feat(knowledge): add chunk management and document processing features
This commit is contained in:
0
src/constants/document.ts
Normal file
0
src/constants/document.ts
Normal file
107
src/hooks/chunk-hooks.ts
Normal file
107
src/hooks/chunk-hooks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名标签类型
|
||||
* 用于标签重命名操作
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IPaginationRequestBody } from './base';
|
||||
import { type IPaginationRequestBody } from './base';
|
||||
|
||||
export interface IFileListRequestBody extends IPaginationRequestBody {
|
||||
parent_id?: string; // folder id
|
||||
|
||||
@@ -49,3 +49,11 @@ export interface IFetchDocumentListRequestBody {
|
||||
suffix?: string[];
|
||||
run_status?: string[];
|
||||
}
|
||||
|
||||
|
||||
export interface IFetchChunkListRequestBody {
|
||||
doc_id?: string;
|
||||
keywords?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
247
src/pages/chunk/components/ChunkListResult.tsx
Normal file
247
src/pages/chunk/components/ChunkListResult.tsx
Normal 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;
|
||||
269
src/pages/chunk/parsed-result.tsx
Normal file
269
src/pages/chunk/parsed-result.tsx
Normal 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;
|
||||
684
src/pages/knowledge/components/DocumentListComponent.tsx
Normal file
684
src/pages/knowledge/components/DocumentListComponent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
// 返回详情页
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ request.interceptors.response.use(
|
||||
if (status === 413 || status === 504) {
|
||||
snackbar.error(RetcodeMessage[status as ResultCode]);
|
||||
}
|
||||
|
||||
// 处理blob类型响应
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response;
|
||||
|
||||
Reference in New Issue
Block a user