diff --git a/web/src/App.tsx b/web/src/App.tsx index 7518d253..6260b04b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3151,7 +3151,13 @@ export default function App() { setDocRevConflict(null); const target = deliverables.find((d) => d.docId === activeDocId); - if (!target || target.governanceMode === 'freeform') { + // Uploaded binary preview tabs stay file-only; structured freeform/controlled both persist. + if ( + target && + (target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) && + target.governanceMode === 'freeform' && + !docAST.rootBlock?.children?.length + ) { return; } try { @@ -3159,8 +3165,13 @@ export default function App() { try { const st = await getRevisionState(activeDocId); etag = st.etag; - } catch { - /* first save bootstraps ledger */ + } catch (e) { + const err = e as Error & { code?: string }; + if (err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT') { + /* first PATCH bootstraps from markdown body */ + } else if (err.code && err.code !== 'DOCUMENT_REVISION_NOT_FOUND') { + /* continue; PATCH AllowCreateBaseline */ + } } const markdown = blockASTToMarkdown(nextAST); const idempotencyKey = @@ -3172,6 +3183,9 @@ export default function App() { { etag, idempotencyKey }, ); setDocRevOverlay(res.manual_overlay || null); + if (res.state?.etag) { + /* overlay refresh after save */ + } } catch (e) { const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } }; if (err.code === 'DOCUMENT_HEAD_CONFLICT') { @@ -3939,6 +3953,8 @@ export default function App() { onMoveBlock={handleMoveBlock} onDeleteBlock={handleDeleteBlock} onOpenComments={setCommentingBlock} + overlayMode={docRevOverlayMode} + overlayHunks={docRevOverlay?.hunks || []} onAskAgent={(snippet) => { handleSendMessage( `针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。` diff --git a/web/src/components/doc/BlockRenderer.tsx b/web/src/components/doc/BlockRenderer.tsx index 814b01a5..ec7abb91 100644 --- a/web/src/components/doc/BlockRenderer.tsx +++ b/web/src/components/doc/BlockRenderer.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { DocumentAST, DocumentBlock } from '../../types/block'; import { UserRole } from '../../types/docgov'; +import type { DiffHunk, OverlayMode } from '../../features/documentRevision/types'; import { HeadingBlockView } from './blocks/HeadingBlockView'; import { ParagraphBlockView } from './blocks/ParagraphBlockView'; import { TableBlockView } from './blocks/TableBlockView'; @@ -21,6 +22,8 @@ export interface BlockRendererProps { onOpenComments: (block: DocumentBlock) => void; onAskAgent?: (content: string) => void; highlightedBlockId?: string | null; + overlayMode?: OverlayMode; + overlayHunks?: DiffHunk[]; } export const BlockRenderer: React.FC = ({ @@ -33,6 +36,8 @@ export const BlockRenderer: React.FC = ({ onOpenComments, onAskAgent, highlightedBlockId, + overlayMode = 'pure', + overlayHunks = [], }) => { const [hoveredIndex, setHoveredIndex] = useState(null); const [contextMenu, setContextMenu] = useState<{ @@ -132,11 +137,22 @@ export const BlockRenderer: React.FC = ({ const isHovered = hoveredIndex === idx; const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id; const commentCount = block.comments?.length || 0; + const hunk = + overlayMode !== 'pure' + ? overlayHunks.find( + (h) => h.block_id_after === block.id || h.block_id_before === block.id, + ) + : undefined; + const manualMark = + !!hunk && overlayMode === 'manual' && (!hunk.actor_kind || hunk.actor_kind === 'human'); + const allMark = !!hunk && overlayMode === 'all'; + const agentish = !!hunk && (hunk.actor_kind === 'agent' || hunk.actor_kind === 'agent_squad'); return (
setHoveredIndex(idx)} onMouseLeave={() => setHoveredIndex(null)} onDoubleClick={() => { @@ -166,10 +182,22 @@ export const BlockRenderer: React.FC = ({ : '1px solid transparent', background: isHighlighted ? 'rgba(239, 68, 68, 0.08)' + : manualMark + ? 'var(--amber-50, #fff8e8)' + : allMark && agentish + ? 'rgba(59, 130, 246, 0.08)' + : allMark + ? 'var(--amber-50, #fff8e8)' : isHovered && isEditable ? 'var(--bg-hover)' : 'transparent', - boxShadow: isHighlighted ? '0 0 10px rgba(239, 68, 68, 0.25)' : 'none', + boxShadow: isHighlighted + ? '0 0 10px rgba(239, 68, 68, 0.25)' + : manualMark || (allMark && !agentish) + ? 'inset 0 -2px 0 var(--amber-500, #d97706)' + : allMark && agentish + ? 'inset 0 -2px 0 #3b82f6' + : 'none', transition: 'background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease', cursor: isEditable ? 'text' : 'default', }} diff --git a/web/src/components/doc/DocumentRevisionToolbar.tsx b/web/src/components/doc/DocumentRevisionToolbar.tsx index 34b53fa6..28de0072 100644 --- a/web/src/components/doc/DocumentRevisionToolbar.tsx +++ b/web/src/components/doc/DocumentRevisionToolbar.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types'; +import { DOC_REV_ERROR } from '../../features/documentRevision/types'; import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision'; import { VersionHistoryDrawer } from './VersionHistoryDrawer'; import { VersionCompareView } from './VersionCompareView'; @@ -19,6 +20,7 @@ export function DocumentRevisionToolbar(props: { const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null); const [reportId, setReportId] = useState(null); const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); useEffect(() => { let cancelled = false; @@ -28,15 +30,29 @@ export function DocumentRevisionToolbar(props: { if (cancelled) return; setState(st); setError(null); - if (overlayMode === 'manual') { + setErrorCode(null); + if (overlayMode === 'pure') { + onOverlay(null); + } else if (overlayMode === 'manual') { onOverlay(await getManualOverlay(docId)); } else { - onOverlay(null); + // all: prefer last sealed→current when possible, else manual overlay + if (st.current_version_no >= 2) { + try { + onOverlay(await getDiff(docId, st.current_version_no - 1, st.current_version_no)); + } catch { + onOverlay(await getManualOverlay(docId)); + } + } else { + onOverlay(await getManualOverlay(docId)); + } } } catch (e) { if (!cancelled) { + const err = e as Error & { code?: string }; setState(null); - setError((e as Error).message); + setErrorCode(err.code || null); + setError(err.message); onOverlay(null); } } @@ -46,6 +62,13 @@ export function DocumentRevisionToolbar(props: { }; }, [docId, overlayMode, onOverlay]); + const emptyHint = + errorCode === DOC_REV_ERROR.NEEDS_CONTENT + ? '版本账本待注册:请先编辑并保存正文,系统将创建首个版本基线' + : error + ? `版本账本未就绪:${error}` + : '加载版本状态…'; + return (
{state ? ( @@ -56,7 +79,7 @@ export function DocumentRevisionToolbar(props: { {state.manual_change_count} 处人工修改 ) : ( - {error ? `版本账本未就绪:${error}` : '加载版本状态…'} + {emptyHint} )}
); } diff --git a/web/src/features/documentRevision/overlayMode.test.ts b/web/src/features/documentRevision/overlayMode.test.ts new file mode 100644 index 00000000..18434243 --- /dev/null +++ b/web/src/features/documentRevision/overlayMode.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { DiffHunk, OverlayMode } from './types'; +import { DOC_REV_ERROR } from './types'; + +function decorationFor(mode: OverlayMode, hunk?: DiffHunk): 'none' | 'manual' | 'agent' { + if (mode === 'pure' || !hunk) return 'none'; + if (mode === 'manual') { + return !hunk.actor_kind || hunk.actor_kind === 'human' ? 'manual' : 'none'; + } + if (hunk.actor_kind === 'agent' || hunk.actor_kind === 'agent_squad') return 'agent'; + return 'manual'; +} + +describe('document revision overlay modes', () => { + const human: DiffHunk = { id: '1', kind: 'modify', block_id_after: 'b1', actor_kind: 'human' }; + const agent: DiffHunk = { id: '2', kind: 'insert', block_id_after: 'b2', actor_kind: 'agent_squad' }; + + it('pure hides all decorations', () => { + assert.equal(decorationFor('pure', human), 'none'); + assert.equal(decorationFor('pure', agent), 'none'); + }); + + it('manual only marks human hunks', () => { + assert.equal(decorationFor('manual', human), 'manual'); + assert.equal(decorationFor('manual', agent), 'none'); + }); + + it('all marks human and agent differently', () => { + assert.equal(decorationFor('all', human), 'manual'); + assert.equal(decorationFor('all', agent), 'agent'); + }); + + it('exposes needs-content error code for empty orphan docs', () => { + assert.equal(DOC_REV_ERROR.NEEDS_CONTENT, 'DOCUMENT_REVISION_NEEDS_CONTENT'); + }); +}); diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts index 1320c35b..45b5c59b 100644 --- a/web/src/features/documentRevision/types.ts +++ b/web/src/features/documentRevision/types.ts @@ -110,4 +110,6 @@ export const DOC_REV_ERROR = { LIFECYCLE_LOCKED: 'DOCUMENT_LIFECYCLE_LOCKED', TOO_LARGE: 'DOCUMENT_TOO_LARGE', NEEDS_CONFIRMATION: 'AGENT_NEEDS_CONFIRMATION', + NOT_FOUND: 'DOCUMENT_REVISION_NOT_FOUND', + NEEDS_CONTENT: 'DOCUMENT_REVISION_NEEDS_CONTENT', } as const;