| frontend/.env.example | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| frontend/package-lock.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| frontend/package.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| frontend/src/components/AmapMeetingPointMap.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| frontend/src/utils/amap.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| frontend/src/views/ScenicSpotDetailView.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
frontend/.env.example
New file @@ -0,0 +1,2 @@ VITE_AMAP_KEY=<高德地图 Web 端 Key> VITE_AMAP_SECURITY_JS_CODE=<高德地图安全密钥,可选> 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", 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", frontend/src/components/AmapMeetingPointMap.vue
New file @@ -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> frontend/src/utils/amap.js
New file @@ -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 } 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; }