diff --git a/frontend/src/app/knowledge-bases/page.tsx b/frontend/src/app/knowledge-bases/page.tsx index b64e6d06..2e88e992 100644 --- a/frontend/src/app/knowledge-bases/page.tsx +++ b/frontend/src/app/knowledge-bases/page.tsx @@ -1,11 +1,37 @@ "use client"; import Link from "next/link"; -import { InboxOutlined, ReloadOutlined } from "@ant-design/icons"; -import { Alert, Button, Card, Divider, Empty, Form, Input, Modal, Select, Space, Spin, Table, Tag, Typography, Upload, message } from "antd"; +import { + CaretDownOutlined, + CaretRightOutlined, + InboxOutlined, + MinusCircleOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + Alert, + Button, + Divider, + Drawer, + Empty, + Form, + Input, + List, + Modal, + Select, + Space, + Spin, + Tag, + Tree, + Typography, + Upload, + message, +} from "antd"; +import type { DataNode } from "antd/es/tree"; import type { UploadFile } from "antd/es/upload/interface"; import { useCallback, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; import { createKnowledgeBase, @@ -19,25 +45,26 @@ import { uploadKnowledgeBaseDocument, } from "@/services/knowledge-base"; import { useAuthStore } from "@/stores/auth.store"; -import type { KnowledgeBaseDocumentItem, KnowledgeBaseItem, KnowledgeBaseVersionItem } from "@/types/api"; +import type { + ChatSourceItem, + KnowledgeBaseDocumentItem, + KnowledgeBaseItem, + KnowledgeBaseVersionItem, +} from "@/types/api"; const { Dragger } = Upload; -const { Title, Text } = Typography; +const { Title, Text, Paragraph } = Typography; type KnowledgeBaseFormValues = { name: string; description?: string; - note?: string; - source_type?: string; - authority?: string; - effective_date?: string; - region?: string; - tags?: string; }; + type KnowledgeBaseRollbackFormValues = { target_version_label: string; change_summary?: string; }; + type KnowledgeBaseGovernanceCheckStatus = "pass" | "warning" | "block"; type KnowledgeBaseGovernanceCheckItem = { key: string; @@ -45,6 +72,7 @@ type KnowledgeBaseGovernanceCheckItem = { status: KnowledgeBaseGovernanceCheckStatus; detail: string; }; + type KnowledgeBaseVersionComparisonRow = { key: string; label: string; @@ -53,45 +81,6 @@ type KnowledgeBaseVersionComparisonRow = { changed: boolean; }; -type SimpleListProps = { - bordered?: boolean; - dataSource: T[]; - header?: ReactNode; - loading?: boolean; - locale?: { emptyText?: ReactNode }; - renderItem: (item: T, index: number) => ReactNode; - size?: "small" | "default"; -}; - -function SimpleList({ bordered, dataSource, header, loading, locale, renderItem, size = "default" }: SimpleListProps) { - if (loading) { - return ( -
- -
- ); - } - - if (!dataSource.length) { - return
{locale?.emptyText ?? "暂无数据"}
; - } - - return ( -
- {header ?
{header}
: null} - {dataSource.map((item, index) => ( -
- {renderItem(item, index)} -
- ))} -
- ); -} - -function SimpleListItem({ children, className }: { children: ReactNode; className?: string }) { - return
{children}
; -} - const DOCUMENT_STATUS_META: Record = { pending: { color: "gold", label: "待处理" }, processing: { color: "blue", label: "处理中" }, @@ -109,30 +98,20 @@ const LIFECYCLE_STATUS_META: Record = }; function formatLifecycleBadge(value: string | undefined) { - if (!value) { - return -; - } + if (!value) return -; const meta = LIFECYCLE_STATUS_META[value] ?? { color: "default", label: value }; return {meta.label}; } function getStageTone(record: KnowledgeBaseDocumentItem) { - if (record.status === "failed" || record.failure_reason) { - return "danger" as const; - } - if (record.index_status === "blocked" || record.parse_status === "partial") { - return "warning" as const; - } - if (record.status === "indexed") { - return "success" as const; - } + if (record.status === "failed" || record.failure_reason) return "danger" as const; + if (record.index_status === "blocked" || record.parse_status === "partial") return "warning" as const; + if (record.status === "indexed") return "success" as const; return "info" as const; } function getStageLabel(record: KnowledgeBaseDocumentItem) { - if (record.index_status === "blocked") { - return `${record.current_stage_label || "处理中"} · 索引阻塞`; - } + if (record.index_status === "blocked") return `${record.current_stage_label || "处理中"} · 索引阻塞`; return record.current_stage_label || "-"; } @@ -142,56 +121,29 @@ function formatDateTime(value: string) { function getRetrievalModeMeta(value: string | null | undefined) { const mode = (value || "").toLowerCase(); - - if (mode === "hybrid") { - return { color: "purple", label: "Hybrid" }; - } - if (mode === "vector") { - return { color: "cyan", label: "Vector" }; - } - if (mode === "bm25") { - return { color: "blue", label: "BM25" }; - } - if (mode === "local-bm25") { - return { color: "gold", label: "Legacy Local BM25" }; - } - + if (mode === "hybrid") return { color: "purple", label: "Hybrid" }; + if (mode === "vector") return { color: "cyan", label: "Vector" }; + if (mode === "bm25") return { color: "blue", label: "BM25" }; + if (mode === "local-bm25") return { color: "gold", label: "Legacy Local BM25" }; return { color: "default", label: value || "Default" }; } function getKnowledgeBaseStatusMeta(value: string | null | undefined) { const normalized = (value || "").toLowerCase(); - if (normalized === "active") { - return { color: "green", label: "已发布" }; - } - if (normalized === "archived") { - return { color: "default", label: "已下线" }; - } - if (normalized === "draft") { - return { color: "gold", label: "草稿" }; - } + if (normalized === "active") return { color: "green", label: "已发布" }; + if (normalized === "archived") return { color: "default", label: "已下线" }; + if (normalized === "draft") return { color: "gold", label: "草稿" }; return { color: "blue", label: value || "未命名状态" }; } -function getErrorMessage(error: unknown, fallback: string) { - return error instanceof Error && error.message ? error.message : fallback; -} - function formatDisplayText(value: string | null | undefined, empty = "未填写") { - if (!value) { - return empty; - } - const trimmed = value.trim(); - return trimmed ? trimmed : empty; + if (!value) return empty; + return value.trim() ? value.trim() : empty; } function getGovernanceCheckMeta(status: KnowledgeBaseGovernanceCheckStatus) { - if (status === "pass") { - return { color: "success", label: "通过" }; - } - if (status === "warning") { - return { color: "warning", label: "注意" }; - } + if (status === "pass") return { color: "success", label: "通过" }; + if (status === "warning") return { color: "warning", label: "注意" }; return { color: "error", label: "阻塞" }; } @@ -219,13 +171,8 @@ function isDocumentRetryable(document: KnowledgeBaseDocumentItem) { function getDocumentActionState(document: KnowledgeBaseDocumentItem) { if (isDocumentReadyForChat(document)) { - return { - readyForChat: true, - disabledReason: null, - retryable: false, - }; + return { readyForChat: true, disabledReason: null, retryable: false }; } - if (isDocumentFailedLike(document)) { return { readyForChat: false, @@ -233,7 +180,6 @@ function getDocumentActionState(document: KnowledgeBaseDocumentItem) { retryable: isDocumentRetryable(document), }; } - return { readyForChat: false, disabledReason: document.next_action || "当前文档尚未完成索引,暂时不能进入问答。", @@ -261,16 +207,34 @@ function buildKnowledgeBaseChatHref( focusType: "knowledge_document", }, }; - return `/chat?review=${encodeURIComponent(JSON.stringify(payload))}`; } +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function getSourceTypeLabel(sourceType: string | null | undefined) { + if (!sourceType) return "未分类"; + const typeMap: Record = { + legal: "法律法规", + policy: "制度规范", + case: "判例", + guide: "业务指南", + }; + return typeMap[sourceType] ?? sourceType; +} + +const ALL_KEY = "__all__"; + export default function KnowledgeBasesPage() { const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); + const [createForm] = Form.useForm(); const [uploadForm] = Form.useForm(); const [rollbackForm] = Form.useForm(); + const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [uploading, setUploading] = useState(false); @@ -281,219 +245,22 @@ export default function KnowledgeBasesPage() { const [retryingDocumentId, setRetryingDocumentId] = useState(null); const [listError, setListError] = useState(null); const [documentsError, setDocumentsError] = useState(null); + const [items, setItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [documents, setDocuments] = useState([]); const [versions, setVersions] = useState([]); const [versionsLoading, setVersionsLoading] = useState(false); - const [sourceTypeFilter, setSourceTypeFilter] = useState("all"); - const [regionFilter, setRegionFilter] = useState("all"); const [documentsLoading, setDocumentsLoading] = useState(false); const [fileList, setFileList] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(ALL_KEY); + const [expandedKeys, setExpandedKeys] = useState([ALL_KEY]); + const rollbackTargetVersionLabel = Form.useWatch("target_version_label", rollbackForm); - - const selectedKnowledgeBase = useMemo( - () => items.find((item) => item.id === selectedId) ?? null, - [items, selectedId], - ); - - const filteredDocuments = useMemo(() => { - return documents.filter((item) => { - const sourceTypeMatch = sourceTypeFilter === "all" || (item.source_type || "unknown") === sourceTypeFilter; - const regionMatch = regionFilter === "all" || (item.region || "unknown") === regionFilter; - return sourceTypeMatch && regionMatch; - }); - }, [documents, regionFilter, sourceTypeFilter]); - - const orderedDocuments = useMemo(() => { - return [...filteredDocuments].sort((left, right) => { - const leftPriority = isDocumentFailedLike(left) ? 0 : left.status === "processing" || left.status === "pending" ? 1 : 2; - const rightPriority = isDocumentFailedLike(right) ? 0 : right.status === "processing" || right.status === "pending" ? 1 : 2; - if (leftPriority !== rightPriority) { - return leftPriority - rightPriority; - } - return new Date(right.created_at).getTime() - new Date(left.created_at).getTime(); - }); - }, [filteredDocuments]); - - const failedDocuments = useMemo( - () => orderedDocuments.filter((document) => isDocumentFailedLike(document)), - [orderedDocuments], - ); - - const sourceTypeOptions = useMemo(() => { - const values = Array.from(new Set(documents.map((item) => item.source_type || "unknown"))); - return [{ value: "all", label: "全部来源类型" }, ...values.map((value) => ({ value, label: value }))]; - }, [documents]); - - const regionOptions = useMemo(() => { - const values = Array.from(new Set(documents.map((item) => item.region || "unknown"))); - return [{ value: "all", label: "全部适用区域" }, ...values.map((value) => ({ value, label: value }))]; - }, [documents]); - - const selectedKnowledgeBaseStatus = getKnowledgeBaseStatusMeta(selectedKnowledgeBase?.status); - const rollbackVersionOptions = useMemo( - () => - versions.map((item) => ({ - value: item.version_label, - label: `${item.version_label} · ${item.status}`, - })), - [versions], - ); - - const rollbackTargetVersion = useMemo( - () => versions.find((item) => item.version_label === rollbackTargetVersionLabel) ?? null, - [rollbackTargetVersionLabel, versions], - ); - const publishActionLabel = useMemo(() => { - if (!selectedKnowledgeBase) { - return "发布知识库"; - } - return selectedKnowledgeBase.status?.toLowerCase() === "active" ? "发布新版本" : "发布知识库"; - }, [selectedKnowledgeBase]); - const publishPreflightChecks = useMemo(() => { - if (!selectedKnowledgeBase) { - return []; - } - - const totalDocuments = selectedKnowledgeBase.document_count ?? 0; - const indexedDocuments = documents.filter((item) => isDocumentReadyForChat(item)).length; - const failedDocumentsCount = documents.filter((item) => isDocumentFailedLike(item)).length; - const inFlightDocuments = documents.filter((item) => item.status === "pending" || item.status === "processing").length; - const documentsMissingSourceMetadata = documents.filter( - (item) => !item.source_type || !item.authority || !item.effective_date || !item.region || !(item.tags?.length), - ).length; - - const checks: KnowledgeBaseGovernanceCheckItem[] = [ - { - key: "document-count", - label: "知识文档数量", - status: totalDocuments > 0 ? "pass" : "block", - detail: totalDocuments > 0 ? `当前已纳入 ${totalDocuments} 份知识文档。` : "当前知识库还没有文档,无法形成可发布知识集合。", - }, - { - key: "source-metadata", - label: "法源元数据完整度", - status: documentsMissingSourceMetadata > 0 ? "warning" : "pass", - detail: - documentsMissingSourceMetadata > 0 - ? `${documentsMissingSourceMetadata} 份文档缺少 source_type / authority / effective_date / region / tags 中的至少一项,建议补齐后再发版。` - : "文档的法源元数据已补齐,便于后续治理和追溯。", - }, - { - key: "description", - label: "知识库说明", - status: selectedKnowledgeBase.description?.trim() ? "pass" : "warning", - detail: selectedKnowledgeBase.description?.trim() - ? "知识库说明已填写,便于团队理解本次发布范围。" - : "当前知识库说明为空,建议补一段适用范围说明。", - }, - { - key: "versioning", - label: "版本快照", - status: "pass", - detail: versions.length > 0 ? `已有 ${versions.length} 个历史版本,本次会继续生成新的治理快照。` : "这是首次发布,系统会自动生成 v1 治理快照。", - }, - ]; - - if (totalDocuments === 0) { - checks.splice(1, 0, { - key: "index-health", - label: "索引完成度", - status: "block", - detail: "没有可供检查的索引结果,请先上传并完成文档处理。", - }); - return checks; - } - - checks.splice(1, 0, { - key: "index-health", - label: "索引完成度", - status: failedDocumentsCount > 0 || inFlightDocuments > 0 || indexedDocuments !== totalDocuments ? "block" : "pass", - detail: - failedDocumentsCount > 0 || inFlightDocuments > 0 || indexedDocuments !== totalDocuments - ? `已索引 ${indexedDocuments}/${totalDocuments},处理中 ${inFlightDocuments},失败 ${failedDocumentsCount}。发布前需要全部文档处于可检索状态。` - : `全部 ${totalDocuments} 份文档均已完成索引,可参与正式检索。`, - }); - - return checks; - }, [documents, selectedKnowledgeBase, versions.length]); - const publishBlockerCount = useMemo( - () => publishPreflightChecks.filter((item) => item.status === "block").length, - [publishPreflightChecks], - ); - const publishWarningCount = useMemo( - () => publishPreflightChecks.filter((item) => item.status === "warning").length, - [publishPreflightChecks], - ); - const versionComparisonRows = useMemo(() => { - if (!selectedKnowledgeBase || !rollbackTargetVersion) { - return []; - } - - return [ - { - key: "version-label", - label: "版本标签", - current: formatDisplayText(selectedKnowledgeBase.current_version_label, "未发布"), - target: rollbackTargetVersion.version_label, - changed: selectedKnowledgeBase.current_version_label !== rollbackTargetVersion.version_label, - }, - { - key: "status", - label: "生命周期状态", - current: selectedKnowledgeBaseStatus.label, - target: getKnowledgeBaseStatusMeta(rollbackTargetVersion.status).label, - changed: (selectedKnowledgeBase.status || "").toLowerCase() !== (rollbackTargetVersion.status || "").toLowerCase(), - }, - { - key: "name", - label: "知识库名称", - current: formatDisplayText(selectedKnowledgeBase.name, "-"), - target: formatDisplayText(rollbackTargetVersion.name, "-"), - changed: selectedKnowledgeBase.name !== rollbackTargetVersion.name, - }, - { - key: "description", - label: "知识库说明", - current: formatDisplayText(selectedKnowledgeBase.description), - target: formatDisplayText(rollbackTargetVersion.description), - changed: formatDisplayText(selectedKnowledgeBase.description) !== formatDisplayText(rollbackTargetVersion.description), - }, - { - key: "retrieval-mode", - label: "检索模式", - current: getRetrievalModeMeta(selectedKnowledgeBase.retrieval_mode).label, - target: getRetrievalModeMeta(rollbackTargetVersion.retrieval_mode).label, - changed: (selectedKnowledgeBase.retrieval_mode || "").toLowerCase() !== (rollbackTargetVersion.retrieval_mode || "").toLowerCase(), - }, - { - key: "published-by", - label: "发布人", - current: formatDisplayText(selectedKnowledgeBase.published_by, "未记录"), - target: formatDisplayText(rollbackTargetVersion.published_by, "未记录"), - changed: formatDisplayText(selectedKnowledgeBase.published_by, "未记录") !== formatDisplayText(rollbackTargetVersion.published_by, "未记录"), - }, - { - key: "published-at", - label: "发布时间", - current: selectedKnowledgeBase.published_at ? formatDateTime(selectedKnowledgeBase.published_at) : "未发布", - target: rollbackTargetVersion.published_at ? formatDateTime(rollbackTargetVersion.published_at) : "未发布", - changed: (selectedKnowledgeBase.published_at || "") !== (rollbackTargetVersion.published_at || ""), - }, - { - key: "change-summary", - label: "版本说明", - current: selectedKnowledgeBase.status?.toLowerCase() === "active" ? "当前线上版本" : "当前草稿状态", - target: formatDisplayText(rollbackTargetVersion.change_summary, "未填写"), - changed: true, - }, - ]; - }, [rollbackTargetVersion, selectedKnowledgeBase, selectedKnowledgeBaseStatus.label]); - const changedVersionComparisonCount = useMemo( - () => versionComparisonRows.filter((item) => item.changed).length, - [versionComparisonRows], - ); + const selectedKnowledgeBase = useMemo(() => items.find((item) => item.id === selectedId) ?? null, [items, selectedId]); const loadKnowledgeBases = useCallback(async () => { if (currentUser?.role !== "admin") { @@ -579,10 +346,7 @@ export default function KnowledgeBasesPage() { }, [messageApi, rollbackForm]); useEffect(() => { - const timer = window.setTimeout(() => { - void loadKnowledgeBases(); - }, 0); - + const timer = window.setTimeout(() => void loadKnowledgeBases(), 0); return () => window.clearTimeout(timer); }, [loadKnowledgeBases]); @@ -594,37 +358,26 @@ export default function KnowledgeBasesPage() { setVersions([]); return; } - void Promise.all([loadDocuments(selectedId), loadVersions(selectedId)]); }, 0); - return () => window.clearTimeout(timer); }, [currentUser?.role, loadDocuments, loadVersions, selectedId]); useEffect(() => { - if (!selectedId || currentUser?.role !== "admin") { - return; - } - + if (!selectedId || currentUser?.role !== "admin") return; const hasInFlightDocument = documents.some((item) => item.status === "pending" || item.status === "processing"); - if (!hasInFlightDocument) { - return; - } - - const timer = window.setTimeout(() => { - void loadDocuments(selectedId, true); - }, 3000); - + if (!hasInFlightDocument) return; + const timer = window.setTimeout(() => void loadDocuments(selectedId, true), 3000); return () => window.clearTimeout(timer); }, [currentUser?.role, documents, loadDocuments, selectedId]); const handleRefreshDocuments = async () => { - if (!selectedId) { - return; - } + if (!selectedId) return; await loadDocuments(selectedId, true); }; + const handleOpenUploadDrawer = () => setDrawerOpen(true); + const handleCreate = async (values: KnowledgeBaseFormValues) => { setSubmitting(true); try { @@ -652,7 +405,6 @@ export default function KnowledgeBasesPage() { messageApi.error("请先选择知识库"); return; } - const selectedFile = fileList[0]?.originFileObj; if (!selectedFile) { messageApi.error("请先选择文档"); @@ -693,10 +445,7 @@ export default function KnowledgeBasesPage() { }; const handleRetryDocument = async (document: KnowledgeBaseDocumentItem) => { - if (!selectedId) { - return; - } - + if (!selectedId) return; setRetryingDocumentId(document.id); try { const result = await retryKnowledgeBaseDocument(selectedId, document.id, undefined, "页面触发重试"); @@ -718,9 +467,7 @@ export default function KnowledgeBasesPage() { }; const handlePublishKnowledgeBase = async () => { - if (!selectedId || !selectedKnowledgeBase) { - return; - } + if (!selectedId || !selectedKnowledgeBase) return; if (publishBlockerCount > 0) { messageApi.error(`发布前检查未通过,还有 ${publishBlockerCount} 个阻塞项需要先处理。`); return; @@ -742,9 +489,7 @@ export default function KnowledgeBasesPage() { }; const handleUnpublishKnowledgeBase = async () => { - if (!selectedId) { - return; - } + if (!selectedId) return; setUnpublishing(true); try { const result = await unpublishKnowledgeBase(selectedId); @@ -762,9 +507,7 @@ export default function KnowledgeBasesPage() { }; const handleRollbackKnowledgeBase = async () => { - if (!selectedId) { - return; - } + if (!selectedId) return; const values = await rollbackForm.validateFields(); setRollingBack(true); try { @@ -789,590 +532,510 @@ export default function KnowledgeBasesPage() { } }; + const filteredItems = useMemo(() => { + let result = items; + if (selectedCategory !== ALL_KEY) { + result = result.filter((item) => { + if (selectedCategory === "active") return item.status?.toLowerCase() === "active"; + if (selectedCategory === "draft") return item.status?.toLowerCase() === "draft"; + if (selectedCategory === "archived") return item.status?.toLowerCase() === "archived"; + if (selectedCategory.startsWith("retrieval:")) return item.retrieval_mode === selectedCategory.replace("retrieval:", ""); + return true; + }); + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.name.toLowerCase().includes(q) || + (item.description ?? "").toLowerCase().includes(q), + ); + } + return result; + }, [items, selectedCategory, searchQuery]); + + const treeData = useMemo((): DataNode[] => { + const activeCount = items.filter((item) => item.status?.toLowerCase() === "active").length; + const draftCount = items.filter((item) => item.status?.toLowerCase() === "draft").length; + const archivedCount = items.filter((item) => item.status?.toLowerCase() === "archived").length; + + const retrievalModes = Array.from(new Set(items.map((item) => item.retrieval_mode).filter(Boolean))); + const retrievalChildren: DataNode[] = retrievalModes.map((mode) => { + const count = items.filter((item) => item.retrieval_mode === mode).length; + return { + key: `retrieval:${mode}`, + title: ( + + {getRetrievalModeMeta(mode).label} + {count} + + ), + }; + }); + + return [ + { + key: ALL_KEY, + title: ( + + 全部知识库 + {items.length} + + ), + children: [ + { key: "group-status", title: 按状态, selectable: false, children: [ + { key: "active", title: ( + 已发布{activeCount} + )}, + { key: "draft", title: ( + 草稿{draftCount} + )}, + { key: "archived", title: ( + 已下线{archivedCount} + )}, + ]}, + retrievalChildren.length > 0 ? { key: "group-retrieval", title: 按检索模式, selectable: false, children: retrievalChildren } : null, + ].filter(Boolean) as DataNode[], + }, + ]; + }, [items]); + + const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => { + const key = info.node.key as string; + setSelectedCategory(key); + }; + + const selectedKnowledgeBaseStatus = getKnowledgeBaseStatusMeta(selectedKnowledgeBase?.status); + const rollbackVersionOptions = useMemo( + () => versions.map((item) => ({ value: item.version_label, label: `${item.version_label} · ${item.status}` })), + [versions], + ); + const rollbackTargetVersion = useMemo( + () => versions.find((item) => item.version_label === rollbackTargetVersionLabel) ?? null, + [rollbackTargetVersionLabel, versions], + ); + const publishActionLabel = useMemo(() => { + if (!selectedKnowledgeBase) return "发布知识库"; + return selectedKnowledgeBase.status?.toLowerCase() === "active" ? "发布新版本" : "发布知识库"; + }, [selectedKnowledgeBase]); + + const publishPreflightChecks = useMemo(() => { + if (!selectedKnowledgeBase) return []; + const totalDocuments = selectedKnowledgeBase.document_count ?? 0; + const indexedDocuments = documents.filter((item) => isDocumentReadyForChat(item)).length; + const failedDocumentsCount = documents.filter((item) => isDocumentFailedLike(item)).length; + const inFlightDocuments = documents.filter((item) => item.status === "pending" || item.status === "processing").length; + const documentsMissingSourceMetadata = documents.filter((item) => !item.source_type || !item.authority || !item.effective_date || !item.region || !(item.tags?.length)).length; + + const checks: KnowledgeBaseGovernanceCheckItem[] = [ + { + key: "document-count", + label: "知识文档数量", + status: totalDocuments > 0 ? "pass" : "block", + detail: totalDocuments > 0 ? `当前已纳入 ${totalDocuments} 份知识文档。` : "当前知识库还没有文档,无法形成可发布知识集合。", + }, + { + key: "source-metadata", + label: "法源元数据完整度", + status: documentsMissingSourceMetadata > 0 ? "warning" : "pass", + detail: documentsMissingSourceMetadata > 0 + ? `${documentsMissingSourceMetadata} 份文档缺少 source_type / authority / effective_date / region / tags 中的至少一项。` + : "文档的法源元数据已补齐。", + }, + { + key: "description", + label: "知识库说明", + status: selectedKnowledgeBase.description?.trim() ? "pass" : "warning", + detail: selectedKnowledgeBase.description?.trim() ? "知识库说明已填写。" : "当前知识库说明为空。", + }, + { + key: "versioning", + label: "版本快照", + status: "pass", + detail: versions.length > 0 ? `已有 ${versions.length} 个历史版本。` : "这是首次发布。", + }, + ]; + + if (totalDocuments > 0) { + checks.splice(1, 0, { + key: "index-health", + label: "索引完成度", + status: failedDocumentsCount > 0 || inFlightDocuments > 0 || indexedDocuments !== totalDocuments ? "block" : "pass", + detail: failedDocumentsCount > 0 || inFlightDocuments > 0 || indexedDocuments !== totalDocuments + ? `已索引 ${indexedDocuments}/${totalDocuments},处理中 ${inFlightDocuments},失败 ${failedDocumentsCount}。` + : `全部 ${totalDocuments} 份文档均已完成索引。`, + }); + } else { + checks.splice(1, 0, { + key: "index-health", + label: "索引完成度", + status: "block", + detail: "没有可供检查的索引结果,请先上传并完成文档处理。", + }); + } + + return checks; + }, [documents, selectedKnowledgeBase, versions.length]); + + const publishBlockerCount = useMemo(() => publishPreflightChecks.filter((item) => item.status === "block").length, [publishPreflightChecks]); + const publishWarningCount = useMemo(() => publishPreflightChecks.filter((item) => item.status === "warning").length, [publishPreflightChecks]); + const failedDocuments = useMemo(() => documents.filter((item) => isDocumentFailedLike(item)), [documents]); + + const versionComparisonRows = useMemo(() => { + if (!selectedKnowledgeBase || !rollbackTargetVersion) return []; + return [ + { key: "version-label", label: "版本标签", current: formatDisplayText(selectedKnowledgeBase.current_version_label, "未发布"), target: rollbackTargetVersion.version_label, changed: selectedKnowledgeBase.current_version_label !== rollbackTargetVersion.version_label }, + { key: "status", label: "生命周期状态", current: selectedKnowledgeBaseStatus.label, target: getKnowledgeBaseStatusMeta(rollbackTargetVersion.status).label, changed: (selectedKnowledgeBase.status || "").toLowerCase() !== (rollbackTargetVersion.status || "").toLowerCase() }, + { key: "name", label: "知识库名称", current: formatDisplayText(selectedKnowledgeBase.name, "-"), target: formatDisplayText(rollbackTargetVersion.name, "-"), changed: selectedKnowledgeBase.name !== rollbackTargetVersion.name }, + { key: "description", label: "知识库说明", current: formatDisplayText(selectedKnowledgeBase.description), target: formatDisplayText(rollbackTargetVersion.description), changed: formatDisplayText(selectedKnowledgeBase.description) !== formatDisplayText(rollbackTargetVersion.description) }, + { key: "retrieval-mode", label: "检索模式", current: getRetrievalModeMeta(selectedKnowledgeBase.retrieval_mode).label, target: getRetrievalModeMeta(rollbackTargetVersion.retrieval_mode).label, changed: (selectedKnowledgeBase.retrieval_mode || "").toLowerCase() !== (rollbackTargetVersion.retrieval_mode || "").toLowerCase() }, + { key: "published-by", label: "发布人", current: formatDisplayText(selectedKnowledgeBase.published_by, "未记录"), target: formatDisplayText(rollbackTargetVersion.published_by, "未记录"), changed: formatDisplayText(selectedKnowledgeBase.published_by, "未记录") !== formatDisplayText(rollbackTargetVersion.published_by, "未记录") }, + { key: "published-at", label: "发布时间", current: selectedKnowledgeBase.published_at ? formatDateTime(selectedKnowledgeBase.published_at) : "未发布", target: rollbackTargetVersion.published_at ? formatDateTime(rollbackTargetVersion.published_at) : "未发布", changed: (selectedKnowledgeBase.published_at || "") !== (rollbackTargetVersion.published_at || "") }, + ]; + }, [rollbackTargetVersion, selectedKnowledgeBase, selectedKnowledgeBaseStatus.label]); + + const changedVersionComparisonCount = useMemo(() => versionComparisonRows.filter((item) => item.changed).length, [versionComparisonRows]); + if (currentUser?.role !== "admin") { return ( -
+
{contextHolder} - - - +
+ 仅管理员可管理知识库。 +
); } return ( -
+
{contextHolder} - -
-
- Knowledge Base - 管理知识库、文档与索引状态。 - 左侧选择知识库,右侧上传和追踪文档处理结果,失败原因直接暴露。 +
+ {/* 左侧树形菜单 */} + - - -
- -
- - -
- - - - - - - -
-
- - - {listError ? ( - - - + + - ) : ( - }} - dataSource={items} - renderItem={(item) => ( - - - - )} - /> - )} - -
+
- - - - {selectedKnowledgeBase ? ( - <> - - {selectedKnowledgeBaseStatus.label} - - {getRetrievalModeMeta(selectedKnowledgeBase.retrieval_mode).label} - - {selectedKnowledgeBase.current_version_label ? 当前版本 {selectedKnowledgeBase.current_version_label} : null} - - {selectedKnowledgeBase.description || "当前知识库暂无说明。"} - - 发布人:{selectedKnowledgeBase.published_by || "-"} - 发布时间:{selectedKnowledgeBase.published_at ? formatDateTime(selectedKnowledgeBase.published_at) : "-"} - - 0 ? "error" : publishWarningCount > 0 ? "warning" : "success"} - title={`${publishActionLabel}前检查:通过 ${publishPreflightChecks.filter((item) => item.status === "pass").length} 项,注意 ${publishWarningCount} 项,阻塞 ${publishBlockerCount} 项`} - description="这一步先把当前知识库的文档、索引与法源元数据过一遍,避免带着未完成状态直接进生产可用版本。" - /> - { - const meta = getGovernanceCheckMeta(item.status); - return ( - - - - {item.label} - {meta.label} - - {item.detail} + {/* 发布检查 */} + {publishPreflightChecks.length > 0 && ( +
+ 发布前检查 + { + const meta = getGovernanceCheckMeta(item.status); + return ( + + + {meta.label} + {item.label} + {item.detail} + + + ); + }} + /> +
+ )} + + {/* 文档列表 */} +
+ 知识文档({documents.length} 份) + {documentsLoading ? ( +
+ ) : documents.length > 0 ? ( + ( + +
+
+ + {document.file_name} + {renderStatusTag(document.status, DOCUMENT_STATUS_META)} + {formatLifecycleBadge(document.parse_status)} + {formatLifecycleBadge(document.index_status)} - - ); - }} - /> - - - - -
- - - -
- 版本对比与回滚确认 - {rollbackTargetVersion ? ( - ( - - - - {item.label} - {item.changed ? "有差异" : "一致"} +
+ + {document.source_type || getSourceTypeLabel(document.source_type)} + {document.authority && {document.authority}} + {document.region && {document.region}} - 当前:{item.current} - 目标:{item.target} - - - )} - /> - ) : ( - +
+ {document.content_excerpt ? ( + + {document.content_excerpt}({document.extracted_text_length} 字) + + ) : document.failure_reason ? ( + {document.failure_reason} + ) : null} + {document.tags?.length > 0 && ( + + {document.tags.slice(0, 5).map((tag) => {tag})} + + )} +
+ + {isDocumentRetryable(document) && ( + + )} + +
+
)} + /> + ) : ( + + + + )} +
+ + {/* 失败文档恢复 */} + {failedDocuments.length > 0 && ( +
+ 异常文档恢复({failedDocuments.length} 份) + ( + + + + {document.file_name} + 失败 + + {document.error_message || document.failure_reason} + + + + )} + /> +
+ )} + + {/* 版本历史 */} + {versions.length > 0 && ( +
+ 版本历史 + ( + + + + {item.version_label} + {getKnowledgeBaseStatusMeta(item.status).label} + + {item.change_summary || "无变更说明"} + + {item.published_at ? formatDateTime(item.published_at) : formatDateTime(item.created_at)} · {item.published_by || "-"} + + + + )} + /> +
+ )} + + {/* 回滚 */} + {versions.length > 1 && ( +
+
+ + + - ( - - - - {item.version_label} - {getKnowledgeBaseStatusMeta(item.status).label} - - {item.change_summary || "无变更说明"} - - {item.published_at ? formatDateTime(item.published_at) : formatDateTime(item.created_at)} - {" · "} - {item.published_by || "-"} - - - - )} - /> - - ) : ( - - )} - - - - - - - false} - fileList={fileList} - maxCount={1} - multiple={false} - accept=".txt,.md,.csv,.json,.docx,.pdf" - onChange={({ fileList: nextFileList }) => setFileList(nextFileList)} - > -

- -

-

点击或拖拽上传知识文档

-

支持 txt、md、csv、json、docx、pdf,上传后展示提取摘要和索引状态。

-
- - - - - - - - - - - - - - - - - - - - - -
-
- - - {failedDocuments.length ? ( - { - const actionState = getDocumentActionState(document); - return ( - - - - {document.file_name} - 失败文档 - {formatLifecycleBadge(document.parse_status)} - {formatLifecycleBadge(document.index_status)} - - {document.error_message || document.failure_reason || "文档处理失败"} - {document.status_detail || "请检查文件内容或格式后重试。"} - {document.next_action ? 下一步:{document.next_action} : null} - - { - event.preventDefault(); - messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); - }} - > - 审查要点 - - { - event.preventDefault(); - messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); - }} - > - 继续问答 - - - - - - ); - }} - /> - ) : ( - + +
)} -
+ + ) : ( + + + + )} + + - - - -
- } - > - {documentsError ? ( - } onClick={() => void handleRefreshDocuments()} disabled={!selectedId}> - 重试 - - } - /> - ) : null} - - : , - }} - columns={[ - { - title: "文件名", - dataIndex: "file_name", - key: "file_name", - }, - { - title: "状态 / 阶段", - dataIndex: "status", - key: "status", - width: 180, - render: (_value: string, record: KnowledgeBaseDocumentItem) => { - const meta = getDocumentStatusMeta(record.status); - return ( - - {meta.label} - - {getStageLabel(record)} - - - ); - }, - }, - { - title: "大小", - dataIndex: "file_size", - key: "file_size", - width: 120, - render: (value: number) => `${value} B`, - }, - { - title: "法源元数据", - key: "source_meta", - width: 260, - render: (_value: unknown, record: KnowledgeBaseDocumentItem) => ( - - {record.source_type || "-"} - {record.authority || "-"} - {record.effective_date || "-"} - {record.region || "-"} - {record.tags?.length ? ( - - {record.tags.map((tag) => ( - {tag} - ))} - - ) : null} - - ), - }, - { - title: "摘要 / 生命周期说明", - dataIndex: "content_excerpt", - key: "content_excerpt", - render: (value: string | null, record: KnowledgeBaseDocumentItem) => { - if (record.status === "failed") { - return ( -
- {record.error_message || "文档处理失败"} -
- {record.status_detail || "请检查文件内容或格式后重试。"} -
- {record.next_action ? ( -
- 下一步:{record.next_action} -
- ) : null} -
- ); - } - - if (!value) { - return ( -
- - {record.status_detail || (record.status === "pending" || record.status === "processing" ? "等待处理结果" : "-")} - - {record.next_action ? ( -
- 下一步:{record.next_action} -
- ) : null} -
- ); - } - - return ( -
-
{value}
- - 提取文本 {record.extracted_text_length} 字{record.preview_truncated ? " · 已截断预览" : ""} - -
- {record.status_detail || "-"} -
- {record.next_action ? ( -
- 下一步:{record.next_action} -
- ) : null} -
- ); - }, - }, - { - title: "处理阶段", - key: "lifecycle", - width: 180, - render: (_value: unknown, record: KnowledgeBaseDocumentItem) => ( - -
- 解析: - {formatLifecycleBadge(record.parse_status)} -
-
- 索引: - {formatLifecycleBadge(record.index_status)} -
- {record.failure_reason ? 失败原因:{record.failure_reason} : null} -
- ), - }, - { - title: "上传时间", - dataIndex: "created_at", - key: "created_at", - width: 180, - render: (value: string) => formatDateTime(value), - }, - { - title: "继续问答", - key: "chat_actions", - width: 220, - render: (_value: unknown, record: KnowledgeBaseDocumentItem) => { - const actionState = getDocumentActionState(record); - const readyForChat = actionState.readyForChat; - return ( - - { - if (!readyForChat) { - event.preventDefault(); - messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); - } - }} - > - 审查要点 - - { - if (!readyForChat) { - event.preventDefault(); - messageApi.warning(actionState.disabledReason || "当前文档尚未完成索引,暂时不能进入问答。"); - } - }} - > - 继续问答 - - {actionState.retryable ? ( - - ) : null} - - ); - }, - }, - ]} - scroll={{ x: 1180 }} - /> - - - - + {/* 上传文档抽屉 */} + setDrawerOpen(false)} + width={560} + > +
+ false} + fileList={fileList} + maxCount={1} + multiple={false} + accept=".txt,.md,.csv,.json,.docx,.pdf" + onChange={({ fileList: nextFileList }) => setFileList(nextFileList)} + > +

+

点击或拖拽上传知识文档

+

支持 txt、md、csv、json、docx、pdf

+
+ + + + + + + + + + + + + + + + + + + + +
); } + +// Helper function for rendering status tags inline +function renderStatusTag(value: string, metaMap: Record) { + const meta = metaMap[value] ?? { color: "default", label: value || "未知" }; + return {meta.label}; +} diff --git a/frontend/src/app/legal-articles/page.tsx b/frontend/src/app/legal-articles/page.tsx index cb39a81d..11564957 100644 --- a/frontend/src/app/legal-articles/page.tsx +++ b/frontend/src/app/legal-articles/page.tsx @@ -1,8 +1,30 @@ -"use client"; +"use client"; -import { ExperimentOutlined, EyeOutlined, FileSearchOutlined, LinkOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; -import { Alert, Button, Card, Drawer, Empty, Form, Input, Select, Space, Statistic, Table, Tag, Typography, message } from "antd"; -import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { + CaretDownOutlined, + CaretRightOutlined, + ExperimentOutlined, + EyeOutlined, + FileSearchOutlined, + LinkOutlined, + ReloadOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + Button, + Drawer, + Empty, + Input, + List, + Select, + Space, + Spin, + Tag, + Tree, + Typography, + message, +} from "antd"; +import type { DataNode } from "antd/es/tree"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -30,30 +52,14 @@ const defaultFilters: LegalArticleFilterValues = { published_status: "all", }; -const sourceTypeOptions = [ - { value: "all", label: "全部法源类型" }, - { value: "法律", label: "法律" }, - { value: "行政法规", label: "行政法规" }, - { value: "司法解释", label: "司法解释" }, - { value: "地方法规", label: "地方法规" }, - { value: "部门规章", label: "部门规章" }, - { value: "规范性文件", label: "规范性文件" }, -]; - -const indexStatusOptions = [ - { value: "all", label: "全部索引状态" }, - { value: "indexed", label: "已索引" }, - { value: "pending", label: "待索引" }, - { value: "processing", label: "索引中" }, - { value: "failed", label: "索引失败" }, -]; - -const publishedStatusOptions = [ - { value: "all", label: "全部发布状态" }, - { value: "published", label: "已发布" }, - { value: "draft", label: "草稿" }, - { value: "archived", label: "已归档" }, -]; +const sourceTypeLabelMap: Record = { + 法律: "法律", + 行政法规: "行政法规", + 司法解释: "司法解释", + 地方法规: "地方法规", + 部门规章: "部门规章", + 规范性文件: "规范性文件", +}; const indexStatusMeta: Record = { indexed: { color: "green", label: "已索引" }, @@ -69,24 +75,16 @@ const publishedStatusMeta: Record = { }; function formatDateTime(value: string | null | undefined) { - if (!value) { - return "-"; - } + if (!value) return "-"; const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } + if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("zh-CN", { hour12: false }); } function formatDate(value: string | null | undefined) { - if (!value) { - return "-"; - } + if (!value) return "-"; const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } + if (Number.isNaN(date.getTime())) return value; return date.toLocaleDateString("zh-CN"); } @@ -99,39 +97,25 @@ function renderStatusTag(value: string, metaMap: Record{meta.label}; } -function normalizeFilters(values: LegalArticleFilterValues): LegalArticleFilterValues { - return { - keyword: values.keyword?.trim() ?? "", - source_id: values.source_id?.trim() ?? "", - source_type: values.source_type ?? "all", - article_no: values.article_no?.trim() ?? "", - index_status: values.index_status ?? "all", - published_status: values.published_status ?? "all", - }; -} - function getSemanticSearchReason(item: ChatSourceItem) { const reason = item.metadata?.match_reason; return typeof reason === "string" && reason.trim() ? reason : "混合语义命中"; } +const ALL_KEY = "__all__"; + export default function LegalArticlesPage() { const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); - const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); const [detailLoading, setDetailLoading] = useState(false); const [items, setItems] = useState([]); - const [filters, setFilters] = useState(defaultFilters); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); const [total, setTotal] = useState(0); - const [selectedArticle, setSelectedArticle] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [filters, setFilters] = useState(defaultFilters); const [vectorizing, setVectorizing] = useState(false); - const [semanticQuery, setSemanticQuery] = useState(""); - const [semanticSearching, setSemanticSearching] = useState(false); - const [semanticResults, setSemanticResults] = useState([]); const [vectorizationSummary, setVectorizationSummary] = useState({ total_articles: 0, vectorized_articles: 0, @@ -142,9 +126,16 @@ export default function LegalArticlesPage() { latest_article_title: null as string | null, }); - const currentPageSourceCount = useMemo(() => new Set(items.map((item) => item.source_id)).size, [items]); - const currentPageIndexedCount = useMemo(() => items.filter((item) => item.index_status === "indexed").length, [items]); - const currentPagePublishedCount = useMemo(() => items.filter((item) => item.published_status === "published").length, [items]); + const [selectedArticle, setSelectedArticle] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(ALL_KEY); + const [expandedKeys, setExpandedKeys] = useState([ALL_KEY]); + + const [semanticQuery, setSemanticQuery] = useState(""); + const [semanticSearching, setSemanticSearching] = useState(false); + const [semanticResults, setSemanticResults] = useState([]); const loadArticles = useCallback( async (nextPage = page, nextPageSize = pageSize, nextFilters = filters) => { @@ -179,9 +170,7 @@ export default function LegalArticlesPage() { const openArticleDetail = useCallback( async (articleId: string, fallback?: LegalArticleItem) => { - if (fallback) { - setSelectedArticle(fallback); - } + if (fallback) setSelectedArticle(fallback); setDrawerOpen(true); setDetailLoading(true); try { @@ -201,32 +190,17 @@ export default function LegalArticlesPage() { ); useEffect(() => { - const timer = window.setTimeout(() => { - void loadArticles(); - }, 0); - + const timer = window.setTimeout(() => void loadArticles(), 0); return () => window.clearTimeout(timer); }, [loadArticles]); useEffect(() => { - if (currentUser?.role !== "admin" || typeof window === "undefined") { - return; - } + if (currentUser?.role !== "admin" || typeof window === "undefined") return; const articleId = new URLSearchParams(window.location.search).get("article_id"); - if (articleId) { - void openArticleDetail(articleId); - } + if (articleId) void openArticleDetail(articleId); }, [currentUser?.role, openArticleDetail]); - const handleSearch = (values: LegalArticleFilterValues) => { - const nextFilters = normalizeFilters(values); - setFilters(nextFilters); - setLoading(true); - void loadArticles(1, pageSize, nextFilters); - }; - const handleReset = () => { - form.setFieldsValue(defaultFilters); setFilters(defaultFilters); setLoading(true); void loadArticles(1, pageSize, defaultFilters); @@ -235,11 +209,7 @@ export default function LegalArticlesPage() { const handleVectorize = async () => { setVectorizing(true); try { - const result = await vectorizeLegalArticlesByFilters({ - batch_size: 200, - force: false, - ...filters, - }); + const result = await vectorizeLegalArticlesByFilters({ batch_size: 200, force: false, ...filters }); if (result.code !== 0) { messageApi.error(result.message || "法条向量化失败"); return; @@ -276,358 +246,293 @@ export default function LegalArticlesPage() { } }; - const handleTableChange = (pagination: TablePaginationConfig) => { - const nextPage = pagination.current ?? 1; - const nextPageSize = pagination.pageSize ?? pageSize; - setLoading(true); - void loadArticles(nextPage, nextPageSize, filters); + const filteredItems = useMemo(() => { + let result = items; + if (selectedCategory !== ALL_KEY) { + result = result.filter((item) => item.source_type === selectedCategory); + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.source_title.toLowerCase().includes(q) || + item.article_no.toLowerCase().includes(q) || + (item.source_id ?? "").toLowerCase().includes(q) || + item.article_text.toLowerCase().includes(q), + ); + } + return result; + }, [items, selectedCategory, searchQuery]); + + const treeData = useMemo((): DataNode[] => { + const typeCounts: Record = {}; + for (const item of items) { + typeCounts[item.source_type] = (typeCounts[item.source_type] || 0) + 1; + } + + const children: DataNode[] = Object.entries(sourceTypeLabelMap) + .filter(([key]) => typeCounts[key]) + .map(([key, label]) => ({ + key, + title: ( + + {label} + {typeCounts[key] ?? 0} + + ), + })); + + return [ + { + key: ALL_KEY, + title: ( + + 全部法条 + {total} + + ), + children, + }, + ]; + }, [items, total]); + + const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => { + const key = info.node.key as string; + setSelectedCategory(key); }; - const semanticColumns: ColumnsType = [ - { - title: "命中法条", - key: "title", - width: 260, - render: (_, item) => ( - - {item.title} - {item.authority || "-"} - - ), - }, - { - title: "相关度", - dataIndex: "score", - key: "score", - width: 100, - render: (value?: number) => (typeof value === "number" ? value.toFixed(3) : "-"), - }, - { - title: "命中原因", - key: "reason", - width: 220, - render: (_, item) => {getSemanticSearchReason(item)}, - }, - { - title: "摘要", - dataIndex: "content", - key: "content", - render: (value: string) => ( - - {value} - - ), - }, - { - title: "操作", - key: "actions", - width: 120, - render: (_, item) => ( - - ), - }, - ]; + const getCategoryLabel = () => { + if (selectedCategory === ALL_KEY) return "全部法条"; + return sourceTypeLabelMap[selectedCategory] ?? selectedCategory; + }; - const columns: ColumnsType = [ - { - title: "法源", - dataIndex: "source_title", - key: "source_title", - width: 260, - render: (_, item) => ( - - {item.source_title} - {item.source_id} - {item.authority} - - ), - }, - { - title: "条文编号", - dataIndex: "article_no", - key: "article_no", - width: 120, - render: (value: string) => {value}, - }, - { - title: "章节", - key: "chapter", - width: 220, - render: (_, item) => ( - - {item.chapter_title || "-"} - {item.section_title || "-"} - - ), - }, - { - title: "法条摘要", - dataIndex: "article_text", - key: "article_text", - width: 360, - render: (value: string) => ( - - {value} - - ), - }, - { - title: "标签", - dataIndex: "tags", - key: "tags", - width: 180, - render: (value: string[]) => ( - - {value?.length ? value.slice(0, 3).map((tag) => {tag}) : -} - {value?.length > 3 ? +{value.length - 3} : null} - - ), - }, - { - title: "知识库吸收", - key: "knowledge", - width: 220, - render: (_, item) => ( - - {renderStatusTag(item.index_status, indexStatusMeta)} - 知识库:{item.knowledge_base_id || "-"} - 文档:{item.knowledge_document_id || "-"} - - ), - }, - { - title: "发布状态", - key: "published_status", - width: 160, - render: (_, item) => ( - - {renderStatusTag(item.published_status, publishedStatusMeta)} - {formatDateTime(item.published_at)} - - ), - }, - { - title: "生效范围", - key: "effective", - width: 160, - render: (_, item) => ( - - {item.region} - {formatDate(item.effective_date)} - {item.validity_status || "-"} - - ), - }, - { - title: "操作", - key: "actions", - fixed: "right", - width: 120, - render: (_, item) => ( - - ), - }, - ]; + const indexedCount = useMemo(() => items.filter((item) => item.index_status === "indexed").length, [items]); + const publishedCount = useMemo(() => items.filter((item) => item.published_status === "published").length, [items]); if (currentUser?.role !== "admin") { return ( -
+
{contextHolder} - - - +
+ 仅管理员可查看法条全文库。 +
); } return ( -
+
{contextHolder} - -
-
- Legal Fulltext Library - 法条全文库 - 查看 Phase14 已吸收入库的法律法规全文条款,核对法源、索引状态、发布状态和知识库追溯信息。 +
+ {/* 左侧树形菜单 */} +
+
+ + +
+ - - - - - - - - - - - - - - - } /> - - + {/* 右侧内容 */} +
+ {/* KPI 统计 */} +
+
+ 法条总数 + {total} +
+
+ 已索引 + {indexedCount} +
+
+ 已发布 + {publishedCount} +
+
+ 向量化覆盖 + {vectorizationSummary.coverage_percent.toFixed(1)}% +
+
- - + {/* 语义检索 */} +
setSemanticQuery(event.target.value)} + onChange={(e) => setSemanticQuery(e.target.value)} onSearch={(value) => void handleSemanticSearch(value)} placeholder="输入法条语义问题,例如:违约责任、付款条件、电子签章证据" value={semanticQuery} + style={{ marginBottom: 8 }} /> - -
`${item.source_id}-${item.detail_path}`} - columns={semanticColumns} - dataSource={semanticResults} - loading={semanticSearching} - locale={{ emptyText: }} - pagination={{ pageSize: 5, showSizeChanger: false }} - scroll={{ x: 1200 }} - /> - - + {semanticResults.length > 0 && ( + ( + + + {item.title} + {item.score != null && {item.score.toFixed(3)}} + {getSemanticSearchReason(item)} + + } + description={item.content ? {item.content} : null} + /> + {item.source_id && ( + + )} + + )} + /> + )} + - -
-
- - } /> - - - - - - - - - - + {/* 法条列表 */} +
+ {getCategoryLabel()} + {filteredItems.length} 条(共 {total} 条) +
+ + {loading ? ( +
+ + 正在加载法条列表...
- - - - - - - - - -
}} - onChange={handleTableChange} - scroll={{ x: 1720 }} - /> - - + ) : filteredItems.length > 0 ? ( + ( + +
+
+ + {item.source_title} + {item.article_no} + {renderStatusTag(item.index_status, indexStatusMeta)} + {renderStatusTag(item.published_status, publishedStatusMeta)} + {item.source_type} + {item.source_id} + +
+ + {item.article_text} + +
+ {item.tags?.length > 0 && ( + + {item.tags.slice(0, 5).map((tag) => {tag})} + {item.tags.length > 5 && +{item.tags.length - 5}} + + )} +
+ + + +
+
+ )} + /> + ) : ( + + + + )} + + + {/* 法条详情抽屉 */} setDrawerOpen(false)} > - - {selectedArticle ? ( - - - {renderStatusTag(selectedArticle.index_status, indexStatusMeta)} - {renderStatusTag(selectedArticle.published_status, publishedStatusMeta)} - {selectedArticle.source_type} - {selectedArticle.region} - -
- 法源 - - {selectedArticle.source_title} - - {selectedArticle.authority} -
-
- 章节 - - {[selectedArticle.chapter_title, selectedArticle.section_title].filter(Boolean).join(" / ") || "-"} - -
-
- 全文 - - {selectedArticle.article_text} - -
- - {selectedArticle.tags.length ? selectedArticle.tags.map((tag) => {tag}) : 暂无标签} - - - - 法源编号:{selectedArticle.source_id} - 生效日期:{formatDate(selectedArticle.effective_date)} - 有效性:{selectedArticle.validity_status || "-"} - 知识库:{selectedArticle.knowledge_base_id || "-"} - 知识文档:{selectedArticle.knowledge_document_id || "-"} - 向量版本:{selectedArticle.semantic_vector_version || "-"} - 向量维度:{selectedArticle.semantic_vector_terms ?? 0} - 向量更新时间:{formatDateTime(selectedArticle.semantic_vector_updated_at)} - 全文校验值:{formatHash(selectedArticle.text_checksum)} - 法源校验值:{formatHash(selectedArticle.source_checksum)} - 更新时间:{formatDateTime(selectedArticle.updated_at)} - {selectedArticle.official_url ? ( - - 打开官方来源 - - ) : null} - - + {detailLoading ? ( +
+ ) : selectedArticle ? ( + + + {renderStatusTag(selectedArticle.index_status, indexStatusMeta)} + {renderStatusTag(selectedArticle.published_status, publishedStatusMeta)} + {selectedArticle.source_type} + {selectedArticle.region} - ) : ( - - )} -
+
+ 法源 + {selectedArticle.source_title} + {selectedArticle.authority} +
+
+ 章节 + + {[selectedArticle.chapter_title, selectedArticle.section_title].filter(Boolean).join(" / ") || "-"} + +
+
+ 全文 + + {selectedArticle.article_text} + +
+ + {selectedArticle.tags.length ? selectedArticle.tags.map((tag) => {tag}) : 暂无标签} + +
+ 追溯信息 + + 法源编号:{selectedArticle.source_id} + 生效日期:{formatDate(selectedArticle.effective_date)} + 有效性:{selectedArticle.validity_status || "-"} + 知识库:{selectedArticle.knowledge_base_id || "-"} + 知识文档:{selectedArticle.knowledge_document_id || "-"} + 向量版本:{selectedArticle.semantic_vector_version || "-"} + 向量维度:{selectedArticle.semantic_vector_terms ?? 0} + 向量更新时间:{formatDateTime(selectedArticle.semantic_vector_updated_at)} + 全文校验值:{formatHash(selectedArticle.text_checksum)} + 更新时间:{formatDateTime(selectedArticle.updated_at)} + {selectedArticle.official_url ? ( + + 打开官方来源 + + ) : null} + +
+ + ) : ( + + )}
); diff --git a/frontend/src/app/rules/page.tsx b/frontend/src/app/rules/page.tsx index b9ee1314..409b1b45 100644 --- a/frontend/src/app/rules/page.tsx +++ b/frontend/src/app/rules/page.tsx @@ -1,7 +1,29 @@ -"use client"; +"use client"; -import { Alert, Button, Card, Empty, Form, Input, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message } from "antd"; -import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { + CaretDownOutlined, + CaretRightOutlined, + PlusOutlined, + SearchOutlined, +} from "@ant-design/icons"; +import { + Button, + Drawer, + Empty, + Form, + Input, + List, + Popconfirm, + Select, + Space, + Spin, + Switch, + Tag, + Tree, + Typography, + message, +} from "antd"; +import type { DataNode } from "antd/es/tree"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -17,7 +39,7 @@ import { import { useAuthStore } from "@/stores/auth.store"; import type { ReviewRuleItem } from "@/types/api"; -const { Title, Text } = Typography; +const { Title, Text, Paragraph } = Typography; type ReviewRuleFormValues = { name: string; @@ -39,11 +61,20 @@ const contractTypeOptions = [ { value: "nda", label: "保密协议" }, ]; -const riskLevelOptions = [ - { value: "high", label: "高风险" }, - { value: "medium", label: "中风险" }, - { value: "low", label: "低风险" }, -]; +const contractTypeLabelMap: Record = { + all: "全部合同", + procurement: "采购合同", + sales: "销售合同", + labor: "劳动合同", + service: "服务合同", + nda: "保密协议", +}; + +const riskLevelLabelMap: Record = { + high: "高风险", + medium: "中风险", + low: "低风险", +}; function formatDateTime(value: string) { return new Date(value).toLocaleString("zh-CN", { hour12: false }); @@ -55,65 +86,61 @@ function getRiskLevelTag(value: string) { return 低风险; } +const ALL_KEY = "__all__"; + export default function RulesPage() { const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); const [form] = Form.useForm(); + const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [statusChangingId, setStatusChangingId] = useState(null); - const [items, setItems] = useState([]); - const [editingId, setEditingId] = useState(null); const [deletingId, setDeletingId] = useState(null); const [publishingId, setPublishingId] = useState(null); const [rollbackingId, setRollbackingId] = useState(null); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [total, setTotal] = useState(0); + + const [items, setItems] = useState([]); + const [editingId, setEditingId] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(ALL_KEY); + const [expandedKeys, setExpandedKeys] = useState([ALL_KEY]); const editingRule = useMemo(() => items.find((item) => item.id === editingId) ?? null, [editingId, items]); - const loadRules = useCallback(async (nextPage = page, nextPageSize = pageSize) => { + const loadRules = useCallback(async () => { if (currentUser?.role !== "admin") { setLoading(false); return; } + setLoading(true); try { - const result = await listReviewRules(nextPage, nextPageSize); + const result = await listReviewRules(1, 1000); if (result.code !== 0) { messageApi.error(result.message || "获取规则列表失败"); return; } setItems(result.data.items); - setPage(result.data.page); - setPageSize(result.data.page_size); - setTotal(result.data.total); } catch { messageApi.error("规则列表加载失败,请稍后重试"); } finally { setLoading(false); } - }, [currentUser?.role, messageApi, page, pageSize]); + }, [currentUser?.role, messageApi]); useEffect(() => { - const timer = window.setTimeout(() => { - void loadRules(); - }, 0); - + const timer = window.setTimeout(() => void loadRules(), 0); return () => window.clearTimeout(timer); }, [loadRules]); useEffect(() => { if (!editingRule) { - form.setFieldsValue({ - contract_type: "all", - risk_level: "medium", - is_active: true, - }); + form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true }); return; } - form.setFieldsValue({ name: editingRule.name, keyword: editingRule.keyword, @@ -126,6 +153,105 @@ export default function RulesPage() { }); }, [editingRule, form]); + const filteredItems = useMemo(() => { + let result = items; + if (selectedCategory !== ALL_KEY) { + if (selectedCategory.startsWith("contract:")) { + const contractType = selectedCategory.replace("contract:", ""); + result = result.filter((item) => item.contract_type === contractType); + } else if (selectedCategory.startsWith("risk:")) { + const riskLevel = selectedCategory.replace("risk:", ""); + result = result.filter((item) => item.risk_level === riskLevel); + } + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.name.toLowerCase().includes(q) || + item.keyword.toLowerCase().includes(q) || + (item.rule_code ?? "").toLowerCase().includes(q) || + item.description.toLowerCase().includes(q), + ); + } + return result; + }, [items, selectedCategory, searchQuery]); + + const treeData = useMemo((): DataNode[] => { + const contractCounts: Record = {}; + const riskCounts: Record = {}; + + for (const item of items) { + contractCounts[item.contract_type] = (contractCounts[item.contract_type] || 0) + 1; + riskCounts[item.risk_level] = (riskCounts[item.risk_level] || 0) + 1; + } + + const contractChildren: DataNode[] = Object.entries(contractTypeLabelMap) + .filter(([key]) => contractCounts[key]) + .map(([key, label]) => ({ + key: `contract:${key}`, + title: ( + + {label} + {contractCounts[key] ?? 0} + + ), + })); + + const riskChildren: DataNode[] = Object.entries(riskLevelLabelMap) + .filter(([key]) => riskCounts[key]) + .map(([key, label]) => ({ + key: `risk:${key}`, + title: ( + + {label} + {riskCounts[key] ?? 0} + + ), + })); + + return [ + { + key: ALL_KEY, + title: ( + + 全部规则 + {items.length} + + ), + children: [ + { key: "group-contract", title: 按合同类型, children: contractChildren, selectable: false }, + { key: "group-risk", title: 按风险等级, children: riskChildren, selectable: false }, + ], + }, + ]; + }, [items]); + + const handleTreeSelect = (_selectedKeys: React.Key[], info: { node: DataNode }) => { + const key = info.node.key as string; + if (!key.startsWith("group-")) { + setSelectedCategory(key); + } + }; + + const handleOpenDrawer = (rule?: ReviewRuleItem) => { + if (rule) { + setEditingId(rule.id); + } else { + setEditingId(null); + form.resetFields(); + form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true }); + } + setDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setDrawerOpen(false); + setEditingId(null); + form.resetFields(); + form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true }); + }; + const handleSubmit = async (values: ReviewRuleFormValues) => { setSubmitting(true); try { @@ -145,10 +271,8 @@ export default function RulesPage() { return; } messageApi.success(editingRule ? "规则已更新" : "规则已创建"); - setEditingId(null); - form.resetFields(); - form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true }); - await loadRules(page, pageSize); + handleCloseDrawer(); + await loadRules(); } catch { messageApi.error("保存规则失败,请稍后重试"); } finally { @@ -165,10 +289,7 @@ export default function RulesPage() { return; } messageApi.success(checked ? "规则已启用" : "规则已停用"); - await loadRules(page, pageSize); - if (editingId === item.id) { - setEditingId(item.id); - } + await loadRules(); } catch { messageApi.error("更新规则状态失败,请稍后重试"); } finally { @@ -185,12 +306,7 @@ export default function RulesPage() { return; } messageApi.success("规则已删除"); - await loadRules(page, pageSize); - if (editingId === ruleId) { - setEditingId(null); - form.resetFields(); - form.setFieldsValue({ contract_type: "all", risk_level: "medium", is_active: true }); - } + await loadRules(); } catch { messageApi.error("删除规则失败,请稍后重试"); } finally { @@ -207,7 +323,7 @@ export default function RulesPage() { return; } messageApi.success("规则已发布到生产可用集合"); - await loadRules(page, pageSize); + await loadRules(); } catch (error) { messageApi.error(error instanceof Error ? error.message : "规则发布失败,请稍后重试"); } finally { @@ -224,7 +340,7 @@ export default function RulesPage() { return; } messageApi.success("规则已从生产可用集合移除"); - await loadRules(page, pageSize); + await loadRules(); } catch (error) { messageApi.error(error instanceof Error ? error.message : "规则下线失败,请稍后重试"); } finally { @@ -247,7 +363,7 @@ export default function RulesPage() { return; } messageApi.success("规则已回滚为新版本"); - await loadRules(page, pageSize); + await loadRules(); } catch (error) { messageApi.error(error instanceof Error ? error.message : "规则回滚失败,请稍后重试"); } finally { @@ -255,228 +371,183 @@ export default function RulesPage() { } }; - const handleTableChange = (pagination: TablePaginationConfig) => { - const nextPage = pagination.current ?? 1; - const nextPageSize = pagination.pageSize ?? pageSize; - setLoading(true); - void loadRules(nextPage, nextPageSize); + const getCategoryLabel = () => { + if (selectedCategory === ALL_KEY) return "全部规则"; + if (selectedCategory.startsWith("contract:")) { + return contractTypeLabelMap[selectedCategory.replace("contract:", "")] ?? "合同类型"; + } + if (selectedCategory.startsWith("risk:")) { + return riskLevelLabelMap[selectedCategory.replace("risk:", "")] ?? "风险等级"; + } + return "规则列表"; }; - const columns: ColumnsType = [ - { - title: "规则名称", - dataIndex: "name", - key: "name", - width: 180, - }, - { - title: "关键词", - dataIndex: "keyword", - key: "keyword", - width: 140, - }, - { - title: "规则编号", - dataIndex: "rule_code", - key: "rule_code", - width: 180, - render: (value: string | null) => value ? {value} : "-", - }, - { - title: "合同类型", - dataIndex: "contract_type", - key: "contract_type", - width: 140, - render: (value: string) => contractTypeOptions.find((item) => item.value === value)?.label ?? value, - }, - { - title: "风险等级", - dataIndex: "risk_level", - key: "risk_level", - width: 120, - render: (value: string) => getRiskLevelTag(value), - }, - { - title: "发布状态", - key: "publish_status", - width: 180, - render: (_, item) => ( - - {item.published_at ? "已发布" : "未发布"} - - {item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"} - - - ), - }, - { - title: "状态", - dataIndex: "is_active", - key: "is_active", - width: 120, - render: (value: boolean) => {value ? "启用中" : "已停用"}, - }, - { - title: "更新时间", - dataIndex: "updated_at", - key: "updated_at", - width: 180, - render: (value: string) => formatDateTime(value), - }, - { - title: "操作", - key: "actions", - width: 220, - render: (_, item) => ( - - - {item.published_at ? ( - - ) : ( - - )} - {item.version_count > 1 ? ( - - ) : null} - void handleToggleStatus(item, checked)} - /> - void handleDelete(item.id)}> - - - - ), - }, - ]; - if (currentUser?.role !== "admin") { return ( -
+
{contextHolder} - - - +
+ 仅管理员可管理审查规则。 +
); } return ( -
+
{contextHolder} - -
-
- Rule Management - 维护审查规则,直接影响风险识别。 - 左侧编辑规则,右侧查看启停状态和命中依据,规则变更更容易复核。 -
- - Admin Workspace - - 规则节奏 - -
    -
  • 先配置关键词规则,再发起合同审查。
  • -
  • 支持按合同类型区分规则。
  • -
  • 停用规则后不再参与后续审查匹配。
  • -
- - -
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - {editingRule ? ( - - ) : null} - - -
- - -
}} - columns={columns} - onChange={handleTableChange} - expandable={{ - expandedRowRender: (item) => ( - -
- 风险描述: -
{item.description}
-
-
- 审查建议: -
{item.suggestion || "-"}
-
-
- 法律依据: -
{item.legal_basis || "-"}
-
-
- ), - }} - scroll={{ x: 1440 }} +
+ {/* 左侧树形菜单 */} +
+ + + {/* 右侧规则列表 */} +
+
+
+ {getCategoryLabel()} + {filteredItems.length} 条规则 +
+ +
+ + {loading ? ( +
+ + 正在加载规则列表... +
+ ) : filteredItems.length > 0 ? ( + ( + +
+
+ + {item.name} + {item.is_active ? "启用" : "停用"} + {item.published_at ? "已发布" : "未发布"} + {item.rule_code && {item.rule_code}} + +
+ + 关键词:{item.keyword} + {contractTypeLabelMap[item.contract_type] ?? item.contract_type} + {getRiskLevelTag(item.risk_level)} + v{item.current_version_label} + +
+ {item.description && ( + + {item.description} + + )} +
+ + void handleToggleStatus(item, checked)} + /> + + {item.published_at ? ( + + ) : ( + + )} + {item.version_count > 1 && ( + + )} + void handleDelete(item.id)}> + + + +
+
+ )} + /> + ) : ( + + + + )} +
+ + + {/* 新建/编辑规则抽屉 */} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
); }