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

107
src/hooks/chunk-hooks.ts Normal file
View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from 'react';
import knowledgeService from '@/services/knowledge_service';
import type { IChunk, IChunkListResult } from '@/interfaces/database/knowledge';
import type { IFetchChunkListRequestBody } from '@/interfaces/request/knowledge';
// Chunk列表Hook状态接口
export interface UseChunkListState {
chunks: IChunk[];
total: number;
loading: boolean;
error: string | null;
currentPage: number;
pageSize: number;
keywords: string;
}
// Chunk列表Hook返回值接口
export interface UseChunkListReturn extends UseChunkListState {
fetchChunks: (params?: IFetchChunkListRequestBody) => Promise<void>;
setKeywords: (keywords: string) => void;
setCurrentPage: (page: number) => void;
setPageSize: (size: number) => void;
refresh: () => Promise<void>;
}
/**
* Chunk列表数据管理Hook
* 支持关键词搜索、分页等功能
*/
export const useChunkList = (
docId: string,
initialParams?: IFetchChunkListRequestBody
): UseChunkListReturn => {
const [chunks, setChunks] = useState<IChunk[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(initialParams?.page || 1);
const [pageSize, setPageSize] = useState(initialParams?.size || 10);
const [keywords, setKeywords] = useState(initialParams?.keywords || '');
/**
* 获取chunk列表
*/
const fetchChunks = useCallback(async (params?: IFetchChunkListRequestBody) => {
if (!docId) return;
try {
setLoading(true);
setError(null);
// 合并参数
const queryParams = {
doc_id: docId,
keywords: params?.keywords ?? keywords,
page: params?.page ?? currentPage,
size: params?.size ?? pageSize,
};
const response = await knowledgeService.getChunkList(queryParams);
// 检查响应状态
if (response.data.code === 0) {
const data: IChunkListResult = response.data.data;
setChunks(data.chunks || []);
setTotal(data.total || 0);
} else {
throw new Error(response.data.message || '获取chunk列表失败');
}
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '获取chunk列表失败';
setError(errorMessage);
console.error('Failed to fetch chunks:', err);
} finally {
setLoading(false);
}
}, [docId, keywords, currentPage, pageSize]);
/**
* 刷新数据
*/
const refresh = useCallback(async () => {
await fetchChunks();
}, [fetchChunks]);
// 初始化加载数据
useEffect(() => {
if (docId) {
fetchChunks();
}
}, [docId, fetchChunks]);
return {
chunks,
total,
loading,
error,
currentPage,
pageSize,
keywords,
fetchChunks,
setKeywords,
setCurrentPage,
setPageSize,
refresh,
};
};

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IFetchKnowledgeListRequestParams, IFetchDocumentListRequestBody } from '@/interfaces/request/knowledge';
import type { IDocumentInfoFilter } from '@/interfaces/database/document';
// 文档列表Hook状态接口
export interface UseDocumentListState {
@@ -17,7 +18,7 @@ export interface UseDocumentListState {
// 文档列表Hook返回值接口
export interface UseDocumentListReturn extends UseDocumentListState {
fetchDocuments: (params?: IFetchKnowledgeListRequestParams) => Promise<void>;
fetchDocumentsFilter: () => Promise<void>;
fetchDocumentsFilter: () => Promise<IDocumentInfoFilter | undefined>;
setKeywords: (keywords: string) => void;
setCurrentPage: (page: number) => void;
setPageSize: (size: number) => void;
@@ -107,7 +108,8 @@ export const useDocumentList = (
kb_id: kbId,
});
if (response.data.code === 0) {
const data = response.data.data;
const data: IDocumentInfoFilter = response.data.data;
return data;
} else {
throw new Error(response.data.message || '获取文档过滤器失败');
}
@@ -324,7 +326,7 @@ export const useDocumentOperations = () => {
setLoading(true);
setError(null);
const response = await knowledgeService.changeDocumentStatus({ doc_id: docIds, status });
const response = await knowledgeService.changeDocumentStatus({ doc_ids: docIds, status });
if (response.data.code === 0) {
return response.data.data;
@@ -349,7 +351,7 @@ export const useDocumentOperations = () => {
setLoading(true);
setError(null);
const response = await knowledgeService.runDocument({ doc_id: docIds });
const response = await knowledgeService.runDocument({ doc_ids: docIds, run: 1, delete: false });
if (response.data.code === 0) {
return response.data.data;
@@ -366,6 +368,31 @@ export const useDocumentOperations = () => {
}
}, []);
/**
* 取消文档处理
*/
const cancelRunDocuments = useCallback(async (docIds: string[]) => {
try {
setLoading(true);
setError(null);
const response = await knowledgeService.runDocument({ doc_ids: docIds, run: 2, delete: false });
if (response.data.code === 0) {
return response.data.data;
} else {
throw new Error(response.data.message || '取消文档处理失败');
}
} catch (err: any) {
const errorMessage = err.response?.data?.message || err.message || '取消文档处理失败';
setError(errorMessage);
console.error('Failed to cancel documents:', err);
throw err;
} finally {
setLoading(false);
}
}, []);
/**
* 清除错误状态
*/
@@ -383,6 +410,7 @@ export const useDocumentOperations = () => {
createDocument,
changeDocumentStatus,
runDocuments,
cancelRunDocuments,
clearError,
};
};
@@ -435,7 +463,7 @@ export const useDocumentBatchOperations = () => {
setLoading(true);
setError(null);
const response = await knowledgeService.changeDocumentStatus({ doc_id: docIds, status });
const response = await knowledgeService.changeDocumentStatus({ doc_ids: docIds, status });
if (response.data.code === 0) {
return response.data.data;
@@ -460,7 +488,7 @@ export const useDocumentBatchOperations = () => {
setLoading(true);
setError(null);
const response = await knowledgeService.runDocument({ doc_id: docIds });
const response = await knowledgeService.runDocument({ doc_ids: docIds, run: 1, delete: false });
if (response.data.code === 0) {
return response.data.data;

View File

@@ -1,4 +1,5 @@
import type { RunningStatus } from '@/constants/knowledge';
import type { IDocumentInfo } from './document';
/**
@@ -426,6 +427,15 @@ export interface INextTestingResult {
isRuned?: boolean;
}
export interface IChunkListResult {
/** 文档块列表 */
chunks: IChunk[];
/** 文档信息列表 */
doc: IDocumentInfo[]
/** 总匹配数量 */
total: number;
}
/**
* 重命名标签类型
* 用于标签重命名操作

View File

@@ -16,3 +16,19 @@ export interface IDocumentMetaRequestBody {
documentId: string;
meta: string; // json format string
}
// export const RUNNING_STATUS_KEYS = Object.freeze({
// UNSTART: '0', // need to run
// RUNNING: '1', // need to cancel
// CANCEL: '2', // need to refresh
// DONE: '3', // need to refresh
// FAIL: '4', // need to refresh
// } as const)
export interface IRunDocumentRequestBody {
doc_ids: Array<string | number> | string | number;
// running status 1 run 2 cancel - operations
run: number;
delete: boolean;
}

View File

@@ -1,4 +1,4 @@
import { IPaginationRequestBody } from './base';
import { type IPaginationRequestBody } from './base';
export interface IFileListRequestBody extends IPaginationRequestBody {
parent_id?: string; // folder id

View File

@@ -49,3 +49,11 @@ export interface IFetchDocumentListRequestBody {
suffix?: string[];
run_status?: string[];
}
export interface IFetchChunkListRequestBody {
doc_id?: string;
keywords?: string;
page?: number;
size?: number;
}

View File

@@ -0,0 +1,247 @@
import React from 'react';
import {
Box,
Paper,
Typography,
Grid,
Card,
CardContent,
Chip,
Stack,
Pagination,
CircularProgress,
Alert,
} from '@mui/material';
import type { IChunk } from '@/interfaces/database/knowledge';
interface ChunkListResultProps {
chunks: IChunk[];
total: number;
loading: boolean;
error: string | null;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
docName?: string;
}
function ChunkListResult(props: ChunkListResultProps) {
const { chunks, total, loading, error, page, pageSize, onPageChange, docName } = props;
if (loading) {
return (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress />
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
chunk数据...
</Typography>
</Paper>
);
}
if (error) {
return (
<Paper sx={{ p: 3 }}>
<Alert severity="error">
{error}
</Alert>
</Paper>
);
}
if (!chunks || chunks.length === 0) {
return (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" color="text.secondary">
chunk数据
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
chunk数据
</Typography>
</Paper>
);
}
const totalPages = Math.ceil(total / pageSize);
return (
<Box>
{/* Chunk结果概览 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Chunk详情
</Typography>
{docName && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
: {docName}
</Typography>
)}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<Card>
<CardContent>
<Typography variant="h4" color="primary">
{total}
</Typography>
<Typography variant="body2" color="text.secondary">
Chunk数量
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Card>
<CardContent>
<Typography variant="h4" color="secondary">
{chunks.filter(chunk => chunk.available_int === 1).length}
</Typography>
<Typography variant="body2" color="text.secondary">
Chunk
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
{/* Chunk列表 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Chunk列表 ( {page} {totalPages} )
</Typography>
<Typography variant="body2" color="text.secondary">
{total} chunk
</Typography>
</Box>
<Grid container spacing={2}>
{chunks.map((chunk, index) => (
<Grid size={12} key={chunk.chunk_id}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
Chunk #{((page - 1) * pageSize) + index + 1}
</Typography>
<Stack direction="row" spacing={1}>
<Chip
label={chunk.available_int === 1 ? '已启用' : '未启用'}
size="small"
color={chunk.available_int === 1 ? 'success' : 'default'}
/>
{chunk.image_id && (
<Chip
label="包含图片"
size="small"
color="info"
variant="outlined"
/>
)}
</Stack>
</Box>
<Typography
variant="body2"
sx={{
mb: 2,
maxHeight: '200px',
overflow: 'auto',
whiteSpace: 'pre-wrap',
backgroundColor: 'grey.50',
p: 2,
borderRadius: 1,
}}
>
{chunk.content_with_weight || '无内容'}
</Typography>
{chunk.important_kwd && chunk.important_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.important_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="primary"
/>
))}
</Box>
</Box>
)}
{chunk.question_kwd && chunk.question_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.question_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="secondary"
/>
))}
</Box>
</Box>
)}
{chunk.tag_kwd && chunk.tag_kwd.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chunk.tag_kwd.map((keyword, kwdIndex) => (
<Chip
key={kwdIndex}
label={keyword}
size="small"
variant="outlined"
color="info"
/>
))}
</Box>
</Box>
)}
{chunk.positions && chunk.positions.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
: {chunk.positions.length}
</Typography>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* 分页控件 */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(_, newPage) => onPageChange(newPage)}
color="primary"
showFirstButton
showLastButton
/>
</Box>
)}
</Paper>
</Box>
);
}
export default ChunkListResult;

View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from "react-router-dom";
import {
Box,
Typography,
Breadcrumbs,
Link,
TextField,
InputAdornment,
Paper,
Alert,
Card,
CardContent,
CardMedia
} from "@mui/material";
import { Search as SearchIcon, ArrowBack as ArrowBackIcon } from '@mui/icons-material';
import { useChunkList } from '@/hooks/chunk-hooks';
import ChunkListResult from './components/ChunkListResult';
import knowledgeService from '@/services/knowledge_service';
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
import type { IDocumentInfo } from '@/interfaces/database/document';
function ChunkParsedResult() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const kb_id = searchParams.get('kb_id');
const doc_id = searchParams.get('doc_id');
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
const [document, setDocument] = useState<IKnowledgeFile | null>(null);
const [documentFile, setDocumentFile] = useState<Blob | null>(null);
const [fileUrl, setFileUrl] = useState<string>('');
const [searchKeyword, setSearchKeyword] = useState('');
// 使用chunk列表hook
const {
chunks,
total,
loading,
error,
currentPage,
pageSize,
setCurrentPage,
setKeywords,
refresh
} = useChunkList(doc_id || '', {
page: 1,
size: 10,
keywords: searchKeyword
});
// 获取知识库和文档信息
useEffect(() => {
const fetchData = async () => {
if (!kb_id || !doc_id) return;
try {
// 获取知识库信息
const kbResponse = await knowledgeService.getKnowledgeDetail({ kb_id });
if (kbResponse.data.code === 0) {
setKnowledgeBase(kbResponse.data.data);
}
// 获取文档信息
const docResponse = await knowledgeService.getDocumentInfos({ doc_ids: [doc_id] });
if (docResponse.data.code === 0) {
const docArr: IKnowledgeFile[] = docResponse.data.data;
if (docArr.length > 0) {
setDocument(docArr[0]);
}
}
// 获取文档文件
const fileResponse = await knowledgeService.getDocumentFile({ doc_id });
if (fileResponse.data) {
// 处理二进制文件数据
setDocumentFile(fileResponse.data);
// 创建文件URL用于预览
const url = URL.createObjectURL(fileResponse.data);
setFileUrl(url);
}
} catch (error) {
console.error('Failed to fetch data:', error);
}
};
// 处理搜索
const handleSearch = (keyword: string) => {
setSearchKeyword(keyword);
setKeywords(keyword);
setCurrentPage(1);
};
fetchData();
// 清理函数释放URL对象
return () => {
if (fileUrl) {
URL.revokeObjectURL(fileUrl);
}
};
}, [kb_id, doc_id]);
// 渲染文件预览组件
const renderFilePreview = () => {
if (!document || !fileUrl) return null;
const fileExtension = document.name?.split('.').pop()?.toLowerCase();
// 图片文件预览
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(fileExtension || '')) {
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
</Typography>
<CardMedia
component="img"
sx={{
maxHeight: 400,
objectFit: 'contain',
border: '1px solid #e0e0e0',
borderRadius: 1
}}
image={fileUrl}
alt={document.name}
/>
</CardContent>
</Card>
);
}
// PDF文件预览
if (fileExtension === 'pdf') {
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
PDF预览
</Typography>
<Box sx={{ height: 600, border: '1px solid #e0e0e0', borderRadius: 1 }}>
<iframe
src={fileUrl}
width="100%"
height="100%"
style={{ border: 'none' }}
title={document.name}
/>
</Box>
</CardContent>
</Card>
);
}
// 其他文件类型显示下载链接
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {document.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {fileExtension?.toUpperCase() || '未知'}
</Typography>
<Link
href={fileUrl}
download={document.name}
sx={{ mt: 2, display: 'inline-block' }}
>
</Link>
</CardContent>
</Card>
);
};
if (!kb_id || !doc_id) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="error">
ID或文档ID
</Alert>
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{/* 面包屑导航 */}
<Box sx={{ mb: 3 }}>
<Breadcrumbs>
<Link
color="inherit"
href="#"
onClick={(e) => {
e.preventDefault();
navigate('/knowledge');
}}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
</Link>
<Link
color="inherit"
href="#"
onClick={(e) => {
e.preventDefault();
navigate(`/knowledge/${kb_id}`);
}}
>
{knowledgeBase?.name || '知识库详情'}
</Link>
<Typography color="text.primary">
{document?.name || '文档Chunk详情'}
</Typography>
</Breadcrumbs>
</Box>
{/* 页面标题 */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h4" gutterBottom>
Chunk解析结果
</Typography>
<Typography variant="body1" color="text.secondary">
"{document?.name}" chunk数据
</Typography>
</Paper>
{/* 文件预览 */}
{renderFilePreview()}
{/* 搜索框 */}
<Paper sx={{ p: 3, mb: 3 }}>
<TextField
fullWidth
placeholder="搜索chunk内容..."
value={searchKeyword}
// onChange={(e) => handleSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Paper>
{/* Chunk列表结果 */}
<ChunkListResult
chunks={chunks}
total={total}
loading={loading}
error={error}
page={currentPage}
pageSize={pageSize}
onPageChange={setCurrentPage}
docName={document?.name}
/>
</Box>
);
}
export default ChunkParsedResult;

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);
};
// 返回详情页

View File

@@ -8,6 +8,7 @@ import ModelsResources from '../pages/ModelsResources';
import { KnowledgeBaseList, KnowledgeBaseCreate, KnowledgeBaseDetail, KnowledgeBaseSetting, KnowledgeBaseTesting } from '../pages/knowledge';
import MCP from '../pages/MCP';
import FormFieldTest from '../pages/FormFieldTest';
import ChunkParsedResult from '@/pages/chunk/parsed-result';
const AppRoutes = () => {
return (
@@ -34,6 +35,10 @@ const AppRoutes = () => {
<Route path="mcp" element={<MCP />} />
<Route path="form-field-test" element={<FormFieldTest />} />
</Route>
{/* 处理chunk相关路由 需要传入 kb_id doc_id */}
<Route path="chunk">
<Route path="parsed-result" element={<ChunkParsedResult />} />
</Route>
{/* 处理未匹配的路由 */}
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -5,6 +5,7 @@ import type {
IFetchKnowledgeListRequestParams,
IFetchDocumentListRequestBody,
ITestRetrievalRequestBody,
IFetchChunkListRequestBody,
} from '@/interfaces/request/knowledge';
import type {
IKnowledge,
@@ -12,8 +13,10 @@ import type {
IChunk,
IRenameTag,
IParserConfig,
IKnowledgeFileParserConfig,
} from '@/interfaces/database/knowledge';
import type { GridRowSelectionModel } from '@mui/x-data-grid';
import type { IRunDocumentRequestBody } from '@/interfaces/request/document';
// 知识库相关API服务
const knowledgeService = {
@@ -110,18 +113,21 @@ const knowledgeService = {
return request.delete(`${api.document_delete}/${doc_id}`);
},
// 更改文档状态
changeDocumentStatus: (data: { doc_id: string | Array<string | number>; status: string }) => {
/**
* 更改文档状态
* @param data 文档ID列表和状态 status 0 禁用 1 启用
*/
changeDocumentStatus: (data: { doc_ids: Array<string | number>; status: string | number }) => {
return post(api.document_change_status, data);
},
// 运行文档处理
runDocument: (data: { doc_id: string | Array<string | number>}) => {
runDocument: (data: IRunDocumentRequestBody) => {
return post(api.document_run, data);
},
// 更改文档解析器配置
changeDocumentParser: (data: { doc_id: string; parser_config: IParserConfig }) => {
changeDocumentParser: (data: { doc_id: string; parser_config: IKnowledgeFileParserConfig }) => {
return post(api.document_change_parser, data);
},
@@ -132,11 +138,13 @@ const knowledgeService = {
// 获取文档文件
getDocumentFile: (params: { doc_id: string }) => {
return request.get(api.get_document_file, { params });
return request.get(`${api.get_document_file}/${params.doc_id}`, {
responseType: 'blob'
});
},
// 获取文档信息
getDocumentInfos: (data: { doc_id: string | Array<string | number> }) => {
getDocumentInfos: (data: { doc_ids: string | Array<string | number> }) => {
return post(api.document_infos, data);
},
@@ -158,7 +166,7 @@ const knowledgeService = {
// ===== 分块管理 =====
// 获取分块列表
getChunkList: (data: any) => {
getChunkList: (data: IFetchChunkListRequestBody) => {
return post(api.chunk_list, data);
},

View File

@@ -113,14 +113,13 @@ request.interceptors.request.use(
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
(response: AxiosResponse) => {
const { status } = response;
// 处理特定状态码
if (status === 413 || status === 504) {
snackbar.error(RetcodeMessage[status as ResultCode]);
}
// 处理blob类型响应
if (response.config.responseType === 'blob') {
return response;