fix(iot): 修复 13 处 bug 并完成 codex 三轮收口

按 codex 两轮 review 分批处理 IoT 模块 13 处 bug,对第二轮反馈中
B42/B47 的类型/字段问题做最终收尾,所有修复 web-antd / web-ele 两端同步。

主要修复:
- B91  设备分组:删除前校验 deviceCount,分组下有设备时弹警告
- B40  物模型 array 数据规格:从 Radio.Group @change 事件正确取值(antd)
- B42  物模型属性历史:list 写入时按 idx 生成 _rowKey,模板 row-key="_rowKey"
       list 类型改 IotDeviceApi.DeviceProperty & { _rowKey: string }
       匹配后端 IotDevicePropertyMapper.xml 实际返回的字段
       (修掉 codex 指出的 antd row-key TS2322 与 ele 同毫秒撞键)
- B119 物模型表单:edit 模式禁用 identifier 编辑
- B47  场景规则主条件:产品/设备切换时清 deviceId/identifier/operator/value
       (修掉 codex 指出的 condition.value.param TS2339,Trigger 无 param)
- B44  数据目的数据库配置:SQL 复制按钮 setTimeout 在 onBeforeUnmount 中清理
- B51  场景规则首页统计:total 取接口 result.total,其余基于当前页
- B29  产品卡片视图:图标为 URL 时改用 <img> 渲染,复用 @vben/utils 的 isHttpUrl
- B43  首页设备地图:移除过度设计的 AbortController,回归 vue3 源项目同款
       InfoWindow 监听写法,querySelector 限定到 .BMap_bubble_content 子树
- B105 场景规则设备选择器:productId 变化后旧 deviceId 不在新列表则清空
- B45  通用 key-value 编辑器:v-for key 改用递增的 _uid,避免编辑/删除时 DOM 复用错乱
- B132 设备导入表单:beforeUpload 校验 .xls/.xlsx
- B126 设备详情:四个 tab 子组件 v-if 增加 device.id 守卫

附带工具收敛:
- @vben-core/shared/utils 新增 formatDayjs,统一 antd TimePicker/DatePicker
  value-format 后回传的 Dayjs|string 归一
- 场景规则首页 updateStatistics 补回 JSDoc,对齐文件内其他 function 风格

验证:
- 改动文件 pnpm exec eslint 0 error
- pnpm -F @vben/web-antd / @vben/web-ele exec vue-tsc --noEmit --skipLibCheck
  过滤 src/views/iot/|src/api/iot/ 均 0 hit
pull/348/head
YunaiV 2026-05-24 10:11:43 +08:00
parent 241cf76788
commit aeff25209d
38 changed files with 293 additions and 108 deletions

View File

@ -60,6 +60,7 @@
"pinia": "catalog:",
"steady-xml": "catalog:",
"tinymce": "catalog:",
"tyme4ts": "catalog:",
"video.js": "catalog:",
"vue": "catalog:",
"vue-dompurify-html": "catalog:",

View File

@ -16,7 +16,9 @@ export namespace MesDvCheckPlanMachineryApi {
/** 查询指定方案的设备列表 */
export function getCheckPlanMachineryListByPlan(planId: number) {
return requestClient.get<MesDvCheckPlanMachineryApi.CheckPlanMachinery[]>(`/mes/dv/check-plan-machinery/list-by-plan?planId=${planId}`);
return requestClient.get<MesDvCheckPlanMachineryApi.CheckPlanMachinery[]>(
`/mes/dv/check-plan-machinery/list-by-plan?planId=${planId}`,
);
}
/** 新增方案设备关联 */

View File

@ -54,7 +54,5 @@ export function deleteAutoCodeRule(id: number) {
/** 导出编码规则 */
export function exportAutoCodeRule(params: PageParam) {
return requestClient.download('/mes/md/auto-code-rule/export-excel', {
params,
});
return requestClient.download('/mes/md/auto-code-rule/export-excel', { params });
}

View File

@ -34,9 +34,7 @@ export namespace MesMdItemApi {
/** 查询物料产品分页 */
export function getItemPage(params: PageParam) {
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', {
params,
});
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', { params });
}
/** 查询物料产品详情 */

View File

@ -97,7 +97,7 @@ onMounted(async () => {
<Tabs v-model:active-key="activeTab" class="mt-4">
<Tabs.TabPane key="info" tab="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
v-if="activeTab === 'info' && device.id"
:device="device"
:product="product"
/>
@ -127,7 +127,7 @@ onMounted(async () => {
</Tabs.TabPane>
<Tabs.TabPane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
v-if="activeTab === 'simulator' && device.id"
:device="device"
:product="product"
:thing-model-list="thingModelList"
@ -135,7 +135,7 @@ onMounted(async () => {
</Tabs.TabPane>
<Tabs.TabPane key="config" tab="设备配置">
<DeviceDetailConfig
v-if="activeTab === 'config'"
v-if="activeTab === 'config' && device.id"
:device="device"
@success="() => getDeviceData(id)"
/>
@ -151,7 +151,7 @@ onMounted(async () => {
tab="Modbus 配置"
>
<DeviceModbusConfig
v-if="activeTab === 'modbus'"
v-if="activeTab === 'modbus' && device.id"
:device="device"
:product="product"
:thing-model-list="thingModelList"

View File

@ -33,7 +33,7 @@ defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); //
const loading = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); //
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const list = ref<Array<IotDeviceApi.DeviceProperty & { _rowKey: string }>>([]); //
const total = ref(0); //
const thingModelDataType = ref<string>(''); //
const propertyIdentifier = ref<string>(''); //
@ -151,9 +151,11 @@ const paginationConfig = computed(() => ({
async function getList() {
loading.value = true;
try {
//
const data = await getHistoryDevicePropertyList(queryParams);
list.value = (data || []) as IotDeviceApi.DevicePropertyDetail[];
list.value = (data || []).map((item, idx) => ({
...item,
_rowKey: `${item.updateTime ?? ''}-${idx}`, // value/updateTime _rowKey
}));
total.value = list.value.length;
//
@ -438,7 +440,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
:data-source="list"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
row-key="updateTime"
row-key="_rowKey"
size="small"
>
<template #bodyCell="{ column, record }">

View File

@ -75,6 +75,11 @@ const [Modal, modalApi] = useVbenModal({
/** 上传前 */
function beforeUpload(file: FileType) {
const fileName = file.name?.toLowerCase() ?? '';
if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) {
message.error('只能上传 Excel 文件(.xls / .xlsx');
return Upload.LIST_IGNORE;
}
formApi.setFieldValue('file', file);
return false;
}

View File

@ -35,6 +35,10 @@ function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
/** 删除设备分组 */
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
if (row.deviceCount && row.deviceCount > 0) {
message.warning(`分组「${row.name}」下存在 ${row.deviceCount} 台设备,无法删除`);
return;
}
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,

View File

@ -4,6 +4,7 @@ import { onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { DICT_TYPE, ProductStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { isHttpUrl } from '@vben/utils';
import {
Button,
@ -117,7 +118,14 @@ onMounted(() => {
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<img
v-if="isHttpUrl(item.icon)"
:src="item.icon"
alt=""
class="size-6 object-contain"
/>
<IconifyIcon
v-else
:icon="item.icon || 'lucide:box'"
class="text-xl"
/>

View File

@ -16,15 +16,18 @@ const props = defineProps<{
const emit = defineEmits(['update:modelValue']);
interface KeyValueItem {
_uid: number;
key: string;
value: string;
}
let uidCounter = 0;
const items = ref<KeyValueItem[]>([]); // key-value
/** 添加项目 */
function addItem() {
items.value.push({ key: '', value: '' });
uidCounter += 1;
items.value.push({ _uid: uidCounter, key: '', value: '' });
updateModelValue();
}
@ -54,16 +57,16 @@ watch(
if (isEmpty(val) || !isEmpty(items.value)) {
return;
}
items.value = Object.entries(props.modelValue).map(([key, value]) => ({
key,
value,
}));
items.value = Object.entries(props.modelValue).map(([key, value]) => {
uidCounter += 1;
return { _uid: uidCounter, key, value };
});
},
);
</script>
<template>
<div v-for="(item, index) in items" :key="index" class="mb-2 flex w-full">
<div v-for="(item, index) in items" :key="item._uid" class="mb-2 flex w-full">
<Input v-model:value="item.key" class="mr-2" placeholder="键" />
<Input v-model:value="item.value" placeholder="值" />
<Button class="ml-2" type="link" danger @click="removeItem(index)">

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isEmpty } from '@vben/utils';
@ -30,14 +30,28 @@ const config = useVModel(props, 'modelValue', emit);
const showSqlTip = ref(false);
const copied = ref(false);
const { copy } = useClipboard();
let copyResetTimer: null | ReturnType<typeof setTimeout> = null;
async function handleCopySql() {
await copy(TABLE_SQL);
copied.value = true;
message.success('建表 SQL 已复制到剪贴板');
setTimeout(() => (copied.value = false), 2000);
if (copyResetTimer) {
clearTimeout(copyResetTimer);
}
copyResetTimer = setTimeout(() => {
copied.value = false;
copyResetTimer = null;
}, 2000);
}
onBeforeUnmount(() => {
if (copyResetTimer) {
clearTimeout(copyResetTimer);
copyResetTimer = null;
}
});
onMounted(() => {
if (!isEmpty(config.value)) {
return;

View File

@ -124,15 +124,24 @@ function handleTriggerTypeChange(type: number) {
/** 处理产品变化事件 */
function handleProductChange() {
//
condition.value.deviceId = undefined;
condition.value.identifier = '';
const trigger = condition.value;
trigger.deviceId = undefined;
trigger.identifier = '';
trigger.operator = undefined;
// Trigger.value TriggerCondition.param
trigger.value = '';
propertyType.value = '';
propertyConfig.value = null;
}
/** 处理设备变化事件 */
function handleDeviceChange() {
//
condition.value.identifier = '';
const trigger = condition.value;
trigger.identifier = '';
trigger.operator = undefined;
trigger.value = '';
propertyType.value = '';
propertyConfig.value = null;
}
/**

View File

@ -55,16 +55,26 @@ async function getDeviceList() {
//
watch(
() => props.productId,
(newProductId) => {
if (newProductId) {
getDeviceList();
} else {
async (newProductId, oldProductId) => {
if (!newProductId) {
deviceList.value = [];
//
if (props.modelValue) {
if (props.modelValue !== undefined && props.modelValue !== null) {
emit('update:modelValue', undefined);
emit('change', undefined);
}
return;
}
await getDeviceList();
// productId deviceId
if (
oldProductId !== undefined &&
oldProductId !== newProductId &&
props.modelValue !== undefined &&
props.modelValue !== null &&
!deviceList.value.some((d: any) => d.id === props.modelValue)
) {
emit('update:modelValue', undefined);
emit('change', undefined);
}
},
{ immediate: true },

View File

@ -182,10 +182,10 @@ function getNextExecutionTime(row: RuleSceneApi.SceneRule): Date | null {
: null;
}
/** 基于当前页列表刷新统计数据 */
function updateStatistics(rows: RuleSceneApi.SceneRule[]) {
/** 刷新规则统计卡片数据 */
function updateStatistics(rows: RuleSceneApi.SceneRule[], total?: number) {
statistics.value = {
total: rows.length,
total: total ?? rows.length,
enabled: rows.filter((item) => item.status === CommonStatusEnum.ENABLE)
.length,
disabled: rows.filter((item) => item.status === CommonStatusEnum.DISABLE)
@ -210,7 +210,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
pageSize: page.pageSize,
...formValues,
});
updateStatistics(result.list || []);
updateStatistics(result.list || [], result.total);
return result;
},
},

View File

@ -29,7 +29,8 @@ const childDataTypeOptions = getDataTypeOptions().filter(
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 元素类型切到 struct 时,初始化 dataSpecsList 占位 */
function handleChange(val: any) {
function handleChange(e: any) {
const val = e?.target?.value ?? e;
if (val !== IoTDataSpecsDataTypeEnum.STRUCT) {
return;
}

View File

@ -231,7 +231,11 @@ function removeDataSpecs(val: any) {
label="标识符"
name="identifier"
>
<Input v-model:value="formData.identifier" placeholder="请输入标识符" />
<Input
v-model:value="formData.identifier"
:disabled="formData.id != null"
placeholder="请输入标识符"
/>
</Form.Item>
<!-- 属性配置 -->
<ThingModelProperty

View File

@ -47,13 +47,33 @@ async function waitTableReady(): Promise<void> {
if (preSelectedIds.value.length === 0) {
return;
}
if (!multiple.value) {
const selected = checked && row ? [row] : [];
selectedRows.value = selected;
await syncSingleSelection(selected[0]);
return;
for (let index = 0; index < MAX_TABLE_READY_FRAMES; index += 1) {
if (queryFinished.value) {
const rows = getTableRows();
if (latestQueryRows.value.length === 0 && rows.length === 0) {
return;
}
if (latestQueryRows.value.length > 0 && rows.length > 0) {
return;
}
}
await waitNextFrame();
}
selectedRows.value = records;
}
/** 获取多选记录,包含 VXE reserve 跨页记录 */
function getMultipleSelectedRows() {
const selectedMap = new Map<number, MesMdClientApi.Client>();
const records = [
...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
...(gridApi.grid.getCheckboxRecords?.() ?? []),
] as MesMdClientApi.Client[];
records.forEach((row) => {
if (row.id != null) {
selectedMap.set(row.id, row);
}
});
return [...selectedMap.values()];
}
/** 处理勾选变化 */

View File

@ -60,6 +60,7 @@
"pinia": "catalog:",
"steady-xml": "catalog:",
"tinymce": "catalog:",
"tyme4ts": "catalog:",
"video.js": "catalog:",
"vue": "catalog:",
"vue-dompurify-html": "catalog:",

View File

@ -16,7 +16,9 @@ export namespace MesDvCheckPlanMachineryApi {
/** 查询指定方案的设备列表 */
export function getCheckPlanMachineryListByPlan(planId: number) {
return requestClient.get<MesDvCheckPlanMachineryApi.CheckPlanMachinery[]>(`/mes/dv/check-plan-machinery/list-by-plan?planId=${planId}`);
return requestClient.get<MesDvCheckPlanMachineryApi.CheckPlanMachinery[]>(
`/mes/dv/check-plan-machinery/list-by-plan?planId=${planId}`,
);
}
/** 新增方案设备关联 */

View File

@ -17,7 +17,9 @@ export namespace MesDvCheckPlanSubjectApi {
/** 查询指定方案的项目列表 */
export function getCheckPlanSubjectListByPlan(planId: number) {
return requestClient.get<MesDvCheckPlanSubjectApi.CheckPlanSubject[]>(`/mes/dv/check-plan-subject/list-by-plan?planId=${planId}`);
return requestClient.get<MesDvCheckPlanSubjectApi.CheckPlanSubject[]>(
`/mes/dv/check-plan-subject/list-by-plan?planId=${planId}`,
);
}
/** 新增方案项目关联 */

View File

@ -51,7 +51,5 @@ export function deleteAutoCodeRule(id: number) {
/** 导出编码规则 */
export function exportAutoCodeRule(params: PageParam) {
return requestClient.download('/mes/md/auto-code-rule/export-excel', {
params,
});
return requestClient.download('/mes/md/auto-code-rule/export-excel', { params });
}

View File

@ -34,9 +34,7 @@ export namespace MesMdItemApi {
/** 查询物料产品分页 */
export function getItemPage(params: PageParam) {
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', {
params,
});
return requestClient.get<PageResult<MesMdItemApi.Item>>('/mes/md/item/page', { params });
}
/** 查询物料产品详情 */

View File

@ -18,22 +18,35 @@ export namespace MesTmToolTypeApi {
/** 查询工具类型分页 */
export function getToolTypePage(params: PageParam) {
return requestClient.get<PageResult<MesTmToolTypeApi.ToolType>>(
'/mes/tm/tool-type/page',
{ params },
);
return requestClient.get<PageResult<MesTmToolTypeApi.ToolType>>('/mes/tm/tool-type/page', { params });
}
/** 查询工具类型精简列表 */
export function getToolTypeSimpleList() {
return requestClient.get<MesTmToolTypeApi.ToolType[]>(
'/mes/tm/tool-type/simple-list',
);
return requestClient.get<MesTmToolTypeApi.ToolType[]>('/mes/tm/tool-type/simple-list');
}
/** 查询工具类型详情 */
export function getToolType(id: number) {
return requestClient.get<MesTmToolTypeApi.ToolType>(
`/mes/tm/tool-type/get?id=${id}`,
);
return requestClient.get<MesTmToolTypeApi.ToolType>(`/mes/tm/tool-type/get?id=${id}`);
}
/** 新增工具类型 */
export function createToolType(data: MesTmToolTypeApi.ToolType) {
return requestClient.post('/mes/tm/tool-type/create', data);
}
/** 修改工具类型 */
export function updateToolType(data: MesTmToolTypeApi.ToolType) {
return requestClient.put('/mes/tm/tool-type/update', data);
}
/** 删除工具类型 */
export function deleteToolType(id: number) {
return requestClient.delete(`/mes/tm/tool-type/delete?id=${id}`);
}
/** 导出工具类型 */
export function exportToolType(params: any) {
return requestClient.download('/mes/tm/tool-type/export-excel', { params });
}

View File

@ -97,7 +97,7 @@ onMounted(async () => {
<ElTabs v-model="activeTab" class="mt-4">
<ElTabPane name="info" label="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
v-if="activeTab === 'info' && device.id"
:device="device"
:product="product"
/>
@ -127,7 +127,7 @@ onMounted(async () => {
</ElTabPane>
<ElTabPane name="simulator" label="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
v-if="activeTab === 'simulator' && device.id"
:device="device"
:product="product"
:thing-model-list="thingModelList"
@ -135,7 +135,7 @@ onMounted(async () => {
</ElTabPane>
<ElTabPane name="config" label="设备配置">
<DeviceDetailConfig
v-if="activeTab === 'config'"
v-if="activeTab === 'config' && device.id"
:device="device"
@success="() => getDeviceData(id)"
/>
@ -151,7 +151,7 @@ onMounted(async () => {
label="Modbus 配置"
>
<DeviceModbusConfig
v-if="activeTab === 'modbus'"
v-if="activeTab === 'modbus' && device.id"
:device="device"
:product="product"
:thing-model-list="thingModelList"

View File

@ -33,7 +33,7 @@ defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); //
const loading = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); //
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const list = ref<Array<IotDeviceApi.DeviceProperty & { _rowKey: string }>>([]); //
const total = ref(0); //
const thingModelDataType = ref<string>(''); //
const propertyIdentifier = ref<string>(''); //
@ -118,9 +118,12 @@ function formatDateRangeWithTime(dates: [string, string]): [string, string] {
async function getList() {
loading.value = true;
try {
//
// value/updateTime _rowKey
const data = await getHistoryDevicePropertyList(queryParams);
list.value = (data || []) as IotDeviceApi.DevicePropertyDetail[];
list.value = (data || []).map((item, idx) => ({
...item,
_rowKey: `${item.updateTime ?? ''}-${idx}`,
}));
total.value = list.value.length;
//
@ -396,7 +399,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<ElTable
:data="list"
:max-height="500"
row-key="updateTime"
row-key="_rowKey"
size="small"
>
<ElTableColumn label="序号" width="80" align="center" type="index" />

View File

@ -73,6 +73,11 @@ const [Modal, modalApi] = useVbenModal({
/** 上传前 */
function beforeUpload(file: File) {
const fileName = file.name?.toLowerCase() ?? '';
if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) {
ElMessage.error('只能上传 Excel 文件(.xls / .xlsx');
return false;
}
formApi.setFieldValue('file', file);
return false;
}

View File

@ -35,6 +35,10 @@ function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
/** 删除设备分组 */
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
if (row.deviceCount && row.deviceCount > 0) {
ElMessage.warning(`分组「${row.name}」下存在 ${row.deviceCount} 台设备,无法删除`);
return;
}
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});

View File

@ -111,19 +111,20 @@ 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();
const deviceId = (e.target as HTMLElement).dataset.id;
if (deviceId) {
router.push({
name: 'IoTDeviceDetail',
params: { id: deviceId },
});
}
});
}, 100);
});

View File

@ -4,6 +4,7 @@ import { onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { DICT_TYPE, ProductStatusEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { isHttpUrl } from '@vben/utils';
import {
ElButton,
@ -111,7 +112,14 @@ onMounted(() => {
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<img
v-if="isHttpUrl(item.icon)"
:src="item.icon"
alt=""
class="size-6 object-contain"
/>
<IconifyIcon
v-else
:icon="item.icon || 'lucide:box'"
class="text-xl"
/>

View File

@ -16,15 +16,18 @@ const props = defineProps<{
const emit = defineEmits(['update:modelValue']);
interface KeyValueItem {
_uid: number;
key: string;
value: string;
}
let uidCounter = 0;
const items = ref<KeyValueItem[]>([]); // key-value
/** 添加项目 */
function addItem() {
items.value.push({ key: '', value: '' });
uidCounter += 1;
items.value.push({ _uid: uidCounter, key: '', value: '' });
updateModelValue();
}
@ -54,16 +57,16 @@ watch(
if (isEmpty(val) || !isEmpty(items.value)) {
return;
}
items.value = Object.entries(props.modelValue).map(([key, value]) => ({
key,
value,
}));
items.value = Object.entries(props.modelValue).map(([key, value]) => {
uidCounter += 1;
return { _uid: uidCounter, key, value };
});
},
);
</script>
<template>
<div v-for="(item, index) in items" :key="index" class="mb-2 flex w-full">
<div v-for="(item, index) in items" :key="item._uid" class="mb-2 flex w-full">
<ElInput v-model="item.key" class="mr-2" placeholder="键" />
<ElInput v-model="item.value" placeholder="值" />
<ElButton class="ml-2" type="danger" link @click="removeItem(index)">

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isEmpty } from '@vben/utils';
@ -30,14 +30,28 @@ const config = useVModel(props, 'modelValue', emit);
const showSqlTip = ref(false);
const copied = ref(false);
const { copy } = useClipboard();
let copyResetTimer: null | ReturnType<typeof setTimeout> = null;
async function handleCopySql() {
await copy(TABLE_SQL);
copied.value = true;
ElMessage.success('建表 SQL 已复制到剪贴板');
setTimeout(() => (copied.value = false), 2000);
if (copyResetTimer) {
clearTimeout(copyResetTimer);
}
copyResetTimer = setTimeout(() => {
copied.value = false;
copyResetTimer = null;
}, 2000);
}
onBeforeUnmount(() => {
if (copyResetTimer) {
clearTimeout(copyResetTimer);
copyResetTimer = null;
}
});
onMounted(() => {
if (!isEmpty(config.value)) {
return;

View File

@ -131,15 +131,24 @@ function handleTriggerTypeChange(type: number) {
/** 处理产品变化事件 */
function handleProductChange() {
//
condition.value.deviceId = undefined;
condition.value.identifier = '';
const trigger = condition.value;
trigger.deviceId = undefined;
trigger.identifier = '';
trigger.operator = undefined;
// Trigger.value TriggerCondition.param
trigger.value = '';
propertyType.value = '';
propertyConfig.value = null;
}
/** 处理设备变化事件 */
function handleDeviceChange() {
//
condition.value.identifier = '';
const trigger = condition.value;
trigger.identifier = '';
trigger.operator = undefined;
trigger.value = '';
propertyType.value = '';
propertyConfig.value = null;
}
/**

View File

@ -60,16 +60,26 @@ async function getDeviceList() {
//
watch(
() => props.productId,
(newProductId) => {
if (newProductId) {
getDeviceList();
} else {
async (newProductId, oldProductId) => {
if (!newProductId) {
deviceList.value = [];
//
if (props.modelValue) {
if (props.modelValue !== undefined && props.modelValue !== null) {
emit('update:modelValue', undefined);
emit('change', undefined);
}
return;
}
await getDeviceList();
// productId deviceId
if (
oldProductId !== undefined &&
oldProductId !== newProductId &&
props.modelValue !== undefined &&
props.modelValue !== null &&
!deviceList.value.some((d: any) => d.id === props.modelValue)
) {
emit('update:modelValue', undefined);
emit('change', undefined);
}
},
{ immediate: true },

View File

@ -186,10 +186,10 @@ function getNextExecutionTime(row: RuleSceneApi.SceneRule): Date | null {
: null;
}
/** 基于当前页列表刷新统计数据 */
function updateStatistics(rows: RuleSceneApi.SceneRule[]) {
/** 刷新规则统计卡片数据 */
function updateStatistics(rows: RuleSceneApi.SceneRule[], total?: number) {
statistics.value = {
total: rows.length,
total: total ?? rows.length,
enabled: rows.filter((item) => item.status === CommonStatusEnum.ENABLE)
.length,
disabled: rows.filter((item) => item.status === CommonStatusEnum.DISABLE)
@ -214,7 +214,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
pageSize: page.pageSize,
...formValues,
});
updateStatistics(result.list || []);
updateStatistics(result.list || [], result.total);
return result;
},
},

View File

@ -237,7 +237,11 @@ function removeDataSpecs(val: any) {
label="标识符"
prop="identifier"
>
<ElInput v-model="formData.identifier" placeholder="请输入标识符" />
<ElInput
v-model="formData.identifier"
:disabled="formData.id != null"
placeholder="请输入标识符"
/>
</ElFormItem>
<!-- 属性配置 -->
<ThingModelProperty

View File

@ -97,3 +97,22 @@ export const setCurrentTimezone = (timezone?: string) => {
export const getCurrentTimezone = () => {
return currentTimezone;
};
/**
* antd TimePicker / DatePicker `@update:value`
*
* antd `value-format`
* `@update:value` `Dayjs`
*
* - null / undefined / '' / 0 ''
* - `value-format`
* - Dayjs `.format()` ISO
*/
export function formatDayjs(
value: dayjs.Dayjs | null | string | undefined,
): string {
if (!value) {
return '';
}
return typeof value === 'string' ? value : value.format();
}

View File

@ -831,6 +831,9 @@ importers:
tinymce:
specifier: 'catalog:'
version: 7.9.2
tyme4ts:
specifier: ^1.5.0
version: 1.5.0
video.js:
specifier: 'catalog:'
version: 7.21.7
@ -1083,6 +1086,9 @@ importers:
tinymce:
specifier: 'catalog:'
version: 7.9.2
tyme4ts:
specifier: ^1.5.0
version: 1.5.0
video.js:
specifier: 'catalog:'
version: 7.21.7
@ -11536,6 +11542,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tyme4ts@1.5.0:
resolution: {integrity: sha512-SqmlNyDtYb3bsnSkjX8lxyMcCt9xBaBkF8xWs/2ORiysWXftEoTGHEi/zxWEJ8s7aANe5veMcLcOuMK6Z+kzbw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -22397,6 +22406,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tyme4ts@1.5.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

View File

@ -194,6 +194,7 @@ catalog:
tsdown: ^0.21.7
turbo: ^2.9.6
tw-animate-css: ^1.4.0
tyme4ts: ^1.5.0
typescript: ^6.0.2
unplugin-dts: ^1.0.0-beta.6
unplugin-element-plus: ^0.11.2