Browse Source

对报警提醒,报警清单,报警研判,当前报警GIS一张图进行优化

zengy 3 weeks ago
parent
commit
73bd217106
51 changed files with 1547 additions and 3464 deletions
  1. 138 0
      .feisuan/rules/project_rule.md
  2. 12 2
      CONTEXT.md
  3. 147 0
      memory/archive/2026-09-24-early.md
  4. 9 2
      memory/index.md
  5. 9 0
      memory/long-term.md
  6. 32 0
      memory/sessions/2026-09-24.md
  7. 11 0
      src/api/alarm.js
  8. 0 34
      src/api/drainage.js
  9. 1 11
      src/api/pipeNetwork/waterSupply.js
  10. 45 0
      src/views/subSystem/alarm/AlarmChart.vue
  11. 56 0
      src/views/subSystem/alarm/AlarmDetail.vue
  12. 52 0
      src/views/subSystem/alarm/AlarmHistory.vue
  13. 1 1
      src/views/subSystem/alarm/AlarmList.vue
  14. 1 1
      src/views/subSystem/alarm/AlarmMap.vue
  15. 2 1
      src/views/subSystem/alarm/AlarmSummary.vue
  16. 41 0
      src/views/subSystem/alarm/alarmHistoryModel.js
  17. 18 0
      src/views/subSystem/alarm/alarmOperations.scss
  18. 55 0
      src/views/subSystem/alarm/alarmOperationsModel.js
  19. 128 0
      src/views/subSystem/alarm/drainage/alarmGIS.scss
  20. 180 0
      src/views/subSystem/alarm/drainage/alarmGIS.vue
  21. 55 0
      src/views/subSystem/alarm/drainage/analysis.vue
  22. 13 12
      src/views/subSystem/alarm/index.vue
  23. 68 0
      src/views/subSystem/alarm/list.vue
  24. 64 0
      src/views/subSystem/alarm/remind.vue
  25. 18 0
      src/views/subSystem/alarm/useAlarmScope.js
  26. 2 2
      src/views/subSystem/drainage/fxpg/zhfxtjfx.vue
  27. 3 3
      src/views/subSystem/drainage/fxpg/zhfxxqfx.vue
  28. 3 3
      src/views/subSystem/drainage/home.vue
  29. 0 402
      src/views/subSystem/drainage/jcbj/bjqd.vue
  30. 0 423
      src/views/subSystem/drainage/jcbj/bjtx.vue
  31. 0 859
      src/views/subSystem/drainage/jcbj/bjyp.vue
  32. 0 816
      src/views/subSystem/drainage/jcbj/dqbj.vue
  33. 1 1
      src/views/subSystem/drainage/jcsj/bzyxqk.vue
  34. 1 1
      src/views/subSystem/drainage/jcsj/gwyxqk.vue
  35. 1 1
      src/views/subSystem/drainage/jcsj/sbyx.vue
  36. 2 2
      src/views/subSystem/gas/gasjcyj/gxylyctx.vue
  37. 2 2
      src/views/subSystem/gas/gasjcyj/krqindbj.vue
  38. 1 1
      src/views/subSystem/gas/gasjcyj/xldxkjkrqindbj.vue
  39. 3 3
      src/views/subSystem/lifeCompany/lAlertInformation/LCorrelation.vue
  40. 2 2
      src/views/subSystem/lifeCompany/lAlertInformation/LReportUpload.vue
  41. 1 1
      src/views/subSystem/lifeCompany/lAlertInformation/LTobeHandled.vue
  42. 2 2
      src/views/subSystem/manholeCover/home/index.vue
  43. 24 867
      src/views/subSystem/waterSupply/components/WaterAlarmPage.vue
  44. 1 1
      src/views/subSystem/waterSupply/components/WaterSupplyHome.vue
  45. 0 1
      src/views/subSystem/waterSupply/monitorAlarm/index.vue
  46. 0 1
      src/views/subSystem/waterSupply/wjcbj/bjgl.vue
  47. 1 1
      src/views/subSystem/waterSupply/wjcbj/jcbjyzt.vue
  48. 124 0
      tests/alarmGIS.visual.mjs
  49. 55 0
      tests/alarmHistory.test.mjs
  50. 139 0
      tests/alarmOperations.test.mjs
  51. 23 5
      tests/waterAlarmAuditWorkbench.test.mjs

+ 138 - 0
.feisuan/rules/project_rule.md

@@ -0,0 +1,138 @@
+
+# 城市生命线项目开发规范指南
+
+为保证代码质量、可维护性、安全性与可扩展性,结合项目实际技术栈与目录结构,请在开发过程中严格遵循以下规范。
+
+## 一、项目基本信息
+
+- **项目名称**:city-life-line
+- **工作目录**:`E:\work\city-life-line`
+- **代码作者**:Administrator
+- **操作系统**:Windows 10
+- **构建工具**:Maven (后端), Vite (前端)
+- **SDK版本**:JDK 11.0.27
+
+## 二、目录结构
+
+项目采用前后端分离架构,以下为当前项目目录结构:
+
+```text
+city-life-line
+├── html
+├── public
+├── src
+│   ├── api                  # 接口定义层
+│   │   ├── monitor          # 监控相关接口
+│   │   ├── pipeNetwork      # 管网相关接口
+│   │   ├── smxyjldczxt      # 业务模块接口
+│   │   ├── system           # 系统管理接口
+│   │   ├── tool             # 工具接口
+│   │   ├── warning          # 预警接口
+│   │   └── work             # 工单接口
+│   ├── assets               # 静态资源
+│   ├── components           # 公共组件
+│   ├── directive            # 自定义指令
+│   ├── layout               # 布局组件
+│   ├── plugins              # 插件
+│   ├── router               # 路由配置
+│   ├── store                # 状态管理
+│   ├── utils                # 工具类
+│   └── views                # 页面视图
+│       ├── error            # 错误页面
+│       ├── monitor          # 系统监控
+│       ├── portal           # 门户
+│       ├── subSystem        # 子系统业务模块
+│       ├── system           # 系统管理
+│       └── tool             # 系统工具
+└── vite                     # Vite 配置
+```
+
+## 三、技术栈要求
+
+根据项目环境与目录特征,确定技术栈如下:
+
+- **前端框架**:Vue 3 + Vite
+- **UI 组件**:Element Plus (推测,基于 RuoYi-Vue 结构)
+- **状态管理**:Vuex / Pinia (位于 `store` 目录)
+- **后端环境**:JDK 11 + Maven
+- **后端框架**:Spring Boot (推荐版本 2.5+ 以适配 JDK 11)
+
+## 四、分层架构规范
+
+### 前端分层规范
+
+| 目录        | 职责说明                         | 开发约束与注意事项                                               |
+|-------------|----------------------------------|----------------------------------------------------------------|
+| **views**   | 页面结构与展示逻辑               | 仅负责页面渲染,业务逻辑应抽离至 API 或 Utils 中               |
+| **api**     | 接口请求定义                     | 按业务模块划分文件(如 `system/user.js`);统一使用封装后的请求工具 |
+| **components** | 可复用 UI 组件                | 遵循单一职责原则;组件命名采用 PascalCase (如 `UserInfo.vue`)    |
+| **store**   | 全局状态管理                     | 模块化管理状态;避免存储临时性或非共享数据                     |
+| **utils**   | 通用工具函数                     | 纯函数开发,避免副作用;添加清晰的 JSDoc 注释                  |
+
+### 后端分层规范
+
+| 层级        | 职责说明                         | 开发约束与注意事项                                               |
+|-------------|----------------------------------|----------------------------------------------------------------|
+| **Controller** | 处理 HTTP 请求,响应前端调用    | 不包含业务逻辑,负责参数校验与结果封装                         |
+| **Service**    | 核心业务逻辑处理                | 复杂业务需拆分方法;事务控制在此层处理                         |
+| **Repository/Mapper** | 数据库交互                | 禁止在代码中拼接 SQL;优先使用 ORM 框架特性                    |
+| **Entity/DO**  | 数据库实体映射                  | 禁止直接暴露给前端,需转换为 DTO/VO                             |
+
+## 五、代码风格规范
+
+### 命名规范
+
+| 类型       | 命名方式             | 示例                  | 适用范围 |
+|------------|----------------------|-----------------------|----------|
+| 文件/组件  | PascalCase           | `UserProfile.vue`     | 前端     |
+| 变量/函数  | lowerCamelCase       | `getUserList()`       | 全栈     |
+| 常量       | UPPER_SNAKE_CASE     | `MAX_RETRY_COUNT`     | 全栈     |
+| CSS 类名   | kebab-case           | `user-container`      | 前端     |
+| 包名       | 全小写               | `com.city.lifeline`   | 后端     |
+
+### 注释规范
+
+- **语言要求**:注释内容必须使用**中文**(用户第一语言)。
+- **类/方法注释**:必须添加 Javadoc 或 JSDoc 注释,说明功能、参数及返回值。
+  ```java
+  /**
+   * 获取用户信息列表
+   * @param query 查询条件
+   * @return 用户列表
+   */
+  public List<UserVO> selectUserList(UserQuery query);
+  ```
+  ```javascript
+  /**
+   * 获取用户列表
+   * @param {Object} params - 查询参数
+   * @returns {Promise} - 异步结果
+   */
+  export function listUser(params) {
+    return request({ url: '/system/user/list', method: 'get', params })
+  }
+  ```
+
+## 六、安全与性能规范
+
+### 前端安全
+
+- **路由守卫**:在 `router` 中配置权限校验,防止未授权访问。
+- **XSS 防护**:禁止使用 `v-html` 渲染用户输入内容,确需使用必须先进行转义。
+- **敏感信息**:禁止在前端代码中硬编码密钥、Token 等敏感信息。
+
+### 后端安全
+
+- **输入校验**:使用 JSR-303 注解进行参数校验 (`javax.validation.constraints.*`)。
+- **SQL 注入**:严禁拼接 SQL 字符串,必须使用预编译语句或 ORM 框架。
+- **权限控制**:关键接口必须添加权限注解(如 `@PreAuthorize`)。
+
+## 七、编码原则总结
+
+| 原则       | 说明                                       |
+|------------|--------------------------------------------|
+| **组件化** | 前端页面拆分为独立组件,提高复用性与可维护性 |
+| **模块化** | API、Store、Utils 按功能模块划分文件       |
+| **RESTful**| 后端接口遵循 RESTful 设计风格              |
+| **DRY**    | 避免重复代码,公共逻辑封装为 Utils 或 Service |
+| **KISS**   | 保持代码简洁,避免过度设计                 |

+ 12 - 2
CONTEXT.md

@@ -13,13 +13,23 @@ _Avoid_: 维修任务、异常工单
 _Avoid_: 报警处理、报警派单
 
 **报警审核**:
-四个子系统共用审核页,对未处理报警做出“确认报警”或“误报警”结论。确认报警创建待派单工单,但两种审核都不修改设备状态或报警处理状态;完整工单处置由统一工单页承载,审核页只展示处置记录。
+四个子系统共用审核页,对未处理报警做出“确认报警”或“误报警”结论。确认报警创建待派单工单,但两种审核都不修改设备状态或报警处理状态;完整工单处置由统一工单页承载。确认日志直接包含“已转工单(实际工单号)”与选填意见,工单关联及后续操作不复制到报警日志。审核页与详情页共用按日期分组的倒序报警历史,样式参照 `dispose.vue`,人工操作显示用户昵称。
 _Avoid_: 工单审核、工单处置
 
 **报警等级**:
 根据报警规则和实际监测数据计算出的严重程度,共分一级、二级、三级和四级,其中一级最严重。
 _Avoid_: 工单优先级
 
+**报警运营页面**:
+供水/排水共用 `subSystem/alarm/list` 清单,排水另设 `remind` 提醒、`drainage/analysis` 研判和 `drainage/alarmGIS` GIS。查询范围由菜单 `rootTypeId` 与后端获授权菜单共同约束,设备类型包含根及后代;清单展示历史已恢复事件,审核列表仍只看未处理事件。GIS 详情仅设备信息、报警记录、监测曲线,无周边环境页签。
+_Avoid_: 将清单权限等同于审核权限
+
+报警提醒仅展示 `alarm_status=0`,移除跳转报警清单操作。清单查询使用“报警时间”和“请输入设备编码/名称”,移除审核结果查询条件但保留结果列。GIS 监测曲线与研判事件候选仅保留实际值非空的事件,0 正常保留;地图点位和报警记录不因此过滤,研判候选在后端分页前筛选。
+
+**报警单号**:
+`alarm_data.alarm_no`,新记录由后端程序在保存前生成 `BJyyyyMMddHHmmss` + 4 位随机数,使用北京时间;保留数据库唯一索引,普通更新不可修改单号;不使用数据库生成触发器或序列。清单/审核首列与转工单创建日志使用该号;不回填历史,无号码显示 `—`。
+_Avoid_: 报警 UUID、监测指标编码、工单编号
+
 **报警展示状态**:
 由报警持久化状态、审核结果和关联工单推导出的面向用户的状态,用于区分待审核、已转工单、已解除和误报警;它不替代设备报警状态。
 _Avoid_: 报警记录持久化状态、设备报警状态
@@ -69,7 +79,7 @@ _Avoid_: 已延期、延期审核、已结案
 _Avoid_: 维修任务、报警工单
 
 **同类报警工单**:
-同一设备针对同一报警编码建立的故障维修工单;报警编码缺失时才按报警分类判定,同一时间只能存在一个尚未结束的同类报警工单。
+同一设备主键针对同一 `alarm_data.warning_code` 建立的故障维修工单,同一时间只能存在一个尚未结束的同类报警工单(0/1/2/3/4/6),已结案或已取消不占名额;普通手动建单、隐患转工单不限制同设备数量。报警编码缺失时阻止报警转工单,不再按报警显示名称替代判定(2026-09-24)。
 _Avoid_: 同一报警记录、同设备全部工单
 
 **随同修复**:

+ 147 - 0
memory/archive/2026-09-24-early.md

@@ -0,0 +1,147 @@
+# 2026-09-24 前期会话归档
+
+原始会话按时间保留,后续条目覆盖前面的过期约定;当前事实以 `../long-term.md` 为准。后续会话见 `../sessions/2026-09-24.md`。
+
+## 报警审核意见选填与取消关闭确认
+
+### 目标与结论
+
+- 用户要求关闭报警审核弹窗不再提示“放弃本次审核”,审核意见改为非必填。
+- `src/views/subSystem/alarm/index.vue` 删除丢弃确认及无用 dirty 状态,取消、关闭按钮、Esc 和路由切换不再触发未保存确认;上传或提交中仍保护退出。审核结果必选,意见选填、最多 500 字,输入框提示同步调整。
+- 后端 `AlarmReviewRequest` 移除意见的 `@NotBlank`,保留 `@Size(max=500)`;`AlarmReviewService` 允许 null、空字符串及纯空白意见,统一以空字符串记录日志,继续限制长度。
+- 不修改确认报警建单、故障描述生成、日志事务、设备状态或附件逻辑。不需要数据库脚本。
+- 同步前端 `tests/waterAlarmAuditWorkbench.test.mjs`、后端审核 service/controller 测试及后端交付说明 `docs/alarm-review-2026-09-23.md`;本条覆盖上一日意见必填和退出需确认的历史交互约定。
+
+### 验证结果
+
+- 前端模型与审核页相关 Node 测试 14 项通过;报警审核 SFC 的脚本、模板及 scoped SCSS 编译通过。
+- 后端 Maven 审核 service/controller、工单 service 测试共 42 项通过(17 + 8 + 17),覆盖两种审核结果的空意见、不传意见、null、空白、长度边界及无意见确认建单。
+- 两个仓库 `git diff --check` 通过。未执行全量前端生产构建、真实登录浏览器 E2E 或真实数据库操作。
+
+### 风险与后续
+
+- 前后端需同步部署,后端重新构建启动后才会取消接口必填限制;上线后检查取消、关闭、Esc 和空意见提交。
+- 保留既有 `.feisuan/` 未跟踪目录,本轮未提交或推送代码。
+
+## 创建工单取消设备级互斥,报警转工单改为同类查重
+
+### 目标与结论
+
+- 用户要求移除“该设备已有处理中的工单,需结案或取消后才能创建新工单”,仅报警审核转工单时限制同设备 + 同报警类型的未完成工单。实际表字段是 `alarm_data.warning_code`。
+- 后端 `WorkOrderServiceImpl` 删除设备级查重及手动/隐患建单中的调用;报警建单通过 `alarm_data.order_id` 关联 `work_order`,只比较设备主键 `work_order.device_id` 和报警 `warning_code`,状态采用 `WorkOrderStatusEnum.activeCodes()`(0/1/2/3/4/6)。不同编码、其他设备、已结案/已取消、无报警关联的手动或隐患工单不占同类名额。
+- 增加 `EquipmentBaseMapper.lockById`,在建单事务中对设备行执行 `FOR UPDATE`,锁后刷新读取报警关联并查重;`WorkOrderMapper.existsActiveAlarmOrder` 不使用部门过滤或报警处理状态过滤。锁与查重查询禁用缓存并刷新 MyBatis 会话缓存,防止等待锁后读取旧结果。
+- 同一报警重复调用仍复用已有工单;报警编码缺失时明确报错,不按显示名称替代。确认报警建单、审核事务和日志保持原逻辑,不修改设备状态。无需结构脚本,不迁移或处理既有重复工单。
+- 后端修改工单 service/interface、设备/工单 mapper 和对应测试,更新交付说明及领域语境;前端仅清理排水批量报修的一条过期注释并同步语境/记忆,未变更运行逻辑。
+
+### 验证结果
+
+- Maven 管理端关联测试 64 项通过:工单 service 26、新增 mapper 契约 2、审核 service 17、审核 controller 8、清警 8、供水范围/未办结口径 3。
+- 覆盖手动/隐患建单不查设备冲突、同设备同编码查重、锁与查重/建单顺序、锁后幂等重读、档案删除、报警编码缺失、未办结枚举和字段关联 SQL 契约;保留工单故障描述和设备状态相关回归。
+- 前端报警模型及审核页相关 Node 测试 14 项通过;两个仓库 `git diff --check` 通过。
+- 未连接真实 KingbaseES,未执行真实双连接并发/浏览器 E2E;行锁通过代码顺序和 MyBatis SQL 契约验证,实机并发仍需部署后验收。
+
+### 后续
+
+- 重新构建并启动后端后生效。数据库无需新增字段、索引或迁移脚本;既有重复工单不自动合并或取消。
+- 保留上一轮未提交改动和 `.feisuan/`,本轮未提交或推送。
+
+## 报警提醒、清单、排水研判与 GIS 重构
+
+### 目标与结论
+
+- 用户要求将排水报警提醒/清单/研判/当前 GIS、供水报警管理整合到统一报警模块。后续明确 GIS 文件为 `alarmGIS.vue`、菜单 path 为 `drainageAlarmGIS`,不需要周边环境页签,已同步代码、后端授权组件白名单和菜单 SQL。
+- 新增 `src/api/alarm.js` 与 `alarm/remind.vue`、`list.vue`、`drainage/analysis.vue`、`drainage/alarmGIS.vue`,共享详情/图表/查询范围及数据模型。清单使用框架 pagination、后端分页排序,首列预警单号;按设备/单号/等级/监测状态/审核结果/日期查询。
+- 提醒每 30 秒刷新,悬停/聚焦/手动/详情打开期间停止滚动且缓冲更新。研判展示真实曲线、事件当天、日均和月均以及独立最新采样;缺值不补零,异常采样间隔与缺失整日/月断线,状态报警无数值曲线时明确提示。
+- GIS 复用系统 GisMapCanvas:设备聚合、等级颜色、定位、聚合展开、底图重试、无坐标列表。详情三个页签为设备信息、报警记录、监测曲线;设备历史记录单独按设备编码后端分页,包括已恢复记录。
+- 修正菜单导航读取实际 Pinia 属性 `sidebarRouters`,按 rootTypeId 查获授权入口,供水/排水相同 alarmList 不串系统。后端独立校验运营菜单和审核菜单;GIS/研判只接受排水根。
+- 删除 4 个旧排水 Vue、供水旧管理入口和 bjgl 包装;WaterAlarmPage 仅保留供水 GIS,去掉管理分支及独占 JS/CSS;删除旧 API 包装。后端删除相应供水统计/导出接口及 DTO、排水旧处理审批方法,保留其他页面仍使用的共享查询。删掉的受版本管理源码可从 Git 历史恢复,未删除业务数据库表。
+- `migration_alarm_operations.sql` 按 menu_id 精确更新 2139/2140/2141/2143/2117,不改父级和角色关联;2117 同时保留供水首页/GIS 所用查询权限。新 alarm_no 数据库日期+序列生成,唯一不可改,不回填历史;报警触发/自动恢复事务日志、工单创建日志使用预警单号。自动恢复由采集事务标记区分,人工处理不误标为自动。
+
+### 验证
+
+- 前端相关 Node 测试 22 项通过;生产构建通过,保留已有 Sass/包体积提示。
+- 后端管理端相关测试 72 项通过,zk-api-service 编译通过。原 CollectionAlarmWriter 缺失阻塞已解除。
+- SQL PGlite 兼容性测试 6 项通过,覆盖日期唯一单号、不可变、不回填、触发/恢复日志、来源区分、重复执行/回滚、精确菜单及实际 MyBatis 查询/原始样本均值。
+- 两仓 diff 检查通过。没有连接实际 KingbaseES、执行真实登录浏览器或底图联调;没有提交、推送、部署。
+
+### 后续
+
+- 交付说明:`E:/work/pipe-ner-server/docs/alarm-operations-2026-09-24.md`。依赖上一轮审核脚本;首次部署按审核脚本、运营脚本顺序执行,本轮脚本须最后执行,避免旧脚本覆盖生命周期函数。
+- 部署管理端、采集 API、前端,重新登录刷新菜单;需 KingbaseES 实机及实际数据验收。采集周期未固定,页面不虚构数据覆盖率;无数据源的状态类报警只展示事件与时间线。
+- 保留此前修改和 `.feisuan/`;此次清理无数据表删除或历史数据迁移。
+
+## alarm_no 改为报警单号、BJ 前缀
+
+- 用户明确将 `alarm_no` 的名称从“预警单号”改为“报警单号”,生成前缀从 YJ 改为 BJ。同步清单/审核/提醒/GIS/详情/研判文案、研判单号搜索识别、后端实体注解、工单创建日志文案、SQL 触发器和列注释,以及测试与交付说明。
+- 字段仍是 `alarm_data.alarm_no`,新号格式 `BJYYYYMMDD` 加至少 8 位全局序列;沿用序列、不重置、不迁移已有号码。数据库脚本位置仍为 `E:/work/pipe-ner-server/pipe-network-service/sql/migration_alarm_operations.sql`,本轮未连接或执行实际数据库。
+- 前端相关 Node 测试 16 项、SQL 兼容性测试 6 项、后端工单 service 测试 26 项通过;字段/单号命名改动未新增依赖。部署按最新脚本更新生成函数并重新部署前后端。
+- 长期记忆和领域文档已更新,本条取代此前记录的 YJ/预警单号命名。未提交、推送或修改其他设备编码中的 YJ 业务分类。
+
+## 报警单号改由程序生成
+
+- 用户要求 `alarm_data.alarm_no` 由程序生成,取消数据库自动赋号。管理端 `AlarmDataServiceImpl.saveAlarmData`(新增、测试报警、两个演练入口)与采集端 `CollectionAlarmWriter` 在 INSERT 前生成 `BJ` + 北京日期 `YYYYMMDD` + MyBatis-Plus 雪花 ID,覆盖请求带入号码。复用已有报警、等级变化和恢复不改号。
+- 两端 AlarmData 将 alarm_no 插入策略从 NEVER 改为 NOT_NULL,更新策略仍为 NEVER。数据库保留唯一索引;不依赖序列、字段默认值或生成触发器,不回填历史。
+- 更新原 `migration_alarm_operations.sql`,兼容清理旧 `trg_alarm_number`、`assign_alarm_number()`、默认值、`alarm_no_seq`,保留报警日志触发器。已有号码和日志不重写。
+- 工作区出现其他改动:报警工单关联改为 `work_order_id`、报警时间改为 `create_time`,全部保留;修正旧 Mapper 测试的关联字段断言以验证最新真实 SQL。
+- 验证:程序赋号专项 7 项通过(管理端 3、采集端 4,含 2000 条并发唯一、先赋号再入库、复用不改号、失败回滚、插入/更新策略);工单 26 项和运营 Mapper 1 项通过;SQL PGlite 6 项通过,含已部署旧触发器环境的清理及重复执行。两仓 diff 检查通过。
+- 同步后端交付说明、两端 CONTEXT 和长期记忆;取代前文“数据库按序列生成”的约定。未执行真实数据库、提交或部署。上线需执行最新版脚本并部署管理端与采集 API,已有历史号码保持原值。
+
+## 报警审核日志收敛、昵称与倒序历史
+
+### 目标与结论
+
+- 用户要求确认报警不再单独记录关联工单/工单操作,而在确认日志内显示“已转工单(实际工单号)”;人工日志使用用户昵称;历史倒序并参考 `dispose.vue` 样式;两后端日志类型枚举合一。
+- 后端审核在成功创建工单并读取真实 `order_no` 后,仅插入一条确认日志,描述含选填意见及工单号,缺失工单号或日志写入失败则异常回滚;保留 `work_order_log` 和不修改设备状态的约定。删除工单写入报警日志的 mapper 方法及调用。
+- 审核与人工处理通过 `SysUserMapper` 取 `sys_user.nick_name`,同时存用户 ID;历史查询关联用户昵称,按 `oper_time DESC NULLS LAST, log_id DESC` 排序。自动动作仍使用系统名称。旧类型历史行保留显示“历史操作”,不删除或迁移历史数据。
+- 新增根模块 `alarm-contract`,唯一的 `AlarmLogOperTypeEnum` 保留 0 报警触发、1 确认报警、2 误报警、3 报警处理;删除 zksy-system / zk-api-service 重复枚举及工单相关类型,两端 Maven 引用该模块。
+- 新增前端 `alarm/AlarmHistory.vue`、`alarmHistoryModel.js`,审核页和详情页共用。按日期、时间及同一时间日志 ID 倒序分组,显示时间、加粗昵称、动作、描述及 50px 图片缩略图;保留点击大图预览,不使用不安全 HTML。
+- 增量脚本 `E:/work/pipe-ner-server/pipe-network-service/sql/migration_alarm_review_log.sql` 清理旧日志触发器/自定义函数,使用 `NOT VALID` 限制新日志类型为 0~3、保留历史旧类型行;同步完整建表脚本及交付说明。
+- 同步长期记忆及语境中的过期单号约定:最新格式已是程序生成 `BJyyyyMMddHHmmss` + 4 位随机数(北京时间),不是雪花 ID;新增/解除日志均由 Java 事务主动写入,不依赖自定义函数/触发器。本条覆盖前述旧约定。
+
+### 验证结果
+
+- 后端根目录执行管理端与采集端 Maven `clean test`,本轮选定 11 个测试类共 97 项通过、零失败/错误/跳过;清理旧 class 后重新编译,验证共享枚举依赖不依赖残留产物。
+- PGlite SQL 兼容测试 18 项通过,覆盖真实历史 SQL 昵称关联及倒序、历史旧类型保留、禁止新写入旧类型、增量脚本可重跑,以及无触发器新增/恢复日志与回滚。
+- 前端相关 Node 测试 18 项通过,生产构建通过,输出到临时目录;未覆盖既有 `dist-verify/`。两仓 `git diff --check` 通过。
+
+### 风险与后续
+
+- 未连接/执行真实 KingbaseES,未进行真实登录浏览器验收;SQL 兼容测试不是 KingbaseES 实机验收。未提交、推送或部署。
+- 停止相关写入后执行增量脚本,同步部署管理端、zk-api-service、前端;从后端根目录使用 `-pl zk-api-service,pipe-network-service/zksy-admin -am` 构建以包含共享模块,单独构建前先安装该模块及父 POM。
+- 保留工作区原有修改和 `.feisuan/`,删除的重复枚举/无用方法可由版本管理恢复,业务日志数据未删除。
+
+## 报警日志枚举改为两端各自定义
+
+- 用户明确不要新增 `alarm-contract` 模块,允许 zksy-system 与 zk-api-service 各创建一份相同的 `AlarmLogOperTypeEnum`。本条取代上一节共享模块的实现和安装要求。
+- 管理端恢复 `com.zksy.base.alarm.enums.AlarmLogOperTypeEnum`,采集端新增 `com.zksy.api.enums.AlarmLogOperTypeEnum`;两份除包名外完全一致,仍只保留 0 触发、1 确认、2 误报、3 处理。同步服务与测试 import。
+- 删除新增共享模块源码/POM,移除根 POM 模块声明及两个工程的依赖;最终检查 `E:/work/pipe-ner-server/alarm-contract` 目录不存在,无运行时代码引用。被移除枚举内容保存在两个本地枚举文件,不丢失业务逻辑或数据。
+- 新增采集端枚举契约测试,管理端同步校验全部类型名称及编码。根目录联合 `clean test` 共 12 类、98 项通过,零失败/错误/跳过;两份源码一致性及两仓 diff 检查通过。
+- 已更新后端交付说明、领域语境、前端长期记忆及索引。前端运行时代码和 SQL 未变,无新增数据库脚本;未提交、推送或部署。
+
+## 报警处置历史日期显示今天、昨天
+
+- 用户要求处置全流程的时间参考工单历史,以今天、昨天等形式显示。读取 `dispose.vue` 的 `dayLabel` 后,在报警历史沿用“今天 · 月日”样式并增加昨天、跨年及无效日期处理,未改工单页面。
+- `alarmHistoryModel.js` 新增 `historyDayLabel`,按本地日历日期判定今天/昨天,避免直接取 UTC 日期导致凌晨误判。当天/昨天保留具体月日,同年其他记录显示月日,跨年记录显示年月日;空值/无效日期显示“时间未记录”。
+- `AlarmHistory.vue` 日期分组标题调用该函数,悬停显示原始完整日期。审核页和详情页同步生效;每条记录时分秒、时间倒序、昵称、图片展示均保持不变。
+- `tests/alarmHistory.test.mjs` 增加今天/昨天、跨月、跨年、闰日、无效值及模板接入测试;Asia/Shanghai 时区下相关 Node 回归共 21 项通过,组件脚本/模板/样式编译及 diff 检查通过。
+- 仅前端变更,无数据库脚本;未执行全量生产构建或真实登录浏览器验收,未提交、推送或部署。已同步长期记忆及索引。
+
+## 今天、昨天标题不再附加日期
+
+- 用户要求今天/昨天不显示后面的日期。`alarmHistoryModel.js` 改为仅返回“今天”“昨天”,更早日期、悬停完整日期及每条记录的时分秒不变,审核页与详情页同步生效。
+- 更新 `tests/alarmHistory.test.mjs` 断言以及长期记忆、索引;相关 Node 测试 21 项通过,diff 检查通过。无后端/SQL 修改,未部署或进行浏览器验收。
+
+## 报警提醒、清单查询、GIS 曲线与研判候选调整
+
+- 用户四点要求均已落实:提醒仅未处理且移除报警清单操作;清单时间条件改“报警时间”、设备提示改“请输入设备编码/名称”、移除审核结果筛选;GIS 监测曲线和研判下拉仅实际值非空事件。
+- 后端 `AlarmOperationsService.reminders` 强制使用 `AlarmRecordStatusEnum.UNHANDLED`,不能通过请求改查已处理事件。`AlarmQuery.actualValueOnly` 默认 false,Mapper 在 WHERE 中加 `actual_value IS NOT NULL` 后再由框架分页,研判使用 true;普通清单/地图查询不受此筛选影响。`analysis` 对实际值为空的报警直接返回 unsupported 和空采样,不查监测表。
+- 前端 `remind.vue` 请求显式状态 0,删除清单导航按钮及无用变量;`list.vue` 修改查询文字及筛选状态,但保留审核结果表格列。`alarmOperationsModel.js` 新增共享候选筛选函数,保留 0、负数,排除 null/undefined/空串并按 ID 去重。
+- `alarmGIS.vue` 只过滤曲线候选/曲线按钮,不过滤点位和历史记录;默认选择首个有值事件,切换设备或历史页后校验当前选择,无候选时显示空状态且不加载图表。`analysis.vue` 使用后端过滤且校验深链接详情,不能将无实际值事件补入下拉。`AlarmDetail.vue` 对无值事件隐藏进入研判按钮。
+- 验证:管理端 service/mapper/controller/数据范围相关 Maven 测试 22 项通过;前端 Node 26 项通过;PGlite 实际 SQL 7 项通过,覆盖未处理、空值/零值、过滤先于分页及总数和设备类型范围;前端生产构建通过,输出到临时目录,未覆盖现有构建目录。两仓 diff 检查通过。
+- 同步交付说明、前后端语境、长期记忆和索引。无需数据库结构 SQL;前后端重新部署后生效,未执行实际数据库、浏览器登录验收、提交或推送。保留工作区其他改动。
+
+## 报警详情三项状态样式减轻
+
+- 用户参考截图要求监测状态、审核结果、处置状态的标签和值不要太粗。`AlarmDetail.vue` 改为三列横排的标签和值,使用语义化 dt/dd,标签 13px、状态标签 12px、字重均 400;状态使用小号浅色、无边框标记,窄屏自动换行。
+- 删除 `alarmOperations.scss` 中旧状态灰底卡片样式,组件采用 scoped 样式避免影响其他页面;工单关联/责任人/期限表格和历史布局不变。监测与审核颜色映射只影响展示,处置颜色复用现有工单字典,不新增状态含义或修改接口。
+- 相关 Node 测试 28 项、前端生产构建及 diff 检查通过。构建输出到临时目录,未覆盖现有发布目录;未进行真实登录浏览器视觉验收、提交、推送或部署。无后端/数据库修改,同步长期记忆和索引。

+ 9 - 2
memory/index.md

@@ -38,8 +38,14 @@ 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)。
-- 排水报警详情 API 请求 `/api/alarm/{id}`,后端实际路径为 `/api/alarm/getById/{id}`。
+- 报警提醒/清单/研判/GIS 已统一至 `src/views/subSystem/alarm`;新详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`,旧未使用的排水详情 API 包装已删除。部署需执行后端 `migration_alarm_operations.sql`,GIS 最新名称为 `alarmGIS.vue` / `drainageAlarmGIS`。
+- 报警单号 `alarm_no` 最新要求为后端程序生成(`BJyyyyMMddHHmmss` + 4 位随机数,北京时间),原数据库生成触发器/序列由最新版脚本移除;管理端和采集 API 需同步部署。
+- 报警确认只记一条审核日志,包含实际工单号;工单操作不复制到 `alarm_log`。历史共用 `AlarmHistory.vue`,昵称、时间倒序、日期分组及图片预览;后端两工程各自定义 `AlarmLogOperTypeEnum`,值保持一致(0~3),不新增共享模块,已有环境执行 `migration_alarm_review_log.sql` 后同步部署两端及前端,历史旧日志保留(2026-09-24)。
 - 若干排水请求设置 `X-Silent-Error`,但 Axios 拦截器未处理该标志。
+- 报警处置历史当天/前一天只显示“今天”“昨天”,不附加日期;更早记录显示日期、跨年保留年份。审核页和详情页共用,具体操作时间仍保留时分秒。
+- 报警详情三项状态已改为普通字重、横排小号浅色标签,去掉灰底大卡片;其他详情内容和状态逻辑保持不变。
+- 排水 `alarmGIS.vue` 已按设计稿调整为等级统计、设备列表和大地图,详情使用不压缩地图的侧边抽屉;沿用真实接口、百度画布和系统分页。42 项相关回归、隔离浏览器布局/交互检查及生产构建通过,真实底图与登录环境待部署核验(2026-09-24)。
+- 报警提醒仅未处理;清单查询文案/审核结果筛选已调整;GIS 曲线与研判下拉仅允许有实际值事件(包含 0),研判后端通过 `actualValueOnly=true` 在分页前过滤。此次无需结构脚本,前后端需部署后生效。
 - 百度地图 AK 仍由 `index.html` 直接配置,环境变量未形成统一生效入口。
 - `package.json` 没有测试、Lint 或类型检查脚本。
 - 供水管网信息查询页已重构接入统一表 `pipe_network_base`(`/PipeNetworkBase/*`,network_type=2);旧表 `water_pipe_info` 的接口、实体、mapper 与前端死代码已全部删除,供水 GIS 图与统计分析 mapper 已迁移至新表(均带 `network_type='2'`),压力分布图改为等级分布(`pipe_network_level` 字典);数据迁移与删表由用户侧执行(2026-09-09)。
@@ -64,5 +70,6 @@ npm run dev       # 默认 http://localhost:80
 - [2026-09-10:WGS84→BD09 诊断与管理端工单生命周期共识更新](sessions/2026-09-10.md)
 - [2026-09-16:监测报警 GIS 接入统一画布(Ticket #4)](sessions/2026-09-16.md)
 - [2026-09-22:报警阈值移除备注字段](sessions/2026-09-22.md)
-- [2026-09-24:告警表删除五个字段及前后端适配](sessions/2026-09-24.md)
 - [2026-09-23:感知设备管理、案件 equipment_id、四子系统统一报警审核](sessions/2026-09-23.md)
+- [2026-09-24:排水报警 GIS 设计稿布局与交互调整](sessions/2026-09-24.md)
+- [2026-09-24 前期归档:报警审核交互、同类工单限制、报警运营重构、日志与历史样式](archive/2026-09-24-early.md)

+ 9 - 0
memory/long-term.md

@@ -60,8 +60,17 @@
 - 工单系统归属以关联设备类型树稳定业务编码判定;`/api/work-order` 是跨系统规范 API,旧入口只做兼容适配;统一工单继续采用数据库状态机和统一日志语义。
 - 报警管理侧重只读态势与台账;四子系统报警审核共用 `src/views/subSystem/alarm/index.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_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 截图样式要求)。
+- 报警提醒服务固定只查 `alarm_status=0`,页面不提供“报警清单”操作。报警清单时间条件叫“报警时间”,设备提示“请输入设备编码/名称”,移除审核结果筛选但保留表格结果列。GIS 曲线选项和研判下拉仅展示 `actual_value` 非空事件(0 有效);地图点位、报警历史不受此限制,研判通过 `actualValueOnly=true` 后端先过滤再分页,无实际值深链接不能重新加入下拉(2026-09-24)。
+- 排水 GIS 样式参考 `prd/outputs/alarm-gis.html`:顶部筛选/等级统计、左侧设备列表、大地图及覆盖式详情抽屉,打开详情不挤占地图。统计展示当前结果的设备数,不是报警事件总数;沿用红橙蓝绿等级色、真实 GisMapCanvas/API 和框架分页。详情仅监测曲线、报警记录、设备信息,数值快照明确标注为触发值,不使用原型合成数据或周边环境内容(2026-09-24)。
+- 报警处置历史日期标题:当天只显示“今天”,前一天只显示“昨天”,不附加日期;同年其他日期显示“M月D日”,跨年保留年份。按本地日历日判断,悬停保留完整日期,每条操作仍显示时分秒,不改变倒序规则(2026-09-24 最新要求)。
 - 报警实际值为空时不展示实际值/阈值行,0 仍正常展示;审核转工单的 `work_order.order_desc` 保存根据报警信息生成的故障描述,审核意见单独保存在报警日志中。
+- 报警审核意见选填,最多 500 字,确认报警和误报警均允许不填,审核结果仍必选。正常关闭审核弹窗或切换路由不再弹出放弃确认;上传、提交进行中仍保留退出保护。此约定取代早期意见必填和未保存退出需确认的交互(2026-09-24)。
+- 普通手动建单和隐患转工单取消同设备未办结工单限制;报警转工单仅按设备主键 + `alarm_data.warning_code` 保证一个未办结工单(0/1/2/3/4/6),5 已结案和 7 已取消可释放名额。不同编码不互斥,报警恢复不等于工单办结;查重在设备行锁保护的建单事务中完成,缺失报警编码时明确报错,不按显示名称替代。旧“同设备所有工单互斥”规则不再适用(2026-09-24)。
 - 日常巡检和设备保养工单由其他业务系统创建及首次派发;派给当前用户后可以在供水工单总览继续处置。
 - 设备报警状态、报警记录状态和工单处置状态必须分别展示,不得互相替代。
 - 管理端统一工单生命周期采用数据库与前端一致的 `0–7` 契约(0 待派单/1 待接单/2 已接单/3 处理中/4 待验收/5 已结案/6 延期中/7 已取消);终态仅已结案和已取消,延期中是处理旁路。验收驳回是“待验收(4)回退到处理中(3)”的动作而非状态。调用方必须按接口契约解释状态编码并以状态名展示。三仓共识见本仓 docs/domain/work-order-lifecycle.md。

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

@@ -0,0 +1,32 @@
+# 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` 重新生成当前源码的验证产物。

+ 11 - 0
src/api/alarm.js

@@ -0,0 +1,11 @@
+import request from '@/utils/request'
+
+const get = (url, params) => request({ url, method: 'get', params })
+export const getAlarms = params => get('/api/alarm', params)
+export const getAlarmReminders = params => get('/api/alarm/reminders', params)
+export const getAlarmOptions = params => get('/api/alarm/options', params)
+export const getAlarmStatistics = params => get('/api/alarm/statistics', params)
+export const getAlarm = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}`, params)
+export const getAlarmTimeline = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}/timeline`, params)
+export const getAlarmAnalysis = (id, params) => get(`/api/alarm/${encodeURIComponent(id)}/analysis`, params)
+export const getAlarmMap = params => get('/api/alarm/map', params)

+ 0 - 34
src/api/drainage.js

@@ -179,24 +179,6 @@ export function publishWarning(data) {
   })
 }
 
-// 处理预警(指定人员处理,status 0→1)
-export function firstApproveWarning(id, data) {
-  return request({
-    url: `/api/drainage/warning/${id}/firstApprove`,
-    method: 'put',
-    params: data
-  })
-}
-
-// 审批预警(管理员审批,status 1→2)
-export function finalApproveWarning(id, data) {
-  return request({
-    url: `/api/drainage/warning/${id}/finalApprove`,
-    method: 'put',
-    params: data
-  })
-}
-
 // 排水分区关联设备
 export function bindPartitionEquipment(data) {
   return request({ url: '/api/drainage/partition/bindEquipment', method: 'post', data })
@@ -249,22 +231,6 @@ export function getAlarmDataPage(pageNum, pageSize, params) {
   return request({ url: '/alarmData/findByPage', method: 'get', params: { pageNum, pageSize, ...params } })
 }
 
-// 处理报警(审核提交)
-export function handleAlarm(alarmId) {
-  return request({
-    url: `/api/alarm/handle/${alarmId}`,
-    method: 'put'
-  })
-}
-
-// 根据ID获取报警详情
-export function getAlarmById(id) {
-  return request({
-    url: `/api/alarm/${id}`,
-    method: 'get'
-  })
-}
-
 // 雷达流量计 - 获取设备最新监测数据
 export function getRadarDataByCode(deviceCode) {
   return request({ url: '/api/radar/data/latest', method: 'get', params: { deviceCode } })

+ 1 - 11
src/api/pipeNetwork/waterSupply.js

@@ -18,19 +18,9 @@ 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}`)
-// 已停用(#46 收口裁定):后端这两个写入口统一返回停用提示,供水报警的写入口只有 auditAlarm,
-// 设备异常管理与报警管理均为只读;保留函数定义以免历史调用点报错,去留由后续工单决定。
-export const resolveAlarm = (id) => request({ url: `/waterSupply/alarm/resolve/${id}`, method: 'put' })
-export const dispatchAlarm = (data) => request({ url: '/waterSupply/alarm/dispatch', method: 'post', data })
-export const auditAlarm = (data) => request({ url: '/waterSupply/alarm/audit', method: 'post', data })
-export const listAlarmAudits = (alarmId) => get('/waterSupply/alarm/audit/list', { alarmId })
-export const getAuditOrderStats = () => get('/waterSupply/alarm/audit/order-stats')
-export const getAlarmFlow = (id) => get(`/waterSupply/alarm/flow/${id}`)
 export const listAlarmMapPoints = () => get('/waterSupply/alarm/map/points')
-// T02 报警管理只读台账与态势统计
+// 供水报警一张图的状态补全查询
 export const listAlarmLedger = (params) => get('/waterSupply/alarm/ledger/page', params)
-export const getAlarmLedgerDashboard = (days = 7) => get('/waterSupply/alarm/ledger/dashboard', { days })
-export const exportAlarmLedger = (params) => request({ url: '/waterSupply/alarm/ledger/export', method: 'post', params, responseType: 'blob' })
 
 // 供水设备 GIS 一张图(WaterDeviceGisMap / WaterSupplyHome 使用)
 export const getPipeNetworkDashboard = (params) => get('/waterSupply/pipeNetwork/statistics/dashboard', params)

+ 45 - 0
src/views/subSystem/alarm/AlarmChart.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <div class="alarm-toolbar"><el-radio-group v-model="period" @change="load"><el-radio-button value="CURVE" label="CURVE">事件前后</el-radio-button><el-radio-button value="DAY" label="DAY">事件当天</el-radio-button><el-radio-button value="DAILY" label="DAILY">日均值</el-radio-button><el-radio-button value="MONTHLY" label="MONTHLY">月均值</el-radio-button></el-radio-group><span class="muted">{{ period === 'CURVE' ? '报警前后 60 分钟' : period === 'DAY' ? '事件当天' : period === 'DAILY' ? '截至事件日的 7 天' : '截至事件日的 6 个月' }}</span></div>
+    <p v-if="result.latestSample" class="muted">最新监测值:{{ result.latestSample.value ?? '无效采样' }} {{ result.alarm?.warningUnit }} · {{ result.latestSample.time }}</p>
+    <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
+    <div v-loading="loading" ref="host" class="alarm-chart"></div>
+    <el-empty v-if="!loading && !validCount && !error" :description="result.message || '所选区间暂无有效采集数据'" :image-size="55" />
+    <div class="muted">{{ result.message }}<template v-if="result.supported"> · 有效采样 {{ validCount }} / {{ sampleCount }} · {{ result.startTime?.replace('T', ' ') }} — {{ result.endTime?.replace('T', ' ') }}</template></div>
+  </div>
+</template>
+<script setup>
+import { ref, computed, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
+import * as echarts from 'echarts'
+import { getAlarmAnalysis } from '@/api/alarm'
+import { seriesPoints, sampleTime } from './alarmOperationsModel'
+const props = defineProps({ alarmId: String, scope: { type: Object, required: true } })
+const period = ref('CURVE'), host = ref(), loading = ref(false), error = ref(''), result = ref({})
+const validCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.validCount), 0))
+const sampleCount = computed(() => (result.value.samples || []).reduce((sum, p) => sum + Number(p.sampleCount), 0))
+let generation = 0, chart, observer
+async function load() {
+  const token = ++generation; result.value = {}; error.value = ''; chart?.clear()
+  if (!props.alarmId) return
+  loading.value = true
+  try {
+    const response = await getAlarmAnalysis(props.alarmId, { ...props.scope, period: period.value })
+    if (token !== generation) return
+    result.value = response.data || {}; await nextTick(); draw()
+  } catch (e) { if (token === generation) error.value = e.message || '曲线加载失败' }
+  finally { if (token === generation) loading.value = false }
+}
+function draw() {
+  if (!host.value) return
+  if (!chart) chart = echarts.init(host.value)
+  const alarm = result.value.alarm || {}, points = result.value.samples || []
+  const raw = period.value === 'CURVE' || period.value === 'DAY'
+  const event = raw && alarm.actualValue != null && alarm.createTime ? [{ name: '报警触发值', type: 'scatter', symbolSize: 12, itemStyle: { color: '#f56c6c' }, data: [[sampleTime(alarm.createTime), Number(alarm.actualValue)]] }] : []
+  const limits = [['下限', alarm.minValue], ['上限', alarm.maxValue]].filter(([, v]) => v !== null && v !== undefined).map(([name, v]) => ({ name, yAxis: Number(v) }))
+  chart.setOption({ tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 65, right: 30, bottom: 60, top: 35 }, xAxis: { type: 'time', min: result.value.startTime ? sampleTime(result.value.startTime) : undefined, max: result.value.endTime ? sampleTime(result.value.endTime) : undefined }, yAxis: { type: 'value', scale: true, name: alarm.warningUnit || '' }, dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20 }], series: [{ name: alarm.warningType || '监测值', type: 'line', showSymbol: points.length < 100, connectNulls: false, data: seriesPoints(points, raw), lineStyle: { color: '#409eff', width: 2 }, markLine: { silent: true, symbol: 'none', lineStyle: { color: '#e6a23c', type: 'dashed' }, data: limits } }, ...event] }, true)
+}
+watch(() => [props.alarmId, JSON.stringify(props.scope)], () => { period.value = 'CURVE'; load() }, { immediate: true })
+onMounted(() => { observer = new ResizeObserver(() => chart?.resize()); observer.observe(host.value); if (result.value.samples) draw() })
+onBeforeUnmount(() => { ++generation; observer?.disconnect(); chart?.dispose() })
+</script>
+<style scoped>.alarm-chart{width:100%;height:360px}</style>

+ 56 - 0
src/views/subSystem/alarm/AlarmDetail.vue

@@ -0,0 +1,56 @@
+<template>
+  <el-drawer v-model="visible" title="报警详情" size="680px" append-to-body destroy-on-close class="alarm-detail">
+    <div v-loading="loading">
+      <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
+      <template v-if="alarm">
+        <AlarmSummary :alarm="alarm" :level-tone="levelTone(alarm.alarmLevel)" />
+        <dl class="alarm-state-grid">
+          <div class="alarm-state-item"><dt>监测状态</dt><dd><el-tag size="small" effect="light" :type="monitorStatusTone(alarm.alarmStatus)">{{ alarm.monitorStatusName || '未知' }}</el-tag></dd></div>
+          <div class="alarm-state-item"><dt>审核结果</dt><dd><el-tag size="small" effect="light" :type="auditStatusTone(alarm.auditStatus)">{{ alarm.auditStatusName || '未知' }}</el-tag></dd></div>
+          <div class="alarm-state-item"><dt>处置状态</dt><dd><el-tag size="small" effect="light" :type="alarm.workOrderId != null && alarm.orderStatus != null ? orderStatusType(alarm.orderStatus) : 'info'">{{ alarm.orderStatusName || '未转工单' }}</el-tag></dd></div>
+        </dl>
+        <el-descriptions :column="2" border><el-descriptions-item label="关联工单">{{ alarm.orderNo || '—' }}</el-descriptions-item><el-descriptions-item label="责任人">{{ alarm.receiveUserName || '未分配' }}</el-descriptions-item><el-descriptions-item label="完成期限" :span="2">{{ alarm.planFinishTime || '—' }}</el-descriptions-item></el-descriptions>
+        <AlarmHistory :records="timeline" :loading="loading" :error="error" :base-url="baseUrl" />
+      </template>
+    </div>
+    <template #footer><el-button v-if="alarm && hasActualValue(alarm) && menu('drainageAlarmAnalysis')" @click="navigate('drainageAlarmAnalysis', { alarmId: alarm.id })">进入研判</el-button><el-button v-if="alarm?.alarmStatus === 0 && alarm?.auditStatus === 0 && menu('alarmAudit')" type="primary" @click="navigate('alarmAudit', { alarmId: alarm.id })">前往审核</el-button><el-button @click="visible = false">关闭</el-button></template>
+  </el-drawer>
+</template>
+<script setup>
+import { ref, watch, onBeforeUnmount } from 'vue'
+import { getAlarm, getAlarmTimeline } from '@/api/alarm'
+import AlarmSummary from './AlarmSummary.vue'
+import AlarmHistory from './AlarmHistory.vue'
+import { auditStatusTone, levelTone, monitorStatusTone } from './alarmOperationsModel'
+import { orderStatusType } from '../pipeNetwork/ops/workOrder/workOrderDict'
+import { hasActualValue } from './alarmReviewModel'
+import { useAlarmScope } from './useAlarmScope'
+const { scope, scopeKey, menu, navigate } = useAlarmScope()
+const emit = defineEmits(['visible-change'])
+const visible = ref(false), loading = ref(false), alarm = ref(null), timeline = ref([]), error = ref('')
+const baseUrl = import.meta.env.VITE_APP_BASE_API
+let generation = 0
+async function open(id) {
+  const token = ++generation
+  visible.value = true; loading.value = true; error.value = ''; alarm.value = null; timeline.value = []
+  try {
+    const [detail, logs] = await Promise.all([getAlarm(id, { ...scope.value }), getAlarmTimeline(id, { ...scope.value })])
+    if (token !== generation) return
+    alarm.value = detail.data; timeline.value = logs.data || []
+  } catch (e) { if (token === generation) error.value = e.message || '详情加载失败' }
+  finally { if (token === generation) loading.value = false }
+}
+watch(scopeKey, () => { ++generation; visible.value = false })
+watch(visible, value => { emit('visible-change', value); if (!value) ++generation })
+onBeforeUnmount(() => { ++generation })
+defineExpose({ open })
+</script>
+
+<style scoped>
+.alarm-state-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 20px; margin: 0 0 20px; }
+.alarm-state-item { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
+.alarm-state-item dt { margin: 0; color: #606266; font-size: 13px; font-weight: 400; line-height: 22px; white-space: nowrap; }
+.alarm-state-item dd { margin: 0; min-width: 0; font-weight: 400; }
+.alarm-state-item :deep(.el-tag) { height: 22px; padding: 0 7px; border: 0; border-radius: 3px; font-size: 12px; font-weight: 400; }
+@media (max-width: 560px) { .alarm-state-grid { grid-template-columns: 1fr; } }
+</style>

+ 52 - 0
src/views/subSystem/alarm/AlarmHistory.vue

@@ -0,0 +1,52 @@
+<template>
+  <section class="alarm-history">
+    <div class="history-heading"><h3>{{ title }}</h3><span>{{ records.length }} 条记录</span></div>
+    <div v-if="groups.length" class="history-hint">按时间倒序分组 · 滚动即可回溯</div>
+    <div v-for="group in groups" :key="group.day" class="history-group">
+      <div class="history-day" :title="group.day">{{ historyDayLabel(group.day) }}</div>
+      <article v-for="item in group.items" :key="item.logId" class="history-row">
+        <time class="history-time" :title="item.operTime">{{ historyTime(item.operTime) }}</time>
+        <div class="history-body">
+          <div class="history-label"><b>{{ item.operUserName || '系统' }}</b><span class="separator">·</span>{{ item.operTypeName || '历史操作' }}</div>
+          <div v-if="item.operDesc" class="history-description">{{ item.operDesc }}</div>
+          <AlarmAttachments v-if="item.attachUrl" :value="item.attachUrl" :base-url="baseUrl" />
+        </div>
+      </article>
+    </div>
+    <el-empty v-if="!groups.length && !loading && !error" description="暂无操作记录" :image-size="72" />
+  </section>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import AlarmAttachments from './AlarmAttachments.vue'
+import { groupAlarmHistory, historyDayLabel, historyTime } from './alarmHistoryModel'
+
+const props = defineProps({
+  records: { type: Array, default: () => [] },
+  title: { type: String, default: '处置全流程' },
+  loading: Boolean,
+  error: { type: String, default: '' },
+  baseUrl: { type: String, default: '' }
+})
+const groups = computed(() => groupAlarmHistory(props.records))
+</script>
+
+<style scoped>
+.alarm-history { margin-top: 24px; color: #303133; }
+.history-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
+.history-heading h3 { font-size: 15px; margin: 0; font-weight: 600; }
+.history-heading > span, .history-hint { color: #909399; font-size: 12px; }
+.history-hint { padding-bottom: 10px; border-bottom: 1px solid #f0f2f4; margin-bottom: 14px; }
+.history-day { font-size: 13px; font-weight: 500; color: #2a3142; margin: 16px 0 4px; }
+.history-row { display: flex; gap: 12px; padding: 8px 0 8px 4px; }
+.history-time { flex: 0 0 60px; font: 12px/1.6 ui-monospace, Consolas, monospace; color: #909399; padding-top: 1px; }
+.history-body { flex: 1; min-width: 0; }
+.history-label { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
+.history-label b { font-weight: 700; }
+.separator { color: #909399; margin: 0 6px; }
+.history-description { font-size: 12.5px; color: #606266; line-height: 1.7; margin-top: 2px; white-space: pre-wrap; overflow-wrap: anywhere; }
+.history-body :deep(.alarm-attachments) { margin-top: 7px; }
+.history-body :deep(.attachment-images) { gap: 7px; }
+.history-body :deep(.attachment-thumbnail) { width: 50px; height: 50px; flex-basis: 50px; border-radius: 6px; }
+</style>

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

@@ -38,7 +38,7 @@
             <el-tag :type="row.alarmStatus === '0' ? 'danger' : 'success'">{{ getDictLabel(alarmStatusDict, row.alarmStatus) }}</el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="alarmTime" label="报警时间" width="160" />
+        <el-table-column prop="createTime" label="报警时间" width="160" />
         <el-table-column label="操作" width="160" fixed="right">
           <template #default="{ row }">
             <el-button v-if="row.alarmStatus === 0" type="warning" size="small" @click="handleOpenHandle(row)">处理</el-button>

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

@@ -17,7 +17,7 @@
           <el-descriptions-item label="实际值">{{ currentAlarm.actualValue }}</el-descriptions-item>
           <el-descriptions-item label="最小值">{{ currentAlarm.minValue }}</el-descriptions-item>
           <el-descriptions-item label="最大值">{{ currentAlarm.maxValue }}</el-descriptions-item>
-          <el-descriptions-item label="报警时间">{{ currentAlarm.alarmTime }}</el-descriptions-item>
+          <el-descriptions-item label="报警时间">{{ currentAlarm.createTime }}</el-descriptions-item>
         </el-descriptions>
       </template>
     </el-drawer>

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

@@ -2,10 +2,11 @@
   <section class="alarm-summary">
     <div class="summary-title"><div><strong>{{ alarm.deviceName || '未命名设备' }}</strong><p>{{ alarm.deviceCode || '—' }}</p></div><el-tag :type="levelTone">{{ alarm.alarmLevelName || '未知等级' }}</el-tag></div>
     <div class="summary-body">
+      <p>报警单号:{{ alarm.alarmNo || '—' }}</p>
       <div>{{ alarmDescription(alarm) }}</div>
       <p v-if="hasActualValue(alarm)">实际值:<span class="value">{{ valueText(alarm.actualValue, alarm.warningUnit) }}</span>;阈值:{{ thresholdText(alarm) }}</p>
       <p>安装位置:{{ alarm.equipmentLocation || '—' }}</p>
-      <p>报警时间:{{ alarm.alarmTime || '—' }}</p>
+      <p>报警时间:{{ alarm.createTime || '—' }}</p>
     </div>
   </section>
 </template>

+ 41 - 0
src/views/subSystem/alarm/alarmHistoryModel.js

@@ -0,0 +1,41 @@
+function historyDate(value) {
+  const text = String(value || '').trim().replace('T', ' ')
+  return /^\d{4}-\d{2}-\d{2}/.test(text) ? text : ''
+}
+
+export function historyTime(value) {
+  return historyDate(value).slice(11, 19) || '—'
+}
+
+export function historyDayLabel(value, now = new Date()) {
+  const day = historyDate(value).slice(0, 10)
+  if (!day) return '时间未记录'
+  const [year, month, date] = day.split('-').map(Number)
+  const parsed = new Date(year, month - 1, date)
+  if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== date) {
+    return '时间未记录'
+  }
+  // 使用本地日历日,不以 UTC 日期或固定 24 小时判断,避免凌晨和跨月误判。
+  const isSameDay = target => year === target.getFullYear()
+    && month === target.getMonth() + 1 && date === target.getDate()
+  const monthDay = `${month}月${date}日`
+  if (isSameDay(now)) return '今天'
+  const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
+  if (isSameDay(yesterday)) return '昨天'
+  return year === now.getFullYear() ? monthDay : `${year}年${monthDay}`
+}
+
+// 保留接口的微秒时间精度;同一时间按字符串主键比较,避免 int8 转 Number 丢失精度。
+export function groupAlarmHistory(records = []) {
+  const sorted = [...records].sort((a, b) => {
+    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()
+  for (const item of sorted) {
+    const day = historyDate(item.operTime).slice(0, 10) || '时间未记录'
+    if (!groups.has(day)) groups.set(day, { day, items: [] })
+    groups.get(day).items.push(item)
+  }
+  return [...groups.values()]
+}

+ 18 - 0
src/views/subSystem/alarm/alarmOperations.scss

@@ -0,0 +1,18 @@
+.alarm-page { padding: 20px; background: #f5f7fa; min-height: calc(100vh - 84px); color: #303133; }
+.alarm-page .el-card { border-radius: 8px; margin-bottom: 20px; }
+.alarm-page .alarm-filters { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; margin: 0; }
+.alarm-page .alarm-filters .el-form-item { margin: 0; }
+.alarm-page .alarm-filters .el-input { width: 220px; }
+.alarm-page .alarm-filters .el-select { width: 150px; }
+.alarm-page .alarm-filters .el-date-editor { width: 280px; }
+.alarm-page .alarm-filter-card .el-card__body { padding: 16px 20px; }
+.alarm-page .el-pagination { margin-top: 20px; justify-content: flex-end; }
+.alarm-page .alarm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
+.alarm-page .alarm-toolbar h3 { margin: 0; font-size: 16px; }
+.alarm-page .muted, .alarm-detail .muted { color: #909399; font-size: 13px; line-height: 1.7; }
+.alarm-page .load-error { margin-bottom: 16px; }
+.alarm-page .alarm-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; }
+.alarm-page .alarm-stat { padding: 18px 22px; border: 1px solid #ebeef5; background: white; border-radius: 8px; margin-bottom: 20px; border-top: 3px solid var(--level-color); }
+.alarm-page .alarm-stat strong { display: block; font-size: 28px; margin-top: 8px; color: var(--level-color); }
+.alarm-detail .operation-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
+@media(max-width:900px) { .alarm-page{padding:12px}.alarm-page .alarm-stats{grid-template-columns:repeat(2,1fr)}.alarm-page .el-pagination{overflow:auto;justify-content:flex-start} }

+ 55 - 0
src/views/subSystem/alarm/alarmOperationsModel.js

@@ -0,0 +1,55 @@
+import { hasActualValue } from './alarmReviewModel.js'
+
+// GIS 曲线和研判候选共用;按报警 ID 去重,保留实际值为 0 的事件。
+export function alarmCurveChoices(rows = []) {
+  return [...new Map(rows.map(row => [row.id, row])).values()].filter(hasActualValue)
+}
+
+export const levelTone = level => ({ 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }[level] || 'info')
+// 只映射展示颜色,状态名称仍以接口返回值为准。
+export const monitorStatusTone = status => ({ 0: 'danger', 1: 'success' }[status] || 'info')
+export const auditStatusTone = status => ({ 0: 'warning', 1: 'primary', 2: 'info' }[status] || 'info')
+export const levelColor = level => ({ 1: '#f56c6c', 2: '#e6a23c', 3: '#409eff', 4: '#67c23a' }[level] || '#909399')
+export const scopeFromRoute = query => ({ rootTypeId: Number(query.rootTypeId), warningCode: query.warning_code || query.warningCode || undefined })
+export const sampleTime = time => Date.parse(/[zZ]$|[+-]\d\d:\d\d$/.test(time) ? time : String(time).replace(' ', 'T') + '+08:00')
+// 原始采样间隔超过本区间中位间隔的3倍时断线;不补零或插值。
+export function seriesPoints(samples, raw = false) {
+  const points = samples.map(p => [sampleTime(p.time), p.value]).filter(p => Number.isFinite(p[0])).sort((a, b) => a[0] - b[0])
+  if (!raw || points.length < 3) return points
+  const intervals = points.slice(1).map((p, i) => p[0] - points[i][0]).filter(n => n > 0).sort((a, b) => a - b)
+  const median = intervals[Math.floor((intervals.length - 1) / 2)]
+  return points.flatMap((p, i) => i && p[0] - points[i - 1][0] > median * 3 ? [[points[i - 1][0] + 1, null], p] : [p])
+}
+export function rangeParams(range) {
+  return range?.length === 2 ? { startTime: `${range[0]} 00:00:00`, endTime: `${range[1]} 23:59:59.999999` } : {}
+}
+export function groupAlarmDevices(rows = []) {
+  const devices = new Map()
+  for (const row of rows) {
+    const key = row.equipmentId || row.deviceCode
+    if (!key) continue
+    if (!devices.has(key)) devices.set(key, { ...row, key, name: row.deviceName || row.deviceCode, alarms: [] })
+    devices.get(key).alarms.push(row)
+  }
+  return [...devices.values()].map(device => {
+    device.alarms.sort((a, b) => String(b.createTime).localeCompare(String(a.createTime)))
+    const current = device.alarms.filter(row => row.alarmStatus === 0)
+    device.alarmLevel = Math.min(...(current.length ? current : device.alarms.slice(0, 1)).map(row => Number(row.alarmLevel) || 4))
+    device.lng = device.longitude === null || device.longitude === '' ? NaN : Number(device.longitude)
+    device.lat = device.latitude === null || device.latitude === '' ? NaN : Number(device.latitude)
+    device.hasCoordinates = Number.isFinite(device.lng) && Number.isFinite(device.lat) && Math.abs(device.lng) <= 180 && Math.abs(device.lat) <= 90 && !(device.lng === 0 && device.lat === 0)
+    device.iconUrl = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44"><path d="M18 42C15 35 2 26 2 18a16 16 0 1 1 32 0c0 8-13 17-16 24Z" fill="${levelColor(device.alarmLevel)}" stroke="white" stroke-width="2"/><text x="18" y="24" text-anchor="middle" fill="white" font-size="18">${device.alarmLevel}</text></svg>`)
+    return device
+  })
+}
+export function findAlarmMenu(routes, leaf, rootTypeId, parent = '') {
+  for (const route of routes || []) {
+    const path = route.path?.startsWith('/') ? route.path : `${parent}/${route.path || ''}`.replace(/\/+/g, '/')
+    let query = route.query || {}
+    try { if (typeof query === 'string') query = JSON.parse(query) } catch { query = {} }
+    if (path.split('/').at(-1) === leaf && Number(query.rootTypeId) === Number(rootTypeId)) return { path, query }
+    const child = findAlarmMenu(route.children, leaf, rootTypeId, path)
+    if (child) return child
+  }
+  return null
+}

+ 128 - 0
src/views/subSystem/alarm/drainage/alarmGIS.scss

@@ -0,0 +1,128 @@
+.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-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 :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-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-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-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: space-between; align-items: center; gap: 14px; }
+.gis-drawer-footer > span { color: #98a3b2; font-size: 11px; }
+.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; } }

+ 180 - 0
src/views/subSystem/alarm/drainage/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="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-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>
+
+    <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>
+      </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>
+      <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"><span>设备、报警、处置状态分别记录</span><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="DrainageAlarmGIS">
+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 '../alarmOperationsModel'
+import { alarmDescription, hasActualValue, thresholdText, valueText } from '../alarmReviewModel'
+import { useAlarmScope } from '../useAlarmScope'
+import '../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 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: status.value })
+    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; status.value = 0; 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 }
+  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>

+ 55 - 0
src/views/subSystem/alarm/drainage/analysis.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="alarm-page">
+    <el-card shadow="never"><div class="alarm-toolbar"><el-select v-model="selectedId" filterable remote :remote-method="searchEvents" :loading="searching" placeholder="搜索报警单号、设备编码或名称,选择事件" style="width:min(680px,80%)" @change="select"><el-option v-for="row in choices" :key="row.id" :value="row.id" :label="`${row.alarmNo || '历史事件'} · ${row.deviceName || row.deviceCode} · ${row.createTime}`" /></el-select><el-button :disabled="!selectedId" @click="detailRef.open(selectedId)">事件详情</el-button></div>
+      <el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
+      <div v-loading="loading"><AlarmSummary v-if="alarm" :alarm="alarm" :level-tone="levelTone(alarm.alarmLevel)" /><div v-if="alarm" class="muted">监测:{{ alarm.monitorStatusName }} · 审核:{{ alarm.auditStatusName }} · 处置:{{ alarm.orderStatusName }}</div></div>
+    </el-card>
+    <el-card v-if="alarm" shadow="never"><AlarmChart :alarm-id="alarm.id" :scope="scope" /></el-card>
+    <el-empty v-else-if="!loading" description="请选择报警事件进行研判" />
+    <AlarmDetail ref="detailRef" />
+  </div>
+</template>
+<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 '../alarmOperationsModel'
+import { hasActualValue } from '../alarmReviewModel'
+import { useAlarmScope } from '../useAlarmScope'
+import '../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
+async function searchEvents(keyword = '') {
+  const token = ++searchGeneration; searching.value = true
+  try { const result = await getAlarms({ ...scope.value, actualValueOnly: true, keyword: keyword.startsWith('BJ') ? undefined : keyword, alarmNo: keyword.startsWith('BJ') ? keyword : undefined, pageNum: 1, pageSize: 100 }); if (token === searchGeneration) choices.value = alarmCurveChoices(result.data.records || []) }
+  catch (e) { if (token === searchGeneration) { choices.value = []; error.value = e.message } }
+  finally { if (token === searchGeneration) searching.value = false }
+}
+async function select(id) {
+  const token = ++generation; alarm.value = null; error.value = ''; loading.value = true
+  try {
+    const result = await getAlarm(id, { ...scope.value })
+    if (token !== generation) return
+    if (!hasActualValue(result.data)) {
+      selectedId.value = ''; choices.value = choices.value.filter(row => row.id !== id)
+      error.value = '该报警无实际值,请选择有实际值的报警事件'; return
+    }
+    alarm.value = result.data
+    if (!choices.value.some(v => v.id === id)) choices.value.unshift(result.data)
+  }
+  catch (e) { if (token === generation) error.value = e.message }
+  finally { if (token === generation) loading.value = false }
+}
+watch(() => [scopeKey.value, route.query.alarmId], async () => {
+  ++generation; ++searchGeneration; alarm.value = null; choices.value = []; selectedId.value = ''; loading.value = false
+  if (scope.value.rootTypeId !== 2) { error.value = '报警研判仅适用于排水子系统'; return }
+  const key = scopeKey.value; await searchEvents()
+  if (key !== scopeKey.value) return
+  selectedId.value = route.query.alarmId || choices.value[0]?.id || ''
+  if (selectedId.value) await select(selectedId.value)
+}, { immediate: true })
+onBeforeUnmount(() => { ++generation; ++searchGeneration })
+</script>

+ 13 - 12
src/views/subSystem/alarm/index.vue

@@ -16,13 +16,14 @@
         <el-tab-pane name="pending"><template #label>待审核 <span class="tab-count">{{ statistics.pending }}</span></template></el-tab-pane>
       </el-tabs>
       <el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="load-error"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
-      <el-table v-loading="loading" :data="rows" row-key="id" border stripe class="table" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" :default-sort="{ prop: 'alarmTime', order: 'descending' }" @sort-change="sortChanged">
+      <el-table v-loading="loading" :data="rows" row-key="id" border stripe class="table" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" :default-sort="{ prop: 'createTime', order: 'descending' }" @sort-change="sortChanged">
+        <el-table-column prop="alarmNo" label="报警单号" width="210" show-overflow-tooltip><template #default="{ row }">{{ row.alarmNo || '—' }}</template></el-table-column>
         <el-table-column prop="deviceCode" label="设备编码" width="200" show-overflow-tooltip />
         <el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip />
         <el-table-column prop="equipmentLocation" label="安装位置" min-width="210" show-overflow-tooltip><template #default="{ row }">{{ row.equipmentLocation || '—' }}</template></el-table-column>
         <el-table-column label="报警描述" min-width="275"><template #default="{ row }"><div class="description">{{ alarmDescription(row) }}</div><div v-if="hasActualValue(row)" class="snapshot">实际值:<span class="value">{{ valueText(row.actualValue, row.warningUnit) }}</span>;阈值:{{ thresholdText(row) }}</div></template></el-table-column>
         <el-table-column label="报警等级" width="105" align="center"><template #default="{ row }"><el-tag :type="levelTone(row.alarmLevel)" effect="light">{{ row.alarmLevelName }}</el-tag></template></el-table-column>
-        <el-table-column prop="alarmTime" label="报警时间" width="175" sortable="custom" />
+        <el-table-column prop="createTime" label="报警时间" width="175" sortable="custom" />
         <el-table-column label="审核结果" width="110" align="center"><template #default="{ row }"><el-tag :type="auditTone(row.auditStatus)" effect="light">{{ row.auditStatusName }}</el-tag></template></el-table-column>
         <el-table-column label="操作" width="160" align="center" fixed="right"><template #default="{ row }"><el-button v-if="isUnreviewed(row)" v-hasPermi="['alarm:review:audit']" link type="primary" @click="openReview(row)">审核</el-button><el-button link type="primary" @click="openHistory(row)">处置记录</el-button></template></el-table-column>
         <template #empty><el-empty :description="error ? '数据加载失败' : '暂无匹配的未处理报警'" /></template>
@@ -35,7 +36,7 @@
         <AlarmSummary v-if="current" :alarm="current" :level-tone="levelTone(current.alarmLevel)" />
         <el-form ref="formRef" :model="form" :rules="rules" label-width="90px" :disabled="submitting || detailLoading">
           <el-form-item label="审核结果" prop="auditStatus"><el-radio-group v-model="form.auditStatus" class="review-choices"><el-radio v-for="item in reviewChoices" :key="item.key" :label="item.value" border>{{ item.label }}</el-radio></el-radio-group></el-form-item>
-          <el-form-item label="审核意见" prop="opinion"><el-input v-model="form.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写核实情况、判定依据或误报原因" /></el-form-item>
+          <el-form-item label="审核意见" prop="opinion"><el-input v-model="form.opinion" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="选填,可填写核实情况、判定依据或误报原因" /></el-form-item>
           <el-form-item label="图片"><div class="attachments"><el-upload v-model:file-list="files" :http-request="uploadFile" :before-upload="beforeUpload" :limit="5" :on-exceed="exceedFiles" :disabled="submitting || detailLoading" accept=".jpg,.jpeg,.png"><el-button :icon="Upload">选择图片</el-button><template #tip><div class="el-upload__tip">JPG、JPEG、PNG,最多 5 张,单张不超过 10 MB。</div></template></el-upload></div></el-form-item>
         </el-form>
         <el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon />
@@ -44,7 +45,11 @@
     </el-dialog>
 
     <el-drawer v-model="historyVisible" title="处置记录" size="640px" append-to-body destroy-on-close>
-      <div v-loading="historyLoading"><AlarmSummary v-if="historyAlarm" :alarm="historyAlarm" :level-tone="levelTone(historyAlarm.alarmLevel)" /><el-alert v-if="historyError" :title="historyError" type="error" :closable="false" /><div class="timeline-heading">处置全流程 <span>{{ logs.length }} 个节点</span></div><el-timeline><el-timeline-item v-for="item in logs" :key="item.logId" :timestamp="item.operTime" placement="top" type="primary" hollow><div class="timeline-title">{{ item.operTypeName }} <small>{{ item.operUserName || '系统' }}</small></div><div class="timeline-content"><div class="operation-text">{{ item.operDesc || '—' }}</div><AlarmAttachments v-if="item.attachUrl" :value="item.attachUrl" :base-url="baseUrl" /></div></el-timeline-item></el-timeline><el-empty v-if="!logs.length && !historyLoading && !historyError" description="暂无操作记录" /></div>
+      <div v-loading="historyLoading">
+        <AlarmSummary v-if="historyAlarm" :alarm="historyAlarm" :level-tone="levelTone(historyAlarm.alarmLevel)" />
+        <el-alert v-if="historyError" :title="historyError" type="error" :closable="false" />
+        <AlarmHistory :records="logs" :loading="historyLoading" :error="historyError" :base-url="baseUrl" />
+      </div>
       <template #footer><el-button @click="historyVisible = false">关闭</el-button></template>
     </el-drawer>
   </div>
@@ -53,11 +58,11 @@
 <script setup>
 import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
 import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
-import { ElMessage, ElMessageBox } from 'element-plus'
+import { ElMessage } from 'element-plus'
 import { Refresh, Search, Upload } from '@element-plus/icons-vue'
 import { getAlarmReviewOptions, getAlarmReviewPage, getAlarmReviewStatistics, getAlarmReviewDetail, getAlarmLogs, submitAlarmReview, uploadAlarmReviewAttachment } from '@/api/alarmReview'
 import AlarmSummary from './AlarmSummary.vue'
-import AlarmAttachments from './AlarmAttachments.vue'
+import AlarmHistory from './AlarmHistory.vue'
 import { alarmDescription, thresholdText, valueText, hasActualValue } from './alarmReviewModel'
 
 const route = useRoute()
@@ -77,10 +82,9 @@ const unreviewed = computed(() => options.auditStatuses.find(item => item.key ==
 const reviewChoices = computed(() => options.auditStatuses.filter(item => item.key !== 'UNREVIEWED'))
 const activeTab = computed(() => filters.auditStatus !== undefined && filters.auditStatus === unreviewed.value ? 'pending' : 'all')
 const uploading = computed(() => files.value.some(file => ['ready', 'uploading'].includes(file.status)))
-const dirty = computed(() => form.auditStatus !== undefined || form.opinion.trim() || files.value.length)
 const rules = {
   auditStatus: [{ required: true, message: '请选择审核结果', trigger: 'change' }],
-  opinion: [{ validator: (_, value, callback) => callback(String(value || '').trim() ? undefined : new Error('请填写审核意见')), trigger: 'blur' }]
+  opinion: [{ max: 500, message: '审核意见最多500字', trigger: 'blur' }]
 }
 const message = e => e?.response?.data?.msg || e?.message || '请求失败,请重试'
 const isUnreviewed = row => unreviewed.value !== undefined && row.auditStatus === unreviewed.value
@@ -143,9 +147,6 @@ async function submit() {
 }
 async function canLeave() {
   if (submitting.value || uploading.value) { ElMessage.warning('请等待上传或提交完成'); return false }
-  if (reviewVisible.value && dirty.value) {
-    try { await ElMessageBox.confirm('尚未提交的审核意见和附件选择将丢失,是否放弃?', '放弃本次审核', { confirmButtonText: '放弃修改', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return false }
-  }
   return true
 }
 async function closeReview(done) { if (await canLeave()) { ++detailRequest; reviewVisible.value = false; if (typeof done === 'function') done() } }
@@ -169,5 +170,5 @@ onBeforeUnmount(() => { ++listRequest; ++detailRequest; ++historyRequest })
 </script>
 
 <style scoped lang="scss">
-.order-container{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.search-card{margin-bottom:20px;border-radius:8px}.search-form{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin:0}.search-form :deep(.el-form-item){margin:0}.search-form :deep(.el-input){width:220px}.search-form :deep(.el-select){width:160px}.search-form :deep(.el-date-editor){width:300px}.search-card :deep(.el-card__body){padding:16px 20px}.table-card{border-radius:8px}.table{width:100%}.pagination{margin-top:20px;justify-content:flex-end}.tab-count{margin-left:8px;background:#f0f2f5;padding:1px 7px;border-radius:4px;font-size:12px}.description{font-weight:500;margin-bottom:5px}.snapshot{font-size:12px;color:#909399;line-height:1.6}.value{color:#e65c40}.load-error{margin-bottom:16px}.review-choices{display:flex;flex-wrap:wrap;gap:12px}.review-choices .el-radio{margin-right:0;min-width:160px}.attachments{width:100%;padding:14px 16px;background:#fafbfc;border:1px dashed #dcdfe6;border-radius:6px}.timeline-heading{display:flex;justify-content:space-between;margin:24px 0;color:#303133;font-weight:600}.timeline-heading span{color:#909399;font-size:13px;font-weight:400}.timeline-title{font-weight:600}.timeline-title small{margin-left:12px;font-weight:400;color:#909399}.timeline-content{padding:12px 14px;background:#f7f9fc;border-radius:4px;margin-top:10px}.operation-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.7}:deep(.el-tabs__header){margin-bottom:18px}:deep(.el-table .cell){line-height:1.7}@media(max-width:900px){.order-container{padding:12px}.pagination{overflow:auto;justify-content:flex-start}}
+.order-container{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.search-card{margin-bottom:20px;border-radius:8px}.search-form{display:flex;flex-wrap:wrap;align-items:center;gap:12px 24px;margin:0}.search-form :deep(.el-form-item){margin:0}.search-form :deep(.el-input){width:220px}.search-form :deep(.el-select){width:160px}.search-form :deep(.el-date-editor){width:300px}.search-card :deep(.el-card__body){padding:16px 20px}.table-card{border-radius:8px}.table{width:100%}.pagination{margin-top:20px;justify-content:flex-end}.tab-count{margin-left:8px;background:#f0f2f5;padding:1px 7px;border-radius:4px;font-size:12px}.description{font-weight:500;margin-bottom:5px}.snapshot{font-size:12px;color:#909399;line-height:1.6}.value{color:#e65c40}.load-error{margin-bottom:16px}.review-choices{display:flex;flex-wrap:wrap;gap:12px}.review-choices .el-radio{margin-right:0;min-width:160px}.attachments{width:100%;padding:14px 16px;background:#fafbfc;border:1px dashed #dcdfe6;border-radius:6px}:deep(.el-tabs__header){margin-bottom:18px}:deep(.el-table .cell){line-height:1.7}@media(max-width:900px){.order-container{padding:12px}.pagination{overflow:auto;justify-content:flex-start}}
 </style>

+ 68 - 0
src/views/subSystem/alarm/list.vue

@@ -0,0 +1,68 @@
+<template>
+  <div class="alarm-page">
+    <el-card shadow="never" class="alarm-filter-card"><el-form :model="filters" inline class="alarm-filters" @submit.prevent="search">
+      <el-form-item label="报警单号"><el-input v-model="filters.alarmNo" clearable placeholder="请输入报警单号" @keyup.enter="search" /></el-form-item>
+      <el-form-item label="设备搜索"><el-input v-model="filters.keyword" clearable placeholder="请输入设备编码/名称" @keyup.enter="search" /></el-form-item>
+      <el-form-item label="报警等级"><el-select v-model="filters.alarmLevel" clearable placeholder="全部等级"><el-option v-for="v in options.alarmLevels" :key="v.value" :label="v.label" :value="v.value" /></el-select></el-form-item>
+      <el-form-item label="监测状态"><el-select v-model="filters.alarmStatus" clearable placeholder="全部状态"><el-option v-for="v in options.monitorStatuses" :key="v.value" :label="v.label" :value="v.value" /></el-select></el-form-item>
+      <el-form-item label="报警时间"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" /></el-form-item>
+      <el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></el-form-item>
+    </el-form></el-card>
+    <el-card shadow="never"><div class="alarm-toolbar"><h3>报警事件</h3><span class="muted">共 {{ total }} 条</span></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>
+      <el-table v-loading="loading" :data="rows" border stripe row-key="id" :header-cell-style="{ background: '#f5f7fa', color: '#606266' }" :default-sort="{ prop: 'createTime', order: 'descending' }" @sort-change="sort">
+        <el-table-column prop="alarmNo" label="报警单号" width="210" show-overflow-tooltip><template #default="{ row }">{{ row.alarmNo || '—' }}</template></el-table-column>
+        <el-table-column prop="deviceCode" label="设备编码" width="200" show-overflow-tooltip />
+        <el-table-column prop="deviceName" label="设备名称" min-width="155" show-overflow-tooltip />
+        <el-table-column prop="equipmentLocation" label="安装位置" min-width="200" show-overflow-tooltip />
+        <el-table-column label="报警描述" min-width="270"><template #default="{ row }"><div>{{ alarmDescription(row) }}</div><div v-if="hasActualValue(row)" class="muted">实际值:{{ valueText(row.actualValue, row.warningUnit) }};阈值:{{ thresholdText(row) }}</div></template></el-table-column>
+        <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="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>
+      </el-table>
+      <pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="load" />
+    </el-card><AlarmDetail ref="detailRef" />
+  </div>
+</template>
+<script setup name="AlarmEventList">
+import { ref, reactive, watch, onBeforeUnmount } from 'vue'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { getAlarms, getAlarmOptions } from '@/api/alarm'
+import { alarmDescription, hasActualValue, valueText, thresholdText } from './alarmReviewModel'
+import { levelTone, rangeParams } from './alarmOperationsModel'
+import { useAlarmScope } from './useAlarmScope'
+import AlarmDetail from './AlarmDetail.vue'
+import './alarmOperations.scss'
+const { scope, scopeKey, route } = useAlarmScope()
+const empty = () => ({ alarmNo: '', keyword: '', alarmLevel: undefined, alarmStatus: undefined })
+const filters = reactive(empty()), dates = ref([]), options = ref({}), rows = ref([]), total = ref(0)
+const pageNum = ref(1), pageSize = ref(10), ascending = ref(false), loading = ref(false), error = ref(''), detailRef = ref()
+let generation = 0
+async function load() {
+  const token = ++generation; loading.value = true; error.value = ''
+  try {
+    const result = await getAlarms({ ...filters, ...rangeParams(dates.value), ...scope.value, pageNum: pageNum.value, pageSize: pageSize.value, ascending: ascending.value })
+    if (token === generation) { rows.value = result.data.records || []; total.value = result.data.total || 0 }
+  } catch (e) { if (token === generation) { rows.value = []; total.value = 0; error.value = e.message || '加载失败' } }
+  finally { if (token === generation) loading.value = false }
+}
+function search() { pageNum.value = 1; return load() }
+function reset() { Object.assign(filters, empty()); dates.value = []; search() }
+function sort({ order }) { ascending.value = order === 'ascending'; search() }
+watch(scopeKey, async () => {
+  ++generation; rows.value = []; total.value = 0; loading.value = false
+  Object.assign(filters, empty()); dates.value = []; options.value = {}
+  const key = scopeKey.value
+  try { const result = await getAlarmOptions({ ...scope.value }); if (key === scopeKey.value) options.value = result.data || {} } catch (e) { error.value = e.message }
+  if (key !== scopeKey.value) return
+  await search()
+  if (route.query.alarmId) detailRef.value?.open(route.query.alarmId)
+}, { immediate: true })
+onBeforeUnmount(() => { ++generation })
+watch(() => route.query.alarmId, id => { if (id) detailRef.value?.open(id) })
+</script>

+ 64 - 0
src/views/subSystem/alarm/remind.vue

@@ -0,0 +1,64 @@
+<template>
+  <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"><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">
+        <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>
+          <p>{{ row.latestOperation || alarmDescription(row) }}</p>
+          <div class="alarm-toolbar"><span class="muted">监测:{{ row.monitorStatusName }} · 审核:{{ row.auditStatusName }} · 处置:{{ row.orderStatusName }}</span><el-button link type="primary" @click="detailRef.open(row.id)">事件详情</el-button></div>
+        </article>
+        <el-empty v-if="!visibleRows.length && !loading" :description="error ? '数据加载失败' : '暂无报警提醒'" />
+      </div>
+      <div class="muted feed-footer">{{ paused ? '视图已暂停,仍持续接收更新' : '自动滚动,悬停或聚焦暂停' }}</div>
+    </el-card><AlarmDetail ref="detailRef" @visible-change="detailPause = $event" />
+  </div>
+</template>
+<script setup name="AlarmRemind">
+import { computed, ref, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
+import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
+import { getAlarmReminders, getAlarmOptions, getAlarmStatistics } from '@/api/alarm'
+import { levelTone, levelColor } from './alarmOperationsModel'
+import { alarmDescription } from './alarmReviewModel'
+import { useAlarmScope } from './useAlarmScope'
+import AlarmDetail from './AlarmDetail.vue'
+import './alarmOperations.scss'
+const { scope, scopeKey } = useAlarmScope()
+const options = ref({}), stats = ref([]), level = ref(), visibleRows = ref([]), received = ref([])
+const manualPause = ref(false), hoverPause = ref(false), focusPause = ref(false), feed = ref(), detailRef = ref()
+const loading = ref(false), error = ref(''), refreshedAt = ref(''), active = ref(true)
+const 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)
+const count = value => Number(stats.value.find(row => Number(row.level) === value)?.total || 0)
+let generation = 0, poll, scroll
+async function load() {
+  if (!active.value || loading.value) return
+  const token = ++generation; loading.value = true
+  try {
+    const [page, summary] = await Promise.all([getAlarmReminders({ ...scope.value, alarmStatus: 0, alarmLevel: level.value, pageNum: 1, pageSize: 100 }), getAlarmStatistics({ ...scope.value, alarmStatus: 0 })])
+    if (token !== generation) return
+    received.value = page.data.records || []; stats.value = summary.data || []; error.value = ''
+    if (!paused.value) visibleRows.value = received.value
+    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 })
+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 })
+onBeforeUnmount(() => { ++generation; clearInterval(poll); clearInterval(scroll) })
+</script>
+<style scoped>
+.reminder-feed{height:calc(100vh - 390px);min-height:350px;overflow:auto;padding-right:8px}.reminder-message{border:1px solid #ebeef5;border-left:4px solid;border-radius:6px;padding:18px 20px;margin-bottom:12px;background:#fff}.reminder-message .alarm-toolbar:last-child{margin-bottom:0}.reminder-message p{line-height:1.8;overflow-wrap:anywhere}.feed-footer{margin-top:16px}
+</style>

+ 18 - 0
src/views/subSystem/alarm/useAlarmScope.js

@@ -0,0 +1,18 @@
+import { computed } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import usePermissionStore from '@/store/modules/permission'
+import { ElMessage } from 'element-plus'
+import { scopeFromRoute, findAlarmMenu } from './alarmOperationsModel'
+
+export function useAlarmScope() {
+  const route = useRoute(), router = useRouter(), permissions = usePermissionStore()
+  const scope = computed(() => scopeFromRoute(route.query))
+  const scopeKey = computed(() => JSON.stringify(scope.value))
+  const menu = leaf => findAlarmMenu(permissions.sidebarRouters, leaf, scope.value.rootTypeId)
+  const navigate = (leaf, extra = {}) => {
+    const target = menu(leaf)
+    if (!target) return ElMessage.warning('当前账号未配置该功能菜单')
+    return router.push({ path: target.path, query: { ...target.query, ...extra } })
+  }
+  return { scope, scopeKey, route, menu, navigate }
+}

+ 2 - 2
src/views/subSystem/drainage/fxpg/zhfxtjfx.vue

@@ -150,7 +150,7 @@
                 </el-tag>
               </div>
               <el-table :data="alarmList" border size="small" v-if="alarmList.length > 0">
-                <el-table-column prop="alarmTime" label="报警时间" min-width="160" />
+                <el-table-column prop="createTime" label="报警时间" min-width="160" />
                 <el-table-column prop="alarmType" label="报警类型" min-width="100" />
                 <el-table-column prop="alarmContent" label="报警内容" min-width="200" />
                 <el-table-column prop="status" label="状态" min-width="80" align="center">
@@ -329,7 +329,7 @@ const handleDetail = async (row) => {
         const page = res.code !== undefined ? res.data : res
         if (Array.isArray(page)) return page
         return page?.records || page?.rows || []
-      }).sort((a, b) => String(b.alarmTime || '').localeCompare(String(a.alarmTime || '')))
+      }).sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
     } catch (e) {
       alarmList.value = []
     } finally {

+ 3 - 3
src/views/subSystem/drainage/fxpg/zhfxxqfx.vue

@@ -90,7 +90,7 @@
                 <!-- 报警列表 -->
                 <el-table :data="alarmList" border style="width: 100%; margin-top: 16px;" v-if="alarmList.length > 0">
                   <el-table-column label="序号" type="index" width="60" align="center" />
-                  <el-table-column prop="alarmTime" label="报警时间" min-width="160" />
+                  <el-table-column prop="createTime" label="报警时间" min-width="160" />
                   <el-table-column prop="alarmType" label="报警类型" min-width="100" />
                   <el-table-column prop="alarmContent" label="报警内容" min-width="220" />
                   <el-table-column prop="status" label="状态" min-width="90" align="center">
@@ -243,7 +243,7 @@ const queryAlarmsForPipe = async (equipmentId, pageSize) => {
     return { rows: page?.records || page?.rows || [], total: Number(page?.total || 0) }
   }))
   return {
-    rows: result.flatMap(item => item.rows).sort((a, b) => String(b.alarmTime || '').localeCompare(String(a.alarmTime || ''))),
+    rows: result.flatMap(item => item.rows).sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || ''))),
     total: result.reduce((sum, item) => sum + item.total, 0)
   }
 }
@@ -278,7 +278,7 @@ const loadAlarmTrend = async () => {
     // 按日期聚合报警数
     const dateMap = {}
     rows.forEach(item => {
-      const date = (item.alarmTime || '').substring(0, 10)
+      const date = (item.createTime || '').substring(0, 10)
       if (date) {
         dateMap[date] = (dateMap[date] || 0) + 1
       }

+ 3 - 3
src/views/subSystem/drainage/home.vue

@@ -673,7 +673,7 @@ async function loadEquipmentData() {
           levelText,
           levelClass,
           code: a.deviceCode || a.device_code || '',
-          time: a.alarmTime || a.alarm_time || a.createTime || '',
+          time: a.createTime || '',
           content: a.warningType || a.warning_type || a.remark || ''
         }
       })
@@ -812,7 +812,7 @@ async function loadWaterloggingData() {
         : []
       waterWarnData.value = filteredAlarms.slice(0, 10).map(a => ({
         addr: a.deviceCode || '',
-        time: a.alarmTime || a.alarm_time || a.createTime || '',
+        time: a.createTime || '',
         type: a.warningType || a.warning_type || '积水报警',
         value: a.actualValue != null ? `${a.actualValue}cm` : '--'
       }))
@@ -930,7 +930,7 @@ async function loadRwTabData() {
         Object.values(equipmentDetails).forEach(eq => { if (eq.code) codeNameMap[eq.code] = eq.name })
         return {
           deviceName: a.equipmentName || a.deviceName || a.name || codeNameMap[code] || '',
-          time: a.alarmTime || a.createTime || '',
+          time: a.createTime || '',
           deviceCode: code,
           deviceType: a.equipmentTypeName || a.deviceType || '排水设备'
         }

+ 0 - 402
src/views/subSystem/drainage/jcbj/bjqd.vue

@@ -1,402 +0,0 @@
-<template>
-  <div class="app-container">
-    <!-- 搜索区 -->
-    <el-form :model="queryParams" ref="queryRef" :inline="true" class="search-form">
-      <el-form-item label="设备名称">
-        <el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable style="width: 180px" />
-      </el-form-item>
-      <el-form-item label="告警等级">
-        <el-select v-model="queryParams.alarmLevel" placeholder="请选择" clearable style="width: 160px">
-          <el-option label="最高" :value="1" />
-          <el-option label="高" :value="2" />
-          <el-option label="中" :value="3" />
-          <el-option label="低" :value="4" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="处理状态">
-        <el-select v-model="queryParams.alarmStatus" placeholder="请选择" clearable style="width: 140px">
-          <el-option label="未处理" :value="0" />
-          <el-option label="已处理" :value="1" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="时间范围">
-        <el-date-picker
-          v-model="queryParams.dateRange"
-          type="daterange"
-          range-separator="至"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-          style="width: 240px"
-          value-format="YYYY-MM-DD"
-        />
-      </el-form-item>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery">搜索</el-button>
-        <el-button @click="resetQuery">重置</el-button>
-      </el-form-item>
-    </el-form>
-
-    <!-- 数据表格 -->
-    <el-table :data="tableData" v-loading="loading" border style="width: 100%" size="default">
-      <el-table-column label="设备名称" prop="deviceName" min-width="130" show-overflow-tooltip />
-      <el-table-column label="设备编码" prop="deviceCode" min-width="120" show-overflow-tooltip />
-      <el-table-column label="设备类型" prop="deviceType" min-width="100" show-overflow-tooltip />
-      <el-table-column label="预警类型" prop="warningType" min-width="100" show-overflow-tooltip />
-      <el-table-column label="实际值" prop="actualValue" min-width="80" align="center" />
-      <el-table-column label="阈值范围" min-width="130" align="center">
-        <template #default="{ row }">
-          {{ row.minValue ?? '-' }} ~ {{ row.maxValue ?? '-' }}
-        </template>
-      </el-table-column>
-      <el-table-column label="告警等级" min-width="90" align="center">
-        <template #default="{ row }">
-          <span :class="levelClass(row.alarmLevel)">{{ levelText(row.alarmLevel) }}</span>
-        </template>
-      </el-table-column>
-      <el-table-column label="处理状态" min-width="90" align="center">
-        <template #default="{ row }">
-          <el-tag :type="statusTagType(row.alarmStatus)" size="small" effect="plain">{{ statusText(row.alarmStatus) }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="报警时间" prop="alarmTime" min-width="160" />
-      <el-table-column label="操作" width="80" align="center" fixed="right">
-        <template #default="{ row }">
-          <el-button link type="primary" @click="handleView(row)">查看</el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-
-    <!-- 分页 -->
-    <div class="pagination-wrap">
-      <el-pagination
-        v-model:current-page="pageNum"
-        v-model:page-size="pageSize"
-        :page-sizes="[10, 20, 50, 100]"
-        :total="total"
-        layout="total, sizes, prev, pager, next, jumper"
-        @size-change="handleSizeChange"
-        @current-change="handlePageChange"
-      />
-    </div>
-
-    <!-- 查看详情弹窗 -->
-    <el-drawer v-model="dialogVisible" title="报警详情" size="680px" :before-close="handleClose" class="detail-drawer" direction="rtl">
-      <div class="detail-panel">
-        <div class="detail-top">
-          <div class="detail-image">
-            <el-icon :size="48" color="#b0c4de"><Monitor /></el-icon>
-          </div>
-          <div class="detail-info">
-            <div class="detail-number">编号: {{ currentRow.id }}</div>
-            <el-row :gutter="16">
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">设备名称</span>
-                  <span class="info-value">{{ currentRow.deviceName || '-' }}</span>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">设备编码</span>
-                  <span class="info-value">{{ currentRow.deviceCode || '-' }}</span>
-                </div>
-              </el-col>
-            </el-row>
-            <el-row :gutter="16">
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">设备类型</span>
-                  <span class="info-value">{{ currentRow.deviceType || '-' }}</span>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">预警类型</span>
-                  <span class="info-value">{{ currentRow.warningType || '-' }}</span>
-                </div>
-              </el-col>
-            </el-row>
-            <el-row :gutter="16">
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">实际值</span>
-                  <span class="info-value">{{ currentRow.actualValue ?? '-' }}</span>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">阈值范围</span>
-                  <span class="info-value">{{ currentRow.minValue ?? '-' }} ~ {{ currentRow.maxValue ?? '-' }}</span>
-                </div>
-              </el-col>
-            </el-row>
-            <el-row :gutter="16">
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">告警等级</span>
-                  <span class="info-value">
-                    <span :class="levelClass(currentRow.alarmLevel)">{{ levelText(currentRow.alarmLevel) }}</span>
-                  </span>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">处理状态</span>
-                  <span class="info-value">
-                    <el-tag :type="statusTagType(currentRow.alarmStatus)" size="small" effect="plain">{{ statusText(currentRow.alarmStatus) }}</el-tag>
-                  </span>
-                </div>
-              </el-col>
-            </el-row>
-            <el-row :gutter="16">
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">报警时间</span>
-                  <span class="info-value">{{ currentRow.alarmTime || '-' }}</span>
-                </div>
-              </el-col>
-              <el-col :span="12">
-                <div class="info-item">
-                  <span class="info-label">偏离比率</span>
-                  <span class="info-value">{{ currentRow.deviationRatio ?? '-' }}</span>
-                </div>
-              </el-col>
-            </el-row>
-            <div class="info-item desc-item" v-if="currentRow.remark">
-              <span class="info-label">备注</span>
-              <span class="info-value desc-text">{{ currentRow.remark }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
-<script setup name="Bjqd">
-import { ref, onMounted } from 'vue'
-import { ElMessage } from 'element-plus'
-import { Monitor } from '@element-plus/icons-vue'
-import { getAlarmDataPage } from '@/api/drainage'
-
-// ==================== 搜索参数 ====================
-const queryParams = ref({
-  deviceName: '',
-  alarmLevel: '',
-  alarmStatus: '',
-  dateRange: []
-})
-const queryRef = ref(null)
-
-function handleQuery() {
-  pageNum.value = 1
-  loadData()
-}
-
-function resetQuery() {
-  queryParams.value = { deviceName: '', alarmLevel: '', alarmStatus: '', dateRange: [] }
-  pageNum.value = 1
-  loadData()
-}
-
-// ==================== 等级/状态映射(alarm_data表) ====================
-const levelMap = { 1: '最高', 2: '高', 3: '中', 4: '低' }
-const statusMap = { 0: '未处理', 1: '已处理' }
-
-function levelText(level) {
-  return levelMap[level] || '-'
-}
-function levelClass(level) {
-  const map = { 1: 'level-critical', 2: 'level-major', 3: 'level-minor', 4: 'level-low' }
-  return map[level] || ''
-}
-function statusText(status) {
-  return statusMap[status] ?? '-'
-}
-function statusTagType(status) {
-  const map = { 0: 'warning', 1: 'success' }
-  return map[status] || 'info'
-}
-
-// ==================== 表格数据(服务端分页) ====================
-const tableData = ref([])
-const total = ref(0)
-const loading = ref(false)
-const pageNum = ref(1)
-const pageSize = ref(10)
-
-async function loadData() {
-  loading.value = true
-  try {
-    const params = { equipmentType: 'drainage' }
-    if (queryParams.value.deviceName) params.deviceName = queryParams.value.deviceName
-    if (queryParams.value.alarmLevel !== '' && queryParams.value.alarmLevel !== null) params.alarmLevel = queryParams.value.alarmLevel
-    if (queryParams.value.alarmStatus !== '' && queryParams.value.alarmStatus !== null) params.alarmStatus = queryParams.value.alarmStatus
-    if (queryParams.value.dateRange && queryParams.value.dateRange.length === 2) {
-      params.startTime = queryParams.value.dateRange[0]
-      params.endTime = queryParams.value.dateRange[1]
-    }
-
-    const res = await getAlarmDataPage(pageNum.value, pageSize.value, params)
-    const pageData = res.code !== undefined ? res.data : res
-    tableData.value = pageData.records || pageData.rows || []
-    total.value = pageData.total || 0
-  } catch (error) {
-    console.error('加载报警清单失败', error)
-    ElMessage.error('加载报警清单失败')
-  } finally {
-    loading.value = false
-  }
-}
-
-function handlePageChange() {
-  loadData()
-}
-
-function handleSizeChange() {
-  pageNum.value = 1
-  loadData()
-}
-
-// ==================== 查看详情 ====================
-const dialogVisible = ref(false)
-const currentRow = ref({})
-
-function handleView(row) {
-  currentRow.value = { ...row }
-  dialogVisible.value = true
-}
-
-function handleClose() {
-  dialogVisible.value = false
-}
-
-// ==================== 生命周期 ====================
-onMounted(() => {
-  loadData()
-})
-</script>
-
-<style scoped>
-.app-container {
-  padding: 20px;
-  background: #fff;
-  min-height: calc(100vh - 84px);
-}
-
-.search-form {
-  margin-bottom: 16px;
-  padding: 16px 16px 0;
-  background: #f5f7fa;
-  border-radius: 6px;
-}
-
-.pagination-wrap {
-  margin-top: 16px;
-  display: flex;
-  justify-content: flex-end;
-}
-
-/* 等级彩色文字 */
-.level-critical {
-  color: #f56c6c;
-  font-weight: 700;
-}
-
-.level-major {
-  color: #e6a23c;
-  font-weight: 700;
-}
-
-.level-minor {
-  color: #409eff;
-  font-weight: 600;
-}
-
-.level-low {
-  color: #67c238;
-  font-weight: 600;
-}
-
-/* 详情弹窗 */
-.detail-panel {
-  padding: 0 4px;
-}
-
-.detail-top {
-  display: flex;
-  gap: 24px;
-}
-
-.detail-image {
-  width: 150px;
-  height: 150px;
-  flex-shrink: 0;
-  background: #dce6f0;
-  border-radius: 8px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  border: 1px solid #c0cfe0;
-}
-
-.detail-info {
-  flex: 1;
-}
-
-.detail-number {
-  font-size: 20px;
-  font-weight: 700;
-  color: #303133;
-  margin-bottom: 14px;
-}
-
-.info-item {
-  display: flex;
-  align-items: flex-start;
-  margin-bottom: 10px;
-  line-height: 22px;
-}
-
-.info-label {
-  color: #909399;
-  font-size: 14px;
-  white-space: nowrap;
-  min-width: 65px;
-  flex-shrink: 0;
-}
-
-.info-value {
-  color: #303133;
-  font-size: 14px;
-  flex: 1;
-}
-
-.desc-item {
-  align-items: flex-start;
-}
-
-.desc-text {
-  line-height: 22px;
-  color: #606266;
-}
-
-/* 底部操作区 */
-.detail-footer {
-  margin-top: 20px;
-  padding-top: 16px;
-  border-top: 1px solid #ebeef5;
-}
-
-.dispatch-row {
-  display: flex;
-  align-items: center;
-}
-
-.dispatch-label {
-  font-size: 14px;
-  color: #303133;
-  font-weight: 600;
-  white-space: nowrap;
-}
-</style>

+ 0 - 423
src/views/subSystem/drainage/jcbj/bjtx.vue

@@ -1,423 +0,0 @@
-<template>
-  <div class="bjtx-page">
-    <!-- 顶部栏 -->
-    <div class="top-bar">
-      <div class="top-left">
-        <h2 class="page-title">实时报警提醒</h2>
-        <span class="current-time">{{ currentTime }}</span>
-      </div>
-      <div class="top-right">
-        <div class="alarm-stats">
-          <span class="stat-item">待处理报警 <em class="stat-num">{{ alarmList.length }}</em> 条</span>
-          <span class="stat-divider">/</span>
-          <span class="stat-item">未处理 <em class="stat-num warn">{{ unHandledCount }}</em> 条</span>
-        </div>
-        <div class="sound-toggle" :class="{ active: soundOn }" @click="soundOn = !soundOn">
-          <el-icon :size="20"><component :is="soundOn ? 'Bell' : 'Mute'" /></el-icon>
-        </div>
-      </div>
-    </div>
-
-    <!-- 滚动报警列表 -->
-    <div class="alarm-scroll-wrapper" ref="scrollWrapper">
-      <div class="alarm-scroll-inner" :style="{ transform: `translateY(${scrollOffset}px)` }">
-        <div
-          v-for="item in alarmList"
-          :key="item.id"
-          class="alarm-card"
-          :class="'level-' + item.alarmLevelText"
-        >
-          <div class="card-left">
-            <span class="level-icon" :class="'icon-' + item.alarmLevelText">
-              {{ item.alarmLevelText === '最高' ? '!!' : item.alarmLevelText === '高' ? '!' : 'i' }}
-            </span>
-          </div>
-          <div class="card-body">
-            <div class="card-row row-1">
-              <span class="alarm-time">{{ item.alarmTime || '-' }}</span>
-              <span class="alarm-level-tag" :class="'tag-' + item.alarmLevelText">{{ item.alarmLevelText }}</span>
-            </div>
-            <div class="card-row row-2">
-              <span class="device-name">{{ item.deviceName || '-' }}</span>
-              <span class="device-code">{{ item.deviceCode || '' }}</span>
-            </div>
-            <div class="card-row row-3">
-              <span class="alarm-metric">预警类型: {{ item.warningType || '-' }} | 实际值: {{ item.actualValue ?? '-' }}</span>
-            </div>
-            <div class="card-row row-4">
-              <el-icon :size="14"><Location /></el-icon>
-              <span class="alarm-location">设备类型: {{ item.deviceType || '-' }} | 状态: {{ item.alarmStatus === 0 ? '未处理' : '已处理' }}</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup name="Bjtx">
-import { ref, computed, onMounted, onUnmounted } from 'vue'
-import { Bell, Mute, Location } from '@element-plus/icons-vue'
-import { getAlarmDataPage } from '@/api/drainage'
-import useUserStore from '@/store/modules/user'
-
-// ==================== 用户信息 ====================
-const userStore = useUserStore()
-const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
-
-// ==================== 时钟 ====================
-const currentTime = ref('')
-let clockTimer = null
-
-function updateClock() {
-  const now = new Date()
-  const y = now.getFullYear()
-  const M = String(now.getMonth() + 1).padStart(2, '0')
-  const d = String(now.getDate()).padStart(2, '0')
-  const h = String(now.getHours()).padStart(2, '0')
-  const m = String(now.getMinutes()).padStart(2, '0')
-  const s = String(now.getSeconds()).padStart(2, '0')
-  currentTime.value = `${y}-${M}-${d} ${h}:${m}:${s}`
-}
-
-// ==================== 声音开关 ====================
-const soundOn = ref(true)
-
-// ==================== 等级映射 ====================
-const alarmLevelMap = { 1: '最高', 2: '高', 3: '中', 4: '低' }
-
-// ==================== 报警数据 ====================
-const alarmList = ref([])
-
-async function loadAlarms() {
-  try {
-    // 报警提醒只展示未处理报警,已审核/已建工单的报警不再提醒。
-    const res = await getAlarmDataPage(1, 200, {
-      equipmentType: 'drainage',
-      alarmStatus: 0
-    })
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || pageData.rows || []
-
-    alarmList.value = records
-      .filter(row => Number(row.alarmStatus) === 0)
-      .map(row => ({
-      ...row,
-      alarmLevelText: alarmLevelMap[row.alarmLevel] || '-'
-      }))
-  } catch (error) {
-    console.error('加载报警数据失败', error)
-  }
-}
-
-const unHandledCount = computed(() => alarmList.value.filter(a => Number(a.alarmStatus) === 0).length)
-
-// ==================== 自动滚动 ====================
-const scrollWrapper = ref(null)
-const scrollOffset = ref(0)
-let scrollTimer = null
-let pollTimer = null
-const CARD_HEIGHT = 128 // 卡片高度 + 间距
-let scrollPaused = false
-
-function startScroll() {
-  scrollTimer = setInterval(() => {
-    if (scrollPaused || alarmList.value.length === 0) return
-    scrollOffset.value -= 1
-    // 每滚动一个卡片高度,将第一条数据移到末尾
-    if (Math.abs(scrollOffset.value) >= CARD_HEIGHT) {
-      const first = alarmList.value.shift()
-      if (first) alarmList.value.push(first)
-      scrollOffset.value = 0
-    }
-  }, 30)
-}
-
-// ==================== 生命周期 ====================
-onMounted(() => {
-  updateClock()
-  clockTimer = setInterval(updateClock, 1000)
-  loadAlarms()
-  startScroll()
-  // 30秒轮询刷新
-  pollTimer = setInterval(loadAlarms, 30000)
-
-  // 鼠标悬停暂停滚动
-  const wrapper = scrollWrapper.value
-  if (wrapper) {
-    wrapper.addEventListener('mouseenter', () => { scrollPaused = true })
-    wrapper.addEventListener('mouseleave', () => { scrollPaused = false })
-  }
-})
-
-onUnmounted(() => {
-  if (clockTimer) clearInterval(clockTimer)
-  if (scrollTimer) clearInterval(scrollTimer)
-  if (pollTimer) clearInterval(pollTimer)
-})
-</script>
-
-<style scoped>
-.bjtx-page {
-  width: 100%;
-  height: 100%;
-  min-height: calc(100vh - 84px);
-  background: linear-gradient(180deg, #0a1628 0%, #0d2137 50%, #0a1628 100%);
-  display: flex;
-  flex-direction: column;
-  color: #e0e6ed;
-  overflow: hidden;
-}
-
-/* 顶部栏 */
-.top-bar {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 18px 28px;
-  background: rgba(10, 30, 60, 0.85);
-  border-bottom: 1px solid rgba(64, 158, 255, 0.25);
-  flex-shrink: 0;
-}
-
-.top-left {
-  display: flex;
-  align-items: baseline;
-  gap: 18px;
-}
-
-.page-title {
-  font-size: 22px;
-  font-weight: 700;
-  color: #fff;
-  letter-spacing: 2px;
-  margin: 0;
-  background: linear-gradient(90deg, #409eff, #67c8ff);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-}
-
-.current-time {
-  font-size: 14px;
-  color: #8bb8e8;
-  font-family: 'Courier New', monospace;
-}
-
-.top-right {
-  display: flex;
-  align-items: center;
-  gap: 24px;
-}
-
-.alarm-stats {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 14px;
-  color: #a0c4e8;
-}
-
-.stat-num {
-  font-style: normal;
-  font-weight: 700;
-  font-size: 18px;
-  color: #409eff;
-}
-
-.stat-num.warn {
-  color: #e6a23c;
-}
-
-.stat-divider {
-  color: #3a5a7a;
-}
-
-.sound-toggle {
-  width: 38px;
-  height: 38px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: 8px;
-  cursor: pointer;
-  background: rgba(255, 255, 255, 0.06);
-  border: 1px solid rgba(64, 158, 255, 0.3);
-  color: #606266;
-  transition: all 0.3s;
-}
-
-.sound-toggle.active {
-  color: #409eff;
-  border-color: #409eff;
-  background: rgba(64, 158, 255, 0.12);
-}
-
-/* 滚动列表 */
-.alarm-scroll-wrapper {
-  flex: 1;
-  overflow: hidden;
-  padding: 16px 28px;
-}
-
-.alarm-scroll-inner {
-  transition: none;
-}
-
-/* 报警卡片 */
-.alarm-card {
-  display: flex;
-  align-items: stretch;
-  background: rgba(12, 30, 55, 0.85);
-  border: 1px solid rgba(64, 158, 255, 0.15);
-  border-radius: 10px;
-  margin-bottom: 10px;
-  overflow: hidden;
-  transition: border-color 0.3s;
-  height: 118px;
-}
-
-.alarm-card:hover {
-  border-color: rgba(64, 158, 255, 0.45);
-}
-
-.alarm-card.level-最高 {
-  border-left: 4px solid #f56c6c;
-}
-
-.alarm-card.level-高 {
-  border-left: 4px solid #e6a23c;
-}
-
-.alarm-card.level-中 {
-  border-left: 4px solid #409eff;
-}
-
-.alarm-card.level-低 {
-  border-left: 4px solid #67c238;
-}
-
-.card-left {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 60px;
-  flex-shrink: 0;
-}
-
-.level-icon {
-  width: 36px;
-  height: 36px;
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 16px;
-  font-weight: 800;
-  color: #fff;
-}
-
-.level-icon.icon-最高 {
-  background: radial-gradient(circle, #f56c6c, #c45656);
-  box-shadow: 0 0 12px rgba(245, 108, 108, 0.5);
-  animation: pulse-red 1.5s infinite;
-}
-
-.level-icon.icon-高 {
-  background: radial-gradient(circle, #e6a23c, #c88630);
-  box-shadow: 0 0 10px rgba(230, 162, 60, 0.4);
-}
-
-.level-icon.icon-中 {
-  background: radial-gradient(circle, #409eff, #2d7dd2);
-  box-shadow: 0 0 8px rgba(64, 158, 255, 0.3);
-}
-
-.level-icon.icon-低 {
-  background: radial-gradient(circle, #67c238, #4e9a2e);
-  box-shadow: 0 0 8px rgba(103, 194, 56, 0.3);
-}
-
-@keyframes pulse-red {
-  0%, 100% { box-shadow: 0 0 12px rgba(245, 108, 108, 0.5); }
-  50% { box-shadow: 0 0 24px rgba(245, 108, 108, 0.8); }
-}
-
-.card-body {
-  flex: 1;
-  padding: 12px 16px;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  gap: 6px;
-}
-
-.card-row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.row-1 {
-  justify-content: space-between;
-}
-
-.alarm-time {
-  font-size: 12px;
-  color: #6a8da8;
-  font-family: 'Courier New', monospace;
-}
-
-.alarm-level-tag {
-  font-size: 11px;
-  padding: 1px 10px;
-  border-radius: 10px;
-  font-weight: 600;
-}
-
-.tag-最高 {
-  background: rgba(245, 108, 108, 0.18);
-  color: #f56c6c;
-  border: 1px solid rgba(245, 108, 108, 0.4);
-}
-
-.tag-高 {
-  background: rgba(230, 162, 60, 0.18);
-  color: #e6a23c;
-  border: 1px solid rgba(230, 162, 60, 0.4);
-}
-
-.tag-中 {
-  background: rgba(64, 158, 255, 0.18);
-  color: #409eff;
-  border: 1px solid rgba(64, 158, 255, 0.4);
-}
-
-.tag-低 {
-  background: rgba(103, 194, 56, 0.18);
-  color: #67c238;
-  border: 1px solid rgba(103, 194, 56, 0.4);
-}
-
-.device-name {
-  font-size: 15px;
-  font-weight: 600;
-  color: #dce8f0;
-}
-
-.device-code {
-  font-size: 12px;
-  color: #6a8da8;
-  margin-left: 8px;
-}
-
-.alarm-metric {
-  font-size: 13px;
-  color: #e6a23c;
-  font-weight: 500;
-}
-
-.alarm-location {
-  font-size: 12px;
-  color: #6a8da8;
-}
-</style>

+ 0 - 859
src/views/subSystem/drainage/jcbj/bjyp.vue

@@ -1,859 +0,0 @@
-<template>
-  <div class="app-container bjyp-page">
-    <!-- Tab栏 -->
-    <div class="tab-bar">
-      <span :class="['tab-item', { active: activeTab === 'home' }]" @click="activeTab = 'home'">首页</span>
-      <!-- <span :class="['tab-item', { active: activeTab === 'monthly' }]" @click="activeTab = 'monthly'">报警月统计 ▼</span> -->
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-row">
-      <div class="stat-card" v-for="item in statCards" :key="item.label">
-        <div class="stat-label">{{ item.label }}</div>
-        <div class="stat-value">
-          {{ item.value }}
-          <span class="stat-sub">{{ item.sub }}</span>
-        </div>
-        <div class="stat-compare">
-          比昨天
-          <span v-if="item.trend === 0" style="color: rgba(255,255,255,0.4)">— 持平</span>
-          <span v-else :class="item.trend > 0 ? 'trend-up' : 'trend-down'">
-            {{ item.trend > 0 ? '↑' : '↓' }} {{ Math.abs(item.trend) }}
-          </span>
-        </div>
-      </div>
-    </div>
-
-    <!-- 统计时间范围:当前范围会同时作用于卡片、列表和全部图表 -->
-    <div class="range-bar">
-      <span class="range-label">统计时间:</span>
-      <el-radio-group v-model="activeDays" size="small" @change="handleRangeChange">
-        <el-radio-button v-for="option in rangeOptions" :key="option.value" :label="option.value">
-          {{ option.label }}
-        </el-radio-button>
-      </el-radio-group>
-    </div>
-
-    <!-- 图表区域 -->
-    <div class="chart-section">
-      <div class="chart-left">
-        <div class="section-title">最近{{ activeDays }}天报警趋势</div>
-        <div ref="trendChartRef" class="trend-chart"></div>
-      </div>
-      <div class="chart-right">
-        <div class="pie-box">
-          <div class="section-title">报警比例分布图</div>
-          <div ref="pieChartRef" class="pie-chart"></div>
-        </div>
-        <div class="pie-box">
-          <div class="section-title">报警处理比例分布</div>
-          <div ref="statusPieRef" class="pie-chart"></div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 报警列表 -->
-    <div class="table-section">
-      <div class="section-title">报警数据列表</div>
-      <el-table :data="tableData" v-loading="loading" border size="small" style="width: 100%">
-        <el-table-column label="观测井编号" prop="wellCode" min-width="130" align="center" show-overflow-tooltip />
-        <el-table-column label="预警类型" prop="warningType" min-width="110" align="center" />
-        <el-table-column label="报警时间" prop="warningTime" min-width="160" align="center" />
-        <el-table-column label="是否处理" min-width="90" align="center">
-          <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)" size="small" effect="plain">{{ statusText(row.status) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="设备编号" prop="equipmentId" min-width="130" align="center" show-overflow-tooltip />
-        <el-table-column label="观测井类型" prop="wellType" min-width="100" align="center" />
-        <el-table-column label="报警编号" prop="id" min-width="80" align="center" />
-        <el-table-column label="操作" width="90" align="center" fixed="right">
-          <template #default="{ row }">
-            <el-button link type="primary" @click="openAnalysis(row)">详情</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-    </div>
-
-    <!-- 分页 -->
-    <div class="pagination-wrap">
-      <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]"
-        :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange"
-        @current-change="handlePageChange" />
-    </div>
-
-    <!-- 研判分析弹窗 -->
-    <el-drawer v-model="analysisVisible" title="研判分析" size="900px" :before-close="closeAnalysis" class="detail-drawer" direction="rtl">
-      <!-- 报警基本信息 -->
-      <div class="analysis-header">
-        <el-descriptions :column="3" border size="small">
-          <el-descriptions-item label="设备名称">{{ currentAlarm.equipmentName }}</el-descriptions-item>
-          <el-descriptions-item label="设备编号">{{ currentAlarm.id }}</el-descriptions-item>
-          <el-descriptions-item label="设备位置">{{ currentAlarm.location }}</el-descriptions-item>
-          <el-descriptions-item label="报警等级">
-            <span :class="levelClass(currentAlarm.warningLevel)">{{ levelText(currentAlarm.warningLevel) }}</span>
-          </el-descriptions-item>
-          <el-descriptions-item label="报警时间">{{ currentAlarm.warningTime }}</el-descriptions-item>
-          <el-descriptions-item label="报警类型">{{ currentAlarm.warningType || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="实际值">{{ currentAlarm.currentValue ?? '-' }}</el-descriptions-item>
-          <el-descriptions-item label="阈值">{{ currentAlarm.thresholdValue ?? '-' }}</el-descriptions-item>
-          <el-descriptions-item label="处理状态">
-            <el-tag :type="statusTagType(currentAlarm.status)" size="small" effect="plain">{{ statusText(currentAlarm.status) }}</el-tag>
-          </el-descriptions-item>
-        </el-descriptions>
-      </div>
-
-      <template #footer>
-        <el-button @click="closeAnalysis">关闭</el-button>
-      </template>
-    </el-drawer>
-  </div>
-</template>
-
-<script setup name="Bjyp">
-import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
-import { ElMessage } from 'element-plus'
-import * as echarts from 'echarts'
-import { getAlarmDataPage, firstApproveWarning, finalApproveWarning, handleAlarm } from '@/api/drainage'
-import useUserStore from '@/store/modules/user'
-
-// ==================== 用户信息 ====================
-const userStore = useUserStore()
-const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
-
-// ==================== Tab栏 ====================
-const activeTab = ref('home')
-const activeDays = ref(7)
-const rangeOptions = [
-  { label: '7天', value: 7 },
-  { label: '15天', value: 15 },
-  { label: '30天', value: 30 }
-]
-
-// ==================== 等级/状态映射 ====================
-const levelMap = { 1: '严重', 2: '重要', 3: '一般' }
-const statusMap = { 0: '未处理', 1: '已处理' }
-
-function levelText(level) {
-  return levelMap[level] || '-'
-}
-function levelClass(level) {
-  const map = { 1: 'level-critical', 2: 'level-major', 3: 'level-minor' }
-  return map[level] || ''
-}
-function statusText(status) {
-  return statusMap[status] ?? '未处理'
-}
-function statusTagType(status) {
-  if (status === 1) return 'success'
-  return 'danger'
-}
-
-// ==================== 预警类型分类 ====================
-function getWarningCategory(wt) {
-  if (!wt) return '其他'
-  if (wt.includes('液位')) return '液位报警'
-  if (wt.includes('液流')) return '液流报警'
-  if (wt.includes('流量')) return '流量报警'
-  if (wt.includes('水质')) return '水质报警'
-  if (wt.includes('压力')) return '压力报警'
-  return '其他'
-}
-function getWellType(wt) {
-  if (!wt) return '排水井'
-  if (wt.includes('液位')) return '液位井'
-  if (wt.includes('液流')) return '液流井'
-  if (wt.includes('流量')) return '流量井'
-  if (wt.includes('水质')) return '水质井'
-  if (wt.includes('压力')) return '压力井'
-  return '排水井'
-}
-
-// ==================== 全量数据(统计与图表用) ====================
-const allRecords = ref([])
-const sevenDayRecords = ref([])
-const tableData = ref([])
-const total = ref(0)
-const loading = ref(false)
-const pageNum = ref(1)
-const pageSize = ref(10)
-
-// 真实"比昨天"趋势数据(从7天数据中计算)
-const statTrends = ref([0, 0, 0, 0, 0, 0])
-
-function computeRealTrends() {
-  const now = new Date()
-  const todayStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0')
-  const yesterday = new Date(now.getTime() - 86400000)
-  const yesterdayStr = yesterday.getFullYear() + '-' + String(yesterday.getMonth() + 1).padStart(2, '0') + '-' + String(yesterday.getDate()).padStart(2, '0')
-
-  function getDateStr(record) {
-    const t = record.warningTime
-    if (!t) return ''
-    return String(t).slice(0, 10)
-  }
-
-  const todayCount = { total: 0, 液位: 0, 液流: 0, 流量: 0, 水质: 0, 压力: 0 }
-  const yesterdayCount = { total: 0, 液位: 0, 液流: 0, 流量: 0, 水质: 0, 压力: 0 }
-
-  sevenDayRecords.value.forEach(r => {
-    const ds = getDateStr(r)
-    const wt = r.warningType || ''
-    if (ds === todayStr) {
-      todayCount.total++
-      if (wt.includes('液位')) todayCount.液位++
-      if (wt.includes('液流')) todayCount.液流++
-      if (wt.includes('流量')) todayCount.流量++
-      if (wt.includes('水质')) todayCount.水质++
-      if (wt.includes('压力')) todayCount.压力++
-    } else if (ds === yesterdayStr) {
-      yesterdayCount.total++
-      if (wt.includes('液位')) yesterdayCount.液位++
-      if (wt.includes('液流')) yesterdayCount.液流++
-      if (wt.includes('流量')) yesterdayCount.流量++
-      if (wt.includes('水质')) yesterdayCount.水质++
-      if (wt.includes('压力')) yesterdayCount.压力++
-    }
-  })
-
-  statTrends.value = [
-    todayCount.total - yesterdayCount.total,
-    todayCount.液位 - yesterdayCount.液位,
-    todayCount.液流 - yesterdayCount.液流,
-    todayCount.流量 - yesterdayCount.流量,
-    todayCount.水质 - yesterdayCount.水质,
-    todayCount.压力 - yesterdayCount.压力
-  ]
-}
-
-// ==================== 统计卡片 ====================
-const statCards = computed(() => {
-  const all = allRecords.value
-  const cnt = (kw) => all.filter(r => r.warningType && r.warningType.includes(kw)).length
-  return [
-    { label: '报警总数', value: all.length, sub: '', trend: statTrends.value[0] },
-    { label: '液位报警', value: cnt('液位'), sub: '', trend: statTrends.value[1] },
-    { label: '液流报警', value: cnt('液流'), sub: '', trend: statTrends.value[2] },
-    { label: '流量报警', value: cnt('流量'), sub: '', trend: statTrends.value[3] },
-    { label: '水质报警', value: cnt('水质'), sub: '', trend: statTrends.value[4] },
-    { label: '压力报警', value: cnt('压力'), sub: '', trend: statTrends.value[5] }
-  ]
-})
-
-// ==================== 数据加载 ====================
-async function loadData() {
-  loading.value = true
-  try {
-    const now = new Date()
-    const start = new Date(now.getTime() - (activeDays.value - 1) * 86400000)
-    const formatDateTime = (date) => {
-      const datePart = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
-      return `${datePart} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
-    }
-    const res = await getAlarmDataPage(1, 9999, {
-      equipmentType: 'drainage',
-      startTime: formatDateTime(start),
-      endTime: formatDateTime(now)
-    })
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || pageData.rows || []
-
-    const mapped = records.map(row => ({
-      id: row.id,
-      equipmentId: row.deviceCode,
-      equipmentName: row.deviceName || '-',
-      wellCode: row.deviceCode || row.deviceName || '-',
-      wellType: getWellType(row.warningType),
-      warningTime: row.alarmTime,
-      warningLevel: row.alarmLevel,
-      warningType: row.warningType || '-',
-      content: row.warningType || '-',
-      location: '-',
-      currentValue: row.actualValue,
-      thresholdValue: row.maxValue,
-      status: row.alarmStatus,
-      longitude: row.longitude,
-      latitude: row.latitude
-    }))
-
-    sevenDayRecords.value = mapped
-    allRecords.value = mapped
-    total.value = mapped.length
-    computeRealTrends()
-    updateTablePage()
-
-    nextTick(() => {
-      initAllCharts()
-    })
-  } catch (error) {
-    console.error('加载报警列表失败', error)
-    ElMessage.error('加载报警列表失败')
-  } finally {
-    loading.value = false
-  }
-}
-
-function handleRangeChange() {
-  pageNum.value = 1
-  loadData()
-}
-
-function updateTablePage() {
-  const start = (pageNum.value - 1) * pageSize.value
-  tableData.value = allRecords.value.slice(start, start + pageSize.value)
-}
-
-function handlePageChange() {
-  updateTablePage()
-}
-function handleSizeChange() {
-  pageNum.value = 1
-  updateTablePage()
-}
-
-// ==================== ECharts 图表 ====================
-const trendChartRef = ref(null)
-const pieChartRef = ref(null)
-const statusPieRef = ref(null)
-
-let trendChart = null
-let pieChart = null
-let statusChart = null
-
-function initAllCharts() {
-  initTrendChart()
-  initPieChart()
-  initStatusPie()
-}
-
-function initTrendChart() {
-  if (!trendChartRef.value) return
-  if (trendChart) trendChart.dispose()
-  trendChart = echarts.init(trendChartRef.value)
-
-  // 生成当前时间范围的日期键(M/D 格式与 YYYY-MM-DD 格式)
-  const days = []
-  const dayKeys = []
-  const now = new Date()
-  for (let i = activeDays.value - 1; i >= 0; i--) {
-    const d = new Date(now.getTime() - i * 86400000)
-    days.push((d.getMonth() + 1) + '/' + d.getDate())
-    dayKeys.push(d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'))
-  }
-
-  // 按日期 + 类型分组统计
-  const categories = ['液位', '液流', '流量', '水质', '压力']
-  const countMap = {}
-  dayKeys.forEach(dk => {
-    countMap[dk] = {}
-    categories.forEach(c => { countMap[dk][c] = 0 })
-  })
-
-  sevenDayRecords.value.forEach(r => {
-    const t = r.warningTime
-    if (!t) return
-    const ds = String(t).slice(0, 10)
-    if (!countMap[ds]) return
-    const wt = r.warningType || ''
-    categories.forEach(c => {
-      if (wt.includes(c)) countMap[ds][c]++
-    })
-  })
-
-  const seriesNames = categories.map(c => c + '报警')
-  const colors = ['#5470c6', '#ee6666', '#91cc75', '#fac858', '#73c0de']
-
-  const series = categories.map((cat, idx) => ({
-    name: cat + '报警',
-    type: 'line',
-    stack: '总量',
-    data: dayKeys.map(dk => countMap[dk][cat]),
-    smooth: true,
-    symbol: 'circle',
-    symbolSize: 5,
-    itemStyle: { color: colors[idx] },
-    areaStyle: { opacity: 0.35 }
-  }))
-
-  trendChart.setOption({
-    tooltip: {
-      trigger: 'axis',
-      axisPointer: { type: 'cross', label: { backgroundColor: 'rgba(20,40,70,0.8)' } }
-    },
-    legend: {
-      data: seriesNames,
-      top: 0,
-      textStyle: { color: 'rgba(255,255,255,0.7)', fontSize: 11 },
-      itemWidth: 12,
-      itemHeight: 8
-    },
-    grid: { left: '2%', right: '3%', top: '18%', bottom: '5%', containLabel: true },
-    xAxis: {
-      type: 'category',
-      data: days,
-      boundaryGap: false,
-      axisLabel: { color: 'rgba(255,255,255,0.6)', fontSize: 11 },
-      axisLine: { lineStyle: { color: 'rgba(255,255,255,0.2)' } }
-    },
-    yAxis: {
-      type: 'value',
-      minInterval: 1,
-      axisLabel: { color: 'rgba(255,255,255,0.6)', fontSize: 11 },
-      splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
-    },
-    series
-  })
-}
-
-function initPieChart() {
-  if (!pieChartRef.value) return
-  if (pieChart) pieChart.dispose()
-  pieChart = echarts.init(pieChartRef.value)
-
-  const catCount = {}
-  allRecords.value.forEach(r => {
-    const cat = getWarningCategory(r.warningType)
-    catCount[cat] = (catCount[cat] || 0) + 1
-  })
-
-  // 确保所有类型都出现
-  const allCats = ['液位报警', '液流报警', '流量报警', '水质报警', '压力报警', '其他']
-  allCats.forEach(c => { if (!catCount[c]) catCount[c] = 0 })
-
-  const data = Object.entries(catCount)
-    .filter(([_, v]) => v > 0)
-    .map(([name, value]) => ({ name, value }))
-
-  if (data.length === 0) {
-    data.push({ name: '暂无数据', value: 1 })
-  }
-
-  const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#fc8452']
-
-  pieChart.setOption({
-    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-    legend: {
-      bottom: 0,
-      textStyle: { color: 'rgba(255,255,255,0.6)', fontSize: 10 },
-      type: 'scroll',
-      itemWidth: 10,
-      itemHeight: 8
-    },
-    color: colors,
-    series: [{
-      type: 'pie',
-      radius: ['38%', '62%'],
-      center: ['50%', '42%'],
-      data,
-      label: { color: 'rgba(255,255,255,0.7)', fontSize: 10, formatter: '{b}\n{d}%' },
-      labelLine: { lineStyle: { color: 'rgba(255,255,255,0.3)' }, length: 8, length2: 8 },
-      itemStyle: { borderColor: 'rgba(10,22,40,0.6)', borderWidth: 2 }
-    }]
-  })
-}
-
-function initStatusPie() {
-  if (!statusPieRef.value) return
-  if (statusChart) statusChart.dispose()
-  statusChart = echarts.init(statusPieRef.value)
-
-  const statusCount = { '未处理': 0, '已处理': 0 }
-  allRecords.value.forEach(r => {
-    const s = statusText(r.status)
-    if (statusCount[s] !== undefined) statusCount[s]++
-    else statusCount['未处理']++
-  })
-
-  const data = Object.entries(statusCount)
-    .filter(([_, v]) => v > 0)
-    .map(([name, value]) => ({ name, value }))
-
-  if (data.length === 0) {
-    data.push({ name: '暂无数据', value: 1 })
-  }
-
-  const colors = ['#ee6666', '#91cc75']
-
-  statusChart.setOption({
-    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
-    legend: {
-      bottom: 0,
-      textStyle: { color: 'rgba(255,255,255,0.6)', fontSize: 10 },
-      type: 'scroll',
-      itemWidth: 10,
-      itemHeight: 8
-    },
-    color: colors,
-    series: [{
-      type: 'pie',
-      radius: ['38%', '62%'],
-      center: ['50%', '42%'],
-      data,
-      label: { color: 'rgba(255,255,255,0.7)', fontSize: 10, formatter: '{b}\n{d}%' },
-      labelLine: { lineStyle: { color: 'rgba(255,255,255,0.3)' }, length: 8, length2: 8 },
-      itemStyle: { borderColor: 'rgba(10,22,40,0.6)', borderWidth: 2 }
-    }]
-  })
-}
-
-function handleResize() {
-  trendChart && trendChart.resize()
-  pieChart && pieChart.resize()
-  statusChart && statusChart.resize()
-}
-
-// ==================== 研判分析弹窗 ====================
-const analysisVisible = ref(false)
-const currentAlarm = ref({})
-
-const conclusionForm = ref({
-  result: '',
-  desc: ''
-})
-
-// 下派表单
-const dispatchForm = ref({
-  handleUser: '',
-  remark: ''
-})
-
-// 按钮loading状态
-const dispatching = ref(false)
-const handling = ref(false)
-const approving = ref(false)
-
-async function openAnalysis(row) {
-  currentAlarm.value = { ...row }
-  conclusionForm.value = { result: '', desc: '' }
-  dispatchForm.value = { handleUser: '', remark: '' }
-  analysisVisible.value = true
-}
-
-function closeAnalysis() {
-  analysisVisible.value = false
-}
-
-function submitConclusion() {
-  if (!conclusionForm.value.result) {
-    ElMessage.warning('请选择研判结果')
-    return
-  }
-  if (!conclusionForm.value.desc) {
-    ElMessage.warning('请输入研判说明')
-    return
-  }
-  const idx = allRecords.value.findIndex(item => item.id === currentAlarm.value.id)
-  if (idx !== -1) {
-    allRecords.value[idx].status = 1
-  }
-  // 同步更新表格当前页
-  updateTablePage()
-  // 刷新图表
-  nextTick(() => {
-    initPieChart()
-    initStatusPie()
-  })
-  ElMessage.success('研判提交成功')
-  closeAnalysis()
-}
-
-// ==================== 生命周期 ====================
-onMounted(() => {
-  loadData()
-  window.addEventListener('resize', handleResize)
-})
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', handleResize)
-  if (trendChart) { trendChart.dispose(); trendChart = null }
-  if (pieChart) { pieChart.dispose(); pieChart = null }
-  if (statusChart) { statusChart.dispose(); statusChart = null }
-})
-</script>
-
-<style scoped>
-.bjyp-page {
-  background: #0a1628;
-  min-height: calc(100vh - 84px);
-  padding: 16px;
-  box-sizing: border-box;
-}
-
-/* ==================== Tab栏 ==================== */
-.tab-bar {
-  display: flex;
-  gap: 4px;
-  margin-bottom: 16px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.tab-item {
-  padding: 8px 24px;
-  font-size: 14px;
-  color: rgba(255, 255, 255, 0.5);
-  cursor: pointer;
-  border-bottom: 2px solid transparent;
-  transition: all 0.2s;
-  user-select: none;
-}
-
-.tab-item:hover {
-  color: rgba(255, 255, 255, 0.8);
-}
-
-.tab-item.active {
-  color: #409eff;
-  border-bottom-color: #409eff;
-}
-
-/* ==================== 统计卡片 ==================== */
-.stats-row {
-  display: flex;
-  gap: 12px;
-  margin-bottom: 16px;
-}
-
-.stat-card {
-  flex: 1;
-  background: rgba(255, 255, 255, 0.05);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 8px;
-  padding: 14px 16px;
-  transition: border-color 0.2s;
-}
-
-.stat-card:hover {
-  border-color: rgba(64, 158, 255, 0.3);
-}
-
-.stat-label {
-  font-size: 13px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-bottom: 8px;
-}
-
-.stat-value {
-  font-size: 28px;
-  font-weight: 700;
-  color: #fff;
-  line-height: 1.2;
-}
-
-.stat-sub {
-  font-size: 12px;
-  font-weight: 400;
-  color: rgba(255, 255, 255, 0.4);
-  margin-left: 4px;
-}
-
-.stat-compare {
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.4);
-  margin-top: 8px;
-}
-
-.range-bar {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-bottom: 12px;
-  color: rgba(255, 255, 255, 0.65);
-}
-
-.range-label {
-  font-size: 13px;
-}
-
-.trend-up {
-  color: #f56c6c;
-  font-weight: 600;
-}
-
-.trend-down {
-  color: #67c23a;
-  font-weight: 600;
-}
-
-/* ==================== 图表区域 ==================== */
-.chart-section {
-  display: flex;
-  gap: 12px;
-  margin-bottom: 16px;
-}
-
-.chart-left {
-  flex: 1;
-  background: rgba(255, 255, 255, 0.05);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 8px;
-  padding: 14px;
-}
-
-.chart-right {
-  width: 340px;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.pie-box {
-  flex: 1;
-  background: rgba(255, 255, 255, 0.05);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 8px;
-  padding: 14px;
-}
-
-.section-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: rgba(255, 255, 255, 0.85);
-  margin-bottom: 8px;
-  padding-left: 10px;
-  border-left: 3px solid #409eff;
-  line-height: 1;
-}
-
-.trend-chart {
-  width: 100%;
-  height: 260px;
-}
-
-.pie-chart {
-  width: 100%;
-  height: 170px;
-}
-
-/* ==================== 表格区域 ==================== */
-.table-section {
-  background: rgba(255, 255, 255, 0.05);
-  border: 1px solid rgba(255, 255, 255, 0.08);
-  border-radius: 8px;
-  padding: 14px;
-}
-
-/* 深色主题表格覆盖 */
-.bjyp-page :deep(.el-table) {
-  background: transparent;
-  color: rgba(255, 255, 255, 0.85);
-  --el-table-border-color: rgba(255, 255, 255, 0.08);
-  --el-table-header-bg-color: rgba(255, 255, 255, 0.06);
-  --el-table-row-hover-bg-color: rgba(255, 255, 255, 0.04);
-  --el-table-bg-color: transparent;
-  --el-table-tr-bg-color: transparent;
-}
-
-.bjyp-page :deep(.el-table th.el-table__cell) {
-  background: rgba(255, 255, 255, 0.06) !important;
-  color: rgba(255, 255, 255, 0.7);
-  font-weight: 600;
-}
-
-.bjyp-page :deep(.el-table .el-table__cell) {
-  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.bjyp-page :deep(.el-table--border .el-table__cell) {
-  border-right: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.bjyp-page :deep(.el-table tr) {
-  background: transparent;
-}
-
-.bjyp-page :deep(.el-table__body tr:hover > td.el-table__cell) {
-  background: rgba(255, 255, 255, 0.04) !important;
-}
-
-.bjyp-page :deep(.el-table__inner-wrapper::before) {
-  background: rgba(255, 255, 255, 0.08);
-}
-
-.bjyp-page :deep(.el-table--border::after) {
-  background: rgba(255, 255, 255, 0.08);
-}
-
-.bjyp-page :deep(.el-table__empty-text) {
-  color: rgba(255, 255, 255, 0.4);
-}
-
-/* ==================== 分页 ==================== */
-.pagination-wrap {
-  margin-top: 12px;
-  display: flex;
-  justify-content: flex-end;
-}
-
-.bjyp-page :deep(.el-pagination) {
-  --el-pagination-bg-color: transparent;
-  --el-pagination-text-color: rgba(255, 255, 255, 0.7);
-  --el-pagination-button-color: rgba(255, 255, 255, 0.7);
-  --el-pagination-button-bg-color: transparent;
-  --el-pagination-hover-color: #409eff;
-}
-
-.bjyp-page :deep(.el-pagination .el-pager li) {
-  background: transparent;
-  color: rgba(255, 255, 255, 0.7);
-}
-
-.bjyp-page :deep(.el-pagination .el-pager li.is-active) {
-  background: #409eff;
-  color: #fff;
-}
-
-.bjyp-page :deep(.el-pagination .btn-prev),
-.bjyp-page :deep(.el-pagination .btn-next) {
-  background: transparent;
-  color: rgba(255, 255, 255, 0.7);
-}
-
-.bjyp-page :deep(.el-pagination .el-select .el-input__wrapper) {
-  background: rgba(255, 255, 255, 0.08);
-  box-shadow: none;
-}
-
-.bjyp-page :deep(.el-pagination .el-input__inner) {
-  color: rgba(255, 255, 255, 0.7);
-}
-
-.bjyp-page :deep(.el-pagination__total) {
-  color: rgba(255, 255, 255, 0.5);
-}
-
-.bjyp-page :deep(.el-pagination__jump) {
-  color: rgba(255, 255, 255, 0.5);
-}
-
-/* ==================== 弹窗内部样式 ==================== */
-.analysis-header {
-  margin-bottom: 16px;
-}
-
-.analysis-conclusion-section {
-  margin-top: 16px;
-  border-top: 1px solid #e4e7ed;
-  padding-top: 16px;
-}
-
-.conclusion-title {
-  font-size: 15px;
-  font-weight: bold;
-  margin-bottom: 0;
-}
-
-/* 等级彩色文字 */
-.level-critical {
-  color: #f56c6c;
-  font-weight: 700;
-}
-
-.level-major {
-  color: #e6a23c;
-  font-weight: 700;
-}
-
-.level-minor {
-  color: #409eff;
-  font-weight: 600;
-}</style>

+ 0 - 816
src/views/subSystem/drainage/jcbj/dqbj.vue

@@ -1,816 +0,0 @@
-<template>
-  <div class="gis-page">
-    <!-- 左侧浮动面板 -->
-    <div class="side-panel">
-      <div class="panel-header">
-        <span class="panel-title">当前报警分布</span>
-      </div>
-
-      <!-- 等级筛选 -->
-      <div class="panel-section">
-        <div class="section-label">等级筛选</div>
-        <div class="layer-list">
-          <div class="layer-item">
-            <el-checkbox v-model="levelChecked.severe" @change="onLevelChange">
-              <span class="level-dot" style="background:#f56c6c"></span> 严重
-            </el-checkbox>
-          </div>
-          <div class="layer-item">
-            <el-checkbox v-model="levelChecked.important" @change="onLevelChange">
-              <span class="level-dot" style="background:#e6a23c"></span> 重要
-            </el-checkbox>
-          </div>
-          <div class="layer-item">
-            <el-checkbox v-model="levelChecked.normal" @change="onLevelChange">
-              <span class="level-dot" style="background:#409eff"></span> 一般
-            </el-checkbox>
-          </div>
-        </div>
-      </div>
-
-      <!-- 统计数据 -->
-      <div class="panel-section">
-        <div class="section-label">报警统计</div>
-        <div class="stats-row">
-          <div class="stat-card">
-            <div class="stat-value">{{ alarmList.length }}</div>
-            <div class="stat-text">总数</div>
-          </div>
-          <div class="stat-card stat-severe">
-            <div class="stat-value">{{ severeCount }}</div>
-            <div class="stat-text">严重</div>
-          </div>
-          <div class="stat-card stat-important">
-            <div class="stat-value">{{ importantCount }}</div>
-            <div class="stat-text">重要</div>
-          </div>
-          <div class="stat-card stat-normal">
-            <div class="stat-value">{{ normalCount }}</div>
-            <div class="stat-text">一般</div>
-          </div>
-        </div>
-      </div>
-
-      <!-- 搜索框 -->
-      <div class="panel-section">
-        <div class="section-label">快速定位</div>
-        <el-input
-          v-model="searchKey"
-          placeholder="输入设备名称搜索"
-          clearable
-          prefix-icon="Search"
-          @input="onSearch"
-        />
-        <div class="search-results" v-if="searchResults.length > 0">
-          <div
-            class="search-item"
-            v-for="item in searchResults"
-            :key="item.id"
-            @click="locatePoint(item)"
-          >
-            <span class="level-dot" :style="{ background: getLevelColor(item.level) }"></span>
-            <span class="search-name">{{ item.name }}</span>
-            <span class="search-level" :class="getLevelClass(item.level)">{{ item.level }}</span>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 地图容器 -->
-    <div id="dqbjGISMap" class="map-container"></div>
-
-    <!-- 详情弹窗 -->
-    <el-dialog
-      v-model="detailVisible"
-      :title="currentItem.name"
-      width="620px"
-      :close-on-click-modal="false"
-      class="detail-dialog"
-      @opened="onDialogOpened"
-      @closed="onDialogClosed"
-    >
-      <div class="detail-content">
-        <!-- 基本信息 -->
-        <div class="detail-section">
-          <div class="section-title">基本信息</div>
-          <div class="detail-header">
-            <el-tag :type="getTagType(currentItem.level)" effect="dark" size="small">
-              {{ currentItem.level }}
-            </el-tag>
-            <span class="detail-name">{{ currentItem.name }}</span>
-          </div>
-          <div class="detail-info">
-            <div class="info-row">
-              <span class="info-label">设备编号</span>
-              <span class="info-value">{{ currentItem.deviceNo }}</span>
-            </div>
-            <div class="info-row">
-              <span class="info-label">报警时间</span>
-              <span class="info-value">{{ currentItem.time }}</span>
-            </div>
-            <div class="info-row">
-              <span class="info-label">报警类型</span>
-              <span class="info-value">{{ currentItem.type }}</span>
-            </div>
-            <div class="info-row">
-              <span class="info-label">当前值</span>
-              <span class="info-value highlight">{{ currentItem.value }}</span>
-            </div>
-            <div class="info-row">
-              <span class="info-label">阈值</span>
-              <span class="info-value">{{ currentItem.threshold }}</span>
-            </div>
-            <div class="info-row">
-              <span class="info-label">位置</span>
-              <span class="info-value">{{ currentItem.location }}</span>
-            </div>
-          </div>
-        </div>
-
-        <!-- 监测曲线 -->
-        <div class="detail-section">
-          <div class="section-title">监测曲线(近24小时)</div>
-          <div ref="chartRef" class="monitor-chart"></div>
-        </div>
-
-        <!-- 周边环境 -->
-        <div class="detail-section">
-          <div class="section-title">周边环境</div>
-          <div class="env-desc">{{ currentItem.envDesc }}</div>
-        </div>
-
-        <!-- 历史记录 -->
-        <div class="detail-section">
-          <div class="section-title">历史记录</div>
-          <el-timeline>
-            <el-timeline-item
-              v-for="(record, index) in currentItem.historyRecords"
-              :key="index"
-              :timestamp="record.time"
-              placement="top"
-              :type="getTagType(record.level)"
-            >
-              <div class="timeline-content">
-                <span class="timeline-name">{{ record.name }}</span>
-                <span class="timeline-type">{{ record.type }}</span>
-                <span class="timeline-value">{{ record.value }}</span>
-              </div>
-            </el-timeline-item>
-          </el-timeline>
-        </div>
-      </div>
-    </el-dialog>
-  </div>
-</template>
-
-<script setup name="Dqbj">
-import { ref, reactive, computed, onMounted, nextTick } from 'vue'
-import { Search } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import * as echarts from 'echarts'
-import locationIcon from '@/assets/images/location.png'
-import { getAlarmDataPage } from '@/api/drainage'
-import useUserStore from '@/store/modules/user'
-
-// ==================== 用户信息 ====================
-const userStore = useUserStore()
-const isAdmin = computed(() => userStore.roles && userStore.roles.includes('admin'))
-
-// ==================== 等级筛选 ====================
-const levelChecked = reactive({ severe: true, important: true, normal: true })
-
-// ==================== 等级映射 ====================
-const levelMap = { 1: '严重', 2: '重要', 3: '一般' }
-
-// ==================== 报警数据(从API加载)====================
-const alarmList = ref([])
-
-async function loadData() {
-  try {
-    const res = await getAlarmDataPage(1, 200, { equipmentType: 'drainage' })
-    const pageData = res.code !== undefined ? res.data : res
-    const records = pageData.records || pageData.rows || []
-    alarmList.value = records
-      .map(row => ({
-        id: row.id,
-        name: row.deviceName || '-',
-        level: row.alarmLevel === 1 ? '严重' : row.alarmLevel === 2 ? '重要' : '一般',
-        time: row.alarmTime || '-',
-        type: row.warningType || '-',
-        value: row.actualValue || '-',
-        threshold: row.maxValue || '-',
-        location: '-',
-        deviceNo: row.deviceCode,
-        envDesc: '-',
-        lng: parseFloat(row.longitude),
-        lat: parseFloat(row.latitude),
-        historyRecords: []
-      }))
-      .filter(w => w.lng && w.lat && !isNaN(w.lng) && !isNaN(w.lat))
-  } catch (error) {
-    console.error('加载当前报警数据失败', error)
-    ElMessage.error('加载当前报警数据失败')
-  }
-}
-
-// ==================== 统计计算 ====================
-const severeCount = computed(() => alarmList.value.filter(v => v.level === '严重').length)
-const importantCount = computed(() => alarmList.value.filter(v => v.level === '重要').length)
-const normalCount = computed(() => alarmList.value.filter(v => v.level === '一般').length)
-
-// ==================== 工具方法 ====================
-function getLevelColor(level) {
-  const map = { '严重': '#f56c6c', '重要': '#e6a23c', '一般': '#409eff' }
-  return map[level] || '#409eff'
-}
-
-function getLevelClass(level) {
-  const map = { '严重': 'level-severe', '重要': 'level-important', '一般': 'level-normal' }
-  return map[level] || ''
-}
-
-function getTagType(level) {
-  const map = { '严重': 'danger', '重要': 'warning', '一般': '' }
-  return map[level] || ''
-}
-
-function getLevelKey(level) {
-  const map = { '严重': 'severe', '重要': 'important', '一般': 'normal' }
-  return map[level] || 'normal'
-}
-
-// ==================== 搜索功能 ====================
-const searchKey = ref('')
-const searchResults = ref([])
-
-function onSearch() {
-  if (!searchKey.value.trim()) {
-    searchResults.value = []
-    return
-  }
-  const key = searchKey.value.trim().toLowerCase()
-  searchResults.value = alarmList.value.filter(v =>
-    v.name.toLowerCase().includes(key) || v.location.toLowerCase().includes(key)
-  )
-}
-
-function locatePoint(item) {
-  if (mapInstance) {
-    mapInstance.centerAndZoom(new BMapGL.Point(item.lng, item.lat), 17)
-    openDetail(item)
-  }
-  searchResults.value = []
-  searchKey.value = ''
-}
-
-// ==================== 地图相关 ====================
-let mapInstance = null
-const markerMap = new Map()
-
-function initMap() {
-  const container = document.getElementById('dqbjGISMap')
-  if (!container || typeof BMapGL === 'undefined') {
-    console.warn('BMapGL 未加载或容器不存在')
-    return
-  }
-
-  try {
-    mapInstance = new BMapGL.Map('dqbjGISMap')
-    const centerPoint = new BMapGL.Point(110.393, 28.452)
-    mapInstance.centerAndZoom(centerPoint, 15)
-    mapInstance.enableScrollWheelZoom(true)
-
-    alarmList.value.forEach(point => {
-      addMapMarker(point)
-    })
-
-    // 默认定位到第一个报警点位
-    if (alarmList.value.length > 0) {
-      const firstPoint = alarmList.value[0]
-      mapInstance.centerAndZoom(new BMapGL.Point(firstPoint.lng, firstPoint.lat), 15)
-    }
-  } catch (e) {
-    console.error('百度地图初始化失败:', e)
-  }
-}
-
-function addMapMarker(point) {
-  const color = getLevelColor(point.level)
-  const bPoint = new BMapGL.Point(point.lng, point.lat)
-
-  const html = `<div style="text-align:center;cursor:pointer;">
-    <div style="color:#fff;background:${color};border-radius:4px;padding:2px 8px;font-size:11px;white-space:nowrap;display:inline-block;margin-bottom:2px;box-shadow:0 1px 4px rgba(0,0,0,0.3);">
-      ${point.name}
-    </div>
-    <div><img src="${locationIcon}" style="width:28px;height:28px;display:block;margin:0 auto;"/></div>
-  </div>`
-
-  const label = new BMapGL.Label(html, {
-    position: bPoint,
-    offset: new BMapGL.Size(-30, -50)
-  })
-  label.setStyle({
-    border: 'none',
-    background: 'transparent',
-    padding: '0',
-    zIndex: '10'
-  })
-  mapInstance.addOverlay(label)
-  markerMap.set(point.id, label)
-
-  label.addEventListener('click', function () {
-    openDetail(point)
-  })
-}
-
-// ==================== 等级筛选 ====================
-function onLevelChange() {
-  alarmList.value.forEach(point => {
-    const label = markerMap.get(point.id)
-    if (!label) return
-    const key = getLevelKey(point.level)
-    const visible = levelChecked[key]
-    label.show()
-    if (!visible) {
-      label.hide()
-    }
-  })
-}
-
-// ==================== 详情弹窗 ====================
-const detailVisible = ref(false)
-const currentItem = ref({})
-const chartRef = ref(null)
-let chartInstance = null
-
-function openDetail(point) {
-  currentItem.value = { ...point }
-  detailVisible.value = true
-}
-
-function onDialogOpened() {
-  nextTick(() => {
-    initChart()
-  })
-}
-
-function onDialogClosed() {
-  if (chartInstance) {
-    chartInstance.dispose()
-    chartInstance = null
-  }
-}
-
-// ==================== ECharts 监测曲线 ====================
-function initChart() {
-  if (!chartRef.value) return
-  chartInstance = echarts.init(chartRef.value)
-
-  // 生成近24小时模拟数据
-  const hours = []
-  const values = []
-  const now = new Date()
-  const thresholdVal = parseFloat(currentItem.value.threshold) || 2.5
-  const currentVal = parseFloat(currentItem.value) || 0
-  const isLevel = currentItem.type === '液位超限' || currentItem.type === '降雨预警'
-
-  for (let i = 23; i >= 0; i--) {
-    const h = new Date(now.getTime() - i * 3600000)
-    hours.push(h.getHours() + ':00')
-    // 模拟波动数据
-    const base = isLevel ? thresholdVal * 0.6 : thresholdVal * 0.5
-    const peak = isLevel ? currentVal : currentVal * 0.9
-    const noise = (Math.random() - 0.5) * base * 0.3
-    const val = i < 6 ? peak - (6 - i) * (peak - base) / 8 + noise : base + noise + (i > 16 ? (i - 16) * (peak - base) / 8 : 0)
-    values.push(Math.max(0, parseFloat(val.toFixed(2))))
-  }
-
-  const unit = isLevel ? 'm' : 'm³/h'
-  const thresholdLine = Array(24).fill(thresholdVal)
-
-  chartInstance.setOption({
-    grid: { top: 30, right: 20, bottom: 30, left: 50 },
-    tooltip: {
-      trigger: 'axis',
-      formatter: (params) => {
-        let s = params[0].axisValue + '<br/>'
-        params.forEach(p => {
-          s += `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${p.color};margin-right:4px;"></span>${p.seriesName}: ${p.value}${unit}<br/>`
-        })
-        return s
-      }
-    },
-    xAxis: {
-      type: 'category',
-      data: hours,
-      axisLabel: { fontSize: 10, color: '#999' },
-      axisLine: { lineStyle: { color: '#ddd' } }
-    },
-    yAxis: {
-      type: 'value',
-      axisLabel: { fontSize: 10, color: '#999', formatter: '{value}' + unit },
-      splitLine: { lineStyle: { color: '#f0f0f0' } }
-    },
-    series: [
-      {
-        name: isLevel ? '液位' : '流量',
-        type: 'line',
-        data: values,
-        smooth: true,
-        lineStyle: { color: '#409eff', width: 2 },
-        areaStyle: {
-          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: 'rgba(64,158,255,0.3)' },
-            { offset: 1, color: 'rgba(64,158,255,0.02)' }
-          ])
-        },
-        itemStyle: { color: '#409eff' },
-        symbol: 'circle',
-        symbolSize: 4
-      },
-      {
-        name: '阈值',
-        type: 'line',
-        data: thresholdLine,
-        lineStyle: { color: '#f56c6c', width: 1, type: 'dashed' },
-        itemStyle: { color: '#f56c6c' },
-        symbol: 'none'
-      }
-    ]
-  })
-}
-
-// ==================== 生命周期 ====================
-onMounted(async () => {
-  await loadData()
-  nextTick(() => {
-    initMap()
-  })
-})
-</script>
-
-<style scoped>
-.gis-page {
-  position: relative;
-  width: 100%;
-  height: 100vh;
-  overflow: hidden;
-}
-
-.map-container {
-  width: 100%;
-  height: 100%;
-}
-
-/* ==================== 左侧浮动面板 ==================== */
-.side-panel {
-  position: absolute;
-  top: 16px;
-  left: 16px;
-  width: 280px;
-  background: rgba(6, 30, 65, 0.85);
-  border-radius: 8px;
-  z-index: 100;
-  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-  padding: 0;
-  max-height: calc(100% - 32px);
-  overflow-y: auto;
-}
-
-.side-panel::-webkit-scrollbar {
-  width: 4px;
-}
-
-.side-panel::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.2);
-  border-radius: 2px;
-}
-
-.panel-header {
-  padding: 16px 18px 12px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.panel-title {
-  font-size: 16px;
-  font-weight: 600;
-  color: #fff;
-  letter-spacing: 1px;
-}
-
-.panel-section {
-  padding: 14px 18px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.panel-section:last-child {
-  border-bottom: none;
-}
-
-.section-label {
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-bottom: 10px;
-  text-transform: uppercase;
-  letter-spacing: 1px;
-}
-
-/* 等级列表 */
-.layer-list {
-  display: flex;
-  flex-direction: column;
-  gap: 6px;
-}
-
-.layer-item :deep(.el-checkbox) {
-  color: rgba(255, 255, 255, 0.85);
-  height: auto;
-}
-
-.layer-item :deep(.el-checkbox__label) {
-  color: rgba(255, 255, 255, 0.85);
-  font-size: 13px;
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-}
-
-.layer-item :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
-  background: #409eff;
-  border-color: #409eff;
-}
-
-.level-dot {
-  display: inline-block;
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-  margin-right: 2px;
-  vertical-align: middle;
-}
-
-/* 统计卡片 */
-.stats-row {
-  display: flex;
-  gap: 8px;
-}
-
-.stat-card {
-  flex: 1;
-  background: rgba(255, 255, 255, 0.08);
-  border-radius: 6px;
-  padding: 10px 4px;
-  text-align: center;
-}
-
-.stat-card.stat-severe {
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.stat-card.stat-important {
-  background: rgba(230, 162, 60, 0.15);
-}
-
-.stat-card.stat-normal {
-  background: rgba(64, 158, 255, 0.15);
-}
-
-.stat-value {
-  font-size: 20px;
-  font-weight: 700;
-  color: #fff;
-  line-height: 1.2;
-}
-
-.stat-severe .stat-value {
-  color: #f56c6c;
-}
-
-.stat-important .stat-value {
-  color: #e6a23c;
-}
-
-.stat-normal .stat-value {
-  color: #409eff;
-}
-
-.stat-text {
-  font-size: 11px;
-  color: rgba(255, 255, 255, 0.5);
-  margin-top: 4px;
-}
-
-/* 搜索 */
-.side-panel :deep(.el-input__wrapper) {
-  background: rgba(255, 255, 255, 0.08);
-  box-shadow: none;
-  border: 1px solid rgba(255, 255, 255, 0.12);
-}
-
-.side-panel :deep(.el-input__inner) {
-  color: #fff;
-  font-size: 13px;
-}
-
-.side-panel :deep(.el-input__inner::placeholder) {
-  color: rgba(255, 255, 255, 0.35);
-}
-
-.side-panel :deep(.el-input__prefix .el-icon) {
-  color: rgba(255, 255, 255, 0.4);
-}
-
-.search-results {
-  margin-top: 8px;
-  max-height: 200px;
-  overflow-y: auto;
-  border-radius: 4px;
-}
-
-.search-results::-webkit-scrollbar {
-  width: 3px;
-}
-
-.search-results::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.15);
-  border-radius: 2px;
-}
-
-.search-item {
-  display: flex;
-  align-items: center;
-  padding: 7px 10px;
-  cursor: pointer;
-  border-radius: 4px;
-  transition: background 0.15s;
-}
-
-.search-item:hover {
-  background: rgba(255, 255, 255, 0.1);
-}
-
-.search-name {
-  flex: 1;
-  font-size: 12px;
-  color: rgba(255, 255, 255, 0.85);
-  margin-left: 6px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.search-level {
-  font-size: 11px;
-  padding: 1px 6px;
-  border-radius: 3px;
-}
-
-.search-level.level-severe {
-  color: #f56c6c;
-  background: rgba(245, 108, 108, 0.15);
-}
-
-.search-level.level-important {
-  color: #e6a23c;
-  background: rgba(230, 162, 60, 0.15);
-}
-
-.search-level.level-normal {
-  color: #409eff;
-  background: rgba(64, 158, 255, 0.15);
-}
-
-/* ==================== 详情弹窗样式 ==================== */
-.detail-dialog :deep(.el-dialog) {
-  border-radius: 10px;
-  overflow: hidden;
-}
-
-.detail-dialog :deep(.el-dialog__header) {
-  background: #1a1a2e;
-  padding: 14px 20px;
-  margin: 0;
-}
-
-.detail-dialog :deep(.el-dialog__title) {
-  color: #fff;
-  font-size: 15px;
-}
-
-.detail-dialog :deep(.el-dialog__headerbtn .el-dialog__close) {
-  color: rgba(255, 255, 255, 0.6);
-}
-
-.detail-dialog :deep(.el-dialog__body) {
-  padding: 0;
-  max-height: 70vh;
-  overflow-y: auto;
-}
-
-.detail-content {
-  padding: 20px;
-}
-
-.detail-section {
-  margin-bottom: 20px;
-}
-
-.detail-section:last-child {
-  margin-bottom: 0;
-}
-
-.section-title {
-  font-size: 14px;
-  font-weight: 600;
-  color: #303133;
-  margin-bottom: 12px;
-  padding-left: 10px;
-  border-left: 3px solid #409eff;
-}
-
-.detail-header {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-bottom: 12px;
-}
-
-.detail-name {
-  font-size: 15px;
-  font-weight: 600;
-  color: #303133;
-}
-
-.detail-info {
-  display: flex;
-  flex-direction: column;
-}
-
-.info-row {
-  display: flex;
-  align-items: center;
-  padding: 8px 0;
-  border-bottom: 1px solid #f5f5f5;
-}
-
-.info-row:last-child {
-  border-bottom: none;
-}
-
-.info-label {
-  width: 80px;
-  flex-shrink: 0;
-  font-size: 13px;
-  color: #909399;
-}
-
-.info-value {
-  font-size: 13px;
-  color: #303133;
-}
-
-.info-value.highlight {
-  color: #f56c6c;
-  font-weight: 600;
-}
-
-/* 监测曲线 */
-.monitor-chart {
-  width: 100%;
-  height: 240px;
-}
-
-/* 周边环境 */
-.env-desc {
-  font-size: 13px;
-  color: #606266;
-  line-height: 1.8;
-  background: #f8f9fa;
-  padding: 12px 16px;
-  border-radius: 6px;
-}
-
-/* 历史记录时间线 */
-.timeline-content {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-}
-
-.timeline-name {
-  font-size: 13px;
-  color: #303133;
-  font-weight: 500;
-}
-
-.timeline-type {
-  font-size: 12px;
-  color: #909399;
-}
-
-.timeline-value {
-  font-size: 12px;
-  color: #f56c6c;
-  font-weight: 500;
-}
-</style>

+ 1 - 1
src/views/subSystem/drainage/jcsj/bzyxqk.vue

@@ -245,7 +245,7 @@ async function loadPumpAlarm(stationCode) {
     alarmStats.unhandled = alarmStats.total - alarmStats.handled
     alarmList.value = records.slice(0, 20).map(r => ({
       station: r.deviceName || r.deviceCode || stationCode,
-      time: r.alarmTime || r.createTime || '-',
+      time: r.createTime || '-',
       type: r.alarmType || r.alarmContent || '-',
       handled: r.status === 1 || r.status === '1'
     }))

+ 1 - 1
src/views/subSystem/drainage/jcsj/gwyxqk.vue

@@ -498,7 +498,7 @@ function loadAlarmData(alarmRecords, equipmentList) {
     const isHandled = a.handleStatus === 1 || a.handleStatus === '已处理' || a.status === 1
     return {
       point: codeNameMap[a.deviceCode] || a.deviceCode || '-',
-      time: a.alarmTime || a.createTime || '-',
+      time: a.createTime || '-',
       type: a.warningType || a.alarmType || '-',
       handleStatus: isHandled ? '已处理' : '未处理',
       handleClass: isHandled ? 'normal' : 'alarm'

+ 1 - 1
src/views/subSystem/drainage/jcsj/sbyx.vue

@@ -634,7 +634,7 @@ function handleRepairSubmit() {
       return
     }
     const orderLevel = urgencyToOrderLevel[repairForm.value.urgency]
-    // 批量派单时为每个设备分别创建工单(单个失败不影响其他设备,后端校验:同一设备仅允许一个进行中工单)
+    // 批量派单时为每个设备分别创建工单,单个失败不影响其他设备;普通报修不限制同设备工单数量。
     const failList = []
     for (const row of rows) {
       try {

+ 2 - 2
src/views/subSystem/gas/gasjcyj/gxylyctx.vue

@@ -219,7 +219,7 @@ async function loadEquipment() {
       id: e.latestAlarmId || null,
       pointCode: e.equipmentCode, pointName: e.equipmentName, address: e.location,
       pressure: e.latestPressure, threshold: e.latestThreshold || 250,
-      alertTime: e.latestAlarmTime,
+      alertTime: e.latestCreateTime,
       level: e.hasAlarm ? (e.latestAlarmLevel === 1 ? 'critical' : 'warning') : 'normal',
       status: e.hasAlarm ? 'pending' : 'normal',
       hasWorkOrder: Boolean(e.latestHasWorkOrder),
@@ -236,7 +236,7 @@ async function loadHistory() {
     const res = await getPressureAlarmHistory(historyPage.value, historyPageSize, d?.[0] || '', d?.[1] || '', currentDeviceCode.value || '')
     historyAlerts.value = (res.data?.records || []).map(a => ({
       id: a.id, pointCode: a.deviceCode, pointName: a.pointName, address: a.address,
-       pressure: a.pressure, threshold: a.threshold || 250, alertTime: a.alarmTime, status: a.alarmStatus === 0 ? 'pending' : 'resolved',
+       pressure: a.pressure, threshold: a.threshold || 250, alertTime: a.createTime, status: a.alarmStatus === 0 ? 'pending' : 'resolved',
        remark: a.remark, flow: 0,
        hasWorkOrder: Boolean(a.hasWorkOrder), workOrderId: a.workOrderId || null,
        workOrderStatus: a.workOrderStatus ?? null, workOrderStatusText: a.workOrderStatusText || ''

+ 2 - 2
src/views/subSystem/gas/gasjcyj/krqindbj.vue

@@ -230,7 +230,7 @@ async function loadEquipment() {
       stationName: e.equipmentName,
       concentration: e.latestConcentration,
       threshold: e.latestThreshold || 20,
-      alertTime: e.latestAlarmTime,
+      alertTime: e.latestCreateTime,
       level: e.hasAlarm ? (e.latestAlarmLevel === 1 ? 'critical' : 'warning') : 'normal',
       status: e.hasAlarm ? 'pending' : 'normal',
       hasWorkOrder: Boolean(e.latestHasWorkOrder),
@@ -265,7 +265,7 @@ function mapAlarm(a) {
     address: a.address || '',
     concentration: a.concentration || a.actualValue || 0,
     threshold: a.threshold || a.maxValue || 20,
-    level: lv, alertTime: a.alarmTime || '', status,
+    level: lv, alertTime: a.createTime || '', status,
        remark: a.remark || null,
        hasWorkOrder: Boolean(a.hasWorkOrder), workOrderId: a.workOrderId || null,
        workOrderStatus: a.workOrderStatus ?? null, workOrderStatusText: a.workOrderStatusText || ''

+ 1 - 1
src/views/subSystem/gas/gasjcyj/xldxkjkrqindbj.vue

@@ -236,7 +236,7 @@ function mapAlarm(alarm) {
     level,
     concentration,
     threshold,
-    alertTime: alarm.alarmTime || '',
+    alertTime: alarm.createTime || '',
     status: Number(alarm.alarmStatus) === 0 ? 'pending' : 'resolved',
     remark: alarm.remark || null,
     trendData: Array(24).fill(concentration),

+ 3 - 3
src/views/subSystem/lifeCompany/lAlertInformation/LCorrelation.vue

@@ -83,7 +83,7 @@
         <el-table-column label="告警编码" prop="warningCode" width="160" />
         <el-table-column label="阈值范围" prop="thresholdText" width="160" />
         <el-table-column label="实际值" prop="actualValue" width="120" />
-        <el-table-column label="报警时间" prop="alarmTime" width="180" />
+        <el-table-column label="报警时间" prop="createTime" width="180" />
         <el-table-column label="报警状态" width="100" align="center">
           <template #default="scope">
             <el-tag :type="scope.row.alarmStatus === 1 ? 'success' : 'warning'">
@@ -217,7 +217,7 @@
           <el-table-column label="告警编码" prop="warning_code" width="160" />
           <el-table-column label="阈值" prop="warning_value" width="120" />
           <el-table-column label="实际值" prop="actual_value" width="120" />
-          <el-table-column label="报警时间" prop="alarm_time" width="180" />
+          <el-table-column label="报警时间" prop="createTime" width="180" />
         </el-table>
       </template>
     </el-drawer>
@@ -230,7 +230,7 @@
         <el-descriptions-item label="告警编码">{{ currentAlarmDetail.warningCode || "-" }}</el-descriptions-item>
         <el-descriptions-item label="阈值范围">{{ currentAlarmDetail.thresholdText || "-" }}</el-descriptions-item>
         <el-descriptions-item label="实际值">{{ currentAlarmDetail.actualValue || "-" }}</el-descriptions-item>
-        <el-descriptions-item label="报警时间">{{ currentAlarmDetail.alarmTime || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="报警时间">{{ currentAlarmDetail.createTime || "-" }}</el-descriptions-item>
         <el-descriptions-item label="报警状态">{{ currentAlarmDetail.alarmStatus === 1 ? "已处理" : "未处理" }}</el-descriptions-item>
         <el-descriptions-item label="备注" :span="2">{{ currentAlarmDetail.remark || "-" }}</el-descriptions-item>
       </el-descriptions>

+ 2 - 2
src/views/subSystem/lifeCompany/lAlertInformation/LReportUpload.vue

@@ -514,9 +514,9 @@ async function handleWarningChange(value) {
 }
 
 function buildChartData() {
-  const alarms = [...alarmList.value].sort((a, b) => String(a.alarm_time || a.alarmTime || "").localeCompare(String(b.alarm_time || b.alarmTime || "")));
+  const alarms = [...alarmList.value].sort((a, b) => String(a.createTime || "").localeCompare(String(b.createTime || "")));
   return {
-    xData: alarms.map((item) => String(item.alarm_time || item.alarmTime || "").slice(5, 16) || "-"),
+    xData: alarms.map((item) => String(item.createTime || "").slice(5, 16) || "-"),
     actualData: alarms.map((item) => Number(item.actual_value || item.actualValue || 0)),
     thresholdData: alarms.map((item) => {
       const raw = item.warning_value || item.warningValue || "";

+ 1 - 1
src/views/subSystem/lifeCompany/lAlertInformation/LTobeHandled.vue

@@ -184,7 +184,7 @@
           <el-table-column label="报警类型" prop="warning_type" min-width="120" />
           <el-table-column label="设备编码" prop="device_code" min-width="160" />
           <el-table-column label="实际值" prop="actual_value" min-width="100" />
-          <el-table-column label="报警时间" prop="alarm_time" min-width="180" />
+          <el-table-column label="报警时间" prop="createTime" min-width="180" />
         </el-table>
 
         <el-divider content-position="left">处置记录</el-divider>

+ 2 - 2
src/views/subSystem/manholeCover/home/index.vue

@@ -228,7 +228,7 @@ async function loadAlarmData() {
     const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000)
     // 筛选15天内的告警
     const recentAlarms = records.filter(r => {
-      const t = new Date(r.alarmTime || r.createTime)
+      const t = new Date(r.createTime)
       return t >= fifteenDaysAgo
     })
     // 告警类型统计
@@ -240,7 +240,7 @@ async function loadAlarmData() {
     renderAlarmChart(typeMap)
     // 动态信息(最新10条)
     dynamicLogs.value = records.slice(0, 10).map(r => ({
-      time: r.alarmTime || r.createTime || '',
+      time: r.createTime || '',
       event: `${r.warningType || '告警'} - ${r.deviceName || r.deviceCode || ''}`,
       alarmStatus: r.alarmStatus ?? 0
     }))

File diff suppressed because it is too large
+ 24 - 867
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue


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

@@ -370,7 +370,7 @@ async function loadDashboardData() {
       level: Number(item.alarmLevel) <= 2 ? '高' : '中',
       title: item.deviceName || item.deviceCode || '监测设备',
       code: item.warningType || item.alarmCode || '-',
-      time: item.alarmTime || '--',
+      time: item.createTime || '--',
       desc: item.equipmentLocation || '报警详情待查看',
       tag: Number(item.alarmLevel) <= 2 ? '紧急' : '一般'
     }))

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

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

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

@@ -1 +0,0 @@
-<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="报警管理" subtitle="实时异常、历史报警与处置状态" mode="manage" /></template>

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

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

+ 124 - 0
tests/alarmGIS.visual.mjs

@@ -0,0 +1,124 @@
+// 独立视觉/交互测试:只在此测试服务器替换接口与地图,不改变生产页面的数据源。
+// PLAYWRIGHT_MODULE 指向已安装 playwright 的 index.mjs,BROWSER_EXECUTABLE 可指定浏览器。
+import { mkdtemp } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import assert from 'node:assert/strict'
+import { createServer } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import AutoImport from 'unplugin-auto-import/vite'
+
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(process.env.PLAYWRIGHT_MODULE).href : 'playwright')
+const output = await mkdtemp(path.join(tmpdir(), 'alarm-gis-visual-'))
+const samples = [
+  { id: 'a1', equipmentId: 'e1', deviceCode: 'PS-DEMO-001', deviceName: '城北路液位监测点', equipmentLocation: '城北路与人民路交叉口东侧', alarmLevel: 1, actualValue: null, longitude: '110.40', latitude: '28.46' },
+  { id: 'a2', equipmentId: 'e1', deviceCode: 'PS-DEMO-001', deviceName: '城北路液位监测点', equipmentLocation: '城北路与人民路交叉口东侧', alarmLevel: 2, actualValue: 0, longitude: '110.40', latitude: '28.46' },
+  { id: 'b1', equipmentId: 'e2', deviceCode: 'PS-DEMO-002', deviceName: '沿江路流量监测点', equipmentLocation: '沿江路排水管网检查井', alarmLevel: 2, actualValue: 2.36, longitude: '110.41', latitude: '28.47' },
+  { id: 'c1', equipmentId: 'e3', deviceCode: 'PS-DEMO-003', deviceName: '人民路井盖监测点', equipmentLocation: '人民路北侧步行道', alarmLevel: 3, actualValue: null, longitude: null, latitude: null },
+  { id: 'd1', equipmentId: 'e4', deviceCode: 'PS-DEMO-004', deviceName: '建设路液位监测点', equipmentLocation: '建设路与文化路交叉口', alarmLevel: 4, actualValue: 1.26, longitude: '110.42', latitude: '28.48' }
+].map((row, index) => ({ ...row, alarmNo: 'BJ20260924102000000' + index, createTime: '2026-09-24 10:20:00', alarmStatus: 0, monitorStatusName: '异常', auditStatus: 0, auditStatusName: '未审核', orderStatusName: '未转工单', warningCode: 'WARN_INSTANT_FLOW', warningType: '液位', warningUnit: 'm', maxValue: 1.8, alarmLevelName: ['一级报警', '二级报警', '三级报警', '四级报警'][row.alarmLevel - 1] }))
+const apiModule = `
+const samples = ${JSON.stringify(samples)};
+const response = data => Promise.resolve({ data });
+export const getAlarmOptions = () => response({ alarmLevels: ['一级报警','二级报警','三级报警','四级报警'].map((label,i)=>({value:i+1,label})), monitorStatuses:[{value:0,label:'异常'},{value:1,label:'已恢复'}] });
+export const getAlarmMap = params => { window.__gisRequests.push(params); return window.__gisDataError ? Promise.reject(new Error('测试接口异常,请重试')) : response(samples.filter(row=>(params.alarmLevel == null || row.alarmLevel===params.alarmLevel) && (params.alarmStatus == null || row.alarmStatus===params.alarmStatus) && (!params.keyword || row.deviceCode.includes(params.keyword) || row.deviceName.includes(params.keyword)))); };
+export const getAlarms = params => { window.__gisHistoryRequests.push(params); const rows=samples.filter(row=>row.deviceCode===params.deviceCode); return response({records:rows,total:rows.length}); };
+export const getAlarmAnalysis = id => { window.__gisAnalysisRequests.push(id); return response({ alarm:samples.find(row=>row.id===id), supported:true, samples:[], message:'测试数据:暂无区间采样' }); };
+export const getAlarm = id => response(samples.find(row=>row.id===id));
+export const getAlarmTimeline = () => response([]);
+`
+const scopeModule = `import { ref, computed } from 'vue'; const scope=ref({rootTypeId:2}); export function useAlarmScope(){return {scope,scopeKey:computed(()=>JSON.stringify(scope.value)),route:{query:{}},menu:()=>null,navigate:()=>{}}}`
+const mapModule = `import { onMounted, h } from 'vue'; export default {props:['points'],emits:['ready','error','point-click','cluster-click'],setup(props,{emit,expose}){expose({fitBounds(){},locatePoint(point){window.__gisLocated=point.deviceCode},zoomIn(){},zoomOut(){}});onMounted(()=>window.__gisMapFails?emit('error','测试环境:底图不可用,列表及详情不受影响。'):emit('ready'));return()=>h('div',{class:'gis-map-canvas','data-testid':'map-fixture',style:'height:100%;background:#f4f7fb'},window.__gisMapFails?[]:props.points.map(point=>h('button',{'data-testid':'map-marker',onClick:()=>emit('point-click',point)},point.name)));}}`
+const entry = `
+import {createApp} from 'vue'; import ElementPlus from 'element-plus'; import 'element-plus/dist/index.css'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
+import Page from '/src/views/subSystem/alarm/drainage/alarmGIS.vue'; import Pagination from '/src/components/Pagination/index.vue';
+window.__gisRequests=[];window.__gisHistoryRequests=[];window.__gisAnalysisRequests=[];window.__gisMapFails=true;
+const app=createApp(Page);app.use(ElementPlus,{locale:zhCn});app.component('pagination',Pagination);app.mount('#app');
+`
+const fixture = {
+  name: 'alarm-gis-visual-fixture', enforce: 'pre',
+  resolveId(id) {
+    if (id === 'virtual:gis-entry') return '\0gis-entry'
+    if (id === 'virtual:gis-api') return '\0gis-api'
+    if (id.endsWith('/useAlarmScope') || id.endsWith('/useAlarmScope.js')) return '\0gis-scope'
+    if (id === 'virtual:gis-map') return '\0gis-map'
+  },
+  load(id) { return ({ '\0gis-entry': entry, '\0gis-api': apiModule, '\0gis-scope': scopeModule, '\0gis-map': mapModule })[id] },
+  configureServer(server) {
+    server.middlewares.use((req, res, next) => {
+      if (req.url !== '/__alarm-gis-fixture') return next()
+      res.setHeader('Content-Type', 'text/html; charset=utf-8')
+      res.end('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><style>body{margin:0;font-family:"Microsoft YaHei",sans-serif;background:#f5f7fa}*{box-sizing:border-box}button{font-family:inherit}</style><div id="app"></div><script type="module" src="/@id/virtual:gis-entry"></script></html>')
+    })
+  }
+}
+const server = await createServer({
+  configFile: false, root: process.cwd(), plugins: [fixture, vue(), AutoImport({ imports: ['vue'], dts: false })],
+  cacheDir: path.join(output, 'vite-cache'),
+  optimizeDeps: { entries: [], include: ['vue', 'element-plus', '@element-plus/icons-vue', 'echarts'] },
+  resolve: { alias: [{find:'@/api/alarm',replacement:'virtual:gis-api'},{find:'@/components/GisMapCanvas.vue',replacement:'virtual:gis-map'},{find:'@',replacement:path.resolve('src')}] },
+  server: { host: '127.0.0.1', port: 0 }, logLevel: 'error'
+})
+let browser
+try {
+  await server.listen()
+  browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE || undefined })
+  const page = await browser.newPage({ viewport: { width: 1366, height: 1000 }, deviceScaleFactor: 1 })
+  const pageErrors = []
+  page.on('pageerror', error => pageErrors.push(error.message))
+  await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:') ? route.continue() : route.abort())
+  await page.goto('http://127.0.0.1:' + server.httpServer.address().port + '/__alarm-gis-fixture')
+  await page.getByRole('heading', {name:'当前报警 GIS 一张图'}).waitFor()
+  await page.locator('.gis-device-item').nth(3).waitFor()
+  assert.equal(await page.locator('.gis-level-metric').count(),4)
+  assert.deepEqual(await page.locator('.gis-level-metric > strong').allTextContents(),['1台','1台','1台','1台'])
+  assert.equal(await page.getByRole('button',{name:'放大地图'}).isDisabled(),true)
+  await page.screenshot({path:path.join(output,'desktop.png'),fullPage:true})
+  const mapBefore = await page.locator('.gis-map-stage').boundingBox()
+  await page.getByLabel('报警设备列表').getByRole('button',{name:'城北路液位监测点',exact:true}).click()
+  await page.locator('.alarm-gis-drawer').waitFor()
+  await page.getByRole('tab',{name:'监测曲线',exact:true}).waitFor()
+  await page.waitForFunction(()=>window.__gisAnalysisRequests.includes('a2'))
+  assert.ok(!(await page.evaluate(()=>window.__gisAnalysisRequests)).includes('a1'))
+  assert.equal((await page.locator('.gis-map-stage').boundingBox()).width,mapBefore.width)
+  await page.getByRole('tab',{name:'报警记录',exact:true}).click()
+  assert.equal(await page.locator('.gis-history-item').count(),2)
+  assert.equal(await page.locator('.gis-history-actions').getByRole('button',{name:'查看曲线'}).count(),1)
+  assert.equal(await page.locator('.pagination-container').isVisible(),true)
+  await page.screenshot({path:path.join(output,'drawer-history.png')})
+  await page.getByRole('tab',{name:'设备信息',exact:true}).click()
+  assert.equal(await page.locator('.gis-detail-grid').last().getByText('110.4',{exact:true}).count(),1)
+  await page.keyboard.press('Escape')
+  await page.locator('.alarm-gis-drawer').waitFor({state:'hidden'})
+  await page.getByRole('button',{name:'人民路井盖监测点',exact:true}).click()
+  assert.equal(await page.getByRole('tab',{name:'报警记录',exact:true}).getAttribute('aria-selected'),'true')
+  await page.getByRole('tab',{name:'监测曲线',exact:true}).click()
+  await page.getByText('暂无有实际值的报警指标',{exact:true}).waitFor()
+  await page.keyboard.press('Escape')
+  await page.locator('.alarm-gis-drawer').waitFor({state:'hidden'})
+  await page.evaluate(()=>window.__gisMapFails=false)
+  await page.getByRole('button',{name:'重试底图',exact:true}).click()
+  await page.getByText('地图已就绪',{exact:true}).waitFor()
+  assert.equal(await page.getByRole('button',{name:'放大地图'}).isDisabled(),false)
+  assert.equal(await page.getByTestId('map-marker').count(),3)
+  await page.locator('.gis-device-item').first().getByRole('button',{name:'定位',exact:true}).click()
+  assert.equal(await page.evaluate(()=>window.__gisLocated),'PS-DEMO-001')
+  await page.getByPlaceholder('搜索设备编码、名称或安装位置').fill('无匹配设备')
+  await page.getByRole('button',{name:'查询',exact:true}).click()
+  await page.getByText('暂无匹配的报警设备',{exact:true}).waitFor()
+  await page.getByRole('button',{name:'重置',exact:true}).click()
+  await page.locator('.gis-device-item').nth(3).waitFor()
+  await page.setViewportSize({width:390,height:844})
+  assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth <= window.innerWidth),true)
+  await page.screenshot({path:path.join(output,'mobile.png'),fullPage:true})
+  await page.getByLabel('报警设备列表').getByRole('button',{name:'城北路液位监测点',exact:true}).click()
+  await page.getByRole('tab',{name:'报警记录',exact:true}).click()
+  await page.screenshot({path:path.join(output,'mobile-drawer.png')})
+  assert.equal(await page.locator('.alarm-gis-drawer').evaluate(el=>el.scrollWidth <= el.clientWidth),true)
+  assert.deepEqual(pageErrors,[])
+  console.log('GIS visual/interaction checks passed. Screenshots: ' + output)
+} finally {
+  await browser?.close()
+  await server.close()
+}

+ 55 - 0
tests/alarmHistory.test.mjs

@@ -0,0 +1,55 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { groupAlarmHistory, historyDayLabel, historyTime } from '../src/views/subSystem/alarm/alarmHistoryModel.js'
+
+test('history day labels show only today or yesterday, retaining dates for older records', () => {
+  const now = new Date(2026, 8, 24, 0, 10)
+  assert.equal(historyDayLabel('2026-09-24', now), '今天')
+  assert.equal(historyDayLabel('2026-09-23', now), '昨天')
+  assert.equal(historyDayLabel('2026-09-22', now), '9月22日')
+  assert.equal(historyDayLabel('2025-09-24', now), '2025年9月24日')
+  assert.equal(historyDayLabel('2026-09-25', now), '9月25日')
+})
+
+test('yesterday labels handle calendar month, year and leap day boundaries', () => {
+  assert.equal(historyDayLabel('2026-08-31', new Date(2026, 8, 1, 0, 1)), '昨天')
+  assert.equal(historyDayLabel('2025-12-31', new Date(2026, 0, 1, 0, 1)), '昨天')
+  assert.equal(historyDayLabel('2024-02-29', new Date(2024, 2, 1, 0, 1)), '昨天')
+})
+
+test('missing or invalid dates have a clear fallback', () => {
+  const now = new Date(2026, 8, 24)
+  for (const value of [null, undefined, '', '时间未记录', 'invalid', '2026-02-30', '2026-13-01']) {
+    assert.equal(historyDayLabel(value, now), '时间未记录')
+  }
+})
+
+test('history is grouped by date descending then time and log id descending without mutating input', () => {
+  const logs = [
+    { logId: '8', operTime: '2026-09-23 18:00:00' },
+    { logId: '9007199254740992', operTime: '2026-09-24 09:00:00' },
+    { logId: '9007199254740993', operTime: '2026-09-24 09:00:00' },
+    { logId: '10', operTime: '2026-09-24T10:00:00' },
+    { logId: '11', operTime: null }
+  ]
+  const groups = groupAlarmHistory(logs)
+  assert.deepEqual(groups.map(g => g.day), ['2026-09-24', '2026-09-23', '时间未记录'])
+  assert.deepEqual(groups[0].items.map(item => item.logId), ['10', '9007199254740993', '9007199254740992'])
+  assert.equal(logs[0].logId, '8')
+  assert.equal(historyTime('2026-09-24T10:00:01'), '10:00:01')
+  assert.equal(historyTime(null), '—')
+})
+
+test('audit and operations use the same dispose-style history with safe description text and image previews', async () => {
+  for (const name of ['index.vue', 'AlarmDetail.vue']) {
+    const source = await readFile('src/views/subSystem/alarm/' + name, 'utf8')
+    assert.ok(source.includes('<AlarmHistory'))
+    assert.ok(!source.includes('<el-timeline'))
+  }
+  const source = await readFile('src/views/subSystem/alarm/AlarmHistory.vue', 'utf8')
+  assert.ok(source.includes('historyDayLabel(group.day)'))
+  assert.ok(source.includes(':title="group.day"'))
+  for (const text of ['按时间倒序分组', 'history-day', 'history-time', 'item.operUserName', '{{ item.operDesc }}', 'AlarmAttachments']) assert.ok(source.includes(text))
+  assert.ok(!source.includes('v-html'))
+})

+ 139 - 0
tests/alarmOperations.test.mjs

@@ -0,0 +1,139 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile, access } from 'node:fs/promises'
+import { alarmCurveChoices, auditStatusTone, monitorStatusTone, groupAlarmDevices, findAlarmMenu, seriesPoints, rangeParams } from '../src/views/subSystem/alarm/alarmOperationsModel.js'
+
+test('detail status colors distinguish monitoring and audit states without treating missing values as zero',()=>{
+  assert.equal(monitorStatusTone(0),'danger')
+  assert.equal(monitorStatusTone('1'),'success')
+  assert.equal(auditStatusTone(0),'warning')
+  assert.equal(auditStatusTone('1'),'primary')
+  assert.equal(auditStatusTone(2),'info')
+  for(const status of [null,undefined,'',9]){
+    assert.equal(monitorStatusTone(status),'info')
+    assert.equal(auditStatusTone(status),'info')
+  }
+})
+
+test('detail uses lightweight inline state labels and small tags, without old gray status cards',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
+  for(const label of ['监测状态','审核结果','处置状态']) assert.ok(source.includes('<dt>'+label+'</dt>'))
+  assert.equal((source.match(/size="small" effect="light"/g)||[]).length,3)
+  assert.ok(source.includes('font-weight: 400'))
+  assert.ok(source.includes('orderStatusType(alarm.orderStatus)'))
+  assert.ok(!source.includes('<label>'))
+  const styles=await readFile('src/views/subSystem/alarm/alarmOperations.scss','utf8')
+  assert.ok(!styles.includes('.state-grid'))
+})
+
+test('curve choices exclude absent values, retain zero and deduplicate events without changing source rows',()=>{
+  const rows=[{id:'null',actualValue:null},{id:'missing'},{id:'empty',actualValue:''},
+    {id:'zero',actualValue:0},{id:'negative',actualValue:-1},{id:'text-zero',actualValue:'0'},
+    {id:'zero',actualValue:0,warningType:'流量'}]
+  const choices=alarmCurveChoices(rows)
+  assert.deepEqual(choices.map(row=>row.id),['zero','negative','text-zero'])
+  assert.equal(choices[0].warningType,'流量')
+  assert.equal(rows.length,7)
+  assert.deepEqual(alarmCurveChoices([]),[])
+})
+
+test('reminders request only unhandled alarms and no longer link to ledger',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/remind.vue','utf8')
+  assert.match(source,/getAlarmReminders\(\{ \.\.\.scope.value, alarmStatus: 0/)
+  assert.ok(!source.includes('报警清单'))
+  assert.ok(!source.includes("navigate('alarmList'"))
+  assert.ok(source.includes('事件详情'))
+})
+
+test('ledger labels are updated and the audit filter is removed without removing its table column',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/list.vue','utf8')
+  assert.ok(source.includes('placeholder="请输入设备编码/名称"'))
+  assert.ok(source.includes('<el-form-item label="报警时间">'))
+  assert.ok(!source.includes('<el-form-item label="审核结果">'))
+  assert.ok(!source.includes('filters.auditStatus'))
+  assert.ok(!source.includes('auditStatus:'))
+  assert.ok(source.includes('prop="auditStatusName" label="审核结果"'))
+})
+
+test('GIS filters curve options and entries without filtering map or alarm history',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
+  assert.ok(source.includes('alarmCurveChoices([...(selected.value?.alarms || []), ...history.value])'))
+  assert.ok(source.includes('device.alarms.find(hasActualValue)'))
+  assert.ok(source.includes('v-if="hasActualValue(row)"'))
+  assert.ok(source.includes('watch(curveChoices'))
+  assert.ok(source.includes("tab === 'curve' && currentCurve"))
+  assert.ok(source.includes('暂无有实际值的报警指标'))
+  assert.ok(!source.includes('actualValueOnly: true'))
+})
+
+test('analysis filters on server before pagination and rejects empty-value deep links',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/drainage/analysis.vue','utf8')
+  assert.ok(source.includes('actualValueOnly: true'))
+  assert.ok(source.includes('alarmCurveChoices(result.data.records || [])'))
+  assert.ok(source.includes('if (!hasActualValue(result.data))'))
+  assert.ok(source.indexOf('if (!hasActualValue(result.data))') < source.indexOf('choices.value.unshift(result.data)'))
+  const detail=await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
+  assert.ok(detail.includes("alarm && hasActualValue(alarm) && menu('drainageAlarmAnalysis')"))
+})
+
+test('GIS groups events by device and uses highest current grade, ignoring recovered severity',()=>{
+  const [device] = groupAlarmDevices([
+    {id:'a',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-01',alarmLevel:1,alarmStatus:1,longitude:'110',latitude:'28'},
+    {id:'b',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-02',alarmLevel:3,alarmStatus:0,longitude:'110',latitude:'28'},
+    {id:'c',equipmentId:'E1',deviceCode:'D1',createTime:'2026-01-03',alarmLevel:2,alarmStatus:0,longitude:'110',latitude:'28'}
+  ])
+  assert.equal(device.alarms.length,3); assert.equal(device.alarmLevel,2); assert.equal(device.alarms[0].id,'c'); assert.equal(device.hasCoordinates,true)
+})
+test('GIS keeps missing or invalid coordinates in device list but does not map them',()=>{
+  for(const [longitude,latitude] of [[null,28],['',28],[undefined,28],['nan',28],[181,28],[110,91],[0,0]]){
+    const [device]=groupAlarmDevices([{deviceCode:'D1',longitude,latitude}]); assert.equal(device.hasCoordinates,false)
+  }
+  assert.equal(groupAlarmDevices([{deviceCode:'D1',longitude:0,latitude:28}])[0].hasCoordinates,true)
+})
+test('same leaf route selects only the matching authorized subsystem',()=>{
+  const routes=[{path:'/water',children:[{path:'alarmList',query:'{"rootTypeId":1}'}]},{path:'/drainage',children:[{path:'alarmList',query:'{"rootTypeId":2}'}]}]
+  assert.equal(findAlarmMenu(routes,'alarmList',2).path,'/drainage/alarmList')
+  assert.equal(findAlarmMenu(routes,'alarmList',3),null)
+  assert.equal(findAlarmMenu([{path:'alarmList',query:'invalid'}],'alarmList',2),null)
+})
+test('menu navigation reads the real permission store sidebar property',async()=>{
+  const scope=await readFile('src/views/subSystem/alarm/useAlarmScope.js','utf8')
+  const store=await readFile('src/store/modules/permission.js','utf8')
+  const property=scope.match(/findAlarmMenu\(permissions\.(\w+)/)[1]
+  assert.match(store,new RegExp(property+': \\[\\]'))
+})
+test('raw series preserve zero and missing values and break large sampling gaps',()=>{
+  const points=seriesPoints([{time:'2026-01-01 00:00:00',value:0},{time:'2026-01-01 00:05:00',value:2},{time:'2026-01-01 00:10:00',value:null},{time:'2026-01-01 01:00:00',value:4}],true)
+  assert.deepEqual(points.map(p=>p[1]),[0,2,null,null,4])
+  assert.equal(seriesPoints([{time:'2026-01-01 00:00:00',value:0}],true)[0][1],0)
+})
+test('new GIS entry has the correct name, three tabs and no surrounding environment tab',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
+  assert.ok(source.includes('DrainageAlarmGIS')); assert.ok(!source.includes('周边环境'))
+  for(const label of ['设备信息','报警记录','监测曲线'])assert.ok(source.includes('label="'+label+'"'))
+  await assert.rejects(access('src/views/subSystem/alarm/drainage/alarmMap.vue'))
+})
+test('GIS prototype layout keeps the map width stable with a separate responsive detail drawer',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.vue','utf8')
+  const styles=await readFile('src/views/subSystem/alarm/drainage/alarmGIS.scss','utf8')
+  for(const text of ['gis-page-heading','gis-level-metrics','gis-device-pane','gis-map-status','gis-map-legend','gis-workspace-footer'])assert.ok(source.includes(text))
+  assert.ok(source.includes('<el-drawer v-model="detailVisible"'))
+  assert.ok(source.includes('append-to-body destroy-on-close'))
+  assert.ok(!source.includes('selection-panel'))
+  assert.ok(source.includes('device.alarmLevel === Number(item.value)'))
+  assert.ok(source.includes('getAlarmMap({ ...scope.value'))
+  assert.ok(source.includes('pageNum: historyPage.value, pageSize: historySize.value'))
+  assert.ok(source.includes('aria-label="查看全部点位"'))
+  assert.ok(styles.includes('grid-template-columns: 300px minmax(0, 1fr)'))
+  assert.ok(styles.includes('@media (max-width: 680px)'))
+})
+test('ledger uses first-column business number, server pagination and complete-day dates',async()=>{
+  const source=await readFile('src/views/subSystem/alarm/list.vue','utf8')
+  assert.match(source,/<el-table-column prop="alarmNo" label="报警单号"/)
+  assert.ok(source.indexOf('prop="alarmNo"')<source.indexOf('prop="deviceCode"'))
+  assert.ok(source.includes('pageNum: pageNum.value, pageSize: pageSize.value'))
+  assert.deepEqual(rangeParams(['2026-01-01','2026-01-02']),{startTime:'2026-01-01 00:00:00',endTime:'2026-01-02 23:59:59.999999'})
+})
+test('replaced subsystem entries are removed',async()=>{
+  for(const name of ['drainage/jcbj/bjtx.vue','drainage/jcbj/bjyp.vue','drainage/jcbj/bjqd.vue','drainage/jcbj/dqbj.vue','waterSupply/monitorAlarm/index.vue','waterSupply/wjcbj/bjgl.vue']) await assert.rejects(access('src/views/subSystem/'+name))
+})

+ 23 - 5
tests/waterAlarmAuditWorkbench.test.mjs

@@ -5,16 +5,30 @@ import { ensureUniqueSubsystemRouteNames } from '../src/router/routeNameUtils.js
 
 const page = () => readFile('src/views/subSystem/alarm/index.vue', 'utf8')
 
-test('shared audit page consumes enum options and requires an opinion for either decision', async () => {
+test('shared audit page requires a decision but leaves the opinion optional with a length limit', async () => {
   const source = await page()
   assert.ok(source.includes("item.key !== 'UNREVIEWED'"))
   assert.ok(source.includes('v-for="item in reviewChoices"'))
-  assert.ok(source.includes('请填写审核意见'))
+  assert.ok(source.includes("auditStatus: [{ required: true, message: '请选择审核结果'"))
+  assert.ok(source.includes("opinion: [{ max: 500, message: '审核意见最多500字', trigger: 'blur' }]"))
+  assert.ok(source.includes('placeholder="选填,可填写核实情况、判定依据或误报原因"'))
+  assert.ok(!source.includes('请填写审核意见'))
   assert.ok(source.includes('auditStatus: undefined'))
   assert.ok(source.includes('await formRef.value.validate()'))
   assert.ok(!source.includes('auditAction'))
 })
 
+test('closing the review skips discard confirmation and still protects active requests', async () => {
+  const source = await page()
+  assert.ok(source.includes(':before-close="closeReview"'))
+  assert.ok(source.includes('@click="closeReview()"'))
+  assert.ok(source.includes('reviewVisible.value = false'))
+  assert.ok(source.includes("if (submitting.value || uploading.value) { ElMessage.warning('请等待上传或提交完成'); return false }"))
+  assert.ok(!source.includes('ElMessageBox'))
+  assert.ok(!source.includes('放弃本次审核'))
+  assert.ok(!source.includes('const dirty'))
+})
+
 test('queue and statistics use shared REST endpoints with subsystem scope and server paging', async () => {
   const source = await page()
   const api = await readFile('src/api/alarmReview.js', 'utf8')
@@ -44,7 +58,9 @@ test('search card uses symmetric vertical padding without trailing form margins'
 test('timeline renders thumbnails with teleported full-size image preview', async () => {
   const source = await page()
   const attachments = await readFile('src/views/subSystem/alarm/AlarmAttachments.vue', 'utf8')
-  assert.ok(source.includes('<AlarmAttachments v-if="item.attachUrl"'))
+  const history = await readFile('src/views/subSystem/alarm/AlarmHistory.vue', 'utf8')
+  assert.ok(source.includes('<AlarmHistory :records="logs"'))
+  assert.ok(history.includes('<AlarmAttachments v-if="item.attachUrl"'))
   assert.ok(attachments.includes(':preview-src-list="previewUrls"'))
   assert.ok(attachments.includes(':initial-index="index"'))
   assert.ok(attachments.includes('preview-teleported'))
@@ -56,9 +72,11 @@ test('timeline renders thumbnails with teleported full-size image preview', asyn
   assert.ok(!source.includes('|pdf'))
 })
 
-test('legacy ledger navigates to alarmAudit and no longer implements auditing', async () => {
+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')
-  assert.ok(ledger.includes("/monitorAlarm/alarmAudit"))
+  const detail = await readFile('src/views/subSystem/alarm/AlarmDetail.vue','utf8')
+  assert.ok(detail.includes("navigate('alarmAudit'"))
+  assert.ok(!ledger.includes("mode === 'manage'"))
   assert.ok(!ledger.includes('submitAudit'))
   assert.ok(!ledger.includes('auditOrderStatCards'))
   const api = await readFile('src/api/pipeNetwork/waterSupply.js','utf8')

Some files were not shown because too many files changed in this diff