feat(web): full change-report panel and pending confirm/reject UI

Prefer pending reports on the toolbar, show risk items and impacts, and
call the decision API only when can_decide_pending is true.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:38:22 +00:00
co-authored by Cursor
parent 79e8349d71
commit 5caefc887a
5 changed files with 345 additions and 48 deletions
+133 -41
View File
@@ -1,17 +1,52 @@
import { useEffect, useState } from 'react';
import type { ChangeReport } from '../../features/documentRevision/types';
import { buildChangeReportViewModel } from '../../features/documentRevision/changeReportView';
import { getChangeReport } from '../../api/documentRevision';
export function ChangeReportPanel(props: { docId: string; reportId: number; onClose: () => void }) {
export function ChangeReportPanel(props: {
docId: string;
reportId: number;
onClose: () => void;
/** Optional: pending candidate decision UI (author only). */
pendingDecision?: {
candidateId: number;
canDecide: boolean;
riskLevel?: string;
busy?: boolean;
error?: string | null;
onConfirm: () => void;
onReject: () => void;
};
}) {
const [report, setReport] = useState<ChangeReport | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getChangeReport(props.docId, props.reportId).then(setReport).catch(() => setReport(null));
let cancelled = false;
setLoading(true);
setLoadError(null);
setReport(null);
getChangeReport(props.docId, props.reportId)
.then((r) => {
if (!cancelled) {
setReport(r);
setLoading(false);
}
})
.catch((e: Error) => {
if (!cancelled) {
setReport(null);
setLoadError(e.message || '变更报告加载失败');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [props.docId, props.reportId]);
const projectSummary =
report?.project_impacts && typeof report.project_impacts.summary === 'string'
? String(report.project_impacts.summary)
: null;
const vm = report ? buildChangeReportViewModel(report) : null;
return (
<div
@@ -22,7 +57,7 @@ export function ChangeReportPanel(props: { docId: string; reportId: number; onCl
position: 'fixed',
right: 16,
bottom: 16,
width: 400,
width: 420,
maxWidth: '90vw',
maxHeight: '70vh',
overflow: 'auto',
@@ -38,55 +73,112 @@ export function ChangeReportPanel(props: { docId: string; reportId: number; onCl
关闭
</button>
</div>
{!report ? (
<p>加载中…</p>
) : (
{loading ? <p data-testid="change-report-loading">加载中…</p> : null}
{loadError ? (
<p role="alert" data-testid="change-report-load-error" style={{ color: '#b91c1c' }}>
加载失败:{loadError}
</p>
) : null}
{vm ? (
<>
<p>
风险:<strong data-testid="change-report-risk">{report.risk_level}</strong>
风险:<strong data-testid="change-report-risk">{vm.riskLevel}</strong>
{' · '}
<span data-testid="change-report-analysis">{report.analysis_status}</span>
{report.analysis_status === 'fallback' || report.analysis_status === 'unassessed' ? (
<span style={{ marginLeft: 6, opacity: 0.75 }}>(确定性分析,非完整 PI 叙事)</span>
<span data-testid="change-report-analysis">{vm.analysisStatus}</span>
{vm.analysisNote ? (
<span style={{ marginLeft: 6, opacity: 0.75 }}>({vm.analysisNote})</span>
) : null}
</p>
<h4>摘要</h4>
<ul data-testid="change-report-summary">
{(report.summary || []).map((s) => (
{vm.summary.length === 0 ? <li style={{ opacity: 0.7 }}>(无摘要)</li> : null}
{vm.summary.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
{projectSummary ? (
<p data-testid="change-report-project-impact">
项目影响:{projectSummary}
<h4>风险条目</h4>
<ul data-testid="change-report-risk-items">
{vm.riskItems.length === 0 ? <li style={{ opacity: 0.7 }}>(无风险条目)</li> : null}
{vm.riskItems.map((it, i) => (
<li key={`${it.category}-${i}`}>
<strong>{it.category || '风险'}</strong>:{it.description}
{it.mitigation ? <div style={{ opacity: 0.85 }}>缓解:{it.mitigation}</div> : null}
</li>
))}
</ul>
<h4>项目影响</h4>
<ul data-testid="change-report-project-impact">
{vm.projectImpactLines.length === 0 ? <li style={{ opacity: 0.7 }}>(无项目影响说明)</li> : null}
{vm.projectImpactLines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
<h4>文档影响</h4>
<ul data-testid="change-report-doc-impacts">
{vm.documentImpacts.length === 0 ? (
<li style={{ opacity: 0.7 }}>无(或尚未登记下游 lineage)</li>
) : null}
{vm.documentImpacts.map((d) => (
<li key={`${d.docId}-${d.impactType}`}>
<strong>{d.docId}</strong>
{d.impactType ? ` [${d.impactType}]` : ''}
{d.reason ? `:${d.reason}` : ''}
{d.recommendedAction ? (
<div style={{ opacity: 0.85 }}>建议:{d.recommendedAction}</div>
) : null}
</li>
))}
</ul>
<h4>开放问题</h4>
<ul data-testid="change-report-open-questions">
{vm.openQuestions.length === 0 ? <li style={{ opacity: 0.7 }}>(无开放问题)</li> : null}
{vm.openQuestions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
</>
) : null}
{props.pendingDecision ? (
<div
data-testid="change-report-pending-actions"
style={{ marginTop: 12, paddingTop: 8, borderTop: '1px solid #eee' }}
>
<p style={{ margin: '0 0 8px' }}>
高风险待确认
{props.pendingDecision.riskLevel ? `(${props.pendingDecision.riskLevel})` : ''}
:编制人确认后才会写入版本历史。
</p>
{props.pendingDecision.error ? (
<p role="alert" style={{ color: '#b91c1c' }}>
{props.pendingDecision.error}
</p>
) : null}
{report.document_impacts?.length > 0 ? (
<div data-testid="change-report-doc-impacts">
<h4>文档影响</h4>
<ul>
{report.document_impacts.map((d) => (
<li key={`${d.doc_id}-${d.impact_type}`}>
{d.doc_id}:{d.reason || d.impact_type}
</li>
))}
</ul>
{props.pendingDecision.canDecide ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
data-testid="change-report-confirm"
disabled={props.pendingDecision.busy}
onClick={props.pendingDecision.onConfirm}
>
确认提交
</button>
<button
type="button"
data-testid="change-report-reject"
disabled={props.pendingDecision.busy}
onClick={props.pendingDecision.onReject}
>
拒绝
</button>
</div>
) : (
<p style={{ opacity: 0.75 }}>文档影响:无(或尚未登记下游 lineage)</p>
<p data-testid="change-report-decide-forbidden" style={{ opacity: 0.8 }}>
您无权确认或拒绝此候选(需编制人)。
</p>
)}
{report.open_questions?.length > 0 ? (
<div data-testid="change-report-open-questions">
<h4>开放问题</h4>
<ul>
{report.open_questions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
</div>
) : null}
</>
)}
</div>
) : null}
</div>
);
}
@@ -2,7 +2,14 @@ import { useEffect, useState } from 'react';
import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types';
import { DOC_REV_ERROR } from '../../features/documentRevision/types';
import { pickLatestChangeReportId } from '../../features/documentRevision/toolbarRefresh';
import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision';
import { preferReportId } from '../../features/documentRevision/changeReportView';
import {
decideAgentCandidate,
getDiff,
getManualOverlay,
getRevisionState,
listVersions,
} from '../../api/documentRevision';
import { VersionHistoryDrawer } from './VersionHistoryDrawer';
import { VersionCompareView } from './VersionCompareView';
import { ChangeReportPanel } from './ChangeReportPanel';
@@ -25,6 +32,11 @@ export function DocumentRevisionToolbar(props: {
const [reportId, setReportId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [decideBusy, setDecideBusy] = useState(false);
const [decideError, setDecideError] = useState<string | null>(null);
const [localRefresh, setLocalRefresh] = useState(0);
const refreshToken = revisionRefreshKey + localRefresh;
useEffect(() => {
let cancelled = false;
@@ -75,7 +87,11 @@ export function DocumentRevisionToolbar(props: {
return () => {
cancelled = true;
};
}, [docId, overlayMode, onOverlay, revisionRefreshKey]);
}, [docId, overlayMode, onOverlay, refreshToken]);
const pendingReportId = state?.pending_change_report_id ?? null;
const preferredReportId = preferReportId(pendingReportId, latestReportId);
const hasPending = state?.pending_candidate_id != null && state.pending_candidate_id > 0;
const emptyHint =
errorCode === DOC_REV_ERROR.NEEDS_CONTENT
@@ -84,6 +100,47 @@ export function DocumentRevisionToolbar(props: {
? `版本账本未就绪:${error}`
: '加载版本状态…';
const reloadAfterDecision = () => {
setReportId(null);
setDecideError(null);
setLocalRefresh((n) => n + 1);
};
const onConfirmPending = async () => {
if (!state?.pending_candidate_id || !state.can_decide_pending) return;
setDecideBusy(true);
setDecideError(null);
try {
await decideAgentCandidate(docId, state.pending_candidate_id, {
decision: 'commit',
confirmed: true,
expected_input_sha: state.head_sha256,
});
reloadAfterDecision();
} catch (e) {
setDecideError((e as Error).message || '确认提交失败');
} finally {
setDecideBusy(false);
}
};
const onRejectPending = async () => {
if (!state?.pending_candidate_id || !state.can_decide_pending) return;
setDecideBusy(true);
setDecideError(null);
try {
await decideAgentCandidate(docId, state.pending_candidate_id, {
decision: 'reject',
confirmed: true,
});
reloadAfterDecision();
} catch (e) {
setDecideError((e as Error).message || '拒绝失败');
} finally {
setDecideBusy(false);
}
};
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 ? (
@@ -92,6 +149,11 @@ export function DocumentRevisionToolbar(props: {
<span>{state.version_status === 'open' ? '当前' : '已封存'}</span>
<span data-testid="doc-rev-revision">r{state.head_revision_no}</span>
<span data-testid="doc-rev-manual-count">{state.manual_change_count} 处人工修改</span>
{hasPending ? (
<span data-testid="doc-rev-pending-badge" style={{ color: '#b45309', fontWeight: 600 }}>
待确认 {state.pending_risk_level || '高风险'}
</span>
) : null}
</>
) : (
<span style={{ opacity: 0.7 }}>{emptyHint}</span>
@@ -125,11 +187,17 @@ export function DocumentRevisionToolbar(props: {
<button
type="button"
data-testid="doc-rev-latest-report"
disabled={latestReportId == null}
title={latestReportId == null ? '尚无智能体变更报告(需至少一次小队成功提交)' : '打开最新变更报告'}
onClick={() => latestReportId != null && setReportId(latestReportId)}
disabled={preferredReportId == null}
title={
hasPending
? '打开待确认高风险变更报告'
: preferredReportId == null
? '尚无智能体变更报告(需至少一次小队成功提交)'
: '打开最新变更报告'
}
onClick={() => preferredReportId != null && setReportId(preferredReportId)}
>
变更报告
{hasPending ? '待确认报告' : '变更报告'}
</button>
<button
type="button"
@@ -166,7 +234,31 @@ export function DocumentRevisionToolbar(props: {
/>
)}
{reportId != null && (
<ChangeReportPanel docId={docId} reportId={reportId} onClose={() => setReportId(null)} />
<ChangeReportPanel
docId={docId}
reportId={reportId}
onClose={() => setReportId(null)}
pendingDecision={
hasPending &&
state?.pending_change_report_id != null &&
reportId === state.pending_change_report_id &&
state.pending_candidate_id
? {
candidateId: state.pending_candidate_id,
canDecide: Boolean(state.can_decide_pending),
riskLevel: state.pending_risk_level,
busy: decideBusy,
error: decideError,
onConfirm: () => {
void onConfirmPending();
},
onReject: () => {
void onRejectPending();
},
}
: undefined
}
/>
)}
{overlayMode === 'manual' && state && <ManualChangeOverlay />}
</div>
@@ -0,0 +1,67 @@
import type { ChangeReport } from './types';
export type ChangeReportViewModel = {
riskLevel: string;
analysisStatus: string;
analysisNote: string | null;
summary: string[];
riskItems: { category: string; description: string; mitigation?: string }[];
projectImpactLines: string[];
documentImpacts: {
docId: string;
impactType: string;
reason: string;
recommendedAction: string;
}[];
openQuestions: string[];
};
/** Pure projection of ChangeReport for readable UI (testable without DOM). */
export function buildChangeReportViewModel(report: ChangeReport): ChangeReportViewModel {
const projectImpactLines: string[] = [];
const pi = report.project_impacts;
if (pi && typeof pi === 'object') {
if (typeof pi.summary === 'string' && pi.summary.trim()) {
projectImpactLines.push(pi.summary.trim());
}
for (const [k, v] of Object.entries(pi)) {
if (k === 'summary') continue;
if (v == null || v === '') continue;
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
projectImpactLines.push(`${k}:${String(v)}`);
} else {
try {
projectImpactLines.push(`${k}:${JSON.stringify(v)}`);
} catch {
projectImpactLines.push(`${k}:(无法序列化)`);
}
}
}
}
const analysisNote =
report.analysis_status === 'fallback' || report.analysis_status === 'unassessed'
? '确定性分析,非完整 PI 叙事'
: null;
return {
riskLevel: report.risk_level || '',
analysisStatus: report.analysis_status || '',
analysisNote,
summary: Array.isArray(report.summary) ? report.summary.filter(Boolean) : [],
riskItems: Array.isArray(report.risk_items) ? report.risk_items : [],
projectImpactLines,
documentImpacts: (report.document_impacts || []).map((d) => ({
docId: d.doc_id,
impactType: d.impact_type || '',
reason: d.reason || '',
recommendedAction: d.recommended_action || '',
})),
openQuestions: Array.isArray(report.open_questions) ? report.open_questions.filter(Boolean) : [],
};
}
export function preferReportId(pendingReportId: number | null | undefined, latestCommittedId: number | null): number | null {
if (pendingReportId != null && pendingReportId > 0) return pendingReportId;
return latestCommittedId;
}
@@ -4,6 +4,7 @@ import type { DiffHunk, DocumentVersion, OverlayMode } from './types';
import { DOC_REV_ERROR } from './types';
import { applyHeadBlockMap, hunkMatchesBlock } from './overlayMatch';
import { countVersionsWithReports, pickLatestChangeReportId } from './toolbarRefresh';
import { buildChangeReportViewModel, preferReportId } from './changeReportView';
import type { DocumentAST, DocumentBlock } from '../../types/block';
function decorationFor(mode: OverlayMode, hunk?: DiffHunk): 'none' | 'manual' | 'agent' {
@@ -123,3 +124,43 @@ describe('toolbar refresh + report entry reachability', () => {
assert.notEqual(depsBefore.refreshKey, depsAfterSave.refreshKey);
});
});
describe('change report view model', () => {
it('surfaces risk items, project impact, doc impacts with advice, and open questions', () => {
const vm = buildChangeReportViewModel({
id: 9,
doc_id: 'doc1',
schema_version: 1,
summary: ['确定性差异:+1 / -1 / ~0 处块级变更'],
risk_level: 'high',
risk_items: [
{ category: 'rule_floor', description: '敏感删除抬升风险', mitigation: '编制人确认' },
],
project_impacts: { summary: '下游受影响文档 1 份', severity: 'high' },
document_impacts: [
{
doc_id: 'doc_down',
impact_type: 'stale_candidate',
reason: '上游更新',
recommended_action: '复核验证方案',
},
],
open_questions: ['业务叙事待 PI 补齐'],
analysis_status: 'fallback',
});
assert.equal(vm.riskLevel, 'high');
assert.equal(vm.analysisNote, '确定性分析,非完整 PI 叙事');
assert.equal(vm.riskItems.length, 1);
assert.match(vm.riskItems[0].description, /敏感删除/);
assert.ok(vm.projectImpactLines.some((l) => l.includes('下游受影响')));
assert.ok(vm.projectImpactLines.some((l) => l.includes('severity')));
assert.equal(vm.documentImpacts[0].recommendedAction, '复核验证方案');
assert.equal(vm.openQuestions[0], '业务叙事待 PI 补齐');
});
it('preferReportId prioritizes pending over committed latest', () => {
assert.equal(preferReportId(55, 20), 55);
assert.equal(preferReportId(null, 20), 20);
assert.equal(preferReportId(undefined, null), null);
});
});
@@ -38,6 +38,11 @@ export interface RevisionState {
capabilities: string[];
etag: string;
head_block_map?: BlockNode[];
pending_candidate_id?: number;
pending_change_report_id?: number;
pending_risk_level?: RiskLevel | string;
pending_candidate_status?: string;
can_decide_pending?: boolean;
}
export interface DiffHunk {