新增任务模板界面

This commit is contained in:
18086616529
2026-06-03 10:14:40 +08:00
parent 7382e85598
commit 5716c4e8ff
18 changed files with 3590 additions and 0 deletions
@@ -0,0 +1,638 @@
# 任务编排界面 · Workflow 编排设计说明
> 设计日期:2026-06-01
> 范围:任务编排页 → **Workflow 编排** Tab
> 状态:MVP 已实现,本文档与当前控件库参数定义同步
---
## 1. 设计目标
为调度人员提供可视化的任务流程编排能力:通过拖拽控件、连线、配置参数,完成 AGV 调度流程的设计,无需编写脚本。
设计原则:
- **所见即所得**:画布即流程,节点即步骤
- **分组清晰**:控件按业务语义分类,左侧一行简介即可理解用途
- **分支可辨**:涉及「成功/失败」「正常/超时」的节点,出口必须在视觉上可区分
- **参数克制**:只展示当前步骤需要的字段,随选项变化动态显隐
---
## 2. 页面结构
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 顶栏:流程名称 · 校验 · [删除选中][加载示例][导入][导出][清除画布] │
├────────────┬─────────────────────────────────────────┬───────────────────┤
│ 流程库 │ 流程画布 │ 参数配置 │
│ 控件库 │ │ │
│ 搜索 │ 节点 · 连线 · 缩放 · 平移 │ 选中节点的属性 │
│ 分组列表 │ │ │
└────────────┴─────────────────────────────────────────┴───────────────────┘
```
| 区域 | 宽度建议 | 职责 |
|------|----------|------|
| 流程库 | 约 220px(与控件库同列) | 管理多个已保存流程,新建 / 保存 / 切换 / 复制 / 删除 |
| 控件库 | 约 220px(流程库下方) | 浏览、搜索、拖拽控件到画布 |
| 流程画布 | 自适应 | 编排主工作区 |
| 参数配置 | 约 280px | 编辑当前选中节点或连线的属性 |
顶栏 **校验状态** 对流程完整性做即时提示(如缺少开始节点、分支未连接等),不打断编辑。
---
## 3. 控件库
### 3.1 分组与配色
| 分组 | 主色 | 控件 |
|------|------|------|
| 流程入口 | 绿色 | 开始、结束 |
| 调度动作 | 橙色 | 前往站点、执行动作、分配车辆 |
| 流程逻辑 | 紫色 | 条件分支、延时事件 |
| 事件与信号 | 蓝色 | 等待事件、通知事件 |
| 协议控制 | 青色 | 设备控制 |
### 3.2 控件卡片信息层级
每个控件卡片包含:
1. **名称**(主标题,一行)
2. **简介**(副标题,**仅一行**,超出省略)
不在控件库中展示长说明或技术协议细节;详细配置留给右侧参数面板。
### 3.3 控件简介一览
| 控件 | 类型编码 | 简介 |
|------|----------|------|
| 开始 | `start` | 流程起点(全局唯一) |
| 结束 | `end` | 流程终点 |
| 前往站点 | `gotoSite` | AGV 移动到目标站点 |
| 执行动作 | `executeAction` | 下发 VDA5050 单条动作指令 |
| 分配车辆 | `assignCar` | 为任务指定车辆或车型 |
| 条件分支 | `branch` | 条件成立与否走不同分支 |
| 延时事件 | `delay` | 等待指定秒数后继续 |
| 等待事件 | `waitEvent` | 等待事件触发,超时走另一路 |
| 通知事件 | `notify` | 调用接口推送 JSON 通知 |
| 设备控制 | `deviceControl` | Modbus 读等待或写输出 |
---
## 4. 画布节点
### 4.1 通用节点样式
- 白底圆角卡片,边框颜色与所属分组一致
- 左侧:一个输入连接点
- 右侧:一个输出连接点(结束节点无输出)
- 内容区:控件类型(小字)+ 用户自定义显示名(加粗)
选中态:蓝色外发光,与未选中节点明确区分。
### 4.2 双出口节点
**适用控件**:条件分支、等待事件
| 元素 | 规范 |
|------|------|
| 节点高度 | 高于普通节点,预留双出口空间 |
| 出口数量 | 右侧上下两个连接点 |
| 出口布局 | 标签与连接点同一行(flex 行布局),文字在左、Handle 在右,保证对齐 |
| 出口标签 | 节点内右侧展示文字标签 |
| 连线颜色 | 上方出口路径偏绿,下方出口路径偏红 |
| 连线标签 | 连线上标注出口含义 |
**出口标识(sourceHandle**
| 控件 | 上方出口 | 下方出口 |
|------|----------|----------|
| 条件分支 | `success`(成立) | `fail`(不成立) |
| 等待事件 | `triggered`(正常执行) | `timeout`(超时执行) |
---
## 5. 控件参数设计
### 5.1 前往站点
**用途**:指令 AGV 移动到指定站点。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 目标站点 | 文本 | 是 | 站点编号,如 S001 |
---
### 5.2 执行动作
**用途**:下发 VDA5050 协议中的单条 Action 指令(`actionId` 由运行时生成,不在编排界面配置)。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 动作类型 | 下拉 | 是 | 取货 / 放货 / 充电,默认「取货」 |
| 动作参数 | 多行文本 | — | JSON 数组,可选 |
**动作类型与导出值对照**
| 界面选项 | 导出值 `actionType` |
|----------|---------------------|
| 取货 | `pick` |
| 放货 | `drop` |
| 充电 | `startCharging` |
**动作参数示例**
```json
[{"key":"loadType","value":"pallet"}]
```
---
### 5.3 分配车辆
**用途**:为任务指定执行车辆或车辆类型。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 分配策略 | 单选 | 是 | 指定车辆 / 指定类型 |
| 车辆编号 | 文本 | 是* | 选择「指定车辆」时显示,如 C01 |
| 车辆类型 | 文本 | 是* | 选择「指定类型」时显示,如车型类名 |
\* 随分配策略切换,只展示对应字段。
**分配策略与导出值对照**
| 界面选项 | 导出值 `strategy` |
|----------|-------------------|
| 指定车辆 | `fixed` |
| 指定类型 | `byType` |
---
### 5.4 等待事件
**用途**:阻塞流程,直到指定事件发生或超时。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 事件名 | 文本 | 是 | 如 site.arrived |
| 超时(秒) | 数字 | — | 默认 60;填 0 表示不超时 |
**双出口语义**
| 出口位置 | 标签 | 含义 |
|----------|------|------|
| 上方(绿) | 正常执行 | 在超时前收到事件,沿此路径继续 |
| 下方(红) | 超时执行 | 超过设定秒数仍未收到事件,沿此路径处理 |
**设计建议用法**
- 正常执行 → 后续业务步骤(如送料完成通知)
- 超时执行 → 告警、重试、人工介入或结束流程
**校验提示**:设置了超时时间但未连接「超时执行」出口时,顶栏给出警告。
---
### 5.5 通知事件
**用途**:向外部系统推送通知,不阻塞主流程。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 接口地址 | 文本 | 是 | HTTP 接口 URL |
| 数据配置 | 多行文本 | 是 | JSON 格式请求体 |
| 消息 | 多行文本 | 否 | 运维界面可见的提示文案 |
| 级别 | 单选 | — | 信息 / 警告 / 错误,默认信息 |
**数据配置示例**
```json
{
"event": "delivery.complete",
"payload": {
"missionId": "M01",
"status": "ok"
}
}
```
---
### 5.6 条件分支
**用途**:根据条件表达式决定流程走向。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 条件表达式 | 多行文本 | 是 | 如 batterySoc > 0.2 |
**双出口语义**
| 出口位置 | 标签 | 含义 |
|----------|------|------|
| 上方(绿) | 成立 | 表达式结果为 true |
| 下方(红) | 不成立 | 表达式结果为 false |
---
### 5.7 延时事件
**用途**:阻塞流程指定时长后继续执行,单出口。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 延时(秒) | 数字 | 是 | 默认 5 |
---
### 5.8 设备控制
**用途**:通过 Modbus 协议读取设备信号(等待触发)或向设备写入输出值。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 读写模式 | 下拉 | 是 | 读信号(接收)/ 写信号(输出),默认读 |
| 协议类型 | 下拉 | 是 | Modbus TCP / Modbus RTU |
| 连接地址 | 文本 | 是 | TCP 如 `10.0.2.30:502`RTU 如 `COM3,9600,8,N,1` |
| 寄存器地址 | 数字 | 是 | 如 40001 或 0(视驱动约定) |
| 数据类型 | 下拉 | 是 | 布尔 / Int16 / UInt16 / Int32 / Float32 |
**读模式额外字段**(选择「读信号」时显示):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 触发条件 | 下拉 | 是 | 等于 / 不等于 / 大于 / 大于等于 / 小于 / 小于等于 / 值发生变化 |
| 轮询间隔 (ms) | 数字 | — | 默认 500 |
**写模式额外字段**(选择「写信号」时显示):
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 写入值 | 文本 | 是 | 如 `1` / `true` / `100` |
| 写入模式 | 下拉 | — | 持续写入 / 脉冲(写入后复位),默认持续写入 |
| 脉冲宽度 (ms) | 数字 | — | 写入模式为脉冲时有效,默认 500 |
| 写入后回读校验 | 开关 | — | 默认开启 |
---
## 6. 参数面板交互规范
| 规则 | 说明 |
|------|------|
| 标签置顶 | 字段名在输入框上方,必填项标红星 |
| 条件显隐 | 仅展示与当前选项相关的字段;切换选项时清理已隐藏字段的值 |
| 布尔控件 | 使用开关,显示「是 / 否」,禁止白字白底不可见 |
| 空状态 | 未选中节点时提示「选中画布上的节点以编辑参数」 |
| 显示名称 | 所有节点均可编辑显示名,独立于控件类型 |
| 无参节点 | 开始、结束节点无可配置业务参数,仅可改显示名 |
| 节点删除 | 参数面板底部提供「删除节点」按钮,二次确认后删除 |
| 连线选中 | 点击画布上的连线,右侧面板切换为连线详情(起点、终点、出口标签) |
---
## 6.1 删除节点与连线
### 选中方式
| 对象 | 操作 |
|------|------|
| 节点 | 点击节点,右侧显示参数面板 |
| 连线 | 点击连线,右侧显示连线详情;选中态连线加粗高亮 |
点击画布空白处取消选中。
### 删除方式
| 方式 | 节点 | 连线 | 说明 |
|------|------|------|------|
| 快捷键 | ✓ | ✓ | 选中后按 **Delete****Backspace**,立即删除,无需确认 |
| 顶栏「删除选中」 | ✓ | ✓ | 删除节点需二次确认;仅删连线时直接删除 |
| 参数面板「删除节点」 | ✓ | — | 二次确认后删除,关联连线一并移除 |
| 连线面板「删除连线」 | — | ✓ | 直接删除,无需确认 |
**规则**
- 删除节点时,与该节点相连的所有连线自动移除
- 删除连线不影响节点本身
- 删除后右侧面板回到空状态;草稿自动保存
---
## 7. 顶栏操作与校验
### 7.1 顶栏操作
| 操作 | 行为 |
|------|------|
| 流程名称 | 可编辑,标识当前流程 |
| 删除选中 | 删除当前选中的节点或连线;无选中时按钮禁用 |
| 加载示例 | 将内置「A区送料示例」加入流程库并切换编辑(不覆盖其他已保存流程) |
| 导入 | 从 JSON 文件导入流程,追加到**当前会话**流程库(支持单流程与流程库合集,见 11.5) |
| 导出 | 下载当前会话流程库内**全部流程**的合集 JSON(见 11.4 节) |
| 清除画布 | 二次确认后清空**当前流程**画布上的节点与连线 |
流程库仅保存在**当前浏览器会话内存**中(刷新页面会恢复为内置示例,除非通过「导入」重新载入)。编辑过程中会自动同步到内存流程库(约 400ms 防抖),便于切换多个流程;**持久化请使用顶栏「导出」下载 JSON 文件**。
### 7.1.1 流程库(多流程管理)
左侧面板顶部为 **流程库** 列表:
| 操作 | 说明 |
|------|------|
| + 新建 | 创建空白流程并切换为当前编辑对象 |
| 保存 | 将当前流程写入内存流程库(存在校验错误时阻止保存并提示) |
| 点击列表项 | 切换到该流程(切换前自动同步当前编辑到内存) |
| 复制 | 复制当前选中流程为「xxx(副本)」 |
| 删除 | 从库中移除该流程(至少保留 1 个) |
每个列表项显示流程名称与节点数量;当前编辑项高亮。
### 7.2 流程校验
顶栏实时展示校验结果:全部通过时显示绿色「校验通过」;存在问题则列出错误与警告,**不阻断编辑**。
| 级别 | 条件 |
|------|------|
| 错误 | 缺少「开始」节点 |
| 错误 | 存在多个「开始」节点 |
| 错误 | 「开始」节点未连接下游 |
| 警告 | 未添加「结束」节点 |
| 警告 | 「结束」节点没有上游连线 |
| 警告 | 中间节点处于孤立状态(无上游连线) |
| 警告 | 「等待事件」未连接「正常执行」出口 |
| 警告 | 「等待事件」设置了超时但未连接「超时执行」出口 |
保存时:若存在错误则阻止并提示;若仅有警告则允许保存并弹出警告条数。
---
## 8. 示例流程(A区送料)
用于演示双出口与通知节点的典型编排:
```
开始
→ 分配车辆(指定车辆 C01
→ 前往站点(S001
→ 执行动作(取货)
→ 等待到达(事件 site.arrived,超时 120 秒)
├─ 正常执行 → 送料完成通知 → 结束
└─ 超时执行 → 等待超时告警 → 结束
```
---
## 9. 视觉与主题注意事项
任务编排编辑器内部面板(控件库、画布区、参数配置)采用 **白底 + 深色文字**,与平台全局深色/紫色主题隔离,避免:
- 表单文字不可见
- 开关关闭态「否」与背景融为一体
- 下拉、输入框在浅色卡片上显示异常
节点、连线、标签颜色以 **绿 = 正向/成功/正常**、**红 = 异常/超时/不成立** 为语义约定,全页保持一致。
---
## 10. 后续扩展方向(设计预留)
- 流程版本管理与发布
- 与 Mission 实例管理的联动(编排 → 实例化)
- 控件库随插件任务类型扩展
- 画布缩略图、对齐辅助线、撤销重做
- 运行时执行态在画布上的高亮反馈
---
## 11. 导出的 JSON 数据示例
- **导出**:下载流程库合集 `workflow-catalog.json`,内含当前会话全部流程(结构见 11.4)。
### 11.1 数据结构说明
| 顶层字段 | 含义 |
|----------|------|
| `id` | 流程唯一标识 |
| `name` | 流程名称 |
| `version` | 流程版本号 |
| `viewport` | 画布视口位置与缩放 |
| `nodes` | 节点列表 |
| `edges` | 连线列表 |
**节点(nodes** 每条包含:
| 字段 | 含义 |
|------|------|
| `id` | 节点 ID |
| `type` | 控件类型(如 start、assignCar、waitEvent |
| `label` | 显示名称 |
| `position` | 画布坐标 |
| `params` | 该节点的参数配置 |
| `nextId` | 下一节点 ID(单出口 / 条件成立 / 正常执行);结束节点为 `null` |
| `failNextId` | 条件不成立时的下一节点 ID(仅条件分支) |
| `timeoutNextId` | 超时执行时的下一节点 ID(仅等待事件) |
**连线(edges** 每条包含:
| 字段 | 含义 |
|------|------|
| `id` | 连线 ID |
| `source` | 起始节点 ID |
| `target` | 目标节点 ID |
| `sourceHandle` | 出口标识(双出口节点必填,如正常执行、超时执行) |
| `label` | 连线标签(可选) |
### 11.2 完整示例(A区送料)
对应第 8 节示例流程的导出内容:
```json
{
"id": "wf-demo-001",
"name": "A区送料示例",
"version": 1,
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"nodes": [
{
"id": "n1",
"type": "start",
"label": "开始",
"position": { "x": 60, "y": 200 },
"params": {},
"nextId": "n2"
},
{
"id": "n2",
"type": "assignCar",
"label": "分配车辆",
"position": { "x": 240, "y": 180 },
"params": {
"strategy": "fixed",
"carId": "C01"
},
"nextId": "n3"
},
{
"id": "n3",
"type": "gotoSite",
"label": "前往入库点",
"position": { "x": 460, "y": 180 },
"params": {
"siteId": "S001"
},
"nextId": "n4"
},
{
"id": "n4",
"type": "executeAction",
"label": "取货",
"position": { "x": 680, "y": 180 },
"params": {
"actionType": "pick",
"actionParameters": "[{\"key\":\"loadType\",\"value\":\"pallet\"}]"
},
"nextId": "n5"
},
{
"id": "n5",
"type": "waitEvent",
"label": "等待到达",
"position": { "x": 900, "y": 180 },
"params": {
"eventName": "site.arrived",
"timeoutSec": 120
},
"nextId": "n6",
"timeoutNextId": "n8"
},
{
"id": "n6",
"type": "notify",
"label": "送料完成通知",
"position": { "x": 1120, "y": 180 },
"params": {
"apiUrl": "http://127.0.0.1:8080/api/hook/notify",
"dataConfig": "{\"event\":\"delivery.complete\",\"message\":\"送料完成\"}",
"message": "送料完成",
"level": "info"
},
"nextId": "n7"
},
{
"id": "n7",
"type": "end",
"label": "结束",
"position": { "x": 1340, "y": 200 },
"params": {},
"nextId": null
},
{
"id": "n8",
"type": "notify",
"label": "等待超时告警",
"position": { "x": 1120, "y": 320 },
"params": {
"apiUrl": "http://127.0.0.1:8080/api/hook/notify",
"dataConfig": "{\"event\":\"wait.timeout\",\"message\":\"等待到达超时\"}",
"message": "等待到达超时",
"level": "warn"
},
"nextId": "n7"
}
],
"edges": [
{ "id": "e1", "source": "n1", "target": "n2" },
{ "id": "e2", "source": "n2", "target": "n3" },
{ "id": "e3", "source": "n3", "target": "n4" },
{ "id": "e4", "source": "n4", "target": "n5" },
{
"id": "e5",
"source": "n5",
"target": "n6",
"sourceHandle": "triggered",
"label": "正常执行"
},
{
"id": "e5t",
"source": "n5",
"target": "n8",
"sourceHandle": "timeout",
"label": "超时执行"
},
{ "id": "e6", "source": "n6", "target": "n7" },
{ "id": "e8", "source": "n8", "target": "n7" }
]
}
```
### 11.3 阅读要点
- 每个节点导出时会根据连线自动写入 **`nextId`**,执行引擎可直接按节点跳转,无需再解析 `edges`
- **等待事件**`nextId` = 正常执行下游,`timeoutNextId` = 超时执行下游。
- **条件分支**`nextId` = 条件成立下游,`failNextId` = 条件不成立下游。
- **结束节点**`nextId``null`
- `edges` 仍保留,便于还原画布布局;与 `nextId` 字段互为补充。
- **等待事件** 的两条出线通过 `sourceHandle` 区分:`triggered` 表示正常执行,`timeout` 表示超时执行。
- **条件分支** 的双出口同理:`success` 表示条件成立,`fail` 表示条件不成立。
- 节点的 `params` 只保存当前生效的字段;例如「分配车辆」在指定车辆策略下仅含 `strategy``carId`,不会出现无关的空字段。
- **执行动作** 的 `actionType` 存 VDA5050 枚举值(`pick` / `drop` / `startCharging`),界面下拉选项与之对应。
- `dataConfig``actionParameters` 等字段在 JSON 中以 **字符串** 形式存储 JSON 内容,导入时需按文本解析。
### 11.4 流程库合集导出(导出)
点击 **「导出」** 后,下载 `workflow-catalog.json`,包含流程库内每一个流程的完整定义(均已附带 `nextId`)。
```json
{
"catalogVersion": 3,
"exportedAt": "2026-06-01T12:00:00.000Z",
"workflows": [
{
"id": "wf-demo-001",
"name": "A区送料示例",
"version": 1,
"viewport": { "x": 0, "y": 0, "zoom": 1 },
"nodes": [ "..." ],
"edges": [ "..." ]
},
{
"id": "wf-1730000000000-1",
"name": "未命名流程",
"version": 1,
"nodes": [],
"edges": []
}
]
}
```
| 字段 | 含义 |
|------|------|
| `catalogVersion` | 流程库 schema 版本,与 localStorage 中一致 |
| `exportedAt` | 导出时间(ISO 8601 |
| `workflows` | 流程数组,每项结构与单流程导出相同 |
执行引擎或后端可遍历 `workflows` 加载多套模板;单流程导入可使用单流程 JSON 文件(结构见 11.2)。
### 11.5 导入 JSON
点击顶栏 **「导入」**,选择本地 `.json` 文件。系统自动识别以下格式并**追加**到流程库(不覆盖已有流程):
| 格式 | 识别条件 | 示例来源 |
|------|----------|----------|
| 单流程 | 顶层含 `nodes``edges` | 单流程 JSON 文件 |
| 流程库合集 | 顶层含 `workflows` 数组 | 「导出」生成的 `workflow-catalog.json` |
| 旧版 draft | 顶层含 `workflow` 对象 | 早期单流程草稿格式 |
**导入规则**
- 导入前会先保存当前正在编辑的流程
- 流程 `id` 与库内已有重复时,自动分配新 ID
- 节点上的 `nextId` / `failNextId` / `timeoutNextId` 会被忽略,画布连线以 `edges` 为准
- 导入完成后切换到**最后一个**导入的流程进行编辑
- 参数会按当前控件库 schema 规范化(补默认值、剔除无效字段)
- 存在校验警告时不阻断导入,顶栏会提示警告条数
@@ -0,0 +1,100 @@
<template>
<div class="edge-inspector">
<div class="inspector-head">
<div class="inspector-title">连线</div>
<div class="inspector-id">ID: {{ edge.id }}</div>
</div>
<dl class="edge-meta">
<div class="edge-meta-row">
<dt>起点</dt>
<dd>{{ edge.sourceLabel }}</dd>
</div>
<div class="edge-meta-row">
<dt>终点</dt>
<dd>{{ edge.targetLabel }}</dd>
</div>
<div v-if="edge.label" class="edge-meta-row">
<dt>出口</dt>
<dd>{{ edge.label }}</dd>
</div>
</dl>
<p class="edge-hint muted"> Delete / Backspace 可快速删除或点击下方按钮</p>
<el-button type="danger" plain size="small" class="edge-delete-btn" @click="emit('delete')">
删除连线
</el-button>
</div>
</template>
<script setup lang="ts">
export interface EdgeInspectorModel {
id: string
sourceLabel: string
targetLabel: string
label?: string | null
}
defineProps<{
edge: EdgeInspectorModel
}>()
const emit = defineEmits<{
delete: []
}>()
</script>
<style scoped>
.edge-inspector {
height: 100%;
overflow: auto;
padding: 12px;
box-sizing: border-box;
}
.inspector-head {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.inspector-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.inspector-id {
font-size: 11px;
color: #909399;
margin-top: 4px;
}
.edge-meta {
margin: 0 0 12px;
}
.edge-meta-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
}
.edge-meta-row dt {
flex-shrink: 0;
width: 36px;
color: #909399;
}
.edge-meta-row dd {
margin: 0;
color: #303133;
font-weight: 500;
}
.edge-hint {
font-size: 12px;
line-height: 1.5;
margin: 0 0 12px;
}
.muted {
color: #909399;
}
.edge-delete-btn {
width: 100%;
}
</style>
@@ -0,0 +1,237 @@
<template>
<div class="node-inspector">
<template v-if="node">
<div class="inspector-head">
<div class="inspector-title">{{ nodeDef?.label ?? node.type }}</div>
<div class="inspector-id">ID: {{ node.id }}</div>
</div>
<el-form label-position="top" size="small" class="inspector-form">
<el-form-item label="显示名称">
<el-input v-model="localLabel" @change="emitLabel" />
</el-form-item>
<template v-for="field in visibleFields" :key="field.key">
<el-form-item :label="field.label" :required="field.required">
<el-input
v-if="field.type === 'string'"
:model-value="String(localParams[field.key] ?? '')"
:placeholder="field.placeholder"
@update:model-value="(v) => setParam(field.key, v)"
/>
<el-input-number
v-else-if="field.type === 'number'"
:model-value="Number(localParams[field.key] ?? 0)"
controls-position="right"
style="width: 100%"
@update:model-value="(v) => setParam(field.key, v ?? 0)"
/>
<el-switch
v-else-if="field.type === 'boolean'"
class="inspector-switch"
:model-value="toBool(localParams[field.key])"
active-text=""
inactive-text=""
inline-prompt
@update:model-value="(v) => setParam(field.key, v)"
/>
<el-select
v-else-if="field.type === 'select'"
:model-value="localParams[field.key]"
style="width: 100%"
placement="bottom-start"
teleported
fit-input-width
:popper-options="selectPopperOptions"
@update:model-value="(v) => setParam(field.key, v)"
>
<el-option
v-for="opt in field.options ?? []"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-input
v-else-if="field.type === 'textarea'"
:model-value="String(localParams[field.key] ?? '')"
type="textarea"
:rows="3"
:placeholder="field.placeholder"
@update:model-value="(v) => setParam(field.key, v)"
/>
</el-form-item>
</template>
<el-empty v-if="visibleFields.length === 0" description="此节点无可配置参数" :image-size="48" />
</el-form>
<div class="inspector-actions">
<el-button type="danger" plain size="small" class="inspector-delete-btn" @click="emit('delete')">
删除节点
</el-button>
</div>
</template>
<el-empty v-else description="选中画布上的节点以编辑参数" :image-size="64" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ParamFieldDef, WorkflowNodeDef } from '@/types/workflow'
import { NODE_CATALOG_MAP } from '@/workflow/nodeCatalog'
const props = defineProps<{
node: WorkflowNodeDef | null
}>()
const emit = defineEmits<{
update: [patch: Partial<WorkflowNodeDef>]
delete: []
}>()
const nodeDef = ref(NODE_CATALOG_MAP.get(props.node?.type ?? ''))
const localLabel = ref('')
type ParamValue = string | number | boolean
const localParams = ref<Record<string, ParamValue>>({})
const visibleFields = computed<ParamFieldDef[]>(() => {
const params = nodeDef.value?.params ?? []
return params.filter((f) => {
if (!f.when) return true
return localParams.value[f.when.key] === f.when.equals
})
})
/** 右侧面板较窄,禁止 Popper 横向翻转到左侧 */
const selectPopperOptions = {
modifiers: [
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] }
},
{
name: 'preventOverflow',
options: { boundary: 'viewport', padding: 8 }
}
]
}
watch(
() => props.node,
(n) => {
nodeDef.value = n ? NODE_CATALOG_MAP.get(n.type) : undefined
localLabel.value = n?.label ?? ''
localParams.value = n
? Object.fromEntries(
Object.entries(n.params).map(([k, v]) => [k, v as ParamValue])
)
: {}
},
{ immediate: true }
)
function toBool(value: unknown): boolean {
if (typeof value === 'boolean') return value
if (value === 'true' || value === 1 || value === '1') return true
return false
}
function isFieldVisible(field: ParamFieldDef): boolean {
if (!field.when) return true
return localParams.value[field.when.key] === field.when.equals
}
function pruneHiddenParams() {
const fields = nodeDef.value?.params ?? []
for (const field of fields) {
if (!isFieldVisible(field)) delete localParams.value[field.key]
}
}
function setParam(key: string, value: ParamValue) {
localParams.value[key] = value
pruneHiddenParams()
emitParams()
}
function emitLabel() {
if (!props.node) return
emit('update', { label: localLabel.value })
}
function emitParams() {
if (!props.node) return
emit('update', { params: { ...localParams.value } })
}
</script>
<style scoped>
.node-inspector {
height: 100%;
overflow: auto;
padding: 12px;
box-sizing: border-box;
}
.inspector-head {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.inspector-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.inspector-id {
font-size: 11px;
color: #909399;
margin-top: 4px;
}
.inspector-form :deep(.el-form-item__label) {
font-weight: 500;
color: #606266 !important;
}
.inspector-form :deep(.el-input__inner),
.inspector-form :deep(.el-textarea__inner) {
color: #303133 !important;
}
.inspector-form :deep(.el-input-number .el-input__inner) {
color: #303133 !important;
}
.inspector-form :deep(.inspector-switch) {
--el-switch-on-color: #7c3aed;
--el-switch-off-color: #dcdfe6;
}
.inspector-form :deep(.inspector-switch .el-switch__core) {
border-color: #dcdfe6;
background: #dcdfe6;
}
.inspector-form :deep(.inspector-switch.is-checked .el-switch__core) {
border-color: #7c3aed;
background: #7c3aed;
}
.inspector-form :deep(.inspector-switch .el-switch__inner) {
color: #606266 !important;
}
.inspector-form :deep(.inspector-switch.is-checked .el-switch__inner) {
color: #fff !important;
}
.inspector-form :deep(.el-select-dropdown__item) {
white-space: normal;
line-height: 1.4;
height: auto;
min-height: 34px;
padding-top: 6px;
padding-bottom: 6px;
}
.inspector-actions {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #ebeef5;
}
.inspector-delete-btn {
width: 100%;
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="node-palette">
<el-input v-model="search" size="small" clearable placeholder="搜索控件" class="palette-search">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div v-for="group in grouped" :key="group.category" class="palette-group">
<div class="palette-group-title">
<span class="palette-group-dot" :style="{ background: group.color }" />
{{ group.category }}
</div>
<div
v-for="item in group.items"
:key="item.type"
class="palette-item"
draggable="true"
@dragstart="onDragStart($event, item.type)"
>
<span class="palette-dot" :style="{ background: item.color }" />
<div class="palette-item-text">
<div class="palette-item-label">{{ item.label }}</div>
<div v-if="item.description" class="palette-item-desc">{{ item.description }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { CATEGORY_ORDER, NODE_CATALOG } from '@/workflow/nodeCatalog'
const search = ref('')
const CATEGORY_COLORS: Record<string, string> = {
流程入口: '#67c23a',
调度动作: '#e6a23c',
流程逻辑: '#9b59b6',
事件与信号: '#409eff',
协议控制: '#13c2c2'
}
const grouped = computed(() => {
const q = search.value.trim().toLowerCase()
const filtered = NODE_CATALOG.filter((n) => {
if (!q) return true
return (
n.label.toLowerCase().includes(q) ||
n.type.toLowerCase().includes(q) ||
n.category.toLowerCase().includes(q)
)
})
const byCategory = new Map<string, typeof NODE_CATALOG>()
for (const item of filtered) {
const list = byCategory.get(item.category) ?? []
list.push(item)
byCategory.set(item.category, list)
}
return CATEGORY_ORDER.filter((cat) => byCategory.has(cat)).map((category) => ({
category,
color: CATEGORY_COLORS[category] ?? '#909399',
items: byCategory.get(category) ?? []
}))
})
function onDragStart(e: DragEvent, type: string) {
if (!e.dataTransfer) return
e.dataTransfer.setData('application/workflow-node', type)
e.dataTransfer.effectAllowed = 'move'
}
</script>
<style scoped>
.node-palette {
height: 100%;
overflow: auto;
padding: 10px;
box-sizing: border-box;
}
.palette-search {
margin-bottom: 10px;
}
.palette-group {
margin-bottom: 14px;
}
.palette-group-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #606266;
margin-bottom: 8px;
}
.palette-group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.palette-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fafafa;
cursor: grab;
transition: border-color 0.15s, background 0.15s;
}
.palette-item:hover {
border-color: #c6e2ff;
background: #ecf5ff;
}
.palette-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 3px;
flex-shrink: 0;
}
.palette-item-label {
font-size: 13px;
font-weight: 500;
color: #303133;
}
.palette-item-desc {
font-size: 11px;
color: #909399;
margin-top: 2px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,696 @@
<template>
<div class="workflow-editor">
<div class="wf-toolbar">
<div class="wf-toolbar-left">
<el-input v-model="workflow.name" size="small" class="wf-name-input" placeholder="流程名称" />
<el-tag size="small" type="info">流程库 {{ library.workflows.length }} </el-tag>
</div>
<div class="wf-toolbar-center">
<el-tag v-for="(issue, i) in validationIssues" :key="i" size="small" :type="issue.level === 'error' ? 'danger' : 'warning'" class="wf-issue-tag">
{{ issue.message }}
</el-tag>
<el-tag v-if="validationIssues.length === 0" size="small" type="success">校验通过</el-tag>
</div>
<div class="wf-toolbar-right">
<el-button size="small" type="danger" plain :disabled="!hasDeletableSelection" @click="deleteSelection">
删除选中
</el-button>
<el-button size="small" @click="resetDemo">加载示例</el-button>
<el-button size="small" @click="onImportJson">导入</el-button>
<el-button size="small" type="primary" plain @click="onExportAll">导出</el-button>
<el-button size="small" type="danger" plain @click="onClearCanvas">清除画布</el-button>
</div>
</div>
<div class="wf-body">
<aside class="wf-panel wf-panel-left">
<WorkflowListPanel
:workflows="library.workflows"
:active-id="library.activeWorkflowId"
@select="switchToWorkflow"
@create="onCreateWorkflow"
@save="onSave"
@duplicate="onDuplicateWorkflow"
@delete="onDeleteWorkflow"
/>
<div class="wf-panel-title">控件库</div>
<NodePalette class="wf-palette-body" />
</aside>
<main class="wf-panel wf-panel-center">
<VueFlow
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:node-types="nodeTypes"
:default-viewport="workflow.viewport ?? { x: 0, y: 0, zoom: 0.85 }"
:delete-key-code="['Delete', 'Backspace']"
fit-view-on-init
@drop="onDrop"
@dragover="onDragOver"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@connect="onConnect"
@nodes-change="onNodesChange"
@edges-change="onEdgesChange"
>
<Background pattern-color="#e8eaed" :gap="16" />
<Controls position="bottom-right" />
</VueFlow>
</main>
<aside class="wf-panel wf-panel-right">
<div class="wf-panel-title">参数配置</div>
<EdgeInspector v-if="selectedEdge" :edge="selectedEdge" @delete="deleteSelectedEdge" />
<NodeInspector
v-else
:node="selectedNode"
@update="onInspectorUpdate"
@delete="confirmDeleteSelectedNode"
/>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
VueFlow,
type Connection,
type EdgeChange,
type GraphEdge,
type GraphNode,
type NodeChange,
type NodeTypesObject,
useVueFlow
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import NodePalette from './NodePalette.vue'
import NodeInspector from './NodeInspector.vue'
import EdgeInspector from './EdgeInspector.vue'
import WorkflowNode from './WorkflowNode.vue'
import WorkflowListPanel from './WorkflowListPanel.vue'
import type { WorkflowDefinition, WorkflowNodeDef } from '@/types/workflow'
import { NODE_CATALOG_MAP, defaultParamsForType } from '@/workflow/nodeCatalog'
import {
addWorkflow,
createEmptyWorkflow,
duplicateWorkflow,
createInitialWorkflowLibrary,
ensureDemoInLibrary,
getActiveWorkflow,
getWorkflowById,
mergeWorkflowsIntoLibrary,
removeWorkflow,
setActiveWorkflowId,
upsertWorkflow,
type StoredWorkflowLibrary
} from '@/workflow/workflowLibrary'
import {
importResultSummary,
parseWorkflowJsonText,
pickJsonFile,
prepareImportedWorkflow,
readFileAsText,
resolveWorkflowIdConflicts,
WorkflowImportError
} from '@/workflow/workflowImport'
import {
cloneWorkflowDefinition,
downloadWorkflowCatalogExport,
fromFlowState,
nextEdgeId,
nextNodeId,
normalizeNodeParams,
toFlowEdges,
toFlowNodes,
validateWorkflow
} from '@/workflow/workflowUtils'
const nodeTypes = { workflow: markRaw(WorkflowNode) } as NodeTypesObject
const library = ref<StoredWorkflowLibrary>(createInitialWorkflowLibrary())
const workflow = reactive<WorkflowDefinition>(cloneWorkflowDefinition(getActiveWorkflow(library.value)))
const flowNodes = ref(toFlowNodes(workflow.nodes))
const flowEdges = ref(toFlowEdges(workflow.edges))
const selectedNodeId = ref<string | null>(null)
const selectedEdgeId = ref<string | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
const { screenToFlowCoordinate, addEdges, getViewport, setViewport, removeNodes, removeEdges, getSelectedNodes, getSelectedEdges } =
useVueFlow()
const selectedNode = computed<WorkflowNodeDef | null>(() => {
if (!selectedNodeId.value) return null
return workflow.nodes.find((n) => n.id === selectedNodeId.value) ?? null
})
const selectedEdge = computed(() => {
if (!selectedEdgeId.value) return null
const edge = flowEdges.value.find((e) => e.id === selectedEdgeId.value)
if (!edge) return null
const sourceNode = workflow.nodes.find((n) => n.id === edge.source)
const targetNode = workflow.nodes.find((n) => n.id === edge.target)
return {
id: edge.id,
sourceLabel: sourceNode?.label ?? edge.source,
targetLabel: targetNode?.label ?? edge.target,
label: edge.label
}
})
const hasDeletableSelection = computed(() => {
return (
getSelectedNodes.value.length > 0 ||
getSelectedEdges.value.length > 0 ||
selectedNodeId.value !== null ||
selectedEdgeId.value !== null
)
})
const validationIssues = computed(() => validateWorkflow(workflow))
watch([flowNodes, flowEdges], () => {
syncWorkflowFromFlow()
scheduleSyncToLibrary()
}, { deep: true })
watch(() => workflow.name, scheduleSyncToLibrary)
function syncWorkflowFromFlow() {
const { nodes, edges } = fromFlowState(flowNodes.value, flowEdges.value)
workflow.nodes = nodes
workflow.edges = edges
}
function buildSnapshot(): WorkflowDefinition {
syncWorkflowFromFlow()
const vp = getViewport()
return {
...cloneWorkflowDefinition(workflow),
version: workflow.version,
viewport: { x: vp.x, y: vp.y, zoom: vp.zoom }
}
}
/** 将当前画布同步到内存流程库(不写浏览器存储) */
function syncCurrentToLibrary(): WorkflowDefinition {
const snapshot = buildSnapshot()
library.value = upsertWorkflow(library.value, snapshot)
return snapshot
}
function scheduleSyncToLibrary() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
syncCurrentToLibrary()
}, 400)
}
function applyWorkflowToCanvas(def: WorkflowDefinition) {
const copy = cloneWorkflowDefinition(def)
Object.assign(workflow, copy)
flowNodes.value = toFlowNodes(workflow.nodes)
flowEdges.value = toFlowEdges(workflow.edges)
selectedNodeId.value = null
selectedEdgeId.value = null
void nextTick(() => {
const vp = copy.viewport ?? { x: 0, y: 0, zoom: 0.85 }
setViewport(vp)
})
}
function switchToWorkflow(id: string) {
if (id === library.value.activeWorkflowId) return
syncCurrentToLibrary()
const next = setActiveWorkflowId(library.value, id)
if (!next) return
library.value = next
applyWorkflowToCanvas(getActiveWorkflow(library.value))
}
function onCreateWorkflow() {
syncCurrentToLibrary()
const created = createEmptyWorkflow()
library.value = addWorkflow(library.value, created)
applyWorkflowToCanvas(created)
ElMessage.success('已创建新流程')
}
async function onDeleteWorkflow(id: string) {
const target = library.value.workflows.find((w) => w.id === id)
if (!target) return
try {
await ElMessageBox.confirm(`确定从流程库删除「${target.name}」?`, '删除流程', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
if (id === library.value.activeWorkflowId) {
syncCurrentToLibrary()
}
const wasActive = id === library.value.activeWorkflowId
const next = removeWorkflow(library.value, id)
if (!next) {
ElMessage.warning('至少保留一个流程')
return
}
library.value = next
if (wasActive) {
applyWorkflowToCanvas(getActiveWorkflow(library.value))
}
ElMessage.success('流程已删除')
}
function onDuplicateWorkflow(id: string) {
syncCurrentToLibrary()
const next = duplicateWorkflow(library.value, id)
if (!next) return
library.value = next
applyWorkflowToCanvas(getActiveWorkflow(library.value))
ElMessage.success('已复制流程')
}
function onSave() {
const snapshot = syncCurrentToLibrary()
const errors = validateWorkflow(snapshot).filter((i) => i.level === 'error')
if (errors.length > 0) {
ElMessage.error(errors[0]!.message)
return
}
const warnings = validateWorkflow(snapshot).filter((i) => i.level === 'warning')
if (warnings.length > 0) {
ElMessage.warning(`已保存「${snapshot.name || '未命名流程'}」,但有 ${warnings.length} 条校验警告`)
} else {
ElMessage.success(`已保存「${snapshot.name || '未命名流程'}`)
}
}
function onExportAll() {
syncCurrentToLibrary()
const workflows = library.value.workflows
const errorWorkflows = workflows.filter((w) => validateWorkflow(w).some((i) => i.level === 'error'))
if (errorWorkflows.length > 0) {
ElMessage.error(`${errorWorkflows[0]!.name}」存在校验错误,请先修复后再导出`)
return
}
downloadWorkflowCatalogExport(workflows, 'workflow-catalog')
ElMessage.success(`已导出 ${workflows.length} 个流程到 workflow-catalog.json`)
}
async function onImportJson() {
const file = await pickJsonFile()
if (!file) return
let text: string
try {
text = await readFileAsText(file)
} catch {
ElMessage.error('读取文件失败')
return
}
let parsed
try {
parsed = parseWorkflowJsonText(text)
} catch (e) {
ElMessage.error(e instanceof WorkflowImportError ? e.message : '导入失败')
return
}
const summary = importResultSummary(parsed)
try {
await ElMessageBox.confirm(
`${summary}。将追加到流程库(流程 ID 与已有重复时自动分配新 ID),是否继续?`,
'导入 JSON',
{ type: 'info', confirmButtonText: '导入', cancelButtonText: '取消' }
)
} catch {
return
}
syncCurrentToLibrary()
const existingIds = new Set(library.value.workflows.map((w) => w.id))
const prepared = resolveWorkflowIdConflicts(
parsed.workflows.map((w) => prepareImportedWorkflow(w)),
existingIds
)
const merged = mergeWorkflowsIntoLibrary(library.value, prepared)
if (!merged) {
ElMessage.error('没有可导入的流程')
return
}
library.value = merged.library
const active = getWorkflowById(library.value, merged.lastImportedId)
if (active) {
applyWorkflowToCanvas(active)
}
const warnCount = prepared.reduce(
(sum, w) => sum + validateWorkflow(w).filter((i) => i.level === 'warning').length,
0
)
if (warnCount > 0) {
ElMessage.warning(`已导入 ${prepared.length} 个流程,共 ${warnCount} 条校验警告,请在顶栏查看`)
} else {
ElMessage.success(
prepared.length === 1
? `已导入流程「${prepared[0]!.name}`
: `已导入 ${prepared.length} 个流程`
)
}
}
function onDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
}
function onDrop(e: DragEvent) {
const type = e.dataTransfer?.getData('application/workflow-node')
if (!type) return
const def = NODE_CATALOG_MAP.get(type)
if (!def) return
if (def.singleton && workflow.nodes.some((n) => n.type === type)) {
ElMessage.warning(`${def.label}」节点在流程中只能有一个`)
return
}
const pos = screenToFlowCoordinate({ x: e.clientX, y: e.clientY })
const id = nextNodeId()
flowNodes.value = [
...flowNodes.value,
{
id,
type: 'workflow',
position: pos,
data: {
type,
label: def.label,
params: normalizeNodeParams(type, defaultParamsForType(type))
}
}
]
}
function onNodeClick({ node }: { node: { id: string } }) {
selectedNodeId.value = node.id
selectedEdgeId.value = null
}
function onEdgeClick({ edge }: { edge: { id: string } }) {
selectedEdgeId.value = edge.id
selectedNodeId.value = null
}
function onPaneClick() {
selectedNodeId.value = null
selectedEdgeId.value = null
}
function onConnect(conn: Connection) {
if (!conn.source || !conn.target) return
addEdges([
{
id: nextEdgeId(),
source: conn.source,
target: conn.target,
sourceHandle: conn.sourceHandle ?? undefined,
animated: true,
style: { stroke: '#409eff', strokeWidth: 2 }
}
])
}
function onNodesChange(changes: NodeChange[]) {
for (const change of changes) {
if (change.type === 'remove' && change.id === selectedNodeId.value) {
selectedNodeId.value = null
}
}
syncWorkflowFromFlow()
}
function onEdgesChange(changes: EdgeChange[]) {
for (const change of changes) {
if (change.type === 'remove' && change.id === selectedEdgeId.value) {
selectedEdgeId.value = null
}
}
syncWorkflowFromFlow()
}
function collectNodesToDelete(): GraphNode[] {
const selected = getSelectedNodes.value
if (selected.length > 0) return selected
if (!selectedNodeId.value) return []
const node = flowNodes.value.find((n) => n.id === selectedNodeId.value)
return node ? [node as GraphNode] : []
}
function collectEdgesToDelete(): GraphEdge[] {
const selected = getSelectedEdges.value
if (selected.length > 0) return selected
if (!selectedEdgeId.value) return []
const edge = flowEdges.value.find((e) => e.id === selectedEdgeId.value)
return edge ? [edge as GraphEdge] : []
}
async function confirmDeleteSelectedNode() {
const nodes = collectNodesToDelete()
if (nodes.length === 0) return
try {
await ElMessageBox.confirm(
nodes.length === 1
? `确定删除节点「${nodes[0]!.data.label}」?关联连线将一并移除。`
: `确定删除选中的 ${nodes.length} 个节点?关联连线将一并移除。`,
'删除节点',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
removeNodes(nodes)
selectedNodeId.value = null
ElMessage.success(nodes.length === 1 ? '节点已删除' : `已删除 ${nodes.length} 个节点`)
}
function deleteSelectedEdge() {
const edges = collectEdgesToDelete()
if (edges.length === 0) return
removeEdges(edges)
selectedEdgeId.value = null
ElMessage.success(edges.length === 1 ? '连线已删除' : `已删除 ${edges.length} 条连线`)
}
async function deleteSelection() {
const nodes = collectNodesToDelete()
let edgesToDelete = collectEdgesToDelete()
if (nodes.length === 0 && edgesToDelete.length === 0) {
ElMessage.info('请先选中要删除的节点或连线')
return
}
if (nodes.length > 0) {
try {
await ElMessageBox.confirm(
nodes.length === 1
? `确定删除节点「${nodes[0]!.data.label}」?关联连线将一并移除。`
: `确定删除选中的 ${nodes.length} 个节点?关联连线将一并移除。`,
'删除节点',
{
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
removeNodes(nodes)
selectedNodeId.value = null
const removedNodeIds = new Set(nodes.map((n) => n.id))
edgesToDelete = edgesToDelete.filter((e) => !removedNodeIds.has(e.source) && !removedNodeIds.has(e.target))
}
edgesToDelete = edgesToDelete.filter((e) => flowEdges.value.some((fe) => fe.id === e.id))
if (edgesToDelete.length > 0) {
removeEdges(edgesToDelete)
selectedEdgeId.value = null
}
const parts: string[] = []
if (nodes.length > 0) parts.push(`${nodes.length} 个节点`)
if (edgesToDelete.length > 0) parts.push(`${edgesToDelete.length} 条连线`)
ElMessage.success(`已删除 ${parts.join('、')}`)
}
function onInspectorUpdate(patch: Partial<WorkflowNodeDef>) {
if (!selectedNodeId.value) return
const idx = flowNodes.value.findIndex((n) => n.id === selectedNodeId.value)
if (idx < 0) return
const node = flowNodes.value[idx]
if (patch.label !== undefined) node.data.label = patch.label
if (patch.params !== undefined) node.data.params = { ...patch.params }
flowNodes.value = [...flowNodes.value]
}
function resetDemo() {
syncCurrentToLibrary()
const { library: lib, demoId } = ensureDemoInLibrary(library.value)
library.value = setActiveWorkflowId(lib, demoId) ?? lib
applyWorkflowToCanvas(getActiveWorkflow(library.value))
ElMessage.success('已切换到示例流程')
}
async function onClearCanvas() {
if (flowNodes.value.length === 0 && flowEdges.value.length === 0) {
ElMessage.info('画布已是空的')
return
}
try {
await ElMessageBox.confirm('确定清空当前流程画布上的所有节点和连线?', '清除画布', {
type: 'warning',
confirmButtonText: '清除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
})
} catch {
return
}
flowNodes.value = []
flowEdges.value = []
workflow.nodes = []
workflow.edges = []
selectedNodeId.value = null
selectedEdgeId.value = null
syncCurrentToLibrary()
ElMessage.success('画布已清空')
}
</script>
<style scoped>
.workflow-editor {
height: 100%;
display: flex;
flex-direction: column;
background: #f5f7fa;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ebeef5;
}
.wf-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
flex-wrap: wrap;
}
.wf-toolbar-left,
.wf-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
flex-wrap: wrap;
}
.wf-toolbar-center {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
min-width: 120px;
}
.wf-name-input {
width: 200px;
}
.wf-issue-tag {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-body {
flex: 1;
display: flex;
min-height: 0;
}
.wf-panel {
display: flex;
flex-direction: column;
background: #fff;
min-height: 0;
}
.wf-panel-left {
width: 220px;
border-right: 1px solid #ebeef5;
flex-shrink: 0;
}
.wf-palette-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.wf-panel-center {
flex: 1;
min-width: 0;
}
.wf-panel-right {
width: 280px;
border-left: 1px solid #ebeef5;
flex-shrink: 0;
}
.wf-panel-title {
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: #606266;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
}
.wf-panel-center :deep(.vue-flow) {
width: 100%;
height: 100%;
}
.wf-panel-center :deep(.vue-flow__node) {
background: transparent !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
}
.wf-panel-center :deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke-width: 3 !important;
filter: drop-shadow(0 0 2px rgba(64, 158, 255, 0.45));
}
.wf-panel-center :deep(.vue-flow__edge.selected .vue-flow__edge-text) {
fill: #409eff;
font-weight: 600;
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="wf-list-panel">
<div class="wf-list-head">
<span class="wf-list-title">流程库</span>
<div class="wf-list-actions">
<el-button size="small" type="primary" link @click="emit('create')">+ 新建</el-button>
<el-button size="small" type="success" link @click="emit('save')">保存</el-button>
</div>
</div>
<ul class="wf-list">
<li
v-for="item in workflows"
:key="item.id"
class="wf-list-item"
:class="{ active: item.id === activeId }"
@click="emit('select', item.id)"
>
<div class="wf-list-item-main">
<span class="wf-list-item-name">{{ item.name || '未命名流程' }}</span>
<span class="wf-list-item-meta">{{ item.nodes.length }} 节点</span>
</div>
<el-dropdown
trigger="click"
@click.stop
@command="(cmd) => onCommand(cmd, item.id)"
>
<el-button size="small" text class="wf-list-more" @click.stop></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="duplicate">复制</el-dropdown-item>
<el-dropdown-item command="delete" :disabled="workflows.length <= 1">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
</ul>
<p class="wf-list-hint"> {{ workflows.length }} 个流程仅当前会话内存持久化请点顶栏导出下载 JSON</p>
</div>
</template>
<script setup lang="ts">
import type { WorkflowDefinition } from '@/types/workflow'
defineProps<{
workflows: WorkflowDefinition[]
activeId: string
}>()
const emit = defineEmits<{
select: [id: string]
create: []
save: []
duplicate: [id: string]
delete: [id: string]
}>()
function onCommand(cmd: string | number | object, id: string) {
if (cmd === 'duplicate') emit('duplicate', id)
if (cmd === 'delete') emit('delete', id)
}
</script>
<style scoped>
.wf-list-panel {
display: flex;
flex-direction: column;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
max-height: 220px;
}
.wf-list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
}
.wf-list-title {
font-size: 12px;
font-weight: 600;
color: #606266;
}
.wf-list-actions {
display: flex;
align-items: center;
gap: 4px;
}
.wf-list {
list-style: none;
margin: 0;
padding: 4px 8px 8px;
overflow-y: auto;
}
.wf-list-item {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.wf-list-item:hover {
background: #f5f7fa;
}
.wf-list-item.active {
background: #ecf5ff;
border: 1px solid #b3d8ff;
}
.wf-list-item-main {
flex: 1;
min-width: 0;
}
.wf-list-item-name {
display: block;
font-size: 13px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-list-item-meta {
font-size: 11px;
color: #909399;
}
.wf-list-more {
padding: 0 4px;
min-height: auto;
}
.wf-list-hint {
margin: 0;
padding: 6px 12px 10px;
font-size: 11px;
color: #909399;
line-height: 1.4;
border-top: 1px solid #f0f2f5;
}
</style>
@@ -0,0 +1,213 @@
<template>
<div
class="wf-node"
:class="{ selected: selected, 'wf-node-dual': hasDualOutlets }"
:style="{ '--node-color': color }"
>
<Handle type="target" :position="Position.Left" class="wf-handle wf-handle-target" />
<template v-if="hasDualOutlets">
<div class="wf-node-body">
<div class="wf-node-head">
<span class="wf-node-dot" />
<span class="wf-node-type">{{ typeLabel }}</span>
</div>
<div class="wf-node-label">{{ data.label }}</div>
</div>
<div class="wf-outlets">
<div class="wf-outlet-row">
<span class="wf-port-label wf-port-top">{{ dualOutletLabels.top }}</span>
<Handle
v-if="data.type === 'branch'"
id="success"
type="source"
:position="Position.Right"
class="wf-handle wf-handle-success wf-outlet-handle"
/>
<Handle
v-else-if="data.type === 'waitEvent'"
id="triggered"
type="source"
:position="Position.Right"
class="wf-handle wf-handle-success wf-outlet-handle"
/>
</div>
<div class="wf-outlet-row">
<span class="wf-port-label wf-port-bottom">{{ dualOutletLabels.bottom }}</span>
<Handle
v-if="data.type === 'branch'"
id="fail"
type="source"
:position="Position.Right"
class="wf-handle wf-handle-fail wf-outlet-handle"
/>
<Handle
v-else-if="data.type === 'waitEvent'"
id="timeout"
type="source"
:position="Position.Right"
class="wf-handle wf-handle-fail wf-outlet-handle"
/>
</div>
</div>
</template>
<template v-else>
<div class="wf-node-body">
<div class="wf-node-head">
<span class="wf-node-dot" />
<span class="wf-node-type">{{ typeLabel }}</span>
</div>
<div class="wf-node-label">{{ data.label }}</div>
</div>
<Handle
v-if="data.type !== 'end'"
type="source"
:position="Position.Right"
class="wf-handle wf-handle-source"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position } from '@vue-flow/core'
import { NODE_CATALOG_MAP } from '@/workflow/nodeCatalog'
import { WAIT_EVENT_HANDLE_LABELS } from '@/workflow/waitEventParams'
const props = defineProps<{
id: string
data: { type: string; label: string; params: Record<string, unknown> }
selected?: boolean
}>()
const typeLabel = computed(() => NODE_CATALOG_MAP.get(props.data.type)?.label ?? props.data.type)
const color = computed(() => NODE_CATALOG_MAP.get(props.data.type)?.color ?? '#409eff')
const hasDualOutlets = computed(() => props.data.type === 'branch' || props.data.type === 'waitEvent')
const dualOutletLabels = computed(() => {
if (props.data.type === 'waitEvent') {
return {
top: WAIT_EVENT_HANDLE_LABELS.triggered,
bottom: WAIT_EVENT_HANDLE_LABELS.timeout
}
}
if (props.data.type === 'branch') {
return { top: '成立', bottom: '不成立' }
}
return { top: '', bottom: '' }
})
</script>
<style scoped>
.wf-node {
min-width: 140px;
padding: 8px 0 8px 12px;
border-radius: 8px;
border: 2px solid var(--node-color, #409eff);
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 12px;
position: relative;
}
.wf-node-dual {
display: flex;
flex-direction: column;
min-height: 88px;
padding-right: 0;
}
.wf-node.selected {
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.35), 0 4px 12px rgba(0, 0, 0, 0.12);
}
.wf-node-body {
flex: 1;
padding-right: 8px;
}
.wf-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.wf-node-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--node-color);
}
.wf-node-type {
color: #909399;
font-size: 11px;
}
.wf-node-label {
font-weight: 600;
color: #303133;
line-height: 1.4;
}
.wf-outlets {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2px;
margin-top: 4px;
padding-right: 0;
}
.wf-outlet-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-height: 22px;
padding-right: 0;
}
.wf-port-label {
font-size: 10px;
line-height: 1;
padding: 2px 5px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
}
.wf-port-top {
color: #529b2e;
background: #f0f9eb;
}
.wf-port-bottom {
color: #c45656;
background: #fef0f0;
}
:deep(.wf-handle) {
width: 10px;
height: 10px;
background: var(--node-color, #409eff);
border: 2px solid #fff;
}
:deep(.wf-handle-target) {
left: -5px !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
}
:deep(.wf-handle-source) {
right: -5px !important;
top: 50% !important;
transform: translate(50%, -50%) !important;
}
:deep(.wf-outlet-handle) {
position: relative !important;
top: auto !important;
right: auto !important;
left: auto !important;
bottom: auto !important;
transform: none !important;
flex-shrink: 0;
margin-right: -5px;
}
:deep(.wf-handle-success) {
background: #67c23a;
}
:deep(.wf-handle-fail) {
background: #f56c6c;
}
</style>
@@ -0,0 +1,67 @@
/** Workflow 编排 DSL(前端 MVP,静态页 / 后续持久化共用) */
export interface WorkflowViewport {
x: number
y: number
zoom: number
}
export interface WorkflowNodeDef {
id: string
type: string
label: string
position: { x: number; y: number }
params: Record<string, unknown>
/** 导出 JSON 时写入:下一节点 ID(单出口 / 成立 / 正常执行) */
nextId?: string | null
/** 导出 JSON 时写入:条件不成立时的下一节点 ID */
failNextId?: string | null
/** 导出 JSON 时写入:超时执行时的下一节点 ID */
timeoutNextId?: string | null
}
export interface WorkflowEdgeDef {
id: string
source: string
target: string
sourceHandle?: string
label?: string
}
export interface WorkflowDefinition {
id: string
name: string
version: number
nodes: WorkflowNodeDef[]
edges: WorkflowEdgeDef[]
viewport?: WorkflowViewport
}
export interface WorkflowCatalog {
workflows: WorkflowDefinition[]
activeWorkflowId?: string
}
export type ParamFieldType = 'string' | 'number' | 'boolean' | 'select' | 'textarea'
export interface ParamFieldDef {
key: string
label: string
type: ParamFieldType
required?: boolean
defaultValue?: unknown
placeholder?: string
options?: Array<{ label: string; value: string | number | boolean }>
/** 仅当节点 params[when.key] === when.equals 时显示该表单项 */
when?: { key: string; equals: string | number | boolean }
}
export interface NodeTypeDef {
type: string
label: string
category: string
description?: string
color?: string
singleton?: boolean
params: ParamFieldDef[]
}
@@ -0,0 +1,39 @@
import type { ParamFieldDef } from '@/types/workflow'
export const ASSIGN_STRATEGY_OPTIONS = [
{ label: '指定车辆', value: 'fixed' },
{ label: '指定类型', value: 'byType' }
] as const
const FIXED = { key: 'strategy', equals: 'fixed' as const }
const BY_TYPE = { key: 'strategy', equals: 'byType' as const }
/** 分配车辆:指定车辆 / 指定类型 */
export function assignCarParams(): ParamFieldDef[] {
return [
{
key: 'strategy',
label: '分配策略',
type: 'select',
required: true,
defaultValue: 'fixed',
options: [...ASSIGN_STRATEGY_OPTIONS]
},
{
key: 'carId',
label: '车辆编号',
type: 'string',
required: true,
placeholder: '如 C01',
when: FIXED
},
{
key: 'carType',
label: '车辆类型',
type: 'string',
required: true,
placeholder: '如 Forklift / SimpleLite.RCS.Cars.AgvCar',
when: BY_TYPE
}
]
}
@@ -0,0 +1,184 @@
import type { NodeTypeDef, WorkflowDefinition } from '@/types/workflow'
import { assignCarParams } from '@/workflow/assignCarParams'
import { notifyParams } from '@/workflow/notifyParams'
import { waitEventParams } from '@/workflow/waitEventParams'
import { deviceControlParams } from '@/workflow/protocolParams'
import { executeActionParams } from '@/workflow/vda5050ActionParams'
/** 控件库分组顺序(左侧 palette 展示顺序) */
export const CATEGORY_ORDER = ['流程入口', '调度动作', '流程逻辑', '事件与信号', '协议控制'] as const
export type NodeCategory = (typeof CATEGORY_ORDER)[number]
export const NODE_CATALOG: NodeTypeDef[] = [
// ── 流程入口 ──
{
type: 'start',
label: '开始',
category: '流程入口',
color: '#67c23a',
description: '流程起点',
singleton: true,
params: []
},
{
type: 'end',
label: '结束',
category: '流程入口',
color: '#909399',
description: '流程终点',
params: []
},
// ── 调度动作 ──
{
type: 'gotoSite',
label: '前往站点',
category: '调度动作',
color: '#e6a23c',
description: 'AGV 移动到目标站点',
params: [
{ key: 'siteId', label: '目标站点', type: 'string', required: true, placeholder: '如 S001' }
]
},
{
type: 'executeAction',
label: '执行动作',
category: '调度动作',
color: '#e6a23c',
description: '下发 VDA5050 单条动作指令',
params: executeActionParams()
},
{
type: 'assignCar',
label: '分配车辆',
category: '调度动作',
color: '#e6a23c',
description: '为任务指定车辆或车型',
params: assignCarParams()
},
// ── 流程逻辑 ──
{
type: 'branch',
label: '条件分支',
category: '流程逻辑',
color: '#9b59b6',
description: '条件成立与否走不同分支',
params: [
{ key: 'expression', label: '条件表达式', type: 'textarea', required: true, placeholder: '如 batterySoc > 0.2' }
]
},
{
type: 'delay',
label: '延时事件',
category: '流程逻辑',
color: '#9b59b6',
description: '等待指定秒数后继续',
params: [
{ key: 'seconds', label: '延时(秒)', type: 'number', required: true, defaultValue: 5 }
]
},
// ── 事件与信号 ──
{
type: 'waitEvent',
label: '等待事件',
category: '事件与信号',
color: '#409eff',
description: '等待事件触发,超时走另一路',
params: waitEventParams()
},
{
type: 'notify',
label: '通知事件',
category: '事件与信号',
color: '#409eff',
description: '调用接口推送 JSON 通知',
params: notifyParams()
},
// ── 协议控制 ──
{
type: 'deviceControl',
label: '设备控制',
category: '协议控制',
color: '#13c2c2',
description: 'Modbus 读等待或写输出',
params: deviceControlParams()
}
]
export const NODE_CATALOG_MAP = new Map(NODE_CATALOG.map((n) => [n.type, n]))
export function defaultParamsForType(type: string): Record<string, unknown> {
const def = NODE_CATALOG_MAP.get(type)
if (!def) return {}
const params: Record<string, unknown> = {}
for (const f of def.params) {
if (f.defaultValue !== undefined) params[f.key] = f.defaultValue
else if (f.type === 'boolean') params[f.key] = false
else if (f.type === 'number') params[f.key] = 0
else params[f.key] = ''
}
return params
}
/** 静态演示用示例流程 */
export const DEMO_WORKFLOW: WorkflowDefinition = {
id: 'wf-demo-001',
name: 'A区送料示例',
version: 1,
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [
{ id: 'n1', type: 'start', label: '开始', position: { x: 60, y: 200 }, params: {} },
{ id: 'n2', type: 'assignCar', label: '分配车辆', position: { x: 240, y: 180 }, params: { strategy: 'fixed', carId: 'C01' } },
{ id: 'n3', type: 'gotoSite', label: '前往入库点', position: { x: 460, y: 180 }, params: { siteId: 'S001' } },
{
id: 'n4',
type: 'executeAction',
label: '取货',
position: { x: 680, y: 180 },
params: {
actionType: 'pick',
actionParameters: '[{"key":"loadType","value":"pallet"}]'
}
},
{ id: 'n5', type: 'waitEvent', label: '等待到达', position: { x: 900, y: 180 }, params: { eventName: 'site.arrived', timeoutSec: 120 } },
{
id: 'n6',
type: 'notify',
label: '送料完成通知',
position: { x: 1120, y: 180 },
params: {
apiUrl: 'http://127.0.0.1:8080/api/hook/notify',
dataConfig: '{"event":"delivery.complete","message":"送料完成"}',
message: '送料完成',
level: 'info'
}
},
{ id: 'n7', type: 'end', label: '结束', position: { x: 1340, y: 200 }, params: {} },
{
id: 'n8',
type: 'notify',
label: '等待超时告警',
position: { x: 1120, y: 320 },
params: {
apiUrl: 'http://127.0.0.1:8080/api/hook/notify',
dataConfig: '{"event":"wait.timeout","message":"等待到达超时"}',
message: '等待到达超时',
level: 'warn'
}
}
],
edges: [
{ id: 'e1', source: 'n1', target: 'n2' },
{ id: 'e2', source: 'n2', target: 'n3' },
{ id: 'e3', source: 'n3', target: 'n4' },
{ id: 'e4', source: 'n4', target: 'n5' },
{ id: 'e5', source: 'n5', target: 'n6', sourceHandle: 'triggered', label: '正常执行' },
{ id: 'e5t', source: 'n5', target: 'n8', sourceHandle: 'timeout', label: '超时执行' },
{ id: 'e6', source: 'n6', target: 'n7' },
{ id: 'e8', source: 'n8', target: 'n7' }
]
}
@@ -0,0 +1,39 @@
import type { ParamFieldDef } from '@/types/workflow'
/** 通知事件:调用外部接口,请求体为 JSON 数据配置 */
export function notifyParams(): ParamFieldDef[] {
return [
{
key: 'apiUrl',
label: '接口地址',
type: 'string',
required: true,
placeholder: '如 http://127.0.0.1:8080/api/hook/notify'
},
{
key: 'dataConfig',
label: '数据配置',
type: 'textarea',
required: true,
defaultValue: '{}',
placeholder: 'JSON 格式,如 {"event":"mission.done","payload":{"status":"ok"}}'
},
{
key: 'message',
label: '消息',
type: 'textarea',
placeholder: '运维可见的提示文本(可选)'
},
{
key: 'level',
label: '级别',
type: 'select',
defaultValue: 'info',
options: [
{ label: '信息', value: 'info' },
{ label: '警告', value: 'warn' },
{ label: '错误', value: 'error' }
]
}
]
}
@@ -0,0 +1,132 @@
import type { ParamFieldDef } from '@/types/workflow'
/** 协议类型(与平台 DeviceProtocol 对齐,MVP 先开放 Modbus */
export const PROTOCOL_TYPE_OPTIONS = [
{ label: 'Modbus TCP', value: 'modbus-tcp' },
{ label: 'Modbus RTU', value: 'modbus-rtu' }
]
export const DEVICE_IO_MODE_OPTIONS = [
{ label: '读信号(接收)', value: 'read' },
{ label: '写信号(输出)', value: 'write' }
]
export const MODBUS_DATA_TYPE_OPTIONS = [
{ label: '布尔', value: 'bool' },
{ label: 'Int16', value: 'int16' },
{ label: 'UInt16', value: 'uint16' },
{ label: 'Int32', value: 'int32' },
{ label: 'Float32', value: 'float32' }
]
export const MODBUS_COMPARE_OPTIONS = [
{ label: '等于', value: 'eq' },
{ label: '不等于', value: 'ne' },
{ label: '大于', value: 'gt' },
{ label: '大于等于', value: 'gte' },
{ label: '小于', value: 'lt' },
{ label: '小于等于', value: 'lte' },
{ label: '值发生变化', value: 'changed' }
]
export const MODBUS_WRITE_MODE_OPTIONS = [
{ label: '持续写入', value: 'hold' },
{ label: '脉冲 (写入后复位)', value: 'pulse' }
]
const READ = { key: 'ioMode', equals: 'read' as const }
const WRITE = { key: 'ioMode', equals: 'write' as const }
/** 设备控制:Modbus 读写合一,通过 ioMode 切换读/写配置 */
export function deviceControlParams(): ParamFieldDef[] {
return [
{
key: 'ioMode',
label: '读写模式',
type: 'select',
required: true,
defaultValue: 'read',
options: DEVICE_IO_MODE_OPTIONS
},
{
key: 'protocol',
label: '协议类型',
type: 'select',
required: true,
defaultValue: 'modbus-tcp',
options: PROTOCOL_TYPE_OPTIONS
},
{
key: 'host',
label: '连接地址',
type: 'string',
required: true,
placeholder: 'modbus-tcp: 10.0.2.30:502modbus-rtu: COM3,9600,8,N,1'
},
{
key: 'address',
label: '寄存器地址',
type: 'number',
required: true,
defaultValue: 0,
placeholder: '如 40001 或 0(视驱动约定)'
},
{
key: 'dataType',
label: '数据类型',
type: 'select',
required: true,
defaultValue: 'bool',
options: MODBUS_DATA_TYPE_OPTIONS
},
// ── 读模式 ──
{
key: 'compareOp',
label: '触发条件',
type: 'select',
required: true,
defaultValue: 'eq',
options: MODBUS_COMPARE_OPTIONS,
when: READ
},
{
key: 'pollIntervalMs',
label: '轮询间隔 (ms)',
type: 'number',
defaultValue: 500,
when: READ
},
// ── 写模式 ──
{
key: 'writeValue',
label: '写入值',
type: 'string',
required: true,
placeholder: '如 1 / true / 100',
when: WRITE
},
{
key: 'writeMode',
label: '写入模式',
type: 'select',
defaultValue: 'hold',
options: MODBUS_WRITE_MODE_OPTIONS,
when: WRITE
},
{
key: 'pulseMs',
label: '脉冲宽度 (ms)',
type: 'number',
defaultValue: 500,
placeholder: 'writeMode=pulse 时有效',
when: WRITE
},
{
key: 'verifyRead',
label: '写入后回读校验',
type: 'boolean',
defaultValue: true,
when: WRITE
}
]
}
@@ -0,0 +1,55 @@
import type { ParamFieldDef } from '@/types/workflow'
/** 动作类型选项 */
export const ACTION_TYPE_OPTIONS = [
{ label: '取货', value: 'pick' },
{ label: '放货', value: 'drop' },
{ label: '充电', value: 'startCharging' }
] as const
/** 执行动作:对齐 VDA5050 Order.actions[] 单条 Action 结构(不含 actionId,下发时由运行时生成) */
export function executeActionParams(): ParamFieldDef[] {
return [
{
key: 'actionType',
label: '动作类型',
type: 'select',
required: true,
defaultValue: 'pick',
options: [...ACTION_TYPE_OPTIONS]
},
{
key: 'actionParameters',
label: '动作参数 (actionParameters)',
type: 'textarea',
placeholder: 'JSON 数组:[{"key":"height","value":"100"}]'
}
]
}
/** 导出/下发前解析为 VDA5050 Action 对象 */
export function resolveVda5050Action(params: Record<string, unknown>): {
actionType: string
actionParameters: Array<{ key: string; value: string }>
} {
let actionParameters: Array<{ key: string; value: string }> = []
const raw = params.actionParameters
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
actionParameters = parsed.map((item) => {
const row = item as Record<string, unknown>
return { key: String(row.key ?? ''), value: String(row.value ?? '') }
})
}
} catch {
/* 静态页阶段由用户自行保证 JSON 合法 */
}
}
return {
actionType: String(params.actionType ?? ''),
actionParameters
}
}
@@ -0,0 +1,32 @@
import type { ParamFieldDef } from '@/types/workflow'
/** 等待事件:双出口 —— 触发 / 超时 */
export function waitEventParams(): ParamFieldDef[] {
return [
{
key: 'eventName',
label: '事件名',
type: 'string',
required: true,
placeholder: '如 site.arrived'
},
{
key: 'timeoutSec',
label: '超时(秒)',
type: 'number',
defaultValue: 60,
placeholder: '超时后走「超时」连线;0 表示不超时'
}
]
}
/** 等待事件节点的出口 handle id */
export const WAIT_EVENT_HANDLES = {
triggered: 'triggered',
timeout: 'timeout'
} as const
export const WAIT_EVENT_HANDLE_LABELS: Record<string, string> = {
triggered: '正常执行',
timeout: '超时执行'
}
@@ -0,0 +1,198 @@
import type { WorkflowDefinition, WorkflowNodeDef } from '@/types/workflow'
import { nextWorkflowId } from '@/workflow/workflowLibrary'
import { normalizeWorkflow } from '@/workflow/workflowUtils'
export type WorkflowImportMode = 'single' | 'catalog'
export interface WorkflowImportResult {
mode: WorkflowImportMode
workflows: WorkflowDefinition[]
}
export class WorkflowImportError extends Error {
constructor(message: string) {
super(message)
this.name = 'WorkflowImportError'
}
}
/** 解析 JSON 文本,支持单流程、流程库合集、旧版 draft 包裹格式 */
export function parseWorkflowJsonText(text: string): WorkflowImportResult {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new WorkflowImportError('JSON 格式无效,请检查文件内容')
}
if (!parsed || typeof parsed !== 'object') {
throw new WorkflowImportError('文件内容不是有效的流程 JSON 对象')
}
const root = parsed as Record<string, unknown>
if (Array.isArray(root.workflows)) {
const workflows = root.workflows
.map((item, index) => coerceWorkflowDefinition(item, index))
.filter((w): w is WorkflowDefinition => w !== null)
if (workflows.length === 0) {
throw new WorkflowImportError('流程库文件中没有可导入的流程')
}
return { mode: 'catalog', workflows }
}
if (root.workflow && typeof root.workflow === 'object') {
const one = coerceWorkflowDefinition(root.workflow, 0)
if (!one) throw new WorkflowImportError('draft 格式中的 workflow 字段无效')
return { mode: 'single', workflows: [one] }
}
const single = coerceWorkflowDefinition(parsed, 0)
if (!single) {
throw new WorkflowImportError('无法识别文件结构:需包含 nodes、edges 字段,或 workflows 数组')
}
return { mode: 'single', workflows: [single] }
}
function coerceWorkflowDefinition(raw: unknown, index: number): WorkflowDefinition | null {
if (!raw || typeof raw !== 'object') return null
const row = raw as Record<string, unknown>
if (!Array.isArray(row.nodes)) return null
const id =
typeof row.id === 'string' && row.id.trim()
? row.id.trim()
: nextWorkflowId()
const name =
typeof row.name === 'string' && row.name.trim()
? row.name.trim()
: `导入流程 ${index + 1}`
const version = typeof row.version === 'number' ? row.version : 1
const edges = Array.isArray(row.edges) ? sanitizeEdges(row.edges) : []
const viewport = sanitizeViewport(row.viewport)
const nodes = (row.nodes as unknown[]).map((n, nodeIndex) => sanitizeNode(n, nodeIndex))
return prepareImportedWorkflow({
id,
name,
version,
nodes,
edges,
...(viewport ? { viewport } : {})
})
}
function sanitizeViewport(raw: unknown): WorkflowDefinition['viewport'] | undefined {
if (!raw || typeof raw !== 'object') return undefined
const v = raw as Record<string, unknown>
const x = Number(v.x)
const y = Number(v.y)
const zoom = Number(v.zoom)
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return undefined
return { x, y, zoom }
}
function sanitizeNode(raw: unknown, index: number): WorkflowNodeDef {
const row = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const id = typeof row.id === 'string' && row.id.trim() ? row.id.trim() : `n-import-${index + 1}`
const type = typeof row.type === 'string' && row.type.trim() ? row.type.trim() : 'start'
const label = typeof row.label === 'string' && row.label.trim() ? row.label.trim() : type
const position =
row.position && typeof row.position === 'object'
? {
x: Number((row.position as Record<string, unknown>).x) || 0,
y: Number((row.position as Record<string, unknown>).y) || 0
}
: { x: 80 + index * 40, y: 120 }
const params =
row.params && typeof row.params === 'object' && !Array.isArray(row.params)
? { ...(row.params as Record<string, unknown>) }
: {}
return { id, type, label, position, params }
}
function sanitizeEdges(raw: unknown[]): WorkflowDefinition['edges'] {
return raw
.map((item, index) => {
if (!item || typeof item !== 'object') return null
const row = item as Record<string, unknown>
const source = typeof row.source === 'string' ? row.source : ''
const target = typeof row.target === 'string' ? row.target : ''
if (!source || !target) return null
return {
id: typeof row.id === 'string' && row.id.trim() ? row.id.trim() : `e-import-${index + 1}`,
source,
target,
...(typeof row.sourceHandle === 'string' ? { sourceHandle: row.sourceHandle } : {}),
...(typeof row.label === 'string' ? { label: row.label } : {})
}
})
.filter((e): e is NonNullable<typeof e> => e !== null)
}
/** 规范化并移除仅用于导出的跳转字段(画布以 edges 为准) */
export function prepareImportedWorkflow(def: WorkflowDefinition): WorkflowDefinition {
const cleaned: WorkflowDefinition = {
...def,
nodes: def.nodes.map((n) => {
const { nextId, failNextId, timeoutNextId, ...rest } = n
void nextId
void failNextId
void timeoutNextId
return rest
})
}
return normalizeWorkflow(cleaned)
}
/** 为与库内已有 id 冲突的流程分配新 id */
export function resolveWorkflowIdConflicts(
workflows: WorkflowDefinition[],
existingIds: Set<string>
): WorkflowDefinition[] {
const used = new Set(existingIds)
return workflows.map((w) => {
let id = w.id
if (used.has(id)) {
id = nextWorkflowId()
}
used.add(id)
return id === w.id ? w : { ...w, id }
})
}
export function pickJsonFile(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,application/json'
input.style.display = 'none'
input.onchange = () => {
const file = input.files?.[0] ?? null
input.remove()
resolve(file)
}
input.oncancel = () => {
input.remove()
resolve(null)
}
document.body.appendChild(input)
input.click()
})
}
export async function readFileAsText(file: File): Promise<string> {
return file.text()
}
export function importResultSummary(result: WorkflowImportResult): string {
if (result.mode === 'catalog') {
return `流程库文件,共 ${result.workflows.length} 个流程`
}
const name = result.workflows[0]?.name ?? '未命名'
return `单个流程「${name}`
}
@@ -0,0 +1,214 @@
import type { WorkflowCatalog, WorkflowDefinition } from '@/types/workflow'
import { DEMO_WORKFLOW } from '@/workflow/nodeCatalog'
import {
cloneWorkflowDefinition,
normalizeWorkflow,
WORKFLOW_CATALOG_VERSION,
WORKFLOW_EDITOR_STORAGE_KEY
} from '@/workflow/workflowUtils'
export const WORKFLOW_LIBRARY_STORAGE_KEY = 'workflowEditor.library'
export interface StoredWorkflowLibrary {
catalogVersion: number
workflows: WorkflowDefinition[]
activeWorkflowId: string
}
export interface WorkflowCatalogExport {
catalogVersion: number
exportedAt: string
workflows: WorkflowDefinition[]
}
let workflowSeq = 0
export function nextWorkflowId(): string {
workflowSeq += 1
return `wf-${Date.now()}-${workflowSeq}`
}
export function createEmptyWorkflow(name = '未命名流程'): WorkflowDefinition {
return {
id: nextWorkflowId(),
name,
version: 1,
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 0.85 }
}
}
function seedLibrary(): StoredWorkflowLibrary {
const demo = normalizeWorkflow(cloneWorkflowDefinition(DEMO_WORKFLOW))
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows: [demo],
activeWorkflowId: demo.id
}
}
/** 打开页面时的初始流程库(仅内存,不读 localStorage */
export function createInitialWorkflowLibrary(): StoredWorkflowLibrary {
return seedLibrary()
}
function migrateLegacyDraft(): WorkflowDefinition | null {
try {
const raw = localStorage.getItem(WORKFLOW_EDITOR_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as { catalogVersion?: number; workflow?: WorkflowDefinition }
if (
(parsed.catalogVersion === WORKFLOW_CATALOG_VERSION || parsed.catalogVersion === 2) &&
parsed.workflow?.nodes
) {
return normalizeWorkflow(parsed.workflow)
}
return null
} catch {
return null
}
}
export function loadWorkflowLibrary(): StoredWorkflowLibrary {
try {
const raw = localStorage.getItem(WORKFLOW_LIBRARY_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as StoredWorkflowLibrary
if (parsed.catalogVersion === WORKFLOW_CATALOG_VERSION && parsed.workflows?.length) {
const activeId =
parsed.activeWorkflowId && parsed.workflows.some((w) => w.id === parsed.activeWorkflowId)
? parsed.activeWorkflowId
: parsed.workflows[0]!.id
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows: parsed.workflows.map((w) => normalizeWorkflow(w)),
activeWorkflowId: activeId
}
}
}
} catch {
/* ignore */
}
const legacy = migrateLegacyDraft()
if (legacy) {
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows: [legacy],
activeWorkflowId: legacy.id
}
}
return seedLibrary()
}
export function saveWorkflowLibrary(lib: StoredWorkflowLibrary): void {
try {
localStorage.setItem(WORKFLOW_LIBRARY_STORAGE_KEY, JSON.stringify(lib))
} catch {
/* localStorage 可能被禁用 */
}
}
export function toWorkflowCatalog(lib: StoredWorkflowLibrary): WorkflowCatalog {
return {
workflows: lib.workflows,
activeWorkflowId: lib.activeWorkflowId
}
}
export function getWorkflowById(lib: StoredWorkflowLibrary, id: string): WorkflowDefinition | undefined {
return lib.workflows.find((w) => w.id === id)
}
export function getActiveWorkflow(lib: StoredWorkflowLibrary): WorkflowDefinition {
return getWorkflowById(lib, lib.activeWorkflowId) ?? lib.workflows[0]!
}
/** 将当前编辑中的流程写回库(按 id 覆盖或追加) */
export function upsertWorkflow(
lib: StoredWorkflowLibrary,
workflow: WorkflowDefinition
): StoredWorkflowLibrary {
const normalized = normalizeWorkflow(workflow)
const idx = lib.workflows.findIndex((w) => w.id === normalized.id)
const workflows =
idx >= 0
? lib.workflows.map((w, i) => (i === idx ? normalized : w))
: [...lib.workflows, normalized]
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows,
activeWorkflowId: normalized.id
}
}
export function setActiveWorkflowId(lib: StoredWorkflowLibrary, id: string): StoredWorkflowLibrary | null {
if (!lib.workflows.some((w) => w.id === id)) return null
return { ...lib, activeWorkflowId: id }
}
export function addWorkflow(lib: StoredWorkflowLibrary, workflow: WorkflowDefinition): StoredWorkflowLibrary {
const normalized = normalizeWorkflow(workflow)
if (lib.workflows.some((w) => w.id === normalized.id)) {
return upsertWorkflow(lib, normalized)
}
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows: [...lib.workflows, normalized],
activeWorkflowId: normalized.id
}
}
export function removeWorkflow(lib: StoredWorkflowLibrary, id: string): StoredWorkflowLibrary | null {
if (lib.workflows.length <= 1) return null
const workflows = lib.workflows.filter((w) => w.id !== id)
let activeWorkflowId = lib.activeWorkflowId
if (activeWorkflowId === id) {
activeWorkflowId = workflows[0]!.id
}
return {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflows,
activeWorkflowId
}
}
export function duplicateWorkflow(lib: StoredWorkflowLibrary, id: string): StoredWorkflowLibrary | null {
const source = getWorkflowById(lib, id)
if (!source) return null
const copy = normalizeWorkflow({
...cloneWorkflowDefinition(source),
id: nextWorkflowId(),
name: `${source.name}(副本)`
})
return addWorkflow(lib, copy)
}
/** 确保示例流程存在于库中,并返回更新后的库与示例 id */
export function ensureDemoInLibrary(lib: StoredWorkflowLibrary): {
library: StoredWorkflowLibrary
demoId: string
} {
const demo = normalizeWorkflow(cloneWorkflowDefinition(DEMO_WORKFLOW))
const exists = lib.workflows.some((w) => w.id === demo.id)
const library = exists ? lib : addWorkflow(lib, demo)
return { library, demoId: demo.id }
}
/** 将多个导入流程追加到库,返回新库与最后导入的流程 id(用于切换画布) */
export function mergeWorkflowsIntoLibrary(
lib: StoredWorkflowLibrary,
workflows: WorkflowDefinition[]
): { library: StoredWorkflowLibrary; lastImportedId: string } | null {
if (workflows.length === 0) return null
let result = lib
for (const w of workflows) {
result = addWorkflow(result, w)
}
return {
library: result,
lastImportedId: workflows[workflows.length - 1]!.id
}
}
@@ -0,0 +1,311 @@
import { toRaw } from 'vue'
import type { ParamFieldDef, WorkflowDefinition, WorkflowEdgeDef, WorkflowNodeDef } from '@/types/workflow'
import type { WorkflowCatalogExport } from '@/workflow/workflowLibrary'
import { NODE_CATALOG_MAP, defaultParamsForType } from '@/workflow/nodeCatalog'
import { WAIT_EVENT_HANDLES, WAIT_EVENT_HANDLE_LABELS } from '@/workflow/waitEventParams'
/** 深拷贝流程定义(workflow 为纯 JSON 结构,避免 structuredClone 对 Proxy/不可克隆对象报错) */
export function cloneWorkflowDefinition(def: WorkflowDefinition): WorkflowDefinition {
const raw = toRaw(def) as WorkflowDefinition
return JSON.parse(JSON.stringify(raw)) as WorkflowDefinition
}
export interface ValidationIssue {
level: 'error' | 'warning'
message: string
}
export function validateWorkflow(def: WorkflowDefinition): ValidationIssue[] {
const issues: ValidationIssue[] = []
const nodes = def.nodes
const edges = def.edges
const starts = nodes.filter((n) => n.type === 'start')
const ends = nodes.filter((n) => n.type === 'end')
if (starts.length === 0) issues.push({ level: 'error', message: '流程缺少「开始」节点' })
if (starts.length > 1) issues.push({ level: 'error', message: '流程只能有一个「开始」节点' })
if (ends.length === 0) issues.push({ level: 'warning', message: '建议添加「结束」节点' })
const outCount = new Map<string, number>()
const inCount = new Map<string, number>()
for (const e of edges) {
outCount.set(e.source, (outCount.get(e.source) ?? 0) + 1)
inCount.set(e.target, (inCount.get(e.target) ?? 0) + 1)
}
for (const n of nodes) {
if (n.type === 'start' && (outCount.get(n.id) ?? 0) === 0) {
issues.push({ level: 'error', message: `${n.label}」未连接下游节点` })
}
if (n.type === 'end' && (inCount.get(n.id) ?? 0) === 0) {
issues.push({ level: 'warning', message: `${n.label}」没有上游连线` })
}
if (n.type !== 'start' && n.type !== 'end' && (inCount.get(n.id) ?? 0) === 0) {
issues.push({ level: 'warning', message: `节点「${n.label}」处于孤立状态` })
}
if (n.type === 'waitEvent') {
const outs = edges.filter((e) => e.source === n.id)
const hasTriggered = outs.some(
(e) => e.sourceHandle === WAIT_EVENT_HANDLES.triggered || !e.sourceHandle
)
const hasTimeout = outs.some((e) => e.sourceHandle === WAIT_EVENT_HANDLES.timeout)
const timeoutSec = Number(n.params.timeoutSec ?? 0)
if (!hasTriggered) {
issues.push({ level: 'warning', message: `${n.label}」未连接「正常执行」出口` })
}
if (timeoutSec > 0 && !hasTimeout) {
issues.push({
level: 'warning',
message: `${n.label}」未连接「超时执行」出口,超时后将无法继续执行`
})
}
}
}
return issues
}
/** 控件参数 schema 变更时递增,用于丢弃过期的 localStorage 数据 */
export const WORKFLOW_CATALOG_VERSION = 3
export const WORKFLOW_EDITOR_STORAGE_KEY = 'workflowEditor.draft'
const DEPRECATED_ASSIGN_STRATEGY: Record<string, string> = {
nearest: 'fixed',
idle: 'byType'
}
function isFieldVisible(field: ParamFieldDef, params: Record<string, unknown>): boolean {
if (!field.when) return true
return params[field.when.key] === field.when.equals
}
/** 将节点 params 与当前控件库定义对齐:补默认值、迁移废弃枚举、剔除无效字段 */
export function normalizeNodeParams(type: string, params: Record<string, unknown>): Record<string, unknown> {
const def = NODE_CATALOG_MAP.get(type)
if (!def) return { ...params }
const merged: Record<string, unknown> = { ...defaultParamsForType(type), ...params }
if (type === 'assignCar' && typeof merged.strategy === 'string') {
const migrated = DEPRECATED_ASSIGN_STRATEGY[merged.strategy]
if (migrated) merged.strategy = migrated
}
const result: Record<string, unknown> = {}
for (const field of def.params) {
if (!isFieldVisible(field, merged)) continue
let value = merged[field.key]
if (field.type === 'select' && field.options?.length) {
const valid = field.options.some((o) => o.value === value)
if (!valid) value = field.defaultValue ?? field.options[0]!.value
}
if (value !== undefined) result[field.key] = value
}
return result
}
export function normalizeWorkflow(def: WorkflowDefinition): WorkflowDefinition {
return {
...def,
nodes: def.nodes.map((n) => ({
...n,
params: normalizeNodeParams(n.type, n.params)
}))
}
}
interface StoredWorkflowDraft {
catalogVersion: number
workflow: WorkflowDefinition
}
export function loadStoredWorkflow(): WorkflowDefinition | null {
try {
const raw = localStorage.getItem(WORKFLOW_EDITOR_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as StoredWorkflowDraft
if (parsed.catalogVersion !== WORKFLOW_CATALOG_VERSION) return null
if (!parsed.workflow?.nodes) return null
return normalizeWorkflow(parsed.workflow)
} catch {
return null
}
}
export function saveStoredWorkflow(def: WorkflowDefinition): void {
try {
const payload: StoredWorkflowDraft = {
catalogVersion: WORKFLOW_CATALOG_VERSION,
workflow: def
}
localStorage.setItem(WORKFLOW_EDITOR_STORAGE_KEY, JSON.stringify(payload))
} catch {
/* localStorage 可能被禁用 */
}
}
export function clearStoredWorkflow(): void {
try {
localStorage.removeItem(WORKFLOW_EDITOR_STORAGE_KEY)
} catch {
/* ignore */
}
}
export function toFlowNodes(nodes: WorkflowNodeDef[]) {
return nodes.map((n) => ({
id: n.id,
type: 'workflow',
position: { ...n.position },
data: { type: n.type, label: n.label, params: { ...n.params } }
}))
}
export function toFlowEdges(edges: WorkflowEdgeDef[]) {
return edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
label: e.label ?? edgeHandleLabel(e.sourceHandle),
selectable: true,
interactionWidth: 20,
animated: true,
style: { stroke: edgeStroke(e.sourceHandle), strokeWidth: 2 },
labelStyle: { fill: '#606266', fontSize: 11 },
labelBgStyle: { fill: '#fff', fillOpacity: 0.85 }
}))
}
function edgeHandleLabel(sourceHandle?: string): string | undefined {
if (sourceHandle === WAIT_EVENT_HANDLES.triggered) return WAIT_EVENT_HANDLE_LABELS.triggered
if (sourceHandle === WAIT_EVENT_HANDLES.timeout) return WAIT_EVENT_HANDLE_LABELS.timeout
if (sourceHandle === 'success') return '成立'
if (sourceHandle === 'fail') return '不成立'
return undefined
}
function edgeStroke(sourceHandle?: string): string {
if (sourceHandle === WAIT_EVENT_HANDLES.timeout || sourceHandle === 'fail') return '#f56c6c'
if (sourceHandle === WAIT_EVENT_HANDLES.triggered || sourceHandle === 'success') return '#67c23a'
return '#409eff'
}
export function fromFlowState(
nodes: Array<{ id: string; position: { x: number; y: number }; data: { type: string; label: string; params: Record<string, unknown> } }>,
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null; label?: string | null }>
): Pick<WorkflowDefinition, 'nodes' | 'edges'> {
return {
nodes: nodes.map((n) => ({
id: n.id,
type: n.data.type,
label: n.data.label,
position: { ...n.position },
params: { ...n.data.params }
})),
edges: edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? undefined,
label: e.label ?? undefined
}))
}
}
let seq = 100
export function nextNodeId() {
seq += 1
return `n${seq}`
}
export function nextEdgeId() {
seq += 1
return `e${seq}`
}
/** 从连线关系为节点附加 nextId(仅用于导出,不参与画布编辑态) */
export function attachNextIds(
nodes: WorkflowNodeDef[],
edges: WorkflowEdgeDef[]
): WorkflowNodeDef[] {
function targetOf(sourceId: string, sourceHandle?: string): string | null {
const outs = edges.filter((e) => e.source === sourceId)
if (sourceHandle) {
const matched = outs.find((e) => e.sourceHandle === sourceHandle)
if (matched) return matched.target
}
const fallback = outs.find((e) => !e.sourceHandle)
return fallback?.target ?? null
}
return nodes.map((node) => {
const base = { ...node }
delete base.nextId
delete base.failNextId
delete base.timeoutNextId
if (node.type === 'end') {
return { ...base, nextId: null }
}
if (node.type === 'branch') {
return {
...base,
nextId: targetOf(node.id, 'success'),
failNextId: targetOf(node.id, 'fail')
}
}
if (node.type === 'waitEvent') {
return {
...base,
nextId: targetOf(node.id, WAIT_EVENT_HANDLES.triggered),
timeoutNextId: targetOf(node.id, WAIT_EVENT_HANDLES.timeout)
}
}
return { ...base, nextId: targetOf(node.id) }
})
}
/** 导出用:附带 nextId 的流程快照 */
export function workflowForExport(def: WorkflowDefinition): WorkflowDefinition {
return {
...def,
nodes: attachNextIds(def.nodes, def.edges)
}
}
/** 将流程 DSL 触发浏览器下载为本地 JSON 文件 */
export function downloadWorkflowJson(def: WorkflowDefinition, filename?: string) {
const payload = workflowForExport(def)
const base = ((filename ?? def.name) || 'workflow').trim() || 'workflow'
const safe = base.replace(/[\\/:*?"<>|]/g, '_')
downloadJsonBlob(JSON.stringify(payload, null, 2), `${safe}.json`)
}
export function downloadJsonBlob(content: string, filename: string) {
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
/** 导出流程库:包含所有已保存流程(含 nextId) */
export function downloadWorkflowCatalogExport(
workflows: WorkflowDefinition[],
filename = 'workflow-catalog'
) {
const payload: WorkflowCatalogExport = {
catalogVersion: WORKFLOW_CATALOG_VERSION,
exportedAt: new Date().toISOString(),
workflows: workflows.map((w) => workflowForExport(w))
}
const safe = filename.replace(/[\\/:*?"<>|]/g, '_')
downloadJsonBlob(JSON.stringify(payload, null, 2), `${safe}.json`)
}