feat(iot):【网关设备:80%】动态注册的初步实现(已测试)

This commit is contained in:
YunaiV
2026-01-25 18:50:26 +08:00
parent c55465a6c0
commit 1ce562601f
17 changed files with 507 additions and 173 deletions

View File

@@ -21,7 +21,6 @@ export namespace IotDeviceApi {
offlineTime?: Date; // 最后离线时间
activeTime?: Date; // 设备激活时间
deviceSecret?: string; // 设备密钥,用于设备认证
authType?: string; // 认证类型(如一机一密、动态注册)
config?: string; // 设备配置
latitude?: number; // 设备位置的纬度
longitude?: number; // 设备位置的经度
@@ -201,3 +200,35 @@ export function getDeviceMessagePairPage(params: PageParam) {
export function sendDeviceMessage(params: IotDeviceApi.DeviceMessageSendReq) {
return requestClient.post('/iot/device/message/send', params);
}
/** 绑定子设备到网关设备 */
export function bindDeviceGateway(gatewayId: number, subIds: number[]) {
return requestClient.put<boolean>('/iot/device/bind-gateway', {
gatewayId,
subIds,
});
}
/** 解绑子设备与网关设备 */
export function unbindDeviceGateway(gatewayId: number, subIds: number[]) {
return requestClient.put<boolean>('/iot/device/unbind-gateway', {
gatewayId,
subIds,
});
}
/** 获取网关设备的子设备列表 */
export function getSubDeviceList(gatewayId: number) {
return requestClient.get<IotDeviceApi.Device[]>(
'/iot/device/sub-device-list',
{ params: { gatewayId } },
);
}
/** 获取未绑定的子设备分页 */
export function getUnboundSubDevicePage(params: PageParam) {
return requestClient.get<PageResult<IotDeviceApi.Device>>(
'/iot/device/unbound-sub-device-page',
{ params },
);
}

View File

@@ -8,6 +8,7 @@ export namespace IotProductApi {
id?: number; // 产品编号
name: string; // 产品名称
productKey?: string; // 产品标识
productSecret?: string; // 产品密钥
protocolId?: number; // 协议编号
protocolType?: number; // 接入协议类型
categoryId?: number; // 产品所属品类标识符
@@ -21,6 +22,7 @@ export namespace IotProductApi {
codecType?: string; // 数据格式(编解码器类型)
dataFormat?: number; // 数据格式
validateType?: number; // 认证方式
registerEnabled?: boolean; // 是否开启动态注册
deviceCount?: number; // 设备数量
createTime?: Date; // 创建时间
}
@@ -67,8 +69,13 @@ export function updateProductStatus(id: number, status: number) {
}
/** 查询产品(精简)列表 */
export function getSimpleProductList() {
return requestClient.get<IotProductApi.Product[]>('/iot/product/simple-list');
export function getSimpleProductList(deviceType?: number) {
return requestClient.get<IotProductApi.Product[]>(
'/iot/product/simple-list',
{
params: { deviceType },
},
);
}
/** 根据 ProductKey 获取产品信息 */

View File

@@ -1,11 +1,10 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
@@ -64,21 +63,6 @@ export function useBasicFormSchema(): VbenFormSchema[] {
'支持英文字母、数字、下划线_、中划线-)、点号(.)、半角冒号(:)和特殊字符@',
),
},
{
fieldName: 'gatewayId',
label: '网关设备',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleDeviceList(DeviceTypeEnum.GATEWAY),
labelField: 'deviceName',
valueField: 'id',
placeholder: '子设备可选择父设备',
},
dependencies: {
triggerFields: ['deviceType'],
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
},
},
];
}

View File

@@ -4,10 +4,11 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref, watchEffect } from 'vue';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { Alert, Button, message, Textarea } from 'ant-design-vue';
import { sendDeviceMessage, updateDevice } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceDetailConfig' });

View File

@@ -11,7 +11,7 @@ import {
} from 'vue';
import { Page } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@@ -19,7 +19,6 @@ import { Button, Select, Space, Switch, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;

View File

@@ -9,6 +9,7 @@ import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
@@ -26,7 +27,6 @@ import {
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
DeviceStateEnum,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';

View File

@@ -1,20 +1,23 @@
<script lang="ts" setup>
import type { PageParam } from '@vben/request';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { VbenFormSchema, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { formatDateTime, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDevicePage } from '#/api/iot/device/device';
import {
bindDeviceGateway,
getSubDeviceList,
getUnboundSubDevicePage,
unbindDeviceGateway,
} from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
interface Props {
@@ -24,10 +27,10 @@ interface Props {
const props = defineProps<Props>();
const router = useRouter();
const products = ref<IotProductApi.Product[]>([]); // 产品列表
/** 子设备列表表格列配置 */
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'deviceName',
title: 'DeviceName',
@@ -39,10 +42,9 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
minWidth: 120,
},
{
field: 'productId',
title: '所属产品',
field: 'productName',
title: '产品名称',
minWidth: 120,
slots: { default: 'product' },
},
{
field: 'state',
@@ -56,21 +58,125 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'onlineTime',
title: '最后上线时间',
minWidth: 180,
formatter: 'formatDateTime',
minWidth: 160,
formatter: ({ cellValue }) => formatDateTime(cellValue),
},
{
field: 'actions',
title: '操作',
width: 150,
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 搜索表单 schema */
function useGridFormSchema(): VbenFormSchema[] {
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.Device>({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
if (!props.deviceId) {
return [];
}
return await getSubDeviceList(props.deviceId);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
pagerConfig: {
enabled: false,
},
},
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 获取子设备列表 */
function getList() {
gridApi.query();
}
/** 打开设备详情 */
function openDeviceDetail(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id } });
}
/** 多选框选中数据 */
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
/** 解绑单个设备 */
async function handleUnbind(row: IotDeviceApi.Device) {
await confirm({ content: `确定要解绑子设备【${row.deviceName}】吗?` });
const hideLoading = message.loading({
content: `正在解绑【${row.deviceName}】...`,
duration: 0,
});
try {
await unbindDeviceGateway(props.deviceId, [row.id!]);
message.success('解绑成功');
getList();
} finally {
hideLoading();
}
}
/** 批量解绑 */
async function handleUnbindBatch() {
await confirm({
content: `确定要解绑选中的 ${checkedIds.value.length} 个子设备吗?`,
});
const hideLoading = message.loading({
content: '正在批量解绑...',
duration: 0,
});
try {
await unbindDeviceGateway(props.deviceId, checkedIds.value);
checkedIds.value = [];
message.success('批量解绑成功');
getList();
} finally {
hideLoading();
}
}
// ===================== 添加子设备弹窗 =====================
const addSelectedRowKeys = ref<number[]>([]);
/** 添加弹窗搜索表单 schema */
function useAddGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: () => getSimpleProductList(DeviceTypeEnum.GATEWAY_SUB),
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
allowClear: true,
},
},
{
fieldName: 'deviceName',
label: 'DeviceName',
@@ -80,116 +186,171 @@ function useGridFormSchema(): VbenFormSchema[] {
allowClear: true,
},
},
];
}
function useAddGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
fieldName: 'status',
label: '设备状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number'),
placeholder: '请选择设备状态',
allowClear: true,
field: 'deviceName',
title: 'DeviceName',
minWidth: 150,
},
{
field: 'nickname',
title: '备注名称',
minWidth: 120,
},
{
field: 'productName',
title: '产品名称',
minWidth: 120,
},
{
field: 'state',
title: '设备状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DEVICE_STATE },
},
},
];
}
const [Grid, gridApi] = useVbenVxeGrid<IotDeviceApi.Device>({
const [AddGrid, addGridApi] = useVbenVxeGrid<IotDeviceApi.Device>({
formOptions: {
schema: useGridFormSchema(),
schema: useAddGridFormSchema(),
submitOnChange: true,
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
columns: useAddGridColumns(),
height: 400,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getUnboundSubDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
proxyConfig: {
ajax: {
query: async (
{
page,
}: {
page: { currentPage: number; pageSize: number };
},
formValues?: { deviceName?: string; status?: 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: formValues?.deviceName || undefined,
status: formValues?.status,
} as PageParam);
},
},
},
toolbarConfig: {
refresh: true,
search: true,
},
pagerConfig: {
enabled: true,
},
},
gridEvents: {
checkboxAll: handleAddSelectionChange,
checkboxChange: handleAddSelectionChange,
},
});
/** 获取产品名称 */
function getProductName(productId: number) {
const product = products.value.find((p) => p.id === productId);
return product?.name || '-';
/** 处理添加弹窗表格选择变化 */
function handleAddSelectionChange() {
const records = addGridApi.grid?.getCheckboxRecords() || [];
addSelectedRowKeys.value = records.map(
(record: IotDeviceApi.Device) => record.id!,
);
}
/** 查看详情 */
function openDetail(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id } });
}
/** 监听设备ID变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
gridApi.query();
const [AddModal, addModalApi] = useVbenModal({
async onConfirm() {
if (addSelectedRowKeys.value.length === 0) {
message.warning('请先选择要添加的子设备');
return;
}
addModalApi.lock();
try {
await bindDeviceGateway(props.deviceId, addSelectedRowKeys.value);
message.success('绑定成功');
await addModalApi.close();
addSelectedRowKeys.value = [];
getList();
} finally {
addModalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (isOpen) {
addSelectedRowKeys.value = [];
await addGridApi.formApi?.resetForm();
await addGridApi.query();
}
},
);
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 如果设备ID存在则查询列表
if (props.deviceId) {
gridApi.query();
}
});
/** 打开添加子设备弹窗 */
function openAddModal() {
addModalApi.open();
}
/** 监听 deviceId 变化 */
watch(
() => props.deviceId,
(newVal) => {
if (newVal) {
getList();
}
},
{ immediate: true },
);
</script>
<template>
<Page auto-content-height>
<!-- 子设备列表 -->
<Grid>
<template #product="{ row }">
{{ getProductName(row.productId) }}
<Grid table-title="子设备列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: '添加子设备',
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: openAddModal,
},
{
label: '批量解绑',
type: 'primary',
danger: true,
icon: ACTION_ICON.DELETE,
disabled: isEmpty(checkedIds),
onClick: handleUnbindBatch,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看详情',
label: '查看',
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: openDetail.bind(null, row.id!),
onClick: () => openDeviceDetail(row.id!),
},
{
label: '解绑',
type: 'link',
danger: true,
onClick: () => handleUnbind(row),
},
]"
/>
</template>
</Grid>
<!-- 添加子设备弹窗 -->
<AddModal title="添加子设备" class="w-3/5">
<AddGrid />
</AddModal>
</Page>
</template>

View File

@@ -6,6 +6,7 @@ import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@@ -15,7 +16,6 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';

View File

@@ -6,6 +6,7 @@ import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@@ -15,7 +16,6 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';

View File

@@ -0,0 +1 @@
export { default as ProductSelect } from './select.vue';

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, ref } from 'vue';
import { Select } from 'ant-design-vue';
import { getSimpleProductList } from '#/api/iot/product/product';
/** 产品下拉选择器组件 */
defineOptions({ name: 'ProductSelect' });
const props = defineProps<{
deviceType?: number; // 设备类型过滤
modelValue?: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value?: number): void;
(e: 'change', value?: number): void;
}>();
const loading = ref(false);
const productList = ref<IotProductApi.Product[]>([]);
/** 处理选择变化 */
function handleChange(value?: number) {
emit('update:modelValue', value);
emit('change', value);
}
/** 获取产品列表 */
async function getProductList() {
try {
loading.value = true;
productList.value = (await getSimpleProductList(props.deviceType)) || [];
} finally {
loading.value = false;
}
}
onMounted(() => {
getProductList();
});
</script>
<template>
<Select
:value="modelValue"
:options="productList.map((p) => ({ label: p.name, value: p.id }))"
:loading="loading"
placeholder="请选择产品"
allow-clear
class="w-full"
@change="handleChange"
/>
</template>

View File

@@ -153,9 +153,20 @@ export function useBasicFormSchema(
];
}
/** 高级设置表单字段(图标、图片、产品描述) */
/** 高级设置表单字段(图标、图片、产品描述、动态注册 */
export function useAdvancedFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'registerEnabled',
label: '动态注册',
component: 'Switch',
componentProps: {
checkedChildren: '开',
unCheckedChildren: '关',
},
defaultValue: false,
help: '设备动态注册无需一一烧录设备证书DeviceSecret每台设备烧录相同的产品证书即 ProductKey 和 ProductSecret ,云端鉴权通过后下发设备证书,您可以根据需要开启或关闭动态注册,保障安全性。',
},
{
fieldName: 'icon',
label: '产品图标',

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { IotProductApi } from '#/api/iot/product/product';
import { ref } from 'vue';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { Card, Descriptions } from 'ant-design-vue';
import { Button, Card, Descriptions, message } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
@@ -13,11 +15,28 @@ interface Props {
defineProps<Props>();
const showProductSecret = ref(false); // 是否显示产品密钥
/** 格式化日期 */
function formatDate(date?: Date | string) {
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN');
}
/** 切换产品密钥显示状态 */
function toggleProductSecretVisible() {
showProductSecret.value = !showProductSecret.value;
}
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
message.success('复制成功');
} catch {
message.error('复制失败');
}
}
</script>
<template>
@@ -54,6 +73,23 @@ function formatDate(date?: Date | string) {
>
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</Descriptions.Item>
<Descriptions.Item v-if="product.productSecret" label="ProductSecret">
<span v-if="showProductSecret">{{ product.productSecret }}</span>
<span v-else>********</span>
<Button class="ml-2" size="small" @click="toggleProductSecretVisible">
{{ showProductSecret ? '隐藏' : '显示' }}
</Button>
<Button
class="ml-2"
size="small"
@click="copyToClipboard(product.productSecret || '')"
>
复制
</Button>
</Descriptions.Item>
<Descriptions.Item label="动态注册">
{{ product.registerEnabled ? '已开启' : '未开启' }}
</Descriptions.Item>
<Descriptions.Item :span="3" label="产品描述">
{{ product.description || '-' }}
</Descriptions.Item>

View File

@@ -68,6 +68,7 @@ async function getAdvancedFormValues() {
}
// 表单未挂载(折叠状态),从 formData 中获取
return {
registerEnabled: formData.value?.registerEnabled,
icon: formData.value?.icon,
picUrl: formData.value?.picUrl,
description: formData.value?.description,
@@ -120,6 +121,7 @@ const [Modal, modalApi] = useVbenModal({
await formApi.setValues(formData.value);
// 如果存在高级字段数据,自动展开 Collapse
if (
formData.value?.registerEnabled ||
formData.value?.icon ||
formData.value?.picUrl ||
formData.value?.description

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { Button, Form, Select, Table } from 'ant-design-vue';
@@ -8,10 +9,7 @@ import { Button, Form, Select, Table } from 'ant-design-vue';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import {
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import { IoTThingModelTypeEnum } from '#/views/iot/utils/constants';
const formData = ref<any[]>([]);
const productList = ref<any[]>([]); // 产品列表

View File

@@ -1,3 +1,5 @@
// TODO @AI感觉这块放到 biz-iot-enum 里好点。
/** 检查值是否为空 */
const isEmpty = (value: any): boolean => {
return value === null || value === undefined || value === '';
@@ -22,49 +24,6 @@ export const IoTThingModelTypeEnum = {
EVENT: 3, // 事件
};
/** IoT 设备消息的方法枚举 */
export const IotDeviceMessageMethodEnum = {
// ========== 设备状态 ==========
STATE_UPDATE: {
method: 'thing.state.update',
name: '设备状态变更',
upstream: true,
},
// ========== 设备属性 ==========
PROPERTY_POST: {
method: 'thing.property.post',
name: '属性上报',
upstream: true,
},
PROPERTY_SET: {
method: 'thing.property.set',
name: '属性设置',
upstream: false,
},
// ========== 设备事件 ==========
EVENT_POST: {
method: 'thing.event.post',
name: '事件上报',
upstream: true,
},
// ========== 服务调用 ==========
SERVICE_INVOKE: {
method: 'thing.service.invoke',
name: '服务调用',
upstream: false,
},
// ========== 设备配置 ==========
CONFIG_PUSH: {
method: 'thing.config.push',
name: '配置推送',
upstream: false,
},
};
// IoT 产品物模型服务调用方式枚举
export const IoTThingModelServiceCallTypeEnum = {
ASYNC: {