Przeglądaj źródła

feat(gis): integrate alarm map with shared canvas

Kazerin 1 miesiąc temu
rodzic
commit
1203c8cf2e

+ 2 - 0
docs/design/2026-09-09-water-supply-gis-marker-style.md

@@ -52,6 +52,8 @@ accepted
 
 状态判定不得替代设备状态、报警记录状态或工单状态的独立数据来源。
 
+监测报警一张图使用报警点位接口提供地图坐标,并通过报警台账与设备点位补全状态来源。设备报警状态、报警记录状态和关联工单状态必须分别展示;任一来源缺失时显示“未获取”,不得由另一来源推导。
+
 ### 统一交互
 
 - 默认地图中心为沅陵县。

+ 1 - 0
memory/index.md

@@ -62,3 +62,4 @@ npm run dev       # 默认 http://localhost:80
 - [2026-09-08:管网信息查询页重构接入 pipe_network_base(分 4 票执行完成)](sessions/2026-09-08.md)
 - [2026-09-09:water_pipe_info 收尾迁移(E 顺序执行完成,旧表链路全删)](sessions/2026-09-09.md)
 - [2026-09-10:WGS84→BD09 诊断与管理端工单生命周期共识更新](sessions/2026-09-10.md)
+- [2026-09-16:监测报警 GIS 接入统一画布(Ticket #4)](sessions/2026-09-16.md)

+ 28 - 0
memory/sessions/2026-09-16.md

@@ -0,0 +1,28 @@
+# 2026-09-16:监测报警 GIS 接入统一画布(Ticket #4)
+
+## 目标
+
+- 完成 Gogs Issue #4:将监测报警一张图接入统一 GIS 页壳和共享画布。
+- 复核后端报警、工单状态和数据库契约,更新前端领域共识。
+
+## 结论
+
+- Issue #3 收尾提交已推送,当前分支在实现 #4 前与 `origin/master` 同步。
+- 报警一张图继续复用 `WaterGisPageShell`、`GisMapCanvas`、彩色聚合图标和“报警集合:数量”文本。
+- 报警点位状态统一调用共享监测点状态推导,不再维护报警页私有优先级;关联工单仅在未结束时显示蓝色,已结案/已取消不再误判为关联工单。
+- 报警详情独立展示报警等级、报警类型、设备报警状态、报警记录状态、报警展示状态、设备当前状态、在线状态和关联工单状态;设备状态或记录状态缺失时显示“未获取”,不互相替代。
+- 报警地图通过分页读取全部报警台账和设备点位补全状态;台账或设备接口失败时显示降级提醒,但保留点位列表和重试入口。
+- 新增 `全图` 控件,并在地图 ready 后重新适配点位视野。
+
+## 验证
+
+- GIS、报警模型和报警页单文件测试:24/24 通过。
+- 前端全量测试:103/103 通过。
+- 生产构建:通过。
+- `git diff --check`:通过。
+- 真实登录态和 BMapGL 点位颜色仍需人工浏览器验收。
+
+## 风险
+
+- 后端 `AlarmMapPointOutDTO` 仍缺少设备报警状态、在线状态和关联工单字段;当前前端通过报警台账与设备 GIS 接口补全。后续若报警数据规模继续增长,建议后端在地图点位契约中直接返回这些字段。
+- 报警台账分页设置 200 条/页、最多 50 页的安全上限;超过上限会进入加载失败状态,而不是静默丢失状态。

+ 8 - 3
src/utils/gisMapColors.js

@@ -1,3 +1,5 @@
+import { isActiveWorkOrderStatus } from './waterSupplyUiModel.js'
+
 export const gisPointStatusColors = {
   normal: '#2ecc71',
   alarm: '#e74c3c',
@@ -52,9 +54,12 @@ export const gisPointStatusTagTypes = {
 }
 
 export function getMonitoringPointStatus(point = {}) {
-  const relatedOrderNo = point.relatedOrderNo ?? point.relatedWorkOrderNo ?? point.orderNo
-  if (relatedOrderNo && relatedOrderNo !== '-') return 'workOrder'
-  if (Number(point.alarmStatus) === 1) return 'alarm'
+  if (point.statusKey && gisPointStatusColors[point.statusKey]) return point.statusKey
+  const relatedOrderNo = point.orderNo ?? point.relatedOrderNo ?? point.relatedWorkOrderNo ?? point.latestOrderNo
+  const orderStatus = point.orderStatus ?? point.relatedWorkOrderStatus ?? point.latestMaintainStatus
+  const orderIsKnownActive = orderStatus === undefined || orderStatus === null || orderStatus === '' || isActiveWorkOrderStatus(orderStatus)
+  if (relatedOrderNo && relatedOrderNo !== '-' && orderIsKnownActive) return 'workOrder'
+  if (Number(point.deviceAlarmStatus ?? point.alarmStatus) === 1) return 'alarm'
   if (Number(point.currentStatus) === 3) return 'warning'
   if (Number(point.onlineStatus) === 0) return 'offline'
   return 'normal'

+ 178 - 0
src/utils/waterAlarmGisModel.js

@@ -0,0 +1,178 @@
+import { getMonitoringPointStatus } from './gisMapColors.js'
+import { isActiveWorkOrderStatus } from './waterSupplyUiModel.js'
+
+/**
+ * 监测报警一张图的点位状态阈值:
+ * 同一设备只保留一个点位,点位状态按“关联工单 > 报警 > 预警 > 离线 > 正常”取最高风险语义。
+ * 该状态只服务于地图着色,不替代设备报警状态、报警记录状态或工单状态。
+ */
+const alarmLevelLabels = { 1: '一级报警', 2: '二级报警', 3: '三级报警', 4: '四级报警' }
+
+export function deriveAlarmGisStatus(point = {}) {
+  const sharedStatus = getMonitoringPointStatus(point)
+  if (sharedStatus !== 'normal') return sharedStatus
+  const hasAlarmEvent = point.alarmId ?? point.id ?? point.warningType ?? point.alarmType
+  return hasAlarmEvent && Number(point.recordStatus) !== 1 ? 'alarm' : 'normal'
+}
+
+const displayStatusLabels = { 1: '待审核', 2: '已转工单', 3: '已解除' }
+
+function deviceKeyOf(record = {}) {
+  const key = record.deviceCode ?? record.equipmentCode ?? record.equipmentId
+  return key === null || key === undefined ? '' : String(key)
+}
+
+function hasCoordinate(value) {
+  return String(value ?? '').trim() !== '' && Number.isFinite(Number(value))
+}
+
+function numberOrUndefined(value) {
+  return value === null || value === undefined || value === '' ? undefined : Number(value)
+}
+
+function toText(...values) {
+  const found = values.find(value => value !== null && value !== undefined && String(value).trim() !== '')
+  return found === undefined ? undefined : found
+}
+
+/** 台账按设备建索引时优先保留仍有关联工单的那条报警 */
+function preferLedgerRow(candidate, current) {
+  if (!current) return true
+  const candidateActive = Boolean(candidate.orderNo) && isActiveWorkOrderStatus(candidate.orderStatus)
+  const currentActive = Boolean(current.orderNo) && isActiveWorkOrderStatus(current.orderStatus)
+  return candidateActive && !currentActive
+}
+
+function indexLedgerRows(ledgerRows) {
+  const byAlarmId = new Map()
+  const byDeviceCode = new Map()
+  ledgerRows.forEach((row) => {
+    if (!row) return
+    const alarmId = row.id ?? row.alarmId
+    if (alarmId !== null && alarmId !== undefined && !byAlarmId.has(String(alarmId))) {
+      byAlarmId.set(String(alarmId), row)
+    }
+    const deviceCode = deviceKeyOf(row)
+    if (!deviceCode) return
+    if (preferLedgerRow(row, byDeviceCode.get(deviceCode)) || !byDeviceCode.has(deviceCode)) {
+      byDeviceCode.set(deviceCode, row)
+    }
+  })
+  return { byAlarmId, byDeviceCode }
+}
+
+function buildDevicePointIndex(devicePoints) {
+  const byDeviceCode = new Map()
+  devicePoints.forEach((device) => {
+    const deviceCode = deviceKeyOf(device)
+    if (deviceCode && !byDeviceCode.has(deviceCode)) byDeviceCode.set(deviceCode, device)
+  })
+  return byDeviceCode
+}
+
+function pickRepresentative(alarms) {
+  return [...alarms].sort((a, b) => {
+    const levelA = numberOrUndefined(a.alarmLevel)
+    const levelB = numberOrUndefined(b.alarmLevel)
+    if (levelA === undefined && levelB === undefined) return 0
+    if (levelA === undefined) return 1
+    if (levelB === undefined) return -1
+    return levelA - levelB
+  })[0]
+}
+
+/**
+ * 报警点位归一化:只保留有坐标的报警点,同一设备合并为一个点位,
+ * 并用报警台账与设备点位补齐设备报警状态、报警记录状态、报警展示状态和关联工单状态。
+ * 三类状态各自成字段,任何一类缺失都不会用另一类顶替。
+ */
+export function buildAlarmGisPoints({ alarmPoints = [], ledgerRows = [], devicePoints = [] } = {}) {
+  const alarmList = (Array.isArray(alarmPoints) ? alarmPoints : [])
+    .filter(point => point && hasCoordinate(point.longitude ?? point.lng) && hasCoordinate(point.latitude ?? point.lat))
+  if (!alarmList.length) return []
+
+  const { byAlarmId, byDeviceCode: ledgerByDeviceCode } = indexLedgerRows(Array.isArray(ledgerRows) ? ledgerRows : [])
+  const deviceByDeviceCode = buildDevicePointIndex(Array.isArray(devicePoints) ? devicePoints : [])
+
+  const groups = new Map()
+  alarmList.forEach((point, index) => {
+    const key = deviceKeyOf(point) || `alarm-${point.alarmId ?? index}`
+    const group = groups.get(key) || []
+    group.push(point)
+    groups.set(key, group)
+  })
+
+  const built = []
+  groups.forEach((alarms, deviceCode) => {
+    const representative = pickRepresentative(alarms)
+    const alarmId = representative.alarmId ?? representative.id
+    const ledgerRow = (alarmId !== undefined && byAlarmId.get(String(alarmId)))
+      || ledgerByDeviceCode.get(deviceCode)
+      || {}
+    const devicePoint = deviceByDeviceCode.get(deviceCode) || {}
+
+    const recordStatus = numberOrUndefined(ledgerRow.alarmStatus)
+    const deviceAlarmStatus = numberOrUndefined(devicePoint.alarmStatus)
+      ?? numberOrUndefined(ledgerRow.deviceAlarmStatus)
+    const displayStatus = numberOrUndefined(ledgerRow.displayStatus)
+    const longitude = Number(representative.longitude ?? representative.lng)
+    const latitude = Number(representative.latitude ?? representative.lat)
+    const alarmLevel = numberOrUndefined(representative.alarmLevel)
+
+    const point = {
+      ...representative,
+      deviceCode: toText(representative.deviceCode, deviceKeyOf(ledgerRow), deviceKeyOf(devicePoint), deviceCode),
+      deviceName: toText(representative.deviceName, devicePoint.equipmentName, ledgerRow.deviceName),
+      longitude,
+      latitude,
+      lng: longitude,
+      lat: latitude,
+      alarmCount: alarms.length,
+      alarmLevel,
+      alarmLevelText: toText(representative.alarmLevelText, alarmLevelLabels[alarmLevel]),
+      recordStatus,
+      recordStatusText: toText(ledgerRow.alarmStatusText, recordStatus === undefined ? '未获取' : recordStatus === 1 ? '已处理' : '待处理'),
+      deviceAlarmStatus,
+      deviceAlarmStatusText: toText(ledgerRow.deviceAlarmStatusText, deviceAlarmStatus === undefined ? '未获取' : deviceAlarmStatus === 1 ? '报警中' : '正常'),
+      displayStatus,
+      displayStatusText: toText(ledgerRow.displayStatusText, displayStatusLabels[displayStatus]),
+      orderNo: toText(ledgerRow.orderNo, representative.orderNo),
+      orderStatus: numberOrUndefined(ledgerRow.orderStatus) ?? numberOrUndefined(representative.orderStatus),
+      onlineStatus: numberOrUndefined(devicePoint.onlineStatus) ?? numberOrUndefined(representative.onlineStatus),
+      currentStatus: numberOrUndefined(devicePoint.currentStatus) ?? numberOrUndefined(representative.currentStatus),
+      onlineStatusText: toText(devicePoint.onlineStatusText, representative.onlineStatusText),
+      currentStatusText: toText(devicePoint.currentStatusText, representative.currentStatusText)
+    }
+    point.statusKey = deriveAlarmGisStatus(point)
+    built.push(point)
+  })
+
+  return built.sort((a, b) => {
+    const levelA = a.alarmLevel ?? Number.MAX_SAFE_INTEGER
+    const levelB = b.alarmLevel ?? Number.MAX_SAFE_INTEGER
+    return levelA - levelB
+  })
+}
+
+const searchableFields = ['deviceName', 'deviceCode', 'name', 'warningType', 'alarmType', 'equipmentLocation', 'location']
+
+export function filterAlarmGisPoints(points = [], keyword = '') {
+  const list = Array.isArray(points) ? points : []
+  const needle = String(keyword ?? '').trim().toLocaleLowerCase()
+  if (!needle) return list
+  return list.filter((point) => searchableFields
+    .some(field => String(point?.[field] ?? '').toLocaleLowerCase().includes(needle)))
+}
+
+export function summarizeAlarmGisPoints(points = []) {
+  const list = Array.isArray(points) ? points : []
+  const byStatus = { normal: 0, alarm: 0, warning: 0, workOrder: 0, offline: 0 }
+  let highLevel = 0
+  list.forEach((point) => {
+    const statusKey = point?.statusKey ?? deriveAlarmGisStatus(point)
+    if (Object.prototype.hasOwnProperty.call(byStatus, statusKey)) byStatus[statusKey] += 1
+    const alarmLevel = numberOrUndefined(point?.alarmLevel)
+    if (alarmLevel !== undefined && alarmLevel <= 2) highLevel += 1
+  })
+  return { total: list.length, highLevel, byStatus }
+}

+ 6 - 0
src/utils/waterSupplyUiModel.js

@@ -97,6 +97,12 @@ export function normalizeWorkOrderStatus(value) {
   return { code: code ? Number(code) : null, text: text || '暂无工单', terminal: code ? terminalWorkOrderStatuses.has(Number(code)) : false }
 }
 
+/** 关联工单是否仍未结束;已结案(5)与已取消(7)不再代表处置进行中 */
+export function isActiveWorkOrderStatus(status) {
+  const normalized = normalizeWorkOrderStatus(status)
+  return normalized.code !== null && !normalized.terminal
+}
+
 export function canDispatchDevice(row = {}) {
   const orderNo = row.relatedOrderNo ?? row.latestOrderNo ?? row.orderNo
   if (!orderNo || orderNo === '-') return true

+ 152 - 48
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -192,44 +192,59 @@
                 :loading="loading"
             >
                 <template #actions>
-                    <el-input v-model="alarmSearch" class="search-input" placeholder="搜索设备/报警类型" clearable
+                    <el-input v-model="query.keyword" class="search-input" placeholder="搜索设备/报警类型/位置" clearable
                               :prefix-icon="Search"/>
                     <el-button :icon="Refresh" @click="loadData">刷新</el-button>
+                    <el-button :icon="Aim" @click="fitAlarmMap">全图</el-button>
                 </template>
                 <template #metrics>
                     <div class="metric">
                         <el-icon class="metric-icon"><Bell/></el-icon>
-                        <span>报警总数</span><strong>{{ mapAlarmCount }}</strong><small>条</small>
+                        <span>报警点位</span><strong>{{ mapStatistics.total }}</strong><small>个</small>
                     </div>
                     <div class="metric">
                         <el-icon class="metric-icon"><Warning/></el-icon>
-                        <span>高等级报警</span><strong class="danger">{{ mapCriticalCount }}</strong><small>条</small>
+                        <span>高等级报警</span><strong class="danger">{{ mapStatistics.highLevel }}</strong><small>个</small>
                     </div>
                     <div class="metric">
-                        <el-icon class="metric-icon"><CircleCheck/></el-icon>
-                        <span>已处理报警</span><strong>{{ mapResolvedCount }}</strong><small>条</small>
+                        <el-icon class="metric-icon"><Tickets/></el-icon>
+                        <span>有关联工单</span><strong class="primary">{{ mapStatistics.byStatus.workOrder }}</strong><small>个</small>
                     </div>
                     <div class="metric">
                         <el-icon class="metric-icon"><Location/></el-icon>
-                        <span>地图点位</span><strong>{{ mappedCount }}</strong><small>个</small>
+                        <span>离线点位</span><strong>{{ mapStatistics.byStatus.offline }}</strong><small>个</small>
                     </div>
                 </template>
                 <template #controls>
                     <div class="panel-section alarm-detail">
                         <h3>选中报警</h3>
                         <template v-if="selectedPoint.id || selectedPoint.alarmId">
-                            <div class="detail-name">{{ selectedPoint.deviceCode || selectedPoint.name || '报警点' }}</div>
+                            <div class="detail-name">{{ selectedPoint.deviceName || selectedPoint.deviceCode || selectedPoint.name || '报警点' }}</div>
                             <el-descriptions :column="1" border size="small">
                                 <el-descriptions-item label="报警等级">
                                     <el-tag :type="levelType(selectedPoint.alarmLevel)">{{ selectedPoint.alarmLevelText || levelText(selectedPoint.alarmLevel) }}</el-tag>
                                 </el-descriptions-item>
                                 <el-descriptions-item label="报警类型">{{ selectedPoint.warningType || selectedPoint.alarmType || '-' }}</el-descriptions-item>
                                 <el-descriptions-item label="触发时间">{{ selectedPoint.alarmTime || selectedPoint.createTime || '-' }}</el-descriptions-item>
-                                <el-descriptions-item label="状态">
-                                    <el-tag :type="Number(selectedPoint.alarmStatus) === 1 ? 'success' : 'danger'">
-                                        {{ Number(selectedPoint.alarmStatus) === 1 ? '已处理' : '未处理' }}
+                                <el-descriptions-item label="点位状态">
+                                    <el-tag :type="statusTagType(selectedPoint)">{{ statusText(selectedPoint) }}</el-tag>
+                                </el-descriptions-item>
+                                <el-descriptions-item label="设备报警状态">
+                                    <el-tag :type="selectedPoint.deviceAlarmStatus === undefined ? 'info' : Number(selectedPoint.deviceAlarmStatus) === 1 ? 'danger' : 'success'">
+                                        {{ selectedPoint.deviceAlarmStatusText || '未获取' }}
+                                    </el-tag>
+                                </el-descriptions-item>
+                                <el-descriptions-item label="报警记录状态">
+                                    <el-tag :type="selectedPoint.recordStatus === undefined ? 'info' : Number(selectedPoint.recordStatus) === 1 ? 'success' : 'danger'">
+                                        {{ selectedPoint.recordStatusText || '未获取' }}
                                     </el-tag>
                                 </el-descriptions-item>
+                                <el-descriptions-item label="关联工单">
+                                    <span>{{ selectedPoint.orderNo || '-' }}</span>
+                                    <span v-if="selectedPoint.orderStatus !== null && selectedPoint.orderStatus !== undefined">
+                                        · {{ orderStatusText(selectedPoint.orderStatus) }}
+                                    </span>
+                                </el-descriptions-item>
                                 <el-descriptions-item label="位置">{{ selectedPoint.equipmentLocation || selectedPoint.location || '-' }}</el-descriptions-item>
                             </el-descriptions>
                             <el-button class="detail-action" type="primary" link @click="openDetail(selectedPoint)">查看完整详情</el-button>
@@ -239,28 +254,34 @@
                 </template>
                 <template #legend>
                     <div class="panel-section">
-                        <h3>报警图层与图例</h3>
+                        <h3>点位状态与图例</h3>
                         <div class="map-legend">
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单 {{ mapStatistics.byStatus.workOrder }}</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警 {{ mapStatistics.byStatus.alarm }}</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警 {{ mapStatistics.byStatus.warning }}</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线 {{ mapStatistics.byStatus.offline }}</div>
+                            <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常 {{ mapStatistics.byStatus.normal }}</div>
                         </div>
+                        <p class="muted legend-note">同一设备多点位合并,按关联工单 &gt; 报警 &gt; 预警 &gt; 离线 &gt; 正常取最高风险状态。</p>
                     </div>
                 </template>
                 <template #results>
                     <div class="panel-section result-section">
                         <h3>搜索报警</h3>
-                        <el-alert v-if="mapError" type="warning" show-icon :closable="false" :title="mapError"/>
-                        <el-empty v-else-if="!filteredAlarmPoints.length" description="暂无报警点位" :image-size="72"/>
-                        <div v-else class="device-result-list">
+                        <el-alert v-if="mapLoadError" type="error" show-icon :closable="false" :title="mapLoadError">
+                            <el-button link type="primary" @click="loadData">重试</el-button>
+                        </el-alert>
+                        <el-alert v-if="mapEnrichmentWarning" type="warning" show-icon :closable="false"
+                                  :title="`${mapEnrichmentWarning}未能补全,点位状态按报警记录展示`"/>
+                        <el-empty v-if="!mapLoadError && !mapPoints.length" description="暂无报警点位" :image-size="72"/>
+                        <el-empty v-if="!mapLoadError && mapPoints.length && !filteredAlarmPoints.length" description="当前筛选下没有匹配的报警点位" :image-size="72"/>
+                        <div v-if="!mapLoadError && filteredAlarmPoints.length" class="device-result-list">
                             <button v-for="point in filteredAlarmPoints" :key="point.id || point.alarmId || point.deviceCode"
                                     type="button" @click="locateAlarmPoint(point)">
                                 <span class="device-name">{{ point.deviceName || point.deviceCode || point.name || '报警点' }}</span>
                                 <span class="device-meta">{{ point.warningType || point.alarmType || '监测报警' }} · {{ point.alarmTime || point.createTime || '-' }}</span>
-                                <el-tag size="small" :type="levelType(point.alarmLevel)">
-                                    {{ point.alarmLevelText || levelText(point.alarmLevel) }}
+                                <el-tag size="small" :type="statusTagType(point)">
+                                    {{ statusText(point) }}
                                 </el-tag>
                             </button>
                         </div>
@@ -273,12 +294,16 @@
                         cluster-label="报警集合"
                         :center="{ lng: 110.3965, lat: 28.4638 }"
                         :zoom="15"
-                        @point-click="openDetail"
+                        @ready="handleAlarmMapReady"
+                        @point-click="locateAlarmPoint"
                         @map-click="detailVisible = false"
                         @error="mapError = $event"
                     />
                     <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError"/>
-                    <el-empty v-if="!gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
+                    <el-alert v-if="mapLoadError" class="map-error" type="error" show-icon :closable="false" :title="mapLoadError">
+                        <el-button link type="primary" @click="loadData">重试</el-button>
+                    </el-alert>
+                    <el-empty v-else-if="!gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
                 </template>
             </WaterGisPageShell>
         </el-card>
@@ -354,7 +379,7 @@
     import {useRoute, useRouter} from 'vue-router'
     import {ElMessage} from 'element-plus'
     import {
-        Bell, CircleCheck, Clock, DataAnalysis, Download, Finished, MapLocation, PieChart,
+        Aim, Bell, CircleCheck, Clock, DataAnalysis, Download, Finished, MapLocation, PieChart,
         Refresh, Search, Tickets, TrendCharts, Warning
     } from '@element-plus/icons-vue'
     import {saveAs} from 'file-saver'
@@ -365,6 +390,7 @@
         getAlarmDetail,
         getAlarmFlow,
         getAlarmLedgerDashboard,
+        getDeviceGisAllPoints,
         listAlarmAudits,
         listAlarmHistory,
         listAlarmLedger,
@@ -372,7 +398,17 @@
     } from '@/api/pipeNetwork/waterSupply'
     import GisMapCanvas from '@/components/GisMapCanvas.vue'
     import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
-    import { getMonitoringPointColor, getMonitoringPointIcon } from '@/utils/gisMapColors'
+    import {
+        getMonitoringPointIcon,
+        getMonitoringPointLabel,
+        getMonitoringPointTagType
+    } from '@/utils/gisMapColors'
+    import {
+        buildAlarmGisPoints,
+        filterAlarmGisPoints,
+        summarizeAlarmGisPoints
+    } from '@/utils/waterAlarmGisModel'
+    import { normalizeDeviceGisPoints, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
     import {normalizeMapPoints, normalizePage, readMonitoringExportError, unwrapResponse} from '@/utils/waterSupplyModel'
     import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
     import {orderStatusText} from '@/views/subSystem/pipeNetwork/ops/workOrder/workOrderDict.js'
@@ -438,7 +474,10 @@
     let devicePie;
     const mapPoints = ref([]);
     const mapError = ref('')
-    const alarmSearch = ref('')
+    // 报警一张图的数据加载失败与状态补全降级分别提示,避免地图空白时用户无从判断
+    const mapLoadError = ref('')
+    const mapEnrichmentWarning = ref('')
+    const mapReady = ref(false)
     const gisMapRef = ref(null)
     const loading = ref(false);
     const detailVisible = ref(false);
@@ -457,27 +496,21 @@
         {label: '已转工单', value: 2},
         {label: '已解除', value: 3}
     ]
-    const filteredAlarmPoints = computed(() => {
-        const keyword = alarmSearch.value.trim().toLocaleLowerCase();
-        if (!keyword) return mapPoints.value;
-        return mapPoints.value.filter(point => [
-            point.deviceName, point.deviceCode, point.name, point.warningType, point.alarmType
-        ].some(value => String(value || '').toLocaleLowerCase().includes(keyword)));
-    });
+    const filteredAlarmPoints = computed(() => filterAlarmGisPoints(
+        mapPoints.value,
+        query.value.keyword
+    ));
 
     const gisMapPoints = computed(() => filteredAlarmPoints.value.map(point => ({
         ...point,
-        lng: point._longitude ?? point.longitude,
-        lat: point._latitude ?? point.latitude,
-        name: point.deviceCode || point.name || '报警点',
-        color: getMonitoringPointColor(point),
+        name: point.deviceName || point.deviceCode || point.name || '报警点',
         iconUrl: getMonitoringPointIcon(point)
     })))
 
-    const mapAlarmCount = computed(() => mapPoints.value.length);
-    const mapCriticalCount = computed(() => mapPoints.value.filter(point => Number(point.alarmLevel) <= 2).length);
-    const mapResolvedCount = computed(() => mapPoints.value.filter(point => Number(point.alarmStatus) === 1).length);
-    const mappedCount = computed(() => mapPoints.value.filter(point => Number.isFinite(Number(point.mapX)) && Number.isFinite(Number(point.mapY))).length)
+    /** 左侧状态总览:五态数量来自点位推导结果,与地图着色使用同一口径 */
+    const mapStatistics = computed(() => summarizeAlarmGisPoints(filteredAlarmPoints.value));
+    const statusText = point => getMonitoringPointLabel(point);
+    const statusTagType = point => getMonitoringPointTagType(point);
     /**
      * 详情抽屉字段:逐条报警流水,标签统一中文;
      * 工单状态复用统一工单字典,避免页面自带一套状态码文本。
@@ -496,8 +529,10 @@
             ['阈值区间', thresholdRange(row)],
             ['偏差率', deviationText(row)],
             ['报警等级', row.alarmLevelText || levelText(row.alarmLevel)],
-            ['报警记录状态', row.alarmStatusText],
+            ['报警记录状态', row.recordStatusText || row.alarmStatusText],
             ['设备报警状态', row.deviceAlarmStatusText],
+            ['设备当前状态', row.currentStatusText],
+            ['在线状态', row.onlineStatusText],
             ['报警展示状态', row.displayStatusText],
             ['关联工单号', row.orderNo],
             ['工单状态', row.orderStatus === null || row.orderStatus === undefined ? '' : orderStatusText(row.orderStatus)],
@@ -580,6 +615,8 @@
 
     async function loadData() {
         loading.value = true
+        mapLoadError.value = ''
+        mapEnrichmentWarning.value = ''
         try {
             if (props.mode === 'manage') {
                 const [summary, ledger] = await Promise.all([
@@ -591,11 +628,15 @@
             } else if (props.mode === 'audit') {
                 page.value = normalizePage(await listAlarmHistory({...query.value, alarmStatus: 0}))
             } else {
-                mapPoints.value = normalizeMapPoints(unwrapResponse(await listAlarmMapPoints()) || [])
+                await loadAlarmMapData()
             }
         } catch (error) {
-            page.value = {records: [], total: 0}
-            mapPoints.value = []
+            if (props.mode === 'map') {
+                mapLoadError.value = getRequestErrorMessage(error, '报警点位加载失败,请重试')
+                mapPoints.value = []
+            } else {
+                page.value = {records: [], total: 0}
+            }
             console.warn(`[${props.title}] 数据加载失败`, error)
             ElMessage.error(getRequestErrorMessage(error, '数据加载失败,请检查服务状态'))
         } finally {
@@ -605,6 +646,66 @@
         }
     }
 
+    /** 报警一张图台账补全条数:与列表页共用同一台账接口,避免同一状态出现两套口径 */
+    const ALARM_MAP_LEDGER_PAGE_SIZE = 200
+    const ALARM_MAP_LEDGER_MAX_PAGES = 50
+
+    async function loadAllAlarmLedgerRows() {
+        const collected = []
+        let total = 0
+        for (let pageNum = 1; pageNum <= ALARM_MAP_LEDGER_MAX_PAGES; pageNum += 1) {
+            const response = await listAlarmLedger({ pageNum, pageSize: ALARM_MAP_LEDGER_PAGE_SIZE })
+            const ledgerPage = normalizePage(response)
+            collected.push(...ledgerPage.records)
+            total = ledgerPage.total
+            if (!ledgerPage.records.length || collected.length >= ledgerPage.total) {
+                if (collected.length < total) throw new Error('报警台账分页未完整返回')
+                return collected
+            }
+        }
+        throw new Error('报警台账分页超过安全上限')
+    }
+
+    /**
+     * 报警点位取自专用点位接口,再用报警台账补全设备/记录/工单状态。
+     * 台账或设备点位不可用时只降级对应状态来源,不影响报警点位本身渲染。
+     */
+    async function loadAlarmMapData() {
+        const [pointsResult, ledgerResult, deviceResult] = await Promise.allSettled([
+            listAlarmMapPoints(),
+            loadAllAlarmLedgerRows(),
+            getDeviceGisAllPoints()
+        ])
+        if (pointsResult.status === 'rejected') throw pointsResult.reason
+
+        const alarmPoints = normalizeMapPoints(unwrapResponse(pointsResult.value) || [])
+        const ledgerRows = ledgerResult.status === 'fulfilled' ? ledgerResult.value : []
+        const devicePoints = deviceResult.status === 'fulfilled'
+            ? normalizeDeviceGisPoints(deviceResult.value).map(normalizeDeviceRow)
+            : []
+        mapPoints.value = buildAlarmGisPoints({alarmPoints, ledgerRows, devicePoints})
+
+        const missingSources = [
+            ledgerResult.status === 'rejected' ? '关联工单状态' : '',
+            deviceResult.status === 'rejected' ? '设备在线与运行状态' : ''
+        ].filter(Boolean)
+        mapEnrichmentWarning.value = missingSources.join('、')
+        if (missingSources.length) {
+            console.warn(`[${props.title}] 状态补全降级:${mapEnrichmentWarning.value}`)
+        }
+        await nextTick()
+        fitAlarmMap()
+    }
+
+    function handleAlarmMapReady() {
+        mapReady.value = true
+        if (mapPoints.value.length) nextTick(fitAlarmMap)
+    }
+
+    function fitAlarmMap() {
+        if (mapReady.value) gisMapRef.value?.fitBounds()
+    }
+
     const resetQuery = () => {
         query.value = {
             pageNum: 1,
@@ -619,6 +720,8 @@
         }
         timeRange.value = []
         pendingAlarmId.value = ''
+        mapLoadError.value = ''
+        mapEnrichmentWarning.value = ''
         loadData()
     }
 
@@ -785,7 +888,8 @@
                 props.mode === 'audit' ? listAlarmAudits(id) : Promise.resolve(null),
                 props.mode === 'audit' ? getAlarmFlow(id) : Promise.resolve(null)
             ])
-            detail.value = unwrapResponse(detailResponse) || row
+            // 台账详情不返回设备在线/运行状态,合并保留点位侧已补全的状态,避免抽屉回退成单一状态
+            detail.value = {...row, ...(unwrapResponse(detailResponse) || {})}
             audits.value = unwrapResponse(auditResponse) || []
             flow.value = unwrapResponse(flowResponse) || []
         } catch (error) {
@@ -795,8 +899,8 @@
 
     function alarmPointFocus(point) {
         return {
-            lng: point._longitude ?? point.longitude,
-            lat: point._latitude ?? point.latitude
+            lng: Number(point.lng ?? point.longitude),
+            lat: Number(point.lat ?? point.latitude)
         };
     }
 

+ 16 - 2
tests/gisMapCanvas.test.mjs

@@ -87,6 +87,8 @@ test('cluster icons reuse the single member icon or fall back to the colorful ma
 test('monitoring point colors follow the documented status semantics', () => {
   assert.equal(getMonitoringPointStatus({ relatedOrderNo: 'WO-1' }), 'workOrder')
   assert.equal(getMonitoringPointStatus({ relatedWorkOrderNo: 'WO-2' }), 'workOrder')
+  assert.equal(getMonitoringPointStatus({ orderNo: 'WO-3', orderStatus: 5 }), 'normal')
+  assert.equal(getMonitoringPointStatus({ orderNo: 'WO-4', orderStatus: 7, deviceAlarmStatus: 1 }), 'alarm')
   assert.equal(getMonitoringPointStatus({ alarmStatus: 1 }), 'alarm')
   assert.equal(getMonitoringPointStatus({ currentStatus: 3 }), 'warning')
   assert.equal(getMonitoringPointStatus({ onlineStatus: 0 }), 'offline')
@@ -110,6 +112,19 @@ test('monitoring point colors follow the documented status semantics', () => {
   assert.equal(getMonitoringPointTagType({ onlineStatus: 0 }), 'info')
 })
 
+test('an explicit status key wins over inferred monitoring point fields', () => {
+  const offlineAlarmRecord = { statusKey: 'offline', alarmStatus: 1, onlineStatus: 0 }
+  assert.equal(getMonitoringPointStatus(offlineAlarmRecord), 'offline')
+  assert.equal(getMonitoringPointColor(offlineAlarmRecord), '#95a5a6')
+  assert.match(getMonitoringPointIcon(offlineAlarmRecord), /T_MarkPoint_Grey\.png/u)
+
+  const handledAlarm = { statusKey: 'normal', alarmStatus: 1 }
+  assert.equal(getMonitoringPointStatus(handledAlarm), 'normal')
+  assert.match(getMonitoringPointIcon(handledAlarm), /T_MarkPoint_Green\.png/u)
+
+  assert.equal(getMonitoringPointStatus({ statusKey: '未知状态', alarmStatus: 1 }), 'alarm')
+})
+
 test('water supply GIS pages share the common canvas component', async () => {
   const deviceSource = await readFile('src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue', 'utf8')
   const facilitySource = await readFile('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')
@@ -132,9 +147,8 @@ test('water supply GIS pages share the common canvas component', async () => {
 
   assert.match(alarmSource, /<GisMapCanvas/u)
   assert.match(alarmSource, /cluster-label="报警集合"/u)
-  assert.match(alarmSource, /getMonitoringPointColor\(point\)/u)
   assert.match(alarmSource, /一级报警/u)
   assert.doesNotMatch(alarmSource, /color: point\.relatedOrderNo \?/u)
   assert.match(alarmSource, /:points="gisMapPoints"/u)
-  assert.match(alarmSource, /@point-click="openDetail"/u)
+  assert.match(alarmSource, /@point-click="locateAlarmPoint"/u)
 })

+ 184 - 0
tests/waterAlarmGisModel.test.mjs

@@ -0,0 +1,184 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  buildAlarmGisPoints,
+  deriveAlarmGisStatus,
+  filterAlarmGisPoints,
+  summarizeAlarmGisPoints
+} from '../src/utils/waterAlarmGisModel.js'
+import { isActiveWorkOrderStatus } from '../src/utils/waterSupplyUiModel.js'
+
+test('alarm gis points take the highest-risk status by documented priority', () => {
+  assert.equal(deriveAlarmGisStatus({}), 'normal')
+  assert.equal(deriveAlarmGisStatus({ onlineStatus: 0 }), 'offline')
+  assert.equal(deriveAlarmGisStatus({ currentStatus: 3 }), 'warning')
+  assert.equal(deriveAlarmGisStatus({ deviceAlarmStatus: 1 }), 'alarm')
+  assert.equal(deriveAlarmGisStatus({ orderNo: 'WO-1', orderStatus: 3 }), 'workOrder')
+
+  assert.equal(deriveAlarmGisStatus({ orderNo: 'WO-1', orderStatus: 3, deviceAlarmStatus: 1, currentStatus: 3, onlineStatus: 0 }), 'workOrder')
+  assert.equal(deriveAlarmGisStatus({ deviceAlarmStatus: 1, currentStatus: 3, onlineStatus: 0 }), 'alarm')
+  assert.equal(deriveAlarmGisStatus({ currentStatus: 3, onlineStatus: 0 }), 'warning')
+  assert.equal(deriveAlarmGisStatus({ onlineStatus: 0, deviceAlarmStatus: 0 }), 'offline')
+})
+
+test('a closed or cancelled work order no longer counts as an active related order', () => {
+  assert.equal(isActiveWorkOrderStatus(0), true)
+  assert.equal(isActiveWorkOrderStatus('2'), true)
+  assert.equal(isActiveWorkOrderStatus(6), true)
+  assert.equal(isActiveWorkOrderStatus(5), false)
+  assert.equal(isActiveWorkOrderStatus('7'), false)
+  assert.equal(isActiveWorkOrderStatus(undefined), false)
+
+  assert.equal(deriveAlarmGisStatus({ orderNo: 'WO-5', orderStatus: 5, deviceAlarmStatus: 1 }), 'alarm')
+  assert.equal(deriveAlarmGisStatus({ orderNo: 'WO-7', orderStatus: 7, onlineStatus: 0 }), 'offline')
+})
+
+test('renders every valid alarm point and drops points without coordinates', () => {
+  const points = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-1', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 2 },
+      { alarmId: 'A-2', deviceCode: 'D-2', longitude: '110.2', latitude: '28.2', alarmLevel: 3 },
+      { alarmId: 'A-3', deviceCode: 'D-3', longitude: '', latitude: '28.3', alarmLevel: 1 }
+    ]
+  })
+
+  assert.deepEqual(points.map(point => point.alarmId), ['A-1', 'A-2'])
+  assert.deepEqual(points.map(point => point.lng), [110.1, 110.2])
+  assert.deepEqual(points.map(point => point.lat), [28.1, 28.2])
+})
+
+test('merges the alarms of one device into a single highest-risk point', () => {
+  const points = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-LOW', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 4, alarmTime: '2026-09-16 08:00:00' },
+      { alarmId: 'A-HIGH', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 1, alarmTime: '2026-09-16 09:00:00' }
+    ]
+  })
+
+  assert.equal(points.length, 1)
+  assert.equal(points[0].alarmId, 'A-HIGH')
+  assert.equal(points[0].alarmCount, 2)
+})
+
+test('enriches alarm points with independent device, record and work-order state', () => {
+  const points = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-1', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 1, warningType: '压力预警' }
+    ],
+    ledgerRows: [
+      {
+        id: 'A-1',
+        deviceCode: 'D-1',
+        alarmStatus: 1,
+        alarmStatusText: '已处理',
+        deviceAlarmStatus: 1,
+        deviceAlarmStatusText: '报警中',
+        displayStatus: 2,
+        displayStatusText: '已转工单',
+        orderNo: 'WO-2026-1',
+        orderStatus: 3
+      }
+    ],
+    devicePoints: [
+      { equipmentCode: 'D-1', onlineStatus: 1, currentStatus: 3, onlineStatusText: '在线', currentStatusText: '维修' }
+    ]
+  })
+
+  assert.equal(points.length, 1)
+  assert.equal(points[0].recordStatus, 1)
+  assert.equal(points[0].deviceAlarmStatus, 1)
+  assert.equal(points[0].displayStatus, 2)
+  assert.equal(points[0].orderNo, 'WO-2026-1')
+  assert.equal(points[0].orderStatus, 3)
+  assert.equal(points[0].onlineStatus, 1)
+  assert.equal(points[0].currentStatus, 3)
+  assert.equal(points[0].onlineStatusText, '在线')
+  assert.equal(points[0].currentStatusText, '维修')
+  // 关联工单进行中时点位取最高风险语义;设备报警状态与记录状态仍各自保留
+  assert.equal(points[0].statusKey, 'workOrder')
+})
+
+test('marks unavailable device and record state as unknown without substituting either one', () => {
+  const [pending] = buildAlarmGisPoints({
+    alarmPoints: [{ alarmId: 'A-1', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 1 }]
+  })
+  assert.equal(pending.deviceAlarmStatus, undefined)
+  assert.equal(pending.deviceAlarmStatusText, '未获取')
+  assert.equal(pending.recordStatus, undefined)
+  assert.equal(pending.recordStatusText, '未获取')
+  assert.equal(pending.statusKey, 'alarm')
+
+  const [handled] = buildAlarmGisPoints({
+    alarmPoints: [{ alarmId: 'A-2', deviceCode: 'D-2', longitude: '110.1', latitude: '28.1', alarmLevel: 4 }],
+    ledgerRows: [{ id: 'A-2', deviceCode: 'D-2', alarmStatus: 1, alarmStatusText: '已处理' }]
+  })
+  assert.equal(handled.deviceAlarmStatus, undefined)
+  assert.equal(handled.deviceAlarmStatusText, '未获取')
+  assert.equal(handled.recordStatus, 1)
+  assert.equal(handled.statusKey, 'normal')
+})
+
+test('keeps an offline or repairing device out of the alarm colour even when its order is closed', () => {
+  const [offline, warning] = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-1', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1' },
+      { alarmId: 'A-2', deviceCode: 'D-2', longitude: '110.2', latitude: '28.2' }
+    ],
+    ledgerRows: [
+      { id: 'A-1', deviceCode: 'D-1', deviceAlarmStatus: 0, orderNo: 'WO-1', orderStatus: 5 },
+      { id: 'A-2', deviceCode: 'D-2', deviceAlarmStatus: 0 }
+    ],
+    devicePoints: [
+      { equipmentCode: 'D-1', onlineStatus: 0, currentStatus: 1 },
+      { equipmentCode: 'D-2', onlineStatus: 1, currentStatus: 3 }
+    ]
+  })
+
+  assert.equal(offline.statusKey, 'offline')
+  assert.equal(warning.statusKey, 'warning')
+})
+
+test('searches alarm points by device, alarm type and location', () => {
+  const points = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-1', deviceCode: 'CN-GS-001', deviceName: '一号压力计', warningType: '压力预警', equipmentLocation: '迎宾路', longitude: '110.1', latitude: '28.1', alarmLevel: 1 },
+      { alarmId: 'A-2', deviceCode: 'CN-GS-002', deviceName: '二号流量计', warningType: '流量预警', equipmentLocation: '沅水大道', longitude: '110.2', latitude: '28.2', alarmLevel: 3 }
+    ]
+  })
+
+  assert.deepEqual(filterAlarmGisPoints(points, '').map(point => point.alarmId), ['A-1', 'A-2'])
+  assert.deepEqual(filterAlarmGisPoints(points, '压力').map(point => point.alarmId), ['A-1'])
+  assert.deepEqual(filterAlarmGisPoints(points, 'cn-gs-002').map(point => point.alarmId), ['A-2'])
+  assert.deepEqual(filterAlarmGisPoints(points, '沅水').map(point => point.alarmId), ['A-2'])
+  assert.deepEqual(filterAlarmGisPoints(points, '不存在的关键字'), [])
+})
+
+test('summarizes alarm points by the five documented map states', () => {
+  const points = buildAlarmGisPoints({
+    alarmPoints: [
+      { alarmId: 'A-1', deviceCode: 'D-1', longitude: '110.1', latitude: '28.1', alarmLevel: 1 },
+      { alarmId: 'A-2', deviceCode: 'D-2', longitude: '110.2', latitude: '28.2', alarmLevel: 4 },
+      { alarmId: 'A-3', deviceCode: 'D-3', longitude: '110.3', latitude: '28.3', alarmLevel: 2 },
+      { alarmId: 'A-4', deviceCode: 'D-4', longitude: '110.4', latitude: '28.4', alarmLevel: 3 }
+    ],
+    ledgerRows: [
+      { id: 'A-1', deviceCode: 'D-1', deviceAlarmStatus: 1 },
+      { id: 'A-2', deviceCode: 'D-2', deviceAlarmStatus: 0 },
+      { id: 'A-3', deviceCode: 'D-3', deviceAlarmStatus: 0, orderNo: 'WO-1', orderStatus: 2 },
+      { id: 'A-4', deviceCode: 'D-4', deviceAlarmStatus: 0 }
+    ],
+    devicePoints: [
+      { equipmentCode: 'D-1', onlineStatus: 1, currentStatus: 1 },
+      { equipmentCode: 'D-2', onlineStatus: 0, currentStatus: 1 },
+      { equipmentCode: 'D-3', onlineStatus: 1, currentStatus: 1 },
+      { equipmentCode: 'D-4', onlineStatus: 1, currentStatus: 3 }
+    ]
+  })
+
+  const summary = summarizeAlarmGisPoints(points)
+  assert.equal(summary.total, 4)
+  assert.equal(summary.highLevel, 2)
+  assert.deepEqual(summary.byStatus, { normal: 0, alarm: 1, warning: 1, workOrder: 1, offline: 1 })
+
+  assert.deepEqual(summarizeAlarmGisPoints([]).byStatus, { normal: 0, alarm: 0, warning: 0, workOrder: 0, offline: 0 })
+})

+ 70 - 0
tests/waterAlarmGisPage.test.mjs

@@ -0,0 +1,70 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const readAlarmPage = () => readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')
+
+test('the alarm map merges the alarm ledger and device points onto the shared canvas', async () => {
+  const source = await readAlarmPage()
+
+  assert.match(source, /buildAlarmGisPoints\(\{/u)
+  assert.match(source, /async function loadAllAlarmLedgerRows\(\)/u)
+  assert.match(source, /collected\.length >= ledgerPage\.total/u)
+  assert.match(source, /getDeviceGisAllPoints\(/u)
+  assert.match(source, /normalizeDeviceGisPoints[\s\S]*?normalizeDeviceRow/u)
+  assert.match(source, /:points="gisMapPoints"/u)
+  assert.match(source, /cluster-label="报警集合"/u)
+})
+
+test('clicking a map point or search result locates the alarm and opens the detail drawer', async () => {
+  const source = await readAlarmPage()
+
+  assert.match(source, /@point-click="locateAlarmPoint"/u)
+  assert.match(source, /function locateAlarmPoint\(point\)[\s\S]*?gisMapRef\.value\?\.locatePoint\(/u)
+  assert.match(source, /function locateAlarmPoint\(point\)[\s\S]*?openDetail\(point\)/u)
+})
+
+test('the alarm map shows distinct empty, failed and retry states', async () => {
+  const source = await readAlarmPage()
+
+  assert.match(source, /const mapLoadError = ref\(''\)/u)
+  assert.match(source, /mapLoadError\.value = /u)
+  assert.match(source, /@click="loadData"[^>]*>\s*重试/u)
+  assert.match(source, /暂无报警点位/u)
+  assert.match(source, /当前筛选下没有匹配的报警点位/u)
+  assert.match(source, /v-if="mapEnrichmentWarning"[\s\S]*?v-if="!mapLoadError && mapPoints\.length && !filteredAlarmPoints\.length"/u)
+})
+
+test('the alarm map exposes five map states with text, not colour alone', async () => {
+  const source = await readAlarmPage()
+
+  const legendStates = [
+    ['work-order', '关联工单'],
+    ['alarm', '报警'],
+    ['warning', '预警'],
+    ['offline', '离线'],
+    ['normal', '正常']
+  ]
+  for (const [statusClass, label] of legendStates) {
+    assert.match(source, new RegExp(`status-dot-${statusClass}"\\/>${label}`, 'u'))
+  }
+  assert.match(source, /mapStatistics\.byStatus/u)
+})
+
+test('the alarm detail panel keeps device, record and work-order states apart', async () => {
+  const source = await readAlarmPage()
+
+  assert.match(source, /\['报警记录状态', row\.recordStatusText \|\| row\.alarmStatusText\]/u)
+  assert.match(source, /\['设备报警状态', row\.deviceAlarmStatusText\]/u)
+  assert.match(source, /\['报警展示状态', row\.displayStatusText\]/u)
+  assert.match(source, /\['工单状态', row\.orderStatus/u)
+  assert.match(source, /\['在线状态', row\.onlineStatusText\]/u)
+})
+
+test('the alarm map fits all points after the map and data are ready', async () => {
+  const source = await readAlarmPage()
+
+  assert.match(source, /@ready="handleAlarmMapReady"/u)
+  assert.match(source, /function fitAlarmMap\(\)/u)
+  assert.match(source, /<el-button :icon="Aim" @click="fitAlarmMap">全图<\/el-button>/u)
+})