Procházet zdrojové kódy

完善窨井-实时监测一张图

zengy před 1 dnem
rodič
revize
4ac2f0a611

+ 436 - 407
src/views/subSystem/manholeCover/mRealTimemonit/mRStateMonitoring.vue

@@ -1,476 +1,505 @@
-<!--窨井盖安全实时监测一张图-->
+<!--窨井盖安全实时监测一张图(参考 equipmentGIS.vue,仅展示 rootTypeId=4 窨井设备)-->
 <template>
-  <div class="gis-monitor-container">
-    <!-- 头部面板 -->
-    <div class="header-panel">
-      <div class="title-logo">
-        <el-icon :size="26"><Monitor /></el-icon>
-        <span class="title">窨井盖安全实时监测一张图</span>
-        <span class="version">GIS动态态势平台</span>
-      </div>
-      <div class="stats-badge">
-        <div class="stat-badge-item"><span class="dot online"></span> 在线 {{ stats.online }}</div>
-        <div class="stat-badge-item"><span class="dot alert"></span> 告警 {{ stats.alarm }}</div>
-        <div class="stat-badge-item"><span class="dot offline"></span> 离线 {{ stats.offline }}</div>
-        <div class="stat-badge-item">总计 {{ stats.total }}</div>
-        <el-button size="small" :icon="Refresh" @click="loadData" :loading="loading">刷新</el-button>
+  <div class="water-page gis-page">
+    <div class="page-head">
+      <div class="page-head-titles">
+        <div class="page-title">
+          <el-icon class="page-title-icon"><MapLocation /></el-icon>
+          <span>窨井盖安全实时监测一张图</span>
+        </div>
       </div>
+      <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
     </div>
 
-    <!-- 主内容区:左侧图层控制 + 右侧GIS地图 -->
-    <div class="main-layout">
-      <!-- 左侧图层控制面板 -->
-      <div class="layer-panel">
-        <div class="layer-header"><el-icon><Location /></el-icon> 动态监测图层</div>
-        <!-- 类型筛选 -->
-        <div class="layer-section">
-          <div class="layer-section-title">设备类型</div>
-          <el-checkbox-group v-model="typeFilter" @change="refreshMarkers">
-            <el-checkbox label="5">供水窨井</el-checkbox>
-            <el-checkbox label="9">排水窨井</el-checkbox>
-            <el-checkbox label="10">燃气窨井</el-checkbox>
-          </el-checkbox-group>
-        </div>
-        <!-- 状态筛选 -->
-        <div class="layer-section">
-          <div class="layer-section-title">设备状态</div>
-          <el-checkbox-group v-model="statusFilter" @change="refreshMarkers">
-            <el-checkbox label="normal">正常</el-checkbox>
-            <el-checkbox label="alert">告警</el-checkbox>
-            <el-checkbox label="offline">离线</el-checkbox>
-          </el-checkbox-group>
+    <!-- 设备统计 -->
+    <div class="kpi-row">
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon total"><el-icon><Monitor /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">设备总数(台)</div>
+          <div class="kpi-value">{{ statistics.total }}</div>
         </div>
-        <!-- 图例 -->
-        <div class="legend-box">
-          <div class="legend-title">图例</div>
-          <div class="legend-item"><span class="legend-icon normal-icon"></span> 正常设备</div>
-          <div class="legend-item"><span class="legend-icon alert-icon"></span> 告警设备</div>
-          <div class="legend-item"><span class="legend-icon offline-icon"></span> 离线设备</div>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon online"><el-icon><CircleCheck /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">在线设备(台)</div>
+          <div class="kpi-value">{{ statistics.online }}</div>
         </div>
-      </div>
-
-      <!-- 右侧 GIS 地图 -->
-      <div class="gis-stage">
-        <div class="map-toolbar">
-          <span class="map-title">窨井设备分布图</span>
-          <div class="legend">
-            <span><i class="legend-dot online"></i> 在线</span>
-            <span><i class="legend-dot alert"></i> 告警</span>
-            <span><i class="legend-dot offline"></i> 离线</span>
-          </div>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon offline"><el-icon><CircleClose /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">离线设备(台)</div>
+          <div class="kpi-value">{{ statistics.offline }}</div>
         </div>
-        <div class="map-area">
-          <div id="manholeStateMap" class="bmap-container"></div>
+      </el-card>
+      <el-card shadow="never" class="kpi">
+        <div class="kpi-icon alarm"><el-icon><Warning /></el-icon></div>
+        <div class="kpi-data">
+          <div class="kpi-label">报警设备(台)</div>
+          <div class="kpi-value">{{ statistics.alarm }}</div>
         </div>
-      </div>
+      </el-card>
     </div>
 
-    <!-- 设备详情抽屉 -->
-    <el-drawer v-model="drawerVisible" :title="selectedDevice?.name + ' - 监测数据详情'" direction="rtl" size="520px" class="detail-drawer">
-      <div v-if="selectedDevice" v-loading="detailLoading">
-        <!-- 基本信息 -->
-        <el-descriptions :column="1" border size="small">
-          <el-descriptions-item label="设备编号">{{ selectedDevice.code }}</el-descriptions-item>
-          <el-descriptions-item label="设备名称">{{ selectedDevice.name }}</el-descriptions-item>
-          <el-descriptions-item label="设备类型">{{ selectedDevice.typeName }}</el-descriptions-item>
-          <el-descriptions-item label="详细地址">{{ selectedDevice.location }}</el-descriptions-item>
-          <el-descriptions-item label="经纬度" v-if="selectedDevice.lng">{{ selectedDevice.lng }}, {{ selectedDevice.lat }}</el-descriptions-item>
-          <el-descriptions-item label="运行状态">
-            <el-tag :type="getStatusTagType(selectedDevice.status)" size="small">{{ getStatusText(selectedDevice.status) }}</el-tag>
-          </el-descriptions-item>
-        </el-descriptions>
-
-        <!-- 井盖监测数据 (jg_device_data) -->
-        <template v-if="selectedDevice.manholeData">
-          <el-divider content-position="left">井盖监测数据</el-divider>
-          <el-row :gutter="12">
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">倾斜角度</div>
-                <div class="metric-value" :class="{ 'metric-alert': isTiltAlert(selectedDevice.manholeData) }">
-                  {{ selectedDevice.manholeData.tiltAngle || '0' }}°
-                </div>
-                <div class="metric-sub">阈值: {{ selectedDevice.manholeData.angleAlarmThreshold || '-' }}°</div>
-              </div>
-            </el-col>
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">电池电量</div>
-                <div class="metric-value" :class="{ 'metric-warn': Number(selectedDevice.manholeData.batteryLevel) <= 20 }">
-                  {{ selectedDevice.manholeData.batteryLevel || '0' }}%
-                </div>
-              </div>
-            </el-col>
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">信号强度</div>
-                <div class="metric-value" :class="{ 'metric-warn': Number(selectedDevice.manholeData.signalStrength) <= 20 }">
-                  {{ selectedDevice.manholeData.signalStrength || '0' }}%
-                </div>
-              </div>
-            </el-col>
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">温度值</div>
-                <div class="metric-value">{{ selectedDevice.manholeData.temperatureValue || '-' }}°C</div>
-              </div>
-            </el-col>
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">水浸状态</div>
-                <div class="metric-value" :class="{ 'metric-alert': Number(selectedDevice.manholeData.waterInfiltrationAlarmStatus) === 1 }">
-                  {{ Number(selectedDevice.manholeData.waterInfiltrationAlarmStatus) === 1 ? '报警' : '正常' }}
-                </div>
-              </div>
-            </el-col>
-            <el-col :span="8">
-              <div class="metric-card">
-                <div class="metric-label">水位状态</div>
-                <div class="metric-value" :class="{ 'metric-alert': Number(selectedDevice.manholeData.waterLevelAlarmStatus) === 1 }">
-                  {{ Number(selectedDevice.manholeData.waterLevelAlarmStatus) === 1 ? '报警' : '正常' }}
-                </div>
-              </div>
-            </el-col>
-          </el-row>
-          <div class="upload-time">数据上传时间: {{ selectedDevice.manholeData.uploadTime || '-' }}</div>
-        </template>
-        <el-empty v-else description="暂无井盖监测数据" :image-size="60" />
+    <!-- 地图 -->
+    <el-card shadow="never" class="map-card">
+      <div class="map-wrap">
+        <GisMapCanvas
+          ref="gisMapRef"
+          :points="mapPoints"
+          cluster-label="设备集合"
+          :center="mapCenter"
+          :zoom="15"
+          @point-click="openDetail($event.equipmentId)"
+          @error="mapError = $event"
+        />
+        <div class="map-legend" @mousedown.stop @click.stop @dblclick.stop>
+          <div class="legend-title">点击切换显示</div>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }" @click="toggleLegend('online')">
+            <i class="legend-dot online"></i>在线
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }" @click="toggleLegend('offline')">
+            <i class="legend-dot offline"></i>离线
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }" @click="toggleLegend('alarm')">
+            <i class="legend-dot alarm"></i>报警(在线/离线时叠加)
+          </button>
+        </div>
+        <el-alert
+          v-if="mapError"
+          class="map-error"
+          type="warning"
+          show-icon
+          :closable="false"
+          :title="mapError"
+        >
+          <el-button link type="primary" @click="loadData">重试</el-button>
+        </el-alert>
+        <el-empty
+          v-if="!loading && !mapError && !mapPoints.length"
+          class="map-empty"
+          description="当前没有可显示的监测设备"
+        />
+      </div>
+    </el-card>
 
-        <!-- 关联井下设备监测数据 -->
-        <el-divider content-position="left">关联井下设备监测</el-divider>
-        <div v-if="relDevices.length === 0" class="empty-rel">暂无关联井下设备</div>
-        <div v-else class="rel-device-list">
-          <div v-for="rel in relDevices" :key="rel.relId" class="rel-device-card">
-            <div class="rel-device-header">
-              <span class="rel-device-name">{{ rel.equipmentName || '未知设备' }}</span>
-              <el-tag size="small" effect="plain">{{ rel.equipmentTypeName || '未知类型' }}</el-tag>
+    <!-- 设备采集记录抽屉 -->
+    <el-drawer
+      v-model="detailVisible"
+      :title="(currentDevice?.equipmentName || currentDevice?.equipmentCode || '设备') + ' - 采集记录'"
+      direction="rtl"
+      size="900px"
+      class="detail-drawer"
+    >
+      <div v-loading="detailLoading">
+        <template v-if="currentDevice">
+          <div class="dlg-summary">
+            <div class="summary-name">{{ currentDevice.equipmentName || '未命名设备' }}</div>
+            <div class="summary-code">{{ currentDevice.equipmentCode }}</div>
+            <span class="summary-tag">{{ currentDevice.equipmentTypeName || '未分类' }}</span>
+            <div class="status-group">
+              <span class="badge" :class="Number(currentDevice.onlineStatus) === 1 ? 'online' : 'offline'">
+                <span class="badge-dot"></span>
+                <span class="badge-text">{{ Number(currentDevice.onlineStatus) === 1 ? '在线' : '离线' }}</span>
+              </span>
+              <span v-if="Number(currentDevice.alarmStatus) === 1" class="badge alarm">
+                <el-icon class="badge-icon"><WarningFilled /></el-icon>
+                <span class="badge-text">报警</span>
+              </span>
             </div>
-            <div class="rel-device-location" v-if="rel.equipmentLocation">
-              <el-icon><Location /></el-icon> {{ rel.equipmentLocation }}
-            </div>
-            <!-- 雷达流量计数据 -->
-            <template v-if="rel.monitorData?.dataType === '雷达流量计'">
-              <el-row :gutter="8" class="rel-metrics">
-                <el-col :span="6"><div class="rel-metric"><span class="rm-label">瞬时流量</span><span class="rm-value">{{ rel.monitorData.instantFlow || '-' }}</span></div></el-col>
-                <el-col :span="6"><div class="rel-metric"><span class="rm-label">流速</span><span class="rm-value">{{ rel.monitorData.flowSpeed || '-' }}</span></div></el-col>
-                <el-col :span="6"><div class="rel-metric"><span class="rm-label">水位</span><span class="rm-value">{{ rel.monitorData.waterLevel || '-' }}</span></div></el-col>
-                <el-col :span="6"><div class="rel-metric"><span class="rm-label">累计流量</span><span class="rm-value">{{ rel.monitorData.totalFlow || '-' }}</span></div></el-col>
-              </el-row>
-            </template>
-            <!-- 环境监测数据 -->
-            <template v-else-if="rel.monitorData?.dataType === '环境监测'">
-              <el-row :gutter="8" class="rel-metrics">
-                <el-col :span="8"><div class="rel-metric"><span class="rm-label">温度</span><span class="rm-value">{{ rel.monitorData.temperature ?? '-' }}°C</span></div></el-col>
-                <el-col :span="8"><div class="rel-metric"><span class="rm-label">{{ rel.monitorData.metricName || '湿度' }}</span><span class="rm-value">{{ rel.monitorData.metricValue ?? rel.monitorData.humidity ?? '-' }}</span></div></el-col>
-                <el-col :span="8"><div class="rel-metric"><span class="rm-label">节点</span><span class="rm-value">{{ rel.monitorData.createTime || '-' }}</span></div></el-col>
-              </el-row>
-            </template>
-            <div v-else class="rel-no-data">暂无监测数据</div>
           </div>
-        </div>
+
+          <div class="form-section-title detail-section-title">采集记录(最近 50 条)</div>
+          <div class="hist-wrap">
+            <el-table :data="pagedRecordRows" max-height="420" size="small" border>
+              <el-table-column
+                v-for="c in recordColumns"
+                :key="c.key"
+                :label="c.label"
+                :min-width="c.minWidth"
+              >
+                <template #default="{ row }">
+                  <span v-if="isStatusKey(c.key)" :class="{ 'cell-alarm': row[c.key] === '1' }">
+                    {{ row[c.key] === '1' ? '报警' : '正常' }}
+                  </span>
+                  <span v-else>{{ row[c.key] != null && row[c.key] !== '' ? row[c.key] : '—' }}</span>
+                </template>
+              </el-table-column>
+              <template #empty><div class="rec-empty">暂无采集记录</div></template>
+            </el-table>
+            <el-pagination
+              v-if="recordRows.length"
+              class="rec-pager"
+              layout="prev, pager, next"
+              :total="recordRows.length"
+              :page-size="recordPageSize"
+              small
+              v-model:current-page="recordPage"
+            />
+          </div>
+        </template>
+        <el-empty v-else description="未选中设备" />
       </div>
     </el-drawer>
   </div>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
-import { Monitor, Location, Refresh } from '@element-plus/icons-vue'
-import { getManholeLayerData, getManholeRelDeviceData } from '@/api/pipeNetwork/basic'
-import { ElMessage } from 'element-plus'
-import locationIcon from '@/assets/images/location.png'
-// 台账存 WGS84,百度地图是 BD-09,不转换会偏约 1 公里
-import { convertCoord } from '@/utils/coordTransform'
+import { computed, nextTick, onMounted, reactive, ref } from 'vue'
+import { CircleCheck, CircleClose, MapLocation, Monitor, Refresh, Warning, WarningFilled } from '@element-plus/icons-vue'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getEquipmentGisPoints, getManholeDataPage } from '@/api/pipeNetwork/basic'
+import { gisPointStatusIcons } from '@/utils/gisMapColors'
 
-// ==================== 数据状态 ====================
-const loading = ref(false)
-const allDevices = ref([])
-const stats = reactive({ total: 0, online: 0, offline: 0, alarm: 0 })
+// 窨井设备固定为 rootTypeId=4
+const ROOT_TYPE_ID = 4
 
-// 筛选条件
-const typeFilter = ref(['5', '9', '10'])
-const statusFilter = ref(['normal', 'alert', 'offline'])
+const mapCenter = { lng: 110.3965, lat: 28.4638 }
 
-// 选中设备 & 详情
-const drawerVisible = ref(false)
+const allDevices = ref([])
+const loading = ref(false)
+const mapError = ref('')
+const gisMapRef = ref(null)
+const detailVisible = ref(false)
 const detailLoading = ref(false)
-const selectedDevice = ref(null)
-const relDevices = ref([])
+const currentDevice = ref(null)
+const recordRows = ref([])
+const recordPage = ref(1)
+const recordPageSize = 10
 
-// ==================== 加载数据 ====================
-async function loadData() {
-  loading.value = true
-  try {
-    const res = await getManholeLayerData({ isQueryManholeData: true })
-    const devices = res.data?.devices || []
-    allDevices.value = devices.map(d => {
-      const onlineStatus = Number(d.equipmentStatus?.onlineStatus)
-      const alarmStatus = Number(d.equipmentStatus?.alarmStatus)
-      const manholeAlarm = Number(d.manholeData?.alarmStatus)
-      const isAlert = alarmStatus === 1 || manholeAlarm === 1 ||
-        Number(d.manholeData?.waterInfiltrationAlarmStatus) === 1 ||
-        Number(d.manholeData?.waterLevelAlarmStatus) === 1
-      const isOnline = onlineStatus === 1
-      let status = 'offline'
-      if (isAlert) status = 'alert'
-      else if (isOnline) status = 'normal'
-      return {
-        id: d.equipmentId,
-        code: d.equipmentCode,
-        name: d.equipmentName,
-        typeId: d.equipmentTypeId,
-        typeName: d.equipmentTypeName,
-        location: d.equipmentLocation,
-        lng: d.longitude != null ? Number(d.longitude) : null,
-        lat: d.latitude != null ? Number(d.latitude) : null,
-        status,
-        manholeData: d.manholeData || null
-      }
-    })
-    const statistics = res.data?.statistics || {}
-    stats.total = statistics.total || allDevices.value.length
-    stats.online = statistics.online || 0
-    stats.offline = statistics.offline || 0
-    stats.alarm = statistics.alarm || 0
-    refreshMarkers()
-  } catch (e) {
-    ElMessage.error('设备数据加载失败')
-  } finally {
-    loading.value = false
-  }
+const statistics = computed(() => ({
+  total: allDevices.value.length,
+  online: allDevices.value.filter((device) => Number(device.onlineStatus) === 1).length,
+  offline: allDevices.value.filter((device) => Number(device.onlineStatus) !== 1).length,
+  alarm: allDevices.value.filter((device) => Number(device.alarmStatus) === 1).length
+}))
+
+// ============ 地图标记图标(在线=绿、离线=灰,报警叠加红点) ============
+const iconsReady = ref(false)
+const markerImages = {
+  green: typeof Image !== 'undefined' ? new Image() : null,
+  grey: typeof Image !== 'undefined' ? new Image() : null,
+  red: typeof Image !== 'undefined' ? new Image() : null
 }
+const iconCache = new Map()
 
-// ==================== 筛选后的设备 ====================
-function getFilteredDevices() {
-  return allDevices.value.filter(d => {
-    if (!typeFilter.value.includes(d.typeId)) return false
-    if (!statusFilter.value.includes(d.status)) return false
-    return true
+function loadMarkerImages() {
+  const entries = [
+    ['green', gisPointStatusIcons.normal],
+    ['grey', gisPointStatusIcons.offline],
+    ['red', gisPointStatusIcons.alarm]
+  ]
+  let pending = entries.length
+  if (!pending) {
+    iconsReady.value = true
+    return
+  }
+  const done = () => {
+    pending -= 1
+    if (pending <= 0) iconsReady.value = true
+  }
+  entries.forEach(([key, url]) => {
+    const img = markerImages[key]
+    if (!img) return done()
+    img.onload = done
+    img.onerror = done
+    img.src = url
   })
 }
 
-// ==================== 百度地图 ====================
-let mapInstance = null
-const markerMap = new Map()
-const YUANLING_CENTER = { lng: 110.393, lat: 28.452 }
+function composeAlarmIcon(online) {
+  const base = online ? markerImages.green : markerImages.grey
+  const red = markerImages.red
+  if (!base || !red || !base.complete || !red.complete) {
+    return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  }
+  const size = 32
+  const canvas = document.createElement('canvas')
+  canvas.width = size
+  canvas.height = size
+  const ctx = canvas.getContext('2d')
+  ctx.drawImage(base, 0, 0, size, size)
+  const badge = Math.round(size * 0.5)
+  ctx.drawImage(red, size - badge + 2, 1, badge, badge)
+  return canvas.toDataURL()
+}
 
-/** 业务坐标(WGS84)→ 百度坐标点 */
-function toBdPoint(lng, lat) {
-  const [bdLng, bdLat] = convertCoord(Number(lng), Number(lat))
-  return new BMapGL.Point(bdLng, bdLat)
+function getDeviceIcon(device) {
+  const online = Number(device.onlineStatus) === 1
+  const alarm = Number(device.alarmStatus) === 1
+  if (!alarm) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  if (!iconsReady.value) return online ? gisPointStatusIcons.normal : gisPointStatusIcons.offline
+  const key = online ? 'online' : 'offline'
+  if (!iconCache.has(key)) iconCache.set(key, composeAlarmIcon(online))
+  return iconCache.get(key)
 }
 
-function initMap() {
-  const container = document.getElementById('manholeStateMap')
-  if (!container || typeof BMapGL === 'undefined') {
-    console.warn('BMapGL 未加载或容器不存在')
-    return
-  }
-  try {
-    mapInstance = new BMapGL.Map('manholeStateMap')
-    mapInstance.centerAndZoom(toBdPoint(YUANLING_CENTER.lng, YUANLING_CENTER.lat), 13)
-    mapInstance.enableScrollWheelZoom(true)
-  } catch (e) {
-    console.error('百度地图初始化失败:', e)
-  }
+// ============ 图例状态筛选 ============
+const legendVisible = reactive({ online: true, offline: true, alarm: true })
+
+function toggleLegend(key) {
+  legendVisible[key] = !legendVisible[key]
 }
 
-function getStatusColor(status) {
-  if (status === 'alert') return '#F56C6C'
-  if (status === 'offline') return '#909399'
-  return '#67C23A'
+function isDeviceVisible(device) {
+  const online = Number(device.onlineStatus) === 1
+  const alarm = Number(device.alarmStatus) === 1
+  if (online && !legendVisible.online) return false
+  if (!online && !legendVisible.offline) return false
+  if (alarm && !legendVisible.alarm) return false
+  return true
 }
 
-function addMapMarker(device) {
-  if (!mapInstance || device.lng == null || device.lat == null) return
-  const color = getStatusColor(device.status)
-  const bPoint = toBdPoint(device.lng, device.lat)
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
-      ${device.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
-  const label = new BMapGL.Label(html, {
-    position: bPoint,
-    offset: new BMapGL.Size(-30, -50)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: '10' })
-  label.addEventListener('click', () => selectDevice(device))
-  mapInstance.addOverlay(label)
-  markerMap.set(device.id, label)
+const mapPoints = computed(() => {
+  void iconsReady.value
+  void legendVisible.online
+  void legendVisible.offline
+  void legendVisible.alarm
+  return allDevices.value
+    .filter(isDeviceVisible)
+    .map((device) => ({
+      ...device,
+      lng: device._longitude,
+      lat: device._latitude,
+      name: device.equipmentName || device.equipmentCode,
+      iconUrl: getDeviceIcon(device)
+    }))
+})
+
+// ============ 采集记录列(样式对齐 remoteControl.vue 采集记录) ============
+const recordColumns = [
+  { key: 'createTime', label: '采集时间', minWidth: 158 },
+  { key: 'tiltAngle', label: '倾斜角度(°)', minWidth: 104 },
+  { key: 'waterInfiltrationAlarmStatus', label: '水浸报警状态', minWidth: 104 },
+  { key: 'waterLevelAlarmStatus', label: '水位报警状态', minWidth: 104 },
+  { key: 'temperatureValue', label: '温度(℃)', minWidth: 104 },
+  { key: 'batteryLevel', label: '电池电量(%)', minWidth: 104 },
+  { key: 'signalStrength', label: '信号强度(%)', minWidth: 104 }
+]
+const statusKeys = ['waterInfiltrationAlarmStatus', 'waterLevelAlarmStatus']
+function isStatusKey(key) {
+  return statusKeys.includes(key)
+}
+const pagedRecordRows = computed(() => {
+  const start = (recordPage.value - 1) * recordPageSize
+  return recordRows.value.slice(start, start + recordPageSize)
+})
+
+// ============ 数据加载 ============
+function normalizeEquipment(row) {
+  return {
+    equipmentId: row.equipmentId,
+    equipmentCode: row.equipmentCode,
+    equipmentName: row.equipmentName,
+    equipmentTypeId: row.equipmentTypeId,
+    equipmentTypeName: row.equipmentTypeName,
+    onlineStatus: row.onlineStatus,
+    alarmStatus: row.alarmStatus,
+    _longitude: row.longitude != null ? Number(row.longitude) : null,
+    _latitude: row.latitude != null ? Number(row.latitude) : null
+  }
 }
 
-function refreshMarkers() {
-  if (!mapInstance) return
-  markerMap.forEach(label => mapInstance.removeOverlay(label))
-  markerMap.clear()
-  getFilteredDevices().forEach(device => addMapMarker(device))
+async function loadData() {
+  loading.value = true
+  mapError.value = ''
+  try {
+    const allResponse = await getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID })
+    const list = allResponse?.data ?? allResponse
+    allDevices.value = (Array.isArray(list) ? list : []).map(normalizeEquipment)
+    await nextTick()
+    gisMapRef.value?.fitBounds()
+  } catch (error) {
+    mapError.value = '监测设备数据加载失败,请点击右上角“刷新”重试'
+  } finally {
+    loading.value = false
+  }
 }
 
-// ==================== 选中设备 & 详情 ====================
-async function selectDevice(device) {
-  selectedDevice.value = device
-  drawerVisible.value = true
-  relDevices.value = []
+async function openDetail(equipmentId) {
+  if (!equipmentId) return
+  const device = allDevices.value.find((d) => d.equipmentId === equipmentId)
+  if (!device) return
+  currentDevice.value = device
+  detailVisible.value = true
   detailLoading.value = true
-  if (mapInstance && device.lng && device.lat) {
-    mapInstance.centerAndZoom(toBdPoint(device.lng, device.lat), 16)
-  }
-  highlightMarker(device.id)
+  recordRows.value = []
+  recordPage.value = 1
   try {
-    const res = await getManholeRelDeviceData(device.id)
-    relDevices.value = res.data || []
+    const res = await getManholeDataPage({ imeiCardNumber: device.equipmentCode, pageNum: 1, pageSize: 50 })
+    const page = res.data || {}
+    recordRows.value = page.records || []
   } catch (e) {
-    ElMessage.error('关联设备数据加载失败')
+    recordRows.value = []
   } finally {
     detailLoading.value = false
   }
 }
 
-function highlightMarker(id) {
-  markerMap.forEach((label, key) => {
-    const dom = label.getContentContainer ? label.getContentContainer() : null
-    if (dom) {
-      const isSelected = key === id
-      dom.style.zIndex = isSelected ? '999' : '10'
-      const title = dom.querySelector('div')
-      if (title) {
-        title.style.transform = isSelected ? 'scale(1.15)' : 'scale(1)'
-        title.style.transition = 'transform 0.2s'
-        title.style.boxShadow = isSelected ? '0 0 0 2px #409eff' : '0 1px 4px rgba(0,0,0,0.3)'
-      }
-    }
-  })
-}
-
-// ==================== 辅助方法 ====================
-function isTiltAlert(manholeData) {
-  const tilt = Number(manholeData?.tiltAngle)
-  const threshold = Number(manholeData?.angleAlarmThreshold)
-  return threshold > 0 && tilt > threshold
-}
-function getStatusText(status) {
-  return { normal: '正常', alert: '告警', offline: '离线' }[status] || status
-}
-function getStatusTagType(status) {
-  return { normal: 'success', alert: 'danger', offline: 'info' }[status] || ''
-}
-
-const handleResize = () => { mapInstance && mapInstance.resize && mapInstance.resize() }
-
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  await nextTick()
-  initMap()
-  window.addEventListener('resize', handleResize)
+onMounted(() => {
+  loadMarkerImages()
   loadData()
 })
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', handleResize)
-  if (mapInstance) {
-    mapInstance.clearOverlays && mapInstance.clearOverlays()
-    mapInstance = null
-  }
-  markerMap.clear()
-})
 </script>
 
 <style scoped>
-.gis-monitor-container {
-  background: #eef2f0;
-  height: 100vh;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-  font-family: 'Segoe UI', 'PingFang SC', Roboto, sans-serif;
+.gis-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
 }
-.header-panel {
-  background: white;
+
+/* 页头 */
+.page-head {
   display: flex;
+  align-items: center;
   justify-content: space-between;
+  margin-bottom: 20px;
+}
+.page-head-titles {
+  display: flex;
   align-items: center;
-  padding: 8px 24px;
-  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
-  z-index: 10;
+  gap: 12px;
+  min-width: 0;
+}
+.page-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
+}
+.page-title-icon {
+  color: #014db2;
+  font-size: 22px;
+}
+
+/* KPI */
+.kpi-row {
+  display: flex;
+  gap: 16px;
+  margin-bottom: 20px;
 }
-.title-logo { display: flex; align-items: center; gap: 12px; }
-.title {
-  font-size: 1.3rem; font-weight: 600;
-  background: linear-gradient(135deg, #2e7d32, #1b5e20);
-  -webkit-background-clip: text; background-clip: text; color: transparent;
+.kpi {
+  flex: 1 1 0;
+  min-width: 0;
+  border-radius: 12px;
 }
-.version { background: #e9f5e9; padding: 2px 8px; border-radius: 20px; font-size: 12px; }
-.stats-badge { display: flex; gap: 16px; align-items: center; font-size: 13px; }
-.stat-badge-item { display: flex; align-items: center; gap: 4px; }
-.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
-.dot.online { background: #67c23a; }
-.dot.alert { background: #f56c6c; }
-.dot.offline { background: #909399; }
-.main-layout { display: flex; flex: 1; overflow: hidden; gap: 12px; padding: 12px; }
-.layer-panel {
-  width: 240px; background: white; border-radius: 16px; padding: 16px;
-  box-shadow: 0 4px 12px rgba(0,0,0,0.05); display: flex; flex-direction: column;
+.kpi :deep(.el-card__body) {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 20px;
 }
-.layer-header {
-  font-weight: bold; font-size: 15px; margin-bottom: 16px;
-  display: flex; align-items: center; gap: 8px;
+.kpi-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  flex: 0 0 44px;
+  border-radius: 10px;
+  font-size: 22px;
 }
-.layer-section { margin-bottom: 16px; }
-.layer-section-title { font-size: 13px; font-weight: 600; color: #606266; margin-bottom: 8px; }
-.legend-box { margin-top: auto; border-top: 1px solid #e9ecef; padding-top: 12px; }
-.legend-title { font-weight: 600; font-size: 13px; margin-bottom: 8px; }
-.legend-item { font-size: 12px; color: #606266; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
-.legend-icon { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
-.normal-icon { background: #67c23a; }
-.alert-icon { background: #f56c6c; }
-.offline-icon { background: #909399; }
-.gis-stage {
-  flex: 1; background: white; border-radius: 16px;
-  display: flex; flex-direction: column; overflow: hidden;
-  box-shadow: 0 6px 14px rgba(0,0,0,0.08);
+.kpi-icon.total { background: #edf3fc; color: #014db2; }
+.kpi-icon.online { background: #e7f8f1; color: #10b981; }
+.kpi-icon.offline { background: #fef4e2; color: #f59e0b; }
+.kpi-icon.alarm { background: #fdecec; color: #ef4444; }
+.kpi-label { font-size: 13px; font-weight: 400; color: #6b7280; }
+.kpi-value { margin-top: 6px; font-size: 28px; font-weight: 700; line-height: 1; color: #0a1628; }
+
+/* 地图 */
+.map-card { border-radius: 12px; }
+.map-card :deep(.el-card__body) { padding: 12px; }
+.map-wrap {
+  position: relative;
+  width: 100%;
+  height: calc(100vh - 340px);
+  min-height: 480px;
+  border-radius: 8px;
+  overflow: hidden;
+}
+.map-legend {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  z-index: 9999;
+  padding: 10px 12px;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
+  pointer-events: auto;
 }
-.map-toolbar {
-  padding: 10px 16px; display: flex; justify-content: space-between;
-  border-bottom: 1px solid #f0f0f0; background: white; z-index: 2;
+.legend-title { margin-bottom: 8px; font-size: 12px; font-weight: 600; color: #94a3b8; }
+.legend-row {
+  display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
+  padding: 0; border: none; background: transparent; font: inherit;
+  font-size: 13px; color: #4d667d; cursor: pointer; transition: opacity 0.15s, color 0.15s;
 }
-.map-title { font-weight: 600; color: #303133; font-size: 14px; }
-.legend { display: flex; gap: 16px; font-size: 12px; align-items: center; }
-.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
-.legend-dot.online { background: #67C23A; }
-.legend-dot.alert { background: #F56C6C; }
-.legend-dot.offline { background: #909399; }
-.map-area { flex: 1; position: relative; min-height: 0; }
-.bmap-container { width: 100%; height: 100%; }
+.legend-row:last-child { margin-bottom: 0; }
+.legend-row:hover { color: #1f3b57; }
+.legend-row.is-off { opacity: 0.4; text-decoration: line-through; }
+.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
+.legend-dot.online { background: #2ecc71; }
+.legend-dot.offline { background: #95a5a6; }
+.legend-dot.alarm { background: #e74c3c; }
+.map-error { position: absolute; left: 16px; bottom: 16px; width: 360px; z-index: 2; }
+.map-empty { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; }
 
-/* 详情抽屉样式 */
-.metric-card {
-  background: #f8f9fc; border-radius: 8px; padding: 10px;
-  text-align: center; margin-bottom: 8px;
+/* 详情抽屉摘要 */
+.dlg-summary {
+  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
+  padding: 14px; border-radius: 8px; background: #f5f9ff;
+}
+.summary-name { font-size: 15px; font-weight: 500; color: #014db2; }
+.summary-code { font-size: 12px; font-weight: 400; color: #6b7280; }
+.summary-tag {
+  display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
+  background: #fff; font-size: 11px; font-weight: 500; color: #014db2;
 }
-.metric-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
-.metric-value { font-size: 18px; font-weight: bold; color: #303133; }
-.metric-alert { color: #f56c6c; }
-.metric-warn { color: #e6a23c; }
-.metric-sub { font-size: 11px; color: #c0c4cc; margin-top: 2px; }
-.upload-time { font-size: 12px; color: #909399; margin-top: 8px; text-align: right; }
-.empty-rel { text-align: center; color: #909399; padding: 20px; font-size: 13px; }
-.rel-device-list { display: flex; flex-direction: column; gap: 12px; }
-.rel-device-card {
-  background: #f8f9fc; border-radius: 8px; padding: 12px;
-  border: 1px solid #ebeef5;
+.form-section-title {
+  margin: 8px 0 12px; padding-left: 10px; border-left: 3px solid #014db2;
+  color: #1f2937; font-size: 14px; font-weight: 600;
 }
-.rel-device-header {
-  display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;
+.detail-section-title { margin-top: 18px; }
+.status-group { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
+.badge { display: flex; align-items: center; gap: 5px; }
+.badge-dot { width: 6px; height: 6px; border-radius: 3px; flex-shrink: 0; }
+.badge-text { font-size: 12px; font-weight: 500; }
+.badge.online .badge-dot { background: #10b981; }
+.badge.online .badge-text { color: #10b981; }
+.badge.offline .badge-dot { background: #f59e0b; }
+.badge.offline .badge-text { color: #f59e0b; }
+.badge.alarm .badge-text { color: #ef4444; }
+.badge-icon { font-size: 13px; color: #ef4444; }
+
+/* 采集记录表格(对齐 remoteControl.vue) */
+.hist-wrap { overflow: auto; border-radius: 8px; background: #fff; }
+.hist-wrap :deep(.el-table) { font-size: 12px; border: 1px solid #e3e9f2; border-radius: 8px; }
+.hist-wrap :deep(.el-table th.el-table__cell) { background: #f7f9fc; color: #5e6d82; font-weight: 600; }
+.hist-wrap :deep(.el-table td.el-table__cell) { padding: 6px 0; border-bottom: 1px solid #eef2f7; }
+.hist-wrap :deep(.el-table .cell) { padding: 0 10px; line-height: 1.5; }
+.hist-wrap :deep(.el-table td.el-table__cell .cell) { white-space: nowrap; }
+.hist-wrap :deep(.el-table__header th.el-table__cell) { border-bottom: 1px solid #e3e9f2; }
+.hist-wrap :deep(.el-table::before),
+.hist-wrap :deep(.el-table::after),
+.hist-wrap :deep(.el-table__inner-wrapper::before),
+.hist-wrap :deep(.el-table__inner-wrapper::after) { display: none; }
+.hist-wrap :deep(.el-table .el-table__row:last-child td.el-table__cell) { border-bottom: none; }
+.rec-empty { color: #9aa7b8; font-size: 12px; padding: 28px 0; }
+.cell-alarm { color: #ef4444; font-weight: 600; }
+.rec-pager { margin-top: 10px; justify-content: flex-end; display: flex; }
+
+@media (max-width: 1200px) {
+  .kpi-row { flex-wrap: wrap; }
+  .kpi { flex: 1 1 45%; }
+  .map-wrap { height: 520px; }
 }
-.rel-device-name { font-weight: 600; font-size: 13px; }
-.rel-device-location { font-size: 12px; color: #606266; display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
-.rel-metrics { margin-top: 4px; }
-.rel-metric {
-  text-align: center; background: white; border-radius: 6px; padding: 6px;
+@media (max-width: 768px) {
+  .kpi { flex: 1 1 100%; }
+  .page-head { flex-direction: column; align-items: flex-start; gap: 12px; }
 }
-.rm-label { display: block; font-size: 11px; color: #909399; }
-.rm-value { display: block; font-size: 14px; font-weight: 600; color: #303133; margin-top: 2px; }
-.rel-no-data { font-size: 12px; color: #c0c4cc; text-align: center; padding: 8px; }
 </style>

+ 8 - 8
src/views/subSystem/manholeCover/mRealTimemonit/mRealApicture.vue

@@ -34,30 +34,30 @@
         <h3>井盖监测采集数据</h3>
       </div>
       <el-table v-loading="loading" :data="list" border stripe size="small" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
-        <el-table-column prop="imeiCardNumber" label="设备编码" min-width="160" show-overflow-tooltip />
-        <el-table-column prop="equipmentName" label="设备名称" min-width="160" show-overflow-tooltip />
-        <el-table-column prop="createTime" label="采集时间" min-width="170" />
-        <el-table-column label="倾斜角度(°)" min-width="110" align="center">
+        <el-table-column prop="imeiCardNumber" label="设备编码" width="140" show-overflow-tooltip />
+        <el-table-column prop="equipmentName" label="设备名称" width="140" show-overflow-tooltip />
+        <el-table-column prop="createTime" label="采集时间" width="158" />
+        <el-table-column label="倾斜角度(°)" width="96" align="center">
           <template #default="{ row }">{{ row.tiltAngle || '—' }}</template>
         </el-table-column>
-        <el-table-column label="水浸报警状态" min-width="120" align="center">
+        <el-table-column label="水浸报警状态" width="104" align="center">
           <template #default="{ row }">
             <el-tag :type="row.waterInfiltrationAlarmStatus === '1' ? 'danger' : 'success'" size="small">
               {{ row.waterInfiltrationAlarmStatus === '1' ? '报警' : '正常' }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="水位报警状态" min-width="120" align="center">
+        <el-table-column label="水位报警状态" width="104" align="center">
           <template #default="{ row }">
             <el-tag :type="row.waterLevelAlarmStatus === '1' ? 'danger' : 'success'" size="small">
               {{ row.waterLevelAlarmStatus === '1' ? '报警' : '正常' }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="温度(℃)" min-width="110" align="center">
+        <el-table-column label="温度(℃)" width="92" align="center">
           <template #default="{ row }">{{ row.temperatureValue || '—' }}</template>
         </el-table-column>
-        <el-table-column label="电池电量(%)" min-width="120" align="center">
+        <el-table-column label="电池电量(%)" width="104" align="center">
           <template #default="{ row }">{{ row.batteryLevel || '—' }}</template>
         </el-table-column>
         <template #empty><el-empty description="暂无采集数据" /></template>