[F8] DAG JSON ↔ vue-flow 双向转换

- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 21:59:13 +08:00
parent 7dbf41b75f
commit 5253a7a818
2 changed files with 865 additions and 0 deletions

View File

@@ -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 ActionTrue/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 ActionTrue×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 保持 PascalCaseTimeout / 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',
]);
});
});
});

View File

@@ -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_<uuid>'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<string, unknown>;
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;
}
/** 后端规则链图谱完整 VOB2 就绪后以后端生成类型为准) */
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_<uuid>`,保存时后端需按前缀判断 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.typeProvider 标识)
*/
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 存储
* - 临时 IDnew_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>,
): 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<T> 中为可选运行时应始终存在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<string, DagNodeType> = {
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<DagNodeType, 'action' | 'condition' | 'trigger'> = {
action: 'action',
branch: 'condition',
condition: 'condition',
custom: 'action',
trigger: 'trigger',
};
return map[nodeType] ?? 'action';
}