Jelajahi Sumber

Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/views/subSystem/waterSupply/components/WaterSupplyHome.vue
zengy 3 minggu lalu
induk
melakukan
b91a8168e5

+ 47 - 0
memory/sessions/2026-09-17.md

@@ -0,0 +1,47 @@
+# 2026-09-17:供水首页工作台 #58 四项修正收尾(前端呈现 + 后端实时报警根因)
+
+## 目标
+
+关闭 T05R / Gogs #58「供水首页工作台四项修正」的最后一段:按用户人工验收反馈,修复实时报警状态、全域态势地图铺满、B 区卡片比例、列表滚动四项问题,并补齐后端实时报警取数根因。
+
+## 结论与变更
+
+### 前端(E:\city-life-line)
+
+- `f275f1f`(#58 主交付):移除 A/B/C 子标题占位;实时报警按展示状态分流(待审核→报警审核,已转工单→设备维修派单);全域态势改用公共 GIS 画布 `GisMapCanvas`;工单进展跳转改到设备维修派单(带 keyword)。
+- `d5a044e`(验收反馈第一轮):`normalizeRealtimeAlarmRows` 增加展示状态兜底;B 区改用 `workbench-grid--equal` 三卡等宽;`.compact-list` 增加 `max-height:360px; overflow-y:auto`;取数上限从 6/7/8 提到 20。
+- `fb03a30`(验收反馈第二轮):兜底判定顺序与后端 `resolveDisplayStatus` 对齐 —— **在办工单优先**,其次才是报警已处理/误报解除,避免把「已转工单」误判为「已解除」而从实时列表丢弃。
+
+### 后端(E:\pipe-ner-server)
+
+- `72017c3`(#58 根因修复):`WaterSupplyAlarmServiceImpl.getRealtimeAlarmList()` 去掉 `.eq(AlarmData::getAlarmStatus, 0)` 硬过滤,改为 `buildLedgerVoList(alarms)` 富化后在 Java 侧排除 `displayStatus=3`(已解除)。原因是已转工单(存在未取消工单)的报警 `alarm_status=1`,被原过滤条件整体挡掉,导致首页实时报警全部显示为「待审核」。
+- 同步修正 `WaterSupplyAlarmController` 的 `/realtime` `@ApiOperation` 描述为「待审核/已转工单,已解除不返回」。
+
+## 关键决策
+
+- 实时报警的分流口径以「展示状态」为唯一依据,而不是 `alarm_status`;`alarm_status` 只作为设备报警状态与兜底推导的输入。
+- 前端兜底的判定顺序必须与后端 `resolveDisplayStatus` 一致:有未取消工单 → `2 已转工单`;否则报警已处理/误报解除 → `3 已解除`;其余 → `1 待审核`。
+- 后端明确返回 `displayStatus` 时始终优先,前端兜底仅作用于旧进程/旧产物缺失该字段的过渡期。
+
+## 文件变更
+
+前端:`src/utils/waterHomeModel.js`、`src/views/subSystem/waterSupply/components/WaterSupplyHome.vue`、`tests/waterHomeModel.test.mjs`、`tests/waterSupplyHomeContract.test.mjs`。
+
+后端:`pipe-network-service/zksy-system/.../WaterSupplyAlarmServiceImpl.java`、`.../WaterSupplyAlarmRealtimeContractTest.java`、`pipe-network-service/zksy-admin/.../WaterSupplyAlarmController.java`。
+
+## 验证结果
+
+- 前端:`node --test tests/*.test.mjs` 157/157 通过(net/新增:兜底推导、在办工单优先、已取消工单不视为已转工单、B 区等宽、列表滚动、地图铺满契约);`vite build --logLevel error` 通过(仅既有 Sass 弃用告警)。
+- 后端:`WaterSupplyAlarmRealtimeContractTest` 先红(2 失败)后绿,4 用例通过;`mvn -pl pipe-network-service/zksy-system test` 141/141 通过;`mvn -o -pl pipe-network-service/zksy-admin -am compile` 通过。
+- 数据库口径(KingbaseES 实库核对):待审核 `alarm_status=0` 有 7 条,已转工单 `alarm_status=1` 有 5 条 —— 修复前这 5 条完全不在实时列表内,修复后应同时出现在首页。
+
+## 风险与后续
+
+- 本地后端进程(PID 24724,09:13 启动)为修复前产物,需重启后端(或依赖 devtools 热重启)后首页实时报警才会出现「已转工单」行;验收需重启后复测。
+- 该项属 T05R 修复范围,不触碰 #51(运行系统清警能力缺失)与 #50(报警与设备 service 收敛)。
+- 前端两仓均只提交未推送;用户确认人工验收通过后再推送并关闭 Gogs #58。
+
+## 未决
+
+- #58 待用户人工验收(重启后端 → 首页实时报警出现「已转工单」→ 点击跳转设备维修派单)。
+- 报警管理页「设备报警状态 vs 报警展示状态」矛盾观察项,转入 #51 范畴,后续用 `/grill-with-docs` 澄清。

+ 36 - 0
memory/sessions/2026-09-18-facility-gis-colors.md

@@ -0,0 +1,36 @@
+# 2026-09-18(续):供水设施 GIS 一张图统一按设施状态着色
+
+## 目标
+
+把供水设施 GIS 一张图的点位颜色从“按设施类型”改为“按设施状态”,与已验收的首页全域态势共用同一套状态色语义。
+
+## 结论与变更
+
+- 提交 `c0bcedb`:状态语义提取到共享色板 `src/utils/gisMapColors.js`
+  - 新增 `facilityStatusMeta` 与 `getFacilityStatusMeta(type, status)` / `getFacilityStatusIcon(type, status)`。
+  - 水源地 0 正常(绿)/1 异常(红);水厂 0 正常(绿)/1 停产(红);泵站 0 正常(绿)/1 故障停运(红)/2 检修中(蓝);用水户 0 正常(绿)/1 欠费(黄)/2 停用(红)。
+  - 状态缺失或未知按“正常”处理,不臆造异常;管网 0 不参与状态映射,返回 `null` 图标。
+- `src/utils/waterHomeModel.js` 删除本地重复的 `HOME_FACILITY_STATUS_META`,改为复用共享函数,首页与设施一张图只有一处状态定义。
+- `facility/gis.vue`:点位按状态取色取图标;图例改为“设施状态图例”(正常/异常类/检修中/欠费)+ 管网仅管线说明;搜索结果列表加状态色点与状态标签(管网回退显示设施类型);详情抽屉增加设施业务状态标签。
+- 删除已无生产引用的 `facilityPointIcons` / `getFacilityPointIcon`(旧按类型图标),避免两套设施颜色定义并存。
+
+## 关键决策
+
+- 设施状态色沿用设备五态语义,不新造色板;红色同时承载异常/停产/故障停运/停用,蓝色承载检修中,黄色承载欠费。
+- 管网保持现状:只画管线、不加图标,也不显示状态标签。
+- 状态定义收敛到共享色板,设施状态语义变更只需改一处。
+
+## 验证结果
+
+- TDD:先加红测试(`getFacilityStatusMeta` 缺失、设施页仍用类型图标),实现后转绿。
+- 前端全量 `node --test tests/*.test.mjs` 161/161 通过;`vite build --logLevel error` 通过。
+
+## 审查备注
+
+- 两轴审查由主线程完成(子代理连续两次因上游 503 不可用):Spec 全部满足;Standards 无硬违规,另有两条判断项已在提交前修复(管网被误标为“正常”、旧类型图标函数成为死代码)。
+- 后端 `WaterSupplyGisMapServiceImpl` 已在 GeoJSON properties 中输出 `status`,前端无需额外接口改动。
+
+## 后续行动
+
+- 待人工验收设施一张图各状态颜色与图例;通过后按需推送(当前仅本地提交)。
+- #50(供水报警与设备 service 收敛)另有并行会话在推进,其共享服务已移至 `com.zksy.base.service.impl`,本轮未触碰。

+ 32 - 0
memory/sessions/2026-09-18.md

@@ -0,0 +1,32 @@
+# 2026-09-18:首页 GIS 点位颜色语义统一(仅首页)
+
+## 目标
+
+按用户要求统一供水首页“全域态势”GIS 点位颜色语义:保留设备五态(红-报警、蓝-工单、黄-预警、灰-离线、绿-正常),设施点位从“按设施类型上色”改为“按设施状态上色”,并补充顶部图例。
+
+## 结论与变更
+
+- 提交 `65a332c`:`src/utils/waterHomeModel.js` 新增 `HOME_FACILITY_STATUS_META`,设施状态映射为共享 statusKey:
+  - 水源地:0 正常(绿)、1 异常(红)
+  - 水厂:0 正常(绿)、1 停产(红)
+  - 泵站:0 正常(绿)、1 故障停运(红)、2 检修中(蓝)
+  - 用水户:0 正常(绿)、1 欠费(黄)、2 停用(红)
+- 设施点位图标改用 `getMonitoringPointIcon({ statusKey })`(与设备同一套红/蓝/黄/灰/绿图标),并携带 `statusText` 供详情抽屉展示业务化状态文案;状态缺失按正常处理。
+- 管网(pipe 类型)不落点位、不加图标,仍以黄色管线呈现(`#f1c40f` 保持不变)。
+- 首页顶部图例改为组合文案:报警/异常、工单/检修中、预警/欠费、离线、正常;图例色值改为直接引用 `gisPointStatusColors`,与 GIS 点位调色板同源。
+- 详情抽屉状态标签优先显示 `overviewDetail.statusText`(设施业务状态),无该字段时回落 `gisStatusLabel`(设备状态)。
+- 设施 GIS 一张图、设备 GIS 一张图等其他页面不受影响,仍按各自规范上色。
+
+## 验证结果
+
+- TDD:新增模型测试“设施点位按状态共享设备色语义”(先红后绿)与首页契约测试“图例与详情覆盖统一状态色”。
+- 前端全量 `node --test tests/*.test.mjs` 159/159 通过;`vite build --logLevel error` 通过。
+
+## 审查备注
+
+- Spec 审查另提出两条历史遗留项,均不在本次 diff 内、未处理:首页蓝白主题的装饰性渐变(#48 已验收交付)、工单处理中状态沿用橙色而非青绿色。如需调整建议另立票。
+- Standards 判断项:`HOME_FACILITY_STATUS_META` 的状态文案与 `waterFacilityGisModel.js` 的 `facilityStatusLabels` 存在语义重复;因后者未导出且设施一张图仍需按类型上色,为守住“仅首页”边界暂保持本地映射。
+
+## 后续行动
+
+- 待用户在首页人工验收设施状态颜色与图例;通过后再推送(当前仅本地提交)。

+ 33 - 8
src/utils/gisMapColors.js

@@ -16,16 +16,41 @@ export const gisPointStatusIcons = {
   offline: new URL('../assets/gis/T_MarkPoint_Grey.png', import.meta.url).href
 }
 
-export const facilityPointIcons = {
-  source: new URL('../assets/gis/T_MarkPoint_Blue.png', import.meta.url).href,
-  plant: new URL('../assets/gis/T_MarkPoint_Green.png', import.meta.url).href,
-  pumpStation: new URL('../assets/gis/T_MarkPoint_Red.png', import.meta.url).href,
-  pipe: new URL('../assets/gis/T_MarkPoint_Yellow.png', import.meta.url).href,
-  user: new URL('../assets/gis/T_MarkPoint_Black.png', import.meta.url).href
+/**
+ * 设施状态语义:与监测设备共用同一套状态色(绿-正常、红-报警/异常、蓝-工单/检修、黄-预警/欠费)。
+ * 供水设施 GIS 一张图与首页全域态势都从这里取状态,保证设施颜色只有一处定义。
+ * 状态缺失或未维护时按“正常”处理,不臆造异常。
+ */
+const facilityStatusMeta = {
+  source: {
+    0: { key: 'normal', text: '正常' },
+    1: { key: 'alarm', text: '异常' }
+  },
+  plant: {
+    0: { key: 'normal', text: '正常' },
+    1: { key: 'alarm', text: '停产' }
+  },
+  pumpStation: {
+    0: { key: 'normal', text: '正常' },
+    1: { key: 'alarm', text: '故障停运' },
+    2: { key: 'workOrder', text: '检修中' }
+  },
+  // 管网保持现状:只构造管线,不落点位图标,因此不参与状态色映射
+  user: {
+    0: { key: 'normal', text: '正常' },
+    1: { key: 'warning', text: '欠费' },
+    2: { key: 'alarm', text: '停用' }
+  }
 }
 
-export function getFacilityPointIcon(type = '') {
-  return facilityPointIcons[type] || facilityPointIcons.plant
+export function getFacilityStatusMeta(type = '', status = null) {
+  return facilityStatusMeta[type]?.[String(status)] || { key: 'normal', text: '正常' }
+}
+
+/** 设施点位图标:按状态取图标,管网不落点位图标。 */
+export function getFacilityStatusIcon(type = '', status = null) {
+  if (type === 'pipe') return null
+  return getMonitoringPointIcon({ statusKey: getFacilityStatusMeta(type, status).key })
 }
 
 const clusterPointIcon = new URL('../assets/gis/T_MarkPoint_Colorful.png', import.meta.url).href

+ 418 - 0
src/utils/waterHomeModel.js

@@ -0,0 +1,418 @@
+import { normalizeDeviceRow } from './waterSupplyUiModel.js'
+import { getFacilityStatusMeta, getMonitoringPointIcon, getMonitoringPointStatus } from './gisMapColors.js'
+
+/**
+ * 供水首页工作台数据模型(T05 #48)。
+ *
+ * 首页只组合既有只读接口,不在本模块调用后端;所有口径(供水范围、工单状态、
+ * 报警等级、在线率)都收敛到这里,便于用 node 单测锁定。
+ */
+
+// 工单状态 0-7,与 ADR-0010 权威枚举一致
+const WORK_ORDER_STATUS_META = {
+  0: { text: '待派单', type: 'danger' },
+  1: { text: '待接单', type: 'warning' },
+  2: { text: '已接单', type: 'primary' },
+  3: { text: '处理中', type: 'warning' },
+  4: { text: '待验收', type: 'primary' },
+  5: { text: '已结案', type: 'success' },
+  6: { text: '延期中', type: 'warning' },
+  7: { text: '已取消', type: 'info' }
+}
+
+const WORK_ORDER_LEVEL_META = {
+  0: { text: '紧急', type: 'danger' },
+  1: { text: '一般', type: 'warning' },
+  2: { text: '低', type: 'info' }
+}
+
+const ALARM_LEVEL_META = {
+  1: { text: '一级报警', type: 'danger' },
+  2: { text: '二级报警', type: 'warning' },
+  3: { text: '三级报警', type: 'primary' },
+  4: { text: '四级报警', type: 'success' }
+}
+
+/**
+ * 供水设备根类型:设备接口 /equipment-base 的数据范围参数,取值对应 EquipmentTypeEnum
+ * 的 1-供水设备。设备与工单两处取数都以此限定供水范围,避免各自写死。
+ */
+export const WATER_DEVICE_ROOT_TYPE_ID = 1
+
+export const HOME_QUICK_ENTRIES = [
+  { key: 'abnormal', label: '设备异常管理', suffix: '/device/abnormal', icon: 'WarningFilled' },
+  { key: 'alarm', label: '报警管理', suffix: '/monitorAlarm/index', icon: 'BellFilled' },
+  { key: 'audit', label: '报警审核', suffix: '/monitorAlarm/audit', icon: 'Finished' },
+  { key: 'map', label: '监测报警一张图', suffix: '/monitorAlarm/map', icon: 'MapLocation' }
+]
+
+/** 供水工单统计/列表的固定范围参数:设备父类别=供水。 */
+export function buildHomeWorkOrderQuery(extra = {}) {
+  return {
+    deviceTypeParentId: String(WATER_DEVICE_ROOT_TYPE_ID),
+    pageNum: 1,
+    pageSize: 5,
+    ...extra
+  }
+}
+
+/** 供水设施总数:overview 会追加 facilityType=total 合计行,须排除避免重复累计。 */
+export function normalizeFacilityTotal(facilityOverview = []) {
+  const rows = Array.isArray(facilityOverview) ? facilityOverview : []
+  return rows
+    .filter(item => item && item.facilityType !== 'total')
+    .reduce((sum, item) => sum + Number(item.totalCount ?? item.count ?? 0), 0)
+}
+
+/** A 区指挥台四项核心总量。 */
+export function normalizeHomeKpis({ facilityRows = [], ledgerStats = {}, alarmStats = {} } = {}) {
+  const realtimeAlarm = Number(alarmStats.realtimeAlarmCount ?? alarmStats.realtimeCount ?? 0)
+  return [
+    { key: 'facility', label: '设施总数', value: normalizeFacilityTotal(facilityRows), unit: '处', tone: 'primary' },
+    { key: 'device', label: '监测设备', value: Number(ledgerStats.total ?? 0), unit: '台', tone: 'primary' },
+    { key: 'online', label: '在线设备', value: Number(ledgerStats.online ?? 0), unit: '台', tone: 'primary' },
+    { key: 'alarm', label: '实时报警', value: realtimeAlarm, unit: '条', tone: realtimeAlarm > 0 ? 'danger' : 'primary' }
+  ]
+}
+
+/**
+ * 统一设备统计口径:/equipment-base/statistics 返回 total/online/offline/alarm,
+ * 首页所需的在线率在此推导,避免各页面各自计算。
+ */
+export function normalizeDeviceStatistics(stats = {}) {
+  const total = Number(stats.total ?? 0)
+  const online = Number(stats.online ?? 0)
+  const offline = Number(stats.offline ?? Math.max(total - online, 0))
+  const alarm = Number(stats.alarm ?? 0)
+  return {
+    total,
+    online,
+    offline,
+    alarm,
+    onlineRate: total > 0 ? Math.round(online * 1000 / total) / 10 : 0
+  }
+}
+
+function countByStatus(stat, status) {
+  const counts = Array.isArray(stat?.counts) ? stat.counts : []
+  const hit = counts.find(item => Number(item?.status) === Number(status))
+  return Number(hit?.count ?? 0)
+}
+
+/** 工单状态统计:待处理 0/1,处理中 2/3/6,待验收 4,已完成 5/7。 */
+export function normalizeWorkOrderStatusStat(stat = {}) {
+  const pendingCount = countByStatus(stat, 0) + countByStatus(stat, 1)
+  const processingCount = countByStatus(stat, 2) + countByStatus(stat, 3) + countByStatus(stat, 6)
+  const verifyingCount = countByStatus(stat, 4)
+  const finishedCount = countByStatus(stat, 5) + countByStatus(stat, 7)
+  const byStatus = Object.entries(WORK_ORDER_STATUS_META)
+    .map(([status, meta]) => ({
+      status: Number(status),
+      label: meta.text,
+      type: meta.type,
+      count: countByStatus(stat, Number(status))
+    }))
+  return {
+    total: Number(stat?.total ?? 0),
+    pendingCount,
+    processingCount,
+    verifyingCount,
+    finishedCount,
+    byStatus
+  }
+}
+
+/** C 区数据总览卡片:报警总数、报警中设备、待验收工单、在线率。 */
+export function normalizeAlarmOverviewCards({ dashboard = {}, deviceStats = {}, workOrderStat = {} } = {}) {
+  const normalized = normalizeWorkOrderStatusStat(workOrderStat)
+  return [
+    { key: 'alarmTotal', label: '报警总数', value: Number(dashboard.totalCount ?? 0), unit: '条', tone: 'primary' },
+    { key: 'alarmingDevice', label: '报警中设备', value: Number(dashboard.alarmingDeviceCount ?? 0), unit: '台', tone: Number(dashboard.alarmingDeviceCount ?? 0) > 0 ? 'danger' : 'primary' },
+    { key: 'verifyingOrder', label: '待验收工单', value: normalized.verifyingCount, unit: '单', tone: normalized.verifyingCount > 0 ? 'warning' : 'primary' },
+    { key: 'onlineRate', label: '在线率', value: formatOnlineRate(deviceStats.onlineRate), unit: '%', tone: 'primary' }
+  ]
+}
+
+function formatOnlineRate(value) {
+  const numeric = Number(value)
+  return Number.isFinite(numeric) ? Number(numeric.toFixed(1)) : 0
+}
+
+/** 报警趋势:按四个等级分别成系列,空天补 0。 */
+export function normalizeAlarmTrend(trend = []) {
+  const items = Array.isArray(trend) ? trend : []
+  const dates = items.map(item => item?.date || '-')
+  const levels = [1, 2, 3, 4].map(level => ({
+    name: `级${level}`,
+    text: (ALARM_LEVEL_META[level] || {}).text || `${level}级报警`,
+    data: items.map(item => Number(item[`level${level}Count`] ?? 0))
+  }))
+  return { dates, levels }
+}
+
+/** 占比饼图:空值名称兜底、数量转数字、只取最大值前若干项。 */
+export function normalizePieRatio(items = [], fallbackName = '未分类', top = 6) {
+  const rows = (Array.isArray(items) ? items : [])
+    .map(item => ({
+      name: String(item?.name || '').trim() || fallbackName,
+      count: Number(item?.count ?? 0)
+    }))
+    .filter(item => item.count > 0)
+    .sort((a, b) => b.count - a.count)
+  return rows.slice(0, top)
+}
+
+export function alarmLevelText(level) {
+  const numeric = Number(level)
+  const meta = ALARM_LEVEL_META[numeric]
+  return meta?.text || (Number.isFinite(numeric) && numeric > 0 ? `${numeric}级报警` : '未分级')
+}
+
+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)直接过滤;
+ * 待审核 / 已转工单行保留展示状态与关联工单号,供首页按状态分流跳转。
+ *
+ * 后端 5c14625 起实时行已携带 displayStatus;为兼容旧进程/旧产物返回的
+ * “只有 orderNo 没有 displayStatus” 的行,这里按工单是否在办兜底推导:
+ * 有未取消工单 -> 已转工单,否则 -> 待审核。兜底只影响缺失字段的行,
+ * 后端明确给出的展示状态始终优先。
+ */
+export function normalizeRealtimeAlarmRows(rows = [], limit = 5) {
+  const list = (Array.isArray(rows) ? rows : [])
+    .filter(row => derivedDisplayStatus(row) !== ALARM_DISPLAY_RELEASED)
+    .slice()
+    .sort((a, b) => String(b?.alarmTime || '').localeCompare(String(a?.alarmTime || '')))
+    .slice(0, limit)
+    .map(row => {
+      const displayStatus = derivedDisplayStatus(row)
+      return {
+        id: row?.id ?? row?.alarmId,
+        deviceCode: row?.deviceCode ?? '-',
+        deviceName: row?.deviceName || row?.deviceCode || '监测设备',
+        warningType: row?.warningType || '监测报警',
+        alarmLevel: Number(row?.alarmLevel ?? 0),
+        alarmLevelText: row?.alarmLevelText || alarmLevelText(row?.alarmLevel),
+        alarmTime: row?.alarmTime || '-',
+        location: row?.equipmentLocation || '-',
+        displayStatus,
+        displayStatusText: row?.displayStatusText
+          || ALARM_DISPLAY_STATUS_META[displayStatus]?.text
+          || '待审核',
+        displayStatusType: ALARM_DISPLAY_STATUS_META[displayStatus]?.type || 'warning',
+        orderNo: row?.orderNo || row?.relatedOrderNo || ''
+      }
+    })
+  return list
+}
+
+function derivedDisplayStatus(row = {}) {
+  const explicit = Number(row?.displayStatus)
+  if (Number.isFinite(explicit) && explicit !== 0) {
+    return explicit
+  }
+  // 与后端 resolveDisplayStatus 同序:在办工单优先,
+  // 其次才看报警是否已处理/误报解除,避免把“已转工单”误判成“已解除”。
+  const hasOrder = String(row?.orderNo || row?.relatedOrderNo || '').trim().length > 0
+  const orderStatus = Number(row?.orderStatus ?? row?.relatedOrderStatus ?? -1)
+  const liveOrder = hasOrder && orderStatus !== 7
+  if (liveOrder) {
+    return 2
+  }
+  return Number(row?.alarmStatus) === 1 ? ALARM_DISPLAY_RELEASED : 1
+}
+
+/**
+ * 实时报警跳转目标:已转工单带工单号 -> 设备维修派单;其余(含待审核)-> 报警审核。
+ */
+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)
+}
+
+/** B 区选中设备摘要。 */
+export function normalizeDeviceSummary(row = {}) {
+  const normalized = normalizeDeviceRow(row)
+  return {
+    ...normalized,
+    name: row.equipmentName || row.deviceName || '-',
+    code: row.equipmentCode || row.deviceCode || '-',
+    location: row.equipmentLocation || row.location || '-',
+    district: row.district || '-',
+    typeName: normalized.equipmentTypeName || '-'
+  }
+}
+
+/** A 区工单进展列表。 */
+export function normalizeWorkOrderRows(rows = [], limit = 5) {
+  return (Array.isArray(rows) ? rows : [])
+    .slice()
+    .sort((a, b) => String(b?.createTime || '').localeCompare(String(a?.createTime || '')))
+    .slice(0, limit)
+    .map(row => {
+      const orderStatus = Number(row?.orderStatus ?? -1)
+      const orderLevel = Number(row?.orderLevel ?? -1)
+      const statusMeta = WORK_ORDER_STATUS_META[orderStatus] || { text: '未知', type: 'info' }
+      const levelMeta = WORK_ORDER_LEVEL_META[orderLevel] || { text: '-', type: 'info' }
+      return {
+        orderId: row?.orderId,
+        orderNo: row?.orderNo || '-',
+        orderStatus,
+        orderStatusText: statusMeta.text,
+        orderStatusType: statusMeta.type,
+        orderLevel,
+        orderLevelText: levelMeta.text,
+        orderLevelType: levelMeta.type,
+        deviceCode: row?.deviceCode || '-',
+        deviceName: row?.deviceName || '-',
+        orderDesc: row?.orderDesc || '-',
+        createTime: row?.createTime || '-'
+      }
+    })
+}
+
+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') {
+    if (point?.type === 'pipe') return null
+    // 设施状态语义统一由共享色板定义,首页与设施 GIS 一张图同源
+    const statusMeta = getFacilityStatusMeta(point?.type, point?.status)
+    return {
+      ...point,
+      id: point?.id ?? point?.featureId,
+      kind,
+      name: point?.name || point?.facilityName || point?.code || '供水设施',
+      code: point?.code || point?.facilityCode || '-',
+      lng,
+      lat,
+      statusKey: statusMeta.key,
+      statusText: statusMeta.text,
+      iconUrl: getMonitoringPointIcon({ statusKey: statusMeta.key }),
+      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 : [])
+    .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.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)
+    }
+  })
+
+  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
+  }
+}

+ 904 - 1135
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -1,1376 +1,1145 @@
-<script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
-import * as echarts from 'echarts'
-import { Odometer, CircleCheckFilled, VideoCameraFilled, WarnTriangleFilled } from '@element-plus/icons-vue'
-import { convertPipeline, convertPoints } from '@/utils/coordTransform'
-import {
-  getAlarmStats,
-  getWaterFacilityFeatures,
-  getWaterFacilityOverview,
-  getDeviceGisAllPoints,
-  getDeviceGisStatistics,
-  listRealtimeAlarms,
-  listFlowMonitoring,
-  listPressureMonitoring,
-  listLeakageMonitoring
-} from '@/api/pipeNetwork/waterSupply'
-import { listEquipment } from '@/api/pipeNetwork/pdeviceInform'
-import { normalizeGeoJson, normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
-
-const nowText = ref('')
-const weekText = ref('')
-const mapError = ref('')
-const loading = ref(false)
-
-const dashboardData = ref({})
-const pipelineLines = ref([])
-const pipePoints = ref([])
-
-const facilityTypes = [
-  { label: '水源地', value: 'source', color: '#38a3ff' },
-  { label: '水厂', value: 'plant', color: '#38d0a0' },
-  { label: '泵站', value: 'pumpStation', color: '#ffc15e' },
-  { label: '管网', value: 'pipe', color: '#7b9bb8' },
-  { label: '用水户', value: 'user', color: '#ff7894' }
-]
-
-const typeCount = type => {
-  const row = (dashboardData.value.facilityOverview || []).find(item => item.facilityType === type)
-  return Number(row?.totalCount ?? row?.count ?? 0)
-}
-
-const baseCards = computed(() => {
-  const fs = dashboardData.value.facilityOverview || []
-  const es = dashboardData.value.equipmentStats || {}
-  const alarm = dashboardData.value.alarmStats || {}
-  const totalFacility = fs.reduce((sum, item) => sum + Number(item.totalCount || 0), 0)
-  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
-  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
-  const realtimeAlarm = Number(alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0)
-  return [
-    { label: '设施总数', value: totalFacility, unit: '处', icon: Odometer, color: '#60b8ff' },
-    { label: '设备总数', value: totalDevice, unit: '台', icon: CircleCheckFilled, color: '#52e0a8' },
-    { label: '在线设备', value: onlineDevice, unit: '台', icon: VideoCameraFilled, color: '#ff8e8e' },
-    { label: '实时报警', value: realtimeAlarm, unit: '条', icon: WarnTriangleFilled, color: '#ffb54d' }
-  ]
-})
-
-const operationSummary = computed(() => {
-  return [
-    { label: '供水管网', value: typeCount('pipe') + '条' },
-    { label: '水厂', value: typeCount('plant') + '座' },
-    { label: '泵站', value: typeCount('pumpStation') + '座' }
-  ]
-})
-
-const repairRecords = computed(() => {
-  const list = dashboardData.value.facilityOverview || []
-  if (list.length) {
-    return list.map(item => ({ month: item.facilityTypeName || item.facilityType || '设施', value: Number(item.totalCount || 0) }))
-  }
-  return [
-    { month: '水源地', value: 0 }, { month: '水厂', value: 0 }, { month: '泵站', value: 0 },
-    { month: '管网', value: 0 }, { month: '用水户', value: 0 }
-  ]
-})
-
-const pointRows = computed(() => {
-  const list = dashboardData.value.pointRows || []
-  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', time: '--', value: '--', status: '离线' }]
-})
-
-const equipmentRows = computed(() => {
-  const list = dashboardData.value.equipmentRows || []
-  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', code: '--', location: '--', status: '--' }]
-})
-
-const warningRows = computed(() => {
-  const list = dashboardData.value.warningRows || []
-  return list.length > 0 ? list : [{ level: '中', title: '暂无告警', code: '--', time: '--:--:--', desc: '当前无告警信息', tag: '正常' }]
-})
-
-const hazardSummary = computed(() => {
-  const alarm = dashboardData.value.alarmStats || {}
-  return [
-    { label: '实时报警', value: alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0, color: '#ff6d79' },
-    { label: '今日报警', value: alarm.todayAlarmCount ?? alarm.todayCount ?? 0, color: '#ffb54d' },
-    { label: '异常率', value: alarm.deviceAbnormalRate ?? alarm.abnormalRate ?? 0, color: '#f3e64d' }
-  ]
-})
-
-const mapKpis = computed(() => {
-  const es = dashboardData.value.equipmentStats || {}
-  const alarm = dashboardData.value.alarmStats || {}
-  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
-  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
-  return [
-    { label: '设备在线率', value: totalDevice > 0 ? (onlineDevice / totalDevice * 100).toFixed(1) + '%' : '--' },
-    { label: '供水设施', value: dashboardData.value.facilityOverview?.reduce((sum, item) => sum + Number(item.totalCount || 0), 0) || 0 },
-    { label: '今日报警', value: (alarm.todayAlarmCount ?? alarm.todayCount ?? 0) + '条' }
-  ]
-})
-
-const mapWrapRef = ref(null)
-const repairChartRef = ref(null)
-const hazardChartRef = ref(null)
-let mapInstance = null
-let repairChart = null
-let hazardChart = null
-let clockTimer = null
-
-function formatWeek(day) {
-  return ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][day]
-}
-
-function pad(value) {
-  return String(value).padStart(2, '0')
-}
-
-function updateClock() {
-  const now = new Date()
-  nowText.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
-  weekText.value = formatWeek(now.getDay())
-}
-
-function initRepairChart() {
-  if (!repairChartRef.value) return
-  repairChart?.dispose()
-  repairChart = echarts.init(repairChartRef.value)
-  const data = repairRecords.value
-  repairChart.setOption({
-    grid: { left: 30, right: 12, top: 20, bottom: 24, containLabel: true },
-    xAxis: {
-      type: 'category',
-      data: data.map(item => item.month),
-      axisLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.8)' } },
-      axisLabel: { color: '#d2efff' }
-    },
-    yAxis: {
-      type: 'value',
-      splitLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.18)' } },
-      axisLine: { show: false },
-      axisLabel: { color: '#d2efff' }
-    },
-    tooltip: { trigger: 'axis' },
-    series: [{
-      type: 'bar',
-      barWidth: 24,
-      data: data.map(item => item.value),
-      itemStyle: {
-        borderRadius: [6, 6, 0, 0],
-        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-          { offset: 0, color: '#38d8ff' },
-          { offset: 1, color: '#166dff' }
-        ])
-      }
-    }]
-  })
-}
-
-function initHazardChart() {
-  if (!hazardChartRef.value) return
-  hazardChart?.dispose()
-  hazardChart = echarts.init(hazardChartRef.value)
-  const data = hazardSummary.value
-  hazardChart.setOption({
-    grid: { left: 18, right: 18, top: 10, bottom: 10, containLabel: true },
-    xAxis: {
-      type: 'value',
-      splitLine: { show: false },
-      axisLine: { show: false },
-      axisTick: { show: false },
-      axisLabel: { show: false }
-    },
-    yAxis: {
-      type: 'category',
-      data: data.map(item => item.label),
-      axisLine: { show: false },
-      axisTick: { show: false },
-      axisLabel: { color: '#d2efff', fontSize: 13 }
-    },
-    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
-    series: [{
-      type: 'bar',
-      data: data.map(item => ({ value: item.value, itemStyle: { color: item.color, borderRadius: 10 } })),
-      barWidth: 14,
-      label: { show: true, position: 'right', color: '#fff', fontWeight: 'bold' },
-      showBackground: true,
-      backgroundStyle: { color: 'rgba(255,255,255,0.08)', borderRadius: 10 }
-    }]
-  })
-}
-
-function waitForBMapGL() {
-  return new Promise((resolve, reject) => {
-    if (window.BMapGL) {
-      resolve(window.BMapGL)
-      return
-    }
-    let attempts = 0
-    const timer = setInterval(() => {
-      attempts += 1
-      if (window.BMapGL) {
-        clearInterval(timer)
-        resolve(window.BMapGL)
-        return
-      }
-      if (attempts >= 100) {
-        clearInterval(timer)
-        reject(new Error('百度地图加载超时'))
-      }
-    }, 200)
-  })
-}
-
-function addPipeLine(BMapGL, pipe) {
-  const bmapPoints = pipe.points.map(([lng, lat]) => new BMapGL.Point(lng, lat))
-  const polyline = new BMapGL.Polyline(bmapPoints, {
-    strokeColor: pipe.color,
-    strokeWeight: 4,
-    strokeOpacity: 0.95
-  })
-  mapInstance.addOverlay(polyline)
-
-  bmapPoints.forEach((point) => {
-    const ring = new BMapGL.Circle(point, 14, {
-      strokeColor: pipe.color,
-      strokeWeight: 2,
-      strokeOpacity: 0.7,
-      fillColor: 'rgba(56,182,233,0.08)',
-      fillOpacity: 0.18
-    })
-    mapInstance.addOverlay(ring)
-  })
-}
-
-function addPipePoint(BMapGL, point) {
-  const markerPoint = new BMapGL.Point(point.lng, point.lat)
-  const marker = new BMapGL.Marker(markerPoint)
-  mapInstance.addOverlay(marker)
-  const label = new BMapGL.Label(`${point.name} ${point.value}`, { offset: new BMapGL.Size(-42, -40) })
-  label.setPosition(markerPoint)
-  label.setStyle({
-    color: '#d8faff',
-    background: 'rgba(18, 68, 112, 0.75)',
-    border: '1px solid rgba(56, 182, 233, 0.55)',
-    borderRadius: '8px',
-    padding: '5px 8px',
-    fontSize: '12px'
-  })
-  mapInstance.addOverlay(label)
-}
-
-async function initMap() {
-  if (!mapWrapRef.value) return
-  try {
-    const BMapGL = await waitForBMapGL()
-    mapInstance = new BMapGL.Map(mapWrapRef.value, { enableMapClick: true })
-    const center = new BMapGL.Point(110.3965, 28.4638)
-    mapInstance.centerAndZoom(center, 15)
-    mapInstance.enableScrollWheelZoom(true)
-    mapInstance.setMapStyleV2({ styleId: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' })
-
-    const bdPipelineLines = pipelineLines.value.map(pipe => convertPipeline(pipe))
-    const bdPipePoints = convertPoints(pipePoints.value)
-    bdPipelineLines.forEach(pipe => addPipeLine(BMapGL, pipe))
-    bdPipePoints.forEach(point => addPipePoint(BMapGL, point))
-  } catch (error) {
-    mapError.value = '百度地图加载失败,请检查地图脚本或网络连接。'
-  }
-}
-
-function handleResize() {
-  repairChart?.resize()
-  hazardChart?.resize()
-}
-
-function parseFeatures(features) {
-  const lines = []
-  const points = []
-  ;(features || []).forEach(feature => {
-    const coordinates = feature.geometry?.coordinates || []
-    const properties = feature.properties || {}
-    if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
-      lines.push({
-        id: feature.id || `line-${lines.length}`,
-        name: properties.facilityTypeName || properties.name || '供水管网',
-        color: '#37b6e9',
-        points: coordinates
-      })
-    }
-    if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
-      points.push({
-        id: feature.id || properties.id,
-        name: properties.facilityName || properties.name || properties.facilityTypeName || '供水设施',
-        value: properties.facilityTypeName || properties.facilityType || '',
-        lng: coordinates[0],
-        lat: coordinates[1],
-        type: properties.facilityType,
-        properties
-      })
-    }
-  })
-  pipelineLines.value = lines
-  pipePoints.value = points
-}
-
-function buildPointRows(flowPage, pressurePage, leakagePage) {
-  const flow = normalizePage(flowPage).records.map(item => normalizeMonitoringRecord(item, 'flow'))
-  const pressure = normalizePage(pressurePage).records.map(item => normalizeMonitoringRecord(item, 'pressure'))
-  const leakage = normalizePage(leakagePage).records.map(item => normalizeMonitoringRecord(item, 'power'))
-  return [...flow, ...pressure, ...leakage].slice(0, 8).map(item => ({
-    id: item.id,
-    name: item.equipmentName,
-    time: item.updateTime,
-    value: item.value == null ? '--' : item.value,
-    status: item.status === 1 || item.status === '1' ? '预警' : '在线'
-  }))
-}
-
-async function loadDashboardData() {
-  loading.value = true
-  try {
-    const [overviewResponse, featuresResponse, devicePointsResponse, deviceStatsResponse, alarmResponse, alarmSummaryResponse, devicePageResponse, flowResponse, pressureResponse, leakageResponse] = await Promise.all([
-      getWaterFacilityOverview(),
-      getWaterFacilityFeatures(),
-      getDeviceGisAllPoints(),
-      getDeviceGisStatistics(),
-      listRealtimeAlarms(),
-      getAlarmStats(),
-      listEquipment({ pageNum: 1, pageSize: 8, rootTypeId: 1 }),
-      listFlowMonitoring({ pageNum: 1, pageSize: 5 }),
-      listPressureMonitoring({ pageNum: 1, pageSize: 5 }),
-      listLeakageMonitoring({ pageNum: 1, pageSize: 5 })
-    ])
-    const overviewData = unwrapResponse(overviewResponse)
-    const facilityOverview = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
-    const features = normalizeGeoJson(featuresResponse).features || []
-    const deviceData = unwrapResponse(devicePointsResponse)
-    const deviceRows = Array.isArray(deviceData) ? deviceData : (deviceData?.features || deviceData?.records || [])
-    const devicePage = normalizePage(devicePageResponse)
-    const rawStats = unwrapResponse(deviceStatsResponse) || {}
-    const typeStatistics = rawStats.typeStatistics || []
-    const flatten = list => (Array.isArray(list) ? list : []).reduce((sum, item) => sum + Number(item.deviceCount || item.count || 0) + flatten(item.children), 0)
-    const equipmentStats = {
-      ...rawStats,
-      total: Number(rawStats.total ?? rawStats.totalDeviceCount ?? 0) || flatten(typeStatistics) || deviceRows.length,
-      online: Number(rawStats.online ?? rawStats.onlineCount ?? deviceRows.filter(item => Number(item.onlineStatus) === 1).length),
-      abnormal: Number(rawStats.abnormal ?? rawStats.abnormalCount ?? deviceRows.filter(item => Number(item.alarmStatus) === 1).length)
-    }
-    const alarmList = Array.isArray(unwrapResponse(alarmResponse)) ? unwrapResponse(alarmResponse) : []
-    const alarmStats = unwrapResponse(alarmSummaryResponse) || {}
-    const equipmentRows = devicePage.records.slice(0, 8).map(item => ({
-      id: item.equipmentId || item.id,
-      name: item.equipmentName || item.name || '-',
-      code: item.equipmentCode || item.code || '-',
-      location: item.equipmentLocation || item.location || '-',
-      status: Number(item.status || item.onlineStatus) === 1 ? '异常' : '正常'
-    }))
-    const warningRows = alarmList.slice(0, 6).map(item => ({
-      level: Number(item.alarmLevel) <= 2 ? '高' : '中',
-      title: item.deviceName || item.deviceCode || '监测设备',
-      code: item.warningType || item.alarmCode || '-',
-      time: item.createTime || '--',
-      desc: item.equipmentLocation || '报警详情待查看',
-      tag: Number(item.alarmLevel) <= 2 ? '紧急' : '一般'
-    }))
-    dashboardData.value = {
-      facilityOverview,
-      equipmentStats,
-      alarmStats,
-      warningRows,
-      equipmentRows,
-      pointRows: buildPointRows(flowResponse, pressureResponse, leakageResponse)
-    }
-    parseFeatures(features)
-  } catch (error) {
-    console.warn('供水首页数据加载失败', error)
-    dashboardData.value = { facilityOverview: [], equipmentStats: {}, alarmStats: {}, warningRows: [], equipmentRows: [], pointRows: [] }
-    pipelineLines.value = []
-    pipePoints.value = []
-  } finally {
-    loading.value = false
-  }
-}
-
-onMounted(async () => {
-  updateClock()
-  clockTimer = window.setInterval(updateClock, 1000)
-  await loadDashboardData()
-  await nextTick()
-  initRepairChart()
-  initHazardChart()
-  initMap()
-  window.addEventListener('resize', handleResize)
-})
-
-onBeforeUnmount(() => {
-  if (clockTimer) window.clearInterval(clockTimer)
-  window.removeEventListener('resize', handleResize)
-  repairChart?.dispose()
-  hazardChart?.dispose()
-  mapInstance = null
-})
-</script>
-
-
 <template>
-  <div class="gas-home-screen">
-    <div class="screen-header">
-      <div class="header-time">{{ nowText }} {{ weekText }}</div>
-      <div class="header-title-wrap">
-        <div class="header-title">供水管网安全运行监测系统</div>
-        <div class="header-subtitle">Water Supply Operation Dashboard</div>
+  <div class="water-page home-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><HomeFilled /></el-icon></span>
+        <div>
+          <h2>供水首页工作台</h2>
+          <span class="muted">面向值班态的一站式供水运行工作台,仅统计供水设施与供水监测设备</span>
+        </div>
       </div>
-      <div class="header-tags">
-        <span class="header-tag">实时监测</span>
-        <span class="header-tag">供水设施</span>
-        <span class="header-tag">报警预警</span>
+      <div class="heading-actions">
+        <el-button :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
       </div>
     </div>
 
-    <div class="screen-body">
-      <div class="side-panel left-panel">
-        <section class="panel-card base-card">
-          <div class="panel-title">基础概况</div>
-          <div class="base-grid">
-            <div v-for="item in baseCards" :key="item.label" class="base-item">
-              <div class="base-icon" :style="{ background: item.color }">
-                <component :is="item.icon" class="base-icon-svg" />
-              </div>
-              <div class="base-text">
-                <div class="base-label">{{ item.label }}</div>
-                <div class="base-value">{{ item.value }}<span>{{ item.unit }}</span></div>
-              </div>
+    <el-alert v-if="loadError" class="load-error" type="error" show-icon :closable="false"
+              title="部分数据加载失败" :description="loadError">
+      <el-button size="small" type="primary" :loading="loading" @click="loadAll">重试</el-button>
+    </el-alert>
+
+    <!-- A 指挥台 -->
+    <section class="home-section">
+      <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>
+          <div class="metric-body">
+            <span>{{ card.label }}</span>
+            <div class="metric-value">
+              <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
             </div>
           </div>
-        </section>
-
-        <section class="panel-card">
-          <div class="panel-title">设施统计</div>
-          <div ref="repairChartRef" class="chart-box repair-chart"></div>
-        </section>
-
-        <section class="panel-card table-card">
-          <div class="panel-title">监测点数据</div>
-          <div class="table-wrap">
-            <table class="data-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>监测点</th>
-                  <th>时间</th>
-                  <th>数值</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="row in pointRows" :key="row.id">
-                  <td>{{ row.id }}</td>
-                  <td>{{ row.name }}</td>
-                  <td>{{ row.time }}</td>
-                  <td>{{ row.value }}</td>
-                  <td :class="['status-cell', row.status === '在线' ? 'ok' : row.status === '预警' ? 'warn' : 'off']">{{ row.status }}</td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </section>
+        </div>
       </div>
-
-      <div class="center-panel">
-        <div class="map-stage">
-          <div class="map-stage-top">
-            <div class="summary-strip">
-              <div v-for="item in operationSummary" :key="item.label" class="summary-item">
-                <span class="summary-label">{{ item.label }}</span>
-                <strong class="summary-value">{{ item.value }}</strong>
-              </div>
+      <div class="workbench-grid">
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><Bell /></el-icon><span>实时报警</span>
+              <el-button link type="primary" class="panel-link" @click="goPage('/monitorAlarm/index')">前往报警管理</el-button>
             </div>
-            <div class="map-kpis">
-              <div v-for="item in mapKpis" :key="item.label" class="map-kpi">
-                <span>{{ item.label }}</span>
-                <strong>{{ item.value }}</strong>
+          </template>
+          <div class="compact-list">
+            <button v-for="item in alarmRows" :key="item.id || item.deviceCode" type="button"
+                    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>
+            </button>
+            <el-empty v-if="!alarmRows.length" description="当前无实时报警" :image-size="60" />
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel map-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><MapLocation /></el-icon><span>全域态势</span>
+              <div class="map-legend">
+                <i class="legend-dot" :style="{ background: gisPointStatusColors.alarm }" />报警/异常
+                <i class="legend-dot" :style="{ background: gisPointStatusColors.workOrder }" />工单/检修中
+                <i class="legend-dot" :style="{ background: gisPointStatusColors.warning }" />预警/欠费
+                <i class="legend-dot" :style="{ background: gisPointStatusColors.offline }" />离线
+                <i class="legend-dot" :style="{ background: gisPointStatusColors.normal }" />正常
+              </div>
+              <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">
+            <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>
-
-          <div ref="mapWrapRef" class="map-view"></div>
-          <div v-if="mapError" class="map-error">{{ mapError }}</div>
-
-          <div class="map-panel info-panel">
-            <div class="mini-title">供水设施信息</div>
-            <div class="mini-row"><span>供水管网</span><strong>{{ typeCount('pipe') }}条</strong></div>
-            <div class="mini-row"><span>水厂</span><strong>{{ typeCount('plant') }}座</strong></div>
-            <div class="mini-row"><span>泵站</span><strong>{{ typeCount('pumpStation') }}座</strong></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)">
+                    {{ overviewDetail.statusText || 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>{{ overviewDetail.statusText || 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('/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('/device/maintain', { keyword: item.orderNo })">
+              <span class="status-dot" :class="dotByOrderType(item.orderStatusType)" />
+              <span class="compact-main">
+                <b>{{ item.orderNo }}</b>
+                <small>{{ item.deviceName || item.deviceCode }}</small>
+              </span>
+              <span class="compact-meta">
+                <el-tag size="small" :type="item.orderStatusType">{{ item.orderStatusText }}</el-tag>
+              </span>
+            </button>
+            <el-empty v-if="!workOrderRows.length" description="当前暂无供水工单" :image-size="60" />
           </div>
-
-          <div class="map-panel point-panel">
-            <div class="mini-title">当前监测信息</div>
-            <div class="mini-row"><span>监测点总数</span><strong>{{ pointRows.length }}个</strong></div>
-            <div class="mini-row"><span>在线设备</span><strong>{{ dashboardData.equipmentStats?.online || 0 }}台</strong></div>
-            <div class="mini-row"><span>实时报警</span><strong>{{ dashboardData.alarmStats?.realtimeAlarmCount ?? dashboardData.alarmStats?.realtimeCount ?? 0 }}条</strong></div>
+          <div class="order-brief">
+            <span>待处理 <b>{{ workOrderStat.pendingCount }}</b></span>
+            <span>处理中 <b>{{ workOrderStat.processingCount }}</b></span>
+            <span>待验收 <b>{{ workOrderStat.verifyingCount }}</b></span>
+            <span>已完成 <b>{{ workOrderStat.finishedCount }}</b></span>
           </div>
-        </div>
+        </el-card>
       </div>
-
-      <div class="side-panel right-panel">
-        <section class="panel-card">
-          <div class="panel-title">设备运行</div>
-          <div class="table-wrap equipment-wrap">
-            <table class="data-table compact-table">
-              <thead>
-                <tr>
-                  <th>序号</th>
-                  <th>设备名称</th>
-                  <th>设备编号</th>
-                  <th>位置</th>
-                  <th>状态</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr v-for="row in equipmentRows" :key="row.id">
-                  <td>{{ row.id }}</td>
-                  <td>{{ row.name }}</td>
-                  <td>{{ row.code }}</td>
-                  <td>{{ row.location }}</td>
-                  <td>{{ row.status }}</td>
-                </tr>
-              </tbody>
-            </table>
+    </section>
+
+    <!-- B 异常待办 -->
+    <section class="home-section">
+      <div class="workbench-grid workbench-grid--equal">
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title">
+              <el-icon><Warning /></el-icon><span>异常待办</span>
+              <el-button link type="primary" class="panel-link" @click="goPage('/device/abnormal')">查看全部</el-button>
+            </div>
+          </template>
+          <div class="compact-list">
+            <button v-for="item in abnormalTodoRows" :key="item.equipmentId" type="button"
+                    class="compact-item" :class="{ active: selectedDevice && selectedDevice.equipmentId === item.equipmentId }"
+                    @click="selectAbnormal(item)">
+              <span class="status-dot" :class="abnormalDot(item)" />
+              <span class="compact-main">
+                <b>{{ item.equipmentName || item.equipmentCode }}</b>
+                <small>{{ item.equipmentCode }}</small>
+              </span>
+              <span class="compact-meta">
+                <el-tag size="small" :type="abnormalType(item)">{{ item.abnormalReasonText || '设备异常' }}</el-tag>
+              </span>
+            </button>
+            <el-empty v-if="!abnormalTodoRows.length" description="当前无异常设备待办" :image-size="60" />
           </div>
-        </section>
-
-        <section class="panel-card warning-card">
-          <div class="panel-title">告警动态</div>
-          <div class="warning-scroll">
-            <div class="warning-list">
-              <div v-for="(item, index) in warningRows" :key="index" class="warning-item" :class="item.level === '高' ? 'danger' : 'notice'">
-                <div class="warning-side">
-                  <div class="warning-badge">{{ item.level }}</div>
-                  <div class="warning-tag">{{ item.tag }}</div>
-                </div>
-                <div class="warning-main">
-                  <div class="warning-title-row">
-                    <span class="warning-title">{{ item.title }}</span>
-                    <span class="warning-time">{{ item.time }}</span>
-                  </div>
-                  <div class="warning-code">{{ item.code }}</div>
-                  <div class="warning-desc">{{ item.desc }}</div>
-                </div>
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel detail-panel">
+          <template #header>
+            <div class="card-title"><el-icon><Cpu /></el-icon><span>设备详情</span></div>
+          </template>
+          <div v-if="selectedDevice" class="device-summary">
+            <div class="summary-top">
+              <div class="summary-name">{{ selectedDevice.name }}</div>
+              <div class="summary-tags">
+                <el-tag size="small" :type="Number(selectedDevice.alarmStatus) === 1 ? 'danger' : 'success'">{{ selectedDevice.alarmStatusText }}</el-tag>
+                <el-tag size="small" :type="Number(selectedDevice.onlineStatus) === 1 ? 'success' : 'info'">{{ selectedDevice.onlineStatusText }}</el-tag>
+                <el-tag v-if="selectedDevice.relatedOrderNo && selectedDevice.relatedOrderNo !== '-'" size="small"
+                        :type="selectedDevice.workOrderStatusCode != null ? orderType(selectedDevice.workOrderStatusCode) : 'info'">
+                  {{ selectedDevice.workOrderStatusText }}
+                </el-tag>
               </div>
             </div>
+            <el-descriptions :column="1" border size="small">
+              <el-descriptions-item label="设备编码">{{ selectedDevice.code }}</el-descriptions-item>
+              <el-descriptions-item label="设备类型">{{ selectedDevice.typeName }}</el-descriptions-item>
+              <el-descriptions-item label="所属区域">{{ selectedDevice.district || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
+              <el-descriptions-item label="报警等级">{{ selectedDevice.alarmLevelText }}</el-descriptions-item>
+              <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('/device/maintain', { keyword: selectedDevice.relatedOrderNo })">
+                  {{ selectedDevice.relatedOrderNo }}
+                </el-button>
+                <span v-else>-</span>
+              </el-descriptions-item>
+            </el-descriptions>
+          </div>
+          <el-empty v-else description="从左侧异常待办中选择设备" :image-size="70" />
+        </el-card>
+
+        <el-card shadow="never" class="water-panel home-panel">
+          <template #header>
+            <div class="card-title"><el-icon><Grid /></el-icon><span>快捷入口</span></div>
+          </template>
+          <div class="quick-grid">
+            <button v-for="entry in quickEntries" :key="entry.key" type="button" class="quick-entry"
+                    @click="goPage(entry.suffix)">
+              <el-icon><component :is="entryIcon(entry.key)"/></el-icon>
+              <span>{{ entry.label }}</span>
+            </button>
           </div>
-        </section>
-
-        <section class="panel-card hazard-card">
-          <div class="panel-title">报警统计</div>
-          <div class="hazard-summary three-col">
-            <div v-for="item in hazardSummary" :key="item.label" class="hazard-summary-item">
-              <span class="hazard-dot" :style="{ background: item.color }"></span>
-              <span>{{ item.label }}</span>
-              <strong :style="{ color: item.color }">{{ item.value }}项</strong>
+        </el-card>
+      </div>
+    </section>
+
+    <!-- C 数据总览 -->
+    <section class="home-section">
+      <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>
+          <div class="metric-body">
+            <span>{{ card.label }}</span>
+            <div class="metric-value">
+              <strong :class="card.tone">{{ card.value }}</strong><small>{{ card.unit }}</small>
             </div>
           </div>
-          <div ref="hazardChartRef" class="chart-box hazard-chart"></div>
-        </section>
+        </div>
       </div>
-    </div>
-
-    <a href="https://deerflow.tech" target="_blank" class="deerflow-mark" title="Created By Deerflow">
-      <span class="deerflow-diamond">◆</span>
-      <span class="deerflow-text">Deerflow</span>
-    </a>
+      <div class="chart-grid">
+        <el-card shadow="never" class="water-panel chart-card trend-card">
+          <template #header>
+            <div class="card-title">
+              <el-icon><TrendCharts /></el-icon><span>报警趋势</span>
+              <span class="muted">近 {{ trendDays }} 天 · 按四个报警等级分别统计</span>
+            </div>
+          </template>
+          <div ref="trendChartRef" class="chart" />
+        </el-card>
+        <el-card shadow="never" class="water-panel chart-card">
+          <template #header>
+            <div class="card-title"><el-icon><PieChart /></el-icon><span>报警分类占比</span></div>
+          </template>
+          <div ref="warningPieRef" class="chart" />
+        </el-card>
+        <el-card shadow="never" class="water-panel chart-card">
+          <template #header>
+            <div class="card-title"><el-icon><DataAnalysis /></el-icon><span>设备分类占比</span></div>
+          </template>
+          <div ref="devicePieRef" class="chart" />
+        </el-card>
+      </div>
+    </section>
   </div>
 </template>
 
-<style scoped lang="scss">
-.gas-home-screen {
-  min-height: 100vh;
-  color: #fff;
-  background:
-    linear-gradient(180deg, rgba(14, 42, 70, 0.96), rgba(8, 20, 37, 1)),
-    radial-gradient(circle at center, rgba(36, 122, 184, 0.18), transparent 58%),
-    #081425;
-  position: relative;
+<script setup>
+import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import * as echarts from 'echarts'
+import {
+  Bell, BellFilled, Connection, Cpu, DataAnalysis, Finished, Grid, HomeFilled,
+  MapLocation, OfficeBuilding, PieChart, Refresh, Tickets, TrendCharts,
+  Warning, WarningFilled
+} from '@element-plus/icons-vue'
+import { getAlarmLedgerDashboard, getAlarmStats, getDeviceGisAllPoints,
+  getWaterFacilityFeatures, getWaterFacilityOverview, listAlarmMapPoints,
+  listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
+import { getEquipmentStatusStatistics, listEquipment } from '@/api/pipeNetwork/pdeviceInform'
+import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
+import { normalizeGeoJson, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+import { normalizeDeviceGisPoints } from '@/utils/waterSupplyUiModel'
+import { gisPointStatusColors, gisPointStatusLabels, gisPointStatusTagTypes } from '@/utils/gisMapColors'
+import { buildFacilityMapModel } from '@/utils/waterFacilityGisModel'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import {
+  HOME_QUICK_ENTRIES, WATER_DEVICE_ROOT_TYPE_ID, buildHomeGisOverview, buildHomeWorkOrderQuery,
+  normalizeAbnormalTodoRows, normalizeAlarmOverviewCards, normalizeAlarmTrend, normalizeDeviceSummary,
+  normalizeDeviceStatistics, normalizeHomeKpis, normalizePieRatio, normalizeRealtimeAlarmRows, realtimeAlarmJumpTarget,
+  normalizeWorkOrderRows, normalizeWorkOrderStatusStat
+} from '@/utils/waterHomeModel'
+
+const router = useRouter()
+
+const loading = ref(true)
+const loadError = ref('')
+const trendDays = 7
+const quickEntries = HOME_QUICK_ENTRIES
+
+const facilityRows = ref([])
+const ledgerStats = ref({})
+const alarmStats = ref({})
+const alarmDashboard = ref({})
+const alarmRows = ref([])
+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([])
+const overviewCards = ref([])
+const trendData = ref({ dates: [], levels: [] })
+const warningRatio = ref([])
+const deviceRatio = ref([])
+
+const trendChartRef = ref(null)
+const warningPieRef = ref(null)
+const devicePieRef = ref(null)
+let trendChart = null
+let warningPie = null
+let devicePie = null
+
+const KPI_ICON = {
+  facility: OfficeBuilding,
+  device: Cpu,
+  online: Connection,
+  alarm: BellFilled
+}
+
+const OVERVIEW_ICON = {
+  alarmTotal: BellFilled,
+  alarmingDevice: WarningFilled,
+  verifyingOrder: Tickets,
+  onlineRate: TrendCharts
+}
+
+const ENTRY_ICON = {
+  abnormal: WarningFilled,
+  alarm: BellFilled,
+  audit: Finished,
+  map: MapLocation
+}
+
+function kpiIcon(key) { return KPI_ICON[key] || OfficeBuilding }
+function overviewIcon(key) { return OVERVIEW_ICON[key] || DataAnalysis }
+function entryIcon(key) { return ENTRY_ICON[key] || Grid }
+function levelType(level) {
+  const meta = { 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }
+  return meta[Number(level)] || 'info'
+}
+function orderType(status) {
+  const meta = {
+    0: 'danger', 1: 'warning', 2: 'primary', 3: 'warning',
+    4: 'primary', 5: 'success', 6: 'warning', 7: 'info'
+  }
+  return meta[Number(status)] || 'info'
 }
-
-.gas-home-screen::before {
-  content: '';
-  position: absolute;
-  inset: 0;
-  pointer-events: none;
-  z-index: 0;
-  background:
-    radial-gradient(circle at 28% 32%, rgba(56, 216, 255, 0.06) 0%, transparent 45%),
-    radial-gradient(circle at 72% 58%, rgba(22, 109, 255, 0.05) 0%, transparent 40%),
-    radial-gradient(circle at 50% 85%, rgba(56, 216, 255, 0.04) 0%, transparent 35%);
-  animation: bgPulse 8s ease-in-out infinite alternate;
+function dotByOrderType(type) {
+  const map = { danger: 'dot-danger', warning: 'dot-warning', primary: 'dot-primary', success: 'dot-success', info: 'dot-info' }
+  return map[type] || 'dot-info'
 }
-
-@keyframes bgPulse {
-  0% { opacity: 0.6; }
-  100% { opacity: 1; }
+function abnormalDot(row) {
+  if (Number(row.alarmStatus) === 1) return 'dot-alarm'
+  if (Number(row.onlineStatus) === 0) return 'dot-offline'
+  return 'dot-warning'
 }
-
-.screen-header {
-  height: 72px;
-  padding: 0 24px;
-  display: grid;
-  grid-template-columns: 320px 1fr 320px;
-  align-items: center;
-  position: relative;
-  z-index: 1;
-  background: linear-gradient(90deg, rgba(27, 98, 155, 0.95), rgba(18, 53, 98, 0.95));
-  border-bottom: 1px solid rgba(93, 194, 255, 0.28);
+function abnormalType(row) {
+  if (Number(row.alarmStatus) === 1) return 'danger'
+  if (Number(row.onlineStatus) === 0) return 'info'
+  return 'warning'
 }
 
-.screen-header::after {
-  content: '';
-  position: absolute;
-  bottom: -1px;
-  left: 5%;
-  width: 90%;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.7), rgba(22, 109, 255, 0.7), transparent);
-  animation: headerGlow 4s ease-in-out infinite alternate;
+async function settle(fn, fallback = null, onError = null) {
+  try {
+    return await fn()
+  } catch (error) {
+    console.warn('供水首页数据片段加载失败', error)
+    // 失败上报必须在这里完成:本函数已吞掉异常并返回兜底值,
+    // 调用侧再挂 .catch 永远不会触发,“部分数据加载失败”提示会变成死代码。
+    onError?.()
+    if (fallback !== null) return fallback
+    throw error
+  }
 }
 
-@keyframes headerGlow {
-  0% { opacity: 0.4; }
-  100% { opacity: 1; }
-}
+async function loadAll() {
+  loading.value = true
+  loadError.value = ''
+  const problems = []
+  const recordProblem = label => () => { problems.push(label) }
+
+  const [overview, ledger, alarm, dashboard, realtime, abnormal, orderStat, orders, deviceGis] = await Promise.allSettled([
+    settle(() => getWaterFacilityOverview(), [], recordProblem('设施概览')),
+    settle(() => getEquipmentStatusStatistics(WATER_DEVICE_ROOT_TYPE_ID), {}, recordProblem('设备统计')),
+    settle(() => getAlarmStats(), {}, recordProblem('报警统计')),
+    settle(() => getAlarmLedgerDashboard(trendDays), {}, recordProblem('报警态势')),
+    settle(() => listRealtimeAlarms(), [], recordProblem('实时报警')),
+    // 异常待办 = 当前处于报警中的供水设备;设备状态由 equipment_status 维护
+    settle(() => listEquipment({ pageNum: 1, pageSize: 50, rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, alarmStatus: 1 }), { records: [], total: 0 }, recordProblem('异常待办')),
+    settle(() => workOrderStatusCount(buildHomeWorkOrderQuery()), { total: 0, counts: [] }, recordProblem('工单统计')),
+    settle(() => listWorkOrder(buildHomeWorkOrderQuery({ pageSize: 20 })), { records: [], total: 0 }, recordProblem('工单列表')),
+    settle(() => getDeviceGisAllPoints(), { features: [] }, recordProblem('设备点位'))
+  ])
+
+  const overviewData = unwrapResponse(overview.value || [])
+  facilityRows.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
+  ledgerStats.value = normalizeDeviceStatistics(unwrapResponse(ledger.value || {}))
+  alarmStats.value = unwrapResponse(alarm.value || {})
+  alarmDashboard.value = unwrapResponse(dashboard.value || {})
+  alarmRows.value = normalizeRealtimeAlarmRows(unwrapResponse(realtime.value || []), 20)
+  const abnormalPage = normalizePage(abnormal.value || { records: [], total: 0 })
+  abnormalTodoRows.value = normalizeAbnormalTodoRows(abnormalPage.records, 20)
+  workOrderStat.value = normalizeWorkOrderStatusStat(unwrapResponse(orderStat.value || { total: 0, counts: [] }))
+  const orderList = unwrapResponse(orders.value || { records: [] })
+  workOrderRows.value = normalizeWorkOrderRows(orderList?.records || [], 20)
+
+  const gisData = unwrapResponse(deviceGis.value || {})
+  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({
+    dashboard: alarmDashboard.value,
+    deviceStats: ledgerStats.value,
+    workOrderStat: workOrderStat.value
+  })
+  trendData.value = normalizeAlarmTrend(alarmDashboard.value?.trend || [])
+  warningRatio.value = normalizePieRatio(alarmDashboard.value?.warningTypeRatio || [], '未分类', 6)
+  deviceRatio.value = normalizePieRatio(alarmDashboard.value?.deviceTypeRatio || [], '未分类', 6)
 
-.header-time {
-  font-size: 18px;
-  font-weight: 700;
-  letter-spacing: 1.5px;
-  color: #ddf6ff;
-  font-variant-numeric: tabular-nums;
-  text-shadow: 0 0 18px rgba(56, 216, 255, 0.35);
+  if (problems.length) loadError.value = problems.join(';')
+  loading.value = false
+  await nextTick()
+  renderCharts()
 }
 
-.header-title-wrap {
-  text-align: center;
+function selectAbnormal(row) {
+  selectedDevice.value = normalizeDeviceSummary(row)
 }
 
-.header-title {
-  font-size: 30px;
-  font-weight: 800;
-  letter-spacing: 3px;
-  background: linear-gradient(180deg, #e8f6ff 0%, #8fd6ff 100%);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+async function loadOverviewMap(recordProblem) {
+  overviewMapError.value = ''
+  const [facilityRes, alarmRes] = await Promise.all([
+    settle(() => getWaterFacilityFeatures(), { features: [] }, recordProblem('设施GIS点位')),
+    settle(() => listAlarmMapPoints(), [], 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()
 }
 
-.header-subtitle {
-  margin-top: 4px;
-  color: rgba(196, 234, 255, 0.65);
-  font-size: 11px;
-  letter-spacing: 3px;
-  text-transform: uppercase;
+function handleOverviewMapReady() {
+  nextTick(() => overviewMapRef.value?.fitBounds())
 }
 
-.header-tags {
-  display: flex;
-  justify-content: flex-end;
-  gap: 8px;
+function openOverviewPoint(point) {
+  overviewDetail.value = point || null
+  overviewDetailOpen.value = Boolean(point)
 }
 
-.header-tag {
-  padding: 6px 14px;
-  border: 1px solid rgba(117, 206, 255, 0.35);
-  border-radius: 16px;
-  color: #d8f5ff;
-  background: rgba(13, 77, 128, 0.45);
-  font-size: 12px;
-  letter-spacing: 1px;
-  transition: all 0.3s;
-  cursor: default;
+function openRealtimeAlarm(item) {
+  const target = realtimeAlarmJumpTarget(item)
+  goPage(target.path, target.query)
 }
 
-.header-tag:hover {
-  border-color: rgba(117, 206, 255, 0.7);
-  background: rgba(13, 77, 128, 0.65);
-  box-shadow: 0 0 16px rgba(56, 216, 255, 0.15);
+function resolveRoutePath(suffix) {
+  const matched = router.getRoutes().find(item => item.path.endsWith(suffix))
+  return matched ? matched.path : ''
 }
 
-.screen-body {
-  height: calc(100vh - 72px);
-  padding: 12px;
-  display: grid;
-  grid-template-columns: 360px 1fr 360px;
-  gap: 12px;
-  position: relative;
-  z-index: 1;
+function goPage(suffix, query = {}) {
+  const path = resolveRoutePath(suffix)
+  if (!path) {
+    console.warn(`未找到供水入口路由: ${suffix}`)
+    return
+  }
+  router.push({ path, query }).catch(() => {})
 }
 
-.side-panel,
-.center-panel {
-  min-height: 0;
+function gisStatusLabel(statusKey) {
+  return gisPointStatusLabels[statusKey] || '正常'
 }
 
-.left-panel,
-.right-panel {
-  display: grid;
-  gap: 12px;
+function gisStatusTagType(statusKey) {
+  return gisPointStatusTagTypes[statusKey] || 'success'
 }
 
-.left-panel {
-  grid-template-rows: 220px 220px 1fr;
+function overviewKindLabel(kind) {
+  return { device: '监测设备', alarm: '报警', facility: '供水设施' }[kind] || '点位'
 }
 
-.right-panel {
-  grid-template-rows: 250px 260px 1fr;
+function renderCharts() {
+  renderTrendChart()
+  warningPie?.dispose()
+  warningPie = renderPieChart(warningPieRef.value, warningRatio.value, '报警数')
+  devicePie?.dispose()
+  devicePie = renderPieChart(devicePieRef.value, deviceRatio.value, '设备数')
 }
 
-.panel-card {
-  min-height: 0;
-  padding: 14px;
-  position: relative;
-  background: linear-gradient(180deg, rgba(13, 33, 56, 0.88), rgba(10, 25, 42, 0.88));
-  border: 1px solid rgba(65, 158, 219, 0.22);
-  border-radius: 12px;
-  box-shadow: 0 2px 24px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(255, 255, 255, 0.025);
-  transition: border-color 0.4s;
+function renderTrendChart() {
+  if (!trendChartRef.value) return
+  trendChart?.dispose()
+  trendChart = echarts.init(trendChartRef.value)
+  const { dates, levels } = trendData.value
+  const palette = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a']
+  trendChart.setOption({
+    tooltip: { trigger: 'axis' },
+    legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
+    grid: { left: 34, right: 14, top: 18, bottom: 48, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: dates,
+      axisLine: { lineStyle: { color: '#cbd5e1' } },
+      axisLabel: { color: '#64748b' }
+    },
+    yAxis: {
+      type: 'value',
+      minInterval: 1,
+      splitLine: { lineStyle: { color: '#eef2f7' } },
+      axisLabel: { color: '#64748b' }
+    },
+    color: palette,
+    series: levels.map((level, index) => ({
+      name: level.text || `级${index + 1}`,
+      type: 'bar',
+      stack: 'alarm',
+      barWidth: 26,
+      data: level.data,
+      itemStyle: { borderRadius: index === levels.length - 1 ? [5, 5, 0, 0] : 0 }
+    }))
+  })
 }
 
-.panel-card::after {
-  content: '';
-  position: absolute;
-  top: -1px;
-  left: 20px;
-  right: 20px;
-  height: 1px;
-  border-radius: 1px;
-  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.25), transparent);
-  pointer-events: none;
+function renderPieChart(container, data, unit) {
+  if (!container) return null
+  const total = data.reduce((sum, item) => sum + item.count, 0)
+  const chart = echarts.init(container)
+  chart.setOption({
+    tooltip: { trigger: 'item', formatter: `{b}:{c} ${unit}({d}%)` },
+    legend: { bottom: 0, icon: 'circle', itemWidth: 9, itemHeight: 9, textStyle: { color: '#475569' } },
+    title: {
+      text: String(total),
+      subtext: unit,
+      left: 'center',
+      top: '32%',
+      textStyle: { color: '#1e293b', fontSize: 24, fontWeight: 700 },
+      subtextStyle: { color: '#64748b', fontSize: 12 }
+    },
+    color: ['#2563eb', '#14b8a6', '#f59e0b', '#ef4444', '#8b5cf6', '#64748b'],
+    series: [{
+      type: 'pie',
+      radius: ['48%', '68%'],
+      center: ['50%', '43%'],
+      data: data.map(item => ({ name: item.name, value: item.count })),
+      label: { formatter: '{b}\n{d}%', color: '#475569', lineHeight: 17 },
+      itemStyle: { borderColor: '#fff', borderWidth: 3, borderRadius: 8 },
+      emphasis: { scaleSize: 7 }
+    }]
+  })
+  return chart
 }
 
-.panel-card:hover {
-  border-color: rgba(65, 158, 219, 0.38);
+function handleResize() {
+  trendChart?.resize()
+  warningPie?.resize()
+  devicePie?.resize()
 }
 
-.panel-title {
-  position: relative;
-  padding-left: 16px;
-  margin-bottom: 14px;
-  color: #8fe5ff;
-  font-size: 18px;
-  font-weight: 800;
-  letter-spacing: 1px;
+onMounted(async () => {
+  await loadAll()
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  trendChart?.dispose()
+  warningPie?.dispose()
+  devicePie?.dispose()
+})
+</script>
+
+<style lang="scss">
+@use './waterSupplyPageTheme.scss';
+</style>
+
+<style scoped>
+.home-page {
+  background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
 }
 
-.panel-title::before {
-  content: '';
-  position: absolute;
-  left: 0;
-  top: 3px;
-  width: 4px;
-  height: 20px;
-  border-radius: 2px;
-  background: linear-gradient(180deg, #28d7ff, #1776ff);
-  box-shadow: 0 0 10px rgba(40, 215, 255, 0.5);
+.heading-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
 }
 
-.base-grid {
+.page-title-icon {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 14px;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  color: #fff;
+  background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
+  border-radius: 8px;
+  box-shadow: 0 8px 16px rgba(37, 99, 235, 0.2);
 }
 
-.base-card {
-  background: linear-gradient(180deg, rgba(10, 30, 53, 0.95), rgba(9, 24, 42, 0.95));
+.load-error {
+  margin-bottom: 14px;
 }
 
-.base-grid {
-  grid-template-columns: repeat(2, minmax(0, 1fr));
+.home-section {
+  margin-bottom: 20px;
+}
+
+.metric-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
   gap: 12px;
+  margin-bottom: 14px;
 }
 
-.base-item {
+.metric {
   display: flex;
   align-items: center;
   gap: 14px;
-  min-height: 72px;
-  padding: 14px;
-  border-radius: 14px;
-  background: linear-gradient(180deg, rgba(20, 57, 89, 0.45), rgba(18, 44, 72, 0.35));
-  border: 1px solid rgba(97, 190, 255, 0.12);
-  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  cursor: default;
-  position: relative;
-  overflow: hidden;
+  min-width: 0;
+  padding: 14px 16px;
+  background: #fff;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04);
 }
 
-.base-item::before {
-  content: '';
-  position: absolute;
-  inset: 0;
-  border-radius: 14px;
-  background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.03), transparent 70%);
-  pointer-events: none;
-  transition: opacity 0.35s;
-  opacity: 0;
+.metric-icon {
+  position: static;
+  flex: 0 0 42px;
+  display: grid;
+  place-items: center;
+  width: 42px;
+  height: 42px;
+  color: #2563eb;
+  background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
+  border-radius: 8px;
+  font-size: 22px;
 }
 
-.base-item:hover {
-  transform: translateY(-2px);
-  border-color: rgba(97, 190, 255, 0.35);
-  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(97, 190, 255, 0.1) inset;
+.metric-body {
+  min-width: 0;
 }
 
-.base-item:hover::before {
-  opacity: 1;
+.metric-body span {
+  display: block;
+  overflow: hidden;
+  margin-bottom: 3px;
+  color: #64748b;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.base-icon {
-  width: 48px;
-  height: 48px;
-  border-radius: 16px;
+.metric-value {
   display: flex;
-  align-items: center;
-  justify-content: center;
-  flex: 0 0 48px;
-  position: relative;
-  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.35s;
-}
-
-.base-item:hover .base-icon {
-  transform: scale(1.08);
-  box-shadow: 0 0 28px rgba(56, 216, 255, 0.3);
+  align-items: baseline;
+  gap: 5px;
 }
 
-.base-icon-svg {
-  width: 26px;
-  height: 26px;
-  color: #fff;
-  transition: transform 0.35s;
+.metric-value strong {
+  color: #1e293b;
+  font-size: 24px;
+  line-height: 1.2;
 }
 
-.base-item:hover .base-icon-svg {
-  transform: scale(1.1);
-}
+.metric-value strong.danger { color: #dc2626; }
+.metric-value strong.warning { color: #d97706; }
 
-.base-text {
-  min-width: 0;
+.metric-value small {
+  color: #94a3b8;
 }
 
-.base-label {
-  color: #c0dff5;
-  font-size: 12px;
-  letter-spacing: 1px;
-  text-transform: uppercase;
+.workbench-grid {
+  display: grid;
+  grid-template-columns: minmax(240px, 1fr) minmax(0, 3fr) minmax(240px, 1fr);
+  gap: 12px;
+  align-items: stretch;
 }
 
-.base-value {
-  margin-top: 4px;
-  color: #fff;
-  font-size: 32px;
-  font-weight: 800;
-  line-height: 1;
-  font-variant-numeric: tabular-nums;
-  letter-spacing: -0.5px;
+.workbench-grid--equal {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
 }
 
-.base-value span {
-  margin-left: 4px;
-  color: #90d9ff;
-  font-size: 12px;
-  font-weight: 500;
-  letter-spacing: 0;
+.home-panel {
+  min-width: 0;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
 }
 
-.chart-box {
-  width: 100%;
-  height: calc(100% - 36px);
+.map-panel {
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 
-.table-card,
-.warning-card,
-.hazard-card {
-  overflow: hidden;
+.map-panel :deep(.el-card__body) {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-height: 0;
 }
 
-.table-wrap {
-  height: calc(100% - 38px);
-  overflow: auto;
+.home-panel :deep(.el-card__header) {
+  padding: 12px 16px;
+  border-bottom: 1px solid #e1ebf3;
+  background: #fbfdff;
 }
 
-.data-table {
-  width: 100%;
-  border-collapse: collapse;
-  table-layout: fixed;
+.home-panel :deep(.el-card__body) {
+  padding: 12px 14px;
 }
 
-.data-table thead {
-  background: rgba(24, 122, 198, 0.55);
+.card-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #1e293b;
+  font-weight: 600;
 }
 
-.data-table thead th {
-  font-weight: 700;
-  letter-spacing: 0.5px;
-  color: #b9e4ff;
+.card-title :deep(.el-icon) {
+  color: #2563eb;
+  font-size: 18px;
 }
 
-.data-table th,
-.data-table td {
-  padding: 10px 6px;
-  color: #f4fbff;
-  text-align: center;
-  font-size: 13px;
-  border-bottom: 1px solid rgba(130, 194, 231, 0.1);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
+.card-title .muted {
+  margin-left: auto;
+  font-size: 12px;
+  font-weight: 400;
 }
 
-.data-table tbody tr {
-  transition: background 0.25s;
+.panel-link {
+  margin-left: auto;
 }
 
-.data-table tbody tr:nth-child(odd) {
-  background: rgba(133, 196, 242, 0.10);
+.panel-links {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  margin-left: auto;
 }
 
-.data-table tbody tr:nth-child(even) {
-  background: rgba(133, 196, 242, 0.04);
+.panel-links .panel-link {
+  margin-left: 0;
 }
 
-.data-table tbody tr:hover {
-  background: rgba(56, 216, 255, 0.12);
+.legend-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
 }
 
-.compact-table th,
-.compact-table td {
-  padding: 9px 4px;
+.compact-list {
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  min-height: 220px;
+  max-height: 360px;
+  overflow-y: auto;
+  padding-right: 2px;
 }
 
-.status-cell.ok {
-  color: #6effc4;
-  position: relative;
+.compact-item {
+  display: grid;
+  grid-template-columns: 10px minmax(0, 1fr) auto;
+  gap: 9px;
+  align-items: center;
+  width: 100%;
+  padding: 9px 10px;
+  text-align: left;
+  background: #fff;
+  border: 1px solid #eef2f7;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
 }
 
-.status-cell.warn {
-  color: #ffe16a;
+.compact-item:hover {
+  background: #f6fbff;
+  border-color: #bfdbfe;
 }
 
-.status-cell.off {
-  color: #ff8e8e;
+.compact-item.active {
+  background: #eef6ff;
+  border-color: #93c5fd;
 }
 
-.status-cell::before {
-  content: '';
-  display: inline-block;
-  width: 6px;
-  height: 6px;
+.status-dot {
+  width: 9px;
+  height: 9px;
   border-radius: 50%;
-  margin-right: 6px;
-  vertical-align: middle;
+  background: #94a3b8;
 }
 
-.status-cell.ok::before {
-  background: #6effc4;
-  box-shadow: 0 0 6px rgba(110, 255, 196, 0.6);
-}
+.dot-alarm { background: #dc2626; }
+.dot-offline { background: #94a3b8; }
+.dot-normal { background: #16a34a; }
+.dot-warning { background: #d97706; }
+.dot-danger { background: #dc2626; }
+.dot-primary { background: #2563eb; }
+.dot-success { background: #16a34a; }
+.dot-info { background: #94a3b8; }
+.dot-workorder { background: #2563eb; }
 
-.status-cell.warn::before {
-  background: #ffe16a;
-  box-shadow: 0 0 6px rgba(255, 225, 106, 0.6);
-}
-
-.status-cell.off::before {
-  background: #ff8e8e;
-  box-shadow: 0 0 6px rgba(255, 142, 142, 0.6);
-}
-
-.center-panel {
-  position: relative;
+.compact-main {
+  display: block;
+  min-width: 0;
 }
 
-.map-stage {
-  position: relative;
-  height: 100%;
+.compact-main b,
+.compact-main small {
+  display: block;
   overflow: hidden;
-  border-radius: 12px;
-  border: 1px solid rgba(56, 159, 223, 0.2);
-  background: rgba(8, 20, 37, 0.64);
-  box-shadow: 0 0 40px rgba(0, 0, 0, 0.3), inset 0 0 60px rgba(8, 30, 60, 0.3);
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.map-stage-top {
-  position: absolute;
-  top: 14px;
-  left: 14px;
-  right: 14px;
-  z-index: 3;
-  display: flex;
-  justify-content: space-between;
-  gap: 12px;
-  pointer-events: none;
+.compact-main b {
+  color: #1e293b;
+  font-size: 13px;
+  font-weight: 600;
 }
 
-.summary-strip,
-.map-kpis {
-  display: flex;
-  gap: 10px;
+.compact-main small {
+  margin-top: 2px;
+  color: #94a3b8;
+  font-size: 12px;
 }
 
-.summary-item,
-.map-kpi {
-  min-width: 110px;
-  padding: 10px 14px;
-  border-radius: 10px;
-  background: rgba(8, 31, 54, 0.82);
-  border: 1px solid rgba(79, 177, 238, 0.18);
-  backdrop-filter: blur(8px);
-  transition: all 0.3s;
+.compact-meta {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 3px;
+  min-width: 0;
 }
 
-.summary-label,
-.map-kpi span {
-  display: block;
-  color: #91d7ff;
-  font-size: 12px;
-  letter-spacing: 0.5px;
+.compact-meta small {
+  color: #94a3b8;
+  font-size: 11px;
+  white-space: nowrap;
 }
 
-.summary-value,
-.map-kpi strong {
-  display: block;
-  margin-top: 4px;
-  color: #fff;
-  font-size: 22px;
-  font-weight: 800;
-  letter-spacing: 1px;
+.map-stage {
+  position: relative;
+  flex: 1;
+  min-height: 0;
+  height: auto;
+  overflow: hidden;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  background: #eef6ff;
 }
 
-.map-view {
+.map-stage :deep(.gis-map-canvas) {
   width: 100%;
   height: 100%;
+  min-height: 0;
 }
 
-.map-error {
-  position: absolute;
-  inset: 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffd3d3;
-  background: rgba(5, 10, 20, 0.72);
-  z-index: 4;
-}
-
-.map-panel {
+.map-inline-alert {
   position: absolute;
+  left: 16px;
+  bottom: 16px;
   z-index: 3;
-  width: 190px;
-  padding: 14px;
-  border-radius: 12px;
-  background: rgba(8, 31, 54, 0.82);
-  border: 1px solid rgba(79, 177, 238, 0.18);
-  backdrop-filter: blur(10px);
+  width: 340px;
 }
 
-.info-panel {
-  left: 18px;
-  bottom: 20px;
-}
-
-.point-panel {
-  right: 18px;
-  bottom: 20px;
-}
-
-.mini-title {
-  margin-bottom: 10px;
-  color: #8fe5ff;
-  font-size: 14px;
-  font-weight: 700;
-  letter-spacing: 0.5px;
+.map-inline-empty {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  z-index: 2;
+  transform: translate(-50%, -50%);
 }
 
-.mini-row {
+.map-legend {
   display: flex;
-  justify-content: space-between;
-  margin-top: 6px;
-  padding: 4px 0;
-  color: #d6efff;
-  font-size: 13px;
-  border-bottom: 1px solid rgba(130, 194, 231, 0.06);
-}
-
-.mini-row:last-child {
-  border-bottom: none;
-}
-
-.warning-scroll {
-  height: calc(100% - 38px);
-  overflow-y: auto;
-  padding-right: 4px;
-}
-
-.warning-scroll::-webkit-scrollbar {
-  width: 5px;
+  align-items: center;
+  gap: 7px;
+  margin-left: auto;
+  color: #64748b;
+  font-size: 12px;
 }
 
-.warning-scroll::-webkit-scrollbar-thumb {
-  border-radius: 999px;
-  background: rgba(96, 180, 235, 0.3);
+.overview-detail {
+  min-height: 240px;
 }
 
-.warning-scroll::-webkit-scrollbar-track {
-  background: transparent;
+.detail-hero h3,
+.detail-section h4 {
+  margin: 0 0 8px;
+  color: #1c4b78;
 }
 
-.warning-list {
-  display: grid;
-  gap: 10px;
+.detail-hero p {
+  margin: 0 0 10px;
+  color: #678197;
+  font-size: 13px;
 }
 
-.warning-item {
+.status-tags {
   display: flex;
-  gap: 12px;
-  align-items: stretch;
-  padding: 14px;
-  border-radius: 14px;
-  border: 1px solid rgba(255, 108, 108, 0.1);
-  background: linear-gradient(180deg, rgba(72, 24, 38, 0.42), rgba(49, 19, 31, 0.30));
-  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  position: relative;
-  overflow: hidden;
-}
-
-.warning-item::before {
-  content: '';
-  position: absolute;
-  left: 0;
-  top: 12px;
-  bottom: 12px;
-  width: 3px;
-  border-radius: 0 3px 3px 0;
-  transition: all 0.35s;
+  flex-wrap: wrap;
+  gap: 6px;
 }
 
-.warning-item.danger::before {
-  background: linear-gradient(180deg, #ff5858, #ff3030);
-  box-shadow: 0 0 12px rgba(255, 58, 58, 0.5);
-  animation: dangerPulse 2s ease-in-out infinite;
+.detail-section {
+  margin-top: 18px;
+  padding-top: 12px;
+  border-top: 1px solid #e5eef7;
 }
 
-.warning-item.notice::before {
-  background: linear-gradient(180deg, #ffc635, #ff9f1a);
-  box-shadow: 0 0 10px rgba(255, 198, 53, 0.35);
+.detail-section dl {
+  display: grid;
+  grid-template-columns: 92px minmax(0, 1fr);
+  gap: 7px 10px;
+  margin: 0;
+  font-size: 13px;
 }
 
-@keyframes dangerPulse {
-  0%, 100% { opacity: 0.6; }
-  50% { opacity: 1; }
+.detail-section dt {
+  color: #71889c;
 }
 
-.warning-item:hover {
-  transform: translateX(3px);
+.detail-section dd {
+  margin: 0;
+  color: #2b4c67;
+  overflow-wrap: anywhere;
 }
 
-.warning-item.danger:hover {
-  border-color: rgba(255, 108, 108, 0.25);
-  box-shadow: 0 4px 20px rgba(255, 58, 58, 0.1);
+.order-brief {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 6px;
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #eef2f7;
 }
 
-.warning-item.notice:hover {
-  border-color: rgba(255, 201, 92, 0.2);
-  box-shadow: 0 4px 20px rgba(255, 198, 53, 0.08);
+.order-brief span {
+  color: #64748b;
+  font-size: 12px;
 }
 
-.warning-item.notice {
-  border-color: rgba(255, 201, 92, 0.1);
-  background: linear-gradient(180deg, rgba(78, 63, 23, 0.28), rgba(48, 43, 20, 0.18));
+.order-brief b {
+  margin-left: 3px;
+  color: #17324d;
+  font-size: 14px;
 }
 
-.warning-side {
-  width: 52px;
-  flex: 0 0 52px;
+.device-summary {
   display: flex;
   flex-direction: column;
-  gap: 8px;
-  align-items: center;
+  gap: 12px;
 }
 
-.warning-badge {
-  width: 34px;
-  height: 34px;
-  border-radius: 10px;
+.detail-panel :deep(.el-card__body) {
   display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  font-weight: 800;
-  color: #fff;
-  transition: transform 0.3s;
-}
-
-.warning-item:hover .warning-badge {
-  transform: scale(1.1);
-}
-
-.warning-tag {
-  width: 100%;
-  padding: 3px 0;
-  border-radius: 999px;
-  text-align: center;
-  font-size: 10px;
-  letter-spacing: 0.5px;
-  color: #dcefff;
-  background: rgba(255, 255, 255, 0.06);
-}
-
-.warning-item.danger .warning-badge {
-  background: rgba(255, 58, 58, 0.28);
-  color: #ff8787;
-}
-
-.warning-item.danger .warning-title {
-  color: #ffd8d8;
-}
-
-.warning-item.notice .warning-badge {
-  background: rgba(255, 198, 53, 0.2);
-  color: #ffd45d;
-}
-
-.warning-item.notice .warning-title {
-  color: #fff2c4;
+  flex-direction: column;
 }
 
-.warning-main {
-  min-width: 0;
+.detail-panel .device-summary {
   flex: 1;
 }
 
-.warning-title-row {
+.summary-top {
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
-  gap: 8px;
+  gap: 10px;
+  flex-wrap: wrap;
 }
 
-.warning-title {
-  font-weight: 700;
+.summary-name {
+  color: #17324d;
   font-size: 16px;
-  line-height: 1.2;
+  font-weight: 700;
 }
 
-.warning-time,
-.warning-code,
-.warning-desc {
-  color: #dcefff;
-  font-size: 12px;
+.summary-tags {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 6px;
 }
 
-.warning-time {
-  color: #b6dfff;
+.detail-panel :deep(.el-descriptions__label) {
+  width: 86px;
+  color: #64748b;
+  background: #f8fafc;
   white-space: nowrap;
 }
 
-.warning-code {
-  margin: 6px 0 4px;
-  color: #9ad8ff;
+.quick-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px;
 }
 
-.warning-desc {
-  line-height: 1.5;
+.quick-entry {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+  padding: 14px 12px;
+  color: #1e293b;
+  background: #fff;
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.15s ease;
 }
 
-.hazard-summary {
-  display: flex;
-  gap: 12px;
-  margin-bottom: 10px;
+.quick-entry:hover {
+  background: #f6fbff;
+  border-color: #bfdbfe;
 }
 
-.hazard-summary.three-col {
-  justify-content: space-between;
+.quick-entry :deep(.el-icon) {
+  flex: 0 0 20px;
+  color: #2563eb;
+  font-size: 20px;
 }
 
-.hazard-summary-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
+.quick-entry span {
+  overflow: hidden;
   font-size: 13px;
-  padding: 8px 12px;
-  border-radius: 10px;
-  background: rgba(255, 255, 255, 0.03);
-  border: 1px solid rgba(255, 255, 255, 0.04);
-  transition: all 0.3s;
+  font-weight: 600;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.hazard-summary-item:hover {
-  background: rgba(255, 255, 255, 0.06);
-  border-color: rgba(255, 255, 255, 0.08);
+.chart-grid {
+  display: grid;
+  grid-template-columns: 1.5fr 1fr 1fr;
+  gap: 12px;
 }
 
-.hazard-dot {
-  width: 8px;
-  height: 24px;
-  display: inline-block;
-  border-radius: 4px;
-  box-shadow: 0 0 10px currentColor;
+.chart-card {
+  border: 1px solid #e1ebf3;
+  border-radius: 8px;
+  box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05);
 }
 
-.hazard-chart {
-  height: 160px;
+.chart-card .chart {
+  height: 260px;
 }
 
-.equipment-wrap {
-  height: calc(100% - 38px);
+.chart-card :deep(.el-card__header) {
+  padding: 12px 16px;
+  border-bottom: 1px solid #e1ebf3;
+  background: #fbfdff;
 }
 
-:deep(.BMap_cpyCtrl),
-:deep(.anchorBL) {
-  display: none !important;
+.chart-card :deep(.el-card__body) {
+  padding: 10px 12px;
 }
 
-.deerflow-mark {
-  position: fixed;
-  bottom: 14px;
-  right: 18px;
-  z-index: 10;
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  text-decoration: none;
-  opacity: 0.28;
-  transition: opacity 0.4s;
-}
+@media (max-width: 1200px) {
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
 
-.deerflow-mark:hover {
-  opacity: 0.6;
-}
+  .workbench-grid {
+    grid-template-columns: 1fr;
+  }
 
-.deerflow-diamond {
-  font-size: 10px;
-  color: #8fe5ff;
-}
+  .chart-grid {
+    grid-template-columns: 1fr 1fr;
+  }
 
-.deerflow-text {
-  font-size: 11px;
-  letter-spacing: 1px;
-  color: #8fe5ff;
-  font-weight: 500;
+  .trend-card {
+    grid-column: 1 / -1;
+  }
 }
 
-@media (max-width: 1500px) {
-  .screen-body {
-    grid-template-columns: 320px 1fr 320px;
+@media (max-width: 768px) {
+  .page-heading,
+  .heading-actions {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .metric-grid,
+  .chart-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .trend-card {
+    grid-column: auto;
+  }
+
+  .map-legend {
+    display: none;
   }
 
-  .header-title {
-    font-size: 24px;
+  .panel-links {
+    display: none;
   }
 
-  .summary-item,
-  .map-kpi {
-    min-width: 96px;
+  .quick-grid {
+    grid-template-columns: 1fr;
   }
 }
 </style>

+ 54 - 8
src/views/subSystem/waterSupply/facility/gis.vue

@@ -102,10 +102,11 @@
 
     <template #legend>
       <div class="panel-section legend-panel">
-          <h3>图例</h3>
-          <div class="legend-row" v-for="item in facilityTypes" :key="item.value">
+          <h3>设施状态图例</h3>
+          <div class="legend-row" v-for="item in facilityStatusLegend" :key="item.label">
             <i class="legend-dot" :style="{ background: item.color }"></i>{{ item.label }}
           </div>
+          <div class="legend-row"><i class="legend-dot" :style="{ background: colorMap.pipe }"></i>供水管网(仅管线)</div>
       </div>
     </template>
 
@@ -122,9 +123,17 @@
           />
           <div v-else class="device-result-list">
             <button v-for="feature in filteredFeatures" :key="feature.id || feature.name" type="button" @click="locateFeature(feature)">
-              <span class="device-name">{{ feature.name }}</span>
+              <span class="device-name">
+                <i class="status-dot" :style="{ background: featureColor(feature) }"></i>
+                {{ feature.name }}
+              </span>
               <span class="device-meta">{{ labelMap[feature.type] || '供水设施' }} · {{ feature.code || '-' }}</span>
-              <el-tag size="small" type="primary">{{ labelMap[feature.type] || '供水设施' }}</el-tag>
+              <!-- 管网只有管线、没有状态语义,这里回退展示设施类型 -->
+              <el-tag v-if="featureStatusMeta(feature)" size="small"
+                      :type="gisPointStatusTagTypes[featureStatusMeta(feature).key] || 'info'">
+                {{ featureStatusMeta(feature).text }}
+              </el-tag>
+              <el-tag v-else size="small" type="primary">{{ labelMap[feature.type] || '供水设施' }}</el-tag>
             </button>
           </div>
       </div>
@@ -167,6 +176,7 @@
             <p>{{ labelMap[detailTarget?.type] || '供水设施' }} · {{ detailTarget?.code || '-' }}</p>
             <div class="status-tags">
               <el-tag type="primary">{{ labelMap[detailTarget?.type] || '供水设施' }}</el-tag>
+              <el-tag :type="gisPointStatusTagTypes[detailStatusMeta.key] || 'info'">{{ detailStatusMeta.text }}</el-tag>
             </div>
           </section>
           <section class="detail-section">
@@ -198,7 +208,7 @@ import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDet
 import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
 import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
 import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature, formatFacilityDetail } from '@/utils/waterFacilityGisModel'
-import { getFacilityPointIcon } from '@/utils/gisMapColors'
+import { getFacilityStatusIcon, getFacilityStatusMeta, gisPointStatusColors, gisPointStatusTagTypes } from '@/utils/gisMapColors'
 
 const centerPoint = { lng: 110.3965, lat: 28.4638 }
 
@@ -234,6 +244,14 @@ const labelMap = {
   user: '用水户'
 }
 
+// 顶部状态标识:与点位同源,绿-正常、红-报警/异常、蓝-检修中、黄-欠费
+const facilityStatusLegend = [
+  { label: '正常', color: gisPointStatusColors.normal },
+  { label: '异常 / 停产 / 故障停运 / 停用', color: gisPointStatusColors.alarm },
+  { label: '检修中', color: gisPointStatusColors.workOrder },
+  { label: '欠费', color: gisPointStatusColors.warning }
+]
+
 const gisMapRef = ref(null)
 const searchKeyword = ref('')
 const selectedFacilityType = ref('')
@@ -285,20 +303,36 @@ const filteredFeatures = computed(() => {
 
 const mapPoints = computed(() => visibleMapModel.value.points.map(point => ({
   ...point,
-  color: colorMap[point.type] || '#457b9d',
-  iconUrl: getFacilityPointIcon(point.type),
+  color: gisPointStatusColors[getFacilityStatusMeta(point.type, point.status).key],
+  iconUrl: getFacilityStatusIcon(point.type, point.status),
+  statusText: getFacilityStatusMeta(point.type, point.status).text,
   name: point.name || point.code || '供水设施'
 })))
 
 const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
   ...line,
   color: colorMap[line.type] || '#fcbf49',
-  iconUrl: getFacilityPointIcon(line.type),
   name: line.name || '供水管网'
 })))
 
 const detailEntries = computed(() => formatFacilityDetail(detailRecord.value, detailTarget.value?.type))
 
+// 管网只有管线、不参与设施状态语义;其余设施取状态元数据
+function featureStatusMeta(feature) {
+  return feature?.type === 'pipe' ? null : getFacilityStatusMeta(feature?.type, feature?.status)
+}
+
+function featureColor(feature) {
+  const meta = featureStatusMeta(feature)
+  return meta ? gisPointStatusColors[meta.key] : colorMap.pipe
+}
+
+// 详情顶部标签展示设施业务状态(异常/停产/故障停运/检修中/欠费/停用),与点位颜色同一口径
+const detailStatusMeta = computed(() => getFacilityStatusMeta(
+  detailTarget.value?.type,
+  detailRecord.value?.status ?? detailTarget.value?.status
+))
+
 watch(layerState, () => {
   if (mapReady.value) nextTick(() => gisMapRef.value?.fitBounds())
 }, { deep: true })
@@ -500,6 +534,18 @@ async function refreshScene() {
   border-radius: 50%;
   flex-shrink: 0;
 }
+.device-name {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+.status-dot {
+  display: inline-block;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex: none;
+}
 .result-section {
   flex: 1;
   min-height: 0;

+ 35 - 9
tests/gisMapCanvas.test.mjs

@@ -1,7 +1,8 @@
 import test from 'node:test'
 import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
-import { getFacilityPointIcon, getMapClusterIcon, getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
+import { getMapClusterIcon, getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
+import { getFacilityStatusIcon, getFacilityStatusMeta } from '../src/utils/gisMapColors.js'
 
 test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
@@ -69,13 +70,38 @@ test('shared gis map canvas renders every point as an official icon with a label
   assert.ok(source.includes("emit('point-click', point)"))
 })
 
-test('facility point icons follow the five-facility-type contract', () => {
-  assert.match(getFacilityPointIcon('source'), /T_MarkPoint_Blue\.png/u)
-  assert.match(getFacilityPointIcon('plant'), /T_MarkPoint_Green\.png/u)
-  assert.match(getFacilityPointIcon('pumpStation'), /T_MarkPoint_Red\.png/u)
-  assert.match(getFacilityPointIcon('pipe'), /T_MarkPoint_Yellow\.png/u)
-  assert.match(getFacilityPointIcon('user'), /T_MarkPoint_Black\.png/u)
-  assert.match(getFacilityPointIcon('unknown'), /T_MarkPoint_Green\.png/u)
+test('facility point colors follow the facility status semantics shared with monitoring devices', () => {
+  // 供水设施一张图与首页统一:绿-正常、红-报警/异常、蓝-工单/检修、黄-预警/欠费
+  assert.deepEqual(getFacilityStatusMeta('source', 0), { key: 'normal', text: '正常' })
+  assert.deepEqual(getFacilityStatusMeta('source', 1), { key: 'alarm', text: '异常' })
+  assert.deepEqual(getFacilityStatusMeta('plant', 1), { key: 'alarm', text: '停产' })
+  assert.deepEqual(getFacilityStatusMeta('pumpStation', 1), { key: 'alarm', text: '故障停运' })
+  assert.deepEqual(getFacilityStatusMeta('pumpStation', 2), { key: 'workOrder', text: '检修中' })
+  assert.deepEqual(getFacilityStatusMeta('user', 1), { key: 'warning', text: '欠费' })
+  assert.deepEqual(getFacilityStatusMeta('user', 2), { key: 'alarm', text: '停用' })
+  // 未维护状态的设施按正常处理,不臆造异常
+  assert.deepEqual(getFacilityStatusMeta('source', null), { key: 'normal', text: '正常' })
+  assert.deepEqual(getFacilityStatusMeta('plant', undefined), { key: 'normal', text: '正常' })
+  assert.deepEqual(getFacilityStatusMeta('source', 9), { key: 'normal', text: '正常' })
+  assert.deepEqual(getFacilityStatusMeta('unknown', 1), { key: 'normal', text: '正常' })
+})
+
+test('facility gis page colors points by facility status instead of facility type', async () => {
+  const source = await readFile('src/views/subSystem/waterSupply/facility/gis.vue', 'utf8')
+
+  assert.match(source, /getFacilityStatusMeta/u)
+  assert.match(source, /getFacilityStatusIcon/u)
+  assert.doesNotMatch(source, /iconUrl: getFacilityPointIcon\(point\.type\)/u)
+  assert.match(source, /设施状态图例/u)
+})
+
+test('pipelines keep no point icon while facilities map status to the shared palette', () => {
+  // 管网保持现状:只构造管线,不添加图标
+  assert.equal(getFacilityStatusIcon('pipe', 0), null)
+  assert.match(getFacilityStatusIcon('source', 0), /T_MarkPoint_Green\.png/u)
+  assert.match(getFacilityStatusIcon('source', 1), /T_MarkPoint_Red\.png/u)
+  assert.match(getFacilityStatusIcon('pumpStation', 2), /T_MarkPoint_Blue\.png/u)
+  assert.match(getFacilityStatusIcon('user', 1), /T_MarkPoint_Yellow\.png/u)
 })
 
 test('cluster icons reuse the single member icon or fall back to the colorful marker', () => {
@@ -140,7 +166,7 @@ test('water supply GIS pages share the common canvas component', async () => {
   assert.match(facilitySource, /<GisMapCanvas/u)
   assert.match(facilitySource, /:points="mapPoints"/u)
   assert.match(facilitySource, /:lines="mapLines"/u)
-  assert.match(facilitySource, /iconUrl: getFacilityPointIcon\(point\.type\)/u)
+  assert.match(facilitySource, /getFacilityStatusIcon\(point\.type, point\.status\)/u)
   assert.match(facilitySource, /cluster-label="设施集合"/u)
   assert.match(facilitySource, /@point-click="locateFeature"/u)
   const alarmSource = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')

+ 372 - 0
tests/waterHomeModel.test.mjs

@@ -0,0 +1,372 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+  HOME_QUICK_ENTRIES,
+  buildHomeWorkOrderQuery,
+  normalizeFacilityTotal,
+  normalizeHomeKpis,
+  normalizeDeviceStatistics,
+  normalizeWorkOrderStatusStat,
+  normalizeAlarmOverviewCards,
+  normalizeAlarmTrend,
+  normalizePieRatio,
+  normalizeRealtimeAlarmRows,
+  realtimeAlarmJumpTarget,
+  normalizeAbnormalTodoRows,
+  normalizeDeviceSummary,
+  normalizeWorkOrderRows,
+  buildHomeGisOverview
+} from '../src/utils/waterHomeModel.js'
+
+test('home work order scope is constrained to the water-supply type tree', () => {
+  assert.deepEqual(buildHomeWorkOrderQuery(), {
+    deviceTypeParentId: '1',
+    pageNum: 1,
+    pageSize: 5
+  })
+  assert.deepEqual(buildHomeWorkOrderQuery({ pageSize: 8 }), {
+    deviceTypeParentId: '1',
+    pageNum: 1,
+    pageSize: 8
+  })
+})
+
+test('facility total excludes the aggregated total row', () => {
+  const rows = [
+    { facilityType: 'source', totalCount: 3 },
+    { facilityType: 'plant', totalCount: 2 },
+    { facilityType: 'pipe', totalCount: 996 },
+    { facilityType: 'total', totalCount: 1000 }
+  ]
+  assert.equal(normalizeFacilityTotal(rows), 1001)
+  assert.equal(normalizeFacilityTotal(null), 0)
+  assert.equal(normalizeFacilityTotal([]), 0)
+})
+
+test('home KPIs map api payloads to workbench cards', () => {
+  const kpis = normalizeHomeKpis({
+    facilityRows: [{ facilityType: 'pipe', totalCount: 996 }, { facilityType: 'total', totalCount: 1000 }],
+    ledgerStats: { total: 15, online: 13 },
+    alarmStats: { realtimeAlarmCount: 3 }
+  })
+  assert.deepEqual(kpis, [
+    { key: 'facility', label: '设施总数', value: 996, unit: '处', tone: 'primary' },
+    { key: 'device', label: '监测设备', value: 15, unit: '台', tone: 'primary' },
+    { key: 'online', label: '在线设备', value: 13, unit: '台', tone: 'primary' },
+    { key: 'alarm', label: '实时报警', value: 3, unit: '条', tone: 'danger' }
+  ])
+})
+
+test('home KPIs keep realtime alarm card neutral when no alarm is active', () => {
+  const alarm = normalizeHomeKpis({ alarmStats: { realtimeAlarmCount: 0 } }).find(item => item.key === 'alarm')
+  assert.equal(alarm.tone, 'primary')
+})
+
+test('work order status statistic groups by authoritative status contract', () => {
+  const stat = normalizeWorkOrderStatusStat({
+    total: 10,
+    counts: [
+      { status: 0, count: 2 },
+      { status: 1, count: 1 },
+      { status: 3, count: 2 },
+      { status: 4, count: 2 },
+      { status: 5, count: 2 },
+      { status: 7, count: 1 }
+    ]
+  })
+  assert.equal(stat.total, 10)
+  assert.equal(stat.pendingCount, 3)
+  assert.equal(stat.processingCount, 2)
+  assert.equal(stat.verifyingCount, 2)
+  assert.equal(stat.finishedCount, 3)
+  assert.equal(stat.byStatus.find(item => item.status === 6).count, 0)
+})
+
+test('alarm overview cards derive online rate and verifying orders', () => {
+  const cards = normalizeAlarmOverviewCards({
+    dashboard: { totalCount: 16, alarmingDeviceCount: 4 },
+    deviceStats: { onlineRate: 86.666666 },
+    workOrderStat: {
+      counts: [{ status: 4, count: 3 }]
+    }
+  })
+  assert.deepEqual(cards, [
+    { key: 'alarmTotal', label: '报警总数', value: 16, unit: '条', tone: 'primary' },
+    { key: 'alarmingDevice', label: '报警中设备', value: 4, unit: '台', tone: 'danger' },
+    { key: 'verifyingOrder', label: '待验收工单', value: 3, unit: '单', tone: 'warning' },
+    { key: 'onlineRate', label: '在线率', value: 86.7, unit: '%', tone: 'primary' }
+  ])
+})
+
+test('alarm trend returns four level series with zero-filled missing days', () => {
+  const trend = normalizeAlarmTrend([
+    { date: '2026-09-15', level1Count: 1, level2Count: 2 },
+    { date: '2026-09-16' }
+  ])
+  assert.deepEqual(trend.dates, ['2026-09-15', '2026-09-16'])
+  assert.equal(trend.levels.length, 4)
+  const level2 = trend.levels.find(item => item.name === '级2')
+  assert.deepEqual(level2.data, [2, 0])
+  assert.equal(normalizeAlarmTrend(null).levels.length, 4)
+})
+
+test('ratio pie drops empty values and caps the number of segments', () => {
+  const items = [
+    { name: ' ' },
+    { name: '压力', count: '8' },
+    { name: '流量', count: '5' },
+    { name: '离线', count: '3' },
+    { name: '低', count: 0 }
+  ]
+  assert.deepEqual(normalizePieRatio(items, '未分类', 3), [
+    { name: '压力', count: 8 },
+    { name: '流量', count: 5 },
+    { name: '离线', count: 3 }
+  ])
+  assert.deepEqual(normalizePieRatio(undefined, '未分类'), [])
+})
+
+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: '城南',
+      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 rows derive transferred status from a live order number when displayStatus is missing', () => {
+  const rows = normalizeRealtimeAlarmRows([
+    { id: 'legacy-order', deviceCode: 'CN-GS-202609020004', orderNo: 'WO202609151100009', orderStatus: 3, alarmTime: '2026-09-15 11:00:00' },
+    { id: 'legacy-pending', deviceCode: 'CN-GS-202609020005', alarmTime: '2026-09-15 10:30:00' }
+  ], 5)
+
+  const transferred = rows.find(item => item.id === 'legacy-order')
+  assert.equal(transferred.displayStatus, 2)
+  assert.equal(transferred.displayStatusText, '已转工单')
+  assert.equal(transferred.displayStatusType, 'primary')
+  const pending = rows.find(item => item.id === 'legacy-pending')
+  assert.equal(pending.displayStatus, 1)
+  assert.equal(pending.displayStatusText, '待审核')
+})
+
+test('realtime alarm rows never treat a cancelled order as transferred', () => {
+  const rows = normalizeRealtimeAlarmRows([
+    { id: 'legacy-cancelled', deviceCode: 'CN-GS-202609020006', orderNo: 'WO202609151100010', orderStatus: 7, alarmTime: '2026-09-15 11:30:00' }
+  ], 5)
+
+  assert.equal(rows[0].displayStatus, 1)
+  assert.equal(rows[0].displayStatusText, '待审核')
+})
+
+test('realtime alarm fallback keeps a live order as transferred even when the alarm row is already handled', () => {
+  // 与后端 resolveDisplayStatus 同序:在办工单优先于报警是否已处理。
+  const rows = normalizeRealtimeAlarmRows([
+    {
+      id: 'legacy-handled-with-order',
+      deviceCode: 'CN-GS-202609020007',
+      alarmStatus: 1,
+      orderNo: 'WO202609151100011',
+      orderStatus: 3,
+      alarmTime: '2026-09-15 12:00:00'
+    }
+  ], 5)
+
+  assert.equal(rows.length, 1)
+  assert.equal(rows[0].displayStatus, 2)
+  assert.equal(rows[0].displayStatusText, '已转工单')
+})
+
+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',
+    equipmentCode: 'CN-GS-202609020002',
+    equipmentName: '二号流量点',
+    alarmStatus: 1,
+    onlineStatus: 1,
+    currentStatus: 1,
+    alertLevel: 2,
+    relatedWorkOrderNo: 'WO202609150001',
+    relatedWorkOrderStatus: 3
+  }], 1)
+  assert.equal(rows.length, 1)
+  assert.equal(rows[0].alarmStatusText, '报警')
+  assert.equal(rows[0].onlineStatusText, '在线')
+  assert.equal(rows[0].alarmLevelText, '二级报警')
+  assert.equal(rows[0].relatedOrderNo, 'WO202609150001')
+  assert.equal(rows[0].workOrderStatusText, '处理中')
+})
+
+test('device summary keeps key text fields for the workbench detail panel', () => {
+  const summary = normalizeDeviceSummary({
+    equipmentId: 'eq-9',
+    equipmentCode: 'CB-GS-202609020003',
+    equipmentName: '三号漏失点',
+    equipmentLocation: '城北',
+    district: '城北',
+    alarmStatus: 0,
+    onlineStatus: 0,
+    currentStatus: 3,
+    equipmentTypeName: '漏失在线监测仪'
+  })
+  assert.equal(summary.name, '三号漏失点')
+  assert.equal(summary.alarmStatusText, '正常')
+  assert.equal(summary.onlineStatusText, '离线')
+  assert.equal(summary.currentStatusText, '维修')
+  assert.equal(summary.abnormalReasonText, '设备维修中')
+  assert.equal(summary.typeName, '漏失在线监测仪')
+})
+
+test('work order rows translate status and level codes to chinese', () => {
+  const rows = normalizeWorkOrderRows([
+    { orderId: 5, orderNo: 'WO202609150001', orderStatus: 4, orderLevel: 0, deviceCode: 'CN-GS-202609020001', createTime: '2026-09-15 10:00:00' }
+  ], 5)
+  assert.equal(rows[0].orderStatusText, '待验收')
+  assert.equal(rows[0].orderStatusType, 'primary')
+  assert.equal(rows[0].orderLevelText, '紧急')
+  assert.equal(rows[0].orderLevelType, 'danger')
+})
+
+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, currentStatus: 0, equipmentTypeName: '压力计' }
+    ],
+    alarmPoints: [
+      { alarmId: 'alarm-1', deviceCode: 'CN-GS-202609020001', deviceName: '一号压力点', longitude: 110.1, latitude: 28.1, warningType: '压力过低', alarmLevel: 1 }
+    ]
+  })
+
+  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 GIS facility points share device status color semantics by facility status', () => {
+  const overview = buildHomeGisOverview({
+    facilityPoints: [
+      { id: 'src-abnormal', type: 'source', name: '一水源地', lng: 110.08, lat: 28.05, status: 1 },
+      { id: 'src-normal', type: 'source', name: '二水源地', lng: 110.09, lat: 28.06, status: 0 },
+      { id: 'src-unknown', type: 'source', name: '三水源地', lng: 110.15, lat: 28.12 },
+      { id: 'plant-stopped', type: 'plant', name: '一水厂', lng: 110.1, lat: 28.07, status: 1 },
+      { id: 'pump-maintaining', type: 'pumpStation', name: '城南泵站', lng: 110.11, lat: 28.08, status: 2 },
+      { id: 'pump-fault', type: 'pumpStation', name: '城北泵站', lng: 110.12, lat: 28.09, status: 1 },
+      { id: 'user-arrears', type: 'user', name: '用水户A', lng: 110.13, lat: 28.1, status: 1 },
+      { id: 'user-stopped', type: 'user', name: '用水户B', lng: 110.14, lat: 28.11, status: 2 },
+      { id: 'pipe-point', type: 'pipe', name: '管网占位点', lng: 110.16, lat: 28.13 }
+    ]
+  })
+
+  const byId = id => overview.points.find(point => point.id === id)
+
+  assert.equal(byId('src-abnormal').statusKey, 'alarm')
+  assert.equal(byId('src-abnormal').statusText, '异常')
+  assert.match(byId('src-abnormal').iconUrl, /T_MarkPoint_Red\.png/u)
+  assert.equal(byId('src-normal').statusKey, 'normal')
+  assert.equal(byId('src-normal').statusText, '正常')
+  assert.match(byId('src-normal').iconUrl, /T_MarkPoint_Green\.png/u)
+  assert.equal(byId('src-unknown').statusKey, 'normal')
+  assert.equal(byId('src-unknown').statusText, '正常')
+
+  assert.equal(byId('plant-stopped').statusKey, 'alarm')
+  assert.equal(byId('plant-stopped').statusText, '停产')
+
+  assert.equal(byId('pump-maintaining').statusKey, 'workOrder')
+  assert.equal(byId('pump-maintaining').statusText, '检修中')
+  assert.match(byId('pump-maintaining').iconUrl, /T_MarkPoint_Blue\.png/u)
+  assert.equal(byId('pump-fault').statusKey, 'alarm')
+  assert.equal(byId('pump-fault').statusText, '故障停运')
+
+  assert.equal(byId('user-arrears').statusKey, 'warning')
+  assert.equal(byId('user-arrears').statusText, '欠费')
+  assert.match(byId('user-arrears').iconUrl, /T_MarkPoint_Yellow\.png/u)
+  assert.equal(byId('user-stopped').statusKey, 'alarm')
+  assert.equal(byId('user-stopped').statusText, '停用')
+
+  assert.equal(byId('pipe-point'), undefined)
+})
+
+test('home quick entries cover the four workbench jumps', () => {
+  assert.deepEqual(HOME_QUICK_ENTRIES.map(item => item.label), ['设备异常管理', '报警管理', '报警审核', '监测报警一张图'])
+  assert.ok(HOME_QUICK_ENTRIES.every(item => item.suffix.startsWith('/')))
+})
+
+test('home device statistics derive online rate from the unified equipment endpoint', () => {
+  assert.deepEqual(normalizeDeviceStatistics({ total: 15, online: 12, offline: 3, alarm: 5 }), {
+    total: 15,
+    online: 12,
+    offline: 3,
+    alarm: 5,
+    onlineRate: 80
+  })
+
+  // 后端未回填 offline 时按总数推导;无设备时不产生除零
+  assert.equal(normalizeDeviceStatistics({ total: 15, online: 12 }).offline, 3)
+  assert.equal(normalizeDeviceStatistics({ total: 0, online: 0 }).onlineRate, 0)
+  assert.equal(normalizeDeviceStatistics({}).onlineRate, 0)
+})

+ 116 - 0
tests/waterSupplyHomeContract.test.mjs

@@ -0,0 +1,116 @@
+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)
+})
+
+test('home B-road detail panel is 1:1 with the todo and shortcut panels', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /workbench-grid--equal/u)
+  assert.match(source, /repeat\(3,\s*minmax\(0,\s*1fr\)\)/u)
+  const sectionB = source.match(/<section class="home-section">([\s\S]*?)<el-card shadow="never" class="water-panel home-panel detail-panel">/u)
+  assert.ok(sectionB, 'detail panel should live in the equal-width B section')
+})
+
+test('home list panels scroll to reveal the full queue instead of pinning a few rows', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  const listSelector = source.match(/\.compact-list\s*\{[^}]*\}/u)
+  assert.ok(listSelector, 'compact list style block exists')
+  assert.match(listSelector[0], /max-height:\s*[\w.]+/u)
+  assert.match(listSelector[0], /overflow-y:\s*auto/u)
+  assert.match(source, /listRealtimeAlarms\(\),/u)
+})
+
+test('home overview map fills the full map card body', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /\.map-panel[\s\S]{0,160}?display:\s*flex/u)
+  assert.match(source, /\.map-stage[\s\S]{0,140}?flex:\s*1/u)
+})
+
+test('home overview legend and detail cover unified device and facility status colors', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  assert.match(source, /gisPointStatusColors\.alarm[\s\S]{0,20}报警\/异常/u)
+  assert.match(source, /gisPointStatusColors\.workOrder[\s\S]{0,20}工单\/检修中/u)
+  assert.match(source, /gisPointStatusColors\.warning[\s\S]{0,20}预警\/欠费/u)
+  assert.match(source, /gisPointStatusColors\.offline[\s\S]{0,20}离线/u)
+  assert.match(source, /gisPointStatusColors\.normal[\s\S]{0,20}正常/u)
+  assert.match(source, /overviewDetail\.statusText \|\| gisStatusLabel\(overviewDetail\.statusKey\)/u)
+})
+
+test('home device data sources track the unified equipment endpoints', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  // 设备台账已统一到 /equipment-base:统计与列表都走统一设备接口,供水范围用 rootTypeId 限定
+  assert.match(source, /getEquipmentStatusStatistics\(/u)
+  assert.match(source, /listEquipment\(/u)
+  assert.match(source, /rootTypeId/u)
+  assert.match(source, /normalizeDeviceStatistics/u)
+  // 供水专属的旧设备接口已下线,不得再被首页引用
+  assert.doesNotMatch(source, /getDeviceLedgerStatistics/u)
+  assert.doesNotMatch(source, /listAbnormalDevices/u)
+})
+
+test('home surfaces partial load failures instead of swallowing them', async () => {
+  const source = await readFile(homePath, 'utf8')
+
+  // settle 自身吞掉异常并返回兜底值,因此失败上报必须由 settle 内部完成;
+  // 链式 .catch(recordProblem(...)) 永不触发,会让“部分数据加载失败”提示变成死代码。
+  assert.match(source, /async function settle\(fn, fallback = null, onError = null\)/u)
+  assert.match(source, /onError\?\.\(\)/u)
+  assert.doesNotMatch(source, /\.catch\(recordProblem\(/u)
+  assert.match(source, /settle\(\(\) => getWaterFacilityOverview\(\), \[\], recordProblem\('设施概览'\)\)/u)
+  assert.match(source, /settle\(\(\) => listRealtimeAlarms\(\), \[\], recordProblem\('实时报警'\)\)/u)
+})