高德地图 MouseTool 绘制模式无法退出问题解决方案
高德地图 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, 地图绘制, 组件通信, 问题排查
更多推荐
所有评论(0)