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:
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user