feat(knowledge): 知识库工作台前端先审后入(确认草稿→Canonical)rendered-UI 活体证

后端:SummaryRespVO 暴露 confirm 所需并发/源令牌(sourceSnapshotId/authorizationSnapshotId/revision),
否则前端无法构造合法 confirm 请求(非破坏性新增,不改 confirm 语义);转换器填充。
前端:useKnowledgeDrafts/useConfirmKnowledgeDraft hooks(回显令牌)+ KnowledgeDraftPanel(待确认草稿+确认入库)
+ KnowledgePage(/knowledge/:workId)接入。e2e knowledge-confirm:chromium MSW-off 点确认入库→真 confirm
→ draft confirmed+entityId(DB 证 draft5 confirmed/entity_id=3)。tsc 0。
遗留:confirm 对已存在同名 Canonical 实体抛未处理 DuplicateKeyException(500),应改干净 conflict(另 flag)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-15 00:41:58 -07:00
parent f63247fb8f
commit 749c5b2471
6 changed files with 195 additions and 0 deletions

View File

@ -427,6 +427,10 @@ public class MuseKnowledgeDraftService {
vo.setConfidence(draft.getConfidence() == null ? null : draft.getConfidence().floatValue());
vo.setConflictSummary(conflictSummary(draft, payload));
vo.setCreatedAt(toOpenApiDateTime(draft.getCreateTime()));
// 暴露 confirm 所需并发/源令牌,供前端"先审后入"原样回显( SummaryRespVO 注释)
vo.setSourceSnapshotId(draft.getSourceSnapshotId());
vo.setAuthorizationSnapshotId(draft.getAuthorizationSnapshotId());
vo.setRevision(draft.getRevision());
return vo;
}

View File

@ -72,6 +72,12 @@ public final class AppKnowledgeDraftVO {
private Float confidence;
private String conflictSummary;
private String createdAt;
// 确认草稿(先审后入)所需的并发/源新鲜度令牌:客户端从列表读取后在 confirm 原样回显,
// 后端据此做乐观并发(revision)与源快照一致性(sourceSnapshotId/authorizationSnapshotId)校验
// 不暴露则前端无法构造合法 confirm 请求( ConfirmReqVO 必填项)
private String sourceSnapshotId;
private String authorizationSnapshotId;
private Integer revision;
}
@Data

View File

@ -0,0 +1,44 @@
import { expect, test, type Page } from '@playwright/test';
/**
* 稿 e2e(,,MSW off)
*
* 前置:vite VITE_API_MOCK=false muse-server 48080( confirm / SummaryRespVO)
* muse_slice_live 1 pending 稿
* 目的:rendered studio 稿 (POST /knowledge-drafts/{id}/confirm)
* Shadow 稿 Canonical , POST (绿)
*/
const TOKEN = 'test1';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('知识草稿确认入库(Shadow→Canonical,真实后端)', async ({ page }) => {
await seedToken(page);
await page.goto('/knowledge/1');
// 待确认草稿面板渲染 + 「确认入库」按钮可见(证后端 SummaryRespVO 暴露的草稿被加载渲染)。
const confirmButton = page.getByRole('button', { name: /确认入库/ }).first();
await expect(confirmButton).toBeVisible({ timeout: 15_000 });
// 点击确认并捕获真实 confirm 写入响应(打真后端,非 mock)。
const [resp] = await Promise.all([
page.waitForResponse(
(r) => /\/knowledge-drafts\/\d+\/confirm$/.test(r.url()) && r.request().method() === 'POST',
{ timeout: 15_000 }
),
confirmButton.click(),
]);
// Yudao 口径:成功为 HTTP 200 + CommonResult{code:0}。
expect(resp.status()).toBe(200);
const body = await resp.json();
expect(body.code).toBe(0);
// 物化结果:草稿转 confirmed,并返回新建 Canonical 实体 id。
expect(body.data?.status).toBe('confirmed');
expect(String(body.data?.entityId ?? '')).not.toBe('');
});

View File

@ -0,0 +1,70 @@
import {
useKnowledgeDrafts,
useConfirmKnowledgeDraft,
type KnowledgeDraftVO,
} from '@/features/knowledge/hooks/useKnowledge';
/**
* - 稿()
*
* AI/ pending 稿;
* (POST /knowledge-drafts/{id}/confirm) Shadow 稿 Canonical /
* 体现双轨主权:候选不直接进正式图谱,(// fail-closed )
*/
export default function KnowledgeDraftPanel({ workId }: { workId: string }) {
const { data, isLoading, isError } = useKnowledgeDrafts(workId);
const confirm = useConfirmKnowledgeDraft(workId);
const pendingDrafts = (data ?? []).filter((draft) => draft.status === 'pending');
if (isLoading) {
return (
<section className="mb-8 rounded-2xl border border-teal-100 bg-teal-50/40 p-6">
<p className="text-sm text-teal-700">稿</p>
</section>
);
}
// 拉取失败或无待确认草稿时不占位(工作台主体仍展示知识库列表)。
if (isError || pendingDrafts.length === 0) {
return null;
}
return (
<section className="mb-8 rounded-2xl border border-teal-100 bg-white shadow-sm overflow-hidden">
<div className="flex items-center gap-2 border-b border-teal-50 bg-gradient-to-r from-teal-50/80 to-cyan-50/40 px-6 py-4">
<span className="text-lg">🔎</span>
<h2 className="text-base font-bold text-slate-800">稿()</h2>
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-teal-600 px-2 py-0.5 text-xs font-semibold text-white">
{pendingDrafts.length}
</span>
</div>
<ul className="divide-y divide-slate-50">
{pendingDrafts.map((draft: KnowledgeDraftVO) => (
<li key={draft.draftId} className="flex items-start justify-between gap-4 px-6 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-block rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">
{draft.entityType}
</span>
<strong className="truncate text-sm font-semibold text-slate-800">{draft.name}</strong>
</div>
{draft.description && (
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-400">{draft.description}</p>
)}
</div>
<button
type="button"
onClick={() => confirm.mutate(draft)}
disabled={confirm.isPending}
className="shrink-0 inline-flex items-center gap-1 rounded-xl bg-gradient-to-r from-teal-600 to-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:from-teal-700 hover:to-cyan-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{confirm.isPending ? '⏳ 确认中…' : '✅ 确认入库'}
</button>
</li>
))}
</ul>
</section>
);
}

View File

@ -403,3 +403,67 @@ export function useDocumentDelete(kbId: string) {
},
});
}
/**
* 稿()
* sourceSnapshotId/authorizationSnapshotId/revision confirm /:
* confirm ,
*/
export interface KnowledgeDraftVO {
draftId: number;
entityType: string;
name: string;
description: string;
status: string;
sourceType?: string;
confidence?: number;
conflictSummary?: string;
createdAt?: string;
sourceSnapshotId?: string;
authorizationSnapshotId?: string;
revision?: number;
}
/**
* 稿(GET /works/{workId}/knowledge-drafts)
* queryKey: ['knowledgeDrafts', workId];workId
*/
export function useKnowledgeDrafts(workId: string | null) {
return useQuery<KnowledgeDraftVO[], Error>({
queryKey: ['knowledgeDrafts', workId],
enabled: !!workId,
queryFn: () => api.get<KnowledgeDraftVO[]>(`/works/${workId}/knowledge-drafts`),
});
}
/**
* 确认知识草稿: Shadow 稿 Canonical /
*
* WHY:POST /knowledge-drafts/{draftId}/confirm AI/(·)
* /(revisionexpectedDraftRevisionsourceSnapshotIdauthorizationSnapshotId),
* ;/( revision )
* :本切片按"原样接受"(accept/single);稿 acknowledged=true,
* riskFlags 稿 acknowledgedRiskCodes()
*/
export function useConfirmKnowledgeDraft(workId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (draft: KnowledgeDraftVO) =>
api.post<{ draftId?: string; entityId?: string; status?: string }>(
`/knowledge-drafts/${draft.draftId}/confirm`,
{
commandId: createCommandId(),
expectedDraftRevision: draft.revision ?? 1,
sourceSnapshotId: draft.sourceSnapshotId,
authorizationSnapshotId: draft.authorizationSnapshotId,
confirmMode: 'single',
decisionType: 'accept',
riskAcknowledgement: { acknowledged: true },
}
),
onSuccess: () => {
// 确认成功后刷新待确认列表(该草稿转 confirmed、移出 pending)。
queryClient.invalidateQueries({ queryKey: ['knowledgeDrafts', workId] });
},
});
}

View File

@ -1,4 +1,6 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftPanel';
import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall } from '@/features/knowledge/hooks/useKnowledge';
import type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge';
import CreateKBModal from '@/features/knowledge/components/CreateKBModal';
@ -10,6 +12,8 @@ import { isKnowledgeBaseSummary } from '@/features/knowledge/utils/typeGuards';
* Zod
*/
export default function KnowledgePage() {
// 作品维度路由(/knowledge/:workId)携带 workId 时,展示该作品的"待确认知识草稿"工作台(先审后入)。
const { workId } = useParams<{ workId: string }>();
const [activeTab, setActiveTab] = useState<'user' | 'installed' | 'global'>('user');
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedKbId, setSelectedKbId] = useState<string | null>(null);
@ -86,6 +90,9 @@ export default function KnowledgePage() {
)}
</div>
{/* 作品维度:待确认知识草稿(先审后入工作台);无 workId 或无待确认草稿时自动隐藏 */}
{workId && <KnowledgeDraftPanel workId={workId} />}
{/* 选项页签切换区域 */}
<div className="flex border-b border-slate-200 gap-6 mb-8 select-none">
<button