480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|||
|
|
import {
|
|||
|
|
Box,
|
|||
|
|
Typography,
|
|||
|
|
Paper,
|
|||
|
|
Table,
|
|||
|
|
TableBody,
|
|||
|
|
TableCell,
|
|||
|
|
TableContainer,
|
|||
|
|
TableHead,
|
|||
|
|
TableRow,
|
|||
|
|
Chip,
|
|||
|
|
IconButton,
|
|||
|
|
Button,
|
|||
|
|
TextField,
|
|||
|
|
InputAdornment,
|
|||
|
|
LinearProgress,
|
|||
|
|
Alert,
|
|||
|
|
Dialog,
|
|||
|
|
DialogTitle,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogActions,
|
|||
|
|
Menu,
|
|||
|
|
MenuItem,
|
|||
|
|
Tooltip,
|
|||
|
|
Stack,
|
|||
|
|
Card,
|
|||
|
|
CardContent,
|
|||
|
|
Grid,
|
|||
|
|
Breadcrumbs,
|
|||
|
|
Link,
|
|||
|
|
} from '@mui/material';
|
|||
|
|
import {
|
|||
|
|
Search as SearchIcon,
|
|||
|
|
Upload as UploadIcon,
|
|||
|
|
Delete as DeleteIcon,
|
|||
|
|
Refresh as RefreshIcon,
|
|||
|
|
MoreVert as MoreVertIcon,
|
|||
|
|
InsertDriveFile as FileIcon,
|
|||
|
|
PictureAsPdf as PdfIcon,
|
|||
|
|
Description as DocIcon,
|
|||
|
|
Image as ImageIcon,
|
|||
|
|
VideoFile as VideoIcon,
|
|||
|
|
AudioFile as AudioIcon,
|
|||
|
|
CloudUpload as CloudUploadIcon,
|
|||
|
|
Settings as SettingsIcon,
|
|||
|
|
} from '@mui/icons-material';
|
|||
|
|
import knowledgeService from '@/services/knowledge_service';
|
|||
|
|
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
|
|||
|
|
import { RUNNING_STATUS_KEYS, type RunningStatus } from '@/constants/knowledge';
|
|||
|
|
|
|||
|
|
// 文件类型图标映射
|
|||
|
|
const getFileIcon = (type: string) => {
|
|||
|
|
const lowerType = type.toLowerCase();
|
|||
|
|
if (lowerType.includes('pdf')) return <PdfIcon />;
|
|||
|
|
if (lowerType.includes('doc') || lowerType.includes('txt') || lowerType.includes('md')) return <DocIcon />;
|
|||
|
|
if (lowerType.includes('jpg') || lowerType.includes('png') || lowerType.includes('jpeg')) return <ImageIcon />;
|
|||
|
|
if (lowerType.includes('mp4') || lowerType.includes('avi') || lowerType.includes('mov')) return <VideoIcon />;
|
|||
|
|
if (lowerType.includes('mp3') || lowerType.includes('wav') || lowerType.includes('m4a')) return <AudioIcon />;
|
|||
|
|
return <FileIcon />;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 文件大小格式化
|
|||
|
|
const formatFileSize = (bytes: number): string => {
|
|||
|
|
if (bytes === 0) return '0 B';
|
|||
|
|
const k = 1024;
|
|||
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 解析状态映射
|
|||
|
|
const getStatusChip = (status: string, progress: number) => {
|
|||
|
|
switch (status) {
|
|||
|
|
case '1':
|
|||
|
|
return <Chip label="已启用" color="success" size="small" />;
|
|||
|
|
case '0':
|
|||
|
|
return <Chip label="已禁用" color="default" size="small" />;
|
|||
|
|
default:
|
|||
|
|
return <Chip label="未知" color="warning" size="small" />;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 运行状态映射
|
|||
|
|
const getRunStatusChip = (run: RunningStatus, progress: number) => {
|
|||
|
|
switch (run) {
|
|||
|
|
case RUNNING_STATUS_KEYS.UNSTART:
|
|||
|
|
return <Chip label="未开始" color="default" size="small" />;
|
|||
|
|
case RUNNING_STATUS_KEYS.RUNNING:
|
|||
|
|
return (
|
|||
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|||
|
|
<Chip label="解析中" color="primary" size="small" />
|
|||
|
|
<Box sx={{ width: 60 }}>
|
|||
|
|
<LinearProgress variant="determinate" value={progress} />
|
|||
|
|
</Box>
|
|||
|
|
<Typography variant="caption">{progress}%</Typography>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
case RUNNING_STATUS_KEYS.CANCEL:
|
|||
|
|
return <Chip label="已取消" color="warning" size="small" />;
|
|||
|
|
case RUNNING_STATUS_KEYS.DONE:
|
|||
|
|
return <Chip label="已完成" color="success" size="small" />;
|
|||
|
|
case RUNNING_STATUS_KEYS.FAIL:
|
|||
|
|
return <Chip label="失败" color="error" size="small" />;
|
|||
|
|
default:
|
|||
|
|
return <Chip label="未知" color="default" size="small" />;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function KnowledgeBaseDetail() {
|
|||
|
|
const { id } = useParams<{ id: string }>();
|
|||
|
|
const navigate = useNavigate();
|
|||
|
|
|
|||
|
|
// 状态管理
|
|||
|
|
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
|
|||
|
|
const [files, setFiles] = useState<IKnowledgeFile[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [filesLoading, setFilesLoading] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [searchKeyword, setSearchKeyword] = useState('');
|
|||
|
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
|||
|
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
|||
|
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
|||
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|||
|
|
|
|||
|
|
// 获取知识库详情
|
|||
|
|
const fetchKnowledgeDetail = async () => {
|
|||
|
|
if (!id) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
const response = await knowledgeService.getKnowledgeDetail({ kb_id: id });
|
|||
|
|
|
|||
|
|
if (response.data.code === 0) {
|
|||
|
|
setKnowledgeBase(response.data.data);
|
|||
|
|
} else {
|
|||
|
|
setError(response.data.message || '获取知识库详情失败');
|
|||
|
|
}
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.message || err.message || '获取知识库详情失败');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取文件列表
|
|||
|
|
const fetchFileList = async () => {
|
|||
|
|
if (!id) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setFilesLoading(true);
|
|||
|
|
// const response = await knowledgeService.getDocumentList(
|
|||
|
|
// { kb_id: id },
|
|||
|
|
// { keywords: searchKeyword }
|
|||
|
|
// );
|
|||
|
|
|
|||
|
|
// if (response.data.code === 0) {
|
|||
|
|
// setFiles(response.data.data.docs || []);
|
|||
|
|
// } else {
|
|||
|
|
// setError(response.data.message || '获取文件列表失败');
|
|||
|
|
// }
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.message || err.message || '获取文件列表失败');
|
|||
|
|
} finally {
|
|||
|
|
setFilesLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 删除文件
|
|||
|
|
const handleDeleteFiles = async () => {
|
|||
|
|
if (selectedFiles.length === 0) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await knowledgeService.removeDocument({ doc_ids: selectedFiles });
|
|||
|
|
setSelectedFiles([]);
|
|||
|
|
setDeleteDialogOpen(false);
|
|||
|
|
fetchFileList(); // 刷新列表
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.message || err.message || '删除文件失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 重新解析文件
|
|||
|
|
const handleReparse = async (docIds: string[]) => {
|
|||
|
|
try {
|
|||
|
|
await knowledgeService.runDocument({ doc_ids: docIds });
|
|||
|
|
fetchFileList(); // 刷新列表
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.message || err.message || '重新解析失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始化数据
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchKnowledgeDetail();
|
|||
|
|
// fetchFileList();
|
|||
|
|
}, [id]);
|
|||
|
|
|
|||
|
|
// 搜索文件
|
|||
|
|
useEffect(() => {
|
|||
|
|
const timer = setTimeout(() => {
|
|||
|
|
fetchFileList();
|
|||
|
|
}, 500);
|
|||
|
|
|
|||
|
|
return () => clearTimeout(timer);
|
|||
|
|
}, [searchKeyword]);
|
|||
|
|
|
|||
|
|
// 过滤文件
|
|||
|
|
const filteredFiles = files.filter(file =>
|
|||
|
|
file.name.toLowerCase().includes(searchKeyword.toLowerCase())
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<Box sx={{ p: 3 }}>
|
|||
|
|
<LinearProgress />
|
|||
|
|
<Typography sx={{ mt: 2 }}>加载中...</Typography>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<Box sx={{ p: 3 }}>
|
|||
|
|
<Alert severity="error">{error}</Alert>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!knowledgeBase) {
|
|||
|
|
return (
|
|||
|
|
<Box sx={{ p: 3 }}>
|
|||
|
|
<Alert severity="warning">知识库不存在</Alert>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Box sx={{ p: 3 }}>
|
|||
|
|
{/* 面包屑导航 */}
|
|||
|
|
<Breadcrumbs sx={{ mb: 2 }}>
|
|||
|
|
<Link
|
|||
|
|
component="button"
|
|||
|
|
variant="body1"
|
|||
|
|
onClick={() => navigate('/knowledge')}
|
|||
|
|
sx={{ textDecoration: 'none' }}
|
|||
|
|
>
|
|||
|
|
知识库
|
|||
|
|
</Link>
|
|||
|
|
<Typography color="text.primary">{knowledgeBase.name}</Typography>
|
|||
|
|
</Breadcrumbs>
|
|||
|
|
|
|||
|
|
{/* 知识库信息卡片 */}
|
|||
|
|
<Card sx={{ mb: 3 }}>
|
|||
|
|
<CardContent>
|
|||
|
|
<Grid container spacing={3}>
|
|||
|
|
<Grid size={{xs:12,md:8}}>
|
|||
|
|
<Typography variant="h4" gutterBottom>
|
|||
|
|
{knowledgeBase.name}
|
|||
|
|
</Typography>
|
|||
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
|||
|
|
{knowledgeBase.description || '暂无描述'}
|
|||
|
|
</Typography>
|
|||
|
|
<Stack direction="row" spacing={2}>
|
|||
|
|
<Chip label={`${knowledgeBase.doc_num} 个文件`} variant="outlined" />
|
|||
|
|
<Chip label={`${knowledgeBase.chunk_num} 个分块`} variant="outlined" />
|
|||
|
|
<Chip label={`${knowledgeBase.token_num} 个令牌`} variant="outlined" />
|
|||
|
|
</Stack>
|
|||
|
|
</Grid>
|
|||
|
|
<Grid size={{xs:12,md:4}}>
|
|||
|
|
<Stack spacing={1} alignItems="flex-end">
|
|||
|
|
<Typography variant="body2" color="text.secondary">
|
|||
|
|
创建时间: {knowledgeBase.create_date}
|
|||
|
|
</Typography>
|
|||
|
|
<Typography variant="body2" color="text.secondary">
|
|||
|
|
更新时间: {knowledgeBase.update_date}
|
|||
|
|
</Typography>
|
|||
|
|
<Typography variant="body2" color="text.secondary">
|
|||
|
|
语言: {knowledgeBase.language}
|
|||
|
|
</Typography>
|
|||
|
|
</Stack>
|
|||
|
|
</Grid>
|
|||
|
|
</Grid>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* 文件操作栏 */}
|
|||
|
|
<Paper sx={{ p: 2, mb: 2 }}>
|
|||
|
|
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
|||
|
|
<TextField
|
|||
|
|
placeholder="搜索文件..."
|
|||
|
|
value={searchKeyword}
|
|||
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|||
|
|
InputProps={{
|
|||
|
|
startAdornment: (
|
|||
|
|
<InputAdornment position="start">
|
|||
|
|
<SearchIcon />
|
|||
|
|
</InputAdornment>
|
|||
|
|
),
|
|||
|
|
}}
|
|||
|
|
sx={{ minWidth: 300 }}
|
|||
|
|
size="small"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Stack direction="row" spacing={1}>
|
|||
|
|
<Button
|
|||
|
|
variant="contained"
|
|||
|
|
startIcon={<UploadIcon />}
|
|||
|
|
onClick={() => setUploadDialogOpen(true)}
|
|||
|
|
>
|
|||
|
|
上传文件
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
{selectedFiles.length > 0 && (
|
|||
|
|
<>
|
|||
|
|
<Button
|
|||
|
|
variant="outlined"
|
|||
|
|
startIcon={<RefreshIcon />}
|
|||
|
|
onClick={() => handleReparse(selectedFiles)}
|
|||
|
|
>
|
|||
|
|
重新解析
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant="outlined"
|
|||
|
|
color="error"
|
|||
|
|
startIcon={<DeleteIcon />}
|
|||
|
|
onClick={() => setDeleteDialogOpen(true)}
|
|||
|
|
>
|
|||
|
|
删除 ({selectedFiles.length})
|
|||
|
|
</Button>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<IconButton onClick={() => fetchFileList()}>
|
|||
|
|
<RefreshIcon />
|
|||
|
|
</IconButton>
|
|||
|
|
</Stack>
|
|||
|
|
</Stack>
|
|||
|
|
</Paper>
|
|||
|
|
|
|||
|
|
{/* 文件列表 */}
|
|||
|
|
<TableContainer component={Paper}>
|
|||
|
|
<Table>
|
|||
|
|
<TableHead>
|
|||
|
|
<TableRow>
|
|||
|
|
<TableCell padding="checkbox">
|
|||
|
|
{/* 全选复选框可以在这里添加 */}
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell>文件名</TableCell>
|
|||
|
|
<TableCell>类型</TableCell>
|
|||
|
|
<TableCell>大小</TableCell>
|
|||
|
|
<TableCell>分块数</TableCell>
|
|||
|
|
<TableCell>状态</TableCell>
|
|||
|
|
<TableCell>解析状态</TableCell>
|
|||
|
|
<TableCell>上传时间</TableCell>
|
|||
|
|
<TableCell>操作</TableCell>
|
|||
|
|
</TableRow>
|
|||
|
|
</TableHead>
|
|||
|
|
<TableBody>
|
|||
|
|
{filesLoading ? (
|
|||
|
|
<TableRow>
|
|||
|
|
<TableCell colSpan={9} align="center">
|
|||
|
|
<LinearProgress />
|
|||
|
|
<Typography sx={{ mt: 1 }}>加载文件列表...</Typography>
|
|||
|
|
</TableCell>
|
|||
|
|
</TableRow>
|
|||
|
|
) : filteredFiles.length === 0 ? (
|
|||
|
|
<TableRow>
|
|||
|
|
<TableCell colSpan={9} align="center">
|
|||
|
|
<Typography color="text.secondary">
|
|||
|
|
{searchKeyword ? '没有找到匹配的文件' : '暂无文件'}
|
|||
|
|
</Typography>
|
|||
|
|
</TableCell>
|
|||
|
|
</TableRow>
|
|||
|
|
) : (
|
|||
|
|
filteredFiles.map((file) => (
|
|||
|
|
<TableRow key={file.id} hover>
|
|||
|
|
<TableCell padding="checkbox">
|
|||
|
|
{/* 文件选择复选框 */}
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell>
|
|||
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|||
|
|
{getFileIcon(file.type)}
|
|||
|
|
<Typography variant="body2">{file.name}</Typography>
|
|||
|
|
</Box>
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell>
|
|||
|
|
<Chip label={file.type.toUpperCase()} size="small" variant="outlined" />
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell>{formatFileSize(file.size)}</TableCell>
|
|||
|
|
<TableCell>{file.chunk_num}</TableCell>
|
|||
|
|
<TableCell>{getStatusChip(file.status, file.progress)}</TableCell>
|
|||
|
|
<TableCell>{getRunStatusChip(file.run, file.progress)}</TableCell>
|
|||
|
|
<TableCell>{file.create_date}</TableCell>
|
|||
|
|
<TableCell>
|
|||
|
|
<IconButton
|
|||
|
|
size="small"
|
|||
|
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
|||
|
|
>
|
|||
|
|
<MoreVertIcon />
|
|||
|
|
</IconButton>
|
|||
|
|
</TableCell>
|
|||
|
|
</TableRow>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
</TableBody>
|
|||
|
|
</Table>
|
|||
|
|
</TableContainer>
|
|||
|
|
|
|||
|
|
{/* 文件操作菜单 */}
|
|||
|
|
<Menu
|
|||
|
|
anchorEl={anchorEl}
|
|||
|
|
open={Boolean(anchorEl)}
|
|||
|
|
onClose={() => setAnchorEl(null)}
|
|||
|
|
>
|
|||
|
|
<MenuItem onClick={() => setAnchorEl(null)}>
|
|||
|
|
<RefreshIcon sx={{ mr: 1 }} />
|
|||
|
|
重新解析
|
|||
|
|
</MenuItem>
|
|||
|
|
<MenuItem onClick={() => setAnchorEl(null)}>
|
|||
|
|
<SettingsIcon sx={{ mr: 1 }} />
|
|||
|
|
解析设置
|
|||
|
|
</MenuItem>
|
|||
|
|
<MenuItem onClick={() => setAnchorEl(null)} sx={{ color: 'error.main' }}>
|
|||
|
|
<DeleteIcon sx={{ mr: 1 }} />
|
|||
|
|
删除
|
|||
|
|
</MenuItem>
|
|||
|
|
</Menu>
|
|||
|
|
|
|||
|
|
{/* 上传文件对话框 */}
|
|||
|
|
<Dialog open={uploadDialogOpen} onClose={() => setUploadDialogOpen(false)} maxWidth="sm" fullWidth>
|
|||
|
|
<DialogTitle>上传文件</DialogTitle>
|
|||
|
|
<DialogContent>
|
|||
|
|
<Box
|
|||
|
|
sx={{
|
|||
|
|
border: '2px dashed #ccc',
|
|||
|
|
borderRadius: 2,
|
|||
|
|
p: 4,
|
|||
|
|
textAlign: 'center',
|
|||
|
|
cursor: 'pointer',
|
|||
|
|
'&:hover': {
|
|||
|
|
borderColor: 'primary.main',
|
|||
|
|
backgroundColor: 'action.hover',
|
|||
|
|
},
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
|||
|
|
<Typography variant="h6" gutterBottom>
|
|||
|
|
拖拽文件到此处或点击上传
|
|||
|
|
</Typography>
|
|||
|
|
<Typography variant="body2" color="text.secondary">
|
|||
|
|
支持 PDF, DOCX, TXT, MD, PNG, JPG, MP4, WAV 等格式
|
|||
|
|
</Typography>
|
|||
|
|
</Box>
|
|||
|
|
</DialogContent>
|
|||
|
|
<DialogActions>
|
|||
|
|
<Button onClick={() => setUploadDialogOpen(false)}>取消</Button>
|
|||
|
|
<Button variant="contained">上传</Button>
|
|||
|
|
</DialogActions>
|
|||
|
|
</Dialog>
|
|||
|
|
|
|||
|
|
{/* 删除确认对话框 */}
|
|||
|
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
|||
|
|
<DialogTitle>确认删除</DialogTitle>
|
|||
|
|
<DialogContent>
|
|||
|
|
<Typography>
|
|||
|
|
确定要删除选中的 {selectedFiles.length} 个文件吗?此操作不可撤销。
|
|||
|
|
</Typography>
|
|||
|
|
</DialogContent>
|
|||
|
|
<DialogActions>
|
|||
|
|
<Button onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
|||
|
|
<Button color="error" onClick={handleDeleteFiles}>删除</Button>
|
|||
|
|
</DialogActions>
|
|||
|
|
</Dialog>
|
|||
|
|
</Box>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default KnowledgeBaseDetail;
|