[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",
|
||||
"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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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