feat(iot): 设备详情页新增设备影子 Tab

支持期望/上报属性对比、期望状态下发、影子开关与重置,并展示原始影子 JSON。
feature/iot
haohao 2026-06-14 16:21:02 +08:00
parent 9b416c722c
commit 98845e72e3
3 changed files with 291 additions and 0 deletions

View File

@ -0,0 +1,51 @@
import request from '@/config/axios'
/** 设备影子属性对比 VO */
export interface DeviceShadowPropertyVO {
identifier: string
name: string
dataType: string
dataSpecs?: any
dataSpecsList?: any[]
desiredValue?: any
reportedValue?: any
desiredTime?: number
reportedTime?: number
}
/** 设备影子 VO */
export interface DeviceShadowVO {
version: number
shadowTime: number
status: number
properties: DeviceShadowPropertyVO[]
desired: Record<string, any>
reported: Record<string, any>
metadata: Record<string, any>
}
/** 设备影子 API */
export const DeviceShadowApi = {
/** 获取设备影子 */
getDeviceShadow: async (deviceId: number) => {
return await request.get<DeviceShadowVO>({
url: `/iot/device/shadow/get`,
params: { deviceId }
})
},
/** 更新期望状态 */
updateDesiredState: async (data: { deviceId: number; desired: Record<string, any> }) => {
return await request.put({ url: `/iot/device/shadow/update-desired`, data })
},
/** 更新影子开关状态 */
updateShadowStatus: async (data: { deviceId: number; status: number }) => {
return await request.put({ url: `/iot/device/shadow/update-status`, data })
},
/** 重置影子 */
resetShadow: async (deviceId: number) => {
return await request.delete({ url: `/iot/device/shadow/reset`, params: { deviceId } })
}
}

View File

@ -0,0 +1,232 @@
<!-- 设备影子 -->
<template>
<ContentWrap>
<!-- 顶部信息栏 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<span class="text-sm text-gray-600"
>版本号<b>{{ shadow.version ?? 0 }}</b></span
>
<span class="text-sm text-gray-600">
最后更新{{ shadow.shadowTime ? formatTime(shadow.shadowTime) : '-' }}
</span>
<span class="text-sm text-gray-600 flex items-center gap-2">
影子开关
<el-switch
v-model="shadowEnabled"
:active-value="CommonStatusEnum.ENABLE"
:inactive-value="CommonStatusEnum.DISABLE"
@change="handleStatusChange"
/>
</span>
</div>
<div class="flex gap-2">
<el-button @click="loadShadow" :loading="loading">
<Icon icon="ep:refresh" class="mr-1" />刷新
</el-button>
<el-button type="danger" plain @click="handleReset" :loading="resetting">
重置影子
</el-button>
</div>
</div>
<!-- 属性对比表 -->
<el-table
:data="shadow.properties"
:show-overflow-tooltip="true"
:stripe="true"
:row-class-name="rowClassName"
v-loading="loading"
>
<el-table-column align="center" label="标识符" prop="identifier" width="140" />
<el-table-column align="center" label="名称" prop="name" width="120" />
<el-table-column align="center" label="数据类型" prop="dataType" width="100" />
<el-table-column align="center" label="期望值 (desired)" min-width="150">
<template #default="{ row }">
<span :class="{ 'text-orange-500 font-bold': isDelta(row) }">
{{ formatValue(row.desiredValue) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="上报值 (reported)" min-width="150">
<template #default="{ row }">
<span :class="{ 'text-orange-500 font-bold': isDelta(row) }">
{{ formatValue(row.reportedValue) }}
</span>
</template>
</el-table-column>
<el-table-column align="center" label="期望更新时间" width="160">
<template #default="{ row }">
{{ row.desiredTime ? formatTime(row.desiredTime) : '-' }}
</template>
</el-table-column>
<el-table-column align="center" label="上报更新时间" width="160">
<template #default="{ row }">
{{ row.reportedTime ? formatTime(row.reportedTime) : '-' }}
</template>
</el-table-column>
</el-table>
<!-- 下发期望 -->
<el-divider content-position="left">下发期望状态</el-divider>
<el-table :data="writableProperties" :show-overflow-tooltip="true" :stripe="true">
<el-table-column align="center" label="标识符" prop="identifier" width="140" />
<el-table-column align="center" label="名称" prop="name" width="120" />
<el-table-column align="center" label="数据类型" prop="dataType" width="100" />
<el-table-column align="center" label="期望值" min-width="200">
<template #default="{ row }">
<el-input v-model="desiredForm[row.identifier]" placeholder="输入期望值" size="small" />
</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-4">
<el-button type="primary" @click="handleUpdateDesired" :loading="updating">
下发期望状态
</el-button>
</div>
<!-- 原始 JSON -->
<el-collapse class="mt-4">
<el-collapse-item title="原始影子文档 (JSON)" name="raw">
<pre class="text-xs bg-gray-50 p-4 rounded overflow-auto max-h-96">{{ rawJson }}</pre>
</el-collapse-item>
</el-collapse>
</ContentWrap>
</template>
<script setup lang="ts">
import type { DeviceShadowVO } from '@/api/iot/device/shadow'
import { DeviceShadowApi } from '@/api/iot/device/shadow'
import { ThingModelData } from '@/api/iot/thingmodel'
import { CommonStatusEnum } from '@/utils/constants'
import { IoTThingModelAccessModeEnum, IoTThingModelTypeEnum } from '@/views/iot/utils/constants'
import { formatDate } from '@/utils/formatTime'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const message = useMessage()
const loading = ref(false)
const updating = ref(false)
const resetting = ref(false)
const shadow = ref<DeviceShadowVO>({
version: 0,
shadowTime: 0,
status: CommonStatusEnum.ENABLE,
properties: [],
desired: {},
reported: {},
metadata: {}
})
const shadowEnabled = ref(CommonStatusEnum.ENABLE)
const desiredForm = ref<Record<string, any>>({})
/** 可写属性列表(引导填写 desired */
const writableProperties = computed(() => {
return props.thingModelList.filter((item) => {
if (item.type !== IoTThingModelTypeEnum.PROPERTY) return false
const accessMode = item.property?.accessMode
return (
accessMode === IoTThingModelAccessModeEnum.READ_WRITE.value ||
accessMode === IoTThingModelAccessModeEnum.WRITE_ONLY.value
)
})
})
const rawJson = computed(() =>
JSON.stringify(
{
state: { desired: shadow.value.desired, reported: shadow.value.reported },
metadata: shadow.value.metadata,
version: shadow.value.version,
timestamp: shadow.value.shadowTime
},
null,
2
)
)
const loadShadow = async () => {
loading.value = true
try {
shadow.value = await DeviceShadowApi.getDeviceShadow(props.deviceId)
shadowEnabled.value = shadow.value.status ?? CommonStatusEnum.ENABLE
} finally {
loading.value = false
}
}
const handleStatusChange = async (status: number) => {
try {
await DeviceShadowApi.updateShadowStatus({ deviceId: props.deviceId, status })
message.success('影子开关已更新')
} catch {
shadowEnabled.value = shadow.value.status
}
}
const handleUpdateDesired = async () => {
const desired: Record<string, any> = {}
for (const [key, value] of Object.entries(desiredForm.value)) {
if (value !== undefined && value !== '') {
desired[key] = value
}
}
if (Object.keys(desired).length === 0) {
message.warning('请至少填写一个期望值')
return
}
updating.value = true
try {
await DeviceShadowApi.updateDesiredState({ deviceId: props.deviceId, desired })
message.success('期望状态已下发')
desiredForm.value = {}
await loadShadow()
} finally {
updating.value = false
}
}
const handleReset = async () => {
await message.confirm('确认重置该设备的影子文档?此操作将清空 desired 和 reported。')
resetting.value = true
try {
await DeviceShadowApi.resetShadow(props.deviceId)
message.success('影子已重置')
await loadShadow()
} finally {
resetting.value = false
}
}
const isDelta = (row: any) => {
const d = row.desiredValue
const r = row.reportedValue
if (d === undefined || d === null) return false
return String(d) !== String(r ?? '')
}
const rowClassName = ({ row }: { row: any }) => (isDelta(row) ? 'shadow-delta-row' : '')
const formatValue = (val: any) => {
if (val === undefined || val === null) return '-'
if (typeof val === 'object') return JSON.stringify(val)
return String(val)
}
const formatTime = (epochSecond: number) => {
return formatDate(new Date(epochSecond * 1000))
}
onMounted(() => {
loadShadow()
})
</script>
<style scoped>
:deep(.shadow-delta-row) {
background-color: var(--el-color-warning-light-9) !important;
}
</style>

View File

@ -27,6 +27,13 @@
<el-tab-pane label="设备消息" name="log">
<DeviceDetailsMessage v-if="activeTab === 'log'" :device-id="device.id" />
</el-tab-pane>
<el-tab-pane label="设备影子" name="shadow">
<DeviceDetailsShadow
v-if="activeTab === 'shadow'"
:device-id="device.id"
:thing-model-list="thingModelList"
/>
</el-tab-pane>
<el-tab-pane label="模拟设备" name="simulator">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
@ -70,6 +77,7 @@ import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsShadow from './DeviceDetailsShadow.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
import DeviceModbusConfig from './DeviceModbusConfig.vue'