付晓文。 3 месяцев назад
Родитель
Сommit
2a433f9108

BIN
src/assets/images/map/real.png


BIN
src/assets/images/map/user.png


BIN
src/assets/images/map/virtual.png


+ 33 - 0
src/router/index.js

@@ -88,6 +88,39 @@ export const constantRoutes = [
       }
     ]
   },
+  // 地图demo
+   {
+    path: '/mapDemo',
+    component: Layout,
+    hidden: true,
+    redirect: 'noredirect',
+    children: [
+      {
+        path: '/mapDemo/index',
+        component: () => import('@/views/mapDemo/index'),
+        name: 'mapDemo',
+        meta: { title: '订单地图demo' }
+      },
+      {
+        path: '/mapDemo/scopeMap',
+        component: () => import('@/views/mapDemo/scopeMap'),
+        name: 'Technician',
+        meta: { title: '范围地图demo' }
+      },
+      {
+        path: '/mapDemo/addScopeMap',
+        component: () => import('@/views/mapDemo/addScopeMap'),
+        name: 'Technician',
+        meta: { title: '添加范围地图demo' }
+      },
+      {
+        path: '/mapDemo/dispatchOrder',
+        component: () => import('@/views/mapDemo/dispatchOrder'),
+        name: 'Technician',
+        meta: { title: '派单demo' }
+      },
+    ]
+  },
   // 轮播管理
   {
     path: '/carousel',

+ 20 - 0
src/utils/geo.js

@@ -0,0 +1,20 @@
+/**
+ * 计算两点球面距离(米)
+ */
+export function calcDistance(lng1, lat1, lng2, lat2) {
+  const R = 6371000
+  const toRad = (deg) => (deg * Math.PI) / 180
+  const dLat = toRad(lat2 - lat1)
+  const dLng = toRad(lng2 - lng1)
+  const a =
+    Math.sin(dLat / 2) ** 2 +
+    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2
+  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+}
+
+/** 格式化距离展示 */
+export function formatDistance(meters) {
+  if (meters == null || Number.isNaN(meters)) return '-'
+  if (meters < 1000) return `${Math.round(meters)}m`
+  return `${(meters / 1000).toFixed(1)}km`
+}

+ 461 - 0
src/views/mapDemo/addScopeMap.vue

@@ -0,0 +1,461 @@
+<template>
+  <div class="add-scope-map-page">
+    <!-- 地址搜索 -->
+    <div class="address-search">
+      <span class="address-search__label">地址</span>
+      <el-autocomplete
+        v-model="searchKeyword"
+        class="address-search__input"
+        :fetch-suggestions="queryAddressSuggestions"
+        placeholder="请输入地址,支持模糊搜索"
+        clearable
+        value-key="value"
+        :debounce="300"
+        :trigger-on-focus="false"
+        @select="handleAddressSelect"
+        @keyup.enter.native="handleSearch"
+      >
+        <template slot-scope="{ item }">
+          <div class="address-suggestion">
+            <span class="address-suggestion__name">{{ item.name }}</span>
+            <span v-if="item.address" class="address-suggestion__addr">{{ item.address }}</span>
+          </div>
+        </template>
+      </el-autocomplete>
+      <el-button type="primary" @click="handleSearch">搜索</el-button>
+    </div>
+
+    <!-- 地图 -->
+    <div :id="mapId" class="map-container" />
+
+    <!-- 围栏表单 -->
+    <el-form ref="form" :model="form" :rules="rules" label-width="100px" class="scope-form">
+      <el-form-item label="围栏名称" prop="name">
+        <el-input v-model="form.name" placeholder="请输入围栏名称" />
+      </el-form-item>
+      <el-form-item label="围栏介绍" prop="intro">
+        <el-input v-model="form.intro" placeholder="请输入围栏介绍" />
+      </el-form-item>
+      <el-form-item label="围栏半径" prop="radius">
+        <el-input v-model="form.radius" placeholder="请输入围栏半径" @input="handleRadiusChange">
+          <template slot="append">KM</template>
+        </el-input>
+      </el-form-item>
+      <el-form-item label="风险等级" prop="riskLevel">
+        <el-select v-model="form.riskLevel" placeholder="请选择" style="width: 100%">
+          <el-option label="高风险" value="3" />
+          <el-option label="中风险" value="2" />
+          <el-option label="低风险" value="1" />
+        </el-select>
+      </el-form-item>
+    </el-form>
+
+    <!-- 底部按钮 -->
+    <div class="footer-btns">
+      <el-button @click="handleCancel">取消</el-button>
+      <el-button @click="handleSave">保存</el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import AMapLoader from '@amap/amap-jsapi-loader'
+
+const AMAP_KEY = '41bc0f647d4432045d472f554bf03d75'
+const AMAP_SECURITY = '80ba66cc0bba5b07de6a275d7aa02a39'
+const DEFAULT_CENTER = [116.397428, 39.90923]
+const DEFAULT_RADIUS_KM = 1
+
+export default {
+  name: 'AddScopeMap',
+  data() {
+    const validateRadius = (rule, value, callback) => {
+      if (value === '' || value === null || value === undefined) {
+        callback(new Error('请输入围栏半径'))
+        return
+      }
+      const num = Number(value)
+      if (Number.isNaN(num) || num <= 0) {
+        callback(new Error('围栏半径必须为大于 0 的数字'))
+        return
+      }
+      callback()
+    }
+
+    return {
+      mapId: 'add-scope-map',
+      searchKeyword: '',
+      form: {
+        name: '',
+        intro: '',
+        radius: String(DEFAULT_RADIUS_KM),
+        riskLevel: ''
+      },
+      rules: {
+        name: [{ required: true, message: '请输入围栏名称', trigger: 'blur' }],
+        intro: [{ required: true, message: '请输入围栏介绍', trigger: 'blur' }],
+        radius: [{ required: true, validator: validateRadius, trigger: 'blur' }],
+        riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }]
+      },
+      locationInfo: {
+        lng: null,
+        lat: null,
+        province: '',
+        city: '',
+        district: '',
+        address: ''
+      },
+      AMap: null,
+      map: null,
+      placeSearch: null,
+      geocoder: null,
+      marker: null,
+      circle: null
+    }
+  },
+  mounted() {
+    this.initMap()
+  },
+  beforeDestroy() {
+    this.destroyMap()
+  },
+  methods: {
+    initMap() {
+      window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY }
+      AMapLoader.load({
+        key: AMAP_KEY,
+        version: '2.0',
+        plugins: ['AMap.PlaceSearch', 'AMap.Geocoder']
+      }).then((AMap) => {
+        this.AMap = AMap
+        this.map = new AMap.Map(this.mapId, {
+          resizeEnable: true,
+          zoom: 14,
+          center: DEFAULT_CENTER,
+          viewMode: '2D'
+        })
+        this.placeSearch = new AMap.PlaceSearch({
+          pageSize: 10,
+          pageIndex: 1
+        })
+        this.geocoder = new AMap.Geocoder({
+          radius: 1000,
+          extensions: 'all'
+        })
+        this.setMapPoint(DEFAULT_CENTER[0], DEFAULT_CENTER[1], '默认位置')
+      }).catch((err) => {
+        console.error('地图加载失败', err)
+        this.$message.error('地图加载失败,请检查网络或 Key 配置')
+      })
+    },
+
+    getRadiusMeters() {
+      const km = Number(this.form.radius)
+      if (Number.isNaN(km) || km <= 0) return DEFAULT_RADIUS_KM * 1000
+      return km * 1000
+    },
+
+    createMarkerContent(label) {
+      const wrap = document.createElement('div')
+      wrap.className = 'add-scope-marker'
+      wrap.innerHTML = `
+        <div class="add-scope-marker__label">${label}</div>
+        <img
+          class="add-scope-marker__pin"
+          src="https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png"
+          alt=""
+        />
+      `
+      return wrap
+    },
+
+    renderOverlays(center, label) {
+      if (!this.map || !this.AMap) return
+
+      if (this.marker) {
+        this.map.remove(this.marker)
+        this.marker = null
+      }
+      if (this.circle) {
+        this.map.remove(this.circle)
+        this.circle = null
+      }
+
+      this.marker = new this.AMap.Marker({
+        position: center,
+        content: this.createMarkerContent(label),
+        anchor: 'bottom-center',
+        zIndex: 200
+      })
+
+      this.circle = new this.AMap.Circle({
+        center,
+        radius: this.getRadiusMeters(),
+        strokeColor: '#409EFF',
+        strokeWeight: 2,
+        strokeOpacity: 0.9,
+        fillColor: '#409EFF',
+        fillOpacity: 0.15,
+        zIndex: 10
+      })
+
+      this.map.add([this.marker, this.circle])
+      this.map.setFitView([this.circle], false, [80, 80, 80, 80])
+    },
+
+    updateLocationAddress(lng, lat) {
+      return new Promise((resolve, reject) => {
+        if (!this.geocoder) {
+          reject(new Error('geocoder 未初始化'))
+          return
+        }
+        this.geocoder.getAddress([lng, lat], (status, result) => {
+          if (status === 'complete' && result.info === 'OK') {
+            const comp = result.regeocode.addressComponent
+            this.locationInfo = {
+              lng,
+              lat,
+              province: comp.province || '',
+              city: comp.city || comp.province || '',
+              district: comp.district || '',
+              address: result.regeocode.formattedAddress || ''
+            }
+            resolve(this.locationInfo)
+            return
+          }
+          reject(new Error('逆地理编码失败'))
+        })
+      })
+    },
+
+    setMapPoint(lng, lat, label) {
+      this.renderOverlays([lng, lat], label)
+      return this.updateLocationAddress(lng, lat)
+    },
+
+    queryAddressSuggestions(queryString, cb) {
+      const keyword = (queryString || '').trim()
+      if (!keyword) {
+        cb([])
+        return
+      }
+      if (!this.placeSearch) {
+        cb([])
+        return
+      }
+
+      this.placeSearch.search(keyword, (status, result) => {
+        if (status !== 'complete' || !result.poiList || !result.poiList.pois.length) {
+          cb([])
+          return
+        }
+        cb(result.poiList.pois.map((poi) => ({
+          value: poi.name,
+          name: poi.name,
+          address: poi.address || poi.district || '',
+          lng: poi.location.lng,
+          lat: poi.location.lat
+        })))
+      })
+    },
+
+    handleAddressSelect(item) {
+      this.searchKeyword = item.name
+      this.locateByPoi(item)
+    },
+
+    locateByPoi(poi) {
+      this.setMapPoint(poi.lng, poi.lat, poi.name)
+        .then(() => {
+          this.$message.success(`已定位:${poi.name}`)
+        })
+        .catch(() => {
+          this.$message.warning('地址定位成功,但省市区解析失败')
+        })
+    },
+
+    handleSearch() {
+      const keyword = this.searchKeyword.trim()
+      if (!keyword) {
+        this.$message.warning('请输入地址')
+        return
+      }
+      if (!this.placeSearch) return
+
+      this.placeSearch.search(keyword, (status, result) => {
+        if (status !== 'complete' || !result.poiList || !result.poiList.pois.length) {
+          this.$message.warning('未找到相关地址')
+          return
+        }
+        const poi = result.poiList.pois[0]
+        this.searchKeyword = poi.name
+        this.locateByPoi({
+          name: poi.name,
+          lng: poi.location.lng,
+          lat: poi.location.lat
+        })
+      })
+    },
+
+    handleRadiusChange() {
+      if (!this.locationInfo.lng || !this.locationInfo.lat) return
+      const label = this.searchKeyword.trim() || '当前位置'
+      this.renderOverlays([this.locationInfo.lng, this.locationInfo.lat], label)
+    },
+
+    handleCancel() {
+      this.searchKeyword = ''
+      this.form = {
+        name: '',
+        intro: '',
+        radius: String(DEFAULT_RADIUS_KM),
+        riskLevel: ''
+      }
+      this.locationInfo = {
+        lng: null,
+        lat: null,
+        province: '',
+        city: '',
+        district: '',
+        address: ''
+      }
+      this.$refs.form && this.$refs.form.resetFields()
+      this.setMapPoint(DEFAULT_CENTER[0], DEFAULT_CENTER[1], '默认位置')
+    },
+
+    handleSave() {
+      this.$refs.form.validate((valid) => {
+        if (!valid) return
+
+        if (!this.locationInfo.province) {
+          this.$message.warning('请先搜索地图地址')
+          return
+        }
+
+        const saveData = {
+          ...this.form,
+          radiusKm: Number(this.form.radius),
+          lng: this.locationInfo.lng,
+          lat: this.locationInfo.lat,
+          address: this.locationInfo.address,
+          province: this.locationInfo.province,
+          city: this.locationInfo.city,
+          district: this.locationInfo.district
+        }
+
+        console.log('保存围栏数据:', saveData)
+        console.log('搜索地图省市区:', {
+          province: this.locationInfo.province,
+          city: this.locationInfo.city,
+          district: this.locationInfo.district
+        })
+
+        this.$message.success('保存成功,省市区已打印到控制台')
+      })
+    },
+
+    destroyMap() {
+      if (this.marker && this.map) {
+        this.map.remove(this.marker)
+      }
+      if (this.circle && this.map) {
+        this.map.remove(this.circle)
+      }
+      this.marker = null
+      this.circle = null
+      if (this.map) {
+        this.map.destroy()
+        this.map = null
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.add-scope-map-page {
+  padding: 16px 20px 24px;
+  background: #fff;
+}
+
+.address-search {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  &__label {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: #303133;
+  }
+
+  .el-input {
+    width: 320px;
+  }
+
+  &__input {
+    width: 320px;
+  }
+}
+
+.address-suggestion {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.4;
+  padding: 2px 0;
+
+  &__name {
+    font-size: 14px;
+    color: #303133;
+  }
+
+  &__addr {
+    margin-top: 2px;
+    font-size: 12px;
+    color: #909399;
+  }
+}
+
+.map-container {
+  width: 100%;
+  height: 420px;
+  margin-bottom: 20px;
+  background: #eef1f5;
+}
+
+.scope-form {
+  max-width: 520px;
+}
+
+.footer-btns {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-top: 8px;
+}
+</style>
+
+<style lang="scss">
+.add-scope-marker {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  transform: translateY(-4px);
+
+  &__label {
+    margin-bottom: 4px;
+    color: #e74c3c;
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 1.4;
+    white-space: nowrap;
+  }
+
+  &__pin {
+    display: block;
+    width: 19px;
+    height: 31px;
+  }
+}
+</style>

+ 732 - 0
src/views/mapDemo/components/DispatchOrderDialog.vue

@@ -0,0 +1,732 @@
+<template>
+  <el-dialog
+    :visible.sync="dialogVisible"
+    width="1100px"
+    custom-class="dispatch-order-dialog"
+    :close-on-click-modal="false"
+    :show-close="false"
+    append-to-body
+    destroy-on-close
+    @opened="onDialogOpened"
+    @closed="onDialogClosed"
+  >
+    <div class="dispatch-order-panel">
+      <div class="panel-header">
+        <span class="panel-title">订单分配</span>
+        <span v-if="currentOrder" class="panel-subtitle">
+          订单号:{{ currentOrder.orderNo }}
+          <template v-if="currentOrder.cAddress"> · {{ currentOrder.cAddress }}</template>
+        </span>
+        <i class="el-icon-close panel-close" @click="handleClose" />
+      </div>
+
+      <div class="filter-bar">
+        <el-select v-model="filterForm.region" placeholder="地区" clearable class="filter-item">
+          <el-option
+            v-for="item in regionOptions"
+            :key="item.value"
+            :label="item.label"
+            :value="item.value"
+          />
+        </el-select>
+        <el-select v-model="filterForm.category" placeholder="服务类目" clearable class="filter-item">
+          <el-option label="按摩" value="按摩" />
+          <el-option label="陪玩" value="陪玩" />
+        </el-select>
+        <el-input
+          v-model="filterForm.keyword"
+          placeholder="搜索技师名称或姓名"
+          prefix-icon="el-icon-search"
+          clearable
+          class="filter-search"
+          @keyup.enter.native="handleSearch"
+        />
+        <div class="filter-actions">
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
+          <el-button @click="handleClear">清空</el-button>
+        </div>
+      </div>
+
+      <div class="panel-body">
+        <div class="technician-list">
+          <div
+            v-for="item in sortedTechnicians"
+            :key="item.id"
+            class="technician-card"
+            :class="{ 'is-active': selectedId === item.id }"
+            @click="selectTechnician(item)"
+          >
+            <span class="technician-radio" :class="{ 'is-checked': selectedId === item.id }">
+              <span class="technician-radio__inner" />
+            </span>
+            <img :src="item.avatar" class="technician-avatar" alt="" />
+            <div class="technician-info">
+              <div class="technician-name-row">
+                <span class="technician-name">{{ item.nickname }}</span>
+                <span class="status-tag" :class="`status-tag--${item.status}`">{{ statusLabel(item.status) }}</span>
+                <span v-if="item.distance != null" class="technician-distance">距用户 {{ formatDistance(item.distance) }}</span>
+              </div>
+              <div class="technician-address">{{ item.address }}</div>
+            </div>
+          </div>
+          <div v-if="!sortedTechnicians.length" class="empty-tip">暂无符合条件的技师</div>
+        </div>
+
+        <div class="map-wrap">
+          <div :id="mapId" class="map-container" />
+        </div>
+      </div>
+
+      <div class="panel-footer">
+        <el-button @click="handleClose">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确认派单</el-button>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import AMapLoader from '@amap/amap-jsapi-loader'
+import { calcDistance, formatDistance } from '@/utils/geo'
+import userDestIcon from '@/assets/images/map/user.png'
+
+const AMAP_KEY = '41bc0f647d4432045d472f554bf03d75'
+const AMAP_SECURITY = '80ba66cc0bba5b07de6a275d7aa02a39'
+
+const STATUS_MAP = {
+  online: '在线',
+  onway: '在路途中',
+  rest: '休息中'
+}
+
+const DEFAULT_FILTER = {
+  region: 'city',
+  category: '',
+  keyword: ''
+}
+
+let mapInstanceSeed = 0
+
+/** 示例技师数据 */
+const MOCK_TECHNICIANS = [
+  { id: 1, nickname: 'NaNny', name: '娜娜', category: '按摩', region: 'city', status: 'rest', lng: 116.397428, lat: 39.90923, address: '北京市东城区王府井大街88号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 2, nickname: '小艾', name: '艾琳', category: '按摩', region: 'city', status: 'onway', lng: 116.451328, lat: 39.908765, address: '北京市朝阳区建国门外大街1号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 3, nickname: '阿杰', name: '杰哥', category: '陪玩', region: 'city', status: 'online', lng: 116.280892, lat: 39.921234, address: '北京市海淀区中关村大街27号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 4, nickname: '小雨', name: '雨晴', category: '陪玩', region: 'city', status: 'online', lng: 116.385621, lat: 39.954812, address: '北京市西城区西单北大街120号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 5, nickname: '大明', name: '明明', category: '按摩', region: 'city', status: 'rest', lng: 116.420981, lat: 39.893456, address: '北京市丰台区南三环西路16号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 6, nickname: '小美', name: '美美', category: '按摩', region: 'city', status: 'online', lng: 116.362481, lat: 39.915612, address: '北京市东城区东直门内大街8号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 7, nickname: '阿强', name: '强子', category: '陪玩', region: 'city', status: 'onway', lng: 116.432156, lat: 39.928341, address: '北京市朝阳区三里屯路19号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 8, nickname: '莉莉', name: '丽丽', category: '按摩', region: 'city', status: 'rest', lng: 116.318765, lat: 39.876543, address: '北京市丰台区方庄路15号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 9, nickname: '小军', name: '军哥', category: '陪玩', region: 'city', status: 'online', lng: 116.468912, lat: 39.941234, address: '北京市朝阳区望京街10号附近', avatar: require('@/assets/images/map/user.png') },
+  { id: 10, nickname: '小芳', name: '芳芳', category: '按摩', region: 'city', status: 'onway', lng: 116.351234, lat: 39.902345, address: '北京市西城区金融大街35号附近', avatar: require('@/assets/images/map/user.png') }
+]
+
+export default {
+  name: 'DispatchOrderDialog',
+  data() {
+    return {
+      dialogVisible: false,
+      mapId: `dispatch-order-map-${++mapInstanceSeed}`,
+      currentOrder: null,
+      orderDest: null,
+      filterForm: { ...DEFAULT_FILTER },
+      searchParams: { ...DEFAULT_FILTER },
+      regionOptions: [{ label: '城市运营中心', value: 'city' }],
+      technicians: MOCK_TECHNICIANS,
+      selectedId: null,
+      orderStatus: 'pending',
+      AMap: null,
+      map: null,
+      markerInstances: [],
+      destMarker: null
+    }
+  },
+  computed: {
+    sortedTechnicians() {
+      const { region, category, keyword } = this.searchParams
+      const kw = keyword.trim().toLowerCase()
+      let list = this.technicians.filter((item) => {
+        if (region && item.region !== region) return false
+        if (category && item.category !== category) return false
+        if (kw && !item.nickname.toLowerCase().includes(kw) && !item.name.toLowerCase().includes(kw)) {
+          return false
+        }
+        return true
+      })
+
+      if (this.orderDest) {
+        const { lng, lat } = this.orderDest
+        list = list
+          .map((item) => ({
+            ...item,
+            distance: calcDistance(lng, lat, item.lng, item.lat)
+          }))
+          .sort((a, b) => a.distance - b.distance)
+      }
+
+      return list
+    },
+    mapMarkers() {
+      return this.sortedTechnicians.map((item) => ({
+        ...item,
+        active: item.id === this.selectedId
+      }))
+    }
+  },
+  watch: {
+    mapMarkers: {
+      deep: true,
+      handler() {
+        this.renderMarkers()
+      }
+    }
+  },
+  beforeDestroy() {
+    this.destroyMap()
+  },
+  methods: {
+    formatDistance,
+
+    /**
+     * 打开派单弹窗
+     * @param {Object} order - 订单信息,需含 arrivalLongitude / arrivalLatitude(用户目的地经纬度)
+     */
+    open(order) {
+      if (!order) return
+      const lng = Number(order.arrivalLongitude)
+      const lat = Number(order.arrivalLatitude)
+      if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
+        this.$message.warning('该订单缺少用户目的地经纬度,无法分配')
+        return
+      }
+      this.currentOrder = order
+      this.orderDest = { lng, lat }
+      this.orderStatus = order.dispatchStatus || 'pending'
+      this.filterForm = {
+        region: order.region || DEFAULT_FILTER.region,
+        category: order.category || '',
+        keyword: ''
+      }
+      this.searchParams = { ...this.filterForm }
+      this.selectedId = null
+      this.dialogVisible = true
+    },
+
+    onDialogOpened() {
+      this.$nextTick(() => {
+        this.initMap()
+      })
+    },
+
+    onDialogClosed() {
+      this.destroyMap()
+      this.currentOrder = null
+      this.orderDest = null
+      this.selectedId = null
+      this.filterForm = { ...DEFAULT_FILTER }
+      this.searchParams = { ...DEFAULT_FILTER }
+    },
+
+    handleSearch() {
+      this.searchParams = { ...this.filterForm }
+      if (this.selectedId && !this.sortedTechnicians.some((item) => item.id === this.selectedId)) {
+        this.selectedId = null
+      }
+      this.$nextTick(() => {
+        this.fitMarkerView()
+      })
+    },
+
+    handleClear() {
+      this.filterForm = { region: '', category: '', keyword: '' }
+      this.searchParams = { region: '', category: '', keyword: '' }
+      this.selectedId = null
+      this.$nextTick(() => {
+        this.fitMarkerView()
+      })
+    },
+
+    statusLabel(status) {
+      return STATUS_MAP[status] || status
+    },
+
+    initMap() {
+      if (this.map) return
+
+      window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY }
+      const center = this.orderDest
+        ? [this.orderDest.lng, this.orderDest.lat]
+        : [116.397428, 39.90923]
+
+      AMapLoader.load({
+        key: AMAP_KEY,
+        version: '2.0'
+      }).then((AMap) => {
+        this.AMap = AMap
+        this.map = new AMap.Map(this.mapId, {
+          resizeEnable: true,
+          zoom: 13,
+          center,
+          viewMode: '2D'
+        })
+        this.renderDestinationMarker()
+        this.renderMarkers()
+        this.fitMarkerView()
+        this.$nextTick(() => {
+          if (this.map) this.map.resize()
+        })
+      }).catch((err) => {
+        console.error('地图加载失败', err)
+        this.$message.error('地图加载失败,请检查网络或 Key 配置')
+      })
+    },
+
+    createDestinationMarkerContent() {
+      const wrap = document.createElement('div')
+      wrap.className = 'dispatch-map-dest'
+      wrap.innerHTML = `
+        <div class="dispatch-map-dest__label">用户目的地</div>
+        <img class="dispatch-map-dest__icon" src="${userDestIcon}" alt="用户目的地" />
+      `
+      return wrap
+    },
+
+    renderDestinationMarker() {
+      if (!this.map || !this.AMap || !this.orderDest) return
+
+      if (this.destMarker) {
+        this.map.remove(this.destMarker)
+        this.destMarker = null
+      }
+
+      this.destMarker = new this.AMap.Marker({
+        position: [this.orderDest.lng, this.orderDest.lat],
+        content: this.createDestinationMarkerContent(),
+        offset: new this.AMap.Pixel(0, 0),
+        anchor: 'bottom-center',
+        zIndex: 300
+      })
+      this.map.add(this.destMarker)
+    },
+
+    createMarkerContent(item) {
+      const wrap = document.createElement('div')
+      wrap.className = item.active
+        ? 'dispatch-map-marker dispatch-map-marker--active'
+        : 'dispatch-map-marker'
+      wrap.innerHTML = `
+        <div class="dispatch-map-marker__label">${item.nickname}</div>
+        <div class="dispatch-map-marker__avatar-wrap">
+          <img class="dispatch-map-marker__avatar" src="${item.avatar}" alt="${item.nickname}" />
+        </div>
+        <div class="dispatch-map-marker__pointer"></div>
+      `
+      return wrap
+    },
+
+    renderMarkers() {
+      if (!this.map || !this.AMap) return
+
+      this.clearMarkers()
+      this.mapMarkers.forEach((item) => {
+        const marker = new this.AMap.Marker({
+          position: [item.lng, item.lat],
+          content: this.createMarkerContent(item),
+          offset: new this.AMap.Pixel(0, 0),
+          anchor: 'bottom-center',
+          zIndex: item.active ? 200 : 100
+        })
+        marker.on('click', () => {
+          this.selectTechnician(item)
+        })
+        this.map.add(marker)
+        this.markerInstances.push(marker)
+      })
+    },
+
+    clearMarkers() {
+      if (this.markerInstances.length && this.map) {
+        this.map.remove(this.markerInstances)
+      }
+      this.markerInstances = []
+    },
+
+    fitMarkerView() {
+      if (!this.map) return
+      const allMarkers = [...this.markerInstances]
+      if (this.destMarker) allMarkers.push(this.destMarker)
+      if (!allMarkers.length) return
+      this.map.setFitView(allMarkers, false, [60, 60, 60, 60])
+    },
+
+    selectTechnician(item) {
+      if (this.selectedId === item.id) {
+        this.selectedId = null
+        return
+      }
+      this.selectedId = item.id
+      if (this.map) {
+        this.map.setZoomAndCenter(15, [item.lng, item.lat])
+      }
+    },
+
+    handleClose() {
+      this.dialogVisible = false
+    },
+
+    handleConfirm() {
+      if (!this.selectedId) {
+        this.$message.warning('请先选择技师')
+        return
+      }
+      if (this.orderStatus !== 'pending') {
+        this.$message.warning('订单状态已变更,无法派单')
+        return
+      }
+      const tech = this.technicians.find((t) => t.id === this.selectedId)
+      this.$message.success(`订单 ${this.currentOrder.orderNo} 已成功派单给 ${tech.nickname}`)
+      this.$emit('success', {
+        order: this.currentOrder,
+        technician: tech
+      })
+      this.dialogVisible = false
+    },
+
+    destroyMap() {
+      this.clearMarkers()
+      if (this.destMarker && this.map) {
+        this.map.remove(this.destMarker)
+        this.destMarker = null
+      }
+      if (this.map) {
+        this.map.destroy()
+        this.map = null
+      }
+      this.AMap = null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dispatch-order-panel {
+  display: flex;
+  flex-direction: column;
+  height: 560px;
+  overflow: hidden;
+}
+
+.panel-header {
+  display: flex;
+  align-items: center;
+  padding: 16px 20px;
+  border-bottom: 1px solid #ebeef5;
+  gap: 12px;
+
+  .panel-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #303133;
+    flex-shrink: 0;
+  }
+
+  .panel-subtitle {
+    flex: 1;
+    font-size: 12px;
+    color: #909399;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .panel-close {
+    font-size: 18px;
+    color: #909399;
+    cursor: pointer;
+    flex-shrink: 0;
+
+    &:hover {
+      color: #606266;
+    }
+  }
+}
+
+.filter-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 16px 20px;
+  border-bottom: 1px solid #ebeef5;
+
+  .filter-item {
+    width: 160px;
+    flex-shrink: 0;
+  }
+
+  .filter-search {
+    flex: 1;
+    min-width: 120px;
+  }
+
+  .filter-actions {
+    display: flex;
+    flex-shrink: 0;
+    gap: 8px;
+  }
+}
+
+.panel-body {
+  display: flex;
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.technician-list {
+  width: 360px;
+  flex-shrink: 0;
+  align-self: stretch;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+  border-right: 1px solid #ebeef5;
+  background: #fafafa;
+}
+
+.technician-card {
+  display: flex;
+  align-items: flex-start;
+  padding: 14px 16px;
+  cursor: pointer;
+  border-bottom: 1px solid #f0f0f0;
+  transition: background 0.2s;
+
+  &:hover,
+  &.is-active {
+    background: #ecf5ff;
+  }
+
+  .technician-radio {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 14px;
+    height: 14px;
+    margin-right: 8px;
+    margin-top: 14px;
+    flex-shrink: 0;
+    border: 1px solid #dcdfe6;
+    border-radius: 50%;
+    background: #fff;
+
+    &__inner {
+      width: 4px;
+      height: 4px;
+      border-radius: 50%;
+      background: #fff;
+      transform: scale(0);
+      transition: transform 0.15s ease-in;
+    }
+
+    &.is-checked {
+      border-color: #409eff;
+      background: #409eff;
+
+      .technician-radio__inner {
+        transform: scale(1);
+      }
+    }
+  }
+
+  .technician-avatar {
+    width: 44px;
+    height: 44px;
+    border-radius: 50%;
+    object-fit: cover;
+    flex-shrink: 0;
+    margin-right: 10px;
+    background: #eee;
+  }
+
+  .technician-info {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .technician-name-row {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-bottom: 6px;
+  }
+
+  .technician-name {
+    font-size: 14px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .technician-distance {
+    margin-left: auto;
+    font-size: 12px;
+    color: #409eff;
+    white-space: nowrap;
+  }
+
+  .status-tag {
+    display: inline-block;
+    padding: 0 6px;
+    font-size: 12px;
+    line-height: 20px;
+    border-radius: 2px;
+
+    &--online {
+      color: #67c23a;
+      background: #f0f9eb;
+    }
+
+    &--onway {
+      color: #e6a23c;
+      background: #fdf6ec;
+    }
+
+    &--rest {
+      color: #909399;
+      background: #f4f4f5;
+    }
+  }
+
+  .technician-address {
+    font-size: 12px;
+    color: #909399;
+    line-height: 1.5;
+    word-break: break-all;
+  }
+}
+
+.empty-tip {
+  padding: 40px 16px;
+  text-align: center;
+  color: #909399;
+  font-size: 14px;
+}
+
+.map-wrap {
+  flex: 1;
+  min-width: 0;
+  min-height: 0;
+  background: #eef1f5;
+}
+
+.map-container {
+  width: 100%;
+  height: 100%;
+}
+
+.panel-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding: 14px 20px;
+  border-top: 1px solid #ebeef5;
+}
+</style>
+
+<style lang="scss">
+.dispatch-order-dialog {
+  .el-dialog__header {
+    display: none;
+  }
+
+  .el-dialog__body {
+    padding: 0;
+  }
+}
+
+.dispatch-map-dest {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  &__label {
+    margin-bottom: 4px;
+    padding: 2px 8px;
+    background: #f56c6c;
+    color: #fff;
+    font-size: 12px;
+    border-radius: 3px;
+    white-space: nowrap;
+  }
+
+  &__icon {
+    display: block;
+    height: 40px;
+    width: auto;
+    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+  }
+}
+
+.dispatch-map-marker {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+
+  &__label {
+    margin-bottom: 4px;
+    padding: 2px 8px;
+    background: rgba(0, 0, 0, 0.65);
+    color: #fff;
+    font-size: 12px;
+    border-radius: 3px;
+    white-space: nowrap;
+  }
+
+  &__avatar-wrap {
+    width: 40px;
+    height: 40px;
+    border-radius: 50%;
+    border: 2px solid #fff;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+    overflow: hidden;
+    background: #eee;
+  }
+
+  &__avatar {
+    display: block;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  &__pointer {
+    width: 0;
+    height: 0;
+    margin-top: -1px;
+    border-left: 6px solid transparent;
+    border-right: 6px solid transparent;
+    border-top: 8px solid #fff;
+    filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.15));
+  }
+
+  &--active {
+    .dispatch-map-marker__label {
+      background: #409eff;
+    }
+
+    .dispatch-map-marker__avatar-wrap {
+      width: 48px;
+      height: 48px;
+      border-color: #409eff;
+      border-width: 3px;
+      box-shadow: 0 2px 12px rgba(64, 158, 255, 0.5);
+    }
+
+    .dispatch-map-marker__pointer {
+      border-top-color: #409eff;
+    }
+  }
+}
+</style>

+ 236 - 0
src/views/mapDemo/components/OrderAddressMap.vue

@@ -0,0 +1,236 @@
+<template>
+  <div class="order-address-map" :style="mapWrapStyle">
+    <div :id="mapId" class="map-container" />
+  </div>
+</template>
+
+<script>
+import AMapLoader from '@amap/amap-jsapi-loader'
+import userIcon from '@/assets/images/map/user.png'
+import realIcon from '@/assets/images/map/real.png'
+import virtualIcon from '@/assets/images/map/virtual.png'
+
+const AMAP_KEY = '41bc0f647d4432045d472f554bf03d75'
+const AMAP_SECURITY = '80ba66cc0bba5b07de6a275d7aa02a39'
+
+let mapInstanceSeed = 0
+
+/** 标记点类型图标:用户下单 / 商户真实 / 商户虚拟 */
+const MARKER_ICONS = {
+  user: userIcon,
+  real: realIcon,
+  virtual: virtualIcon
+}
+
+export default {
+  name: 'OrderAddressMap',
+  props: {
+    /** 标记点列表 [{ lng, lat, label, type? }] type: user | real | virtual */
+    markers: {
+      type: Array,
+      default: () => []
+    },
+    /** 地图中心 [lng, lat] */
+    center: {
+      type: Array,
+      default: () => [116.397428, 39.90923]
+    },
+    zoom: {
+      type: Number,
+      default: 16
+    },
+    /** 地图高度,支持 '500px'、'100%'、500 等写法 */
+    height: {
+      type: [String, Number],
+      default: '100%'
+    },
+    /** 是否自动适配所有标记点视野 */
+    fitMarkers: {
+      type: Boolean,
+      default: true
+    }
+  },
+  data() {
+    return {
+      mapId: `order-address-map-${++mapInstanceSeed}`,
+      AMap: null,
+      map: null,
+      markerInstances: []
+    }
+  },
+  computed: {
+    mapWrapStyle() {
+      const h = this.height
+      return {
+        height: typeof h === 'number' ? `${h}px` : h
+      }
+    }
+  },
+  watch: {
+    height() {
+      this.$nextTick(() => this.resizeMap())
+    },
+    markers: {
+      deep: true,
+      handler() {
+        this.renderMarkers()
+      }
+    }
+  },
+  mounted() {
+    window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY }
+    this.initMap()
+  },
+  beforeDestroy() {
+    this.destroyMap()
+  },
+  methods: {
+    initMap() {
+      AMapLoader.reset()
+      AMapLoader.load({
+        key: AMAP_KEY,
+        version: '2.0'
+      }).then((AMap) => {
+        this.AMap = AMap
+        this.map = new AMap.Map(this.mapId, {
+          resizeEnable: true,
+          zoom: this.zoom,
+          center: this.center,
+          viewMode: '2D'
+        })
+
+        this.renderMarkers()
+
+        if (this.fitMarkers && this.markers.length) {
+          this.fitMarkerView()
+        }
+      }).catch((err) => {
+        console.error('地图加载失败', err)
+        this.$message.error('地图加载失败,请检查网络或 Key 配置')
+      })
+    },
+
+    renderMarkers() {
+      if (!this.map || !this.AMap) return
+
+      this.clearMarkers()
+      this.markers.forEach((item, index) => {
+        if (!item.lng || !item.lat) return
+
+        const marker = new this.AMap.Marker({
+          position: [item.lng, item.lat],
+          content: this.createMarkerContent(item),
+          offset: new this.AMap.Pixel(0, 0),
+          anchor: 'bottom-center',
+          zIndex: 100 + index
+        })
+
+        marker.on('click', () => {
+          this.$emit('marker-click', { ...item, index })
+        })
+
+        this.map.add(marker)
+        this.markerInstances.push(marker)
+      })
+    },
+
+    createMarkerContent(item) {
+      const { label = '', type = 'user' } = item
+      const iconSrc = MARKER_ICONS[type] || MARKER_ICONS.user
+      const wrap = document.createElement('div')
+      wrap.className = `oam-marker oam-marker--${type}`
+      wrap.innerHTML = `
+        <div class="oam-marker-pin">
+          <img src="${iconSrc}" alt="" class="oam-marker-icon" />
+        </div>
+        <div class="oam-marker-label">${label}</div>
+      `
+      return wrap
+    },
+
+    clearMarkers() {
+      if (this.markerInstances.length && this.map) {
+        this.map.remove(this.markerInstances)
+      }
+      this.markerInstances = []
+    },
+
+    fitMarkerView() {
+      if (!this.map || !this.markerInstances.length) return
+      this.map.setFitView(this.markerInstances, false, [80, 80, 80, 80])
+    },
+
+    /** 供外部调用:刷新地图尺寸 */
+    resizeMap() {
+      if (this.map) {
+        this.map.resize()
+      }
+    },
+
+    /** 供外部调用:获取地图实例 */
+    getMapInstance() {
+      return this.map
+    },
+
+    destroyMap() {
+      this.clearMarkers()
+      if (this.map) {
+        this.map.destroy()
+        this.map = null
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.order-address-map {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+  background: #eef1f5;
+}
+
+.map-container {
+  width: 100%;
+  height: 100%;
+}
+</style>
+
+<style lang="scss">
+.oam-marker {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+  transform: translateY(-4px);
+
+  .oam-marker-pin {
+    line-height: 0;
+    filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
+
+    .oam-marker-icon {
+      display: block;
+      height: 40px;
+      width: auto;
+      max-width: 40px;
+      object-fit: contain;
+    }
+  }
+
+  .oam-marker-label {
+    margin-top: 2px;
+    padding: 3px 10px;
+    background: rgba(0, 0, 0, 0.65);
+    color: #fff;
+    font-size: 12px;
+    line-height: 1.4;
+    border-radius: 3px;
+    white-space: nowrap;
+    max-width: 160px;
+    text-align: center;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+</style>

+ 41 - 0
src/views/mapDemo/dispatchOrder.vue

@@ -0,0 +1,41 @@
+<template>
+  <div class="dispatch-order-page">
+    <el-button type="primary" @click="openDispatch">订单分配</el-button>
+
+    <dispatch-order-dialog ref="dispatchDialog" />
+  </div>
+</template>
+
+<script>
+import DispatchOrderDialog from './components/DispatchOrderDialog.vue'
+
+/** 示例订单(含用户目的地经纬度,实际由订单列表页传入) */
+const MOCK_ORDER = {
+  cId: '1',
+  orderNo: 'ORD20260601001',
+  cName: '张三',
+  cPhone: '13800138001',
+  cAddress: '北京市东城区王府井大街88号',
+  category: '按摩',
+  region: 'city',
+  arrivalLongitude: 116.297428,
+  arrivalLatitude: 39.90923,
+  dispatchStatus: 'pending'
+}
+
+export default {
+  name: 'DispatchOrder',
+  components: { DispatchOrderDialog },
+  methods: {
+    openDispatch() {
+      this.$refs.dispatchDialog.open(MOCK_ORDER)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.dispatch-order-page {
+  padding: 24px;
+}
+</style>

+ 45 - 0
src/views/mapDemo/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <div class="map-demo-page">
+    <order-address-map
+      :markers="markers"
+      :center="center"
+      :zoom="12"
+      height="400px"
+      @marker-click="onMarkerClick"
+    />
+
+  </div>
+</template>
+
+<script>
+import OrderAddressMap from './components/OrderAddressMap.vue'
+
+export default {
+  name: 'MapDemo',
+  components: { OrderAddressMap },
+  data() {
+    return {
+      center: [116.397428, 39.90923],
+      // 示例:用户下单地址user / 商户真实地址real / 商户虚拟地址virtual
+      markers: [
+        { lng: 116.280892, lat: 39.921234, label: '用户下单地址1', type: 'user' },
+        { lng: 116.385621, lat: 39.954812, label: '商户真实地址2', type: 'real' },
+        { lng: 116.451328, lat: 39.908765, label: '商户虚拟地址3', type: 'virtual' }
+      ]
+    }
+  },
+  methods: {
+    onMarkerClick(marker) {
+      this.$message.info(`点击标记:${marker.label}`)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.map-demo-page {
+  width: 100%;
+  height: auto;
+  padding: 0;
+}
+</style>

+ 356 - 0
src/views/mapDemo/scopeMap.vue

@@ -0,0 +1,356 @@
+<template>
+  <div class="scope-map-page">
+    <!-- 查看围栏 -->
+    <div class="view-map-section">
+      <div class="view-map-title">查看地图</div>
+      <div class="view-map-search">
+        <el-input
+          v-model="searchKeyword"
+          placeholder="请输入围栏名称"
+          clearable
+          @keyup.enter.native="handleSearch"
+          @clear="handleSearchClear"
+        />
+        <el-button type="primary" @click="handleSearch">搜索</el-button>
+      </div>
+      <div :id="mapId" class="scope-map-container" />
+    </div>
+  </div>
+</template>
+
+<script>
+import AMapLoader from '@amap/amap-jsapi-loader'
+
+const AMAP_KEY = '41bc0f647d4432045d472f554bf03d75'
+const AMAP_SECURITY = '80ba66cc0bba5b07de6a275d7aa02a39'
+const DEFAULT_RADIUS = 1000
+
+/** 不同位置区域:每个位置一个标记 + 一个圈 */
+const DEFAULT_POINT_LIST = [
+  { lng: 116.397428, lat: 39.90923, label: '位置一', radius: 500 },
+  { lng: 116.451328, lat: 39.908765, label: '位置二', radius: 800 },
+  { lng: 116.280892, lat: 39.921234, label: '位置三', radius: 600 }
+]
+
+const POINT_COLORS = ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C']
+
+export default {
+  name: 'ScopeMap',
+  data() {
+    return {
+      mapId: 'scope-map-demo',
+      searchKeyword: '',
+      pointList: DEFAULT_POINT_LIST.map((item) => ({ ...item })),
+      activeIndex: -1,
+      AMap: null,
+      map: null,
+      placeSearch: null,
+      markerInstances: [],
+      circleInstances: []
+    }
+  },
+  mounted() {
+    this.initMap()
+  },
+  beforeDestroy() {
+    this.destroyMap()
+  },
+  methods: {
+    initMap() {
+      window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY }
+      AMapLoader.load({
+        key: AMAP_KEY,
+        version: '2.0',
+        plugins: ['AMap.PlaceSearch']
+      }).then((AMap) => {
+        this.AMap = AMap
+        this.map = new AMap.Map(this.mapId, {
+          resizeEnable: true,
+          zoom: 13,
+          center: [116.397428, 39.90923],
+          viewMode: '2D'
+        })
+        this.placeSearch = new AMap.PlaceSearch({
+          pageSize: 1,
+          pageIndex: 1
+        })
+        this.renderOverlays()
+        this.fitView()
+      }).catch((err) => {
+        console.error('地图加载失败', err)
+        this.$message.error('地图加载失败,请检查网络或 Key 配置')
+      })
+    },
+
+    createMarkerContent(label, color = '#e74c3c', active = false) {
+      const wrap = document.createElement('div')
+      wrap.className = active ? 'scope-map-marker scope-map-marker--active' : 'scope-map-marker'
+      wrap.innerHTML = active
+        ? `
+        <div class="scope-map-marker__label scope-map-marker__label--active" style="background:${color}">${label}</div>
+        <img
+          class="scope-map-marker__pin scope-map-marker__pin--active"
+          src="https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png"
+          alt=""
+        />
+      `
+        : `
+        <div class="scope-map-marker__label" style="color:${color}">${label}</div>
+        <img
+          class="scope-map-marker__pin"
+          src="https://webapi.amap.com/theme/v1.3/markers/n/mark_r.png"
+          alt=""
+        />
+      `
+      return wrap
+    },
+
+    getCircleStyle(index, color, active) {
+      const hasActive = this.activeIndex >= 0
+      if (active) {
+        return {
+          strokeColor: color,
+          strokeWeight: 4,
+          strokeOpacity: 1,
+          fillColor: color,
+          fillOpacity: 0.38,
+          zIndex: 300 + index
+        }
+      }
+      if (hasActive) {
+        return {
+          strokeColor: color,
+          strokeWeight: 1,
+          strokeOpacity: 0.25,
+          fillColor: color,
+          fillOpacity: 0.04,
+          zIndex: 10 + index
+        }
+      }
+      return {
+        strokeColor: color,
+        strokeWeight: 2,
+        strokeOpacity: 0.9,
+        fillColor: color,
+        fillOpacity: 0.12,
+        zIndex: 10 + index
+      }
+    },
+
+    renderOverlays() {
+      if (!this.map || !this.AMap) return
+
+      this.clearOverlays()
+
+      this.pointList.forEach((point, index) => {
+        const color = POINT_COLORS[index % POINT_COLORS.length]
+        const center = [point.lng, point.lat]
+        const radius = point.radius || DEFAULT_RADIUS
+        const active = index === this.activeIndex
+
+        const marker = new this.AMap.Marker({
+          position: center,
+          content: this.createMarkerContent(point.label, color, active),
+          anchor: 'bottom-center',
+          offset: new this.AMap.Pixel(0, 0),
+          zIndex: active ? 500 : 200 + index
+        })
+        this.map.add(marker)
+        this.markerInstances.push(marker)
+
+        const circle = new this.AMap.Circle({
+          center,
+          radius,
+          ...this.getCircleStyle(index, color, active)
+        })
+        this.map.add(circle)
+        this.circleInstances.push(circle)
+      })
+    },
+
+    clearOverlays() {
+      if (this.markerInstances.length && this.map) {
+        this.map.remove(this.markerInstances)
+      }
+      if (this.circleInstances.length && this.map) {
+        this.map.remove(this.circleInstances)
+      }
+      this.markerInstances = []
+      this.circleInstances = []
+    },
+
+    fitView(index) {
+      if (!this.map) return
+      const targetIndex = typeof index === 'number' ? index : -1
+      if (targetIndex >= 0 && this.markerInstances[targetIndex]) {
+        this.map.setFitView(
+          [this.markerInstances[targetIndex], this.circleInstances[targetIndex]],
+          false,
+          [100, 100, 100, 100]
+        )
+        return
+      }
+      const overlays = [...this.markerInstances, ...this.circleInstances]
+      if (!overlays.length) return
+      this.map.setFitView(overlays, false, [60, 60, 60, 60])
+    },
+
+    focusPoint(index) {
+      this.activeIndex = index
+      this.renderOverlays()
+      this.fitView(index)
+    },
+
+    findLocalPointIndex(keyword) {
+      const normalized = keyword.trim().toLowerCase()
+      if (!normalized) return -1
+
+      const exactIndex = this.pointList.findIndex(
+        (item) => item.label.trim().toLowerCase() === normalized
+      )
+      if (exactIndex >= 0) return exactIndex
+
+      return this.pointList.findIndex(
+        (item) => item.label.trim().toLowerCase().includes(normalized)
+      )
+    },
+
+    handleSearchClear() {
+      this.activeIndex = -1
+      this.renderOverlays()
+      this.fitView()
+    },
+
+    handleSearch() {
+      const keyword = this.searchKeyword.trim()
+      if (!keyword) {
+        this.$message.warning('请输入位置名称')
+        return
+      }
+
+      const localIndex = this.findLocalPointIndex(keyword)
+      if (localIndex >= 0) {
+        this.focusPoint(localIndex)
+        this.$message.success(`已定位:${this.pointList[localIndex].label}`)
+        return
+      }
+
+      if (!this.placeSearch) return
+
+      this.placeSearch.search(keyword, (status, result) => {
+        if (status !== 'complete' || !result.poiList || !result.poiList.pois.length) {
+          this.$message.warning('未找到相关位置')
+          return
+        }
+        const poi = result.poiList.pois[0]
+        this.pointList.push({
+          lng: poi.location.lng,
+          lat: poi.location.lat,
+          label: poi.name,
+          radius: DEFAULT_RADIUS
+        })
+        this.focusPoint(this.pointList.length - 1)
+        this.$message.success(`已定位:${poi.name}`)
+      })
+    },
+
+    destroyMap() {
+      this.clearOverlays()
+      if (this.map) {
+        this.map.destroy()
+        this.map = null
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.scope-map-page {
+  width: 100%;
+  height: auto;
+  padding: 0;
+}
+
+.view-map-section {
+  padding: 16px;
+  background: #fff;
+}
+
+.view-map-title {
+  margin-bottom: 12px;
+  font-size: 16px;
+  font-weight: 500;
+  color: #303133;
+}
+
+.view-map-search {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  .el-input {
+    width: 320px;
+  }
+}
+
+.scope-map-container {
+  width: 100%;
+  height: 520px;
+  background: #eef1f5;
+}
+</style>
+
+<style lang="scss">
+.scope-map-marker {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  transform: translateY(-4px);
+
+  &__label {
+    margin-bottom: 4px;
+    font-size: 14px;
+    font-weight: 500;
+    line-height: 1.4;
+    white-space: nowrap;
+
+    &--active {
+      padding: 4px 12px;
+      color: #fff !important;
+      font-size: 15px;
+      font-weight: 600;
+      border-radius: 4px;
+      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
+    }
+  }
+
+  &__pin {
+    display: block;
+    width: 19px;
+    height: 31px;
+
+    &--active {
+      width: 24px;
+      height: 39px;
+      filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
+    }
+  }
+
+  &--active {
+    animation: scope-map-pulse 1.2s ease-in-out infinite;
+  }
+}
+
+@keyframes scope-map-pulse {
+  0%,
+  100% {
+    transform: translateY(-4px) scale(1);
+  }
+
+  50% {
+    transform: translateY(-4px) scale(1.06);
+  }
+}
+</style>