From 3a4c00a1ba1245129ac6752723a07ac6b33bbdd9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 15:35:07 +0000 Subject: [PATCH] feat(web): show the trace panel and the recheck acknowledgement The acknowledgement stays visible on the advance bar and does not clear the mark. Co-authored-by: Cursor --- web/src/App.tsx | 2 + web/src/api/projectSettings.ts | 4 +- web/src/api/projectTrace.ts | 55 +++++++++++++ .../components/workflow/ProjectTracePanel.tsx | 78 +++++++++++++++++++ .../workflow/StageAdvanceBanner.tsx | 24 +++++- web/src/lib/recheckAck.test.ts | 13 ++++ web/src/lib/recheckAck.ts | 9 +++ web/src/types/projectSettings.ts | 1 + 8 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 web/src/api/projectTrace.ts create mode 100644 web/src/components/workflow/ProjectTracePanel.tsx create mode 100644 web/src/lib/recheckAck.test.ts create mode 100644 web/src/lib/recheckAck.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index fa5d6f48..abddb5ae 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,6 +34,7 @@ import { import { DocumentLineage } from './types/projectWorkflow'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner'; +import { ProjectTracePanel } from './components/workflow/ProjectTracePanel'; import { OnDemand } from './components/lazy/OnDemand'; import { fetchProjectSettings } from './api/projectSettings'; import { LoginView } from './components/auth/LoginView'; @@ -434,6 +435,7 @@ export default function App() { isCreator={currentWfState.creatorId === currentUser.id} /> )} + {currentProjectId > 0 && } { diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index 75c598d1..cbda60a2 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -206,7 +206,8 @@ export async function terminateWorkflowInstance( export async function advanceStage( instanceId: number, stageKey: string, - expectedConfigRevision: number + expectedConfigRevision: number, + acknowledgeRecheck = false ): Promise { const res = await fetch( `${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/advance`, @@ -216,6 +217,7 @@ export async function advanceStage( body: JSON.stringify({ stage_key: stageKey, expected_config_revision: expectedConfigRevision, + acknowledge_recheck: acknowledgeRecheck, }), } ); diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts new file mode 100644 index 00000000..6f938972 --- /dev/null +++ b/web/src/api/projectTrace.ts @@ -0,0 +1,55 @@ +import { getBearerAuthHeaders } from './authHeaders'; + +const API_BASE = ''; + +export interface TraceItem { + id: number; + business_key: string; + kind: string; + statement?: string; + source_doc_id?: string; + review_state?: string; +} + +export interface TraceIndex { + doc_id: string; + status: string; + item_count: number; + error?: string; +} + +export interface RecheckMark { + id: number; + stage_key: string; + source_doc_id: string; + status: string; + reason: string; +} + +export interface ProjectTraceView { + items: TraceItem[]; + hidden_count: number; + index: TraceIndex[]; + recheck_marks: RecheckMark[]; +} + +export async function fetchProjectTrace(projectId: number): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`需求与影响加载失败: ${res.status}`); + } + return res.json(); +} + +export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise { + const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ reason }), + }); + if (!res.ok) { + throw new Error(`关闭待复核失败: ${res.status}`); + } +} diff --git a/web/src/components/workflow/ProjectTracePanel.tsx b/web/src/components/workflow/ProjectTracePanel.tsx new file mode 100644 index 00000000..8d4a3bf4 --- /dev/null +++ b/web/src/components/workflow/ProjectTracePanel.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace'; + +interface ProjectTracePanelProps { + projectId: number; +} + +export const ProjectTracePanel: React.FC = ({ projectId }) => { + const [view, setView] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + setView(await fetchProjectTrace(projectId)); + setError(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }, [projectId]); + + useEffect(() => { + load(); + }, [load]); + + if (error) { + return
需求与影响暂时不可用:{error}
; + } + if (!view) return null; + + return ( +
+
需求与影响
+
+ 已索引 {view.index.length} 份文档 · 条目 {view.items.length} + {view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''} +
+ {view.recheck_marks.length > 0 && ( +
    + {view.recheck_marks.map((mark) => ( +
  • + 阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id}) + +
  • + ))} +
+ )} + {view.items.length > 0 && ( +
    + {view.items.slice(0, 8).map((item) => ( +
  • + {item.business_key} + {item.statement ? ` · ${item.statement}` : ''} +
  • + ))} +
+ )} +
+ ); +}; + +const boxStyle: React.CSSProperties = { + margin: '8px 12px 0', + padding: '10px 12px', + borderRadius: 8, + border: '1px solid var(--border, #e2e8f0)', + background: 'var(--bg-subtle, #f8fafc)', + fontSize: 12, +}; diff --git a/web/src/components/workflow/StageAdvanceBanner.tsx b/web/src/components/workflow/StageAdvanceBanner.tsx index 80d9c198..21d31861 100644 --- a/web/src/components/workflow/StageAdvanceBanner.tsx +++ b/web/src/components/workflow/StageAdvanceBanner.tsx @@ -7,6 +7,7 @@ import { advanceStage, } from '../../api/projectSettings'; import { StageReadiness } from '../../types/projectSettings'; +import { canSubmitAdvance, recheckAckRequired } from '../../lib/recheckAck'; interface StageAdvanceBannerProps { projectId: number; @@ -25,6 +26,7 @@ export const StageAdvanceBanner: React.FC = ({ const [instanceId, setInstanceId] = useState(null); const [readiness, setReadiness] = useState(null); const [lastNotificationId, setLastNotificationId] = useState(0); + const [acked, setAcked] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -42,6 +44,7 @@ export const StageAdvanceBanner: React.FC = ({ if (!activeStage) return; const rd = await fetchStageReadiness(live.id, activeStage); setReadiness(rd); + setAcked(false); } catch (err: unknown) { setError(err instanceof Error ? err.message : '加载阶段状态失败'); } finally { @@ -80,11 +83,18 @@ export const StageAdvanceBanner: React.FC = ({ }, [projectId, lastNotificationId, load]); const handleAdvance = async () => { - if (!readiness || !instanceId || !readiness.can_advance) return; + if (!readiness || !instanceId) return; + const ackRequired = recheckAckRequired(readiness); + if (!canSubmitAdvance(readiness.can_advance, ackRequired, acked)) return; setAdvancing(true); setError(null); try { - const result = await advanceStage(instanceId, readiness.stage_key, readiness.config_revision); + const result = await advanceStage( + instanceId, + readiness.stage_key, + readiness.config_revision, + ackRequired + ); onStageAdvanced?.(result.next_stage_key); await load(); } catch (err: unknown) { @@ -100,6 +110,8 @@ export const StageAdvanceBanner: React.FC = ({ if (loading && !readiness) return null; if (!readiness || !instanceId) return null; + const ackRequired = recheckAckRequired(readiness); + const submit = canSubmitAdvance(Boolean(readiness.can_advance), ackRequired, acked); return (
= ({ {isCreator && readiness.can_advance && ( )}
+ {ackRequired && ( + + )} {!readiness.ready && readiness.blocking_issues.length > 0 && (
    {readiness.blocking_issues.slice(0, 3).map((issue) => ( diff --git a/web/src/lib/recheckAck.test.ts b/web/src/lib/recheckAck.test.ts new file mode 100644 index 00000000..320b7e46 --- /dev/null +++ b/web/src/lib/recheckAck.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { canSubmitAdvance, recheckAckRequired } from './recheckAck'; + +describe('recheckAck', () => { + it('requires an explicit acknowledgement before advance', () => { + assert.equal(recheckAckRequired({ requires_recheck_ack: true }), true); + assert.equal(recheckAckRequired({ requires_recheck_ack: false }), false); + assert.equal(canSubmitAdvance(true, true, false), false); + assert.equal(canSubmitAdvance(true, true, true), true); + assert.equal(canSubmitAdvance(true, false, false), true); + }); +}); diff --git a/web/src/lib/recheckAck.ts b/web/src/lib/recheckAck.ts new file mode 100644 index 00000000..52ba2d9f --- /dev/null +++ b/web/src/lib/recheckAck.ts @@ -0,0 +1,9 @@ +export function recheckAckRequired(readiness: { requires_recheck_ack?: boolean } | null): boolean { + return readiness?.requires_recheck_ack === true; +} + +export function canSubmitAdvance(canAdvance: boolean, ackRequired: boolean, acknowledged: boolean): boolean { + if (!canAdvance) return false; + if (ackRequired && !acknowledged) return false; + return true; +} diff --git a/web/src/types/projectSettings.ts b/web/src/types/projectSettings.ts index 71246f5a..c31e1908 100644 --- a/web/src/types/projectSettings.ts +++ b/web/src/types/projectSettings.ts @@ -143,6 +143,7 @@ export interface StageReadiness { can_advance: boolean; config_revision: number; blocking_issues: Array<{ code: string; message: string; task_key?: string }>; + requires_recheck_ack?: boolean; doc_tasks: Array<{ task_key: string; required: boolean;