Просмотр исходного кода

feat(manholeCover): 重构井盖报警态势分析页面

- 集成百度地图替换原有Canvas绘制方案,提升地图展示效果
- 添加设备搜索过滤功能,支持按设备编号/名称/位置进行模糊查询
- 实现多维度图层筛选,包括运维状态、报警状态、在线状态等筛选条件
- 新增设备详情抽屉面板,展示设备详细信息和报警记录
- 优化地图交互体验,支持点击设备查看详细信息和定位功能
- 添加统计标签显示搜索结果和当前显示设备数量
- 实现设备标记点渲染,根据状态显示不同颜色和样式
- 添加地图加载错误处理和空状态提示
- 重构数据处理逻辑,从模拟数据改为真实API接口调用
nahida 1 неделя назад
Родитель
Сommit
20562b65b1
1 измененных файлов с 606 добавлено и 183 удалено
  1. 606 183
      src/views/subSystem/manholeCover/alarm/alarmSanalysis.vue

+ 606 - 183
src/views/subSystem/manholeCover/alarm/alarmSanalysis.vue

@@ -28,9 +28,28 @@
       <!-- 左侧图层控制面板 -->
       <div class="layer-control">
         <div class="layer-header">
-          <el-icon>x</el-icon> 态势图层
+          <el-icon><Monitor /></el-icon> 态势图层
+        </div>
+        <div class="layer-filter-panel">
+          <el-input
+            v-model="queryParams.keyword"
+            clearable
+            :prefix-icon="Search"
+            placeholder="设备编号/名称/位置模糊查询"
+            @keyup.enter="handleSearch"
+          />
+          <div class="filter-actions">
+            <el-button type="primary" size="small" @click="handleSearch">查询</el-button>
+            <el-button size="small" :icon="RefreshRight" @click="handleReset">重置</el-button>
+          </div>
+          <div class="filter-summary">
+            <el-tag size="small" effect="plain">搜索结果 {{ totalDevices }} 台</el-tag>
+            <el-tag size="small" type="success" effect="plain">当前显示 {{ displayDevices.length }} 台</el-tag>
+            <el-tag v-if="activeQuickFilter" size="small" type="warning" effect="plain">态势筛选: {{ quickFilterLabel }}</el-tag>
+          </div>
         </div>
         <el-tree
+            ref="layerTreeRef"
             :data="layerTree"
             show-checkbox
             node-key="id"
@@ -57,6 +76,7 @@
         <div class="time-selector">
           <el-date-picker v-model="timeRange" type="datetimerange" range-separator="至" size="small" :shortcuts="shortcuts" start-placeholder="开始" end-placeholder="结束" />
         </div>
+        <div class="panel-tip">已选图层会实时过滤地图点位,支持结合关键字一起查询。</div>
       </div>
 
       <!-- 右侧GIS地图核心区 -->
@@ -68,23 +88,23 @@
             <el-button size="small" :icon="FullScreen" @click="resetView">重置视图</el-button>
           </el-button-group>
           <div class="current-stat">
-            <span><el-icon><Position /></el-icon> 监测点总数: {{ totalDevices }}</span>
+            <span><el-icon><Position /></el-icon> 设备总数: {{ totalDevices }}</span>
+            <span><el-icon><Monitor /></el-icon> 当前显示: {{ displayDevices.length }}</span>
             <span class="alert-summary">报警中: {{ totalActiveAlerts }}</span>
           </div>
         </div>
-        <div class="map-container" ref="mapContainer">
-          <canvas id="alertMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
-          <!-- 悬浮信息卡 -->
-          <div v-if="hoverDevice" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverDevice.name }}</div>
-            <div>状态: {{ hoverDevice.statusText }}</div>
-            <div>报警: {{ hoverDevice.hasAlert ? hoverDevice.alertType : '无' }}</div>
-            <div>位置: {{ hoverDevice.locationShort }}</div>
+        <div class="map-container" ref="mapContainer" v-loading="listLoading">
+          <div ref="baiduMapRef" class="baidu-map"></div>
+          <div v-if="!listLoading && !displayDevices.length" class="empty-state">
+            当前筛选条件下暂无设备
+          </div>
+          <div v-if="mapError" class="map-error">
+            {{ mapError }}
           </div>
         </div>
         <!-- 右下角报警聚合弹窗/详情 -->
         <el-drawer v-model="drawerVisible" :title="selectedDevice?.name + ' - 监测报警详情'" direction="rtl" size="420px">
-          <div v-if="selectedDevice">
+          <div v-if="selectedDevice" v-loading="detailLoading">
             <el-descriptions :column="1" border>
               <el-descriptions-item label="设备编号">{{ selectedDevice.id }}</el-descriptions-item>
               <el-descriptions-item label="地理位置">{{ selectedDevice.address }}</el-descriptions-item>
@@ -118,62 +138,105 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
-import { MapLocation, ZoomIn, ZoomOut, FullScreen, Position, WarningFilled, Location, Monitor, Opportunity } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
+import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
+import {
+  Connection,
+  FullScreen,
+  InfoFilled,
+  Monitor,
+  Position,
+  RefreshRight,
+  Search,
+  Setting,
+  WarningFilled,
+  ZoomIn,
+  ZoomOut
+} from '@element-plus/icons-vue'
+import {ElMessage} from 'element-plus'
+import {getManholeAlarmDeviceDetail, getManholeAlarmDeviceList} from '@/api/pipeNetwork/basic'
 
-// 图层树结构(分图层汇聚展示)
 const layerTree = ref([
   {
-    id: 'layer-status',
-    label: '井盖本体状态图层',
-    icon: 'Monitor',
-    alertCount: 3,
+    id: 'current-status',
+    label: '运维状态筛选',
+    icon: Setting,
+    alertCount: 0,
     children: [
-      { id: 'tilt', label: '位移/倾斜监测', icon: 'Position', alertCount: 2 },
-      { id: 'open', label: '开盖状态监测', icon: 'Lock', alertCount: 1 },
-      { id: 'vibration', label: '震动异常监测', icon: 'Opportunity', alertCount: 0 }
+      { id: 'current-1', label: '在用设备', icon: Monitor, alertCount: 0 },
+      { id: 'current-3', label: '维修设备', icon: Setting, alertCount: 0 }
     ]
   },
   {
-    id: 'layer-alert',
-    label: '实时报警图层',
-    icon: 'WarningFilled',
-    alertCount: 3,
+    id: 'alarm-status',
+    label: '报警状态筛选',
+    icon: WarningFilled,
+    alertCount: 0,
     children: [
-      { id: 'critical-alert', label: '严重报警点', icon: 'WarningFilled', alertCount: 2 },
-      { id: 'warning-alert', label: '一般报警点', icon: 'InfoFilled', alertCount: 1 }
+      { id: 'alarm-1', label: '报警设备', icon: WarningFilled, alertCount: 0 },
+      { id: 'alarm-0', label: '正常设备', icon: InfoFilled, alertCount: 0 }
     ]
   },
   {
-    id: 'layer-device',
-    label: '设备运维图层',
-    icon: 'Setting',
-    alertCount: 1,
+    id: 'online-status',
+    label: '在线状态筛选',
+    icon: Connection,
+    alertCount: 0,
     children: [
-      { id: 'battery-low', label: '低电量设备', icon: 'Battery', alertCount: 1 },
-      { id: 'offline', label: '离线设备', icon: 'Connection', alertCount: 1 }
+      { id: 'online-1', label: '在线设备', icon: Connection, alertCount: 0 },
+      { id: 'online-0', label: '离线设备', icon: Connection, alertCount: 0 }
     ]
   }
 ])
-const defaultCheckedLayers = ['layer-status', 'layer-alert', 'tilt', 'open', 'critical-alert', 'warning-alert']
+const defaultCheckedLayers = ['current-1', 'current-3', 'alarm-1', 'alarm-0', 'online-1', 'online-0']
 const visibleLayers = ref(new Set(defaultCheckedLayers))
+const layerTreeRef = ref(null)
+const queryParams = ref({
+  keyword: ''
+})
+const mockManholes = ref([])
+const listLoading = ref(false)
+const detailLoading = ref(false)
+const activeQuickFilter = ref('')
+const baiduMapRef = ref(null)
+const mapError = ref('')
 
-// 模拟窨井盖设备数据 (含报警态势)
-const mockManholes = ref([
-  { id: 'MN-001', name: '人民路1号', lng: 121.487, lat: 31.249, address: '人民路与解放路口', status: 'critical', statusText: '严重报警', hasAlert: true, level: 'critical', alertType: '倾斜超标', realTimeValue: '14.2°', threshold: '8°', lastComm: '2025-04-02 14:20', locationShort: '人民路口', battery: 67,
-    alertLogs: [{ time: '2025-04-02 14:18', content: '倾斜角14.2°超过阈值8°' }] },
-  { id: 'MN-002', name: '滨江路防爆井', lng: 121.502, lat: 31.235, address: '滨江路化工园区', status: 'critical', statusText: '严重报警', hasAlert: true, level: 'critical', alertType: '水位超标', realTimeValue: '62cm', threshold: '40cm', lastComm: '2025-04-02 14:05', locationShort: '滨江园区', battery: 34,
-    alertLogs: [{ time: '2025-04-02 13:58', content: '水位62cm超阈值' }] },
-  { id: 'MN-003', name: '古城街井盖', lng: 121.478, lat: 31.258, address: '古城街北段', status: 'warning', statusText: '一般报警', hasAlert: true, level: 'warning', alertType: '异常震动', realTimeValue: '112mg', threshold: '80mg', lastComm: '2025-04-02 13:30', locationShort: '古城街', battery: 55,
-    alertLogs: [{ time: '2025-04-02 13:25', content: '震动112mg异常' }] },
-  { id: 'MN-004', name: '高新区智能井', lng: 121.512, lat: 31.242, address: '高新大道云计算中心', status: 'normal', statusText: '正常', hasAlert: false, lastComm: '2025-04-02 14:15', locationShort: '高新区', battery: 92 },
-  { id: 'MN-005', name: '南港路井盖', lng: 121.495, lat: 31.228, address: '南港路旧车站', status: 'warning', statusText: '一般报警', hasAlert: true, level: 'warning', alertType: '井盖开启异常', realTimeValue: '开启', threshold: '关闭', lastComm: '2025-04-02 12:50', locationShort: '南港路', battery: 41,
-    alertLogs: [{ time: '2025-04-02 12:48', content: '井盖非正常开启' }] },
-  { id: 'MN-006', name: '石化区防爆井', lng: 121.524, lat: 31.239, address: '石化大道', status: 'offline', statusText: '离线', hasAlert: false, lastComm: '2025-04-01 20:00', locationShort: '石化区', battery: 12 }
-])
+const BAIDU_MAP_AK = import.meta.env.VITE_BAIDU_MAP_AK || ''
+const DEFAULT_MAP_CENTER = { lng: 110.393, lat: 28.452 }
+
+let mapInstance = null
+let mapOverlays = []
+let baiduMapScriptPromise = null
+
+const quickFilterLabelMap = {
+  critical: '严重报警',
+  warning: '一般报警',
+  normal: '正常井盖',
+  offline: '离线设备'
+}
+
+const quickFilterLabel = computed(() => quickFilterLabelMap[activeQuickFilter.value] || '')
+
+function isBatteryLow(device) {
+  const battery = toNumber(device.battery)
+  return battery !== null && battery <= 20
+}
+
+function matchesQuickFilter(device) {
+  if (!activeQuickFilter.value) return true
+  return device.status === activeQuickFilter.value
+}
+
+function getSingleCheckedValue(checkedKeys, prefix) {
+  const matched = [...checkedKeys].filter(key => key.startsWith(prefix))
+  if (matched.length !== 1) return undefined
+  const value = Number(matched[0].slice(prefix.length))
+  return Number.isFinite(value) ? value : undefined
+}
+
+const displayDevices = computed(() => {
+  return mockManholes.value.filter(matchesQuickFilter)
+})
 
-// 统计
 const criticalCount = computed(() => mockManholes.value.filter(m => m.status === 'critical').length)
 const warningCount = computed(() => mockManholes.value.filter(m => m.status === 'warning').length)
 const normalCount = computed(() => mockManholes.value.filter(m => m.status === 'normal').length)
@@ -181,143 +244,437 @@ const offlineCount = computed(() => mockManholes.value.filter(m => m.status ===
 const totalDevices = computed(() => mockManholes.value.length)
 const totalActiveAlerts = computed(() => criticalCount.value + warningCount.value)
 
-// GIS Canvas 绘图部分
-const canvasRef = ref(null)
 const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-const bounds = { minLng: 121.45, maxLng: 121.55, minLat: 31.22, maxLat: 31.28 }
-
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf2e6'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i < 12; i++) {
-    ctx.beginPath()
-    ctx.moveTo((i / 12) * mapWidth, 0)
-    ctx.lineTo((i / 12) * mapWidth, mapHeight)
-    ctx.stroke()
-    ctx.beginPath()
-    ctx.moveTo(0, (i / 12) * mapHeight)
-    ctx.lineTo(mapWidth, (i / 12) * mapHeight)
-    ctx.stroke()
+const bounds = ref({ minLng: 121.45, maxLng: 121.55, minLat: 31.22, maxLat: 31.28 })
+
+function toNumber(value) {
+  const num = Number(value)
+  return Number.isFinite(num) ? num : null
+}
+
+function formatDateTime(value) {
+  return value || '—'
+}
+
+function isValidCoordinate(lng, lat) {
+  return lng !== null && lat !== null && lng >= 70 && lng <= 140 && lat >= 3 && lat <= 55
+}
+
+function getThresholdTextByCode(raw, warningCode) {
+  const list = Array.isArray(raw?.warningThresholdList) ? raw.warningThresholdList : []
+  const target = list.find(item => item.warningCode === warningCode)
+  if (!target) return '—'
+  const { minValue, maxValue } = target
+  if (minValue !== null && minValue !== undefined && maxValue !== null && maxValue !== undefined) {
+    return `${minValue} ~ ${maxValue}`
+  }
+  if (minValue !== null && minValue !== undefined) return `<= ${minValue}`
+  if (maxValue !== null && maxValue !== undefined) return `>= ${maxValue}`
+  return '—'
+}
+
+function buildAlertInfo(raw) {
+  const status = raw?.equipmentStatus || {}
+  const manholeData = raw?.manholeData || {}
+  const battery = toNumber(manholeData.batteryLevel)
+  const signal = toNumber(manholeData.signalStrength)
+  const tiltAngle = manholeData?.tiltAngle
+
+  if (Number(status.onlineStatus) !== 1) {
+    return { status: 'offline', statusText: '离线', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
+  }
+
+  if (`${manholeData.waterLevelAlarmStatus}` === '1') {
+    return {
+      status: 'critical',
+      statusText: '严重报警',
+      hasAlert: true,
+      level: 'critical',
+      alertType: '水位报警',
+      realTimeValue: '报警',
+      threshold: getThresholdTextByCode(raw, 'WARN-WATER-LEVEL')
+    }
   }
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('人民路', 180, 110)
-  ctx.fillText('滨江路', 380, 280)
-  ctx.fillText('高新区', 580, 170)
-}
-
-function drawManholes() {
-  if (!ctx) return
-  mockManholes.value.forEach(device => {
-    const { x, y } = worldToCanvas(device.lng, device.lat)
-    if (x < -40 || x > mapWidth + 40 || y < -40 || y > mapHeight + 40) return
-    // 根据状态确定图标样式
-    let color = '#67C23A'
-    let size = 14
-    let isAlertBlink = false
-    if (device.status === 'critical') { color = '#F56C6C'; size = 18; isAlertBlink = true }
-    else if (device.status === 'warning') { color = '#E6A23C'; size = 16 }
-    else if (device.status === 'offline') { color = '#909399'; size = 12 }
-    // 闪烁效果: 简单模拟根据时间正弦变化,但在重绘中直接绘制不闪烁,但可增加外圈
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = color
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-    if (isAlertBlink) {
-      ctx.beginPath()
-      ctx.arc(x, y, size + 4, 0, 2 * Math.PI)
-      ctx.fillStyle = 'rgba(245,108,108,0.3)'
-      ctx.fill()
+
+  if (`${manholeData.waterInfiltrationAlarmStatus}` === '1') {
+    return {
+      status: 'critical',
+      statusText: '严重报警',
+      hasAlert: true,
+      level: 'critical',
+      alertType: '水浸报警',
+      realTimeValue: '报警',
+      threshold: getThresholdTextByCode(raw, 'WARN-WATER-INFILTRATION')
     }
-    ctx.fillStyle = '#fff'
-    ctx.font = 'bold 12px sans-serif'
-    ctx.fillText(device.name.slice(0, 4), x-10, y-8)
-    // 图层过滤额外绘制报警标识
-    if (visibleLayers.value.has('critical-alert') && device.status === 'critical') {
-      ctx.fillStyle = '#ff4d4f'
-      ctx.beginPath()
-      ctx.moveTo(x+12, y-12)
-      ctx.lineTo(x+18, y-5)
-      ctx.fill()
+  }
+
+  if (Number(status.alarmStatus) === 1) {
+    return {
+      status: 'critical',
+      statusText: '严重报警',
+      hasAlert: true,
+      level: 'critical',
+      alertType: tiltAngle ? '倾斜报警' : '设备报警',
+      realTimeValue: tiltAngle ? `${tiltAngle}°` : '报警',
+      threshold: tiltAngle ? getThresholdTextByCode(raw, 'WARN-TILT') : getThresholdTextByCode(raw, 'WARN-DEVICE-ALARM')
     }
-    if (visibleLayers.value.has('tilt') && device.alertType?.includes('倾斜')) {
-      ctx.fillStyle = '#f56c6c'
-      ctx.fillRect(x+8, y-14, 6, 6)
+  }
+
+  if ((battery !== null && battery <= 20) || (signal !== null && signal <= 20)) {
+    return {
+      status: 'warning',
+      statusText: '一般报警',
+      hasAlert: true,
+      level: 'warning',
+      alertType: battery !== null && battery <= 20 ? '低电量预警' : '弱信号预警',
+      realTimeValue: battery !== null && battery <= 20 ? `${battery}%` : `${signal}%`,
+      threshold: '20'
     }
+  }
+
+  return { status: 'normal', statusText: '正常', hasAlert: false, level: '', alertType: '', realTimeValue: '—', threshold: '—' }
+}
+
+function buildAlertLogs(raw, alertInfo) {
+  if (!alertInfo?.hasAlert) return []
+  const timestamp = raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime || ''
+  return [{
+    time: formatDateTime(timestamp),
+    content: `${alertInfo.alertType}${alertInfo.realTimeValue && alertInfo.realTimeValue !== '—' ? `:${alertInfo.realTimeValue}` : ''}${alertInfo.threshold && alertInfo.threshold !== '—' ? `,阈值 ${alertInfo.threshold}` : ''}`
+  }]
+}
+
+function normalizeDevice(raw, _index = 0, baseDevice = {}) {
+  const rawLng = toNumber(raw?.longitude)
+  const rawLat = toNumber(raw?.latitude)
+  const baseLng = toNumber(baseDevice.lng)
+  const baseLat = toNumber(baseDevice.lat)
+  const useRawCoordinate = isValidCoordinate(rawLng, rawLat)
+  const useBaseCoordinate = isValidCoordinate(baseLng, baseLat)
+  const lng = useRawCoordinate ? rawLng : (useBaseCoordinate ? baseLng : null)
+  const lat = useRawCoordinate ? rawLat : (useBaseCoordinate ? baseLat : null)
+  const alertInfo = buildAlertInfo(raw)
+  const address = raw?.equipmentLocation || baseDevice.address || '暂无位置信息'
+  return {
+    equipmentId: raw?.equipmentId || baseDevice.equipmentId || '',
+    id: raw?.equipmentCode || baseDevice.id || raw?.equipmentId || '',
+    name: raw?.equipmentName || baseDevice.name || raw?.equipmentCode || '未命名设备',
+    lng,
+    lat,
+    address,
+    status: alertInfo.status,
+    statusText: alertInfo.statusText,
+    hasAlert: alertInfo.hasAlert,
+    level: alertInfo.level,
+    alertType: alertInfo.alertType,
+    realTimeValue: alertInfo.realTimeValue,
+    threshold: alertInfo.threshold,
+    lastComm: formatDateTime(raw?.equipmentStatus?.statusUpdateTime || raw?.updateTime || raw?.installTime),
+    locationShort: address.length > 8 ? `${address.slice(0, 8)}...` : address,
+    battery: raw?.manholeData?.batteryLevel || '—',
+    alertLogs: buildAlertLogs(raw, alertInfo),
+    equipmentTypeName: raw?.equipmentTypeName || '—',
+    equipmentModel: raw?.equipmentModel || '—',
+    manufacturer: raw?.manufacturer || '—',
+    installTime: formatDateTime(raw?.installTime),
+    maintainer: raw?.maintainer || '—',
+    raw
+  }
+}
+
+function refreshLayerTree() {
+  const sourceDevices = mockManholes.value
+  const critical = criticalCount.value
+  const warning = warningCount.value
+  const normal = normalCount.value
+  const offline = offlineCount.value
+  const inUse = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 1).length
+  const maintenance = sourceDevices.filter(item => Number(item.raw?.equipmentStatus?.currentStatus) === 3).length
+
+  layerTree.value[0].alertCount = inUse + maintenance
+  layerTree.value[0].children[0].alertCount = inUse
+  layerTree.value[0].children[1].alertCount = maintenance
+  layerTree.value[1].alertCount = critical + warning + normal
+  layerTree.value[1].children[0].alertCount = critical + warning
+  layerTree.value[1].children[1].alertCount = normal
+  layerTree.value[2].alertCount = totalDevices.value
+  layerTree.value[2].children[0].alertCount = totalDevices.value - offline
+  layerTree.value[2].children[1].alertCount = offline
+}
+
+function refreshBounds() {
+  const validList = displayDevices.value.filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
+  if (!validList.length) {
+    bounds.value = { minLng: 110.35, maxLng: 110.43, minLat: 28.42, maxLat: 28.5 }
+    return
+  }
+  const lngList = validList.map(item => item.lng)
+  const latList = validList.map(item => item.lat)
+  const minLng = Math.min(...lngList)
+  const maxLng = Math.max(...lngList)
+  const minLat = Math.min(...latList)
+  const maxLat = Math.max(...latList)
+  bounds.value = {
+    minLng: minLng - 0.01,
+    maxLng: maxLng + 0.01,
+    minLat: minLat - 0.01,
+    maxLat: maxLat + 0.01
+  }
+}
+
+function getStatusColor(status) {
+  if (status === 'critical') return '#f56c6c'
+  if (status === 'warning') return '#e6a23c'
+  if (status === 'offline') return '#909399'
+  return '#67c23a'
+}
+
+function getMarkerHtml(device, isActive = false) {
+  const color = getStatusColor(device.status)
+  const pulse = device.status === 'critical'
+    ? 'box-shadow: 0 0 0 8px rgba(245,108,108,0.18);'
+    : ''
+  const activeRing = isActive
+    ? 'border: 3px solid #ffffff; transform: scale(1.08);'
+    : 'border: 2px solid #ffffff;'
+  return `
+    <div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-6px);cursor:pointer;">
+      <div style="max-width:160px;padding:4px 8px;border-radius:999px;background:rgba(20,33,61,0.82);color:#fff;font-size:12px;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;">
+        ${device.name}
+      </div>
+      <div style="width:18px;height:18px;border-radius:50%;background:${color};${activeRing}${pulse}"></div>
+    </div>
+  `
+}
+
+function clearMapOverlays() {
+  if (!mapInstance) return
+  mapOverlays.forEach((overlay) => {
+    try {
+      mapInstance.removeOverlay(overlay)
+    } catch (_) {}
   })
+  mapOverlays = []
+}
+
+function fitMapViewport() {
+  if (!mapInstance || !window.BMapGL) return
+  if (!displayDevices.value.length) {
+    mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
+    return
+  }
+  const points = displayDevices.value
+    .filter(device => isValidCoordinate(device.lng, device.lat))
+    .map(device => new window.BMapGL.Point(device.lng, device.lat))
+  if (!points.length) {
+    mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
+    return
+  }
+  mapInstance.setViewport(points)
+  if (mapInstance.getZoom() > 16) {
+    mapInstance.setZoom(16)
+  }
+  if (mapInstance.getZoom() < 12) {
+    mapInstance.setZoom(12)
+  }
+}
+
+function renderMapOverlays() {
+  if (!mapInstance || !window.BMapGL) return
+  clearMapOverlays()
+  displayDevices.value
+    .filter(device => isValidCoordinate(device.lng, device.lat))
+    .forEach((device) => {
+    const point = new window.BMapGL.Point(device.lng, device.lat)
+    const isActive = selectedDevice.value?.equipmentId === device.equipmentId
+    const label = new window.BMapGL.Label(getMarkerHtml(device, isActive), {
+      position: point,
+      offset: new window.BMapGL.Size(-30, -52)
+    })
+    label.setStyle({
+      border: 'none',
+      background: 'transparent',
+      padding: '0'
+    })
+    label.addEventListener('click', async (event) => {
+      event?.domEvent?.stopPropagation?.()
+      await openDeviceDetail(device)
+    })
+    mapInstance.addOverlay(label)
+    mapOverlays.push(label)
+    })
+  fitMapViewport()
 }
 
 function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-  drawBaseMap()
-  drawManholes()
+  if (!mapInstance || !window.BMapGL) return
+  renderMapOverlays()
 }
 
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function resetView() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
+function ensureBaiduMap() {
+  if (window.BMapGL) {
+    return Promise.resolve(window.BMapGL)
+  }
+  if (baiduMapScriptPromise) {
+    return baiduMapScriptPromise
+  }
+  if (!BAIDU_MAP_AK) {
+    return Promise.reject(new Error('未配置百度地图 AK,请在 .env.development 中配置 VITE_BAIDU_MAP_AK'))
+  }
+
+  baiduMapScriptPromise = new Promise((resolve, reject) => {
+    const existing = document.querySelector('script[data-baidu-map="webgl"]')
+    if (existing) {
+      let attempts = 0
+      const timer = window.setInterval(() => {
+        attempts++
+        if (window.BMapGL) {
+          window.clearInterval(timer)
+          resolve(window.BMapGL)
+        } else if (attempts >= 50) {
+          window.clearInterval(timer)
+          reject(new Error('百度地图脚本加载超时'))
+        }
+      }, 200)
+      return
+    }
+
+    const script = document.createElement('script')
+    script.src = `https://api.map.baidu.com/api?type=webgl&v=1.0&ak=${BAIDU_MAP_AK}`
+    script.async = true
+    script.defer = true
+    script.setAttribute('data-baidu-map', 'webgl')
+    script.onload = () => {
+      let attempts = 0
+      const timer = window.setInterval(() => {
+        attempts++
+        if (window.BMapGL) {
+          window.clearInterval(timer)
+          resolve(window.BMapGL)
+        } else if (attempts >= 50) {
+          window.clearInterval(timer)
+          reject(new Error('百度地图脚本初始化超时'))
+        }
+      }, 200)
+    }
+    script.onerror = () => reject(new Error('百度地图脚本加载失败'))
+    document.head.appendChild(script)
+  })
+
+  return baiduMapScriptPromise
+}
 
-const onLayerChange = () => { renderMap() }
-const focusLayer = (type) => { /* 可缩放至对应类型设备,简化处理 */ ElMessage.info(`聚焦${type}类设备`) }
+async function initMap() {
+  if (!baiduMapRef.value) return
+  try {
+    await ensureBaiduMap()
+    if (!mapInstance) {
+      mapInstance = new window.BMapGL.Map(baiduMapRef.value, { enableMapClick: true })
+      mapInstance.enableScrollWheelZoom(true)
+      mapInstance.setMinZoom(11)
+      mapInstance.setMaxZoom(18)
+      mapInstance.centerAndZoom(new window.BMapGL.Point(DEFAULT_MAP_CENTER.lng, DEFAULT_MAP_CENTER.lat), 14)
+      mapInstance.addEventListener('click', () => {
+        drawerVisible.value = false
+      })
+    }
+    mapError.value = ''
+    renderMap()
+  } catch (error) {
+    mapError.value = error?.message || '百度地图加载失败'
+  }
+}
+
+async function loadDeviceList() {
+  listLoading.value = true
+  try {
+    const keyword = queryParams.value.keyword?.trim()
+    const checkedKeys = visibleLayers.value
+    const currentStatus = getSingleCheckedValue(checkedKeys, 'current-')
+    const alarmStatus = getSingleCheckedValue(checkedKeys, 'alarm-')
+    const onlineStatus = getSingleCheckedValue(checkedKeys, 'online-')
+    const res = await getManholeAlarmDeviceList({
+      keyword: keyword || undefined,
+      currentStatus,
+      alarmStatus,
+      onlineStatus,
+      isQueryManholeData: true
+    })
+    const list = Array.isArray(res?.data) ? res.data : []
+    mockManholes.value = list.map((item, index) => normalizeDevice(item, index))
+    refreshLayerTree()
+    refreshBounds()
+    await nextTick()
+    renderMap()
+  } catch (error) {
+    mockManholes.value = []
+    ElMessage.error('井盖设备数据加载失败')
+  } finally {
+    listLoading.value = false
+  }
+}
+
+async function openDeviceDetail(device) {
+  if (!device?.equipmentId) return
+  selectedDevice.value = device
+  drawerVisible.value = true
+  detailLoading.value = true
+  try {
+    const res = await getManholeAlarmDeviceDetail(device.equipmentId)
+    if (res?.data) {
+      const index = mockManholes.value.findIndex(item => item.equipmentId === device.equipmentId)
+      selectedDevice.value = normalizeDevice(res.data, index, device)
+      if (mapInstance && window.BMapGL && isValidCoordinate(selectedDevice.value.lng, selectedDevice.value.lat)) {
+        mapInstance.panTo(new window.BMapGL.Point(selectedDevice.value.lng, selectedDevice.value.lat))
+      }
+      renderMap()
+    }
+  } catch (error) {
+    ElMessage.error('设备详情加载失败')
+  } finally {
+    detailLoading.value = false
+  }
+}
+
+function zoomIn() {
+  if (!mapInstance) return
+  mapInstance.setZoom(Math.min(mapInstance.getZoom() + 1, 18))
+}
+function zoomOut() {
+  if (!mapInstance) return
+  mapInstance.setZoom(Math.max(mapInstance.getZoom() - 1, 11))
+}
+function resetView() {
+  if (!mapInstance || !window.BMapGL) return
+  fitMapViewport()
+}
+
+const onLayerChange = (_data, checkedInfo) => {
+  visibleLayers.value = new Set(checkedInfo.checkedKeys)
+  loadDeviceList()
+}
+const focusLayer = async (type) => {
+  activeQuickFilter.value = activeQuickFilter.value === type ? '' : type
+  const target = displayDevices.value.find(item => item.status === type)
+  if (!target) {
+    if (activeQuickFilter.value) {
+      ElMessage.info('当前筛选下无对应设备')
+    }
+    return
+  }
+  if (!activeQuickFilter.value) {
+    ElMessage.info('当前无对应设备')
+    return
+  }
+  await nextTick()
+  renderMap()
+}
 
-// 交互变量
 const selectedDevice = ref(null)
 const drawerVisible = ref(false)
-const hoverDevice = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
 const timeRange = ref([])
 const shortcuts = [{ text: '最近24小时', value: () => [new Date(Date.now() - 86400000), new Date()] }]
 
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  mockManholes.value.forEach(device => {
-    const { x, y } = worldToCanvas(device.lng, device.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = device }
-  })
-  if (hit) { selectedDevice.value = hit; drawerVisible.value = true }
-}
-
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 18, hit = null
-  mockManholes.value.forEach(device => {
-    const { x, y } = worldToCanvas(device.lng, device.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = device }
-  })
-  if (hit) { hoverDevice.value = hit; hoverX.value = e.clientX + 12; hoverY.value = e.clientY - 40 }
-  else hoverDevice.value = null
-}
-
 const getStatusTag = (status) => {
   if (status === 'critical') return 'danger'
   if (status === 'warning') return 'warning'
@@ -327,18 +684,46 @@ const getStatusTag = (status) => {
 const getAlertAdvice = (device) => {
   if (device.alertType?.includes('倾斜')) return '建议立即安排检修人员现场确认井盖固定情况,如损坏需更换。'
   if (device.alertType?.includes('水位')) return '井内水位过高,需紧急排水,检查管道是否堵塞。'
-  if (device.alertType?.includes('震动')) return '持续震动可能为外力破坏,建议巡检周边施工情况。'
+  if (device.alertType?.includes('水浸')) return '建议核查井内积水和周边排水情况,必要时安排现场排查。'
+  if (device.alertType?.includes('低电量')) return '建议尽快安排电池更换,避免设备掉线。'
+  if (device.alertType?.includes('弱信号')) return '建议核查设备天线、网络覆盖和安装环境。'
   return '请根据现场情况迅速核实并处置。'
 }
 const dispatchWorkOrder = () => { ElMessage.success('已派发工单至维护班组') }
 
-onMounted(() => {
+function handleSearch() {
+  activeQuickFilter.value = ''
+  loadDeviceList()
+}
+
+function handleReset() {
+  queryParams.value.keyword = ''
+  activeQuickFilter.value = ''
+  visibleLayers.value = new Set(defaultCheckedLayers)
+  layerTreeRef.value?.setCheckedKeys(defaultCheckedLayers)
+  loadDeviceList()
+}
+
+watch(displayDevices, async () => {
+  refreshBounds()
+  await nextTick()
+  renderMap()
+}, { deep: true })
+
+onMounted(async () => {
+  await nextTick()
+  await initMap()
+  await loadDeviceList()
   nextTick(() => {
     renderMap()
     window.addEventListener('resize', renderMap)
   })
 })
-onUnmounted(() => window.removeEventListener('resize', renderMap))
+onUnmounted(() => {
+  window.removeEventListener('resize', renderMap)
+  clearMapOverlays()
+  mapInstance = null
+})
 </script>
 
 <style scoped>
@@ -379,6 +764,20 @@ onUnmounted(() => window.removeEventListener('resize', renderMap))
   overflow-y: auto;
 }
 .layer-header { font-weight: bold; display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
+.layer-filter-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.filter-actions {
+  display: flex;
+  gap: 8px;
+}
+.filter-summary {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
 .layer-tree { flex: 1; }
 .layer-badge { margin-left: auto; }
 .legend-panel { border-top: 1px solid #e9ecef; padding-top: 12px; margin-top: 8px; }
@@ -408,19 +807,43 @@ onUnmounted(() => window.removeEventListener('resize', renderMap))
 .map-container {
   flex: 1;
   position: relative;
-  cursor: crosshair;
-}
-canvas { width: 100%; height: 100%; display: block; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
+  overflow: hidden;
+}
+.baidu-map {
+  width: 100%;
+  height: 100%;
+}
+.empty-state {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  color: #909399;
+  font-size: 14px;
+  background: rgba(255, 255, 255, 0.92);
+  padding: 10px 18px;
   border-radius: 12px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+  z-index: 2;
+}
+.map-error {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+  max-width: 320px;
+  color: #f56c6c;
+  background: rgba(255, 255, 255, 0.95);
+  border: 1px solid rgba(245, 108, 108, 0.25);
+  border-radius: 12px;
+  padding: 10px 12px;
   font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-  backdrop-filter: blur(6px);
+  line-height: 1.6;
+  z-index: 3;
 }
 .advice-box { background: #f8f9fa; padding: 12px; border-radius: 12px; }
-</style>
+.panel-tip {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
+</style>