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;
+}