diff --git a/web/src/App.tsx b/web/src/App.tsx index 3fb5f2c0..09ddffd5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,10 @@ import { listProjectWorkflowDocuments } from './api/projectDocuments'; import { patchDocumentContent, getRevisionState } from './api/documentRevision'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch'; +import { + documentRevisionInactiveHint, + revisionToolbarMountMode, +} from './features/documentRevision/revisionToolbarMount'; import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; import { addProjectMember, @@ -3887,13 +3891,44 @@ export default function App() { onExportXlsx={handleExportXlsx} onExportMarkdown={handleExportMarkdown} /> - + {(() => { + const revDoc = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const mode = revisionToolbarMountMode( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (mode === 'toolbar' && revDoc) { + return ( + + ); + } + const hint = documentRevisionInactiveHint( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (!hint) return null; + return ( +
+ {hint} +
+ ); + })()} {docRevConflict && (
{docRevConflict} diff --git a/web/src/features/documentRevision/revisionToolbarMount.test.ts b/web/src/features/documentRevision/revisionToolbarMount.test.ts new file mode 100644 index 00000000..9dbd472d --- /dev/null +++ b/web/src/features/documentRevision/revisionToolbarMount.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LOCAL_REVISION_PREVIEW_HINT, + TEMPLATE_REVISION_PREVIEW_HINT, + documentRevisionInactiveHint, + revisionToolbarMountMode, + shouldMountDocumentRevisionToolbar, +} from './revisionToolbarMount'; + +describe('revisionToolbarMount', () => { + it('exposes clear non-error template preview hint copy', () => { + assert.equal(TEMPLATE_REVISION_PREVIEW_HINT, '模板预览:首轮编制后启用版本账本'); + }); + + it('template-only slots do not mount toolbar and show template hint', () => { + const doc = { docId: 'doc-p6059-intake', isTemplateOnly: true as const }; + assert.equal(shouldMountDocumentRevisionToolbar(doc), false); + assert.equal(revisionToolbarMountMode(doc), 'template_hint'); + assert.equal(documentRevisionInactiveHint(doc), TEMPLATE_REVISION_PREVIEW_HINT); + }); + + it('legacy local mock docs do not mount toolbar', () => { + for (const docId of ['doc-001', 'doc-002', 'doc-shared-001', 'doc-private-001', 'doc-cusa-intake']) { + assert.equal(shouldMountDocumentRevisionToolbar({ docId, isTemplateOnly: false }), false, docId); + assert.equal(revisionToolbarMountMode({ docId, isTemplateOnly: false }), 'local_hint', docId); + assert.equal( + documentRevisionInactiveHint({ docId, isTemplateOnly: false }), + LOCAL_REVISION_PREVIEW_HINT, + docId, + ); + } + }); + + it('generated controlled doc-p / doc_proj mount toolbar when not template-only', () => { + assert.equal( + shouldMountDocumentRevisionToolbar({ docId: 'doc-p6059-intake', isTemplateOnly: false }), + true, + ); + assert.equal( + shouldMountDocumentRevisionToolbar({ docId: 'doc_proj6059_intake', isTemplateOnly: false }), + true, + ); + assert.equal( + revisionToolbarMountMode({ docId: 'doc_proj56_srs', isTemplateOnly: false }), + 'toolbar', + ); + assert.equal(documentRevisionInactiveHint({ docId: 'doc_proj56_srs', isTemplateOnly: false }), null); + }); + + it('server freeform mounts toolbar; IndexedDB upload orphan does not', () => { + assert.equal( + shouldMountDocumentRevisionToolbar({ + docId: 'doc-free-1710000000000-2', + isTemplateOnly: false, + governanceMode: 'freeform', + }), + true, + ); + assert.equal( + shouldMountDocumentRevisionToolbar({ + docId: 'doc-upload-1789263145197-0', + isTemplateOnly: false, + sourceType: 'uploaded_file', + governanceMode: 'freeform', + }), + false, + ); + assert.equal( + revisionToolbarMountMode({ + docId: 'doc-upload-1789263145197-0', + isTemplateOnly: false, + sourceType: 'uploaded_file', + }), + 'local_hint', + ); + }); +}); diff --git a/web/src/features/documentRevision/revisionToolbarMount.ts b/web/src/features/documentRevision/revisionToolbarMount.ts new file mode 100644 index 00000000..81b82b60 --- /dev/null +++ b/web/src/features/documentRevision/revisionToolbarMount.ts @@ -0,0 +1,107 @@ +/** + * App 是否挂载 DocumentRevisionToolbar(会请求 revision-state)的可测判断。 + * + * 根因:对 isTemplateOnly 新项目模板与 legacy local mock(如 doc-001)无条件挂工具条, + * 导致「版本账本未就绪」假错误。模板预览只显示非错误提示,不发 revision-state。 + * + * UI-16:IndexedDB 孤儿上传(doc-upload-*)不得挂工具条冒充 V1。 + * 受控生成后(isTemplateOnly=false 的 doc-p / doc_proj)与服务端 freeform 正常挂载。 + */ + +import type { DeliverableItem } from '../../types/workbench'; + +export const TEMPLATE_REVISION_PREVIEW_HINT = '模板预览:首轮编制后启用版本账本'; +export const LOCAL_REVISION_PREVIEW_HINT = '本地预览:尚未接入服务端版本账本'; + +export type RevisionMountDoc = Pick & + Partial< + Pick< + DeliverableItem, + 'governanceMode' | 'sourceType' | 'fileMeta' | 'space' | 'isFixedWorkflow' | 'taskKey' + > + >; + +export type RevisionToolbarMountMode = 'toolbar' | 'template_hint' | 'local_hint' | 'none'; + +/** 前端种子/演示交付物 ID(无服务端账本)。 */ +export function isLegacyLocalMockDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id) return true; + if (/^doc-\d{3}$/.test(id)) return true; + if (/^doc-shared-\d+$/.test(id)) return true; + if (/^doc-private-\d{1,3}$/.test(id)) return true; + if (id.startsWith('doc-cusa-')) return true; + return false; +} + +/** 仅 IndexedDB、从未服务端注册的上传预览(UI-16)。 */ +export function isIndexedDbUploadOrphanDocId(docId: string): boolean { + return /^doc-upload-/.test((docId || '').trim()); +} + +/** 服务端受控 / freeform 事实源 ID。 */ +export function isServerBackedRevisionDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id || isLegacyLocalMockDocId(id) || isIndexedDbUploadOrphanDocId(id)) return false; + if (/^doc_proj\d+_/.test(id)) return true; + if (/^doc-free-\d+/.test(id)) return true; + return false; +} + +/** + * 生成后的受控槽位(isTemplateOnly 已清)可用 doc-p / doc_proj 连字符形态挂载。 + * 模板槽位由 isTemplateOnly 拦截,不会走到这里。 + */ +export function isGeneratedControlledDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id || isLegacyLocalMockDocId(id)) return false; + if (/^doc_proj\d+_/.test(id)) return true; + if (/^doc-p\d+-/.test(id)) return true; + if (/^doc-proj\d+-/.test(id)) return true; + return false; +} + +/** 仅真实可接入账本且非模板槽位才挂工具条并发起 revision-state。 */ +export function shouldMountDocumentRevisionToolbar( + doc: RevisionMountDoc | null | undefined, +): boolean { + if (!doc?.docId) return false; + if (doc.isTemplateOnly) return false; + if (isLegacyLocalMockDocId(doc.docId)) return false; + if (isIndexedDbUploadOrphanDocId(doc.docId)) return false; + + if (isServerBackedRevisionDocId(doc.docId)) return true; + if (isGeneratedControlledDocId(doc.docId)) return true; + + // 服务端 freeform:ID 可能非 doc-free 前缀,但带 freeform 事实字段且非本地孤儿 + if (doc.governanceMode === 'freeform' && doc.sourceType !== 'uploaded_file') { + return !isLegacyLocalMockDocId(doc.docId); + } + + return false; +} + +export function revisionToolbarMountMode( + doc: RevisionMountDoc | null | undefined, +): RevisionToolbarMountMode { + if (!doc?.docId) return 'none'; + if (doc.isTemplateOnly) return 'template_hint'; + if (shouldMountDocumentRevisionToolbar(doc)) return 'toolbar'; + if (isLegacyLocalMockDocId(doc.docId) || isIndexedDbUploadOrphanDocId(doc.docId)) { + return 'local_hint'; + } + if (/^doc-p\d+-/.test(doc.docId) || /^doc-proj\d+-/.test(doc.docId)) { + return 'template_hint'; + } + return 'local_hint'; +} + +/** 未挂载工具条时的非错误提示(不得写成账本未就绪)。 */ +export function documentRevisionInactiveHint( + doc: RevisionMountDoc | null | undefined, +): string | null { + const mode = revisionToolbarMountMode(doc); + if (mode === 'toolbar' || mode === 'none') return null; + if (mode === 'template_hint') return TEMPLATE_REVISION_PREVIEW_HINT; + return LOCAL_REVISION_PREVIEW_HINT; +}