新增任务模板界面
This commit is contained in:
@@ -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:502;modbus-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`)
|
||||
}
|
||||
Generated
+154
@@ -13,6 +13,15 @@ importers:
|
||||
'@element-plus/icons-vue':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.2(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2(vue@3.5.34(typescript@5.9.3))
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.16.1
|
||||
@@ -412,6 +421,9 @@ packages:
|
||||
'@types/node@22.19.19':
|
||||
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -431,6 +443,23 @@ packages:
|
||||
'@volar/typescript@2.4.15':
|
||||
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
|
||||
|
||||
'@vue-flow/background@1.3.2':
|
||||
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/controls@1.1.3':
|
||||
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/core@1.48.2':
|
||||
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/compiler-core@3.5.34':
|
||||
resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==}
|
||||
|
||||
@@ -474,14 +503,23 @@ packages:
|
||||
'@vue/shared@3.5.34':
|
||||
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/core@14.3.0':
|
||||
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@14.3.0':
|
||||
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@14.3.0':
|
||||
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
|
||||
peerDependencies:
|
||||
@@ -547,6 +585,44 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dayjs@1.11.20:
|
||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||
|
||||
@@ -1186,6 +1262,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.19.19))(vue@3.5.34(typescript@5.9.3))':
|
||||
@@ -1205,6 +1283,27 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.34(typescript@5.9.3))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3)))(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.34(typescript@5.9.3))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.34(typescript@5.9.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue/compiler-core@3.5.34':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -1279,6 +1378,16 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.34': {}
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.34(typescript@5.9.3))
|
||||
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@14.3.0(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
@@ -1286,8 +1395,17 @@ snapshots:
|
||||
'@vueuse/shared': 14.3.0(vue@3.5.34(typescript@5.9.3))
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@14.3.0': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.34(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@14.3.0(vue@3.5.34(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
@@ -1360,6 +1478,42 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
dayjs@1.11.20: {}
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user