213 lines
5.9 KiB
Vue
213 lines
5.9 KiB
Vue
<script setup lang="ts">
|
||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { NButton } from 'naive-ui'
|
||
import { loadAMap } from '@/lib/amap'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
open: boolean
|
||
latitude: number | null
|
||
longitude: number | null
|
||
title?: string
|
||
description?: string
|
||
footerText?: string
|
||
}>(), {
|
||
title: '地图选点',
|
||
description: '点击地图即可回填经纬度,也可以继续手动修改。',
|
||
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
confirm: [payload: { latitude: number; longitude: number }]
|
||
}>()
|
||
|
||
const loading = ref(false)
|
||
const errorMsg = ref('')
|
||
const pickedLat = ref<number | null>(null)
|
||
const pickedLng = ref<number | null>(null)
|
||
const mapEl = ref<HTMLDivElement | null>(null)
|
||
|
||
let mapPickerAMap: typeof AMap | null = null
|
||
let mapPickerInstance: AMap.Map | null = null
|
||
let mapPickerMarker: AMap.Marker | null = null
|
||
|
||
function formatCoordinate(value: number | null) {
|
||
return value === null ? '未选择' : value.toFixed(6)
|
||
}
|
||
|
||
function destroyMapPicker() {
|
||
mapPickerMarker?.setMap(null)
|
||
mapPickerMarker = null
|
||
mapPickerInstance?.destroy()
|
||
mapPickerInstance = null
|
||
}
|
||
|
||
function placeMarker(longitude: number, latitude: number) {
|
||
if (!mapPickerAMap || !mapPickerInstance) return
|
||
|
||
if (!mapPickerMarker) {
|
||
mapPickerMarker = new mapPickerAMap.Marker({
|
||
position: [longitude, latitude],
|
||
title: '拍摄位置',
|
||
} as AMap.MarkerOptions)
|
||
mapPickerMarker.setMap(mapPickerInstance)
|
||
} else {
|
||
mapPickerMarker.setPosition([longitude, latitude])
|
||
}
|
||
}
|
||
|
||
function extractLngLat(event: unknown) {
|
||
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
||
const lng = payload?.lnglat?.lng
|
||
const lat = payload?.lnglat?.lat
|
||
|
||
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
||
return { lng, lat }
|
||
}
|
||
|
||
function handleMapClick(event: unknown) {
|
||
const picked = extractLngLat(event)
|
||
if (!picked) return
|
||
|
||
pickedLng.value = picked.lng
|
||
pickedLat.value = picked.lat
|
||
placeMarker(picked.lng, picked.lat)
|
||
}
|
||
|
||
async function initializeMap() {
|
||
loading.value = true
|
||
errorMsg.value = ''
|
||
pickedLat.value = props.latitude
|
||
pickedLng.value = props.longitude
|
||
|
||
await nextTick()
|
||
destroyMapPicker()
|
||
|
||
try {
|
||
mapPickerAMap = await loadAMap()
|
||
|
||
const hasCoordinates =
|
||
typeof props.latitude === 'number' &&
|
||
typeof props.longitude === 'number'
|
||
|
||
const center: [number, number] = hasCoordinates
|
||
? [props.longitude as number, props.latitude as number]
|
||
: [104.07, 30.67]
|
||
|
||
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
|
||
viewMode: '2D',
|
||
pitch: 0,
|
||
rotation: 0,
|
||
zoom: hasCoordinates ? 11 : 4,
|
||
center,
|
||
mapStyle: 'amap://styles/normal',
|
||
resizeEnable: true,
|
||
} as AMap.MapOptions)
|
||
|
||
mapPickerInstance.on('click', handleMapClick)
|
||
|
||
if (hasCoordinates) {
|
||
placeMarker(props.longitude as number, props.latitude as number)
|
||
}
|
||
} catch (error) {
|
||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
emit('close')
|
||
}
|
||
|
||
function confirm() {
|
||
if (pickedLat.value === null || pickedLng.value === null) return
|
||
emit('confirm', {
|
||
latitude: pickedLat.value,
|
||
longitude: pickedLng.value,
|
||
})
|
||
}
|
||
|
||
watch(() => props.open, open => {
|
||
if (open) {
|
||
initializeMap()
|
||
} else {
|
||
loading.value = false
|
||
errorMsg.value = ''
|
||
destroyMapPicker()
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
destroyMapPicker()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="open"
|
||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||
@click="close"
|
||
>
|
||
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
|
||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||
<div>
|
||
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
||
<p class="mt-1 text-sm text-slate-500">{{ description }}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||
@click="close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div class="px-6 py-5">
|
||
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||
<span>当前选择:</span>
|
||
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(pickedLat) }}</span>
|
||
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(pickedLng) }}</span>
|
||
</div>
|
||
|
||
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
|
||
<div ref="mapEl" class="h-[420px] w-full"></div>
|
||
|
||
<div
|
||
v-if="loading"
|
||
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
||
>
|
||
正在加载地图...
|
||
</div>
|
||
|
||
<div
|
||
v-if="errorMsg"
|
||
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
||
>
|
||
{{ errorMsg }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
||
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
||
<div class="flex gap-3">
|
||
<NButton secondary strong @click="close">取消</NButton>
|
||
<NButton
|
||
type="primary"
|
||
secondary
|
||
strong
|
||
:disabled="pickedLat === null || pickedLng === null"
|
||
@click="confirm"
|
||
>
|
||
使用这个位置
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|