feat(iot):【设备定位】添加设备位置功能,支持地图展示和坐标选择

This commit is contained in:
YunaiV
2026-01-21 21:10:09 +08:00
parent 4aeb7a489a
commit 50216e5047
15 changed files with 714 additions and 139 deletions

View File

@@ -33,3 +33,6 @@ VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395
VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883
# VITE_APP_API_ENCRYPT_REQUEST_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
# 百度地图
VITE_BAIDU_MAP_KEY=Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN

View File

@@ -13,6 +13,7 @@ export namespace IotDeviceApi {
groupIds?: number[]; // 设备分组编号数组
productId: number; // 产品编号
productKey?: string; // 产品标识
productName?: string; // 产品名称(只有部分接口返回,例如 getDeviceLocationList
deviceType?: number; // 设备类型
gatewayId?: number; // 网关设备 ID
state?: number; // 设备状态
@@ -22,7 +23,6 @@ export namespace IotDeviceApi {
deviceSecret?: string; // 设备密钥,用于设备认证
authType?: string; // 认证类型(如一机一密、动态注册)
config?: string; // 设备配置
locationType?: number; // 定位类型
latitude?: number; // 设备位置的纬度
longitude?: number; // 设备位置的经度
createTime?: Date; // 创建时间
@@ -138,6 +138,11 @@ export function getDeviceListByProductId(productId: number) {
});
}
/** 获取设备位置列表(用于地图展示) */
export function getDeviceLocationList() {
return requestClient.get<IotDeviceApi.Device[]>('/iot/device/location-list');
}
/** 获取导入模板 */
export function importDeviceTemplate() {
return requestClient.download('/iot/device/get-import-template');

View File

@@ -17,7 +17,6 @@ export namespace IotProductApi {
description?: string; // 产品描述
status?: number; // 产品状态
deviceType?: number; // 设备类型
locationType?: number; // 定位类型
netType?: number; // 联网方式
codecType?: string; // 数据格式(编解码器类型)
dataFormat?: number; // 数据格式

View File

@@ -0,0 +1,3 @@
export { default as MapDialog } from './src/map-dialog.vue';
export { loadBaiduMapSdk } from './src/utils';

View File

@@ -0,0 +1,287 @@
<!-- 地图选择弹窗组件基于百度地图 GL 实现 -->
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Form, Input, Select, Space } from 'ant-design-vue';
import { loadBaiduMapSdk } from './utils';
const emit = defineEmits<{
confirm: [
data: {
address: string;
latitude: string;
longitude: string;
},
];
}>();
const mapContainerRef = ref<HTMLElement>();
const state = reactive({
lonLat: '', // 经纬度字符串,格式为 "经度,纬度"
address: '', // 地址信息
loading: false, // 地址搜索加载状态
latitude: '', // 纬度
longitude: '', // 经度
map: null as any, // 百度地图实例
mapAddressOptions: [] as any[], // 地址搜索选项
mapMarker: null as any, // 地图标记点
geocoder: null as any, // 地理编码器实例
mapContainerReady: false, // 地图容器是否准备好
});
// 初始经纬度(打开弹窗时传入)
const initLongitude = ref<number | undefined>();
const initLatitude = ref<number | undefined>();
/** 弹窗打开动画完成后初始化地图 */
async function handleDialogOpened() {
// 先显示地图容器
state.mapContainerReady = true;
// 等待下一个 DOM 更新周期,确保地图容器已渲染
await nextTick();
// 加载百度地图 SDK
await loadBaiduMapSdk();
initMapInstance();
}
/** 弹窗关闭后清理地图 */
function handleDialogClosed() {
// 销毁地图实例
if (state.map) {
state.map.destroy?.();
state.map = null;
}
state.mapMarker = null;
state.geocoder = null;
state.mapContainerReady = false;
}
/** 初始化地图实例 */
function initMapInstance() {
if (!mapContainerRef.value) {
return;
}
// 初始化地图和地理编码器
initMap();
initGeocoder();
// 监听地图点击事件
state.map.addEventListener('click', (e: any) => {
const point = e.latlng;
state.lonLat = `${point.lng},${point.lat}`;
regeoCode(state.lonLat);
});
// 如果有初始经纬度,加载标记点
if (initLongitude.value && initLatitude.value) {
const lonLat = `${initLongitude.value},${initLatitude.value}`;
regeoCode(lonLat);
}
}
/** 初始化地图 */
function initMap() {
state.map = new window.BMapGL.Map(mapContainerRef.value);
state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11);
state.map.enableScrollWheelZoom();
state.map.disableDoubleClickZoom();
state.map.addControl(new window.BMapGL.NavigationControl());
state.map.addControl(new window.BMapGL.ScaleControl());
state.map.addControl(new window.BMapGL.ZoomControl());
}
/** 初始化地理编码器 */
function initGeocoder() {
state.geocoder = new window.BMapGL.Geocoder();
}
/** 搜索地址 */
function autoSearch(queryValue: string) {
if (!queryValue) {
state.mapAddressOptions = [];
return;
}
state.loading = true;
// noinspection JSUnusedGlobalSymbols
const localSearch = new window.BMapGL.LocalSearch(state.map, {
onSearchComplete: (results: any) => {
state.loading = false;
const temp: any[] = [];
if (results && results._pois) {
results._pois.forEach((p: any) => {
const point = p.point;
if (point && point.lng && point.lat) {
temp.push({
name: p.title,
value: `${point.lng},${point.lat}`,
});
}
});
}
state.mapAddressOptions = temp;
},
});
localSearch.search(queryValue);
}
/** 处理地址选择 */
function handleAddressSelect(value: string) {
if (value) {
regeoCode(value);
}
}
/** 添加标记点 */
function setMarker(lnglat: string[]) {
if (!lnglat) {
return;
}
if (state.mapMarker !== null) {
state.map.removeOverlay(state.mapMarker);
}
const point = new window.BMapGL.Point(lnglat[0], lnglat[1]);
state.mapMarker = new window.BMapGL.Marker(point);
state.map.addOverlay(state.mapMarker);
state.map.centerAndZoom(point, 16);
}
/** 经纬度转地址、添加标记点 */
function regeoCode(lonLat: string) {
if (!lonLat) {
return;
}
const lnglat = lonLat.split(',');
if (lnglat.length !== 2) {
return;
}
state.longitude = lnglat[0]!;
state.latitude = lnglat[1]!;
const point = new window.BMapGL.Point(lnglat[0], lnglat[1]);
state.map.centerAndZoom(point, 16);
setMarker(lnglat);
getAddress(lnglat);
}
/** 根据经纬度获取地址信息 */
function getAddress(lnglat: string[]) {
const point = new window.BMapGL.Point(lnglat[0], lnglat[1]);
state.geocoder.getLocation(point, (result: any) => {
if (result && result.address) {
state.address = result.address;
}
});
}
/** 确认选择 */
function handleConfirm() {
if (state.longitude && state.latitude) {
emit('confirm', {
longitude: state.longitude,
latitude: state.latitude,
address: state.address,
});
}
modalApi.close();
}
const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
if (isOpen) {
handleDialogOpened();
} else {
handleDialogClosed();
}
},
});
/** 打开弹窗 */
function open(longitude?: number, latitude?: number) {
initLongitude.value = longitude;
initLatitude.value = latitude;
state.longitude = longitude ? String(longitude) : '';
state.latitude = latitude ? String(latitude) : '';
state.address = '';
state.mapAddressOptions = [];
modalApi.open();
}
defineExpose({ open });
</script>
<template>
<Modal :footer="false" class="w-[700px]" title="百度地图">
<div class="w-full">
<!-- 第一行位置搜索 -->
<Form :label-col="{ span: 4 }">
<Form.Item label="定位位置">
<Select
v-model:value="state.address"
:filter-option="false"
:loading="state.loading"
:options="
state.mapAddressOptions.map((item) => ({
label: item.name,
value: item.value,
}))
"
allow-clear
class="w-full"
placeholder="可输入地址查询经纬度"
show-search
@search="autoSearch"
@select="handleAddressSelect"
/>
</Form.Item>
<!-- 第二行坐标显示 -->
<Form.Item label="当前坐标">
<Space>
<Input
:value="state.longitude"
addon-before="经度"
disabled
style="width: 180px"
/>
<Input
:value="state.latitude"
addon-before="纬度"
disabled
style="width: 180px"
/>
</Space>
</Form.Item>
</Form>
<!-- 第三行地图 -->
<div
v-if="state.mapContainerReady"
ref="mapContainerRef"
class="mt-[10px] h-[400px] w-full"
></div>
<div
v-else
class="mt-[10px] flex h-[400px] w-full items-center justify-center"
>
<span class="text-gray-400">地图加载中...</span>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<Button type="primary" @click="handleConfirm"> </Button>
<Button @click="modalApi.close()"> </Button>
</div>
</Modal>
</template>

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: null | Promise<void> = null;
/**
* 加载百度地图 GL SDK
* @param timeout 超时时间(毫秒),默认 10000
* @returns Promise<void>
*/
export const loadBaiduMapSdk = (timeout = 10_000): 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.append(script);
});
return loadPromise;
};

View File

@@ -1,7 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DeviceTypeEnum, DICT_TYPE, LocationTypeEnum } from '@vben/constants';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -133,16 +133,6 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
.optional()
.or(z.literal('')),
},
{
fieldName: 'locationType',
label: '定位类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'longitude',
label: '设备经度',
@@ -150,11 +140,16 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入设备经度',
class: 'w-full',
min: -180,
max: 180,
precision: 6,
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
rules: z
.number()
.min(-180, '经度范围为 -180 到 180')
.max(180, '经度范围为 -180 到 180')
.optional()
.nullable(),
},
{
fieldName: 'latitude',
@@ -163,11 +158,16 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入设备纬度',
class: 'w-full',
min: -90,
max: 90,
precision: 6,
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
rules: z
.number()
.min(-90, '纬度范围为 -90 到 90')
.max(90, '纬度范围为 -90 到 90')
.optional()
.nullable(),
},
];
}

View File

@@ -11,17 +11,16 @@ import { formatDateTime } from '@vben/utils';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
message,
Modal,
Row,
} from 'ant-design-vue';
import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { MapDialog } from '#/components/map';
interface Props {
device: IotDeviceApi.Device;
@@ -35,12 +34,18 @@ const authPasswordVisible = ref(false);
const authInfo = ref<IotDeviceApi.DeviceAuthInfoRespVO>(
{} as IotDeviceApi.DeviceAuthInfoRespVO,
);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
/** 控制地图显示的标志 */
const showMap = computed(() => {
/** 是否有位置信息 */
const hasLocation = computed(() => {
return !!(props.device.longitude && props.device.latitude);
});
/** 打开地图弹窗 */
function openMapDialog() {
mapDialogRef.value?.open(props.device.longitude, props.device.latitude);
}
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
@@ -67,106 +72,63 @@ function handleAuthInfoDialogClose() {
authDialogVisible.value = false;
}
</script>
<template>
<div>
<Row :gutter="16">
<!-- 左侧设备信息 -->
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center">
<IconifyIcon class="mr-2 text-primary" icon="lucide:info" />
<span>设备信息</span>
</div>
</template>
<Descriptions :column="1" bordered size="small">
<Card title="设备信息">
<Descriptions :column="3" bordered size="small">
<Descriptions.Item label="产品名称">
{{ props.product.name }}
{{ product.name }}
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ props.product.productKey }}
{{ product.productKey }}
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="props.product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
<DictTag
:type="DICT_TYPE.IOT_LOCATION_TYPE"
:value="props.product.locationType"
:value="product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ props.device.deviceName }}
{{ device.deviceName }}
</Descriptions.Item>
<Descriptions.Item label="备注名称">
{{ props.device.nickname || '--' }}
{{ device.nickname || '--' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="props.device.state"
/>
<DictTag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDateTime(props.device.createTime) }}
{{ formatDateTime(device.createTime) }}
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDateTime(props.device.activeTime) }}
{{ formatDateTime(device.activeTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDateTime(props.device.onlineTime) }}
{{ formatDateTime(device.onlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDateTime(props.device.offlineTime) }}
{{ formatDateTime(device.offlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="设备位置">
<template v-if="hasLocation">
<span class="mr-2">
{{ device.longitude }}, {{ device.latitude }}
</span>
<Button type="link" size="small" @click="openMapDialog">
<IconifyIcon icon="lucide:map-pin" class="mr-1" />
查看地图
</Button>
</template>
<span v-else class="text-gray-400">暂无位置信息</span>
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
size="small"
type="link"
@click="handleAuthInfoDialogOpen"
>
<Button size="small" type="link" @click="handleAuthInfoDialogOpen">
查看
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<!-- 右侧地图 -->
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconifyIcon class="mr-2 text-primary" icon="lucide:map-pin" />
<span>设备位置</span>
</div>
<div class="text-sm text-gray-500">
最后上线{{ formatDateTime(props.device.onlineTime) || '--' }}
</div>
</div>
</template>
<div class="h-[500px] w-full">
<div
v-if="showMap"
class="flex h-full w-full items-center justify-center rounded bg-gray-100"
>
<span class="text-gray-400">地图组件</span>
</div>
<div
v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<IconifyIcon class="mr-2" icon="lucide:alert-triangle" />
<span>暂无位置信息</span>
</div>
</div>
</Card>
</Col>
</Row>
<!-- 认证信息弹框 -->
<Modal
@@ -226,5 +188,8 @@ function handleAuthInfoDialogClose() {
<Button @click="handleAuthInfoDialogClose">关闭</Button>
</div>
</Modal>
<!-- 地图弹窗 -->
<MapDialog ref="mapDialogRef" />
</div>
</template>

View File

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

View File

@@ -6,11 +6,12 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Collapse, message } from 'ant-design-vue';
import { Button, Collapse, message, Space } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { MapDialog } from '#/components/map';
import { $t } from '#/locales';
import { useAdvancedFormSchema, useBasicFormSchema } from '../data';
@@ -21,6 +22,7 @@ const emit = defineEmits(['success']);
const formData = ref<IotDeviceApi.Device>();
const products = ref<IotProductApi.Product[]>([]);
const activeKey = ref<string[]>([]);
const mapDialogRef = ref<InstanceType<typeof MapDialog>>();
const getTitle = computed(() => {
return formData.value?.id
@@ -78,12 +80,38 @@ async function getAdvancedFormValues() {
picUrl: formData.value?.picUrl,
groupIds: formData.value?.groupIds,
serialNumber: formData.value?.serialNumber,
locationType: formData.value?.locationType,
longitude: formData.value?.longitude,
latitude: formData.value?.latitude,
};
}
/** 打开地图选择弹窗 */
async function openMapDialog() {
// 如果高级表单未挂载,先展开 Collapse
if (!advancedFormApi.isMounted) {
activeKey.value = ['advanced'];
await nextTick();
await nextTick();
}
const values = await advancedFormApi.getValues();
mapDialogRef.value?.open(
values.longitude ? Number(values.longitude) : undefined,
values.latitude ? Number(values.latitude) : undefined,
);
}
/** 处理地图选择确认 */
async function handleMapConfirm(data: {
address: string;
latitude: string;
longitude: string;
}) {
if (advancedFormApi.isMounted) {
await advancedFormApi.setFieldValue('longitude', Number(data.longitude));
await advancedFormApi.setFieldValue('latitude', Number(data.latitude));
}
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -160,8 +188,13 @@ onMounted(async () => {
<Collapse v-model:active-key="activeKey" class="mt-4">
<Collapse.Panel key="advanced" header="更多设置">
<AdvancedForm />
<Space class="mt-2">
<Button type="primary" @click="openMapDialog">坐标拾取</Button>
</Space>
</Collapse.Panel>
</Collapse>
</div>
</Modal>
<!-- 地图选择弹窗 -->
<MapDialog ref="mapDialogRef" @confirm="handleMapConfirm" />
</template>

View File

@@ -11,6 +11,7 @@ import { getStatisticsSummary } from '#/api/iot/statistics';
import { defaultStatsData } from './data';
import DeviceCountCard from './modules/device-count-card.vue';
import DeviceMapCard from './modules/device-map-card.vue';
import DeviceStateCountCard from './modules/device-state-count-card.vue';
import MessageTrendCard from './modules/message-trend-card.vue';
@@ -97,10 +98,17 @@ onMounted(() => {
</Row>
<!-- 第三行消息统计 -->
<Row :gutter="16">
<Row :gutter="16" class="mb-4">
<Col :span="24">
<MessageTrendCard />
</Col>
</Row>
<!-- 第四行设备分布地图 -->
<Row :gutter="16">
<Col :span="24">
<DeviceMapCard />
</Col>
</Row>
</Page>
</template>

View File

@@ -0,0 +1,215 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Card, Empty, Spin } from 'ant-design-vue';
import { getDeviceLocationList } from '#/api/iot/device/device';
import { loadBaiduMapSdk } from '#/components/map';
import { DeviceStateEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceMapCard' });
const router = useRouter();
const mapContainerRef = ref<HTMLElement>();
let mapInstance: any = null;
const loading = ref(true);
const deviceList = ref<IotDeviceApi.Device[]>([]);
/** 是否有数据 */
const hasData = computed(() => deviceList.value.length > 0);
/** 设备状态颜色映射 */
const stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // 待激活 - 黄色
[DeviceStateEnum.ONLINE]: '#22C55E', // 在线 - 绿色
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 离线 - 灰色
};
/** 获取设备状态配置 */
function getStateConfig(state: number): { color: string; name: string } {
const stateNames: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '待激活',
[DeviceStateEnum.ONLINE]: '在线',
[DeviceStateEnum.OFFLINE]: '离线',
};
return {
name: stateNames[state] || '未知',
color: stateColorMap[state] || '#909399',
};
}
/** 创建自定义标记点图标 */
function 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),
});
}
/** 初始化地图 */
function 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)" class="device-link" data-id="${device.id}" style="color: #1890ff; 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('.device-link');
if (link) {
link.addEventListener('click', (e) => {
e.preventDefault();
const deviceId = e.target as HTMLElement.dataset.id;
if (deviceId) {
router.push({
name: 'IoTDeviceDetail',
params: { id: deviceId },
});
}
});
}
}, 100);
});
mapInstance.openInfoWindow(infoWindow, point);
});
mapInstance.addOverlay(marker);
});
}
/** 加载设备数据 */
async function loadDeviceData() {
loading.value = true;
try {
deviceList.value = await getDeviceLocationList();
} finally {
loading.value = false;
}
}
/** 初始化 */
async function init() {
await loadDeviceData();
if (!hasData.value) {
return;
}
await loadBaiduMapSdk();
initMap();
}
/** 组件挂载时初始化 */
onMounted(() => {
init();
});
/** 组件卸载时销毁地图实例 */
onUnmounted(() => {
if (mapInstance) {
mapInstance.destroy?.();
mapInstance = null;
}
});
</script>
<template>
<Card class="h-full" title="设备分布地图">
<template #extra>
<div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.ONLINE] }"
></span>
<span class="text-gray-500">在线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE] }"
></span>
<span class="text-gray-500">离线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
}"
></span>
<span class="text-gray-500">待激活</span>
</span>
</div>
</template>
<Spin v-if="loading" class="flex h-[500px] items-center justify-center" />
<Empty
v-else-if="!hasData"
class="h-[500px]"
description="暂无设备位置数据"
/>
<div
v-show="hasData && !loading"
ref="mapContainerRef"
class="h-[500px] w-full"
></div>
</Card>
</template>

View File

@@ -137,6 +137,7 @@ export function useBasicFormSchema(
},
rules: 'required',
},
// TODO @haohao这个貌似不需要
{
fieldName: 'status',
label: '产品状态',
@@ -149,16 +150,6 @@ export function useBasicFormSchema(
defaultValue: 0,
rules: 'required',
},
{
fieldName: 'locationType',
label: '定位类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_LOCATION_TYPE, 'number'),
placeholder: '请选择定位类型',
},
rules: 'required',
},
];
}

View File

@@ -35,9 +35,6 @@ function formatDate(date?: Date | string) {
:value="product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
{{ product.locationType ?? '-' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(product.createTime) }}
</Descriptions.Item>

View File

@@ -8,6 +8,13 @@ export const IOT_PROVIDE_KEY = {
PRODUCT: 'IOT_PRODUCT',
};
/** IoT 设备状态枚举 */
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
OFFLINE = 2, // 离线
ONLINE = 1, // 在线
}
/** IoT 产品物模型类型枚举类 */
export const IoTThingModelTypeEnum = {
PROPERTY: 1, // 属性