ソースを参照

供水子系统-监测报警一张图整合

zengy 3 週間 前
親
コミット
fd2d123433

+ 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前端测试文件。需同步更新前后端并重启管理后端,无数据库结构变更,未部署或提交。

+ 4 - 2
memory/index.md

@@ -38,7 +38,8 @@ npm run dev       # 默认 http://localhost:80
 - 门户页调用 `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`。
+- 报警提醒/清单/审核位于 `src/views/subSystem/alarm`(审核入口 `audit.vue`);排水研判及 GIS 已移动至 `src/views/subSystem/drainage/alarm`。详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`。已有环境执行 `migration_alarm_menu_components.sql` 更新四个审核菜单及排水 2141/2143 的 component,path/query/权限保持不变;初装脚本 `migration_alarm_review.sql`、`migration_alarm_operations.sql` 也已同步新路径(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)。
@@ -72,5 +73,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)

+ 3 - 2
memory/long-term.md

@@ -58,15 +58,16 @@
 - 供水报警与工单前端统一使用唯一接单责任人;无手机号不阻断派单。
 - 供水、排水、燃气、窨井共用统一工单业务流程,系统展示范围由关联设备类型过滤;前端优先复用既有 REST 接口,不因页面入口创建平行工单生命周期。
 - 工单系统归属以关联设备类型树稳定业务编码判定;`/api/work-order` 是跨系统规范 API,旧入口只做兼容适配;统一工单继续采用数据库状态机和统一日志语义。
-- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/index.vue`,`sys_menu.path` 均为 `alarmAudit`,菜单 `query.rootTypeId` 按设备类型树隔离,燃气再限定 `warning_code`。确认报警创建待派单工单但不改设备状态;工单后续操作仍在统一工单页。此约定取代早期“审核页承载工单全流程/误报解除”的设计。
+- 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/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/remind.vue`、`alarm/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` 保存根据报警信息生成的故障描述,审核意见单独保存在报警日志中。

+ 77 - 28
memory/sessions/2026-09-24.md

@@ -1,41 +1,90 @@
 # 2026-09-24 会话记录
 
-此前报警审核、运营重构、日志及状态样式调整的完整记录已保留至 [当日前期归档](../archive/2026-09-24-early.md)。稳定决策已合并到长期记忆,不以归档中的过期实现为准。
+此前完整记录见 [报警审核与运营前期归档](../archive/2026-09-24-early.md) 和 [GIS 样式及供水首页归档](../archive/2026-09-24-gis-home.md)。最新入口和稳定决策以 memory/index.md、memory/long-term.md 为准。
 
-## 排水报警 GIS 按设计稿重排
+## 菜单列表为空排查
 
-### 目标与结论
+- 检查 `src/views/system/menu/index.vue`、菜单 API、请求拦截器、`handleTree` 与后端菜单控制器/服务/Mapper,前后端 `data` 数组契约一致。
+- 后端固定查询 `app_user.sys_menu`;仅 `userId=1` 查询全部菜单,其余用户通过 `sys_user_role` / `sys_role_menu` 查询获授权菜单,接口要求 `system:menu:list`。树转换依赖父子关系,异常环或自关联可能导致记录无法进入根节点。
+- 当前实际开发代理为 `127.0.0.1:8301`,与旧记忆中的8300代理不同;未登录只读探测接口返回401,证明接口可达,不能据此判断用户登录会话异常。
+- 未获取用户浏览器中实际列表响应,尚不能确定根因;需提供该请求的业务code、data条数或错误msg(不含凭据)。另发现页面加载失败不释放loading,但未将此作为已确认根因,也未修改业务代码或数据库。
 
-- 用户要求参考 `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 依赖。
+### 接口有返回但列表为空的进一步排查
 
-### 验证结果
+- 用户补充接口有返回。复核当前代码,`AjaxResult.success(List<SysMenu>)` 与页面 `response.data`、`menuId/parentId` 一致,请求拦截器只剥离 Axios 外层;不能仅凭源码判定运行环境返回层级错误。
+- 直接运行现有 `handleTree`:正常驼峰平铺数组得到根/子节点;下划线字段、0号菜单自关联或整组父子循环得到空树;缺失父节点本身会作为根展示,不足以解释全部为空;分页对象产生 `data is not iterable`,页面因无 finally 会保持加载状态。
+- 已请求实际 `/system/menu/list` 响应外层及少量 `menuId/parentId` 样本、Console 错误;未收到前不能确定本次根因。仅诊断,未修改运行时代码、数据库或用户其他改动。
 
-- 报警运营、历史、审核及 GIS 画布/聚合 Node 测试共 42 项通过。
-- 使用本机 Edge 完成隔离浏览器交互测试:统计聚合、空值/零值曲线筛选、历史分页、设备信息、无坐标、地图重试/定位、查询/重置、详情不压缩地图、390px 窄屏无横向溢出;无浏览器 JavaScript 错误。已检查桌面与窄屏截图。
-- `npm run build:prod` 成功,构建输出到系统临时目录,保留原有发布目录;`git diff --check` 通过。
+### 实际菜单数据确认根因
 
-### 风险与后续
+- 用户提供实际菜单数组并明确 Console 无错误。对附件的 308 条记录运行原始 `handleTree`,返回 0 个根节点。
+- 找到异常菜单:报警清单 `menuId=0,parentId=2124`;2124 的父节点是排水根 2011,2011 的 parentId=0,形成父关系环 `0→2124→2011→0`。0 原本是所有顶级目录的父节点哨兵值,被真实菜单主键占用后,所有记录都能找到父节点,构树不会产生根节点,也不会抛异常。
+- 仅在内存中把异常记录改为未占用正 ID,原函数恢复 11 个根、完整 308 条记录;数据字段与数组本身无问题。数据库应修正该菜单主键并核对角色关联,不能把全部顶级 parentId=0 一起改掉。本轮未改代码或数据库。
+- 用户再次提供完整数组;逐条比较后确认新附件与前一份 308 条记录字段完全一致,仅排版不同。报警清单仍为 menuId=0、parentId=2124,2140 仍缺失;原函数仍返回空树,无新增异常。继续保持只读诊断,未改代码或数据库。
 
-- 浏览器验证使用隔离测试数据与地图替身,未验证真实登录、后端实时数据或地图提供商底图加载。部署后需在实际业务菜单中核验。
-- 本轮无后端、SQL 或菜单变更,未提交、推送或部署,保留所有既有工作区修改及 `.feisuan/`、`dist-verify/`。
-- 当日记录接近 20 KB,已归档前期完整记录并同步索引、长期记忆,未丢弃历史内容。
+### 排水工单菜单进入首页时自动展开
 
-## 确认 dist-verify 能否清理
+- 用户要求结合菜单数据分析 2180 自动展开原因。实际展开的是其父目录 2179(工单管理);2179 的 parentId=2011,且二者 path 均为 `subSystem/drainage`。
+- 对附件实际字段运行当前 `joinRoutePath`,根目录 2011 与工单目录 2179 的侧栏 submenu index 同为 `/subSystem/drainage`。Element Plus 按 index 记录展开状态,首页激活时展开排水根目录会同时使工单目录呈现展开。
+- `alwaysShow` 使单子菜单的工单目录保留层级;`unique-opened` 和当前 600ms 自动展开拦截不能区分重复 index,不是 2180 的缓存配置引起。
+- 建议把 2179.path 改为独立相对段(如 `workOrderManage`),实施时检查子路由引用并重新获取菜单路由。本轮仅诊断,未修改业务代码或数据库,保留现有侧栏改动;完成真实路径函数和组件源码核验,未进行浏览器复现。
 
-- 用户仅询问 `E:/work/city-life-line/dist-verify` 能否删除,本轮只检查,未执行删除。
-- 目录不是链接,内容为编译后的 HTML、assets 及静态资源,Git 未跟踪;项目源码、配置和脚本未发现依赖该路径。Vite 默认构建输出为 `dist`,开发服务不依赖 `dist-verify`。
-- 结论:该验证构建产物可清理,不影响源码和正常开发/构建;如有项目外静态服务另行指向该目录,需先停用或切换。需要时可通过 `npm run build:prod -- --outDir dist-verify` 重新生成当前源码的验证产物。
+### 报警提醒首次进入显示延迟排查
 
-## 修复供水首页引用已删除的报警态势接口
+- 用户询问 `remind.vue` 每次进入等待数秒是否由定时刷新导致,本轮仅诊断。
+- 首次 `watch(scopeKey, ..., { immediate: true })` 同步调用 restart/load,激活也调用 load;30 秒轮询用于后续刷新,70ms 定时器仅滚动,不设置首次请求延迟,选项请求亦不阻塞首次列表请求。
+- load 使用 `Promise.all` 等提醒、等级统计均成功后才更新列表:统计较慢会连带延迟已有提醒的显示,任一请求失败也使本轮列表赋值被跳过。
+- 暂停条件同时包含鼠标悬停、焦点、手动暂停及详情打开;首次响应也受 `if (!paused.value)` 限制,暂停时数据已接收但可见列表仍为空,取消暂停后才赋值。
+- 将当前页面真实脚本置于 Node VM,使用 Vue 响应式及可控接口 Promise,验证初次请求先于 mounted/定时器执行、提醒先返回仍等待统计、首次悬停导致 received=1/visible=0、移开鼠标立即显示。仅模拟控制流,未测量用户实际接口耗时,不能据此认定某接口存在慢 SQL。
+- 建议后续将列表与统计分别更新,并保证首次成功加载不被暂停逻辑拦截;未修改运行时代码、数据库或现有用户改动。
 
-- 截图报错为 `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、未重启服务、未部署/提交/推送。过程中发现其他工单代码与测试文件的并行改动,均保留未覆盖。
+### 报警清单审核与处置状态标签
+
+- 用户要求报警清单的审核结果、处置状态像监测状态一样使用不同颜色及背景框;当前实际入口是 `src/views/subSystem/alarm/list.vue`。
+- 两列改为 Element Plus 浅色背景标签,复用详情页 `auditStatusTone` 和统一工单 `orderStatusType` 配色,文本仍来自接口。无关联工单或无状态值时使用灰色,避免将空值误当作待派单。
+- 验证:Vue 脚本及模板编译、目标文件 `git diff --check` 通过;未做浏览器视觉复验。无接口或数据库变更,保留其他工作区改动,未提交或推送。
+
+### 修复报警提醒接口返回后显示延迟
+
+- 用户补充提醒接口已返回但列表偶尔仍需等待,本轮修复 `src/views/subSystem/alarm/remind.vue`。
+- 提醒与等级统计独立更新,提醒成功即可解除加载并显示,不再等待统计;统计、选项及列表错误分别保留并支持重试,辅助接口失败不吞掉已成功返回的提醒数据。
+- 首次成功响应、筛选重载、缓存页重新激活及空列表首次收到事件均立即显示,不被悬停/焦点/手动暂停/详情暂停拦截。已有内容的后续轮询仍保留暂停阅读机制,正常后台刷新不再遮罩已有列表。
+- 保留 30 秒轮询、70ms 滚动与请求代次校验;离开缓存页时清除悬停、焦点、详情暂停,重新激活立即刷新,旧列表及统计响应不能覆盖新查询。
+- 新增 `tests/alarmRemindLoading.test.mjs`,使用真实页面脚本、Vue 响应式和可控请求验证 12 项场景,全部通过;Vue 脚本、模板及样式编译通过,目标代码 diff 检查通过。
+- 未进行真实浏览器或后端耗时复验;本次仅前端修复,无数据库或接口变更,未提交、推送或部署,保留其他改动。
+
+### 门户移除中央盾牌装饰层
+
+- 用户要求去掉门户中间带勾盾牌,避免遮住后方子系统入口;实际文件为 `src/views/portal/index.vue`。
+- 删除引用 `assets/portal/conter.png` 的中央装饰 DOM、相关定位样式及发光动画;背景与子系统旋转、点击跳转逻辑保持不变,图片资源文件保留。
+- 验证:Vue 脚本、模板、SCSS 编译及目标文件 diff 检查通过;未做实际浏览器复验。无后端或数据库改动,未提交、推送或部署。
+
+### 门户编辑器报红原因排查
+
+- 用户询问门户大量红线原因。页面声明 `script setup lang="ts"`,但路由参数、数组回调及点击参数缺少类型;`ref([])` 和以 null 初始化的计时器在严格类型检查下还可能引发 never[]、空值类型相关错误。
+- 项目根目录未发现 tsconfig/jsconfig JSON 配置,package.json 未直接声明 TypeScript/vue-tsc 或类型检查脚本,permission Store 是 JS 且依赖自动导入,类型信息不完整。
+- 实际 Vue 脚本和模板编译通过;诊断接口未返回截图中的 IDE 错误,不能声称已逐项核实全部18处报错。结合截图红线位置,主要指向 TypeScript 隐式 any 与类型推断问题。
+- 本轮仅解释原因和修复方向,未修改业务代码;若继续修复,可补齐页面类型与项目类型配置,或按项目 JS 风格移除该页 TS 专用语法。
+
+### 门户统一 JavaScript 修复报红
+
+- 用户授权按最佳方案修改;结合当前 JS 项目配置,将 `src/views/portal/index.vue` 改为普通 `script setup`,移除 Record/Map 泛型、number 参数注解、非空断言及未使用的 computed/watch 导入,不增加依赖或全局类型配置。
+- 保留页面功能、动画、路由与已有中央装饰移除改动。Vue 编译器以纯 JS 模式完成脚本、模板、SCSS 编译,目标页面 git diff --check 通过。
+- IDE 诊断接口仍返回18条 TS 错误(隐式 any、never、别名模块),部分位置与源码不对应,疑似诊断尚未刷新;未声称已确认 IDE 零错误,需重开文件后核验。未做浏览器复验,未提交或推送。
+
+### 供水报警 GIS 替换旧地图及接口清理
+
+- 按用户要求把现有排水 `alarm/drainage/alarmGIS.vue` 和 SCSS 复制到 `waterSupply/alarm/alarmGIS.vue`,组件名 `WaterSupplyAlarmGIS`,改为供水根类型校验并复用统一地图、详情、历史、曲线和附件组件。
+- 删除前端旧 `monitorAlarm/map.vue`、`wjcbj/jcbjyzt.vue`、`WaterAlarmPage.vue`、专用 `waterAlarmGisModel.js` 及两份旧地图测试;删除 API 包装中的旧地图、台账补全、详情及无调用的历史查询。供水首页地图改用 `getAlarmMap({rootTypeId:1})`,正确解包统一响应,两个地图入口携带 rootTypeId 跳转新菜单。
+- 后端删除旧 `/waterSupply/alarm/map/points`、`/ledger/page`、`/detail/{alarmId}`、`/history`、对应 service 方法及转换逻辑、地图 DTO 和失效测试;保留首页正在使用的统计、实时异常及清警接口。统一地图/曲线支持供水和排水,新增供水 GIS 菜单组件白名单,继续按授权菜单及数据库类型树限制范围,地图固定未处理报警。
+- 由用户提供的菜单数据确认旧功能是 menu_id=2120。新增 Kingbase 脚本 `pipe-ner-server/pipe-network-service/sql/migration_water_supply_alarm_gis.sql`,只更新该 ID:path=`waterSupplyAlarmGIS`、component=`subSystem/waterSupply/alarm/alarmGIS`、query=`{"rootTypeId":1}`、perms=`alarm:operations:list`,保留父级、排序、角色关联。用户所写 JSON 按框架语义配置在 query。
+- 验证:63 项受影响前端检查和新增 5 项真实页面脚本行为检查通过;Maven 编译及 35 项后端 service、mapper、范围、controller、首页实时异常测试通过;前端生产构建成功(输出到系统临时目录),两仓业务代码 diff 检查通过,源代码无旧地图实现残余引用。
+- 未执行数据库 SQL,未做真实登录/Kingbase/地图提供商联调,未部署、提交或推送;已删除代码可从 Git 恢复。需同步部署前后端、执行菜单脚本后重新登录。保留原有及同时出现的门户、报警与 IDE 配置改动。
+
+### 排水报警目录迁移与审核页改名
+
+- 将 `src/views/subSystem/alarm/drainage` 中的 `analysis.vue`、`alarmGIS.vue`、`alarmGIS.scss` 移到 `src/views/subSystem/drainage/alarm`,相对公共组件、工具及样式引用同步更新,原有未提交样式与交互改动完整保留。
+- 将公共审核页 `src/views/subSystem/alarm/index.vue` 改名为 `audit.vue`,更新现有前端测试入口;后端 `AlarmReviewScopeService` 的审核和排水运营组件白名单、范围测试、初始化 SQL 与交付文档均同步新路径。
+- 新增 `pipe-ner-server/pipe-network-service/sql/migration_alarm_menu_components.sql`,只更新菜单 2118/2142/2097/2024(审核)和 2141/2143(排水研判/GIS)的 component,保留 path/query/perms/父级及角色关联。初装 `migration_alarm_review.sql` 和 `migration_alarm_operations.sql` 已直接使用新路径。
+- 验证:13 项前端检查、三个 Vue 页面的脚本/模板/SCSS 编译及导入解析、Maven 编译和 8 项后端范围测试通过;运行时代码与 SQL 无旧组件路径引用。无需改变业务 API 或数据范围。
+- 未执行数据库脚本,未做真实登录/Kingbase 联调,未部署、提交或推送;同步更新前后端、执行组件路径脚本并重新登录后生效。当天记录接近 20 KB,按仓库约定将前期 GIS/首页记录完整归档到 `archive/2026-09-24-gis-home.md`,更新索引和长期记忆。

+ 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%;

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


+ 8 - 3
src/views/subSystem/alarm/list.vue

@@ -19,8 +19,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>
@@ -34,7 +38,8 @@ import { ref, reactive, watch, onBeforeUnmount } from 'vue'
 import { Search, Refresh } from '@element-plus/icons-vue'
 import { getAlarms, getAlarmOptions } 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 './styles/alarmOperations.scss'

+ 42 - 11
src/views/subSystem/alarm/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>
@@ -31,6 +31,8 @@ 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>

+ 1 - 1
src/views/subSystem/alarm/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()

+ 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/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
+import '../../alarm/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/utils/alarmOperationsModel'
+import { hasActualValue } from '../../alarm/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
+import '../../alarm/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/utils/alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../../alarm/utils/alarmReviewModel'
+import { useAlarmScope } from '../../alarm/utils/useAlarmScope'
+import '../../alarm/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>

+ 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/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/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/utils/alarmOperationsModel.js'
+import * as review from '../src/views/subSystem/alarm/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)
 })