[F3] DAG 画布属性面板 + 动态表单机制
主会话(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<string, unknown>
决策 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) <noreply@anthropic.com>
This commit is contained in:
@@ -129,7 +129,46 @@
|
|||||||
"trigger": "Trigger",
|
"trigger": "Trigger",
|
||||||
"condition": "Condition",
|
"condition": "Condition",
|
||||||
"action": "Action"
|
"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": {
|
"node": {
|
||||||
"device_state": {
|
"device_state": {
|
||||||
|
|||||||
@@ -129,7 +129,46 @@
|
|||||||
"trigger": "触发器",
|
"trigger": "触发器",
|
||||||
"condition": "条件",
|
"condition": "条件",
|
||||||
"action": "动作"
|
"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": {
|
"node": {
|
||||||
"device_state": {
|
"device_state": {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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['data']>,
|
||||||
|
): 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<ProviderMetadata[]>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts
Normal file
65
apps/web-antd/src/views/iot/rule/chain/api/node-metadata.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
/** 下拉选项 */
|
||||||
|
options?: Array<{ label: string; value: boolean | number | string }>;
|
||||||
|
/** 校验规则(antd Form rule 风格) */
|
||||||
|
validate?: Array<Record<string, unknown>>;
|
||||||
|
/** 默认值 */
|
||||||
|
value?: unknown;
|
||||||
|
/** 子项(嵌套表单) */
|
||||||
|
children?: FormCreateRuleItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** form-create schema(完整) */
|
||||||
|
export interface FormCreateSchema {
|
||||||
|
/** 字段规则数组 */
|
||||||
|
rule: FormCreateRuleItem[];
|
||||||
|
/** 全局 option(submitBtn / resetBtn 等) */
|
||||||
|
option?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<ProviderMetadata[]> {
|
||||||
|
return requestClient.get<ProviderMetadata[]>('/iot/rule/provider/metadata');
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Card, Input } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { buildMockMetadata } from '#/views/iot/rule/chain/components/node-schema';
|
||||||
|
import { useSelectedNodeStore } from '#/views/iot/rule/chain/stores/selected-node';
|
||||||
|
|
||||||
|
import DynamicNodeForm from './DynamicNodeForm.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DagPropertyPanel' });
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), { readonly: false });
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 是否只读(画布 readonly 时属性面板也禁用) */
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const store = useSelectedNodeStore();
|
||||||
|
|
||||||
|
// 启动时加载 Provider metadata。后端 B4/B5/B6 未就绪时 fallback 到 mock
|
||||||
|
onMounted(async () => {
|
||||||
|
if (store.metadataLoaded || store.metadataLoading) return;
|
||||||
|
await store.loadMetadata().catch(() => {
|
||||||
|
// 后端未就绪:注入 mock metadata。mock 由 node-schema/index.ts 提供,
|
||||||
|
// 节点类型清单对齐 F2 useNodeCatalog(mock catalog 体量较小,直接导出)。
|
||||||
|
store.loadMetadata(async () => {
|
||||||
|
// 懒加载 useNodeCatalog mock,避免循环依赖
|
||||||
|
const { fetchNodeCatalog } = await import('#/components/iot-dag');
|
||||||
|
const catalog = await fetchNodeCatalog();
|
||||||
|
return buildMockMetadata(catalog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 当前选中节点 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedNode = computed(() => store.current);
|
||||||
|
const nodeLabel = ref('');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedNode,
|
||||||
|
(n) => {
|
||||||
|
nodeLabel.value = n?.data?.label ?? '';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentSchema = computed(() => {
|
||||||
|
const providerType = selectedNode.value?.data?.providerType;
|
||||||
|
if (!providerType) return null;
|
||||||
|
return store.getSchemaByType(providerType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentMetadata = computed(() => {
|
||||||
|
const providerType = selectedNode.value?.data?.providerType;
|
||||||
|
if (!providerType) return null;
|
||||||
|
return store.getMetadataByType(providerType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentConfig = computed<Record<string, unknown>>(() => {
|
||||||
|
return selectedNode.value?.data?.configuration ?? {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 事件 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleConfigChange(newConfig: Record<string, unknown>): void {
|
||||||
|
if (!selectedNode.value) return;
|
||||||
|
store.updateConfig(selectedNode.value.id, newConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLabelChange(value: string): void {
|
||||||
|
if (!selectedNode.value) return;
|
||||||
|
store.updateLabel(selectedNode.value.id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 展示标题 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const panelTitle = computed(() => {
|
||||||
|
if (!selectedNode.value) return $t('iot.dag.panel.titleEmpty');
|
||||||
|
const metaType = currentMetadata.value?.type;
|
||||||
|
if (metaType) {
|
||||||
|
return $t(`iot.dag.node.${metaType}.name`);
|
||||||
|
}
|
||||||
|
return selectedNode.value.data?.label ?? $t('iot.dag.panel.titleFallback');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
class="dag-property-panel"
|
||||||
|
:title="panelTitle"
|
||||||
|
:body-style="{ padding: '12px 16px' }"
|
||||||
|
>
|
||||||
|
<!-- 空态 -->
|
||||||
|
<div v-if="!selectedNode" class="dag-property-panel__hint">
|
||||||
|
{{ $t('iot.dag.panel.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中节点:展示 label 输入 + 动态表单 -->
|
||||||
|
<div v-else class="dag-property-panel__body">
|
||||||
|
<!-- 节点名称(label) -->
|
||||||
|
<div class="dag-property-panel__field">
|
||||||
|
<label class="dag-property-panel__label">
|
||||||
|
{{ $t('iot.dag.panel.nodeLabel') }}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
v-model:value="nodeLabel"
|
||||||
|
:disabled="readonly"
|
||||||
|
:placeholder="$t('iot.dag.panel.nodeLabelPlaceholder')"
|
||||||
|
@change="handleLabelChange(nodeLabel)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 动态配置表单 -->
|
||||||
|
<div class="dag-property-panel__field">
|
||||||
|
<DynamicNodeForm
|
||||||
|
:model-value="currentConfig"
|
||||||
|
:readonly="readonly"
|
||||||
|
:schema="currentSchema"
|
||||||
|
@update:model-value="handleConfigChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dag-property-panel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dag-property-panel__hint {
|
||||||
|
padding: 24px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dag-property-panel__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dag-property-panel__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dag-property-panel__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type {
|
||||||
|
FormCreateRuleItem,
|
||||||
|
FormCreateSchema,
|
||||||
|
} from '#/views/iot/rule/chain/api/node-metadata';
|
||||||
|
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import formCreate from '@form-create/ant-design-vue';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
defineOptions({ name: 'DynamicNodeForm' });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: () => ({}),
|
||||||
|
readonly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: Record<string, unknown>): void;
|
||||||
|
(e: 'change', value: Record<string, unknown>): void;
|
||||||
|
(e: 'validate', valid: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ── Props / Emits ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** form-create schema(rule + option) */
|
||||||
|
schema: FormCreateSchema | null;
|
||||||
|
/** 表单值(节点 configuration) */
|
||||||
|
modelValue?: Record<string, unknown>;
|
||||||
|
/** 是否只读 */
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── i18n 解析 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 字符串若以 `i18n:` 前缀开头则走 $t 查翻译,否则原样返回 */
|
||||||
|
function resolveI18nString<T>(value: T): T {
|
||||||
|
if (typeof value === 'string' && value.startsWith('i18n:')) {
|
||||||
|
return $t(value.slice(5)) as unknown as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归解析 rule 中的 i18n 字段(title / placeholder / options.label / validate.message) */
|
||||||
|
function resolveRule(rule: FormCreateRuleItem[]): FormCreateRuleItem[] {
|
||||||
|
return rule.map((r) => {
|
||||||
|
const resolvedProps = r.props
|
||||||
|
? { ...r.props, placeholder: resolveI18nString(r.props.placeholder) }
|
||||||
|
: undefined;
|
||||||
|
const resolvedOptions = r.options
|
||||||
|
? r.options.map((o) => ({ ...o, label: resolveI18nString(o.label) }))
|
||||||
|
: undefined;
|
||||||
|
const resolvedValidate = r.validate
|
||||||
|
? r.validate.map((v) => ({ ...v, message: resolveI18nString(v.message) }))
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
title: resolveI18nString(r.title),
|
||||||
|
props: resolvedProps,
|
||||||
|
options: resolvedOptions,
|
||||||
|
validate: resolvedValidate,
|
||||||
|
children: r.children ? resolveRule(r.children) : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── form-create 渲染配置 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const resolvedRule = computed<FormCreateRuleItem[]>(() => {
|
||||||
|
if (!props.schema) return [];
|
||||||
|
return resolveRule(props.schema.rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedOption = computed<Record<string, unknown>>(() => {
|
||||||
|
const base = {
|
||||||
|
submitBtn: false,
|
||||||
|
resetBtn: false,
|
||||||
|
// readonly 时禁用整个表单
|
||||||
|
...(props.readonly ? { global: { props: { disabled: true } } } : {}),
|
||||||
|
};
|
||||||
|
return { ...base, ...props.schema?.option };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── form-create 实例 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FormCreateComponent = formCreate.$form() as unknown as ReturnType<
|
||||||
|
typeof formCreate.$form
|
||||||
|
>;
|
||||||
|
const fApi = ref<unknown>(null);
|
||||||
|
const formValue = ref<Record<string, unknown>>({ ...props.modelValue });
|
||||||
|
|
||||||
|
// schema 切换时重置:表单字段重置,value 同步为 props.modelValue
|
||||||
|
watch(
|
||||||
|
() => props.schema,
|
||||||
|
async () => {
|
||||||
|
formValue.value = { ...props.modelValue };
|
||||||
|
await nextTick();
|
||||||
|
const api = fApi.value as null | { resetFields?: () => void };
|
||||||
|
api?.resetFields?.();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 外部 modelValue 变化时同步
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val !== formValue.value) {
|
||||||
|
formValue.value = { ...val };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 内部值变化时通知外部(过滤 undefined,避免写入 null/undefined)
|
||||||
|
watch(
|
||||||
|
formValue,
|
||||||
|
(val) => {
|
||||||
|
const cleaned: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(val)) {
|
||||||
|
if (v !== undefined) cleaned[k] = v;
|
||||||
|
}
|
||||||
|
emit('update:modelValue', cleaned);
|
||||||
|
emit('change', cleaned);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 对外暴露 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function validate(): Promise<boolean> {
|
||||||
|
const api = fApi.value as null | {
|
||||||
|
validate?: (cb: (valid: boolean) => void) => void;
|
||||||
|
};
|
||||||
|
if (!api?.validate) return true;
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
api.validate!((valid) => {
|
||||||
|
emit('validate', valid);
|
||||||
|
resolve(valid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ validate });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dynamic-node-form">
|
||||||
|
<!-- schema 为空:提示未选节点 -->
|
||||||
|
<div v-if="!schema" class="dynamic-node-form__empty">
|
||||||
|
{{ $t('iot.dag.panel.empty') }}
|
||||||
|
</div>
|
||||||
|
<!-- schema 未识别:错误态 -->
|
||||||
|
<div v-else-if="resolvedRule.length === 0" class="dynamic-node-form__error">
|
||||||
|
{{ $t('iot.dag.panel.schemaUnknown') }}
|
||||||
|
</div>
|
||||||
|
<!-- 正常:form-create 渲染 -->
|
||||||
|
<FormCreateComponent
|
||||||
|
v-else
|
||||||
|
v-model:api="fApi"
|
||||||
|
v-model="formValue"
|
||||||
|
:option="mergedOption"
|
||||||
|
:rule="resolvedRule"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dynamic-node-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-node-form__empty,
|
||||||
|
.dynamic-node-form__error {
|
||||||
|
padding: 24px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-node-form__error {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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:<key>'` 约定,DynamicNodeForm 渲染时替换为实际文案。
|
||||||
|
*
|
||||||
|
* 切换到后端真实 metadata:Pinia store loadMetadata() 完成后 getSchemaByType 优先读
|
||||||
|
* 真实数据,本 mock 仅为后端未就绪时的兜底。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FormCreateSchema,
|
||||||
|
ProviderMetadata,
|
||||||
|
} from '#/views/iot/rule/chain/api/node-metadata';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 复用字段工厂
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function deviceSelectorRule(required = true): Record<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<Record<string, FormCreateSchema>> = 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
123
apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts
Normal file
123
apps/web-antd/src/views/iot/rule/chain/stores/selected-node.ts
Normal file
@@ -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<DagNode | null>(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<string, unknown>,
|
||||||
|
): 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<ProviderMetadata[]>([]);
|
||||||
|
const metadataLoaded = ref(false);
|
||||||
|
const metadataLoading = ref(false);
|
||||||
|
const metadataError = ref<Error | null>(null);
|
||||||
|
|
||||||
|
/** 按 providerType 索引(O(1) 查表) */
|
||||||
|
const metadataByType = computed<Map<string, ProviderMetadata>>(() => {
|
||||||
|
const m = new Map<string, ProviderMetadata>();
|
||||||
|
for (const p of metadataList.value) m.set(p.type, p);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 加载 Provider metadata(应用启动或进入编辑页时调用一次) */
|
||||||
|
async function loadMetadata(
|
||||||
|
fetcher: () => Promise<ProviderMetadata[]> = getProviderMetadata,
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user