From c25b631c10c0acda36f74bbe477ee5060ff2c2a7 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 31 May 2026 00:36:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(iot):=20=E5=AE=8C=E5=96=84=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E8=81=94=E5=8A=A8=E9=85=8D=E7=BD=AE=E5=BF=85=E5=A1=AB?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vue3 抽取场景联动校验工具,统一触发器、附加条件和执行器校验 - Vben5 antd/ele 同步场景联动提交前兜底校验 - 补充 CRON 表达式、JSON 参数和动态字段必填校验 - 保留 deviceId=0 表示全部设备的业务语义 --- .../src/views/iot/rule/scene/modules/form.vue | 209 +-------- .../src/views/iot/utils/scene-rule.ts | 401 ++++++++++++++++++ .../src/views/iot/rule/scene/modules/form.vue | 209 +-------- .../web-ele/src/views/iot/utils/scene-rule.ts | 401 ++++++++++++++++++ 4 files changed, 844 insertions(+), 376 deletions(-) create mode 100644 apps/web-antd/src/views/iot/utils/scene-rule.ts create mode 100644 apps/web-ele/src/views/iot/utils/scene-rule.ts diff --git a/apps/web-antd/src/views/iot/rule/scene/modules/form.vue b/apps/web-antd/src/views/iot/rule/scene/modules/form.vue index 191e6f4e3..cccd24183 100644 --- a/apps/web-antd/src/views/iot/rule/scene/modules/form.vue +++ b/apps/web-antd/src/views/iot/rule/scene/modules/form.vue @@ -4,15 +4,7 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene'; import { computed, nextTick, reactive, ref } from 'vue'; import { useVbenDrawer } from '@vben/common-ui'; -import { - CommonStatusEnum, - IotRuleSceneActionTypeEnum, - IotRuleSceneTriggerConditionTypeEnum, - IotRuleSceneTriggerTimeOperatorEnum, - IotRuleSceneTriggerTypeEnum, - isDeviceTrigger, -} from '@vben/constants'; -import { CronUtils } from '@vben/utils'; +import { CommonStatusEnum, IotRuleSceneTriggerTypeEnum } from '@vben/constants'; import { Form, message } from 'ant-design-vue'; @@ -22,6 +14,10 @@ import { updateSceneRule, } from '#/api/iot/rule/scene'; import { $t } from '#/locales'; +import { + validateSceneRuleActions, + validateSceneRuleTriggers, +} from '#/views/iot/utils/scene-rule'; import ActionSection from '../form/sections/action-section.vue'; import BasicInfoSection from '../form/sections/basic-info-section.vue'; @@ -47,6 +43,16 @@ const [Drawer, drawerApi] = useVbenDrawer({ } catch { return; } + const triggerError = validateSceneRuleTriggers(formData.value.triggers); + if (triggerError) { + message.error(triggerError); + return; + } + const actionError = validateSceneRuleActions(formData.value.actions); + if (actionError) { + message.error(actionError); + return; + } drawerApi.lock(); try { const data = { ...formData.value } as RuleSceneApi.SceneRule; @@ -117,194 +123,21 @@ function normalizeFormData(result: any): RuleSceneApi.SceneRule { /** 触发器校验 */ function validateTriggers(_rule: any, value: any, callback: any) { - if (!value || !Array.isArray(value) || value.length === 0) { - callback(new Error('至少需要一个触发器')); + const error = validateSceneRuleTriggers(value); + if (error) { + callback(new Error(error)); return; } - for (const [i, trigger] of value.entries()) { - if (!trigger.type) { - callback(new Error(`触发器 ${i + 1}:触发器类型不能为空`)); - return; - } - if (isDeviceTrigger(trigger.type)) { - if (!trigger.productId) { - callback(new Error(`触发器 ${i + 1}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值;仅 undefined / null 视为未选 - if (trigger.deviceId === undefined || trigger.deviceId === null) { - callback(new Error(`触发器 ${i + 1}:设备不能为空`)); - return; - } - const isStateUpdate = - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE; - if (!isStateUpdate && !trigger.identifier) { - callback(new Error(`触发器 ${i + 1}:物模型标识符不能为空`)); - return; - } - // 事件上报 / 服务调用:operator 由前端自动设为 '=',参数值留空表示「事件 / 调用发生即匹配」 - const isEventOrService = - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST || - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE; - if (!isEventOrService) { - if (!trigger.operator) { - callback(new Error(`触发器 ${i + 1}:操作符不能为空`)); - return; - } - if ( - trigger.value === undefined || - trigger.value === null || - trigger.value === '' - ) { - callback(new Error(`触发器 ${i + 1}:参数值不能为空`)); - return; - } - } - } - if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) { - if (!trigger.cronExpression) { - callback(new Error(`触发器 ${i + 1}:CRON 表达式不能为空`)); - return; - } - if (!CronUtils.validate(trigger.cronExpression)) { - callback(new Error(`触发器 ${i + 1}:CRON 表达式格式不正确`)); - return; - } - } - // 递归校验 conditionGroups(嵌套条件组) - if (trigger.conditionGroups?.length) { - for (const [gi, group] of trigger.conditionGroups.entries()) { - if (!Array.isArray(group) || group.length === 0) { - callback( - new Error(`触发器 ${i + 1}:条件组 ${gi + 1} 不能为空`), - ); - return; - } - for (const [ci, condition] of group.entries()) { - const prefix = `触发器 ${i + 1} 条件组 ${gi + 1} 条件 ${ci + 1}`; - if (!condition.type) { - callback(new Error(`${prefix}:条件类型不能为空`)); - return; - } - const isDeviceStatus = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS; - const isDeviceProperty = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY; - const isCurrentTime = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME; - if (isDeviceStatus || isDeviceProperty) { - if (!condition.productId) { - callback(new Error(`${prefix}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值 - if ( - condition.deviceId === undefined || - condition.deviceId === null - ) { - callback(new Error(`${prefix}:设备不能为空`)); - return; - } - if (isDeviceProperty && !condition.identifier) { - callback(new Error(`${prefix}:物模型标识符不能为空`)); - return; - } - } - if (!condition.operator) { - callback(new Error(`${prefix}:操作符不能为空`)); - return; - } - // 设备状态:param 是状态值(必填);设备属性:param 是比较值(必填) - if ( - (isDeviceStatus || isDeviceProperty) && - (condition.param === undefined || - condition.param === null || - condition.param === '') - ) { - callback( - new Error( - `${prefix}:${isDeviceStatus ? '设备状态' : '比较值'}不能为空`, - ), - ); - return; - } - // 当前时间:TODAY 不需要 param;BETWEEN_TIME 需要双段「v1,v2」;其它需要单段 - if (isCurrentTime) { - const op = condition.operator; - if (op === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value) { - // TODAY 无需 param - } else if ( - op === IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value - ) { - const parts = condition.param - ? String(condition.param).split(',') - : []; - if (parts.length < 2 || !parts[0] || !parts[1]) { - callback(new Error(`${prefix}:起止时间不能为空`)); - return; - } - } else if (!condition.param) { - callback(new Error(`${prefix}:时间值不能为空`)); - return; - } - } - } - } - } - } callback(); } /** 执行器校验 */ function validateActions(_rule: any, value: any, callback: any) { - if (!value || !Array.isArray(value) || value.length === 0) { - callback(new Error('至少需要一个执行器')); + const error = validateSceneRuleActions(value); + if (error) { + callback(new Error(error)); return; } - for (const [i, action] of value.entries()) { - if (!action.type) { - callback(new Error(`执行器 ${i + 1}:执行器类型不能为空`)); - return; - } - if ( - action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET || - action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE - ) { - if (!action.productId) { - callback(new Error(`执行器 ${i + 1}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES); - // 后端 IotDevicePropertySetSceneRuleAction / IotDeviceServiceInvokeSceneRuleAction - // 均支持广播执行,因此 0 是合法值,仅 undefined / null 视为未选 - if (action.deviceId === undefined || action.deviceId === null) { - callback(new Error(`执行器 ${i + 1}:设备不能为空`)); - return; - } - if ( - action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE && - !action.identifier - ) { - callback(new Error(`执行器 ${i + 1}:服务不能为空`)); - return; - } - if (!action.params || Object.keys(action.params).length === 0) { - callback(new Error(`执行器 ${i + 1}:参数配置不能为空`)); - return; - } - } - // 仅恢复告警动作需要选择已有告警配置;触发告警动作不需要预选 alertConfigId - if ( - action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER && - !action.alertConfigId - ) { - callback(new Error(`执行器 ${i + 1}:告警配置不能为空`)); - return; - } - } callback(); } diff --git a/apps/web-antd/src/views/iot/utils/scene-rule.ts b/apps/web-antd/src/views/iot/utils/scene-rule.ts new file mode 100644 index 000000000..9b038806f --- /dev/null +++ b/apps/web-antd/src/views/iot/utils/scene-rule.ts @@ -0,0 +1,401 @@ +import type { RuleSceneApi } from '#/api/iot/rule/scene'; + +import { + IotRuleSceneActionTypeEnum, + IotRuleSceneTriggerConditionTypeEnum, + IotRuleSceneTriggerTimeOperatorEnum, + IotRuleSceneTriggerTypeEnum, + isDeviceTrigger, +} from '@vben/constants'; +import { CronUtils, isEmptyVal, isObject } from '@vben/utils'; + +/** + * 判断普通 ID 选择值是否缺失。 + * + * 产品、告警配置等普通业务 ID 应为正数;`0` 不代表有效业务数据。 + * + * @param value 普通业务 ID + * @returns 是否缺失 + */ +function isRequiredIdMissing(value: unknown): boolean { + return !value; +} + +/** + * 判断设备 ID 选择值是否缺失。 + * + * 场景联动的设备选择器中,`0` 表示「全部设备」,是合法值;因此这里只能把 + * `undefined`、`null`、空字符串视为未选择,不能使用普通 falsy 判断。 + * + * @param value 设备 ID + * @returns 是否缺失 + */ +function isDeviceIdMissing(value: unknown): boolean { + return isEmptyVal(value); +} + +/** + * 判断执行器参数是否为空。 + * + * 参数配置当前以 JSON 字符串为主,同时兼容历史对象值: + * - 空字符串、空白字符串视为空; + * - 空对象 `{}` 视为空; + * - 非空 JSON 对象视为已配置; + * - 非法 JSON 不在这里判空,交给 JSON 格式校验返回更准确的错误。 + * + * @param params 执行器参数 + * @returns 是否为空 + */ +export function isActionParamsEmpty(params?: unknown): boolean { + if (isEmptyVal(params)) { + return true; + } + + if (typeof params === 'string') { + if (!params.trim()) { + return true; + } + try { + const parsed = JSON.parse(params); + if (isObject(parsed) && !Array.isArray(parsed)) { + return Object.keys(parsed).length === 0; + } + } catch { + return false; + } + return false; + } + + if (isObject(params) && !Array.isArray(params)) { + return Object.keys(params).length === 0; + } + + return false; +} + +/** + * 判断执行器参数是否为合法 JSON。 + * + * 参数编辑组件正常会保存 JSON 字符串;编辑旧数据时可能仍然是对象,对象可直接视为合法。 + * + * @param params 执行器参数 + * @returns 是否合法 + */ +function isActionParamsJsonValid(params?: unknown): boolean { + if (isObject(params)) { + return true; + } + try { + JSON.parse(String(params)); + return true; + } catch { + return false; + } +} + +/** + * 校验单个附加子条件。 + * + * 该方法用于提交前兜底校验,错误提示会带上 path,方便定位到第几个触发器、 + * 第几个条件组和第几个条件。 + * + * @param condition 子条件配置 + * @param path 错误提示前缀 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerCondition( + condition: RuleSceneApi.TriggerCondition, + path: string, +): null | string { + if (!condition.type) { + return `${path}:条件类型不能为空`; + } + + const isDeviceStatus = + condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS; + const isDeviceProperty = + condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY; + + // 设备状态和设备属性都必须先选择产品、设备;deviceId = 0 表示全部设备。 + if (isDeviceStatus || isDeviceProperty) { + if (isRequiredIdMissing(condition.productId)) { + return `${path}:产品不能为空`; + } + if (isDeviceIdMissing(condition.deviceId)) { + return `${path}:设备不能为空`; + } + } + + // 设备状态只校验操作符和状态枚举值。 + if (isDeviceStatus) { + if (!condition.operator) { + return `${path}:操作符不能为空`; + } + if (isEmptyVal(condition.param)) { + return `${path}:设备状态不能为空`; + } + return null; + } + + // 设备属性需要校验物模型标识符、操作符和比较值。 + if (isDeviceProperty) { + if (!condition.identifier) { + return `${path}:监控项不能为空`; + } + if (!condition.operator) { + return `${path}:操作符不能为空`; + } + if (isEmptyVal(condition.param)) { + return `${path}:比较值不能为空`; + } + return null; + } + + // 当前时间按操作符动态判断 param 是否需要填写。 + if (condition.type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME) { + if (!condition.operator) { + return `${path}:时间条件不能为空`; + } + + if ( + condition.operator === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value + ) { + return null; + } + + if (isEmptyVal(condition.param)) { + return `${path}:时间值不能为空`; + } + + if ( + condition.operator === + IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value + ) { + const parts = String(condition.param).split(','); + if (!parts[0]?.trim() || !parts[1]?.trim()) { + return `${path}:开始和结束时间不能为空`; + } + } + } + + return null; +} + +/** + * 校验触发器的附加条件组。 + * + * 条件组结构是二维数组:外层是 OR 关系的条件组,内层是 AND 关系的条件列表。 + * 这里逐组、逐条件返回第一条错误,避免一次性弹出过多提示。 + * + * @param groups 附加条件组 + * @param triggerIndex 触发器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerConditionGroups( + groups: RuleSceneApi.TriggerCondition[][] | undefined, + triggerIndex: number, +): null | string { + if (!groups?.length) { + return null; + } + + for (const [groupIndex, group] of groups.entries()) { + // 空条件组没有实际过滤条件,提交后语义不明确,需要拦截。 + if (!Array.isArray(group) || group.length === 0) { + return `触发器 ${triggerIndex + 1}:条件组 ${groupIndex + 1} 不能为空`; + } + + for (const [conditionIndex, condition] of group.entries()) { + const error = validateTriggerCondition( + condition, + `触发器 ${triggerIndex + 1} 条件组 ${groupIndex + 1} 条件 ${ + conditionIndex + 1 + }`, + ); + if (error) { + return error; + } + } + } + + return null; +} + +/** + * 校验单个触发器配置。 + * + * 该方法用于场景联动表单提交前兜底校验,避免触发器配置没有独立表单项 prop 时漏掉必填项。 + * 校验逻辑需要和触发器主条件 UI 保持一致,同时继续校验附加条件组。 + * + * @param trigger 触发器配置 + * @param index 触发器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerItem( + trigger: RuleSceneApi.Trigger, + index: number, +): null | string { + const prefix = `触发器 ${index + 1}`; + + if (!trigger.type) { + return `${prefix}:触发器类型不能为空`; + } + + // 设备类触发器都有产品、设备两个基础字段;deviceId = 0 表示全部设备。 + if (isDeviceTrigger(trigger.type)) { + if (isRequiredIdMissing(trigger.productId)) { + return `${prefix}:产品不能为空`; + } + if (isDeviceIdMissing(trigger.deviceId)) { + return `${prefix}:设备不能为空`; + } + + // 设备状态变化不依赖物模型标识符,只校验操作符和状态值。 + if (trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE) { + if (!trigger.operator) { + return `${prefix}:操作符不能为空`; + } + if (isEmptyVal(trigger.value)) { + return `${prefix}:设备状态不能为空`; + } + } else { + if (!trigger.identifier) { + return `${prefix}:监控项不能为空`; + } + + // 事件上报和服务调用只监听是否发生,不需要额外的操作符和比较值。 + const isEventOrService = + trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST || + trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE; + if (!isEventOrService) { + if (!trigger.operator) { + return `${prefix}:操作符不能为空`; + } + if (isEmptyVal(trigger.value)) { + return `${prefix}:参数值不能为空`; + } + } + } + } + + // 定时触发器需要 CRON 表达式,并继续校验 CRON 格式。 + if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) { + if (!trigger.cronExpression) { + return `${prefix}:CRON 表达式不能为空`; + } + if (!CronUtils.validate(trigger.cronExpression)) { + return `${prefix}:CRON 表达式格式不正确`; + } + } + + return validateTriggerConditionGroups(trigger.conditionGroups, index); +} + +/** + * 校验触发器列表。 + * + * 场景联动至少需要一个触发器;列表内逐条返回第一条错误,避免一次提交出现多条提示。 + * + * @param triggers 触发器列表 + * @returns 错误信息,通过则返回 null + */ +export function validateSceneRuleTriggers( + triggers?: RuleSceneApi.Trigger[], +): null | string { + if (!triggers?.length) { + return '至少需要一个触发器'; + } + + for (const [index, trigger] of triggers.entries()) { + const error = validateTriggerItem(trigger, index); + if (error) { + return error; + } + } + + return null; +} + +/** + * 校验单个执行器配置。 + * + * 该方法用于场景联动表单提交前兜底校验,避免执行器配置没有独立表单项 prop 时漏掉必填项。 + * 校验逻辑需要和执行器 UI 保持一致。 + * + * @param action 执行器配置 + * @param index 执行器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateActionItem( + action: RuleSceneApi.Action, + index: number, +): null | string { + const prefix = `执行器 ${index + 1}`; + + if (!action.type) { + return `${prefix}:执行器类型不能为空`; + } + + // 设备属性设置和设备服务调用都需要指定设备,并填写物模型参数。 + if ( + action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET || + action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE + ) { + if (isRequiredIdMissing(action.productId)) { + return `${prefix}:产品不能为空`; + } + if (isDeviceIdMissing(action.deviceId)) { + return `${prefix}:设备不能为空`; + } + if ( + action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE && + !action.identifier + ) { + return `${prefix}:服务不能为空`; + } + + if (isActionParamsEmpty(action.params)) { + return `${prefix}:参数配置不能为空`; + } + if (!isActionParamsJsonValid(action.params)) { + return `${prefix}:参数格式须为合法 JSON`; + } + + return null; + } + + // 告警恢复执行器需要绑定具体告警配置;触发告警不需要预选告警配置。 + if ( + action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER && + !action.alertConfigId + ) { + return `${prefix}:告警配置不能为空`; + } + + return null; +} + +/** + * 校验执行器列表。 + * + * 场景联动至少需要一个执行器;列表内逐条返回第一条错误,避免一次提交出现多条提示。 + * + * @param actions 执行器列表 + * @returns 错误信息,通过则返回 null + */ +export function validateSceneRuleActions( + actions?: RuleSceneApi.Action[], +): null | string { + if (!actions?.length) { + return '至少需要一个执行器'; + } + + for (const [index, action] of actions.entries()) { + const error = validateActionItem(action, index); + if (error) { + return error; + } + } + + return null; +} diff --git a/apps/web-ele/src/views/iot/rule/scene/modules/form.vue b/apps/web-ele/src/views/iot/rule/scene/modules/form.vue index 98e95c06b..449b55083 100644 --- a/apps/web-ele/src/views/iot/rule/scene/modules/form.vue +++ b/apps/web-ele/src/views/iot/rule/scene/modules/form.vue @@ -4,15 +4,7 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene'; import { computed, nextTick, reactive, ref } from 'vue'; import { useVbenDrawer } from '@vben/common-ui'; -import { - CommonStatusEnum, - IotRuleSceneActionTypeEnum, - IotRuleSceneTriggerConditionTypeEnum, - IotRuleSceneTriggerTimeOperatorEnum, - IotRuleSceneTriggerTypeEnum, - isDeviceTrigger, -} from '@vben/constants'; -import { CronUtils } from '@vben/utils'; +import { CommonStatusEnum, IotRuleSceneTriggerTypeEnum } from '@vben/constants'; import { ElForm, ElMessage } from 'element-plus'; @@ -22,6 +14,10 @@ import { updateSceneRule, } from '#/api/iot/rule/scene'; import { $t } from '#/locales'; +import { + validateSceneRuleActions, + validateSceneRuleTriggers, +} from '#/views/iot/utils/scene-rule'; import ActionSection from '../form/sections/action-section.vue'; import BasicInfoSection from '../form/sections/basic-info-section.vue'; @@ -47,6 +43,16 @@ const [Drawer, drawerApi] = useVbenDrawer({ } catch { return; } + const triggerError = validateSceneRuleTriggers(formData.value.triggers); + if (triggerError) { + ElMessage.error(triggerError); + return; + } + const actionError = validateSceneRuleActions(formData.value.actions); + if (actionError) { + ElMessage.error(actionError); + return; + } drawerApi.lock(); try { const data = { ...formData.value } as RuleSceneApi.SceneRule; @@ -117,194 +123,21 @@ function normalizeFormData(result: any): RuleSceneApi.SceneRule { /** 触发器校验 */ function validateTriggers(_rule: any, value: any, callback: any) { - if (!value || !Array.isArray(value) || value.length === 0) { - callback(new Error('至少需要一个触发器')); + const error = validateSceneRuleTriggers(value); + if (error) { + callback(new Error(error)); return; } - for (const [i, trigger] of value.entries()) { - if (!trigger.type) { - callback(new Error(`触发器 ${i + 1}:触发器类型不能为空`)); - return; - } - if (isDeviceTrigger(trigger.type)) { - if (!trigger.productId) { - callback(new Error(`触发器 ${i + 1}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值;仅 undefined / null 视为未选 - if (trigger.deviceId === undefined || trigger.deviceId === null) { - callback(new Error(`触发器 ${i + 1}:设备不能为空`)); - return; - } - const isStateUpdate = - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE; - if (!isStateUpdate && !trigger.identifier) { - callback(new Error(`触发器 ${i + 1}:物模型标识符不能为空`)); - return; - } - // 事件上报 / 服务调用:operator 由前端自动设为 '=',参数值留空表示「事件 / 调用发生即匹配」 - const isEventOrService = - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST || - trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE; - if (!isEventOrService) { - if (!trigger.operator) { - callback(new Error(`触发器 ${i + 1}:操作符不能为空`)); - return; - } - if ( - trigger.value === undefined || - trigger.value === null || - trigger.value === '' - ) { - callback(new Error(`触发器 ${i + 1}:参数值不能为空`)); - return; - } - } - } - if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) { - if (!trigger.cronExpression) { - callback(new Error(`触发器 ${i + 1}:CRON 表达式不能为空`)); - return; - } - if (!CronUtils.validate(trigger.cronExpression)) { - callback(new Error(`触发器 ${i + 1}:CRON 表达式格式不正确`)); - return; - } - } - // 递归校验 conditionGroups(嵌套条件组) - if (trigger.conditionGroups?.length) { - for (const [gi, group] of trigger.conditionGroups.entries()) { - if (!Array.isArray(group) || group.length === 0) { - callback( - new Error(`触发器 ${i + 1}:条件组 ${gi + 1} 不能为空`), - ); - return; - } - for (const [ci, condition] of group.entries()) { - const prefix = `触发器 ${i + 1} 条件组 ${gi + 1} 条件 ${ci + 1}`; - if (!condition.type) { - callback(new Error(`${prefix}:条件类型不能为空`)); - return; - } - const isDeviceStatus = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS; - const isDeviceProperty = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY; - const isCurrentTime = - condition.type === - IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME; - if (isDeviceStatus || isDeviceProperty) { - if (!condition.productId) { - callback(new Error(`${prefix}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES),是合法值 - if ( - condition.deviceId === undefined || - condition.deviceId === null - ) { - callback(new Error(`${prefix}:设备不能为空`)); - return; - } - if (isDeviceProperty && !condition.identifier) { - callback(new Error(`${prefix}:物模型标识符不能为空`)); - return; - } - } - if (!condition.operator) { - callback(new Error(`${prefix}:操作符不能为空`)); - return; - } - // 设备状态:param 是状态值(必填);设备属性:param 是比较值(必填) - if ( - (isDeviceStatus || isDeviceProperty) && - (condition.param === undefined || - condition.param === null || - condition.param === '') - ) { - callback( - new Error( - `${prefix}:${isDeviceStatus ? '设备状态' : '比较值'}不能为空`, - ), - ); - return; - } - // 当前时间:TODAY 不需要 param;BETWEEN_TIME 需要双段「v1,v2」;其它需要单段 - if (isCurrentTime) { - const op = condition.operator; - if (op === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value) { - // TODAY 无需 param - } else if ( - op === IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value - ) { - const parts = condition.param - ? String(condition.param).split(',') - : []; - if (parts.length < 2 || !parts[0] || !parts[1]) { - callback(new Error(`${prefix}:起止时间不能为空`)); - return; - } - } else if (!condition.param) { - callback(new Error(`${prefix}:时间值不能为空`)); - return; - } - } - } - } - } - } callback(); } /** 执行器校验 */ function validateActions(_rule: any, value: any, callback: any) { - if (!value || !Array.isArray(value) || value.length === 0) { - callback(new Error('至少需要一个执行器')); + const error = validateSceneRuleActions(value); + if (error) { + callback(new Error(error)); return; } - for (const [i, action] of value.entries()) { - if (!action.type) { - callback(new Error(`执行器 ${i + 1}:执行器类型不能为空`)); - return; - } - if ( - action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET || - action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE - ) { - if (!action.productId) { - callback(new Error(`执行器 ${i + 1}:产品不能为空`)); - return; - } - // deviceId = 0 表示「全部设备」(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES); - // 后端 IotDevicePropertySetSceneRuleAction / IotDeviceServiceInvokeSceneRuleAction - // 均支持广播执行,因此 0 是合法值,仅 undefined / null 视为未选 - if (action.deviceId === undefined || action.deviceId === null) { - callback(new Error(`执行器 ${i + 1}:设备不能为空`)); - return; - } - if ( - action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE && - !action.identifier - ) { - callback(new Error(`执行器 ${i + 1}:服务不能为空`)); - return; - } - if (!action.params || Object.keys(action.params).length === 0) { - callback(new Error(`执行器 ${i + 1}:参数配置不能为空`)); - return; - } - } - // 仅恢复告警动作需要选择已有告警配置;触发告警动作不需要预选 alertConfigId - if ( - action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER && - !action.alertConfigId - ) { - callback(new Error(`执行器 ${i + 1}:告警配置不能为空`)); - return; - } - } callback(); } diff --git a/apps/web-ele/src/views/iot/utils/scene-rule.ts b/apps/web-ele/src/views/iot/utils/scene-rule.ts new file mode 100644 index 000000000..9b038806f --- /dev/null +++ b/apps/web-ele/src/views/iot/utils/scene-rule.ts @@ -0,0 +1,401 @@ +import type { RuleSceneApi } from '#/api/iot/rule/scene'; + +import { + IotRuleSceneActionTypeEnum, + IotRuleSceneTriggerConditionTypeEnum, + IotRuleSceneTriggerTimeOperatorEnum, + IotRuleSceneTriggerTypeEnum, + isDeviceTrigger, +} from '@vben/constants'; +import { CronUtils, isEmptyVal, isObject } from '@vben/utils'; + +/** + * 判断普通 ID 选择值是否缺失。 + * + * 产品、告警配置等普通业务 ID 应为正数;`0` 不代表有效业务数据。 + * + * @param value 普通业务 ID + * @returns 是否缺失 + */ +function isRequiredIdMissing(value: unknown): boolean { + return !value; +} + +/** + * 判断设备 ID 选择值是否缺失。 + * + * 场景联动的设备选择器中,`0` 表示「全部设备」,是合法值;因此这里只能把 + * `undefined`、`null`、空字符串视为未选择,不能使用普通 falsy 判断。 + * + * @param value 设备 ID + * @returns 是否缺失 + */ +function isDeviceIdMissing(value: unknown): boolean { + return isEmptyVal(value); +} + +/** + * 判断执行器参数是否为空。 + * + * 参数配置当前以 JSON 字符串为主,同时兼容历史对象值: + * - 空字符串、空白字符串视为空; + * - 空对象 `{}` 视为空; + * - 非空 JSON 对象视为已配置; + * - 非法 JSON 不在这里判空,交给 JSON 格式校验返回更准确的错误。 + * + * @param params 执行器参数 + * @returns 是否为空 + */ +export function isActionParamsEmpty(params?: unknown): boolean { + if (isEmptyVal(params)) { + return true; + } + + if (typeof params === 'string') { + if (!params.trim()) { + return true; + } + try { + const parsed = JSON.parse(params); + if (isObject(parsed) && !Array.isArray(parsed)) { + return Object.keys(parsed).length === 0; + } + } catch { + return false; + } + return false; + } + + if (isObject(params) && !Array.isArray(params)) { + return Object.keys(params).length === 0; + } + + return false; +} + +/** + * 判断执行器参数是否为合法 JSON。 + * + * 参数编辑组件正常会保存 JSON 字符串;编辑旧数据时可能仍然是对象,对象可直接视为合法。 + * + * @param params 执行器参数 + * @returns 是否合法 + */ +function isActionParamsJsonValid(params?: unknown): boolean { + if (isObject(params)) { + return true; + } + try { + JSON.parse(String(params)); + return true; + } catch { + return false; + } +} + +/** + * 校验单个附加子条件。 + * + * 该方法用于提交前兜底校验,错误提示会带上 path,方便定位到第几个触发器、 + * 第几个条件组和第几个条件。 + * + * @param condition 子条件配置 + * @param path 错误提示前缀 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerCondition( + condition: RuleSceneApi.TriggerCondition, + path: string, +): null | string { + if (!condition.type) { + return `${path}:条件类型不能为空`; + } + + const isDeviceStatus = + condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS; + const isDeviceProperty = + condition.type === IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY; + + // 设备状态和设备属性都必须先选择产品、设备;deviceId = 0 表示全部设备。 + if (isDeviceStatus || isDeviceProperty) { + if (isRequiredIdMissing(condition.productId)) { + return `${path}:产品不能为空`; + } + if (isDeviceIdMissing(condition.deviceId)) { + return `${path}:设备不能为空`; + } + } + + // 设备状态只校验操作符和状态枚举值。 + if (isDeviceStatus) { + if (!condition.operator) { + return `${path}:操作符不能为空`; + } + if (isEmptyVal(condition.param)) { + return `${path}:设备状态不能为空`; + } + return null; + } + + // 设备属性需要校验物模型标识符、操作符和比较值。 + if (isDeviceProperty) { + if (!condition.identifier) { + return `${path}:监控项不能为空`; + } + if (!condition.operator) { + return `${path}:操作符不能为空`; + } + if (isEmptyVal(condition.param)) { + return `${path}:比较值不能为空`; + } + return null; + } + + // 当前时间按操作符动态判断 param 是否需要填写。 + if (condition.type === IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME) { + if (!condition.operator) { + return `${path}:时间条件不能为空`; + } + + if ( + condition.operator === IotRuleSceneTriggerTimeOperatorEnum.TODAY.value + ) { + return null; + } + + if (isEmptyVal(condition.param)) { + return `${path}:时间值不能为空`; + } + + if ( + condition.operator === + IotRuleSceneTriggerTimeOperatorEnum.BETWEEN_TIME.value + ) { + const parts = String(condition.param).split(','); + if (!parts[0]?.trim() || !parts[1]?.trim()) { + return `${path}:开始和结束时间不能为空`; + } + } + } + + return null; +} + +/** + * 校验触发器的附加条件组。 + * + * 条件组结构是二维数组:外层是 OR 关系的条件组,内层是 AND 关系的条件列表。 + * 这里逐组、逐条件返回第一条错误,避免一次性弹出过多提示。 + * + * @param groups 附加条件组 + * @param triggerIndex 触发器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerConditionGroups( + groups: RuleSceneApi.TriggerCondition[][] | undefined, + triggerIndex: number, +): null | string { + if (!groups?.length) { + return null; + } + + for (const [groupIndex, group] of groups.entries()) { + // 空条件组没有实际过滤条件,提交后语义不明确,需要拦截。 + if (!Array.isArray(group) || group.length === 0) { + return `触发器 ${triggerIndex + 1}:条件组 ${groupIndex + 1} 不能为空`; + } + + for (const [conditionIndex, condition] of group.entries()) { + const error = validateTriggerCondition( + condition, + `触发器 ${triggerIndex + 1} 条件组 ${groupIndex + 1} 条件 ${ + conditionIndex + 1 + }`, + ); + if (error) { + return error; + } + } + } + + return null; +} + +/** + * 校验单个触发器配置。 + * + * 该方法用于场景联动表单提交前兜底校验,避免触发器配置没有独立表单项 prop 时漏掉必填项。 + * 校验逻辑需要和触发器主条件 UI 保持一致,同时继续校验附加条件组。 + * + * @param trigger 触发器配置 + * @param index 触发器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateTriggerItem( + trigger: RuleSceneApi.Trigger, + index: number, +): null | string { + const prefix = `触发器 ${index + 1}`; + + if (!trigger.type) { + return `${prefix}:触发器类型不能为空`; + } + + // 设备类触发器都有产品、设备两个基础字段;deviceId = 0 表示全部设备。 + if (isDeviceTrigger(trigger.type)) { + if (isRequiredIdMissing(trigger.productId)) { + return `${prefix}:产品不能为空`; + } + if (isDeviceIdMissing(trigger.deviceId)) { + return `${prefix}:设备不能为空`; + } + + // 设备状态变化不依赖物模型标识符,只校验操作符和状态值。 + if (trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE) { + if (!trigger.operator) { + return `${prefix}:操作符不能为空`; + } + if (isEmptyVal(trigger.value)) { + return `${prefix}:设备状态不能为空`; + } + } else { + if (!trigger.identifier) { + return `${prefix}:监控项不能为空`; + } + + // 事件上报和服务调用只监听是否发生,不需要额外的操作符和比较值。 + const isEventOrService = + trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST || + trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE; + if (!isEventOrService) { + if (!trigger.operator) { + return `${prefix}:操作符不能为空`; + } + if (isEmptyVal(trigger.value)) { + return `${prefix}:参数值不能为空`; + } + } + } + } + + // 定时触发器需要 CRON 表达式,并继续校验 CRON 格式。 + if (trigger.type === IotRuleSceneTriggerTypeEnum.TIMER) { + if (!trigger.cronExpression) { + return `${prefix}:CRON 表达式不能为空`; + } + if (!CronUtils.validate(trigger.cronExpression)) { + return `${prefix}:CRON 表达式格式不正确`; + } + } + + return validateTriggerConditionGroups(trigger.conditionGroups, index); +} + +/** + * 校验触发器列表。 + * + * 场景联动至少需要一个触发器;列表内逐条返回第一条错误,避免一次提交出现多条提示。 + * + * @param triggers 触发器列表 + * @returns 错误信息,通过则返回 null + */ +export function validateSceneRuleTriggers( + triggers?: RuleSceneApi.Trigger[], +): null | string { + if (!triggers?.length) { + return '至少需要一个触发器'; + } + + for (const [index, trigger] of triggers.entries()) { + const error = validateTriggerItem(trigger, index); + if (error) { + return error; + } + } + + return null; +} + +/** + * 校验单个执行器配置。 + * + * 该方法用于场景联动表单提交前兜底校验,避免执行器配置没有独立表单项 prop 时漏掉必填项。 + * 校验逻辑需要和执行器 UI 保持一致。 + * + * @param action 执行器配置 + * @param index 执行器在列表中的下标,用于生成可定位的错误提示 + * @returns 错误信息,通过则返回 null + */ +export function validateActionItem( + action: RuleSceneApi.Action, + index: number, +): null | string { + const prefix = `执行器 ${index + 1}`; + + if (!action.type) { + return `${prefix}:执行器类型不能为空`; + } + + // 设备属性设置和设备服务调用都需要指定设备,并填写物模型参数。 + if ( + action.type === IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET || + action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE + ) { + if (isRequiredIdMissing(action.productId)) { + return `${prefix}:产品不能为空`; + } + if (isDeviceIdMissing(action.deviceId)) { + return `${prefix}:设备不能为空`; + } + if ( + action.type === IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE && + !action.identifier + ) { + return `${prefix}:服务不能为空`; + } + + if (isActionParamsEmpty(action.params)) { + return `${prefix}:参数配置不能为空`; + } + if (!isActionParamsJsonValid(action.params)) { + return `${prefix}:参数格式须为合法 JSON`; + } + + return null; + } + + // 告警恢复执行器需要绑定具体告警配置;触发告警不需要预选告警配置。 + if ( + action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER && + !action.alertConfigId + ) { + return `${prefix}:告警配置不能为空`; + } + + return null; +} + +/** + * 校验执行器列表。 + * + * 场景联动至少需要一个执行器;列表内逐条返回第一条错误,避免一次提交出现多条提示。 + * + * @param actions 执行器列表 + * @returns 错误信息,通过则返回 null + */ +export function validateSceneRuleActions( + actions?: RuleSceneApi.Action[], +): null | string { + if (!actions?.length) { + return '至少需要一个执行器'; + } + + for (const [index, action] of actions.entries()) { + const error = validateActionItem(action, index); + if (error) { + return error; + } + } + + return null; +}