From c22faecfcbfb1b722fef541512754bfe8ff9ca38 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 19 Sep 2026 11:40:03 +0000 Subject: [PATCH] refactor(web): move document lifecycle actions into a hook Keep claim, review, and approval on one server record so the open document and the sidebar stay in the same state. Co-authored-by: Cursor --- .../long-term/2026-09-19-audit-remainder.md | 2 +- web/src/App.tsx | 207 ++------------- .../hooks/useDocumentLifecycleActions.test.ts | 16 ++ web/src/hooks/useDocumentLifecycleActions.ts | 236 ++++++++++++++++++ 4 files changed, 270 insertions(+), 191 deletions(-) create mode 100644 web/src/hooks/useDocumentLifecycleActions.test.ts create mode 100644 web/src/hooks/useDocumentLifecycleActions.ts diff --git a/.doc/ops/long-term/2026-09-19-audit-remainder.md b/.doc/ops/long-term/2026-09-19-audit-remainder.md index 80cd617c..c75d2d22 100644 --- a/.doc/ops/long-term/2026-09-19-audit-remainder.md +++ b/.doc/ops/long-term/2026-09-19-audit-remainder.md @@ -46,7 +46,7 @@ updated: 2026-09-19 | FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` | | FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 | | M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 | -| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。自由文档、工作流对账和编辑租约轮询在 `useServerDocumentSync`。工作台主体仍在 `App.tsx` | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。自由文档、工作流对账和编辑租约轮询在 `useServerDocumentSync`。认领、送审、审核和批准在 `useDocumentLifecycleActions`。发送消息与块编辑仍在 `App.tsx` | | H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 | 仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。 diff --git a/web/src/App.tsx b/web/src/App.tsx index f2eb69ed..5d406896 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -49,14 +49,8 @@ import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; import { resolveCurrentDocRole } from './lib/currentDocRole'; import { holdsDocDuty } from './lib/docGovActionVisibility'; import { - claimDocument, - submitForReview, - reviewDecision, - approverDecision, proposeModification, decideModificationProposal, - mapLeaseToAstEditLease, - type DocGovRecord, } from './api/docgov'; import { canAddBlockComment } from './lib/commentPolicy'; import { @@ -194,6 +188,7 @@ import { import { useChatWindow } from './hooks/useChatWindow'; import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync'; import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration'; +import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions'; import { useServerDocumentSync } from './hooks/useServerDocumentSync'; import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; @@ -3109,190 +3104,22 @@ export default function App() { setDocAST({ ...docAST }); }; - // 法定生命周期流转(真实 docgov HTTP) - const applyDocGovRecord = (doc: DocGovRecord) => { - const nextState = (doc.current_state || docAST.lifecycleState) as DocLifecycleState; - const nextClaimed = doc.author_claimed ?? docAST.isClaimed; - const lease = mapLeaseToAstEditLease(doc); - const nextAst: DocumentAST = { - ...docAST, - lifecycleState: nextState, - isClaimed: nextClaimed, - authorId: doc.author_id ?? docAST.authorId, - author: doc.author_name || docAST.author, - reviewerId: doc.reviewer_id ?? docAST.reviewerId, - reviewer: doc.reviewer_name || docAST.reviewer, - approverId: doc.approver_id ?? docAST.approverId, - approver: doc.approver_name || docAST.approver, - dutyPolicy: doc.duty_policy || docAST.dutyPolicy, - contentSha256: doc.content_sha256 || docAST.contentSha256, - editLease: lease ?? docAST.editLease, - }; - setDocAST(nextAst); - setDeliverables((prev) => - prev.map((d) => - d.docId === activeDocId || d.docId === doc.doc_id - ? { - ...d, - lifecycleState: nextState, - isClaimed: nextClaimed, - authorId: doc.author_id ?? d.authorId, - author: doc.author_name || d.author, - reviewerId: doc.reviewer_id ?? d.reviewerId, - reviewer: doc.reviewer_name || d.reviewer, - approverId: doc.approver_id ?? d.approverId, - approver: doc.approver_name || d.approver, - dutyPolicy: doc.duty_policy || d.dutyPolicy, - editLease: lease ?? d.editLease, - } - : d, - ), - ); - }; - - const pushDocGovError = (actionLabel: string, err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - setMessages((prev) => [ - ...prev, - { - id: 'docgov-err-' + Date.now(), - sessionId: activeSessionId, - role: 'system', - content: `⚠️ ${actionLabel}失败:${message}`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - }; - - const handleClaim = async () => { - try { - const doc = await claimDocument({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - user_role: 'author', - }); - applyDocGovRecord(doc); - } catch (err) { - pushDocGovError('认领文档', err); - } - }; - - const handleSubmitReview = async () => { - try { - const doc = await submitForReview({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - }); - applyDocGovRecord(doc); - } catch (err) { - pushDocGovError('提交送审', err); - } - }; - - const handlePassReview = async () => { - try { - const doc = await reviewDecision({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - action: 'approve', - }); - applyDocGovRecord(doc); - } catch (err) { - pushDocGovError('审核通过', err); - } - }; - - const handleRejectReview = async (reason: string) => { - try { - const doc = await reviewDecision({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - action: 'reject', - reason, - }); - applyDocGovRecord(doc); - setMessages((prev) => [ - ...prev, - { - id: 'rej-' + Date.now(), - sessionId: activeSessionId, - role: 'system', - content: `⚠️ ${currentUser.name}(审核)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } catch (err) { - pushDocGovError('审核驳回', err); - } - }; - - const handleApprove = async () => { - try { - const doc = await approverDecision({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - action: 'approve', - }); - applyDocGovRecord(doc); - setMessages((prev) => [ - ...prev, - { - id: 'appr-' + Date.now(), - sessionId: activeSessionId, - role: 'assistant', - authorName: '水木东方协同参谋', - intentTag: '🏆 终审批准生效', - content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, - timestamp: new Date().toTimeString().substring(0, 5), - recommendedCases: [ - { - docId: docAST.docId, - taskKey: docAST.taskKey, - artifactTitle: docAST.title, - sectionTitle: '终审已生效标杆案例', - similarityScore: 1.0, - keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], - summarySnippet: - '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', - }, - ], - }, - ]); - } catch (err) { - pushDocGovError('终审批准', err); - } - }; - - const handleRejectApprove = async (reason: string) => { - try { - const doc = await approverDecision({ - doc_id: docAST.docId, - user_id: currentUser.id, - user_name: currentUser.name, - action: 'reject', - reason, - rollback_to: 'drafting', - }); - applyDocGovRecord(doc); - setMessages((prev) => [ - ...prev, - { - id: 'rej-appr-' + Date.now(), - sessionId: activeSessionId, - role: 'system', - content: `⚠️ ${currentUser.name}(批准人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, - timestamp: new Date().toTimeString().substring(0, 5), - }, - ]); - } catch (err) { - pushDocGovError('批准驳回', err); - } - }; + const { + handleClaim, + handleSubmitReview, + handlePassReview, + handleRejectReview, + handleApprove, + handleRejectApprove, + } = useDocumentLifecycleActions({ + docAST, + setDocAST, + activeDocId, + currentUser, + activeSessionId, + setDeliverables, + setMessages, + }); // 导出 DOCX (Word 渲染器) const handleExportDocx = async () => { diff --git a/web/src/hooks/useDocumentLifecycleActions.test.ts b/web/src/hooks/useDocumentLifecycleActions.test.ts new file mode 100644 index 00000000..fdd8d946 --- /dev/null +++ b/web/src/hooks/useDocumentLifecycleActions.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('document lifecycle actions live in one hook', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useDocumentLifecycleActions.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useDocumentLifecycleActions\(/g) || []).length, 1); + assert.equal(app.includes('claimDocument('), false); + assert.equal(app.includes('approverDecision('), false); + assert.equal(hook.includes('claimDocument'), true); + assert.equal(hook.includes('submitForReview'), true); + assert.equal(hook.includes('reviewDecision'), true); + assert.equal(hook.includes('approverDecision'), true); + assert.equal(hook.includes("rollback_to: 'drafting'"), true); +}); diff --git a/web/src/hooks/useDocumentLifecycleActions.ts b/web/src/hooks/useDocumentLifecycleActions.ts new file mode 100644 index 00000000..766ffd1e --- /dev/null +++ b/web/src/hooks/useDocumentLifecycleActions.ts @@ -0,0 +1,236 @@ +import type { Dispatch, SetStateAction } from 'react'; +import { + approverDecision, + claimDocument, + mapLeaseToAstEditLease, + reviewDecision, + submitForReview, + type DocGovRecord, +} from '../api/docgov'; +import type { DocumentAST } from '../types/block'; +import type { DocLifecycleState, UserProfile } from '../types/docgov'; +import type { ChatMessage, DeliverableItem } from '../types/workbench'; + +export interface DocumentLifecycleInput { + docAST: DocumentAST; + setDocAST: (next: DocumentAST) => void; + activeDocId: string; + currentUser: UserProfile; + activeSessionId: string; + setDeliverables: Dispatch>; + setMessages: Dispatch>; +} + +export interface DocumentLifecycleActions { + handleClaim: () => Promise; + handleSubmitReview: () => Promise; + handlePassReview: () => Promise; + handleRejectReview: (reason: string) => Promise; + handleApprove: () => Promise; + handleRejectApprove: (reason: string) => Promise; +} + +export function useDocumentLifecycleActions(input: DocumentLifecycleInput): DocumentLifecycleActions { + const { + docAST, + setDocAST, + activeDocId, + currentUser, + activeSessionId, + setDeliverables, + setMessages, + } = input; + + const applyDocGovRecord = (doc: DocGovRecord) => { + const nextState = (doc.current_state || docAST.lifecycleState) as DocLifecycleState; + const nextClaimed = doc.author_claimed ?? docAST.isClaimed; + const lease = mapLeaseToAstEditLease(doc); + const nextAst: DocumentAST = { + ...docAST, + lifecycleState: nextState, + isClaimed: nextClaimed, + authorId: doc.author_id ?? docAST.authorId, + author: doc.author_name || docAST.author, + reviewerId: doc.reviewer_id ?? docAST.reviewerId, + reviewer: doc.reviewer_name || docAST.reviewer, + approverId: doc.approver_id ?? docAST.approverId, + approver: doc.approver_name || docAST.approver, + dutyPolicy: doc.duty_policy || docAST.dutyPolicy, + contentSha256: doc.content_sha256 || docAST.contentSha256, + editLease: lease ?? docAST.editLease, + }; + setDocAST(nextAst); + setDeliverables((prev) => + prev.map((d) => + d.docId === activeDocId || d.docId === doc.doc_id + ? { + ...d, + lifecycleState: nextState, + isClaimed: nextClaimed, + authorId: doc.author_id ?? d.authorId, + author: doc.author_name || d.author, + reviewerId: doc.reviewer_id ?? d.reviewerId, + reviewer: doc.reviewer_name || d.reviewer, + approverId: doc.approver_id ?? d.approverId, + approver: doc.approver_name || d.approver, + dutyPolicy: doc.duty_policy || d.dutyPolicy, + editLease: lease ?? d.editLease, + } + : d, + ), + ); + }; + + const pushDocGovError = (actionLabel: string, err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + setMessages((prev) => [ + ...prev, + { + id: 'docgov-err-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${actionLabel}失败:${message}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }; + + const handleClaim = async () => { + try { + const doc = await claimDocument({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + user_role: 'author', + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('认领文档', err); + } + }; + + const handleSubmitReview = async () => { + try { + const doc = await submitForReview({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('提交送审', err); + } + }; + + const handlePassReview = async () => { + try { + const doc = await reviewDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'approve', + }); + applyDocGovRecord(doc); + } catch (err) { + pushDocGovError('审核通过', err); + } + }; + + const handleRejectReview = async (reason: string) => { + try { + const doc = await reviewDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'reject', + reason, + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'rej-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${currentUser.name}(审核)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + pushDocGovError('审核驳回', err); + } + }; + + const handleApprove = async () => { + try { + const doc = await approverDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'approve', + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'appr-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '水木东方协同参谋', + intentTag: '🏆 终审批准生效', + content: `🎉 祝贺!${currentUser.name}(批准人)已终审批准《${docAST.title}》!系统已自动对该受控交付物进行微粒度章节切片与向量化,成功注入全局标杆案例经验库!`, + timestamp: new Date().toTimeString().substring(0, 5), + recommendedCases: [ + { + docId: docAST.docId, + taskKey: docAST.taskKey, + artifactTitle: docAST.title, + sectionTitle: '终审已生效标杆案例', + similarityScore: 1.0, + keyRegulations: ['GB 9706.1-2020', 'YY 0569-2011', 'IEC 60601-1-2'], + summarySnippet: + '本交付物已通过编制、审核、批准法定三级签署闭环,作为往期标杆经验向全平台开放自推荐。', + }, + ], + }, + ]); + } catch (err) { + pushDocGovError('终审批准', err); + } + }; + + const handleRejectApprove = async (reason: string) => { + try { + const doc = await approverDecision({ + doc_id: docAST.docId, + user_id: currentUser.id, + user_name: currentUser.name, + action: 'reject', + reason, + rollback_to: 'drafting', + }); + applyDocGovRecord(doc); + setMessages((prev) => [ + ...prev, + { + id: 'rej-appr-' + Date.now(), + sessionId: activeSessionId, + role: 'system', + content: `⚠️ ${currentUser.name}(批准人)驳回了《${docAST.title}》,已退回编制阶段。驳回原因:${reason}`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + } catch (err) { + pushDocGovError('批准驳回', err); + } + }; + + return { + handleClaim, + handleSubmitReview, + handlePassReview, + handleRejectReview, + handleApprove, + handleRejectApprove, + }; +}