diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index c27459e38..408b57d16 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -304,6 +304,88 @@ "history": "History", "noHistory": "No history records" } + }, + "ruleChain": { + "list": { + "title": "Rule Chain List" + }, + "edit": { + "title": "Edit Rule Chain", + "namePlaceholder": "Enter rule chain name", + "nameRequired": "Rule chain name is required", + "loadFailed": "Failed to load rule chain, please retry", + "saveFailed": "Save failed, please retry", + "unsavedTitle": "Unsaved Changes", + "unsavedContent": "Leaving this page will discard unsaved changes. Continue?", + "unsavedLeave": "Leave", + "unsavedBadge": "Unsaved", + "unlockTip": "This rule chain is enabled. Click to unlock for editing.", + "dropHint": "Drag nodes to canvas to start building" + }, + "v1Banner": { + "message": "Scene Rules (v1) Migrated", + "description": "v2 Rule Chain DAG is the new orchestration entry. v1 Scene Rules page remains available during the gradual rollout.", + "link": "Go to v1 Scene Rules" + }, + "field": { + "name": "Name", + "type": "Type", + "subsystem": "Subsystem", + "bindScope": "Bind Scope", + "product": "Product", + "device": "Device", + "priority": "Priority", + "status": "Status", + "debugMode": "Debug Mode", + "version": "Version", + "updateTime": "Updated At", + "actions": "Actions" + }, + "filter": { + "namePlaceholder": "Search name", + "subsystemPlaceholder": "All Subsystems", + "typePlaceholder": "All Types", + "statusPlaceholder": "All Status" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning", + "warningHint": "Model changes caused some rules to fail. Please check the rule chain configuration." + }, + "type": { + "scene": "Scene Linkage", + "data": "Data Forwarding", + "custom": "Custom" + }, + "debug": { + "confirmTitle": "Enable Debug Mode", + "confirmContent": "Debug mode records input/output for each node and may produce large logs affecting performance. Confirm?", + "toggleSuccess": "Debug mode toggled", + "toggleFailed": "Toggle failed, please retry" + }, + "action": { + "create": "New Rule Chain", + "enable": "Enable", + "disable": "Disable", + "enableSuccess": "Enabled successfully", + "disableSuccess": "Disabled successfully", + "statusChangeFailed": "Operation failed, please retry", + "deleteConfirm": "Delete this rule chain? This action cannot be undone.", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Delete failed, please retry", + "copy": "Copy", + "copySuccess": "Copied successfully", + "copyFailed": "Copy failed, please retry", + "deploy": "Deploy", + "deployConfirmTitle": "Confirm Deploy", + "deployConfirmContent": "After deploying, the rule chain will take effect immediately at runtime. Please confirm the configuration is correct.", + "deploySuccess": "Deployed successfully", + "deployFailed": "Deploy failed, please retry", + "saveSuccess": "Saved successfully", + "unlock": "Unlock for Editing", + "back": "Back to List" + } } } } diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index 9a466d3ad..1121ee4fe 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -304,6 +304,88 @@ "history": "历史变化", "noHistory": "暂无历史记录" } + }, + "ruleChain": { + "list": { + "title": "规则链列表" + }, + "edit": { + "title": "编辑规则链", + "namePlaceholder": "请输入规则链名称", + "nameRequired": "规则链名称不能为空", + "loadFailed": "加载规则链失败,请重试", + "saveFailed": "保存失败,请重试", + "unsavedTitle": "有未保存的变更", + "unsavedContent": "离开此页面将丢失未保存的变更,确认离开?", + "unsavedLeave": "离开", + "unsavedBadge": "未保存", + "unlockTip": "当前规则链已启用,点击解锁进入编辑模式", + "dropHint": "拖拽节点到画布开始编排" + }, + "v1Banner": { + "message": "场景规则(v1)已迁移", + "description": "v2 规则链 DAG 为新编排入口,v1 场景规则页面继续可用(灰度阶段)", + "link": "访问 v1 场景规则" + }, + "field": { + "name": "名称", + "type": "类型", + "subsystem": "子系统", + "bindScope": "绑定范围", + "product": "产品", + "device": "设备", + "priority": "优先级", + "status": "状态", + "debugMode": "调试模式", + "version": "版本", + "updateTime": "更新时间", + "actions": "操作" + }, + "filter": { + "namePlaceholder": "搜索名称", + "subsystemPlaceholder": "全部子系统", + "typePlaceholder": "全部类型", + "statusPlaceholder": "全部状态" + }, + "status": { + "enabled": "已启用", + "disabled": "已禁用", + "warning": "异常", + "warningHint": "物模型变更导致部分规则失效,请检查规则链配置" + }, + "type": { + "scene": "场景联动", + "data": "数据转发", + "custom": "自定义" + }, + "debug": { + "confirmTitle": "开启调试模式", + "confirmContent": "调试模式会记录每个节点的输入输出,可能产生大量日志并影响性能,确认开启?", + "toggleSuccess": "调试模式已切换", + "toggleFailed": "切换失败,请重试" + }, + "action": { + "create": "新建规则链", + "enable": "启用", + "disable": "禁用", + "enableSuccess": "启用成功", + "disableSuccess": "禁用成功", + "statusChangeFailed": "操作失败,请重试", + "deleteConfirm": "确认删除该规则链?删除后不可恢复。", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败,请重试", + "copy": "复制", + "copySuccess": "复制成功", + "copyFailed": "复制失败,请重试", + "deploy": "发布", + "deployConfirmTitle": "确认发布", + "deployConfirmContent": "发布后规则链将立即在运行时生效,请确认配置无误。", + "deploySuccess": "发布成功", + "deployFailed": "发布失败,请重试", + "saveSuccess": "保存成功", + "unlock": "解锁编辑", + "back": "返回列表" + } } } } diff --git a/apps/web-antd/src/router/routes/modules/iot.ts b/apps/web-antd/src/router/routes/modules/iot.ts index 497d1c60d..4dabadbef 100644 --- a/apps/web-antd/src/router/routes/modules/iot.ts +++ b/apps/web-antd/src/router/routes/modules/iot.ts @@ -73,6 +73,28 @@ const routes: RouteRecordRaw[] = [ }, component: () => import('#/views/iot/alarm/record/list.vue'), }, + // F7: 规则链 DAG 列表(v2,决策 5 双入口) + { + path: '/iot/rule/chain', + name: 'IoTRuleChainList', + meta: { + title: '规则链 DAG', + icon: 'lucide:workflow', + keepAlive: true, + }, + component: () => import('#/views/iot/rule/chain/list.vue'), + }, + // F7: 规则链 DAG 编辑页(hideInMenu,归属规则链列表高亮) + { + path: '/iot/rule/chain/edit/:id?', + name: 'IoTRuleChainEdit', + meta: { + title: '编辑规则链', + hideInMenu: true, + activePath: '/iot/rule/chain', + }, + component: () => import('#/views/iot/rule/chain/edit.vue'), + }, ]; export default routes; diff --git a/apps/web-antd/src/views/iot/rule/chain/__tests__/rule-chain-store.spec.ts b/apps/web-antd/src/views/iot/rule/chain/__tests__/rule-chain-store.spec.ts new file mode 100644 index 000000000..d3fe1f8e9 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/__tests__/rule-chain-store.spec.ts @@ -0,0 +1,254 @@ +/** + * F7 — useRuleChainStore 单元测试 + * + * 覆盖: + * 1. initNew — 初始化新规则链 + * 2. updateMeta — 更新元信息并标记 dirty + * 3. setNodes / setEdges — 节点/连线变更标记 dirty + * 4. addNode — 追加节点 + * 5. reset — 清理所有状态 + * 6. save (mock) — 调用 API 并清除 dirty + * 7. deploy (mock) — dirty 时先 save 再 deploy + */ + +import type { DagEdge, DagNode } from '#/components/iot-dag'; + +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useRuleChainStore } from '../stores/rule-chain'; + +// ── mock @vben/request 不可用,直接 mock API 模块 ────────────────────────────── + +const mockCreate = vi.fn().mockResolvedValue(42); +const mockUpdate = vi.fn().mockResolvedValue(undefined); +const mockDeploy = vi.fn().mockResolvedValue(undefined); +const mockGetById = vi.fn(); + +vi.mock('#/views/iot/rule/chain/api/rule-chain', () => ({ + ruleChainApi: { + create: (...args: unknown[]) => mockCreate(...args), + update: (...args: unknown[]) => mockUpdate(...args), + deploy: (...args: unknown[]) => mockDeploy(...args), + getById: (...args: unknown[]) => mockGetById(...args), + }, +})); + +// mock ant-design-vue message(避免 document is not defined) +vi.mock('ant-design-vue', () => ({ + message: { success: vi.fn(), error: vi.fn() }, + Modal: { confirm: vi.fn() }, +})); + +// mock locales +vi.mock('#/locales', () => ({ + $t: (key: string) => key, +})); + +// mock fromCanvas / toCanvas +vi.mock('#/views/iot/rule/chain/utils/dag-converter', () => ({ + fromCanvas: ( + nodes: DagNode[], + _edges: unknown[], + meta: Record, + ) => ({ + ...meta, + nodes: nodes.map((n) => ({ + id: n.id, + type: 'trigger', + category: 'trigger', + configuration: {}, + })), + links: [], + }), + toCanvas: () => ({ nodes: [], edges: [] }), +})); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('useRuleChainStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + // ── 1. initNew ────────────────────────────────────────────────────────────── + + it('initNew 应重置为干净的新规则链状态', () => { + const store = useRuleChainStore(); + // 先标记 dirty + store.updateMeta({ name: '测试' }); + expect(store.dirty).toBe(true); + + store.initNew('DATA'); + + expect(store.meta.id).toBe(0); + expect(store.meta.type).toBe('DATA'); + expect(store.meta.name).toBe(''); + expect(store.dirty).toBe(false); + expect(store.nodes).toHaveLength(0); + expect(store.edges).toHaveLength(0); + }); + + // ── 2. updateMeta ─────────────────────────────────────────────────────────── + + it('updateMeta 应合并字段并置 dirty=true', () => { + const store = useRuleChainStore(); + store.initNew(); + + store.updateMeta({ name: '规则链A', priority: 5 }); + + expect(store.meta.name).toBe('规则链A'); + expect(store.meta.priority).toBe(5); + expect(store.dirty).toBe(true); + }); + + // ── 3. setNodes / setEdges ────────────────────────────────────────────────── + + it('setNodes 应更新节点列表并置 dirty=true', () => { + const store = useRuleChainStore(); + store.initNew(); + + const node: DagNode = { + id: 'node-1', + type: 'triggerNode', + position: { x: 0, y: 0 }, + data: { type: 'trigger', label: '测试节点' }, + }; + + store.setNodes([node]); + + expect(store.nodes).toHaveLength(1); + expect(store.nodes[0]?.id).toBe('node-1'); + expect(store.dirty).toBe(true); + }); + + it('setEdges 应更新连线列表并置 dirty=true', () => { + const store = useRuleChainStore(); + store.initNew(); + + const edge: DagEdge = { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + data: { relationType: 'Success' }, + }; + + store.setEdges([edge]); + + expect(store.edges).toHaveLength(1); + expect(store.dirty).toBe(true); + }); + + // ── 4. addNode ────────────────────────────────────────────────────────────── + + it('addNode 应追加节点到列表并置 dirty=true', () => { + const store = useRuleChainStore(); + store.initNew(); + + const n1: DagNode = { + id: 'n1', + type: 'triggerNode', + position: { x: 10, y: 20 }, + data: { type: 'trigger', label: 'N1' }, + }; + const n2: DagNode = { + id: 'n2', + type: 'actionNode', + position: { x: 200, y: 20 }, + data: { type: 'action', label: 'N2' }, + }; + + store.addNode(n1); + store.addNode(n2); + + expect(store.nodes).toHaveLength(2); + expect(store.nodes[1]?.id).toBe('n2'); + expect(store.dirty).toBe(true); + }); + + // ── 5. reset ──────────────────────────────────────────────────────────────── + + it('reset 应清理所有状态', () => { + const store = useRuleChainStore(); + store.updateMeta({ name: '有名字' }); + store.addNode({ + id: 'x', + type: 'actionNode', + position: { x: 0, y: 0 }, + data: { type: 'action', label: 'X' }, + }); + expect(store.dirty).toBe(true); + + store.reset(); + + expect(store.meta.id).toBe(0); + expect(store.meta.name).toBe(''); + expect(store.nodes).toHaveLength(0); + expect(store.edges).toHaveLength(0); + expect(store.dirty).toBe(false); + expect(store.loading).toBe(false); + expect(store.saving).toBe(false); + }); + + // ── 6. save(新建) ───────────────────────────────────────────────────────── + + it('save (新建) 应调用 create API 并更新 meta.id 且清除 dirty', async () => { + const store = useRuleChainStore(); + store.initNew(); + store.updateMeta({ name: '新链' }); + // 强制写 dirty(initNew 后 updateMeta 已设置) + expect(store.dirty).toBe(true); + + const savedId = await store.save(); + + expect(mockCreate).toHaveBeenCalledOnce(); + expect(savedId).toBe(42); + expect(store.meta.id).toBe(42); + expect(store.dirty).toBe(false); + }); + + it('save (更新) 应调用 update API', async () => { + const store = useRuleChainStore(); + store.initNew(); + // 模拟已有 ID + store.meta.id = 100; + store.updateMeta({ name: '更新的链' }); + + await store.save(); + + expect(mockUpdate).toHaveBeenCalledOnce(); + expect(mockCreate).not.toHaveBeenCalled(); + expect(store.dirty).toBe(false); + }); + + // ── 7. deploy ─────────────────────────────────────────────────────────────── + + it('deploy 在 dirty=false 时直接调用 deploy API(不重复 save)', async () => { + const store = useRuleChainStore(); + store.initNew(); + store.meta.id = 10; + store.meta.name = '链'; + store.dirty = false; + + await store.deploy(); + + expect(mockDeploy).toHaveBeenCalledWith(10); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('deploy 在 dirty=true 时先 save 再 deploy', async () => { + const store = useRuleChainStore(); + store.initNew(); + store.meta.id = 20; + store.updateMeta({ name: '未保存链' }); + expect(store.dirty).toBe(true); + + await store.deploy(); + + // dirty=true 时 id=20 → 走 update + expect(mockUpdate).toHaveBeenCalledOnce(); + expect(mockDeploy).toHaveBeenCalledWith(20); + }); +}); diff --git a/apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts b/apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts new file mode 100644 index 000000000..15f340313 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts @@ -0,0 +1,166 @@ +/** + * F7 — 规则链 CRUD + 启用/禁用/发布 API + * + * 后端 B2 就绪前以任务卡 §3.3 约定的 API 契约为准。 + * 使用 @vben/request 的 requestClient,不用 axios/fetch。 + */ + +import type { RuleChainGraphVO } from '#/views/iot/rule/chain/utils/dag-converter'; + +import { requestClient } from '#/api/request'; + +// ────────────────────────────────────────────── +// 1. 请求 / 响应数据类型 +// ────────────────────────────────────────────── + +/** 规则链状态 */ +export type RuleChainStatus = 'DISABLED' | 'ENABLED' | 'WARNING'; + +/** 规则链类型 */ +export type RuleChainType = 'CUSTOM' | 'DATA' | 'SCENE'; + +/** 规则链列表项 VO */ +export interface RuleChainRespVO { + /** 调试模式 */ + debugMode: boolean; + /** 描述 */ + description?: string; + /** 绑定设备 ID */ + deviceId?: number; + /** 绑定设备名 */ + deviceName?: string; + /** 规则链 ID */ + id: number; + /** 名称 */ + name: string; + /** 优先级 */ + priority?: number; + /** 绑定产品 ID */ + productId?: number; + /** 绑定产品名 */ + productName?: string; + /** 状态:DISABLED / ENABLED / WARNING */ + status: RuleChainStatus; + /** 子系统 ID */ + subsystemId?: number; + /** 子系统名 */ + subsystemName?: string; + /** 类型:SCENE / DATA / CUSTOM */ + type: RuleChainType; + /** 更新时间(ISO 字符串) */ + updateTime?: string; + /** 版本号 */ + version?: number; + /** WARNING 原因(物模型变更等) */ + warningReason?: string; +} + +/** 分页请求参数 */ +export interface RuleChainPageReqVO { + /** 页码,从 1 开始 */ + pageNo?: number; + /** 每页条数 */ + pageSize?: number; + /** 状态筛选 */ + status?: RuleChainStatus; + /** 子系统 ID 筛选 */ + subsystemId?: number; + /** 类型筛选 */ + type?: RuleChainType; + /** 关键字(名称) */ + name?: string; +} + +/** 通用分页结果 */ +export interface PageResult { + list: T[]; + total: number; +} + +/** 创建 / 更新规则链请求 VO(含图谱) */ +export type RuleChainSaveReqVO = Omit & { + description?: string; + id?: number; + priority?: number; +}; + +/** 子系统简单列表项 */ +export interface SubsystemSimpleVO { + id: number; + name: string; +} + +// ────────────────────────────────────────────── +// 2. API 方法 +// ────────────────────────────────────────────── + +export const ruleChainApi = { + /** + * 分页查询规则链列表 + */ + getPage: (params: RuleChainPageReqVO) => + requestClient.get>('/iot/rule-chain/page', { + params, + }), + + /** + * 按 ID 获取规则链(含图谱节点/连线) + */ + getById: (id: number) => + requestClient.get('/iot/rule-chain/get', { + params: { id }, + }), + + /** + * 创建规则链 + * @returns 新规则链 ID + */ + create: (data: RuleChainSaveReqVO) => + requestClient.post('/iot/rule-chain/create', data), + + /** + * 更新规则链(含图谱) + */ + update: (data: RuleChainSaveReqVO & { id: number }) => + requestClient.put('/iot/rule-chain/update', data), + + /** + * 删除规则链 + */ + delete: (id: number) => + requestClient.delete(`/iot/rule-chain/delete?id=${id}`), + + /** + * 启用规则链 + */ + enable: (id: number) => requestClient.put('/iot/rule-chain/enable', { id }), + + /** + * 禁用规则链 + */ + disable: (id: number) => requestClient.put('/iot/rule-chain/disable', { id }), + + /** + * 发布规则链(生效到运行时) + */ + deploy: (id: number) => requestClient.put('/iot/rule-chain/deploy', { id }), + + /** + * 切换调试模式 + */ + toggleDebug: (id: number, enabled: boolean) => + requestClient.put('/iot/rule-chain/debug', { id, enabled }), + + /** + * 复制规则链 + * @returns 新规则链 ID + */ + copy: (id: number) => + requestClient.post(`/iot/rule-chain/copy?id=${id}`), + + /** + * 获取子系统简单列表(用于筛选器) + */ + getSubsystemList: () => + requestClient.get('/iot/subsystem/simple-list'), +}; diff --git a/apps/web-antd/src/views/iot/rule/chain/edit.vue b/apps/web-antd/src/views/iot/rule/chain/edit.vue new file mode 100644 index 000000000..faeee1ca1 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/edit.vue @@ -0,0 +1,525 @@ + + + + + diff --git a/apps/web-antd/src/views/iot/rule/chain/list.vue b/apps/web-antd/src/views/iot/rule/chain/list.vue new file mode 100644 index 000000000..39f09eb61 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/list.vue @@ -0,0 +1,632 @@ + + + + + diff --git a/apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts b/apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts new file mode 100644 index 000000000..4c8bc5d8a --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts @@ -0,0 +1,283 @@ +/** + * F7 — 规则链编辑页 Pinia Store + * + * 职责: + * - 持有当前编辑的规则链元信息(meta)+ 画布节点/连线 + * - dirty 标记:有未保存变更时置 true,离开前提示 + * - 提供 load / save / deploy 动作(封装 API 调用) + * + * 注意:画布的 undo/redo 由 useDagState(DagCanvas 内部)管理, + * store 只负责"最终状态"的持久化。 + */ + +import type { DagEdge, DagNode } from '#/components/iot-dag'; +import type { + RuleChainRespVO, + RuleChainSaveReqVO, +} from '#/views/iot/rule/chain/api/rule-chain'; +import type { RuleChainGraphVO } from '#/views/iot/rule/chain/utils/dag-converter'; + +import { ref } from 'vue'; + +import { message } from 'ant-design-vue'; +import { defineStore } from 'pinia'; + +import { $t } from '#/locales'; +import { ruleChainApi } from '#/views/iot/rule/chain/api/rule-chain'; +import { + fromCanvas, + toCanvas, +} from '#/views/iot/rule/chain/utils/dag-converter'; + +// ────────────────────────────────────────────── +// 规则链元信息(不含图谱节点/连线) +// ────────────────────────────────────────────── + +export interface RuleChainMeta { + debugMode: boolean; + description: string; + id: number; + name: string; + priority: number; + status: number; + subsystemId?: number; + type: 'CUSTOM' | 'DATA' | 'SCENE'; + version?: number; +} + +// ────────────────────────────────────────────── +// Store 定义 +// ────────────────────────────────────────────── + +export const useRuleChainStore = defineStore('iot-rule-chain-edit', () => { + // ── 状态 ────────────────────────────────────────────────────────────────── + + /** 当前编辑的规则链元信息 */ + const meta = ref({ + id: 0, + name: '', + type: 'SCENE', + status: 1, + debugMode: false, + description: '', + priority: 0, + }); + + /** 画布节点(与 DagCanvas v-model:nodes 双向绑定) */ + const nodes = ref([]); + + /** 画布连线(与 DagCanvas v-model:edges 双向绑定) */ + const edges = ref([]); + + /** 是否有未保存的变更 */ + const dirty = ref(false); + + /** 加载中 */ + const loading = ref(false); + + /** 保存中 */ + const saving = ref(false); + + /** 发布中 */ + const deploying = ref(false); + + // ── 动作 ────────────────────────────────────────────────────────────────── + + /** + * 加载规则链(进入编辑页时调用) + */ + async function load(id: number): Promise { + loading.value = true; + dirty.value = false; + try { + const graph = await ruleChainApi.getById(id); + _applyGraph(graph); + } finally { + loading.value = false; + } + } + + /** + * 将后端图谱数据应用到 store + */ + function _applyGraph(graph: RuleChainGraphVO): void { + meta.value = { + id: graph.id, + name: graph.name, + type: graph.type, + status: graph.status, + debugMode: false, + description: '', + priority: 0, + subsystemId: graph.subsystemId, + }; + const { nodes: n, edges: e } = toCanvas(graph); + nodes.value = n; + edges.value = e; + dirty.value = false; + } + + /** + * 初始化新规则链(创建场景) + */ + function initNew(type: 'CUSTOM' | 'DATA' | 'SCENE' = 'SCENE'): void { + meta.value = { + id: 0, + name: '', + type, + status: 1, + debugMode: false, + description: '', + priority: 0, + }; + nodes.value = []; + edges.value = []; + dirty.value = false; + } + + /** + * 保存规则链(创建 or 更新) + */ + async function save(): Promise { + saving.value = true; + try { + const graph = fromCanvas(nodes.value, edges.value, { + id: meta.value.id, + name: meta.value.name, + type: meta.value.type, + status: meta.value.status, + subsystemId: meta.value.subsystemId, + }); + + const reqData: RuleChainSaveReqVO = { + ...graph, + description: meta.value.description, + priority: meta.value.priority, + }; + + let savedId: number; + if (meta.value.id === 0) { + // 创建 + savedId = await ruleChainApi.create(reqData); + meta.value.id = savedId; + } else { + // 更新 + await ruleChainApi.update({ ...reqData, id: meta.value.id }); + savedId = meta.value.id; + } + + dirty.value = false; + message.success($t('iot.ruleChain.action.saveSuccess')); + return savedId; + } finally { + saving.value = false; + } + } + + /** + * 发布规则链(运行时生效) + */ + async function deploy(): Promise { + if (dirty.value) { + await save(); + } + deploying.value = true; + try { + await ruleChainApi.deploy(meta.value.id); + message.success($t('iot.ruleChain.action.deploySuccess')); + } finally { + deploying.value = false; + } + } + + /** + * 更新 meta 字段(触发 dirty) + */ + function updateMeta(patch: Partial): void { + Object.assign(meta.value, patch); + dirty.value = true; + } + + /** + * 画布节点变更回调(DagCanvas 触发 update:nodes) + */ + function setNodes(newNodes: DagNode[]): void { + nodes.value = newNodes; + dirty.value = true; + } + + /** + * 画布连线变更回调(DagCanvas 触发 update:edges) + */ + function setEdges(newEdges: DagEdge[]): void { + edges.value = newEdges; + dirty.value = true; + } + + /** + * 向画布添加节点(drop 创建时调用) + */ + function addNode(node: DagNode): void { + nodes.value = [...nodes.value, node]; + dirty.value = true; + } + + /** + * 清理 store(离开编辑页时调用) + */ + function reset(): void { + meta.value = { + id: 0, + name: '', + type: 'SCENE', + status: 1, + debugMode: false, + description: '', + priority: 0, + }; + nodes.value = []; + edges.value = []; + dirty.value = false; + loading.value = false; + saving.value = false; + deploying.value = false; + } + + /** + * 从列表项数据快速填充 meta(不加载图谱,用于编辑页头部展示) + */ + function applyListItem(item: RuleChainRespVO): void { + meta.value = { + id: item.id, + name: item.name, + type: item.type, + status: item.status === 'ENABLED' ? 1 : 0, + debugMode: item.debugMode, + description: item.description ?? '', + priority: item.priority ?? 0, + subsystemId: item.subsystemId, + }; + } + + return { + // state + meta, + nodes, + edges, + dirty, + loading, + saving, + deploying, + // actions + load, + initNew, + save, + deploy, + updateMeta, + setNodes, + setEdges, + addNode, + reset, + applyListItem, + }; +});