From 5716c4e8ffafb7f6732706c5e26c902d34aec5ee Mon Sep 17 00:00:00 2001 From: 18086616529 <344820985@qq.com> Date: Wed, 3 Jun 2026 10:14:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=BB=E5=8A=A1=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../design-system/pages/mission-editor.md | 638 ++++++++++++++++ .../src/components/workflow/EdgeInspector.vue | 100 +++ .../src/components/workflow/NodeInspector.vue | 237 ++++++ .../src/components/workflow/NodePalette.vue | 141 ++++ .../components/workflow/WorkflowEditor.vue | 696 ++++++++++++++++++ .../components/workflow/WorkflowListPanel.vue | 140 ++++ .../src/components/workflow/WorkflowNode.vue | 213 ++++++ .../simple-platform-vue/src/types/workflow.ts | 67 ++ .../src/workflow/assignCarParams.ts | 39 + .../src/workflow/nodeCatalog.ts | 184 +++++ .../src/workflow/notifyParams.ts | 39 + .../src/workflow/protocolParams.ts | 132 ++++ .../src/workflow/vda5050ActionParams.ts | 55 ++ .../src/workflow/waitEventParams.ts | 32 + .../src/workflow/workflowImport.ts | 198 +++++ .../src/workflow/workflowLibrary.ts | 214 ++++++ .../src/workflow/workflowUtils.ts | 311 ++++++++ frontends/pnpm-lock.yaml | 154 ++++ 18 files changed, 3590 insertions(+) create mode 100644 frontends/apps/simple-platform-vue/design-system/pages/mission-editor.md create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/EdgeInspector.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/NodeInspector.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/WorkflowEditor.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/WorkflowListPanel.vue create mode 100644 frontends/apps/simple-platform-vue/src/components/workflow/WorkflowNode.vue create mode 100644 frontends/apps/simple-platform-vue/src/types/workflow.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/notifyParams.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/protocolParams.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/waitEventParams.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/workflowImport.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/workflowLibrary.ts create mode 100644 frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts diff --git a/frontends/apps/simple-platform-vue/design-system/pages/mission-editor.md b/frontends/apps/simple-platform-vue/design-system/pages/mission-editor.md new file mode 100644 index 0000000..aec368c --- /dev/null +++ b/frontends/apps/simple-platform-vue/design-system/pages/mission-editor.md @@ -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 规范化(补默认值、剔除无效字段) +- 存在校验警告时不阻断导入,顶栏会提示警告条数 diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/EdgeInspector.vue b/frontends/apps/simple-platform-vue/src/components/workflow/EdgeInspector.vue new file mode 100644 index 0000000..27fb9e2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/EdgeInspector.vue @@ -0,0 +1,100 @@ + + + + 连线 + ID: {{ edge.id }} + + + + + 起点 + {{ edge.sourceLabel }} + + + 终点 + {{ edge.targetLabel }} + + + 出口 + {{ edge.label }} + + + + 按 Delete / Backspace 可快速删除;或点击下方按钮。 + + + 删除连线 + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/NodeInspector.vue b/frontends/apps/simple-platform-vue/src/components/workflow/NodeInspector.vue new file mode 100644 index 0000000..91dc21c --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/NodeInspector.vue @@ -0,0 +1,237 @@ + + + + + {{ nodeDef?.label ?? node.type }} + ID: {{ node.id }} + + + + + + + + + + setParam(field.key, v)" + /> + setParam(field.key, v ?? 0)" + /> + setParam(field.key, v)" + /> + setParam(field.key, v)" + > + + + setParam(field.key, v)" + /> + + + + + + + + + 删除节点 + + + + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue b/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue new file mode 100644 index 0000000..20534b6 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/NodePalette.vue @@ -0,0 +1,141 @@ + + + + + + + + + + {{ group.category }} + + + + + {{ item.label }} + {{ item.description }} + + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowEditor.vue b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowEditor.vue new file mode 100644 index 0000000..94e9399 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowEditor.vue @@ -0,0 +1,696 @@ + + + + + + 流程库 {{ library.workflows.length }} 个 + + + + {{ issue.message }} + + 校验通过 + + + + 删除选中 + + 加载示例 + 导入 + 导出 + 清除画布 + + + + + + + + + + + + + + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowListPanel.vue b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowListPanel.vue new file mode 100644 index 0000000..206ccb2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowListPanel.vue @@ -0,0 +1,140 @@ + + + + 流程库 + + + 新建 + 保存 + + + + + + + {{ item.name || '未命名流程' }} + {{ item.nodes.length }} 节点 + + onCommand(cmd, item.id)" + > + ⋯ + + + 复制 + 删除 + + + + + + + 共 {{ workflows.length }} 个流程(仅当前会话内存);持久化请点顶栏「导出」下载 JSON。 + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowNode.vue b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowNode.vue new file mode 100644 index 0000000..d0d83b7 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/workflow/WorkflowNode.vue @@ -0,0 +1,213 @@ + + + + + + + + + {{ typeLabel }} + + {{ data.label }} + + + + {{ dualOutletLabels.top }} + + + + + {{ dualOutletLabels.bottom }} + + + + + + + + + + + {{ typeLabel }} + + {{ data.label }} + + + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/types/workflow.ts b/frontends/apps/simple-platform-vue/src/types/workflow.ts new file mode 100644 index 0000000..3882d9e --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/types/workflow.ts @@ -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 + /** 导出 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[] +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts b/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts new file mode 100644 index 0000000..a4adff3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/assignCarParams.ts @@ -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 + } + ] +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts b/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts new file mode 100644 index 0000000..b581553 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/nodeCatalog.ts @@ -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 { + const def = NODE_CATALOG_MAP.get(type) + if (!def) return {} + const params: Record = {} + 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' } + ] +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/notifyParams.ts b/frontends/apps/simple-platform-vue/src/workflow/notifyParams.ts new file mode 100644 index 0000000..336e1d2 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/notifyParams.ts @@ -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' } + ] + } + ] +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/protocolParams.ts b/frontends/apps/simple-platform-vue/src/workflow/protocolParams.ts new file mode 100644 index 0000000..92d624c --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/protocolParams.ts @@ -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 + } + ] +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts b/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts new file mode 100644 index 0000000..af6cc4e --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/vda5050ActionParams.ts @@ -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): { + 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 + return { key: String(row.key ?? ''), value: String(row.value ?? '') } + }) + } + } catch { + /* 静态页阶段由用户自行保证 JSON 合法 */ + } + } + + return { + actionType: String(params.actionType ?? ''), + actionParameters + } +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/waitEventParams.ts b/frontends/apps/simple-platform-vue/src/workflow/waitEventParams.ts new file mode 100644 index 0000000..9d42b36 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/waitEventParams.ts @@ -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 = { + triggered: '正常执行', + timeout: '超时执行' +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/workflowImport.ts b/frontends/apps/simple-platform-vue/src/workflow/workflowImport.ts new file mode 100644 index 0000000..e862ae3 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/workflowImport.ts @@ -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 + + 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 + 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 + 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 + 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).x) || 0, + y: Number((row.position as Record).y) || 0 + } + : { x: 80 + index * 40, y: 120 } + + const params = + row.params && typeof row.params === 'object' && !Array.isArray(row.params) + ? { ...(row.params as Record) } + : {} + + 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 + 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 => 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 +): 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 { + 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 { + 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}」` +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/workflowLibrary.ts b/frontends/apps/simple-platform-vue/src/workflow/workflowLibrary.ts new file mode 100644 index 0000000..8d42a0f --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/workflowLibrary.ts @@ -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 + } +} diff --git a/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts b/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts new file mode 100644 index 0000000..43079af --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/workflow/workflowUtils.ts @@ -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() + const inCount = new Map() + 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 = { + nearest: 'fixed', + idle: 'byType' +} + +function isFieldVisible(field: ParamFieldDef, params: Record): boolean { + if (!field.when) return true + return params[field.when.key] === field.when.equals +} + +/** 将节点 params 与当前控件库定义对齐:补默认值、迁移废弃枚举、剔除无效字段 */ +export function normalizeNodeParams(type: string, params: Record): Record { + const def = NODE_CATALOG_MAP.get(type) + if (!def) return { ...params } + + const merged: Record = { ...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 = {} + 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 } }>, + edges: Array<{ id: string; source: string; target: string; sourceHandle?: string | null; label?: string | null }> +): Pick { + 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`) +} diff --git a/frontends/pnpm-lock.yaml b/frontends/pnpm-lock.yaml index 6adc522..017afd4 100644 --- a/frontends/pnpm-lock.yaml +++ b/frontends/pnpm-lock.yaml @@ -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: {}
按 Delete / Backspace 可快速删除;或点击下方按钮。
共 {{ workflows.length }} 个流程(仅当前会话内存);持久化请点顶栏「导出」下载 JSON。