feat: DAG可视化编排画布 - 图查询/依赖增删接口 + React Flow画布
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user