第12章 工作流编辑器开发
作者
谭策 — 独立开发者 | AIOps 领域探索者
- 🌐 项目官网:ITOpsAgentinfo
- 📝 博客:zjzwfw.cloud
- 📧 邮箱:huawei_network@foxmail.com
- 💬 微信公众号:IT Online

许可证
MPL-2.0 © 谭策
本章导读
在 IT 运维自动化场景中,单一 Agent 的能力有限,复杂任务通常需要多个 Agent 按顺序协作完成。例如:先由"日志分析 Agent"排查错误原因,再由"修复 Agent"执行修复脚本,最后由"验证 Agent"确认修复结果。工作流编辑器通过可视化的拖拽方式,让运维人员可以像搭积木一样组合多个 Agent,形成可复用的自动化流程。
本章将以 ITOps Agent Platform 的 WorkflowEditor 页面为核心,深入讲解如何使用 @xyflow/react(React Flow)构建功能完整的工作流编辑器。你将学习节点类型定义、拖拽交互、画布操作、撤销/重做、工作流验证和导入导出等核心功能,掌握如何从零开发一个生产级的可视化编辑器。
学习目标
- 理解
@xyflow/react的核心概念和架构设计 - 掌握自定义节点组件的开发方法
- 学会实现拖拽创建节点和连线功能
- 理解画布缩放/平移和 MiniMap 的实现原理
- 掌握撤销/重做历史记录模式
- 学会设计工作流验证逻辑
- 掌握工作流的保存/加载/导入/导出模式
- 理解前后端数据结构映射关系
12.1 @xyflow/react 基础
12.1.1 什么是 @xyflow/react
@xyflow/react 是 React Flow 的官方包(v12+ 从 reactflow 迁移而来),是一个基于 React 的节点图编辑器库。它提供了构建可视化编辑器的所有基础设施:
┌─────────────────────────────────────────────────────────┐
│ @xyflow/react │
├─────────────────────────────────────────────────────────┤
│ ReactFlow 核心组件 │
│ ├── 节点渲染系统 (Node Types) │
│ ├── 连线渲染系统 (Edge Types) │
│ ├── 交互系统 (拖拽/缩放/平移/选择) │
│ └── 布局系统 (坐标转换/视口管理) │
├─────────────────────────────────────────────────────────┤
│ 内置 UI 组件 │
│ ├── Controls (缩放/适应/锁定) │
│ ├── MiniMap (缩略导航图) │
│ ├── Background (网格/点阵背景) │
│ └── Panel (浮动面板) │
├─────────────────────────────────────────────────────────┤
│ 工具函数 │
│ ├── addEdge (创建连线) │
│ ├── useNodesState (节点状态 Hook) │
│ ├── useEdgesState (连线状态 Hook) │
│ └── MarkerType (箭头标记类型) │
└─────────────────────────────────────────────────────────┘12.1.2 核心数据模型
React Flow 围绕三个核心概念构建:
| 概念 | 说明 | 数据结构 |
|---|---|---|
| Node(节点) | 画布上的独立元素,代表一个工作步骤 | { id, type, position, data } |
| Edge(连线) | 连接两个节点的有向边,代表执行顺序 | { id, source, target, animated } |
| Handle(连接点) | 节点上的输入/输出锚点,用于连线 | <Handle type="target|source" /> |
数据结构示意:
interface Node {
id: string; // 唯一标识
type: string; // 节点类型('agent' | 其他)
position: { x: number; y: number }; // 画布坐标
data: Record<string, any>; // 自定义业务数据
connectable: boolean; // 是否可连接
selected?: boolean; // 是否选中
}
interface Edge {
id: string; // 唯一标识
source: string; // 起点节点 ID
target: string; // 终点节点 ID
sourceHandle?: string; // 起点连接点
targetHandle?: string; // 终点连接点
animated?: boolean; // 是否显示动画
markerEnd?: object; // 终点箭头样式
style?: object; // 连线样式
}12.1.3 项目依赖
{
"@xyflow/react": "^12.x",
"@xyflow/react/dist/style.css"
}WorkflowEditor.tsx 的导入清单:
import {
ReactFlow,
ReactFlowProvider,
addEdge,
useNodesState,
useEdgesState,
Controls,
Background,
MiniMap,
Panel,
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Handle, Position } from '@xyflow/react';12.2 自定义节点开发
12.1.1 AgentNode 组件
项目定义了一个自定义 Agent 节点,用于在画布上展示 Agent 信息:
// frontend/src/pages/WorkflowEditor.tsx
import { Handle, Position } from '@xyflow/react';
const AgentNode = ({ data, selected }: { data: any; selected: boolean }) => {
return (
<div
className={`
px-4 py-3 rounded-lg shadow-md border-2 min-w-[200px]
${selected
? 'border-primary bg-primary/10 ring-2 ring-primary/30'
: 'border-border bg-surface'
}
transition-all duration-200
`}
>
{/* 输入连接点(左侧) */}
<Handle
type="target"
position={Position.Left}
className="w-3 h-3 bg-primary"
/>
{/* Agent 头像和名称 */}
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">{data.avatar || '🤖'}</span>
<span className="font-semibold text-text-primary text-sm">
{data.label || 'Agent'}
</span>
</div>
{/* Agent 描述 */}
{data.description && (
<div className="text-xs text-text-secondary mb-2 line-clamp-2">
{data.description}
</div>
)}
{/* 输入输出键显示 */}
<div className="space-y-1 mb-2">
{data.inputKey && (
<div className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded border border-blue-200">
← 输入: {data.inputKey}
</div>
)}
{data.outputKey && (
<div className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded border border-green-200">
→ 输出: {data.outputKey}
</div>
)}
</div>
{/* Prompt 配置状态 */}
{data.prompt && (
<div className="text-xs text-text-secondary bg-background px-2 py-1 rounded border border-border">
已配置Prompt
</div>
)}
{/* 输出连接点(右侧) */}
<Handle
type="source"
position={Position.Right}
className="w-3 h-3 bg-primary"
/>
</div>
);
};节点设计说明:
┌─────────────────────────────────────┐
│ ● ┌─ 输入连接点 (Handle target) │
│ │ │
│ │ 🤖 日志分析 Agent │
│ │ │
│ │ 分析服务器日志,找出错误原因 │ ← data.description
│ │ │
│ │ ← 输入: log_data │ ← data.inputKey
│ │ → 输出: analysis_result │ ← data.outputKey
│ │ │
│ │ 已配置Prompt │ ← data.prompt (可选)
│ │ │
│ ● ──┤ ─ 输出连接点 (Handle source)
└─────────────────────────────────────┘连接点设计:
- Target(输入):位于左侧,接收来自上游节点的数据流
- Source(输出):位于右侧,将数据传递给下游节点
- 数据流向:左 → 右,符合从左到右的阅读习惯
12.1.2 节点类型注册
自定义节点必须注册到 React Flow 的节点类型映射表中:
const nodeTypes: NodeTypes = {
agent: AgentNode, // 类型名 → 组件映射
};
// 在 ReactFlow 组件中使用
<ReactFlow
nodeTypes={nodeTypes} // 传入类型映射
// ...
/>当节点的 type: 'agent' 时,React Flow 会自动使用 AgentNode 组件渲染。
12.3 工作流编辑器架构
12.3.1 整体布局
工作流编辑器采用三栏布局:
┌────────────────────────────────────────────────────────────────┐
│ ← 返回 │ 编辑工作流 │ [撤销] [重做] │ [导入] [导出] │ 保存 │
├────────────────────────────────────────────────────────────────┤
│ 工作流名称: [____________] 描述: [____________] ☑ 设为模板 │
│ ┌────────────────────────────────────────────────────────────┐│
│ │ ⚠ 发现问题 ││
│ │ • 请至少添加一个节点 ││
│ └────────────────────────────────────────────────────────────┘│
├──────────────┬─────────────────────────────────────────────────┤
│ 可用 Agent │ │
│ ┌──────────┐ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │🤖 日志分析│ │ │ │───►│ │───►│ │ │
│ └──────────┘ │ └─────────┘ └─────────┘ └─────────┘ │
│ ┌──────────┐ │ ● MiniMap │
│ │🔧 修复 │ │ │
│ └──────────┘ │ │
│ ┌──────────┐ │ [Background 网格背景] │
│ │✅ 验证 │ │ │
│ └──────────┘ │ [Controls 缩放控件] [Panel 操作提示] │
├──────────────┤ │
│ 节点配置 │ │
│ ┌──────────┐ │ │
│ │ 显示名称 │ │ │
│ │ 节点描述 │ │ │
│ │ 输入键名 │ │ │
│ │ 输出键名 │ │ │
│ │ 自定义Prompt│ │ │
│ └──────────┘ │ │
└──────────────┴─────────────────────────────────────────────────┘
左侧面板 中间画布 (ReactFlow)12.3.2 状态管理
编辑器管理了 8 个状态:
| 状态 | 类型 | 用途 |
|---|---|---|
nodes | Node[] | 画布上的所有节点 |
edges | Edge[] | 节点之间的连线 |
reactFlowInstance | any | React Flow 实例,用于坐标转换等 |
name | string | 工作流名称 |
description | string | 工作流描述 |
selectedNode | Node | null | 当前选中的节点 |
isTemplate | boolean | 是否设为模板 |
history | Array<{nodes, edges}> | 撤销/重做历史 |
historyIndex | number | 当前历史位置 |
validationErrors | string[] | 验证错误列表 |
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [isTemplate, setIsTemplate] = useState(false);
const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [validationErrors, setValidationErrors] = useState<string[]>([]);12.3.3 ReactFlowProvider 上下文
@xyflow/react 的许多 Hook 和组件依赖 ReactFlowProvider 提供的上下文。编辑器将内容包裹在 Provider 中:
export default function WorkflowEditor() {
return (
<ReactFlowProvider>
<WorkflowEditorContent />
</ReactFlowProvider>
);
}为什么需要 Provider:
┌─────────────────────────────────────┐
│ ReactFlowProvider │
│ │
│ 提供: │
│ ├── ReactFlow 实例上下文 │
│ ├── 坐标转换函数 │
│ ├── 节点/边状态共享 │
│ └── 视口状态 (缩放/平移) │
│ │
│ ┌───────────────────────────────┐ │
│ │ WorkflowEditorContent │ │
│ │ │ │
│ │ 可以使用: │ │
│ │ ├── useReactFlow() │ │
│ │ ├── useStore() │ │
│ │ └── MiniMap / Controls 等 │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘12.4 数据加载与保存
12.4.1 加载已有工作流
使用 React Query 从后端获取工作流数据:
const { data: workflow } = useQuery({
queryKey: ['workflow', id],
queryFn: async () => {
const res = await api.get(`/api/workflows/${id}`);
return res.data.data;
},
enabled: !!id && id !== 'new', // 新建时不请求
});
// 数据加载完成后初始化状态
useEffect(() => {
if (workflow) {
setName(workflow.name);
setDescription(workflow.description);
setIsTemplate(workflow.is_template === 1);
if (workflow.nodes && workflow.nodes.length > 0) {
setNodes(workflow.nodes); // 后端存储的 JSON 已解析为 Node[]
}
if (workflow.edges && workflow.edges.length > 0) {
setEdges(workflow.edges); // 后端存储的 JSON 已解析为 Edge[]
}
// 初始化历史
setHistory([{ nodes: workflow.nodes || [], edges: workflow.edges || [] }]);
setHistoryIndex(0);
}
}, [workflow, setNodes, setEdges]);12.4.2 后端存储格式
工作流数据以 JSON 序列化后存储在 SQLite 中:
CREATE TABLE workflows (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
nodes TEXT, -- JSON 字符串: [{"id":"node-1","type":"agent",...}]
edges TEXT, -- JSON 字符串: [{"id":"e1","source":"node-1",...}]
agent_configs TEXT, -- JSON 字符串: 节点配置
is_template INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP
);后端路由处理:
// backend/src/routes/workflowRoutes.ts
// 创建:序列化后存储
router.post('/', requireRole('admin', 'operator'), (req, res) => {
const { name, description, nodes, edges, agent_configs, is_template } = req.body;
const id = randomUUID();
db.prepare(`
INSERT INTO workflows (id, name, description, nodes, edges, agent_configs, is_template)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id, name, description,
JSON.stringify(nodes || []), // 序列化
JSON.stringify(edges || []),
JSON.stringify(agent_configs || {}),
is_template ? 1 : 0
);
});
// 查询:反序列化后返回
router.get('/:id', (req, res) => {
const workflow = db.prepare('SELECT * FROM workflows WHERE id = ?').get(req.params.id);
const w = workflow as Record<string, unknown>;
if (w.nodes) w.nodes = JSON.parse(w.nodes as string); // 反序列化
if (w.edges) w.edges = JSON.parse(w.edges as string);
if (w.agent_configs) w.agent_configs = JSON.parse(w.agent_configs as string);
res.json({ success: true, data: workflow });
});前后端数据流:
前端 Node[] / Edge[] (JS 对象)
│
│ JSON.stringify (保存)
▼
后端 TEXT 字段 (JSON 字符串)
│
│ 存储到 SQLite
▼
SQLite 数据库
│
│ 读取
▼
后端 TEXT 字段 (JSON 字符串)
│
│ JSON.parse (查询)
▼
前端 Node[] / Edge[] (JS 对象)12.4.3 保存工作流
使用 React Query mutation 保存到后端:
const saveMutation = useMutation({
mutationFn: async (data: WorkflowData) => {
if (!validateWorkflow()) {
throw new Error('工作流验证失败');
}
if (id && id !== 'new') {
await api.put(`/api/workflows/${id}`, data); // 更新
} else {
await api.post('/api/workflows', data); // 创建
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workflows'] });
navigate('/workflows');
alert('保存成功!');
},
onError: (error: any) => {
alert(error.message || '保存失败,请重试');
},
});
const handleSave = useCallback(() => {
if (!validateWorkflow()) {
alert('工作流验证失败:\n' + validationErrors.join('\n'));
return;
}
saveMutation.mutate({
name,
description,
nodes,
edges,
is_template: isTemplate ? 1 : 0,
});
}, [name, description, nodes, edges, isTemplate, saveMutation, validateWorkflow]);12.5 拖拽与连线交互
12.5.1 从侧边栏拖拽创建节点
左侧面板的 Agent 列表项支持 HTML5 拖拽 API:
// 拖拽开始:写入 dataTransfer
<div
draggable
onDragStart={(event) => {
event.dataTransfer.setData('application/reactflow/agentId', agent.id);
event.dataTransfer.setData('application/reactflow/agentName', agent.name);
event.dataTransfer.setData('application/reactflow/agentAvatar', agent.avatar || '🤖');
event.dataTransfer.setData('application/reactflow/agentDescription', agent.description || '');
event.dataTransfer.setData('application/reactflow/agentSystemPrompt', agent.system_prompt || '');
event.dataTransfer.effectAllowed = 'move';
}}
className="p-3 rounded-lg border border-border bg-background hover:border-primary cursor-move"
>
<span className="text-xl">{agent.avatar || '🤖'}</span>
<span>{agent.name}</span>
</div>拖拽释放处理:
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault(); // 必须阻止默认行为才能触发 onDrop
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
// 读取拖拽数据
const agentId = event.dataTransfer.getData('application/reactflow/agentId');
const agentName = event.dataTransfer.getData('application/reactflow/agentName');
const agentAvatar = event.dataTransfer.getData('application/reactflow/agentAvatar');
const agentDescription = event.dataTransfer.getData('application/reactflow/agentDescription');
const agentSystemPrompt = event.dataTransfer.getData('application/reactflow/agentSystemPrompt');
if (typeof agentId !== 'string') return;
// 将屏幕坐标转换为 React Flow 画布坐标
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// 创建新节点
const newNode: Node = {
id: `node-${Date.now()}`, // 使用时间戳生成唯一 ID
type: 'agent',
position,
data: {
label: agentName,
agentId,
avatar: agentAvatar,
description: agentDescription,
prompt: agentSystemPrompt,
},
connectable: true,
};
setNodes((nds) => nds.concat(newNode));
},
[reactFlowInstance, setNodes]
);坐标转换说明:
屏幕坐标 (clientX/Y) 画布坐标 (position)
(考虑缩放和平移)
┌─────────────────┐ ┌─────────────────┐
│ │ │ │
│ (200, 300) │ │ (150, 250) │
│ ● ────────┼──screenToFlowPosition──►● │
│ │ │ │
│ React Flow 画布 │ │ 缩放 1.5x │
│ 缩放: 1.5x │ │ 平移: x=50,y=50 │
│ 平移: x=50,y=50 │ │ │
└─────────────────┘ └─────────────────┘12.5.2 节点连线
onConnect 回调处理节点间的连线创建:
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge({
...params,
animated: true, // 流动动画
markerEnd: { type: MarkerType.ArrowClosed }, // 闭合箭头
style: { stroke: '#3b82f6', strokeWidth: 2 }, // 蓝色线条
}, eds));
},
[setEdges]
);连线视觉设计:
┌──────────┐ ┌──────────┐
│ Node A │─────● source ────────●── target ───│ Node B │
│ │ \ / │ │
│ │ \ / │ │
│ │ \ / │ │
│ │ ──────▶ │ │
│ │ 蓝色箭头 + 流动动画 │ │
└──────────┘ └──────────┘12.6 画布操作
12.6.1 缩放和平移
React Flow 内置缩放和平移支持,通过鼠标操作:
| 操作 | 效果 |
|---|---|
| 鼠标滚轮 | 以光标为中心缩放 |
| Ctrl + 拖拽 | 框选区域放大 |
| 鼠标拖拽空白区域 | 平移画布 |
| 点击 Controls 按钮 | 放大/缩小/适应视图 |
<ReactFlow
fitView // 初始时自动适应所有节点在视口内
// ...
>
<Controls /> // 内置缩放控件 (+/-/fit/lock)
</ReactFlow>12.6.2 MiniMap 缩略图
MiniMap 提供画布的缩略预览,支持点击导航:
<MiniMap
nodeColor={(node) => {
return node.selected ? '#3b82f6' : '#475569';
}}
className="border border-border rounded-lg overflow-hidden"
/>MiniMap 布局:
┌─────────────────────────────┐
│ │
│ 主画布区域 │
│ │
│ ┌─────────┐ │
│ │ MiniMap │ │ ← 右下角
│ │ 缩略图 │ │
│ │ ▓▓░░▓▓ │ │ ← 节点分布
│ │ ░▓▓░ │ │ ← 蓝色=选中节点
│ └─────────┘ │
└─────────────────────────────┘12.6.3 背景网格
<Background gap={16} size={1} />| 参数 | 值 | 说明 |
|---|---|---|
gap | 16 | 网格间距(像素) |
size | 1 | 点的大小(1=点阵,更大=网格线) |
12.7 撤销/重做历史
12.7.1 历史存储
使用数组存储每次操作后的节点和边快照:
const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 保存当前状态到历史
const saveHistory = useCallback(() => {
// 删除当前索引之后的所有历史(如果用户在中间状态做了新操作)
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push({ nodes: [...nodes], edges: [...edges] });
// 限制历史长度,防止内存增长
if (newHistory.length > 50) newHistory.shift();
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}, [nodes, edges, history, historyIndex]);12.7.2 防抖保存
直接监听 nodes/edges 变化会导致每次拖动都保存历史。使用防抖优化:
const saveHistoryRef = useRef(saveHistory);
useEffect(() => {
saveHistoryRef.current = saveHistory;
}, [saveHistory]);
useEffect(() => {
if (nodes.length > 0 || edges.length > 0) {
const timer = setTimeout(() => {
saveHistoryRef.current();
}, 500); // 500ms 防抖
return () => clearTimeout(timer);
}
}, [nodes.length, edges.length]); // 只监听长度变化,而非内容为什么监听长度而非内容:
- 拖动节点时
nodes内容变化但长度不变,不需要保存历史 - 只在添加/删除节点时保存,减少历史记录噪音
12.7.3 撤销和重做实现
const handleUndo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const state = history[newIndex];
setNodes(state.nodes);
setEdges(state.edges);
setHistoryIndex(newIndex);
}
}, [history, historyIndex, setNodes, setEdges]);
const handleRedo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const state = history[newIndex];
setNodes(state.nodes);
setEdges(state.edges);
setHistoryIndex(newIndex);
}
}, [history, historyIndex, setNodes, setEdges]);历史操作模型:
历史数组:
[State0] [State1] [State2] [State3]
│ │ │ │
└────────┴────────┴────────┘
撤销/重做 游标
撤销: 游标左移,恢复前一个状态
重做: 游标右移,恢复后一个状态
新操作: 删除游标右侧所有状态,追加新状态12.8 节点选择与编辑
12.8.1 节点选择
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node);
}, []);
const onPaneClick = useCallback(() => {
setSelectedNode(null); // 点击空白区域取消选择
}, []);12.8.2 节点配置面板
选中节点后,右侧面板展示可编辑的配置项:
{selectedNode && (
<div className="border-t border-border p-4 bg-background/50 overflow-y-auto max-h-96">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">节点配置</h3>
<div className="flex gap-1">
<button onClick={duplicateSelectedNode}>复制</button>
<button onClick={deleteSelectedNode}>删除</button>
</div>
</div>
{/* 显示名称 */}
<input
value={(selectedNode.data?.label as string) || ''}
onChange={(e) => {
setNodes((nds) =>
nds.map((n) =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, label: e.target.value } }
: n
)
);
}}
/>
{/* 节点描述 */}
<textarea value={selectedNode.data?.description as string || ''} ... />
{/* 输入/输出键配置 */}
<input value={selectedNode.data?.inputKey as string || ''} ... />
<input value={selectedNode.data?.outputKey as string || ''} ... />
{/* 自定义 Prompt */}
<textarea value={selectedNode.data?.prompt as string || ''} ... />
</div>
)}状态同步模式:
修改输入框 ──► setNodes (更新画布数据)
│
├──► ReactFlow 重新渲染节点
│
└──► setSelectedNode (同步选中节点引用)
│
└──► 配置面板显示最新值12.8.3 复制与删除节点
const deleteSelectedNode = useCallback(() => {
if (!selectedNode) return;
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) => eds.filter(
(e) => e.source !== selectedNode.id && e.target !== selectedNode.id
));
setSelectedNode(null);
}, [selectedNode, setNodes, setEdges]);
const duplicateSelectedNode = useCallback(() => {
if (!selectedNode) return;
const newNode: Node = {
...selectedNode,
id: `node-${Date.now()}`, // 新 ID
position: {
x: selectedNode.position.x + 50,
y: selectedNode.position.y + 50 // 偏移 50px 避免重叠
},
};
setNodes((nds) => nds.concat(newNode));
}, [selectedNode, setNodes]);12.9 工作流验证
12.9.1 验证规则
保存前对工作流进行结构验证,确保可以正常执行:
const validateWorkflow = useCallback(() => {
const errors: string[] = [];
// 1. 名称验证
if (!name.trim()) {
errors.push('请输入工作流名称');
}
// 2. 节点数量验证
if (nodes.length === 0) {
errors.push('请至少添加一个节点');
}
// 3. 孤立节点检测
if (nodes.length > 1) {
const connectedNodes = new Set<string>();
edges.forEach(e => {
connectedNodes.add(e.source);
connectedNodes.add(e.target);
});
const orphanNodes = nodes.filter(n => !connectedNodes.has(n.id));
if (orphanNodes.length > 0) {
errors.push(`发现 ${orphanNodes.length} 个孤立节点,请连接或删除`);
}
}
setValidationErrors(errors);
return errors.length === 0;
}, [name, nodes, edges]);12.9.2 验证错误展示
{validationErrors.length > 0 && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-center gap-2 text-red-500 mb-2">
<AlertCircle className="w-4 h-4" />
<span className="font-medium">发现问题</span>
</div>
<ul className="text-sm text-red-500 space-y-1">
{validationErrors.map((err, i) => (
<li key={i}>• {err}</li>
))}
</ul>
</div>
)}12.9.3 验证场景分析
| 验证规则 | 触发条件 | 错误信息 | 严重程度 |
|---|---|---|---|
| 名称为空 | name.trim() === '' | 请输入工作流名称 | 阻塞保存 |
| 无节点 | nodes.length === 0 | 请至少添加一个节点 | 阻塞保存 |
| 孤立节点 | 多节点但存在未连线节点 | 发现 N 个孤立节点 | 阻塞保存 |
| 环检测 (简化) | TODO | 存在循环依赖 | 阻塞保存 |
孤立节点检测算法:
节点: [A, B, C, D]
边: [A→B, B→C]
connectedNodes = {A, B, C} (所有出现在边中的节点)
orphanNodes = [D] (不在 connectedNodes 中的节点)
→ 错误: "发现 1 个孤立节点"12.10 导入与导出
12.10.1 导出为 JSON 文件
const handleExport = useCallback(() => {
const data = {
name,
description,
nodes,
edges,
is_template: isTemplate ? 1 : 0,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name || 'workflow'}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url); // 释放 Blob URL,防止内存泄漏
}, [name, description, nodes, edges, isTemplate]);导出文件示例:
{
"name": "服务器CPU告警自动排查",
"description": "当收到CPU告警时自动排查",
"nodes": [
{
"id": "node-1680000001",
"type": "agent",
"position": { "x": 100, "y": 200 },
"data": {
"label": "日志分析Agent",
"agentId": "agent-001",
"avatar": "🤖",
"description": "分析日志找出原因"
}
}
],
"edges": [],
"is_template": 0,
"exportedAt": "2026-05-27T10:30:00.000Z"
}12.10.2 从 JSON 文件导入
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImport = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
if (data.nodes && data.edges) {
setName(data.name || '导入的工作流');
setDescription(data.description || '');
setIsTemplate(data.is_template === 1);
setNodes(data.nodes);
setEdges(data.edges);
alert('导入成功!');
} else {
alert('无效的工作流文件');
}
} catch {
alert('导入失败:无效的JSON格式');
}
};
reader.readAsText(file);
event.target.value = ''; // 重置 file input,允许重复选择同一文件
}, [setNodes, setEdges]);触发导入的隐藏文件输入:
<input
type="file"
ref={fileInputRef}
onChange={handleImport}
accept=".json"
className="hidden"
/>
<button onClick={() => fileInputRef.current?.click()}>
<Upload /> 导入
</button>12.11 工作流执行
12.11.1 执行入口
工作流编辑器本身不执行工作流,而是导航到任务页面触发执行:
const handleExecute = useCallback(() => {
if (!id || id === 'new') {
alert('请先保存工作流再执行');
return;
}
navigate(`/tasks?workflowId=${id}`);
}, [id, navigate]);12.11.2 执行流程概览
WorkflowEditor 任务页面 后端 API Agent 执行
│ │ │ │
│── navigate ───────────►│ │ │
│ /tasks?workflowId=X │ │ │
│ │── POST /api/tasks ──►│ │
│ │ {workflowId: X} │ │
│ │ │── load workflow ───►│
│ │ │ (nodes + edges) │
│ │ │ │
│ │ │── execute node 1 ──►│
│ │◄── task:created ────│ │
│ │ (WebSocket) │ │
│ │ │── execute node 2 ──►│
│ │◄── task:progress ───│ │
│ │ (WebSocket) │ │
│ │ │── execute node N ──►│
│ │◄── task:completed ──│ │
│ │ (WebSocket) │ │12.12 画布操作与布局
12.12.1 清空画布
const handleClear = useCallback(() => {
if (confirm('确定要清空画布吗?此操作不可撤销。')) {
setNodes([]);
setEdges([]);
setSelectedNode(null);
}
}, [setNodes, setEdges]);使用 Panel 组件在画布左下角放置清空按钮:
<Panel position="bottom-left">
<div className="bg-surface/95 backdrop-blur-sm p-2 rounded-lg border border-border">
<button onClick={handleClear} className="text-red-500">
<Trash2 /> 清空画布
</button>
</div>
</Panel>12.12.2 操作提示面板
在画布顶部中央展示实时统计信息:
<Panel position="top-center">
<div className="bg-surface/95 backdrop-blur-sm px-4 py-2 rounded-lg border border-border">
<span>从左侧拖拽Agent到画布创建节点</span>
<span>•</span>
<span>{nodes.length} 个节点</span>
<span>•</span>
<span>{edges.length} 条连接</span>
</div>
</Panel>12.13 完整的 ReactFlow 配置
将所有组件组合在一起的最终配置:
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange} // 节点移动/选中/删除
onEdgesChange={onEdgesChange} // 连线删除
onConnect={onConnect} // 新连线创建
onInit={setReactFlowInstance} // 实例初始化
onDrop={onDrop} // 拖拽释放
onDragOver={onDragOver} // 拖拽经过
onNodeClick={onNodeClick} // 节点点击
onPaneClick={onPaneClick} // 空白区域点击
nodeTypes={nodeTypes} // 自定义节点类型
proOptions={proOptions} // 隐藏 attribution
fitView // 自动适应视图
>
<Background gap={16} size={1} />
<Controls />
<MiniMap
nodeColor={(node) => node.selected ? '#3b82f6' : '#475569'}
/>
<Panel position="top-center">
{/* 操作提示 */}
</Panel>
<Panel position="bottom-left">
{/* 清空画布 */}
</Panel>
</ReactFlow>12.14 工作流编辑器最佳实践
12.14.1 节点 ID 生成
| 方案 | 优点 | 缺点 |
|---|---|---|
Date.now() | 简单、几乎不冲突 | 极快操作可能冲突 |
crypto.randomUUID() | 完全不冲突 | 需要 Web Crypto API |
| 递增计数器 | 简单有序 | 需要同步状态 |
项目使用 node-${Date.now()} 方案,在正常使用场景下足够安全。
12.14.2 状态更新模式
React Flow 推荐使用函数式更新,避免闭包问题:
// 正确:函数式更新
setNodes((nds) => nds.concat(newNode));
setEdges((eds) => eds.filter(e => e.source !== nodeId));
// 错误:直接使用当前状态
setNodes([...nodes, newNode]); // 可能使用过时的 nodes12.14.3 性能优化建议
| 场景 | 优化策略 |
|---|---|
| 大量节点 | 使用 useCallback 缓存事件处理器 |
| 频繁更新 | 使用 useMemo 缓存派生数据 |
| 自定义节点 | 使用 React.memo 避免不必要的重渲染 |
| 历史保存 | 使用防抖,只监听关键状态变化 |
本章小结
本章系统讲解了使用 @xyflow/react 构建工作流编辑器的完整流程,核心要点包括:
- 自定义节点:通过 Handle 组件定义输入/输出连接点,通过 nodeTypes 映射注册自定义组件
- 拖拽交互:使用 HTML5 Drag & Drop API 配合
screenToFlowPosition实现侧边栏拖拽创建 - 连线系统:通过
onConnect和addEdge实现带箭头和动画的可视化连线 - 画布操作:内置 Controls、MiniMap、Background 组件提供缩放/导航/背景功能
- 撤销/重做:使用历史数组 + 游标模式,配合防抖优化减少无效记录
- 工作流验证:保存前检查名称、节点数量、孤立节点,确保工作流可执行
- 导入/导出:通过 Blob API 实现 JSON 文件下载,FileReader 实现文件上传解析
- 后端集成:JSON 序列化存储到 SQLite,查询时反序列化为前端可用格式
工作流编辑器是可视化编程的典型应用,React Flow 提供了强大的基础设施,业务逻辑围绕节点数据模型展开。
本章练习
基础练习
阅读
WorkflowEditor.tsx中的AgentNode组件,解释Handle组件的type和position属性的作用。如果把Position.Left改为Position.Top,节点的外观和行为会有什么变化?在
onDrop回调中,reactFlowInstance.screenToFlowPosition的作用是什么?如果不进行坐标转换,直接将event.clientX/Y赋值给节点位置,会出现什么问题?分析
saveHistory函数中的history.slice(0, historyIndex + 1)操作。为什么需要在保存新状态前删除游标之后的历史?请举例说明。
进阶练习
当前的
validateWorkflow函数只做了简化版的环检测(注释为 TODO)。请实现一个完整的环检测算法(可以使用深度优先搜索或拓扑排序),检测工作流中是否存在循环依赖。项目使用
nodes.length和edges.length作为useEffect的依赖来决定是否保存历史。这个策略存在什么问题?请分析一个场景:拖动节点改变了位置但长度不变,此时是否应该保存历史?提出你的优化方案。当前的撤销/重做使用完整快照模式(存储整个 nodes/edges 数组)。当工作流包含 100 个节点和 200 条边时,每次保存会产生大量数据。请设计一个差异(diff)模式的实现方案,只记录每次操作的变化量。
思考题
项目中的 Agent 节点通过
data.agentId关联到后端 Agent。如果 Agent 被删除,工作流中引用该 Agent 的节点应该如何处理?请提出至少两种策略(静默处理、标记无效、自动修复)并比较优缺点。@xyflow/react支持自定义 Edge 类型。如果要在连线上显示数据流转的实时状态(如"执行中"、"成功"、"失败"),你会如何设计和实现?请描述数据结构、组件设计和与 WebSocket 的集成方式。当前工作流编辑器使用 React Flow 的单画布模式。如果需要支持"子工作流"(一个节点代表嵌套的子工作流,点击可下钻),你会如何设计数据结构和 UI 交互?请画出页面层级关系图和数据结构示例。
延伸阅读
- @xyflow/react 官方文档:https://reactflow.dev/ - 完整的 API 参考和教程
- React Flow 示例:https://reactflow.dev/examples - 交互式示例,涵盖各种场景
- React Flow 架构指南:https://reactflow.dev/learn/customization/custom-nodes - 自定义节点/边的最佳实践
- HTML5 Drag and Drop API:https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API - 浏览器原生拖拽规范
- Graphviz 布局算法:https://graphviz.org/ - 自动图布局算法参考(适合复杂工作流的自动排列)
