|
@@ -0,0 +1,388 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <el-dialog
|
|
|
|
|
+ v-model="visible"
|
|
|
|
|
+ title="地图选点"
|
|
|
|
|
+ width="800px"
|
|
|
|
|
+ :close-on-click-modal="false"
|
|
|
|
|
+ append-to-body
|
|
|
|
|
+ @open="onOpen"
|
|
|
|
|
+ @close="onClose"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="map-picker">
|
|
|
|
|
+ <div class="map-toolbar">
|
|
|
|
|
+ <el-autocomplete
|
|
|
|
|
+ v-model="keyword"
|
|
|
|
|
+ :fetch-suggestions="fetchSuggestions"
|
|
|
|
|
+ :trigger-on-focus="true"
|
|
|
|
|
+ placeholder="搜索地点(输入联想,回车或点选)"
|
|
|
|
|
+ clearable
|
|
|
|
|
+ style="width: 280px"
|
|
|
|
|
+ @keyup.enter="searchPlace"
|
|
|
|
|
+ @select="onSelectSuggestion"
|
|
|
|
|
+ >
|
|
|
|
|
+ <template #append>
|
|
|
|
|
+ <el-button :icon="Search" @click="searchPlace" />
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template #default="{ item }">
|
|
|
|
|
+ <div class="suggestion-item">
|
|
|
|
|
+ <div class="suggestion-title">{{ item.title }}</div>
|
|
|
|
|
+ <div class="suggestion-addr">{{ item.address }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-autocomplete>
|
|
|
|
|
+ <el-tooltip content="定位到我的位置" placement="top">
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ class="map-locate-btn"
|
|
|
|
|
+ :icon="Aim"
|
|
|
|
|
+ circle
|
|
|
|
|
+ :loading="locating"
|
|
|
|
|
+ @click="locateMe"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ <span class="map-tip">点击地图或搜索结果选择位置</span>
|
|
|
|
|
+ <span class="map-coord">当前: {{ coordText }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div ref="mapContainer" class="map-container" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="visible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" :disabled="!pickedLat" @click="confirm">确定</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { ref, computed } from 'vue'
|
|
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
|
|
+import { Search, Aim } from '@element-plus/icons-vue'
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps<{
|
|
|
|
|
+ modelValue: boolean
|
|
|
|
|
+ /** 初始纬度,弹窗打开时地图中心 */
|
|
|
|
|
+ initLat?: number
|
|
|
|
|
+ /** 初始经度 */
|
|
|
|
|
+ initLng?: number
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ (e: 'update:modelValue', v: boolean): void
|
|
|
|
|
+ (e: 'pick', payload: { lat: number; lng: number; address?: string }): void
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const visible = computed({
|
|
|
|
|
+ get: () => props.modelValue,
|
|
|
|
|
+ set: v => emit('update:modelValue', v)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const keyword = ref('')
|
|
|
|
|
+const mapContainer = ref<HTMLDivElement | null>(null)
|
|
|
|
|
+const pickedLat = ref<number | null>(null)
|
|
|
|
|
+const pickedLng = ref<number | null>(null)
|
|
|
|
|
+const pickedAddress = ref('')
|
|
|
|
|
+const locating = ref(false)
|
|
|
|
|
+const coordText = computed(() => {
|
|
|
|
|
+ if (pickedLat.value == null || pickedLng.value == null) return '未选点'
|
|
|
|
|
+ return `纬度 ${pickedLat.value.toFixed(6)}, 经度 ${pickedLng.value.toFixed(6)}`
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+let TMap: any = null
|
|
|
|
|
+let map: any = null
|
|
|
|
|
+let marker: any = null
|
|
|
|
|
+let scriptLoaded = false
|
|
|
|
|
+let scriptLoading: Promise<void> | null = null
|
|
|
|
|
+
|
|
|
|
|
+function getBrowserLocation(timeoutMs = 8000, highAccuracy = true): Promise<{ lat: number; lng: number } | null> {
|
|
|
|
|
+ return new Promise(resolve => {
|
|
|
|
|
+ if (!navigator?.geolocation) return resolve(null)
|
|
|
|
|
+ const timer = setTimeout(() => resolve(null), timeoutMs)
|
|
|
|
|
+ navigator.geolocation.getCurrentPosition(
|
|
|
|
|
+ pos => {
|
|
|
|
|
+ clearTimeout(timer)
|
|
|
|
|
+ resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
|
|
|
|
+ },
|
|
|
|
|
+ () => {
|
|
|
|
|
+ clearTimeout(timer)
|
|
|
|
|
+ resolve(null)
|
|
|
|
|
+ },
|
|
|
|
|
+ { enableHighAccuracy: highAccuracy, timeout: timeoutMs, maximumAge: 30000 }
|
|
|
|
|
+ )
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function locateMe() {
|
|
|
|
|
+ if (!map) return
|
|
|
|
|
+ locating.value = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ const geo = await getBrowserLocation()
|
|
|
|
|
+ if (!geo) {
|
|
|
|
|
+ ElMessage.warning('无法获取位置,请检查浏览器定位权限')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ map.setCenter({ lat: geo.lat, lng: geo.lng })
|
|
|
|
|
+ map.setZoom(18)
|
|
|
|
|
+ placeMarker(geo.lat, geo.lng)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ locating.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resolveInitialCenter(): Promise<{ lat: number; lng: number }> {
|
|
|
|
|
+ // 已填了经纬度 → 用填的
|
|
|
|
|
+ if (typeof props.initLat === 'number' && typeof props.initLng === 'number' && (props.initLat !== 0 || props.initLng !== 0)) {
|
|
|
|
|
+ return { lat: props.initLat, lng: props.initLng }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 没填 → 自动定位
|
|
|
|
|
+ const geo = await getBrowserLocation()
|
|
|
|
|
+ if (geo) return geo
|
|
|
|
|
+ return { lat: 39.908823, lng: 116.397470 } // 默认北京
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function loadScript(): Promise<void> {
|
|
|
|
|
+ if (scriptLoaded) return Promise.resolve()
|
|
|
|
|
+ if (scriptLoading) return scriptLoading
|
|
|
|
|
+ const key = import.meta.env.VITE_QQMAP_KEY
|
|
|
|
|
+ if (!key) {
|
|
|
|
|
+ return Promise.reject(new Error('缺少腾讯地图 VITE_QQMAP_KEY 配置'))
|
|
|
|
|
+ }
|
|
|
|
|
+ scriptLoading = new Promise((resolve, reject) => {
|
|
|
|
|
+ const existing = document.querySelector('script[data-qqmap-gljs]') as HTMLScriptElement | null
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ scriptLoaded = true
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const s = document.createElement('script')
|
|
|
|
|
+ s.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}&libraries=service`
|
|
|
|
|
+ s.async = true
|
|
|
|
|
+ s.dataset.qqmapGljs = '1'
|
|
|
|
|
+ s.onload = () => {
|
|
|
|
|
+ scriptLoaded = true
|
|
|
|
|
+ resolve()
|
|
|
|
|
+ }
|
|
|
|
|
+ s.onerror = () => reject(new Error('腾讯地图脚本加载失败'))
|
|
|
|
|
+ document.head.appendChild(s)
|
|
|
|
|
+ })
|
|
|
|
|
+ return scriptLoading
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function onOpen() {
|
|
|
|
|
+ pickedLat.value = null
|
|
|
|
|
+ pickedLng.value = null
|
|
|
|
|
+ pickedAddress.value = ''
|
|
|
|
|
+ keyword.value = ''
|
|
|
|
|
+ try {
|
|
|
|
|
+ await loadScript()
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ ElMessage.error(e?.message || '地图加载失败')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 等待 DOM 容器就绪
|
|
|
|
|
+ await new Promise(r => setTimeout(r, 50))
|
|
|
|
|
+ if (!mapContainer.value) return
|
|
|
|
|
+ const win = window as any
|
|
|
|
|
+ TMap = win.TMap || win.tMap
|
|
|
|
|
+ if (!TMap) {
|
|
|
|
|
+ ElMessage.error('腾讯地图 SDK 未挂载')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 自动定位: 优先级 form.lat/lng > 浏览器 Geolocation > 默认北京
|
|
|
|
|
+ const center = await resolveInitialCenter()
|
|
|
|
|
+ map = new TMap.Map(mapContainer.value, {
|
|
|
|
|
+ center: new TMap.LatLng(center.lat, center.lng),
|
|
|
|
|
+ zoom: 18,
|
|
|
|
|
+ pitch: 0,
|
|
|
|
|
+ rotation: 0
|
|
|
|
|
+ })
|
|
|
|
|
+ map.on('click', (evt: any) => {
|
|
|
|
|
+ const { lat, lng } = evt.latLng
|
|
|
|
|
+ placeMarker(lat, lng)
|
|
|
|
|
+ })
|
|
|
|
|
+ // 如果有初始坐标,放一个 marker
|
|
|
|
|
+ if (typeof props.initLat === 'number' && typeof props.initLng === 'number') {
|
|
|
|
|
+ placeMarker(props.initLat, props.initLng)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 异步逆地理得到地址(用 WebService API,避免依赖 GL service 库)
|
|
|
|
|
+ reverseGeocode(pickedLat.value, pickedLng.value).then(addr => {
|
|
|
|
|
+ if (addr) pickedAddress.value = addr
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function placeMarker(lat: number, lng: number) {
|
|
|
|
|
+ if (!map || !TMap) return
|
|
|
|
|
+ pickedLat.value = lat
|
|
|
|
|
+ pickedLng.value = lng
|
|
|
|
|
+ if (marker) {
|
|
|
|
|
+ // 腾讯 GL JS MultiMarker 没有 setPosition,用 updateGeometries 更新位置
|
|
|
|
|
+ try {
|
|
|
|
|
+ marker.updateGeometries([{ id: 'pick', position: new TMap.LatLng(lat, lng) }])
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 旧版本 API: 先删再加
|
|
|
|
|
+ try { marker.del(['pick']) } catch {}
|
|
|
|
|
+ marker.add({ id: 'pick', position: new TMap.LatLng(lat, lng) })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ marker = new TMap.MultiMarker({
|
|
|
|
|
+ map,
|
|
|
|
|
+ styles: {
|
|
|
|
|
+ marker: new TMap.MarkerStyle({
|
|
|
|
|
+ width: 24,
|
|
|
|
|
+ height: 32,
|
|
|
|
|
+ src: 'https://mapapi.qq.com/web/lbs/javascriptV2/img/marker.png',
|
|
|
|
|
+ anchor: { x: 12, y: 32 }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ geometries: [{ id: 'pick', position: new TMap.LatLng(lat, lng) }]
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ // 异步逆地理得到地址(不阻塞选点)
|
|
|
|
|
+ reverseGeocode(lat, lng).then(addr => {
|
|
|
|
|
+ if (addr) pickedAddress.value = addr
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function reverseGeocode(lat: number, lng: number): Promise<string | null> {
|
|
|
|
|
+ const key = import.meta.env.VITE_QQMAP_KEY
|
|
|
|
|
+ if (!key) return null
|
|
|
|
|
+ try {
|
|
|
|
|
+ const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`
|
|
|
|
|
+ const res = await fetch(url)
|
|
|
|
|
+ const data = await res.json()
|
|
|
|
|
+ if (data?.status === 0 && data?.result?.address) return data.result.address
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ return null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function searchPlace() {
|
|
|
|
|
+ if (!keyword.value.trim()) return
|
|
|
|
|
+ const kw = keyword.value.trim()
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 1. 先不传 region 搜(让腾讯自己判断)
|
|
|
|
|
+ let data = await fetchSearch(kw)
|
|
|
|
|
+ // 2. 没结果 → 用 cluster 第一个城市重试
|
|
|
|
|
+ if ((!data || data.length === 0) && lastClusterCity.value) {
|
|
|
|
|
+ data = await fetchSearch(kw, lastClusterCity.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!data || data.length === 0) {
|
|
|
|
|
+ ElMessage.warning('未找到该地点,请尝试更详细的关键词(如"城市+地点")')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ applyPoi(data[0])
|
|
|
|
|
+ } catch (e: any) {
|
|
|
|
|
+ ElMessage.error(e?.message || '搜索失败')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 应用一个 POI 到地图(选点 + 平移 + 缩放)
|
|
|
|
|
+function applyPoi(poi: any) {
|
|
|
|
|
+ if (!map || !poi?.location) return
|
|
|
|
|
+ const { lat, lng } = poi.location
|
|
|
|
|
+ map.setCenter({ lat, lng })
|
|
|
|
|
+ map.setZoom(18)
|
|
|
|
|
+ placeMarker(lat, lng)
|
|
|
|
|
+ if (poi.address && !pickedAddress.value) pickedAddress.value = poi.address
|
|
|
|
|
+ if (poi.title) keyword.value = poi.title
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// el-autocomplete 联想回调
|
|
|
|
|
+async function fetchSuggestions(queryString: string, cb: (suggestions: any[]) => void) {
|
|
|
|
|
+ if (!queryString || queryString.trim().length < 2) {
|
|
|
|
|
+ cb([])
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await fetchSearch(queryString.trim())
|
|
|
|
|
+ // 透传后端原始顺序(腾讯 place API 默认按权重+距离综合排序)
|
|
|
|
|
+ cb(data.slice(0, 8))
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ cb([])
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 点击联想项
|
|
|
|
|
+function onSelectSuggestion(item: any) {
|
|
|
|
|
+ applyPoi(item)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const lastClusterCity = ref<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+async function fetchSearch(keyword: string, region?: string): Promise<any[]> {
|
|
|
|
|
+ const params = new URLSearchParams({ keyword })
|
|
|
|
|
+ if (region) params.set('region', region)
|
|
|
|
|
+ const url = `/dev-api/risk/map/search?${params.toString()}`
|
|
|
|
|
+ const res = await fetch(url, { credentials: 'include' })
|
|
|
|
|
+ const json: any = await res.json()
|
|
|
|
|
+ if (json?.status !== 0) {
|
|
|
|
|
+ throw new Error(json?.message || '搜索失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ // 记录第一个 cluster 城市,供下次没结果时用
|
|
|
|
|
+ if (json.cluster?.[0]?.title) {
|
|
|
|
|
+ lastClusterCity.value = json.cluster[0].title
|
|
|
|
|
+ }
|
|
|
|
|
+ return json.data || []
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function confirm() {
|
|
|
|
|
+ if (pickedLat.value == null || pickedLng.value == null) return
|
|
|
|
|
+ emit('pick', { lat: pickedLat.value, lng: pickedLng.value, address: pickedAddress.value })
|
|
|
|
|
+ visible.value = false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onClose() {
|
|
|
|
|
+ // 不销毁 map 也没事,下次 onOpen 会重建
|
|
|
|
|
+ map = null
|
|
|
|
|
+ marker = null
|
|
|
|
|
+ TMap = null
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="scss">
|
|
|
|
|
+.map-picker {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.map-toolbar {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+}
|
|
|
|
|
+.map-tip {
|
|
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+.map-coord {
|
|
|
|
|
+ color: var(--el-color-primary);
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ margin-left: auto;
|
|
|
|
|
+}
|
|
|
|
|
+.map-locate-btn {
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ &:hover {
|
|
|
|
|
+ color: var(--el-color-primary);
|
|
|
|
|
+ border-color: var(--el-color-primary);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+.suggestion-item {
|
|
|
|
|
+ padding: 4px 0;
|
|
|
|
|
+ line-height: 1.3;
|
|
|
|
|
+}
|
|
|
|
|
+.suggestion-title {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
|
|
+}
|
|
|
|
|
+.suggestion-addr {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+}
|
|
|
|
|
+.map-container {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 480px;
|
|
|
|
|
+ border: 1px solid var(--el-border-color-lighter);
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|