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:
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user