Ver código fonte

feat(map): 使用 AntV L7 重构设备地图

- 接入 L7 和 L7 Maps 地图依赖
- 使用天地图卫星底图创建 3D 地图场景
- 加载并清洗设备经纬度数据
- 添加统一高亮设备光圈和动态弧线轨迹
- 复用图层数据并完善场景生命周期清理
Zimo 3 semanas atrás
pai
commit
099aefe938
4 arquivos alterados com 1900 adições e 956 exclusões
  1. 2 0
      package.json
  2. 496 9
      pnpm-lock.yaml
  3. 347 947
      src/views/pms/map/Map.vue
  4. 1055 0
      src/views/pms/map/Map1.vue

+ 2 - 0
package.json

@@ -25,6 +25,8 @@
     "lint:lint-staged": "lint-staged -c "
     "lint:lint-staged": "lint-staged -c "
   },
   },
   "dependencies": {
   "dependencies": {
+    "@antv/l7": "^2.29.1",
+    "@antv/l7-maps": "^2.29.1",
     "@antv/x6": "^3.1.6",
     "@antv/x6": "^3.1.6",
     "@element-plus/icons-vue": "^2.1.0",
     "@element-plus/icons-vue": "^2.1.0",
     "@form-create/designer": "^3.2.6",
     "@form-create/designer": "^3.2.6",

Diferenças do arquivo suprimidas por serem muito extensas
+ 496 - 9
pnpm-lock.yaml


+ 347 - 947
src/views/pms/map/Map.vue

@@ -1,1055 +1,455 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { LineLayer, PointLayer, Scene } from '@antv/l7'
+import type { ILayer } from '@antv/l7'
+import { Mapbox } from '@antv/l7-maps'
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
 import { IotDeviceApi } from '@/api/pms/device'
 import { IotDeviceApi } from '@/api/pms/device'
-import * as DeptApi from '@/api/system/dept'
-import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
-import { DICT_TYPE, getDictLabel } from '@/utils/dict'
-import { defaultProps, handleTree } from '@/utils/tree'
 
 
-type MapMode = 'TMAP_NORMAL_MAP' | 'TMAP_SATELLITE_MAP'
-type StatusFilter = 'all' | 'online' | 'offline'
+type TianDiTuLayer = 'img_w' | 'cia_w'
 
 
 type MapDevice = {
 type MapDevice = {
   id: number
   id: number
   deviceCode: string
   deviceCode: string
   deviceName: string
   deviceName: string
-  lat: number
-  lng: number
-  location: string
-  deptId: number
+  lat?: number | string | null
+  lng?: number | string | null
   ifInline: 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
+type RenderDevice = Omit<MapDevice, 'lat' | 'lng'> & {
+  lat: number
+  lng: number
 }
 }
 
 
-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<number | undefined>(undefined)
-const dept = ref('')
-const deviceCode = ref('')
+type ArcData = {
+  lng1: number
+  lat1: number
+  lng2: number
+  lat2: number
+}
 
 
-const keyword = ref('')
-const statusFilter = ref<StatusFilter>('all')
-const mapMode = ref<MapMode>('TMAP_NORMAL_MAP')
+type DeviceLayerSet = {
+  arcBase: ILayer
+  arcAnimated: ILayer
+  halo: ILayer
+}
 
 
-const RUIHENG_XINGYU_DEPT_ID = 157
+const INITIAL_CENTER: [number, number] = [104.114129, 36.550339]
+const ARC_ORIGIN: [number, number] = [118.4939, 37.40613]
+const MAP_ZOOM = 5
+const MAP_PITCH = 45
 const DEFAULT_DEPT_ID = 156
 const DEFAULT_DEPT_ID = 156
-const initialCenter: [number, number] = [104.114129, 36.550339]
-const initialZoom = 5
-const mapControls = ['Scale']
-const isZhzqSource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhzq'
-const isZhlySource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhly'
-
-const queryParams = reactive({
-  deptId: isZhzqSource() ? RUIHENG_XINGYU_DEPT_ID : DEFAULT_DEPT_ID
-})
-
-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 RUIHENG_XINGYU_DEPT_ID = 157
+// 荧光洋红在绿色卫星影像和深色底图上都有较高对比度。
+const DEVICE_COLOR = '#ff3df2'
+
+// 相同数据结构的图层共享解析配置,避免在每次渲染时重复创建配置对象。
+const POINT_SOURCE_OPTIONS = {
+  parser: {
+    type: 'json' as const,
+    x: 'lng',
+    y: 'lat'
+  }
 }
 }
 
 
-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 ARC_SOURCE_OPTIONS = {
+  parser: {
+    type: 'json' as const,
+    x: 'lng1',
+    y: 'lat1',
+    x1: 'lng2',
+    y1: 'lat2'
   }
   }
 }
 }
 
 
-const statusOptions = [
-  { label: '全部', value: 'all' },
-  { label: '在线', value: 'online' },
-  { label: '离线', value: 'offline' }
-] as const
-
-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))
+const mapContainerRef = ref<HTMLDivElement>()
+const mapLoading = ref(true)
+const mapError = ref('')
+const deviceLoading = ref(false)
+const deviceError = ref('')
+const devices = ref<RenderDevice[]>([])
 
 
-    return matchKeyword && matchStatus
-  })
-})
-
-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 deviceCount = computed(() => devices.value.length)
+const deviceSummary = computed(() =>
+  deviceLoading.value ? '设备加载中' : `${deviceCount.value} 个设备`
 )
 )
-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()])
-})
+let scene: Scene | null = null
+let sceneReady = false
+let disposed = false
 
 
-const loadDeptTree = async () => {
-  const res = isZhzqSource()
-    ? await DeptApi.specifiedSimpleDepts(RUIHENG_XINGYU_DEPT_ID)
-    : await DeptApi.getSimpleDeptList()
-  deptList.value = res || []
-  treeList.value = handleTree(res || [])
-}
+// 所有设备共用三个图层。后续设备刷新通过 setData 更新,避免反复分配 WebGL 资源。
+let deviceLayerSet: DeviceLayerSet | null = null
 
 
-const loadDevices = async () => {
-  loading.value = true
-  try {
-    const res = await IotDeviceApi.getMapDevice({
-      ...queryParams,
-      ...(isZhlySource() ? { source: 'ly' } : {})
-    })
-    devices.value = res || []
-  } catch (error) {
-    message.error('地图设备加载失败,请稍后重试')
-  } finally {
-    loading.value = false
-  }
-}
+const getLoginSource = () => sessionStorage.getItem('LOGIN_SOURCE')
 
 
-const handleDeptChange = () => {
-  return loadDevices()
-}
+const getDeviceQuery = () => {
+  const loginSource = getLoginSource()
 
 
-const handleMapInit = (map: any) => {
-  mapRef.value = map
-  applyMapMode()
-  syncCurrentZoom()
+  return {
+    deptId: loginSource === 'zhzq' ? RUIHENG_XINGYU_DEPT_ID : DEFAULT_DEPT_ID,
+    ...(loginSource === 'zhly' ? { source: 'ly' } : {})
+  }
 }
 }
 
 
-const syncCurrentZoom = () => {
-  currentZoom.value = mapRef.value?.getZoom?.() ?? initialZoom
-}
+const normalizeDevices = (rawDevices: MapDevice[]): RenderDevice[] => {
+  const result: RenderDevice[] = []
+
+  // 清洗和坐标转换在一次遍历中完成。
+  rawDevices.forEach((device) => {
+    const lng = Number(device.lng)
+    const lat = Number(device.lat)
+    const positionValid =
+      Number.isFinite(lng) &&
+      Number.isFinite(lat) &&
+      lng !== 0 &&
+      lat !== 0 &&
+      lng >= -180 &&
+      lng <= 180 &&
+      lat >= -90 &&
+      lat <= 90
+
+    if (!positionValid) return
+
+    result.push({
+      ...device,
+      lng,
+      lat
+    })
+  })
 
 
-const zoomIn = () => {
-  mapRef.value?.zoomIn?.()
+  return result
 }
 }
 
 
-const zoomOut = () => {
-  mapRef.value?.zoomOut?.()
-}
+const createArcData = (list: RenderDevice[]): ArcData[] =>
+  list.map((device) => ({
+    lng1: ARC_ORIGIN[0],
+    lat1: ARC_ORIGIN[1],
+    lng2: device.lng,
+    lat2: device.lat
+  }))
 
 
-const switchMapMode = (mode: MapMode) => {
-  mapMode.value = mode
-  applyMapMode()
-}
+const createDeviceLayerSet = (
+  currentScene: Scene,
+  renderDevices: RenderDevice[],
+  arcs: ArcData[]
+): DeviceLayerSet => {
+  const arcBase = new LineLayer({
+    name: 'device-arc-base',
+    zIndex: 4,
+    blend: 'normal'
+  })
+    .source(arcs, ARC_SOURCE_OPTIONS)
+    .shape('arc')
+    .size(4)
+    .color(DEVICE_COLOR)
+    .style({ opacity: 0.62 })
+
+  const arcAnimated = new LineLayer({
+    name: 'device-arc-animated',
+    zIndex: 5,
+    blend: 'normal'
+  })
+    .source(arcs, ARC_SOURCE_OPTIONS)
+    .shape('arc')
+    .size(7)
+    .color(DEVICE_COLOR)
+    .style({ opacity: 1 })
+    .animate({
+      enable: true,
+      duration: 2.5,
+      interval: 0.08,
+      trailLength: 0.8
+    })
 
 
-const applyMapMode = () => {
-  if (!mapRef.value) return
-  ;(mapRef.value as any).setMapType?.((window as any)[mapMode.value])
-}
+  const halo = new PointLayer({
+    name: 'device-halo',
+    zIndex: 10,
+    blend: 'additive'
+  })
+    .source(renderDevices, POINT_SOURCE_OPTIONS)
+    .shape('circle')
+    .size(28)
+    .color(DEVICE_COLOR)
+    .style({
+      opacity: 0.46,
+      stroke: DEVICE_COLOR,
+      strokeWidth: 2.5
+    })
+    .animate(true)
 
 
-const isOnline = (device?: MapDevice | null) => {
-  return Number(device?.ifInline) === 3
+  const layerSet = { arcBase, arcAnimated, halo }
+  Object.values(layerSet).forEach((layer) => currentScene.addLayer(layer))
+  return layerSet
 }
 }
 
 
-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 updateDeviceLayerSet = (
+  layerSet: DeviceLayerSet,
+  renderDevices: RenderDevice[],
+  arcs: ArcData[]
+) => {
+  layerSet.arcBase.setData(arcs)
+  layerSet.arcAnimated.setData(arcs)
+  layerSet.halo.setData(renderDevices)
 }
 }
 
 
-const getMarkerIcon = (device?: MapDevice | null) => {
-  const theme = isOnline(device) ? markerThemeMap.online : markerThemeMap.offline
-  const cachedIcon = markerIconCache.get(theme.key)
+const renderDevices = () => {
+  const currentScene = scene
+  if (!currentScene || !sceneReady) return
 
 
-  if (cachedIcon) return cachedIcon
+  const arcs = createArcData(devices.value)
 
 
-  const markerIcon = {
-    iconUrl: svgToDataUri(buildMarkerSvg(theme)),
-    iconSize: [46, 58] as [number, number],
-    iconAnchor: [23, 49] as [number, number]
+  if (deviceLayerSet) {
+    updateDeviceLayerSet(deviceLayerSet, devices.value, arcs)
+    return
   }
   }
 
 
-  markerIconCache.set(theme.key, markerIcon)
-  return markerIcon
+  if (!devices.value.length) return
+  deviceLayerSet = createDeviceLayerSet(currentScene, devices.value, arcs)
 }
 }
 
 
-const buildMarkerSvg = (theme: MarkerTheme) => {
-  const glyph = renderMarkerGlyph(theme.iconName, 18)
+const loadDevices = async () => {
+  deviceLoading.value = true
+  deviceError.value = ''
 
 
-  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()
-}
+  try {
+    const response = (await IotDeviceApi.getMapDevice(getDeviceQuery())) as MapDevice[]
+    if (disposed) return
 
 
-const renderMarkerGlyph = (iconName: MarkerGlyphName, boxSize = 18) => {
-  const iconBody = markerGlyphBodyMap[iconName]
-  const iconScale = boxSize / 24
+    devices.value = normalizeDevices(response || [])
+    renderDevices()
+  } catch (error) {
+    if (disposed) return
 
 
-  return `
-    <g transform="scale(${iconScale})">
-      ${iconBody}
-    </g>
-  `.trim()
+    deviceError.value = '设备位置加载失败'
+    console.error('Failed to load map devices:', error)
+  } finally {
+    if (!disposed) deviceLoading.value = false
+  }
 }
 }
 
 
-const svgToDataUri = (svg: string) => {
-  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
-}
+const createTianDiTuTileUrls = (type: TianDiTuLayer, token: string) =>
+  Array.from(
+    { length: 8 },
+    (_, index) =>
+      `https://t${index}.tianditu.gov.cn/DataServer?T=${type}&x={x}&y={y}&l={z}&tk=${token}`
+  )
 
 
-const clusterStyles = [
-  {
-    size: 80,
-    textColor: '#ffffff',
-    textSize: 14,
-    range: [0, 20]
-  },
-  {
-    size: 80,
-    textColor: '#ffffff',
-    textSize: 14,
-    range: [20, 100]
+const createSatelliteStyle = (token: string) => ({
+  version: 8,
+  sources: {
+    'tianditu-satellite': {
+      type: 'raster',
+      tiles: createTianDiTuTileUrls('img_w', token),
+      tileSize: 256,
+      minzoom: 0,
+      maxzoom: 18
+    },
+    'tianditu-labels': {
+      type: 'raster',
+      tiles: createTianDiTuTileUrls('cia_w', token),
+      tileSize: 256,
+      minzoom: 0,
+      maxzoom: 18
+    }
   },
   },
-  {
-    size: 80,
-    textColor: '#ffffff',
-    textSize: 14,
-    range: [100, 99999]
-  }
-]
-
-const handleMarkerClick = (e: any) => {
-  const device = e?.layer?.extData as MapDevice | undefined
-
-  if (!device) return
-
-  openInfoWindow(device, resolveLngLat(e?.lnglat))
-}
+  layers: [
+    {
+      id: 'tianditu-satellite',
+      type: 'raster',
+      source: 'tianditu-satellite'
+    },
+    {
+      id: 'tianditu-labels',
+      type: 'raster',
+      source: 'tianditu-labels'
+    }
+  ]
+})
 
 
-const handleInfoWindowTargetChange = (target: [number, number] | null) => {
-  infoWindowTarget.value = target
+const initMap = () => {
+  const container = mapContainerRef.value
+  const rawToken = import.meta.env.VITE_TK?.trim()
 
 
-  if (!target) {
-    activeDevice.value = null
+  if (!container) return
+  if (!rawToken) {
+    mapLoading.value = false
+    mapError.value = '地图加载失败,请配置天地图密钥'
+    return
   }
   }
-}
 
 
-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
-}
+  try {
+    const currentScene = new Scene({
+      id: container,
+      map: new Mapbox({
+        style: createSatelliteStyle(encodeURIComponent(rawToken)),
+        center: INITIAL_CENTER,
+        zoom: MAP_ZOOM,
+        pitch: MAP_PITCH,
+        rotation: 0,
+        minZoom: 3,
+        maxZoom: 18
+      }),
+      logoVisible: false
+    })
 
 
-const resolveLngLat = (lnglat: any): [number, number] | null => {
-  const lng = Number(lnglat?.lng ?? lnglat?.getLng?.())
-  const lat = Number(lnglat?.lat ?? lnglat?.getLat?.())
+    scene = currentScene
+    currentScene.on('loaded', () => {
+      // loaded 可能晚于组件卸载,必须确认场景仍是当前实例。
+      if (disposed || scene !== currentScene) return
 
 
-  if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
-    return null
+      sceneReady = true
+      mapLoading.value = false
+      renderDevices()
+    })
+  } catch (error) {
+    mapLoading.value = false
+    mapError.value = '地图加载失败,请检查天地图密钥或网络连接'
+    console.error('Failed to initialize the L7 TianDiTu map:', error)
   }
   }
-
-  return [lng, lat]
-}
-
-const getDeptName = (device?: MapDevice | null) => {
-  return deptNameMap.value.get(Number(device?.deptId)) || '-'
-}
-
-const getDeviceStatusText = (device?: MapDevice | null) => {
-  return getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device?.deviceStatus) || '-'
-}
-
-const getDeviceOnlineText = (device?: MapDevice | null) => {
-  return (
-    getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device?.ifInline) ||
-    (isOnline(device) ? '在线' : '离线')
-  )
-}
-
-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)]
+onMounted(() => {
+  // 地图初始化和设备请求互不依赖,并行启动可缩短首屏等待时间。
+  initMap()
+  loadDevices()
 })
 })
 
 
-const handleClusterInit = (clusterer: any) => {
-  clusterer?.setMaxZoom?.(16)
-}
+onBeforeUnmount(() => {
+  disposed = true
+  sceneReady = false
+  deviceLayerSet = null
+  scene?.destroy()
+  scene = null
+})
 </script>
 </script>
 
 
 <template>
 <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="map-page">
+    <div ref="mapContainerRef" class="map-container"></div>
 
 
-          <div class="toolbar-panel__footer">
-            <div class="toolbar-badges">
-              <span class="toolbar-badge">缩放 {{ currentZoom }}</span>
-            </div>
+    <div class="map-mode-badge">
+      <span class="map-mode-badge__dot"></span>
+      <span>天地图 · 3D 卫星 · {{ deviceSummary }}</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>
+    <div v-if="!deviceLoading && deviceCount" class="device-legend">
+      <span><i class="device-legend__dot"></i>设备</span>
+    </div>
 
 
-      <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 v-if="mapLoading" class="map-state">地图加载中...</div>
+    <div v-else-if="mapError" class="map-state map-state--error">{{ mapError }}</div>
+    <div v-if="deviceError" class="device-error">{{ deviceError }}</div>
   </div>
   </div>
 </template>
 </template>
 
 
 <style scoped lang="scss">
 <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;
+@mixin glass-panel($radius) {
+  color: #f8fafc;
+  pointer-events: none;
+  background: rgb(15 23 42 / 78%);
+  border: 1px solid rgb(255 255 255 / 18%);
+  border-radius: $radius;
+  box-shadow: 0 8px 24px rgb(15 23 42 / 24%);
+  backdrop-filter: blur(10px);
+}
 
 
+.map-page {
   position: relative;
   position: relative;
-  height: var(--page-height);
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-height: 480px;
   overflow: hidden;
   overflow: hidden;
-  border: 1px solid rgb(203 213 225 / 70%);
-  border-radius: 20px;
-  box-shadow: 0 24px 60px rgb(15 23 42 / 12%);
-}
-
-.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);
-}
-
-.hero-panel__head {
-  display: flex;
-  gap: 20px;
-  justify-content: space-between;
-}
-
-.hero-panel__kicker {
-  margin-bottom: 6px;
-  font-size: 12px;
-  font-weight: 700;
-  letter-spacing: 0.16em;
-  color: var(--teal);
-  text-transform: uppercase;
+  background: #eef2f6;
 }
 }
 
 
-.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);
+.map-container {
+  width: 100%;
+  height: 100%;
 }
 }
 
 
-.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%);
-}
+.map-mode-badge {
+  @include glass-panel(999px);
 
 
-.hero-panel__map-mode::before {
   position: absolute;
   position: absolute;
-  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;
+  top: 16px;
+  right: 16px;
+  z-index: 10;
   display: inline-flex;
   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;
   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: 14px;
-  justify-content: space-between;
   align-items: center;
   align-items: center;
-  margin-top: 16px;
-}
-
-.toolbar-badges {
-  display: flex;
-  flex: 1;
-  gap: 8px;
-  flex-wrap: wrap;
-}
-
-.toolbar-badge {
-  display: inline-flex;
-  padding: 7px 10px;
-  font-size: 12px;
+  padding: 9px 13px;
+  font-size: 13px;
   font-weight: 600;
   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;
+.map-mode-badge__dot {
+  width: 7px;
+  height: 7px;
+  background: #38bdf8;
+  border-radius: 50%;
+  box-shadow: 0 0 10px #38bdf8;
 }
 }
 
 
-.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;
-}
+.device-legend {
+  @include glass-panel(8px);
 
 
-.icon-action:hover {
-  transform: translateY(-1px);
-}
-
-.legend-panel__title {
-  margin-bottom: 10px;
-  font-size: 14px;
-  font-weight: 700;
-  color: var(--text-main);
-}
-
-.legend-list {
+  position: absolute;
+  right: 16px;
+  bottom: 16px;
+  z-index: 10;
   display: flex;
   display: flex;
   gap: 14px;
   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;
   align-items: center;
-  justify-content: center;
-}
-
-.legend-chip--online {
-  background: linear-gradient(135deg, #0f766e, #2dd4bf);
-}
+  padding: 9px 13px;
+  font-size: 12px;
 
 
-.legend-chip--offline {
-  background: linear-gradient(135deg, #ea580c, #fb7185);
+  span {
+    display: inline-flex;
+    gap: 6px;
+    align-items: center;
+  }
 }
 }
 
 
-.legend-chip--cluster {
-  background: linear-gradient(135deg, #0369a1, #1d4ed8);
+.device-legend__dot {
+  width: 8px;
+  height: 8px;
+  background: #ff3df2;
+  border: 1px solid #fff;
+  border-radius: 50%;
 }
 }
 
 
-.legend-chip__icon {
+.map-state {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  z-index: 10;
+  padding: 10px 16px;
   font-size: 14px;
   font-size: 14px;
+  color: #475569;
+  pointer-events: none;
+  background: rgb(255 255 255 / 92%);
+  border-radius: 8px;
+  transform: translate(-50%, -50%);
+  box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
 }
 }
 
 
-.device-infowindow {
-  min-width: 280px;
-  color: var(--text-main);
+.map-state--error {
+  color: #dc2626;
 }
 }
 
 
-.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 {
+.device-error {
+  position: absolute;
+  top: 68px;
+  right: 16px;
+  z-index: 10;
+  padding: 8px 12px;
   font-size: 12px;
   font-size: 12px;
-  font-weight: 600;
-  line-height: 1.6;
-  color: var(--text-sub);
-}
-
-.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;
-}
-
-.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;
-}
-
-.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;
-  }
+  color: #b91c1c;
+  background: rgb(254 242 242 / 94%);
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  box-shadow: 0 8px 24px rgb(15 23 42 / 12%);
 }
 }
 </style>
 </style>

+ 1055 - 0
src/views/pms/map/Map1.vue

@@ -0,0 +1,1055 @@
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { IotDeviceApi } from '@/api/pms/device'
+import * as DeptApi from '@/api/system/dept'
+import DeviceMonitorDrawer from '@/views/pms/map/DeviceMonitorDrawer.vue'
+import { DICT_TYPE, getDictLabel } from '@/utils/dict'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+type MapMode = 'TMAP_NORMAL_MAP' | 'TMAP_SATELLITE_MAP'
+type StatusFilter = 'all' | 'online' | 'offline'
+
+type MapDevice = {
+  id: number
+  deviceCode: string
+  deviceName: string
+  lat: number
+  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<number | undefined>(undefined)
+const dept = ref('')
+const deviceCode = ref('')
+
+const keyword = ref('')
+const statusFilter = ref<StatusFilter>('all')
+const mapMode = ref<MapMode>('TMAP_NORMAL_MAP')
+
+const RUIHENG_XINGYU_DEPT_ID = 157
+const DEFAULT_DEPT_ID = 156
+const initialCenter: [number, number] = [104.114129, 36.550339]
+const initialZoom = 5
+const mapControls = ['Scale']
+const isZhzqSource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhzq'
+const isZhlySource = () => sessionStorage.getItem('LOGIN_SOURCE') === 'zhly'
+
+const queryParams = reactive({
+  deptId: isZhzqSource() ? RUIHENG_XINGYU_DEPT_ID : DEFAULT_DEPT_ID
+})
+
+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 statusOptions = [
+  { label: '全部', value: 'all' },
+  { label: '在线', value: 'online' },
+  { label: '离线', value: 'offline' }
+] as const
+
+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 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 = isZhzqSource()
+    ? await DeptApi.specifiedSimpleDepts(RUIHENG_XINGYU_DEPT_ID)
+    : await DeptApi.getSimpleDeptList()
+  deptList.value = res || []
+  treeList.value = handleTree(res || [])
+}
+
+const loadDevices = async () => {
+  loading.value = true
+  try {
+    const res = await IotDeviceApi.getMapDevice({
+      ...queryParams,
+      ...(isZhlySource() ? { source: 'ly' } : {})
+    })
+    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 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]
+  }
+
+  markerIconCache.set(theme.key, markerIcon)
+  return markerIcon
+}
+
+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 renderMarkerGlyph = (iconName: MarkerGlyphName, boxSize = 18) => {
+  const iconBody = markerGlyphBodyMap[iconName]
+  const iconScale = boxSize / 24
+
+  return `
+    <g transform="scale(${iconScale})">
+      ${iconBody}
+    </g>
+  `.trim()
+}
+
+const svgToDataUri = (svg: string) => {
+  return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
+}
+
+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 handleMarkerClick = (e: any) => {
+  const device = e?.layer?.extData as MapDevice | undefined
+
+  if (!device) return
+
+  openInfoWindow(device, resolveLngLat(e?.lnglat))
+}
+
+const handleInfoWindowTargetChange = (target: [number, number] | null) => {
+  infoWindowTarget.value = target
+
+  if (!target) {
+    activeDevice.value = null
+  }
+}
+
+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 getDeptName = (device?: MapDevice | null) => {
+  return deptNameMap.value.get(Number(device?.deptId)) || '-'
+}
+
+const getDeviceStatusText = (device?: MapDevice | null) => {
+  return getDictLabel(DICT_TYPE.PMS_DEVICE_STATUS, device?.deviceStatus) || '-'
+}
+
+const getDeviceOnlineText = (device?: MapDevice | null) => {
+  return (
+    getDictLabel(DICT_TYPE.IOT_DEVICE_STATUS, device?.ifInline) ||
+    (isOnline(device) ? '在线' : '离线')
+  )
+}
+
+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)]
+})
+
+const handleClusterInit = (clusterer: any) => {
+  clusterer?.setMaxZoom?.(16)
+}
+</script>
+
+<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;
+  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%);
+}
+
+.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);
+}
+
+.hero-panel__head {
+  display: flex;
+  gap: 20px;
+  justify-content: space-between;
+}
+
+.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%);
+}
+
+.hero-panel__map-mode::before {
+  position: absolute;
+  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: 14px;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 16px;
+}
+
+.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;
+}
+
+.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);
+}
+
+.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;
+}
+
+.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;
+}
+
+.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>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff