feat(knowledge): add knowledge base management with dialog system

- Implement knowledge base list, create, and detail pages
- Add dialog provider and components for confirmation and alerts
- Include knowledge card and grid view components
- Enhance header with user menu and logout functionality
- Implement knowledge operations hooks for CRUD operations
This commit is contained in:
2025-10-13 12:26:10 +08:00
parent d475a0e982
commit 5c937df5ed
18 changed files with 2151 additions and 184 deletions

View File

@@ -0,0 +1,480 @@
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;