feat(knowledge): add chunk management and document processing features

This commit is contained in:
2025-10-16 16:23:53 +08:00
parent 4f956e79ba
commit 5a0a9ef2a1
17 changed files with 1655 additions and 366 deletions

View File

@@ -0,0 +1,684 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Typography,
Paper,
Chip,
IconButton,
TextField,
InputAdornment,
Menu,
MenuItem,
Tooltip,
Button,
Stack,
FormControl,
InputLabel,
Select,
OutlinedInput,
type SelectChangeEvent,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
LinearProgress,
CircularProgress,
} from '@mui/material';
import {
Search as SearchIcon,
FileUpload as FileUploadIcon,
Delete as DeleteIcon,
PlayArrow as PlayIcon,
Stop as StopIcon,
Edit as EditIcon,
Visibility as ViewIcon,
CheckCircle as EnableIcon,
Cancel as DisableIcon,
Clear as ClearIcon,
MoreVert as MoreVertIcon,
PictureAsPdf as PdfIcon,
Description as DocIcon,
Image as ImageIcon,
VideoFile as VideoIcon,
AudioFile as AudioIcon,
Refresh as RefreshIcon,
InsertDriveFile as FileIcon,
Upload as UploadIcon,
BugReportOutlined as ProcessIcon,
} from '@mui/icons-material';
import { DataGrid, type GridColDef, type GridRowSelectionModel } from '@mui/x-data-grid';
import { zhCN, enUS } from '@mui/x-data-grid/locales';
import type { IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IDocumentInfoFilter } from '@/interfaces/database/document';
import { RUNNING_STATUS_KEYS, type RunningStatus } from '@/constants/knowledge';
import { LanguageAbbreviation } from '@/locales';
import dayjs from 'dayjs';
interface DocumentListComponentProps {
files: IKnowledgeFile[];
loading: boolean;
searchKeyword: string;
onSearchChange: (keyword: string) => void;
onReparse: (fileIds: string[]) => void;
onDelete: (fileIds: string[]) => void;
onUpload: () => void;
onRefresh: () => void;
rowSelectionModel: GridRowSelectionModel;
onRowSelectionModelChange: (model: GridRowSelectionModel) => void;
// 分页相关props
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
// 筛选器相关props
documentFilter?: IDocumentInfoFilter;
onFetchFilter?: () => Promise<IDocumentInfoFilter | undefined>;
// 新增操作功能props
onRename: (fileId: string, newName: string) => void;
onChangeStatus: (fileIds: string[], status: string) => void;
onCancelRun: (fileIds: string[]) => void;
onViewDetails?: (file: IKnowledgeFile) => void;
onViewProcessDetails?: (file: IKnowledgeFile) => void;
}
const getRunStatusLabel = (status: string) => {
const statusLabels = {
[RUNNING_STATUS_KEYS.UNSTART]: '未开始',
[RUNNING_STATUS_KEYS.RUNNING]: '运行中',
[RUNNING_STATUS_KEYS.CANCEL]: '已取消',
[RUNNING_STATUS_KEYS.DONE]: '完成',
[RUNNING_STATUS_KEYS.FAIL]: '失败',
};
return statusLabels[status as keyof typeof statusLabels] || '未知';
};
const getFileIcon = (type: string, suffix?: string) => {
const fileTypeArr = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'gif', 'mp4', 'avi', 'mov', 'mp3', 'wav'];
const index = fileTypeArr.indexOf(type.toLowerCase());
if (index === -1) {
// type 找不到就用 suffix
const suffixIndex = fileTypeArr.indexOf(suffix?.toLowerCase() || '');
if (suffixIndex !== -1) {
return getFileIcon(fileTypeArr[suffixIndex]);
}
return <FileIcon />;
}
switch (type.toLowerCase()) {
case 'pdf': return <PdfIcon color="error" />;
case 'doc':
case 'docx': return <DocIcon color="primary" />;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif': return <ImageIcon color="success" />;
case 'mp4':
case 'avi':
case 'mov': return <VideoIcon color="secondary" />;
case 'mp3':
case 'wav': return <AudioIcon color="warning" />;
default: return <FileIcon />;
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
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) => {
return <Chip label={status === '1' ? '启用' : '禁用'}
color={status === '1' ? 'success' : 'error'}
size="small" />;
};
const getRunStatusChip = (run: RunningStatus, progress: number) => {
const statusConfig = {
[RUNNING_STATUS_KEYS.UNSTART]: { label: '未开始', color: 'default' as const },
[RUNNING_STATUS_KEYS.RUNNING]: { label: `解析中`, color: 'info' as const },
[RUNNING_STATUS_KEYS.CANCEL]: { label: '已取消', color: 'warning' as const },
[RUNNING_STATUS_KEYS.DONE]: { label: '完成', color: 'success' as const },
[RUNNING_STATUS_KEYS.FAIL]: { label: '失败', color: 'error' as const },
};
const config = statusConfig[run] || { label: '未知', color: 'default' as const };
if (run === RUNNING_STATUS_KEYS.RUNNING) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Box sx={{ minWidth: 80 }}>
<Typography variant="caption" color="text.secondary">
{progress}%
</Typography>
<LinearProgress variant="determinate" value={progress} sx={{ height: 4, borderRadius: 2 }} />
</Box>
</Box>
);
}
return <Chip label={config.label} color={config.color} size="small" />;
};
const DocumentListComponent: React.FC<DocumentListComponentProps> = ({
files,
loading,
searchKeyword,
onSearchChange,
onReparse,
onDelete,
onUpload,
onRefresh,
rowSelectionModel,
onRowSelectionModelChange,
total,
page,
pageSize,
onPageChange,
onPageSizeChange,
documentFilter,
onFetchFilter,
onRename,
onChangeStatus,
onCancelRun,
onViewDetails,
onViewProcessDetails
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// 菜单出现的时候选中的 file; selectedFileId 会清空, ref用于上一次的保存状态
const selectedFileIdRef = useRef<string>('');
const selectedFileRef = useRef<IKnowledgeFile | undefined>(undefined);
const [selectedFileId, setSelectedFileId] = useState<string>('');
const [selectedFile, setSelectedFile] = useState<IKnowledgeFile | undefined>(undefined);
const [filter, setFilter] = useState<IDocumentInfoFilter | undefined>(documentFilter);
const [selectedRunStatus, setSelectedRunStatus] = useState<string[]>([]);
const [selectedSuffix, setSelectedSuffix] = useState<string[]>([]);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [newFileName, setNewFileName] = useState('');
const { i18n } = useTranslation();
// 根据当前语言获取DataGrid的localeText
const getDataGridLocale = () => {
const currentLanguage = i18n.language;
return currentLanguage === LanguageAbbreviation.Zh ? zhCN : enUS;
};
// 获取筛选器数据
useEffect(() => {
const fetchFilter = async () => {
try {
const filterData = await onFetchFilter?.();
setFilter(filterData);
} catch (error) {
console.error('Failed to fetch document filter:', error);
}
};
if (!filter) {
fetchFilter();
}
}, [onFetchFilter, filter]);
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, file: IKnowledgeFile) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
setSelectedFileId(file.id);
setSelectedFile(file);
selectedFileIdRef.current = file.id;
selectedFileRef.current = file;
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedFileId('');
setSelectedFile(undefined);
};
const handleReparse = () => {
if (selectedFileId) {
onReparse([selectedFileId]);
}
handleMenuClose();
};
const handleDelete = () => {
if (selectedFileId) {
onDelete([selectedFileId]);
}
handleMenuClose();
};
const handleRename = () => {
if (selectedFileRef.current) {
setNewFileName(selectedFileRef.current.name);
setRenameDialogOpen(true);
}
handleMenuClose();
};
const handleRenameConfirm = () => {
console.log('selectedFileId', selectedFileIdRef.current);
console.log('newFileName', newFileName);
if (selectedFileIdRef.current && newFileName.trim()) {
onRename(selectedFileIdRef.current, newFileName.trim());
setRenameDialogOpen(false);
setNewFileName('');
}
};
const handleChangeStatus = (status: string) => {
if (selectedFileId) {
onChangeStatus([selectedFileId], status);
}
handleMenuClose();
};
const handleCancelRun = () => {
if (selectedFileId) {
onCancelRun([selectedFileId]);
}
handleMenuClose();
};
const handleViewProcessDetails = (file?: IKnowledgeFile) => {
if (file && onViewProcessDetails) {
onViewProcessDetails?.(file);
}
handleMenuClose();
};
const handleViewDetails = (file?: IKnowledgeFile) => {
if (file && onViewDetails) {
onViewDetails?.(file);
}
handleMenuClose();
};
const handleRunStatusChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
setSelectedRunStatus(typeof value === 'string' ? value.split(',') : value);
};
const handleSuffixChange = (event: SelectChangeEvent<string[]>) => {
const value = event.target.value;
setSelectedSuffix(typeof value === 'string' ? value.split(',') : value);
};
// 处理分页变化
const handlePaginationModelChange = (model: { page: number; pageSize: number }) => {
if (model.page !== page - 1) { // DataGrid的page是0-based我们的是1-based
onPageChange(model.page + 1);
}
if (model.pageSize !== pageSize) {
onPageSizeChange(model.pageSize);
}
};
// DataGrid 列定义
const columns: GridColDef[] = [
{
field: 'name',
headerName: '文件名',
flex: 2,
minWidth: 200,
cellClassName: 'grid-center-cell',
renderCell: (params) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
":hover": { color: 'primary.main', fontWeight: 'bold' }
}}
onClick={(e) => {
e.stopPropagation();
console.log('查看详情:', params.row);
if (onViewDetails) {
onViewDetails(params.row);
}
}}
>
<Tooltip title={`查看 ${params.value} 的详情`}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getFileIcon(params.row.type, params.row.suffix)}
<Typography variant="body2" noWrap>{params.value}</Typography>
</Box>
</Tooltip>
</Box>
),
},
{
field: 'type',
headerName: '类型',
flex: 0.5,
minWidth: 80,
renderCell: (params) => (
<Chip label={params.value.toUpperCase()} size="small" variant="outlined" />
),
},
{
field: 'size',
headerName: '大小',
flex: 0.5,
minWidth: 80,
renderCell: (params) => formatFileSize(params.value),
},
{
field: 'chunk_num',
headerName: '分块数',
flex: 0.5,
minWidth: 80,
type: 'number',
},
{
field: 'status',
headerName: '状态',
flex: 0.8,
minWidth: 100,
renderCell: (params) => getStatusChip(params.value),
},
{
field: 'run',
headerName: '解析状态',
flex: 0.8,
minWidth: 100,
renderCell: (params) => getRunStatusChip(params.value, params.row.progress),
},
{
field: 'create_time',
headerName: '上传时间',
flex: 1,
minWidth: 140,
valueFormatter: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
field: 'actions',
headerName: '操作',
flex: 1.2,
minWidth: 200,
sortable: false,
cellClassName: 'grid-center-cell',
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 1,
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main'
}
}}
onClick={(e) => {
e.stopPropagation();
console.log('查看详情:', params.row);
handleViewDetails(params.row);
}}
>
<ViewIcon fontSize="small" />
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 1,
'&:hover': {
backgroundColor: 'action.hover',
color: 'primary.main'
}
}}
onClick={(e) => {
e.stopPropagation();
console.log('查看解析详情:', params.row);
handleViewProcessDetails(params.row);
}}
>
<ProcessIcon fontSize="small" />
</Box>
<Tooltip title="更多操作">
<IconButton
size="small"
onClick={(e) => handleMenuClick(e, params.row)}
>
<MoreVertIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
),
},
];
return (
<Box>
{/* 筛选器 */}
{filter && (
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" sx={{ mb: 2 }}></Typography>
<Stack direction="row" spacing={2} alignItems="center">
{/* 运行状态筛选 */}
<FormControl sx={{ minWidth: 200 }}>
<InputLabel></InputLabel>
<Select
multiple
value={selectedRunStatus}
onChange={handleRunStatusChange}
input={<OutlinedInput label="运行状态" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={getRunStatusLabel(value)} size="small" />
))}
</Box>
)}
>
{Object.entries(filter.run_status).map(([status, count]) => (
<MenuItem key={status} value={status}>
{getRunStatusLabel(status)} ({count})
</MenuItem>
))}
</Select>
</FormControl>
{/* 文件类型筛选 */}
<FormControl sx={{ minWidth: 200 }}>
<InputLabel></InputLabel>
<Select
multiple
value={selectedSuffix}
onChange={handleSuffixChange}
input={<OutlinedInput label="文件类型" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip key={value} label={value.toUpperCase()} size="small" />
))}
</Box>
)}
>
{Object.entries(filter.suffix).map(([suffix, count]) => (
<MenuItem key={suffix} value={suffix}>
{suffix.toUpperCase()} ({count})
</MenuItem>
))}
</Select>
</FormControl>
<Button variant="outlined" onClick={() => {
setSelectedRunStatus([]);
setSelectedSuffix([]);
}}>
</Button>
</Stack>
</Paper>
)}
{/* 文件操作栏 */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
<TextField
placeholder="搜索文件..."
value={searchKeyword}
onChange={(e) => onSearchChange(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={onUpload}
>
</Button>
{rowSelectionModel.ids.size > 0 && (
<>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => onReparse(Array.from(rowSelectionModel.ids).map((id) => id.toString()))}
>
({rowSelectionModel.ids.size})
</Button>
<Button
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={() => onDelete(Array.from(rowSelectionModel.ids).map((id) => id.toString()))}
>
({rowSelectionModel.ids.size})
</Button>
</>
)}
<IconButton onClick={onRefresh}>
<RefreshIcon />
</IconButton>
</Stack>
</Stack>
</Paper>
{/* 文件列表 */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={files}
columns={columns}
loading={loading}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={onRowSelectionModelChange}
pageSizeOptions={[10, 25, 50, 100]}
paginationMode="server"
rowCount={total}
paginationModel={{
page: page - 1,
pageSize: pageSize,
}}
onPaginationModelChange={handlePaginationModelChange}
localeText={getDataGridLocale().components.MuiDataGrid.defaultProps.localeText}
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
'& .MuiDataGrid-row:hover': {
backgroundColor: 'action.hover',
},
}}
/>
</Paper>
{/* 右键菜单 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={() => handleViewDetails(selectedFile)}>
<ViewIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
<MenuItem onClick={handleRename}>
<EditIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
<MenuItem onClick={handleReparse}>
<PlayIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
{selectedFile?.run === RUNNING_STATUS_KEYS.RUNNING && (
<MenuItem onClick={handleCancelRun}>
<StopIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
)}
{selectedFile?.status === '1' ? (
<MenuItem onClick={() => handleChangeStatus('0')}>
<DisableIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
) : (
<MenuItem onClick={() => handleChangeStatus('1')}>
<EnableIcon sx={{ mr: 1 }} />
<Typography></Typography>
</MenuItem>
)}
<MenuItem onClick={handleDelete}>
<DeleteIcon sx={{ mr: 1 }} color="error" />
<Typography color="error"></Typography>
</MenuItem>
</Menu>
{/* 重命名对话框 */}
<Dialog open={renameDialogOpen} onClose={() => setRenameDialogOpen(false)}>
<DialogTitle></DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="文件名"
fullWidth
variant="outlined"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRenameDialogOpen(false)}></Button>
<Button onClick={handleRenameConfirm} variant="contained"></Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default DocumentListComponent;

View File

@@ -1,341 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Typography,
Paper,
Chip,
IconButton,
TextField,
InputAdornment,
Menu,
MenuItem,
Tooltip,
Button,
Stack,
} from '@mui/material';
import {
Search as SearchIcon,
MoreVert as MoreVertIcon,
InsertDriveFile as FileIcon,
PictureAsPdf as PdfIcon,
Description as DocIcon,
Image as ImageIcon,
VideoFile as VideoIcon,
AudioFile as AudioIcon,
Upload as UploadIcon,
Refresh as RefreshIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import { DataGrid, type GridColDef, type GridRowSelectionModel } from '@mui/x-data-grid';
import { zhCN, enUS } from '@mui/x-data-grid/locales';
import type { IKnowledgeFile } from '@/interfaces/database/knowledge';
import { RUNNING_STATUS_KEYS, type RunningStatus } from '@/constants/knowledge';
import { LanguageAbbreviation } from '@/locales';
import dayjs from 'dayjs';
interface FileListComponentProps {
files: IKnowledgeFile[];
loading: boolean;
searchKeyword: string;
onSearchChange: (keyword: string) => void;
onReparse: (fileIds: string[]) => void;
onDelete: (fileIds: string[]) => void;
onUpload: () => void;
onRefresh: () => void;
rowSelectionModel: GridRowSelectionModel;
onRowSelectionModelChange: (model: GridRowSelectionModel) => void;
// 分页相关props
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'pdf': return <PdfIcon color="error" />;
case 'doc':
case 'docx': return <DocIcon color="primary" />;
case 'jpg':
case 'jpeg':
case 'png':
case 'gif': return <ImageIcon color="success" />;
case 'mp4':
case 'avi':
case 'mov': return <VideoIcon color="secondary" />;
case 'mp3':
case 'wav': return <AudioIcon color="warning" />;
default: return <FileIcon />;
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
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) => {
return <Chip label={status === '1' ? '启用' : '禁用'}
color={status === '1' ? 'success' : 'error'}
size="small" />;
};
const getRunStatusChip = (run: RunningStatus, progress: number) => {
const statusConfig = {
[RUNNING_STATUS_KEYS.UNSTART]: { label: '未开始', color: 'default' as const },
[RUNNING_STATUS_KEYS.RUNNING]: { label: `解析中 ${progress}%`, color: 'info' as const },
[RUNNING_STATUS_KEYS.CANCEL]: { label: '已取消', color: 'warning' as const },
[RUNNING_STATUS_KEYS.DONE]: { label: '完成', color: 'success' as const },
[RUNNING_STATUS_KEYS.FAIL]: { label: '失败', color: 'error' as const },
};
const config = statusConfig[run] || { label: '未知', color: 'default' as const };
return <Chip label={config.label} color={config.color} size="small" />;
};
const FileListComponent: React.FC<FileListComponentProps> = ({
files,
loading,
searchKeyword,
onSearchChange,
onReparse,
onDelete,
onUpload,
onRefresh,
rowSelectionModel,
onRowSelectionModelChange,
total,
page,
pageSize,
onPageChange,
onPageSizeChange,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedFileId, setSelectedFileId] = useState<string>('');
const { i18n } = useTranslation();
// 根据当前语言获取DataGrid的localeText
const getDataGridLocale = () => {
const currentLanguage = i18n.language;
return currentLanguage === LanguageAbbreviation.Zh ? zhCN : enUS;
};
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, fileId: string) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
setSelectedFileId(fileId);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedFileId('');
};
const handleReparse = () => {
if (selectedFileId) {
onReparse([selectedFileId]);
}
handleMenuClose();
};
const handleDelete = () => {
if (selectedFileId) {
onDelete([selectedFileId]);
}
handleMenuClose();
};
// 处理分页变化
const handlePaginationModelChange = (model: { page: number; pageSize: number }) => {
if (model.page !== page - 1) { // DataGrid的page是0-based我们的是1-based
onPageChange(model.page + 1);
}
if (model.pageSize !== pageSize) {
onPageSizeChange(model.pageSize);
}
};
// DataGrid 列定义
const columns: GridColDef[] = [
{
field: 'name',
headerName: '文件名',
flex: 1,
minWidth: 100,
maxWidth: 300,
cellClassName: 'grid-center-cell',
renderCell: (params) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getFileIcon(params.row.type)}
<Tooltip title={params.value}>
<Typography variant="body2">{params.value}</Typography>
</Tooltip>
</Box>
),
},
{
field: 'type',
headerName: '类型',
width: 100,
renderCell: (params) => (
<Chip label={params.value.toUpperCase()} size="small" variant="outlined" />
),
},
{
field: 'size',
headerName: '大小',
width: 120,
renderCell: (params) => formatFileSize(params.value),
},
{
field: 'chunk_num',
headerName: '分块数',
width: 100,
type: 'number',
},
{
field: 'status',
headerName: '状态',
width: 120,
renderCell: (params) => getStatusChip(params.value),
},
{
field: 'run',
headerName: '解析状态',
width: 120,
renderCell: (params) => getRunStatusChip(params.value, params.row.progress),
},
{
field: 'create_time',
headerName: '上传时间',
width: 160,
valueFormatter: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
field: 'actions',
headerName: '操作',
width: 80,
sortable: false,
renderCell: (params) => (
<IconButton
size="small"
onClick={(e) => handleMenuClick(e, params.row.id)}
>
<MoreVertIcon />
</IconButton>
),
},
];
return (
<Box>
{/* 文件操作栏 */}
<Paper sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
<TextField
placeholder="搜索文件..."
value={searchKeyword}
onChange={(e) => onSearchChange(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={onUpload}
>
</Button>
{rowSelectionModel.ids.size > 0 && (
<>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => onReparse(Array.from(rowSelectionModel.ids).map((id) => id.toString()))}
>
({rowSelectionModel.ids.size})
</Button>
<Button
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={() => onDelete(Array.from(rowSelectionModel.ids).map((id) => id.toString()))}
>
({rowSelectionModel.ids.size})
</Button>
</>
)}
<IconButton onClick={onRefresh}>
<RefreshIcon />
</IconButton>
</Stack>
</Stack>
</Paper>
{/* 文件列表 */}
<Paper sx={{ height: 600, width: '100%' }}>
<DataGrid
rows={files}
columns={columns}
loading={loading}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={onRowSelectionModelChange}
pageSizeOptions={[10, 25, 50, 100]}
paginationMode="server"
rowCount={total}
paginationModel={{
page: page - 1,
pageSize: pageSize,
}}
onPaginationModelChange={handlePaginationModelChange}
localeText={getDataGridLocale().components.MuiDataGrid.defaultProps.localeText}
sx={{
'& .MuiDataGrid-cell:focus': {
outline: 'none',
},
'& .MuiDataGrid-row:hover': {
backgroundColor: 'action.hover',
},
}}
/>
</Paper>
{/* 右边菜单 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleReparse}>
<Typography></Typography>
</MenuItem>
<MenuItem onClick={handleDelete}>
<Typography color="error"></Typography>
</MenuItem>
</Menu>
</Box>
);
};
export default FileListComponent;

View File

@@ -29,7 +29,8 @@ interface TestChunkResultProps {
selectedDocIds: string[];
}
function TestChunkResult({ result, page, pageSize, onDocumentFilter, selectedDocIds }: TestChunkResultProps) {
function TestChunkResult(props: TestChunkResultProps) {
const { result, loading, page, pageSize, onDocumentFilter, selectedDocIds } = props;
if (!result) {
return (
<Paper sx={{ p: 3, textAlign: 'center' }}>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
@@ -14,16 +14,22 @@ import {
Breadcrumbs,
Link,
Stack,
Card,
CardContent,
Divider,
Chip,
} from '@mui/material';
import { type GridRowSelectionModel } from '@mui/x-data-grid';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IDocumentInfoFilter } from '@/interfaces/database/document';
import FileUploadDialog from '@/components/FileUploadDialog';
import KnowledgeInfoCard from './components/KnowledgeInfoCard';
import FileListComponent from './components/FileListComponent';
import DocumentListComponent from './components/DocumentListComponent';
import FloatingActionButtons from './components/FloatingActionButtons';
import KnowledgeBreadcrumbs from './components/KnowledgeBreadcrumbs';
import { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks';
import { RUNNING_STATUS_KEYS } from '@/constants/knowledge';
function KnowledgeBaseDetail() {
const { id } = useParams<{ id: string }>();
@@ -42,6 +48,12 @@ function KnowledgeBaseDetail() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [testingDialogOpen, setTestingDialogOpen] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [processDetailsDialogOpen, setProcessDetailsDialogOpen] = useState(false);
const [selectedFileDetails, setSelectedFileDetails] = useState<IKnowledgeFile | null>(null);
// 轮询相关状态
const pollingIntervalRef = useRef<any>(null);
const [isPolling, setIsPolling] = useState(false);
// 使用新的document hooks
const {
@@ -61,6 +73,9 @@ function KnowledgeBaseDetail() {
uploadDocuments,
deleteDocuments,
runDocuments,
renameDocument,
changeDocumentStatus,
cancelRunDocuments,
loading: operationLoading,
error: operationError,
} = useDocumentOperations();
@@ -119,11 +134,98 @@ function KnowledgeBaseDetail() {
try {
await runDocuments(docIds);
refreshFiles(); // 刷新列表
startPolling(); // 开始轮询
} catch (err: any) {
setError(err.response?.data?.message || err.message || '重新解析失败');
}
};
// 重命名文件
const handleRename = async (fileId: string, newName: string) => {
try {
await renameDocument(fileId, newName);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '重命名失败');
}
};
// 更改文件状态
const handleChangeStatus = async (fileIds: string[], status: string) => {
try {
await changeDocumentStatus(fileIds, status);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '更改状态失败');
}
};
// 取消运行
const handleCancelRun = async (fileIds: string[]) => {
try {
await cancelRunDocuments(fileIds);
refreshFiles();
} catch (err: any) {
setError(err.response?.data?.message || err.message || '取消运行失败');
}
};
// 查看详情
const handleViewDetails = (file: IKnowledgeFile) => {
console.log("查看详情:", file);
navigate(`/chunk/parsed-result?kb_id=${id}&doc_id=${file.id}`);
};
// 查看解析详情
const handleViewProcessDetails = (file: IKnowledgeFile) => {
console.log("查看解析详情:", file);
setSelectedFileDetails(file);
setProcessDetailsDialogOpen(true);
};
// 开始轮询
const startPolling = () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
setIsPolling(true);
pollingIntervalRef.current = setInterval(() => {
refreshFiles();
}, 3000); // 每3秒轮询一次
};
// 停止轮询
const stopPolling = () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setIsPolling(false);
};
// 检查是否有运行中的文档
const hasRunningDocuments = files.some(file => file.run === RUNNING_STATUS_KEYS.RUNNING);
// 根据运行状态自动管理轮询
useEffect(() => {
if (hasRunningDocuments && !isPolling) {
startPolling();
} else if (!hasRunningDocuments && isPolling) {
stopPolling();
}
}, [hasRunningDocuments, isPolling]);
// 组件卸载时清理轮询
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// 初始化数据
useEffect(() => {
fetchKnowledgeDetail();
@@ -166,6 +268,10 @@ function KnowledgeBaseDetail() {
);
}
function fetchDocumentsFilter(): Promise<IDocumentInfoFilter | undefined> {
throw new Error('Function not implemented.');
}
return (
<Box sx={{ p: 3 }}>
{/* 面包屑导航 */}
@@ -175,7 +281,7 @@ function KnowledgeBaseDetail() {
<KnowledgeInfoCard knowledgeBase={knowledgeBase} />
{/* 文件列表组件 */}
<FileListComponent
<DocumentListComponent
files={files}
loading={filesLoading}
searchKeyword={searchKeyword}
@@ -183,7 +289,6 @@ function KnowledgeBaseDetail() {
onReparse={(fileIds) => handleReparse(fileIds)}
onDelete={(fileIds) => {
console.log('删除文件:', fileIds);
setRowSelectionModel({
type: 'include',
ids: new Set(fileIds)
@@ -205,6 +310,11 @@ function KnowledgeBaseDetail() {
pageSize={pageSize}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
onRename={handleRename}
onChangeStatus={handleChangeStatus}
onCancelRun={handleCancelRun}
onViewDetails={handleViewDetails}
onViewProcessDetails={handleViewProcessDetails}
/>
{/* 浮动操作按钮 */}
@@ -319,6 +429,137 @@ function KnowledgeBaseDetail() {
<Button variant="contained"></Button>
</DialogActions>
</Dialog>
{/* 文档详情对话框 */}
<Dialog
open={processDetailsDialogOpen}
onClose={() => setProcessDetailsDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle></DialogTitle>
<DialogContent sx={{ p: 3 }}>
{selectedFileDetails && (
<Stack spacing={3}>
{/* 基本信息卡片 */}
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 500 }}>
{selectedFileDetails.name}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
ID
</Typography>
<Typography variant="body1">
{selectedFileDetails.parser_id || '未指定'}
</Typography>
</Box>
</Stack>
</CardContent>
</Card>
{/* 处理状态卡片 */}
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Stack spacing={2}>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1">
{selectedFileDetails.process_begin_at || '未开始'}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1">
{selectedFileDetails.process_duration ? `${selectedFileDetails.process_duration}` : '未完成'}
</Typography>
</Box>
<Divider />
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Chip
label={`${100*(selectedFileDetails.progress || 0)}%`}
color="primary"
size="small"
/>
<LinearProgress
variant="determinate"
value={100*(selectedFileDetails.progress || 0)}
sx={{ flexGrow: 1, height: 8, borderRadius: 4 }}
/>
</Box>
</Box>
</Stack>
</CardContent>
</Card>
{/* 处理详情卡片 */}
{selectedFileDetails.progress_msg && (
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom color="primary">
</Typography>
<Box
sx={{
maxHeight: 300,
overflow: 'auto',
backgroundColor: 'grey.50',
borderRadius: 1,
p: 2,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Typography
variant="body2"
component="pre"
sx={{
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
color: 'text.primary',
}}
>
{selectedFileDetails.progress_msg}
</Typography>
</Box>
</CardContent>
</Card>
)}
</Stack>
)}
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button onClick={() => setProcessDetailsDialogOpen(false)} variant="contained">
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -99,7 +99,12 @@ function KnowledgeBaseTesting() {
});
// 处理测试提交
const handleTestSubmit = async (data: TestFormData) => {
return handleTestSubmitFunc(data, false);
}
const handleTestSubmitFunc = async (data: TestFormData, withSelectedDocs: boolean = false) => {
if (!id) return;
setTesting(true);
@@ -127,14 +132,16 @@ function KnowledgeBaseTesting() {
}
// 如果有选择的文档,添加到请求中
if (data.doc_ids && data.doc_ids.length > 0) {
requestBody.doc_ids = data.doc_ids;
if (withSelectedDocs) {
const doc_ids = data.doc_ids || [];
if (doc_ids.length > 0) {
requestBody.doc_ids = doc_ids;
}
} else {
if (selectedDocIds.length > 0) {
requestBody.doc_ids = selectedDocIds;
}
}
if (data.cross_languages && data.cross_languages.length > 0) {
requestBody.cross_languages = data.cross_languages;
}
@@ -209,7 +216,7 @@ function KnowledgeBaseTesting() {
const handleDocumentFilter = (docIds: string[]) => {
setSelectedDocIds(docIds);
setValue('doc_ids', docIds);
handleTestSubmit(getValues());
handleTestSubmitFunc(getValues(), true);
};
// 返回详情页