高德地图 MouseTool 绘制模式无法退出问题解决方案

问题描述

在 Vue 3 项目中集成高德地图 MouseTool 绘制工具时,遇到无法退出绘制模式的问题。具体表现为:

  • 点击"停止绘制(保留)"按钮后,鼠标仍然处于绘制状态
  • 点击"停止并清除所有"按钮后,已绘制的图形被清除,但绘制模式仍未退出
  • 同样的代码逻辑在纯 HTML 示例中完全正常

环境信息

  • 前端框架: Vue 3 + Vite
  • 地图库: 高德地图 JS API 2.0
  • 组件架构:
    • MapContainer.vue - 地图容器组件,负责初始化地图
    • MapParcelDrawer.vue - 地图绘制组件,负责绘制功能

问题代码(有问题的版本)

MapParcelDrawer.vue(问题版本)

// 初始化鼠标工具
const initMouseTool = async () => {
  if (!props.AMap || !props.map) return;

  // 等待 MouseTool 插件加载完成
  await new Promise((resolve) => {
    if (props.AMap.MouseTool) {
      resolve();
    } else {
      props.AMap.plugin(['AMap.MouseTool'], () => {
        resolve();
      });
    }
  });

  // 使用 props 传入的 AMap 和 map 创建 mouseTool
  mouseTool = new props.AMap.MouseTool(props.map);

  // 监听绘制完成事件
  mouseTool.on('draw', function(e) {
    var obj = e.obj;
    if (obj) {
      drawnOverlays.push(obj);
      updateOverlayList();
    }
  });
};

// 停止绘制
const stopDraw = (shouldClear = false) => {
  if (mouseTool) {
    mouseTool.close(shouldClear);
    if (shouldClear) {
      props.map.remove(drawnOverlays);
      drawnOverlays = [];
      updateOverlayList();
    }
  }
};

Home.vue(父组件)

<MapParcelDrawer
  v-if="isMapReady"
  ref="parcelDrawerRef"
  :map="mapInstance"
  :AMap="aMapInstance"
/>

问题分析

1. 组件架构导致的引用不一致

在 Vue 组件化架构中:

  • MapContainer.vue 创建地图实例
  • MapParcelDrawer.vue 通过 props 接收地图实例
  • 问题: mouseTool 绑定的地图实例和实际操作的地图实例可能不是同一个引用

2. 与 HTML 示例的对比

HTML 示例(正常工作):

// 全局作用域中创建
var map = new AMap.Map("container", {...});
var mouseTool = new AMap.MouseTool(map);

// 停止绘制 - 正常工作
function stopDraw(shouldClear = false) {
  mouseTool.close(shouldClear);
}

Vue 组件(有问题):

// 通过 props 接收
const props = defineProps({
  map: { type: Object, required: true },
  AMap: { type: Object, required: true }
});

// 使用 props 创建 mouseTool
mouseTool = new props.AMap.MouseTool(props.map);

3. 根本原因

在 Vue 的响应式系统中,通过 props 传递的对象引用可能会因为组件重新渲染、ref 解包等机制而发生变化。当 mouseTool 绑定的地图实例引用与实际地图实例不一致时,mouseTool.close() 就无法正确操作地图的绘制状态。

解决方案

核心思路

将地图实例保存到全局 window 对象上,确保所有组件访问的是同一个地图实例引用。

1. 修改 MapContainer.vue

在创建地图实例后,将其保存到 window 对象:

// 初始化高德地图
const initMap = () => {
  if (typeof window === 'undefined' || !window.AMap || !mapContainer.value) return;
  
  try {
    const centerPoint = [114.31, 30.59];
    
    if (!mapInstance) {
      // 创建地图实例
      mapInstance = new window.AMap.Map(mapContainer.value, {
        center: centerPoint,
        zoom: 15,
        zooms: [10, 20],
        layers: defaultLayers
      });
      
      // ✅ 关键:将地图实例保存到 window 对象
      window._amapMapInstance = mapInstance;
      console.log('地图实例创建成功并保存到window对象');
    }
    
    // ... 其他代码
  } catch (error) {
    console.error('初始化地图失败:', error);
  }
};

2. 修改 MapParcelDrawer.vue

创建 getMapInstance() 函数,优先从 window 对象获取地图实例:

// 获取地图实例 - 优先使用 window 对象,与 HTML 示例保持一致
const getMapInstance = () => {
  // 优先从 window 获取地图实例(由 MapContainer.vue 设置)
  if (typeof window !== 'undefined' && window._amapMapInstance) {
    return window._amapMapInstance;
  }
  // 回退到 props.map
  return props.map;
};

// 初始化鼠标工具
const initMouseTool = async () => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  if (!AMap || !map) {
    console.error('AMap 或 map 未就绪');
    return;
  }

  // 等待 MouseTool 插件加载完成
  await new Promise((resolve) => {
    if (AMap.MouseTool) {
      resolve();
    } else {
      AMap.plugin(['AMap.MouseTool'], () => {
        resolve();
      });
    }
  });

  // ✅ 关键:使用 window.AMap 和 window 上的地图实例创建 mouseTool
  mouseTool = new AMap.MouseTool(map);

  // 监听绘制完成事件
  mouseTool.on('draw', function(e) {
    var obj = e.obj;
    if (obj) {
      drawnOverlays.push(obj);
      updateOverlayList();
    }
  });
  
  console.log('MouseTool 初始化成功');
};

// 停止绘制
const stopDraw = async (shouldClear = false) => {
  // 确保 mouseTool 已初始化
  if (!mouseTool) {
    await initMouseTool();
  }
  
  if (mouseTool) {
    mouseTool.close(shouldClear);
    console.log('停止绘制, shouldClear:', shouldClear);
    
    if (shouldClear) {
      const map = getMapInstance();
      if (map) {
        map.remove(drawnOverlays);
      }
      drawnOverlays = [];
      updateOverlayList();
    }
  } else {
    console.error('MouseTool 未初始化,无法停止绘制');
  }
};

3. 统一所有使用地图实例的地方

确保所有操作地图的地方都使用 getMapInstance()

// 更新图形列表
const updateOverlayList = () => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  // 防御清理
  drawnOverlays = drawnOverlays.filter(ov => ov && (map && map.hasOverlay ? map.hasOverlay(ov) : true));
  
  // ... 其他代码
};

// 删除图形
const deleteOverlay = (index) => {
  const overlay = drawnOverlays[index];
  const map = getMapInstance();
  
  try {
    exitEditMode();
    if (overlay.setMap) overlay.setMap(null);
    if (map && map.hasOverlay && map.hasOverlay(overlay)) {
      map.remove(overlay);
    }
    // ... 其他代码
  } catch (err) {
    console.error('删除失败:', err);
  }
};

// 导入 JSON
const importOverlays = (event) => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  // 使用 AMap 和 map 创建覆盖物
  if (item.type === 'circle') {
    overlay = new AMap.Circle({...});
  }
  
  if (overlay) {
    map.add(overlay);  // 使用 window 上的地图实例
    drawnOverlays.push(overlay);
  }
};

关键要点总结

1. 问题本质

Vue 组件间的 props 传递可能导致对象引用不一致,特别是在使用 ref 和响应式数据时。

2. 解决核心

通过 window 对象共享地图实例,确保所有组件操作的是同一个地图实例引用。

3. 最佳实践

  • 创建时: 在 MapContainer.vue 中创建地图实例并保存到 window
  • 使用时: 在 MapParcelDrawer.vue 中通过 getMapInstance() 获取
  • 一致性: 所有操作地图的地方都使用同一个获取函数

4. 调试技巧

添加关键位置的 console.log 帮助排查问题:

console.log('地图实例创建成功并保存到window对象');
console.log('MouseTool 初始化成功');
console.log('停止绘制, shouldClear:', shouldClear);

完整代码参考

[MapContainer.vue 完整代码]↓

<template>
  <div class="map-container">
    <div ref="mapContainer" class="map"></div>
    
    <div class="map-controls">
      <button 
        v-if="mapType === 'standard'"
        class="control-btn active"
        @click="switchMapType('satellite')"
      >
        切换为卫星图
      </button>
      <button 
        v-else
        class="control-btn active"
        @click="switchMapType('standard')"
      >
        切换为标准图
      </button>
    </div>

    <!-- 设备详细信息卡片 -->
    <transition name="card-slide">
      <div v-if="selectedDevice" class="device-detail-card" :class="[selectedDevice.type, selectedDevice.status]">
        <div class="card-header">
          <div class="device-icon" :style="{ background: getDeviceColor(selectedDevice.type) }">
            <component :is="getDeviceIcon(selectedDevice.type)" />
          </div>
          <div class="device-title">
            <h3>{{ selectedDevice.name }}</h3>
            <span class="device-id">设备ID: {{ selectedDevice.id }}</span>
          </div>
          <button class="close-btn" @click="closeDetailCard">
            <CloseOutlined />
          </button>
        </div>
        
        <div class="card-content">
          <!-- 监控设备播放窗口 -->
          <div v-if="selectedDevice.type === 'camera'" class="camera-preview">
            <div class="camera-frame">
              <div class="camera-screen">
                <div class="camera-live-indicator">
                  <span class="live-dot"></span>
                  LIVE
                </div>
                <div class="camera-timestamp">{{ formatTime() }}</div>
              </div>
            </div>
          </div>
          
          <!-- 设备状态 -->
          <div class="status-row">
            <span class="status-label">设备状态</span>
            <span class="status-value" :class="selectedDevice.status">
              {{ getStatusText(selectedDevice.status) }}
            </span>
          </div>
          
          <!-- 告警状态 -->
          <div v-if="selectedDevice.hasAlarm" class="alarm-status">
            <span class="alarm-icon">⚠️</span>
            <span class="alarm-text">存在 {{ selectedDevice.alarmCount }} 条告警</span>
          </div>
          
          <!-- 设备详细信息表格 -->
          <div class="device-info-grid">
            <div v-for="(value, key) in selectedDevice.data" :key="key" class="info-item">
              <span class="info-label">{{ key }}</span>
              <span class="info-value" :class="getValueClass(key, value)">
                {{ value }}
              </span>
            </div>
          </div>
          
          <!-- 位置信息 -->
          <div class="location-info">
            <span class="location-icon">📍</span>
            <span class="location-text">
              经度: {{ selectedDevice.longitude.toFixed(4) }}° | 
              纬度: {{ selectedDevice.latitude.toFixed(4) }}°
            </span>
          </div>
        </div>
        
        <div class="card-actions">
          <button class="action-btn primary" @click="goToDevicePage">
            <DashboardOutlined />
            查看设备详情
          </button>
          <button class="action-btn secondary" @click="viewAlertLog">
            <BellOutlined />
            查看告警日志
          </button>
          <button v-if="selectedDevice.type === 'camera'" class="action-btn camera" @click="openCamera">
            <VideoCameraOutlined />
            实时监控
          </button>
        </div>
      </div>
    </transition>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import {
  CloseOutlined, 
  DashboardOutlined, 
  BellOutlined, 
  VideoCameraOutlined,
  HomeOutlined,
  ExperimentOutlined,
  CloudOutlined,
  SettingOutlined,
  CameraOutlined,
  ThunderboltOutlined,
  FilterOutlined,
  AppstoreOutlined
} from '@ant-design/icons-vue';

const props = defineProps({
  devices: {
    type: Array,
    default: () => []
  }
});

const emit = defineEmits(['device-click']);

const router = useRouter();
const mapContainer = ref(null);
const mapType = ref('satellite');
const selectedDevice = ref(null);
let mapInstance = null;
let markers = [];
let infoWindows = [];
let parcelOverlays = []; // 地块覆盖物数组

// 设备类型配置
const deviceTypes = {
  pump: { name: '水泵', icon: markRaw(ThunderboltOutlined), color: '#10b981' },
  filter: { name: '过滤器', icon: markRaw(FilterOutlined), color: '#10b981' },
  pipeline: { name: '管道', icon: markRaw(AppstoreOutlined), color: '#10b981' },
  valve: { name: '阀门', icon: markRaw(SettingOutlined), color: '#10b981' },
  water: { name: '水质检测', icon: markRaw(ExperimentOutlined), color: '#10b981' },
  soil: { name: '土壤墒情', icon: markRaw(HomeOutlined), color: '#10b981' },
  weather: { name: '气象监测', icon: markRaw(CloudOutlined), color: '#10b981' },
  camera: { name: '视频监控', icon: markRaw(CameraOutlined), color: '#10b981' }
};

// 获取设备图标
const getDeviceIcon = (type) => {
  return deviceTypes[type]?.icon || SettingOutlined;
};

// 获取设备颜色
const getDeviceColor = (type) => {
  return deviceTypes[type]?.color || '#6b7280';
};

// 获取状态文本
const getStatusText = (status) => {
  const statusMap = {
    normal: '正常',
    alarm: '异常'
  };
  return statusMap[status] || status;
};

// 获取数值样式类
const getValueClass = (key, value) => {
  if (key.includes('状态') || key.includes('泄漏检测')) {
    if (value === '正常' || value === '运行中' || value === '开启' || value === '在线' || value === '录制中') {
      return 'normal';
    } else if (value === '异常' || value === '故障' || value === '离线' || value === '停止' || value === '有泄漏' || value === '严重堵塞') {
      return 'alarm';
    } else {
      return 'warning';
    }
  }
  return '';
};

// 格式化时间
const formatTime = () => {
  const now = new Date();
  return now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
};

// 关闭详情卡片
const closeDetailCard = () => {
  selectedDevice.value = null;
};

// 跳转到设备页面
const goToDevicePage = () => {
  if (selectedDevice.value) {
    const typeMap = {
      pump: '/devices/pump',
      filter: '/devices/filter',
      pipeline: '/devices/pipeline',
      valve: '/devices/valve',
      water: '/environment/water',
      soil: '/environment/soil',
      weather: '/environment/weather',
      camera: '/video'
    };
    router.push(typeMap[selectedDevice.value.type] || '/');
  }
};

// 查看告警日志
const viewAlertLog = () => {
  router.push('/alert-log');
};

// 打开监控
const openCamera = () => {
  console.log('打开监控:', selectedDevice.value?.name);
};

// 初始化高德地图
const initMap = () => {
  // 确保在浏览器环境中初始化
  if (typeof window === 'undefined' || !window.AMap || !mapContainer.value) return;
  
  try {
    console.log('开始初始化地图...');
    
    // 使用固定中心点测试
    const centerPoint = [114.31, 30.59];
    console.log('地图中心点:', centerPoint);
    
    // 创建地图实例
    if (!mapInstance) {
      console.log('创建地图实例...');
      
      // 根据默认地图类型设置图层
      const defaultLayers = mapType.value === 'satellite' 
        ? [new window.AMap.TileLayer.Satellite()]
        : [new window.AMap.TileLayer()];
      
      mapInstance = new window.AMap.Map(mapContainer.value, {
        center: centerPoint,
        zoom: 15,
        zooms: [10, 20],
        layers: defaultLayers
      });
      
      // 将地图实例保存到window对象,供其他组件使用(与HTML示例保持一致)
      window._amapMapInstance = mapInstance;
      console.log('地图实例创建成功并保存到window对象,默认地图类型:', mapType.value);
    } else {
      // 切换地图类型
      console.log('切换地图类型...');
      updateMapType();
      console.log('地图类型切换成功');
    }
    
    // 清除现有标记
    console.log('清除现有标记...');
    clearMarkers();
    console.log('标记清除成功');
    
    // 恢复添加设备标记
    console.log('添加设备标记...');
    addDeviceMarkers();
    console.log('设备标记添加成功');
    
    // 暂时注释掉调整地图视野
    /*
    // 调整地图视野以包含所有标记
    console.log('调整地图视野...');
    fitMapBounds();
    console.log('地图视野调整成功');
    */
    
    console.log('地图初始化完成');
    
  } catch (error) {
    console.error('初始化地图失败:', error);
    console.error('错误堆栈:', error.stack);
  }
};

// 更新地图类型
const updateMapType = () => {
  if (!mapInstance) return;
  
  try {
    if (mapType.value === 'satellite') {
      // 切换到卫星图 - 使用TileLayer.Satellite
      const satelliteLayer = new window.AMap.TileLayer.Satellite();
      mapInstance.setLayers([satelliteLayer]);
    } else {
      // 切换到标准图 - 使用默认图层
      const normalLayer = new window.AMap.TileLayer();
      mapInstance.setLayers([normalLayer]);
    }
  } catch (error) {
    console.error('切换地图类型失败:', error);
  }
};

// 清除标记
const clearMarkers = () => {
  if (!mapInstance) return;
  
  markers.forEach(marker => {
    mapInstance.remove(marker);
  });
  markers = [];
  
  infoWindows.forEach(infoWindow => {
    infoWindow.close();
  });
  infoWindows = [];
};

// 获取文字阴影效果
const getTextShadow = (effects) => {
  const shadows = [];
  
  if (effects?.includes('glow')) {
    shadows.push('0 0 10px currentColor', '0 0 20px currentColor', '0 0 30px currentColor');
  }
  
  if (effects?.includes('neon')) {
    shadows.push(
      '0 0 5px #fff',
      '0 0 10px #fff',
      '0 0 20px currentColor',
      '0 0 40px currentColor',
      '0 0 80px currentColor'
    );
  }
  
  return shadows.join(', ');
};

// 在地图上显示地块
const displayParcel = (parcel) => {
  if (!window.AMap || !mapInstance) return null;
  
  let overlay = null;
  
  try {
    switch (parcel.type) {
      case 'rectangle':
        // 转换几何数据为 AMap.Bounds
        if (parcel.geometry && parcel.geometry.southwest && parcel.geometry.northeast) {
          const bounds = new window.AMap.Bounds(
            new window.AMap.LngLat(parcel.geometry.southwest.lng, parcel.geometry.southwest.lat),
            new window.AMap.LngLat(parcel.geometry.northeast.lng, parcel.geometry.northeast.lat)
          );
          overlay = new window.AMap.Rectangle({
            bounds: bounds,
            fillColor: parcel.style.backgroundColor,
            strokeColor: parcel.style.borderColor,
            strokeWeight: parcel.style.borderWidth,
            strokeStyle: parcel.style.borderStyle,
            fillOpacity: parcel.style.backgroundOpacity / 100
          });
        }
        break;
        
      case 'circle':
        // 转换几何数据为 AMap.LngLat
        if (parcel.geometry && parcel.geometry.center) {
          const center = new window.AMap.LngLat(parcel.geometry.center.lng, parcel.geometry.center.lat);
          overlay = new window.AMap.Circle({
            center: center,
            radius: parcel.geometry.radius,
            fillColor: parcel.style.backgroundColor,
            strokeColor: parcel.style.borderColor,
            strokeWeight: parcel.style.borderWidth,
            strokeStyle: parcel.style.borderStyle,
            fillOpacity: parcel.style.backgroundOpacity / 100
          });
        }
        break;
        
      case 'polygon':
        overlay = new window.AMap.Polygon({
          path: parcel.geometry,
          fillColor: parcel.style.backgroundColor,
          strokeColor: parcel.style.borderColor,
          strokeWeight: parcel.style.borderWidth,
          strokeStyle: parcel.style.borderStyle,
          fillOpacity: parcel.style.backgroundOpacity / 100
        });
        break;
        
      case 'text':
        // 转换几何数据为 AMap.LngLat
        if (parcel.geometry) {
          let position = parcel.geometry;
          if (typeof parcel.geometry === 'object' && parcel.geometry.lng && parcel.geometry.lat) {
            position = new window.AMap.LngLat(parcel.geometry.lng, parcel.geometry.lat);
          }
          overlay = new window.AMap.Text({
            text: parcel.name,
            position: position,
            style: {
              'background-color': 'transparent',
              'border': 'none',
              'color': parcel.style.textColor,
              'font-size': `${parcel.style.fontSize}px`,
              'font-weight': 'bold',
              'text-shadow': getTextShadow(parcel.style.effects || [])
            }
          });
        }
        break;
    }
    
    if (overlay) {
      // 设置z-index在设备标记下方(设备标记z-index为100+)
      // 注意:AMap.Text 不支持 setZIndex 方法
      if (parcel.type !== 'text' && typeof overlay.setZIndex === 'function') {
        overlay.setZIndex(10);
      }
      mapInstance.add(overlay);
      parcelOverlays.push(overlay);
      
      // 添加点击事件
      overlay.on('click', () => {
        console.log('地块点击:', parcel.name);
      });
    }
  } catch (error) {
    console.error('显示地块失败:', error);
  }
  
  return overlay;
};

// 清除所有地块
const clearParcels = () => {
  if (!mapInstance) return;

  parcelOverlays.forEach(overlay => {
    mapInstance.remove(overlay);
  });
  parcelOverlays = [];
};

// 暴露地图实例和AMap给父组件
defineExpose({
  mapInstance: () => mapInstance,
  AMap: () => window.AMap,
  displayParcel,
  clearParcels
});

// 计算告警颜色深度
const getAlarmColor = (alarmCount) => {
  // 根据告警数量返回不同深度的红色
  if (alarmCount <= 0) return '#10b981'; // 绿色 - 正常
  if (alarmCount === 1) return '#f59e0b'; // 橙色 - 轻微
  if (alarmCount <= 3) return '#ef4444'; // 红色 - 一般
  return '#dc2626'; // 深红色 - 严重
};

// 计算标记大小
const getMarkerSize = (hasAlarm) => {
  const baseSize = hasAlarm ? 28 : 22; // 红色标记比绿色大,但整体变小
  return baseSize;
};

// 计算动画速度
const getAnimationDuration = (hasAlarm) => {
  return hasAlarm ? '1.5s' : '3s'; // 红色快,绿色慢
};

// 添加设备标记
const addDeviceMarkers = () => {
  if (!mapInstance) return;
  
  console.log('添加设备标记,设备数量:', props.devices.length);
  
  // 分离正常设备和告警设备,确保告警设备在上方
  const normalDevices = props.devices.filter(d => !d.hasAlarm);
  const alarmDevices = props.devices.filter(d => d.hasAlarm);
  
  // 先添加正常设备,再添加告警设备(告警设备z-index更高)
  const sortedDevices = [...normalDevices, ...alarmDevices];
  
  sortedDevices.forEach((device, index) => {
    const position = [device.longitude, device.latitude];
    const hasAlarm = device.hasAlarm;
    const alarmCount = device.alarmCount || 0;
    const color = getAlarmColor(alarmCount);
    const size = getMarkerSize(hasAlarm);
    const animationDuration = getAnimationDuration(hasAlarm);
    const zIndex = hasAlarm ? 2000 + index : 1000 + index;
    
    // 创建标记容器
    const markerContent = document.createElement('div');
    markerContent.className = 'device-marker';
    markerContent.style.cssText = `
      position: relative;
      width: ${size * 5}px;
      height: ${size * 4 + 40}px;
      z-index: ${zIndex};
    `;
    
    // 告警设备额外特效
    const alarmExtraEffects = hasAlarm ? `
      <!-- 告警设备额外波纹 -->
      <div class="marker-alarm-ripple-1" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        border: 3px solid #ff0000;
        background: transparent;
        animation: alarmRipple 2s ease-out infinite;
        pointer-events: none;
        opacity: 0.9;
        z-index: ${zIndex + 1};
      "></div>
      <div class="marker-alarm-ripple-2" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        border: 3px solid #ff6600;
        background: transparent;
        animation: alarmRipple 2s ease-out infinite 0.7s;
        pointer-events: none;
        opacity: 0.7;
        z-index: ${zIndex + 1};
      "></div>
    ` : '';
    
    // 科技风标记HTML
    markerContent.innerHTML = `
      <!-- 扩散波纹效果 - 多层动态扩散 -->
      <div class="marker-ripple-1" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        border: 2px solid ${color};
        background: transparent;
        animation: rippleWave ${animationDuration} ease-out infinite;
        pointer-events: none;
        opacity: 0.8;
      "></div>
      
      <div class="marker-ripple-2" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        border: 2px solid ${color};
        background: transparent;
        animation: rippleWave ${animationDuration} ease-out infinite 0.5s;
        pointer-events: none;
        opacity: 0.6;
      "></div>
      
      <div class="marker-ripple-3" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        border: 2px solid ${color};
        background: transparent;
        animation: rippleWave ${animationDuration} ease-out infinite 1s;
        pointer-events: none;
        opacity: 0.4;
      "></div>
      
      ${alarmExtraEffects}
      
      <!-- 光晕效果 -->
      <div class="marker-halo" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size * 1.8}px;
        height: ${size * 1.8}px;
        border-radius: 50%;
        background: radial-gradient(circle, ${color}50 0%, ${color}20 50%, transparent 70%);
        animation: haloPulse ${animationDuration} ease-in-out infinite;
        pointer-events: none;
      "></div>
      
      <!-- 主标记点 - 带呼吸动画 -->
      <div class="marker-main" style="
        position: absolute;
        top: ${size * 2}px;
        left: 50%;
        transform: translate(-50%, -50%);
        width: ${size}px;
        height: ${size}px;
        border-radius: 50%;
        background: linear-gradient(135deg, ${color} 0%, ${color}dd 50%, ${color} 100%);
        border: 2px solid rgba(255, 255, 255, 0.8);
        box-shadow: 
          0 0 10px ${color},
          0 0 20px ${color}60,
          0 0 30px ${color}30,
          inset 0 0 8px rgba(255, 255, 255, 0.3);
        cursor: pointer;
        transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
        z-index: ${zIndex + 5};
        animation: breathe ${hasAlarm ? '1.5s' : '3s'} ease-in-out infinite;
      "></div>
      
      <!-- 设备名称标签 - 在标记点下方 -->
      <div class="marker-label" style="
        position: absolute;
        top: ${size * 2 + size/2 + 8}px;
        left: 50%;
        transform: translateX(-50%);
        white-space: nowrap;
        background: linear-gradient(135deg, rgba(0, 31, 63, 0.95) 0%, rgba(15, 23, 42, 0.95) 100%);
        backdrop-filter: blur(8px);
        padding: 6px 12px;
        border-radius: 6px;
        border: 1px solid ${color}60;
        font-size: 12px;
        font-weight: 700;
        color: #00FFFF;
        text-shadow: 0 0 6px ${color};
        box-shadow: 
          0 4px 12px rgba(0, 0, 0, 0.5),
          0 0 15px ${color}30,
          inset 0 0 8px ${color}15;
        opacity: 0.95;
        transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
        pointer-events: none;
        z-index: ${zIndex + 20};
        display: flex;
        align-items: center;
        gap: 6px;
      ">
        <!-- 设备图标在文字框左侧 -->
        <span class="label-icon" style="
          font-size: 14px;
          filter: drop-shadow(0 0 2px ${color});
        ">${getDeviceIconForMarker(device.type)}</span>
        <span>${device.name}</span>
        
        <!-- 告警数量气泡 - 在文字框右上角 -->
        ${hasAlarm ? `
        <span class="label-badge" style="
          position: absolute;
          top: -6px;
          right: -6px;
          min-width: 18px;
          height: 18px;
          padding: 0 4px;
          background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
          border-radius: 9px;
          border: 2px solid rgba(255, 255, 255, 0.9);
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 9px;
          font-weight: 800;
          color: #fff;
          text-shadow: 0 0 3px rgba(0,0,0,0.5);
          box-shadow: 
            0 0 8px #ef4444,
            0 2px 3px rgba(0,0,0,0.3);
          animation: badgePulse 1.2s ease-in-out infinite;
          z-index: ${zIndex + 25};
        ">${alarmCount}</span>
        ` : ''}
      </div>
    `;
    
    // 添加CSS动画关键帧(如果尚未添加)
    if (!document.getElementById('marker-animations')) {
      const style = document.createElement('style');
      style.id = 'marker-animations';
      style.textContent = `
        @keyframes rippleWave {
          0% { 
            transform: translate(-50%, -50%) scale(1); 
            opacity: 0.8; 
            border-width: 2px;
          }
          50% { 
            opacity: 0.4; 
          }
          100% { 
            transform: translate(-50%, -50%) scale(2.5); 
            opacity: 0; 
            border-width: 0px;
          }
        }
        @keyframes alarmRipple {
          0% { 
            transform: translate(-50%, -50%) scale(1); 
            opacity: 0.9; 
            border-width: 3px;
          }
          100% { 
            transform: translate(-50%, -50%) scale(4); 
            opacity: 0; 
            border-width: 0px;
          }
        }
        @keyframes rotateRing {
          0% { transform: translate(-50%, -50%) rotate(0deg); }
          100% { transform: translate(-50%, -50%) rotate(360deg); }
        }
        @keyframes breathe {
          0%, 100% { 
            transform: translate(-50%, -50%) scale(1); 
            box-shadow: 
              0 0 10px currentColor,
              0 0 20px currentColor60,
              0 0 30px currentColor30,
              inset 0 0 8px rgba(255, 255, 255, 0.3);
          }
          50% { 
            transform: translate(-50%, -50%) scale(1.15); 
            box-shadow: 
              0 0 15px currentColor,
              0 0 30px currentColor80,
              0 0 45px currentColor50,
              inset 0 0 12px rgba(255, 255, 255, 0.5);
          }
        }
        @keyframes haloPulse {
          0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
          50% { transform: translate(-50%, -50%) scale(1.3); opacity: 0.3; }
        }
        @keyframes badgePulse {
          0%, 100% { transform: scale(1); box-shadow: 0 0 8px #ef4444, 0 2px 3px rgba(0,0,0,0.3); }
          50% { transform: scale(1.2); box-shadow: 0 0 15px #ef4444, 0 0 25px #ef4444, 0 2px 3px rgba(0,0,0,0.3); }
        }
        .device-marker:hover .marker-main {
          animation: none !important;
          transform: translate(-50%, -50%) scale(1.3) !important;
          box-shadow: 
            0 0 20px currentColor,
            0 0 40px currentColor,
            0 0 60px currentColor,
            inset 0 0 15px rgba(255, 255, 255, 0.6) !important;
        }
        .device-marker:hover .marker-label {
          opacity: 1 !important;
          transform: translateX(-50%) translateY(-2px) scale(1.05) !important;
          box-shadow: 
            0 6px 20px rgba(0, 0, 0, 0.6),
            0 0 25px ${color}50,
            inset 0 0 12px ${color}25 !important;
        }
      `;
      document.head.appendChild(style);
    }
    
    // 创建标记
    const marker = new window.AMap.Marker({
      position: position,
      content: markerContent,
      zIndex: zIndex,
      offset: new window.AMap.Pixel(-size * 2.5, -size * 2)
    });
    
    // 添加点击事件
    marker.on('click', () => {
      selectedDevice.value = device;
      emit('device-click', device);
    });
    
    // 添加鼠标悬停事件 - 显示详细信息
    marker.on('mouseover', () => {
      showEnhancedInfoWindow(device, position);
    });
    
    marker.on('mouseout', () => {
      closeInfoWindows();
    });
    
    // 添加到地图
    mapInstance.add(marker);
    markers.push(marker);
  });
  
  console.log('标记添加完成,共添加:', markers.length, '个标记');
};

// 获取设备标记图标
const getDeviceIconForMarker = (type) => {
  const iconMap = {
    pump: '⚡',
    filter: '🔍',
    pipeline: '💧',
    valve: '🎛️',
    water: '💦',
    soil: '🌱',
    weather: '☁️',
    camera: '📹'
  };
  return iconMap[type] || '📍';
};

// 显示增强版信息窗口
const showEnhancedInfoWindow = (device, position) => {
  if (!mapInstance) return;
  
  try {
    const hasAlarm = device.hasAlarm;
    const alarmCount = device.alarmCount || 0;
    const color = getAlarmColor(alarmCount);
    const uniqueId = 'info-window-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
    
    // 预添加全局样式到head,避免闪烁
    if (!document.getElementById('amap-info-window-styles')) {
      const styleEl = document.createElement('style');
      styleEl.id = 'amap-info-window-styles';
      styleEl.textContent = `
        .amap-info-window, .amap-info-content {
          background: transparent !important;
          border: none !important;
          box-shadow: none !important;
          padding: 0 !important;
          margin: 0 !important;
          overflow: visible !important;
        }
        .amap-info-sharp {
          display: none !important;
        }
        .amap-info-outer {
          background: transparent !important;
          box-shadow: none !important;
          border: none !important;
        }
        .amap-info-inner {
          background: transparent !important;
          box-shadow: none !important;
          border: none !important;
          padding: 0 !important;
        }
        .amap-info-close {
          display: none !important;
        }
        .custom-info-window {
          will-change: transform, opacity;
          transform: translateZ(0);
          backface-visibility: hidden;
        }
      `;
      document.head.appendChild(styleEl);
    }
    
    // 创建科技风信息窗口内容 - 使用class和!important确保样式生效
    const content = `
      <div class="custom-info-window ${uniqueId}" style="
        min-width: 240px !important;
        background: linear-gradient(135deg, rgba(0, 31, 63, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%) !important;
        border: 2px solid ${color} !important;
        border-radius: 12px !important;
        padding: 16px !important;
        box-shadow: 
          0 8px 32px rgba(0, 0, 0, 0.6),
          0 0 30px ${color}40,
          inset 0 0 30px ${color}15 !important;
        backdrop-filter: blur(15px) !important;
        -webkit-backdrop-filter: blur(15px) !important;
        position: relative !important;
        overflow: hidden !important;
        color: #fff !important;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
        opacity: 0;
        animation: infoWindowFadeIn 0.15s ease-out forwards;
      ">
        <!-- 顶部装饰线 -->
        <div class="info-window-top-line" style="
          position: absolute !important;
          top: 0 !important;
          left: 0 !important;
          right: 0 !important;
          height: 3px !important;
          background: linear-gradient(90deg, transparent 0%, ${color} 50%, transparent 100%) !important;
          box-shadow: 0 0 10px ${color} !important;
        "></div>
        
        <!-- 标题栏 -->
        <div class="info-window-header" style="
          display: flex !important;
          align-items: center !important;
          gap: 10px !important;
          margin-bottom: 12px !important;
          padding-bottom: 10px !important;
          border-bottom: 1px solid ${color}80 !important;
        ">
          <div class="info-window-dot" style="
            width: 10px !important;
            height: 10px !important;
            border-radius: 50% !important;
            background: ${color} !important;
            box-shadow: 0 0 12px ${color}, 0 0 20px ${color} !important;
            flex-shrink: 0 !important;
          "></div>
          <div class="info-window-title" style="
            font-size: 15px !important;
            font-weight: 800 !important;
            color: #00FFFF !important;
            text-shadow: 0 0 10px ${color} !important;
            letter-spacing: 1px !important;
            line-height: 1.2 !important;
          ">${device.name}</div>
        </div>
        
        <!-- 设备类型 -->
        <div class="info-window-type" style="
          display: flex !important;
          align-items: center !important;
          gap: 8px !important;
          margin-bottom: 10px !important;
          font-size: 13px !important;
          color: rgba(255, 255, 255, 0.9) !important;
          line-height: 1.4 !important;
        ">
          <span style="font-size: 14px; line-height: 1;">${getDeviceIconForMarker(device.type)}</span>
          <span style="font-weight: 500 !important;">${deviceTypes[device.type]?.name || device.type}</span>
        </div>
        
        <!-- 状态信息 -->
        <div class="info-window-status" style="
          display: flex !important;
          align-items: center !important;
          gap: 10px !important;
          padding: 10px 12px !important;
          background: ${hasAlarm ? 'rgba(239, 68, 68, 0.25)' : 'rgba(16, 185, 129, 0.25)'} !important;
          border-radius: 8px !important;
          border: 1px solid ${hasAlarm ? 'rgba(239, 68, 68, 0.6)' : 'rgba(16, 185, 129, 0.6)'} !important;
          box-shadow: inset 0 0 15px ${hasAlarm ? 'rgba(239, 68, 68, 0.15)' : 'rgba(16, 185, 129, 0.15)'} !important;
          line-height: 1.4 !important;
        ">
          <div style="
            width: 8px !important;
            height: 8px !important;
            border-radius: 50% !important;
            background: ${color} !important;
            box-shadow: 0 0 10px ${color} !important;
            flex-shrink: 0 !important;
          "></div>
          <span style="
            font-size: 13px !important;
            font-weight: 700 !important;
            color: ${color} !important;
            text-shadow: 0 0 5px ${color}80 !important;
            line-height: 1.4 !important;
          ">${hasAlarm ? `⚠️ ${alarmCount} 条告警` : '✅ 正常运行'}</span>
        </div>
        
        <!-- 告警详情(如果有) -->
        ${hasAlarm ? `
        <div class="info-window-details" style="
          margin-top: 12px !important;
          padding-top: 12px !important;
          border-top: 1px dashed ${color}60 !important;
          font-size: 12px !important;
          color: rgba(255, 255, 255, 0.85) !important;
          line-height: 1.6 !important;
        ">
          <div style="display: flex !important; justify-content: space-between !important; margin-bottom: 6px !important;">
            <span style="color: rgba(255, 255, 255, 0.7) !important;">告警等级:</span>
            <span style="color: ${color} !important; font-weight: 700 !important; text-shadow: 0 0 5px ${color}80 !important;">${alarmCount > 3 ? '严重' : alarmCount > 1 ? '一般' : '轻微'}</span>
          </div>
          <div style="display: flex !important; justify-content: space-between !important; margin-bottom: 6px !important;">
            <span style="color: rgba(255, 255, 255, 0.7) !important;">设备ID:</span>
            <span style="color: rgba(255, 255, 255, 0.95) !important; font-family: monospace !important;">${device.id}</span>
          </div>
          <div style="display: flex !important; justify-content: space-between !important;">
            <span style="color: rgba(255, 255, 255, 0.7) !important;">坐标:</span>
            <span style="color: rgba(255, 255, 255, 0.95) !important; font-family: monospace !important; font-size: 11px !important;">${device.longitude.toFixed(4)}, ${device.latitude.toFixed(4)}</span>
          </div>
        </div>
        ` : `
        <div class="info-window-details" style="
          margin-top: 12px !important;
          padding-top: 12px !important;
          border-top: 1px dashed ${color}60 !important;
          font-size: 12px !important;
          color: rgba(255, 255, 255, 0.85) !important;
          line-height: 1.6 !important;
        ">
          <div style="display: flex !important; justify-content: space-between !important;">
            <span style="color: rgba(255, 255, 255, 0.7) !important;">设备ID:</span>
            <span style="color: rgba(255, 255, 255, 0.95) !important; font-family: monospace !important;">${device.id}</span>
          </div>
        </div>
        `}
        
        <!-- 底部装饰 -->
        <div class="info-window-bottom-line" style="
          position: absolute !important;
          bottom: 0 !important;
          left: 0 !important;
          right: 0 !important;
          height: 2px !important;
          background: linear-gradient(90deg, transparent 0%, ${color}80 50%, transparent 100%) !important;
        "></div>
      </div>
      
      <style>
        @keyframes infoWindowFadeIn {
          from { opacity: 0; transform: translateY(5px); }
          to { opacity: 1; transform: translateY(0); }
        }
      </style>
    `;
    
    // 创建信息窗口 - 禁用默认动画
    const infoWindow = new window.AMap.InfoWindow({
      content: content,
      position: position,
      offset: new window.AMap.Pixel(0, -35),
      autoMove: false,
      avoid: []
    });
    
    // 打开信息窗口
    infoWindow.open(mapInstance, position);
    infoWindows.push(infoWindow);
    
    // 立即应用样式覆盖
    requestAnimationFrame(() => {
      const amapInfoWindow = document.querySelector('.amap-info-window');
      const amapInfoContent = document.querySelector('.amap-info-content');
      const amapInfoOuter = document.querySelector('.amap-info-outer');
      const amapInfoInner = document.querySelector('.amap-info-inner');
      
      [amapInfoWindow, amapInfoContent, amapInfoOuter, amapInfoInner].forEach(el => {
        if (el) {
          el.style.cssText = `
            background: transparent !important;
            border: none !important;
            box-shadow: none !important;
            padding: 0 !important;
            margin: 0 !important;
            overflow: visible !important;
          `;
        }
      });
      
      // 隐藏默认的尖角和关闭按钮
      const sharp = document.querySelector('.amap-info-sharp');
      const closeBtn = document.querySelector('.amap-info-close');
      if (sharp) sharp.style.display = 'none';
      if (closeBtn) closeBtn.style.display = 'none';
    });
    
  } catch (error) {
    console.error('显示信息窗口失败:', error);
  }
};

// 关闭所有信息窗口
const closeInfoWindows = () => {
  infoWindows.forEach(infoWindow => {
    infoWindow.close();
  });
  infoWindows = [];
};

// 调整地图视野
const fitMapBounds = () => {
  if (!mapInstance || props.devices.length === 0 || !window.AMap.Bounds) return;
  
  try {
    const bounds = new window.AMap.Bounds();
    props.devices.forEach(device => {
      // 检查坐标值是否有效
      if (device.longitude && device.latitude && !isNaN(device.longitude) && !isNaN(device.latitude)) {
        bounds.extend([device.longitude, device.latitude]);
      }
    });
    
    // 只有当边界有效时才调整视野
    mapInstance.setBounds(bounds, true);
  } catch (error) {
    console.error('调整地图视野失败:', error);
  }
};

// 切换地图类型
const switchMapType = (type) => {
  mapType.value = type;
  updateMapType();
};

// 监听窗口大小变化
const handleResize = () => {
  // 确保在浏览器环境中调用
  if (typeof window !== 'undefined' && mapInstance) {
    mapInstance.resize();
  }
};

onMounted(() => {
  // 如果已有设备数据,初始化地图
  if (props.devices.length > 0) {
    initMap();
  }
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  if (mapInstance) {
    mapInstance.destroy();
    mapInstance = null;
  }
  window.removeEventListener('resize', handleResize);
});

// 监听设备数据变化
watch(() => props.devices, () => {
  console.log('设备数据变化,重新初始化地图:', props.devices.length);
  initMap();
}, { deep: true });
</script>

<style scoped>
.map-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.map {
  width: 100%;
  height: 100%;
}

.map-controls {
  position: absolute;
  bottom: 30px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 12px;
  z-index: 100;
}

.control-btn {
  padding: 8px 16px;
  background: rgba(15, 23, 42, 0.8);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(16, 185, 129, 0.3);
  border-radius: 8px;
  color: white;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.control-btn:hover {
  background: rgba(16, 185, 129, 0.3);
  border-color: rgba(16, 185, 129, 0.5);
  box-shadow: 0 0 15px rgba(16, 185, 129, 0.4);
}

.control-btn.active {
  background: rgba(16, 185, 129, 0.5);
  border-color: rgba(16, 185, 129, 0.8);
  box-shadow: 0 0 20px rgba(16, 185, 129, 0.6);
}

/* 设备标记样式 */
.device-marker {
  position: relative;
  width: 40px;
  height: 40px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.device-marker:hover {
  transform: scale(1.1);
}

.marker-icon {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3), 0 0 15px rgba(255, 255, 255, 0.2);
  border: 2px solid rgba(255, 255, 255, 0.5);
}

.marker-alarm {
  position: absolute;
  top: -5px;
  right: -5px;
  font-size: 16px;
  animation: markerAlarmPulse 1s ease-in-out infinite;
}

@keyframes markerAlarmPulse {
  0%, 100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.2);
    opacity: 0.8;
  }
}

/* 设备详情卡片样式 - 正常状态 */
.device-detail-card.normal {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 420px;
  background: linear-gradient(135deg, rgba(15, 40, 25, 0.98) 0%, rgba(5, 20, 10, 0.98) 100%);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(16, 185, 129, 0.5);
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 30px rgba(16, 185, 129, 0.3), inset 0 0 20px rgba(16, 185, 129, 0.1);
  z-index: 1000;
  animation: cardGlowNormal 1.5s ease-in-out infinite alternate;
}

@keyframes cardGlowNormal {
  0% {
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(16, 185, 129, 0.2), inset 0 0 15px rgba(16, 185, 129, 0.05);
  }
  100% {
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 40px rgba(16, 185, 129, 0.5), inset 0 0 25px rgba(16, 185, 129, 0.15);
  }
}

/* 设备详情卡片样式 - 异常状态 */
.device-detail-card.alarm {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 420px;
  background: linear-gradient(135deg, rgba(40, 15, 25, 0.98) 0%, rgba(20, 5, 10, 0.98) 100%);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(239, 68, 68, 0.5);
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 30px rgba(239, 68, 68, 0.3), inset 0 0 20px rgba(239, 68, 68, 0.1);
  z-index: 1000;
  animation: cardGlowAlarm 1.5s ease-in-out infinite alternate;
}

@keyframes cardGlowAlarm {
  0% {
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(239, 68, 68, 0.2), inset 0 0 15px rgba(239, 68, 68, 0.05);
  }
  100% {
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 40px rgba(239, 68, 68, 0.5), inset 0 0 25px rgba(239, 68, 68, 0.15);
  }
}

.card-header {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 20px 24px;
  border-bottom: 1px solid rgba(239, 68, 68, 0.3);
}

.device-icon {
  width: 48px;
  height: 48px;
  border-radius: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  color: white;
  box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}

.device-title {
  flex: 1;
}

.device-title h3 {
  font-size: 18px;
  font-weight: 700;
  color: #ffffff;
  margin: 0 0 4px 0;
}

.device-id {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.6);
}

.close-btn {
  width: 32px;
  height: 32px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgba(255, 255, 255, 0.8);
  cursor: pointer;
  transition: all 0.3s ease;
}

.close-btn:hover {
  background: rgba(239, 68, 68, 0.3);
  border-color: rgba(239, 68, 68, 0.5);
  color: #ffffff;
  transform: rotate(90deg);
}

.card-content {
  padding: 20px 24px;
}

/* 监控设备播放窗口 */
.camera-preview {
  margin-bottom: 16px;
}

.camera-frame {
  border: 2px solid rgba(239, 68, 68, 0.5);
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}

.camera-screen {
  height: 150px;
  background: linear-gradient(135deg, rgba(0, 0, 0, 0.8) 0%, rgba(30, 41, 59, 0.8) 100%);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: 12px;
}

.camera-live-indicator {
  display: flex;
  align-items: center;
  gap: 6px;
  color: #ef4444;
  font-size: 12px;
  font-weight: 700;
}

.live-dot {
  width: 8px;
  height: 8px;
  background: #ef4444;
  border-radius: 50%;
  animation: livePulse 1.5s ease-in-out infinite;
}

@keyframes livePulse {
  0%, 100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(1.2);
  }
}

.camera-timestamp {
  color: rgba(255, 255, 255, 0.8);
  font-size: 14px;
  font-family: monospace;
  text-align: right;
}

/* 设备状态 */
.status-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}

.status-label {
  font-size: 14px;
  color: rgba(255, 255, 255, 0.7);
}

.status-value {
  padding: 4px 12px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: 600;
}

.status-value.normal {
  background: rgba(16, 185, 129, 0.2);
  color: #10b981;
}

.status-value.warning {
  background: rgba(245, 158, 11, 0.2);
  color: #f59e0b;
}

.status-value.error {
  background: rgba(239, 68, 68, 0.2);
  color: #ef4444;
}

.status-value.standby {
  background: rgba(107, 114, 128, 0.2);
  color: #9ca3af;
}

/* 告警状态 */
.alarm-status {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  background: rgba(239, 68, 68, 0.15);
  border: 1px solid rgba(239, 68, 68, 0.5);
  border-radius: 8px;
  margin-bottom: 16px;
  animation: alarmPulse 1.5s ease-in-out infinite;
}

@keyframes alarmPulse {
  0%, 100% {
    box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
  }
  50% {
    box-shadow: 0 0 20px rgba(239, 68, 68, 0.6);
  }
}

.alarm-icon {
  font-size: 18px;
}

.alarm-text {
  font-size: 14px;
  font-weight: 600;
  color: #ef4444;
}

/* 设备信息表格 */
.device-info-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  margin-bottom: 16px;
}

.info-item {
  background: rgba(15, 23, 42, 0.8);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  padding: 12px;
}

.info-label {
  display: block;
  font-size: 11px;
  color: rgba(255, 255, 255, 0.5);
  text-transform: uppercase;
  letter-spacing: 1px;
  margin-bottom: 4px;
}

.info-value {
  font-size: 15px;
  font-weight: 600;
  color: #ffffff;
}

.info-value.normal {
  color: #10b981;
}

.info-value.warning {
  color: #f59e0b;
}

.info-value.error {
  color: #ef4444;
}

/* 位置信息 */
.location-info {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  background: rgba(16, 185, 129, 0.1);
  border: 1px solid rgba(16, 185, 129, 0.3);
  border-radius: 8px;
}

.location-icon {
  font-size: 16px;
}

.location-text {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.8);
}

/* 卡片操作按钮 */
.card-actions {
  display: flex;
  gap: 12px;
  padding: 16px 24px;
  border-top: 1px solid rgba(239, 68, 68, 0.2);
}

.action-btn {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 12px 16px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s ease;
}

.action-btn.primary {
  background: linear-gradient(135deg, rgba(239, 68, 68, 0.9) 0%, rgba(220, 38, 38, 0.9) 100%);
  border: 1px solid rgba(239, 68, 68, 0.5);
  color: white;
}

.action-btn.primary:hover {
  background: linear-gradient(135deg, rgba(239, 68, 68, 1) 0%, rgba(220, 38, 38, 1) 100%);
  box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
  transform: translateY(-2px);
}

.action-btn.secondary {
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  color: rgba(255, 255, 255, 0.9);
}

.action-btn.secondary:hover {
  background: rgba(255, 255, 255, 0.15);
  border-color: rgba(255, 255, 255, 0.3);
  transform: translateY(-2px);
}

.action-btn.camera {
  background: linear-gradient(135deg, rgba(236, 72, 153, 0.9) 0%, rgba(219, 39, 119, 0.9) 100%);
  border: 1px solid rgba(236, 72, 153, 0.5);
  color: white;
}

.action-btn.camera:hover {
  background: linear-gradient(135deg, rgba(236, 72, 153, 1) 0%, rgba(219, 39, 119, 1) 100%);
  box-shadow: 0 4px 15px rgba(236, 72, 153, 0.4);
  transform: translateY(-2px);
}

/* 卡片过渡动画 */
.card-slide-enter-active,
.card-slide-leave-active {
  transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}

.card-slide-enter-from,
.card-slide-leave-to {
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.9);
}

/* 响应式设计 */
@media (max-width: 768px) {
  .device-detail-card {
    width: 90%;
    max-width: 400px;
  }
  
  .card-actions {
    flex-direction: column;
  }
  
  .action-btn {
    width: 100%;
  }
}
</style>

[MapParcelDrawer.vue 完整代码]↓

<template>
  <div class="parcel-drawer-container">
    <!-- 底部绘制地块按钮 -->
    <div class="draw-button-container">
      <button class="draw-button" @click="toggleMenu">
        <div class="draw-button-icon">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
          </svg>
        </div>
        <span class="draw-button-text">绘制地块</span>
      </button>

      <!-- 绘制 & 样式工具面板 -->
      <transition name="menu-slide">
        <div v-if="isMenuVisible" class="input-card">
          <div class="input-card-header">
            <h4>绘制 & 样式工具</h4>
            <button class="close-btn" @click="closeMenu">
              <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <line x1="18" y1="6" x2="6" y2="18"></line>
                <line x1="6" y1="6" x2="18" y2="18"></line>
              </svg>
            </button>
          </div>

          <!-- 预设样式选择 -->
          <div class="preset-buttons">
            <h5>预设样式</h5>
            <button class="btn" @click="setStylePreset('default')">默认样式</button>
            <button class="btn" @click="setStylePreset('red')">红色填充</button>
            <button class="btn" @click="setStylePreset('green')">绿色虚线</button>
            <button class="btn" @click="setStylePreset('blue')">蓝色粗边</button>
          </div>

          <div class="draw-buttons">
            <button class="btn" @click="draw('polyline')">绘制线段</button>
            <button class="btn" @click="draw('polygon')">绘制多边形</button>
            <button class="btn" @click="draw('rectangle')">绘制矩形</button>
            <button class="btn" @click="draw('circle')">绘制圆形</button>
          </div>

          <div class="stop-buttons">
            <button class="btn" @click="stopDraw(false)">停止绘制(保留)</button>
            <button class="btn" @click="stopDraw(true)">停止并清除所有</button>
          </div>

          <div class="edit-buttons">
            <button class="btn" @click="enterEditModeAll()">进入编辑模式(全部)</button>
            <button class="btn" @click="exitEditMode()">关闭编辑模式</button>
          </div>

          <div class="import-export-buttons">
            <button class="btn" @click="exportOverlays()">导出为 JSON</button>
            <button class="btn" @click="document.getElementById('importFile').click()">导入 JSON 文件</button>
            <input type="file" id="importFile" accept=".json" style="display:none;" @change="importOverlays($event)">
          </div>
        </div>
      </transition>
    </div>

    <!-- 图形列表面板 -->
    <div id="overlayList">
      <div id="overlayListHeader">
        <span>当前图形列表</span>
        <span id="overlayCount">0 个</span>
      </div>
      <div id="overlayListBody"></div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  map: {
    type: Object,
    required: true
  },
  AMap: {
    type: Object,
    required: true
  }
});

const emit = defineEmits(['parcel-created', 'drawing-cancelled', 'edit-parcel']);

// 绘制相关变量
let mouseTool = null;
let drawnOverlays = [];
let editors = [];

// 当前样式预设
const currentStyle = ref('default');

// 菜单展开状态
const isMenuVisible = ref(false);

// 切换菜单显示
const toggleMenu = () => {
  isMenuVisible.value = !isMenuVisible.value;
};

// 关闭菜单
const closeMenu = () => {
  isMenuVisible.value = false;
};

const stylePresets = {
  default: {
    strokeColor: "#FF33FF",
    strokeWeight: 6,
    strokeOpacity: 1,
    fillColor: '#1791fc',
    fillOpacity: 0.4,
    strokeStyle: 'solid'
  },
  red: {
    strokeColor: "#f5222d",
    strokeWeight: 5,
    strokeOpacity: 0.9,
    fillColor: '#ff4d4f',
    fillOpacity: 0.55,
    strokeStyle: 'solid'
  },
  green: {
    strokeColor: "#52c41a",
    strokeWeight: 5,
    strokeOpacity: 1,
    fillColor: '#73d13d',
    fillOpacity: 0.35,
    strokeStyle: 'dashed',
    strokeDasharray: [10, 5]
  },
  blue: {
    strokeColor: "#2f54eb",
    strokeWeight: 9,
    strokeOpacity: 0.9,
    fillColor: '#597ef7',
    fillOpacity: 0.45,
    strokeStyle: 'solid'
  }
};

// 获取地图实例 - 优先使用window对象,与HTML示例保持一致
const getMapInstance = () => {
  // 优先从window获取地图实例(由MapContainer.vue设置)
  if (typeof window !== 'undefined' && window._amapMapInstance) {
    return window._amapMapInstance;
  }
  // 回退到props.map
  return props.map;
};

// 初始化鼠标工具
const initMouseTool = async () => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  if (!AMap || !map) {
    console.error('AMap或map未就绪');
    return;
  }

  // 等待 MouseTool 插件加载完成
  await new Promise((resolve) => {
    if (AMap.MouseTool) {
      resolve();
    } else {
      AMap.plugin(['AMap.MouseTool'], () => {
        resolve();
      });
    }
  });

  // 使用与HTML示例相同的方式创建mouseTool
  mouseTool = new AMap.MouseTool(map);

  // 监听绘制完成事件
  mouseTool.on('draw', function(e) {
    var obj = e.obj;
    if (obj) {
      drawnOverlays.push(obj);
      updateOverlayList();
    }
  });
  
  console.log('MouseTool初始化成功');
};

// 设置样式预设
const setStylePreset = (key) => {
  if (stylePresets[key]) {
    currentStyle.value = key;
    alert(`已切换为:${key === 'default' ? '默认样式' : key + '样式'}`);
  }
};

// 绘制图形
const draw = async (type) => {
  if (!mouseTool) {
    await initMouseTool();
  }
  
  // 确保 mouseTool 已初始化
  if (!mouseTool) {
    console.error('MouseTool 初始化失败');
    return;
  }
  
  // 使用与HTML示例相同的方式关闭绘制
  mouseTool.close(false);

  let options = { ...stylePresets[currentStyle.value] };

  if (type === 'polyline') {
    options.strokeColor = "#3366FF"; // 线段保持区别
    mouseTool.polyline(options);
  } else if (type === 'polygon') {
    mouseTool.polygon(options);
  } else if (type === 'rectangle') {
    mouseTool.rectangle(options);
  } else if (type === 'circle') {
    mouseTool.circle(options);
  }
  
  console.log('开始绘制:', type);
};

// 停止绘制
const stopDraw = async (shouldClear = false) => {
  // 确保 mouseTool 已初始化
  if (!mouseTool) {
    await initMouseTool();
  }
  
  if (mouseTool) {
    // 使用与HTML示例相同的方式关闭绘制
    mouseTool.close(shouldClear);
    console.log('停止绘制, shouldClear:', shouldClear);
    
    if (shouldClear) {
      const map = getMapInstance();
      if (map) {
        map.remove(drawnOverlays);
      }
      drawnOverlays = [];
      updateOverlayList();
    }
  } else {
    console.error('MouseTool未初始化,无法停止绘制');
  }
};

// 更新图形列表
const updateOverlayList = () => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  // 防御清理
  drawnOverlays = drawnOverlays.filter(ov => ov && (map && map.hasOverlay ? map.hasOverlay(ov) : true));

  const body = document.getElementById('overlayListBody');
  const count = document.getElementById('overlayCount');

  if (body && count) {
    body.innerHTML = '';
    count.textContent = drawnOverlays.length + ' 个';

    drawnOverlays.forEach((overlay, index) => {
      const typeName = overlay instanceof AMap.Circle ? '圆形' :
                      overlay instanceof AMap.Rectangle ? '矩形' :
                      overlay instanceof AMap.Polygon ? '多边形' : '线段';

      const item = document.createElement('div');
      item.className = 'overlay-item';
      
      const typeSpan = document.createElement('span');
      typeSpan.textContent = `#${index+1} ${typeName}`;
      item.appendChild(typeSpan);
      
      const deleteSpan = document.createElement('span');
      deleteSpan.className = 'delete-btn';
      deleteSpan.textContent = '×';
      deleteSpan.addEventListener('click', (e) => {
        e.stopPropagation();
        deleteOverlay(index);
      });
      item.appendChild(deleteSpan);

      item.addEventListener('click', (e) => {
        if (e.target !== deleteSpan) {
          editSingleOverlay(overlay);
        }
      });

      body.appendChild(item);
    });
  }
};

// 删除图形
const deleteOverlay = (index) => {
  if (index < 0 || index >= drawnOverlays.length) return;

  const overlay = drawnOverlays[index];
  const map = getMapInstance();

  try {
    exitEditMode();

    if (overlay.setMap) overlay.setMap(null);
    if (map && map.hasOverlay && map.hasOverlay(overlay)) map.remove(overlay);

    drawnOverlays.splice(index, 1);
    updateOverlayList();

  } catch (err) {
    console.error('删除失败:', err);
    drawnOverlays.splice(index, 1);
    updateOverlayList();
  }
};

// 编辑单个图形
const editSingleOverlay = (overlay) => {
  exitEditMode();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;

  // 确保编辑器插件加载
  if (!AMap.PolygonEditor || !AMap.CircleEditor) {
    AMap.plugin(['AMap.PolygonEditor', 'AMap.CircleEditor'], () => {
      createEditor(overlay);
    });
  } else {
    createEditor(overlay);
  }
};

// 创建编辑器
const createEditor = (overlay) => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  let editor = null;
  if (overlay instanceof AMap.Polygon || overlay instanceof AMap.Rectangle) {
    editor = new AMap.PolygonEditor(map, overlay);
  } else if (overlay instanceof AMap.Circle) {
    editor = new AMap.CircleEditor(map, overlay);
  }

  if (editor) {
    editor.open();
    editors = [editor];
  }
};

// 进入全部编辑模式
const enterEditModeAll = async () => {
  await stopDraw(false);
  exitEditMode();
  
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;

  // 确保编辑器插件加载
  if (!AMap.PolygonEditor || !AMap.CircleEditor) {
    AMap.plugin(['AMap.PolygonEditor', 'AMap.CircleEditor'], () => {
      createEditorsForAll();
    });
  } else {
    createEditorsForAll();
  }
};

// 为所有图形创建编辑器
const createEditorsForAll = () => {
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;
  
  drawnOverlays.forEach(overlay => {
    let editor = null;
    if (overlay instanceof AMap.Polygon || overlay instanceof AMap.Rectangle) {
      editor = new AMap.PolygonEditor(map, overlay);
    } else if (overlay instanceof AMap.Circle) {
      editor = new AMap.CircleEditor(map, overlay);
    }
    if (editor) {
      editor.open();
      editors.push(editor);
    }
  });
};

// 退出编辑模式
const exitEditMode = () => {
  editors.forEach(ed => ed && ed.close && ed.close());
  editors = [];
};

// 导出 JSON
const exportOverlays = () => {
  if (drawnOverlays.length === 0) {
    alert('当前没有图形可导出');
    return;
  }
  
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;

  const data = drawnOverlays.map(overlay => {
    let result = { type: '' };
    const safeOptions = {
      strokeColor: overlay.getOptions().strokeColor,
      strokeOpacity: overlay.getOptions().strokeOpacity,
      strokeWeight: overlay.getOptions().strokeWeight,
      fillColor: overlay.getOptions().fillColor,
      fillOpacity: overlay.getOptions().fillOpacity,
      strokeStyle: overlay.getOptions().strokeStyle,
      strokeDasharray: overlay.getOptions().strokeDasharray
    };

    if (overlay instanceof AMap.Circle) {
      result.type = 'circle';
      result.center = overlay.getCenter().toArray();
      result.radius = overlay.getRadius();
    } else if (overlay.getPath) {
      result.type = overlay instanceof AMap.Rectangle ? 'rectangle' :
                    overlay instanceof AMap.Polygon ? 'polygon' : 'polyline';
      result.path = overlay.getPath().map(lnglat => [lnglat.lng, lnglat.lat]);
    }
    result.options = safeOptions;
    return result;
  });

  const json = JSON.stringify(data, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'map_overlays_' + new Date().toISOString().slice(0,10) + '.json';
  a.click();
  URL.revokeObjectURL(url);
};

// 导入 JSON
const importOverlays = (event) => {
  const file = event.target.files[0];
  if (!file) return;
  
  const map = getMapInstance();
  const AMap = (typeof window !== 'undefined' && window.AMap) ? window.AMap : props.AMap;

  const reader = new FileReader();
  reader.onload = function(e) {
    try {
      const data = JSON.parse(e.target.result);
      if (!Array.isArray(data)) throw new Error('无效格式');

      let importedCount = 0;
      data.forEach(item => {
        let overlay;
        try {
          if (item.type === 'circle') {
            overlay = new AMap.Circle({
              center: item.center,
              radius: item.radius,
              ...item.options
            });
          } else if (item.type === 'polygon' || item.type === 'rectangle') {
            overlay = new AMap.Polygon({
              path: item.path,
              ...item.options
            });
          } else if (item.type === 'polyline') {
            overlay = new AMap.Polyline({
              path: item.path,
              ...item.options
            });
          }

          if (overlay) {
            map.add(overlay);
            drawnOverlays.push(overlay);
            importedCount++;
          }
        } catch (err) {
          console.error('创建覆盖物失败:', err);
        }
      });

      updateOverlayList();
      alert(`成功导入 ${importedCount} 个图形`);
    } catch (err) {
      alert('导入失败:' + err.message);
    }
  };
  reader.readAsText(file);
  // 清空文件输入
  event.target.value = '';
};

// 组件挂载
onMounted(() => {
  if (props.map && props.AMap) {
    initMouseTool();
    // 初始化时更新列表
    updateOverlayList();
  }
});

// 组件卸载
onUnmounted(() => {
  if (mouseTool) {
    mouseTool.close();
  }
  exitEditMode();
});

// 暴露方法
defineExpose({
  draw,
  stopDraw,
  enterEditModeAll,
  exitEditMode,
  exportOverlays,
  importOverlays,
  deleteOverlay,
  updateOverlayList
});
</script>

<style scoped>
/* 容器样式 */
.parcel-drawer-container {
  position: relative;
  width: 100%;
  height: 100%;
}

/* 底部绘制地块按钮容器 */
.draw-button-container {
  position: absolute;
  bottom: 0;
  left: 30px;
  z-index: 1000;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* 绘制地块按钮 */
.draw-button {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 16px 24px;
  background: linear-gradient(135deg, rgba(0, 31, 63, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(16, 185, 129, 0.4);
  border-radius: 16px 16px 0 0;
  box-shadow: 
    0 -10px 30px rgba(0, 0, 0, 0.4),
    0 0 20px rgba(16, 185, 129, 0.2);
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
  overflow: hidden;
}

.draw-button:hover {
  transform: translateY(-2px);
  border-color: rgba(16, 185, 129, 0.7);
  box-shadow: 
    0 -12px 40px rgba(0, 0, 0, 0.5),
    0 0 30px rgba(16, 185, 129, 0.3);
}

.draw-button-icon {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, rgba(16, 185, 129, 0.3) 0%, rgba(16, 185, 129, 0.1) 100%);
  border: 1px solid rgba(16, 185, 129, 0.5);
  border-radius: 8px;
  color: #10b981;
  transition: all 0.3s ease;
}

.draw-button:hover .draw-button-icon {
  background: linear-gradient(135deg, rgba(16, 185, 129, 0.5) 0%, rgba(16, 185, 129, 0.2) 100%);
  box-shadow: 0 0 15px rgba(16, 185, 129, 0.5);
  transform: scale(1.1);
}

.draw-button-text {
  font-size: 16px;
  font-weight: 600;
  color: #ffffff;
  text-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
  letter-spacing: 1px;
}

/* 输入卡片样式 */
.input-card {
  width: 280px;
  background: linear-gradient(135deg, rgba(0, 31, 63, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(16, 185, 129, 0.4);
  border-radius: 16px;
  box-shadow: 
    0 20px 60px rgba(0, 0, 0, 0.5),
    0 0 40px rgba(16, 185, 129, 0.2);
  overflow: hidden;
  margin-bottom: 12px;
  animation: menuSlideIn 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}

@keyframes menuSlideIn {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.input-card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, transparent 100%);
  border-bottom: 1px solid rgba(16, 185, 129, 0.3);
}

.input-card-header h4 {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #ffffff;
  text-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
}

.close-btn {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 8px;
  color: rgba(255, 255, 255, 0.7);
  cursor: pointer;
  transition: all 0.3s ease;
}

.close-btn:hover {
  background: rgba(239, 68, 68, 0.2);
  border-color: rgba(239, 68, 68, 0.5);
  color: #ef4444;
  transform: rotate(90deg);
}

/* 预设样式按钮 */
.preset-buttons {
  padding: 16px 20px;
  border-bottom: 1px solid rgba(16, 185, 129, 0.2);
}

.preset-buttons h5 {
  margin: 0 0 12px 0;
  font-size: 14px;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.9);
  text-align: center;
}

/* 按钮组通用样式 */
.draw-buttons,
.stop-buttons,
.edit-buttons,
.import-export-buttons {
  padding: 16px 20px;
  border-bottom: 1px solid rgba(16, 185, 129, 0.2);
}

.draw-buttons:last-child,
.stop-buttons:last-child,
.edit-buttons:last-child,
.import-export-buttons:last-child {
  border-bottom: none;
}

/* 按钮样式 */
.btn {
  width: 100%;
  padding: 10px 16px;
  margin-bottom: 8px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(16, 185, 129, 0.3);
  border-radius: 8px;
  color: #ffffff;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.3s ease;
  backdrop-filter: blur(10px);
}

.btn:hover {
  background: rgba(16, 185, 129, 0.2);
  border-color: rgba(16, 185, 129, 0.6);
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}

/* 特定按钮样式 */
.preset-buttons .btn:nth-child(2) {
  background: rgba(24, 144, 255, 0.2);
  border-color: rgba(24, 144, 255, 0.5);
}

.preset-buttons .btn:nth-child(3) {
  background: rgba(245, 34, 45, 0.2);
  border-color: rgba(245, 34, 45, 0.5);
}

.preset-buttons .btn:nth-child(4) {
  background: rgba(82, 196, 26, 0.2);
  border-color: rgba(82, 196, 26, 0.5);
}

.preset-buttons .btn:nth-child(5) {
  background: rgba(47, 84, 235, 0.2);
  border-color: rgba(47, 84, 235, 0.5);
}

.stop-buttons .btn:nth-child(1) {
  background: rgba(255, 77, 79, 0.2);
  border-color: rgba(255, 77, 79, 0.5);
}

.stop-buttons .btn:nth-child(2) {
  background: rgba(250, 140, 22, 0.2);
  border-color: rgba(250, 140, 22, 0.5);
}

.edit-buttons .btn:nth-child(1) {
  background: rgba(82, 196, 26, 0.2);
  border-color: rgba(82, 196, 26, 0.5);
}

.edit-buttons .btn:nth-child(2) {
  background: rgba(24, 144, 255, 0.2);
  border-color: rgba(24, 144, 255, 0.5);
}

.import-export-buttons .btn:nth-child(1) {
  background: rgba(114, 46, 209, 0.2);
  border-color: rgba(114, 46, 209, 0.5);
}

.import-export-buttons .btn:nth-child(2) {
  background: rgba(19, 194, 194, 0.2);
  border-color: rgba(19, 194, 194, 0.5);
}

/* 图形列表面板 */
#overlayList {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 320px;
  max-height: 70vh;
  background: linear-gradient(135deg, rgba(0, 31, 63, 0.98) 0%, rgba(15, 23, 42, 0.98) 100%);
  backdrop-filter: blur(20px);
  border: 1px solid rgba(16, 185, 129, 0.4);
  border-radius: 16px;
  box-shadow: 
    0 20px 60px rgba(0, 0, 0, 0.5),
    0 0 40px rgba(16, 185, 129, 0.2);
  z-index: 1000;
  overflow: hidden;
  font-size: 14px;
}

#overlayListHeader {
  padding: 16px 20px;
  background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, transparent 100%);
  border-bottom: 1px solid rgba(16, 185, 129, 0.3);
  font-weight: 600;
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: #ffffff;
  text-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
}

#overlayListBody {
  max-height: calc(70vh - 60px);
  overflow-y: auto;
  backdrop-filter: blur(10px);
}

.overlay-item {
  padding: 12px 20px;
  border-bottom: 1px solid rgba(16, 185, 129, 0.2);
  display: flex;
  justify-content: space-between;
  align-items: center;
  cursor: pointer;
  transition: all 0.3s ease;
  color: rgba(255, 255, 255, 0.9);
}

.overlay-item:hover {
  background: rgba(16, 185, 129, 0.1);
  transform: translateX(4px);
}

.overlay-item .delete-btn {
  color: #ff4d4f;
  font-weight: bold;
  cursor: pointer;
  font-size: 18px;
  padding: 0 8px;
  transition: all 0.3s ease;
}

.overlay-item .delete-btn:hover {
  color: #cf000f;
  transform: scale(1.2);
}

/* 滚动条样式 */
#overlayListBody::-webkit-scrollbar {
  width: 6px;
}

#overlayListBody::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, 0.1);
  border-radius: 3px;
}

#overlayListBody::-webkit-scrollbar-thumb {
  background: rgba(16, 185, 129, 0.5);
  border-radius: 3px;
}

#overlayListBody::-webkit-scrollbar-thumb:hover {
  background: rgba(16, 185, 129, 0.8);
}

/* 过渡动画 */
.menu-slide-enter-active,
.menu-slide-leave-active {
  transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
}

.menu-slide-enter-from,
.menu-slide-leave-to {
  opacity: 0;
  transform: translateY(20px) scale(0.95);
}

/* 装饰元素 */
.input-card::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: radial-gradient(circle at 50% 0%, rgba(16, 185, 129, 0.1) 0%, transparent 50%);
  pointer-events: none;
}

/* 响应式设计 */
@media (max-width: 768px) {
  .draw-button-container {
    left: 20px;
  }
  
  .draw-button {
    padding: 14px 20px;
  }
  
  .input-card {
    width: 260px;
  }
  
  #overlayList {
    width: 280px;
    max-height: 60vh;
  }
}
</style>

[HTML 示例代码]↓

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="initial-scale=1.0, user-scalable=no, width=device-width">
    <style>
    html, body, #container { width: 100%; height: 100%; margin:0; padding:0; }
    #overlayList {
        position: absolute;
        top: 10px; right: 10px;
        width: 300px;
        max-height: 70vh;
        background: white;
        border-radius: 8px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        z-index: 1000;
        overflow: hidden;
        font-size: 14px;
    }
    #overlayListHeader {
        padding: 10px 16px;
        background: #f8f9fa;
        border-bottom: 1px solid #dee2e6;
        font-weight: bold;
        display: flex;
        justify-content: space-between;
        align-items: center;
    }
    #overlayListBody {
        max-height: calc(70vh - 50px);
        overflow-y: auto;
    }
    .overlay-item {
        padding: 10px 16px;
        border-bottom: 1px solid #eee;
        display: flex;
        justify-content: space-between;
        align-items: center;
        cursor: pointer;
    }
    .overlay-item:hover {
        background: #f0f8ff;
    }
    .overlay-item .delete-btn {
        color: #ff4d4f;
        font-weight: bold;
        cursor: pointer;
        font-size: 18px;
        padding: 0 8px;
    }
    .overlay-item .delete-btn:hover {
        color: #cf000f;
    }
    .preset-buttons button {
        margin: 4px 0;
    }
    </style>
    <title>高德地图 - 绘制 + 列表管理 + 样式 + 导入导出</title>
    <link rel="stylesheet" href="https://a.amap.com/jsapi_demos/static/demo-center/css/demo-center.css" />
    <script src="https://webapi.amap.com/maps?v=2.0&key=您申请的key值&plugin=AMap.MouseTool,AMap.PolygonEditor,AMap.CircleEditor"></script>
    <script src="https://a.amap.com/jsapi_demos/static/demo-center/js/demoutils.js"></script>
</head>
<body>
<div id="container"></div>

<!-- 图形列表面板 -->
<div id="overlayList">
    <div id="overlayListHeader">
        <span>当前图形列表</span>
        <span id="overlayCount">0 个</span>
    </div>
    <div id="overlayListBody"></div>
</div>

<div class="input-card" style="width: 240px; padding: 12px; position: absolute; top: 10px; left: 10px; z-index: 999; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);">
   <h4 style="margin: 0 0 12px 0; font-weight: 600; text-align: center;">绘制 & 样式工具</h4>

   <!-- 预设样式选择 -->
   <div class="preset-buttons" style="margin-bottom: 12px;">
     <h5 style="margin: 0 0 6px 0; font-size: 14px; text-align: center;">预设样式</h5>
     <button class="btn" onclick="setStylePreset('default')" style="width:100%; margin:2px 0; background:#1890ff;color:white;">默认样式</button>
     <button class="btn" onclick="setStylePreset('red')"    style="width:100%; margin:2px 0; background:#f5222d;color:white;">红色填充</button>
     <button class="btn" onclick="setStylePreset('green')"  style="width:100%; margin:2px 0; background:#52c41a;color:white;">绿色虚线</button>
     <button class="btn" onclick="setStylePreset('blue')"   style="width:100%; margin:2px 0; background:#2f54eb;color:white;">蓝色粗边</button>
   </div>

   <button class="btn" onclick="draw('polyline')"   style="margin-bottom:6px;width:100%;">绘制线段</button>
   <button class="btn" onclick="draw('polygon')"    style="margin-bottom:6px;width:100%;">绘制多边形</button>
   <button class="btn" onclick="draw('rectangle')"  style="margin-bottom:6px;width:100%;">绘制矩形</button>
   <button class="btn" onclick="draw('circle')"     style="margin-bottom:12px;width:100%;">绘制圆形</button>

   <button class="btn" onclick="stopDraw(false)" style="margin-bottom:6px;width:100%;background:#ff4d4f;color:white;">停止绘制(保留)</button>
   <button class="btn" onclick="stopDraw(true)"  style="margin-bottom:12px;width:100%;background:#fa8c16;color:white;">停止并清除所有</button>

   <button class="btn" onclick="enterEditModeAll()" style="margin-bottom:6px;width:100%;background:#52c41a;color:white;">进入编辑模式(全部)</button>
   <button class="btn" onclick="exitEditMode()"     style="margin-bottom:12px;width:100%;background:#1890ff;color:white;">关闭编辑模式</button>

   <button class="btn" onclick="exportOverlays()"   style="margin:6px 0;width:100%;background:#722ed1;color:white;">导出为 JSON</button>
   <button class="btn" onclick="document.getElementById('importFile').click()" style="width:100%;background:#13c2c2;color:white;">导入 JSON 文件</button>
   <input type="file" id="importFile" accept=".json" style="display:none;" onchange="importOverlays(event)">
</div>

<script type="text/javascript">
    var map = new AMap.Map("container", {
        center: [116.434381, 39.898515],
        zoom: 14,
        resizeEnable: true
    });

    var mouseTool = new AMap.MouseTool(map);
    var drawnOverlays = [];
    var editors = [];

    // 当前样式预设
    var currentStyle = 'default';

    const stylePresets = {
        default: {
            strokeColor: "#FF33FF",
            strokeWeight: 6,
            strokeOpacity: 1,
            fillColor: '#1791fc',
            fillOpacity: 0.4,
            strokeStyle: 'solid'
        },
        red: {
            strokeColor: "#f5222d",
            strokeWeight: 5,
            strokeOpacity: 0.9,
            fillColor: '#ff4d4f',
            fillOpacity: 0.55,
            strokeStyle: 'solid'
        },
        green: {
            strokeColor: "#52c41a",
            strokeWeight: 5,
            strokeOpacity: 1,
            fillColor: '#73d13d',
            fillOpacity: 0.35,
            strokeStyle: 'dashed',
            strokeDasharray: [10, 5]
        },
        blue: {
            strokeColor: "#2f54eb",
            strokeWeight: 9,
            strokeOpacity: 0.9,
            fillColor: '#597ef7',
            fillOpacity: 0.45,
            strokeStyle: 'solid'
        }
    };

    function setStylePreset(key) {
        if (stylePresets[key]) {
            currentStyle = key;
            alert(`已切换为:${key === 'default' ? '默认样式' : key + '样式'}`);
        }
    }

    function draw(type) {
        mouseTool.close(false);

        let options = { ...stylePresets[currentStyle] };

        if (type === 'polyline') {
            options.strokeColor = "#3366FF"; // 线段保持区别
            mouseTool.polyline(options);
        } else if (type === 'polygon') {
            mouseTool.polygon(options);
        } else if (type === 'rectangle') {
            mouseTool.rectangle(options);
        } else if (type === 'circle') {
            mouseTool.circle(options);
        }
    }

    function stopDraw(shouldClear = false) {
        mouseTool.close(shouldClear);
        if (shouldClear) {
            map.remove(drawnOverlays);
            drawnOverlays = [];
            updateOverlayList();
        }
    }

    mouseTool.on('draw', function(e) {
        var obj = e.obj;
        if (obj) {
            drawnOverlays.push(obj);
            updateOverlayList();
        }
    });

    // 更新图形列表 + 防御清理
    function updateOverlayList() {
        drawnOverlays = drawnOverlays.filter(ov => ov && (map.hasOverlay ? map.hasOverlay(ov) : true));

        const body = document.getElementById('overlayListBody');
        const count = document.getElementById('overlayCount');

        body.innerHTML = '';
        count.textContent = drawnOverlays.length + ' 个';

        drawnOverlays.forEach((overlay, index) => {
            const typeName = overlay instanceof AMap.Circle ? '圆形' :
                            overlay instanceof AMap.Rectangle ? '矩形' :
                            overlay instanceof AMap.Polygon ? '多边形' : '线段';

            const item = document.createElement('div');
            item.className = 'overlay-item';
            item.innerHTML = `
                <span>#${index+1} ${typeName}</span>
                <span class="delete-btn" onclick="deleteOverlay(${index})">×</span>
            `;

            item.addEventListener('click', (e) => {
                if (e.target.className !== 'delete-btn') {
                    editSingleOverlay(overlay);
                }
            });

            body.appendChild(item);
        });
    }

    // 删除图形(加强版)
    function deleteOverlay(index) {
        if (index < 0 || index >= drawnOverlays.length) return;

        const overlay = drawnOverlays[index];

        try {
            exitEditMode();

            if (overlay.setMap) overlay.setMap(null);
            if (map.hasOverlay && map.hasOverlay(overlay)) map.remove(overlay);

            drawnOverlays.splice(index, 1);
            updateOverlayList();

        } catch (err) {
            console.error('删除失败:', err);
            drawnOverlays.splice(index, 1);
            updateOverlayList();
        }
    }

    // 编辑单个
    function editSingleOverlay(overlay) {
        exitEditMode();

        let editor = null;
        if (overlay instanceof AMap.Polygon || overlay instanceof AMap.Rectangle) {
            editor = new AMap.PolygonEditor(map, overlay);
        } else if (overlay instanceof AMap.Circle) {
            editor = new AMap.CircleEditor(map, overlay);
        }

        if (editor) {
            editor.open();
            editors = [editor];
        }
    }

    // 全部编辑
    function enterEditModeAll() {
        stopDraw(false);
        exitEditMode();

        drawnOverlays.forEach(overlay => {
            let editor = null;
            if (overlay instanceof AMap.Polygon || overlay instanceof AMap.Rectangle) {
                editor = new AMap.PolygonEditor(map, overlay);
            } else if (overlay instanceof AMap.Circle) {
                editor = new AMap.CircleEditor(map, overlay);
            }
            if (editor) {
                editor.open();
                editors.push(editor);
            }
        });
    }

    function exitEditMode() {
        editors.forEach(ed => ed && ed.close && ed.close());
        editors = [];
    }

    // 导出 JSON
    function exportOverlays() {
        if (drawnOverlays.length === 0) {
            alert('当前没有图形可导出');
            return;
        }

        const data = drawnOverlays.map(overlay => {
            let result = { type: '' };
            const safeOptions = {
                strokeColor: overlay.getOptions().strokeColor,
                strokeOpacity: overlay.getOptions().strokeOpacity,
                strokeWeight: overlay.getOptions().strokeWeight,
                fillColor: overlay.getOptions().fillColor,
                fillOpacity: overlay.getOptions().fillOpacity,
                strokeStyle: overlay.getOptions().strokeStyle,
                strokeDasharray: overlay.getOptions().strokeDasharray
            };

            if (overlay instanceof AMap.Circle) {
                result.type = 'circle';
                result.center = overlay.getCenter().toArray();
                result.radius = overlay.getRadius();
            } else if (overlay.getPath) {
                result.type = overlay instanceof AMap.Rectangle ? 'rectangle' :
                              overlay instanceof AMap.Polygon ? 'polygon' : 'polyline';
                result.path = overlay.getPath().map(lnglat => [lnglat.lng, lnglat.lat]);
            }
            result.options = safeOptions;
            return result;
        });

        const json = JSON.stringify(data, null, 2);
        const blob = new Blob([json], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'map_overlays_' + new Date().toISOString().slice(0,10) + '.json';
        a.click();
        URL.revokeObjectURL(url);
    }

    // 导入 JSON
    function importOverlays(event) {
        const file = event.target.files[0];
        if (!file) return;

        const reader = new FileReader();
        reader.onload = function(e) {
            try {
                const data = JSON.parse(e.target.result);
                if (!Array.isArray(data)) throw new Error('无效格式');

                data.forEach(item => {
                    let overlay;
                    if (item.type === 'circle') {
                        overlay = new AMap.Circle({
                            center: item.center,
                            radius: item.radius,
                            ...item.options
                        });
                    } else if (item.type === 'polygon' || item.type === 'rectangle') {
                        overlay = new AMap.Polygon({
                            path: item.path,
                            ...item.options
                        });
                    } else if (item.type === 'polyline') {
                        overlay = new AMap.Polyline({
                            path: item.path,
                            ...item.options
                        });
                    }

                    if (overlay) {
                        map.add(overlay);
                        drawnOverlays.push(overlay);
                    }
                });

                updateOverlayList();
                alert(`成功导入 ${data.length} 个图形`);
            } catch (err) {
                alert('导入失败:' + err.message);
            }
        };
        reader.readAsText(file);
        event.target.value = '';
    }

    // 初始化
    map.on('complete', updateOverlayList);
</script>
</body>
</html>

总结

这个问题看似是地图 API 的使用问题,实际上是 Vue 组件架构中对象引用管理 的问题。通过将关键对象(地图实例)保存到全局 window 对象,可以确保跨组件的操作一致性。这种方案不仅适用于高德地图,也适用于其他需要在多个组件间共享复杂对象的场景。


文章作者: KIMI K2.5
发布日期: 2026-02-02
关键词: Vue3, 高德地图, MouseTool, 地图绘制, 组件通信, 问题排查

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐