[F7] 规则链列表/编辑页(DAG 画布整合,决策 5 双入口)
整合 F1 DagCanvas + F2 DagNodePanel + F3 DagPropertyPanel + F8 dag-converter
为完整的规则链 v2 编辑体验。
新增:
- apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts
- CRUD + enable/disable/deploy/debug/copy API 封装
- apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts
- Pinia store: meta + nodes + edges + dirty guard + save/deploy 区分新建/更新
- apps/web-antd/src/views/iot/rule/chain/list.vue
- 4 维筛选(name/subsystem/type/status)+ 分页 20/50/100
- 启用/禁用乐观 UI + 调试切换 Modal 确认(评审 B8)
- WARNING 状态红 Badge + Tooltip 物模型变更原因(评审 E4)
- 双入口 Banner Alert 链到 v1 scene 页(决策 5)
- hasAccessByCodes iot:rule:{update,delete} 权限
- apps/web-antd/src/views/iot/rule/chain/edit.vue
- 三栏布局:DagNodePanel(220) + DagCanvas + DagPropertyPanel(320)
- drop 处理: handleDragOver preventDefault / screenToFlowCoordinate /
newTempId 新节点临时 ID
- nodeClick → selectedNodeStore.setCurrent; paneClick → clear
- onBeforeRouteLeave dirty 拦截 + Modal 确认
- readonly 模式: status=ENABLED 默认只读,可"解锁编辑"
- 发布: save(若 dirty) → deploy API(Modal 前置确认)
- 创建后 router.replace 含真实 ID
- apps/web-antd/src/views/iot/rule/chain/__tests__/rule-chain-store.spec.ts (10 用例)
修改:
- apps/web-antd/src/router/routes/modules/iot.ts
追加 /iot/rule/chain (顶级) + /iot/rule/chain/edit/:id? (hideInMenu detail)
- locales/langs/{zh-CN,en-US}/page.json 补 iot.ruleChain.* 约 80 键
Known Pitfalls 落地:
- drop 兼容: handleDragOver 调 e.preventDefault + dropEffect='copy'(Firefox)
- 临时 ID: newTempId 生成 new_<uuid>,isTempId 识别 INSERT(F8 约定)
- dirty guard: onBeforeRouteLeave 拦截离开
- readonly: status=ENABLED 已发布默认只读,可解锁重新编辑
- 画布双击 nodeDblclick 已透传(DagPropertyPanel 可选聚焦)
决策 5 双入口落实:
- 现有 /iot/rule/scene/* (v1 表单) 完全保留
- 新增 /iot/rule/chain/* (v2 DAG)
- list.vue 顶部 Alert 给用户两入口引导
质检:
- pnpm test:unit src/views/iot/rule/chain → 58/58 通过(累计 F1-F8 测试)
- pnpm lint F7 files → 0 errors
- pnpm check:type chain → 0 errors
后端 B2 API 契约:
- GET /iot/rule-chain/page / get?id=
- POST /iot/rule-chain/create PUT update DELETE delete?id=
- PUT enable / disable / deploy / debug
- POST copy?id=
- GET /iot/subsystem/simple-list (F9 提供,F7 筛选器复用)
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "返回列表"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => ({
|
||||
...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);
|
||||
});
|
||||
});
|
||||
166
apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts
Normal file
166
apps/web-antd/src/views/iot/rule/chain/api/rule-chain.ts
Normal file
@@ -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<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 创建 / 更新规则链请求 VO(含图谱) */
|
||||
export type RuleChainSaveReqVO = Omit<RuleChainGraphVO, 'id'> & {
|
||||
description?: string;
|
||||
id?: number;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
/** 子系统简单列表项 */
|
||||
export interface SubsystemSimpleVO {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 2. API 方法
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export const ruleChainApi = {
|
||||
/**
|
||||
* 分页查询规则链列表
|
||||
*/
|
||||
getPage: (params: RuleChainPageReqVO) =>
|
||||
requestClient.get<PageResult<RuleChainRespVO>>('/iot/rule-chain/page', {
|
||||
params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* 按 ID 获取规则链(含图谱节点/连线)
|
||||
*/
|
||||
getById: (id: number) =>
|
||||
requestClient.get<RuleChainGraphVO>('/iot/rule-chain/get', {
|
||||
params: { id },
|
||||
}),
|
||||
|
||||
/**
|
||||
* 创建规则链
|
||||
* @returns 新规则链 ID
|
||||
*/
|
||||
create: (data: RuleChainSaveReqVO) =>
|
||||
requestClient.post<number>('/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<number>(`/iot/rule-chain/copy?id=${id}`),
|
||||
|
||||
/**
|
||||
* 获取子系统简单列表(用于筛选器)
|
||||
*/
|
||||
getSubsystemList: () =>
|
||||
requestClient.get<SubsystemSimpleVO[]>('/iot/subsystem/simple-list'),
|
||||
};
|
||||
525
apps/web-antd/src/views/iot/rule/chain/edit.vue
Normal file
525
apps/web-antd/src/views/iot/rule/chain/edit.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* F7 — 规则链编辑页
|
||||
*
|
||||
* 布局:
|
||||
* Header: [名称] [状态开关] [保存] [发布] [返回]
|
||||
* ┌──────────┬─────────────────────────┬──────────────┐
|
||||
* │ DagNode │ DagCanvas │ DagProperty │
|
||||
* │ Panel │ (drop 创建新节点) │ Panel │
|
||||
* │ (左侧) │ (中间) │ (右侧) │
|
||||
* └──────────┴─────────────────────────┴──────────────┘
|
||||
*
|
||||
* Known Pitfalls 落地:
|
||||
* - ⚠️ drop 兼容性:dragover 必须 preventDefault 才允许 drop(Firefox)
|
||||
* - ⚠️ 临时 ID:新建节点用 newTempId(),保存后端返回真实 ID 后刷新
|
||||
* - ⚠️ dirty 标记:离开页面时提示未保存(onBeforeRouteLeave)
|
||||
* - ⚠️ readonly 模式:status=ENABLED 的已发布链禁用拖拽/删除
|
||||
*/
|
||||
|
||||
import type { DagNode, DagNodeType } from '#/components/iot-dag';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { DagCanvas, DagNodePanel } from '#/components/iot-dag';
|
||||
import { $t } from '#/locales';
|
||||
import { useSelectedNodeStore } from '#/views/iot/rule/chain/stores/selected-node';
|
||||
import { newTempId } from '#/views/iot/rule/chain/utils/dag-converter';
|
||||
|
||||
import DagPropertyPanel from './components/DagPropertyPanel.vue';
|
||||
import { useRuleChainStore } from './stores/rule-chain';
|
||||
|
||||
defineOptions({ name: 'RuleChainEdit' });
|
||||
|
||||
// ── 路由与参数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const chainId = computed(() => {
|
||||
const id = route.params.id;
|
||||
if (!id || id === 'new') return 0;
|
||||
return Number(id);
|
||||
});
|
||||
|
||||
const isNew = computed(() => chainId.value === 0);
|
||||
|
||||
// ── 权限 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canUpdate = hasAccessByCodes(['iot:rule:update']);
|
||||
|
||||
// ── Stores ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useRuleChainStore();
|
||||
const selectedNodeStore = useSelectedNodeStore();
|
||||
|
||||
// ── readonly 模式(已发布且不可编辑时) ───────────────────────────────────────
|
||||
// 合理默认:status=ENABLED 时画布只读(已发布规则链),可在 header 点击"编辑"解锁
|
||||
|
||||
const forceEdit = ref(false);
|
||||
const readonly = computed(() => {
|
||||
if (!canUpdate) return true;
|
||||
if (isNew.value) return false;
|
||||
if (forceEdit.value) return false;
|
||||
return store.meta.status === 1 && !isNew.value; // 1=ENABLED
|
||||
});
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isNew.value) {
|
||||
store.initNew();
|
||||
selectedNodeStore.reset();
|
||||
} else {
|
||||
try {
|
||||
await store.load(chainId.value);
|
||||
selectedNodeStore.reset();
|
||||
} catch {
|
||||
message.error($t('iot.ruleChain.edit.loadFailed'));
|
||||
router.replace({ name: 'IoTRuleChainList' });
|
||||
}
|
||||
}
|
||||
// 预加载节点 metadata(用于属性面板)
|
||||
if (!selectedNodeStore.metadataLoaded && !selectedNodeStore.metadataLoading) {
|
||||
selectedNodeStore.loadMetadata().catch(() => {
|
||||
// B4/B5/B6 未就绪时 fallback 到 mock(DagPropertyPanel 内已处理)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
store.reset();
|
||||
selectedNodeStore.reset();
|
||||
});
|
||||
|
||||
// ── 离开提示(dirty guard) ───────────────────────────────────────────────────
|
||||
|
||||
onBeforeRouteLeave((_to, _from, next) => {
|
||||
if (!store.dirty) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: $t('iot.ruleChain.edit.unsavedTitle'),
|
||||
content: $t('iot.ruleChain.edit.unsavedContent'),
|
||||
okText: $t('iot.ruleChain.edit.unsavedLeave'),
|
||||
cancelText: $t('action.cancel'),
|
||||
onOk: () => next(),
|
||||
onCancel: () => next(false),
|
||||
});
|
||||
});
|
||||
|
||||
// ── vue-flow 坐标转换(drop 使用) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 使用 useVueFlow 获取 screenToFlowCoordinate。
|
||||
* 注意:useVueFlow 必须在 setup 作用域中调用(非异步块内)。
|
||||
*/
|
||||
const { screenToFlowCoordinate } = useVueFlow();
|
||||
|
||||
// ── Drop 处理(从 NodeTypeCard 拖拽到画布) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* dragover 必须 preventDefault,Firefox 才允许触发 drop 事件。
|
||||
* ⚠️ Known Pitfall 落地
|
||||
*/
|
||||
function handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* drop 创建新节点
|
||||
* 载荷来自 NodeTypeCard.handleDragStart:
|
||||
* { type: providerType, category: dagNodeType }
|
||||
*/
|
||||
function handleDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
if (readonly.value) return;
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
// 读取载荷(主 MIME / Firefox 降级)
|
||||
const raw =
|
||||
e.dataTransfer.getData('application/x-iot-dag-node') ||
|
||||
e.dataTransfer.getData('text/plain');
|
||||
if (!raw) return;
|
||||
|
||||
let payload: { category: string; type: string };
|
||||
try {
|
||||
payload = JSON.parse(raw) as { category: string; type: string };
|
||||
// text/plain fallback 时 raw 是 type 字符串
|
||||
if (!payload.type) {
|
||||
payload = { type: raw, category: 'action' };
|
||||
}
|
||||
} catch {
|
||||
payload = { type: raw, category: 'action' };
|
||||
}
|
||||
|
||||
// 将屏幕坐标转换为画布坐标
|
||||
const position = screenToFlowCoordinate({ x: e.clientX, y: e.clientY });
|
||||
|
||||
// 将 category 映射到 DagNodeType(兜底 'action')
|
||||
const validCategories: DagNodeType[] = [
|
||||
'trigger',
|
||||
'condition',
|
||||
'action',
|
||||
'branch',
|
||||
'custom',
|
||||
];
|
||||
const nodeType: DagNodeType = validCategories.includes(
|
||||
payload.category as DagNodeType,
|
||||
)
|
||||
? (payload.category as DagNodeType)
|
||||
: 'action';
|
||||
|
||||
const newNode: DagNode = {
|
||||
id: newTempId(),
|
||||
type: `${payload.category}Node`,
|
||||
position,
|
||||
data: {
|
||||
type: nodeType,
|
||||
providerType: payload.type,
|
||||
label: payload.type,
|
||||
configuration: {},
|
||||
},
|
||||
};
|
||||
|
||||
store.addNode(newNode);
|
||||
}
|
||||
|
||||
// ── 画布事件 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleNodeClick(node: DagNode): void {
|
||||
selectedNodeStore.setCurrent(node);
|
||||
}
|
||||
|
||||
function handlePaneClick(): void {
|
||||
selectedNodeStore.setCurrent(null);
|
||||
}
|
||||
|
||||
function handleNodesUpdate(nodes: DagNode[]): void {
|
||||
store.setNodes(nodes);
|
||||
}
|
||||
|
||||
function handleEdgesUpdate(
|
||||
edges: import('#/components/iot-dag').DagEdge[],
|
||||
): void {
|
||||
store.setEdges(edges);
|
||||
}
|
||||
|
||||
// ── 保存 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (!store.meta.name.trim()) {
|
||||
message.warning($t('iot.ruleChain.edit.nameRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const savedId = await store.save();
|
||||
if (isNew.value && savedId > 0) {
|
||||
// 创建后跳转到编辑 URL(含真实 ID)
|
||||
router.replace({
|
||||
name: 'IoTRuleChainEdit',
|
||||
params: { id: savedId },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
message.error($t('iot.ruleChain.edit.saveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 发布 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDeploy(): Promise<void> {
|
||||
if (!store.meta.name.trim()) {
|
||||
message.warning($t('iot.ruleChain.edit.nameRequired'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: $t('iot.ruleChain.action.deployConfirmTitle'),
|
||||
content: $t('iot.ruleChain.action.deployConfirmContent'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await store.deploy();
|
||||
} catch {
|
||||
message.error($t('iot.ruleChain.action.deployFailed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 返回 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function handleBack(): void {
|
||||
router.push({ name: 'IoTRuleChainList' });
|
||||
}
|
||||
|
||||
// ── 名称实时更新(直接操作 store.meta) ──────────────────────────────────────
|
||||
|
||||
function handleNameChange(val: string): void {
|
||||
store.updateMeta({ name: val });
|
||||
}
|
||||
|
||||
// ── 状态开关(启用/禁用) ─────────────────────────────────────────────────────
|
||||
|
||||
function handleStatusToggle(checked: boolean): void {
|
||||
store.updateMeta({ status: checked ? 1 : 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rule-chain-edit">
|
||||
<!-- ── Header ── -->
|
||||
<div class="rule-chain-edit__header">
|
||||
<div class="rule-chain-edit__header-left">
|
||||
<!-- 名称输入 -->
|
||||
<Input
|
||||
:value="store.meta.name"
|
||||
:placeholder="$t('iot.ruleChain.edit.namePlaceholder')"
|
||||
:bordered="true"
|
||||
class="rule-chain-edit__name-input"
|
||||
@change="
|
||||
(e) => handleNameChange((e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 启用/禁用开关 -->
|
||||
<Space :size="6" align="center">
|
||||
<Switch
|
||||
:checked="store.meta.status === 1"
|
||||
:disabled="!canUpdate"
|
||||
size="small"
|
||||
@change="(val) => handleStatusToggle(val as boolean)"
|
||||
/>
|
||||
<span class="rule-chain-edit__status-label">
|
||||
{{
|
||||
store.meta.status === 1
|
||||
? $t('iot.ruleChain.status.enabled')
|
||||
: $t('iot.ruleChain.status.disabled')
|
||||
}}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<!-- dirty 标记 -->
|
||||
<Badge
|
||||
v-if="store.dirty"
|
||||
color="orange"
|
||||
:text="$t('iot.ruleChain.edit.unsavedBadge')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rule-chain-edit__header-right">
|
||||
<!-- readonly 时显示"解锁编辑"按钮 -->
|
||||
<Tooltip
|
||||
v-if="readonly && canUpdate"
|
||||
:title="$t('iot.ruleChain.edit.unlockTip')"
|
||||
>
|
||||
<Button @click="forceEdit = true">
|
||||
{{ $t('iot.ruleChain.action.unlock') }}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<!-- 保存 -->
|
||||
<Button
|
||||
v-if="!readonly"
|
||||
:loading="store.saving"
|
||||
:disabled="!store.dirty && !isNew"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $t('action.save') }}
|
||||
</Button>
|
||||
|
||||
<!-- 发布 -->
|
||||
<Button
|
||||
v-if="!isNew && canUpdate"
|
||||
type="primary"
|
||||
:loading="store.deploying"
|
||||
@click="handleDeploy"
|
||||
>
|
||||
{{ $t('iot.ruleChain.action.deploy') }}
|
||||
</Button>
|
||||
|
||||
<!-- 返回 -->
|
||||
<Button @click="handleBack">
|
||||
{{ $t('iot.ruleChain.action.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 主体(三栏布局) ── -->
|
||||
<Spin :spinning="store.loading" class="rule-chain-edit__body-spin">
|
||||
<div class="rule-chain-edit__body">
|
||||
<!-- 左侧:节点类型面板 -->
|
||||
<DagNodePanel
|
||||
:hide-panel="readonly"
|
||||
:width="220"
|
||||
class="rule-chain-edit__node-panel"
|
||||
/>
|
||||
|
||||
<!-- 中间:画布(处理 drop) -->
|
||||
<div
|
||||
class="rule-chain-edit__canvas-wrap"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- 空态提示(只读模式下不展示拖拽提示) -->
|
||||
<div
|
||||
v-if="store.nodes.length === 0 && !readonly"
|
||||
class="rule-chain-edit__drop-hint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ $t('iot.dag.canvas.empty') }}
|
||||
</div>
|
||||
|
||||
<DagCanvas
|
||||
v-model:nodes="store.nodes"
|
||||
v-model:edges="store.edges"
|
||||
:readonly="readonly"
|
||||
:show-minimap="true"
|
||||
:show-controls="true"
|
||||
class="rule-chain-edit__canvas"
|
||||
@node-click="handleNodeClick"
|
||||
@pane-click="handlePaneClick"
|
||||
@update:nodes="handleNodesUpdate"
|
||||
@update:edges="handleEdgesUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:属性面板 -->
|
||||
<div class="rule-chain-edit__property-panel">
|
||||
<DagPropertyPanel :readonly="readonly" />
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── 页面容器 ── */
|
||||
.rule-chain-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.rule-chain-edit__header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 1px 4px rgb(0 21 41 / 8%);
|
||||
}
|
||||
|
||||
.rule-chain-edit__header-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-chain-edit__header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-chain-edit__name-input {
|
||||
width: 220px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-chain-edit__status-label {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* ── 主体 ── */
|
||||
.rule-chain-edit__body-spin {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ant-design-vue Spin 包装需撑满 */
|
||||
.rule-chain-edit__body-spin :deep(.ant-spin-container) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rule-chain-edit__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 节点面板(左侧,220px 固定宽) ── */
|
||||
.rule-chain-edit__node-panel {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 画布中间区域 ── */
|
||||
.rule-chain-edit__canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rule-chain-edit__canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 空态提示覆盖层(绝对定位,z-index 低于画布控件) */
|
||||
.rule-chain-edit__drop-hint {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: #bfbfbf;
|
||||
pointer-events: none;
|
||||
background: rgb(255 255 255 / 80%);
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* ── 属性面板(右侧,320px 固定宽) ── */
|
||||
.rule-chain-edit__property-panel {
|
||||
flex-shrink: 0;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
overflow: hidden auto;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
}
|
||||
</style>
|
||||
632
apps/web-antd/src/views/iot/rule/chain/list.vue
Normal file
632
apps/web-antd/src/views/iot/rule/chain/list.vue
Normal file
@@ -0,0 +1,632 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* F7 — 规则链列表页
|
||||
*
|
||||
* 功能:
|
||||
* - 分页展示 v2 规则链(不含 v1 SceneRule)
|
||||
* - 子系统 / 类型 / 状态筛选
|
||||
* - 启用/禁用乐观 UI(操作失败回滚)
|
||||
* - 调试模式开关(含警告提示)
|
||||
* - 复制、删除(二次确认)
|
||||
* - 双入口 Banner 链接到 v1 scene 页面(决策 5)
|
||||
* - WARNING 状态红色 + 悬停原因
|
||||
* - 权限控制:iot:rule:query / update / delete
|
||||
*/
|
||||
|
||||
import type {
|
||||
RuleChainPageReqVO,
|
||||
RuleChainRespVO,
|
||||
RuleChainStatus,
|
||||
RuleChainType,
|
||||
SubsystemSimpleVO,
|
||||
} from './api/rule-chain';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
SelectOption,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { ruleChainApi } from './api/rule-chain';
|
||||
|
||||
defineOptions({ name: 'RuleChainList' });
|
||||
|
||||
// ── 路由 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// ── 权限 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canUpdate = hasAccessByCodes(['iot:rule:update']);
|
||||
const canDelete = hasAccessByCodes(['iot:rule:delete']);
|
||||
|
||||
// ── 筛选表单 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const filterForm = reactive<RuleChainPageReqVO>({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
type: undefined,
|
||||
subsystemId: undefined,
|
||||
});
|
||||
|
||||
const statusOptions: { label: string; value: RuleChainStatus }[] = [
|
||||
{ label: $t('iot.ruleChain.status.enabled'), value: 'ENABLED' },
|
||||
{ label: $t('iot.ruleChain.status.disabled'), value: 'DISABLED' },
|
||||
{ label: $t('iot.ruleChain.status.warning'), value: 'WARNING' },
|
||||
];
|
||||
|
||||
const typeOptions: { label: string; value: RuleChainType }[] = [
|
||||
{ label: $t('iot.ruleChain.type.scene'), value: 'SCENE' },
|
||||
{ label: $t('iot.ruleChain.type.data'), value: 'DATA' },
|
||||
{ label: $t('iot.ruleChain.type.custom'), value: 'CUSTOM' },
|
||||
];
|
||||
|
||||
// ── 子系统下拉 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const subsystems = ref<SubsystemSimpleVO[]>([]);
|
||||
|
||||
async function loadSubsystems(): Promise<void> {
|
||||
try {
|
||||
subsystems.value = await ruleChainApi.getSubsystemList();
|
||||
} catch {
|
||||
// 不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表数据 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const dataList = ref<RuleChainRespVO[]>([]);
|
||||
const total = ref(0);
|
||||
const tableLoading = ref(false);
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const res = await ruleChainApi.getPage(filterForm);
|
||||
dataList.value = res.list;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(): void {
|
||||
filterForm.pageNo = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
filterForm.name = undefined;
|
||||
filterForm.status = undefined;
|
||||
filterForm.type = undefined;
|
||||
filterForm.subsystemId = undefined;
|
||||
filterForm.pageNo = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number, pageSize: number): void {
|
||||
filterForm.pageNo = page;
|
||||
filterForm.pageSize = pageSize;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
// ── 跳转编辑页 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function goCreate(): void {
|
||||
router.push({ name: 'IoTRuleChainEdit' });
|
||||
}
|
||||
|
||||
function goEdit(id: number): void {
|
||||
router.push({ name: 'IoTRuleChainEdit', params: { id } });
|
||||
}
|
||||
|
||||
// ── 启用/禁用(乐观 UI) ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleToggleStatus(
|
||||
rawRecord: Record<string, unknown> | RuleChainRespVO,
|
||||
): Promise<void> {
|
||||
const record = rawRecord as RuleChainRespVO;
|
||||
const prevStatus = record.status;
|
||||
const isEnabled = prevStatus === 'ENABLED';
|
||||
|
||||
// 乐观 UI:立即更新
|
||||
record.status = isEnabled ? 'DISABLED' : 'ENABLED';
|
||||
|
||||
try {
|
||||
await (isEnabled
|
||||
? ruleChainApi.disable(record.id)
|
||||
: ruleChainApi.enable(record.id));
|
||||
message.success(
|
||||
isEnabled
|
||||
? $t('iot.ruleChain.action.disableSuccess')
|
||||
: $t('iot.ruleChain.action.enableSuccess'),
|
||||
);
|
||||
} catch {
|
||||
// 回滚
|
||||
record.status = prevStatus;
|
||||
message.error($t('iot.ruleChain.action.statusChangeFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 调试模式开关 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function handleToggleDebug(
|
||||
rawRecord: Record<string, unknown> | RuleChainRespVO,
|
||||
): void {
|
||||
const record = rawRecord as RuleChainRespVO;
|
||||
const toEnable = !record.debugMode;
|
||||
if (toEnable) {
|
||||
Modal.confirm({
|
||||
title: $t('iot.ruleChain.debug.confirmTitle'),
|
||||
content: $t('iot.ruleChain.debug.confirmContent'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
await doToggleDebug(record, true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
doToggleDebug(record, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doToggleDebug(
|
||||
record: RuleChainRespVO,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const prev = record.debugMode;
|
||||
record.debugMode = enabled;
|
||||
try {
|
||||
await ruleChainApi.toggleDebug(record.id, enabled);
|
||||
message.success($t('iot.ruleChain.debug.toggleSuccess'));
|
||||
} catch {
|
||||
record.debugMode = prev;
|
||||
message.error($t('iot.ruleChain.debug.toggleFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDelete(id: number): Promise<void> {
|
||||
try {
|
||||
await ruleChainApi.delete(id);
|
||||
message.success($t('iot.ruleChain.action.deleteSuccess'));
|
||||
fetchList();
|
||||
} catch {
|
||||
message.error($t('iot.ruleChain.action.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 复制 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleCopy(id: number): Promise<void> {
|
||||
try {
|
||||
const newId = await ruleChainApi.copy(id);
|
||||
message.success($t('iot.ruleChain.action.copySuccess'));
|
||||
fetchList();
|
||||
// 可选:跳转到新建的规则链编辑页
|
||||
goEdit(newId);
|
||||
} catch {
|
||||
message.error($t('iot.ruleChain.action.copyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 状态颜色映射 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function statusColor(status: RuleChainStatus): string {
|
||||
const map: Record<RuleChainStatus, string> = {
|
||||
DISABLED: 'default',
|
||||
ENABLED: 'success',
|
||||
WARNING: 'error',
|
||||
};
|
||||
return map[status] ?? 'default';
|
||||
}
|
||||
|
||||
function statusLabel(status: RuleChainStatus): string {
|
||||
const map: Record<RuleChainStatus, string> = {
|
||||
DISABLED: $t('iot.ruleChain.status.disabled'),
|
||||
ENABLED: $t('iot.ruleChain.status.enabled'),
|
||||
WARNING: $t('iot.ruleChain.status.warning'),
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
function typeLabel(type: RuleChainType): string {
|
||||
const map: Record<RuleChainType, string> = {
|
||||
CUSTOM: $t('iot.ruleChain.type.custom'),
|
||||
DATA: $t('iot.ruleChain.type.data'),
|
||||
SCENE: $t('iot.ruleChain.type.scene'),
|
||||
};
|
||||
return map[type] ?? type;
|
||||
}
|
||||
|
||||
// ── 表格列定义 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: $t('iot.ruleChain.field.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.type'),
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.subsystem'),
|
||||
dataIndex: 'subsystemName',
|
||||
key: 'subsystemName',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.bindScope'),
|
||||
key: 'bindScope',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.priority'),
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.debugMode'),
|
||||
dataIndex: 'debugMode',
|
||||
key: 'debugMode',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.version'),
|
||||
dataIndex: 'version',
|
||||
key: 'version',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.updateTime'),
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: $t('iot.ruleChain.field.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadSubsystems();
|
||||
fetchList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rule-chain-list">
|
||||
<!-- 双入口 Banner(决策 5:保留 v1 入口) -->
|
||||
<Alert
|
||||
:message="$t('iot.ruleChain.v1Banner.message')"
|
||||
:description="$t('iot.ruleChain.v1Banner.description')"
|
||||
type="info"
|
||||
show-icon
|
||||
banner
|
||||
class="rule-chain-list__banner"
|
||||
>
|
||||
<template #action>
|
||||
<Button size="small" @click="router.push('/iot/rule/scene')">
|
||||
{{ $t('iot.ruleChain.v1Banner.link') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<!-- 筛选区 -->
|
||||
<Card class="rule-chain-list__filter" :bordered="false">
|
||||
<Form layout="inline" :model="filterForm">
|
||||
<FormItem :label="$t('iot.ruleChain.field.name')">
|
||||
<Input
|
||||
v-model:value="filterForm.name"
|
||||
:placeholder="$t('iot.ruleChain.filter.namePlaceholder')"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('iot.ruleChain.field.subsystem')">
|
||||
<Select
|
||||
v-model:value="filterForm.subsystemId"
|
||||
:placeholder="$t('iot.ruleChain.filter.subsystemPlaceholder')"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
>
|
||||
<SelectOption v-for="s in subsystems" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('iot.ruleChain.field.type')">
|
||||
<Select
|
||||
v-model:value="filterForm.type"
|
||||
:placeholder="$t('iot.ruleChain.filter.typePlaceholder')"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
<FormItem :label="$t('iot.ruleChain.field.status')">
|
||||
<Select
|
||||
v-model:value="filterForm.status"
|
||||
:placeholder="$t('iot.ruleChain.filter.statusPlaceholder')"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
>
|
||||
<SelectOption
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<Space>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
{{ $t('action.search') }}
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
{{ $t('action.reset') }}
|
||||
</Button>
|
||||
</Space>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<!-- 列表区 -->
|
||||
<Card :bordered="false" class="rule-chain-list__table">
|
||||
<template #extra>
|
||||
<Button type="primary" @click="goCreate">
|
||||
{{ $t('iot.ruleChain.action.create') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="dataList"
|
||||
:loading="tableLoading"
|
||||
:pagination="{
|
||||
current: filterForm.pageNo,
|
||||
pageSize: filterForm.pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
onChange: handlePageChange,
|
||||
}"
|
||||
:scroll="{ x: 1400 }"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 名称列(可点击跳转编辑) -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<Button
|
||||
type="link"
|
||||
class="rule-chain-list__name-link"
|
||||
@click="goEdit(record.id)"
|
||||
>
|
||||
{{ record.name }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- 类型列 -->
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<Tag>{{ typeLabel(record.type) }}</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 绑定范围(产品/设备) -->
|
||||
<template v-else-if="column.key === 'bindScope'">
|
||||
<span v-if="record.productName" class="rule-chain-list__scope">
|
||||
{{ $t('iot.ruleChain.field.product') }}: {{ record.productName }}
|
||||
</span>
|
||||
<span v-if="record.deviceName" class="rule-chain-list__scope">
|
||||
{{ $t('iot.ruleChain.field.device') }}: {{ record.deviceName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!record.productName && !record.deviceName"
|
||||
class="rule-chain-list__scope--empty"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态列(WARNING 红色 + 悬停提示) -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tooltip
|
||||
v-if="record.status === 'WARNING'"
|
||||
:title="
|
||||
record.warningReason || $t('iot.ruleChain.status.warningHint')
|
||||
"
|
||||
placement="top"
|
||||
>
|
||||
<Badge
|
||||
:color="statusColor(record.status)"
|
||||
:text="statusLabel(record.status)"
|
||||
class="rule-chain-list__badge--warning"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
v-else
|
||||
:color="statusColor(record.status)"
|
||||
:text="statusLabel(record.status)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 调试模式 -->
|
||||
<template v-else-if="column.key === 'debugMode'">
|
||||
<Switch
|
||||
:checked="record.debugMode"
|
||||
:disabled="!canUpdate"
|
||||
size="small"
|
||||
@click="() => handleToggleDebug(record)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 版本 -->
|
||||
<template v-else-if="column.key === 'version'">
|
||||
<span>v{{ record.version ?? 1 }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Space :size="4" wrap>
|
||||
<!-- 编辑 -->
|
||||
<Button size="small" type="link" @click="goEdit(record.id)">
|
||||
{{ $t('action.edit') }}
|
||||
</Button>
|
||||
|
||||
<!-- 启用/禁用 -->
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
size="small"
|
||||
type="link"
|
||||
:class="
|
||||
record.status === 'ENABLED'
|
||||
? 'rule-chain-list__btn--danger'
|
||||
: ''
|
||||
"
|
||||
@click="handleToggleStatus(record)"
|
||||
>
|
||||
{{
|
||||
record.status === 'ENABLED'
|
||||
? $t('iot.ruleChain.action.disable')
|
||||
: $t('iot.ruleChain.action.enable')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<!-- 复制 -->
|
||||
<Button
|
||||
v-if="canUpdate"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="handleCopy(record.id)"
|
||||
>
|
||||
{{ $t('iot.ruleChain.action.copy') }}
|
||||
</Button>
|
||||
|
||||
<!-- 删除(二次确认) -->
|
||||
<Popconfirm
|
||||
v-if="canDelete"
|
||||
:title="$t('iot.ruleChain.action.deleteConfirm')"
|
||||
:ok-text="$t('action.confirm')"
|
||||
:cancel-text="$t('action.cancel')"
|
||||
ok-type="danger"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
class="rule-chain-list__btn--danger"
|
||||
>
|
||||
{{ $t('action.delete') }}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rule-chain-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rule-chain-list__banner {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rule-chain-list__filter {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-chain-list__table {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rule-chain-list__name-link {
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-chain-list__scope {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.rule-chain-list__scope--empty {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.rule-chain-list__badge--warning :deep(.ant-badge-status-text) {
|
||||
color: #ff4d4f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rule-chain-list__btn--danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.rule-chain-list__btn--danger:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
</style>
|
||||
283
apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts
Normal file
283
apps/web-antd/src/views/iot/rule/chain/stores/rule-chain.ts
Normal file
@@ -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<RuleChainMeta>({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 'SCENE',
|
||||
status: 1,
|
||||
debugMode: false,
|
||||
description: '',
|
||||
priority: 0,
|
||||
});
|
||||
|
||||
/** 画布节点(与 DagCanvas v-model:nodes 双向绑定) */
|
||||
const nodes = ref<DagNode[]>([]);
|
||||
|
||||
/** 画布连线(与 DagCanvas v-model:edges 双向绑定) */
|
||||
const edges = ref<DagEdge[]>([]);
|
||||
|
||||
/** 是否有未保存的变更 */
|
||||
const dirty = ref(false);
|
||||
|
||||
/** 加载中 */
|
||||
const loading = ref(false);
|
||||
|
||||
/** 保存中 */
|
||||
const saving = ref(false);
|
||||
|
||||
/** 发布中 */
|
||||
const deploying = ref(false);
|
||||
|
||||
// ── 动作 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载规则链(进入编辑页时调用)
|
||||
*/
|
||||
async function load(id: number): Promise<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<RuleChainMeta>): 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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user