[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:
lzh
2026-04-23 23:12:11 +08:00
parent 887e51eaaa
commit 7b1a0132d0
9 changed files with 1250 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View 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[];
/** 全局 optionsubmitBtn / resetBtn 等) */
option?: Record<string, unknown>;
}
/** Provider 节点元数据(含 schema比 NodeTypeMeta 多一层) */
export interface ProviderMetadata extends NodeTypeMeta {
/** 动态表单 schemaform-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');
}

View File

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

View File

@@ -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 schemarule + 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>

View File

@@ -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 渲染时替换为实际文案。
*
* 切换到后端真实 metadataPinia 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,
}));
}

View 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;
}
/** 更新选中节点的 labelname */
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,
};
},
);