feat:[issue #1652] add web run-flow viewer (#1654)

* feat: add web run-flow viewer

* docs: document run-flow web viewer contracts

* fix: add market review run flow entry
This commit is contained in:
LouisHong
2026-06-11 22:08:55 +08:00
committed by GitHub
parent fa9d9b0084
commit f7ac00bda9
28 changed files with 2740 additions and 25 deletions

View File

@@ -11,6 +11,7 @@ import type {
TaskStatus,
TaskListResponse,
} from '../types/analysis';
import type { RunFlowSnapshot } from '../types/runFlow';
// ============ API Interfaces ============
@@ -161,6 +162,18 @@ export const analysisApi = {
return data;
},
/**
* Get a run-flow snapshot for an active analysis task.
* @param taskId Task ID
*/
getTaskFlow: async (taskId: string): Promise<RunFlowSnapshot> => {
const response = await apiClient.get<Record<string, unknown>>(
`/api/v1/analysis/tasks/${encodeURIComponent(taskId)}/flow`
);
return toCamelCase<RunFlowSnapshot>(response.data);
},
/**
* Get the SSE stream URL.
*/

View File

@@ -10,6 +10,7 @@ import type {
RunDiagnosticSummary,
StockBarResponse,
} from '../types/analysis';
import type { RunFlowSnapshot } from '../types/runFlow';
// ============ API 接口 ============
@@ -90,6 +91,15 @@ export const historyApi = {
return toCamelCase<RunDiagnosticSummary>(response.data);
},
/**
* 获取历史报告运行流快照
* @param recordId 分析历史记录主键 ID
*/
getRecordFlow: async (recordId: number): Promise<RunFlowSnapshot> => {
const response = await apiClient.get<Record<string, unknown>>(`/api/v1/history/${recordId}/flow`);
return toCamelCase<RunFlowSnapshot>(response.data);
},
/**
* 批量删除历史记录
* @param recordIds 分析历史记录主键 ID 列表

View File

@@ -1,7 +1,8 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards } from 'lucide-react';
import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards, Workflow } from 'lucide-react';
import { historyApi } from '../../api/history';
import { formatUiText, UI_TEXT } from '../../i18n/uiText';
import type {
AnalysisReport,
MarketReviewPayload,
@@ -21,6 +22,7 @@ interface MarketReviewReportViewProps {
payload?: MarketReviewPayload | null;
reportLanguage?: ReportLanguage;
className?: string;
onOpenRunFlow?: (recordId: number) => void;
}
type CopyType = 'markdown' | 'text';
@@ -262,9 +264,11 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
payload: providedPayload,
reportLanguage = 'zh',
className = '',
onOpenRunFlow,
}) => {
const normalizedReportLanguage = normalizeReportLanguage(reportLanguage);
const text = getReportText(normalizedReportLanguage);
const runFlowText = UI_TEXT[normalizedReportLanguage];
const marketReviewText = MARKET_REVIEW_TEXT[normalizedReportLanguage];
const [loadedMarkdown, setLoadedMarkdown] = useState<LoadedMarkdown | null>(null);
const [loadError, setLoadError] = useState<LoadError | null>(null);
@@ -295,6 +299,7 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
[marketReviewPayload],
);
const showStructuredMarketTitles = Boolean(marketReviewPayload?.markets);
const canOpenRunFlow = recordId !== undefined && onOpenRunFlow;
useEffect(() => {
if (!recordId || providedContent || hasStructuredContent) {
@@ -384,6 +389,20 @@ export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
</div>
<div className="flex shrink-0 items-center gap-2">
{canOpenRunFlow ? (
<Tooltip content={runFlowText['runFlow.open']}>
<span className="inline-flex">
<button
type="button"
onClick={() => onOpenRunFlow(recordId)}
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground"
aria-label={formatUiText(runFlowText['runFlow.openHistoryAria'], { recordId })}
>
<Workflow className="h-5 w-5" aria-hidden="true" />
</button>
</span>
</Tooltip>
) : null}
<Tooltip content={text.copyMarkdownSource}>
<span className="inline-flex">
<button

View File

@@ -1,7 +1,8 @@
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Activity, Check, ChevronDown, Copy } from 'lucide-react';
import { Activity, Check, ChevronDown, Copy, Workflow } from 'lucide-react';
import { historyApi } from '../../api/history';
import { formatUiText, UI_TEXT } from '../../i18n/uiText';
import type {
ReportLanguage,
RunDiagnosticComponent,
@@ -16,6 +17,7 @@ interface ReportDiagnosticsProps {
recordId?: number;
summary?: RunDiagnosticSummary;
language?: ReportLanguage;
onOpenRunFlow?: (recordId: number) => void;
}
type BadgeVariant = NonNullable<React.ComponentProps<typeof Badge>['variant']>;
@@ -134,9 +136,11 @@ export const ReportDiagnostics: React.FC<ReportDiagnosticsProps> = ({
recordId,
summary,
language = 'zh',
onOpenRunFlow,
}) => {
const reportLanguage = normalizeReportLanguage(language);
const text = TEXT[reportLanguage];
const runFlowText = UI_TEXT[reportLanguage];
const [fetchState, setFetchState] = useState<{
recordId?: number;
summary: RunDiagnosticSummary | null;
@@ -325,17 +329,30 @@ export const ReportDiagnostics: React.FC<ReportDiagnosticsProps> = ({
) : null}
</div>
</div>
<Button
variant="ghost"
size="xsm"
disabled={!hasCopyText}
onClick={() => void copyDiagnostics()}
aria-label={copied ? text.copied : text.copy}
className="shrink-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? text.copied : text.copy}
</Button>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{recordId !== undefined && onOpenRunFlow ? (
<Button
variant="ghost"
size="xsm"
onClick={() => onOpenRunFlow(recordId)}
aria-label={formatUiText(runFlowText['runFlow.openHistoryAria'], { recordId })}
>
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
{runFlowText['runFlow.open']}
</Button>
) : null}
<Button
variant="ghost"
size="xsm"
disabled={!hasCopyText}
onClick={() => void copyDiagnostics()}
aria-label={copied ? text.copied : text.copy}
className="shrink-0"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? text.copied : text.copy}
</Button>
</div>
</div>
<div>

View File

@@ -19,6 +19,7 @@ interface ReportSummaryProps {
isActioning: boolean;
actionMessage: string | null;
};
onOpenRunFlow?: (recordId: number) => void;
}
/**
@@ -29,6 +30,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
data,
isHistory = false,
watchlist,
onOpenRunFlow,
}) => {
// 兼容 AnalysisResult 和 AnalysisReport 两种数据格式
const report: AnalysisReport = 'report' in data ? data.report : data;
@@ -50,6 +52,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
report={report}
recordId={recordId}
reportLanguage={reportLanguage}
onOpenRunFlow={onOpenRunFlow}
/>
);
}
@@ -82,6 +85,7 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
recordId={recordId}
summary={diagnosticSummary}
language={reportLanguage}
onOpenRunFlow={onOpenRunFlow}
/>
{/* 透明度与追溯区 */}

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AnalysisReport, MarketReviewPayload } from '../../../types/analysis';
import { MarketReviewReportView } from '../MarketReviewReportView';
@@ -174,4 +174,22 @@ describe('MarketReviewReportView', () => {
expect(screen.queryByText('Advancers')).not.toBeInTheDocument();
expect(screen.queryByText('Decliners')).not.toBeInTheDocument();
});
it('opens run flow for historical market review records', () => {
const onOpenRunFlow = vi.fn();
render(
<MarketReviewReportView
payload={combinedMarketReviewPayload}
content="# 大盘复盘"
recordId={7}
reportLanguage="zh"
onOpenRunFlow={onOpenRunFlow}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 7 运行流' }));
expect(onOpenRunFlow).toHaveBeenCalledWith(7);
});
});

View File

@@ -88,6 +88,18 @@ describe('ReportDiagnostics', () => {
expect(screen.getByText('Fetch / LLM / save / notification path')).toBeInTheDocument();
});
it('opens historical run flow from the diagnostics body', async () => {
const onOpenRunFlow = vi.fn();
vi.mocked(historyApi.getDiagnostics).mockResolvedValue(diagnosticSummary);
render(<ReportDiagnostics recordId={1} onOpenRunFlow={onOpenRunFlow} />);
fireEvent.click(await screen.findByText('运行状态'));
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 1 运行流' }));
expect(onOpenRunFlow).toHaveBeenCalledWith(1);
});
it('refetches diagnostics after StrictMode cleans up the first effect run', async () => {
vi.mocked(historyApi.getDiagnostics).mockResolvedValue(diagnosticSummary);

View File

@@ -0,0 +1,167 @@
import type React from 'react';
import { useMemo, useState } from 'react';
import { AlertTriangle, Filter, GitBranch, ListFilter, OctagonAlert, XCircle } from 'lucide-react';
import { Badge, Button, StatusDot } from '../common';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { UiTextKey } from '../../i18n/uiText';
import type { RunFlowEvent } from '../../types/runFlow';
import {
compactText,
formatDateTime,
formatMetadataValue,
getRunFlowSeverityLabel,
RUN_FLOW_SEVERITY_STYLE,
} from './utils';
interface RunFlowEventListProps {
events: RunFlowEvent[];
selectedNodeId?: string | null;
onSelectNode?: (nodeId: string) => void;
}
type EventFilter = 'all' | 'important' | 'problems' | 'fallback' | 'cancelled';
const FILTER_ICONS = {
all: ListFilter,
important: AlertTriangle,
problems: OctagonAlert,
fallback: GitBranch,
cancelled: XCircle,
} as const;
const eventText = (event: RunFlowEvent): string =>
`${event.type} ${event.title} ${event.message || ''}`.toLowerCase();
const matchesFilter = (event: RunFlowEvent, filter: EventFilter): boolean => {
if (filter === 'all') return true;
const text = eventText(event);
if (filter === 'important') {
return event.severity === 'warning'
|| event.severity === 'danger'
|| /fallback|retry|cancel|failed|error|timeout/.test(text);
}
if (filter === 'problems') {
return event.severity === 'warning'
|| event.severity === 'danger'
|| /failed|error|timeout/.test(text);
}
if (filter === 'fallback') {
return /fallback|retry|降级|重试/.test(text);
}
return /cancel|取消/.test(text);
};
export const RunFlowEventList: React.FC<RunFlowEventListProps> = ({
events,
selectedNodeId,
onSelectNode,
}) => {
const { language, t } = useUiLanguage();
const [filter, setFilter] = useState<EventFilter>('all');
const sortedEvents = useMemo(() => (
[...events].sort((left, right) => {
const leftTime = left.timestamp ? Date.parse(left.timestamp) : 0;
const rightTime = right.timestamp ? Date.parse(right.timestamp) : 0;
return leftTime - rightTime;
})
), [events]);
const visibleEvents = useMemo(
() => sortedEvents.filter((event) => matchesFilter(event, filter)),
[filter, sortedEvents],
);
const filters: EventFilter[] = ['all', 'important', 'problems', 'fallback', 'cancelled'];
return (
<div className="home-subpanel flex min-h-0 flex-col overflow-hidden p-3" data-testid="run-flow-events">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="label-uppercase">{t('runFlow.events.title')}</p>
<p className="mt-1 text-xs text-muted-text">
{t('runFlow.events.count', { count: visibleEvents.length })}
</p>
</div>
<div className="flex flex-wrap items-center gap-1.5" aria-label={t('runFlow.events.filters')}>
{filters.map((item) => {
const Icon = FILTER_ICONS[item];
return (
<Button
key={item}
type="button"
variant={filter === item ? 'outline' : 'ghost'}
size="xsm"
onClick={() => setFilter(item)}
aria-pressed={filter === item}
className="h-7 px-2 text-xs"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(`runFlow.events.filter.${item}` as UiTextKey)}
</Button>
);
})}
</div>
</div>
<div className="mt-3 min-h-0 space-y-2 overflow-y-auto pr-1">
{visibleEvents.length > 0 ? visibleEvents.map((event) => {
const style = RUN_FLOW_SEVERITY_STYLE[event.severity] || RUN_FLOW_SEVERITY_STYLE.info;
const selected = Boolean(event.nodeId && event.nodeId === selectedNodeId);
const metadata = Object.entries(event.metadata || {})
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.slice(0, 3);
const content = (
<div
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
selected ? 'border-primary/70 bg-primary/10' : 'border-subtle bg-base/30 hover:bg-hover/60'
}`}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={style.badge} className="gap-1.5 shadow-none">
<StatusDot tone={style.tone} className="h-1.5 w-1.5" />
{getRunFlowSeverityLabel(event.severity, t)}
</Badge>
<span className="text-xs text-muted-text">
{formatDateTime(event.timestamp, language, t)}
</span>
<span className="font-mono text-[11px] text-muted-text">{compactText(event.type, 28)}</span>
</div>
<p className="mt-2 text-sm font-medium text-foreground">{event.title}</p>
{event.message ? (
<p className="mt-1 text-xs leading-5 text-secondary-text">{event.message}</p>
) : null}
{metadata.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{metadata.map(([key, value]) => (
<span key={key} className="home-accent-chip px-2 py-0.5 text-[11px] text-muted-text">
{key}: {formatMetadataValue(value)}
</span>
))}
</div>
) : null}
</div>
);
if (!event.nodeId || !onSelectNode) {
return <div key={event.id}>{content}</div>;
}
return (
<button
key={event.id}
type="button"
className="block w-full"
onClick={() => onSelectNode(event.nodeId || '')}
aria-label={t('runFlow.events.openNode', { title: event.title })}
>
{content}
</button>
);
}) : (
<div className="flex min-h-32 flex-col items-center justify-center rounded-lg border border-dashed border-subtle px-4 py-8 text-center text-sm text-secondary-text">
<Filter className="mb-2 h-5 w-5 text-muted-text" aria-hidden="true" />
{t('runFlow.events.empty')}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,416 @@
import type React from 'react';
import { useMemo, useId } from 'react';
import { Badge, StatusDot, Tooltip } from '../common';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { RunFlowEdge, RunFlowLane, RunFlowNode, RunFlowStatus } from '../../types/runFlow';
import {
compactText,
formatDuration,
getNodeDisplayOrder,
getRunFlowEdgeKindLabel,
getRunFlowStatusLabel,
RUN_FLOW_STATUS_STYLE,
} from './utils';
interface RunFlowGraphProps {
lanes: RunFlowLane[];
nodes: RunFlowNode[];
edges: RunFlowEdge[];
selectedNodeId?: string | null;
onSelectNode?: (node: RunFlowNode) => void;
}
type PositionedNode = RunFlowNode & {
x: number;
y: number;
width: number;
height: number;
row: number;
laneIndex: number;
};
const LANE_WIDTH = 292;
const NODE_WIDTH = 244;
const NODE_HEIGHT = 108;
const HEADER_HEIGHT = 42;
const ROW_HEIGHT = 126;
const LEFT_PADDING = 20;
const TOP_PADDING = 18;
const BOTTOM_PADDING = 30;
const getEdgeStroke = (status: RunFlowStatus): string => {
if (status === 'failed' || status === 'timeout') return 'hsl(var(--destructive))';
if (status === 'fallback' || status === 'degraded' || status === 'cancel_requested') return 'hsl(var(--warning))';
if (status === 'success') return 'hsl(var(--success))';
if (status === 'running') return 'hsl(var(--primary))';
return 'hsl(var(--muted-text))';
};
const findAvailableRow = (occupiedRows: Set<number>, preferredRow: number): number => {
const safePreferred = Math.max(0, preferredRow);
for (let distance = 0; distance < 1000; distance += 1) {
const lower = safePreferred - distance;
const upper = safePreferred + distance;
if (lower >= 0 && !occupiedRows.has(lower)) {
return lower;
}
if (!occupiedRows.has(upper)) {
return upper;
}
}
return occupiedRows.size;
};
const getAnchorOffset = (total: number, index: number, height: number): number => {
if (total <= 1) {
return height / 2;
}
const step = height / (total + 1);
return step * (index + 1);
};
const getCenteredTrackOffset = (total: number, index: number, step = 12): number => (
(index - (total - 1) / 2) * step
);
export const RunFlowGraph: React.FC<RunFlowGraphProps> = ({
lanes,
nodes,
edges,
selectedNodeId,
onSelectNode,
}) => {
const arrowId = useId().replace(/:/g, '-');
const { t } = useUiLanguage();
const laneList = useMemo(() => {
const sortedLanes = [...lanes].sort((left, right) => left.order - right.order);
const knownLaneIds = new Set(sortedLanes.map((lane) => lane.id));
const extraLanes = nodes
.map((node) => node.lane)
.filter((laneId, index, values) => !knownLaneIds.has(laneId) && values.indexOf(laneId) === index)
.map((laneId, index) => ({
id: laneId,
label: laneId,
order: sortedLanes.length + index + 1,
}));
return [...sortedLanes, ...extraLanes];
}, [lanes, nodes]);
const layout = useMemo(() => {
const grouped = new Map<string, RunFlowNode[]>();
const originalIndex = new Map<string, number>();
const nodeById = new Map<string, RunFlowNode>();
const laneIndexById = new Map<string, number>();
laneList.forEach((lane, index) => {
laneIndexById.set(lane.id, index);
});
nodes.forEach((node, index) => {
const items = grouped.get(node.lane) || [];
items.push(node);
grouped.set(node.lane, items);
originalIndex.set(node.id, index);
nodeById.set(node.id, node);
});
const validEdges = edges.filter((edge) => nodeById.has(edge.from) && nodeById.has(edge.to));
const incomingByNode = new Map<string, RunFlowEdge[]>();
const outgoingByNode = new Map<string, RunFlowEdge[]>();
validEdges.forEach((edge) => {
incomingByNode.set(edge.to, [...(incomingByNode.get(edge.to) || []), edge]);
outgoingByNode.set(edge.from, [...(outgoingByNode.get(edge.from) || []), edge]);
});
const laneOrderByNode = new Map<string, number>();
laneList.forEach((lane) => {
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
getNodeDisplayOrder(left, originalIndex.get(left.id) ?? 0)
- getNodeDisplayOrder(right, originalIndex.get(right.id) ?? 0)
));
laneNodes.forEach((node, index) => {
laneOrderByNode.set(node.id, index);
});
});
const preferredRows = new Map<string, number>();
const visiting = new Set<string>();
const resolvePreferredRow = (nodeId: string): number => {
if (preferredRows.has(nodeId)) {
return preferredRows.get(nodeId) || 0;
}
if (visiting.has(nodeId)) {
return laneOrderByNode.get(nodeId) || 0;
}
visiting.add(nodeId);
const node = nodeById.get(nodeId);
const baseRow = laneOrderByNode.get(nodeId) || 0;
if (!node) {
visiting.delete(nodeId);
return baseRow;
}
const nodeLaneIndex = laneIndexById.get(node.lane) || 0;
const parentRows = (incomingByNode.get(nodeId) || [])
.map((edge) => nodeById.get(edge.from))
.filter((parent): parent is RunFlowNode => Boolean(parent))
.filter((parent) => (laneIndexById.get(parent.lane) || 0) <= nodeLaneIndex)
.map((parent) => {
const parentRow = resolvePreferredRow(parent.id);
return parent.lane === node.lane ? parentRow + 1 : parentRow;
});
const preferredRow = parentRows.length > 0
? Math.max(0, Math.round(parentRows.reduce((sum, row) => sum + row, 0) / parentRows.length))
: baseRow;
const resolvedRow = Math.max(0, Math.max(baseRow - 1, preferredRow));
preferredRows.set(nodeId, resolvedRow);
visiting.delete(nodeId);
return resolvedRow;
};
const positioned = new Map<string, PositionedNode>();
let maxRow = 0;
laneList.forEach((lane, lanePosition) => {
const laneNodes = [...(grouped.get(lane.id) || [])].sort((left, right) => (
resolvePreferredRow(left.id) - resolvePreferredRow(right.id)
|| getNodeDisplayOrder(left, originalIndex.get(left.id) ?? 0)
- getNodeDisplayOrder(right, originalIndex.get(right.id) ?? 0)
));
const occupiedRows = new Set<number>();
laneNodes.forEach((node) => {
const row = findAvailableRow(occupiedRows, resolvePreferredRow(node.id));
occupiedRows.add(row);
maxRow = Math.max(maxRow, row);
positioned.set(node.id, {
...node,
x: lanePosition * LANE_WIDTH + LEFT_PADDING,
y: HEADER_HEIGHT + TOP_PADDING + row * ROW_HEIGHT,
width: NODE_WIDTH,
height: NODE_HEIGHT,
row,
laneIndex: lanePosition,
});
});
});
const sortEdgesForAnchors = (edgeItems: RunFlowEdge[], fromNodeId: string) => [...edgeItems].sort((left, right) => {
const leftTarget = nodeById.get(left.to);
const rightTarget = nodeById.get(right.to);
const leftSource = nodeById.get(left.from);
const rightSource = nodeById.get(right.from);
const leftOther = left.from === fromNodeId ? leftTarget : leftSource;
const rightOther = right.from === fromNodeId ? rightTarget : rightSource;
const leftPosition = leftOther ? positioned.get(leftOther.id) : null;
const rightPosition = rightOther ? positioned.get(rightOther.id) : null;
return (leftPosition?.laneIndex ?? 0) - (rightPosition?.laneIndex ?? 0)
|| (leftPosition?.row ?? 0) - (rightPosition?.row ?? 0)
|| left.id.localeCompare(right.id);
});
const outgoingAnchors = new Map<string, number>();
outgoingByNode.forEach((nodeEdges, nodeId) => {
const node = positioned.get(nodeId);
if (!node) return;
const sortedEdges = sortEdgesForAnchors(nodeEdges, nodeId);
sortedEdges.forEach((edge, index) => {
outgoingAnchors.set(edge.id, node.y + getAnchorOffset(sortedEdges.length, index, node.height));
});
});
const incomingAnchors = new Map<string, number>();
incomingByNode.forEach((nodeEdges, nodeId) => {
const node = positioned.get(nodeId);
if (!node) return;
const sortedEdges = sortEdgesForAnchors(nodeEdges, nodeId);
sortedEdges.forEach((edge, index) => {
incomingAnchors.set(edge.id, node.y + getAnchorOffset(sortedEdges.length, index, node.height));
});
});
return {
positioned,
incomingAnchors,
outgoingAnchors,
width: Math.max(laneList.length * LANE_WIDTH + LEFT_PADDING, LANE_WIDTH),
height: HEADER_HEIGHT + TOP_PADDING + (maxRow + 1) * ROW_HEIGHT + BOTTOM_PADDING,
};
}, [edges, laneList, nodes]);
const edgePaths = edges
.map((edge, edgeIndex) => {
const from = layout.positioned.get(edge.from);
const to = layout.positioned.get(edge.to);
if (!from || !to) {
return null;
}
const sameLane = from.lane === to.lane;
const sameLaneVertical = sameLane && Math.abs(to.y - from.y) >= ROW_HEIGHT / 2;
if (sameLaneVertical) {
const downward = to.y >= from.y;
const trackOffset = getCenteredTrackOffset(edges.length, edgeIndex, 2);
const startX = from.x + from.width / 2 + trackOffset;
const endX = to.x + to.width / 2 + trackOffset;
const startY = downward ? from.y + from.height : from.y;
const endY = downward ? to.y : to.y + to.height;
const routeY = (startY + endY) / 2;
const path = `M ${startX} ${startY} C ${startX} ${routeY}, ${endX} ${routeY}, ${endX} ${endY}`;
return {
edge,
path,
labelX: (startX + endX) / 2,
labelY: routeY - 6,
};
}
const forward = to.x >= from.x;
const startX = forward ? from.x + from.width : from.x;
const endX = forward ? to.x : to.x + to.width;
const startY = layout.outgoingAnchors.get(edge.id) ?? from.y + from.height / 2;
const endY = layout.incomingAnchors.get(edge.id) ?? to.y + to.height / 2;
const direction = forward ? 1 : -1;
const trackOffset = getCenteredTrackOffset(edges.length, edgeIndex, 2);
const laneGap = Math.abs(endX - startX);
const routeX = sameLane
? startX + direction * (34 + Math.abs(trackOffset))
: startX + direction * Math.max(44, laneGap / 2) + trackOffset;
const path = `M ${startX} ${startY} C ${routeX} ${startY}, ${routeX} ${endY}, ${endX} ${endY}`;
return {
edge,
path,
labelX: routeX,
labelY: (startY + endY) / 2 - 6,
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
return (
<div className="home-subpanel overflow-hidden p-3" data-testid="run-flow-graph">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="label-uppercase">{t('runFlow.graph.title')}</p>
<p className="mt-1 text-xs text-muted-text">{t('runFlow.graph.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{(['data', 'control', 'fallback', 'retry'] as const).map((kind) => (
<Badge key={kind} variant={kind === 'fallback' || kind === 'retry' ? 'warning' : 'default'} className="shadow-none">
{getRunFlowEdgeKindLabel(kind, t)}
</Badge>
))}
</div>
</div>
<div className="overflow-x-auto pb-2">
<div
className="relative"
style={{ width: layout.width, minHeight: layout.height }}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10"
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
>
<defs>
<marker
id={`${arrowId}-arrow`}
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill="currentColor" />
</marker>
</defs>
{edgePaths.map(({ edge, path, labelX, labelY }) => (
<g key={edge.id} style={{ color: getEdgeStroke(edge.status) }}>
<path
d={path}
fill="none"
stroke="currentColor"
strokeWidth={edge.kind === 'fallback' || edge.kind === 'retry' ? 2.5 : 1.75}
strokeDasharray={edge.kind === 'retry' ? '7 5' : edge.kind === 'fallback' ? '4 4' : undefined}
markerEnd={`url(#${arrowId}-arrow)`}
opacity={0.78}
/>
{edge.label ? (
<text
x={labelX}
y={labelY}
textAnchor="middle"
className="fill-muted-text text-[10px]"
style={{ paintOrder: 'stroke', stroke: 'hsl(var(--card))', strokeWidth: 4 }}
>
{compactText(edge.label, 22)}
</text>
) : null}
</g>
))}
</svg>
{laneList.map((lane, index) => (
<div
key={`${lane.id}-band`}
aria-hidden="true"
className="absolute top-0 z-0 rounded-lg border border-subtle/70 bg-base/20"
style={{
left: index * LANE_WIDTH + LEFT_PADDING - 8,
width: NODE_WIDTH + 16,
height: layout.height,
}}
/>
))}
{laneList.map((lane, index) => (
<div
key={lane.id}
className="absolute top-0 z-20 rounded-lg border border-subtle bg-base/75 px-3 py-2 text-xs font-medium text-secondary-text backdrop-blur-sm"
style={{ left: index * LANE_WIDTH + LEFT_PADDING, width: NODE_WIDTH }}
>
{lane.label}
</div>
))}
{Array.from(layout.positioned.values()).map((node) => {
const style = RUN_FLOW_STATUS_STYLE[node.status] || RUN_FLOW_STATUS_STYLE.unknown;
const selected = selectedNodeId === node.id;
const statusLabel = getRunFlowStatusLabel(node.status, t);
return (
<Tooltip key={node.id} content={node.message || statusLabel} side="bottom">
<button
type="button"
data-testid={`run-flow-node-${node.id}`}
onClick={() => onSelectNode?.(node)}
aria-pressed={selected}
aria-label={t('runFlow.graph.nodeAria', { label: node.label, status: statusLabel })}
data-layout-lane={node.laneIndex}
data-layout-row={node.row}
className={`absolute z-30 flex flex-col items-start rounded-lg border bg-elevated/92 px-3 py-2 text-left shadow-soft-card backdrop-blur-sm transition-all hover:-translate-y-0.5 hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-cyan/15 ${
selected ? 'border-primary/70 ring-2 ring-primary/20' : 'border-subtle'
}`}
style={{ left: node.x, top: node.y, width: node.width, minHeight: node.height }}
>
<span className="flex w-full min-w-0 items-start justify-between gap-2">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">{node.label}</span>
{node.provider ? (
<span className="mt-0.5 block truncate text-xs text-muted-text">{node.provider}</span>
) : null}
</span>
<StatusDot tone={style.tone} pulse={style.pulse} className="mt-1 h-2 w-2" />
</span>
<span className="mt-2 flex w-full flex-wrap items-center gap-1.5">
<Badge variant={style.badge} className="shadow-none">
{statusLabel}
</Badge>
{typeof node.durationMs === 'number' ? (
<span className="text-[11px] text-muted-text">{formatDuration(node.durationMs, t)}</span>
) : null}
</span>
</button>
</Tooltip>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import type React from 'react';
import { Info, X } from 'lucide-react';
import { Badge, Button, StatusDot } from '../common';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { RunFlowNode } from '../../types/runFlow';
import {
formatDateTime,
formatDuration,
formatMetadataValue,
getRunFlowNodeKindLabel,
getRunFlowStatusLabel,
RUN_FLOW_STATUS_STYLE,
} from './utils';
interface RunFlowNodeDetailsProps {
node?: RunFlowNode | null;
onClose?: () => void;
}
export const RunFlowNodeDetails: React.FC<RunFlowNodeDetailsProps> = ({ node, onClose }) => {
const { language, t } = useUiLanguage();
if (!node) {
return (
<aside className="home-subpanel p-4 text-sm text-secondary-text" data-testid="run-flow-node-details-empty">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-cyan" aria-hidden="true" />
{t('runFlow.nodeDetails.empty')}
</div>
</aside>
);
}
const style = RUN_FLOW_STATUS_STYLE[node.status] || RUN_FLOW_STATUS_STYLE.unknown;
const metadata = Object.entries(node.metadata || {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
const detailRows = [
[t('runFlow.nodeDetails.kind'), getRunFlowNodeKindLabel(node.kind, t)],
[t('runFlow.nodeDetails.provider'), node.provider || t('runFlow.valueUnavailable')],
[t('runFlow.nodeDetails.duration'), formatDuration(node.durationMs, t)],
[t('runFlow.nodeDetails.attempts'), node.attempts ? String(node.attempts) : t('runFlow.valueUnavailable')],
[t('runFlow.nodeDetails.recordCount'), typeof node.recordCount === 'number' ? String(node.recordCount) : t('runFlow.valueUnavailable')],
[t('runFlow.nodeDetails.startedAt'), formatDateTime(node.startedAt, language, t)],
[t('runFlow.nodeDetails.endedAt'), formatDateTime(node.endedAt, language, t)],
];
return (
<aside className="home-subpanel p-4" data-testid="run-flow-node-details">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="label-uppercase">{t('runFlow.nodeDetails.title')}</p>
<h3 className="mt-1 truncate text-base font-semibold text-foreground">{node.label}</h3>
{node.message ? (
<p className="mt-2 text-sm leading-6 text-secondary-text">{node.message}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={style.badge} className="gap-1.5 shadow-none">
<StatusDot tone={style.tone} pulse={style.pulse} className="h-1.5 w-1.5" />
{getRunFlowStatusLabel(node.status, t)}
</Badge>
{onClose ? (
<Button
type="button"
variant="ghost"
size="xsm"
onClick={onClose}
aria-label={t('runFlow.nodeDetails.close')}
className="h-7 w-7 px-0"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
</div>
<dl className="mt-4 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
{detailRows.map(([label, value]) => (
<div key={label} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
<dt className="text-xs text-muted-text">{label}</dt>
<dd className="mt-1 break-words text-foreground">{value}</dd>
</div>
))}
</dl>
{metadata.length > 0 ? (
<div className="mt-4">
<p className="label-uppercase">{t('runFlow.nodeDetails.metadata')}</p>
<dl className="mt-2 grid grid-cols-1 gap-2 text-sm">
{metadata.map(([key, value]) => (
<div key={key} className="rounded-lg border border-subtle bg-base/35 px-3 py-2">
<dt className="font-mono text-xs text-muted-text">{key}</dt>
<dd className="mt-1 break-words text-foreground">{formatMetadataValue(value)}</dd>
</div>
))}
</dl>
</div>
) : null}
</aside>
);
};

View File

@@ -0,0 +1,154 @@
import type React from 'react';
import { useMemo, useState } from 'react';
import { AlertCircle, RefreshCw, Workflow } from 'lucide-react';
import { Button, EmptyState, InlineAlert } from '../common';
import { useRunFlowSnapshot } from '../../hooks/useRunFlowSnapshot';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { RunFlowNode, RunFlowSnapshotSource } from '../../types/runFlow';
import { RunFlowEventList } from './RunFlowEventList';
import { RunFlowGraph } from './RunFlowGraph';
import { RunFlowNodeDetails } from './RunFlowNodeDetails';
import { RunFlowSummaryBar } from './RunFlowSummaryBar';
interface RunFlowPanelProps {
source: RunFlowSnapshotSource | null;
title?: string;
}
export const RunFlowPanel: React.FC<RunFlowPanelProps> = ({ source, title }) => {
const { t } = useUiLanguage();
const { snapshot, isLoading, error, refetch } = useRunFlowSnapshot({
source,
enabled: Boolean(source),
});
const [selectedNodeId, setSelectedNodeId] = useState<string | null | false>(null);
const defaultNodeId = useMemo(() => {
if (!snapshot?.nodes.length) {
return null;
}
const notable = snapshot.nodes.find((node) => (
node.status === 'failed'
|| node.status === 'fallback'
|| node.status === 'degraded'
|| node.status === 'running'
|| node.status === 'cancel_requested'
));
return notable?.id || snapshot.nodes[0].id;
}, [snapshot]);
const resolvedSelectedNodeId = useMemo(() => {
if (selectedNodeId === false) {
return null;
}
if (selectedNodeId && snapshot?.nodes.some((node) => node.id === selectedNodeId)) {
return selectedNodeId;
}
return defaultNodeId;
}, [defaultNodeId, selectedNodeId, snapshot]);
const selectedNode = useMemo(
() => snapshot?.nodes.find((node) => node.id === resolvedSelectedNodeId) || null,
[resolvedSelectedNodeId, snapshot],
);
const selectNode = (node: RunFlowNode) => setSelectedNodeId(node.id);
const selectNodeById = (nodeId: string) => {
if (snapshot?.nodes.some((node) => node.id === nodeId)) {
setSelectedNodeId(nodeId);
}
};
if (isLoading && !snapshot) {
return (
<div className="flex min-h-[22rem] flex-col items-center justify-center text-center" data-testid="run-flow-panel-loading">
<div className="home-spinner h-10 w-10 animate-spin border-[3px]" aria-hidden="true" />
<h3 className="mt-4 text-base font-semibold text-foreground">{t('runFlow.loadingTitle')}</h3>
<p className="mt-2 max-w-sm text-sm text-secondary-text">{t('runFlow.loadingDescription')}</p>
</div>
);
}
if (error && !snapshot) {
return (
<div className="space-y-4" data-testid="run-flow-panel-error">
<InlineAlert
variant="danger"
title={error.title || t('runFlow.errorTitle')}
message={error.message}
className="rounded-xl px-3 py-2 text-sm shadow-none"
/>
<Button type="button" variant="secondary" size="sm" onClick={() => void refetch()}>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
{t('runFlow.retry')}
</Button>
</div>
);
}
if (!snapshot) {
return (
<EmptyState
title={t('runFlow.emptyTitle')}
description={t('runFlow.emptyDescription')}
icon={<Workflow className="h-6 w-6" aria-hidden="true" />}
className="border-dashed"
/>
);
}
const hasDetails = snapshot.nodes.length > 0 || snapshot.events.length > 0;
return (
<div className="space-y-3" data-testid="run-flow-panel">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="label-uppercase">{t('runFlow.eyebrow')}</p>
<h2 className="mt-1 truncate text-lg font-semibold text-foreground">
{title || t('runFlow.title')}
</h2>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void refetch()}
isLoading={isLoading}
loadingText={t('runFlow.refreshing')}
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
{t('runFlow.refresh')}
</Button>
</div>
<RunFlowSummaryBar snapshot={snapshot} />
{!hasDetails ? (
<EmptyState
title={t('runFlow.emptySnapshotTitle')}
description={t('runFlow.emptySnapshotDescription')}
icon={<AlertCircle className="h-6 w-6" aria-hidden="true" />}
className="border-dashed"
/>
) : (
<div className="grid min-w-0 grid-cols-1 gap-3 2xl:grid-cols-[minmax(0,1fr)_24rem]">
<div className="min-w-0 space-y-3">
<RunFlowGraph
lanes={snapshot.lanes}
nodes={snapshot.nodes}
edges={snapshot.edges}
selectedNodeId={resolvedSelectedNodeId}
onSelectNode={selectNode}
/>
<RunFlowNodeDetails node={selectedNode} onClose={() => setSelectedNodeId(false)} />
</div>
<div className="min-h-[20rem] 2xl:max-h-[calc(100vh-18rem)]">
<RunFlowEventList
events={snapshot.events}
selectedNodeId={resolvedSelectedNodeId}
onSelectNode={selectNodeById}
/>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,92 @@
import type React from 'react';
import { Clock, Database, GitBranch, MessageSquareText, Workflow } from 'lucide-react';
import { Badge, StatusDot } from '../common';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { RunFlowSnapshot } from '../../types/runFlow';
import { compactText, formatDateTime, formatDuration, getRunFlowStatusLabel, RUN_FLOW_STATUS_STYLE } from './utils';
interface RunFlowSummaryBarProps {
snapshot: RunFlowSnapshot;
}
export const RunFlowSummaryBar: React.FC<RunFlowSummaryBarProps> = ({ snapshot }) => {
const { language, t } = useUiLanguage();
const style = RUN_FLOW_STATUS_STYLE[snapshot.status] || RUN_FLOW_STATUS_STYLE.unknown;
const title = snapshot.stockName || snapshot.stockCode || t('runFlow.valueUnavailable');
const taskId = compactText(snapshot.taskId, 32);
const traceId = compactText(snapshot.traceId || '', 32);
const items = [
{
key: 'elapsed',
icon: Clock,
label: t('runFlow.summary.elapsed'),
value: formatDuration(snapshot.summary.elapsedMs, t),
},
{
key: 'fallback',
icon: GitBranch,
label: t('runFlow.summary.fallbackCount'),
value: String(snapshot.summary.fallbackCount ?? 0),
},
{
key: 'failed',
icon: MessageSquareText,
label: t('runFlow.summary.failedAttempts'),
value: String(snapshot.summary.failedAttempts ?? 0),
},
{
key: 'sources',
icon: Database,
label: t('runFlow.summary.dataSources'),
value: String(snapshot.summary.dataSourceCount ?? 0),
},
];
return (
<div className="home-subpanel p-3" data-testid="run-flow-summary">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-cyan" aria-hidden="true" />
<h3 className="truncate text-base font-semibold text-foreground">{title}</h3>
<Badge variant={style.badge} className="gap-1.5 shadow-none">
<StatusDot tone={style.tone} pulse={style.pulse} className="h-1.5 w-1.5" />
{getRunFlowStatusLabel(snapshot.status, t)}
</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-text">
<span className="home-accent-chip px-2 py-0.5 font-mono">
{t('runFlow.summary.task')}: {taskId || t('runFlow.valueUnavailable')}
</span>
{traceId ? (
<span className="home-accent-chip px-2 py-0.5 font-mono">
{t('runFlow.summary.trace')}: {traceId}
</span>
) : null}
{snapshot.summary.model ? (
<span className="home-accent-chip px-2 py-0.5">
{t('runFlow.summary.model')}: {compactText(snapshot.summary.model, 36)}
</span>
) : null}
<span className="home-accent-chip px-2 py-0.5">
{t('runFlow.summary.generatedAt')}: {formatDateTime(snapshot.generatedAt, language, t)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:min-w-[28rem]">
{items.map(({ key, icon: Icon, label, value }) => (
<div key={key} className="rounded-lg border border-subtle bg-surface/40 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs text-muted-text">
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
</div>
<p className="mt-1 text-sm font-semibold text-foreground tabular-nums">{value}</p>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { RunFlowEvent } from '../../../types/runFlow';
import { RunFlowEventList } from '../RunFlowEventList';
const events: RunFlowEvent[] = [
{
id: 'evt-1',
timestamp: '2026-06-08T08:00:01Z',
severity: 'info',
type: 'task_created',
nodeId: 'request',
title: '任务创建',
},
{
id: 'evt-2',
timestamp: '2026-06-08T08:00:02Z',
severity: 'warning',
type: 'provider_fallback',
nodeId: 'daily_data',
title: '日线降级',
message: 'Tushare 失败后切换 AkShare',
},
{
id: 'evt-3',
timestamp: '2026-06-08T08:00:03Z',
severity: 'danger',
type: 'task_cancelled',
nodeId: 'queue',
title: '任务取消',
},
];
describe('RunFlowEventList', () => {
it('filters fallback and cancellation events with visible text labels', () => {
render(<RunFlowEventList events={events} />);
expect(screen.getByText('任务创建')).toBeInTheDocument();
expect(screen.getByText('日线降级')).toBeInTheDocument();
expect(screen.getByText('任务取消')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '降级/重试' }));
expect(screen.getByText('日线降级')).toBeInTheDocument();
expect(screen.queryByText('任务创建')).not.toBeInTheDocument();
expect(screen.queryByText('任务取消')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(screen.getByText('任务取消')).toBeInTheDocument();
expect(screen.queryByText('日线降级')).not.toBeInTheDocument();
expect(screen.getByText('危险')).toBeInTheDocument();
});
it('selects the event node when an event row is clicked', () => {
const onSelectNode = vi.fn();
render(<RunFlowEventList events={events} onSelectNode={onSelectNode} />);
fireEvent.click(screen.getByRole('button', { name: '查看事件 日线降级 关联节点' }));
expect(onSelectNode).toHaveBeenCalledWith('daily_data');
});
});

View File

@@ -0,0 +1,192 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { RunFlowEdge, RunFlowLane, RunFlowNode } from '../../../types/runFlow';
import { RunFlowGraph } from '../RunFlowGraph';
const lanes: RunFlowLane[] = [
{ id: 'entry', label: '入口', order: 1 },
{ id: 'data_source', label: '数据来源', order: 2 },
{ id: 'analysis', label: '分析引擎', order: 3 },
];
const nodes: RunFlowNode[] = [
{
id: 'request',
lane: 'entry',
kind: 'entry',
label: '用户请求',
status: 'success',
},
{
id: 'news',
lane: 'data_source',
kind: 'data_source',
label: '新闻舆情',
status: 'fallback',
provider: 'AkShare',
},
];
const edges: RunFlowEdge[] = [
{
id: 'request-news',
from: 'request',
to: 'news',
kind: 'fallback',
status: 'fallback',
label: '降级输入',
},
];
describe('RunFlowGraph', () => {
it('renders auto-layered lanes, edge legend labels, and clickable nodes', () => {
const onSelectNode = vi.fn();
render(
<RunFlowGraph
lanes={lanes}
nodes={nodes}
edges={edges}
onSelectNode={onSelectNode}
/>,
);
expect(screen.getByText('入口')).toBeInTheDocument();
expect(screen.getByText('数据来源')).toBeInTheDocument();
expect(screen.getByText('降级')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '新闻舆情 节点,状态 Fallback' }));
expect(onSelectNode).toHaveBeenCalledWith(expect.objectContaining({ id: 'news' }));
});
it('distributes fan-out edge anchors instead of routing every line through the node center', () => {
const fanOutNodes: RunFlowNode[] = [
{
id: 'request',
lane: 'entry',
kind: 'entry',
label: '用户请求',
status: 'success',
},
{
id: 'daily',
lane: 'data_source',
kind: 'data_source',
label: '日线K线',
status: 'success',
},
{
id: 'quote',
lane: 'data_source',
kind: 'data_source',
label: '实时行情',
status: 'success',
},
{
id: 'llm',
lane: 'analysis',
kind: 'model',
label: 'LLM 生成',
status: 'success',
},
];
const fanOutEdges: RunFlowEdge[] = [
{
id: 'request-daily',
from: 'request',
to: 'daily',
kind: 'control',
status: 'success',
},
{
id: 'request-quote',
from: 'request',
to: 'quote',
kind: 'control',
status: 'success',
},
{
id: 'daily-llm',
from: 'daily',
to: 'llm',
kind: 'data',
status: 'success',
},
{
id: 'quote-llm',
from: 'quote',
to: 'llm',
kind: 'data',
status: 'success',
},
];
const { container } = render(
<RunFlowGraph
lanes={lanes}
nodes={fanOutNodes}
edges={fanOutEdges}
/>,
);
const pathData = Array.from(container.querySelectorAll('svg g path'))
.map((path) => path.getAttribute('d') || '');
const fanOutStartYs = pathData
.slice(0, 2)
.map((path) => Number(path.match(/^M\s+\S+\s+(\S+)/)?.[1]));
expect(new Set(fanOutStartYs).size).toBe(2);
expect(screen.getByTestId('run-flow-node-daily')).toHaveAttribute('data-layout-row');
expect(screen.getByTestId('run-flow-node-quote')).toHaveAttribute('data-layout-row');
});
it('routes same-lane vertical edges from card bottom to the next card top', () => {
const verticalNodes: RunFlowNode[] = [
{
id: 'daily',
lane: 'data_source',
kind: 'data_source',
label: '日线K线',
status: 'success',
},
{
id: 'quote',
lane: 'data_source',
kind: 'data_source',
label: '实时行情',
status: 'success',
},
];
const verticalEdges: RunFlowEdge[] = [
{
id: 'daily-quote',
from: 'daily',
to: 'quote',
kind: 'control',
status: 'success',
},
];
const { container } = render(
<RunFlowGraph
lanes={lanes}
nodes={verticalNodes}
edges={verticalEdges}
/>,
);
const pathData = container.querySelector('svg g path')?.getAttribute('d') || '';
const pathNumbers = pathData.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || [];
const [startX, startY, curveStartX, , curveEndX, , endX, endY] = pathNumbers;
const dailyNode = screen.getByTestId('run-flow-node-daily');
const quoteNode = screen.getByTestId('run-flow-node-quote');
const dailyBottom = parseFloat(dailyNode.style.top) + parseFloat(dailyNode.style.minHeight);
const quoteTop = parseFloat(quoteNode.style.top);
expect(startX).toBe(endX);
expect(curveStartX).toBe(startX);
expect(curveEndX).toBe(endX);
expect(startY).toBeLessThan(endY);
expect(startY).toBe(dailyBottom);
expect(endY).toBe(quoteTop);
});
});

View File

@@ -0,0 +1,190 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { analysisApi } from '../../../api/analysis';
import { historyApi } from '../../../api/history';
import type { RunFlowSnapshot } from '../../../types/runFlow';
import { RunFlowPanel } from '../RunFlowPanel';
vi.mock('../../../api/analysis', () => ({
analysisApi: {
getTaskFlow: vi.fn(),
},
}));
vi.mock('../../../api/history', () => ({
historyApi: {
getRecordFlow: vi.fn(),
},
}));
const snapshot: RunFlowSnapshot = {
taskId: 'task-1',
traceId: 'trace-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'degraded',
generatedAt: '2026-06-08T08:00:00Z',
summary: {
elapsedMs: 3250,
failedAttempts: 1,
fallbackCount: 1,
model: 'DeepSeek',
dataSourceCount: 2,
eventCount: 3,
},
lanes: [
{ id: 'entry', label: '入口', order: 1 },
{ id: 'data_source', label: '数据来源', order: 2 },
{ id: 'analysis', label: '分析引擎', order: 3 },
{ id: 'artifact', label: '产物', order: 4 },
],
nodes: [
{
id: 'request',
lane: 'entry',
kind: 'entry',
label: '用户请求',
status: 'success',
message: '任务请求已创建',
},
{
id: 'news',
lane: 'data_source',
kind: 'data_source',
label: '新闻舆情',
provider: 'AkShare',
status: 'fallback',
durationMs: 1200,
attempts: 2,
recordCount: 8,
message: '主数据源失败后降级成功',
metadata: {
fallbackFrom: 'Tushare',
fallbackTo: 'AkShare',
},
},
{
id: 'llm',
lane: 'analysis',
kind: 'model',
label: 'LLM 生成',
provider: 'DeepSeek',
status: 'success',
durationMs: 1800,
},
],
edges: [
{
id: 'request-news',
from: 'request',
to: 'news',
kind: 'control',
status: 'success',
label: '调度',
},
{
id: 'news-llm',
from: 'news',
to: 'llm',
kind: 'fallback',
status: 'fallback',
label: '降级输入',
},
],
events: [
{
id: 'evt-1',
timestamp: '2026-06-08T08:00:01Z',
severity: 'info',
type: 'task_created',
nodeId: 'request',
title: '任务创建',
},
{
id: 'evt-2',
timestamp: '2026-06-08T08:00:02Z',
severity: 'warning',
type: 'provider_fallback',
nodeId: 'news',
title: '新闻数据源降级',
message: '重试后切换数据源',
},
],
};
describe('RunFlowPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state while the snapshot request is pending', () => {
vi.mocked(analysisApi.getTaskFlow).mockReturnValue(new Promise(() => undefined));
render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} />);
expect(screen.getByTestId('run-flow-panel-loading')).toBeInTheDocument();
expect(screen.getByText('正在加载运行流')).toBeInTheDocument();
});
it('renders an error state and reload action when the request fails', async () => {
vi.mocked(analysisApi.getTaskFlow).mockRejectedValue({
response: {
status: 404,
data: { message: '运行流不存在' },
},
});
render(<RunFlowPanel source={{ type: 'task', taskId: 'missing-task' }} />);
expect(await screen.findByTestId('run-flow-panel-error')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument();
});
it('renders an empty snapshot state when there are no nodes or events', async () => {
vi.mocked(historyApi.getRecordFlow).mockResolvedValue({
...snapshot,
nodes: [],
edges: [],
events: [],
summary: { ...snapshot.summary, eventCount: 0 },
});
render(<RunFlowPanel source={{ type: 'history', recordId: 1 }} />);
expect(await screen.findByText('暂无运行流细节')).toBeInTheDocument();
expect(historyApi.getRecordFlow).toHaveBeenCalledWith(1);
});
it('renders a successful graph, event stream, and selectable node details', async () => {
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(snapshot);
render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} title="贵州茅台运行流" />);
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
expect(screen.getByText('贵州茅台运行流')).toBeInTheDocument();
expect(screen.getByTestId('run-flow-graph')).toBeInTheDocument();
expect(screen.getByTestId('run-flow-events')).toBeInTheDocument();
expect(await screen.findByTestId('run-flow-node-details')).toHaveTextContent('新闻舆情');
fireEvent.click(screen.getByRole('button', { name: 'LLM 生成 节点,状态 成功' }));
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('LLM 生成');
expect(screen.getByTestId('run-flow-node-details')).toHaveTextContent('DeepSeek');
});
it('does not update state after a pending request is cleaned up', async () => {
let resolveSnapshot: (value: RunFlowSnapshot) => void = () => undefined;
vi.mocked(analysisApi.getTaskFlow).mockReturnValue(new Promise((resolve) => {
resolveSnapshot = resolve;
}));
const { unmount } = render(<RunFlowPanel source={{ type: 'task', taskId: 'task-1' }} />);
unmount();
await act(async () => {
resolveSnapshot(snapshot);
});
expect(analysisApi.getTaskFlow).toHaveBeenCalledWith('task-1');
});
});

View File

@@ -0,0 +1,5 @@
export { RunFlowPanel } from './RunFlowPanel';
export { RunFlowSummaryBar } from './RunFlowSummaryBar';
export { RunFlowGraph } from './RunFlowGraph';
export { RunFlowEventList } from './RunFlowEventList';
export { RunFlowNodeDetails } from './RunFlowNodeDetails';

View File

@@ -0,0 +1,149 @@
import type { UiTextKey } from '../../i18n/uiText';
import type {
RunFlowEdgeKind,
RunFlowEventSeverity,
RunFlowNode,
RunFlowNodeKind,
RunFlowStatus,
} from '../../types/runFlow';
export type RunFlowT = (key: UiTextKey, params?: Record<string, string | number>) => string;
export const RUN_FLOW_STATUS_STYLE: Record<RunFlowStatus, {
badge: 'default' | 'success' | 'warning' | 'danger' | 'info';
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
pulse?: boolean;
}> = {
pending: { badge: 'default', tone: 'neutral' },
running: { badge: 'info', tone: 'info', pulse: true },
success: { badge: 'success', tone: 'success' },
failed: { badge: 'danger', tone: 'danger' },
degraded: { badge: 'warning', tone: 'warning' },
fallback: { badge: 'warning', tone: 'warning' },
timeout: { badge: 'danger', tone: 'danger' },
cancel_requested: { badge: 'warning', tone: 'warning', pulse: true },
cancelled: { badge: 'default', tone: 'neutral' },
skipped: { badge: 'default', tone: 'neutral' },
unknown: { badge: 'default', tone: 'neutral' },
};
export const RUN_FLOW_SEVERITY_STYLE: Record<RunFlowEventSeverity, {
badge: 'default' | 'success' | 'warning' | 'danger' | 'info';
tone: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
}> = {
info: { badge: 'info', tone: 'info' },
success: { badge: 'success', tone: 'success' },
warning: { badge: 'warning', tone: 'warning' },
danger: { badge: 'danger', tone: 'danger' },
};
const STATUS_LABEL_KEYS: Record<RunFlowStatus, UiTextKey> = {
pending: 'runFlow.status.pending',
running: 'runFlow.status.running',
success: 'runFlow.status.success',
failed: 'runFlow.status.failed',
degraded: 'runFlow.status.degraded',
fallback: 'runFlow.status.fallback',
timeout: 'runFlow.status.timeout',
cancel_requested: 'runFlow.status.cancelRequested',
cancelled: 'runFlow.status.cancelled',
skipped: 'runFlow.status.skipped',
unknown: 'runFlow.status.unknown',
};
const SEVERITY_LABEL_KEYS: Record<RunFlowEventSeverity, UiTextKey> = {
info: 'runFlow.severity.info',
success: 'runFlow.severity.success',
warning: 'runFlow.severity.warning',
danger: 'runFlow.severity.danger',
};
const EDGE_KIND_LABEL_KEYS: Record<RunFlowEdgeKind, UiTextKey> = {
data: 'runFlow.edge.data',
control: 'runFlow.edge.control',
fallback: 'runFlow.edge.fallback',
retry: 'runFlow.edge.retry',
};
const NODE_KIND_LABEL_KEYS: Record<RunFlowNodeKind, UiTextKey> = {
entry: 'runFlow.nodeKind.entry',
queue: 'runFlow.nodeKind.queue',
data_source: 'runFlow.nodeKind.dataSource',
analysis: 'runFlow.nodeKind.analysis',
model: 'runFlow.nodeKind.model',
artifact: 'runFlow.nodeKind.artifact',
notification: 'runFlow.nodeKind.notification',
};
export const getRunFlowStatusLabel = (status: RunFlowStatus, t: RunFlowT): string =>
t(STATUS_LABEL_KEYS[status] || 'runFlow.status.unknown');
export const getRunFlowSeverityLabel = (severity: RunFlowEventSeverity, t: RunFlowT): string =>
t(SEVERITY_LABEL_KEYS[severity] || 'runFlow.severity.info');
export const getRunFlowEdgeKindLabel = (kind: RunFlowEdgeKind, t: RunFlowT): string =>
t(EDGE_KIND_LABEL_KEYS[kind] || 'runFlow.edge.control');
export const getRunFlowNodeKindLabel = (kind: RunFlowNodeKind, t: RunFlowT): string =>
t(NODE_KIND_LABEL_KEYS[kind] || 'runFlow.nodeKind.analysis');
export const formatDuration = (value: number | null | undefined, t: RunFlowT): string => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return t('runFlow.valueUnavailable');
}
if (value < 1000) {
return t('runFlow.durationMs', { value });
}
if (value < 60000) {
return t('runFlow.durationSeconds', { value: (value / 1000).toFixed(1) });
}
return t('runFlow.durationMinutes', { value: (value / 60000).toFixed(1) });
};
export const formatDateTime = (
value: string | null | undefined,
language: 'zh' | 'en',
t: RunFlowT,
): string => {
if (!value) {
return t('runFlow.valueUnavailable');
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString(language === 'en' ? 'en-US' : 'zh-CN');
};
export const compactText = (value: string | null | undefined, maxLength = 64): string => {
const text = (value || '').trim();
if (!text || text.length <= maxLength) {
return text;
}
return `${text.slice(0, Math.max(8, maxLength - 12))}...${text.slice(-8)}`;
};
export const getNodeDisplayOrder = (node: RunFlowNode, index: number): number => {
const explicitOrder = node.metadata?.order;
if (typeof explicitOrder === 'number' && Number.isFinite(explicitOrder)) {
return explicitOrder;
}
return index;
};
export const formatMetadataValue = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '-';
}
if (typeof value === 'string') {
return compactText(value, 120);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return compactText(JSON.stringify(value), 120);
} catch {
return compactText(String(value), 120);
}
};

View File

@@ -1,6 +1,6 @@
import type React from 'react';
import { ChevronDown, RefreshCw } from 'lucide-react';
import { Badge, Card, StatusDot } from '../common';
import { ChevronDown, RefreshCw, Workflow } from 'lucide-react';
import { Badge, Button, Card, StatusDot, Tooltip } from '../common';
import { DashboardPanelHeader } from '../dashboard';
import type { TaskInfo } from '../../types/analysis';
import { getRequestedPhaseLabel } from '../../utils/marketPhase';
@@ -11,12 +11,13 @@ import { useUiLanguage } from '../../contexts/UiLanguageContext';
*/
interface TaskItemProps {
task: TaskInfo;
onOpenRunFlow?: (task: TaskInfo) => void;
}
/**
* 单个任务项
*/
const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
const TaskItem: React.FC<TaskItemProps> = ({ task, onOpenRunFlow }) => {
const { language, t } = useUiLanguage();
const isPending = task.status === 'pending';
const isProcessing = task.status === 'processing';
@@ -92,7 +93,28 @@ const TaskItem: React.FC<TaskItemProps> = ({ task }) => {
</div>
{/* 状态标签 */}
<div className="flex-shrink-0">
<div className="flex flex-shrink-0 items-center gap-2">
{onOpenRunFlow ? (
<Tooltip content={t('taskPanel.openRunFlow')}>
<span className="inline-flex">
<Button
type="button"
variant="ghost"
size="xsm"
className="h-8 w-8 px-0"
onClick={(event) => {
event.stopPropagation();
onOpenRunFlow(task);
}}
aria-label={t('taskPanel.openRunFlowAria', {
stock: task.stockName || task.stockCode,
})}
>
<Workflow className="h-4 w-4" aria-hidden="true" />
</Button>
</span>
</Tooltip>
) : null}
<Badge
variant={statusVariant}
className="min-w-[4.75rem] justify-center gap-1.5 shadow-none"
@@ -118,6 +140,8 @@ interface TaskPanelProps {
title?: string;
/** 自定义类名 */
className?: string;
/** 打开运行流面板 */
onOpenRunFlow?: (task: TaskInfo) => void;
}
/**
@@ -129,6 +153,7 @@ export const TaskPanel: React.FC<TaskPanelProps> = ({
visible = true,
title,
className = '',
onOpenRunFlow,
}) => {
const { t } = useUiLanguage();
// 筛选活跃任务pending 和 processing
@@ -181,7 +206,7 @@ export const TaskPanel: React.FC<TaskPanelProps> = ({
<div className="max-h-64 overflow-y-auto p-2">
<div className="space-y-2">
{activeTasks.map((task) => (
<TaskItem key={task.taskId} task={task} />
<TaskItem key={task.taskId} task={task} onOpenRunFlow={onOpenRunFlow} />
))}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TaskPanel } from '../TaskPanel';
import type { TaskInfo } from '../../../types/analysis';
@@ -72,6 +72,20 @@ describe('TaskPanel', () => {
expect(container.querySelector('.home-subpanel')).toBeTruthy();
});
it('opens the run-flow view from an active task icon button', () => {
const onOpenRunFlow = vi.fn();
render(
<TaskPanel
tasks={[baseTask]}
onOpenRunFlow={onOpenRunFlow}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 运行流' }));
expect(onOpenRunFlow).toHaveBeenCalledWith(baseTask);
});
it('does not render when there are no active tasks', () => {
const { container } = render(
<TaskPanel

View File

@@ -1,6 +1,7 @@
export { useAuth } from './useAuth';
export { useDashboardLifecycle } from './useDashboardLifecycle';
export { useHomeDashboardState } from './useHomeDashboardState';
export { useRunFlowSnapshot } from './useRunFlowSnapshot';
export { useTaskStream } from './useTaskStream';
export { useSystemConfig } from './useSystemConfig';
export type {

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { analysisApi } from '../api/analysis';
import { getParsedApiError, type ParsedApiError } from '../api/error';
import { historyApi } from '../api/history';
import type { RunFlowSnapshot, RunFlowSnapshotSource } from '../types/runFlow';
interface UseRunFlowSnapshotOptions {
source?: RunFlowSnapshotSource | null;
enabled?: boolean;
}
interface UseRunFlowSnapshotResult {
snapshot: RunFlowSnapshot | null;
isLoading: boolean;
error: ParsedApiError | null;
refetch: () => Promise<void>;
}
type RunFlowRequestState = {
requestKey: string;
snapshot: RunFlowSnapshot | null;
error: ParsedApiError | null;
};
const getSourceKey = (source?: RunFlowSnapshotSource | null): string => {
if (!source) {
return 'none';
}
return source.type === 'task'
? `task:${source.taskId}`
: `history:${source.recordId}`;
};
const isUsableSource = (source?: RunFlowSnapshotSource | null): source is RunFlowSnapshotSource => {
if (!source) {
return false;
}
if (source.type === 'task') {
return Boolean(source.taskId.trim());
}
return Number.isFinite(source.recordId);
};
export function useRunFlowSnapshot({
source,
enabled = true,
}: UseRunFlowSnapshotOptions): UseRunFlowSnapshotResult {
const [requestState, setRequestState] = useState<RunFlowRequestState>({
requestKey: 'none',
snapshot: null,
error: null,
});
const [reloadToken, setReloadToken] = useState(0);
const sourceKey = useMemo(() => getSourceKey(source), [source]);
const sourceType = source?.type;
const taskId = source?.type === 'task' ? source.taskId : '';
const recordId = source?.type === 'history' ? source.recordId : null;
const requestKey = `${sourceKey}:${reloadToken}`;
const shouldLoad = enabled && isUsableSource(source);
const refetch = useCallback(async () => {
setReloadToken((value) => value + 1);
}, []);
useEffect(() => {
if (!shouldLoad || !sourceType) {
return undefined;
}
let active = true;
const request = sourceType === 'task'
? analysisApi.getTaskFlow(taskId)
: historyApi.getRecordFlow(recordId ?? 0);
request
.then((result) => {
if (active) {
setRequestState({
requestKey,
snapshot: result,
error: null,
});
}
})
.catch((err: unknown) => {
if (active) {
setRequestState({
requestKey,
snapshot: null,
error: getParsedApiError(err),
});
}
});
return () => {
active = false;
};
}, [recordId, requestKey, shouldLoad, sourceType, taskId]);
const hasFreshState = shouldLoad && requestState.requestKey === requestKey;
return {
snapshot: hasFreshState ? requestState.snapshot : null,
isLoading: shouldLoad && !hasFreshState,
error: hasFreshState ? requestState.error : null,
refetch,
};
}

View File

@@ -211,9 +211,91 @@ const zh = {
'taskPanel.processingAria': '任务进行中',
'taskPanel.processingTasks': '{count} 进行中',
'taskPanel.pendingAria': '任务等待中',
'taskPanel.openRunFlow': '查看运行流',
'taskPanel.openRunFlowAria': '查看 {stock} 运行流',
'taskPanel.statusAria': '任务状态:{status}',
'taskPanel.title': '分析任务',
'runFlow.drawerTitle': '运行流',
'runFlow.eyebrow': '运行流',
'runFlow.title': '数据流与信息流',
'runFlow.open': '查看运行流',
'runFlow.openHistoryAria': '查看历史记录 {recordId} 运行流',
'runFlow.taskDrawerTitle': '{stock} 运行流',
'runFlow.historyDrawerTitle': '{stock} 历史运行流',
'runFlow.loadingTitle': '正在加载运行流',
'runFlow.loadingDescription': '正在读取任务快照、运行诊断和事件链路。',
'runFlow.errorTitle': '运行流加载失败',
'runFlow.retry': '重新加载',
'runFlow.refresh': '刷新',
'runFlow.refreshing': '刷新中',
'runFlow.emptyTitle': '暂无运行流',
'runFlow.emptyDescription': '请选择一个活跃任务或历史报告查看运行流。',
'runFlow.emptySnapshotTitle': '暂无运行流细节',
'runFlow.emptySnapshotDescription': '当前快照没有节点或事件;缺少 diagnostics 时会显示骨架或空状态。',
'runFlow.valueUnavailable': '未记录',
'runFlow.durationMs': '{value} ms',
'runFlow.durationSeconds': '{value} 秒',
'runFlow.durationMinutes': '{value} 分钟',
'runFlow.status.pending': '等待中',
'runFlow.status.running': '运行中',
'runFlow.status.success': '成功',
'runFlow.status.failed': '失败',
'runFlow.status.degraded': '降级',
'runFlow.status.fallback': 'Fallback',
'runFlow.status.timeout': '超时',
'runFlow.status.cancelRequested': '请求取消',
'runFlow.status.cancelled': '已取消',
'runFlow.status.skipped': '已跳过',
'runFlow.status.unknown': '未知',
'runFlow.severity.info': '信息',
'runFlow.severity.success': '成功',
'runFlow.severity.warning': '告警',
'runFlow.severity.danger': '危险',
'runFlow.edge.data': '数据',
'runFlow.edge.control': '控制',
'runFlow.edge.fallback': '降级',
'runFlow.edge.retry': '重试',
'runFlow.nodeKind.entry': '入口',
'runFlow.nodeKind.queue': '队列',
'runFlow.nodeKind.dataSource': '数据源',
'runFlow.nodeKind.analysis': '分析',
'runFlow.nodeKind.model': '模型',
'runFlow.nodeKind.artifact': '产物',
'runFlow.nodeKind.notification': '通知',
'runFlow.summary.elapsed': '总耗时',
'runFlow.summary.fallbackCount': '降级/重试',
'runFlow.summary.failedAttempts': '失败尝试',
'runFlow.summary.dataSources': '数据源',
'runFlow.summary.task': 'Task',
'runFlow.summary.trace': 'Trace',
'runFlow.summary.model': '模型',
'runFlow.summary.generatedAt': '生成时间',
'runFlow.graph.title': '运行拓扑',
'runFlow.graph.description': '自动分层展示入口、数据来源、分析引擎和产物链路。',
'runFlow.graph.nodeAria': '{label} 节点,状态 {status}',
'runFlow.events.title': '事件流',
'runFlow.events.count': '{count} 条事件',
'runFlow.events.filters': '事件筛选',
'runFlow.events.filter.all': '全部',
'runFlow.events.filter.important': '关键',
'runFlow.events.filter.problems': '失败/告警',
'runFlow.events.filter.fallback': '降级/重试',
'runFlow.events.filter.cancelled': '取消',
'runFlow.events.openNode': '查看事件 {title} 关联节点',
'runFlow.events.empty': '当前筛选下暂无事件。',
'runFlow.nodeDetails.empty': '选择一个节点查看详情。',
'runFlow.nodeDetails.title': '节点详情',
'runFlow.nodeDetails.close': '关闭节点详情',
'runFlow.nodeDetails.kind': '类型',
'runFlow.nodeDetails.provider': 'Provider',
'runFlow.nodeDetails.duration': '耗时',
'runFlow.nodeDetails.attempts': '尝试次数',
'runFlow.nodeDetails.recordCount': '记录数',
'runFlow.nodeDetails.startedAt': '开始时间',
'runFlow.nodeDetails.endedAt': '结束时间',
'runFlow.nodeDetails.metadata': '元数据',
'report.addToWatchlist': '加入自选',
'report.removeFromWatchlist': '从自选删除',
'report.watchlist': '自选',
@@ -607,9 +689,91 @@ const en: Record<UiTextKey, string> = {
'taskPanel.processingAria': 'Task processing',
'taskPanel.processingTasks': '{count} processing',
'taskPanel.pendingAria': 'Task pending',
'taskPanel.openRunFlow': 'View run flow',
'taskPanel.openRunFlowAria': 'View {stock} run flow',
'taskPanel.statusAria': 'Task status: {status}',
'taskPanel.title': 'Analysis tasks',
'runFlow.drawerTitle': 'Run Flow',
'runFlow.eyebrow': 'RUN FLOW',
'runFlow.title': 'Data and information flow',
'runFlow.open': 'View run flow',
'runFlow.openHistoryAria': 'View run flow for history record {recordId}',
'runFlow.taskDrawerTitle': '{stock} run flow',
'runFlow.historyDrawerTitle': '{stock} history run flow',
'runFlow.loadingTitle': 'Loading run flow',
'runFlow.loadingDescription': 'Reading the task snapshot, run diagnostics, and event path.',
'runFlow.errorTitle': 'Run flow failed to load',
'runFlow.retry': 'Reload',
'runFlow.refresh': 'Refresh',
'runFlow.refreshing': 'Refreshing',
'runFlow.emptyTitle': 'No run flow',
'runFlow.emptyDescription': 'Select an active task or historical report to inspect its run flow.',
'runFlow.emptySnapshotTitle': 'No run-flow details yet',
'runFlow.emptySnapshotDescription': 'This snapshot has no nodes or events; missing diagnostics show as a skeleton or empty state.',
'runFlow.valueUnavailable': 'Not recorded',
'runFlow.durationMs': '{value} ms',
'runFlow.durationSeconds': '{value} sec',
'runFlow.durationMinutes': '{value} min',
'runFlow.status.pending': 'Pending',
'runFlow.status.running': 'Running',
'runFlow.status.success': 'Success',
'runFlow.status.failed': 'Failed',
'runFlow.status.degraded': 'Degraded',
'runFlow.status.fallback': 'Fallback',
'runFlow.status.timeout': 'Timeout',
'runFlow.status.cancelRequested': 'Cancel requested',
'runFlow.status.cancelled': 'Cancelled',
'runFlow.status.skipped': 'Skipped',
'runFlow.status.unknown': 'Unknown',
'runFlow.severity.info': 'Info',
'runFlow.severity.success': 'Success',
'runFlow.severity.warning': 'Warning',
'runFlow.severity.danger': 'Danger',
'runFlow.edge.data': 'Data',
'runFlow.edge.control': 'Control',
'runFlow.edge.fallback': 'Fallback',
'runFlow.edge.retry': 'Retry',
'runFlow.nodeKind.entry': 'Entry',
'runFlow.nodeKind.queue': 'Queue',
'runFlow.nodeKind.dataSource': 'Data source',
'runFlow.nodeKind.analysis': 'Analysis',
'runFlow.nodeKind.model': 'Model',
'runFlow.nodeKind.artifact': 'Artifact',
'runFlow.nodeKind.notification': 'Notification',
'runFlow.summary.elapsed': 'Elapsed',
'runFlow.summary.fallbackCount': 'Fallback/retry',
'runFlow.summary.failedAttempts': 'Failed attempts',
'runFlow.summary.dataSources': 'Data sources',
'runFlow.summary.task': 'Task',
'runFlow.summary.trace': 'Trace',
'runFlow.summary.model': 'Model',
'runFlow.summary.generatedAt': 'Generated',
'runFlow.graph.title': 'Run topology',
'runFlow.graph.description': 'Auto-layered lanes show entry, data sources, analysis engines, and artifact paths.',
'runFlow.graph.nodeAria': '{label} node, status {status}',
'runFlow.events.title': 'Event stream',
'runFlow.events.count': '{count} events',
'runFlow.events.filters': 'Event filters',
'runFlow.events.filter.all': 'All',
'runFlow.events.filter.important': 'Key',
'runFlow.events.filter.problems': 'Failures/warnings',
'runFlow.events.filter.fallback': 'Fallback/retry',
'runFlow.events.filter.cancelled': 'Cancel',
'runFlow.events.openNode': 'View node linked to event {title}',
'runFlow.events.empty': 'No events match this filter.',
'runFlow.nodeDetails.empty': 'Select a node to inspect details.',
'runFlow.nodeDetails.title': 'Node details',
'runFlow.nodeDetails.close': 'Close node details',
'runFlow.nodeDetails.kind': 'Kind',
'runFlow.nodeDetails.provider': 'Provider',
'runFlow.nodeDetails.duration': 'Duration',
'runFlow.nodeDetails.attempts': 'Attempts',
'runFlow.nodeDetails.recordCount': 'Records',
'runFlow.nodeDetails.startedAt': 'Started',
'runFlow.nodeDetails.endedAt': 'Ended',
'runFlow.nodeDetails.metadata': 'Metadata',
'report.addToWatchlist': 'Add to watchlist',
'report.removeFromWatchlist': 'Remove from watchlist',
'report.watchlist': 'Watchlist',

View File

@@ -7,20 +7,22 @@ import { analysisApi } from '../api/analysis';
import { historyApi } from '../api/history';
import { agentApi, type SkillInfo } from '../api/agent';
import { systemConfigApi } from '../api/systemConfig';
import { ApiErrorAlert, Button, EmptyState, InlineAlert } from '../components/common';
import { ApiErrorAlert, Button, Drawer, EmptyState, InlineAlert } from '../components/common';
import { DashboardStateBlock } from '../components/dashboard';
import { StockAutocomplete } from '../components/StockAutocomplete';
import { StockHistoryTrendDrawer, StockBar } from '../components/history';
import { ReportMarkdownDrawer } from '../components/report/ReportMarkdownDrawer';
import { MarketReviewReportView } from '../components/report/MarketReviewReportView';
import { ReportSummary } from '../components/report/ReportSummary';
import { RunFlowPanel } from '../components/run-flow';
import { TaskPanel } from '../components/tasks';
import { useDashboardLifecycle, useHomeDashboardState } from '../hooks';
import { useWatchlist } from '../hooks/useWatchlist';
import { useUiLanguage } from '../contexts/UiLanguageContext';
import type { SetupStatusResponse } from '../types/systemConfig';
import { normalizeReportLanguage } from '../utils/reportLanguage';
import type { MarketReviewPayload, StockBarItem } from '../types/analysis';
import type { MarketReviewPayload, StockBarItem, TaskInfo } from '../types/analysis';
import type { RunFlowSnapshotSource } from '../types/runFlow';
type MarketReviewNotice = {
variant: 'success' | 'warning' | 'danger';
@@ -28,6 +30,10 @@ type MarketReviewNotice = {
message: string;
} | null;
type RunFlowDrawerState =
| { open: false }
| { open: true; source: RunFlowSnapshotSource; title: string };
const HomePage: React.FC = () => {
const navigate = useNavigate();
const { language: uiLanguage, t } = useUiLanguage();
@@ -40,6 +46,7 @@ const HomePage: React.FC = () => {
const [analysisSkills, setAnalysisSkills] = useState<SkillInfo[]>([]);
const [selectedStrategyId, setSelectedStrategyId] = useState('');
const [strategyMenuOpen, setStrategyMenuOpen] = useState(false);
const [runFlowDrawer, setRunFlowDrawer] = useState<RunFlowDrawerState>({ open: false });
const marketReviewPollTimer = useRef<number | null>(null);
const dashboardScrollRef = useRef<HTMLElement | null>(null);
const strategyMenuRef = useRef<HTMLDivElement | null>(null);
@@ -399,6 +406,29 @@ const HomePage: React.FC = () => {
});
}, [selectedAnalysisSkills, selectedReport, submitAnalysis]);
const openTaskRunFlow = useCallback((task: TaskInfo) => {
const stock = task.stockName || task.stockCode || task.taskId;
setRunFlowDrawer({
open: true,
source: { type: 'task', taskId: task.taskId },
title: t('runFlow.taskDrawerTitle', { stock }),
});
}, [t]);
const openHistoryRunFlow = useCallback((recordId: number) => {
const meta = selectedReport?.meta.id === recordId ? selectedReport.meta : null;
const stock = meta?.stockName || meta?.stockCode || String(recordId);
setRunFlowDrawer({
open: true,
source: { type: 'history', recordId },
title: t('runFlow.historyDrawerTitle', { stock }),
});
}, [selectedReport, t]);
const closeRunFlowDrawer = useCallback(() => {
setRunFlowDrawer({ open: false });
}, []);
const pollMarketReviewStatus = useCallback(
async (taskId: string) => {
stopMarketReviewPolling();
@@ -575,7 +605,7 @@ const HomePage: React.FC = () => {
const sidebarContent = useMemo(
() => (
<div className="flex min-h-0 h-full flex-col gap-3 overflow-hidden">
<TaskPanel tasks={activeTasks} />
<TaskPanel tasks={activeTasks} onOpenRunFlow={openTaskRunFlow} />
<StockBar
items={mergedStockBarItems}
isLoading={isLoadingStockBar}
@@ -595,6 +625,7 @@ const HomePage: React.FC = () => {
handleHistoryItemClick,
handleDeleteStock,
isDeletingStock,
openTaskRunFlow,
selectedReport?.meta.stockCode,
selectedReport?.meta.id,
],
@@ -926,6 +957,7 @@ const HomePage: React.FC = () => {
<ReportSummary
data={selectedReport}
isHistory
onOpenRunFlow={openHistoryRunFlow}
watchlist={{
isInWatchlist: watchlistState.isInWatchlist,
onToggle: watchlistState.toggleWatchlist,
@@ -964,6 +996,22 @@ const HomePage: React.FC = () => {
/>
) : null}
{runFlowDrawer.open ? (
<Drawer
isOpen={runFlowDrawer.open}
onClose={closeRunFlowDrawer}
title={t('runFlow.drawerTitle')}
width="max-w-[96vw]"
zIndex={80}
>
<RunFlowPanel
key={`${runFlowDrawer.source.type}-${runFlowDrawer.source.type === 'task' ? runFlowDrawer.source.taskId : runFlowDrawer.source.recordId}`}
source={runFlowDrawer.source}
title={runFlowDrawer.title}
/>
</Drawer>
) : null}
</div>
);
};

View File

@@ -7,6 +7,7 @@ import { historyApi } from '../../api/history';
import { systemConfigApi } from '../../api/systemConfig';
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
import { useStockPoolStore } from '../../stores';
import type { RunFlowSnapshot } from '../../types/runFlow';
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
import HomePage from '../HomePage';
@@ -28,6 +29,7 @@ vi.mock('../../api/history', () => ({
getNews: vi.fn().mockResolvedValue({ total: 0, items: [] }),
getMarkdown: vi.fn().mockResolvedValue('# report'),
getDiagnostics: vi.fn(),
getRecordFlow: vi.fn(),
getStockBarList: vi.fn().mockResolvedValue({ total: 0, items: [] }),
deleteByCode: vi.fn(),
},
@@ -42,6 +44,7 @@ vi.mock('../../api/analysis', async () => {
triggerMarketReview: vi.fn(),
getStatus: vi.fn(),
getTasks: vi.fn(),
getTaskFlow: vi.fn(),
},
};
});
@@ -118,6 +121,62 @@ const marketReviewHistoryReport = {
},
};
const runFlowSnapshot: RunFlowSnapshot = {
taskId: 'task-1',
traceId: 'trace-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'running',
generatedAt: '2026-06-08T08:00:00Z',
summary: {
elapsedMs: 1200,
failedAttempts: 0,
fallbackCount: 0,
dataSourceCount: 1,
eventCount: 1,
},
lanes: [
{ id: 'entry', label: '入口', order: 1 },
{ id: 'analysis', label: '分析引擎', order: 2 },
],
nodes: [
{
id: 'request',
lane: 'entry',
kind: 'entry',
label: '用户请求',
status: 'success',
},
{
id: 'analysis',
lane: 'analysis',
kind: 'analysis',
label: '分析流程',
status: 'running',
},
],
edges: [
{
id: 'request-analysis',
from: 'request',
to: 'analysis',
kind: 'control',
status: 'running',
label: '调度',
},
],
events: [
{
id: 'evt-1',
timestamp: '2026-06-08T08:00:00Z',
severity: 'info',
type: 'task_started',
nodeId: 'analysis',
title: '任务开始',
},
],
};
describe('HomePage', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -138,6 +197,8 @@ describe('HomePage', () => {
components: {},
copyText: 'data_status: unknown',
});
vi.mocked(historyApi.getRecordFlow).mockResolvedValue(runFlowSnapshot);
vi.mocked(analysisApi.getTaskFlow).mockResolvedValue(runFlowSnapshot);
vi.mocked(systemConfigApi.getSetupStatus).mockResolvedValue({
isComplete: true,
readyForSmoke: true,
@@ -232,6 +293,72 @@ describe('HomePage', () => {
expect(screen.getByText('暂无个股记录')).toBeInTheDocument();
});
it('opens the run-flow drawer from an active task in TaskPanel', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.getTasks).mockResolvedValue({
total: 1,
pending: 0,
processing: 1,
tasks: [
{
taskId: 'task-1',
traceId: 'trace-1',
stockCode: '600519',
stockName: '贵州茅台',
status: 'processing',
progress: 35,
message: '分析中',
reportType: 'detailed',
createdAt: '2026-06-08T08:00:00Z',
},
],
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '查看 贵州茅台 运行流' }));
await waitFor(() => {
expect(analysisApi.getTaskFlow).toHaveBeenCalledWith('task-1');
});
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
expect(screen.getByText('贵州茅台 运行流')).toBeInTheDocument();
});
it('opens the run-flow drawer from completed report diagnostics', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,
page: 1,
limit: 20,
items: [historyItem],
});
vi.mocked(historyApi.getDetail).mockResolvedValue(historyReport);
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByText('运行状态'));
fireEvent.click(screen.getByRole('button', { name: '查看历史记录 1 运行流' }));
await waitFor(() => {
expect(historyApi.getRecordFlow).toHaveBeenCalledWith(1);
});
expect(await screen.findByTestId('run-flow-panel')).toBeInTheDocument();
expect(screen.getByText('贵州茅台 历史运行流')).toBeInTheDocument();
});
it('shows market review history in the stock bar', async () => {
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
total: 1,

View File

@@ -0,0 +1,97 @@
export type RunFlowStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'degraded'
| 'fallback'
| 'timeout'
| 'cancel_requested'
| 'cancelled'
| 'skipped'
| 'unknown';
export type RunFlowNodeKind =
| 'entry'
| 'queue'
| 'data_source'
| 'analysis'
| 'model'
| 'artifact'
| 'notification';
export type RunFlowEdgeKind = 'data' | 'control' | 'fallback' | 'retry';
export type RunFlowEventSeverity = 'info' | 'success' | 'warning' | 'danger';
export interface RunFlowLane {
id: string;
label: string;
order: number;
}
export interface RunFlowNode {
id: string;
lane: string;
kind: RunFlowNodeKind;
label: string;
status: RunFlowStatus;
provider?: string | null;
startedAt?: string | null;
endedAt?: string | null;
durationMs?: number | null;
attempts?: number | null;
recordCount?: number | null;
message?: string | null;
metadata?: Record<string, unknown>;
}
export interface RunFlowEdge {
id: string;
from: string;
to: string;
kind: RunFlowEdgeKind;
status: RunFlowStatus;
label?: string | null;
message?: string | null;
metadata?: Record<string, unknown>;
}
export interface RunFlowEvent {
id: string;
timestamp?: string | null;
severity: RunFlowEventSeverity;
type: string;
nodeId?: string | null;
title: string;
message?: string | null;
metadata?: Record<string, unknown>;
}
export interface RunFlowSummary {
elapsedMs?: number | null;
bottleneckNodeId?: string | null;
failedAttempts: number;
fallbackCount: number;
model?: string | null;
dataSourceCount: number;
eventCount: number;
}
export interface RunFlowSnapshot {
taskId: string;
traceId?: string | null;
stockCode: string;
stockName?: string | null;
status: RunFlowStatus;
summary: RunFlowSummary;
lanes: RunFlowLane[];
nodes: RunFlowNode[];
edges: RunFlowEdge[];
events: RunFlowEvent[];
generatedAt: string;
}
export type RunFlowSnapshotSource =
| { type: 'task'; taskId: string }
| { type: 'history'; recordId: number };

View File

@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] #1390 收紧建议动作 legacy fallback英文 `not to ...``avoid selling/reducing/trimming ...` 等否定/回避表达不再误判为买卖动作Web 旧记录不再把中文金融上下文、`buy or sell`、多 guard 歧义文本或 `buyback` / `buy-back` / `buy back` / `selloff` / `sell-off` / `sell off` 等英文复合词渲染成 action badge并在有结构化 `action` 时让回测/历史趋势等入口按界面语言显示 action 标签。
- [改进] 完善运行时日志上下文,补充 logger name、触发来源、市场统计与实时行情预取链路状态便于排查调度、API、Bot 和数据源降级路径。
- [新功能] 新增分析任务与历史报告运行流快照 API提供 lanes、nodes、edges、events、summary 等统一契约,并从任务队列、运行诊断和 AnalysisContextPack overview 构建脱敏数据流/信息流。
- [新功能] Web 端为活跃任务、历史报告和大盘复盘报告补充运行流视图入口,支持查看运行摘要、拓扑节点、事件流和基础排障详情。
- [修复] 修复历史报告运行流快照在混合时区事件时间戳下返回 500 的问题。
- [改进] #1459 持仓管理页新增持仓账户删除入口,复用现有账户软删除接口,误建账户会从默认列表、快照、风险、录入入口和事件列表隐藏且不物理清理历史流水。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->

View File

@@ -213,7 +213,7 @@
{
"name": "status",
"in": "query",
"description": "筛选状态pending, processing, completed, failed支持逗号分隔多个",
"description": "筛选状态pending, processing, completed, failed, cancel_requested, cancelled(支持逗号分隔多个)",
"schema": {
"type": "string",
"example": "pending,processing"
@@ -1486,6 +1486,112 @@
}
}
}
},
"/api/v1/analysis/tasks/{task_id}/flow": {
"get": {
"tags": [
"Analysis"
],
"summary": "获取分析任务运行流",
"description": "根据 task_id 查询活跃任务或已落库任务的运行流快照。活跃任务缺少诊断时返回 skeleton flow完成任务可按同一 task_id/query_id 尝试读取历史诊断。",
"operationId": "getTaskRunFlow",
"parameters": [
{
"name": "task_id",
"in": "path",
"required": true,
"description": "分析任务 ID",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "任务运行流快照",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunFlowSnapshot"
}
}
}
},
"404": {
"description": "任务不存在或已过期",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "服务器错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/history/{record_id}/flow": {
"get": {
"tags": [
"History"
],
"summary": "获取历史报告运行流",
"description": "根据历史记录 ID 或可解析的 query_id 获取历史报告运行流快照。旧历史缺少诊断时返回 skeleton/unknown 状态,不影响报告详情读取。",
"operationId": "getHistoryRunFlow",
"parameters": [
{
"name": "record_id",
"in": "path",
"required": true,
"description": "历史记录主键 ID 或 query_id",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "历史报告运行流快照",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunFlowSnapshot"
}
}
}
},
"404": {
"description": "报告不存在",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "服务器错误",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
}
},
"components": {
@@ -3484,6 +3590,379 @@
"error": "not_found",
"message": "资源不存在"
}
},
"RunFlowLane": {
"type": "object",
"description": "运行流泳道定义",
"properties": {
"id": {
"type": "string",
"description": "稳定泳道 ID"
},
"label": {
"type": "string",
"description": "展示标签"
},
"order": {
"type": "integer",
"description": "展示顺序"
}
},
"required": [
"id",
"label",
"order"
]
},
"RunFlowNode": {
"type": "object",
"description": "运行流中的一个节点",
"properties": {
"id": {
"type": "string",
"description": "稳定节点 ID"
},
"lane": {
"type": "string",
"description": "所属泳道 ID"
},
"kind": {
"type": "string",
"enum": [
"entry",
"queue",
"data_source",
"analysis",
"model",
"artifact",
"notification"
],
"description": "节点类型"
},
"label": {
"type": "string",
"description": "展示标签"
},
"status": {
"type": "string",
"enum": [
"pending",
"running",
"success",
"failed",
"degraded",
"fallback",
"timeout",
"cancel_requested",
"cancelled",
"skipped",
"unknown"
],
"description": "节点状态"
},
"provider": {
"type": "string",
"nullable": true,
"description": "Provider、模型或通知渠道名称"
},
"started_at": {
"type": "string",
"nullable": true,
"description": "开始时间 ISO 字符串"
},
"ended_at": {
"type": "string",
"nullable": true,
"description": "结束时间 ISO 字符串"
},
"duration_ms": {
"type": "integer",
"nullable": true,
"minimum": 0,
"description": "耗时毫秒数"
},
"attempts": {
"type": "integer",
"nullable": true,
"minimum": 1,
"description": "节点代表的尝试次数"
},
"record_count": {
"type": "integer",
"nullable": true,
"minimum": 0,
"description": "返回记录数"
},
"message": {
"type": "string",
"nullable": true,
"description": "短脱敏状态说明"
},
"metadata": {
"type": "object",
"description": "脱敏后的低敏元数据",
"additionalProperties": true
}
},
"required": [
"id",
"lane",
"kind",
"label",
"status"
]
},
"RunFlowEdge": {
"type": "object",
"description": "运行流中的有向连线",
"properties": {
"id": {
"type": "string",
"description": "稳定连线 ID"
},
"from": {
"type": "string",
"description": "来源节点 ID"
},
"to": {
"type": "string",
"description": "目标节点 ID"
},
"kind": {
"type": "string",
"enum": [
"data",
"control",
"fallback",
"retry"
],
"description": "连线类型"
},
"status": {
"type": "string",
"enum": [
"pending",
"running",
"success",
"failed",
"degraded",
"fallback",
"timeout",
"cancel_requested",
"cancelled",
"skipped",
"unknown"
],
"description": "连线状态"
},
"label": {
"type": "string",
"nullable": true,
"description": "短展示标签"
},
"message": {
"type": "string",
"nullable": true,
"description": "短脱敏说明"
},
"metadata": {
"type": "object",
"description": "脱敏后的低敏元数据",
"additionalProperties": true
}
},
"required": [
"id",
"from",
"to",
"kind",
"status"
]
},
"RunFlowEvent": {
"type": "object",
"description": "运行流事件",
"properties": {
"id": {
"type": "string",
"description": "稳定事件 ID"
},
"timestamp": {
"type": "string",
"nullable": true,
"description": "事件时间 ISO 字符串"
},
"severity": {
"type": "string",
"enum": [
"info",
"success",
"warning",
"danger"
],
"description": "事件严重级别"
},
"type": {
"type": "string",
"description": "稳定事件类型"
},
"node_id": {
"type": "string",
"nullable": true,
"description": "关联节点 ID"
},
"title": {
"type": "string",
"description": "短标题"
},
"message": {
"type": "string",
"nullable": true,
"description": "短脱敏说明"
},
"metadata": {
"type": "object",
"description": "脱敏后的低敏元数据",
"additionalProperties": true
}
},
"required": [
"id",
"severity",
"type",
"title"
]
},
"RunFlowSummary": {
"type": "object",
"description": "运行流摘要指标",
"properties": {
"elapsed_ms": {
"type": "integer",
"nullable": true,
"minimum": 0,
"description": "观测到的总耗时毫秒数"
},
"bottleneck_node_id": {
"type": "string",
"nullable": true,
"description": "耗时最长节点 ID"
},
"failed_attempts": {
"type": "integer",
"minimum": 0,
"description": "失败尝试次数"
},
"fallback_count": {
"type": "integer",
"minimum": 0,
"description": "fallback 或 retry 转换次数"
},
"model": {
"type": "string",
"nullable": true,
"description": "诊断中观测到的脱敏模型名"
},
"data_source_count": {
"type": "integer",
"minimum": 0,
"description": "数据来源节点数量"
},
"event_count": {
"type": "integer",
"minimum": 0,
"description": "事件数量"
}
},
"required": [
"failed_attempts",
"fallback_count",
"data_source_count",
"event_count"
]
},
"RunFlowSnapshot": {
"type": "object",
"description": "任务或历史报告运行流快照",
"properties": {
"task_id": {
"type": "string",
"description": "任务 ID 或 query_id"
},
"trace_id": {
"type": "string",
"nullable": true,
"description": "诊断 trace ID"
},
"stock_code": {
"type": "string",
"description": "股票代码"
},
"stock_name": {
"type": "string",
"nullable": true,
"description": "股票名称"
},
"status": {
"type": "string",
"enum": [
"pending",
"running",
"success",
"failed",
"degraded",
"fallback",
"timeout",
"cancel_requested",
"cancelled",
"skipped",
"unknown"
],
"description": "整体运行流状态"
},
"summary": {
"$ref": "#/components/schemas/RunFlowSummary"
},
"lanes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunFlowLane"
}
},
"nodes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunFlowNode"
}
},
"edges": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunFlowEdge"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunFlowEvent"
}
},
"generated_at": {
"type": "string",
"description": "快照生成时间 ISO 字符串"
}
},
"required": [
"task_id",
"stock_code",
"status",
"summary",
"lanes",
"nodes",
"edges",
"events",
"generated_at"
]
}
},
"securitySchemes": {

View File

@@ -16,6 +16,35 @@ GET /api/v1/history/{record_id}/diagnostics
- 诊断面板支持复制后端生成的脱敏 `copy_text`,用于 issue 或部署排障。
- 分析链路在保存历史后会补齐任务/Provider/LLM/通知诊断到 `context_snapshot.diagnostics`,历史诊断接口统一聚合为用户可读摘要。
## 运行流视图
运行流视图是在运行诊断摘要之上的可视化排障入口用于串联一次分析从触发、数据获取、ContextPack 组装、LLM 生成到保存/通知的大致链路。它不替代诊断摘要的 `copy_text`,而是把同一批脱敏诊断证据组织为节点、连线、事件和摘要指标,方便从 Web 首页快速定位异常或降级环节。
后端提供两个只读快照接口:
```http
GET /api/v1/analysis/tasks/{task_id}/flow
GET /api/v1/history/{record_id}/flow
```
- `tasks/{task_id}/flow` 面向活跃任务。任务仍在内存队列中时优先返回当前任务快照;任务已完成时可按同一 `task_id/query_id` 尝试读取历史诊断。缺少诊断时返回 skeleton flow不伪造 provider、LLM 或通知事件。
- `history/{record_id}/flow` 面向历史报告,支持历史记录主键 ID 或可解析的 `query_id`。普通个股分析与 `MARKET/market_review` 大盘复盘复用同一 `RunFlowSnapshot` 契约。
- 快照顶层包含 `summary``lanes``nodes``edges``events``generated_at`。节点状态使用 `pending/running/success/failed/degraded/fallback/timeout/cancel_requested/cancelled/skipped/unknown`,其中用户取消类状态不会被映射成 `failed`
- 旧历史、缺失 `context_snapshot.diagnostics` 或证据不足时,后端返回 `unknown` 或 skeleton 节点Web 端按空/未知状态展示,不影响报告详情读取。
Web 入口:
- 首页活跃任务卡片提供运行流入口,打开抽屉后按 `task_id` 拉取任务快照。
- 历史报告摘要和运行诊断区域提供运行流入口,打开抽屉后按历史记录 ID 拉取历史快照。
- 面板展示摘要、基础拓扑、事件流和节点详情;复杂拓扑聚合、实时增量事件和布局 polish 会在后续阶段继续收敛。
脱敏与兼容边界:
- 运行流只读取既有任务信息、历史结果和 `context_snapshot.diagnostics` 中的低敏诊断字段,不新增配置项、不改数据库结构、不迁移旧历史。
- `model``provider``fallback_model` 仅用于展示实际诊断到的调用信息不参与模型选择、请求路由、Base URL 解析或配置保存。
- `metadata`、错误信息和本地路径会经过后端裁剪与脱敏,避免暴露 API key、token、cookie、webhook、prompt/raw response、代理头和本地绝对路径。
- 回滚时可移除 Web 入口和查询路径;后端新增只读快照接口不改变原有分析、历史、通知或诊断摘要接口的成功/失败语义。
## 状态文案
总体状态: