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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 15:35:07 +00:00
co-authored by Cursor
parent e4b4e0dde8
commit 3a4c00a1ba
8 changed files with 182 additions and 4 deletions
+2
View File
@@ -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 && <ProjectTracePanel projectId={currentProjectId} />}
<StaleDocumentNotice
lineages={projectLineages}
onTriggerRecheck={() => {
+3 -1
View File
@@ -206,7 +206,8 @@ export async function terminateWorkflowInstance(
export async function advanceStage(
instanceId: number,
stageKey: string,
expectedConfigRevision: number
expectedConfigRevision: number,
acknowledgeRecheck = false
): Promise<AdvanceStageResult> {
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,
}),
}
);
+55
View File
@@ -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<ProjectTraceView> {
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<void> {
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}`);
}
}
@@ -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<ProjectTracePanelProps> = ({ projectId }) => {
const [view, setView] = useState<ProjectTraceView | null>(null);
const [error, setError] = useState<string | null>(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 <div style={boxStyle}>需求与影响暂时不可用:{error}</div>;
}
if (!view) return null;
return (
<div style={boxStyle}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>需求与影响</div>
<div style={{ color: 'var(--text-muted)' }}>
已索引 {view.index.length} 份文档 · 条目 {view.items.length}
{view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''}
</div>
{view.recheck_marks.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.recheck_marks.map((mark) => (
<li key={mark.id}>
阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id})
<button
type="button"
style={{ marginLeft: 8 }}
onClick={() => {
clearRecheckMark(projectId, mark.id, 'no_change').then(load).catch((err: unknown) => {
setError(err instanceof Error ? err.message : '关闭失败');
});
}}
>
无需变更
</button>
</li>
))}
</ul>
)}
{view.items.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.items.slice(0, 8).map((item) => (
<li key={item.id}>
{item.business_key}
{item.statement ? ` · ${item.statement}` : ''}
</li>
))}
</ul>
)}
</div>
);
};
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,
};
@@ -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<StageAdvanceBannerProps> = ({
const [instanceId, setInstanceId] = useState<number | null>(null);
const [readiness, setReadiness] = useState<StageReadiness | null>(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<StageAdvanceBannerProps> = ({
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<StageAdvanceBannerProps> = ({
}, [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<StageAdvanceBannerProps> = ({
if (loading && !readiness) return null;
if (!readiness || !instanceId) return null;
const ackRequired = recheckAckRequired(readiness);
const submit = canSubmitAdvance(Boolean(readiness.can_advance), ackRequired, acked);
return (
<div
@@ -129,7 +141,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
{isCreator && readiness.can_advance && (
<button
onClick={handleAdvance}
disabled={advancing}
disabled={advancing || !submit}
style={{
display: 'flex',
alignItems: 'center',
@@ -148,6 +160,12 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
</button>
)}
</div>
{ackRequired && (
<label style={{ display: 'flex', gap: 8, marginTop: 8, color: '#9a3412', fontWeight: 600 }}>
<input type="checkbox" checked={acked} onChange={(e) => setAcked(e.target.checked)} />
此阶段有待复核影响。确认已知悉后才能推进。知悉不会关闭标记。
</label>
)}
{!readiness.ready && readiness.blocking_issues.length > 0 && (
<ul style={{ margin: '6px 0 0', paddingLeft: '18px', color: 'var(--text-muted)' }}>
{readiness.blocking_issues.slice(0, 3).map((issue) => (
+13
View File
@@ -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);
});
});
+9
View File
@@ -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;
}
+1
View File
@@ -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;