liujie
2026-08-27 0909f98243dd6102ab28a6339c7392abadd5f534
update
3个文件已添加
3个文件已修改
317 ■■■■■ 已修改文件
frontend/.env.example 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/package-lock.json 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/package.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/components/AmapMeetingPointMap.vue 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/utils/amap.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend/src/views/ScenicSpotDetailView.vue 78 ●●●●● 补丁 | 查看 | 原始文档 | 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; }