Просмотр исходного кода

合并远端并保留供水设备异常管理页面修复

接纳远端报警公共页面整理与报警清单统计变更,保留 #65 设备异常管理看板、统一设备取数、异常筛选、详情抽屉与导出实现。
Kazerin 3 недель назад
Родитель
Сommit
ed14f538fe
39 измененных файлов с 1016 добавлено и 1212 удалено
  1. 66 0
      memory/archive/2026-09-24-gis-home.md
  2. 6 2
      memory/index.md
  3. 7 2
      memory/long-term.md
  4. 15 0
      memory/sessions/2026-09-24.md
  5. 0 5
      src/api/pipeNetwork/waterSupply.js
  6. 0 178
      src/utils/waterAlarmGisModel.js
  7. 1 1
      src/utils/waterHomeModel.js
  8. 9 52
      src/views/portal/index.vue
  9. 1 1
      src/views/subSystem/alarm/AlarmAttachments.vue
  10. 1 1
      src/views/subSystem/alarm/AlarmChart.vue
  11. 3 3
      src/views/subSystem/alarm/AlarmDetail.vue
  12. 1 1
      src/views/subSystem/alarm/AlarmHistory.vue
  13. 1 1
      src/views/subSystem/alarm/AlarmSummary.vue
  14. 2 2
      src/views/subSystem/alarm/common/audit.vue
  15. 78 7
      src/views/subSystem/alarm/common/list.vue
  16. 43 12
      src/views/subSystem/alarm/common/remind.vue
  17. 0 0
      src/views/subSystem/alarm/common/styles/alarmOperations.scss
  18. 1 1
      src/views/subSystem/alarm/common/utils/alarmHistoryModel.js
  19. 0 0
      src/views/subSystem/alarm/common/utils/alarmOperationsModel.js
  20. 0 0
      src/views/subSystem/alarm/common/utils/alarmReviewModel.js
  21. 0 0
      src/views/subSystem/alarm/common/utils/useAlarmScope.js
  22. 11 13
      src/views/subSystem/drainage/alarm/alarmGIS.scss
  23. 16 17
      src/views/subSystem/drainage/alarm/alarmGIS.vue
  24. 7 7
      src/views/subSystem/drainage/alarm/analysis.vue
  25. 126 0
      src/views/subSystem/waterSupply/alarm/alarmGIS.scss
  26. 180 0
      src/views/subSystem/waterSupply/alarm/alarmGIS.vue
  27. 0 626
      src/views/subSystem/waterSupply/components/WaterAlarmPage.vue
  28. 6 6
      src/views/subSystem/waterSupply/components/WaterSupplyHome.vue
  29. 0 1
      src/views/subSystem/waterSupply/monitorAlarm/map.vue
  30. 0 1
      src/views/subSystem/waterSupply/wjcbj/jcbjyzt.vue
  31. 103 0
      tests/alarmListStatistics.test.mjs
  32. 210 0
      tests/alarmRemindLoading.test.mjs
  33. 5 5
      tests/gisMapCanvas.test.mjs
  34. 2 2
      tests/waterAlarmAuditWorkbench.test.mjs
  35. 0 184
      tests/waterAlarmGisModel.test.mjs
  36. 0 70
      tests/waterAlarmGisPage.test.mjs
  37. 9 10
      tests/waterGisConsistency.test.mjs
  38. 103 0
      tests/waterSupplyAlarmGIS.test.mjs
  39. 3 1
      tests/waterSupplyHomeContract.test.mjs

+ 66 - 0
memory/archive/2026-09-24-gis-home.md

@@ -0,0 +1,66 @@
+# 2026-09-24 会话记录
+
+此前报警审核、运营重构、日志及状态样式调整的完整记录已保留至 [当日前期归档](../archive/2026-09-24-early.md)。稳定决策已合并到长期记忆,不以归档中的过期实现为准。
+
+## 排水报警 GIS 按设计稿重排
+
+### 目标与结论
+
+- 用户要求参考 `E:/work/prd/outputs/alarm-gis.html` 美化排水 GIS。实际入口仍是 `src/views/subSystem/alarm/drainage/alarmGIS.vue`,组件名 `DrainageAlarmGIS`、菜单 path `drainageAlarmGIS` 不变。
+- 页面改为顶部筛选、四级统计、左侧设备列表和大地图;右侧详情采用独立抽屉,打开时不压缩地图。统一蓝白底色、细边框、轻量文字、小号等级标记、定位/缩放工具、图例及列表选中效果;窄屏改为上下布局,抽屉全宽。
+- 详情保留监测曲线、报警记录、设备信息,不添加周边环境;报警历史使用紧凑时间线卡片和框架分页。详情数值明确标为报警触发快照,不冒充当前测量值。保留真实接口与 GisMapCanvas,不复制原型的示例数据或腾讯地图配置。
+- 四级统计按当前查询结果聚合设备计数,并明确标注;等级仍为红、橙、蓝、绿,设备按等级、编码排序。后端范围、分页、默认未处理状态不变。无实际值事件仍出现在地图/历史中,仅曲线候选排除空值,0 有效。
+- 增加底图加载/失败/重试、无坐标提示与无匹配设备状态;底图未就绪时禁用地图操作,设备详情仍能打开。
+- 新增 scoped `alarmGIS.scss`,回归断言在 `tests/alarmOperations.test.mjs`;新增独立 `tests/alarmGIS.visual.mjs`,只在测试服务器中替换接口和底图,不影响生产数据源或增加 npm 依赖。
+
+### 验证结果
+
+- 报警运营、历史、审核及 GIS 画布/聚合 Node 测试共 42 项通过。
+- 使用本机 Edge 完成隔离浏览器交互测试:统计聚合、空值/零值曲线筛选、历史分页、设备信息、无坐标、地图重试/定位、查询/重置、详情不压缩地图、390px 窄屏无横向溢出;无浏览器 JavaScript 错误。已检查桌面与窄屏截图。
+- `npm run build:prod` 成功,构建输出到系统临时目录,保留原有发布目录;`git diff --check` 通过。
+
+### 风险与后续
+
+- 浏览器验证使用隔离测试数据与地图替身,未验证真实登录、后端实时数据或地图提供商底图加载。部署后需在实际业务菜单中核验。
+- 本轮无后端、SQL 或菜单变更,未提交、推送或部署,保留所有既有工作区修改及 `.feisuan/`、`dist-verify/`。
+- 当日记录接近 20 KB,已归档前期完整记录并同步索引、长期记忆,未丢弃历史内容。
+
+## 确认 dist-verify 能否清理
+
+- 用户仅询问 `E:/work/city-life-line/dist-verify` 能否删除,本轮只检查,未执行删除。
+- 目录不是链接,内容为编译后的 HTML、assets 及静态资源,Git 未跟踪;项目源码、配置和脚本未发现依赖该路径。Vite 默认构建输出为 `dist`,开发服务不依赖 `dist-verify`。
+- 结论:该验证构建产物可清理,不影响源码和正常开发/构建;如有项目外静态服务另行指向该目录,需先停用或切换。需要时可通过 `npm run build:prod -- --outDir dist-verify` 重新生成当前源码的验证产物。
+
+## 修复供水首页引用已删除的报警态势接口
+
+- 截图报错为 `waterSupply.js` 未导出 `getAlarmLedgerDashboard`。合并后恢复的 `WaterSupplyHome.vue` 仍调用旧函数,旧后台 `/waterSupply/alarm/ledger/dashboard` 也已删除;单补导出会继续报 404。
+- 首页改用 `src/api/alarm.js` 的 `getAlarmDashboard({ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID, days: 7 })`。统一 `AlarmController` 新增 `GET /api/alarm/dashboard`,沿用 `alarm:operations:list` 权限及获授权菜单/类型树校验,不恢复旧接口或旧页面。
+- 后端在数据库聚合总报警数、当前报警设备去重数、分级自然日趋势、监测指标及设备类型占比;保持历史事件也参与统计、设备状态不由报警状态替代。近 N 天使用北京时间,允许 1~31 天,无事件日期四级计数补 0;空范围不查询全表。指标名称优先配置表并防止重复关联放大计数,设备类型从台账关联。
+- 不删首页图表、不改布局及其他取数链路,不使用静态模拟数。测试增加首页所有 API 命名导入与真实导出的解析校验,防止重构删除接口后遗漏消费方。
+- 验证:相关前端 Node 测试 50 项、生产构建、后端运营 service/mapper/controller/范围测试 26 项、实际 MyBatis SQL 的内存 PGlite 兼容测试 4 项均通过。两仓 diff 检查通过;构建输出到临时目录,未覆盖原发布目录。
+- 前端和管理后端需同步更新,重启后端再刷新页面;无需数据库脚本。未验证真实登录或实际 KingbaseES、未重启服务、未部署/提交/推送。过程中发现其他工单代码与测试文件的并行改动,均保留未覆盖。
+
+## 修复报警历史排序运行时报错
+
+- 用户截图显示 `localeCompre is not a function`;将 `src/views/subSystem/alarm/utils/alarmHistoryModel.js` 第 31 行方法名修正为 `localeCompare`,恢复多条历史记录的排序与分组。
+- 验证:直接运行真实模块,时间倒序、微秒精度、同时间大整数 ID 倒序、日期分组、空时间/空列表与输入不变断言均通过,文件无编辑器错误。
+- 未进行浏览器复验;刷新报警历史页面可核验实际显示。本次无后端变更,未提交或推送。
+
+## GIS 等级统计文案微调
+
+- 按用户要求修改 `src/views/subSystem/alarm/drainage/alarmGIS.vue`:去掉等级统计中的“当前结果 ·”,将“报警设备”合并到上方等级名称后面。
+- 编辑器检查无错误,其他位置有类型及未解析变量警告;本次仅修改静态文案结构,未做浏览器复验,无后端或统计逻辑变更。
+
+## GIS 设备搜索范围和输入宽度
+
+- `alarmGIS.vue` 输入提示改为“请输入设备编码/名称”;`alarmGIS.scss` 桌面及中等桌面断点将搜索列限制到 260px,保留小屏自适应。
+- 后端 `AlarmOperationsMapper.xml` 共用关键词条件移除安装位置匹配,地图/清单等仅按设备编码或名称模糊查询;`AlarmReviewQuery.java` 补充参数文档,`AlarmOperationsMapperTest.java` 增加关键词 SQL 回归断言。
+- 验证:Vue 模板、SCSS 编译通过;后端 Mapper 测试 1 项通过,涵盖地图/清单/提醒/等级统计关键词及原有查询约束;编辑器无错误,两仓 diff 检查通过。
+- 需更新并重启管理后端使搜索范围生效,无数据库结构变更;未做真实接口或浏览器复验,未部署、提交或推送。
+
+## GIS 固定当前报警及筛选统计样式
+
+- 按用户五项要求调整 `alarmGIS.vue` / `alarmGIS.scss`:移除监测状态筛选,设备搜索和报警等级标签左置;去掉右上角“排水监测视图”和详情底部状态说明;统计标题仅等级,下一行以灰色“报警设备”紧接数量与单位。保留紧凑输入宽度及小屏布局。
+- 地图请求固定 `alarmStatus: 0`,后端 `AlarmOperationsService.map` 强制使用 `UNHANDLED`,不允许通过请求切换为已处理报警。地图列表、点位及统计同口径;详情报警记录仍保留设备历史。
+- `AlarmOperationsServiceTest` 补充缺省/0/1状态请求回归;服务和 Mapper 共15项测试通过。Vue脚本、模板、SCSS编译通过,编辑器无错误。
+- 本轮未进行浏览器或真实数据库复验;当前工作区未找到此前记录的两个GIS前端测试文件。需同步更新前后端并重启管理后端,无数据库结构变更,未部署或提交。

+ 6 - 2
memory/index.md

@@ -34,11 +34,14 @@ npm run dev       # 默认 http://localhost:80
 
 ## 当前已知风险
 
+- 报警清单 `alarm/common/list.vue` 顶部新增报警总数、监测异常、监测已恢复三张统计卡片,按当前全部筛选结果计数、不受分页影响;既有查询区和表格保持原样。复用 `/api/alarm/statistics`,后端分级统计新增 abnormal/recovered,需要同步更新管理后端,无数据库结构变更(2026-09-24)。
+
 - 业务菜单主要由后端 `/getRouters` 动态下发,前端常量路由不包含完整业务树。
 - 门户页调用 `userStore.logout()`,用户 Store 当前定义的方法名为 `logOut()`,需要后续确认是否修复。
 - 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
 - 供水 T3 统计(Issue #7)与 T4 运行监测(Issue #8)已人工验收通过并在 Gogs 关闭(2026-09-05)。
-- 报警提醒/清单/研判/GIS 已统一至 `src/views/subSystem/alarm`;新详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`,旧未使用的排水详情 API 包装已删除。部署需执行后端 `migration_alarm_operations.sql`,GIS 最新名称为 `alarmGIS.vue` / `drainageAlarmGIS`。
+- 报警提醒/清单/审核及配套 `utils`、`styles` 位于 `src/views/subSystem/alarm/common`;排水研判及 GIS 位于 `src/views/subSystem/drainage/alarm`。详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`。本次已有环境执行 `migration_alarm_common_components.sql` 精准更新七个公共页面菜单的 component,同步部署前后端并重新登录;path/query/权限保持不变,初装脚本已同步(2026-09-24)。
+- 供水 GIS 已按排水版复制到 `src/views/subSystem/waterSupply/alarm/alarmGIS.vue`;旧地图及其专用接口删除,首页同步使用统一地图接口。菜单 2120 由 `migration_water_supply_alarm_gis.sql` 更新为 `waterSupplyAlarmGIS`,范围 JSON 写在 query(rootTypeId=1),需同步部署前后端并执行脚本、重新登录(2026-09-24)。
 - 供水首页恢复后遗留的 `getAlarmLedgerDashboard` 导入已修复,态势图改用统一 `GET /api/alarm/dashboard?rootTypeId=1&days=7`;本次需同步更新并重启管理后端,无新增结构脚本,原统计图表保留(2026-09-24)。
 - 报警单号 `alarm_no` 最新要求为后端程序生成(`BJyyyyMMddHHmmss` + 4 位随机数,北京时间),原数据库生成触发器/序列由最新版脚本移除;管理端和采集 API 需同步部署。
 - 报警确认只记一条审核日志,包含实际工单号;工单操作不复制到 `alarm_log`。历史共用 `AlarmHistory.vue`,昵称、时间倒序、日期分组及图片预览;后端两工程各自定义 `AlarmLogOperTypeEnum`,值保持一致(0~3),不新增共享模块,已有环境执行 `migration_alarm_review_log.sql` 后同步部署两端及前端,历史旧日志保留(2026-09-24)。
@@ -73,5 +76,6 @@ npm run dev       # 默认 http://localhost:80
 - [2026-09-16:监测报警 GIS 接入统一画布(Ticket #4)](sessions/2026-09-16.md)
 - [2026-09-22:报警阈值移除备注字段](sessions/2026-09-22.md)
 - [2026-09-23:感知设备管理、案件 equipment_id、四子系统统一报警审核](sessions/2026-09-23.md)
-- [2026-09-24:排水报警 GIS 设计稿布局与交互调整](sessions/2026-09-24.md)
+- [2026-09-24:菜单排查、提醒修复、供水 GIS 和报警目录调整](sessions/2026-09-24.md)
 - [2026-09-24 前期归档:报警审核交互、同类工单限制、报警运营重构、日志与历史样式](archive/2026-09-24-early.md)
+- [2026-09-24 GIS 与首页归档:排水地图样式、状态及供水首页接口](archive/2026-09-24-gis-home.md)

+ 7 - 2
memory/long-term.md

@@ -40,6 +40,10 @@
 
 ## 用户已确认决策
 
+- 报警清单顶部展示蓝色“报警总数”、红色“监测异常”、绿色“监测已恢复”三张卡片;仅增加统计区,保留原查询、表格和分页。统计按当前查询范围的全部事件计数,监测恢复不等同于工单处置完成(2026-09-24)。
+
+- 报警审核、清单、提醒三个页面及其 `utils`、`styles` 统一放在 `src/views/subSystem/alarm/common`;详情、历史等共享组件继续保留在 `alarm` 根目录。后端菜单组件白名单、初装及增量 SQL 同步新路径,路由 path 与 query 不变(2026-09-24)。
+
 - 告警表 `alarm_data` 不再存储设备名称、设备类型、处理时间、处理人和处理备注(2026-09-24)。名称和类型从设备台账查询,处理状态仍使用 `alarm_status`;操作日志独立保留。
 
 - 采用仓库原生 `AGENTS.md + memory/` 方案。
@@ -58,15 +62,16 @@
 - 供水报警与工单前端统一使用唯一接单责任人;无手机号不阻断派单。
 - 供水、排水、燃气、窨井共用统一工单业务流程,系统展示范围由关联设备类型过滤;前端优先复用既有 REST 接口,不因页面入口创建平行工单生命周期。
 - 工单系统归属以关联设备类型树稳定业务编码判定;`/api/work-order` 是跨系统规范 API,旧入口只做兼容适配;统一工单继续采用数据库状态机和统一日志语义。
-- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/index.vue`,`sys_menu.path` 均为 `alarmAudit`,菜单 `query.rootTypeId` 按设备类型树隔离,燃气再限定 `warning_code`。确认报警创建待派单工单但不改设备状态;工单后续操作仍在统一工单页。此约定取代早期“审核页承载工单全流程/误报解除”的设计。
+- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/common/audit.vue`,`sys_menu.path` 均为 `alarmAudit`,菜单 `query.rootTypeId` 按设备类型树隔离,燃气再限定 `warning_code`。确认报警创建待派单工单但不改设备状态;工单后续操作仍在统一工单页。此约定取代早期“审核页承载工单全流程/误报解除”的设计。
 - 报警继续使用 `alarm_level`:1~4 为一级~四级报警,一级最严重;`audit_status` 独立于 `alarm_status`。本次数据库脚本不迁移历史数据,不引入 `source_key`;`alarm_log.log_id` 使用 Kingbase `int8` 和独立序列 `alarm_log_id_seq`。
-- 报警运营共用 `alarm/remind.vue`、`alarm/list.vue`,排水研判与 GIS 为 `alarm/drainage/analysis.vue`、`alarm/drainage/alarmGIS.vue`;GIS 菜单 path 为 `drainageAlarmGIS`,详情无周边环境页签。现有菜单 2139/2140/2141/2143/2117 精确更新,供水 root=1、排水 root=2;后端校验授权菜单后查根及后代,运营读取与审核授权分开。新清单替代供水旧管理页,供水原有 GIS 继续保留(2026-09-24)。
+- 报警运营共用 `alarm/common/remind.vue`、`alarm/common/list.vue`,排水研判与 GIS 为 `drainage/alarm/analysis.vue`、`drainage/alarm/alarmGIS.vue`;GIS 菜单 path 为 `drainageAlarmGIS`,详情无周边环境页签。现有菜单 2139/2140/2141/2143/2117 精确更新,供水 root=1、排水 root=2;后端校验授权菜单后查根及后代,运营读取与审核授权分开。供水 GIS 最新改为复制排水页面至 `waterSupply/alarm/alarmGIS.vue`(含配套 SCSS),菜单 2120 的 path=`waterSupplyAlarmGIS`、query=`{"rootTypeId":1}`、perms=`alarm:operations:list`;旧地图页面、模型及专用地图/台账补全/详情/历史接口删除,首页地图取数也使用统一 `/api/alarm/map`。地图与曲线允许供水/排水,仍校验授权菜单及设备类型树;执行 `migration_water_supply_alarm_gis.sql` 后重新登录(2026-09-24 最新要求,取代保留旧供水 GIS 的决定)。
 - `alarm_data.alarm_no` 改由后端程序新增时生成 `BJyyyyMMddHHmmss` 加 4 位随机数(北京时间),覆盖请求带入的号码,普通更新不改号;不使用数据库赋号触发器/默认值/序列,保留唯一索引、不回填旧数据。管理端新增/演练及采集新增都赋号,采集复用与恢复不改号。相关列表首列展示报警单号,转工单创建日志使用该号;报警触发和采集自动恢复由 Java 在同事务记报警日志,不依赖自定义函数/触发器。研判仅使用真实采样,日/月均聚合原始有效值,不补零;没有数值数据源的指标明确提示无曲线(2026-09-24 用户最新调整,替代数据库生成及雪花 ID 约定)。
 - 报警确认只写一条审核日志,描述包含选填意见及“已转工单(实际工单号)”;关联工单和工单操作不再单独写 `alarm_log`,仍保留正常 `work_order_log`。人工日志使用 `sys_user.nick_name`;审核页与详情页共用 `AlarmHistory.vue`,按时间、同一时间按 ID 倒序,日期分组样式参考 `dispose.vue`,保留图片预览。按最新要求,`zksy-system` 与 `zk-api-service` 各自定义 `AlarmLogOperTypeEnum`(0 触发/1 确认/2 误报/3 处理),两份值保持一致,不新增共享模块,移除工单相关类型,不删除旧类型历史行(2026-09-24)。
 - 报警审核附件只支持 JPG/JPEG/PNG 图片(最多 5 张、单张 10 MB),前后端禁止 PDF;处置记录以缩略图展示,点击查看大图,可切换同条记录的多张图片。
 - 报警详情的监测状态、审核结果、处置状态采用紧凑横排“普通字重标签 + 小号浅色状态标记”,不使用灰底大卡片或加粗标签;状态文本仍来自接口,处置颜色复用工单字典(2026-09-24 截图样式要求)。
 - 供水首页保留报警态势/趋势/占比图,使用统一 `/api/alarm/dashboard`(rootTypeId=1、days默认7,后端范围校验),不引用已删除的 `getAlarmLedgerDashboard` 或旧 `/waterSupply/alarm/ledger/dashboard`。后端按全量授权数据聚合,仅趋势限制日期,不用分页明细推算总量(2026-09-24 修复)。
 - 报警提醒服务固定只查 `alarm_status=0`,页面不提供“报警清单”操作。报警清单时间条件叫“报警时间”,设备提示“请输入设备编码/名称”,移除审核结果筛选但保留表格结果列。GIS 曲线选项和研判下拉仅展示 `actual_value` 非空事件(0 有效);地图点位、报警历史不受此限制,研判通过 `actualValueOnly=true` 后端先过滤再分页,无实际值深链接不能重新加入下拉(2026-09-24)。
+- 排水 GIS 地图接口固定只查 `alarm_data.alarm_status=0`,前端不提供监测状态筛选,设备编码/名称和等级筛选使用左标签右控件;设备列表、地图点位、等级统计同口径,详情报警记录保留历史。统计中灰色“报警设备”位于数量前,去掉右上角视图文案及详情底部状态说明(2026-09-24)。
 - 排水 GIS 样式参考 `prd/outputs/alarm-gis.html`:顶部筛选/等级统计、左侧设备列表、大地图及覆盖式详情抽屉,打开详情不挤占地图。统计展示当前结果的设备数,不是报警事件总数;沿用红橙蓝绿等级色、真实 GisMapCanvas/API 和框架分页。详情仅监测曲线、报警记录、设备信息,数值快照明确标注为触发值,不使用原型合成数据或周边环境内容(2026-09-24)。
 - 报警处置历史日期标题:当天只显示“今天”,前一天只显示“昨天”,不附加日期;同年其他日期显示“M月D日”,跨年保留年份。按本地日历日判断,悬停保留完整日期,每条操作仍显示时分秒,不改变倒序规则(2026-09-24 最新要求)。
 - 报警实际值为空时不展示实际值/阈值行,0 仍正常展示;审核转工单的 `work_order.order_desc` 保存根据报警信息生成的故障描述,审核意见单独保存在报警日志中。

+ 15 - 0
memory/sessions/2026-09-24.md

@@ -67,3 +67,18 @@
 - 资产状态编码存在两套口径:后端 `CurrentStatusEnum`(0-在用/1-维修/2-报废,统一设备模块与本次看板采用)与 `waterSupplyUiModel`/GIS 仍在用的 3-维修。两处不一致,后续需要收敛,否则维修中设备在 GIS 与异常看板可能判定不同。
 - 详情抽屉的「关联未办结工单」取自 `/waterSupply/device/gis/detail/{id}`(`waterSupply:device:gis` 权限);无该权限时抽屉降级为列表数据并给出重试提示。
 - 未验证真实登录、KingbaseES 实时数据与人工验收;异常看板在真实数据下的分页与导出仍待人工检查。
+## 报警公共页面增加 common 目录
+
+- 按要求将 `alarm/audit.vue`、`list.vue`、`remind.vue` 和完整 `utils`、`styles` 内容移动至 `alarm/common`;详情、历史、曲线等共享组件继续保留在 `alarm` 根目录。同步修改相对组件引用、工单字典引用、供水/排水工具与样式引用和既有测试路径,页面业务逻辑不变。
+- 后端 `AlarmReviewScopeService` 的审核/运营菜单组件匹配同步新路径,继续校验各子系统设备类型范围且区分读取与审核权限;初装审核/运营 SQL、SQL 回归期望及交付文档同步更新。
+- 新增 `pipe-network-service/sql/migration_alarm_common_components.sql`,仅按七个 menu_id(2118/2142/2097/2024/2117/2140/2139)更新 component 和更新时间,保留 path/query/权限及角色关联,可重复执行。
+- 验证:前端既有测试 25 项、后端范围测试 8 项通过;11 个相关 Vue SFC、共享 SCSS 编译通过,4 个工具模块及 60 处本地导入校验通过;两仓 diff 检查通过。
+- 风险与后续:未执行真实数据库 SQL,未验证真实登录页面,未部署或提交;上线需同步更新前后端、执行增量菜单脚本并重新登录。原有后端 IDE 配置和 `.feisuan/` 修改保留。
+
+## 报警清单顶部增加三项统计
+
+- 依截图在 `alarm/common/list.vue` 查询区上方新增三张白底卡片:报警总数(蓝)、监测异常(红)、监测已恢复(绿),配线性图标及说明,窄屏自动纵向排列。查询区、表格与分页模板逐字比较保持不变,样式限定在本页面新增区域。
+- 复用 `GET /api/alarm/statistics`,后端 `countByLevel` 聚合保留 level/total 并增加 abnormal/recovered,状态值通过 `AlarmRecordStatusEnum` 传入;统计沿用同一查询过滤和已授权设备类型范围,不接收分页参数。未知状态仅计入总数,不误算恢复。
+- 统计与列表独立加载;切换查询/子系统时丢弃旧响应,异常显示占位和重试,空结果显示 0,不等待统计才显示列表。
+- 验证:前端统计异步与提醒回归共 15 项、后端运营 service/mapper 共 19 项通过;Vue 脚本/模板/SCSS 编译和图标导出检查通过,两仓 diff 检查通过。未进行真实浏览器登录和 Kingbase 数据库联调。
+- 后续:前端与管理后端同步部署后生效;本次无新增 SQL、无表结构或菜单调整,未提交/推送/部署,保留既有目录迁移及其他工作区改动。

+ 0 - 5
src/api/pipeNetwork/waterSupply.js

@@ -16,11 +16,6 @@ export const exportLeakageMonitoring = (params) => request({ url: '/waterSupply/
 
 export const getAlarmStats = () => get('/waterSupply/alarm/stats')
 export const listRealtimeAlarms = () => get('/waterSupply/alarm/realtime')
-export const listAlarmHistory = (params) => get('/waterSupply/alarm/history', params)
-export const getAlarmDetail = (id) => get(`/waterSupply/alarm/detail/${id}`)
-export const listAlarmMapPoints = () => get('/waterSupply/alarm/map/points')
-// 供水报警一张图的状态补全查询
-export const listAlarmLedger = (params) => get('/waterSupply/alarm/ledger/page', params)
 
 // 供水设备 GIS 一张图(WaterDeviceGisMap / WaterSupplyHome 使用)
 export const getPipeNetworkDashboard = (params) => get('/waterSupply/pipeNetwork/statistics/dashboard', params)

+ 0 - 178
src/utils/waterAlarmGisModel.js

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

+ 1 - 1
src/utils/waterHomeModel.js

@@ -43,7 +43,7 @@ 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' }
+  { key: 'map', label: '监测报警一张图', suffix: '/waterSupplyAlarmGIS', query: { rootTypeId: WATER_DEVICE_ROOT_TYPE_ID }, icon: 'MapLocation' }
 ]
 
 /** 供水工单统计/列表的固定范围参数:设备父类别=供水。 */

+ 9 - 52
src/views/portal/index.vue

@@ -13,13 +13,6 @@
       </div>
     </header>
 
-    <!-- 中央核心区域 -->
-    <div class="core-area">
-      <div class="cloud-container">
-        <div class="cloud-bg"></div>
-      </div>
-    </div>
-
     <!-- 功能模块入口(3D椭圆旋转环) -->
     <div class="ring-3d-container">
       <div
@@ -41,8 +34,8 @@
   </div>
 </template>
 
-<script setup lang="ts">
-import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
+<script setup>
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { DEFAULT_BACKEND_HOME } from '@/router'
 import useUserStore from '@/store/modules/user'
@@ -74,26 +67,26 @@ const STEP_ANGLE = (2 * Math.PI) / 7
 const modules = ref([])
 
 // 【优化1】缓存模块样式,避免频繁计算
-const moduleStyles = reactive<Record<number, any>>({})
+const moduleStyles = reactive({})
 
 // 【优化2】预计算三角函数值缓存
-const sinCache = new Map<number, number>()
-const cosCache = new Map<number, number>()
+const sinCache = new Map()
+const cosCache = new Map()
 
-const getCachedSin = (angle: number) => {
+const getCachedSin = (angle) => {
   const key = Math.round(angle * 1000)
   if (!sinCache.has(key)) {
     sinCache.set(key, Math.sin(angle))
   }
-  return sinCache.get(key)!
+  return sinCache.get(key)
 }
 
-const getCachedCos = (angle: number) => {
+const getCachedCos = (angle) => {
   const key = Math.round(angle * 1000)
   if (!cosCache.has(key)) {
     cosCache.set(key, Math.cos(angle))
   }
-  return cosCache.get(key)!
+  return cosCache.get(key)
 }
 
 // 【优化3】批量更新所有模块样式
@@ -372,42 +365,6 @@ onUnmounted(() => {
   }
 }
 
-.core-area {
-  position: absolute;
-  top: 60%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  text-align: center;
-  z-index: 20;
-  pointer-events: none;
-
-  .cloud-container {
-    position: relative;
-
-    .cloud-bg {
-      width: 400px;
-      height: 400px;
-      margin: 0 auto;
-      position: relative;
-      background: url("../../assets/portal/conter.png") no-repeat center center;
-      background-size: 100% 100%;
-
-      /* 新增:动态发光 + 呼吸效果 */
-      animation: cloudGlow 1s ease-in-out infinite alternate;
-    }
-
-    /* 发光动画:柔和呼吸光效 */
-    @keyframes cloudGlow {
-      0% {
-        filter: brightness(1) drop-shadow(0 0 12px rgba(0, 191, 255, 1));
-      }
-      100% {
-        filter: brightness(1.5) drop-shadow(0 0 28px rgba(0, 191, 255, 1));
-      }
-    }
-  }
-}
-
 .ring-3d-container {
   position: absolute;
   top: 65%;

+ 1 - 1
src/views/subSystem/alarm/AlarmAttachments.vue

@@ -23,7 +23,7 @@
 
 <script setup>
 import { computed } from 'vue'
-import { imageAttachments } from './utils/alarmReviewModel'
+import { imageAttachments } from './common/utils/alarmReviewModel'
 
 const props = defineProps({
   value: { type: String, default: '' },

+ 1 - 1
src/views/subSystem/alarm/AlarmChart.vue

@@ -12,7 +12,7 @@
 import { ref, computed, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
 import * as echarts from 'echarts'
 import { getAlarmAnalysis } from '@/api/alarm'
-import { seriesPoints, sampleTime } from './utils/alarmOperationsModel'
+import { seriesPoints, sampleTime } from './common/utils/alarmOperationsModel'
 const props = defineProps({ alarmId: String, scope: { type: Object, required: true } })
 const period = ref('CURVE'), host = ref(), loading = ref(false), error = ref(''), result = ref({})
 const validCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.validCount), 0))

+ 3 - 3
src/views/subSystem/alarm/AlarmDetail.vue

@@ -21,10 +21,10 @@ import { ref, watch, onBeforeUnmount } from 'vue'
 import { getAlarm, getAlarmTimeline } from '@/api/alarm'
 import AlarmSummary from './AlarmSummary.vue'
 import AlarmHistory from './AlarmHistory.vue'
-import { auditStatusTone, levelTone, monitorStatusTone } from './utils/alarmOperationsModel'
+import { auditStatusTone, levelTone, monitorStatusTone } from './common/utils/alarmOperationsModel'
 import { orderStatusType } from '../pipeNetwork/ops/workOrder/workOrderDict'
-import { hasActualValue } from './utils/alarmReviewModel'
-import { useAlarmScope } from './utils/useAlarmScope'
+import { hasActualValue } from './common/utils/alarmReviewModel'
+import { useAlarmScope } from './common/utils/useAlarmScope'
 const { scope, scopeKey, menu, navigate } = useAlarmScope()
 const emit = defineEmits(['visible-change'])
 const visible = ref(false), loading = ref(false), alarm = ref(null), timeline = ref([]), error = ref('')

+ 1 - 1
src/views/subSystem/alarm/AlarmHistory.vue

@@ -20,7 +20,7 @@
 <script setup>
 import { computed } from 'vue'
 import AlarmAttachments from './AlarmAttachments.vue'
-import { groupAlarmHistory, historyDayLabel, historyTime } from './utils/alarmHistoryModel'
+import { groupAlarmHistory, historyDayLabel, historyTime } from './common/utils/alarmHistoryModel'
 
 const props = defineProps({
   records: { type: Array, default: () => [] },

+ 1 - 1
src/views/subSystem/alarm/AlarmSummary.vue

@@ -11,7 +11,7 @@
   </section>
 </template>
 <script setup>
-import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
+import { alarmDescription, thresholdText, valueText, hasActualValue } from './common/utils/alarmReviewModel'
 defineProps({ alarm: { type: Object, required: true }, levelTone: { type: String, default: 'info' } })
 </script>
 <style scoped>

+ 2 - 2
src/views/subSystem/alarm/index.vue → src/views/subSystem/alarm/common/audit.vue

@@ -61,8 +61,8 @@ import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { Refresh, Search, Upload } from '@element-plus/icons-vue'
 import { getAlarmReviewOptions, getAlarmReviewPage, getAlarmReviewStatistics, getAlarmReviewDetail, getAlarmLogs, submitAlarmReview, uploadAlarmReviewAttachment } from '@/api/alarmReview'
-import AlarmSummary from './AlarmSummary.vue'
-import AlarmHistory from './AlarmHistory.vue'
+import AlarmSummary from '../AlarmSummary.vue'
+import AlarmHistory from '../AlarmHistory.vue'
 import { alarmDescription, thresholdText, valueText, hasActualValue } from './utils/alarmReviewModel'
 
 const route = useRoute()

+ 78 - 7
src/views/subSystem/alarm/list.vue → src/views/subSystem/alarm/common/list.vue

@@ -1,5 +1,16 @@
 <template>
   <div class="alarm-page">
+    <section v-loading="statisticsLoading" class="alarm-list-statistics" aria-label="报警统计" :aria-busy="statisticsLoading">
+      <div v-for="card in statisticsCards" :key="card.key" class="alarm-list-stat-card" :class="`is-${card.tone}`">
+        <div class="stat-content">
+          <div class="stat-label">{{ card.label }}</div>
+          <div class="stat-value">{{ statistics?.[card.key] ?? '—' }}</div>
+          <div class="stat-caption">{{ card.caption }}</div>
+        </div>
+        <el-icon class="stat-icon" aria-hidden="true"><component :is="card.icon" /></el-icon>
+      </div>
+      <el-alert v-if="statisticsError" class="statistics-error" :title="statisticsError" type="error" show-icon :closable="false"><el-button link type="primary" @click="load">重新加载</el-button></el-alert>
+    </section>
     <el-card shadow="never" class="alarm-filter-card"><el-form :model="filters" inline class="alarm-filters" @submit.prevent="search">
       <el-form-item label="报警单号"><el-input v-model="filters.alarmNo" clearable placeholder="请输入报警单号" @keyup.enter="search" /></el-form-item>
       <el-form-item label="设备搜索"><el-input v-model="filters.keyword" clearable placeholder="请输入设备编码/名称" @keyup.enter="search" /></el-form-item>
@@ -19,8 +30,12 @@
         <el-table-column label="报警等级" width="105" align="center"><template #default="{ row }"><el-tag :type="levelTone(row.alarmLevel)">{{ row.alarmLevelName }}</el-tag></template></el-table-column>
         <el-table-column prop="createTime" label="报警时间" width="180" sortable="custom" />
         <el-table-column label="监测状态" width="100" align="center"><template #default="{ row }"><el-tag :type="row.alarmStatus === 0 ? 'danger' : 'success'">{{ row.monitorStatusName }}</el-tag></template></el-table-column>
-        <el-table-column prop="auditStatusName" label="审核结果" width="105" align="center" />
-        <el-table-column prop="orderStatusName" label="处置状态" width="100" align="center" />
+        <el-table-column prop="auditStatusName" label="审核结果" width="105" align="center">
+          <template #default="{ row }"><el-tag effect="light" :type="auditStatusTone(row.auditStatus)">{{ row.auditStatusName || '未知' }}</el-tag></template>
+        </el-table-column>
+        <el-table-column prop="orderStatusName" label="处置状态" width="100" align="center">
+          <template #default="{ row }"><el-tag effect="light" :type="row.workOrderId != null && row.orderStatus != null ? orderStatusType(row.orderStatus) : 'info'">{{ row.orderStatusName || '未转工单' }}</el-tag></template>
+        </el-table-column>
         <el-table-column prop="receiveUserName" label="责任人" width="100"><template #default="{ row }">{{ row.receiveUserName || '未分配' }}</template></el-table-column>
         <el-table-column label="操作" width="80" fixed="right" align="center"><template #default="{ row }"><el-button link type="primary" @click="detailRef.open(row.id)">详情</el-button></template></el-table-column>
         <template #empty><el-empty :description="error ? '数据加载失败' : '暂无匹配报警'" /></template>
@@ -31,22 +46,45 @@
 </template>
 <script setup name="AlarmEventList">
 import { ref, reactive, watch, onBeforeUnmount } from 'vue'
-import { Search, Refresh } from '@element-plus/icons-vue'
-import { getAlarms, getAlarmOptions } from '@/api/alarm'
+import { Search, Refresh, Document, Warning, CircleCheck } from '@element-plus/icons-vue'
+import { getAlarms, getAlarmOptions, getAlarmStatistics } from '@/api/alarm'
 import { alarmDescription, hasActualValue, valueText, thresholdText } from './utils/alarmReviewModel'
-import { levelTone, rangeParams } from './utils/alarmOperationsModel'
+import { auditStatusTone, levelTone, rangeParams } from './utils/alarmOperationsModel'
+import { orderStatusType } from '../../pipeNetwork/ops/workOrder/workOrderDict'
 import { useAlarmScope } from './utils/useAlarmScope'
-import AlarmDetail from './AlarmDetail.vue'
+import AlarmDetail from '../AlarmDetail.vue'
 import './styles/alarmOperations.scss'
 const { scope, scopeKey, route } = useAlarmScope()
 const empty = () => ({ alarmNo: '', keyword: '', alarmLevel: undefined, alarmStatus: undefined })
 const filters = reactive(empty()), dates = ref([]), options = ref({}), rows = ref([]), total = ref(0)
 const pageNum = ref(1), pageSize = ref(10), ascending = ref(false), loading = ref(false), error = ref(''), detailRef = ref()
+const statistics = ref(null), statisticsLoading = ref(false), statisticsError = ref('')
+const statisticsCards = [
+  { key: 'total', label: '报警总数', caption: '当前查询范围 · 按事件计数', tone: 'primary', icon: Document },
+  { key: 'abnormal', label: '监测异常', caption: '当前查询范围 · 按事件计数', tone: 'danger', icon: Warning },
+  { key: 'recovered', label: '监测已恢复', caption: '监测恢复不代表处置完成', tone: 'success', icon: CircleCheck }
+]
 let generation = 0
+async function loadStatistics(params, token) {
+  statisticsLoading.value = true; statisticsError.value = ''; statistics.value = null
+  try {
+    const result = await getAlarmStatistics(params)
+    if (token !== generation) return
+    statistics.value = (result.data || []).reduce((counts, row) => ({
+      total: counts.total + Number(row.total || 0),
+      abnormal: counts.abnormal + Number(row.abnormal || 0),
+      recovered: counts.recovered + Number(row.recovered || 0)
+    }), { total: 0, abnormal: 0, recovered: 0 })
+  } catch (e) { if (token === generation) statisticsError.value = e.message || '统计加载失败,请重试' }
+  finally { if (token === generation) statisticsLoading.value = false }
+}
 async function load() {
   const token = ++generation; loading.value = true; error.value = ''
+  const params = { ...filters, ...rangeParams(dates.value), ...scope.value }
+  // 统计覆盖全部筛选结果,独立加载,不阻塞列表显示。
+  loadStatistics(params, token)
   try {
-    const result = await getAlarms({ ...filters, ...rangeParams(dates.value), ...scope.value, pageNum: pageNum.value, pageSize: pageSize.value, ascending: ascending.value })
+    const result = await getAlarms({ ...params, pageNum: pageNum.value, pageSize: pageSize.value, ascending: ascending.value })
     if (token === generation) { rows.value = result.data.records || []; total.value = result.data.total || 0 }
   } catch (e) { if (token === generation) { rows.value = []; total.value = 0; error.value = e.message || '加载失败' } }
   finally { if (token === generation) loading.value = false }
@@ -56,6 +94,7 @@ function reset() { Object.assign(filters, empty()); dates.value = []; search() }
 function sort({ order }) { ascending.value = order === 'ascending'; search() }
 watch(scopeKey, async () => {
   ++generation; rows.value = []; total.value = 0; loading.value = false
+  statistics.value = null; statisticsLoading.value = false; statisticsError.value = ''
   Object.assign(filters, empty()); dates.value = []; options.value = {}
   const key = scopeKey.value
   try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} } catch (e) { error.value = e.message }
@@ -66,3 +105,35 @@ watch(scopeKey, async () => {
 onBeforeUnmount(() => { ++generation })
 watch(() => route.query.alarmId, id => { if (id) detailRef.value?.open(id) })
 </script>
+
+<style scoped lang="scss">
+.alarm-list-statistics {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+  margin-bottom: 20px;
+}
+.alarm-list-stat-card {
+  --stat-color: var(--el-color-primary);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 22px 24px;
+  background: #fff;
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 8px;
+
+  &.is-danger { --stat-color: var(--el-color-danger); }
+  &.is-success { --stat-color: var(--el-color-success); }
+}
+.stat-label { color: #303133; font-size: 14px; line-height: 22px; }
+.stat-value { margin: 4px 0; color: var(--stat-color); font-size: 30px; font-weight: 600; line-height: 36px; font-variant-numeric: tabular-nums; }
+.stat-caption { color: #909399; font-size: 12px; line-height: 20px; }
+.stat-icon { flex-shrink: 0; color: var(--stat-color); font-size: 26px; }
+.statistics-error { grid-column: 1 / -1; }
+@media (max-width: 768px) {
+  .alarm-list-statistics { grid-template-columns: 1fr; gap: 12px; }
+  .alarm-list-stat-card { padding: 16px 20px; }
+}
+</style>

+ 43 - 12
src/views/subSystem/alarm/remind.vue → src/views/subSystem/alarm/common/remind.vue

@@ -2,10 +2,10 @@
   <div class="alarm-page">
     <div class="alarm-stats"><div v-for="v in options.alarmLevels || []" :key="v.value" class="alarm-stat" :style="{ '--level-color': levelColor(v.value) }"><span>{{ v.label }}</span><strong>{{ count(v.value) }}</strong><span class="muted">当前未恢复报警事件</span></div></div>
     <el-card shadow="never">
-      <div class="alarm-toolbar"><div><el-tag :type="error ? 'danger' : 'success'">{{ error ? '刷新失败' : '定时刷新已开启' }}</el-tag><span class="muted"> · 每 30 秒刷新 · {{ refreshedAt || '正在加载' }}</span></div><el-button :icon="manualPause ? VideoPlay : VideoPause" @click="manualPause = !manualPause">{{ manualPause ? '恢复滚动' : '暂停滚动' }}</el-button></div>
+      <div class="alarm-toolbar"><div><el-tag :type="refreshError ? 'danger' : 'success'">{{ refreshError ? '刷新失败' : '定时刷新已开启' }}</el-tag><span class="muted"> · 每 30 秒刷新 · {{ refreshedAt || '正在加载' }}</span></div><el-button :icon="manualPause ? VideoPlay : VideoPause" @click="manualPause = !manualPause">{{ manualPause ? '恢复滚动' : '暂停滚动' }}</el-button></div>
       <div class="alarm-toolbar"><el-form inline class="alarm-filters"><el-form-item label="报警等级"><el-select v-model="level" clearable placeholder="全部等级" @change="restart"><el-option v-for="v in options.alarmLevels || []" :key="v.value" :label="v.label" :value="v.value" /></el-select></el-form-item></el-form><span class="muted">{{ pending ? `有 ${pending} 条更新,恢复后显示` : `最近更新的 ${visibleRows.length} 条事件` }}</span></div>
-      <el-alert v-if="error" :title="error" show-icon type="error" :closable="false"><el-button link @click="load">重新加载</el-button></el-alert>
-      <div ref="feed" v-loading="loading" class="reminder-feed" tabindex="0" @mouseenter="hoverPause = true" @mouseleave="hoverPause = false" @focusin="focusPause = true" @focusout="focusPause = false">
+      <el-alert v-if="refreshError" :title="refreshError" show-icon type="error" :closable="false"><el-button link @click="retry">重新加载</el-button></el-alert>
+      <div ref="feed" v-loading="loading && !hasLoaded" class="reminder-feed" tabindex="0" @mouseenter="hoverPause = true" @mouseleave="hoverPause = false" @focusin="focusPause = true" @focusout="focusPause = false">
         <article v-for="row in visibleRows" :key="row.id" class="reminder-message" :style="{ borderLeftColor: levelColor(row.alarmLevel) }">
           <div class="alarm-toolbar"><strong>{{ row.deviceName || row.deviceCode }} <el-tag :type="levelTone(row.alarmLevel)">{{ row.alarmLevelName }}</el-tag></strong><span class="muted">{{ row.latestOperationTime || row.createTime }}</span></div>
           <div class="muted">报警单号:{{ row.alarmNo || '—' }} · {{ row.deviceCode }} · {{ row.equipmentLocation || '暂无安装位置' }}</div>
@@ -25,12 +25,14 @@ import { getAlarmReminders, getAlarmOptions, getAlarmStatistics } from '@/api/al
 import { levelTone, levelColor } from './utils/alarmOperationsModel'
 import { alarmDescription } from './utils/alarmReviewModel'
 import { useAlarmScope } from './utils/useAlarmScope'
-import AlarmDetail from './AlarmDetail.vue'
+import AlarmDetail from '../AlarmDetail.vue'
 import './styles/alarmOperations.scss'
 const { scope, scopeKey } = useAlarmScope()
 const options = ref({}), stats = ref([]), level = ref(), visibleRows = ref([]), received = ref([])
 const manualPause = ref(false), hoverPause = ref(false), focusPause = ref(false), feed = ref(), detailRef = ref()
 const loading = ref(false), error = ref(''), refreshedAt = ref(''), active = ref(true)
+const hasLoaded = ref(false), statisticsError = ref(''), optionsError = ref('')
+const refreshError = computed(() => error.value || statisticsError.value || optionsError.value)
 const detailPause = ref(false)
 const paused = computed(() => manualPause.value || hoverPause.value || focusPause.value || detailPause.value)
 const pending = computed(() => received.value.filter(row => !visibleRows.value.some(old => old.id === row.id && old.latestOperationTime === row.latestOperationTime)).length)
@@ -39,24 +41,53 @@ let generation = 0, poll, scroll
 async function load() {
   if (!active.value || loading.value) return
   const token = ++generation; loading.value = true
+  const params = { ...scope.value, alarmStatus: 0 }
+  // 统计独立刷新,列表不等待统计接口完成。
+  loadStatistics(params, token)
   try {
-    const [page, summary] = await Promise.all([getAlarmReminders({ ...scope.value, alarmStatus: 0, alarmLevel: level.value, pageNum: 1, pageSize: 100 }), getAlarmStatistics({ ...scope.value, alarmStatus: 0 })])
+    const page = await getAlarmReminders({ ...params, alarmLevel: level.value, pageNum: 1, pageSize: 100 })
     if (token !== generation) return
-    received.value = page.data.records || []; stats.value = summary.data || []; error.value = ''
-    if (!paused.value) visibleRows.value = received.value
+    received.value = page.data.records || []; error.value = ''
+    // 首次进入、重新筛选或空列表收到数据时,暂停阅读不应阻止显示。
+    if (!hasLoaded.value || !visibleRows.value.length || !paused.value) visibleRows.value = received.value
+    hasLoaded.value = true
     refreshedAt.value = new Date().toLocaleTimeString()
   } catch (e) { if (token === generation) error.value = e.message || '刷新失败,请重试' }
   finally { if (token === generation) loading.value = false }
 }
-function restart() { ++generation; loading.value = false; visibleRows.value = []; received.value = []; load() }
-watch(paused, value => { if (!value) visibleRows.value = received.value })
-watch(scopeKey, async () => { restart(); const key = scopeKey.value; try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} } catch (e) { error.value = e.message } }, { immediate: true })
+async function loadStatistics(params, token) {
+  try {
+    const summary = await getAlarmStatistics(params)
+    if (token === generation) { stats.value = summary.data || []; statisticsError.value = '' }
+  } catch (e) { if (token === generation) statisticsError.value = e.message || '等级统计刷新失败,请重试' }
+}
+async function loadOptions() {
+  const key = scopeKey.value
+  try {
+    const result = await getAlarmOptions({ ...scope.value })
+    if (key === scopeKey.value) { options.value = result.data || {}; optionsError.value = '' }
+  } catch (e) { if (key === scopeKey.value) optionsError.value = e.message || '报警等级加载失败,请重试' }
+}
+function restart() {
+  ++generation; loading.value = false; hasLoaded.value = false
+  visibleRows.value = []; received.value = []; refreshedAt.value = ''; error.value = ''; statisticsError.value = ''
+  return load()
+}
+function retry() { load(); if (optionsError.value) loadOptions() }
+watch(paused, value => { if (!value && active.value) visibleRows.value = received.value })
+watch(scopeKey, () => { stats.value = []; options.value = {}; optionsError.value = ''; restart(); loadOptions() }, { immediate: true })
 onMounted(() => {
   poll = setInterval(load, 30000)
   scroll = setInterval(() => { const el = feed.value; if (!active.value || paused.value || !el || el.scrollHeight <= el.clientHeight) return; el.scrollTop = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 ? 0 : el.scrollTop + 1 }, 70)
 })
-onActivated(() => { active.value = true; load() })
-onDeactivated(() => { active.value = false; ++generation; loading.value = false })
+onActivated(() => {
+  if (active.value) return
+  active.value = true; hasLoaded.value = false; load()
+})
+onDeactivated(() => {
+  active.value = false; ++generation; loading.value = false
+  hoverPause.value = false; focusPause.value = false; detailPause.value = false
+})
 onBeforeUnmount(() => { ++generation; clearInterval(poll); clearInterval(scroll) })
 </script>
 <style scoped>

+ 0 - 0
src/views/subSystem/alarm/styles/alarmOperations.scss → src/views/subSystem/alarm/common/styles/alarmOperations.scss


+ 1 - 1
src/views/subSystem/alarm/utils/alarmHistoryModel.js → src/views/subSystem/alarm/common/utils/alarmHistoryModel.js

@@ -28,7 +28,7 @@ export function historyDayLabel(value, now = new Date()) {
 // 保留接口的微秒时间精度;同一时间按字符串主键比较,避免 int8 转 Number 丢失精度。
 export function groupAlarmHistory(records = []) {
   const sorted = [...records].sort((a, b) => {
-    const dateOrder = historyDate(b.operTime).localeCompre(historyDate(a.operTime))
+    const dateOrder = historyDate(b.operTime).localeCompare(historyDate(a.operTime))
     return dateOrder || String(b.logId ?? '').localeCompare(String(a.logId ?? ''), 'en', { numeric: true })
   })
   const groups = new Map()

+ 0 - 0
src/views/subSystem/alarm/utils/alarmOperationsModel.js → src/views/subSystem/alarm/common/utils/alarmOperationsModel.js


+ 0 - 0
src/views/subSystem/alarm/utils/alarmReviewModel.js → src/views/subSystem/alarm/common/utils/alarmReviewModel.js


+ 0 - 0
src/views/subSystem/alarm/utils/useAlarmScope.js → src/views/subSystem/alarm/common/utils/useAlarmScope.js


+ 11 - 13
src/views/subSystem/alarm/drainage/alarmGIS.scss → src/views/subSystem/drainage/alarm/alarmGIS.scss

@@ -2,22 +2,21 @@
 .gis-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
 .gis-page-heading h1 { margin: 0 0 8px; font-size: 22px; font-weight: 600; letter-spacing: .3px; }
 .gis-page-heading p { margin: 0; color: #7b8799; font-size: 13px; line-height: 1.6; }
-.gis-view-label { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; padding: 6px 10px; color: #527197; background: #edf4ff; border: 1px solid #dce8f8; border-radius: 5px; font-size: 12px; }
 .gis-filter-card, .gis-workspace { background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; box-shadow: 0 2px 5px #273f5d03; }
 .gis-filter-card { padding: 16px 20px 20px; margin-bottom: 16px; }
-.gis-filters { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(140px, 180px) minmax(160px, 200px) auto; align-items: end; gap: 16px 20px; }
+.gis-filters { display: grid; grid-template-columns: minmax(272px, 332px) minmax(212px, 252px) auto; justify-content: start; align-items: center; gap: 16px 20px; }
 .gis-filters :deep(.el-form-item) { margin: 0; min-width: 0; }
-.gis-filters :deep(.el-form-item__label) { height: auto; margin-bottom: 8px; font-size: 12px; font-weight: 400; color: #5f6d81; line-height: 18px; }
+.gis-filters :deep(.el-form-item__label) { height: 32px; margin-bottom: 0; font-size: 12px; font-weight: 400; color: #5f6d81; line-height: 32px; }
 .gis-filters :deep(.el-select), .gis-filters :deep(.el-input) { width: 100%; }
 .gis-filter-actions { display: flex; white-space: nowrap; }
 .gis-level-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
-.gis-level-metric { display: flex; align-items: center; gap: 12px; min-height: 82px; padding: 16px 18px; background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; }
+.gis-level-metric { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 6px 12px; min-height: 82px; padding: 16px 18px; background: #fff; border: 1px solid #e6eaf0; border-radius: 8px; }
 .gis-grade-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid var(--grade-color); border-radius: 50%; background: var(--grade-tint); color: var(--grade-color); font-size: 14px; font-weight: 600; }
 .gis-metric-label { min-width: 0; font-size: 13px; line-height: 1.4; }
-.gis-metric-label small { display: block; margin-top: 6px; color: #8a95a5; font-size: 11px; }
-@media (max-width: 680px) { .gis-metric-label small span { display: none; } }
-.gis-level-metric > strong { margin-left: auto; color: #293b53; font-size: 26px; font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
-.gis-level-metric > strong small { margin-left: 5px; font-size: 12px; font-weight: 400; color: #8a95a5; }
+.gis-metric-value { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: flex-end; flex-wrap: wrap; gap: 4px 8px; }
+.gis-metric-value > span { color: #8a95a5; font-size: 12px; }
+.gis-metric-value strong { color: #293b53; font-size: 26px; font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.gis-metric-value strong small { margin-left: 5px; font-size: 12px; font-weight: 400; color: #8a95a5; }
 .gis-workspace { overflow: hidden; }
 .gis-workspace-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #e6eaf0; }
 .gis-workspace-heading > div { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
@@ -117,12 +116,11 @@
 .gis-detail-content :deep(.el-pagination) { justify-content: flex-end; flex-wrap: wrap; gap: 6px; }
 .gis-device-images { margin-top: 24px; }
 .gis-device-images h3 { margin-bottom: 14px; }
-.gis-drawer-footer { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
-.gis-drawer-footer > span { color: #98a3b2; font-size: 11px; }
+.gis-drawer-footer { display: flex; justify-content: flex-end; align-items: center; gap: 14px; }
 .gis-cluster-list { display: grid; gap: 8px; }
 .gis-cluster-list button { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid #e7edf5; background: #f8faff; border-radius: 5px; text-align: left; color: #526581; cursor: pointer; font: inherit; font-size: 13px; }
 .gis-cluster-list button span { flex: 1; }
 .gis-cluster-list small { color: #95a1b2; }
-@media (max-width: 1250px) { .gis-filters { grid-template-columns: minmax(200px, 1fr) 140px 170px auto; gap: 16px; } .gis-level-metric { padding: 14px 12px; gap: 8px; } .gis-workspace-body { grid-template-columns: 270px minmax(0, 1fr); } .gis-map-note { display: none; } }
-@media (max-width: 1000px) { .gis-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gis-filter-actions { align-self: end; } .gis-level-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gis-workspace-hint { display: none; } .gis-map-status { top: 62px; } .gis-map-fallback { inset: 110px 18px 95px; } }
-@media (max-width: 680px) { .gis-page-heading h1 { font-size: 19px; } .gis-view-label { display: none; } .gis-filter-card { padding: 14px; } .gis-search-field { grid-column: 1 / -1; } .gis-filter-actions { grid-column: 1 / -1; justify-content: flex-end; } .gis-level-metric { gap: 7px; padding: 12px 10px; } .gis-level-metric > strong { font-size: 23px; } .gis-metric-label { font-size: 12px; } .gis-metric-label small { font-size: 10px; } .gis-level-metric .gis-grade-badge { width: 25px; height: 25px; } .gis-workspace-body { display: flex; flex-direction: column; height: auto; } .gis-device-pane { height: 280px; border-right: 0; border-bottom: 1px solid #e6eaf0; } .gis-map-stage { height: 450px; } .gis-workspace-heading { padding: 14px; } .gis-workspace-footer { padding: 12px; } .gis-workspace-footer span:last-child { display: none; } .gis-detail-grid { gap: 16px; } .gis-snapshot { gap: 14px; } .gis-detail-banner { align-items: flex-start; } :global(.alarm-gis-drawer .el-drawer__body) { padding: 18px; } .gis-drawer-footer > span { max-width: 190px; line-height: 1.6; } }
+@media (max-width: 1250px) { .gis-filters { gap: 16px; } .gis-level-metric { padding: 14px 12px; gap: 8px; } .gis-workspace-body { grid-template-columns: 270px minmax(0, 1fr); } .gis-map-note { display: none; } }
+@media (max-width: 1000px) { .gis-filters { grid-template-columns: minmax(0, 332px) minmax(0, 252px); } .gis-filter-actions { grid-column: 1 / -1; justify-content: flex-end; } .gis-level-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gis-workspace-hint { display: none; } .gis-map-status { top: 62px; } .gis-map-fallback { inset: 110px 18px 95px; } }
+@media (max-width: 680px) { .gis-page-heading h1 { font-size: 19px; } .gis-filter-card { padding: 14px; } .gis-filters { grid-template-columns: minmax(0, 1fr); } .gis-level-metric { gap: 7px; padding: 12px 10px; } .gis-metric-value strong { font-size: 23px; } .gis-metric-value > span { font-size: 11px; } .gis-metric-label { font-size: 12px; } .gis-level-metric .gis-grade-badge { width: 25px; height: 25px; } .gis-workspace-body { display: flex; flex-direction: column; height: auto; } .gis-device-pane { height: 280px; border-right: 0; border-bottom: 1px solid #e6eaf0; } .gis-map-stage { height: 450px; } .gis-workspace-heading { padding: 14px; } .gis-workspace-footer { padding: 12px; } .gis-workspace-footer span:last-child { display: none; } .gis-detail-grid { gap: 16px; } .gis-snapshot { gap: 14px; } .gis-detail-banner { align-items: flex-start; } :global(.alarm-gis-drawer .el-drawer__body) { padding: 18px; } }

+ 16 - 17
src/views/subSystem/alarm/drainage/alarmGIS.vue → src/views/subSystem/drainage/alarm/alarmGIS.vue

@@ -6,10 +6,9 @@
     </header>
 
     <section class="gis-filter-card">
-      <el-form class="gis-filters" label-position="top" @submit.prevent="load">
-        <el-form-item label="设备搜索" class="gis-search-field"><el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="搜索设备编码、名称或安装位置" @keyup.enter="load" /></el-form-item>
+      <el-form class="gis-filters" label-position="left" label-width="72px" @submit.prevent="load">
+        <el-form-item label="设备搜索" class="gis-search-field"><el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="请输入设备编码/名称" @keyup.enter="load" /></el-form-item>
         <el-form-item label="报警等级"><el-select v-model="level" clearable placeholder="全部等级"><el-option v-for="v in options.alarmLevels || []" :key="v.value" :value="v.value" :label="v.label" /></el-select></el-form-item>
-        <el-form-item label="监测状态"><el-select v-model="status" clearable placeholder="全部状态"><el-option v-for="v in options.monitorStatuses || []" :key="v.value" :value="v.value" :label="v.value === 0 ? '当前报警(未恢复)' : v.label" /></el-select></el-form-item>
         <div class="gis-filter-actions"><el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></div>
       </el-form>
     </section>
@@ -17,14 +16,14 @@
     <div class="gis-level-metrics" aria-label="当前查询结果的设备等级统计" :aria-busy="loading">
       <article v-for="item in levelMetrics" :key="item.value" class="gis-level-metric" :style="levelStyle(item.value)">
         <span class="gis-grade-badge">{{ item.value }}</span>
-        <div class="gis-metric-label"><span>{{ item.label }}</span><small><span>当前结果 · </span>报警设备</small></div>
-        <strong>{{ loading || error ? '—' : item.count }}<small>台</small></strong>
+        <div class="gis-metric-label">{{ item.label }}</div>
+        <div class="gis-metric-value"><span>报警设备</span><strong>{{ loading || error ? '—' : item.count }}<small>台</small></strong></div>
       </article>
     </div>
     <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="load-error"><el-button link @click="load">重新加载</el-button></el-alert>
 
     <section class="gis-workspace">
-      <header class="gis-workspace-heading"><div><el-icon><MapLocation /></el-icon><h2>报警设备分布</h2><span>{{ devices.length }} 台设备 / {{ rows.length }} 条报警</span></div><span class="gis-workspace-hint">单设备单点 · 最高等级展示</span></header>
+      <header class="gis-workspace-heading"><div><el-icon><MapLocation /></el-icon><h2>报警设备分布</h2><span>{{ devices.length }} 台设备 / {{ rows.length }} 条报警</span></div></header>
       <div class="gis-workspace-body">
         <aside class="gis-device-pane" aria-label="报警设备列表" v-loading="loading">
           <div class="gis-list-heading"><span>筛选结果 <b>{{ devices.length }}</b> 台</span><span>等级优先</span></div>
@@ -96,7 +95,7 @@
           </el-tab-pane>
         </el-tabs>
       </div>
-      <template #footer><div class="gis-drawer-footer"><span>设备、报警、处置状态分别记录</span><el-button @click="detailVisible = false">关闭</el-button></div></template>
+      <template #footer><div class="gis-drawer-footer"><el-button @click="detailVisible = false">关闭</el-button></div></template>
     </el-drawer>
     <el-dialog :model-value="cluster.length > 0" title="聚合点位" width="min(440px, 94vw)" @close="cluster = []"><div class="gis-cluster-list"><button v-for="device in cluster" :key="device.key" type="button" @click="choose(device); cluster = []"><span>{{ device.name }}</span><small>{{ device.alarms.length }} 条报警</small><el-icon><ArrowRight /></el-icon></button></div></el-dialog>
     <AlarmDetail ref="detailRef" />
@@ -108,17 +107,17 @@ import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
 import { Search, Refresh, MapLocation, Location, Aim, Plus, Minus, ArrowRight, Warning, Loading } from '@element-plus/icons-vue'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
 import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
-import AlarmDetail from '../AlarmDetail.vue'
-import AlarmChart from '../AlarmChart.vue'
-import AlarmAttachments from '../AlarmAttachments.vue'
-import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../utils/alarmOperationsModel'
-import { alarmDescription, hasActualValue, thresholdText, valueText } from '../utils/alarmReviewModel'
-import { useAlarmScope } from '../utils/useAlarmScope'
-import '../styles/alarmOperations.scss'
+import AlarmDetail from '../../alarm/AlarmDetail.vue'
+import AlarmChart from '../../alarm/AlarmChart.vue'
+import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
+import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
 
 const { scope, scopeKey } = useAlarmScope()
 const baseUrl = import.meta.env.VITE_APP_BASE_API
-const keyword = ref(''), level = ref(), status = ref(0), options = ref({}), rows = ref([]), selected = ref(null), tab = ref('curve'), curveId = ref('')
+const keyword = ref(''), level = ref(), options = ref({}), rows = ref([]), selected = ref(null), tab = ref('curve'), curveId = ref('')
 const loading = ref(false), error = ref(''), mapError = ref(''), mapReady = ref(false), mapKey = ref(0), mapRef = ref(), detailRef = ref(), cluster = ref([])
 const detailVisible = ref(false)
 const devices = computed(() => groupAlarmDevices(rows.value).sort((a, b) => a.alarmLevel - b.alarmLevel || String(a.deviceCode).localeCompare(String(b.deviceCode))))
@@ -149,7 +148,7 @@ let generation = 0
 async function load() {
   const token = ++generation; loading.value = true; error.value = ''; detailVisible.value = false; selected.value = null; cluster.value = []
   try {
-    const result = await getAlarmMap({ ...scope.value, keyword: keyword.value, alarmLevel: level.value, alarmStatus: status.value })
+    const result = await getAlarmMap({ ...scope.value, keyword: keyword.value, alarmLevel: level.value, alarmStatus: 0 })
     if (token === generation) { rows.value = result.data || []; await nextTick(); mapRef.value?.fitBounds() }
   } catch (e) { if (token === generation) { error.value = e.message || '点位加载失败'; rows.value = [] } }
   finally { if (token === generation) loading.value = false }
@@ -165,7 +164,7 @@ function choose(device) {
 function ready() { mapError.value = ''; mapReady.value = true; mapRef.value?.fitBounds() }
 function failMap(message) { mapReady.value = false; mapError.value = message || '请检查网络或地图服务配置,稍后重试。' }
 function retryMap() { mapError.value = ''; mapReady.value = false; ++mapKey.value }
-function reset() { keyword.value = ''; level.value = undefined; status.value = 0; return load() }
+function reset() { keyword.value = ''; level.value = undefined; return load() }
 watch(scopeKey, async () => {
   ++generation; rows.value = []; selected.value = null; detailVisible.value = false; loading.value = false; error.value = ''; options.value = {}
   if (scope.value.rootTypeId !== 2) { error.value = '该地图仅适用于排水子系统'; return }

+ 7 - 7
src/views/subSystem/alarm/drainage/analysis.vue → src/views/subSystem/drainage/alarm/analysis.vue

@@ -12,13 +12,13 @@
 <script setup name="DrainageAlarmAnalysis">
 import { ref, watch, onBeforeUnmount } from 'vue'
 import { getAlarms, getAlarm } from '@/api/alarm'
-import AlarmSummary from '../AlarmSummary.vue'
-import AlarmChart from '../AlarmChart.vue'
-import AlarmDetail from '../AlarmDetail.vue'
-import { alarmCurveChoices, levelTone } from '../utils/alarmOperationsModel'
-import { hasActualValue } from '../utils/alarmReviewModel'
-import { useAlarmScope } from '../utils/useAlarmScope'
-import '../styles/alarmOperations.scss'
+import AlarmSummary from '../../alarm/AlarmSummary.vue'
+import AlarmChart from '../../alarm/AlarmChart.vue'
+import AlarmDetail from '../../alarm/AlarmDetail.vue'
+import { alarmCurveChoices, levelTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { hasActualValue } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
 const { scope, scopeKey, route } = useAlarmScope()
 const choices = ref([]), selectedId = ref(''), alarm = ref(null), loading = ref(false), searching = ref(false), error = ref(''), detailRef = ref()
 let generation = 0, searchGeneration = 0

+ 126 - 0
src/views/subSystem/waterSupply/alarm/alarmGIS.scss

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

+ 180 - 0
src/views/subSystem/waterSupply/alarm/alarmGIS.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="alarm-page alarm-gis-page">
+    <header class="gis-page-heading">
+      <div><h1>当前报警 GIS 一张图</h1><p>按设备查看报警分布,关联监测曲线与报警记录</p></div>
+      <span class="gis-view-label"><el-icon><MapLocation /></el-icon>供水监测视图</span>
+    </header>
+
+    <section class="gis-filter-card">
+      <el-form class="gis-filters" label-position="left" label-width="72px" @submit.prevent="load">
+        <el-form-item label="设备搜索" class="gis-search-field"><el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="请输入设备编码/名称" @keyup.enter="load" /></el-form-item>
+        <el-form-item label="报警等级"><el-select v-model="level" clearable placeholder="全部等级"><el-option v-for="v in options.alarmLevels || []" :key="v.value" :value="v.value" :label="v.label" /></el-select></el-form-item>
+        <div class="gis-filter-actions"><el-button type="primary" :icon="Search" :loading="loading" @click="load">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></div>
+      </el-form>
+    </section>
+
+    <div class="gis-level-metrics" aria-label="当前查询结果的设备等级统计" :aria-busy="loading">
+      <article v-for="item in levelMetrics" :key="item.value" class="gis-level-metric" :style="levelStyle(item.value)">
+        <span class="gis-grade-badge">{{ item.value }}</span>
+        <div class="gis-metric-label">{{ item.label }}</div>
+        <div class="gis-metric-value"><span>报警设备</span><strong>{{ loading || error ? '—' : item.count }}<small>台</small></strong></div>
+      </article>
+    </div>
+    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="load-error"><el-button link @click="load">重新加载</el-button></el-alert>
+
+    <section class="gis-workspace">
+      <header class="gis-workspace-heading"><div><el-icon><MapLocation /></el-icon><h2>报警设备分布</h2><span>{{ devices.length }} 台设备 / {{ rows.length }} 条报警</span></div></header>
+      <div class="gis-workspace-body">
+        <aside class="gis-device-pane" aria-label="报警设备列表" v-loading="loading">
+          <div class="gis-list-heading"><span>筛选结果 <b>{{ devices.length }}</b> 台</span><span>等级优先</span></div>
+          <div class="gis-device-list">
+            <article v-for="device in devices" :key="device.key" :class="['gis-device-item', { 'is-selected': selected?.key === device.key }]" :style="levelStyle(device.alarmLevel)">
+              <div class="gis-device-title"><span class="gis-grade-badge">{{ device.alarmLevel }}</span><button type="button" class="gis-device-name" @click="choose(device)">{{ device.name }}</button></div>
+              <p class="gis-device-code">{{ device.deviceCode }}</p>
+              <div class="gis-device-location"><el-icon><Location /></el-icon><span>{{ device.equipmentLocation || '暂无安装位置' }}</span></div>
+              <div class="gis-device-meta"><span>{{ levelLabel(device.alarmLevel) }}</span><span>{{ device.alarms.length }} 条报警</span></div>
+              <div class="gis-device-actions"><span v-if="!device.hasCoordinates" class="gis-coordinate-warning">缺少有效坐标</span><span v-else class="gis-coordinate-ready"><i></i>已关联点位</span><div><el-button link type="primary" :disabled="!device.hasCoordinates || !mapReady" @click="locate(device)">定位</el-button><el-button link type="primary" @click="choose(device)">详情<el-icon><ArrowRight /></el-icon></el-button></div></div>
+            </article>
+            <el-empty v-if="!devices.length && !loading" :description="error ? '设备列表加载失败' : '暂无匹配的报警设备'" :image-size="72"><el-button v-if="!error" @click="reset">重置筛选</el-button></el-empty>
+          </div>
+        </aside>
+
+        <div class="gis-map-stage">
+          <GisMapCanvas :key="mapKey" ref="mapRef" :points="points" :marker-size="36" :map-style-id="''" @ready="ready" @error="failMap" @point-click="choose" @cluster-click="cluster = $event.points || []" />
+          <div class="gis-map-status" role="status"><i :class="{ 'is-ready': mapReady, 'is-error': mapError }"></i>{{ mapError ? '底图未就绪 · 列表可用' : mapReady ? '地图已就绪' : '正在加载底图' }}</div>
+          <div class="gis-map-tools" aria-label="地图工具">
+            <el-tooltip content="查看全部点位" placement="bottom"><el-button :icon="Aim" :disabled="!mapReady || !points.length" aria-label="查看全部点位" @click="mapRef?.fitBounds()" /></el-tooltip>
+            <el-tooltip content="放大地图" placement="bottom"><el-button :icon="Plus" :disabled="!mapReady" aria-label="放大地图" @click="mapRef?.zoomIn()" /></el-tooltip>
+            <el-tooltip content="缩小地图" placement="bottom"><el-button :icon="Minus" :disabled="!mapReady" aria-label="缩小地图" @click="mapRef?.zoomOut()" /></el-tooltip>
+          </div>
+          <div v-if="!mapReady" class="gis-map-fallback">
+            <span class="gis-fallback-icon"><el-icon :class="{ 'is-loading': !mapError }"><Warning v-if="mapError" /><Loading v-else /></el-icon></span>
+            <h3>{{ mapError ? '底图暂时无法加载' : '正在连接地图服务' }}</h3>
+            <p>{{ mapError || '地图加载中,设备列表与详情可继续查看。' }}</p>
+            <el-button v-if="mapError" type="primary" :icon="Refresh" @click="retryMap">重试底图</el-button>
+            <span v-if="mapError" class="gis-fallback-note">设备资料、报警记录与监测曲线仍可独立查看</span>
+          </div>
+          <div class="gis-map-bottom"><div class="gis-map-legend" aria-label="报警等级图例"><span v-for="item in options.alarmLevels || []" :key="item.value" :style="levelStyle(item.value)"><i></i>{{ item.label }}</span></div><span v-if="missingCoordinates" class="gis-map-note">{{ missingCoordinates }} 台设备缺少有效坐标</span></div>
+        </div>
+      </div>
+      <footer class="gis-workspace-footer"><span><el-icon><Location /></el-icon>{{ selected ? '已选中:' + selected.name + ' · ' + selected.deviceCode : '选择设备查看详情,点击定位查看所在位置' }}</span><span>点位数字代表报警等级</span></footer>
+    </section>
+
+    <el-drawer v-model="detailVisible" size="min(680px, 100vw)" append-to-body destroy-on-close class="alarm-gis-drawer">
+      <template #header><div class="gis-drawer-heading"><span>设备详情</span><h2>{{ selected?.name }}</h2></div></template>
+      <div v-if="selected" class="gis-detail-content">
+        <div class="gis-detail-banner"><span class="gis-detail-grade" :style="levelStyle(selected.alarmLevel)"><span class="gis-grade-badge">{{ selected.alarmLevel }}</span>{{ levelLabel(selected.alarmLevel) }}</span><span>{{ selected.alarms.length }} 条报警 · 当前查询结果</span></div>
+        <dl class="gis-detail-grid"><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div><dt>最近报警时间</dt><dd>{{ latestAlarm?.createTime || '—' }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div></dl>
+        <div v-if="latestAlarm && hasActualValue(latestAlarm)" class="gis-snapshot"><div><span>最近报警触发值 · {{ latestAlarm.warningType || latestAlarm.warningCode }}</span><strong>{{ valueText(latestAlarm.actualValue, latestAlarm.warningUnit) }}</strong></div><div><span>触发时阈值</span><p>{{ thresholdText(latestAlarm) }}</p></div></div>
+
+        <el-tabs v-model="tab" class="gis-detail-tabs">
+          <el-tab-pane label="监测曲线" name="curve">
+            <div class="gis-panel-heading"><h3>监测趋势</h3><span>真实采集数据</span></div>
+            <el-select v-model="curveId" :disabled="!curveChoices.length" placeholder="选择报警指标" class="gis-curve-select"><el-option v-for="row in curveChoices" :key="row.id" :value="row.id" :label="`${row.warningType || row.warningCode} · ${row.alarmNo || row.createTime}`" /></el-select>
+            <AlarmChart v-if="detailVisible && tab === 'curve' && currentCurve" :alarm-id="currentCurve.id" :scope="scope" />
+            <el-empty v-if="!curveChoices.length" description="暂无有实际值的报警指标" :image-size="70" />
+          </el-tab-pane>
+          <el-tab-pane label="报警记录" name="alarms">
+            <div class="gis-panel-heading"><h3>本设备报警记录</h3><span>共 {{ historyTotal }} 条 · 时间倒序</span></div>
+            <el-alert v-if="historyError" :title="historyError" type="error" :closable="false"><el-button link @click="loadHistory">重试</el-button></el-alert>
+            <div v-loading="historyLoading" class="gis-alarm-history">
+              <article v-for="row in history" :key="row.id" class="gis-history-item" :style="levelStyle(row.alarmLevel)">
+                <div class="gis-history-heading"><el-tag size="small" effect="light" :type="levelTone(row.alarmLevel)">{{ row.alarmLevelName || levelLabel(row.alarmLevel) }}</el-tag><el-tag size="small" effect="light" :type="monitorStatusTone(row.alarmStatus)">{{ row.monitorStatusName }}</el-tag></div>
+                <p class="gis-history-description">{{ alarmDescription(row) }}</p>
+                <dl><dt>报警单号</dt><dd>{{ row.alarmNo || '—' }}</dd><dt>报警时间</dt><dd>{{ row.createTime || '—' }}</dd><dt>监测指标</dt><dd>{{ row.warningType || row.warningCode || '—' }}</dd></dl>
+                <div class="gis-history-actions"><el-button link type="primary" @click="detailRef.open(row.id)">报警详情</el-button><el-button v-if="hasActualValue(row)" link type="primary" @click="curveId = row.id; tab = 'curve'">查看曲线</el-button></div>
+              </article>
+              <el-empty v-if="!history.length && !historyLoading && !historyError" description="暂无报警记录" :image-size="60" />
+            </div>
+            <pagination v-show="historyTotal > 0" v-model:page="historyPage" v-model:limit="historySize" :total="historyTotal" :auto-scroll="false" layout="total, prev, pager, next" @pagination="loadHistory" />
+          </el-tab-pane>
+          <el-tab-pane label="设备信息" name="device">
+            <div class="gis-panel-heading"><h3>设备档案</h3><span>关联设备台账</span></div>
+            <dl class="gis-detail-grid"><div><dt>设备名称</dt><dd>{{ selected.name }}</dd></div><div><dt>设备编码</dt><dd>{{ selected.deviceCode }}</dd></div><div class="is-wide"><dt>安装位置</dt><dd>{{ selected.equipmentLocation || '—' }}</dd></div><div><dt>经度</dt><dd>{{ selected.hasCoordinates ? selected.lng : '暂无有效坐标' }}</dd></div><div><dt>纬度</dt><dd>{{ selected.hasCoordinates ? selected.lat : '暂无有效坐标' }}</dd></div></dl>
+            <section v-if="selected.equipmentImage" class="gis-device-images"><h3>设备图片</h3><AlarmAttachments :value="selected.equipmentImage" :base-url="baseUrl" /></section>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+      <template #footer><div class="gis-drawer-footer"><el-button @click="detailVisible = false">关闭</el-button></div></template>
+    </el-drawer>
+    <el-dialog :model-value="cluster.length > 0" title="聚合点位" width="min(440px, 94vw)" @close="cluster = []"><div class="gis-cluster-list"><button v-for="device in cluster" :key="device.key" type="button" @click="choose(device); cluster = []"><span>{{ device.name }}</span><small>{{ device.alarms.length }} 条报警</small><el-icon><ArrowRight /></el-icon></button></div></el-dialog>
+    <AlarmDetail ref="detailRef" />
+  </div>
+</template>
+
+<script setup name="WaterSupplyAlarmGIS">
+import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
+import { Search, Refresh, MapLocation, Location, Aim, Plus, Minus, ArrowRight, Warning, Loading } from '@element-plus/icons-vue'
+import { WATER_DEVICE_ROOT_TYPE_ID } from '@/utils/waterHomeModel'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { getAlarmMap, getAlarmOptions, getAlarms } from '@/api/alarm'
+import AlarmDetail from '../../alarm/AlarmDetail.vue'
+import AlarmChart from '../../alarm/AlarmChart.vue'
+import AlarmAttachments from '../../alarm/AlarmAttachments.vue'
+import { alarmCurveChoices, groupAlarmDevices, levelColor, levelTone, monitorStatusTone } from '../../alarm/common/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/common/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/common/utils/useAlarmScope'
+import '../../alarm/common/styles/alarmOperations.scss'
+
+const { scope, scopeKey } = useAlarmScope()
+const baseUrl = import.meta.env.VITE_APP_BASE_API
+const keyword = ref(''), level = ref(), options = ref({}), rows = ref([]), selected = ref(null), tab = ref('curve'), curveId = ref('')
+const loading = ref(false), error = ref(''), mapError = ref(''), mapReady = ref(false), mapKey = ref(0), mapRef = ref(), detailRef = ref(), cluster = ref([])
+const detailVisible = ref(false)
+const devices = computed(() => groupAlarmDevices(rows.value).sort((a, b) => a.alarmLevel - b.alarmLevel || String(a.deviceCode).localeCompare(String(b.deviceCode))))
+const points = computed(() => devices.value.filter(d => d.hasCoordinates))
+const missingCoordinates = computed(() => devices.value.length - points.value.length)
+const levelMetrics = computed(() => (options.value.alarmLevels || []).map(item => ({ ...item, count: devices.value.filter(device => device.alarmLevel === Number(item.value)).length })))
+const latestAlarm = computed(() => selected.value?.alarms[0])
+const levelStyle = value => ({ '--grade-color': levelColor(value), '--grade-tint': levelColor(value) + '14' })
+const levelLabel = value => options.value.alarmLevels?.find(item => Number(item.value) === Number(value))?.label || '未知等级'
+const history = ref([]), historyPage = ref(1), historySize = ref(10), historyTotal = ref(0), historyLoading = ref(false), historyError = ref('')
+const curveChoices = computed(() => alarmCurveChoices([...(selected.value?.alarms || []), ...history.value]))
+const currentCurve = computed(() => curveChoices.value.find(row => row.id === curveId.value))
+watch(curveChoices, choices => { if (!choices.some(row => row.id === curveId.value)) curveId.value = choices[0]?.id || '' })
+
+let historyGeneration = 0
+async function loadHistory() {
+  const device = selected.value
+  if (!device) return
+  const token = ++historyGeneration; historyLoading.value = true; historyError.value = ''
+  try {
+    const result = await getAlarms({ ...scope.value, deviceCode: device.deviceCode, pageNum: historyPage.value, pageSize: historySize.value })
+    if (token === historyGeneration) { history.value = result.data.records || []; historyTotal.value = result.data.total || 0 }
+  } catch (e) { if (token === historyGeneration) { historyError.value = e.message || '设备报警记录加载失败'; history.value = [] } }
+  finally { if (token === historyGeneration) historyLoading.value = false }
+}
+watch(selected, device => { ++historyGeneration; history.value = []; historyTotal.value = 0; historyPage.value = 1; historyError.value = ''; historyLoading.value = false; if (device) loadHistory() })
+let generation = 0
+async function load() {
+  const token = ++generation; loading.value = true; error.value = ''; detailVisible.value = false; selected.value = null; cluster.value = []
+  try {
+    const result = await getAlarmMap({ ...scope.value, keyword: keyword.value, alarmLevel: level.value, alarmStatus: 0 })
+    if (token === generation) { rows.value = result.data || []; await nextTick(); mapRef.value?.fitBounds() }
+  } catch (e) { if (token === generation) { error.value = e.message || '点位加载失败'; rows.value = [] } }
+  finally { if (token === generation) loading.value = false }
+}
+function locate(device) { selected.value = device; if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device) }
+function choose(device) {
+  selected.value = device
+  curveId.value = device.alarms.find(hasActualValue)?.id || ''
+  tab.value = curveId.value ? 'curve' : 'alarms'
+  detailVisible.value = true
+  if (device.hasCoordinates && mapReady.value) mapRef.value?.locatePoint(device)
+}
+function ready() { mapError.value = ''; mapReady.value = true; mapRef.value?.fitBounds() }
+function failMap(message) { mapReady.value = false; mapError.value = message || '请检查网络或地图服务配置,稍后重试。' }
+function retryMap() { mapError.value = ''; mapReady.value = false; ++mapKey.value }
+function reset() { keyword.value = ''; level.value = undefined; return load() }
+watch(scopeKey, async () => {
+  ++generation; rows.value = []; selected.value = null; detailVisible.value = false; loading.value = false; error.value = ''; options.value = {}
+  if (scope.value.rootTypeId !== WATER_DEVICE_ROOT_TYPE_ID) { error.value = '该地图仅适用于供水子系统'; return }
+  const key = scopeKey.value
+  try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} }
+  catch (e) { if (key === scopeKey.value) { error.value = e.message || '查询选项加载失败'; return } }
+  if (key === scopeKey.value) await reset()
+}, { immediate: true })
+onBeforeUnmount(() => { ++generation; ++historyGeneration })
+</script>
+
+<style scoped lang="scss" src="./alarmGIS.scss"></style>

+ 0 - 626
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -1,626 +0,0 @@
-<template>
-    <div class="water-page alarm-page">
-        <el-card shadow="never" class="water-panel map-card">
-            <WaterGisPageShell
-                embedded
-                eyebrow="WATER SUPPLY ALARM GIS"
-                title="监测报警一张图"
-                subtitle="按监测点状态着色"
-                :loading="loading"
-            >
-                <template #actions>
-                    <el-input v-model="query.keyword" class="search-input" placeholder="搜索设备/报警类型/位置" clearable
-                              :prefix-icon="Search"/>
-                    <el-button :icon="Refresh" @click="loadData">刷新</el-button>
-                    <el-button :icon="Aim" @click="fitAlarmMap">全图</el-button>
-                </template>
-                <template #metrics>
-                    <div class="metric">
-                        <div class="metric-icon"><el-icon><Bell/></el-icon></div>
-                        <div class="metric-body">
-                            <span>报警点位</span>
-                            <div class="metric-value"><strong>{{ mapStatistics.total }}</strong><small>个</small></div>
-                        </div>
-                    </div>
-                    <div class="metric">
-                        <div class="metric-icon"><el-icon><Warning/></el-icon></div>
-                        <div class="metric-body">
-                            <span>高等级报警</span>
-                            <div class="metric-value"><strong class="danger">{{ mapStatistics.highLevel }}</strong><small>个</small></div>
-                        </div>
-                    </div>
-                    <div class="metric">
-                        <div class="metric-icon"><el-icon><Tickets/></el-icon></div>
-                        <div class="metric-body">
-                            <span>有关联工单</span>
-                            <div class="metric-value"><strong class="primary">{{ mapStatistics.byStatus.workOrder }}</strong><small>个</small></div>
-                        </div>
-                    </div>
-                    <div class="metric">
-                        <div class="metric-icon"><el-icon><Location/></el-icon></div>
-                        <div class="metric-body">
-                            <span>离线点位</span>
-                            <div class="metric-value"><strong>{{ mapStatistics.byStatus.offline }}</strong><small>个</small></div>
-                        </div>
-                    </div>
-                </template>
-                <template #controls>
-                    <div class="panel-section alarm-detail">
-                        <h3>选中报警</h3>
-                        <template v-if="selectedPoint.id || selectedPoint.alarmId">
-                            <div class="detail-name">{{ selectedPoint.deviceName || selectedPoint.deviceCode || selectedPoint.name || '报警点' }}</div>
-                            <el-descriptions :column="1" border size="small">
-                                <el-descriptions-item label="报警等级">
-                                    <el-tag :type="levelType(selectedPoint.alarmLevel)">{{ selectedPoint.alarmLevelText || levelText(selectedPoint.alarmLevel) }}</el-tag>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="报警类型">{{ selectedPoint.warningType || selectedPoint.alarmType || '-' }}</el-descriptions-item>
-                                <el-descriptions-item label="触发时间">{{ selectedPoint.createTime || '-' }}</el-descriptions-item>
-                                <el-descriptions-item label="点位状态">
-                                    <el-tag :type="statusTagType(selectedPoint)">{{ statusText(selectedPoint) }}</el-tag>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="设备报警状态">
-                                    <el-tag :type="selectedPoint.deviceAlarmStatus === undefined ? 'info' : Number(selectedPoint.deviceAlarmStatus) === 1 ? 'danger' : 'success'">
-                                        {{ selectedPoint.deviceAlarmStatusText || '未获取' }}
-                                    </el-tag>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="报警记录状态">
-                                    <el-tag :type="selectedPoint.recordStatus === undefined ? 'info' : Number(selectedPoint.recordStatus) === 1 ? 'success' : 'danger'">
-                                        {{ selectedPoint.recordStatusText || '未获取' }}
-                                    </el-tag>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="关联工单">
-                                    <span>{{ selectedPoint.orderNo || '-' }}</span>
-                                    <span v-if="selectedPoint.orderStatus !== null && selectedPoint.orderStatus !== undefined">
-                                        · {{ orderStatusText(selectedPoint.orderStatus) }}
-                                    </span>
-                                </el-descriptions-item>
-                                <el-descriptions-item label="位置">{{ selectedPoint.equipmentLocation || selectedPoint.location || '-' }}</el-descriptions-item>
-                            </el-descriptions>
-                            <el-button class="detail-action" type="primary" link @click="openDetail(selectedPoint)">查看完整详情</el-button>
-                        </template>
-                        <el-empty v-else description="点击点位查看详情" :image-size="65"/>
-                    </div>
-                </template>
-                <template #legend>
-                    <div class="panel-section">
-                        <h3>点位状态与图例</h3>
-                        <div class="map-legend">
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-work-order"/>关联工单 {{ mapStatistics.byStatus.workOrder }}</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-alarm"/>报警 {{ mapStatistics.byStatus.alarm }}</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-warning"/>预警 {{ mapStatistics.byStatus.warning }}</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-offline"/>离线 {{ mapStatistics.byStatus.offline }}</div>
-                            <div class="map-legend-item"><i class="map-legend-dot status-dot-normal"/>正常 {{ mapStatistics.byStatus.normal }}</div>
-                        </div>
-                        <p class="muted legend-note">同一设备多点位合并,按关联工单 &gt; 报警 &gt; 预警 &gt; 离线 &gt; 正常取最高风险状态。</p>
-                    </div>
-                </template>
-                <template #results>
-                    <div class="panel-section result-section">
-                        <h3>搜索报警</h3>
-                        <el-alert v-if="mapLoadError" type="error" show-icon :closable="false" :title="mapLoadError">
-                            <el-button link type="primary" @click="loadData">重试</el-button>
-                        </el-alert>
-                        <el-alert v-if="mapEnrichmentWarning" type="warning" show-icon :closable="false"
-                                  :title="`${mapEnrichmentWarning}未能补全,点位状态按报警记录展示`"/>
-                        <el-empty v-if="!mapLoadError && !mapPoints.length" description="暂无报警点位" :image-size="72"/>
-                        <el-empty v-if="!mapLoadError && mapPoints.length && !filteredAlarmPoints.length" description="当前筛选下没有匹配的报警点位" :image-size="72"/>
-                        <div v-if="!mapLoadError && filteredAlarmPoints.length" class="device-result-list">
-                            <button v-for="point in filteredAlarmPoints" :key="point.id || point.alarmId || point.deviceCode"
-                                    type="button" @click="locateAlarmPoint(point)">
-                                <span class="device-name">{{ point.deviceName || point.deviceCode || point.name || '报警点' }}</span>
-                                <span class="device-meta">{{ point.warningType || point.alarmType || '监测报警' }} · {{ point.createTime || '-' }}</span>
-                                <el-tag size="small" :type="statusTagType(point)">
-                                    {{ statusText(point) }}
-                                </el-tag>
-                            </button>
-                        </div>
-                    </div>
-                </template>
-                <template #map>
-                    <GisMapCanvas
-                        ref="gisMapRef"
-                        :points="gisMapPoints"
-                        cluster-label="报警集合"
-                        :center="{ lng: 110.3965, lat: 28.4638 }"
-                        :zoom="15"
-                        @ready="handleAlarmMapReady"
-                        @point-click="locateAlarmPoint"
-                        @map-click="detailVisible = false"
-                        @error="mapError = $event"
-                    />
-                    <el-alert v-if="mapError" class="map-error" type="warning" show-icon :closable="false" :title="mapError"/>
-                    <el-alert v-if="mapLoadError" class="map-error" type="error" show-icon :closable="false" :title="mapLoadError">
-                        <el-button link type="primary" @click="loadData">重试</el-button>
-                    </el-alert>
-                    <el-empty v-else-if="!loading && !gisMapPoints.length && !mapError" class="map-empty" description="当前筛选下没有可显示的报警点位"/>
-                </template>
-            </WaterGisPageShell>
-        </el-card>
-
-        <el-drawer v-model="detailVisible" title="报警详情" size="420px"
-                   direction="rtl" class="detail-drawer alarm-map-detail-drawer" append-to-body>
-            <div v-loading="detailLoading" class="device-detail">
-                <el-alert v-if="detailError" type="error" show-icon :closable="false" title="报警详情加载失败,请重试"/>
-                <template v-if="detail && (detail.id || detail.alarmId)">
-                    <section class="detail-hero">
-                        <h3>{{ detail.deviceName || detail.deviceCode || '报警点' }}</h3>
-                        <p>{{ detail.warningType || '报警事件' }} · {{ detail.createTime || '-' }}</p>
-                        <div class="status-tags">
-                            <el-tag :type="levelType(detail.alarmLevel)">{{ detail.alarmLevelText || levelText(detail.alarmLevel) }}</el-tag>
-                            <el-tag :type="statusTagType(detail)">{{ statusText(detail) }}</el-tag>
-                            <el-tag :type="Number(detail.deviceAlarmStatus) === 1 ? 'danger' : 'success'">
-                                设备报警状态:{{ detail.deviceAlarmStatusText || '未获取' }}
-                            </el-tag>
-                            <el-tag :type="Number(detail.recordStatus) === 1 ? 'success' : 'danger'">
-                                报警记录状态:{{ detail.recordStatusText || '未获取' }}
-                            </el-tag>
-                        </div>
-                    </section>
-                    <section class="detail-section">
-                        <h4>报警信息</h4>
-                        <dl>
-                            <template v-for="field in detailFields" :key="field.label">
-                                <dt>{{ field.label }}</dt>
-                                <dd>{{ field.value }}</dd>
-                            </template>
-                        </dl>
-                    </section>
-                </template>
-                <el-empty v-else-if="!detailLoading && !detailError" description="暂无报警详情"/>
-            </div>
-        </el-drawer>
-
-    </div>
-</template>
-<script setup>
-import {computed, nextTick, onMounted, ref} from 'vue'
-import {ElMessage} from 'element-plus'
-import {Aim, Bell, Location, Refresh, Search, Tickets, Warning} from '@element-plus/icons-vue'
-import {getAlarmDetail, getDeviceGisAllPoints, listAlarmLedger, listAlarmMapPoints} from '@/api/pipeNetwork/waterSupply'
-    import GisMapCanvas from '@/components/GisMapCanvas.vue'
-    import WaterGisPageShell from '@/components/WaterGisPageShell.vue'
-    import {
-        getMonitoringPointIcon,
-        getMonitoringPointLabel,
-        getMonitoringPointTagType
-    } from '@/utils/gisMapColors'
-    import {
-        buildAlarmGisPoints,
-        filterAlarmGisPoints,
-        summarizeAlarmGisPoints
-    } from '@/utils/waterAlarmGisModel'
-    import { normalizeDeviceGisPoints, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
-    import {normalizeMapPoints, normalizePage, unwrapResponse} from '@/utils/waterSupplyModel'
-    import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
-    import {orderStatusText} from '@/views/subSystem/pipeNetwork/ops/workOrder/workOrderDict.js'
-
-const props = defineProps({title: {type: String, default: '监测报警一张图'}})
-const query = ref({keyword: ''})
-const detailLoading = ref(false), detailError = ref('')
-    const mapPoints = ref([]);
-    const mapError = ref('')
-    // 报警一张图的数据加载失败与状态补全降级分别提示,避免地图空白时用户无从判断
-    const mapLoadError = ref('')
-    const mapEnrichmentWarning = ref('')
-    const mapReady = ref(false)
-    const gisMapRef = ref(null)
-    const loading = ref(false);
-    const detailVisible = ref(false);
-    const detail = ref({});
-    const selectedPoint = computed(() => detail.value || {});
-    const filteredAlarmPoints = computed(() => filterAlarmGisPoints(
-        mapPoints.value,
-        query.value.keyword
-    ));
-
-    const gisMapPoints = computed(() => filteredAlarmPoints.value.map(point => ({
-        ...point,
-        name: point.deviceName || point.deviceCode || point.name || '报警点',
-        iconUrl: getMonitoringPointIcon(point)
-    })))
-
-    /** 左侧状态总览:五态数量来自点位推导结果,与地图着色使用同一口径 */
-    const mapStatistics = computed(() => summarizeAlarmGisPoints(filteredAlarmPoints.value));
-    const statusText = point => getMonitoringPointLabel(point);
-    const statusTagType = point => getMonitoringPointTagType(point);
-    /**
-     * 详情抽屉字段:逐条报警流水,标签统一中文;
-     * 工单状态复用统一工单字典,避免页面自带一套状态码文本。
-     */
-    const detailFields = computed(() => {
-        const row = detail.value || {}
-        const pairs = [
-            ['报警编码', row.warningCode],
-            ['报警时间', row.createTime],
-            ['报警类型', row.warningType],
-            ['设备名称', row.deviceName],
-            ['设备编码', row.deviceCode],
-            ['设备类型', row.deviceType],
-            ['安装位置', row.equipmentLocation],
-            ['报警参数值', row.actualValue],
-            ['阈值区间', thresholdRange(row)],
-            ['偏差率', deviationText(row)],
-            ['报警等级', row.alarmLevelText || levelText(row.alarmLevel)],
-            ['报警记录状态', row.recordStatusText || row.alarmStatusText],
-            ['设备报警状态', row.deviceAlarmStatusText],
-            ['设备当前状态', row.currentStatusText],
-            ['在线状态', row.onlineStatusText],
-            ['报警展示状态', row.displayStatusText],
-            ['关联工单号', row.orderNo],
-            ['工单状态', row.orderStatus === null || row.orderStatus === undefined ? '' : orderStatusText(row.orderStatus)],
-            ['备注', row.remark],
-            ['经度', row.longitude],
-            ['纬度', row.latitude]
-        ]
-        return pairs
-            .filter(([, value]) => value !== undefined && value !== null && value !== '')
-            .map(([label, value]) => ({label, value}))
-    })
-    const levelText = value => ({1: '一级报警', 2: '二级报警', 3: '三级报警', 4: '四级报警'}[value] || '未知等级');
-    const levelType = value => ({1: 'danger', 2: 'warning', 3: 'primary', 4: 'success'}[value] || 'info');
-    /** 阈值区间文本:下限 ~ 上限;单边阈值时显示对应的比较方向 */
-    const thresholdRange = row => {
-        const hasMin = row.minValue !== null && row.minValue !== undefined && row.minValue !== '';
-        const hasMax = row.maxValue !== null && row.maxValue !== undefined && row.maxValue !== '';
-        if (hasMin && hasMax) return `${row.minValue} ~ ${row.maxValue}`;
-        if (hasMin) return `≥ ${row.minValue}`;
-        if (hasMax) return `≤ ${row.maxValue}`;
-        return '未配置阈值';
-    };
-    const deviationText = row => {
-        const value = Number(row.deviationRatio);
-        return Number.isFinite(value) ? `${value.toFixed(2)}%` : '-';
-    };
-async function loadData() {
-  loading.value = true; mapLoadError.value = ''; mapEnrichmentWarning.value = ''
-  try { await loadAlarmMapData() }
-  catch (error) { mapLoadError.value = getRequestErrorMessage(error, '报警点位加载失败,请重试'); mapPoints.value = []; ElMessage.error(mapLoadError.value) }
-  finally { loading.value = false }
-}
-    /** 报警一张图台账补全条数:与列表页共用同一台账接口,避免同一状态出现两套口径 */
-    const ALARM_MAP_LEDGER_PAGE_SIZE = 200
-    const ALARM_MAP_LEDGER_MAX_PAGES = 50
-
-    async function loadAllAlarmLedgerRows() {
-        const collected = []
-        let total = 0
-        for (let pageNum = 1; pageNum <= ALARM_MAP_LEDGER_MAX_PAGES; pageNum += 1) {
-            const response = await listAlarmLedger({ pageNum, pageSize: ALARM_MAP_LEDGER_PAGE_SIZE })
-            const ledgerPage = normalizePage(response)
-            collected.push(...ledgerPage.records)
-            total = ledgerPage.total
-            if (!ledgerPage.records.length || collected.length >= ledgerPage.total) {
-                if (collected.length < total) throw new Error('报警台账分页未完整返回')
-                return collected
-            }
-        }
-        throw new Error('报警台账分页超过安全上限')
-    }
-
-    /**
-     * 报警点位取自专用点位接口,再用报警台账补全设备/记录/工单状态。
-     * 台账或设备点位不可用时只降级对应状态来源,不影响报警点位本身渲染。
-     */
-    async function loadAlarmMapData() {
-        const [pointsResult, ledgerResult, deviceResult] = await Promise.allSettled([
-            listAlarmMapPoints(),
-            loadAllAlarmLedgerRows(),
-            getDeviceGisAllPoints()
-        ])
-        if (pointsResult.status === 'rejected') throw pointsResult.reason
-
-        const alarmPoints = normalizeMapPoints(unwrapResponse(pointsResult.value) || [])
-        const ledgerRows = ledgerResult.status === 'fulfilled' ? ledgerResult.value : []
-        const devicePoints = deviceResult.status === 'fulfilled'
-            ? normalizeDeviceGisPoints(deviceResult.value).map(normalizeDeviceRow)
-            : []
-        mapPoints.value = buildAlarmGisPoints({alarmPoints, ledgerRows, devicePoints})
-
-        const missingSources = [
-            ledgerResult.status === 'rejected' ? '关联工单状态' : '',
-            deviceResult.status === 'rejected' ? '设备在线与运行状态' : ''
-        ].filter(Boolean)
-        mapEnrichmentWarning.value = missingSources.join('、')
-        if (missingSources.length) {
-            console.warn(`[${props.title}] 状态补全降级:${mapEnrichmentWarning.value}`)
-        }
-        await nextTick()
-        fitAlarmMap()
-    }
-
-    function handleAlarmMapReady() {
-        mapReady.value = true
-        if (mapPoints.value.length) nextTick(fitAlarmMap)
-    }
-
-    function fitAlarmMap() {
-        if (mapReady.value) gisMapRef.value?.fitBounds()
-    }
-
-    async function openDetail(row) {
-        detail.value = row
-        detailVisible.value = true
-        const id = row.id || row.alarmId
-        if (!id) return
-        detailLoading.value = true; detailError.value = ''
-        try {
-            const detailResponse = await getAlarmDetail(id)
-            // 台账详情不返回设备在线/运行状态,合并保留点位侧已补全的状态,避免抽屉回退成单一状态
-            detail.value = {...row, ...(unwrapResponse(detailResponse) || {})}
-        } catch (error) {
-            detail.value = row
-            detailError.value = getRequestErrorMessage(error, '报警详情加载失败')
-        } finally { detailLoading.value = false }
-    }
-
-    function alarmPointFocus(point) {
-        return {
-            lng: Number(point.lng ?? point.longitude),
-            lat: Number(point.lat ?? point.latitude)
-        };
-    }
-
-    function locateAlarmPoint(point) {
-        gisMapRef.value?.locatePoint(alarmPointFocus(point));
-        openDetail(point);
-    }
-
-onMounted(loadData)
-</script>
-<style scoped>
-    .water-page {
-        padding: 20px;
-        background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 45%, #eef2f7 100%);
-        min-height: calc(100vh - 84px)
-    }
-
-    .muted {
-        font-size: 13px;
-        color: #909399
-    }
-
-    .metric {
-        position: static;
-        display: flex;
-        align-items: center;
-        gap: 14px;
-        min-height: 78px;
-        padding: 14px 16px;
-        background: #fff;
-        border: 1px solid #e1ebf3;
-        border-radius: 8px;
-        box-shadow: 0 6px 14px rgba(15, 23, 42, 0.04)
-    }
-
-    .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
-    }
-
-    .metric-body span {
-        display: block;
-        margin-bottom: 3px;
-        overflow: hidden;
-        color: #64748b;
-        font-size: 13px;
-        text-overflow: ellipsis;
-        white-space: nowrap
-    }
-
-    .metric-value {
-        display: flex;
-        align-items: baseline;
-        gap: 5px
-    }
-
-    .metric-value strong {
-        color: #1e293b;
-        font-size: 24px;
-        line-height: 1.2
-    }
-
-    .metric-value small {
-        margin-left: 0;
-        color: #94a3b8;
-        font-size: 12px
-    }
-
-    .danger {
-        color: #e45656
-    }
-
-    .primary {
-        color: #2563eb
-    }
-
-    .map-card {
-        min-height: 560px
-    }
-
-    .map-card .search-input {
-        width: 220px
-    }
-
-    .map-card .panel-section h3 {
-        margin: 0 0 8px;
-        font-size: 14px;
-        color: #29567e
-    }
-
-    .map-card .alarm-detail {
-        margin-top: 0;
-        padding-top: 0;
-        border-top: 0
-    }
-
-    .map-card .device-result-list {
-        display: flex;
-        flex-direction: column;
-        gap: 6px
-    }
-
-    .map-card .device-result-list button {
-        width: 100%;
-        display: grid;
-        grid-template-columns: minmax(0, 1fr) auto;
-        gap: 3px 8px;
-        align-items: center;
-        border: 1px solid #dbe8f5;
-        background: #fff;
-        border-radius: 8px;
-        padding: 8px 10px;
-        text-align: left;
-        cursor: pointer
-    }
-
-    .map-card .device-name {
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        font-weight: 600;
-        color: #274b6d
-    }
-
-    .map-card .device-meta {
-        grid-column: 1 / -1;
-        font-size: 12px;
-        color: #7793aa
-    }
-
-    .map-card .map-error {
-        position: absolute;
-        left: 16px;
-        bottom: 16px;
-        width: 360px;
-        z-index: 1
-    }
-
-    .map-card .map-empty {
-        position: absolute;
-        left: 50%;
-        top: 50%;
-        transform: translate(-50%, -50%);
-        z-index: 1
-    }
-
-</style>
-<style lang="scss">
-    @use './waterSupplyPageTheme.scss';
-    .alarm-page .map-workspace .alarm-map {
-        height: auto;
-        min-height: 560px;
-        border: 0;
-        background-color: #f8fbff;
-        background-image: linear-gradient(#eaf0f6 1px, transparent 1px), linear-gradient(90deg, #eaf0f6 1px, transparent 1px);
-        background-size: 40px 40px;
-    }
-
-    .alarm-page .alarm-map {
-        min-height: 560px;
-        height: auto;
-        border: 0;
-        background-color: #f8fbff;
-        background-image: linear-gradient(#eaf0f6 1px, transparent 1px), linear-gradient(90deg, #eaf0f6 1px, transparent 1px);
-        background-size: 40px 40px;
-    }
-
-    .alarm-page .status-dot-normal {
-        background: #2ecc71;
-    }
-
-    .alarm-page .status-dot-alarm {
-        background: #e74c3c;
-    }
-
-    .alarm-page .status-dot-warning {
-        background: #f1c40f;
-    }
-
-    .alarm-page .status-dot-work-order {
-        background: #3498db;
-    }
-
-    .alarm-page .status-dot-offline {
-        background: #95a5a6;
-    }
-    .alarm-page .alarm-detail {
-        margin-top: 24px;
-        padding-top: 16px;
-        border-top: 1px solid #e1ebf3;
-    }
-
-    .alarm-page .detail-name {
-        margin-bottom: 10px;
-        color: #17324d;
-        font-weight: 700;
-    }
-
-    .alarm-page .detail-action {
-        margin-top: 10px;
-    }
-
-    /* 报警一张图详情抽屉:与监测设备/供水设施一张图保持同一抽屉结构 */
-    .alarm-map-detail-drawer :deep(.el-drawer__body) {
-        overflow: auto
-    }
-
-    .alarm-map-detail-drawer .device-detail {
-        min-height: 240px
-    }
-
-    .alarm-map-detail-drawer .detail-hero h3,
-    .alarm-map-detail-drawer .detail-section h4 {
-        margin: 0 0 8px;
-        color: #1c4b78
-    }
-
-    .alarm-map-detail-drawer .detail-hero p {
-        margin: 0 0 10px;
-        color: #678197;
-        font-size: 13px
-    }
-
-    .alarm-map-detail-drawer .status-tags {
-        display: flex;
-        flex-wrap: wrap;
-        gap: 6px
-    }
-
-    .alarm-map-detail-drawer .detail-section {
-        margin-top: 18px;
-        padding-top: 12px;
-        border-top: 1px solid #e5eef7
-    }
-
-    .alarm-map-detail-drawer .detail-section dl {
-        display: grid;
-        grid-template-columns: 88px minmax(0, 1fr);
-        gap: 7px 10px;
-        margin: 0;
-        font-size: 13px
-    }
-
-    .alarm-map-detail-drawer .detail-section dt {
-        color: #71889c
-    }
-
-    .alarm-map-detail-drawer .detail-section dd {
-        margin: 0;
-        color: #2b4c67;
-        overflow-wrap: anywhere
-    }
-
-    @media (max-width: 768px) {
-        .alarm-map-detail-drawer {
-            width: 100% !important
-        }
-    }
-</style>

+ 6 - 6
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -71,7 +71,7 @@
               <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>
+                <el-button link type="primary" class="panel-link" @click="goPage('/waterSupplyAlarmGIS', { rootTypeId: WATER_DEVICE_ROOT_TYPE_ID })">报警</el-button>
               </div>
             </div>
           </template>
@@ -225,7 +225,7 @@
           </template>
           <div class="quick-grid">
             <button v-for="entry in quickEntries" :key="entry.key" type="button" class="quick-entry"
-                    @click="goPage(entry.suffix)">
+                    @click="goPage(entry.suffix, entry.query)">
               <el-icon><component :is="entryIcon(entry.key)"/></el-icon>
               <span>{{ entry.label }}</span>
             </button>
@@ -284,9 +284,9 @@ import {
   Warning, WarningFilled
 } from '@element-plus/icons-vue'
 import { getAlarmStats, getDeviceGisAllPoints,
-  getWaterFacilityFeatures, getWaterFacilityOverview, listAlarmMapPoints,
+  getWaterFacilityFeatures, getWaterFacilityOverview,
   listRealtimeAlarms } from '@/api/pipeNetwork/waterSupply'
-import { getAlarmDashboard } from '@/api/alarm'
+import { getAlarmDashboard, getAlarmMap } from '@/api/alarm'
 import { getEquipmentStatusStatistics, listEquipment } from '@/api/pipeNetwork/pdeviceInform'
 import { listWorkOrder, workOrderStatusCount } from '@/api/pipeNetwork/ops/workOrder'
 import { normalizeGeoJson, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
@@ -463,14 +463,14 @@ async function loadOverviewMap(recordProblem) {
   overviewMapError.value = ''
   const [facilityRes, alarmRes] = await Promise.all([
     settle(() => getWaterFacilityFeatures(), { features: [] }, recordProblem('设施GIS点位')),
-    settle(() => listAlarmMapPoints(), [], recordProblem('报警点位'))
+    settle(() => getAlarmMap({ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID }), [], recordProblem('报警点位'))
   ])
   const facilityModel = buildFacilityMapModel(normalizeGeoJson(facilityRes))
   const overviewModel = buildHomeGisOverview({
     facilityPoints: facilityModel.points,
     facilityLines: facilityModel.lines,
     devicePoints: normalizeDeviceGisPoints(overviewDeviceRaw.value),
-    alarmPoints: (Array.isArray(alarmRes) ? alarmRes : [])
+    alarmPoints: unwrapResponse(alarmRes) || []
   })
   overviewMapPoints.value = overviewModel.points
   overviewMapLines.value = overviewModel.lines

+ 0 - 1
src/views/subSystem/waterSupply/monitorAlarm/map.vue

@@ -1 +0,0 @@
-<script setup>import Page from '../wjcbj/jcbjyzt.vue'</script><template><Page /></template>

+ 0 - 1
src/views/subSystem/waterSupply/wjcbj/jcbjyzt.vue

@@ -1 +0,0 @@
-<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="监测报警一张图" subtitle="报警点位定位与等级分布" /></template>

+ 103 - 0
tests/alarmListStatistics.test.mjs

@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import vm from 'node:vm'
+import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
+import { parse } from '@vue/compiler-sfc'
+import { rangeParams } from '../src/views/subSystem/alarm/common/utils/alarmOperationsModel.js'
+
+const { descriptor } = parse(await readFile(new URL('../src/views/subSystem/alarm/common/list.vue', import.meta.url), 'utf8'))
+const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')
+async function flush() {
+  for (let i = 0; i < 8; i++) await Promise.resolve()
+  await nextTick()
+}
+function mount(t) {
+  const requests = { list: [], statistics: [] }, scope = ref({ rootTypeId: 2 }), lifecycle = {}
+  const request = key => params => new Promise((resolve, reject) => requests[key].push({ params, resolve, reject }))
+  const context = {
+    ref, reactive, watch, rangeParams, Document: {}, Warning: {}, CircleCheck: {},
+    useAlarmScope: () => ({ scope, scopeKey: computed(() => JSON.stringify(scope.value)), route: { query: {} } }),
+    getAlarms: request('list'), getAlarmStatistics: request('statistics'),
+    getAlarmOptions: async () => ({ data: {} }),
+    onBeforeUnmount: callback => { lifecycle.unmount = callback }
+  }
+  const effects = effectScope()
+  effects.run(() => vm.runInNewContext(`${script}
+    globalThis.page = { statistics, statisticsLoading, statisticsError, rows, total, loading, error, filters, dates, pageNum, pageSize, search, reset, load }
+  `, context))
+  t.after(() => { lifecycle.unmount(); effects.stop() })
+  return { page: context.page, requests, scope }
+}
+const stats = (total, abnormal, recovered) => ({ data: [{ level: 1, total, abnormal, recovered }] })
+const snapshot = value => JSON.parse(JSON.stringify(value))
+
+test('statistics use the full filtered result and do not delay table rendering', async t => {
+  const { page, requests } = mount(t)
+  await flush()
+  requests.list[0].resolve({ data: { records: [{ id: 'one' }], total: 120 } })
+  await flush()
+  assert.equal(page.rows.value.length, 1)
+  assert.equal(page.loading.value, false)
+  assert.equal(page.statisticsLoading.value, true)
+  requests.statistics[0].resolve({ data: [
+    { level: 1, total: '30', abnormal: '10', recovered: '20' },
+    { level: null, total: '90', abnormal: '40', recovered: '48' }
+  ] })
+  await flush()
+  assert.deepEqual(snapshot(page.statistics.value), { total: 120, abnormal: 50, recovered: 68 })
+  Object.assign(page.filters, { alarmNo: 'BJ', keyword: '设备', alarmLevel: 2, alarmStatus: 1 })
+  page.dates.value = ['2026-09-01', '2026-09-24']
+  page.pageNum.value = 3
+  page.load()
+  const params = requests.statistics[1].params
+  assert.equal(params.rootTypeId, 2)
+  assert.equal(params.keyword, '设备')
+  assert.equal(params.alarmNo, 'BJ')
+  assert.equal(params.alarmLevel, 2)
+  assert.equal(params.alarmStatus, 1)
+  assert.equal(params.startTime, '2026-09-01 00:00:00')
+  assert.equal(params.endTime, '2026-09-24 23:59:59.999999')
+  assert.equal(params.pageNum, undefined)
+  assert.equal(params.pageSize, undefined)
+  assert.equal(requests.list[1].params.pageNum, 3)
+})
+
+test('late statistics from an old search or subsystem cannot overwrite current cards', async t => {
+  const { page, requests, scope } = mount(t)
+  await flush()
+  page.filters.keyword = '新查询'
+  page.search()
+  requests.statistics[1].resolve(stats(4, 3, 1))
+  await flush()
+  requests.statistics[0].resolve(stats(120, 50, 70))
+  await flush()
+  assert.equal(page.statistics.value.total, 4)
+  page.search()
+  scope.value = { rootTypeId: 1 }
+  await flush()
+  assert.equal(page.statistics.value, null)
+  assert.equal(requests.statistics[3].params.rootTypeId, 1)
+  requests.statistics[3].resolve(stats(2, 1, 1))
+  requests.statistics[2].resolve(stats(10, 8, 2))
+  await flush()
+  assert.equal(page.statistics.value.total, 2)
+})
+
+test('statistics failure remains distinct from zero and leaves the list usable', async t => {
+  const { page, requests } = mount(t)
+  await flush()
+  requests.statistics[0].reject(new Error('统计请求失败'))
+  requests.list[0].resolve({ data: { records: [{ id: 'one' }], total: 1 } })
+  await flush()
+  assert.equal(page.statistics.value, null)
+  assert.equal(page.statisticsError.value, '统计请求失败')
+  assert.equal(page.statisticsLoading.value, false)
+  assert.equal(page.rows.value.length, 1)
+  assert.equal(page.error.value, '')
+  page.reset()
+  requests.statistics[1].resolve({ data: [] })
+  await flush()
+  assert.deepEqual(snapshot(page.statistics.value), { total: 0, abnormal: 0, recovered: 0 })
+  assert.equal(page.statisticsError.value, '')
+})

+ 210 - 0
tests/alarmRemindLoading.test.mjs

@@ -0,0 +1,210 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import vm from 'node:vm'
+import { computed, effectScope, nextTick, ref, watch } from 'vue'
+import { parse } from '@vue/compiler-sfc'
+
+const filename = new URL('../src/views/subSystem/alarm/common/remind.vue', import.meta.url)
+const { descriptor } = parse(await readFile(filename, 'utf8'))
+// 执行实际页面脚本,用可控请求和生命周期验证异步显示,不依赖真实接口或等待轮询。
+const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')
+
+function mountReminder(t) {
+  const requests = { reminders: [], statistics: [], options: [] }
+  const lifecycle = {}, timers = new Map(), scope = ref({ rootTypeId: 2 })
+  const request = name => params => new Promise((resolve, reject) => {
+    requests[name].push({ params, resolve, reject })
+  })
+  const context = {
+    computed, ref, watch,
+    useAlarmScope: () => ({ scope, scopeKey: computed(() => JSON.stringify(scope.value)) }),
+    getAlarmReminders: request('reminders'),
+    getAlarmStatistics: request('statistics'),
+    getAlarmOptions: request('options'),
+    onMounted: callback => { lifecycle.mounted = callback },
+    onActivated: callback => { lifecycle.activated = callback },
+    onDeactivated: callback => { lifecycle.deactivated = callback },
+    onBeforeUnmount: callback => { lifecycle.unmount = callback },
+    setInterval: (callback, delay) => { timers.set(delay, callback); return delay },
+    clearInterval: timer => timers.delete(timer)
+  }
+  const effects = effectScope()
+  effects.run(() => vm.runInNewContext(`${script}
+    globalThis.page = {
+      visibleRows, received, loading, hasLoaded, stats, options, error, refreshError,
+      manualPause, hoverPause, focusPause, detailPause, pending, level,
+      load, restart, retry
+    }
+  `, context))
+  t.after(() => { lifecycle.unmount(); effects.stop() })
+  return { page: context.page, requests, lifecycle, timers, scope }
+}
+
+async function flush() {
+  for (let i = 0; i < 5; i += 1) await Promise.resolve()
+  await nextTick()
+}
+
+const rowsResponse = records => ({ data: { records } })
+const event = id => ({ id, latestOperationTime: '2026-09-24 10:00:00' })
+
+test('first response displays immediately without waiting for statistics, options or polling', async t => {
+  const { page, requests, lifecycle, timers } = mountReminder(t)
+  assert.equal(requests.reminders.length, 1)
+  assert.equal(timers.size, 0)
+  lifecycle.mounted()
+  lifecycle.activated()
+  assert.equal(requests.reminders.length, 1)
+  assert.ok(timers.has(30000))
+  const rows = [event(1)]
+  requests.reminders[0].resolve(rowsResponse(rows))
+  await flush()
+  assert.deepEqual(page.visibleRows.value, rows)
+  assert.equal(page.loading.value, false)
+  assert.equal(page.hasLoaded.value, true)
+  requests.statistics[0].resolve({ data: [{ level: 1, total: 3 }] })
+  await flush()
+  assert.equal(page.stats.value[0].total, 3)
+  assert.deepEqual(page.visibleRows.value, rows)
+})
+
+for (const pause of ['hoverPause', 'focusPause', 'manualPause', 'detailPause']) {
+  test(`first response still displays while ${pause} is active`, async t => {
+    const { page, requests } = mountReminder(t)
+    page[pause].value = true
+    requests.reminders[0].resolve(rowsResponse([event(1)]))
+    await flush()
+    assert.equal(page.visibleRows.value[0]?.id, 1)
+    assert.equal(page.pending.value, 0)
+    assert.equal(page.loading.value, false)
+  })
+}
+
+test('subsequent polling preserves paused reading and releases updates when unpaused', async t => {
+  const { page, requests, lifecycle, timers } = mountReminder(t)
+  lifecycle.mounted()
+  requests.reminders[0].resolve(rowsResponse([event(1)]))
+  await flush()
+  page.hoverPause.value = true
+  timers.get(30000)()
+  assert.equal(page.loading.value, true)
+  assert.equal(page.hasLoaded.value, true)
+  assert.equal(page.visibleRows.value[0].id, 1)
+  requests.reminders[1].resolve(rowsResponse([event(2)]))
+  await flush()
+  assert.equal(page.visibleRows.value[0].id, 1)
+  assert.equal(page.received.value[0].id, 2)
+  assert.equal(page.pending.value, 1)
+  page.hoverPause.value = false
+  await flush()
+  assert.equal(page.visibleRows.value[0].id, 2)
+  assert.equal(page.pending.value, 0)
+})
+
+test('an initially empty list shows newly received events even while paused', async t => {
+  const { page, requests } = mountReminder(t)
+  requests.reminders[0].resolve(rowsResponse([]))
+  await flush()
+  page.hoverPause.value = true
+  page.load()
+  requests.reminders[1].resolve(rowsResponse([event(1)]))
+  await flush()
+  assert.equal(page.visibleRows.value[0]?.id, 1)
+})
+
+test('statistics and options failures do not hide reminders and can be retried', async t => {
+  const { page, requests } = mountReminder(t)
+  requests.statistics[0].reject(new Error('statistics failed'))
+  requests.options[0].reject(new Error('options failed'))
+  requests.reminders[0].resolve(rowsResponse([event(1)]))
+  await flush()
+  assert.equal(page.loading.value, false)
+  assert.equal(page.visibleRows.value.length, 1)
+  assert.equal(page.refreshError.value, 'statistics failed')
+  page.retry()
+  assert.equal(requests.options.length, 2)
+  requests.statistics[1].resolve({ data: [] })
+  requests.options[1].resolve({ data: { alarmLevels: [] } })
+  requests.reminders[1].resolve(rowsResponse([event(2)]))
+  await flush()
+  assert.equal(page.refreshError.value, '')
+  assert.equal(page.visibleRows.value[0].id, 2)
+})
+
+test('a failed list request stops loading immediately and recovers while paused', async t => {
+  const { page, requests } = mountReminder(t)
+  requests.reminders[0].reject(new Error('reminders failed'))
+  await flush()
+  assert.equal(page.loading.value, false)
+  assert.equal(page.error.value, 'reminders failed')
+  requests.statistics[0].resolve({ data: [] })
+  await flush()
+  assert.equal(page.error.value, 'reminders failed')
+  page.focusPause.value = true
+  page.retry()
+  requests.reminders[1].resolve(rowsResponse([event(1)]))
+  await flush()
+  assert.equal(page.error.value, '')
+  assert.equal(page.visibleRows.value[0].id, 1)
+})
+
+test('changing scope rejects older responses and displays the new scope while paused', async t => {
+  const { page, requests, scope } = mountReminder(t)
+  page.hoverPause.value = true
+  scope.value = { rootTypeId: 1 }
+  await flush()
+  assert.equal(requests.reminders[1].params.rootTypeId, 1)
+  requests.reminders[1].resolve(rowsResponse([event(2)]))
+  requests.statistics[1].resolve({ data: [{ level: 1, total: 2 }] })
+  requests.options[1].resolve({ data: { name: 'current' } })
+  await flush()
+  requests.reminders[0].resolve(rowsResponse([event(1)]))
+  requests.statistics[0].resolve({ data: [{ level: 1, total: 99 }] })
+  requests.options[0].reject(new Error('old scope failed'))
+  await flush()
+  assert.equal(page.visibleRows.value[0].id, 2)
+  assert.equal(page.stats.value[0].total, 2)
+  assert.equal(page.options.value.name, 'current')
+  assert.equal(page.refreshError.value, '')
+})
+
+test('filter changes show the new result immediately despite manual pause', async t => {
+  const { page, requests } = mountReminder(t)
+  requests.reminders[0].resolve(rowsResponse([event(1)]))
+  await flush()
+  page.manualPause.value = true
+  page.level.value = 2
+  page.restart()
+  assert.equal(requests.reminders[1].params.alarmLevel, 2)
+  requests.reminders[1].resolve(rowsResponse([event(2)]))
+  await flush()
+  assert.equal(page.visibleRows.value[0].id, 2)
+  assert.equal(page.manualPause.value, true)
+})
+
+test('returning to a cached page refreshes immediately and ignores pre-deactivation requests', async t => {
+  const { page, requests, lifecycle } = mountReminder(t)
+  requests.reminders[0].resolve(rowsResponse([event(1)]))
+  await flush()
+  page.hoverPause.value = true
+  page.focusPause.value = true
+  page.manualPause.value = true
+  page.load()
+  lifecycle.deactivated()
+  page.load()
+  assert.equal(requests.reminders.length, 2)
+  assert.equal(page.hoverPause.value, false)
+  assert.equal(page.focusPause.value, false)
+  lifecycle.activated()
+  assert.equal(requests.reminders.length, 3)
+  requests.reminders[1].resolve(rowsResponse([event(2)]))
+  await flush()
+  assert.equal(page.loading.value, true)
+  assert.equal(page.visibleRows.value[0].id, 1)
+  requests.reminders[2].resolve(rowsResponse([event(3)]))
+  await flush()
+  assert.equal(page.loading.value, false)
+  assert.equal(page.visibleRows.value[0].id, 3)
+  assert.equal(page.manualPause.value, true)
+})

+ 5 - 5
tests/gisMapCanvas.test.mjs

@@ -169,12 +169,12 @@ test('water supply GIS pages share the common canvas component', async () => {
   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')
+  const alarmSource = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue', 'utf8')
 
   assert.match(alarmSource, /<GisMapCanvas/u)
-  assert.match(alarmSource, /cluster-label="报警集合"/u)
-  assert.match(alarmSource, /一级报警/u)
+  assert.match(alarmSource, /@cluster-click="cluster = \$event.points \|\| \[\]"/u)
+  assert.match(alarmSource, /levelStyle\(device.alarmLevel\)/u)
   assert.doesNotMatch(alarmSource, /color: point\.relatedOrderNo \?/u)
-  assert.match(alarmSource, /:points="gisMapPoints"/u)
-  assert.match(alarmSource, /@point-click="locateAlarmPoint"/u)
+  assert.match(alarmSource, /:points="points"/u)
+  assert.match(alarmSource, /@point-click="choose"/u)
 })

+ 2 - 2
tests/waterAlarmAuditWorkbench.test.mjs

@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'
 import test from 'node:test'
 import { ensureUniqueSubsystemRouteNames } from '../src/router/routeNameUtils.js'
 
-const page = () => readFile('src/views/subSystem/alarm/index.vue', 'utf8')
+const page = () => readFile('src/views/subSystem/alarm/common/audit.vue', 'utf8')
 
 test('shared audit page requires a decision but leaves the opinion optional with a length limit', async () => {
   const source = await page()
@@ -73,7 +73,7 @@ test('timeline renders thumbnails with teleported full-size image preview', asyn
 })
 
 test('shared alarm detail navigates to authorized audit and supply map no longer implements auditing', async () => {
-  const ledger = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue','utf8')
+  const ledger = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue','utf8')
   const detail = await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
   assert.ok(detail.includes("navigate('alarmAudit'"))
   assert.ok(!ledger.includes("mode === 'manage'"))

+ 0 - 184
tests/waterAlarmGisModel.test.mjs

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

+ 0 - 70
tests/waterAlarmGisPage.test.mjs

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

+ 9 - 10
tests/waterGisConsistency.test.mjs

@@ -4,8 +4,7 @@ import { readFile } from 'node:fs/promises'
 
 const pages = {
   facility: 'src/views/subSystem/waterSupply/facility/gis.vue',
-  device: 'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue',
-  alarm: 'src/views/subSystem/waterSupply/components/WaterAlarmPage.vue'
+  device: 'src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue'
 }
 
 const shellPath = 'src/components/WaterGisPageShell.vue'
@@ -23,7 +22,7 @@ test('the shared shell owns one metric grid, map error and map empty styling', a
   assert.match(shell, /:deep\(\.map-empty\)/u)
 })
 
-test('all three GIS maps fill the metrics slot without nesting another metric grid', async () => {
+test('facility and device GIS maps fill the metrics slot without nesting another metric grid', async () => {
   for (const [name, path] of Object.entries(pages)) {
     const source = await readFile(path, 'utf8')
     const metricsSlot = source.match(/#metrics>[\s\S]*?<\/template>/u)?.[0] || ''
@@ -38,7 +37,7 @@ test('all three GIS maps fill the metrics slot without nesting another metric gr
   }
 })
 
-test('all three GIS maps keep the same refresh, empty, error and drawer contract', async () => {
+test('facility and device GIS maps keep the same refresh, empty, error and drawer contract', async () => {
   for (const [name, path] of Object.entries(pages)) {
     const source = await readFile(path, 'utf8')
 
@@ -54,7 +53,7 @@ test('all three GIS maps keep the same refresh, empty, error and drawer contract
   }
 })
 
-test('all three GIS maps share the searchable result list structure', async () => {
+test('facility and device GIS maps share the searchable result list structure', async () => {
   for (const [name, path] of Object.entries(pages)) {
     const source = await readFile(path, 'utf8')
 
@@ -84,7 +83,7 @@ test('the shared metric card neutralises the global absolute metric icon rule',
   assert.match(shell, /\.metric-grid :deep\(\.metric-icon\) \{\s*position: static;/u)
 })
 
-test('all three GIS maps render the shared shell inside the same map card and drawer layout', async () => {
+test('facility and device GIS maps render the shared shell inside the same map card and drawer layout', async () => {
   for (const [name, path] of Object.entries(pages)) {
     const source = await readFile(path, 'utf8')
 
@@ -94,7 +93,7 @@ test('all three GIS maps render the shared shell inside the same map card and dr
     assert.match(source, /<section class="detail-section">/u, `${name} should use the shared drawer section`)
   }
 })
-test('all three GIS maps share the page heading and filter card chrome', async () => {
+test('facility and device GIS maps share the page heading and filter card chrome', async () => {
   for (const [name, path] of Object.entries(pages)) {
     const source = await readFile(path, 'utf8')
 
@@ -122,10 +121,10 @@ test('the GIS page chrome theme is shared by the facility and device maps', asyn
 test('every GIS map renders its points from the filtered layer state', async () => {
   const facility = await readFile(pages.facility, 'utf8')
   const device = await readFile(pages.device, 'utf8')
-  const alarm = await readFile(pages.alarm, 'utf8')
+  const alarm = await readFile('src/views/subSystem/waterSupply/alarm/alarmGIS.vue', 'utf8')
 
   assert.match(facility, /const visibleMapModel = computed\(\(\) => filterFacilityMapModel\(fullMapModel\.value, layerState\)\)/u)
   assert.match(facility, /const mapPoints = computed\(\(\) => visibleMapModel\.value\.points\.map/u)
   assert.match(device, /const mapPoints = computed\(\(\) => filteredDevices\.value\.map/u)
-  assert.match(alarm, /const gisMapPoints = computed\(\(\) => filteredAlarmPoints\.value\.map/u)
-})
+  assert.match(alarm, /const points = computed\(\(\) => devices\.value\.filter\(d => d\.hasCoordinates\)\)/u)
+})

+ 103 - 0
tests/waterSupplyAlarmGIS.test.mjs

@@ -0,0 +1,103 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import vm from 'node:vm'
+import { parse } from '@vue/compiler-sfc'
+import { computed, effectScope, nextTick, ref, watch } from 'vue'
+import * as operations from '../src/views/subSystem/alarm/common/utils/alarmOperationsModel.js'
+import * as review from '../src/views/subSystem/alarm/common/utils/alarmReviewModel.js'
+import { HOME_QUICK_ENTRIES, WATER_DEVICE_ROOT_TYPE_ID, buildHomeGisOverview } from '../src/utils/waterHomeModel.js'
+
+const { descriptor } = parse(await readFile(new URL('../src/views/subSystem/waterSupply/alarm/alarmGIS.vue', import.meta.url), 'utf8'))
+const script = descriptor.scriptSetup.content.replace(/^import .+$/gm, '')
+  .replace('import.meta.env.VITE_APP_BASE_API', "''")
+const alarms = [
+  { id: 'w1', equipmentId: 'eq1', deviceCode: 'W-1', deviceName: '供水压力设备', alarmLevel: 1,
+    alarmStatus: 0, actualValue: 0, longitude: 110.4, latitude: 28.4, createTime: '2026-09-24 10:00:00' },
+  { id: 'w2', equipmentId: 'eq1', deviceCode: 'W-1', alarmLevel: 2, alarmStatus: 0, actualValue: null,
+    longitude: 110.4, latitude: 28.4, createTime: '2026-09-24 09:00:00' },
+  { id: 'w3', equipmentId: 'eq2', deviceCode: 'W-2', alarmLevel: 3, alarmStatus: 0, actualValue: null }
+]
+
+async function flush() {
+  for (let i = 0; i < 6; i += 1) await Promise.resolve()
+  await nextTick()
+}
+
+function mountMap(t, rootTypeId = 1) {
+  const calls = { map: [], options: [], history: [] }, scope = ref({ rootTypeId })
+  let unmount
+  const context = {
+    ref, computed, watch, nextTick, ...operations, ...review, WATER_DEVICE_ROOT_TYPE_ID,
+    onBeforeUnmount: callback => { unmount = callback },
+    useAlarmScope: () => ({ scope, scopeKey: computed(() => JSON.stringify(scope.value)) }),
+    getAlarmMap: async params => { calls.map.push(params); return { data: alarms } },
+    getAlarmOptions: async params => {
+      calls.options.push(params)
+      return { data: { alarmLevels: [1, 2, 3, 4].map(value => ({ value, label: `${value}级报警` })) } }
+    },
+    getAlarms: async params => {
+      calls.history.push(params)
+      return { data: { records: alarms.filter(row => row.deviceCode === params.deviceCode), total: 2 } }
+    }
+  }
+  const effects = effectScope()
+  effects.run(() => vm.runInNewContext(`${script}
+    globalThis.page = { error, devices, points, missingCoordinates, levelMetrics,
+      selected, detailVisible, curveChoices, currentCurve, historyPage, historySize,
+      choose, loadHistory }
+  `, context))
+  t.after(() => { unmount(); effects.stop() })
+  return { calls, page: context.page, scope }
+}
+
+test('water GIS requests current water alarms and groups them by equipment', async t => {
+  const { page, calls } = mountMap(t)
+  await flush()
+  assert.equal(calls.map.length, 1)
+  assert.equal(calls.map[0].rootTypeId, 1)
+  assert.equal(calls.map[0].alarmStatus, 0)
+  assert.equal(calls.options[0].rootTypeId, 1)
+  assert.equal(page.devices.value.length, 2)
+  assert.equal(page.points.value.length, 1)
+  assert.equal(page.missingCoordinates.value, 1)
+  assert.equal(page.levelMetrics.value[0].count, 1)
+  assert.equal(page.levelMetrics.value[1].count, 0)
+})
+
+test('water GIS rejects a drainage scope before fetching any alarm data', async t => {
+  const { page, calls } = mountMap(t, 2)
+  await flush()
+  assert.equal(calls.map.length, 0)
+  assert.equal(calls.options.length, 0)
+  assert.match(page.error.value, /供水子系统/)
+})
+
+test('device history carries the water scope and curve choices retain zero but exclude null', async t => {
+  const { page, calls } = mountMap(t)
+  await flush()
+  page.choose(page.devices.value[0])
+  await flush()
+  assert.equal(page.detailVisible.value, true)
+  assert.equal(calls.history[0].rootTypeId, 1)
+  assert.equal(calls.history[0].deviceCode, 'W-1')
+  assert.equal(page.curveChoices.value.length, 1)
+  assert.equal(page.currentCurve.value.id, 'w1')
+  page.historyPage.value = 2
+  await page.loadHistory()
+  assert.equal(calls.history[1].pageNum, 2)
+  assert.equal(calls.history[1].pageSize, 10)
+})
+
+test('home map shortcut includes the required water scope', () => {
+  const entry = HOME_QUICK_ENTRIES.find(item => item.key === 'map')
+  assert.equal(entry.suffix, '/waterSupplyAlarmGIS')
+  assert.deepEqual(entry.query, { rootTypeId: 1 })
+})
+
+test('home overview accepts unified alarm records without duplicating device points', () => {
+  const overview = buildHomeGisOverview({ alarmPoints: alarms })
+  assert.equal(overview.points.length, 1)
+  assert.equal(overview.points[0].id, 'w1')
+  assert.equal(overview.points[0].statusKey, 'alarm')
+})

+ 3 - 1
tests/waterSupplyHomeContract.test.mjs

@@ -65,7 +65,9 @@ test('home overview uses the shared GIS map canvas and aggregates all one-map la
   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, /getAlarmMap\(\{ rootTypeId: WATER_DEVICE_ROOT_TYPE_ID \}\)/u)
+  assert.match(source, /alarmPoints: unwrapResponse\(alarmRes\) \|\| \[\]/u)
+  assert.doesNotMatch(source, /listAlarmMapPoints/u)
   assert.match(source, /getDeviceGisAllPoints\(\)/u)
 })