fix(iot): 修复场景联动动态列表 key 与校验问题

- 新增 getStableObjectKey,统一处理对象列表 v-for 稳定 key
- 场景联动触发器、执行器、条件组、条件项改用稳定对象 key
- 保持场景规则 API 类型不包含 UI 专用 _key 字段
- 修复场景联动触发器/执行器校验与地图详情跳转
pull/348/head
YunaiV 2026-05-24 16:43:02 +08:00
parent aeff25209d
commit 6fb45f1ded
19 changed files with 100 additions and 47 deletions

View File

@ -24,7 +24,7 @@ export namespace RuleSceneApi {
operator?: string;
value?: any;
cronExpression?: string;
conditionGroups?: TriggerCondition[][];
conditionGroups?: TriggerCondition[][]; // 后端结构List<List<TriggerCondition>>;外层「或」、组内「且」
}
/** 场景联动规则的触发条件 */

View File

@ -19,21 +19,29 @@ export namespace MesDvMaintenRecordLineApi {
/** 查询设备保养记录明细分页 */
export function getMaintenRecordLinePage(params: PageParam) {
return requestClient.get<PageResult<MesDvMaintenRecordLineApi.MaintenRecordLine>>('/mes/dv/mainten-record-line/page', { params });
return requestClient.get<
PageResult<MesDvMaintenRecordLineApi.MaintenRecordLine>
>('/mes/dv/mainten-record-line/page', { params });
}
/** 查询设备保养记录明细详情 */
export function getMaintenRecordLine(id: number) {
return requestClient.get<MesDvMaintenRecordLineApi.MaintenRecordLine>(`/mes/dv/mainten-record-line/get?id=${id}`);
return requestClient.get<MesDvMaintenRecordLineApi.MaintenRecordLine>(
`/mes/dv/mainten-record-line/get?id=${id}`,
);
}
/** 新增设备保养记录明细 */
export function createMaintenRecordLine(data: MesDvMaintenRecordLineApi.MaintenRecordLine) {
export function createMaintenRecordLine(
data: MesDvMaintenRecordLineApi.MaintenRecordLine,
) {
return requestClient.post('/mes/dv/mainten-record-line/create', data);
}
/** 修改设备保养记录明细 */
export function updateMaintenRecordLine(data: MesDvMaintenRecordLineApi.MaintenRecordLine) {
export function updateMaintenRecordLine(
data: MesDvMaintenRecordLineApi.MaintenRecordLine,
) {
return requestClient.put('/mes/dv/mainten-record-line/update', data);
}

View File

@ -111,19 +111,22 @@ function initMap() {
//
infoWindow.addEventListener('open', () => {
setTimeout(() => {
const link = document.querySelector('.device-link');
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
const deviceId = (e.target as HTMLElement).dataset.id;
if (deviceId) {
router.push({
name: 'IoTDeviceDetail',
params: { id: deviceId },
});
}
});
const link = document.querySelector(
'.BMap_bubble_content .device-link',
);
if (!link) {
return;
}
link.addEventListener('click', (e) => {
e.preventDefault();
if (device.id === undefined || device.id === null) {
return;
}
router.push({
name: 'IoTDeviceDetail',
params: { id: device.id },
});
});
}, 100);
});

View File

@ -5,6 +5,7 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene';
import { nextTick } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Tag } from 'ant-design-vue';
@ -183,7 +184,7 @@ function removeConditionGroup() {
<div class="relative">
<div
v-for="(subGroup, subGroupIndex) in trigger.conditionGroups"
:key="`sub-group-${subGroupIndex}`"
:key="getStableObjectKey(subGroup)"
class="relative"
>
<!-- 子条件组容器橙色主题 -->

View File

@ -8,6 +8,7 @@ import {
IotRuleSceneTriggerConditionTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button } from 'ant-design-vue';
@ -105,7 +106,7 @@ function updateCondition(
<div v-else class="space-y-4">
<div
v-for="(condition, conditionIndex) in subGroup"
:key="`condition-${conditionIndex}`"
:key="getStableObjectKey(condition)"
class="relative"
>
<!-- 条件配置 -->

View File

@ -5,6 +5,7 @@ import { nextTick } from 'vue';
import { IotRuleSceneTriggerTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Tag } from 'ant-design-vue';
@ -96,7 +97,7 @@ function updateConditionGroup(
>
<div
v-for="(group, groupIndex) in conditionGroups"
:key="`group-${groupIndex}`"
:key="getStableObjectKey(group)"
class="relative"
>
<!-- 条件组容器橙色主题 -->

View File

@ -8,6 +8,7 @@ import {
IotRuleSceneActionTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Card, Empty, Form, Select, Tag } from 'ant-design-vue';
@ -183,7 +184,7 @@ function onActionTypeChange(action: RuleSceneApi.Action, type: number) {
<div v-else class="space-y-[24px]">
<div
v-for="(action, index) in actions"
:key="`action-${index}`"
:key="getStableObjectKey(action)"
class="rounded-lg border border-blue-200 bg-blue-50/40 shadow-sm transition-shadow hover:shadow-md dark:border-blue-900/40 dark:bg-blue-950/20"
>
<!-- 执行器头部蓝色主题 -->

View File

@ -9,6 +9,7 @@ import {
isDeviceTrigger,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Card, Empty, Form, Tag } from 'ant-design-vue';
@ -131,7 +132,7 @@ onMounted(() => {
<div v-if="triggers.length > 0" class="space-y-[24px]">
<div
v-for="(triggerItem, index) in triggers"
:key="`trigger-${index}`"
:key="getStableObjectKey(triggerItem)"
class="rounded-[8px] border border-green-200 bg-green-50/40 shadow-sm transition-shadow hover:shadow-md dark:border-green-900/40 dark:bg-green-950/20"
>
<!-- 触发器头部绿色主题 -->

View File

@ -103,12 +103,14 @@ function buildEmptyFormData(): RuleSceneApi.SceneRule {
/** 回显时兜底,保证触发器/执行器数组不为空 */
function normalizeFormData(result: any): RuleSceneApi.SceneRule {
const triggers: RuleSceneApi.Trigger[] = result.triggers?.length
? result.triggers
: buildEmptyFormData().triggers!;
const actions: RuleSceneApi.Action[] = result.actions || [];
return {
...result,
triggers: result.triggers?.length
? result.triggers
: buildEmptyFormData().triggers,
actions: result.actions || [],
triggers,
actions,
};
}

View File

@ -24,8 +24,7 @@ export namespace RuleSceneApi {
operator?: string;
value?: any;
cronExpression?: string;
// 后端结构List<List<TriggerCondition>>;外层「或」、组内「且」
conditionGroups?: TriggerCondition[][];
conditionGroups?: TriggerCondition[][]; // 后端结构List<List<TriggerCondition>>;外层「或」、组内「且」
}
/** 场景联动规则的触发条件 */

View File

@ -19,21 +19,29 @@ export namespace MesDvMaintenRecordLineApi {
/** 查询设备保养记录明细分页 */
export function getMaintenRecordLinePage(params: PageParam) {
return requestClient.get<PageResult<MesDvMaintenRecordLineApi.MaintenRecordLine>>('/mes/dv/mainten-record-line/page', { params });
return requestClient.get<
PageResult<MesDvMaintenRecordLineApi.MaintenRecordLine>
>('/mes/dv/mainten-record-line/page', { params });
}
/** 查询设备保养记录明细详情 */
export function getMaintenRecordLine(id: number) {
return requestClient.get<MesDvMaintenRecordLineApi.MaintenRecordLine>(`/mes/dv/mainten-record-line/get?id=${id}`);
return requestClient.get<MesDvMaintenRecordLineApi.MaintenRecordLine>(
`/mes/dv/mainten-record-line/get?id=${id}`,
);
}
/** 新增设备保养记录明细 */
export function createMaintenRecordLine(data: MesDvMaintenRecordLineApi.MaintenRecordLine) {
export function createMaintenRecordLine(
data: MesDvMaintenRecordLineApi.MaintenRecordLine,
) {
return requestClient.post('/mes/dv/mainten-record-line/create', data);
}
/** 修改设备保养记录明细 */
export function updateMaintenRecordLine(data: MesDvMaintenRecordLineApi.MaintenRecordLine) {
export function updateMaintenRecordLine(
data: MesDvMaintenRecordLineApi.MaintenRecordLine,
) {
return requestClient.put('/mes/dv/mainten-record-line/update', data);
}

View File

@ -117,13 +117,13 @@ function initMap() {
}
link.addEventListener('click', (e) => {
e.preventDefault();
const deviceId = (e.target as HTMLElement).dataset.id;
if (deviceId) {
router.push({
name: 'IoTDeviceDetail',
params: { id: deviceId },
});
if (device.id === undefined || device.id === null) {
return;
}
router.push({
name: 'IoTDeviceDetail',
params: { id: device.id },
});
});
}, 100);
});

View File

@ -5,6 +5,7 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene';
import { nextTick } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton, ElTag } from 'element-plus';
@ -180,7 +181,7 @@ function removeConditionGroup() {
<div class="relative">
<div
v-for="(subGroup, subGroupIndex) in trigger.conditionGroups"
:key="`sub-group-${subGroupIndex}`"
:key="getStableObjectKey(subGroup)"
class="relative"
>
<!-- 子条件组容器 -->

View File

@ -8,6 +8,7 @@ import {
IotRuleSceneTriggerConditionTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton } from 'element-plus';
@ -105,7 +106,7 @@ function updateCondition(
<div v-else class="space-y-4">
<div
v-for="(condition, conditionIndex) in subGroup"
:key="`condition-${conditionIndex}`"
:key="getStableObjectKey(condition)"
class="relative"
>
<!-- 条件配置 -->

View File

@ -5,6 +5,7 @@ import { nextTick } from 'vue';
import { IotRuleSceneTriggerTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton, ElTag } from 'element-plus';
@ -96,7 +97,7 @@ function updateConditionGroup(
>
<div
v-for="(group, groupIndex) in conditionGroups"
:key="`group-${groupIndex}`"
:key="getStableObjectKey(group)"
class="relative"
>
<!-- 条件组容器橙色主题 -->

View File

@ -8,6 +8,7 @@ import {
IotRuleSceneActionTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
@ -199,7 +200,7 @@ function onActionTypeChange(action: RuleSceneApi.Action, type: number) {
<div v-else class="space-y-[24px]">
<div
v-for="(action, index) in actions"
:key="`action-${index}`"
:key="getStableObjectKey(action)"
class="rounded-lg border-2 border-blue-200 bg-blue-50 shadow-sm transition-shadow hover:shadow-md"
>
<!-- 执行器头部 - 蓝色主题 -->

View File

@ -9,6 +9,7 @@ import {
isDeviceTrigger,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { getStableObjectKey } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton, ElCard, ElEmpty, ElFormItem, ElTag } from 'element-plus';
@ -164,7 +165,7 @@ onMounted(() => {
<div v-if="triggers.length > 0" class="space-y-[24px]">
<div
v-for="(triggerItem, index) in triggers"
:key="`trigger-${index}`"
:key="getStableObjectKey(triggerItem)"
class="rounded-[8px] border-2 border-green-200 bg-green-50 shadow-sm transition-shadow hover:shadow-md"
>
<!-- 触发器头部 - 绿色主题 -->

View File

@ -103,12 +103,14 @@ function buildEmptyFormData(): RuleSceneApi.SceneRule {
/** 回显时兜底,保证触发器/执行器数组不为空 */
function normalizeFormData(result: any): RuleSceneApi.SceneRule {
const triggers: RuleSceneApi.Trigger[] = result.triggers?.length
? result.triggers
: buildEmptyFormData().triggers!;
const actions: RuleSceneApi.Action[] = result.actions || [];
return {
...result,
triggers: result.triggers?.length
? result.triggers
: buildEmptyFormData().triggers,
actions: result.actions || [],
triggers,
actions,
};
}

View File

@ -1,3 +1,5 @@
import { buildShortUUID } from './uuid';
export function bindMethods<T extends object>(instance: T): void {
const prototype = Object.getPrototypeOf(instance);
const propertyNames = Object.getOwnPropertyNames(prototype);
@ -114,3 +116,22 @@ export function jsonParse(str: string) {
return str;
}
}
const stableObjectKeyMap = new WeakMap<object, string>();
/**
* key
*
* v-for 使 UI
*/
export function getStableObjectKey(
item: object,
generator: () => string = buildShortUUID,
): string {
let key = stableObjectKeyMap.get(item);
if (!key) {
key = generator();
stableObjectKeyMap.set(item, key);
}
return key;
}