From b3462c3286f860417ce75cf87b37a292bbd13b0b Mon Sep 17 00:00:00 2001 From: YunaiV Date: Thu, 21 May 2026 08:53:16 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=88iot=EF=BC=89:=20=E5=B0=86=20antd?= =?UTF-8?q?=20=E7=9A=84=20rule=20scene=20=E8=BF=81=E7=A7=BB=E5=88=B0=20ele?= =?UTF-8?q?=20=E9=87=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/iot/thingmodel/index.ts | 2 +- .../form/configs/device-control-config.vue | 4 +- .../form/selectors/property-selector.vue | 4 +- .../src/views/iot/thingmodel/modules/tsl.vue | 4 +- apps/web-ele/src/api/iot/thingmodel/index.ts | 2 +- apps/web-ele/src/views/iot/rule/scene/data.ts | 139 +++++ .../rule/scene/form/configs/alert-config.vue | 85 +++ .../scene/form/configs/condition-config.vue | 344 ++++++++++ .../configs/current-time-condition-config.vue | 258 ++++++++ .../form/configs/device-control-config.vue | 427 +++++++++++++ .../form/configs/device-trigger-config.vue | 271 ++++++++ .../configs/main-condition-inner-config.vue | 380 ++++++++++++ .../configs/sub-condition-group-config.vue | 167 +++++ .../configs/timer-condition-group-config.vue | 179 ++++++ .../scene/form/inputs/json-params-input.vue | 587 ++++++++++++++++++ .../rule/scene/form/inputs/value-input.vue | 302 +++++++++ .../scene/form/sections/action-section.vue | 312 ++++++++++ .../form/sections/basic-info-section.vue | 104 ++++ .../scene/form/sections/trigger-section.vue | 254 ++++++++ .../scene/form/selectors/device-selector.vue | 111 ++++ .../form/selectors/operator-selector.vue | 278 +++++++++ .../scene/form/selectors/product-selector.vue | 85 +++ .../form/selectors/property-selector.vue | 465 ++++++++++++++ .../src/views/iot/rule/scene/index.vue | 394 ++++++++++++ .../src/views/iot/rule/scene/modules/form.vue | 255 ++++++++ .../src/views/iot/thingmodel/modules/tsl.vue | 4 +- packages/utils/src/cron.ts | 390 ++++++++++++ packages/utils/src/index.ts | 1 + 28 files changed, 5798 insertions(+), 10 deletions(-) create mode 100644 apps/web-ele/src/views/iot/rule/scene/data.ts create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/alert-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/condition-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/current-time-condition-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/device-control-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/device-trigger-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/main-condition-inner-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/sub-condition-group-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/configs/timer-condition-group-config.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/inputs/json-params-input.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/inputs/value-input.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/sections/action-section.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/sections/basic-info-section.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/sections/trigger-section.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/selectors/device-selector.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/selectors/operator-selector.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/selectors/product-selector.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/form/selectors/property-selector.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/index.vue create mode 100644 apps/web-ele/src/views/iot/rule/scene/modules/form.vue create mode 100644 packages/utils/src/cron.ts diff --git a/apps/web-antd/src/api/iot/thingmodel/index.ts b/apps/web-antd/src/api/iot/thingmodel/index.ts index 45d50295d..dc42a85e9 100644 --- a/apps/web-antd/src/api/iot/thingmodel/index.ts +++ b/apps/web-antd/src/api/iot/thingmodel/index.ts @@ -244,7 +244,7 @@ export function deleteThingModel(id: number) { } /** 获取物模型 TSL */ -export function getThingModelTSL(productId: number) { +export function getThingModelTSLByProductId(productId: number) { return requestClient.get( '/iot/thing-model/get-tsl', { diff --git a/apps/web-antd/src/views/iot/rule/scene/form/configs/device-control-config.vue b/apps/web-antd/src/views/iot/rule/scene/form/configs/device-control-config.vue index f1e1dec1f..1457f2e63 100644 --- a/apps/web-antd/src/views/iot/rule/scene/form/configs/device-control-config.vue +++ b/apps/web-antd/src/views/iot/rule/scene/form/configs/device-control-config.vue @@ -15,7 +15,7 @@ import { isObject } from '@vben/utils'; import { useVModel } from '@vueuse/core'; import { Col, Form, Row, Select, Tag } from 'ant-design-vue'; -import { getThingModelTSL } from '#/api/iot/thingmodel'; +import { getThingModelTSLByProductId } from '#/api/iot/thingmodel'; import JsonParamsInput from '../inputs/json-params-input.vue'; import DeviceSelector from '../selectors/device-selector.vue'; @@ -137,7 +137,7 @@ function handleServiceChange(serviceIdentifier?: any) { async function fetchThingModelTSL(productId: number) { if (!productId) return null; try { - return await getThingModelTSL(productId); + return await getThingModelTSLByProductId(productId); } catch (error) { console.error('获取物模型 TSL 数据失败:', error); return null; diff --git a/apps/web-antd/src/views/iot/rule/scene/form/selectors/property-selector.vue b/apps/web-antd/src/views/iot/rule/scene/form/selectors/property-selector.vue index 902a089d9..b2bd067ad 100644 --- a/apps/web-antd/src/views/iot/rule/scene/form/selectors/property-selector.vue +++ b/apps/web-antd/src/views/iot/rule/scene/form/selectors/property-selector.vue @@ -18,7 +18,7 @@ import { IconifyIcon } from '@vben/icons'; import { useVModel } from '@vueuse/core'; import { Button, Popover, Select, Tag } from 'ant-design-vue'; -import { getThingModelTSL } from '#/api/iot/thingmodel'; +import { getThingModelTSLByProductId } from '#/api/iot/thingmodel'; /** 属性选择器组件 */ defineOptions({ name: 'PropertySelector' }); @@ -110,7 +110,7 @@ async function loadThingModelTSL() { } loading.value = true; try { - const tsl = await getThingModelTSL(props.productId); + const tsl = await getThingModelTSLByProductId(props.productId); propertyList.value = parseThingModelData(tsl); } catch (error) { console.error('获取物模型 TSL 失败:', error); diff --git a/apps/web-antd/src/views/iot/thingmodel/modules/tsl.vue b/apps/web-antd/src/views/iot/thingmodel/modules/tsl.vue index cad4ea1b8..8e37ef16f 100644 --- a/apps/web-antd/src/views/iot/thingmodel/modules/tsl.vue +++ b/apps/web-antd/src/views/iot/thingmodel/modules/tsl.vue @@ -10,7 +10,7 @@ import { IOT_PROVIDE_KEY } from '@vben/constants'; import { Radio, Textarea } from 'ant-design-vue'; -import { getThingModelTSL } from '#/api/iot/thingmodel'; +import { getThingModelTSLByProductId } from '#/api/iot/thingmodel'; const product = inject>(IOT_PROVIDE_KEY.PRODUCT); @@ -26,7 +26,7 @@ const [Modal, modalApi] = useVbenModal({ modalApi.lock(); try { // 加载数据 - thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0); + thingModelTSL.value = await getThingModelTSLByProductId(product?.value?.id || 0); // 设置到 values tslString.value = JSON.stringify(thingModelTSL.value, null, 2); } finally { diff --git a/apps/web-ele/src/api/iot/thingmodel/index.ts b/apps/web-ele/src/api/iot/thingmodel/index.ts index 66fa208cf..e5152c742 100644 --- a/apps/web-ele/src/api/iot/thingmodel/index.ts +++ b/apps/web-ele/src/api/iot/thingmodel/index.ts @@ -235,7 +235,7 @@ export function deleteThingModel(id: number) { } /** 获取物模型 TSL */ -export function getThingModelTSL(productId: number) { +export function getThingModelTSLByProductId(productId: number) { return requestClient.get('/iot/thing-model/get-tsl', { params: { productId }, }); diff --git a/apps/web-ele/src/views/iot/rule/scene/data.ts b/apps/web-ele/src/views/iot/rule/scene/data.ts new file mode 100644 index 000000000..fd948f9bf --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/data.ts @@ -0,0 +1,139 @@ +import type { VbenFormSchema } from '#/adapter/form'; +import type { VxeTableGridOptions } from '#/adapter/vxe-table'; + +import { CommonStatusEnum, DICT_TYPE } from '@vben/constants'; +import { getDictOptions } from '@vben/hooks'; + +import { z } from '#/adapter/form'; +import { getRangePickerDefaultProps } from '#/utils'; + +/** 新增/修改的表单 */ +export function useFormSchema(): VbenFormSchema[] { + return [ + { + component: 'Input', + fieldName: 'id', + dependencies: { + triggerFields: [''], + show: () => false, + }, + }, + { + component: 'Input', + fieldName: 'name', + label: '规则名称', + rules: 'required', + componentProps: { + placeholder: '请输入规则名称', + }, + }, + { + fieldName: 'status', + label: '规则状态', + component: 'RadioGroup', + componentProps: { + options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'), + buttonStyle: 'solid', + optionType: 'button', + }, + rules: z.number().default(CommonStatusEnum.ENABLE), + }, + { + fieldName: 'description', + label: '规则描述', + component: 'Textarea', + componentProps: { + placeholder: '请输入规则描述', + rows: 3, + }, + formItemClass: 'col-span-2', + }, + ]; +} + +/** 列表的搜索表单 */ +export function useGridFormSchema(): VbenFormSchema[] { + return [ + { + fieldName: 'name', + label: '规则名称', + component: 'Input', + componentProps: { + placeholder: '请输入规则名称', + allowClear: true, + }, + }, + { + fieldName: 'status', + label: '规则状态', + component: 'Select', + componentProps: { + options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'), + placeholder: '请选择状态', + allowClear: true, + }, + }, + { + fieldName: 'createTime', + label: '创建时间', + component: 'RangePicker', + componentProps: { + ...getRangePickerDefaultProps(), + allowClear: true, + }, + }, + ]; +} + +/** 列表的字段 */ +export function useGridColumns(): VxeTableGridOptions['columns'] { + return [ + { type: 'checkbox', width: 40 }, + { + field: 'id', + title: '规则编号', + minWidth: 80, + }, + { + field: 'name', + title: '规则名称', + minWidth: 150, + }, + { + field: 'description', + title: '规则描述', + minWidth: 200, + }, + { + field: 'status', + title: '规则状态', + minWidth: 100, + cellRender: { + name: 'CellDict', + props: { type: DICT_TYPE.COMMON_STATUS }, + }, + }, + { + field: 'actionCount', + title: '执行动作数', + minWidth: 100, + }, + { + field: 'executeCount', + title: '执行次数', + minWidth: 100, + }, + { + field: 'createTime', + title: '创建时间', + minWidth: 180, + formatter: 'formatDateTime', + }, + { + title: '操作', + width: 240, + fixed: 'right', + slots: { default: 'actions' }, + }, + ]; +} diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/alert-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/alert-config.vue new file mode 100644 index 000000000..d922679a6 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/alert-config.vue @@ -0,0 +1,85 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/condition-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/condition-config.vue new file mode 100644 index 000000000..e27ef9a55 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/condition-config.vue @@ -0,0 +1,344 @@ + + + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/current-time-condition-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/current-time-condition-config.vue new file mode 100644 index 000000000..76af7c3d6 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/current-time-condition-config.vue @@ -0,0 +1,258 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/device-control-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/device-control-config.vue new file mode 100644 index 000000000..afb78bb9f --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/device-control-config.vue @@ -0,0 +1,427 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/device-trigger-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/device-trigger-config.vue new file mode 100644 index 000000000..dd0d303a5 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/device-trigger-config.vue @@ -0,0 +1,271 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/main-condition-inner-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/main-condition-inner-config.vue new file mode 100644 index 000000000..81dfa81ae --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/main-condition-inner-config.vue @@ -0,0 +1,380 @@ + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/sub-condition-group-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/sub-condition-group-config.vue new file mode 100644 index 000000000..0011cbd82 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/sub-condition-group-config.vue @@ -0,0 +1,167 @@ + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/configs/timer-condition-group-config.vue b/apps/web-ele/src/views/iot/rule/scene/form/configs/timer-condition-group-config.vue new file mode 100644 index 000000000..2dbe3fe8c --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/configs/timer-condition-group-config.vue @@ -0,0 +1,179 @@ + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/inputs/json-params-input.vue b/apps/web-ele/src/views/iot/rule/scene/form/inputs/json-params-input.vue new file mode 100644 index 000000000..cbfc9aea6 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/inputs/json-params-input.vue @@ -0,0 +1,587 @@ + + + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/inputs/value-input.vue b/apps/web-ele/src/views/iot/rule/scene/form/inputs/value-input.vue new file mode 100644 index 000000000..673cefc39 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/inputs/value-input.vue @@ -0,0 +1,302 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/sections/action-section.vue b/apps/web-ele/src/views/iot/rule/scene/form/sections/action-section.vue new file mode 100644 index 000000000..829e951a6 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/sections/action-section.vue @@ -0,0 +1,312 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/sections/basic-info-section.vue b/apps/web-ele/src/views/iot/rule/scene/form/sections/basic-info-section.vue new file mode 100644 index 000000000..cf558dd6e --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/sections/basic-info-section.vue @@ -0,0 +1,104 @@ + + + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/sections/trigger-section.vue b/apps/web-ele/src/views/iot/rule/scene/form/sections/trigger-section.vue new file mode 100644 index 000000000..7a48892b6 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/sections/trigger-section.vue @@ -0,0 +1,254 @@ + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/selectors/device-selector.vue b/apps/web-ele/src/views/iot/rule/scene/form/selectors/device-selector.vue new file mode 100644 index 000000000..d0da5de56 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/selectors/device-selector.vue @@ -0,0 +1,111 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/selectors/operator-selector.vue b/apps/web-ele/src/views/iot/rule/scene/form/selectors/operator-selector.vue new file mode 100644 index 000000000..1b9640526 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/selectors/operator-selector.vue @@ -0,0 +1,278 @@ + + + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/selectors/product-selector.vue b/apps/web-ele/src/views/iot/rule/scene/form/selectors/product-selector.vue new file mode 100644 index 000000000..d4129dd35 --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/selectors/product-selector.vue @@ -0,0 +1,85 @@ + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/form/selectors/property-selector.vue b/apps/web-ele/src/views/iot/rule/scene/form/selectors/property-selector.vue new file mode 100644 index 000000000..ad6b4d82b --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/form/selectors/property-selector.vue @@ -0,0 +1,465 @@ + + + + + + diff --git a/apps/web-ele/src/views/iot/rule/scene/index.vue b/apps/web-ele/src/views/iot/rule/scene/index.vue new file mode 100644 index 000000000..06710a57a --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/index.vue @@ -0,0 +1,394 @@ + + + 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 new file mode 100644 index 000000000..649f3869f --- /dev/null +++ b/apps/web-ele/src/views/iot/rule/scene/modules/form.vue @@ -0,0 +1,255 @@ + + + diff --git a/apps/web-ele/src/views/iot/thingmodel/modules/tsl.vue b/apps/web-ele/src/views/iot/thingmodel/modules/tsl.vue index 8ce0c0bb5..f7d487b21 100644 --- a/apps/web-ele/src/views/iot/thingmodel/modules/tsl.vue +++ b/apps/web-ele/src/views/iot/thingmodel/modules/tsl.vue @@ -10,7 +10,7 @@ import { IOT_PROVIDE_KEY } from '@vben/constants'; import { ElInput, ElRadioButton, ElRadioGroup } from 'element-plus'; -import { getThingModelTSL } from '#/api/iot/thingmodel'; +import { getThingModelTSLByProductId } from '#/api/iot/thingmodel'; const product = inject>(IOT_PROVIDE_KEY.PRODUCT); @@ -26,7 +26,7 @@ const [Modal, modalApi] = useVbenModal({ modalApi.lock(); try { // 加载数据 - thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0); + thingModelTSL.value = await getThingModelTSLByProductId(product?.value?.id || 0); // 设置到 values tslString.value = JSON.stringify(thingModelTSL.value, null, 2); } finally { diff --git a/packages/utils/src/cron.ts b/packages/utils/src/cron.ts new file mode 100644 index 000000000..02e26b04f --- /dev/null +++ b/packages/utils/src/cron.ts @@ -0,0 +1,390 @@ +/** + * 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/packages/utils/src/index.ts b/packages/utils/src/index.ts index eb9bef1ab..ea44feb94 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ +export * from './cron'; export * from './helpers'; export * from './validator'; export * from '@vben-core/shared/cache';