Vue3+高德地图实战:打造告警监控的一份指南

一、需求分析

最近做了一个vue3+高德地图的工作,可视化监控,实时展示全国/地区范围内的设备告警情况,包括:

  • 风险等级筛选(多选)

  • 地区选择(省市区三级联动)

  • 动态统计(告警设备总数、告警城市数等)

  • 地图交互

    • 不同缩放层级显示对应行政级别的数据(省→市→区)
    • 点击标记点查看详情或下钻
    • 鹰眼导航辅助定位

核心挑战

  • 数据动态加载:避免频繁请求,需缓存不同层级数据
  • 性能优化:大量标记点(Marker)渲染时的流畅性
  • 交互一致性:搜索条件变化时,地图视角与数据的同步

二、 开发流程

技术选型

技术栈 用途
Vue3 + ElementUI 前端框架 & UI组件库
高德地图JS API 地图渲染、标记点、信息窗口
Pinia 状态管理(缓存层级数据)

因为我的需求是省市县/区的展示,不需要具体到更细一级的设备,并且hover上去需要展示众多数据,也不需要前端用indexCluster来聚合,需求上就直接用markers实现了。

关键实现步骤

  1. 地图初始化

    • 加载高德地图,设置默认缩放级别和中心点
    • 添加行政区域图层AMap.DistrictLayer)和鹰眼控件
  2. 数据分层管理

    • 通过 Pinia 缓存省/市/区三级数据,避免重复请求
    • 根据当前缩放级别动态切换数据源
  3. 标记点动态渲染

    • 根据告警比例动态设置标记点颜色(红/绿)
    • 使用 InfoWindow 展示详情,内嵌 Vue 组件
  4. 搜索条件联动

    • 风险等级或地区变化时,重新请求数据并更新视图
    • 无数据时自动回退到默认视图

三、数据交互分析

后端返回的数据结构

css 复制代码
{
    "code": "000000",
    "content": {
        "alterCityTotal": 1,
        "alterDeviceTotal": 43,
        "cityTotal": 1,
        "deviceTotal": 43,
        "regionAlterList": [
            {
                "addressId": "1001,1001",
                "addressName": "北京市",
                "alterDeviceTotal": 125,
                "city": "北京市",
                "deviceTotal": 341,
                "lat": 39.88125,
                "layer": "city",
                "lng": 116.42488,
                "lnglat": [
                    116.42488,
                    39.88125
                ],
                "province": "北京市",
                "rate": "36.66"
            },
            {
                "addressId": "1001",
                "addressName": "北京市",
                "alterDeviceTotal": 125,
                "deviceTotal": 341,
                "lat": 39.88125,
                "layer": "province",
                "lng": 116.42488,
                "lnglat": [
                    116.42488,
                    39.88125
                ],
                "province": "北京市",
                "rate": "36.66"
            },
            {
                "addressId": "1001,1001,4895",
                "addressName": "朝阳区",
                "alterDeviceTotal": 43,
                "city": "北京市",
                "deviceTotal": 43,
                "lat": 39.93123,
                "layer": "district",
                "lng": 116.46234,
                "lnglat": [
                    116.46234,
                    39.93123
                ],
                "province": "北京市",
                "rate": "100",
                "region": "朝阳区"
            }
        ]
    },
    "errors": [],
    "message": "",
    "metadata": {},
    "status": "success"
}

前端使用

(1) 数据存储与分层管理

  • 使用 Pinia 缓存不同层级的 regionAlterList,避免重复请求。

  • 通过 layer 字段区分数据层级,例如:

    • zoom=5(省级) → 仅渲染 layer="province" 的数据
    • zoom=10(市级) → 仅渲染 layer="city" 的数据

(2) 动态渲染策略

  • 标记点生成 :遍历 regionAlterList,根据 lnglat 创建地图标记点(Marker)。
  • 样式计算 :通过 rate 字段判断告警比例,动态设置标记点颜色:

(3) 统计信息联动

  • 顶部统计栏(如"告警城市数")直接绑定 alterCityTotalcityTotal
  • 搜索条件变化时,重新请求数据并更新统计数值。

四、 代码分析

1. 核心模块概览

代码主要分为以下几个功能模块:

  • 地图初始化与配置
  • 数据获取与状态管理
  • 标记点(Marker)渲染与交互
  • 搜索条件处理
  • 组件生命周期管理

2. 逐函数解析

2.1 地图初始化相关函数

onMounted 钩子函数
php 复制代码
onMounted(() => {
  AMapLoader.load({
    key: 'xxxxxxxx',// 去高德地图申请,建议使用企业账号
    version: '2.0',
    plugins: ['AMap.DistrictLayer', 'AMap.HawkEye']
  })
    .then((AMap) => {
      // 创建地图实例
      map = new AMap.Map('container', {
        viewMode: '2D',
        zoom: 4.7,
        zooms: [4, 16],
        center: [104.937478, 35.439575],
        mapStyle: 'amap://styles/noramal'
      });
      
      // 添加行政区划图层
      const districtLayer = new AMap.DistrictLayer.Country({
        SOC: 'CHN',
        depth: 3,
        zIndex: 90,
        styles: DISTRICT_LAYER_DEFAULT_STYLE
      });
      
      // 添加鹰眼控件
      var hawkEye = new AMap.HawkEye({
        isOpen: true,
        position: 'RB',
        mapStyle: 'amap://styles/normal'
      });
      
      map.add(districtLayer);
      map.addControl(hawkEye);
      initData(); // 初始化数据
      setMapListener(); // 设置事件监听
    })
    .catch((e) => {
      console.error('地图加载失败:', e);
    });
});

功能

  • 异步加载高德地图JS API
  • 初始化地图实例并设置默认视图(中国全境)
  • 添加行政区划图层和鹰眼控件
  • 调用initData()初始化数据
  • 调用setMapListener()设置事件监听

关键点

  • 使用AMapLoader确保API加载完成后再初始化
  • 地图初始中心点设置为中国地理中心(104.937478, 35.439575)
  • 添加了两个重要地图组件:行政区划图层和鹰眼控件
setMapListener 函数
ini 复制代码
const setMapListener = () => {
  // 监听地图缩放事件
  map.on('zoomend', () => {
    const zoom = map.getZoom();
    updateMapData(zoom);
  });
};

功能

  • 监听地图缩放事件
  • 缩放结束后获取当前缩放级别并调用updateMapData

2.2 数据管理相关函数

initData 函数
ini 复制代码
const initData = async () => {
  await getMapData();
  mapDataStore.updateData(aMapDataList.value);
  // 从store获取省级数据
  const provinceData = mapDataStore.getDataByLayer(AREA_LEVEL_PROVINCE);

  if (provinceData) {
    updateMarkers(provinceData.regionAlterList);
    // 设置初始层级
    currentLayer.value = AREA_LEVEL_PROVINCE;
  }
};

功能

  • 初始化加载地图数据
  • 将数据存入Pinia store
  • 渲染省级标记点
  • 设置当前层级为省级

执行流程

  1. 调用getMapData()获取数据
  2. 更新store中的数据
  3. 从store获取省级数据
  4. 调用updateMarkers渲染标记点
getMapData 函数
ini 复制代码
const getMapData = async () => {
  const params = {
    riskLevel: searchForm.riskLevel?.length ? searchForm.riskLevel.join(',') : null,
    addressId: searchForm.addressId?.length ? searchForm.addressId.join(',') : null
  };

  const res = await api.checkItem.getAreaSummaryList(params);

  if (res && res.content) {
    // 更新 store 中的全量数据
    mapDataStore.updateData(res.content);
    aMapDataList.value = res.content;

    // 获取当前层级的数据并更新标记
    const currentZoom = map.getZoom();
    const currentLayer = getAreaLevelByZoom(currentZoom);
    const data = mapDataStore.getDataByLayer(currentLayer);
    if (data) {
      updateMarkers(data.regionAlterList);
    }
    return res;
  }
  return null;
};

功能

  • 根据搜索条件获取地图数据
  • 更新store和本地数据
  • 根据当前缩放级别渲染对应层级的标记点

参数处理

  • 将数组类型的riskLeveladdressId转换为字符串参数
  • 空数组处理为null避免无效参数

数据流

  1. 发起API请求
  2. 成功返回后更新store
  3. 更新组件内数据aMapDataList
  4. 根据当前层级渲染标记点
updateMapData 函数
ini 复制代码
const updateMapData = async (zoom) => {
  const newLayer = getAreaLevelByZoom(zoom);
  if (!newLayer) return;

  // 如果层级没有变化,不重新请求数据
  if (newLayer === currentLayer.value) return;

  // 更新当前层级
  currentLayer.value = newLayer;

  if (infoWindow) {
    infoWindow.close();
    infoWindow = null;
  }

  // 从 store 获取数据
  const data = mapDataStore.getDataByLayer(newLayer);

  if (data) {
    updateMarkers(data.regionAlterList);
  } else {
    updateMarkers([]); // 清空标记点
  }
};

功能

  • 根据缩放级别更新显示的数据层级
  • 避免重复渲染相同层级
  • 关闭已打开的信息窗口
  • 从store获取对应层级数据并更新标记点

优化点

  • 层级变化判断避免不必要渲染
  • 及时清理已打开的信息窗口

2.3 标记点相关函数

getStyle 函数
ini 复制代码
const getStyle = (data) => {
  const size = 70;
  // 根据告警比例决定颜色
  const rate = parseFloat(data.rate) || 0;
  const isHighAlert = rate >= 20; // 告警比例大于等于20%使用红色,否则使用绿色
  const bgColor = isHighAlert ? 'rgba(255, 77, 79, 0.7)' : 'rgb(82.4, 155.2, 46.4,0.7)';
  const borderColor = isHighAlert ? 'rgba(255, 77, 79, 1)' : 'rgb(82.4, 155.2, 46.4,0.7)';

  return {
    bgColor,
    borderColor,
    size,
    color: '#ffffff',
    textAlign: 'center',
    boxShadow: '0px 0px 5px rgba(0,0,0,0.8)'
  };
};

功能

  • 根据数据生成标记点样式
  • 告警比例≥20%显示红色,否则显示绿色
  • 返回包含各种样式属性的对象
createInfoWindowContent 函数
ini 复制代码
const createInfoWindowContent = (data) => {
  // 创建容器
  const container = document.createElement('div');

  // 创建应用实例
  const app = createApp(InfoWindowTable, {
    detail: data,
    onViewDevices: () => {
      state.alertDeviceInfoDialogShow = true;
      AlertDeviceInfoDialog({
        show: state.alertDeviceInfoDialogShow,
        riskLevel: searchForm.riskLevel,
        detail: data,
        close: () => {
          state.alertDeviceInfoDialogShow = false;
        }
      });
    }
  });

  // 挂载组件
  app.mount(container);
  return container;
};

功能

  • 动态创建信息窗口内容
  • 使用Vue的createApp挂载自定义组件InfoWindowTable
  • 传递点击事件处理函数

技术亮点

  • 在原生地图API中嵌入Vue组件
  • 实现组件化信息窗口内容
  • 支持自定义事件传递
updateMarkers 函数
ini 复制代码
const updateMarkers = (data) => {
  // 清除现有标记
  markers.forEach((marker) => marker.setMap(null));
  markers = [];

  // 关闭已打开的 infoWindow
  if (infoWindow) {
    infoWindow.close();
    infoWindow = null;
  }

  if (!data || data.length === 0) return;

  // 获取当前地图的缩放级别
  const currentZoom = map.getZoom();
  const currentLayer = getAreaLevelByZoom(currentZoom);

  // 根据当前层级过滤数据
  const filteredData = data.filter((item) => {
    // 确保经纬度是有效的数组
    if (!Array.isArray(item.lnglat) || item.lnglat.length !== 2 || !item.lnglat[0] || !item.lnglat[1]) {
      return false;
    }

    const [lng, lat] = item.lnglat;
    // 确保经纬度是有效的数字
    if (typeof lng !== 'number' || typeof lat !== 'number' || isNaN(lng) || isNaN(lat)) {
      return false;
    }

    // 只显示当前层级的数据
    return item.layer === currentLayer;
  });

  if (filteredData.length === 0) return;

  // 创建标记点
  filteredData.forEach((item) => {
    const [lng, lat] = item.lnglat;
    const styleObj = getStyle(item);

    // 创建标记点内容
    const content = document.createElement('div');
    content.className = 'amap-cluster';
    content.style.backgroundColor = styleObj.bgColor;
    content.style.width = styleObj.size + 'px';
    content.style.height = styleObj.size + 'px';
    content.style.border = `solid 1px ${styleObj.borderColor}`;
    content.style.borderRadius = '50%';
    content.innerHTML = `
      <span class="showText">${item.addressName}</span>
      <span class="showText">${item.alterDeviceTotal}</span>
    `;
    content.style.color = styleObj.color;
    content.style.textAlign = styleObj.textAlign;
    content.style.boxShadow = styleObj.boxShadow;

    // 创建标记点
    const marker = new AMap.Marker({
      position: new AMap.LngLat(lng, lat),
      content: content,
      offset: new AMap.Pixel(-30, -30),
      zIndex: 100,
      extData: item
    });

    // 添加事件监听
    marker.on('mouseover', () => {
      infoWindow = new AMap.InfoWindow({
        position: new AMap.LngLat(lng, lat),
        offset: new AMap.Pixel(0, -80),
        content: createInfoWindowContent(item),
        closeWhenClickMap: true
      });
      infoWindow.open(map);
    });

    marker.on('click', () => {
      const curZoom = map.getZoom();
      if (curZoom < 20) {
        map.setZoomAndCenter(curZoom + 1, new AMap.LngLat(lng, lat));
      }
    });

    // 将标记点添加到地图
    marker.setMap(map);
    markers.push(marker);
  });
};

功能

  • 全量更新地图标记点
  • 清理旧标记点和信息窗口
  • 过滤无效数据
  • 根据当前层级筛选数据
  • 创建新标记点并添加交互事件

执行流程

  1. 清理现有标记
  2. 关闭已打开的信息窗口
  3. 数据有效性校验
  4. 层级过滤
  5. 为每个数据项创建标记点
  6. 添加鼠标悬停和点击事件

交互细节

  • 鼠标悬停显示信息窗口
  • 点击标记点放大一级并居中
  • 使用自定义HTML内容作为标记点

2.4 搜索与视图控制

onSearchFormChange 函数
ini 复制代码
const onSearchFormChange = async (val, triggeringField) => {
  if (triggeringField === 'riskLevel') {
    // 风险等级改变
    if (searchForm.addressId?.length) {
      // 如果已选择地区,清空 store 并获取该地区的数据
      mapDataStore.clearData();
      const res = await getMapData();
      const currentAddressIdStr = searchForm.addressId.join(',');
      await handleRegionData(res, currentAddressIdStr);
    } else {
      // 如果未选择地区,检查是否需要清空 store
      if (hasSearchConditions()) {
        mapDataStore.clearData();
      }
      await resetMapView();
    }
  } else if (triggeringField === 'addressId') {
    // 地区改变
    searchForm.addressId = val || [];

    if (searchForm.addressId.length) {
      // 如果选择了地区,清空 store
      mapDataStore.clearData();
      const res = await getMapData();
      const currentAddressIdStr = searchForm.addressId.join(',');
      await handleRegionData(res, currentAddressIdStr);
    } else {
      // 如果清除了地区选择,检查是否需要清空 store
      if (hasSearchConditions()) {
        mapDataStore.clearData();
      }
      await resetMapView();
    }
  }
};

功能

  • 处理搜索条件变化
  • 区分风险等级和地区两种变化类型
  • 根据条件组合采取不同的数据加载策略

处理逻辑

  • 风险等级变化:

    • 如果有地区选择:重新加载该地区数据
    • 无地区选择:重置为全国视图
  • 地区选择变化:

    • 选择地区:加载该地区数据并定位
    • 清除地区:重置为全国视图
handleRegionData 函数
ini 复制代码
const handleRegionData = async (res, currentAddressIdStr) => {
  if (!res?.content) return;

  // 从返回数据中找到目标地区的数据
  const targetItem = res.content.regionAlterList?.find((item) => item.addressId === currentAddressIdStr);

  if (targetItem?.lnglat) {
    let newZoom;
    // 根据返回数据中的 layer 判断缩放级别
    if (targetItem.layer === AREA_LEVEL_PROVINCE) newZoom = 7;
    else if (targetItem.layer === AREA_LEVEL_CITY) newZoom = 10;
    else if (targetItem.layer === AREA_LEVEL_DISTRICT) newZoom = 12;

    map.setZoomAndCenter(newZoom, new AMap.LngLat(targetItem.lnglat[0], targetItem.lnglat[1]));
    updateMarkers(res.content.regionAlterList);
  } else {
    // 如果找不到目标地区,清空 store 并回到省级视图
    mapDataStore.clearData();
    map.setZoom(4.7);
    map.setCenter([104.937478, 35.439575]);
    updateMarkers([]);

    // 给出提示
    ElMessage({
      message: '该地区无正在告警设备',
      type: 'warning'
    });
  }
};

功能

  • 处理地区数据定位
  • 根据地区层级设置合适的缩放级别
  • 找不到数据时回退到默认视图并提示

层级与缩放级别对应关系

  • 省级:zoom=7
  • 市级:zoom=10
  • 区级:zoom=12
resetMapView 函数
ini 复制代码
const resetMapView = async () => {
  map.setZoom(4.7);
  map.setCenter([104.937478, 35.439575]);
  const res = await getMapData();
  if (res?.content) {
    updateMarkers(res.content.regionAlterList);
  }
};

功能

  • 重置地图视图到初始状态
  • 重新加载数据并更新标记点

2.5 工具函数

getAreaLevelByZoom 函数
kotlin 复制代码
const getAreaLevelByZoom = (zoom) => {
  if (zoom >= MAP_ZOOM_PROVINCE_MIN && zoom <= MAP_ZOOM_PROVINCE_MAX) {
    return AREA_LEVEL_PROVINCE;
  } else if (zoom >= MAP_ZOOM_CITY_MIN && zoom <= MAP_ZOOM_CITY_MAX) {
    return AREA_LEVEL_CITY;
  } else if (zoom >= MAP_ZOOM_DISTRICT_MIN && zoom <= MAP_ZOOM_DISTRICT_MAX) {
    return AREA_LEVEL_DISTRICT;
  }
  return null;
};

功能

  • 根据缩放级别判断当前显示的区域层级
  • 使用预定义的常量范围进行比较

常量定义

ini 复制代码
export const MAP_ZOOM_PROVINCE_MIN = 2;
export const MAP_ZOOM_PROVINCE_MAX = 8;
export const MAP_ZOOM_CITY_MIN = 8;
export const MAP_ZOOM_CITY_MAX = 11;
export const MAP_ZOOM_DISTRICT_MIN = 11;
export const MAP_ZOOM_DISTRICT_MAX = 20;
hasSearchConditions 函数
ini 复制代码
const hasSearchConditions = () => {
  return (
    (searchForm.riskLevel && searchForm.riskLevel.length > 0) ||
    (searchForm.addressId && searchForm.addressId.length > 0)
  );
};

功能

  • 判断当前是否有搜索条件
  • 用于决定是否需要清空store

3. 组件生命周期

scss 复制代码
onUnmounted(() => {
  // 清除所有标记点
  markers.forEach((marker) => marker.setMap(null));
  markers = [];
  map?.destroy();
});

五、pinia仓库代码

javascript 复制代码
/*
 * @Descripttion: 地图数据管理store
 * @version: 1.0.0
 * @Author: 
 * @Date: 2025-06-13 14:30:00
 * @LastEditors: 
 * @LastEditTime: 2025-06-17 16:20:49
 */

import { defineStore } from 'pinia';
import { AREA_LEVEL_PROVINCE, AREA_LEVEL_CITY, AREA_LEVEL_DISTRICT } from '@/constant/modules/checkItem';

export const useMapDataStore = defineStore({
  id: 'mapData',
  state: () => ({
    fullData: null // 存储完整的全量数据
  }),
  getters: {
    // 获取所有省级数据
    provinceData: (state) => {
      if (!state.fullData?.regionAlterList) return null;
      const provinceList = state.fullData.regionAlterList.filter((item) => item.layer === AREA_LEVEL_PROVINCE);
      return {
        ...state.fullData,
        regionAlterList: provinceList
      };
    },
    // 获取所有市级数据
    cityData: (state) => {
      if (!state.fullData?.regionAlterList) return null;
      const cityList = state.fullData.regionAlterList.filter((item) => item.layer === AREA_LEVEL_CITY);
      return {
        ...state.fullData,
        regionAlterList: cityList
      };
    },
    // 获取所有区级数据
    districtData: (state) => {
      if (!state.fullData?.regionAlterList) return null;
      const districtList = state.fullData.regionAlterList.filter((item) => item.layer === AREA_LEVEL_DISTRICT);
      return {
        ...state.fullData,
        regionAlterList: districtList
      };
    }
  },
  actions: {
    // 更新数据
    updateData(data) {
      this.fullData = data;
    },

    // 清除数据
    clearData() {
      this.fullData = null;
    },

    // 根据层级获取数据(用于地图初始化和缩放)
    getDataByLayer(layer) {
      switch (layer) {
        case AREA_LEVEL_PROVINCE:
          return this.provinceData;
        case AREA_LEVEL_CITY:
          return this.cityData;
        case AREA_LEVEL_DISTRICT:
          return this.districtData;
        default:
          return null;
      }
    }
  }
});

总结

通过分层数据管理、动态渲染优化和精准的状态控制,实现了高效的地图监控系统。关键经验

  1. 分层数据管理 :通过layer字段和缩放级别实现智能数据加载
  2. 状态共享:使用Pinia store缓存数据,避免重复请求
  3. 性能优化:标记点全量更新而非增量更新,确保一致性
  4. 交互体验:动态样式、信息窗口、点击下钻等多维交互设计

补充

因为上述代码比较冗余,现通过cursor生成一个本地能直接使用的case,mock了一份数据且把常量移过来了~把高德的key换成你自己的,就可以看demo啦~。

ini 复制代码
<!--
 * @Descripttion: 
 * @version: 
 * @Author: 
 * @Date: 2025-06-10 14:16:03
 * @LastEditors: 
 * @LastEditTime: 2025-06-20 14:37:48
-->
<template>
  <BaseCard>
    <div class="monitor-header">
      <el-form ref="formRef" :inline="true" :model="searchForm" class="monitor-form">
        <div class="form-left">
          <el-form-item label="风险等级:" prop="riskLevel">
            <el-checkbox-group v-model="searchForm.riskLevel" @change="(val) => onSearchFormChange(val, 'riskLevel')">
              <el-checkbox v-for="item in riskLevelOptions" :key="item.value" :label="item.value"></el-checkbox>
            </el-checkbox-group>
          </el-form-item>
          <el-form-item label="地区:" prop="addressId">
            <AddressCascader
              style="width: 410px"
              :value="searchForm.addressId"
              :teleported="false"
              @change="(val) => onSearchFormChange(val, 'addressId')"
            ></AddressCascader>
          </el-form-item>
        </div>
        <div class="form-right">
          <el-form-item label="正在告警总设备数:" class="stat-item">
            <p>
              <span class="value-red">{{ aMapDataList.alterDeviceTotal }}个</span>
              <span class="separator">/</span>
              <span>{{ aMapDataList.deviceTotal }}个</span>
            </p>
          </el-form-item>
          <el-form-item label="告警城市:" class="stat-item">
            <p>
              <span class="value-red">{{ aMapDataList.alterCityTotal }}个</span>
              <span class="separator">/</span>
              <span>{{ aMapDataList.cityTotal }}个</span>
            </p>
          </el-form-item>
        </div>
      </el-form>
    </div>
    <div id="container"></div>
  </BaseCard>
</template>

<script setup>
// ====== checkItem.js常量mock在本文件 ======
const RISK_LEVEL_HIGH = 'high';
const RISK_LEVEL_CRIT = 'crit';
const RISK_LEVEL_WARN = 'warn';
const RISK_LEVEL_NOTICE = 'notice';
const riskLevelOptions = [
  { label: RISK_LEVEL_CRIT, value: RISK_LEVEL_CRIT },
  { label: RISK_LEVEL_HIGH, value: RISK_LEVEL_HIGH },
  { label: RISK_LEVEL_WARN, value: RISK_LEVEL_WARN },
  { label: RISK_LEVEL_NOTICE, value: RISK_LEVEL_NOTICE }
];
const MAP_ZOOM_PROVINCE_MIN = 2;
const MAP_ZOOM_PROVINCE_MAX = 8;
const MAP_ZOOM_CITY_MIN = 8;
const MAP_ZOOM_CITY_MAX = 11;
const MAP_ZOOM_DISTRICT_MIN = 11;
const MAP_ZOOM_DISTRICT_MAX = 20;
const DISTRICT_LAYER_DEFAULT_STYLE = {
  'stroke-width': 1,
  'coastline-stroke': '#DC143C',
  'nation-stroke': '#DC143C',
  'province-stroke': '#FF4500',
  'city-stroke': 'rgba(100, 149, 237, 0.5)',
  fill: (e) => ''
};
const AREA_LEVEL_PROVINCE = 'province';
const AREA_LEVEL_CITY = 'city';
const AREA_LEVEL_DISTRICT = 'district';
// ====== end checkItem.js常量mock ======

defineOptions({
  name: 'AmapMonitor'
});

import { ref, onMounted, onUnmounted, reactive } from 'vue';
import AlertDeviceInfoDialog from './alertDeviceInfoDialog';
// import constant from '@/constant';
import AMapLoader from '@amap/amap-jsapi-loader';
import BaseCard from '@/components/common/baseCard.vue';
import InfoWindowTable from './InfoWindowTable.vue';
import AddressCascader from '@/components/common/selectAddressCascader.vue';
import { createApp } from 'vue';
// import api from '@/api';
// import { useMapDataStore } from '@/store/modules/mapDataStore';
import { ElMessage } from 'element-plus';

// ====== mock数据 ======
const mockRegionAlterList = [
  {
    addressId: '1001',
    addressName: '北京市',
    layer: AREA_LEVEL_PROVINCE,
    lnglat: [116.407396, 39.9042],
    alterDeviceTotal: 5,
    deviceTotal: 20,
    rate: 25
  },
  {
    addressId: '2001',
    addressName: '上海市',
    layer: AREA_LEVEL_PROVINCE,
    lnglat: [121.473701, 31.230416],
    alterDeviceTotal: 2,
    deviceTotal: 15,
    rate: 13.3
  },
  {
    addressId: '3001',
    addressName: '广州市',
    layer: AREA_LEVEL_PROVINCE,
    lnglat: [113.264385, 23.129112],
    alterDeviceTotal: 1,
    deviceTotal: 10,
    rate: 10
  }
];
const mockAMapDataList = {
  cityTotal: 3,
  alterCityTotal: 2,
  deviceTotal: 45,
  alterDeviceTotal: 8,
  regionAlterList: mockRegionAlterList
};
// ====== end mock数据 ======

const searchForm = reactive({
  riskLevel: [],
  addressId: []
});

const state = reactive({
  alertDeviceInfoDialogShow: false
});

let map = null;
let markers = []; // 存储所有标记点
let infoWindow = null;

const aMapDataList = ref({ ...mockAMapDataList });

// 添加一个变量来记录当前层级,控制有搜索条件时,只有跨层才发请求更新数据
const currentLayer = ref(null);

// const mapDataStore = useMapDataStore();

// 获取样式
const getStyle = (data) => {
  const size = 70;
  // 根据告警比例决定颜色
  const rate = parseFloat(data.rate) || 0;
  const isHighAlert = rate >= 20; // 告警比例大于等于20%使用红色,否则使用绿色
  const bgColor = isHighAlert ? 'rgba(255, 77, 79, 0.7)' : 'rgb(82.4, 155.2, 46.4,0.7)';
  const borderColor = isHighAlert ? 'rgba(255, 77, 79, 1)' : 'rgb(82.4, 155.2, 46.4,0.7)';

  return {
    bgColor,
    borderColor,
    size,
    color: '#ffffff',
    textAlign: 'center',
    boxShadow: '0px 0px 5px rgba(0,0,0,0.8)'
  };
};

// 创建信息窗口内容
const createInfoWindowContent = (data) => {
  // 创建容器
  const container = document.createElement('div');

  // 创建应用实例
  const app = createApp(InfoWindowTable, {
    detail: data,
    onViewDevices: () => {
      state.alertDeviceInfoDialogShow = true;
      AlertDeviceInfoDialog({
        show: state.alertDeviceInfoDialogShow,
        riskLevel: searchForm.riskLevel,
        detail: data,
        close: () => {
          state.alertDeviceInfoDialogShow = false;
        }
      });
    }
  });

  // 挂载组件
  app.mount(container);
  return container;
};

// 更新标记点数据
const updateMarkers = (data) => {
  // 清除现有标记
  markers.forEach((marker) => marker.setMap(null));
  markers = [];

  // 关闭已打开的 infoWindow
  if (infoWindow) {
    infoWindow.close();
    infoWindow = null;
  }

  if (!data || data.length === 0) return;

  // 获取当前地图的缩放级别
  const currentZoom = map.getZoom();
  const currentLayer = getAreaLevelByZoom(currentZoom);

  // 根据当前层级过滤数据
  const filteredData = data.filter((item) => {
    // 确保经纬度是有效的数组
    if (!Array.isArray(item.lnglat) || item.lnglat.length !== 2 || !item.lnglat[0] || !item.lnglat[1]) {
      return false;
    }

    const [lng, lat] = item.lnglat;
    // 确保经纬度是有效的数字
    if (typeof lng !== 'number' || typeof lat !== 'number' || isNaN(lng) || isNaN(lat)) {
      return false;
    }

    // 只显示当前层级的数据
    return item.layer === currentLayer;
  });

  if (filteredData.length === 0) return;

  // 创建标记点
  filteredData.forEach((item) => {
    const [lng, lat] = item.lnglat;
    const styleObj = getStyle(item);

    // 创建标记点内容
    const content = document.createElement('div');
    content.className = 'amap-cluster';
    content.style.backgroundColor = styleObj.bgColor;
    content.style.width = styleObj.size + 'px';
    content.style.height = styleObj.size + 'px';
    content.style.border = `solid 1px ${styleObj.borderColor}`;
    content.style.borderRadius = '50%';
    content.innerHTML = `
      <span class="showText">${item.addressName}</span>
      <span class="showText">${item.alterDeviceTotal}</span>
    `;
    content.style.color = styleObj.color;
    content.style.textAlign = styleObj.textAlign;
    content.style.boxShadow = styleObj.boxShadow;

    // 创建标记点
    const marker = new AMap.Marker({
      position: new AMap.LngLat(lng, lat),
      content: content,
      offset: new AMap.Pixel(-30, -30),
      zIndex: 100,
      extData: item
    });

    // 添加事件监听
    marker.on('mouseover', () => {
      infoWindow = new AMap.InfoWindow({
        position: new AMap.LngLat(lng, lat),
        offset: new AMap.Pixel(0, -80),
        content: createInfoWindowContent(item),
        closeWhenClickMap: true
      });
      infoWindow.open(map);
    });

    marker.on('click', () => {
      const curZoom = map.getZoom();
      if (curZoom < 20) {
        map.setZoomAndCenter(curZoom + 1, new AMap.LngLat(lng, lat));
      }
    });

    // 将标记点添加到地图
    marker.setMap(map);
    markers.push(marker);
  });
};

// 根据缩放级别获取对应的区域级别
const getAreaLevelByZoom = (zoom) => {
  if (zoom >= MAP_ZOOM_PROVINCE_MIN && zoom <= MAP_ZOOM_PROVINCE_MAX) {
    return AREA_LEVEL_PROVINCE;
  } else if (zoom >= MAP_ZOOM_CITY_MIN && zoom <= MAP_ZOOM_CITY_MAX) {
    return AREA_LEVEL_CITY;
  } else if (zoom >= MAP_ZOOM_DISTRICT_MIN && zoom <= MAP_ZOOM_DISTRICT_MAX) {
    return AREA_LEVEL_DISTRICT;
  }
  return null;
};

// 判断是否有搜索条件
const hasSearchConditions = () => {
  return (
    (searchForm.riskLevel && searchForm.riskLevel.length > 0) ||
    (searchForm.addressId && searchForm.addressId.length > 0)
  );
};

// 缩放时获取对应级别的数据
const updateMapData = async (zoom) => {
  const newLayer = getAreaLevelByZoom(zoom);
  if (!newLayer) return;

  // 如果层级没有变化,不重新请求数据
  if (newLayer === currentLayer.value) return;

  // 更新当前层级
  currentLayer.value = newLayer;

  if (infoWindow) {
    infoWindow.close();
    infoWindow = null;
  }

  // 直接用mock数据
  updateMarkers(aMapDataList.value.regionAlterList);
};

// 获取地图数据
const getMapData = async () => {
  // const params = {
  //   riskLevel: searchForm.riskLevel?.length ? searchForm.riskLevel.join(',') : null,
  //   addressId: searchForm.addressId?.length ? searchForm.addressId.join(',') : null
  // };
  // const res = await api.checkItem.getAreaSummaryList(params);
  // if (res && res.content) {
  //   aMapDataList.value = res.content;
  //   // ...
  //   return res;
  // }
  // return null;
  // 直接用mock数据
  aMapDataList.value = { ...mockAMapDataList };
  return { content: mockAMapDataList };
};

// 初始化时获取省级数据
const initData = async () => {
  await getMapData();
  // const provinceData = mapDataStore.getDataByLayer(AREA_LEVEL_PROVINCE);
  // if (provinceData) {
  //   updateMarkers(provinceData.regionAlterList);
  //   currentLayer.value = AREA_LEVEL_PROVINCE;
  // }
  updateMarkers(aMapDataList.value.regionAlterList);
  currentLayer.value = AREA_LEVEL_PROVINCE;
};

const setMapListener = () => {
  // 监听地图缩放事件
  map.on('zoomend', () => {
    const zoom = map.getZoom();
    updateMapData(zoom);
  });
};

onMounted(() => {
  AMapLoader.load({
    key: 'xxxxxxxx',// 去高德地图申请,建议使用企业账号
    version: '2.0',
    plugins: ['AMap.DistrictLayer', 'AMap.HawkEye']
  })
    .then((AMap) => {
      map = new AMap.Map('container', {
        viewMode: '2D',
        zoom: 4.7,
        zooms: [4, 16],
        center: [104.937478, 35.439575],
        mapStyle: 'amap://styles/noramal'
      });
      // 创建国家行政区图层
      const districtLayer = new AMap.DistrictLayer.Country({
        SOC: 'CHN',
        depth: 3,
        zIndex: 90,
        styles: DISTRICT_LAYER_DEFAULT_STYLE
      });
      // 创建鹰眼控件
      var hawkEye = new AMap.HawkEye({
        isOpen: true, // 确保鹰眼控件默认打开
        position: 'RB', // 设置鹰眼控件在右下角
        mapStyle: 'amap://styles/normal'
      });
      map.add(districtLayer);
      map.addControl(hawkEye);
      initData(); // 初始化数据
      setMapListener(); // 设置地图监听
    })
    .catch((e) => {
      console.error('地图加载失败:', e);
    });
});

// 重置地图视图并获取数据
const resetMapView = async () => {
  map.setZoom(4.7);
  map.setCenter([104.937478, 35.439575]);
  // const res = await getMapData();
  // if (res?.content) {
  //   updateMarkers(res.content.regionAlterList);
  // }
  aMapDataList.value = { ...mockAMapDataList };
  updateMarkers(aMapDataList.value.regionAlterList);
};

// 处理地区数据
const handleRegionData = async (res, currentAddressIdStr) => {
  if (!res?.content) return;
  // 从返回数据中找到目标地区的数据
  const targetItem = res.content.regionAlterList?.find((item) => item.addressId === currentAddressIdStr);
  if (targetItem?.lnglat) {
    let newZoom;
    // 根据返回数据中的 layer 判断缩放级别
    if (targetItem.layer === AREA_LEVEL_PROVINCE) newZoom = 7;
    else if (targetItem.layer === AREA_LEVEL_CITY) newZoom = 10;
    else if (targetItem.layer === AREA_LEVEL_DISTRICT) newZoom = 12;
    map.setZoomAndCenter(newZoom, new AMap.LngLat(targetItem.lnglat[0], targetItem.lnglat[1]));
    updateMarkers(res.content.regionAlterList);
  } else {
    map.setZoom(4.7);
    map.setCenter([104.937478, 35.439575]);
    updateMarkers([]);
    ElMessage({
      message: '该地区无正在告警设备',
      type: 'warning'
    });
  }
};

const onSearchFormChange = async (val, triggeringField) => {
  if (triggeringField === 'riskLevel') {
    if (searchForm.addressId?.length) {
      const res = await getMapData();
      const currentAddressIdStr = searchForm.addressId.join(',');
      await handleRegionData(res, currentAddressIdStr);
    } else {
      await resetMapView();
    }
  } else if (triggeringField === 'addressId') {
    searchForm.addressId = val || [];
    if (searchForm.addressId.length) {
      const res = await getMapData();
      const currentAddressIdStr = searchForm.addressId.join(',');
      await handleRegionData(res, currentAddressIdStr);
    } else {
      await resetMapView();
    }
  }
};

onUnmounted(() => {
  markers.forEach((marker) => marker.setMap(null));
  markers = [];
  map?.destroy();
});
</script>

<style lang="scss" scoped>
.monitor-header {
  display: flex;
  justify-content: flex-start;
  align-items: flex-start;
  padding: 0;
  position: relative;
  margin-bottom: 16px;
  &::after {
    content: '';
    height: 1px;
    position: absolute;
    left: -17px;
    right: -17px;
    bottom: -8px;
    background: #ccc;
  }
  .monitor-form {
    display: flex;
    justify-content: space-between;
    flex-wrap: wrap;
    width: 100%;
  }
  .form-left {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  }
  .form-right {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  }
  .stat-item {
    :deep(.el-form-item__label) {
      margin-right: -10px;
    }
    .value-red {
      color: #ff4d4f;
      font-size: 20px;
      font-weight: bold;
    }
    .separator {
      margin: 0 4px;
      color: #999;
      font-weight: 700;
    }
  }
}

:deep(.el-form-item) {
  margin-bottom: 0;
  margin-right: 0;
}

#container {
  width: 100%;
  height: 80vh;
  position: relative;
  margin-top: 16px;
}

:deep(.amap-cluster) {
  display: flex;
  justify-content: center;
  flex-direction: column;
  align-items: center;
  font-size: 12px;
  padding: 4px;
}

:deep(.showText) {
  font-size: 14px;
  font-weight: bold;
  display: block;
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
  width: 100%;
  text-align: center;
  line-height: 1.2;
  color: #f2f3f5;
}
</style>
相关推荐
gongzemin8 分钟前
前端根据文件流渲染 PDF 和 DOCX 文件
前端·vue.js·express
jsonchao29 分钟前
大厂失业后,我用cursor开发了第二款海外产品
前端·程序员
gnip39 分钟前
低代码平台自定义组件实现思路
前端·低代码
实习生小黄1 小时前
基于扫描算法获取psd图层轮廓
前端·javascript·算法
青松学前端1 小时前
你不知道的秘密-axios源码
前端·javascript
GISer_Jing1 小时前
IntersectionObserver API&应用场景&示例代码详解
前端·javascript
未来之窗软件服务1 小时前
学校住宿缴费系统h5-——东方仙盟——仙盟创梦IDE
前端·javascript·ide·仙盟创梦ide·东方仙盟
markyankee1011 小时前
JavaScript 作用域与闭包详解
前端·javascript
gufs镜像1 小时前
Swift学习总结——使用Playground
前端·ios·面试
高冷的小明1 小时前
React-Find 一款能快速在网页定位到源码的工具,支持React19.x/next 15
前端·javascript·react.js