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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 11:40:03 +00:00
co-authored by Cursor
parent 22ea4c31ed
commit c22faecfcb
4 changed files with 270 additions and 191 deletions
@@ -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。
+17 -190
View File
@@ -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 () => {
@@ -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);
});
@@ -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<SetStateAction<DeliverableItem[]>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
}
export interface DocumentLifecycleActions {
handleClaim: () => Promise<void>;
handleSubmitReview: () => Promise<void>;
handlePassReview: () => Promise<void>;
handleRejectReview: (reason: string) => Promise<void>;
handleApprove: () => Promise<void>;
handleRejectApprove: (reason: string) => Promise<void>;
}
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,
};
}