From 40923670e02a25856a43652cab89d141347e37b0 Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 24 Apr 2026 00:03:09 +0800 Subject: [PATCH] =?UTF-8?q?[F4/F5/F6]=20=E8=A7=A6=E5=8F=91=E5=99=A8/?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6/=E5=8A=A8=E4=BD=9C=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=20+=2013=20=E4=B8=AA=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20widget=20(MVP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三任务合并 commit。Subagent 因 stream_read_error 中途异常,主会话补齐 F5/F6 schema 与 SCHEMA_MAP 注册,并补 i18n keys。 ## F4 触发器(补 2 种) - node-schema: deviceEventTriggerSchema / deviceServiceTriggerSchema (F3 已有 device_state/device_property/timer) - productSelectorRule 工厂函数复用 ## F5 条件(补 2 种 + AviatorExprEditor 接入) - node-schema: timeRangeConditionSchema (mode/startTime/endTime/timezone) conditionDeviceStateConditionSchema (deviceId/expectedState) - expression schema 从 a-textarea 升级到 AviatorExprEditor 自定义组件 (F3 已有 expression 基础版) ## F6 动作(补 2 种) - node-schema: serviceInvokeActionSchema / alarmClearActionSchema (F3 已有 property_set/alarm_trigger/notify) ## SCHEMA_MAP 完整覆盖 13 种节点(对齐 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 ## 自定义 widgets(13 个 + register.ts) apps/web-antd/src/views/iot/rule/chain/components/widgets/: - DeviceSelector / ProductSelector / PropertyIdentifier / EventIdentifier / ServiceIdentifier / CronEditor (F4) - AviatorExprEditor / VariableHint (F5) - AlarmConfigSelector / NotifyChannelPicker / NotifyTemplateEditor / UserSelector (F6) - register.ts: formCreate.component 批量注册,import 即生效 ## MVP 决策 - AviatorExprEditor 用 a-textarea + VariableHint chip 列表(TODO post-MVP 接 Monaco/CodeMirror) - CronEditor 用 a-input + Popover 预设按钮(TODO post-MVP 加 cron-parser 下次执行时间预览) - DeviceSelector/ProductSelector 用 antd Select show-search remote loading(TODO 虚拟滚动) - AlarmConfigSelector/UserSelector TODO 联 B12/system API ## i18n 追加 - iot.dag.trigger.* (15 keys): product/eventIdentifier/serviceIdentifier/ cronPlaceholder/cronPresets 等 - iot.dag.condition.* (9 keys): timeRangeMode/modeDaily/startTime 等 - iot.dag.action.* (8 keys): alarmConfig/inputParams/templateTitle/Body 等 - iot.dag.validate.* (6 new): alarmConfigRequired/endTimeRequired/ eventRequired/productRequired/serviceRequired/startTimeRequired ## Known Pitfalls 落地 - 评审 B5/C5: 模板变量统一 \${namespace.key},VariableHint 只展示新语法 - 联动刷新: productId 变更 → PropertyIdentifier/DeviceSelector 清空(widget 内部 watch) - Webhook URL 前端格式正则,SSRF 白名单在 B16 后端 - 自定义 widgets 通过 formCreate.component 注册,DynamicNodeForm 识别 type 名 质检: - pnpm test:unit src/views/iot/rule/chain → 58/58 通过 - pnpm lint chain/ → 0 errors - pnpm check:type (web-antd) → chain 0 errors Post-MVP TODO: - Monaco Editor 或 CodeMirror 6 接入 AviatorExprEditor(语法高亮 + 自动补全) - cron-parser 接入 CronEditor(下次执行时间预览) - DeviceSelector/ProductSelector 虚拟滚动(千级设备场景) - AlarmConfigSelector/UserSelector 联调真实后端 API - E2E 测试覆盖 drop → 填表 → 保存完整流程 Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Sonnet 4.6 --- .../src/locales/langs/en-US/page.json | 46 +++- .../src/locales/langs/zh-CN/page.json | 46 +++- .../rule/chain/__tests__/node-schema.spec.ts | 10 +- .../chain/components/node-schema/index.ts | 223 +++++++++++++++++- .../widgets/AlarmConfigSelector.vue | 73 ++++++ .../components/widgets/AviatorExprEditor.vue | 105 +++++++++ .../chain/components/widgets/CronEditor.vue | 185 +++++++++++++++ .../components/widgets/DeviceSelector.vue | 106 +++++++++ .../components/widgets/EventIdentifier.vue | 83 +++++++ .../widgets/NotifyChannelPicker.vue | 82 +++++++ .../widgets/NotifyTemplateEditor.vue | 170 +++++++++++++ .../components/widgets/ProductSelector.vue | 105 +++++++++ .../components/widgets/PropertyIdentifier.vue | 87 +++++++ .../components/widgets/ServiceIdentifier.vue | 82 +++++++ .../chain/components/widgets/UserSelector.vue | 88 +++++++ .../chain/components/widgets/VariableHint.vue | 122 ++++++++++ .../rule/chain/components/widgets/register.ts | 51 ++++ 17 files changed, 1657 insertions(+), 7 deletions(-) create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/AlarmConfigSelector.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/AviatorExprEditor.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/CronEditor.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/DeviceSelector.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/EventIdentifier.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/NotifyChannelPicker.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/NotifyTemplateEditor.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/ProductSelector.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/PropertyIdentifier.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/ServiceIdentifier.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/UserSelector.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/VariableHint.vue create mode 100644 apps/web-antd/src/views/iot/rule/chain/components/widgets/register.ts diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index 408b57d16..e15a02da0 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -168,7 +168,51 @@ "cronRequired": "Please enter a Cron expression", "expressionRequired": "Please enter an expression", "valueRequired": "Please enter a value", - "alarmNameRequired": "Please enter an alarm name" + "alarmNameRequired": "Please enter an alarm name", + "alarmConfigRequired": "Please select an alarm config", + "endTimeRequired": "Please enter end time", + "eventRequired": "Please select an event", + "productRequired": "Please select a product", + "serviceRequired": "Please select a service", + "startTimeRequired": "Please enter start time" + }, + "trigger": { + "product": "Product", + "productPlaceholder": "Select product", + "selectProductFirst": "Select a product first", + "eventIdentifier": "Event", + "eventPlaceholder": "Select event", + "serviceIdentifier": "Service", + "servicePlaceholder": "Select service", + "device_optional": "Device (Optional)", + "deviceOptionalPlaceholder": "Select a device or leave empty to match all", + "identifiersPlaceholder": "Select property identifiers", + "cronPlaceholder": "e.g. 0 0 9 * * ?", + "cronPresets": "Presets", + "cronPresetBtn": "Use preset", + "cronFormatOk": "Format OK", + "cronFormatError": "Invalid Cron expression" + }, + "condition": { + "timeRangeMode": "Time Range Mode", + "modeDaily": "Daily", + "modeWeekly": "Weekly", + "modeHoliday": "Holiday", + "startTime": "Start Time", + "endTime": "End Time", + "expectedState": "Expected State", + "expressionPlaceholder": "Enter Aviator expression, supports ${data.x} / ${meta.x}", + "variableHintLabel": "Available Variables" + }, + "action": { + "alarmConfig": "Alarm Config", + "alarmConfigPlaceholder": "Select alarm config", + "inputParams": "Input Params (JSON)", + "templateTitle": "Title", + "templateTitlePlaceholder": "Supports ${alarm.name} / ${meta.deviceName}", + "templateBody": "Body", + "templateBodyPlaceholder": "Supports ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}", + "userSelectorPlaceholder": "Select recipients" }, "node": { "device_state": { diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index 1121ee4fe..9a0593158 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -168,7 +168,51 @@ "cronRequired": "请输入 Cron 表达式", "expressionRequired": "请输入表达式", "valueRequired": "请输入值", - "alarmNameRequired": "请输入告警名称" + "alarmNameRequired": "请输入告警名称", + "alarmConfigRequired": "请选择告警配置", + "endTimeRequired": "请输入结束时间", + "eventRequired": "请选择事件", + "productRequired": "请选择产品", + "serviceRequired": "请选择服务", + "startTimeRequired": "请输入开始时间" + }, + "trigger": { + "product": "产品", + "productPlaceholder": "请选择产品", + "selectProductFirst": "请先选择产品", + "eventIdentifier": "事件标识", + "eventPlaceholder": "请选择事件", + "serviceIdentifier": "服务标识", + "servicePlaceholder": "请选择服务", + "device_optional": "指定设备(可选)", + "deviceOptionalPlaceholder": "选择具体设备或留空匹配所有", + "identifiersPlaceholder": "请选择属性标识", + "cronPlaceholder": "如 0 0 9 * * ?", + "cronPresets": "预设", + "cronPresetBtn": "使用预设", + "cronFormatOk": "格式正确", + "cronFormatError": "Cron 表达式格式错误" + }, + "condition": { + "timeRangeMode": "时间段模式", + "modeDaily": "每日", + "modeWeekly": "每周", + "modeHoliday": "节假日", + "startTime": "开始时间", + "endTime": "结束时间", + "expectedState": "预期状态", + "expressionPlaceholder": "输入 Aviator 表达式,支持 ${data.x} / ${meta.x}", + "variableHintLabel": "可用变量" + }, + "action": { + "alarmConfig": "告警配置", + "alarmConfigPlaceholder": "请选择告警配置", + "inputParams": "输入参数(JSON)", + "templateTitle": "标题", + "templateTitlePlaceholder": "支持 ${alarm.name} / ${meta.deviceName}", + "templateBody": "内容", + "templateBodyPlaceholder": "支持 ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}", + "userSelectorPlaceholder": "请选择接收用户" }, "node": { "device_state": { diff --git a/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts b/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts index 13b139b18..c1d6b85a0 100644 --- a/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts +++ b/apps/web-antd/src/views/iot/rule/chain/__tests__/node-schema.spec.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom /** * F3 — node-schema mock 映射单元测试 * @@ -6,6 +7,9 @@ * - getMockSchema 未知 providerType 返回 null * - listMockProviderTypes 覆盖所有 F3 Acceptance 要求的 5+ 种节点类型 * - buildMockMetadata 过滤无 schema 的 catalog 项 + * + * 注:node-schema 顶部 import widgets/register 间接触达 window + * (widget .vue 引入 @vben/request → preferences)所以需 happy-dom。 */ import { describe, expect, it } from 'vitest'; @@ -82,14 +86,14 @@ describe('node-schema mock', () => { it('过滤 catalog 中没有 mock schema 的类型', () => { const catalog = [ - { category: 'trigger' as const, icon: 'bell', type: 'device_event' }, + { category: 'trigger' as const, icon: 'bell', type: 'not_a_type_1' }, { category: 'trigger' as const, icon: 'wrench', - type: 'device_service', + type: 'not_a_type_2', }, ]; - // device_event / device_service 在 mock 中未定义 schema + // 传入未定义在 mock 中的 type,buildMockMetadata 应过滤掉 const metadata = buildMockMetadata(catalog); expect(metadata).toHaveLength(0); }); diff --git a/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts b/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts index 4de9a6a51..9f530048b 100644 --- a/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts +++ b/apps/web-antd/src/views/iot/rule/chain/components/node-schema/index.ts @@ -11,6 +11,9 @@ * * 切换到后端真实 metadata:Pinia store loadMetadata() 完成后 getSchemaByType 优先读 * 真实数据,本 mock 仅为后端未就绪时的兜底。 + * + * F4/F5/F6 扩展:补全 6 种 schema(device_event / device_service / time_range / + * condition_device_state / service_invoke / alarm_clear)+ 自定义 widget 注册。 */ import type { @@ -18,6 +21,9 @@ import type { ProviderMetadata, } from '#/views/iot/rule/chain/api/node-metadata'; +// 触发自定义 widget 注册(import 即生效,幂等) +import '#/views/iot/rule/chain/components/widgets/register'; + // ────────────────────────────────────────────── // 复用字段工厂 // ────────────────────────────────────────────── @@ -57,6 +63,24 @@ function propertyIdentifierRule(required = true): Record { }; } +// ────────────────────────────────────────────── +// 复用 ProductSelector 字段工厂(F4 新增) +// ────────────────────────────────────────────── + +function productSelectorRule(required = true): Record { + return { + type: 'ProductSelector', + field: 'productId', + title: 'i18n:iot.dag.trigger.product', + props: { + placeholder: 'i18n:iot.dag.trigger.productPlaceholder', + }, + validate: required + ? [{ required: true, message: 'i18n:iot.dag.validate.productRequired' }] + : [], + }; +} + // ────────────────────────────────────────────── // Trigger schemas // ────────────────────────────────────────────── @@ -115,6 +139,65 @@ const timerTriggerSchema: FormCreateSchema = { ], }; +/** + * F4 — device_event 触发器(新增) + * 字段:productId + eventIdentifier + deviceId(可选) + * ⚠️ 评审 B5:高级过滤如需表达式字段,使用 ${data.x}/${meta.x} 新语法 + */ +const deviceEventTriggerSchema: FormCreateSchema = { + rule: [ + productSelectorRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'EventIdentifier', + field: 'eventIdentifier', + title: 'i18n:iot.dag.trigger.eventIdentifier', + props: { productId: '{{formData.productId}}' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.eventRequired' }, + ], + }, + { + type: 'DeviceSelector', + field: 'deviceId', + title: 'i18n:iot.dag.trigger.device_optional', + props: { + productId: '{{formData.productId}}', + placeholder: 'i18n:iot.dag.trigger.deviceOptionalPlaceholder', + }, + validate: [], + }, + ], +}; + +/** + * F4 — device_service 触发器(新增) + * 字段:productId + serviceIdentifier + deviceId(可选) + */ +const deviceServiceTriggerSchema: FormCreateSchema = { + rule: [ + productSelectorRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'ServiceIdentifier', + field: 'serviceIdentifier', + title: 'i18n:iot.dag.trigger.serviceIdentifier', + props: { productId: '{{formData.productId}}' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.serviceRequired' }, + ], + }, + { + type: 'DeviceSelector', + field: 'deviceId', + title: 'i18n:iot.dag.trigger.device_optional', + props: { + productId: '{{formData.productId}}', + placeholder: 'i18n:iot.dag.trigger.deviceOptionalPlaceholder', + }, + validate: [], + }, + ], +}; + // ────────────────────────────────────────────── // Condition schemas // ────────────────────────────────────────────── @@ -122,12 +205,11 @@ const timerTriggerSchema: FormCreateSchema = { const expressionConditionSchema: FormCreateSchema = { rule: [ { - type: 'a-textarea', + type: 'AviatorExprEditor', field: 'expression', title: 'i18n:iot.dag.field.expression', props: { placeholder: 'data.temp > 30 && meta.ts > 0', - autoSize: { minRows: 3, maxRows: 8 }, }, validate: [ { @@ -139,6 +221,80 @@ const expressionConditionSchema: FormCreateSchema = { ], }; +/** + * F5 — time_range 条件(新增) + * 字段:mode + startTime + endTime + timezone + */ +const timeRangeConditionSchema: FormCreateSchema = { + rule: [ + { + type: 'a-radio-group', + field: 'mode', + title: 'i18n:iot.dag.condition.timeRangeMode', + value: 'daily', + options: [ + { label: 'i18n:iot.dag.condition.modeDaily', value: 'daily' }, + { label: 'i18n:iot.dag.condition.modeWeekly', value: 'weekly' }, + { label: 'i18n:iot.dag.condition.modeHoliday', value: 'holiday' }, + ], + }, + { + type: 'a-input', + field: 'startTime', + title: 'i18n:iot.dag.condition.startTime', + props: { placeholder: '08:00' }, + validate: [ + { + required: true, + message: 'i18n:iot.dag.validate.startTimeRequired', + }, + ], + }, + { + type: 'a-input', + field: 'endTime', + title: 'i18n:iot.dag.condition.endTime', + props: { placeholder: '18:00' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.endTimeRequired' }, + ], + }, + { + type: 'a-input', + field: 'timezone', + title: 'i18n:iot.dag.field.timezone', + value: 'Asia/Shanghai', + }, + ], +}; + +/** + * F5 — condition_device_state 条件(新增) + * 字段:deviceId + expectedState + */ +const conditionDeviceStateConditionSchema: FormCreateSchema = { + rule: [ + { + type: 'DeviceSelector', + field: 'deviceId', + title: 'i18n:iot.dag.field.device', + validate: [ + { required: true, message: 'i18n:iot.dag.validate.deviceRequired' }, + ], + }, + { + type: 'a-radio-group', + field: 'expectedState', + title: 'i18n:iot.dag.condition.expectedState', + value: 'online', + options: [ + { label: 'i18n:iot.dag.option.online', value: 'online' }, + { label: 'i18n:iot.dag.option.offline', value: 'offline' }, + ], + }, + ], +}; + // ────────────────────────────────────────────── // Action schemas // ────────────────────────────────────────────── @@ -237,14 +393,77 @@ const notifyActionSchema: FormCreateSchema = { // 汇总映射 // ────────────────────────────────────────────── +/** + * F6 — service_invoke 动作(新增) + * 字段:productId + deviceId + serviceIdentifier + inputParams (JSON) + */ +const serviceInvokeActionSchema: FormCreateSchema = { + rule: [ + productSelectorRule() as unknown as FormCreateSchema['rule'][number], + { + type: 'DeviceSelector', + field: 'deviceId', + title: 'i18n:iot.dag.field.device', + props: { productId: '{{formData.productId}}' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.deviceRequired' }, + ], + }, + { + type: 'ServiceIdentifier', + field: 'serviceIdentifier', + title: 'i18n:iot.dag.trigger.serviceIdentifier', + props: { productId: '{{formData.productId}}' }, + validate: [ + { required: true, message: 'i18n:iot.dag.validate.serviceRequired' }, + ], + }, + { + type: 'a-textarea', + field: 'inputParams', + title: 'i18n:iot.dag.action.inputParams', + props: { + placeholder: '{"key": "value"}', + autoSize: { minRows: 2, maxRows: 6 }, + }, + }, + ], +}; + +/** + * F6 — alarm_clear 动作(新增) + * 字段:alarmConfigId(清除对应告警) + */ +const alarmClearActionSchema: FormCreateSchema = { + rule: [ + { + type: 'AlarmConfigSelector', + field: 'alarmConfigId', + title: 'i18n:iot.dag.action.alarmConfig', + validate: [ + { + required: true, + message: 'i18n:iot.dag.validate.alarmConfigRequired', + }, + ], + }, + ], +}; + /** providerType → schema 映射(mock) */ const SCHEMA_MAP: Readonly> = Object.freeze({ device_state: deviceStateTriggerSchema, device_property: devicePropertyTriggerSchema, + device_event: deviceEventTriggerSchema, + device_service: deviceServiceTriggerSchema, timer: timerTriggerSchema, expression: expressionConditionSchema, + time_range: timeRangeConditionSchema, + condition_device_state: conditionDeviceStateConditionSchema, property_set: propertySetActionSchema, + service_invoke: serviceInvokeActionSchema, alarm_trigger: alarmTriggerActionSchema, + alarm_clear: alarmClearActionSchema, notify: notifyActionSchema, }); diff --git a/apps/web-antd/src/views/iot/rule/chain/components/widgets/AlarmConfigSelector.vue b/apps/web-antd/src/views/iot/rule/chain/components/widgets/AlarmConfigSelector.vue new file mode 100644 index 000000000..f12e47641 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/components/widgets/AlarmConfigSelector.vue @@ -0,0 +1,73 @@ + + + diff --git a/apps/web-antd/src/views/iot/rule/chain/components/widgets/AviatorExprEditor.vue b/apps/web-antd/src/views/iot/rule/chain/components/widgets/AviatorExprEditor.vue new file mode 100644 index 000000000..f7fa25874 --- /dev/null +++ b/apps/web-antd/src/views/iot/rule/chain/components/widgets/AviatorExprEditor.vue @@ -0,0 +1,105 @@ + + +