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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user