diff --git a/frontend/src/pages/DagCanvas.tsx b/frontend/src/pages/DagCanvas.tsx
new file mode 100644
index 0000000..a6f873e
--- /dev/null
+++ b/frontend/src/pages/DagCanvas.tsx
@@ -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 (
+
+
+
+ #{data.taskId}
+ {cfg.label}
+
+
+
+ {data.title}
+
+
+ {pcfg && {pcfg.label}}
+ {data.requires_review && 需审核}
+
+
+
+
+ );
+}
+
+const nodeTypes = { taskNode: TaskNode };
+
+// ---------- Layered auto-layout by topological depth ----------
+function layoutNodes(graphNodes: DagGraphNode[], graphEdges: DagGraphEdge[]) {
+ const deps: Record = {};
+ graphNodes.forEach((n) => (deps[n.id] = []));
+ graphEdges.forEach((e) => {
+ if (deps[e.target]) deps[e.target].push(e.source);
+ });
+
+ const depth: Record = {};
+ const computeDepth = (id: number, visiting: Set): 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 = {};
+ graphNodes.forEach((n) => {
+ const d = depth[n.id] ?? 0;
+ if (!byDepth[d]) byDepth[d] = [];
+ byDepth[d].push(n.id);
+ });
+
+ const positions: Record = {};
+ 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([]);
+ const [projectId, setProjectId] = useState();
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [loading, setLoading] = useState(false);
+ const [executing, setExecuting] = useState(false);
+ const [selectedEdge, setSelectedEdge] = useState(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 | 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(
+ () => (
+ {
+ const s = (n.data as { status?: TaskStatus }).status;
+ return s ? taskStatusConfig[s].color : '#8c8c8c';
+ }}
+ pannable
+ zoomable
+ />
+ ),
+ []
+ );
+
+ return (
+
+
+
+
+ );
+}