feat: DAG可视化编排画布 - 图查询/依赖增删接口 + React Flow画布

This commit is contained in:
2026-08-13 18:20:33 +08:00
parent 26cb289a17
commit 486e331260
+338
View File
@@ -0,0 +1,338 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
Handle,
Position,
MarkerType,
} from 'reactflow';
import type { Node, Edge, Connection, NodeProps } from 'reactflow';
import 'reactflow/dist/style.css';
import {
Card,
Select,
Button,
Space,
message,
Modal,
Typography,
Tag,
Tooltip,
Empty,
} from 'antd';
import {
ThunderboltOutlined,
ReloadOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { projectApi, dagApi } from '@/api';
import type { Project, TaskStatus, DagGraphNode, DagGraphEdge } from '@/types';
import { taskStatusConfig, priorityConfig } from '@/components/constants';
const { Text } = Typography;
// ---------- Custom task node ----------
function TaskNode({ data }: NodeProps) {
const status = data.status as TaskStatus;
const cfg = taskStatusConfig[status] || { label: status, color: '#8c8c8c' };
const pcfg = priorityConfig[data.priority as keyof typeof priorityConfig];
return (
<div
style={{
minWidth: 180,
maxWidth: 240,
background: '#fff',
border: `2px solid ${cfg.color}`,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
}}
>
<Handle type="target" position={Position.Top} />
<div
style={{
background: cfg.color,
color: '#fff',
padding: '4px 10px',
fontSize: 12,
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>#{data.taskId}</span>
<span>{cfg.label}</span>
</div>
<div style={{ padding: '8px 10px' }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4, wordBreak: 'break-all' }}>
{data.title}
</div>
<Space size={4}>
{pcfg && <Tag color={pcfg.color} style={{ marginRight: 0 }}>{pcfg.label}</Tag>}
{data.requires_review && <Tag color="purple" style={{ marginRight: 0 }}></Tag>}
</Space>
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
const nodeTypes = { taskNode: TaskNode };
// ---------- Layered auto-layout by topological depth ----------
function layoutNodes(graphNodes: DagGraphNode[], graphEdges: DagGraphEdge[]) {
const deps: Record<number, number[]> = {};
graphNodes.forEach((n) => (deps[n.id] = []));
graphEdges.forEach((e) => {
if (deps[e.target]) deps[e.target].push(e.source);
});
const depth: Record<number, number> = {};
const computeDepth = (id: number, visiting: Set<number>): number => {
if (depth[id] !== undefined) return depth[id];
if (visiting.has(id)) return 0; // cycle guard
visiting.add(id);
const parents = deps[id] || [];
const d = parents.length === 0 ? 0 : Math.max(...parents.map((p) => computeDepth(p, visiting))) + 1;
visiting.delete(id);
depth[id] = d;
return d;
};
graphNodes.forEach((n) => computeDepth(n.id, new Set()));
const byDepth: Record<number, number[]> = {};
graphNodes.forEach((n) => {
const d = depth[n.id] ?? 0;
if (!byDepth[d]) byDepth[d] = [];
byDepth[d].push(n.id);
});
const positions: Record<number, { x: number; y: number }> = {};
Object.entries(byDepth).forEach(([d, ids]) => {
ids.forEach((id, idx) => {
positions[id] = { x: Number(d) * 300, y: idx * 140 };
});
});
return positions;
}
export default function DagCanvas() {
const [projects, setProjects] = useState<Project[]>([]);
const [projectId, setProjectId] = useState<number | undefined>();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [loading, setLoading] = useState(false);
const [executing, setExecuting] = useState(false);
const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);
// Load projects
useEffect(() => {
projectApi.list().then((ps) => {
setProjects(ps);
if (ps.length > 0 && !projectId) setProjectId(ps[0].id);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadGraph = useCallback(async (pid: number) => {
setLoading(true);
try {
const graph = await dagApi.graph(pid);
const positions = layoutNodes(graph.nodes, graph.edges);
const rfNodes: Node[] = graph.nodes.map((n) => ({
id: String(n.id),
type: 'taskNode',
position: positions[n.id] || { x: 0, y: 0 },
data: {
taskId: n.id,
title: n.title,
status: n.status,
priority: n.priority,
requires_review: n.requires_review,
},
}));
const rfEdges: Edge[] = graph.edges.map((e) => ({
id: e.id,
source: String(e.source),
target: String(e.target),
markerEnd: { type: MarkerType.ArrowClosed },
style: { strokeWidth: 2 },
}));
setNodes(rfNodes);
setEdges(rfEdges);
} catch {
message.error('加载DAG图失败');
} finally {
setLoading(false);
}
}, [setNodes, setEdges]);
useEffect(() => {
if (projectId) loadGraph(projectId);
}, [projectId, loadGraph]);
// Connect: add dependency (drag from source(bottom) to target(top))
const onConnect = useCallback(
async (conn: Connection) => {
if (!conn.source || !conn.target || !projectId) return;
try {
await dagApi.addDependency({
task_id: Number(conn.target),
depends_on_id: Number(conn.source),
});
setEdges((eds) =>
addEdge(
{
...conn,
id: `e${conn.source}-${conn.target}`,
markerEnd: { type: MarkerType.ArrowClosed },
style: { strokeWidth: 2 },
},
eds
)
);
message.success('依赖已添加');
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } } };
message.error(e.response?.data?.detail || '添加依赖失败');
}
},
[projectId, setEdges]
);
// Delete selected edge
const removeSelectedEdge = useCallback(async () => {
if (!selectedEdge) return;
const source = Number(selectedEdge.source);
const target = Number(selectedEdge.target);
try {
await dagApi.removeDependency({ task_id: target, depends_on_id: source });
setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
message.success('依赖已移除');
setSelectedEdge(null);
} catch {
message.error('移除依赖失败');
}
}, [selectedEdge, setEdges]);
// Execute DAG
const executeAll = useCallback(() => {
if (!projectId || nodes.length === 0) return;
Modal.confirm({
title: '执行 DAG 编排',
content: `将按依赖顺序执行项目内全部 ${nodes.length} 个任务,确认执行?`,
okText: '开始执行',
cancelText: '取消',
onOk: async () => {
setExecuting(true);
try {
const taskIds = nodes.map((n) => Number(n.id));
const res = await dagApi.execute({ project_id: projectId, task_ids: taskIds });
const r = res.results as Record<string, number> | undefined;
Modal.success({
title: 'DAG 执行完成',
content: r
? `成功 ${r.success ?? 0} / 失败 ${r.failed ?? 0} / 阻塞 ${r.blocked ?? 0} / 跳过 ${r.skipped ?? 0}`
: '执行完成',
});
await loadGraph(projectId);
} catch {
message.error('DAG 执行失败');
} finally {
setExecuting(false);
}
},
});
}, [projectId, nodes, loadGraph]);
const minimap = useMemo(
() => (
<MiniMap
nodeColor={(n) => {
const s = (n.data as { status?: TaskStatus }).status;
return s ? taskStatusConfig[s].color : '#8c8c8c';
}}
pannable
zoomable
/>
),
[]
);
return (
<div>
<Card
title="DAG 编排画布"
extra={
<Space>
<Select
style={{ width: 220 }}
placeholder="选择项目"
value={projectId}
onChange={setProjectId}
options={projects.map((p) => ({ label: p.name, value: p.id }))}
/>
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} onClick={() => projectId && loadGraph(projectId)} loading={loading} />
</Tooltip>
<Tooltip title={selectedEdge ? '删除选中的依赖连线' : '先点击选中一条连线'}>
<Button
danger
icon={<DeleteOutlined />}
disabled={!selectedEdge}
onClick={removeSelectedEdge}
>
</Button>
</Tooltip>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={executeAll}
loading={executing}
disabled={nodes.length === 0}
>
DAG
</Button>
</Space>
}
>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
线
</Text>
</div>
<div style={{ height: 'calc(100vh - 260px)', minHeight: 480, border: '1px solid #f0f0f0', borderRadius: 8 }}>
{nodes.length === 0 && !loading ? (
<Empty description="该项目暂无任务,请先在任务管理中创建任务" style={{ marginTop: 120 }} />
) : (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeClick={(_, edge) => setSelectedEdge(edge)}
onPaneClick={() => setSelectedEdge(null)}
nodeTypes={nodeTypes}
fitView
deleteKeyCode={[]}
>
<Background gap={16} />
<Controls />
{minimap}
</ReactFlow>
)}
</div>
</Card>
</div>
);
}