<script setup>
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { ElMessage } from 'element-plus'
|
import { Search } from '@element-plus/icons-vue'
|
import { loadAMap } from '@/utils/amap'
|
|
const props = defineProps({
|
points: { type: Array, default: () => [] },
|
activeIndex: { type: Number, default: null },
|
cityName: { type: String, default: '' },
|
showSearch: { type: Boolean, default: true },
|
directPlacement: { type: Boolean, default: false },
|
address: { type: String, default: '' },
|
searchLabel: { type: String, default: '' },
|
compact: { type: Boolean, default: false },
|
})
|
const emit = defineEmits(['place', 'address-resolved'])
|
|
const mapContainer = ref(null)
|
const loading = ref(true)
|
const loadError = ref('')
|
const keyword = ref('')
|
const searching = ref(false)
|
const searchResults = ref([])
|
|
let AMap
|
let map
|
let autoComplete
|
let placeSearch
|
let geocoder
|
let markers = []
|
let hasFittedPoints = false
|
let suggestTimer
|
let suggestionRequestId = 0
|
let placementRequestId = 0
|
let skipNextSuggestion = false
|
let preferResolvedAddress = false
|
let lastSyncedAddress = ''
|
|
const placementIndex = computed(() => {
|
if (Number.isInteger(props.activeIndex)) return props.activeIndex
|
return props.directPlacement && props.points.length ? 0 : null
|
})
|
const isMarking = computed(() => Number.isInteger(placementIndex.value))
|
const hasPosition = (point) => (
|
point?.longitude != null
|
&& point?.latitude != null
|
&& Number.isFinite(Number(point.longitude))
|
&& Number.isFinite(Number(point.latitude))
|
)
|
|
const clearMarkers = () => {
|
if (map && markers.length) map.remove(markers)
|
markers = []
|
}
|
|
const refreshMarkers = () => {
|
if (!map || !AMap) return
|
clearMarkers()
|
markers = props.points.flatMap((point, index) => {
|
if (!hasPosition(point)) return []
|
const content = document.createElement('button')
|
content.type = 'button'
|
content.className = `amap-meeting-marker${placementIndex.value === index ? ' is-active' : ''}`
|
const markerLabel = document.createElement('span')
|
markerLabel.textContent = String(index + 1)
|
content.appendChild(markerLabel)
|
content.title = point.pointName || `汇合点${index + 1}`
|
const marker = new AMap.Marker({
|
position: [Number(point.longitude), Number(point.latitude)],
|
anchor: 'bottom-center',
|
content,
|
title: content.title,
|
zIndex: placementIndex.value === index ? 120 : 100,
|
})
|
marker.on('click', () => map.setZoomAndCenter(17, marker.getPosition()))
|
return [marker]
|
})
|
if (markers.length) {
|
map.add(markers)
|
if (!hasFittedPoints) {
|
map.setFitView(markers, false, [70, 70, 70, 70], 17)
|
hasFittedPoints = true
|
}
|
}
|
}
|
|
const focusCurrentPoint = () => {
|
const point = props.points[placementIndex.value]
|
if (map && hasPosition(point)) {
|
map.setZoomAndCenter(Math.max(map.getZoom(), 17), [Number(point.longitude), Number(point.latitude)])
|
}
|
}
|
|
const moveToCity = () => {
|
if (!map || !props.cityName || props.points.some(hasPosition)) return
|
map.setCity(props.cityName)
|
}
|
|
const resolveAddress = ({ longitude, latitude }) => new Promise((resolve) => {
|
if (!geocoder) {
|
resolve('')
|
return
|
}
|
geocoder.getAddress([longitude, latitude], (status, result) => {
|
const address = status === 'complete' ? result.regeocode?.formattedAddress : ''
|
resolve(typeof address === 'string' ? address.trim() : '')
|
})
|
})
|
|
const resolveMissingAddresses = async () => {
|
for (let index = 0; index < props.points.length; index += 1) {
|
const point = props.points[index]
|
if (!hasPosition(point) || point.address?.trim()) continue
|
const address = await resolveAddress({
|
longitude: Number(point.longitude),
|
latitude: Number(point.latitude),
|
})
|
if (address) emit('address-resolved', { index, address })
|
}
|
}
|
|
const placeAtCoordinates = async (coordinates, fallbackAddress = '') => {
|
if (!isMarking.value) {
|
ElMessage.info('请先选择需要标注的汇合点')
|
return
|
}
|
const index = placementIndex.value
|
const requestId = ++placementRequestId
|
const address = await resolveAddress(coordinates)
|
if (requestId !== placementRequestId) return
|
preferResolvedAddress = true
|
emit('place', {
|
index,
|
...coordinates,
|
address: address || fallbackAddress,
|
})
|
}
|
|
const handleMapClick = (event) => {
|
const coordinates = {
|
longitude: Number(event.lnglat.getLng().toFixed(6)),
|
latitude: Number(event.lnglat.getLat().toFixed(6)),
|
}
|
placeAtCoordinates(coordinates)
|
}
|
|
const getLocationCoordinates = (location) => {
|
if (!location) return null
|
const longitude = typeof location.getLng === 'function'
|
? location.getLng()
|
: Array.isArray(location) ? location[0] : location.lng ?? location.longitude
|
const latitude = typeof location.getLat === 'function'
|
? location.getLat()
|
: Array.isArray(location) ? location[1] : location.lat ?? location.latitude
|
if (!Number.isFinite(Number(longitude)) || !Number.isFinite(Number(latitude))) return null
|
return {
|
longitude: Number(Number(longitude).toFixed(6)),
|
latitude: Number(Number(latitude).toFixed(6)),
|
}
|
}
|
|
const updateSearchCity = () => {
|
// 高德地点检索中“全国”会被当作城市关键字,空值才表示不限制城市。
|
const city = props.cityName?.trim() || ''
|
autoComplete?.setCity(city)
|
autoComplete?.setCityLimit(Boolean(city))
|
placeSearch?.setCity(city)
|
placeSearch?.setCityLimit(Boolean(city))
|
geocoder?.setCity(city)
|
}
|
|
const loadSuggestions = (value) => {
|
window.clearTimeout(suggestTimer)
|
if (skipNextSuggestion) {
|
skipNextSuggestion = false
|
return
|
}
|
|
const query = value.trim()
|
const requestId = ++suggestionRequestId
|
if (!query || !autoComplete) {
|
searchResults.value = []
|
return
|
}
|
|
suggestTimer = window.setTimeout(() => {
|
autoComplete.search(query, (status, result) => {
|
if (requestId !== suggestionRequestId || keyword.value.trim() !== query) return
|
searchResults.value = status === 'complete' ? (result.tips || []).filter((tip) => tip.name) : []
|
})
|
}, 300)
|
}
|
|
const searchLocation = () => {
|
if (!placeSearch) return
|
const value = keyword.value.trim()
|
if (!value) {
|
ElMessage.warning('请输入景区、地址或地标')
|
return
|
}
|
window.clearTimeout(suggestTimer)
|
suggestionRequestId += 1
|
searching.value = true
|
searchResults.value = []
|
placeSearch.search(value, (status, result) => {
|
searching.value = false
|
if (status !== 'complete' || !result.poiList?.pois?.length) {
|
ElMessage.warning('未搜索到相关地点')
|
return
|
}
|
searchResults.value = result.poiList.pois
|
})
|
}
|
|
const locateSearchResult = async (poi) => {
|
skipNextSuggestion = true
|
keyword.value = poi.name
|
searchResults.value = []
|
const coordinates = getLocationCoordinates(poi.location)
|
if (!coordinates) {
|
ElMessage.warning('该地点缺少坐标,请直接点击地图标注')
|
return
|
}
|
map.setZoomAndCenter(17, [coordinates.longitude, coordinates.latitude])
|
await placeAtCoordinates(
|
coordinates,
|
[poi.district, poi.address, poi.name].filter(Boolean).join(' · '),
|
)
|
}
|
|
const clearSearch = () => {
|
suggestionRequestId += 1
|
window.clearTimeout(suggestTimer)
|
searchResults.value = []
|
}
|
|
const syncKeywordAddress = (address) => {
|
const value = typeof address === 'string' ? address.trim() : ''
|
if (!value) {
|
lastSyncedAddress = ''
|
return
|
}
|
if (preferResolvedAddress || !keyword.value.trim() || keyword.value === lastSyncedAddress) {
|
skipNextSuggestion = true
|
keyword.value = value
|
preferResolvedAddress = false
|
}
|
lastSyncedAddress = value
|
}
|
|
onMounted(async () => {
|
try {
|
AMap = await loadAMap()
|
map = new AMap.Map(mapContainer.value, {
|
center: [104.195397, 35.86166],
|
zoom: 4,
|
resizeEnable: true,
|
viewMode: '2D',
|
})
|
map.addControl(new AMap.Scale())
|
map.addControl(new AMap.ToolBar({ position: { right: '14px', bottom: '38px' } }))
|
const city = props.cityName?.trim() || ''
|
autoComplete = new AMap.AutoComplete({ city, citylimit: Boolean(city) })
|
placeSearch = new AMap.PlaceSearch({ pageSize: 8, pageIndex: 1, extensions: 'base' })
|
geocoder = new AMap.Geocoder({ city })
|
updateSearchCity()
|
map.on('click', handleMapClick)
|
refreshMarkers()
|
if (!markers.length) moveToCity()
|
await resolveMissingAddresses()
|
} catch (error) {
|
loadError.value = error?.message || '地图加载失败,请检查高德地图配置与网络连接'
|
} finally {
|
loading.value = false
|
}
|
})
|
|
watch(() => props.points, refreshMarkers, { deep: true })
|
watch(() => props.activeIndex, () => {
|
refreshMarkers()
|
focusCurrentPoint()
|
})
|
watch(() => props.cityName, () => {
|
updateSearchCity()
|
moveToCity()
|
})
|
watch(() => props.address, syncKeywordAddress, { immediate: true })
|
watch(keyword, loadSuggestions)
|
|
onBeforeUnmount(() => {
|
window.clearTimeout(suggestTimer)
|
suggestionRequestId += 1
|
placementRequestId += 1
|
clearMarkers()
|
map?.destroy()
|
map = null
|
})
|
</script>
|
|
<template>
|
<div class="amap-picker" :class="{ compact }">
|
<div v-if="showSearch" class="map-search" :class="{ 'has-label': searchLabel }">
|
<span v-if="searchLabel" class="map-search-label">{{ searchLabel }}</span>
|
<el-input
|
v-model="keyword"
|
clearable
|
placeholder="搜索景区、地址或地标,选择后自动定位"
|
@keyup.enter="searchLocation"
|
@clear="clearSearch"
|
/>
|
<el-button type="primary" :icon="Search" :loading="searching" :disabled="loading || Boolean(loadError)" @click="searchLocation">搜索</el-button>
|
<div v-if="searchResults.length" class="search-results">
|
<button v-for="(poi, index) in searchResults" :key="poi.id || `${poi.name}-${index}`" type="button" @click="locateSearchResult(poi)">
|
<strong>{{ poi.name }}</strong>
|
<span>{{ [poi.district, poi.address].filter(Boolean).join(' · ') || '暂无详细地址' }}</span>
|
</button>
|
</div>
|
</div>
|
<div v-loading="loading" class="map-shell" :class="{ marking: isMarking }">
|
<div ref="mapContainer" class="map-container" />
|
<div v-if="loadError" class="map-error">{{ loadError }}</div>
|
<span v-else-if="isMarking" class="marking-tip">点击地图设置“{{ points[placementIndex]?.pointName }}”</span>
|
</div>
|
</div>
|
</template>
|
|
<style scoped>
|
.amap-picker { width: min(100%, 900px); }
|
.amap-picker.compact { width: 100%; }
|
.map-search { position: relative; z-index: 10; display: grid; grid-template-columns: minmax(280px, 440px) auto; gap: 10px; align-items: center; width: fit-content; margin-bottom: 12px; }
|
.compact .map-search { width: 100%; grid-template-columns: minmax(0, 1fr) 72px; }
|
.map-search.has-label { width: 100%; grid-template-columns: 92px minmax(0, 1fr) 72px; }
|
.map-search-label { color: #475467; font-size: 14px; font-weight: 500; white-space: nowrap; }
|
.search-results { position: absolute; top: 40px; left: 0; width: 440px; max-height: 280px; overflow-y: auto; background: white; border: 1px solid #d0d5dd; box-shadow: 0 8px 24px rgba(16, 24, 40, .14); }
|
.compact .search-results { width: calc(100% - 82px); }
|
.map-search.has-label .search-results { left: 102px; width: calc(100% - 184px); }
|
.search-results button { display: flex; flex-direction: column; gap: 3px; width: 100%; padding: 10px 12px; color: #344054; text-align: left; background: white; border: 0; border-bottom: 1px solid #eaecf0; cursor: pointer; }
|
.search-results button:hover { background: #f2f4f7; }
|
.search-results strong { font-size: 13px; }
|
.search-results span { overflow: hidden; color: #667085; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
.map-shell { position: relative; height: 420px; overflow: hidden; border: 1px solid #d0d5dd; }
|
.compact .map-shell { height: 190px; }
|
.map-shell.marking { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47, 111, 237, .15); }
|
.map-container { width: 100%; height: 100%; }
|
.map-error { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; color: #b42318; background: #fef3f2; text-align: center; }
|
.marking-tip { position: absolute; z-index: 5; top: 12px; right: 12px; max-width: 300px; padding: 7px 11px; color: white; background: rgba(47, 111, 237, .92); border-radius: 4px; font-size: 12px; }
|
:global(.amap-meeting-marker) { display: grid; place-items: center; width: 32px; height: 38px; padding: 0 0 6px; color: white; background: #1677ff; border: 2px solid white; border-radius: 50% 50% 50% 0; box-shadow: 0 3px 9px rgba(16, 24, 40, .3); font-size: 12px; font-weight: 700; transform: rotate(-45deg); cursor: pointer; }
|
:global(.amap-meeting-marker.is-active) { background: #d92d20; transform: rotate(-45deg) scale(1.12); }
|
:global(.amap-meeting-marker span) { transform: rotate(45deg); }
|
@media (max-width: 900px) {
|
.map-search { width: 100%; grid-template-columns: 1fr auto; }
|
.search-results { width: calc(100% - 74px); }
|
}
|
</style>
|