fix(iot): 修复 IoT 复评后续对齐问题

- 补齐设备详情子设备、Modbus 操作权限
- 修复属性搜索清空、模拟器空参数、告警处理备注校验
- 修复 HTTP 数据目的 URL 回显、Redis Stream 密码必填
- 优化固件上传读取时机,补充 isEmptyVal 并复用 JSON 参数校验
- 修正场景产品状态字典和 antd ValueInput 图标导入
pull/348/head
YunaiV 2026-05-25 00:43:50 +08:00
parent ab697925cf
commit d2763dc044
27 changed files with 178 additions and 98 deletions

View File

@ -32,6 +32,7 @@ const props = withDefaults(defineProps<FileUploadProps>(), {
multiple: false,
api: undefined,
resultField: '',
returnText: false,
showDescription: false,
});
const emit = defineEmits([
@ -147,9 +148,6 @@ function handleUploadError(error: any) {
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
//
if (fileList.value!.length >= props.maxNumber) {
message.error($t('ui.upload.maxNumber', [props.maxNumber]));
@ -176,6 +174,10 @@ async function beforeUpload(file: File) {
//
uploadNumber.value++;
if (props.returnText) {
const fileContent = await file.text();
emit('returnText', fileContent);
}
return true;
}

View File

@ -58,6 +58,7 @@ const textareaProps = computed(() => {
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
returnText: true,
};
});
</script>

View File

@ -27,6 +27,7 @@ export interface FileUploadProps {
maxSize?: number; // 文件最大多少MB
multiple?: boolean; // 是否支持多选
resultField?: string; // support xxx.xxx.xx
returnText?: boolean; // 是否返回文件文本内容
showDescription?: boolean; // 是否显示下面的描述
value?: string | string[];
}

View File

@ -45,10 +45,6 @@ function handleProcess(row: AlertRecordApi.AlertRecord) {
}),
]),
async onOk() {
if (!processRemark.value) {
message.warning('请输入处理原因');
throw new Error('请输入处理原因');
}
const hideLoading = message.loading({
content: '正在处理...',
duration: 0,

View File

@ -14,7 +14,7 @@ import { computed, h, onMounted, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
import { Button, message } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getModbusConfig } from '#/api/iot/device/modbus/config';
@ -307,7 +307,16 @@ onMounted(async () => {
<!-- 连接配置区域 -->
<ConfigDescriptions :data="modbusConfig" class="mb-4">
<template #extra>
<Button type="primary" @click="handleEditConfig"></Button>
<TableAction
:actions="[
{
label: '编辑',
type: 'primary',
auth: ['iot:device:create'],
onClick: handleEditConfig,
},
]"
/>
</template>
</ConfigDescriptions>
@ -320,6 +329,7 @@ onMounted(async () => {
label: '新增点位',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:create'],
onClick: handleAddPoint,
},
]"
@ -331,12 +341,14 @@ onMounted(async () => {
{
label: '编辑',
type: 'link',
auth: ['iot:device:update'],
onClick: () => handleEditPoint(row),
},
{
label: '删除',
type: 'link',
danger: true,
auth: ['iot:device:delete'],
popConfirm: {
title: `确定要删除点位【${row.name}】吗?`,
confirm: () => handleDeletePoint(row),

View File

@ -312,13 +312,15 @@ async function handleEventPost(row: ThingModelApi.ThingModel) {
const valueStr = formData.value[row.identifier!];
let eventValue: any;
if (valueStr) {
try {
eventValue = JSON.parse(valueStr);
} catch {
message.error('事件参数格式错误请输入有效的JSON格式');
return;
}
if (valueStr === undefined || valueStr === null || valueStr === '') {
message.warning('请输入事件参数');
return;
}
try {
eventValue = JSON.parse(valueStr);
} catch {
message.error('事件参数格式错误请输入有效的JSON格式');
return;
}
// IotDeviceEventPostReqDTO { identifier, value, time }
@ -399,21 +401,23 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
const valueStr = formData.value[row.identifier!];
let inputParams: any = {};
if (valueStr) {
try {
inputParams = JSON.parse(valueStr);
} catch {
message.error('服务参数格式错误请输入有效的JSON格式');
return;
}
if (
typeof inputParams !== 'object' ||
inputParams === null ||
Array.isArray(inputParams)
) {
message.error('服务参数必须是 JSON 对象');
return;
}
if (valueStr === undefined || valueStr === null || valueStr === '') {
message.warning('请输入服务参数');
return;
}
try {
inputParams = JSON.parse(valueStr);
} catch {
message.error('服务参数格式错误请输入有效的JSON格式');
return;
}
if (
typeof inputParams !== 'object' ||
inputParams === null ||
Array.isArray(inputParams)
) {
message.error('服务参数必须是 JSON 对象');
return;
}
// IotDeviceServiceInvokeReqDTO { identifier, inputParams }

View File

@ -317,6 +317,7 @@ watch(
label: '添加子设备',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:update'],
onClick: openAddModal,
},
{
@ -324,6 +325,7 @@ watch(
type: 'primary',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:device:update'],
disabled: isEmpty(checkedIds),
onClick: handleUnbindBatch,
},
@ -342,6 +344,7 @@ watch(
label: '解绑',
type: 'link',
danger: true,
auth: ['iot:device:update'],
onClick: () => handleUnbind(row),
},
]"

View File

@ -207,6 +207,13 @@ function handleQuery() {
}
}
/** 搜索关键词变化 */
function handleKeywordChange(event: Event) {
if (!(event.target as HTMLInputElement).value) {
handleQuery();
}
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
@ -281,6 +288,7 @@ onBeforeUnmount(() => {
allow-clear
placeholder="请输入属性名称、标识符"
style="width: 240px"
@change="handleKeywordChange"
@press-enter="handleQuery"
/>
<Switch

View File

@ -13,7 +13,11 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import { getProductName, useGridColumns, useGridFormSchema } from './data';
import {
getProductName,
useGridColumns,
useGridFormSchema,
} from './data';
import OtaFirmwareForm from './modules/form.vue';
const { push } = useRouter();
@ -116,7 +120,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 所属产品列点击跳产品详情 -->
<template #productName="{ row }">
<a
v-if="row.productId"
v-if="row.productId && getProductName(row.productId) !== '-'"
class="cursor-pointer text-primary hover:underline"
@click="handleOpenProductDetail(row.productId)"
>

View File

@ -21,21 +21,31 @@ const fullUrl = computed(() =>
urlPath.value ? urlPrefix.value + urlPath.value : '',
);
function syncUrlFields(url?: string) {
if (url?.startsWith('https://')) {
urlPrefix.value = 'https://';
urlPath.value = url.slice(8);
} else if (url?.startsWith('http://')) {
urlPrefix.value = 'http://';
urlPath.value = url.slice(7);
} else {
urlPath.value = url ?? '';
}
}
watch([urlPrefix, urlPath], () => {
config.value.url = fullUrl.value;
});
watch(
() => config.value?.url,
(url) => syncUrlFields(url),
{ immediate: true },
);
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.url?.startsWith('https://')) {
urlPrefix.value = 'https://';
urlPath.value = config.value.url.slice(8);
} else if (config.value.url?.startsWith('http://')) {
urlPrefix.value = 'http://';
urlPath.value = config.value.url.slice(7);
} else {
urlPath.value = config.value.url ?? '';
}
syncUrlFields(config.value.url);
return;
}
config.value = {

View File

@ -76,7 +76,11 @@ onMounted(() => {
class="w-full"
/>
</Form.Item>
<Form.Item :name="['config', 'password']" label="密码">
<Form.Item
:name="['config', 'password']"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<Input.Password v-model:value="config.password" placeholder="请输入密码" />
</Form.Item>
<Form.Item

View File

@ -12,6 +12,7 @@ import {
JsonParamsInputTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { isEmptyVal } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Input, Popover, Tag } from 'ant-design-vue';
@ -242,10 +243,8 @@ function handleParamsChange() {
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
const value = parsed[param.identifier];
if (param.required && isEmptyVal(value)) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);

View File

@ -6,6 +6,7 @@ import {
IoTDataSpecsDataTypeEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import {

View File

@ -79,7 +79,7 @@ onMounted(() => {
{{ product.productKey }}
</div>
</div>
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
<DictTag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
</div>
</Select.Option>
</Select>

View File

@ -35,6 +35,7 @@ const props = withDefaults(defineProps<FileUploadProps>(), {
multiple: false,
api: undefined,
resultField: '',
returnText: false,
showDescription: false,
});
const emit = defineEmits([
@ -163,9 +164,6 @@ function handleUploadError(error: any) {
*/
/* eslint-disable unicorn/no-nested-ternary */
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
// 使 getValue
const currentFiles = getValue();
const currentCount = Array.isArray(currentFiles)
@ -198,6 +196,10 @@ async function beforeUpload(file: File) {
//
uploadNumber.value++;
if (props.returnText) {
const fileContent = await file.text();
emit('returnText', fileContent);
}
return true;
}

View File

@ -58,6 +58,7 @@ const textareaProps = computed(() => {
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
returnText: true,
};
});
</script>

View File

@ -27,6 +27,7 @@ export interface FileUploadProps {
maxSize?: number; // 文件最大多少 MB
multiple?: boolean; // 是否支持多选
resultField?: string; // support xxx.xxx.xx
returnText?: boolean; // 是否返回文件文本内容
showDescription?: boolean; // 是否显示下面的描述
value?: string | string[];
}

View File

@ -44,12 +44,6 @@ async function handleProcess(row: AlertRecordApi.AlertRecord) {
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请输入处理原因',
inputValidator: (value: string) => {
if (!value) {
return '请输入处理原因';
}
return true;
},
},
);
const loadingInstance = ElLoading.service({ text: '正在处理...' });

View File

@ -14,7 +14,7 @@ import { computed, h, onMounted, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
import { ElButton, ElMessage } from 'element-plus';
import { ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getModbusConfig } from '#/api/iot/device/modbus/config';
@ -307,7 +307,16 @@ onMounted(async () => {
<!-- 连接配置区域 -->
<ConfigDescriptions :data="modbusConfig" class="mb-4">
<template #extra>
<ElButton type="primary" @click="handleEditConfig"></ElButton>
<TableAction
:actions="[
{
label: '编辑',
type: 'primary',
auth: ['iot:device:create'],
onClick: handleEditConfig,
},
]"
/>
</template>
</ConfigDescriptions>
@ -320,6 +329,7 @@ onMounted(async () => {
label: '新增点位',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:create'],
onClick: handleAddPoint,
},
]"
@ -332,12 +342,14 @@ onMounted(async () => {
label: '编辑',
type: 'primary',
link: true,
auth: ['iot:device:update'],
onClick: () => handleEditPoint(row),
},
{
label: '删除',
type: 'danger',
link: true,
auth: ['iot:device:delete'],
popConfirm: {
title: `确定要删除点位【${row.name}】吗?`,
confirm: () => handleDeletePoint(row),

View File

@ -202,13 +202,15 @@ async function handleEventPost(row: ThingModelApi.ThingModel) {
const valueStr = formData.value[row.identifier!];
let eventValue: any;
if (valueStr) {
try {
eventValue = JSON.parse(valueStr);
} catch {
ElMessage.error('事件参数格式错误请输入有效的JSON格式');
return;
}
if (valueStr === undefined || valueStr === null || valueStr === '') {
ElMessage.warning('请输入事件参数');
return;
}
try {
eventValue = JSON.parse(valueStr);
} catch {
ElMessage.error('事件参数格式错误请输入有效的JSON格式');
return;
}
// IotDeviceEventPostReqDTO { identifier, value, time }
@ -286,21 +288,23 @@ async function handleServiceInvoke(row: ThingModelApi.ThingModel) {
const valueStr = formData.value[row.identifier!];
let inputParams: any = {};
if (valueStr) {
try {
inputParams = JSON.parse(valueStr);
} catch {
ElMessage.error('服务参数格式错误请输入有效的JSON格式');
return;
}
if (
typeof inputParams !== 'object' ||
inputParams === null ||
Array.isArray(inputParams)
) {
ElMessage.error('服务参数必须是 JSON 对象');
return;
}
if (valueStr === undefined || valueStr === null || valueStr === '') {
ElMessage.warning('请输入服务参数');
return;
}
try {
inputParams = JSON.parse(valueStr);
} catch {
ElMessage.error('服务参数格式错误请输入有效的JSON格式');
return;
}
if (
typeof inputParams !== 'object' ||
inputParams === null ||
Array.isArray(inputParams)
) {
ElMessage.error('服务参数必须是 JSON 对象');
return;
}
// IotDeviceServiceInvokeReqDTO { identifier, inputParams }

View File

@ -315,12 +315,14 @@ watch(
label: '添加子设备',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:update'],
onClick: openAddModal,
},
{
label: '批量解绑',
type: 'danger',
icon: ACTION_ICON.DELETE,
auth: ['iot:device:update'],
disabled: isEmpty(checkedIds),
onClick: handleUnbindBatch,
},
@ -340,6 +342,7 @@ watch(
label: '解绑',
type: 'danger',
link: true,
auth: ['iot:device:update'],
onClick: () => handleUnbind(row),
},
]"

View File

@ -282,6 +282,7 @@ onBeforeUnmount(() => {
clearable
placeholder="请输入属性名称、标识符"
style="width: 240px"
@clear="handleQuery"
@keyup.enter="handleQuery"
/>
<ElSwitch

View File

@ -13,7 +13,11 @@ import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import { getProductName, useGridColumns, useGridFormSchema } from './data';
import {
getProductName,
useGridColumns,
useGridFormSchema,
} from './data';
import OtaFirmwareForm from './modules/form.vue';
const { push } = useRouter();
@ -113,7 +117,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 所属产品列点击跳产品详情 -->
<template #productName="{ row }">
<a
v-if="row.productId"
v-if="row.productId && getProductName(row.productId) !== '-'"
class="cursor-pointer text-[var(--el-color-primary)] hover:underline"
@click="handleOpenProductDetail(row.productId)"
>

View File

@ -21,21 +21,31 @@ const fullUrl = computed(() =>
urlPath.value ? urlPrefix.value + urlPath.value : '',
);
function syncUrlFields(url?: string) {
if (url?.startsWith('https://')) {
urlPrefix.value = 'https://';
urlPath.value = url.slice(8);
} else if (url?.startsWith('http://')) {
urlPrefix.value = 'http://';
urlPath.value = url.slice(7);
} else {
urlPath.value = url ?? '';
}
}
watch([urlPrefix, urlPath], () => {
config.value.url = fullUrl.value;
});
watch(
() => config.value?.url,
(url) => syncUrlFields(url),
{ immediate: true },
);
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.url?.startsWith('https://')) {
urlPrefix.value = 'https://';
urlPath.value = config.value.url.slice(8);
} else if (config.value.url?.startsWith('http://')) {
urlPrefix.value = 'http://';
urlPath.value = config.value.url.slice(7);
} else {
urlPath.value = config.value.url ?? '';
}
syncUrlFields(config.value.url);
return;
}
config.value = {

View File

@ -82,7 +82,11 @@ onMounted(() => {
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.password" label="密码">
<ElFormItem
prop="config.password"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<ElInput
v-model="config.password"
type="password"

View File

@ -12,6 +12,7 @@ import {
JsonParamsInputTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { isEmptyVal } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElButton, ElInput, ElPopover, ElTag } from 'element-plus';
@ -242,10 +243,8 @@ function handleParamsChange() {
//
for (const param of paramsList.value) {
if (
param.required &&
(!parsed[param.identifier] || parsed[param.identifier] === '')
) {
const value = parsed[param.identifier];
if (param.required && isEmptyVal(value)) {
jsonError.value = JSON_PARAMS_INPUT_CONSTANTS.PARAM_REQUIRED_ERROR(
param.name,
);

View File

@ -80,7 +80,7 @@ onMounted(() => {
{{ product.productKey }}
</div>
</div>
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
<DictTag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
</div>
</ElOption>
</ElSelect>