Prechádzať zdrojové kódy

feat(drainage): 预警GIS一张图对齐报警版式并修复白屏与自适应

null 19 hodín pred
rodič
commit
c88993df67

+ 13 - 4
src/api/drainage.js

@@ -119,6 +119,15 @@ export function getWaterloggingMap(params = {}) {
   })
 }
 
+// 全部易积水点:用于设备占用关系判断(一台设备只挂一个点位)
+export function getWaterloggingList() {
+  return request({
+    url: '/api/drainage/waterlogging/list',
+    method: 'get',
+    headers: { 'X-Silent-Error': 'true' }
+  })
+}
+
 export function getWaterloggingDetail(id) {
   return request({
     url: '/api/drainage/waterlogging/' + id,
@@ -127,7 +136,7 @@ export function getWaterloggingDetail(id) {
   })
 }
 
-// 筛选下拉项:所属区域、积水程度去重值
+// 筛选下拉项:风险等级去重值
 export function getWaterloggingFilterOptions() {
   return request({
     url: '/api/drainage/waterlogging/filterOptions',
@@ -148,9 +157,9 @@ export function deleteWaterlogging(id) {
 export function batchDeleteWaterlogging(ids) {
   return request({ url: '/api/drainage/waterlogging/batch', method: 'delete', data: ids })
 }
-// 关联摄像头(整体替换,空数组表示解除全部)
-export function bindWaterloggingCameras(id, cameraIds) {
-  return request({ url: `/api/drainage/waterlogging/${id}/cameras`, method: 'post', data: { cameraIds } })
+// 关联设备(整体替换,空数组表示解除全部;后端只接受设备类型 16 雷达液位计 / 20 视频监控)
+export function bindWaterloggingDevices(id, equipmentIds) {
+  return request({ url: `/api/drainage/waterlogging/${id}/devices`, method: 'post', data: { equipmentIds } })
 }
 
 // ============ 监测设备管理 ============

+ 2 - 2
src/views/subSystem/drainage/fxpg/components/RiskAssessmentTabs.vue

@@ -90,7 +90,7 @@
           </div>
         </div>
         <div class="assess-note">
-          评分口径:管网本体 35 分 + 运行报警 35 分 + 环境叠加 20 分 + 数据质量 10 分,合计 100 分;
+          评分口径:管网本体 35 分(管龄、材质、管径、管线类别),按 35 分制折算为百分制总分;
           ≥70 为高风险,≥40 为中风险,其余为低风险。
         </div>
       </el-tab-pane>
@@ -305,7 +305,7 @@ function formatPoint(lng, lat) {
 /* ==================== 维度卡片 ==================== */
 .dimension-grid {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
+  grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
   gap: 14px;
 }
 

+ 368 - 887
src/views/subSystem/drainage/jcsj/bzyxqk.vue

@@ -1,1032 +1,513 @@
 <template>
-  <div class="bzyxqk-screen">
-    <!-- ==================== 顶部标题栏 ==================== -->
-    <div class="screen-header">
-      <div class="header-left">
-        <span class="header-time">{{ currentDateTime }}</span>
+  <div class="app-container bzyxqk-page">
+    <!-- 页头:标题 + 概况 + 定位操作 -->
+    <div class="page-head">
+      <div class="page-head-text">
+        <div class="page-title">泵站运行情况GIS一张图</div>
+        <div class="page-sub">
+          泵站 {{ total }} 座 · 正常 {{ normal }} · 检修 {{ repair }} · 停用 {{ stopped }} · 故障 {{ fault }}<span
+            v-if="unlocatedCount"> · 未维护坐标 {{ unlocatedCount }} 座</span>
+        </div>
       </div>
-      <div class="header-center">
-        <h1 class="header-title">泵站运行情况GIS一张图</h1>
+      <div class="page-head-actions">
+        <el-input v-model="searchKeyword" class="locate-input" placeholder="泵站名称" clearable
+          @keyup.enter="onSearchPump" @clear="fitAllPumps" />
+        <el-button icon="Aim" @click="onSearchPump">定位</el-button>
+        <el-button icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+        <el-button icon="FullScreen" @click="fitAllPumps">全图</el-button>
       </div>
-      <div class="header-right"></div>
     </div>
 
-    <!-- ==================== 主体三栏布局 ==================== -->
-    <div class="screen-body">
-      <!-- ========== 左侧面板 ========== -->
-      <div class="panel-left">
-        <!-- 泵站概况 -->
-        <div class="panel-box" style="flex: 0 0 auto;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 泵站概况
-          </div>
-          <div class="stats-cards">
-            <div class="stat-card">
-              <div class="stat-val">{{ pumpStats.total }}</div>
-              <div class="stat-label">泵站总数</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val green">{{ pumpStats.running }}</div>
-              <div class="stat-label">运行中</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val gray">{{ pumpStats.stopped }}</div>
-              <div class="stat-label">已停止</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val red">{{ pumpStats.fault }}</div>
-              <div class="stat-label">故障</div>
-            </div>
-          </div>
-          <div class="pie-chart-wrapper">
-            <div id="bzyxqkPieChart" class="chart-area"></div>
-            <div class="pie-legend">
-              <div class="legend-item" v-for="(item, i) in pieData" :key="i">
-                <span class="legend-dot" :style="{ background: item.color }"></span>
-                <span class="legend-label">{{ item.name }}</span>
-                <span class="legend-value">{{ item.value }}</span>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 泵站列表 -->
-        <div class="panel-box" style="flex: 1 1 0;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 泵站列表
-          </div>
-          <div class="pump-table-wrap">
-            <table class="dark-table">
-              <thead>
-                <tr>
-                  <th>泵站名称</th>
-                  <th>状态</th>
-                  <th>设计流量</th>
-                  <th>设计扬程</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="(row, i) in pumpList" :key="i"
-                    :class="{ 'row-active': selectedPumpIndex === i }"
-                    @click="selectPump(i)">
-                  <td :title="row.name">{{ row.name }}</td>
-                  <td>
-                    <span :class="'status-tag status-' + row.statusClass">{{ row.status }}</span>
-                  </td>
-                  <td>{{ row.designFlow }}</td>
-                  <td>{{ row.designHead }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </div>
-      </div>
-
-      <!-- ========== 中间地图区域 ========== -->
-      <div class="panel-center">
-        <div class="map-search">
-          <input type="text" placeholder="搜索泵站" class="search-input" v-model="searchKeyword" @input="onSearchPump" />
-          <span class="search-icon">🔍</span>
-        </div>
+    <!-- 一张图 -->
+    <el-card shadow="never" class="map-card" :body-style="{ padding: '0' }">
+      <div class="map-wrap">
         <div id="bzyxqkMap" class="map-container"></div>
-      </div>
-
-      <!-- ========== 右侧面板 ========== -->
-      <div class="panel-right">
-        <!-- 深度曲线 -->
-        <div class="panel-box" style="flex: 1 1 0;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span>数据统计
-          </div>
-          <div id="bzyxqkLineChart" class="chart-area-lg"></div>
-        </div>
 
-        <!-- 视频监控 -->
-        <div class="panel-box" style="flex: 0 0 auto;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 视频监控
-          </div>
-          <div class="video-grid">
-            <div class="video-item" v-for="(v, i) in videoList" :key="i">
-              <div class="video-placeholder">
-                <span class="camera-icon">📷</span>
-              </div>
-              <div class="video-name">{{ v.name }}</div>
-            </div>
-          </div>
+        <!-- 状态图例:颜色口径与地图标记一致,点击可切换该状态的展示 -->
+        <div class="map-legend">
+          <div class="legend-title">点击切换显示</div>
+          <button v-for="item in mapLegend" :key="item.key" type="button" class="legend-row"
+            :class="{ 'is-off': !legendVisible[item.key] }"
+            @click="legendVisible[item.key] = !legendVisible[item.key]">
+            <i class="legend-dot" :style="{ background: item.color }"></i>
+            <span class="legend-name">{{ item.text }}</span>
+            <span class="legend-count">{{ item.count }}</span>
+          </button>
+          <div v-if="unlocatedCount > 0" class="legend-tip">{{ unlocatedCount }} 座未录入坐标</div>
         </div>
 
-        <!-- 报警记录 -->
-        <div class="panel-box" style="flex: 1 1 0;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 历史报警记录
-          </div>
-          <div class="alert-stats">
-            <div class="alert-stat-card">
-              <div class="alert-stat-val">{{ alarmStats.total }}</div>
-              <div class="alert-stat-label">报警总数</div>
-            </div>
-            <div class="alert-stat-card">
-              <div class="alert-stat-val green">{{ alarmStats.handled }}</div>
-              <div class="alert-stat-label">已处置</div>
-            </div>
-            <div class="alert-stat-card">
-              <div class="alert-stat-val red">{{ alarmStats.unhandled }}</div>
-              <div class="alert-stat-label">未处置</div>
-            </div>
-          </div>
-          <div class="alarm-table-wrap">
-            <table class="dark-table">
-              <thead>
-                <tr>
-                  <th>站</th>
-                  <th>时间</th>
-                  <th>类型</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="(row, i) in alarmList" :key="i">
-                  <td :title="row.station">{{ row.station }}</td>
-                  <td>{{ row.time }}</td>
-                  <td>{{ row.type }}</td>
-                  <td>
-                    <span :class="'handle-tag ' + (row.handled ? 'handled' : 'unhandled')">
-                      {{ row.handled ? '已处置' : '未处置' }}
-                    </span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </div>
+        <div v-if="mapError" class="map-error">{{ mapError }}</div>
       </div>
-    </div>
+    </el-card>
   </div>
 </template>
 
 <script setup name="Bzyxqk">
-import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
-import * as echarts from 'echarts'
-import locationIcon from '@/assets/images/location.png'
-import { getFacilityPage, getPumpStationById, getPumpStationMonthlyStats, getAlarmDataPage } from '@/api/drainage'
+import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
+import { ensureTdtMap } from '@/utils/mapApi'
+import { useDict } from '@/utils/dict'
+import { gisPointStatusColors, gisPointStatusIcons } from '@/utils/gisMapColors'
+import { getFacilityPage } from '@/api/drainage'
 
-// ==================== 实时时间 ====================
-const currentDateTime = ref('')
-let timer = null
+// ==================== 泵站状态口径 ====================
+// 泵站台账 = 工程设施表中 facility_type=2 的记录,状态走字典 drainage_facility_status
+// (0 正常 / 1 检修 / 2 停用 / 3 故障),颜色与图标复用全局 GIS 状态色
+const STATUS_FALLBACK = [
+  { value: '0', label: '正常' },
+  { value: '1', label: '检修' },
+  { value: '2', label: '停用' },
+  { value: '3', label: '故障' }
+]
 
-function updateTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  const weekDays = ['日', '一', '二', '三', '四', '五', '六']
-  currentDateTime.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())} 星期${weekDays[now.getDay()]}`
+const STATUS_KEY_MAP = {
+  '0': 'normal', // 绿
+  '1': 'workOrder', // 蓝
+  '2': 'offline', // 灰
+  '3': 'alarm' // 红
 }
 
-// ==================== 泵站概况 ====================
-// 泵站总数/运行/故障/检修 来自 water_pump_station 表 status 字段
-const pumpStats = reactive({ total: 0, running: 0, stopped: 0, fault: 0 })
+const { drainage_facility_status: facilityStatusDict } = useDict('drainage_facility_status')
 
-const pieData = computed(() => [
-  { name: '运行中', value: pumpStats.running, color: '#67c23a' },
-  { name: '已停止', value: pumpStats.stopped, color: '#909399' },
-  { name: '故障', value: pumpStats.fault, color: '#f56c6c' }
-])
+const statusOptions = computed(() => (facilityStatusDict.value?.length ? facilityStatusDict.value : STATUS_FALLBACK))
 
-// ==================== 泵站列表 ====================
-const pumpList = ref([])
-const facilityStartDate = ref('') // 工程设施最早关联日期
+function statusLabel(value) {
+  const key = String(value ?? '0')
+  return statusOptions.value.find(item => String(item.value) === key)?.label || STATUS_FALLBACK.find(item => item.value === key)?.label || '未知'
+}
 
-const selectedPumpIndex = ref(-1)
+// ==================== 泵站数据 ====================
+const pumpList = ref([])
+const loading = ref(false)
+
+// 未录入坐标的泵站数量:地图上不落点,但要在界面上说明清楚,不能静默丢数据
+const unlocatedCount = computed(() => pumpList.value.filter(p => !p.located).length)
+
+// 页头概况统计:按台账全量口径,不受图例开关影响
+const total = computed(() => pumpList.value.length)
+const normal = computed(() => pumpList.value.filter(p => p.statusKey === 'normal').length)
+const repair = computed(() => pumpList.value.filter(p => p.statusKey === 'workOrder').length)
+const stopped = computed(() => pumpList.value.filter(p => p.statusKey === 'offline').length)
+const fault = computed(() => pumpList.value.filter(p => p.statusKey === 'alarm').length)
+
+// 地图状态图例:颜色与标记图标同源,数量按当前台账实时统计
+const mapLegend = computed(() => [
+  { key: 'normal', text: '正常', color: gisPointStatusColors.normal, count: normal.value },
+  { key: 'workOrder', text: '检修', color: gisPointStatusColors.workOrder, count: repair.value },
+  { key: 'offline', text: '停用', color: gisPointStatusColors.offline, count: stopped.value },
+  { key: 'alarm', text: '故障', color: gisPointStatusColors.alarm, count: fault.value }
+])
 
-function selectPump(index) {
-  selectedPumpIndex.value = index
-  const pump = pumpList.value[index]
-  if (!pump) return
-  // 加载深度曲线
-  initLineChart()
-  // 加载报警数据
-  loadPumpAlarm(pump.stationCode)
-}
+// 图例开关:点某一行可临时隐藏该状态,仅影响地图显示,不影响统计口径
+const legendVisible = reactive({ normal: true, workOrder: true, offline: true, alarm: true })
 
 // ==================== 搜索 ====================
 const searchKeyword = ref('')
 
 function onSearchPump() {
-  if (!searchKeyword.value.trim()) return
-  const idx = pumpList.value.findIndex(p => p.name.includes(searchKeyword.value.trim()))
-  if (idx >= 0) {
-    selectPump(idx)
+  const keyword = searchKeyword.value.trim()
+  if (!keyword) {
+    fitAllPumps()
+    return
+  }
+  const pump = pumpList.value.find(p => p.name.includes(keyword))
+  if (pump) {
+    locatePump(pump)
   }
 }
 
-// ==================== 深度曲线 ====================
-
-// ==================== 视频监控 ====================
-// TODO: 暂无视频监控API,保留mock数据
-const videoList = ref([
-  { name: '沅陵一泵站' },
-  { name: '城北泵站' },
-  { name: '河西泵站' },
-  { name: '人民路泵站' }
-])
-
-// ==================== 报警记录 ====================
-const alarmStats = reactive({ total: 0, handled: 0, unhandled: 0 })
-const alarmList = ref([])
-
-async function loadPumpAlarm(stationCode) {
-  if (!stationCode) return
-  try {
-    const res = await getAlarmDataPage(1, 100, { deviceCode: stationCode })
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || pageData.rows || []
-    alarmStats.total = records.length
-    alarmStats.handled = records.filter(r => r.status === 1 || r.status === '1').length
-    alarmStats.unhandled = alarmStats.total - alarmStats.handled
-    alarmList.value = records.slice(0, 20).map(r => ({
-      station: r.deviceName || r.deviceCode || stationCode,
-      time: r.createTime || '-',
-      type: r.alarmType || r.alarmContent || '-',
-      handled: r.status === 1 || r.status === '1'
-    }))
-  } catch (e) {
-    console.error('加载泵站报警失败', e)
-    alarmStats.total = 0
-    alarmStats.handled = 0
-    alarmStats.unhandled = 0
-    alarmList.value = []
+// ==================== 数据加载 ====================
+// 台账归一化:只保留泵站(设施类型固定 2),坐标/设计参数/状态都取设施自身字段
+function normalizeFacility(item) {
+  const statusValue = String(item.status ?? '0')
+  const statusKey = STATUS_KEY_MAP[statusValue] || 'normal'
+  const lng = Number(item.longitude) || 0
+  const lat = Number(item.latitude) || 0
+  return {
+    id: item.facilityId,
+    name: item.facilityName || '未命名泵站',
+    code: item.facilityCode || '',
+    statusValue,
+    statusKey,
+    statusText: statusLabel(statusValue),
+    color: gisPointStatusColors[statusKey],
+    icon: gisPointStatusIcons[statusKey],
+    designFlow: formatDecimal(item.designFlow),
+    designHead: formatDecimal(item.designHead),
+    address: item.location || '',
+    managementUnit: item.managementUnit || '',
+    level: item.facilityLevel || '',
+    buildDate: item.buildDate || '',
+    lng,
+    lat,
+    located: lng > 0 && lat > 0
   }
 }
 
-// ==================== 数据加载 ====================
+function formatDecimal(value) {
+  if (value === null || value === undefined || value === '') return '-'
+  const num = Number(value)
+  if (!Number.isFinite(num)) return String(value)
+  return String(Number(num.toFixed(2)))
+}
+
 async function loadData() {
+  loading.value = true
   try {
-    // 1. 先查询设施类型为"泵站"的工程设施,获取关联的泵站ID和坐标
-    //    facilityType 走字典 drainage_facility_type,2 = 泵站
-    const facilityRes = await getFacilityPage(1, 1000, { facilityType: '2' })
-    const facilityPage = facilityRes.code !== undefined ? facilityRes.data : facilityRes
-    const facilities = facilityPage.records || []
-
-    // 2. 收集所有关联的泵站ID和对应的设施坐标
-    const pumpIdFacilityMap = {} // { pumpId: { lng, lat, facilityName, dataTime } }
-    let earliestDataTime = null
-    facilities.forEach(f => {
-      if (f.equipmentId) {
-        // 设施为点要素,坐标直接取经纬度字段
-        const fLng = Number(f.longitude) || 0
-        const fLat = Number(f.latitude) || 0
-        // 记录设施的建档时间(原 dataTime 字段已随表结构重建移除)
-        const fTime = f.createTime || ''
-        if (fTime && (!earliestDataTime || fTime < earliestDataTime)) {
-          earliestDataTime = fTime
-        }
-        // 分割多个ID
-        const ids = f.equipmentId.split(',').map(s => s.trim()).filter(Boolean)
-        ids.forEach(id => {
-          pumpIdFacilityMap[id] = { lng: fLng, lat: fLat, facilityName: f.facilityName || '', dataTime: fTime }
-        })
-      }
-    })
-
-    const pumpIds = Object.keys(pumpIdFacilityMap)
-    // 保存最早关联日期
-    facilityStartDate.value = earliestDataTime ? earliestDataTime.substring(0, 10) : ''
-    if (pumpIds.length === 0) {
-      pumpList.value = []
-      pumpStats.total = 0
-      pumpStats.running = 0
-      pumpStats.stopped = 0
-      pumpStats.fault = 0
-      renderMapMarkers()
-      return
-    }
-
-    // 3. 逐个查询泵站详情
-    const results = []
-    for (const id of pumpIds) {
-      try {
-        const res = await getPumpStationById(id)
-        const item = res.code !== undefined ? res.data : res
-        if (item && item.id) results.push(item)
-      } catch (e) { /* skip */ }
-    }
-
-    // 4. 组装泵站列表数据(坐标来自工程设施的points)
-    pumpList.value = results.map(item => {
-      let status = '正常', statusClass = 'running'
-      if (item.status === '1' || item.status === 1) { status = '故障'; statusClass = 'fault' }
-      else if (item.status === '2' || item.status === 2) { status = '检修'; statusClass = 'repair' }
-
-      const facilityInfo = pumpIdFacilityMap[String(item.id)] || {}
-      return {
-        id: item.id,
-        stationCode: item.stationCode || item.station_code || '',
-        name: item.stationName || item.station_name || '未命名泵站',
-        status,
-        statusClass,
-        designFlow: item.designFlow ?? item.design_flow ?? '-',
-        designHead: item.designHead ?? item.design_head ?? '-',
-        lng: facilityInfo.lng || 0,
-        lat: facilityInfo.lat || 0
-      }
-    })
-
-    // 计算统计
-    pumpStats.total = pumpList.value.length
-    pumpStats.running = pumpList.value.filter(p => p.statusClass === 'running').length
-    pumpStats.stopped = pumpList.value.filter(p => p.statusClass === 'repair').length
-    pumpStats.fault = pumpList.value.filter(p => p.statusClass === 'fault').length
-
-    // 渲染地图
+    // 排水侧泵站台账在工程设施表(facility_type=2),pump_station 共用表暂无排水泵站数据
+    const res = await getFacilityPage(1, 1000, { facilityType: '2' })
+    const page = res.code !== undefined ? res.data : res
+    const records = page?.records || page?.rows || []
+    pumpList.value = records.map(normalizeFacility)
     renderMapMarkers()
-
-    // 默认选中第一个泵站加载曲线和报警
-    if (pumpList.value.length > 0) {
-      selectPump(0)
-    }
   } catch (e) {
     console.error('加载泵站数据失败:', e)
+    pumpList.value = []
+    renderMapMarkers()
+  } finally {
+    loading.value = false
   }
 }
 
 // ==================== 地图初始化 ====================
 let mapInstance = null
+let TdtMapRef = null
+let mapOverlays = []
+const mapError = ref('')
 
-function initMap() {
+const DEFAULT_CENTER = { lng: 110.393, lat: 28.452 }
+
+async function initMap() {
   const container = document.getElementById('bzyxqkMap')
-  if (!container || typeof TdtMap === 'undefined') {
-    console.warn('TdtMap 未加载或容器不存在')
-    return
-  }
+  if (!container) return
 
   try {
-    mapInstance = new TdtMap.Map('bzyxqkMap')
-    const centerPoint = new TdtMap.Point(110.393, 28.452)
-    mapInstance.centerAndZoom(centerPoint, 15)
+    TdtMapRef = await ensureTdtMap()
+    if (!TdtMapRef) {
+      mapError.value = '地图服务未就绪,请检查天地图配置'
+      return
+    }
+    mapInstance = new TdtMapRef.Map('bzyxqkMap')
+    mapInstance.centerAndZoom(new TdtMapRef.Point(DEFAULT_CENTER.lng, DEFAULT_CENTER.lat), 15)
     mapInstance.enableScrollWheelZoom(true)
-
-    // 渲染泵站标记(数据加载完成后由loadData调用)
+    mapError.value = ''
     renderMapMarkers()
   } catch (e) {
     console.error('天地图初始化失败:', e)
+    mapError.value = '地图加载失败,请检查天地图服务配置'
   }
 }
 
-// 渲染地图标记(数据加载完成后调用)
-function renderMapMarkers() {
+// 只清理本页自己添加的覆盖物,不误伤底图控件
+function clearMarkers() {
   if (!mapInstance) return
-  mapInstance.clearOverlays()
-
-  const allPoints = []
-  pumpList.value.forEach(pump => {
-    if (pump.lng && pump.lat) {
-      addMapMarker(pump)
-      allPoints.push(new TdtMap.Point(pump.lng, pump.lat))
+  mapOverlays.forEach(overlay => {
+    try {
+      mapInstance.removeOverlay(overlay)
+    } catch (_) {
+      // ignore overlay cleanup errors
     }
   })
-
-  // 自动调整视野以包含所有标记
-  if (allPoints.length > 0) {
-    mapInstance.setViewport(allPoints)
-  }
+  mapOverlays = []
 }
 
-function addMapMarker(pump) {
-  const statusColor = {
-    'running': '#67c23a',
-    'stopped': '#909399',
-    'fault': '#f56c6c'
-  }
-  const statusText = {
-    'running': '运行',
-    'stopped': '停止',
-    'fault': '故障'
-  }
-  const color = statusColor[pump.statusClass] || '#409eff'
-  const bPoint = new TdtMap.Point(pump.lng, pump.lat)
+// 地图只画泵站:每座泵站一个按状态着色的标记 + 名称标签
+// fit=false 用于图例切换场景,避免每次点选都重置视野
+function renderMapMarkers({ fit = true } = {}) {
+  if (!mapInstance || !TdtMapRef) return
+  clearMarkers()
 
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 6px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;">${pump.name}</div>
-    <div><img src="${locationIcon}" style="width:24px;height:24px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new TdtMap.Label(html, {
-    position: bPoint,
-    offset: new TdtMap.Size(-30, -55)
-  })
-  label.setStyle({
-    border: 'none',
-    background: 'transparent',
-    padding: '0'
-  })
-  mapInstance.addOverlay(label)
-
-  // 点击标记弹出详情
-  label.addEventListener('click', function () {
-    const infoContent = `<div style="color:#333;font-size:13px;padding:4px;">
-      <div style="font-weight:bold;margin-bottom:6px;font-size:14px;">${pump.name}</div>
-      <div>运行状态:<b style="color:${color}">${statusText[pump.statusClass]}</b></div>
-      <div>地址:${pump.address || '-'}</div>
-      <div>区域:${pump.district || '-'}</div>
-      <div>运营单位:${pump.operator || '-'}</div>
-      <div>经度:${pump.lng}</div>
-      <div>纬度:${pump.lat}</div>
-    </div>`
-    const infoWindow = new TdtMap.InfoWindow(infoContent, {
-      width: 220,
-      title: ''
-    })
-    mapInstance.openInfoWindow(infoWindow, bPoint)
-  })
+  const located = pumpList.value.filter(p => p.located && legendVisible[p.statusKey] !== false)
+  located.forEach(pump => addMapMarker(pump))
+  if (fit && located.length > 0) {
+    fitAllPumps()
+  }
 }
 
-// ==================== ECharts 图表 ====================
-let pieChart = null
-let lineChart = null
+// 图例开关变化后重绘标记,保留当前视野
+watch(legendVisible, () => renderMapMarkers({ fit: false }))
 
-function initPieChart() {
-  const dom = document.getElementById('bzyxqkPieChart')
-  if (!dom) return
-  pieChart = echarts.init(dom)
-  const total = pieData.value.reduce((s, d) => s + d.value, 0)
-  pieChart.setOption({
-    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-    legend: { show: false },
-    graphic: [{
-      type: 'text',
-      left: 'center',
-      top: '38%',
-      style: { text: '泵站总数', fill: '#a0cfff', fontSize: 12, textAlign: 'center' }
-    }, {
-      type: 'text',
-      left: 'center',
-      top: '52%',
-      style: { text: String(total), fill: '#fff', fontSize: 22, fontWeight: 'bold', textAlign: 'center' }
-    }],
-    series: [{
-      type: 'pie',
-      radius: ['45%', '70%'],
-      center: ['50%', '50%'],
-      label: { show: false },
-      labelLine: { show: false },
-      data: pieData.value.map(d => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })),
-      emphasis: { scale: true, scaleSize: 5 }
-    }]
-  })
+function fitAllPumps() {
+  if (!mapInstance || !TdtMapRef) return
+  const located = pumpList.value.filter(p => p.located)
+  if (!located.length) return
+  const points = located.map(p => new TdtMapRef.Point(p.lng, p.lat))
+  mapInstance.setViewport(points, { margins: [80, 80, 80, 80] })
 }
 
-async function initLineChart() {
-  const container = document.getElementById('bzyxqkLineChart')
-  if (!container) return
-
-  let chartInstance = echarts.getInstanceByDom(container)
-  if (!chartInstance) {
-    chartInstance = echarts.init(container)
-  }
-  lineChart = chartInstance
-
-  // 收集所有已关联泵站ID
-  const allIds = pumpList.value.map(p => p.id).filter(Boolean)
-  if (allIds.length === 0) {
-    chartInstance.setOption({ title: { text: '暂无泵站数据', left: 'center', top: 'center', textStyle: { color: '#8ec6f0', fontSize: 14 } }, xAxis: { show: false }, yAxis: { show: false }, series: [] })
-    return
-  }
-
-  try {
-    const res = await getPumpStationMonthlyStats(allIds.join(','), facilityStartDate.value)
-    const statsData = res.code !== undefined ? res.data : res
-    if (!Array.isArray(statsData) || statsData.length === 0) {
-      chartInstance.setOption({ title: { text: '暂无统计数据', left: 'center', top: 'center', textStyle: { color: '#8ec6f0', fontSize: 14 } }, xAxis: { show: false }, yAxis: { show: false }, series: [] })
-      return
-    }
+function locatePump(pump) {
+  if (!mapInstance || !TdtMapRef || !pump || !pump.located) return
+  const point = new TdtMapRef.Point(pump.lng, pump.lat)
+  mapInstance.centerAndZoom(point, 16)
+  openPumpInfo(pump, point)
+}
 
-    const months = statsData.map(d => d.month)
-    const flowData = statsData.map(d => d.designFlow || 0)
-    const headData = statsData.map(d => d.designHead || 0)
+function escapeHtml(value) {
+  return String(value ?? '')
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;')
+}
 
-    chartInstance.setOption({
-      tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-      legend: { data: ['设计流量', '设计扬程'], top: 5, textStyle: { color: '#8ec6f0', fontSize: 11 } },
-      grid: { top: 40, bottom: 30, left: 50, right: 50 },
-      xAxis: { type: 'category', data: months, axisLabel: { color: '#8ec6f0', fontSize: 11 }, axisLine: { lineStyle: { color: '#3a5f7a' } } },
-      yAxis: [
-        { type: 'value', name: 'm³/h', nameTextStyle: { color: '#8ec6f0', fontSize: 10 }, axisLabel: { color: '#8ec6f0', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e3a50' } } },
-        { type: 'value', name: 'm', nameTextStyle: { color: '#8ec6f0', fontSize: 10 }, axisLabel: { color: '#8ec6f0', fontSize: 10 }, splitLine: { show: false } }
-      ],
-      series: [
-        { name: '设计流量', type: 'bar', data: flowData, barWidth: '30%', itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] } },
-        { name: '设计扬程', type: 'bar', data: headData, yAxisIndex: 1, barWidth: '30%', itemStyle: { color: '#67c23a', borderRadius: [4, 4, 0, 0] } }
-      ]
-    })
-  } catch (e) {
-    console.error('加载泵站月度统计失败:', e)
-  }
+function addMapMarker(pump) {
+  const size = 30
+  // 库里坐标为 WGS84,天地图底图为 CGCS2000,两者厘米级一致,直通不偏移
+  const point = new TdtMapRef.Point(pump.lng, pump.lat)
+  // 图标与状态色同源(绿-正常 / 蓝-检修 / 灰-停用 / 红-故障)
+  const icon = new TdtMapRef.Icon(pump.icon, new TdtMapRef.Size(size, size), {
+    anchor: new TdtMapRef.Size(size / 2, size),
+    imageSize: new TdtMapRef.Size(size, size)
+  })
+  const marker = new TdtMapRef.Marker(point, { icon, enableDragging: false })
+  marker.addEventListener('click', () => openPumpInfo(pump, point))
+  mapInstance.addOverlay(marker)
+  mapOverlays.push(marker)
+
+  const label = new TdtMapRef.Label(
+    `<div class="pump-map-label" style="border-color:${pump.color};color:${pump.color}">${escapeHtml(pump.name)}</div>`,
+    // 标签固定宽度 84px,水平偏移取半宽才能居中压在图标上方
+    { position: point, offset: new TdtMapRef.Size(-42, -size - 12) }
+  )
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  label.addEventListener('click', () => openPumpInfo(pump, point))
+  mapInstance.addOverlay(label)
+  mapOverlays.push(label)
+}
+
+function openPumpInfo(pump, point) {
+  if (!mapInstance || !TdtMapRef) return
+  const row = (label, value) => `<div class="pump-info-row"><span>${label}</span><b>${escapeHtml(value)}</b></div>`
+  const content = `<div class="pump-info">
+    <div class="pump-info-title" style="border-color:${pump.color}">${escapeHtml(pump.name)}</div>
+    <div class="pump-info-row"><span>运行状态</span><b style="color:${pump.color}">${escapeHtml(pump.statusText)}</b></div>
+    ${row('设施编码', pump.code || '-')}
+    ${row('设施等级', pump.level || '-')}
+    ${row('管理单位', pump.managementUnit || '-')}
+    ${row('设施位置', pump.address || '-')}
+    ${row('设计流量', pump.designFlow === '-' ? '-' : `${pump.designFlow} m³/h`)}
+    ${row('设计扬程', pump.designHead === '-' ? '-' : `${pump.designHead} m`)}
+    ${row('建成日期', pump.buildDate || '-')}
+  </div>`
+  const infoWindow = new TdtMapRef.InfoWindow(content, { width: 240, title: '' })
+  mapInstance.openInfoWindow(infoWindow, point)
 }
 
 // ==================== 生命周期 ====================
-onMounted(() => {
-  updateTime()
-  timer = setInterval(updateTime, 1000)
-
-  // 先加载后端数据,完成后初始化地图和图表
-  loadData().finally(() => {
-    setTimeout(() => {
-      initMap()
-      initPieChart()
-      initLineChart()
-    }, 300)
-  })
+onMounted(async () => {
+  // 先取数据,再初始化地图,保证地图首帧就带标记
+  await loadData()
+  await initMap()
 })
 
 onUnmounted(() => {
-  if (timer) clearInterval(timer)
-  if (pieChart) pieChart.dispose()
-  if (lineChart) lineChart.dispose()
-  if (mapInstance) mapInstance = null
-})
-
-window.addEventListener('resize', () => {
-  if (pieChart) pieChart.resize()
-  if (lineChart) lineChart.resize()
+  clearMarkers()
+  try {
+    mapInstance?.destroy?.()
+  } catch (_) {
+    // ignore map cleanup errors
+  }
+  mapInstance = null
 })
 </script>
 
-<style scoped>
-/* ==================== 全局暗色大屏 ==================== */
-.bzyxqk-screen {
-  width: 100%;
-  height: calc(100vh - 84px);
-  background: #0a1929;
-  color: #fff;
-  font-family: 'Microsoft YaHei', sans-serif;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
+<style scoped lang="scss">
+.bzyxqk-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
 }
 
-/* ==================== 顶部标题栏 ==================== */
-.screen-header {
-  height: 64px;
-  background: linear-gradient(180deg, #0d2137 0%, #0a1929 100%);
-  border-bottom: 1px solid rgba(64, 158, 255, 0.3);
+/* ==================== 页头 ==================== */
+.page-head {
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   justify-content: space-between;
-  padding: 0 24px;
-  flex-shrink: 0;
-  position: relative;
-}
-
-.screen-header::after {
-  content: '';
-  position: absolute;
-  bottom: 0;
-  left: 10%;
-  right: 10%;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, #409eff, transparent);
+  gap: 16px;
+  margin-bottom: 16px;
 }
 
-.header-left {
-  flex: 1;
-  text-align: left;
-}
-
-.header-time {
-  font-size: 15px;
-  color: #a0cfff;
-  letter-spacing: 1px;
-}
-
-.header-center {
-  flex: 2;
-  text-align: center;
-}
-
-.header-title {
-  font-size: 26px;
-  font-weight: 700;
-  margin: 0;
-  letter-spacing: 4px;
-  background: linear-gradient(180deg, #fff 0%, #7ec8e3 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  text-shadow: none;
-}
-
-.header-right {
-  flex: 1;
-  text-align: right;
-}
-
-/* ==================== 主体三栏 ==================== */
-.screen-body {
-  flex: 1;
-  display: flex;
-  padding: 12px;
-  gap: 12px;
-  min-height: 0;
-}
-
-.panel-left,
-.panel-right {
-  width: 22%;
-  min-width: 260px;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  min-height: 0;
-}
-
-.panel-center {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  position: relative;
-}
-
-/* ==================== 面板盒子 ==================== */
-.panel-box {
-  background: rgba(6, 30, 65, 0.8);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 6px;
-  padding: 12px;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  overflow: hidden;
-}
-
-.panel-title {
-  font-size: 14px;
+.page-title {
+  font-size: 20px;
   font-weight: 600;
-  color: #7ec8e3;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  flex-shrink: 0;
+  color: #0a1628;
 }
 
-.title-icon {
-  color: #409eff;
-  font-size: 10px;
+.page-sub {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #97a3b7;
 }
 
-/* ==================== 泵站概况统计卡片 ==================== */
-.stats-cards {
+.page-head-actions {
   display: flex;
+  align-items: center;
   gap: 8px;
-  margin-bottom: 10px;
   flex-shrink: 0;
 }
 
-.stat-card {
-  flex: 1;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.15);
-  border-radius: 6px;
-  padding: 8px 4px;
-  text-align: center;
-}
-
-.stat-val {
-  font-size: 20px;
-  font-weight: 700;
-  color: #409eff;
+.locate-input {
+  width: 200px;
 }
 
-.stat-val.green {
-  color: #67c23a;
-}
-
-.stat-val.gray {
-  color: #909399;
+/* ==================== 地图 ==================== */
+.map-card {
+  border-radius: 8px;
+  overflow: hidden;
 }
 
-.stat-val.red {
-  color: #f56c6c;
+.map-wrap {
+  position: relative;
+  height: calc(100vh - 230px);
+  min-height: 480px;
 }
 
-.stat-label {
-  font-size: 10px;
-  color: #7ec8e3;
-  margin-top: 2px;
+.map-container {
+  width: 100%;
+  height: 100%;
 }
 
-/* ==================== 环形图 ==================== */
-.pie-chart-wrapper {
+/* ==================== 地图状态图例 ==================== */
+/* 放左下角:右下是天地图版权信息
+   天地图底层是 Leaflet 1.9,内部图层层级固定:瓦片 200 / 覆盖物 400 / 标记 600 / 弹窗 700 /
+   控件 800。自定义浮层若低于 400 会被瓦片整层盖住(缩放重绘、瓦片淡入后图例就"消失"了),
+   所以统一取 900,同时保持在 1000(控件容器)与 2000+(抽屉/弹窗)以下 */
+.map-legend {
+  position: absolute;
+  left: 14px;
+  bottom: 14px;
+  z-index: 900;
   display: flex;
-  align-items: center;
-  height: 140px;
-}
-
-.chart-area {
-  width: 55%;
-  height: 140px;
+  flex-direction: column;
+  gap: 6px;
+  min-width: 140px;
+  padding: 10px 12px;
+  background: rgba(255, 255, 255, 0.95);
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(31, 74, 121, 0.1);
 }
 
-.pie-legend {
-  width: 45%;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
+.legend-title {
+  font-size: 11px;
+  color: #97a3b7;
 }
 
-.legend-item {
+.legend-row {
   display: flex;
   align-items: center;
-  gap: 6px;
+  gap: 7px;
+  padding: 0;
+  background: none;
+  border: none;
   font-size: 12px;
+  color: #334155;
+  cursor: pointer;
 }
 
-.legend-dot {
-  width: 10px;
-  height: 10px;
-  border-radius: 2px;
-  flex-shrink: 0;
-}
-
-.legend-label {
-  color: #a0cfff;
-  flex: 1;
+.legend-row.is-off {
+  opacity: 0.35;
 }
 
-.legend-value {
-  color: #fff;
-  font-weight: 600;
+.legend-dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  flex-shrink: 0;
 }
 
-/* ==================== 泵站列表表格 ==================== */
-.pump-table-wrap {
+.legend-name {
   flex: 1;
-  overflow-y: auto;
-  min-height: 0;
-}
-
-.dark-table {
-  width: 100%;
-  border-collapse: collapse;
-  font-size: 12px;
-}
-
-.dark-table th {
-  background: rgba(20, 60, 110, 0.6);
-  color: #7ec8e3;
-  padding: 7px 6px;
-  text-align: center;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.2);
-  font-weight: 500;
+  text-align: left;
   white-space: nowrap;
 }
 
-.dark-table td {
-  padding: 6px;
-  text-align: center;
-  color: #d0e8ff;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.08);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 80px;
-}
-
-.dark-table tbody tr:nth-child(odd) {
-  background: rgba(10, 25, 50, 0.4);
-}
-
-.dark-table tbody tr:nth-child(even) {
-  background: rgba(20, 40, 70, 0.3);
-}
-
-.dark-table tbody tr:hover {
-  background: rgba(64, 158, 255, 0.1);
-  cursor: pointer;
-}
-
-.dark-table tbody tr.row-active {
-  background: rgba(64, 158, 255, 0.2) !important;
-  border-left: 2px solid #409eff;
+.legend-count {
+  color: #1f2d3d;
+  font-weight: 600;
 }
 
-.status-tag {
-  padding: 1px 6px;
-  border-radius: 3px;
+.legend-tip {
+  margin-top: 3px;
+  padding-top: 6px;
+  border-top: 1px solid #e8eef5;
   font-size: 11px;
-}
-
-.status-running {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
-}
-
-.status-stopped {
-  color: #909399;
-  background: rgba(144, 147, 153, 0.15);
-}
-
-.status-fault {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.status-repair {
+  line-height: 16px;
   color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
 }
 
-/* ==================== 中间地图 ==================== */
-.map-container {
-  flex: 1;
-  border-radius: 6px;
-  overflow: hidden;
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  min-height: 0;
-}
-
-.map-search {
-  position: absolute;
-  top: 12px;
-  left: 12px;
-  z-index: 10;
-  display: flex;
-  align-items: center;
-}
-
-.search-input {
-  background: rgba(6, 30, 65, 0.85);
-  border: 1px solid rgba(64, 158, 255, 0.3);
-  color: #a0cfff;
-  padding: 6px 30px 6px 12px;
-  border-radius: 4px;
-  font-size: 12px;
-  outline: none;
-  width: 160px;
-}
-
-.search-input::placeholder {
-  color: #4a6a8a;
-}
-
-.search-icon {
+.map-error {
   position: absolute;
-  right: 8px;
-  font-size: 14px;
-  cursor: pointer;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 901;
+  padding: 8px 14px;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #f56c6c;
+  background: rgba(255, 255, 255, 0.96);
+  border: 1px solid #fbc4c4;
+  box-shadow: 0 4px 12px rgba(31, 74, 121, 0.12);
 }
+</style>
 
-/* ==================== 深度曲线 ==================== */
-.time-btn-group {
-  display: flex;
-  gap: 0;
-  margin-bottom: 8px;
-  flex-shrink: 0;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 4px;
+<style>
+/* 地图标记标签与信息窗由天地图动态插入 DOM,scoped 样式命中不到,单独放全局块
+   白底标签与 GisMapCanvas 的 gis-marker-label-text 同款,文字色跟随泵站状态色 */
+.pump-map-label {
+  box-sizing: border-box;
+  display: block;
+  width: 84px;
+  padding: 2px 4px;
   overflow: hidden;
-}
-
-.time-btn {
-  flex: 1;
-  background: transparent;
-  border: none;
-  color: #a0cfff;
-  padding: 5px 8px;
   font-size: 11px;
-  cursor: pointer;
-  transition: all 0.3s;
-  border-right: 1px solid rgba(64, 158, 255, 0.1);
-}
-
-.time-btn:last-child {
-  border-right: none;
-}
-
-.time-btn.active {
-  background: rgba(64, 158, 255, 0.25);
-  color: #fff;
+  line-height: 16px;
   font-weight: 600;
-}
-
-.time-btn:hover {
-  background: rgba(64, 158, 255, 0.15);
-}
-
-.chart-area-lg {
-  width: 100%;
-  flex: 1;
-  min-height: 130px;
-}
-
-/* ==================== 视频监控 ==================== */
-.video-grid {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 8px;
-  flex-shrink: 0;
-}
-
-.video-item {
+  white-space: nowrap;
   text-align: center;
-}
-
-.video-placeholder {
-  background: rgba(10, 25, 50, 0.8);
-  border: 1px solid rgba(64, 158, 255, 0.15);
+  text-overflow: ellipsis;
+  background: rgba(255, 255, 255, 0.92);
+  border: 1px solid rgba(118, 151, 181, 0.32);
   border-radius: 4px;
-  height: 70px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.camera-icon {
-  font-size: 22px;
-  opacity: 0.5;
-}
-
-.video-name {
-  font-size: 10px;
-  color: #a0cfff;
-  margin-top: 4px;
-}
-
-/* ==================== 报警记录 ==================== */
-.alert-stats {
-  display: flex;
-  gap: 8px;
-  margin-bottom: 8px;
-  flex-shrink: 0;
+  box-shadow: 0 2px 6px rgba(31, 74, 121, 0.12);
+  cursor: pointer;
 }
 
-.alert-stat-card {
-  flex: 1;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.15);
-  border-radius: 6px;
-  padding: 8px;
-  text-align: center;
+.pump-info {
+  padding: 2px 4px;
+  font-size: 12px;
+  color: #303133;
 }
 
-.alert-stat-val {
-  font-size: 20px;
+.pump-info-title {
+  margin-bottom: 6px;
+  padding-left: 6px;
+  font-size: 13px;
   font-weight: 700;
-  color: #e6a23c;
-}
-
-.alert-stat-val.green {
-  color: #67c23a;
+  line-height: 18px;
+  color: #303133;
+  border-left: 3px solid #409eff;
 }
 
-.alert-stat-val.red {
-  color: #f56c6c;
-}
-
-.alert-stat-label {
-  font-size: 10px;
-  color: #7ec8e3;
-  margin-top: 2px;
-}
-
-.alarm-table-wrap {
-  flex: 1;
-  overflow-y: auto;
-  min-height: 0;
-}
-
-.handle-tag {
-  padding: 1px 6px;
-  border-radius: 3px;
-  font-size: 11px;
-}
-
-.handle-tag.handled {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
-}
-
-.handle-tag.unhandled {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-/* ==================== 滚动条样式 ==================== */
-::-webkit-scrollbar {
-  width: 4px;
-}
-
-::-webkit-scrollbar-track {
-  background: rgba(10, 25, 50, 0.3);
+.pump-info-row {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 10px;
+  line-height: 20px;
 }
 
-::-webkit-scrollbar-thumb {
-  background: rgba(64, 158, 255, 0.3);
-  border-radius: 2px;
+.pump-info-row > span {
+  flex-shrink: 0;
+  color: #909399;
 }
 
-::-webkit-scrollbar-thumb:hover {
-  background: rgba(64, 158, 255, 0.5);
+.pump-info-row > b {
+  overflow: hidden;
+  font-weight: 600;
+  text-align: right;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
 </style>

+ 812 - 1156
src/views/subSystem/drainage/jcsj/gwyxqk.vue

@@ -1,1396 +1,1052 @@
 <template>
-  <div class="gwyxqk-screen">
-    <!-- ==================== 顶部标题栏 ==================== -->
-    <div class="screen-header">
-      <div class="header-left">
-        <span class="header-time">{{ currentDateTime }}</span>
-      </div>
-      <div class="header-center">
-        <h1 class="header-title">管网运行情况GIS一张图</h1>
+  <div class="app-container gwyxqk-page">
+    <!-- 页头 -->
+    <div class="page-head">
+      <div class="page-head-text">
+        <div class="page-title">管网运行情况GIS一张图</div>
+        <div class="page-sub">
+          排水监测设备 {{ stats.total }} 台 · 在线 {{ stats.online }} · 离线 {{ stats.offline }} · 报警
+          {{ stats.alarm }}<span v-if="stats.noCoord"> · 未维护坐标 {{ stats.noCoord }} 台</span>
+        </div>
       </div>
-      <div class="header-right">
+      <div class="page-head-actions">
+        <el-input v-model="keyword" class="locate-input" placeholder="设备名称 / 编码" clearable
+          @keyup.enter="locateDevice" />
+        <el-button icon="Aim" @click="locateDevice">定位</el-button>
+        <el-button icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+        <el-button icon="FullScreen" @click="fitMap">全图</el-button>
       </div>
     </div>
 
-    <!-- ==================== 主体三栏布局 ==================== -->
-    <div class="screen-body">
-      <!-- ========== 左侧面板 ========== -->
-      <div class="panel-left">
-        <!-- 管网概况 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 管网概况
-          </div>
-          <div class="stats-cards">
-            <div class="stat-card">
-              <div class="stat-val">{{ overviewStats.total }}</div>
-              <div class="stat-label">监测点总数</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val green">{{ overviewStats.normal }}</div>
-              <div class="stat-label">正常运行</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val orange">{{ overviewStats.alarm }}</div>
-              <div class="stat-label">异常报警</div>
-            </div>
-            <div class="stat-card">
-              <div class="stat-val gray">{{ overviewStats.offline }}</div>
-              <div class="stat-label">离线设备</div>
-            </div>
-          </div>
-          <div class="pie-chart-wrapper">
-            <div id="gwyxqkPieChart" class="chart-area"></div>
-            <div class="pie-legend">
-              <div class="legend-item" v-for="(item, i) in pieData" :key="i">
-                <span class="legend-dot" :style="{ background: item.color }"></span>
-                <span class="legend-label">{{ item.name }}</span>
-                <span class="legend-value">{{ item.value }}</span>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 实时数据表 -->
-        <div class="panel-box" style="flex: 1;">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 实时数据
-            <span class="refresh-time">刷新: {{ refreshTime }}</span>
-          </div>
-          <div class="realtime-table-wrap">
-            <table class="dark-table">
-              <thead>
-                <tr>
-                  <th>监测点名称</th>
-                  <th>流量(m³/h)</th>
-                  <th>液位(m)</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="(row, i) in realtimeData" :key="i">
-                  <td :title="row.name">{{ row.name }}</td>
-                  <td>{{ row.flow }}</td>
-                  <td>{{ row.level }}</td>
-                  <td>
-                    <span :class="'status-tag status-' + row.statusClass">{{ row.status }}</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </div>
+    <!-- 运行概况 -->
+    <div class="kpi-row">
+      <div class="kpi-card">
+        <div class="kpi-label">设备总数(台)</div>
+        <div class="kpi-value">{{ stats.total }}</div>
       </div>
-
-      <!-- ========== 中间地图区域 ========== -->
-      <div class="panel-center">
-        <div class="map-search">
-          <input type="text" placeholder="输入监测点名称" class="search-input" />
-          <span class="search-icon">🔍</span>
-        </div>
-        <div id="gwyxqkMap" class="map-container"></div>
-        <div class="pipeline-map-status">
-          <span class="pipeline-status-dot"></span>
-          排水管线 {{ gisPipelines.length }} 段
-        </div>
-        <div v-if="pipelineMapError" class="pipeline-map-error">{{ pipelineMapError }}</div>
+      <div class="kpi-card">
+        <div class="kpi-label">在线设备(台)</div>
+        <div class="kpi-value is-online">{{ stats.online }}</div>
+      </div>
+      <div class="kpi-card">
+        <div class="kpi-label">离线设备(台)</div>
+        <div class="kpi-value is-offline">{{ stats.offline }}</div>
       </div>
+      <div class="kpi-card">
+        <div class="kpi-label">报警设备(台)</div>
+        <div class="kpi-value is-alarm">{{ stats.alarm }}</div>
+      </div>
+    </div>
 
-      <!-- ========== 右侧面板 ========== -->
-      <div class="panel-right">
-        <!-- 监测曲线 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 监测曲线
-            <select v-model="selectedCurveDevice" class="device-select" @change="onCurveDeviceChange">
-              <option value="">请选择设备</option>
-              <option v-for="d in linkedDeviceList" :key="d.equipmentId" :value="d.equipmentCode">{{ d.equipmentName }}</option>
-            </select>
-          </div>
-          <div class="curve-tabs">
-            <span
-              v-for="tab in curveTabs"
-              :key="tab.value"
-              :class="['curve-tab', { active: activeCurveTab === tab.value }]"
-              @click="activeCurveTab = tab.value"
-            >{{ tab.label }}</span>
-          </div>
-          <div id="gwyxqkLineChart" class="chart-area-sm"></div>
+    <!-- 一张图 -->
+    <el-card shadow="never" class="map-card" :body-style="{ padding: '0' }">
+      <div class="map-wrap" v-loading="loading">
+        <GisMapCanvas ref="mapRef" :points="mapPoints" :lines="mapLines" cluster-label="设备集合"
+          :marker-size="32" @ready="onMapReady" @error="onMapError" @point-click="openDevice"
+          @cluster-click="openCluster" @line-click="openLine" />
+
+        <div class="map-legend">
+          <div class="legend-title">点击切换显示</div>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.online }"
+            @click="legendVisible.online = !legendVisible.online">
+            <i class="legend-dot dot-online"></i>在线({{ stats.online - stats.alarmOnline }})
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.offline }"
+            @click="legendVisible.offline = !legendVisible.offline">
+            <i class="legend-dot dot-offline"></i>离线({{ stats.offline - stats.alarmOffline }})
+          </button>
+          <button type="button" class="legend-row" :class="{ 'is-off': !legendVisible.alarm }"
+            @click="legendVisible.alarm = !legendVisible.alarm">
+            <i class="legend-dot dot-alarm"></i>报警({{ stats.alarm }})
+          </button>
+          <div class="legend-split"></div>
+          <div class="legend-line"><i style="background: #2f9be8"></i>雨水管</div>
+          <div class="legend-line"><i style="background: #35be83"></i>污水管</div>
+          <div class="legend-line"><i style="background: #909399"></i>未识别</div>
         </div>
 
-        <!-- 流量统计 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 流量统计
-          </div>
-          <div id="gwyxqkBarChart" class="chart-area-sm"></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" :image-size="80"
+          description="当前没有可定位的排水设备,请先在设备台账维护经纬度" />
+      </div>
+    </el-card>
+
+    <!-- 设备详情:按设备类型差异化展示 -->
+    <el-drawer v-model="detailVisible" size="min(760px, 100vw)" append-to-body class="device-drawer">
+      <template #header>
+        <div class="drawer-title">
+          <span class="drawer-name">{{ profile.base?.equipmentName || '设备详情' }}</span>
+          <template v-if="profile.base">
+            <span class="tag" :class="'tag-' + deviceStatusKey(profile.base)">{{ deviceStatusText(profile.base) }}</span>
+            <span class="drawer-type">{{ profile.base.equipmentTypeName || '未分类' }}</span>
+          </template>
         </div>
-
-        <!-- 报警信息 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 报警信息
+      </template>
+
+      <div v-loading="detailLoading" class="drawer-body">
+        <template v-if="profile.base">
+          <!-- 基础信息 -->
+          <div class="dev-block">
+            <div class="dev-grid">
+              <div>设备编码:<b>{{ profile.base.equipmentCode || '—' }}</b></div>
+              <div>设备类型:<b>{{ profile.base.equipmentTypeName || '—' }}</b></div>
+              <div>所属片区:<b>{{ districtLabel(profile.base.district) }}</b></div>
+              <div>安装位置:<b>{{ profile.base.equipmentLocation || '—' }}</b></div>
+              <div>经度:<b>{{ profile.base.longitude ?? '—' }}</b></div>
+              <div>纬度:<b>{{ profile.base.latitude ?? '—' }}</b></div>
+              <div class="full">最后通信:<b>{{ profile.lastCommTime || '—' }}</b></div>
+            </div>
           </div>
-          <div class="alert-stats">
-            <div class="alert-stat-card">
-              <div class="alert-stat-val">{{ alarmStats.total }}</div>
-              <div class="alert-stat-label">报警总数</div>
+
+          <!-- 实时数据:列由设备类型对应的采集表决定(与远程测控同一取值口径) -->
+          <div class="dev-section">
+            <div class="sec-head">
+              <span class="sec-bar"></span>
+              <span class="sec-title">实时数据</span>
+              <span class="sec-hint">{{ metricHint }}</span>
             </div>
-            <div class="alert-stat-card">
-              <div class="alert-stat-val green">{{ alarmStats.handled }}</div>
-              <div class="alert-stat-label">已处理</div>
+            <div v-if="latestMetrics.length" class="metric-grid">
+              <div v-for="m in latestMetrics" :key="m.key" class="metric-card" :class="{ 'is-alarm': m.alarm }">
+                <div class="metric-label" :title="m.label">{{ m.label }}</div>
+                <div class="metric-value">
+                  {{ m.value }}<small v-if="m.unit">{{ m.unit }}</small>
+                </div>
+              </div>
             </div>
-            <div class="alert-stat-card">
-              <div class="alert-stat-val red">{{ alarmStats.unhandled }}</div>
-              <div class="alert-stat-label">未处理</div>
+            <div v-else class="sec-empty">
+              {{ recordRows.length ? '该设备上报的是音视频附件,明细见下方采集记录' : '该设备暂无采集数据(设备类型未配置原始采集表或尚未上报)' }}
             </div>
+            <div v-if="latestTime" class="metric-time">采集时间:{{ latestTime }}</div>
           </div>
-          <div class="alert-table-wrap">
-            <table class="dark-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>监测点</th>
-                  <th>时间</th>
-                  <th>类型</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="(row, i) in alarmList" :key="i">
-                  <td>{{ i + 1 }}</td>
-                  <td :title="row.point">{{ row.point }}</td>
-                  <td>{{ row.time }}</td>
-                  <td>{{ row.type }}</td>
-                  <td>
-                    <span :class="'status-tag status-' + row.handleClass">{{ row.handleStatus }}</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
+
+          <!-- 采集记录 -->
+          <div class="dev-section">
+            <div class="sec-head">
+              <span class="sec-bar"></span>
+              <span class="sec-title">采集记录</span>
+              <span class="sec-hint">最近 {{ recordRows.length }} 条</span>
+            </div>
+            <div v-if="recordColumns.length" class="record-wrap">
+              <el-table :data="pagedRecordRows" size="small" max-height="420">
+                <el-table-column v-for="c in recordColumns" :key="c.key" :label="columnLabel(c)"
+                  :min-width="c.key === 'create_time' ? 168 : (c.key === 'filepath' ? 170 : 104)">
+                  <template #default="{ row }">
+                    <span v-if="c.key === 'create_time'">{{ row.createTime || '—' }}</span>
+                    <span v-else-if="c.key === 'filepath'" class="record-audio-cell">
+                      <audio v-if="hasCellValue(row, c.key)" class="record-audio" controls preload="none"
+                        :src="buildMinioAttachmentUrl(valueOf(row, c.key))" @play="pauseOtherAudios"></audio>
+                      <span v-else>—</span>
+                    </span>
+                    <span v-else :class="{ 'cell-alarm': alarmOf(row, c.key) }">{{ valueOf(row, c.key) }}</span>
+                  </template>
+                </el-table-column>
+                <template #empty><div class="sec-empty">暂无采集记录</div></template>
+              </el-table>
+              <el-pagination v-if="recordRows.length > recordPageSize" class="record-pager" small
+                layout="prev, pager, next" :total="recordRows.length" :page-size="recordPageSize"
+                v-model:current-page="recordPage" />
+            </div>
+            <div v-else class="sec-empty">该设备类型未配置采集记录表</div>
           </div>
-        </div>
+        </template>
+        <el-empty v-else-if="!detailLoading" description="未找到设备档案" />
       </div>
-    </div>
+    </el-drawer>
+
+    <!-- 聚合点位 -->
+    <el-dialog v-model="clusterVisible" title="聚合设备" width="min(460px, 94vw)" append-to-body>
+      <div class="cluster-list">
+        <button v-for="item in clusterDevices" :key="item.id" type="button" class="cluster-item"
+          @click="pickClusterDevice(item)">
+          <span class="cluster-dot" :class="dotClassOfKey(item.statusKey)"></span>
+          <span class="cluster-name">{{ item.name }}</span>
+          <span class="cluster-status">{{ statusTextOfKey(item.statusKey) }}</span>
+        </button>
+      </div>
+      <div v-if="!clusterDevices.length" class="sec-empty">该聚合点没有设备</div>
+    </el-dialog>
+
+    <!-- 管线详情 -->
+    <el-dialog :model-value="Boolean(lineDetail)" title="排水管线" width="min(520px, 94vw)" append-to-body
+      @update:model-value="(v) => !v && (lineDetail = null)">
+      <el-descriptions v-if="lineDetail" :column="2" border size="small">
+        <el-descriptions-item label="管线名称" :span="2">{{ lineDetail.name || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="设施编号">{{ lineDetail.code || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="管线类别">
+          <span class="sewage-tag" :style="{ color: sewageColor(lineDetail.level) }">
+            {{ sewageLabel(lineDetail.level) }}
+          </span>
+        </el-descriptions-item>
+        <el-descriptions-item label="管径(mm)">{{ lineDetail.diameter ?? '—' }}</el-descriptions-item>
+        <el-descriptions-item label="长度(m)">{{ lineDetail.length ?? '—' }}</el-descriptions-item>
+        <el-descriptions-item label="材质">{{ lineDetail.material || '—' }}</el-descriptions-item>
+        <el-descriptions-item label="所在道路">{{ lineDetail.location || '—' }}</el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
   </div>
 </template>
 
 <script setup name="Gwyxqk">
-import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
-import * as echarts from 'echarts'
-import locationIcon from '@/assets/images/location.png'
-import { getEquipmentPage, getAlarmDataPage, getRadarDataByCode, getRadarDataHistory } from '@/api/drainage'
-import { getGisPipeLines } from '@/api/pipeNetwork/basic'
-import { convertCoord } from '@/utils/coordTransform'
-import { whenMapReady } from '@/utils/mapApi'
-
-// ==================== 实时时间 ====================
-const currentDateTime = ref('')
-let timer = null
-
-function updateTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  const weekDays = ['日', '一', '二', '三', '四', '五', '六']
-  currentDateTime.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())} 星期${weekDays[now.getDay()]}`
-}
-
-// ==================== 刷新时间 ====================
-const refreshTime = ref('')
-function updateRefreshTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  refreshTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-}
-
-// ==================== 管网概况(从API数据计算)====================
-const overviewStats = reactive({ total: 0, normal: 0, alarm: 0, offline: 0 })
-
-const pieData = ref([
-  { name: '正常运行', value: 0, color: '#67c23a' },
-  { name: '预警', value: 0, color: '#e6a23c' },
-  { name: '报警', value: 0, color: '#f56c6c' },
-  { name: '离线', value: 0, color: '#909399' }
-])
-
-// ==================== 实时数据(从radar_data API加载)====================
-const realtimeData = ref([])
-
-// ==================== 监测曲线 ====================
-const curveTabs = [
-  { label: '24小时', value: '24h' },
-  { label: '7天', value: '7d' },
-  { label: '15天', value: '15d' },
-  { label: '30天', value: '30d' }
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { useDict } from '@/utils/dict'
+import { getEquipmentGisPoints, getGisPipeLines } from '@/api/pipeNetwork/basic'
+import { getEquipmentProfile, pagePerceptionEquipment } from '@/api/pipeNetwork/perception'
+import { buildMinioAttachmentUrl } from '@/utils/minioAttachment'
+
+/** 排水设备根类型:equipment_type.parent_id = 2,后端按根节点动态展开整棵子树 */
+const ROOT_TYPE_ID = 2
+/** 排水管线 networkType:1-排水 */
+const DRAINAGE_NETWORK_TYPE = '1'
+
+const { drainage_district: districtDict } = useDict('drainage_district')
+
+const DISTRICT_FALLBACK = [
+  { value: '0', label: '城南' },
+  { value: '1', label: '城北' },
+  { value: '2', label: '太常' }
 ]
-const activeCurveTab = ref('24h')
 
-// ==================== 设备下拉与曲线选择 ====================
-const linkedDeviceList = ref([])
-const selectedCurveDevice = ref('')
+// 雨污配色与「排水管网管理」一致:0-污水(青) 1-雨水(蓝),未识别灰
+const SEWAGE_COLORS = { '0': '#35be83', '1': '#2f9be8' }
+const SEWAGE_LABELS = { '0': '污水', '1': '雨水' }
+
+// ==================== 数据源 ====================
+const loading = ref(false)
+const mapError = ref('')
+const mapRef = ref(null)
+/** 带经纬度的设备(地图打点数据源) */
+const devices = ref([])
+/** 台账总数(含未维护经纬度的设备),取不到时退化为地图点数 */
+const ledgerTotal = ref(0)
+const mapLines = ref([])
+
+const legendVisible = reactive({ online: true, offline: true, alarm: true })
+
+/** 三态口径:报警优先,其次在线,最后离线(与远程测控列表一致) */
+function deviceStatusKey(device = {}) {
+  if (Number(device.alarmStatus) === 1) return 'alarm'
+  return Number(device.onlineStatus) === 1 ? 'normal' : 'offline'
+}
+
+function deviceStatusText(device = {}) {
+  const key = deviceStatusKey(device)
+  return key === 'alarm' ? '报警' : key === 'normal' ? '在线' : '离线'
+}
+
+function statusTextOfKey(key) {
+  return key === 'alarm' ? '报警' : key === 'offline' ? '离线' : '在线'
+}
+
+/** 状态 → 圆点样式(图例与聚合列表共用) */
+function dotClassOfKey(key) {
+  if (key === 'alarm') return 'dot-alarm'
+  return key === 'offline' ? 'dot-offline' : 'dot-online'
+}
+
+const stats = computed(() => {
+  const total = Math.max(ledgerTotal.value, devices.value.length)
+  const online = devices.value.filter((d) => Number(d.onlineStatus) === 1).length
+  const alarm = devices.value.filter((d) => Number(d.alarmStatus) === 1).length
+  const alarmOnline = devices.value.filter(
+    (d) => Number(d.alarmStatus) === 1 && Number(d.onlineStatus) === 1
+  ).length
+  const alarmOffline = alarm - alarmOnline
+  return {
+    total,
+    online,
+    offline: devices.value.length - online,
+    alarm,
+    alarmOnline,
+    alarmOffline,
+    noCoord: Math.max(0, total - devices.value.length)
+  }
+})
 
-// ==================== 报警信息(从API加载)====================
-const alarmStats = reactive({ total: 0, handled: 0, unhandled: 0 })
-const alarmList = ref([])
+// LEGEND_KEY:状态 → 图例项,隐藏图例即隐藏对应点位
+const LEGEND_KEY = { normal: 'online', offline: 'offline', alarm: 'alarm' }
 
-// ==================== 地图数据(从API加载)====================
-const mapPoints = ref([])
-const gisPipelines = ref([])
-const pipelineMapError = ref('')
+const mapPoints = computed(() =>
+  devices.value
+    .map((device) => {
+      const coord = toCoord(device.longitude, device.latitude)
+      if (!coord) return null
+      return {
+        id: device.equipmentId,
+        name: device.equipmentName || device.equipmentCode || '排水设备',
+        ...coord,
+        statusKey: deviceStatusKey(device),
+        raw: device
+      }
+    })
+    .filter((point) => point && legendVisible[LEGEND_KEY[point.statusKey]])
+)
 
-// ==================== 设备状态映射 ====================
-function getEquipStatusInfo(equipment) {
-  if (equipment.onlineStatus === 0) return { status: '离线', color: '#909399' }
-  if (equipment.alarmStatus === 1) return { status: '报警', color: '#f56c6c' }
-  if (equipment.currentStatus === 3) return { status: '预警', color: '#e6a23c' }
-  return { status: '正常', color: '#67c23a' }
+function toCoord(lng, lat) {
+  const x = Number(lng)
+  const y = Number(lat)
+  if (!Number.isFinite(x) || !Number.isFinite(y) || (x === 0 && y === 0)) return null
+  return { lng: x, lat: y }
 }
 
-function getCurrentStatusText(val) {
-  const map = { 1: '在用', 2: '闲置', 3: '维修', 4: '报废', 5: '待入库' }
-  return map[val] || '未知'
+function sewageColor(level) {
+  const key = level === null || level === undefined ? '' : String(level)
+  return SEWAGE_COLORS[key] || '#909399'
 }
 
-// ==================== 分页响应兼容提取 ====================
-function extractPage(res) {
-  const page = res.code !== undefined ? res.data : res
-  return page.records || page.rows || []
+function sewageLabel(level) {
+  const key = level === null || level === undefined ? '' : String(level)
+  return SEWAGE_LABELS[key] || '未识别'
 }
 
-// ==================== 数据加载 ====================
-async function loadMapData() {
+// ==================== 加载 ====================
+async function loadData() {
+  loading.value = true
+  mapError.value = ''
   try {
-    await loadGisPipelineData()
-    // 1. 旧排水管网关联数据已下线,页面不再加载管网-设备关联;
-    //    地图管线由 loadGisPipelineData 从 /api/gis/pipe-lines 提供。
-
-    // 2. 获取所有排水设备
-    const eqRes = await getEquipmentPage(1, 999, { equipmentTypeId: 'drainage' })
-    const allEquipment = extractPage(eqRes)
-    console.log('[gwyxqk] 排水设备总数:', allEquipment.length)
-
-    // 3. 旧表下线后以全部排水设备作为统计、点位与曲线数据源
-    const linkedEquipment = allEquipment
-
-    // 构建关联设备列表(供下拉选择)
-    linkedDeviceList.value = linkedEquipment.map(eq => ({
-      equipmentId: eq.equipmentId || eq.id,
-      equipmentCode: eq.equipmentCode || '',
-      equipmentName: eq.equipmentName || '未命名设备'
-    }))
-    if (linkedDeviceList.value.length > 0 && !selectedCurveDevice.value) {
-      selectedCurveDevice.value = linkedDeviceList.value[0].equipmentCode
-    }
-
-    // 5. 获取报警数据
-    let alarmRecords = []
-    try {
-      const alarmRes = await getAlarmDataPage(1, 999, { deviceType: 'drainage' })
-      alarmRecords = extractPage(alarmRes)
-    } catch (alarmErr) {
-      console.warn('[gwyxqk] 报警数据加载失败:', alarmErr)
-    }
-
-    // 构建报警设备编码集合
-    const alarmDeviceCodes = new Set(alarmRecords.map(a => a.deviceCode))
-
-    // 6. 确定用于展示和统计的设备列表(优先关联设备,无关联则用全部设备)
-    const displayEquipment = linkedEquipment.length > 0 ? linkedEquipment : allEquipment
-
-    // 7. 统计概况
-    const totalCount = displayEquipment.length
-    const offlineCount = displayEquipment.filter(e => e.onlineStatus === 0).length
-    const alarmCount = displayEquipment.filter(e => e.onlineStatus === 1 && alarmDeviceCodes.has(e.equipmentCode)).length
-    const warningCount = displayEquipment.filter(e => e.onlineStatus === 1 && !alarmDeviceCodes.has(e.equipmentCode) && e.currentStatus === 3).length
-    const normalCount = totalCount - offlineCount - alarmCount - warningCount
-
-    overviewStats.total = totalCount
-    overviewStats.offline = offlineCount
-    overviewStats.alarm = alarmCount
-    overviewStats.normal = normalCount >= 0 ? normalCount : 0
-
-    pieData.value = [
-      { name: '正常运行', value: overviewStats.normal, color: '#67c23a' },
-      { name: '预警', value: warningCount, color: '#e6a23c' },
-      { name: '报警', value: overviewStats.alarm, color: '#f56c6c' },
-      { name: '离线', value: overviewStats.offline, color: '#909399' }
-    ]
-
-    // 8. 转换设备为地图点位(使用设备名称作为监测点名称)
-    mapPoints.value = displayEquipment
-      .filter(e => {
-        const lng = parseFloat(e.longitude)
-        const lat = parseFloat(e.latitude)
-        return lng && lat && !isNaN(lng) && !isNaN(lat)
-      })
-      .map(e => {
-        const statusInfo = getEquipStatusInfo(e)
-        return {
-          name: e.equipmentName || '未命名设备',
-          lng: parseFloat(e.longitude),
-          lat: parseFloat(e.latitude),
-          status: statusInfo.status,
-          color: statusInfo.color,
-          equipment: e
-        }
-      })
-    console.log('[gwyxqk] mapPoints生成:', mapPoints.value.length, '个点', mapPoints.value.map(p => p.name + '(' + p.lng + ',' + p.lat + ')'))
-
-    // 9. 加载实时数据
-    await loadRealtimeData(displayEquipment)
-
-    // 10. 加载报警信息
-    loadAlarmData(alarmRecords, displayEquipment)
-
-    console.log('[gwyxqk] 数据加载完成')
+    const [pointRes, ledgerRes] = await Promise.all([
+      getEquipmentGisPoints({ rootTypeId: ROOT_TYPE_ID }),
+      // 只要总数:台账里未维护坐标的设备也应计入「设备总数」
+      pagePerceptionEquipment({ pageNum: 1, pageSize: 1, rootTypeId: ROOT_TYPE_ID }).catch(() => null)
+    ])
+    const list = pointRes?.data ?? pointRes
+    devices.value = (Array.isArray(list) ? list : []).filter(
+      (item) => toCoord(item.longitude, item.latitude) !== null
+    )
+    const ledgerPage = ledgerRes?.data ?? ledgerRes
+    ledgerTotal.value = Number(ledgerPage?.total ?? devices.value.length) || devices.value.length
+    await loadPipeLines()
+    await nextTick()
+    mapRef.value?.fitBounds()
   } catch (error) {
-    console.error('加载地图数据失败:', error)
+    console.error('[gwyxqk] 设备点位加载失败', error)
+    mapError.value = '设备点位数据加载失败,请点击刷新重试'
+  } finally {
+    loading.value = false
   }
 }
 
-async function loadGisPipelineData() {
-  pipelineMapError.value = ''
+/** 排水管网:GeoJSON → GisMapCanvas 线;颜色仍按雨污类型(库中 status 全为 0,不按状态着色) */
+async function loadPipeLines() {
   try {
-    const response = await getGisPipeLines({ networkType: '1' })
+    const response = await getGisPipeLines({ networkType: DRAINAGE_NETWORK_TYPE })
     const features = Array.isArray(response?.data?.features) ? response.data.features : []
-    gisPipelines.value = features.map(feature => {
-      const properties = feature?.properties || {}
-      const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
-      const points = coordinates
-        .map(item => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
-        .filter(item => Number.isFinite(item.lng) && Number.isFinite(item.lat))
-      return {
-        id: properties.networkId,
-        code: properties.networkCode,
-        name: properties.networkName || properties.networkCode || '排水管网',
-        level: properties.networkLevel,
-        diameter: properties.diameter,
-        length: properties.length,
-        material: properties.material,
-        location: properties.location,
-        points
-      }
-    }).filter(line => line.points.length >= 2)
-    if (!gisPipelines.value.length) pipelineMapError.value = '暂无带完整起止坐标的排水管网数据'
+    mapLines.value = features
+      .map((feature) => {
+        const properties = feature?.properties || {}
+        const coordinates = feature?.geometry?.type === 'LineString' ? feature.geometry.coordinates : []
+        const points = coordinates
+          .map((item) => ({ lng: Number(item?.[0]), lat: Number(item?.[1]) }))
+          .filter((item) => Number.isFinite(item.lng) && Number.isFinite(item.lat))
+        return {
+          id: properties.networkId,
+          name: properties.networkName || properties.networkCode || '排水管线',
+          code: properties.networkCode,
+          level: properties.networkLevel,
+          diameter: properties.diameter,
+          length: properties.length,
+          material: properties.material,
+          location: properties.location,
+          points,
+          color: sewageColor(properties.networkLevel),
+          strokeWeight: 4,
+          strokeOpacity: 0.85
+        }
+      })
+      .filter((line) => line.points.length >= 2)
   } catch (error) {
-    gisPipelines.value = []
-    pipelineMapError.value = '排水管网数据加载失败,请检查后端服务。'
-    console.error('[gwyxqk] 排水管网 GIS 线路加载失败:', error)
+    console.warn('[gwyxqk] 排水管网 GIS 线路加载失败', error)
+    mapLines.value = []
   }
 }
 
-// ==================== 实时数据加载(/latest 返回单条 RadarData 对象)====================
-async function loadRealtimeData(equipmentList) {
-  console.log('[gwyxqk] loadRealtimeData 收到设备:', equipmentList.length, '条', equipmentList.map(e => e.equipmentCode))
-  const rows = []
-  for (const eq of equipmentList) {
-    const code = eq.equipmentCode
-    const name = eq.equipmentName || '未命名设备'
-    let flow = '--'
-    let level = '--'
-    let status = '离线'
-    let statusClass = 'offline'
-
-    if (eq.onlineStatus === 1) {
-      status = '正常'
-      statusClass = 'normal'
-    }
-
-    if (code) {
-      try {
-        const radarRes = await getRadarDataByCode(code)
-        console.log('[gwyxqk] 设备', code, 'radar /latest 原始响应:', radarRes)
-        // /latest 接口直接返回单条 RadarData 对象
-        const latest = radarRes.code !== undefined ? radarRes.data : radarRes
-        if (latest) {
-          // 兼容驼峰和下划线两种字段命名
-          const waterLevel = latest.waterLevel ?? latest.water_level
-          const instantFlow = latest.meter1InstantFlow ?? latest.meter1_instant_flow
-          const flowSpeed = latest.flowSpeed ?? latest.flow_speed
-          level = waterLevel != null ? Number(waterLevel).toFixed(2) : '--'
-          flow = instantFlow != null ? Number(instantFlow).toFixed(1) :
-                 flowSpeed != null ? Number(flowSpeed).toFixed(1) : '--'
-        }
-      } catch (err) {
-        console.warn('[gwyxqk] 设备', code, 'radar_data查询失败:', err)
-      }
-    }
-
-    rows.push({ name, flow, level, status, statusClass })
-  }
-  realtimeData.value = rows
-  console.log('[gwyxqk] 实时数据加载完成:', rows.length, '条')
+function onMapReady() {
+  nextTick(() => setTimeout(fitMap, 120))
 }
 
-// ==================== 报警数据加载 ====================
-function loadAlarmData(alarmRecords, equipmentList) {
-  // 构建设备编码→名称映射
-  const codeNameMap = {}
-  equipmentList.forEach(eq => {
-    if (eq.equipmentCode) codeNameMap[eq.equipmentCode] = eq.equipmentName || eq.equipmentCode
-  })
+function onMapError(message) {
+  mapError.value = message || '天地图加载失败,请检查网络或地图服务配置'
+}
 
-  // 过滤只保留关联设备的报警
-  const linkedCodes = new Set(Object.keys(codeNameMap))
-  const filtered = linkedCodes.size > 0
-    ? alarmRecords.filter(a => linkedCodes.has(a.deviceCode))
-    : alarmRecords
-
-  // 统计
-  const total = filtered.length
-  const handled = filtered.filter(a => a.handleStatus === 1 || a.handleStatus === '已处理' || a.status === 1).length
-  const unhandled = total - handled
-
-  alarmStats.total = total
-  alarmStats.handled = handled
-  alarmStats.unhandled = unhandled
-
-  // 列表
-  alarmList.value = filtered.slice(0, 50).map(a => {
-    const isHandled = a.handleStatus === 1 || a.handleStatus === '已处理' || a.status === 1
-    return {
-      point: codeNameMap[a.deviceCode] || a.deviceCode || '-',
-      time: a.createTime || '-',
-      type: a.warningType || a.alarmType || '-',
-      handleStatus: isHandled ? '已处理' : '未处理',
-      handleClass: isHandled ? 'normal' : 'alarm'
-    }
-  })
-  console.log('[gwyxqk] 报警数据加载完成:', total, '条')
+function fitMap() {
+  mapRef.value?.fitBounds()
 }
 
-// ==================== 地图初始化 ====================
-let mapInstance = null
+// ==================== 定位 ====================
+const keyword = ref('')
 
-function initMap() {
-  const container = document.getElementById('gwyxqkMap')
-  if (!container || !window.TdtMap) {
-    console.warn('TdtMap 未加载或容器不存在')
+function locateDevice() {
+  const kw = keyword.value.trim()
+  if (!kw) {
+    ElMessage.warning('请输入设备名称或设备编码')
     return
   }
-
-  try {
-    mapInstance = new TdtMap.Map('gwyxqkMap')
-    // 默认中心点,有数据时以第一个设备点为中心
-    let centerLng = 110.393
-    let centerLat = 28.452
-    if (mapPoints.value.length > 0) {
-      centerLng = mapPoints.value[0].lng
-      centerLat = mapPoints.value[0].lat
-    }
-    const [mapCenterLng, mapCenterLat] = convertCoord(centerLng, centerLat)
-    const centerPoint = new TdtMap.Point(mapCenterLng, mapCenterLat)
-    mapInstance.centerAndZoom(centerPoint, 14)
-    mapInstance.enableScrollWheelZoom(true)
-
-    const viewportPoints = drawGisPipelines()
-    // 叠加实时数据中的设备点位,并让视野覆盖管线和设备。
-    console.log('[gwyxqk] 初始化地图,排水管线数量:', gisPipelines.value.length, '设备点数量:', mapPoints.value.length)
-    mapPoints.value.forEach(point => {
-      addMapMarker(point)
-      const [lng, lat] = convertCoord(point.lng, point.lat)
-      viewportPoints.push(new TdtMap.Point(lng, lat))
-    })
-    if (viewportPoints.length > 0) mapInstance.setViewport(viewportPoints)
-  } catch (e) {
-    console.error('天地图初始化失败:', e)
+  const matched = devices.value.filter(
+    (d) => String(d.equipmentCode || '') === kw || String(d.equipmentName || '').includes(kw)
+  )
+  if (!matched.length) {
+    ElMessage.warning('未匹配到已维护坐标的设备')
+    return
   }
+  const first = matched[0]
+  const point = mapPoints.value.find((p) => p.id === first.equipmentId)
+  if (point) mapRef.value?.locatePoint(point)
+  if (matched.length > 1) ElMessage.info(`匹配到 ${matched.length} 台设备,已定位第一台`)
+  openDevice({ id: first.equipmentId, raw: first })
+}
+
+// ==================== 设备详情(取值口径同终端设备远程测控) ====================
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const profile = ref(emptyProfile())
+const recordPage = ref(1)
+const recordPageSize = 10
+
+const recordView = computed(() => profile.value.recordView || { columns: [], rows: [] })
+const recordColumns = computed(() => recordView.value.columns || [])
+const recordRows = computed(() => recordView.value.rows || [])
+const pagedRecordRows = computed(() => {
+  const start = (recordPage.value - 1) * recordPageSize
+  return recordRows.value.slice(start, start + recordPageSize)
+})
+
+const metricHint = computed(() => {
+  const columns = recordColumns.value.filter((c) => c.key !== 'create_time' && c.key !== 'filepath')
+  if (!columns.length) return ''
+  return columns.map((c) => c.label).filter(Boolean).join(' / ')
+})
+
+function emptyProfile() {
+  return { base: null, lastCommTime: '', recordView: { columns: [], rows: [] } }
 }
 
-function getGisPipelineColor(level) {
-  const text = String(level || '').toLowerCase()
-  if (text.includes('主') || text.includes('一级') || text.includes('main')) return '#00e5ff'
-  if (text.includes('支') || text.includes('二级') || text.includes('branch')) return '#409eff'
-  return '#67c23a'
+function columnMap(profileValue) {
+  const map = {}
+  ;(profileValue?.recordView?.columns || []).forEach((col) => {
+    map[col.key] = col
+  })
+  return map
 }
 
-function escapeMapText(value) {
-  return String(value ?? '')
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;')
-    .replace(/'/g, '&#39;')
+function openDevice(point) {
+  const device = point?.raw || point
+  const target = device?.equipmentId || point?.id || device?.id
+  if (!target) return
+  detailVisible.value = true
+  detailLoading.value = true
+  profile.value = emptyProfile()
+  recordPage.value = 1
+  loadProfile(target)
 }
 
-function drawGisPipelines() {
-  const viewportPoints = []
-  gisPipelines.value.forEach(line => {
-    const linePoints = line.points.map(point => {
-      const [lng, lat] = convertCoord(point.lng, point.lat)
-      return new TdtMap.Point(lng, lat)
+function loadProfile(equipmentId) {
+  return getEquipmentProfile(equipmentId)
+    .then((res) => {
+      profile.value = res?.data || emptyProfile()
     })
-    const polyline = new TdtMap.Polyline(linePoints, {
-      strokeColor: getGisPipelineColor(line.level),
-      strokeWeight: 5,
-      strokeOpacity: 0.9
+    .catch((error) => {
+      console.warn('[gwyxqk] 设备档案加载失败', equipmentId, error)
+      profile.value = emptyProfile()
+      ElMessage.error('设备档案加载失败,请稍后重试')
     })
-    polyline.addEventListener('click', event => {
-      event?.domEvent?.stopPropagation?.()
-      const content = `<div style="min-width:220px;color:#303133;line-height:1.8">
-        <div style="font-size:15px;font-weight:700;color:#1677ff;margin-bottom:6px">${escapeMapText(line.name)}</div>
-        <div>设施编号:${escapeMapText(line.code || '-')}</div>
-        <div>管网等级:${escapeMapText(line.level || '-')}</div>
-        <div>管径:${escapeMapText(line.diameter ?? '-')} mm</div>
-        <div>长度:${escapeMapText(line.length ?? '-')} m</div>
-        <div>材质:${escapeMapText(line.material || '-')}</div>
-        <div>位置:${escapeMapText(line.location || '-')}</div>
-      </div>`
-      mapInstance.openInfoWindow(new TdtMap.InfoWindow(content, { width: 280, enableMessage: false }), linePoints[0])
+    .finally(() => {
+      detailLoading.value = false
     })
-    mapInstance.addOverlay(polyline)
-    viewportPoints.push(...linePoints)
-  })
-  return viewportPoints
 }
 
-function addMapMarker(point) {
-  const color = point.color || '#409eff'
-  const [lng, lat] = convertCoord(point.lng, point.lat)
-  const bPoint = new TdtMap.Point(lng, lat)
-
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 6px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;">${point.name}</div>
-    <div style="color:#fff;font-size:10px;background:rgba(0,0,0,0.6);border-radius:3px;padding:1px 4px;margin-bottom:2px;">状态:${point.status}</div>
-    <div><img src="${locationIcon}" style="width:24px;height:24px;display:block;margin:0 auto;"/></div>
-  </div>`
+/**
+ * 最新采集值 → 指标卡片,列与单位完全由设备类型对应的原始采集表决定(与远程测控同一口径)。
+ * 宽表(layout=wide):首行即最新一条,value 为纯值、单位在列定义里,仅无纯值时用 display 兜底。
+ * 竖表(layout=long,水质一读数多因子):一行一个因子,行按时间倒序,按因子取首次出现即最新值。
+ */
+const latest = computed(() => {
+  const view = recordView.value
+  const rows = view.rows || []
+  if (!rows.length) return { metrics: [], time: '' }
+  const columns = columnMap(profile.value)
+
+  if (view.layout === 'long') {
+    // 竖表一行一个监测因子,按因子取各自最新一次值(行按时间倒序,首次出现即最新)
+    const factorMap = new Map()
+    rows.forEach((row) => {
+      ;(row.cells || []).forEach((cell) => {
+        if (cell.key === 'filepath') return
+        const { label, value } = splitLongDisplay(cell.display, columns[cell.key]?.label)
+        if (!factorMap.has(label)) {
+          factorMap.set(label, {
+            key: `${cell.key}-${label}`,
+            label,
+            value,
+            unit: '',
+            alarm: Boolean(cell.alarm)
+          })
+        }
+      })
+    })
+    return { metrics: [...factorMap.values()], time: rows[0]?.createTime || '' }
+  }
 
-  const label = new TdtMap.Label(html, {
-    position: bPoint,
-    offset: new TdtMap.Size(-30, -55)
-  })
-  label.setStyle({
-    border: 'none',
-    background: 'transparent',
-    padding: '0'
-  })
-  mapInstance.addOverlay(label)
-
-  // 点击标记弹出设备信息窗口
-  label.addEventListener('click', function () {
-    const eq = point.equipment || {}
-    const infoContent = `<div style="color:#333;font-size:13px;padding:4px;">
-      <div style="font-weight:bold;margin-bottom:6px;">${point.name}</div>
-      <div>设备编号:${eq.equipmentCode || '-'}</div>
-      <div>安装位置:${eq.equipmentLocation || '-'}</div>
-      <div>设备状态:<b style="color:${color}">${getCurrentStatusText(eq.currentStatus)}</b></div>
-      <div>报警状态:<b style="color:${eq.alarmStatus === 1 ? '#f56c6c' : '#67c23a'}">${eq.alarmStatus === 1 ? '报警' : '正常'}</b></div>
-      <div>在线状态:<b style="color:${eq.onlineStatus === 1 ? '#67c23a' : '#909399'}">${eq.onlineStatus === 1 ? '在线' : '离线'}</b></div>
-      <div>最近维护:${eq.lastMaintainDate || '-'}</div>
-      <div>下次维护:${eq.nextMaintainDate || '-'}</div>
-      <div>经度:${point.lng}</div>
-      <div>纬度:${point.lat}</div>
-    </div>`
-    const infoWindow = new TdtMap.InfoWindow(infoContent, {
-      width: 240,
-      title: ''
+  const first = rows[0]
+  const metrics = (first.cells || [])
+    .filter((cell) => cell.key !== 'filepath')
+    .map((cell) => {
+      const col = columns[cell.key] || {}
+      const hasValue = cell.value !== null && cell.value !== undefined && cell.value !== ''
+      return {
+        key: cell.key,
+        label: col.label || cell.key,
+        value: hasValue ? cell.value : cell.display || '—',
+        unit: hasValue ? col.unit || '' : '',
+        alarm: Boolean(cell.alarm)
+      }
     })
-    mapInstance.openInfoWindow(infoWindow, bPoint)
-  })
-}
+  return { metrics, time: first.createTime || '' }
+})
 
-// ==================== ECharts 图表 ====================
-let pieChart = null
-let lineChart = null
-let barChart = null
-
-function initPieChart() {
-  const dom = document.getElementById('gwyxqkPieChart')
-  if (!dom) return
-  pieChart = echarts.init(dom)
-  const total = pieData.value.reduce((s, d) => s + d.value, 0)
-  pieChart.setOption({
-    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-    legend: { show: false },
-    graphic: [{
-      type: 'text',
-      left: 'center',
-      top: '38%',
-      style: { text: '管网状态', fill: '#a0cfff', fontSize: 12, textAlign: 'center' }
-    }, {
-      type: 'text',
-      left: 'center',
-      top: '52%',
-      style: { text: String(total), fill: '#fff', fontSize: 22, fontWeight: 'bold', textAlign: 'center' }
-    }],
-    series: [{
-      type: 'pie',
-      radius: ['45%', '70%'],
-      center: ['50%', '50%'],
-      label: { show: false },
-      labelLine: { show: false },
-      data: pieData.value.map(d => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })),
-      emphasis: { scale: true, scaleSize: 5 }
-    }]
-  })
+/**
+ * 竖表展示串形如「氨氮:2mg/L」(名称 + 值含单位),指标卡片需要拆成标签与值。
+ * 无冒号时退回列标题,整串作为值。
+ */
+function splitLongDisplay(display, fallbackLabel) {
+  const text = String(display ?? '').trim()
+  const hit = text.match(/^([^::]+)[::]\s*(.*)$/)
+  if (!hit) return { label: fallbackLabel || '指标', value: text || '—' }
+  return { label: hit[1].trim(), value: hit[2].trim() || '—' }
 }
 
-// ==================== 监测曲线(/page 分页接口,按 startTime/endTime 查询)====================
-const curveChartData = ref({ labels: [], flowData: [], levelData: [] })
+const latestMetrics = computed(() => latest.value.metrics)
+const latestTime = computed(() => latest.value.time)
 
-// 时间格式化工具:yyyy-MM-dd HH:mm:ss
-function fmtDateTime(d) {
-  const pad = n => String(n).padStart(2, '0')
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+function columnLabel(col) {
+  return col.unit ? `${col.label}(${col.unit})` : col.label
 }
 
-async function loadCurveHistory() {
-  const code = selectedCurveDevice.value
-  if (!code) {
-    curveChartData.value = { labels: [], flowData: [], levelData: [] }
-    return
-  }
-
-  // 根据 activeCurveTab 计算时间范围
-  const now = new Date()
-  const start = new Date()
-  const tab = activeCurveTab.value
-  if (tab === '24h') start.setHours(start.getHours() - 24)
-  else if (tab === '7d') start.setDate(start.getDate() - 7)
-  else if (tab === '15d') start.setDate(start.getDate() - 15)
-  else start.setDate(start.getDate() - 30)
-
-  const startTime = fmtDateTime(start)
-  const endTime = fmtDateTime(now)
-
-  try {
-    const res = await getRadarDataHistory(code, startTime, endTime)
-    console.log('[gwyxqk] 曲线历史数据原始响应:', res)
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || pageData.rows || (Array.isArray(pageData) ? pageData : [])
-    console.log('[gwyxqk] 曲线历史数据:', code, startTime, '~', endTime, records.length, '条')
-
-    const labels = []
-    const flowData = []
-    const levelData = []
-
-    records.forEach(r => {
-      const t = r.createTime || r.create_time || ''
-      // 24h 显示 HH:mm,其他显示 MM-dd
-      if (tab === '24h') {
-        labels.push(t.substring(11, 16))
-      } else {
-        labels.push(t.substring(5, 10))
-      }
-      // 兼容驼峰和下划线字段命名
-      const instantFlow = r.meter1InstantFlow ?? r.meter1_instant_flow
-      const waterLevel = r.waterLevel ?? r.water_level
-      flowData.push(instantFlow != null ? Number(instantFlow) : 0)
-      levelData.push(waterLevel != null ? Number(waterLevel) : 0)
-    })
+function valueOf(row, key) {
+  const cell = (row.cells || []).find((item) => item.key === key)
+  if (!cell) return '—'
+  // 竖表展示串已含指标名与单位,宽表用不含单位的纯值
+  if (recordView.value.layout === 'long') return cell.display || '—'
+  return cell.value !== null && cell.value !== undefined && cell.value !== '' ? cell.value : cell.display || '—'
+}
 
-    // 如果数据为空,显示空图表提示
-    if (labels.length === 0) {
-      labels.push('暂无数据')
-      flowData.push(0)
-      levelData.push(0)
-    }
-
-    curveChartData.value = { labels, flowData, levelData }
-  } catch (err) {
-    console.warn('[gwyxqk] 曲线历史数据加载失败:', err)
-    curveChartData.value = { labels: [], flowData: [], levelData: [] }
-  }
+function hasCellValue(row, key) {
+  const value = valueOf(row, key)
+  return Boolean(value) && value !== '—'
 }
 
-function onCurveDeviceChange() {
-  console.log('[gwyxqk] 切换曲线设备:', selectedCurveDevice.value)
-  loadCurveHistory().then(() => {
-    initLineChart()
-  })
+function alarmOf(row, key) {
+  const cell = (row.cells || []).find((item) => item.key === key)
+  return cell ? Boolean(cell.alarm) : false
 }
 
-function initLineChart(tab, labels, flowData, levelData) {
-  const dom = document.getElementById('gwyxqkLineChart')
-  if (!dom) return
-  if (!lineChart) {
-    lineChart = echarts.init(dom)
-  }
-  const currentTab = tab || activeCurveTab.value
-  // 优先使用外部传入数据,否则从 curveChartData 取
-  const chartLabels = labels || curveChartData.value.labels
-  const chartFlow = flowData || curveChartData.value.flowData
-  const chartLevel = levelData || curveChartData.value.levelData
-  lineChart.setOption({
-    tooltip: { trigger: 'axis' },
-    legend: {
-      data: ['流量(m³/h)', '液位(m)'],
-      textStyle: { color: '#7ec8e3', fontSize: 10 },
-      top: 0,
-      right: 10,
-      itemWidth: 12,
-      itemHeight: 8
-    },
-    grid: { left: 40, right: 40, top: 30, bottom: 25 },
-    xAxis: {
-      type: 'category',
-      data: chartLabels,
-      axisLabel: { color: '#7ec8e3', fontSize: 9, interval: currentTab === '30d' ? 4 : currentTab === '15d' ? 2 : 'auto' },
-      axisLine: { lineStyle: { color: '#1a3a5c' } }
-    },
-    yAxis: [
-      {
-        type: 'value',
-        name: 'm³/h',
-        nameTextStyle: { color: '#7ec8e3', fontSize: 10 },
-        axisLabel: { color: '#7ec8e3', fontSize: 10 },
-        splitLine: { lineStyle: { color: '#1a3a5c' } },
-        axisLine: { show: false }
-      },
-      {
-        type: 'value',
-        name: 'm',
-        nameTextStyle: { color: '#e6a23c', fontSize: 10 },
-        axisLabel: { color: '#e6a23c', fontSize: 10 },
-        splitLine: { show: false },
-        axisLine: { show: false }
-      }
-    ],
-    series: [
-      {
-        name: '流量(m³/h)',
-        type: 'line',
-        data: chartFlow,
-        smooth: true,
-        symbol: 'none',
-        lineStyle: { color: '#409eff', width: 2 },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(64,158,255,0.3)' },
-            { offset: 1, color: 'rgba(64,158,255,0.02)' }
-          ])
-        }
-      },
-      {
-        name: '液位(m)',
-        type: 'line',
-        yAxisIndex: 1,
-        data: chartLevel,
-        smooth: true,
-        symbol: 'none',
-        lineStyle: { color: '#e6a23c', width: 2 },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(230,162,60,0.3)' },
-            { offset: 1, color: 'rgba(230,162,60,0.02)' }
-          ])
-        }
-      }
-    ]
+// 采集记录音频:一次只播一条
+function pauseOtherAudios(event) {
+  document.querySelectorAll('audio.record-audio').forEach((el) => {
+    if (el !== event.target) el.pause()
   })
 }
 
-// ==================== 流量统计(各设备最新流量柱状图,/latest 返回单条对象)====================
-async function initBarChart() {
-  const dom = document.getElementById('gwyxqkBarChart')
-  if (!dom) return
-  if (!barChart) {
-    barChart = echarts.init(dom)
-  }
-
-  const devices = linkedDeviceList.value
-  if (devices.length === 0) {
-    barChart.setOption({
-      xAxis: { type: 'category', data: [] },
-      yAxis: { type: 'value' },
-      series: [{ type: 'bar', data: [] }]
-    })
-    return
-  }
+// ==================== 聚合点位 ====================
+const clusterVisible = ref(false)
+const clusterDevices = ref([])
 
-  const names = []
-  const values = []
-  for (const d of devices) {
-    names.push(d.equipmentName || d.equipmentCode)
-    let flowVal = 0
-    if (d.equipmentCode) {
-      try {
-        const radarRes = await getRadarDataByCode(d.equipmentCode)
-        console.log('[gwyxqk] 柱状图设备', d.equipmentCode, '/latest 响应:', radarRes)
-        // /latest 直接返回单条对象
-        const latest = radarRes.code !== undefined ? radarRes.data : radarRes
-        if (latest) {
-          const instantFlow = latest.meter1InstantFlow ?? latest.meter1_instant_flow
-          const flowSpeed = latest.flowSpeed ?? latest.flow_speed
-          flowVal = instantFlow != null ? Number(instantFlow) :
-                    flowSpeed != null ? Number(flowSpeed) : 0
-        }
-      } catch (err) {
-        console.warn('[gwyxqk] 柱状图设备', d.equipmentCode, '数据加载失败:', err)
-      }
-    }
-    values.push(Math.round(flowVal * 10) / 10)
-  }
+function openCluster(cluster) {
+  // GisMapCanvas 抛出的是聚合对象 { points, count, bounds },兼容直接给数组的情况
+  const points = Array.isArray(cluster) ? cluster : cluster?.points || []
+  clusterDevices.value = points
+  clusterVisible.value = points.length > 0
+}
 
-  console.log('[gwyxqk] 流量统计柱状图数据:', names.length, '个设备')
-  barChart.setOption({
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-    grid: { left: 50, right: 10, top: 10, bottom: 30 },
-    xAxis: {
-      type: 'category',
-      data: names,
-      axisLabel: { color: '#7ec8e3', fontSize: 9, rotate: 20 },
-      axisLine: { lineStyle: { color: '#1a3a5c' } }
-    },
-    yAxis: {
-      type: 'value',
-      name: 'm³/h',
-      nameTextStyle: { color: '#7ec8e3', fontSize: 10 },
-      axisLabel: { color: '#7ec8e3', fontSize: 10 },
-      splitLine: { lineStyle: { color: '#1a3a5c' } },
-      axisLine: { show: false }
-    },
-    series: [{
-      type: 'bar',
-      data: values,
-      barWidth: 14,
-      itemStyle: {
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#409eff' },
-          { offset: 1, color: '#1a3a5c' }
-        ]),
-        borderRadius: [2, 2, 0, 0]
-      }
-    }]
-  })
+function pickClusterDevice(item) {
+  clusterVisible.value = false
+  openDevice(item)
 }
 
-// ==================== 监测曲线Tab切换 ====================
-watch(activeCurveTab, (val) => {
-  console.log('[gwyxqk] 切换曲线Tab:', val)
-  if (selectedCurveDevice.value) {
-    loadCurveHistory().then(() => {
-      initLineChart(val)
-    })
-  } else {
-    initLineChart(val)
-  }
-})
+// ==================== 管线详情 ====================
+const lineDetail = ref(null)
 
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  updateTime()
-  timer = setInterval(() => {
-    updateTime()
-    updateRefreshTime()
-  }, 1000)
-  updateRefreshTime()
-
-  // 先从API加载数据
-  await loadMapData()
-
-  // 延迟初始化确保 DOM 渲染完成
-  setTimeout(() => {
-    whenMapReady(initMap, () => { pipelineMapError.value = '天地图加载超时,请检查网络或地图配置。' })
-    initPieChart()
-    // 加载默认设备的曲线历史数据
-    if (selectedCurveDevice.value) {
-      loadCurveHistory().then(() => {
-        initLineChart()
-      })
-    } else {
-      initLineChart()
-    }
-    initBarChart()
-  }, 300)
-})
+function openLine(line) {
+  lineDetail.value = line || null
+}
+
+// ==================== 通用 ====================
+function districtLabel(value) {
+  if (value === null || value === undefined || value === '') return '—'
+  const hit = (districtDict?.value || []).find((item) => String(item.value) === String(value))
+  if (hit) return hit.label
+  const fallback = DISTRICT_FALLBACK.find((item) => item.value === String(value))
+  return fallback ? fallback.label : String(value)
+}
 
-onUnmounted(() => {
-  if (timer) clearInterval(timer)
-  if (pieChart) pieChart.dispose()
-  if (lineChart) lineChart.dispose()
-  if (barChart) barChart.dispose()
-  if (mapInstance) mapInstance = null
+onMounted(() => {
+  loadData()
 })
 
-// 监听窗口大小变化,重绘图表
-window.addEventListener('resize', () => {
-  if (pieChart) pieChart.resize()
-  if (lineChart) lineChart.resize()
-  if (barChart) barChart.resize()
+onBeforeUnmount(() => {
+  document.querySelectorAll('audio.record-audio').forEach((el) => el.pause())
 })
 </script>
 
-<style scoped>
-/* ==================== 全局暗色大屏 ==================== */
-.gwyxqk-screen {
-  width: 100%;
-  height: calc(100vh - 84px);
-  background: #0a1929;
-  color: #fff;
-  font-family: 'Microsoft YaHei', sans-serif;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
+<style scoped lang="scss">
+.gwyxqk-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
 }
 
-/* ==================== 顶部标题栏 ==================== */
-.screen-header {
-  height: 64px;
-  background: linear-gradient(180deg, #0d2137 0%, #0a1929 100%);
-  border-bottom: 1px solid rgba(64, 158, 255, 0.3);
+/* ==================== 页头 ==================== */
+.page-head {
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   justify-content: space-between;
-  padding: 0 24px;
-  flex-shrink: 0;
-  position: relative;
+  gap: 16px;
+  margin-bottom: 16px;
 }
 
-.screen-header::after {
-  content: '';
-  position: absolute;
-  bottom: 0;
-  left: 10%;
-  right: 10%;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, #409eff, transparent);
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
 }
 
-.header-left {
-  flex: 1;
-  text-align: left;
+.page-sub {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #97a3b7;
 }
 
-.header-time {
-  font-size: 15px;
-  color: #a0cfff;
-  letter-spacing: 1px;
+.page-head-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
 }
 
-.header-center {
-  flex: 2;
-  text-align: center;
+.locate-input {
+  width: 200px;
 }
 
-.header-title {
-  font-size: 26px;
-  font-weight: 700;
-  margin: 0;
-  letter-spacing: 4px;
-  background: linear-gradient(180deg, #fff 0%, #7ec8e3 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  text-shadow: none;
+/* ==================== 概况卡片 ==================== */
+.kpi-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
 }
 
-.header-right {
+.kpi-card {
   flex: 1;
-  text-align: right;
+  padding: 14px 16px;
+  background: #fff;
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
 }
 
-/* ==================== 主体三栏 ==================== */
-.screen-body {
-  flex: 1;
-  display: flex;
-  padding: 12px;
-  gap: 12px;
-  min-height: 0;
+.kpi-label {
+  font-size: 12px;
+  color: #97a3b7;
 }
 
-.panel-left,
-.panel-right {
-  width: 22%;
-  min-width: 260px;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  min-height: 0;
+.kpi-value {
+  margin-top: 6px;
+  font-size: 22px;
+  font-weight: 700;
+  color: #0a1628;
+  line-height: 1.2;
 }
 
-.panel-center {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  position: relative;
+.kpi-value.is-online {
+  color: #16a34a;
 }
 
-/* ==================== 面板盒子 ==================== */
-.panel-box {
-  background: rgba(6, 30, 65, 0.8);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 6px;
-  padding: 12px;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
+.kpi-value.is-offline {
+  color: #64748b;
+}
+
+.kpi-value.is-alarm {
+  color: #ef4444;
+}
+
+/* ==================== 地图 ==================== */
+.map-card {
+  border-radius: 8px;
   overflow: hidden;
 }
 
-.panel-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #7ec8e3;
-  margin-bottom: 10px;
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  flex-shrink: 0;
+.map-wrap {
+  position: relative;
+  height: calc(100vh - 320px);
+  min-height: 460px;
 }
 
-.title-icon {
-  color: #409eff;
-  font-size: 10px;
+/* 天地图底层是 Leaflet 1.9,内部图层层级固定:瓦片 200 / 覆盖物 400 / 标记 600 / 弹窗 700 /
+   控件 800。自定义浮层若低于 400 会被瓦片整层盖住(缩放重绘、瓦片淡入后图例就"消失"了),
+   所以统一取 850+,同时保持在 1000(控件容器)与 2000+(抽屉/弹窗)以下,
+   避免浮层跑到缩放按钮或详情抽屉上面 */
+.map-legend {
+  position: absolute;
+  left: 14px;
+  bottom: 14px;
+  z-index: 900;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 10px 12px;
+  background: rgba(255, 255, 255, 0.95);
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
+  box-shadow: 0 4px 12px rgba(31, 74, 121, 0.1);
 }
 
-.refresh-time {
-  margin-left: auto;
+.legend-title {
   font-size: 11px;
-  color: #607080;
-  font-weight: 400;
+  color: #97a3b7;
 }
 
-/* ==================== 管网概况统计卡片 ==================== */
-.stats-cards {
+.legend-row {
   display: flex;
-  gap: 8px;
-  margin-bottom: 10px;
-  flex-shrink: 0;
+  align-items: center;
+  gap: 7px;
+  padding: 0;
+  font-size: 12px;
+  color: #334155;
+  background: none;
+  border: none;
+  cursor: pointer;
 }
 
-.stat-card {
-  flex: 1;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.15);
-  border-radius: 6px;
-  padding: 8px 4px;
-  text-align: center;
+.legend-row.is-off {
+  opacity: 0.35;
 }
 
-.stat-val {
-  font-size: 20px;
-  font-weight: 700;
-  color: #409eff;
+.legend-dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  display: inline-block;
 }
 
-.stat-val.green {
-  color: #67c23a;
+.dot-online {
+  background: #2ecc71;
 }
 
-.stat-val.orange {
-  color: #e6a23c;
+.dot-offline {
+  background: #95a5a6;
 }
 
-.stat-val.gray {
-  color: #909399;
+.dot-alarm {
+  background: #e74c3c;
 }
 
-.stat-label {
-  font-size: 10px;
-  color: #7ec8e3;
-  margin-top: 2px;
+.legend-split {
+  height: 1px;
+  margin: 3px 0;
+  background: #e8eef5;
 }
 
-/* ==================== 环形图 ==================== */
-.pie-chart-wrapper {
+.legend-line {
   display: flex;
   align-items: center;
-  height: 140px;
+  gap: 7px;
+  font-size: 12px;
+  color: #5e6d82;
 }
 
-.chart-area {
-  width: 55%;
-  height: 140px;
+.legend-line i {
+  width: 14px;
+  height: 3px;
+  border-radius: 2px;
 }
 
-.pie-legend {
-  width: 45%;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
+.map-error {
+  position: absolute;
+  top: 14px;
+  left: 14px;
+  z-index: 950;
+  width: 320px;
 }
 
-.legend-item {
+.map-empty {
+  position: absolute;
+  inset: 0;
+  z-index: 850;
   display: flex;
   align-items: center;
-  gap: 6px;
-  font-size: 12px;
-}
-
-.legend-dot {
-  width: 10px;
-  height: 10px;
-  border-radius: 2px;
-  flex-shrink: 0;
+  justify-content: center;
+  background: rgba(249, 249, 249, 0.85);
+  pointer-events: none;
 }
 
-.legend-label {
-  color: #a0cfff;
-  flex: 1;
+/* ==================== 设备详情抽屉 ==================== */
+.drawer-title {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
 }
 
-.legend-value {
-  color: #fff;
+.drawer-name {
+  font-size: 16px;
   font-weight: 600;
+  color: #0a1628;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-/* ==================== 实时数据表格 ==================== */
-.realtime-table-wrap {
-  flex: 1;
-  overflow-y: auto;
-  min-height: 0;
-}
-
-.dark-table {
-  width: 100%;
-  border-collapse: collapse;
+.drawer-type {
   font-size: 12px;
+  color: #97a3b7;
 }
 
-.dark-table th {
-  background: rgba(20, 60, 110, 0.6);
-  color: #7ec8e3;
-  padding: 7px 6px;
-  text-align: center;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.2);
+.tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 2px 9px;
+  border-radius: 20px;
+  font-size: 12px;
   font-weight: 500;
   white-space: nowrap;
 }
 
-.dark-table td {
-  padding: 6px;
-  text-align: center;
-  color: #d0e8ff;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.08);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 80px;
+.tag-normal {
+  background: #dcfce7;
+  color: #16a34a;
 }
 
-.dark-table tbody tr:nth-child(odd) {
-  background: rgba(10, 25, 50, 0.4);
+.tag-offline {
+  background: #f1f5f9;
+  color: #64748b;
 }
 
-.dark-table tbody tr:nth-child(even) {
-  background: rgba(20, 40, 70, 0.3);
+.tag-alarm {
+  background: #fee2e2;
+  color: #ef4444;
 }
 
-.dark-table tbody tr:hover {
-  background: rgba(64, 158, 255, 0.1);
+.dev-block {
+  padding: 14px;
+  margin-bottom: 16px;
+  background: #f8fafc;
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
 }
 
-.status-tag {
-  padding: 1px 6px;
-  border-radius: 3px;
-  font-size: 11px;
+.dev-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 6px 14px;
+  font-size: 12px;
+  color: #5e6d82;
 }
 
-.status-normal {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
+.dev-grid .full {
+  grid-column: 1 / -1;
 }
 
-.status-warning {
-  color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
+.dev-grid b {
+  color: #0a1628;
+  font-weight: 500;
 }
 
-.status-alarm {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
+.dev-section {
+  margin-bottom: 18px;
 }
 
-.status-offline {
-  color: #909399;
-  background: rgba(144, 147, 153, 0.15);
+.sec-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
 }
 
-/* ==================== 中间地图 ==================== */
-.map-container {
+.sec-bar {
+  width: 3px;
+  height: 14px;
+  border-radius: 2px;
+  background: #1677ff;
+}
+
+.sec-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #0a1628;
+}
+
+.sec-hint {
   flex: 1;
-  border-radius: 6px;
+  font-size: 12px;
+  color: #97a3b7;
   overflow: hidden;
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  min-height: 0;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.map-search {
-  position: absolute;
-  top: 12px;
-  left: 12px;
-  z-index: 10;
-  display: flex;
-  align-items: center;
+.sec-empty {
+  padding: 22px 0;
+  color: #9aa7b8;
+  font-size: 12px;
+  text-align: center;
 }
 
-.pipeline-map-status {
-  position: absolute;
-  top: 48px;
-  left: 12px;
-  z-index: 5;
-  display: flex;
-  align-items: center;
-  gap: 7px;
-  padding: 7px 12px;
-  color: #d9f4ff;
-  background: rgba(6, 30, 65, 0.86);
-  border: 1px solid rgba(0, 229, 255, 0.42);
-  border-radius: 4px;
-  font-size: 12px;
-  pointer-events: none;
+/* 指标卡片:列随设备类型变化 */
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
+  gap: 10px;
 }
 
-.pipeline-status-dot {
-  width: 9px;
-  height: 9px;
-  background: #00e5ff;
-  border-radius: 50%;
-  box-shadow: 0 0 8px rgba(0, 229, 255, 0.9);
+.metric-card {
+  padding: 10px 12px;
+  background: #fff;
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
 }
 
-.pipeline-map-error {
-  position: absolute;
-  left: 50%;
-  bottom: 18px;
-  z-index: 5;
-  max-width: 70%;
-  padding: 8px 14px;
-  color: #ffd591;
-  background: rgba(80, 45, 0, 0.88);
-  border: 1px solid rgba(230, 162, 60, 0.55);
-  border-radius: 4px;
-  font-size: 12px;
-  text-align: center;
-  transform: translateX(-50%);
-  pointer-events: none;
+.metric-card.is-alarm {
+  background: #fff5f5;
+  border-color: #fecaca;
 }
 
-.search-input {
-  background: rgba(6, 30, 65, 0.85);
-  border: 1px solid rgba(64, 158, 255, 0.3);
-  color: #a0cfff;
-  padding: 6px 30px 6px 12px;
-  border-radius: 4px;
+.metric-label {
   font-size: 12px;
-  outline: none;
-  width: 160px;
+  color: #97a3b7;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.search-input::placeholder {
-  color: #4a6a8a;
+.metric-value {
+  margin-top: 5px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #0a1628;
+  line-height: 1.3;
+  word-break: break-all;
 }
 
-.search-icon {
-  position: absolute;
-  right: 8px;
-  font-size: 14px;
-  cursor: pointer;
+.metric-card.is-alarm .metric-value {
+  color: #ef4444;
 }
 
-/* ==================== 监测曲线Tab ==================== */
-.curve-tabs {
-  display: flex;
-  gap: 0;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 4px;
-  margin-bottom: 8px;
-  flex-shrink: 0;
-  overflow: hidden;
+.metric-value small {
+  margin-left: 2px;
+  font-size: 12px;
+  font-weight: 400;
+  color: #97a3b7;
 }
 
-.curve-tab {
-  flex: 1;
-  text-align: center;
-  padding: 5px 4px;
-  font-size: 11px;
-  color: #a0cfff;
-  cursor: pointer;
-  transition: all 0.3s;
-  border-right: 1px solid rgba(64, 158, 255, 0.1);
+.metric-time {
+  margin-top: 8px;
+  font-size: 12px;
+  color: #97a3b7;
 }
 
-.curve-tab:last-child {
-  border-right: none;
+/* 采集记录 */
+.record-wrap :deep(.el-table) {
+  font-size: 12px;
+  border: 1px solid #e3e9f2;
+  border-radius: 8px;
 }
 
-.curve-tab.active {
-  background: rgba(64, 158, 255, 0.25);
-  color: #fff;
+.record-wrap :deep(.el-table th.el-table__cell) {
+  background: #f7f9fc;
+  color: #5e6d82;
   font-weight: 600;
 }
 
-.curve-tab:hover {
-  background: rgba(64, 158, 255, 0.15);
+.record-wrap :deep(.el-table td.el-table__cell .cell) {
+  white-space: nowrap;
 }
 
-.device-select {
-  margin-left: 10px;
-  background: #0d2137;
-  color: #7ec8e3;
-  border: 1px solid #1a3a5c;
-  border-radius: 3px;
-  padding: 2px 6px;
-  font-size: 11px;
-  outline: none;
+.cell-alarm {
+  color: #ef4444;
+  font-weight: 600;
 }
 
-.chart-area-sm {
-  width: 100%;
-  height: 150px;
-  flex-shrink: 0;
+.record-audio-cell {
+  display: flex;
+  align-items: center;
+  overflow: hidden;
 }
 
-/* ==================== 报警信息 ==================== */
-.alert-stats {
-  display: flex;
-  gap: 8px;
-  margin-bottom: 8px;
-  flex-shrink: 0;
+.record-audio {
+  width: 150px;
+  max-width: 100%;
+  height: 26px;
+  vertical-align: middle;
 }
 
-.alert-stat-card {
-  flex: 1;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.15);
-  border-radius: 6px;
-  padding: 8px;
-  text-align: center;
+.record-pager {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 10px;
 }
 
-.alert-stat-val {
-  font-size: 20px;
-  font-weight: 700;
-  color: #e6a23c;
+/* 聚合点位 */
+.cluster-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  max-height: 420px;
+  overflow: auto;
 }
 
-.alert-stat-val.green {
-  color: #67c23a;
+.cluster-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 9px 12px;
+  background: #f8fafc;
+  border: 1px solid #e3e9f2;
+  border-radius: 6px;
+  font-size: 13px;
+  color: #0a1628;
+  text-align: left;
+  cursor: pointer;
+  transition: 0.15s;
 }
 
-.alert-stat-val.red {
-  color: #f56c6c;
+.cluster-item:hover {
+  background: #eef5ff;
+  border-color: #bcd8ff;
 }
 
-.alert-stat-label {
-  font-size: 10px;
-  color: #7ec8e3;
-  margin-top: 2px;
+.cluster-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex-shrink: 0;
 }
 
-.alert-table-wrap {
+.cluster-name {
   flex: 1;
-  overflow-y: auto;
-  min-height: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-/* ==================== 滚动条样式 ==================== */
-::-webkit-scrollbar {
-  width: 4px;
+.cluster-status {
+  font-size: 12px;
+  color: #97a3b7;
+  flex-shrink: 0;
 }
 
-::-webkit-scrollbar-track {
-  background: rgba(10, 25, 50, 0.3);
+.sewage-tag {
+  font-weight: 600;
 }
 
-::-webkit-scrollbar-thumb {
-  background: rgba(64, 158, 255, 0.3);
-  border-radius: 2px;
+:deep(.el-drawer__header) {
+  margin-bottom: 0;
+  padding: 12px 20px;
+  border-bottom: 1px solid #e3e9f2;
+  color: #0a1628;
 }
 
-::-webkit-scrollbar-thumb:hover {
-  background: rgba(64, 158, 255, 0.5);
+:deep(.el-drawer__body) {
+  padding: 16px 20px 20px;
 }
 </style>

+ 1131 - 729
src/views/subSystem/drainage/jcsj/jsqc.vue

@@ -1,943 +1,1345 @@
 <template>
-  <div class="jsqc-screen">
-    <!-- ==================== 顶部标题栏 ==================== -->
-    <div class="screen-header">
-      <div class="header-left">
-        <span class="header-time">{{ currentDateTime }}</span>
-      </div>
-      <div class="header-center">
-        <h1 class="header-title">排水管网安全运行监测系统</h1>
-      </div>
-      <div class="header-right">
-      </div>
-    </div>
-
-    <!-- ==================== 主体三栏布局 ==================== -->
-    <div class="screen-body">
-      <!-- ========== 左侧面板 ========== -->
-      <div class="panel-left">
-        <!-- 监测点概况 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 监测点概况
+  <div class="app-container waterlogging-one-map">
+    <!-- ==================== 一张图:只有积水点 ==================== -->
+    <el-card shadow="never" class="map-card" :body-style="{ padding: '12px' }">
+      <template #header>
+        <div class="card-header">
+          <div class="card-title">
+            <span>积水点GIS一张图</span>
+            <span class="card-sub">
+              共 <b>{{ mapPoints.length }}</b> 个点位 · 点击地图标记查看该点位关联设备
+            </span>
           </div>
-          <div class="pie-chart-wrapper">
-            <div id="jsqcPieChart" class="chart-area"></div>
-            <div class="pie-legend">
-              <div class="legend-item" v-for="(item, i) in pieData" :key="i">
-                <span class="legend-dot" :style="{ background: item.color }"></span>
-                <span class="legend-label">{{ item.name }}</span>
-                <span class="legend-value">{{ item.value }}</span>
-              </div>
+          <div class="card-actions">
+            <el-button size="small" icon="Refresh" :loading="loading" @click="loadPoints">刷新</el-button>
+            <el-button size="small" icon="Aim" @click="fitMap">全图</el-button>
+          </div>
+        </div>
+      </template>
+
+      <div class="map-wrap" v-loading="loading">
+        <GisMapCanvas ref="mapRef" :points="mapPoints" :lines="[]" :enable-clustering="true"
+          cluster-label="积水点" @ready="onMapReady" @point-click="handlePointClick" />
+        <div v-if="!loading && !mapPoints.length" class="map-empty">
+          暂无可展示的积水点(需先录入经纬度)
+        </div>
+        <div class="map-legend">
+          <span class="legend-item"><i class="legend-dot"></i>易积水点</span>
+          <span class="legend-tip">点击标记查看关联设备与监测数据</span>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- ==================== 点位详情:关联设备 + 设备数据 ==================== -->
+    <el-drawer v-model="detailVisible" size="1080px" :with-header="false" append-to-body
+      class="point-drawer" @closed="onDrawerClosed">
+      <div v-if="currentPoint" class="point-detail">
+        <div class="detail-hero">
+          <div class="hero-main">
+            <div class="hero-name" :title="currentPoint.pointName">
+              {{ currentPoint.pointName || '未命名积水点' }}
+            </div>
+            <div class="hero-sub">
+              <el-tag v-if="hasValue(currentPoint.riskLevel)" size="small"
+                :type="riskTagType(currentPoint.riskLevel)">
+                {{ riskLabel(currentPoint.riskLevel) }}
+              </el-tag>
+              <span class="hero-sub-item">{{ districtLabel(currentPoint.district) }}</span>
+              <span class="hero-sub-item">{{ currentPoint.location || '未填写位置描述' }}</span>
             </div>
           </div>
+          <el-button text circle icon="Close" class="hero-close" @click="detailVisible = false" />
         </div>
 
-        <!-- 实时数据 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 实时数据
-            <span class="refresh-time">刷新: {{ refreshTime }}</span>
+        <!-- 关联设备 -->
+        <div class="detail-section">
+          <div class="section-head">
+            <span class="section-bar"></span>
+            <span class="section-title">关联设备</span>
+            <span class="section-hint">{{ pointDevices.length ? `共 ${pointDevices.length} 台,点击设备查看对应数据` : '' }}</span>
+            <div class="section-tools">
+              <el-button link type="primary" icon="Refresh" :loading="deviceLoading" @click="reloadDevices">
+                刷新
+              </el-button>
+            </div>
           </div>
-          <div class="realtime-table-wrap">
-            <table class="dark-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>监测点</th>
-                  <th>时间</th>
-                  <th>积水量(cm)</th>
-                  <th>级别</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="(row, i) in realtimeData" :key="i">
-                  <td>{{ i + 1 }}</td>
-                  <td :title="row.name">{{ row.name }}</td>
-                  <td>{{ row.time }}</td>
-                  <td>{{ row.depth }}</td>
-                  <td>
-                    <span :class="'level-tag level-' + row.levelClass">{{ row.level }}</span>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
+
+          <div v-if="pointDevices.length" class="device-grid">
+            <div v-for="item in pointDevices" :key="item.equipmentId" class="device-card"
+              :class="{ 'is-active': selectedDeviceId === String(item.equipmentId) }" @click="selectDevice(item)">
+              <div class="device-card-head">
+                <span class="device-kind" :class="'kind-' + deviceKind(item)">{{ kindLabel(item) }}</span>
+                <span v-if="item.onlineStatus === 1" class="device-online"><i></i>在线</span>
+                <span v-else-if="item.onlineStatus === 0" class="device-offline"><i></i>离线</span>
+              </div>
+              <div class="device-name" :title="item.equipmentName || item.equipmentCode">
+                {{ item.equipmentName || item.equipmentCode }}
+              </div>
+              <div class="device-meta">{{ deviceTypeLabel(item) }}</div>
+              <div class="device-extra">
+                <template v-if="deviceKind(item) === 'level'">
+                  <span class="device-value">{{ levelText(item) }}</span>
+                  <span class="device-time">{{ levelTimeText(item) }}</span>
+                </template>
+                <template v-else-if="deviceKind(item) === 'video'">点击播放实时视频</template>
+                <template v-else>点击查看采集记录</template>
+              </div>
+            </div>
           </div>
+          <el-empty v-else-if="!deviceLoading" :image-size="70"
+            description="该点位尚未关联设备,可在「易积水点台账」中关联后查看" />
         </div>
-      </div>
 
-      <!-- ========== 中间地图区域 ========== -->
-      <div class="panel-center">
-        <div id="jsqcMap" class="map-container"></div>
-      </div>
+        <!-- 已选设备前的引导 -->
+        <div v-if="!activeDevice && pointDevices.length && !deviceLoading" class="device-hint">
+          请选择上方关联设备,查看实时视频或水位曲线等监测数据
+        </div>
 
-      <!-- ========== 右侧面板 ========== -->
-      <div class="panel-right">
-        <!-- 积水统计 -->
-        <div class="panel-box">
-          <div class="panel-title">
-            <span class="title-icon">▶</span> 积水统计
+        <!-- 设备数据(按类型差异化) -->
+        <div v-if="activeDevice" class="detail-section">
+          <div class="section-head">
+            <span class="section-bar" :class="activeKind === 'level' ? 'bar-blue' : (activeKind === 'video' ? 'bar-green' : 'bar-orange')"></span>
+            <span class="section-title">{{ activeDevice.equipmentName || activeDevice.equipmentCode }}</span>
+            <span class="section-hint">{{ deviceTypeLabel(activeDevice) }}</span>
+            <div class="section-tools">
+              <el-radio-group v-if="activeKind === 'level'" v-model="curveTab" size="small" @change="loadCurve">
+                <el-radio-button v-for="tab in curveTabs" :key="tab.value" :value="tab.value">
+                  {{ tab.label }}
+                </el-radio-button>
+              </el-radio-group>
+            </div>
           </div>
-          <div id="jsqcLineChart" class="chart-area-sm"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 积水深度曲线弹窗 -->
-    <div v-if="depthModalVisible" class="depth-modal" @click.self="closeDepthModal">
-      <div class="depth-modal-content">
-        <div class="depth-modal-header">
-          <span class="depth-modal-title">{{ selectedPointName }} - 积水深度曲线</span>
-          <span class="depth-modal-close" @click="closeDepthModal">×</span>
+
+          <div class="device-facts">
+            <span>设备编码:{{ activeDevice.equipmentCode || '-' }}</span>
+            <span>所属片区:{{ districtLabel(activeDevice.district) }}</span>
+            <span>安装位置:{{ activeDevice.equipmentLocation || '-' }}</span>
+            <span>最后通信:{{ lastCommTimeText }}</span>
+          </div>
+
+          <!-- 摄像头:直接播放视频 -->
+          <template v-if="activeKind === 'video'">
+            <div class="video-wrap">
+              <DrainageLiveTile v-if="activeVideoId" :key="activeVideoId" :video-id="activeVideoId" />
+              <div v-else class="panel-empty">该设备未匹配到视频通道</div>
+            </div>
+          </template>
+
+          <!-- 平升液位流量终端:水位曲线 -->
+          <template v-else-if="activeKind === 'level'">
+            <div class="level-bar">
+              <div class="level-item">
+                <div class="level-label">最新水位({{ activeLevel?.unit || 'm' }})</div>
+                <div class="level-value" :class="{ 'is-empty': !activeLevel }">
+                  {{ activeLevel ? activeLevel.value.toFixed(2) : '—' }}
+                </div>
+              </div>
+              <div class="level-item">
+                <div class="level-label">采集时间</div>
+                <div class="level-text">{{ activeLevel?.time ? formatTimeText(activeLevel.time) : '—' }}</div>
+              </div>
+              <div class="level-item">
+                <div class="level-label">数据来源</div>
+                <div class="level-text">{{ curveSource || '—' }}</div>
+              </div>
+            </div>
+
+            <div class="curve-wrap" v-loading="curveLoading">
+              <div ref="chartRef" class="curve-chart"></div>
+              <div v-if="!curveLoading && !curveSeries.length" class="curve-empty">
+                所选时间范围内暂无水位数据,可切换时间范围再试
+              </div>
+            </div>
+          </template>
+
+          <!-- 其他类型:采集记录 -->
+          <template v-else>
+            <el-table v-if="recordColumns.length" :data="recordRows" size="small" max-height="320"
+              class="record-table">
+              <el-table-column v-for="col in recordColumns" :key="col.key" :label="columnLabel(col)"
+                :min-width="col.key === 'create_time' ? 170 : 110" show-overflow-tooltip>
+                <template #default="{ row }">{{ cellText(row, col) }}</template>
+              </el-table-column>
+            </el-table>
+            <div v-else class="panel-empty">该设备暂无采集数据(未配置原始采集表)</div>
+          </template>
         </div>
-        <div class="depth-modal-tabs">
-          <div
-            v-for="tab in depthTabs"
-            :key="tab.value"
-            class="depth-modal-tab"
-            :class="{ active: currentDepthTab === tab.value }"
-            @click="switchDepthTab(tab.value)"
-          >{{ tab.label }}</div>
+
+        <!-- 点位信息 -->
+        <div class="detail-section">
+          <div class="section-head">
+            <span class="section-bar bar-gray"></span>
+            <span class="section-title">点位信息</span>
+          </div>
+          <el-descriptions :column="2" border size="small">
+            <el-descriptions-item label="点位编码">{{ fmt(currentPoint.pointCode) }}</el-descriptions-item>
+            <el-descriptions-item label="点位名称">{{ fmt(currentPoint.pointName) }}</el-descriptions-item>
+            <el-descriptions-item label="所属片区">{{ districtLabel(currentPoint.district) }}</el-descriptions-item>
+            <el-descriptions-item label="风险等级">{{ riskLabel(currentPoint.riskLevel) }}</el-descriptions-item>
+            <el-descriptions-item label="位置描述">{{ fmt(currentPoint.location) }}</el-descriptions-item>
+            <el-descriptions-item label="更新时间">{{ fmt(currentPoint.updateTime) }}</el-descriptions-item>
+            <el-descriptions-item label="责任单位">{{ fmt(currentPoint.dutyUnit) }}</el-descriptions-item>
+            <el-descriptions-item label="责任人">{{ fmt(currentPoint.dutyPerson) }}</el-descriptions-item>
+            <el-descriptions-item label="联系电话" :span="2">{{ fmt(currentPoint.dutyPhone) }}</el-descriptions-item>
+          </el-descriptions>
         </div>
-        <div id="jsqcDepthChart" class="depth-chart-area"></div>
       </div>
-    </div>
+    </el-drawer>
   </div>
 </template>
 
 <script setup name="Jsqc">
-import { ref, onMounted, onUnmounted, nextTick } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
 import * as echarts from 'echarts'
-import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import locationIcon from '@/assets/images/location.png'
-import { getWaterloggingPage } from '@/api/drainage'
-
-const router = useRouter()
-
-// ==================== 实时时间 ====================
-const currentDateTime = ref('')
-let timer = null
-
-function updateTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  const weekDays = ['日', '一', '二', '三', '四', '五', '六']
-  const dateStr = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())} 星期${weekDays[now.getDay()]}`
-  currentDateTime.value = dateStr
-}
-
-// ==================== 刷新时间 ====================
-const refreshTime = ref('')
-function updateRefreshTime() {
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  refreshTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-}
-
-// ==================== 监测点概况 ====================
-const pieData = ref([
-  { name: '未积水', value: 0, color: '#67c23a' },
-  { name: '轻度积水', value: 0, color: '#409eff' },
-  { name: '中度积水', value: 0, color: '#e6a23c' },
-  { name: '严重积水', value: 0, color: '#f56c6c' }
-])
-
-// ==================== 实时数据 ====================
-const realtimeData = ref([])
-
-// ==================== 地图监测点数据 ====================
-const mapPoints = ref([])
-
-// ==================== 积水等级映射 ====================
-// 后端 waterloggingLevel 约定:0=未积水,1=轻度,2=中度,3=严重
-function getWaterloggingLevelInfo(level) {
-  const map = {
-    0: { label: '未积水', levelClass: 'none' },
-    1: { label: '轻度积水', levelClass: 'light' },
-    2: { label: '中度积水', levelClass: 'moderate' },
-    3: { label: '严重积水', levelClass: 'severe' }
-  }
-  return map[level] || map[Number(level)] || { label: '未积水', levelClass: 'none' }
-}
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import DrainageLiveTile from '@/views/subSystem/drainage/spjc/DrainageLiveTile.vue'
+import { useDict } from '@/utils/dict'
+import { getDrainageVideoMap } from '@/api/drainageVideo'
+import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
+import { getEquipmentProfile } from '@/api/pipeNetwork/perception'
+import { getWaterloggingMap, getWaterLevelHistory, getRadarDataHistory } from '@/api/drainage'
+
+const {
+  drainage_waterlogging_risk_level: riskDict,
+  drainage_district: districtDict
+} = useDict('drainage_waterlogging_risk_level', 'drainage_district')
+
+// ==================== 字典兜底(字典未配置时页面仍可用) ====================
+const RISK_FALLBACK = [
+  { value: '0', label: '低风险' },
+  { value: '1', label: '中风险' },
+  { value: '2', label: '高风险' }
+]
+const DISTRICT_FALLBACK = [
+  { value: '0', label: '城南' },
+  { value: '1', label: '城北' },
+  { value: '2', label: '太常' }
+]
 
-// ==================== API数据加载 ====================
-async function loadWaterloggingData() {
-  try {
-    const res = await getWaterloggingPage(1, 999)
-    // 兼容直接返回 Page 对象(无 code 包装)和 AjaxResult 包装两种格式
-    const pageData = res && res.code !== undefined ? res.data : res
-    const list = Array.isArray(pageData.records) ? pageData.records : (Array.isArray(pageData) ? pageData : [])
-
-    // 构建地图点位:易积水点为单点要素,直接用经纬度
-    const allMapPoints = []
-    list.forEach(p => {
-      const levelInfo = getWaterloggingLevelInfo(p.waterloggingLevel)
-      const lng = Number(p.longitude)
-      const lat = Number(p.latitude)
-      if (!Number.isFinite(lng) || !Number.isFinite(lat) || (lng === 0 && lat === 0)) return
-      allMapPoints.push({
-        name: p.pointName || '未命名',
-        lng,
-        lat,
-        depth: p.waterVolume ?? 0,
-        level: levelInfo.label,
-        levelClass: levelInfo.levelClass,
-        parentId: p.pointId,
-        parentName: p.pointName
-      })
-    })
-    mapPoints.value = allMapPoints
-
-    // 构建实时数据表(按子点位展开)
-    const pad = n => String(n).padStart(2, '0')
-    const now = new Date()
-    const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-    realtimeData.value = allMapPoints.map((p, idx) => ({
-      name: p.name,
-      time: timeStr,
-      depth: p.depth,
-      level: p.level,
-      levelClass: p.levelClass
-    })).slice(0, 50)
-
-    // 更新 pieData 统计(按 waterloggingLevel 统计各等级数量)
-    const levelCount = { 0: 0, 1: 0, 2: 0, 3: 0 }
-    list.forEach(p => {
-      const key = Number(p.waterloggingLevel ?? 0)
-      if (levelCount[key] !== undefined) levelCount[key]++
-      else levelCount[0]++
+// ==================== 地图(只展示积水点) ====================
+const loading = ref(false)
+const records = ref([])
+const mapRef = ref(null)
+
+function toCoord(lng, lat) {
+  const x = Number(lng)
+  const y = Number(lat)
+  if (!Number.isFinite(x) || !Number.isFinite(y) || (x === 0 && y === 0)) return null
+  return { lng: x, lat: y }
+}
+
+const mapPoints = computed(() =>
+  records.value
+    .map((row) => {
+      const coord = toCoord(row.longitude, row.latitude)
+      if (!coord) return null
+      return {
+        id: row.pointId,
+        name: row.pointName || row.pointCode || '积水点',
+        ...coord,
+        statusKey: 'normal',
+        raw: row
+      }
     })
-    pieData.value = [
-      { name: '未积水', value: levelCount[0], color: '#67c23a' },
-      { name: '轻度积水', value: levelCount[1], color: '#409eff' },
-      { name: '中度积水', value: levelCount[2], color: '#e6a23c' },
-      { name: '严重积水', value: levelCount[3], color: '#f56c6c' }
-    ]
-
-  } catch (e) {
-    console.error('加载易积水点数据失败:', e)
-    ElMessage.error('加载易积水点数据失败')
+    .filter(Boolean)
+)
+
+async function loadPoints() {
+  loading.value = true
+  try {
+    const res = await getWaterloggingMap()
+    const data = res && res.code !== undefined ? (res.data || []) : (res || [])
+    records.value = Array.isArray(data) ? data : (data.records || [])
+  } catch (error) {
+    console.error('加载积水点数据失败', error)
+    ElMessage.error('积水点数据加载失败,请稍后重试')
+    records.value = []
+  } finally {
+    loading.value = false
   }
 }
 
-// ==================== 导航方法 ====================
-function goBack() {
-  router.push('/')
+function fitMap() {
+  mapRef.value?.fitBounds()
 }
-function goAdmin() {
-  router.push('/')
+
+function onMapReady() {
+  nextTick(() => setTimeout(fitMap, 120))
 }
 
-// ==================== 地图初始化 ====================
-let mapInstance = null
+// ==================== 设备台账 ====================
+/**
+ * 易积水点用 equipment_ids(逗号分隔的 equipment_base.equipment_id)关联设备,
+ * 台账里既有摄像头(视频监控 20)也有液位类终端,所以这里按排水根节点拉全量设备,
+ * 保证关联 ID 都能翻译成名称、类型与在线状态。
+ */
+const deviceLedger = ref([])
+let ledgerPromise = null
+let videoPromise = null
 
-function initMap() {
-  const container = document.getElementById('jsqcMap')
-  if (!container || typeof TdtMap === 'undefined') {
-    console.warn('TdtMap 未加载或容器不存在')
-    return
-  }
+const ledgerMap = computed(() => {
+  const map = {}
+  deviceLedger.value.forEach((item) => {
+    if (item?.equipmentId !== undefined && item?.equipmentId !== null) {
+      map[String(item.equipmentId)] = item
+    }
+  })
+  return map
+})
 
+async function loadLedger() {
+  const map = new Map()
   try {
-    mapInstance = new TdtMap.Map('jsqcMap')
-    mapInstance.enableScrollWheelZoom(true)
-
-    // 添加标记点
-    mapPoints.value.forEach(point => {
-      addMapMarker(point)
-    })
-
-    // 根据所有点位自适应视野
-    if (mapPoints.value.length > 0) {
-      const viewPoints = mapPoints.value.map(p => new TdtMap.Point(p.lng, p.lat))
-      mapInstance.setViewport(viewPoints)
-      // 确保缩放级别不低于14,避免地图显示太小
-      if (mapInstance.getZoom() < 14) {
-        mapInstance.setZoom(14)
-      }
-    } else {
-      mapInstance.centerAndZoom(new TdtMap.Point(110.393, 28.452), 14)
+    for (let pageNum = 1; pageNum <= 6; pageNum++) {
+      const res = await listEquipment({ rootTypeId: 2, pageNum, pageSize: 500 })
+      const page = res?.data && Array.isArray(res.data.records) ? res.data : res
+      const rows = page?.records || page?.rows || []
+      if (!rows.length) break
+      rows.forEach((item) => {
+        const id = item?.equipmentId
+        if (id !== undefined && id !== null && !map.has(String(id))) map.set(String(id), item)
+      })
+      if (rows.length < 500) break
     }
-  } catch (e) {
-    console.error('天地图初始化失败:', e)
+  } catch (error) {
+    console.warn('排水设备台账加载失败', error)
+  }
+  deviceLedger.value = [...map.values()]
+}
+
+function ensureLedger() {
+  if (!ledgerPromise) ledgerPromise = loadLedger()
+  return ledgerPromise
+}
+
+/** 视频资源:仅用于把设备名对上视频通道,取不到时用设备ID 兜底 */
+const videoMap = ref({})
+function ensureVideoMap() {
+  if (!videoPromise) {
+    videoPromise = getDrainageVideoMap()
+      .then((res) => {
+        const data = res && res.code !== undefined ? (res.data || []) : (res || [])
+        const map = {}
+        ;(Array.isArray(data) ? data : []).forEach((item) => {
+          if (item?.videoId !== undefined && item?.videoId !== null) map[String(item.videoId)] = item
+        })
+        videoMap.value = map
+      })
+      .catch((error) => {
+        console.warn('视频资源加载失败', error)
+        videoMap.value = {}
+      })
   }
+  return videoPromise
 }
 
-function addMapMarker(point) {
-  const levelColor = {
-    '未积水': '#67c23a',
-    '轻度积水': '#409eff',
-    '中度积水': '#e6a23c',
-    '严重积水': '#f56c6c'
+// ==================== 设备档案(水位取值口径同 remoteControl.vue) ====================
+const profileMap = ref({})
+const profileLoading = reactive({})
+
+function profileOf(device) {
+  if (!device) return null
+  return profileMap.value[String(device.equipmentId)] || null
+}
+
+async function ensureProfile(device) {
+  const id = String(device?.equipmentId || '')
+  if (!id) return null
+  if (profileMap.value[id]) return profileMap.value[id]
+  if (profileLoading[id]) return null
+  profileLoading[id] = true
+  try {
+    const res = await getEquipmentProfile(device.equipmentId)
+    const data = res && res.code !== undefined ? res.data : res
+    profileMap.value[id] = data && typeof data === 'object' ? data : null
+  } catch (error) {
+    console.warn('设备档案加载失败', device.equipmentId, error)
+    profileMap.value[id] = null
+  } finally {
+    profileLoading[id] = false
   }
-  const color = levelColor[point.level] || '#409eff'
-  const bPoint = new TdtMap.Point(point.lng, point.lat)
-
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 6px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;">${point.name}</div>
-    <div style="color:#fff;font-size:10px;background:rgba(0,0,0,0.6);border-radius:3px;padding:1px 4px;margin-bottom:2px;">水位:${point.depth}cm ${point.level}</div>
-    <div><img src="${locationIcon}" style="width:24px;height:24px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new TdtMap.Label(html, {
-    position: bPoint,
-    offset: new TdtMap.Size(-30, -55)
-  })
-  label.setStyle({
-    border: 'none',
-    background: 'transparent',
-    padding: '0'
+  return profileMap.value[id]
+}
+
+/**
+ * 水位列:和设备档案里的采集记录列一一对应(remoteControl 的「实时数据 / 采集记录」就是按列渲染的),
+ * 所以优先认 water_level,其次认列名含「水位」,避免不同采集表的列名差异。
+ */
+function waterLevelColumn(profile) {
+  const columns = profile?.recordView?.columns || []
+  if (!columns.length) return null
+  return columns.find((col) => col.key === 'water_level')
+    || columns.find((col) => String(col.label || '').includes('水位'))
+    || null
+}
+
+function cellOf(row, key) {
+  const cells = row?.cells || []
+  return cells.find((cell) => cell.key === key) || null
+}
+
+/** 最新一条采集记录里的水位值(与 remoteControl 取值逻辑一致:cell.value + 列单位) */
+function latestLevel(profile) {
+  const col = waterLevelColumn(profile)
+  if (!col) return null
+  const row = (profile?.recordView?.rows || [])[0]
+  if (!row) return null
+  const cell = cellOf(row, col.key)
+  const raw = cell?.value
+  // cell.value 为「—」或空串时表示该项无有效采集值,不能当 0 处理
+  if (raw === null || raw === undefined || String(raw).trim() === '') return null
+  const value = Number(raw)
+  if (!Number.isFinite(value)) return null
+  return { value, unit: col.unit || '', time: row.createTime || '' }
+}
+
+// ==================== 设备类型判定 ====================
+/** 视频监控(排口+内涝点),视频设备的 equipment_id 即视频模块的 video_id */
+const DEVICE_TYPE_VIDEO = '20'
+const LEVEL_TYPE_KEYWORD = /液位|水位|流量/
+
+function typeNameOf(device) {
+  const profile = profileOf(device)
+  return profile?.base?.equipmentTypeName || device?.equipmentTypeName || ''
+}
+
+function deviceTypeLabel(device) {
+  return typeNameOf(device) || '未知类型'
+}
+
+function isVideoDevice(device) {
+  if (!device) return false
+  if (/视频|摄像/.test(typeNameOf(device))) return true
+  return String(device.equipmentTypeId) === DEVICE_TYPE_VIDEO
+}
+
+/** 设备类型 → 展示口径:video 播放视频 / level 水位曲线 / other 采集记录 */
+function deviceKind(device) {
+  if (!device) return 'other'
+  if (isVideoDevice(device)) return 'video'
+  if (waterLevelColumn(profileOf(device))) return 'level'
+  // 档案还没拿到(或未配置原始采集表)时按类型名兜底识别液位类终端
+  if (LEVEL_TYPE_KEYWORD.test(typeNameOf(device))) return 'level'
+  return 'other'
+}
+
+function kindLabel(device) {
+  const kind = deviceKind(device)
+  if (kind === 'video') return '视频'
+  if (kind === 'level') return '水位'
+  return '监测'
+}
+
+// ==================== 详情 / 设备选择 ====================
+const detailVisible = ref(false)
+const currentPoint = ref(null)
+const pointDevices = ref([])
+const selectedDeviceId = ref('')
+const deviceLoading = ref(false)
+
+function parseIds(value) {
+  if (!value) return []
+  return String(value)
+    .split(',')
+    .map((id) => id.trim())
+    .filter(Boolean)
+}
+
+/** 关联设备 ID → 台账对象;台账没命中时先用 ID 兜底,档案回来后再补名称 */
+function resolvePointDevices(point) {
+  return parseIds(point?.equipmentIds).map((id) => ledgerMap.value[id] || {
+    equipmentId: id,
+    equipmentCode: id,
+    equipmentName: id
   })
-  mapInstance.addOverlay(label)
+}
 
-  // 点击标记打开积水深度曲线弹窗
-  label.addEventListener('click', function () {
-    openDepthModal(point)
+/** 用设备档案(名称/类型/在线状态)补全台账缺失的信息 */
+function enrichDevices(list) {
+  return list.map((device) => {
+    const profile = profileOf(device)
+    if (!profile?.base) return device
+    const base = profile.base
+    return {
+      ...device,
+      equipmentName: device.equipmentName && device.equipmentName !== device.equipmentId
+        ? device.equipmentName
+        : (base.equipmentName || device.equipmentName),
+      equipmentCode: base.equipmentCode || device.equipmentCode,
+      equipmentTypeName: base.equipmentTypeName || device.equipmentTypeName,
+      equipmentTypeId: base.equipmentTypeId || device.equipmentTypeId,
+      equipmentLocation: base.equipmentLocation || device.equipmentLocation,
+      district: base.district !== undefined && base.district !== null ? base.district : device.district,
+      onlineStatus: base.onlineStatus !== undefined && base.onlineStatus !== null
+        ? base.onlineStatus
+        : device.onlineStatus
+    }
   })
 }
 
-// ==================== ECharts 图表 ====================
-let pieChart = null
-let lineChart = null
-
-function initPieChart() {
-  const dom = document.getElementById('jsqcPieChart')
-  if (!dom) return
-  pieChart = echarts.init(dom)
-  const total = pieData.value.reduce((s, d) => s + d.value, 0)
-  pieChart.setOption({
-    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-    legend: { show: false },
-    graphic: [{
-      type: 'text',
-      left: 'center',
-      top: '38%',
-      style: { text: '积水点个数', fill: '#a0cfff', fontSize: 12, textAlign: 'center' }
-    }, {
-      type: 'text',
-      left: 'center',
-      top: '52%',
-      style: { text: String(total), fill: '#fff', fontSize: 22, fontWeight: 'bold', textAlign: 'center' }
-    }],
-    series: [{
-      type: 'pie',
-      radius: ['45%', '70%'],
-      center: ['50%', '50%'],
-      label: { show: false },
-      labelLine: { show: false },
-      data: pieData.value.map(d => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })),
-      emphasis: { scale: true, scaleSize: 5 }
-    }]
+const activeDevice = computed(() =>
+  pointDevices.value.find((item) => String(item.equipmentId) === selectedDeviceId.value) || null
+)
+const activeKind = computed(() => deviceKind(activeDevice.value))
+const activeProfile = computed(() => profileOf(activeDevice.value))
+const activeLevel = computed(() => latestLevel(activeProfile.value))
+const activeVideoId = computed(() => {
+  const device = activeDevice.value
+  if (!device) return ''
+  const id = String(device.equipmentId || '')
+  const hit = videoMap.value[id]
+  return hit?.videoId !== undefined && hit?.videoId !== null ? String(hit.videoId) : id
+})
+const lastCommTimeText = computed(() => {
+  const time = activeProfile.value?.lastCommTime
+  return time ? formatTimeText(time) : '—'
+})
+
+function levelText(device) {
+  const level = latestLevel(profileOf(device))
+  if (!level) return '暂无水位数据'
+  return `${level.value.toFixed(2)}${level.unit || ''}`
+}
+
+function levelTimeText(device) {
+  const level = latestLevel(profileOf(device))
+  return level?.time ? formatTimeText(level.time) : ''
+}
+
+async function handlePointClick(point) {
+  const row = point?.raw || point
+  if (!row) return
+  const pointId = row.pointId
+  currentPoint.value = { ...row }
+  pointDevices.value = []
+  selectedDeviceId.value = ''
+  curveSeries.value = []
+  curveSource.value = ''
+  detailVisible.value = true
+  deviceLoading.value = true
+
+  // 台账、视频资源按需加载,第二轮进页面不再请求
+  await Promise.all([ensureLedger(), ensureVideoMap()])
+  if (currentPoint.value?.pointId !== pointId) return
+
+  const devices = resolvePointDevices(currentPoint.value)
+  pointDevices.value = devices
+  if (!devices.length) {
+    deviceLoading.value = false
+    return
+  }
+  // 一次最多 8 台,档案用于识别设备类型与最新水位
+  await Promise.all(devices.slice(0, 8).map((device) => ensureProfile(device)))
+  if (currentPoint.value?.pointId !== pointId) return
+  pointDevices.value = enrichDevices(devices)
+  deviceLoading.value = false
+}
+
+async function reloadDevices() {
+  const point = currentPoint.value
+  if (!point) return
+  deviceLoading.value = true
+  const devices = resolvePointDevices(point)
+  await Promise.all(devices.slice(0, 8).map((device) => ensureProfile(device)))
+  pointDevices.value = enrichDevices(devices)
+  deviceLoading.value = false
+  // 设备被解绑后保留旧的选中状态会显示错数据,这里同步清掉
+  if (selectedDeviceId.value
+    && !devices.some((item) => String(item.equipmentId) === selectedDeviceId.value)) {
+    selectedDeviceId.value = ''
+    disposeChart()
+    curveSeries.value = []
+    curveSource.value = ''
+  }
+}
+
+async function selectDevice(device) {
+  if (!device) return
+  const id = String(device.equipmentId)
+  if (selectedDeviceId.value === id) return
+  selectedDeviceId.value = id
+  disposeChart()
+  curveSeries.value = []
+  curveSource.value = ''
+  curveLoading.value = false
+  await ensureProfile(device)
+  pointDevices.value = enrichDevices(pointDevices.value)
+  if (deviceKind(device) !== 'level') return
+  await nextTick()
+  ensureChart()
+  loadCurve()
+}
+
+function onDrawerClosed() {
+  disposeChart()
+  currentPoint.value = null
+  pointDevices.value = []
+  selectedDeviceId.value = ''
+  curveSeries.value = []
+  curveSource.value = ''
+  curveLoading.value = false
+  deviceLoading.value = false
+}
+
+// ==================== 水位曲线数据 ====================
+const curveTabs = [
+  { label: '24小时', value: 'h24' },
+  { label: '7天', value: 'd7' },
+  { label: '15天', value: 'd15' },
+  { label: '30天', value: 'd30' }
+]
+const CURVE_WINDOW = {
+  h24: 24 * 3600 * 1000,
+  d7: 7 * 24 * 3600 * 1000,
+  d15: 15 * 24 * 3600 * 1000,
+  d30: 30 * 24 * 3600 * 1000
+}
+/** 水位监测接口没有时间区间参数,只能按最新 N 条拉取后前端裁剪 */
+const CURVE_PAGE_SIZE = { h24: 500, d7: 1000, d15: 1500, d30: 2000 }
+const curveTab = ref('h24')
+const curveLoading = ref(false)
+const curveSeries = ref([])
+const curveSource = ref('')
+
+function pad2(value) {
+  return String(value).padStart(2, '0')
+}
+
+function fmtDateTime(date) {
+  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
+    `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
+}
+
+function parseTime(value) {
+  if (!value) return null
+  const text = String(value).replace('T', ' ').trim()
+  if (!text) return null
+  const time = new Date(text.replace(/-/g, '/')).getTime()
+  return Number.isFinite(time) ? time : null
+}
+
+function formatTimeText(value) {
+  const time = parseTime(value)
+  if (time === null) return String(value)
+  const d = new Date(time)
+  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
+    `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
+}
+
+function unwrapRecords(res) {
+  if (!res) return []
+  if (Array.isArray(res)) return res
+  const data = res.code !== undefined ? res.data : res
+  if (!data) return []
+  if (Array.isArray(data)) return data
+  return data.records || data.rows || []
+}
+
+/** 同一时刻只保留最后一条,避免曲线出现竖直折线 */
+function normalizeSeries(points) {
+  points.sort((a, b) => a.time - b.time)
+  const result = []
+  points.forEach((point) => {
+    if (result.length && result[result.length - 1].time === point.time) {
+      result[result.length - 1] = point
+    } else {
+      result.push(point)
+    }
   })
+  return result
+}
+
+/** 采集表原始记录 → 曲线点(字段口径与设备档案一致:create_time + water_level) */
+function seriesFromRecords(list, from, to, deviceCode) {
+  const points = []
+  ;(list || []).forEach((item) => {
+    if (!item) return
+    // 雷达/水位接口都是编码模糊匹配,这里再做一次精确比对,避免相似编码串数据
+    const code = item.deviceCode ?? item.device_code
+    if (deviceCode && code && String(code) !== String(deviceCode)) return
+    const time = parseTime(item.createTime ?? item.create_time)
+    const raw = item.waterLevel ?? item.water_level
+    // 接口里空值可能是 null / 空串,直接 Number('') 会变 0,先挡掉
+    if (raw === null || raw === undefined || String(raw).trim() === '') return
+    const value = Number(raw)
+    if (time === null || !Number.isFinite(value)) return
+    if (time < from || time > to) return
+    points.push({ time, value })
+  })
+  return normalizeSeries(points)
+}
+
+/** 设备档案里的采集记录 → 曲线点(参考 remoteControl.vue 的水位取值口径) */
+function seriesFromProfile(profile, from, to) {
+  const col = waterLevelColumn(profile)
+  if (!col) return []
+  const points = []
+  ;(profile?.recordView?.rows || []).forEach((row) => {
+    const time = parseTime(row?.createTime)
+    const raw = cellOf(row, col.key)?.value
+    if (raw === null || raw === undefined || String(raw).trim() === '') return
+    const value = Number(raw)
+    if (time === null || !Number.isFinite(value)) return
+    if (time < from || time > to) return
+    points.push({ time, value })
+  })
+  return normalizeSeries(points)
+}
+
+async function fetchRadarSeries(deviceCode, from, to) {
+  const res = await getRadarDataHistory(deviceCode, fmtDateTime(new Date(from)), fmtDateTime(new Date(to)))
+  return seriesFromRecords(unwrapRecords(res), from, to, deviceCode)
+}
+
+async function fetchMonitorSeries(deviceCode, from, to) {
+  const res = await getWaterLevelHistory(deviceCode, CURVE_PAGE_SIZE[curveTab.value])
+  return seriesFromRecords(unwrapRecords(res), from, to, deviceCode)
 }
 
-async function initLineChart() {
-  // 统计最近6个月积水事件数
-  const dom = document.getElementById('jsqcLineChart')
-  if (!dom) return
-  lineChart = echarts.init(dom)
+async function loadCurve() {
+  const device = activeDevice.value
+  if (!device || deviceKind(device) !== 'level') return
+  const token = `${selectedDeviceId.value}_${curveTab.value}`
+  curveLoading.value = true
+  curveSeries.value = []
+  const to = Date.now()
+  const from = to - CURVE_WINDOW[curveTab.value]
+  const profile = profileOf(device)
+  // 设备档案会带上它真正的原始采集表,按表选数据源,不要靠设备类型猜
+  const table = String(profile?.origCollTable || '').trim()
+  const deviceCode = device.equipmentCode
+  let points = []
+  let source = ''
   try {
-    const res = await getWaterloggingPage(1, 9999)
-    const pageData = res && res.code !== undefined ? res.data : res
-    const list = Array.isArray(pageData.records) ? pageData.records : (Array.isArray(pageData) ? pageData : [])
-    const now = new Date()
-    const xData = []
-    const yData = []
-    for (let i = 5; i >= 0; i--) {
-      const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
-      const month = d.getMonth() + 1
-      const year = d.getFullYear()
-      xData.push(`${month}月`)
-      const count = list.filter(item => {
-        const t = item.createTime || ''
-        if (!t) return false
-        const itemDate = new Date(t)
-        return itemDate.getFullYear() === year && itemDate.getMonth() + 1 === month
-      }).length
-      yData.push(count)
+    if (deviceCode) {
+      if (table === 'radar_data') {
+        points = await fetchRadarSeries(deviceCode, from, to)
+        if (points.length) source = '雷达流量计采集表'
+      } else if (table === 'water_monitor_data') {
+        points = await fetchMonitorSeries(deviceCode, from, to)
+        if (points.length) source = '水位监测数据'
+      }
     }
-    lineChart.setOption({
-      tooltip: { trigger: 'axis' },
-      grid: { left: 40, right: 10, top: 10, bottom: 25 },
-      xAxis: {
-        type: 'category',
-        data: xData,
-        axisLabel: { color: '#7ec8e3', fontSize: 10 },
-        axisLine: { lineStyle: { color: '#1a3a5c' } }
-      },
-      yAxis: {
-        type: 'value',
-        name: '次',
-        nameTextStyle: { color: '#7ec8e3', fontSize: 10 },
-        axisLabel: { color: '#7ec8e3', fontSize: 10 },
-        splitLine: { lineStyle: { color: '#1a3a5c' } },
-        axisLine: { show: false }
-      },
-      series: [{
-        type: 'bar',
-        data: yData,
-        barWidth: 12,
-        itemStyle: {
-          color: '#00e5ff',
-          borderRadius: [2, 2, 0, 0]
-        }
-      }]
-    })
-  } catch (e) {
-    console.error('积水统计加载失败:', e)
+    if (!points.length) {
+      points = seriesFromProfile(profile, from, to)
+      if (points.length) source = '设备采集记录'
+    }
+    // 未配置原始采集表的液位类设备,最后再试一次雷达表
+    if (!points.length && deviceCode && !table) {
+      points = await fetchRadarSeries(deviceCode, from, to)
+      if (points.length) source = '雷达流量计采集表'
+    }
+  } catch (error) {
+    console.warn('水位曲线数据加载失败', deviceCode, error)
   }
+  // 切换设备/时间范围后丢弃过期响应
+  if (token !== `${selectedDeviceId.value}_${curveTab.value}`) return
+  curveSeries.value = points
+  curveSource.value = source || '暂无数据'
+  curveLoading.value = false
+  renderChart(points)
 }
 
-// ==================== 积水深度曲线弹窗 ====================
-const depthModalVisible = ref(false)
-const selectedPointName = ref('')
-const currentDepthTab = ref('24h')
-const depthTabs = [
-  { label: '24小时', value: '24h' },
-  { label: '7天', value: '7d' },
-  { label: '15天', value: '15d' },
-  { label: '30天', value: '30d' }
-]
-let depthChart = null
-
-function openDepthModal(point) {
-  selectedPointName.value = point.name
-  currentDepthTab.value = '24h'
-  depthModalVisible.value = true
-  nextTick(() => {
-    initDepthChart()
-  })
+// ==================== 图表 ====================
+const chartRef = ref(null)
+let chart = null
+
+function ensureChart() {
+  if (!chartRef.value) return
+  if (!chart) chart = echarts.init(chartRef.value)
+  renderChart(curveSeries.value)
 }
 
-function closeDepthModal() {
-  depthModalVisible.value = false
-  if (depthChart) {
-    depthChart.dispose()
-    depthChart = null
+function disposeChart() {
+  if (chart) {
+    chart.dispose()
+    chart = null
   }
 }
 
-function switchDepthTab(tab) {
-  currentDepthTab.value = tab
-  initDepthChart()
+/** 数据量过大时抽稀,避免 30 天数据把曲线拖成锯齿 */
+const MAX_CHART_POINTS = 1200
+function downsample(points) {
+  if (points.length <= MAX_CHART_POINTS) return points
+  const step = Math.ceil(points.length / MAX_CHART_POINTS)
+  const result = []
+  points.forEach((point, index) => {
+    if (index % step === 0) result.push(point)
+  })
+  const last = points[points.length - 1]
+  if (result[result.length - 1] !== last) result.push(last)
+  return result
 }
 
-function generateDepthData(tab) {
-  let labels = []
-  let values = []
-  const now = new Date()
-  const pad = n => String(n).padStart(2, '0')
-  if (tab === '24h') {
-    for (let i = 0; i < 24; i++) {
-      labels.push(`${pad(i)}:00`)
-      const base = i < 8 || i > 20 ? Math.random() * 3 : Math.random() * 15 + 5
-      values.push(Math.round(base * 10) / 10)
-    }
-  } else {
-    const count = tab === '7d' ? 7 : tab === '15d' ? 15 : 30
-    for (let i = count - 1; i >= 0; i--) {
-      const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
-      labels.push(`${pad(d.getMonth() + 1)}-${pad(d.getDate())}`)
-      values.push(Math.round((Math.random() * 20 + Math.random() * 5) * 10) / 10)
-    }
+function renderChart(points) {
+  if (!chart) return
+  if (!points.length) {
+    chart.clear()
+    return
+  }
+  const list = downsample(points)
+  const tab = curveTab.value
+  const axisTime = (time) => {
+    const d = new Date(time)
+    return tab === 'h24'
+      ? `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
+      : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
   }
-  return { labels, values }
-}
-
-function initDepthChart() {
-  const dom = document.getElementById('jsqcDepthChart')
-  if (!dom) return
-  if (depthChart) depthChart.dispose()
-  depthChart = echarts.init(dom)
-  const { labels, values } = generateDepthData(currentDepthTab.value)
-  depthChart.setOption({
-    tooltip: { trigger: 'axis' },
-    grid: { left: 50, right: 20, top: 20, bottom: 30 },
+  chart.setOption({
+    tooltip: {
+      trigger: 'axis',
+      valueFormatter: (value) => `${value} m`
+    },
+    grid: { left: 12, right: 20, top: 28, bottom: 8, containLabel: true },
     xAxis: {
       type: 'category',
-      data: labels,
-      axisLabel: { color: '#7ec8e3', fontSize: 10 },
-      axisLine: { lineStyle: { color: '#1a3a5c' } }
+      boundaryGap: false,
+      data: list.map((item) => axisTime(item.time)),
+      axisLabel: { color: '#909399', fontSize: 11, hideOverlap: true },
+      axisLine: { lineStyle: { color: '#dcdfe6' } },
+      axisTick: { show: false }
     },
     yAxis: {
       type: 'value',
-      name: 'cm',
-      nameTextStyle: { color: '#7ec8e3', fontSize: 11 },
-      axisLabel: { color: '#7ec8e3', fontSize: 10 },
-      splitLine: { lineStyle: { color: '#1a3a5c' } },
-      axisLine: { show: false }
+      name: '水位(m)',
+      scale: true,
+      nameTextStyle: { color: '#909399', fontSize: 11, padding: [0, 0, 6, 0] },
+      axisLabel: { color: '#909399', fontSize: 11 },
+      axisLine: { show: false },
+      axisTick: { show: false },
+      splitLine: { lineStyle: { color: '#f0f2f5' } }
     },
     series: [{
+      name: '水位',
       type: 'line',
-      data: values,
       smooth: true,
-      symbol: 'circle',
-      symbolSize: 6,
+      showSymbol: list.length <= 60,
+      symbolSize: 5,
+      data: list.map((item) => item.value),
       lineStyle: { color: '#409eff', width: 2 },
       itemStyle: { color: '#409eff' },
       areaStyle: {
         color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: 'rgba(64, 158, 255, 0.4)' },
-          { offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
+          { offset: 0, color: 'rgba(64, 158, 255, 0.32)' },
+          { offset: 1, color: 'rgba(64, 158, 255, 0.04)' }
         ])
       }
     }]
-  })
+  }, true)
 }
 
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  updateTime()
-  timer = setInterval(() => {
-    updateTime()
-    updateRefreshTime()
-  }, 1000)
-  updateRefreshTime()
-
-  // 先加载后端数据,再初始化地图和图表
-  await loadWaterloggingData()
-
-  // 延迟初始化确保 DOM 渲染完成
-  setTimeout(() => {
-    initMap()
-    initPieChart()
-    initLineChart()
-  }, 300)
-})
+function handleResize() {
+  if (chart) chart.resize()
+}
+
+// ==================== 采集记录(其他类型设备) ====================
+const recordColumns = computed(() => activeProfile.value?.recordView?.columns || [])
+const recordRows = computed(() => (activeProfile.value?.recordView?.rows || []).slice(0, 20))
+
+function columnLabel(col) {
+  if (!col) return ''
+  return col.unit ? `${col.label || col.key}(${col.unit})` : (col.label || col.key)
+}
+
+function cellText(row, col) {
+  if (!col) return '—'
+  if (col.key === 'create_time') return row?.createTime ? formatTimeText(row.createTime) : '—'
+  const cell = cellOf(row, col.key)
+  if (!cell) return '—'
+  return cell.value && cell.value !== '' ? cell.value : (cell.display || '—')
+}
+
+// ==================== 展示辅助 ====================
+function dictItem(dictRef, value) {
+  const list = dictRef?.value || []
+  return list.find((item) => String(item.value) === String(value))
+}
+
+function hasValue(value) {
+  return value !== null && value !== undefined && value !== ''
+}
+
+function riskLabel(value) {
+  if (!hasValue(value)) return '-'
+  const fallback = RISK_FALLBACK.find((item) => item.value === String(value))
+  return dictItem(riskDict, value)?.label || fallback?.label || String(value)
+}
+
+function riskTagType(value) {
+  const listClass = dictItem(riskDict, value)?.listClass || ''
+  if (['success', 'warning', 'danger', 'info'].includes(listClass)) return listClass
+  const fallback = { '0': 'success', '1': 'warning', '2': 'danger' }
+  return fallback[String(value)] || 'info'
+}
 
-onUnmounted(() => {
-  if (timer) clearInterval(timer)
-  if (pieChart) pieChart.dispose()
-  if (lineChart) lineChart.dispose()
-  if (depthChart) depthChart.dispose()
-  if (mapInstance) mapInstance = null
+function districtLabel(value) {
+  if (!hasValue(value)) return '-'
+  const fallback = DISTRICT_FALLBACK.find((item) => item.value === String(value))
+  return dictItem(districtDict, value)?.label || fallback?.label || String(value)
+}
+
+function fmt(value) {
+  if (!hasValue(value)) return '-'
+  return value
+}
+
+// ==================== 生命周期 ====================
+onMounted(() => {
+  loadPoints()
+  window.addEventListener('resize', handleResize)
 })
 
-// 监听窗口大小变化,重绘图表
-window.addEventListener('resize', () => {
-  if (pieChart) pieChart.resize()
-  if (lineChart) lineChart.resize()
-  if (depthChart) depthChart.resize()
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  disposeChart()
 })
 </script>
 
 <style scoped>
-/* ==================== 全局暗色大屏 ==================== */
-.jsqc-screen {
-  width: 100%;
-  height: calc(100vh - 84px);
-  background: #0a1929;
-  color: #fff;
-  font-family: 'Microsoft YaHei', sans-serif;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
+.waterlogging-one-map {
+  padding: 16px;
+}
+
+.map-card {
+  margin-bottom: 0;
 }
 
-/* ==================== 顶部标题栏 ==================== */
-.screen-header {
-  height: 64px;
-  background: linear-gradient(180deg, #0d2137 0%, #0a1929 100%);
-  border-bottom: 1px solid rgba(64, 158, 255, 0.3);
+.card-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 0 24px;
-  flex-shrink: 0;
-  position: relative;
+  gap: 12px;
 }
 
-.screen-header::after {
-  content: '';
-  position: absolute;
-  bottom: 0;
-  left: 10%;
-  right: 10%;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, #409eff, transparent);
+.card-title {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
 }
 
-.header-left {
-  flex: 1;
-  text-align: left;
+.card-sub {
+  font-size: 12px;
+  font-weight: 400;
+  color: #909399;
 }
 
-.header-time {
-  font-size: 15px;
-  color: #a0cfff;
-  letter-spacing: 1px;
+.card-sub b {
+  color: #409eff;
 }
 
-.header-center {
-  flex: 2;
-  text-align: center;
+.card-actions {
+  display: flex;
+  gap: 8px;
 }
 
-.header-title {
-  font-size: 26px;
-  font-weight: 700;
-  margin: 0;
-  letter-spacing: 4px;
-  background: linear-gradient(180deg, #fff 0%, #7ec8e3 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  text-shadow: none;
+/* ==================== 地图 ==================== */
+.map-wrap {
+  position: relative;
+  height: calc(100vh - 205px);
+  min-height: 460px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  overflow: hidden;
 }
 
-.header-right {
-  flex: 1;
-  text-align: right;
+.map-empty {
+  position: absolute;
+  inset: 0;
   display: flex;
-  justify-content: flex-end;
-  gap: 10px;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 13px;
+  background: rgba(255, 255, 255, 0.72);
+  pointer-events: none;
 }
 
-.header-btn {
-  background: rgba(64, 158, 255, 0.15);
-  border: 1px solid rgba(64, 158, 255, 0.4);
-  color: #a0cfff;
-  padding: 6px 16px;
+.map-legend {
+  position: absolute;
+  left: 12px;
+  bottom: 12px;
+  /* 必须高于 Leaflet 内部图层(瓦片 200 / 覆盖物 400 / 标记 600 / 控件 800),
+     否则地图缩放重绘后图例被瓦片盖住 */
+  z-index: 900;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 7px 12px;
+  border: 1px solid #e4e7ed;
   border-radius: 4px;
-  font-size: 13px;
-  cursor: pointer;
-  transition: all 0.3s;
+  background: rgba(255, 255, 255, 0.94);
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  font-size: 12px;
+  color: #606266;
 }
 
-.header-btn:hover {
-  background: rgba(64, 158, 255, 0.3);
-  border-color: #409eff;
-  color: #fff;
+.legend-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
 }
 
-/* ==================== 主体三栏 ==================== */
-.screen-body {
-  flex: 1;
-  display: flex;
-  padding: 12px;
-  gap: 12px;
-  min-height: 0;
+.legend-item i,
+.legend-dot {
+  display: inline-block;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
 }
 
-.panel-left,
-.panel-right {
-  width: 22%;
-  min-width: 260px;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-  min-height: 0;
+.legend-dot {
+  background: #2f9be8;
 }
 
-.panel-center {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  position: relative;
+.legend-tip {
+  color: #c0c4cc;
 }
 
-/* ==================== 面板盒子 ==================== */
-.panel-box {
-  background: rgba(6, 30, 65, 0.8);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 6px;
-  padding: 12px;
-  display: flex;
-  flex-direction: column;
-  flex: 1;
-  min-height: 0;
-  overflow: hidden;
+/* ==================== 详情抽屉 ==================== */
+.point-drawer :deep(.el-drawer__body) {
+  padding: 0;
 }
 
-.panel-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #7ec8e3;
-  margin-bottom: 10px;
+.point-detail {
+  padding: 0 20px 20px;
+}
+
+.detail-hero {
   display: flex;
-  align-items: center;
-  gap: 4px;
-  flex-shrink: 0;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin: 0 -20px;
+  padding: 20px;
+  background: linear-gradient(135deg, #f5f8fc 0%, #eaf1fa 100%);
+  border-bottom: 1px solid #e4e7ed;
 }
 
-.title-icon {
-  color: #409eff;
-  font-size: 10px;
+.hero-main {
+  min-width: 0;
 }
 
-.refresh-time {
-  margin-left: auto;
-  font-size: 11px;
-  color: #607080;
-  font-weight: 400;
+.hero-name {
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 1.3;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-/* ==================== 监测点概况 ==================== */
-.pie-chart-wrapper {
-  flex: 1;
+.hero-sub {
   display: flex;
   align-items: center;
-  min-height: 0;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 8px;
+  font-size: 12px;
+  color: #606266;
 }
 
-.chart-area {
-  width: 55%;
-  height: 100%;
-  min-height: 120px;
+.hero-sub-item {
+  color: #909399;
 }
 
-.pie-legend {
-  width: 45%;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
+.hero-close {
+  flex-shrink: 0;
 }
 
-.legend-item {
+/* 分区 */
+.detail-section {
+  margin-top: 18px;
+}
+
+.section-head {
   display: flex;
   align-items: center;
-  gap: 6px;
-  font-size: 12px;
+  gap: 8px;
+  margin-bottom: 10px;
 }
 
-.legend-dot {
-  width: 10px;
-  height: 10px;
+.section-bar {
+  width: 3px;
+  height: 14px;
   border-radius: 2px;
-  flex-shrink: 0;
+  background: #409eff;
 }
 
-.legend-label {
-  color: #a0cfff;
-  flex: 1;
+.section-bar.bar-blue {
+  background: #409eff;
 }
 
-.legend-value {
-  color: #fff;
-  font-weight: 600;
+.section-bar.bar-green {
+  background: #35be83;
 }
 
-/* ==================== 实时数据表格 ==================== */
-.realtime-table-wrap {
-  flex: 1;
-  overflow-y: auto;
-  min-height: 0;
+.section-bar.bar-orange {
+  background: #e6a23c;
 }
 
-.dark-table {
-  width: 100%;
-  border-collapse: collapse;
+.section-bar.bar-gray {
+  background: #909399;
+}
+
+.section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.section-hint {
   font-size: 12px;
+  color: #909399;
 }
 
-.dark-table th {
-  background: rgba(20, 60, 110, 0.6);
-  color: #7ec8e3;
-  padding: 7px 6px;
-  text-align: center;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.2);
-  font-weight: 500;
-  white-space: nowrap;
+.section-tools {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-left: auto;
 }
 
-.dark-table td {
-  padding: 6px;
-  text-align: center;
-  color: #d0e8ff;
-  border-bottom: 1px solid rgba(64, 158, 255, 0.08);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 80px;
+/* 关联设备卡片 */
+.device-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
+  gap: 10px;
 }
 
-.dark-table tbody tr:nth-child(odd) {
-  background: rgba(10, 25, 50, 0.4);
+.device-card {
+  padding: 10px 12px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  background: #fafbfc;
+  cursor: pointer;
+  transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
 }
 
-.dark-table tbody tr:nth-child(even) {
-  background: rgba(20, 40, 70, 0.3);
+.device-card:hover {
+  border-color: #b3d8ff;
+  background: #f5f9ff;
+}
+
+.device-card.is-active {
+  border-color: #409eff;
+  background: #ecf5ff;
+  box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.24);
 }
 
-.dark-table tbody tr:hover {
-  background: rgba(64, 158, 255, 0.1);
+.device-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
 }
 
-.level-tag {
-  padding: 1px 6px;
-  border-radius: 3px;
+.device-kind {
+  display: inline-flex;
+  align-items: center;
+  height: 20px;
+  padding: 0 7px;
+  border-radius: 10px;
   font-size: 11px;
+  line-height: 1;
 }
 
-.level-none {
-  color: #67c23a;
-  background: rgba(103, 194, 58, 0.15);
+.kind-video {
+  color: #1f7a5c;
+  background: #dcf5ea;
 }
 
-.level-light {
-  color: #409eff;
-  background: rgba(64, 158, 255, 0.15);
+.kind-level {
+  color: #1a5fb4;
+  background: #e2eefc;
 }
 
-.level-moderate {
-  color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
+.kind-other {
+  color: #6b6b73;
+  background: #eef0f4;
 }
 
-.level-severe {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
+.device-online,
+.device-offline {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+  color: #67c23a;
 }
 
-/* ==================== 中间地图 ==================== */
-.map-container {
-  flex: 1;
-  border-radius: 6px;
-  overflow: hidden;
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  min-height: 0;
+.device-offline {
+  color: #909399;
 }
 
-.map-tabs {
-  display: flex;
-  gap: 0;
-  background: rgba(6, 30, 65, 0.9);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 6px;
+.device-online i,
+.device-offline i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: currentColor;
+}
+
+.device-name {
   margin-top: 8px;
-  flex-shrink: 0;
+  font-size: 13px;
+  font-weight: 600;
+  color: #303133;
   overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.map-tab {
-  flex: 1;
-  text-align: center;
-  padding: 8px 4px;
+.device-meta {
+  margin-top: 2px;
   font-size: 12px;
-  color: #a0cfff;
-  cursor: pointer;
-  transition: all 0.3s;
-  border-right: 1px solid rgba(64, 158, 255, 0.1);
+  color: #909399;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.map-tab:last-child {
-  border-right: none;
+.device-extra {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  margin-top: 8px;
+  font-size: 12px;
+  color: #c0c4cc;
 }
 
-.map-tab.active {
-  background: rgba(64, 158, 255, 0.25);
-  color: #fff;
+.device-value {
+  font-size: 15px;
   font-weight: 600;
+  color: #409eff;
+  font-variant-numeric: tabular-nums;
 }
 
-.map-tab:hover {
-  background: rgba(64, 158, 255, 0.15);
-}
-
-.chart-area-sm {
-  width: 100%;
-  flex: 1;
-  min-height: 130px;
+.device-time {
+  color: #909399;
 }
 
-/* ==================== 滚动条样式 ==================== */
-::-webkit-scrollbar {
-  width: 4px;
+/* 设备信息行 */
+.device-facts {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px 20px;
+  margin-bottom: 10px;
+  padding: 8px 12px;
+  border-radius: 4px;
+  background: #f7f9fc;
+  font-size: 12px;
+  color: #606266;
 }
 
-::-webkit-scrollbar-track {
-  background: rgba(10, 25, 50, 0.3);
+/* 视频 */
+.video-wrap {
+  height: 380px;
+  border-radius: 4px;
+  overflow: hidden;
+  background: #101820;
 }
 
-::-webkit-scrollbar-thumb {
-  background: rgba(64, 158, 255, 0.3);
-  border-radius: 2px;
+/* 水位 */
+.level-bar {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 1px;
+  margin-bottom: 10px;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  background: #ebeef5;
+  overflow: hidden;
 }
 
-::-webkit-scrollbar-thumb:hover {
-  background: rgba(64, 158, 255, 0.5);
+.level-item {
+  padding: 10px 14px;
+  background: #fafbfc;
+  min-width: 0;
 }
 
-/* ==================== 积水深度曲线弹窗 ==================== */
-.depth-modal {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.7);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 1000;
+.level-label {
+  margin-bottom: 6px;
+  font-size: 12px;
+  color: #909399;
 }
 
-.depth-modal-content {
-  width: 720px;
-  max-width: 90vw;
-  background: rgba(6, 30, 65, 0.95);
-  border: 1px solid rgba(64, 158, 255, 0.4);
-  border-radius: 8px;
-  padding: 16px;
-  box-shadow: 0 0 20px rgba(64, 158, 255, 0.2);
+.level-value {
+  font-size: 22px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 1.2;
+  font-variant-numeric: tabular-nums;
 }
 
-.depth-modal-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 12px;
+.level-value.is-empty {
+  color: #c0c4cc;
 }
 
-.depth-modal-title {
-  font-size: 15px;
-  font-weight: 600;
-  color: #7ec8e3;
+.level-text {
+  font-size: 13px;
+  color: #303133;
+  line-height: 1.6;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.depth-modal-close {
-  font-size: 22px;
-  color: #a0cfff;
-  cursor: pointer;
-  line-height: 1;
+.curve-wrap {
+  position: relative;
+  height: 280px;
 }
 
-.depth-modal-close:hover {
-  color: #fff;
+.curve-chart {
+  width: 100%;
+  height: 100%;
 }
 
-.depth-modal-tabs {
+.curve-empty {
+  position: absolute;
+  inset: 0;
   display: flex;
-  gap: 8px;
-  margin-bottom: 12px;
-}
-
-.depth-modal-tab {
-  flex: 1;
+  align-items: center;
+  justify-content: center;
+  padding: 0 24px;
   text-align: center;
-  padding: 6px 4px;
-  font-size: 12px;
-  color: #a0cfff;
-  background: rgba(10, 25, 50, 0.6);
-  border: 1px solid rgba(64, 158, 255, 0.2);
-  border-radius: 4px;
-  cursor: pointer;
-  transition: all 0.3s;
+  font-size: 13px;
+  color: #909399;
+  background: rgba(255, 255, 255, 0.82);
 }
 
-.depth-modal-tab.active {
-  background: rgba(64, 158, 255, 0.3);
-  color: #fff;
-  border-color: rgba(64, 158, 255, 0.6);
+/* 采集记录 */
+.record-table {
+  width: 100%;
 }
 
-.depth-modal-tab:hover {
-  background: rgba(64, 158, 255, 0.2);
+/* 空态 */
+.panel-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 160px;
+  border: 1px dashed #e4e7ed;
+  border-radius: 4px;
+  font-size: 13px;
+  color: #909399;
 }
 
-.depth-chart-area {
-  width: 100%;
-  height: 320px;
+/* 未选设备时的引导 */
+.device-hint {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 18px;
+  padding: 22px 16px;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  background: #fafbfc;
+  font-size: 13px;
+  color: #909399;
 }
 </style>

+ 186 - 208
src/views/subSystem/drainage/jcsj/yjsdgl.vue

@@ -24,11 +24,10 @@
       </div>
 
       <div class="map-legend">
-        <span class="legend-title">积水程度:</span>
-        <span v-for="item in levelOptions" :key="item.value" class="legend-item">
-          <i :style="{ background: levelColor(item.value) }"></i>{{ item.label }}
+        <span class="legend-item">
+          <i style="background: #2f9be8"></i>易积水点
         </span>
-        <span class="legend-tip">点击地图标记可直接查看关联摄像头</span>
+        <span class="legend-tip">点击地图标记可查看关联设备与实时视频</span>
       </div>
     </el-card>
 
@@ -47,12 +46,6 @@
             <el-option v-for="item in districtOptions" :key="item.value" :label="item.label" :value="item.value" />
           </el-select>
         </el-form-item>
-        <el-form-item label="积水程度">
-          <el-select v-model="query.waterloggingLevel" placeholder="请选择" clearable style="width: 140px">
-            <el-option v-for="item in levelOptions" :key="item.value" :label="item.label"
-              :value="Number(item.value)" />
-          </el-select>
-        </el-form-item>
         <el-form-item label="风险等级">
           <el-select v-model="query.riskLevel" placeholder="请选择" clearable style="width: 130px">
             <el-option v-for="item in riskOptions" :key="item.value" :label="item.label" :value="item.value" />
@@ -88,28 +81,17 @@
           <template #default="{ row }">{{ districtLabel(row.district) }}</template>
         </el-table-column>
         <el-table-column label="位置描述" prop="location" min-width="140" show-overflow-tooltip />
-        <el-table-column label="积水程度" width="110" align="center">
-          <template #default="{ row }">
-            <el-tag size="small" :type="levelTagType(row.waterloggingLevel)">{{ levelLabel(row.waterloggingLevel) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="积水量(m³)" width="110" align="right">
-          <template #default="{ row }">{{ formatVolume(row.waterVolume) }}</template>
-        </el-table-column>
-        <el-table-column label="积水深度(cm)" width="115" align="right">
-          <template #default="{ row }">{{ formatNumber(row.waterDepth) }}</template>
-        </el-table-column>
         <el-table-column label="风险等级" width="100" align="center">
           <template #default="{ row }">
             <el-tag size="small" :type="riskTagType(row.riskLevel)">{{ riskLabel(row.riskLevel) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="关联摄像头" min-width="160">
+        <el-table-column label="关联设备" min-width="180">
           <template #default="{ row }">
-            <template v-if="cameraListOf(row).length">
-              <el-tag v-for="cam in cameraListOf(row).slice(0, 2)" :key="cam.videoId" size="small" type="info"
-                class="camera-tag">{{ cam.deviceName }}</el-tag>
-              <span v-if="cameraListOf(row).length > 2" class="text-muted">等 {{ cameraListOf(row).length }} 个</span>
+            <template v-if="deviceListOf(row).length">
+              <el-tag v-for="device in deviceListOf(row).slice(0, 2)" :key="device.equipmentId" size="small"
+                type="info" class="camera-tag">{{ device.equipmentName }}</el-tag>
+              <span v-if="deviceListOf(row).length > 2" class="text-muted">等 {{ deviceListOf(row).length }} 台</span>
             </template>
             <span v-else class="text-muted">未关联</span>
           </template>
@@ -117,11 +99,11 @@
         <el-table-column label="操作" width="260" align="center" fixed="right">
           <template #default="{ row }">
             <el-button link type="primary" @click="openDetail(row)">详情</el-button>
-            <el-button link type="primary" :disabled="!cameraListOf(row).length"
-              @click="playCameras(row)">视频</el-button>
+            <el-button link type="primary" :disabled="!videoDevicesOf(row).length"
+              @click="playDevices(row)">视频</el-button>
             <el-button v-hasPermi="['drainage:waterlogging:edit']" link type="primary" @click="handleEdit(row)">修改</el-button>
             <el-button v-hasPermi="['drainage:waterlogging:edit']" link type="primary"
-              @click="openBind(row)">关联摄像头</el-button>
+              @click="openBind(row)">关联设备</el-button>
             <el-button v-hasPermi="['drainage:waterlogging:remove']" link type="danger"
               @click="handleDelete(row)">删除</el-button>
           </template>
@@ -137,12 +119,6 @@
         <el-descriptions-item label="点位编码">{{ fmt(detailRow.pointCode) }}</el-descriptions-item>
         <el-descriptions-item label="点位名称">{{ fmt(detailRow.pointName) }}</el-descriptions-item>
         <el-descriptions-item label="所属片区">{{ districtLabel(detailRow.district) }}</el-descriptions-item>
-        <el-descriptions-item label="积水程度">
-          <el-tag size="small" :type="levelTagType(detailRow.waterloggingLevel)">{{ levelLabel(detailRow.waterloggingLevel) }}</el-tag>
-        </el-descriptions-item>
-        <el-descriptions-item label="积水量(m³)">{{ formatVolume(detailRow.waterVolume) }}</el-descriptions-item>
-        <el-descriptions-item label="积水深度(cm)">{{ formatNumber(detailRow.waterDepth) }}</el-descriptions-item>
-        <el-descriptions-item label="积水面积(m²)">{{ formatNumber(detailRow.waterArea) }}</el-descriptions-item>
         <el-descriptions-item label="风险等级">
           <el-tag size="small" :type="riskTagType(detailRow.riskLevel)">{{ riskLabel(detailRow.riskLevel) }}</el-tag>
         </el-descriptions-item>
@@ -157,17 +133,22 @@
       </el-descriptions>
 
       <div class="detail-section">
-        <div class="detail-section-title">关联摄像头</div>
-        <div v-if="detailCameras.length" class="camera-cards">
-          <div v-for="cam in detailCameras" :key="cam.videoId" class="camera-card">
+        <div class="detail-section-title">关联设备</div>
+        <div v-if="detailDevices.length" class="camera-cards">
+          <div v-for="device in detailDevices" :key="device.equipmentId" class="camera-card">
             <div class="camera-card-main">
-              <div class="camera-name">{{ cam.deviceName || cam.deviceCode }}</div>
-              <div class="camera-meta">{{ cam.districtName }} · {{ cam.location || '未填写位置' }}</div>
+              <div class="camera-name">{{ device.equipmentName || device.equipmentCode }}</div>
+              <div class="camera-meta">
+                {{ deviceTypeLabel(device) }} · {{ districtLabel(device.district) }} ·
+                {{ device.equipmentLocation || '未填写位置' }}
+              </div>
             </div>
-            <el-button link type="primary" @click="playVideo(cam)">查看视频</el-button>
+            <el-button v-if="isVideoDevice(device)" link type="primary" @click="playDevice(device)">
+              查看视频
+            </el-button>
           </div>
         </div>
-        <el-empty v-else description="该点位尚未关联摄像头" :image-size="70" />
+        <el-empty v-else description="该点位尚未关联设备" :image-size="70" />
       </div>
 
       <div class="detail-section">
@@ -214,36 +195,6 @@
             </el-form-item>
           </el-col>
         </el-row>
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="积水程度" prop="waterloggingLevel">
-              <el-select v-model="form.waterloggingLevel" placeholder="请选择积水程度" style="width: 100%">
-                <el-option v-for="item in levelOptions" :key="item.value" :label="item.label"
-                  :value="Number(item.value)" />
-              </el-select>
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="积水量(m³)" prop="waterVolume">
-              <el-input-number v-model="form.waterVolume" :min="0" :precision="2" :controls="false"
-                placeholder="请输入积水量" style="width: 100%" />
-            </el-form-item>
-          </el-col>
-        </el-row>
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="积水深度(cm)" prop="waterDepth">
-              <el-input-number v-model="form.waterDepth" :min="0" :precision="2" :controls="false"
-                placeholder="请输入积水深度" style="width: 100%" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="积水面积(m²)" prop="waterArea">
-              <el-input-number v-model="form.waterArea" :min="0" :precision="2" :controls="false"
-                placeholder="请输入积水面积" style="width: 100%" />
-            </el-form-item>
-          </el-col>
-        </el-row>
         <el-row :gutter="16">
           <el-col :span="12">
             <el-form-item label="风险等级" prop="riskLevel">
@@ -323,32 +274,39 @@
       </template>
     </el-dialog>
 
-    <!-- 关联摄像头 -->
-    <el-dialog v-model="bindVisible" title="关联摄像头" width="780px" append-to-body :close-on-click-modal="false">
+    <!-- 关联设备(限雷达液位计 16 / 视频监控 20) -->
+    <el-dialog v-model="bindVisible" title="关联设备" width="860px" append-to-body :close-on-click-modal="false">
       <div class="bind-tip">
         当前点位:<strong>{{ bindRow.pointName }}</strong>
-        <span class="text-muted">(已选 {{ bindSelected.length }} 个)</span>
+        <span class="text-muted">(已选 {{ bindSelected.length }} 台,仅可选雷达液位计与视频监控设备)</span>
       </div>
       <el-form :inline="true" class="bind-filter">
-        <el-form-item label="摄像头名称">
+        <el-form-item label="设备名称">
           <el-input v-model="bindKeyword" placeholder="请输入名称或编码" clearable style="width: 220px" />
         </el-form-item>
         <el-form-item>
           <el-button @click="bindSelected = []">清空选择</el-button>
         </el-form-item>
       </el-form>
-      <el-table v-loading="bindLoading" :data="bindCandidates" border height="380" row-key="videoId"
+      <el-table v-loading="bindLoading" :data="bindCandidates" border height="380" row-key="equipmentId"
         @selection-change="handleBindSelectionChange" ref="bindTableRef">
-        <el-table-column type="selection" width="50" align="center" :selectable="isCameraSelectable"
+        <el-table-column type="selection" width="50" align="center" :selectable="isDeviceSelectable"
           :reserve-selection="true" />
-        <el-table-column label="摄像头名称" prop="deviceName" min-width="150" show-overflow-tooltip />
-        <el-table-column label="设备编码" prop="deviceCode" min-width="150" show-overflow-tooltip />
-        <el-table-column label="所属片区" prop="districtName" width="100" />
-        <el-table-column label="安装位置" prop="location" min-width="140" show-overflow-tooltip />
+        <el-table-column label="设备名称" prop="equipmentName" min-width="150" show-overflow-tooltip />
+        <el-table-column label="设备类型" min-width="130" show-overflow-tooltip>
+          <template #default="{ row }">{{ deviceTypeLabel(row) }}</template>
+        </el-table-column>
+        <el-table-column label="设备编码" prop="equipmentCode" min-width="150" show-overflow-tooltip />
+        <el-table-column label="所属片区" width="100">
+          <template #default="{ row }">{{ districtLabel(row.district) }}</template>
+        </el-table-column>
+        <el-table-column label="安装位置" prop="equipmentLocation" min-width="140" show-overflow-tooltip />
         <el-table-column label="关联状态" min-width="160">
           <template #default="{ row }">
-            <span v-if="row.pointId === bindRow.pointId" class="status-self">当前点位</span>
-            <span v-else-if="row.pointId" class="status-occupied">已关联:{{ row.pointName || '其他点位' }}</span>
+            <span v-if="deviceOccupation(row) === bindRow.pointId" class="status-self">当前点位</span>
+            <span v-else-if="deviceOccupation(row)" class="status-occupied">
+              已关联:{{ deviceOccupationName(row) }}
+            </span>
             <span v-else class="text-muted">未关联</span>
           </template>
         </el-table-column>
@@ -359,13 +317,13 @@
       </template>
     </el-dialog>
 
-    <!-- 摄像头实时视频 -->
+    <!-- 关联视频设备实时画面 -->
     <el-dialog v-model="playerVisible" :title="playerTitle" width="760px" append-to-body destroy-on-close>
       <div class="player-wrap">
         <DrainageLiveTile v-if="playerVideoId" :key="playerVideoId" :video-id="playerVideoId" />
       </div>
       <div v-if="playerOptions.length > 1" class="player-switch">
-        <span class="text-muted">该点位共 {{ playerOptions.length }} 个摄像头:</span>
+        <span class="text-muted">该点位共 {{ playerOptions.length }} 台视频设备:</span>
         <el-button v-for="cam in playerOptions" :key="cam.videoId" size="small"
           :type="cam.videoId === playerVideoId ? 'primary' : ''" @click="playerVideoId = cam.videoId">
           {{ cam.deviceName }}
@@ -382,24 +340,24 @@ import { Aim, Refresh } from '@element-plus/icons-vue'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import DrainageLiveTile from '@/views/subSystem/drainage/spjc/DrainageLiveTile.vue'
 import { useDict } from '@/utils/dict'
-import { getDrainageVideoMap } from '@/api/drainageVideo'
+import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
 import { deptTreeSelect, enabledAll } from '@/api/system/user'
 import {
   getWaterloggingPage,
   getWaterloggingMap,
+  getWaterloggingList,
   getWaterloggingDetail,
   addWaterlogging,
   updateWaterlogging,
   deleteWaterlogging,
   batchDeleteWaterlogging,
-  bindWaterloggingCameras
+  bindWaterloggingDevices
 } from '@/api/drainage'
 
 const {
-  drainage_waterlogging_level: levelDict,
   drainage_waterlogging_risk_level: riskDict,
   drainage_district: districtDict
-} = useDict('drainage_waterlogging_level', 'drainage_waterlogging_risk_level', 'drainage_district')
+} = useDict('drainage_waterlogging_risk_level', 'drainage_district')
 
 /** 所属片区兜底(字典未配置时页面仍可用),与 DistrictEnum 的 0城南/1城北/2太常 对齐 */
 const DISTRICT_FALLBACK = [
@@ -411,15 +369,6 @@ const districtOptions = computed(() =>
   districtDict.value && districtDict.value.length ? districtDict.value : DISTRICT_FALLBACK
 )
 
-/** 积水程度兜底(字典未配置时页面仍可用) */
-const LEVEL_FALLBACK = [
-  { value: '0', label: '未积水' },
-  { value: '1', label: '轻度积水' },
-  { value: '2', label: '中度积水' },
-  { value: '3', label: '严重积水' }
-]
-const levelOptions = computed(() => (levelDict.value && levelDict.value.length ? levelDict.value : LEVEL_FALLBACK))
-
 /** 风险等级兜底 */
 const RISK_FALLBACK = [
   { value: '0', label: '低风险' },
@@ -428,15 +377,16 @@ const RISK_FALLBACK = [
 ]
 const riskOptions = computed(() => (riskDict.value && riskDict.value.length ? riskDict.value : RISK_FALLBACK))
 
-/** 图例色块,与列表标签语义保持一致 */
-const LEVEL_COLORS = { '0': '#67c23a', '1': '#409eff', '2': '#e6a23c', '3': '#f56c6c' }
+/** 易积水点只允许关联这两类设备(EquipmentTypeEnum.16 雷达液位计 / 20 视频监控-排口+内涝点) */
+const DEVICE_TYPE_RADAR_LEVEL = '16'
+const DEVICE_TYPE_VIDEO = '20'
+const ALLOWED_DEVICE_TYPE_IDS = [DEVICE_TYPE_RADAR_LEVEL, DEVICE_TYPE_VIDEO]
 
 // ==================== 查询条件 ====================
 const emptyQuery = () => ({
   pointCode: '',
   pointName: '',
   district: '',
-  waterloggingLevel: null,
   riskLevel: ''
 })
 const query = ref(emptyQuery())
@@ -455,37 +405,107 @@ const selectedIds = ref([])
 const mapRecords = ref([])
 const mapRef = ref(null)
 
-// ==================== 摄像头资源 ====================
-/** 全部视频设备,用于把 cameraIds 翻译成名称、以及关联弹窗候选 */
-const videoResources = ref([])
-const videoMap = ref({})
+// ==================== 设备资源 ====================
+/** 可关联设备台账(限 16 雷达液位计 / 20 视频监控),用于翻译名称与关联弹窗候选 */
+const deviceResources = ref([])
 
-async function loadVideoResources() {
+async function loadDevices() {
+  const map = new Map()
   try {
-    const res = await getDrainageVideoMap()
-    const data = res && res.code !== undefined ? (res.data || []) : (res || [])
-    videoResources.value = Array.isArray(data) ? data : []
-    const map = {}
-    videoResources.value.forEach((item) => {
-      map[String(item.videoId)] = item
-    })
-    videoMap.value = map
+    for (let pageNum = 1; pageNum <= 5; pageNum++) {
+      const res = await listEquipment({
+        pageNum,
+        pageSize: 500,
+        rootTypeId: 2,
+        equipmentTypeIds: ALLOWED_DEVICE_TYPE_IDS.join(',')
+      })
+      const page = res?.data && Array.isArray(res.data.records) ? res.data : res
+      const rows = page?.records || page?.rows || []
+      if (!rows.length) break
+      rows.forEach((item) => {
+        const id = item?.equipmentId
+        if (id !== undefined && id !== null && !map.has(String(id))) map.set(String(id), item)
+      })
+      if (rows.length < 500) break
+    }
   } catch (error) {
-    // 摄像头资源拿不到不影响点位本身展示,只是名称退化为 ID
-    console.warn('视频资源加载失败', error)
-    videoResources.value = []
-    videoMap.value = {}
+    // 设备台账拿不到不影响点位本身展示,只是名称退化为 ID
+    console.warn('设备台账加载失败', error)
   }
+  deviceResources.value = [...map.values()]
 }
 
-function parseCameraIds(value) {
+const deviceMap = computed(() => {
+  const map = {}
+  deviceResources.value.forEach((item) => {
+    map[String(item.equipmentId)] = item
+  })
+  return map
+})
+
+function parseDeviceIds(value) {
   if (!value) return []
   return String(value).split(',').map((id) => id.trim()).filter(Boolean)
 }
 
-/** 某点位关联的摄像头对象列表(找不到资源时用 ID 兜底) */
-function cameraListOf(row) {
-  return parseCameraIds(row?.cameraIds).map((id) => videoMap.value[id] || { videoId: id, deviceName: id })
+/** 某点位关联的设备列表(台账里找不到时用 ID 兜底) */
+function deviceListOf(row) {
+  return parseDeviceIds(row?.equipmentIds).map((id) => deviceMap.value[id] || {
+    equipmentId: id,
+    equipmentName: id,
+    equipmentCode: id
+  })
+}
+
+function isVideoDevice(device) {
+  return String(device?.equipmentTypeId) === DEVICE_TYPE_VIDEO
+}
+
+/** 只有视频监控设备能看实时画面 */
+function videoDevicesOf(row) {
+  return deviceListOf(row).filter((device) => isVideoDevice(device))
+}
+
+function deviceTypeLabel(device) {
+  if (device?.equipmentTypeName) return device.equipmentTypeName
+  const typeId = String(device?.equipmentTypeId || '')
+  if (typeId === DEVICE_TYPE_RADAR_LEVEL) return '雷达液位计'
+  if (typeId === DEVICE_TYPE_VIDEO) return '视频监控'
+  return '-'
+}
+
+// ==================== 设备占用情况 ====================
+/**
+ * 设备 → 关联点位映射
+ *
+ * 一台设备只能挂在一个易积水点上(后端也会校验)。这里提前把占用关系取出来,
+ * 让「已关联其他点位」的设备在弹窗里不可选,避免选了才被挡回来。
+ */
+const pointOccupations = ref({})
+
+async function loadPointOccupations() {
+  try {
+    const res = await getWaterloggingList()
+    const list = res && res.code !== undefined ? (res.data || []) : (res || [])
+    const map = {}
+    ;(Array.isArray(list) ? list : []).forEach((point) => {
+      parseDeviceIds(point?.equipmentIds).forEach((id) => {
+        map[id] = { pointId: point.pointId, pointName: point.pointName }
+      })
+    })
+    pointOccupations.value = map
+  } catch (error) {
+    console.warn('易积水点设备占用关系加载失败', error)
+    pointOccupations.value = {}
+  }
+}
+
+function deviceOccupation(row) {
+  return pointOccupations.value[String(row?.equipmentId)]?.pointId || ''
+}
+
+function deviceOccupationName(row) {
+  return pointOccupations.value[String(row?.equipmentId)]?.pointName || '其他点位'
 }
 
 // ==================== 详情 ====================
@@ -493,7 +513,7 @@ const detailVisible = ref(false)
 const detailRow = ref({})
 const detailMapRef = ref(null)
 
-const detailCameras = computed(() => cameraListOf(detailRow.value))
+const detailDevices = computed(() => deviceListOf(detailRow.value))
 
 // ==================== 视频播放 ====================
 const playerVisible = ref(false)
@@ -501,27 +521,34 @@ const playerVideoId = ref('')
 const playerTitle = ref('')
 const playerOptions = ref([])
 
-function playVideo(camera) {
-  playerOptions.value = [camera]
-  playerVideoId.value = camera.videoId
-  playerTitle.value = camera.deviceName || '实时视频'
+/** 视频设备的 equipment_id 即视频模块的 video_id */
+function toVideoOption(device) {
+  return { videoId: String(device.equipmentId), deviceName: device.equipmentName || device.equipmentCode }
+}
+
+function playDevice(device) {
+  const option = toVideoOption(device)
+  playerOptions.value = [option]
+  playerVideoId.value = option.videoId
+  playerTitle.value = option.deviceName || '实时视频'
   playerVisible.value = true
 }
 
-/** 列表/地图点击摄像头入口:有多个时弹窗内切换 */
-function playCameras(row) {
-  const cameras = cameraListOf(row)
-  if (!cameras.length) {
-    ElMessage.warning('该点位尚未关联摄像头')
+/** 列表/地图入口:该点位的视频设备有多个时弹窗内切换 */
+function playDevices(row) {
+  const videos = videoDevicesOf(row)
+  if (!videos.length) {
+    ElMessage.warning('该点位尚未关联视频监控设备')
     return
   }
-  playerOptions.value = cameras
-  playerVideoId.value = cameras[0].videoId
+  const options = videos.map(toVideoOption)
+  playerOptions.value = options
+  playerVideoId.value = options[0].videoId
   playerTitle.value = `${row.pointName || '易积水点'} · 实时视频`
   playerVisible.value = true
 }
 
-// ==================== 关联摄像头 ====================
+// ==================== 关联设备 ====================
 const bindVisible = ref(false)
 const bindLoading = ref(false)
 const bindSubmitting = ref(false)
@@ -532,21 +559,22 @@ const bindTableRef = ref(null)
 
 const bindCandidates = computed(() => {
   const keyword = bindKeyword.value.trim().toLowerCase()
-  if (!keyword) return videoResources.value
-  return videoResources.value.filter((item) =>
-    `${item.deviceName || ''} ${item.deviceCode || ''}`.toLowerCase().includes(keyword)
+  if (!keyword) return deviceResources.value
+  return deviceResources.value.filter((item) =>
+    `${item.equipmentName || ''} ${item.equipmentCode || ''}`.toLowerCase().includes(keyword)
   )
 })
 
-function isCameraSelectable(row) {
-  // 只允许选择「未关联」或「已关联到当前点位」的摄像头
-  return !row.pointId || row.pointId === bindRow.value.pointId
+function isDeviceSelectable(row) {
+  // 只允许选择「未关联」或「已关联到当前点位」的设备
+  const occupiedPointId = deviceOccupation(row)
+  return !occupiedPointId || occupiedPointId === bindRow.value.pointId
 }
 
 function openBind(row) {
   bindRow.value = { ...row }
   bindKeyword.value = ''
-  bindSelected.value = parseCameraIds(row.cameraIds)
+  bindSelected.value = parseDeviceIds(row.equipmentIds)
   bindVisible.value = true
   nextTick(() => {
     syncBindSelection()
@@ -557,31 +585,31 @@ function syncBindSelection() {
   if (!bindTableRef.value) return
   bindTableRef.value.clearSelection()
   bindCandidates.value.forEach((item) => {
-    if (bindSelected.value.includes(String(item.videoId))) {
+    if (bindSelected.value.includes(String(item.equipmentId))) {
       bindTableRef.value.toggleRowSelection(item, true)
     }
   })
 }
 
 function handleBindSelectionChange(selection) {
-  bindSelected.value = selection.map((item) => String(item.videoId))
+  bindSelected.value = selection.map((item) => String(item.equipmentId))
 }
 
 async function submitBind() {
   if (!bindRow.value.pointId) return
   bindSubmitting.value = true
   try {
-    const res = await bindWaterloggingCameras(bindRow.value.pointId, bindSelected.value)
+    const res = await bindWaterloggingDevices(bindRow.value.pointId, bindSelected.value)
     if (res && (res.code === 0 || res.code === 200)) {
       ElMessage.success('关联成功')
       bindVisible.value = false
-      await Promise.all([loadAll(), loadVideoResources()])
+      await Promise.all([loadAll(), loadPointOccupations()])
     } else {
       ElMessage.error(res?.msg || '关联失败')
     }
   } catch (error) {
-    console.error('关联摄像头失败', error)
-    ElMessage.error('关联失败')
+    console.error('关联设备失败', error)
+    ElMessage.error(error?.response?.data?.msg || '关联失败')
   } finally {
     bindSubmitting.value = false
   }
@@ -672,24 +700,13 @@ const mapPoints = computed(() =>
         id: row.pointId,
         name: row.pointName || row.pointCode || '积水点',
         ...coord,
-        statusKey: levelStatusKey(row.waterloggingLevel),
+        statusKey: 'normal',
         raw: row
       }
     })
     .filter(Boolean)
 )
 
-/** 积水程度 → 地图图标状态(复用 gisMapColors 的状态色) */
-const LEVEL_STATUS_KEY = { '0': 'normal', '1': 'workOrder', '2': 'warning', '3': 'alarm' }
-
-function levelStatusKey(level) {
-  return LEVEL_STATUS_KEY[String(level)] || 'normal'
-}
-
-function levelColor(level) {
-  return LEVEL_COLORS[String(level)] || '#909399'
-}
-
 function fitMap() {
   mapRef.value?.fitBounds()
 }
@@ -709,10 +726,10 @@ watch(
 function handleMapPointClick(point) {
   const row = point?.raw || point
   if (!row) return
-  if (cameraListOf(row).length) {
-    playCameras(row)
+  if (videoDevicesOf(row).length) {
+    playDevices(row)
   } else {
-    ElMessage.info('该点位尚未关联摄像头')
+    ElMessage.info('该点位尚未关联视频监控设备')
     openDetail(row)
   }
 }
@@ -725,7 +742,7 @@ const detailPoints = computed(() => {
     id: detailRow.value.pointId,
     name: detailRow.value.pointName || '积水点',
     ...coord,
-    statusKey: levelStatusKey(detailRow.value.waterloggingLevel)
+    statusKey: 'normal'
   }]
 })
 
@@ -771,10 +788,6 @@ const emptyForm = () => ({
   location: '',
   longitude: null,
   latitude: null,
-  waterloggingLevel: 0,
-  waterDepth: null,
-  waterVolume: null,
-  waterArea: null,
   riskLevel: '',
   dutyDeptId: null,
   dutyUnit: '',
@@ -787,8 +800,7 @@ const form = ref(emptyForm())
 
 const formRules = {
   pointCode: [{ required: true, message: '请输入点位编码', trigger: 'blur' }],
-  pointName: [{ required: true, message: '请输入点位名称', trigger: 'blur' }],
-  waterloggingLevel: [{ required: true, message: '请选择积水程度', trigger: 'change' }]
+  pointName: [{ required: true, message: '请输入点位名称', trigger: 'blur' }]
 }
 
 // ==================== 责任单位 / 责任人候选 ====================
@@ -924,7 +936,7 @@ const formPoints = computed(() => {
     id: 'form',
     name: form.value.pointName || '待保存点位',
     ...coord,
-    statusKey: levelStatusKey(form.value.waterloggingLevel)
+    statusKey: 'normal'
   }]
 })
 
@@ -941,13 +953,8 @@ function handleEdit(row) {
   form.value = {
     ...emptyForm(),
     ...row,
-    waterloggingLevel: row.waterloggingLevel === null || row.waterloggingLevel === undefined
-      ? 0 : Number(row.waterloggingLevel),
     longitude: row.longitude === null || row.longitude === undefined ? null : Number(row.longitude),
     latitude: row.latitude === null || row.latitude === undefined ? null : Number(row.latitude),
-    waterDepth: row.waterDepth === null || row.waterDepth === undefined ? null : Number(row.waterDepth),
-    waterVolume: row.waterVolume === null || row.waterVolume === undefined ? null : Number(row.waterVolume),
-    waterArea: row.waterArea === null || row.waterArea === undefined ? null : Number(row.waterArea),
     // 部门/人员 ID 是 bigint,下拉的 value 需保持数字类型,否则回显不上
     dutyDeptId: row.dutyDeptId === null || row.dutyDeptId === undefined ? null : Number(row.dutyDeptId),
     dutyUserId: row.dutyUserId === null || row.dutyUserId === undefined ? null : Number(row.dutyUserId)
@@ -994,10 +1001,6 @@ function buildPayload() {
     location: source.location || null,
     longitude: source.longitude,
     latitude: source.latitude,
-    waterloggingLevel: source.waterloggingLevel,
-    waterDepth: source.waterDepth,
-    waterVolume: source.waterVolume,
-    waterArea: source.waterArea,
     riskLevel: source.riskLevel || null,
     dutyDeptId: source.dutyDeptId || null,
     dutyUnit: source.dutyUnit || null,
@@ -1098,20 +1101,6 @@ function districtLabel(value) {
   return dictItem(districtDict, value)?.label || fallback?.label || String(value)
 }
 
-function levelLabel(value) {
-  if (value === null || value === undefined || value === '') return '-'
-  const fallback = LEVEL_FALLBACK.find((item) => item.value === String(value))
-  return dictItem(levelDict, value)?.label || fallback?.label || '-'
-}
-
-function levelTagType(value) {
-  const item = dictItem(levelDict, value)
-  const listClass = item?.listClass || item?.elTagType || ''
-  if (['success', 'warning', 'danger', 'info'].includes(listClass)) return listClass
-  const fallback = { '0': 'success', '1': 'info', '2': 'warning', '3': 'danger' }
-  return fallback[String(value)] || 'info'
-}
-
 function riskLabel(value) {
   if (value === null || value === undefined || value === '') return '-'
   const fallback = RISK_FALLBACK.find((item) => item.value === String(value))
@@ -1126,25 +1115,14 @@ function riskTagType(value) {
   return fallback[String(value)] || 'info'
 }
 
-function formatVolume(value) {
-  if (value === null || value === undefined || value === '') return '--'
-  const num = Number(value)
-  return Number.isFinite(num) ? num.toFixed(2) : '--'
-}
-
-function formatNumber(value) {
-  if (value === null || value === undefined || value === '') return '--'
-  const num = Number(value)
-  return Number.isFinite(num) ? String(num) : '--'
-}
-
 function fmt(value) {
   if (value === null || value === undefined || value === '') return '-'
   return value
 }
 
 onMounted(() => {
-  loadVideoResources()
+  loadDevices()
+  loadPointOccupations()
   loadDutyResources()
   loadAll()
 })

+ 362 - 0
src/views/subSystem/drainage/jcyj/components/WarningGisWorkspace.vue

@@ -0,0 +1,362 @@
+<template>
+  <div class="app-container warning-gis-page">
+    <header class="gis-page-heading">
+      <div>
+        <h1>{{ route.meta.title || (isHistory ? '历史预警GIS一张图' : '当前预警GIS一张图') }}</h1>
+        <p>{{ isHistory ? '回溯所有已解除预警的分布,按设备查看归档详情' : '按设备查看预警分布,关联预警详情与处置信息' }}</p>
+      </div>
+    </header>
+
+    <section class="gis-filter-card">
+      <el-form class="gis-filters" label-position="left" label-width="72px" @submit.prevent="load">
+        <el-form-item label="设备搜索" class="gis-search-field">
+          <el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="请输入设备名称" @keyup.enter="load" />
+        </el-form-item>
+        <el-form-item label="预警等级">
+          <el-select v-model="level" clearable placeholder="全部等级">
+            <el-option v-for="item in warningLevels" :key="item.value" :value="item.value" :label="item.label" />
+          </el-select>
+        </el-form-item>
+        <div class="gis-filter-actions">
+          <el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button>
+          <el-button :icon="Refresh" @click="reset">重置</el-button>
+        </div>
+      </el-form>
+    </section>
+
+    <div class="gis-level-metrics" aria-label="当前查询结果的设备等级统计" :aria-busy="loading">
+      <article v-for="item in levelMetrics" :key="item.value" class="gis-level-metric" :style="levelStyle(item.value)">
+        <span class="gis-grade-badge">{{ item.value }}</span>
+        <div class="gis-metric-label">{{ item.label }}预警</div>
+        <div class="gis-metric-value"><span>预警设备</span><strong>{{ loading || error ? '—' : item.count }}<small>台</small></strong></div>
+      </article>
+    </div>
+    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="load-error">
+      <el-button link @click="load">重新加载</el-button>
+    </el-alert>
+
+    <section class="gis-workspace">
+      <header class="gis-workspace-heading">
+        <div>
+          <el-icon><MapLocation /></el-icon>
+          <h2>预警设备分布</h2>
+          <span>{{ devices.length }} 台设备 / {{ rows.length }} 条预警</span>
+        </div>
+      </header>
+      <div class="gis-workspace-body">
+        <aside class="gis-device-pane" aria-label="预警设备列表" v-loading="loading">
+          <div class="gis-list-heading"><span>筛选结果 <b>{{ devices.length }}</b> 台</span><span>等级优先</span></div>
+          <div class="gis-device-list">
+            <article v-for="device in devices" :key="device.key" :class="['gis-device-item', { 'is-selected': selected?.key === device.key }]" :style="levelStyle(device.warningLevel)">
+              <div class="gis-device-title">
+                <span class="gis-grade-badge">{{ device.warningLevel }}</span>
+                <button type="button" class="gis-device-name" @click="choose(device)">{{ device.name }}</button>
+              </div>
+              <p class="gis-device-code">{{ device.warnings[0]?.warningNo || '暂无预警单号' }}</p>
+              <div class="gis-device-location"><el-icon><Location /></el-icon><span>{{ device.location || '暂无位置信息' }}</span></div>
+              <div class="gis-device-meta"><span>{{ levelLabel(device.warningLevel) }}</span><span>{{ device.warnings.length }} 条预警</span></div>
+              <div class="gis-device-actions">
+                <span v-if="!device.hasCoordinates" class="gis-coordinate-warning">缺少有效坐标</span>
+                <span v-else class="gis-coordinate-ready"><i></i>已关联点位</span>
+                <div>
+                  <el-button link type="primary" :disabled="!device.hasCoordinates || !mapReady" @click="locate(device)">定位</el-button>
+                  <el-button link type="primary" @click="choose(device)">详情<el-icon><ArrowRight /></el-icon></el-button>
+                </div>
+              </div>
+            </article>
+            <el-empty v-if="!devices.length && !loading" :description="emptyDescription" :image-size="72">
+              <el-button v-if="!error" @click="reset">重置筛选</el-button>
+            </el-empty>
+          </div>
+        </aside>
+
+        <div class="gis-map-stage">
+          <GisMapCanvas :key="mapKey" ref="mapRef" :points="points" :marker-size="36" @ready="ready" @error="failMap" @point-click="choose" @cluster-click="cluster = $event.points || []" />
+          <div class="gis-map-status" role="status"><i :class="{ 'is-ready': mapReady, 'is-error': mapError }"></i>{{ mapError ? '底图未就绪 · 列表可用' : mapReady ? '地图已就绪' : '正在加载底图' }}</div>
+          <div class="gis-map-tools" aria-label="地图工具">
+            <el-tooltip content="查看全部点位" placement="bottom"><el-button :icon="Aim" :disabled="!mapReady || !points.length" aria-label="查看全部点位" @click="mapRef?.fitBounds()" /></el-tooltip>
+            <el-tooltip content="放大地图" placement="bottom"><el-button :icon="Plus" :disabled="!mapReady" aria-label="放大地图" @click="mapRef?.zoomIn()" /></el-tooltip>
+            <el-tooltip content="缩小地图" placement="bottom"><el-button :icon="Minus" :disabled="!mapReady" aria-label="缩小地图" @click="mapRef?.zoomOut()" /></el-tooltip>
+          </div>
+          <div v-if="!mapReady" class="gis-map-fallback">
+            <span class="gis-fallback-icon"><el-icon :class="{ 'is-loading': !mapError }"><Warning v-if="mapError" /><Loading v-else /></el-icon></span>
+            <h3>{{ mapError ? '底图暂时无法加载' : '正在连接地图服务' }}</h3>
+            <p>{{ mapError || '地图加载中,设备列表与详情可继续查看。' }}</p>
+            <el-button v-if="mapError" type="primary" :icon="Refresh" @click="retryMap">重试底图</el-button>
+            <span v-if="mapError" class="gis-fallback-note">预警记录与设备详情仍可独立查看</span>
+          </div>
+          <div class="gis-map-bottom">
+            <div class="gis-map-legend" aria-label="预警等级图例">
+              <span v-for="item in warningLevels" :key="item.value" :style="levelStyle(item.value)"><i></i>{{ item.label }}</span>
+            </div>
+            <span v-if="missingCoordinates" class="gis-map-note">{{ missingCoordinates }} 台设备缺少有效坐标</span>
+          </div>
+        </div>
+      </div>
+      <footer class="gis-workspace-footer">
+        <span><el-icon><Location /></el-icon>{{ selected ? '已选中:' + selected.name : '选择设备查看详情,点击定位查看所在位置' }}</span>
+        <span>点位数字代表预警等级</span>
+      </footer>
+    </section>
+
+    <el-drawer v-model="detailVisible" size="min(680px, 100vw)" append-to-body destroy-on-close class="warning-gis-drawer">
+      <template #header>
+        <div class="gis-drawer-heading"><span>设备详情</span><h2>{{ selected?.name }}</h2></div>
+      </template>
+      <div v-if="selected" class="gis-detail-content">
+        <div class="gis-detail-banner">
+          <span class="gis-detail-grade" :style="levelStyle(selected.warningLevel)"><span class="gis-grade-badge">{{ selected.warningLevel }}</span>{{ levelLabel(selected.warningLevel) }}</span>
+          <span>{{ selected.warnings.length }} 条预警 · {{ isHistory ? '已解除归档' : '当前查询结果' }}</span>
+        </div>
+        <dl class="gis-detail-grid">
+          <div><dt>最新预警单号</dt><dd>{{ selected.warnings[0]?.warningNo || '—' }}</dd></div>
+          <div><dt>权属单位</dt><dd>{{ selected.ownershipUnit || '—' }}</dd></div>
+          <div class="is-wide"><dt>设备位置</dt><dd>{{ selected.location || '—' }}</dd></div>
+        </dl>
+        <div v-if="latestWarning" class="gis-snapshot">
+          <div><span>最新预警 · {{ formatDateTime(latestWarning.createTime) }}</span><p>{{ latestWarning.content || '—' }}</p></div>
+        </div>
+
+        <el-tabs v-model="tab" class="gis-detail-tabs">
+          <el-tab-pane label="预警记录" name="records">
+            <div class="gis-panel-heading"><h3>本设备预警记录</h3><span>共 {{ selected.warnings.length }} 条 · 时间倒序</span></div>
+            <div class="gis-record-list">
+              <article v-for="row in selected.warnings" :key="row.id" class="gis-history-item" :style="levelStyle(row.warningLevel)">
+                <div class="gis-history-heading">
+                  <el-tag size="small" effect="light" :type="levelTone(row.warningLevel)">{{ levelLabel(row.warningLevel) }}</el-tag>
+                  <el-tag size="small" effect="light" :type="statusTone(row.status)">{{ statusText(row.status) }}</el-tag>
+                </div>
+                <p class="gis-history-description">{{ row.content || '—' }}</p>
+                <dl>
+                  <dt>预警单号</dt><dd>{{ row.warningNo || '—' }}</dd>
+                  <dt>预警时间</dt><dd>{{ formatDateTime(row.createTime) }}</dd>
+                  <dt>预警类型</dt><dd>{{ warningTypeText(row) }}</dd>
+                </dl>
+                <div class="gis-history-actions">
+                  <el-button link type="primary" @click="openWarningDetail(row)">预警详情</el-button>
+                </div>
+              </article>
+              <el-empty v-if="!selected.warnings.length" description="暂无预警记录" :image-size="60" />
+            </div>
+          </el-tab-pane>
+          <el-tab-pane label="设备信息" name="device">
+            <div class="gis-panel-heading"><h3>设备档案</h3><span>按预警记录汇总</span></div>
+            <dl class="gis-detail-grid">
+              <div><dt>设备名称</dt><dd>{{ selected.name }}</dd></div>
+              <div><dt>权属单位</dt><dd>{{ selected.ownershipUnit || '—' }}</dd></div>
+              <div><dt>设备位置</dt><dd>{{ selected.location || '—' }}</dd></div>
+              <div><dt>涉及预警</dt><dd>{{ selected.warnings.length }} 条</dd></div>
+              <div><dt>经度</dt><dd>{{ selected.hasCoordinates ? selected.lng : '暂无有效坐标' }}</dd></div>
+              <div><dt>纬度</dt><dd>{{ selected.hasCoordinates ? selected.lat : '暂无有效坐标' }}</dd></div>
+            </dl>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+      <template #footer>
+        <div class="gis-drawer-footer"><el-button @click="detailVisible = false">关闭</el-button></div>
+      </template>
+    </el-drawer>
+
+    <el-dialog :model-value="cluster.length > 0" title="聚合点位" width="min(440px, 94vw)" @close="cluster = []">
+      <div class="gis-cluster-list">
+        <button v-for="device in cluster" :key="device.key" type="button" @click="choose(device); cluster = []">
+          <span>{{ device.name }}</span><small>{{ device.warnings.length }} 条预警</small><el-icon><ArrowRight /></el-icon>
+        </button>
+      </div>
+    </el-dialog>
+
+    <WarningGisDetail v-model="warningDetailVisible" :record="activeWarning" :mode="mode" />
+  </div>
+</template>
+
+<script setup name="WarningGisWorkspace">
+import { ref, computed, nextTick, onBeforeUnmount, onMounted } from 'vue'
+import { Search, Refresh, MapLocation, Location, Aim, Plus, Minus, ArrowRight, Warning, Loading } from '@element-plus/icons-vue'
+import { useRoute } from 'vue-router'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getWarningCurrentPage, getWarningHistoryPage } from '@/api/drainage'
+import { listWarningTypes } from '@/api/warning/list'
+import useUserStore from '@/store/modules/user'
+import WarningGisDetail from './WarningGisDetail.vue'
+
+/**
+ * 排水预警 GIS 一张图(当前预警 / 历史预警 复用同一页面)
+ * mode=current:所有未解除预警(后端 currentOnly,status IN DRAFT/PENDING/RECEIVED/PROCESSING)
+ * mode=history:所有已解除预警(后端 historyOnly,status IN HANDLED/CLOSED)
+ * 版式参考 subSystem/alarm/common/alarmGIS.vue,文案与数据源均替换为 early_warning。
+ */
+const props = defineProps({
+  mode: { type: String, default: 'current' }
+})
+
+const isHistory = computed(() => props.mode === 'history')
+const route = useRoute()
+const userStore = useUserStore()
+const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
+
+// ==================== 等级 / 状态口径 ====================
+const warningLevels = [
+  { value: 1, label: '严重' },
+  { value: 2, label: '重要' },
+  { value: 3, label: '一般' }
+]
+const levelColor = level => ({ 1: '#f56c6c', 2: '#e6a23c', 3: '#409eff' }[Number(level)] || '#909399')
+const levelTone = level => ({ 1: 'danger', 2: 'warning', 3: 'primary' }[Number(level)] || 'info')
+const statusText = status => ({ DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }[status] ?? '-')
+const statusTone = status => ({ DRAFT: 'info', PENDING: 'warning', RECEIVED: 'warning', PROCESSING: 'warning', HANDLED: 'success', CLOSED: '' }[status] || 'info')
+const levelLabel = value => warningLevels.find(item => Number(item.value) === Number(value))?.label || '未知等级'
+const levelStyle = value => ({ '--grade-color': levelColor(value), '--grade-tint': levelColor(value) + '14' })
+
+// ==================== 预警类型 ====================
+/** warningType(类型编码) -> 类型名称,来自 warning_type 配置(与预警清单页同一份口径) */
+const warningTypeMap = ref(new Map())
+async function loadWarningTypes() {
+  try {
+    const res = await listWarningTypes()
+    const list = res?.data || res?.rows || []
+    if (!Array.isArray(list)) return
+    const map = new Map()
+    list.forEach(item => {
+      if (item?.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
+    })
+    warningTypeMap.value = map
+  } catch (error) {
+    console.warn('加载预警类型配置失败', error)
+  }
+}
+function warningTypeText(row) {
+  if (!row) return '-'
+  return (row.warningType && warningTypeMap.value.get(String(row.warningType))) || row.warningType || '-'
+}
+
+// ==================== 数据 ====================
+const keyword = ref('')
+const level = ref()
+const rows = ref([])
+const selected = ref(null)
+const tab = ref('records')
+const loading = ref(false), error = ref(''), mapError = ref(''), mapReady = ref(false), mapKey = ref(0)
+const mapRef = ref(), cluster = ref([])
+const detailVisible = ref(false)
+const warningDetailVisible = ref(false), activeWarning = ref(null)
+
+function formatDateTime(value) {
+  if (!value) return '—'
+  return String(value).replace('T', ' ').slice(0, 19)
+}
+
+/** early_warning 无设备主键列,按设备名称(缺失时退回预警单号)聚合 */
+function groupWarningDevices(list = []) {
+  const devices = new Map()
+  for (const row of list) {
+    const key = row.equipmentName || row.warningNo
+    if (!key) continue
+    if (!devices.has(key)) {
+      devices.set(key, {
+        key,
+        name: row.equipmentName || '未命名预警',
+        location: row.location || '',
+        ownershipUnit: row.ownershipUnit || '',
+        warnings: []
+      })
+    }
+    const device = devices.get(key)
+    device.warnings.push(row)
+    if (!device.location && row.location) device.location = row.location
+    if (!device.ownershipUnit && row.ownershipUnit) device.ownershipUnit = row.ownershipUnit
+  }
+  return [...devices.values()].map(device => {
+    device.warnings.sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
+    // 设备等级取名下最严重的一条(数字越小越严重)
+    device.warningLevel = Math.min(...device.warnings.map(row => Number(row.warningLevel) || 3))
+    const located = device.warnings.find(row => {
+      const lng = Number(row.longitude)
+      const lat = Number(row.latitude)
+      return Number.isFinite(lng) && Number.isFinite(lat) && Math.abs(lng) <= 180 && Math.abs(lat) <= 90 && !(lng === 0 && lat === 0)
+    })
+    device.lng = located ? Number(located.longitude) : NaN
+    device.lat = located ? Number(located.latitude) : NaN
+    device.hasCoordinates = Number.isFinite(device.lng) && Number.isFinite(device.lat)
+    device.iconUrl = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44"><path d="M18 42C15 35 2 26 2 18a16 16 0 1 1 32 0c0 8-13 17-16 24Z" fill="${levelColor(device.warningLevel)}" stroke="white" stroke-width="2"/><text x="18" y="24" text-anchor="middle" fill="white" font-size="18">${device.warningLevel}</text></svg>`)
+    return device
+  })
+}
+
+const devices = computed(() => groupWarningDevices(rows.value)
+  .sort((a, b) => Number(a.warningLevel) - Number(b.warningLevel) || String(a.name).localeCompare(String(b.name))))
+const points = computed(() => devices.value.filter(device => device.hasCoordinates))
+const missingCoordinates = computed(() => devices.value.length - points.value.length)
+const levelMetrics = computed(() => warningLevels.map(item => ({ ...item, count: devices.value.filter(device => Number(device.warningLevel) === Number(item.value)).length })))
+const latestWarning = computed(() => selected.value?.warnings[0])
+const emptyDescription = computed(() => error.value ? '设备列表加载失败' : (isHistory.value ? '暂无已解除的预警' : '暂无匹配的预警设备'))
+
+function buildParams() {
+  const params = { equipmentType: 'drainage' }
+  if (isHistory.value) {
+    // 历史接口按已处置/已解除限定数据范围
+    params.historyOnly = true
+  } else {
+    // 当前接口按未解除限定数据范围;普通用户只能看到发给自己的预警
+    params.currentOnly = true
+    params.userId = userStore.id
+    params.isAdmin = isAdmin.value
+    if (userStore.deptId) params.deptId = userStore.deptId
+  }
+  if (keyword.value) params.equipmentName = keyword.value.trim()
+  if (level.value) params.warningLevel = String(level.value)
+  return params
+}
+
+let generation = 0
+async function load() {
+  const token = ++generation
+  loading.value = true
+  error.value = ''
+  detailVisible.value = false
+  selected.value = null
+  cluster.value = []
+  try {
+    const res = isHistory.value
+      ? await getWarningHistoryPage(1, 1000, buildParams())
+      : await getWarningCurrentPage(1, 1000, buildParams())
+    if (token === generation) {
+      const pageData = res.code !== undefined ? res.data : res
+      rows.value = pageData.records || []
+      await nextTick()
+      mapRef.value?.fitBounds()
+    }
+  } catch (e) {
+    if (token === generation) { error.value = e.message || '点位加载失败'; rows.value = [] }
+  } finally {
+    if (token === generation) loading.value = false
+  }
+}
+
+// ==================== 交互 ====================
+function locate(device) {
+  selected.value = device
+  if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device)
+}
+function choose(device) {
+  selected.value = device
+  tab.value = 'records'
+  detailVisible.value = true
+  if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device)
+}
+function openWarningDetail(row) {
+  activeWarning.value = row
+  warningDetailVisible.value = true
+}
+function ready() { mapError.value = ''; mapReady.value = true; mapRef.value?.fitBounds() }
+function failMap(message) { mapReady.value = false; mapError.value = message || '请检查网络或地图服务配置,稍后重试。' }
+function retryMap() { mapError.value = ''; mapReady.value = false; ++mapKey.value }
+function reset() { keyword.value = ''; level.value = undefined; return load() }
+
+onMounted(async () => {
+  await loadWarningTypes()
+  load()
+})
+onBeforeUnmount(() => { ++generation })
+</script>
+
+<style scoped lang="scss" src="./warningGIS.scss"></style>

+ 640 - 0
src/views/subSystem/drainage/jcyj/components/WarningListPanel.vue

@@ -0,0 +1,640 @@
+<template>
+  <div class="app-container warning-list-page">
+    <!-- ==================== 顶部统计区 ==================== -->
+    <el-card shadow="never" class="page-card">
+      <div class="stats-panel">
+        <div class="stats-left">
+          <div class="stat-item" v-for="item in statItems" :key="item.label">
+            <div class="stat-value" :class="item.cls">{{ item.value }}</div>
+            <div class="stat-label">{{ item.label }}</div>
+          </div>
+        </div>
+        <div class="stats-center">
+          <div class="ring-chart-box" v-for="chart in ringCharts" :key="chart.id">
+            <div :id="chart.id" class="ring-chart"></div>
+            <div class="ring-legend">
+              <div class="legend-row" v-for="(leg, i) in chart.legends.value" :key="i">
+                <span class="legend-dot" :style="{ background: leg.color }"></span>
+                <span class="legend-text">{{ leg.name }}</span>
+                <span class="legend-num">{{ leg.value }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- ==================== 筛选区 ==================== -->
+    <el-card shadow="never" class="page-card">
+      <el-form ref="queryRef" :model="filters" :inline="true" label-width="80px">
+        <el-form-item label="预警名称">
+          <el-input v-model="filters.equipmentName" placeholder="请输入预警名称" clearable style="width: 200px"
+            @keyup.enter="handleSearch" />
+        </el-form-item>
+        <el-form-item label="预警类型">
+          <el-select v-model="filters.warningType" placeholder="请选择预警类型" clearable filterable style="width: 180px">
+            <el-option v-for="item in warningTypeOptions" :key="item.dictValue" :label="item.dictLabel"
+              :value="item.dictValue" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="预警级别">
+          <el-select v-model="filters.warningLevel" placeholder="请选择预警级别" clearable style="width: 180px">
+            <el-option label="严重" :value="1" />
+            <el-option label="重要" :value="2" />
+            <el-option label="一般" :value="3" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="filters.status" placeholder="请选择状态" clearable style="width: 180px">
+            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="预警时间">
+          <el-date-picker v-model="filters.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
+            end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 260px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="Search" @click="handleSearch">查询</el-button>
+          <el-button icon="Refresh" @click="handleClear">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- ==================== 数据表格 ==================== -->
+    <el-card shadow="never" class="page-card">
+      <template #header>
+        <div class="list-header">
+          <div class="section-title">{{ pageTitle }}</div>
+          <div class="list-actions">
+            <el-button v-if="isHistory" type="warning" plain icon="Download" :loading="exporting"
+              @click="handleExport">导出</el-button>
+            <el-button type="primary" plain icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-table :data="tableData" style="width: 100%" v-loading="loading" :empty-text="emptyText">
+        <el-table-column v-if="isHistory" label="归档编号" width="150" show-overflow-tooltip>
+          <template #default="{ row }">{{ archiveNoOf(row) }}</template>
+        </el-table-column>
+        <el-table-column prop="warningNo" label="预警单号" width="210" show-overflow-tooltip />
+        <el-table-column prop="equipmentName" label="预警名称" min-width="150" show-overflow-tooltip />
+        <el-table-column label="预警类型" width="120" show-overflow-tooltip>
+          <template #default="{ row }">{{ warningTypeText(row) }}</template>
+        </el-table-column>
+        <el-table-column label="预警级别" width="110" align="center">
+          <template #default="{ row }">
+            <el-tag :type="levelTagType(row.warningLevel)">{{ levelText(row.warningLevel) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="预警描述" min-width="240" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.content || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="ownershipUnit" label="权属单位" width="150" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.ownershipUnit || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="location" label="位置" min-width="160" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.location || '-' }}</template>
+        </el-table-column>
+        <el-table-column prop="publisher" label="发布人" width="120" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.publisher || '-' }}</template>
+        </el-table-column>
+        <el-table-column label="预警时间" width="180">
+          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
+        </el-table-column>
+        <el-table-column v-if="isHistory" label="处置人" width="120" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.processor || '-' }}</template>
+        </el-table-column>
+        <el-table-column v-if="isHistory" label="归档时间" width="180">
+          <template #default="{ row }">{{ formatDateTime(archiveTimeOf(row)) }}</template>
+        </el-table-column>
+        <el-table-column label="状态" width="110" align="center">
+          <template #default="{ row }">
+            <el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize"
+        :auto-scroll="false" @pagination="loadData" />
+    </el-card>
+  </div>
+</template>
+
+<script setup name="WarningListPanel">
+import { ref, reactive, computed, onMounted, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import * as echarts from 'echarts'
+import { getWarningCurrentPage, getWarningHistoryPage, getEquipmentTypeList } from '@/api/drainage'
+import { listWarningTypes } from '@/api/warning/list'
+import useUserStore from '@/store/modules/user'
+
+/**
+ * 排水预警列表(当前预警 / 历史预警 复用同一页面)
+ * mode=current:所有未解除预警(后端 currentOnly,status IN DRAFT/PENDING/RECEIVED/PROCESSING)
+ * mode=history:所有已解除预警(后端 historyOnly,status IN HANDLED/CLOSED)
+ */
+const props = defineProps({
+  mode: { type: String, default: 'current' }
+})
+
+const isHistory = computed(() => props.mode === 'history')
+const pageTitle = computed(() => (isHistory.value ? '历史预警' : '当前预警'))
+const emptyText = computed(() => (isHistory.value ? '暂无已解除的预警记录' : '暂无未解除的预警记录'))
+
+// ==================== 用户信息 ====================
+const userStore = useUserStore()
+const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
+
+// ==================== 表格数据 ====================
+const tableData = ref([])
+const total = ref(0)
+const loading = ref(false)
+const pageNum = ref(1)
+const pageSize = ref(10)
+
+// ==================== 等级/状态工具方法 ====================
+function levelText(level) {
+  const map = { 1: '严重', 2: '重要', 3: '一般' }
+  return map[level] || '-'
+}
+function levelTagType(level) {
+  const map = { 1: 'danger', 2: 'warning', 3: 'success' }
+  return map[level] || 'info'
+}
+function statusText(status) {
+  const map = { DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }
+  return map[status] ?? '-'
+}
+function statusTagType(status) {
+  const map = { DRAFT: 'info', PENDING: 'warning', RECEIVED: 'warning', PROCESSING: 'warning', HANDLED: 'success', CLOSED: '' }
+  return map[status] || 'info'
+}
+
+function formatDateTime(value) {
+  if (!value) return '-'
+  return String(value).replace('T', ' ').slice(0, 19)
+}
+
+/** 归档编号:预警解除即归档,编号由预警单号派生,稳定可追溯 */
+function archiveNoOf(row) {
+  if (!row?.warningNo) return '-'
+  return `ARC${String(row.warningNo).replace(/^WRN/, '').slice(-12)}`
+}
+
+/** 归档时间:解除/处置动作发生在 update_time,缺失时退回发布时间 */
+function archiveTimeOf(row) {
+  return row?.updateTime || row?.publishTime || null
+}
+
+// ==================== 设备类型 / 预警类型映射 ====================
+const equipmentTypeMap = ref(new Map())
+/** warningType(类型编码) -> 类型名称,来自 warning_type 表(与生命线预警系统同一份配置) */
+const warningTypeMap = ref(new Map())
+/** 预警类型筛选项原样保留,下拉直接用 */
+const warningTypeOptions = ref([])
+
+async function loadEquipmentTypes() {
+  try {
+    const res = await getEquipmentTypeList('2')
+    const list = res.code !== undefined ? res.data : res
+    const map = new Map()
+    if (Array.isArray(list)) {
+      list.forEach(item => {
+        if (item.id !== undefined && item.id !== null) map.set(String(item.id), item.typeName)
+      })
+    }
+    equipmentTypeMap.value = map
+  } catch (error) {
+    console.error('加载设备类型列表失败', error)
+  }
+}
+
+async function loadWarningTypes() {
+  try {
+    const res = await listWarningTypes()
+    const list = res?.data || res?.rows || []
+    const map = new Map()
+    if (Array.isArray(list)) {
+      list.forEach(item => {
+        if (item.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
+      })
+    }
+    warningTypeOptions.value = Array.isArray(list) ? list : []
+    warningTypeMap.value = map
+  } catch (error) {
+    console.warn('加载预警类型配置失败', error)
+  }
+}
+
+/** 预警类型展示:新数据看 warningType,历史数据回退到 deviceCode 的设备类型名 */
+function warningTypeText(row) {
+  if (!row) return '-'
+  const byType = row.warningType ? warningTypeMap.value.get(String(row.warningType)) : null
+  if (byType) return byType
+  const byDeviceType = row.deviceCode ? equipmentTypeMap.value.get(String(row.deviceCode)) : null
+  if (byDeviceType) return byDeviceType
+  return row.warningType || row.deviceCode || '-'
+}
+
+// ==================== 状态筛选 ====================
+/** 状态选项跟随数据范围:当前预警给未解除的四种,历史预警给已处置/已解除 */
+const statusOptions = computed(() => (isHistory.value
+  ? [
+    { label: '已处置', value: 'HANDLED' },
+    { label: '已解除', value: 'CLOSED' }
+  ]
+  : [
+    { label: '草稿', value: 'DRAFT' },
+    { label: '待办', value: 'PENDING' },
+    { label: '已接收', value: 'RECEIVED' },
+    { label: '处置中', value: 'PROCESSING' }
+  ]))
+
+// ==================== 统计指标 ====================
+function countByStatus(status) {
+  return tableData.value.filter(o => o.status === status).length
+}
+
+const statItems = computed(() => {
+  const list = tableData.value
+  if (isHistory.value) {
+    const devices = new Set(list.map(o => o.equipmentName).filter(Boolean))
+    return [
+      { label: '已归档预警', value: total.value, cls: '' },
+      { label: '已处置', value: countByStatus('HANDLED'), cls: 'green' },
+      { label: '已解除', value: countByStatus('CLOSED'), cls: 'blue' },
+      { label: '涉及设备', value: devices.size, cls: '' }
+    ]
+  }
+  return [
+    { label: '未解除预警', value: total.value, cls: '' },
+    { label: '待办', value: countByStatus('PENDING'), cls: 'red' },
+    { label: '处置中', value: countByStatus('PROCESSING'), cls: '' },
+    { label: '已接收', value: countByStatus('RECEIVED'), cls: '' }
+  ]
+})
+
+// ==================== 环形图配置 ====================
+const chartPrefix = computed(() => (isHistory.value ? 'history' : 'current'))
+
+const statusLegends = computed(() => (isHistory.value
+  ? [
+    { name: '已处置', value: countByStatus('HANDLED'), color: '#67C23A' },
+    { name: '已解除', value: countByStatus('CLOSED'), color: '#409EFF' }
+  ]
+  : [
+    { name: '草稿', value: countByStatus('DRAFT'), color: '#909399' },
+    { name: '待办', value: countByStatus('PENDING'), color: '#E6A23C' },
+    { name: '处置中', value: countByStatus('PROCESSING'), color: '#409EFF' },
+    { name: '已接收', value: countByStatus('RECEIVED'), color: '#67C23A' }
+  ]))
+
+const levelLegends = computed(() => [
+  { name: '严重', value: tableData.value.filter(o => Number(o.warningLevel) === 1).length, color: '#F56C6C' },
+  { name: '重要', value: tableData.value.filter(o => Number(o.warningLevel) === 2).length, color: '#E6A23C' },
+  { name: '一般', value: tableData.value.filter(o => Number(o.warningLevel) === 3).length, color: '#67C23A' }
+])
+
+const typeLegends = computed(() => {
+  const types = [...new Set(tableData.value.map(o => warningTypeText(o)).filter(t => t && t !== '-'))]
+  const colors = ['#409EFF', '#E6A23C', '#67C23A', '#F56C6C']
+  return types.map((t, i) => ({
+    name: t,
+    value: tableData.value.filter(o => warningTypeText(o) === t).length,
+    color: colors[i % colors.length]
+  }))
+})
+
+/** 两张页面可能被 keep-alive 同时缓存,id 带上模式前缀避免冲突 */
+const ringCharts = computed(() => [
+  { id: `${chartPrefix.value}StatusChart`, title: '状态', legends: statusLegends },
+  { id: `${chartPrefix.value}LevelChart`, title: '级别', legends: levelLegends },
+  { id: `${chartPrefix.value}TypeChart`, title: '类型', legends: typeLegends }
+])
+
+function initCharts() {
+  ringCharts.value.forEach(chart => {
+    const dom = document.getElementById(chart.id)
+    if (!dom) return
+    const instance = echarts.getInstanceByDom(dom)
+    if (instance) instance.dispose()
+
+    const legends = chart.legends.value
+    const chartInstance = echarts.init(dom)
+    chartInstance.setOption({
+      title: {
+        text: chart.title,
+        left: 'center',
+        top: 'center',
+        textStyle: { fontSize: 13, color: '#333', fontWeight: 'normal' }
+      },
+      tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
+      series: [{
+        type: 'pie',
+        radius: ['45%', '70%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        label: { show: false },
+        emphasis: { label: { show: false } },
+        data: legends.map(l => ({ name: l.name, value: l.value, itemStyle: { color: l.color } }))
+      }]
+    })
+  })
+}
+
+// ==================== 筛选 ====================
+const queryRef = ref()
+const filters = reactive({
+  equipmentName: '',
+  dateRange: null,
+  warningLevel: '',
+  warningType: '',
+  status: ''
+})
+
+function buildParams() {
+  const params = { equipmentType: 'drainage' }
+  if (isHistory.value) {
+    // 历史接口按已处置/已解除限定数据范围
+    params.historyOnly = true
+  } else {
+    // 当前接口按未解除限定数据范围
+    params.currentOnly = true
+    // 普通用户只能看到发给自己的预警,管理员能看到所有预警
+    params.userId = userStore.id
+    params.isAdmin = isAdmin.value
+    // 传递部门ID,用于按部门可见性过滤(publish_scope=2)
+    if (userStore.deptId) params.deptId = userStore.deptId
+  }
+  if (filters.equipmentName) params.equipmentName = filters.equipmentName
+  if (filters.warningLevel) params.warningLevel = filters.warningLevel
+  if (filters.warningType) params.warningType = filters.warningType
+  if (filters.status) params.status = filters.status
+  if (filters.dateRange && filters.dateRange.length === 2) {
+    params.startTime = `${filters.dateRange[0]} 00:00:00`
+    params.endTime = `${filters.dateRange[1]} 23:59:59`
+  }
+  return params
+}
+
+// ==================== 数据加载 ====================
+async function loadData() {
+  loading.value = true
+  try {
+    const params = buildParams()
+    const res = isHistory.value
+      ? await getWarningHistoryPage(pageNum.value, pageSize.value, params)
+      : await getWarningCurrentPage(pageNum.value, pageSize.value, params)
+    const pageData = res.code !== undefined ? res.data : res
+    tableData.value = pageData.records || []
+    total.value = pageData.total || 0
+  } catch (error) {
+    console.error('加载预警列表失败', error)
+    ElMessage.error('加载预警列表失败')
+  } finally {
+    loading.value = false
+    nextTick(() => initCharts())
+  }
+}
+
+function handleSearch() {
+  pageNum.value = 1
+  loadData()
+}
+
+function handleClear() {
+  filters.equipmentName = ''
+  filters.warningLevel = ''
+  filters.warningType = ''
+  filters.status = ''
+  filters.dateRange = null
+  pageNum.value = 1
+  loadData()
+}
+
+// ==================== 归档导出(仅历史预警) ====================
+const exporting = ref(false)
+const EXPORT_PAGE_SIZE = 100
+
+const EXPORT_HEADER = [
+  '归档编号', '预警单号', '预警名称', '预警类型', '预警级别', '预警描述',
+  '权属单位', '位置', '预警时间', '发布人', '处置人', '归档时间', '处理状态'
+]
+
+function exportValues(row) {
+  return [
+    archiveNoOf(row),
+    row.warningNo || '',
+    row.equipmentName || '',
+    warningTypeText(row),
+    levelText(row.warningLevel),
+    row.content || '',
+    row.ownershipUnit || '',
+    row.location || '',
+    formatDateTime(row.createTime),
+    row.publisher || '',
+    row.processor || '',
+    formatDateTime(archiveTimeOf(row)),
+    statusText(row.status)
+  ]
+}
+
+/** 按当前筛选条件翻页拉全量,单页给大值容易被后端分页上限截断 */
+async function fetchAllRows() {
+  const collected = []
+  const params = buildParams()
+  let page = 1
+  while (page <= 50) {
+    const res = await getWarningHistoryPage(page, EXPORT_PAGE_SIZE, params)
+    const pageData = res.code !== undefined ? res.data : res
+    const rows = pageData.records || pageData.rows || []
+    if (rows.length === 0) break
+    collected.push(...rows)
+    const totalCount = Number(pageData.total) || 0
+    if (rows.length < EXPORT_PAGE_SIZE) break
+    if (totalCount > 0 && collected.length >= totalCount) break
+    page += 1
+  }
+  return collected
+}
+
+async function handleExport() {
+  exporting.value = true
+  try {
+    const rows = await fetchAllRows()
+    if (rows.length === 0) {
+      ElMessage.warning('当前条件下没有可导出的归档记录')
+      return
+    }
+    // 表格库按需加载:只有点导出时才拉入,当前预警页不必为此买单
+    const [xlsxModule, { saveAs }] = await Promise.all([
+      import('xlsx'),
+      import('file-saver')
+    ])
+    // xlsx 有 ESM 与 CJS 两种产物,default 未必存在,做一次兼容
+    const XLSX = xlsxModule.default || xlsxModule
+    const sheet = XLSX.utils.aoa_to_sheet([EXPORT_HEADER, ...rows.map(exportValues)])
+    const book = XLSX.utils.book_new()
+    XLSX.utils.book_append_sheet(book, sheet, '历史预警归档')
+    const d = new Date()
+    const pad = (n) => String(n).padStart(2, '0')
+    const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`
+    saveAs(new Blob([XLSX.write(book, { bookType: 'xlsx', type: 'array' })]),
+      `历史预警归档清单_${stamp}.xlsx`)
+    ElMessage.success(`已导出 ${rows.length} 条归档记录`)
+  } catch (error) {
+    console.error('导出归档清单失败', error)
+    ElMessage.error('导出失败,请稍后重试')
+  } finally {
+    exporting.value = false
+  }
+}
+
+onMounted(async () => {
+  await Promise.all([loadEquipmentTypes(), loadWarningTypes()])
+  loadData()
+})
+</script>
+
+<style scoped>
+.warning-list-page {
+  padding: 16px;
+  background: #f5f7fb;
+}
+
+/* ==================== 卡片 ==================== */
+.page-card {
+  border-radius: 16px;
+  margin-bottom: 16px;
+}
+
+.page-card :deep(.el-card__header) {
+  padding: 16px 20px;
+  border-bottom: 1px solid #edf2f7;
+}
+
+.page-card :deep(.el-card__body) {
+  padding: 20px;
+}
+
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.list-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+}
+
+.list-actions {
+  display: flex;
+  gap: 8px;
+}
+
+/* ==================== 顶部统计区 ==================== */
+.stats-panel {
+  display: flex;
+  align-items: stretch;
+  gap: 20px;
+}
+
+.stats-left {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  min-width: 130px;
+  padding-right: 16px;
+  border-right: 1px solid #ebeef5;
+}
+
+.stat-item {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  padding: 6px 0;
+}
+
+.stat-value {
+  font-size: 24px;
+  font-weight: 700;
+  color: #303133;
+}
+
+.stat-value.red {
+  color: #F56C6C;
+}
+
+.stat-value.green {
+  color: #67C23A;
+}
+
+.stat-value.blue {
+  color: #409EFF;
+}
+
+.stat-label {
+  font-size: 13px;
+  color: #909399;
+}
+
+.stats-center {
+  display: flex;
+  gap: 16px;
+  flex: 1;
+}
+
+.ring-chart-box {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex: 1;
+  min-width: 0;
+}
+
+.ring-chart {
+  width: 130px;
+  height: 130px;
+  flex-shrink: 0;
+}
+
+.ring-legend {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.legend-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #606266;
+}
+
+.legend-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.legend-text {
+  white-space: nowrap;
+}
+
+.legend-num {
+  font-weight: 600;
+  margin-left: 4px;
+}
+
+/* ==================== 分页 ==================== */
+:deep(.pagination-container) {
+  padding: 16px 0 0;
+  background: transparent;
+}
+</style>

+ 122 - 0
src/views/subSystem/drainage/jcyj/components/warningGIS.scss

@@ -0,0 +1,122 @@
+.warning-gis-page {
+  color: #303b4e;
+  min-height: calc(100vh - 84px);
+  background: #f5f7fa;
+  overflow-x: hidden;
+}
+.gis-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
+.gis-page-heading h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: .3px; }
+.gis-page-heading p { margin: 0; color: #7b8799; font-size: 13px; line-height: 1.6; }
+.gis-filter-card, .gis-workspace { background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; box-shadow: 0 2px 5px #273f5d03; }
+.gis-filter-card { padding: 16px 20px 20px; margin-bottom: 16px; }
+.gis-filters { display: grid; grid-template-columns: minmax(272px, 332px) minmax(212px, 252px) auto; justify-content: start; align-items: center; gap: 16px 20px; }
+.gis-filters :deep(.el-form-item) { margin: 0; min-width: 0; }
+.gis-filters :deep(.el-form-item__label) { height: 32px; margin-bottom: 0; font-size: 12px; font-weight: 400; color: #5f6d81; line-height: 32px; }
+.gis-filters :deep(.el-select), .gis-filters :deep(.el-input) { width: 100%; }
+.gis-filter-actions { display: flex; white-space: nowrap; }
+/* 预警只有三个等级,统计卡三列铺满 */
+.gis-level-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
+.gis-level-metric { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 6px 12px; min-height: 82px; padding: 16px 18px; background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; }
+.gis-grade-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid var(--grade-color); border-radius: 50%; background: var(--grade-tint); color: var(--grade-color); font-size: 14px; font-weight: 600; }
+.gis-metric-label { min-width: 0; font-size: 13px; line-height: 1.4; }
+.gis-metric-value { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: flex-end; flex-wrap: wrap; gap: 4px 8px; }
+.gis-metric-value > span { color: #8a95a5; font-size: 12px; }
+.gis-metric-value strong { color: #293b53; font-size: 26px; font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.gis-metric-value strong small { margin-left: 5px; font-size: 12px; font-weight: 400; color: #8a95a5; }
+.gis-workspace { overflow: hidden; }
+.gis-workspace-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #e6eaf0; }
+.gis-workspace-heading > div { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
+.gis-workspace-heading .el-icon { color: #409eff; font-size: 18px; }
+.gis-workspace-heading h2 { margin: 0; font-size: 15px; font-weight: 600; }
+.gis-workspace-heading span { color: #8390a2; font-size: 12px; }
+.gis-workspace-body { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: clamp(420px, calc(100vh - 410px), 740px); }
+.gis-device-pane { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #e6eaf0; background: #fff; }
+.gis-list-heading { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #edf0f5; color: #7c899b; font-size: 12px; }
+.gis-list-heading b { color: #3d536f; font-weight: 500; }
+.gis-device-list { min-height: 0; overflow: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: #d5dfea transparent; }
+.gis-device-item { padding: 14px 12px; margin-bottom: 4px; border: 1px solid transparent; border-bottom-color: #edf0f5; border-radius: 6px; transition: background .15s, border-color .15s; }
+.gis-device-item:hover { background: #f8faff; }
+.gis-device-item.is-selected { background: #f0f6ff; border-color: #b8d4fa; }
+.gis-device-title { display: flex; align-items: flex-start; gap: 9px; }
+.gis-device-title .gis-grade-badge { width: 23px; height: 23px; font-size: 12px; }
+.gis-device-name { padding: 1px 0; border: 0; background: transparent; font: inherit; color: #34445c; font-size: 13px; font-weight: 600; text-align: left; line-height: 21px; overflow-wrap: anywhere; cursor: pointer; }
+.gis-device-name:hover { color: #409eff; }
+.gis-device-name:focus-visible, .gis-cluster-list button:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; border-radius: 2px; }
+.gis-device-code { margin: 8px 0; color: #8290a5; font: 11px/1.6 Consolas, monospace; overflow-wrap: anywhere; }
+.gis-device-location { display: flex; align-items: flex-start; gap: 4px; color: #758297; font-size: 12px; line-height: 1.7; }
+.gis-device-location .el-icon { margin-top: 4px; flex-shrink: 0; }
+.gis-device-location span { overflow-wrap: anywhere; }
+.gis-device-meta { display: flex; gap: 8px; margin-top: 9px; color: #8190a3; font-size: 11px; }
+.gis-device-meta span:first-child { color: var(--grade-color); }
+.gis-device-meta span + span::before { content: '·'; margin-right: 8px; }
+.gis-device-actions { display: flex; justify-content: space-between; align-items: center; gap: 5px; margin-top: 10px; font-size: 11px; }
+.gis-device-actions :deep(.el-button) { font-size: 12px; font-weight: 400; padding: 3px 0; }
+.gis-device-actions :deep(.el-button + .el-button) { margin-left: 12px; }
+.gis-coordinate-warning { color: #b77b28; }
+.gis-coordinate-ready { display: flex; align-items: center; gap: 5px; color: #8090a3; }
+.gis-coordinate-ready i { width: 5px; height: 5px; border-radius: 50%; background: #69b58c; }
+.gis-map-stage { position: relative; min-width: 0; overflow: hidden; background: #f4f7fb; }
+.gis-map-stage :deep(.gis-map-canvas) { min-height: 0; background: #f4f7fb; }
+.gis-map-status, .gis-map-legend, .gis-map-tools :deep(.el-button) { background: #fff; border: 1px solid #e3e9f1; box-shadow: 0 2px 8px #263e5b0a; }
+.gis-map-status { position: absolute; left: 16px; top: 16px; z-index: 2; display: flex; align-items: center; gap: 7px; padding: 10px 12px; border-radius: 6px; color: #65768c; font-size: 12px; }
+.gis-map-status i { width: 7px; height: 7px; border-radius: 50%; background: #dca14d; }
+.gis-map-status i.is-ready { background: #52aa7a; }
+.gis-map-status i.is-error { background: #e89174; }
+.gis-map-tools { position: absolute; right: 16px; top: 16px; z-index: 2; display: flex; gap: 6px; }
+.gis-map-tools :deep(.el-button) { width: 36px; height: 36px; padding: 0; margin: 0; border-radius: 5px; font-size: 17px; }
+.gis-map-fallback { position: absolute; inset: 75px 24px 90px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; background: #f4f7fb; }
+.gis-fallback-icon { display: grid; place-items: center; width: 56px; height: 56px; margin-bottom: 18px; background: #e9eff7; border-radius: 14px; color: #8194ac; font-size: 26px; }
+.gis-map-fallback h3 { margin: 0 0 12px; font-size: 17px; font-weight: 500; color: #4c5f79; }
+.gis-map-fallback p { max-width: 400px; margin: 0 0 20px; font-size: 13px; color: #8895a6; line-height: 1.8; overflow-wrap: anywhere; }
+.gis-fallback-note { max-width: 380px; margin-top: 14px; color: #94a0b1; font-size: 11px; line-height: 1.7; }
+.gis-map-bottom { position: absolute; left: 16px; right: 16px; bottom: 38px; z-index: 2; display: flex; align-items: end; justify-content: space-between; gap: 10px; pointer-events: none; }
+.gis-map-legend { display: flex; flex-wrap: wrap; gap: 12px; padding: 10px 12px; border-radius: 6px; }
+.gis-map-legend span { display: flex; align-items: center; gap: 5px; color: #65768b; font-size: 11px; white-space: nowrap; }
+.gis-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: var(--grade-color); }
+.gis-map-note { padding: 7px 9px; background: #fffffff2; border-radius: 4px; color: #8b7758; font-size: 11px; }
+.gis-workspace-footer { display: flex; justify-content: space-between; gap: 12px; padding: 12px 18px; border-top: 1px solid #e6eaf0; color: #8996a8; font-size: 12px; line-height: 1.6; }
+.gis-workspace-footer span:first-child { display: flex; align-items: center; gap: 5px; min-width: 0; overflow-wrap: anywhere; }
+.gis-workspace-footer .el-icon { flex-shrink: 0; }
+.gis-workspace-footer span:last-child { flex-shrink: 0; }
+:global(.warning-gis-drawer .el-drawer__header) { margin: 0; padding: 20px 24px; border-bottom: 1px solid #e6eaf0; }
+:global(.warning-gis-drawer .el-drawer__body) { padding: 22px 24px; }
+:global(.warning-gis-drawer .el-drawer__footer) { padding: 15px 24px; border-top: 1px solid #e6eaf0; }
+.gis-drawer-heading > span { display: block; margin-bottom: 7px; font-size: 11px; color: #8a97a9; }
+.gis-drawer-heading h2 { margin: 0; color: #34445b; font-size: 19px; font-weight: 600; overflow-wrap: anywhere; }
+.gis-detail-banner { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 22px; }
+.gis-detail-banner > span:last-child { color: #8a97a9; font-size: 12px; }
+.gis-detail-grade { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--grade-color); }
+.gis-detail-grade .gis-grade-badge { width: 26px; height: 26px; font-size: 12px; }
+.gis-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; margin: 0; }
+.gis-detail-grid .is-wide { grid-column: 1 / -1; }
+.gis-detail-grid dt { margin-bottom: 7px; color: #8794a7; font-size: 12px; font-weight: 400; }
+.gis-detail-grid dd { margin: 0; color: #45556d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
+.gis-snapshot { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; margin-top: 20px; padding: 15px 16px; background: #f6f8fc; border: 1px solid #edf1f7; border-radius: 6px; }
+.gis-snapshot span { color: #8290a3; font-size: 11px; }
+.gis-snapshot p { margin: 10px 0 0; color: #45556d; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
+.gis-detail-tabs { margin-top: 20px; }
+.gis-detail-tabs :deep(.el-tabs__item) { padding: 0 18px; font-size: 13px; font-weight: 400; }
+.gis-detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: #e6eaf0; }
+.gis-detail-tabs :deep(.el-tabs__header) { margin-bottom: 22px; }
+.gis-panel-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
+.gis-panel-heading h3 { margin: 0; color: #41536e; font-size: 14px; font-weight: 500; }
+.gis-panel-heading > span { color: #94a0b1; font-size: 11px; }
+.gis-record-list { min-height: 110px; margin-left: 6px; padding-left: 20px; border-left: 1px solid #e3eaf4; }
+.gis-history-item { position: relative; padding: 0 0 22px; }
+.gis-history-item::before { content: ''; position: absolute; left: -25px; top: 6px; width: 7px; height: 7px; border: 1px solid var(--grade-color); background: #fff; border-radius: 50%; }
+.gis-history-heading { display: flex; justify-content: space-between; gap: 12px; }
+.gis-history-heading :deep(.el-tag) { border: 0; border-radius: 3px; font-weight: 400; }
+.gis-history-description { margin: 10px 0; color: #526178; font-size: 13px; line-height: 1.8; overflow-wrap: anywhere; }
+.gis-history-item dl { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 6px 10px; margin: 0; color: #8492a6; font-size: 12px; }
+.gis-history-item dt { font-weight: 400; }
+.gis-history-item dd { margin: 0; overflow-wrap: anywhere; }
+.gis-history-actions { display: flex; justify-content: flex-end; gap: 14px; margin-top: 10px; }
+.gis-history-actions :deep(.el-button) { margin: 0; font-size: 12px; font-weight: 400; }
+.gis-drawer-footer { display: flex; justify-content: flex-end; align-items: center; gap: 14px; }
+.gis-cluster-list { display: grid; gap: 8px; }
+.gis-cluster-list button { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid #e7edf5; background: #f8faff; border-radius: 5px; text-align: left; color: #526581; cursor: pointer; font: inherit; font-size: 13px; }
+.gis-cluster-list button span { flex: 1; }
+.gis-cluster-list small { color: #95a1b2; }
+@media (max-width: 1250px) { .gis-filters { gap: 16px; } .gis-level-metric { padding: 14px 12px; gap: 8px; } .gis-workspace-body { grid-template-columns: 270px minmax(0, 1fr); } .gis-map-note { display: none; } }
+@media (max-width: 1000px) { .gis-filters { grid-template-columns: minmax(0, 332px) minmax(0, 252px); } .gis-filter-actions { grid-column: 1 / -1; justify-content: flex-end; } .gis-map-status { top: 62px; } .gis-map-fallback { inset: 110px 18px 95px; } }
+@media (max-width: 680px) { .gis-page-heading h1 { font-size: 19px; } .gis-filter-card { padding: 14px; } .gis-filters { grid-template-columns: minmax(0, 1fr); } .gis-level-metric { gap: 7px; padding: 12px 10px; } .gis-metric-value strong { font-size: 23px; } .gis-metric-value > span { font-size: 11px; } .gis-metric-label { font-size: 12px; } .gis-level-metric .gis-grade-badge { width: 25px; height: 25px; } .gis-workspace-body { display: flex; flex-direction: column; height: auto; } .gis-device-pane { height: 280px; border-right: 0; border-bottom: 1px solid #e6eaf0; } .gis-map-stage { height: 450px; } .gis-workspace-heading { padding: 14px; } .gis-workspace-footer { padding: 12px; } .gis-workspace-footer span:last-child { display: none; } .gis-detail-grid { gap: 16px; } .gis-snapshot { gap: 14px; } .gis-detail-banner { align-items: flex-start; } :global(.warning-gis-drawer .el-drawer__body) { padding: 18px; } }

+ 6 - 630
src/views/subSystem/drainage/jcyj/dqyjGIS.vue

@@ -1,635 +1,11 @@
 <template>
-  <div class="gis-page">
-    <!-- ==================== 左侧浮动面板 ==================== -->
-    <aside class="side-panel">
-      <div class="panel-header">
-        <div class="panel-title">当前预警分布</div>
-        <div class="panel-sub">
-          共 <b>{{ warningList.length }}</b> 条 · 已上图 <b>{{ mapPoints.length }}</b> 条
-        </div>
-      </div>
-
-      <!-- 概览(点击卡片按等级筛选) -->
-      <div class="panel-section">
-        <div class="section-label">
-          预警概览
-          <span class="section-hint">点击卡片筛选等级</span>
-        </div>
-        <div class="stat-grid">
-          <div class="stat-card" :class="{ active: levelFilter === 'all' }" @click="setLevel('all')">
-            <div class="stat-value">{{ rawList.length }}</div>
-            <div class="stat-text">全部</div>
-          </div>
-          <div class="stat-card stat-severe" :class="{ active: levelFilter === 'severe' }" @click="setLevel('severe')">
-            <div class="stat-value">{{ severeCount }}</div>
-            <div class="stat-text">严重</div>
-          </div>
-          <div class="stat-card stat-important" :class="{ active: levelFilter === 'important' }"
-            @click="setLevel('important')">
-            <div class="stat-value">{{ importantCount }}</div>
-            <div class="stat-text">重要</div>
-          </div>
-          <div class="stat-card stat-normal" :class="{ active: levelFilter === 'normal' }" @click="setLevel('normal')">
-            <div class="stat-value">{{ normalCount }}</div>
-            <div class="stat-text">一般</div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 预警类型筛选 -->
-      <div class="panel-section">
-        <div class="section-label">
-          预警类型
-          <span class="link-btn" v-if="typeFilter || levelFilter !== 'all'" @click="resetFilter">重置</span>
-        </div>
-        <el-select v-model="typeFilter" placeholder="全部类型" clearable filterable size="small" style="width:100%">
-          <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
-        </el-select>
-      </div>
-
-      <!-- 预警清单 -->
-      <div class="panel-section list-section">
-        <div class="section-label">
-          预警清单
-          <span class="section-hint">{{ displayList.length }} 条</span>
-        </div>
-        <el-input v-model="searchKey" placeholder="搜索名称 / 位置" clearable size="small" :prefix-icon="Search" />
-        <div class="warning-list">
-          <div class="warning-item" v-for="item in displayList" :key="item.id"
-            :class="{ 'no-coord': !item.lng }" @click="locate(item)">
-            <span class="level-dot" :style="{ background: getLevelColor(item.level) }"></span>
-            <div class="wi-main">
-              <div class="wi-name" :title="item.name">{{ item.name }}</div>
-              <div class="wi-sub">{{ item.typeText }} · {{ formatTime(item.createTime) }}</div>
-            </div>
-            <span class="wi-level" :class="getLevelClass(item.level)">{{ item.level }}</span>
-          </div>
-          <div class="list-empty" v-if="!displayList.length">暂无符合条件的预警</div>
-        </div>
-      </div>
-    </aside>
-
-    <!-- ==================== 地图容器 ==================== -->
-    <div id="dqyjGISMap" class="map-container"></div>
-
-    <!-- 地图工具条 -->
-    <div class="map-tools">
-      <el-tooltip content="回到预警范围" placement="left">
-        <el-button circle @click="fitView"><el-icon><Aim /></el-icon></el-button>
-      </el-tooltip>
-    </div>
-
-    <!-- ==================== 详情抽屉 ==================== -->
-    <WarningGisDetail v-model="detailVisible" :record="currentRecord" mode="current" />
-  </div>
+  <WarningGisWorkspace mode="current" />
 </template>
 
 <script setup name="DqyjGIS">
-import { ref, computed, watch, onMounted, nextTick } from 'vue'
-import { Search, Aim } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import locationIcon from '@/assets/images/location.png'
-import { getWarningCurrentPage } from '@/api/drainage'
-import { listWarningTypes } from '@/api/warning/list'
-import useUserStore from '@/store/modules/user'
-import WarningGisDetail from './components/WarningGisDetail.vue'
-
-// ==================== 用户信息 ====================
-const userStore = useUserStore()
-const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
-
-// ==================== 数据 ====================
-/** 后端原始记录,详情抽屉直接用(保留全部字段) */
-const rawList = ref([])
-/** 归一化后的全部预警(含无坐标的,清单里照样能看详情) */
-const warningList = ref([])
-
-/** 能上图的点位:地图 Point 只接受有效经纬度 */
-const mapPoints = computed(() => warningList.value.filter((p) => p.lng && p.lat))
-
-const levelMap = { 1: '严重', 2: '重要', 3: '一般' }
-const levelKeyMap = { 1: 'severe', 2: 'important', 3: 'normal' }
-const statusMap = { DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }
-
-/** 预警类型下拉:warningType 去重,名称取 warning_type 配置 */
-const typeOptions = ref([])
-const warningTypeMap = ref(new Map())
-
-async function loadWarningTypes() {
-  try {
-    const res = await listWarningTypes()
-    const list = res?.data || res?.rows || []
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach((item) => {
-        if (item?.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
-      })
-    }
-    warningTypeMap.value = map
-  } catch (error) {
-    console.warn('加载预警类型配置失败', error)
-  }
-}
-
-async function loadData() {
-  try {
-    const params = {
-      equipmentType: 'drainage',
-      currentOnly: true,
-      userId: userStore.id,
-      isAdmin: isAdmin.value
-    }
-    if (userStore.deptId) params.deptId = userStore.deptId
-
-    const [res] = await Promise.all([getWarningCurrentPage(1, 1000, params), loadWarningTypes()])
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || []
-    rawList.value = records
-
-    warningList.value = records.map(normalize)
-
-    // 类型下拉:按记录里实际出现的类型构建
-    const types = [...new Set(records.map((r) => r.warningType).filter(Boolean))]
-    typeOptions.value = types.map((val) => ({
-      value: String(val),
-      label: warningTypeMap.value.get(String(val)) || String(val)
-    }))
-
-    const noCoord = records.length - warningList.value.filter((p) => p.lng && p.lat).length
-    if (noCoord > 0) {
-      console.warn(`[GIS预警] ${noCoord} 条预警缺少经纬度,未上图(清单中仍可查看详情)`)
-    }
-  } catch (error) {
-    console.error('加载当前预警数据失败', error)
-    ElMessage.error('加载当前预警数据失败')
-  }
-}
-
-function normalize(w) {
-  return {
-    id: w.id,
-    name: w.equipmentName || '-',
-    level: levelMap[w.warningLevel] || '一般',
-    levelKey: levelKeyMap[w.warningLevel] || 'normal',
-    typeValue: w.warningType ? String(w.warningType) : '',
-    typeText: w.warningType ? (warningTypeMap.value.get(String(w.warningType)) || String(w.warningType)) : '未分类',
-    createTime: w.createTime || '',
-    location: w.location || '',
-    statusText: statusMap[w.status] || '-',
-    raw: w,
-    lng: parseFloat(w.longitude),
-    lat: parseFloat(w.latitude)
-  }
-}
-
-// ==================== 统计 ====================
-const severeCount = computed(() => rawList.value.filter((v) => v.warningLevel === 1).length)
-const importantCount = computed(() => rawList.value.filter((v) => v.warningLevel === 2).length)
-const normalCount = computed(() => rawList.value.filter((v) => v.warningLevel === 3).length)
-
-// ==================== 筛选 ====================
-const levelFilter = ref('all')
-const typeFilter = ref('')
-const searchKey = ref('')
-
-const displayList = computed(() => {
-  const key = searchKey.value.trim().toLowerCase()
-  return warningList.value.filter((item) => {
-    if (levelFilter.value !== 'all' && item.levelKey !== levelFilter.value) return false
-    if (typeFilter.value && item.typeValue !== typeFilter.value) return false
-    if (key && !(item.name.toLowerCase().includes(key) || item.location.toLowerCase().includes(key))) return false
-    return true
-  })
-})
-
-function setLevel(key) {
-  levelFilter.value = levelFilter.value === key ? 'all' : key
-}
-
-function resetFilter() {
-  levelFilter.value = 'all'
-  typeFilter.value = ''
-  searchKey.value = ''
-}
-
-// 筛选变化时同步 marker 显隐
-watch(displayList, () => {
-  const visibleIds = new Set(displayList.value.map((item) => item.id))
-  warningList.value.forEach((point) => {
-    const label = markerMap.get(point.id)
-    if (!label) return
-    if (visibleIds.has(point.id)) label.show()
-    else label.hide()
-  })
-})
-
-// ==================== 工具方法 ====================
-function getLevelColor(level) {
-  const map = { '严重': '#f56c6c', '重要': '#e6a23c', '一般': '#409eff' }
-  return map[level] || '#409eff'
-}
-
-function getLevelClass(level) {
-  const map = { '严重': 'level-severe', '重要': 'level-important', '一般': 'level-normal' }
-  return map[level] || ''
-}
-
-function formatTime(value) {
-  if (!value) return '-'
-  return String(value).replace('T', ' ').slice(5, 16)
-}
-
-// ==================== 地图 ====================
-let mapInstance = null
-const markerMap = new Map()
-
-function initMap() {
-  const container = document.getElementById('dqyjGISMap')
-  if (!container || typeof TdtMap === 'undefined') {
-    console.warn('TdtMap 未加载或容器不存在')
-    return
-  }
-  try {
-    mapInstance = new TdtMap.Map('dqyjGISMap')
-    mapInstance.centerAndZoom(new TdtMap.Point(110.393, 28.452), 15)
-    mapInstance.enableScrollWheelZoom(true)
-
-    mapPoints.value.forEach(addMapMarker)
-    fitView()
-  } catch (e) {
-    console.error('天地图初始化失败:', e)
-  }
-}
-
-function addMapMarker(point) {
-  const color = getLevelColor(point.level)
-  const bPoint = new TdtMap.Point(point.lng, point.lat)
-  const html = `<div class="gis-marker" 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);">
-      ${point.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new TdtMap.Label(html, {
-    position: bPoint,
-    offset: new TdtMap.Size(-30, -50)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: '10' })
-  mapInstance.addOverlay(label)
-  markerMap.set(point.id, label)
-
-  label.addEventListener('click', () => locate(point))
-}
-
-/** 回到能看全所有点位的视野 */
-function fitView() {
-  if (!mapInstance || !mapPoints.value.length) return
-  const points = mapPoints.value.map((p) => new TdtMap.Point(p.lng, p.lat))
-  if (points.length === 1) {
-    mapInstance.centerAndZoom(points[0], 16)
-    return
-  }
-  mapInstance.setViewport(points)
-}
-
-/** 定位到点位并打开详情 */
-function locate(item) {
-  if (!item) return
-  if (mapInstance && item.lng && item.lat) {
-    mapInstance.centerAndZoom(new TdtMap.Point(item.lng, item.lat), 17)
-  }
-  if (!item.lng || !item.lat) {
-    ElMessage.warning('该预警缺少经纬度坐标,无法在地图上定位')
-  }
-  openDetail(item)
-}
-
-// ==================== 详情 ====================
-const detailVisible = ref(false)
-const currentRecord = ref(null)
-
-function openDetail(item) {
-  // 详情展示用后端原始记录,字段最全
-  currentRecord.value = item.raw || null
-  detailVisible.value = true
-}
-
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  await loadData()
-  nextTick(() => initMap())
-})
+/**
+ * 当前预警GIS一张图:与历史预警GIS一张图复用同一页面,数据范围为所有未解除预警。
+ * 注意:template 必须保持单根节点(不能写注释),否则外层 <transition mode="out-in"> 会白屏。
+ */
+import WarningGisWorkspace from './components/WarningGisWorkspace.vue'
 </script>
-
-<style scoped>
-.gis-page {
-  position: relative;
-  width: 100%;
-  height: calc(100vh - 84px);
-  overflow: hidden;
-}
-
-.map-container {
-  /* Leaflet 内部 pane 的 z-index 为 200~800,用独立堆叠上下文限制在容器内,
-     否则会盖住 z-index 更低的左侧面板和右上工具条 */
-  position: relative;
-  z-index: 0;
-  width: 100%;
-  height: 100%;
-}
-
-/* ==================== 左侧浮动面板 ==================== */
-.side-panel {
-  position: absolute;
-  top: 16px;
-  left: 16px;
-  bottom: 16px;
-  width: 300px;
-  display: flex;
-  flex-direction: column;
-  background: rgba(6, 30, 65, 0.88);
-  border: 1px solid rgba(90, 160, 255, 0.18);
-  border-radius: 10px;
-  z-index: 9999;
-  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
-  backdrop-filter: blur(6px);
-  overflow: hidden;
-}
-
-.panel-header {
-  padding: 16px 18px 12px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-  flex-shrink: 0;
-}
-
-.panel-title {
-  font-size: 16px;
-  font-weight: 600;
-  color: #fff;
-  letter-spacing: 1px;
-}
-
-.panel-sub {
-  margin-top: 6px;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.55);
-}
-
-.panel-sub b {
-  color: #61c0ff;
-  font-weight: 600;
-}
-
-.panel-section {
-  padding: 12px 18px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-  flex-shrink: 0;
-}
-
-/* 清单区撑满剩余高度并内部滚动 */
-.panel-section.list-section {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  border-bottom: none;
-  padding-bottom: 14px;
-}
-
-.section-label {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.55);
-  margin-bottom: 10px;
-  letter-spacing: 1px;
-}
-
-.section-hint {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.32);
-  letter-spacing: 0;
-}
-
-.link-btn {
-  font-size: 12px;
-  color: #61c0ff;
-  cursor: pointer;
-  letter-spacing: 0;
-}
-
-.link-btn:hover {
-  text-decoration: underline;
-}
-
-/* 统计卡片 */
-.stat-grid {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 6px;
-}
-
-.stat-card {
-  background: rgba(255, 255, 255, 0.07);
-  border: 1px solid transparent;
-  border-radius: 6px;
-  padding: 9px 2px;
-  text-align: center;
-  cursor: pointer;
-  transition: all 0.18s;
-}
-
-.stat-card:hover {
-  background: rgba(255, 255, 255, 0.13);
-}
-
-.stat-card.active {
-  border-color: rgba(97, 192, 255, 0.85);
-  background: rgba(97, 192, 255, 0.16);
-}
-
-.stat-card.stat-severe {
-  background: rgba(245, 108, 108, 0.14);
-}
-
-.stat-card.stat-important {
-  background: rgba(230, 162, 60, 0.14);
-}
-
-.stat-card.stat-normal {
-  background: rgba(64, 158, 255, 0.14);
-}
-
-.stat-value {
-  font-size: 19px;
-  font-weight: 700;
-  color: #fff;
-  line-height: 1.2;
-  font-variant-numeric: tabular-nums;
-}
-
-.stat-severe .stat-value {
-  color: #f56c6c;
-}
-
-.stat-important .stat-value {
-  color: #e6a23c;
-}
-
-.stat-normal .stat-value {
-  color: #409eff;
-}
-
-.stat-text {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-top: 3px;
-}
-
-/* 深色面板下的输入控件 */
-.side-panel :deep(.el-input__wrapper),
-.side-panel :deep(.el-select__wrapper) {
-  background: rgba(255, 255, 255, 0.08);
-  box-shadow: none;
-  border: 1px solid rgba(255, 255, 255, 0.12);
-}
-
-.side-panel :deep(.el-input__inner),
-.side-panel :deep(.el-select__placeholder) {
-  color: #fff;
-  font-size: 13px;
-}
-
-.side-panel :deep(.el-input__inner::placeholder) {
-  color: rgba(255, 255, 255, 0.35);
-}
-
-.side-panel :deep(.el-input__prefix .el-icon),
-.side-panel :deep(.el-select__caret) {
-  color: rgba(255, 255, 255, 0.45);
-}
-
-/* 预警清单 */
-.warning-list {
-  flex: 1;
-  min-height: 0;
-  margin-top: 8px;
-  overflow-y: auto;
-  margin-right: -6px;
-  padding-right: 4px;
-}
-
-.warning-list::-webkit-scrollbar {
-  width: 4px;
-}
-
-.warning-list::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.18);
-  border-radius: 2px;
-}
-
-.warning-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 8px 8px;
-  border-radius: 6px;
-  cursor: pointer;
-  transition: background 0.15s;
-}
-
-.warning-item:hover {
-  background: rgba(255, 255, 255, 0.1);
-}
-
-/* 无坐标的记录仍可查看详情,视觉上弱化 */
-.warning-item.no-coord .wi-name,
-.warning-item.no-coord .wi-sub {
-  color: rgba(255, 255, 255, 0.42);
-}
-
-.warning-item.no-coord .level-dot {
-  opacity: 0.4;
-}
-
-.wi-main {
-  flex: 1;
-  min-width: 0;
-}
-
-.wi-name {
-  font-size: 13px;
-  color: rgba(255, 255, 255, 0.9);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.wi-sub {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.42);
-  margin-top: 2px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.wi-level {
-  flex-shrink: 0;
-  font-size: 11px;
-  padding: 1px 6px;
-  border-radius: 3px;
-}
-
-.list-empty {
-  text-align: center;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.32);
-  padding: 24px 0;
-}
-
-.level-dot {
-  flex-shrink: 0;
-  display: inline-block;
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-}
-
-.wi-level.level-severe {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.wi-level.level-important {
-  color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
-}
-
-.wi-level.level-normal {
-  color: #409eff;
-  background: rgba(64, 158, 255, 0.15);
-}
-
-/* ==================== 地图工具条 ==================== */
-.map-tools {
-  position: absolute;
-  top: 16px;
-  right: 16px;
-  z-index: 9999;
-}
-
-.map-tools :deep(.el-button) {
-  background: rgba(6, 30, 65, 0.85);
-  border-color: rgba(90, 160, 255, 0.25);
-  color: #fff;
-}
-
-.map-tools :deep(.el-button:hover) {
-  background: rgba(30, 70, 130, 0.95);
-  border-color: rgba(90, 160, 255, 0.5);
-  color: #fff;
-}
-</style>

+ 6 - 880
src/views/subSystem/drainage/jcyj/dqyjgl.vue

@@ -1,885 +1,11 @@
 <template>
-  <div class="app-container dqyjgl-page">
-    <!-- ==================== 顶部统计区 ==================== -->
-    <div class="stats-panel">
-      <!-- 左侧4个统计指标 -->
-      <div class="stats-left">
-        <div class="stat-item" v-for="item in statItems" :key="item.label">
-          <div class="stat-value" :class="item.cls">{{ item.value }}</div>
-          <div class="stat-label">{{ item.label }}</div>
-        </div>
-      </div>
-      <!-- 中间3个环形图 -->
-      <div class="stats-center">
-        <div class="ring-chart-box" v-for="chart in ringCharts" :key="chart.title">
-          <div :id="chart.id" class="ring-chart"></div>
-          <div class="ring-legend">
-            <div class="legend-row" v-for="(leg, i) in chart.legends.value" :key="i">
-              <span class="legend-dot" :style="{ background: leg.color }"></span>
-              <span class="legend-text">{{ leg.name }}</span>
-              <span class="legend-num">{{ leg.value }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-      <!-- 右侧数字指标 -->
-      <!-- <div class="stats-right">
-        <div class="right-metric">
-          <el-icon class="metric-icon"><Promotion /></el-icon>
-          <span class="metric-num">212</span>
-        </div>
-        <div class="right-metric">
-          <el-icon class="metric-icon orange"><Promotion /></el-icon>
-          <span class="metric-num">128</span>
-        </div>
-      </div> -->
-    </div>
-
-    <!-- ==================== 筛选区 ==================== -->
-    <el-form ref="queryRef" :model="filters" :inline="true" v-show="showSearch" label-width="80px">
-      <el-form-item label="预警名称">
-        <el-input v-model="filters.equipmentName" placeholder="请输入预警名称" clearable style="width: 200px"
-          @keyup.enter="handleSearch" />
-      </el-form-item>
-      <el-form-item label="预警级别">
-        <el-select v-model="filters.warningLevel" placeholder="请选择预警级别" clearable style="width: 180px">
-          <el-option label="严重" :value="1" />
-          <el-option label="重要" :value="2" />
-          <el-option label="一般" :value="3" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="预警类型">
-        <el-select v-model="filters.warningType" placeholder="请选择预警类型" clearable filterable style="width: 180px">
-          <el-option v-for="item in warningTypeOptions" :key="item.dictValue" :label="item.dictLabel"
-            :value="item.dictValue" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="预警时间" style="width: 308px">
-        <el-date-picker v-model="filters.dateRange" type="daterange" range-separator="-"
-          start-placeholder="开始日期" end-placeholder="结束日期" style="width: 240px" value-format="YYYY-MM-DD" />
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
-        <el-button icon="Refresh" @click="handleClear">重置</el-button>
-      </el-form-item>
-    </el-form>
-
-    <!-- ==================== 状态分类 + 操作栏 ==================== -->
-    <el-row :gutter="10" class="mb8">
-      <el-radio-group v-model="activeTab" @change="handleTabChange">
-        <el-radio-button v-for="tab in tabs" :key="tab" :value="tab">{{ tab }}</el-radio-button>
-      </el-radio-group>
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
-      </el-col>
-      <right-toolbar v-model:showSearch="showSearch" @queryTable="loadData"></right-toolbar>
-    </el-row>
-
-    <!-- ==================== 数据表格 ==================== -->
-    <div>
-      <el-table :data="tableData" style="width:100%" v-loading="loading">
-        <el-table-column prop="warningNo" label="预警单号" width="180" show-overflow-tooltip />
-        <el-table-column prop="equipmentName" label="预警名称" min-width="130" show-overflow-tooltip />
-        <el-table-column label="预警类型" width="130" align="center" show-overflow-tooltip>
-          <template #default="{ row }">{{ warningTypeText(row) }}</template>
-        </el-table-column>
-        <el-table-column prop="warningLevel" label="预警级别" width="90" align="center">
-          <template #default="{ row }">
-            <el-tag :type="levelTagType(row.warningLevel)" size="small">{{ levelText(row.warningLevel) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="预警描述" min-width="200" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.content || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="ownershipUnit" label="权属单位" width="140" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.ownershipUnit || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="location" label="位置" min-width="150" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.location || '-' }}</template>
-        </el-table-column>
-        <el-table-column label="预警时间" width="170">
-          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
-        </el-table-column>
-        <el-table-column prop="publisher" label="发布人" width="110" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.publisher || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="status" label="状态" width="90" align="center">
-          <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="170" fixed="right">
-          <template #default="{ row }">
-            <el-button type="primary" link size="small" @click="handleView(row)">查看</el-button>
-            <el-button v-if="row.status !== 'HANDLED' && row.status !== 'CLOSED'" type="warning" link size="small"
-              @click="handleClose(row)">解除</el-button>
-            <el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-      <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize"
-        @pagination="loadData" />
-    </div>
-
-    <!-- ==================== 查看详情弹窗 ==================== -->
-    <el-drawer v-model="detailVisible" title="预警详情" size="720px" :close-on-click-modal="false"
-      class="detail-drawer" direction="rtl">
-      <div class="detail-content" v-if="currentOrder" v-loading="detailLoading">
-        <div class="section-box">
-          <div class="section-title">预警信息</div>
-          <el-descriptions :column="2" border size="small">
-            <el-descriptions-item label="预警单号">{{ currentOrder.warningNo || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警名称">{{ currentOrder.equipmentName || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警类型">{{ warningTypeText(currentOrder) }}</el-descriptions-item>
-            <el-descriptions-item label="预警级别">
-              <el-tag :type="levelTagType(currentOrder.warningLevel)" size="small">{{ levelText(currentOrder.warningLevel)
-                }}</el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="预警专项">{{ currentOrder.warningSpecial || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="权属单位">{{ currentOrder.ownershipUnit || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警时间">{{ formatDateTime(currentOrder.createTime) }}</el-descriptions-item>
-            <el-descriptions-item label="状态">
-              <el-tag :type="statusTagType(currentOrder.status)" size="small">{{ statusText(currentOrder.status)
-                }}</el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="发布人">{{ currentOrder.publisher || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="创建人">{{ currentOrder.createBy || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="位置" :span="2">{{ currentOrder.location || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警描述" :span="2">
-              <div class="desc-text">{{ currentOrder.content || '-' }}</div>
-            </el-descriptions-item>
-            <el-descriptions-item v-if="currentOrder.remark" label="备注" :span="2">{{ currentOrder.remark }}
-            </el-descriptions-item>
-          </el-descriptions>
-        </div>
-
-        <div class="section-box">
-          <div class="section-title">关联设备与预警指标</div>
-          <el-descriptions v-if="detailDevice" :column="2" border size="small">
-            <el-descriptions-item label="设备名称">{{ detailDevice.equipmentName || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="设备编码">{{ detailDevice.equipmentCode || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="设备类型">{{ equipmentTypeMap.get(String(detailDevice.equipmentTypeId)) || '-'
-              }}</el-descriptions-item>
-            <el-descriptions-item label="在线状态">
-              <el-tag :type="detailDevice.onlineStatus === 1 ? 'success' : 'info'" size="small">
-                {{ detailDevice.onlineStatus === 1 ? '在线' : '离线' }}
-              </el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="安装位置" :span="2">{{ detailDevice.equipmentLocation ||
-              currentOrder.location || '-' }}</el-descriptions-item>
-          </el-descriptions>
-          <el-alert v-else type="info" :closable="false" show-icon class="device-missing"
-            title="未在设备台账中找到该预警关联的设备,实时指标无法展示" />
-
-          <div class="metric-grid">
-            <div class="metric-card">
-              <div class="metric-label">实时液位</div>
-              <div class="metric-value">
-                <template v-if="detailMetric.level !== null">{{ detailMetric.level
-                  }}<span class="metric-unit">m</span></template>
-                <span v-else class="metric-empty">—</span>
-              </div>
-            </div>
-            <div class="metric-card">
-              <div class="metric-label">报警阈值</div>
-              <div class="metric-value metric-value-text">{{ detailMetric.thresholdText || '未配置' }}</div>
-            </div>
-            <div class="metric-card">
-              <div class="metric-label">采集时间</div>
-              <div class="metric-value metric-value-text">{{ detailMetric.collectTime || '—' }}</div>
-            </div>
-          </div>
-
-          <el-table v-if="detailThresholds.length" :data="detailThresholds" border size="small"
-            empty-text="该设备尚未配置报警阈值">
-            <el-table-column label="监测指标" prop="warningName" min-width="120" />
-            <el-table-column label="单位" width="90" align="center">
-              <template #default="{ row }">{{ row.warningUnit || '-' }}</template>
-            </el-table-column>
-            <el-table-column label="阈值范围" min-width="150" align="center">
-              <template #default="{ row }">{{ formatThreshold(row) }}</template>
-            </el-table-column>
-          </el-table>
-        </div>
-
-        <div class="section-box">
-          <div class="section-title">处置人员</div>
-          <el-descriptions :column="2" border size="small">
-            <el-descriptions-item label="处置人">{{ currentOrder.processor || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="发布人">{{ currentOrder.publisher || '-' }}</el-descriptions-item>
-          </el-descriptions>
-        </div>
-      </div>
-
-      <template #footer>
-        <el-button @click="detailVisible = false">关 闭</el-button>
-      </template>
-    </el-drawer>
-  </div>
+  <WarningListPanel mode="current" />
 </template>
 
 <script setup name="Dqyjgl">
-import { ref, computed, onMounted, nextTick } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import { Promotion } from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { getWarningCurrentPage, updateWarningStatus, deleteWarning, getEquipmentTypeList, getLatestWaterLevelByDevice } from '@/api/drainage'
-import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
-import { pageWarningThresholds } from '@/api/warningThreshold'
-import { listWarningTypes } from '@/api/warning/list'
-import useUserStore from '@/store/modules/user'
-
-// ==================== 用户信息 ====================
-const userStore = useUserStore()
-const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
-
-// ==================== 表格数据 ====================
-const tableData = ref([])
-const total = ref(0)
-const loading = ref(false)
-const pageNum = ref(1)
-const pageSize = ref(10)
-
-// ==================== 等级/状态工具方法 ====================
-function levelText(level) {
-  const map = { 1: '严重', 2: '重要', 3: '一般' }
-  return map[level] || '-'
-}
-function levelTagType(level) {
-  const map = { 1: 'danger', 2: 'warning', 3: '' }
-  return map[level] || 'info'
-}
-function statusText(status) {
-  const map = { DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }
-  return map[status] ?? '-'
-}
-function statusTagType(status) {
-  const map = { DRAFT: 'info', PENDING: 'warning', RECEIVED: 'warning', PROCESSING: 'warning', HANDLED: 'success', CLOSED: '' }
-  return map[status] || 'info'
-}
-
-// ==================== 设备类型 / 预警类型映射 ====================
-const equipmentTypeMap = ref(new Map())
-/** warningType(类型编码) -> 类型名称,来自 warning_type 表(与生命线预警系统同一份配置) */
-const warningTypeMap = ref(new Map())
-/** 预警类型筛选项原样保留,下拉直接用 */
-const warningTypeOptions = ref([])
-
-async function loadEquipmentTypes() {
-  try {
-    const res = await getEquipmentTypeList('2')
-    const list = res.code !== undefined ? res.data : res
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach(item => {
-        if (item.id !== undefined && item.id !== null) map.set(String(item.id), item.typeName)
-      })
-    }
-    equipmentTypeMap.value = map
-  } catch (error) {
-    console.error('加载设备类型列表失败', error)
-  }
-}
-
-async function loadWarningTypes() {
-  try {
-    const res = await listWarningTypes()
-    const list = res?.data || res?.rows || []
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach(item => {
-        if (item.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
-      })
-    }
-    warningTypeOptions.value = Array.isArray(list) ? list : []
-    warningTypeMap.value = map
-  } catch (error) {
-    console.warn('加载预警类型配置失败', error)
-  }
-}
-
-/** 预警类型展示:新数据看 warningType,历史数据回退到 deviceCode 的设备类型名 */
-function warningTypeText(row) {
-  if (!row) return '-'
-  const byType = row.warningType ? warningTypeMap.value.get(String(row.warningType)) : null
-  if (byType) return byType
-  const byDeviceType = row.deviceCode ? equipmentTypeMap.value.get(String(row.deviceCode)) : null
-  if (byDeviceType) return byDeviceType
-  return row.warningType || row.deviceCode || '-'
-}
-
-// ==================== 统计指标 ====================
-const statItems = computed(() => {
-  const list = tableData.value
-  return [
-    { label: '今日预警', value: total.value, cls: '' },
-    { label: '待办', value: list.filter(o => o.status === 'PENDING').length, cls: 'red' },
-    { label: '处置中', value: list.filter(o => o.status === 'PROCESSING').length, cls: '' },
-    { label: '已接收', value: list.filter(o => o.status === 'RECEIVED').length, cls: '' }
-  ]
-})
-
-// ==================== 环形图配置 ====================
-const statusLegends = computed(() => [
-  { name: '草稿', value: tableData.value.filter(o => o.status === 'DRAFT').length, color: '#909399' },
-  { name: '待办', value: tableData.value.filter(o => o.status === 'PENDING').length, color: '#E6A23C' },
-  { name: '处置中', value: tableData.value.filter(o => o.status === 'PROCESSING').length, color: '#409EFF' },
-  { name: '已接收', value: tableData.value.filter(o => o.status === 'RECEIVED').length, color: '#67C23A' }
-])
-const levelLegends = computed(() => [
-  { name: '严重', value: tableData.value.filter(o => Number(o.warningLevel) === 1).length, color: '#F56C6C' },
-  { name: '重要', value: tableData.value.filter(o => Number(o.warningLevel) === 2).length, color: '#E6A23C' },
-  { name: '一般', value: tableData.value.filter(o => Number(o.warningLevel) === 3).length, color: '#409EFF' }
-])
-const typeLegends = computed(() => {
-  const types = [...new Set(tableData.value.map(o => warningTypeText(o)).filter(t => t && t !== '-'))]
-  const colors = ['#409EFF', '#E6A23C', '#67C23A', '#F56C6C']
-  return types.map((t, i) => ({
-    name: t,
-    value: tableData.value.filter(o => warningTypeText(o) === t).length,
-    color: colors[i % colors.length]
-  }))
-})
-const ringCharts = [
-  { id: 'statusChart', title: '状态', legends: statusLegends },
-  { id: 'levelChart', title: '级别', legends: levelLegends },
-  { id: 'typeChart', title: '类型', legends: typeLegends }
-]
-
-// ==================== 筛选 ====================
-const tabs = ['全部', '草稿', '待办', '处置中', '已发布']
-const activeTab = ref('全部')
-const showSearch = ref(true)
-const queryRef = ref()
-const filters = ref({
-  equipmentName: '',
-  dateRange: null,
-  warningLevel: '',
-  warningType: ''
-})
-
-// ==================== 数据加载 ====================
-async function loadData() {
-  loading.value = true
-  try {
-    const params = {
-      equipmentType: 'drainage'
-    }
-    if (filters.value.equipmentName) params.equipmentName = filters.value.equipmentName
-    if (filters.value.warningLevel) params.warningLevel = filters.value.warningLevel
-    if (filters.value.warningType) params.warningType = filters.value.warningType
-    if (filters.value.dateRange && filters.value.dateRange.length === 2) {
-      params.startTime = `${filters.value.dateRange[0]} 00:00:00`
-      params.endTime = `${filters.value.dateRange[1]} 23:59:59`
-    }
-    // 告诉后端只返回当前预警(未处置的)
-    params.currentOnly = true
-    // Tab 映射为状态筛选
-    const tabStatusMap = { '草稿': 'DRAFT', '待办': 'PENDING', '已接收': 'RECEIVED', '处置中': 'PROCESSING' }
-    if (activeTab.value !== '全部' && tabStatusMap[activeTab.value] !== undefined) {
-      params.status = tabStatusMap[activeTab.value]
-    }
-    // 普通用户只能看到发给自己的预警,管理员能看到所有预警
-    params.userId = userStore.id
-    params.isAdmin = isAdmin.value
-    // 传递部门ID,用于按部门可见性过滤(publish_scope=2)
-    if (userStore.deptId) {
-      params.deptId = userStore.deptId
-    }
-    const res = await getWarningCurrentPage(pageNum.value, pageSize.value, params)
-    const pageData = res.code !== undefined ? res.data : res
-    tableData.value = pageData.records || []
-    total.value = pageData.total || 0
-  } catch (error) {
-    console.error('加载当前预警列表失败', error)
-    ElMessage.error('加载当前预警列表失败')
-  } finally {
-    loading.value = false
-    nextTick(() => initCharts())
-  }
-}
-
-function handleSearch() {
-  pageNum.value = 1
-  loadData()
-}
-
-function handleClear() {
-  filters.value = { equipmentName: '', dateRange: null, warningLevel: '', warningType: '' }
-  activeTab.value = '全部'
-  pageNum.value = 1
-  loadData()
-}
-
-function handleTabChange(tab) {
-  activeTab.value = tab
-  pageNum.value = 1
-  loadData()
-}
-
-// ==================== 操作:关闭/删除 ====================
-async function handleClose(row) {
-  try {
-    await ElMessageBox.confirm('确认解除该预警?解除后会进入历史预警。', '提示', { type: 'warning' })
-    // 处置人一并写入,否则历史预警的「处置人」列永远是空
-    await updateWarningStatus(row.id, {
-      status: 'CLOSED',
-      processor: userStore.nickName || userStore.name || ''
-    })
-    ElMessage.success('预警已解除')
-    loadData()
-  } catch (error) {
-    if (error !== 'cancel') console.error('关闭预警失败', error)
-  }
-}
-
-async function handleDelete(row) {
-  try {
-    await ElMessageBox.confirm('确认删除该预警?删除后不可恢复。', '提示', { type: 'warning' })
-    await deleteWarning(row.id)
-    ElMessage.success('删除成功')
-    loadData()
-  } catch (error) {
-    if (error !== 'cancel') console.error('删除预警失败', error)
-  }
-}
-
-// ==================== 详情弹窗 ====================
-const detailVisible = ref(false)
-const currentOrder = ref(null)
-const detailLoading = ref(false)
-const detailDevice = ref(null)
-const detailThresholds = ref([])
-const waterLevelMap = ref({})
-const thresholdMap = ref({})
-/** equipmentName -> 设备档案,排水预警 device_code 存的是类型 id,只能按名称反查设备 */
-const deviceByName = ref(null)
-
-/** 实时液位/阈值都是从设备档案派生的,统一在这里算,模板里直接取值 */
-const detailMetric = computed(() => {
-  const device = detailDevice.value
-  const code = device?.equipmentCode
-  const levelItem = code ? waterLevelMap.value[code] : null
-  const thresholds = code ? (thresholdMap.value[code] || []) : []
-  return {
-    level: levelItem && levelItem.value !== undefined && levelItem.value !== null ? levelItem.value : null,
-    collectTime: levelItem?.time ? formatDateTime(levelItem.time) : '',
-    thresholdText: thresholds.length > 0 ? thresholds.map(formatThreshold).join(' / ') : ''
-  }
-})
-
-async function handleView(row) {
-  currentOrder.value = { ...row }
-  detailDevice.value = null
-  detailThresholds.value = []
-  detailVisible.value = true
-  detailLoading.value = true
-  try {
-    const [device] = await Promise.all([resolveDevice(row.equipmentName), loadLiveMetrics()])
-    detailDevice.value = device || null
-    const code = device?.equipmentCode
-    detailThresholds.value = code ? (thresholdMap.value[code] || []) : []
-  } catch (error) {
-    console.warn('预警详情数据加载失败', error)
-  } finally {
-    detailLoading.value = false
-  }
-}
-
-/** 按设备名称在排水设备台账里反查设备(一次加载后缓存) */
-async function resolveDevice(equipmentName) {
-  if (!equipmentName) return null
-  if (!deviceByName.value) {
-    const map = new Map()
-    let pageNum = 1
-    while (pageNum <= 10) {
-      const res = await listEquipment({ rootTypeId: 2, pageNum, pageSize: 100 })
-      const page = res?.data && Array.isArray(res.data.records) ? res.data : res
-      const rows = page?.records || page?.rows || []
-      if (rows.length === 0) break
-      rows.forEach((item) => {
-        if (item?.equipmentName && !map.has(item.equipmentName)) map.set(item.equipmentName, item)
-      })
-      if (rows.length < 100) break
-      pageNum += 1
-    }
-    deviceByName.value = map
-  }
-  return deviceByName.value.get(equipmentName) || null
-}
-
-/** 实时液位 + 报警阈值,都按设备编码索引 */
-async function loadLiveMetrics() {
-  const [levelResult, thresholdResult] = await Promise.allSettled([
-    getLatestWaterLevelByDevice(),
-    pageWarningThresholds({ rootTypeId: 2, pageNum: 1, pageSize: 500 })
-  ])
-
-  if (levelResult.status === 'fulfilled') {
-    const list = Array.isArray(levelResult.value) ? levelResult.value : (levelResult.value?.data || [])
-    const map = {}
-    list.forEach((item) => {
-      if (!item?.deviceCode) return
-      map[item.deviceCode] = { value: item.waterLevel, time: item.createTime }
-    })
-    waterLevelMap.value = map
-  }
-
-  if (thresholdResult.status === 'fulfilled') {
-    const res = thresholdResult.value
-    const page = res?.data && Array.isArray(res.data.records) ? res.data : res
-    const rows = page?.records || page?.rows || []
-    const map = {}
-    rows.forEach((row) => {
-      if (!row?.deviceCode) return
-      if (!map[row.deviceCode]) map[row.deviceCode] = []
-      map[row.deviceCode].push(row)
-    })
-    thresholdMap.value = map
-  }
-}
-
-/** 阈值展示:dataType 0 区间 / 1 下限 / 2 上限(字段与报警阈值配置接口一致) */
-function formatThreshold(item) {
-  if (!item) return '-'
-  const unit = item.warningUnit || ''
-  const min = item.minValue
-  const max = item.maxValue
-  if (item.dataType === 1) return min === null || min === undefined ? '-' : `≥ ${min}${unit}`
-  if (item.dataType === 2) return max === null || max === undefined ? '-' : `≤ ${max}${unit}`
-  if ((min === null || min === undefined) && (max === null || max === undefined)) return '未设置'
-  return `${min ?? '-'} ~ ${max ?? '-'}${unit}`
-}
-
-function formatDateTime(value) {
-  if (!value) return '-'
-  return String(value).replace('T', ' ').slice(0, 19)
-}
-
-
-
-// ==================== ECharts 初始化 ====================
-function initCharts() {
-  ringCharts.forEach(chart => {
-    const dom = document.getElementById(chart.id)
-    if (!dom) return
-    const instance = echarts.getInstanceByDom(dom)
-    if (instance) instance.dispose()
-
-    const legends = chart.legends.value
-    const chartInstance = echarts.init(dom)
-    chartInstance.setOption({
-      title: {
-        text: chart.title,
-        left: 'center',
-        top: 'center',
-        textStyle: { fontSize: 13, color: '#333', fontWeight: 'normal' }
-      },
-      tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-      series: [{
-        type: 'pie',
-        radius: ['45%', '70%'],
-        center: ['50%', '50%'],
-        avoidLabelOverlap: false,
-        label: { show: false },
-        emphasis: { label: { show: false } },
-        data: legends.map(l => ({ name: l.name, value: l.value, itemStyle: { color: l.color } }))
-      }]
-    })
-  })
-}
-
-onMounted(async () => {
-  await Promise.all([loadEquipmentTypes(), loadWarningTypes()])
-  loadData()
-})
+/**
+ * 当前预警管理:与历史预警管理复用同一页面,数据范围为所有未解除预警。
+ * 注意:template 必须保持单根节点(不能写注释),否则外层 <transition mode="out-in"> 会白屏。
+ */
+import WarningListPanel from './components/WarningListPanel.vue'
 </script>
-
-<style scoped>
-.dqyjgl-page {
-  padding: 16px;
-  background: #f5f5f5;
-  min-height: calc(100vh - 84px);
-}
-
-/* ==================== 顶部统计区 ==================== */
-.stats-panel {
-  display: flex;
-  align-items: stretch;
-  background: #fff;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  padding: 16px 20px;
-  margin-bottom: 12px;
-  gap: 20px;
-}
-
-.stats-left {
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  min-width: 130px;
-  padding-right: 16px;
-  border-right: 1px solid #ebeef5;
-}
-
-.stat-item {
-  display: flex;
-  align-items: baseline;
-  gap: 8px;
-  padding: 6px 0;
-}
-
-.stat-value {
-  font-size: 24px;
-  font-weight: 700;
-  color: #303133;
-}
-
-.stat-value.red {
-  color: #F56C6C;
-}
-
-.stat-label {
-  font-size: 13px;
-  color: #909399;
-}
-
-.stats-center {
-  display: flex;
-  gap: 16px;
-  flex: 1;
-}
-
-.ring-chart-box {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  flex: 1;
-  min-width: 0;
-}
-
-.ring-chart {
-  width: 130px;
-  height: 130px;
-  flex-shrink: 0;
-}
-
-.ring-legend {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.legend-row {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 12px;
-  color: #606266;
-}
-
-.legend-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-  flex-shrink: 0;
-}
-
-.legend-text {
-  white-space: nowrap;
-}
-
-.legend-num {
-  font-weight: 600;
-  margin-left: 4px;
-}
-
-.stats-right {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  gap: 16px;
-  min-width: 80px;
-  border-left: 1px solid #ebeef5;
-  padding-left: 16px;
-}
-
-.right-metric {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.metric-icon {
-  font-size: 22px;
-  color: #E6A23C;
-}
-
-.metric-icon.orange {
-  color: #F56C6C;
-}
-
-.metric-num {
-  font-size: 22px;
-  font-weight: 700;
-  color: #303133;
-}
-
-/* ==================== 筛选区 ==================== */
-.filter-panel {
-  background: #fff;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  padding: 12px 16px;
-  margin-bottom: 12px;
-}
-
-.filter-tabs {
-  display: flex;
-  gap: 0;
-  margin-bottom: 12px;
-  border-bottom: 1px solid #ebeef5;
-}
-
-.tab-item {
-  padding: 8px 20px;
-  font-size: 14px;
-  color: #606266;
-  cursor: pointer;
-  border-bottom: 2px solid transparent;
-  transition: all 0.2s;
-}
-
-.tab-item:hover {
-  color: #409EFF;
-}
-
-.tab-item.active {
-  color: #409EFF;
-  border-bottom-color: #409EFF;
-  font-weight: 600;
-}
-
-.filter-form {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  flex-wrap: wrap;
-}
-
-/* ==================== 表格区 ==================== */
-.table-panel {
-  background: #fff;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
-  padding: 16px;
-}
-
-.priority-一般 {
-  color: #409EFF;
-}
-
-.priority-重要 {
-  color: #E6A23C;
-}
-
-.priority-紧急 {
-  color: #F56C6C;
-  font-weight: 600;
-}
-
-/* ==================== 详情弹窗 ==================== */
-.detail-content {
-  padding: 0 8px;
-}
-
-.steps-wrap {
-  margin-bottom: 24px;
-  padding: 8px 0;
-}
-
-.section-box {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 16px;
-  margin-bottom: 16px;
-}
-
-.section-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #303133;
-  margin-bottom: 12px;
-  overflow: hidden;
-}
-
-.desc-text {
-  background: #f5f7fa;
-  border-radius: 4px;
-  padding: 10px;
-  font-size: 13px;
-  color: #606266;
-  line-height: 1.6;
-  max-height: 120px;
-  overflow-y: auto;
-}
-
-/* 预警指标实时数据卡片 */
-.metric-grid {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 12px;
-  margin: 12px 0;
-}
-
-.metric-card {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 12px 14px;
-  background: #fafbfc;
-}
-
-.metric-label {
-  font-size: 12px;
-  color: #909399;
-  margin-bottom: 6px;
-}
-
-.metric-value {
-  font-size: 22px;
-  font-weight: 600;
-  color: #303133;
-  line-height: 1.2;
-}
-
-.metric-value-text {
-  font-size: 14px;
-  font-weight: 500;
-  word-break: break-all;
-}
-
-.metric-unit {
-  font-size: 12px;
-  font-weight: 400;
-  color: #909399;
-  margin-left: 2px;
-}
-
-.metric-empty {
-  color: #c0c4cc;
-}
-
-.device-missing {
-  margin-bottom: 12px;
-}
-
-:deep(.el-form-item__label) {
-  color: #909399;
-}
-
-:deep(.el-form-item__content) {
-  color: #303133;
-}
-</style>

+ 7 - 663
src/views/subSystem/drainage/jcyj/lsyjGIS.vue

@@ -1,667 +1,11 @@
 <template>
-  <div class="gis-page">
-    <!-- ==================== 左侧浮动面板 ==================== -->
-    <aside class="side-panel">
-      <div class="panel-header">
-        <div class="panel-title">历史预警分布</div>
-        <div class="panel-sub">
-          共 <b>{{ warningList.length }}</b> 条 · 已上图 <b>{{ mapPoints.length }}</b> 条
-        </div>
-      </div>
-
-      <!-- 概览(点击卡片按等级筛选) -->
-      <div class="panel-section">
-        <div class="section-label">
-          预警概览
-          <span class="section-hint">点击卡片筛选等级</span>
-        </div>
-        <div class="stat-grid">
-          <div class="stat-card" :class="{ active: levelFilter === 'all' }" @click="setLevel('all')">
-            <div class="stat-value">{{ rawList.length }}</div>
-            <div class="stat-text">全部</div>
-          </div>
-          <div class="stat-card stat-severe" :class="{ active: levelFilter === 'severe' }" @click="setLevel('severe')">
-            <div class="stat-value">{{ severeCount }}</div>
-            <div class="stat-text">严重</div>
-          </div>
-          <div class="stat-card stat-important" :class="{ active: levelFilter === 'important' }"
-            @click="setLevel('important')">
-            <div class="stat-value">{{ importantCount }}</div>
-            <div class="stat-text">重要</div>
-          </div>
-          <div class="stat-card stat-normal" :class="{ active: levelFilter === 'normal' }" @click="setLevel('normal')">
-            <div class="stat-value">{{ normalCount }}</div>
-            <div class="stat-text">一般</div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 筛选 -->
-      <div class="panel-section">
-        <div class="section-label">
-          筛选条件
-          <span class="link-btn" v-if="typeFilter || statusFilter || levelFilter !== 'all'" @click="resetFilter">重置</span>
-        </div>
-        <div class="filter-row">
-          <el-select v-model="typeFilter" placeholder="全部类型" clearable filterable size="small" style="flex:1">
-            <el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-          <el-select v-model="statusFilter" placeholder="全部状态" clearable size="small"
-            style="width:106px;flex-shrink:0">
-            <el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-        </div>
-      </div>
-
-      <!-- 预警清单 -->
-      <div class="panel-section list-section">
-        <div class="section-label">
-          归档清单
-          <span class="section-hint">{{ displayList.length }} 条</span>
-        </div>
-        <el-input v-model="searchKey" placeholder="搜索名称 / 位置" clearable size="small" :prefix-icon="Search" />
-        <div class="warning-list">
-          <div class="warning-item" v-for="item in displayList" :key="item.id" :class="{ 'no-coord': !item.lng }"
-            @click="locate(item)">
-            <span class="level-dot" :style="{ background: getLevelColor(item.level) }"></span>
-            <div class="wi-main">
-              <div class="wi-name" :title="item.name">{{ item.name }}</div>
-              <div class="wi-sub">归档于 {{ formatTime(item.archiveTime) }}</div>
-            </div>
-            <div class="wi-right">
-              <span class="wi-level" :class="getLevelClass(item.level)">{{ item.level }}</span>
-              <span class="wi-status" :class="`st-${item.statusKey}`">{{ item.statusText }}</span>
-            </div>
-          </div>
-          <div class="list-empty" v-if="!displayList.length">暂无符合条件的历史预警</div>
-        </div>
-      </div>
-    </aside>
-
-    <!-- ==================== 地图容器 ==================== -->
-    <div id="lsyjGISMap" class="map-container"></div>
-
-    <!-- 地图工具条 -->
-    <div class="map-tools">
-      <el-tooltip content="回到预警范围" placement="left">
-        <el-button circle @click="fitView"><el-icon><Aim /></el-icon></el-button>
-      </el-tooltip>
-    </div>
-
-    <!-- ==================== 详情抽屉 ==================== -->
-    <WarningGisDetail v-model="detailVisible" :record="currentRecord" mode="history" />
-  </div>
+  <WarningGisWorkspace mode="history" />
 </template>
 
-<script setup name="LsyjglGIS">
-import { ref, computed, watch, onMounted, nextTick } from 'vue'
-import { Search, Aim } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import locationIcon from '@/assets/images/location.png'
-import { getWarningHistoryPage } from '@/api/drainage'
-import { listWarningTypes } from '@/api/warning/list'
-import WarningGisDetail from './components/WarningGisDetail.vue'
-
-// ==================== 数据 ====================
-const rawList = ref([])
-/** 归一化后的全部预警(含无坐标的,清单里照样能看详情) */
-const warningList = ref([])
-
-/** 能上图的点位 */
-const mapPoints = computed(() => warningList.value.filter((p) => p.lng && p.lat))
-
-const levelMap = { 1: '严重', 2: '重要', 3: '一般' }
-const levelKeyMap = { 1: 'severe', 2: 'important', 3: 'normal' }
-const statusMap = {
-  DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收',
-  PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除'
-}
-
-const typeOptions = ref([])
-const statusOptions = ref([])
-const warningTypeMap = ref(new Map())
-
-async function loadWarningTypes() {
-  try {
-    const res = await listWarningTypes()
-    const list = res?.data || res?.rows || []
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach((item) => {
-        if (item?.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
-      })
-    }
-    warningTypeMap.value = map
-  } catch (error) {
-    console.warn('加载预警类型配置失败', error)
-  }
-}
-
-async function loadData() {
-  try {
-    const [res] = await Promise.all([
-      getWarningHistoryPage(1, 1000, { historyOnly: true, equipmentType: 'drainage' }),
-      loadWarningTypes()
-    ])
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || []
-    rawList.value = records
-    warningList.value = records.map(normalize)
-
-    // 类型下拉:按记录里实际出现的类型构建
-    const types = [...new Set(records.map((r) => r.warningType).filter(Boolean))]
-    typeOptions.value = types.map((val) => ({
-      value: String(val),
-      label: warningTypeMap.value.get(String(val)) || String(val)
-    }))
-
-    // 状态下拉
-    const statuses = [...new Set(records.map((r) => r.status).filter(Boolean))]
-    statusOptions.value = statuses.map((val) => ({ value: val, label: statusMap[val] || val }))
-
-    const noCoord = records.length - warningList.value.filter((p) => p.lng && p.lat).length
-    if (noCoord > 0) {
-      console.warn(`[历史预警GIS] ${noCoord} 条预警缺少经纬度,未上图(清单中仍可查看详情)`)
-    }
-  } catch (error) {
-    console.error('加载历史预警数据失败', error)
-    ElMessage.error('加载历史预警数据失败')
-  }
-}
-
-function normalize(w) {
-  return {
-    id: w.id,
-    name: w.equipmentName || '-',
-    level: levelMap[w.warningLevel] || '一般',
-    levelKey: levelKeyMap[w.warningLevel] || 'normal',
-    typeValue: w.warningType ? String(w.warningType) : '',
-    typeText: w.warningType ? (warningTypeMap.value.get(String(w.warningType)) || String(w.warningType)) : '未分类',
-    statusValue: w.status || '',
-    statusKey: String(w.status || 'unknown').toLowerCase(),
-    statusText: statusMap[w.status] || '-',
-    createTime: w.createTime || '',
-    archiveTime: w.updateTime || w.publishTime || w.createTime || '',
-    location: w.location || '',
-    raw: w,
-    lng: parseFloat(w.longitude),
-    lat: parseFloat(w.latitude)
-  }
-}
-
-// ==================== 统计 ====================
-const severeCount = computed(() => rawList.value.filter((v) => v.warningLevel === 1).length)
-const importantCount = computed(() => rawList.value.filter((v) => v.warningLevel === 2).length)
-const normalCount = computed(() => rawList.value.filter((v) => v.warningLevel === 3).length)
-
-// ==================== 筛选 ====================
-const levelFilter = ref('all')
-const typeFilter = ref('')
-const statusFilter = ref('')
-const searchKey = ref('')
-
-const displayList = computed(() => {
-  const key = searchKey.value.trim().toLowerCase()
-  return warningList.value.filter((item) => {
-    if (levelFilter.value !== 'all' && item.levelKey !== levelFilter.value) return false
-    if (typeFilter.value && item.typeValue !== typeFilter.value) return false
-    if (statusFilter.value && item.statusValue !== statusFilter.value) return false
-    if (key && !(item.name.toLowerCase().includes(key) || item.location.toLowerCase().includes(key))) return false
-    return true
-  })
-})
-
-function setLevel(key) {
-  levelFilter.value = levelFilter.value === key ? 'all' : key
-}
-
-function resetFilter() {
-  levelFilter.value = 'all'
-  typeFilter.value = ''
-  statusFilter.value = ''
-  searchKey.value = ''
-}
-
-watch(displayList, () => {
-  const visibleIds = new Set(displayList.value.map((item) => item.id))
-  warningList.value.forEach((point) => {
-    const label = markerMap.get(point.id)
-    if (!label) return
-    if (visibleIds.has(point.id)) label.show()
-    else label.hide()
-  })
-})
-
-// ==================== 工具方法 ====================
-function getLevelColor(level) {
-  const map = { '严重': '#f56c6c', '重要': '#e6a23c', '一般': '#409eff' }
-  return map[level] || '#409eff'
-}
-
-function getLevelClass(level) {
-  const map = { '严重': 'level-severe', '重要': 'level-important', '一般': 'level-normal' }
-  return map[level] || ''
-}
-
-function formatTime(value) {
-  if (!value) return '-'
-  return String(value).replace('T', ' ').slice(5, 16)
-}
-
-// ==================== 地图 ====================
-let mapInstance = null
-const markerMap = new Map()
-
-function initMap() {
-  const container = document.getElementById('lsyjGISMap')
-  if (!container || typeof TdtMap === 'undefined') {
-    console.warn('TdtMap 未加载或容器不存在')
-    return
-  }
-  try {
-    mapInstance = new TdtMap.Map('lsyjGISMap')
-    mapInstance.centerAndZoom(new TdtMap.Point(110.393, 28.452), 15)
-    mapInstance.enableScrollWheelZoom(true)
-
-    mapPoints.value.forEach(addMapMarker)
-    fitView()
-  } catch (e) {
-    console.error('天地图初始化失败:', e)
-  }
-}
-
-function addMapMarker(point) {
-  const color = getLevelColor(point.level)
-  const bPoint = new TdtMap.Point(point.lng, point.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);">
-      ${point.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new TdtMap.Label(html, {
-    position: bPoint,
-    offset: new TdtMap.Size(-30, -50)
-  })
-  label.setStyle({ border: 'none', background: 'transparent', padding: '0', zIndex: '10' })
-  mapInstance.addOverlay(label)
-  markerMap.set(point.id, label)
-
-  label.addEventListener('click', () => locate(point))
-}
-
-/** 回到能看全所有点位的视野 */
-function fitView() {
-  if (!mapInstance || !mapPoints.value.length) return
-  const points = mapPoints.value.map((p) => new TdtMap.Point(p.lng, p.lat))
-  if (points.length === 1) {
-    mapInstance.centerAndZoom(points[0], 16)
-    return
-  }
-  mapInstance.setViewport(points)
-}
-
-function locate(item) {
-  if (!item) return
-  if (mapInstance && item.lng && item.lat) {
-    mapInstance.centerAndZoom(new TdtMap.Point(item.lng, item.lat), 17)
-  }
-  if (!item.lng || !item.lat) {
-    ElMessage.warning('该预警缺少经纬度坐标,无法在地图上定位')
-  }
-  openDetail(item)
-}
-
-// ==================== 详情 ====================
-const detailVisible = ref(false)
-const currentRecord = ref(null)
-
-function openDetail(item) {
-  currentRecord.value = item.raw || null
-  detailVisible.value = true
-}
-
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  await loadData()
-  nextTick(() => initMap())
-})
+<script setup name="LsyjGIS">
+/**
+ * 历史预警GIS一张图:与当前预警GIS一张图复用同一页面,数据范围为所有已解除预警。
+ * 注意:template 必须保持单根节点(不能写注释),否则外层 <transition mode="out-in"> 会白屏。
+ */
+import WarningGisWorkspace from './components/WarningGisWorkspace.vue'
 </script>
-
-<style scoped>
-.gis-page {
-  position: relative;
-  width: 100%;
-  height: calc(100vh - 84px);
-  overflow: hidden;
-}
-
-.map-container {
-  /* Leaflet 内部 pane 的 z-index 为 200~800,用独立堆叠上下文限制在容器内,
-     否则会盖住 z-index 更低的左侧面板和右上工具条 */
-  position: relative;
-  z-index: 0;
-  width: 100%;
-  height: 100%;
-}
-
-/* ==================== 左侧浮动面板 ==================== */
-.side-panel {
-  position: absolute;
-  top: 16px;
-  left: 16px;
-  bottom: 16px;
-  width: 300px;
-  display: flex;
-  flex-direction: column;
-  background: rgba(6, 30, 65, 0.88);
-  border: 1px solid rgba(90, 160, 255, 0.18);
-  border-radius: 10px;
-  z-index: 9999;
-  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
-  backdrop-filter: blur(6px);
-  overflow: hidden;
-}
-
-.panel-header {
-  padding: 16px 18px 12px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-  flex-shrink: 0;
-}
-
-.panel-title {
-  font-size: 16px;
-  font-weight: 600;
-  color: #fff;
-  letter-spacing: 1px;
-}
-
-.panel-sub {
-  margin-top: 6px;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.55);
-}
-
-.panel-sub b {
-  color: #61c0ff;
-  font-weight: 600;
-}
-
-.panel-section {
-  padding: 12px 18px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-  flex-shrink: 0;
-}
-
-.panel-section.list-section {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  border-bottom: none;
-  padding-bottom: 14px;
-}
-
-.section-label {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.55);
-  margin-bottom: 10px;
-  letter-spacing: 1px;
-}
-
-.section-hint {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.32);
-  letter-spacing: 0;
-}
-
-.link-btn {
-  font-size: 12px;
-  color: #61c0ff;
-  cursor: pointer;
-  letter-spacing: 0;
-}
-
-.link-btn:hover {
-  text-decoration: underline;
-}
-
-/* 统计卡片 */
-.stat-grid {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 6px;
-}
-
-.stat-card {
-  background: rgba(255, 255, 255, 0.07);
-  border: 1px solid transparent;
-  border-radius: 6px;
-  padding: 9px 2px;
-  text-align: center;
-  cursor: pointer;
-  transition: all 0.18s;
-}
-
-.stat-card:hover {
-  background: rgba(255, 255, 255, 0.13);
-}
-
-.stat-card.active {
-  border-color: rgba(97, 192, 255, 0.85);
-  background: rgba(97, 192, 255, 0.16);
-}
-
-.stat-card.stat-severe {
-  background: rgba(245, 108, 108, 0.14);
-}
-
-.stat-card.stat-important {
-  background: rgba(230, 162, 60, 0.14);
-}
-
-.stat-card.stat-normal {
-  background: rgba(64, 158, 255, 0.14);
-}
-
-.stat-value {
-  font-size: 19px;
-  font-weight: 700;
-  color: #fff;
-  line-height: 1.2;
-  font-variant-numeric: tabular-nums;
-}
-
-.stat-severe .stat-value {
-  color: #f56c6c;
-}
-
-.stat-important .stat-value {
-  color: #e6a23c;
-}
-
-.stat-normal .stat-value {
-  color: #409eff;
-}
-
-.stat-text {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-top: 3px;
-}
-
-/* 筛选行 */
-.filter-row {
-  display: flex;
-  gap: 6px;
-}
-
-/* 深色面板下的输入控件 */
-.side-panel :deep(.el-input__wrapper),
-.side-panel :deep(.el-select__wrapper) {
-  background: rgba(255, 255, 255, 0.08);
-  box-shadow: none;
-  border: 1px solid rgba(255, 255, 255, 0.12);
-}
-
-.side-panel :deep(.el-input__inner),
-.side-panel :deep(.el-select__placeholder) {
-  color: #fff;
-  font-size: 13px;
-}
-
-.side-panel :deep(.el-input__inner::placeholder) {
-  color: rgba(255, 255, 255, 0.35);
-}
-
-.side-panel :deep(.el-input__prefix .el-icon),
-.side-panel :deep(.el-select__caret) {
-  color: rgba(255, 255, 255, 0.45);
-}
-
-/* 预警清单 */
-.warning-list {
-  flex: 1;
-  min-height: 0;
-  margin-top: 8px;
-  overflow-y: auto;
-  margin-right: -6px;
-  padding-right: 4px;
-}
-
-.warning-list::-webkit-scrollbar {
-  width: 4px;
-}
-
-.warning-list::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.18);
-  border-radius: 2px;
-}
-
-.warning-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 8px 8px;
-  border-radius: 6px;
-  cursor: pointer;
-  transition: background 0.15s;
-}
-
-.warning-item:hover {
-  background: rgba(255, 255, 255, 0.1);
-}
-
-/* 无坐标的记录仍可查看详情,视觉上弱化 */
-.warning-item.no-coord .wi-name,
-.warning-item.no-coord .wi-sub {
-  color: rgba(255, 255, 255, 0.42);
-}
-
-.warning-item.no-coord .level-dot {
-  opacity: 0.4;
-}
-
-.wi-main {
-  flex: 1;
-  min-width: 0;
-}
-
-.wi-name {
-  font-size: 13px;
-  color: rgba(255, 255, 255, 0.9);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.wi-sub {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.42);
-  margin-top: 2px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.wi-right {
-  flex-shrink: 0;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-end;
-  gap: 3px;
-}
-
-.wi-level {
-  font-size: 11px;
-  padding: 1px 6px;
-  border-radius: 3px;
-}
-
-.wi-status {
-  font-size: 10px;
-  color: rgba(255, 255, 255, 0.45);
-}
-
-.wi-status.st-handled {
-  color: #67c23a;
-}
-
-.wi-status.st-closed {
-  color: #61c0ff;
-}
-
-.list-empty {
-  text-align: center;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.32);
-  padding: 24px 0;
-}
-
-.level-dot {
-  flex-shrink: 0;
-  display: inline-block;
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-}
-
-.wi-level.level-severe {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.wi-level.level-important {
-  color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
-}
-
-.wi-level.level-normal {
-  color: #409eff;
-  background: rgba(64, 158, 255, 0.15);
-}
-
-/* ==================== 地图工具条 ==================== */
-.map-tools {
-  position: absolute;
-  top: 16px;
-  right: 16px;
-  z-index: 9999;
-}
-
-.map-tools :deep(.el-button) {
-  background: rgba(6, 30, 65, 0.85);
-  border-color: rgba(90, 160, 255, 0.25);
-  color: #fff;
-}
-
-.map-tools :deep(.el-button:hover) {
-  background: rgba(30, 70, 130, 0.95);
-  border-color: rgba(90, 160, 255, 0.5);
-  color: #fff;
-}
-</style>

+ 6 - 841
src/views/subSystem/drainage/jcyj/lsyjgl.vue

@@ -1,846 +1,11 @@
 <template>
-  <div class="app-container lsyjgl-page">
-
-    <!-- ==================== 分类统计 ==================== -->
-    <div class="stats-panel">
-      <div class="stats-left">
-        <div class="stat-item" v-for="item in statItems" :key="item.label">
-          <div class="stat-value" :class="item.cls">{{ item.value }}</div>
-          <div class="stat-label">{{ item.label }}</div>
-        </div>
-      </div>
-      <div class="stats-right">
-        <div class="ring-chart-box" v-for="chart in ringCharts" :key="chart.id">
-          <div :id="chart.id" class="ring-chart"></div>
-          <div class="ring-legend">
-            <div class="legend-row" v-for="(leg, i) in chart.legends.value" :key="i">
-              <span class="legend-dot" :style="{ background: leg.color }"></span>
-              <span class="legend-text">{{ leg.name }}</span>
-              <span class="legend-num">{{ leg.value }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- ==================== 搜索区域 ==================== -->
-    <el-form ref="queryRef" :model="filters" :inline="true" v-show="showSearch" label-width="80px">
-      <el-form-item label="预警名称">
-        <el-input v-model="filters.equipmentName" placeholder="请输入预警名称" clearable style="width: 200px"
-          @keyup.enter="handleSearch" />
-      </el-form-item>
-      <el-form-item label="预警类型">
-        <el-select v-model="filters.warningType" placeholder="请选择预警类型" clearable filterable style="width: 180px">
-          <el-option v-for="item in warningTypeOptions" :key="item.dictValue" :label="item.dictLabel"
-            :value="item.dictValue" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="预警级别">
-        <el-select v-model="filters.warningLevel" placeholder="请选择预警级别" clearable style="width: 180px">
-          <el-option label="严重" :value="1" />
-          <el-option label="重要" :value="2" />
-          <el-option label="一般" :value="3" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="处理状态">
-        <el-select v-model="filters.status" placeholder="请选择处理状态" clearable style="width: 180px">
-          <el-option label="已处置" value="HANDLED" />
-          <el-option label="已解除" value="CLOSED" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="预警时间" style="width: 308px">
-        <el-date-picker v-model="filters.dateRange" type="daterange" range-separator="-" start-placeholder="开始日期"
-          end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 240px" />
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" icon="Search" @click="handleSearch">搜索</el-button>
-        <el-button icon="Refresh" @click="handleClear">重置</el-button>
-      </el-form-item>
-    </el-form>
-
-    <!-- ==================== 操作栏 ==================== -->
-    <el-row :gutter="10" class="mb8">
-      <el-col :span="1.5">
-        <el-button type="warning" plain icon="Download" :loading="exporting" @click="handleExport">导出</el-button>
-      </el-col>
-      <el-col :span="1.5">
-        <el-button type="primary" plain icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
-      </el-col>
-      <right-toolbar v-model:showSearch="showSearch" @queryTable="loadData"></right-toolbar>
-    </el-row>
-
-    <!-- ==================== 数据表格 ==================== -->
-    <div>
-      <el-table :data="tableData" style="width:100%" v-loading="loading"
-        empty-text="暂无已处置 / 已解除的预警记录">
-        <el-table-column label="归档编号" width="150" show-overflow-tooltip>
-          <template #default="{ row }">{{ archiveNoOf(row) }}</template>
-        </el-table-column>
-        <el-table-column prop="warningNo" label="预警单号" width="175" show-overflow-tooltip />
-        <el-table-column prop="equipmentName" label="预警名称" min-width="130" show-overflow-tooltip />
-        <el-table-column label="预警类型" width="130" align="center" show-overflow-tooltip>
-          <template #default="{ row }">{{ warningTypeText(row) }}</template>
-        </el-table-column>
-        <el-table-column prop="warningLevel" label="预警级别" width="90" align="center">
-          <template #default="{ row }">
-            <el-tag :type="levelTagType(row.warningLevel)" size="small">{{ levelText(row.warningLevel) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="预警描述" min-width="190" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.content || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="ownershipUnit" label="权属单位" width="145" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.ownershipUnit || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="location" label="位置" min-width="140" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.location || '-' }}</template>
-        </el-table-column>
-        <el-table-column label="预警时间" width="168">
-          <template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
-        </el-table-column>
-        <el-table-column prop="publisher" label="发布人" width="110" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.publisher || '-' }}</template>
-        </el-table-column>
-        <el-table-column prop="processor" label="处置人" width="110" show-overflow-tooltip>
-          <template #default="{ row }">{{ row.processor || '-' }}</template>
-        </el-table-column>
-        <el-table-column label="归档时间" width="168">
-          <template #default="{ row }">{{ formatDateTime(archiveTimeOf(row)) }}</template>
-        </el-table-column>
-        <el-table-column prop="status" label="处理状态" width="100" align="center">
-          <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="130" fixed="right">
-          <template #default="{ row }">
-            <el-button link type="primary" size="small" @click="handleView(row)">查看</el-button>
-            <el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-      <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize"
-        @pagination="loadData" />
-    </div>
-
-    <!-- ==================== 查看详情弹窗 ==================== -->
-    <el-drawer v-model="dialogVisible" title="历史预警详情" size="720px" :close-on-click-modal="false"
-      class="detail-drawer" direction="rtl">
-      <div class="detail-content" v-if="currentDetail" v-loading="detailLoading">
-        <div class="section-box">
-          <div class="section-title">预警信息</div>
-          <el-descriptions :column="2" border size="small">
-            <el-descriptions-item label="预警单号">{{ currentDetail.warningNo || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警名称">{{ currentDetail.equipmentName || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警类型">{{ warningTypeText(currentDetail) }}</el-descriptions-item>
-            <el-descriptions-item label="预警级别">
-              <el-tag :type="levelTagType(currentDetail.warningLevel)" size="small">{{
-                levelText(currentDetail.warningLevel) }}</el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="预警专项">{{ currentDetail.warningSpecial || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="权属单位">{{ currentDetail.ownershipUnit || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警时间">{{ formatDateTime(currentDetail.createTime) }}</el-descriptions-item>
-            <el-descriptions-item label="处理状态">
-              <el-tag :type="statusTagType(currentDetail.status)" size="small">{{ statusText(currentDetail.status)
-                }}</el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="发布人">{{ currentDetail.publisher || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="创建人">{{ currentDetail.createBy || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="位置" :span="2">{{ currentDetail.location || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="预警描述" :span="2">
-              <div class="desc-text">{{ currentDetail.content || '-' }}</div>
-            </el-descriptions-item>
-            <el-descriptions-item v-if="currentDetail.remark" label="备注" :span="2">
-              {{ currentDetail.remark }}
-            </el-descriptions-item>
-          </el-descriptions>
-        </div>
-
-        <div class="section-box">
-          <div class="section-title">归档信息</div>
-          <el-descriptions :column="2" border size="small">
-            <el-descriptions-item label="归档编号">{{ archiveNoOf(currentDetail) }}</el-descriptions-item>
-            <el-descriptions-item label="归档时间">{{ formatDateTime(archiveTimeOf(currentDetail)) }}</el-descriptions-item>
-            <el-descriptions-item label="处置人">{{ currentDetail.processor || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="归档状态">{{ statusText(currentDetail.status) }}</el-descriptions-item>
-            <el-descriptions-item label="发布时间" :span="2">
-              {{ formatDateTime(currentDetail.publishTime) }}
-            </el-descriptions-item>
-          </el-descriptions>
-        </div>
-
-        <div class="section-box">
-          <div class="section-title">关联设备与预警指标</div>
-          <el-descriptions v-if="detailDevice" :column="2" border size="small">
-            <el-descriptions-item label="设备名称">{{ detailDevice.equipmentName || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="设备编码">{{ detailDevice.equipmentCode || '-' }}</el-descriptions-item>
-            <el-descriptions-item label="设备类型">{{ equipmentTypeMap.get(String(detailDevice.equipmentTypeId)) || '-'
-              }}</el-descriptions-item>
-            <el-descriptions-item label="在线状态">
-              <el-tag :type="detailDevice.onlineStatus === 1 ? 'success' : 'info'" size="small">
-                {{ detailDevice.onlineStatus === 1 ? '在线' : '离线' }}
-              </el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="安装位置" :span="2">{{ detailDevice.equipmentLocation ||
-              currentDetail.location || '-' }}</el-descriptions-item>
-          </el-descriptions>
-          <el-alert v-else type="info" :closable="false" show-icon class="device-missing"
-            title="未在设备台账中找到该预警关联的设备,指标数据无法展示" />
-
-          <div class="metric-grid">
-            <div class="metric-card">
-              <div class="metric-label">实时液位</div>
-              <div class="metric-value">
-                <template v-if="detailMetric.level !== null">{{ detailMetric.level
-                  }}<span class="metric-unit">m</span></template>
-                <span v-else class="metric-empty">—</span>
-              </div>
-            </div>
-            <div class="metric-card">
-              <div class="metric-label">报警阈值</div>
-              <div class="metric-value metric-value-text">{{ detailMetric.thresholdText || '未配置' }}</div>
-            </div>
-            <div class="metric-card">
-              <div class="metric-label">采集时间</div>
-              <div class="metric-value metric-value-text">{{ detailMetric.collectTime || '—' }}</div>
-            </div>
-          </div>
-
-          <el-table v-if="detailThresholds.length" :data="detailThresholds" border size="small"
-            empty-text="该设备尚未配置报警阈值">
-            <el-table-column label="监测指标" prop="warningName" min-width="120" />
-            <el-table-column label="单位" width="90" align="center">
-              <template #default="{ row }">{{ row.warningUnit || '-' }}</template>
-            </el-table-column>
-            <el-table-column label="阈值范围" min-width="150" align="center">
-              <template #default="{ row }">{{ formatThreshold(row) }}</template>
-            </el-table-column>
-          </el-table>
-        </div>
-      </div>
-
-      <template #footer>
-        <el-button @click="dialogVisible = false">关 闭</el-button>
-      </template>
-    </el-drawer>
-
-  </div>
+  <WarningListPanel mode="history" />
 </template>
 
 <script setup name="Lsyjgl">
-import { ref, reactive, computed, onMounted, nextTick } from 'vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-import * as XLSX from 'xlsx'
-import { saveAs } from 'file-saver'
-import * as echarts from 'echarts'
-import { getWarningHistoryPage, deleteWarning, getEquipmentTypeList, getLatestWaterLevelByDevice } from '@/api/drainage'
-import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
-import { pageWarningThresholds } from '@/api/warningThreshold'
-import { listWarningTypes } from '@/api/warning/list'
-
-// ==================== 设备类型 / 预警类型映射 ====================
-const equipmentTypeMap = ref(new Map())
-/** warningType(类型编码) -> 类型名称,来自 warning_type 表 */
-const warningTypeMap = ref(new Map())
-const warningTypeOptions = ref([])
-
-async function loadEquipmentTypes() {
-  try {
-    const res = await getEquipmentTypeList('2')
-    const list = res.code !== undefined ? res.data : res
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach(item => {
-        if (item.id !== undefined && item.id !== null) map.set(String(item.id), item.typeName)
-      })
-    }
-    equipmentTypeMap.value = map
-  } catch (error) {
-    console.error('加载设备类型列表失败', error)
-  }
-}
-
-async function loadWarningTypes() {
-  try {
-    const res = await listWarningTypes()
-    const list = res?.data || res?.rows || []
-    const map = new Map()
-    if (Array.isArray(list)) {
-      list.forEach(item => {
-        if (item.dictValue) map.set(String(item.dictValue), item.dictLabel || item.dictValue)
-      })
-    }
-    warningTypeOptions.value = Array.isArray(list) ? list : []
-    warningTypeMap.value = map
-  } catch (error) {
-    console.warn('加载预警类型配置失败', error)
-  }
-}
-
-/** 预警类型展示:新数据看 warningType,历史数据回退到 deviceCode 的设备类型名 */
-function warningTypeText(row) {
-  if (!row) return '-'
-  const byType = row.warningType ? warningTypeMap.value.get(String(row.warningType)) : null
-  if (byType) return byType
-  const byDeviceType = row.deviceCode ? equipmentTypeMap.value.get(String(row.deviceCode)) : null
-  if (byDeviceType) return byDeviceType
-  return row.warningType || row.deviceCode || '-'
-}
-
-// ==================== 等级/状态工具方法 ====================
-function levelText(level) {
-  const map = { 1: '严重', 2: '重要', 3: '一般' }
-  return map[level] || '-'
-}
-function levelTagType(level) {
-  const map = { 1: 'danger', 2: 'warning', 3: '' }
-  return map[level] || 'info'
-}
-function statusText(status) {
-  const map = { DRAFT: '草稿', PENDING: '待办', RECEIVED: '已接收', PROCESSING: '处置中', HANDLED: '已处置', CLOSED: '已解除' }
-  return map[status] ?? '-'
-}
-function statusTagType(status) {
-  const map = { DRAFT: 'info', PENDING: 'warning', RECEIVED: 'warning', PROCESSING: 'warning', HANDLED: 'success', CLOSED: '' }
-  return map[status] || 'info'
-}
-
-function formatDateTime(value) {
-  if (!value) return '-'
-  return String(value).replace('T', ' ').slice(0, 19)
-}
-
-/** 归档编号:预警解除即归档,编号由预警单号派生,稳定可追溯 */
-function archiveNoOf(row) {
-  if (!row?.warningNo) return '-'
-  return `ARC${String(row.warningNo).replace(/^WRN/, '').slice(-12)}`
-}
-
-/** 归档时间:解除/处置动作发生在 update_time,缺失时退回发布时间 */
-function archiveTimeOf(row) {
-  return row?.updateTime || row?.publishTime || null
-}
-
-// ==================== 分类统计 ====================
-const statItems = computed(() => {
-  const list = tableData.value
-  const devices = new Set(list.map(o => o.equipmentName).filter(Boolean))
-  return [
-    { label: '归档预警', value: total.value, cls: '' },
-    { label: '已处置', value: list.filter(o => o.status === 'HANDLED').length, cls: 'green' },
-    { label: '已解除', value: list.filter(o => o.status === 'CLOSED').length, cls: 'blue' },
-    { label: '涉及设备', value: devices.size, cls: '' }
-  ]
-})
-
-const statusLegends = computed(() => [
-  { name: '已处置', value: tableData.value.filter(o => o.status === 'HANDLED').length, color: '#67C23A' },
-  { name: '已解除', value: tableData.value.filter(o => o.status === 'CLOSED').length, color: '#409EFF' }
-])
-
-const levelLegends = computed(() => [
-  { name: '严重', value: tableData.value.filter(o => o.warningLevel === 1).length, color: '#F56C6C' },
-  { name: '重要', value: tableData.value.filter(o => o.warningLevel === 2).length, color: '#E6A23C' },
-  { name: '一般', value: tableData.value.filter(o => o.warningLevel === 3).length, color: '#909399' }
-])
-
-const ringCharts = [
-  { id: 'historyStatusChart', legends: statusLegends },
-  { id: 'historyLevelChart', legends: levelLegends }
-]
-
-function initCharts() {
-  ringCharts.forEach(chart => {
-    const dom = document.getElementById(chart.id)
-    if (!dom) return
-    const legends = chart.legends.value
-    const chartInstance = echarts.getInstanceByDom(dom) || echarts.init(dom)
-    chartInstance.setOption({
-      tooltip: { trigger: 'item', formatter: '{b}:{c}({d}%)' },
-      series: [{
-        type: 'pie',
-        radius: ['58%', '82%'],
-        center: ['50%', '50%'],
-        avoidLabelOverlap: false,
-        label: { show: false },
-        labelLine: { show: false },
-        data: legends.map(item => ({
-          name: item.name,
-          value: item.value,
-          itemStyle: { color: item.color }
-        }))
-      }]
-    }, true)
-  })
-}
-
-// ==================== 表格数据 ====================
-const tableData = ref([])
-const total = ref(0)
-const loading = ref(false)
-const pageNum = ref(1)
-const pageSize = ref(10)
-
-// ==================== 搜索筛选 ====================
-const showSearch = ref(true)
-const queryRef = ref()
-const filters = reactive({
-  equipmentName: '',
-  warningType: '',
-  warningLevel: '',
-  status: '',
-  dateRange: null
-})
-
-function buildParams() {
-  const params = {
-    historyOnly: true,
-    equipmentType: 'drainage'
-  }
-  if (filters.equipmentName) params.equipmentName = filters.equipmentName
-  if (filters.warningType) params.warningType = filters.warningType
-  if (filters.warningLevel) params.warningLevel = filters.warningLevel
-  if (filters.status !== '' && filters.status !== null) params.status = filters.status
-  if (filters.dateRange && filters.dateRange.length === 2) {
-    params.startTime = `${filters.dateRange[0]} 00:00:00`
-    params.endTime = `${filters.dateRange[1]} 23:59:59`
-  }
-  return params
-}
-
-// ==================== 数据加载 ====================
-async function loadData() {
-  loading.value = true
-  try {
-    // 历史页由后端按历史状态(HANDLED/CLOSED)限定
-    const res = await getWarningHistoryPage(pageNum.value, pageSize.value, buildParams())
-    const pageData = res.code !== undefined ? res.data : res
-    tableData.value = pageData.records || []
-    total.value = pageData.total || 0
-  } catch (error) {
-    console.error('加载历史预警列表失败', error)
-    ElMessage.error('加载历史预警列表失败')
-  } finally {
-    loading.value = false
-    nextTick(() => initCharts())
-  }
-}
-
-function handleSearch() {
-  pageNum.value = 1
-  loadData()
-}
-
-function handleClear() {
-  filters.equipmentName = ''
-  filters.warningType = ''
-  filters.warningLevel = ''
-  filters.status = ''
-  filters.dateRange = null
-  pageNum.value = 1
-  loadData()
-}
-
-// ==================== 归档导出 ====================
-const exporting = ref(false)
-const EXPORT_PAGE_SIZE = 100
-
-const EXPORT_HEADER = [
-  '归档编号', '预警单号', '预警名称', '预警类型', '预警级别', '预警描述',
-  '权属单位', '位置', '预警时间', '发布人', '处置人', '归档时间', '处理状态'
-]
-
-function exportValues(row) {
-  return [
-    archiveNoOf(row),
-    row.warningNo || '',
-    row.equipmentName || '',
-    warningTypeText(row),
-    levelText(row.warningLevel),
-    row.content || '',
-    row.ownershipUnit || '',
-    row.location || '',
-    formatDateTime(row.createTime),
-    row.publisher || '',
-    row.processor || '',
-    formatDateTime(archiveTimeOf(row)),
-    statusText(row.status)
-  ]
-}
-
-/** 按当前筛选条件翻页拉全量,单页给大值容易被后端分页上限截断 */
-async function fetchAllRows() {
-  const collected = []
-  let page = 1
-  while (page <= 50) {
-    const res = await getWarningHistoryPage(page, EXPORT_PAGE_SIZE, buildParams())
-    const pageData = res.code !== undefined ? res.data : res
-    const rows = pageData.records || pageData.rows || []
-    if (rows.length === 0) break
-    collected.push(...rows)
-    const totalCount = Number(pageData.total) || 0
-    if (rows.length < EXPORT_PAGE_SIZE) break
-    if (totalCount > 0 && collected.length >= totalCount) break
-    page += 1
-  }
-  return collected
-}
-
-async function handleExport() {
-  exporting.value = true
-  try {
-    const rows = await fetchAllRows()
-    if (rows.length === 0) {
-      ElMessage.warning('当前条件下没有可导出的归档记录')
-      return
-    }
-    const sheet = XLSX.utils.aoa_to_sheet([EXPORT_HEADER, ...rows.map(exportValues)])
-    const book = XLSX.utils.book_new()
-    XLSX.utils.book_append_sheet(book, sheet, '历史预警归档')
-    const d = new Date()
-    const pad = (n) => String(n).padStart(2, '0')
-    const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`
-    saveAs(new Blob([XLSX.write(book, { bookType: 'xlsx', type: 'array' })]),
-      `历史预警归档清单_${stamp}.xlsx`)
-    ElMessage.success(`已导出 ${rows.length} 条归档记录`)
-  } catch (error) {
-    console.error('导出归档清单失败', error)
-    ElMessage.error('导出失败,请稍后重试')
-  } finally {
-    exporting.value = false
-  }
-}
-
-// ==================== 操作:删除 ====================
-async function handleDelete(row) {
-  try {
-    await ElMessageBox.confirm('确认删除该预警?删除后不可恢复。', '提示', { type: 'warning' })
-    await deleteWarning(row.id)
-    ElMessage.success('删除成功')
-    loadData()
-  } catch (error) {
-    if (error !== 'cancel') console.error('删除预警失败', error)
-  }
-}
-
-// ==================== 查看详情弹窗 ====================
-const dialogVisible = ref(false)
-const currentDetail = ref(null)
-const detailLoading = ref(false)
-const detailDevice = ref(null)
-const detailThresholds = ref([])
-const waterLevelMap = ref({})
-const thresholdMap = ref({})
-/** equipmentName -> 设备档案:预警 device_code 存的是类型 id,只能按名称反查设备 */
-const deviceByName = ref(null)
-
-const detailMetric = computed(() => {
-  const code = detailDevice.value?.equipmentCode
-  const levelItem = code ? waterLevelMap.value[code] : null
-  const thresholds = code ? (thresholdMap.value[code] || []) : []
-  return {
-    level: levelItem && levelItem.value !== undefined && levelItem.value !== null ? levelItem.value : null,
-    collectTime: levelItem?.time ? formatDateTime(levelItem.time) : '',
-    thresholdText: thresholds.length > 0 ? thresholds.map(formatThreshold).join(' / ') : ''
-  }
-})
-
-async function handleView(row) {
-  currentDetail.value = { ...row }
-  detailDevice.value = null
-  detailThresholds.value = []
-  dialogVisible.value = true
-  detailLoading.value = true
-  try {
-    const [device] = await Promise.all([resolveDevice(row.equipmentName), loadLiveMetrics()])
-    detailDevice.value = device || null
-    const code = device?.equipmentCode
-    detailThresholds.value = code ? (thresholdMap.value[code] || []) : []
-  } catch (error) {
-    console.warn('预警详情数据加载失败', error)
-  } finally {
-    detailLoading.value = false
-  }
-}
-
-async function resolveDevice(equipmentName) {
-  if (!equipmentName) return null
-  if (!deviceByName.value) {
-    const map = new Map()
-    let page = 1
-    while (page <= 10) {
-      const res = await listEquipment({ rootTypeId: 2, pageNum: page, pageSize: 100 })
-      const pageData = res?.data && Array.isArray(res.data.records) ? res.data : res
-      const rows = pageData?.records || pageData?.rows || []
-      if (rows.length === 0) break
-      rows.forEach((item) => {
-        if (item?.equipmentName && !map.has(item.equipmentName)) map.set(item.equipmentName, item)
-      })
-      if (rows.length < 100) break
-      page += 1
-    }
-    deviceByName.value = map
-  }
-  return deviceByName.value.get(equipmentName) || null
-}
-
-async function loadLiveMetrics() {
-  const [levelResult, thresholdResult] = await Promise.allSettled([
-    getLatestWaterLevelByDevice(),
-    pageWarningThresholds({ rootTypeId: 2, pageNum: 1, pageSize: 500 })
-  ])
-
-  if (levelResult.status === 'fulfilled') {
-    const list = Array.isArray(levelResult.value) ? levelResult.value : (levelResult.value?.data || [])
-    const map = {}
-    list.forEach((item) => {
-      if (!item?.deviceCode) return
-      map[item.deviceCode] = { value: item.waterLevel, time: item.createTime }
-    })
-    waterLevelMap.value = map
-  }
-
-  if (thresholdResult.status === 'fulfilled') {
-    const res = thresholdResult.value
-    const pageData = res?.data && Array.isArray(res.data.records) ? res.data : res
-    const rows = pageData?.records || pageData?.rows || []
-    const map = {}
-    rows.forEach((row) => {
-      if (!row?.deviceCode) return
-      if (!map[row.deviceCode]) map[row.deviceCode] = []
-      map[row.deviceCode].push(row)
-    })
-    thresholdMap.value = map
-  }
-}
-
-/** 阈值展示:dataType 0 区间 / 1 下限 / 2 上限 */
-function formatThreshold(item) {
-  if (!item) return '-'
-  const unit = item.warningUnit || ''
-  const min = item.minValue
-  const max = item.maxValue
-  if (item.dataType === 1) return min === null || min === undefined ? '-' : `≥ ${min}${unit}`
-  if (item.dataType === 2) return max === null || max === undefined ? '-' : `≤ ${max}${unit}`
-  if ((min === null || min === undefined) && (max === null || max === undefined)) return '未设置'
-  return `${min ?? '-'} ~ ${max ?? '-'}${unit}`
-}
-
-onMounted(async () => {
-  await Promise.all([loadEquipmentTypes(), loadWarningTypes()])
-  loadData()
-})
+/**
+ * 历史预警管理:与当前预警管理复用同一页面,数据范围为所有已解除预警。
+ * 注意:template 必须保持单根节点(不能写注释),否则外层 <transition mode="out-in"> 会白屏。
+ */
+import WarningListPanel from './components/WarningListPanel.vue'
 </script>
-
-<style scoped>
-.lsyjgl-page {
-  background: #fff;
-  min-height: 100%;
-  padding: 16px;
-}
-
-/* 分类统计 */
-.stats-panel {
-  display: flex;
-  gap: 16px;
-  align-items: stretch;
-  margin-bottom: 16px;
-  padding: 16px 20px;
-  border: 1px solid #e4e7ed;
-  border-radius: 4px;
-}
-
-.stats-left {
-  display: grid;
-  grid-template-columns: repeat(2, 1fr);
-  gap: 12px;
-  width: 300px;
-  flex-shrink: 0;
-}
-
-.stat-item {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 10px 14px;
-  background: #fafbfc;
-}
-
-.stat-value {
-  font-size: 24px;
-  font-weight: 600;
-  color: #303133;
-  line-height: 1.2;
-}
-
-.stat-value.green {
-  color: #67c23a;
-}
-
-.stat-value.blue {
-  color: #409eff;
-}
-
-.stat-label {
-  margin-top: 2px;
-  font-size: 12px;
-  color: #909399;
-}
-
-.stats-right {
-  flex: 1;
-  display: flex;
-  gap: 32px;
-  justify-content: center;
-  align-items: center;
-}
-
-.ring-chart-box {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.ring-chart {
-  width: 110px;
-  height: 110px;
-}
-
-.ring-legend {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.legend-row {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 12px;
-  color: #606266;
-  white-space: nowrap;
-}
-
-.legend-dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
-}
-
-.legend-num {
-  font-weight: 600;
-  color: #303133;
-}
-
-/* 搜索区 */
-.search-panel {
-  background: #fff;
-  border: 1px solid #e4e7ed;
-  border-radius: 4px;
-  padding: 16px 20px;
-  margin-bottom: 16px;
-}
-
-.search-form {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 12px;
-}
-
-.form-item {
-  display: flex;
-  align-items: center;
-}
-
-.form-label {
-  font-size: 14px;
-  color: #606266;
-  white-space: nowrap;
-  margin-right: 4px;
-}
-
-/* 表格区 */
-.table-panel {
-  background: #fff;
-  border-radius: 4px;
-}
-
-/* 详情弹窗 */
-.detail-content {
-  padding: 0 8px;
-}
-
-.section-box {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 16px;
-  margin-bottom: 16px;
-}
-
-.section-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #303133;
-  margin-bottom: 12px;
-}
-
-.desc-text {
-  background: #f5f7fa;
-  border-radius: 4px;
-  padding: 10px;
-  font-size: 13px;
-  color: #606266;
-  line-height: 1.6;
-  max-height: 120px;
-  overflow-y: auto;
-}
-
-/* 预警指标卡片 */
-.metric-grid {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 12px;
-  margin: 12px 0;
-}
-
-.metric-card {
-  border: 1px solid #ebeef5;
-  border-radius: 4px;
-  padding: 12px 14px;
-  background: #fafbfc;
-}
-
-.metric-label {
-  font-size: 12px;
-  color: #909399;
-  margin-bottom: 6px;
-}
-
-.metric-value {
-  font-size: 22px;
-  font-weight: 600;
-  color: #303133;
-  line-height: 1.2;
-}
-
-.metric-value-text {
-  font-size: 14px;
-  font-weight: 500;
-  word-break: break-all;
-}
-
-.metric-unit {
-  font-size: 12px;
-  font-weight: 400;
-  color: #909399;
-  margin-left: 2px;
-}
-
-.metric-empty {
-  color: #c0c4cc;
-}
-
-.device-missing {
-  margin-bottom: 12px;
-}
-</style>

+ 411 - 25
src/views/subSystem/drainage/spjc/jcspGIS.vue

@@ -1,20 +1,101 @@
 <template>
-  <div class="video-gis" v-loading="loading">
-    <aside>
-      <h3>视频资源分布</h3><el-checkbox-group v-model="layers"><el-checkbox v-for="layer in videoLayers" :key="layer.value" :label="layer.value"><i :style="{background:layer.color}" />{{ layer.label }}</el-checkbox></el-checkbox-group>
-      <el-input v-model="keyword" placeholder="名称/编码/易积水点" clearable /><p>资源 {{ rows.length }} 个 · 当前上图 {{ points.length }} 个</p>
-      <el-alert v-if="missing" :title="`${missing} 个视频坐标缺失或无效,未上图;请在设备台账补齐。`" type="warning" :closable="false" /><el-button @click="load">刷新资源</el-button>
-      <el-alert v-if="error" :title="error" type="error" :closable="false" /><el-empty v-if="!points.length && !loading" description="当前图层暂无可上图视频" :image-size="70" />
-      <div v-for="row in points" :key="row.id" class="resource"><el-button link type="primary" @click="locate(row)">{{ row.deviceName }}</el-button><small>{{ row.districtName }} · {{ row.pointName || '未关联点位' }}</small><el-button v-hasPermi="['video:device:play']" link @click="open(row)">查看视频</el-button></div>
-    </aside>
-    <main>
-      <GisMapCanvas :key="mapKey" ref="map" :points="points" :marker-size="36" @ready="ready" @error="mapError=$event" @point-click="open" @cluster-click="cluster=$event.points || []" />
-      <div v-if="mapError" class="map-error"><span>{{ mapError }}</span><el-button @click="retryMap">重试地图</el-button></div>
-      <div v-if="cluster.length" class="cluster"><el-button link @click="cluster=[]">关闭重叠视频列表</el-button><el-button v-for="row in cluster" :key="row.videoId" link type="primary" @click="open(row)">{{ row.deviceName }}</el-button></div>
-    </main>
-    <el-dialog :model-value="Boolean(viewer)" :title="viewer?.deviceName" width="760px" @update:model-value="viewer=null"><div class="viewer"><DrainageLiveTile v-if="viewer" :key="viewer.videoId" :video-id="viewer.videoId" /></div><p>{{ viewer?.districtName }} · {{ viewer?.location }} · {{ viewer?.pointName || '未关联易积水点' }}</p></el-dialog>
+  <div class="app-container jcspgis-page">
+    <!-- 页头 -->
+    <div class="page-head">
+      <div class="page-head-text">
+        <div class="page-title">监测视频GIS一张图</div>
+        <div class="page-sub">
+          视频 {{ rows.length }} 路 · 当前上图 {{ points.length }} 路<span v-if="missing">
+            · 坐标缺失 {{ missing }} 路</span>
+        </div>
+      </div>
+      <div class="page-head-actions">
+        <el-input v-model="keyword" class="search-input" placeholder="名称 / 编码 / 易积水点" clearable
+          prefix-icon="Search" />
+        <el-button icon="Refresh" :loading="loading" @click="load">刷新资源</el-button>
+        <el-button icon="FullScreen" @click="fitMap">全图</el-button>
+      </div>
+    </div>
+
+    <!-- 左:资源面板 / 右:一张图 -->
+    <div class="layout-row">
+      <aside class="res-panel">
+        <div class="panel-head">视频资源分布</div>
+
+        <div class="panel-section">
+          <el-checkbox-group v-model="layers" class="layer-checks">
+            <el-checkbox v-for="layer in videoLayers" :key="layer.value" :value="layer.value">
+              <i class="layer-dot" :style="{ background: layer.color }"></i>{{ layer.label }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </div>
+
+        <el-alert v-if="missing" class="panel-alert" type="warning" :closable="false"
+          :title="`${missing} 个视频坐标缺失或无效,未上图;请在设备台账补齐。`" />
+        <el-alert v-if="error" class="panel-alert" type="error" :closable="false" :title="error" />
+
+        <div class="panel-list-title">资源列表({{ points.length }})</div>
+        <div class="panel-list" v-loading="loading">
+          <div v-for="row in points" :key="row.videoId" class="res-item">
+            <div class="res-info">
+              <el-button link type="primary" class="res-name" @click="locate(row)">{{ row.deviceName }}</el-button>
+              <small class="res-meta">{{ row.districtName }} · {{ row.pointName || '未关联点位' }}</small>
+            </div>
+            <el-button v-hasPermi="['video:device:play']" link type="primary" icon="VideoPlay"
+              @click="open(row)">视频</el-button>
+          </div>
+          <el-empty v-if="!points.length && !loading" :image-size="70"
+            description="当前图层暂无可上图视频" />
+        </div>
+      </aside>
+
+      <el-card shadow="never" class="map-card" :body-style="{ padding: '0' }">
+        <div class="map-wrap">
+          <GisMapCanvas ref="map" :points="points" :marker-size="36" @ready="onMapReady"
+            @error="mapError = $event" @point-click="open" @cluster-click="cluster = $event.points || []" />
+
+          <!-- 图例:视频来源配色 -->
+          <div class="map-legend">
+            <div class="legend-title">视频来源</div>
+            <div v-for="layer in videoLayers" :key="layer.value" class="legend-row">
+              <i class="legend-dot" :style="{ background: layer.color }"></i>{{ layer.label }}
+            </div>
+          </div>
+
+          <!-- 重叠视频聚合列表 -->
+          <div v-if="cluster.length" class="cluster-pop">
+            <div class="cluster-head">
+              <span>重叠视频({{ cluster.length }})</span>
+              <el-button link @click="cluster = []">关闭</el-button>
+            </div>
+            <div class="cluster-list">
+              <el-button v-for="row in cluster" :key="row.videoId" class="cluster-item" link type="primary"
+                @click="open(row)">{{ row.deviceName }}</el-button>
+            </div>
+          </div>
+
+          <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError">
+            <el-button link type="primary" @click="retryMap">重试地图</el-button>
+          </el-alert>
+          <el-empty v-if="!loading && !mapError && !points.length" class="map-empty" :image-size="80"
+            description="当前没有可上图的视频,请先在设备台账维护经纬度" />
+        </div>
+      </el-card>
+    </div>
+
+    <!-- 视频播放弹窗 -->
+    <el-dialog :model-value="Boolean(viewer)" :title="viewer?.deviceName" width="760px"
+      @update:model-value="viewer = null">
+      <div class="viewer-box">
+        <DrainageLiveTile v-if="viewer" :key="viewer.videoId" :video-id="viewer.videoId" />
+      </div>
+      <div class="viewer-meta">
+        {{ viewer?.districtName }} · {{ viewer?.location }} · {{ viewer?.pointName || '未关联易积水点' }}
+      </div>
+    </el-dialog>
   </div>
 </template>
+
 <script setup name="JcspGIS">
 import { ref, computed, nextTick, watch, onDeactivated } from 'vue'
 import { ElMessage } from 'element-plus'
@@ -23,16 +104,321 @@ import { checkPermi } from '@/utils/permission'
 import { getDrainageVideoMap } from '@/api/drainageVideo'
 import { videoLayers, videoMapPoints, hasVideoCoordinates } from '@/utils/drainageVideoModel'
 import DrainageLiveTile from './DrainageLiveTile.vue'
-const rows=ref([]), layers=ref(videoLayers.map(layer=>layer.value)), keyword=ref(''), loading=ref(false), error=ref(''), viewer=ref(null), map=ref(), mapKey=ref(0), mapError=ref(''), cluster=ref([])
-const points=computed(()=>videoMapPoints(rows.value,layers.value,keyword.value))
-const missing=computed(()=>rows.value.filter(row=>!hasVideoCoordinates(row)).length)
-function locate(row){map.value?.locatePoint(row)}
-function open(row){if(!checkPermi(['video:device:play'])){ElMessage.warning('当前账号无视频播放权限');return}viewer.value=row;cluster.value=[]}
-function ready(){mapError.value='';map.value?.fitBounds()}
-function retryMap(){mapError.value='';mapKey.value++}
-watch([layers,keyword],()=>{cluster.value=[]},{deep:true})
-async function load(){loading.value=true;error.value='';cluster.value=[];viewer.value=null;try{rows.value=(await getDrainageVideoMap()).data || [];await nextTick();map.value?.fitBounds()}catch(e){rows.value=[];error.value=e.message || '视频地图资源加载失败'}finally{loading.value=false}}
-onDeactivated(()=>{viewer.value=null})
+
+const rows = ref([])
+const layers = ref(videoLayers.map(layer => layer.value))
+const keyword = ref('')
+const loading = ref(false)
+const error = ref('')
+const viewer = ref(null)
+const map = ref()
+const mapKey = ref(0)
+const mapError = ref('')
+const cluster = ref([])
+
+const points = computed(() => videoMapPoints(rows.value, layers.value, keyword.value))
+const missing = computed(() => rows.value.filter(row => !hasVideoCoordinates(row)).length)
+
+function locate(row) {
+  map.value?.locatePoint(row)
+}
+
+function open(row) {
+  if (!checkPermi(['video:device:play'])) {
+    ElMessage.warning('当前账号无视频播放权限')
+    return
+  }
+  viewer.value = row
+  cluster.value = []
+}
+
+function fitMap() {
+  map.value?.fitBounds()
+}
+
+function onMapReady() {
+  mapError.value = ''
+  map.value?.fitBounds()
+}
+
+function retryMap() {
+  mapError.value = ''
+  mapKey.value++
+}
+
+watch([layers, keyword], () => { cluster.value = [] }, { deep: true })
+
+async function load() {
+  loading.value = true
+  error.value = ''
+  cluster.value = []
+  viewer.value = null
+  try {
+    rows.value = (await getDrainageVideoMap()).data || []
+    await nextTick()
+    map.value?.fitBounds()
+  } catch (e) {
+    rows.value = []
+    error.value = e.message || '视频地图资源加载失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+onDeactivated(() => { viewer.value = null })
 load()
 </script>
-<style scoped>.video-gis{display:flex;height:calc(100vh - 84px);min-height:600px}aside{width:280px;flex-shrink:0;padding:18px;overflow:auto}aside .el-input,aside .el-alert{margin:12px 0}i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}main{flex:1;position:relative;min-width:0}.resource{padding:12px 0;border-bottom:1px solid #eee}.resource small{display:block;color:#909399;margin:5px 0}.map-error{position:absolute;inset:0;background:#eef2f7ee;display:flex;gap:16px;align-items:center;justify-content:center;flex-direction:column}.cluster{position:absolute;top:16px;right:16px;display:flex;flex-direction:column;max-height:400px;overflow:auto;background:#fff;padding:14px;box-shadow:0 3px 14px #0003}.viewer{height:420px}</style>
+
+<style scoped>
+.jcspgis-page {
+  padding: 20px;
+  background: #f9f9f9;
+  min-height: calc(100vh - 84px);
+}
+
+/* ==================== 页头 ==================== */
+.page-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #0a1628;
+}
+
+.page-sub {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #97a3b7;
+}
+
+.page-head-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.search-input {
+  width: 220px;
+}
+
+/* ==================== 左右布局 ==================== */
+.layout-row {
+  --map-h: calc(100vh - 236px);
+  display: flex;
+  align-items: stretch;
+  gap: 16px;
+}
+
+/* ---------- 左侧资源面板 ---------- */
+.res-panel {
+  width: 300px;
+  flex-shrink: 0;
+  height: var(--map-h);
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border: 1px solid #e8eef5;
+  border-radius: 8px;
+  padding: 16px;
+  box-sizing: border-box;
+}
+
+.panel-head {
+  font-size: 15px;
+  font-weight: 600;
+  color: #0a1628;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.panel-section {
+  margin-top: 12px;
+}
+
+.layer-checks {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.layer-dot {
+  display: inline-block;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  margin-right: 6px;
+  vertical-align: middle;
+}
+
+.panel-alert {
+  margin-top: 12px;
+}
+
+.panel-list-title {
+  margin: 14px 0 4px;
+  font-size: 12px;
+  color: #97a3b7;
+}
+
+.panel-list {
+  flex: 1;
+  overflow: auto;
+  min-height: 0;
+}
+
+.res-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 10px 0;
+  border-bottom: 1px solid #f0f3f8;
+}
+
+.res-info {
+  min-width: 0;
+}
+
+.res-name {
+  font-weight: 500;
+  max-width: 190px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  vertical-align: middle;
+}
+
+.res-meta {
+  display: block;
+  margin-top: 3px;
+  font-size: 12px;
+  color: #909399;
+}
+
+/* ---------- 右侧地图 ---------- */
+.map-card {
+  flex: 1;
+  min-width: 0;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.map-wrap {
+  position: relative;
+  height: var(--map-h);
+  min-height: 460px;
+}
+
+/* 天地图底层是 Leaflet 1.9(瓦片 200 / 标记 600 / 弹窗 700 / 控件容器 1000),
+   页面浮层统一压在 850~950:低于 400 会被瓦片盖住,过高会越过抽屉/弹窗 */
+.map-legend {
+  position: absolute;
+  left: 14px;
+  bottom: 14px;
+  z-index: 900;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  background: #fff;
+  border-radius: 6px;
+  padding: 10px 14px;
+  box-shadow: 0 2px 10px rgba(13, 32, 63, 0.12);
+}
+
+.legend-title {
+  font-size: 11px;
+  color: #97a3b7;
+}
+
+.legend-row {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  font-size: 12px;
+  color: #5e6d82;
+}
+
+.legend-dot {
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  display: inline-block;
+}
+
+.cluster-pop {
+  position: absolute;
+  top: 14px;
+  right: 14px;
+  z-index: 900;
+  width: 240px;
+  background: #fff;
+  border-radius: 6px;
+  padding: 10px 12px;
+  box-shadow: 0 2px 10px rgba(13, 32, 63, 0.12);
+}
+
+.cluster-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  font-weight: 600;
+  color: #334155;
+  padding-bottom: 6px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.cluster-list {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  max-height: 320px;
+  overflow: auto;
+  padding-top: 4px;
+}
+
+.cluster-item {
+  max-width: 100%;
+  padding: 3px 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.map-error {
+  position: absolute;
+  top: 14px;
+  left: 14px;
+  right: 14px;
+  z-index: 900;
+}
+
+.map-empty {
+  position: absolute;
+  inset: 0;
+  z-index: 850;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(249, 250, 252, 0.72);
+}
+
+/* ==================== 视频弹窗 ==================== */
+.viewer-box {
+  height: 420px;
+  background: #000;
+}
+
+.viewer-meta {
+  margin-top: 10px;
+  font-size: 13px;
+  color: #909399;
+}
+</style>