feat(iot):【设备定位】添加设备位置功能,支持地图展示和坐标选择
This commit is contained in:
3
apps/web-antd/src/components/map/index.ts
Normal file
3
apps/web-antd/src/components/map/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MapDialog } from './src/map-dialog.vue';
|
||||
|
||||
export { loadBaiduMapSdk } from './src/utils';
|
||||
287
apps/web-antd/src/components/map/src/map-dialog.vue
Normal file
287
apps/web-antd/src/components/map/src/map-dialog.vue
Normal 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>
|
||||
62
apps/web-antd/src/components/map/src/utils.ts
Normal file
62
apps/web-antd/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: 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;
|
||||
};
|
||||
Reference in New Issue
Block a user