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

fix(water): use detail drawers for monitoring pages

Kazerin 1 неделя назад
Родитель
Сommit
dcf468bcbf
64 измененных файлов с 5338 добавлено и 3723 удалено
  1. 3 0
      .gitignore
  2. 69 0
      CONTEXT.md
  3. 174 0
      docs/design/2026-08-22-water-supply-work-order-ui.md
  4. 101 0
      docs/superpowers/plans/2026-08-14-project-memory.md
  5. 84 0
      docs/superpowers/plans/2026-08-17-water-supply-ui-completion.md
  6. 44 0
      docs/superpowers/specs/2026-08-14-project-memory-design.md
  7. 72 0
      findings.md
  8. 43 0
      memory/README.md
  9. 55 0
      memory/index.md
  10. 69 0
      memory/long-term.md
  11. 63 0
      memory/sessions/2026-08-14.md
  12. 152 0
      memory/sessions/2026-08-17.md
  13. 29 0
      memory/sessions/2026-08-21.md
  14. 37 0
      memory/sessions/2026-08-22.md
  15. 8 0
      memory/sessions/2026-08-27.md
  16. 65 0
      progress.md
  17. 2 37
      src/layout/components/Sidebar/index.vue
  18. 3 2
      src/layout/components/TagsView/index.vue
  19. 14 13
      src/router/index.js
  20. 34 0
      src/router/modules/waterSupplyRouter.js
  21. 31 0
      src/router/routeNameUtils.js
  22. 11 0
      src/router/routeUtils.js
  23. 32 0
      src/router/sidebarRouteUtils.js
  24. 2 1
      src/store/modules/permission.js
  25. 64 0
      src/utils/waterSupplyModel.js
  26. 53 0
      src/utils/waterSupplyUiModel.js
  27. 2 2
      src/views/subSystem/basic/pipeInfo/index.vue
  28. 1 1
      src/views/subSystem/basic/waterUserInfo/index.vue
  29. 7 0
      src/views/subSystem/waterSupply/components/WaterAlarmPage.vue
  30. 78 0
      src/views/subSystem/waterSupply/components/WaterDevicePage.vue
  31. 154 0
      src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue
  32. 80 0
      src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue
  33. 1376 0
      src/views/subSystem/waterSupply/components/WaterSupplyHome.vue
  34. 28 0
      src/views/subSystem/waterSupply/components/waterSupplyPageTheme.scss
  35. 1 0
      src/views/subSystem/waterSupply/device/abnormal.vue
  36. 1 0
      src/views/subSystem/waterSupply/device/gis.vue
  37. 1 0
      src/views/subSystem/waterSupply/device/ledger.vue
  38. 1 0
      src/views/subSystem/waterSupply/device/maintain.vue
  39. 1 0
      src/views/subSystem/waterSupply/device/record.vue
  40. 1007 0
      src/views/subSystem/waterSupply/facility/gis.vue
  41. 1 0
      src/views/subSystem/waterSupply/facility/statistics.vue
  42. 2 14
      src/views/subSystem/waterSupply/index.vue
  43. 1 0
      src/views/subSystem/waterSupply/monitorAlarm/audit.vue
  44. 1 0
      src/views/subSystem/waterSupply/monitorAlarm/index.vue
  45. 1 0
      src/views/subSystem/waterSupply/monitorAlarm/map.vue
  46. 1 0
      src/views/subSystem/waterSupply/monitorAlarm/threshold.vue
  47. 1009 0
      src/views/subSystem/waterSupply/monitorAlarm/workOrder.vue
  48. 1 0
      src/views/subSystem/waterSupply/monitoring/flow.vue
  49. 1 0
      src/views/subSystem/waterSupply/monitoring/leakage.vue
  50. 1 0
      src/views/subSystem/waterSupply/monitoring/pressure.vue
  51. 2 14
      src/views/subSystem/waterSupply/whome/wHome.vue
  52. 1 442
      src/views/subSystem/waterSupply/wjcbj/bjgl.vue
  53. 1 547
      src/views/subSystem/waterSupply/wjcbj/bjsh.vue
  54. 1 545
      src/views/subSystem/waterSupply/wjcbj/jcbjyzt.vue
  55. 1 486
      src/views/subSystem/waterSupply/wjcbj/yzgl.vue
  56. 3 539
      src/views/subSystem/waterSupply/wyxjc/lljc.vue
  57. 3 540
      src/views/subSystem/waterSupply/wyxjc/lousjc.vue
  58. 3 540
      src/views/subSystem/waterSupply/wyxjc/lsjc.vue
  59. 86 0
      task_plan.md
  60. 22 0
      tests/routeNameUtils.test.mjs
  61. 24 0
      tests/routerRouteUtils.test.mjs
  62. 42 0
      tests/sidebarRouteUtils.test.mjs
  63. 33 0
      tests/waterSupplyModel.test.mjs
  64. 45 0
      tests/waterSupplyUiModel.test.mjs

+ 3 - 0
.gitignore

@@ -21,3 +21,6 @@ selenium-debug.log
 
 package-lock.json
 yarn.lock
+
+# Local document parsing cache
+.firecrawl/

+ 69 - 0
CONTEXT.md

@@ -0,0 +1,69 @@
+# 城市生命线业务语境
+
+本语境统一城市生命线平台各业务页面使用的业务语言,当前重点覆盖供水监测报警与工单处置。
+
+## 供水监测与工单
+
+**设备异常**:
+供水监测设备处于需要人工关注或处置的异常状态;设备自动报警是设备异常的一种来源。
+_Avoid_: 维修任务、异常工单
+
+**报警转工单**:
+用户根据设备报警创建尚未指定接单责任人的故障维修工单,将报警纳入人工处置流程。报警记录与工单分别表达监测事实和处置过程。
+_Avoid_: 报警处理、报警派单
+
+**报警审核**:
+对设备报警是否属实及其审核意见进行确认的业务活动;它与同一页面承载的工单处置仍是不同活动。
+_Avoid_: 工单审核、工单处置
+
+**报警等级**:
+根据报警规则和实际监测数据计算出的严重程度,共分一级、二级、三级和四级,其中一级最严重。
+_Avoid_: 工单优先级
+
+**报警记录状态**:
+表示单条报警记录是否已经人工处置的辅助状态;它不决定设备当前是否报警,也不代表关联工单的进度。
+_Avoid_: 设备报警状态、工单状态
+
+**设备报警状态**:
+表示监测设备当前是否处于报警中的设备级状态,由运行系统根据设备仍然有效的报警原因维护。
+_Avoid_: 报警记录状态、工单状态
+
+**工单处置状态**:
+表示统一工单当前所处的待派单、待接单、已接单、处理中、待验收、已结案或已驳回阶段。
+_Avoid_: 设备报警状态、报警记录状态
+
+**工单派发**:
+为待派单工单指定唯一接单责任人的业务活动。
+_Avoid_: 报警转工单、工单接单
+
+**接单责任人**:
+一次工单派发中唯一负责接收并推进该工单的系统用户。
+_Avoid_: 接单人列表、维修人员姓名
+
+**工单处置**:
+接单责任人对工单执行接单、处理和提交验收,管理人员随后完成验收或驳回的全过程。
+_Avoid_: 报警审核、报警处理
+
+**故障维修工单**:
+处理设备故障、设备异常或设备报警的工单类型。维修任务和报警转入的处置均归入此类型。
+_Avoid_: 维修任务、报警工单
+
+**同类报警工单**:
+同一设备针对同一报警编码建立的故障维修工单;报警编码缺失时才按报警分类判定,同一时间只能存在一个尚未结束的同类报警工单。
+_Avoid_: 同一报警记录、同设备全部工单
+
+**随同修复**:
+一项维修工作同时消除了同一设备其他工单所对应异常的处置结果;其他工单仍须保留记录并完成各自的验收结案。
+_Avoid_: 自动结案、重复维修
+
+**日常巡检工单**:
+用于执行计划内日常检查的工单类型。
+_Avoid_: 巡检任务
+
+**设备保养工单**:
+用于执行计划内设备维护保养的工单类型。
+_Avoid_: 保养任务、维修任务
+
+**统一工单**:
+承载故障维修、日常巡检和设备保养全过程的业务记录。
+_Avoid_: 维修任务、报警工单

+ 174 - 0
docs/design/2026-08-22-water-supply-work-order-ui.md

@@ -0,0 +1,174 @@
+# 供水报警与工单前端适配设计
+
+状态:已确认
+
+## 目标
+
+统一供水设备异常、报警转工单、责任人派发和工单处置体验,并对相关页面进行蓝白运行指挥台风格的视觉重构。
+
+本次覆盖:
+
+- 设备维修派单
+- 设备异常管理
+- 报警管理
+- 报警审核
+- 供水工单总览(现有统一工单页)
+- 供水系统完整菜单树的图标
+
+不在本次范围:
+
+- 在供水系统创建或首次派发日常巡检、设备保养工单
+- 其他供水业务页面的布局重构
+- 深色主题
+- 新增未经确认的按钮权限编码
+
+## 业务规则
+
+### 报警与设备状态
+
+- 设备当前是否报警以设备状态表的设备报警状态为准。
+- 单条报警记录的未处理或已处理状态只作为历史辅助信息。
+- 报警严重程度由运行监测系统计算,前端只展示一级至四级结果,不重新计算。
+- 报警等级颜色固定为:一级红、二级橙、三级蓝、四级绿。标签必须同时显示等级文字,不能只依赖颜色。
+
+### 报警转工单与去重
+
+- 报警接口先创建待派单的故障维修工单,责任人由后续统一工单派发完成。
+- 同一报警记录不得重复生成工单。
+- 同一设备、同一报警编码同时只能存在一个未结束的故障维修工单;报警编码缺失时按报警分类判定。
+- 同一设备的不同报警编码可以分别生成故障维修工单。
+- 用户在报警管理点击“派单”时,前端连续完成“确保待派单工单存在”和“选择责任人并派发”两个步骤。第二步失败时保留待派单工单,以便重试。
+
+### 恢复、误报与结案
+
+- 工单结案只能触发清警复核,不能直接修改设备报警状态。
+- 运行系统确认设备不存在有效报警原因后,自动将设备恢复为正常。
+- 一次维修同时消除其他异常时,其他未结案工单显示“设备已恢复”,责任人可选择“随同修复”并继续完成各自验收,系统不得静默结案。
+- 报警审核为误报或解除时,尚未开始处理的关联工单自动驳回或取消;处理中的工单要求填写原因并由管理人员确认终止。该操作只触发清警复核。
+
+### 人员与操作权限
+
+- 一次派单只能指定一个接单责任人。
+- 可选人员限状态正常且未删除的系统用户。
+- 手机号缺失时显示“未登记手机号”,但不阻断派单。
+- 前端不允许手工输入权威姓名或电话,业务请求只使用选中的用户身份。
+- 当前接单责任人可以接单、开始处理、提交验收和申请延期。
+- 派单人或有权管理人员可以验收、驳回和处理延期申请。
+- 前端根据当前用户、工单状态及后端返回的允许操作展示按钮,最终权限由后端校验。
+
+## 页面职责
+
+### 设备异常管理
+
+- 展示全部异常设备、设备报警状态、当前报警原因和关联工单进度。
+- 支持查看详情和故障维修派单。
+- 已存在适用的未结束工单时,隐藏重复派单操作,显示工单编号、责任人、状态和查看入口。
+
+### 设备维修派单
+
+- 聚焦尚无适用故障维修工单的异常设备。
+- 主操作为选择唯一接单责任人并派单。
+- 已有关联工单的设备降低视觉优先级并提供查看入口。
+
+### 报警管理
+
+- 展示整体报警态势、报警等级、设备报警状态和关联工单处置状态。
+- 支持报警详情、报警转工单、责任人派发和查看关联工单。
+- 不提供接单、处理、验收等后续处置操作。
+
+### 报警审核
+
+- 菜单名称保持“报警审核”。
+- 只承载供水设备的故障维修工单及其关联报警。
+- 提供报警审核,以及派单、接单、开始处理、提交验收、验收、驳回和延期等完整工单流程。
+
+### 供水工单总览
+
+- 保留统一工单页并调整为全部供水类型工单的集中检索与管理入口。
+- 故障维修工单提供完整处置流程。
+- 日常巡检和设备保养工单由其他业务系统创建。
+- 巡检或保养工单派给当前用户后,允许其在本页继续接单、处理、提交验收和申请延期;相应管理人员可以执行后续审核操作。
+
+## 共享交互
+
+### 接单责任人选择器
+
+- 使用约 860px 的“部门树 + 单选用户表”弹窗。
+- 支持部门筛选,以及用户名、昵称、手机号搜索和分页。
+- 展示昵称、用户名、部门、手机号和账号状态。
+- 单击表格行可以选中,同时保留明确的单选标识。
+- 底部固定展示已选责任人;未选择时禁止确认。
+
+### 表单反馈
+
+- 设备维修、设备异常和报警管理派单均要求责任人和问题描述。
+- 优先级默认一般;报警可按已有报警等级给出默认优先级;计划完成时间可选。
+- 报警工单类型只读显示“故障维修(报警转入)”。
+- 成功提示“已派单,等待接单”,刷新并定位关联工单。
+- 失败时保留弹窗和输入内容,优先显示后端业务错误。
+- 并发重复创建时刷新数据并提示已存在关联处置工单,不提示为新建成功。
+
+## 视觉规范
+
+### 视觉语言
+
+- 风格:供水运行指挥台,桌面业务系统优先。
+- 主色:蓝白;页面背景为浅灰蓝,标题使用深蓝。
+- 状态色:红色报警或紧急、橙色待办、青绿色处理中、绿色正常或完成、灰色驳回或不可操作。
+- 报警等级四色是独立语义,不与工单状态混用。
+- 内容区圆角 6-8px,使用细边框和轻阴影;不使用大圆角胶囊、装饰性渐变或嵌套卡片。
+- 交互过渡为 120-180ms;报警状态不持续闪烁。
+
+### 页面构图
+
+- 报警管理:紧凑指标带、筛选栏、报警表格、右侧详情抽屉。
+- 报警审核:左侧列表与右侧详情工作台;详情标签为报警信息、工单处置、处理记录;底部固定当前可用操作。
+- 供水工单总览:复用主从工作台,强化工单类型、来源系统、责任人和状态。
+- 设备异常管理:异常指标带、筛选、设备表格和右侧详情抽屉。
+- 设备维修派单:待派单队列为主,突出责任人选择和派单操作。
+
+### 图标与导航
+
+- 供水系统每个菜单项必须具有语义匹配的 Element Plus 线性图标。
+- 页面标题和命令按钮沿用同一图标语言,不使用 Emoji 或位图按钮。
+- 菜单展开、折叠状态均显示图标;折叠时提供悬停提示。
+- 未配置或无法解析的图标使用统一兜底图标,不能留空或破坏布局。
+
+### 密度与响应式
+
+- 采用紧凑但可读的表格、筛选和操作密度,不使用大标题或卡片墙。
+- 1280px 以上显示完整主从工作台。
+- 1024px 下缩窄左侧列表并保持详情可操作。
+- 768px 以下列表与详情上下排列,抽屉和弹窗占满可用宽度。
+- 所有状态必须同时具有文字和颜色表达,并保持清晰对比。
+
+### 加载与异常
+
+- 表格、详情和操作使用局部加载状态。
+- 空数据使用明确空状态。
+- 接口失败在原位置显示错误与重试入口,保留已有数据和表单内容。
+
+## 后端前置契约
+
+前端实施前先完成并验收以下后端能力:
+
+1. 待派单供水工单使用唯一接单责任人派发,并返回可刷新定位的工单信息。
+2. 报警转工单按“设备 + 报警编码”限制同时存在的未结束工单;编码缺失时回退到报警分类。
+3. 误报或解除审核按工单进度联动取消、驳回或管理确认终止,并触发清警复核而非直接清警。
+4. 报警列表或详情返回设备报警状态,以及关联工单 ID、编号、类型、状态和责任人信息。
+5. 工单接口能够表达当前用户允许执行的操作,并在服务端执行最终授权校验。
+6. 日常巡检和设备保养工单允许接单责任人在供水工单总览继续后续处置。
+7. 供水动态菜单配置提供所有菜单项的图标值,并与前端图标解析规则一致。
+
+## 验收标准
+
+- 五个页面按职责显示正确的数据范围和操作集合。
+- 三类状态来源明确,页面不再把报警记录状态当作设备报警状态。
+- 同一责任人选择器在相关入口保持一致,且派单只产生一个责任人。
+- 同设备同报警编码不能并行生成多个未结束工单;不同报警编码可以分别生成。
+- 设备恢复不会自动关闭其他工单;“随同修复”仍保留完整验收记录。
+- 误报流程不会留下无法处置的活动工单,也不会由前端直接清警。
+- 供水菜单展开、折叠和图标缺失场景均有稳定图标布局。
+- 构建及相关测试通过。
+- 提供 1440px、1024px 和 390px 宽度的关键页面截图,确认菜单、表格、抽屉和弹窗无溢出、重叠或空白渲染。
+- 执行者不得合并或推送代码;当前对话负责代码审查和验收。

+ 101 - 0
docs/superpowers/plans/2026-08-14-project-memory.md

@@ -0,0 +1,101 @@
+# Project Memory Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为城市生命线项目创建仓库内的对话记忆库,并建立每次任务前读取、结束后更新、定期整理长期记忆的协作规则。
+
+**Architecture:** 使用根目录 `AGENTS.md` 作为项目级入口,使用 `memory/` 保存索引、长期记忆和按日期聚合的会话记录。全部采用 Markdown,不进入 Vite 构建,不增加运行时依赖。
+
+**Tech Stack:** Markdown、Git、PowerShell 静态验证
+
+**Spec:** `docs/superpowers/specs/2026-08-14-project-memory-design.md`
+
+## Global Constraints
+
+- 不修改 Vue/Vite 运行时代码。
+- 不增加 npm 依赖。
+- 不记录密码、Token、地图密钥或其他敏感值。
+- 每累计 5 次会话,或当日会话文件超过约 20 KB,整理一次长期记忆。
+
+---
+
+### Task 1: 创建项目级记忆规则和目录说明
+
+**Files:**
+- Create: `AGENTS.md`
+- Create: `memory/README.md`
+
+**Interfaces:**
+- Consumes: `docs/superpowers/specs/2026-08-14-project-memory-design.md`
+- Produces: 后续协作者可执行的读取、写入、整理和隐私规则
+
+- [x] **Step 1: 创建 `AGENTS.md`**
+
+写明以下强制流程:任务开始读取 `memory/index.md` 和 `memory/long-term.md`;必要时读取最近会话;任务结束更新当天会话文件与索引;稳定决策写入长期记忆;禁止记录敏感信息。
+
+- [x] **Step 2: 创建 `memory/README.md`**
+
+包含目录职责、会话记录模板、五次会话整理周期、20 KB 整理阈值、归档规则和敏感信息边界。
+
+- [x] **Step 3: 验证规则文件存在且包含关键约定**
+
+Run:
+
+```powershell
+Test-Path AGENTS.md
+Test-Path memory/README.md
+rg -n "memory/index.md|memory/long-term.md|每次|敏感|5 次|20 KB" AGENTS.md memory/README.md
+```
+
+Expected: 两个 `Test-Path` 均输出 `True`,`rg` 命中读取、更新、隐私和整理规则。
+
+### Task 2: 初始化索引、长期记忆和本次会话记录
+
+**Files:**
+- Create: `memory/index.md`
+- Create: `memory/long-term.md`
+- Create: `memory/sessions/2026-08-14.md`
+- Modify: `task_plan.md`
+- Modify: `progress.md`
+
+**Interfaces:**
+- Consumes: 已核实的项目探索结果和本次用户确认的记忆库约定
+- Produces: 后续对话可直接读取的项目上下文与会话历史
+
+- [x] **Step 1: 创建 `memory/index.md`**
+
+记录项目名称、技术栈、核心入口、动态路由特征、当前已知风险、长期记忆链接和会话索引。
+
+- [x] **Step 2: 创建 `memory/long-term.md`**
+
+记录已经确认的稳定事实:项目定位、技术架构、启动方式、后端代理依赖、记忆库更新约定和敏感信息规则。
+
+- [x] **Step 3: 创建 `memory/sessions/2026-08-14.md`**
+
+记录本次项目探索、启动服务说明、记忆库设计决策、创建/修改文件、验证情况和后续待办。
+
+- [x] **Step 4: 更新规划记录**
+
+将 `task_plan.md` 的记忆库创建阶段标记完成,并在 `progress.md` 记录实施结果。
+
+- [x] **Step 5: 检查文件结构和敏感信息**
+
+Run:
+
+```powershell
+Get-ChildItem memory -Recurse -File | Select-Object FullName
+rg -n "VITE_BAIDU_MAP_AK\s*=|Bearer\s+[A-Za-z0-9]|password\s*=|token\s*=" AGENTS.md memory
+```
+
+Expected: 列出四个记忆文件;敏感信息扫描无匹配。
+
+- [x] **Step 6: 检查变更范围**
+
+Run:
+
+```powershell
+git status --short
+git diff --stat
+```
+
+Expected: 仅出现规划文档、`AGENTS.md`、`memory/` 和当前规划记录,不出现 `src/`、`package.json` 或 Vite 配置变更。

+ 84 - 0
docs/superpowers/plans/2026-08-17-water-supply-ui-completion.md

@@ -0,0 +1,84 @@
+# Water Supply Frontend Completion Implementation Plan
+
+> **For agentic workers:** Execute this plan task-by-task in the current workspace. Steps use checkbox syntax and must be verified before completion.
+
+**Goal:** Replace the blank water-supply home with a gas-style GIS dashboard, expose all requested water-supply modules through dynamic menus, and align monitoring/alarm interactions with the supplied alarm specification.
+
+**Architecture:** Keep the existing Vue 3/RuoYi dynamic-route architecture. Add one reusable water dashboard component for both water home entries, extend existing water components rather than duplicating pages, and keep API contracts in `src/api/pipeNetwork/waterSupply.js`. Add a backend type constraint because frontend filtering cannot correct paginated mixed-device responses.
+
+**Tech Stack:** Vue 3 `<script setup>`, Element Plus, ECharts, existing BMapGL/coordinate utilities, Axios request wrapper, Spring Boot/MyBatis service layer, SQL menu initialization.
+
+**Spec:** `.firecrawl/water-monitor-alarm.md` and the user requirements in this task.
+
+## Global Constraints
+
+- Page names must remain aligned with the supplied water-supply menu names.
+- The four water modules remain dynamically authorized by backend `/getRouters`; frontend component existence alone is insufficient.
+- The home dashboard must show a GIS/map area with left and right data cards in the visual language of `gas/ghome/gHome.vue`.
+- Monitoring pages must show only the corresponding flow, pressure, or leakage sub-device data.
+- No credentials, tokens, map keys, or complete sensitive API responses may be written to memory or tracked files.
+- Preserve all unrelated existing worktree changes.
+
+### Task 1: Add the water-supply GIS dashboard
+
+**Files:**
+- Create: `src/views/subSystem/waterSupply/components/WaterSupplyHome.vue`
+- Modify: `src/views/subSystem/waterSupply/whome/wHome.vue`
+- Modify: `src/views/subSystem/waterSupply/index.vue`
+- Test: `tests/waterSupplyModel.test.mjs`
+
+- [ ] Build the dashboard around `getWaterFacilityOverview`, `getWaterFacilityFeatures`, `getDeviceGisAllPoints`, `getDeviceGisStatistics`, `getAlarmStats`, and `listRealtimeAlarms`, with zero-value/empty-state fallbacks for failed APIs.
+- [ ] Reuse the gas dashboard visual hierarchy: title/time header, three-column body, dark blue map stage, left operational cards, right alarm/device cards, and responsive collapse below 1100px.
+- [ ] Render facility points and pipe lines from GeoJSON using the existing coordinate normalization helper, with facility legend, alarm-level legend, selected-point detail, and refresh action.
+- [ ] Make `wHome.vue` and `index.vue` thin wrappers around the component so either route renders the same non-empty page.
+- [ ] Add a model-level assertion for dashboard fallback/coordinate normalization and run the test.
+
+### Task 2: Align monitoring pages with device subtypes
+
+**Files:**
+- Modify: `src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue`
+- Modify: `src/utils/waterSupplyModel.js`
+- Modify: `tests/waterSupplyModel.test.mjs`
+- Modify: `E:/pipe-ner-server/pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/MonitoringServiceImpl.java`
+
+- [ ] Add a `monitorType` prop to the shared page and display `设备子类型` from `equipmentTypeName`.
+- [ ] Keep each page bound to its dedicated API and metric field: flow `instantFlow`, pressure `pressureValue`, leakage `power`.
+- [ ] In `MonitoringServiceImpl`, resolve matching `equipment_type` rows by type ID/name keywords and constrain the initial `EquipmentBase` query to those IDs before pagination; leave records empty when no matching subtype exists.
+- [ ] Preserve existing filters, trend calls, and export behavior; add a regression assertion for type-name normalization on the frontend model side.
+
+### Task 3: Complete monitoring-alarm interactions
+
+**Files:**
+- Modify: `src/views/subSystem/waterSupply/components/WaterAlarmPage.vue`
+- Modify: `src/api/pipeNetwork/waterSupply.js`
+- Modify: `src/utils/waterSupplyModel.js`
+
+- [ ] Add a real-time exception card section from `/waterSupply/alarm/realtime`, including device, level, location, actual value, threshold, and time.
+- [ ] Add detail loading through `/waterSupply/alarm/detail/{id}`, one-click resolve, and a dispatch form bound to `/waterSupply/alarm/dispatch`.
+- [ ] Add audit action/result/opinion/attachment fields, audit history and full-flow timeline using `/audit`, `/audit/list`, and `/flow/{id}`; use the existing upload component only if its request contract is compatible.
+- [ ] Keep threshold CRUD and add clear classification/time-period labels; retain alarm map level coloring, search, and detail opening.
+- [ ] Ensure every operation is permission-safe and presents an empty/error state without breaking the page.
+
+### Task 4: Enable the complete dynamic menu tree
+
+**Files:**
+- Create: `E:/pipe-ner-server/pipe-network-service/sql/water_supply_menu.sql`
+- Modify: `src/views/subSystem/waterSupply/**` only when a component path differs from the menu component value.
+
+- [ ] Add idempotent SQL for the water root, four module directories, 19 page menus, and the required list/export/handle/audit/dispatch button permissions.
+- [ ] Set requested menus `visible='0'` and `status='0'`, use the exact Chinese names from the user list, and point components to existing semantic paths.
+- [ ] Verify menu parent lookup is by stable parent name/perms and does not duplicate rows on repeat execution.
+
+### Task 5: Verify and record
+
+**Files:**
+- Modify: `task_plan.md`
+- Modify: `findings.md`
+- Modify: `progress.md`
+- Modify: `memory/sessions/2026-08-17.md`
+- Modify: `memory/index.md` and `memory/long-term.md` only if stable facts change
+
+- [ ] Run `node tests/waterSupplyModel.test.mjs`.
+- [ ] Parse all changed Vue SFCs with `@vue/compiler-sfc` and run the production build using the workspace Node runtime.
+- [ ] Start or reuse the frontend dev server and use the in-app browser to verify the water home renders a non-empty dashboard and the requested dynamic menu routes resolve after backend authorization.
+- [ ] Record errors, test results, changed files, and remaining backend/data limitations without secrets.

+ 44 - 0
docs/superpowers/specs/2026-08-14-project-memory-design.md

@@ -0,0 +1,44 @@
+# 项目记忆库设计
+
+## 目标
+
+在仓库内建立可持续维护的项目记忆库,保存每次对话的上下文、技术决策、变更记录和待办事项,并定期将稳定信息整理为长期记忆。
+
+## 目录结构
+
+```text
+AGENTS.md
+memory/
+├── README.md
+├── index.md
+├── long-term.md
+└── sessions/
+    └── YYYY-MM-DD.md
+```
+
+## 文件职责
+
+- `AGENTS.md`:项目级协作规则。每次任务开始先读取 `memory/index.md` 与 `memory/long-term.md`;任务结束更新当日会话记录。
+- `memory/README.md`:记忆库使用说明、记录模板、整理周期和隐私边界。
+- `memory/index.md`:当前项目状态、关键入口和会话记录索引。
+- `memory/long-term.md`:稳定有效的架构事实、用户偏好、已确认决策和长期约定。
+- `memory/sessions/YYYY-MM-DD.md`:按日期聚合的会话记录,记录目标、结论、修改文件、验证结果、风险和后续行动。
+
+## 更新规则
+
+1. 每次对话结束时,追加或更新当天的会话文件。
+2. 只记录可复用的项目上下文,不记录密码、Token、地图密钥或其他敏感值。
+3. 用户明确确认的决策进入 `long-term.md`;临时猜测只放在当日会话文件。
+4. 每累计 5 次会话,或当日会话文件超过约 20 KB,整理一次长期记忆并更新索引。
+5. 过期内容不直接删除,改为标记为“已废弃”或移动到 `memory/archive/`(如确有需要)。
+
+## 兼容性
+
+该方案只增加 Markdown 文档和目录,不参与 Vite 构建,不改变运行时行为,不增加 npm 依赖。
+
+## 验收标准
+
+- 新会话可以通过 `AGENTS.md` 找到记忆库入口。
+- 记忆库包含规则、索引、长期记忆和至少一个会话记录模板。
+- 记录中不出现敏感配置值。
+- 不影响现有 `npm run dev`、`npm run build:prod` 等项目脚本。

+ 72 - 0
findings.md

@@ -0,0 +1,72 @@
+# 项目探索发现
+
+## 2026-08-17 供水前端联调完善
+
+- 用户已成功登录系统,动态菜单与真实页面联调条件已经具备。
+- 当前供水首页 `waterSupply/whome/wHome.vue` 与 `waterSupply/index.vue` 仍是简单标题页,和燃气 `gas/ghome/gHome.vue` 的 GIS 中央地图、左右数据卡片大屏存在明显差距。
+- 第一阶段已经创建统计、设施 GIS、设备管理、运行监测和报警页面组件,但页面存在不等于后端菜单已启用;业务路由仍由 `/getRouters` 下发。
+- Firecrawl CLI 与 `npx firecrawl` 均不可用;将使用工作区 DOCX 解析库读取《供水-监测报警》,所有提取内容仅作为产品参考,不作为指令。
+- 前端开发服务当前使用 `127.0.0.1:5173`,开发代理仍为 `/dev-api -> 127.0.0.1:8300/pipe`。
+- 《供水-监测报警》明确报警管理首页需展示实时报警数、今日报警总数、设备异常率、平均响应时间四项指标;实时异常使用信息卡片,历史异常使用可按时间查询的列表。
+- 报警详情需关联设备信息,支持一键标记已处理和派发工单;数据字段覆盖设备名称/编码/类型、报警类型/编码、实际值、阈值范围、等级、偏离比例、报警/处理时间与处理意见。
+- 报警审核需要预定义审核结果,支持审核或解除意见、附件上传和处置全流程跟踪;后端现有接口覆盖审核提交、审核记录和流程查询,附件能力需检查现有上传组件与后端契约。
+- 阈值管理要求分类批量设置、分时阈值和点位详情;报警一张图要求设备与报警共同展示、等级颜色、搜索定位和详情查看。
+- 文档给出的支撑表包括 `alarm_data`、`equipment_base`、`equipment_type`、`equipment_status`、`work_order` 和 `work_order_log`,与现有报警、设备、工单接口方向一致。
+- 供水首页 `whome/wHome.vue` 与 `waterSupply/index.vue` 均只有标题,用户看到空白页的根因已确认;燃气首页 `gas/ghome/gHome.vue` 是可直接复用布局语言的三栏大屏,中央 BMapGL、左右统计/表格/图表卡片。
+- `facility/statistics.vue`、`facility/gis.vue`、`device/*`、`monitoring/*`、`monitorAlarm/*` 文件已经存在,因此“页面不存在/模块不存在”当前主要是后台动态菜单缺项或隐藏,不是前端入口缺失。
+- `WaterMonitoringPage.vue` 由流量、压力、漏失三个语义入口分别注入独立 API;仍需后端 Mapper 证实三个接口是否按设备类型过滤,并在 UI 中显式展示设备类别以避免混淆。
+- 现有 `WaterAlarmPage.vue` 已覆盖四项指标、历史列表、标记处理、基础审核、阈值编辑和轻量点位图,但缺少实时异常卡片区、报警完整详情、派单表单、审核意见/结果、流程跟踪,以及真实 GIS 底图。
+- 后端仓库只发现 `water_supply_alarm.sql` 中注释形式的单个监测报警菜单示例,尚无完整供水菜单初始化脚本。
+- 后端 `WaterSupplyAlarmController` 已提供 `/detail/{alarmId}`、`/resolve/{alarmId}`、`/dispatch`、`/audit`、`/audit/list`、`/flow/{alarmId}` 和 `/map/points`,足以实现文档要求的详情、处理、派单、审核、附件字段和全流程跟踪;前端 API 已声明 `getAlarmDetail`、`dispatchAlarm`、`getAlarmFlow`,但 `WaterAlarmPage` 尚未调用。
+- 报警后端权限分为 `waterSupply:alarm:list`、`waterSupply:alarm:handle`、`waterSupply:alarm:dispatch`、`waterSupply:alarm:audit`;菜单初始化必须同时考虑页面权限与按钮权限,否则页面可见但操作会返回 403。
+- 流量、压力、漏失 Controller 分别调用 `MonitoringService.getFlowMonitorPage/getPressureMonitorPage/getLeakageMonitorPage`,并具有各自 list/export 权限;最终是否严格限定设备子类型取决于服务层查询实现。
+- `WaterFacilityDashboard.vue` 和 `WaterDevicePage.vue` 已具备统计/设施点线、设备清单/异常/维修/GIS 的第一阶段内容,可通过后台菜单直接启用,无需重新创建页面。
+- `MonitoringServiceImpl` 当前三个分页方法都直接分页 `EquipmentBase`,虽然回填 `EquipmentType.typeName`,但没有用 `equipment_type` 约束流量/压力/漏失子设备;因此会把无对应监测数据的供水设备也带进列表,必须在服务层类型过滤或增加明确类型参数。
+- 流量数据源为雷达/遥测,压力数据源为消防压力表,漏失数据源为噪声信息;三类趋势接口按设备编码取对应子设备历史数据。
+
+## 2026-08-17 供水前端实施任务
+
+- 用户要求依据 `城市地下管网及设施建设改造智能化系统项目0821.pdf` 的 5.3.2 章节设计并开发供水前端,页面功能模块命名必须与文档对齐,具体内容按约 50% 置信度实现。
+- 实际前端仓库为 `E:\city-life-line`,后端仓库为 `E:\pipe-ner-server`。
+- 前端当前存在用户维护的未跟踪 `AGENTS.md`、`docs/`、`memory/` 与规划文件,本任务保留并继续使用这些文件,不覆盖无关改动。
+- 既有记忆确认:供水页面目前未接真实 API;后端已具备设施 CRUD、统计 dashboard、设施 GIS、监测、设备台账、报警、审核、阈值与导出接口。
+- 前端完整业务菜单主要来自后端 `/getRouters` 动态下发,因此页面文件、组件路径与后端菜单配置需要共同核对。
+- PDF 文件约 51.2 MB;本机无 Firecrawl CLI,工作区内置 Python 提供 `pypdf` 与 `pdfplumber`,本次采用本地解析并将产物放入忽略的 `.firecrawl/`。
+- planning session-catchup 仅发现上一轮“记忆库已创建”的未同步摘要,当前 `AGENTS.md`、`memory/` 与规划文件已经覆盖该上下文,没有额外代码需要恢复。
+
+### PDF 5.3.2 已核实需求
+
+- 总体目标:覆盖供水管网运行参数监管、安全监测、精细化漏损管控、泵房自动化、调度能源、巡检维修和资产生命周期管理。
+- `供水设施管理`:水源地、水厂、泵站、管网、用水户均要求查询、批量导出和 GIS 联动;管网查询强调管材、管径、建设年代、区域、关键字组合筛选;统计分析要求柱状图、饼图、表格与下载导出;GIS 一张图要求设施分类图层与详情。
+- `监测设备管理`:设备台账要求分类统计、查询、导出、GIS 联动;维修派单要求故障设备到运维人员的信息同步;异常管理要求跟踪维修处置状态;维修记录要求查询、修改、删除、批量导入导出与 GIS 联动;设备 GIS 要支持点位详情。
+- `运行监测`:流量、压力、漏失均要求监测点位清单、多重查询、批量导出和 GIS 联动定位。
+- `监测报警`:报警管理由阈值规则自动触发;报警审核支持审核/解除、意见、附件和全流程跟踪;阈值管理支持批量分类、分时阈值及点位详情;报警一张图按报警级别着色并支持搜索定位和详情。
+- 用户给出的末项“监测报警一张”应按 PDF 正式名称实现为“监测报警一张图”。
+
+### 供水前端实施结论
+
+- 19 个页面均已有与文档名称对应的 Vue 组件入口;五类设施查询复用已存在的真实 CRUD 页面,其余页面通过统一 `waterSupply.js` 接入后端。
+- 后端动态菜单仅在 SQL 中发现监测报警示例,未发现完整 19 项菜单迁移;前端已提供语义化 component 路径,实际菜单是否显示仍取决于部署数据库中的 `sys_menu` 和角色授权。
+- 设施 GIS 接口返回 GeoJSON Point/LineString,当前前端实现分类图层、点线分布和点位详情,属于 50% 置信度下的轻量一张图实现,不包含底图、空间检索或复杂地图控件。
+- 维修派单接口 `MaintainOrderVO` 已按 `equipmentId`、`equipmentCode`、`equipmentName`、`maintainType`、`maintainContent`、`dispatchTime` 对齐;维修人员和电话仍需业务侧在联调时确定录入方式。
+- 监测接口 VO 使用业务字段名:流量为 `instantFlow`、压力为 `pressureValue`、漏失为声学功率 `power`;前端适配器已统一映射到表格和趋势图的 `value`。
+- 当前本机没有 8300 后端服务,无法验证登录、`/getRouters` 菜单树、权限指令和真实接口数据;前端已提供接口失败空状态。
+- 生产构建需要 Node 20.19+;当前系统 Node 20.15.1 不满足部分依赖要求,使用工作区 Node 24 并补装缺失的 `@oxc-parser/binding-win32-x64-msvc@0.131.0` 后构建通过,未修改 `package.json` 或锁文件。
+
+- 实际项目为 Vue 3.4.31 + Vite 5.3.2 + Element Plus 2.7.6 + Pinia 2.1.7,基于 RuoYi-Vue 3.8.8 改造。
+- `src` 约 402 个文件,其中 220 个 Vue、70 个 JS;业务视图约 178 个,API 文件 33 个。
+- 运行入口:`src/main.js` 创建 Vue 应用,注册 Element Plus、Pinia、Router、插件、指令、SVG 图标和公共组件。
+- 认证链路:`src/permission.js` 路由守卫检查 Token;首次访问通过 `/getInfo` 获取角色/权限,再通过 `/getRouters` 生成动态路由。
+- `src/router/index.js` 当前只保留登录、注册、门户、RuoYi 首页和错误页等常量路由,业务模块路由文件引用被注释,业务菜单依赖后端动态下发。
+- `src/utils/request.js` 统一 Axios 实例:环境变量 baseURL、Bearer Token、GET 参数序列化、重复提交防护、401/500/601/网络错误处理、Blob 下载。
+- 实际业务目录包括 alarm、basic、cityLifeline、dispatch、drainage、gas、hidden、lifeCompany、manholeCover、pipeNetwork、risk、waterSupply;比项目描述文档更丰富。
+- 地图存在两套:`index.html` 直接加载百度 WebGL BMapGL;部分页面使用 Leaflet;坐标转换工具面向 BD-09。
+- ECharts 按需或全量使用于排水、燃气、供水、监控等页面;多个页面存在定时刷新/动画/轮询。
+- `package.json` 只有 dev/build/preview 脚本,没有测试、lint、typecheck 脚本。
+- README 仍主要是 RuoYi 原始模板说明,项目专属信息以 `项目文件夹描述.md` 和源码为准。
+- 已发现潜在维护点:门户页调用 `userStore.logout()`,而用户 Store 定义的方法名为 `logOut()`;百度 AK 在 `index.html` 中直接配置,环境文件仅有相关配置痕迹,未形成统一生效入口。
+- 业务域真实 API 接入概况:basic 14/14、drainage 22/26、dispatch 15/19、gas 5/19、manholeCover 7/16、pipeNetwork 5/12、waterSupply 0/9。
+- 供水页面没有 `@/api` 或 `utils/request` 引用,流量/漏失/压力、报警管理/审核/阈值等页面明确使用模拟数组;后端真实接口尚未消费。
+- 排水 `getAlarmById` 使用 `/api/alarm/{id}`,后端实际为 `/api/alarm/getById/{id}`;同项目 `hazard.js` 使用正确路径。
+- `X-Silent-Error` 在排水 API 中被设置,但 `src/utils/request.js` 没有对应处理逻辑。
+- 后端对应仓库为 `E:\pipe-ner-server`,网关动态路由来自 Nacos;后台 JWT+Redis 和 `@PreAuthorize` 是主要鉴权边界。

+ 43 - 0
memory/README.md

@@ -0,0 +1,43 @@
+# 项目记忆库
+
+本目录保存城市生命线项目的持续上下文,作为跨对话协作时的事实来源。
+
+## 文件职责
+
+- `index.md`:项目当前状态、关键入口、已知风险和会话索引。
+- `long-term.md`:稳定有效的架构事实、长期决策、用户偏好和协作约定。
+- `sessions/YYYY-MM-DD.md`:按日期聚合的会话记录。
+- `archive/`:仅在整理时存放不再活跃的历史记录,按需创建。
+
+## 会话记录模板
+
+```md
+## 会话:YYYY-MM-DD
+
+### 目标
+
+### 结论与决策
+
+### 文件变更
+
+### 验证结果
+
+### 风险与待办
+```
+
+## 更新流程
+
+1. 新任务开始前读取 `index.md`、`long-term.md` 和相关近期会话。
+2. 任务过程中只记录已经核实的事实;猜测和待确认事项留在当日会话记录。
+3. 任务结束后更新当日会话文件,并同步索引或长期记忆中的稳定内容。
+4. 每累计 5 次会话,或当天记录超过约 20 KB,整理一次长期记忆。
+
+## 整理原则
+
+- 长期记忆只保留仍然有效、可复用的内容。
+- 冲突信息以最新经用户确认或代码验证的事实为准。
+- 过期内容标记为“已废弃”或移动到 `archive/`,不要无声删除重要决策。
+
+## 隐私边界
+
+不得记录密码、Token、API 密钥、地图密钥、内部账号、个人隐私和完整敏感响应。配置说明只保留变量名、用途和配置状态。

+ 55 - 0
memory/index.md

@@ -0,0 +1,55 @@
+# 城市生命线项目记忆索引
+
+## 项目概况
+
+- 项目:城市生命线 — 地下管网管控平台
+- 前端:Vue 3.4、Vite 5.3、Element Plus、Pinia、UnoCSS、SCSS
+- 基础框架:RuoYi-Vue 3.8.8 改造
+- 业务领域:地下管网、燃气、排水、供水、告警、风险和应急指挥调度
+
+## 关键入口
+
+- 应用入口:`src/main.js`
+- 根组件:`src/App.vue`
+- 路由守卫:`src/permission.js`
+- 路由定义:`src/router/index.js`
+- 动态权限路由:`src/store/modules/permission.js`
+- Axios 封装:`src/utils/request.js`
+- Vite 配置:`vite.config.js`
+- 环境变量:`.env.development`、`.env.staging`、`.env.production`
+
+## 运行方式
+
+```powershell
+cd E:\city-life-line
+npm install       # 首次运行或依赖变化时执行
+npm run dev       # 默认 http://localhost:80
+```
+
+开发环境 `/dev-api` 代理到本机 `8300/pipe` 后端;如果 80 端口不可用,可运行 `npm run dev -- --port 5173`。不要依赖 `bin/run-web.bat` 的 Yarn 命令,当前项目以 npm 和 `package-lock.json` 为准。
+
+## 业务模块
+
+当前源码实际包含 `basic`、`alarm`、`gas`、`drainage`、`dispatch`、`cityLifeline`、`waterSupply`、`manholeCover`、`pipeNetwork`、`lifeCompany`、`risk` 和 `hidden` 等模块。
+
+## 当前已知风险
+
+- 业务菜单主要由后端 `/getRouters` 动态下发,前端常量路由不包含完整业务树。
+- 门户页调用 `userStore.logout()`,用户 Store 当前定义的方法名为 `logOut()`,需要后续确认是否修复。
+- 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
+- 排水报警详情 API 请求 `/api/alarm/{id}`,后端实际路径为 `/api/alarm/getById/{id}`。
+- 若干排水请求设置 `X-Silent-Error`,但 Axios 拦截器未处理该标志。
+- 百度地图 AK 仍由 `index.html` 直接配置,环境变量未形成统一生效入口。
+- `package.json` 没有测试、Lint 或类型检查脚本。
+
+## 长期记忆
+
+- [长期记忆](long-term.md)
+- [供水报警与工单前端适配设计](../docs/design/2026-08-22-water-supply-work-order-ui.md)
+
+## 会话记录
+
+- [2026-08-14:项目探索、启动方式与记忆库建立](sessions/2026-08-14.md)
+- [2026-08-17:供水管网安全运行监测系统前端实现](sessions/2026-08-17.md)
+- [2026-08-21:工程技能仓库配置](sessions/2026-08-21.md)
+- [2026-08-22:供水报警与工单前端适配设计](sessions/2026-08-22.md)

+ 69 - 0
memory/long-term.md

@@ -0,0 +1,69 @@
+# 城市生命线长期记忆
+
+## 项目定位
+
+这是“城市地下管网及设施建设改造智能化系统项目”软件平台中“地下管网管控平台”的 Vue 3 前端,覆盖燃气、排水、供水、管网基础、告警、风险和应急调度等业务域。供水前端与 `E:\pipe-ner-server\pipe-network-service` 后端共同构成“供水管网安全运行监测系统”。
+
+## 技术架构
+
+- Vue 3 + Vite
+- Element Plus + Pinia
+- UnoCSS + SCSS
+- Axios 统一请求层,使用 JWT 鉴权
+- 百度地图 WebGL 与 Leaflet 并存
+- ECharts 用于监测、统计和大屏可视化
+- 业务路由由后端菜单和权限动态生成
+- 对应后端仓库为 `E:\pipe-ner-server`;管理后台基于 Spring Boot/RuoYi,网关路由由 Nacos 动态配置。
+
+## 当前功能成熟度
+
+- 基础台账、排水、应急调度的前端 API 接入相对完整。
+- 燃气、井盖、管网页面为真实接口与模拟数据混合。
+- 供水 19 个页面已按项目文档 5.3.2 命名完成第一阶段实现;五类设施 CRUD 复用既有页面,设备、运行监测、报警、统计和轻量 GIS 已接后端真实接口,后续按完整供水子系统验收。
+
+## 启动约定
+
+- 使用 npm,不依赖 Yarn。
+- 开发命令:`npm run dev`
+- 默认前端地址:`http://localhost:80`
+- 备用端口:`npm run dev -- --port 5173`
+- 开发代理:`/dev-api` → 本机 `8300/pipe` 后端
+- 正常登录和业务菜单需要对应后端服务运行。
+
+## 记忆库约定
+
+- 每次对话结束后更新当天的 `memory/sessions/YYYY-MM-DD.md`。
+- 稳定决策和长期有效事实同步到本文件。
+- 每累计 5 次会话或单日记录超过约 20 KB 时整理长期记忆。
+- 不记录密码、Token、API 密钥、地图密钥、内部账号或个人隐私。
+
+## 用户已确认决策
+
+- 采用仓库原生 `AGENTS.md + memory/` 方案。
+- 直接在当前项目目录维护记忆库,不创建额外 worktree。
+- 记忆库只使用 Markdown,不参与前端运行时,不新增 npm 依赖。
+- 前后端 Issue 均通过内网 Gogs 网页低频维护,不假定 CLI 或 API 集成。
+- 工程技能采用默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
+- 前端领域文档采用单上下文布局;后端 `E:\pipe-ner-server` 采用按 Maven 模块划分的多上下文布局。
+
+## 供水前端约定
+
+- 供水动态菜单组件路径统一使用 `waterSupply/facility/*`、`waterSupply/device/*`、`waterSupply/monitoring/*`、`waterSupply/monitorAlarm/*`。
+- 业务菜单仍由 `/getRouters` 动态下发;前端组件入口存在不等于角色已经获得对应 `sys_menu` 权限。
+- GIS 第一阶段使用后端 GeoJSON 做分类、点线分布与详情联动,后续接入真实底图时保持接口和页面名称不变。
+- 供水报警与工单前端统一使用唯一接单责任人;无手机号不阻断派单。
+- 供水、排水、燃气、窨井共用统一工单业务流程,系统展示范围由关联设备类型过滤;前端优先复用既有 REST 接口,不因页面入口创建平行工单生命周期。
+- 工单系统归属以关联设备类型树稳定业务编码判定;`/api/work-order` 是跨系统规范 API,旧入口只做兼容适配;统一工单继续采用数据库状态机和统一日志语义。
+- 报警管理侧重报警态势和派单,报警审核保留原菜单名并承载供水故障维修工单全流程,供水工单总览集中展示全部供水类型工单。
+- 日常巡检和设备保养工单由其他业务系统创建及首次派发;派给当前用户后可以在供水工单总览继续处置。
+- 设备报警状态、报警记录状态和工单处置状态必须分别展示,不得互相替代。
+- 相关页面采用蓝白运行指挥台风格;一级至四级报警依次使用红、橙、蓝、绿,供水菜单全部配置图标。
+- 详细设计见 `docs/design/2026-08-22-water-supply-work-order-ui.md`。
+
+## 产品级决策
+
+- 正式产品定位为“城市地下管网及设施建设改造智能化系统项目”软件平台中的“地下管网管控平台”;供水交付单元为“供水管网安全运行监测系统”,由本前端与 `E:\pipe-ner-server\pipe-network-service` 后端共同构成。
+- KingbaseES 是生产和最终验收唯一数据库真相源;PostgreSQL 仅在无法避免时用于兼容性验证。
+- 供水整体开发按业务纵向切片推进,既有接口可复用时优先复用;真实接口失败不得回退到随机模拟数据。
+- 前后端保持独立仓库和独立构建,以版本化 REST 契约、真实登录态 E2E 和 KingbaseES 集成验收作为集成门禁。
+- 工单处置图片采用 MinIO + KingbaseES 附件元数据;现有工单图片 URL 字段继续兼容,新图片按处理前/处置后阶段展示。

+ 63 - 0
memory/sessions/2026-08-14.md

@@ -0,0 +1,63 @@
+## 会话:2026-08-14
+
+### 目标
+
+探索城市生命线项目,说明前端启动方式,并建立可跨对话维护的项目记忆库。
+
+### 结论与决策
+
+- 项目是 Vue 3 + Vite + Element Plus + Pinia 的 RuoYi-Vue 改造项目。
+- 业务路由主要由后端菜单接口动态下发。
+- 使用 npm 启动前端:`npm run dev`,默认端口 80。
+- 开发代理 `/dev-api` 指向本机 `8300/pipe` 后端。
+- 当前机器有 Node.js 和 npm,没有 Yarn,因此不推荐直接使用 `bin/run-web.bat`。
+- 用户确认采用 `AGENTS.md + memory/` 记忆库方案,并同意直接在当前目录实施。
+
+### 文件变更
+
+- 新增 `AGENTS.md`。
+- 新增 `memory/README.md`、`memory/index.md`、`memory/long-term.md`。
+- 新增本会话记录文件。
+- 新增设计说明和实施计划文档。
+
+### 验证结果
+
+- 已核对 `package.json`、`vite.config.js`、环境配置和批处理脚本。
+- 记忆库文档不包含敏感配置值。
+- 尚未修改 Vue/Vite 运行时代码。
+- 执行 `npm test` 时确认项目未配置 `test` 脚本;本次改用记忆库结构、内容和变更范围检查作为验证依据。
+
+### 风险与待办
+
+- 后续确认并决定是否修复门户页 `logout`/`logOut` 方法名不一致。
+- 后续统一百度地图密钥配置来源。
+- 每次后续对话结束时更新本目录,并按约定定期整理长期记忆。
+
+## 会话补充:前后端功能联动复核
+
+### 目标
+
+结合 `E:\pipe-ner-server` 后端记忆库与当前源码,优先复核前端和后端功能、调用链及真实完成度。
+
+### 结论与决策
+
+- 前端登录、用户信息和动态菜单与后端 `/login`、`/getInfo`、`/getRouters` 对齐。
+- 前端业务成熟度不均:基础台账、排水、调度接入较多真实 API;供水 9 个页面没有真实 API 引用,多处使用模拟数据。
+- 后端供水已提供监测、设备、报警/审核/阈值、统计 dashboard、设施 GIS 和导出接口,下一阶段应优先完成供水前端闭环。
+- 网关鉴权过滤当前直接放行,后台 Spring Security/JWT+Redis 与方法权限是主要安全边界。
+
+### 文件变更
+
+- 仅更新前端记忆库、探索发现和进度文档;未修改 `src/`、Vite 配置或后端运行代码。
+
+### 验证结果
+
+- 静态统计 12 个前端业务域的页面、API 引用、模拟标记、地图、图表和轮询使用情况。
+- 反查后端 Controller、登录/菜单、安全配置、网关动态路由、设备 Netty Handler 和告警服务实现。
+- 确认门户退出方法名、排水报警详情路径、静默错误标志和地图配置等具体风险。
+
+### 风险与待办
+
+- 优先补齐供水报警、统计、设施 GIS、监测和导出页面/API。
+- 修复 `logout`/`logOut`、排水报警详情路径、`X-Silent-Error` 未处理问题。
+- 后端仍有导入导出占位、燃气伪派单和实时监控 TODO,联调前需区分“接口存在”与“功能完成”。

+ 152 - 0
memory/sessions/2026-08-17.md

@@ -0,0 +1,152 @@
+# 2026-08-17:供水管网安全运行监测系统前端实现
+
+## 目标
+
+依据项目 PDF 第 5.3.2 节和已开发后端接口,完成供水管网安全运行监测系统 19 个前端页面,页面命名与文档一致,功能按约 50% 置信度实现。
+
+## 结论
+
+- 完成供水设施管理、监测设备管理、运行监测、监测报警四个模块的 19 个页面入口。
+- 五类供水设施查询复用既有 CRUD 页面;新增统一 API、响应适配、设备管理、监测、报警、统计和 GIS 可复用页面。
+- 设施 GIS 使用后端 GeoJSON Point/LineString 展示分类图层、点线分布和详情;报警、设备地图按经纬度边界归一化显示。
+- 管网统计实现设施概览、柱状图、饼图和明细表;报警支持处理、审核和阈值编辑;设备异常支持维修派单。
+- 交付复核修正流量 `instantFlow` 与压力 `pressureValue` 的响应字段适配,点位表格和 24 小时趋势统一复用归一化逻辑;漏失按后端契约展示 `power/kW`。
+
+## 主要文件
+
+- `src/api/pipeNetwork/waterSupply.js`
+- `src/utils/waterSupplyModel.js`
+- `src/views/subSystem/waterSupply/components/`
+- `src/views/subSystem/waterSupply/facility/`
+- `src/views/subSystem/waterSupply/device/`
+- `src/views/subSystem/waterSupply/monitoring/`
+- `src/views/subSystem/waterSupply/monitorAlarm/`
+- `tests/waterSupplyModel.test.mjs`
+
+## 验证
+
+- `node tests/waterSupplyModel.test.mjs`:通过。
+- 监测字段映射回归测试已按 RED-GREEN 执行:修复前 `instantFlow` 断言失败,修复后流量、压力断言均通过。
+- 供水目录 32 个 Vue SFC 使用 `@vue/compiler-sfc` 解析:0 个错误。
+- 使用工作区 Node 24 执行 Vite 生产构建:退出码 0;仅有项目既有 Sass 弃用提示。
+- 本地开发服务器 `http://127.0.0.1:5173/` 登录页实际渲染正常。
+
+## 风险与后续
+
+- 本机 8300 后端未启动,验证码请求返回接口 500,因此尚未验证登录后的动态菜单、角色权限和真实数据响应。
+- 后端 SQL 中未发现完整 19 项供水菜单迁移;部署环境需确认 `sys_menu` 的 component 字符串与本次语义化入口一致。
+- 当前 GIS 是轻量点线视图,未接百度或 Leaflet 底图;维修人员、附件、批量导入等完整业务流程待后续联调。
+- 不记录账号、Token、地图密钥或接口敏感响应。
+
+## 后端启动复查
+
+- `pipe-network-service` 已在本机 `8301` 启动,验证码接口返回成功,Swagger 可发现 49 条供水接口路径,供水接口未携带令牌时按预期返回应用级 401。
+- 前端 `5173` 可访问,但其 `/dev-api` 代理仍指向 `8300/pipe`;当前没有 `zksy-gateway` 进程,因此验证码代理返回 HTTP 500,登录链路尚未打通。
+- 网关配置依赖的 Nacos、Redis 内网端口可达;下一步应启动 `zk-gateway`,或在仅本地直连场景下将开发代理切换到 `8301` 并调整路径重写。
+- 本次仅做运行状态检查和记忆更新,没有修改前端或后端运行代码。
+
+## 网关启用后复查
+
+- `zksy-gateway` 已启动并监听 `::8300`,未知路径可返回 404;前端 `5173` 根页面仍返回 200。
+- Nacos 路由配置已加载 `pipe -> lb://pipe-network-service`;但 Nacos 注册的 `pipe-network-service` 实例为 `172.17.0.27:8301`,宿主机对该地址 TCP/HTTP 均不可达。
+- 业务服务本机 `::1:8301` 的验证码接口正常,网关 `/pipe/captchaImage` 和前端 `/dev-api/captchaImage` 仍超时/失败;浏览器登录页验证码图片没有资源地址。
+- 当前阻塞是服务注册地址与宿主机网络边界不匹配。应让网关可访问注册实例(调整服务注册 IP/网络或把网关放入同一容器网络)后再做登录和动态菜单联调。
+
+## 无代码修复建议
+
+- Spring Cloud Alibaba Nacos Discovery 支持通过启动参数覆盖 `spring.cloud.nacos.discovery.ip` 和 `spring.cloud.nacos.discovery.port`;本地 IntelliJ 启动 `pipe-network-service` 时可注册 `127.0.0.1:8301`,无需修改源码或前端代理。
+- 复查确认 `127.0.0.1:8300` 与 `[::1]:8300` 均可返回网关 404,网关 IPv4/IPv6 监听不是阻塞点;重启业务服务并确认 Nacos 旧的 `172.17.0.27:8301` 实例不再参与负载均衡。
+
+## 会话补充:阅读记忆库与项目现状复核
+
+### 目标
+
+按要求探索当前项目并阅读记忆库文档,恢复跨对话上下文。
+
+### 结论与决策
+
+- 已阅读 `AGENTS.md`、`memory/README.md`、`memory/index.md`、`memory/long-term.md` 及近期会话记录。
+- 确认项目为 Vue 3 + Vite + Element Plus + Pinia 的 RuoYi-Vue 改造前端;业务菜单由后端 `/getRouters` 动态下发。
+- 当前工作区仍有未提交的供水第一阶段实现,包含 `waterSupply.js`、`waterSupplyModel.js`、供水 components/facility/device/monitoring/monitorAlarm 页面与测试。
+- 当前 `task_plan.md` 中“供水前端联调完善”处于 in_progress,重点为首页 GIS、菜单 SQL、监测设备过滤和报警流程补齐。
+
+### 文件变更
+
+- 仅追加本会话记录;未修改 `src/`、Vite 配置或 npm 脚本。
+
+### 验证结果
+
+- 已核对 `package.json`、Git 状态、顶层规划和发现文档、供水页面目录。
+
+### 风险与待办
+
+- 后续任务需保持现有未提交改动,不覆盖用户已有修改。
+- 继续推进动态菜单、供水首页和报警联调时,应先确认后端服务注册/网关可访问性。
+
+## 会话补充:供水前端继续开发(燃气风格首页/导出/GIS联动)
+
+### 目标
+
+按用户提供的供水管网安全运行监测系统功能概述,参考燃气管网首页样式继续完善前端并对接后端接口。
+
+### 结论与决策
+
+- 供水首页已从普通页面升级为燃气管网风格的大屏:三栏布局、BMapGL 中央地图、ECharts 图表、实时监测/设备/告警卡片。
+- 五类供水设施台账页面补齐“导出”和“GIS 联动”按钮,对接后端 `/api/water/*/export` 文件流。
+- 管网数据统计分析页补齐 Excel 导出。
+- 运行监测和设备管理页面增加 GIS 联动快捷入口。
+- 设施 GIS 页支持路由 type 参数预筛选图层。
+
+### 文件变更
+
+- `src/api/pipeNetwork/waterSupply.js`:新增五类设施导出 API。
+- `src/views/subSystem/waterSupply/components/WaterSupplyHome.vue`:大屏首页重写。
+- `src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue`:GIS 预筛选、统计导出。
+- `src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue`、`WaterDevicePage.vue`:GIS 联动入口。
+- `src/views/subSystem/basic/{waterSource,waterPlant,pumpStation,pipeInfo,waterUserInfo}/index.vue`:导出/GIS 按钮。
+- `progress.md`:记录本轮进展。
+
+### 验证结果
+
+- `node tests/waterSupplyModel.test.mjs` 通过。
+- 12 个相关 SFC 使用 `@vue/compiler-sfc` 解析通过。
+- `npm run build:prod` 构建成功。
+
+### 风险与待办
+
+- 动态菜单仍依赖后端 `/getRouters` 与 `sys_menu`,新增页面/按钮权限需同步后端菜单 SQL。
+- BMapGL 加载依赖网络和 AK,离线或 AK 受限时首页会显示地图加载失败提示。
+- 运行监测后端仍未按设备子类型过滤,后续需在服务层补充或前端增加更明确的类型筛选。
+
+## 会话补充:根据用户反馈调整供水模块
+
+### 目标
+
+处理四条反馈:取消 GIS 联动按钮、提供燃气风格供水设施 GIS 一张图菜单、补齐管网统计页面入口、运行监测按子设备类型过滤、监测报警工单处置参考燃气工单页。
+
+### 结论与决策
+
+- 移除所有“GIS 联动”按钮,GIS 改为独立菜单页面。
+- 供水设施 GIS 一张图采用燃气管网 `GisDashboard.vue` 同款布局,使用 BMapGL + 后端 GeoJSON 图层。
+- 新增前端静态路由 `waterSupplyRouter.js`,菜单包含“供水设施GIS一张图”“管网数据统计分析”“报警工单处置”。
+- 新增供水报警工单处置页,参考 `BusinessWorkOrder.vue` 的列表/详情/派单/核查/处理/验收/结案/时间线流程。
+- 后端通用工单服务增加 `water` 设备类型过滤,供水报警派发的工单可被查询和处置。
+- 运行监测后端已按流量计/压力计/漏失设备类型过滤,三个子页面数据范围正确。
+
+### 文件变更
+
+- 前端:`src/router/index.js`、`src/router/modules/waterSupplyRouter.js`(新增)、`src/views/subSystem/waterSupply/facility/gis.vue`(重写)、`src/views/subSystem/waterSupply/monitorAlarm/workOrder.vue`(新增)、移除相关 GIS 按钮。
+- 后端:`BusinessWorkOrderServiceImpl.java` 增加 `equipmentType='water'` 分支。
+- 文档:`progress.md` 更新。
+
+### 验证结果
+
+- 相关 Vue SFC 解析通过。
+- `npm run build:prod` 构建成功。
+- 后端 Java 文件仅做局部字符串/SQL 过滤调整,未执行后端编译。
+
+### 风险与待办
+
+- 静态路由与后端动态菜单可能重复,若后端已配置供水菜单需避免同名冲突。
+- 供水顶级设备类型需在数据库中实际存在 `type_name='供水' AND parent_type_id='0'`,否则工单过滤结果为空。
+- 报警工单处置页依赖通用工单接口 `/api/work-order/*`,需要后端重启后生效。

+ 29 - 0
memory/sessions/2026-08-21.md

@@ -0,0 +1,29 @@
+# 2026-08-21:工程技能仓库配置
+
+## 目标
+
+运行 `setup-matt-pocock-skills`,为前端与后端仓库配置 issue tracker、triage 标签和领域文档消费布局。
+
+## 结论
+
+- 两个仓库的 Issue 均位于内网 Gogs,通过网页低频手动维护。
+- 两个仓库都采用五个默认 triage 标签。
+- 前端采用单上下文领域文档布局。
+- 后端 `E:\pipe-ner-server` 是 12 模块 Maven 仓库,采用多上下文领域文档布局。
+
+## 文件变更
+
+- 更新前端 `AGENTS.md`,新增 `Agent skills` 区块。
+- 新增前端 `docs/agents/issue-tracker.md`、`triage-labels.md` 和 `domain.md`。
+- 在后端新建 `AGENTS.md` 及对应的三份 `docs/agents/` 配置。
+- 更新本记忆索引和长期决策。
+
+## 验证
+
+- 逐文件读取并核对两个仓库的配置内容。
+- 检查 Git 状态,确认仅新增或更新预期文档,未改动运行时代码。
+
+## 风险和后续行动
+
+- 本次只定义 triage 标签词汇,没有通过 Gogs 网页创建远程标签;首次使用前需确认仓库中存在这些标签。
+- `CONTEXT.md`、`CONTEXT-MAP.md` 和 ADR 目录按技能约定由领域建模流程按需创建,本次不生成空文件。

+ 37 - 0
memory/sessions/2026-08-22.md

@@ -0,0 +1,37 @@
+# 2026-08-22:供水报警与工单前端适配设计
+
+## 本次目标
+
+在分发前端执行任务前,确认供水设备异常、报警、工单处置和相关页面视觉重构规范。
+
+## 已完成
+
+- 探索供水设备、报警管理、报警审核和统一工单页面的复用关系及现有接口。
+- 对照燃气与窨井工单页面,确定采用紧凑的主从工作台,而非照搬旧页面。
+- 新建根目录 `CONTEXT.md`,统一报警、设备状态、接单责任人和工单处置术语。
+- 确认设备报警状态、报警记录状态和工单处置状态为三个独立概念。
+- 确认报警管理负责态势与派单,报警审核负责供水故障维修工单完整流程,供水工单总览展示全部供水类型工单。
+- 确认巡检和保养工单由其他系统创建与首次派发,被派给当前用户后可在供水工单总览继续处置。
+- 确认同设备同报警编码同时只能存在一个未结束工单,不同报警编码可分别生成。
+- 确认工单验收不直接清警;运行系统根据全部有效报警原因执行自动清警。
+- 完成五个页面、共享责任人选择器、供水菜单图标和响应式验收规范。
+
+## 重要发现
+
+- 当前报警转工单接口只创建待派单工单,不接收责任人,符合两阶段流程,但前端需要随后调用统一工单派发。
+- 当前后端按单条报警 ID 去重,尚不满足“设备 + 报警编码”的活动工单唯一性。
+- 当前误报审核只更新报警记录状态,不会联动终止关联工单。
+- 当前统一工单派发仍使用多人 ID 列表,需先补充唯一责任人的后端契约。
+- 供水菜单主要来自后端动态菜单,完整图标覆盖需要同步更新后端菜单配置。
+
+## 后续顺序
+
+1. 用户最终确认设计摘要。
+2. 使用隔离 worktree 和 `gpt-5.6-terra` 分发后端契约任务,执行者不得合并或推送。
+3. 当前对话完成后端代码审查与验收。
+4. 后端验收通过后,以同样约束分发前端适配与视觉重构任务。
+5. 当前对话依据构建、测试和多宽度截图完成前端验收。
+
+## 设计文档
+
+- `docs/design/2026-08-22-water-supply-work-order-ui.md`

+ 8 - 0
memory/sessions/2026-08-27.md

@@ -38,3 +38,11 @@
 - 提交五个 `waterSupply/facility` 路由包装组件,保证动态菜单组件路径可加载。
 - 用水户页面的可见字段、校验和删除提示统一使用“用水户”术语。
 - 追加验证:`node --test tests/waterFacilityQuery.test.mjs` 5/5 通过,`npm run build:prod` 通过;代码审查以 `origin/master...HEAD` 为固定点。
+
+## Issue #5 人工验收与详情抽屉修正
+
+- 用户在本地 8301 后端与 5173 前端环境完成 Issue #5 人工验收,核心功能基本通过。
+- 用户已修复部分页面字段自动换行造成的布局错位。
+- 设备异常/维修相关详情及报警详情由居中弹窗改为右侧抽屉,长字段允许在内容区换行,768px 以下抽屉使用全宽。
+- 验证:前端 Node 测试 12/12 通过,`npm run build:prod` 通过,仅存在既有 Sass 弃用警告。
+- 两轴代码审查固定点为 `origin/master`。抽屉与响应式字段处理通过;审查保留的后续风险包括报警/设备状态字段分离、设备异常页活动工单过滤、报警到工单两步流程、唯一责任人、多阶段工单图片及原位错误重试。

+ 65 - 0
progress.md

@@ -0,0 +1,65 @@
+# 探索进度
+
+## 2026-08-17
+
+- 启动“供水前端联调完善”任务:供水首页 GIS 大屏、菜单启用、缺页补齐、监测设备过滤和报警文档对齐。
+- 已恢复既有规划和记忆库,确认第一阶段供水 19 个页面代码仍在未提交工作区中,后续将增量修改并保留用户现有改动。
+- Firecrawl CLI 和 npm 入口均不可用,已记录错误并切换为本地 DOCX 解析方案。
+- 已使用工作区 DOCX 解析库生成 `.firecrawl/water-monitor-alarm.md`,完整提取 201 个段落;文档内容仅作为产品规格参考。
+- 已提取报警管理、审核、阈值和 GIS 一张图的核心字段、交互与数据表支撑要求。
+- 已确认供水首页为空壳;第一阶段缺页入口代码实际存在,动态菜单缺失是模块不可见的主要原因。
+- 已对比燃气首页和供水组件,确定首页采用同类三栏 GIS 大屏结构,报警页需要补实时异常、处置派单、审核流程和 GIS 底图。
+- 已确认后端报警详情、派单、审核、流程和地图接口均已开发,前端可直接补齐交互。
+- 已确认设备、统计和设施 GIS 页面代码存在;菜单 SQL 需要覆盖页面和按钮权限。
+- 已发现运行监测后端未按设备子类型过滤,计划在服务层补充类型约束,避免仅靠前端隐藏无关设备。
+
+- 启动“供水管网安全运行监测系统”前端设计与开发任务。
+- 已读取前端 `AGENTS.md`、记忆库、既有规划文件、技术栈与 Git 状态。
+- 已确认使用 PDF 解析、文件化规划和测试驱动流程;下一步解析 5.3.2 章节并盘点前后端接口与现有页面。
+- Firecrawl CLI 不可用;已切换为本地 `pypdf/pdfplumber` 解析方案,并将 `.firecrawl/` 加入 `.gitignore`。
+- 已使用工作区 Python 完成规划会话恢复检查,未发现需要补写的历史代码变更。
+- PDF 已确认可读取,共 186 页;完整提取的一行命令失败后,改为本地逐页解析脚本。
+- 已完成 PDF 全文的本地 Markdown 提取,并定位第 52-54 页的 5.3.2 章节。
+- 已整理四个一级模块和 19 个页面的功能要求;下一步逐项核对后端接口覆盖率。
+- 已新增供水统一 API、响应适配模型和模型测试,将原有流量、压力、漏失、报警、审核、阈值、报警一张图页面由模拟数据改为真实接口。
+- 已新增设备台账、维修派单、异常、维修记录、设备 GIS、管网统计和设施 GIS 页面,并复用既有五类供水设施 CRUD 页面。
+- 已接入设施 GeoJSON Point/LineString、设施统计柱状图/饼图,并修正报警和设备经纬度点位的边界归一化。
+- 已补齐 `waterSupply/facility/*`、`device/*`、`monitoring/*`、`monitorAlarm/*` 共 19 个动态菜单组件入口。
+- `node tests/waterSupplyModel.test.mjs`、32 个供水 SFC 解析、Node 24 生产构建均通过;构建仅有项目既有 Sass 弃用提示。
+- 交付复核发现并修正监测 VO 字段映射:流量 `instantFlow`、压力 `pressureValue` 现可同时用于点位表格与 24 小时趋势;回归测试先失败后通过。
+- 本地开发服务器已在 `http://127.0.0.1:5173/` 启动,登录页资源正常;因本机 8300 后端未运行,验证码返回接口 500,无法完成登录后动态菜单和真实数据联调。
+
+## 2026-08-14
+
+- 完成根目录、package、README、源码文件清单核对。
+- 完成 main/App、permission/router、Pinia user/permission、request 和 Vite 插件链路阅读。
+- 完成业务模块数量、API 使用、地图/图表/轮询特征的静态统计。
+- 当前无代码修改;工作区原始 Git 状态干净。
+- 已根据用户确认的方向撰写记忆库设计文档:`docs/superpowers/specs/2026-08-14-project-memory-design.md`。
+- 用户已通过设计;已创建实施计划:`docs/superpowers/plans/2026-08-14-project-memory.md`。
+- 已在当前目录创建 `AGENTS.md` 与 `memory/` 记忆库,写入项目索引、长期记忆和 2026-08-14 会话记录。
+- 已通过目录结构、敏感信息和变更范围检查;未修改 `src/`、`package.json` 或 Vite 配置。
+- 完成前尝试运行 `npm test`,项目未配置该脚本;已记录为现有测试能力缺口。
+- 读取后端主记忆库并完成前后端工程边界、启动入口和代理路径复核。
+- 静态统计 12 个前端业务域的 API 接入、模拟数据、地图、图表和轮询使用情况。
+- 反查后端供水 Controller、登录/菜单、安全配置、网关和设备采集/告警链路。
+- 更新前端记忆库,记录供水前端断层和已确认的接口/调用问题;未修改运行代码。
+
+## 2026-08-17 供水前端继续完善(燃气风格首页/导出/GIS联动)
+
+- 已按燃气管网大屏样式重写供水首页 `WaterSupplyHome.vue`:三栏 GIS 大屏、BMapGL 地图、ECharts 设施统计/报警统计、监测点/设备/告警实时卡片,并接入供水设施、设备、报警、监测后端接口。
+- 供水设施五类基础页面(水源地/水厂/泵站/管网/用水户)已增加“导出”和“GIS 联动”按钮,对接 `WaterFacilityExportController` 文件流接口。
+- 供水设施 GIS 页支持通过路由 `?type=source|plant|pumpStation|pipe|user` 预筛选图层,与基础台账页面联动定位。
+- 管网数据统计分析页增加 Excel 导出按钮,对接 `/waterSupply/pipeNetwork/statistics/export`。
+- 运行监测和设备管理页面增加“GIS 联动”快捷入口,跳转设备 GIS 一张图。
+- 验证:模型测试通过;相关 12 个 SFC 解析通过;`npm run build:prod` 退出码 0。
+
+## 2026-08-17 根据反馈继续调整供水前端
+
+- 已取消供水设施台账、监测、设备页面中的“GIS 联动”按钮,保留导出功能;GIS 入口改为独立菜单页面。
+- 已将供水设施 GIS 一张图重写为燃气管网 `GisDashboard` 同款风格:左侧图层控制/图例/统计/详情,中间百度地图,支持搜索定位、放大缩小、全图。
+- 新增前端静态菜单 `src/router/modules/waterSupplyRouter.js`,在侧边栏增加“供水设施GIS一张图”“管网数据统计分析”“报警工单处置”三个入口,解决“找不到管网数据统计分析页面”和“菜单无 GIS 页面”的问题。
+- 新增“报警工单处置”页面 `waterSupply/monitorAlarm/workOrder.vue`,参考燃气业务工单处置页设计,支持工单列表、详情、派单、核查、处理、提交验收、验收通过/结案、处理历史时间线。
+- 后端 `BusinessWorkOrderServiceImpl` 增加 `equipmentType='water'` 过滤分支,供水报警派发后的工单可按供水设备类型查询和处置。
+- 运行监测后端 `MonitoringServiceImpl` 已有 `constrainToMonitoringTypes` 按流量/压力/漏失子类型过滤,三个子页面只会取对应类型设备;前端继续沿用该接口。
+- 验证:相关 SFC 解析通过;`npm run build:prod` 退出码 0。

+ 2 - 37
src/layout/components/Sidebar/index.vue

@@ -33,6 +33,7 @@ import variables from '@/assets/styles/variables.module.scss'
 import useAppStore from '@/store/modules/app'
 import useSettingsStore from '@/store/modules/settings'
 import usePermissionStore from '@/store/modules/permission'
+import { selectSidebarRoutes } from '@/router/sidebarRouteUtils'
 
 const route = useRoute()
 const appStore = useAppStore()
@@ -52,44 +53,8 @@ const activeMenu = computed(() => {
   return path
 })
 
-const legacySubSystemPrefixes = ['/pipeNetwork']
-
-function isSubSystemPath(path) {
-  return path.startsWith('/subSystem/') || legacySubSystemPrefixes.some(prefix => path === prefix || path.startsWith(`${prefix}/`))
-}
-
-function getCurrentSystemPrefix(path) {
-  if (path.startsWith('/subSystem/')) {
-    return path.split('/').slice(0, 3).join('/')
-  }
-  return legacySubSystemPrefixes.find(prefix => path === prefix || path.startsWith(`${prefix}/`)) || ''
-}
-
 const showRoutes = computed(() => {
-  const currentPath = route.path
-  const allRoutes = permissionStore.sidebarRouters
-
-  if (currentPath.startsWith('/portal')) {
-    return []
-  }
-
-  const isAdminPage =
-    currentPath.startsWith('/index') ||
-    currentPath.startsWith('/system') ||
-    currentPath.startsWith('/log') ||
-    currentPath.startsWith('/monitor') ||
-    !isSubSystemPath(currentPath)
-
-  if (isAdminPage) {
-    return allRoutes.filter(item => !isSubSystemPath(item.path))
-  }
-
-  if (isSubSystemPath(currentPath)) {
-    const currentSystemPrefix = getCurrentSystemPrefix(currentPath)
-    return allRoutes.filter(item => currentSystemPrefix && item.path.startsWith(currentSystemPrefix))
-  }
-
-  return []
+  return selectSidebarRoutes(route.path, permissionStore.sidebarRouters)
 })
 
 </script>

+ 3 - 2
src/layout/components/TagsView/index.vue

@@ -56,6 +56,7 @@ import { getNormalPath } from '@/utils/ruoyi'
 import useTagsViewStore from '@/store/modules/tagsView'
 import useSettingsStore from '@/store/modules/settings'
 import usePermissionStore from '@/store/modules/permission'
+import { getSubsystemHomePath } from '@/router/sidebarRouteUtils'
 import { useRoute, useRouter } from 'vue-router'
 import { computed, watch, ref, nextTick, onMounted, getCurrentInstance } from 'vue'
 
@@ -267,7 +268,7 @@ function toLastView(visitedViews, view) {
     } else if (prefix === 'drainage') {
       router.push('/drainage/index')
     } else if (prefix === 'waterSupply') {
-      router.push('/waterSupply/index')
+      router.push(getSubsystemHomePath(prefix))
     } else if (prefix === 'gas') {
       router.push('/gas/index')
     } else if (prefix === 'pipeNetwork') {
@@ -428,4 +429,4 @@ onMounted(() => {
     }
   }
 }
-</style>
+</style>

+ 14 - 13
src/router/index.js

@@ -1,9 +1,9 @@
 import { createWebHistory, createRouter } from 'vue-router'
 /* Layout */
 import Layout from '@/layout'
+import { mergeRouteRecords } from './routeUtils'
 // import manholeCoverRouter from './modules/manholeCoverRouter.js'
 // import drainageRouters from './modules/drainageRouter'
-// import waterSupplyRouters from './modules/waterSupplyRouter'
 // import gasRouters from './modules/gasRouter'
 // import pipeNetworkRouters from './modules/pipeNetworkRouter'
 // import dispatchRouters from './modules/dispatchRouter'
@@ -33,7 +33,8 @@ import Layout from '@/layout'
  */
 
 // 公共路由
-export const constantRoutes = [
+export const constantRoutes = mergeRouteRecords(
+  [
   {
     path: '/redirect',
     component: Layout,
@@ -103,16 +104,16 @@ export const constantRoutes = [
     ]
   },
 
-  // 子系统后台路由
-  // manholeCoverRouter,
-  // drainageRouters,
-  // waterSupplyRouters,
-  // gasRouters,
-  // pipeNetworkRouters,
-  // dispatchRouters,
-  // lifeCompanyRouters,
-  // cityLifelineRouters
-]
+    // 子系统后台路由
+    // manholeCoverRouter,
+    // drainageRouters,
+    // gasRouters,
+    // pipeNetworkRouters,
+    // dispatchRouters,
+    // lifeCompanyRouters,
+    // cityLifelineRouters
+  ]
+)
 
 // 动态路由,基于用户权限动态去加载
 export const dynamicRoutes = [
@@ -200,4 +201,4 @@ const router = createRouter({
   },
 });
 
-export default router;
+export default router;

+ 34 - 0
src/router/modules/waterSupplyRouter.js

@@ -0,0 +1,34 @@
+import Layout from '@/layout'
+
+// 供水管网安全运行监测系统 - 前端静态菜单
+// 说明:如果后端动态菜单已配置,可移除本文件或保留为兜底入口。
+export default [
+  {
+    path: '/waterSupply',
+    component: Layout,
+    redirect: '/waterSupply/facility/gis',
+    name: 'WaterSupply',
+    alwaysShow: true,
+    meta: { title: '供水管网安全运行监测系统', icon: 'example' },
+    children: [
+      {
+        path: 'facility/gis',
+        component: () => import('@/views/subSystem/waterSupply/facility/gis.vue'),
+        name: 'WaterSupplyFacilityGis',
+        meta: { title: '供水设施GIS一张图', icon: 'map' }
+      },
+      {
+        path: 'facility/statistics',
+        component: () => import('@/views/subSystem/waterSupply/facility/statistics.vue'),
+        name: 'WaterSupplyFacilityStatistics',
+        meta: { title: '管网数据统计分析', icon: 'chart' }
+      },
+      {
+        path: 'monitorAlarm/workOrder',
+        component: () => import('@/views/subSystem/waterSupply/monitorAlarm/workOrder.vue'),
+        name: 'WaterSupplyMonitorAlarmWorkOrder',
+        meta: { title: '报警工单处置', icon: 'work-order' }
+      }
+    ]
+  }
+]

+ 31 - 0
src/router/routeNameUtils.js

@@ -0,0 +1,31 @@
+function joinRoutePath(parentPath = '', routePath = '') {
+  const joined = `${parentPath}/${routePath}`.replace(/\/+/g, '/')
+  return joined.startsWith('/') ? joined : `/${joined}`
+}
+
+function routeNameFromPath(path) {
+  return path
+    .split('/')
+    .filter(Boolean)
+    .map(segment => segment.replace(/[^a-zA-Z0-9_$-]/g, '').replace(/(^|[-_$])([a-zA-Z0-9])/g, (_, __, c) => c.toUpperCase()))
+    .join('') || 'Root'
+}
+
+/**
+ * Vue Router names are global. Backend menu names are generated from the
+ * relative leaf path, so common leaves such as index and gis collide across
+ * subsystems. Give subsystem routes stable names derived from their absolute
+ * route path before registering them.
+ */
+export function ensureUniqueSubsystemRouteNames(routes = [], parentPath = '') {
+  routes.forEach(route => {
+    const absolutePath = joinRoutePath(parentPath, route.path)
+    if (absolutePath.startsWith('/subSystem/')) {
+      route.name = routeNameFromPath(absolutePath)
+    }
+    if (Array.isArray(route.children) && route.children.length) {
+      ensureUniqueSubsystemRouteNames(route.children, absolutePath)
+    }
+  })
+  return routes
+}

+ 11 - 0
src/router/routeUtils.js

@@ -0,0 +1,11 @@
+/**
+ * Merge route groups into the flat array expected by vue-router.
+ *
+ * Route modules are commonly exported as arrays so they can contain one or
+ * more route records. Passing one of those arrays directly to the router
+ * creates an invalid top-level route entry and can make the sidebar crash
+ * while reading `route.path`.
+ */
+export function mergeRouteRecords(...routeGroups) {
+  return routeGroups.flat()
+}

+ 32 - 0
src/router/sidebarRouteUtils.js

@@ -0,0 +1,32 @@
+const legacySubSystemPrefixes = ['/pipeNetwork', '/waterSupply']
+
+export function isSubSystemPath(path = '') {
+  return path.startsWith('/subSystem/') || legacySubSystemPrefixes.some(prefix => path === prefix || path.startsWith(`${prefix}/`))
+}
+
+export function getCurrentSystemPrefix(path = '') {
+  if (path.startsWith('/subSystem/')) {
+    return path.split('/').slice(0, 3).join('/')
+  }
+  return legacySubSystemPrefixes.find(prefix => path === prefix || path.startsWith(`${prefix}/`)) || ''
+}
+
+export function selectSidebarRoutes(currentPath, allRoutes = []) {
+  if (currentPath.startsWith('/portal')) {
+    return []
+  }
+
+  if (isSubSystemPath(currentPath)) {
+    const currentSystemPrefix = getCurrentSystemPrefix(currentPath)
+    return allRoutes.filter(item => currentSystemPrefix && item.path.startsWith(currentSystemPrefix))
+  }
+
+  return allRoutes.filter(item => !isSubSystemPath(item.path))
+}
+
+export function getSubsystemHomePath(prefix) {
+  if (prefix === 'waterSupply') {
+    return '/subSystem/waterSupply/index'
+  }
+  return `/${prefix}/index`
+}

+ 2 - 1
src/store/modules/permission.js

@@ -4,6 +4,7 @@ import { getRouters } from '@/api/menu'
 import Layout from '@/layout/index'
 import ParentView from '@/components/ParentView'
 import InnerLink from '@/layout/components/InnerLink'
+import { ensureUniqueSubsystemRouteNames } from '@/router/routeNameUtils'
 
 // 匹配views里面所有的.vue文件
 const modules = import.meta.glob('./../../views/**/*.vue')
@@ -40,7 +41,7 @@ const usePermissionStore = defineStore(
             const rdata = JSON.parse(JSON.stringify(res.data))
             const defaultData = JSON.parse(JSON.stringify(res.data))
             const sidebarRoutes = filterAsyncRouter(sdata)
-            const rewriteRoutes = filterAsyncRouter(rdata, false, true)
+            const rewriteRoutes = ensureUniqueSubsystemRouteNames(filterAsyncRouter(rdata, false, true))
             const defaultRoutes = filterAsyncRouter(defaultData)
             const asyncRoutes = filterDynamicRoutes(dynamicRoutes)
             asyncRoutes.forEach(route => { router.addRoute(route) })

+ 64 - 0
src/utils/waterSupplyModel.js

@@ -0,0 +1,64 @@
+export function unwrapResponse(response) {
+  return response && Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response
+}
+
+export function normalizePage(response) {
+  const page = unwrapResponse(response) || {}
+  const records = page.records || page.rows || (Array.isArray(page) ? page : [])
+  return {
+    records: Array.isArray(records) ? records : [],
+    total: Number(page.total ?? records.length) || 0,
+    current: Number(page.current ?? page.pageNum ?? 1) || 1,
+    size: Number(page.size ?? page.pageSize ?? (records.length || 10)) || 10
+  }
+}
+
+export function normalizeMonitoringRecord(record = {}, metric = 'value') {
+  const metricValue = metric === 'flow'
+    ? record.instantFlow
+    : metric === 'pressure'
+      ? record.pressureValue
+      : undefined
+  const value = record[metric] ?? metricValue ?? record.currentValue ?? record.value ?? record.monitorValue
+  return {
+    ...record,
+    id: record.id ?? record.equipmentId ?? record.pointId,
+    equipmentName: record.equipmentName ?? record.name ?? record.deviceName ?? '未命名设备',
+    equipmentCode: record.equipmentCode ?? record.deviceCode ?? record.code ?? '-',
+    equipmentTypeName: record.equipmentTypeName ?? record.typeName ?? record.deviceType ?? record.equipmentType ?? '-',
+    location: record.equipmentLocation ?? record.location ?? record.address ?? '-',
+    value: value == null || value === '' ? null : Number(value),
+    unit: record.unit ?? (metric === 'pressure' ? 'MPa' : metric === 'power' ? 'kW' : 'm³/h'),
+    status: record.status ?? record.onlineStatus ?? record.alarmStatus ?? 0,
+    updateTime: record.updateTime ?? record.monitorTime ?? record.createTime ?? '-'
+  }
+}
+
+export function normalizeGeoJson(response) {
+  const data = unwrapResponse(response)
+  if (data && data.type === 'FeatureCollection') return data
+  if (Array.isArray(data)) return { type: 'FeatureCollection', features: data }
+  return { type: 'FeatureCollection', features: [] }
+}
+
+export function normalizeMapPoints(points = []) {
+  const valid = (Array.isArray(points) ? points : []).map(point => ({
+    ...point,
+    _longitude: Number(point.longitude ?? point.lng),
+    _latitude: Number(point.latitude ?? point.lat)
+  })).filter(point => Number.isFinite(point._longitude) && Number.isFinite(point._latitude))
+  if (!valid.length) return []
+  const longitudes = valid.map(point => point._longitude)
+  const latitudes = valid.map(point => point._latitude)
+  const minLongitude = Math.min(...longitudes)
+  const maxLongitude = Math.max(...longitudes)
+  const minLatitude = Math.min(...latitudes)
+  const maxLatitude = Math.max(...latitudes)
+  const longitudeSpan = maxLongitude - minLongitude
+  const latitudeSpan = maxLatitude - minLatitude
+  return valid.map(({ _longitude, _latitude, ...point }) => ({
+    ...point,
+    mapX: longitudeSpan ? Math.round((6 + ((_longitude - minLongitude) / longitudeSpan) * 88) * 100) / 100 : 50,
+    mapY: latitudeSpan ? Math.round((90 - ((_latitude - minLatitude) / latitudeSpan) * 82) * 100) / 100 : 50
+  }))
+}

+ 53 - 0
src/utils/waterSupplyUiModel.js

@@ -0,0 +1,53 @@
+const alarmLevelLabels = {
+  1: '一级报警',
+  2: '二级报警',
+  3: '三级报警',
+  4: '四级报警'
+}
+
+const workOrderStatusLabels = {
+  1: '待派单',
+  2: '待接单',
+  3: '已接单',
+  4: '处理中',
+  5: '待验收',
+  6: '已结案',
+  7: '已驳回',
+  8: '已取消'
+}
+
+export function normalizeDeviceRow(row = {}) {
+  const alarmStatus = Number(row.alarmStatus ?? row.status ?? 0)
+  const alarmLevel = Number(row.alertLevel ?? row.alarmLevel ?? 0)
+  const relatedOrderNo = row.latestOrderNo ?? row.orderNo ?? row.latestMaintainId ?? '-'
+  const rawOrderStatus = row.latestMaintainStatus ?? row.orderStatus ?? row.statusText
+
+  return {
+    ...row,
+    deviceStatusText: alarmStatus === 1 ? '报警' : '正常',
+    alarmLevelText: alarmLevelLabels[alarmLevel] ?? (alarmLevel ? `${alarmLevel}级报警` : '未分级'),
+    relatedOrderNo,
+    workOrderStatusText: typeof rawOrderStatus === 'number'
+      ? (workOrderStatusLabels[rawOrderStatus] ?? '未知')
+      : (rawOrderStatus || '暂无工单')
+  }
+}
+
+export function buildMaintainOrderPayload(device = {}, form = {}) {
+  const payload = {
+    equipmentId: device.equipmentId ?? device.id,
+    equipmentCode: device.equipmentCode,
+    equipmentName: device.equipmentName,
+    maintainType: '故障维修',
+    maintainContent: form.description?.trim() || '异常设备维修派单'
+  }
+
+  if (form.receiveUserId != null && form.receiveUserId !== '') {
+    payload.receiveUserId = Number(form.receiveUserId)
+  }
+  return payload
+}
+
+export function getDeviceOrderEndpoint(view) {
+  return view === 'dispatch' ? '/waterSupply/device/abnormal/order' : null
+}

+ 2 - 2
src/views/subSystem/basic/pipeInfo/index.vue

@@ -5,7 +5,7 @@
       <el-button type="primary" link size="small" @click="retryRequest">重试</el-button>
     </div>
     <!-- 搜索区域 -->
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="68px">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="78px">
       <el-form-item label="管网编号" prop="pipeCode">
         <el-input v-model="queryParams.pipeCode" placeholder="请输入管网编号" clearable size="small" @keyup.enter="handleQuery" />
       </el-form-item>
@@ -111,7 +111,7 @@
 
     <!-- 新增/修改对话框 -->
     <el-dialog :title="dialogTitle" v-model="dialogVisible" width="700px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="100px">
+      <el-form ref="form" :model="form" :rules="rules" label-width="108px">
         <el-row :gutter="20">
           <el-col :span="12">
             <el-form-item label="管网编号" prop="pipeCode">

+ 1 - 1
src/views/subSystem/basic/waterUserInfo/index.vue

@@ -5,7 +5,7 @@
       <el-button type="primary" link size="small" @click="retryRequest">重试</el-button>
     </div>
     <!-- 搜索区域 -->
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="88px">
       <el-form-item label="用水户编号" prop="userCode">
         <el-input v-model="queryParams.userCode" placeholder="请输入用水户编号" clearable size="small" @keyup.enter="handleQuery" />
       </el-form-item>

Разница между файлами не показана из-за своего большого размера
+ 7 - 0
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue


+ 78 - 0
src/views/subSystem/waterSupply/components/WaterDevicePage.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="water-page device-page">
+    <div class="page-heading">
+      <div class="page-title">
+        <span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span>
+        <div><h2>{{ title }}</h2><span class="muted">{{ subtitle }}</span></div>
+      </div>
+      <el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button>
+    </div>
+
+    <el-card shadow="never" class="filter-card">
+      <el-form :inline="true" :model="query">
+        <el-form-item label="设备名称"><el-input v-model="query.equipmentName" clearable /></el-form-item>
+        <el-form-item label="设备编码"><el-input v-model="query.equipmentCode" clearable /></el-form-item>
+        <el-form-item v-if="view !== 'maintain'" label="报警状态"><el-select v-model="query.alarmStatus" clearable><el-option label="正常" :value="0" /><el-option label="报警" :value="1" /></el-select></el-form-item>
+        <el-form-item v-if="view !== 'maintain'" label="报警等级"><el-select v-model="query.alertLevel" clearable><el-option v-for="item in levels" :key="item.value" v-bind="item" /></el-select></el-form-item>
+        <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
+      </el-form>
+    </el-card>
+
+    <el-card shadow="never" class="water-panel">
+      <div class="card-title"><span><el-icon><Monitor /></el-icon> {{ title }}清单</span><el-button link type="primary" @click="exportData">导出</el-button></div>
+      <el-table :data="page.records" border stripe v-loading="loading" row-key="equipmentId">
+        <el-table-column type="index" label="#" width="55" /><el-table-column prop="equipmentCode" label="设备编码" min-width="130" /><el-table-column prop="equipmentName" label="设备名称" min-width="150" /><el-table-column prop="equipmentTypeName" label="设备类型" min-width="120" /><el-table-column prop="equipmentLocation" label="安装位置" min-width="180" show-overflow-tooltip />
+        <el-table-column v-if="view !== 'maintain'" label="设备状态" width="100"><template #default="{ row }"><el-tag :type="Number(row.alarmStatus) === 1 ? 'danger' : 'success'">{{ row.deviceStatusText }}</el-tag></template></el-table-column>
+        <el-table-column v-if="view !== 'maintain'" label="报警等级" width="110"><template #default="{ row }"><el-tag :type="alarmLevelType(row.alertLevel)">{{ row.alarmLevelText }}</el-tag></template></el-table-column>
+        <el-table-column label="关联维修单" min-width="190" show-overflow-tooltip><template #default="{ row }">{{ view === 'maintain' ? (row.orderNo || '-') : row.relatedOrderNo }}</template></el-table-column>
+        <el-table-column label="维修状态" min-width="110"><template #default="{ row }">{{ view === 'maintain' ? (row.status || '暂无') : row.workOrderStatusText }}</template></el-table-column>
+        <el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="selectPoint(row)">详情</el-button><el-button v-if="view === 'dispatch'" link type="warning" @click="openOrder(row)">维修派单</el-button><el-button v-if="view === 'maintain'" link type="danger" @click="removeRecord(row)">删除</el-button></template></el-table-column>
+      </el-table>
+      <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData" />
+    </el-card>
+
+    <el-drawer v-model="detailVisible" class="device-detail-drawer" title="设备详情" direction="rtl" size="680px" append-to-body>
+      <div class="detail-summary"><span class="detail-name">{{ detail.equipmentName || '-' }}</span><el-tag :type="Number(detail.alarmStatus) === 1 ? 'danger' : 'success'">{{ detail.deviceStatusText || '-' }}</el-tag></div>
+      <el-descriptions :column="2" border>
+        <el-descriptions-item v-for="item in detailFields" :key="item.label" :label="item.label">{{ item.value }}</el-descriptions-item>
+      </el-descriptions>
+      <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
+    </el-drawer>
+    <el-dialog v-model="dispatchVisible" title="设备维修派单" width="600px"><el-form :model="dispatchForm" label-width="100px"><el-form-item label="设备"><span>{{ dispatchDevice.equipmentName }}({{ dispatchDevice.equipmentCode }})</span></el-form-item><el-form-item label="问题描述" required><el-input v-model="dispatchForm.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入故障或异常描述" /></el-form-item><el-form-item label="接单责任人" required><el-select v-model="dispatchForm.receiveUserId" filterable clearable placeholder="请选择责任人" :loading="userLoading" style="width:100%"><el-option v-for="user in users" :key="user.userId" :label="user.nickName || user.userName" :value="user.userId" /></el-select></el-form-item></el-form><template #footer><el-button @click="dispatchVisible = false">取消</el-button><el-button type="primary" :loading="dispatching" @click="submitDispatch">确认派单</el-button></template></el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Monitor, Refresh } from '@element-plus/icons-vue'
+import { listUser } from '@/api/system/user'
+import { createAbnormalOrder, exportAbnormalDevices, exportDeviceLedger, listAbnormalDevices, listDeviceLedger, listMaintainRecords, deleteMaintainRecord } from '@/api/pipeNetwork/waterSupply'
+import { normalizePage } from '@/utils/waterSupplyModel'
+import { buildMaintainOrderPayload, normalizeDeviceRow } from '@/utils/waterSupplyUiModel'
+
+const props = defineProps({ title: { type: String, required: true }, subtitle: { type: String, default: '设备信息、运行状态与维护过程管理' }, view: { type: String, default: 'ledger' } })
+const { title, subtitle, view } = props
+const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined })
+const page = ref({ records: [], total: 0 }); const loading = ref(false); const detailVisible = ref(false); const detail = ref({}); const dispatchVisible = ref(false); const dispatching = ref(false); const dispatchDevice = ref({}); const dispatchForm = ref({ description: '', receiveUserId: undefined }); const users = ref([]); const userLoading = ref(false)
+const levels = [{ label: '一级', value: 1 }, { label: '二级', value: 2 }, { label: '三级', value: 3 }, { label: '四级', value: 4 }]; const pageIcon = Monitor; const alarmLevelType = level => ({ 1: 'danger', 2: 'warning', 3: 'primary', 4: 'success' }[Number(level)] || 'info')
+const detailFields = computed(() => [['设备编码', detail.value.equipmentCode], ['设备类型', detail.value.equipmentTypeName || detail.value.typeName], ['安装位置', detail.value.equipmentLocation], ['设备状态', detail.value.deviceStatusText], ['报警等级', detail.value.alarmLevelText], ['在线状态', Number(detail.value.onlineStatus) === 1 ? '在线' : '离线'], ['关联维修单', detail.value.relatedOrderNo || detail.value.orderNo], ['维修状态', detail.value.workOrderStatusText || detail.value.status], ['状态更新时间', detail.value.statusUpdateTime]].map(([label, value]) => ({ label, value: value ?? '-' })))
+
+async function loadData() { loading.value = true; try { let response; if (view === 'maintain') response = await listMaintainRecords({ ...query.value }); else if (view === 'dispatch' || view === 'abnormal') response = await listAbnormalDevices({ ...query.value }); else response = await listDeviceLedger({ ...query.value }); const normalized = normalizePage(response); page.value = { ...normalized, records: normalized.records.map(normalizeDeviceRow) } } catch (error) { page.value = { records: [], total: 0 }; console.warn(`[${title}] 数据加载失败`, error) } finally { loading.value = false } }
+function reset() { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', alarmStatus: undefined, alertLevel: undefined }; loadData() }
+function selectPoint(row) { detail.value = normalizeDeviceRow(row); detailVisible.value = true }
+async function loadUsers() { if (users.value.length) return; userLoading.value = true; try { const response = await listUser({ pageNum: 1, pageSize: 200 }); users.value = response.rows || response.data?.rows || [] } catch (error) { ElMessage.error('责任人列表加载失败') } finally { userLoading.value = false } }
+async function openOrder(row) { dispatchDevice.value = row; dispatchForm.value = { description: row.abnormalDescription || row.description || '', receiveUserId: undefined }; await loadUsers(); dispatchVisible.value = true }
+async function submitDispatch() { if (!dispatchForm.value.description?.trim()) return ElMessage.warning('请填写问题描述'); if (!dispatchForm.value.receiveUserId) return ElMessage.warning('请选择接单责任人'); dispatching.value = true; try { await createAbnormalOrder(buildMaintainOrderPayload(dispatchDevice.value, dispatchForm.value)); ElMessage.success('维修派单已提交'); dispatchVisible.value = false; await loadData() } catch (error) { ElMessage.error('派单失败,请检查权限或服务状态') } finally { dispatching.value = false } }
+async function removeRecord(row) { try { await deleteMaintainRecord(row.maintainId || row.id); ElMessage.success('记录已删除'); await loadData() } catch (error) { ElMessage.error('删除失败') } }
+async function exportData() { try { await (view === 'dispatch' || view === 'abnormal' ? exportAbnormalDevices : exportDeviceLedger)({ ...query.value }); ElMessage.success('导出请求已提交') } catch (error) { ElMessage.error('导出失败') } }
+onMounted(loadData)
+</script>
+
+<style scoped>
+.device-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-title{display:flex;gap:12px;align-items:center}.page-title h2{margin:0 0 5px;font-size:20px}.page-title-icon{display:grid;place-items:center;width:38px;height:38px;color:#2563eb;background:#eaf2ff;border-radius:8px}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.card-title{display:flex;justify-content:space-between;align-items:center;font-weight:600;margin-bottom:14px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px}.detail-name{font-size:18px;font-weight:700;color:#17324d}.device-detail-drawer :deep(.el-drawer__body){overflow:auto}.device-detail-drawer :deep(.el-descriptions__label){white-space:nowrap}.device-detail-drawer :deep(.el-descriptions__content){word-break:break-word;overflow-wrap:anywhere}
+</style>
+<style lang="scss">
+@use './waterSupplyPageTheme.scss';
+@media(max-width:768px){.device-detail-drawer{width:100%!important}}
+</style>

Разница между файлами не показана из-за своего большого размера
+ 154 - 0
src/views/subSystem/waterSupply/components/WaterFacilityDashboard.vue


+ 80 - 0
src/views/subSystem/waterSupply/components/WaterMonitoringPage.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="water-page">
+    <div class="page-heading">
+      <div class="page-title"><span class="page-title-icon"><el-icon><component :is="pageIcon" /></el-icon></span><div><h2>{{ title }}</h2><span class="muted">实时采集点位与近24小时趋势</span></div></div>
+      <el-button :icon="Refresh" @click="loadData">刷新</el-button>
+    </div>
+    <el-card shadow="never" class="filter-card">
+      <el-form :inline="true" :model="query" @submit.prevent="loadData">
+        <el-form-item label="设备名称"><el-input v-model="query.equipmentName" clearable placeholder="请输入设备名称" /></el-form-item>
+        <el-form-item label="设备编码"><el-input v-model="query.equipmentCode" clearable placeholder="请输入设备编码" /></el-form-item>
+        <el-form-item label="安装位置"><el-input v-model="query.equipmentLocation" clearable placeholder="请输入位置" /></el-form-item>
+        <el-form-item :label="rangeLabel"><el-input-number v-model="query[minKey]" :min="0" controls-position="right" /><span class="range-sep">至</span><el-input-number v-model="query[maxKey]" :min="0" controls-position="right" /></el-form-item>
+        <el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
+      </el-form>
+    </el-card>
+    <div class="metric-grid">
+      <div class="metric"><el-icon class="metric-icon"><Monitor /></el-icon><span>监测点位</span><strong>{{ page.total }}</strong><small>个</small></div>
+      <div class="metric"><el-icon class="metric-icon"><DataAnalysis /></el-icon><span>当前均值</span><strong>{{ averageValue }}</strong><small>{{ unit }}</small></div>
+      <div class="metric"><el-icon class="metric-icon"><Warning /></el-icon><span>异常点位</span><strong class="danger">{{ abnormalCount }}</strong><small>个</small></div>
+      <div class="metric"><el-icon class="metric-icon"><Clock /></el-icon><span>数据更新时间</span><strong class="time">{{ latestTime }}</strong></div>
+    </div>
+    <el-card shadow="never" class="table-card">
+      <div class="card-title"><span>{{ title }}点位清单</span><el-button link type="primary" @click="exportData">批量导出</el-button></div>
+      <el-table :data="page.records" stripe border v-loading="loading" @row-click="showTrend">
+        <el-table-column type="index" label="#" width="55" />
+        <el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
+        <el-table-column prop="equipmentName" label="设备名称" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="equipmentTypeName" label="设备子类型" min-width="145" show-overflow-tooltip />
+        <el-table-column prop="location" label="安装位置" min-width="180" show-overflow-tooltip />
+        <el-table-column :label="`${metricLabel}(${unit})`" min-width="130">
+          <template #default="{ row }"><span :class="{ danger: row.value == null || row.status === 1 || row.status === '1' }">{{ row.value ?? '-' }}</span></template>
+        </el-table-column>
+        <el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag></template></el-table-column>
+        <el-table-column prop="updateTime" label="采集时间" min-width="165" />
+        <el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click.stop="showTrend(row)">趋势</el-button></template></el-table-column>
+      </el-table>
+      <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="page.total" layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" @change="loadData" />
+    </el-card>
+    <el-drawer v-model="trendVisible" :title="`${current?.equipmentName || ''} - ${title}趋势`" size="520px">
+      <div ref="chartRef" class="trend-chart"></div>
+      <el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备编码">{{ current.equipmentCode }}</el-descriptions-item><el-descriptions-item label="安装位置">{{ current.location }}</el-descriptions-item><el-descriptions-item label="当前值">{{ current.value ?? '-' }} {{ current.unit }}</el-descriptions-item></el-descriptions>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onMounted, ref } from 'vue'
+import { Clock, DataAnalysis, Monitor, Refresh, Warning } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import * as echarts from 'echarts'
+import { normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+
+const props = defineProps({ title: { type: String, required: true }, metricLabel: { type: String, required: true }, unit: { type: String, default: 'm³/h' }, minKey: { type: String, default: 'flowMin' }, maxKey: { type: String, default: 'flowMax' }, rangeLabel: { type: String, default: '数值范围' }, load: { type: Function, required: true }, trend: { type: Function, required: true }, exportApi: { type: Function, required: true }, metric: { type: String, default: 'value' }, monitorType: { type: String, default: '' } })
+const pageIcon = computed(() => props.metric === 'pressure' ? Warning : props.metric === 'power' ? DataAnalysis : Monitor)
+const query = ref({ pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined })
+const page = ref({ records: [], total: 0 })
+const loading = ref(false)
+const current = ref(null)
+const trendVisible = ref(false)
+const chartRef = ref(null)
+let chart
+const loadData = async () => { loading.value = true; try { const response = await props.load({ ...query.value }); const normalized = normalizePage(response); normalized.records = normalized.records.map(row => normalizeMonitoringRecord(row, props.metric)); page.value = normalized } catch (error) { page.value = { records: [], total: 0 }; console.warn(`[${props.title}] 数据加载失败`, error) } finally { loading.value = false } }
+const resetQuery = () => { query.value = { pageNum: 1, pageSize: 10, equipmentName: '', equipmentCode: '', equipmentLocation: '', [props.minKey]: undefined, [props.maxKey]: undefined }; loadData() }
+const averageValue = computed(() => { const values = page.value.records.map(item => item.value).filter(value => Number.isFinite(value)); return values.length ? (values.reduce((a, b) => a + b, 0) / values.length).toFixed(2) : '-' })
+const abnormalCount = computed(() => page.value.records.filter(item => item.status === 1 || item.status === '1' || item.value == null).length)
+const latestTime = computed(() => page.value.records.map(item => item.updateTime).filter(Boolean).sort().at(-1) || '-')
+const statusText = row => row.status === 1 || row.status === '1' ? '异常' : row.status === 0 || row.status === '0' ? '正常' : row.status ? '在线' : '未知'
+const statusType = row => statusText(row) === '异常' ? 'danger' : statusText(row) === '正常' || statusText(row) === '在线' ? 'success' : 'info'
+const showTrend = async row => { current.value = row; trendVisible.value = true; await nextTick(); const response = await props.trend(row.id); const data = unwrapResponse(response); const records = Array.isArray(data) ? data : data?.records || []; const labels = records.map(item => item.time || item.monitorTime || item.createTime || ''); const values = records.map(item => normalizeMonitoringRecord(item, props.metric).value ?? 0); chart?.dispose(); chart = echarts.init(chartRef.value); chart.setOption({ tooltip: { trigger: 'axis' }, grid: { left: 40, right: 18, top: 20, bottom: 35 }, xAxis: { type: 'category', data: labels }, yAxis: { type: 'value', name: props.unit }, series: [{ type: 'line', smooth: true, data: values, areaStyle: { opacity: 0.12 }, itemStyle: { color: '#2563eb' } }] }) }
+const exportData = async () => { try { await props.exportApi({ ...query.value }); ElMessage.success('导出请求已提交') } catch (error) { ElMessage.warning('当前接口暂不支持文件流导出') } }
+onMounted(loadData)
+</script>
+
+<style lang="scss">
+@use './waterSupplyPageTheme.scss';
+</style>
+
+<style scoped>
+.water-page{padding:20px;background:#f5f7fa;min-height:calc(100vh - 84px)}.page-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-heading h2{margin:0 0 5px;font-size:20px;color:#1f2937}.muted{font-size:13px;color:#909399}.filter-card{margin-bottom:16px}.filter-card :deep(.el-form-item){margin-bottom:0}.range-sep{margin:0 8px;color:#909399}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.metric{background:#fff;border:1px solid #ebeef5;padding:16px 18px;min-height:70px}.metric span{display:block;color:#909399;font-size:13px}.metric strong{font-size:24px;line-height:35px;color:#1f2937}.metric small{margin-left:5px;color:#909399}.metric .time{font-size:16px}.danger{color:#e45656!important}.table-card{min-height:420px}.card-title{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:14px}.table-card :deep(.el-pagination){justify-content:flex-end;margin-top:16px}.trend-chart{height:300px;margin-bottom:20px}@media(max-width:900px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-card :deep(.el-form-item){margin-bottom:12px}}
+</style>

+ 1376 - 0
src/views/subSystem/waterSupply/components/WaterSupplyHome.vue

@@ -0,0 +1,1376 @@
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
+import * as echarts from 'echarts'
+import { Odometer, CircleCheckFilled, VideoCameraFilled, WarnTriangleFilled } from '@element-plus/icons-vue'
+import { convertPipeline, convertPoints } from '@/utils/coordTransform'
+import {
+  getAlarmStats,
+  getWaterFacilityFeatures,
+  getWaterFacilityOverview,
+  getDeviceGisAllPoints,
+  getDeviceGisStatistics,
+  listRealtimeAlarms,
+  listDeviceLedger,
+  listFlowMonitoring,
+  listPressureMonitoring,
+  listLeakageMonitoring
+} from '@/api/pipeNetwork/waterSupply'
+import { normalizeGeoJson, normalizeMonitoringRecord, normalizePage, unwrapResponse } from '@/utils/waterSupplyModel'
+
+const nowText = ref('')
+const weekText = ref('')
+const mapError = ref('')
+const loading = ref(false)
+
+const dashboardData = ref({})
+const pipelineLines = ref([])
+const pipePoints = ref([])
+
+const facilityTypes = [
+  { label: '水源地', value: 'source', color: '#38a3ff' },
+  { label: '水厂', value: 'plant', color: '#38d0a0' },
+  { label: '泵站', value: 'pumpStation', color: '#ffc15e' },
+  { label: '管网', value: 'pipe', color: '#7b9bb8' },
+  { label: '用水户', value: 'user', color: '#ff7894' }
+]
+
+const typeCount = type => {
+  const row = (dashboardData.value.facilityOverview || []).find(item => item.facilityType === type)
+  return Number(row?.totalCount ?? row?.count ?? 0)
+}
+
+const baseCards = computed(() => {
+  const fs = dashboardData.value.facilityOverview || []
+  const es = dashboardData.value.equipmentStats || {}
+  const alarm = dashboardData.value.alarmStats || {}
+  const totalFacility = fs.reduce((sum, item) => sum + Number(item.totalCount || 0), 0)
+  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
+  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
+  const realtimeAlarm = Number(alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0)
+  return [
+    { label: '设施总数', value: totalFacility, unit: '处', icon: Odometer, color: '#60b8ff' },
+    { label: '设备总数', value: totalDevice, unit: '台', icon: CircleCheckFilled, color: '#52e0a8' },
+    { label: '在线设备', value: onlineDevice, unit: '台', icon: VideoCameraFilled, color: '#ff8e8e' },
+    { label: '实时报警', value: realtimeAlarm, unit: '条', icon: WarnTriangleFilled, color: '#ffb54d' }
+  ]
+})
+
+const operationSummary = computed(() => {
+  return [
+    { label: '供水管网', value: typeCount('pipe') + '条' },
+    { label: '水厂', value: typeCount('plant') + '座' },
+    { label: '泵站', value: typeCount('pumpStation') + '座' }
+  ]
+})
+
+const repairRecords = computed(() => {
+  const list = dashboardData.value.facilityOverview || []
+  if (list.length) {
+    return list.map(item => ({ month: item.facilityTypeName || item.facilityType || '设施', value: Number(item.totalCount || 0) }))
+  }
+  return [
+    { month: '水源地', value: 0 }, { month: '水厂', value: 0 }, { month: '泵站', value: 0 },
+    { month: '管网', value: 0 }, { month: '用水户', value: 0 }
+  ]
+})
+
+const pointRows = computed(() => {
+  const list = dashboardData.value.pointRows || []
+  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', time: '--', value: '--', status: '离线' }]
+})
+
+const equipmentRows = computed(() => {
+  const list = dashboardData.value.equipmentRows || []
+  return list.length > 0 ? list : [{ id: 1, name: '暂无数据', code: '--', location: '--', status: '--' }]
+})
+
+const warningRows = computed(() => {
+  const list = dashboardData.value.warningRows || []
+  return list.length > 0 ? list : [{ level: '中', title: '暂无告警', code: '--', time: '--:--:--', desc: '当前无告警信息', tag: '正常' }]
+})
+
+const hazardSummary = computed(() => {
+  const alarm = dashboardData.value.alarmStats || {}
+  return [
+    { label: '实时报警', value: alarm.realtimeAlarmCount ?? alarm.realtimeCount ?? 0, color: '#ff6d79' },
+    { label: '今日报警', value: alarm.todayAlarmCount ?? alarm.todayCount ?? 0, color: '#ffb54d' },
+    { label: '异常率', value: alarm.deviceAbnormalRate ?? alarm.abnormalRate ?? 0, color: '#f3e64d' }
+  ]
+})
+
+const mapKpis = computed(() => {
+  const es = dashboardData.value.equipmentStats || {}
+  const alarm = dashboardData.value.alarmStats || {}
+  const totalDevice = Number(es.total ?? es.totalDeviceCount ?? 0)
+  const onlineDevice = Number(es.online ?? es.onlineCount ?? 0)
+  return [
+    { label: '设备在线率', value: totalDevice > 0 ? (onlineDevice / totalDevice * 100).toFixed(1) + '%' : '--' },
+    { label: '供水设施', value: dashboardData.value.facilityOverview?.reduce((sum, item) => sum + Number(item.totalCount || 0), 0) || 0 },
+    { label: '今日报警', value: (alarm.todayAlarmCount ?? alarm.todayCount ?? 0) + '条' }
+  ]
+})
+
+const mapWrapRef = ref(null)
+const repairChartRef = ref(null)
+const hazardChartRef = ref(null)
+let mapInstance = null
+let repairChart = null
+let hazardChart = null
+let clockTimer = null
+
+function formatWeek(day) {
+  return ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][day]
+}
+
+function pad(value) {
+  return String(value).padStart(2, '0')
+}
+
+function updateClock() {
+  const now = new Date()
+  nowText.value = `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
+  weekText.value = formatWeek(now.getDay())
+}
+
+function initRepairChart() {
+  if (!repairChartRef.value) return
+  repairChart?.dispose()
+  repairChart = echarts.init(repairChartRef.value)
+  const data = repairRecords.value
+  repairChart.setOption({
+    grid: { left: 30, right: 12, top: 20, bottom: 24, containLabel: true },
+    xAxis: {
+      type: 'category',
+      data: data.map(item => item.month),
+      axisLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.8)' } },
+      axisLabel: { color: '#d2efff' }
+    },
+    yAxis: {
+      type: 'value',
+      splitLine: { lineStyle: { color: 'rgba(91, 167, 219, 0.18)' } },
+      axisLine: { show: false },
+      axisLabel: { color: '#d2efff' }
+    },
+    tooltip: { trigger: 'axis' },
+    series: [{
+      type: 'bar',
+      barWidth: 24,
+      data: data.map(item => item.value),
+      itemStyle: {
+        borderRadius: [6, 6, 0, 0],
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: '#38d8ff' },
+          { offset: 1, color: '#166dff' }
+        ])
+      }
+    }]
+  })
+}
+
+function initHazardChart() {
+  if (!hazardChartRef.value) return
+  hazardChart?.dispose()
+  hazardChart = echarts.init(hazardChartRef.value)
+  const data = hazardSummary.value
+  hazardChart.setOption({
+    grid: { left: 18, right: 18, top: 10, bottom: 10, containLabel: true },
+    xAxis: {
+      type: 'value',
+      splitLine: { show: false },
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { show: false }
+    },
+    yAxis: {
+      type: 'category',
+      data: data.map(item => item.label),
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: '#d2efff', fontSize: 13 }
+    },
+    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+    series: [{
+      type: 'bar',
+      data: data.map(item => ({ value: item.value, itemStyle: { color: item.color, borderRadius: 10 } })),
+      barWidth: 14,
+      label: { show: true, position: 'right', color: '#fff', fontWeight: 'bold' },
+      showBackground: true,
+      backgroundStyle: { color: 'rgba(255,255,255,0.08)', borderRadius: 10 }
+    }]
+  })
+}
+
+function waitForBMapGL() {
+  return new Promise((resolve, reject) => {
+    if (window.BMapGL) {
+      resolve(window.BMapGL)
+      return
+    }
+    let attempts = 0
+    const timer = setInterval(() => {
+      attempts += 1
+      if (window.BMapGL) {
+        clearInterval(timer)
+        resolve(window.BMapGL)
+        return
+      }
+      if (attempts >= 100) {
+        clearInterval(timer)
+        reject(new Error('百度地图加载超时'))
+      }
+    }, 200)
+  })
+}
+
+function addPipeLine(BMapGL, pipe) {
+  const bmapPoints = pipe.points.map(([lng, lat]) => new BMapGL.Point(lng, lat))
+  const polyline = new BMapGL.Polyline(bmapPoints, {
+    strokeColor: pipe.color,
+    strokeWeight: 4,
+    strokeOpacity: 0.95
+  })
+  mapInstance.addOverlay(polyline)
+
+  bmapPoints.forEach((point) => {
+    const ring = new BMapGL.Circle(point, 14, {
+      strokeColor: pipe.color,
+      strokeWeight: 2,
+      strokeOpacity: 0.7,
+      fillColor: 'rgba(56,182,233,0.08)',
+      fillOpacity: 0.18
+    })
+    mapInstance.addOverlay(ring)
+  })
+}
+
+function addPipePoint(BMapGL, point) {
+  const markerPoint = new BMapGL.Point(point.lng, point.lat)
+  const marker = new BMapGL.Marker(markerPoint)
+  mapInstance.addOverlay(marker)
+  const label = new BMapGL.Label(`${point.name} ${point.value}`, { offset: new BMapGL.Size(-42, -40) })
+  label.setPosition(markerPoint)
+  label.setStyle({
+    color: '#d8faff',
+    background: 'rgba(18, 68, 112, 0.75)',
+    border: '1px solid rgba(56, 182, 233, 0.55)',
+    borderRadius: '8px',
+    padding: '5px 8px',
+    fontSize: '12px'
+  })
+  mapInstance.addOverlay(label)
+}
+
+async function initMap() {
+  if (!mapWrapRef.value) return
+  try {
+    const BMapGL = await waitForBMapGL()
+    mapInstance = new BMapGL.Map(mapWrapRef.value, { enableMapClick: true })
+    const center = new BMapGL.Point(110.3965, 28.4638)
+    mapInstance.centerAndZoom(center, 15)
+    mapInstance.enableScrollWheelZoom(true)
+    mapInstance.setMapStyleV2({ styleId: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' })
+
+    const bdPipelineLines = pipelineLines.value.map(pipe => convertPipeline(pipe))
+    const bdPipePoints = convertPoints(pipePoints.value)
+    bdPipelineLines.forEach(pipe => addPipeLine(BMapGL, pipe))
+    bdPipePoints.forEach(point => addPipePoint(BMapGL, point))
+  } catch (error) {
+    mapError.value = '百度地图加载失败,请检查地图脚本或网络连接。'
+  }
+}
+
+function handleResize() {
+  repairChart?.resize()
+  hazardChart?.resize()
+}
+
+function parseFeatures(features) {
+  const lines = []
+  const points = []
+  ;(features || []).forEach(feature => {
+    const coordinates = feature.geometry?.coordinates || []
+    const properties = feature.properties || {}
+    if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
+      lines.push({
+        id: feature.id || `line-${lines.length}`,
+        name: properties.facilityTypeName || properties.name || '供水管网',
+        color: '#37b6e9',
+        points: coordinates
+      })
+    }
+    if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
+      points.push({
+        id: feature.id || properties.id,
+        name: properties.facilityName || properties.name || properties.facilityTypeName || '供水设施',
+        value: properties.facilityTypeName || properties.facilityType || '',
+        lng: coordinates[0],
+        lat: coordinates[1],
+        type: properties.facilityType,
+        properties
+      })
+    }
+  })
+  pipelineLines.value = lines
+  pipePoints.value = points
+}
+
+function buildPointRows(flowPage, pressurePage, leakagePage) {
+  const flow = normalizePage(flowPage).records.map(item => normalizeMonitoringRecord(item, 'flow'))
+  const pressure = normalizePage(pressurePage).records.map(item => normalizeMonitoringRecord(item, 'pressure'))
+  const leakage = normalizePage(leakagePage).records.map(item => normalizeMonitoringRecord(item, 'power'))
+  return [...flow, ...pressure, ...leakage].slice(0, 8).map(item => ({
+    id: item.id,
+    name: item.equipmentName,
+    time: item.updateTime,
+    value: item.value == null ? '--' : item.value,
+    status: item.status === 1 || item.status === '1' ? '预警' : '在线'
+  }))
+}
+
+async function loadDashboardData() {
+  loading.value = true
+  try {
+    const [overviewResponse, featuresResponse, devicePointsResponse, deviceStatsResponse, alarmResponse, alarmSummaryResponse, devicePageResponse, flowResponse, pressureResponse, leakageResponse] = await Promise.all([
+      getWaterFacilityOverview(),
+      getWaterFacilityFeatures(),
+      getDeviceGisAllPoints(),
+      getDeviceGisStatistics(),
+      listRealtimeAlarms(),
+      getAlarmStats(),
+      listDeviceLedger({ pageNum: 1, pageSize: 8 }),
+      listFlowMonitoring({ pageNum: 1, pageSize: 5 }),
+      listPressureMonitoring({ pageNum: 1, pageSize: 5 }),
+      listLeakageMonitoring({ pageNum: 1, pageSize: 5 })
+    ])
+    const overviewData = unwrapResponse(overviewResponse)
+    const facilityOverview = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
+    const features = normalizeGeoJson(featuresResponse).features || []
+    const deviceData = unwrapResponse(devicePointsResponse)
+    const deviceRows = Array.isArray(deviceData) ? deviceData : (deviceData?.features || deviceData?.records || [])
+    const devicePage = normalizePage(devicePageResponse)
+    const rawStats = unwrapResponse(deviceStatsResponse) || {}
+    const typeStatistics = rawStats.typeStatistics || []
+    const flatten = list => (Array.isArray(list) ? list : []).reduce((sum, item) => sum + Number(item.deviceCount || item.count || 0) + flatten(item.children), 0)
+    const equipmentStats = {
+      ...rawStats,
+      total: Number(rawStats.total ?? rawStats.totalDeviceCount ?? 0) || flatten(typeStatistics) || deviceRows.length,
+      online: Number(rawStats.online ?? rawStats.onlineCount ?? deviceRows.filter(item => Number(item.onlineStatus) === 1).length),
+      abnormal: Number(rawStats.abnormal ?? rawStats.abnormalCount ?? deviceRows.filter(item => Number(item.alarmStatus) === 1).length)
+    }
+    const alarmList = Array.isArray(unwrapResponse(alarmResponse)) ? unwrapResponse(alarmResponse) : []
+    const alarmStats = unwrapResponse(alarmSummaryResponse) || {}
+    const equipmentRows = devicePage.records.slice(0, 8).map(item => ({
+      id: item.equipmentId || item.id,
+      name: item.equipmentName || item.name || '-',
+      code: item.equipmentCode || item.code || '-',
+      location: item.equipmentLocation || item.location || '-',
+      status: Number(item.status || item.onlineStatus) === 1 ? '异常' : '正常'
+    }))
+    const warningRows = alarmList.slice(0, 6).map(item => ({
+      level: Number(item.alarmLevel) <= 2 ? '高' : '中',
+      title: item.deviceName || item.deviceCode || '监测设备',
+      code: item.warningType || item.alarmCode || '-',
+      time: item.alarmTime || '--',
+      desc: item.equipmentLocation || '报警详情待查看',
+      tag: Number(item.alarmLevel) <= 2 ? '紧急' : '一般'
+    }))
+    dashboardData.value = {
+      facilityOverview,
+      equipmentStats,
+      alarmStats,
+      warningRows,
+      equipmentRows,
+      pointRows: buildPointRows(flowResponse, pressureResponse, leakageResponse)
+    }
+    parseFeatures(features)
+  } catch (error) {
+    console.warn('供水首页数据加载失败', error)
+    dashboardData.value = { facilityOverview: [], equipmentStats: {}, alarmStats: {}, warningRows: [], equipmentRows: [], pointRows: [] }
+    pipelineLines.value = []
+    pipePoints.value = []
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  updateClock()
+  clockTimer = window.setInterval(updateClock, 1000)
+  await loadDashboardData()
+  await nextTick()
+  initRepairChart()
+  initHazardChart()
+  initMap()
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  if (clockTimer) window.clearInterval(clockTimer)
+  window.removeEventListener('resize', handleResize)
+  repairChart?.dispose()
+  hazardChart?.dispose()
+  mapInstance = null
+})
+</script>
+
+
+<template>
+  <div class="gas-home-screen">
+    <div class="screen-header">
+      <div class="header-time">{{ nowText }} {{ weekText }}</div>
+      <div class="header-title-wrap">
+        <div class="header-title">供水管网安全运行监测系统</div>
+        <div class="header-subtitle">Water Supply Operation Dashboard</div>
+      </div>
+      <div class="header-tags">
+        <span class="header-tag">实时监测</span>
+        <span class="header-tag">供水设施</span>
+        <span class="header-tag">报警预警</span>
+      </div>
+    </div>
+
+    <div class="screen-body">
+      <div class="side-panel left-panel">
+        <section class="panel-card base-card">
+          <div class="panel-title">基础概况</div>
+          <div class="base-grid">
+            <div v-for="item in baseCards" :key="item.label" class="base-item">
+              <div class="base-icon" :style="{ background: item.color }">
+                <component :is="item.icon" class="base-icon-svg" />
+              </div>
+              <div class="base-text">
+                <div class="base-label">{{ item.label }}</div>
+                <div class="base-value">{{ item.value }}<span>{{ item.unit }}</span></div>
+              </div>
+            </div>
+          </div>
+        </section>
+
+        <section class="panel-card">
+          <div class="panel-title">设施统计</div>
+          <div ref="repairChartRef" class="chart-box repair-chart"></div>
+        </section>
+
+        <section class="panel-card table-card">
+          <div class="panel-title">监测点数据</div>
+          <div class="table-wrap">
+            <table class="data-table">
+              <thead>
+                <tr>
+                  <th>序号</th>
+                  <th>监测点</th>
+                  <th>时间</th>
+                  <th>数值</th>
+                  <th>状态</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="row in pointRows" :key="row.id">
+                  <td>{{ row.id }}</td>
+                  <td>{{ row.name }}</td>
+                  <td>{{ row.time }}</td>
+                  <td>{{ row.value }}</td>
+                  <td :class="['status-cell', row.status === '在线' ? 'ok' : row.status === '预警' ? 'warn' : 'off']">{{ row.status }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </section>
+      </div>
+
+      <div class="center-panel">
+        <div class="map-stage">
+          <div class="map-stage-top">
+            <div class="summary-strip">
+              <div v-for="item in operationSummary" :key="item.label" class="summary-item">
+                <span class="summary-label">{{ item.label }}</span>
+                <strong class="summary-value">{{ item.value }}</strong>
+              </div>
+            </div>
+            <div class="map-kpis">
+              <div v-for="item in mapKpis" :key="item.label" class="map-kpi">
+                <span>{{ item.label }}</span>
+                <strong>{{ item.value }}</strong>
+              </div>
+            </div>
+          </div>
+
+          <div ref="mapWrapRef" class="map-view"></div>
+          <div v-if="mapError" class="map-error">{{ mapError }}</div>
+
+          <div class="map-panel info-panel">
+            <div class="mini-title">供水设施信息</div>
+            <div class="mini-row"><span>供水管网</span><strong>{{ typeCount('pipe') }}条</strong></div>
+            <div class="mini-row"><span>水厂</span><strong>{{ typeCount('plant') }}座</strong></div>
+            <div class="mini-row"><span>泵站</span><strong>{{ typeCount('pumpStation') }}座</strong></div>
+          </div>
+
+          <div class="map-panel point-panel">
+            <div class="mini-title">当前监测信息</div>
+            <div class="mini-row"><span>监测点总数</span><strong>{{ pointRows.length }}个</strong></div>
+            <div class="mini-row"><span>在线设备</span><strong>{{ dashboardData.equipmentStats?.online || 0 }}台</strong></div>
+            <div class="mini-row"><span>实时报警</span><strong>{{ dashboardData.alarmStats?.realtimeAlarmCount ?? dashboardData.alarmStats?.realtimeCount ?? 0 }}条</strong></div>
+          </div>
+        </div>
+      </div>
+
+      <div class="side-panel right-panel">
+        <section class="panel-card">
+          <div class="panel-title">设备运行</div>
+          <div class="table-wrap equipment-wrap">
+            <table class="data-table compact-table">
+              <thead>
+                <tr>
+                  <th>序号</th>
+                  <th>设备名称</th>
+                  <th>设备编号</th>
+                  <th>位置</th>
+                  <th>状态</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="row in equipmentRows" :key="row.id">
+                  <td>{{ row.id }}</td>
+                  <td>{{ row.name }}</td>
+                  <td>{{ row.code }}</td>
+                  <td>{{ row.location }}</td>
+                  <td>{{ row.status }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </section>
+
+        <section class="panel-card warning-card">
+          <div class="panel-title">告警动态</div>
+          <div class="warning-scroll">
+            <div class="warning-list">
+              <div v-for="(item, index) in warningRows" :key="index" class="warning-item" :class="item.level === '高' ? 'danger' : 'notice'">
+                <div class="warning-side">
+                  <div class="warning-badge">{{ item.level }}</div>
+                  <div class="warning-tag">{{ item.tag }}</div>
+                </div>
+                <div class="warning-main">
+                  <div class="warning-title-row">
+                    <span class="warning-title">{{ item.title }}</span>
+                    <span class="warning-time">{{ item.time }}</span>
+                  </div>
+                  <div class="warning-code">{{ item.code }}</div>
+                  <div class="warning-desc">{{ item.desc }}</div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </section>
+
+        <section class="panel-card hazard-card">
+          <div class="panel-title">报警统计</div>
+          <div class="hazard-summary three-col">
+            <div v-for="item in hazardSummary" :key="item.label" class="hazard-summary-item">
+              <span class="hazard-dot" :style="{ background: item.color }"></span>
+              <span>{{ item.label }}</span>
+              <strong :style="{ color: item.color }">{{ item.value }}项</strong>
+            </div>
+          </div>
+          <div ref="hazardChartRef" class="chart-box hazard-chart"></div>
+        </section>
+      </div>
+    </div>
+
+    <a href="https://deerflow.tech" target="_blank" class="deerflow-mark" title="Created By Deerflow">
+      <span class="deerflow-diamond">◆</span>
+      <span class="deerflow-text">Deerflow</span>
+    </a>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.gas-home-screen {
+  min-height: 100vh;
+  color: #fff;
+  background:
+    linear-gradient(180deg, rgba(14, 42, 70, 0.96), rgba(8, 20, 37, 1)),
+    radial-gradient(circle at center, rgba(36, 122, 184, 0.18), transparent 58%),
+    #081425;
+  position: relative;
+}
+
+.gas-home-screen::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 0;
+  background:
+    radial-gradient(circle at 28% 32%, rgba(56, 216, 255, 0.06) 0%, transparent 45%),
+    radial-gradient(circle at 72% 58%, rgba(22, 109, 255, 0.05) 0%, transparent 40%),
+    radial-gradient(circle at 50% 85%, rgba(56, 216, 255, 0.04) 0%, transparent 35%);
+  animation: bgPulse 8s ease-in-out infinite alternate;
+}
+
+@keyframes bgPulse {
+  0% { opacity: 0.6; }
+  100% { opacity: 1; }
+}
+
+.screen-header {
+  height: 72px;
+  padding: 0 24px;
+  display: grid;
+  grid-template-columns: 320px 1fr 320px;
+  align-items: center;
+  position: relative;
+  z-index: 1;
+  background: linear-gradient(90deg, rgba(27, 98, 155, 0.95), rgba(18, 53, 98, 0.95));
+  border-bottom: 1px solid rgba(93, 194, 255, 0.28);
+}
+
+.screen-header::after {
+  content: '';
+  position: absolute;
+  bottom: -1px;
+  left: 5%;
+  width: 90%;
+  height: 1px;
+  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.7), rgba(22, 109, 255, 0.7), transparent);
+  animation: headerGlow 4s ease-in-out infinite alternate;
+}
+
+@keyframes headerGlow {
+  0% { opacity: 0.4; }
+  100% { opacity: 1; }
+}
+
+.header-time {
+  font-size: 18px;
+  font-weight: 700;
+  letter-spacing: 1.5px;
+  color: #ddf6ff;
+  font-variant-numeric: tabular-nums;
+  text-shadow: 0 0 18px rgba(56, 216, 255, 0.35);
+}
+
+.header-title-wrap {
+  text-align: center;
+}
+
+.header-title {
+  font-size: 30px;
+  font-weight: 800;
+  letter-spacing: 3px;
+  background: linear-gradient(180deg, #e8f6ff 0%, #8fd6ff 100%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+
+.header-subtitle {
+  margin-top: 4px;
+  color: rgba(196, 234, 255, 0.65);
+  font-size: 11px;
+  letter-spacing: 3px;
+  text-transform: uppercase;
+}
+
+.header-tags {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.header-tag {
+  padding: 6px 14px;
+  border: 1px solid rgba(117, 206, 255, 0.35);
+  border-radius: 16px;
+  color: #d8f5ff;
+  background: rgba(13, 77, 128, 0.45);
+  font-size: 12px;
+  letter-spacing: 1px;
+  transition: all 0.3s;
+  cursor: default;
+}
+
+.header-tag:hover {
+  border-color: rgba(117, 206, 255, 0.7);
+  background: rgba(13, 77, 128, 0.65);
+  box-shadow: 0 0 16px rgba(56, 216, 255, 0.15);
+}
+
+.screen-body {
+  height: calc(100vh - 72px);
+  padding: 12px;
+  display: grid;
+  grid-template-columns: 360px 1fr 360px;
+  gap: 12px;
+  position: relative;
+  z-index: 1;
+}
+
+.side-panel,
+.center-panel {
+  min-height: 0;
+}
+
+.left-panel,
+.right-panel {
+  display: grid;
+  gap: 12px;
+}
+
+.left-panel {
+  grid-template-rows: 220px 220px 1fr;
+}
+
+.right-panel {
+  grid-template-rows: 250px 260px 1fr;
+}
+
+.panel-card {
+  min-height: 0;
+  padding: 14px;
+  position: relative;
+  background: linear-gradient(180deg, rgba(13, 33, 56, 0.88), rgba(10, 25, 42, 0.88));
+  border: 1px solid rgba(65, 158, 219, 0.22);
+  border-radius: 12px;
+  box-shadow: 0 2px 24px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(255, 255, 255, 0.025);
+  transition: border-color 0.4s;
+}
+
+.panel-card::after {
+  content: '';
+  position: absolute;
+  top: -1px;
+  left: 20px;
+  right: 20px;
+  height: 1px;
+  border-radius: 1px;
+  background: linear-gradient(90deg, transparent, rgba(56, 216, 255, 0.25), transparent);
+  pointer-events: none;
+}
+
+.panel-card:hover {
+  border-color: rgba(65, 158, 219, 0.38);
+}
+
+.panel-title {
+  position: relative;
+  padding-left: 16px;
+  margin-bottom: 14px;
+  color: #8fe5ff;
+  font-size: 18px;
+  font-weight: 800;
+  letter-spacing: 1px;
+}
+
+.panel-title::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 3px;
+  width: 4px;
+  height: 20px;
+  border-radius: 2px;
+  background: linear-gradient(180deg, #28d7ff, #1776ff);
+  box-shadow: 0 0 10px rgba(40, 215, 255, 0.5);
+}
+
+.base-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.base-card {
+  background: linear-gradient(180deg, rgba(10, 30, 53, 0.95), rgba(9, 24, 42, 0.95));
+}
+
+.base-grid {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.base-item {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  min-height: 72px;
+  padding: 14px;
+  border-radius: 14px;
+  background: linear-gradient(180deg, rgba(20, 57, 89, 0.45), rgba(18, 44, 72, 0.35));
+  border: 1px solid rgba(97, 190, 255, 0.12);
+  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+  cursor: default;
+  position: relative;
+  overflow: hidden;
+}
+
+.base-item::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  border-radius: 14px;
+  background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.03), transparent 70%);
+  pointer-events: none;
+  transition: opacity 0.35s;
+  opacity: 0;
+}
+
+.base-item:hover {
+  transform: translateY(-2px);
+  border-color: rgba(97, 190, 255, 0.35);
+  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(97, 190, 255, 0.1) inset;
+}
+
+.base-item:hover::before {
+  opacity: 1;
+}
+
+.base-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 0 0 48px;
+  position: relative;
+  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.35s;
+}
+
+.base-item:hover .base-icon {
+  transform: scale(1.08);
+  box-shadow: 0 0 28px rgba(56, 216, 255, 0.3);
+}
+
+.base-icon-svg {
+  width: 26px;
+  height: 26px;
+  color: #fff;
+  transition: transform 0.35s;
+}
+
+.base-item:hover .base-icon-svg {
+  transform: scale(1.1);
+}
+
+.base-text {
+  min-width: 0;
+}
+
+.base-label {
+  color: #c0dff5;
+  font-size: 12px;
+  letter-spacing: 1px;
+  text-transform: uppercase;
+}
+
+.base-value {
+  margin-top: 4px;
+  color: #fff;
+  font-size: 32px;
+  font-weight: 800;
+  line-height: 1;
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.5px;
+}
+
+.base-value span {
+  margin-left: 4px;
+  color: #90d9ff;
+  font-size: 12px;
+  font-weight: 500;
+  letter-spacing: 0;
+}
+
+.chart-box {
+  width: 100%;
+  height: calc(100% - 36px);
+}
+
+.table-card,
+.warning-card,
+.hazard-card {
+  overflow: hidden;
+}
+
+.table-wrap {
+  height: calc(100% - 38px);
+  overflow: auto;
+}
+
+.data-table {
+  width: 100%;
+  border-collapse: collapse;
+  table-layout: fixed;
+}
+
+.data-table thead {
+  background: rgba(24, 122, 198, 0.55);
+}
+
+.data-table thead th {
+  font-weight: 700;
+  letter-spacing: 0.5px;
+  color: #b9e4ff;
+}
+
+.data-table th,
+.data-table td {
+  padding: 10px 6px;
+  color: #f4fbff;
+  text-align: center;
+  font-size: 13px;
+  border-bottom: 1px solid rgba(130, 194, 231, 0.1);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.data-table tbody tr {
+  transition: background 0.25s;
+}
+
+.data-table tbody tr:nth-child(odd) {
+  background: rgba(133, 196, 242, 0.10);
+}
+
+.data-table tbody tr:nth-child(even) {
+  background: rgba(133, 196, 242, 0.04);
+}
+
+.data-table tbody tr:hover {
+  background: rgba(56, 216, 255, 0.12);
+}
+
+.compact-table th,
+.compact-table td {
+  padding: 9px 4px;
+}
+
+.status-cell.ok {
+  color: #6effc4;
+  position: relative;
+}
+
+.status-cell.warn {
+  color: #ffe16a;
+}
+
+.status-cell.off {
+  color: #ff8e8e;
+}
+
+.status-cell::before {
+  content: '';
+  display: inline-block;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  margin-right: 6px;
+  vertical-align: middle;
+}
+
+.status-cell.ok::before {
+  background: #6effc4;
+  box-shadow: 0 0 6px rgba(110, 255, 196, 0.6);
+}
+
+.status-cell.warn::before {
+  background: #ffe16a;
+  box-shadow: 0 0 6px rgba(255, 225, 106, 0.6);
+}
+
+.status-cell.off::before {
+  background: #ff8e8e;
+  box-shadow: 0 0 6px rgba(255, 142, 142, 0.6);
+}
+
+.center-panel {
+  position: relative;
+}
+
+.map-stage {
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 12px;
+  border: 1px solid rgba(56, 159, 223, 0.2);
+  background: rgba(8, 20, 37, 0.64);
+  box-shadow: 0 0 40px rgba(0, 0, 0, 0.3), inset 0 0 60px rgba(8, 30, 60, 0.3);
+}
+
+.map-stage-top {
+  position: absolute;
+  top: 14px;
+  left: 14px;
+  right: 14px;
+  z-index: 3;
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  pointer-events: none;
+}
+
+.summary-strip,
+.map-kpis {
+  display: flex;
+  gap: 10px;
+}
+
+.summary-item,
+.map-kpi {
+  min-width: 110px;
+  padding: 10px 14px;
+  border-radius: 10px;
+  background: rgba(8, 31, 54, 0.82);
+  border: 1px solid rgba(79, 177, 238, 0.18);
+  backdrop-filter: blur(8px);
+  transition: all 0.3s;
+}
+
+.summary-label,
+.map-kpi span {
+  display: block;
+  color: #91d7ff;
+  font-size: 12px;
+  letter-spacing: 0.5px;
+}
+
+.summary-value,
+.map-kpi strong {
+  display: block;
+  margin-top: 4px;
+  color: #fff;
+  font-size: 22px;
+  font-weight: 800;
+  letter-spacing: 1px;
+}
+
+.map-view {
+  width: 100%;
+  height: 100%;
+}
+
+.map-error {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffd3d3;
+  background: rgba(5, 10, 20, 0.72);
+  z-index: 4;
+}
+
+.map-panel {
+  position: absolute;
+  z-index: 3;
+  width: 190px;
+  padding: 14px;
+  border-radius: 12px;
+  background: rgba(8, 31, 54, 0.82);
+  border: 1px solid rgba(79, 177, 238, 0.18);
+  backdrop-filter: blur(10px);
+}
+
+.info-panel {
+  left: 18px;
+  bottom: 20px;
+}
+
+.point-panel {
+  right: 18px;
+  bottom: 20px;
+}
+
+.mini-title {
+  margin-bottom: 10px;
+  color: #8fe5ff;
+  font-size: 14px;
+  font-weight: 700;
+  letter-spacing: 0.5px;
+}
+
+.mini-row {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 6px;
+  padding: 4px 0;
+  color: #d6efff;
+  font-size: 13px;
+  border-bottom: 1px solid rgba(130, 194, 231, 0.06);
+}
+
+.mini-row:last-child {
+  border-bottom: none;
+}
+
+.warning-scroll {
+  height: calc(100% - 38px);
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.warning-scroll::-webkit-scrollbar {
+  width: 5px;
+}
+
+.warning-scroll::-webkit-scrollbar-thumb {
+  border-radius: 999px;
+  background: rgba(96, 180, 235, 0.3);
+}
+
+.warning-scroll::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.warning-list {
+  display: grid;
+  gap: 10px;
+}
+
+.warning-item {
+  display: flex;
+  gap: 12px;
+  align-items: stretch;
+  padding: 14px;
+  border-radius: 14px;
+  border: 1px solid rgba(255, 108, 108, 0.1);
+  background: linear-gradient(180deg, rgba(72, 24, 38, 0.42), rgba(49, 19, 31, 0.30));
+  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+  position: relative;
+  overflow: hidden;
+}
+
+.warning-item::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 12px;
+  bottom: 12px;
+  width: 3px;
+  border-radius: 0 3px 3px 0;
+  transition: all 0.35s;
+}
+
+.warning-item.danger::before {
+  background: linear-gradient(180deg, #ff5858, #ff3030);
+  box-shadow: 0 0 12px rgba(255, 58, 58, 0.5);
+  animation: dangerPulse 2s ease-in-out infinite;
+}
+
+.warning-item.notice::before {
+  background: linear-gradient(180deg, #ffc635, #ff9f1a);
+  box-shadow: 0 0 10px rgba(255, 198, 53, 0.35);
+}
+
+@keyframes dangerPulse {
+  0%, 100% { opacity: 0.6; }
+  50% { opacity: 1; }
+}
+
+.warning-item:hover {
+  transform: translateX(3px);
+}
+
+.warning-item.danger:hover {
+  border-color: rgba(255, 108, 108, 0.25);
+  box-shadow: 0 4px 20px rgba(255, 58, 58, 0.1);
+}
+
+.warning-item.notice:hover {
+  border-color: rgba(255, 201, 92, 0.2);
+  box-shadow: 0 4px 20px rgba(255, 198, 53, 0.08);
+}
+
+.warning-item.notice {
+  border-color: rgba(255, 201, 92, 0.1);
+  background: linear-gradient(180deg, rgba(78, 63, 23, 0.28), rgba(48, 43, 20, 0.18));
+}
+
+.warning-side {
+  width: 52px;
+  flex: 0 0 52px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  align-items: center;
+}
+
+.warning-badge {
+  width: 34px;
+  height: 34px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 800;
+  color: #fff;
+  transition: transform 0.3s;
+}
+
+.warning-item:hover .warning-badge {
+  transform: scale(1.1);
+}
+
+.warning-tag {
+  width: 100%;
+  padding: 3px 0;
+  border-radius: 999px;
+  text-align: center;
+  font-size: 10px;
+  letter-spacing: 0.5px;
+  color: #dcefff;
+  background: rgba(255, 255, 255, 0.06);
+}
+
+.warning-item.danger .warning-badge {
+  background: rgba(255, 58, 58, 0.28);
+  color: #ff8787;
+}
+
+.warning-item.danger .warning-title {
+  color: #ffd8d8;
+}
+
+.warning-item.notice .warning-badge {
+  background: rgba(255, 198, 53, 0.2);
+  color: #ffd45d;
+}
+
+.warning-item.notice .warning-title {
+  color: #fff2c4;
+}
+
+.warning-main {
+  min-width: 0;
+  flex: 1;
+}
+
+.warning-title-row {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.warning-title {
+  font-weight: 700;
+  font-size: 16px;
+  line-height: 1.2;
+}
+
+.warning-time,
+.warning-code,
+.warning-desc {
+  color: #dcefff;
+  font-size: 12px;
+}
+
+.warning-time {
+  color: #b6dfff;
+  white-space: nowrap;
+}
+
+.warning-code {
+  margin: 6px 0 4px;
+  color: #9ad8ff;
+}
+
+.warning-desc {
+  line-height: 1.5;
+}
+
+.hazard-summary {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+
+.hazard-summary.three-col {
+  justify-content: space-between;
+}
+
+.hazard-summary-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  padding: 8px 12px;
+  border-radius: 10px;
+  background: rgba(255, 255, 255, 0.03);
+  border: 1px solid rgba(255, 255, 255, 0.04);
+  transition: all 0.3s;
+}
+
+.hazard-summary-item:hover {
+  background: rgba(255, 255, 255, 0.06);
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+.hazard-dot {
+  width: 8px;
+  height: 24px;
+  display: inline-block;
+  border-radius: 4px;
+  box-shadow: 0 0 10px currentColor;
+}
+
+.hazard-chart {
+  height: 160px;
+}
+
+.equipment-wrap {
+  height: calc(100% - 38px);
+}
+
+:deep(.BMap_cpyCtrl),
+:deep(.anchorBL) {
+  display: none !important;
+}
+
+.deerflow-mark {
+  position: fixed;
+  bottom: 14px;
+  right: 18px;
+  z-index: 10;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  text-decoration: none;
+  opacity: 0.28;
+  transition: opacity 0.4s;
+}
+
+.deerflow-mark:hover {
+  opacity: 0.6;
+}
+
+.deerflow-diamond {
+  font-size: 10px;
+  color: #8fe5ff;
+}
+
+.deerflow-text {
+  font-size: 11px;
+  letter-spacing: 1px;
+  color: #8fe5ff;
+  font-weight: 500;
+}
+
+@media (max-width: 1500px) {
+  .screen-body {
+    grid-template-columns: 320px 1fr 320px;
+  }
+
+  .header-title {
+    font-size: 24px;
+  }
+
+  .summary-item,
+  .map-kpi {
+    min-width: 96px;
+  }
+}
+</style>

+ 28 - 0
src/views/subSystem/waterSupply/components/waterSupplyPageTheme.scss

@@ -0,0 +1,28 @@
+.water-page {
+  min-height: calc(100vh - 84px);
+  padding: 20px;
+  color: #1e293b;
+  background: #f3f7fb;
+}
+.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
+.page-heading h2 { margin: 0 0 5px; color: #172b4d; font-size: 20px; letter-spacing: 0; }
+.page-title { display: flex; align-items: center; gap: 10px; }
+.page-title-icon { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; color: #1677b8; background: #e6f4ff; border: 1px solid #c7e7fb; border-radius: 8px; }
+.muted { color: #718096; font-size: 13px; }
+.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
+.metric { position: relative; min-height: 74px; padding: 14px 16px 12px 48px; overflow: hidden; background: #fff; border: 1px solid #e1ebf3; border-radius: 8px; box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05); }
+.metric-icon { position: absolute; top: 16px; left: 16px; color: #1677b8; font-size: 22px; }
+.metric span { display: block; color: #718096; font-size: 13px; }
+.metric strong { color: #17324d; font-size: 24px; line-height: 34px; }
+.metric small { margin-left: 5px; color: #94a3b8; }
+.water-panel { overflow: hidden; border: 1px solid #e1ebf3; border-radius: 8px; box-shadow: 0 5px 16px rgba(28, 75, 110, 0.05); }
+.map-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 260px; min-height: 560px; overflow: hidden; background: #fff; }
+.water-map { position: relative; min-height: 560px; overflow: hidden; border-right: 1px solid #e1ebf3; background-color: #f8fbff; background-image: linear-gradient(#eaf0f6 1px, transparent 1px), linear-gradient(90deg, #eaf0f6 1px, transparent 1px); background-size: 40px 40px; }
+.map-side-panel { padding: 16px; background: #fbfdff; }
+.map-side-panel h3 { margin: 0 0 12px; color: #17324d; font-size: 15px; }
+.map-side-panel + .map-side-panel { border-top: 1px solid #e1ebf3; }
+.map-legend { display: grid; gap: 10px; color: #526273; font-size: 13px; }
+.map-legend-item { display: flex; align-items: center; gap: 8px; }
+.map-legend-dot { width: 10px; height: 10px; border-radius: 50%; box-shadow: 0 0 0 3px rgba(22, 119, 184, 0.1); }
+@media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .map-workspace { grid-template-columns: 1fr; } .water-map { border-right: 0; border-bottom: 1px solid #e1ebf3; } }
+@media (max-width: 620px) { .water-page { padding: 12px; } .page-heading { align-items: flex-start; flex-direction: column; } .metric-grid { grid-template-columns: 1fr; } }

+ 1 - 0
src/views/subSystem/waterSupply/device/abnormal.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备异常管理" view="abnormal" /></template>

+ 1 - 0
src/views/subSystem/waterSupply/device/gis.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="监测设备 GIS 一张图" view="gis" /></template>

+ 1 - 0
src/views/subSystem/waterSupply/device/ledger.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备台账管理" view="ledger" /></template>

+ 1 - 0
src/views/subSystem/waterSupply/device/maintain.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备维修派单" view="dispatch" subtitle="异常设备维修派单与运维同步" /></template>

+ 1 - 0
src/views/subSystem/waterSupply/device/record.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterDevicePage.vue'</script><template><Page title="设备维修记录" view="maintain" /></template>

+ 1007 - 0
src/views/subSystem/waterSupply/facility/gis.vue

@@ -0,0 +1,1007 @@
+<template>
+  <div class="gis-dashboard">
+    <div class="dashboard-header">
+      <div class="header-title">
+        <div class="title-main">
+          <el-icon><MapLocation /></el-icon>
+          <span>供水设施GIS一张图</span>
+        </div>
+        <div class="title-metrics">
+          <span class="metric metric-danger">水源地 {{ summaryStats.sourceCount }}处</span>
+          <span class="metric metric-primary">水厂 {{ summaryStats.plantCount }}座</span>
+          <span class="metric metric-warning">泵站 {{ summaryStats.pumpStationCount }}座</span>
+          <span class="metric">供水管网 {{ summaryStats.pipeCount }}条</span>
+        </div>
+      </div>
+      <div class="header-actions">
+        <el-input
+          v-model="searchKeyword"
+          class="search-input"
+          placeholder="搜索设施名称/编号/地址"
+          clearable
+          @keyup.enter="handleSearch"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+        <el-button type="primary" @click="handleSearch">
+          <el-icon><Search /></el-icon>
+          搜索
+        </el-button>
+        <el-button @click="refreshScene">
+          <el-icon><RefreshRight /></el-icon>
+          刷新
+        </el-button>
+        <el-button @click="toggleFullscreen">
+          <el-icon><FullScreen /></el-icon>
+          全图
+        </el-button>
+      </div>
+    </div>
+
+    <div ref="screenRef" class="dashboard-content">
+      <aside class="dashboard-sidebar">
+        <el-card shadow="never" class="panel-card">
+          <template #header>
+            <span>图层控制</span>
+          </template>
+          <div class="layer-group">
+            <div class="layer-title">供水设施图层</div>
+            <el-checkbox v-for="item in facilityTypes" :key="item.value" v-model="layerState[item.value]">{{ item.label }}</el-checkbox>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="panel-card">
+          <template #header>
+            <span>图例说明</span>
+          </template>
+          <div class="legend-list">
+            <div class="legend-item" v-for="item in facilityTypes" :key="item.value"><span class="legend-dot" :style="{ background: item.color }"></span><span>{{ item.label }}</span></div>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="panel-card stats-card">
+          <template #header>
+<span>供水设施统计</span>
+          </template>
+          <div class="stats-list">
+            <div class="stats-row"><span>水源地</span><strong>{{ summaryStats.sourceCount }}</strong></div>
+            <div class="stats-row"><span>水厂</span><strong>{{ summaryStats.plantCount }}</strong></div>
+            <div class="stats-row"><span>泵站</span><strong>{{ summaryStats.pumpStationCount }}</strong></div>
+            <div class="stats-row"><span>供水管网</span><strong>{{ summaryStats.pipeCount }}</strong></div>
+            <div class="stats-row"><span>用水户</span><strong>{{ summaryStats.userCount }}</strong></div>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="panel-card detail-card">
+          <template #header>
+            <span>要素详情</span>
+          </template>
+          <div v-if="selectedFeature" class="detail-body">
+            <div class="detail-name">{{ selectedFeature.name }}</div>
+            <div class="detail-tag">{{ selectedFeature.category }}</div>
+            <el-descriptions :column="1" border size="small">
+              <el-descriptions-item
+                v-for="item in selectedFeatureDetails"
+                :key="item.label"
+                :label="item.label"
+              >
+                {{ item.value }}
+              </el-descriptions-item>
+            </el-descriptions>
+          </div>
+          <el-empty v-else description="点击地图要素查看详情" :image-size="72" />
+        </el-card>
+      </aside>
+
+      <section class="dashboard-map-panel">
+        <div class="map-toolbar">
+          <div class="toolbar-left">
+            <el-button @click="zoomIn">
+              <el-icon><Plus /></el-icon>
+              放大
+            </el-button>
+            <el-button @click="zoomOut">
+              <el-icon><Minus /></el-icon>
+              缩小
+            </el-button>
+            <el-button @click="locateCenter">
+              <el-icon><Location /></el-icon>
+              定位
+            </el-button>
+          </div>
+          <div class="toolbar-right">当前坐标: {{ currentCoordinate }}</div>
+        </div>
+        <div id="gisMap" ref="mapRef" class="gis-map"></div>
+        <div v-if="mapError" class="map-error">{{ mapError }}</div>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
+import { FullScreen, Location, MapLocation, Minus, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { convertCoord } from '@/utils/coordTransform'
+import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDetail } from '@/api/pipeNetwork/waterSupply'
+import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
+
+const centerPoint = { lng: 110.386, lat: 28.445 }
+
+const MAP_BOUNDS = {
+  southWest: { lng: 110.355, lat: 28.428 },
+  northEast: { lng: 110.410, lat: 28.460 }
+}
+const MAP_MIN_ZOOM = 13
+const MAP_MAX_ZOOM = 19
+const MAP_DEFAULT_ZOOM = 15
+
+const defaultLayerState = () => ({
+  source: true,
+  plant: true,
+  pumpStation: true,
+  pipe: true,
+  user: true
+})
+
+const facilityTypes = [
+  { label: '水源地', value: 'source', color: '#457b9d' },
+  { label: '水厂', value: 'plant', color: '#2a9d8f' },
+  { label: '泵站', value: 'pumpStation', color: '#e76f51' },
+  { label: '管网', value: 'pipe', color: '#fcbf49' },
+  { label: '用水户', value: 'user', color: '#7f4f24' }
+]
+
+const colorMap = {
+  source: '#457b9d',
+  plant: '#2a9d8f',
+  pumpStation: '#e76f51',
+  pipe: '#fcbf49',
+  user: '#7f4f24'
+}
+
+const labelMap = {
+  source: '水源地',
+  plant: '水厂',
+  pumpStation: '泵站',
+  pipe: '供水管网',
+  user: '用水户'
+}
+
+const mapRef = ref(null)
+const screenRef = ref(null)
+const searchKeyword = ref('')
+const currentCoordinate = ref(`${centerPoint.lng}, ${centerPoint.lat}`)
+const selectedFeature = ref(null)
+const mapError = ref('')
+const layerState = reactive(defaultLayerState())
+const facilityOverview = ref([])
+const pipelineData = ref([])
+const pointData = ref([])
+
+let mapInstance = null
+let activeInfoWindow = null
+let overlayClicked = false
+
+const typeCount = type => {
+  const row = facilityOverview.value.find(item => item.facilityType === type)
+  return Number(row?.totalCount ?? row?.count ?? 0)
+}
+
+const summaryStats = computed(() => ({
+  sourceCount: typeCount('source'),
+  plantCount: typeCount('plant'),
+  pumpStationCount: typeCount('pumpStation'),
+  pipeCount: typeCount('pipe'),
+  userCount: typeCount('user')
+}))
+
+const selectedFeatureDetails = computed(() => {
+  if (!selectedFeature.value) return []
+  return Object.entries(selectedFeature.value.details).map(([label, value]) => ({ label, value }))
+})
+
+const searchableFeatures = computed(() => {
+  const pipelines = pipelineData.value.map(item => ({
+    kind: 'pipeline',
+    type: item.type,
+    name: item.name,
+    code: item.code,
+    road: item.road,
+    lng: getMidPoint(item.points).lng,
+    lat: getMidPoint(item.points).lat,
+    raw: item
+  }))
+  const points = pointData.value.map(item => ({
+    kind: 'point',
+    type: item.type,
+    name: item.name,
+    code: item.code,
+    road: item.road,
+    lng: item.lng,
+    lat: item.lat,
+    raw: item
+  }))
+  return [...pipelines, ...points]
+})
+
+watch(layerState, () => {
+  renderMapScene()
+}, { deep: true })
+
+onMounted(async () => {
+  await nextTick()
+  await loadGisData()
+  waitForBMapGL()
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
+})
+
+onBeforeUnmount(() => {
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
+  if (mapInstance) {
+    mapInstance.clearOverlays()
+    mapInstance = null
+  }
+})
+
+function waitForBMapGL() {
+  if (typeof BMapGL !== 'undefined') {
+    initMap()
+    return
+  }
+
+  let attempts = 0
+  const maxAttempts = 50
+  const timer = setInterval(() => {
+    attempts++
+    if (typeof BMapGL !== 'undefined') {
+      clearInterval(timer)
+      initMap()
+      return
+    }
+    if (attempts >= maxAttempts) {
+      clearInterval(timer)
+      mapError.value = '百度地图脚本加载超时,请刷新页面重试。'
+    }
+  }, 200)
+}
+
+function initMap() {
+  const el = document.getElementById('gisMap')
+  if (!el) {
+    mapError.value = '地图容器未找到。'
+    return
+  }
+
+  try {
+    mapInstance = new BMapGL.Map('gisMap', { enableMapClick: true })
+  } catch (e) {
+    console.error('创建地图实例失败:', e)
+    mapError.value = '百度地图初始化失败,请检查AK和网络连接。'
+    return
+  }
+
+  try {
+    const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
+    const centerBMapPoint = new BMapGL.Point(clng, clat)
+    mapInstance.centerAndZoom(centerBMapPoint, MAP_DEFAULT_ZOOM)
+    mapInstance.enableScrollWheelZoom(true)
+  } catch (e) {
+    console.error('设置地图中心失败:', e)
+  }
+
+  try {
+    if (typeof mapInstance.setMinZoom === 'function') {
+      mapInstance.setMinZoom(MAP_MIN_ZOOM)
+    }
+    if (typeof mapInstance.setMaxZoom === 'function') {
+      mapInstance.setMaxZoom(MAP_MAX_ZOOM)
+    }
+  } catch (e) {
+    console.warn('设置缩放限制失败:', e)
+  }
+
+  try {
+    if (typeof mapInstance.setMaxBounds === 'function') {
+      const sw = new BMapGL.Point(MAP_BOUNDS.southWest.lng, MAP_BOUNDS.southWest.lat)
+      const ne = new BMapGL.Point(MAP_BOUNDS.northEast.lng, MAP_BOUNDS.northEast.lat)
+      mapInstance.setMaxBounds(new BMapGL.Bounds(sw, ne))
+    }
+  } catch (e) {
+    console.warn('设置地图范围限制失败:', e)
+  }
+
+  mapInstance.addEventListener('click', () => {
+    if (overlayClicked) {
+      overlayClicked = false
+      return
+    }
+    closeInfoWindow()
+    selectedFeature.value = null
+  })
+
+  mapInstance.addEventListener('mousemove', (event) => {
+    try {
+      if (event && event.latlng) {
+        currentCoordinate.value = `${event.latlng.lng.toFixed(4)}, ${event.latlng.lat.toFixed(4)}`
+      }
+    } catch (_) { /* ignore mousemove errors */ }
+  })
+
+  try {
+    renderMapScene()
+  } catch (e) {
+    console.error('渲染地图要素失败:', e)
+    mapError.value = '地图要素渲染失败。'
+  }
+}
+
+function renderMapScene() {
+  if (!mapInstance || typeof BMapGL === 'undefined') return
+
+  closeInfoWindow()
+  try { mapInstance.clearOverlays() } catch (_) { /* ignore */ }
+  try { drawPipelines() } catch (e) { console.warn('绘制供水管网失败:', e) }
+  try { drawPoints() } catch (e) { console.warn('绘制供水设施失败:', e) }
+}
+
+function drawPipelines() {
+  pipelineData.value.forEach(item => {
+    try {
+      if (!layerState[item.type]) return
+      const pts = item.points.map(p => { const [lng, lat] = convertCoord(p.lng, p.lat); return new BMapGL.Point(lng, lat) })
+      const polyline = new BMapGL.Polyline(pts, {
+        strokeColor: colorMap[item.type],
+        strokeWeight: 6,
+        strokeOpacity: 0.92
+      })
+      const firstPt = { lng: convertCoord(item.points[0].lng, item.points[0].lat)[0], lat: convertCoord(item.points[0].lng, item.points[0].lat)[1] }
+      try {
+        polyline.addEventListener('click', (e) => {
+          overlayClicked = true
+          e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+          showPopup(firstPt.lng, firstPt.lat, buildPopupHTML(buildPipelineDetail(item)))
+          selectedFeature.value = buildPipelineDetail(item)
+        })
+      } catch (_) { /* polyline click listener failed */ }
+      mapInstance.addOverlay(polyline)
+
+      const rawMid = getMidPoint(item.points)
+      const mid = { lng: convertCoord(rawMid.lng, rawMid.lat)[0], lat: convertCoord(rawMid.lng, rawMid.lat)[1] }
+      addPipeLabel(mid.lng, mid.lat, item.name, colorMap[item.type], item)
+    } catch (e) {
+      console.warn(`绘制供水管网 ${item.name} 失败:`, e)
+    }
+  })
+}
+
+function drawPoints() {
+  pointData.value.forEach(item => {
+    try {
+      if (!layerState[item.type]) return
+      const [plng, plat] = convertCoord(item.lng, item.lat)
+      addPointMarker(plng, plat, colorMap[item.type], item.name, 'dot', () => {
+        showPopup(plng, plat, buildPopupHTML(buildPointDetail(item)))
+        selectedFeature.value = buildPointDetail(item)
+      })
+    } catch (e) {
+      console.warn(`绘制供水设施 ${item.name} 失败:`, e)
+    }
+  })
+}
+
+function addPipeLabel(lng, lat, text, color, rawItem) {
+  const point = new BMapGL.Point(lng, lat)
+  const html = `<div class="pipe-label" style="color:${color}">${text}</div>`
+  const label = new BMapGL.Label(html, {
+    position: point,
+    offset: new BMapGL.Size(-28, -8)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  try {
+    label.addEventListener('click', (e) => {
+      overlayClicked = true
+      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+      showPopup(lng, lat, buildPopupHTML(buildPipelineDetail(rawItem)))
+      selectedFeature.value = buildPipelineDetail(rawItem)
+    })
+  } catch (_) { /* label click listener failed */ }
+  mapInstance.addOverlay(label)
+}
+
+function addPointMarker(lng, lat, color, name, shape, onClick) {
+  const point = new BMapGL.Point(lng, lat)
+  const size = 22
+  const borderRadius = shape === 'square' ? '3px' : '50%'
+  const html = `<div class="marker-wrap" style="cursor:pointer">
+    <div style="width:${size}px;height:${size}px;background:${color};border-radius:${borderRadius};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,0.25);margin:0 auto;"></div>
+    <div class="marker-text">${name}</div>
+  </div>`
+  const label = new BMapGL.Label(html, {
+    position: point,
+    offset: new BMapGL.Size(-size / 2 - 3, -size - 8)
+  })
+  label.setStyle({ border: 'none', background: 'transparent', padding: '0' })
+  try {
+    label.addEventListener('click', (e) => {
+      overlayClicked = true
+      e.domEvent && e.domEvent.stopPropagation && e.domEvent.stopPropagation()
+      onClick && onClick()
+    })
+  } catch (_) { /* marker click listener failed */ }
+  mapInstance.addOverlay(label)
+}
+
+function showPopup(lng, lat, html) {
+  try {
+    closeInfoWindow()
+    const point = new BMapGL.Point(lng, lat)
+    const infoWindow = new BMapGL.InfoWindow(html, {
+      width: 310,
+      title: '',
+      enableMessage: false
+    })
+    activeInfoWindow = infoWindow
+    mapInstance.openInfoWindow(infoWindow, point)
+  } catch (e) {
+    console.warn('弹出信息窗口失败:', e)
+  }
+}
+
+function closeInfoWindow() {
+  if (activeInfoWindow && mapInstance) {
+    mapInstance.closeInfoWindow()
+    activeInfoWindow = null
+  }
+}
+
+function buildPopupHTML(detail) {
+  const rows = Object.entries(detail.details)
+    .map(([k, v]) => `<div class="popup-row"><span class="popup-label">${k}</span><span class="popup-value">${v}</span></div>`)
+    .join('')
+  return `<div class="info-popup">
+    <div class="popup-header">${detail.name}</div>
+    <div class="popup-tag">${detail.category}</div>
+    <div class="popup-divider"></div>
+    ${rows}
+  </div>`
+}
+
+function buildPipelineDetail(item) {
+  return {
+    name: item.name,
+    category: labelMap[item.type] || '供水管网',
+    details: {
+      '管网编号': item.code || '-',
+      '管网名称': item.name,
+      '管径': item.diameter || '-',
+      '材质': item.material || '-',
+      '起点位置': item.startPoint || '-',
+      '终点位置': item.endPoint || '-',
+      '铺设年份': item.layingYear || '-',
+      '长度': item.length || '-',
+      '运行状态': item.status || '-'
+    }
+  }
+}
+
+function buildPointDetail(item) {
+  return {
+    name: item.name,
+    category: labelMap[item.type] || '供水设施',
+    details: {
+      '设施编号': item.code || '-',
+      '设施名称': item.name,
+      '所属类型': labelMap[item.type] || '-',
+      '地址': item.address || item.location || '-',
+      '状态': item.status || '-',
+      '经度': item.lng.toFixed(5),
+      '纬度': item.lat.toFixed(5)
+    }
+  }
+}
+
+function getMidPoint(points) {
+  return points[Math.floor(points.length / 2)]
+}
+
+async function loadGisData() {
+  try {
+    const [overviewRes, featureRes] = await Promise.all([
+      getWaterFacilityOverview(),
+      getWaterFacilityFeatures()
+    ])
+    const overviewData = unwrapResponse(overviewRes)
+    facilityOverview.value = Array.isArray(overviewData) ? overviewData : (overviewData?.facilityOverview || [])
+    const geoJson = normalizeGeoJson(featureRes)
+    const lines = []
+    const points = []
+    ;(geoJson.features || []).forEach(feature => {
+      const coordinates = feature.geometry?.coordinates || []
+      const properties = feature.properties || {}
+      const type = properties.facilityType || properties.type || ''
+      if (feature.geometry?.type === 'LineString' && coordinates.length > 1) {
+        lines.push({
+          id: feature.id || properties.id,
+          type: type || 'pipe',
+          name: properties.pipeName || properties.name || properties.facilityTypeName || '供水管网',
+          code: properties.pipeCode || properties.code || '-',
+          material: properties.pipeMaterial,
+          diameter: properties.pipeDiameter,
+          startPoint: properties.startPoint,
+          endPoint: properties.endPoint,
+          layingYear: properties.layingYear,
+          length: properties.pipeLength,
+          status: properties.status,
+          road: properties.road || properties.address || '',
+          points: coordinates.map(([lng, lat]) => ({ lng, lat }))
+        })
+      }
+      if (feature.geometry?.type === 'Point' && coordinates.length >= 2) {
+        points.push({
+          id: feature.id || properties.id,
+          type: type,
+          name: properties.facilityName || properties.name || properties.pipeName || properties.facilityTypeName || '供水设施',
+          code: properties.facilityCode || properties.code || '-',
+          address: properties.address || properties.location || '',
+          status: properties.status,
+          road: properties.address || properties.location || '',
+          lng: coordinates[0],
+          lat: coordinates[1]
+        })
+      }
+    })
+    pipelineData.value = lines
+    pointData.value = points
+    if (mapInstance && typeof BMapGL !== 'undefined') renderMapScene()
+  } catch (e) {
+    console.error('加载供水GIS数据失败', e)
+    ElMessage.warning('供水GIS数据加载失败')
+  }
+}
+
+function handleSearch() {
+  const keyword = searchKeyword.value.trim()
+  if (!keyword) {
+    ElMessage.warning('请输入搜索关键字')
+    return
+  }
+
+  const matched = searchableFeatures.value.find(item =>
+    [item.name, item.code, item.road].some(text => text?.includes(keyword))
+  )
+
+  if (!matched) {
+    ElMessage.info('未找到匹配要素')
+    return
+  }
+
+  const [mlng, mlat] = convertCoord(matched.lng, matched.lat)
+  mapInstance.centerAndZoom(new BMapGL.Point(mlng, mlat), 18)
+
+  if (matched.kind === 'pipeline') {
+    selectedFeature.value = buildPipelineDetail(matched.raw)
+    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
+  } else {
+    selectedFeature.value = buildPointDetail(matched.raw)
+    showPopup(mlng, mlat, buildPopupHTML(selectedFeature.value))
+  }
+
+  ElMessage.success(`已定位到 ${matched.name}`)
+}
+
+async function refreshScene() {
+  layerState.source = true
+  layerState.plant = true
+  layerState.pumpStation = true
+  layerState.pipe = true
+  layerState.user = true
+  searchKeyword.value = ''
+  selectedFeature.value = null
+  closeInfoWindow()
+  await loadGisData()
+  locateCenter()
+}
+
+function locateCenter() {
+  if (!mapInstance) return
+  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
+  mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), MAP_DEFAULT_ZOOM)
+}
+
+function zoomIn() {
+  if (!mapInstance) return
+  const z = mapInstance.getZoom()
+  if (z < MAP_MAX_ZOOM) mapInstance.zoomIn()
+  else ElMessage.info('已放大至最大级别')
+}
+
+function zoomOut() {
+  if (!mapInstance) return
+  const z = mapInstance.getZoom()
+  if (z > MAP_MIN_ZOOM) mapInstance.zoomOut()
+  else ElMessage.info('已缩小至最小级别')
+}
+
+function toggleFullscreen() {
+  const target = screenRef.value
+  if (!target) return
+  if (!document.fullscreenElement) {
+    target.requestFullscreen?.()
+    return
+  }
+  document.exitFullscreen?.()
+}
+
+function handleFullscreenChange() {
+  if (!mapInstance) return
+  const [clng, clat] = convertCoord(centerPoint.lng, centerPoint.lat)
+  setTimeout(() => {
+    mapInstance.centerAndZoom(new BMapGL.Point(clng, clat), mapInstance.getZoom())
+  }, 200)
+}
+</script>
+
+
+<style scoped>
+.gis-dashboard {
+  height: calc(100vh - 84px);
+  padding: 16px;
+  background: #f2f5f9;
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+}
+
+.dashboard-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 10px 16px;
+  margin-bottom: 14px;
+  background: #fff;
+  border-radius: 10px;
+  box-shadow: 0 4px 16px rgba(15, 35, 95, 0.05);
+  flex-shrink: 0;
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  flex-wrap: wrap;
+}
+
+.title-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #1f2d3d;
+  font-size: 17px;
+  font-weight: 700;
+}
+
+.title-metrics {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  flex-wrap: wrap;
+}
+
+.metric {
+  position: relative;
+  padding-left: 14px;
+  color: #44546a;
+  font-size: 13px;
+}
+
+.metric::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 50%;
+  width: 9px;
+  height: 9px;
+  border-radius: 50%;
+  transform: translateY(-50%);
+}
+
+.metric-danger::before { background: #e63946; }
+.metric-primary::before { background: #457b9d; }
+.metric-warning::before { background: #7f4f24; }
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.search-input {
+  width: 300px;
+}
+
+.dashboard-content {
+  flex: 1;
+  display: flex;
+  gap: 14px;
+  min-height: 0;
+}
+
+.dashboard-sidebar {
+  width: 320px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  flex-shrink: 0;
+  overflow-y: auto;
+}
+
+.panel-card {
+  border: none;
+  border-radius: 14px;
+  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
+  flex-shrink: 0;
+}
+
+.layer-group {
+  display: flex;
+  flex-direction: column;
+  gap: 9px;
+  padding-bottom: 16px;
+  margin-bottom: 16px;
+  border-bottom: 1px solid #eef2f8;
+}
+
+.layer-group-last {
+  padding-bottom: 0;
+  margin-bottom: 0;
+  border-bottom: none;
+}
+
+.layer-title {
+  color: #303133;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.legend-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: #4e5969;
+  font-size: 13px;
+}
+
+.legend-line {
+  width: 34px;
+  height: 0;
+  border-top-width: 3px;
+  border-top-style: solid;
+  border-radius: 999px;
+  flex-shrink: 0;
+}
+
+.legend-high { border-top-color: #e63946; }
+.legend-sub-high { border-top-color: #f77f00; }
+.legend-medium { border-top-color: #fcbf49; }
+.legend-low { border-top-color: #2a9d8f; }
+
+.legend-dot,
+.legend-square {
+  width: 12px;
+  height: 12px;
+  flex-shrink: 0;
+}
+
+.legend-dot { border-radius: 50%; }
+.legend-square { border-radius: 2px; }
+.legend-valve { background: #457b9d; }
+.legend-regulator { background: #e76f51; }
+.legend-flowmeter { background: #2d6a4f; }
+.legend-terminal { background: #6c757d; }
+.legend-cover { background: #7f4f24; }
+.legend-inspection { background: #b08968; }
+
+.stats-card {
+  background: linear-gradient(180deg, #f8fbff 0%, #f3f7fb 100%);
+}
+
+.stats-list {
+  display: flex;
+  flex-direction: column;
+  gap: 11px;
+}
+
+.stats-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: #4e5969;
+  font-size: 13px;
+}
+
+.stats-row strong {
+  color: #1f2d3d;
+  font-size: 14px;
+}
+
+.detail-card {
+  flex: 1;
+  min-height: 280px;
+}
+
+.detail-body {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.detail-name {
+  color: #1f2d3d;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.detail-tag {
+  display: inline-flex;
+  align-items: center;
+  width: fit-content;
+  padding: 3px 10px;
+  color: #2d6cdf;
+  background: #edf4ff;
+  border-radius: 999px;
+  font-size: 12px;
+}
+
+.dashboard-map-panel {
+  position: relative;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border-radius: 14px;
+  overflow: hidden;
+  box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05);
+  min-width: 0;
+}
+
+.map-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 10px 16px;
+  border-bottom: 1px solid #edf2f7;
+  flex-shrink: 0;
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.toolbar-right {
+  color: #7a8699;
+  font-size: 13px;
+}
+
+.gis-map {
+  flex: 1;
+  min-height: 0;
+  background: #e9f0e5;
+}
+
+.map-error {
+  position: absolute;
+  inset: 52px 0 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  background: rgba(255, 255, 255, 0.85);
+  font-size: 15px;
+  z-index: 10;
+}
+</style>
+
+<style>
+.pipe-label {
+  padding: 2px 8px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1.3;
+  white-space: nowrap;
+  text-shadow: 0 0 4px #fff, 0 0 4px #fff;
+  background: rgba(255, 255, 255, 0.7);
+  border-radius: 10px;
+}
+
+.marker-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.marker-text {
+  margin-top: 3px;
+  padding: 1px 7px;
+  color: #2f3a4f;
+  font-size: 11px;
+  line-height: 1.3;
+  white-space: nowrap;
+  background: rgba(255, 255, 255, 0.78);
+  border-radius: 10px;
+  text-shadow: 0 0 3px #fff;
+}
+
+.info-popup {
+  padding: 4px 2px;
+  font-size: 13px;
+  color: #333;
+}
+
+.popup-header {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1a1a2e;
+  margin-bottom: 6px;
+}
+
+.popup-tag {
+  display: inline-block;
+  padding: 2px 10px;
+  font-size: 11px;
+  color: #1d4ed8;
+  background: #eff6ff;
+  border-radius: 999px;
+  margin-bottom: 10px;
+}
+
+.popup-divider {
+  height: 1px;
+  background: #e5e7eb;
+  margin-bottom: 10px;
+}
+
+.popup-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 5px 0;
+  border-bottom: 1px dashed #f0f0f0;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.popup-row:last-child {
+  border-bottom: none;
+}
+
+.popup-label {
+  color: #6b7280;
+  flex-shrink: 0;
+  margin-right: 12px;
+}
+
+.popup-value {
+  color: #1f2937;
+  font-weight: 500;
+  text-align: right;
+  word-break: break-all;
+}
+
+.BMap_cpyCtrl,
+.anchorBL {
+  display: none;
+}
+</style>

+ 1 - 0
src/views/subSystem/waterSupply/facility/statistics.vue

@@ -0,0 +1 @@
+<script setup>import Page from '../components/WaterFacilityDashboard.vue'</script><template><Page title="管网数据统计分析" /></template>

+ 2 - 14
src/views/subSystem/waterSupply/index.vue

@@ -1,14 +1,2 @@
-<!--供水管网安全运行监测系统-->
-<script setup>
-
-</script>
-
-<template>
-  <div class="app-container">
-    <h1>供水管网安全运行监测系统</h1>
-  </div>
-</template>
-
-<style scoped lang="scss">
-
-</style>
+<script setup>import WaterSupplyHome from './components/WaterSupplyHome.vue'</script>
+<template><WaterSupplyHome /></template>

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

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

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

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

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

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

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

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

+ 1009 - 0
src/views/subSystem/waterSupply/monitorAlarm/workOrder.vue

@@ -0,0 +1,1009 @@
+<!-- 业务工单管理 -->
+<template>
+  <div class="business-work-order">
+    <!-- 顶部搜索区 -->
+    <el-card class="search-card" shadow="never">
+      <div class="search-form">
+        <el-form :model="queryParams" ref="queryFormRef" :inline>
+          <el-form-item label="类型">
+            <el-select v-model="queryParams.orderType" placeholder="全部类型" clearable style="width: 150px">
+              <el-option label="故障维修" :value="1" />
+              <el-option label="日常巡检" :value="2" />
+              <el-option label="设备保养" :value="3" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="优先级">
+            <el-select v-model="queryParams.orderLevel" placeholder="全部优先级" clearable style="width: 120px">
+              <el-option label="紧急" :value="1" />
+              <el-option label="一般" :value="2" />
+              <el-option label="低" :value="3" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="状态">
+            <el-select v-model="queryParams.orderStatus" placeholder="全部状态" clearable style="width: 120px">
+              <el-option label="待派单" :value="1" />
+              <el-option label="待接单" :value="2" />
+              <el-option label="已接单" :value="3" />
+              <el-option label="处理中" :value="4" />
+              <el-option label="已提交验收" :value="5" />
+              <el-option label="已结案" :value="6" />
+              <el-option label="已驳回" :value="7" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="关键词">
+            <el-input
+              v-model="queryParams.keyword"
+              placeholder="工单编号/设备编码"
+              clearable
+              style="width: 200px"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="handleSearch">查询</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+    </el-card>
+
+    <!-- 主容器 -->
+    <el-row :gutter="16" style="height: calc(100vh - 200px)">
+      <!-- 左侧:工单列表 -->
+      <el-col :span="8" style="height: 100%">
+        <el-card class="box-card" shadow="always">
+          <template #header>
+            <div class="card-header">
+              <span>工单列表</span>
+            </div>
+          </template>
+
+          <!-- 工单列表 -->
+          <el-scrollbar style="flex: 1">
+            <div class="order-list">
+              <div
+                v-for="order in tableData"
+                :key="order.orderId"
+                class="order-item"
+                :class="{ 'active': selectedOrderId === order.orderId }"
+                @click="handleSelectOrder(order)"
+              >
+                <div class="order-header">
+                  <span class="order-no">{{ order.orderNo }}</span>
+                  <el-tag
+                    v-if="order.orderStatus === 1"
+                    type="warning"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >待派单</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 2"
+                    type="info"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >待接单</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 3"
+                    type="success"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >已接单</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 4"
+                    type="danger"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >处理中</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 5"
+                    type="success"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >已提交验收</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 6"
+                    type="info"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >已结案</el-tag>
+                  <el-tag
+                    v-else-if="order.orderStatus === 7"
+                    type="danger"
+                    size="small"
+                    effect="light"
+                    class="status-badge"
+                  >已驳回</el-tag>
+                </div>
+                <div class="order-body">
+                  <div class="order-tags">
+                    <el-tag :type="getLevelTag(order.orderLevel)" size="small" effect="plain">{{ getLevelLabel(order.orderLevel) }}</el-tag>
+                    <el-tag :type="getTypeTag(order.orderType)" size="small" effect="plain">{{ getTypeLabel(order.orderType) }}</el-tag>
+                  </div>
+                  <div class="order-desc" v-if="order.orderDesc">{{ order.orderDesc }}</div>
+                  <div class="device-code">设备编号:{{ order.deviceCode || '-' }}</div>
+                  <div class="device-code" v-if="order.hazardNo">关联隐患:{{ order.hazardNo }}</div>
+                  <div class="create-time">创建:{{ formatDate(order.createTime) }}</div>
+                </div>
+              </div>
+              <el-empty v-if="tableData.length === 0" description="暂无数据" :image-size="100"></el-empty>
+            </div>
+          </el-scrollbar>
+
+          <!-- 分页 -->
+          <div class="pagination-wrapper">
+            <el-pagination
+              v-model:current-page="pageNum"
+              v-model:page-size="pageSize"
+              :total="total"
+              layout="total, prev, pager, next"
+              :page-sizes="[10, 20, 50, 100]"
+              @size-change="handleSizeChange"
+              @current-change="handlePageChange"
+              class="pagination"
+            />
+          </div>
+        </el-card>
+      </el-col>
+
+      <!-- 右侧:工单详情 -->
+      <el-col :span="16" style="height: 100%">
+        <el-card v-loading="detailLoading" class="box-card" shadow="always">
+          <template #header>
+            <div class="card-header">
+              <span v-if="selectedOrder && selectedOrder.orderId">工单详情 - {{ selectedOrder.orderNo }}</span>
+              <span v-else>请选择工单查看详情</span>
+            </div>
+          </template>
+
+          <div v-if="selectedOrder && selectedOrder.orderId">
+            <!-- 标签页 -->
+            <el-tabs v-model="activeTab" class="detail-tabs">
+              <!-- 工单信息 -->
+              <el-tab-pane label="工单信息" name="info">
+                <el-descriptions :column="2" border size="small">
+                  <el-descriptions-item label="工单编号">{{ selectedOrder.orderNo }}</el-descriptions-item>
+                  <el-descriptions-item label="工单状态">
+                    <el-tag
+                      v-if="selectedOrder.orderStatus === 1"
+                      type="warning"
+                      size="small"
+                    >待派单</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 2"
+                      type="info"
+                      size="small"
+                    >待接单</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 3"
+                      type="success"
+                      size="small"
+                    >已接单</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 4"
+                      type="danger"
+                      size="small"
+                    >处理中</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 5"
+                      type="success"
+                      size="small"
+                    >已提交验收</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 6"
+                      type="info"
+                      size="small"
+                    >已结案</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderStatus === 7"
+                      type="danger"
+                      size="small"
+                    >已驳回</el-tag>
+                  </el-descriptions-item>
+                  <el-descriptions-item label="关联案件编号">{{ selectedOrder.caseNo || '-' }}</el-descriptions-item>
+                  <el-descriptions-item label="关联隐患编号">{{ selectedOrder.hazardNo || '-' }}</el-descriptions-item>
+                  <el-descriptions-item label="设备编号">{{ selectedOrder.deviceCode || '-' }}</el-descriptions-item>
+                  <el-descriptions-item label="优先级">
+                    <el-tag
+                      v-if="selectedOrder.orderLevel === 1"
+                      type="danger"
+                      size="small"
+                    >紧急</el-tag>
+                    <el-tag
+                      v-else-if="selectedOrder.orderLevel === 2"
+                      type="warning"
+                      size="small"
+                    >一般</el-tag>
+                    <el-tag
+                      v-else
+                      type="info"
+                      size="small"
+                    >低</el-tag>
+                  </el-descriptions-item>
+                  <el-descriptions-item label="工单类型">{{ getOrderTypeText(selectedOrder.orderType) }}</el-descriptions-item>
+                  <el-descriptions-item label="问题描述" :span="2">{{ selectedOrder.orderDesc }}</el-descriptions-item>
+                  <el-descriptions-item label="创建时间">{{ formatDate(selectedOrder.createTime) }}</el-descriptions-item>
+                  <el-descriptions-item label="更新时间">{{ formatDate(selectedOrder.updateTime) }}</el-descriptions-item>
+                  <el-descriptions-item label="运维状态">{{ selectedOrder.currentStatusText || selectedOrder.currentStatus || '-' }}</el-descriptions-item>
+                  <el-descriptions-item label="在线状态">{{ Number(selectedOrder.onlineStatus) === 1 ? '在线' : '离线' }}</el-descriptions-item>
+                  <el-descriptions-item label="报警状态">{{ Number(selectedOrder.alarmStatus) === 1 ? '报警' : '正常' }}</el-descriptions-item>
+                  <el-descriptions-item label="处置结果">{{ selectedOrder.repairResult || '-' }}</el-descriptions-item>
+                </el-descriptions>
+
+                <!-- 工单图片 -->
+                <div v-if="workImageList.length > 0" class="image-section">
+                  <h4 class="section-title">工单图片</h4>
+                  <div class="image-list">
+                    <image-preview
+                      v-for="(img, index) in workImageList"
+                      :key="index"
+                      :src="img"
+                      :width="100"
+                      :height="100"
+                    />
+                  </div>
+                </div>
+
+                <!-- 工单操作 -->
+                <div class="action-section">
+                  <h4 class="section-title">工单操作</h4>
+                  <div class="action-buttons">
+                    <el-button
+                      v-if="canAccept()"
+                      type="primary"
+                      @click="handleAccept"
+                    >接单</el-button>
+                    <el-button
+                      v-if="canStart()"
+                      type="success"
+                      @click="handleStart"
+                    >开始处理</el-button>
+                    <el-button
+                      v-if="canDispatch()"
+                      type="primary"
+                      icon="Download"
+                      @click="handleDispatch"
+                    >下派任务</el-button>
+                    <el-button
+                      v-if="canVerify()"
+                      type="warning"
+                      icon="Check"
+                      @click="handleVerify"
+                    >核查</el-button>
+                    <el-button
+                      v-if="canProcess()"
+                      type="success"
+                      icon="Edit"
+                      @click="handleProcess"
+                    >处理</el-button>
+                    <el-button
+                      v-if="canComplete()"
+                      type="danger"
+                      icon="Checked"
+                      @click="handleComplete"
+                    >提交验收</el-button>
+                    <el-button
+                      v-if="canClose()"
+                      type="success"
+                      icon="CircleCheck"
+                      @click="handleClose"
+                    >验收通过</el-button>
+                    <el-button
+                      v-if="canReject()"
+                      type="info"
+                      icon="Close"
+                      @click="handleReject"
+                    >驳回</el-button>
+                  </div>
+                </div>
+              </el-tab-pane>
+
+              <!-- 处理历史 -->
+              <el-tab-pane label="处理历史" name="history">
+                <div class="timeline-scroll">
+                <el-timeline v-if="currentOrderLogs && currentOrderLogs.length > 0">
+                  <el-timeline-item
+                    v-for="log in currentOrderLogs"
+                    :key="log.logId"
+                    :timestamp="formatDate(log.operTime)"
+                    placement="top"
+                    :type="getLogType(log.operType)"
+                    :size="'large'"
+                  >
+                    <el-card shadow="hover">
+                      <div class="timeline-content">
+                        <div class="timestamp">{{ formatDate(log.operTime) }}</div>
+                        <div class="operator-info">
+                          <span class="operator-name">{{ log.operUserName }}</span>
+                          <span class="oper-type">· {{ getOperTypeLabel(log.operType) }}</span>
+                        </div>
+                        <div class="oper-desc">{{ log.operDesc }}</div>
+                      </div>
+                    </el-card>
+                  </el-timeline-item>
+                </el-timeline>
+                <el-empty v-else description="暂无处理记录" :image-size="100"></el-empty>
+                </div>
+              </el-tab-pane>
+            </el-tabs>
+          </div>
+
+          <el-empty v-else description="请从左侧选择工单" :image-size="150"></el-empty>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 派单弹窗 -->
+    <el-dialog v-model="dispatchDialogVisible" title="下派任务 - 选择接单人" width="750px">
+      <div style="display: flex; gap: 16px; min-height: 350px;">
+        <!-- 左侧:部门树 -->
+        <div style="width: 280px; border-right: 1px solid #e4e7ed; padding-right: 12px;">
+          <el-input
+            v-model="deptFilterText"
+            placeholder="筛选部门"
+            clearable
+            size="small"
+            style="margin-bottom: 8px"
+          />
+          <el-tree
+            ref="deptTreeRef"
+            :data="deptTreeData"
+            :props="{ label: 'deptName', children: 'children' }"
+            :filter-node-method="filterDeptNode"
+            node-key="deptId"
+            highlight-current
+            @node-click="handleDeptClick"
+            style="font-size: 14px"
+          />
+        </div>
+        <!-- 右侧:用户列表 -->
+        <div style="flex: 1; min-width: 0;">
+          <el-table
+            :data="userList"
+            border
+            size="small"
+            max-height="300"
+            ref="userTableRef"
+            row-key="userId"
+            @selection-change="handleSelectionChange"
+            @row-click="handleRowClick"
+          >
+            <el-table-column type="selection" width="40" align="center" :reserve-selection="true" />
+            <el-table-column prop="userName" label="用户名" width="100" />
+            <el-table-column prop="nickName" label="昵称" width="100" />
+            <el-table-column prop="dept.deptName" label="部门" show-overflow-tooltip />
+          </el-table>
+          <div v-if="userList.length === 0" style="text-align: center; color: #909399; padding: 40px 0;">请先在左侧选择部门</div>
+        </div>
+      </div>
+      <el-form :model="dispatchForm" label-width="80px" style="margin-top: 16px">
+        <el-form-item label="备注"><el-input v-model="dispatchForm.remarks" placeholder="请输入派单备注" /></el-form-item>
+      </el-form>
+      <template #footer>
+      <span style="float: left; line-height: 32px; color: #606266;">
+        已选人员:<span v-if="selectedUserNames.length > 0" style="color: #409eff; font-weight: bold;">{{ selectedUserNames.join(', ') }}</span>
+        <span v-else style="color: #909399">未选择</span>
+        (<span v-if="dispatchForm.handlerIds && dispatchForm.handlerIds.length > 0">{{ dispatchForm.handlerIds.length }}</span><span v-else>0</span>)
+      </span>
+      <el-button @click="dispatchDialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="submitDispatch">确认</el-button>
+    </template>
+    </el-dialog>
+
+    <!-- 核查/处理弹窗 -->
+    <el-dialog v-model="actionDialogVisible" :title="actionTitle" width="600px">
+      <el-input v-model="actionFormData.solution" type="textarea" :rows="6" placeholder="请输入说明文字" />
+      <template #footer>
+        <el-button @click="actionDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitAction">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 完成/验收弹窗 -->
+    <el-dialog v-model="completeDialogVisible" title="提交验收" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="验收意见">
+          <el-input v-model="completeForm.result" type="textarea" :rows="4" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="completeDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitComplete">提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 验收通过/结案弹窗 -->
+    <el-dialog v-model="closeDialogVisible" title="验收通过" width="500px">
+      <el-form label-width="80px">
+        <el-form-item label="验收结果">
+          <el-input v-model="closeForm.result" type="textarea" :rows="4" placeholder="请输入验收结果/整改反馈" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="closeDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitClose">确认验收</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="BusinessWorkOrder">
+import { ElMessage } from 'element-plus'
+import ImagePreview from '@/components/ImagePreview'
+import { listWorkOrder, getWorkOrderDetail, dispatchWorkOrder, verifyWorkOrder, acceptWorkOrder, startWorkOrder, processWorkOrder, completeWorkOrder, closeWorkOrder, rejectWorkOrder } from '@/api/work/order'
+import { listDept } from '@/api/system/dept'
+import { listUser } from '@/api/system/user'
+import { handleTree } from '@/utils/ruoyi'
+
+const listLoading = ref(false)
+const tableData = ref([])
+const pageNum = ref(1)
+const pageSize = ref(10)
+const total = ref(0)
+const queryFormRef = ref(null)
+const queryParams = reactive({
+  orderType: null,
+  orderLevel: null,
+  keyword: '',
+  // 供水工单:只查询供水类型设备工单
+    equipmentType: 'water'
+})
+
+// 选中的工单
+const selectedOrderId = ref(null)
+const selectedOrder = ref({})
+const activeTab = ref('info')
+const currentOrderLogs = ref([])
+
+// 工单图片列表(多个用逗号分隔)
+const workImageList = computed(() => {
+  if (!selectedOrder.value.workImage) return []
+  return selectedOrder.value.workImage.split(',').filter(img => img && img.trim())
+})
+
+// 派单相关
+const dispatchDialogVisible = ref(false)
+const dispatchForm = reactive({ handlerIds: [], remarks: '' })
+const deptTreeRef = ref(null)
+const userTableRef = ref(null)
+const deptTreeData = ref([])
+const deptFilterText = ref('')
+const userList = ref([])
+const selectedUserList = ref([]) // 存储选中的用户行数据
+
+// 部门筛选
+watch(deptFilterText, (val) => { deptTreeRef.value && deptTreeRef.value.filter(val) })
+function filterDeptNode(value, data) {
+  if (!value) return true
+  return data.deptName.includes(value)
+}
+
+// 加载部门树
+async function loadDeptTree() {
+  if (deptTreeData.value.length > 0) return
+  try {
+    const res = await listDept()
+    deptTreeData.value = handleTree(res.data || [], 'deptId')
+  } catch (e) { console.error('加载部门失败', e) }
+}
+
+// 点击部门加载用户
+async function handleDeptClick(node) {
+  try {
+    // 清除当前选择
+    userTableRef.value && userTableRef.value.clearSelection()
+    selectedUserList.value = []
+    dispatchForm.handlerIds = []
+
+    const res = await listUser({ deptId: node.deptId, pageNum: 1, pageSize: 100 })
+    userList.value = res.rows || []
+  } catch (e) { console.error('加载用户失败', e) }
+}
+
+// 选择变化时触发
+function handleSelectionChange(selection) {
+  selectedUserList.value = selection
+  dispatchForm.handlerIds = selection.map(item => item.userId)
+}
+
+// 点击行切换选中状态
+function handleRowClick(row) {
+  userTableRef.value && userTableRef.value.toggleRowSelection(row)
+}
+
+// 获取已选人员昵称(用于显示)
+const selectedUserNames = computed(() => {
+  return selectedUserList.value.map(item => item.nickName || item.userName)
+})
+
+/** 查询工单列表 */
+async function handleSearch() {
+  listLoading.value = true
+  try {
+    const res = await listWorkOrder(queryParams, pageNum.value, pageSize.value)
+    if (res.code === 200) {
+      tableData.value = res.data.records || []
+      total.value = res.data.total || 0
+    }
+  } catch (e) {
+    ElMessage.error('查询失败')
+  } finally {
+    listLoading.value = false
+  }
+}
+
+/** 重置搜索 */
+function handleReset() {
+  queryFormRef.value && queryFormRef.value.resetFields()
+  queryParams.orderType = null
+  queryParams.orderLevel = null
+  queryParams.keyword = ''
+  pageNum.value = 1
+  selectedOrderId.value = null
+  selectedOrder.value = {}
+  handleSearch()
+}
+
+/** 选择工单 */
+async function handleSelectOrder(order) {
+  selectedOrderId.value = order.orderId
+  selectedOrder.value = { ...order } // 克隆对象确保更新
+  activeTab.value = 'info'
+  currentOrderLogs.value = []
+
+  // 加载工单详情和处理历史
+  await loadOrderDetail(order.orderId)
+}
+
+/** 分页变化 */
+function handleSizeChange() {
+  pageNum.value = 1
+  selectedOrderId.value = null
+  selectedOrder.value = {}
+  handleSearch()
+}
+
+function handlePageChange() {
+  selectedOrderId.value = null
+  selectedOrder.value = {}
+  handleSearch()
+}
+
+/** 派单、核查、处理、完成、驳回的判断函数(示例) */
+function canDispatch() { return selectedOrder.value.orderStatus === 1 }
+function canAccept() { return selectedOrder.value.orderStatus === 2 }
+function canStart() { return selectedOrder.value.orderStatus === 3 }
+function canVerify() { return [2, 3].includes(selectedOrder.value.orderStatus) }
+function canProcess() { return [3, 4].includes(selectedOrder.value.orderStatus) }
+function canComplete() { return selectedOrder.value.orderStatus === 4 }
+function canClose() { return selectedOrder.value.orderStatus === 5 }
+function canReject() { return [1, 2].includes(selectedOrder.value.orderStatus) }
+
+function handleDispatch() {
+  if (!canDispatch()) {
+    ElMessage.warning('仅可对待派单工单进行派单操作')
+    return
+  }
+  Object.assign(dispatchForm, { handlerIds: [], remarks: '' })
+  selectedUserList.value = []
+  userList.value = []
+  loadDeptTree()
+  dispatchDialogVisible.value = true
+}
+
+async function handleAccept() {
+  try {
+    await acceptWorkOrder(selectedOrder.value.orderId)
+    ElMessage.success('接单成功')
+    await handleSearch()
+    await loadOrderDetail(selectedOrder.value.orderId)
+  } catch (e) { ElMessage.error('接单失败') }
+}
+
+async function handleStart() {
+  try {
+    await startWorkOrder(selectedOrder.value.orderId)
+    ElMessage.success('已开始处理')
+    await handleSearch()
+    await loadOrderDetail(selectedOrder.value.orderId)
+  } catch (e) { ElMessage.error('开始处理失败') }
+}
+
+async function submitDispatch() {
+  if (!dispatchForm.handlerIds || dispatchForm.handlerIds.length === 0) {
+    ElMessage.warning('请选择至少一个接单人')
+    return
+  }
+  try {
+    await dispatchWorkOrder({
+      orderId: selectedOrder.value.orderId,
+      handlerIds: dispatchForm.handlerIds, // 传递数组
+      remarks: dispatchForm.remarks
+    })
+    ElMessage.success('派单成功!已向 ' + dispatchForm.handlerIds.length + ' 人下发任务')
+    dispatchDialogVisible.value = false
+    handleSearch()
+  } catch (e) {
+    ElMessage.error('派单失败')
+  }
+}
+
+/** 核查、处理相关 */
+const actionDialogVisible = ref(false)
+const actionTitle = ref('')
+const actionFormData = reactive({ solution: '' })
+
+function handleVerify() {
+  if (![2, 3].includes(selectedOrder.value.orderStatus)) {
+    ElMessage.warning('当前工单不允许核查')
+    return
+  }
+  actionTitle.value = '核查工单'
+  actionFormData.solution = ''
+  actionDialogVisible.value = true
+}
+
+function handleProcess() {
+  if (![3, 4].includes(selectedOrder.value.orderStatus)) {
+    ElMessage.warning('当前工单不允许处理')
+    return
+  }
+  actionTitle.value = '处理工单'
+  actionFormData.solution = ''
+  actionDialogVisible.value = true
+}
+
+async function submitAction() {
+  if (!actionFormData.solution.trim()) {
+    ElMessage.warning('请填写说明')
+    return
+  }
+  try {
+    const data = {
+      orderId: selectedOrder.value.orderId,
+      handleResult: 'solved',
+      solution: actionFormData.solution
+    }
+    if (actionTitle.value === '核查工单') {
+      await verifyWorkOrder(data)
+    } else {
+      await processWorkOrder(data)
+    }
+    ElMessage.success(actionTitle.value + '成功')
+    actionDialogVisible.value = false
+    handleSearch()
+  } catch (e) {
+    ElMessage.error('操作失败')
+  }
+}
+
+
+/** 完成/验收相关 */
+const completeDialogVisible = ref(false)
+const completeForm = reactive({ result: '' })
+
+function handleComplete() {
+  if (![4, 5].includes(selectedOrder.value.orderStatus)) {
+    ElMessage.warning('当前工单不允许提交验收')
+    return
+  }
+  completeForm.result = ''
+  completeDialogVisible.value = true
+}
+
+async function submitComplete() {
+  if (!completeForm.result.trim()) {
+    ElMessage.warning('请填写验收意见')
+    return
+  }
+  try {
+    await completeWorkOrder(selectedOrder.value.orderId, completeForm.result)
+    ElMessage.success('提交验收成功')
+    completeDialogVisible.value = false
+    handleSearch()
+  } catch (e) {
+    ElMessage.error('提交失败')
+  }
+}
+
+/** 驳回相关 */
+function handleReject() {
+  if (![1, 2].includes(selectedOrder.value.orderStatus)) {
+    ElMessage.warning('当前工单不允许驳回')
+    return
+  }
+  rejectWorkOrder({ orderId: selectedOrder.value.orderId }).then(async () => {
+    ElMessage.success('工单已驳回')
+    await handleSearch()
+    await loadOrderDetail(selectedOrder.value.orderId)
+  }).catch(() => ElMessage.error('驳回失败'))
+}
+
+/** 验收通过/结案相关 */
+const closeDialogVisible = ref(false)
+const closeForm = reactive({ result: '' })
+
+function handleClose() {
+  if (selectedOrder.value.orderStatus !== 5) {
+    ElMessage.warning('当前工单不允许验收通过')
+    return
+  }
+  closeForm.result = ''
+  closeDialogVisible.value = true
+}
+
+async function submitClose() {
+  if (!closeForm.result.trim()) {
+    ElMessage.warning('请填写验收结果')
+    return
+  }
+  try {
+    await closeWorkOrder({
+      orderId: selectedOrder.value.orderId,
+      result: closeForm.result
+    })
+    ElMessage.success('验收通过,关联隐患已同步为已整改')
+    closeDialogVisible.value = false
+    handleSearch()
+  } catch (e) {
+    ElMessage.error('验收失败')
+  }
+}
+
+/** 辅助函数 */
+function getOrderTypeText(type) {
+  const map = { 1: '故障维修', 2: '日常巡检', 3: '设备保养' }
+  return map[type] || '-'
+}
+
+function getOperTypeLabel(type) {
+  const map = {
+    1: '创建工单', 2: '派单', 3: '接单', 4: '开始维修',
+    5: '提交验收', 6: '验收通过', 7: '驳回工单', 8: '申请延期'
+  }
+  return map[type] || '-'
+}
+
+function getLogType(type) {
+  const map = {
+    1: 'primary', 2: 'success', 3: 'warning', 4: 'danger',
+    5: 'success', 6: 'success', 7: 'danger', 8: 'warning'
+  }
+  return map[type] || ''
+}
+
+function formatDate(dateStr) {
+  if (!dateStr) return '-'
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+/** 优先级标签 */
+function getLevelLabel(level) {
+  const map = { 1: '紧急', 2: '一般', 3: '低' }
+  return map[level] || '未知'
+}
+function getLevelTag(level) {
+  const map = { 1: 'danger', 2: 'warning', 3: 'info' }
+  return map[level] || 'info'
+}
+
+/** 工单类型标签 */
+function getTypeLabel(type) {
+  const map = { 1: '故障维修', 2: '日常巡检', 3: '设备保养' }
+  return map[type] || '未知'
+}
+function getTypeTag(type) {
+  const map = { 1: 'danger', 2: 'primary', 3: 'success' }
+  return map[type] || 'info'
+}
+
+/** 加载工单详情和处理历史 */
+async function loadOrderDetail(orderId) {
+  try {
+    const res = await getWorkOrderDetail(orderId)
+    if (res.code === 200) {
+      selectedOrder.value = { ...selectedOrder.value, ...res.data.order }
+      currentOrderLogs.value = res.data.logs || []
+    }
+  } catch (e) {
+    ElMessage.error('加载工单详情失败')
+  }
+}
+
+onMounted(() => {
+  handleSearch()
+})
+</script>
+
+<style scoped lang="scss">
+.business-work-order {
+  padding: 0 16px 16px;
+
+  .search-card {
+    margin-bottom: 16px;
+
+    :deep(.el-card__body) {
+      padding: 16px;
+    }
+  }
+
+  .search-form {
+    .el-form {
+      display: flex;
+      flex-wrap: wrap;
+      align-items: flex-start;
+      gap: 12px;
+
+      .el-form-item {
+        margin-bottom: 0;
+        min-width: 150px;
+
+        &:last-child {
+          margin-left: auto;
+        }
+      }
+    }
+  }
+
+  .order-list {
+    overflow-y: auto;
+  }
+
+  .order-item {
+    padding: 16px;
+    margin-bottom: 8px;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s;
+    border: 1px solid #e4e7ed;
+
+    &:hover {
+      background-color: #f5f7fa;
+    }
+
+    &.active {
+      background-color: #ecf5ff;
+      border-color: #409eff;
+    }
+
+    .order-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 8px;
+
+      .order-no {
+        font-weight: bold;
+        font-size: 14px;
+      }
+
+      .status-badge {
+        min-width: 60px;
+      }
+    }
+
+    .order-body {
+      font-size: 12px;
+      color: #606266;
+
+      .order-tags {
+        display: flex;
+        gap: 6px;
+        margin-bottom: 6px;
+      }
+
+      .order-desc {
+        margin-top: 4px;
+        color: #303133;
+        font-size: 13px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+
+      .device-code, .create-time {
+        margin-top: 4px;
+      }
+    }
+  }
+
+  .pagination-wrapper {
+    padding: 16px 0;
+    border-top: 1px solid #e4e7ed;
+    margin-top: 8px;
+
+    .pagination {
+      display: flex;
+      justify-content: flex-end;
+    }
+  }
+
+  .box-card {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+
+    :deep(.el-card__body) {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+  }
+
+  .detail-tabs {
+    margin-top: 16px;
+  }
+
+  .timeline-scroll {
+    max-height: calc(100vh - 340px);
+    overflow-y: auto;
+    padding-right: 8px;
+  }
+
+  .image-section {
+    margin-top: 24px;
+    padding-top: 24px;
+    border-top: 1px solid #e4e7ed;
+
+    .image-list {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 12px;
+    }
+  }
+
+  .action-section {
+    margin-top: 24px;
+    padding-top: 24px;
+    border-top: 1px solid #e4e7ed;
+
+    .section-title {
+      font-size: 16px;
+      font-weight: bold;
+      margin-bottom: 16px;
+    }
+
+    .action-buttons {
+      display: flex;
+      gap: 12px;
+      flex-wrap: wrap;
+
+      .el-button {
+        min-width: 100px;
+      }
+    }
+  }
+
+  .timeline-content {
+    .timestamp {
+      font-size: 12px;
+      color: #909399;
+      margin-bottom: 6px;
+    }
+
+    .operator-info {
+      font-size: 14px;
+      margin-bottom: 8px;
+
+      .operator-name {
+        font-weight: bold;
+        color: #303133;
+      }
+
+      .oper-type {
+        color: #909399;
+      }
+    }
+
+    .oper-desc {
+      font-size: 14px;
+      color: #606266;
+    }
+  }
+}
+</style>

+ 1 - 0
src/views/subSystem/waterSupply/monitoring/flow.vue

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

+ 1 - 0
src/views/subSystem/waterSupply/monitoring/leakage.vue

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

+ 1 - 0
src/views/subSystem/waterSupply/monitoring/pressure.vue

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

+ 2 - 14
src/views/subSystem/waterSupply/whome/wHome.vue

@@ -1,14 +1,2 @@
-<!--供水管网安全运行监测系统-->
-<script setup>
-
-</script>
-
-<template>
-  <div class="app-container">
-    <h1>供水管网安全运行监测系统</h1>
-  </div>
-</template>
-
-<style scoped lang="scss">
-
-</style>
+<script setup>import WaterSupplyHome from '../components/WaterSupplyHome.vue'</script>
+<template><WaterSupplyHome /></template>

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

@@ -1,442 +1 @@
-<!--报警管理-->
-<template>
-  <div class="auto-alert-system">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><AlarmClock /></el-icon>
-        <span class="title">监测设备自动报警系统</span>
-        <div class="stats-badge">
-          <span class="stat-item"><i class="dot"></i>报警规则 {{ ruleCount }}</span>
-          <span class="stat-item critical"><i class="dot"></i>实时报警 {{ activeAlertCount }}</span>
-          <span class="stat-item"><i class="dot"></i>今日触发 {{ todayTriggerCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-button type="primary" :icon="Plus" @click="showRuleDialog">新增规则</el-button>
-        <el-button :icon="Refresh" @click="refreshData">刷新</el-button>
-        <el-button :icon="Setting" @click="showGlobalConfig">全局配置</el-button>
-      </div>
-    </div>
-
-    <!-- 实时报警看板 -->
-    <div class="alert-board">
-      <div class="board-header">
-        <span><el-icon><BellFilled /></el-icon> 实时报警看板</span>
-        <el-tag v-if="activeAlertCount > 0" type="danger" effect="dark">{{ activeAlertCount }} 个活跃报警</el-tag>
-        <el-tag v-else type="success" effect="dark">系统正常</el-tag>
-      </div>
-      <div class="alert-list">
-        <div
-            v-for="alert in activeAlerts"
-            :key="alert.id"
-            class="alert-item"
-            :class="{ critical: alert.level === 'critical', warning: alert.level === 'warning' }"
-        >
-          <div class="alert-icon">
-            <el-icon><WarningFilled /></el-icon>
-          </div>
-          <div class="alert-content">
-            <div class="alert-title">{{ alert.deviceName }}</div>
-            <div class="alert-desc">{{ alert.ruleName }}: {{ alert.currentValue }} {{ alert.unit }} (阈值: {{ alert.threshold }} {{ alert.unit }})</div>
-            <div class="alert-time">{{ alert.triggerTime }}</div>
-          </div>
-          <div class="alert-action">
-            <el-button size="small" type="primary" @click="viewAlertDetail(alert)">处理</el-button>
-          </div>
-        </div>
-        <div v-if="activeAlerts.length === 0" class="empty-alert">
-          <el-empty description="暂无活跃报警" :image-size="80" />
-        </div>
-      </div>
-    </div>
-
-    <!-- 报警规则列表 -->
-    <div class="rule-panel">
-      <div class="panel-header">
-        <span><el-icon><List /></el-icon> 报警规则配置</span>
-        <el-input
-            v-model="ruleSearch"
-            placeholder="搜索规则名称/设备类型"
-            clearable
-            :prefix-icon="Search"
-            style="width: 200px"
-            size="small"
-        />
-      </div>
-      <el-table :data="filteredRules" stripe border style="width: 100%">
-        <el-table-column prop="name" label="规则名称" width="160" />
-        <el-table-column prop="deviceType" label="适用设备类型" width="140" />
-        <el-table-column prop="monitorParam" label="监测参数" width="120" />
-        <el-table-column prop="condition" label="触发条件" width="100">
-          <template #default="{ row }">
-            <el-tag size="small">{{ getConditionText(row.condition) }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="threshold" label="阈值" width="100">
-          <template #default="{ row }">
-            {{ row.threshold }} {{ row.unit }}
-          </template>
-        </el-table-column>
-        <el-table-column prop="alertLevel" label="报警级别" width="100">
-          <template #default="{ row }">
-            <el-tag :type="row.alertLevel === 'critical' ? 'danger' : 'warning'" size="small">
-              {{ row.alertLevel === 'critical' ? '严重' : '预警' }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="status" label="状态" width="80" align="center">
-          <template #default="{ row }">
-            <el-switch v-model="row.status" active-text="启用" inactive-text="停用" @change="toggleRule(row)" />
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="120" fixed="right" align="center">
-          <template #default="{ row }">
-            <el-button link type="primary" size="small" @click="editRule(row)">编辑</el-button>
-            <el-button link type="danger" size="small" @click="deleteRule(row)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-    </div>
-
-    <!-- 规则编辑对话框 -->
-    <el-dialog v-model="ruleDialogVisible" :title="ruleDialogTitle" width="500px">
-      <el-form :model="ruleForm" label-width="100px">
-        <el-form-item label="规则名称" required>
-          <el-input v-model="ruleForm.name" placeholder="请输入规则名称" />
-        </el-form-item>
-        <el-form-item label="适用设备类型" required>
-          <el-select v-model="ruleForm.deviceType" placeholder="请选择" style="width: 100%">
-            <el-option label="智能井盖" value="smart" />
-            <el-option label="水位计" value="water" />
-            <el-option label="气体监测仪" value="gas" />
-            <el-option label="压力监测仪" value="pressure" />
-            <el-option label="流量计" value="flow" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="监测参数" required>
-          <el-select v-model="ruleForm.monitorParam" placeholder="请选择" style="width: 100%">
-            <el-option label="甲烷浓度" value="methane" />
-            <el-option label="水位深度" value="waterLevel" />
-            <el-option label="倾斜角度" value="tilt" />
-            <el-option label="管道压力" value="pressure" />
-            <el-option label="瞬时流量" value="flow" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="触发条件" required>
-          <el-radio-group v-model="ruleForm.condition">
-            <el-radio value="gt">大于 (>)</el-radio>
-            <el-radio value="lt">小于 (<)</el-radio>
-            <el-radio value="gte">大于等于 (≥)</el-radio>
-            <el-radio value="lte">小于等于 (≤)</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="阈值" required>
-          <el-input-number v-model="ruleForm.threshold" :min="0" :step="1" style="width: 150px" />
-          <span style="margin-left: 8px">{{ ruleForm.unit }}</span>
-        </el-form-item>
-        <el-form-item label="报警级别" required>
-          <el-radio-group v-model="ruleForm.alertLevel">
-            <el-radio value="warning">预警</el-radio>
-            <el-radio value="critical">严重报警</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="报警说明">
-          <el-input type="textarea" v-model="ruleForm.description" placeholder="报警触发时的说明信息" rows="2" />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="ruleDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveRule">保存</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 全局配置对话框 -->
-    <el-dialog v-model="globalConfigVisible" title="全局报警配置" width="450px">
-      <el-form label-width="120px">
-        <el-form-item label="报警轮询间隔">
-          <el-input-number v-model="globalConfig.pollInterval" :min="5" :max="300" /> 秒
-        </el-form-item>
-        <el-form-item label="重复报警间隔">
-          <el-input-number v-model="globalConfig.repeatInterval" :min="1" :max="60" /> 分钟
-        </el-form-item>
-        <el-form-item label="报警静默时段">
-          <el-time-picker
-              v-model="globalConfig.silencePeriod"
-              is-range
-              range-separator="至"
-              start-placeholder="开始时间"
-              end-placeholder="结束时间"
-              format="HH:mm"
-          />
-        </el-form-item>
-        <el-form-item label="通知方式">
-          <el-checkbox-group v-model="globalConfig.notifyMethods">
-            <el-checkbox value="sms">短信通知</el-checkbox>
-            <el-checkbox value="email">邮件通知</el-checkbox>
-            <el-checkbox value="app">APP推送</el-checkbox>
-          </el-checkbox-group>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="globalConfigVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveGlobalConfig">保存配置</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 报警详情抽屉 -->
-    <el-drawer v-model="alertDetailVisible" :title="`报警详情 - ${currentAlert?.deviceName}`" size="400px" direction="rtl">
-      <div v-if="currentAlert" class="alert-detail">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="设备名称">{{ currentAlert.deviceName }}</el-descriptions-item>
-          <el-descriptions-item label="设备编号">{{ currentAlert.deviceCode }}</el-descriptions-item>
-          <el-descriptions-item label="报警规则">{{ currentAlert.ruleName }}</el-descriptions-item>
-          <el-descriptions-item label="当前值">{{ currentAlert.currentValue }} {{ currentAlert.unit }}</el-descriptions-item>
-          <el-descriptions-item label="报警阈值">{{ currentAlert.threshold }} {{ currentAlert.unit }}</el-descriptions-item>
-          <el-descriptions-item label="报警级别">
-            <el-tag :type="currentAlert.level === 'critical' ? 'danger' : 'warning'">
-              {{ currentAlert.level === 'critical' ? '严重报警' : '预警' }}
-            </el-tag>
-          </el-descriptions-item>
-          <el-descriptions-item label="触发时间">{{ currentAlert.triggerTime }}</el-descriptions-item>
-        </el-descriptions>
-        <div class="action-buttons">
-          <el-button type="primary" @click="resolveAlert">确认处理</el-button>
-          <el-button @click="snoozeAlert">稍后处理</el-button>
-        </div>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
-<script setup>
-import { ref, computed, onMounted } from 'vue'
-import { AlarmClock, Plus, Refresh, Setting, BellFilled, List, Search, WarningFilled } from '@element-plus/icons-vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-
-// 报警规则列表
-const rules = ref([
-  { id: 1, name: '甲烷浓度超标', deviceType: '气体监测仪', monitorParam: '甲烷浓度', condition: 'gt', threshold: 20, unit: 'ppm', alertLevel: 'critical', status: true, description: '甲烷浓度超过20ppm触发严重报警' },
-  { id: 2, name: '水位超限', deviceType: '水位计', monitorParam: '水位深度', condition: 'gt', threshold: 40, unit: 'cm', alertLevel: 'critical', status: true, description: '水位超过40cm触发严重报警' },
-  { id: 3, name: '倾斜超标', deviceType: '智能井盖', monitorParam: '倾斜角度', condition: 'gt', threshold: 8, unit: '°', alertLevel: 'critical', status: true, description: '倾斜角度超过8°触发严重报警' },
-  { id: 4, name: '管道压力异常', deviceType: '压力监测仪', monitorParam: '管道压力', condition: 'lt', threshold: 300, unit: 'kPa', alertLevel: 'warning', status: true, description: '压力低于300kPa触发预警' },
-  { id: 5, name: '流量异常', deviceType: '流量计', monitorParam: '瞬时流量', condition: 'gt', threshold: 1200, unit: 'm³/h', alertLevel: 'warning', status: true, description: '流量超过1200m³/h触发预警' }
-])
-
-// 模拟实时报警数据
-const activeAlerts = ref([
-  { id: 1, deviceName: '世纪大道流量计', deviceCode: 'FM-001', ruleName: '流量异常', currentValue: 1350, threshold: 1200, unit: 'm³/h', level: 'warning', triggerTime: '2025-04-02 15:23:45' },
-  { id: 2, deviceName: '人民广场气体监测仪', deviceCode: 'CH4-001', ruleName: '甲烷浓度超标', currentValue: 28, threshold: 20, unit: 'ppm', level: 'critical', triggerTime: '2025-04-02 15:18:22' }
-])
-
-const ruleSearch = ref('')
-const ruleDialogVisible = ref(false)
-const ruleDialogTitle = ref('新增规则')
-const ruleForm = ref({
-  id: null, name: '', deviceType: '', monitorParam: '', condition: 'gt', threshold: 0, unit: '', alertLevel: 'warning', description: ''
-})
-const globalConfigVisible = ref(false)
-const globalConfig = ref({
-  pollInterval: 30, repeatInterval: 5, silencePeriod: null, notifyMethods: ['sms', 'app']
-})
-const alertDetailVisible = ref(false)
-const currentAlert = ref(null)
-
-const ruleCount = computed(() => rules.value.length)
-const activeAlertCount = computed(() => activeAlerts.value.length)
-const todayTriggerCount = computed(() => 8)
-
-const filteredRules = computed(() => {
-  if (!ruleSearch.value) return rules.value
-  const kw = ruleSearch.value.toLowerCase()
-  return rules.value.filter(r => r.name.toLowerCase().includes(kw) || r.deviceType.toLowerCase().includes(kw))
-})
-
-const getConditionText = (cond) => {
-  const map = { gt: '>', lt: '<', gte: '≥', lte: '≤' }
-  return map[cond] || cond
-}
-
-// 模拟数据监测触发
-const simulateMonitor = () => {
-  // 模拟新报警触发(演示用)
-  const newAlert = {
-    id: Date.now(),
-    deviceName: '张杨路水位计',
-    deviceCode: 'WL-003',
-    ruleName: '水位超限',
-    currentValue: 45,
-    threshold: 40,
-    unit: 'cm',
-    level: 'critical',
-    triggerTime: new Date().toLocaleString()
-  }
-  // 避免重复添加
-  if (!activeAlerts.value.some(a => a.deviceCode === 'WL-003')) {
-    activeAlerts.value.unshift(newAlert)
-    ElNotification({
-      title: '新报警触发',
-      message: `${newAlert.deviceName} 触发 ${newAlert.ruleName}`,
-      type: 'warning'
-    })
-  }
-}
-
-const refreshData = () => {
-  ElMessage.success('数据已刷新')
-}
-
-const showRuleDialog = () => {
-  ruleDialogTitle.value = '新增规则'
-  ruleForm.value = { id: null, name: '', deviceType: '', monitorParam: '', condition: 'gt', threshold: 0, unit: '', alertLevel: 'warning', description: '' }
-  ruleDialogVisible.value = true
-}
-
-const editRule = (row) => {
-  ruleDialogTitle.value = '编辑规则'
-  ruleForm.value = { ...row }
-  ruleDialogVisible.value = true
-}
-
-const saveRule = () => {
-  if (!ruleForm.value.name || !ruleForm.value.deviceType || !ruleForm.value.threshold) {
-    ElMessage.warning('请填写完整信息')
-    return
-  }
-  if (ruleForm.value.id) {
-    const index = rules.value.findIndex(r => r.id === ruleForm.value.id)
-    if (index !== -1) rules.value[index] = { ...ruleForm.value }
-    ElMessage.success('规则已更新')
-  } else {
-    ruleForm.value.id = Date.now()
-    ruleForm.value.status = true
-    rules.value.push({ ...ruleForm.value })
-    ElMessage.success('规则已添加')
-  }
-  ruleDialogVisible.value = false
-}
-
-const deleteRule = (row) => {
-  ElMessageBox.confirm(`确定删除规则"${row.name}"吗?`, '提示', { type: 'warning' }).then(() => {
-    const index = rules.value.findIndex(r => r.id === row.id)
-    if (index !== -1) rules.value.splice(index, 1)
-    ElMessage.success('规则已删除')
-  })
-}
-
-const toggleRule = (row) => {
-  ElMessage.success(`规则${row.status ? '已启用' : '已停用'}`)
-}
-
-const showGlobalConfig = () => { globalConfigVisible.value = true }
-const saveGlobalConfig = () => { globalConfigVisible.value = false; ElMessage.success('全局配置已保存') }
-
-const viewAlertDetail = (alert) => {
-  currentAlert.value = alert
-  alertDetailVisible.value = true
-}
-
-const resolveAlert = () => {
-  const index = activeAlerts.value.findIndex(a => a.id === currentAlert.value.id)
-  if (index !== -1) activeAlerts.value.splice(index, 1)
-  alertDetailVisible.value = false
-  ElMessage.success('报警已处理')
-}
-
-const snoozeAlert = () => {
-  alertDetailVisible.value = false
-  ElMessage.info('已设置稍后提醒')
-}
-
-// 定时模拟触发新报警
-let interval
-onMounted(() => {
-  interval = setInterval(() => {
-    // 随机触发新报警(演示用)
-    if (Math.random() < 0.3) {
-      simulateMonitor()
-    }
-  }, 30000)
-})
-
-// 清理定时器
-import { onUnmounted } from 'vue'
-import { ElNotification } from 'element-plus'
-onUnmounted(() => {
-  if (interval) clearInterval(interval)
-})
-</script>
-
-<style scoped>
-.auto-alert-system {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.critical { color: #f56c6c; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #909399; }
-.header-right { display: flex; gap: 12px; }
-
-.alert-board {
-  background: white;
-  border-radius: 16px;
-  margin-bottom: 20px;
-  overflow: hidden;
-}
-.board-header {
-  padding: 16px;
-  border-bottom: 1px solid #e9ecef;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  font-weight: 600;
-}
-.alert-list { max-height: 300px; overflow-y: auto; padding: 12px; }
-.alert-item {
-  display: flex;
-  align-items: flex-start;
-  gap: 12px;
-  padding: 12px;
-  margin-bottom: 8px;
-  border-radius: 12px;
-  background: #fafbfc;
-  border: 1px solid #e9ecef;
-}
-.alert-item.critical { background: #fff8f8; border-left: 3px solid #f56c6c; }
-.alert-item.warning { background: #fffef5; border-left: 3px solid #e6a23c; }
-.alert-icon .el-icon { font-size: 24px; color: #f56c6c; }
-.alert-content { flex: 1; }
-.alert-title { font-weight: 600; margin-bottom: 4px; }
-.alert-desc { font-size: 12px; color: #606266; }
-.alert-time { font-size: 11px; color: #aaa; margin-top: 4px; }
-.empty-alert { padding: 20px; text-align: center; }
-
-.rule-panel {
-  background: white;
-  border-radius: 16px;
-  padding: 16px;
-}
-.panel-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16px;
-  font-weight: 600;
-}
-
-.alert-detail { padding: 8px; }
-.action-buttons { margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end; }
-</style>
+<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="报警管理" subtitle="实时异常、历史报警与处置状态" mode="manage" /></template>

+ 1 - 547
src/views/subSystem/waterSupply/wjcbj/bjsh.vue

@@ -1,547 +1 @@
-<!--报警审核-->
-<template>
-  <div class="alert-audit-system">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Warning /></el-icon>
-        <span class="title">报警审核与处置中心</span>
-        <div class="stats-badge">
-          <span class="stat-item pending"><i class="dot"></i>待审核 {{ pendingCount }}</span>
-          <span class="stat-item processing"><i class="dot"></i>处置中 {{ processingCount }}</span>
-          <span class="stat-item resolved"><i class="dot"></i>已解除 {{ resolvedCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-input
-            v-model="searchKeyword"
-            placeholder="搜索报警内容/设备名称"
-            clearable
-            :prefix-icon="Search"
-            style="width: 240px"
-        />
-        <el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 120px">
-          <el-option label="待审核" value="pending" />
-          <el-option label="处置中" value="processing" />
-          <el-option label="已解除" value="resolved" />
-        </el-select>
-        <el-button type="primary" :icon="Refresh" @click="refreshData">刷新</el-button>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧报警列表 + 右侧审核面板 -->
-    <div class="main-layout">
-      <!-- 左侧报警列表 -->
-      <div class="alert-list-panel">
-        <div class="panel-header">
-          <span><el-icon><List /></el-icon> 报警事件列表</span>
-          <span class="total-count">共 {{ filteredAlerts.length }} 条</span>
-        </div>
-        <div class="alert-list">
-          <div
-              v-for="alert in paginatedAlerts"
-              :key="alert.id"
-              class="alert-item"
-              :class="{
-              selected: selectedAlert?.id === alert.id,
-              pending: alert.status === 'pending',
-              processing: alert.status === 'processing',
-              resolved: alert.status === 'resolved'
-            }"
-              @click="selectAlert(alert)"
-          >
-            <div class="alert-level">
-              <el-tag :type="alert.level === 'critical' ? 'danger' : 'warning'" size="small">
-                {{ alert.level === 'critical' ? '严重' : '预警' }}
-              </el-tag>
-            </div>
-            <div class="alert-info">
-              <div class="alert-title">{{ alert.deviceName }}</div>
-              <div class="alert-desc">{{ alert.ruleName }}: {{ alert.currentValue }}{{ alert.unit }}</div>
-              <div class="alert-time">{{ alert.triggerTime }}</div>
-            </div>
-            <div class="alert-status">
-              <el-tag :type="getStatusType(alert.status)" size="small">
-                {{ getStatusText(alert.status) }}
-              </el-tag>
-            </div>
-          </div>
-        </div>
-        <div class="pagination-area">
-          <el-pagination
-              small
-              layout="prev, pager, next"
-              :total="filteredAlerts.length"
-              :page-size="pageSize"
-              v-model:current-page="currentPage"
-          />
-        </div>
-      </div>
-
-      <!-- 右侧审核处置面板 -->
-      <div class="audit-panel" v-if="selectedAlert">
-        <div class="panel-header">
-          <span><el-icon><Checked /></el-icon> 报警审核与处置</span>
-          <el-tag :type="getStatusType(selectedAlert.status)" size="large">
-            {{ getStatusText(selectedAlert.status) }}
-          </el-tag>
-        </div>
-
-        <!-- 报警基本信息 -->
-        <div class="alert-basic">
-          <el-descriptions :column="2" border size="small">
-            <el-descriptions-item label="设备名称">{{ selectedAlert.deviceName }}</el-descriptions-item>
-            <el-descriptions-item label="设备编号">{{ selectedAlert.deviceCode }}</el-descriptions-item>
-            <el-descriptions-item label="报警规则">{{ selectedAlert.ruleName }}</el-descriptions-item>
-            <el-descriptions-item label="报警级别">
-              <el-tag :type="selectedAlert.level === 'critical' ? 'danger' : 'warning'" size="small">
-                {{ selectedAlert.level === 'critical' ? '严重报警' : '预警' }}
-              </el-tag>
-            </el-descriptions-item>
-            <el-descriptions-item label="当前值">{{ selectedAlert.currentValue }} {{ selectedAlert.unit }}</el-descriptions-item>
-            <el-descriptions-item label="报警阈值">{{ selectedAlert.threshold }} {{ selectedAlert.unit }}</el-descriptions-item>
-            <el-descriptions-item label="触发时间">{{ selectedAlert.triggerTime }}</el-descriptions-item>
-            <el-descriptions-item label="报警位置">{{ selectedAlert.location }}</el-descriptions-item>
-          </el-descriptions>
-        </div>
-
-        <!-- 审核/处置操作区(根据状态显示不同内容) -->
-        <div class="action-section" v-if="selectedAlert.status === 'pending'">
-          <div class="section-title">审核结果</div>
-          <el-radio-group v-model="auditResult" class="audit-options">
-            <el-radio value="confirm">确认为真报警</el-radio>
-            <el-radio value="false_alarm">误报警</el-radio>
-            <el-radio value="uncertain">需现场核实</el-radio>
-          </el-radio-group>
-
-          <div class="section-title">审核意见</div>
-          <el-input
-              type="textarea"
-              v-model="auditOpinion"
-              placeholder="请输入审核意见..."
-              rows="3"
-              maxlength="500"
-              show-word-limit
-          />
-
-          <div class="section-title">附件上传</div>
-          <el-upload
-              class="upload-area"
-              drag
-              action="#"
-              :auto-upload="false"
-              :file-list="uploadFiles"
-              :on-change="handleFileChange"
-              multiple
-          >
-            <el-icon class="upload-icon"><Upload /></el-icon>
-            <div class="upload-text">点击或拖拽上传相关附件(现场照片、检测报告等)</div>
-          </el-upload>
-
-          <div class="action-buttons">
-            <el-button type="primary" @click="submitAudit">提交审核</el-button>
-            <el-button @click="resetAuditForm">重置</el-button>
-          </div>
-        </div>
-
-        <!-- 处置操作区(待现场核实状态) -->
-        <div class="action-section" v-if="selectedAlert.status === 'processing'">
-          <div class="section-title">处置操作</div>
-          <el-radio-group v-model="disposeResult" class="dispose-options">
-            <el-radio value="resolved">处置完成,解除报警</el-radio>
-            <el-radio value="pending_repair">已派修,待修复</el-radio>
-            <el-radio value="monitoring">持续监测中</el-radio>
-          </el-radio-group>
-
-          <div class="section-title">处置说明</div>
-          <el-input
-              type="textarea"
-              v-model="disposeRemark"
-              placeholder="请输入现场处置情况说明..."
-              rows="3"
-          />
-
-          <div class="section-title">现场照片/证据</div>
-          <el-upload
-              class="upload-area"
-              drag
-              action="#"
-              :auto-upload="false"
-              :file-list="disposeFiles"
-              :on-change="handleDisposeFileChange"
-              multiple
-          >
-            <el-icon class="upload-icon"><Camera /></el-icon>
-            <div class="upload-text">点击或拖拽上传现场处置照片</div>
-          </el-upload>
-
-          <div class="action-buttons">
-            <el-button type="primary" @click="submitDispose">提交处置结果</el-button>
-            <el-button @click="resetDisposeForm">重置</el-button>
-          </div>
-        </div>
-
-        <!-- 已解除/已完成状态 -->
-        <div class="completed-section" v-if="selectedAlert.status === 'resolved'">
-          <el-alert
-              :title="`报警已${selectedAlert.resolvedType === 'false_alarm' ? '排除(误报警)' : '处置解除'}`"
-              type="success"
-              show-icon
-              :closable="false"
-          />
-          <div class="resolve-info">
-            <div><strong>审核/处置结果:</strong>{{ selectedAlert.resolvedTypeName }}</div>
-            <div><strong>意见说明:</strong>{{ selectedAlert.resolvedOpinion }}</div>
-            <div v-if="selectedAlert.attachments?.length" class="attachments">
-              <strong>附件:</strong>
-              <div v-for="file in selectedAlert.attachments" :key="file.name" class="attachment-item">
-                <el-icon><Document /></el-icon>
-                <span>{{ file.name }}</span>
-                <el-button link type="primary" size="small">下载</el-button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <!-- 处置跟踪时间轴 -->
-        <div class="timeline-section">
-          <div class="section-title">处置跟踪记录</div>
-          <el-timeline>
-            <el-timeline-item
-                v-for="record in selectedAlert.processRecords"
-                :key="record.id"
-                :timestamp="record.time"
-                :type="record.type"
-                :hollow="true"
-            >
-              <div class="timeline-content">
-                <strong>{{ record.operator }}</strong> - {{ record.action }}
-                <div class="timeline-desc">{{ record.remark }}</div>
-                <div v-if="record.attachments?.length" class="timeline-attachments">
-                  附件: {{ record.attachments.map(f => f.name).join(', ') }}
-                </div>
-              </div>
-            </el-timeline-item>
-          </el-timeline>
-        </div>
-      </div>
-
-      <!-- 未选中状态 -->
-      <div v-else class="empty-panel">
-        <el-empty description="请从左侧选择报警事件进行审核处置" :image-size="140" />
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { ref, computed } from 'vue'
-import { Warning, Search, Refresh, List, Checked, Upload, Camera, Document } from '@element-plus/icons-vue'
-import { ElMessage, ElMessageBox } from 'element-plus'
-
-// 模拟报警数据
-const alerts = ref([
-  {
-    id: 1, deviceName: '世纪大道流量计', deviceCode: 'FM-001', ruleName: '流量异常',
-    currentValue: 1350, threshold: 1200, unit: 'm³/h', level: 'warning',
-    triggerTime: '2025-04-02 15:23:45', location: '世纪大道福山路口',
-    status: 'pending', // pending, processing, resolved
-    processRecords: [
-      { id: 1, time: '2025-04-02 15:23:45', operator: '系统', action: '报警触发', type: 'danger', remark: '流量1350m³/h超过阈值1200m³/h' }
-    ],
-    attachments: [], resolvedType: null, resolvedTypeName: null, resolvedOpinion: null
-  },
-  {
-    id: 2, deviceName: '人民广场气体监测仪', deviceCode: 'CH4-001', ruleName: '甲烷浓度超标',
-    currentValue: 28, threshold: 20, unit: 'ppm', level: 'critical',
-    triggerTime: '2025-04-02 15:18:22', location: '人民大道200号',
-    status: 'processing',
-    processRecords: [
-      { id: 1, time: '2025-04-02 15:18:22', operator: '系统', action: '报警触发', type: 'danger', remark: '甲烷浓度28ppm超过阈值20ppm' },
-      { id: 2, time: '2025-04-02 15:35:00', operator: '张建国', action: '审核确认为真报警,已派发现场核实', type: 'warning', remark: '已通知抢修队前往现场' }
-    ],
-    attachments: [], resolvedType: null, resolvedTypeName: null, resolvedOpinion: null
-  },
-  {
-    id: 3, deviceName: '张杨路水位计', deviceCode: 'WL-003', ruleName: '水位超限',
-    currentValue: 45, threshold: 40, unit: 'cm', level: 'critical',
-    triggerTime: '2025-04-02 14:55:10', location: '张杨路松林路口',
-    status: 'resolved',
-    processRecords: [
-      { id: 1, time: '2025-04-02 14:55:10', operator: '系统', action: '报警触发', type: 'danger', remark: '水位45cm超过阈值40cm' },
-      { id: 2, time: '2025-04-02 15:10:00', operator: '李志强', action: '审核确认为真报警', type: 'warning', remark: '已通知排水部门' },
-      { id: 3, time: '2025-04-02 15:45:00', operator: '李志强', action: '处置完成,报警解除', type: 'success', remark: '现场排水完成,水位恢复正常' }
-    ],
-    attachments: [{ name: '现场照片_001.jpg', url: '' }],
-    resolvedType: 'resolved', resolvedTypeName: '处置完成', resolvedOpinion: '现场排水作业完成,设备运行正常'
-  },
-  {
-    id: 4, deviceName: '东方路压力监测仪', deviceCode: 'PR-003', ruleName: '管道压力异常',
-    currentValue: 285, threshold: 300, unit: 'kPa', level: 'warning',
-    triggerTime: '2025-04-02 14:30:05', location: '东方路蓝村路口',
-    status: 'pending',
-    processRecords: [
-      { id: 1, time: '2025-04-02 14:30:05', operator: '系统', action: '报警触发', type: 'danger', remark: '压力285kPa低于阈值300kPa' }
-    ],
-    attachments: [], resolvedType: null, resolvedTypeName: null, resolvedOpinion: null
-  }
-])
-
-// 统计数据
-const pendingCount = computed(() => alerts.value.filter(a => a.status === 'pending').length)
-const processingCount = computed(() => alerts.value.filter(a => a.status === 'processing').length)
-const resolvedCount = computed(() => alerts.value.filter(a => a.status === 'resolved').length)
-
-// 筛选
-const searchKeyword = ref('')
-const statusFilter = ref('')
-const currentPage = ref(1)
-const pageSize = 5
-const selectedAlert = ref(alerts.value[0])
-
-// 审核表单
-const auditResult = ref('confirm')
-const auditOpinion = ref('')
-const uploadFiles = ref([])
-
-// 处置表单
-const disposeResult = ref('resolved')
-const disposeRemark = ref('')
-const disposeFiles = ref([])
-
-const filteredAlerts = computed(() => {
-  let list = alerts.value
-  if (statusFilter.value) {
-    list = list.filter(a => a.status === statusFilter.value)
-  }
-  if (searchKeyword.value) {
-    const kw = searchKeyword.value.toLowerCase()
-    list = list.filter(a => a.deviceName.toLowerCase().includes(kw) || a.ruleName.toLowerCase().includes(kw))
-  }
-  return list
-})
-
-const paginatedAlerts = computed(() => {
-  const start = (currentPage.value - 1) * pageSize
-  return filteredAlerts.value.slice(start, start + pageSize)
-})
-
-const getStatusType = (status) => {
-  const map = { pending: 'danger', processing: 'warning', resolved: 'success' }
-  return map[status]
-}
-const getStatusText = (status) => {
-  const map = { pending: '待审核', processing: '处置中', resolved: '已解除' }
-  return map[status]
-}
-
-const selectAlert = (alert) => {
-  selectedAlert.value = alert
-  resetAuditForm()
-  resetDisposeForm()
-}
-
-const resetAuditForm = () => {
-  auditResult.value = 'confirm'
-  auditOpinion.value = ''
-  uploadFiles.value = []
-}
-
-const resetDisposeForm = () => {
-  disposeResult.value = 'resolved'
-  disposeRemark.value = ''
-  disposeFiles.value = []
-}
-
-const handleFileChange = (file, fileList) => {
-  uploadFiles.value = fileList
-}
-const handleDisposeFileChange = (file, fileList) => {
-  disposeFiles.value = fileList
-}
-
-// 提交审核
-const submitAudit = () => {
-  if (!auditOpinion.value) {
-    ElMessage.warning('请填写审核意见')
-    return
-  }
-  const alert = selectedAlert.value
-  const resultMap = {
-    confirm: { nextStatus: 'processing', typeName: '确认为真报警', action: '审核确认为真报警' },
-    false_alarm: { nextStatus: 'resolved', typeName: '误报警', action: '审核为误报警,报警解除' },
-    uncertain: { nextStatus: 'processing', typeName: '需现场核实', action: '审核为需现场核实,已派单' }
-  }
-  const result = resultMap[auditResult.value]
-
-  // 添加处置记录
-  alert.processRecords.push({
-    id: Date.now(),
-    time: new Date().toLocaleString(),
-    operator: '审核员',
-    action: result.action,
-    type: auditResult.value === 'false_alarm' ? 'success' : 'warning',
-    remark: auditOpinion.value,
-    attachments: uploadFiles.value
-  })
-
-  alert.status = result.nextStatus
-  if (auditResult.value === 'false_alarm') {
-    alert.resolvedType = 'false_alarm'
-    alert.resolvedTypeName = '误报警'
-    alert.resolvedOpinion = auditOpinion.value
-    alert.attachments = uploadFiles.value
-  }
-
-  ElMessage.success(`审核完成:${result.typeName}`)
-  resetAuditForm()
-}
-
-// 提交处置结果
-const submitDispose = () => {
-  if (!disposeRemark.value) {
-    ElMessage.warning('请填写处置说明')
-    return
-  }
-  const alert = selectedAlert.value
-  const resultMap = {
-    resolved: { nextStatus: 'resolved', typeName: '处置完成,解除报警', action: '现场处置完成,报警解除' },
-    pending_repair: { nextStatus: 'processing', typeName: '已派修,待修复', action: '已派维修人员,待修复' },
-    monitoring: { nextStatus: 'processing', typeName: '持续监测中', action: '现场情况稳定,持续监测' }
-  }
-  const result = resultMap[disposeResult.value]
-
-  alert.processRecords.push({
-    id: Date.now(),
-    time: new Date().toLocaleString(),
-    operator: '处置人员',
-    action: result.action,
-    type: disposeResult.value === 'resolved' ? 'success' : 'warning',
-    remark: disposeRemark.value,
-    attachments: disposeFiles.value
-  })
-
-  if (disposeResult.value === 'resolved') {
-    alert.status = 'resolved'
-    alert.resolvedType = 'resolved'
-    alert.resolvedTypeName = '处置完成'
-    alert.resolvedOpinion = disposeRemark.value
-    alert.attachments = disposeFiles.value
-  }
-
-  ElMessage.success(`处置提交:${result.typeName}`)
-  resetDisposeForm()
-}
-
-const refreshData = () => { ElMessage.success('数据已刷新') }
-</script>
-
-<style scoped>
-.alert-audit-system {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.pending { color: #f56c6c; }
-.stat-item.processing { color: #e6a23c; }
-.stat-item.resolved { color: #67c23a; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; }
-.stat-item.pending .dot { background: #f56c6c; }
-.stat-item.processing .dot { background: #e6a23c; }
-.stat-item.resolved .dot { background: #67c23a; }
-.header-right { display: flex; gap: 12px; }
-
-.main-layout {
-  display: flex;
-  gap: 20px;
-}
-.alert-list-panel {
-  width: 380px;
-  background: white;
-  border-radius: 16px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.panel-header {
-  padding: 16px;
-  border-bottom: 1px solid #e9ecef;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  font-weight: 600;
-}
-.alert-list {
-  flex: 1;
-  overflow-y: auto;
-  padding: 12px;
-  max-height: calc(100vh - 280px);
-}
-.alert-item {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 12px;
-  margin-bottom: 8px;
-  border-radius: 12px;
-  background: #fafbfc;
-  border: 1px solid #e9ecef;
-  cursor: pointer;
-  transition: all 0.2s;
-}
-.alert-item.selected { background: #ecf5ff; border-color: #409eff; }
-.alert-item.pending { border-left: 3px solid #f56c6c; }
-.alert-item.processing { border-left: 3px solid #e6a23c; }
-.alert-item.resolved { border-left: 3px solid #67c23a; }
-.alert-info { flex: 1; }
-.alert-title { font-weight: 600; margin-bottom: 4px; }
-.alert-desc { font-size: 12px; color: #606266; }
-.alert-time { font-size: 11px; color: #aaa; margin-top: 4px; }
-
-.audit-panel {
-  flex: 1;
-  background: white;
-  border-radius: 16px;
-  padding: 20px;
-  overflow-y: auto;
-  max-height: calc(100vh - 180px);
-}
-.empty-panel {
-  flex: 1;
-  background: white;
-  border-radius: 16px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.section-title { font-weight: 600; margin: 20px 0 12px 0; font-size: 14px; }
-.audit-options, .dispose-options { display: flex; gap: 20px; margin-bottom: 8px; }
-.upload-area { margin: 8px 0; }
-.upload-icon { font-size: 32px; color: #c0c4cc; margin-bottom: 8px; }
-.upload-text { font-size: 12px; color: #909399; }
-.action-buttons { display: flex; gap: 12px; margin-top: 20px; justify-content: flex-end; }
-.completed-section { margin: 20px 0; }
-.resolve-info { margin-top: 16px; padding: 12px; background: #f5f7fa; border-radius: 8px; }
-.attachments { margin-top: 8px; }
-.attachment-item { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
-
-.timeline-section { margin-top: 24px; border-top: 1px solid #e9ecef; padding-top: 16px; }
-.timeline-content { font-size: 13px; }
-.timeline-desc { font-size: 12px; color: #909399; margin-top: 4px; }
-.timeline-attachments { font-size: 11px; color: #409eff; margin-top: 4px; }
-
-.pagination-area { padding: 12px; text-align: center; border-top: 1px solid #eee; }
-</style>
+<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="报警审核" subtitle="审核报警真实性并记录审核意见" mode="audit" /></template>

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

@@ -1,545 +1 @@
-<!--监测报警一张图-->
-<template>
-  <div class="water-alert-gis">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Warning /></el-icon>
-        <span class="title">供水报警点位GIS一张图</span>
-        <div class="stats-badge">
-          <span class="stat-item critical"><i class="dot"></i>严重报警 {{ criticalCount }}</span>
-          <span class="stat-item warning"><i class="dot"></i>预警 {{ warningCount }}</span>
-          <span class="stat-item normal"><i class="dot"></i>正常 {{ normalCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-input
-            v-model="searchKeyword"
-            placeholder="搜索报警点位/地址"
-            clearable
-            :prefix-icon="Search"
-            style="width: 240px"
-            @keyup.enter="searchAndLocate"
-        />
-        <el-button type="primary" :icon="Location" @click="searchAndLocate">定位</el-button>
-        <el-button :icon="Refresh" @click="refreshMap">刷新</el-button>
-        <el-button :icon="FullScreen" @click="fitBounds">全图</el-button>
-      </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-cards">
-      <div class="stat-card critical" @click="filterByLevel('critical')">
-        <div class="stat-value">{{ criticalCount }}</div>
-        <div class="stat-label">严重报警</div>
-      </div>
-      <div class="stat-card warning" @click="filterByLevel('warning')">
-        <div class="stat-value">{{ warningCount }}</div>
-        <div class="stat-label">预警</div>
-      </div>
-      <div class="stat-card normal" @click="filterByLevel('normal')">
-        <div class="stat-value">{{ normalCount }}</div>
-        <div class="stat-label">正常点位</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ totalPoints }}</div>
-        <div class="stat-label">监测点总数</div>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧报警列表 + 右侧GIS地图 -->
-    <div class="main-layout">
-      <!-- 左侧报警列表 -->
-      <div class="alert-list-panel">
-        <div class="panel-header">
-          <span><el-icon><List /></el-icon> 报警点列表</span>
-          <el-select v-model="levelFilter" placeholder="报警级别" clearable size="small" style="width: 110px">
-            <el-option label="严重报警" value="critical" />
-            <el-option label="预警" value="warning" />
-            <el-option label="正常" value="normal" />
-          </el-select>
-        </div>
-        <div class="alert-list">
-          <div
-              v-for="point in paginatedPoints"
-              :key="point.id"
-              class="alert-item"
-              :class="{
-              selected: selectedPoint?.id === point.id,
-              critical: point.level === 'critical',
-              warning: point.level === 'warning'
-            }"
-              @click="selectPoint(point)"
-          >
-            <div class="alert-level-icon">
-              <el-icon v-if="point.level === 'critical'"><WarningFilled /></el-icon>
-              <el-icon v-else-if="point.level === 'warning'"><InfoFilled /></el-icon>
-              <el-icon v-else><CircleCheck /></el-icon>
-            </div>
-            <div class="alert-info">
-              <div class="alert-title">{{ point.name }}</div>
-              <div class="alert-address">
-                <el-icon><Location /></el-icon> {{ point.shortAddress }}
-              </div>
-              <div class="alert-value" v-if="point.alertValue">
-                报警值: {{ point.alertValue }}
-              </div>
-              <div class="alert-time">{{ point.alertTime }}</div>
-            </div>
-          </div>
-        </div>
-        <div class="pagination-area">
-          <el-pagination
-              small
-              layout="prev, pager, next"
-              :total="filteredPoints.length"
-              :page-size="pageSize"
-              v-model:current-page="currentPage"
-          />
-        </div>
-      </div>
-
-      <!-- 右侧GIS地图 -->
-      <div class="gis-container">
-        <div class="map-toolbar">
-          <el-button-group>
-            <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
-            <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
-            <el-button size="small" :icon="Location" @click="fitBounds">重置</el-button>
-          </el-button-group>
-          <div class="map-legend">
-            <span><i class="legend-dot critical-dot"></i>严重报警</span>
-            <span><i class="legend-dot warning-dot"></i>预警</span>
-            <span><i class="legend-dot normal-dot"></i>正常</span>
-          </div>
-        </div>
-        <div class="map-wrapper" ref="mapContainer">
-          <canvas id="waterAlertMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
-          <div v-if="hoverPoint" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverPoint.name }}</div>
-            <div>级别: {{ hoverPoint.level === 'critical' ? '严重报警' : hoverPoint.level === 'warning' ? '预警' : '正常' }}</div>
-            <div v-if="hoverPoint.alertValue">报警值: {{ hoverPoint.alertValue }}</div>
-            <div>位置: {{ hoverPoint.address }}</div>
-          </div>
-        </div>
-
-        <!-- 详情抽屉 -->
-        <el-drawer v-model="detailDrawerVisible" :title="`点位详情 - ${currentPoint?.name}`" size="450px" direction="rtl">
-          <div v-if="currentPoint" class="point-detail">
-            <div class="level-tag" :class="currentPoint.level">
-              {{ currentPoint.level === 'critical' ? '严重报警' : currentPoint.level === 'warning' ? '预警' : '正常' }}
-            </div>
-            <el-descriptions :column="1" border>
-              <el-descriptions-item label="点位名称">{{ currentPoint.name }}</el-descriptions-item>
-              <el-descriptions-item label="点位编号">{{ currentPoint.code }}</el-descriptions-item>
-              <el-descriptions-item label="报警级别">
-                <el-tag :type="currentPoint.level === 'critical' ? 'danger' : currentPoint.level === 'warning' ? 'warning' : 'success'">
-                  {{ currentPoint.level === 'critical' ? '严重报警' : currentPoint.level === 'warning' ? '预警' : '正常' }}
-                </el-tag>
-              </el-descriptions-item>
-              <el-descriptions-item label="报警类型">{{ currentPoint.alertType || '—' }}</el-descriptions-item>
-              <el-descriptions-item label="报警值">{{ currentPoint.alertValue || '—' }}</el-descriptions-item>
-              <el-descriptions-item label="报警阈值">{{ currentPoint.threshold || '—' }}</el-descriptions-item>
-              <el-descriptions-item label="详细地址">{{ currentPoint.address }}</el-descriptions-item>
-              <el-descriptions-item label="经纬度">{{ currentPoint.lng }}, {{ currentPoint.lat }}</el-descriptions-item>
-              <el-descriptions-item label="报警时间">{{ currentPoint.alertTime }}</el-descriptions-item>
-              <el-descriptions-item label="处理状态">
-                <el-tag :type="currentPoint.status === 'pending' ? 'danger' : 'success'">
-                  {{ currentPoint.status === 'pending' ? '待处理' : '已处理' }}
-                </el-tag>
-              </el-descriptions-item>
-            </el-descriptions>
-            <div class="action-buttons" v-if="currentPoint.status === 'pending'">
-              <el-button type="primary" size="small" @click="handleAlert">开始处理</el-button>
-              <el-button size="small" @click="viewHistory">查看历史</el-button>
-            </div>
-          </div>
-        </el-drawer>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { Warning, Search, Refresh, FullScreen, Location, List, WarningFilled, InfoFilled, CircleCheck, ZoomIn, ZoomOut } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-
-// 供水报警点位数据
-const alertPoints = ref([
-  { id: 1, code: 'WA-001', name: '世纪大道供水管监测点', level: 'critical', alertType: '压力骤降', alertValue: '2.1 bar', threshold: '3.0 bar', shortAddress: '世纪大道福山路口', address: '世纪大道与福山路交叉口东侧', lng: 121.505, lat: 31.235, alertTime: '2025-04-02 15:23:45', status: 'pending' },
-  { id: 2, code: 'WA-002', name: '人民广场供水点', level: 'critical', alertType: '流量异常', alertValue: '320 m³/h', threshold: '500 m³/h', shortAddress: '人民大道200号', address: '人民大道200号地下泵房', lng: 121.487, lat: 31.239, alertTime: '2025-04-02 14:58:12', status: 'pending' },
-  { id: 3, code: 'WA-003', name: '张杨路水质监测点', level: 'warning', alertType: '浊度超标', alertValue: '5.2 NTU', threshold: '3.0 NTU', shortAddress: '张杨路松林路口', address: '张杨路与松林路交叉口', lng: 121.52, lat: 31.242, alertTime: '2025-04-02 14:32:05', status: 'pending' },
-  { id: 4, code: 'WA-004', name: '龙阳路压力监测点', level: 'warning', alertType: '压力波动', alertValue: '3.8 bar', threshold: '4.2 bar', shortAddress: '龙阳路白杨路口', address: '龙阳路与白杨路交叉口', lng: 121.51, lat: 31.215, alertTime: '2025-04-02 14:15:30', status: 'pending' },
-  { id: 5, code: 'WA-005', name: '东方路流量计', level: 'normal', alertType: '—', alertValue: null, threshold: null, shortAddress: '东方路蓝村路口', address: '东方路与蓝村路交叉口', lng: 121.53, lat: 31.248, alertTime: '2025-04-02 10:00:00', status: 'resolved' },
-  { id: 6, code: 'WA-006', name: '浦东南路水质点', level: 'normal', alertType: '—', alertValue: null, threshold: null, shortAddress: '浦东南路400号', address: '浦东南路400号', lng: 121.49, lat: 31.23, alertTime: '2025-04-02 09:00:00', status: 'resolved' }
-])
-
-// 统计数据
-const criticalCount = computed(() => alertPoints.value.filter(p => p.level === 'critical').length)
-const warningCount = computed(() => alertPoints.value.filter(p => p.level === 'warning').length)
-const normalCount = computed(() => alertPoints.value.filter(p => p.level === 'normal').length)
-const totalPoints = computed(() => alertPoints.value.length)
-
-// 筛选
-const searchKeyword = ref('')
-const levelFilter = ref('')
-const currentPage = ref(1)
-const pageSize = 5
-const selectedPoint = ref(alertPoints.value[0])
-const currentPoint = ref(null)
-const detailDrawerVisible = ref(false)
-
-const filteredPoints = computed(() => {
-  let list = alertPoints.value
-  if (levelFilter.value) {
-    list = list.filter(p => p.level === levelFilter.value)
-  }
-  if (searchKeyword.value) {
-    const kw = searchKeyword.value.toLowerCase()
-    list = list.filter(p => p.name.toLowerCase().includes(kw) || p.address.toLowerCase().includes(kw))
-  }
-  return list
-})
-
-const paginatedPoints = computed(() => {
-  const start = (currentPage.value - 1) * pageSize
-  return filteredPoints.value.slice(start, start + pageSize)
-})
-
-// GIS画布相关
-const canvasRef = ref(null)
-const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-let dragStart = null
-let isDragging = false
-const bounds = { minLng: 121.45, maxLng: 121.56, minLat: 31.20, maxLat: 31.27 }
-
-// 交互状态
-const hoverPoint = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
-
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf5e9'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i <= 10; i++) {
-    const x = (i / 10) * mapWidth
-    ctx.beginPath()
-    ctx.moveTo(x, 0); ctx.lineTo(x, mapHeight); ctx.stroke()
-    const y = (i / 10) * mapHeight
-    ctx.beginPath()
-    ctx.moveTo(0, y); ctx.lineTo(mapWidth, y); ctx.stroke()
-  }
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('世纪大道', 350, 120)
-  ctx.fillText('张杨路', 450, 280)
-  ctx.fillText('东方路', 580, 200)
-  ctx.fillText('龙阳路', 200, 370)
-  ctx.fillText('人民广场', 180, 300)
-}
-
-function drawPoints() {
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    let color = '#67c23a'
-    let size = 12
-    if (point.level === 'critical') { color = '#f56c6c'; size = 16 }
-    else if (point.level === 'warning') { color = '#e6a23c'; size = 14 }
-
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = color
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-
-    if (point.level === 'critical') {
-      ctx.beginPath()
-      ctx.arc(x, y, size + 5, 0, 2 * Math.PI)
-      ctx.fillStyle = 'rgba(245,108,108,0.3)'
-      ctx.fill()
-    }
-
-    ctx.fillStyle = '#333'
-    ctx.font = '10px sans-serif'
-    ctx.fillText(point.name.slice(0, 6), x - 15, y - 10)
-  })
-}
-
-function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-  drawBaseMap()
-  drawPoints()
-}
-
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function fitBounds() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
-function refreshMap() { renderMap() }
-
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) { currentPoint.value = hit; detailDrawerVisible.value = true }
-}
-
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) {
-    hoverPoint.value = hit
-    hoverX.value = e.clientX + 12
-    hoverY.value = e.clientY - 40
-  } else {
-    hoverPoint.value = null
-  }
-}
-
-function initDragEvents() {
-  const canvas = canvasRef.value
-  if (!canvas) return
-  canvas.addEventListener('mousedown', (e) => {
-    dragStart = { x: e.clientX, y: e.clientY, offsetX: offsetX, offsetY: offsetY }
-    isDragging = true
-    canvas.style.cursor = 'grabbing'
-  })
-  window.addEventListener('mousemove', (e) => {
-    if (!isDragging) return
-    const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y
-    offsetX = dragStart.offsetX + dx; offsetY = dragStart.offsetY + dy
-    renderMap()
-  })
-  window.addEventListener('mouseup', () => {
-    isDragging = false
-    if (canvasRef.value) canvasRef.value.style.cursor = 'crosshair'
-  })
-}
-
-function searchAndLocate() {
-  if (!searchKeyword.value) return
-  const found = alertPoints.value.find(p => p.name.includes(searchKeyword.value) || p.address.includes(searchKeyword.value))
-  if (found) {
-    const center = worldToCanvas(found.lng, found.lat)
-    mapZoom = 2
-    offsetX = mapWidth/2 - center.x
-    offsetY = mapHeight/2 - center.y
-    renderMap()
-    currentPoint.value = found
-    detailDrawerVisible.value = true
-    ElMessage.success(`已定位到: ${found.name}`)
-  } else {
-    ElMessage.warning('未找到匹配的点位')
-  }
-}
-
-const selectPoint = (point) => {
-  currentPoint.value = point
-  detailDrawerVisible.value = true
-  // 地图居中
-  const center = worldToCanvas(point.lng, point.lat)
-  offsetX = mapWidth/2 - center.x
-  offsetY = mapHeight/2 - center.y
-  renderMap()
-}
-
-const filterByLevel = (level) => {
-  levelFilter.value = level
-  currentPage.value = 1
-}
-
-const handleAlert = () => { ElMessage.success('开始处理报警') }
-const viewHistory = () => { ElMessage.info('查看历史记录(演示)') }
-
-watch(() => filteredPoints.value, () => { currentPage.value = 1; renderMap() })
-watch(() => levelFilter.value, () => renderMap())
-
-onMounted(() => {
-  nextTick(() => { renderMap(); initDragEvents(); window.addEventListener('resize', renderMap) })
-})
-onUnmounted(() => window.removeEventListener('resize', renderMap))
-</script>
-
-<style scoped>
-.water-alert-gis {
-  background: #f0f2f6;
-  height: 100vh;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.page-header {
-  background: white;
-  padding: 12px 24px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  border-bottom: 1px solid #e9ecef;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.critical { color: #f56c6c; }
-.stat-item.warning { color: #e6a23c; }
-.stat-item.normal { color: #67c23a; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; }
-.stat-item.critical .dot { background: #f56c6c; }
-.stat-item.warning .dot { background: #e6a23c; }
-.stat-item.normal .dot { background: #67c23a; }
-.header-right { display: flex; gap: 12px; }
-
-.stats-cards {
-  display: flex;
-  gap: 16px;
-  padding: 16px;
-  background: white;
-  margin: 0 12px 12px 12px;
-  border-radius: 16px;
-}
-.stat-card {
-  flex: 1;
-  text-align: center;
-  padding: 12px;
-  border-radius: 12px;
-  cursor: pointer;
-  transition: all 0.2s;
-}
-.stat-card.critical { background: #fff8f8; border-left: 3px solid #f56c6c; }
-.stat-card.warning { background: #fffef5; border-left: 3px solid #e6a23c; }
-.stat-card.normal { background: #f5fff5; border-left: 3px solid #67c23a; }
-.stat-value { font-size: 28px; font-weight: bold; }
-.stat-card.critical .stat-value { color: #f56c6c; }
-.stat-card.warning .stat-value { color: #e6a23c; }
-.stat-card.normal .stat-value { color: #67c23a; }
-.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
-
-.main-layout {
-  display: flex;
-  flex: 1;
-  overflow: hidden;
-  padding: 0 12px 12px 12px;
-  gap: 12px;
-}
-.alert-list-panel {
-  width: 360px;
-  background: white;
-  border-radius: 16px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.panel-header {
-  padding: 16px;
-  border-bottom: 1px solid #e9ecef;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  font-weight: 600;
-}
-.alert-list {
-  flex: 1;
-  overflow-y: auto;
-  padding: 12px;
-}
-.alert-item {
-  display: flex;
-  gap: 12px;
-  padding: 12px;
-  margin-bottom: 8px;
-  border-radius: 12px;
-  background: #fafbfc;
-  border: 1px solid #e9ecef;
-  cursor: pointer;
-  transition: all 0.2s;
-}
-.alert-item.selected { background: #ecf5ff; border-color: #409eff; }
-.alert-item.critical { border-left: 3px solid #f56c6c; }
-.alert-item.warning { border-left: 3px solid #e6a23c; }
-.alert-level-icon { font-size: 22px; }
-.alert-item.critical .alert-level-icon { color: #f56c6c; }
-.alert-item.warning .alert-level-icon { color: #e6a23c; }
-.alert-info { flex: 1; }
-.alert-title { font-weight: 600; margin-bottom: 4px; }
-.alert-address { font-size: 12px; color: #6c757d; display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
-.alert-value { font-size: 12px; color: #f56c6c; margin-bottom: 4px; }
-.alert-time { font-size: 11px; color: #aaa; }
-
-.gis-container {
-  flex: 1;
-  background: white;
-  border-radius: 16px;
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-}
-.map-toolbar {
-  padding: 10px 16px;
-  border-bottom: 1px solid #e9ecef;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.map-legend { display: flex; gap: 16px; font-size: 12px; }
-.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; }
-.critical-dot { background: #f56c6c; }
-.warning-dot { background: #e6a23c; }
-.normal-dot { background: #67c23a; }
-.map-wrapper { flex: 1; position: relative; cursor: crosshair; }
-canvas { width: 100%; height: 100%; display: block; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-}
-.hover-title { font-weight: 600; margin-bottom: 4px; }
-
-.point-detail { padding: 8px; }
-.level-tag { text-align: center; padding: 8px; border-radius: 8px; margin-bottom: 16px; font-weight: bold; }
-.level-tag.critical { background: #f56c6c20; color: #f56c6c; }
-.level-tag.warning { background: #e6a23c20; color: #e6a23c; }
-.level-tag.normal { background: #67c23a20; color: #67c23a; }
-.action-buttons { margin-top: 16px; display: flex; gap: 12px; justify-content: flex-end; }
-.pagination-area { padding: 12px; text-align: center; border-top: 1px solid #eee; }
-</style>
+<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="监测报警一张图" subtitle="报警点位定位与等级分布" mode="map" /></template>

+ 1 - 486
src/views/subSystem/waterSupply/wjcbj/yzgl.vue

@@ -1,486 +1 @@
-<!--阈值管理-->
-<template>
-  <div class="threshold-management">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Setting /></el-icon>
-        <span class="title">传感器阈值批量设置</span>
-        <div class="stats-badge">
-          <span class="stat-item"><i class="dot"></i>传感器总数 {{ totalSensors }}</span>
-          <span class="stat-item"><i class="dot"></i>已配置 {{ configuredCount }}</span>
-          <span class="stat-item"><i class="dot"></i>待配置 {{ unconfiguredCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-button type="primary" :icon="Plus" @click="showBatchDialog">批量设置</el-button>
-        <el-button :icon="Refresh" @click="refreshData">刷新</el-button>
-        <el-button :icon="Download" @click="exportConfig">导出配置</el-button>
-      </div>
-    </div>
-
-    <!-- 分类查询栏 -->
-    <div class="query-bar">
-      <el-select v-model="queryParams.deviceType" placeholder="设备类型" clearable style="width: 140px">
-        <el-option label="智能井盖" value="smart" />
-        <el-option label="水位计" value="water" />
-        <el-option label="气体监测仪" value="gas" />
-        <el-option label="压力监测仪" value="pressure" />
-        <el-option label="流量计" value="flow" />
-      </el-select>
-      <el-select v-model="queryParams.paramType" placeholder="监测参数" clearable style="width: 140px">
-        <el-option label="甲烷浓度" value="methane" />
-        <el-option label="水位深度" value="waterLevel" />
-        <el-option label="倾斜角度" value="tilt" />
-        <el-option label="管道压力" value="pressure" />
-        <el-option label="瞬时流量" value="flow" />
-      </el-select>
-      <el-select v-model="queryParams.hasThreshold" placeholder="阈值状态" clearable style="width: 120px">
-        <el-option label="已配置" value="configured" />
-        <el-option label="未配置" value="unconfigured" />
-      </el-select>
-      <el-input
-          v-model="queryParams.keyword"
-          placeholder="搜索点位名称/编号"
-          clearable
-          :prefix-icon="Search"
-          style="width: 200px"
-      />
-      <el-button type="primary" :icon="Search" @click="searchPoints">查询</el-button>
-      <el-button @click="resetQuery">重置</el-button>
-    </div>
-
-    <!-- 传感器列表 -->
-    <div class="sensor-table-container">
-      <div class="table-header">
-        <span>传感器点位列表</span>
-        <el-checkbox v-model="selectAll" @change="handleSelectAll">全选</el-checkbox>
-      </div>
-      <el-table
-          :data="paginatedSensors"
-          stripe
-          border
-          style="width: 100%"
-          @selection-change="handleSelectionChange"
-          ref="tableRef"
-      >
-        <el-table-column type="selection" width="45" align="center" />
-        <el-table-column prop="code" label="点位编号" width="120" sortable />
-        <el-table-column prop="name" label="点位名称" min-width="150" show-overflow-tooltip />
-        <el-table-column prop="deviceTypeName" label="设备类型" width="110" />
-        <el-table-column prop="paramTypeName" label="监测参数" width="110" />
-        <el-table-column label="阈值配置" width="200">
-          <template #default="{ row }">
-            <div v-if="row.thresholdConfig" class="threshold-display">
-              <span>预警: {{ row.thresholdConfig.warningThreshold }} {{ row.unit }}</span>
-              <span>告警: {{ row.thresholdConfig.criticalThreshold }} {{ row.unit }}</span>
-              <span v-if="row.thresholdConfig.timeRange" class="time-range">({{ row.thresholdConfig.timeRange }})</span>
-            </div>
-            <span v-else class="no-config">未配置</span>
-          </template>
-        </el-table-column>
-        <el-table-column label="分时阈值" width="150" align="center">
-          <template #default="{ row }">
-            <el-tag v-if="row.thresholdConfig?.timeRange" type="success" size="small">已启用</el-tag>
-            <el-tag v-else type="info" size="small">未启用</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="120" fixed="right" align="center">
-          <template #default="{ row }">
-            <el-button link type="primary" size="small" @click="editSingleThreshold(row)">编辑</el-button>
-            <el-button link type="info" size="small" @click="viewDetail(row)">详情</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-      <div class="pagination-wrapper">
-        <el-pagination
-            background
-            layout="total, sizes, prev, pager, next"
-            :total="filteredSensors.length"
-            v-model:current-page="currentPage"
-            v-model:page-size="pageSize"
-            :page-sizes="[10, 20, 50]"
-        />
-      </div>
-    </div>
-
-    <!-- 批量设置对话框 -->
-    <el-dialog v-model="batchDialogVisible" title="批量设置阈值" width="600px">
-      <el-form :model="batchForm" label-width="110px">
-        <el-form-item label="选择传感器">
-          <div class="selected-sensors">
-            <el-tag v-for="sensor in selectedSensors" :key="sensor.id" closable @close="removeSelected(sensor)" style="margin: 4px">
-              {{ sensor.name }}
-            </el-tag>
-          </div>
-          <div class="selected-count">已选择 {{ selectedSensors.length }} 个传感器</div>
-        </el-form-item>
-        <el-divider />
-        <el-form-item label="报警阈值" required>
-          <div class="threshold-inputs">
-            <el-input-number v-model="batchForm.warningThreshold" :min="0" :step="1" placeholder="预警值" style="width: 140px" />
-            <span class="separator">~</span>
-            <el-input-number v-model="batchForm.criticalThreshold" :min="0" :step="1" placeholder="告警值" style="width: 140px" />
-            <span class="unit">{{ batchForm.unit }}</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="分时阈值设置">
-          <el-switch v-model="batchForm.enableTimeRange" active-text="启用" inactive-text="停用" />
-        </el-form-item>
-        <el-form-item v-if="batchForm.enableTimeRange" label="分时时段">
-          <div class="time-range-config">
-            <div class="time-item">
-              <span class="time-label">工作日夜间 (22:00-06:00):</span>
-              <el-input-number v-model="batchForm.nightThreshold" :min="0" :step="1" style="width: 100px" />
-              <span class="unit">{{ batchForm.unit }}</span>
-            </div>
-            <div class="time-item">
-              <span class="time-label">工作日白天 (06:00-22:00):</span>
-              <el-input-number v-model="batchForm.dayThreshold" :min="0" :step="1" style="width: 100px" />
-              <span class="unit">{{ batchForm.unit }}</span>
-            </div>
-            <div class="time-item">
-              <span class="time-label">节假日/周末:</span>
-              <el-input-number v-model="batchForm.holidayThreshold" :min="0" :step="1" style="width: 100px" />
-              <span class="unit">{{ batchForm.unit }}</span>
-            </div>
-          </div>
-        </el-form-item>
-        <el-form-item label="备注">
-          <el-input type="textarea" v-model="batchForm.remark" placeholder="可选,填写批量设置说明" rows="2" />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="batchDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitBatchConfig">确认设置</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 单点编辑对话框 -->
-    <el-dialog v-model="singleDialogVisible" :title="`编辑阈值 - ${currentSensor?.name}`" width="550px">
-      <el-form :model="singleForm" label-width="110px">
-        <el-form-item label="点位编号">{{ currentSensor?.code }}</el-form-item>
-        <el-form-item label="设备类型">{{ currentSensor?.deviceTypeName }}</el-form-item>
-        <el-form-item label="监测参数">{{ currentSensor?.paramTypeName }}</el-form-item>
-        <el-form-item label="报警阈值" required>
-          <div class="threshold-inputs">
-            <el-input-number v-model="singleForm.warningThreshold" :min="0" :step="1" placeholder="预警值" style="width: 140px" />
-            <span class="separator">~</span>
-            <el-input-number v-model="singleForm.criticalThreshold" :min="0" :step="1" placeholder="告警值" style="width: 140px" />
-            <span class="unit">{{ currentSensor?.unit }}</span>
-          </div>
-        </el-form-item>
-        <el-form-item label="分时阈值">
-          <el-switch v-model="singleForm.enableTimeRange" active-text="启用" inactive-text="停用" />
-        </el-form-item>
-        <el-form-item v-if="singleForm.enableTimeRange" label="分时时段">
-          <div class="time-range-config">
-            <div class="time-item">夜间 (22:00-06:00): <el-input-number v-model="singleForm.nightThreshold" :min="0" style="width: 100px" /> {{ currentSensor?.unit }}</div>
-            <div class="time-item">白天 (06:00-22:00): <el-input-number v-model="singleForm.dayThreshold" :min="0" style="width: 100px" /> {{ currentSensor?.unit }}</div>
-            <div class="time-item">节假日: <el-input-number v-model="singleForm.holidayThreshold" :min="0" style="width: 100px" /> {{ currentSensor?.unit }}</div>
-          </div>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="singleDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitSingleConfig">保存</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 阈值详情抽屉 -->
-    <el-drawer v-model="detailDrawerVisible" :title="`阈值详情 - ${currentSensor?.name}`" size="450px" direction="rtl">
-      <div v-if="currentSensor" class="threshold-detail">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="点位编号">{{ currentSensor.code }}</el-descriptions-item>
-          <el-descriptions-item label="点位名称">{{ currentSensor.name }}</el-descriptions-item>
-          <el-descriptions-item label="设备类型">{{ currentSensor.deviceTypeName }}</el-descriptions-item>
-          <el-descriptions-item label="监测参数">{{ currentSensor.paramTypeName }}</el-descriptions-item>
-          <el-descriptions-item label="单位">{{ currentSensor.unit }}</el-descriptions-item>
-          <el-descriptions-item label="预警阈值" v-if="currentSensor.thresholdConfig">
-            {{ currentSensor.thresholdConfig.warningThreshold }} {{ currentSensor.unit }}
-          </el-descriptions-item>
-          <el-descriptions-item label="告警阈值" v-if="currentSensor.thresholdConfig">
-            {{ currentSensor.thresholdConfig.criticalThreshold }} {{ currentSensor.unit }}
-          </el-descriptions-item>
-          <el-descriptions-item label="分时阈值" v-if="currentSensor.thresholdConfig?.timeRange">
-            {{ currentSensor.thresholdConfig.timeRange }}
-          </el-descriptions-item>
-          <el-descriptions-item label="配置时间">{{ currentSensor.thresholdConfig?.configTime || '—' }}</el-descriptions-item>
-          <el-descriptions-item label="配置人员">{{ currentSensor.thresholdConfig?.configUser || '—' }}</el-descriptions-item>
-        </el-descriptions>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
-<script setup>
-import { ref, computed } from 'vue'
-import { Setting, Plus, Refresh, Download, Search } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-
-// 模拟传感器数据
-const sensors = ref([
-  { id: 1, code: 'FM-001', name: '世纪大道流量计', deviceType: 'flow', deviceTypeName: '流量计', paramType: 'flow', paramTypeName: '瞬时流量', unit: 'm³/h', thresholdConfig: { warningThreshold: 1000, criticalThreshold: 1200, configTime: '2025-03-15', configUser: '系统管理员' } },
-  { id: 2, code: 'FM-002', name: '张杨路流量计', deviceType: 'flow', deviceTypeName: '流量计', paramType: 'flow', paramTypeName: '瞬时流量', unit: 'm³/h', thresholdConfig: { warningThreshold: 900, criticalThreshold: 1100, configTime: '2025-03-20', configUser: '系统管理员' } },
-  { id: 3, code: 'CH4-001', name: '人民广场气体监测仪', deviceType: 'gas', deviceTypeName: '气体监测仪', paramType: 'methane', paramTypeName: '甲烷浓度', unit: 'ppm', thresholdConfig: { warningThreshold: 10, criticalThreshold: 20, configTime: '2025-03-10', configUser: '系统管理员' } },
-  { id: 4, code: 'CH4-002', name: '世纪大道气体监测仪', deviceType: 'gas', deviceTypeName: '气体监测仪', paramType: 'methane', paramTypeName: '甲烷浓度', unit: 'ppm', thresholdConfig: null },
-  { id: 5, code: 'WL-001', name: '张杨路水位计', deviceType: 'water', deviceTypeName: '水位计', paramType: 'waterLevel', paramTypeName: '水位深度', unit: 'cm', thresholdConfig: { warningThreshold: 30, criticalThreshold: 40, configTime: '2025-03-18', configUser: '系统管理员' } },
-  { id: 6, code: 'WL-002', name: '龙阳路水位计', deviceType: 'water', deviceTypeName: '水位计', paramType: 'waterLevel', paramTypeName: '水位深度', unit: 'cm', thresholdConfig: null },
-  { id: 7, code: 'PR-001', name: '东方路压力监测仪', deviceType: 'pressure', deviceTypeName: '压力监测仪', paramType: 'pressure', paramTypeName: '管道压力', unit: 'kPa', thresholdConfig: { warningThreshold: 320, criticalThreshold: 300, configTime: '2025-03-12', configUser: '系统管理员' } },
-  { id: 8, code: 'TL-001', name: '人民路井盖', deviceType: 'smart', deviceTypeName: '智能井盖', paramType: 'tilt', paramTypeName: '倾斜角度', unit: '°', thresholdConfig: null },
-])
-
-// 统计数据
-const totalSensors = computed(() => sensors.value.length)
-const configuredCount = computed(() => sensors.value.filter(s => s.thresholdConfig).length)
-const unconfiguredCount = computed(() => totalSensors.value - configuredCount.value)
-
-// 查询参数
-const queryParams = ref({
-  deviceType: '',
-  paramType: '',
-  hasThreshold: '',
-  keyword: ''
-})
-const currentPage = ref(1)
-const pageSize = ref(10)
-const selectedRows = ref([])
-const selectAll = ref(false)
-const tableRef = ref(null)
-
-// 弹窗状态
-const batchDialogVisible = ref(false)
-const singleDialogVisible = ref(false)
-const detailDrawerVisible = ref(false)
-const selectedSensors = ref([])
-const currentSensor = ref(null)
-
-// 批量表单
-const batchForm = ref({
-  warningThreshold: 0,
-  criticalThreshold: 0,
-  unit: '',
-  enableTimeRange: false,
-  nightThreshold: 0,
-  dayThreshold: 0,
-  holidayThreshold: 0,
-  remark: ''
-})
-
-// 单点表单
-const singleForm = ref({
-  warningThreshold: 0,
-  criticalThreshold: 0,
-  enableTimeRange: false,
-  nightThreshold: 0,
-  dayThreshold: 0,
-  holidayThreshold: 0
-})
-
-// 过滤传感器
-const filteredSensors = computed(() => {
-  let list = sensors.value
-  if (queryParams.value.deviceType) {
-    list = list.filter(s => s.deviceType === queryParams.value.deviceType)
-  }
-  if (queryParams.value.paramType) {
-    list = list.filter(s => s.paramType === queryParams.value.paramType)
-  }
-  if (queryParams.value.hasThreshold === 'configured') {
-    list = list.filter(s => s.thresholdConfig)
-  } else if (queryParams.value.hasThreshold === 'unconfigured') {
-    list = list.filter(s => !s.thresholdConfig)
-  }
-  if (queryParams.value.keyword) {
-    const kw = queryParams.value.keyword.toLowerCase()
-    list = list.filter(s => s.name.toLowerCase().includes(kw) || s.code.toLowerCase().includes(kw))
-  }
-  return list
-})
-
-const paginatedSensors = computed(() => {
-  const start = (currentPage.value - 1) * pageSize.value
-  return filteredSensors.value.slice(start, start + pageSize.value)
-})
-
-// 方法
-const searchPoints = () => { currentPage.value = 1 }
-const resetQuery = () => {
-  queryParams.value = { deviceType: '', paramType: '', hasThreshold: '', keyword: '' }
-  currentPage.value = 1
-}
-const refreshData = () => { ElMessage.success('数据已刷新') }
-const exportConfig = () => { ElMessage.success('导出阈值配置(演示)') }
-
-const handleSelectionChange = (val) => {
-  selectedRows.value = val
-  selectAll.value = val.length === paginatedSensors.value.length && paginatedSensors.value.length > 0
-}
-const handleSelectAll = (val) => {
-  if (val) {
-    paginatedSensors.value.forEach(row => tableRef.value?.toggleRowSelection(row, true))
-  } else {
-    tableRef.value?.clearSelection()
-  }
-}
-
-const showBatchDialog = () => {
-  if (selectedRows.value.length === 0) {
-    ElMessage.warning('请先选择要批量设置的传感器')
-    return
-  }
-  selectedSensors.value = [...selectedRows.value]
-  // 获取公共单位(简化处理,取第一个的单位)
-  if (selectedSensors.value.length > 0) {
-    batchForm.value.unit = selectedSensors.value[0].unit
-  }
-  batchForm.value.warningThreshold = 0
-  batchForm.value.criticalThreshold = 0
-  batchForm.value.enableTimeRange = false
-  batchForm.value.nightThreshold = 0
-  batchForm.value.dayThreshold = 0
-  batchForm.value.holidayThreshold = 0
-  batchForm.value.remark = ''
-  batchDialogVisible.value = true
-}
-
-const removeSelected = (sensor) => {
-  const index = selectedSensors.value.findIndex(s => s.id === sensor.id)
-  if (index !== -1) selectedSensors.value.splice(index, 1)
-  // 同时从表格选择中移除
-  tableRef.value?.toggleRowSelection(sensor, false)
-  selectedRows.value = selectedRows.value.filter(r => r.id !== sensor.id)
-}
-
-const submitBatchConfig = () => {
-  if (batchForm.value.warningThreshold <= 0 || batchForm.value.criticalThreshold <= 0) {
-    ElMessage.warning('请填写完整的阈值')
-    return
-  }
-  const configTime = new Date().toLocaleString()
-  let timeRangeDesc = ''
-  if (batchForm.value.enableTimeRange) {
-    timeRangeDesc = `分时: 夜间${batchForm.value.nightThreshold} | 白天${batchForm.value.dayThreshold} | 节假日${batchForm.value.holidayThreshold}`
-  }
-  selectedSensors.value.forEach(sensor => {
-    sensor.thresholdConfig = {
-      warningThreshold: batchForm.value.warningThreshold,
-      criticalThreshold: batchForm.value.criticalThreshold,
-      configTime,
-      configUser: '当前用户',
-      timeRange: timeRangeDesc || null
-    }
-  })
-  ElMessage.success(`成功设置 ${selectedSensors.value.length} 个传感器的阈值`)
-  batchDialogVisible.value = false
-  selectedRows.value = []
-  selectAll.value = false
-  tableRef.value?.clearSelection()
-}
-
-const editSingleThreshold = (row) => {
-  currentSensor.value = row
-  if (row.thresholdConfig) {
-    singleForm.value.warningThreshold = row.thresholdConfig.warningThreshold
-    singleForm.value.criticalThreshold = row.thresholdConfig.criticalThreshold
-    singleForm.value.enableTimeRange = !!row.thresholdConfig.timeRange
-  } else {
-    singleForm.value.warningThreshold = 0
-    singleForm.value.criticalThreshold = 0
-    singleForm.value.enableTimeRange = false
-    singleForm.value.nightThreshold = 0
-    singleForm.value.dayThreshold = 0
-    singleForm.value.holidayThreshold = 0
-  }
-  singleDialogVisible.value = true
-}
-
-const submitSingleConfig = () => {
-  if (singleForm.value.warningThreshold <= 0 || singleForm.value.criticalThreshold <= 0) {
-    ElMessage.warning('请填写完整的阈值')
-    return
-  }
-  let timeRangeDesc = ''
-  if (singleForm.value.enableTimeRange) {
-    timeRangeDesc = `分时: 夜间${singleForm.value.nightThreshold} | 白天${singleForm.value.dayThreshold} | 节假日${singleForm.value.holidayThreshold}`
-  }
-  currentSensor.value.thresholdConfig = {
-    warningThreshold: singleForm.value.warningThreshold,
-    criticalThreshold: singleForm.value.criticalThreshold,
-    configTime: new Date().toLocaleString(),
-    configUser: '当前用户',
-    timeRange: timeRangeDesc || null
-  }
-  ElMessage.success(`阈值配置已保存`)
-  singleDialogVisible.value = false
-}
-
-const viewDetail = (row) => {
-  currentSensor.value = row
-  detailDrawerVisible.value = true
-}
-</script>
-
-<style scoped>
-.threshold-management {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #909399; }
-.header-right { display: flex; gap: 12px; }
-
-.query-bar {
-  background: white;
-  border-radius: 12px;
-  padding: 16px;
-  margin-bottom: 20px;
-  display: flex;
-  gap: 12px;
-  flex-wrap: wrap;
-  align-items: center;
-}
-
-.sensor-table-container {
-  background: white;
-  border-radius: 16px;
-  padding: 16px;
-}
-.table-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16px;
-  font-weight: 600;
-}
-.threshold-display { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
-.threshold-display .time-range { color: #e6a23c; }
-.no-config { color: #c0c4cc; }
-.pagination-wrapper { margin-top: 16px; display: flex; justify-content: flex-end; }
-
-.selected-sensors { min-height: 50px; border: 1px dashed #ddd; padding: 8px; border-radius: 8px; margin-bottom: 8px; }
-.selected-count { font-size: 12px; color: #909399; }
-
-.threshold-inputs { display: flex; align-items: center; gap: 8px; }
-.separator { color: #909399; }
-.unit { margin-left: 4px; color: #606266; }
-
-.time-range-config { display: flex; flex-direction: column; gap: 12px; width: 100%; }
-.time-item { display: flex; align-items: center; gap: 12px; }
-.time-label { width: 160px; font-size: 13px; }
-
-.threshold-detail { padding: 8px; }
-</style>
+<script setup>import WaterAlarmPage from '../components/WaterAlarmPage.vue'</script><template><WaterAlarmPage title="阈值管理" subtitle="分类设置监测阈值与分时规则" mode="threshold" /></template>

+ 3 - 539
src/views/subSystem/waterSupply/wyxjc/lljc.vue

@@ -1,541 +1,5 @@
-<!--流量监测-->
-<template>
-  <div class="flow-monitor-dashboard">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Connection /></el-icon>
-        <span class="title">流量计监测数据管理</span>
-        <div class="stats-badge">
-          <span class="stat-item"><i class="dot"></i>监测点位 {{ totalPoints }}</span>
-          <span class="stat-item online"><i class="dot"></i>在线 {{ onlineCount }}</span>
-          <span class="stat-item alert"><i class="dot"></i>异常 {{ alertCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-button type="primary" :icon="Plus" @click="addPoint">新增点位</el-button>
-        <el-button :icon="Download" @click="exportData">批量导出</el-button>
-        <el-button :icon="Refresh" @click="refreshData">刷新</el-button>
-      </div>
-    </div>
-
-    <!-- 多重查询区域 -->
-    <div class="query-bar">
-      <div class="query-row">
-        <el-input
-            v-model="queryParams.pointName"
-            placeholder="点位名称"
-            clearable
-            style="width: 180px"
-            size="default"
-        />
-        <el-input
-            v-model="queryParams.pointCode"
-            placeholder="点位编号"
-            clearable
-            style="width: 150px"
-            size="default"
-        />
-        <el-select v-model="queryParams.status" placeholder="运行状态" clearable style="width: 120px" size="default">
-          <el-option label="在线" value="online" />
-          <el-option label="离线" value="offline" />
-          <el-option label="异常" value="alert" />
-        </el-select>
-        <el-select v-model="queryParams.area" placeholder="所属区域" clearable style="width: 140px" size="default">
-          <el-option label="浦东新区" value="浦东新区" />
-          <el-option label="徐汇区" value="徐汇区" />
-          <el-option label="黄浦区" value="黄浦区" />
-        </el-select>
-        <el-date-picker
-            v-model="queryParams.dateRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            style="width: 260px"
-            size="default"
-        />
-        <el-button type="primary" :icon="Search" @click="searchPoints">查询</el-button>
-        <el-button @click="resetQuery">重置</el-button>
-      </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-cards">
-      <div class="stat-card">
-        <div class="stat-value">22<span class="unit">万m³/h</span></div>
-        <div class="stat-label">总瞬时流量</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">20<span class="unit">m³/h</span></div>
-        <div class="stat-label">平均流量</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ maxFlow }}<span class="unit">m³/h</span></div>
-        <div class="stat-label">最大流量</div>
-        <div class="stat-trend">点位: {{ maxFlowPoint }}</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ onlineRate }}<span class="unit">%</span></div>
-        <div class="stat-label">设备在线率</div>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧点位列表 + 右侧GIS地图 -->
-    <div class="main-layout">
-      <!-- 左侧点位清单表格 -->
-      <div class="point-table-panel">
-        <div class="table-header">
-          <span>流量监测点位清单</span>
-          <div class="table-actions">
-            <el-button size="small" @click="exportSelected">导出选中</el-button>
-            <el-button size="small" type="primary" @click="batchExport">批量导出全部</el-button>
-          </div>
-        </div>
-        <el-table
-            :data="paginatedPoints"
-            stripe
-            border
-            style="width: 100%"
-            @row-click="selectPointOnMap"
-            @selection-change="handleSelectionChange"
-            ref="tableRef"
-        >
-          <el-table-column type="selection" width="45" align="center" />
-          <el-table-column type="index" label="序号" width="55" align="center" />
-          <el-table-column prop="code" label="点位编号" width="120" sortable />
-          <el-table-column prop="name" label="点位名称" min-width="150" show-overflow-tooltip />
-          <el-table-column prop="area" label="所属区域" width="100" />
-          <el-table-column prop="flow" label="瞬时流量(m³/h)" width="120" sortable>
-            <template #default="{ row }">
-              <span :class="{ 'alert-value': row.flow > row.warningThreshold }">{{ row.flow }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column prop="cumulativeFlow" label="累计流量(万m³)" width="120" sortable />
-          <el-table-column prop="pressure" label="管压(kPa)" width="100" />
-          <el-table-column prop="status" label="运行状态" width="90" align="center">
-            <template #default="{ row }">
-              <el-tag :type="row.status === 'online' ? 'success' : row.status === 'alert' ? 'danger' : 'info'" size="small">
-                {{ row.status === 'online' ? '在线' : row.status === 'alert' ? '异常' : '离线' }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="updateTime" label="最后通讯" width="150" sortable />
-          <el-table-column label="操作" width="100" fixed="right" align="center">
-            <template #default="{ row }">
-              <el-button link type="primary" size="small" @click.stop="viewDetail(row)">详情</el-button>
-              <el-button link type="success" size="small" @click.stop="locateOnMap(row)">定位</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-        <div class="pagination-wrapper">
-          <el-pagination
-              background
-              layout="total, sizes, prev, pager, next"
-              :total="filteredPoints.length"
-              v-model:current-page="currentPage"
-              v-model:page-size="pageSize"
-              :page-sizes="[10, 20, 50]"
-          />
-        </div>
-      </div>
-
-      <!-- 右侧GIS地图 -->
-      <div class="gis-panel">
-        <div class="map-header">
-          <span>点位分布地图</span>
-          <div class="map-tools">
-            <el-button-group>
-              <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
-              <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
-              <el-button size="small" :icon="Location" @click="fitBounds">全图</el-button>
-            </el-button-group>
-          </div>
-        </div>
-        <div class="map-container" ref="mapContainer">
-          <canvas id="flowMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
-          <div v-if="hoverPoint" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverPoint.name }}</div>
-            <div>流量: {{ hoverPoint.flow }} m³/h</div>
-            <div>管压: {{ hoverPoint.pressure }} kPa</div>
-            <div>状态: {{ hoverPoint.status === 'online' ? '在线' : hoverPoint.status === 'alert' ? '异常' : '离线' }}</div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 点位详情抽屉 -->
-    <el-drawer v-model="detailDrawerVisible" :title="`点位详情 - ${currentPoint?.name}`" size="450px" direction="rtl">
-      <div v-if="currentPoint" class="point-detail">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="点位编号">{{ currentPoint.code }}</el-descriptions-item>
-          <el-descriptions-item label="点位名称">{{ currentPoint.name }}</el-descriptions-item>
-          <el-descriptions-item label="所属区域">{{ currentPoint.area }}</el-descriptions-item>
-          <el-descriptions-item label="详细地址">{{ currentPoint.address }}</el-descriptions-item>
-          <el-descriptions-item label="经纬度">{{ currentPoint.lng }}, {{ currentPoint.lat }}</el-descriptions-item>
-          <el-descriptions-item label="瞬时流量">{{ currentPoint.flow }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="累计流量">{{ currentPoint.cumulativeFlow }} 万m³</el-descriptions-item>
-          <el-descriptions-item label="管压">{{ currentPoint.pressure }} kPa</el-descriptions-item>
-          <el-descriptions-item label="报警阈值">{{ currentPoint.warningThreshold }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="运行状态">
-            <el-tag :type="currentPoint.status === 'online' ? 'success' : currentPoint.status === 'alert' ? 'danger' : 'info'">
-              {{ currentPoint.status === 'online' ? '在线' : currentPoint.status === 'alert' ? '异常' : '离线' }}
-            </el-tag>
-          </el-descriptions-item>
-          <el-descriptions-item label="设备型号">{{ currentPoint.deviceModel }}</el-descriptions-item>
-          <el-descriptions-item label="安装日期">{{ currentPoint.installDate }}</el-descriptions-item>
-          <el-descriptions-item label="最后通讯">{{ currentPoint.updateTime }}</el-descriptions-item>
-        </el-descriptions>
-        <div class="trend-section">
-          <div class="sub-title">近24小时流量趋势</div>
-          <div class="trend-chart">
-            <v-chart :option="trendChartOption" autoresize />
-          </div>
-        </div>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { Connection, Plus, Download, Refresh, Search, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
-import { use } from 'echarts/core'
-import { CanvasRenderer } from 'echarts/renderers'
-import { LineChart } from 'echarts/charts'
-import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
-import { ElMessage } from 'element-plus'
-
-use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
-
-// 模拟流量监测点数据
-const flowPoints = ref([
-  { id: 1, code: 'FM-001', name: '世纪大道流量计', area: '浦东新区', address: '世纪大道福山路口', lng: 121.505, lat: 31.235,
-    flow: 1250, cumulativeFlow: 328.5, pressure: 385, warningThreshold: 1200,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-03-15', updateTime: '2025-04-02 15:23:45',
-    trendData: [1150, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180, 1170, 1160, 1150, 1140, 1160, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180] },
-  { id: 2, code: 'FM-002', name: '张杨路流量计', area: '浦东新区', address: '张杨路松林路口', lng: 121.52, lat: 31.242,
-    flow: 980, cumulativeFlow: 256.3, pressure: 350, warningThreshold: 1000,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-05-20', updateTime: '2025-04-02 15:22:30',
-    trendData: [900, 920, 940, 960, 980, 970, 960, 950, 940, 930, 920, 910, 900, 890, 910, 930, 950, 970, 980, 970, 960, 950, 940, 930] },
-  { id: 3, code: 'FM-003', name: '浦东南路流量计', area: '浦东新区', address: '浦东南路400号', lng: 121.49, lat: 31.23,
-    flow: 2100, cumulativeFlow: 512.8, pressure: 420, warningThreshold: 1800,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-01-10', updateTime: '2025-04-02 15:21:15',
-    trendData: [1800, 1850, 1900, 1950, 2000, 2050, 2100, 2080, 2050, 2020, 1980, 1950, 1920, 1900, 1880, 1860, 1840, 1820, 1800, 1780, 1760, 1740, 1720, 1700] },
-  { id: 4, code: 'FM-004', name: '龙阳路流量计', area: '浦东新区', address: '龙阳路白杨路口', lng: 121.51, lat: 31.215,
-    flow: 680, cumulativeFlow: 185.2, pressure: 310, warningThreshold: 800,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-07-01', updateTime: '2025-04-02 15:20:00',
-    trendData: [620, 640, 660, 680, 670, 660, 650, 640, 630, 620, 610, 600, 590, 580, 600, 620, 640, 660, 680, 670, 660, 650, 640, 630] },
-  { id: 5, code: 'FM-005', name: '东方路流量计', area: '浦东新区', address: '东方路蓝村路口', lng: 121.53, lat: 31.248,
-    flow: 1520, cumulativeFlow: 398.6, pressure: 370, warningThreshold: 1400,
-    status: 'online', deviceModel: 'FL-2500', installDate: '2024-09-15', updateTime: '2025-04-02 15:19:30',
-    trendData: [1400, 1430, 1460, 1490, 1520, 1510, 1500, 1480, 1460, 1440, 1420, 1400, 1380, 1360, 1380, 1400, 1420, 1440, 1460, 1480, 1500, 1480, 1460, 1440] },
-  { id: 6, code: 'FM-006', name: '人民路流量计', area: '黄浦区', address: '人民路与世纪大道路口', lng: 121.487, lat: 31.239,
-    flow: 1850, cumulativeFlow: 468.2, pressure: 395, warningThreshold: 1600,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-11-20', updateTime: '2025-04-02 15:18:45',
-    trendData: [1600, 1650, 1700, 1750, 1800, 1850, 1830, 1800, 1780, 1750, 1720, 1700, 1680, 1660, 1640, 1620, 1600, 1580, 1560, 1540, 1520, 1500, 1480, 1460] },
-  { id: 7, code: 'FM-007', name: '淮海路流量计', area: '徐汇区', address: '淮海中路陕西南路', lng: 121.475, lat: 31.225,
-    flow: 850, cumulativeFlow: 225.4, pressure: 340, warningThreshold: 900,
-    status: 'offline', deviceModel: 'FL-2000', installDate: '2024-12-05', updateTime: '2025-04-02 08:00:00',
-    trendData: [800, 820, 840, 850, 840, 830, 820, 810, 800, 790, 780, 770, 760, 750, 740, 730, 720, 710, 700, 690, 680, 670, 660, 650] }
-])
-
-// 统计数据
-const totalPoints = computed(() => flowPoints.value.length)
-const onlineCount = computed(() => flowPoints.value.filter(p => p.status === 'online').length)
-const alertCount = computed(() => flowPoints.value.filter(p => p.status === 'alert').length)
-const totalFlow = computed(() => flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / 10000)
-const avgFlow = computed(() => (flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / totalPoints.value).toFixed(0))
-const maxFlow = computed(() => Math.max(...flowPoints.value.map(p => p.flow)))
-const maxFlowPoint = computed(() => flowPoints.value.find(p => p.flow === maxFlow.value)?.name || '—')
-const onlineRate = computed(() => ((onlineCount.value / totalPoints.value) * 100).toFixed(1))
-
-// 查询参数
-const queryParams = ref({
-  pointName: '',
-  pointCode: '',
-  status: '',
-  area: '',
-  dateRange: []
-})
-const currentPage = ref(1)
-const pageSize = ref(10)
-const selectedRows = ref([])
-const tableRef = ref(null)
-
-// 过滤后的点位
-const filteredPoints = computed(() => {
-  let list = flowPoints.value
-  if (queryParams.value.pointName) {
-    list = list.filter(p => p.name.includes(queryParams.value.pointName))
-  }
-  if (queryParams.value.pointCode) {
-    list = list.filter(p => p.code.includes(queryParams.value.pointCode))
-  }
-  if (queryParams.value.status) {
-    list = list.filter(p => p.status === queryParams.value.status)
-  }
-  if (queryParams.value.area) {
-    list = list.filter(p => p.area === queryParams.value.area)
-  }
-  return list
-})
-
-const paginatedPoints = computed(() => {
-  const start = (currentPage.value - 1) * pageSize.value
-  return filteredPoints.value.slice(start, start + pageSize.value)
-})
-
-// GIS画布相关
-const canvasRef = ref(null)
-const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-let dragStart = null
-let isDragging = false
-const bounds = { minLng: 121.45, maxLng: 121.56, minLat: 31.20, maxLat: 31.27 }
-
-// 交互状态
-const hoverPoint = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
-const currentPoint = ref(null)
-const detailDrawerVisible = ref(false)
-
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf5e9'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i <= 10; i++) {
-    const x = (i / 10) * mapWidth
-    ctx.beginPath()
-    ctx.moveTo(x, 0); ctx.lineTo(x, mapHeight); ctx.stroke()
-    const y = (i / 10) * mapHeight
-    ctx.beginPath()
-    ctx.moveTo(0, y); ctx.lineTo(mapWidth, y); ctx.stroke()
-  }
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('世纪大道', 350, 120)
-  ctx.fillText('张杨路', 450, 280)
-  ctx.fillText('东方路', 580, 200)
-}
-
-function drawPoints() {
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    let color = '#67c23a'
-    let size = 10
-    if (point.status === 'alert') { color = '#f56c6c'; size = 14 }
-    else if (point.status === 'offline') { color = '#909399'; size = 8 }
-
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = color
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-
-    if (point.status === 'alert') {
-      ctx.beginPath()
-      ctx.arc(x, y, size + 4, 0, 2 * Math.PI)
-      ctx.fillStyle = 'rgba(245,108,108,0.3)'
-      ctx.fill()
-    }
-
-    ctx.fillStyle = '#333'
-    ctx.font = '10px sans-serif'
-    ctx.fillText(point.name.slice(0, 4), x - 12, y - 8)
-  })
-}
-
-function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-  drawBaseMap()
-  drawPoints()
-}
-
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function fitBounds() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
-
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) { currentPoint.value = hit; detailDrawerVisible.value = true }
-}
-
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) {
-    hoverPoint.value = hit
-    hoverX.value = e.clientX + 12
-    hoverY.value = e.clientY - 40
-  } else {
-    hoverPoint.value = null
-  }
-}
-
-function initDragEvents() {
-  const canvas = canvasRef.value
-  if (!canvas) return
-  canvas.addEventListener('mousedown', (e) => {
-    dragStart = { x: e.clientX, y: e.clientY, offsetX: offsetX, offsetY: offsetY }
-    isDragging = true
-    canvas.style.cursor = 'grabbing'
-  })
-  window.addEventListener('mousemove', (e) => {
-    if (!isDragging) return
-    const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y
-    offsetX = dragStart.offsetX + dx; offsetY = dragStart.offsetY + dy
-    renderMap()
-  })
-  window.addEventListener('mouseup', () => {
-    isDragging = false
-    if (canvasRef.value) canvasRef.value.style.cursor = 'crosshair'
-  })
-}
-
-// 趋势图配置
-const trendChartOption = computed(() => {
-  if (!currentPoint.value) return {}
-  const times = Array.from({ length: 24 }, (_, i) => `${i}:00`)
-  const threshold = currentPoint.value.warningThreshold
-  return {
-    tooltip: { trigger: 'axis' },
-    xAxis: { type: 'category', data: times, axisLabel: { rotate: 45, interval: 4 } },
-    yAxis: { type: 'value', name: '流量 (m³/h)' },
-    series: [
-      { name: '瞬时流量', type: 'line', data: currentPoint.value.trendData, smooth: true, lineStyle: { color: '#409eff', width: 2 }, areaStyle: { opacity: 0.1 } },
-      { name: '报警阈值', type: 'line', data: Array(24).fill(threshold), lineStyle: { color: '#f56c6c', width: 2, type: 'dashed' }, symbol: 'none' }
-    ]
-  }
-})
-
-// 方法
-const searchPoints = () => { currentPage.value = 1; renderMap() }
-const resetQuery = () => {
-  queryParams.value = { pointName: '', pointCode: '', status: '', area: '', dateRange: [] }
-  currentPage.value = 1
-  renderMap()
-}
-const refreshData = () => { renderMap(); ElMessage.success('数据已刷新') }
-const addPoint = () => { ElMessage.info('新增点位(演示)') }
-const exportData = () => { ElMessage.success('批量导出全部数据(演示)') }
-const exportSelected = () => { ElMessage.success(`导出选中 ${selectedRows.value.length} 个点位(演示)`) }
-const batchExport = () => { ElMessage.success('批量导出全部点位(演示)') }
-const viewDetail = (row) => { currentPoint.value = row; detailDrawerVisible.value = true }
-const selectPointOnMap = (row) => { locateOnMap(row); viewDetail(row) }
-const locateOnMap = (row) => {
-  const center = worldToCanvas(row.lng, row.lat)
-  mapZoom = 2
-  offsetX = mapWidth/2 - center.x
-  offsetY = mapHeight/2 - center.y
-  renderMap()
-}
-const handleSelectionChange = (val) => { selectedRows.value = val }
-
-watch(() => filteredPoints.value, () => { currentPage.value = 1; renderMap() })
-
-onMounted(() => {
-  nextTick(() => { renderMap(); initDragEvents(); window.addEventListener('resize', renderMap) })
-})
-onUnmounted(() => window.removeEventListener('resize', renderMap))
+import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
+import { listFlowMonitoring, getFlowTrend, exportFlowMonitoring } from '@/api/pipeNetwork/waterSupply'
 </script>
-
-<style scoped>
-.flow-monitor-dashboard {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.online { color: #67c23a; }
-.stat-item.alert { color: #f56c6c; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #909399; }
-.stat-item.online .dot { background: #67c23a; }
-.stat-item.alert .dot { background: #f56c6c; }
-.header-right { display: flex; gap: 12px; }
-
-.query-bar { background: white; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
-.query-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
-
-.stats-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
-.stat-card { background: white; border-radius: 12px; padding: 16px; text-align: center; }
-.stat-value { font-size: 28px; font-weight: bold; }
-.stat-value .unit { font-size: 14px; font-weight: normal; }
-.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
-.stat-trend { font-size: 11px; color: #606266; margin-top: 4px; }
-
-.main-layout { display: flex; gap: 20px; }
-.point-table-panel { flex: 1.5; background: white; border-radius: 16px; padding: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; font-weight: 600; }
-.table-actions { display: flex; gap: 8px; }
-.pagination-wrapper { margin-top: 16px; display: flex; justify-content: flex-end; }
-
-.gis-panel { flex: 1; background: white; border-radius: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.map-header { padding: 12px 16px; border-bottom: 1px solid #e9ecef; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
-.map-container { flex: 1; position: relative; cursor: crosshair; min-height: 500px; }
-canvas { width: 100%; height: 100%; display: block; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-}
-.hover-title { font-weight: 600; margin-bottom: 4px; }
-.point-detail { padding: 8px; }
-.alert-value { color: #f56c6c; font-weight: bold; }
-.sub-title { font-weight: 600; margin: 16px 0 12px 0; }
-.trend-chart { height: 200px; }
-</style>
+<template><WaterMonitoringPage title="流量监测" metric-label="流量" unit="m³/h" metric="flow" monitor-type="流量监测" :load="listFlowMonitoring" :trend="getFlowTrend" :export-api="exportFlowMonitoring" min-key="flowMin" max-key="flowMax" /></template>

+ 3 - 540
src/views/subSystem/waterSupply/wyxjc/lousjc.vue

@@ -1,542 +1,5 @@
-<!--漏失监测-->
-<!--漏失监测-->
-<template>
-  <div class="flow-monitor-dashboard">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Connection /></el-icon>
-        <span class="title">漏失计监测数据管理</span>
-        <div class="stats-badge">
-          <span class="stat-item"><i class="dot"></i>监测点位 {{ totalPoints }}</span>
-          <span class="stat-item online"><i class="dot"></i>在线 {{ onlineCount }}</span>
-          <span class="stat-item alert"><i class="dot"></i>异常 {{ alertCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-button type="primary" :icon="Plus" @click="addPoint">新增点位</el-button>
-        <el-button :icon="Download" @click="exportData">批量导出</el-button>
-        <el-button :icon="Refresh" @click="refreshData">刷新</el-button>
-      </div>
-    </div>
-
-    <!-- 多重查询区域 -->
-    <div class="query-bar">
-      <div class="query-row">
-        <el-input
-            v-model="queryParams.pointName"
-            placeholder="点位名称"
-            clearable
-            style="width: 180px"
-            size="default"
-        />
-        <el-input
-            v-model="queryParams.pointCode"
-            placeholder="点位编号"
-            clearable
-            style="width: 150px"
-            size="default"
-        />
-        <el-select v-model="queryParams.status" placeholder="运行状态" clearable style="width: 120px" size="default">
-          <el-option label="在线" value="online" />
-          <el-option label="离线" value="offline" />
-          <el-option label="异常" value="alert" />
-        </el-select>
-        <el-select v-model="queryParams.area" placeholder="所属区域" clearable style="width: 140px" size="default">
-          <el-option label="浦东新区" value="浦东新区" />
-          <el-option label="徐汇区" value="徐汇区" />
-          <el-option label="黄浦区" value="黄浦区" />
-        </el-select>
-        <el-date-picker
-            v-model="queryParams.dateRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            style="width: 260px"
-            size="default"
-        />
-        <el-button type="primary" :icon="Search" @click="searchPoints">查询</el-button>
-        <el-button @click="resetQuery">重置</el-button>
-      </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-cards">
-      <div class="stat-card">
-        <div class="stat-value">22<span class="unit">万m³/h</span></div>
-        <div class="stat-label">总瞬时漏失</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">20<span class="unit">m³/h</span></div>
-        <div class="stat-label">平均漏失</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ maxFlow }}<span class="unit">m³/h</span></div>
-        <div class="stat-label">最大漏失</div>
-        <div class="stat-trend">点位: {{ maxFlowPoint }}</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ onlineRate }}<span class="unit">%</span></div>
-        <div class="stat-label">设备在线率</div>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧点位列表 + 右侧GIS地图 -->
-    <div class="main-layout">
-      <!-- 左侧点位清单表格 -->
-      <div class="point-table-panel">
-        <div class="table-header">
-          <span>漏失监测点位清单</span>
-          <div class="table-actions">
-            <el-button size="small" @click="exportSelected">导出选中</el-button>
-            <el-button size="small" type="primary" @click="batchExport">批量导出全部</el-button>
-          </div>
-        </div>
-        <el-table
-            :data="paginatedPoints"
-            stripe
-            border
-            style="width: 100%"
-            @row-click="selectPointOnMap"
-            @selection-change="handleSelectionChange"
-            ref="tableRef"
-        >
-          <el-table-column type="selection" width="45" align="center" />
-          <el-table-column type="index" label="序号" width="55" align="center" />
-          <el-table-column prop="code" label="点位编号" width="120" sortable />
-          <el-table-column prop="name" label="点位名称" min-width="150" show-overflow-tooltip />
-          <el-table-column prop="area" label="所属区域" width="100" />
-          <el-table-column prop="flow" label="瞬时漏失(m³/h)" width="120" sortable>
-            <template #default="{ row }">
-              <span :class="{ 'alert-value': row.flow > row.warningThreshold }">{{ row.flow }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column prop="cumulativeFlow" label="累计漏失(万m³)" width="120" sortable />
-          <el-table-column prop="pressure" label="管压(kPa)" width="100" />
-          <el-table-column prop="status" label="运行状态" width="90" align="center">
-            <template #default="{ row }">
-              <el-tag :type="row.status === 'online' ? 'success' : row.status === 'alert' ? 'danger' : 'info'" size="small">
-                {{ row.status === 'online' ? '在线' : row.status === 'alert' ? '异常' : '离线' }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="updateTime" label="最后通讯" width="150" sortable />
-          <el-table-column label="操作" width="100" fixed="right" align="center">
-            <template #default="{ row }">
-              <el-button link type="primary" size="small" @click.stop="viewDetail(row)">详情</el-button>
-              <el-button link type="success" size="small" @click.stop="locateOnMap(row)">定位</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-        <div class="pagination-wrapper">
-          <el-pagination
-              background
-              layout="total, sizes, prev, pager, next"
-              :total="filteredPoints.length"
-              v-model:current-page="currentPage"
-              v-model:page-size="pageSize"
-              :page-sizes="[10, 20, 50]"
-          />
-        </div>
-      </div>
-
-      <!-- 右侧GIS地图 -->
-      <div class="gis-panel">
-        <div class="map-header">
-          <span>点位分布地图</span>
-          <div class="map-tools">
-            <el-button-group>
-              <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
-              <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
-              <el-button size="small" :icon="Location" @click="fitBounds">全图</el-button>
-            </el-button-group>
-          </div>
-        </div>
-        <div class="map-container" ref="mapContainer">
-          <canvas id="flowMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
-          <div v-if="hoverPoint" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverPoint.name }}</div>
-            <div>漏失: {{ hoverPoint.flow }} m³/h</div>
-            <div>管压: {{ hoverPoint.pressure }} kPa</div>
-            <div>状态: {{ hoverPoint.status === 'online' ? '在线' : hoverPoint.status === 'alert' ? '异常' : '离线' }}</div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 点位详情抽屉 -->
-    <el-drawer v-model="detailDrawerVisible" :title="`点位详情 - ${currentPoint?.name}`" size="450px" direction="rtl">
-      <div v-if="currentPoint" class="point-detail">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="点位编号">{{ currentPoint.code }}</el-descriptions-item>
-          <el-descriptions-item label="点位名称">{{ currentPoint.name }}</el-descriptions-item>
-          <el-descriptions-item label="所属区域">{{ currentPoint.area }}</el-descriptions-item>
-          <el-descriptions-item label="详细地址">{{ currentPoint.address }}</el-descriptions-item>
-          <el-descriptions-item label="经纬度">{{ currentPoint.lng }}, {{ currentPoint.lat }}</el-descriptions-item>
-          <el-descriptions-item label="瞬时漏失">{{ currentPoint.flow }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="累计漏失">{{ currentPoint.cumulativeFlow }} 万m³</el-descriptions-item>
-          <el-descriptions-item label="管压">{{ currentPoint.pressure }} kPa</el-descriptions-item>
-          <el-descriptions-item label="报警阈值">{{ currentPoint.warningThreshold }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="运行状态">
-            <el-tag :type="currentPoint.status === 'online' ? 'success' : currentPoint.status === 'alert' ? 'danger' : 'info'">
-              {{ currentPoint.status === 'online' ? '在线' : currentPoint.status === 'alert' ? '异常' : '离线' }}
-            </el-tag>
-          </el-descriptions-item>
-          <el-descriptions-item label="设备型号">{{ currentPoint.deviceModel }}</el-descriptions-item>
-          <el-descriptions-item label="安装日期">{{ currentPoint.installDate }}</el-descriptions-item>
-          <el-descriptions-item label="最后通讯">{{ currentPoint.updateTime }}</el-descriptions-item>
-        </el-descriptions>
-        <div class="trend-section">
-          <div class="sub-title">近24小时漏失趋势</div>
-          <div class="trend-chart">
-            <v-chart :option="trendChartOption" autoresize />
-          </div>
-        </div>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { Connection, Plus, Download, Refresh, Search, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
-import { use } from 'echarts/core'
-import { CanvasRenderer } from 'echarts/renderers'
-import { LineChart } from 'echarts/charts'
-import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
-import { ElMessage } from 'element-plus'
-
-use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
-
-// 模拟漏失监测点数据
-const flowPoints = ref([
-  { id: 1, code: 'FM-001', name: '世纪大道漏失计', area: '浦东新区', address: '世纪大道福山路口', lng: 121.505, lat: 31.235,
-    flow: 1250, cumulativeFlow: 328.5, pressure: 385, warningThreshold: 1200,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-03-15', updateTime: '2025-04-02 15:23:45',
-    trendData: [1150, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180, 1170, 1160, 1150, 1140, 1160, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180] },
-  { id: 2, code: 'FM-002', name: '张杨路漏失计', area: '浦东新区', address: '张杨路松林路口', lng: 121.52, lat: 31.242,
-    flow: 980, cumulativeFlow: 256.3, pressure: 350, warningThreshold: 1000,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-05-20', updateTime: '2025-04-02 15:22:30',
-    trendData: [900, 920, 940, 960, 980, 970, 960, 950, 940, 930, 920, 910, 900, 890, 910, 930, 950, 970, 980, 970, 960, 950, 940, 930] },
-  { id: 3, code: 'FM-003', name: '浦东南路漏失计', area: '浦东新区', address: '浦东南路400号', lng: 121.49, lat: 31.23,
-    flow: 2100, cumulativeFlow: 512.8, pressure: 420, warningThreshold: 1800,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-01-10', updateTime: '2025-04-02 15:21:15',
-    trendData: [1800, 1850, 1900, 1950, 2000, 2050, 2100, 2080, 2050, 2020, 1980, 1950, 1920, 1900, 1880, 1860, 1840, 1820, 1800, 1780, 1760, 1740, 1720, 1700] },
-  { id: 4, code: 'FM-004', name: '龙阳路漏失计', area: '浦东新区', address: '龙阳路白杨路口', lng: 121.51, lat: 31.215,
-    flow: 680, cumulativeFlow: 185.2, pressure: 310, warningThreshold: 800,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-07-01', updateTime: '2025-04-02 15:20:00',
-    trendData: [620, 640, 660, 680, 670, 660, 650, 640, 630, 620, 610, 600, 590, 580, 600, 620, 640, 660, 680, 670, 660, 650, 640, 630] },
-  { id: 5, code: 'FM-005', name: '东方路漏失计', area: '浦东新区', address: '东方路蓝村路口', lng: 121.53, lat: 31.248,
-    flow: 1520, cumulativeFlow: 398.6, pressure: 370, warningThreshold: 1400,
-    status: 'online', deviceModel: 'FL-2500', installDate: '2024-09-15', updateTime: '2025-04-02 15:19:30',
-    trendData: [1400, 1430, 1460, 1490, 1520, 1510, 1500, 1480, 1460, 1440, 1420, 1400, 1380, 1360, 1380, 1400, 1420, 1440, 1460, 1480, 1500, 1480, 1460, 1440] },
-  { id: 6, code: 'FM-006', name: '人民路漏失计', area: '黄浦区', address: '人民路与世纪大道路口', lng: 121.487, lat: 31.239,
-    flow: 1850, cumulativeFlow: 468.2, pressure: 395, warningThreshold: 1600,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-11-20', updateTime: '2025-04-02 15:18:45',
-    trendData: [1600, 1650, 1700, 1750, 1800, 1850, 1830, 1800, 1780, 1750, 1720, 1700, 1680, 1660, 1640, 1620, 1600, 1580, 1560, 1540, 1520, 1500, 1480, 1460] },
-  { id: 7, code: 'FM-007', name: '淮海路漏失计', area: '徐汇区', address: '淮海中路陕西南路', lng: 121.475, lat: 31.225,
-    flow: 850, cumulativeFlow: 225.4, pressure: 340, warningThreshold: 900,
-    status: 'offline', deviceModel: 'FL-2000', installDate: '2024-12-05', updateTime: '2025-04-02 08:00:00',
-    trendData: [800, 820, 840, 850, 840, 830, 820, 810, 800, 790, 780, 770, 760, 750, 740, 730, 720, 710, 700, 690, 680, 670, 660, 650] }
-])
-
-// 统计数据
-const totalPoints = computed(() => flowPoints.value.length)
-const onlineCount = computed(() => flowPoints.value.filter(p => p.status === 'online').length)
-const alertCount = computed(() => flowPoints.value.filter(p => p.status === 'alert').length)
-const totalFlow = computed(() => flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / 10000)
-const avgFlow = computed(() => (flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / totalPoints.value).toFixed(0))
-const maxFlow = computed(() => Math.max(...flowPoints.value.map(p => p.flow)))
-const maxFlowPoint = computed(() => flowPoints.value.find(p => p.flow === maxFlow.value)?.name || '—')
-const onlineRate = computed(() => ((onlineCount.value / totalPoints.value) * 100).toFixed(1))
-
-// 查询参数
-const queryParams = ref({
-  pointName: '',
-  pointCode: '',
-  status: '',
-  area: '',
-  dateRange: []
-})
-const currentPage = ref(1)
-const pageSize = ref(10)
-const selectedRows = ref([])
-const tableRef = ref(null)
-
-// 过滤后的点位
-const filteredPoints = computed(() => {
-  let list = flowPoints.value
-  if (queryParams.value.pointName) {
-    list = list.filter(p => p.name.includes(queryParams.value.pointName))
-  }
-  if (queryParams.value.pointCode) {
-    list = list.filter(p => p.code.includes(queryParams.value.pointCode))
-  }
-  if (queryParams.value.status) {
-    list = list.filter(p => p.status === queryParams.value.status)
-  }
-  if (queryParams.value.area) {
-    list = list.filter(p => p.area === queryParams.value.area)
-  }
-  return list
-})
-
-const paginatedPoints = computed(() => {
-  const start = (currentPage.value - 1) * pageSize.value
-  return filteredPoints.value.slice(start, start + pageSize.value)
-})
-
-// GIS画布相关
-const canvasRef = ref(null)
-const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-let dragStart = null
-let isDragging = false
-const bounds = { minLng: 121.45, maxLng: 121.56, minLat: 31.20, maxLat: 31.27 }
-
-// 交互状态
-const hoverPoint = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
-const currentPoint = ref(null)
-const detailDrawerVisible = ref(false)
-
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf5e9'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i <= 10; i++) {
-    const x = (i / 10) * mapWidth
-    ctx.beginPath()
-    ctx.moveTo(x, 0); ctx.lineTo(x, mapHeight); ctx.stroke()
-    const y = (i / 10) * mapHeight
-    ctx.beginPath()
-    ctx.moveTo(0, y); ctx.lineTo(mapWidth, y); ctx.stroke()
-  }
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('世纪大道', 350, 120)
-  ctx.fillText('张杨路', 450, 280)
-  ctx.fillText('东方路', 580, 200)
-}
-
-function drawPoints() {
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    let color = '#67c23a'
-    let size = 10
-    if (point.status === 'alert') { color = '#f56c6c'; size = 14 }
-    else if (point.status === 'offline') { color = '#909399'; size = 8 }
-
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = color
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-
-    if (point.status === 'alert') {
-      ctx.beginPath()
-      ctx.arc(x, y, size + 4, 0, 2 * Math.PI)
-      ctx.fillStyle = 'rgba(245,108,108,0.3)'
-      ctx.fill()
-    }
-
-    ctx.fillStyle = '#333'
-    ctx.font = '10px sans-serif'
-    ctx.fillText(point.name.slice(0, 4), x - 12, y - 8)
-  })
-}
-
-function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-  drawBaseMap()
-  drawPoints()
-}
-
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function fitBounds() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
-
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) { currentPoint.value = hit; detailDrawerVisible.value = true }
-}
-
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) {
-    hoverPoint.value = hit
-    hoverX.value = e.clientX + 12
-    hoverY.value = e.clientY - 40
-  } else {
-    hoverPoint.value = null
-  }
-}
-
-function initDragEvents() {
-  const canvas = canvasRef.value
-  if (!canvas) return
-  canvas.addEventListener('mousedown', (e) => {
-    dragStart = { x: e.clientX, y: e.clientY, offsetX: offsetX, offsetY: offsetY }
-    isDragging = true
-    canvas.style.cursor = 'grabbing'
-  })
-  window.addEventListener('mousemove', (e) => {
-    if (!isDragging) return
-    const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y
-    offsetX = dragStart.offsetX + dx; offsetY = dragStart.offsetY + dy
-    renderMap()
-  })
-  window.addEventListener('mouseup', () => {
-    isDragging = false
-    if (canvasRef.value) canvasRef.value.style.cursor = 'crosshair'
-  })
-}
-
-// 趋势图配置
-const trendChartOption = computed(() => {
-  if (!currentPoint.value) return {}
-  const times = Array.from({ length: 24 }, (_, i) => `${i}:00`)
-  const threshold = currentPoint.value.warningThreshold
-  return {
-    tooltip: { trigger: 'axis' },
-    xAxis: { type: 'category', data: times, axisLabel: { rotate: 45, interval: 4 } },
-    yAxis: { type: 'value', name: '漏失 (m³/h)' },
-    series: [
-      { name: '瞬时漏失', type: 'line', data: currentPoint.value.trendData, smooth: true, lineStyle: { color: '#409eff', width: 2 }, areaStyle: { opacity: 0.1 } },
-      { name: '报警阈值', type: 'line', data: Array(24).fill(threshold), lineStyle: { color: '#f56c6c', width: 2, type: 'dashed' }, symbol: 'none' }
-    ]
-  }
-})
-
-// 方法
-const searchPoints = () => { currentPage.value = 1; renderMap() }
-const resetQuery = () => {
-  queryParams.value = { pointName: '', pointCode: '', status: '', area: '', dateRange: [] }
-  currentPage.value = 1
-  renderMap()
-}
-const refreshData = () => { renderMap(); ElMessage.success('数据已刷新') }
-const addPoint = () => { ElMessage.info('新增点位(演示)') }
-const exportData = () => { ElMessage.success('批量导出全部数据(演示)') }
-const exportSelected = () => { ElMessage.success(`导出选中 ${selectedRows.value.length} 个点位(演示)`) }
-const batchExport = () => { ElMessage.success('批量导出全部点位(演示)') }
-const viewDetail = (row) => { currentPoint.value = row; detailDrawerVisible.value = true }
-const selectPointOnMap = (row) => { locateOnMap(row); viewDetail(row) }
-const locateOnMap = (row) => {
-  const center = worldToCanvas(row.lng, row.lat)
-  mapZoom = 2
-  offsetX = mapWidth/2 - center.x
-  offsetY = mapHeight/2 - center.y
-  renderMap()
-}
-const handleSelectionChange = (val) => { selectedRows.value = val }
-
-watch(() => filteredPoints.value, () => { currentPage.value = 1; renderMap() })
-
-onMounted(() => {
-  nextTick(() => { renderMap(); initDragEvents(); window.addEventListener('resize', renderMap) })
-})
-onUnmounted(() => window.removeEventListener('resize', renderMap))
+import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
+import { listLeakageMonitoring, getLeakageTrend, exportLeakageMonitoring } from '@/api/pipeNetwork/waterSupply'
 </script>
-
-<style scoped>
-.flow-monitor-dashboard {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.online { color: #67c23a; }
-.stat-item.alert { color: #f56c6c; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #909399; }
-.stat-item.online .dot { background: #67c23a; }
-.stat-item.alert .dot { background: #f56c6c; }
-.header-right { display: flex; gap: 12px; }
-
-.query-bar { background: white; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
-.query-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
-
-.stats-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
-.stat-card { background: white; border-radius: 12px; padding: 16px; text-align: center; }
-.stat-value { font-size: 28px; font-weight: bold; }
-.stat-value .unit { font-size: 14px; font-weight: normal; }
-.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
-.stat-trend { font-size: 11px; color: #606266; margin-top: 4px; }
-
-.main-layout { display: flex; gap: 20px; }
-.point-table-panel { flex: 1.5; background: white; border-radius: 16px; padding: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; font-weight: 600; }
-.table-actions { display: flex; gap: 8px; }
-.pagination-wrapper { margin-top: 16px; display: flex; justify-content: flex-end; }
-
-.gis-panel { flex: 1; background: white; border-radius: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.map-header { padding: 12px 16px; border-bottom: 1px solid #e9ecef; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
-.map-container { flex: 1; position: relative; cursor: crosshair; min-height: 500px; }
-canvas { width: 100%; height: 100%; display: block; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-}
-.hover-title { font-weight: 600; margin-bottom: 4px; }
-.point-detail { padding: 8px; }
-.alert-value { color: #f56c6c; font-weight: bold; }
-.sub-title { font-weight: 600; margin: 16px 0 12px 0; }
-.trend-chart { height: 200px; }
-</style>
+<template><WaterMonitoringPage title="漏失监测" metric-label="漏失" unit="kW" metric="power" monitor-type="漏失监测" range-label="功率范围" min-key="powerMin" max-key="powerMax" :load="listLeakageMonitoring" :trend="getLeakageTrend" :export-api="exportLeakageMonitoring" /></template>

+ 3 - 540
src/views/subSystem/waterSupply/wyxjc/lsjc.vue

@@ -1,542 +1,5 @@
-
-<!--压力监测-->
-<template>
-  <div class="flow-monitor-dashboard">
-    <!-- 页面头部 -->
-    <div class="page-header">
-      <div class="header-left">
-        <el-icon><Connection /></el-icon>
-        <span class="title">压力计监测数据管理</span>
-        <div class="stats-badge">
-          <span class="stat-item"><i class="dot"></i>监测点位 {{ totalPoints }}</span>
-          <span class="stat-item online"><i class="dot"></i>在线 {{ onlineCount }}</span>
-          <span class="stat-item alert"><i class="dot"></i>异常 {{ alertCount }}</span>
-        </div>
-      </div>
-      <div class="header-right">
-        <el-button type="primary" :icon="Plus" @click="addPoint">新增点位</el-button>
-        <el-button :icon="Download" @click="exportData">批量导出</el-button>
-        <el-button :icon="Refresh" @click="refreshData">刷新</el-button>
-      </div>
-    </div>
-
-    <!-- 多重查询区域 -->
-    <div class="query-bar">
-      <div class="query-row">
-        <el-input
-            v-model="queryParams.pointName"
-            placeholder="点位名称"
-            clearable
-            style="width: 180px"
-            size="default"
-        />
-        <el-input
-            v-model="queryParams.pointCode"
-            placeholder="点位编号"
-            clearable
-            style="width: 150px"
-            size="default"
-        />
-        <el-select v-model="queryParams.status" placeholder="运行状态" clearable style="width: 120px" size="default">
-          <el-option label="在线" value="online" />
-          <el-option label="离线" value="offline" />
-          <el-option label="异常" value="alert" />
-        </el-select>
-        <el-select v-model="queryParams.area" placeholder="所属区域" clearable style="width: 140px" size="default">
-          <el-option label="浦东新区" value="浦东新区" />
-          <el-option label="徐汇区" value="徐汇区" />
-          <el-option label="黄浦区" value="黄浦区" />
-        </el-select>
-        <el-date-picker
-            v-model="queryParams.dateRange"
-            type="daterange"
-            range-separator="至"
-            start-placeholder="开始日期"
-            end-placeholder="结束日期"
-            style="width: 260px"
-            size="default"
-        />
-        <el-button type="primary" :icon="Search" @click="searchPoints">查询</el-button>
-        <el-button @click="resetQuery">重置</el-button>
-      </div>
-    </div>
-
-    <!-- 统计卡片 -->
-    <div class="stats-cards">
-      <div class="stat-card">
-        <div class="stat-value">22<span class="unit">万m³/h</span></div>
-        <div class="stat-label">总瞬时压力</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">20<span class="unit">m³/h</span></div>
-        <div class="stat-label">平均压力</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ maxFlow }}<span class="unit">m³/h</span></div>
-        <div class="stat-label">最大压力</div>
-        <div class="stat-trend">点位: {{ maxFlowPoint }}</div>
-      </div>
-      <div class="stat-card">
-        <div class="stat-value">{{ onlineRate }}<span class="unit">%</span></div>
-        <div class="stat-label">设备在线率</div>
-      </div>
-    </div>
-
-    <!-- 主内容区:左侧点位列表 + 右侧GIS地图 -->
-    <div class="main-layout">
-      <!-- 左侧点位清单表格 -->
-      <div class="point-table-panel">
-        <div class="table-header">
-          <span>压力监测点位清单</span>
-          <div class="table-actions">
-            <el-button size="small" @click="exportSelected">导出选中</el-button>
-            <el-button size="small" type="primary" @click="batchExport">批量导出全部</el-button>
-          </div>
-        </div>
-        <el-table
-            :data="paginatedPoints"
-            stripe
-            border
-            style="width: 100%"
-            @row-click="selectPointOnMap"
-            @selection-change="handleSelectionChange"
-            ref="tableRef"
-        >
-          <el-table-column type="selection" width="45" align="center" />
-          <el-table-column type="index" label="序号" width="55" align="center" />
-          <el-table-column prop="code" label="点位编号" width="120" sortable />
-          <el-table-column prop="name" label="点位名称" min-width="150" show-overflow-tooltip />
-          <el-table-column prop="area" label="所属区域" width="100" />
-          <el-table-column prop="flow" label="瞬时压力(m³/h)" width="120" sortable>
-            <template #default="{ row }">
-              <span :class="{ 'alert-value': row.flow > row.warningThreshold }">{{ row.flow }}</span>
-            </template>
-          </el-table-column>
-          <el-table-column prop="cumulativeFlow" label="累计压力(万m³)" width="120" sortable />
-          <el-table-column prop="pressure" label="管压(kPa)" width="100" />
-          <el-table-column prop="status" label="运行状态" width="90" align="center">
-            <template #default="{ row }">
-              <el-tag :type="row.status === 'online' ? 'success' : row.status === 'alert' ? 'danger' : 'info'" size="small">
-                {{ row.status === 'online' ? '在线' : row.status === 'alert' ? '异常' : '离线' }}
-              </el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="updateTime" label="最后通讯" width="150" sortable />
-          <el-table-column label="操作" width="100" fixed="right" align="center">
-            <template #default="{ row }">
-              <el-button link type="primary" size="small" @click.stop="viewDetail(row)">详情</el-button>
-              <el-button link type="success" size="small" @click.stop="locateOnMap(row)">定位</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-        <div class="pagination-wrapper">
-          <el-pagination
-              background
-              layout="total, sizes, prev, pager, next"
-              :total="filteredPoints.length"
-              v-model:current-page="currentPage"
-              v-model:page-size="pageSize"
-              :page-sizes="[10, 20, 50]"
-          />
-        </div>
-      </div>
-
-      <!-- 右侧GIS地图 -->
-      <div class="gis-panel">
-        <div class="map-header">
-          <span>点位分布地图</span>
-          <div class="map-tools">
-            <el-button-group>
-              <el-button size="small" :icon="ZoomIn" @click="zoomIn">放大</el-button>
-              <el-button size="small" :icon="ZoomOut" @click="zoomOut">缩小</el-button>
-              <el-button size="small" :icon="Location" @click="fitBounds">全图</el-button>
-            </el-button-group>
-          </div>
-        </div>
-        <div class="map-container" ref="mapContainer">
-          <canvas id="flowMapCanvas" ref="canvasRef" @click="onMapClick" @mousemove="onCanvasMouseMove"></canvas>
-          <div v-if="hoverPoint" class="hover-card" :style="{ top: hoverY + 'px', left: hoverX + 'px' }">
-            <div class="hover-title">{{ hoverPoint.name }}</div>
-            <div>压力: {{ hoverPoint.flow }} m³/h</div>
-            <div>管压: {{ hoverPoint.pressure }} kPa</div>
-            <div>状态: {{ hoverPoint.status === 'online' ? '在线' : hoverPoint.status === 'alert' ? '异常' : '离线' }}</div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 点位详情抽屉 -->
-    <el-drawer v-model="detailDrawerVisible" :title="`点位详情 - ${currentPoint?.name}`" size="450px" direction="rtl">
-      <div v-if="currentPoint" class="point-detail">
-        <el-descriptions :column="1" border>
-          <el-descriptions-item label="点位编号">{{ currentPoint.code }}</el-descriptions-item>
-          <el-descriptions-item label="点位名称">{{ currentPoint.name }}</el-descriptions-item>
-          <el-descriptions-item label="所属区域">{{ currentPoint.area }}</el-descriptions-item>
-          <el-descriptions-item label="详细地址">{{ currentPoint.address }}</el-descriptions-item>
-          <el-descriptions-item label="经纬度">{{ currentPoint.lng }}, {{ currentPoint.lat }}</el-descriptions-item>
-          <el-descriptions-item label="瞬时压力">{{ currentPoint.flow }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="累计压力">{{ currentPoint.cumulativeFlow }} 万m³</el-descriptions-item>
-          <el-descriptions-item label="管压">{{ currentPoint.pressure }} kPa</el-descriptions-item>
-          <el-descriptions-item label="报警阈值">{{ currentPoint.warningThreshold }} m³/h</el-descriptions-item>
-          <el-descriptions-item label="运行状态">
-            <el-tag :type="currentPoint.status === 'online' ? 'success' : currentPoint.status === 'alert' ? 'danger' : 'info'">
-              {{ currentPoint.status === 'online' ? '在线' : currentPoint.status === 'alert' ? '异常' : '离线' }}
-            </el-tag>
-          </el-descriptions-item>
-          <el-descriptions-item label="设备型号">{{ currentPoint.deviceModel }}</el-descriptions-item>
-          <el-descriptions-item label="安装日期">{{ currentPoint.installDate }}</el-descriptions-item>
-          <el-descriptions-item label="最后通讯">{{ currentPoint.updateTime }}</el-descriptions-item>
-        </el-descriptions>
-        <div class="trend-section">
-          <div class="sub-title">近24小时压力趋势</div>
-          <div class="trend-chart">
-            <v-chart :option="trendChartOption" autoresize />
-          </div>
-        </div>
-      </div>
-    </el-drawer>
-  </div>
-</template>
-
 <script setup>
-import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
-import { Connection, Plus, Download, Refresh, Search, ZoomIn, ZoomOut, Location } from '@element-plus/icons-vue'
-import { use } from 'echarts/core'
-import { CanvasRenderer } from 'echarts/renderers'
-import { LineChart } from 'echarts/charts'
-import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
-import { ElMessage } from 'element-plus'
-
-use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
-
-// 模拟压力监测点数据
-const flowPoints = ref([
-  { id: 1, code: 'FM-001', name: '世纪大道压力计', area: '浦东新区', address: '世纪大道福山路口', lng: 121.505, lat: 31.235,
-    flow: 1250, cumulativeFlow: 328.5, pressure: 385, warningThreshold: 1200,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-03-15', updateTime: '2025-04-02 15:23:45',
-    trendData: [1150, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180, 1170, 1160, 1150, 1140, 1160, 1180, 1200, 1220, 1250, 1240, 1230, 1210, 1190, 1180] },
-  { id: 2, code: 'FM-002', name: '张杨路压力计', area: '浦东新区', address: '张杨路松林路口', lng: 121.52, lat: 31.242,
-    flow: 980, cumulativeFlow: 256.3, pressure: 350, warningThreshold: 1000,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-05-20', updateTime: '2025-04-02 15:22:30',
-    trendData: [900, 920, 940, 960, 980, 970, 960, 950, 940, 930, 920, 910, 900, 890, 910, 930, 950, 970, 980, 970, 960, 950, 940, 930] },
-  { id: 3, code: 'FM-003', name: '浦东南路压力计', area: '浦东新区', address: '浦东南路400号', lng: 121.49, lat: 31.23,
-    flow: 2100, cumulativeFlow: 512.8, pressure: 420, warningThreshold: 1800,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-01-10', updateTime: '2025-04-02 15:21:15',
-    trendData: [1800, 1850, 1900, 1950, 2000, 2050, 2100, 2080, 2050, 2020, 1980, 1950, 1920, 1900, 1880, 1860, 1840, 1820, 1800, 1780, 1760, 1740, 1720, 1700] },
-  { id: 4, code: 'FM-004', name: '龙阳路压力计', area: '浦东新区', address: '龙阳路白杨路口', lng: 121.51, lat: 31.215,
-    flow: 680, cumulativeFlow: 185.2, pressure: 310, warningThreshold: 800,
-    status: 'online', deviceModel: 'FL-2000', installDate: '2024-07-01', updateTime: '2025-04-02 15:20:00',
-    trendData: [620, 640, 660, 680, 670, 660, 650, 640, 630, 620, 610, 600, 590, 580, 600, 620, 640, 660, 680, 670, 660, 650, 640, 630] },
-  { id: 5, code: 'FM-005', name: '东方路压力计', area: '浦东新区', address: '东方路蓝村路口', lng: 121.53, lat: 31.248,
-    flow: 1520, cumulativeFlow: 398.6, pressure: 370, warningThreshold: 1400,
-    status: 'online', deviceModel: 'FL-2500', installDate: '2024-09-15', updateTime: '2025-04-02 15:19:30',
-    trendData: [1400, 1430, 1460, 1490, 1520, 1510, 1500, 1480, 1460, 1440, 1420, 1400, 1380, 1360, 1380, 1400, 1420, 1440, 1460, 1480, 1500, 1480, 1460, 1440] },
-  { id: 6, code: 'FM-006', name: '人民路压力计', area: '黄浦区', address: '人民路与世纪大道路口', lng: 121.487, lat: 31.239,
-    flow: 1850, cumulativeFlow: 468.2, pressure: 395, warningThreshold: 1600,
-    status: 'alert', deviceModel: 'FL-3000', installDate: '2024-11-20', updateTime: '2025-04-02 15:18:45',
-    trendData: [1600, 1650, 1700, 1750, 1800, 1850, 1830, 1800, 1780, 1750, 1720, 1700, 1680, 1660, 1640, 1620, 1600, 1580, 1560, 1540, 1520, 1500, 1480, 1460] },
-  { id: 7, code: 'FM-007', name: '淮海路压力计', area: '徐汇区', address: '淮海中路陕西南路', lng: 121.475, lat: 31.225,
-    flow: 850, cumulativeFlow: 225.4, pressure: 340, warningThreshold: 900,
-    status: 'offline', deviceModel: 'FL-2000', installDate: '2024-12-05', updateTime: '2025-04-02 08:00:00',
-    trendData: [800, 820, 840, 850, 840, 830, 820, 810, 800, 790, 780, 770, 760, 750, 740, 730, 720, 710, 700, 690, 680, 670, 660, 650] }
-])
-
-// 统计数据
-const totalPoints = computed(() => flowPoints.value.length)
-const onlineCount = computed(() => flowPoints.value.filter(p => p.status === 'online').length)
-const alertCount = computed(() => flowPoints.value.filter(p => p.status === 'alert').length)
-const totalFlow = computed(() => flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / 10000)
-const avgFlow = computed(() => (flowPoints.value.reduce((sum, p) => sum + p.flow, 0) / totalPoints.value).toFixed(0))
-const maxFlow = computed(() => Math.max(...flowPoints.value.map(p => p.flow)))
-const maxFlowPoint = computed(() => flowPoints.value.find(p => p.flow === maxFlow.value)?.name || '—')
-const onlineRate = computed(() => ((onlineCount.value / totalPoints.value) * 100).toFixed(1))
-
-// 查询参数
-const queryParams = ref({
-  pointName: '',
-  pointCode: '',
-  status: '',
-  area: '',
-  dateRange: []
-})
-const currentPage = ref(1)
-const pageSize = ref(10)
-const selectedRows = ref([])
-const tableRef = ref(null)
-
-// 过滤后的点位
-const filteredPoints = computed(() => {
-  let list = flowPoints.value
-  if (queryParams.value.pointName) {
-    list = list.filter(p => p.name.includes(queryParams.value.pointName))
-  }
-  if (queryParams.value.pointCode) {
-    list = list.filter(p => p.code.includes(queryParams.value.pointCode))
-  }
-  if (queryParams.value.status) {
-    list = list.filter(p => p.status === queryParams.value.status)
-  }
-  if (queryParams.value.area) {
-    list = list.filter(p => p.area === queryParams.value.area)
-  }
-  return list
-})
-
-const paginatedPoints = computed(() => {
-  const start = (currentPage.value - 1) * pageSize.value
-  return filteredPoints.value.slice(start, start + pageSize.value)
-})
-
-// GIS画布相关
-const canvasRef = ref(null)
-const mapContainer = ref(null)
-let ctx = null
-let mapWidth = 0, mapHeight = 0
-let mapZoom = 1
-let offsetX = 0, offsetY = 0
-let dragStart = null
-let isDragging = false
-const bounds = { minLng: 121.45, maxLng: 121.56, minLat: 31.20, maxLat: 31.27 }
-
-// 交互状态
-const hoverPoint = ref(null)
-const hoverX = ref(0), hoverY = ref(0)
-const currentPoint = ref(null)
-const detailDrawerVisible = ref(false)
-
-function worldToCanvas(lng, lat) {
-  const px = (lng - bounds.minLng) / (bounds.maxLng - bounds.minLng) * mapWidth
-  const py = (1 - (lat - bounds.minLat) / (bounds.maxLat - bounds.minLat)) * mapHeight
-  const cx = mapWidth / 2, cy = mapHeight / 2
-  return { x: (px - cx) * mapZoom + cx + offsetX, y: (py - cy) * mapZoom + cy + offsetY }
-}
-
-function drawBaseMap() {
-  if (!ctx) return
-  ctx.fillStyle = '#eaf5e9'
-  ctx.fillRect(0, 0, mapWidth, mapHeight)
-  ctx.strokeStyle = '#c8ddc4'
-  ctx.lineWidth = 1
-  for (let i = 0; i <= 10; i++) {
-    const x = (i / 10) * mapWidth
-    ctx.beginPath()
-    ctx.moveTo(x, 0); ctx.lineTo(x, mapHeight); ctx.stroke()
-    const y = (i / 10) * mapHeight
-    ctx.beginPath()
-    ctx.moveTo(0, y); ctx.lineTo(mapWidth, y); ctx.stroke()
-  }
-  ctx.fillStyle = '#a8d0a6'
-  ctx.font = '12px "Microsoft YaHei"'
-  ctx.fillText('世纪大道', 350, 120)
-  ctx.fillText('张杨路', 450, 280)
-  ctx.fillText('东方路', 580, 200)
-}
-
-function drawPoints() {
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    if (x < -20 || x > mapWidth + 20 || y < -20 || y > mapHeight + 20) return
-
-    let color = '#67c23a'
-    let size = 10
-    if (point.status === 'alert') { color = '#f56c6c'; size = 14 }
-    else if (point.status === 'offline') { color = '#909399'; size = 8 }
-
-    ctx.beginPath()
-    ctx.arc(x, y, size, 0, 2 * Math.PI)
-    ctx.fillStyle = color
-    ctx.fill()
-    ctx.strokeStyle = '#fff'
-    ctx.lineWidth = 2
-    ctx.stroke()
-
-    if (point.status === 'alert') {
-      ctx.beginPath()
-      ctx.arc(x, y, size + 4, 0, 2 * Math.PI)
-      ctx.fillStyle = 'rgba(245,108,108,0.3)'
-      ctx.fill()
-    }
-
-    ctx.fillStyle = '#333'
-    ctx.font = '10px sans-serif'
-    ctx.fillText(point.name.slice(0, 4), x - 12, y - 8)
-  })
-}
-
-function renderMap() {
-  if (!canvasRef.value || !mapContainer.value) return
-  const canvas = canvasRef.value
-  mapWidth = mapContainer.value.clientWidth
-  mapHeight = mapContainer.value.clientHeight
-  canvas.width = mapWidth
-  canvas.height = mapHeight
-  ctx = canvas.getContext('2d')
-  drawBaseMap()
-  drawPoints()
-}
-
-function zoomIn() { mapZoom = Math.min(mapZoom + 0.15, 2.5); renderMap() }
-function zoomOut() { mapZoom = Math.max(mapZoom - 0.15, 0.6); renderMap() }
-function fitBounds() { mapZoom = 1; offsetX = 0; offsetY = 0; renderMap() }
-
-function onMapClick(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) { currentPoint.value = hit; detailDrawerVisible.value = true }
-}
-
-function onCanvasMouseMove(e) {
-  const rect = canvasRef.value.getBoundingClientRect()
-  const mouseX = (e.clientX - rect.left) * (mapWidth / rect.width)
-  const mouseY = (e.clientY - rect.top) * (mapHeight / rect.height)
-  let minDist = 20, hit = null
-  filteredPoints.value.forEach(point => {
-    const { x, y } = worldToCanvas(point.lng, point.lat)
-    const dist = Math.hypot(mouseX - x, mouseY - y)
-    if (dist < minDist) { minDist = dist; hit = point }
-  })
-  if (hit) {
-    hoverPoint.value = hit
-    hoverX.value = e.clientX + 12
-    hoverY.value = e.clientY - 40
-  } else {
-    hoverPoint.value = null
-  }
-}
-
-function initDragEvents() {
-  const canvas = canvasRef.value
-  if (!canvas) return
-  canvas.addEventListener('mousedown', (e) => {
-    dragStart = { x: e.clientX, y: e.clientY, offsetX: offsetX, offsetY: offsetY }
-    isDragging = true
-    canvas.style.cursor = 'grabbing'
-  })
-  window.addEventListener('mousemove', (e) => {
-    if (!isDragging) return
-    const dx = e.clientX - dragStart.x, dy = e.clientY - dragStart.y
-    offsetX = dragStart.offsetX + dx; offsetY = dragStart.offsetY + dy
-    renderMap()
-  })
-  window.addEventListener('mouseup', () => {
-    isDragging = false
-    if (canvasRef.value) canvasRef.value.style.cursor = 'crosshair'
-  })
-}
-
-// 趋势图配置
-const trendChartOption = computed(() => {
-  if (!currentPoint.value) return {}
-  const times = Array.from({ length: 24 }, (_, i) => `${i}:00`)
-  const threshold = currentPoint.value.warningThreshold
-  return {
-    tooltip: { trigger: 'axis' },
-    xAxis: { type: 'category', data: times, axisLabel: { rotate: 45, interval: 4 } },
-    yAxis: { type: 'value', name: '压力 (m³/h)' },
-    series: [
-      { name: '瞬时压力', type: 'line', data: currentPoint.value.trendData, smooth: true, lineStyle: { color: '#409eff', width: 2 }, areaStyle: { opacity: 0.1 } },
-      { name: '报警阈值', type: 'line', data: Array(24).fill(threshold), lineStyle: { color: '#f56c6c', width: 2, type: 'dashed' }, symbol: 'none' }
-    ]
-  }
-})
-
-// 方法
-const searchPoints = () => { currentPage.value = 1; renderMap() }
-const resetQuery = () => {
-  queryParams.value = { pointName: '', pointCode: '', status: '', area: '', dateRange: [] }
-  currentPage.value = 1
-  renderMap()
-}
-const refreshData = () => { renderMap(); ElMessage.success('数据已刷新') }
-const addPoint = () => { ElMessage.info('新增点位(演示)') }
-const exportData = () => { ElMessage.success('批量导出全部数据(演示)') }
-const exportSelected = () => { ElMessage.success(`导出选中 ${selectedRows.value.length} 个点位(演示)`) }
-const batchExport = () => { ElMessage.success('批量导出全部点位(演示)') }
-const viewDetail = (row) => { currentPoint.value = row; detailDrawerVisible.value = true }
-const selectPointOnMap = (row) => { locateOnMap(row); viewDetail(row) }
-const locateOnMap = (row) => {
-  const center = worldToCanvas(row.lng, row.lat)
-  mapZoom = 2
-  offsetX = mapWidth/2 - center.x
-  offsetY = mapHeight/2 - center.y
-  renderMap()
-}
-const handleSelectionChange = (val) => { selectedRows.value = val }
-
-watch(() => filteredPoints.value, () => { currentPage.value = 1; renderMap() })
-
-onMounted(() => {
-  nextTick(() => { renderMap(); initDragEvents(); window.addEventListener('resize', renderMap) })
-})
-onUnmounted(() => window.removeEventListener('resize', renderMap))
+import WaterMonitoringPage from '../components/WaterMonitoringPage.vue'
+import { listPressureMonitoring, getPressureTrend, exportPressureMonitoring } from '@/api/pipeNetwork/waterSupply'
 </script>
-
-<style scoped>
-.flow-monitor-dashboard {
-  background: #f0f2f6;
-  min-height: 100vh;
-  padding: 20px;
-}
-.page-header {
-  background: white;
-  border-radius: 16px;
-  padding: 16px 24px;
-  margin-bottom: 20px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.header-left { display: flex; align-items: center; gap: 20px; }
-.header-left .title { font-size: 1.3rem; font-weight: 600; }
-.stats-badge { display: flex; gap: 20px; }
-.stat-item { font-size: 13px; display: flex; align-items: center; gap: 6px; }
-.stat-item.online { color: #67c23a; }
-.stat-item.alert { color: #f56c6c; }
-.stat-item .dot { width: 8px; height: 8px; border-radius: 50%; background: #909399; }
-.stat-item.online .dot { background: #67c23a; }
-.stat-item.alert .dot { background: #f56c6c; }
-.header-right { display: flex; gap: 12px; }
-
-.query-bar { background: white; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
-.query-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
-
-.stats-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
-.stat-card { background: white; border-radius: 12px; padding: 16px; text-align: center; }
-.stat-value { font-size: 28px; font-weight: bold; }
-.stat-value .unit { font-size: 14px; font-weight: normal; }
-.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
-.stat-trend { font-size: 11px; color: #606266; margin-top: 4px; }
-
-.main-layout { display: flex; gap: 20px; }
-.point-table-panel { flex: 1.5; background: white; border-radius: 16px; padding: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.table-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; font-weight: 600; }
-.table-actions { display: flex; gap: 8px; }
-.pagination-wrapper { margin-top: 16px; display: flex; justify-content: flex-end; }
-
-.gis-panel { flex: 1; background: white; border-radius: 16px; display: flex; flex-direction: column; overflow: hidden; }
-.map-header { padding: 12px 16px; border-bottom: 1px solid #e9ecef; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
-.map-container { flex: 1; position: relative; cursor: crosshair; min-height: 500px; }
-canvas { width: 100%; height: 100%; display: block; }
-.hover-card {
-  position: fixed;
-  background: rgba(0,0,0,0.8);
-  color: white;
-  padding: 8px 12px;
-  border-radius: 10px;
-  font-size: 12px;
-  pointer-events: none;
-  z-index: 200;
-}
-.hover-title { font-weight: 600; margin-bottom: 4px; }
-.point-detail { padding: 8px; }
-.alert-value { color: #f56c6c; font-weight: bold; }
-.sub-title { font-weight: 600; margin: 16px 0 12px 0; }
-.trend-chart { height: 200px; }
-</style>
+<template><WaterMonitoringPage title="压力监测" metric-label="压力" unit="MPa" metric="pressure" monitor-type="压力监测" range-label="压力范围" min-key="pressureMin" max-key="pressureMax" :load="listPressureMonitoring" :trend="getPressureTrend" :export-api="exportPressureMonitoring" /></template>

+ 86 - 0
task_plan.md

@@ -0,0 +1,86 @@
+# 项目探索与记忆库计划
+
+## Goal
+
+理解城市生命线前端项目,并建立仓库内可持续维护的对话记忆库。
+
+## Phases
+
+- [x] 核对实际目录、依赖和文档
+- [x] 梳理启动、路由、权限、状态和请求链路
+- [x] 统计业务模块与地图/图表/实时刷新实现
+- [x] 汇总与项目描述文档的差异及后续建议
+- [x] 形成记忆库设计并通过用户审阅
+- [x] 创建记忆库实施计划
+- [x] 创建 `AGENTS.md` 与 `memory/` 目录
+- [x] 写入本次会话记录并更新长期记忆索引
+
+## Next Step
+
+启动 8300 后端后,联调 `/getRouters`、角色菜单权限和 19 个页面的真实数据响应。
+
+## 新任务:供水管网安全运行监测系统前端
+
+### Phase 10: 文档、接口与现状勘察
+**Status:** completed
+- [x] 解析 PDF 5.3.2 章节并提取页面能力
+- [x] 盘点后端供水 Controller、参数和响应结构
+- [x] 盘点现有供水页面、API、动态菜单与可复用组件
+
+### Phase 11: 行为规格与测试先行
+**Status:** completed
+- [x] 定义页面模块配置、接口适配和展示模型
+- [x] 为新增可测试逻辑编写失败测试并确认 RED
+
+### Phase 12: 页面与接口实现
+**Status:** completed
+- [x] 对齐四级菜单命名与页面入口
+- [x] 完成供水设施管理、监测设备管理、运行监测和监测报警页面
+- [x] 接入已开发后端接口并提供降级空状态
+
+### Phase 13: 构建与浏览器验证
+**Status:** completed
+- [x] 运行自动化测试和生产构建
+- [x] 启动前端并检查登录页资源和桌面布局;后端未启动,动态菜单内页联调待服务可用后继续
+
+### Phase 14: 记忆库与交付
+**Status:** completed
+- [x] 更新本次会话记录、项目索引和稳定事实
+- [x] 汇总接口覆盖、已知限制和后续联调项
+
+## Errors Encountered
+
+| Error | Attempt | Resolution |
+|---|---:|---|
+| PowerShell 字符串插值中的冒号解析错误 | 1 | 改用 `${name}` 插值并重新执行 |
+| 并行只读命令中单个 rg/脚本返回非零导致整体失败 | 1 | 拆分或显式 `exit 0`,不重复原命令 |
+| Firecrawl CLI 未安装,且 PDF 约 51.2 MB 接近服务上传上限 | 1 | 改用工作区内置 Python 的 `pypdf/pdfplumber` 在本地解析,输出仍放入 `.firecrawl/` |
+| 系统 PATH 中无 Python,规划会话恢复脚本首次无法运行 | 1 | 使用 Codex 工作区依赖提供的 Python 成功运行 session-catchup |
+| PDF 完整提取的一行 Python 命令受 PowerShell 引号和执行时长影响,未生成输出 | 2 | 改用 `.firecrawl/extract_pdf.py` 逐页流式写入 Markdown |
+| Firecrawl CLI 未安装,`npx firecrawl` 也没有可执行入口 | 2 | 改用工作区内置 DOCX 解析库,产物仍保存到忽略目录 `.firecrawl/` |
+
+## 新任务:供水前端联调完善
+
+### Phase 15: 文档与现状复核
+**Status:** in_progress
+- [x] 解析《供水-监测报警》并提取页面字段、流程和视觉要求
+- [x] 对比燃气首页、供水首页、监测页面和动态菜单现状
+- [ ] 核对后台供水菜单 SQL、组件路径和角色可见性
+
+### Phase 16: 首页与页面能力实现
+**Status:** in_progress
+- [x] 以燃气首页布局为基线完成供水 GIS 总览首页
+- [x] 补齐管网数据统计分析和供水设施 GIS 一张图入口
+- [x] 完善监测设备管理与监测报警页面交互
+- [x] 将流量、压力、漏失页面限定为对应子设备数据
+
+### Phase 17: 菜单启用与联调验证
+**Status:** pending
+- [x] 提供前端静态菜单入口并对齐供水 GIS/统计/工单组件路径(后端菜单 SQL 仍待同步)
+- [x] 运行模型测试、SFC 解析和生产构建
+- [ ] 使用已登录浏览器验证首页、菜单和关键页面
+- [x] 更新前端记忆库与本轮交付记录
+
+## Next Step
+
+解析《供水-监测报警》并完成燃气/供水页面与后台菜单映射。

+ 22 - 0
tests/routeNameUtils.test.mjs

@@ -0,0 +1,22 @@
+import assert from 'node:assert/strict'
+import { ensureUniqueSubsystemRouteNames } from '../src/router/routeNameUtils.js'
+
+const routes = ensureUniqueSubsystemRouteNames([
+  {
+    path: '/subSystem/waterSupply',
+    name: 'WaterSupply',
+    children: [
+      { path: 'index', name: 'Index' },
+      { path: 'facility/gis', name: 'Gis' },
+      { path: 'device/gis', name: 'Gis' }
+    ]
+  }
+])
+
+assert.deepEqual(
+  [routes[0].name, ...routes[0].children.map(route => route.name)],
+  ['SubSystemWaterSupply', 'SubSystemWaterSupplyIndex', 'SubSystemWaterSupplyFacilityGis', 'SubSystemWaterSupplyDeviceGis']
+)
+assert.equal(new Set([routes[0].name, ...routes[0].children.map(route => route.name)]).size, 4)
+
+console.log('routeNameUtils tests passed')

+ 24 - 0
tests/routerRouteUtils.test.mjs

@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict'
+
+let mergeRouteRecords
+
+try {
+  ({ mergeRouteRecords } = await import('../src/router/routeUtils.js'))
+} catch {
+  // The first RED run intentionally reaches this branch before the helper exists.
+}
+
+assert.equal(typeof mergeRouteRecords, 'function', 'route modules must be merged by a route-record helper')
+
+const baseRoutes = [{ path: '/login' }]
+const waterSupplyRoutes = [{ path: '/waterSupply' }]
+const mergedRoutes = mergeRouteRecords(baseRoutes, waterSupplyRoutes)
+
+assert.deepEqual(
+  mergedRoutes.map(route => route.path),
+  ['/login', '/waterSupply'],
+  'route module arrays must be expanded into top-level route records'
+)
+assert.ok(mergedRoutes.every(route => !Array.isArray(route)), 'top-level routes must not contain nested arrays')
+
+console.log('routerRouteUtils tests passed')

+ 42 - 0
tests/sidebarRouteUtils.test.mjs

@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict'
+
+let selectSidebarRoutes
+let getSubsystemHomePath
+
+try {
+  ({ selectSidebarRoutes, getSubsystemHomePath } = await import('../src/router/sidebarRouteUtils.js'))
+} catch {
+  // The first RED run intentionally reaches this branch before the helper exists.
+}
+
+assert.equal(typeof selectSidebarRoutes, 'function', 'sidebar routing must be selected by a shared route helper')
+assert.equal(typeof getSubsystemHomePath, 'function', 'subsystem fallback navigation must use canonical route prefixes')
+
+const routes = [
+  { path: '/index' },
+  { path: '/system' },
+  { path: '/subSystem/gas' },
+  { path: '/subSystem/waterSupply' },
+  { path: '/waterSupply' },
+  { path: '/pipeNetwork' }
+]
+
+assert.deepEqual(
+  selectSidebarRoutes('/system/menu', routes).map(route => route.path),
+  ['/index', '/system'],
+  'admin pages must not display subsystem routes, including legacy aliases'
+)
+
+assert.deepEqual(
+  selectSidebarRoutes('/subSystem/waterSupply/whome', routes).map(route => route.path),
+  ['/subSystem/waterSupply'],
+  'the water subsystem must display only its own dynamic route tree'
+)
+
+assert.equal(
+  getSubsystemHomePath('waterSupply'),
+  '/subSystem/waterSupply/index',
+  'water fallback navigation must target the backend route namespace'
+)
+
+console.log('sidebarRouteUtils tests passed')

+ 33 - 0
tests/waterSupplyModel.test.mjs

@@ -0,0 +1,33 @@
+import assert from 'node:assert/strict'
+import { normalizePage, normalizeMonitoringRecord, normalizeGeoJson, normalizeMapPoints } from '../src/utils/waterSupplyModel.js'
+
+const page = normalizePage({ data: { records: [{ id: 1 }], total: 4, current: 2, size: 10 } })
+assert.equal(page.records.length, 1)
+assert.equal(page.total, 4)
+assert.equal(page.current, 2)
+
+const record = normalizeMonitoringRecord({ equipmentId: 'eq-1', deviceName: '一号泵', currentValue: '2.5' }, 'pressure')
+assert.equal(record.id, 'eq-1')
+assert.equal(record.value, 2.5)
+assert.equal(record.unit, 'MPa')
+
+const flowRecord = normalizeMonitoringRecord({ equipmentId: 'flow-1', instantFlow: '18.6' }, 'flow')
+assert.equal(flowRecord.value, 18.6)
+
+const pressureRecord = normalizeMonitoringRecord({ equipmentId: 'pressure-1', pressureValue: 0.42 }, 'pressure')
+assert.equal(pressureRecord.value, 0.42)
+
+const typedRecord = normalizeMonitoringRecord({ equipmentId: 'flow-2', typeName: '流量监测(测试)', instantFlow: 8.2 }, 'flow')
+assert.equal(typedRecord.equipmentTypeName, '流量监测(测试)')
+
+assert.equal(normalizeGeoJson({ data: { type: 'FeatureCollection', features: [] } }).type, 'FeatureCollection')
+assert.equal(normalizeGeoJson({ data: [] }).features.length, 0)
+
+const mapPoints = normalizeMapPoints([
+  { id: 1, longitude: 110.1, latitude: 28.1 },
+  { id: 2, longitude: 110.3, latitude: 28.4 }
+])
+assert.deepEqual(mapPoints.map(item => [item.mapX, item.mapY]), [[6, 90], [94, 8]])
+assert.deepEqual(normalizeMapPoints([{ id: 1, longitude: 110.2, latitude: 28.2 }]).map(item => [item.mapX, item.mapY]), [[50, 50]])
+assert.deepEqual(normalizeMapPoints([{ id: 1, longitude: null, latitude: undefined }]), [])
+console.log('waterSupplyModel tests passed')

+ 45 - 0
tests/waterSupplyUiModel.test.mjs

@@ -0,0 +1,45 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  normalizeDeviceRow,
+  buildMaintainOrderPayload,
+  getDeviceOrderEndpoint
+} from '../src/utils/waterSupplyUiModel.js'
+
+test('normalizes alarm and related work-order fields for device pages', () => {
+  const row = normalizeDeviceRow({
+    equipmentId: 'EQ-1',
+    equipmentCode: 'P-001',
+    equipmentName: '一号压力计',
+    alarmStatus: 1,
+    alertLevel: 2,
+    latestOrderNo: 'WO202606101703482640',
+    latestMaintainStatus: '处理中'
+  })
+
+  assert.equal(row.deviceStatusText, '报警')
+  assert.equal(row.alarmLevelText, '二级报警')
+  assert.equal(row.relatedOrderNo, 'WO202606101703482640')
+  assert.equal(row.workOrderStatusText, '处理中')
+})
+
+test('builds a repair dispatch payload with the selected responsible user', () => {
+  const payload = buildMaintainOrderPayload(
+    { equipmentId: 'EQ-1', equipmentCode: 'P-001', equipmentName: '一号压力计' },
+    { description: '压力低于阈值', receiveUserId: 18 }
+  )
+
+  assert.deepEqual(payload, {
+    equipmentId: 'EQ-1',
+    equipmentCode: 'P-001',
+    equipmentName: '一号压力计',
+    maintainType: '故障维修',
+    maintainContent: '压力低于阈值',
+    receiveUserId: 18
+  })
+})
+
+test('uses the abnormal-device order endpoint for device repair dispatch', () => {
+  assert.equal(getDeviceOrderEndpoint('dispatch'), '/waterSupply/device/abnormal/order')
+  assert.equal(getDeviceOrderEndpoint('abnormal'), null)
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов