feat(knowledge): add knowledge base detail page components and hooks

refactor(knowledge): restructure knowledge detail page with new components
feat(components): add FileUploadDialog for file upload functionality
feat(hooks): implement document management hooks for CRUD operations
This commit is contained in:
2025-10-14 15:42:40 +08:00
parent 34181cf025
commit 7384ae36d0
12 changed files with 1456 additions and 356 deletions

View File

@@ -3,110 +3,26 @@ 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,
Button,
TextField,
Breadcrumbs,
Link,
Stack,
} 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 { type GridRowSelectionModel } from '@mui/x-data-grid';
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" />;
}
};
import FileUploadDialog from '@/components/FileUploadDialog';
import KnowledgeInfoCard from './components/KnowledgeInfoCard';
import FileListComponent from './components/FileListComponent';
import FloatingActionButtons from './components/FloatingActionButtons';
import { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks';
function KnowledgeBaseDetail() {
const { id } = useParams<{ id: string }>();
@@ -114,15 +30,34 @@ function KnowledgeBaseDetail() {
// 状态管理
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 [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>({
type: 'include',
ids: new Set()
});
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [testingDialogOpen, setTestingDialogOpen] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
// 使用新的document hooks
const {
documents: files,
loading: filesLoading,
error: filesError,
refresh: refreshFiles,
setKeywords,
} = useDocumentList(id || '');
const {
uploadDocuments,
deleteDocuments,
runDocuments,
loading: operationLoading,
error: operationError,
} = useDocumentOperations();
// 获取知识库详情
const fetchKnowledgeDetail = async () => {
@@ -144,48 +79,38 @@ function KnowledgeBaseDetail() {
}
};
// 获取文件列表
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 || '获取文件列表失败');
// }
// 删除文件
const handleDeleteFiles = async () => {
try {
await deleteDocuments(Array.from(rowSelectionModel.ids) as string[]);
setDeleteDialogOpen(false);
setRowSelectionModel({
type: 'include',
ids: new Set()
});
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '获取文件列表失败');
} finally {
setFilesLoading(false);
setError(err.response?.data?.message || err.message || '删除文件失败');
}
};
// 删除文件
const handleDeleteFiles = async () => {
if (selectedFiles.length === 0) return;
// 上传文件处理
const handleUploadFiles = async (uploadFiles: File[]) => {
console.log('上传文件:', uploadFiles);
const kb_id = knowledgeBase?.id || '';
try {
await knowledgeService.removeDocument({ doc_ids: selectedFiles });
setSelectedFiles([]);
setDeleteDialogOpen(false);
fetchFileList(); // 刷新列表
await uploadDocuments(kb_id, uploadFiles);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '删除文件失败');
throw new Error(err.response?.data?.message || err.message || '上传文件失败');
}
};
// 重新解析文件
const handleReparse = async (docIds: string[]) => {
try {
await knowledgeService.runDocument({ doc_ids: docIds });
fetchFileList(); // 刷新列表
await runDocuments(docIds);
refreshFiles(); // 刷新列表
} catch (err: any) {
setError(err.response?.data?.message || err.message || '重新解析失败');
}
@@ -194,22 +119,19 @@ function KnowledgeBaseDetail() {
// 初始化数据
useEffect(() => {
fetchKnowledgeDetail();
// fetchFileList();
}, [id]);
// 搜索文件
useEffect(() => {
const timer = setTimeout(() => {
fetchFileList();
setKeywords(searchKeyword);
}, 500);
return () => clearTimeout(timer);
}, [searchKeyword]);
// 过滤文件
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchKeyword.toLowerCase())
);
return () => clearTimeout(timer);
}, [searchKeyword, setKeywords]);
// 合并错误状态
const combinedError = error || filesError || operationError;
if (loading) {
return (
@@ -220,10 +142,10 @@ function KnowledgeBaseDetail() {
);
}
if (error) {
if (combinedError) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">{error}</Alert>
<Alert severity="error">{combinedError}</Alert>
</Box>
);
}
@@ -252,220 +174,59 @@ function KnowledgeBaseDetail() {
</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>
<KnowledgeInfoCard knowledgeBase={knowledgeBase} />
{/* 文件操作栏 */}
<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"
/>
{/* 文件列表组件 */}
<FileListComponent
files={files}
loading={filesLoading}
searchKeyword={searchKeyword}
onSearchChange={setSearchKeyword}
onReparse={(fileIds) => handleReparse(fileIds)}
onDelete={(fileIds) => {
console.log('删除文件:', fileIds);
<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>
setRowSelectionModel({
type: 'include',
ids: new Set(fileIds)
});
setDeleteDialogOpen(true);
}}
onUpload={() => setUploadDialogOpen(true)}
onRefresh={() => {
refreshFiles();
fetchKnowledgeDetail();
}}
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newModel) => {
console.log('新的选择模型:', newModel);
setRowSelectionModel(newModel);
}}
/>
{/* 文件列表 */}
<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>
{/* 浮动操作按钮 */}
<FloatingActionButtons
onTestClick={() => setTestingDialogOpen(true)}
onConfigClick={() => setConfigDialogOpen(true)}
/>
{/* 上传文件对话框 */}
<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>
<FileUploadDialog
open={uploadDialogOpen}
onClose={() => setUploadDialogOpen(false)}
onUpload={handleUploadFiles}
title="上传文件到知识库"
acceptedFileTypes={['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav']}
maxFileSize={100}
maxFiles={10}
/>
{/* 删除确认对话框 */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography>
{selectedFiles.length}
{rowSelectionModel.ids.size}
</Typography>
</DialogContent>
<DialogActions>
@@ -473,6 +234,88 @@ function KnowledgeBaseDetail() {
<Button color="error" onClick={handleDeleteFiles}></Button>
</DialogActions>
</Dialog>
{/* 检索测试对话框 */}
<Dialog open={testingDialogOpen} onClose={() => setTestingDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
<TextField
fullWidth
label="测试查询"
placeholder="输入要测试的查询内容..."
multiline
rows={3}
/>
<Stack direction="row" spacing={2}>
<TextField
label="返回结果数量"
type="number"
defaultValue={5}
sx={{ width: 150 }}
/>
<TextField
label="相似度阈值"
type="number"
defaultValue={0.7}
inputProps={{ min: 0, max: 1, step: 0.1 }}
sx={{ width: 150 }}
/>
</Stack>
<Box sx={{ minHeight: 200, border: '1px solid #e0e0e0', borderRadius: 1, p: 2 }}>
<Typography variant="body2" color="text.secondary">
...
</Typography>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setTestingDialogOpen(false)}></Button>
<Button variant="contained"></Button>
</DialogActions>
</Dialog>
{/* 配置设置对话框 */}
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={3} sx={{ mt: 1 }}>
<TextField
fullWidth
label="知识库名称"
defaultValue={knowledgeBase?.name}
/>
<TextField
fullWidth
label="描述"
multiline
rows={3}
defaultValue={knowledgeBase?.description}
/>
<TextField
fullWidth
label="语言"
defaultValue={knowledgeBase?.language}
/>
<TextField
fullWidth
label="嵌入模型"
defaultValue={knowledgeBase?.embd_id}
disabled
/>
<TextField
fullWidth
label="解析器"
defaultValue={knowledgeBase?.parser_id}
disabled
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfigDialogOpen(false)}></Button>
<Button variant="contained"></Button>
</DialogActions>
</Dialog>
</Box>
);
}