diff --git a/frontend/src/pages/DataManagement/dataset.model.ts b/frontend/src/pages/DataManagement/dataset.model.ts index 6e51fc2..e75e80d 100644 --- a/frontend/src/pages/DataManagement/dataset.model.ts +++ b/frontend/src/pages/DataManagement/dataset.model.ts @@ -34,10 +34,12 @@ export enum DataSource { export interface DatasetFile { id: string; + datasetId?: string; fileName: string; size: string; uploadDate: string; path: string; + filePath?: string; } export interface Dataset { diff --git a/frontend/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.tsx b/frontend/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.tsx index 962d36b..ff46c5f 100644 --- a/frontend/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.tsx +++ b/frontend/src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.tsx @@ -1,6 +1,17 @@ import type React from "react"; -import { useEffect, useState } from "react"; -import { Table, Badge, Button, Breadcrumb, Tooltip, App, Card, Input, Empty, Spin } from "antd"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Table, + Badge, + Button, + Breadcrumb, + Tooltip, + App, + Card, + Input, + Empty, + Spin, +} from "antd"; import { DeleteOutlined, EditOutlined, @@ -18,9 +29,9 @@ import { queryKnowledgeBaseFilesUsingGet, retrieveKnowledgeBaseContent, } from "../knowledge-base.api"; -import useFetchData from "@/hooks/useFetchData"; import AddDataDialog from "../components/AddDataDialog"; import CreateKnowledgeBase from "../components/CreateKnowledgeBase"; +import { File, Folder } from "lucide-react"; interface StatisticItem { icon?: React.ReactNode; @@ -39,6 +50,31 @@ interface RecallResult { primaryKey?: string; } +type KBFileRow = KBFile & { + isDirectory?: boolean; + displayName?: string; + fullPath?: string; + fileCount?: number; +}; + +const normalizePath = (value?: string) => (value ?? "").replace(/\\/g, "/"); + +const normalizePrefix = (value?: string) => { + const trimmed = normalizePath(value).replace(/^\/+/, "").trim(); + if (!trimmed) { + return ""; + } + return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; +}; + +const splitRelativePath = (fullPath: string, prefix: string) => { + if (prefix && !fullPath.startsWith(prefix)) { + return []; + } + const remainder = fullPath.slice(prefix.length); + return remainder.split("/").filter(Boolean); +}; + const KnowledgeBaseDetailPage: React.FC = () => { const navigate = useNavigate(); const { message } = App.useApp(); @@ -46,37 +82,66 @@ const KnowledgeBaseDetailPage: React.FC = () => { const [knowledgeBase, setKnowledgeBase] = useState(undefined); const [showEdit, setShowEdit] = useState(false); const [activeTab, setActiveTab] = useState<'fileList' | 'recallTest'>('fileList'); + const [filePrefix, setFilePrefix] = useState(""); + const [fileKeyword, setFileKeyword] = useState(""); + const [filesLoading, setFilesLoading] = useState(false); + const [allFiles, setAllFiles] = useState([]); + const [filePagination, setFilePagination] = useState({ + current: 1, + pageSize: 10, + }); const [recallLoading, setRecallLoading] = useState(false); const [recallResults, setRecallResults] = useState([]); const [recallQuery, setRecallQuery] = useState(""); - const fetchKnowledgeBaseDetails = async (id: string) => { - const { data } = await queryKnowledgeBaseByIdUsingGet(id); + const fetchKnowledgeBaseDetails = useCallback(async (baseId: string) => { + const { data } = await queryKnowledgeBaseByIdUsingGet(baseId); setKnowledgeBase(mapKnowledgeBase(data)); - }; + }, []); + + const fetchFiles = useCallback(async () => { + if (!id) { + setAllFiles([]); + return; + } + setFilesLoading(true); + try { + const pageSize = 200; + let page = 0; + let combined: KBFile[] = []; + while (true) { + const { data } = await queryKnowledgeBaseFilesUsingGet(id, { + page, + size: pageSize, + }); + const content = Array.isArray(data?.content) ? data.content : []; + combined = combined.concat(content.map(mapFileData)); + if (content.length < pageSize) { + break; + } + if (typeof data?.totalElements === "number" && combined.length >= data.totalElements) { + break; + } + page += 1; + } + setAllFiles(combined); + } catch (error) { + console.error("Failed to fetch knowledge base files:", error); + message.error("文件列表加载失败"); + } finally { + setFilesLoading(false); + } + }, [id, message]); useEffect(() => { if (id) { fetchKnowledgeBaseDetails(id); + fetchFiles(); } - }, [id]); - - const { - loading, - tableData: files, - searchParams, - pagination, - fetchData: fetchFiles, - setSearchParams, - handleFiltersChange, - handleKeywordChange, - } = useFetchData( - (params) => id ? queryKnowledgeBaseFilesUsingGet(id, params) : Promise.resolve({ data: [] }), - mapFileData - ); + }, [id, fetchKnowledgeBaseDetails, fetchFiles]); // File table logic - const handleDeleteFile = async (file: KBFile) => { + const handleDeleteFile = async (file: KBFileRow) => { try { await deleteKnowledgeBaseFileByIdUsingDelete(knowledgeBase!.id, { ids: [file.id] @@ -119,6 +184,168 @@ const KnowledgeBaseDetailPage: React.FC = () => { setRecallLoading(false); }; + const handleOpenDirectory = (directoryName: string) => { + const currentPrefix = normalizePrefix(filePrefix); + const nextPrefix = normalizePrefix(`${currentPrefix}${directoryName}`); + setFilePrefix(nextPrefix); + }; + + const handleBackToParent = () => { + const currentPrefix = normalizePrefix(filePrefix); + if (!currentPrefix) { + return; + } + const trimmed = currentPrefix.replace(/\/$/, ""); + const parts = trimmed.split("/").filter(Boolean); + parts.pop(); + const parentPrefix = parts.length ? `${parts.join("/")}/` : ""; + setFilePrefix(parentPrefix); + }; + + const handleDeleteDirectory = async (directoryName: string) => { + if (!knowledgeBase?.id) { + return; + } + const currentPrefix = normalizePrefix(filePrefix); + const directoryPrefix = normalizePrefix(`${currentPrefix}${directoryName}`); + const targetIds = allFiles + .filter((file) => { + const fullPath = normalizePath(file.fileName || file.name); + return fullPath.startsWith(directoryPrefix); + }) + .map((file) => file.id); + if (targetIds.length === 0) { + message.info("该文件夹为空"); + return; + } + try { + await deleteKnowledgeBaseFileByIdUsingDelete(knowledgeBase.id, { + ids: targetIds, + }); + message.success(`已删除 ${targetIds.length} 个文件`); + fetchFiles(); + } catch { + message.error("文件夹删除失败"); + } + }; + + const handleKeywordChange = (keyword: string) => { + setFileKeyword(keyword); + }; + + useEffect(() => { + setFilePagination((prev) => ({ ...prev, current: 1 })); + }, [filePrefix, fileKeyword]); + + const normalizedPrefix = useMemo(() => normalizePrefix(filePrefix), [filePrefix]); + + const { rows: fileRows, total: fileTotal } = useMemo(() => { + const keyword = fileKeyword.trim().toLowerCase(); + const folderMap = new Map(); + const fileItems: KBFileRow[] = []; + + allFiles.forEach((file) => { + const fullPath = normalizePath(file.fileName || file.name); + if (!fullPath) { + return; + } + const segments = splitRelativePath(fullPath, normalizedPrefix); + if (segments.length === 0) { + return; + } + const leafName = segments[0]; + const fileMatches = + !keyword || + leafName.toLowerCase().includes(keyword) || + fullPath.toLowerCase().includes(keyword); + + if (segments.length > 1) { + const folderName = leafName; + const entry = folderMap.get(folderName) || { + name: folderName, + fileCount: 0, + hasMatch: false, + }; + entry.fileCount += 1; + if (fileMatches) { + entry.hasMatch = true; + } + folderMap.set(folderName, entry); + return; + } + + if (!fileMatches) { + return; + } + + fileItems.push({ + ...file, + name: leafName, + displayName: leafName, + fullPath, + }); + }); + + const folderItems: KBFileRow[] = Array.from(folderMap.values()) + .filter((entry) => { + if (!keyword) { + return true; + } + return ( + entry.hasMatch || + entry.name.toLowerCase().includes(keyword) + ); + }) + .map((entry) => + ({ + id: `directory-${normalizedPrefix}${entry.name}`, + fileName: entry.name, + name: entry.name, + status: null, + chunkCount: 0, + createdAt: "", + updatedAt: "", + metadata: {}, + knowledgeBaseId: knowledgeBase?.id || "", + fileId: "", + updatedBy: "", + createdBy: "", + isDirectory: true, + displayName: entry.name, + fullPath: `${normalizedPrefix}${entry.name}/`, + fileCount: entry.fileCount, + }) as KBFileRow + ); + + const sortByName = (a: KBFileRow, b: KBFileRow) => + (a.displayName || a.name || "").localeCompare( + b.displayName || b.name || "", + "zh-Hans-CN" + ); + + folderItems.sort(sortByName); + fileItems.sort(sortByName); + + const combined = [...folderItems, ...fileItems]; + return { rows: combined, total: combined.length }; + }, [allFiles, fileKeyword, knowledgeBase?.id, normalizedPrefix]); + + const filePageCurrent = filePagination.current; + const filePageSize = filePagination.pageSize; + + const pagedFileRows = useMemo(() => { + const startIndex = (filePageCurrent - 1) * filePageSize; + const endIndex = startIndex + filePageSize; + return fileRows.slice(startIndex, endIndex); + }, [filePageCurrent, filePageSize, fileRows]); + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(fileTotal / filePageSize)); + if (filePageCurrent > maxPage) { + setFilePagination((prev) => ({ ...prev, current: maxPage })); + } + }, [filePageCurrent, filePageSize, fileTotal]); + const operations = [ { key: "edit", @@ -170,14 +397,38 @@ const KnowledgeBaseDetailPage: React.FC = () => { width: 200, ellipsis: true, fixed: "left" as const, + render: (name: string, record: KBFileRow) => { + const displayName = record.displayName || name; + if (record.isDirectory) { + return ( + + ); + } + return ( +
+ + {displayName} +
+ ); + }, }, { title: "状态", dataIndex: "status", key: "vectorizationStatus", width: 120, - render: (status: unknown) => { - if (typeof status === 'object' && status !== null) { + render: (status: unknown, record: KBFileRow) => { + if (record.isDirectory) { + return ; + } + if (typeof status === "object" && status !== null) { const s = status as { color?: string; label?: string }; return ; } @@ -190,6 +441,8 @@ const KnowledgeBaseDetailPage: React.FC = () => { key: "chunkCount", width: 100, ellipsis: true, + render: (value: number, record: KBFileRow) => + record.isDirectory ? "-" : value ?? 0, }, { title: "创建时间", @@ -197,6 +450,8 @@ const KnowledgeBaseDetailPage: React.FC = () => { key: "createdAt", ellipsis: true, width: 180, + render: (value: string, record: KBFileRow) => + record.isDirectory ? "-" : value || "-", }, { title: "更新时间", @@ -204,26 +459,51 @@ const KnowledgeBaseDetailPage: React.FC = () => { key: "updatedAt", ellipsis: true, width: 180, + render: (value: string, record: KBFileRow) => + record.isDirectory ? "-" : value || "-", }, { title: "操作", key: "actions", align: "right" as const, width: 100, - render: (_: unknown, file: KBFile) => ( -
- {fileOps.map((op) => ( - + render: (_: unknown, file: KBFileRow) => { + if (file.isDirectory) { + return ( +
- ), + ); + } + return ( +
+ {fileOps.map((op) => ( + +
+ ); + }, }, ]; @@ -265,12 +545,12 @@ const KnowledgeBaseDetailPage: React.FC = () => { <>
setSearchParams({ ...searchParams, filter: { type: [], status: [], tags: [] } })} + onFiltersChange={() => {}} + onClearFilters={() => setFileKeyword("")} showViewToggle={false} showReload={false} /> @@ -281,14 +561,54 @@ const KnowledgeBaseDetailPage: React.FC = () => {
{activeTab === 'fileList' ? ( - + <> +
+ {normalizedPrefix && ( + + )} + {normalizedPrefix && ( + + 当前路径: {normalizedPrefix} + + )} +
+
`共 ${total} 条`, + onChange: (page, pageSize) => + setFilePagination({ + current: page, + pageSize: pageSize || filePagination.pageSize, + }), + }} + scroll={{ y: "calc(100vh - 30rem)" }} + /> + ) : (
基于语义文本检索和全文检索后的加权平均结果
diff --git a/frontend/src/pages/KnowledgeBase/components/AddDataDialog.tsx b/frontend/src/pages/KnowledgeBase/components/AddDataDialog.tsx index cf23b7d..7f635e6 100644 --- a/frontend/src/pages/KnowledgeBase/components/AddDataDialog.tsx +++ b/frontend/src/pages/KnowledgeBase/components/AddDataDialog.tsx @@ -15,6 +15,7 @@ import { addKnowledgeBaseFilesUsingPost } from "../knowledge-base.api"; import DatasetFileTransfer from "@/components/business/DatasetFileTransfer"; import { DescriptionsItemType } from "antd/es/descriptions"; import { DatasetFileCols } from "../knowledge-base.const"; +import type { DatasetFile } from "@/pages/DataManagement/dataset.model"; export default function AddDataDialog({ knowledgeBase, onDataAdded }) { const [open, setOpen] = useState(false); @@ -25,6 +26,34 @@ export default function AddDataDialog({ knowledgeBase, onDataAdded }) { const [selectedFilesMap, setSelectedFilesMap] = useState({}); + const normalizePath = (value?: string) => + (value ?? "").replace(/\\/g, "/"); + + const resolveRelativeFileName = (file: DatasetFile) => { + const normalizedName = normalizePath(file.fileName); + if (normalizedName.includes("/")) { + return normalizedName.replace(/^\/+/, ""); + } + + const rawPath = normalizePath(file.path || file.filePath); + const datasetId = String(file.datasetId || ""); + if (rawPath && datasetId) { + const marker = `/${datasetId}/`; + const index = rawPath.lastIndexOf(marker); + if (index >= 0) { + const relative = rawPath + .slice(index + marker.length) + .replace(/^\/+/, ""); + if (relative) { + return relative; + } + } + } + + const fallbackName = rawPath.split("/").pop(); + return fallbackName || file.fileName; + }; + // 定义分块选项 const sliceOptions = [ { label: "默认分块", value: "DEFAULT_CHUNK" }, @@ -129,7 +158,7 @@ export default function AddDataDialog({ knowledgeBase, onDataAdded }) { const requestData = { files: Object.values(selectedFilesMap).map((file) => ({ id: String(file.id), - fileName: file.fileName, + fileName: resolveRelativeFileName(file as DatasetFile), })), processType: newKB.processType, chunkSize: Number(newKB.chunkSize), // 确保是数字类型