diff --git a/src/components/FileUploadDialog.tsx b/src/components/FileUploadDialog.tsx new file mode 100644 index 0000000..dc8e821 --- /dev/null +++ b/src/components/FileUploadDialog.tsx @@ -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; + acceptedFileTypes?: string[]; + maxFileSize?: number; // in MB + maxFiles?: number; + title?: string; +} + +interface UploadFile { + file: File; + id: string; + progress: number; + error?: string; +} + +const FileUploadDialog: React.FC = ({ + open, + onClose, + onUpload, + acceptedFileTypes = ['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav'], + maxFileSize = 100, // 100MB + maxFiles = 10, + title = '上传文件', +}) => { + const [files, setFiles] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(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) => { + 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 ( + + {title} + + {error && ( + + {error} + + )} + + {/* 拖拽上传区域 */} + 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', + }, + }} + > + + + {isDragOver ? '释放文件到此处' : '拖拽文件到此处或点击上传'} + + + 支持格式: {acceptedFileTypes.join(', ')} + + + 最大文件大小: {maxFileSize}MB,最多 {maxFiles} 个文件 + + + + + + {/* 文件列表 */} + {files.length > 0 && ( + + + 已选择的文件 ({files.length}/{maxFiles}) + + + {files.map((uploadFile) => ( + + + + + + + removeFile(uploadFile.id)} + disabled={uploading} + > + + + + + ))} + + + )} + + {uploading && ( + + + 正在上传... + + + + )} + + + + + + + ); +}; + +export default FileUploadDialog; \ No newline at end of file diff --git a/src/hooks/document-hooks.ts b/src/hooks/document-hooks.ts new file mode 100644 index 0000000..d44520f --- /dev/null +++ b/src/hooks/document-hooks.ts @@ -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; + setKeywords: (keywords: string) => void; + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + refresh: () => Promise; +} + +/** + * 文档列表数据管理Hook + * 支持关键词搜索、分页等功能 + */ +export const useDocumentList = ( + kbId: string, + initialParams?: IFetchKnowledgeListRequestParams +): UseDocumentListReturn => { + const [documents, setDocuments] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(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(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, + }; +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 01a3414..dee69bf 100644 --- a/src/index.css +++ b/src/index.css @@ -13,3 +13,9 @@ body { a { text-decoration: inherit; } + +.grid-center-cell { + display: flex; + align-items: center; + justify-content: center; +} \ No newline at end of file diff --git a/src/interfaces/database/knowledge.ts b/src/interfaces/database/knowledge.ts index 9fdd450..ba2f9f9 100644 --- a/src/interfaces/database/knowledge.ts +++ b/src/interfaces/database/knowledge.ts @@ -172,7 +172,7 @@ export interface IKnowledgeFile { size: number; /** 文件来源类型 */ source_type: string; - /** 文件状态(启用/禁用) */ + /** 文件状态(1 启用/ 0 禁用) */ status: string; /** 文件缩略图(base64编码),可选 */ thumbnail?: any; diff --git a/src/pages/knowledge/components/FileListComponent.tsx b/src/pages/knowledge/components/FileListComponent.tsx new file mode 100644 index 0000000..8806882 --- /dev/null +++ b/src/pages/knowledge/components/FileListComponent.tsx @@ -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 ; + case 'doc': + case 'docx': return ; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': return ; + case 'mp4': + case 'avi': + case 'mov': return ; + case 'mp3': + case 'wav': return ; + default: return ; + } +}; + +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 ; +}; + +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 ; +}; + +const FileListComponent: React.FC = ({ + files, + loading, + searchKeyword, + onSearchChange, + onReparse, + onDelete, + onUpload, + onRefresh, + rowSelectionModel, + onRowSelectionModelChange, +}) => { + const [anchorEl, setAnchorEl] = useState(null); + const [selectedFileId, setSelectedFileId] = useState(''); + + const { i18n } = useTranslation(); + + // 根据当前语言获取DataGrid的localeText + const getDataGridLocale = () => { + const currentLanguage = i18n.language; + return currentLanguage === LanguageAbbreviation.Zh ? zhCN : enUS; + }; + + const handleMenuClick = (event: React.MouseEvent, 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) => ( + + {getFileIcon(params.row.type)} + + {params.value} + + + ), + }, + { + field: 'type', + headerName: '类型', + width: 100, + renderCell: (params) => ( + + ), + }, + { + 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) => ( + handleMenuClick(e, params.row.id)} + > + + + ), + }, + ]; + + return ( + + {/* 文件操作栏 */} + + + onSearchChange(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ minWidth: 300 }} + size="small" + /> + + + + + {rowSelectionModel.ids.size > 0 && ( + <> + + + + + )} + + + + + + + + + {/* 文件列表 */} + + + + + {/* 右边菜单 */} + + + 重新解析 + + + 删除 + + + + ); +}; + +export default FileListComponent; \ No newline at end of file diff --git a/src/pages/knowledge/components/FloatingActionButtons.tsx b/src/pages/knowledge/components/FloatingActionButtons.tsx new file mode 100644 index 0000000..670e312 --- /dev/null +++ b/src/pages/knowledge/components/FloatingActionButtons.tsx @@ -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 = ({ + onTestClick, + onConfigClick, +}) => { + const actions = [ + { + icon: , + name: '检索测试', + onClick: onTestClick, + }, + { + icon: , + name: '配置设置', + onClick: onConfigClick, + }, + ]; + + return ( + } + > + {actions.map((action) => ( + + ))} + + ); +}; + +export default FloatingActionButtons; \ No newline at end of file diff --git a/src/pages/knowledge/components/KnowledgeInfoCard.tsx b/src/pages/knowledge/components/KnowledgeInfoCard.tsx new file mode 100644 index 0000000..61676b4 --- /dev/null +++ b/src/pages/knowledge/components/KnowledgeInfoCard.tsx @@ -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 = ({ knowledgeBase }) => { + return ( + + + + + + {knowledgeBase.name} + + + {knowledgeBase.description || '暂无描述'} + + + + + + + + + + + + 创建时间: {dayjs(knowledgeBase.create_time).format('YYYY-MM-DD HH:mm:ss')} + + + 更新时间: {dayjs(knowledgeBase.update_time).format('YYYY-MM-DD HH:mm:ss')} + + + 语言: {knowledgeBase.language || 'English'} + + + 权限: {knowledgeBase.permission} + + + 嵌入模型: {knowledgeBase.embd_id} + + + 解析器: {knowledgeBase.parser_id} + + + + + + + ); +}; + +export default KnowledgeInfoCard; \ No newline at end of file diff --git a/src/pages/knowledge/create.tsx b/src/pages/knowledge/create.tsx index fe39e9b..3c2469c 100644 --- a/src/pages/knowledge/create.tsx +++ b/src/pages/knowledge/create.tsx @@ -58,7 +58,7 @@ function KnowledgeBaseCreate() { // 使用知识库操作 hooks const { - loading: isSubmitting, + loading: isSubmitting, error, createKnowledge, updateKnowledgeModelConfig, @@ -82,9 +82,9 @@ function KnowledgeBaseCreate() { embd_id: 'text-embedding-v3@Tongyi-Qianwen', chunk_token_num: 512, layout_recognize: 'DeepDOC', - delimiter: '\n!?。;!?', - auto_keywords: 5, - auto_questions: 3, + delimiter: '\n', + auto_keywords: 0, + auto_questions: 0, html4excel: false, topn_tags: 10, use_raptor: false, diff --git a/src/pages/knowledge/detail.tsx b/src/pages/knowledge/detail.tsx index e1e2f21..0114211 100644 --- a/src/pages/knowledge/detail.tsx +++ b/src/pages/knowledge/detail.tsx @@ -3,110 +3,26 @@ import { useParams, useNavigate } from 'react-router-dom'; import { Box, Typography, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Chip, - IconButton, - Button, - TextField, - InputAdornment, LinearProgress, Alert, Dialog, DialogTitle, DialogContent, DialogActions, - Menu, - MenuItem, - Tooltip, - Stack, - Card, - CardContent, - Grid, + Button, + TextField, Breadcrumbs, Link, + Stack, } from '@mui/material'; -import { - Search as SearchIcon, - Upload as UploadIcon, - Delete as DeleteIcon, - Refresh as RefreshIcon, - MoreVert as MoreVertIcon, - InsertDriveFile as FileIcon, - PictureAsPdf as PdfIcon, - Description as DocIcon, - Image as ImageIcon, - VideoFile as VideoIcon, - AudioFile as AudioIcon, - CloudUpload as CloudUploadIcon, - Settings as SettingsIcon, -} from '@mui/icons-material'; +import { type GridRowSelectionModel } from '@mui/x-data-grid'; import knowledgeService from '@/services/knowledge_service'; import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge'; -import { RUNNING_STATUS_KEYS, type RunningStatus } from '@/constants/knowledge'; - -// 文件类型图标映射 -const getFileIcon = (type: string) => { - const lowerType = type.toLowerCase(); - if (lowerType.includes('pdf')) return ; - if (lowerType.includes('doc') || lowerType.includes('txt') || lowerType.includes('md')) return ; - if (lowerType.includes('jpg') || lowerType.includes('png') || lowerType.includes('jpeg')) return ; - if (lowerType.includes('mp4') || lowerType.includes('avi') || lowerType.includes('mov')) return ; - if (lowerType.includes('mp3') || lowerType.includes('wav') || lowerType.includes('m4a')) return ; - return ; -}; - -// 文件大小格式化 -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 ; - case '0': - return ; - default: - return ; - } -}; - -// 运行状态映射 -const getRunStatusChip = (run: RunningStatus, progress: number) => { - switch (run) { - case RUNNING_STATUS_KEYS.UNSTART: - return ; - case RUNNING_STATUS_KEYS.RUNNING: - return ( - - - - - - {progress}% - - ); - case RUNNING_STATUS_KEYS.CANCEL: - return ; - case RUNNING_STATUS_KEYS.DONE: - return ; - case RUNNING_STATUS_KEYS.FAIL: - return ; - default: - return ; - } -}; +import FileUploadDialog from '@/components/FileUploadDialog'; +import KnowledgeInfoCard from './components/KnowledgeInfoCard'; +import FileListComponent from './components/FileListComponent'; +import FloatingActionButtons from './components/FloatingActionButtons'; +import { useDocumentList, useDocumentOperations } from '@/hooks/document-hooks'; function KnowledgeBaseDetail() { const { id } = useParams<{ id: string }>(); @@ -114,15 +30,34 @@ function KnowledgeBaseDetail() { // 状态管理 const [knowledgeBase, setKnowledgeBase] = useState(null); - const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); - const [filesLoading, setFilesLoading] = useState(false); const [error, setError] = useState(null); const [searchKeyword, setSearchKeyword] = useState(''); - const [selectedFiles, setSelectedFiles] = useState([]); - const [anchorEl, setAnchorEl] = useState(null); + const [rowSelectionModel, setRowSelectionModel] = useState({ + type: 'include', + ids: new Set() + }); const [uploadDialogOpen, setUploadDialogOpen] = 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 () => { @@ -144,48 +79,38 @@ function KnowledgeBaseDetail() { } }; - // 获取文件列表 - const fetchFileList = async () => { - if (!id) return; - - try { - setFilesLoading(true); - // const response = await knowledgeService.getDocumentList( - // { kb_id: id }, - // { keywords: searchKeyword } - // ); - - // if (response.data.code === 0) { - // setFiles(response.data.data.docs || []); - // } else { - // setError(response.data.message || '获取文件列表失败'); - // } + // 删除文件 + const handleDeleteFiles = async () => { + try { + await deleteDocuments(Array.from(rowSelectionModel.ids) as string[]); + setDeleteDialogOpen(false); + setRowSelectionModel({ + type: 'include', + ids: new Set() + }); + refreshFiles(); } catch (err: any) { - setError(err.response?.data?.message || err.message || '获取文件列表失败'); - } finally { - setFilesLoading(false); + setError(err.response?.data?.message || err.message || '删除文件失败'); } }; - // 删除文件 - const handleDeleteFiles = async () => { - if (selectedFiles.length === 0) return; - + // 上传文件处理 + const handleUploadFiles = async (uploadFiles: File[]) => { + console.log('上传文件:', uploadFiles); + const kb_id = knowledgeBase?.id || ''; try { - await knowledgeService.removeDocument({ doc_ids: selectedFiles }); - setSelectedFiles([]); - setDeleteDialogOpen(false); - fetchFileList(); // 刷新列表 + await uploadDocuments(kb_id, uploadFiles); + refreshFiles(); } catch (err: any) { - setError(err.response?.data?.message || err.message || '删除文件失败'); + throw new Error(err.response?.data?.message || err.message || '上传文件失败'); } }; // 重新解析文件 const handleReparse = async (docIds: string[]) => { try { - await knowledgeService.runDocument({ doc_ids: docIds }); - fetchFileList(); // 刷新列表 + await runDocuments(docIds); + refreshFiles(); // 刷新列表 } catch (err: any) { setError(err.response?.data?.message || err.message || '重新解析失败'); } @@ -194,22 +119,19 @@ function KnowledgeBaseDetail() { // 初始化数据 useEffect(() => { fetchKnowledgeDetail(); - // fetchFileList(); }, [id]); // 搜索文件 useEffect(() => { const timer = setTimeout(() => { - fetchFileList(); + setKeywords(searchKeyword); }, 500); - - return () => clearTimeout(timer); - }, [searchKeyword]); - // 过滤文件 - const filteredFiles = files.filter(file => - file.name.toLowerCase().includes(searchKeyword.toLowerCase()) - ); + return () => clearTimeout(timer); + }, [searchKeyword, setKeywords]); + + // 合并错误状态 + const combinedError = error || filesError || operationError; if (loading) { return ( @@ -220,10 +142,10 @@ function KnowledgeBaseDetail() { ); } - if (error) { + if (combinedError) { return ( - {error} + {combinedError} ); } @@ -252,220 +174,59 @@ function KnowledgeBaseDetail() { {/* 知识库信息卡片 */} - - - - - - {knowledgeBase.name} - - - {knowledgeBase.description || '暂无描述'} - - - - - - - - - - - 创建时间: {knowledgeBase.create_date} - - - 更新时间: {knowledgeBase.update_date} - - - 语言: {knowledgeBase.language} - - - - - - + - {/* 文件操作栏 */} - - - setSearchKeyword(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ minWidth: 300 }} - size="small" - /> + {/* 文件列表组件 */} + handleReparse(fileIds)} + onDelete={(fileIds) => { + console.log('删除文件:', fileIds); - - - - {selectedFiles.length > 0 && ( - <> - - - - )} - - fetchFileList()}> - - - - - + setRowSelectionModel({ + type: 'include', + ids: new Set(fileIds) + }); + setDeleteDialogOpen(true); + }} + onUpload={() => setUploadDialogOpen(true)} + onRefresh={() => { + refreshFiles(); + fetchKnowledgeDetail(); + }} + rowSelectionModel={rowSelectionModel} + onRowSelectionModelChange={(newModel) => { + console.log('新的选择模型:', newModel); + setRowSelectionModel(newModel); + }} + /> - {/* 文件列表 */} - - - - - - {/* 全选复选框可以在这里添加 */} - - 文件名 - 类型 - 大小 - 分块数 - 状态 - 解析状态 - 上传时间 - 操作 - - - - {filesLoading ? ( - - - - 加载文件列表... - - - ) : filteredFiles.length === 0 ? ( - - - - {searchKeyword ? '没有找到匹配的文件' : '暂无文件'} - - - - ) : ( - filteredFiles.map((file) => ( - - - {/* 文件选择复选框 */} - - - - {getFileIcon(file.type)} - {file.name} - - - - - - {formatFileSize(file.size)} - {file.chunk_num} - {getStatusChip(file.status, file.progress)} - {getRunStatusChip(file.run, file.progress)} - {file.create_date} - - setAnchorEl(e.currentTarget)} - > - - - - - )) - )} - -
-
- - {/* 文件操作菜单 */} - setAnchorEl(null)} - > - setAnchorEl(null)}> - - 重新解析 - - setAnchorEl(null)}> - - 解析设置 - - setAnchorEl(null)} sx={{ color: 'error.main' }}> - - 删除 - - + {/* 浮动操作按钮 */} + setTestingDialogOpen(true)} + onConfigClick={() => setConfigDialogOpen(true)} + /> {/* 上传文件对话框 */} - setUploadDialogOpen(false)} maxWidth="sm" fullWidth> - 上传文件 - - - - - 拖拽文件到此处或点击上传 - - - 支持 PDF, DOCX, TXT, MD, PNG, JPG, MP4, WAV 等格式 - - - - - - - - + setUploadDialogOpen(false)} + onUpload={handleUploadFiles} + title="上传文件到知识库" + acceptedFileTypes={['.pdf', '.docx', '.txt', '.md', '.png', '.jpg', '.jpeg', '.mp4', '.wav']} + maxFileSize={100} + maxFiles={10} + /> {/* 删除确认对话框 */} setDeleteDialogOpen(false)}> 确认删除 - 确定要删除选中的 {selectedFiles.length} 个文件吗?此操作不可撤销。 + 确定要删除选中的 {rowSelectionModel.ids.size} 个文件吗?此操作不可撤销。 @@ -473,6 +234,88 @@ function KnowledgeBaseDetail() { + + {/* 检索测试对话框 */} + setTestingDialogOpen(false)} maxWidth="md" fullWidth> + 检索测试 + + + + + + + + + + 测试结果将在这里显示... + + + + + + + + + + + {/* 配置设置对话框 */} + setConfigDialogOpen(false)} maxWidth="sm" fullWidth> + 配置设置 + + + + + + + + + + + + + + ); } diff --git a/src/services/knowledge_service.ts b/src/services/knowledge_service.ts index 2eacb34..0cb4c8d 100644 --- a/src/services/knowledge_service.ts +++ b/src/services/knowledge_service.ts @@ -13,6 +13,7 @@ import type { IRenameTag, ParserConfig, } from '@/interfaces/database/knowledge'; +import type { GridRowSelectionModel } from '@mui/x-data-grid'; // 知识库相关API服务 const knowledgeService = { @@ -63,7 +64,7 @@ const knowledgeService = { params?: IFetchKnowledgeListRequestParams, 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) => { - 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) => { - 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 }) => { 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; status: string }) => { return post(api.document_change_status, data); }, // 运行文档处理 - runDocument: (data: { doc_ids: string[] }) => { + runDocument: (data: { doc_id: string | Array}) => { return post(api.document_run, data); }, @@ -127,7 +136,7 @@ const knowledgeService = { }, // 获取文档信息 - getDocumentInfos: (data: { doc_ids: string[] }) => { + getDocumentInfos: (data: { doc_id: string | Array }) => { return post(api.document_infos, data); }, diff --git a/src/theme/index.ts b/src/theme/index.ts index 32af04a..24b3a26 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,4 +1,6 @@ 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 const brandColors = { @@ -159,6 +161,9 @@ export const theme = createTheme({ }, }, }, -}); +}, + xGridEnUS, + coreEnUS, +); export default theme; \ No newline at end of file diff --git a/src/utils/request.ts b/src/utils/request.ts index 119968f..52cec7f 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -85,14 +85,19 @@ const request: AxiosInstance = axios.create({ // 请求拦截器 request.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - // 转换数据格式 - if (config.data) { + // 转换数据格式 - 跳过FormData对象 + if (config.data && !(config.data instanceof FormData)) { config.data = convertTheKeysOfTheObjectToSnake(config.data); } if (config.params) { config.params = convertTheKeysOfTheObjectToSnake(config.params); } + // 对于FormData,删除默认的Content-Type让浏览器自动设置 + if (config.data instanceof FormData) { + delete config.headers['Content-Type']; + } + // 添加授权头 const authorization = getAuthorization(); if (authorization && !config.headers?.skipToken) {