吉安感知网项目-前端
张含笑
2026-01-20 58da3409db496c6c97b355277c847ce6ca541ddf
applications/task-work-order/src/components/map-container/device-map-container.vue
@@ -1,22 +1,82 @@
<template>
   <div class="work-cesium map-container" :id="props.containerId"></div>
   <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
      <div class="layer-control-wrap" ref="layerWrapRef">
         <div class="layer-control" @click="toggleLayerPanel">
            <img :src="layerControlIcon" alt="图层控制" />
         </div>
         <div v-if="showLayerPanel" class="layer-panel">
            <div class="panel-title">图层管理</div>
   <div class="map-shell">
      <CommonCesiumMap ref="mapRef" class="command-cesium map-container" :dom-id="props.containerId" :active="true"
         :flat-mode="false" :terrain="false" :layer-mode="4" :contour="false" :boundary="false"
         :show-admin-boundary="true" :zoom-to-boundary="true" :enable-stage-emit="true" :cluster-height="100000"
         @ready="handleMapReady" @stage-change="handleStageChange" />
      <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
         <div class="layer-control-wrap" ref="layerWrapRef">
            <div class="layer-control" @click="toggleLayerPanel">
               <img :src="layerControlIcon" alt="图层控制" />
            </div>
            <div v-if="showLayerPanel" class="layer-panel">
               <div class="panel-title">图层管理</div>
            <div class="panel-content">
               <el-tree
                  :data="layerTree"
                  show-checkbox
                  default-expand-all
                  node-key="key"
                  :props="layerTreeProps"
                  :default-checked-keys="defaultCheckedKeys"
               />
               <div class="panel-content">
                  <el-tree :data="layerTree" show-checkbox default-expand-all node-key="key"
                     :props="layerTreeProps" :default-checked-keys="defaultCheckedKeys" />
               </div>
            </div>
         </div>
      </div>
      <div v-if="popupVisible" class="device-popup" :style="popupStyle" @click.stop>
         <div class="device-popup__header">
            <el-tooltip
               class="device-popup__title-tooltip"
               :content="popupTitle"
               placement="top"
               effect="dark"
               :show-after="200"
            >
               <span class="device-popup__title">{{ popupTitleDisplay }}</span>
            </el-tooltip>
            <img class="device-popup__close" :src="popupClose" alt="" @click.stop="closePopup">
         </div>
         <div class="device-popup__type">
            <span class="value">{{ popupTypeText }}</span>
         </div>
         <div class="device-popup__subtitle">
            <span class="device-popup__dot" :class="popupActionClass"></span>
            <span class="device-popup__action">{{ popupActionText }}</span>
         </div>
         <div class="device-popup__rows">
            <div class="device-popup__row">
               <div class="device-popup__col">
                  <span class="label">状态</span>
                  <span class="value" :class="popupStatusClass">{{ popupStatusText }}</span>
               </div>
               <span class="divider">|</span>
               <div class="device-popup__col">
                  <span class="label">电量</span>
                  <span class="value">{{ popupBatteryText }}</span>
               </div>
            </div>
            <div class="device-popup__row">
               <div class="device-popup__col">
                  <span class="label">方位角</span>
                  <span class="value">{{ popupAzimuthText }}</span>
               </div>
               <span class="divider">|</span>
               <div class="device-popup__col">
                  <span class="label">俯仰角</span>
                  <span class="value">{{ popupElevationText }}</span>
               </div>
            </div>
            <div class="device-popup__row">
               <span class="label">侦测目标</span>
               <span class="value">{{ popupDetectCountText }}</span>
            </div>
            <div class="device-popup__row">
               <span class="label">有效范围</span>
               <span class="value">{{ popupRangeText }}</span>
            </div>
         </div>
      </div>
@@ -25,9 +85,18 @@
<script setup>
import * as Cesium from 'cesium'
import { PublicCesium } from '@/utils/cesium/publicCesium'
import CommonCesiumMap from '@/components/map-container/common-cesium-map.vue'
import { geomAnalysis } from '@ztzf/utils'
import { fwDefenseZonePageApi } from '@/views/areaManage/defenseZone/defenseZoneApi'
import { fwAreaDividePageApi } from '@/views/areaManage/partition/partitionApi'
import { fwDefenseSceneListApi } from '@/views/areaManage/sceneConfig/sceneConfigApi'
import { cockpitAggregationApi } from '@/api/dataCockpit'
import layerControlIcon from '@/assets/images/dataCockpit/layerControl.png'
import equipmentIcon from '@/assets/images/dataCockpit/map/equipment.png'
import aggregationIcon from '@/assets/images/dataCockpit/map/aggregation.png'
import commandPostIcon from '@/assets/images/dataCockpit/map/command-post.png'
import popupClose from '@/assets/images/dataCockpit/map/popup-close.png'
import jaGeojsonRaw from '@/assets/geojson/ja.geojson?raw'
const props = defineProps({
   onlineDevices: {
@@ -48,11 +117,25 @@
   },
})
let viewInstance = null
const DEFAULT_ZONE_PAGE_SIZE = 999
const mapRef = ref(null)
let viewer = null
const deviceEntityIds = new Set()
const deviceEntityMap = new Map()
let defenseZoneSource = null
let partitionSource = null
let aggregationSource = null
let commandPostSource = null
const detailVisible = ref(true)
const clusterVisible = ref(false)
const countyCenterMap = new Map()
const showLayerPanel = ref(false)
const layerWrapRef = ref(null)
const selectedDevice = ref(null)
let selectedDeviceEntity = null
let deviceClickHandler = null
let popupRenderHandler = null
const layerTreeProps = {
   label: 'label',
   children: 'children',
@@ -103,12 +186,60 @@
      viewer.entities.removeById(id)
   })
   deviceEntityIds.clear()
   deviceEntityMap.clear()
}
const clearDefenseZoneEntities = () => {
   if (!defenseZoneSource) return
   defenseZoneSource.entities.removeAll()
}
const clearPartitionEntities = () => {
   if (!partitionSource) return
   partitionSource.entities.removeAll()
}
const clearAggregationEntities = () => {
   if (!aggregationSource) return
   aggregationSource.entities.removeAll()
}
const clearCommandPostEntities = () => {
   if (!commandPostSource) return
   commandPostSource.entities.removeAll()
}
const setDetailVisibility = visible => {
   detailVisible.value = visible
   if (defenseZoneSource) defenseZoneSource.show = visible
   if (partitionSource) partitionSource.show = visible
   if (commandPostSource) commandPostSource.show = visible
   if (!visible) closePopup()
   deviceEntityIds.forEach(id => {
      const entity = viewer?.entities?.getById(id)
      if (entity) entity.show = visible
   })
}
const setClusterVisibility = visible => {
   clusterVisible.value = visible
   if (aggregationSource) aggregationSource.show = visible
}
const ensureCountyCenterMap = () => {
   if (countyCenterMap.size) return
   const geojson = JSON.parse(jaGeojsonRaw)
   geojson.features?.forEach(feature => {
      const name = feature?.properties?.name
      const center = feature?.properties?.centroid || feature?.properties?.center
      if (!name || !Array.isArray(center) || center.length < 2) return
      countyCenterMap.set(name, { longitude: center[0], latitude: center[1] })
   })
}
const RING_STYLES = [
   { inner: 0, outer: 5000, gradient: ['#FF361C', '#360B00'] },
   { inner: 5000, outer: 8000, gradient: ['#FFC609', '#583300'] },
   { inner: 8000, outer: 10000, gradient: ['#2AEDBF', '#012B11'] },
   { inner: 0, outer: 2000, gradient: ['#FF361C', '#360B00'] }
]
const MATERIAL_TYPE = 'RadialGradientMaterial'
@@ -156,7 +287,7 @@
   return positions
}
const addDeviceRings = (center, baseId) => {
const addDeviceRings = (center, baseId, visible) => {
   RING_STYLES.forEach((ring, index) => {
      const outerPositions = buildCirclePositions(center, ring.outer)
      const holes = ring.inner
@@ -171,6 +302,7 @@
      const material = new RadialGradientMaterialProperty(color1, color2)
      viewer.entities.add({
         id: entityId,
         show: visible,
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(outerPositions, holes),
            material,
@@ -186,26 +318,26 @@
      this.color2 = color2
   }
   get isConstant() {
   get isConstant () {
      return true
   }
   get definitionChanged() {
   get definitionChanged () {
      return this._definitionChanged
   }
   getType() {
   getType () {
      return MATERIAL_TYPE
   }
   getValue(time, result) {
   getValue (time, result) {
      const target = result || {}
      target.color1 = this.color1
      target.color2 = this.color2
      return target
   }
   equals(other) {
   equals (other) {
      return (
         other instanceof RadialGradientMaterialProperty &&
         Cesium.Color.equals(this.color1, other.color1) &&
@@ -222,18 +354,347 @@
      if (!position) return
      const entityId = `online-device-${item.id ?? index}`
      deviceEntityIds.add(entityId)
      addDeviceRings(position, entityId)
      viewer.entities.add({
      addDeviceRings(position, entityId, detailVisible.value)
      const entity = viewer.entities.add({
         id: entityId,
         show: detailVisible.value,
         position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
         billboard: {
            image: equipmentIcon,
            width: 40.34,
            height: 40.34,
            width: 40,
            height: 56,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
         },
      })
      deviceEntityMap.set(entityId, { data: item, entity })
   })
   if (selectedDeviceEntity && !deviceEntityMap.has(selectedDeviceEntity.id)) {
      closePopup()
   }
}
const getDefenseZonePositions = geom => {
   const points = geomAnalysis(geom)
   if (points.length < 3) return []
   const first = points[0]
   const last = points[points.length - 1]
   const list =
      first && last && first.longitude === last.longitude && first.latitude === last.latitude
         ? points.slice(0, -1)
         : points
   return list.map(item => Cesium.Cartesian3.fromDegrees(item.longitude, item.latitude))
}
const renderZonePolygons = ({ zones, source, idPrefix, lineColor, fillGradient }) => {
   if (!viewer || !source) return
   registerRadialGradientMaterial()
   const borderColor = Cesium.Color.fromCssColorString(lineColor)
   const fillColorStart = Cesium.Color.fromCssColorString(fillGradient[0]).withAlpha(0.34)
   const fillColorEnd = Cesium.Color.fromCssColorString(fillGradient[1]).withAlpha(0.34)
   const fillMaterial = new RadialGradientMaterialProperty(fillColorStart, fillColorEnd)
   zones.forEach((zone, index) => {
      if (!zone?.geom) return
      const positions = getDefenseZonePositions(zone.geom)
      if (!positions.length) return
      const entityId = `${idPrefix}-${zone.id ?? index}`
      const linePositions = positions.length > 1 ? [...positions, positions[0]] : positions
      source.entities.add({
         id: entityId,
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(positions),
            material: fillMaterial,
            outline: true,
            outlineColor: borderColor,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
         polyline: {
            positions: linePositions,
            clampToGround: true,
            width: 2,
            material: borderColor,
         },
      })
   })
}
const renderDefenseZones = zones => {
   if (!viewer) return
   if (!defenseZoneSource) {
      defenseZoneSource = new Cesium.CustomDataSource('defenseZoneSource')
      viewer.dataSources.add(defenseZoneSource)
   }
   clearDefenseZoneEntities()
   defenseZoneSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: defenseZoneSource,
      idPrefix: 'defense-zone',
      lineColor: '#19D266',
      fillGradient: ['#2AEDBF', '#012B11'],
   })
}
const renderPartitions = zones => {
   if (!viewer) return
   if (!partitionSource) {
      partitionSource = new Cesium.CustomDataSource('partitionSource')
      viewer.dataSources.add(partitionSource)
   }
   clearPartitionEntities()
   partitionSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: partitionSource,
      idPrefix: 'partition-zone',
      lineColor: '#FFCD2A',
      fillGradient: ['#FFC609', '#583300'],
   })
}
const loadDefenseZones = async () => {
   if (!viewer) return
   try {
      const res = await fwDefenseZonePageApi({ current: 1, size: DEFAULT_ZONE_PAGE_SIZE })
      renderDefenseZones(res?.data?.data?.records ?? [])
   } catch (error) {
      renderDefenseZones([])
   }
}
const loadPartitions = async () => {
   if (!viewer) return
   try {
      const res = await fwAreaDividePageApi({ current: 1, size: DEFAULT_ZONE_PAGE_SIZE })
      renderPartitions(res?.data?.data?.records ?? [])
   } catch (error) {
      renderPartitions([])
   }
}
const renderAggregation = list => {
   if (!viewer) return
   ensureCountyCenterMap()
   if (!aggregationSource) {
      aggregationSource = new Cesium.CustomDataSource('aggregationSource')
      viewer.dataSources.add(aggregationSource)
   }
   clearAggregationEntities()
   aggregationSource.show = clusterVisible.value
   const countMap = new Map()
      ; (list || []).forEach(item => {
         if (!item?.type) return
         countMap.set(item.type, Number(item.count ?? 0))
      })
   Array.from(countyCenterMap.entries()).forEach(([name, center], index) => {
      const position = Cesium.Cartesian3.fromDegrees(center.longitude, center.latitude, 0)
      const count = countMap.get(name) ?? 0
      aggregationSource.entities.add({
         id: `aggregation-${name}-${index}`,
         position,
         billboard: {
            image: aggregationIcon,
            width: 66.73,
            height: 43,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
         label: {
            text: `${count}`,
            fillColor: Cesium.Color.WHITE,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.CENTER,
            horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
            font: '11pt Source Han Sans CN',
            eyeOffset: new Cesium.Cartesian3(0, 0, -20), // 让label "浮" 在广告牌前面
            pixelOffset: new Cesium.Cartesian2(0, -35),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
      })
   })
}
const getPickedDevice = picks => {
   for (const pick of picks) {
      const entityId = typeof pick?.id === 'string' ? pick.id : pick?.id?.id
      if (entityId && deviceEntityMap.has(entityId)) {
         return deviceEntityMap.get(entityId)
      }
   }
   return null
}
const updatePopupPosition = () => {
   if (!viewer || !selectedDeviceEntity) return
   const cartesian =
      selectedDeviceEntity.position?.getValue?.(Cesium.JulianDate.now()) ||
      selectedDeviceEntity.position?._value
   if (!cartesian) return
   const screenPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
   if (!screenPosition) return
   popupPosition.value = { x: screenPosition.x, y: screenPosition.y }
}
const startPopupRender = () => {
   if (!viewer || popupRenderHandler) return
   popupRenderHandler = () => updatePopupPosition()
   viewer.scene.postRender.addEventListener(popupRenderHandler)
   updatePopupPosition()
}
const stopPopupRender = () => {
   if (!viewer || !popupRenderHandler) return
   viewer.scene.postRender.removeEventListener(popupRenderHandler)
   popupRenderHandler = null
}
const handleDeviceClick = movement => {
   if (!viewer) return
   const picks = viewer.scene.drillPick(movement.position) || []
   const pickedDevice = getPickedDevice(picks)
   if (!pickedDevice) {
      closePopup()
      return
   }
   selectedDevice.value = pickedDevice.data
   selectedDeviceEntity = pickedDevice.entity
   startPopupRender()
}
const initDeviceClickHandler = () => {
   if (deviceClickHandler || !viewer) return
   deviceClickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
   deviceClickHandler.setInputAction(handleDeviceClick, Cesium.ScreenSpaceEventType.LEFT_CLICK)
}
const destroyDeviceClickHandler = () => {
   deviceClickHandler?.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)
   deviceClickHandler?.destroy()
   deviceClickHandler = null
}
function closePopup () {
   selectedDevice.value = null
   selectedDeviceEntity = null
   stopPopupRender()
}
const popupPosition = ref({ x: 0, y: 0 })
const popupVisible = computed(() => Boolean(selectedDevice.value))
const popupStyle = computed(() => ({
   left: `${popupPosition.value.x}px`,
   top: `${popupPosition.value.y}px`,
}))
const POPUP_TITLE_MAX = 12
const popupTitle = computed(() => selectedDevice.value?.deviceName || selectedDevice.value?.deviceModel || '-')
const popupTitleOverflow = computed(() => popupTitle.value.length > POPUP_TITLE_MAX)
const popupTitleDisplay = computed(() => {
   if (!popupTitleOverflow.value) return popupTitle.value
   return `${popupTitle.value.slice(0, POPUP_TITLE_MAX)}……`
})
const popupStatusText = computed(() => {
   const status = selectedDevice.value?.status
   if (status === 0) return '在线'
   if (status === 1) return '离线'
   if (status === 2) return '故障'
   if (status === 3) return '报废'
   return '-'
})
const popupStatusClass = computed(() => (selectedDevice.value?.status === 0 ? 'online' : ''))
const popupActionText = computed(() => {
   const workMode = selectedDevice.value?.workMode
   if (workMode === 1 || workMode === '1') return '侦测中'
   if (workMode === 2 || workMode === '2') return '信号干扰中'
   if (workMode === 3 || workMode === '3') return '诱导驱离中'
   if (workMode === 4 || workMode === '4') return '待机'
   return workMode || '-'
})
const popupActionClass = computed(() => {
   const workMode = selectedDevice.value?.workMode
   if (workMode === 1 || workMode === '1') return 'is-detecting'
   if (workMode === 2 || workMode === '2') return 'is-jamming'
   if (workMode === 3 || workMode === '3') return 'is-driving'
   if (workMode === 4 || workMode === '4') return 'is-standby'
   return 'is-standby'
})
const popupTypeText = computed(() => {
   const type = selectedDevice.value?.deviceType
   if (type === 1 || type === '1') return '便捷侦测箱'
   if (type === 2 || type === '2') return '反制枪'
   if (type === 3 || type === '3') return '察打一体'
   return type || '-'
})
const popupBatteryText = computed(() => {
   const val = selectedDevice.value?.batteryPct
   if (val == null) return '-'
   return `${val}%`
})
const popupAzimuthText = computed(() => {
   const val = selectedDevice.value?.azimuth
   if (val == null) return '-'
   return `${val}°`
})
const popupElevationText = computed(() => {
   const val = selectedDevice.value?.elevation
   if (val == null) return '-'
   return `${val}°`
})
const popupDetectCountText = computed(() => {
   const val = selectedDevice.value?.detectTargetCnt
   if (val == null) return '-'
   return `${val}台`
})
const popupRangeText = computed(() => {
   const val = selectedDevice.value?.effectiveRangeKm
   if (val == null) return '-'
   return `${val}KM`
})
const renderCommandPosts = list => {
   if (!viewer) return
   if (!commandPostSource) {
      commandPostSource = new Cesium.CustomDataSource('commandPostSource')
      viewer.dataSources.add(commandPostSource)
   }
   clearCommandPostEntities()
   commandPostSource.show = detailVisible.value
      ; (list || []).forEach((item, index) => {
         const position = getDevicePosition(item)
         if (!position) return
         commandPostSource.entities.add({
            id: `command-post-${item.id ?? index}`,
            position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
            billboard: {
               image: commandPostIcon,
               width: 40,
               height: 56,
               verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
               heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
            },
         })
      })
}
const loadAggregation = async () => {
   try {
      const res = await cockpitAggregationApi()
      renderAggregation(res?.data?.data ?? [])
   } catch (error) {
      renderAggregation([])
   }
}
const loadCommandPosts = async () => {
   try {
      const res = await fwDefenseSceneListApi()
      renderCommandPosts(res?.data?.data ?? [])
   } catch (error) {
      renderCommandPosts([])
   }
}
watch(
   () => props.onlineDevices,
@@ -255,6 +716,29 @@
   showLayerPanel.value = !showLayerPanel.value
}
const updateStageDisplay = stage => {
   const showCluster = stage === 'cluster'
   setClusterVisibility(showCluster)
   setDetailVisibility(!showCluster)
}
const handleMapReady = ({ viewer: mapViewer }) => {
   viewer = mapViewer
   const height = viewer?.camera?.positionCartographic?.height
   const stage = height != null && height >= 100000 ? 'cluster' : 'detail'
   updateStageDisplay(stage)
   renderDeviceEntities(props.onlineDevices)
   loadDefenseZones()
   loadPartitions()
   loadAggregation()
   loadCommandPosts()
   initDeviceClickHandler()
}
const handleStageChange = stage => {
   updateStageDisplay(stage)
}
const handleClickOutside = event => {
   if (!showLayerPanel.value) return
   const target = event.target
@@ -264,31 +748,30 @@
onMounted(() => {
   document.addEventListener('click', handleClickOutside)
   viewInstance = new PublicCesium({
      dom: props.containerId,
      flatMode: false,
      terrain: false,
      layerMode: 4,
      contour: false,
   })
   viewer = viewInstance.getViewer()
   renderDeviceEntities(props.onlineDevices)
   const map = mapRef.value?.getMap()
   if (map?.viewer) handleMapReady(map)
})
onBeforeUnmount(() => {
   document.removeEventListener('click', handleClickOutside)
   clearDeviceEntities()
   if (viewInstance) {
      viewInstance?.viewerDestroy()
      viewInstance = null
   }
   clearDefenseZoneEntities()
   clearPartitionEntities()
   clearAggregationEntities()
   clearCommandPostEntities()
   closePopup()
   destroyDeviceClickHandler()
   viewer = null
})
</script>
<style lang="scss" scoped>
.map-shell {
   position: relative;
   width: 100%;
   height: 100%;
}
.map-container {
   position: absolute;
   top: 0;
@@ -297,16 +780,182 @@
   height: 100%;
}
.device-popup {
   position: absolute;
   transform: translate(-50%, calc(-100% - 72px));
   width: 202px;
   padding: 0 16px 20px;
   border-radius: 16px;
   background: rgba(5, 5, 15, 0.7);
   backdrop-filter: blur(16px);
   color: #C3C3DD;
   font-family: Source Han Sans CN, Source Han Sans CN;
   font-size: 12px;
   pointer-events: auto;
   z-index: 11;
   box-sizing: border-box;
   &::after {
      content: "";
      position: absolute;
      left: 50%;
      bottom: -8px;
      transform: translateX(-50%);
      width: 0;
      height: 0;
      border-left: 8px solid transparent;
      border-right: 8px solid transparent;
      border-top: 8px solid rgba(5, 5, 15, 0.7);
   }
   .device-popup__header {
      height: 40px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: relative;
      font-weight: bold;
      font-size: 14px;
      color: #FFFFFF;
      &::after {
         content: "";
         position: absolute;
         left: 0;
         bottom: 0;
         width: 50px;
         height: 2px;
         background: #284FE3;
         box-shadow: 0px 3px 2px 0px rgba(15, 89, 255, 0.1), 0px 7px 5px 0px rgba(15, 89, 255, 0.15), 0px 13px 10px 0px rgba(15, 89, 255, 0.18), 0px 22px 18px 0px rgba(15, 89, 255, 0.21), 0px 42px 33px 0px rgba(15, 89, 255, 0.26), 0px 100px 80px 0px rgba(15, 89, 255, 0.36);
         border-radius: 5px 5px 0px 0px;
      }
      &::before {
         content: "";
         position: absolute;
         left: 55px;
         right: 0;
         bottom: 0;
         height: 2px;
         background: #323245;
         border-radius: 0px 0px 5px 5px;
      }
      .device-popup__title-tooltip {
         flex: 1;
         min-width: 0;
         display: block;
      }
      .device-popup__title {
         display: inline-block;
         max-width: 100%;
         white-space: nowrap;
         overflow: hidden;
         text-overflow: ellipsis;
      }
      .device-popup__close {
         cursor: pointer;
      }
   }
   .device-popup__type {
      display: flex;
      align-items: center;
      gap: 6px;
      font-size: 12px;
      margin-top: 8px;
      margin-bottom: 10px;
      color: #d2d7df;
      .label {
         color: #9aa4b2;
      }
   }
   .device-popup__subtitle {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 2px 10px;
      border-radius: 999px;
      background: rgba(255, 255, 255, 0.06);
      font-size: 12px;
      margin-bottom: 10px;
      .device-popup__dot {
         width: 6px;
         height: 6px;
         border-radius: 50%;
         background: #6b7685;
         &.is-detecting {
            background: #35f08c;
            box-shadow: 0 0 6px rgba(53, 240, 140, 0.8);
         }
         &.is-jamming {
            background: #ff4444;
            box-shadow: 0 0 6px rgba(255, 68, 68, 0.7);
         }
         &.is-driving {
            background: #ffb020;
            box-shadow: 0 0 6px rgba(255, 176, 32, 0.7);
         }
         &.is-standby {
            background: #9aa4b2;
         }
      }
   }
   .device-popup__rows {
      display: flex;
      flex-direction: column;
      gap: 6px;
      font-size: 12px;
      color: #d2d7df;
      .device-popup__row {
         display: flex;
         align-items: center;
         margin-bottom: 10px;
         .device-popup__col {
            flex: 1;
            display: flex;
            align-items: center;
            &:last-child {
               justify-content: flex-end;
            }
         }
      }
      .value {
         margin-left: 10px;
      }
      .value.online {
         color: #35f08c;
      }
   }
}
.layer-control-root {
   position: absolute;
   left: 411px;
   left: 337px;
   bottom: 22px;
   z-index: 9;
   transition: transform 0.3s ease-in-out;
   pointer-events: none;
   &.collapsed {
      transform: translateX(-401px);
      transform: translateX(-317px);
   }
}
@@ -318,8 +967,8 @@
}
.layer-control {
   width: 34px;
   height: 34px;
   width: 46px;
   height: 46px;
   cursor: pointer;
   img {
@@ -333,7 +982,7 @@
   display: flex;
   flex-direction: column;
   position: absolute;
   left: 46px;
   left: 66px;
   bottom: 0;
   width: 160px;
   max-height: 442px;
@@ -370,6 +1019,7 @@
         line-height: 30px !important;
         .el-tree-node__content {
            padding-left: 0 !important;
            display: flex;
            align-items: center;
            height: 40px !important;
@@ -381,6 +1031,12 @@
         .el-tree-node__children {
            .el-tree-node__content {
               border: none;
            }
         }
         &:focus {
            .el-tree-node__content {
               background: transparent !important;
            }
         }
      }
@@ -412,7 +1068,8 @@
   display: none;
}
.layer-panel :deep(.el-tree-node__content:hover) {
.layer-panel :deep(.el-tree-node__content:hover),
.layer-panel :deep(.el-tree-node__content:focus) {
   background: transparent !important;
}