<!-- 考勤地点地图选择器:百度地图展示,对外统一输出 WGS84 坐标 -->
|
<script lang="ts" setup>
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { Alert, Button, Input, Spin, message } from 'ant-design-vue';
|
|
import { loadBaiduMapSdk } from '#/components/map';
|
|
/** 对外输出:WGS84 坐标 + 反查到的地址/地点名 */
|
interface LocationSelection {
|
address?: string;
|
latitude: number;
|
locationName?: string;
|
longitude: number;
|
}
|
|
interface Props {
|
disabled?: boolean;
|
latitude?: number;
|
longitude?: number;
|
radiusMeters?: number;
|
}
|
|
const props = withDefaults(defineProps<Props>(), {
|
disabled: false,
|
latitude: undefined,
|
longitude: undefined,
|
radiusMeters: 10,
|
});
|
|
const emit = defineEmits<{
|
select: [location: LocationSelection];
|
}>();
|
|
/** 默认中心点:新疆乌鲁木齐,未配置坐标时使用 */
|
const DEFAULT_CENTER: [number, number] = [87.625, 43.835];
|
const PI = Math.PI;
|
const X_PI = (PI * 3000) / 180;
|
const EARTH_SEMI_MAJOR_AXIS = 6_378_245;
|
const EARTH_ECCENTRICITY_SQUARED = 0.006_693_421_622_965_943;
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
type AnyMap = any;
|
|
const mapContainer = ref<HTMLElement>();
|
const loading = ref(false);
|
const searchKeyword = ref('');
|
const selectedAddress = ref('');
|
|
let map: AnyMap;
|
let marker: AnyMap;
|
let rangeCircle: AnyMap;
|
let geocoder: AnyMap;
|
|
const baiduMapKey = String(import.meta.env.VITE_BAIDU_MAP_KEY ?? '').trim();
|
const hasMapConfig = computed(() => Boolean(baiduMapKey));
|
|
function outOfChina(longitude: number, latitude: number) {
|
return (
|
longitude < 72.004 ||
|
longitude > 137.8347 ||
|
latitude < 0.8293 ||
|
latitude > 55.8271
|
);
|
}
|
|
function transformLatitude(longitude: number, latitude: number) {
|
let result =
|
-100 +
|
2 * longitude +
|
3 * latitude +
|
0.2 * latitude * latitude +
|
0.1 * longitude * latitude +
|
0.2 * Math.sqrt(Math.abs(longitude));
|
result +=
|
((20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2) / 3;
|
result +=
|
((20 * Math.sin(latitude * PI) + 40 * Math.sin((latitude / 3) * PI)) * 2) / 3;
|
result +=
|
((160 * Math.sin((latitude / 12) * PI) +
|
320 * Math.sin((latitude * PI) / 30)) *
|
2) /
|
3;
|
return result;
|
}
|
|
function transformLongitude(longitude: number, latitude: number) {
|
let result =
|
300 +
|
longitude +
|
2 * latitude +
|
0.1 * longitude * longitude +
|
0.1 * longitude * latitude +
|
0.1 * Math.sqrt(Math.abs(longitude));
|
result +=
|
((20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2) / 3;
|
result +=
|
((20 * Math.sin(longitude * PI) + 40 * Math.sin((longitude / 3) * PI)) * 2) / 3;
|
result +=
|
((150 * Math.sin((longitude / 12) * PI) +
|
300 * Math.sin((longitude / 30) * PI)) *
|
2) /
|
3;
|
return result;
|
}
|
|
/** WGS84 → GCJ02 */
|
function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
|
if (outOfChina(longitude, latitude)) {
|
return [longitude, latitude];
|
}
|
let deltaLatitude = transformLatitude(longitude - 105, latitude - 35);
|
let deltaLongitude = transformLongitude(longitude - 105, latitude - 35);
|
const radianLatitude = (latitude / 180) * PI;
|
let magic = Math.sin(radianLatitude);
|
magic = 1 - EARTH_ECCENTRICITY_SQUARED * magic * magic;
|
const sqrtMagic = Math.sqrt(magic);
|
deltaLatitude =
|
(deltaLatitude * 180) /
|
(((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY_SQUARED)) /
|
(magic * sqrtMagic)) *
|
PI);
|
deltaLongitude =
|
(deltaLongitude * 180) /
|
((EARTH_SEMI_MAJOR_AXIS / sqrtMagic) * Math.cos(radianLatitude) * PI);
|
return [longitude + deltaLongitude, latitude + deltaLatitude];
|
}
|
|
/** GCJ02 → WGS84,迭代逼近,误差可控制在 0.5 米内 */
|
function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
|
if (outOfChina(longitude, latitude)) {
|
return [longitude, latitude];
|
}
|
let wgsLongitude = longitude;
|
let wgsLatitude = latitude;
|
for (let index = 0; index < 5; index++) {
|
const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude);
|
wgsLongitude += longitude - gcjLongitude;
|
wgsLatitude += latitude - gcjLatitude;
|
}
|
return [wgsLongitude, wgsLatitude];
|
}
|
|
/** GCJ02 → BD09 */
|
function gcj02ToBd09(longitude: number, latitude: number): [number, number] {
|
const z =
|
Math.sqrt(longitude * longitude + latitude * latitude) +
|
0.000_02 * Math.sin(latitude * X_PI);
|
const theta =
|
Math.atan2(latitude, longitude) + 0.000_003 * Math.cos(longitude * X_PI);
|
return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
|
}
|
|
/** BD09 → GCJ02 */
|
function bd09ToGcj02(longitude: number, latitude: number): [number, number] {
|
const x = longitude - 0.0065;
|
const y = latitude - 0.006;
|
const z = Math.sqrt(x * x + y * y) - 0.000_02 * Math.sin(y * X_PI);
|
const theta = Math.atan2(y, x) - 0.000_003 * Math.cos(x * X_PI);
|
return [z * Math.cos(theta), z * Math.sin(theta)];
|
}
|
|
function wgs84ToBd09(longitude: number, latitude: number): [number, number] {
|
return gcj02ToBd09(...wgs84ToGcj02(longitude, latitude));
|
}
|
|
function bd09ToWgs84(longitude: number, latitude: number): [number, number] {
|
return gcj02ToWgs84(...bd09ToGcj02(longitude, latitude));
|
}
|
|
function updateOverlay(
|
longitude: number,
|
latitude: number,
|
centerMap = false,
|
) {
|
if (!map || !window.BMapGL) {
|
return;
|
}
|
const point = new window.BMapGL.Point(longitude, latitude);
|
if (!marker) {
|
marker = new window.BMapGL.Marker(point);
|
map.addOverlay(marker);
|
} else {
|
marker.setPosition(point);
|
}
|
if (!rangeCircle) {
|
rangeCircle = new window.BMapGL.Circle(point, props.radiusMeters, {
|
fillColor: '#1677ff',
|
fillOpacity: 0.16,
|
strokeColor: '#1677ff',
|
strokeOpacity: 0.8,
|
strokeWeight: 2,
|
});
|
map.addOverlay(rangeCircle);
|
} else {
|
rangeCircle.setCenter(point);
|
rangeCircle.setRadius(props.radiusMeters);
|
}
|
if (centerMap) {
|
map.centerAndZoom(point, 19);
|
}
|
}
|
|
function reverseGeocode(longitude: number, latitude: number) {
|
return new Promise<{ address?: string; name?: string }>((resolve) => {
|
if (!geocoder) {
|
resolve({});
|
return;
|
}
|
geocoder.getLocation(
|
new window.BMapGL.Point(longitude, latitude),
|
(result: any) => {
|
const components = result?.addressComponents ?? {};
|
const nearestPoi = result?.surroundingPois?.[0]?.title;
|
resolve({
|
address: result?.address,
|
name:
|
nearestPoi ||
|
components.street ||
|
components.district ||
|
components.city,
|
});
|
},
|
);
|
});
|
}
|
|
/** 地图坐标(BD09)落点后,回传 WGS84 给表单 */
|
async function selectBaiduLocation(
|
longitude: number,
|
latitude: number,
|
centerMap = true,
|
) {
|
updateOverlay(longitude, latitude, centerMap);
|
const [wgsLongitude, wgsLatitude] = bd09ToWgs84(longitude, latitude);
|
const location = await reverseGeocode(longitude, latitude);
|
selectedAddress.value = location.address ?? '';
|
emit('select', {
|
address: location.address,
|
latitude: Number(wgsLatitude.toFixed(7)),
|
locationName: location.name,
|
longitude: Number(wgsLongitude.toFixed(7)),
|
});
|
}
|
|
async function handleSearch() {
|
const keyword = searchKeyword.value.trim();
|
if (!keyword || !geocoder) {
|
return;
|
}
|
loading.value = true;
|
try {
|
const point = await new Promise<any>((resolve, reject) => {
|
geocoder.getPoint(keyword, (result: any) => {
|
result
|
? resolve(result)
|
: reject(
|
new Error('没有搜索到该地址,请输入更完整的省市区和地点名称'),
|
);
|
});
|
});
|
await selectBaiduLocation(point.lng, point.lat);
|
} catch (error: any) {
|
message.warning(error?.message || '地址搜索失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function initializeMap() {
|
if (!hasMapConfig.value || !mapContainer.value) {
|
return;
|
}
|
loading.value = true;
|
try {
|
await loadBaiduMapSdk(15_000);
|
await nextTick();
|
const hasCoordinates =
|
Number.isFinite(props.longitude) && Number.isFinite(props.latitude);
|
const center = hasCoordinates
|
? wgs84ToBd09(Number(props.longitude), Number(props.latitude))
|
: DEFAULT_CENTER;
|
map = new window.BMapGL.Map(mapContainer.value);
|
map.centerAndZoom(
|
new window.BMapGL.Point(center[0], center[1]),
|
hasCoordinates ? 19 : 12,
|
);
|
map.enableScrollWheelZoom(true);
|
map.addControl(new window.BMapGL.ZoomControl());
|
map.addControl(new window.BMapGL.ScaleControl());
|
geocoder = new window.BMapGL.Geocoder();
|
map.addEventListener('click', (event: any) => {
|
if (!props.disabled) {
|
void selectBaiduLocation(event.latlng.lng, event.latlng.lat, false);
|
}
|
});
|
if (hasCoordinates) {
|
updateOverlay(center[0], center[1]);
|
const location = await reverseGeocode(center[0], center[1]);
|
selectedAddress.value = location.address ?? '';
|
}
|
} catch (error: any) {
|
message.error(error?.message || '地图初始化失败');
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
watch(
|
() => [props.longitude, props.latitude] as const,
|
([longitude, latitude]) => {
|
if (
|
!map ||
|
!Number.isFinite(longitude) ||
|
!Number.isFinite(latitude)
|
) {
|
return;
|
}
|
const point = wgs84ToBd09(Number(longitude), Number(latitude));
|
updateOverlay(point[0], point[1]);
|
},
|
);
|
|
watch(
|
() => props.radiusMeters,
|
(radius) => {
|
if (Number.isFinite(radius)) {
|
rangeCircle?.setRadius(Number(radius));
|
}
|
},
|
);
|
|
onMounted(initializeMap);
|
|
onBeforeUnmount(() => {
|
map?.clearOverlays?.();
|
map?.destroy?.();
|
map = undefined;
|
marker = undefined;
|
rangeCircle = undefined;
|
geocoder = undefined;
|
});
|
</script>
|
|
<template>
|
<Alert
|
v-if="!hasMapConfig"
|
type="warning"
|
show-icon
|
message="百度地图尚未配置"
|
description="请配置 VITE_BAIDU_MAP_KEY 后重新构建前端;未配置时可手工填写经纬度。"
|
/>
|
<Spin v-else :spinning="loading">
|
<div v-if="!disabled" class="mb-2 flex gap-2">
|
<Input
|
v-model:value="searchKeyword"
|
allow-clear
|
placeholder="输入省、市、区和具体地点,例如:乌鲁木齐市厂区"
|
@press-enter="handleSearch"
|
/>
|
<Button :loading="loading" @click="handleSearch">搜索地址</Button>
|
</div>
|
<div
|
ref="mapContainer"
|
class="h-[360px] w-full overflow-hidden rounded border border-gray-200"
|
></div>
|
<div
|
class="mt-2 flex flex-wrap justify-between gap-2 text-xs text-gray-500"
|
>
|
<span>
|
{{
|
disabled
|
? '打卡位置及允许范围'
|
: '点击地图选择打卡中心点;蓝色圆圈表示允许打卡范围'
|
}}
|
</span>
|
<span v-if="selectedAddress">{{ selectedAddress }}</span>
|
</div>
|
</Spin>
|
</template>
|