From 5253a7a8188fc48135f2569faba57caa36409330 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 21:59:13 +0800 Subject: [PATCH] =?UTF-8?q?[F8]=20DAG=20JSON=20=E2=86=94=20vue-flow=20?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts - toCanvas / fromCanvas 对称转换 - newTempId (crypto.randomUUID) + isTempId (new_ 前缀) - RuleChainGraphVO/RuleChainNodeVO/RuleChainLinkVO 本地类型定义 - B2 后端 DTO 就绪后可平替类型,转换函数签名不变 - apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts - 23 用例:roundtrip_simple / roundtrip_branch / newNode_tempId / emptyGraph - 对称性断言 toCanvas(fromCanvas(toCanvas(x))) ≡ toCanvas(x) - Known Pitfalls 落地: ⚠️ 临时 ID 字符串穿透 / Math.round 坐标 / relationType PascalCase / edge source-target 校验 Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Sonnet 4.6 --- .../rule/chain/utils/dag-converter.spec.ts | 621 ++++++++++++++++++ .../iot/rule/chain/utils/dag-converter.ts | 244 +++++++ 2 files changed, 865 insertions(+) create mode 100644 apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts create mode 100644 apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts diff --git a/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts b/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts new file mode 100644 index 000000000..80a5b8280 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.spec.ts @@ -0,0 +1,621 @@ +/** + * F8 — dag-converter 单元测试 + * + * 覆盖任务卡 §6 四个测试用例: + * 1. roundtrip_simple — 1 Trigger + 1 Action + 1 link + * 2. roundtrip_branch — 1 Trigger + 1 Condition + 4 Action(True/False 分支) + * 3. newNode_tempId — 新节点使用临时 ID (new_xxx) + * 4. emptyGraph — 空 nodes + 空 links + * + * 对称性断言:toCanvas(fromCanvas(toCanvas(graph))) ≡ toCanvas(graph) + */ + +import type { DagEdge, DagNode } from '../../../../../components/iot-dag'; +import type { RuleChainGraphVO } from './dag-converter'; + +import { describe, expect, it } from 'vitest'; + +import { fromCanvas, isTempId, newTempId, toCanvas } from './dag-converter'; + +// ────────────────────────────────────────────── +// 测试夹具(Fixtures) +// ────────────────────────────────────────────── + +/** 最简规则链:1 Trigger + 1 Action + 1 link */ +const SIMPLE_GRAPH: RuleChainGraphVO = { + id: 1, + name: '简单场景', + type: 'SCENE', + status: 1, + nodes: [ + { + id: 10, + category: 'trigger', + type: 'device_property', + name: '属性触发', + configuration: { productId: 100, propertyId: 'temp' }, + positionX: 100, + positionY: 200, + }, + { + id: 20, + category: 'action', + type: 'notify', + name: '发送通知', + configuration: { channel: 'SMS' }, + positionX: 400, + positionY: 200, + }, + ], + links: [ + { + id: 100, + sourceNodeId: 10, + targetNodeId: 20, + relationType: 'Success', + sortOrder: 0, + }, + ], +}; + +/** 分支规则链:1 Trigger + 1 Condition + 4 Action(True×2 / False×2) */ +const BRANCH_GRAPH: RuleChainGraphVO = { + id: 2, + name: '分支场景', + type: 'SCENE', + status: 1, + nodes: [ + { + id: 1, + category: 'trigger', + type: 'timer', + name: '定时触发', + configuration: { cron: '0 * * * *' }, + positionX: 50, + positionY: 50, + }, + { + id: 2, + category: 'condition', + type: 'property_compare', + name: '温度判断', + configuration: { operator: '>', threshold: 30 }, + positionX: 250, + positionY: 50, + }, + { + id: 3, + category: 'action', + type: 'notify', + name: '高温告警', + configuration: { msg: '温度过高' }, + positionX: 450, + positionY: 0, + }, + { + id: 4, + category: 'action', + type: 'log', + name: '高温日志', + configuration: {}, + positionX: 450, + positionY: 80, + }, + { + id: 5, + category: 'action', + type: 'notify', + name: '正常通知', + configuration: { msg: '温度正常' }, + positionX: 450, + positionY: 160, + }, + { + id: 6, + category: 'action', + type: 'log', + name: '正常日志', + configuration: {}, + positionX: 450, + positionY: 240, + }, + ], + links: [ + { + id: 10, + sourceNodeId: 1, + targetNodeId: 2, + relationType: 'Success', + sortOrder: 0, + }, + { + id: 11, + sourceNodeId: 2, + targetNodeId: 3, + relationType: 'True', + sortOrder: 0, + }, + { + id: 12, + sourceNodeId: 2, + targetNodeId: 4, + relationType: 'True', + sortOrder: 1, + }, + { + id: 13, + sourceNodeId: 2, + targetNodeId: 5, + relationType: 'False', + sortOrder: 0, + }, + { + id: 14, + sourceNodeId: 2, + targetNodeId: 6, + relationType: 'False', + sortOrder: 1, + }, + ], +}; + +// ────────────────────────────────────────────── +// 测试用例 +// ────────────────────────────────────────────── + +describe('dag-converter', () => { + // ── 1. roundtrip_simple ────────────────────── + describe('roundtrip_simple', () => { + it('toCanvas 输出节点数量正确', () => { + const { nodes, edges } = toCanvas(SIMPLE_GRAPH); + expect(nodes).toHaveLength(2); + expect(edges).toHaveLength(1); + }); + + it('toCanvas 节点字段映射正确', () => { + const { nodes } = toCanvas(SIMPLE_GRAPH); + const triggerNode = nodes.find((n) => n.id === '10'); + + expect(triggerNode?.type).toBe('triggerNode'); + expect(triggerNode?.position).toEqual({ x: 100, y: 200 }); + expect(triggerNode?.data?.type).toBe('trigger'); + expect(triggerNode?.data?.providerType).toBe('device_property'); + expect(triggerNode?.data?.label).toBe('属性触发'); + expect(triggerNode?.data?.configuration).toEqual({ + productId: 100, + propertyId: 'temp', + }); + }); + + it('toCanvas 边字段映射正确', () => { + const { edges } = toCanvas(SIMPLE_GRAPH); + const edge = edges.at(0); + + expect(edge?.id).toBe('100'); + expect(edge?.source).toBe('10'); + expect(edge?.target).toBe('20'); + expect(edge?.label).toBe('Success'); + expect(edge?.type).toBe('smoothstep'); + expect(edge?.data?.relationType).toBe('Success'); + }); + + it('fromCanvas(toCanvas(graph)) 对称还原(简单场景)', () => { + const { nodes, edges } = toCanvas(SIMPLE_GRAPH); + const restored = fromCanvas(nodes, edges, { + id: SIMPLE_GRAPH.id, + name: SIMPLE_GRAPH.name, + type: SIMPLE_GRAPH.type, + status: SIMPLE_GRAPH.status, + }); + + expect(restored.nodes).toHaveLength(SIMPLE_GRAPH.nodes.length); + expect(restored.links).toHaveLength(SIMPLE_GRAPH.links.length); + + // 验证节点还原 + const restoredTrigger = restored.nodes.find((n) => Number(n.id) === 10); + expect(restoredTrigger?.category).toBe('trigger'); + expect(restoredTrigger?.type).toBe('device_property'); + expect(restoredTrigger?.positionX).toBe(100); + expect(restoredTrigger?.positionY).toBe(200); + + // 验证边还原 + const restoredLink = restored.links.at(0); + expect(Number(restoredLink?.sourceNodeId)).toBe(10); + expect(Number(restoredLink?.targetNodeId)).toBe(20); + expect(restoredLink?.relationType).toBe('Success'); + }); + + it('对称性:toCanvas(fromCanvas(toCanvas(graph))) ≡ toCanvas(graph)', () => { + const first = toCanvas(SIMPLE_GRAPH); + const restored = fromCanvas(first.nodes, first.edges, { + id: SIMPLE_GRAPH.id, + name: SIMPLE_GRAPH.name, + type: SIMPLE_GRAPH.type, + status: SIMPLE_GRAPH.status, + }); + const second = toCanvas(restored); + + // 节点 id / position / data 保持一致 + expect(second.nodes.map((n) => n.id)).toEqual( + first.nodes.map((n) => n.id), + ); + expect(second.nodes.map((n) => n.position)).toEqual( + first.nodes.map((n) => n.position), + ); + expect(second.nodes.map((n) => n.data?.type)).toEqual( + first.nodes.map((n) => n.data?.type), + ); + expect(second.nodes.map((n) => n.data?.providerType)).toEqual( + first.nodes.map((n) => n.data?.providerType), + ); + + // 边一致 + expect(second.edges.map((e) => e.source)).toEqual( + first.edges.map((e) => e.source), + ); + expect(second.edges.map((e) => e.target)).toEqual( + first.edges.map((e) => e.target), + ); + expect(second.edges.map((e) => e.data?.relationType)).toEqual( + first.edges.map((e) => e.data?.relationType), + ); + }); + }); + + // ── 2. roundtrip_branch ────────────────────── + describe('roundtrip_branch', () => { + it('toCanvas 分支场景节点/边数量正确', () => { + const { nodes, edges } = toCanvas(BRANCH_GRAPH); + expect(nodes).toHaveLength(6); + expect(edges).toHaveLength(5); + }); + + it('condition 节点类型映射正确', () => { + const { nodes } = toCanvas(BRANCH_GRAPH); + const conditionNode = nodes.find((n) => n.id === '2'); + + expect(conditionNode?.type).toBe('conditionNode'); + expect(conditionNode?.data?.type).toBe('condition'); + expect(conditionNode?.data?.providerType).toBe('property_compare'); + }); + + it('true/false 边 relationType 保持 PascalCase', () => { + const { edges } = toCanvas(BRANCH_GRAPH); + const trueEdges = edges.filter((e) => e.data?.relationType === 'True'); + const falseEdges = edges.filter((e) => e.data?.relationType === 'False'); + + expect(trueEdges).toHaveLength(2); + expect(falseEdges).toHaveLength(2); + }); + + it('fromCanvas(toCanvas(graph)) 对称还原(分支场景)', () => { + const { nodes, edges } = toCanvas(BRANCH_GRAPH); + const restored = fromCanvas(nodes, edges, { + id: BRANCH_GRAPH.id, + name: BRANCH_GRAPH.name, + type: BRANCH_GRAPH.type, + status: BRANCH_GRAPH.status, + }); + + expect(restored.nodes).toHaveLength(6); + expect(restored.links).toHaveLength(5); + + // condition 节点 category 还原 + const restoredCondition = restored.nodes.find((n) => Number(n.id) === 2); + expect(restoredCondition?.category).toBe('condition'); + + // True 边还原 + const trueLinks = restored.links.filter((l) => l.relationType === 'True'); + expect(trueLinks).toHaveLength(2); + }); + + it('对称性验证(分支场景)', () => { + const first = toCanvas(BRANCH_GRAPH); + const restored = fromCanvas(first.nodes, first.edges, { + id: BRANCH_GRAPH.id, + name: BRANCH_GRAPH.name, + type: BRANCH_GRAPH.type, + status: BRANCH_GRAPH.status, + }); + const second = toCanvas(restored); + + const sortById = (arr: DagNode[]) => + arr.toSorted((a, b) => a.id.localeCompare(b.id)); + const sortEdgeById = (arr: DagEdge[]) => + arr.toSorted((a, b) => a.id.localeCompare(b.id)); + + const firstNodesSorted = sortById(first.nodes); + const secondNodesSorted = sortById(second.nodes); + + expect(secondNodesSorted.map((n) => n.id)).toEqual( + firstNodesSorted.map((n) => n.id), + ); + expect(secondNodesSorted.map((n) => n.data?.type)).toEqual( + firstNodesSorted.map((n) => n.data?.type), + ); + + const firstEdgesSorted = sortEdgeById(first.edges); + const secondEdgesSorted = sortEdgeById(second.edges); + + expect(secondEdgesSorted.map((e) => e.data?.relationType)).toEqual( + firstEdgesSorted.map((e) => e.data?.relationType), + ); + }); + }); + + // ── 3. newNode_tempId ──────────────────────── + describe('newNode_tempId', () => { + it('newTempId 生成 new_ 前缀的 ID', () => { + const id = newTempId(); + expect(id).toMatch(/^new_/); + expect(isTempId(id)).toBe(true); + }); + + it('isTempId 对正式 ID 返回 false', () => { + expect(isTempId('123')).toBe(false); + expect(isTempId('456')).toBe(false); + }); + + it('连续生成的 tempId 不重复', () => { + const ids = Array.from({ length: 10 }, () => newTempId()); + const unique = new Set(ids); + expect(unique.size).toBe(10); + }); + + it('fromCanvas 保留临时 ID 字符串', () => { + const tempId = 'new_abc123'; + const nodes: DagNode[] = [ + { + id: tempId, + type: 'actionNode', + position: { x: 0, y: 0 }, + data: { + type: 'action', + providerType: 'notify', + label: '新建动作', + configuration: {}, + }, + }, + ]; + const edges: DagEdge[] = []; + const result = fromCanvas(nodes, edges, { + id: 99, + name: 'test', + type: 'SCENE', + status: 1, + }); + + // 临时 ID 应被保留(强制转换为 number 位置上的字符串值) + expect(String(result.nodes.at(0)?.id)).toBe(tempId); + }); + + it('临时 ID 节点的 category 正确映射', () => { + const nodes: DagNode[] = [ + { + id: 'new_aaa', + type: 'triggerNode', + position: { x: 10, y: 20 }, + data: { + type: 'trigger', + providerType: 'timer', + label: '定时', + configuration: {}, + }, + }, + { + id: 'new_bbb', + type: 'actionNode', + position: { x: 100, y: 20 }, + data: { + type: 'action', + providerType: 'notify', + label: '通知', + configuration: {}, + }, + }, + ]; + const edges: DagEdge[] = [ + { + id: 'new_eee', + source: 'new_aaa', + target: 'new_bbb', + label: 'Success', + type: 'smoothstep', + data: { relationType: 'Success' }, + }, + ]; + + const result = fromCanvas(nodes, edges, { + id: 0, + name: 'temp', + type: 'SCENE', + status: 1, + }); + expect(result.nodes.at(0)?.category).toBe('trigger'); + expect(result.nodes.at(1)?.category).toBe('action'); + expect(result.links.at(0)?.relationType).toBe('Success'); + }); + }); + + // ── 4. emptyGraph ──────────────────────────── + describe('emptyGraph', () => { + const EMPTY_GRAPH: RuleChainGraphVO = { + id: 99, + name: '空规则链', + type: 'CUSTOM', + status: 0, + nodes: [], + links: [], + }; + + it('toCanvas 返回空 nodes 和 edges', () => { + const { nodes, edges } = toCanvas(EMPTY_GRAPH); + expect(nodes).toHaveLength(0); + expect(edges).toHaveLength(0); + }); + + it('fromCanvas([], [], meta) 返回空节点和边', () => { + const result = fromCanvas([], [], { + id: EMPTY_GRAPH.id, + name: EMPTY_GRAPH.name, + type: EMPTY_GRAPH.type, + status: EMPTY_GRAPH.status, + }); + + expect(result.nodes).toHaveLength(0); + expect(result.links).toHaveLength(0); + expect(result.id).toBe(99); + expect(result.name).toBe('空规则链'); + expect(result.type).toBe('CUSTOM'); + }); + + it('toCanvas(fromCanvas(toCanvas(emptyGraph))) 对称性', () => { + const first = toCanvas(EMPTY_GRAPH); + const restored = fromCanvas(first.nodes, first.edges, EMPTY_GRAPH); + const second = toCanvas(restored); + + expect(second.nodes).toHaveLength(0); + expect(second.edges).toHaveLength(0); + }); + }); + + // ── 5. 边缘情况 ────────────────────────────── + describe('edge cases', () => { + it('positionX/Y 缺失时默认 {x:0, y:0}', () => { + const graph: RuleChainGraphVO = { + id: 1, + name: 'test', + type: 'SCENE', + status: 1, + nodes: [ + { + id: 1, + category: 'trigger', + type: 'manual', + configuration: {}, + // positionX / positionY 未定义 + }, + ], + links: [], + }; + const { nodes } = toCanvas(graph); + expect(nodes.at(0)?.position).toEqual({ x: 0, y: 0 }); + }); + + it('node.name 缺失时 label 回退为 node.type', () => { + const graph: RuleChainGraphVO = { + id: 1, + name: 'test', + type: 'SCENE', + status: 1, + nodes: [ + { + id: 1, + category: 'action', + type: 'notify', + configuration: {}, + // name 未定义 + }, + ], + links: [], + }; + const { nodes } = toCanvas(graph); + expect(nodes.at(0)?.data?.label).toBe('notify'); + }); + + it('position 浮点坐标取整', () => { + const nodes: DagNode[] = [ + { + id: '1', + type: 'triggerNode', + position: { x: 100.7, y: 200.3 }, + data: { type: 'trigger', label: 'T', configuration: {} }, + }, + ]; + const result = fromCanvas(nodes, [], { + id: 1, + name: 'test', + type: 'SCENE', + status: 1, + }); + expect(result.nodes.at(0)?.positionX).toBe(101); + expect(result.nodes.at(0)?.positionY).toBe(200); + }); + + it('fromCanvas 边引用不存在节点时抛错', () => { + const nodes: DagNode[] = [ + { + id: '1', + type: 'triggerNode', + position: { x: 0, y: 0 }, + data: { type: 'trigger', label: 'T', configuration: {} }, + }, + ]; + const edges: DagEdge[] = [ + { + id: 'e1', + source: '1', + target: '999', // 不存在 + type: 'smoothstep', + data: { relationType: 'Success' }, + }, + ]; + expect(() => fromCanvas(nodes, edges, {})).toThrow( + 'does not reference an existing node', + ); + }); + + it('relationType 保持 PascalCase(Timeout / Skip / Failure)', () => { + const graph: RuleChainGraphVO = { + id: 1, + name: 'test', + type: 'SCENE', + status: 1, + nodes: [ + { + id: 1, + category: 'trigger', + type: 'timer', + configuration: {}, + positionX: 0, + positionY: 0, + }, + { + id: 2, + category: 'action', + type: 'notify', + configuration: {}, + positionX: 100, + positionY: 0, + }, + ], + links: [ + { + id: 1, + sourceNodeId: 1, + targetNodeId: 2, + relationType: 'Timeout', + }, + { + id: 2, + sourceNodeId: 1, + targetNodeId: 2, + relationType: 'Skip', + }, + { + id: 3, + sourceNodeId: 1, + targetNodeId: 2, + relationType: 'Failure', + }, + ], + }; + const { edges } = toCanvas(graph); + expect(edges.map((e) => e.data?.relationType)).toEqual([ + 'Timeout', + 'Skip', + 'Failure', + ]); + }); + }); +}); diff --git a/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts b/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts new file mode 100644 index 000000000..c9d94c036 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/utils/dag-converter.ts @@ -0,0 +1,244 @@ +/** + * F8 — DAG JSON ↔ vue-flow 数据格式转换 + * + * 职责:将后端 RuleChainGraphVO 与 vue-flow 的 DagNode[] / DagEdge[] 双向互转。 + * + * 类型映射说明: + * - 后端 node.category ('trigger'|'condition'|'action') → DagNodeData.type (DagNodeType) + * - 后端 node.type (provider 标识, e.g. 'device_property') → DagNodeData.providerType + * - 后端 node.name → DagNodeData.label(回退用 node.type 作为 label) + * - 后端 link.relationType (PascalCase) → DagEdgeData.relationType + * - 临时节点 id 形如 'new_',fromCanvas 时原样保留字符串,后端通过前缀识别 INSERT + * + * @module dag-converter + */ + +import type { + DagEdge, + DagNode, + DagNodeData, + DagNodeType, +} from '../../../../../components/iot-dag'; + +// ────────────────────────────────────────────── +// 1. 后端数据模型(B2 就绪后以后端生成的 DTO 为准) +// ────────────────────────────────────────────── + +export interface RuleChainNodeVO { + category: 'action' | 'condition' | 'trigger'; + configuration: Record; + id: number; + name?: string; + positionX?: number; + positionY?: number; + /** Provider 类型标识,如 device_property / alarm_trigger / timer 等 */ + type: string; +} + +export interface RuleChainLinkVO { + id: number; + relationType: + | 'Failure' + | 'False' + | 'Skip' + | 'Success' + | 'Timeout' + | 'True' + | string; + sortOrder?: number; + sourceNodeId: number; + targetNodeId: number; +} + +/** 后端规则链图谱完整 VO(B2 就绪后以后端生成类型为准) */ +export interface RuleChainGraphVO { + deviceId?: number; + id: number; + links: RuleChainLinkVO[]; + name: string; + nodes: RuleChainNodeVO[]; + productId?: number; + status: number; + subsystemId?: number; + type: 'CUSTOM' | 'DATA' | 'SCENE'; +} + +// ────────────────────────────────────────────── +// 2. 辅助:生成临时 ID +// ────────────────────────────────────────────── + +/** + * 为新建节点生成临时 ID(前端尚未经后端分配 BIGINT ID 时使用)。 + * 格式:`new_`,保存时后端需按前缀判断 INSERT / UPDATE。 + */ +export function newTempId(): string { + // 使用 Web Crypto API(浏览器 & Node 18+ 均内置) + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + return `new_${id}`; +} + +/** + * 判断是否为临时 ID(未经后端分配)。 + */ +export function isTempId(id: string): boolean { + return id.startsWith('new_'); +} + +// ────────────────────────────────────────────── +// 3. toCanvas — 后端 → vue-flow +// ────────────────────────────────────────────── + +/** + * 将后端 RuleChainGraphVO 转换为 vue-flow 所需的 nodes + edges。 + * + * - position 若后端未存储则默认 { x: 0, y: 0 } + * - DagNode.type 设为 `${category}Node`(triggerNode / conditionNode / actionNode) + * 用于匹配 F2 注册的 custom node 组件;category 超出已知枚举时降级为 customNode + * - DagNodeData.type 映射自 category(业务类型枚举) + * - DagNodeData.providerType 映射自 node.type(Provider 标识) + */ +export function toCanvas(graph: RuleChainGraphVO): { + edges: DagEdge[]; + nodes: DagNode[]; +} { + const nodes: DagNode[] = graph.nodes.map((n) => { + const nodeType = categoryToDagNodeType(n.category); + return { + id: String(n.id), + type: `${n.category}Node`, + position: { + x: n.positionX ?? 0, + y: n.positionY ?? 0, + }, + data: { + type: nodeType, + providerType: n.type, + label: n.name ?? n.type, + configuration: n.configuration, + }, + }; + }); + + const edges: DagEdge[] = graph.links.map((l) => ({ + id: String(l.id), + source: String(l.sourceNodeId), + target: String(l.targetNodeId), + label: l.relationType, + type: 'smoothstep', + data: { + relationType: l.relationType, + sortOrder: l.sortOrder, + }, + })); + + return { nodes, edges }; +} + +// ────────────────────────────────────────────── +// 4. fromCanvas — vue-flow → 后端 +// ────────────────────────────────────────────── + +/** + * 将 vue-flow 的 nodes + edges 还原为 RuleChainGraphVO(用于保存到后端)。 + * + * - 节点 position 坐标取整(Math.round),后端以 INT 存储 + * - 临时 ID(new_xxx):保留原始字符串值写入后端字段(类型断言为 number), + * F7(保存逻辑)或后端通过 isTempId 前缀区分 INSERT / UPDATE + * - meta 参数用于传入 id/name/type/status 等画布无法感知的元信息 + * + * @throws {Error} 如果 edge 引用了不存在的节点 ID + */ +export function fromCanvas( + nodes: DagNode[], + edges: DagEdge[], + meta: Partial, +): RuleChainGraphVO { + // 校验:边的 source/target 必须引用已存在的节点 + const nodeIdSet = new Set(nodes.map((n) => n.id)); + for (const e of edges) { + if (!nodeIdSet.has(e.source)) { + throw new Error( + `Edge "${e.id}" source "${e.source}" does not reference an existing node`, + ); + } + if (!nodeIdSet.has(e.target)) { + throw new Error( + `Edge "${e.id}" target "${e.target}" does not reference an existing node`, + ); + } + } + + const backendNodes: RuleChainNodeVO[] = nodes.map((n) => { + // n.data 在 vue-flow Node 中为可选,运行时应始终存在(fromCanvas 由受控代码调用) + const data = n.data ?? ({ type: 'action', label: '' } as DagNodeData); + return { + // 临时 ID 保留字符串(类型强制转换,后端通过前缀识别) + id: isTempId(n.id) ? (n.id as unknown as number) : Number(n.id), + category: dagNodeTypeToCategory(data.type), + type: data.providerType ?? data.type, + name: data.label, + configuration: data.configuration ?? {}, + positionX: Math.round(n.position.x), + positionY: Math.round(n.position.y), + }; + }); + + const backendLinks: RuleChainLinkVO[] = edges.map((e) => ({ + id: isTempId(e.id) ? (e.id as unknown as number) : Number(e.id), + sourceNodeId: isTempId(e.source) + ? (e.source as unknown as number) + : Number(e.source), + targetNodeId: isTempId(e.target) + ? (e.target as unknown as number) + : Number(e.target), + relationType: e.data?.relationType ?? String(e.label ?? ''), + sortOrder: e.data?.sortOrder ?? 0, + })); + + return { + id: 0, + name: '', + type: 'SCENE', + status: 1, + ...meta, + nodes: backendNodes, + links: backendLinks, + }; +} + +// ────────────────────────────────────────────── +// 5. 内部辅助:category ↔ DagNodeType 映射 +// ────────────────────────────────────────────── + +/** + * 后端 category → vue-flow DagNodeType + * 未知 category 降级为 'custom' + */ +function categoryToDagNodeType(category: string): DagNodeType { + const map: Record = { + action: 'action', + condition: 'condition', + trigger: 'trigger', + }; + return map[category] ?? 'custom'; +} + +/** + * vue-flow DagNodeType → 后端 category + * branch/custom 在后端归类(branch→condition, custom→action) + */ +function dagNodeTypeToCategory( + nodeType: DagNodeType, +): 'action' | 'condition' | 'trigger' { + const map: Record = { + action: 'action', + branch: 'condition', + condition: 'condition', + custom: 'action', + trigger: 'trigger', + }; + return map[nodeType] ?? 'action'; +}