# 第12章 工作流编辑器开发

## 作者

**谭策** — 独立开发者 | AIOps 领域探索者

- 🌐 项目官网：[ITOpsAgentinfo](https://www.zjzwfw.cloud/ITOpsAgentinfo)
- 📝 博客：[zjzwfw.cloud](https://www.zjzwfw.cloud/)
- 📧 邮箱：<huawei_network@foxmail.com>
- 💬 微信公众号：**IT Online**

<p align="left">
  <img src="../../frontend/public/wechaterweima.png" width="200" alt="IT Online 微信公众号">
</p>

## 许可证

[MPL-2.0](../../LICENSE) © 谭策

## 本章导读

在 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" />` |

**数据结构示意**：

```typescript
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 项目依赖

```json
{
  "@xyflow/react": "^12.x",
  "@xyflow/react/dist/style.css"
}
```

`WorkflowEditor.tsx` 的导入清单：

```typescript
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 信息：

```typescript
// 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 的节点类型映射表中：

```typescript
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[]` | 验证错误列表 |

```typescript
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 中：

```typescript
export default function WorkflowEditor() {
  return (
    <ReactFlowProvider>
      <WorkflowEditorContent />
    </ReactFlowProvider>
  );
}
```

**为什么需要 Provider**：

```
┌─────────────────────────────────────┐
│         ReactFlowProvider            │
│                                      │
│  提供:                                │
│  ├── ReactFlow 实例上下文              │
│  ├── 坐标转换函数                      │
│  ├── 节点/边状态共享                   │
│  └── 视口状态 (缩放/平移)              │
│                                      │
│  ┌───────────────────────────────┐  │
│  │      WorkflowEditorContent     │  │
│  │                                │  │
│  │  可以使用:                      │  │
│  │  ├── useReactFlow()            │  │
│  │  ├── useStore()                │  │
│  │  └── MiniMap / Controls 等     │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘
```

## 12.4 数据加载与保存

### 12.4.1 加载已有工作流

使用 React Query 从后端获取工作流数据：

```typescript
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 中：

```sql
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
);
```

**后端路由处理**：

```typescript
// 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 保存到后端：

```typescript
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：

```typescript
// 拖拽开始：写入 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>
```

**拖拽释放处理**：

```typescript
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` 回调处理节点间的连线创建：

```typescript
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 按钮 | 放大/缩小/适应视图 |

```typescript
<ReactFlow
  fitView           // 初始时自动适应所有节点在视口内
  // ...
>
  <Controls />      // 内置缩放控件 (+/-/fit/lock)
</ReactFlow>
```

### 12.6.2 MiniMap 缩略图

MiniMap 提供画布的缩略预览，支持点击导航：

```typescript
<MiniMap
  nodeColor={(node) => {
    return node.selected ? '#3b82f6' : '#475569';
  }}
  className="border border-border rounded-lg overflow-hidden"
/>
```

**MiniMap 布局**：

```
┌─────────────────────────────┐
│                             │
│        主画布区域            │
│                             │
│    ┌─────────┐              │
│    │ MiniMap │              │  ← 右下角
│    │ 缩略图   │              │
│    │ ▓▓░░▓▓  │              │  ← 节点分布
│    │  ░▓▓░   │              │  ← 蓝色=选中节点
│    └─────────┘              │
└─────────────────────────────┘
```

### 12.6.3 背景网格

```typescript
<Background gap={16} size={1} />
```

| 参数 | 值 | 说明 |
|------|-----|------|
| `gap` | 16 | 网格间距（像素） |
| `size` | 1 | 点的大小（1=点阵，更大=网格线） |

## 12.7 撤销/重做历史

### 12.7.1 历史存储

使用数组存储每次操作后的节点和边快照：

```typescript
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` 变化会导致每次拖动都保存历史。使用防抖优化：

```typescript
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 撤销和重做实现

```typescript
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 节点选择

```typescript
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
  setSelectedNode(node);
}, []);

const onPaneClick = useCallback(() => {
  setSelectedNode(null);  // 点击空白区域取消选择
}, []);
```

### 12.8.2 节点配置面板

选中节点后，右侧面板展示可编辑的配置项：

```tsx
{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 复制与删除节点

```typescript
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 验证规则

保存前对工作流进行结构验证，确保可以正常执行：

```typescript
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 验证错误展示

```tsx
{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 文件

```typescript
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]);
```

**导出文件示例**：

```json
{
  "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 文件导入

```typescript
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]);
```

**触发导入的隐藏文件输入**：

```tsx
<input
  type="file"
  ref={fileInputRef}
  onChange={handleImport}
  accept=".json"
  className="hidden"
/>
<button onClick={() => fileInputRef.current?.click()}>
  <Upload /> 导入
</button>
```

## 12.11 工作流执行

### 12.11.1 执行入口

工作流编辑器本身不执行工作流，而是导航到任务页面触发执行：

```typescript
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 清空画布

```typescript
const handleClear = useCallback(() => {
  if (confirm('确定要清空画布吗？此操作不可撤销。')) {
    setNodes([]);
    setEdges([]);
    setSelectedNode(null);
  }
}, [setNodes, setEdges]);
```

使用 `Panel` 组件在画布左下角放置清空按钮：

```tsx
<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 操作提示面板

在画布顶部中央展示实时统计信息：

```tsx
<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 配置

将所有组件组合在一起的最终配置：

```typescript
<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 推荐使用函数式更新，避免闭包问题：

```typescript
// 正确：函数式更新
setNodes((nds) => nds.concat(newNode));
setEdges((eds) => eds.filter(e => e.source !== nodeId));

// 错误：直接使用当前状态
setNodes([...nodes, newNode]);  // 可能使用过时的 nodes
```

### 12.14.3 性能优化建议

| 场景 | 优化策略 |
|------|---------|
| 大量节点 | 使用 `useCallback` 缓存事件处理器 |
| 频繁更新 | 使用 `useMemo` 缓存派生数据 |
| 自定义节点 | 使用 `React.memo` 避免不必要的重渲染 |
| 历史保存 | 使用防抖，只监听关键状态变化 |

## 本章小结

本章系统讲解了使用 `@xyflow/react` 构建工作流编辑器的完整流程，核心要点包括：

1. **自定义节点**：通过 Handle 组件定义输入/输出连接点，通过 nodeTypes 映射注册自定义组件
2. **拖拽交互**：使用 HTML5 Drag & Drop API 配合 `screenToFlowPosition` 实现侧边栏拖拽创建
3. **连线系统**：通过 `onConnect` 和 `addEdge` 实现带箭头和动画的可视化连线
4. **画布操作**：内置 Controls、MiniMap、Background 组件提供缩放/导航/背景功能
5. **撤销/重做**：使用历史数组 + 游标模式，配合防抖优化减少无效记录
6. **工作流验证**：保存前检查名称、节点数量、孤立节点，确保工作流可执行
7. **导入/导出**：通过 Blob API 实现 JSON 文件下载，FileReader 实现文件上传解析
8. **后端集成**：JSON 序列化存储到 SQLite，查询时反序列化为前端可用格式

工作流编辑器是可视化编程的典型应用，React Flow 提供了强大的基础设施，业务逻辑围绕节点数据模型展开。

## 本章练习

### 基础练习

1. 阅读 `WorkflowEditor.tsx` 中的 `AgentNode` 组件，解释 `Handle` 组件的 `type` 和 `position` 属性的作用。如果把 `Position.Left` 改为 `Position.Top`，节点的外观和行为会有什么变化？

2. 在 `onDrop` 回调中，`reactFlowInstance.screenToFlowPosition` 的作用是什么？如果不进行坐标转换，直接将 `event.clientX/Y` 赋值给节点位置，会出现什么问题？

3. 分析 `saveHistory` 函数中的 `history.slice(0, historyIndex + 1)` 操作。为什么需要在保存新状态前删除游标之后的历史？请举例说明。

### 进阶练习

4. 当前的 `validateWorkflow` 函数只做了简化版的环检测（注释为 TODO）。请实现一个完整的环检测算法（可以使用深度优先搜索或拓扑排序），检测工作流中是否存在循环依赖。

5. 项目使用 `nodes.length` 和 `edges.length` 作为 `useEffect` 的依赖来决定是否保存历史。这个策略存在什么问题？请分析一个场景：拖动节点改变了位置但长度不变，此时是否应该保存历史？提出你的优化方案。

6. 当前的撤销/重做使用完整快照模式（存储整个 nodes/edges 数组）。当工作流包含 100 个节点和 200 条边时，每次保存会产生大量数据。请设计一个差异（diff）模式的实现方案，只记录每次操作的变化量。

### 思考题

7. 项目中的 Agent 节点通过 `data.agentId` 关联到后端 Agent。如果 Agent 被删除，工作流中引用该 Agent 的节点应该如何处理？请提出至少两种策略（静默处理、标记无效、自动修复）并比较优缺点。

8. `@xyflow/react` 支持自定义 Edge 类型。如果要在连线上显示数据流转的实时状态（如"执行中"、"成功"、"失败"），你会如何设计和实现？请描述数据结构、组件设计和与 WebSocket 的集成方式。

9. 当前工作流编辑器使用 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/ - 自动图布局算法参考（适合复杂工作流的自动排列）
