Browse Source

feat(waterSupply): 供水首页四项修正(占位移除/实时报警分流/全域态势公共GIS/工单跳转设备维修派单) (#58)

Kazerin 1 tháng trước cách đây
mục cha
commit
f275f1f0bc

+ 149 - 34
src/utils/waterHomeModel.js

@@ -1,5 +1,5 @@
 import { normalizeDeviceRow } from './waterSupplyUiModel.js'
-import { normalizeMapPoints } from './waterSupplyModel.js'
+import { getFacilityPointIcon, getMonitoringPointIcon, getMonitoringPointStatus } from './gisMapColors.js'
 
 /**
  * 供水首页工作台数据模型(T05 #48)。
@@ -144,9 +144,23 @@ export function alarmLevelText(level) {
   return meta?.text || (Number.isFinite(numeric) && numeric > 0 ? `${numeric}级报警` : '未分级')
 }
 
-/** A 区实时报警列表。 */
+const ALARM_DISPLAY_STATUS_META = {
+  1: { text: '待审核', type: 'warning' },
+  2: { text: '已转工单', type: 'primary' },
+  3: { text: '已解除', type: 'success' }
+}
+
+const ALARM_DISPLAY_RELEASED = 3
+
+/**
+ * A 区实时报警列表。
+ *
+ * 实时列表只展示仍需要关注的报警:已解除(displayStatus=3)直接过滤;
+ * 待审核 / 已转工单行保留展示状态与关联工单号,供首页按状态分流跳转。
+ */
 export function normalizeRealtimeAlarmRows(rows = [], limit = 5) {
   const list = (Array.isArray(rows) ? rows : [])
+    .filter(row => Number(row?.displayStatus) !== ALARM_DISPLAY_RELEASED)
     .slice()
     .sort((a, b) => String(b?.alarmTime || '').localeCompare(String(a?.alarmTime || '')))
     .slice(0, limit)
@@ -159,12 +173,35 @@ export function normalizeRealtimeAlarmRows(rows = [], limit = 5) {
       alarmLevelText: row?.alarmLevelText || alarmLevelText(row?.alarmLevel),
       alarmTime: row?.alarmTime || '-',
       location: row?.equipmentLocation || '-',
-      displayStatus: row?.displayStatus,
-      displayStatusText: row?.displayStatusText || '-'
+      displayStatus: Number(row?.displayStatus),
+      displayStatusText: row?.displayStatusText
+        || ALARM_DISPLAY_STATUS_META[Number(row?.displayStatus)]?.text
+        || '待审核',
+      displayStatusType: ALARM_DISPLAY_STATUS_META[Number(row?.displayStatus)]?.type || 'warning',
+      orderNo: row?.orderNo || row?.relatedOrderNo || ''
     }))
   return list
 }
 
+/**
+ * 实时报警跳转目标:已转工单带工单号 -> 设备维修派单;其余(含待审核)-> 报警审核。
+ */
+export function realtimeAlarmJumpTarget(row = {}) {
+  if (Number(row?.displayStatus) === 2) {
+    const orderNo = String(row?.orderNo || '').trim()
+    if (orderNo) {
+      return { path: '/device/maintain', query: { keyword: orderNo } }
+    }
+  }
+  return {
+    path: '/monitorAlarm/audit',
+    query: {
+      alarmId: row?.id ?? row?.alarmId ?? undefined,
+      deviceCode: row?.deviceCode || undefined
+    }
+  }
+}
+
 /** B 区异常待办清单:复用设备行标准化,避免口径漂移。 */
 export function normalizeAbnormalTodoRows(rows = [], limit = 6) {
   return (Array.isArray(rows) ? rows : []).slice(0, limit).map(normalizeDeviceRow)
@@ -211,41 +248,119 @@ export function normalizeWorkOrderRows(rows = [], limit = 5) {
     })
 }
 
-/** 全域态势点位:设备点 + 实时报警点,按状态着色,去重后归一化到面板坐标。 */
-export function buildOverviewMapPoints({ devicePoints = [], alarmRows = [] } = {}) {
-  const alarmList = (Array.isArray(alarmRows) ? alarmRows : [])
-    .filter(row => Number.isFinite(Number(row?.longitude ?? row?.lng)) && Number.isFinite(Number(row?.latitude ?? row?.lat)))
-    .map(row => ({
-      id: `alarm-${row?.id ?? row?.alarmId ?? row?.deviceCode ?? Math.random()}`,
-      deviceCode: row?.deviceCode || '-',
-      name: row?.deviceName || row?.deviceCode || '报警点',
-      longitude: Number(row?.longitude ?? row?.lng),
-      latitude: Number(row?.latitude ?? row?.lat),
-      state: 'alarm',
-      level: Number(row?.alarmLevel ?? 0),
-      meta: row?.warningType || '监测报警'
-    }))
+function finiteCoordinate(value) {
+  const numeric = Number(value)
+  return Number.isFinite(numeric) ? numeric : null
+}
+
+function hasCoordinates(point = {}) {
+  const lng = finiteCoordinate(point?.lng ?? point?.longitude)
+  const lat = finiteCoordinate(point?.lat ?? point?.latitude)
+  return lng !== null && lat !== null
+}
 
+function normalizeHomeOverviewPoint(point, kind) {
+  const lng = finiteCoordinate(point?.lng ?? point?.longitude)
+  const lat = finiteCoordinate(point?.lat ?? point?.latitude)
+  if (lng === null || lat === null) return null
+
+  if (kind === 'facility') {
+    return {
+      ...point,
+      id: point?.id ?? point?.featureId,
+      kind,
+      name: point?.name || point?.facilityName || point?.code || '供水设施',
+      code: point?.code || point?.facilityCode || '-',
+      lng,
+      lat,
+      statusKey: 'normal',
+      iconUrl: getFacilityPointIcon(point?.type),
+      meta: point?.road || '供水设施'
+    }
+  }
+
+  if (kind === 'alarm') {
+    return {
+      ...point,
+      id: point?.alarmId ?? point?.id,
+      kind,
+      name: point?.deviceName || point?.name || point?.deviceCode || '报警点',
+      code: point?.deviceCode || point?.code || '-',
+      lng,
+      lat,
+      statusKey: 'alarm',
+      iconUrl: getMonitoringPointIcon({ statusKey: 'alarm' }),
+      meta: point?.warningType || '监测报警'
+    }
+  }
+
+  const normalized = normalizeDeviceRow(point)
+  const statusKey = getMonitoringPointStatus(normalized)
+  return {
+    ...point,
+    kind,
+    id: point?.equipmentId ?? point?.id ?? normalized.equipmentCode ?? point?.deviceCode,
+    name: normalized.equipmentName || normalized.equipmentCode || '监测设备',
+    code: normalized.equipmentCode || point?.deviceCode || '-',
+    lng,
+    lat,
+    statusKey,
+    iconUrl: getMonitoringPointIcon({ ...normalized, statusKey }),
+    meta: normalized.equipmentTypeName || '监测设备'
+  }
+}
+
+/**
+ * 首页全域态势:聚合设施、设备与报警三类一张图数据,统一交给公共 GisMapCanvas 渲染。
+ *
+ * 同一设备的报警点与设备点按 deviceCode 合并,报警状态优先于设备自身状态;
+ * 设施点位与管网线段原样透传,颜色/图标沿用各一张图规范。
+ */
+export function buildHomeGisOverview({
+  facilityPoints = [],
+  facilityLines = [],
+  devicePoints = [],
+  alarmPoints = []
+} = {}) {
   const deviceList = (Array.isArray(devicePoints) ? devicePoints : [])
-    .filter(point => Number.isFinite(Number(point?.longitude ?? point?.lng)) && Number.isFinite(Number(point?.latitude ?? point?.lat)))
-    .map(point => ({
-      id: `device-${point?.equipmentId ?? point?.id ?? point?.deviceCode}`,
-      deviceCode: point?.equipmentCode || point?.deviceCode || '-',
-      name: point?.equipmentName || point?.deviceName || point?.equipmentCode || '监测设备',
-      longitude: Number(point?.longitude ?? point?.lng),
-      latitude: Number(point?.latitude ?? point?.lat),
-      state: Number(point?.alarmStatus) === 1 ? 'alarm' : Number(point?.onlineStatus) === 0 ? 'offline' : 'normal',
-      meta: point?.equipmentTypeName || '监测设备'
-    }))
+    .map(point => normalizeHomeOverviewPoint(point, 'device'))
+    .filter(Boolean)
+  const alarmList = (Array.isArray(alarmPoints) ? alarmPoints : [])
+    .map(point => normalizeHomeOverviewPoint(point, 'alarm'))
+    .filter(Boolean)
 
   const merged = new Map()
-  ;[...deviceList, ...alarmList].forEach(point => {
-    const key = point.deviceCode || point.id
-    const existing = merged.get(key)
-    if (!existing || (point.state === 'alarm' && existing.state !== 'alarm')) {
-      merged.set(key, point)
+  deviceList.forEach(point => {
+    const key = String(point.code && point.code !== '-' ? point.code : point.id || '')
+    if (key) merged.set(key, point)
+  })
+  alarmList.forEach(point => {
+    const key = String(point.code && point.code !== '-' ? point.code : point.id || '')
+    const existing = key ? merged.get(key) : undefined
+    if (!key || !existing || existing.statusKey !== 'alarm') {
+      if (key) merged.set(key, point)
     }
   })
 
-  return normalizeMapPoints(Array.from(merged.values()))
+  const facilityPointList = (Array.isArray(facilityPoints) ? facilityPoints : [])
+    .map(point => normalizeHomeOverviewPoint(point, 'facility'))
+    .filter(Boolean)
+  const lines = (Array.isArray(facilityLines) ? facilityLines : [])
+    .map(line => ({
+      ...line,
+      name: line?.name || line?.pipeName || '供水管网',
+      color: '#f1c40f',
+      points: (line?.points || [])
+        .map(point => ({
+          lng: finiteCoordinate(point?.lng ?? point?.longitude),
+          lat: finiteCoordinate(point?.lat ?? point?.latitude)
+        }))
+        .filter(point => point.lng !== null && point.lat !== null)
+    }))
+    .filter(line => (line.points || []).length >= 2)
+
+  return {
+    points: [...facilityPointList, ...merged.values()].filter(hasCoordinates),
+    lines
+  }
 }

+ 213 - 95
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -20,7 +20,6 @@
 
     <!-- A 指挥台 -->
     <section class="home-section">
-      <div class="section-heading"><el-icon><Monitor /></el-icon><span>A · 指挥台</span></div>
       <div class="metric-grid">
         <div v-for="card in homeKpis" :key="card.key" class="metric">
           <div class="metric-icon"><el-icon><component :is="kpiIcon(card.key)"/></el-icon></div>
@@ -42,13 +41,14 @@
           </template>
           <div class="compact-list">
             <button v-for="item in alarmRows" :key="item.id || item.deviceCode" type="button"
-                    class="compact-item" @click="goPage('/monitorAlarm/audit', { alarmId: item.id || undefined, deviceCode: item.deviceCode })">
+                    class="compact-item" @click="openRealtimeAlarm(item)">
               <span class="status-dot dot-alarm" />
               <span class="compact-main">
                 <b>{{ item.deviceName }}</b>
                 <small>{{ item.deviceCode }}</small>
               </span>
               <span class="compact-meta">
+                <el-tag size="small" :type="item.displayStatusType">{{ item.displayStatusText }}</el-tag>
                 <el-tag size="small" :type="levelType(item.alarmLevel)">{{ item.alarmLevelText }}</el-tag>
                 <small>{{ item.alarmTime }}</small>
               </span>
@@ -63,34 +63,79 @@
               <el-icon><MapLocation /></el-icon><span>全域态势</span>
               <div class="map-legend">
                 <i class="legend-dot dot-alarm" />报警
+                <i class="legend-dot dot-workorder" />工单
+                <i class="legend-dot dot-warning" />预警
                 <i class="legend-dot dot-offline" />离线
                 <i class="legend-dot dot-normal" />正常
               </div>
-              <el-button link type="primary" class="panel-link" @click="goPage('/device/gis')">监测设备一张图</el-button>
+              <div class="panel-links">
+                <el-button link type="primary" class="panel-link" @click="goPage('/facility/gis')">设施</el-button>
+                <el-button link type="primary" class="panel-link" @click="goPage('/device/gis')">设备</el-button>
+                <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/map')">报警</el-button>
+              </div>
             </div>
           </template>
           <div class="map-stage">
-            <div v-for="point in overviewMapPoints" :key="point.id" class="map-point"
-                 :class="`state-${point.state}`" :style="{ left: point.mapX + '%', top: point.mapY + '%' }"
-                 :title="`${point.name} · ${point.meta}`">
-              <span />
-              <b>{{ point.name }}</b>
-            </div>
-            <el-empty v-if="!overviewMapPoints.length" description="暂无可展示的设备点位" :image-size="70" />
-            <div class="map-note">点位归一化示意 · 精确位置请进入各一张图</div>
+            <GisMapCanvas
+              ref="overviewMapRef"
+              :points="overviewMapPoints"
+              :lines="overviewMapLines"
+              cluster-label="供水点位"
+              :center="overviewMapCenter"
+              :zoom="overviewMapZoom"
+              @point-click="openOverviewPoint"
+              @line-click="openOverviewPoint"
+              @map-click="overviewDetailOpen = false"
+              @ready="handleOverviewMapReady"
+              @error="overviewMapError = $event"
+            />
+            <el-alert v-if="overviewMapError" class="map-inline-alert" type="warning" show-icon :closable="false"
+                      :title="overviewMapError">
+              <el-button link type="primary" @click="loadAll">重试</el-button>
+            </el-alert>
+            <el-empty v-else-if="overviewLoaded && !overviewMapPoints.length && !overviewMapLines.length"
+                      class="map-inline-empty" description="暂无可展示的全域点位" :image-size="70" />
           </div>
+          <el-drawer v-model="overviewDetailOpen" title="点位详情" size="420px" direction="rtl"
+                     class="detail-drawer" append-to-body>
+            <div v-if="overviewDetail" class="overview-detail">
+              <section class="detail-hero">
+                <h3>{{ overviewDetail.name }}</h3>
+                <p>{{ overviewDetail.code }}</p>
+                <div class="status-tags">
+                  <el-tag size="small" :type="gisStatusTagType(overviewDetail.statusKey)">
+                    {{ gisStatusLabel(overviewDetail.statusKey) }}
+                  </el-tag>
+                  <el-tag v-if="overviewDetail.meta" size="small" type="info">{{ overviewDetail.meta }}</el-tag>
+                </div>
+              </section>
+              <section class="detail-section">
+                <h4>点位信息</h4>
+                <dl>
+                  <dt>名称</dt><dd>{{ overviewDetail.name }}</dd>
+                  <dt>编码</dt><dd>{{ overviewDetail.code }}</dd>
+                  <dt>类别</dt><dd>{{ overviewKindLabel(overviewDetail.kind) }}</dd>
+                  <dt>状态</dt><dd>{{ gisStatusLabel(overviewDetail.statusKey) }}</dd>
+                  <dt>类型 / 报警</dt><dd>{{ overviewDetail.meta }}</dd>
+                  <dt>经度</dt><dd>{{ overviewDetail.lng }}</dd>
+                  <dt>纬度</dt><dd>{{ overviewDetail.lat }}</dd>
+                </dl>
+              </section>
+            </div>
+            <el-empty v-else description="请点击地图点位查看详情" :image-size="70" />
+          </el-drawer>
         </el-card>
 
         <el-card shadow="never" class="water-panel home-panel">
           <template #header>
             <div class="card-title">
               <el-icon><Tickets /></el-icon><span>工单进展</span>
-              <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/workOrder')">统一工单</el-button>
+              <el-button link type="primary" class="panel-link" @click="goPage('/device/maintain')">设备维修派单</el-button>
             </div>
           </template>
           <div class="compact-list">
             <button v-for="item in workOrderRows" :key="item.orderId || item.orderNo" type="button"
-                    class="compact-item" @click="goPage('/monitorAlarm/workOrder', { keyword: item.orderNo })">
+                    class="compact-item" @click="goPage('/device/maintain', { keyword: item.orderNo })">
               <span class="status-dot" :class="dotByOrderType(item.orderStatusType)" />
               <span class="compact-main">
                 <b>{{ item.orderNo }}</b>
@@ -114,7 +159,6 @@
 
     <!-- B 异常待办 -->
     <section class="home-section">
-      <div class="section-heading"><el-icon><WarningFilled /></el-icon><span>B · 异常待办</span></div>
       <div class="workbench-grid">
         <el-card shadow="never" class="water-panel home-panel">
           <template #header>
@@ -165,7 +209,7 @@
               <el-descriptions-item label="异常原因">{{ selectedDevice.abnormalReasonText || '-' }}</el-descriptions-item>
               <el-descriptions-item label="关联工单">
                 <el-button v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" link type="primary"
-                           @click="goPage('/monitorAlarm/workOrder', { keyword: selectedDevice.relatedOrderNo })">
+                           @click="goPage('/device/maintain', { keyword: selectedDevice.relatedOrderNo })">
                   {{ selectedDevice.relatedOrderNo }}
                 </el-button>
                 <span v-else>-</span>
@@ -192,7 +236,6 @@
 
     <!-- C 数据总览 -->
     <section class="home-section">
-      <div class="section-heading"><el-icon><DataAnalysis /></el-icon><span>C · 数据总览</span></div>
       <div class="metric-grid">
         <div v-for="card in overviewCards" :key="card.key" class="metric">
           <div class="metric-icon"><el-icon><component :is="overviewIcon(card.key)"/></el-icon></div>
@@ -237,17 +280,22 @@ import { useRouter } from 'vue-router'
 import * as echarts from 'echarts'
 import {
   Bell, BellFilled, Connection, Cpu, DataAnalysis, Finished, Grid, HomeFilled,
-  MapLocation, Monitor, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
+  MapLocation, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
   Warning, WarningFilled
 } from '@element-plus/icons-vue'
 import { getAlarmLedgerDashboard, getAlarmStats, getDeviceGisAllPoints, getDeviceLedgerStatistics,
-  getWaterFacilityOverview, listAbnormalDevices, listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
+  getWaterFacilityFeatures, getWaterFacilityOverview, listAbnormalDevices, listAlarmMapPoints,
+  listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
 import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
-import { normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+import { normalizeGeoJson, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+import { normalizeDeviceGisPoints } from '@/utils/waterSupplyUiModel'
+import { gisPointStatusLabels, gisPointStatusTagTypes } from '@/utils/gisMapColors'
+import { buildFacilityMapModel } from '@/utils/waterFacilityGisModel'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import {
-  HOME_QUICK_ENTRIES, buildHomeWorkOrderQuery, buildOverviewMapPoints,
+  HOME_QUICK_ENTRIES, buildHomeGisOverview, buildHomeWorkOrderQuery,
   normalizeAbnormalTodoRows, normalizeAlarmOverviewCards, normalizeAlarmTrend, normalizeDeviceSummary,
-  normalizeHomeKpis, normalizePieRatio, normalizeRealtimeAlarmRows,
+  normalizeHomeKpis, normalizePieRatio, normalizeRealtimeAlarmRows, realtimeAlarmJumpTarget,
   normalizeWorkOrderRows, normalizeWorkOrderStatusStat
 } from '@/utils/waterHomeModel'
 
@@ -267,6 +315,15 @@ const workOrderRows = ref([])
 const workOrderStat = ref({})
 const abnormalTodoRows = ref([])
 const overviewMapPoints = ref([])
+const overviewMapLines = ref([])
+const overviewDeviceRaw = ref([])
+const overviewMapCenter = { lng: 110.3965, lat: 28.4638 }
+const overviewMapZoom = 15
+const overviewMapError = ref('')
+const overviewLoaded = ref(false)
+const overviewDetailOpen = ref(false)
+const overviewDetail = ref(null)
+const overviewMapRef = ref(null)
 const selectedDevice = ref(null)
 
 const homeKpis = ref([])
@@ -373,9 +430,8 @@ async function loadAll() {
   workOrderRows.value = normalizeWorkOrderRows(orderList?.records || [], 6)
 
   const gisData = unwrapResponse(deviceGis.value || {})
-  const devicePoints = Array.isArray(gisData) ? gisData : (gisData?.features || [])
-  const alarmSource = normalizeRealtimeAlarmRows(unwrapResponse(realtime.value || []), 50)
-  overviewMapPoints.value = buildOverviewMapPoints({ devicePoints, alarmRows: alarmSource })
+  overviewDeviceRaw.value = Array.isArray(gisData) ? gisData : (gisData?.features || [])
+  await loadOverviewMap(recordProblem)
 
   homeKpis.value = normalizeHomeKpis({ facilityRows: facilityRows.value, ledgerStats: ledgerStats.value, alarmStats: alarmStats.value })
   overviewCards.value = normalizeAlarmOverviewCards({
@@ -397,6 +453,40 @@ function selectAbnormal(row) {
   selectedDevice.value = normalizeDeviceSummary(row)
 }
 
+async function loadOverviewMap(recordProblem) {
+  overviewMapError.value = ''
+  const [facilityRes, alarmRes] = await Promise.all([
+    settle(() => getWaterFacilityFeatures(), { features: [] }).catch(recordProblem('设施GIS点位')),
+    settle(() => listAlarmMapPoints(), []).catch(recordProblem('报警点位'))
+  ])
+  const facilityModel = buildFacilityMapModel(normalizeGeoJson(facilityRes))
+  const overviewModel = buildHomeGisOverview({
+    facilityPoints: facilityModel.points,
+    facilityLines: facilityModel.lines,
+    devicePoints: normalizeDeviceGisPoints(overviewDeviceRaw.value),
+    alarmPoints: (Array.isArray(alarmRes) ? alarmRes : [])
+  })
+  overviewMapPoints.value = overviewModel.points
+  overviewMapLines.value = overviewModel.lines
+  overviewLoaded.value = true
+  await nextTick()
+  overviewMapRef.value?.fitBounds()
+}
+
+function handleOverviewMapReady() {
+  nextTick(() => overviewMapRef.value?.fitBounds())
+}
+
+function openOverviewPoint(point) {
+  overviewDetail.value = point || null
+  overviewDetailOpen.value = Boolean(point)
+}
+
+function openRealtimeAlarm(item) {
+  const target = realtimeAlarmJumpTarget(item)
+  goPage(target.path, target.query)
+}
+
 function resolveRoutePath(suffix) {
   const matched = router.getRoutes().find(item => item.path.endsWith(suffix))
   return matched ? matched.path : ''
@@ -411,6 +501,18 @@ function goPage(suffix, query = {}) {
   router.push({ path, query }).catch(() => {})
 }
 
+function gisStatusLabel(statusKey) {
+  return gisPointStatusLabels[statusKey] || '正常'
+}
+
+function gisStatusTagType(statusKey) {
+  return gisPointStatusTagTypes[statusKey] || 'success'
+}
+
+function overviewKindLabel(kind) {
+  return { device: '监测设备', alarm: '报警', facility: '供水设施' }[kind] || '点位'
+}
+
 function renderCharts() {
   renderTrendChart()
   warningPie?.dispose()
@@ -535,21 +637,6 @@ onBeforeUnmount(() => {
   margin-bottom: 20px;
 }
 
-.section-heading {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 12px;
-  color: #17324d;
-  font-size: 15px;
-  font-weight: 700;
-}
-
-.section-heading :deep(.el-icon) {
-  color: #2563eb;
-  font-size: 18px;
-}
-
 .metric-grid {
   display: grid;
   grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -617,8 +704,9 @@ onBeforeUnmount(() => {
 
 .workbench-grid {
   display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
+  grid-template-columns: minmax(240px, 1fr) minmax(0, 3fr) minmax(240px, 1fr);
   gap: 12px;
+  align-items: stretch;
 }
 
 .home-panel {
@@ -661,6 +749,24 @@ onBeforeUnmount(() => {
   margin-left: auto;
 }
 
+.panel-links {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  margin-left: auto;
+}
+
+.panel-links .panel-link {
+  margin-left: 0;
+}
+
+.legend-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+}
+
 .compact-list {
   display: flex;
   flex-direction: column;
@@ -708,6 +814,7 @@ onBeforeUnmount(() => {
 .dot-primary { background: #2563eb; }
 .dot-success { background: #16a34a; }
 .dot-info { background: #94a3b8; }
+.dot-workorder { background: #2563eb; }
 
 .compact-main {
   display: block;
@@ -748,6 +855,37 @@ onBeforeUnmount(() => {
   white-space: nowrap;
 }
 
+.map-stage {
+  position: relative;
+  height: 340px;
+  overflow: hidden;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  background: #eef6ff;
+}
+
+.map-stage :deep(.gis-map-canvas) {
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+}
+
+.map-inline-alert {
+  position: absolute;
+  left: 16px;
+  bottom: 16px;
+  z-index: 3;
+  width: 340px;
+}
+
+.map-inline-empty {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  z-index: 2;
+  transform: translate(-50%, -50%);
+}
+
 .map-legend {
   display: flex;
   align-items: center;
@@ -757,74 +895,50 @@ onBeforeUnmount(() => {
   font-size: 12px;
 }
 
-.legend-dot {
-  display: inline-block;
-  width: 8px;
-  height: 8px;
-  border-radius: 50%;
+.overview-detail {
+  min-height: 240px;
 }
 
-.map-stage {
-  position: relative;
-  min-height: 226px;
-  overflow: hidden;
-  border: 1px solid #e1ebf3;
-  border-radius: 8px;
-  background:
-    linear-gradient(rgba(219, 234, 254, 0.35) 1px, transparent 1px),
-    linear-gradient(90deg, rgba(219, 234, 254, 0.35) 1px, transparent 1px),
-    linear-gradient(135deg, #eef6ff 0%, #f8fbff 100%);
-  background-size: 28px 28px;
+.detail-hero h3,
+.detail-section h4 {
+  margin: 0 0 8px;
+  color: #1c4b78;
 }
 
-.map-point {
-  position: absolute;
-  transform: translate(-50%, -50%);
-  z-index: 2;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 3px;
-  cursor: default;
+.detail-hero p {
+  margin: 0 0 10px;
+  color: #678197;
+  font-size: 13px;
 }
 
-.map-point span {
-  display: block;
-  width: 12px;
-  height: 12px;
-  border-radius: 50%;
-  border: 2px solid #fff;
-  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.28);
+.status-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
 }
 
-.map-point.state-alarm span {
-  background: #dc2626;
-  box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.16);
+.detail-section {
+  margin-top: 18px;
+  padding-top: 12px;
+  border-top: 1px solid #e5eef7;
 }
 
-.map-point.state-offline span { background: #94a3b8; }
-.map-point.state-normal span { background: #16a34a; }
+.detail-section dl {
+  display: grid;
+  grid-template-columns: 92px minmax(0, 1fr);
+  gap: 7px 10px;
+  margin: 0;
+  font-size: 13px;
+}
 
-.map-point b {
-  max-width: 110px;
-  overflow: hidden;
-  padding: 1px 5px;
-  color: #334155;
-  background: rgba(255, 255, 255, 0.88);
-  border-radius: 4px;
-  font-size: 10px;
-  font-weight: 600;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+.detail-section dt {
+  color: #71889c;
 }
 
-.map-note {
-  position: absolute;
-  left: 10px;
-  bottom: 8px;
-  z-index: 1;
-  color: #94a3b8;
-  font-size: 11px;
+.detail-section dd {
+  margin: 0;
+  color: #2b4c67;
+  overflow-wrap: anywhere;
 }
 
 .order-brief {
@@ -984,6 +1098,10 @@ onBeforeUnmount(() => {
     display: none;
   }
 
+  .panel-links {
+    display: none;
+  }
+
   .quick-grid {
     grid-template-columns: 1fr;
   }

+ 74 - 16
tests/waterHomeModel.test.mjs

@@ -11,10 +11,11 @@ import {
   normalizeAlarmTrend,
   normalizePieRatio,
   normalizeRealtimeAlarmRows,
+  realtimeAlarmJumpTarget,
   normalizeAbnormalTodoRows,
   normalizeDeviceSummary,
   normalizeWorkOrderRows,
-  buildOverviewMapPoints
+  buildHomeGisOverview
 } from '../src/utils/waterHomeModel.js'
 
 test('home work order scope is constrained to the water-supply type tree', () => {
@@ -127,15 +128,62 @@ test('ratio pie drops empty values and caps the number of segments', () => {
 
 test('realtime alarm rows expose chinese friendly display fields', () => {
   const rows = normalizeRealtimeAlarmRows([
-    { id: 'a1', deviceCode: 'CN-GS-202609020001', deviceName: '一号压力点', warningType: '压力过低', alarmLevel: 1, alarmTime: '2026-09-15 10:00:00', equipmentLocation: '城南' }
+    {
+      id: 'a1',
+      deviceCode: 'CN-GS-202609020001',
+      deviceName: '一号压力点',
+      warningType: '压力过低',
+      alarmLevel: 1,
+      alarmTime: '2026-09-15 10:00:00',
+      equipmentLocation: '城南',
+      displayStatus: 1
+    }
   ])
   assert.equal(rows.length, 1)
   assert.equal(rows[0].deviceName, '一号压力点')
   assert.equal(rows[0].alarmLevelText, '一级报警')
   assert.equal(rows[0].location, '城南')
+  assert.equal(rows[0].displayStatusText, '待审核')
+  assert.equal(rows[0].displayStatusType, 'warning')
   assert.equal(normalizeRealtimeAlarmRows([], 5).length, 0)
 })
 
+test('realtime alarm rows drop released alarms and keep status for routing', () => {
+  const rows = normalizeRealtimeAlarmRows([
+    { id: 'a-pending', deviceCode: 'CN-GS-202609020001', displayStatus: 1, alarmTime: '2026-09-15 10:00:00' },
+    { id: 'a-order', deviceCode: 'CN-GS-202609020002', displayStatus: 2, orderNo: 'WO202609151100001', alarmTime: '2026-09-15 09:00:00' },
+    { id: 'a-released', deviceCode: 'CN-GS-202609020003', displayStatus: 3, alarmTime: '2026-09-15 08:00:00' }
+  ], 5)
+
+  assert.deepEqual(rows.map(item => item.id), ['a-pending', 'a-order'])
+  assert.equal(rows.find(item => item.id === 'a-order').displayStatusText, '已转工单')
+  assert.equal(rows.find(item => item.id === 'a-order').orderNo, 'WO202609151100001')
+  assert.equal(rows.find(item => item.id === 'a-pending').displayStatusType, 'warning')
+})
+
+test('realtime alarm jumps split between audit and work order by display status', () => {
+  assert.deepEqual(realtimeAlarmJumpTarget({
+    id: 'alarm-1',
+    deviceCode: 'CN-GS-202609020001',
+    displayStatus: 1
+  }), {
+    path: '/monitorAlarm/audit',
+    query: { alarmId: 'alarm-1', deviceCode: 'CN-GS-202609020001' }
+  })
+  assert.deepEqual(realtimeAlarmJumpTarget({
+    id: 'alarm-2',
+    deviceCode: 'CN-GS-202609020002',
+    displayStatus: 2,
+    orderNo: 'WO202609151100001'
+  }), {
+    path: '/device/maintain',
+    query: { keyword: 'WO202609151100001' }
+  })
+  // 已转工单但缺少工单号时回落到报警审核,避免无效跳转
+  const fallback = realtimeAlarmJumpTarget({ id: 'alarm-3', displayStatus: 2 })
+  assert.equal(fallback.path, '/monitorAlarm/audit')
+})
+
 test('abnormal todo rows reuse the shared device normalization contract', () => {
   const rows = normalizeAbnormalTodoRows([{
     equipmentId: 'eq-1',
@@ -186,24 +234,34 @@ test('work order rows translate status and level codes to chinese', () => {
   assert.equal(rows[0].orderLevelType, 'danger')
 })
 
-test('overview map points merge devices and alarms with alarm priority', () => {
-  const points = buildOverviewMapPoints({
+test('home GIS overview aggregates facilities, devices and alarms with alarm priority', () => {
+  const overview = buildHomeGisOverview({
+    facilityPoints: [
+      { id: 'f-1', type: 'source', name: '一水源地', code: 'S-001', lng: 110.08, lat: 28.05 }
+    ],
+    facilityLines: [
+      { id: 'pipe-1', name: '城南主管', type: 'pipe', points: [{ lng: 110.1, lat: 28.1 }, { lng: 110.2, lat: 28.2 }] }
+    ],
     devicePoints: [
-      { equipmentId: 'eq-1', equipmentCode: 'CN-GS-202609020001', equipmentName: '一号点', longitude: 110.1, latitude: 28.1, alarmStatus: 0, onlineStatus: 1 },
-      { equipmentId: 'eq-2', equipmentCode: 'CB-GS-202609020002', equipmentName: '二号点', longitude: 110.2, latitude: 28.2, alarmStatus: 1, onlineStatus: 1 }
+      { equipmentId: 'eq-1', equipmentCode: 'CN-GS-202609020001', equipmentName: '一号压力点', longitude: 110.1, latitude: 28.1, alarmStatus: 0, onlineStatus: 1, currentStatus: 0, equipmentTypeName: '压力计' }
     ],
-    alarmRows: [
-      { deviceCode: 'CN-GS-202609020001', deviceName: '一号点', longitude: 110.1, latitude: 28.1, warningType: '压力过低', alarmLevel: 1 }
+    alarmPoints: [
+      { alarmId: 'alarm-1', deviceCode: 'CN-GS-202609020001', deviceName: '一号压力点', longitude: 110.1, latitude: 28.1, warningType: '压力过低', alarmLevel: 1 }
     ]
   })
-  assert.equal(points.length, 2)
-  const one = points.find(item => item.deviceCode === 'CN-GS-202609020001')
-  assert.equal(one.state, 'alarm')
-  assert.equal(one.meta, '压力过低')
-  const two = points.find(item => item.deviceCode === 'CB-GS-202609020002')
-  assert.equal(two.state, 'alarm')
-  assert.ok(points.every(item => Number.isFinite(item.mapX) && Number.isFinite(item.mapY)))
-  assert.deepEqual(buildOverviewMapPoints({ devicePoints: [], alarmRows: [] }), [])
+
+  assert.equal(overview.lines.length, 1)
+  assert.equal(overview.lines[0].color, '#f1c40f')
+  assert.equal(overview.lines[0].points.length, 2)
+  assert.ok(overview.points.some(point => point.id === 'f-1' && point.statusKey === 'normal'))
+  const device = overview.points.find(point => point.code === 'CN-GS-202609020001')
+  assert.ok(device)
+  assert.equal(device.statusKey, 'alarm')
+  assert.equal(device.meta, '压力过低')
+  assert.ok(overview.points.every(point =>
+    Number.isFinite(point.lng) && Number.isFinite(point.lat) && point.name && point.iconUrl))
+  const empty = buildHomeGisOverview({})
+  assert.deepEqual(empty, { points: [], lines: [] })
 })
 
 test('home quick entries cover the four workbench jumps', () => {

+ 54 - 0
tests/waterSupplyHomeContract.test.mjs

@@ -0,0 +1,54 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const homePath = 'src/views/subSystem/waterSupply/components/WaterSupplyHome.vue'
+
+test('home removes the A/B/C placeholder headings', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.doesNotMatch(source, /A · 指挥台/u)
+  assert.doesNotMatch(source, /B · 异常待办/u)
+  assert.doesNotMatch(source, /C · 数据总览/u)
+})
+
+test('home realtime alarms split between audit and work order by display status', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /openRealtimeAlarm\(item\)/u)
+  assert.match(source, /realtimeAlarmJumpTarget\(item\)/u)
+  assert.match(source, /goPage\(target\.path, target\.query\)/u)
+  assert.match(source, /item\.displayStatusText/u)
+  assert.match(source, /item\.displayStatusType/u)
+  assert.match(source, /goPage\('\/device\/maintain'/u)
+})
+
+test('home overview uses the shared GIS map canvas and aggregates all one-map layers', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /<GisMapCanvas/u)
+  assert.match(source, /ref="overviewMapRef"/u)
+  assert.match(source, /:points="overviewMapPoints"/u)
+  assert.match(source, /:lines="overviewMapLines"/u)
+  assert.match(source, /@point-click="openOverviewPoint"/u)
+  assert.match(source, /@line-click="openOverviewPoint"/u)
+  assert.match(source, /buildHomeGisOverview/u)
+  assert.match(source, /getWaterFacilityFeatures\(\)/u)
+  assert.match(source, /listAlarmMapPoints\(\)/u)
+  assert.match(source, /getDeviceGisAllPoints\(\)/u)
+})
+
+test('home overview map takes at least 60% of the same row in desktop layout', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /grid-template-columns:\s*minmax\(240px,\s*1fr\)\s+minmax\(0,\s*3fr\)\s+minmax\(240px,\s*1fr\)/u)
+})
+
+test('home work order jumps point to device maintenance dispatch page', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  const jumps = source.match(/goPage\('\/device\/maintain'/gu) || []
+  assert.ok(jumps.length >= 3, 'header, work order rows and related order should all route to device maintain')
+  assert.doesNotMatch(source, /monitorAlarm\/workOrder/u)
+  assert.match(source, />设备维修派单<\/el-button>/u)
+})