569 lines
13 KiB
Vue
569 lines
13 KiB
Vue
<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>
|