!315 refactor:【antd】【iot】设备管理跟后端对齐,必要的 ReqVO、RespVO,子设备管理实现

Merge pull request !315 from haohaoMT/dev
This commit is contained in:
芋道源码
2026-01-05 12:10:39 +00:00
committed by Gitee
16 changed files with 537 additions and 141 deletions

View File

@@ -3,39 +3,76 @@ import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export namespace IotDeviceApi { export namespace IotDeviceApi {
// TODO @haohao需要跟后端对齐必要的 ReqVO、RespVO /** 设备新增/修改 Request VO */
/** 设备 */ export interface DeviceSaveReqVO {
export interface Device { id?: number; // 设备编号
id?: number; // 设备 ID主键自增
deviceName: string; // 设备名称 deviceName: string; // 设备名称
nickname?: string; // 备注名称
serialNumber?: string; // 设备序列号
picUrl?: string; // 设备图片
groupIds?: number[]; // 设备分组编号数组
productId: number; // 产品编号(必填)
gatewayId?: number; // 网关设备 ID
config?: string; // 设备配置
locationType: number; // 定位类型(必填)
latitude?: number; // 设备位置的纬度
longitude?: number; // 设备位置的经度
}
/** 设备更新分组 Request VO */
export interface DeviceUpdateGroupReqVO {
ids: number[]; // 设备编号列表(必填)
groupIds: number[]; // 分组编号列表(必填)
}
/** 设备分页 Request VO */
export interface DevicePageReqVO extends PageParam {
deviceName?: string; // 设备名称
nickname?: string; // 备注名称
productId?: number; // 产品编号
deviceType?: number; // 设备类型
status?: number; // 设备状态
groupId?: number; // 设备分组编号
gatewayId?: number; // 网关设备 ID
}
/** 设备 Response VO */
export interface DeviceRespVO {
id: number; // 设备编号
deviceName: string; // 设备名称
nickname?: string; // 设备备注名称
serialNumber?: string; // 设备序列号
picUrl?: string; // 设备图片
groupIds?: number[]; // 设备分组编号数组
productId: number; // 产品编号 productId: number; // 产品编号
productKey?: string; // 产品标识 productKey?: string; // 产品标识
deviceType?: number; // 设备类型 deviceType?: number; // 设备类型
nickname?: string; // 设备备注名称
gatewayId?: number; // 网关设备 ID gatewayId?: number; // 网关设备 ID
state?: number; // 设备状态 state?: number; // 设备状态
status?: number; // 设备状态(兼容字段)
onlineTime?: Date; // 最后上线时间 onlineTime?: Date; // 最后上线时间
offlineTime?: Date; // 最后离线时间 offlineTime?: Date; // 最后离线时间
activeTime?: Date; // 设备激活时间 activeTime?: Date; // 设备激活时间
createTime?: Date; // 创建时间 deviceSecret?: string; // 设备密钥,用于设备认证
ip?: string; // 设备的 IP 地址 authType?: string; // 认证类型(如一机一密、动态注册)
firmwareVersion?: string; // 设备的固件版本 config?: string; // 设备配置
deviceSecret?: string; // 设备密钥,用于设备认证,需安全存储 locationType?: number; // 定位方式
mqttClientId?: string; // MQTT 客户端 ID
mqttUsername?: string; // MQTT 用户名
mqttPassword?: string; // MQTT 密码
authType?: string; // 认证类型
locationType?: number; // 定位类型
latitude?: number; // 设备位置的纬度 latitude?: number; // 设备位置的纬度
longitude?: number; // 设备位置的经度 longitude?: number; // 设备位置的经度
areaId?: number; // 地区编码 createTime?: Date; // 创建时间
address?: string; // 设备详细地址 }
serialNumber?: string; // 设备序列号
config?: string; // 设备配置 /** 设备认证信息 Response VO */
groupIds?: number[]; // 添加分组 ID export interface DeviceAuthInfoRespVO {
picUrl?: string; // 设备图片 clientId: string; // 客户端 ID
location?: string; // 位置信息(格式:经度,纬度) username: string; // 用户名
password: string; // 密码
}
/** 设备导入 Response VO */
export interface DeviceImportRespVO {
createDeviceNames?: string[]; // 创建成功的设备名称列表
updateDeviceNames?: string[]; // 更新成功的设备名称列表
failureDeviceNames?: Record<string, string>; // 失败的设备名称及原因
} }
/** IoT 设备属性详细 VO */ /** IoT 设备属性详细 VO */
@@ -56,30 +93,17 @@ export namespace IotDeviceApi {
updateTime: Date; // 更新时间 updateTime: Date; // 更新时间
} }
/** 设备认证参数 VO */
export interface DeviceAuthInfo {
clientId: string; // 客户端 ID
username: string; // 用户名
password: string; // 密码
}
/** 设备发送消息 Request VO */ /** 设备发送消息 Request VO */
export interface DeviceMessageSendReq { export interface DeviceMessageSendReq {
deviceId: number; // 设备编号 deviceId: number; // 设备编号
method: string; // 请求方法 method: string; // 请求方法
params?: any; // 请求参数 params?: any; // 请求参数
} }
/** 设备分组更新请求 */
export interface DeviceGroupUpdateReq {
ids: number[]; // 设备 ID 列表
groupIds: number[]; // 分组 ID 列表
}
} }
/** 查询设备分页 */ /** 查询设备分页 */
export function getDevicePage(params: PageParam) { export function getDevicePage(params: IotDeviceApi.DevicePageReqVO) {
return requestClient.get<PageResult<IotDeviceApi.Device>>( return requestClient.get<PageResult<IotDeviceApi.DeviceRespVO>>(
'/iot/device/page', '/iot/device/page',
{ params }, { params },
); );
@@ -87,38 +111,40 @@ export function getDevicePage(params: PageParam) {
/** 查询设备详情 */ /** 查询设备详情 */
export function getDevice(id: number) { export function getDevice(id: number) {
return requestClient.get<IotDeviceApi.Device>(`/iot/device/get?id=${id}`); return requestClient.get<IotDeviceApi.DeviceRespVO>(
`/iot/device/get?id=${id}`,
);
} }
/** 新增设备 */ /** 新增设备 */
export function createDevice(data: IotDeviceApi.Device) { export function createDevice(data: IotDeviceApi.DeviceSaveReqVO) {
return requestClient.post('/iot/device/create', data); return requestClient.post<number>('/iot/device/create', data);
} }
/** 修改设备 */ /** 修改设备 */
export function updateDevice(data: IotDeviceApi.Device) { export function updateDevice(data: IotDeviceApi.DeviceSaveReqVO) {
return requestClient.put('/iot/device/update', data); return requestClient.put<boolean>('/iot/device/update', data);
} }
/** 修改设备分组 */ /** 修改设备分组 */
export function updateDeviceGroup(data: IotDeviceApi.DeviceGroupUpdateReq) { export function updateDeviceGroup(data: IotDeviceApi.DeviceUpdateGroupReqVO) {
return requestClient.put('/iot/device/update-group', data); return requestClient.put<boolean>('/iot/device/update-group', data);
} }
/** 删除单个设备 */ /** 删除单个设备 */
export function deleteDevice(id: number) { export function deleteDevice(id: number) {
return requestClient.delete(`/iot/device/delete?id=${id}`); return requestClient.delete<boolean>(`/iot/device/delete?id=${id}`);
} }
/** 删除多个设备 */ /** 删除多个设备 */
export function deleteDeviceList(ids: number[]) { export function deleteDeviceList(ids: number[]) {
return requestClient.delete('/iot/device/delete-list', { return requestClient.delete<boolean>('/iot/device/delete-list', {
params: { ids: ids.join(',') }, params: { ids: ids.join(',') },
}); });
} }
/** 导出设备 */ /** 导出设备 */
export function exportDeviceExcel(params: any) { export function exportDeviceExcel(params: IotDeviceApi.DevicePageReqVO) {
return requestClient.download('/iot/device/export-excel', { params }); return requestClient.download('/iot/device/export-excel', { params });
} }
@@ -129,16 +155,22 @@ export function getDeviceCount(productId: number) {
/** 获取设备的精简信息列表 */ /** 获取设备的精简信息列表 */
export function getSimpleDeviceList(deviceType?: number, productId?: number) { export function getSimpleDeviceList(deviceType?: number, productId?: number) {
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', { return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
params: { deviceType, productId }, '/iot/device/simple-list',
}); {
params: { deviceType, productId },
},
);
} }
/** 根据产品编号,获取设备的精简信息列表 */ /** 根据产品编号,获取设备的精简信息列表 */
export function getDeviceListByProductId(productId: number) { export function getDeviceListByProductId(productId: number) {
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/simple-list', { return requestClient.get<IotDeviceApi.DeviceRespVO[]>(
params: { productId }, '/iot/device/simple-list',
}); {
params: { productId },
},
);
} }
/** 获取导入模板 */ /** 获取导入模板 */
@@ -148,10 +180,13 @@ export function importDeviceTemplate() {
/** 导入设备 */ /** 导入设备 */
export function importDevice(file: File, updateSupport: boolean) { export function importDevice(file: File, updateSupport: boolean) {
return requestClient.upload('/iot/device/import', { return requestClient.upload<IotDeviceApi.DeviceImportRespVO>(
file, '/iot/device/import',
updateSupport, {
}); file,
updateSupport,
},
);
} }
/** 获取设备属性最新数据 */ /** 获取设备属性最新数据 */
@@ -172,7 +207,7 @@ export function getHistoryDevicePropertyList(params: any) {
/** 获取设备认证信息 */ /** 获取设备认证信息 */
export function getDeviceAuthInfo(id: number) { export function getDeviceAuthInfo(id: number) {
return requestClient.get<IotDeviceApi.DeviceAuthInfo>( return requestClient.get<IotDeviceApi.DeviceAuthInfoRespVO>(
'/iot/device/get-auth-info', '/iot/device/get-auth-info',
{ params: { id } }, { params: { id } },
); );

View File

@@ -9,8 +9,8 @@ import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group'; import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product'; import { getSimpleProductList } from '#/api/iot/product/product';
/** 新增/修改的表单 */ /** 基础表单字段 */
export function useFormSchema(): VbenFormSchema[] { export function useBasicFormSchema(): VbenFormSchema[] {
return [ return [
{ {
component: 'Input', component: 'Input',
@@ -36,6 +36,14 @@ export function useFormSchema(): VbenFormSchema[] {
}, },
rules: 'required', rules: 'required',
}, },
{
component: 'Input',
fieldName: 'deviceType',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{ {
fieldName: 'deviceName', fieldName: 'deviceName',
label: 'DeviceName', label: 'DeviceName',
@@ -62,7 +70,7 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'ApiSelect', component: 'ApiSelect',
componentProps: { componentProps: {
api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY), api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY),
labelField: 'nickname', labelField: 'deviceName',
valueField: 'id', valueField: 'id',
placeholder: '子设备可选择父设备', placeholder: '子设备可选择父设备',
}, },
@@ -71,6 +79,12 @@ export function useFormSchema(): VbenFormSchema[] {
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB, show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
}, },
}, },
];
}
/** 高级设置表单字段(更多设置) */
export function useAdvancedFormSchema(): VbenFormSchema[] {
return [
{ {
fieldName: 'nickname', fieldName: 'nickname',
label: '备注名称', label: '备注名称',
@@ -89,6 +103,11 @@ export function useFormSchema(): VbenFormSchema[] {
.optional() .optional()
.or(z.literal('')), .or(z.literal('')),
}, },
{
fieldName: 'picUrl',
label: '设备图片',
component: 'ImageUpload',
},
{ {
fieldName: 'groupIds', fieldName: 'groupIds',
label: '设备分组', label: '设备分组',
@@ -278,6 +297,14 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
title: '备注名称', title: '备注名称',
minWidth: 120, minWidth: 120,
}, },
{
field: 'picUrl',
title: '设备图片',
width: 100,
cellRender: {
name: 'CellImage',
},
},
{ {
field: 'productId', field: 'productId',
title: '所属产品', title: '所属产品',

View File

@@ -31,7 +31,7 @@ const router = useRouter();
const id = Number(route.params.id); const id = Number(route.params.id);
const loading = ref(true); const loading = ref(true);
const product = ref<IotProductApi.Product>({} as IotProductApi.Product); const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device); const device = ref<IotDeviceApi.DeviceRespVO>({} as IotDeviceApi.DeviceRespVO);
const activeTab = ref('info'); const activeTab = ref('info');
const thingModelList = ref<ThingModelData[]>([]); const thingModelList = ref<ThingModelData[]>([]);

View File

@@ -12,7 +12,7 @@ import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceDetailConfig' }); defineOptions({ name: 'DeviceDetailConfig' });
const props = defineProps<{ const props = defineProps<{
device: IotDeviceApi.Device; device: IotDeviceApi.DeviceRespVO;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -114,7 +114,7 @@ async function updateDeviceConfig() {
await updateDevice({ await updateDevice({
id: props.device.id, id: props.device.id,
config: JSON.stringify(config.value), config: JSON.stringify(config.value),
} as IotDeviceApi.Device); } as IotDeviceApi.DeviceSaveReqVO);
message.success({ content: '更新成功!' }); message.success({ content: '更新成功!' });
// 触发 success 事件 // 触发 success 事件
emit('success'); emit('success');

View File

@@ -12,7 +12,7 @@ import DeviceForm from '../../modules/form.vue';
interface Props { interface Props {
product: IotProductApi.Product; product: IotProductApi.Product;
device: IotDeviceApi.Device; device: IotDeviceApi.DeviceRespVO;
loading?: boolean; loading?: boolean;
} }
@@ -50,7 +50,7 @@ function goToProductDetail(productId: number | undefined) {
} }
/** 打开编辑表单 */ /** 打开编辑表单 */
function openEditForm(row: IotDeviceApi.Device) { function openEditForm(row: IotDeviceApi.DeviceRespVO) {
formModalApi.setData(row).open(); formModalApi.setData(row).open();
} }
</script> </script>

View File

@@ -24,7 +24,7 @@ import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
interface Props { interface Props {
device: IotDeviceApi.Device; device: IotDeviceApi.DeviceRespVO;
product: IotProductApi.Product; product: IotProductApi.Product;
} }
@@ -32,8 +32,8 @@ const props = defineProps<Props>();
const authDialogVisible = ref(false); const authDialogVisible = ref(false);
const authPasswordVisible = ref(false); const authPasswordVisible = ref(false);
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>( const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
{} as IotDeviceApi.DeviceAuthInfo, {} as IotDeviceApi.DeviceAuthInfoRespVO,
); );
/** 控制地图显示的标志 */ /** 控制地图显示的标志 */
@@ -75,8 +75,7 @@ function handleAuthInfoDialogClose() {
<Card class="h-full"> <Card class="h-full">
<template #title> <template #title>
<div class="flex items-center"> <div class="flex items-center">
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 --> <IconifyIcon class="mr-2 text-primary" icon="lucide:info" />
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
<span>设备信息</span> <span>设备信息</span>
</div> </div>
</template> </template>
@@ -142,8 +141,7 @@ function handleAuthInfoDialogClose() {
<template #title> <template #title>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 --> <IconifyIcon class="mr-2 text-primary" icon="lucide:map-pin" />
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
<span>设备位置</span> <span>设备位置</span>
</div> </div>
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
@@ -162,8 +160,7 @@ function handleAuthInfoDialogClose() {
v-else v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400" class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
> >
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 --> <IconifyIcon class="mr-2" icon="lucide:alert-triangle" />
<IconifyIcon class="mr-2" icon="ep:warning" />
<span>暂无位置信息</span> <span>暂无位置信息</span>
</div> </div>
</div> </div>

View File

@@ -34,7 +34,7 @@ import DataDefinition from '../../../../thingmodel/modules/components/data-defin
import DeviceDetailsMessage from './message.vue'; import DeviceDetailsMessage from './message.vue';
const props = defineProps<{ const props = defineProps<{
device: IotDeviceApi.Device; device: IotDeviceApi.DeviceRespVO;
product: IotProductApi.Product; product: IotProductApi.Product;
thingModelList: ThingModelData[]; thingModelList: ThingModelData[];
}>(); }>();

View File

@@ -1,45 +1,223 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { Card, Empty } from 'ant-design-vue'; import { onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
// TODO @haohao这里要实现一把么 import { Page } from '@vben/common-ui';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { Button, Input, Select, Space } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDevicePage } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
interface Props { interface Props {
deviceId: number; deviceId: number;
} }
defineProps<Props>(); const props = defineProps<Props>();
const router = useRouter();
const loading = ref(false); /** 产品列表 */
const subDevices = ref<any[]>([]); const products = ref<IotProductApi.Product[]>([]);
/** 获取子设备列表 */ /** 查询参数 */
async function getSubDeviceList() { const queryParams = reactive({
loading.value = true; deviceName: '',
try { status: undefined as number | undefined,
// TODO: 实现获取子设备列表的API调用 });
// const data = await getSubDevicesByGatewayId(deviceId);
// subDevices.value = data || []; /** Grid 列定义 */
subDevices.value = []; function useGridColumns(): VxeTableGridOptions['columns'] {
} catch (error) { return [
console.error('获取子设备列表失败:', error); {
} finally { field: 'deviceName',
loading.value = false; title: 'DeviceName',
} minWidth: 150,
},
{
field: 'nickname',
title: '备注名称',
minWidth: 120,
},
{
field: 'productId',
title: '所属产品',
minWidth: 120,
slots: { default: 'product' },
},
{
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
{
field: 'onlineTime',
title: '最后上线时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
} }
onMounted(() => { /** 创建 Grid 实例 */
getSubDeviceList(); const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
rowConfig: {
keyField: 'id',
isHover: true,
},
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
gatewayId: props.deviceId,
deviceType: DeviceTypeEnum.GATEWAY_SUB,
deviceName: queryParams.deviceName || undefined,
status: queryParams.status,
} as IotDeviceApi.DevicePageReqVO);
},
},
},
toolbarConfig: {
refresh: true,
search: false,
},
pagerConfig: {
enabled: true,
},
},
});
/** 搜索操作 */
function handleQuery() {
gridApi.query();
}
/** 重置搜索 */
function resetQuery() {
queryParams.deviceName = '';
queryParams.status = undefined;
handleQuery();
}
/** 获取产品名称 */
function getProductName(productId: number) {
const product = products.value.find((p) => p.id === productId);
return product?.name || '-';
}
/** 查看详情 */
function openDetail(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id } });
}
/** 监听设备ID变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 如果设备ID存在则查询列表
if (props.deviceId) {
handleQuery();
}
}); });
</script> </script>
<template> <template>
<Card :loading="loading" title="子设备管理"> <Page auto-content-height>
<Empty <!-- 搜索区域 -->
:image="Empty.PRESENTED_IMAGE_SIMPLE" <div class="mb-4 flex flex-wrap items-center gap-3">
description="暂无子设备数据,此功能待实现" <Input
/> v-model:value="queryParams.deviceName"
<!-- TODO: 实现子设备列表展示和管理功能 --> placeholder="请输入设备名称"
</Card> style="width: 200px"
allow-clear
@press-enter="handleQuery"
/>
<Select
v-model:value="queryParams.status"
allow-clear
placeholder="请选择设备状态"
style="width: 160px"
>
<Select.Option
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
<Space>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" />
搜索
</Button>
<Button @click="resetQuery">
<IconifyIcon icon="ep:refresh-right" class="mr-5px" />
重置
</Button>
</Space>
</div>
<!-- 子设备列表 -->
<Grid>
<!-- 所属产品列 -->
<template #product="{ row }">
{{ getProductName(row.productId) }}
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看详情',
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: openDetail.bind(null, row.id!),
},
]"
/>
</template>
</Grid>
</Page>
</template> </template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device'; import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group'; import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product'; import type { IotProductApi } from '#/api/iot/product/product';
@@ -51,6 +50,9 @@ const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref(); const cardViewRef = ref();
const checkedIds = ref<number[]>([]); const checkedIds = ref<number[]>([]);
/** 判断是否为列表视图 */
const isListView = () => viewMode.value === 'list';
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({ const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
connectedComponent: DeviceForm, connectedComponent: DeviceForm,
destroyOnClose: true, destroyOnClose: true,
@@ -66,13 +68,13 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
destroyOnClose: true, destroyOnClose: true,
}); });
const queryParams = ref({ const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
deviceName: '', deviceName: '',
nickname: '', nickname: '',
productId: undefined as number | undefined, productId: undefined,
deviceType: undefined as number | undefined, deviceType: undefined,
status: undefined as number | undefined, status: undefined,
groupId: undefined as number | undefined, groupId: undefined,
}); // 搜索参数 }); // 搜索参数
/** 搜索 */ /** 搜索 */
@@ -112,7 +114,11 @@ async function handleViewModeChange(mode: 'card' | 'list') {
/** 导出表格 */ /** 导出表格 */
async function handleExport() { async function handleExport() {
const data = await exportDeviceExcel(queryParams.value); const data = await exportDeviceExcel({
...queryParams.value,
pageNo: 1,
pageSize: 999999,
} as IotDeviceApi.DevicePageReqVO);
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data }); downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
} }
@@ -141,12 +147,12 @@ function handleCreate() {
} }
/** 编辑设备 */ /** 编辑设备 */
function handleEdit(row: IotDeviceApi.Device) { function handleEdit(row: IotDeviceApi.DeviceRespVO) {
deviceFormModalApi.setData(row).open(); deviceFormModalApi.setData(row).open();
} }
/** 删除设备 */ /** 删除设备 */
async function handleDelete(row: IotDeviceApi.Device) { async function handleDelete(row: IotDeviceApi.DeviceRespVO) {
const hideLoading = message.loading({ const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.deviceName]), content: $t('ui.actionMessage.deleting', [row.deviceName]),
duration: 0, duration: 0,
@@ -197,12 +203,12 @@ function handleImport() {
function handleRowCheckboxChange({ function handleRowCheckboxChange({
records, records,
}: { }: {
records: IotDeviceApi.Device[]; records: IotDeviceApi.DeviceRespVO[];
}) { }) {
checkedIds.value = records.map((item) => item.id!); checkedIds.value = records.map((item) => item.id!);
} }
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.DeviceRespVO>({
gridOptions: { gridOptions: {
checkboxConfig: { checkboxConfig: {
highlight: true, highlight: true,
@@ -213,12 +219,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }) => { query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
return await getDevicePage({ return await getDevicePage({
pageNo: page.currentPage, pageNo: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,
...queryParams.value, ...queryParams.value,
}); } as IotDeviceApi.DevicePageReqVO);
}, },
}, },
}, },
@@ -230,7 +240,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true, refresh: true,
search: true, search: true,
}, },
} as VxeTableGridOptions<IotDeviceApi.Device>, },
gridEvents: { gridEvents: {
checkboxAll: handleRowCheckboxChange, checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange, checkboxChange: handleRowCheckboxChange,
@@ -388,7 +398,7 @@ onMounted(async () => {
type: 'primary', type: 'primary',
icon: 'ant-design:folder-add-outlined', icon: 'ant-design:folder-add-outlined',
auth: ['iot:device:update'], auth: ['iot:device:update'],
ifShow: () => viewMode === 'list', ifShow: isListView,
disabled: isEmpty(checkedIds), disabled: isEmpty(checkedIds),
onClick: handleAddToGroup, onClick: handleAddToGroup,
}, },
@@ -398,7 +408,7 @@ onMounted(async () => {
danger: true, danger: true,
icon: ACTION_ICON.DELETE, icon: ACTION_ICON.DELETE,
auth: ['iot:device:delete'], auth: ['iot:device:delete'],
ifShow: () => viewMode === 'list', ifShow: isListView,
disabled: isEmpty(checkedIds), disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch, onClick: handleDeleteBatch,
}, },
@@ -490,7 +500,14 @@ onMounted(async () => {
ref="cardViewRef" ref="cardViewRef"
:products="products" :products="products"
:device-groups="deviceGroups" :device-groups="deviceGroups"
:search-params="queryParams" :search-params="{
deviceName: queryParams.deviceName || '',
nickname: queryParams.nickname || '',
productId: queryParams.productId,
deviceType: queryParams.deviceType,
status: queryParams.status,
groupId: queryParams.groupId,
}"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit" @edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants'; import { DICT_TYPE } from '@vben/constants';
@@ -9,6 +11,7 @@ import {
Card, Card,
Col, Col,
Empty, Empty,
Image,
Pagination, Pagination,
Popconfirm, Popconfirm,
Row, Row,
@@ -43,9 +46,9 @@ const emit = defineEmits<{
}>(); }>();
const loading = ref(false); const loading = ref(false);
const list = ref<any[]>([]); const list = ref<IotDeviceApi.DeviceRespVO[]>([]);
const total = ref(0); const total = ref(0);
const queryParams = ref({ const queryParams = ref<Partial<IotDeviceApi.DevicePageReqVO>>({
pageNo: 1, pageNo: 1,
pageSize: 12, pageSize: 12,
}); });
@@ -63,7 +66,7 @@ async function getList() {
const data = await getDevicePage({ const data = await getDevicePage({
...queryParams.value, ...queryParams.value,
...props.searchParams, ...props.searchParams,
}); } as IotDeviceApi.DevicePageReqVO);
list.value = data.list || []; list.value = data.list || [];
total.value = data.total || 0; total.value = data.total || 0;
} finally { } finally {
@@ -128,8 +131,8 @@ onMounted(() => {
/> />
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="mb-3"> <div class="mb-3 flex items-start">
<div class="info-list"> <div class="info-list flex-1">
<div class="info-item"> <div class="info-item">
<span class="info-label">所属产品</span> <span class="info-label">所属产品</span>
<a <a
@@ -154,13 +157,27 @@ onMounted(() => {
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Deviceid</span> <span class="info-label">Deviceid</span>
<Tooltip :title="item.Deviceid || item.id" placement="top"> <Tooltip :title="String(item.id)" placement="top">
<span class="info-value device-id cursor-pointer"> <span class="info-value device-id cursor-pointer">
{{ item.Deviceid || item.id }} {{ item.id }}
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<!-- 设备图片 -->
<div class="device-image">
<Image
v-if="item.picUrl"
:src="item.picUrl"
:preview="true"
class="size-full rounded object-cover"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div>
</div> </div>
<!-- 按钮组 --> <!-- 按钮组 -->
<div class="action-buttons"> <div class="action-buttons">
@@ -263,6 +280,19 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
} }
// 设备图片
.device-image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
// 信息列表 // 信息列表
.info-list { .info-list {
.info-item { .info-item {
@@ -385,6 +415,11 @@ html.dark {
color: rgb(255 255 255 / 75%); color: rgb(255 255 255 / 75%);
} }
} }
.device-image {
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
} }
} }
} }

View File

@@ -1,22 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device'; import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, ref } from 'vue'; import { computed, nextTick, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue'; import { Collapse, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device'; import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { useFormSchema } from '../data'; import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceForm' }); defineOptions({ name: 'IoTDeviceForm' });
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
const formData = ref<IotDeviceApi.Device>(); const formData = ref<IotDeviceApi.DeviceRespVO>();
const products = ref<IotProductApi.Product[]>([]);
const activeKey = ref<string[]>([]);
const getTitle = computed(() => { const getTitle = computed(() => {
return formData.value?.id return formData.value?.id
? $t('ui.actionTitle.edit', ['设备']) ? $t('ui.actionTitle.edit', ['设备'])
@@ -31,10 +36,54 @@ const [Form, formApi] = useVbenForm({
}, },
wrapperClass: 'grid-cols-1', wrapperClass: 'grid-cols-1',
layout: 'horizontal', layout: 'horizontal',
schema: useFormSchema(), schema: useBasicFormSchema(),
showDefaultActions: false,
handleValuesChange: async (values, changedFields) => {
// 当产品 ProductId 变化时,自动设置设备类型
if (changedFields.includes('productId')) {
const productId = values.productId;
if (!productId) {
await formApi.setFieldValue('deviceType', undefined);
return;
}
// 从产品列表中查找产品
const product = products.value.find((p) => p.id === productId);
if (product?.deviceType !== undefined) {
await formApi.setFieldValue('deviceType', product.deviceType);
}
}
},
});
const [AdvancedForm, advancedFormApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
wrapperClass: 'grid-cols-1',
layout: 'horizontal',
schema: useAdvancedFormSchema(),
showDefaultActions: false, showDefaultActions: false,
}); });
/** 获取高级表单的值(如果表单未挂载,则从 formData 中获取) */
async function getAdvancedFormValues() {
if (advancedFormApi.isMounted) {
return await advancedFormApi.getValues();
}
// 表单未挂载(折叠状态),从 formData 中获取
return {
nickname: formData.value?.nickname,
picUrl: formData.value?.picUrl,
groupIds: formData.value?.groupIds,
serialNumber: formData.value?.serialNumber,
locationType: formData.value?.locationType,
longitude: formData.value?.longitude,
latitude: formData.value?.latitude,
};
}
const [Modal, modalApi] = useVbenModal({ const [Modal, modalApi] = useVbenModal({
async onConfirm() { async onConfirm() {
const { valid } = await formApi.validate(); const { valid } = await formApi.validate();
@@ -42,8 +91,13 @@ const [Modal, modalApi] = useVbenModal({
return; return;
} }
modalApi.lock(); modalApi.lock();
// 提交表单 // 合并两个表单的值(字段不冲突,可直接合并)
const data = (await formApi.getValues()) as IotDeviceApi.Device; const basicValues = await formApi.getValues();
const advancedValues = await getAdvancedFormValues();
const data = {
...basicValues,
...advancedValues,
} as IotDeviceApi.DeviceSaveReqVO;
try { try {
await (formData.value?.id ? updateDevice(data) : createDevice(data)); await (formData.value?.id ? updateDevice(data) : createDevice(data));
// 关闭并提示 // 关闭并提示
@@ -57,11 +111,14 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) { async onOpenChange(isOpen: boolean) {
if (!isOpen) { if (!isOpen) {
formData.value = undefined; formData.value = undefined;
activeKey.value = [];
return; return;
} }
// 加载数据 // 加载数据
const data = modalApi.getData<IotDeviceApi.Device>(); const data = modalApi.getData<IotDeviceApi.DeviceRespVO>();
if (!data || !data.id) { if (!data || !data.id) {
// 新增:确保 Collapse 折叠
activeKey.value = [];
return; return;
} }
// 编辑模式:加载数据 // 编辑模式:加载数据
@@ -69,15 +126,43 @@ const [Modal, modalApi] = useVbenModal({
try { try {
formData.value = await getDevice(data.id); formData.value = await getDevice(data.id);
await formApi.setValues(formData.value); await formApi.setValues(formData.value);
// 如果存在高级字段数据,自动展开 Collapse
if (
formData.value?.nickname ||
formData.value?.picUrl ||
formData.value?.groupIds?.length ||
formData.value?.serialNumber ||
formData.value?.locationType !== undefined
) {
activeKey.value = ['advanced'];
// 等待 Collapse 展开后表单挂载
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
} finally { } finally {
modalApi.unlock(); modalApi.unlock();
} }
}, },
}); });
/** 初始化产品列表 */
onMounted(async () => {
products.value = await getSimpleProductList();
});
</script> </script>
<template> <template>
<Modal :title="getTitle" class="w-2/5"> <Modal :title="getTitle" class="w-2/5">
<Form class="mx-4" /> <div class="mx-4">
<Form />
<Collapse v-model:active-key="activeKey" class="mt-4">
<Collapse.Panel key="advanced" header="更多设置">
<AdvancedForm />
</Collapse.Panel>
</Collapse>
</div>
</Modal> </Modal>
</template> </template>

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
@@ -41,7 +43,7 @@ const [Modal, modalApi] = useVbenModal({
await updateDeviceGroup({ await updateDeviceGroup({
ids: deviceIds.value, ids: deviceIds.value,
groupIds: data.groupIds as number[], groupIds: data.groupIds as number[],
}); } as IotDeviceApi.DeviceUpdateGroupReqVO);
// 关闭并提示 // 关闭并提示
await modalApi.close(); await modalApi.close();
emit('success'); emit('success');

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface'; import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { useVbenModal } from '@vben/common-ui'; import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils'; import { downloadFileFromBlobPart } from '@vben/utils';
@@ -38,7 +39,7 @@ const [Modal, modalApi] = useVbenModal({
try { try {
const result = await importDevice(data.file, data.updateSupport); const result = await importDevice(data.file, data.updateSupport);
// 处理导入结果提示 // 处理导入结果提示
const importData = result.data || result; const importData = result as IotDeviceApi.DeviceImportRespVO;
if (importData) { if (importData) {
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`; let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
if (importData.createDeviceNames?.length) { if (importData.createDeviceNames?.length) {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device'; import type { DeviceRespVO, IotDeviceApi } from '#/api/iot/device/device';
import type { OtaTask } from '#/api/iot/ota/task'; import type { OtaTask } from '#/api/iot/ota/task';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
@@ -57,7 +57,7 @@ const formRules = {
}, },
], ],
}; };
const devices = ref<IotDeviceApi.Device[]>([]); const devices = ref<IotDeviceApi.DeviceRespVO[]>([]);
/** 设备选项 */ /** 设备选项 */
const deviceOptions = computed(() => { const deviceOptions = computed(() => {

View File

@@ -236,6 +236,15 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
name: 'CellImage', name: 'CellImage',
}, },
}, },
{
field: 'status',
title: '产品状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_PRODUCT_STATUS },
},
},
{ {
field: 'createTime', field: 'createTime',
title: '创建时间', title: '创建时间',

View File

@@ -115,6 +115,11 @@ onMounted(() => {
<div class="ml-3 min-w-0 flex-1"> <div class="ml-3 min-w-0 flex-1">
<div class="product-title">{{ item.name }}</div> <div class="product-title">{{ item.name }}</div>
</div> </div>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_STATUS"
:value="item.status"
class="status-tag"
/>
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="mb-3 flex items-start"> <div class="mb-3 flex items-start">
@@ -264,6 +269,11 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
// 状态标签
.status-tag {
font-size: 12px;
}
// 信息列表 // 信息列表
.info-list { .info-list {
.info-item { .info-item {