feat: polish knowledge base publish governance UX

This commit is contained in:
2026-05-15 19:18:20 +08:00
parent eddc53d043
commit a775b74bbe
2 changed files with 312 additions and 9 deletions
+280 -9
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { InboxOutlined, ReloadOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Empty, Form, Input, List, Select, Space, Table, Tag, Typography, Upload, message } from "antd";
import { Alert, Button, Card, Divider, Empty, Form, Input, List, Modal, Select, Space, Table, Tag, Typography, Upload, message } from "antd";
import type { UploadFile } from "antd/es/upload/interface";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -37,6 +37,20 @@ type KnowledgeBaseRollbackFormValues = {
target_version_label: string;
change_summary?: string;
};
type KnowledgeBaseGovernanceCheckStatus = "pass" | "warning" | "block";
type KnowledgeBaseGovernanceCheckItem = {
key: string;
label: string;
status: KnowledgeBaseGovernanceCheckStatus;
detail: string;
};
type KnowledgeBaseVersionComparisonRow = {
key: string;
label: string;
current: string;
target: string;
changed: boolean;
};
const DOCUMENT_STATUS_META: Record<string, { color: string; label: string }> = {
pending: { color: "gold", label: "待处理" },
@@ -123,6 +137,24 @@ 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;
}
function getGovernanceCheckMeta(status: KnowledgeBaseGovernanceCheckStatus) {
if (status === "pass") {
return { color: "success", label: "通过" };
}
if (status === "warning") {
return { color: "warning", label: "注意" };
}
return { color: "error", label: "阻塞" };
}
function getDocumentStatusMeta(status: string) {
return DOCUMENT_STATUS_META[status] ?? { color: "default", label: status };
}
@@ -218,6 +250,7 @@ export default function KnowledgeBasesPage() {
const [regionFilter, setRegionFilter] = useState<string>("all");
const [documentsLoading, setDocumentsLoading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const rollbackTargetVersionLabel = Form.useWatch("target_version_label", rollbackForm);
const selectedKnowledgeBase = useMemo(
() => items.find((item) => item.id === selectedId) ?? null,
@@ -268,6 +301,160 @@ export default function KnowledgeBasesPage() {
[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<KnowledgeBaseGovernanceCheckItem[]>(() => {
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<KnowledgeBaseVersionComparisonRow[]>(() => {
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 loadKnowledgeBases = useCallback(async () => {
if (currentUser?.role !== "admin") {
setLoading(false);
@@ -306,7 +493,8 @@ export default function KnowledgeBasesPage() {
setDocumentsLoading(true);
}
try {
const result = await listKnowledgeBaseDocuments(knowledgeBaseId);
const documentCount = items.find((item) => item.id === knowledgeBaseId)?.document_count ?? 20;
const result = await listKnowledgeBaseDocuments(knowledgeBaseId, 1, Math.max(documentCount, 20));
if (result.code !== 0) {
const nextMessage = result.message || "获取知识库文档失败";
setDocumentsError(nextMessage);
@@ -325,7 +513,7 @@ export default function KnowledgeBasesPage() {
setDocumentsLoading(false);
setRefreshingDocuments(false);
}
}, [messageApi]);
}, [items, messageApi]);
const loadVersions = useCallback(async (knowledgeBaseId: string) => {
setVersionsLoading(true);
@@ -339,7 +527,7 @@ export default function KnowledgeBasesPage() {
}
setVersions(result.data);
rollbackForm.setFieldsValue({
target_version_label: result.data[0]?.version_label,
target_version_label: result.data[1]?.version_label ?? result.data[0]?.version_label,
});
} catch (error: unknown) {
messageApi.error(getErrorMessage(error, "获取知识库版本失败,请稍后重试"));
@@ -490,7 +678,11 @@ export default function KnowledgeBasesPage() {
};
const handlePublishKnowledgeBase = async () => {
if (!selectedId) {
if (!selectedId || !selectedKnowledgeBase) {
return;
}
if (publishBlockerCount > 0) {
messageApi.error(`发布前检查未通过,还有 ${publishBlockerCount} 个阻塞项需要先处理。`);
return;
}
setPublishing(true);
@@ -666,11 +858,36 @@ export default function KnowledgeBasesPage() {
<Text type="secondary">发布人:{selectedKnowledgeBase.published_by || "-"}</Text>
<Text type="secondary">发布时间:{selectedKnowledgeBase.published_at ? formatDateTime(selectedKnowledgeBase.published_at) : "-"}</Text>
</Space>
<Alert
showIcon
type={publishBlockerCount > 0 ? "error" : publishWarningCount > 0 ? "warning" : "success"}
message={`${publishActionLabel}前检查:通过 ${publishPreflightChecks.filter((item) => item.status === "pass").length} 项,注意 ${publishWarningCount} 项,阻塞 ${publishBlockerCount} 项`}
description="这一步先把当前知识库的文档、索引与法源元数据过一遍,避免带着未完成状态直接进生产可用版本。"
/>
<List
size="small"
bordered
dataSource={publishPreflightChecks}
renderItem={(item) => {
const meta = getGovernanceCheckMeta(item.status);
return (
<List.Item>
<Space direction="vertical" size={2} style={{ width: "100%" }}>
<Space wrap size={[8, 8]}>
<Text strong>{item.label}</Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
<Text type="secondary">{item.detail}</Text>
</Space>
</List.Item>
);
}}
/>
<Space wrap>
<Button type="primary" loading={publishing} onClick={() => void handlePublishKnowledgeBase()}>
<Button type="primary" loading={publishing} disabled={publishBlockerCount > 0} onClick={() => void handlePublishKnowledgeBase()}>
发布知识库
</Button>
<Button loading={unpublishing} onClick={() => void handleUnpublishKnowledgeBase()}>
<Button loading={unpublishing} disabled={selectedKnowledgeBase.status?.toLowerCase() !== "active"} onClick={() => void handleUnpublishKnowledgeBase()}>
下线知识库
</Button>
</Space>
@@ -679,7 +896,7 @@ export default function KnowledgeBasesPage() {
<Select
placeholder="选择历史版本"
loading={versionsLoading}
options={rollbackVersionOptions}
options={rollbackVersionOptions.filter((item) => item.value !== selectedKnowledgeBase.current_version_label)}
disabled={!rollbackVersionOptions.length}
/>
</Form.Item>
@@ -687,7 +904,61 @@ export default function KnowledgeBasesPage() {
<Input placeholder="例如:恢复到稳定知识库版本" />
</Form.Item>
</Form>
<Button loading={rollingBack} disabled={!rollbackVersionOptions.length} onClick={() => void handleRollbackKnowledgeBase()}>
<Divider style={{ margin: "8px 0" }}>版本对比与回滚确认</Divider>
{rollbackTargetVersion ? (
<List
size="small"
bordered
header={`当前版本与 ${rollbackTargetVersion.version_label} 的差异`}
dataSource={versionComparisonRows}
renderItem={(item) => (
<List.Item>
<Space direction="vertical" size={2} style={{ width: "100%" }}>
<Space wrap size={[8, 8]}>
<Text strong>{item.label}</Text>
<Tag color={item.changed ? "gold" : "default"}>{item.changed ? "有差异" : "一致"}</Tag>
</Space>
<Text type="secondary">当前:{item.current}</Text>
<Text type="secondary">目标:{item.target}</Text>
</Space>
</List.Item>
)}
/>
) : (
<Alert showIcon type="info" message="选择一个历史版本后,这里会展示与当前知识库的关键差异。" />
)}
<Button
loading={rollingBack}
disabled={!rollbackVersionOptions.length || !rollbackTargetVersion || rollbackTargetVersion.version_label === selectedKnowledgeBase?.current_version_label}
onClick={() => {
Modal.confirm({
title: rollbackTargetVersion ? `确认回滚到 ${rollbackTargetVersion.version_label} 吗?` : "确认回滚知识库吗?",
okText: "确认回滚",
cancelText: "取消",
okButtonProps: { danger: true },
content: rollbackTargetVersion ? (
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Text>
当前版本:{formatDisplayText(selectedKnowledgeBase?.current_version_label, "未发布")},目标版本:{rollbackTargetVersion.version_label}。
</Text>
<Text type="secondary">
本次共检测到 {changedVersionComparisonCount} 项差异,回滚后系统会生成新的治理快照,而不是直接覆盖历史版本。
</Text>
<Alert
showIcon
type="warning"
message={
rollbackTargetVersion.change_summary
? `目标版本说明:${rollbackTargetVersion.change_summary}`
: "目标版本未填写版本说明,请结合历史记录确认。"
}
/>
</Space>
) : undefined,
onOk: async () => handleRollbackKnowledgeBase(),
});
}}
>
回滚知识库
</Button>
<List
@@ -1660,3 +1660,35 @@
### 当前状态更新
- 现在“默认流程”与“仓库中的正式治理状态”已经对齐到同一套 `Codex` 无人审批口径。
- 当前剩余不是功能阻塞,而是需要把这批 `2026-05-15` 的治理状态刷新结果纳入仓库版本,作为新的正式基线。
## 2026-05-15 knowledge-bases 发布治理前端体验打磨
### 本轮完成项
- `frontend/src/app/knowledge-bases/page.tsx`
- 在“发布治理”面板中新增发布前检查摘要:
- 知识文档数量
- 索引完成度
- 法源元数据完整度
- 知识库说明
- 版本快照状态
- 发布按钮现在会在存在阻塞项时禁用,避免带着未完成索引直接发版
- 回滚目标默认优先落到“上一历史版本”,不再默认指向当前版本
- 新增“版本对比与回滚确认”区域:
- 对比当前版本与目标历史版本的名称、说明、检索模式、状态、发布人、发布时间、版本说明
- 明确标出“一致 / 有差异”
- 回滚按钮改成二次确认:
- 弹窗中展示当前版本、目标版本、差异项数量、目标版本说明
- 明确说明回滚会生成新的治理快照,而不是覆盖历史版本
- 文档加载口径补强:
- 发布治理检查会按当前知识库文档总数拉取足量文档,避免只看默认 20 条分页结果导致检查失真
### 本轮验证
- `npm --prefix frontend run lint`
- 结果:通过
- `npm --prefix frontend run build`
- 结果:通过
### 当前状态更新
- `knowledge_bases` 的发布治理已经从“有按钮可操作”推进到“发布前可检查、回滚前可比对、执行前可确认”的前端闭环
- 当前剩余更偏锦上添花的项:
- 发布前治理说明文案进一步润色
- 历史版本更多字段级 diff
- 与 `/files` 或治理资产详情页的更深联动