Vue3 + Element Plus版本iot前端迁移到vben版本
This commit is contained in:
321
apps/web-antd/src/views/iot/device/device/data.ts
Normal file
321
apps/web-antd/src/views/iot/device/device/data.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
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';
|
||||
|
||||
/** 新增/修改的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceName',
|
||||
label: 'DeviceName',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 DeviceName',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, 'DeviceName 长度不能少于 4 个字符')
|
||||
.max(32, 'DeviceName 长度不能超过 32 个字符')
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_.\-:@]{4,32}$/,
|
||||
'支持英文字母、数字、下划线(_)、中划线(-)、点号(.)、半角冒号(:)和特殊字符@',
|
||||
),
|
||||
},
|
||||
{
|
||||
fieldName: 'gatewayId',
|
||||
label: '网关设备',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: async () => {
|
||||
const { getSimpleDeviceList } = await import(
|
||||
'#/api/iot/device/device'
|
||||
);
|
||||
return getSimpleDeviceList(DeviceTypeEnum.GATEWAY);
|
||||
},
|
||||
labelField: 'nickname',
|
||||
valueField: 'id',
|
||||
placeholder: '子设备可选择父设备',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['deviceType'],
|
||||
show: (values) => values.deviceType === 1, // GATEWAY_SUB
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '备注名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(4, '备注名称长度限制为 4~64 个字符')
|
||||
.max(64, '备注名称长度限制为 4~64 个字符')
|
||||
.regex(
|
||||
/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/,
|
||||
'备注名称只能包含中文、英文字母、日文、数字和下划线(_)',
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
{
|
||||
fieldName: 'groupIds',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择设备分组',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'serialNumber',
|
||||
label: '设备序列号',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备序列号',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, '序列号只能包含字母、数字、中划线和下划线')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
},
|
||||
// {
|
||||
// fieldName: 'locationType',
|
||||
// label: '定位类型',
|
||||
// component: 'RadioGroup',
|
||||
// componentProps: {
|
||||
// options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
|
||||
// buttonStyle: 'solid',
|
||||
// optionType: 'button',
|
||||
// },
|
||||
// },
|
||||
{
|
||||
fieldName: 'longitude',
|
||||
label: '设备经度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备经度',
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['locationType'],
|
||||
show: (values) => values.locationType === 3, // MANUAL
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'latitude',
|
||||
label: '设备维度',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入设备维度',
|
||||
class: 'w-full',
|
||||
},
|
||||
dependencies: {
|
||||
triggerFields: ['locationType'],
|
||||
show: (values) => values.locationType === 3, // MANUAL
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 设备分组表单 */
|
||||
export function useGroupFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'groupIds',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
mode: 'multiple',
|
||||
placeholder: '请选择设备分组',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 设备导入表单 */
|
||||
export function useImportFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'file',
|
||||
label: '设备数据',
|
||||
component: 'Upload',
|
||||
rules: 'required',
|
||||
help: '仅允许导入 xls、xlsx 格式文件',
|
||||
},
|
||||
{
|
||||
fieldName: 'updateSupport',
|
||||
label: '是否覆盖',
|
||||
component: 'Switch',
|
||||
componentProps: {
|
||||
checkedChildren: '是',
|
||||
unCheckedChildren: '否',
|
||||
},
|
||||
rules: z.boolean().default(false),
|
||||
help: '是否更新已经存在的设备数据',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'productId',
|
||||
label: '产品',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceName',
|
||||
label: 'DeviceName',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入 DeviceName',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'nickname',
|
||||
label: '备注名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入备注名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceType',
|
||||
label: '设备类型',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
|
||||
placeholder: '请选择设备类型',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '设备状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATUS, 'number'),
|
||||
placeholder: '请选择设备状态',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'groupId',
|
||||
label: '设备分组',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择设备分组',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: 'DeviceName',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'nickname',
|
||||
title: '备注名称',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'productId',
|
||||
title: '所属产品',
|
||||
minWidth: 120,
|
||||
slots: { default: 'product' },
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'groupIds',
|
||||
title: '所属分组',
|
||||
minWidth: 150,
|
||||
slots: { default: 'groups' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '设备状态',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_DEVICE_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'onlineTime',
|
||||
title: '最后上线时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,28 +1,469 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
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 { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteDevice,
|
||||
deleteDeviceList,
|
||||
exportDeviceExcel,
|
||||
getDevicePage
|
||||
} from '#/api/iot/device/device';
|
||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
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' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const products = ref<any[]>([]);
|
||||
const deviceGroups = ref<any[]>([]);
|
||||
const viewMode = ref<'list' | 'card'>('card');
|
||||
const cardViewRef = ref();
|
||||
|
||||
// Modal instances
|
||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeviceGroupFormModal, deviceGroupFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceGroupForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceImportForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined as number | undefined,
|
||||
deviceType: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
groupId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
// 获取字典选项
|
||||
const getIntDictOptions = (dictType: string) => {
|
||||
return getDictOptions(dictType, 'number');
|
||||
};
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
searchParams.value = {
|
||||
deviceName: '',
|
||||
nickname: '',
|
||||
productId: undefined,
|
||||
deviceType: undefined,
|
||||
status: undefined,
|
||||
groupId: undefined,
|
||||
};
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
const data = await exportDeviceExcel(searchParams.value);
|
||||
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
|
||||
}
|
||||
|
||||
/** 打开设备详情 */
|
||||
function openDetail(id: number) {
|
||||
router.push({ name: 'IoTDeviceDetail', params: { id } });
|
||||
}
|
||||
|
||||
/** 跳转到产品详情页面 */
|
||||
function openProductDetail(productId: number) {
|
||||
router.push({ name: 'IoTProductDetail', params: { id: productId } });
|
||||
}
|
||||
|
||||
/** 打开物模型数据 */
|
||||
function openModel(id: number) {
|
||||
router.push({ name: 'IoTDeviceDetail', params: { id }, query: { tab: 'model' } });
|
||||
}
|
||||
|
||||
/** 新增设备 */
|
||||
function handleCreate() {
|
||||
deviceFormModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑设备 */
|
||||
function handleEdit(row: any) {
|
||||
deviceFormModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备 */
|
||||
async function handleDelete(row: any) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除设备...`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDevice(row.id);
|
||||
message.success($t('common.delSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除设备 */
|
||||
async function handleDeleteBatch() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要删除的设备');
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: '正在批量删除...',
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
await deleteDeviceList(ids);
|
||||
message.success($t('common.delSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加到分组 */
|
||||
function handleAddToGroup() {
|
||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
||||
if (checkedRows.length === 0) {
|
||||
message.warning('请选择要添加到分组的设备');
|
||||
return;
|
||||
}
|
||||
const ids = checkedRows.map((row: any) => row.id);
|
||||
deviceGroupFormModalApi.setData(ids).open();
|
||||
}
|
||||
|
||||
/** 设备导入 */
|
||||
function handleImport() {
|
||||
deviceImportFormModalApi.open();
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [],
|
||||
},
|
||||
gridOptions: {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
reserve: true,
|
||||
},
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
// 获取产品列表
|
||||
products.value = await getSimpleProductList();
|
||||
// 获取分组列表
|
||||
deviceGroups.value = await getSimpleDeviceGroupList();
|
||||
|
||||
// 处理 productId 参数
|
||||
const { productId } = route.query;
|
||||
if (productId) {
|
||||
searchParams.value.productId = Number(productId);
|
||||
// 自动触发搜索
|
||||
handleSearch();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/device/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/device/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<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">
|
||||
<Select
|
||||
v-model:value="searchParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Input
|
||||
v-model:value="searchParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="searchParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="searchParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
v-model:value="searchParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Space :size="12">
|
||||
<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']">
|
||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
<Button @click="handleImport" v-hasPermi="['iot:device:import']">
|
||||
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
|
||||
导入
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
@click="handleAddToGroup"
|
||||
v-hasPermi="['iot:device:update']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
|
||||
添加到分组
|
||||
</Button>
|
||||
<Button
|
||||
v-show="viewMode === 'list'"
|
||||
danger
|
||||
@click="handleDeleteBatch"
|
||||
v-hasPermi="['iot:device:delete']"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
<Button
|
||||
:type="viewMode === 'card' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'card'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:appstore-outlined" />
|
||||
</Button>
|
||||
<Button
|
||||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:unordered-list-outlined" />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<template #toolbar-tools>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<!-- 所属产品列 -->
|
||||
<template #product="{ row }">
|
||||
<a class="cursor-pointer text-primary" @click="openProductDetail(row.productId)">
|
||||
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- 所属分组列 -->
|
||||
<template #groups="{ row }">
|
||||
<template v-if="row.groupIds?.length">
|
||||
<Tag
|
||||
v-for="groupId in row.groupIds"
|
||||
:key="groupId"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ deviceGroups.find((g: any) => g.id === groupId)?.name }}
|
||||
</Tag>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看',
|
||||
type: 'link',
|
||||
onClick: openDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '日志',
|
||||
type: 'link',
|
||||
onClick: openModel.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: `确认删除设备吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<DeviceCardView
|
||||
v-show="viewMode === 'card'"
|
||||
ref="cardViewRef"
|
||||
:products="products"
|
||||
:device-groups="deviceGroups"
|
||||
:search-params="searchParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@detail="openDetail"
|
||||
@model="openModel"
|
||||
@product-detail="openProductDetail"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vxe-toolbar div) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 隐藏 VxeGrid 自带的搜索表单区域 */
|
||||
:deep(.vxe-grid--form-wrapper) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
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' });
|
||||
|
||||
interface Props {
|
||||
products: any[];
|
||||
deviceGroups: any[];
|
||||
searchParams?: {
|
||||
deviceName: string;
|
||||
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);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
// 获取产品名称
|
||||
const getProductName = (productId: number) => {
|
||||
const product = props.products.find((p: any) => p.id === productId);
|
||||
return product?.name || '-';
|
||||
};
|
||||
|
||||
// 获取设备列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getDevicePage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
};
|
||||
|
||||
// 获取设备类型颜色
|
||||
const getDeviceTypeColor = (deviceType: number) => {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'cyan',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
};
|
||||
|
||||
// 获取设备状态信息
|
||||
const getStatusInfo = (state: number) => {
|
||||
if (state === DeviceStateEnum.ONLINE) {
|
||||
return {
|
||||
text: '在线',
|
||||
color: '#52c41a',
|
||||
bgColor: '#f6ffed',
|
||||
borderColor: '#b7eb8f',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: '未激活',
|
||||
color: '#ff4d4f',
|
||||
bgColor: '#fff1f0',
|
||||
borderColor: '#ffccc7',
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-card-view">
|
||||
<!-- 设备卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: 0 }"
|
||||
class="device-card"
|
||||
:bordered="false"
|
||||
>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="card-content">
|
||||
<!-- 头部:图标和状态 -->
|
||||
<div class="card-header">
|
||||
<div class="device-icon">
|
||||
<IconifyIcon icon="mdi:chip" />
|
||||
</div>
|
||||
<div
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: getStatusInfo(item.state).color,
|
||||
backgroundColor: getStatusInfo(item.state).bgColor,
|
||||
borderColor: getStatusInfo(item.state).borderColor,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
{{ getStatusInfo(item.state).text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备名称 -->
|
||||
<div class="device-name" :title="item.deviceName">
|
||||
{{ item.deviceName }}
|
||||
</div>
|
||||
|
||||
<!-- 信息区域 -->
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="label">所属产品</span>
|
||||
<a
|
||||
class="value link"
|
||||
@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>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Deviceid</span>
|
||||
<span class="value code" :title="item.Deviceid || item.id">
|
||||
{{ item.Deviceid || item.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-edit"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('edit', item); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:note-pencil" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-view"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('detail', item.id); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:eye" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-data"
|
||||
@click="(e: MouseEvent) => { e.stopPropagation(); emit('model', item.id); }"
|
||||
>
|
||||
<IconifyIcon icon="ph:database" />
|
||||
数据
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该设备吗?"
|
||||
@confirm="() => emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
class="action-btn btn-delete"
|
||||
@click="(e: MouseEvent) => e.stopPropagation()"
|
||||
>
|
||||
<IconifyIcon icon="ph:trash" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无设备数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-card-view {
|
||||
.device-card {
|
||||
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);
|
||||
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);
|
||||
transform: translateY(-4px);
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 头部区域
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.device-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid;
|
||||
line-height: 18px;
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设备名称
|
||||
.device-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
// 信息区域
|
||||
.info-section {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 13px;
|
||||
color: #262626;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&.link {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮栏
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 400;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.btn-edit {
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-view {
|
||||
color: #faad14;
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #faad14;
|
||||
border-color: #faad14;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-data {
|
||||
color: #722ed1;
|
||||
background: #f9f0ff;
|
||||
border-color: #d3adf7;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #722ed1;
|
||||
border-color: #722ed1;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 4px;
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
border-color: #ffa39e;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
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 { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑设备' : '新增设备';
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IotDeviceApi.Device;
|
||||
try {
|
||||
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
if (!data || !data.id) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getDevice(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { updateDeviceGroup } from '#/api/iot/device/device';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGroupFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroupForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const deviceIds = ref<number[]>([]);
|
||||
const getTitle = computed(() => '添加设备到分组');
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useGroupFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = await formApi.getValues();
|
||||
try {
|
||||
await updateDeviceGroup({
|
||||
ids: deviceIds.value,
|
||||
groupIds: data.groupIds as number[],
|
||||
});
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('common.updateSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
deviceIds.value = [];
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const ids = modalApi.getData<number[]>();
|
||||
if (ids) {
|
||||
deviceIds.value = ids;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { importDeviceTemplate } from '#/api/iot/device/device';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { useImportFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const getTitle = computed(() => '设备导入');
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useImportFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const file = values.file;
|
||||
|
||||
if (!file || !file.length) {
|
||||
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(
|
||||
`${import.meta.env.VITE_GLOB_API_URL}/iot/device/import?updateSupport=${values.updateSupport}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
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) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新成功数量:${data.updateDeviceNames?.length || 0};`;
|
||||
if (data.updateDeviceNames) {
|
||||
for (const deviceName of data.updateDeviceNames) {
|
||||
text += `< ${deviceName} >`;
|
||||
}
|
||||
}
|
||||
text += `更新失败数量:${Object.keys(data.failureDeviceNames || {}).length};`;
|
||||
if (data.failureDeviceNames) {
|
||||
for (const deviceName in data.failureDeviceNames) {
|
||||
text += `< ${deviceName}: ${data.failureDeviceNames[deviceName]} >`;
|
||||
}
|
||||
}
|
||||
message.info(text);
|
||||
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '导入失败');
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
// 重置表单
|
||||
await formApi.resetForm();
|
||||
await formApi.setValues({
|
||||
updateSupport: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 下载模板 */
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const res = await importDeviceTemplate();
|
||||
downloadFileFromBlobPart({ fileName: '设备导入模版.xls', source: res });
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '下载失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-1/3">
|
||||
<Form class="mx-4" />
|
||||
<div class="mx-4 mt-4 text-center">
|
||||
<a class="text-primary cursor-pointer" @click="handleDownloadTemplate">
|
||||
下载导入模板
|
||||
</a>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,370 @@
|
||||
<!-- IoT 设备选择,使用弹窗展示 -->
|
||||
<template>
|
||||
<a-modal
|
||||
:title="dialogTitle"
|
||||
v-model:open="dialogVisible"
|
||||
width="60%"
|
||||
:footer="null"
|
||||
>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
:model="queryParams"
|
||||
class="-mb-15px"
|
||||
>
|
||||
<a-form-item v-if="!props.productId" label="产品" name="productId">
|
||||
<a-select
|
||||
v-model:value="queryParams.productId"
|
||||
placeholder="请选择产品"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:value="product.id"
|
||||
>
|
||||
{{ product.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="DeviceName" name="deviceName">
|
||||
<a-input
|
||||
v-model:value="queryParams.deviceName"
|
||||
placeholder="请输入 DeviceName"
|
||||
allow-clear
|
||||
@pressEnter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注名称" name="nickname">
|
||||
<a-input
|
||||
v-model:value="queryParams.nickname"
|
||||
placeholder="请输入备注名称"
|
||||
allow-clear
|
||||
@pressEnter="handleQuery"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="deviceType">
|
||||
<a-select
|
||||
v-model:value="queryParams.deviceType"
|
||||
placeholder="请选择设备类型"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备状态" name="status">
|
||||
<a-select
|
||||
v-model:value="queryParams.status"
|
||||
placeholder="请选择设备状态"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备分组" name="groupId">
|
||||
<a-select
|
||||
v-model:value="queryParams.groupId"
|
||||
placeholder="请选择设备分组"
|
||||
allow-clear
|
||||
style="width: 240px"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="group in deviceGroups"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button @click="handleQuery">
|
||||
<Icon class="mr-5px" icon="ep:search" />
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery">
|
||||
<Icon class="mr-5px" icon="ep:refresh" />
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<a-table
|
||||
ref="tableRef"
|
||||
:loading="loading"
|
||||
:dataSource="list"
|
||||
:columns="columns"
|
||||
:pagination="false"
|
||||
:row-selection="multiple ? rowSelection : undefined"
|
||||
@row-click="handleRowClick"
|
||||
:row-key="(record: IotDeviceApi.Device) => record.id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'radio'">
|
||||
<a-radio
|
||||
:checked="selectedId === record.id"
|
||||
@click="() => handleRadioChange(record)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'productId'">
|
||||
{{ 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" />
|
||||
</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">
|
||||
{{ 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" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'onlineTime'">
|
||||
{{ dateFormatter(null, null, record.onlineTime) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNo"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<template #footer>
|
||||
<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>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- 设备配置 -->
|
||||
<template>
|
||||
<div>
|
||||
<a-alert
|
||||
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
|
||||
type="info"
|
||||
show-icon
|
||||
class="my-4"
|
||||
description="如需编辑文件,请点击下方编辑按钮"
|
||||
/>
|
||||
<JsonEditor
|
||||
v-model="config"
|
||||
:mode="isEditing ? 'code' : 'view'"
|
||||
height="600px"
|
||||
@error="onError"
|
||||
/>
|
||||
<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>
|
||||
<a-button v-else @click="enableEdit">编辑</a-button>
|
||||
<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>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!-- 设备信息(头部) -->
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<!-- 右上:按钮 -->
|
||||
<a-button
|
||||
v-if="product.status === 0"
|
||||
v-hasPermi="['iot:device:update']"
|
||||
@click="openForm('update', device.id)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="mt-4">
|
||||
<a-descriptions :column="1">
|
||||
<a-descriptions-item label="产品">
|
||||
<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>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<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>
|
||||
@@ -0,0 +1,179 @@
|
||||
<!-- 设备信息 -->
|
||||
<template>
|
||||
<div>
|
||||
<a-row :gutter="16">
|
||||
<!-- 左侧设备信息 -->
|
||||
<a-col :span="12">
|
||||
<a-card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:info-filled" class="mr-2 text-primary" />
|
||||
<span>设备信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="产品名称">
|
||||
{{ product.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="DeviceName">
|
||||
{{ device.deviceName }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="备注名称">
|
||||
{{ device.nickname || '--' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前状态">
|
||||
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.state" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(device.createTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="激活时间">
|
||||
{{ formatDate(device.activeTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后上线时间">
|
||||
{{ formatDate(device.onlineTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后离线时间">
|
||||
{{ formatDate(device.offlineTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="MQTT 连接参数">
|
||||
<a-button type="link" @click="handleAuthInfoDialogOpen" size="small">
|
||||
查看
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧地图 -->
|
||||
<a-col :span="12">
|
||||
<a-card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:location" class="mr-2 text-primary" />
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<Icon icon="ep:warning" class="mr-2" />
|
||||
<span>暂无位置信息</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 认证信息弹框 -->
|
||||
<a-modal
|
||||
v-model:open="authDialogVisible"
|
||||
title="MQTT 连接参数"
|
||||
width="640px"
|
||||
:footer="null"
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<Icon icon="ph:copy" />
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="password">
|
||||
<a-input-group compact>
|
||||
<a-input
|
||||
v-model:value="authInfo.password"
|
||||
readonly
|
||||
:type="authPasswordVisible ? 'text' : 'password'"
|
||||
style="width: calc(100% - 160px)"
|
||||
/>
|
||||
<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">
|
||||
<Icon icon="ph:copy" />
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div class="text-right mt-4">
|
||||
<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>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!-- 设备消息列表 -->
|
||||
<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-option
|
||||
v-for="item in methodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-select
|
||||
v-model:value="queryParams.upstream"
|
||||
placeholder="上行/下行"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option label="上行" value="true">上行</a-select-option>
|
||||
<a-select-option label="下行" value="false">下行</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<Icon icon="ep:search" class="mr-5px" /> 搜索
|
||||
</a-button>
|
||||
<a-switch
|
||||
v-model:checked="autoRefresh"
|
||||
class="ml-20px"
|
||||
checked-children="定时刷新"
|
||||
un-checked-children="定时刷新"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<a-table :loading="loading" :dataSource="list" :columns="columns" :pagination="false" class="whitespace-nowrap">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'ts'">
|
||||
{{ formatDate(record.ts) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'upstream'">
|
||||
<a-tag :color="record.upstream ? 'blue' : 'green'">
|
||||
{{ record.upstream ? '上行' : '下行' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === '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 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'params'">
|
||||
<span v-if="record.reply">
|
||||
{{ `{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}` }}
|
||||
</span>
|
||||
<span v-else>{{ record.params }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-10px flex justify-end">
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getMessageList"
|
||||
/>
|
||||
</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>
|
||||
@@ -0,0 +1,485 @@
|
||||
<!-- 模拟设备 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<a-row :gutter="20">
|
||||
<!-- 左侧指令调试区域 -->
|
||||
<a-col :span="12">
|
||||
<a-card>
|
||||
<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-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_POST.method" tab="属性上报">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.property?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-input
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
设置属性值后,点击「发送属性上报」按钮
|
||||
</span>
|
||||
<a-button type="primary" @click="handlePropertyPost">发送属性上报</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 事件上报 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.EVENT_POST.method" tab="事件上报">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="eventList" :columns="eventColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.event?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-textarea
|
||||
:value="getFormValue(record.identifier)"
|
||||
@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>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 状态变更 -->
|
||||
<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>
|
||||
<a-button danger @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
|
||||
设备下线
|
||||
</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 下行指令调试 -->
|
||||
<a-tab-pane key="downstream" tab="下行指令调试">
|
||||
<a-tabs v-if="activeTab === 'downstream'" v-model:active-key="downstreamTab">
|
||||
<!-- 属性调试 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_SET.method" tab="属性设置">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataType'">
|
||||
{{ record.property?.dataType ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-input
|
||||
:value="getFormValue(record.identifier)"
|
||||
@update:value="setFormValue(record.identifier, $event)"
|
||||
placeholder="输入值"
|
||||
size="small"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<span class="text-sm text-gray-600">
|
||||
设置属性值后,点击「发送属性设置」按钮
|
||||
</span>
|
||||
<a-button type="primary" @click="handlePropertySet">发送属性设置</a-button>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 服务调用 -->
|
||||
<a-tab-pane :key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method" tab="设备服务调用">
|
||||
<ContentWrap>
|
||||
<a-table :dataSource="serviceList" :columns="serviceColumns" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'dataDefinition'">
|
||||
<DataDefinition :data="record" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-textarea
|
||||
:value="getFormValue(record.identifier)"
|
||||
@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="handleServiceInvoke(record)"
|
||||
>
|
||||
服务调用
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</ContentWrap>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧设备日志区域 -->
|
||||
<a-col :span="12">
|
||||
<ContentWrap title="设备消息">
|
||||
<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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<a-tabs v-model:active-key="activeTab" class="thing-model-tabs">
|
||||
<a-tab-pane key="property" tab="设备属性(运行状态)">
|
||||
<DeviceDetailsThingModelProperty :device-id="deviceId" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="event" tab="设备事件上报">
|
||||
<DeviceDetailsThingModelEvent
|
||||
:device-id="props.deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="service" tab="设备服务调用">
|
||||
<DeviceDetailsThingModelService
|
||||
:device-id="deviceId"
|
||||
:thing-model-list="props.thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</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) {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.thing-model-tabs :deep(.ant-tabs-tabpane) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!-- 设备事件管理 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
@submit.prevent
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<a-form-item label="标识符" name="identifier">
|
||||
<a-select
|
||||
v-model:value="queryParams.identifier"
|
||||
placeholder="请选择事件标识符"
|
||||
allow-clear
|
||||
style="width: 240px;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="event in eventThingModels"
|
||||
:key="event.identifier"
|
||||
:value="event.identifier!"
|
||||
>
|
||||
{{ event.name }}({{ event.identifier }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围" name="times">
|
||||
<a-range-picker
|
||||
v-model:value="queryParams.times"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 360px;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:search" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery" style="margin-left: 8px;">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<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">
|
||||
<template #default="{ record }">
|
||||
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<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">
|
||||
<template #default="{ record }">
|
||||
{{ getEventName(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<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>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<!-- 设备属性管理 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<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"
|
||||
/>
|
||||
<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'">
|
||||
<IconifyIcon icon="ep:grid" />
|
||||
</a-button>
|
||||
<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;" />
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<template v-if="viewMode === 'card'">
|
||||
<a-row :gutter="16" v-loading="loading">
|
||||
<a-col
|
||||
v-for="item in list"
|
||||
:key="item.identifier"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<a-card
|
||||
class="h-full transition-colors relative overflow-hidden"
|
||||
: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">
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="mr-2.5 flex items-center">
|
||||
<IconifyIcon icon="ep:cpu" class="text-[18px] text-[#0070ff]" />
|
||||
</div>
|
||||
<div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
|
||||
<!-- 标识符 -->
|
||||
<div class="inline-flex items-center mr-2">
|
||||
<a-tag size="small" color="blue">
|
||||
{{ item.identifier }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<!-- 数据类型标签 -->
|
||||
<div class="inline-flex items-center mr-2">
|
||||
<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)"
|
||||
>
|
||||
<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">
|
||||
{{ 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]">
|
||||
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<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="name" />
|
||||
<a-table-column title="数据类型" align="center" data-index="dataType" />
|
||||
<a-table-column title="属性值" align="center" data-index="value">
|
||||
<template #default="{ record }">
|
||||
{{ formatValueWithUnit(record) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column
|
||||
title="更新时间"
|
||||
align="center"
|
||||
data-index="updateTime"
|
||||
:width="180"
|
||||
>
|
||||
<template #default="{ record }">
|
||||
{{ record.updateTime ? formatDate(record.updateTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" align="center">
|
||||
<template #default="{ record }">
|
||||
<a-button
|
||||
type="link"
|
||||
@click="openHistory(props.deviceId, record.identifier, record.dataType)"
|
||||
>
|
||||
查看数据
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<DeviceDetailsThingModelPropertyHistory ref="historyRef" :deviceId="props.deviceId" />
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 移除 a-row 的额外边距 */
|
||||
:deep(.ant-row) {
|
||||
margin-left: -8px !important;
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="dialogVisible"
|
||||
title="查看数据"
|
||||
width="1200px"
|
||||
:destroy-on-close="true"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="property-history-container">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar-wrapper mb-4">
|
||||
<a-space :size="12" class="w-full" wrap>
|
||||
<!-- 时间选择 -->
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
:show-time="{ format: 'HH:mm:ss' }"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
:placeholder="['开始时间', '结束时间']"
|
||||
class="!w-[400px]"
|
||||
@change="handleTimeChange"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<a-button @click="handleExport" :loading="exporting" :disabled="list.length === 0">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:export-outlined" />
|
||||
</template>
|
||||
导出
|
||||
</a-button>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<a-button-group class="ml-auto">
|
||||
<a-button
|
||||
:type="viewMode === 'chart' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'chart'"
|
||||
:disabled="isComplexDataType"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:line-chart-outlined" />
|
||||
</template>
|
||||
图表
|
||||
</a-button>
|
||||
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:table-outlined" />
|
||||
</template>
|
||||
列表
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
</a-space>
|
||||
|
||||
<!-- 数据统计信息 -->
|
||||
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
|
||||
<a-space :size="16">
|
||||
<span>共 {{ total }} 条数据</span>
|
||||
<span v-if="viewMode === 'chart' && !isComplexDataType">
|
||||
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值: {{ avgValue }}
|
||||
</span>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<a-spin :spinning="loading" :delay="200">
|
||||
<!-- 图表模式 -->
|
||||
<div v-if="viewMode === 'chart'" class="chart-container">
|
||||
<a-empty
|
||||
v-if="list.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无数据"
|
||||
class="py-20"
|
||||
/>
|
||||
<EchartsUI v-else ref="chartRef" height="500px" />
|
||||
</div>
|
||||
|
||||
<!-- 表格模式 -->
|
||||
<div v-else class="table-container">
|
||||
<a-table
|
||||
:dataSource="list"
|
||||
:columns="tableColumns"
|
||||
:pagination="paginationConfig"
|
||||
:scroll="{ y: 500 }"
|
||||
row-key="updateTime"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'updateTime'">
|
||||
{{ formatDate(new Date(record.updateTime)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'value'">
|
||||
<a-tag v-if="isComplexDataType" color="processing">
|
||||
{{ formatComplexValue(record.value) }}
|
||||
</a-tag>
|
||||
<span v-else class="font-medium">{{ record.value }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</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 {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
.toolbar-wrapper {
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-container,
|
||||
.table-container {
|
||||
padding: 16px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<!-- 设备服务调用 -->
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<a-form
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
layout="inline"
|
||||
@submit.prevent
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<a-form-item label="标识符" name="identifier">
|
||||
<a-select
|
||||
v-model:value="queryParams.identifier"
|
||||
placeholder="请选择服务标识符"
|
||||
allow-clear
|
||||
style="width: 240px;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="service in serviceThingModels"
|
||||
:key="service.identifier"
|
||||
:value="service.identifier!"
|
||||
>
|
||||
{{ service.name }}({{ service.identifier }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围" name="times">
|
||||
<a-range-picker
|
||||
v-model:value="queryParams.times"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 360px;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleQuery">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:search" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="resetQuery" style="margin-left: 8px;">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="ep:refresh" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<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">
|
||||
<template #default="{ record }">
|
||||
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="响应时间" align="center" data-index="responseTime" :width="180">
|
||||
<template #default="{ record }">
|
||||
{{ record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-' }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<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">
|
||||
<template #default="{ record }">
|
||||
{{ getServiceName(record.request?.identifier) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<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>
|
||||
</a-table-column>
|
||||
<a-table-column title="输出参数" align="center" data-index="outputParams">
|
||||
<template #default="{ record }">
|
||||
<span v-if="record.reply">
|
||||
{{
|
||||
`{"code":${record.reply.code},"msg":"${record.reply.msg}","data":${record.reply.data}\}`
|
||||
}}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Page>
|
||||
<DeviceDetailsHeader
|
||||
:loading="loading"
|
||||
:product="product"
|
||||
: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" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="model" tab="物模型数据">
|
||||
<DeviceDetailsThingModel
|
||||
v-if="activeTab === 'model' && device.id"
|
||||
:device-id="device.id"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<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" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="simulator" tab="模拟设备">
|
||||
<DeviceDetailsSimulator
|
||||
v-if="activeTab === 'simulator'"
|
||||
:product="product"
|
||||
:device="device"
|
||||
:thing-model-list="thingModelList"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="config" tab="设备配置">
|
||||
<DeviceDetailConfig
|
||||
v-if="activeTab === 'config'"
|
||||
:device="device"
|
||||
@success="getDeviceData"
|
||||
/>
|
||||
</a-tab-pane>
|
||||
</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>
|
||||
|
||||
114
apps/web-antd/src/views/iot/device/group/data.ts
Normal file
114
apps/web-antd/src/views/iot/device/group/data.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||
|
||||
/** 新增/修改设备分组的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分组名称不能为空')
|
||||
.max(64, '分组名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'parentId',
|
||||
label: '父级分组',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: getSimpleDeviceGroupList,
|
||||
fieldNames: {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
},
|
||||
placeholder: '请选择父级分组',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '分组描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分组名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分组名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
field: 'name',
|
||||
title: '分组名称',
|
||||
minWidth: 200,
|
||||
treeNode: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '分组描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'deviceCount',
|
||||
title: '设备数量',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,28 +1,141 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { deleteDeviceGroup, getDeviceGroupPage } from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useGridColumns, useGridFormSchema } from './data';
|
||||
import DeviceGroupForm from './modules/device-group-form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroup' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: DeviceGroupForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function onRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建设备分组 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑设备分组 */
|
||||
function handleEdit(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除设备分组 */
|
||||
async function handleDelete(row: IotDeviceGroupApi.DeviceGroup) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteDeviceGroup(row.id as number);
|
||||
message.success({
|
||||
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
|
||||
});
|
||||
onRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
treeConfig: {
|
||||
transform: true,
|
||||
rowField: 'id',
|
||||
parentField: 'parentId',
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
const data = await getDeviceGroupPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
// 转换为树形结构
|
||||
return {
|
||||
...data,
|
||||
list: handleTree(data.list, 'id', 'parentId'),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<IotDeviceGroupApi.DeviceGroup>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/group/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/device/group/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="onRefresh" />
|
||||
<Grid table-title="设备分组列表">
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['设备分组']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:device-group:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:device-group:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device-group:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createDeviceGroup,
|
||||
getDeviceGroup,
|
||||
updateDeviceGroup,
|
||||
} from '#/api/iot/device/group';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTDeviceGroupForm' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [];
|
||||
}>();
|
||||
|
||||
const formData = ref<IotDeviceGroupApi.DeviceGroup>();
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['设备分组'])
|
||||
: $t('ui.actionTitle.create', ['设备分组']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
schema: useFormSchema(),
|
||||
showCollapseButton: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
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 modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
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 {
|
||||
formData.value = await getDeviceGroup(data.id);
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user