From 5caefc887ac92d82a1a68a87f57b2dde5c892d4d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 09:38:22 +0000 Subject: [PATCH] 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 --- web/src/components/doc/ChangeReportPanel.tsx | 174 +++++++++++++----- .../doc/DocumentRevisionToolbar.tsx | 106 ++++++++++- .../documentRevision/changeReportView.ts | 67 +++++++ .../documentRevision/overlayMode.test.ts | 41 +++++ web/src/features/documentRevision/types.ts | 5 + 5 files changed, 345 insertions(+), 48 deletions(-) create mode 100644 web/src/features/documentRevision/changeReportView.ts diff --git a/web/src/components/doc/ChangeReportPanel.tsx b/web/src/components/doc/ChangeReportPanel.tsx index bd47a2e9..1d68f13d 100644 --- a/web/src/components/doc/ChangeReportPanel.tsx +++ b/web/src/components/doc/ChangeReportPanel.tsx @@ -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(null); + const [loadError, setLoadError] = useState(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 (
- {!report ? ( -

加载中…

- ) : ( + {loading ?

加载中…

: null} + {loadError ? ( +

+ 加载失败:{loadError} +

+ ) : null} + {vm ? ( <>

- 风险:{report.risk_level} + 风险:{vm.riskLevel} {' · '} - {report.analysis_status} - {report.analysis_status === 'fallback' || report.analysis_status === 'unassessed' ? ( - (确定性分析,非完整 PI 叙事) + {vm.analysisStatus} + {vm.analysisNote ? ( + ({vm.analysisNote}) ) : null}

摘要

    - {(report.summary || []).map((s) => ( + {vm.summary.length === 0 ?
  • (无摘要)
  • : null} + {vm.summary.map((s) => (
  • {s}
  • ))}
- {projectSummary ? ( -

- 项目影响:{projectSummary} +

风险条目

+
    + {vm.riskItems.length === 0 ?
  • (无风险条目)
  • : null} + {vm.riskItems.map((it, i) => ( +
  • + {it.category || '风险'}:{it.description} + {it.mitigation ?
    缓解:{it.mitigation}
    : null} +
  • + ))} +
+

项目影响

+
    + {vm.projectImpactLines.length === 0 ?
  • (无项目影响说明)
  • : null} + {vm.projectImpactLines.map((line) => ( +
  • {line}
  • + ))} +
+

文档影响

+
    + {vm.documentImpacts.length === 0 ? ( +
  • 无(或尚未登记下游 lineage)
  • + ) : null} + {vm.documentImpacts.map((d) => ( +
  • + {d.docId} + {d.impactType ? ` [${d.impactType}]` : ''} + {d.reason ? `:${d.reason}` : ''} + {d.recommendedAction ? ( +
    建议:{d.recommendedAction}
    + ) : null} +
  • + ))} +
+

开放问题

+
    + {vm.openQuestions.length === 0 ?
  • (无开放问题)
  • : null} + {vm.openQuestions.map((q) => ( +
  • {q}
  • + ))} +
+ + ) : null} + {props.pendingDecision ? ( +
+

+ 高风险待确认 + {props.pendingDecision.riskLevel ? `(${props.pendingDecision.riskLevel})` : ''} + :编制人确认后才会写入版本历史。 +

+ {props.pendingDecision.error ? ( +

+ {props.pendingDecision.error}

) : null} - {report.document_impacts?.length > 0 ? ( -
-

文档影响

-
    - {report.document_impacts.map((d) => ( -
  • - {d.doc_id}:{d.reason || d.impact_type} -
  • - ))} -
+ {props.pendingDecision.canDecide ? ( +
+ +
) : ( -

文档影响:无(或尚未登记下游 lineage)

+

+ 您无权确认或拒绝此候选(需编制人)。 +

)} - {report.open_questions?.length > 0 ? ( -
-

开放问题

-
    - {report.open_questions.map((q) => ( -
  • {q}
  • - ))} -
-
- ) : null} - - )} +
+ ) : null}
); } diff --git a/web/src/components/doc/DocumentRevisionToolbar.tsx b/web/src/components/doc/DocumentRevisionToolbar.tsx index a2caf851..673214a1 100644 --- a/web/src/components/doc/DocumentRevisionToolbar.tsx +++ b/web/src/components/doc/DocumentRevisionToolbar.tsx @@ -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(null); const [error, setError] = useState(null); const [errorCode, setErrorCode] = useState(null); + const [decideBusy, setDecideBusy] = useState(false); + const [decideError, setDecideError] = useState(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 (
{state ? ( @@ -92,6 +149,11 @@ export function DocumentRevisionToolbar(props: { {state.version_status === 'open' ? '当前' : '已封存'} r{state.head_revision_no} {state.manual_change_count} 处人工修改 + {hasPending ? ( + + 待确认 {state.pending_risk_level || '高风险'} + + ) : null} ) : ( {emptyHint} @@ -125,11 +187,17 @@ export function DocumentRevisionToolbar(props: {
diff --git a/web/src/features/documentRevision/changeReportView.ts b/web/src/features/documentRevision/changeReportView.ts new file mode 100644 index 00000000..798f001c --- /dev/null +++ b/web/src/features/documentRevision/changeReportView.ts @@ -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; +} diff --git a/web/src/features/documentRevision/overlayMode.test.ts b/web/src/features/documentRevision/overlayMode.test.ts index a94c565f..464330cc 100644 --- a/web/src/features/documentRevision/overlayMode.test.ts +++ b/web/src/features/documentRevision/overlayMode.test.ts @@ -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); + }); +}); diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts index 8ecf8524..594a213f 100644 --- a/web/src/features/documentRevision/types.ts +++ b/web/src/features/documentRevision/types.ts @@ -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 {