From 0909f98243dd6102ab28a6339c7392abadd5f534 Mon Sep 17 00:00:00 2001
From: liujie <1793218484@qq.com>
Date: 星期四, 27 八月 2026 17:56:09 +0800
Subject: [PATCH] update
---
frontend/src/views/ScenicSpotDetailView.vue | 78 ++++--------
frontend/src/utils/amap.js | 22 +++
frontend/.env.example | 2
frontend/package.json | 1
frontend/src/components/AmapMeetingPointMap.vue | 207 ++++++++++++++++++++++++++++++++++
frontend/package-lock.json | 7 +
6 files changed, 265 insertions(+), 52 deletions(-)
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..8b7c406
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,2 @@
+VITE_AMAP_KEY=<高德地图 Web 端 Key>
+VITE_AMAP_SECURITY_JS_CODE=<高德地图安全密钥,可选>
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3b0555b..1e55253 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "tourguide-admin-web",
"version": "1.0.0",
"dependencies": {
+ "@amap/amap-jsapi-loader": "^1.0.1",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.10.0",
"element-plus": "^2.10.4",
@@ -23,6 +24,12 @@
"node": ">=24.1.0 <25"
}
},
+ "node_modules/@amap/amap-jsapi-loader": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@amap/amap-jsapi-loader/-/amap-jsapi-loader-1.0.1.tgz",
+ "integrity": "sha512-nPyLKt7Ow/ThHLkSvn2etQlUzqxmTVgK7bIgwdBRTg2HK5668oN7xVxkaiRe3YZEzGzfV2XgH5Jmu2T73ljejw==",
+ "license": "MIT"
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ded715f..720a07d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@amap/amap-jsapi-loader": "^1.0.1",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.10.0",
"element-plus": "^2.10.4",
diff --git a/frontend/src/components/AmapMeetingPointMap.vue b/frontend/src/components/AmapMeetingPointMap.vue
new file mode 100644
index 0000000..6d2a621
--- /dev/null
+++ b/frontend/src/components/AmapMeetingPointMap.vue
@@ -0,0 +1,207 @@
+<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: '' },
+})
+const emit = defineEmits(['place'])
+
+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 placeSearch
+let markers = []
+let hasFittedPoints = false
+
+const isMarking = computed(() => Number.isInteger(props.activeIndex))
+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${props.activeIndex === 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: props.activeIndex === 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[props.activeIndex]
+ 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 handleMapClick = (event) => {
+ if (!isMarking.value) {
+ ElMessage.info('请先选择需要标注的汇合点')
+ return
+ }
+ emit('place', {
+ index: props.activeIndex,
+ longitude: Number(event.lnglat.getLng().toFixed(6)),
+ latitude: Number(event.lnglat.getLat().toFixed(6)),
+ })
+}
+
+const searchLocation = () => {
+ if (!placeSearch) return
+ const value = keyword.value.trim()
+ if (!value) {
+ ElMessage.warning('请输入景区、地址或地标')
+ return
+ }
+ searching.value = true
+ searchResults.value = []
+ placeSearch.setCity(props.cityName || '全国')
+ 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 = (poi) => {
+ if (!poi.location) return
+ map.setZoomAndCenter(17, poi.location)
+ keyword.value = poi.name
+ searchResults.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' } }))
+ placeSearch = new AMap.PlaceSearch({ pageSize: 8, pageIndex: 1, extensions: 'base' })
+ map.on('click', handleMapClick)
+ refreshMarkers()
+ if (!markers.length) moveToCity()
+ } catch (error) {
+ loadError.value = error?.message || '地图加载失败,请检查高德地图配置与网络连接'
+ } finally {
+ loading.value = false
+ }
+})
+
+watch(() => props.points, refreshMarkers, { deep: true })
+watch(() => props.activeIndex, () => {
+ refreshMarkers()
+ focusCurrentPoint()
+})
+watch(() => props.cityName, () => {
+ if (placeSearch) placeSearch.setCity(props.cityName || '全国')
+ moveToCity()
+})
+
+onBeforeUnmount(() => {
+ clearMarkers()
+ map?.destroy()
+ map = null
+})
+</script>
+
+<template>
+ <div class="amap-picker">
+ <div class="map-search">
+ <el-input
+ v-model="keyword"
+ clearable
+ placeholder="搜索景区、地址或地标"
+ @keyup.enter="searchLocation"
+ @clear="searchResults = []"
+ />
+ <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.address || poi.district }}</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[activeIndex]?.pointName }}”</span>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.amap-picker { width: min(100%, 900px); }
+.map-search { position: relative; z-index: 10; display: grid; grid-template-columns: minmax(280px, 440px) auto; gap: 10px; width: fit-content; margin-bottom: 12px; }
+.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); }
+.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; }
+.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>
diff --git a/frontend/src/utils/amap.js b/frontend/src/utils/amap.js
new file mode 100644
index 0000000..89bb726
--- /dev/null
+++ b/frontend/src/utils/amap.js
@@ -0,0 +1,22 @@
+import AMapLoader from '@amap/amap-jsapi-loader'
+
+const amapKey = import.meta.env.VITE_AMAP_KEY
+const securityJsCode = import.meta.env.VITE_AMAP_SECURITY_JS_CODE
+let loaderPromise
+
+export const loadAMap = () => {
+ if (!amapKey) return Promise.reject(new Error('未配置高德地图 Web Key'))
+
+ if (securityJsCode) {
+ window._AMapSecurityConfig = { securityJsCode }
+ }
+
+ if (!loaderPromise) {
+ loaderPromise = AMapLoader.load({
+ key: amapKey,
+ version: '2.0',
+ plugins: ['AMap.PlaceSearch', 'AMap.Scale', 'AMap.ToolBar'],
+ })
+ }
+ return loaderPromise
+}
diff --git a/frontend/src/views/ScenicSpotDetailView.vue b/frontend/src/views/ScenicSpotDetailView.vue
index c83acf2..a04c3f6 100644
--- a/frontend/src/views/ScenicSpotDetailView.vue
+++ b/frontend/src/views/ScenicSpotDetailView.vue
@@ -5,6 +5,7 @@
import { ArrowLeft, Delete, Location, Plus } from '@element-plus/icons-vue'
import { getCities } from '@/api/cities'
import { createScenicSpot, getScenicSpotDetail, updateScenicSpotDetail } from '@/api/scenicSpots'
+import AmapMeetingPointMap from '@/components/AmapMeetingPointMap.vue'
const route = useRoute()
const router = useRouter()
@@ -14,6 +15,9 @@
const saving = ref(false)
const markingIndex = ref(null)
const cityOptions = ref([])
+const selectedCityName = computed(() => (
+ cityOptions.value.find((city) => city.id === form.cityId)?.cityName || form.cityName || ''
+))
const defaultPrice = (durationMinutes, minParticipants, maxParticipants = null) => ({
durationMinutes,
@@ -86,24 +90,14 @@
}
const startMarking = (index) => {
markingIndex.value = index
- ElMessage.info('请点击下方地图标注汇合点')
+ ElMessage.info('请在下方地图搜索位置或直接点击标注')
}
-const markPoint = (index, event) => {
- if (markingIndex.value !== index) return
- const rect = event.currentTarget.getBoundingClientRect()
- const xRatio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
- const yRatio = Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height))
- form.meetingPoints[index].longitude = Number((xRatio * 360 - 180).toFixed(6))
- form.meetingPoints[index].latitude = Number((90 - yRatio * 180).toFixed(6))
+const markPoint = ({ index, longitude, latitude }) => {
+ if (markingIndex.value !== index || !form.meetingPoints[index]) return
+ form.meetingPoints[index].longitude = longitude
+ form.meetingPoints[index].latitude = latitude
markingIndex.value = null
ElMessage.success('汇合点已标注')
-}
-const markerStyle = (point) => {
- if (point.longitude == null || point.latitude == null) return { display: 'none' }
- return {
- left: `${((Number(point.longitude) + 180) / 360) * 100}%`,
- top: `${((90 - Number(point.latitude)) / 180) * 100}%`,
- }
}
const validateForm = () => {
@@ -221,28 +215,24 @@
<div class="meeting-toolbar">
<span>汇合点{{ index + 1 }}</span>
<el-input v-model="point.pointName" maxlength="128" placeholder="请输入汇合点名称" />
- <el-button type="primary" :icon="Location" @click="startMarking(index)">标注汇合点</el-button>
+ <el-button :type="markingIndex === index ? 'danger' : 'primary'" :icon="Location" @click="startMarking(index)">
+ {{ point.longitude == null ? '标注汇合点' : '重新标注' }}
+ </el-button>
<el-checkbox :model-value="point.isDefault === 1" @change="setDefaultMeetingPoint(index)">默认</el-checkbox>
<el-switch v-model="point.status" :active-value="1" :inactive-value="0" />
- </div>
- <div class="map-row">
- <div class="map-board" :class="{ marking: markingIndex === index }" @click="markPoint(index, $event)">
- <span class="road road-a" /><span class="road road-b" /><span class="road road-c" />
- <span class="water-block" /><span class="park-block" />
- <span class="map-label label-a">游客中心</span>
- <span class="map-label label-b">景区入口</span>
- <span class="map-label label-c">停车场</span>
- <span class="marker" :style="markerStyle(point)"><el-icon><Location /></el-icon></span>
- <span v-if="markingIndex === index" class="marking-tip">点击地图放置标记</span>
- </div>
- <div class="point-actions">
- <small v-if="point.longitude != null">经度 {{ Number(point.longitude).toFixed(4) }}<br>纬度 {{ Number(point.latitude).toFixed(4) }}</small>
- <el-button type="danger" :icon="Delete" @click="removeMeetingPoint(index)">删除</el-button>
- </div>
+ <small v-if="point.longitude != null">{{ Number(point.longitude).toFixed(6) }}, {{ Number(point.latitude).toFixed(6) }}</small>
+ <el-button type="danger" link :icon="Delete" @click="removeMeetingPoint(index)">删除</el-button>
</div>
</div>
</div>
<el-button type="primary" :icon="Plus" @click="addMeetingPoint">添加汇合点</el-button>
+ <AmapMeetingPointMap
+ class="meeting-map"
+ :points="form.meetingPoints"
+ :active-index="markingIndex"
+ :city-name="selectedCityName"
+ @place="markPoint"
+ />
</el-card>
<el-card class="section-card" shadow="never">
@@ -285,27 +275,11 @@
.price-fields :deep(.el-input-number) { width: 100%; }
.price-fields :deep(.el-input-number .el-input__inner) { text-align: left; }
.participant-label { color: #101828; font-size: 13px; font-weight: 700; }
-.meeting-list { display: flex; flex-direction: column; gap: 22px; margin-bottom: 16px; }
-.meeting-point-panel { max-width: 820px; padding: 18px; background: #f8fafc; border-radius: 4px; }
-.meeting-toolbar { display: grid; grid-template-columns: 64px 240px 118px 62px 52px; align-items: center; gap: 12px; margin-bottom: 14px; color: #475467; font-size: 13px; }
-.map-row { display: flex; align-items: flex-end; gap: 16px; }
-.map-board { position: relative; width: 520px; height: 210px; overflow: hidden; cursor: crosshair; background-color: #eef3e8; background-image: linear-gradient(90deg, rgba(255,255,255,.78) 2px, transparent 2px), linear-gradient(rgba(255,255,255,.78) 2px, transparent 2px); background-size: 54px 42px; border: 1px solid #d7dee8; }
-.map-board.marking { border-color: #2f6fed; box-shadow: 0 0 0 2px rgba(47,111,237,.15); }
-.road { position: absolute; z-index: 1; display: block; height: 8px; background: #fff; border: 1px solid #d8dee7; transform-origin: left center; }
-.road-a { top: 45px; left: -20px; width: 580px; transform: rotate(8deg); }
-.road-b { top: 165px; left: 12px; width: 530px; transform: rotate(-5deg); }
-.road-c { top: -12px; left: 250px; width: 260px; transform: rotate(82deg); }
-.water-block { position: absolute; top: 22px; left: 300px; width: 86px; height: 74px; background: #b9e4f7; border-radius: 44% 56% 52% 48%; }
-.park-block { position: absolute; right: 26px; bottom: 22px; width: 95px; height: 62px; background: #b9dfad; }
-.map-label { position: absolute; z-index: 2; color: #6b7280; font-size: 11px; }
-.label-a { left: 80px; top: 55px; }
-.label-b { left: 226px; top: 126px; }
-.label-c { right: 48px; top: 38px; }
-.marker { position: absolute; z-index: 4; display: grid; place-items: center; width: 26px; height: 26px; color: white; background: #2f6fed; border: 3px solid white; border-radius: 50% 50% 50% 0; box-shadow: 0 3px 8px rgba(15,23,42,.25); transform: translate(-50%, -100%) rotate(-45deg); }
-.marker :deep(.el-icon) { transform: rotate(45deg); }
-.marking-tip { position: absolute; z-index: 5; right: 10px; top: 10px; padding: 6px 10px; color: white; background: rgba(47,111,237,.9); border-radius: 4px; font-size: 12px; }
-.point-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
-.point-actions small { color: #667085; line-height: 1.7; }
+.meeting-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
+.meeting-point-panel { max-width: 900px; padding: 14px 16px; background: #f8fafc; border-radius: 4px; }
+.meeting-toolbar { display: grid; grid-template-columns: 64px minmax(180px, 240px) 118px 62px 52px minmax(190px, 1fr) 54px; align-items: center; gap: 12px; color: #475467; font-size: 13px; }
+.meeting-toolbar small { color: #667085; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.meeting-map { margin-top: 18px; }
.order-config-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 0 42px; max-width: 1000px; }
.order-config-grid :deep(.el-form-item__label) { width: 220px; color: #475467; }
.order-config-grid :deep(.el-input-number) { width: 210px; }
--
Gitblit v1.7.1