Преглед на файлове

调整地图使用天地图

Zimo преди 5 дни
родител
ревизия
f1a4980076
променени са 7 файла, в които са добавени 1498 реда и са изтрити 391 реда
  1. 2 0
      .env
  2. 1 0
      package.json
  3. 33 0
      pnpm-lock.yaml
  4. 16 1
      src/components/UploadFile/src/UploadFile.vue
  5. 6 1
      src/main.ts
  6. 976 389
      src/views/pms/map/Map.vue
  7. 464 0
      src/views/pms/map/oldMap.vue

+ 2 - 0
.env

@@ -19,6 +19,8 @@ VITE_APP_DOCALERT_ENABLE=false
 # 百度统计
 VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc
 
+VITE_TK = 76c73bfc78128f895f998b30cead1b6b
+
 # 默认账户密码
 VITE_APP_DEFAULT_LOGIN_TENANT = 芋道源码
 VITE_APP_DEFAULT_LOGIN_USERNAME =

+ 1 - 0
package.json

@@ -99,6 +99,7 @@
     "vue-i18n": "9.10.2",
     "vue-json-viewer": "^3.0.4",
     "vue-router": "4.4.5",
+    "vue-tianditu": "^2.7.6",
     "vue-types": "^5.1.1",
     "vue3-next-qrcode": "^4.0.0",
     "vue3-signature": "^0.2.4",

+ 33 - 0
pnpm-lock.yaml

@@ -230,6 +230,9 @@ importers:
       vue-router:
         specifier: 4.4.5
         version: 4.4.5(vue@3.5.12(typescript@5.3.3))
+      vue-tianditu:
+        specifier: ^2.7.6
+        version: 2.7.6(vue@3.5.12(typescript@5.3.3))
       vue-types:
         specifier: ^5.1.1
         version: 5.1.3(vue@3.5.12(typescript@5.3.3))
@@ -5377,6 +5380,17 @@ packages:
       terser:
         optional: true
 
+  vue-demi@0.12.5:
+    resolution: {integrity: sha512-BREuTgTYlUr0zw0EZn3hnhC3I6gPWv+Kwh4MCih6QcAeaTlaIX0DwOVN0wHej7hSvDPecz4jygy/idsgKfW58Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+    peerDependencies:
+      '@vue/composition-api': ^1.0.0-rc.1
+      vue: ^3.0.0-0 || ^2.6.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   vue-demi@0.13.11:
     resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
     engines: {node: '>=12'}
@@ -5439,6 +5453,15 @@ packages:
   vue-template-compiler@2.7.16:
     resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==}
 
+  vue-tianditu@2.7.6:
+    resolution: {integrity: sha512-Yvcxg0IAgGB3ZY8zlC5ayjo9f85fa8PFTaifguTNpri76g67GO9toaRWMjBKi39yYHMBADaT3P6bEkJx87X8MQ==}
+    peerDependencies:
+      '@vue/composition-api': ^1.4.2
+      vue: ^2.0.0 || >=3.0.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   vue-tsc@1.8.27:
     resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==}
     hasBin: true
@@ -11156,6 +11179,10 @@ snapshots:
       sass: 1.81.0
       terser: 5.36.0
 
+  vue-demi@0.12.5(vue@3.5.12(typescript@5.3.3)):
+    dependencies:
+      vue: 3.5.12(typescript@5.3.3)
+
   vue-demi@0.13.11(vue@3.5.12(typescript@5.3.3)):
     dependencies:
       vue: 3.5.12(typescript@5.3.3)
@@ -11217,6 +11244,12 @@ snapshots:
       de-indent: 1.0.2
       he: 1.2.0
 
+  vue-tianditu@2.7.6(vue@3.5.12(typescript@5.3.3)):
+    dependencies:
+      mitt: 3.0.1
+      vue: 3.5.12(typescript@5.3.3)
+      vue-demi: 0.12.5(vue@3.5.12(typescript@5.3.3))
+
   vue-tsc@1.8.27(typescript@5.3.3):
     dependencies:
       '@volar/typescript': 1.11.1

+ 16 - 1
src/components/UploadFile/src/UploadFile.vue

@@ -80,7 +80,21 @@ const emit = defineEmits(['update:modelValue'])
 
 const props = defineProps({
   modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
-  fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf','png','jpg','jpeg','sql','pptx','mp4','apk']), // 文件类型, 例如['png', 'jpg', 'jpeg']
+  fileType: propTypes.array.def([
+    'doc',
+    'xls',
+    'ppt',
+    'txt',
+    'pdf',
+    'png',
+    'jpg',
+    'jpeg',
+    'sql',
+    'pptx',
+    'mp4',
+    'apk',
+    'wgt'
+  ]), // 文件类型, 例如['png', 'jpg', 'jpeg']
   fileSize: propTypes.number.def(50), // 大小限制(MB)
   limit: propTypes.number.def(5), // 数量限制
   autoUpload: propTypes.bool.def(true), // 自动上传
@@ -108,6 +122,7 @@ const beforeUpload: UploadProps['beforeUpload'] = (file: UploadRawFile) => {
     fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1)
   }
   const isImg = props.fileType.some((type: string) => {
+    console.log('file', file)
     if (file.type.indexOf(type) > -1) return true
     return !!(fileExtension && fileExtension.indexOf(type) > -1)
   })

+ 6 - 1
src/main.ts

@@ -4,6 +4,8 @@ import '@/plugins/unocss'
 // 导入全局的svg图标
 import '@/plugins/svgIcon'
 
+import VueTianditu from 'vue-tianditu'
+
 // 初始化多语言
 import { setupI18n } from '@/plugins/vueI18n'
 
@@ -35,7 +37,6 @@ import { createApp } from 'vue'
 
 import App from './App.vue'
 
-
 import './permission'
 
 import '@/plugins/tongji' // 百度统计
@@ -74,6 +75,10 @@ const setupAll = async () => {
   app.use(VueDOMPurifyHTML)
   app.config.globalProperties.$mqttTool = mqttTool
 
+  app.use(VueTianditu, {
+    v: '4.0',
+    tk: import.meta.env.VITE_TK
+  })
 
   app.mount('#app')
 }

+ 976 - 389
src/views/pms/map/Map.vue

@@ -1,459 +1,1046 @@
-<template>
-  <div class="map-container">
-    <div class="status-info">
-      <div class="status-item">
-        <span class="online-icon"></span>
-        <span style="font-weight: bold;font-size: 17px">在线数量 {{ inlineCount }}</span>
-      </div>
-      <div class="status-item">
-        <span class="offline-icon"></span>
-        <span style="font-weight: bold;font-size: 17px">离线数量 {{ outlineCount }}</span>
-      </div>
-    </div>
-    <div id="baidu-map" ref="mapContainer"></div>
-    <div class="map-controls">
-      <button @click="zoomIn">{{t('map.amplify')}}</button>
-      <button @click="zoomOut">{{t('map.reduce')}}</button>
-      <button @click="toggleMapType"
-      >{{t('map.SwitchMapType')}}({{ mapType === 'BMAP_NORMAL_MAP' ? '地图' : '卫星' }})</button>
-        <el-tree-select
-          class="my-el-select"
-          v-model="queryParams.deptId"
-          :data="treeList"
-          :props="defaultProps"
-          check-strictly
-          node-key="id"
-          :placeholder="t('deviceForm.deptHolder')"
-          filterable
-          @change="getData"
-        />
-    </div>
-  </div>
-  <DeviceMonitorDrawer :model-value="drawerVisible" @update:model-value="(val) => (drawerVisible = val)" :id="deviceId" :deviceName="deviceName" :lastLineTime="lastLineTime"
-                       :ifLine="ifLine" :dept="dept" :deviceCode="deviceCode"
-                       ref="showDrawer" />
-</template>
-
 <script setup lang="ts">
-import { onBeforeUnmount, onMounted, ref } from 'vue'
-import { DICT_TYPE, getDictLabel } from '@/utils/dict'
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
-import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { IotDeviceApi } from '@/api/pms/device'
 import * as DeptApi from '@/api/system/dept'
-import {defaultProps, handleTree} from "@/utils/tree";
+import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { defaultProps, handleTree } from '@/utils/tree'
 
-interface Cluster {
-  lng: number
+type MapMode = 'TMAP_NORMAL_MAP' | 'TMAP_SATELLITE_MAP'
+type StatusFilter = 'all' | 'online' | 'offline'
+
+type MapDevice = {
+  id: number
+  deviceCode: string
+  deviceName: string
   lat: number
-  count: number
-  devices?: IotDeviceVO[]
-}
-defineOptions({ name: 'DeviceMap' })
-const showDrawer = ref()
-const drawerVisible = ref<boolean>(false)
-const mapContainer = ref<HTMLElement | null>(null)
-const map = ref<any>(null)
-const mapType = ref<'BMAP_NORMAL_MAP' | 'BMAP_SATELLITE_MAP'>('BMAP_NORMAL_MAP')
-const clusters = ref<Cluster[]>([])
-const deviceId = ref()
-const deviceName = ref('')
+  lng: number
+  location: string
+  deptId: number
+  ifInline: number
+  lastInlineTime: string
+  deviceStatus: string
+}
+
+type MarkerGlyphName = 'mdi:access-point' | 'mdi:access-point-off'
+
+type MarkerTheme = {
+  key: 'online' | 'offline'
+  iconName: MarkerGlyphName
+  startColor: string
+  endColor: string
+  ringColor: string
+  badgeColor: string
+  statusColor: string
+  shadowColor: string
+  iconColor: string
+}
+
+const { t } = useI18n()
+const message = useMessage()
+
+const loading = ref(false)
+const deptList = ref<any[]>([])
+const treeList = ref<any[]>([])
+const mapRef = ref<any>(null)
+const activeDevice = ref<MapDevice | null>(null)
+const infoWindowTarget = ref<[number, number] | null>(null)
+const showDrawer = ref<any>(null)
+const drawerVisible = ref(false)
+const deviceId = ref<number | undefined>(undefined)
+const drawerDeviceName = ref('')
 const lastLineTime = ref('')
-const ifLine = ref()
+const ifLine = ref<number | undefined>(undefined)
 const dept = ref('')
 const deviceCode = ref('')
-const { t } = useI18n() // 国际化
-const inlineCount = ref(0)
-const outlineCount = ref(0)
-// 设备数据示例
-const devices = ref<IotDeviceVO[]>()
-const treeList = ref<Tree[]>([]) // 树形结构
-// 初始化地图
-const initMap = () => {
-  if (!mapContainer.value) return
-
-  const script = document.createElement('script')
-  script.src = `https://api.map.baidu.com/api?v=3.0&ak=c0crhdxQ5H7WcqbcazGr7mnHrLa4GmO0&type=webgl`
-  script.async = true
-  script.onload = () => {
-    if ((window as any).BMap) {
-      map.value = new (window as any).BMap.Map(mapContainer.value)
-      const point = new (window as any).BMap.Point(104.114129, 37.550339)
-      map.value.centerAndZoom(point, 5)
-
-      map.value.enableScrollWheelZoom(true)
-      map.value.setMapType((window as any)[mapType.value])
-
-      map.value.addControl(new (window as any).BMap.NavigationControl())
-      map.value.addControl(new (window as any).BMap.ScaleControl())
-
-      initDeviceMarkers()
-      map.value.addEventListener('zoomend', () => {
-        initDeviceMarkers()
-      })
-    } else {
-      console.error('百度地图API加载失败')
-    }
-  }
-  script.onerror = () => {
-    console.error('百度地图API加载失败')
-  }
-  document.head.appendChild(script)
-}
-
-const initDeviceMarkers = () => {
-  if (!map.value) return
-
-  map.value.clearOverlays()
-
-  const zoomLevel = map.value.getZoom()
-  if (zoomLevel > 8) {
-    debugger
-    // 高缩放级别下显示单个设备标记
-    devices.value.forEach((device) => {
-      const point = new (window as any).BMap.Point(device.lng, device.lat)
-      const marker = createDeviceMarker(device, point)
-      map.value.addOverlay(marker)
-    })
-  } else {
-    debugger
-    // 低缩放级别下进行聚合
-    clusters.value = clusterDevices(devices.value, map.value)
-    clusters.value.forEach((cluster) => {
-      if (cluster.count === 1) {
-        // 只有一个设备时显示设备图标
-        const device = cluster.devices?.[0]
-        if (device) {
-          const point = new (window as any).BMap.Point(device.lng, device.lat)
-          const marker = createDeviceMarker(device, point)
-          map.value.addOverlay(marker)
-        }
-      } else if (cluster.count > 1) {
-        // 多个设备时显示聚合标签
-        const point = new (window as any).BMap.Point(cluster.lng, cluster.lat)
-        const label = createClusterLabel(cluster, point)
-        map.value.addOverlay(label)
-      }
-    })
+
+const keyword = ref('')
+const statusFilter = ref<StatusFilter>('all')
+const mapMode = ref<MapMode>('TMAP_NORMAL_MAP')
+
+const initialCenter: [number, number] = [104.114129, 36.550339]
+const initialZoom = 5
+const mapControls = ['Scale']
+
+const queryParams = reactive({
+  deptId: 156
+})
+
+const devices = ref<MapDevice[]>([])
+const currentZoom = ref(initialZoom)
+const markerIconCache = new Map<
+  string,
+  { iconUrl: string; iconSize: [number, number]; iconAnchor: [number, number] }
+>()
+
+const markerGlyphBodyMap: Record<MarkerGlyphName, string> = {
+  'mdi:access-point':
+    '<path fill="currentColor" d="M4.93 4.93A9.97 9.97 0 0 0 2 12c0 2.76 1.12 5.26 2.93 7.07l1.41-1.41A7.94 7.94 0 0 1 4 12c0-2.21.89-4.22 2.34-5.66zm14.14 0l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 2.22-.89 4.22-2.34 5.66l1.41 1.41A9.97 9.97 0 0 0 22 12c0-2.76-1.12-5.26-2.93-7.07M7.76 7.76A5.98 5.98 0 0 0 6 12c0 1.65.67 3.15 1.76 4.24l1.41-1.41A4 4 0 0 1 8 12c0-1.11.45-2.11 1.17-2.83zm8.48 0l-1.41 1.41A4 4 0 0 1 16 12c0 1.11-.45 2.11-1.17 2.83l1.41 1.41A5.98 5.98 0 0 0 18 12c0-1.65-.67-3.15-1.76-4.24M12 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2"/>',
+  'mdi:access-point-off':
+    '<path fill="currentColor" d="M20.84 22.73L12.1 14H12a2 2 0 0 1-2-2v-.1l-1.6-1.61c-.25.52-.4 1.09-.4 1.71c0 1.11.45 2.11 1.17 2.83l-1.41 1.41A5.98 5.98 0 0 1 6 12c0-1.17.34-2.26.93-3.18L5.5 7.37C4.55 8.67 4 10.27 4 12c0 2.22.89 4.22 2.34 5.66l-1.41 1.41A9.97 9.97 0 0 1 2 12c0-2.28.77-4.37 2.06-6.05L1.11 3l1.28-1.27l19.72 19.73zm-4.91-10l1.6 1.6c.3-.72.47-1.5.47-2.33c0-1.65-.67-3.15-1.76-4.24l-1.41 1.41a3.99 3.99 0 0 1 1.1 3.56m3.1 3.1l1.47 1.45c.94-1.53 1.5-3.34 1.5-5.28c0-2.76-1.12-5.26-2.93-7.07l-1.41 1.41A7.96 7.96 0 0 1 20 12c0 1.39-.35 2.7-.97 3.83"/>'
+}
+
+const markerThemeMap: Record<'online' | 'offline', MarkerTheme> = {
+  online: {
+    key: 'online',
+    iconName: 'mdi:access-point',
+    startColor: '#0f766e',
+    endColor: '#2dd4bf',
+    ringColor: '#ccfbf1',
+    badgeColor: '#f8fffd',
+    statusColor: '#10b981',
+    shadowColor: 'rgba(15, 118, 110, 0.3)',
+    iconColor: '#0f766e'
+  },
+  offline: {
+    key: 'offline',
+    iconName: 'mdi:access-point-off',
+    startColor: '#ea580c',
+    endColor: '#fb7185',
+    ringColor: '#ffe4e6',
+    badgeColor: '#fffaf8',
+    statusColor: '#f97316',
+    shadowColor: 'rgba(234, 88, 12, 0.28)',
+    iconColor: '#c2410c'
   }
 }
 
-const createDeviceMarker = (device: IotDeviceVO, point: any) => {
-  // 根据设备是否在线选择不同的图标
-  const iconUrl = device.ifInline === 3
-    ? import.meta.env.VITE_BASE_URL + '/images/newding.svg'
-    : import.meta.env.VITE_BASE_URL + '/images/newfail.svg';
-debugger
-  const marker = new (window as any).BMap.Marker(point, {
-    icon: new (window as any).BMap.Icon(
-      iconUrl,
-      new (window as any).BMap.Size(40, 47),
-      {
-        anchor: new (window as any).BMap.Size(25, 40)
-        // imageOffset: new (window as any).BMap.Size(0, -5)
-      }
-    )
-  });
-
-  // 添加点击事件
-  marker.addEventListener('click', () => {
-    showDeviceInfoWindow(device, point);
-  });
-
-  return marker;
-};
-
-const createClusterLabel = (cluster: Cluster, point: any) => {
-  const label = new (window as any).BMap.Label(cluster.count.toString(), {
-    position: point,
-    offset: new (window as any).BMap.Size(-10, -10)
-  })
+const statusOptions = [
+  { label: '全部', value: 'all' },
+  { label: '在线', value: 'online' },
+  { label: '离线', value: 'offline' }
+] as const
 
-  // 创建一个 style 标签并添加到 head 中,定义呼吸动画
-  const style = document.createElement('style')
-  style.textContent = `
-    @keyframes breathing {
-      0% {
-        border-color: rgba(255, 255, 255, 0.3);
-      }
-      50% {
-        border-color: rgba(255, 255, 255, 0.8);
-      }
-      100% {
-        border-color: rgba(255, 255, 255, 0.3);
-      }
-    }
-  `
-  document.head.appendChild(style)
-
-  // 根据 cluster.count 的值设置不同的背景颜色
-  const backgroundColor = cluster.count > 10 ? '#1d4eed' : '#f67d1a';
-
-  // 初始样式
-  label.setStyle({
-    color: '#fff',
-    backgroundColor: backgroundColor,
-    borderRadius: '50%',
-    width: '50px',
-    height: '50px',
-    textAlign: 'center',
-    lineHeight: '40px',
-    cursor: 'pointer',
-    fontSize: '18px',
-    fontWeight: 'bold',
-    boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
-    transition: 'all 0.3s ease',
-    border: '6px solid rgba(255, 255, 255, 0.5)', // 添加带有不透明度的边框
-    animation: 'breathing 1.5s infinite' // 添加呼吸动画
+const totalCount = computed(() => devices.value.length)
+const onlineCount = computed(() => devices.value.filter((item) => isOnline(item)).length)
+const offlineCount = computed(() => devices.value.filter((item) => !isOnline(item)).length)
+
+const filteredDevices = computed(() => {
+  const text = keyword.value.trim().toLowerCase()
+  return devices.value.filter((item) => {
+    const matchKeyword =
+      !text ||
+      [item.deviceName, item.deviceCode, item.location]
+        .filter(Boolean)
+        .some((value) => String(value).toLowerCase().includes(text))
+
+    const matchStatus =
+      statusFilter.value === 'all' ||
+      (statusFilter.value === 'online' && isOnline(item)) ||
+      (statusFilter.value === 'offline' && !isOnline(item))
+
+    return matchKeyword && matchStatus
   })
+})
 
-  // 鼠标悬停样式
-  const hoverStyle = {
-    backgroundColor: '#2196df',
-    transform: 'scale(1.1)'
+const filteredCount = computed(() => filteredDevices.value.length)
+const markerDevices = computed(() =>
+  filteredDevices.value
+    .filter((item) => hasValidPosition(item))
+    .map((item) => ({
+      ...item,
+      position: [Number(item.lng), Number(item.lat)] as [number, number],
+      icon: getMarkerIcon(item)
+    }))
+)
+const clusterMarkers = computed(() =>
+  markerDevices.value.map((item) => ({
+    position: item.position,
+    icon: item.icon,
+    title: item.deviceName || item.deviceCode || '',
+    zIndexOffset: isOnline(item) ? 240 : 180,
+    extData: item
+  }))
+)
+const deptNameMap = computed(() => {
+  return deptList.value.reduce((acc, cur) => {
+    acc.set(Number(cur.id), String(cur.name).trim())
+    return acc
+  }, new Map<number, string>())
+})
+
+onMounted(async () => {
+  await Promise.all([loadDeptTree(), loadDevices()])
+})
+
+const loadDeptTree = async () => {
+  const res = await DeptApi.getSimpleDeptList()
+  deptList.value = res || []
+  treeList.value = handleTree(res || [])
+}
+
+const loadDevices = async () => {
+  loading.value = true
+  try {
+    const res = await IotDeviceApi.getMapDevice(queryParams)
+    devices.value = res || []
+  } catch (error) {
+    message.error('地图设备加载失败,请稍后重试')
+  } finally {
+    loading.value = false
   }
+}
+
+const handleDeptChange = () => {
+  return loadDevices()
+}
+
+const handleMapInit = (map: any) => {
+  mapRef.value = map
+  applyMapMode()
+  syncCurrentZoom()
+}
+
+const syncCurrentZoom = () => {
+  currentZoom.value = mapRef.value?.getZoom?.() ?? initialZoom
+}
+
+const zoomIn = () => {
+  mapRef.value?.zoomIn?.()
+}
+
+const zoomOut = () => {
+  mapRef.value?.zoomOut?.()
+}
+
+const switchMapMode = (mode: MapMode) => {
+  mapMode.value = mode
+  applyMapMode()
+}
+
+const applyMapMode = () => {
+  if (!mapRef.value) return
+  ;(mapRef.value as any).setMapType?.((window as any)[mapMode.value])
+}
+
+const isOnline = (device?: MapDevice | null) => {
+  return Number(device?.ifInline) === 3
+}
+
+const hasValidPosition = (device?: MapDevice | null) => {
+  const lng = Number(device?.lng)
+  const lat = Number(device?.lat)
+
+  return Number.isFinite(lng) && Number.isFinite(lat) && lng !== 0 && lat !== 0
+}
 
-  // 鼠标移出样式
-  const normalStyle = {
-    backgroundColor: backgroundColor,
-    transform: 'scale(1)'
+const getMarkerIcon = (device?: MapDevice | null) => {
+  const theme = isOnline(device) ? markerThemeMap.online : markerThemeMap.offline
+  const cachedIcon = markerIconCache.get(theme.key)
+
+  if (cachedIcon) return cachedIcon
+
+  const markerIcon = {
+    iconUrl: svgToDataUri(buildMarkerSvg(theme)),
+    iconSize: [46, 58] as [number, number],
+    iconAnchor: [23, 49] as [number, number]
   }
 
-  // 添加点击事件
-  label.addEventListener('click', () => {
-    if (map.value) {
-      const currentZoom = map.value.getZoom();
-      const MAX_ZOOM = 12; // 手动设定最大缩放级别
-      const newZoom = Math.min(currentZoom + 2, MAX_ZOOM);
-      map.value.setZoom(newZoom);
-      map.value.panTo(point);
-      initDeviceMarkers(); // 重新计算并显示标记和聚合标签
-    }
-  })
+  markerIconCache.set(theme.key, markerIcon)
+  return markerIcon
+}
 
-  return label
+const buildMarkerSvg = (theme: MarkerTheme) => {
+  const glyph = renderMarkerGlyph(theme.iconName, 18)
+
+  return `
+    <svg xmlns="http://www.w3.org/2000/svg" width="46" height="58" viewBox="0 0 46 58" fill="none">
+      <defs>
+        <linearGradient id="marker-gradient-${theme.key}" x1="10" y1="6" x2="35" y2="45" gradientUnits="userSpaceOnUse">
+          <stop stop-color="${theme.startColor}" />
+          <stop offset="1" stop-color="${theme.endColor}" />
+        </linearGradient>
+        <filter id="marker-shadow-${theme.key}" x="2" y="1" width="42" height="54" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+          <feDropShadow dx="0" dy="6" stdDeviation="4" flood-color="${theme.shadowColor}" />
+        </filter>
+      </defs>
+      <ellipse cx="23" cy="54.5" rx="8.5" ry="2.5" fill="rgba(15, 23, 42, 0.12)" />
+      <g filter="url(#marker-shadow-${theme.key})">
+        <path
+          d="M23 3C14.72 3 8 9.72 8 18c0 11.39 11.46 23.89 14.02 30.68a1.06 1.06 0 0 0 1.96 0C26.54 41.89 38 29.39 38 18C38 9.72 31.28 3 23 3Z"
+          fill="url(#marker-gradient-${theme.key})"
+        />
+        <path
+          d="M23 3C14.72 3 8 9.72 8 18c0 11.39 11.46 23.89 14.02 30.68a1.06 1.06 0 0 0 1.96 0C26.54 41.89 38 29.39 38 18C38 9.72 31.28 3 23 3Z"
+          stroke="rgba(255, 255, 255, 0.74)"
+          stroke-width="1.2"
+        />
+        <path
+          d="M14 10.5C16.72 7.56 19.82 6.09 23.31 6.09c2.98 0 5.81 1.02 8.48 3.06c2.66 2.04 4 4.44 4 7.18c0 1.09-.2 2.18-.6 3.28c-.4-4.09-1.85-7.16-4.35-9.23c-2.49-2.06-5.18-3.1-8.08-3.1c-3.09 0-6 1.08-8.76 3.22Z"
+          fill="rgba(255, 255, 255, 0.22)"
+        />
+        <circle cx="23" cy="18" r="11.4" fill="${theme.badgeColor}" />
+        <circle cx="23" cy="18" r="11.4" stroke="${theme.ringColor}" stroke-width="1.8" />
+        <g transform="translate(14 9)" color="${theme.iconColor}" fill="${theme.iconColor}">
+          ${glyph}
+        </g>
+        <circle cx="31.2" cy="26.6" r="3.7" fill="${theme.statusColor}" stroke="white" stroke-width="1.4" />
+      </g>
+    </svg>
+  `.trim()
 }
 
-const clusterDevices = (devices: IotDeviceVO[], map: any): Cluster[] => {
-  const clusters: Cluster[] = []
-  const gridSize = getGridSize(map.getZoom())
+const renderMarkerGlyph = (iconName: MarkerGlyphName, boxSize = 18) => {
+  const iconBody = markerGlyphBodyMap[iconName]
+  const iconScale = boxSize / 24
 
-  const gridMap = new Map<string, Cluster>()
+  return `
+    <g transform="scale(${iconScale})">
+      ${iconBody}
+    </g>
+  `.trim()
+}
 
-  devices.forEach((device) => {
-    const gridKey = `${Math.floor(device.lng / gridSize)}_${Math.floor(device.lat / gridSize)}`
+const svgToDataUri = (svg: string) => {
+  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
+}
 
-    if (!gridMap.has(gridKey)) {
-      gridMap.set(gridKey, {
-        lng: Math.floor(device.lng / gridSize) * gridSize + gridSize / 2,
-        lat: Math.floor(device.lat / gridSize) * gridSize + gridSize / 2,
-        count: 0,
-        devices: []
-      })
-    }
+const clusterStyles = [
+  {
+    size: 80,
+    textColor: '#ffffff',
+    textSize: 14,
+    range: [0, 20]
+  },
+  {
+    size: 80,
+    textColor: '#ffffff',
+    textSize: 14,
+    range: [20, 100]
+  },
+  {
+    size: 80,
+    textColor: '#ffffff',
+    textSize: 14,
+    range: [100, 99999]
+  }
+]
 
-    const cluster = gridMap.get(gridKey)!
-    cluster.count++
-    if (!cluster.devices) cluster.devices = []
-    cluster.devices.push(device)
-  })
+const handleMarkerClick = (e: any) => {
+  const device = e?.layer?.extData as MapDevice | undefined
 
-  return Array.from(gridMap.values())
-}
-
-const getGridSize = (zoom: number): number => {
-  debugger
-  if (zoom <= 5) return 5
-  if (zoom <= 8) return 3
-  if (zoom < 10) return 0.1
-  return 0.1
-}
-
-const showDeviceInfoWindow = (device: IotDeviceVO, point: any) => {
-  DeptApi.getDept(device.deptId).then(res=>{
-    dept.value = res.name
-    const content = `
-<div style="display: flex;flex-direction: column;justify-content: center;border: 1px solid #ccc;">
-        <div style="margin-top: 1px;padding: 8px">
-          <p><strong>${t('iotDevice.code')}:</strong> ${device.deviceCode}</p>
-          <p><strong>${t('iotDevice.name')}:</strong> ${device.deviceName}</p>
-          <p><strong>${t('iotDevice.dept')}:</strong> ${res.name}</p>
-          <p><strong>${t('form.position')}:</strong> ${device.location?device.location.replaceAll('"',''):''}</p>
-          <p><strong>${t('dict.status')}:</strong> ${getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device.deviceStatus)}</p>
-          <p><strong>${t('monitor.online')}:</strong> ${getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device.ifInline)}</p>
-          <p><strong>${t('monitor.latestDataTime')}:</strong> ${device.lastInlineTime}</p>
-        </div>
-        <div style="margin-bottom: 5px;padding: 8px">
-            <button id="device-detail-btn" style=" background-color: #2196f3;
-              color: white;
-              border: none;
-              padding: 8px 16px;
-              border-radius: 4px;
-
-              cursor: pointer;">${t('fault.view')}</button>
-        </div>
-        </div>
-      `
-    const infoWindow = new (window as any).BMap.InfoWindow(content, {
-      width: 350,
-      height: 270
-    })
-    map.value.openInfoWindow(infoWindow, point)
-
-    // 事件绑定(需延迟确保DOM加载)
-    setTimeout(function () {
-      document.getElementById('device-detail-btn').addEventListener('click', function () {
-        drawerVisible.value = true
-        deviceId.value = device.id;
-        deviceCode.value = device.deviceCode
-        deviceName.value = device.deviceName
-        ifLine.value = device.ifInline
-        lastLineTime.value = device.lastInlineTime
-        showDrawer.value.openDrawer()
-
-      })
-    }, 200)
-  })
+  if (!device) return
 
+  openInfoWindow(device, resolveLngLat(e?.lnglat))
 }
 
-const zoomIn = () => {
-  if (map.value) {
-    map.value.zoomIn()
+const handleInfoWindowTargetChange = (target: [number, number] | null) => {
+  infoWindowTarget.value = target
+
+  if (!target) {
+    activeDevice.value = null
   }
 }
 
-const zoomOut = () => {
-  if (map.value) {
-    map.value.zoomOut()
+const openInfoWindow = (device: MapDevice, target?: [number, number] | null) => {
+  if (!target && !hasValidPosition(device)) return
+
+  activeDevice.value = device
+  infoWindowTarget.value = target ?? [Number(device.lng), Number(device.lat)]
+}
+
+const closeInfoWindow = () => {
+  activeDevice.value = null
+  infoWindowTarget.value = null
+}
+
+const resolveLngLat = (lnglat: any): [number, number] | null => {
+  const lng = Number(lnglat?.lng ?? lnglat?.getLng?.())
+  const lat = Number(lnglat?.lat ?? lnglat?.getLat?.())
+
+  if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
+    return null
   }
+
+  return [lng, lat]
 }
 
-const toggleMapType = () => {
-  if (!map.value) return
+const getDeptName = (device?: MapDevice | null) => {
+  return deptNameMap.value.get(Number(device?.deptId)) || '-'
+}
 
-  mapType.value = mapType.value === 'BMAP_NORMAL_MAP' ? 'BMAP_SATELLITE_MAP' : 'BMAP_NORMAL_MAP'
-  map.value.setMapType((window as any)[mapType.value])
+const getDeviceStatusText = (device?: MapDevice | null) => {
+  return getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device?.deviceStatus) || '-'
 }
-const queryParams = reactive({
-  deptId: undefined
-})
-const getData = async () => {
-  debugger
-  await IotDeviceApi.getMapDevice(queryParams).then((res) => {
-    devices.value = res.filter((item)=> item.lat!=0&&item.lng!=0)
-    outlineCount.value = devices.value.filter((item)=> item.ifInline===4).length
-    inlineCount.value = devices.value.filter((item)=> item.ifInline===3).length
-    initMap()
-  })
+
+const getDeviceOnlineText = (device?: MapDevice | null) => {
+  return (
+    getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device?.ifInline) ||
+    (isOnline(device) ? '在线' : '离线')
+  )
 }
-onMounted(async () => {
-  await getData()
-  const res = await DeptApi.getSimpleDeptList()
-  treeList.value = []
-  treeList.value.push(...handleTree(res))
-  debugger
-})
 
-onBeforeUnmount(() => {
-  if (map.value) {
-    // 清除地图上的覆盖物
-    map.value.clearOverlays();
-    // 移除地图容器中的内容
-    if (mapContainer.value) {
-      mapContainer.value.innerHTML = '';
-    }
-    // 解除地图的事件绑定
-    map.value.removeEventListener('zoomend', initDeviceMarkers);
+const getDeviceLocationText = (device?: MapDevice | null) => {
+  const location = String(device?.location || '')
+    .replaceAll('"', '')
+    .trim()
+
+  return location || '-'
+}
+
+const openDeviceDrawer = () => {
+  if (!activeDevice.value) return
+
+  drawerVisible.value = true
+  deviceId.value = activeDevice.value.id
+  deviceCode.value = activeDevice.value.deviceCode || ''
+  drawerDeviceName.value = activeDevice.value.deviceName || ''
+  ifLine.value = activeDevice.value.ifInline
+  lastLineTime.value = activeDevice.value.lastInlineTime || ''
+  dept.value = getDeptName(activeDevice.value)
+  showDrawer.value?.openDrawer?.()
+}
+
+watch(filteredDevices, (list) => {
+  if (!activeDevice.value) return
+
+  const nextDevice = list.find(
+    (item) => item.id === activeDevice.value?.id && hasValidPosition(item)
+  )
+
+  if (!nextDevice) {
+    closeInfoWindow()
+    return
   }
+
+  activeDevice.value = nextDevice
+  infoWindowTarget.value = [Number(nextDevice.lng), Number(nextDevice.lat)]
 })
 
-//     return {
-//       mapContainer,
-//       selectedDevice,
-//       hoverDevice,
-//       // deviceStatusMap,
-//       zoomIn,
-//       zoomOut,
-//       toggleMapType
-//     }
-// })
+const handleClusterInit = (clusterer: any) => {
+  clusterer?.setMaxZoom?.(16)
+}
 </script>
 
-<style scoped>
-.map-container {
+<template>
+  <div v-loading="loading" class="device-map-page">
+    <tdt-map
+      :center="initialCenter"
+      :zoom="initialZoom"
+      :controls="mapControls"
+      :min-zoom="3"
+      @init="handleMapInit"
+      @zoomend="syncCurrentZoom"
+    >
+      <tdt-marker-clusterer
+        :markers="clusterMarkers"
+        :styles="clusterStyles"
+        @click="handleMarkerClick"
+        @init="handleClusterInit"
+      />
+
+      <tdt-infowindow
+        :target="infoWindowTarget"
+        :min-width="280"
+        :max-width="360"
+        :offset="[0, -30]"
+        :auto-pan="true"
+        :close-on-click="true"
+        @update:target="handleInfoWindowTargetChange"
+      >
+        <div v-if="activeDevice" class="device-infowindow">
+          <div class="device-infowindow__list">
+            <div class="device-infowindow__row">
+              <span>历史编码</span>
+              <strong>{{ activeDevice.deviceCode || '-' }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>设备名称</span>
+              <strong>{{ activeDevice.deviceName || '-' }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>所在部门</span>
+              <strong>{{ getDeptName(activeDevice) }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>位置</span>
+              <strong>{{ getDeviceLocationText(activeDevice) }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>状态</span>
+              <strong>{{ getDeviceStatusText(activeDevice) }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>在线状态</span>
+              <strong>{{ getDeviceOnlineText(activeDevice) }}</strong>
+            </div>
+            <div class="device-infowindow__row">
+              <span>最后在线时间</span>
+              <strong>{{ activeDevice.lastInlineTime || '暂无数据' }}</strong>
+            </div>
+          </div>
+
+          <div class="device-infowindow__footer">
+            <button type="button" class="device-infowindow__button" @click="openDeviceDrawer">
+              查看监测详情
+            </button>
+          </div>
+        </div>
+      </tdt-infowindow>
+
+      <tdt-control position="topleft" visible>
+        <div
+          class="floating-panel hero-panel"
+          @click.stop
+          @dblclick.stop
+          @mousedown.stop
+          @mouseup.stop
+          @mousemove.stop
+          @pointerdown.stop
+          @pointerup.stop
+          @pointermove.stop
+          @touchstart.stop
+          @touchmove.stop
+          @touchend.stop
+          @wheel.stop
+        >
+          <div class="hero-panel__head">
+            <div>
+              <div class="hero-panel__kicker">PMS · TianDiTu</div>
+              <div class="hero-panel__title">设备智览地图</div>
+              <div class="hero-panel__desc">
+                设备位置、在线状态与监测入口,支持部门筛选、聚合放大和一键追踪。
+              </div>
+            </div>
+            <div
+              class="hero-panel__map-mode -translate-y-1"
+              :class="mapMode === 'TMAP_SATELLITE_MAP' ? 'is-satellite' : 'is-map'"
+            >
+              <button
+                type="button"
+                class="mode-chip text-gray-6"
+                :class="{ 'is-active': mapMode === 'TMAP_NORMAL_MAP' }"
+                @click="switchMapMode('TMAP_NORMAL_MAP')"
+              >
+                地图
+              </button>
+              <button
+                type="button"
+                class="mode-chip text-gray-6"
+                :class="{ 'is-active': mapMode === 'TMAP_SATELLITE_MAP' }"
+                @click="switchMapMode('TMAP_SATELLITE_MAP')"
+              >
+                卫星
+              </button>
+            </div>
+          </div>
+
+          <div class="hero-stats">
+            <div class="hero-stat hero-stat--primary">
+              <span>设备总数</span>
+              <strong>{{ totalCount }}</strong>
+            </div>
+            <div class="hero-stat hero-stat--success">
+              <span>在线设备</span>
+              <strong>{{ onlineCount }}</strong>
+            </div>
+            <div class="hero-stat hero-stat--danger">
+              <span>离线设备</span>
+              <strong>{{ offlineCount }}</strong>
+            </div>
+            <div class="hero-stat hero-stat--neutral">
+              <span>筛选结果</span>
+              <strong>{{ filteredCount }}</strong>
+            </div>
+          </div>
+        </div>
+      </tdt-control>
+
+      <tdt-control position="topright" visible>
+        <div
+          class="floating-panel toolbar-panel p-4!"
+          @click.stop
+          @dblclick.stop
+          @mousedown.stop
+          @mouseup.stop
+          @mousemove.stop
+          @pointerdown.stop
+          @pointerup.stop
+          @pointermove.stop
+          @touchstart.stop
+          @touchmove.stop
+          @touchend.stop
+          @wheel.stop
+        >
+          <el-form size="default" label-position="top">
+            <el-form-item label="关键词">
+              <el-input v-model="keyword" clearable placeholder="设备名称 / 编码 / 位置">
+                <template #prefix>
+                  <Icon icon="ep:search" />
+                </template>
+              </el-input>
+            </el-form-item>
+
+            <el-form-item label="部门筛选">
+              <el-tree-select
+                v-model="queryParams.deptId"
+                :data="treeList"
+                :props="defaultProps"
+                check-strictly
+                node-key="id"
+                filterable
+                clearable
+                :placeholder="t('deviceForm.deptHolder')"
+                @change="handleDeptChange"
+              />
+            </el-form-item>
+
+            <el-form-item label="设备状态">
+              <div class="status-tabs">
+                <button
+                  v-for="item in statusOptions"
+                  :key="item.value"
+                  type="button"
+                  class="status-tab"
+                  :class="{ 'is-active': statusFilter === item.value }"
+                  @click="statusFilter = item.value"
+                >
+                  {{ item.label }}
+                </button>
+              </div>
+            </el-form-item>
+          </el-form>
+
+          <div class="toolbar-panel__footer">
+            <div class="toolbar-badges">
+              <span class="toolbar-badge">缩放 {{ currentZoom }}</span>
+            </div>
+
+            <div class="toolbar-actions">
+              <button type="button" class="icon-action" @click="zoomOut">
+                <Icon icon="ep:minus" />
+              </button>
+              <button type="button" class="icon-action" @click="zoomIn">
+                <Icon icon="ep:plus" />
+              </button>
+            </div>
+          </div>
+        </div>
+      </tdt-control>
+
+      <tdt-control position="bottomright" visible>
+        <div class="floating-panel p-4!">
+          <div class="legend-panel__title">图例说明</div>
+          <div class="legend-list">
+            <div class="legend-item">
+              <span class="legend-chip legend-chip--online">
+                <span class="i-mdi:access-point legend-chip__icon"></span>
+              </span>
+              <span>在线设备</span>
+            </div>
+            <div class="legend-item">
+              <span class="legend-chip legend-chip--offline">
+                <span class="i-mdi:access-point-off legend-chip__icon"></span>
+              </span>
+              <span>离线设备</span>
+            </div>
+            <div class="legend-item">
+              <span class="legend-chip legend-chip--cluster">
+                <span class="i-mdi:google-circles-communities legend-chip__icon"></span>
+              </span>
+              <span>聚合点位</span>
+            </div>
+          </div>
+        </div>
+      </tdt-control>
+    </tdt-map>
+
+    <DeviceMonitorDrawer
+      :model-value="drawerVisible"
+      @update:model-value="(val) => (drawerVisible = val)"
+      :id="deviceId"
+      :deviceName="drawerDeviceName"
+      :lastLineTime="lastLineTime"
+      :ifLine="ifLine"
+      :dept="dept"
+      :deviceCode="deviceCode"
+      ref="showDrawer"
+    />
+  </div>
+</template>
+
+<style scoped lang="scss">
+.device-map-page {
+  --page-height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  --panel-bg: rgb(255 255 255 / 78%);
+  --panel-border: rgb(255 255 255 / 58%);
+  --panel-shadow: 0 22px 50px rgb(15 23 42 / 14%);
+  --text-main: #0f172a;
+  --text-sub: #526072;
+  --teal: #0f766e;
+  --orange: #f97316;
+  --slate: #334155;
+
   position: relative;
-  width: 100%;
-  height: 90vh;
+  height: var(--page-height);
+  overflow: hidden;
+  border: 1px solid rgb(203 213 225 / 70%);
+  border-radius: 20px;
+  box-shadow: 0 24px 60px rgb(15 23 42 / 12%);
 }
 
-#baidu-map {
-  width: 100%;
-  height: 100%;
+.floating-panel {
+  padding: 24px;
+  background: var(--panel-bg);
+  border: 1px solid var(--panel-border);
+  border-radius: 16px;
+  box-shadow: var(--panel-shadow);
+  backdrop-filter: blur(18px);
 }
 
-.map-controls {
-  position: absolute;
-  top: 20px;
-  right: 20px; /* 修改为right定位 */
-  z-index: 1000;
+.hero-panel__head {
   display: flex;
-  flex-direction: column; /* 垂直排列按钮 */
-  gap: 10px;
+  gap: 20px;
+  justify-content: space-between;
 }
 
-.map-controls button {
-  padding: 5px 10px;
-  background: #fff;
-  border: 1px solid #ccc;
-  border-radius: 3px;
-  cursor: pointer;
+.hero-panel__kicker {
+  margin-bottom: 6px;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.16em;
+  color: var(--teal);
+  text-transform: uppercase;
+}
+
+.hero-panel__title {
+  font-size: 28px;
+  font-weight: 700;
+  line-height: 1.15;
+  color: var(--text-main);
+}
+
+.hero-panel__desc {
+  max-width: 460px;
+  margin-top: 8px;
+  font-size: 14px;
+  line-height: 1.6;
+  color: var(--text-sub);
+}
+
+.hero-panel__map-mode {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  height: fit-content;
+  min-width: 152px;
+  padding: 4px;
+  gap: 0;
+  align-items: center;
+  background: linear-gradient(180deg, rgb(255 255 255 / 92%), rgb(241 245 249 / 90%));
+  border: 1px solid rgb(255 255 255 / 90%);
+  border-radius: 999px;
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 92%),
+    0 8px 20px rgb(15 23 42 / 8%);
 }
-.status-info {
+
+.hero-panel__map-mode::before {
   position: absolute;
-  top: 20px;
-  left: 40%;
-  z-index: 1000;
-  background: white;
-  padding: 10px 15px;
-  border-radius: 6px;
-  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+  top: 4px;
+  left: 4px;
+  width: calc((100% - 8px) / 2);
+  height: calc(100% - 8px);
+  background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
+  border-radius: 999px;
+  content: '';
+  transform: translateX(0);
+  box-shadow:
+    0 6px 14px rgb(15 118 110 / 16%),
+    inset 0 1px 0 rgb(255 255 255 / 22%);
+  transition: transform 0.22s ease;
+}
+
+.hero-panel__map-mode.is-satellite::before {
+  transform: translateX(100%);
+}
+
+.mode-chip {
+  position: relative;
+  z-index: 1;
+  display: inline-flex;
+  height: 36px;
+  min-width: 0;
+  padding: 0 18px;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1;
+  text-align: center;
+  white-space: nowrap;
+  cursor: pointer;
+  background: transparent;
+  border: none;
+  border-radius: 999px;
+  transition:
+    color 0.18s ease,
+    opacity 0.18s ease;
+  align-items: center;
+  justify-content: center;
+}
+
+.mode-chip:not(.is-active) {
+  opacity: 0.82;
+}
+
+.mode-chip.is-active {
+  color: #fff;
+  opacity: 1;
+}
+
+.hero-stats {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.hero-stat {
+  padding: 14px 16px;
+  background: rgb(255 255 255 / 72%);
+  border-radius: 20px;
+}
+
+.hero-stat span {
+  display: block;
+  font-size: 12px;
+  color: var(--text-sub);
+}
+
+.hero-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 28px;
+  line-height: 1;
+  color: var(--text-main);
+}
+
+.hero-stat--primary {
+  background: linear-gradient(135deg, rgb(15 118 110 / 12%), rgb(20 184 166 / 8%));
+}
+
+.hero-stat--success {
+  background: linear-gradient(135deg, rgb(34 197 94 / 12%), rgb(45 212 191 / 8%));
+}
+
+.hero-stat--danger {
+  background: linear-gradient(135deg, rgb(249 115 22 / 12%), rgb(239 68 68 / 8%));
+}
+
+.hero-stat--neutral {
+  background: linear-gradient(135deg, rgb(148 163 184 / 12%), rgb(203 213 225 / 8%));
+}
+
+.toolbar-panel {
+  width: min(320px, calc(100vw - 64px));
+}
+
+.status-tabs {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 8px;
+  width: 100%;
+}
+
+.status-tab {
+  padding: 10px 12px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-sub);
+  cursor: pointer;
+  background: rgb(255 255 255 / 72%);
+  border: 1px solid rgb(148 163 184 / 18%);
+  border-radius: 14px;
+  transition: all 0.22s ease;
+}
+
+.status-tab.is-active {
+  color: var(--teal);
+  background: linear-gradient(135deg, rgb(15 118 110 / 12%), rgb(20 184 166 / 8%));
+  border-color: rgb(15 118 110 / 18%);
+}
+
+.toolbar-panel__footer {
   display: flex;
-  gap: 20px;
+  gap: 14px;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 16px;
 }
 
-.status-item {
+.toolbar-badges {
   display: flex;
+  flex: 1;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.toolbar-badge {
+  display: inline-flex;
+  padding: 7px 10px;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--slate);
+  background: rgb(255 255 255 / 86%);
+  border-radius: 999px;
+  align-items: center;
+}
+
+.toolbar-actions {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  align-items: center;
+}
+
+.icon-action {
+  display: inline-flex;
+  width: 40px;
+  height: 40px;
+  font-size: 16px;
+  color: var(--text-main);
+  cursor: pointer;
+  background: linear-gradient(135deg, rgb(255 255 255 / 96%), rgb(248 250 252 / 86%));
+  border: none;
+  border-radius: 14px;
+  box-shadow: 0 12px 24px rgb(15 23 42 / 8%);
+  transition: transform 0.2s ease;
+  justify-content: center;
   align-items: center;
+}
+
+.icon-action:hover {
+  transform: translateY(-1px);
+}
+
+.legend-panel__title {
+  margin-bottom: 10px;
+  font-size: 14px;
+  font-weight: 700;
+  color: var(--text-main);
+}
+
+.legend-list {
+  display: flex;
+  gap: 14px;
+  flex-wrap: wrap;
+}
+
+.legend-item {
+  display: inline-flex;
+  font-size: 13px;
+  color: var(--text-sub);
   gap: 8px;
+  align-items: center;
+}
+
+.legend-chip {
+  display: inline-flex;
+  width: 26px;
+  height: 26px;
+  color: #fff;
+  border-radius: 999px;
+  box-shadow: 0 10px 22px rgb(15 23 42 / 12%);
+  align-items: center;
+  justify-content: center;
+}
+
+.legend-chip--online {
+  background: linear-gradient(135deg, #0f766e, #2dd4bf);
+}
+
+.legend-chip--offline {
+  background: linear-gradient(135deg, #ea580c, #fb7185);
+}
+
+.legend-chip--cluster {
+  background: linear-gradient(135deg, #0369a1, #1d4ed8);
+}
+
+.legend-chip__icon {
   font-size: 14px;
-  height: 50px;
 }
 
-.online-icon {
-  display: inline-block;
-  width: 25px;
-  height: 25px;
-  background-image: url('https://aims.deepoil.cc/images/newding.svg');
-  background-size: contain;
-  background-repeat: no-repeat;
-  background-position: center;
+.device-infowindow {
+  min-width: 280px;
+  color: var(--text-main);
+}
+
+.device-infowindow__list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.device-infowindow__row {
+  display: grid;
+  grid-template-columns: 92px minmax(0, 1fr);
+  gap: 12px;
+  padding: 10px 12px;
+  background: linear-gradient(180deg, rgb(248 250 252 / 96%), rgb(241 245 249 / 90%));
+  border: 1px solid rgb(226 232 240 / 90%);
+  border-radius: 14px;
+  align-items: start;
+}
+
+.device-infowindow__row span {
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1.6;
+  color: var(--text-sub);
 }
 
-.offline-icon {
-  display: inline-block;
-  width: 25px;
-  height: 25px;
-  background-image: url('https://aims.deepoil.cc/images/newfail.svg');
-  background-size: contain;
-  background-repeat: no-repeat;
-  background-position: center;
+.device-infowindow__row strong {
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.5;
+  color: var(--text-main);
+  word-break: break-word;
 }
 
+.device-infowindow__footer {
+  margin-top: 14px;
+}
 
-:deep(.el-select__selection) {
-  height: 35px; /* 自定义高度 */
+.device-infowindow__button {
+  display: inline-flex;
+  width: 100%;
+  padding: 11px 16px;
+  font-size: 13px;
+  font-weight: 700;
+  color: #fff;
+  cursor: pointer;
+  background: linear-gradient(135deg, #0f766e, #14b8a6);
+  border: none;
+  border-radius: 14px;
+  box-shadow: 0 12px 22px rgb(15 118 110 / 20%);
+  transition:
+    transform 0.18s ease,
+    box-shadow 0.18s ease,
+    opacity 0.18s ease;
+  justify-content: center;
+  align-items: center;
 }
-:deep(.el-select__placeholder.is-transparent){
-  color: orangered;
+
+.device-infowindow__button:hover {
+  transform: translateY(-1px);
+  box-shadow: 0 14px 26px rgb(15 118 110 / 24%);
+}
+
+.device-infowindow__button:active {
+  opacity: 0.92;
+}
+
+@media (width <= 640px) {
+  .device-infowindow {
+    min-width: 236px;
+  }
+
+  .device-infowindow__row {
+    grid-template-columns: 1fr;
+    gap: 6px;
+  }
 }
 </style>

+ 464 - 0
src/views/pms/map/oldMap.vue

@@ -0,0 +1,464 @@
+<template>
+  <div class="map-container">
+    <div class="status-info">
+      <div class="status-item">
+        <span class="online-icon"></span>
+        <span style="font-size: 17px; font-weight: bold">在线数量 {{ inlineCount }}</span>
+      </div>
+      <div class="status-item">
+        <span class="offline-icon"></span>
+        <span style="font-size: 17px; font-weight: bold">离线数量 {{ outlineCount }}</span>
+      </div>
+    </div>
+    <div id="baidu-map" ref="mapContainer"></div>
+    <div class="map-controls">
+      <button @click="zoomIn">{{ t('map.amplify') }}</button>
+      <button @click="zoomOut">{{ t('map.reduce') }}</button>
+      <button @click="toggleMapType"
+        >{{ t('map.SwitchMapType') }}({{ mapType === 'BMAP_NORMAL_MAP' ? '地图' : '卫星' }})</button
+      >
+      <el-tree-select
+        class="my-el-select"
+        v-model="queryParams.deptId"
+        :data="treeList"
+        :props="defaultProps"
+        check-strictly
+        node-key="id"
+        :placeholder="t('deviceForm.deptHolder')"
+        filterable
+        @change="getData"
+      />
+    </div>
+  </div>
+  <DeviceMonitorDrawer
+    :model-value="drawerVisible"
+    @update:model-value="(val) => (drawerVisible = val)"
+    :id="deviceId"
+    :deviceName="deviceName"
+    :lastLineTime="lastLineTime"
+    :ifLine="ifLine"
+    :dept="dept"
+    :deviceCode="deviceCode"
+    ref="showDrawer"
+  />
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
+import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+interface Cluster {
+  lng: number
+  lat: number
+  count: number
+  devices?: IotDeviceVO[]
+}
+defineOptions({ name: 'DeviceMap' })
+const showDrawer = ref()
+const drawerVisible = ref<boolean>(false)
+const mapContainer = ref<HTMLElement | null>(null)
+const map = ref<any>(null)
+const mapType = ref<'BMAP_NORMAL_MAP' | 'BMAP_SATELLITE_MAP'>('BMAP_NORMAL_MAP')
+const clusters = ref<Cluster[]>([])
+const deviceId = ref()
+const deviceName = ref('')
+const lastLineTime = ref('')
+const ifLine = ref()
+const dept = ref('')
+const deviceCode = ref('')
+const { t } = useI18n() // 国际化
+const inlineCount = ref(0)
+const outlineCount = ref(0)
+// 设备数据示例
+const devices = ref<IotDeviceVO[]>()
+const treeList = ref<Tree[]>([]) // 树形结构
+// 初始化地图
+const initMap = () => {
+  if (!mapContainer.value) return
+
+  const script = document.createElement('script')
+  script.src = `https://api.map.baidu.com/api?v=3.0&ak=c0crhdxQ5H7WcqbcazGr7mnHrLa4GmO0&type=webgl`
+  script.async = true
+  script.onload = () => {
+    if ((window as any).BMap) {
+      map.value = new (window as any).BMap.Map(mapContainer.value)
+      const point = new (window as any).BMap.Point(104.114129, 37.550339)
+      map.value.centerAndZoom(point, 5)
+
+      map.value.enableScrollWheelZoom(true)
+      map.value.setMapType((window as any)[mapType.value])
+
+      map.value.addControl(new (window as any).BMap.NavigationControl())
+      map.value.addControl(new (window as any).BMap.ScaleControl())
+
+      initDeviceMarkers()
+      map.value.addEventListener('zoomend', () => {
+        initDeviceMarkers()
+      })
+    } else {
+      console.error('百度地图API加载失败')
+    }
+  }
+  script.onerror = () => {
+    console.error('百度地图API加载失败')
+  }
+  document.head.appendChild(script)
+}
+
+const initDeviceMarkers = () => {
+  if (!map.value) return
+
+  map.value.clearOverlays()
+
+  const zoomLevel = map.value.getZoom()
+  if (zoomLevel > 8) {
+    debugger
+    // 高缩放级别下显示单个设备标记
+    devices.value.forEach((device) => {
+      const point = new (window as any).BMap.Point(device.lng, device.lat)
+      const marker = createDeviceMarker(device, point)
+      map.value.addOverlay(marker)
+    })
+  } else {
+    debugger
+    // 低缩放级别下进行聚合
+    clusters.value = clusterDevices(devices.value, map.value)
+    clusters.value.forEach((cluster) => {
+      if (cluster.count === 1) {
+        // 只有一个设备时显示设备图标
+        const device = cluster.devices?.[0]
+        if (device) {
+          const point = new (window as any).BMap.Point(device.lng, device.lat)
+          const marker = createDeviceMarker(device, point)
+          map.value.addOverlay(marker)
+        }
+      } else if (cluster.count > 1) {
+        // 多个设备时显示聚合标签
+        const point = new (window as any).BMap.Point(cluster.lng, cluster.lat)
+        const label = createClusterLabel(cluster, point)
+        map.value.addOverlay(label)
+      }
+    })
+  }
+}
+
+const createDeviceMarker = (device: IotDeviceVO, point: any) => {
+  // 根据设备是否在线选择不同的图标
+  const iconUrl =
+    device.ifInline === 3
+      ? import.meta.env.VITE_BASE_URL + '/images/newding.svg'
+      : import.meta.env.VITE_BASE_URL + '/images/newfail.svg'
+  debugger
+  const marker = new (window as any).BMap.Marker(point, {
+    icon: new (window as any).BMap.Icon(iconUrl, new (window as any).BMap.Size(40, 47), {
+      anchor: new (window as any).BMap.Size(25, 40)
+      // imageOffset: new (window as any).BMap.Size(0, -5)
+    })
+  })
+
+  // 添加点击事件
+  marker.addEventListener('click', () => {
+    showDeviceInfoWindow(device, point)
+  })
+
+  return marker
+}
+
+const createClusterLabel = (cluster: Cluster, point: any) => {
+  const label = new (window as any).BMap.Label(cluster.count.toString(), {
+    position: point,
+    offset: new (window as any).BMap.Size(-10, -10)
+  })
+
+  // 创建一个 style 标签并添加到 head 中,定义呼吸动画
+  const style = document.createElement('style')
+  style.textContent = `
+    @keyframes breathing {
+      0% {
+        border-color: rgba(255, 255, 255, 0.3);
+      }
+      50% {
+        border-color: rgba(255, 255, 255, 0.8);
+      }
+      100% {
+        border-color: rgba(255, 255, 255, 0.3);
+      }
+    }
+  `
+  document.head.appendChild(style)
+
+  // 根据 cluster.count 的值设置不同的背景颜色
+  const backgroundColor = cluster.count > 10 ? '#1d4eed' : '#f67d1a'
+
+  // 初始样式
+  label.setStyle({
+    color: '#fff',
+    backgroundColor: backgroundColor,
+    borderRadius: '50%',
+    width: '50px',
+    height: '50px',
+    textAlign: 'center',
+    lineHeight: '40px',
+    cursor: 'pointer',
+    fontSize: '18px',
+    fontWeight: 'bold',
+    boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
+    transition: 'all 0.3s ease',
+    border: '6px solid rgba(255, 255, 255, 0.5)', // 添加带有不透明度的边框
+    animation: 'breathing 1.5s infinite' // 添加呼吸动画
+  })
+
+  // 鼠标悬停样式
+  const hoverStyle = {
+    backgroundColor: '#2196df',
+    transform: 'scale(1.1)'
+  }
+
+  // 鼠标移出样式
+  const normalStyle = {
+    backgroundColor: backgroundColor,
+    transform: 'scale(1)'
+  }
+
+  // 添加点击事件
+  label.addEventListener('click', () => {
+    if (map.value) {
+      const currentZoom = map.value.getZoom()
+      const MAX_ZOOM = 12 // 手动设定最大缩放级别
+      const newZoom = Math.min(currentZoom + 2, MAX_ZOOM)
+      map.value.setZoom(newZoom)
+      map.value.panTo(point)
+      initDeviceMarkers() // 重新计算并显示标记和聚合标签
+    }
+  })
+
+  return label
+}
+
+const clusterDevices = (devices: IotDeviceVO[], map: any): Cluster[] => {
+  const clusters: Cluster[] = []
+  const gridSize = getGridSize(map.getZoom())
+
+  const gridMap = new Map<string, Cluster>()
+
+  devices.forEach((device) => {
+    const gridKey = `${Math.floor(device.lng / gridSize)}_${Math.floor(device.lat / gridSize)}`
+
+    if (!gridMap.has(gridKey)) {
+      gridMap.set(gridKey, {
+        lng: Math.floor(device.lng / gridSize) * gridSize + gridSize / 2,
+        lat: Math.floor(device.lat / gridSize) * gridSize + gridSize / 2,
+        count: 0,
+        devices: []
+      })
+    }
+
+    const cluster = gridMap.get(gridKey)!
+    cluster.count++
+    if (!cluster.devices) cluster.devices = []
+    cluster.devices.push(device)
+  })
+
+  return Array.from(gridMap.values())
+}
+
+const getGridSize = (zoom: number): number => {
+  debugger
+  if (zoom <= 5) return 5
+  if (zoom <= 8) return 3
+  if (zoom < 10) return 0.1
+  return 0.1
+}
+
+const showDeviceInfoWindow = (device: IotDeviceVO, point: any) => {
+  DeptApi.getDept(device.deptId).then((res) => {
+    dept.value = res.name
+    const content = `
+<div style="display: flex;flex-direction: column;justify-content: center;border: 1px solid #ccc;">
+        <div style="margin-top: 1px;padding: 8px">
+          <p><strong>${t('iotDevice.code')}:</strong> ${device.deviceCode}</p>
+          <p><strong>${t('iotDevice.name')}:</strong> ${device.deviceName}</p>
+          <p><strong>${t('iotDevice.dept')}:</strong> ${res.name}</p>
+          <p><strong>${t('form.position')}:</strong> ${device.location ? device.location.replaceAll('"', '') : ''}</p>
+          <p><strong>${t('dict.status')}:</strong> ${getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device.deviceStatus)}</p>
+          <p><strong>${t('monitor.online')}:</strong> ${getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device.ifInline)}</p>
+          <p><strong>${t('monitor.latestDataTime')}:</strong> ${device.lastInlineTime}</p>
+        </div>
+        <div style="margin-bottom: 5px;padding: 8px">
+            <button id="device-detail-btn" style=" background-color: #2196f3;
+              color: white;
+              border: none;
+              padding: 8px 16px;
+              border-radius: 4px;
+
+              cursor: pointer;">${t('fault.view')}</button>
+        </div>
+        </div>
+      `
+    const infoWindow = new (window as any).BMap.InfoWindow(content, {
+      width: 350,
+      height: 270
+    })
+    map.value.openInfoWindow(infoWindow, point)
+
+    // 事件绑定(需延迟确保DOM加载)
+    setTimeout(function () {
+      document.getElementById('device-detail-btn').addEventListener('click', function () {
+        drawerVisible.value = true
+        deviceId.value = device.id
+        deviceCode.value = device.deviceCode
+        deviceName.value = device.deviceName
+        ifLine.value = device.ifInline
+        lastLineTime.value = device.lastInlineTime
+        showDrawer.value.openDrawer()
+      })
+    }, 200)
+  })
+}
+
+const zoomIn = () => {
+  if (map.value) {
+    map.value.zoomIn()
+  }
+}
+
+const zoomOut = () => {
+  if (map.value) {
+    map.value.zoomOut()
+  }
+}
+
+const toggleMapType = () => {
+  if (!map.value) return
+
+  mapType.value = mapType.value === 'BMAP_NORMAL_MAP' ? 'BMAP_SATELLITE_MAP' : 'BMAP_NORMAL_MAP'
+  map.value.setMapType((window as any)[mapType.value])
+}
+const queryParams = reactive({
+  deptId: undefined
+})
+const getData = async () => {
+  debugger
+  await IotDeviceApi.getMapDevice(queryParams).then((res) => {
+    devices.value = res.filter((item) => item.lat != 0 && item.lng != 0)
+    outlineCount.value = devices.value.filter((item) => item.ifInline === 4).length
+    inlineCount.value = devices.value.filter((item) => item.ifInline === 3).length
+    initMap()
+  })
+}
+onMounted(async () => {
+  await getData()
+  const res = await DeptApi.getSimpleDeptList()
+  treeList.value = []
+  treeList.value.push(...handleTree(res))
+  debugger
+})
+
+onBeforeUnmount(() => {
+  if (map.value) {
+    // 清除地图上的覆盖物
+    map.value.clearOverlays()
+    // 移除地图容器中的内容
+    if (mapContainer.value) {
+      mapContainer.value.innerHTML = ''
+    }
+    // 解除地图的事件绑定
+    map.value.removeEventListener('zoomend', initDeviceMarkers)
+  }
+})
+
+//     return {
+//       mapContainer,
+//       selectedDevice,
+//       hoverDevice,
+//       // deviceStatusMap,
+//       zoomIn,
+//       zoomOut,
+//       toggleMapType
+//     }
+// })
+</script>
+
+<style scoped>
+.map-container {
+  position: relative;
+  width: 100%;
+  height: 90vh;
+}
+
+#baidu-map {
+  width: 100%;
+  height: 100%;
+}
+
+.map-controls {
+  position: absolute;
+  top: 20px;
+  right: 20px; /* 修改为right定位 */
+  z-index: 1000;
+  display: flex;
+  flex-direction: column; /* 垂直排列按钮 */
+  gap: 10px;
+}
+
+.map-controls button {
+  padding: 5px 10px;
+  cursor: pointer;
+  background: #fff;
+  border: 1px solid #ccc;
+  border-radius: 3px;
+}
+
+.status-info {
+  position: absolute;
+  top: 20px;
+  left: 40%;
+  z-index: 1000;
+  display: flex;
+  padding: 10px 15px;
+  background: white;
+  border-radius: 6px;
+  box-shadow: 0 2px 10px rgb(0 0 0 / 10%);
+  gap: 20px;
+}
+
+.status-item {
+  display: flex;
+  height: 50px;
+  font-size: 14px;
+  align-items: center;
+  gap: 8px;
+}
+
+.online-icon {
+  display: inline-block;
+  width: 25px;
+  height: 25px;
+  background-image: url('https://aims.deepoil.cc/images/newding.svg');
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: contain;
+}
+
+.offline-icon {
+  display: inline-block;
+  width: 25px;
+  height: 25px;
+  background-image: url('https://aims.deepoil.cc/images/newfail.svg');
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: contain;
+}
+
+:deep(.el-select__selection) {
+  height: 35px; /* 自定义高度 */
+}
+
+:deep(.el-select__placeholder.is-transparent) {
+  color: orangered;
+}
+</style>