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 99772f7c..d09ecce0 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 | 部分。登录壳不再静态引用导出引擎、文件预览、管理台、块编辑器和图表。入口包 2.83 MB → 1.00 MB(br 626 KB → 235 KB)。`loginShellImports.test.ts`。工作台主体仍在 `App.tsx`,未按 hook 拆文件 | +| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入收到 `useWorkbenchPersistence`(含一次旧消息键迁移)。工作台主体仍在 `App.tsx`,其余 effect 未拆完 | | 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 4610ca5d..6145696f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -187,23 +187,18 @@ import { import { resolveTaskTemplateForMessage } from './engine/templateMatch'; import { loadSessions, - saveSessions, loadActiveSessionId, - saveActiveSessionId, loadDeliverables, - saveDeliverables, loadUsers, - saveUsers, - migrateAndClearLegacyMessages, purgeDemoSeedsFromLocalStorage, resolveActiveSessionForUser, loadProjects, - saveProjects, filterProjectSessionsForUser, getProjectMembership, buildPrivateSessionId, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; +import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence'; import { useTurnRuntime } from './hooks/useTurnRuntime'; import { useTaskRunStream } from './hooks/useTaskRunStream'; import { fetchChatTurnStatus } from './api/chatTurn'; @@ -226,7 +221,7 @@ import { useUnreadMarks } from './hooks/useUnreadMarks'; import { CategoryDock } from './components/chat/CategoryDock'; import { fetchChatCategories, fetchChatDates, mapTaskStatusToMessageStatus } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage, AssistantMessageStatus } from './types/workbench'; -import { saveFile, restoreFileRawToDeliverables, applyRestoredFileRaw } from './data/fileStore'; +import { saveFile } from './data/fileStore'; import { uploadProjectAsset } from './api/projectAssets'; import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun, getTaskRun } from './api/taskRuns'; @@ -297,23 +292,10 @@ export default function App() { const [sessions, setSessions] = useState(() => loadSessions()); const [activeSessionId, setActiveSessionId] = useState(() => loadActiveSessionId('sess-001')); - // 自动持久化 sessions 与 activeSessionId - useEffect(() => { - saveSessions(sessions); - }, [sessions]); - - useEffect(() => { - saveActiveSessionId(activeSessionId); - }, [activeSessionId]); - // 全局角色库与人员名单状态 (支持动态增删改与持久化) const [roles, setRoles] = useState(() => DEFAULT_ROLES); const [allUsers, setAllUsers] = useState(() => loadUsers(MOCK_USERS)); - useEffect(() => { - saveUsers(allUsers); - }, [allUsers]); - // 当前用户身份 (职位在全公司唯一不变,绑定全局角色,但在不同文档有不同法定职责) const [currentUser, setCurrentUser] = useState(() => allUsers[0] || MOCK_USERS[0] || { id: 0, @@ -332,24 +314,6 @@ export default function App() { const deliverablesRef = useRef(deliverables); deliverablesRef.current = deliverables; - // 自动持久化 deliverables(序列化时剔除不可持久化的 File 对象,见 storage.ts) - useEffect(() => { - saveDeliverables(deliverables); - }, [deliverables]); - - // ✅ 启动时从 IndexedDB 恢复上传文件的 fileRaw(解决页面刷新后 File 对象丢失问题) - // 仅在组件 mount 时执行一次;恢复成功后对 deliverables 做最小化 patch(函数式合并,不覆盖期间新增)。 - useEffect(() => { - let cancelled = false; - const snapshot = deliverablesRef.current; - restoreFileRawToDeliverables(snapshot).then((restored) => { - if (cancelled || restored === snapshot) return; - setDeliverables((prev) => applyRestoredFileRaw(prev, restored)); - }); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // 只在 mount 时运行一次 - const [activeCategory, setActiveCategory] = useState('workflow'); // 项目与成员体系状态 (支持持久化) @@ -358,9 +322,15 @@ export default function App() { projectsRef.current = projects; const memberInflightRef = useRef(new Map>()); const sessionEpochRef = useRef(new RequestEpoch()); - useEffect(() => { - saveProjects(projects); - }, [projects]); + useWorkbenchPersistence({ + sessions, + activeSessionId, + allUsers, + deliverables, + deliverablesRef, + setDeliverables, + projects, + }); const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); const [createProjectError, setCreateProjectError] = useState(null); @@ -1588,10 +1558,6 @@ export default function App() { // 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage const [messages, setMessages] = useState([]); - useEffect(() => { - void migrateAndClearLegacyMessages(); - }, []); - // 基于当前激活的项目会话严格隔离对话记录 const currentSessionMessages = useMemo(() => { return messages.filter((m) => m.sessionId === activeSessionId); @@ -1809,11 +1775,6 @@ export default function App() { setActiveQuotes((prev) => prev.filter((_, i) => i !== index)); }; - // 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键 - useEffect(() => { - migrateAndClearLegacyMessages(); - }, []); - // 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态) const isStreamingActive = useMemo(() => { return currentSessionMessages.some( diff --git a/web/src/hooks/useWorkbenchPersistence.test.ts b/web/src/hooks/useWorkbenchPersistence.test.ts new file mode 100644 index 00000000..a6006c3b --- /dev/null +++ b/web/src/hooks/useWorkbenchPersistence.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +test('App persists workbench state through one hook and migrates legacy messages once', () => { + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8'); + const hook = readFileSync(new URL('./useWorkbenchPersistence.ts', import.meta.url), 'utf8'); + assert.equal((app.match(/useWorkbenchPersistence\(/g) || []).length, 1); + assert.equal(app.includes('migrateAndClearLegacyMessages'), false); + assert.equal(app.includes('saveSessions('), false); + assert.equal(app.includes('saveProjects('), false); + for (const name of ['saveSessions', 'saveProjects', 'saveDeliverables', 'saveUsers', 'migrateAndClearLegacyMessages']) { + assert.equal(hook.includes(name), true, `hook missing ${name}`); + } + assert.equal((hook.match(/migrateAndClearLegacyMessages/g) || []).length, 2); +}); diff --git a/web/src/hooks/useWorkbenchPersistence.ts b/web/src/hooks/useWorkbenchPersistence.ts new file mode 100644 index 00000000..253a6993 --- /dev/null +++ b/web/src/hooks/useWorkbenchPersistence.ts @@ -0,0 +1,74 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { + migrateAndClearLegacyMessages, + saveActiveSessionId, + saveDeliverables, + saveProjects, + saveSessions, + saveUsers, +} from '../data/storage'; +import { applyRestoredFileRaw, restoreFileRawToDeliverables } from '../data/fileStore'; +import type { UserProfile } from '../types/docgov'; +import type { DeliverableItem, ProjectInfo, ProjectSession } from '../types/workbench'; + +export interface WorkbenchPersistenceInput { + sessions: ProjectSession[]; + activeSessionId: string; + allUsers: UserProfile[]; + deliverables: DeliverableItem[]; + deliverablesRef: MutableRefObject; + setDeliverables: Dispatch>; + projects: ProjectInfo[]; +} + +/** + * 工作台本地缓存的写入与一次性迁移。 + * 必须在对应 useState 初始化之后调用;demo seed 清理仍留在渲染前,不能放进 effect。 + */ +export function useWorkbenchPersistence(input: WorkbenchPersistenceInput): void { + const { + sessions, + activeSessionId, + allUsers, + deliverables, + deliverablesRef, + setDeliverables, + projects, + } = input; + + useEffect(() => { + saveSessions(sessions); + }, [sessions]); + + useEffect(() => { + saveActiveSessionId(activeSessionId); + }, [activeSessionId]); + + useEffect(() => { + saveUsers(allUsers); + }, [allUsers]); + + useEffect(() => { + saveDeliverables(deliverables); + }, [deliverables]); + + useEffect(() => { + saveProjects(projects); + }, [projects]); + + useEffect(() => { + let cancelled = false; + const snapshot = deliverablesRef.current; + restoreFileRawToDeliverables(snapshot).then((restored) => { + if (cancelled || restored === snapshot) return; + setDeliverables((prev) => applyRestoredFileRaw(prev, restored)); + }); + return () => { + cancelled = true; + }; + }, [deliverablesRef, setDeliverables]); + + useEffect(() => { + void migrateAndClearLegacyMessages(); + }, []); +}