From e7a61ce150d36d255f178c5992ee0c7ddc4007a4 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Wed, 20 May 2026 00:41:06 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=88iot=EF=BC=89:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20antd=20=E9=87=8C=E7=9A=84=E6=95=B4=E4=BD=93=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=A3=8E=E6=A0=BC=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/iot/rule/scene/index.ts | 33 +- apps/web-antd/src/api/iot/thingmodel/index.ts | 4 +- apps/web-antd/src/utils/cron.ts | 391 ++++++++++++++++++ apps/web-antd/src/utils/index.ts | 1 + .../src/views/iot/alert/config/data.ts | 3 +- .../src/views/iot/alert/config/index.vue | 12 +- .../views/iot/alert/config/modules/form.vue | 6 +- .../src/views/iot/alert/record/data.ts | 18 +- .../src/views/iot/alert/record/index.vue | 24 +- .../src/views/iot/device/device/data.ts | 3 +- .../device/device/detail/modules/config.vue | 8 +- .../device/device/detail/modules/message.vue | 2 +- .../detail/modules/modbus-config-form.vue | 2 - .../device/detail/modules/modbus-config.vue | 4 +- .../detail/modules/modbus-point-form.vue | 8 +- .../device/detail/modules/simulator.vue | 30 +- .../device/detail/modules/sub-device.vue | 4 +- .../detail/modules/thing-model-event.vue | 2 +- .../modules/thing-model-property-history.vue | 9 +- .../detail/modules/thing-model-property.vue | 2 +- .../detail/modules/thing-model-service.vue | 2 +- .../src/views/iot/device/device/index.vue | 3 +- .../views/iot/device/device/modules/form.vue | 10 +- .../iot/device/device/modules/group-form.vue | 4 +- .../iot/device/device/modules/import-form.vue | 5 +- .../src/views/iot/device/group/data.ts | 3 +- .../src/views/iot/device/group/index.vue | 2 +- .../views/iot/device/group/modules/form.vue | 3 + apps/web-antd/src/views/iot/home/index.vue | 10 +- .../iot/home/modules/device-count-card.vue | 2 +- .../home/modules/device-state-count-card.vue | 2 +- .../iot/home/modules/message-trend-card.vue | 2 +- .../src/views/iot/product/category/data.ts | 5 +- .../src/views/iot/product/category/index.vue | 2 +- .../iot/product/category/modules/form.vue | 2 +- .../iot/product/product/components/select.vue | 6 +- .../src/views/iot/product/product/data.ts | 11 +- .../iot/product/product/detail/index.vue | 2 +- .../product/product/detail/modules/header.vue | 1 - .../product/product/detail/modules/info.vue | 2 +- .../iot/product/product/modules/card-view.vue | 8 +- .../iot/product/product/modules/form.vue | 6 +- .../src/views/iot/rule/data/rule/index.vue | 8 +- .../web-antd/src/views/iot/thingmodel/data.ts | 5 +- .../src/views/iot/thingmodel/index.vue | 5 +- .../thing-model-array-data-specs.vue | 73 ++++ .../thing-model-enum-data-specs.vue | 67 +++ .../thing-model-number-data-specs.vue | 78 ++++ .../thing-model-struct-data-specs.vue | 160 +++++++ .../src/views/iot/thingmodel/modules/tsl.vue | 2 +- .../web-antd/src/views/iot/utils/constants.ts | 20 +- .../@core/base/shared/src/utils/inference.ts | 2 +- 52 files changed, 920 insertions(+), 159 deletions(-) create mode 100644 apps/web-antd/src/utils/cron.ts create mode 100644 apps/web-antd/src/views/iot/thingmodel/modules/data-specs/thing-model-array-data-specs.vue create mode 100644 apps/web-antd/src/views/iot/thingmodel/modules/data-specs/thing-model-enum-data-specs.vue create mode 100644 apps/web-antd/src/views/iot/thingmodel/modules/data-specs/thing-model-number-data-specs.vue create mode 100644 apps/web-antd/src/views/iot/thingmodel/modules/data-specs/thing-model-struct-data-specs.vue diff --git a/apps/web-antd/src/api/iot/rule/scene/index.ts b/apps/web-antd/src/api/iot/rule/scene/index.ts index 9a8acf758..fb30ed59b 100644 --- a/apps/web-antd/src/api/iot/rule/scene/index.ts +++ b/apps/web-antd/src/api/iot/rule/scene/index.ts @@ -16,20 +16,14 @@ export namespace RuleSceneApi { /** 场景联动规则的触发器 */ export interface Trigger { - type?: string; + type?: number; productId?: number; deviceId?: number; identifier?: string; operator?: string; value?: any; cronExpression?: string; - conditionGroups?: TriggerConditionGroup[]; - } - - /** 场景联动规则的触发条件组 */ - export interface TriggerConditionGroup { - conditions?: TriggerCondition[]; - operator?: string; + conditionGroups?: TriggerCondition[][]; } /** 场景联动规则的触发条件 */ @@ -39,17 +33,19 @@ export namespace RuleSceneApi { identifier?: string; operator?: string; value?: any; - type?: string; + type?: number; + param?: string; } /** 场景联动规则的动作 */ export interface Action { - type?: string; + type?: number; productId?: number; deviceId?: number; identifier?: string; value?: any; alertConfigId?: number; + params?: Record; } } @@ -67,20 +63,15 @@ export interface IotSceneRule { /** IoT 场景联动规则触发器 */ export interface Trigger { - type?: string; + type?: number; productId?: number; deviceId?: number; identifier?: string; operator?: string; value?: any; cronExpression?: string; - conditionGroups?: TriggerConditionGroup[]; -} - -/** IoT 场景联动规则触发条件组 */ -export interface TriggerConditionGroup { - conditions?: TriggerCondition[]; - operator?: string; + // 后端结构:List>;外层「或」、组内「且」 + conditionGroups?: TriggerCondition[][]; } /** IoT 场景联动规则触发条件 */ @@ -90,19 +81,19 @@ export interface TriggerCondition { identifier?: string; operator?: string; value?: any; - type?: string; + type?: number; param?: string; } /** IoT 场景联动规则动作 */ export interface Action { - type?: string; + type?: number; productId?: number; deviceId?: number; identifier?: string; value?: any; alertConfigId?: number; - params?: string; + params?: Record; } /** 查询场景联动规则分页 */ diff --git a/apps/web-antd/src/api/iot/thingmodel/index.ts b/apps/web-antd/src/api/iot/thingmodel/index.ts index c8bcb7950..4c8d0bb76 100644 --- a/apps/web-antd/src/api/iot/thingmodel/index.ts +++ b/apps/web-antd/src/api/iot/thingmodel/index.ts @@ -1,3 +1,5 @@ +import type { Rule } from 'ant-design-vue/es/form'; + import type { PageParam, PageResult } from '@vben/request'; import { isEmpty } from '@vben/utils'; @@ -123,7 +125,7 @@ export function buildIdentifierLikeNameValidator(label: string) { } /** IoT 物模型表单校验规则 */ -export const ThingModelFormRules = { +export const ThingModelFormRules: Record = { name: [ { required: true, message: '功能名称不能为空', trigger: 'blur' }, { diff --git a/apps/web-antd/src/utils/cron.ts b/apps/web-antd/src/utils/cron.ts new file mode 100644 index 000000000..7bdbbcd88 --- /dev/null +++ b/apps/web-antd/src/utils/cron.ts @@ -0,0 +1,391 @@ +// TODO @AI:能不能弄到通用的,基础 utils 里?方便 antd 和 ele 复用? +/** + * CRON 表达式工具类;提供 CRON 表达式的解析、格式化、验证等功能 + */ + +/** CRON 字段类型枚举 */ +export enum CronFieldType { + DAY = 'day', + HOUR = 'hour', + MINUTE = 'minute', + MONTH = 'month', + SECOND = 'second', + WEEK = 'week', + YEAR = 'year', +} + +/** CRON 字段配置 */ +export interface CronFieldConfig { + key: CronFieldType; + label: string; + max: number; + min: number; + names?: Record; +} + +/** CRON 字段配置常量 */ +export const CRON_FIELD_CONFIGS: Record = { + [CronFieldType.SECOND]: { + key: CronFieldType.SECOND, + label: '秒', + min: 0, + max: 59, + }, + [CronFieldType.MINUTE]: { + key: CronFieldType.MINUTE, + label: '分', + min: 0, + max: 59, + }, + [CronFieldType.HOUR]: { + key: CronFieldType.HOUR, + label: '时', + min: 0, + max: 23, + }, + [CronFieldType.DAY]: { + key: CronFieldType.DAY, + label: '日', + min: 1, + max: 31, + }, + [CronFieldType.MONTH]: { + key: CronFieldType.MONTH, + label: '月', + min: 1, + max: 12, + names: { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12, + }, + }, + [CronFieldType.WEEK]: { + key: CronFieldType.WEEK, + label: '周', + min: 0, + max: 7, + names: { + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6, + }, + }, + [CronFieldType.YEAR]: { + key: CronFieldType.YEAR, + label: '年', + min: 1970, + max: 2099, + }, +}; + +/** 解析后的 CRON 字段 */ +export interface ParsedCronField { + description: string; + original: string; + type: + | 'any' + | 'last' + | 'list' + | 'nth' + | 'range' + | 'specific' + | 'step' + | 'weekday'; + values: number[]; +} + +/** 解析后的 CRON 表达式 */ +export interface ParsedCronExpression { + day: ParsedCronField; + description: string; + hour: ParsedCronField; + isValid: boolean; + minute: ParsedCronField; + month: ParsedCronField; + nextExecutionTime?: Date; + second: ParsedCronField; + week: ParsedCronField; + year?: ParsedCronField; +} + +/** CRON 表达式工具类 */ +export const CronUtils = { + /** 验证 CRON 表达式格式 */ + validate(cronExpression: string): boolean { + if (!cronExpression || typeof cronExpression !== 'string') { + return false; + } + const parts = cronExpression.trim().split(/\s+/); + if (parts.length < 5 || parts.length > 7) { + return false; + } + const cronRegex = /^[\d#*,/?LW\-]+$/; + return parts.every((part) => cronRegex.test(part)); + }, + + /** 解析单个 CRON 字段 */ + parseField( + fieldValue: string, + fieldType: CronFieldType, + config: CronFieldConfig, + ): ParsedCronField { + const field: ParsedCronField = { + type: 'any', + values: [], + original: fieldValue, + description: '', + }; + if (fieldValue === '*' || fieldValue === '?') { + field.type = 'any'; + field.description = `每${config.label}`; + return field; + } + if (fieldValue === 'L' && fieldType === CronFieldType.DAY) { + field.type = 'last'; + field.description = '每月最后一天'; + return field; + } + if (fieldValue.includes('-')) { + const [start, end] = fieldValue.split('-').map(Number); + if ( + !Number.isNaN(start) && + !Number.isNaN(end) && + start! >= config.min && + end! <= config.max + ) { + field.type = 'range'; + field.values = Array.from( + { length: end! - start! + 1 }, + (_, i) => start! + i, + ); + field.description = `${config.label} ${start}-${end}`; + } + return field; + } + if (fieldValue.includes('/')) { + const [base, step] = fieldValue.split('/'); + const stepNum = Number(step); + if (!Number.isNaN(stepNum) && stepNum > 0) { + field.type = 'step'; + if (base === '*') { + field.description = `每 ${stepNum} ${config.label}`; + } else { + const startNum = Number(base); + field.description = `从 ${startNum} 开始每 ${stepNum} ${config.label}`; + } + } + return field; + } + if (fieldValue.includes(',')) { + const values = fieldValue + .split(',') + .map(Number) + .filter((n) => !Number.isNaN(n)); + if (values.length > 0) { + field.type = 'list'; + field.values = values; + field.description = `${config.label} ${values.join(',')}`; + } + return field; + } + const numValue = Number(fieldValue); + if ( + !Number.isNaN(numValue) && + numValue >= config.min && + numValue <= config.max + ) { + field.type = 'specific'; + field.values = [numValue]; + field.description = `${config.label} ${numValue}`; + } + return field; + }, + + /** 解析完整的 CRON 表达式 */ + parse(cronExpression: string): ParsedCronExpression { + const result: ParsedCronExpression = { + second: { type: 'any', values: [], original: '*', description: '每秒' }, + minute: { type: 'any', values: [], original: '*', description: '每分' }, + hour: { type: 'any', values: [], original: '*', description: '每时' }, + day: { type: 'any', values: [], original: '*', description: '每日' }, + month: { type: 'any', values: [], original: '*', description: '每月' }, + week: { type: 'any', values: [], original: '?', description: '任意周' }, + isValid: false, + description: '', + }; + if (!this.validate(cronExpression)) { + result.description = '无效的 CRON 表达式'; + return result; + } + const parts = cronExpression.trim().split(/\s+/); + const fieldTypes = [ + CronFieldType.SECOND, + CronFieldType.MINUTE, + CronFieldType.HOUR, + CronFieldType.DAY, + CronFieldType.MONTH, + CronFieldType.WEEK, + ]; + const startIndex = parts.length === 5 ? 1 : 0; + for (const [i, part] of parts.entries()) { + const fieldType = fieldTypes[i + startIndex]; + if (fieldType && CRON_FIELD_CONFIGS[fieldType]) { + const config = CRON_FIELD_CONFIGS[fieldType]; + (result as any)[fieldType] = this.parseField(part, fieldType, config); + } + } + if (parts.length === 7) { + const yearConfig = CRON_FIELD_CONFIGS[CronFieldType.YEAR]; + result.year = this.parseField(parts[6]!, CronFieldType.YEAR, yearConfig); + } + result.isValid = true; + result.description = this.generateDescription(result); + return result; + }, + + /** 生成 CRON 表达式的可读描述 */ + generateDescription(parsed: ParsedCronExpression): string { + const parts: string[] = []; + if (parsed.hour.type === 'specific' && parsed.minute.type === 'specific') { + const hour = parsed.hour.values[0]!; + const minute = parsed.minute.values[0]!; + parts.push( + `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`, + ); + } else if (parsed.hour.type === 'specific') { + parts.push(`每天 ${parsed.hour.values[0]} 点`); + } else if ( + parsed.minute.type === 'specific' && + parsed.minute.values[0] === 0 && + parsed.hour.type === 'any' + ) { + parts.push('每小时整点'); + } else if (parsed.minute.type === 'step') { + const step = parsed.minute.original.split('/')[1]; + parts.push(`每 ${step} 分钟`); + } else if (parsed.hour.type === 'step') { + const step = parsed.hour.original.split('/')[1]; + parts.push(`每 ${step} 小时`); + } + if (parsed.day.type === 'specific') { + parts.push(`每月 ${parsed.day.values[0]} 日`); + } else if (parsed.week.type === 'specific') { + const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + const weekDay = parsed.week.values[0]!; + if (weekDay >= 0 && weekDay <= 6) { + parts.push(`每${weekNames[weekDay]}`); + } + } else if (parsed.week.type === 'range') { + parts.push('工作日'); + } + if (parsed.month.type === 'specific') { + parts.push(`${parsed.month.values[0]} 月`); + } + return parts.length > 0 ? parts.join(' ') : '自定义时间规则'; + }, + + /** 格式化 CRON 表达式为可读文本 */ + format(cronExpression: string): string { + if (!cronExpression) return ''; + const parsed = this.parse(cronExpression); + return parsed.isValid ? parsed.description : cronExpression; + }, + + /** 计算 CRON 表达式的下次执行时间(简化版,仅覆盖常见模式) */ + getNextExecutionTime( + cronExpression: string, + fromDate?: Date, + ): Date | null { + const parsed = this.parse(cronExpression); + if (!parsed.isValid) { + return null; + } + const now = fromDate || new Date(); + const nextTime = new Date(now.getTime() + 1000); + if (parsed.second.type === 'specific' && parsed.minute.type === 'any') { + const targetSecond = parsed.second.values[0]!; + nextTime.setSeconds(targetSecond, 0); + if (nextTime <= now) { + nextTime.setMinutes(nextTime.getMinutes() + 1); + } + return nextTime; + } + if ( + parsed.second.type === 'specific' && + parsed.minute.type === 'specific' && + parsed.hour.type === 'any' + ) { + const targetSecond = parsed.second.values[0]!; + const targetMinute = parsed.minute.values[0]!; + nextTime.setMinutes(targetMinute, targetSecond, 0); + if (nextTime <= now) { + nextTime.setHours(nextTime.getHours() + 1); + } + return nextTime; + } + if ( + parsed.second.type === 'specific' && + parsed.minute.type === 'specific' && + parsed.hour.type === 'specific' + ) { + const targetSecond = parsed.second.values[0]!; + const targetMinute = parsed.minute.values[0]!; + const targetHour = parsed.hour.values[0]!; + nextTime.setHours(targetHour, targetMinute, targetSecond, 0); + if (nextTime <= now) { + nextTime.setDate(nextTime.getDate() + 1); + } + return nextTime; + } + if (parsed.minute.type === 'step') { + const step = Number.parseInt(parsed.minute.original.split('/')[1]!); + const currentMinute = nextTime.getMinutes(); + const nextMinute = Math.ceil(currentMinute / step) * step; + if (nextMinute >= 60) { + nextTime.setHours(nextTime.getHours() + 1, 0, 0, 0); + } else { + nextTime.setMinutes(nextMinute, 0, 0); + } + return nextTime; + } + return new Date(now.getTime() + 60_000); + }, + + /** 获取 CRON 表达式的执行频率描述 */ + getFrequencyDescription(cronExpression: string): string { + const parsed = this.parse(cronExpression); + if (!parsed.isValid) { + return '无效表达式'; + } + if (parsed.second.type === 'any' && parsed.minute.type === 'any') { + return '每秒执行'; + } + if (parsed.minute.type === 'any' && parsed.hour.type === 'any') { + return '每分钟执行'; + } + if (parsed.hour.type === 'any' && parsed.day.type === 'any') { + return '每小时执行'; + } + if (parsed.day.type === 'any' && parsed.month.type === 'any') { + return '每天执行'; + } + if (parsed.month.type === 'any') { + return '每月执行'; + } + return '按计划执行'; + }, +}; diff --git a/apps/web-antd/src/utils/index.ts b/apps/web-antd/src/utils/index.ts index 0a4c03ca5..5f3457367 100644 --- a/apps/web-antd/src/utils/index.ts +++ b/apps/web-antd/src/utils/index.ts @@ -1,5 +1,6 @@ import type { Recordable } from '@vben/types'; +export * from './cron'; export * from './rangePickerProps'; export * from './routerHelper'; diff --git a/apps/web-antd/src/views/iot/alert/config/data.ts b/apps/web-antd/src/views/iot/alert/config/data.ts index 6bd5d5f00..0189db9b8 100644 --- a/apps/web-antd/src/views/iot/alert/config/data.ts +++ b/apps/web-antd/src/views/iot/alert/config/data.ts @@ -1,5 +1,6 @@ import type { VbenFormSchema } from '#/adapter/form'; import type { VxeTableGridOptions } from '#/adapter/vxe-table'; +import type { AlertConfigApi } from '#/api/iot/alert/config'; import { CommonStatusEnum, DICT_TYPE } from '@vben/constants'; import { getDictOptions } from '@vben/hooks'; @@ -137,7 +138,7 @@ export function useGridFormSchema(): VbenFormSchema[] { } /** 列表的字段 */ -export function useGridColumns(): VxeTableGridOptions['columns'] { +export function useGridColumns(): VxeTableGridOptions['columns'] { return [ { type: 'checkbox', width: 40 }, { diff --git a/apps/web-antd/src/views/iot/alert/config/index.vue b/apps/web-antd/src/views/iot/alert/config/index.vue index a7e5b9868..98cc61c27 100644 --- a/apps/web-antd/src/views/iot/alert/config/index.vue +++ b/apps/web-antd/src/views/iot/alert/config/index.vue @@ -1,4 +1,4 @@ -