fix:【iot 物联网】linter 报错

This commit is contained in:
YunaiV
2025-10-10 20:26:17 +08:00
parent b6fee5c05b
commit f740461c2a
107 changed files with 7161 additions and 5905 deletions

View File

@@ -6,7 +6,10 @@ import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { DeviceTypeEnum, getSimpleProductList } from '#/api/iot/product/product';
import {
DeviceTypeEnum,
getSimpleProductList,
} from '#/api/iot/product/product';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -43,7 +46,7 @@ export function useFormSchema(): VbenFormSchema[] {
.min(4, 'DeviceName 长度不能少于 4 个字符')
.max(32, 'DeviceName 长度不能超过 32 个字符')
.regex(
/^[a-zA-Z0-9_.\-:@]{4,32}$/,
/^[\w.\-:@]{4,32}$/,
'支持英文字母、数字、下划线_、中划线-)、点号(.)、半角冒号(:)和特殊字符@',
),
},
@@ -79,7 +82,7 @@ export function useFormSchema(): VbenFormSchema[] {
.min(4, '备注名称长度限制为 4~64 个字符')
.max(64, '备注名称长度限制为 4~64 个字符')
.regex(
/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/,
/^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/,
'备注名称只能包含中文、英文字母、日文、数字和下划线_',
)
.optional()
@@ -106,7 +109,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: z
.string()
.regex(/^[a-zA-Z0-9-_]+$/, '序列号只能包含字母、数字、中划线和下划线')
.regex(/^[\w-]+$/, '序列号只能包含字母、数字、中划线和下划线')
.optional()
.or(z.literal('')),
},
@@ -318,4 +321,3 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}

View File

@@ -4,30 +4,39 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Button, Card, Input, message, Select, Space, Tag } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
Button,
Card,
Input,
message,
Select,
Space,
Tag,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
import {
deleteDevice,
deleteDeviceList,
exportDeviceExcel,
getDevicePage
getDevicePage,
} from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useGridColumns } from './data';
// @ts-ignore
import DeviceCardView from './modules/DeviceCardView.vue';
import DeviceForm from './modules/DeviceForm.vue';
import DeviceGroupForm from './modules/DeviceGroupForm.vue';
import DeviceImportForm from './modules/DeviceImportForm.vue';
// @ts-ignore
import DeviceCardView from './modules/DeviceCardView.vue';
import { useGridColumns } from './data';
/** IoT 设备列表 */
defineOptions({ name: 'IoTDevice' });
@@ -36,7 +45,7 @@ const route = useRoute();
const router = useRouter();
const products = ref<any[]>([]);
const deviceGroups = ref<any[]>([]);
const viewMode = ref<'list' | 'card'>('card');
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
// Modal instances
@@ -120,7 +129,11 @@ function openProductDetail(productId: number) {
/** 打开物模型数据 */
function openModel(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id }, query: { tab: 'model' } });
router.push({
name: 'IoTDeviceDetail',
params: { id },
query: { tab: 'model' },
});
}
/** 新增设备 */
@@ -219,7 +232,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions,
});
/** 初始化 **/
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
@@ -241,11 +254,11 @@ onMounted(async () => {
<DeviceFormModal @success="handleRefresh" />
<DeviceGroupFormModal @success="handleRefresh" />
<DeviceImportFormModal @success="handleRefresh" />
<!-- 统一搜索工具栏 -->
<Card :body-style="{ padding: '16px' }" class="mb-4">
<!-- 搜索表单 -->
<div class="flex flex-wrap items-center gap-3 mb-3">
<div class="mb-3 flex flex-wrap items-center gap-3">
<Select
v-model:value="searchParams.productId"
placeholder="请选择产品"
@@ -265,14 +278,14 @@ onMounted(async () => {
placeholder="请输入 DeviceName"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchParams.nickname"
placeholder="请输入备注名称"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
/>
<Select
v-model:value="searchParams.deviceType"
@@ -329,11 +342,19 @@ onMounted(async () => {
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<Space :size="12">
<Button type="primary" @click="handleCreate" v-hasPermi="['iot:device:create']">
<Button
type="primary"
@click="handleCreate"
v-hasPermi="['iot:device:create']"
>
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
新增
</Button>
<Button type="primary" @click="handleExport" v-hasPermi="['iot:device:export']">
<Button
type="primary"
@click="handleExport"
v-hasPermi="['iot:device:export']"
>
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
导出
</Button>
@@ -359,7 +380,7 @@ onMounted(async () => {
批量删除
</Button>
</Space>
<!-- 视图切换 -->
<Space :size="4">
<Button
@@ -385,7 +406,10 @@ onMounted(async () => {
<!-- 所属产品列 -->
<template #product="{ row }">
<a class="cursor-pointer text-primary" @click="openProductDetail(row.productId)">
<a
class="text-primary cursor-pointer"
@click="openProductDetail(row.productId)"
>
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
</a>
</template>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
@@ -11,38 +15,35 @@ import {
Row,
Tag,
} from 'ant-design-vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
defineOptions({ name: 'DeviceCardView' });
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [id: number];
edit: [row: any];
model: [id: number];
productDetail: [productId: number];
}>();
interface Props {
products: any[];
deviceGroups: any[];
searchParams?: {
deviceName: string;
deviceType?: number;
groupId?: number;
nickname: string;
productId?: number;
deviceType?: number;
status?: number;
groupId?: number;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
edit: [row: any];
delete: [row: any];
detail: [id: number];
model: [id: number];
productDetail: [productId: number];
}>();
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
@@ -111,7 +112,7 @@ onMounted(() => {
});
// 暴露方法供父组件调用
defineExpose({
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
@@ -145,7 +146,7 @@ defineExpose({
<div class="device-icon">
<IconifyIcon icon="mdi:chip" />
</div>
<div
<div
class="status-badge"
:style="{
color: getStatusInfo(item.state).color,
@@ -167,17 +168,30 @@ defineExpose({
<div class="info-section">
<div class="info-item">
<span class="label">所属产品</span>
<a
<a
class="value link"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('productDetail', item.productId); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('productDetail', item.productId);
}
"
>
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="label">设备类型</span>
<Tag :color="getDeviceTypeColor(item.deviceType)" size="small">
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
<Tag
:color="getDeviceTypeColor(item.deviceType)"
size="small"
>
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div>
<div class="info-item">
@@ -190,29 +204,44 @@ defineExpose({
<!-- 操作按钮 -->
<div class="action-bar">
<Button
<Button
type="default"
size="small"
class="action-btn btn-edit"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('edit', item); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('edit', item);
}
"
>
<IconifyIcon icon="ph:note-pencil" />
编辑
</Button>
<Button
<Button
type="default"
size="small"
class="action-btn btn-view"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('detail', item.id); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('detail', item.id);
}
"
>
<IconifyIcon icon="ph:eye" />
详情
</Button>
<Button
<Button
type="default"
size="small"
class="action-btn btn-data"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('model', item.id); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('model', item.id);
}
"
>
<IconifyIcon icon="ph:database" />
数据
@@ -221,7 +250,7 @@ defineExpose({
title="确认删除该设备吗?"
@confirm="() => emit('delete', item)"
>
<Button
<Button
type="default"
size="small"
class="action-btn btn-delete"
@@ -262,13 +291,19 @@ defineExpose({
height: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
box-shadow:
0 1px 2px 0 rgba(0, 0, 0, 0.03),
0 1px 6px -1px rgba(0, 0, 0, 0.02),
0 2px 4px 0 rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid #f0f0f0;
background: #fff;
&:hover {
box-shadow: 0 1px 2px -2px rgba(0, 0, 0, 0.16), 0 3px 6px 0 rgba(0, 0, 0, 0.12), 0 5px 12px 4px rgba(0, 0, 0, 0.09);
box-shadow:
0 1px 2px -2px rgba(0, 0, 0, 0.16),
0 3px 6px 0 rgba(0, 0, 0, 0.12),
0 5px 12px 4px rgba(0, 0, 0, 0.09);
transform: translateY(-4px);
border-color: #e6e6e6;
}
@@ -379,7 +414,9 @@ defineExpose({
}
&.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
font-family:
'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas',
monospace;
font-size: 12px;
color: #595959;
font-weight: 500;

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import {
createDevice,
getDevice,
updateDevice,
type IotDeviceApi
} from '#/api/iot/device/device';
import { message } from 'ant-design-vue';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useFormSchema } from '../data';

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { updateDeviceGroup } from '#/api/iot/device/device';
import { $t } from '#/locales';

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { importDeviceTemplate } from '#/api/iot/device/device';
import { downloadFileFromBlobPart } from '@vben/utils';
import { useImportFormSchema } from '../data';
@@ -31,22 +32,22 @@ const [Modal, modalApi] = useVbenModal({
if (!valid) {
return;
}
const values = await formApi.getValues();
const file = values.file;
if (!file || !file.length) {
if (!file || file.length === 0) {
message.error('请上传文件');
return;
}
modalApi.lock();
try {
// 构建表单数据
const formData = new FormData();
formData.append('file', file[0].originFileObj);
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
// 使用 fetch 上传文件
const accessToken = localStorage.getItem('accessToken') || '';
const response = await fetch(
@@ -57,21 +58,21 @@ const [Modal, modalApi] = useVbenModal({
Authorization: `Bearer ${accessToken}`,
},
body: formData,
}
},
);
const result = await response.json();
if (result.code !== 0) {
message.error(result.msg || '导入失败');
return;
}
// 拼接提示语
const data = result.data;
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
if (data.createDeviceNames) {
for (let deviceName of data.createDeviceNames) {
for (const deviceName of data.createDeviceNames) {
text += `< ${deviceName} >`;
}
}
@@ -88,7 +89,7 @@ const [Modal, modalApi] = useVbenModal({
}
}
message.info(text);
// 关闭并提示
await modalApi.close();
emit('success');

View File

@@ -1,9 +1,222 @@
<!-- IoT 设备选择使用弹窗展示 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, onMounted, reactive, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
import { getDevicePage } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
defineOptions({ name: 'IoTDeviceTableSelect' });
const props = defineProps({
multiple: {
type: Boolean,
default: false,
},
productId: {
type: Number,
default: null,
},
});
/** 提交表单 */
const emit = defineEmits(['success']);
// 获取字典选项
const getIntDictOptions = (dictType: string) => {
return getDictOptions(dictType, 'number');
};
// 日期格式化
const dateFormatter = (_row: any, _column: any, cellValue: any) => {
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : '';
};
const dialogVisible = ref(false);
const dialogTitle = ref('设备选择器');
const formLoading = ref(false);
const loading = ref(true); // 列表的加载中
const list = ref<IotDeviceApi.Device[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const selectedDevices = ref<IotDeviceApi.Device[]>([]); // 选中的设备列表
const selectedId = ref<number>(); // 单选模式下选中的ID
const products = ref<IotProductApi.Product[]>([]); // 产品列表
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]); // 设备分组列表
const selectedRowKeys = ref<number[]>([]); // 多选模式下选中的keys
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined as string | undefined,
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
nickname: undefined as string | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined,
});
const queryFormRef = ref(); // 搜索的表单
// 表格列定义
const columns = computed(() => {
const baseColumns = [
{
title: 'DeviceName',
dataIndex: 'deviceName',
key: 'deviceName',
align: 'center',
},
{
title: '备注名称',
dataIndex: 'nickname',
key: 'nickname',
align: 'center',
},
{
title: '所属产品',
key: 'productId',
align: 'center',
},
{
title: '设备类型',
key: 'deviceType',
align: 'center',
},
{
title: '所属分组',
key: 'groupIds',
align: 'center',
},
{
title: '设备状态',
key: 'status',
align: 'center',
},
{
title: '最后上线时间',
key: 'onlineTime',
align: 'center',
width: 180,
},
];
// 单选模式添加单选列
if (!props.multiple) {
baseColumns.unshift({
title: '',
key: 'radio',
width: 55,
align: 'center',
} as any);
}
return baseColumns;
});
// 多选配置
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[], rows: IotDeviceApi.Device[]) => {
selectedRowKeys.value = keys;
selectedDevices.value = rows;
},
}));
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (props.productId) {
queryParams.productId = props.productId;
}
const data = await getDevicePage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
/** 打开弹窗 */
const open = async () => {
dialogVisible.value = true;
// 重置选择状态
selectedDevices.value = [];
selectedId.value = undefined;
selectedRowKeys.value = [];
if (!props.productId) {
// 获取产品列表
products.value = await getSimpleProductList();
}
// 获取设备列表
await getList();
};
defineExpose({ open });
/** 处理行点击事件 */
const tableRef = ref();
const handleRowClick = (row: IotDeviceApi.Device) => {
if (!props.multiple) {
selectedId.value = row.id;
selectedDevices.value = [row];
}
};
/** 处理单选变更事件 */
const handleRadioChange = (row: IotDeviceApi.Device) => {
selectedId.value = row.id;
selectedDevices.value = [row];
};
const submitForm = async () => {
if (selectedDevices.value.length === 0) {
message.warning({
content: props.multiple ? '请至少选择一个设备' : '请选择一个设备',
});
return;
}
emit(
'success',
props.multiple ? selectedDevices.value : selectedDevices.value[0],
);
dialogVisible.value = false;
};
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList();
});
</script>
<template>
<a-modal
:title="dialogTitle"
v-model:open="dialogVisible"
width="60%"
<a-modal
:title="dialogTitle"
v-model:open="dialogVisible"
width="60%"
:footer="null"
>
<ContentWrap>
@@ -35,7 +248,7 @@
v-model:value="queryParams.deviceName"
placeholder="请输入 DeviceName"
allow-clear
@pressEnter="handleQuery"
@press-enter="handleQuery"
style="width: 240px"
/>
</a-form-item>
@@ -44,7 +257,7 @@
v-model:value="queryParams.nickname"
placeholder="请输入备注名称"
allow-clear
@pressEnter="handleQuery"
@press-enter="handleQuery"
style="width: 240px"
/>
</a-form-item>
@@ -56,7 +269,9 @@
style="width: 240px"
>
<a-select-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
v-for="dict in getIntDictOptions(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
)"
:key="dict.value"
:value="dict.value"
>
@@ -114,7 +329,7 @@
<a-table
ref="tableRef"
:loading="loading"
:dataSource="list"
:data-source="list"
:columns="columns"
:pagination="false"
:row-selection="multiple ? rowSelection : undefined"
@@ -132,17 +347,28 @@
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
</template>
<template v-else-if="column.key === 'deviceType'">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="record.deviceType" />
<dict-tag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="record.deviceType"
/>
</template>
<template v-else-if="column.key === 'groupIds'">
<template v-if="record.groupIds?.length">
<a-tag v-for="id in record.groupIds" :key="id" class="ml-5px" size="small">
<a-tag
v-for="id in record.groupIds"
:key="id"
class="ml-5px"
size="small"
>
{{ deviceGroups.find((g) => g.id === id)?.name }}
</a-tag>
</template>
</template>
<template v-else-if="column.key === 'status'">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="record.status" />
<dict-tag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:value="record.status"
/>
</template>
<template v-else-if="column.key === 'onlineTime'">
{{ dateFormatter(null, null, record.onlineTime) }}
@@ -160,211 +386,10 @@
</ContentWrap>
<template #footer>
<a-button @click="submitForm" type="primary" :disabled="formLoading">确 定</a-button>
<a-button @click="submitForm" type="primary" :disabled="formLoading">
确 定
</a-button>
<a-button @click="dialogVisible = false"> </a-button>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import { formatDate } from '@vben/utils'
import type { IotDeviceApi } from '#/api/iot/device/device'
import { getDevicePage } from '#/api/iot/device/device'
import type { IotProductApi } from '#/api/iot/product/product'
import { getSimpleProductList } from '#/api/iot/product/product'
import type { IotDeviceGroupApi } from '#/api/iot/device/group'
import { getSimpleDeviceGroupList } from '#/api/iot/device/group'
import { message } from 'ant-design-vue'
defineOptions({ name: 'IoTDeviceTableSelect' })
const props = defineProps({
multiple: {
type: Boolean,
default: false
},
productId: {
type: Number,
default: null
}
})
// 获取字典选项
const getIntDictOptions = (dictType: string) => {
return getDictOptions(dictType, 'number')
}
// 日期格式化
const dateFormatter = (_row: any, _column: any, cellValue: any) => {
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : ''
}
const dialogVisible = ref(false)
const dialogTitle = ref('设备选择器')
const formLoading = ref(false)
const loading = ref(true) // 列表的加载中
const list = ref<IotDeviceApi.Device[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const selectedDevices = ref<IotDeviceApi.Device[]>([]) // 选中的设备列表
const selectedId = ref<number>() // 单选模式下选中的ID
const products = ref<IotProductApi.Product[]>([]) // 产品列表
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]) // 设备分组列表
const selectedRowKeys = ref<number[]>([]) // 多选模式下选中的keys
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined as string | undefined,
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
nickname: undefined as string | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined
})
const queryFormRef = ref() // 搜索的表单
// 表格列定义
const columns = computed(() => {
const baseColumns = [
{
title: 'DeviceName',
dataIndex: 'deviceName',
key: 'deviceName',
align: 'center'
},
{
title: '备注名称',
dataIndex: 'nickname',
key: 'nickname',
align: 'center'
},
{
title: '所属产品',
key: 'productId',
align: 'center'
},
{
title: '设备类型',
key: 'deviceType',
align: 'center'
},
{
title: '所属分组',
key: 'groupIds',
align: 'center'
},
{
title: '设备状态',
key: 'status',
align: 'center'
},
{
title: '最后上线时间',
key: 'onlineTime',
align: 'center',
width: 180
}
]
// 单选模式添加单选列
if (!props.multiple) {
baseColumns.unshift({
title: '',
key: 'radio',
width: 55,
align: 'center'
} as any)
}
return baseColumns
})
// 多选配置
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[], rows: IotDeviceApi.Device[]) => {
selectedRowKeys.value = keys
selectedDevices.value = rows
}
}))
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
if (props.productId) {
queryParams.productId = props.productId
}
const data = await getDevicePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开弹窗 */
const open = async () => {
dialogVisible.value = true
// 重置选择状态
selectedDevices.value = []
selectedId.value = undefined
selectedRowKeys.value = []
if (!props.productId) {
// 获取产品列表
products.value = await getSimpleProductList()
}
// 获取设备列表
await getList()
}
defineExpose({ open })
/** 处理行点击事件 */
const tableRef = ref()
const handleRowClick = (row: IotDeviceApi.Device) => {
if (!props.multiple) {
selectedId.value = row.id
selectedDevices.value = [row]
}
}
/** 处理单选变更事件 */
const handleRadioChange = (row: IotDeviceApi.Device) => {
selectedId.value = row.id
selectedDevices.value = [row]
}
/** 提交表单 */
const emit = defineEmits(['success'])
const submitForm = async () => {
if (selectedDevices.value.length === 0) {
message.warning({ content: props.multiple ? '请至少选择一个设备' : '请选择一个设备' })
return
}
emit('success', props.multiple ? selectedDevices.value : selectedDevices.value[0])
dialogVisible.value = false
}
/** 初始化 **/
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList()
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList()
})
</script>

View File

@@ -1,4 +1,118 @@
<!-- 设备配置 -->
<script lang="ts" setup>
import type { DeviceVO } from '#/api/iot/device/device';
import { ref, watchEffect } from 'vue';
import { message } from 'ant-design-vue';
import { DeviceApi } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceDetailConfig' });
const props = defineProps<{
device: DeviceVO;
}>();
const emit = defineEmits<{
(e: 'success'): void; // 定义 success 事件,不需要参数
}>();
const loading = ref(false); // 加载中
const pushLoading = ref(false); // 推送加载中
const config = ref<any>({}); // 只存储 config 字段
const hasJsonError = ref(false); // 是否有 JSON 格式错误
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
} catch {
config.value = {};
}
});
const isEditing = ref(false); // 编辑状态
/** 启用编辑模式的函数 */
const enableEdit = () => {
isEditing.value = true;
hasJsonError.value = false; // 重置错误状态
};
/** 取消编辑的函数 */
const cancelEdit = () => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
} catch {
config.value = {};
}
isEditing.value = false;
hasJsonError.value = false; // 重置错误状态
};
/** 保存配置的函数 */
const saveConfig = async () => {
if (hasJsonError.value) {
message.error({ content: 'JSON格式错误请修正后再提交' });
return;
}
await updateDeviceConfig();
isEditing.value = false;
};
/** 配置推送处理函数 */
const handleConfigPush = async () => {
try {
pushLoading.value = true;
// 调用配置推送接口
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value,
});
message.success({ content: '配置推送成功!' });
} catch (error) {
if (error !== 'cancel') {
message.error({ content: '配置推送失败!' });
console.error('配置推送错误:', error);
}
} finally {
pushLoading.value = false;
}
};
/** 更新设备配置 */
const updateDeviceConfig = async () => {
try {
// 提交请求
loading.value = true;
await DeviceApi.updateDevice({
id: props.device.id,
config: JSON.stringify(config.value),
} as DeviceVO);
message.success({ content: '更新成功!' });
// 触发 success 事件
emit('success');
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
/** 处理 JSON 编辑器错误的函数 */
const onError = (errors: any) => {
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
hasJsonError.value = false;
return;
}
hasJsonError.value = true;
};
</script>
<template>
<div>
<a-alert
@@ -16,125 +130,23 @@
/>
<div class="mt-5 text-center">
<a-button v-if="isEditing" @click="cancelEdit">取消</a-button>
<a-button v-if="isEditing" type="primary" @click="saveConfig" :disabled="hasJsonError">
<a-button
v-if="isEditing"
type="primary"
@click="saveConfig"
:disabled="hasJsonError"
>
保存
</a-button>
<a-button v-else @click="enableEdit">编辑</a-button>
<a-button v-if="!isEditing" type="primary" @click="handleConfigPush" :loading="pushLoading">
<a-button
v-if="!isEditing"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</a-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watchEffect } from 'vue'
import { message } from 'ant-design-vue'
import { DeviceApi } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
defineOptions({ name: 'DeviceDetailConfig' })
const props = defineProps<{
device: DeviceVO
}>()
const emit = defineEmits<{
(e: 'success'): void // 定义 success 事件,不需要参数
}>()
const loading = ref(false) // 加载中
const pushLoading = ref(false) // 推送加载中
const config = ref<any>({}) // 只存储 config 字段
const hasJsonError = ref(false) // 是否有 JSON 格式错误
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {}
} catch (e) {
config.value = {}
}
})
const isEditing = ref(false) // 编辑状态
/** 启用编辑模式的函数 */
const enableEdit = () => {
isEditing.value = true
hasJsonError.value = false // 重置错误状态
}
/** 取消编辑的函数 */
const cancelEdit = () => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {}
} catch (e) {
config.value = {}
}
isEditing.value = false
hasJsonError.value = false // 重置错误状态
}
/** 保存配置的函数 */
const saveConfig = async () => {
if (hasJsonError.value) {
message.error({ content: 'JSON格式错误请修正后再提交' })
return
}
await updateDeviceConfig()
isEditing.value = false
}
/** 配置推送处理函数 */
const handleConfigPush = async () => {
try {
pushLoading.value = true
// 调用配置推送接口
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value
})
message.success({ content: '配置推送成功!' })
} catch (error) {
if (error !== 'cancel') {
message.error({ content: '配置推送失败!' })
console.error('配置推送错误:', error)
}
} finally {
pushLoading.value = false
}
}
/** 更新设备配置 */
const updateDeviceConfig = async () => {
try {
// 提交请求
loading.value = true
await DeviceApi.updateDevice({
id: props.device.id,
config: JSON.stringify(config.value)
} as DeviceVO)
message.success({ content: '更新成功!' })
// 触发 success 事件
emit('success')
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
/** 处理 JSON 编辑器错误的函数 */
const onError = (errors: any) => {
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
hasJsonError.value = false
return
}
hasJsonError.value = true
}
</script>

View File

@@ -1,4 +1,55 @@
<!-- 设备信息头部 -->
<script setup lang="ts">
import type { DeviceVO } from '#/api/iot/device/device';
import type { ProductVO } from '#/api/iot/product/product';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import DeviceForm from '../DeviceForm.vue';
interface Props {
product: ProductVO;
device: DeviceVO;
loading?: boolean;
}
withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<{
refresh: [];
}>();
const router = useRouter();
/** 操作修改 */
const formRef = ref();
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id);
};
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string | undefined) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
} catch {
message.error({ content: '复制失败' });
}
};
/** 跳转到产品详情页面 */
const goToProductDetail = (productId: number | undefined) => {
if (productId) {
router.push({ name: 'IoTProductDetail', params: { id: productId } });
}
};
</script>
<template>
<div class="mb-4">
<div class="flex items-start justify-between">
@@ -20,13 +71,20 @@
<a-card class="mt-4">
<a-descriptions :column="1">
<a-descriptions-item label="产品">
<a @click="goToProductDetail(product.id)" class="cursor-pointer text-blue-600">
<a
@click="goToProductDetail(product.id)"
class="cursor-pointer text-blue-600"
>
{{ product.name }}
</a>
</a-descriptions-item>
<a-descriptions-item label="ProductKey">
{{ product.productKey }}
<a-button size="small" class="ml-2" @click="copyToClipboard(product.productKey)">
<a-button
size="small"
class="ml-2"
@click="copyToClipboard(product.productKey)"
>
复制
</a-button>
</a-descriptions-item>
@@ -37,51 +95,3 @@
<DeviceForm ref="formRef" @success="emit('refresh')" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import DeviceForm from '../DeviceForm.vue'
import type { ProductVO } from '#/api/iot/product/product'
import type { DeviceVO } from '#/api/iot/device/device'
interface Props {
product: ProductVO
device: DeviceVO
loading?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false,
})
const emit = defineEmits<{
refresh: []
}>()
const router = useRouter()
/** 操作修改 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string | undefined) => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
message.success({ content: '复制成功' })
} catch (error) {
message.error({ content: '复制失败' })
}
}
/** 跳转到产品详情页面 */
const goToProductDetail = (productId: number | undefined) => {
if (productId) {
router.push({ name: 'IoTProductDetail', params: { id: productId } })
}
}
</script>

View File

@@ -1,4 +1,64 @@
<!-- 设备信息 -->
<script setup lang="ts">
import type { DeviceVO, IotDeviceAuthInfoVO } from '#/api/iot/device/device';
import type { ProductVO } from '#/api/iot/product/product';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDate } from '@vben/utils';
import { message } from 'ant-design-vue';
import { DeviceApi } from '#/api/iot/device/device';
// 消息提示
const { product, device } = defineProps<{
device: DeviceVO;
product: ProductVO;
}>(); // 定义 Props
const emit = defineEmits(['refresh']); // 定义 Emits
const authDialogVisible = ref(false); // 定义设备认证信息弹框的可见性
const authPasswordVisible = ref(false); // 定义密码可见性状态
const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO); // 定义设备认证信息对象
/** 控制地图显示的标志 */
const showMap = computed(() => {
return !!(device.longitude && device.latitude);
});
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
} catch {
message.error({ content: '复制失败' });
}
};
/** 打开设备认证信息弹框的方法 */
const handleAuthInfoDialogOpen = async () => {
if (!device.id) return;
try {
authInfo.value = await DeviceApi.getDeviceAuthInfo(device.id);
// 显示设备认证信息弹框
authDialogVisible.value = true;
} catch (error) {
console.error('获取设备认证信息出错:', error);
message.error({
content: '获取设备认证信息失败,请检查网络连接或联系管理员',
});
}
};
/** 关闭设备认证信息弹框的方法 */
const handleAuthInfoDialogClose = () => {
authDialogVisible.value = false;
};
</script>
<template>
<div>
<a-row :gutter="16">
@@ -7,7 +67,7 @@
<a-card class="h-full">
<template #title>
<div class="flex items-center">
<Icon icon="ep:info-filled" class="mr-2 text-primary" />
<Icon icon="ep:info-filled" class="text-primary mr-2" />
<span>设备信息</span>
</div>
</template>
@@ -19,7 +79,10 @@
{{ product.productKey }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
<dict-tag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</a-descriptions-item>
<a-descriptions-item label="DeviceName">
{{ device.deviceName }}
@@ -28,7 +91,10 @@
{{ device.nickname || '--' }}
</a-descriptions-item>
<a-descriptions-item label="当前状态">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.state" />
<dict-tag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:value="device.state"
/>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDate(device.createTime) }}
@@ -43,7 +109,11 @@
{{ formatDate(device.offlineTime) }}
</a-descriptions-item>
<a-descriptions-item label="MQTT 连接参数">
<a-button type="link" @click="handleAuthInfoDialogOpen" size="small">
<a-button
type="link"
@click="handleAuthInfoDialogOpen"
size="small"
>
查看
</a-button>
</a-descriptions-item>
@@ -57,18 +127,21 @@
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon icon="ep:location" class="mr-2 text-primary" />
<Icon icon="ep:location" class="text-primary mr-2" />
<span>设备位置</span>
</div>
</div>
</template>
<div class="h-[500px] w-full">
<div v-if="showMap" class="h-full w-full bg-gray-100 flex items-center justify-center rounded">
<div
v-if="showMap"
class="flex h-full w-full items-center justify-center rounded bg-gray-100"
>
<span class="text-gray-400">地图组件</span>
</div>
<div
v-else
class="flex items-center justify-center h-full w-full bg-gray-50 text-gray-400 rounded"
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<Icon icon="ep:warning" class="mr-2" />
<span>暂无位置信息</span>
@@ -88,16 +161,30 @@
<a-form :label-col="{ span: 6 }">
<a-form-item label="clientId">
<a-input-group compact>
<a-input v-model:value="authInfo.clientId" readonly style="width: calc(100% - 80px)" />
<a-button @click="copyToClipboard(authInfo.clientId)" type="primary">
<a-input
v-model:value="authInfo.clientId"
readonly
style="width: calc(100% - 80px)"
/>
<a-button
@click="copyToClipboard(authInfo.clientId)"
type="primary"
>
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
</a-form-item>
<a-form-item label="username">
<a-input-group compact>
<a-input v-model:value="authInfo.username" readonly style="width: calc(100% - 80px)" />
<a-button @click="copyToClipboard(authInfo.username)" type="primary">
<a-input
v-model:value="authInfo.username"
readonly
style="width: calc(100% - 80px)"
/>
<a-button
@click="copyToClipboard(authInfo.username)"
type="primary"
>
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
@@ -110,70 +197,24 @@
:type="authPasswordVisible ? 'text' : 'password'"
style="width: calc(100% - 160px)"
/>
<a-button @click="authPasswordVisible = !authPasswordVisible" type="primary">
<a-button
@click="authPasswordVisible = !authPasswordVisible"
type="primary"
>
<Icon :icon="authPasswordVisible ? 'ph:eye-slash' : 'ph:eye'" />
</a-button>
<a-button @click="copyToClipboard(authInfo.password)" type="primary">
<a-button
@click="copyToClipboard(authInfo.password)"
type="primary"
>
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
</a-form-item>
</a-form>
<div class="text-right mt-4">
<div class="mt-4 text-right">
<a-button @click="handleAuthInfoDialogClose">关闭</a-button>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { DICT_TYPE } from '@vben/constants'
import type { ProductVO } from '#/api/iot/product/product'
import { formatDate } from '@vben/utils'
import type { DeviceVO } from '#/api/iot/device/device'
import { DeviceApi } from '#/api/iot/device/device'
import type { IotDeviceAuthInfoVO } from '#/api/iot/device/device'
// 消息提示
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
const emit = defineEmits(['refresh']) // 定义 Emits
const authDialogVisible = ref(false) // 定义设备认证信息弹框的可见性
const authPasswordVisible = ref(false) // 定义密码可见性状态
const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO) // 定义设备认证信息对象
/** 控制地图显示的标志 */
const showMap = computed(() => {
return !!(device.longitude && device.latitude)
})
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success({ content: '复制成功' })
} catch (error) {
message.error({ content: '复制失败' })
}
}
/** 打开设备认证信息弹框的方法 */
const handleAuthInfoDialogOpen = async () => {
if (!device.id) return
try {
authInfo.value = await DeviceApi.getDeviceAuthInfo(device.id)
// 显示设备认证信息弹框
authDialogVisible.value = true
} catch (error) {
console.error('获取设备认证信息出错:', error)
message.error({ content: '获取设备认证信息失败,请检查网络连接或联系管理员' })
}
}
/** 关闭设备认证信息弹框的方法 */
const handleAuthInfoDialogClose = () => {
authDialogVisible.value = false
}
</script>

View File

@@ -1,10 +1,178 @@
<!-- 设备消息列表 -->
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { formatDate } from '@vben/utils';
import { DeviceApi } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
}>();
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10,
});
// 列表数据
const loading = ref(false);
const total = ref(0);
const list = ref<any[]>([]);
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
align: 'center',
width: 180,
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
align: 'center',
width: 140,
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
align: 'center',
width: 140,
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
align: 'center',
width: 300,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
align: 'center',
width: 140,
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
align: 'center',
ellipsis: true,
},
];
/** 查询消息列表 */
const getMessageList = async () => {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await DeviceApi.getDeviceMessagePage(queryParams);
total.value = data.total;
list.value = data.list;
} finally {
loading.value = false;
}
};
/** 搜索操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getMessageList();
};
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList();
}
});
/** 刷新消息列表 */
const refresh = (delay = 0) => {
if (delay > 0) {
setTimeout(() => {
handleQuery();
}, delay);
} else {
handleQuery();
}
};
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<ContentWrap>
<!-- 搜索区域 -->
<a-form :model="queryParams" layout="inline">
<a-form-item>
<a-select v-model:value="queryParams.method" placeholder="所有方法" style="width: 160px" allow-clear>
<a-select
v-model:value="queryParams.method"
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<a-select-option
v-for="item in methodOptions"
:key="item.value"
@@ -40,7 +208,13 @@
</a-form>
<!-- 消息列表 -->
<a-table :loading="loading" :dataSource="list" :columns="columns" :pagination="false" class="whitespace-nowrap">
<a-table
:loading="loading"
:data-source="list"
:columns="columns"
:pagination="false"
class="whitespace-nowrap"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'ts'">
{{ formatDate(record.ts) }}
@@ -51,14 +225,21 @@
</a-tag>
</template>
<template v-else-if="column.key === 'reply'">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="record.reply" />
<dict-tag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="record.reply"
/>
</template>
<template v-else-if="column.key === 'method'">
{{ methodOptions.find((item) => item.value === record.method)?.label }}
{{
methodOptions.find((item) => item.value === record.method)?.label
}}
</template>
<template v-else-if="column.key === 'params'">
<span v-if="record.reply">
{{ `{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}` }}
{{
`{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}`
}}
</span>
<span v-else>{{ record.params }}</span>
</template>
@@ -76,157 +257,3 @@
</div>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { DeviceApi } from '#/api/iot/device/device'
import { formatDate } from '@vben/utils'
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
}>()
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10
})
// 列表数据
const loading = ref(false)
const total = ref(0)
const list = ref<any[]>([])
const autoRefresh = ref(false) // 自动刷新开关
let autoRefreshTimer: any = null // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method
}))
})
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
align: 'center',
width: 180
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
align: 'center',
width: 140
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
align: 'center',
width: 140
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
align: 'center',
width: 300
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
align: 'center',
width: 140
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
align: 'center',
ellipsis: true
}
]
/** 查询消息列表 */
const getMessageList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePage(queryParams)
total.value = data.total
list.value = data.list
} finally {
loading.value = false
}
}
/** 搜索操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getMessageList()
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList()
}, 5000)
} else {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery()
}
}
)
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList()
}
})
/** 刷新消息列表 */
const refresh = (delay = 0) => {
if (delay > 0) {
setTimeout(() => {
handleQuery()
}, delay)
} else {
handleQuery()
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh
})
</script>

View File

@@ -1,4 +1,339 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { DeviceVO } from '#/api/iot/device/device';
import type { ProductVO } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { DeviceApi, DeviceStateEnum } from '#/api/iot/device/device';
import {
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DeviceDetailsMessage from './DeviceDetailsMessage.vue';
const props = defineProps<{
device: DeviceVO;
product: ProductVO;
thingModelList: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 表单数据:存储用户输入的模拟值
const formData = ref<Record<string, string>>({});
// 根据类型过滤物模型数据
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter(
(item) => String(item.type) === String(type),
);
};
// 计算属性:属性列表
const propertyList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY),
);
// 计算属性:事件列表
const eventList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.EVENT),
);
// 计算属性:服务列表
const serviceList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE),
);
// 属性表格列定义
const propertyColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center',
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left',
},
{
title: '值',
key: 'value',
width: 150,
align: 'center',
fixed: 'right',
},
];
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center',
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left',
},
{
title: '值',
key: 'value',
width: 200,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right',
},
];
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left',
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 200,
align: 'left',
},
{
title: '参数值',
key: 'value',
width: 200,
align: 'center',
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right',
},
];
// 获取表单值
const getFormValue = (identifier: string) => {
return formData.value[identifier] || '';
};
// 设置表单值
const setFormValue = (identifier: string, value: string) => {
formData.value[identifier] = value;
};
// 属性上报
const handlePropertyPost = async () => {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' });
console.error(error);
}
};
// 事件上报
const handleEventPost = async (row: ThingModelData) => {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' });
console.error(error);
}
};
// 状态变更
const handleDeviceState = async (state: number) => {
try {
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' });
console.error(error);
}
};
// 属性设置
const handlePropertySet = async () => {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' });
console.error(error);
}
};
// 服务调用
const handleServiceInvoke = async (row: ThingModelData) => {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' });
console.error(error);
}
};
</script>
<template>
<ContentWrap>
<a-row :gutter="20">
@@ -8,11 +343,21 @@
<a-tabs v-model:active-key="activeTab">
<!-- 上行指令调试 -->
<a-tab-pane key="upstream" tab="上行指令调试">
<a-tabs v-if="activeTab === 'upstream'" v-model:active-key="upstreamTab">
<a-tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
>
<!-- 属性上报 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_POST.method" tab="属性上报">
<a-tab-pane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
<a-table
:data-source="propertyList"
:columns="propertyColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
@@ -23,26 +368,37 @@
<template v-else-if="column.key === 'value'">
<a-input
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</a-table>
<div class="flex justify-between items-center mt-4">
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<a-button type="primary" @click="handlePropertyPost">发送属性上报</a-button>
<a-button type="primary" @click="handlePropertyPost">
发送属性上报
</a-button>
</div>
</ContentWrap>
</a-tab-pane>
<!-- 事件上报 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.EVENT_POST.method" tab="事件上报">
<a-tab-pane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<a-table :dataSource="eventList" :columns="eventColumns" :pagination="false">
<a-table
:data-source="eventList"
:columns="eventColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
@@ -53,14 +409,20 @@
<template v-else-if="column.key === 'value'">
<a-textarea
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="primary" size="small" @click="handleEventPost(record)">
<a-button
type="primary"
size="small"
@click="handleEventPost(record)"
>
上报事件
</a-button>
</template>
@@ -70,13 +432,22 @@
</a-tab-pane>
<!-- 状态变更 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.STATE_UPDATE.method" tab="状态变更">
<a-tab-pane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<a-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
<a-button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</a-button>
<a-button danger @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
<a-button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</a-button>
</div>
@@ -87,11 +458,21 @@
<!-- 下行指令调试 -->
<a-tab-pane key="downstream" tab="下行指令调试">
<a-tabs v-if="activeTab === 'downstream'" v-model:active-key="downstreamTab">
<a-tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
>
<!-- 属性调试 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_SET.method" tab="属性设置">
<a-tab-pane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
<a-table
:data-source="propertyList"
:columns="propertyColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
@@ -102,26 +483,37 @@
<template v-else-if="column.key === 'value'">
<a-input
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</a-table>
<div class="flex justify-between items-center mt-4">
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<a-button type="primary" @click="handlePropertySet">发送属性设置</a-button>
<a-button type="primary" @click="handlePropertySet">
发送属性设置
</a-button>
</div>
</ContentWrap>
</a-tab-pane>
<!-- 服务调用 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method" tab="设备服务调用">
<a-tab-pane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<a-table :dataSource="serviceList" :columns="serviceColumns" :pagination="false">
<a-table
:data-source="serviceList"
:columns="serviceColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
@@ -129,7 +521,9 @@
<template v-else-if="column.key === 'value'">
<a-textarea
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
@@ -157,329 +551,13 @@
<!-- 右侧设备日志区域 -->
<a-col :span="12">
<ContentWrap title="设备消息">
<DeviceDetailsMessage v-if="device.id" ref="deviceMessageRef" :device-id="device.id" />
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</ContentWrap>
</a-col>
</a-row>
</ContentWrap>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { ProductVO } from '#/api/iot/product/product'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { DeviceApi, DeviceStateEnum } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '#/views/iot/utils/constants'
const props = defineProps<{
product: ProductVO
device: DeviceVO
thingModelList: ThingModelData[]
}>()
// 消息弹窗
const activeTab = ref('upstream') // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method) // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method) // 下行子标签
const deviceMessageRef = ref() // 设备消息组件引用
const deviceMessageRefreshDelay = 2000 // 延迟 N 秒,保证模拟上行的消息被处理
// 表单数据:存储用户输入的模拟值
const formData = ref<Record<string, string>>({})
// 根据类型过滤物模型数据
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter((item) => String(item.type) === String(type))
}
// 计算属性:属性列表
const propertyList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY))
// 计算属性:事件列表
const eventList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.EVENT))
// 计算属性:服务列表
const serviceList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE))
// 属性表格列定义
const propertyColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center'
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '值',
key: 'value',
width: 150,
align: 'center',
fixed: 'right'
}
]
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center'
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '值',
key: 'value',
width: 200,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right'
}
]
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '参数值',
key: 'value',
width: 200,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right'
}
]
// 获取表单值
const getFormValue = (identifier: string) => {
return formData.value[identifier] || ''
}
// 设置表单值
const setFormValue = (identifier: string, value: string) => {
formData.value[identifier] = value
}
// 属性上报
const handlePropertyPost = async () => {
try {
const params: Record<string, any> = {}
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!]
if (value) {
params[item.identifier!] = value
}
})
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' })
return
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params
})
message.success({ content: '属性上报成功' })
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
} catch (error) {
message.error({ content: '属性上报失败' })
console.error(error)
}
}
// 事件上报
const handleEventPost = async (row: ThingModelData) => {
try {
const valueStr = formData.value[row.identifier!]
let params: any = {}
if (valueStr) {
try {
params = JSON.parse(valueStr)
} catch (e) {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' })
return
}
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params
}
})
message.success({ content: '事件上报成功' })
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
} catch (error) {
message.error({ content: '事件上报失败' })
console.error(error)
}
}
// 状态变更
const handleDeviceState = async (state: number) => {
try {
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state }
})
message.success({ content: '状态变更成功' })
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
} catch (error) {
message.error({ content: '状态变更失败' })
console.error(error)
}
}
// 属性设置
const handlePropertySet = async () => {
try {
const params: Record<string, any> = {}
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!]
if (value) {
params[item.identifier!] = value
}
})
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' })
return
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params
})
message.success({ content: '属性设置成功' })
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
} catch (error) {
message.error({ content: '属性设置失败' })
console.error(error)
}
}
// 服务调用
const handleServiceInvoke = async (row: ThingModelData) => {
try {
const valueStr = formData.value[row.identifier!]
let params: any = {}
if (valueStr) {
try {
params = JSON.parse(valueStr)
} catch (e) {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' })
return
}
}
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params
}
})
message.success({ content: '服务调用成功' })
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
} catch (error) {
message.error({ content: '服务调用失败' })
console.error(error)
}
}
</script>

View File

@@ -1,4 +1,22 @@
<!-- 设备物模型设备属性事件管理服务调用 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue';
import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue';
import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const activeTab = ref('property'); // 默认选中设备属性
</script>
<template>
<ContentWrap>
<a-tabs v-model:active-key="activeTab" class="thing-model-tabs">
@@ -20,21 +38,6 @@
</a-tabs>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ContentWrap } from '@vben/common-ui'
import type { ThingModelData } from '#/api/iot/thingmodel'
import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue'
import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue'
import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const activeTab = ref('property') // 默认选中设备属性
</script>
<style scoped>
.thing-model-tabs :deep(.ant-tabs-content) {

View File

@@ -1,4 +1,113 @@
<!-- 设备事件管理 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { Pagination } from 'ant-design-vue';
import { DeviceApi } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = [];
handleQuery();
};
/** 获取事件名称 */
const getEventName = (identifier: string | undefined) => {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
};
/** 获取事件类型 */
const getEventType = (identifier: string | undefined) => {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
};
/** 解析参数 */
const parseParams = (params: string) => {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
};
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
@@ -7,14 +116,14 @@
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px;"
style="margin-bottom: 16px"
>
<a-form-item label="标识符" name="identifier">
<a-select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
style="width: 240px;"
style="width: 240px"
>
<a-select-option
v-for="event in eventThingModels"
@@ -30,7 +139,7 @@
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px;"
style="width: 360px"
/>
</a-form-item>
<a-form-item>
@@ -40,7 +149,7 @@
</template>
搜索
</a-button>
<a-button @click="resetQuery" style="margin-left: 8px;">
<a-button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
@@ -49,34 +158,60 @@
</a-form-item>
</a-form>
<a-divider style="margin: 16px 0;" />
<a-divider style="margin: 16px 0" />
<!-- 事件列表 -->
<a-table v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="上报时间" align="center" data-index="reportTime" :width="180">
<a-table-column
title="上报时间"
align="center"
data-index="reportTime"
:width="180"
>
<template #default="{ record }">
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</a-table-column>
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
<a-table-column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<a-tag color="blue" size="small">
{{ record.request?.identifier }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="事件名称" align="center" data-index="eventName" :width="160">
<a-table-column
title="事件名称"
align="center"
data-index="eventName"
:width="160"
>
<template #default="{ record }">
{{ getEventName(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="事件类型" align="center" data-index="eventType" :width="100">
<a-table-column
title="事件类型"
align="center"
data-index="eventType"
:width="100"
>
<template #default="{ record }">
{{ getEventType(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="输入参数" align="center" data-index="params">
<template #default="{ record }"> {{ parseParams(record.request.params) }} </template>
<template #default="{ record }">
{{ parseParams(record.request.params) }}
</template>
</a-table-column>
</a-table>
@@ -89,108 +224,3 @@
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { Pagination } from 'ant-design-vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi } from '#/api/iot/device/device'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { formatDate } from '@vben/utils'
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum
} from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const loading = ref(false) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([] as any[]) // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10
})
const queryFormRef = ref() // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.EVENT)
)
})
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.identifier = ''
queryParams.times = []
handleQuery()
}
/** 获取事件名称 */
const getEventName = (identifier: string | undefined) => {
if (!identifier) return '-'
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
return event?.name || identifier
}
/** 获取事件类型 */
const getEventType = (identifier: string | undefined) => {
if (!identifier) return '-'
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
if (!event?.event?.type) return '-'
return getEventTypeLabel(event.event.type) || '-'
}
/** 解析参数 */
const parseParams = (params: string) => {
try {
const parsed = JSON.parse(params)
if (parsed.params) {
return parsed.params
}
return parsed
} catch (error) {
return {}
}
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -1,37 +1,144 @@
<!-- 设备属性管理 -->
<script setup lang="ts">
import type { IotDevicePropertyDetailRespVO } from '#/api/iot/device/device';
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { DeviceApi } from '#/api/iot/device/device';
import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue';
const props = defineProps<{ deviceId: number }>();
const loading = ref(true); // 列表的加载中
const list = ref<IotDevicePropertyDetailRespVO[]>([]); // 显示的列表数据
const filterList = ref<IotDevicePropertyDetailRespVO[]>([]); // 完整的数据列表
const queryParams = reactive({
keyword: '' as string,
});
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 定时器
const viewMode = ref<'card' | 'list'>('card'); // 视图模式状态
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await DeviceApi.getLatestDeviceProperties(params);
handleFilter();
} finally {
loading.value = false;
}
};
/** 前端筛选数据 */
const handleFilter = () => {
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
list.value = filterList.value.filter(
(item: IotDevicePropertyDetailRespVO) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
} else {
list.value = filterList.value;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
handleFilter();
};
/** 历史操作 */
const historyRef = ref();
const openHistory = (
deviceId: number,
identifier: string,
dataType: string,
) => {
historyRef.value.open(deviceId, identifier, dataType);
};
/** 格式化属性值和单位 */
const formatValueWithUnit = (item: IotDevicePropertyDetailRespVO) => {
if (item.value === null || item.value === undefined || item.value === '') {
return '-';
}
const unitName = item.dataSpecs?.unitName;
return unitName ? `${item.value} ${unitName}` : item.value;
};
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList();
}, 5000); // 每 5 秒刷新一次
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<div class="flex items-center justify-between" style="margin-bottom: 16px;">
<div class="flex items-center" style="gap: 16px;">
<div class="flex items-center justify-between" style="margin-bottom: 16px">
<div class="flex items-center" style="gap: 16px">
<a-input
v-model:value="queryParams.keyword"
placeholder="请输入属性名称、标识符"
allow-clear
style="width: 240px;"
@pressEnter="handleQuery"
style="width: 240px"
@press-enter="handleQuery"
/>
<div class="flex items-center" style="gap: 8px;">
<span style="font-size: 14px; color: #666;">自动刷新</span>
<a-switch
v-model:checked="autoRefresh"
size="small"
/>
<div class="flex items-center" style="gap: 8px">
<span style="font-size: 14px; color: #666">自动刷新</span>
<a-switch v-model:checked="autoRefresh" size="small" />
</div>
</div>
<a-button-group>
<a-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
<a-button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
>
<IconifyIcon icon="ep:grid" />
</a-button>
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<a-button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
>
<IconifyIcon icon="ep:list" />
</a-button>
</a-button-group>
</div>
<!-- 分隔线 -->
<a-divider style="margin: 16px 0;" />
<a-divider style="margin: 16px 0" />
<!-- 卡片视图 -->
<template v-if="viewMode === 'card'">
<a-row :gutter="16" v-loading="loading">
@@ -45,53 +152,60 @@
class="mb-4"
>
<a-card
class="h-full transition-colors relative overflow-hidden"
class="relative h-full overflow-hidden transition-colors"
:body-style="{ padding: '0' }"
>
<!-- 添加渐变背景层 -->
<div
class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none bg-gradient-to-b from-[#eefaff] to-transparent"
>
</div>
<div class="p-4 relative">
class="pointer-events-none absolute left-0 right-0 top-0 h-[50px] bg-gradient-to-b from-[#eefaff] to-transparent"
></div>
<div class="relative p-4">
<!-- 标题区域 -->
<div class="flex items-center mb-3">
<div class="mb-3 flex items-center">
<div class="mr-2.5 flex items-center">
<IconifyIcon icon="ep:cpu" class="text-[18px] text-[#0070ff]" />
<IconifyIcon
icon="ep:cpu"
class="text-[18px] text-[#0070ff]"
/>
</div>
<div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
<div class="font-600 flex-1 text-[16px]">{{ item.name }}</div>
<!-- 标识符 -->
<div class="inline-flex items-center mr-2">
<div class="mr-2 inline-flex items-center">
<a-tag size="small" color="blue">
{{ item.identifier }}
</a-tag>
</div>
<!-- 数据类型标签 -->
<div class="inline-flex items-center mr-2">
<div class="mr-2 inline-flex items-center">
<a-tag size="small">
{{ item.dataType }}
</a-tag>
</div>
<!-- 数据图标 - 可点击 -->
<div
class="cursor-pointer flex items-center justify-center w-8 h-8 rounded-full hover:bg-blue-50 transition-colors"
@click="openHistory(props.deviceId, item.identifier, item.dataType)"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-50"
@click="
openHistory(props.deviceId, item.identifier, item.dataType)
"
>
<IconifyIcon icon="ep:data-line" class="text-[18px] text-[#0070ff]" />
<IconifyIcon
icon="ep:data-line"
class="text-[18px] text-[#0070ff]"
/>
</div>
</div>
<!-- 信息区域 -->
<div class="text-[14px]">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">属性值</span>
<span class="text-[#0b1d30] font-600">
<span class="mr-2.5 text-[#717c8e]">属性值</span>
<span class="font-600 text-[#0b1d30]">
{{ formatValueWithUnit(item) }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">更新时间</span>
<span class="text-[#0b1d30] text-[12px]">
<span class="mr-2.5 text-[#717c8e]">更新时间</span>
<span class="text-[12px] text-[#0b1d30]">
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
</span>
</div>
@@ -104,7 +218,11 @@
<!-- 列表视图 -->
<a-table v-else v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="属性标识符" align="center" data-index="identifier" />
<a-table-column
title="属性标识符"
align="center"
data-index="identifier"
/>
<a-table-column title="属性名称" align="center" data-index="name" />
<a-table-column title="数据类型" align="center" data-index="dataType" />
<a-table-column title="属性值" align="center" data-index="value">
@@ -126,7 +244,9 @@
<template #default="{ record }">
<a-button
type="link"
@click="openHistory(props.deviceId, record.identifier, record.dataType)"
@click="
openHistory(props.deviceId, record.identifier, record.dataType)
"
>
查看数据
</a-button>
@@ -135,10 +255,12 @@
</a-table>
<!-- 表单弹窗添加/修改 -->
<DeviceDetailsThingModelPropertyHistory ref="historyRef" :deviceId="props.deviceId" />
<DeviceDetailsThingModelPropertyHistory
ref="historyRef"
:device-id="props.deviceId"
/>
</ContentWrap>
</template>
<style scoped>
/* 移除 a-row 的额外边距 */
:deep(.ant-row) {
@@ -146,98 +268,3 @@
margin-right: -8px !important;
}
</style>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, onBeforeUnmount } from 'vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi, type IotDevicePropertyDetailRespVO } from '#/api/iot/device/device'
import { formatDate } from '@vben/utils'
import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue'
const props = defineProps<{ deviceId: number }>()
const loading = ref(true) // 列表的加载中
const list = ref<IotDevicePropertyDetailRespVO[]>([]) // 显示的列表数据
const filterList = ref<IotDevicePropertyDetailRespVO[]>([]) // 完整的数据列表
const queryParams = reactive({
keyword: '' as string
})
const autoRefresh = ref(false) // 自动刷新开关
let autoRefreshTimer: any = null // 定时器
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined
}
filterList.value = await DeviceApi.getLatestDeviceProperties(params)
handleFilter()
} finally {
loading.value = false
}
}
/** 前端筛选数据 */
const handleFilter = () => {
if (!queryParams.keyword.trim()) {
list.value = filterList.value
} else {
const keyword = queryParams.keyword.toLowerCase()
list.value = filterList.value.filter(
(item: IotDevicePropertyDetailRespVO) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword)
)
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
handleFilter()
}
/** 历史操作 */
const historyRef = ref()
const openHistory = (deviceId: number, identifier: string, dataType: string) => {
historyRef.value.open(deviceId, identifier, dataType)
}
/** 格式化属性值和单位 */
const formatValueWithUnit = (item: IotDevicePropertyDetailRespVO) => {
if (item.value === null || item.value === undefined || item.value === '') {
return '-'
}
const unitName = item.dataSpecs?.unitName
return unitName ? `${item.value} ${unitName}` : item.value
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList()
}, 5000) // 每 5 秒刷新一次
} else {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
}
})
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -1,4 +1,374 @@
<!-- 设备物模型 -> 运行状态 -> 查看数据设备的属性值历史-->
<script setup lang="ts">
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { IotDevicePropertyRespVO } from '#/api/iot/device/device';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { beginOfDay, endOfDay, formatDate } from '@vben/utils';
import { Empty, message, Modal } from 'ant-design-vue';
import dayjs from 'dayjs';
import { DeviceApi } from '#/api/iot/device/device';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
/** IoT 设备属性历史数据详情 */
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' });
defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); // 弹窗的是否展示
const loading = ref(false);
const exporting = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); // 视图模式状态
const list = ref<IotDevicePropertyRespVO[]>([]); // 列表的数据
const total = ref(0); // 总数据量
const thingModelDataType = ref<string>(''); // 物模型数据类型
const propertyIdentifier = ref<string>(''); // 属性标识符
const dateRange = ref<[Dayjs, Dayjs]>([
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day'),
]);
const queryParams = reactive({
deviceId: -1,
identifier: '',
times: [
formatDate(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDate(endOfDay(new Date())),
],
});
// Echarts 相关
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// 判断是否为复杂数据类型struct 或 array
const isComplexDataType = computed(() => {
if (!thingModelDataType.value) return false;
return [
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
].includes(thingModelDataType.value as any);
});
// 统计数据
const maxValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !isNaN(v));
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
});
const minValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !isNaN(v));
return values.length > 0 ? Math.min(...values).toFixed(2) : '-';
});
const avgValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !isNaN(v));
if (values.length === 0) return '-';
const sum = values.reduce((acc, val) => acc + val, 0);
return (sum / values.length).toFixed(2);
});
// 表格列配置
const tableColumns = computed(() => [
{
title: '序号',
key: 'index',
width: 80,
align: 'center',
customRender: ({ index }: { index: number }) => index + 1,
},
{
title: '时间',
key: 'updateTime',
dataIndex: 'updateTime',
width: 200,
align: 'center',
},
{
title: '属性值',
key: 'value',
dataIndex: 'value',
align: 'center',
},
]);
// 分页配置
const paginationConfig = computed(() => ({
current: 1,
pageSize: 20,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`,
}));
/** 获得设备历史数据 */
const getList = async () => {
loading.value = true;
try {
const data = await DeviceApi.getHistoryDevicePropertyList(queryParams);
list.value = data?.list || [];
total.value = list.value.length;
// 如果是图表模式且不是复杂数据类型,渲染图表
if (
viewMode.value === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
await nextTick();
renderChart();
}
} catch {
message.error('获取数据失败');
list.value = [];
total.value = 0;
} finally {
loading.value = false;
}
};
/** 渲染图表 */
const renderChart = () => {
if (!list.value || list.value.length === 0) return;
const chartData = list.value.map((item) => [item.updateTime, item.value]);
renderEcharts({
title: {
text: '属性值趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
},
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
formatter: (params: any) => {
const param = params[0];
return `
<div style="padding: 8px;">
<div style="margin-bottom: 4px; font-weight: bold;">
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
</div>
<div>
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
<span>属性值: <strong>${param.value[1]}</strong></span>
</div>
</div>
`;
},
},
xAxis: {
type: 'time',
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
axisLabel: {
formatter: (value: number) => {
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '');
},
},
},
yAxis: {
type: 'value',
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0],
},
},
series: [
{
name: '属性值',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF',
},
itemStyle: {
color: '#1890FF',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)',
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)',
},
],
},
},
data: chartData,
},
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
type: 'slider',
height: 30,
bottom: 20,
},
],
});
};
/** 打开弹窗 */
const open = async (deviceId: number, identifier: string, dataType: string) => {
dialogVisible.value = true;
queryParams.deviceId = deviceId;
queryParams.identifier = identifier;
propertyIdentifier.value = identifier;
thingModelDataType.value = dataType;
// 如果物模型是 struct、array需要默认使用 list 模式
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
// 等待弹窗完全渲染后再获取数据
await nextTick();
await getList();
};
/** 时间变化处理 */
const handleTimeChange = () => {
if (!dateRange.value || dateRange.value.length !== 2) {
return;
}
queryParams.times = [
formatDate(dateRange.value[0].toDate()),
formatDate(dateRange.value[1].toDate()),
];
getList();
};
/** 刷新数据 */
const handleRefresh = () => {
getList();
};
/** 导出数据 */
const handleExport = async () => {
if (list.value.length === 0) {
message.warning('暂无数据可导出');
return;
}
exporting.value = true;
try {
// 构建CSV内容
const headers = ['序号', '时间', '属性值'];
const csvContent = [
headers.join(','),
...list.value.map((item, index) => {
return [
index + 1,
formatDate(new Date(item.updateTime)),
isComplexDataType.value
? `"${JSON.stringify(item.value)}"`
: item.value,
].join(',');
}),
].join('\n');
// 创建 BOM 头,解决中文乱码
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8',
});
// 下载文件
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
} finally {
exporting.value = false;
}
};
/** 关闭弹窗 */
const handleClose = () => {
dialogVisible.value = false;
list.value = [];
total.value = 0;
};
/** 格式化复杂数据类型 */
const formatComplexValue = (value: any) => {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
};
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (
newMode === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
await nextTick();
renderChart();
}
});
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</script>
<template>
<Modal
v-model:open="dialogVisible"
@@ -30,7 +400,11 @@
</a-button>
<!-- 导出按钮 -->
<a-button @click="handleExport" :loading="exporting" :disabled="list.length === 0">
<a-button
@click="handleExport"
:loading="exporting"
:disabled="list.length === 0"
>
<template #icon>
<Icon icon="ant-design:export-outlined" />
</template>
@@ -49,7 +423,10 @@
</template>
图表
</a-button>
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<a-button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
>
<template #icon>
<Icon icon="ant-design:table-outlined" />
</template>
@@ -63,7 +440,8 @@
<a-space :size="16">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && !isComplexDataType">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值: {{ avgValue }}
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值:
{{ avgValue }}
</span>
</a-space>
</div>
@@ -85,7 +463,7 @@
<!-- 表格模式 -->
<div v-else class="table-container">
<a-table
:dataSource="list"
:data-source="list"
:columns="tableColumns"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
@@ -113,358 +491,6 @@
</template>
</Modal>
</template>
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts'
import type { IotDevicePropertyRespVO } from '#/api/iot/device/device'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'
import { beginOfDay, endOfDay, formatDate } from '@vben/utils'
import { Empty, message, Modal } from 'ant-design-vue'
import dayjs, { type Dayjs } from 'dayjs'
import { DeviceApi } from '#/api/iot/device/device'
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants'
defineProps<{ deviceId: number }>()
/** IoT 设备属性历史数据详情 */
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' })
const dialogVisible = ref(false) // 弹窗的是否展示
const loading = ref(false)
const exporting = ref(false)
const viewMode = ref<'chart' | 'list'>('chart') // 视图模式状态
const list = ref<IotDevicePropertyRespVO[]>([]) // 列表的数据
const total = ref(0) // 总数据量
const thingModelDataType = ref<string>('') // 物模型数据类型
const propertyIdentifier = ref<string>('') // 属性标识符
const dateRange = ref<[Dayjs, Dayjs]>([
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day')
])
const queryParams = reactive({
deviceId: -1,
identifier: '',
times: [
formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
formatDate(endOfDay(new Date()))
]
})
// Echarts 相关
const chartRef = ref<EchartsUIType>()
const { renderEcharts } = useEcharts(chartRef)
// 判断是否为复杂数据类型struct 或 array
const isComplexDataType = computed(() => {
if (!thingModelDataType.value) return false
return [IoTDataSpecsDataTypeEnum.STRUCT, IoTDataSpecsDataTypeEnum.ARRAY].includes(
thingModelDataType.value as any
)
})
// 统计数据
const maxValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
return values.length > 0 ? Math.max(...values).toFixed(2) : '-'
})
const minValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
return values.length > 0 ? Math.min(...values).toFixed(2) : '-'
})
const avgValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
if (values.length === 0) return '-'
const sum = values.reduce((acc, val) => acc + val, 0)
return (sum / values.length).toFixed(2)
})
// 表格列配置
const tableColumns = computed(() => [
{
title: '序号',
key: 'index',
width: 80,
align: 'center',
customRender: ({ index }: { index: number }) => index + 1
},
{
title: '时间',
key: 'updateTime',
dataIndex: 'updateTime',
width: 200,
align: 'center'
},
{
title: '属性值',
key: 'value',
dataIndex: 'value',
align: 'center'
}
])
// 分页配置
const paginationConfig = computed(() => ({
current: 1,
pageSize: 20,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`
}))
/** 获得设备历史数据 */
const getList = async () => {
loading.value = true
try {
const data = await DeviceApi.getHistoryDevicePropertyList(queryParams)
list.value = data?.list || []
total.value = list.value.length
// 如果是图表模式且不是复杂数据类型,渲染图表
if (viewMode.value === 'chart' && !isComplexDataType.value && list.value.length > 0) {
await nextTick()
renderChart()
}
} catch (error) {
message.error('获取数据失败')
list.value = []
total.value = 0
} finally {
loading.value = false
}
}
/** 渲染图表 */
const renderChart = () => {
if (!list.value || list.value.length === 0) return
const chartData = list.value.map((item) => [item.updateTime, item.value])
renderEcharts({
title: {
text: '属性值趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params: any) => {
const param = params[0]
return `
<div style="padding: 8px;">
<div style="margin-bottom: 4px; font-weight: bold;">
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
</div>
<div>
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
<span>属性值: <strong>${param.value[1]}</strong></span>
</div>
</div>
`
}
},
xAxis: {
type: 'time',
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0]
},
axisLabel: {
formatter: (value: number) => {
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '')
}
}
},
yAxis: {
type: 'value',
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0]
}
},
series: [
{
name: '属性值',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF'
},
itemStyle: {
color: '#1890FF'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)'
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)'
}
]
}
},
data: chartData
}
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
type: 'slider',
height: 30,
bottom: 20
}
]
})
}
/** 打开弹窗 */
const open = async (deviceId: number, identifier: string, dataType: string) => {
dialogVisible.value = true
queryParams.deviceId = deviceId
queryParams.identifier = identifier
propertyIdentifier.value = identifier
thingModelDataType.value = dataType
// 如果物模型是 struct、array需要默认使用 list 模式
if (isComplexDataType.value) {
viewMode.value = 'list'
} else {
viewMode.value = 'chart'
}
// 等待弹窗完全渲染后再获取数据
await nextTick()
await getList()
}
/** 时间变化处理 */
const handleTimeChange = () => {
if (!dateRange.value || dateRange.value.length !== 2) {
return
}
queryParams.times = [
formatDate(dateRange.value[0].toDate()),
formatDate(dateRange.value[1].toDate())
]
getList()
}
/** 刷新数据 */
const handleRefresh = () => {
getList()
}
/** 导出数据 */
const handleExport = async () => {
if (list.value.length === 0) {
message.warning('暂无数据可导出')
return
}
exporting.value = true
try {
// 构建CSV内容
const headers = ['序号', '时间', '属性值']
const csvContent = [
headers.join(','),
...list.value.map((item, index) => {
return [
index + 1,
formatDate(new Date(item.updateTime)),
isComplexDataType.value ? `"${JSON.stringify(item.value)}"` : item.value
].join(',')
})
].join('\n')
// 创建 BOM 头,解决中文乱码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8' })
// 下载文件
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
} finally {
exporting.value = false
}
}
/** 关闭弹窗 */
const handleClose = () => {
dialogVisible.value = false
list.value = []
total.value = 0
}
/** 格式化复杂数据类型 */
const formatComplexValue = (value: any) => {
if (typeof value === 'object') {
return JSON.stringify(value)
}
return String(value)
}
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (newMode === 'chart' && !isComplexDataType.value && list.value.length > 0) {
await nextTick()
renderChart()
}
})
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
</script>
<style scoped lang="scss">
.property-history-container {
@@ -485,4 +511,3 @@ defineExpose({ open }) // 提供 open 方法,用于打开弹窗
}
}
</style>

View File

@@ -1,4 +1,114 @@
<!-- 设备服务调用 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { Pagination } from 'ant-design-vue';
import { DeviceApi } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = [];
handleQuery();
};
/** 获取服务名称 */
const getServiceName = (identifier: string | undefined) => {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
};
/** 获取调用方式 */
const getCallType = (identifier: string | undefined) => {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
};
/** 解析参数 */
const parseParams = (params: string) => {
if (!params) return '-';
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2);
}
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
};
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
@@ -7,14 +117,14 @@
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px;"
style="margin-bottom: 16px"
>
<a-form-item label="标识符" name="identifier">
<a-select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
style="width: 240px;"
style="width: 240px"
>
<a-select-option
v-for="service in serviceThingModels"
@@ -30,7 +140,7 @@
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px;"
style="width: 360px"
/>
</a-form-item>
<a-form-item>
@@ -40,7 +150,7 @@
</template>
搜索
</a-button>
<a-button @click="resetQuery" style="margin-left: 8px;">
<a-button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
@@ -49,39 +159,72 @@
</a-form-item>
</a-form>
<a-divider style="margin: 16px 0;" />
<a-divider style="margin: 16px 0" />
<!-- 服务调用列表 -->
<a-table v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="调用时间" align="center" data-index="requestTime" :width="180">
<a-table-column
title="调用时间"
align="center"
data-index="requestTime"
:width="180"
>
<template #default="{ record }">
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</a-table-column>
<a-table-column title="响应时间" align="center" data-index="responseTime" :width="180">
<a-table-column
title="响应时间"
align="center"
data-index="responseTime"
:width="180"
>
<template #default="{ record }">
{{ record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-' }}
{{
record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-'
}}
</template>
</a-table-column>
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
<a-table-column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<a-tag color="blue" size="small">
{{ record.request?.identifier }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="服务名称" align="center" data-index="serviceName" :width="160">
<a-table-column
title="服务名称"
align="center"
data-index="serviceName"
:width="160"
>
<template #default="{ record }">
{{ getServiceName(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="调用方式" align="center" data-index="callType" :width="100">
<a-table-column
title="调用方式"
align="center"
data-index="callType"
:width="100"
>
<template #default="{ record }">
{{ getCallType(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="输入参数" align="center" data-index="inputParams">
<template #default="{ record }"> {{ parseParams(record.request?.params) }} </template>
<template #default="{ record }">
{{ parseParams(record.request?.params) }}
</template>
</a-table-column>
<a-table-column title="输出参数" align="center" data-index="outputParams">
<template #default="{ record }">
@@ -104,109 +247,3 @@
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { Pagination } from 'ant-design-vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi } from '#/api/iot/device/device'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { formatDate } from '@vben/utils'
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum
} from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const loading = ref(false) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([] as any[]) // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10
})
const queryFormRef = ref() // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.SERVICE)
)
})
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.identifier = ''
queryParams.times = []
handleQuery()
}
/** 获取服务名称 */
const getServiceName = (identifier: string | undefined) => {
if (!identifier) return '-'
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
return service?.name || identifier
}
/** 获取调用方式 */
const getCallType = (identifier: string | undefined) => {
if (!identifier) return '-'
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
if (!service?.service?.callType) return '-'
return getThingModelServiceCallTypeLabel(service.service.callType) || '-'
}
/** 解析参数 */
const parseParams = (params: string) => {
if (!params) return '-'
try {
const parsed = JSON.parse(params)
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2)
}
return JSON.stringify(parsed, null, 2)
} catch (error) {
return params
}
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -1,3 +1,79 @@
<script lang="ts" setup>
import type { DeviceVO } from '#/api/iot/device/device';
import type { ProductVO } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { onMounted, ref, unref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabbarStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { DeviceApi } from '#/api/iot/device/device';
import { DeviceTypeEnum, ProductApi } from '#/api/iot/product/product';
import { ThingModelApi } from '#/api/iot/thingmodel';
import DeviceDetailConfig from './DeviceDetailConfig.vue';
import DeviceDetailsHeader from './DeviceDetailsHeader.vue';
import DeviceDetailsInfo from './DeviceDetailsInfo.vue';
import DeviceDetailsMessage from './DeviceDetailsMessage.vue';
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue';
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue';
defineOptions({ name: 'IoTDeviceDetail' });
const route = useRoute();
const id = Number(route.params.id); // 将字符串转换为数字
const loading = ref(true); // 加载中
const product = ref<ProductVO>({} as ProductVO); // 产品详情
const device = ref<DeviceVO>({} as DeviceVO); // 设备详情
const activeTab = ref('info'); // 默认激活的标签页
const thingModelList = ref<ThingModelData[]>([]); // 物模型列表数据
/** 获取设备详情 */
const getDeviceData = async () => {
loading.value = true;
try {
device.value = await DeviceApi.getDevice(id);
await getProductData(device.value.productId);
await getThingModelList(device.value.productId);
} finally {
loading.value = false;
}
};
/** 获取产品详情 */
const getProductData = async (id: number) => {
product.value = await ProductApi.getProduct(id);
};
/** 获取物模型列表 */
const getThingModelList = async (productId: number) => {
try {
const data = await ThingModelApi.getThingModelList(productId);
thingModelList.value = data || [];
} catch (error) {
console.error('获取物模型列表失败:', error);
thingModelList.value = [];
}
};
/** 初始化 */
const tabbarStore = useTabbarStore(); // 视图操作
const router = useRouter(); // 路由
const { currentRoute } = router;
onMounted(async () => {
if (!id) {
message.warning({ content: '参数错误,产品不能为空!' });
await tabbarStore.closeTab(unref(currentRoute), router);
return;
}
await getDeviceData();
activeTab.value = (route.query.tab as string) || 'info';
});
</script>
<template>
<Page>
<DeviceDetailsHeader
@@ -6,10 +82,14 @@
:device="device"
@refresh="getDeviceData"
/>
<a-tabs v-model:active-key="activeTab" class="device-detail-tabs mt-4">
<a-tab-pane key="info" tab="设备信息">
<DeviceDetailsInfo v-if="activeTab === 'info'" :product="product" :device="device" />
<DeviceDetailsInfo
v-if="activeTab === 'info'"
:product="product"
:device="device"
/>
</a-tab-pane>
<a-tab-pane key="model" tab="物模型数据">
<DeviceDetailsThingModel
@@ -18,9 +98,16 @@
:thing-model-list="thingModelList"
/>
</a-tab-pane>
<a-tab-pane v-if="product.deviceType === DeviceTypeEnum.GATEWAY" key="sub-device" tab="子设备管理" />
<a-tab-pane
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
key="sub-device"
tab="子设备管理"
/>
<a-tab-pane key="log" tab="设备消息">
<DeviceDetailsMessage v-if="activeTab === 'log' && device.id" :device-id="device.id" />
<DeviceDetailsMessage
v-if="activeTab === 'log' && device.id"
:device-id="device.id"
/>
</a-tab-pane>
<a-tab-pane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator
@@ -40,75 +127,3 @@
</a-tabs>
</Page>
</template>
<script lang="ts" setup>
import { ref, unref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTabbarStore } from '@vben/stores'
import { Page } from '@vben/common-ui'
import { DeviceApi } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import { DeviceTypeEnum, ProductApi } from '#/api/iot/product/product'
import type { ProductVO } from '#/api/iot/product/product'
import { ThingModelApi } from '#/api/iot/thingmodel'
import type { ThingModelData } from '#/api/iot/thingmodel'
import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
defineOptions({ name: 'IoTDeviceDetail' })
const route = useRoute()
const id = Number(route.params.id) // 将字符串转换为数字
const loading = ref(true) // 加载中
const product = ref<ProductVO>({} as ProductVO) // 产品详情
const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
const activeTab = ref('info') // 默认激活的标签页
const thingModelList = ref<ThingModelData[]>([]) // 物模型列表数据
/** 获取设备详情 */
const getDeviceData = async () => {
loading.value = true
try {
device.value = await DeviceApi.getDevice(id)
await getProductData(device.value.productId)
await getThingModelList(device.value.productId)
} finally {
loading.value = false
}
}
/** 获取产品详情 */
const getProductData = async (id: number) => {
product.value = await ProductApi.getProduct(id)
}
/** 获取物模型列表 */
const getThingModelList = async (productId: number) => {
try {
const data = await ThingModelApi.getThingModelList(productId)
thingModelList.value = data || []
} catch (error) {
console.error('获取物模型列表失败:', error)
thingModelList.value = []
}
}
/** 初始化 */
const tabbarStore = useTabbarStore() // 视图操作
const router = useRouter() // 路由
const { currentRoute } = router
onMounted(async () => {
if (!id) {
message.warning({ content: '参数错误,产品不能为空!' })
await tabbarStore.closeTab(unref(currentRoute), router)
return
}
await getDeviceData()
activeTab.value = (route.query.tab as string) || 'info'
})
</script>

View File

@@ -48,19 +48,17 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
try {
const values = await formApi.getValues();
if (formData.value?.id) {
await updateDeviceGroup({
...values,
id: formData.value.id,
} as IotDeviceGroupApi.DeviceGroup);
} else {
await createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup);
}
await (formData.value?.id
? updateDeviceGroup({
...values,
id: formData.value.id,
} as IotDeviceGroupApi.DeviceGroup)
: createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -68,20 +66,20 @@ const [Modal, modalApi] = useVbenModal({
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
const data = modalApi.getData<IotDeviceGroupApi.DeviceGroup>();
// 如果没有数据或没有 id表示是新增
if (!data || !data.id) {
formData.value = undefined;
return;
}
// 编辑模式:加载数据
modalApi.lock();
try {