[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:
lzh
2026-04-23 23:50:23 +08:00
parent 7b1a0132d0
commit 27f9b06d12
8 changed files with 2046 additions and 0 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -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": "返回列表"
}
}
}
}

View File

@@ -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;

View File

@@ -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: '新链' });
// 强制写 dirtyinitNew 后 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);
});
});

View 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'),
};

View File

@@ -0,0 +1,525 @@
<script lang="ts" setup>
/**
* F7 — 规则链编辑页
*
* 布局:
* Header: [名称] [状态开关] [保存] [发布] [返回]
* ┌──────────┬─────────────────────────┬──────────────┐
* │ DagNode │ DagCanvas │ DagProperty │
* │ Panel │ (drop 创建新节点) │ Panel │
* │ (左侧) │ (中间) │ (右侧) │
* └──────────┴─────────────────────────┴──────────────┘
*
* Known Pitfalls 落地:
* - ⚠️ drop 兼容性dragover 必须 preventDefault 才允许 dropFirefox
* - ⚠️ 临时 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 到 mockDagPropertyPanel 内已处理)
});
}
});
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 必须 preventDefaultFirefox 才允许触发 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>

View 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>

View File

@@ -0,0 +1,283 @@
/**
* F7 — 规则链编辑页 Pinia Store
*
* 职责:
* - 持有当前编辑的规则链元信息meta+ 画布节点/连线
* - dirty 标记:有未保存变更时置 true离开前提示
* - 提供 load / save / deploy 动作(封装 API 调用)
*
* 注意:画布的 undo/redo 由 useDagStateDagCanvas 内部)管理,
* 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,
};
});