feat(knowledge): add knowledge base detail page components and hooks
refactor(knowledge): restructure knowledge detail page with new components feat(components): add FileUploadDialog for file upload functionality feat(hooks): implement document management hooks for CRUD operations
This commit is contained in:
295
src/components/FileUploadDialog.tsx
Normal file
295
src/components/FileUploadDialog.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
CloudUpload as CloudUploadIcon,
|
||||||
|
InsertDriveFile as FileIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface FileUploadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpload: (files: File[]) => Promise<void>;
|
||||||
|
acceptedFileTypes?: string[];
|
||||||
|
maxFileSize?: number; // in MB
|
||||||
|
maxFiles?: number;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadFile {
|
||||||
|
file: File;
|
||||||
|
id: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUploadDialog: React.FC<FileUploadDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onUpload,
|
||||||
|
acceptedFileTypes = ['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav'],
|
||||||
|
maxFileSize = 100, // 100MB
|
||||||
|
maxFiles = 10,
|
||||||
|
title = '上传文件',
|
||||||
|
}) => {
|
||||||
|
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
// 检查文件大小
|
||||||
|
if (file.size > maxFileSize * 1024 * 1024) {
|
||||||
|
return `文件大小不能超过 ${maxFileSize}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (!acceptedFileTypes.includes(fileExtension)) {
|
||||||
|
return `不支持的文件类型: ${fileExtension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
||||||
|
const fileArray = Array.from(newFiles);
|
||||||
|
const validFiles: UploadFile[] = [];
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
// 检查文件数量限制
|
||||||
|
if (files.length + fileArray.length > maxFiles) {
|
||||||
|
setError(`最多只能上传 ${maxFiles} 个文件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileArray.forEach((file) => {
|
||||||
|
const validationError = validateFile(file);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
hasError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在同名文件
|
||||||
|
const isDuplicate = files.some(f => f.file.name === file.name);
|
||||||
|
if (isDuplicate) {
|
||||||
|
setError(`文件 "${file.name}" 已存在`);
|
||||||
|
hasError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push({
|
||||||
|
file,
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasError && validFiles.length > 0) {
|
||||||
|
setFiles(prev => [...prev, ...validFiles]);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [files, maxFiles, maxFileSize, acceptedFileTypes]);
|
||||||
|
|
||||||
|
const removeFile = (id: string) => {
|
||||||
|
setFiles(prev => prev.filter(f => f.id !== id));
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const droppedFiles = e.dataTransfer.files;
|
||||||
|
if (droppedFiles.length > 0) {
|
||||||
|
addFiles(droppedFiles);
|
||||||
|
}
|
||||||
|
}, [addFiles]);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFiles = e.target.files;
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
addFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
// 清空input值,允许重复选择同一文件
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filesToUpload = files.map(f => f.file);
|
||||||
|
await onUpload(filesToUpload);
|
||||||
|
|
||||||
|
// 上传成功后清空文件列表
|
||||||
|
setFiles([]);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!uploading) {
|
||||||
|
setFiles([]);
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 拖拽上传区域 */}
|
||||||
|
<Box
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
sx={{
|
||||||
|
border: `2px dashed ${isDragOver ? '#1976d2' : '#ccc'}`,
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: isDragOver ? 'action.hover' : 'transparent',
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
mb: 2,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{isDragOver ? '释放文件到此处' : '拖拽文件到此处或点击上传'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
支持格式: {acceptedFileTypes.join(', ')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
最大文件大小: {maxFileSize}MB,最多 {maxFiles} 个文件
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={acceptedFileTypes.join(',')}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 文件列表 */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
已选择的文件 ({files.length}/{maxFiles})
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{files.map((uploadFile) => (
|
||||||
|
<ListItem key={uploadFile.id} divider>
|
||||||
|
<ListItemIcon>
|
||||||
|
<FileIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={uploadFile.file.name}
|
||||||
|
secondary={formatFileSize(uploadFile.file.size)}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={() => removeFile(uploadFile.id)}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploading && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
正在上传...
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose} disabled={uploading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={files.length === 0 || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? '上传中...' : `上传 (${files.length})`}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUploadDialog;
|
||||||
472
src/hooks/document-hooks.ts
Normal file
472
src/hooks/document-hooks.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// 文档列表Hook状态接口
|
||||||
|
export interface UseDocumentListState {
|
||||||
|
documents: IKnowledgeFile[];
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
keywords: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档列表Hook返回值接口
|
||||||
|
export interface UseDocumentListReturn extends UseDocumentListState {
|
||||||
|
fetchDocuments: (params?: IFetchKnowledgeListRequestParams) => Promise<void>;
|
||||||
|
setKeywords: (keywords: string) => void;
|
||||||
|
setCurrentPage: (page: number) => void;
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档列表数据管理Hook
|
||||||
|
* 支持关键词搜索、分页等功能
|
||||||
|
*/
|
||||||
|
export const useDocumentList = (
|
||||||
|
kbId: string,
|
||||||
|
initialParams?: IFetchKnowledgeListRequestParams
|
||||||
|
): UseDocumentListReturn => {
|
||||||
|
const [documents, setDocuments] = useState<IKnowledgeFile[]>([]);
|
||||||
|
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?.page_size || 10);
|
||||||
|
const [keywords, setKeywords] = useState(initialParams?.keywords || '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档列表
|
||||||
|
*/
|
||||||
|
const fetchDocuments = useCallback(async (params?: IFetchKnowledgeListRequestParams) => {
|
||||||
|
if (!kbId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 合并参数
|
||||||
|
const queryParams = {
|
||||||
|
keywords: params?.keywords ?? keywords,
|
||||||
|
page: params?.page ?? currentPage,
|
||||||
|
page_size: params?.page_size ?? pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
const requestBody: IFetchDocumentListRequestBody = {};
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const requestParams: IFetchKnowledgeListRequestParams = {
|
||||||
|
kb_id: kbId,
|
||||||
|
};
|
||||||
|
if (queryParams.page) {
|
||||||
|
requestParams.page = queryParams.page;
|
||||||
|
}
|
||||||
|
if (queryParams.page_size) {
|
||||||
|
requestParams.page_size = queryParams.page_size;
|
||||||
|
}
|
||||||
|
if (queryParams.keywords && queryParams.keywords.trim()) {
|
||||||
|
requestParams.keywords = queryParams.keywords.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await knowledgeService.getDocumentList(
|
||||||
|
Object.keys(requestParams).length > 0 ? requestParams : undefined,
|
||||||
|
requestBody
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
const data = response.data.data;
|
||||||
|
setDocuments(data.docs || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '获取文档列表失败');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.message || err.message || '获取文档列表失败';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Failed to fetch documents:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [kbId, keywords, currentPage, pageSize]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新当前页面数据
|
||||||
|
*/
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
return fetchDocuments();
|
||||||
|
}, [fetchDocuments]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置关键词并重置到第一页
|
||||||
|
*/
|
||||||
|
const handleSetKeywords = useCallback((newKeywords: string) => {
|
||||||
|
setKeywords(newKeywords);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前页
|
||||||
|
*/
|
||||||
|
const handleSetCurrentPage = useCallback((page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页面大小并重置到第一页
|
||||||
|
*/
|
||||||
|
const handleSetPageSize = useCallback((size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当关键词、页码或页面大小变化时重新获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDocuments();
|
||||||
|
}, [keywords, currentPage, pageSize]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
keywords,
|
||||||
|
fetchDocuments,
|
||||||
|
setKeywords: handleSetKeywords,
|
||||||
|
setCurrentPage: handleSetCurrentPage,
|
||||||
|
setPageSize: handleSetPageSize,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档操作Hook
|
||||||
|
* 提供上传、删除、重命名等功能
|
||||||
|
*/
|
||||||
|
export const useDocumentOperations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
*/
|
||||||
|
const uploadDocuments = useCallback(async (kbId: string, files: File[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('kb_id', kbId);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('file', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await knowledgeService.uploadDocument(formData);
|
||||||
|
|
||||||
|
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 upload documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传并解析文档
|
||||||
|
*/
|
||||||
|
const uploadAndParseDocuments = useCallback(async (kbId: string, files: File[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('kb_id', kbId);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('file', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await knowledgeService.uploadAndParseDocument(formData);
|
||||||
|
|
||||||
|
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 upload and parse documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文档
|
||||||
|
*/
|
||||||
|
const deleteDocuments = useCallback(async (docIds: string | string[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.removeDocument({ doc_id: docIds });
|
||||||
|
|
||||||
|
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 delete documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文档
|
||||||
|
*/
|
||||||
|
const renameDocument = useCallback(async (docId: string, name: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.renameDocument({ doc_id: docId, name });
|
||||||
|
|
||||||
|
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 rename document:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文档
|
||||||
|
*/
|
||||||
|
const createDocument = useCallback(async (data: any) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.createDocument(data);
|
||||||
|
|
||||||
|
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 create document:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更改文档状态
|
||||||
|
*/
|
||||||
|
const changeDocumentStatus = useCallback(async (docIds: string[], status: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.changeDocumentStatus({ doc_id: docIds, status });
|
||||||
|
|
||||||
|
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 change document status:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行文档处理
|
||||||
|
*/
|
||||||
|
const runDocuments = useCallback(async (docIds: string[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.runDocument({ doc_id: docIds });
|
||||||
|
|
||||||
|
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 run documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除错误状态
|
||||||
|
*/
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
uploadDocuments,
|
||||||
|
uploadAndParseDocuments,
|
||||||
|
deleteDocuments,
|
||||||
|
renameDocument,
|
||||||
|
createDocument,
|
||||||
|
changeDocumentStatus,
|
||||||
|
runDocuments,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档批量操作Hook
|
||||||
|
* 提供批量删除等功能
|
||||||
|
*/
|
||||||
|
export const useDocumentBatchOperations = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除文档
|
||||||
|
*/
|
||||||
|
const batchDeleteDocuments = useCallback(async (docIds: string[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
docIds.map(docId => knowledgeService.removeDocument({ doc_id: docId }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results
|
||||||
|
.map((result, index) => ({ result, index }))
|
||||||
|
.filter(({ result }) => result.status === 'rejected')
|
||||||
|
.map(({ index }) => docIds[index]);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
throw new Error(`删除失败的文档: ${failures.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.response?.data?.message || err.message || '批量删除文档失败';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Failed to batch delete documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更改文档状态
|
||||||
|
*/
|
||||||
|
const batchChangeDocumentStatus = useCallback(async (docIds: string[], status: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.changeDocumentStatus({ doc_id: docIds, status });
|
||||||
|
|
||||||
|
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 batch change document status:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量运行文档处理
|
||||||
|
*/
|
||||||
|
const batchRunDocuments = useCallback(async (docIds: string[]) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await knowledgeService.runDocument({ doc_id: docIds });
|
||||||
|
|
||||||
|
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 batch run documents:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除错误状态
|
||||||
|
*/
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
batchDeleteDocuments,
|
||||||
|
batchChangeDocumentStatus,
|
||||||
|
batchRunDocuments,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,3 +13,9 @@ body {
|
|||||||
a {
|
a {
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-center-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@@ -172,7 +172,7 @@ export interface IKnowledgeFile {
|
|||||||
size: number;
|
size: number;
|
||||||
/** 文件来源类型 */
|
/** 文件来源类型 */
|
||||||
source_type: string;
|
source_type: string;
|
||||||
/** 文件状态(启用/禁用) */
|
/** 文件状态(1 启用/ 0 禁用) */
|
||||||
status: string;
|
status: string;
|
||||||
/** 文件缩略图(base64编码),可选 */
|
/** 文件缩略图(base64编码),可选 */
|
||||||
thumbnail?: any;
|
thumbnail?: any;
|
||||||
|
|||||||
323
src/pages/knowledge/components/FileListComponent.tsx
Normal file
323
src/pages/knowledge/components/FileListComponent.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) => {
|
||||||
|
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 filteredFiles = files.filter(file =>
|
||||||
|
file.name.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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={filteredFiles}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
checkboxSelection
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
rowSelectionModel={rowSelectionModel}
|
||||||
|
onRowSelectionModelChange={onRowSelectionModelChange}
|
||||||
|
pageSizeOptions={[10, 25, 50, 100]}
|
||||||
|
initialState={{
|
||||||
|
pagination: {
|
||||||
|
paginationModel: { page: 0, pageSize: 25 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
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;
|
||||||
70
src/pages/knowledge/components/FloatingActionButtons.tsx
Normal file
70
src/pages/knowledge/components/FloatingActionButtons.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SpeedDial, SpeedDialAction } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Search as TestIcon,
|
||||||
|
Settings as ConfigIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface FloatingActionButtonsProps {
|
||||||
|
onTestClick: () => void;
|
||||||
|
onConfigClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingActionButtons: React.FC<FloatingActionButtonsProps> = ({
|
||||||
|
onTestClick,
|
||||||
|
onConfigClick,
|
||||||
|
}) => {
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
icon: <TestIcon />,
|
||||||
|
name: '检索测试',
|
||||||
|
onClick: onTestClick,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ConfigIcon />,
|
||||||
|
name: '配置设置',
|
||||||
|
onClick: onConfigClick,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpeedDial
|
||||||
|
ariaLabel="知识库操作"
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 128,
|
||||||
|
right: 64,
|
||||||
|
'& .MuiSpeedDial-fab': {
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
icon={<SettingsIcon />}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<SpeedDialAction
|
||||||
|
key={action.name}
|
||||||
|
icon={action.icon}
|
||||||
|
slotProps={{
|
||||||
|
tooltip: {
|
||||||
|
title: action.name,
|
||||||
|
sx: {
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'white',
|
||||||
|
":hover": {
|
||||||
|
bgcolor: 'primary.dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={action.onClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SpeedDial>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FloatingActionButtons;
|
||||||
72
src/pages/knowledge/components/KnowledgeInfoCard.tsx
Normal file
72
src/pages/knowledge/components/KnowledgeInfoCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Grid,
|
||||||
|
Typography,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { IKnowledge } from '@/interfaces/database/knowledge';
|
||||||
|
|
||||||
|
interface KnowledgeInfoCardProps {
|
||||||
|
knowledgeBase: IKnowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 KnowledgeInfoCard: React.FC<KnowledgeInfoCardProps> = ({ knowledgeBase }) => {
|
||||||
|
return (
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 8 }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
{knowledgeBase.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{knowledgeBase.description || '暂无描述'}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={2} flexWrap="wrap">
|
||||||
|
<Chip label={`${knowledgeBase.doc_num || 0} 个文件`} variant="outlined" />
|
||||||
|
<Chip label={`${knowledgeBase.chunk_num || 0} 个分块`} variant="outlined" />
|
||||||
|
<Chip label={`${knowledgeBase.token_num || 0} 个令牌`} variant="outlined" />
|
||||||
|
<Chip label={`大小: ${formatFileSize(knowledgeBase.size || 0)}`} variant="outlined" />
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 4 }}>
|
||||||
|
<Stack spacing={1} alignItems="flex-end">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
创建时间: {dayjs(knowledgeBase.create_time).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
更新时间: {dayjs(knowledgeBase.update_time).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
语言: {knowledgeBase.language || 'English'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
权限: {knowledgeBase.permission}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
嵌入模型: {knowledgeBase.embd_id}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
解析器: {knowledgeBase.parser_id}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KnowledgeInfoCard;
|
||||||
@@ -58,7 +58,7 @@ function KnowledgeBaseCreate() {
|
|||||||
|
|
||||||
// 使用知识库操作 hooks
|
// 使用知识库操作 hooks
|
||||||
const {
|
const {
|
||||||
loading: isSubmitting,
|
loading: isSubmitting,
|
||||||
error,
|
error,
|
||||||
createKnowledge,
|
createKnowledge,
|
||||||
updateKnowledgeModelConfig,
|
updateKnowledgeModelConfig,
|
||||||
@@ -82,9 +82,9 @@ function KnowledgeBaseCreate() {
|
|||||||
embd_id: 'text-embedding-v3@Tongyi-Qianwen',
|
embd_id: 'text-embedding-v3@Tongyi-Qianwen',
|
||||||
chunk_token_num: 512,
|
chunk_token_num: 512,
|
||||||
layout_recognize: 'DeepDOC',
|
layout_recognize: 'DeepDOC',
|
||||||
delimiter: '\n!?。;!?',
|
delimiter: '\n',
|
||||||
auto_keywords: 5,
|
auto_keywords: 0,
|
||||||
auto_questions: 3,
|
auto_questions: 0,
|
||||||
html4excel: false,
|
html4excel: false,
|
||||||
topn_tags: 10,
|
topn_tags: 10,
|
||||||
use_raptor: false,
|
use_raptor: false,
|
||||||
|
|||||||
@@ -3,110 +3,26 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Paper,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Chip,
|
|
||||||
IconButton,
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
InputAdornment,
|
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
Alert,
|
Alert,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Menu,
|
Button,
|
||||||
MenuItem,
|
TextField,
|
||||||
Tooltip,
|
|
||||||
Stack,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Grid,
|
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Link,
|
Link,
|
||||||
|
Stack,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import { type GridRowSelectionModel } from '@mui/x-data-grid';
|
||||||
Search as SearchIcon,
|
|
||||||
Upload as UploadIcon,
|
|
||||||
Delete as DeleteIcon,
|
|
||||||
Refresh as RefreshIcon,
|
|
||||||
MoreVert as MoreVertIcon,
|
|
||||||
InsertDriveFile as FileIcon,
|
|
||||||
PictureAsPdf as PdfIcon,
|
|
||||||
Description as DocIcon,
|
|
||||||
Image as ImageIcon,
|
|
||||||
VideoFile as VideoIcon,
|
|
||||||
AudioFile as AudioIcon,
|
|
||||||
CloudUpload as CloudUploadIcon,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import knowledgeService from '@/services/knowledge_service';
|
import knowledgeService from '@/services/knowledge_service';
|
||||||
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
|
import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge';
|
||||||
import { RUNNING_STATUS_KEYS, type RunningStatus } from '@/constants/knowledge';
|
import FileUploadDialog from '@/components/FileUploadDialog';
|
||||||
|
import KnowledgeInfoCard from './components/KnowledgeInfoCard';
|
||||||
// 文件类型图标映射
|
import FileListComponent from './components/FileListComponent';
|
||||||
const getFileIcon = (type: string) => {
|
import FloatingActionButtons from './components/FloatingActionButtons';
|
||||||
const lowerType = type.toLowerCase();
|
import { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks';
|
||||||
if (lowerType.includes('pdf')) return <PdfIcon />;
|
|
||||||
if (lowerType.includes('doc') || lowerType.includes('txt') || lowerType.includes('md')) return <DocIcon />;
|
|
||||||
if (lowerType.includes('jpg') || lowerType.includes('png') || lowerType.includes('jpeg')) return <ImageIcon />;
|
|
||||||
if (lowerType.includes('mp4') || lowerType.includes('avi') || lowerType.includes('mov')) return <VideoIcon />;
|
|
||||||
if (lowerType.includes('mp3') || lowerType.includes('wav') || lowerType.includes('m4a')) return <AudioIcon />;
|
|
||||||
return <FileIcon />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件大小格式化
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析状态映射
|
|
||||||
const getStatusChip = (status: string, progress: number) => {
|
|
||||||
switch (status) {
|
|
||||||
case '1':
|
|
||||||
return <Chip label="已启用" color="success" size="small" />;
|
|
||||||
case '0':
|
|
||||||
return <Chip label="已禁用" color="default" size="small" />;
|
|
||||||
default:
|
|
||||||
return <Chip label="未知" color="warning" size="small" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 运行状态映射
|
|
||||||
const getRunStatusChip = (run: RunningStatus, progress: number) => {
|
|
||||||
switch (run) {
|
|
||||||
case RUNNING_STATUS_KEYS.UNSTART:
|
|
||||||
return <Chip label="未开始" color="default" size="small" />;
|
|
||||||
case RUNNING_STATUS_KEYS.RUNNING:
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Chip label="解析中" color="primary" size="small" />
|
|
||||||
<Box sx={{ width: 60 }}>
|
|
||||||
<LinearProgress variant="determinate" value={progress} />
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption">{progress}%</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
case RUNNING_STATUS_KEYS.CANCEL:
|
|
||||||
return <Chip label="已取消" color="warning" size="small" />;
|
|
||||||
case RUNNING_STATUS_KEYS.DONE:
|
|
||||||
return <Chip label="已完成" color="success" size="small" />;
|
|
||||||
case RUNNING_STATUS_KEYS.FAIL:
|
|
||||||
return <Chip label="失败" color="error" size="small" />;
|
|
||||||
default:
|
|
||||||
return <Chip label="未知" color="default" size="small" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function KnowledgeBaseDetail() {
|
function KnowledgeBaseDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -114,15 +30,34 @@ function KnowledgeBaseDetail() {
|
|||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
|
const [knowledgeBase, setKnowledgeBase] = useState<IKnowledge | null>(null);
|
||||||
const [files, setFiles] = useState<IKnowledgeFile[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>({
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
type: 'include',
|
||||||
|
ids: new Set()
|
||||||
|
});
|
||||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [testingDialogOpen, setTestingDialogOpen] = useState(false);
|
||||||
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// 使用新的document hooks
|
||||||
|
const {
|
||||||
|
documents: files,
|
||||||
|
loading: filesLoading,
|
||||||
|
error: filesError,
|
||||||
|
refresh: refreshFiles,
|
||||||
|
setKeywords,
|
||||||
|
} = useDocumentList(id || '');
|
||||||
|
|
||||||
|
const {
|
||||||
|
uploadDocuments,
|
||||||
|
deleteDocuments,
|
||||||
|
runDocuments,
|
||||||
|
loading: operationLoading,
|
||||||
|
error: operationError,
|
||||||
|
} = useDocumentOperations();
|
||||||
|
|
||||||
// 获取知识库详情
|
// 获取知识库详情
|
||||||
const fetchKnowledgeDetail = async () => {
|
const fetchKnowledgeDetail = async () => {
|
||||||
@@ -144,48 +79,38 @@ function KnowledgeBaseDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取文件列表
|
// 删除文件
|
||||||
const fetchFileList = async () => {
|
const handleDeleteFiles = async () => {
|
||||||
if (!id) return;
|
try {
|
||||||
|
await deleteDocuments(Array.from(rowSelectionModel.ids) as string[]);
|
||||||
try {
|
setDeleteDialogOpen(false);
|
||||||
setFilesLoading(true);
|
setRowSelectionModel({
|
||||||
// const response = await knowledgeService.getDocumentList(
|
type: 'include',
|
||||||
// { kb_id: id },
|
ids: new Set()
|
||||||
// { keywords: searchKeyword }
|
});
|
||||||
// );
|
refreshFiles();
|
||||||
|
|
||||||
// if (response.data.code === 0) {
|
|
||||||
// setFiles(response.data.data.docs || []);
|
|
||||||
// } else {
|
|
||||||
// setError(response.data.message || '获取文件列表失败');
|
|
||||||
// }
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || err.message || '获取文件列表失败');
|
setError(err.response?.data?.message || err.message || '删除文件失败');
|
||||||
} finally {
|
|
||||||
setFilesLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除文件
|
// 上传文件处理
|
||||||
const handleDeleteFiles = async () => {
|
const handleUploadFiles = async (uploadFiles: File[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
console.log('上传文件:', uploadFiles);
|
||||||
|
const kb_id = knowledgeBase?.id || '';
|
||||||
try {
|
try {
|
||||||
await knowledgeService.removeDocument({ doc_ids: selectedFiles });
|
await uploadDocuments(kb_id, uploadFiles);
|
||||||
setSelectedFiles([]);
|
refreshFiles();
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
fetchFileList(); // 刷新列表
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || err.message || '删除文件失败');
|
throw new Error(err.response?.data?.message || err.message || '上传文件失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重新解析文件
|
// 重新解析文件
|
||||||
const handleReparse = async (docIds: string[]) => {
|
const handleReparse = async (docIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
await knowledgeService.runDocument({ doc_ids: docIds });
|
await runDocuments(docIds);
|
||||||
fetchFileList(); // 刷新列表
|
refreshFiles(); // 刷新列表
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || err.message || '重新解析失败');
|
setError(err.response?.data?.message || err.message || '重新解析失败');
|
||||||
}
|
}
|
||||||
@@ -194,22 +119,19 @@ function KnowledgeBaseDetail() {
|
|||||||
// 初始化数据
|
// 初始化数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchKnowledgeDetail();
|
fetchKnowledgeDetail();
|
||||||
// fetchFileList();
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// 搜索文件
|
// 搜索文件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
fetchFileList();
|
setKeywords(searchKeyword);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchKeyword]);
|
|
||||||
|
|
||||||
// 过滤文件
|
return () => clearTimeout(timer);
|
||||||
const filteredFiles = files.filter(file =>
|
}, [searchKeyword, setKeywords]);
|
||||||
file.name.toLowerCase().includes(searchKeyword.toLowerCase())
|
|
||||||
);
|
// 合并错误状态
|
||||||
|
const combinedError = error || filesError || operationError;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -220,10 +142,10 @@ function KnowledgeBaseDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (combinedError) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
<Alert severity="error">{error}</Alert>
|
<Alert severity="error">{combinedError}</Alert>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -252,220 +174,59 @@ function KnowledgeBaseDetail() {
|
|||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
{/* 知识库信息卡片 */}
|
{/* 知识库信息卡片 */}
|
||||||
<Card sx={{ mb: 3 }}>
|
<KnowledgeInfoCard knowledgeBase={knowledgeBase} />
|
||||||
<CardContent>
|
|
||||||
<Grid container spacing={3}>
|
|
||||||
<Grid size={{xs:12,md:8}}>
|
|
||||||
<Typography variant="h4" gutterBottom>
|
|
||||||
{knowledgeBase.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
{knowledgeBase.description || '暂无描述'}
|
|
||||||
</Typography>
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
<Chip label={`${knowledgeBase.doc_num} 个文件`} variant="outlined" />
|
|
||||||
<Chip label={`${knowledgeBase.chunk_num} 个分块`} variant="outlined" />
|
|
||||||
<Chip label={`${knowledgeBase.token_num} 个令牌`} variant="outlined" />
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{xs:12,md:4}}>
|
|
||||||
<Stack spacing={1} alignItems="flex-end">
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
创建时间: {knowledgeBase.create_date}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
更新时间: {knowledgeBase.update_date}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
语言: {knowledgeBase.language}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 文件操作栏 */}
|
{/* 文件列表组件 */}
|
||||||
<Paper sx={{ p: 2, mb: 2 }}>
|
<FileListComponent
|
||||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between">
|
files={files}
|
||||||
<TextField
|
loading={filesLoading}
|
||||||
placeholder="搜索文件..."
|
searchKeyword={searchKeyword}
|
||||||
value={searchKeyword}
|
onSearchChange={setSearchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onReparse={(fileIds) => handleReparse(fileIds)}
|
||||||
InputProps={{
|
onDelete={(fileIds) => {
|
||||||
startAdornment: (
|
console.log('删除文件:', fileIds);
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchIcon />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
sx={{ minWidth: 300 }}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
setRowSelectionModel({
|
||||||
<Button
|
type: 'include',
|
||||||
variant="contained"
|
ids: new Set(fileIds)
|
||||||
startIcon={<UploadIcon />}
|
});
|
||||||
onClick={() => setUploadDialogOpen(true)}
|
setDeleteDialogOpen(true);
|
||||||
>
|
}}
|
||||||
上传文件
|
onUpload={() => setUploadDialogOpen(true)}
|
||||||
</Button>
|
onRefresh={() => {
|
||||||
|
refreshFiles();
|
||||||
{selectedFiles.length > 0 && (
|
fetchKnowledgeDetail();
|
||||||
<>
|
}}
|
||||||
<Button
|
rowSelectionModel={rowSelectionModel}
|
||||||
variant="outlined"
|
onRowSelectionModelChange={(newModel) => {
|
||||||
startIcon={<RefreshIcon />}
|
console.log('新的选择模型:', newModel);
|
||||||
onClick={() => handleReparse(selectedFiles)}
|
setRowSelectionModel(newModel);
|
||||||
>
|
}}
|
||||||
重新解析
|
/>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
startIcon={<DeleteIcon />}
|
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
|
||||||
>
|
|
||||||
删除 ({selectedFiles.length})
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IconButton onClick={() => fetchFileList()}>
|
|
||||||
<RefreshIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 文件列表 */}
|
{/* 浮动操作按钮 */}
|
||||||
<TableContainer component={Paper}>
|
<FloatingActionButtons
|
||||||
<Table>
|
onTestClick={() => setTestingDialogOpen(true)}
|
||||||
<TableHead>
|
onConfigClick={() => setConfigDialogOpen(true)}
|
||||||
<TableRow>
|
/>
|
||||||
<TableCell padding="checkbox">
|
|
||||||
{/* 全选复选框可以在这里添加 */}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>文件名</TableCell>
|
|
||||||
<TableCell>类型</TableCell>
|
|
||||||
<TableCell>大小</TableCell>
|
|
||||||
<TableCell>分块数</TableCell>
|
|
||||||
<TableCell>状态</TableCell>
|
|
||||||
<TableCell>解析状态</TableCell>
|
|
||||||
<TableCell>上传时间</TableCell>
|
|
||||||
<TableCell>操作</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{filesLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} align="center">
|
|
||||||
<LinearProgress />
|
|
||||||
<Typography sx={{ mt: 1 }}>加载文件列表...</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredFiles.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} align="center">
|
|
||||||
<Typography color="text.secondary">
|
|
||||||
{searchKeyword ? '没有找到匹配的文件' : '暂无文件'}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredFiles.map((file) => (
|
|
||||||
<TableRow key={file.id} hover>
|
|
||||||
<TableCell padding="checkbox">
|
|
||||||
{/* 文件选择复选框 */}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
{getFileIcon(file.type)}
|
|
||||||
<Typography variant="body2">{file.name}</Typography>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip label={file.type.toUpperCase()} size="small" variant="outlined" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatFileSize(file.size)}</TableCell>
|
|
||||||
<TableCell>{file.chunk_num}</TableCell>
|
|
||||||
<TableCell>{getStatusChip(file.status, file.progress)}</TableCell>
|
|
||||||
<TableCell>{getRunStatusChip(file.run, file.progress)}</TableCell>
|
|
||||||
<TableCell>{file.create_date}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
|
||||||
>
|
|
||||||
<MoreVertIcon />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
|
|
||||||
{/* 文件操作菜单 */}
|
|
||||||
<Menu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
onClose={() => setAnchorEl(null)}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={() => setAnchorEl(null)}>
|
|
||||||
<RefreshIcon sx={{ mr: 1 }} />
|
|
||||||
重新解析
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => setAnchorEl(null)}>
|
|
||||||
<SettingsIcon sx={{ mr: 1 }} />
|
|
||||||
解析设置
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={() => setAnchorEl(null)} sx={{ color: 'error.main' }}>
|
|
||||||
<DeleteIcon sx={{ mr: 1 }} />
|
|
||||||
删除
|
|
||||||
</MenuItem>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
{/* 上传文件对话框 */}
|
{/* 上传文件对话框 */}
|
||||||
<Dialog open={uploadDialogOpen} onClose={() => setUploadDialogOpen(false)} maxWidth="sm" fullWidth>
|
<FileUploadDialog
|
||||||
<DialogTitle>上传文件</DialogTitle>
|
open={uploadDialogOpen}
|
||||||
<DialogContent>
|
onClose={() => setUploadDialogOpen(false)}
|
||||||
<Box
|
onUpload={handleUploadFiles}
|
||||||
sx={{
|
title="上传文件到知识库"
|
||||||
border: '2px dashed #ccc',
|
acceptedFileTypes={['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav']}
|
||||||
borderRadius: 2,
|
maxFileSize={100}
|
||||||
p: 4,
|
maxFiles={10}
|
||||||
textAlign: 'center',
|
/>
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
backgroundColor: 'action.hover',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloudUploadIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 2 }} />
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
拖拽文件到此处或点击上传
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
支持 PDF, DOCX, TXT, MD, PNG, JPG, MP4, WAV 等格式
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setUploadDialogOpen(false)}>取消</Button>
|
|
||||||
<Button variant="contained">上传</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 删除确认对话框 */}
|
{/* 删除确认对话框 */}
|
||||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Typography>
|
<Typography>
|
||||||
确定要删除选中的 {selectedFiles.length} 个文件吗?此操作不可撤销。
|
确定要删除选中的 {rowSelectionModel.ids.size} 个文件吗?此操作不可撤销。
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -473,6 +234,88 @@ function KnowledgeBaseDetail() {
|
|||||||
<Button color="error" onClick={handleDeleteFiles}>删除</Button>
|
<Button color="error" onClick={handleDeleteFiles}>删除</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 检索测试对话框 */}
|
||||||
|
<Dialog open={testingDialogOpen} onClose={() => setTestingDialogOpen(false)} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>检索测试</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="测试查询"
|
||||||
|
placeholder="输入要测试的查询内容..."
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="返回结果数量"
|
||||||
|
type="number"
|
||||||
|
defaultValue={5}
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="相似度阈值"
|
||||||
|
type="number"
|
||||||
|
defaultValue={0.7}
|
||||||
|
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ minHeight: 200, border: '1px solid #e0e0e0', borderRadius: 1, p: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
测试结果将在这里显示...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setTestingDialogOpen(false)}>关闭</Button>
|
||||||
|
<Button variant="contained">开始测试</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 配置设置对话框 */}
|
||||||
|
<Dialog open={configDialogOpen} onClose={() => setConfigDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>配置设置</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={3} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="知识库名称"
|
||||||
|
defaultValue={knowledgeBase?.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="描述"
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
defaultValue={knowledgeBase?.description}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="语言"
|
||||||
|
defaultValue={knowledgeBase?.language}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="嵌入模型"
|
||||||
|
defaultValue={knowledgeBase?.embd_id}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="解析器"
|
||||||
|
defaultValue={knowledgeBase?.parser_id}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setConfigDialogOpen(false)}>取消</Button>
|
||||||
|
<Button variant="contained">保存</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
IRenameTag,
|
IRenameTag,
|
||||||
ParserConfig,
|
ParserConfig,
|
||||||
} from '@/interfaces/database/knowledge';
|
} from '@/interfaces/database/knowledge';
|
||||||
|
import type { GridRowSelectionModel } from '@mui/x-data-grid';
|
||||||
|
|
||||||
// 知识库相关API服务
|
// 知识库相关API服务
|
||||||
const knowledgeService = {
|
const knowledgeService = {
|
||||||
@@ -63,7 +64,7 @@ const knowledgeService = {
|
|||||||
params?: IFetchKnowledgeListRequestParams,
|
params?: IFetchKnowledgeListRequestParams,
|
||||||
body?: IFetchDocumentListRequestBody
|
body?: IFetchDocumentListRequestBody
|
||||||
) => {
|
) => {
|
||||||
return request.post(api.get_document_list, { data: body || {}, params });
|
return request.post(api.get_document_list, { data: body || {} }, { params });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 创建文档
|
// 创建文档
|
||||||
@@ -73,12 +74,20 @@ const knowledgeService = {
|
|||||||
|
|
||||||
// 上传文档
|
// 上传文档
|
||||||
uploadDocument: (data: FormData) => {
|
uploadDocument: (data: FormData) => {
|
||||||
return post(api.document_upload, data);
|
// 设置请求头为 multipart/form-data
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
};
|
||||||
|
return post(api.document_upload, data, { headers });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 上传并解析文档
|
// 上传并解析文档
|
||||||
uploadAndParseDocument: (data: FormData) => {
|
uploadAndParseDocument: (data: FormData) => {
|
||||||
return post(api.upload_and_parse, data);
|
// 设置请求头为 multipart/form-data
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
};
|
||||||
|
return post(api.upload_and_parse, data, { headers });
|
||||||
},
|
},
|
||||||
|
|
||||||
// 解析文档
|
// 解析文档
|
||||||
@@ -92,7 +101,7 @@ const knowledgeService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 删除文档
|
// 删除文档
|
||||||
removeDocument: (data: { doc_ids: string[] }) => {
|
removeDocument: (data: { doc_id: string | Array<string | number> }) => {
|
||||||
return post(api.document_rm, data);
|
return post(api.document_rm, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -102,12 +111,12 @@ const knowledgeService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 更改文档状态
|
// 更改文档状态
|
||||||
changeDocumentStatus: (data: { doc_ids: string[]; status: string }) => {
|
changeDocumentStatus: (data: { doc_id: string | Array<string | number>; status: string }) => {
|
||||||
return post(api.document_change_status, data);
|
return post(api.document_change_status, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 运行文档处理
|
// 运行文档处理
|
||||||
runDocument: (data: { doc_ids: string[] }) => {
|
runDocument: (data: { doc_id: string | Array<string | number>}) => {
|
||||||
return post(api.document_run, data);
|
return post(api.document_run, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -127,7 +136,7 @@ const knowledgeService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 获取文档信息
|
// 获取文档信息
|
||||||
getDocumentInfos: (data: { doc_ids: string[] }) => {
|
getDocumentInfos: (data: { doc_id: string | Array<string | number> }) => {
|
||||||
return post(api.document_infos, data);
|
return post(api.document_infos, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
import { enUS as xGridEnUS } from '@mui/x-data-grid/locales';
|
||||||
|
import { enUS as coreEnUS } from '@mui/material/locale';
|
||||||
|
|
||||||
// Company branding colors extracted from web_prototype CSS
|
// Company branding colors extracted from web_prototype CSS
|
||||||
const brandColors = {
|
const brandColors = {
|
||||||
@@ -159,6 +161,9 @@ export const theme = createTheme({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
xGridEnUS,
|
||||||
|
coreEnUS,
|
||||||
|
);
|
||||||
|
|
||||||
export default theme;
|
export default theme;
|
||||||
@@ -85,14 +85,19 @@ const request: AxiosInstance = axios.create({
|
|||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
request.interceptors.request.use(
|
request.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
// 转换数据格式
|
// 转换数据格式 - 跳过FormData对象
|
||||||
if (config.data) {
|
if (config.data && !(config.data instanceof FormData)) {
|
||||||
config.data = convertTheKeysOfTheObjectToSnake(config.data);
|
config.data = convertTheKeysOfTheObjectToSnake(config.data);
|
||||||
}
|
}
|
||||||
if (config.params) {
|
if (config.params) {
|
||||||
config.params = convertTheKeysOfTheObjectToSnake(config.params);
|
config.params = convertTheKeysOfTheObjectToSnake(config.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于FormData,删除默认的Content-Type让浏览器自动设置
|
||||||
|
if (config.data instanceof FormData) {
|
||||||
|
delete config.headers['Content-Type'];
|
||||||
|
}
|
||||||
|
|
||||||
// 添加授权头
|
// 添加授权头
|
||||||
const authorization = getAuthorization();
|
const authorization = getAuthorization();
|
||||||
if (authorization && !config.headers?.skipToken) {
|
if (authorization && !config.headers?.skipToken) {
|
||||||
|
|||||||
Reference in New Issue
Block a user