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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 06:15:20 +00:00
co-authored by Cursor
parent 7a7162dcee
commit 9aafe7bd37
12 changed files with 612 additions and 8 deletions
+14
View File
@@ -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 });
});
});
+68 -6
View File
@@ -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<OverlayMode>('manual');
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
const [docRevConflict, setDocRevConflict] = useState<string | null>(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}
/>
<DocumentRevisionToolbar
docId={activeDocId}
overlayMode={docRevOverlayMode}
onOverlayModeChange={setDocRevOverlayMode}
onOverlay={setDocRevOverlay}
/>
{docRevConflict && (
<div role="alert" style={{ padding: '8px 12px', background: '#fff3cd', borderBottom: '1px solid #e0c36a' }}>
{docRevConflict}
<button type="button" style={{ marginLeft: 8 }} onClick={() => window.location.reload()}>
刷新后重做
</button>
</div>
)}
{docRevOverlayMode === 'manual' && docRevOverlay && docRevOverlay.hunks?.length > 0 && (
<div style={{ fontSize: 12, padding: '4px 12px', opacity: 0.85 }}>
人工差异 {docRevOverlay.summary.modifies + docRevOverlay.summary.inserts + docRevOverlay.summary.deletes} 处
</div>
)}
{/* 全景大盘抽屉 (支持受控/共享/私密三板块与一键流转操作) */}
<DeckDrawer
@@ -3928,6 +3989,7 @@ export default function App() {
{editingBlock && (
<BlockEditorModal
block={editingBlock}
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
onSave={handleSaveBlockEdit}
onClose={() => setEditingBlock(null)}
/>
+103
View File
@@ -0,0 +1,103 @@
import type {
ChangeReport,
DiffResult,
DocumentVersion,
PatchContentBody,
RevisionState,
} from '../features/documentRevision/types';
async function api<T>(path: string, init?: RequestInit): Promise<T> {
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<RevisionState>(`/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<DiffResult>(
`/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<DiffResult>(`/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<ChangeReport>(`/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<RevisionState>(
`/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 }),
});
}
+4 -2
View File
@@ -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<BlockEditorModalProps> = ({ block, onSave, onClose }) => {
export const BlockEditorModal: React.FC<BlockEditorModalProps> = ({ block, onSave, onClose, actorLabel }) => {
const [content, setContent] = useState(block.content || '');
const [level, setLevel] = useState(block.level || 1);
const [tableData, setTableData] = useState<string[][]>(
@@ -101,7 +103,7 @@ export const BlockEditorModal: React.FC<BlockEditorModalProps> = ({ block, onSav
},
metadata: {
...block.metadata,
lastModifiedBy: '张工 (编制负责人)',
lastModifiedBy: actorLabel || '未知用户',
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
},
};
@@ -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<ChangeReport | null>(null);
useEffect(() => {
getChangeReport(props.docId, props.reportId).then(setReport).catch(() => setReport(null));
}, [props.docId, props.reportId]);
return (
<div role="dialog" aria-label="变更报告" style={{ position: 'fixed', right: 16, bottom: 16, width: 360, maxWidth: '90vw', background: '#fff', border: '1px solid #ccc', zIndex: 60, padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h3>变更报告</h3>
<button type="button" onClick={props.onClose}>关闭</button>
</div>
{!report ? <p>加载中…</p> : (
<>
<p>风险:<strong>{report.risk_level}</strong> · {report.analysis_status}</p>
<ul>{report.summary.map((s) => <li key={s}>{s}</li>)}</ul>
{report.document_impacts?.length > 0 && (
<p>影响文档 {report.document_impacts.length} 份</p>
)}
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import type { DiffHunk } from '../../features/documentRevision/types';
export function DiffNavigator(props: { hunks: DiffHunk[] }) {
return (
<nav aria-label="差异导航" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
{props.hunks.map((h) => (
<a key={h.id} href={`#hunk-${h.id}`} style={{ fontSize: 12 }}>
{h.kind}:{h.id}
</a>
))}
</nav>
);
}
@@ -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<RevisionState | null>(null);
const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null);
const [reportId, setReportId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="doc-rev-toolbar" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid var(--border, #ddd)' }}>
{state ? (
<>
<strong>V{state.current_version_no}</strong>
<span>{state.version_status === 'open' ? '当前' : '已封存'}</span>
<span>r{state.head_revision_no}</span>
<span>{state.manual_change_count} 处人工修改</span>
</>
) : (
<span style={{ opacity: 0.7 }}>{error ? `版本账本未就绪:${error}` : '加载版本状态…'}</span>
)}
<span style={{ flex: 1 }} />
<label>
<select
aria-label="变更显示模式"
value={overlayMode}
onChange={(e) => onOverlayModeChange(e.target.value as OverlayMode)}
>
<option value="pure">纯净阅读</option>
<option value="manual">人工修改</option>
<option value="all">全部变更</option>
</select>
</label>
<button
type="button"
onClick={async () => {
const res = await listVersions(docId);
setVersions(res.versions || []);
setHistoryOpen(true);
}}
>
版本历史
</button>
<button
type="button"
disabled={!state}
onClick={async () => {
if (!state || state.current_version_no < 2) {
setCompare({ a: 1, b: state?.current_version_no || 1, diff: null });
return;
}
const diff = await getDiff(docId, state.current_version_no - 1, state.current_version_no);
setCompare({ a: state.current_version_no - 1, b: state.current_version_no, diff });
}}
>
比较
</button>
{historyOpen && (
<VersionHistoryDrawer
versions={versions}
onClose={() => 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 && (
<VersionCompareView
fromVersion={compare.a}
toVersion={compare.b}
diff={compare.diff}
onClose={() => setCompare(null)}
/>
)}
{reportId != null && (
<ChangeReportPanel docId={docId} reportId={reportId} onClose={() => setReportId(null)} />
)}
{overlayMode === 'manual' && <ManualChangeOverlay />}
</div>
);
}
@@ -0,0 +1,10 @@
import React from 'react';
/** Marker component — decorations are applied via overlay hunks on blocks. */
export function ManualChangeOverlay() {
return (
<span className="manual-change-overlay-hint" style={{ fontSize: 12, opacity: 0.75 }}>
人工修改标记:琥珀底 + 下划线(相对最新智能体 baseline)
</span>
);
}
@@ -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 (
<div role="dialog" aria-label="版本比较" style={{ position: 'fixed', inset: 20, background: 'var(--panel, #fff)', border: '1px solid #ccc', zIndex: 50, padding: 12, overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>比较 V{fromVersion} → V{toVersion}</h3>
<button type="button" onClick={onClose}>关闭</button>
</div>
<p>
+{diff.summary.inserts} / -{diff.summary.deletes} / ~{diff.summary.modifies} / ↔{diff.summary.moves}
{narrow ? '(窄屏 unified)' : '(宽屏 side-by-side)'}
</p>
<DiffNavigator hunks={diff.hunks} />
<div style={{ display: narrow ? 'block' : 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{diff.hunks.map((h) => (
<div key={h.id} id={`hunk-${h.id}`} style={{ borderLeft: '3px solid #c47', paddingLeft: 8, marginBottom: 8 }}>
<div style={{ fontSize: 12 }}>{h.kind} · {h.section || '正文'}</div>
{h.before_text != null && <pre style={{ background: '#fde8e8', margin: 0, whiteSpace: 'pre-wrap' }}>{h.before_text}</pre>}
{h.after_text != null && <pre style={{ background: '#e8f6e8', margin: 0, whiteSpace: 'pre-wrap' }}>{h.after_text}</pre>}
</div>
))}
</div>
</div>
);
}
@@ -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<number | null>(null);
const [b, setB] = React.useState<number | null>(null);
return (
<div role="dialog" aria-label="版本历史" style={{ position: 'fixed', right: 0, top: 0, bottom: 0, width: 360, maxWidth: '100%', background: 'var(--panel, #fff)', borderLeft: '1px solid #ddd', zIndex: 40, padding: 12, overflow: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h3>版本历史</h3>
<button type="button" onClick={onClose} aria-label="关闭">关闭</button>
</div>
<ul style={{ listStyle: 'none', padding: 0 }}>
{versions.map((v) => (
<li key={v.id} style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}>
<div>
<strong>V{v.version_no}</strong> {v.status === 'open' ? '当前' : '已封存'} · {v.origin_kind}
</div>
<div style={{ fontSize: 12, opacity: 0.8 }}>人工修订 {v.human_revision_count}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<button type="button" onClick={() => setA(v.version_no)}>选为 A</button>
<button type="button" onClick={() => setB(v.version_no)}>选为 B</button>
{v.change_report_id ? (
<button type="button" onClick={() => onOpenReport(v.change_report_id!)}>报告</button>
) : null}
</div>
</li>
))}
</ul>
<button
type="button"
disabled={a == null || b == null}
onClick={() => a != null && b != null && onSelectCompare(a, b)}
>
比较 A/B
</button>
</div>
);
}
@@ -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 (
<ul aria-label="文档变更通知" style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{props.items.map((n) => (
<li key={n.id} style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}>
<div><strong>{n.title}</strong></div>
<div style={{ fontSize: 13 }}>{n.body}</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
<button
type="button"
onClick={async () => {
await patchNotification(n.id, 'read');
props.onChanged?.();
}}
>
已读
</button>
<button
type="button"
onClick={async () => {
await patchNotification(n.id, 'acted');
props.onChanged?.();
}}
>
已处理
</button>
<button
type="button"
onClick={async () => {
await patchNotification(n.id, 'dismissed');
props.onChanged?.();
}}
>
忽略
</button>
</div>
</li>
))}
</ul>
);
}
+113
View File
@@ -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;