From 7b1a0132d0692b1488b02e5155a1b948eb20f9d6 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 23:12:11 +0800 Subject: [PATCH] =?UTF-8?q?[F3]=20DAG=20=E7=94=BB=E5=B8=83=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E9=9D=A2=E6=9D=BF=20+=20=E5=8A=A8=E6=80=81=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主会话(Opus)自做,覆盖任务卡 §2 全部文件。 核心文件: - apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts - GET /iot/rule/provider/metadata API 封装 - ProviderMetadata/FormCreateSchema/FormCreateRuleItem 类型定义 - apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts - Pinia store: current 节点 + dirty 标记 + metadata 缓存 - metadataByType O(1) 索引 + getSchemaByType/getMetadataByType 查表 - loadMetadata fetcher 可注入(测试 + 后端未就绪 fallback 到 mock) - apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts - 7 种节点 mock schema (device_state/device_property/timer/expression/ property_set/alarm_trigger/notify) - buildMockMetadata() 供 fallback 使用 - apps/web-antd/src/views/iot/rule/chain/components/DynamicNodeForm.vue - @form-create/ant-design-vue 封装,schema 切换自动 resetFields - "i18n:" 前缀约定递归解析 title/placeholder/options.label/validate.message - 过滤 undefined 避免 configuration 写入噪声 - apps/web-antd/src/views/iot/rule/chain/components/DagPropertyPanel.vue - 面板容器:节点名称输入 + DynamicNodeForm - onMounted 加载 metadata,后端失败 fallback 到 mock(node-schema) 测试: - selected-node-store.spec.ts 13 用例(store actions / loadMetadata / getSchemaByType / reset) - node-schema.spec.ts 12 用例(mock schema 覆盖 5+ 种 / buildMockMetadata 映射) i18n: iot.dag.panel.* / iot.dag.field.* / iot.dag.option.* / iot.dag.validate.* zh-CN 与 en-US 同步新增。 Known Pitfalls 落地: - 评审 B5/H2: 模板变量用 ${data.x}/${alarm.x}/${trigger.x} 占位(notify template placeholder 字面展示,禁用旧 $[...] 语法) - form-create schema 切换时调用 fApi.resetFields 防止字段残留 - configuration 过滤 undefined 防止响应式写入 null - iot:subsystem:simple-list 权限 403 由 @vben/request 拦截器统一处理 - TS 严格模式零 any,configuration 类型 Record 决策 5 双入口: - 本任务仅新建 chain/ 目录的组件 + store + API + mock schema - 路由追加由后续任务处理(项目采用动态菜单机制,静态路由 modules/iot.ts 仅放 hideInMenu 详情页;chain 的 list/edit 菜单由后端 menu 表配置) - 现有 iot/rule/scene/* 保持不变 ✓ 质检: - pnpm test:unit src/views/iot/rule/chain → 48/48 通过 - pnpm lint apps/web-antd/src/views/iot/rule/chain/ → 0 errors - pnpm check:type (web-antd) → chain 目录 0 errors(其他模块预存错误与本任务无关) note: DagPropertyPanel + DynamicNodeForm 的渲染集成测试留给 F7 edit.vue 真实画布 dogfood;本次 unit test 覆盖纯函数与 store。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/locales/langs/en-US/page.json | 41 ++- .../src/locales/langs/zh-CN/page.json | 41 ++- .../rule/chain/__tests__/node-schema.spec.ts | 105 +++++++ .../__tests__/selected-node-store.spec.ts | 236 ++++++++++++++ .../views/iot/rule/chain/api/node-metadata.ts | 65 ++++ .../chain/components/DagPropertyPanel.vue | 163 ++++++++++ .../rule/chain/components/DynamicNodeForm.vue | 186 +++++++++++ .../chain/components/node-schema/index.ts | 292 ++++++++++++++++++ .../iot/rule/chain/stores/selected-node.ts | 123 ++++++++ 9 files changed, 1250 insertions(+), 2 deletions(-) create mode 100644 apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts create mode 100644 apps/web-antd/src/views/iot/rule/chain/__tests__/selected-node-store.spec.ts create mode 100644 apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/DagPropertyPanel.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/DynamicNodeForm.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts create mode 100644 apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index e0079da86..c27459e38 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -129,7 +129,46 @@ "trigger": "Trigger", "condition": "Condition", "action": "Action" - } + }, + "empty": "Click a node on canvas to configure", + "schemaUnknown": "Unknown node type or metadata not loaded", + "titleEmpty": "Property Panel", + "titleFallback": "Node Configuration", + "nodeLabel": "Node Name", + "nodeLabelPlaceholder": "Enter node name" + }, + "field": { + "device": "Device", + "devicePlaceholder": "Select device", + "propertyIdentifier": "Property", + "propertyPlaceholder": "Enter property identifier", + "dataType": "Data Type", + "deviceEvent": "Event", + "cron": "Cron Expression", + "timezone": "Timezone", + "expression": "Expression", + "value": "Value", + "valuePlaceholder": "Enter value", + "alarmName": "Alarm Name", + "alarmMessage": "Alarm Message", + "alarmMessagePlaceholder": "Supports ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}", + "severity": "Severity", + "notifyChannel": "Notification Channel", + "notifyRecipients": "Recipients", + "notifyRecipientsPlaceholder": "Multiple recipients separated by commas", + "notifyTemplate": "Message Template" + }, + "option": { + "online": "Online", + "offline": "Offline" + }, + "validate": { + "deviceRequired": "Please select a device", + "propertyRequired": "Please enter property identifier", + "cronRequired": "Please enter a Cron expression", + "expressionRequired": "Please enter an expression", + "valueRequired": "Please enter a value", + "alarmNameRequired": "Please enter an alarm name" }, "node": { "device_state": { diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index 042c0446e..9a466d3ad 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -129,7 +129,46 @@ "trigger": "触发器", "condition": "条件", "action": "动作" - } + }, + "empty": "点击画布节点以配置", + "schemaUnknown": "节点类型不识别或 metadata 未加载", + "titleEmpty": "属性面板", + "titleFallback": "节点配置", + "nodeLabel": "节点名称", + "nodeLabelPlaceholder": "请输入节点名称" + }, + "field": { + "device": "设备", + "devicePlaceholder": "请选择设备", + "propertyIdentifier": "属性", + "propertyPlaceholder": "请输入属性标识", + "dataType": "数据类型", + "deviceEvent": "事件", + "cron": "Cron 表达式", + "timezone": "时区", + "expression": "表达式", + "value": "值", + "valuePlaceholder": "请输入值", + "alarmName": "告警名称", + "alarmMessage": "告警内容", + "alarmMessagePlaceholder": "支持 ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}", + "severity": "严重度", + "notifyChannel": "通知渠道", + "notifyRecipients": "接收人", + "notifyRecipientsPlaceholder": "多个接收人以逗号分隔", + "notifyTemplate": "消息模板" + }, + "option": { + "online": "上线", + "offline": "下线" + }, + "validate": { + "deviceRequired": "请选择设备", + "propertyRequired": "请输入属性标识", + "cronRequired": "请输入 Cron 表达式", + "expressionRequired": "请输入表达式", + "valueRequired": "请输入值", + "alarmNameRequired": "请输入告警名称" }, "node": { "device_state": { diff --git a/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts b/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts new file mode 100644 index 000000000..13b139b18 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts @@ -0,0 +1,105 @@ +/** + * F3 — node-schema mock 映射单元测试 + * + * 覆盖: + * - getMockSchema 已知 providerType 返回非空 schema + * - getMockSchema 未知 providerType 返回 null + * - listMockProviderTypes 覆盖所有 F3 Acceptance 要求的 5+ 种节点类型 + * - buildMockMetadata 过滤无 schema 的 catalog 项 + */ + +import { describe, expect, it } from 'vitest'; + +import { + buildMockMetadata, + getMockSchema, + listMockProviderTypes, +} from '#/views/iot/rule/chain/components/node-schema'; + +describe('node-schema mock', () => { + describe('getMockSchema', () => { + it.each([ + ['device_state'], + ['device_property'], + ['timer'], + ['expression'], + ['property_set'], + ['alarm_trigger'], + ['notify'], + ])('已知类型 %s 返回非空 rule', (providerType) => { + const schema = getMockSchema(providerType); + expect(schema).not.toBeNull(); + expect(schema?.rule.length).toBeGreaterThan(0); + }); + + it('未知类型返回 null', () => { + expect(getMockSchema('not_a_real_type')).toBeNull(); + }); + }); + + describe('listMockProviderTypes', () => { + it('覆盖 Acceptance 要求的 5+ 种节点类型', () => { + const types = listMockProviderTypes(); + expect(types.length).toBeGreaterThanOrEqual(5); + // Acceptance 明列至少覆盖: device_property(触发) / + // expression(条件) / property_set(动作) / alarm_trigger / notify + expect(types).toEqual( + expect.arrayContaining([ + 'device_property', + 'expression', + 'property_set', + 'alarm_trigger', + 'notify', + ]), + ); + }); + }); + + describe('buildMockMetadata', () => { + it('根据 catalog 生成带 schema 的 ProviderMetadata 数组', () => { + const catalog = [ + { + category: 'trigger' as const, + icon: 'activity', + type: 'device_property', + description: 'desc key', + }, + { + category: 'action' as const, + icon: 'send', + type: 'notify', + }, + ]; + + const metadata = buildMockMetadata(catalog); + + expect(metadata).toHaveLength(2); + expect(metadata[0]?.type).toBe('device_property'); + expect(metadata[0]?.schema.rule.length).toBeGreaterThan(0); + expect(metadata[0]?.dagNodeType).toBe('trigger'); + expect(metadata[1]?.dagNodeType).toBe('action'); + }); + + it('过滤 catalog 中没有 mock schema 的类型', () => { + const catalog = [ + { category: 'trigger' as const, icon: 'bell', type: 'device_event' }, + { + category: 'trigger' as const, + icon: 'wrench', + type: 'device_service', + }, + ]; + // device_event / device_service 在 mock 中未定义 schema + const metadata = buildMockMetadata(catalog); + expect(metadata).toHaveLength(0); + }); + + it('category 到 dagNodeType 的映射', () => { + const catalog = [ + { category: 'condition' as const, icon: 'code', type: 'expression' }, + ]; + const metadata = buildMockMetadata(catalog); + expect(metadata[0]?.dagNodeType).toBe('condition'); + }); + }); +}); diff --git a/apps/web-antd/src/views/iot/rule/chain/__tests__/selected-node-store.spec.ts b/apps/web-antd/src/views/iot/rule/chain/__tests__/selected-node-store.spec.ts new file mode 100644 index 000000000..8f5f909d1 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/__tests__/selected-node-store.spec.ts @@ -0,0 +1,236 @@ +// @vitest-environment happy-dom +/** + * F3 — selected-node store 单元测试 + * + * 覆盖: + * - setCurrent 切换节点时 dirty 重置 + * - updateConfig / updateLabel 仅影响当前选中节点 + * - loadMetadata fetcher 注入 + metadataByType 索引 + * - getSchemaByType / getMetadataByType 查表 + * - reset 清理 + * + * 注:store 导入链经由 `#/api/request` 引入 @vben/preferences 的 StorageManager + * 其构造函数访问 window.localStorage,所以 spec 需 happy-dom 环境。 + */ + +import type { DagNode } from '#/components/iot-dag'; +import type { ProviderMetadata } from '#/views/iot/rule/chain/api/node-metadata'; + +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useSelectedNodeStore } from '#/views/iot/rule/chain/stores/selected-node'; + +// ────────────────────────────────────────────── +// 测试夹具 +// ────────────────────────────────────────────── + +function makeDagNode( + id: string, + providerType: string, + overrides?: Partial, +): DagNode { + return { + id, + type: 'triggerNode', + position: { x: 0, y: 0 }, + data: { + type: 'trigger', + providerType, + label: `节点 ${id}`, + configuration: {}, + ...overrides, + }, + }; +} + +const MOCK_METADATA: ProviderMetadata[] = [ + { + category: 'trigger', + dagNodeType: 'trigger', + icon: 'activity', + type: 'device_property', + schema: { + rule: [{ type: 'a-input', field: 'deviceId', title: '设备' }], + }, + }, + { + category: 'action', + dagNodeType: 'action', + icon: 'send', + type: 'notify', + schema: { + rule: [{ type: 'a-select', field: 'channel', title: '渠道' }], + }, + }, +]; + +// ────────────────────────────────────────────── +// 测试 +// ────────────────────────────────────────────── + +describe('selected-node store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + describe('setCurrent', () => { + it('设置当前节点并重置 dirty', () => { + const store = useSelectedNodeStore(); + const node = makeDagNode('n1', 'device_property'); + + store.setCurrent(node); + expect(store.current?.id).toBe('n1'); + expect(store.dirty).toBe(false); + }); + + it('切换节点时 dirty 重置为 false', () => { + const store = useSelectedNodeStore(); + store.setCurrent(makeDagNode('n1', 'device_property')); + store.updateConfig('n1', { deviceId: 100 }); + expect(store.dirty).toBe(true); + + store.setCurrent(makeDagNode('n2', 'notify')); + expect(store.dirty).toBe(false); + }); + + it('可清空当前选中(传 null)', () => { + const store = useSelectedNodeStore(); + store.setCurrent(makeDagNode('n1', 'device_property')); + store.setCurrent(null); + expect(store.current).toBeNull(); + }); + }); + + describe('updateConfig', () => { + it('更新当前节点 configuration 并置 dirty=true', () => { + const store = useSelectedNodeStore(); + store.setCurrent(makeDagNode('n1', 'device_property')); + + store.updateConfig('n1', { deviceId: 100, identifier: 'temp' }); + + expect(store.current?.data?.configuration).toEqual({ + deviceId: 100, + identifier: 'temp', + }); + expect(store.dirty).toBe(true); + }); + + it('nodeId 与当前选中不匹配时无副作用', () => { + const store = useSelectedNodeStore(); + store.setCurrent(makeDagNode('n1', 'device_property')); + + store.updateConfig('other-id', { deviceId: 999 }); + + expect(store.current?.data?.configuration).toEqual({}); + expect(store.dirty).toBe(false); + }); + + it('current 为 null 时无副作用', () => { + const store = useSelectedNodeStore(); + store.updateConfig('n1', { deviceId: 100 }); + expect(store.dirty).toBe(false); + }); + }); + + describe('updateLabel', () => { + it('更新当前节点 label 并置 dirty=true', () => { + const store = useSelectedNodeStore(); + store.setCurrent(makeDagNode('n1', 'device_property')); + + store.updateLabel('n1', '温度上报'); + + expect(store.current?.data?.label).toBe('温度上报'); + expect(store.dirty).toBe(true); + }); + }); + + describe('loadMetadata', () => { + it('注入 fetcher 加载 metadata 并建立 type 索引', async () => { + const store = useSelectedNodeStore(); + + await store.loadMetadata(async () => MOCK_METADATA); + + expect(store.metadataLoaded).toBe(true); + expect(store.metadataList).toHaveLength(2); + expect(store.metadataByType.get('device_property')?.icon).toBe( + 'activity', + ); + expect(store.metadataByType.get('notify')?.icon).toBe('send'); + }); + + it('fetcher 抛错时记录 error 不清空旧数据', async () => { + const store = useSelectedNodeStore(); + // 先成功加载 + await store.loadMetadata(async () => MOCK_METADATA); + expect(store.metadataLoaded).toBe(true); + + // 失败重载 + await store.loadMetadata(async () => { + throw new Error('network'); + }); + + expect(store.metadataError?.message).toBe('network'); + // 旧数据保留 + expect(store.metadataList).toHaveLength(2); + }); + + it('loading 互斥:正在加载时再次调用被忽略', async () => { + const store = useSelectedNodeStore(); + + let resolveFirst: () => void = () => {}; + const firstPromise = new Promise((resolve) => { + resolveFirst = () => resolve(MOCK_METADATA); + }); + + const p1 = store.loadMetadata(() => firstPromise); + // 第二次调用不应触发新的 fetch + let secondCalled = false; + const p2 = store.loadMetadata(async () => { + secondCalled = true; + return []; + }); + + resolveFirst(); + await Promise.all([p1, p2]); + + expect(secondCalled).toBe(false); + expect(store.metadataList).toHaveLength(2); + }); + }); + + describe('getSchemaByType / getMetadataByType', () => { + it('已加载后按 providerType 查 schema', async () => { + const store = useSelectedNodeStore(); + await store.loadMetadata(async () => MOCK_METADATA); + + const schema = store.getSchemaByType('device_property'); + expect(schema?.rule).toHaveLength(1); + expect(schema?.rule?.[0]?.field).toBe('deviceId'); + }); + + it('未知 providerType 返回 null', async () => { + const store = useSelectedNodeStore(); + await store.loadMetadata(async () => MOCK_METADATA); + + expect(store.getSchemaByType('not_a_type')).toBeNull(); + expect(store.getMetadataByType('not_a_type')).toBeNull(); + }); + }); + + describe('reset', () => { + it('清空 current 和 dirty(保留 metadata 缓存)', async () => { + const store = useSelectedNodeStore(); + await store.loadMetadata(async () => MOCK_METADATA); + store.setCurrent(makeDagNode('n1', 'device_property')); + store.updateConfig('n1', { deviceId: 1 }); + + store.reset(); + + expect(store.current).toBeNull(); + expect(store.dirty).toBe(false); + // metadata 缓存保留(避免重复加载) + expect(store.metadataList).toHaveLength(2); + }); + }); +}); diff --git a/apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts b/apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts new file mode 100644 index 000000000..1dab8fdb7 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts @@ -0,0 +1,65 @@ +/** + * F3 — 节点 Provider Metadata API + * + * 后端 B4/B5/B6 Provider SPI 暴露每个节点类型的 form-create schema。 + * 前端启动(或打开规则链编辑页)时一次性拉取,缓存到 Pinia。 + * + * 后端 B4/B5/B6 未就绪时使用 mock;对齐 F2 `useNodeCatalog` 的 NodeTypeMeta + * 并扩展 `schema.rule` 字段。 + */ + +import type { NodeTypeMeta } from '#/components/iot-dag'; + +import { requestClient } from '#/api/request'; + +// ────────────────────────────────────────────── +// 类型定义 +// ────────────────────────────────────────────── + +/** form-create rule 单项 — 与 @form-create/ant-design-vue 的 Rule 对齐(宽松定义) */ +export interface FormCreateRuleItem { + /** 组件类型(select / input / DeviceSelector 等) */ + type: string; + /** 字段名 */ + field?: string; + /** 显示标题(i18n key 或原始文案) */ + title?: string; + /** 组件 props */ + props?: Record; + /** 下拉选项 */ + options?: Array<{ label: string; value: boolean | number | string }>; + /** 校验规则(antd Form rule 风格) */ + validate?: Array>; + /** 默认值 */ + value?: unknown; + /** 子项(嵌套表单) */ + children?: FormCreateRuleItem[]; +} + +/** form-create schema(完整) */ +export interface FormCreateSchema { + /** 字段规则数组 */ + rule: FormCreateRuleItem[]; + /** 全局 option(submitBtn / resetBtn 等) */ + option?: Record; +} + +/** Provider 节点元数据(含 schema,比 NodeTypeMeta 多一层) */ +export interface ProviderMetadata extends NodeTypeMeta { + /** 动态表单 schema(form-create 配置) */ + schema: FormCreateSchema; +} + +// ────────────────────────────────────────────── +// API +// ────────────────────────────────────────────── + +/** + * 拉取所有 Provider 节点元数据(含 schema) + * + * 后端端点约定:`GET /iot/rule/provider/metadata` + * B4/B5/B6 就绪后返回结构需对齐 `ProviderMetadata[]`。 + */ +export function getProviderMetadata(): Promise { + return requestClient.get('/iot/rule/provider/metadata'); +} diff --git a/apps/web-antd/src/views/iot/rule/chain/components/DagPropertyPanel.vue b/apps/web-antd/src/views/iot/rule/chain/components/DagPropertyPanel.vue new file mode 100644 index 000000000..04de0d762 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/components/DagPropertyPanel.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/apps/web-antd/src/views/iot/rule/chain/components/DynamicNodeForm.vue b/apps/web-antd/src/views/iot/rule/chain/components/DynamicNodeForm.vue new file mode 100644 index 000000000..b9b0cc1a6 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/components/DynamicNodeForm.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts b/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts new file mode 100644 index 000000000..4de9a6a51 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts @@ -0,0 +1,292 @@ +/** + * F3 — 节点类型 → form-create schema 映射(mock) + * + * 后端 B4/B5/B6 Provider metadata API 就绪前,前端 mock 5+ 种节点类型的 schema。 + * 对齐 F2 useNodeCatalog 的节点清单(device_state / device_property / device_event / + * device_service / timer / expression / time_range / condition_device_state / + * property_set / service_invoke / alarm_trigger / alarm_clear / notify)。 + * + * schema 遵循 @form-create/ant-design-vue 的 Rule 格式。 + * i18n 通过 `title: 'i18n:'` 约定,DynamicNodeForm 渲染时替换为实际文案。 + * + * 切换到后端真实 metadata:Pinia store loadMetadata() 完成后 getSchemaByType 优先读 + * 真实数据,本 mock 仅为后端未就绪时的兜底。 + */ + +import type { + FormCreateSchema, + ProviderMetadata, +} from '#/views/iot/rule/chain/api/node-metadata'; + +// ────────────────────────────────────────────── +// 复用字段工厂 +// ────────────────────────────────────────────── + +function deviceSelectorRule(required = true): Record { + return { + type: 'a-select', + field: 'deviceId', + title: 'i18n:iot.dag.field.device', + props: { + placeholder: 'i18n:iot.dag.field.devicePlaceholder', + showSearch: true, + allowClear: true, + }, + // TODO(F4): 替换为远程 DeviceSelector(虚拟滚动 + 搜索 + 分页,按 subsystemId 筛选) + options: [], + validate: required + ? [{ required: true, message: 'i18n:iot.dag.validate.deviceRequired' }] + : [], + }; +} + +function propertyIdentifierRule(required = true): Record { + return { + type: 'a-input', + field: 'identifier', + title: 'i18n:iot.dag.field.propertyIdentifier', + props: { placeholder: 'i18n:iot.dag.field.propertyPlaceholder' }, + validate: required + ? [ + { + required: true, + message: 'i18n:iot.dag.validate.propertyRequired', + }, + ] + : [], + }; +} + +// ────────────────────────────────────────────── +// Trigger schemas +// ────────────────────────────────────────────── + +const deviceStateTriggerSchema: FormCreateSchema = { + rule: [ + deviceSelectorRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'a-radio-group', + field: 'event', + title: 'i18n:iot.dag.field.deviceEvent', + value: 'online', + options: [ + { label: 'i18n:iot.dag.option.online', value: 'online' }, + { label: 'i18n:iot.dag.option.offline', value: 'offline' }, + ], + }, + ], +}; + +const devicePropertyTriggerSchema: FormCreateSchema = { + rule: [ + deviceSelectorRule() as unknown as FormCreateSchema['rule'][number], + propertyIdentifierRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'a-select', + field: 'dataType', + title: 'i18n:iot.dag.field.dataType', + options: [ + { label: 'int', value: 'int' }, + { label: 'float', value: 'float' }, + { label: 'bool', value: 'bool' }, + { label: 'string', value: 'string' }, + ], + }, + ], +}; + +const timerTriggerSchema: FormCreateSchema = { + rule: [ + { + type: 'a-input', + field: 'cron', + title: 'i18n:iot.dag.field.cron', + props: { placeholder: '0 0 * * * ?' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.cronRequired' }, + ], + }, + { + type: 'a-input', + field: 'timezone', + title: 'i18n:iot.dag.field.timezone', + value: 'Asia/Shanghai', + }, + ], +}; + +// ────────────────────────────────────────────── +// Condition schemas +// ────────────────────────────────────────────── + +const expressionConditionSchema: FormCreateSchema = { + rule: [ + { + type: 'a-textarea', + field: 'expression', + title: 'i18n:iot.dag.field.expression', + props: { + placeholder: 'data.temp > 30 && meta.ts > 0', + autoSize: { minRows: 3, maxRows: 8 }, + }, + validate: [ + { + required: true, + message: 'i18n:iot.dag.validate.expressionRequired', + }, + ], + }, + ], +}; + +// ────────────────────────────────────────────── +// Action schemas +// ────────────────────────────────────────────── + +const propertySetActionSchema: FormCreateSchema = { + rule: [ + deviceSelectorRule() as unknown as FormCreateSchema['rule'][number], + propertyIdentifierRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'a-input', + field: 'value', + title: 'i18n:iot.dag.field.value', + props: { + placeholder: 'i18n:iot.dag.field.valuePlaceholder', + }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.valueRequired' }, + ], + }, + ], +}; + +const alarmTriggerActionSchema: FormCreateSchema = { + rule: [ + { + type: 'a-input', + field: 'alarmName', + title: 'i18n:iot.dag.field.alarmName', + validate: [ + { + required: true, + message: 'i18n:iot.dag.validate.alarmNameRequired', + }, + ], + }, + { + type: 'a-select', + field: 'severity', + title: 'i18n:iot.dag.field.severity', + value: 'MAJOR', + options: [ + { label: 'CRITICAL', value: 'CRITICAL' }, + { label: 'MAJOR', value: 'MAJOR' }, + { label: 'MINOR', value: 'MINOR' }, + { label: 'WARNING', value: 'WARNING' }, + { label: 'INFO', value: 'INFO' }, + ], + }, + { + type: 'a-textarea', + field: 'message', + title: 'i18n:iot.dag.field.alarmMessage', + props: { + placeholder: 'i18n:iot.dag.field.alarmMessagePlaceholder', + autoSize: { minRows: 2, maxRows: 4 }, + }, + }, + ], +}; + +const notifyActionSchema: FormCreateSchema = { + rule: [ + { + type: 'a-select', + field: 'channel', + title: 'i18n:iot.dag.field.notifyChannel', + value: 'SMS', + options: [ + { label: 'SMS', value: 'SMS' }, + { label: 'EMAIL', value: 'EMAIL' }, + { label: 'WEBHOOK', value: 'WEBHOOK' }, + ], + }, + { + type: 'a-input', + field: 'recipients', + title: 'i18n:iot.dag.field.notifyRecipients', + props: { + placeholder: 'i18n:iot.dag.field.notifyRecipientsPlaceholder', + }, + }, + { + type: 'a-textarea', + field: 'template', + title: 'i18n:iot.dag.field.notifyTemplate', + props: { + // eslint-disable-next-line no-template-curly-in-string -- 模板变量占位符字面展示给用户 + placeholder: '${data.temp} / ${alarm.name} / ${trigger.value}', + autoSize: { minRows: 2, maxRows: 6 }, + }, + }, + ], +}; + +// ────────────────────────────────────────────── +// 汇总映射 +// ────────────────────────────────────────────── + +/** providerType → schema 映射(mock) */ +const SCHEMA_MAP: Readonly> = Object.freeze({ + device_state: deviceStateTriggerSchema, + device_property: devicePropertyTriggerSchema, + timer: timerTriggerSchema, + expression: expressionConditionSchema, + property_set: propertySetActionSchema, + alarm_trigger: alarmTriggerActionSchema, + notify: notifyActionSchema, +}); + +/** + * 按 providerType 查 mock schema;未定义返回 null。 + * DynamicNodeForm 应先查 Pinia store 的真实 metadata,未命中再回退到本表。 + */ +export function getMockSchema(providerType: string): FormCreateSchema | null { + return SCHEMA_MAP[providerType] ?? null; +} + +/** 所有 mock schema 覆盖的 providerType 清单(调试用) */ +export function listMockProviderTypes(): string[] { + return Object.keys(SCHEMA_MAP); +} + +/** + * 构造 mock ProviderMetadata[](用于 store.loadMetadata fetcher 注入场景) + * 后端 B4/B5/B6 就绪后可删除此函数。 + */ +export function buildMockMetadata( + catalog: Array<{ + category: 'action' | 'condition' | 'trigger'; + description?: string; + icon: string; + type: string; + }>, +): ProviderMetadata[] { + // category → dagNodeType 映射(与 DagNodeType 字面对齐) + const categoryToDagNodeType = { + action: 'action', + condition: 'condition', + trigger: 'trigger', + } as const; + return catalog + .filter((c) => SCHEMA_MAP[c.type] !== undefined) + .map((c) => ({ + category: c.category, + dagNodeType: categoryToDagNodeType[c.category], + description: c.description, + icon: c.icon, + type: c.type, + schema: SCHEMA_MAP[c.type] as FormCreateSchema, + })); +} diff --git a/apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts b/apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts new file mode 100644 index 000000000..524dd9229 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts @@ -0,0 +1,123 @@ +/** + * F3 — 当前选中节点 Pinia store + * + * 规则链编辑页:画布点击节点 → 属性面板渲染对应表单 → 编辑保存回写 store → 画布节点 data 同步。 + * + * 设计要点: + * - store 不持有完整节点列表(那是画布的事),只持有"当前选中"快照 + * - updateConfig 只改选中节点的 `configuration` + `name`,不改位置/连线 + * - 切换选中节点时清空 dirty 标记 + */ + +import type { DagNode } from '#/components/iot-dag'; +import type { + FormCreateSchema, + ProviderMetadata, +} from '#/views/iot/rule/chain/api/node-metadata'; + +import { computed, ref } from 'vue'; + +import { defineStore } from 'pinia'; + +import { getProviderMetadata } from '#/views/iot/rule/chain/api/node-metadata'; + +export const useSelectedNodeStore = defineStore( + 'iot-rule-chain-selected-node', + () => { + // ── 当前选中节点 ──────────────────────────────────────────────────────────── + const current = ref(null); + const dirty = ref(false); + + /** 设置当前选中节点(切换节点时 dirty 重置) */ + function setCurrent(node: DagNode | null): void { + current.value = node; + dirty.value = false; + } + + /** 更新选中节点的 configuration */ + function updateConfig( + nodeId: string, + config: Record, + ): void { + if (!current.value || current.value.id !== nodeId) return; + if (!current.value.data) return; + current.value.data.configuration = config; + dirty.value = true; + } + + /** 更新选中节点的 label(name) */ + function updateLabel(nodeId: string, label: string): void { + if (!current.value || current.value.id !== nodeId) return; + if (!current.value.data) return; + current.value.data.label = label; + dirty.value = true; + } + + // ── Provider metadata 缓存 ───────────────────────────────────────────────── + const metadataList = ref([]); + const metadataLoaded = ref(false); + const metadataLoading = ref(false); + const metadataError = ref(null); + + /** 按 providerType 索引(O(1) 查表) */ + const metadataByType = computed>(() => { + const m = new Map(); + for (const p of metadataList.value) m.set(p.type, p); + return m; + }); + + /** 加载 Provider metadata(应用启动或进入编辑页时调用一次) */ + async function loadMetadata( + fetcher: () => Promise = getProviderMetadata, + ): Promise { + if (metadataLoading.value) return; + metadataLoading.value = true; + metadataError.value = null; + try { + metadataList.value = await fetcher(); + metadataLoaded.value = true; + } catch (error) { + metadataError.value = + error instanceof Error ? error : new Error(String(error)); + } finally { + metadataLoading.value = false; + } + } + + /** 按 providerType 查 schema;未找到返回 null */ + function getSchemaByType(providerType: string): FormCreateSchema | null { + return metadataByType.value.get(providerType)?.schema ?? null; + } + + /** 按 providerType 查完整 metadata */ + function getMetadataByType(providerType: string): null | ProviderMetadata { + return metadataByType.value.get(providerType) ?? null; + } + + /** 清理状态(离开编辑页时调用) */ + function reset(): void { + current.value = null; + dirty.value = false; + } + + return { + // state + current, + dirty, + metadataList, + metadataLoaded, + metadataLoading, + metadataError, + // getters + metadataByType, + // actions + setCurrent, + updateConfig, + updateLabel, + loadMetadata, + getSchemaByType, + getMetadataByType, + reset, + }; + }, +);