diff --git a/src/components/Breadcrumbs/BaseBreadcrumbs.tsx b/src/components/Breadcrumbs/BaseBreadcrumbs.tsx new file mode 100644 index 0000000..0c6e1c0 --- /dev/null +++ b/src/components/Breadcrumbs/BaseBreadcrumbs.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Breadcrumbs, Link, Typography, type SxProps, type Theme } from '@mui/material'; + +export interface BreadcrumbItem { + /** 显示文本 */ + label: string; + /** 导航路径 */ + path?: string; + /** 是否为最后一项(当前页面) */ + isLast?: boolean; + /** 点击事件处理函数,优先级高于path */ + onClick?: () => void; +} + +export interface BaseBreadcrumbsProps { + /** 面包屑项目列表 */ + items: BreadcrumbItem[]; + /** 自定义样式 */ + sx?: SxProps; + /** 分隔符 */ + separator?: React.ReactNode; + /** 最大显示项目数 */ + maxItems?: number; + /** 链接变体 */ + linkVariant?: 'body1' | 'body2' | 'caption' | 'subtitle1' | 'subtitle2'; +} + +const BaseBreadcrumbs: React.FC = ({ + items, + sx, + separator, + maxItems, + linkVariant = 'body2' +}) => { + const navigate = useNavigate(); + + const handleItemClick = (item: BreadcrumbItem) => { + if (item.onClick) { + item.onClick(); + } else if (item.path) { + navigate(item.path); + } + }; + + return ( + + {items.map((item, index) => { + if (item.isLast) { + return ( + + {item.label} + + ); + } + + return ( + handleItemClick(item)} + sx={{ + textDecoration: 'none', + cursor: 'pointer', + border: 'none', + background: 'none', + padding: 0, + font: 'inherit', + color: 'inherit' + }} + > + {item.label} + + ); + })} + + ); +}; + +export default BaseBreadcrumbs; \ No newline at end of file diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts new file mode 100644 index 0000000..853fccb --- /dev/null +++ b/src/components/Breadcrumbs/index.ts @@ -0,0 +1,2 @@ +export { default as BaseBreadcrumbs } from './BaseBreadcrumbs'; +export type { BreadcrumbItem, BaseBreadcrumbsProps } from './BaseBreadcrumbs'; \ No newline at end of file diff --git a/src/pages/chunk/components/ChunkListResult.tsx b/src/pages/chunk/components/ChunkListResult.tsx index a296d5c..6416d70 100644 --- a/src/pages/chunk/components/ChunkListResult.tsx +++ b/src/pages/chunk/components/ChunkListResult.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useCallback } from 'react'; import { Box, Paper, @@ -15,16 +15,32 @@ import { Avatar, IconButton, Tooltip, + Checkbox, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + DialogContentText, + Toolbar, + FormControlLabel, } from '@mui/material'; import { Image as ImageIcon, TextSnippet as TextIcon, Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon, + Delete as DeleteIcon, + ToggleOn as EnableIcon, + ToggleOff as DisableIcon, + SelectAll as SelectAllIcon, + Clear as ClearIcon, } from '@mui/icons-material'; import type { IChunk } from '@/interfaces/database/knowledge'; +import knowledgeService from '@/services/knowledge_service'; interface ChunkListResultProps { + doc_id: string; chunks: IChunk[]; total: number; loading: boolean; @@ -32,11 +48,92 @@ interface ChunkListResultProps { page: number; pageSize: number; onPageChange: (page: number) => void; + onRefresh?: () => void; docName?: string; } function ChunkListResult(props: ChunkListResultProps) { - const { chunks, total, loading, error, page, pageSize, onPageChange, docName } = props; + const { doc_id, chunks, total, loading, error, page, pageSize, onPageChange, onRefresh, docName } = props; + + // 选择状态 + const [selectedChunks, setSelectedChunks] = useState([]); + const [selectAll, setSelectAll] = useState(false); + + // 操作状态 + const [operationLoading, setOperationLoading] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + // 处理单个选择 + const handleSelectChunk = useCallback((chunkId: string, checked: boolean) => { + setSelectedChunks(prev => { + if (checked) { + return [...prev, chunkId]; + } else { + return prev.filter(id => id !== chunkId); + } + }); + }, []); + + // 处理全选 + const handleSelectAll = useCallback((checked: boolean) => { + setSelectAll(checked); + if (checked) { + setSelectedChunks(chunks.map(chunk => chunk.chunk_id)); + } else { + setSelectedChunks([]); + } + }, [chunks]); + + // 清空选择 + const handleClearSelection = useCallback(() => { + setSelectedChunks([]); + setSelectAll(false); + }, []); + + // 启用/禁用chunks + const handleToggleChunks = useCallback(async (enable: boolean) => { + if (selectedChunks.length === 0) return; + + try { + setOperationLoading(true); + await knowledgeService.switchChunk({ + chunk_ids: selectedChunks, + available_int: enable ? 1 : 0, + doc_id: doc_id || '' + }); + + // delay 800 ms + await new Promise(resolve => setTimeout(resolve, 800)); + // 清空选择并刷新 + handleClearSelection(); + onRefresh?.(); + } catch (err) { + console.error('Failed to toggle chunks:', err); + } finally { + setOperationLoading(false); + } + }, [selectedChunks, handleClearSelection, onRefresh]); + + // 删除chunks + const handleDeleteChunks = useCallback(async () => { + if (selectedChunks.length === 0) return; + + try { + setOperationLoading(true); + await knowledgeService.removeChunk({ + chunk_ids: selectedChunks + }); + + // 关闭对话框,清空选择并刷新 + setDeleteDialogOpen(false); + handleClearSelection(); + onRefresh?.(); + } catch (err) { + console.error('Failed to delete chunks:', err); + } finally { + setOperationLoading(false); + } + }, [selectedChunks, handleClearSelection, onRefresh]); if (loading) { return ( @@ -73,50 +170,85 @@ function ChunkListResult(props: ChunkListResultProps) { } const totalPages = Math.ceil(total / pageSize); + const enabledCount = chunks.filter(chunk => chunk.available_int === 1).length; + const selectedEnabledCount = selectedChunks.filter(id => + chunks.find(chunk => chunk.chunk_id === id)?.available_int === 1 + ).length; + const selectedDisabledCount = selectedChunks.length - selectedEnabledCount; return ( - {/* Chunk结果概览 */} - - - 文档Chunk详情 - - {docName && ( - - 文档名称: {docName} - - )} - - - - - - {total} - - - 总Chunk数量 - - - - - - - - - {chunks.filter(chunk => chunk.available_int === 1).length} - - - 已启用Chunk - - - - - + {/* 批量操作工具栏 */} + + + 0 && selectedChunks.length < chunks.length} + onChange={(e) => handleSelectAll(e.target.checked)} + /> + } + label={`全选 (已选择 ${selectedChunks.length} 个)`} + /> + + + + {selectedChunks.length > 0 && ( + + {selectedDisabledCount > 0 && ( + + )} + + {selectedEnabledCount > 0 && ( + + )} + + + + + + )} + {/* Chunk列表 */} - + Chunk列表 (第 {page} 页,共 {totalPages} 页) @@ -125,7 +257,7 @@ function ChunkListResult(props: ChunkListResultProps) { - + {chunks.map((chunk, index) => ( - + {/* 头部信息 */} + handleSelectChunk(chunk.chunk_id, e.target.checked)} + sx={{ mr: 1 }} + /> + - + + - + Chunk #{((page - 1) * pageSize) + index + 1} ID: {chunk.chunk_id} + {chunk.image_id && ( - + @@ -179,39 +323,27 @@ function ChunkListResult(props: ChunkListResultProps) { - - {/* 内容区域 */} - - + + 内容预览 @@ -222,17 +354,17 @@ function ChunkListResult(props: ChunkListResultProps) { {/* 图片显示区域 */} {chunk.image_id && ( - - + + 相关图片 - { const target = e.target as HTMLImageElement; target.style.display = 'none'; }} /> - + )} {/* 关键词区域 */} {((chunk.important_kwd ?? []).length > 0 || (chunk.question_kwd ?? []).length > 0 || (chunk.tag_kwd ?? []).length > 0) && ( - - + + 关键词信息 - {chunk.important_kwd && chunk.important_kwd.length > 0 && ( - - - 重要关键词 - - + + {chunk.important_kwd && chunk.important_kwd.length > 0 && ( + + + 重要: + {chunk.important_kwd.map((keyword, kwdIndex) => ( ))} - - )} + )} - {chunk.question_kwd && chunk.question_kwd.length > 0 && ( - - - 问题关键词 - - + {chunk.question_kwd && chunk.question_kwd.length > 0 && ( + + + 问题: + {chunk.question_kwd.map((keyword, kwdIndex) => ( ))} - - )} + )} - {chunk.tag_kwd && chunk.tag_kwd.length > 0 && ( - - - 标签关键词 - - + {chunk.tag_kwd && chunk.tag_kwd.length > 0 && ( + + + 标签: + {chunk.tag_kwd.map((keyword, kwdIndex) => ( ))} - - )} - - )} - - {/* 位置信息 */} - {chunk.positions && chunk.positions.length > 0 && ( - - + )} + )} @@ -356,6 +470,31 @@ function ChunkListResult(props: ChunkListResultProps) { )} + + {/* 删除确认对话框 */} + setDeleteDialogOpen(false)} + > + 确认删除 + + + 确定要删除选中的 {selectedChunks.length} 个chunk吗?此操作不可撤销。 + + + + + + + ); } diff --git a/src/pages/chunk/document-preview.tsx b/src/pages/chunk/document-preview.tsx index 3f5d946..603d7a0 100644 --- a/src/pages/chunk/document-preview.tsx +++ b/src/pages/chunk/document-preview.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Box, @@ -7,8 +7,6 @@ import { CircularProgress, Alert, Button, - Breadcrumbs, - Link, Card, CardContent, CardMedia, @@ -20,6 +18,7 @@ import { } from '@mui/icons-material'; import knowledgeService from '@/services/knowledge_service'; import type { IKnowledge, IKnowledgeFile } from '@/interfaces/database/knowledge'; +import KnowledgeBreadcrumbs from '@/pages/knowledge/components/KnowledgeBreadcrumbs'; interface DocumentPreviewProps {} @@ -34,6 +33,9 @@ function DocumentPreview(props: DocumentPreviewProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [fileLoading, setFileLoading] = useState(false); + + // 用于取消请求的AbortController + const abortControllerRef = useRef(null); // 获取知识库和文档信息 useEffect(() => { @@ -60,6 +62,9 @@ function DocumentPreview(props: DocumentPreviewProps) { if (docResponse.data.data?.length > 0) { setDocumentObj(docResponse.data.data[0]); } + + // 自动开始预览文件 + loadDocumentFile(); } catch (err) { console.error('获取数据失败:', err); setError('获取数据失败,请稍后重试'); @@ -79,7 +84,19 @@ function DocumentPreview(props: DocumentPreviewProps) { setFileLoading(true); setError(null); - const fileResponse = await knowledgeService.getDocumentFile({ doc_id }); + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 创建新的AbortController + abortControllerRef.current = new AbortController(); + + const fileResponse = await knowledgeService.getDocumentFile({ + doc_id + }, { + signal: abortControllerRef.current.signal + }); if (fileResponse.data instanceof Blob) { setDocumentFile(fileResponse.data); @@ -88,20 +105,27 @@ function DocumentPreview(props: DocumentPreviewProps) { } else { setError('文件格式不支持预览'); } - } catch (err) { - console.error('获取文档文件失败:', err); - setError('获取文档文件失败,请稍后重试'); + } catch (err: any) { + console.log('err', err); + if (err.name !== 'AbortError' && err.name !== 'CanceledError') { + console.error('获取文档文件失败:', err); + setError('获取文档文件失败,请稍后重试'); + } } finally { setFileLoading(false); } }; - // 清理文件URL + // 清理fileUrl useEffect(() => { return () => { if (fileUrl) { URL.revokeObjectURL(fileUrl); } + // 组件卸载时取消正在进行的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } }; }, [fileUrl]); @@ -119,6 +143,17 @@ function DocumentPreview(props: DocumentPreviewProps) { // 返回上一页 const handleGoBack = () => { + // 取消正在进行的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // 清理文件URL + if (fileUrl) { + URL.revokeObjectURL(fileUrl); + setFileUrl(''); + } + navigate(-1); }; @@ -204,33 +239,27 @@ function DocumentPreview(props: DocumentPreviewProps) { return ( {/* 面包屑导航 */} - - navigate('/knowledge')} - sx={{ textDecoration: 'none' }} - > - 知识库 - - navigate(`/knowledge/${kb_id}`)} - sx={{ textDecoration: 'none' }} - > - {kb?.name || '知识库详情'} - - navigate(`/knowledge/${kb_id}/document/${doc_id}/chunks`)} - sx={{ textDecoration: 'none' }} - > - 文档分块 - - 文件预览 - + {/* 页面标题和操作按钮 */} @@ -254,17 +283,6 @@ function DocumentPreview(props: DocumentPreviewProps) { 返回 - {!fileUrl && ( - - )} - {fileUrl && (