Forráskód Böngészése

更新 src/views/map 模块

reghao 1 napja
szülő
commit
f802d4161c
5 módosított fájl, 1150 hozzáadás és 388 törlés
  1. 12 34
      src/api/map.js
  2. 8 1
      src/router/index.js
  3. 392 167
      src/views/map/AMap.vue
  4. 308 0
      src/views/map/ChartMap.vue
  5. 430 186
      src/views/map/OpenLayersMap.vue

+ 12 - 34
src/api/map.js

@@ -1,24 +1,22 @@
 import { get, post } from '@/utils/request'
 
 const mapAPI = {
+  mapApi: '/api/geo/map',
   photoMarks: '/api/geo/photo/marks',
   photoMarksArea: '/api/geo/photo/marks_area',
   photoMarksNearby: '/api/geo/photo/marks_nearby',
-  cupMapApi: '/api/geo/map/cup',
   cascadeApi: '/api/geo/map/cascade',
   photoMarkInfo: '/api/geo/photo/mark/info',
-  photoItemApi: '/api/geo/photo/item',
-  addPositionAPi: '/api/geo/map/position',
-  addMyPositionAPi: '/api/geo/map/my_position',
-  addPathAPi: '/api/geo/map/trail',
-  mapMarks: '/api/geo/map/marks',
-  trailPathApi: '/api/geo/map/trail',
-  chartMapAreaApi: '/api/geo/chartmap/area',
-  chartMapGeojsonApi: '/api/geo/chartmap/geojson'
+  geoPathApi: '/api/geo/map/path',
+  chartMapGeojsonApi: '/api/geo/map/geojson'
+}
+
+export function saveBatchMarks(jsonData) {
+  return post(mapAPI.mapApi + '/marks', jsonData)
 }
 
 export function getMapMarks() {
-  return get(mapAPI.mapMarks)
+  return get(mapAPI.mapApi + '/marks')
 }
 
 export function getPhotoMarks(areaCode) {
@@ -33,10 +31,6 @@ export function getPhotoMarksNearby(distance) {
   return get(mapAPI.photoMarksNearby + '?distance=' + distance)
 }
 
-export function getCupMap() {
-  return get(mapAPI.cupMapApi)
-}
-
 export function getAreaCascadeOptions() {
   return get(mapAPI.cascadeApi)
 }
@@ -45,28 +39,12 @@ export function getMarkerInfo(id) {
   return get(mapAPI.photoMarkInfo + '?id=' + id)
 }
 
-export function getPhotoItems() {
-  return get(mapAPI.photoItemApi)
-}
-
-export function addGeoPosition(data) {
-  return post(mapAPI.addPositionAPi, data)
-}
-
-export function addMyPosition(data) {
-  return post(mapAPI.addMyPositionAPi, data)
-}
-
-export function addPath(data) {
-  return post(mapAPI.addPathAPi, data)
-}
-
-export function getGeoPoint() {
-  return get(mapAPI.trailPathApi + '/' + 1)
+export function addGeoPath(data) {
+  return post(mapAPI.geoPathApi, data)
 }
 
-export function getChartMapArea() {
-  return get(mapAPI.chartMapAreaApi)
+export function getGeoPath(queryParam) {
+  return get(mapAPI.geoPathApi, queryParam)
 }
 
 export function getChartMapGeojson(areaCode) {

+ 8 - 1
src/router/index.js

@@ -29,10 +29,11 @@ const ImagePage = () => import('views/home/ImagePage')
 const PlaylistIndex = () => import('views/home/PlaylistIndex')
 const PlaylistView = () => import('views/home/PlaylistView')
 const SearchIndex = () => import('views/home/SearchIndex')
-
 const ShortVideoIndex = () => import('views/home/ShortVideo')
+
 const AMap = () => import('views/map/AMap')
 const OpenLayersMap = () => import('views/map/OpenLayersMap')
+const ChartMap = () => import('views/map/ChartMap')
 
 const VodAudit = () => import('views/admin/aaa/VideoAudit')
 
@@ -146,6 +147,12 @@ export const constantRoutes = [
     component: OpenLayersMap,
     meta: { title: 'OpenLayersMap', needAuth: false }
   },
+  {
+    path: '/map2',
+    name: 'ChartMap',
+    component: ChartMap,
+    meta: { title: 'ChartMap', needAuth: false }
+  },
   {
     path: '/bg',
     name: 'BackgroundIndex',

+ 392 - 167
src/views/map/AMap.vue

@@ -3,12 +3,39 @@
     <div id="container" class="amap-instance" />
 
     <div class="map-overlay-controls">
-      <div class="control-group tip-group">
+      <div v-if="enableAddPath || isDrawingCircle || isMarking || activeMeasureType" class="control-group tip-group">
         <i class="el-icon-info" />
-        <span>点击地图触发操作</span>
+        <span>{{ tipText }}</span>
       </div>
 
-      <div class="control-group button-group">
+      <div class="control-group button-group-grid">
+        <el-button
+          type="success"
+          size="small"
+          icon="el-icon-download"
+          @click="loadMarker"
+        >
+          加载标记
+        </el-button>
+
+        <el-button
+          :type="isMarking ? 'success' : 'primary'"
+          size="small"
+          icon="el-icon-location-outline"
+          @click="toggleMarkPlaceState"
+        >
+          {{ isMarking ? '保存并结束' : '标记地点' }}
+        </el-button>
+
+        <el-button
+          type="info"
+          size="small"
+          icon="el-icon-guide"
+          @click="onPathNavigator"
+        >
+          路径巡航
+        </el-button>
+
         <el-button
           :type="enableAddPath ? 'success' : 'primary'"
           size="small"
@@ -17,65 +44,66 @@
         >
           {{ addPathText }}
         </el-button>
-        <el-button type="info" size="small" icon="el-icon-guide" @click="onPathNavigator">
-          路径巡航
+
+        <el-button
+          :type="isDrawingCircle ? 'danger' : 'warning'"
+          size="small"
+          icon="el-icon-magic-stick"
+          @click="toggleDrawCircleState"
+        >
+          {{ isDrawingCircle ? '结束画圆' : '绘制圆形' }}
+        </el-button>
+
+        <el-button
+          :type="activeMeasureType === 'distance' ? 'danger' : 'warning'"
+          size="small"
+          icon="el-icon-odometer"
+          @click="toggleMeasure('distance')"
+        >
+          {{ activeMeasureType === 'distance' ? '结束测距' : '测距离' }}
         </el-button>
-        <el-button type="danger" size="small" icon="el-icon-delete" @click="clearCircle">
-          清除图形
+
+        <el-button
+          :type="activeMeasureType === 'area' ? 'danger' : 'warning'"
+          size="small"
+          icon="el-icon-crop"
+          @click="toggleMeasure('area')"
+        >
+          {{ activeMeasureType === 'area' ? '结束测面' : '测面积' }}
         </el-button>
       </div>
-    </div>
 
-    <el-dialog
-      :visible.sync="showPositionDialog"
-      width="400px"
-      custom-class="custom-map-dialog"
-      center
-      append-to-body
-    >
-      <div slot="title" class="dialog-title">
-        <i class="el-icon-location-information"></i>
-        <span>位置详情与操作</span>
+      <div class="control-group single-button-row">
+        <el-button
+          type="danger"
+          size="small"
+          icon="el-icon-delete"
+          style="width: 100%"
+          @click="clearAll"
+        >
+          清除所有图形与测量
+        </el-button>
       </div>
 
-      <div class="dialog-body">
-        <div class="coord-display">
-          <div class="label">选定坐标:</div>
-          <div class="value" v-if="positionForm.lng !== null && positionForm.lat !== null">
-            {{ positionForm.lng.toFixed(6) }}, {{ positionForm.lat.toFixed(6) }}
-          </div>
-          <div class="value" v-else>-- , --</div>
-        </div>
-
-        <el-divider></el-divider>
-
-        <div v-if="showInput" class="radius-setter">
-          <p class="input-tip">设置绘制半径 (米)</p>
-          <div class="input-row">
-            <el-input-number
-              v-model="radius"
-              :min="10"
-              :max="10000"
-              controls-position="right"
-              style="width: 100%"
-            />
-            <el-button type="primary" @click="handleNumChange" style="margin-top: 15px; width: 100%">确定半径并绘制</el-button>
-          </div>
-        </div>
-
-        <div v-else class="action-grid">
-          <el-button icon="el-icon-document-add" @click="onSavePosition">保存坐标</el-button>
-          <el-button icon="el-icon-magic-stick" type="success" @click="onDrawCircle">绘制圆形</el-button>
-          <el-button icon="el-icon-star-off" type="warning" @click="onSaveMyPosition">设为我的位置</el-button>
-        </div>
+      <div v-if="isDrawingCircle" class="radius-mini-setter">
+        <span class="label">默认半径(米):</span>
+        <el-input-number
+          v-model="radius"
+          :min="10"
+          :max="10000"
+          size="mini"
+          controls-position="right"
+          style="width: 90px"
+        />
       </div>
-    </el-dialog>
+    </div>
   </div>
 </template>
 
 <script>
 import AMapLoader from '@amap/amap-jsapi-loader'
-import { addGeoPosition, addMyPosition, addPath, getGeoPoint } from '@/api/map'
+// 注意:在这里引入你实际用来批量保存标记点的后端 API,这里假设名字叫 saveBatchMarks
+import { addGeoPath, getGeoPath, getMapMarks, saveBatchMarks } from '@/api/map'
 
 export default {
   name: 'AMap',
@@ -84,25 +112,39 @@ export default {
       amap: null,
       map: null,
       AMap: null,
-      zoom: 12, // 比例尺 10 公里
+      zoom: 12,
       center: [104.068071, 30.576432],
       path: [],
       mapCircle: null,
       circleEditor: null,
-      showPositionDialog: false,
-      showInput: false,
-      radius: 1000, // 半径 1 a 公里
-      positionForm: { lng: null, lat: null },
-      pointArr: [],
+      radius: 1000,
       mapKeys: {
         securityJsCode: '983d6ee43bab3edf3693e91508f94aa9',
         key: '7b75ab2839ce68b884c7a682501ea774'
       },
       addPathText: '添加路径',
       enableAddPath: false,
+      isDrawingCircle: false,
+      isMarking: false,
+      activeMeasureType: null,
       pathPointList: [],
       previewMarkers: [],
-      previewPolyline: null
+      previewPolyline: null,
+      manualMarkers: [], // 临时存放本次批量标记在图面上的 Marker 实例对象
+      tempMarkedPoints: [], // 👈 新增:专门用来传递给后端的纯坐标数据数组 [ {lng, lat}, ... ]
+      loadedMarkers: [],
+      rangingTool: null,
+      mouseTool: null
+    }
+  },
+  computed: {
+    tipText() {
+      if (this.isMarking) return `已标记 ${this.tempMarkedPoints.length} 个点,点击工具栏按钮可保存提交`
+      if (this.isDrawingCircle) return '请在地图上点击某点作为圆心'
+      if (this.enableAddPath) return '点击地图连续添加路径点'
+      if (this.activeMeasureType === 'distance') return '单击开始测距,双击结束测距'
+      if (this.activeMeasureType === 'area') return '单击绘制多边形,双击结束并计算面积'
+      return ''
     }
   },
   mounted() {
@@ -121,15 +163,13 @@ export default {
       AMapLoader.load({
         key: this.mapKeys.key,
         version: '1.4.15',
-        plugins: ['AMap.Autocomplete', 'AMap.PlaceSearch', 'AMap.Scale', 'AMap.OverView', 'AMap.ToolBar',
-          'AMap.MapType', 'AMap.PolyEditor', 'AMap.CircleEditor', 'AMap.MassMarks', 'AMap.Size',
-          'AMap.Pixel', 'AMap.DistrictSearch'],
+        plugins: ['AMap.CircleEditor', 'AMap.RangingTool', 'AMap.MouseTool'],
         AMapUI: {
           version: '1.0',
-          plugins: ['misc/PathSimplifier', 'overlay/SimpleMarker']
+          plugins: ['misc/PathSimplifier']
         }
       }).then((AMap) => {
-        this.AMap = AMap;
+        this.AMap = AMap
         this.map = new AMap.Map('container', {
           viewMode: '2D',
           zoom: this.zoom,
@@ -138,21 +178,31 @@ export default {
           mapStyle: 'amap://styles/light'
         })
 
+        this.rangingTool = new AMap.RangingTool(this.map)
+        this.mouseTool = new AMap.MouseTool(this.map)
+
+        this.mouseTool.on('draw', (e) => {})
+
         this.map.on('click', (e) => {
+          if (this.activeMeasureType) return
+
           const lng = e.lnglat.getLng()
           const lat = e.lnglat.getLat()
-
+          const currentIndex = this.pathPointList.length
+          const currentTimestamp = new Date().getTime()
           if (this.enableAddPath) {
-            const point = { lng: lng, lat: lat }
+            const point = {
+              index: currentIndex,
+              lng: lng,
+              lat: lat,
+              timestamp: currentTimestamp
+            }
             this.pathPointList.push(point)
 
             const marker = new AMap.Marker({
               position: e.lnglat,
               map: this.map,
-              label: {
-                content: this.pathPointList.length,
-                direction: 'top'
-              }
+              label: { content: this.pathPointList.length, direction: 'top' }
             })
             this.previewMarkers.push(marker)
 
@@ -162,19 +212,17 @@ export default {
             } else {
               this.previewPolyline = new AMap.Polyline({
                 path: pathArr,
-                strokeColor: "#3366FF",
+                strokeColor: '#3366FF',
                 strokeOpacity: 0.8,
                 strokeWeight: 5,
-                strokeStyle: "solid",
                 map: this.map
               })
             }
-          } else {
-            this.pointArr = [lng, lat]
-            this.positionForm.lng = lng
-            this.positionForm.lat = lat
-            this.showPositionDialog = true
-            this.showInput = false // 重置输入状态
+          } else if (this.isDrawingCircle) {
+            this.drawCircle([lng, lat], this.radius)
+          } else if (this.isMarking) {
+            // 👈 修改:点击地图时同时记录图面实例与纯数据
+            this.addManualMarker(e.lnglat)
           }
         })
       }).catch((e) => {
@@ -182,10 +230,167 @@ export default {
       })
     },
 
+    loadMarker() {
+      this.stopAllTools()
+      this.activeMeasureType = null
+
+      getMapMarks().then(resp => {
+        if (resp.code === 0) {
+          if (this.loadedMarkers.length > 0) {
+            this.map.remove(this.loadedMarkers)
+            this.loadedMarkers = []
+          }
+
+          for (const item of resp.data) {
+            const lng = item.lng
+            const lat = item.lat
+            const title = item.title
+            const id = item.id
+
+            const marker = new this.AMap.Marker({
+              position: [lng, lat],
+              map: this.map,
+              title: title,
+              animation: 'AMAP_ANIMATION_DROP',
+              extData: { id: id }
+            })
+
+            marker.on('click', () => {
+              this.$message.info(`地点:${title} [${lng.toFixed(6)}, ${lat.toFixed(6)}]`)
+            })
+
+            this.loadedMarkers.push(marker)
+          }
+          this.$message.success(`成功加载了 ${resp.data.length} 个历史标记点`)
+        }
+      }).catch(error => {
+        console.error('加载标记点失败:', error.message)
+        this.$message.error('加载标记点失败')
+      })
+    },
+
+    // 状态切换:标记地点 (包含批量提交后端的关键控制)
+    toggleMarkPlaceState() {
+      // 1. 如果当前处于标记状态,此时点击意味着“结束并保存”
+      if (this.isMarking) {
+        if (this.tempMarkedPoints.length === 0) {
+          this.$message.warning('未选择任何标记点')
+          this.isMarking = false
+          return
+        }
+
+        // 发送保存数组到后端
+        saveBatchMarks(this.tempMarkedPoints).then(resp => {
+          if (resp.code === 0) {
+            this.$message.success('批量标记地点保存成功!')
+          }
+        }).catch(err => {
+          console.error(err)
+          this.$message.error('保存标记点失败')
+        }).finally(() => {
+          this.isMarking = false
+          this.tempMarkedPoints = []
+          this.clearManualMarkers()
+        })
+        return
+      }
+
+      // 2. 如果是从其他状态切换过来,初始化环境
+      this.stopAllTools()
+      this.activeMeasureType = null
+      this.isMarking = true
+      this.clearManualMarkers()
+      this.$message.info('已开启标记模式,请在地图上点击打点')
+    },
+
+    // 往临时数组和图面上同步添加数据
+    addManualMarker(lnglat) {
+      const lng = lnglat.getLng()
+      const lat = lnglat.getLat()
+      const currentIndex = this.tempMarkedPoints.length
+      const currentTimestamp = new Date().getTime()
+
+      // 1. 把纯数据压入准备发往后端的数组中
+      this.tempMarkedPoints.push({
+        index: currentIndex,
+        lng: lng,
+        lat: lat,
+        timestamp: currentTimestamp
+      })
+
+      // 2. 在图面上渲染临时图标提供视觉反馈
+      const marker = new this.AMap.Marker({
+        position: lnglat,
+        map: this.map,
+        animation: 'AMAP_ANIMATION_DROP',
+        title: `待保存点 [${lng.toFixed(6)}, ${lat.toFixed(6)}]`
+      })
+
+      this.manualMarkers.push(marker)
+    },
+
+    // 清空本次未保存的临时标记图层
+    clearManualMarkers() {
+      if (this.manualMarkers.length > 0) {
+        this.map.remove(this.manualMarkers)
+        this.manualMarkers = []
+      }
+      this.tempMarkedPoints = []
+    },
+
+    toggleMeasure(type) {
+      if (this.activeMeasureType === type) {
+        this.stopAllTools()
+        this.activeMeasureType = null
+        return
+      }
+
+      this.stopAllTools()
+      this.activeMeasureType = type
+
+      if (type === 'distance') {
+        if (this.rangingTool) this.rangingTool.turnOn()
+      } else if (type === 'area') {
+        if (this.mouseTool) {
+          this.mouseTool.measureArea({
+            strokeColor: '#fc5531',
+            strokeOpacity: 1,
+            strokeWeight: 2,
+            fillColor: '#fc5531',
+            fillOpacity: 0.2
+          })
+        }
+      }
+    },
+
+    stopAllTools() {
+      this.enableAddPath = false
+      this.addPathText = '添加路径'
+
+      // 注意:如果是切换到别的工具导致退出标记状态,视为放弃未保存的打点,执行擦除
+      if (this.isMarking) {
+        this.clearManualMarkers()
+      }
+      this.isMarking = false
+      this.isDrawingCircle = false
+
+      if (this.rangingTool) this.rangingTool.turnOff()
+      if (this.mouseTool) this.mouseTool.close(false)
+    },
+
+    toggleDrawCircleState() {
+      const targetState = !this.isDrawingCircle
+      this.stopAllTools()
+      this.activeMeasureType = null
+      this.isDrawingCircle = targetState
+    },
+
     addPath() {
       if (!this.enableAddPath) {
+        this.stopAllTools()
+        this.activeMeasureType = null
         this.enableAddPath = true
-        this.addPathText = '完成添加'
+        this.addPathText = '保存路径'
         this.clearPreviewOverlays()
         this.pathPointList = []
       } else {
@@ -195,10 +400,8 @@ export default {
         }
         this.enableAddPath = false
         this.addPathText = '添加路径'
-        addPath(this.pathPointList).then(resp => {
-          if (resp.code === 0) {
-            this.$message.success('路径保存成功')
-          }
+        addGeoPath(this.pathPointList).then(resp => {
+          if (resp.code === 0) this.$message.success('路径保存成功')
         }).finally(() => {
           this.clearPreviewOverlays()
           this.pathPointList = []
@@ -206,43 +409,43 @@ export default {
       }
     },
 
-    clearPreviewOverlays() {
-      if (this.previewMarkers.length > 0) {
-        this.map.remove(this.previewMarkers)
-        this.previewMarkers = []
-      }
-      if (this.previewPolyline) {
-        this.map.remove(this.previewPolyline)
-        this.previewPolyline = null
-      }
-    },
-
     drawCircle(pointArr, radius) {
+      this.clearCircle()
       const center = new this.AMap.LngLat(pointArr[0], pointArr[1])
       const circle = new this.AMap.Circle({
         center: center,
         radius: radius,
-        strokeColor: '#ff3333',
-        fillColor: '#1791fc',
-        fillOpacity: 0.4,
+        strokeColor: '#409EFF',
+        strokeOpacity: 1,
+        strokeWeight: 2,
+        fillColor: '#409EFF',
+        fillOpacity: 0.2,
         zIndex: 50
       })
       this.map.add(circle)
       this.map.setFitView([circle])
       this.mapCircle = circle
-      const that = this
+
+      const self = this
       this.map.plugin(['AMap.CircleEditor'], function() {
-        const circleEditor = new that.AMap.CircleEditor(that.map, circle)
+        const circleEditor = new self.AMap.CircleEditor(self.map, circle)
         circleEditor.open()
-        that.circleEditor = circleEditor
+        self.circleEditor = circleEditor
       })
     },
 
     onPathNavigator() {
-      getGeoPoint().then(resp => {
+      this.stopAllTools()
+      this.activeMeasureType = null
+
+      var queryParam = {
+        pathId: 1
+      }
+      getGeoPath(queryParam).then(resp => {
         if (resp.code === 0) {
           this.path = resp.data
           if (this.path.length === 0) return this.$message.warning('暂无路径')
+
           AMapUI.load(['ui/misc/PathSimplifier'], (PathSimplifier) => {
             const pathSimplifierIns = new PathSimplifier({
               map: this.map,
@@ -257,27 +460,15 @@ export default {
       })
     },
 
-    onSavePosition() {
-      this.showPositionDialog = false
-      addGeoPosition(this.positionForm).then(resp => {
-        if (resp.code === 0) this.$message.success('保存成功')
-      })
-    },
-
-    onSaveMyPosition() {
-      this.showPositionDialog = false
-      addMyPosition(this.positionForm).then(resp => {
-        if (resp.code === 0) this.$message.success('设置成功')
-      })
-    },
-
-    onDrawCircle() { this.showInput = true },
-
-    handleNumChange() {
-      if (this.radius < 10) return this.$message.error('半径过小')
-      this.showInput = false
-      this.showPositionDialog = false
-      this.drawCircle(this.pointArr, this.radius)
+    clearPreviewOverlays() {
+      if (this.previewMarkers.length > 0) {
+        this.map.remove(this.previewMarkers)
+        this.previewMarkers = []
+      }
+      if (this.previewPolyline) {
+        this.map.remove(this.previewPolyline)
+        this.previewPolyline = null
+      }
     },
 
     clearCircle() {
@@ -285,18 +476,35 @@ export default {
       if (this.circleEditor) this.circleEditor.close()
       this.mapCircle = null
       this.circleEditor = null
-      this.$message.info('已清除图形')
     },
-    goBack() { this.$router.back() }
+
+    clearAll() {
+      this.stopAllTools()
+      this.activeMeasureType = null
+
+      this.clearCircle()
+      this.clearPreviewOverlays()
+      this.clearManualMarkers()
+
+      if (this.loadedMarkers.length > 0) {
+        this.map.remove(this.loadedMarkers)
+        this.loadedMarkers = []
+      }
+
+      if (this.rangingTool) this.rangingTool.turnOff()
+      if (this.mouseTool) this.mouseTool.close(true)
+
+      this.pathPointList = []
+      this.$message.info('图面覆盖物与测量数据已完全清空')
+    }
   }
 }
 </script>
 
 <style scoped lang="scss">
-/* 核心:撑满全屏 */
 .map-full-container {
   width: 100%;
-  height: 100vh; /* 撑满视口高度 */
+  height: 100vh;
   position: relative;
   overflow: hidden;
   margin: 0;
@@ -305,10 +513,9 @@ export default {
 
 .amap-instance {
   width: 100%;
-  height: 100%; /* 继承父级高度 */
+  height: 100%;
 }
 
-/* 悬浮控件样式优化 */
 .map-overlay-controls {
   position: absolute;
   top: 20px;
@@ -317,61 +524,79 @@ export default {
   display: flex;
   flex-direction: column;
   align-items: flex-end;
-  gap: 12px;
+  gap: 10px;
 
   .tip-group {
-    background: rgba(255, 255, 255, 0.9);
-    padding: 8px 16px;
-    border-radius: 50px;
-    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
-    color: #f56c6c;
-    font-size: 13px;
-    backdrop-filter: blur(8px);
-    border: 1px solid rgba(255, 255, 255, 0.5);
+    background: rgba(255, 255, 255, 0.95);
+    padding: 6px 14px;
+    border-radius: 4px;
+    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
+    color: #e6a23c;
+    font-size: 12px;
+    border-left: 4px solid #e6a23c;
     display: flex;
     align-items: center;
     i { margin-right: 6px; }
   }
 
-  .button-group {
-    background: rgba(255, 255, 255, 0.85);
-    padding: 10px;
-    border-radius: 12px;
-    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
-    backdrop-filter: blur(10px);
-    display: flex;
-    flex-direction: column;
-    gap: 8px;
-    border: 1px solid rgba(255, 255, 255, 0.6);
+  .button-group-grid {
+    background: rgba(255, 255, 255, 0.9);
+    padding: 8px 8px 4px 8px;
+    border-radius: 6px 6px 0 0;
+    box-shadow: 0 -2px 12px 0 rgba(0, 0, 0, 0.1);
+    border: 1px solid #dcdfe6;
+    border-bottom: none;
+
+    display: grid;
+    grid-template-columns: repeat(2, 110px);
+    gap: 6px;
 
     .el-button {
       margin: 0 !important;
-      width: 120px;
-      font-weight: 500;
-      border-radius: 8px;
+      width: 100%;
+      height: 32px;
+      padding: 0 4px;
+      font-size: 12px;
+      border-radius: 4px;
     }
   }
-}
 
-/* 弹窗及其他样式 */
-::v-deep .custom-map-dialog {
-  border-radius: 16px;
-  overflow: hidden;
-  .el-dialog__header { padding: 20px; background: #fbfcfd; }
-}
+  .single-button-row {
+    background: rgba(255, 255, 255, 0.9);
+    padding: 0 8px 8px 8px;
+    border-radius: 0 0 6px 6px;
+    box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15);
+    border: 1px solid #dcdfe6;
+    border-top: none;
+    width: 236px;
+    box-sizing: border-box;
 
-.coord-display {
-  background: #f0f7ff;
-  padding: 20px;
-  border-radius: 12px;
-  text-align: center;
-  .value { font-family: 'Monaco', monospace; font-size: 18px; color: #409EFF; }
-}
+    .el-button {
+      height: 32px;
+      font-size: 12px;
+    }
+  }
 
-.action-grid {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  .el-button { margin: 0 !important; text-align: left; padding: 12px 20px; }
+  .radius-mini-setter {
+    background: #409eff;
+    color: #ffffff;
+    padding: 5px 8px;
+    border-radius: 4px;
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    box-shadow: 0 4px 10px rgba(64, 158, 255, 0.3);
+    .label { font-weight: bold; }
+
+    ::v-deep .el-input-number--mini {
+      .el-input__inner {
+        padding-left: 5px;
+        padding-right: 25px;
+        height: 24px;
+        line-height: 24px;
+      }
+    }
+  }
 }
 </style>

+ 308 - 0
src/views/map/ChartMap.vue

@@ -0,0 +1,308 @@
+<template>
+  <div ref="mapContainer" class="chart-map-container">
+    <div class="chart-wrapper">
+      <div ref="chartsDOM" class="chart-content" />
+    </div>
+
+    <div class="control-toolbar">
+      <div class="toolbar-row-select">
+        <el-cascader
+          v-model="cascaderOption"
+          :options="cascaderOptions"
+          :props="{ checkStrictly: true }"
+          placeholder="选择地区(支持地区搜索)"
+          clearable
+          filterable
+          @change="onCascaderChange"
+        />
+      </div>
+
+      <div class="toolbar-row-buttons">
+        <button
+          :class="['action-btn', { 'btn-active': isLabelShow }]"
+          :disabled="!areaCode"
+          @click="onToggleLabel"
+        >
+          {{ isLabelShow ? '隐藏地名' : '显示地名' }}
+        </button>
+
+        <button
+          class="action-btn"
+          :disabled="!areaCode || loading"
+          @click="onButtonRefresh"
+        >
+          <i v-if="loading" class="el-icon-loading" />
+          {{ loading ? '刷新中' : '刷新数据' }}
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts'
+import { getAreaCascadeOptions, getChartMapGeojson } from '@/api/map'
+
+export default {
+  name: 'ChartMap',
+  data() {
+    return {
+      myChart: null,
+      areaCode: '',
+      isLabelShow: false,
+      loading: false,
+      cascaderOption: [],
+      cascaderOptions: []
+    }
+  },
+  mounted() {
+    this.getData()
+    window.addEventListener('resize', this.handleResize)
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize)
+    if (this.myChart) {
+      this.myChart.dispose()
+    }
+  },
+  created() {
+    document.title = 'ChartMap'
+  },
+  methods: {
+    getData() {
+      getAreaCascadeOptions().then(resp => {
+        if (resp.code === 0) {
+          this.cascaderOptions = resp.data
+
+          this.cascaderOption = ['900000', '100000']
+          this.onCascaderChange(this.cascaderOption)
+        }
+      })
+    },
+    onCascaderChange(val) {
+      // 兼容清空选择器时的情况
+      if (!val || val.length === 0) {
+        this.areaCode = ''
+        if (this.myChart) this.myChart.clear()
+        return
+      }
+
+      const length = val.length
+      this.areaCode = this.cascaderOption[length - 1]
+      this.loading = true
+
+      if (!this.myChart) {
+        this.myChart = echarts.init(this.$refs['chartsDOM'])
+      }
+      this.myChart.showLoading({ text: '地图数据加载中...' })
+
+      getChartMapGeojson(this.areaCode).then(resp => {
+        if (resp && resp.code === 0) {
+          const { geoJson: geoJsonStr, list: dataList } = resp.data
+          const geoJson = typeof geoJsonStr === 'string' ? JSON.parse(geoJsonStr) : geoJsonStr
+          const mapName = this.areaCode
+          const splitList = [
+            { start: 800, end: 1000 },
+            { start: 600, end: 800 },
+            { start: 400, end: 600 },
+            { start: 200, end: 400 },
+            { start: 100, end: 200 },
+            { start: 0, end: 100 }
+          ]
+          const chartOption = this.buildChartOption(mapName, dataList, splitList)
+
+          echarts.registerMap(mapName, geoJson)
+          this.myChart.setOption(chartOption, true)
+        }
+      }).catch(err => {
+        this.$message.error('加载地图数据失败')
+        console.error(err)
+      }).finally(() => {
+        this.loading = false
+        if (this.myChart) {
+          this.myChart.hideLoading()
+        }
+      })
+    },
+    buildChartOption(mapName, dataList, splitList) {
+      return {
+        backgroundColor: '#FFFFFF',
+        title: {
+          text: '地区数据统计地图',
+          subtext: '数据实时更新',
+          left: 'center',
+          top: 20
+        },
+        tooltip: {
+          trigger: 'item',
+          formatter: '{b}<br/>{a}: {c}'
+        },
+        visualMap: {
+          show: true,
+          left: 30,
+          bottom: 30,
+          splitList: splitList,
+          // --- 核心修改:红色系由深到浅渐变 ---
+          // 对应 6 个分段:数值最高(深红) -> 数值最低(极浅红/粉白)
+          color: [
+            '#cf1322', // 强烈的深红色(对应最高区间,如 800-1000)
+            '#f5222d', // 标准大红色
+            '#ff4d4f', // 亮红色
+            '#ff7875', // 珊瑚红 / 浅红
+            '#ffa39e', // 粉红色
+            '#fff1f0' // 极浅的粉白(对应最低区间,如 0-100)
+          ]
+        },
+        series: [{
+          name: '数量',
+          type: 'map',
+          map: mapName,
+          roam: true,
+          itemStyle: {
+            borderColor: '#fff',
+            borderWidth: 1
+          },
+          emphasis: {
+            itemStyle: {
+              areaColor: '#389e0d'
+            }
+          },
+          label: {
+            show: this.isLabelShow,
+            color: '#333',
+            fontSize: 11
+          },
+          data: dataList
+        }]
+      }
+    },
+    onToggleLabel() {
+      if (!this.myChart) return
+      this.isLabelShow = !this.isLabelShow
+      this.myChart.setOption({
+        series: [{
+          label: {
+            show: this.isLabelShow
+          }
+        }]
+      })
+    },
+    onButtonRefresh() {
+      // 核心修正:这里原先调用了闲置的 onSelect(),已修正为直接调用事件处理
+      if (this.cascaderOption.length > 0) {
+        this.onCascaderChange(this.cascaderOption)
+      } else {
+        this.$message.warning('请先选择地区再进行数据刷新')
+      }
+    },
+    handleResize() {
+      if (this.myChart) {
+        this.myChart.resize()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.chart-map-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.chart-wrapper {
+  width: 100%;
+  height: 100%;
+}
+
+.chart-content {
+  width: 100%;
+  height: 100%;
+}
+
+/* ================= UI 优化核心样式 ================= */
+
+/* 右上角工具箱容器:将总宽度调整至 220px 适配更宽的级联选择器 */
+.control-toolbar {
+  position: absolute;
+  z-index: 999;
+  top: 20px;
+  right: 20px;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 8px; /* 稍微内缩 padding 显得更精致 */
+  border-radius: 6px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.15);
+  width: 220px; /* 👈 从 160px 拓宽至 220px */
+
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+/* 选择器行占满宽度 */
+.toolbar-row-select {
+  width: 100%;
+}
+
+/* 确保 el-cascader 内部完全铺满行宽 */
+.toolbar-row-select .el-cascader {
+  width: 100%;
+}
+
+/* 按钮行依旧平分 */
+.toolbar-row-buttons {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 6px;
+}
+
+/* 通用轻量按钮样式 */
+.action-btn {
+  height: 28px;
+  border: 1px solid #dcdfe6;
+  background-color: #fff;
+  color: #606266;
+  font-size: 11px;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s;
+  padding: 0 4px;
+  white-space: nowrap;
+}
+
+.action-btn:hover:not(:disabled) {
+  color: #fc5531;
+  border-color: #fc5531;
+  background-color: rgba(252, 86, 49, 0.05);
+}
+
+.action-btn:disabled {
+  background-color: #f5f7fa;
+  border-color: #e4e7ed;
+  color: #c0c4cc;
+  cursor: not-allowed;
+}
+
+.btn-active {
+  background-color: #fc5531 !important;
+  color: #fff !important;
+  border-color: #fc5531 !important;
+}
+
+/* 深度选择器穿透:彻底重构为适配 el-cascader 的紧凑型地图外观 */
+::v-deep .el-cascader .el-input__inner {
+  height: 28px !important;
+  line-height: 28px !important;
+  font-size: 11px;
+  border-color: #dcdfe6;
+  padding-left: 8px; /* 轻微缩进提升留白美感 */
+}
+
+::v-deep .el-cascader .el-input__icon {
+  line-height: 28px !important;
+}
+</style>

+ 430 - 186
src/views/map/OpenLayersMap.vue

@@ -1,17 +1,27 @@
 <template>
-  <!-- 绘制、编辑、输出GeoJson数据 -->
   <div id="map" ref="map">
-    <div class="btns">
-      <button @click="drawFeature">绘制</button>
-      <button @click="editorFeature" v-text="editorBtnText" />
-      <button @click="outputJson">输出Json</button>
-      <button @click="measure('distence')">距</button>
-      <button @click="measure('area')">面</button>
-      <button @click="measure('angle')">角</button>
-      <button @click="fullscreen" v-html="textarr" />
-      <button @click="single">坐标</button>
+    <div class="toolbox">
+      <button @click="loadMarker">加载标记</button>
+      <button
+        :style="{ backgroundColor: isMarking ? '#ff4d4f' : '', color: isMarking ? '#fff' : '' }"
+        @click="markPlace"
+      >
+        {{ isMarking ? '结束标记' : '标记地点' }}
+      </button>
+      <button
+        :style="{ backgroundColor: isDrawingCircle ? '#409EFF' : '', color: isDrawingCircle ? '#fff' : '' }"
+        @click="drawCircle"
+      >
+        {{ isDrawingCircle ? '结束画圆' : '绘制圆形' }}
+      </button>
+      <button @click="measure('distance')">测距离</button>
+      <button @click="measure('area')">测面积</button>
       <button @click="clear">清除</button>
     </div>
+
+    <div ref="radiusOverlayLabel" class="ol-radius-marker" style="display: none;">
+      {{ liveRadiusText }}
+    </div>
   </div>
 </template>
 
@@ -21,16 +31,13 @@ import Map from 'ol/Map'
 import View from 'ol/View'
 import { Vector as VectorSource, OSM, XYZ } from 'ol/source'
 import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'
-import { GeoJSON } from 'ol/format'
-// 交互 Select在选择功能时触发。Modify用于修改功能几何的交互。Draw绘制功能几何的交互。
-import { Draw, Modify, Select } from 'ol/interaction'
-
+// Draw 绘制功能几何的交互
+import { Draw, Modify } from 'ol/interaction'
 import Overlay from 'ol/Overlay'
-// 线条几何形状
+// 线条几何形状
 import { LineString } from 'ol/geom'
-// 具有几何和其他属性属性属性的地理特征的矢量对象,类似于像 GeoJSON 这样的矢量文件格式中的特征
+// 具有几何和其他属性属性属性的地理特征的矢量对象,类似于像 GeoJSON 这样的矢量文件格式中的特征
 import Feature from 'ol/Feature'
-// 使用返回的键或
 import { unByKey } from 'ol/Observable'
 // 获取几何形状的球形长度和面积
 import { getLength, getArea } from 'ol/sphere'
@@ -38,51 +45,51 @@ import Style from 'ol/style/Style'
 import Stroke from 'ol/style/Stroke'
 import Fill from 'ol/style/Fill'
 import Circle from 'ol/style/Circle'
-import { fromLonLat } from 'ol/proj'
-// import Icon from "ol/style/Icon";
-// import { transform } from "ol/proj";
-// import Point from "ol/geom/Point";
+import { fromLonLat, toLonLat } from 'ol/proj'
+import Icon from 'ol/style/Icon'
+import Point from 'ol/geom/Point'
+import Text from 'ol/style/Text'
+import { getMapMarks } from '@/api/map'
 
 export default {
   name: 'OpenLayersMap',
   data() {
     return {
+      center: [104.068071, 30.576432],
+      zoom: 12, // 比例尺 10 公里
       map: null,
       vectorLayer: null,
       vectorSource: null,
+      // 标记地点
+      isMarking: false,
+      clickListenerKey: null,
+      // --- 新增:绘制圆形相关变量 ---
+      isDrawingCircle: false, // 是否处于画圆状态
+      liveRadiusText: '', // 实时半径文字
+      radiusOverlay: null, // 存放半径文本的 Overlay 实例
       draw: null,
       select: null,
       modify: null,
-      editorBtnText: '编辑',
-      textarr: '全屏',
-      measureType: 'diatence',
+      measureType: 'distance',
       tipDiv: null,
       pointermoveEvent: null, // 地图pointermove事件
       sketchFeature: null, // 绘制的要素
       geometryListener: null, // 要素几何change事件
-      measureResult: '0' // 测量结果
+      measureResult: '0', // 测量结果
+      markerSource: null // 新增:专门存放后台加载的标记点
     }
   },
   mounted() {
+    this.markerSource = new VectorSource({ wrapX: false })
+    const markerLayer = new VectorLayer({
+      source: this.markerSource,
+      name: '后台标记图层'
+    })
+
     // 底图使用 OpenStreetMap, 对应 view
     var baseLayer = new TileLayer({
       source: new OSM()
     })
-    // 谷歌地球, 对应 view1
-    var baseLayer1 = new TileLayer({
-      source: new XYZ({
-        url: 'https://www.google.com/maps/vt?lyrs=y&gl=cn&x={x}&y={y}&z={z}',
-        crossOrigin: 'anonymous',
-        wrapX: true
-      })
-    })
-    // 谷歌地图, 对应 view2
-    var baseLayer2 = new TileLayer({
-      source: new XYZ({
-        url: 'http://www.google.cn/maps/vt/pb=!1m4!1m3!1i{z}!2i{x}!3i{y}!2m3!1e0!2sm!3i345013117!3m8!2szh-CN!3scn!5e1105!12m4!1e68!2m2!1sset!2sRoadmap!4e0'
-      })
-    })
-
     // 矢量图层源
     this.vectorSource = new VectorSource({
       wrapX: false
@@ -92,86 +99,296 @@ export default {
       source: this.vectorSource
     })
 
-    var view = new View({
+    var url = 'https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
+    // 切换为高德地图的电子地图底图
+    var amapLayer = new TileLayer({
+      source: new XYZ({
+        // 高德官方切片地址(t1-t4可选,lang=zh_cn代表中文,style=7代表标准地图)
+        url: url,
+        crossOrigin: 'anonymous',
+        maxZoom: 18
+      }),
+      name: '高德底图'
+    })
+    /* this.map = new Map({
+      layers: [baseLayer, this.vectorLayer, markerLayer],
+      target: 'map',
+      view: new View({
       projection: 'EPSG:4326',
       center: [104.06531800244139, 30.65852484539117],
       zoom: 10
     })
-    var view1 = new View({
-      projection: 'EPSG:3857', // 坐标系EPSG:4326或EPSG:3857
-      zoom: 0, // 打开页面时默认地图缩放级别
-      center: fromLonLat([121.5, 25]) // 转到墨卡托坐标系
-    })
-    var view2 = new View({
-      center: [104.06531800244139, 30.65852484539117],
-      zoom: 12,
-      maxZoom: 18,
-      projection: 'EPSG:4326',
-      constrainResolution: true, // 设置缩放级别为整数
-      smoothResolutionConstraint: false // 关闭无级缩放地图
-    })
+    })*/
 
     this.map = new Map({
-      layers: [baseLayer, this.vectorLayer],
+      layers: [amapLayer, this.vectorLayer, markerLayer], // 替换掉 baseLayer
       target: 'map',
-      view: view
+      view: new View({
+        projection: 'EPSG:3857', // ⚠️注意:高德切片是墨卡托(3857)投影,建议View也改为3857
+        center: fromLonLat(this.center), // 用 fromLonLat 转换中心点
+        zoom: 12,
+        maxZoom: 18
+      })
     })
   },
   created() {
     document.title = 'OpenLayersMap'
   },
   methods: {
-    drawFeature() {
-      this.draw = new Draw({
-        source: this.vectorSource,
-        // 用此实例绘制的几何形状类型。
-        type: 'Polygon'
-      })
-      // addInteraction  将给定的交互添加到地图中
-      this.map.addInteraction(this.draw)
-      // 绘制完成
-      this.draw.on('drawend', () => {
-        //  removeInteraction 从地图中删除给定的交互。
-        this.map.removeInteraction(this.draw)
-        this.draw = null
-      })
-    },
+    drawCircle() {
+      this.isDrawingCircle = !this.isDrawingCircle
+
+      // 清理上一次的组件
+      if (this.draw) this.map.removeInteraction(this.draw)
+      if (this.modify) this.map.removeInteraction(this.modify)
+      if (this.radiusOverlay) this.map.removeOverlay(this.radiusOverlay)
+
+      if (this.isDrawingCircle) {
+        this.map.getTargetElement().style.cursor = 'crosshair'
 
-    editorFeature() {
-      if (this.editorBtnText === '编辑') {
-        this.editorBtnText = '完成编辑'
-        // 要素选择组件
-        this.select = new Select({
-          // wrapX 将世界水平包裹在草图覆盖物上。
-          wrapX: false
+        // 1. 创建半径气泡标签 DOM
+        const labelEl = document.createElement('div')
+        labelEl.className = 'ol-radius-marker'
+
+        this.radiusOverlay = new Overlay({
+          element: labelEl,
+          offset: [15, -15],
+          positioning: 'bottom-left',
+          stopEvent: false
+        })
+        this.map.addOverlay(this.radiusOverlay)
+
+        // 2. 初始化绘制器
+        this.draw = new Draw({
+          source: this.vectorSource,
+          type: 'Circle'
         })
-        // 要素编辑
-        this.modify = new Modify({
-          // getFeatures 随机获取源上当前功能的快照。返回的阵列是副本,功能是源中功能的引用。
-          features: this.select.getFeatures()
+
+        let centerFeature = null
+
+        // 3. 监听开始绘制
+        this.draw.on('drawstart', (evt) => {
+          const geometry = evt.feature.getGeometry()
+
+          // 绘制圆心红点
+          const initialCenter = geometry.getCenter()
+          centerFeature = new Feature({ geometry: new Point(initialCenter) })
+          centerFeature.setStyle(new Style({
+            image: new Circle({
+              radius: 5,
+              fill: new Fill({ color: '#ff4d4f' }),
+              stroke: new Stroke({ color: '#ffffff', width: 2 })
+            })
+          }))
+          this.vectorSource.addFeature(centerFeature)
+
+          // 绑定高频拖拽计算半径事件
+          const bindGeometryChange = (geom) => {
+            geom.on('change', () => {
+              const radiusInMeters = geom.getRadius()
+              const text = radiusInMeters < 1000 ? `${radiusInMeters.toFixed(1)}m` : `${(radiusInMeters / 1000).toFixed(2)}km`
+              labelEl.innerText = text
+
+              // 标签死死跟着圆周上的鼠标/控制点边缘
+              const lastCoordinate = geom.getLastCoordinate()
+              if (this.radiusOverlay) {
+                this.radiusOverlay.setPosition(lastCoordinate)
+              }
+              // 同步纠正圆心物理位置
+              if (centerFeature) centerFeature.getGeometry().setCoordinates(geom.getCenter())
+            })
+          }
+
+          bindGeometryChange(geometry)
+          // 将这个绑定函数挂载到 feature 上,方便后期二次编辑修改时复用
+          evt.feature.set('changeBinder', bindGeometryChange)
         })
 
-        // 编辑完成后
-        this.modify.on('modifyend', function(e) {
-          console.log('编辑后的要素:', e.features)
+        // 4. 核心新增:监听绘制结束,立刻无缝移交控制权给 Modify
+        this.draw.on('drawend', (evt) => {
+          // 移除画圆交互(不让继续画新圆了),但保留图形和标签
+          this.map.removeInteraction(this.draw)
+          this.map.getTargetElement().style.cursor = 'default'
+
+          const finishedFeature = evt.feature
+
+          // 5. 激活修改交互器(传入指定的图层源)
+          this.modify = new Modify({
+            source: this.vectorSource
+          })
+          this.map.addInteraction(this.modify)
+
+          // 6. 当用户再次用鼠标按住圆周边缘控制点拖拽调整时
+          this.modify.on('modifystart', (mEvt) => {
+            // 重新绑定几何变化监听,让半径气泡文字在拖拽修改时再度跟着动起来!
+            mEvt.features.forEach((f) => {
+              const geom = f.getGeometry()
+              const binder = f.get('changeBinder')
+              if (binder) binder(geom)
+            })
+          })
+
+          this.modify.on('modifyend', () => {
+            console.log('圆半径二次调整结束')
+          })
         })
 
-        this.map.addInteraction(this.select)
-        this.map.addInteraction(this.modify)
+        this.map.addInteraction(this.draw)
       } else {
-        this.editorBtnText = '编辑'
-        this.map.removeInteraction(this.select)
-        this.map.removeInteraction(this.modify)
-        this.select = null
-        this.modify = null
+        // 退出状态
+        this.map.getTargetElement().style.cursor = ''
+      }
+    },
+    initFeatureClick() {
+      this.map.on('singleclick', (evt) => {
+        // 核心:如果是地点标记状态,优先允许用户打点,不触发图标点击事件
+        if (this.isMarking) return
+
+        // 1. 获取点击处的屏幕像素坐标
+        const pixel = this.map.getEventPixel(evt.originalEvent)
+
+        // 2. 探测当前像素点下是否存在 Feature 要素
+        const feature = this.map.forEachFeatureAtPixel(pixel, (clickedFeature) => {
+          return clickedFeature // 如果有,直接返回这个要素
+        })
+
+        // 3. 如果抓到了要素,说明用户点中了某个图标
+        if (feature) {
+          // 4. 健壮性检查:通过我们在创建要素时注入的 customType 属性,判定它是不是我们要的标记点
+          const customType = feature.get('customType')
+          if (customType === 'api-marker') {
+            const name = feature.get('title')
+            const id = feature.get('id')
+            this.$message.info(`你点击了后台标记点:${name} (ID: ${id})`)
+          } else if (feature.getGeometry() instanceof Point && !customType) {
+            // === 处理手动通过 markPlace 打的橙色小圆点点击 ===
+            // 因为在 addMarker 里你没有加 customType,但它的几何类型是 Point
+            this.$message.info('你点击了手动标记的圆点位置')
+            // 示例:点击手动标记点时,可以把它从数据源中删掉
+            // this.vectorSource.removeFeature(feature)
+          }
+        }
+      })
+    },
+    // 加载标记点主方法
+    async loadMarker() {
+      this.initFeatureClick()
+      // 1. 先清除之前加载过的历史标记
+      if (this.markerSource) {
+        this.markerSource.clear()
       }
+
+      getMapMarks().then(resp => {
+        if (resp.code === 0) {
+          for (const item of resp.data) {
+            const numLng1 = item.position.lng
+            const numLat1 = item.position.lat
+            // 创建要素几何(使用转换后的坐标,如果是高德底图,记得在外面套一层 fromLonLat)
+            const finalCoord = fromLonLat([numLng1, numLat1]) // 如果底图是高德请解开这行,下面传入 finalCoord
+            const feature = new Feature({
+              geometry: new Point(finalCoord),
+              customType: 'api-marker',
+              ...item
+            })
+
+            var text = item.title + '\n[,' + numLng1.toFixed(4) + ' ' + numLat1.toFixed(4) + ']'
+            // 设置样式
+            feature.setStyle(
+              new Style({
+                // 1. 图标样式
+                image: new Icon({
+                  src: 'https://cdn-icons-png.flaticon.com/512/684/684908.png',
+                  crossOrigin: 'anonymous',
+                  anchor: [0.5, 1], // 锚点在底部尖角,图标整体向上伸展约 36 像素
+                  scale: 0.07
+                }),
+
+                // 2. 文字样式
+                text: new Text({
+                  text: text,
+                  font: '12px sans-serif',
+                  fill: new Fill({ color: '#3a55c2' }),
+                  stroke: new Stroke({ color: '#ffffff', width: 3 }),
+                  offsetY: -42, // 调大负数!直接把文字推到图标头顶上方(36px 图标高度 + 6px 呼吸间距)
+                  textBaseline: 'bottom', // 保持底部对齐,确保多行文本也能整齐向上排列
+                  placement: 'point'
+                })
+              })
+            )
+
+            // 添加到数据源
+            this.markerSource.addFeature(feature)
+            if (this.markerSource && this.markerSource.getFeatures().length > 0) {
+              const extent = this.markerSource.getExtent()
+
+              // 安全校验
+              if (extent && isFinite(extent[0]) && isFinite(extent[1])) {
+                this.map.getView().fit(extent, {
+                  padding: [50, 50, 50, 50],
+                  maxZoom: 14,
+                  duration: 800
+                })
+              }
+            }
+          }
+        }
+      }).catch(error => {
+        console.error('加载标记点失败:', error.message)
+      })
     },
+    // 在地图上标记经纬度
+    addMarker(coordinate) {
+      // 经度(保留6位小数)
+      const lng = coordinate[0].toFixed(6)
+      // 纬度(保留6位小数)
+      const lat = coordinate[1].toFixed(6)
+      /* const lng = Number(lngStr)
+      const lat = Number(latStr)*/
+
+      // 1. 创建一个点几何
+      const pointGeom = new Point([lng, lat])
 
-    // 输出矢量图层要素为GeoJson数据
-    outputJson() {
-      const features = this.vectorSource.getFeatures()
-      const jsonObj = new GeoJSON().writeFeatures(features)
-      console.log('->GeoJson格式数据:', jsonObj)
+      // 2. 创建一个要素(Feature),并将几何图形赋予它
+      const markerFeature = new Feature({
+        geometry: pointGeom
+      })
+
+      const lonLat = toLonLat(coordinate)
+      const lng1 = lonLat[0].toFixed(6)
+      const lat1 = lonLat[1].toFixed(6)
+      // 格式化需要显示的文字(保留6位小数)
+      const textLabel = `[${lng1}, ${lat1}]`
+
+      // 3. 设置标记的样式(这里画一个实心圆,你也可以用 Icon 贴图)
+      markerFeature.setStyle(
+        new Style({
+          text: new Text({
+            text: textLabel, // 要显示的文字内容
+            font: '12px sans-serif', // 字体大小和家族
+            fill: new Fill({ color: '#333333' }), // 文字颜色
+            stroke: new Stroke({ color: '#ffffff', width: 3 }), // 文字白边背景(防瞎,更清晰)
+            offsetY: -12, // 改为负数,将文字向上推 12 像素
+            textBaseline: 'bottom', // 文本基线设为底部,确保文字的屁股对齐偏移位置,不会压到圆点
+            placement: 'point' // 渲染类型
+          }),
+          image: new Circle({
+            radius: 4, // 圆半径
+            fill: new Fill({ color: '#ff7300' }), // 填充红色
+            stroke: new Stroke({ color: '#ffffff', width: 2 }) // 白色外边框
+          })
+        })
+      )
+
+      // 4. 将这个要素添加到你现有的矢量图层源中
+      // 注意:这里建议先清除之前的标记,或者你可以专门建一个标记图层
+      this.vectorSource.addFeature(markerFeature)
+
+      // 5. 动画平移让地图中心对准这个标记点
+      /* this.map.getView().animate({
+        center: [lng, lat],
+        duration: 800, // 动画持续时间(毫秒)
+        zoom: 14 // 顺便放大地图级别
+      })*/
     },
     creatDraw(type) {
       let maxPoints = null
@@ -226,11 +443,7 @@ export default {
         // 绘制时点击处理事件
         condition: (evt) => {
           // 测距时添加点标注
-          if (
-            this.measureResult != '0' &&
-            !this.map.getOverlayById(this.measureResult) &&
-            this.measureType === 'distence'
-          ) {
+          if (this.measureResult !== '0' && !this.map.getOverlayById(this.measureResult) && this.measureType === 'distance') {
             this.creatMark(
               null,
               this.measureResult,
@@ -249,7 +462,7 @@ export default {
         this.sketchFeature = e.feature
         const proj = this.map.getView().getProjection()
         //* *****距离测量开始时*****//
-        if (this.measureType === 'distence') {
+        if (this.measureType === 'distance') {
           this.creatMark(null, '起点', 'start').setPosition(
             this.map.getCoordinateFromPixel(e.target.downPx_)
           )
@@ -257,15 +470,14 @@ export default {
           this.geometryListener = this.sketchFeature
             .getGeometry()
             .on('change', (evt) => {
-              this.measureResult = this.distenceFormat(
+              this.measureResult = this.distanceFormat(
                 getLength(evt.target, { projection: proj, radius: 6378137 })
               )
               this.tipDiv.innerHTML =
                 '总长:' + this.measureResult + '</br>单击确定地点,双击结束'
             })
-        }
-        //* *****面积测量开始时*****//
-        else if (this.measureType === 'area') {
+        } else if (this.measureType === 'area') {
+          //* *****面积测量开始时*****//
           this.tipDiv.innerHTML = '面积:0 m<sup>2</sup></br>继续单击确定地点'
           this.geometryListener = this.sketchFeature
             .getGeometry()
@@ -281,9 +493,8 @@ export default {
                   '面积:' + this.measureResult + '</br>单击确定地点,双击结束'
               }
             })
-        }
-        //* *****角度测量开始时*****//
-        else if (this.measureType === 'angle') {
+        } else if (this.measureType === 'angle') {
+          //* *****角度测量开始时*****//
           this.tipDiv.innerHTML = '继续单击确定顶点'
           this.geometryListener = this.sketchFeature
             .getGeometry()
@@ -310,7 +521,7 @@ export default {
           this.clearMeasure()
         })
         //* *****距离测量结束时*****//
-        if (this.measureType === 'distence') {
+        if (this.measureType === 'distance') {
           this.creatMark(closeBtn, null, 'close1').setPosition(
             e.feature.getGeometry().getLastCoordinate()
           )
@@ -320,9 +531,8 @@ export default {
             'length'
           ).setPosition(e.feature.getGeometry().getLastCoordinate())
           this.map.removeOverlay(this.map.getOverlayById(this.measureResult))
-        }
-        //* *****面积测量结束时*****//
-        else if (this.measureType === 'area') {
+        } else if (this.measureType === 'area') {
+          //* *****面积测量结束时*****//
           this.creatMark(closeBtn, null, 'close2').setPosition(
             e.feature.getGeometry().getInteriorPoint().getCoordinates()
           )
@@ -333,9 +543,8 @@ export default {
           ).setPosition(
             e.feature.getGeometry().getInteriorPoint().getCoordinates()
           )
-        }
-        //* *****角度测量结束时*****//
-        else if (this.measureType === 'angle') {
+        } else if (this.measureType === 'angle') {
+          //* *****角度测量结束时*****//
           this.creatMark(closeBtn, null, 'close3').setPosition(
             e.feature.getGeometry().getCoordinates()[1]
           )
@@ -349,19 +558,48 @@ export default {
         this.stopMeasure()
       })
     },
+    markPlace() {
+      // 切换状态
+      this.isMarking = !this.isMarking
+
+      if (this.isMarking) {
+        // 1. 改变鼠标指针为十字准星
+        this.map.getTargetElement().style.cursor = 'crosshair'
+        // 2. 定义点击时的执行函数
+        const handleMapClick = (evt) => {
+          // 获取点击位置的原始墨卡托坐标(高德地图) [米, 米]
+          // evt.coordinate 是一个数组:[lng, lat]
+          const coordinate = evt.coordinate
+          this.addMarker(coordinate)
+        }
+
+        // 3. 绑定持久点击事件,并把返回的 key 存起来以便后续解绑
+        this.clickListenerKey = this.map.on('singleclick', handleMapClick)
+      } else {
+        // ================= 退出标记地点状态 =================
+        // 1. 恢复默认鼠标指针
+        this.map.getTargetElement().style.cursor = ''
+
+        // 2. 核心:解除绑定的点击事件,让地图恢复正常
+        if (this.clickListenerKey) {
+          // OpenLayers 6+ 中解绑事件的方式:使用 ol/Observable 的 unByKey 或者直接传入 key
+          // 如果你的版本支持, 可以直接使用下面的标准方法:
+          this.map.un('singleclick', this.clickListenerKey.listener)
+          this.clickListenerKey = null
+        }
+      }
+    },
     /**
      * 测量
      */
     measure(type) {
-      if (this.draw != null) return false // 防止在绘制过程再创建测量
+      if (this.draw !== null) return false // 防止在绘制过程再创建测量
       this.measureType = type
-      if (this.vectorLayer != null) this.clearMeasure()
+      if (this.vectorLayer !== null) this.clearMeasure()
       this.tipDiv = document.createElement('div')
       this.tipDiv.innerHTML = '单击确定起点'
       this.tipDiv.className = 'tipDiv'
-      this.tipDiv.style =
-        'width:auto;height:auto;padding:4px;border:1px solid #fc5531;font-size:12px;background-color:#fff;position:relative;top:60%;left:60%;font-weight:600;'
-
+      this.tipDiv.style = 'width:auto;height:auto;padding:4px;border:1px solid #fc5531;font-size:12px;background-color:#fff;position:relative;top:60%;left:60%;font-weight:600;'
       const overlay = new Overlay({
         element: this.tipDiv,
         autoPan: false,
@@ -374,7 +612,7 @@ export default {
       this.pointermoveEvent = this.map.on('pointermove', (evt) => {
         overlay.setPosition(evt.coordinate)
       })
-      if (this.measureType === 'distence' || this.measureType === 'angle') {
+      if (this.measureType === 'distance' || this.measureType === 'angle') {
         this.creatDraw('LineString')
       } else if (this.measureType === 'area') {
         this.creatDraw('Polygon')
@@ -403,7 +641,7 @@ export default {
     /**
      * 格式化距离结果输出
      */
-    distenceFormat(length) {
+    distanceFormat(length) {
       let output
       if (length > 100) {
         output = Math.round((length / 1000) * 100) / 100 + ' ' + 'km' // 换算成km单位
@@ -490,54 +728,6 @@ export default {
       this.geometryListener = null
       this.measureResult = '0'
     },
-    // 全屏
-    fullscreen() {
-      if (this.textarr === '全屏') {
-        this.textarr = '缩小'
-        const rfs =
-          this.$refs.map.requestFullScreen ||
-          this.$refs.map.webkitRequestFullScreen ||
-          this.$refs.map.mozRequestFullScreen ||
-          this.$refs.map.msRequestFullScreen
-        if (typeof rfs !== 'undefined' && rfs) {
-          rfs.call(this.$refs.map)
-          console.log('1全屏')
-        } else if (typeof window.ActiveXObject !== 'undefined') {
-          // for IE,这里其实就是模拟了按下键盘的F11,使浏览器全屏
-          // eslint-disable-next-line no-undef
-          const wscript = new ActiveXObject('WScript.Shell')
-          console.log(wscript)
-          if (wscript != null) {
-            wscript.SendKeys('{F11}')
-            console.log('3全屏')
-          }
-          console.log('2全屏')
-        }
-      } else {
-        // el.webkitExitFullscreen()
-        this.textarr = '全屏'
-        const cfs =
-          document.exitFullscreen ||
-          document.msExitFullscreen ||
-          document.mozCancelFullScreen ||
-          document.webkitCancelFullScreen
-        console.log(cfs, 'cfs')
-        if (typeof cfs !== 'undefined' && cfs) {
-          cfs.call(document)
-          console.log('4全屏')
-        } else if (typeof window.ActiveXObject !== 'undefined') {
-          // for IE,这里和fullScreen相同,模拟按下F11键退出全屏
-          // eslint-disable-next-line no-undef
-          const wscript = new ActiveXObject('WScript.Shell')
-          console.log('5全屏')
-          if (wscript != null) {
-            wscript.SendKeys('{F11}')
-            console.log('6全屏')
-          }
-        }
-      }
-      // this.map.addControl(new FullScreen())
-    },
     // 坐标
     single() {
       this.map.on('singleclick', function(e) {
@@ -546,8 +736,11 @@ export default {
     },
     // 清除
     clear() {
-      // getSource 获取包装源的参考
-      this.vectorLayer.getSource().clear()
+      // 清空测量矢量图层
+      if (this.vectorLayer) this.vectorLayer.getSource().clear()
+      // 清空后台加载的标记图层
+      if (this.markerSource) this.markerSource.clear()
+      // 清空所有弹窗 Overlay
       this.map.getOverlays().clear()
     }
   }
@@ -556,22 +749,73 @@ export default {
 
 <style scoped>
 #map {
+  position: absolute;
+  top: 0;
+  left: 0;
   width: 100%;
-  height: 600px;
-  position: relative;
+  height: 100%;
+  overflow: hidden;
 }
-.btns {
+
+/* 右上角工具箱容器 */
+.toolbox {
   position: absolute;
   z-index: 999;
-  left: 50%;
-  top: 20px;
-  transform: translate(-50%);
+  top: 20px;          /* 距离顶部 20px */
+  right: 20px;        /* 距离右侧 20px */
+  background: rgba(255, 255, 255, 0.9); /* 半透明白底背景 */
+  padding: 8px;
+  border-radius: 6px; /* 圆角 */
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.15); /* 地图控件常用的阴影 */
+
+  display: grid;
+  grid-template-columns: repeat(2, 75px); /* 固定为两列,每列宽 75px */
+  gap: 6px;           /* 按钮之间的间距 */
+}
+
+/* 工具箱内的按钮通用样式 */
+.toolbox button {
+  height: 28px;
+  border: 1px solid #dcdfe6;
+  background-color: #fff;
+  color: #606266;
+  font-size: 12px;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s;
+  padding: 0 4px;
 }
-.btns button {
-  display: inline-block;
-  text-decoration: none;
-  text-align: center;
-  height: 30px;
-  width: 90px;
+
+/* 鼠标悬浮和点击效果 */
+.toolbox button:hover {
+  color: #fc5531;
+  border-color: #fc5531;
+  background-color: rgba(252, 86, 49, 0.05);
+}
+
+/* 实时跟随鼠标的半径标签气泡样式 */
+.ol-radius-marker {
+  background: rgba(64, 158, 255, 0.95); /* 漂亮的科技蓝 */
+  color: #ffffff;
+  padding: 4px 8px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: bold;
+  white-space: nowrap;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+  border: 1px solid #ffffff;
+  position: relative;
+  pointer-events: none; /* 极其重要:确保 DOM 穿透,否则会挡住鼠标导致无法继续绘制 */
+}
+
+/* 气泡下方的微型小尖角 */
+.ol-radius-marker::after {
+  content: "";
+  position: absolute;
+  bottom: -4px;
+  left: 10px;
+  border-width: 4px 4px 0;
+  border-style: solid;
+  border-color: rgba(64, 158, 255, 0.95) transparent;
 }
 </style>