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