feat(iot):【设备定位:100%】首页接入地图,基于 sequential-crafting-thacker.md 规划

This commit is contained in:
YunaiV
2026-01-21 13:41:21 +08:00
parent 79865ae712
commit c7907d0d73
7 changed files with 280 additions and 53 deletions

View File

@@ -5,6 +5,7 @@ export interface DeviceVO {
id: number // 设备 ID主键自增
deviceName: string // 设备名称
productId: number // 产品编号
productName?: string // 产品名称(只有部分接口返回,例如 getDeviceLocationList
productKey: string // 产品标识
deviceType: number // 设备类型
nickname: string // 设备备注名称
@@ -114,6 +115,11 @@ export const DeviceApi = {
return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType, productId } })
},
// 获取设备位置列表(用于地图展示)
getDeviceLocationList: async () => {
return await request.get<DeviceVO[]>({ url: `/iot/device/location-list` })
},
// 根据产品编号,获取设备的精简信息列表
getDeviceListByProductId: async (productId: number) => {
return await request.get({ url: `/iot/device/simple-list?`, params: { productId } })

View File

@@ -16,18 +16,6 @@ export interface IotStatisticsSummaryRespVO {
productCategoryDeviceCounts: Record<string, number>
}
/** 时间戳-数值的键值对类型 */
interface TimeValueItem {
[key: string]: number
}
/** IoT 消息统计数据类型 */
export interface IotStatisticsDeviceMessageSummaryRespVO {
statType: number
upstreamCounts: TimeValueItem[]
downstreamCounts: TimeValueItem[]
}
/** 新的消息统计数据项 */
export interface IotStatisticsDeviceMessageSummaryByDateRespVO {
time: string
@@ -41,6 +29,17 @@ export interface IotStatisticsDeviceMessageReqVO {
times?: string[]
}
/** 设备位置数据 VO */
export interface DeviceLocationRespVO {
id: number
deviceName: string
nickname?: string
productName?: string
state: number
longitude: number
latitude: number
}
// IoT 数据统计 API
export const StatisticsApi = {
// 查询全局的数据统计

View File

@@ -1,3 +1,4 @@
import MapDialog from './src/MapDialog.vue'
export { loadBaiduMapSdk } from './src/utils'
export { MapDialog }

View File

@@ -57,14 +57,7 @@
<script setup lang="ts">
import { reactive, ref, nextTick } from 'vue'
// 扩展 Window 接口以包含百度地图 GL API
declare global {
interface Window {
BMapGL: any
initBaiduMapDialog: () => void
}
}
import { loadBaiduMapSdk } from './utils'
const emits = defineEmits(['confirm'])
@@ -80,8 +73,7 @@ const state = reactive({
mapAddressOptions: [] as any[], // 地址搜索选项
mapMarker: null as any, // 地图标记点
geocoder: null as any, // 地理编码器实例
mapContainerReady: false, // 地图容器是否准备好
sdkLoaded: false // 百度地图 SDK 是否已加载
mapContainerReady: false // 地图容器是否准备好
})
// 初始经纬度(打开弹窗时传入)
@@ -108,11 +100,9 @@ const handleDialogOpened = async () => {
// 等待下一个 DOM 更新周期,确保地图容器已渲染
await nextTick()
if (!state.sdkLoaded) {
loadMapSdk()
} else {
initMapInstance()
}
// 加载百度地图 SDK
await loadBaiduMapSdk()
initMapInstance()
}
/** 弹窗关闭后清理地图 */
@@ -127,28 +117,6 @@ const handleDialogClosed = () => {
state.mapContainerReady = false
}
/** 加载百度地图 SDK */
const loadMapSdk = () => {
// 检查是否已加载百度地图 SDK
if (window.BMapGL) {
state.sdkLoaded = true
initMapInstance()
return
}
// 动态创建脚本标签加载百度地图 SDK
const script = document.createElement('script')
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
import.meta.env.VITE_BAIDU_MAP_KEY
}&callback=initBaiduMapDialog`
document.body.appendChild(script)
// 定义回调函数SDK 加载完成后调用
window.initBaiduMapDialog = () => {
state.sdkLoaded = true
initMapInstance()
}
}
/** 初始化地图实例 */
const initMapInstance = () => {
if (!mapContainerRef.value) {

View File

@@ -0,0 +1,62 @@
/**
* 百度地图 SDK 加载工具
*/
// 扩展 Window 接口以包含百度地图 GL API
declare global {
interface Window {
BMapGL: any
}
}
// 全局回调名称
const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__'
// SDK 加载状态
let loadPromise: Promise<void> | null = null
/**
* 加载百度地图 GL SDK
* @param timeout 超时时间(毫秒),默认 10000
* @returns Promise<void>
*/
export const loadBaiduMapSdk = (timeout = 10000): Promise<void> => {
// 已加载完成
if (window.BMapGL) {
return Promise.resolve()
}
// 正在加载中,返回同一个 Promise
if (loadPromise) {
return loadPromise
}
loadPromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
loadPromise = null
reject(new Error('百度地图 SDK 加载超时'))
}, timeout)
// 全局回调
;(window as any)[CALLBACK_NAME] = () => {
clearTimeout(timeoutId)
delete (window as any)[CALLBACK_NAME]
resolve()
}
// 创建 script 标签
const script = document.createElement('script')
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
import.meta.env.VITE_BAIDU_MAP_KEY
}&callback=${CALLBACK_NAME}`
script.onerror = () => {
clearTimeout(timeoutId)
loadPromise = null
delete (window as any)[CALLBACK_NAME]
reject(new Error('百度地图 SDK 加载失败'))
}
document.body.appendChild(script)
})
return loadPromise
}

View File

@@ -0,0 +1,187 @@
<template>
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-base font-medium text-gray-600">设备分布地图</span>
<div class="flex items-center gap-4 text-sm">
<span v-for="item in stateOptions" :key="item.value" class="flex items-center gap-1">
<span
class="inline-block w-3 h-3 rounded-full"
:style="{ backgroundColor: stateColorMap[item.value] }"
></span>
<span class="text-gray-500">{{ item.label }}</span>
</span>
</div>
</div>
</template>
<div v-if="loading" class="h-[500px] flex justify-center items-center">
<el-empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[500px] flex justify-center items-center">
<el-empty description="暂无设备位置数据" />
</div>
<div v-show="hasData && !loading" ref="mapContainerRef" class="h-[500px] w-full"></div>
</el-card>
</template>
<script lang="ts" setup>
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { useRouter } from 'vue-router'
import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
import { DeviceStateEnum } from '@/views/iot/utils/constants'
import { loadBaiduMapSdk } from '@/components/Map/src/utils'
defineOptions({ name: 'DeviceMapCard' })
const router = useRouter()
const mapContainerRef = ref<HTMLElement>()
let mapInstance: any = null
const loading = ref(true)
const deviceList = ref<DeviceVO[]>([])
/** 是否有数据 */
const hasData = computed(() => deviceList.value.length > 0)
/** 状态图例列表(从字典获取) */
const stateOptions = computed(() => getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE))
/** 设备状态颜色映射 */
const stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
[DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
[DeviceStateEnum.OFFLINE]: '#9CA3AF' // 离线 - 灰色
}
/** 获取设备状态配置(从字典获取) */
const getStateConfig = (state: number): { name: string; color: string } => {
const dict = getDictObj(DICT_TYPE.IOT_DEVICE_STATE, state)
return {
name: dict?.label || '未知',
color: stateColorMap[state] || '#909399'
}
}
/** 创建自定义标记点图标 */
const createMarkerIcon = (color: string, isOnline: boolean) => {
const size = isOnline ? 24 : 20
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="${color}" stroke="white" stroke-width="2"/>
${isOnline ? `<circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : ''}
</svg>
`
const blob = new Blob([svg], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
return new window.BMapGL.Icon(url, new window.BMapGL.Size(size, size), {
anchor: new window.BMapGL.Size(size / 2, size / 2)
})
}
/** 初始化地图 */
const initMap = () => {
if (!mapContainerRef.value || !window.BMapGL) {
return
}
// 销毁旧实例
if (mapInstance) {
mapInstance.destroy?.()
mapInstance = null
}
// 创建地图实例,默认以中国为中心
mapInstance = new window.BMapGL.Map(mapContainerRef.value)
mapInstance.centerAndZoom(new window.BMapGL.Point(106, 37.5), 5)
mapInstance.enableScrollWheelZoom()
// 添加控件
mapInstance.addControl(new window.BMapGL.ScaleControl())
mapInstance.addControl(new window.BMapGL.ZoomControl())
// 添加设备标记点
deviceList.value.forEach((device) => {
const config = getStateConfig(device.state)
const isOnline = device.state === DeviceStateEnum.ONLINE
const point = new window.BMapGL.Point(device.longitude, device.latitude)
// 创建标记
const marker = new window.BMapGL.Marker(point, {
icon: createMarkerIcon(config.color, isOnline)
})
// 创建信息窗口内容
const infoContent = `
<div style="padding: 8px; min-width: 180px;">
<div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">${device.nickname || device.deviceName}</div>
<div style="color: #666; font-size: 12px; line-height: 1.8;">
<div>产品: ${device.productName || '-'}</div>
<div>状态: <span style="color: ${config.color}; font-weight: 500;">${config.name}</span></div>
</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #eee;">
<a href="javascript:void(0)" style="color: #409EFF; font-size: 12px; text-decoration: none;">点击查看详情 →</a>
</div>
</div>
`
// 点击标记显示信息窗口
marker.addEventListener('click', () => {
const infoWindow = new window.BMapGL.InfoWindow(infoContent, {
width: 220,
height: 140,
title: ''
})
// 信息窗口打开后绑定链接点击事件
infoWindow.addEventListener('open', () => {
setTimeout(() => {
const link = document.querySelector('.BMap_bubble_content a')
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault()
router.push({ name: 'IoTDeviceDetail', params: { id: device.id } })
})
}
}, 100)
})
mapInstance.openInfoWindow(infoWindow, point)
})
mapInstance.addOverlay(marker)
})
}
/** 加载设备数据 */
const loadDeviceData = async () => {
loading.value = true
try {
deviceList.value = await DeviceApi.getDeviceLocationList()
} finally {
loading.value = false
}
}
/** 初始化 */
const init = async () => {
await loadDeviceData()
if (!hasData.value) {
return
}
await loadBaiduMapSdk()
await nextTick()
initMap()
}
/** 组件挂载时初始化 */
onMounted(() => {
init()
})
/** 组件卸载时销毁地图实例 */
onUnmounted(() => {
if (mapInstance) {
mapInstance.destroy?.()
mapInstance = null
}
})
</script>

View File

@@ -54,13 +54,18 @@
</el-row>
<!-- 第三行消息统计行 -->
<el-row>
<el-row class="mb-4">
<el-col :span="24">
<MessageTrendCard />
</el-col>
</el-row>
<!-- TODO 第四行地图 -->
<!-- 第四行设备分布地图 -->
<el-row>
<el-col :span="24">
<DeviceMapCard />
</el-col>
</el-row>
</template>
<script setup lang="ts" name="Index">
@@ -69,6 +74,7 @@ import ComparisonCard from './components/ComparisonCard.vue'
import DeviceCountCard from './components/DeviceCountCard.vue'
import DeviceStateCountCard from './components/DeviceStateCountCard.vue'
import MessageTrendCard from './components/MessageTrendCard.vue'
import DeviceMapCard from './components/DeviceMapCard.vue'
/** IoT 首页 */
defineOptions({ name: 'IoTHome' })
@@ -96,8 +102,6 @@ const getStats = async () => {
try {
// 获取基础统计数据
statsData.value = await StatisticsApi.getStatisticsSummary()
} catch (error) {
console.error('获取统计数据出错:', error)
} finally {
loading.value = false
}