569 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="position-picker-mobile">
<van-field
:model-value="modelValue"
is-link
readonly
:label="label"
:placeholder="placeholder"
@click="openPopup"
/>
<van-popup
v-model:show="showPopup"
position="right"
class="position-picker-popup"
@opened="handlePopupOpened"
>
<div class="picker-screen">
<van-nav-bar
title="坐标点选取"
left-text="返回"
left-arrow
fixed
placeholder
@click-left="showPopup = false"
/>
<div class="picker-toolbar">
<div class="coord-grid">
<div class="coord-card">
<span class="coord-label">经度</span>
<span class="coord-value">{{ displayLongitude || '--' }}</span>
</div>
<div class="coord-card">
<span class="coord-label">纬度</span>
<span class="coord-value">{{ displayLatitude || '--' }}</span>
</div>
</div>
<div class="search-row">
<van-search
v-model="searchKeyword"
shape="round"
class="search-box"
:placeholder="searchPlaceholder"
@search="handleSearch"
/>
<van-button
type="primary"
block
class="search-btn"
:loading="searching"
@click="handleSearch"
>
搜索
</van-button>
</div>
<div v-if="draftAddress" class="address-tip">
<span class="address-label">地点</span>
<span class="address-text">{{ draftAddress }}</span>
</div>
</div>
<div class="map-wrapper">
<div ref="mapContainerRef" class="map-container"></div>
<div v-if="mapError" class="map-overlay map-overlay--error">
<p>{{ mapError }}</p>
<van-button type="primary" size="small" @click="retryMapLoad">重新加载</van-button>
</div>
<div v-else-if="!mapReady" class="map-overlay">地图加载中...</div>
</div>
<div class="bottom-bar">
<div class="bottom-tip">点击地图或拖动标记点后再确认位置</div>
<van-button type="primary" block round @click="handleConfirm">确认位置</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import axios from 'axios'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
import { showToast } from 'vant'
const AMAP_CONFIG = {
webServiceKey: 'c30e9ebd414fd6a4dfcc1ba8c2060dbb',
baseUrl: 'https://restapi.amap.com',
timeout: 10000
}
const DEFAULT_CENTER = {
longitude: 106.551643,
latitude: 29.563761
}
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow
})
const props = defineProps({
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请选择位置'
},
searchPlaceholder: {
type: String,
default: '请输入地点名称'
},
initialLongitude: {
type: [String, Number],
default: null
},
initialLatitude: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const showPopup = ref(false)
const mapReady = ref(false)
const mapError = ref('')
const searching = ref(false)
const searchKeyword = ref('')
const mapContainerRef = ref(null)
const selectedLongitude = ref(null)
const selectedLatitude = ref(null)
const selectedAddress = ref('')
const draftLongitude = ref(null)
const draftLatitude = ref(null)
const draftAddress = ref('')
let mapInstance = null
let markerInstance = null
let reverseGeocodeToken = 0
const normalizeCoordinate = (value) => {
if (value === '' || value === null || value === undefined) {
return null
}
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
const displayLongitude = computed(() => {
return draftLongitude.value === null ? '' : draftLongitude.value.toFixed(6)
})
const displayLatitude = computed(() => {
return draftLatitude.value === null ? '' : draftLatitude.value.toFixed(6)
})
const currentCenter = () => {
const longitude = selectedLongitude.value ?? normalizeCoordinate(props.initialLongitude) ?? DEFAULT_CENTER.longitude
const latitude = selectedLatitude.value ?? normalizeCoordinate(props.initialLatitude) ?? DEFAULT_CENTER.latitude
return { longitude, latitude }
}
const resolveInitialSelection = () => {
if (selectedLongitude.value !== null && selectedLatitude.value !== null) {
return {
longitude: selectedLongitude.value,
latitude: selectedLatitude.value
}
}
const longitude = normalizeCoordinate(props.initialLongitude)
const latitude = normalizeCoordinate(props.initialLatitude)
if (longitude !== null && latitude !== null) {
return { longitude, latitude }
}
return null
}
const syncDraftFromSelected = () => {
const initialSelection = resolveInitialSelection()
draftLongitude.value = initialSelection?.longitude ?? null
draftLatitude.value = initialSelection?.latitude ?? null
draftAddress.value = selectedAddress.value || props.modelValue || ''
searchKeyword.value = draftAddress.value
}
const setMarkerPosition = (longitude, latitude, shouldPan = true) => {
if (!mapInstance || longitude === null || latitude === null) {
return
}
const latLng = [latitude, longitude]
if (!markerInstance) {
markerInstance = L.marker(latLng, {
draggable: true
}).addTo(mapInstance)
markerInstance.on('dragend', () => {
const markerLatLng = markerInstance.getLatLng()
updateDraftPosition(markerLatLng.lng, markerLatLng.lat, true)
})
} else {
markerInstance.setLatLng(latLng)
}
if (shouldPan) {
mapInstance.setView(latLng, Math.max(mapInstance.getZoom(), 15))
}
}
const reverseGeocode = async (longitude, latitude) => {
const token = ++reverseGeocodeToken
try {
const response = await axios.get(`${AMAP_CONFIG.baseUrl}/v3/geocode/regeo`, {
params: {
key: AMAP_CONFIG.webServiceKey,
location: `${longitude},${latitude}`,
extensions: 'base',
output: 'json'
},
timeout: AMAP_CONFIG.timeout
})
if (token !== reverseGeocodeToken) {
return
}
if (response.data.status !== '1') {
throw new Error(response.data.info || '逆地理编码失败')
}
const formattedAddress = response.data.regeocode?.formatted_address || ''
draftAddress.value = formattedAddress
searchKeyword.value = formattedAddress
} catch (_error) {
if (token !== reverseGeocodeToken) {
return
}
draftAddress.value = draftAddress.value || `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`
}
}
const updateDraftPosition = (longitude, latitude, shouldResolveAddress = false) => {
draftLongitude.value = Number(longitude.toFixed(6))
draftLatitude.value = Number(latitude.toFixed(6))
setMarkerPosition(draftLongitude.value, draftLatitude.value, false)
if (shouldResolveAddress) {
reverseGeocode(draftLongitude.value, draftLatitude.value)
}
}
const initMap = async () => {
mapError.value = ''
mapReady.value = false
try {
await nextTick()
if (!mapContainerRef.value) {
return
}
const center = currentCenter()
if (!mapInstance) {
mapInstance = L.map(mapContainerRef.value, {
zoomControl: true,
attributionControl: false
}).setView([center.latitude, center.longitude], 13)
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
subdomains: ['1', '2', '3', '4'],
maxZoom: 18,
minZoom: 3
}).addTo(mapInstance)
mapInstance.on('click', (event) => {
updateDraftPosition(event.latlng.lng, event.latlng.lat, true)
})
} else {
mapInstance.setView([center.latitude, center.longitude], mapInstance.getZoom() || 13)
}
if (draftLongitude.value !== null && draftLatitude.value !== null) {
setMarkerPosition(draftLongitude.value, draftLatitude.value)
} else if (markerInstance) {
markerInstance.remove()
markerInstance = null
}
mapReady.value = true
requestAnimationFrame(() => {
mapInstance?.invalidateSize()
})
if (!draftAddress.value && draftLongitude.value !== null && draftLatitude.value !== null) {
reverseGeocode(draftLongitude.value, draftLatitude.value)
}
} catch (error) {
console.error('地图初始化失败:', error)
mapError.value = '地图初始化失败,请稍后重试'
}
}
const openPopup = () => {
syncDraftFromSelected()
showPopup.value = true
}
const handlePopupOpened = async () => {
await initMap()
requestAnimationFrame(() => {
mapInstance?.invalidateSize()
})
}
const handleSearch = async () => {
const keyword = searchKeyword.value.trim()
if (!keyword) {
showToast('请输入地点名称')
return
}
searching.value = true
try {
const response = await axios.get(`${AMAP_CONFIG.baseUrl}/v3/geocode/geo`, {
params: {
key: AMAP_CONFIG.webServiceKey,
address: keyword,
output: 'json'
},
timeout: AMAP_CONFIG.timeout
})
if (response.data.status !== '1') {
throw new Error(response.data.info || '地点搜索失败')
}
const geocode = response.data.geocodes?.[0]
if (!geocode?.location) {
throw new Error('未找到对应地点')
}
const [longitude, latitude] = geocode.location.split(',').map(Number)
draftAddress.value = geocode.formatted_address || keyword
searchKeyword.value = draftAddress.value
updateDraftPosition(longitude, latitude)
setMarkerPosition(longitude, latitude)
} catch (error) {
console.error('地点搜索失败:', error)
showToast(error.message || '地点搜索失败,请重试')
} finally {
searching.value = false
}
}
const retryMapLoad = () => {
initMap()
}
const handleConfirm = () => {
if (draftLongitude.value === null || draftLatitude.value === null) {
showToast('请先在地图上选择位置')
return
}
selectedLongitude.value = draftLongitude.value
selectedLatitude.value = draftLatitude.value
selectedAddress.value = draftAddress.value || searchKeyword.value.trim() || `${draftLatitude.value}, ${draftLongitude.value}`
emit('update:modelValue', selectedAddress.value)
emit('change', {
longitude: selectedLongitude.value,
latitude: selectedLatitude.value,
address: selectedAddress.value
})
showPopup.value = false
}
watch(
() => props.modelValue,
(value) => {
if (!value) {
return
}
selectedAddress.value = value
if (!showPopup.value) {
draftAddress.value = value
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>
<style scoped lang="scss">
.position-picker-mobile {
width: 100%;
}
.position-picker-popup {
width: 100%;
height: 100%;
background: #f7f8fa;
}
.picker-screen {
display: flex;
flex-direction: column;
height: 100%;
}
.picker-toolbar {
padding: 12px 12px 10px;
background: #fff;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
z-index: 2;
}
.coord-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.coord-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
}
.coord-label {
color: #6b7280;
font-size: 12px;
}
.coord-value {
color: #1f2937;
font-size: 16px;
font-weight: 600;
word-break: break-all;
}
.search-row {
display: flex;
align-items: center;
gap: 10px;
}
.search-box {
flex: 1;
padding: 0;
background: transparent;
}
.search-btn {
width: 88px;
flex-shrink: 0;
}
.address-tip {
display: flex;
gap: 8px;
margin-top: 10px;
color: #4b5563;
font-size: 12px;
line-height: 1.5;
}
.address-label {
flex-shrink: 0;
color: #2563eb;
font-weight: 600;
}
.address-text {
word-break: break-all;
}
.map-wrapper {
position: relative;
flex: 1;
min-height: 280px;
background: #eef2f7;
}
.map-container {
width: 100%;
height: 100%;
}
.map-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
font-size: 14px;
background: rgba(247, 248, 250, 0.9);
z-index: 2;
}
.map-overlay--error {
flex-direction: column;
gap: 12px;
color: #ee0a24;
}
.bottom-bar {
padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.08);
}
.bottom-tip {
margin-bottom: 10px;
color: #86909c;
font-size: 12px;
text-align: center;
}
:deep(.van-nav-bar) {
flex-shrink: 0;
}
:deep(.van-nav-bar__content) {
background: #fff;
}
:deep(.leaflet-container) {
font-family: inherit;
}
</style>