From 9aafe7bd3774cba9ef318d90832ca373cca67fac Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 06:15:20 +0000 Subject: [PATCH] feat(web): persist block edits via revision API and compare UI Remove hardcoded editor actor, add revision toolbar/history/compare panels, conflict banner, and notification status actions. Co-authored-by: Cursor --- web/e2e/document-revision.spec.ts | 14 ++ web/src/App.tsx | 74 ++++++++++- web/src/api/documentRevision.ts | 103 +++++++++++++++ web/src/components/doc/BlockEditorModal.tsx | 6 +- web/src/components/doc/ChangeReportPanel.tsx | 27 ++++ web/src/components/doc/DiffNavigator.tsx | 14 ++ .../doc/DocumentRevisionToolbar.tsx | 123 ++++++++++++++++++ .../components/doc/ManualChangeOverlay.tsx | 10 ++ web/src/components/doc/VersionCompareView.tsx | 35 +++++ .../components/doc/VersionHistoryDrawer.tsx | 45 +++++++ .../DocumentNotificationList.tsx | 56 ++++++++ web/src/features/documentRevision/types.ts | 113 ++++++++++++++++ 12 files changed, 612 insertions(+), 8 deletions(-) create mode 100644 web/e2e/document-revision.spec.ts create mode 100644 web/src/api/documentRevision.ts create mode 100644 web/src/components/doc/ChangeReportPanel.tsx create mode 100644 web/src/components/doc/DiffNavigator.tsx create mode 100644 web/src/components/doc/DocumentRevisionToolbar.tsx create mode 100644 web/src/components/doc/ManualChangeOverlay.tsx create mode 100644 web/src/components/doc/VersionCompareView.tsx create mode 100644 web/src/components/doc/VersionHistoryDrawer.tsx create mode 100644 web/src/components/notifications/DocumentNotificationList.tsx create mode 100644 web/src/features/documentRevision/types.ts diff --git a/web/e2e/document-revision.spec.ts b/web/e2e/document-revision.spec.ts new file mode 100644 index 00000000..4557cebe --- /dev/null +++ b/web/e2e/document-revision.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; + +/** + * Smoke coverage for DOCREV toolbar / compare entry points. + * Full dual-user conflict E2E requires authenticated env fixtures. + */ +test.describe('document revision UI', () => { + test.skip(!process.env.DOCREV_E2E_BASE_URL, 'set DOCREV_E2E_BASE_URL to run'); + + test('toolbar renders version history control', async ({ page }) => { + await page.goto(process.env.DOCREV_E2E_BASE_URL!); + await expect(page.getByRole('button', { name: '版本历史' })).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index c3e27daa..7518d253 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import { SlimTabBar } from './components/doc/SlimTabBar'; import { DeckDrawer } from './components/doc/DeckDrawer'; import { BlockRenderer } from './components/doc/BlockRenderer'; import { BlockEditorModal } from './components/doc/BlockEditorModal'; +import { DocumentRevisionToolbar } from './components/doc/DocumentRevisionToolbar'; import { CommentSidePanel } from './components/doc/CommentSidePanel'; import { DocGovActionBar } from './components/doc/DocGovActionBar'; import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar'; @@ -19,6 +20,8 @@ import { } from './api/freeformDoc'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; +import { patchDocumentContent, getRevisionState } from './api/documentRevision'; +import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; import { addProjectMember, @@ -673,6 +676,9 @@ export default function App() { if (targetProjId === 3) return 'doc-cusa-feasibility-reg'; return deliverables[0]?.docId || 'doc-001'; }); + const [docRevOverlayMode, setDocRevOverlayMode] = useState('manual'); + const [docRevOverlay, setDocRevOverlay] = useState(null); + const [docRevConflict, setDocRevConflict] = useState(null); const [deckDrawerOpen, setDeckDrawerOpen] = useState(false); // 认证与会话状态 @@ -3128,16 +3134,52 @@ export default function App() { ]); }; - // 块操作 - const handleSaveBlockEdit = (updated: DocumentBlock) => { + // 块操作:更新本地 AST 后经 revision ledger 持久化(服务端身份为准) + const handleSaveBlockEdit = async (updated: DocumentBlock) => { const children = docAST.rootBlock.children || []; const idx = children.findIndex((c) => c.id === updated.id); - if (idx !== -1) { - children[idx] = updated; - recomputeNumbering(docAST.rootBlock); - setDocAST({ ...docAST }); + if (idx === -1) { + setEditingBlock(null); + return; } + children[idx] = updated; + recomputeNumbering(docAST.rootBlock); + const nextAST = { ...docAST }; + setDocAST(nextAST); + setDocCache((prev) => ({ ...prev, [activeDocId]: nextAST })); setEditingBlock(null); + setDocRevConflict(null); + + const target = deliverables.find((d) => d.docId === activeDocId); + if (!target || target.governanceMode === 'freeform') { + return; + } + try { + let etag = '0:0'; + try { + const st = await getRevisionState(activeDocId); + etag = st.etag; + } catch { + /* first save bootstraps ledger */ + } + const markdown = blockASTToMarkdown(nextAST); + const idempotencyKey = + (typeof crypto !== 'undefined' && 'randomUUID' in crypto && crypto.randomUUID()) || + `edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const res = await patchDocumentContent( + activeDocId, + { markdown, edit_session_id: `ui-${currentUser.id}` }, + { etag, idempotencyKey }, + ); + setDocRevOverlay(res.manual_overlay || null); + } catch (e) { + const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } }; + if (err.code === 'DOCUMENT_HEAD_CONFLICT') { + setDocRevConflict('文档已被他人更新。请刷新后重做,系统不会自动覆盖你的副本。'); + } else { + setDocRevConflict(err.message || '保存失败'); + } + } }; const handleInsertBlock = (index: number, position: 'above' | 'below') => { @@ -3797,6 +3839,25 @@ export default function App() { onExportXlsx={handleExportXlsx} onExportMarkdown={handleExportMarkdown} /> + + {docRevConflict && ( +
+ {docRevConflict} + +
+ )} + {docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && ( +
+ 人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处 +
+ )} {/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */} setEditingBlock(null)} /> diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts new file mode 100644 index 00000000..5c070412 --- /dev/null +++ b/web/src/api/documentRevision.ts @@ -0,0 +1,103 @@ +import type { + ChangeReport, + DiffResult, + DocumentVersion, + PatchContentBody, + RevisionState, +} from '../features/documentRevision/types'; + +async function api(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'include', + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error((data as { error?: string }).error || res.statusText) as Error & { + status: number; + code?: string; + conflict?: unknown; + body?: unknown; + }; + err.status = res.status; + err.code = (data as { code?: string }).code; + err.conflict = (data as { conflict?: unknown }).conflict; + err.body = data; + throw err; + } + return data as T; +} + +export function getRevisionState(docId: string) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/revision-state`); +} + +export function listVersions(docId: string) { + return api<{ versions: DocumentVersion[] }>(`/api/v1/documents/${encodeURIComponent(docId)}/versions`); +} + +export function getVersionContent(docId: string, versionNo: number, view: 'final' | 'baseline' = 'final') { + return api<{ content_markdown: string; content_sha256: string }>( + `/api/v1/documents/${encodeURIComponent(docId)}/versions/${versionNo}?view=${view}`, + ); +} + +export function getDiff(docId: string, fromVersion: number, toVersion: number) { + return api( + `/api/v1/documents/${encodeURIComponent(docId)}/diff?from_version=${fromVersion}&to_version=${toVersion}&from_view=final&to_view=final`, + ); +} + +export function getManualOverlay(docId: string) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/manual-overlay`); +} + +export function patchDocumentContent( + docId: string, + body: PatchContentBody, + opts: { etag: string; idempotencyKey: string }, +) { + return api<{ state: RevisionState; manual_overlay: DiffResult; conflict?: unknown }>( + `/api/v1/documents/${encodeURIComponent(docId)}/content`, + { + method: 'PATCH', + headers: { + 'If-Match': `"${opts.etag}"`, + 'Idempotency-Key': opts.idempotencyKey, + }, + body: JSON.stringify(body), + }, + ); +} + +export function getChangeReport(docId: string, reportId: number) { + return api(`/api/v1/documents/${encodeURIComponent(docId)}/change-reports/${reportId}`); +} + +export function decideAgentCandidate( + docId: string, + candidateId: number, + body: { + decision: 'commit' | 'reject'; + confirmed?: boolean; + expected_input_sha?: string; + expected_output_sha?: string; + reason?: string; + }, +) { + return api( + `/api/v1/documents/${encodeURIComponent(docId)}/agent-candidates/${candidateId}/decision`, + { method: 'POST', body: JSON.stringify(body) }, + ); +} + +export function patchNotification(id: number, status: 'read' | 'acted' | 'dismissed') { + return api<{ ok: boolean }>(`/api/v1/notifications/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); +} diff --git a/web/src/components/doc/BlockEditorModal.tsx b/web/src/components/doc/BlockEditorModal.tsx index 0bb7d29a..682f42fa 100644 --- a/web/src/components/doc/BlockEditorModal.tsx +++ b/web/src/components/doc/BlockEditorModal.tsx @@ -28,9 +28,11 @@ interface BlockEditorModalProps { block: DocumentBlock; onSave: (updated: DocumentBlock) => void; onClose: () => void; + /** Server-trusted display name; never hardcode. */ + actorLabel?: string; } -export const BlockEditorModal: React.FC = ({ block, onSave, onClose }) => { +export const BlockEditorModal: React.FC = ({ block, onSave, onClose, actorLabel }) => { const [content, setContent] = useState(block.content || ''); const [level, setLevel] = useState(block.level || 1); const [tableData, setTableData] = useState( @@ -101,7 +103,7 @@ export const BlockEditorModal: React.FC = ({ block, onSav }, metadata: { ...block.metadata, - lastModifiedBy: '张工 (编制负责人)', + lastModifiedBy: actorLabel || '未知用户', lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16), }, }; diff --git a/web/src/components/doc/ChangeReportPanel.tsx b/web/src/components/doc/ChangeReportPanel.tsx new file mode 100644 index 00000000..a4faab83 --- /dev/null +++ b/web/src/components/doc/ChangeReportPanel.tsx @@ -0,0 +1,27 @@ +import React, { useEffect, useState } from 'react'; +import type { ChangeReport } from '../../features/documentRevision/types'; +import { getChangeReport } from '../../api/documentRevision'; + +export function ChangeReportPanel(props: { docId: string; reportId: number; onClose: () => void }) { + const [report, setReport] = useState(null); + useEffect(() => { + getChangeReport(props.docId, props.reportId).then(setReport).catch(() => setReport(null)); + }, [props.docId, props.reportId]); + return ( +
+
+

变更报告

+ +
+ {!report ?

加载中…

: ( + <> +

风险:{report.risk_level} · {report.analysis_status}

+
    {report.summary.map((s) =>
  • {s}
  • )}
+ {report.document_impacts?.length > 0 && ( +

影响文档 {report.document_impacts.length} 份

+ )} + + )} +
+ ); +} diff --git a/web/src/components/doc/DiffNavigator.tsx b/web/src/components/doc/DiffNavigator.tsx new file mode 100644 index 00000000..1aa57a2f --- /dev/null +++ b/web/src/components/doc/DiffNavigator.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import type { DiffHunk } from '../../features/documentRevision/types'; + +export function DiffNavigator(props: { hunks: DiffHunk[] }) { + return ( + + ); +} diff --git a/web/src/components/doc/DocumentRevisionToolbar.tsx b/web/src/components/doc/DocumentRevisionToolbar.tsx new file mode 100644 index 00000000..13bec9d7 --- /dev/null +++ b/web/src/components/doc/DocumentRevisionToolbar.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState } from 'react'; +import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types'; +import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision'; +import { VersionHistoryDrawer } from './VersionHistoryDrawer'; +import { VersionCompareView } from './VersionCompareView'; +import { ChangeReportPanel } from './ChangeReportPanel'; +import { ManualChangeOverlay } from './ManualChangeOverlay'; + +export function DocumentRevisionToolbar(props: { + docId: string; + overlayMode: OverlayMode; + onOverlayModeChange: (m: OverlayMode) => void; + onOverlay: (d: DiffResult | null) => void; +}) { + const { docId, overlayMode, onOverlayModeChange, onOverlay } = props; + const [state, setState] = useState(null); + const [versions, setVersions] = useState([]); + const [historyOpen, setHistoryOpen] = useState(false); + const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null); + const [reportId, setReportId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const st = await getRevisionState(docId); + if (cancelled) return; + setState(st); + setError(null); + if (overlayMode === 'manual') { + onOverlay(await getManualOverlay(docId)); + } else { + onOverlay(null); + } + } catch (e) { + if (!cancelled) { + setState(null); + setError((e as Error).message); + onOverlay(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [docId, overlayMode, onOverlay]); + + return ( +
+ {state ? ( + <> + V{state.current_version_no} + {state.version_status === 'open' ? '当前' : '已封存'} + r{state.head_revision_no} + {state.manual_change_count} 处人工修改 + + ) : ( + {error ? `版本账本未就绪:${error}` : '加载版本状态…'} + )} + + + + + {historyOpen && ( + setHistoryOpen(false)} + onSelectCompare={async (a, b) => { + const diff = await getDiff(docId, a, b); + setCompare({ a, b, diff }); + setHistoryOpen(false); + }} + onOpenReport={(id) => setReportId(id)} + /> + )} + {compare && compare.diff && ( + setCompare(null)} + /> + )} + {reportId != null && ( + setReportId(null)} /> + )} + {overlayMode === 'manual' && } +
+ ); +} diff --git a/web/src/components/doc/ManualChangeOverlay.tsx b/web/src/components/doc/ManualChangeOverlay.tsx new file mode 100644 index 00000000..d664ab94 --- /dev/null +++ b/web/src/components/doc/ManualChangeOverlay.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +/** Marker component — decorations are applied via overlay hunks on blocks. */ +export function ManualChangeOverlay() { + return ( + + 人工修改标记:琥珀底 + 下划线(相对最新智能体 baseline) + + ); +} diff --git a/web/src/components/doc/VersionCompareView.tsx b/web/src/components/doc/VersionCompareView.tsx new file mode 100644 index 00000000..f2872024 --- /dev/null +++ b/web/src/components/doc/VersionCompareView.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { DiffResult } from '../../features/documentRevision/types'; +import { DiffNavigator } from './DiffNavigator'; + +export function VersionCompareView(props: { + fromVersion: number; + toVersion: number; + diff: DiffResult; + onClose: () => void; +}) { + const { fromVersion, toVersion, diff, onClose } = props; + const narrow = typeof window !== 'undefined' && window.innerWidth < 900; + return ( +
+
+

比较 V{fromVersion} → V{toVersion}

+ +
+

+ +{diff.summary.inserts} / -{diff.summary.deletes} / ~{diff.summary.modifies} / ↔{diff.summary.moves} + {narrow ? '(窄屏 unified)' : '(宽屏 side-by-side)'} +

+ +
+ {diff.hunks.map((h) => ( +
+
{h.kind} · {h.section || '正文'}
+ {h.before_text != null &&
{h.before_text}
} + {h.after_text != null &&
{h.after_text}
} +
+ ))} +
+
+ ); +} diff --git a/web/src/components/doc/VersionHistoryDrawer.tsx b/web/src/components/doc/VersionHistoryDrawer.tsx new file mode 100644 index 00000000..3365de5b --- /dev/null +++ b/web/src/components/doc/VersionHistoryDrawer.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { DocumentVersion } from '../../features/documentRevision/types'; + +export function VersionHistoryDrawer(props: { + versions: DocumentVersion[]; + onClose: () => void; + onSelectCompare: (a: number, b: number) => void; + onOpenReport: (reportId: number) => void; +}) { + const { versions, onClose, onSelectCompare, onOpenReport } = props; + const [a, setA] = React.useState(null); + const [b, setB] = React.useState(null); + return ( +
+
+

版本历史

+ +
+
    + {versions.map((v) => ( +
  • +
    + V{v.version_no} {v.status === 'open' ? '当前' : '已封存'} · {v.origin_kind} +
    +
    人工修订 {v.human_revision_count}
    +
    + + + {v.change_report_id ? ( + + ) : null} +
    +
  • + ))} +
+ +
+ ); +} diff --git a/web/src/components/notifications/DocumentNotificationList.tsx b/web/src/components/notifications/DocumentNotificationList.tsx new file mode 100644 index 00000000..36dc1046 --- /dev/null +++ b/web/src/components/notifications/DocumentNotificationList.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { patchNotification } from '../../api/documentRevision'; + +export type DocNotificationItem = { + id: number; + type: string; + title: string; + body: string; + status: string; + created_at?: string; +}; + +export function DocumentNotificationList(props: { + items: DocNotificationItem[]; + onChanged?: () => void; +}) { + return ( +
    + {props.items.map((n) => ( +
  • +
    {n.title}
    +
    {n.body}
    +
    + + + +
    +
  • + ))} +
+ ); +} diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts new file mode 100644 index 00000000..1320c35b --- /dev/null +++ b/web/src/features/documentRevision/types.ts @@ -0,0 +1,113 @@ +/** Document revision ledger types (aligned with internal/docrevision). */ +export type VersionStatus = 'open' | 'sealed'; +export type OriginKind = 'template' | 'agent' | 'agent_squad' | 'migration'; +export type ActorKind = 'human' | 'agent' | 'agent_squad' | 'template' | 'migration' | 'system'; +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; +export type OverlayMode = 'pure' | 'manual' | 'all'; + +export interface ActorSnapshot { + display_name?: string; + role?: string; + duty?: string; + agent_key?: string; + squad_name?: string; + model?: string; + config_version?: string; +} + +export interface BlockNode { + id: string; + type: string; + start_offset: number; + end_offset: number; + heading_path?: string; + business_key?: string; + fingerprint?: string; + order: number; +} + +export interface RevisionState { + doc_id: string; + current_version_no: number; + version_status: VersionStatus; + baseline_snapshot_id: number; + head_snapshot_id: number; + head_revision_no: number; + head_sha256: string; + manual_change_count: number; + capabilities: string[]; + etag: string; +} + +export interface DiffHunk { + id: string; + kind: 'insert' | 'delete' | 'modify' | 'move'; + section?: string; + before_text?: string; + after_text?: string; + block_id_before?: string; + block_id_after?: string; + actor_kind?: ActorKind; + actor_label?: string; + line_start?: number; + line_end?: number; +} + +export interface DiffResult { + from_snapshot_id?: number; + to_snapshot_id?: number; + summary: { inserts: number; deletes: number; modifies: number; moves: number }; + hunks: DiffHunk[]; +} + +export interface DocumentVersion { + id: number; + doc_id: string; + version_no: number; + status: VersionStatus; + origin_kind: OriginKind; + baseline_snapshot_id: number; + head_snapshot_id: number; + human_revision_count: number; + source_run_id?: string; + change_report_id?: number; + opened_at: string; + sealed_at?: string; +} + +export interface ChangeReport { + id: number; + doc_id: string; + schema_version: number; + summary: string[]; + risk_level: RiskLevel; + risk_items: { category: string; description: string; mitigation?: string }[]; + document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[]; + open_questions: string[]; + analysis_status: string; +} + +export interface ConflictInfo { + code: string; + base_snapshot_id: number; + current_snapshot_id: number; + current_revision_no: number; + current_sha256: string; +} + +export interface PatchContentBody { + markdown: string; + edit_session_id?: string; + client_block_map_version?: number; +} + +export const DOC_REV_ERROR = { + HEAD_CONFLICT: 'DOCUMENT_HEAD_CONFLICT', + EDIT_FORBIDDEN: 'DOCUMENT_EDIT_FORBIDDEN', + BUSY: 'DOCUMENT_BUSY', + CANDIDATE_STALE: 'AGENT_CANDIDATE_STALE', + REPORT_INVALID: 'CHANGE_REPORT_INVALID', + LIFECYCLE_LOCKED: 'DOCUMENT_LIFECYCLE_LOCKED', + TOO_LARGE: 'DOCUMENT_TOO_LARGE', + NEEDS_CONFIRMATION: 'AGENT_NEEDS_CONFIRMATION', +} as const;