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

Merge pull request !858 from 芋道源码/feature/iot-sub
pull/855/MERGE
芋道源码 2026-01-25 10:52:34 +00:00 committed by Gitee
commit 859d493513
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
9 changed files with 469 additions and 26 deletions

View File

@ -21,7 +21,6 @@ export interface DeviceVO {
mqttClientId: string // MQTT 客户端 ID
mqttUsername: string // MQTT 用户名
mqttPassword: string // MQTT 密码
authType: string // 认证类型
latitude?: number // 设备位置的纬度
longitude?: number // 设备位置的经度
areaId: number // 地区编码
@ -158,5 +157,28 @@ export const DeviceApi = {
// 发送设备消息
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
return await request.post({ url: `/iot/device/message/send`, data: params })
},
// 绑定子设备到网关
bindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/bind-gateway`, data })
},
// 解绑子设备与网关
unbindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/unbind-gateway`, data })
},
// 获取网关的子设备列表
getSubDeviceList: async (gatewayId: number) => {
return await request.get<DeviceVO[]>({
url: `/iot/device/sub-device-list`,
params: { gatewayId }
})
},
// 获取未绑定网关的子设备分页
getUnboundSubDevicePage: async (params: any) => {
return await request.get({ url: `/iot/device/unbound-sub-device-page`, params })
}
}

View File

@ -5,6 +5,8 @@ export interface ProductVO {
id: number // 产品编号
name: string // 产品名称
productKey: string // 产品标识
productSecret?: string // 产品密钥
registerEnabled?: boolean // 动态注册
protocolId: number // 协议编号
categoryId: number // 产品所属品类标识符
categoryName?: string // 产品所属品类名称
@ -68,8 +70,8 @@ export const ProductApi = {
},
// 查询产品(精简)列表
getSimpleProductList() {
return request.get({ url: '/iot/product/simple-list' })
getSimpleProductList(deviceType?: number) {
return request.get({ url: '/iot/product/simple-list', params: { deviceType } })
},
// 根据 ProductKey 获取产品信息

View File

@ -30,20 +30,6 @@
:disabled="formType === 'update'"
/>
</el-form-item>
<el-form-item
v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
label="网关设备"
prop="gatewayId"
>
<el-select v-model="formData.gatewayId" placeholder="子设备可选择父设备" clearable>
<el-option
v-for="gateway in gatewayDevices"
:key="gateway.id"
:label="gateway.nickname || gateway.deviceName"
:value="gateway.id"
/>
</el-select>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
@ -114,7 +100,6 @@ const formData = ref({
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined as number | undefined,
serialNumber: undefined,
longitude: undefined as number | string | undefined,
@ -222,7 +207,6 @@ const formRules = reactive({
})
const formRef = ref() // Ref
const products = ref<ProductVO[]>([]) //
const gatewayDevices = ref<DeviceVO[]>([]) //
const deviceGroups = ref<any[]>([])
/** 打开弹窗 */
@ -242,8 +226,6 @@ const open = async (type: string, id?: number) => {
}
}
//
gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
//
products.value = await ProductApi.getSimpleProductList()
//
@ -283,7 +265,6 @@ const resetForm = () => {
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined,
serialNumber: undefined,
longitude: undefined,

View File

@ -0,0 +1,264 @@
<!-- 子设备管理 -->
<template>
<ContentWrap>
<!-- 操作按钮 -->
<div class="mb-4">
<el-button type="primary" plain @click="openBindDialog" v-hasPermi="['iot:device:update']">
<Icon icon="ep:plus" class="mr-5px" /> 添加子设备
</el-button>
<el-button
type="danger"
plain
@click="handleUnbindBatch"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device:update']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量解绑
</el-button>
</div>
<!-- 子设备列表 -->
<el-table
v-loading="loading"
:data="subDeviceList"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName">
<template #default="{ row }">
<el-link type="primary" @click="openDeviceDetail(row.id)">{{ row.deviceName }}</el-link>
</template>
</el-table-column>
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
<el-table-column
label="最后上线时间"
align="center"
prop="onlineTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" width="120px">
<template #default="{ row }">
<el-button link type="primary" @click="openDeviceDetail(row.id)"> </el-button>
<el-button
link
type="danger"
@click="handleUnbind(row.id)"
v-hasPermi="['iot:device:update']"
>
解绑
</el-button>
</template>
</el-table-column>
</el-table>
</ContentWrap>
<!-- 添加子设备弹窗 -->
<Dialog title="添加子设备" v-model="bindDialogVisible" width="900px">
<ContentWrap>
<!-- 搜索区域 -->
<el-form :model="bindQueryParams" ref="bindQueryFormRef" :inline="true" class="-mb-15px">
<el-form-item label="产品" prop="productId">
<ProductSelect
v-model="bindQueryParams.productId"
:device-type="DeviceTypeEnum.GATEWAY_SUB"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="bindQueryParams.deviceName"
placeholder="请输入设备名称"
clearable
class="!w-200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getBindableDevicePage">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button @click="resetBindQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<!-- 分页表格 -->
<el-table
ref="bindTableRef"
v-loading="bindFormLoading"
:data="bindableDevices"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleBindSelectionChange"
max-height="400px"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName" />
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<Pagination
v-model:page="bindQueryParams.pageNo"
v-model:limit="bindQueryParams.pageSize"
:total="bindTotal"
@pagination="getBindableDevicePage"
/>
</ContentWrap>
<template #footer>
<el-button type="primary" @click="handleBindSubmit" :loading="bindFormLoading">
确定已选 {{ bindSelectedIds.length }}
</el-button>
<el-button @click="bindDialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceTypeEnum } from '@/api/iot/product/product'
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import ProductSelect from '@/views/iot/product/product/components/ProductSelect.vue'
const props = defineProps<{
gatewayId: number
}>()
const message = useMessage()
const { push } = useRouter()
const loading = ref(false) //
const subDeviceList = ref<DeviceVO[]>([]) //
const selectedIds = ref<number[]>([]) // ID
const bindDialogVisible = ref(false) //
const bindFormLoading = ref(false) //
const bindTableRef = ref()
const bindQueryFormRef = ref()
const bindableDevices = ref<DeviceVO[]>([]) //
const bindSelectedIds = ref<number[]>([]) // ID
const bindTotal = ref(0) //
const bindQueryParams = reactive({
pageNo: 1,
pageSize: 10,
productId: undefined as number | undefined,
deviceName: ''
})
/** 获取子设备列表 */
const getSubDeviceList = async () => {
loading.value = true
try {
subDeviceList.value = await DeviceApi.getSubDeviceList(props.gatewayId)
} finally {
loading.value = false
}
}
/** 打开设备详情 */
const openDeviceDetail = (id: number) => {
push({ name: 'IoTDeviceDetail', params: { id } })
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceVO[]) => {
selectedIds.value = selection.map((item) => item.id)
}
/** 打开绑定弹窗 */
const openBindDialog = async () => {
bindSelectedIds.value = []
bindDialogVisible.value = true
await getBindableDevicePage()
}
/** 获取可绑定设备分页 */
const getBindableDevicePage = async () => {
bindFormLoading.value = true
try {
const result = await DeviceApi.getUnboundSubDevicePage(bindQueryParams)
bindableDevices.value = result.list
bindTotal.value = result.total
} finally {
bindFormLoading.value = false
}
}
/** 重置绑定弹窗搜索条件 */
const resetBindQuery = () => {
bindQueryParams.pageNo = 1
bindQueryParams.productId = undefined
bindQueryParams.deviceName = ''
getBindableDevicePage()
}
/** 绑定弹窗多选框选中数据 */
const handleBindSelectionChange = (selection: DeviceVO[]) => {
bindSelectedIds.value = selection.map((item) => item.id)
}
/** 提交绑定 */
const handleBindSubmit = async () => {
if (bindSelectedIds.value.length === 0) {
message.warning('请选择要绑定的子设备')
return
}
bindFormLoading.value = true
try {
await DeviceApi.bindDeviceGateway({
subIds: bindSelectedIds.value,
gatewayId: props.gatewayId
})
message.success('绑定成功')
bindDialogVisible.value = false
await getSubDeviceList()
} finally {
bindFormLoading.value = false
}
}
/** 解绑单个设备 */
const handleUnbind = async (id: number) => {
try {
await message.confirm('确定要解绑该子设备吗?')
await DeviceApi.unbindDeviceGateway({ subIds: [id], gatewayId: props.gatewayId })
message.success('解绑成功')
await getSubDeviceList()
} catch {}
}
/** 批量解绑 */
const handleUnbindBatch = async () => {
try {
await message.confirm(`确定要解绑选中的 ${selectedIds.value.length} 个子设备吗?`)
await DeviceApi.unbindDeviceGateway({ subIds: selectedIds.value, gatewayId: props.gatewayId })
message.success('批量解绑成功')
selectedIds.value = []
await getSubDeviceList()
} catch {}
}
/** 初始化 */
onMounted(async () => {
await getSubDeviceList()
})
</script>

View File

@ -17,7 +17,13 @@
:thing-model-list="thingModelList"
/>
</el-tab-pane>
<el-tab-pane label="子设备管理" v-if="product.deviceType === DeviceTypeEnum.GATEWAY" />
<el-tab-pane
label="子设备管理"
name="subDevice"
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
>
<DeviceDetailsSubDevice v-if="activeTab === 'subDevice'" :gateway-id="device.id" />
</el-tab-pane>
<el-tab-pane label="设备消息" name="log">
<DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
</el-tab-pane>
@ -50,6 +56,7 @@ import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
import DeviceDetailsSubDevice from './DeviceDetailsSubDevice.vue'
defineOptions({ name: 'IoTDeviceDetail' })

View File

@ -75,6 +75,20 @@
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
<el-form-item label="动态注册" prop="registerEnabled">
<template #label>
<el-tooltip
content="设备动态注册无需一一烧录设备证书DeviceSecret每台设备烧录相同的产品证书即 ProductKey 和 ProductSecret ,云端鉴权通过后下发设备证书,您可以根据需要开启或关闭动态注册,保障安全性。"
placement="top"
>
<span>
动态注册
<Icon icon="ep:question-filled" class="ml-2px" />
</span>
</el-tooltip>
</template>
<el-switch v-model="formData.registerEnabled" />
</el-form-item>
<el-form-item label="产品图标" prop="icon">
<UploadImg v-model="formData.icon" :height="'80px'" :width="'80px'" />
</el-form-item>
@ -120,7 +134,8 @@ const formData = ref({
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
registerEnabled: false
})
const formRules = reactive({
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
@ -194,7 +209,8 @@ const resetForm = () => {
description: undefined,
deviceType: undefined,
netType: undefined,
codecType: CodecTypeEnum.ALINK
codecType: CodecTypeEnum.ALINK,
registerEnabled: false
}
formRef.value?.resetFields()
}

View File

@ -0,0 +1,65 @@
<!-- 产品下拉选择器组件 -->
<template>
<el-select
:model-value="modelValue"
@update:model-value="handleChange"
placeholder="请选择产品"
filterable
clearable
class="w-full"
:loading="loading"
>
<el-option
v-for="product in productList"
:key="product.id"
:label="product.name"
:value="product.id"
/>
</el-select>
</template>
<script setup lang="ts">
import { ProductApi } from '@/api/iot/product/product'
/** 产品下拉选择器组件 */
defineOptions({ name: 'ProductSelect' })
const props = defineProps<{
modelValue?: number
deviceType?: number //
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value?: number): void
(e: 'change', value?: number): void
}>()
const loading = ref(false) //
const productList = ref<any[]>([]) //
/**
* 处理选择变化事件
*
* @param value 选中的产品 ID
*/
const handleChange = (value?: number) => {
emit('update:modelValue', value)
emit('change', value)
}
/** 获取产品列表 */
const getProductList = async () => {
try {
loading.value = true
const res = await ProductApi.getSimpleProductList(props.deviceType)
productList.value = res || []
} finally {
loading.value = false
}
}
/** 组件挂载时获取产品列表 */
onMounted(() => {
getProductList()
})
</script>

View File

@ -21,6 +21,28 @@
>
<dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</el-descriptions-item>
<el-descriptions-item label="动态注册">
<el-tag :type="product.registerEnabled ? 'success' : 'info'">
{{ product.registerEnabled ? '已开启' : '已关闭' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="产品密钥">
<div class="flex items-center">
<span>{{ secretVisible ? product.productSecret : '******' }}</span>
<el-button link type="primary" class="ml-2" @click="secretVisible = !secretVisible">
<Icon :icon="secretVisible ? 'ep:hide' : 'ep:view'" />
</el-button>
<el-button
v-if="secretVisible && product.productSecret"
link
type="primary"
class="ml-1"
@click="copySecret"
>
<Icon icon="ep:document-copy" />
</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="产品描述">{{ product.description }}</el-descriptions-item>
</el-descriptions>
</ContentWrap>
@ -29,6 +51,19 @@
import { DICT_TYPE } from '@/utils/dict'
import { DeviceTypeEnum, ProductVO } from '@/api/iot/product/product'
import { formatDate } from '@/utils/formatTime'
import { useClipboard } from '@vueuse/core'
const { product } = defineProps<{ product: ProductVO }>()
const message = useMessage()
const secretVisible = ref(false)
const { copy } = useClipboard()
/** 复制产品密钥 */
const copySecret = async () => {
if (product.productSecret) {
await copy(product.productSecret)
message.success('复制成功')
}
}
</script>

View File

@ -24,7 +24,41 @@ export const IotDeviceMessageMethodEnum = {
// ========== 设备状态 ==========
STATE_UPDATE: {
method: 'thing.state.update',
name: '设备状态变更',
name: '设备状态更新',
upstream: true
},
// ========== 拓扑管理 ==========
TOPO_ADD: {
method: 'thing.topo.add',
name: '添加拓扑关系',
upstream: true
},
TOPO_DELETE: {
method: 'thing.topo.delete',
name: '删除拓扑关系',
upstream: true
},
TOPO_GET: {
method: 'thing.topo.get',
name: '获取拓扑关系',
upstream: true
},
TOPO_CHANGE: {
method: 'thing.topo.change',
name: '拓扑关系变更通知',
upstream: false
},
// ========== 设备注册 ==========
DEVICE_REGISTER: {
method: 'thing.auth.register',
name: '设备动态注册',
upstream: true
},
SUB_DEVICE_REGISTER: {
method: 'thing.auth.register.sub',
name: '子设备动态注册',
upstream: true
},
@ -39,6 +73,11 @@ export const IotDeviceMessageMethodEnum = {
name: '属性设置',
upstream: false
},
PROPERTY_PACK_POST: {
method: 'thing.event.property.pack.post',
name: '批量上报(属性 + 事件 + 子设备)',
upstream: true
},
// ========== 设备事件 ==========
EVENT_POST: {
@ -59,6 +98,18 @@ export const IotDeviceMessageMethodEnum = {
method: 'thing.config.push',
name: '配置推送',
upstream: false
},
// ========== OTA 固件 ==========
OTA_UPGRADE: {
method: 'thing.ota.upgrade',
name: 'OTA 固件信息推送',
upstream: false
},
OTA_PROGRESS: {
method: 'thing.ota.progress',
name: 'OTA 升级进度上报',
upstream: true
}
}