import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { App, Breadcrumb, Button, Card, Descriptions, Empty, Input, Modal, Spin, Table, Tooltip, } from "antd"; import { DeleteOutlined, DownloadOutlined, EditOutlined, EyeOutlined, PlusOutlined } from "@ant-design/icons"; import { useNavigate, useParams } from "react-router"; import DetailHeader from "@/components/DetailHeader"; import { SearchControls } from "@/components/SearchControls"; import { createKnowledgeDirectoryUsingPost, deleteKnowledgeDirectoryUsingDelete, deleteKnowledgeItemByIdUsingDelete, deleteKnowledgeSetByIdUsingDelete, downloadKnowledgeItemFileUsingGet, convertKnowledgeItemPreviewUsingPost, exportKnowledgeItemsUsingGet, queryKnowledgeDirectoriesUsingGet, queryKnowledgeItemsUsingGet, queryKnowledgeItemPreviewStatusUsingGet, queryKnowledgeSetByIdUsingGet, updateKnowledgeSetByIdUsingPut, } from "../knowledge-management.api"; import { createDatasetTagUsingPost, queryDatasetTagsUsingGet, } from "../../DataManagement/dataset.api"; import { knowledgeContentTypeOptions, knowledgeSourceTypeOptions, knowledgeStatusMap, mapKnowledgeItem, KnowledgeItemView, } from "../knowledge-management.const"; import { KnowledgeItem, KnowledgeDirectory, KnowledgeSet, KnowledgeContentType, KnowledgeSourceType, KnowledgeStatusType, } from "../knowledge-management.model"; import CreateKnowledgeSet from "../components/CreateKnowledgeSet"; import KnowledgeItemEditor from "../components/KnowledgeItemEditor"; import ImportKnowledgeItemsDialog from "../components/ImportKnowledgeItemsDialog"; import { formatDate } from "@/utils/unit"; import { File, Folder, Clock } from "lucide-react"; import { PREVIEW_TEXT_MAX_LENGTH, resolvePreviewFileType, truncatePreviewText, type PreviewFileType, } from "@/utils/filePreview"; const PREVIEW_MAX_HEIGHT = 500; const PREVIEW_MODAL_WIDTH = { text: "80vw", media: "80vw", }; const PREVIEW_TEXT_FONT_SIZE = 12; const PREVIEW_TEXT_PADDING = 12; const PREVIEW_AUDIO_PADDING = 40; const OFFICE_FILE_EXTENSIONS = [".doc", ".docx"]; const OFFICE_PREVIEW_POLL_INTERVAL = 2000; const OFFICE_PREVIEW_POLL_MAX_TIMES = 60; type OfficePreviewStatus = "UNSET" | "PENDING" | "PROCESSING" | "READY" | "FAILED"; const parseMetadata = (value?: string | Record) => { if (!value) { return null; } if (typeof value === "object") { return value as Record; } if (typeof value !== "string") { return null; } try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" ? (parsed as Record) : null; } catch { return null; } }; const isAnnotationItem = (record: KnowledgeItemView) => { const metadata = parseMetadata(record.metadata); const source = metadata && typeof metadata === "object" ? (metadata as { source?: { type?: string } }).source : null; return source?.type === "annotation"; }; const isOfficeFileName = (fileName?: string) => { const lowerName = (fileName || "").toLowerCase(); return OFFICE_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext)); }; const normalizeOfficePreviewStatus = (status?: string): OfficePreviewStatus => { if (!status) { return "UNSET"; } const upper = status.toUpperCase(); if (upper === "PENDING" || upper === "PROCESSING" || upper === "READY" || upper === "FAILED") { return upper as OfficePreviewStatus; } return "UNSET"; }; type KnowledgeItemRow = KnowledgeItemView & { isDirectory?: boolean; displayName?: string; fullPath?: string; fileCount?: number; }; const PATH_SEPARATOR = "/"; const normalizePath = (value?: string) => (value ?? "").replace(/\\/g, PATH_SEPARATOR); const normalizePrefix = (value?: string) => { const trimmed = normalizePath(value).replace(/^\/+/, "").trim(); if (!trimmed) { return ""; } return trimmed.endsWith(PATH_SEPARATOR) ? trimmed : `${trimmed}${PATH_SEPARATOR}`; }; const splitRelativePath = (fullPath: string, prefix: string) => { if (prefix && !fullPath.startsWith(prefix)) { return []; } const remainder = fullPath.slice(prefix.length); return remainder.split(PATH_SEPARATOR).filter(Boolean); }; const KnowledgeSetDetail = () => { const navigate = useNavigate(); const { message, modal } = App.useApp(); const { id } = useParams<{ id: string }>(); const [knowledgeSet, setKnowledgeSet] = useState(null); const [showEdit, setShowEdit] = useState(false); const [itemEditorOpen, setItemEditorOpen] = useState(false); const [currentItem, setCurrentItem] = useState(null); const [readItemId, setReadItemId] = useState(null); const [readModalOpen, setReadModalOpen] = useState(false); const [readContent, setReadContent] = useState(""); const [readTitle, setReadTitle] = useState(""); const [previewVisible, setPreviewVisible] = useState(false); const [previewContent, setPreviewContent] = useState(""); const [previewFileName, setPreviewFileName] = useState(""); const [previewFileType, setPreviewFileType] = useState("text"); const [previewMediaUrl, setPreviewMediaUrl] = useState(""); const [previewLoadingItemId, setPreviewLoadingItemId] = useState(null); const [officePreviewStatus, setOfficePreviewStatus] = useState(null); const [officePreviewError, setOfficePreviewError] = useState(""); const officePreviewPollingRef = useRef(null); const officePreviewItemRef = useRef(null); const [filePrefix, setFilePrefix] = useState(""); const [fileKeyword, setFileKeyword] = useState(""); const [itemsLoading, setItemsLoading] = useState(false); const [allItems, setAllItems] = useState([]); const [directoriesLoading, setDirectoriesLoading] = useState(false); const [allDirectories, setAllDirectories] = useState([]); const [filePagination, setFilePagination] = useState({ current: 1, pageSize: 10, }); const fetchKnowledgeSet = useCallback(async () => { if (!id) return; const { data } = await queryKnowledgeSetByIdUsingGet(id); setKnowledgeSet(data); }, [id]); const fetchItems = useCallback(async () => { if (!id) { setAllItems([]); return; } setItemsLoading(true); try { const pageSize = 200; let page = 1; let combined: KnowledgeItemView[] = []; const currentPrefix = normalizePrefix(filePrefix); const keyword = fileKeyword.trim(); while (true) { const { data } = await queryKnowledgeItemsUsingGet(id, { page, size: pageSize, ...(currentPrefix ? { relativePath: currentPrefix } : {}), ...(keyword ? { keyword } : {}), }); const content = Array.isArray(data?.content) ? data.content : []; combined = combined.concat(content.map(mapKnowledgeItem)); if (content.length < pageSize) { break; } if (typeof data?.totalElements === "number" && combined.length >= data.totalElements) { break; } page += 1; } setAllItems(combined); } catch (error) { console.error("加载知识条目失败", error); message.error("知识条目加载失败"); } finally { setItemsLoading(false); } }, [fileKeyword, filePrefix, id, message]); const fetchDirectories = useCallback(async () => { if (!id) { setAllDirectories([]); return; } setDirectoriesLoading(true); try { const currentPrefix = normalizePrefix(filePrefix); const keyword = fileKeyword.trim(); const { data } = await queryKnowledgeDirectoriesUsingGet(id, { ...(currentPrefix ? { relativePath: currentPrefix } : {}), ...(keyword ? { keyword } : {}), }); const directories = Array.isArray(data) ? data : []; setAllDirectories(directories); } catch (error) { console.error("加载知识目录失败", error); message.error("知识目录加载失败"); } finally { setDirectoriesLoading(false); } }, [fileKeyword, filePrefix, id, message]); useEffect(() => { fetchKnowledgeSet(); }, [fetchKnowledgeSet]); useEffect(() => { if (id) { fetchItems(); fetchDirectories(); } }, [id, fetchItems, fetchDirectories]); useEffect(() => { setFilePagination((prev) => ({ ...prev, current: 1 })); }, [filePrefix, fileKeyword]); const isReadOnly = knowledgeSet?.status === KnowledgeStatusType.ARCHIVED || knowledgeSet?.status === KnowledgeStatusType.DEPRECATED; const handleDeleteSet = async () => { if (!knowledgeSet) return; await deleteKnowledgeSetByIdUsingDelete(knowledgeSet.id); message.success("知识集已删除"); navigate("/data/knowledge-management"); }; const handleDeleteItem = async (item: KnowledgeItemView) => { if (!id) return; await deleteKnowledgeItemByIdUsingDelete(id, item.id); message.success("知识条目已删除"); fetchItems(); }; const handleExportItems = async () => { if (!id) return; await exportKnowledgeItemsUsingGet(id); message.success("知识条目导出成功"); }; const isReadableItem = (record: KnowledgeItemView) => { if ( record.contentType === KnowledgeContentType.FILE || record.sourceType === KnowledgeSourceType.FILE_UPLOAD ) { return false; } return ( record.contentType === KnowledgeContentType.TEXT || record.contentType === KnowledgeContentType.MARKDOWN ); }; const resolveItemRelativePath = useCallback((record: KnowledgeItemView) => { const rawPath = record.relativePath || ""; return normalizePath(rawPath).replace(/^\/+/, ""); }, []); const resolveDirectoryRelativePath = useCallback((directory: KnowledgeDirectory) => { const rawPath = directory.relativePath || ""; return normalizePath(rawPath).replace(/^\/+/, ""); }, []); const resolveDisplayName = useCallback( (record: KnowledgeItemView) => { const relativePath = resolveItemRelativePath(record); if (relativePath) { const segments = relativePath.split(PATH_SEPARATOR).filter(Boolean); const lastSegment = segments[segments.length - 1]; if (lastSegment) { return lastSegment; } } return record.sourceFileId || "条目"; }, [resolveItemRelativePath] ); const resolvePreviewFileName = (record: KnowledgeItemView) => { const relativePath = resolveItemRelativePath(record); if (relativePath) { const segments = relativePath.split(PATH_SEPARATOR).filter(Boolean); const lastSegment = segments[segments.length - 1]; if (lastSegment) { return lastSegment; } } if (record.sourceFileId) { return record.sourceFileId; } if (record.content) { const segments = record.content.split("/"); const lastSegment = segments[segments.length - 1]; if (lastSegment) { return lastSegment; } } return "文件"; }; const clearOfficePreviewPolling = useCallback(() => { if (officePreviewPollingRef.current) { window.clearTimeout(officePreviewPollingRef.current); officePreviewPollingRef.current = null; } }, []); useEffect(() => { return () => { clearOfficePreviewPolling(); }; }, [clearOfficePreviewPolling]); const pollOfficePreviewStatus = useCallback( async (setId: string, itemId: string, attempt: number) => { clearOfficePreviewPolling(); officePreviewPollingRef.current = window.setTimeout(async () => { if (officePreviewItemRef.current !== itemId) { return; } try { const { data } = await queryKnowledgeItemPreviewStatusUsingGet(setId, itemId); const status = normalizeOfficePreviewStatus(data?.status); if (status === "READY") { setPreviewMediaUrl(`/api/data-management/knowledge-sets/${setId}/items/${itemId}/preview`); setOfficePreviewStatus("READY"); setOfficePreviewError(""); setPreviewLoadingItemId(null); fetchItems(); return; } if (status === "FAILED") { setOfficePreviewStatus("FAILED"); setOfficePreviewError(data?.previewError || "转换失败,请稍后重试"); setPreviewLoadingItemId(null); fetchItems(); return; } if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) { setOfficePreviewStatus("FAILED"); setOfficePreviewError("转换超时,请稍后重试"); setPreviewLoadingItemId(null); return; } pollOfficePreviewStatus(setId, itemId, attempt + 1); } catch (error) { console.error("轮询预览状态失败", error); if (attempt >= OFFICE_PREVIEW_POLL_MAX_TIMES - 1) { setOfficePreviewStatus("FAILED"); setOfficePreviewError("转换超时,请稍后重试"); setPreviewLoadingItemId(null); return; } pollOfficePreviewStatus(setId, itemId, attempt + 1); } }, OFFICE_PREVIEW_POLL_INTERVAL); }, [clearOfficePreviewPolling, fetchItems] ); const handlePreviewItemFile = async (record: KnowledgeItemView) => { if (!id) return; const fileName = resolvePreviewFileName(record); const previewUrl = `/api/data-management/knowledge-sets/${id}/items/${record.id}/preview`; setPreviewFileName(fileName); setPreviewContent(""); setPreviewMediaUrl(""); if (isOfficeFileName(fileName)) { setPreviewFileType("pdf"); setPreviewVisible(true); setPreviewLoadingItemId(record.id); setOfficePreviewStatus("PROCESSING"); setOfficePreviewError(""); officePreviewItemRef.current = record.id; try { const { data: statusData } = await queryKnowledgeItemPreviewStatusUsingGet(id, record.id); const currentStatus = normalizeOfficePreviewStatus(statusData?.status); if (currentStatus === "READY") { setPreviewMediaUrl(previewUrl); setOfficePreviewStatus("READY"); setPreviewLoadingItemId(null); fetchItems(); return; } if (currentStatus === "FAILED") { setOfficePreviewStatus("PROCESSING"); } if (currentStatus === "PROCESSING") { pollOfficePreviewStatus(id, record.id, 0); return; } const { data } = await convertKnowledgeItemPreviewUsingPost(id, record.id); const status = normalizeOfficePreviewStatus(data?.status); if (status === "READY") { setPreviewMediaUrl(previewUrl); setOfficePreviewStatus("READY"); setPreviewLoadingItemId(null); } else if (status === "FAILED") { setOfficePreviewStatus("FAILED"); setOfficePreviewError(data?.previewError || "转换失败,请稍后重试"); setPreviewLoadingItemId(null); } else { setOfficePreviewStatus("PROCESSING"); pollOfficePreviewStatus(id, record.id, 0); } fetchItems(); } catch (error) { console.error("触发预览转换失败", error); message.error("触发预览转换失败"); setOfficePreviewStatus("FAILED"); setOfficePreviewError("触发预览转换失败"); } finally { setPreviewLoadingItemId(null); } return; } const fileType = resolvePreviewFileType(fileName); if (!fileType) { message.warning("不支持预览该文件类型"); return; } setPreviewFileType(fileType); if (fileType === "text") { setPreviewLoadingItemId(record.id); try { const response = await fetch(previewUrl); if (!response.ok) { throw new Error("下载失败"); } const text = await response.text(); setPreviewContent(truncatePreviewText(text, PREVIEW_TEXT_MAX_LENGTH)); setPreviewVisible(true); } catch (error) { console.error("预览知识条目文件失败", error); message.error("预览失败,请稍后重试"); } finally { setPreviewLoadingItemId(null); } return; } setPreviewMediaUrl(previewUrl); setPreviewVisible(true); }; const closePreview = () => { clearOfficePreviewPolling(); officePreviewItemRef.current = null; setPreviewVisible(false); setPreviewContent(""); setPreviewMediaUrl(""); setPreviewFileName(""); setPreviewFileType("text"); setOfficePreviewStatus(null); setOfficePreviewError(""); }; const handleReadItem = async (record: KnowledgeItemView) => { if ( record.contentType === KnowledgeContentType.FILE || record.sourceType === KnowledgeSourceType.FILE_UPLOAD ) { message.info("该条目为文件,请使用下载查看"); return; } setReadItemId(record.id); setReadTitle("知识条目"); if (!record.sourceDatasetId || !record.sourceFileId || isAnnotationItem(record)) { const content = record.content || ""; setReadContent(truncatePreviewText(content, PREVIEW_TEXT_MAX_LENGTH)); setReadModalOpen(true); setReadItemId(null); return; } const fileUrl = `/api/data-management/datasets/${record.sourceDatasetId}/files/${record.sourceFileId}/download`; try { const response = await fetch(fileUrl); if (!response.ok) { throw new Error("下载失败"); } const text = await response.text(); setReadContent(truncatePreviewText(text, PREVIEW_TEXT_MAX_LENGTH)); setReadModalOpen(true); } catch (error) { console.error("读取知识条目失败", error); message.error("读取失败,请稍后重试"); } finally { setReadItemId(null); } }; const handleDownloadItem = async (record: KnowledgeItemView) => { if (!id) return; try { await downloadKnowledgeItemFileUsingGet(id, record.id, record.sourceFileId); } catch (error) { console.error("下载知识条目文件失败", error); message.error("下载失败,请稍后重试"); } }; const statusMeta = knowledgeSet?.status ? knowledgeStatusMap[knowledgeSet.status] : undefined; const statistics = useMemo( () => [ { key: "items", icon: , value: allItems.length, }, { key: "updated", icon: , value: knowledgeSet?.updatedAt ? formatDate(knowledgeSet.updatedAt) : "-", }, ], [allItems.length, knowledgeSet?.updatedAt] ); const handleKeywordChange = (keyword: string) => { setFileKeyword(keyword); }; 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(PATH_SEPARATOR).filter(Boolean); parts.pop(); const parentPrefix = parts.length ? `${parts.join(PATH_SEPARATOR)}${PATH_SEPARATOR}` : ""; setFilePrefix(parentPrefix); }; const isInvalidDirectoryName = (value: string) => !value || value.includes("/") || value.includes("\\") || value.includes(".."); const handleDeleteDirectory = async (directoryName: string) => { if (!id) { return; } const currentPrefix = normalizePrefix(filePrefix); const directoryPath = normalizePrefix(`${currentPrefix}${directoryName}`).replace(/\/$/, ""); try { await deleteKnowledgeDirectoryUsingDelete(id, directoryPath); message.success("文件夹已删除"); fetchItems(); fetchDirectories(); } catch (error) { console.error("删除文件夹失败", error); message.error("文件夹删除失败"); } }; const normalizedPrefix = useMemo(() => normalizePrefix(filePrefix), [filePrefix]); const { rows: itemRows, total: itemTotal } = useMemo(() => { const folderMap = new Map(); const fileItems: KnowledgeItemRow[] = []; allItems.forEach((item) => { const fullPath = resolveItemRelativePath(item); if (!fullPath) { if (!normalizedPrefix) { const displayName = resolveDisplayName(item); fileItems.push({ ...item, displayName, fullPath: displayName, }); } return; } const segments = splitRelativePath(fullPath, normalizedPrefix); if (segments.length === 0) { return; } const leafName = segments[0]; if (segments.length > 1) { const entry = folderMap.get(leafName) || { name: leafName, fileCount: 0, }; entry.fileCount += 1; folderMap.set(leafName, entry); return; } fileItems.push({ ...item, displayName: leafName, fullPath, }); }); allDirectories.forEach((directory) => { const fullPath = resolveDirectoryRelativePath(directory); if (!fullPath) { return; } const segments = splitRelativePath(fullPath, normalizedPrefix); if (segments.length === 0) { return; } const leafName = segments[0]; if (!folderMap.has(leafName)) { folderMap.set(leafName, { name: leafName, fileCount: 0 }); } }); const folderItems: KnowledgeItemRow[] = Array.from(folderMap.values()).map((entry) => ({ id: `directory-${normalizedPrefix}${entry.name}`, setId: id || "", content: "", contentType: KnowledgeContentType.FILE, status: null, rawStatus: KnowledgeStatusType.DRAFT, tags: [], createdAt: "", updatedAt: "", sourceType: KnowledgeSourceType.FILE_UPLOAD, sourceDatasetId: "", sourceFileId: "", metadata: "", isDirectory: true, displayName: entry.name, fullPath: `${normalizedPrefix}${entry.name}/`, fileCount: entry.fileCount, })); const sortByName = (a: KnowledgeItemRow, b: KnowledgeItemRow) => (a.displayName || "").localeCompare(b.displayName || "", "zh-Hans-CN"); folderItems.sort(sortByName); fileItems.sort(sortByName); const combined = [...folderItems, ...fileItems]; return { rows: combined, total: combined.length }; }, [ allDirectories, allItems, id, normalizedPrefix, resolveDisplayName, resolveDirectoryRelativePath, resolveItemRelativePath, ]); const pageCurrent = filePagination.current; const pageSize = filePagination.pageSize; const pagedItemRows = useMemo(() => { const startIndex = (pageCurrent - 1) * pageSize; const endIndex = startIndex + pageSize; return itemRows.slice(startIndex, endIndex); }, [itemRows, pageCurrent, pageSize]); useEffect(() => { const maxPage = Math.max(1, Math.ceil(itemTotal / pageSize)); if (pageCurrent > maxPage) { setFilePagination((prev) => ({ ...prev, current: maxPage })); } }, [itemTotal, pageCurrent, pageSize]); const itemColumns = [ { title: "文件名", dataIndex: "sourceFileId", key: "sourceFileId", fixed: "left" as const, width: 260, ellipsis: true, render: (_: string, record: KnowledgeItemRow) => { const displayName = record.displayName || resolveDisplayName(record); if (record.isDirectory) { return ( ); } return (
{displayName}
); }, }, { title: "类型", dataIndex: "contentType", key: "contentType", width: 120, render: (contentType: string, record: KnowledgeItemRow) => { if (record.isDirectory) { return "文件夹"; } return ( knowledgeContentTypeOptions.find((opt) => opt.value === contentType)?.label || contentType ); }, }, { title: "来源", dataIndex: "sourceType", key: "sourceType", width: 140, ellipsis: true, render: (sourceType: string, record: KnowledgeItemRow) => { if (record.isDirectory) { return "-"; } return ( knowledgeSourceTypeOptions.find((opt) => opt.value === sourceType)?.label || sourceType || "-" ); }, }, { title: "更新时间", dataIndex: "updatedAt", key: "updatedAt", width: 180, ellipsis: true, render: (value: string, record: KnowledgeItemRow) => record.isDirectory ? "-" : value || "-", }, { title: "操作", key: "actions", width: 200, render: (_: unknown, record: KnowledgeItemRow) => { if (record.isDirectory) { const displayName = record.displayName || record.sourceFileId || ""; return (
{normalizedPrefix && ( )} {normalizedPrefix && ( 当前路径: {normalizedPrefix} )}
{itemRows.length === 0 ? ( ) : ( `共 ${total} 条`, onChange: (page, pageSize) => setFilePagination({ current: page, pageSize: pageSize || filePagination.pageSize, }), }} scroll={{ y: "calc(100vh - 36rem)" }} /> )} { setItemEditorOpen(false); setCurrentItem(null); }} onSuccess={() => { setItemEditorOpen(false); setCurrentItem(null); fetchItems(); }} /> 关闭 , ]} width={previewFileType === "text" ? PREVIEW_MODAL_WIDTH.text : PREVIEW_MODAL_WIDTH.media} > {previewFileType === "text" && (
            {previewContent}
          
)} {previewFileType === "image" && (
{previewFileName}
)} {previewFileType === "pdf" && ( <> {previewMediaUrl ? (