fix(iot): 修复 21 处 bug(P1×15 + P2×6)

经 codex 4 轮复评定稿,antd / ele 两端同步。

P1(场景规则 / 物模型 / Modbus / Redis sink / 路由):
- B7/B8 隐藏路由 path 与 activePath 对齐 vue3 源(产品 / OTA 固件详情)
- B9 移除后端不存在的 deleteSceneRuleList 封装
- B10 物模型 number specs 恢复 min/max/step/unit 校验
- B11 物模型新增枚举项补 dataType: ENUM
- B12 物模型 struct 非空校验绑 fieldPath,array 嵌套显式覆盖
       property.dataSpecs.dataSpecsList,确保父表单 validate 触发
- B13 struct 与 input-output-param 编辑回填 cloneDeep,取消不污染原对象
- B14 Modbus Client 模式 ip/port/timeout/retryInterval 改 dependencies 条件必填
- B19 Redis sink 补 dataStructure(默认 Stream)+ Hash/ZSet 条件字段
- B20 仅 ALERT_RECOVER 强校验 alertConfigId,ALERT_TRIGGER 放行
- B21 conditionGroups 递归校验
       · 设备状态/属性 param 必填
       · CURRENT_TIME 按 operator 区分:TODAY 免、BETWEEN_TIME 双段、其它单段
       · 触发器 / 条件 / 执行器 deviceId 改显式 null/undefined 判断,
         保留「全部设备 = 0」(后端 action 支持广播执行)
- B22 事件上报条件改回普通 Input,允许标量值或留空
- B23 antd 当前时间条件 :value / @update:value 绑定 + Dayjs 类型 normalize;
       归一逻辑抽到 @vben/utils.formatDayjs(packages/@core/base/shared/utils/date.ts),
       供所有 app 复用
- B24 设备控制动作切换无条件清依赖,去掉 isInitialized 冗余守卫
- B26 JSON 参数输入先全部校验通过后再写入父表单

P2(产品 / 设备 / 物模型展示 / 数据源):
- B28 产品 deviceType 去默认值,强制用户显式选择
- B30 设备列表 DeviceName 加点击详情 slot
- B31 设备卡片显示备注名称(nickname || deviceName)
- B32 设备详情 hasLocation 改用 != null,合法 0 坐标不再判空
- B41 物模型数据定义展示顺序改为 name-value
- B46 数据源 getData() 剔除仅 UI 用的 identifierLoading 临时字段
pull/348/head
YunaiV 2026-05-24 00:32:35 +08:00
parent ef57c96b2f
commit 241cf76788
46 changed files with 1012 additions and 282 deletions

View File

@ -80,13 +80,6 @@ export function deleteSceneRule(id: number) {
return requestClient.delete(`/iot/scene-rule/delete?id=${id}`);
}
/** 批量删除场景联动规则 */
export function deleteSceneRuleList(ids: number[]) {
return requestClient.delete('/iot/scene-rule/delete-list', {
params: { ids: ids.join(',') },
});
}
/** 更新场景联动规则状态 */
export function updateSceneRuleStatus(id: number, status: number) {
return requestClient.put(`/iot/scene-rule/update-status`, {

View File

@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [
},
children: [
{
path: 'product/detail/:id',
path: 'product/product/detail/:id',
name: 'IoTProductDetail',
meta: {
title: '产品详情',
@ -30,14 +30,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/iot/device/device/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',
path: 'ota/operation/firmware/detail/:id',
name: 'IoTOtaFirmwareDetail',
meta: {
title: '固件详情',
activePath: '/iot/ota',
activePath: '/iot/operation/ota/firmware',
},
component: () =>
import('#/views/iot/ota/firmware/detail/index.vue'),
component: () => import('#/views/iot/ota/firmware/detail/index.vue'),
},
],
},

View File

@ -276,6 +276,7 @@ export function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['colu
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
slots: { default: 'deviceName' },
},
{
field: 'nickname',

View File

@ -36,9 +36,9 @@ const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
/** 是否有位置信息 */
/** 是否有位置信息(合法经纬度 0 不应视为空) */
const hasLocation = computed(() => {
return !!(props.device.longitude && props.device.latitude);
return props.device.longitude != null && props.device.latitude != null;
});
/** 打开地图弹窗 */

View File

@ -58,11 +58,13 @@ const [Form, formApi] = useVbenForm({
componentProps: {
placeholder: '请输入 Modbus 服务器 IP 地址',
},
// Client Server
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client IP
show: () => isClient.value,
rules: () =>
isClient.value ? z.string().min(1, '请输入 IP 地址') : null,
},
rules: z.string().min(1, '请输入 IP 地址').optional(),
},
{
fieldName: 'port',
@ -76,9 +78,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入端口' }).min(1).max(65_535)
: null,
},
rules: z.number().min(1).max(65_535).optional(),
defaultValue: 502,
},
{
@ -106,9 +111,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入连接超时时间' }).min(1000)
: null,
},
rules: z.number().min(1000).optional(),
defaultValue: 3000,
},
{
@ -123,9 +131,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入重试间隔' }).min(1000)
: null,
},
rules: z.number().min(1000).optional(),
defaultValue: 10_000,
},
{

View File

@ -430,6 +430,11 @@ onMounted(async () => {
<!-- 列表视图 -->
<Grid table-title="" v-show="viewMode === 'list'">
<template #deviceName="{ row }">
<a class="cursor-pointer text-primary" @click="openDetail(row.id!)">
{{ row.deviceName }}
</a>
</template>
<template #product="{ row }">
<a
class="cursor-pointer text-primary"

View File

@ -177,13 +177,16 @@ onMounted(() => {
</div>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
Deviceid
备注名称
</span>
<Tooltip :title="String(item.id)" placement="top">
<Tooltip
:title="item.nickname || item.deviceName"
placement="top"
>
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
class="inline-block max-w-[150px] cursor-pointer truncate align-middle text-xs opacity-85 dark:text-white/75"
>
{{ item.id }}
{{ item.nickname || item.deviceName }}
</span>
</Tooltip>
</div>

View File

@ -110,7 +110,6 @@ export function useBasicFormSchema(
buttonStyle: 'solid',
optionType: 'button',
},
defaultValue: DeviceTypeEnum.DEVICE,
dependencies: {
triggerFields: ['id'],
componentProps: (values) => ({

View File

@ -176,9 +176,11 @@ function validate() {
return Promise.resolve();
}
/** 取当前所有行的值 */
/** 取当前所有行的值(剔除 identifierLoading 等仅供 UI 使用的临时字段) */
function getData() {
return formData.value;
return formData.value.map(
({ identifierLoading: _identifierLoading, ...rest }) => rest,
);
}
/** 设置初始数据 */

View File

@ -1,11 +1,11 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Input, InputNumber } from 'ant-design-vue';
import { Form, Input, InputNumber, Select } from 'ant-design-vue';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
@ -13,8 +13,23 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [
{ label: 'Stream', value: 1 },
{ label: 'Hash', value: 2 },
{ label: 'List', value: 3 },
{ label: 'Set', value: 4 },
{ label: 'ZSet', value: 5 },
{ label: 'String', value: 6 },
]; // Redis IotRedisDataStructureEnum
const isHash = computed(() => Number(config.value?.dataStructure) === 2);
const isZSet = computed(() => Number(config.value?.dataStructure) === 5);
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) {
config.value.dataStructure = 1;
}
return;
}
config.value = {
@ -24,6 +39,7 @@ onMounted(() => {
password: '',
database: 0,
topic: '',
dataStructure: 1,
};
});
</script>
@ -89,4 +105,37 @@ onMounted(() => {
>
<Input v-model:value="config.topic" placeholder="请输入主题" />
</Form.Item>
<Form.Item
:name="['config', 'dataStructure']"
:rules="[
{ required: true, message: 'Redis 数据结构不能为空', trigger: 'change' },
]"
label="数据结构"
>
<Select
v-model:value="config.dataStructure"
:options="REDIS_DATA_STRUCTURE_OPTIONS"
placeholder="请选择 Redis 数据结构"
/>
</Form.Item>
<Form.Item
v-if="isHash"
:name="['config', 'hashField']"
label="Hash 字段"
>
<Input
v-model:value="config.hashField"
placeholder="留空使用 deviceId 作为 Hash 字段"
/>
</Form.Item>
<Form.Item
v-if="isZSet"
:name="['config', 'scoreField']"
label="Score 字段"
>
<Input
v-model:value="config.scoreField"
placeholder="留空使用当前时间戳作为 Score"
/>
</Form.Item>
</template>

View File

@ -1,11 +1,14 @@
<!-- 当前时间条件配置组件 -->
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
import type { RuleSceneApi } from '#/api/iot/rule/scene';
import { computed, watch } from 'vue';
import { IotRuleSceneTriggerTimeOperatorEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDayjs } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
@ -126,11 +129,12 @@ function updateConditionField(field: any, value: any) {
* 处理第一个时间值变化
* @param value 时间值
*/
function handleTimeValueChange(value: string) {
function handleTimeValueChange(value: Dayjs | null | string) {
const normalized = formatDayjs(value);
const currentParams = condition.value.param
? condition.value.param.split(',')
: [];
currentParams[0] = value || '';
currentParams[0] = normalized;
//
condition.value.param = needsSecondTimeInput.value
@ -142,11 +146,12 @@ function handleTimeValueChange(value: string) {
* 处理第二个时间值变化
* @param value 时间值
*/
function handleTimeValue2Change(value: string) {
function handleTimeValue2Change(value: Dayjs | null | string) {
const normalized = formatDayjs(value);
const currentParams = condition.value.param
? condition.value.param.split(',')
: [''];
currentParams[1] = value || '';
currentParams[1] = normalized;
condition.value.param = currentParams.slice(0, 2).join(',');
}
@ -175,8 +180,8 @@ watch(
<Col :span="8">
<Form.Item label="时间条件" required>
<Select
:model-value="condition.operator"
@update:model-value="
:value="condition.operator"
@update:value="
(value: any) => updateConditionField('operator', value)
"
placeholder="请选择时间条件"
@ -207,8 +212,8 @@ watch(
<Form.Item label="时间值" required>
<TimePicker
v-if="needsTimeInput"
:model-value="timeValue"
@update:model-value="handleTimeValueChange"
:value="timeValue"
@update:value="handleTimeValueChange"
placeholder="请选择时间"
format="HH:mm:ss"
value-format="HH:mm:ss"
@ -216,8 +221,8 @@ watch(
/>
<DatePicker
v-else-if="needsDateInput"
:model-value="timeValue"
@update:model-value="handleTimeValueChange"
:value="timeValue"
@update:value="handleTimeValueChange"
type="datetime"
placeholder="请选择日期时间"
format="YYYY-MM-DD HH:mm:ss"
@ -233,8 +238,8 @@ watch(
<Form.Item label="结束时间" required>
<TimePicker
v-if="needsTimeInput"
:model-value="timeValue2"
@update:model-value="handleTimeValue2Change"
:value="timeValue2"
@update:value="handleTimeValue2Change"
placeholder="请选择结束时间"
format="HH:mm:ss"
value-format="HH:mm:ss"
@ -242,8 +247,8 @@ watch(
/>
<DatePicker
v-else
:model-value="timeValue2"
@update:model-value="handleTimeValue2Change"
:value="timeValue2"
@update:value="handleTimeValue2Change"
type="datetime"
placeholder="请选择结束日期时间"
format="YYYY-MM-DD HH:mm:ss"

View File

@ -69,18 +69,16 @@ const isServiceInvokeAction = computed(() => {
/**
* 处理产品变化事件
* @param productId 产品 ID
*
* ProductSelector 只在用户主动切换时 emit change编辑回填阶段不会触发
*/
function handleProductChange(productId?: number) {
//
if (action.value.productId !== productId) {
action.value.deviceId = undefined;
action.value.identifier = undefined; //
action.value.params = '' as any; //
selectedService.value = null; //
serviceList.value = []; //
}
action.value.deviceId = undefined;
action.value.identifier = undefined;
action.value.params = '' as any;
selectedService.value = null;
serviceList.value = [];
//
if (productId) {
if (isPropertySetAction.value) {
loadThingModelProperties(productId);
@ -94,11 +92,8 @@ function handleProductChange(productId?: number) {
* 处理设备变化事件
* @param deviceId 设备 ID
*/
function handleDeviceChange(deviceId?: number) {
//
if (action.value.deviceId !== deviceId) {
action.value.params = '' as any; //
}
function handleDeviceChange(_deviceId?: number) {
action.value.params = '' as any;
}
/**
@ -257,14 +252,10 @@ function getDefaultValueForParam(param: any) {
}
}
const isInitialized = ref(false); //
/**
* 初始化组件数据
*/
async function initializeComponent() {
if (isInitialized.value) return;
const currentAction = action.value;
if (!currentAction) return;
@ -282,8 +273,6 @@ async function initializeComponent() {
// TSL
await loadServiceFromTSL(currentAction.productId, currentAction.identifier);
}
isInitialized.value = true;
}
/** 组件初始化 */
@ -295,9 +284,6 @@ onMounted(() => {
watch(
() => [action.value.productId, action.value.type, action.value.identifier],
async ([newProductId, , newIdentifier], [oldProductId, , oldIdentifier]) => {
//
if (!isInitialized.value) return;
//
if (newProductId !== oldProductId) {
if (newProductId && isPropertySetAction.value) {

View File

@ -12,7 +12,7 @@ import {
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select } from 'ant-design-vue';
import { Col, Form, Input, Row, Select } from 'ant-design-vue';
import JsonParamsInput from '../inputs/json-params-input.vue';
import ValueInput from '../inputs/value-input.vue';
@ -105,22 +105,6 @@ const serviceConfig = computed(() => {
return undefined;
});
// - JsonParamsInput
const eventConfig = computed(() => {
if (
propertyConfig.value &&
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
) {
return {
event: {
name: propertyConfig.value.name || '事件',
outputParams: propertyConfig.value.outputParams || [],
},
};
}
return undefined;
});
/**
* 更新条件字段
* @param field 字段名
@ -266,15 +250,16 @@ function handlePropertyChange(propertyInfo: any) {
:config="serviceConfig as any"
placeholder="请输入 JSON 格式的服务参数"
/>
<!-- 事件上报参数配置 -->
<JsonParamsInput
<!-- 事件上报参数配置源项目允许标量值或留空表示事件发生即匹配 -->
<Input
v-else-if="
triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
"
v-model="condition.value"
type="event"
:config="eventConfig as any"
placeholder="请输入 JSON 格式的事件参数"
:value="condition.value"
@update:value="
(value) => updateConditionField('value', value)
"
placeholder="留空则事件发生即匹配"
/>
<!-- 普通值输入 -->
<ValueInput

View File

@ -219,39 +219,42 @@ const emptyMessage = computed(() => {
/**
* 处理参数变化事件
*
* 注意必须在所有校验合法 JSON / 必须是对象 / 必填参数通过后再回写
* localValue否则父表单仅校验非空时会先写入非法值再提示错误
*/
function handleParamsChange() {
try {
jsonError.value = ''; //
jsonError.value = '';
if (paramsJson.value.trim()) {
const parsed = JSON.parse(paramsJson.value);
localValue.value = paramsJson.value;
//
if (typeof parsed !== 'object' || parsed === null) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
return;
}
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);
return;
}
}
} else {
if (!paramsJson.value.trim()) {
localValue.value = '';
return;
}
//
jsonError.value = '';
const parsed = JSON.parse(paramsJson.value);
//
if (typeof parsed !== 'object' || parsed === null) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
return;
}
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);
return;
}
}
//
localValue.value = paramsJson.value;
} catch (error) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_ERROR(
error instanceof Error

View File

@ -7,6 +7,8 @@ import { useVbenDrawer } from '@vben/common-ui';
import {
CommonStatusEnum,
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerConditionTypeEnum,
IotRuleSceneTriggerTimeOperatorEnum,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '@vben/constants';
@ -126,7 +128,8 @@ function validateTriggers(_rule: any, value: any, callback: any) {
callback(new Error(`触发器 ${i + 1}:产品不能为空`));
return;
}
if (!trigger.deviceId) {
// deviceId = 0 DEVICE_SELECTOR_OPTIONS.ALL_DEVICES undefined / null
if (trigger.deviceId === undefined || trigger.deviceId === null) {
callback(new Error(`触发器 ${i + 1}:设备不能为空`));
return;
}
@ -160,6 +163,89 @@ function validateTriggers(_rule: any, value: any, callback: any) {
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 paramBETWEEN_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();
}
@ -183,7 +269,10 @@ function validateActions(_rule: any, value: any, callback: any) {
callback(new Error(`执行器 ${i + 1}:产品不能为空`));
return;
}
if (!action.deviceId) {
// 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;
}
@ -199,9 +288,9 @@ function validateActions(_rule: any, value: any, callback: any) {
return;
}
}
// alertConfigId
if (
(action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) &&
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER &&
!action.alertConfigId
) {
callback(new Error(`执行器 ${i + 1}:告警配置不能为空`));

View File

@ -33,7 +33,7 @@ const formattedDataSpecsList = computed(() => {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.value}-${item.name}`)
.map((item) => `${item.name}-${item.value}`)
.join('、');
});
@ -44,8 +44,8 @@ const shortText = computed(() => {
}
const first = list[0];
return list.length > 1
? `${first.value}-${first.name}${list.length}`
: `${first.value}-${first.name}`;
? `${first.name}-${first.value}${list.length}`
: `${first.name}-${first.value}`;
});
</script>

View File

@ -68,5 +68,6 @@ function handleChange(val: any) {
<ThingModelStructDataSpecs
v-if="dataSpecs.childDataType === IoTDataSpecsDataTypeEnum.STRUCT"
v-model="dataSpecs.dataSpecsList"
:field-path="['property', 'dataSpecs', 'dataSpecsList']"
/>
</template>

View File

@ -2,6 +2,7 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
@ -17,7 +18,11 @@ const validateEnumName = buildIdentifierLikeNameValidator('枚举描述');
/** 添加枚举项 */
function addEnum() {
dataSpecsList.value.push({ name: '', value: '' } as any);
dataSpecsList.value.push({
dataType: IoTDataSpecsDataTypeEnum.ENUM,
name: '',
value: '',
} as any);
}
/** 删除枚举项 */

View File

@ -34,24 +34,99 @@ function unitChange(unitSpecs: any) {
dataSpecs.value.unitName = unitName;
dataSpecs.value.unit = unit;
}
/** 校验最小值 */
function validateMin(_rule: any, _value: any, callback: any) {
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (Number.isNaN(min)) {
callback(new Error('请输入有效的数值'));
return;
}
if (!Number.isNaN(max) && min >= max) {
callback(new Error('最小值必须小于最大值'));
return;
}
callback();
}
/** 校验最大值 */
function validateMax(_rule: any, _value: any, callback: any) {
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (Number.isNaN(max)) {
callback(new Error('请输入有效的数值'));
return;
}
if (!Number.isNaN(min) && max <= min) {
callback(new Error('最大值必须大于最小值'));
return;
}
callback();
}
/** 校验步长 */
function validateStep(_rule: any, _value: any, callback: any) {
const step = Number(dataSpecs.value.step);
if (Number.isNaN(step)) {
callback(new Error('请输入有效的数值'));
return;
}
if (step <= 0) {
callback(new Error('步长必须大于 0'));
return;
}
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (!Number.isNaN(min) && !Number.isNaN(max) && step > max - min) {
callback(new Error('步长不能大于最大值与最小值的差值'));
return;
}
callback();
}
</script>
<template>
<Form.Item label="取值范围">
<div class="flex items-center justify-between">
<div class="flex-1">
<Form.Item
:name="['property', 'dataSpecs', 'min']"
:rules="[
{ required: true, message: '最小值不能为空', trigger: 'blur' },
{ validator: validateMin, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<Input v-model:value="dataSpecs.min" placeholder="请输入最小值" />
</div>
</Form.Item>
<span class="mx-2">~</span>
<div class="flex-1">
<Form.Item
:name="['property', 'dataSpecs', 'max']"
:rules="[
{ required: true, message: '最大值不能为空', trigger: 'blur' },
{ validator: validateMax, trigger: 'blur' },
]"
class="mb-0 flex-1"
>
<Input v-model:value="dataSpecs.max" placeholder="请输入最大值" />
</div>
</Form.Item>
</div>
</Form.Item>
<Form.Item label="步长">
<Form.Item
:name="['property', 'dataSpecs', 'step']"
:rules="[
{ required: true, message: '步长不能为空', trigger: 'blur' },
{ validator: validateStep, trigger: 'blur' },
]"
label="步长"
>
<Input v-model:value="dataSpecs.step" placeholder="请输入步长" />
</Form.Item>
<Form.Item label="单位">
<Form.Item
:name="['property', 'dataSpecs', 'unit']"
:rules="[{ required: true, message: '请选择单位', trigger: 'change' }]"
label="单位"
>
<Select
:value="
dataSpecs.unit ? `${dataSpecs.unitName}-${dataSpecs.unit}` : ''

View File

@ -5,7 +5,7 @@ import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { cloneDeep, isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
@ -14,13 +14,33 @@ import { ThingModelFormRules } from '#/api/iot/thingmodel';
import ThingModelProperty from '../property.vue';
const props = defineProps<{ modelValue: any }>();
const props = withDefaults(
defineProps<{
/** dataSpecsList name property.dataSpecsList
* array 嵌套 struct 时父级需传 ['property', 'dataSpecs', 'dataSpecsList']
* 否则父表单 validate() 无法定位该字段非空校验不会触发 */
fieldPath?: string[];
modelValue: any;
}>(),
{
fieldPath: () => ['property', 'dataSpecsList'],
},
);
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const structFormRef = ref();
const formData = ref<any>(buildEmptyFormData());
/** 校验结构体属性对象非空 */
function validateStructSpecsList(_rule: any, _value: any, callback: any) {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('请至少添加一个结构体属性对象'));
return;
}
callback();
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
@ -64,14 +84,15 @@ const [Modal, modalApi] = useVbenModal({
if (isEmpty(data)) {
return;
}
// cloneDeep v-model dataSpecsList
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.childDataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
},
};
},
@ -109,7 +130,11 @@ onMounted(() => {
</script>
<template>
<Form.Item label="属性对象">
<Form.Item
:name="fieldPath"
:rules="[{ validator: validateStructSpecsList, trigger: 'change' }]"
label="属性对象"
>
<div
v-for="(item, index) in dataSpecsList"
:key="index"

View File

@ -5,7 +5,7 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { cloneDeep, isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
@ -72,15 +72,15 @@ const [Modal, modalApi] = useVbenModal({
if (isEmpty(data)) {
return;
}
// values
// cloneDeep v-model thingModelParams
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.dataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
},
};
},

View File

@ -130,10 +130,8 @@ export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
onChange: async () => {
await formApi?.setFieldValue('areaId', undefined);
},
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
fieldNames: { label: 'name', value: 'id' },
options: list,
placeholder: '请选择库区',
};
},
@ -152,10 +150,8 @@ export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
: [];
return {
allowClear: true,
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
fieldNames: { label: 'name', value: 'id' },
options: list,
placeholder: '请选择库位',
};
},

View File

@ -1,3 +1,13 @@
/** MES 单据状态常量 */
export const MesOrderStatusConstants = {
DRAFT: 0,
CONFIRMED: 1,
APPROVING: 2,
APPROVED: 3,
FINISHED: 4,
CANCELLED: 5,
} as const;
/** MES 物料/产品标识枚举 */
export const MesItemOrProductEnum = {
ITEM: {
@ -10,14 +20,121 @@ export const MesItemOrProductEnum = {
},
} as const;
/** MES 工具状态枚举 */
export const MesToolStatusEnum = {
STORE: 1,
ISSUE: 2,
REPAIR: 3,
SCRAP: 4,
} as const;
/** MES 保养维护类型枚举 */
export const MesMaintenTypeEnum = {
REGULAR: 1,
USAGE: 2,
} as const;
/** MES 设备状态枚举 */
export const MesDvMachineryStatusEnum = {
STOP: 1,
PRODUCING: 2,
MAINTENANCE: 3,
} as const;
/** MES 假期类型枚举 */
export const HolidayType = {
WORKDAY: 1,
HOLIDAY: 2,
} as const;
/** MES 排班计划状态枚举 */
export const MesCalPlanStatusEnum = {
PREPARE: 0,
CONFIRMED: 1,
} as const;
/** MES 轮班方式枚举 */
export const MesCalShiftTypeEnum = {
SINGLE: 1,
TWO: 2,
THREE: 3,
} as const;
/** MES 倒班方式枚举 */
export const MesCalShiftMethodEnum = {
QUARTER: 1,
MONTH: 2,
WEEK: 3,
DAY: 4,
} as const;
/** MES 点检保养项目类型枚举 */
export const MesDvSubjectTypeEnum = {
CHECK: 1,
MAINTENANCE: 2,
} as const;
/** MES 点检保养方案状态枚举 */
export const MesDvCheckPlanStatusEnum = {
PREPARE: 0,
ENABLED: 1,
} as const;
/** MES 设备保养记录状态枚举 */
export const MesDvMaintenRecordStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
SUBMITTED: MesOrderStatusConstants.FINISHED,
} as const;
/** MES 设备保养明细结果枚举 */
export const MesDvMaintenStatusEnum = {
NORMAL: 1,
ABNORMAL: 2,
} as const;
/** MES 设备点检记录状态枚举 */
export const MesDvCheckRecordStatusEnum = {
DRAFT: 10,
FINISHED: 20,
} as const;
/** MES 设备点检结果枚举 */
export const MesDvCheckResultEnum = {
NORMAL: 1,
ABNORMAL: 2,
} as const;
/** MES 维修工单状态枚举 */
export const MesDvRepairStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
FINISHED: MesOrderStatusConstants.FINISHED,
} as const;
/** MES 维修结果枚举 */
export const MesDvRepairResultEnum = {
PASS: 1,
FAIL: 2,
} as const;
/** MES 自动编码规则 Code 枚举 */
export const MesAutoCodeRuleCode = {
CAL_PLAN_CODE: 'CAL_PLAN_CODE',
CAL_TEAM_CODE: 'CAL_TEAM_CODE',
DV_CHECK_PLAN_CODE: 'DV_CHECK_PLAN_CODE',
DV_MACHINERY_TYPE_CODE: 'DV_MACHINERY_TYPE_CODE',
DV_MACHINERY_CODE: 'DV_MACHINERY_CODE',
DV_REPAIR_CODE: 'DV_REPAIR_CODE',
DV_SUBJECT_CODE: 'DV_SUBJECT_CODE',
MD_CLIENT_CODE: 'MD_CLIENT_CODE',
MD_ITEM_TYPE_CODE: 'MD_ITEM_TYPE_CODE',
MD_ITEM_CODE: 'MD_ITEM_CODE',
MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE',
MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE',
TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE',
TM_TOOL_CODE: 'TM_TOOL_CODE',
} as const;
/** MES 编码规则分段类型枚举 */

View File

@ -81,13 +81,6 @@ export function deleteSceneRule(id: number) {
return requestClient.delete(`/iot/scene-rule/delete?id=${id}`);
}
/** 批量删除场景联动规则 */
export function deleteSceneRuleList(ids: number[]) {
return requestClient.delete('/iot/scene-rule/delete-list', {
params: { ids: ids.join(',') },
});
}
/** 更新场景联动规则状态 */
export function updateSceneRuleStatus(id: number, status: number) {
return requestClient.put(`/iot/scene-rule/update-status`, {

View File

@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [
},
children: [
{
path: 'product/detail/:id',
path: 'product/product/detail/:id',
name: 'IoTProductDetail',
meta: {
title: '产品详情',
@ -30,14 +30,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/iot/device/device/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',
path: 'ota/operation/firmware/detail/:id',
name: 'IoTOtaFirmwareDetail',
meta: {
title: '固件详情',
activePath: '/iot/ota',
activePath: '/iot/operation/ota/firmware',
},
component: () =>
import('#/views/iot/ota/firmware/detail/index.vue'),
component: () => import('#/views/iot/ota/firmware/detail/index.vue'),
},
],
},

View File

@ -276,6 +276,7 @@ export function useGridColumns(): VxeTableGridOptions<IotDeviceApi.Device>['colu
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
slots: { default: 'deviceName' },
},
{
field: 'nickname',

View File

@ -38,9 +38,9 @@ const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
/** 是否有位置信息 */
/** 是否有位置信息(合法经纬度 0 不应视为空) */
const hasLocation = computed(() => {
return !!(props.device.longitude && props.device.latitude);
return props.device.longitude != null && props.device.latitude != null;
});
/** 打开地图弹窗 */

View File

@ -58,11 +58,13 @@ const [Form, formApi] = useVbenForm({
componentProps: {
placeholder: '请输入 Modbus 服务器 IP 地址',
},
// Client Server
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client IP
show: () => isClient.value,
rules: () =>
isClient.value ? z.string().min(1, '请输入 IP 地址') : null,
},
rules: z.string().min(1, '请输入 IP 地址').optional(),
},
{
fieldName: 'port',
@ -76,9 +78,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入端口' }).min(1).max(65_535)
: null,
},
rules: z.number().min(1).max(65_535).optional(),
defaultValue: 502,
},
{
@ -106,9 +111,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入连接超时时间' }).min(1000)
: null,
},
rules: z.number().min(1000).optional(),
defaultValue: 3000,
},
{
@ -123,9 +131,12 @@ const [Form, formApi] = useVbenForm({
},
dependencies: {
triggerFields: [''],
show: () => isClient.value, // Client
show: () => isClient.value,
rules: () =>
isClient.value
? z.number({ message: '请输入重试间隔' }).min(1000)
: null,
},
rules: z.number().min(1000).optional(),
defaultValue: 10_000,
},
{

View File

@ -424,6 +424,11 @@ onMounted(async () => {
<!-- 列表视图 -->
<Grid table-title="" v-show="viewMode === 'list'">
<template #deviceName="{ row }">
<a class="cursor-pointer text-primary" @click="openDetail(row.id!)">
{{ row.deviceName }}
</a>
</template>
<template #product="{ row }">
<a
class="cursor-pointer text-[var(--el-color-primary)]"

View File

@ -173,13 +173,16 @@ onMounted(() => {
</div>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
Deviceid
备注名称
</span>
<ElTooltip :content="String(item.id)" placement="top">
<ElTooltip
:content="item.nickname || item.deviceName"
placement="top"
>
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
class="inline-block max-w-[150px] cursor-pointer truncate align-middle text-xs opacity-85 dark:text-white/75"
>
{{ item.id }}
{{ item.nickname || item.deviceName }}
</span>
</ElTooltip>
</div>

View File

@ -107,7 +107,6 @@ export function useBasicFormSchema(
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
},
defaultValue: DeviceTypeEnum.DEVICE,
dependencies: {
triggerFields: ['id'],
componentProps: (values) => ({

View File

@ -176,9 +176,11 @@ function validate() {
return Promise.resolve();
}
/** 取当前所有行的值 */
/** 取当前所有行的值(剔除 identifierLoading 等仅供 UI 使用的临时字段) */
function getData() {
return formData.value;
return formData.value.map(
({ identifierLoading: _identifierLoading, ...rest }) => rest,
);
}
/** 设置初始数据 */

View File

@ -1,11 +1,17 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { computed, onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import {
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
@ -13,8 +19,23 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [
{ label: 'Stream', value: 1 },
{ label: 'Hash', value: 2 },
{ label: 'List', value: 3 },
{ label: 'Set', value: 4 },
{ label: 'ZSet', value: 5 },
{ label: 'String', value: 6 },
]; // Redis IotRedisDataStructureEnum
const isHash = computed(() => Number(config.value?.dataStructure) === 2);
const isZSet = computed(() => Number(config.value?.dataStructure) === 5);
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) {
config.value.dataStructure = 1;
}
return;
}
config.value = {
@ -24,6 +45,7 @@ onMounted(() => {
password: '',
database: 0,
topic: '',
dataStructure: 1,
};
});
</script>
@ -94,4 +116,44 @@ onMounted(() => {
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem>
<ElFormItem
prop="config.dataStructure"
:rules="[
{ required: true, message: 'Redis 数据结构不能为空', trigger: 'change' },
]"
label="数据结构"
>
<ElSelect
v-model="config.dataStructure"
placeholder="请选择 Redis 数据结构"
class="w-full"
>
<ElOption
v-for="item in REDIS_DATA_STRUCTURE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="isHash"
prop="config.hashField"
label="Hash 字段"
>
<ElInput
v-model="config.hashField"
placeholder="留空使用 deviceId 作为 Hash 字段"
/>
</ElFormItem>
<ElFormItem
v-if="isZSet"
prop="config.scoreField"
label="Score 字段"
>
<ElInput
v-model="config.scoreField"
placeholder="留空使用当前时间戳作为 Score"
/>
</ElFormItem>
</template>

View File

@ -76,18 +76,16 @@ const isServiceInvokeAction = computed(() => {
/**
* 处理产品变化事件
* @param productId 产品 ID
*
* ProductSelector 只在用户主动切换时 emit change编辑回填阶段不会触发
*/
function handleProductChange(productId?: number) {
//
if (action.value.productId !== productId) {
action.value.deviceId = undefined;
action.value.identifier = undefined; //
action.value.params = ''; //
selectedService.value = null; //
serviceList.value = []; //
}
action.value.deviceId = undefined;
action.value.identifier = undefined;
action.value.params = '';
selectedService.value = null;
serviceList.value = [];
//
if (productId) {
if (isPropertySetAction.value) {
loadThingModelProperties(productId);
@ -101,11 +99,8 @@ function handleProductChange(productId?: number) {
* 处理设备变化事件
* @param deviceId 设备 ID
*/
function handleDeviceChange(deviceId?: number) {
//
if (action.value.deviceId !== deviceId) {
action.value.params = ''; //
}
function handleDeviceChange(_deviceId?: number) {
action.value.params = '';
}
/**
@ -270,14 +265,10 @@ function getDefaultValueForParam(param: any) {
}
}
const isInitialized = ref(false); //
/**
* 初始化组件数据
*/
async function initializeComponent() {
if (isInitialized.value) return;
const currentAction = action.value;
if (!currentAction) return;
@ -295,8 +286,6 @@ async function initializeComponent() {
// TSL
await loadServiceFromTSL(currentAction.productId, currentAction.identifier);
}
isInitialized.value = true;
}
/** 组件初始化 */
@ -308,9 +297,6 @@ onMounted(() => {
watch(
() => [action.value.productId, action.value.type, action.value.identifier],
async ([newProductId, , newIdentifier], [oldProductId, , oldIdentifier]) => {
//
if (!isInitialized.value) return;
//
if (newProductId !== oldProductId) {
if (newProductId && isPropertySetAction.value) {

View File

@ -12,7 +12,14 @@ import {
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { ElCol, ElFormItem, ElOption, ElRow, ElSelect } from 'element-plus';
import {
ElCol,
ElFormItem,
ElInput,
ElOption,
ElRow,
ElSelect,
} from 'element-plus';
import JsonParamsInput from '../inputs/json-params-input.vue';
import ValueInput from '../inputs/value-input.vue';
@ -105,22 +112,6 @@ const serviceConfig = computed(() => {
return undefined;
});
// - JsonParamsInput
const eventConfig = computed(() => {
if (
propertyConfig.value &&
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
) {
return {
event: {
name: propertyConfig.value.name || '事件',
outputParams: propertyConfig.value.outputParams || [],
},
};
}
return undefined;
});
/**
* 更新条件字段
* @param field 字段名
@ -265,15 +256,16 @@ function handlePropertyChange(propertyInfo: any) {
:config="serviceConfig as any"
placeholder="请输入 JSON 格式的服务参数"
/>
<!-- 事件上报参数配置 -->
<JsonParamsInput
<!-- 事件上报参数配置源项目允许标量值或留空表示事件发生即匹配 -->
<ElInput
v-else-if="
triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST
"
v-model="condition.value"
type="event"
:config="eventConfig as any"
placeholder="请输入 JSON 格式的事件参数"
:model-value="condition.value"
@update:model-value="
(value: any) => updateConditionField('value', value)
"
placeholder="留空则事件发生即匹配"
/>
<!-- 普通值输入 -->
<ValueInput

View File

@ -219,39 +219,42 @@ const emptyMessage = computed(() => {
/**
* 处理参数变化事件
*
* 注意必须在所有校验合法 JSON / 必须是对象 / 必填参数通过后再回写
* localValue否则父表单仅校验非空时会先写入非法值再提示错误
*/
function handleParamsChange() {
try {
jsonError.value = ''; //
jsonError.value = '';
if (paramsJson.value.trim()) {
const parsed = JSON.parse(paramsJson.value);
localValue.value = paramsJson.value;
//
if (typeof parsed !== 'object' || parsed === null) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
return;
}
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);
return;
}
}
} else {
if (!paramsJson.value.trim()) {
localValue.value = '';
return;
}
//
jsonError.value = '';
const parsed = JSON.parse(paramsJson.value);
//
if (typeof parsed !== 'object' || parsed === null) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAMS_MUST_BE_OBJECT;
return;
}
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);
return;
}
}
//
localValue.value = paramsJson.value;
} catch (error) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.JSON_FORMAT_ERROR(
error instanceof Error

View File

@ -7,6 +7,8 @@ import { useVbenDrawer } from '@vben/common-ui';
import {
CommonStatusEnum,
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerConditionTypeEnum,
IotRuleSceneTriggerTimeOperatorEnum,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '@vben/constants';
@ -126,7 +128,8 @@ function validateTriggers(_rule: any, value: any, callback: any) {
callback(new Error(`触发器 ${i + 1}:产品不能为空`));
return;
}
if (!trigger.deviceId) {
// deviceId = 0 DEVICE_SELECTOR_OPTIONS.ALL_DEVICES undefined / null
if (trigger.deviceId === undefined || trigger.deviceId === null) {
callback(new Error(`触发器 ${i + 1}:设备不能为空`));
return;
}
@ -160,6 +163,89 @@ function validateTriggers(_rule: any, value: any, callback: any) {
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 paramBETWEEN_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();
}
@ -183,7 +269,10 @@ function validateActions(_rule: any, value: any, callback: any) {
callback(new Error(`执行器 ${i + 1}:产品不能为空`));
return;
}
if (!action.deviceId) {
// 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;
}
@ -199,9 +288,9 @@ function validateActions(_rule: any, value: any, callback: any) {
return;
}
}
// alertConfigId
if (
(action.type === IotRuleSceneActionTypeEnum.ALERT_TRIGGER ||
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER) &&
action.type === IotRuleSceneActionTypeEnum.ALERT_RECOVER &&
!action.alertConfigId
) {
callback(new Error(`执行器 ${i + 1}:告警配置不能为空`));

View File

@ -33,7 +33,7 @@ const formattedDataSpecsList = computed(() => {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.value}-${item.name}`)
.map((item) => `${item.name}-${item.value}`)
.join('、');
});
@ -44,8 +44,8 @@ const shortText = computed(() => {
}
const first = list[0];
return list.length > 1
? `${first.value}-${first.name}${list.length}`
: `${first.value}-${first.name}`;
? `${first.name}-${first.value}${list.length}`
: `${first.name}-${first.value}`;
});
</script>

View File

@ -68,5 +68,6 @@ function handleChange(val: any) {
<ThingModelStructDataSpecs
v-if="dataSpecs.childDataType === IoTDataSpecsDataTypeEnum.STRUCT"
v-model="dataSpecs.dataSpecsList"
field-path="property.dataSpecs.dataSpecsList"
/>
</template>

View File

@ -2,6 +2,7 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
@ -17,7 +18,11 @@ const validateEnumName = buildIdentifierLikeNameValidator('枚举描述');
/** 添加枚举项 */
function addEnum() {
dataSpecsList.value.push({ name: '', value: '' } as any);
dataSpecsList.value.push({
dataType: IoTDataSpecsDataTypeEnum.ENUM,
name: '',
value: '',
} as any);
}
/** 删除枚举项 */

View File

@ -27,24 +27,99 @@ function unitChange(unitSpecs: any) {
dataSpecs.value.unitName = unitName;
dataSpecs.value.unit = unit;
}
/** 校验最小值 */
function validateMin(_rule: any, _value: any, callback: any) {
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (Number.isNaN(min)) {
callback(new Error('请输入有效的数值'));
return;
}
if (!Number.isNaN(max) && min >= max) {
callback(new Error('最小值必须小于最大值'));
return;
}
callback();
}
/** 校验最大值 */
function validateMax(_rule: any, _value: any, callback: any) {
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (Number.isNaN(max)) {
callback(new Error('请输入有效的数值'));
return;
}
if (!Number.isNaN(min) && max <= min) {
callback(new Error('最大值必须大于最小值'));
return;
}
callback();
}
/** 校验步长 */
function validateStep(_rule: any, _value: any, callback: any) {
const step = Number(dataSpecs.value.step);
if (Number.isNaN(step)) {
callback(new Error('请输入有效的数值'));
return;
}
if (step <= 0) {
callback(new Error('步长必须大于 0'));
return;
}
const min = Number(dataSpecs.value.min);
const max = Number(dataSpecs.value.max);
if (!Number.isNaN(min) && !Number.isNaN(max) && step > max - min) {
callback(new Error('步长不能大于最大值与最小值的差值'));
return;
}
callback();
}
</script>
<template>
<ElFormItem label="取值范围">
<div class="flex items-center justify-between">
<div class="flex-1">
<ElFormItem
:rules="[
{ required: true, message: '最小值不能为空' },
{ validator: validateMin, trigger: 'blur' },
]"
class="mb-0 flex-1"
prop="property.dataSpecs.min"
>
<ElInput v-model="dataSpecs.min" placeholder="请输入最小值" />
</div>
</ElFormItem>
<span class="mx-2">~</span>
<div class="flex-1">
<ElFormItem
:rules="[
{ required: true, message: '最大值不能为空' },
{ validator: validateMax, trigger: 'blur' },
]"
class="mb-0 flex-1"
prop="property.dataSpecs.max"
>
<ElInput v-model="dataSpecs.max" placeholder="请输入最大值" />
</div>
</ElFormItem>
</div>
</ElFormItem>
<ElFormItem label="步长">
<ElFormItem
:rules="[
{ required: true, message: '步长不能为空' },
{ validator: validateStep, trigger: 'blur' },
]"
label="步长"
prop="property.dataSpecs.step"
>
<ElInput v-model="dataSpecs.step" placeholder="请输入步长" />
</ElFormItem>
<ElFormItem label="单位">
<ElFormItem
:rules="[{ required: true, message: '请选择单位' }]"
label="单位"
prop="property.dataSpecs.unit"
>
<ElSelect
:model-value="
dataSpecs.unit ? `${dataSpecs.unitName}-${dataSpecs.unit}` : ''

View File

@ -5,7 +5,7 @@ import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { cloneDeep, isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
@ -20,13 +20,33 @@ import { ThingModelFormRules } from '#/api/iot/thingmodel';
import ThingModelProperty from '../property.vue';
const props = defineProps<{ modelValue: any }>();
const props = withDefaults(
defineProps<{
/** dataSpecsList prop property.dataSpecsList
* array 嵌套 struct 时父级需传 'property.dataSpecs.dataSpecsList'
* 否则父表单 validate() 无法定位该字段非空校验不会触发 */
fieldPath?: string;
modelValue: any;
}>(),
{
fieldPath: 'property.dataSpecsList',
},
);
const emits = defineEmits(['update:modelValue']);
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>;
const structFormRef = ref();
const formData = ref<any>(buildEmptyFormData());
/** 校验结构体属性对象非空 */
function validateStructSpecsList(_rule: any, _value: any, callback: any) {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('请至少添加一个结构体属性对象'));
return;
}
callback();
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
@ -70,14 +90,15 @@ const [Modal, modalApi] = useVbenModal({
if (isEmpty(data)) {
return;
}
// cloneDeep v-model dataSpecsList
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.childDataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
},
};
},
@ -115,7 +136,11 @@ onMounted(() => {
</script>
<template>
<ElFormItem label="属性对象">
<ElFormItem
:prop="fieldPath"
:rules="[{ validator: validateStructSpecsList, trigger: 'change' }]"
label="属性对象"
>
<div
v-for="(item, index) in dataSpecsList"
:key="index"

View File

@ -5,7 +5,7 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { cloneDeep, isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
@ -78,15 +78,15 @@ const [Modal, modalApi] = useVbenModal({
if (isEmpty(data)) {
return;
}
// values
// cloneDeep v-model thingModelParams
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',
description: data.description ?? '',
property: {
dataType: data.dataType ?? IoTDataSpecsDataTypeEnum.INT,
dataSpecs: data.dataSpecs ?? {},
dataSpecsList: data.dataSpecsList ?? [],
dataSpecs: data.dataSpecs ? cloneDeep(data.dataSpecs) : {},
dataSpecsList: data.dataSpecsList ? cloneDeep(data.dataSpecsList) : [],
},
};
},

View File

@ -129,11 +129,9 @@ export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
onChange: async () => {
await formApi?.setFieldValue('areaId', undefined);
},
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
options: list,
placeholder: '请选择库区',
props: { label: 'name', value: 'id' },
};
},
},
@ -151,11 +149,9 @@ export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
: [];
return {
clearable: true,
options: list.map((item) => ({
label: item.name,
value: item.id,
})),
options: list,
placeholder: '请选择库位',
props: { label: 'name', value: 'id' },
};
},
},

View File

@ -1,3 +1,13 @@
/** MES 单据状态常量 */
export const MesOrderStatusConstants = {
DRAFT: 0,
CONFIRMED: 1,
APPROVING: 2,
APPROVED: 3,
FINISHED: 4,
CANCELLED: 5,
} as const;
/** MES 物料/产品标识枚举 */
export const MesItemOrProductEnum = {
ITEM: {
@ -10,14 +20,121 @@ export const MesItemOrProductEnum = {
},
} as const;
/** MES 工具状态枚举 */
export const MesToolStatusEnum = {
STORE: 1,
ISSUE: 2,
REPAIR: 3,
SCRAP: 4,
} as const;
/** MES 保养维护类型枚举 */
export const MesMaintenTypeEnum = {
REGULAR: 1,
USAGE: 2,
} as const;
/** MES 设备状态枚举 */
export const MesDvMachineryStatusEnum = {
STOP: 1,
PRODUCING: 2,
MAINTENANCE: 3,
} as const;
/** MES 假期类型枚举 */
export const HolidayType = {
WORKDAY: 1,
HOLIDAY: 2,
} as const;
/** MES 排班计划状态枚举 */
export const MesCalPlanStatusEnum = {
PREPARE: 0,
CONFIRMED: 1,
} as const;
/** MES 轮班方式枚举 */
export const MesCalShiftTypeEnum = {
SINGLE: 1,
TWO: 2,
THREE: 3,
} as const;
/** MES 倒班方式枚举 */
export const MesCalShiftMethodEnum = {
QUARTER: 1,
MONTH: 2,
WEEK: 3,
DAY: 4,
} as const;
/** MES 点检保养项目类型枚举 */
export const MesDvSubjectTypeEnum = {
CHECK: 1,
MAINTENANCE: 2,
} as const;
/** MES 点检保养方案状态枚举 */
export const MesDvCheckPlanStatusEnum = {
PREPARE: 0,
ENABLED: 1,
} as const;
/** MES 设备保养记录状态枚举 */
export const MesDvMaintenRecordStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
SUBMITTED: MesOrderStatusConstants.FINISHED,
} as const;
/** MES 设备保养明细结果枚举 */
export const MesDvMaintenStatusEnum = {
NORMAL: 1,
ABNORMAL: 2,
} as const;
/** MES 设备点检记录状态枚举 */
export const MesDvCheckRecordStatusEnum = {
DRAFT: 10,
FINISHED: 20,
} as const;
/** MES 设备点检结果枚举 */
export const MesDvCheckResultEnum = {
NORMAL: 1,
ABNORMAL: 2,
} as const;
/** MES 维修工单状态枚举 */
export const MesDvRepairStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
FINISHED: MesOrderStatusConstants.FINISHED,
} as const;
/** MES 维修结果枚举 */
export const MesDvRepairResultEnum = {
PASS: 1,
FAIL: 2,
} as const;
/** MES 自动编码规则 Code 枚举 */
export const MesAutoCodeRuleCode = {
CAL_PLAN_CODE: 'CAL_PLAN_CODE',
CAL_TEAM_CODE: 'CAL_TEAM_CODE',
DV_CHECK_PLAN_CODE: 'DV_CHECK_PLAN_CODE',
DV_MACHINERY_TYPE_CODE: 'DV_MACHINERY_TYPE_CODE',
DV_MACHINERY_CODE: 'DV_MACHINERY_CODE',
DV_REPAIR_CODE: 'DV_REPAIR_CODE',
DV_SUBJECT_CODE: 'DV_SUBJECT_CODE',
MD_CLIENT_CODE: 'MD_CLIENT_CODE',
MD_ITEM_TYPE_CODE: 'MD_ITEM_TYPE_CODE',
MD_ITEM_CODE: 'MD_ITEM_CODE',
MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE',
MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE',
TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE',
TM_TOOL_CODE: 'TM_TOOL_CODE',
} as const;
/** MES 编码规则分段类型枚举 */

View File

@ -183,6 +183,23 @@ const MES_DICT = {
MES_MD_AUTO_CODE_PART_TYPE: 'mes_md_auto_code_part_type', // MES 编码规则分段类型
MES_CLIENT_TYPE: 'mes_client_type', // MES 客户类型
MES_VENDOR_LEVEL: 'mes_vendor_level', // MES 供应商级别
MES_CAL_HOLIDAY_TYPE: 'mes_cal_holiday_type', // MES 假期类型
MES_CAL_SHIFT_TYPE: 'mes_cal_shift_type', // MES 轮班方式
MES_CAL_SHIFT_METHOD: 'mes_cal_shift_method', // MES 倒班方式
MES_CAL_CALENDAR_TYPE: 'mes_cal_calendar_type', // MES 班组类型
MES_CAL_PLAN_STATUS: 'mes_cal_plan_status', // MES 排班计划状态
MES_TM_TOOL_STATUS: 'mes_tm_tool_status', // MES 工具状态
MES_TM_MAINTEN_TYPE: 'mes_tm_mainten_type', // MES 保养维护类型
MES_DV_MACHINERY_STATUS: 'mes_dv_machinery_status', // MES 设备状态
MES_DV_SUBJECT_TYPE: 'mes_dv_subject_type', // MES 点检保养项目类型
MES_DV_CYCLE_TYPE: 'mes_dv_cycle_type', // MES 点检保养周期类型
MES_DV_CHECK_PLAN_STATUS: 'mes_dv_check_plan_status', // MES 点检保养方案状态
MES_MAINTEN_RECORD_STATUS: 'mes_mainten_record_status', // MES 保养记录状态
MES_MAINTEN_STATUS: 'mes_mainten_status', // MES 保养结果
MES_DV_REPAIR_STATUS: 'mes_dv_repair_status', // MES 维修工单状态
MES_DV_REPAIR_RESULT: 'mes_dv_repair_result', // MES 维修结果
MES_DV_CHECK_RECORD_STATUS: 'mes_dv_check_record_status', // MES 点检记录状态
MES_DV_CHECK_RESULT: 'mes_dv_check_result', // MES 点检结果
MES_WM_BARCODE_BIZ_TYPE: 'mes_wm_barcode_biz_type', // MES 条码业务类型
MES_WM_BARCODE_FORMAT: 'mes_wm_barcode_format', // MES 条码格式
MES_WM_PRODUCT_SALES_STATUS: 'mes_wm_product_sales_status', // MES 销售出库单状态