mirror of
https://github.com/yudaocode/yudao-ui-admin-vue3.git
synced 2026-03-29 23:25:52 +00:00
feat(iot):【设备定位:100%】首页接入地图,基于 sequential-crafting-thacker.md 规划
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -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 = {
|
||||
// 查询全局的数据统计
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import MapDialog from './src/MapDialog.vue'
|
||||
export { loadBaiduMapSdk } from './src/utils'
|
||||
|
||||
export { MapDialog }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
62
src/components/Map/src/utils.ts
Normal file
62
src/components/Map/src/utils.ts
Normal 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
|
||||
}
|
||||
187
src/views/iot/home/components/DeviceMapCard.vue
Normal file
187
src/views/iot/home/components/DeviceMapCard.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user