Sfoglia il codice sorgente

feat: 泵控逻辑重构与泵站2D/3D可视化增强

- 泵控联锁: 全局中控/屏控切换(M1.0共用), 统一启停/手动自动按钮
- 2D/3D控件: 布局引擎系统(LayoutEngine), 渲染器模块化拆分
- 新增服务: ParameterSyncService(参数同步到PLC), PasswordProtector
- AutoControlService: 按项目配置守卫, 默认不启用上位机自动控制
- 报警系统增强: 报警复位, 活跃状态管理
- ProjectView重构: 泵卡片统一切换按钮, BoolConverters扩展
- 文档更新: 杨杰二厂业务逻辑规格书
DESKTOP-74CLTRG\Leol 1 mese fa
parent
commit
8da325383e
45 ha cambiato i file con 5744 aggiunte e 561 eliminazioni
  1. 313 0
      docs/扬杰二厂业务逻辑规格书.md
  2. BIN
      docs/扬杰二厂泵站点位表 (2)(1).xlsx
  3. BIN
      docs/扬杰二厂泵站点位表(1).xlsx
  4. 36 12
      projects/yangjie2.json
  5. 90 10
      src/YZWater.Avalonia/App.axaml.cs
  6. 269 0
      src/YZWater.Avalonia/Controls/Layout/DefaultLayoutEngine.cs
  7. 18 0
      src/YZWater.Avalonia/Controls/Layout/ILayoutEngine.cs
  8. 97 0
      src/YZWater.Avalonia/Controls/Layout/LayoutData.cs
  9. 21 0
      src/YZWater.Avalonia/Controls/Layout/LayoutEngineFactory.cs
  10. 297 0
      src/YZWater.Avalonia/Controls/Layout/PumpStationLayoutEngine.cs
  11. 888 41
      src/YZWater.Avalonia/Controls/PumpStation2DControl.cs
  12. 344 71
      src/YZWater.Avalonia/Controls/PumpStation3DControl.cs
  13. 216 35
      src/YZWater.Avalonia/Controls/Renderers/PipeRenderer.cs
  14. 197 0
      src/YZWater.Avalonia/Controls/Renderers/PumpRenderer.cs
  15. 264 15
      src/YZWater.Avalonia/Controls/Renderers/TankRenderer.cs
  16. 155 0
      src/YZWater.Avalonia/Controls/SparklineControl.cs
  17. 118 0
      src/YZWater.Avalonia/Converters/BoolConverters.cs
  18. 45 0
      src/YZWater.Avalonia/Converters/TankStageConverters.cs
  19. 54 0
      src/YZWater.Avalonia/Services/KeyboardHelper.cs
  20. 5 2
      src/YZWater.Avalonia/Views/LoginView.axaml
  21. 1 0
      src/YZWater.Avalonia/Views/MainWindow.axaml
  22. 541 182
      src/YZWater.Avalonia/Views/ProjectView.axaml
  23. 7 47
      src/YZWater.Avalonia/Views/ViewBView.axaml
  24. 1 0
      src/YZWater.Avalonia/Views/ViewDView.axaml
  25. 17 0
      src/YZWater.Avalonia/YZWater.Avalonia.csproj
  26. 0 2
      src/YZWater.Core/Models/PlcDataModel.cs
  27. 48 2
      src/YZWater.Core/Models/ProjectProfile.cs
  28. 20 2
      src/YZWater.Core/Models/SystemConfig.cs
  29. 23 0
      src/YZWater.Core/Models/TankStage.cs
  30. 167 2
      src/YZWater.Core/Services/AlarmService.cs
  31. 22 2
      src/YZWater.Core/Services/AuditService.cs
  32. 54 1
      src/YZWater.Core/Services/AutoControlService.cs
  33. 18 11
      src/YZWater.Core/Services/DataLoggingService.cs
  34. 65 7
      src/YZWater.Core/Services/DatabaseService.cs
  35. 2 0
      src/YZWater.Core/Services/LanguageService.cs
  36. 145 7
      src/YZWater.Core/Services/MockPlcService.cs
  37. 177 0
      src/YZWater.Core/Services/ParameterSyncService.cs
  38. 118 0
      src/YZWater.Core/Services/PasswordProtector.cs
  39. 99 23
      src/YZWater.Core/Services/PlcPollingService.cs
  40. 1 0
      src/YZWater.Core/Services/PlcService.cs
  41. 11 0
      src/YZWater.Core/ViewModels/LoginViewModel.cs
  42. 8 0
      src/YZWater.Core/ViewModels/MainViewModel.cs
  43. 746 19
      src/YZWater.Core/ViewModels/ProjectViewModel.cs
  44. 0 68
      src/YZWater.Core/ViewModels/ViewBViewModel.cs
  45. 26 0
      src/YZWater.Core/ViewModels/ViewDViewModel.cs

+ 313 - 0
docs/扬杰二厂业务逻辑规格书.md

@@ -0,0 +1,313 @@
+# 扬杰二厂泵站 — 业务逻辑规格书
+
+> 版本: v2.0
+> 日期: 2026-09-08
+> 依据: 现场 PLC 点位确认表
+> 状态: 已确认,待实施
+
+---
+
+## 一、项目定位
+
+| 项 | 值 |
+|---|---|
+| 工艺场景 | 扬杰二厂 **集水池排污泵站**(1 个集水池 + 3 台排污泵) |
+| PLC | 西门子 S7-200 Smart |
+| 上位机角色 | **中控室监控** — 只监不控(自动逻辑由 PLC 执行) |
+| 控制模式 | 屏控(现场触摸屏)↔ 中控(远程上位机)|
+| 前端模板 | `PumpStation` |
+
+### 核心原则
+
+> **自动控制由现场 PLC 完成,上位机不自动启停泵。**
+>
+> 上位机仅在 **中控 + 手动** 模式下允许远程手动启停泵。
+
+---
+
+## 二、控制模式与安全联锁
+
+### 2.1 两级模式切换
+
+远程操作泵启停需要满足 **两级条件**,缺一不可:
+
+```
+第一步: 切换到「中控模式」
+  ModeSelectAddress = 0 → 中控(上位机可操作)
+  ModeSelectAddress = 1 → 屏控(现场控制,上位机只监视)
+       │
+       ▼
+第二步: 切换到「手动模式」
+  AutoStateAddress = 0 → 手动(允许远程启停)
+  AutoStateAddress = 1 → 自动(PLC 自动逻辑运行中,远程不可干预)
+       │
+       ▼
+   ✅ 可以操作泵启停
+```
+
+### 2.2 联锁判定表
+
+| ModeSelect (中控/屏控) | AutoState (自动/手动) | 上位机可启停泵? | 说明 |
+|:---:|:---:|:---:|------|
+| 屏控 (1) | 自动 (1) | ❌ | 现场自动运行,上位机纯监视 |
+| 屏控 (1) | 手动 (0) | ❌ | 现场手动操作,上位机纯监视 |
+| 中控 (0) | 自动 (1) | ❌ | 上位机监控,PLC 自动逻辑执行 |
+| **中控 (0)** | **手动 (0)** | **✅** | **唯一允许远程启停的状态** |
+
+### 2.3 上位机启停泵前置检查流程
+
+```
+操作员点击 启动/停止 按钮
+   │
+   ▼
+① 权限检查: AuthService.HasPermission ≥ Operator
+   │
+   ▼
+② 项目配置检查: profile.Control.AllowManualControl == true
+   │
+   ▼
+③ 模式联锁检查:
+   ├─ 读 ModeSelectAddress → 必须 = 0 (中控)
+   │   └─ 否则: 提示 "当前为屏控模式,无法远程操作"
+   └─ 读 AutoStateAddress → 必须 = 0 (手动)
+       └─ 否则: 提示 "当前为自动模式,请切换到手动"
+   │
+   ▼
+④ 执行点动写入:
+   启动 → WriteBool(StartAddress, true) → 200ms → WriteBool(StartAddress, false)
+   停止 → WriteBool(StopAddress, true)  → 200ms → WriteBool(StopAddress, false)
+   │
+   ▼
+⑤ 审计日志 + 下一轮询验证运行状态
+```
+
+---
+
+## 三、PLC 点位表(已确认)
+
+### 3.1 基本监控 — 模拟量读取
+
+| # | 名称 | 地址 | 数据类型 | 方向 | JSON 字段 |
+|---|------|------|---------|:----:|----------|
+| 1 | 1#泵电流 | VD50 | REAL | 读 | `pumps[0].currentAddress` |
+| 2 | 2#泵电流 | VD54 | REAL | 读 | `pumps[1].currentAddress` |
+| 3 | 3#泵电流 | VD58 | REAL | 读 | `pumps[2].currentAddress` |
+| 4 | 即时压力 | VD6600 | REAL | 读 | `gauges[0].valueAddress` |
+| 5 | 即时流量 | VD6604 | REAL | 读 | `gauges[1].valueAddress` |
+| 6 | 总流量 | VD7000 | REAL | 读 | `sensors[0].address` |
+| 7 | 即时液位 | VD62 | REAL | 读 | `tanks[0].levelAddress` |
+
+### 3.2 基本监控 — 数字量读取
+
+| # | 名称 | 地址 | 数据类型 | 方向 | JSON 字段 |
+|---|------|------|---------|:----:|----------|
+| 1 | 1#泵运行状态 | Q0.1 | 开关量 | 读 | `pumps[0].runAddress` |
+| 2 | 2#泵运行状态 | Q0.2 | 开关量 | 读 | `pumps[1].runAddress` |
+| 3 | 3#泵运行状态 | Q0.3 | 开关量 | 读 | `pumps[2].runAddress` |
+
+### 3.3 故障监控 — 数字量读取
+
+| # | 名称 | 地址 | 数据类型 | 方向 | JSON 字段 |
+|---|------|------|---------|:----:|----------|
+| 1 | 低液位报警 | M19.5 | 开关量 | 读 | `digitalInputs` (新增) |
+| 2 | 高压报警 | M30.1 | 开关量 | 读 | `digitalInputs` (新增) |
+| 3 | 泵1过流报警 | M17.1 | 开关量 | 读 | `pumps[0].faultAddress` |
+| 4 | 泵2过流报警 | M17.2 | 开关量 | 读 | `pumps[1].faultAddress` |
+| 5 | 泵3过流报警 | M17.3 | 开关量 | 读 | `pumps[2].faultAddress` |
+| 6 | 变频器1报警 | M3.1 | 开关量 | 读 | `digitalInputs` |
+| 7 | 变频器2报警 | M6.1 | 开关量 | 读 | `digitalInputs` |
+| 8 | 变频器3报警 | M9.1 | 开关量 | 读 | `digitalInputs` |
+| 9 | 复位按钮 | M30.0 | 开关量 | **脉冲写** | `digitalOutputs` |
+
+### 3.4 参数设置 — 模拟量读写
+
+| # | 名称 | 地址 | 数据类型 | 方向 | JSON 字段 |
+|---|------|------|---------|:----:|----------|
+| 1 | 启动1台泵液位 | VD500 | REAL | 读写 | `tanks[0].startPump1LevelAddress` |
+| 2 | 启动2台泵液位 | VD504 | REAL | 读写 | `tanks[0].startPump2LevelAddress` |
+| 3 | 启动3台泵液位 | VD508 | REAL | 读写 | `tanks[0].startPump3LevelAddress` |
+| 4 | 停止液位 | VD528 | REAL | 读写 | `tanks[0].stopLevelAddress` |
+| 5 | 保护液位 | VD962 | REAL | 读写 | `tanks[0].protectionLevelAddress` |
+| 6 | 高压设置 | VD6704 | REAL | 读写 | `gauges[0].highSetAddress` |
+| 7 | 低压设置 | VD6700 | REAL | 读写 | `gauges[0].lowSetAddress` |
+
+### 3.5 泵控制 — 模式与操作
+
+| 功能 | 1#泵 | 2#泵 | 3#泵 | JSON 字段 |
+|------|:----:|:----:|:----:|----------|
+| 中控/屏控选择 (读) | M1.0 | M4.0 | M7.0 | `modeSelectAddress` |
+| 手自动状态 (读) | M2.6 | M5.6 | M8.6 | `autoStateAddress` |
+| 手动启动 (脉冲写) | M1.3 | M4.3 | M7.3 | `startAddress` |
+| 手动停止 (脉冲写) | M1.4 | M4.4 | M7.4 | `stopAddress` |
+| 切手动命令 (脉冲写) | M2.1 | M5.3 | M8.3 | `manualCommandAddress` |
+| 切自动命令 (脉冲写) | M2.2 | M5.4 | M8.4 | `autoCommandAddress` |
+
+### 3.6 日台时(运行时间统计)
+
+| 功能 | 1#泵 | 2#泵 | 3#泵 | 数据类型 | JSON 字段 |
+|------|:----:|:----:|:----:|:--------:|----------|
+| 台时-小时 (读) | VD950 | VD954 | VD958 | DINT | `dailyHoursAddress` / `dailyHoursDataType` |
+| 台时-分钟 (读) | VD10 | VD18 | VD28 | DINT | `dailyMinutesAddress` / `dailyMinutesDataType` |
+| 台时清零 (脉冲写) | M19.4 | M19.4 | M19.4 | - | `dailyResetAddress` |
+
+---
+
+## 四、上位机功能清单
+
+### 4.1 实时监控(只读)
+
+| 功能 | 数据来源 | 刷新频率 |
+|------|---------|---------|
+| 集水池液位 + 趋势曲线 | VD62 | 1s |
+| 3 台泵运行状态 (运行/停止/故障) | Q0.1-3 + M17.1-3 | 1s |
+| 3 台泵电流 + 负荷比 | VD50/54/58 | 1s |
+| 即时压力 + 刻度 | VD6600 | 1s |
+| 即时流量 | VD6604 | 1s |
+| 总流量 | VD7000 | 1s |
+| 中控/屏控模式显示 | M1.0/M4.0/M7.0 | 1s |
+| 手自动状态显示 | M2.6/M5.6/M8.6 | 1s |
+| 日台时 (时+分) | VD950+VD10 等 | 1s |
+| 故障报警 (低液位/高压/过流/变频器) | M19.5/M30.1/M17.x/M3.1/M6.1/M9.1 | 1s |
+
+### 4.2 手动控制(需 中控+手动 模式)
+
+| 操作 | 前置条件 | PLC 写入 |
+|------|---------|---------|
+| 远程启动泵 | ModeSelect=0 AND AutoState=0 | 点动 StartAddress (200ms) |
+| 远程停止泵 | ModeSelect=0 AND AutoState=0 | 点动 StopAddress (200ms) |
+| 日台时清零 | 无 | 点动 DailyResetAddress (200ms) |
+
+### 4.3 参数同步(写入 PLC)
+
+| 操作 | PLC 写入地址 |
+|------|-------------|
+| 设置启泵液位阈值 | VD500 / VD504 / VD508 |
+| 设置停泵液位 | VD528 |
+| 设置保护液位 | VD962 |
+| 设置压力高限 | VD6704 |
+| 设置压力低限 | VD6700 |
+| 一键全量同步 | 以上所有 |
+
+### 4.4 报警管理
+
+| 操作 | 行为 |
+|------|------|
+| 报警确认 | 标记 IsConfirmed + 记录操作人 |
+| 报警复位(单条) | 清除活跃状态 + 点动 M30.0 |
+| 报警复位(全部) | 清除所有活跃状态 + 点动 M30.0 |
+| 报警通知 | 级别 ≥ 3 的报警触发桌面通知 |
+
+### 4.5 中控/远程 (Hub 命令)
+
+| 命令 | 行为 |
+|------|------|
+| `query_mode` | 查询当前控制模式 |
+| `sync_params` | 远程触发参数全量同步 |
+| `reset_alarm` | 远程复位指定报警 |
+| `reset_all_alarms` | 远程复位所有报警 |
+| `start_pump` / `stop_pump` | 远程启停泵(需 AllowManualControl) |
+
+---
+
+## 五、项目配置(ControlConfig)
+
+```json
+{
+  "control": {
+    "enableAutoControl": false,
+    "controlModeDescription": "PLC自动(上位机仅监控)",
+    "allowManualControl": true,
+    "allowParameterSync": true
+  }
+}
+```
+
+| 字段 | 含义 | 本项目值 |
+|------|------|---------|
+| `enableAutoControl` | 上位机是否执行自动启停逻辑 | `false` — PLC 自行处理 |
+| `controlModeDescription` | UI 显示用的模式描述 | "PLC自动(上位机仅监控)" |
+| `allowManualControl` | 是否允许远程手动启停泵 | `true` — 需联锁检查 |
+| `allowParameterSync` | 是否允许写参数到 PLC | `true` |
+
+---
+
+## 六、数据采集流程
+
+```
+PlcPollingService 定时轮询 (默认 1s)
+   │
+   ├─ 读模拟量: VD62(液位) / VD6600(压力) / VD6604(流量)
+   │            VD50/54/58(泵电流) / VD7000(总流量)
+   │            VD950+VD10 / VD954+VD18 / VD958+VD28(日台时)
+   │
+   ├─ 读数字量: Q0.1-3(泵运行) / M17.1-3(泵过流)
+   │            M3.1/M6.1/M9.1(变频器故障)
+   │            M19.5(低液位报警) / M30.1(高压报警)
+   │            M1.0/M4.0/M7.0(中控/屏控) / M2.6/M5.6/M8.6(手自动)
+   │
+   ├─ 读控制参数: VD500/504/508/528/962(液位阈值)
+   │              VD6700/6704(压力阈值)
+   │
+   └─ 发布 DataUpdated 事件
+        ├─ AlarmService         → 报警检测 + 报警显示
+        ├─ ProjectViewModel     → UI 刷新 (模式显示、泵状态、液位趋势)
+        ├─ DataLoggingService   → 历史数据入库
+        └─ (AutoControlService  → 不激活,因 enableAutoControl=false)
+```
+
+**注意**: `AutoControlService` 在 `enableAutoControl=false` 时不会订阅 `DataUpdated`,不向 PLC 写入任何启停命令。
+
+---
+
+## 七、报警逻辑
+
+### 7.1 报警源
+
+| 报警类型 | 触发条件 | 来源 | 级别 |
+|---------|---------|------|------|
+| 低液位报警 | M19.5 = 1 | PLC 数字量 | 警告(2) |
+| 高压报警 | M30.1 = 1 | PLC 数字量 | 严重(3) |
+| 泵1过流 | M17.1 = 1 | PLC 数字量 | 严重(3) |
+| 泵2过流 | M17.2 = 1 | PLC 数字量 | 严重(3) |
+| 泵3过流 | M17.3 = 1 | PLC 数字量 | 严重(3) |
+| 变频器1故障 | M3.1 = 1 | PLC 数字量 | 严重(3) |
+| 变频器2故障 | M6.1 = 1 | PLC 数字量 | 严重(3) |
+| 变频器3故障 | M9.1 = 1 | PLC 数字量 | 严重(3) |
+| 压力超高限 | 压力 > VD6704 | 上位机计算 | 警告(2) |
+| 压力超低限 | 压力 < VD6700 | 上位机计算 | 警告(2) |
+| PLC 断连 | 通信超时 | 上位机检测 | 紧急(4) |
+
+### 7.2 报警处理
+
+```
+报警触发 → 记录到 alarm_records 表 → UI 弹出 → 桌面通知
+   │
+   ├─ 确认: 操作员点击确认 → 标记 IsConfirmed
+   └─ 复位: 操作员点击复位 → 清除活跃状态 + 点动 M30.0 → PLC 侧复位报警输出
+```
+
+### 7.3 报警特性
+
+| 特性 | 值 |
+|------|-----|
+| 延迟触发 | 3 秒(防止瞬间波动误报) |
+| 死区防抖 | ±2(防止在阈值边界反复触发) |
+| 升级机制 | 未确认 10 分钟自动升到级别 4 |
+| 异步入库 | Channel 有界队列 (200),不阻塞轮询 |
+
+---
+
+## 八、与旧版差异(v1 → v2 变更摘要)
+
+| # | 变更项 | v1 (旧) | v2 (新) |
+|---|--------|---------|---------|
+| 1 | 上位机角色 | 自动启停泵 (AutoControlService) | **仅监控**,PLC 处理自动逻辑 |
+| 2 | 故障地址 | I0.1/I0.2/I0.3 (I区物理输入) | M17.1/M17.2/M17.3 (泵过流) + M3.1/M6.1/M9.1 (变频器) |
+| 3 | 低液位报警 | 无 | **M19.5** (新增) |
+| 4 | 高压报警 | 无 | **M30.1** (新增) |
+| 5 | 总流量 | VD884 (日流量) | **VD7000** (总流量) |
+| 6 | 操作联锁 | 无 | **中控+手动** 双重检查 |
+| 7 | 参数同步 | 无 | **可写** 7 个阈值到 PLC |
+| 8 | 报警复位 | 仅确认 | 确认 + **复位** (软件清除 + M30.0 脉冲) |
+| 9 | 模式显示 | 无聚合 | **中控/屏控/混合** 模式徽章 |
+| 10 | 按项目配置 | 无 | **ControlConfig** 按项目决定是否自动控制 |

BIN
docs/扬杰二厂泵站点位表 (2)(1).xlsx


BIN
docs/扬杰二厂泵站点位表(1).xlsx


+ 36 - 12
projects/yangjie2.json

@@ -2,16 +2,25 @@
   "name": "扬杰二厂泵站",
   "name": "扬杰二厂泵站",
   "description": "3台排污泵 + 液位/压力/流量监控",
   "description": "3台排污泵 + 液位/压力/流量监控",
   "viewTemplate": "PumpStation",
   "viewTemplate": "PumpStation",
+  "control": {
+    "enableAutoControl": false,
+    "controlModeDescription": "PLC自动(上位机仅监控)",
+    "allowManualControl": true,
+    "allowParameterSync": true
+  },
+  "modeSelectAddress": "M1.0",
   "pumps": [
   "pumps": [
     {
     {
       "name": "1#泵",
       "name": "1#泵",
       "runAddress": "Q0.1",
       "runAddress": "Q0.1",
-      "faultAddress": "I0.1",
+      "faultAddress": "M17.1",
       "currentAddress": "VD50",
       "currentAddress": "VD50",
+      "ratedCurrent": 25,
       "autoStateAddress": "M2.6",
       "autoStateAddress": "M2.6",
       "dailyHoursAddress": "VD950",
       "dailyHoursAddress": "VD950",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD10",
       "dailyMinutesAddress": "VD10",
-      "modeSelectAddress": "M1.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M1.3",
       "startAddress": "M1.3",
       "stopAddress": "M1.4",
       "stopAddress": "M1.4",
       "autoCommandAddress": "M2.2",
       "autoCommandAddress": "M2.2",
@@ -21,12 +30,14 @@
     {
     {
       "name": "2#泵",
       "name": "2#泵",
       "runAddress": "Q0.2",
       "runAddress": "Q0.2",
-      "faultAddress": "I0.2",
+      "faultAddress": "M17.2",
       "currentAddress": "VD54",
       "currentAddress": "VD54",
+      "ratedCurrent": 25,
       "autoStateAddress": "M5.6",
       "autoStateAddress": "M5.6",
       "dailyHoursAddress": "VD954",
       "dailyHoursAddress": "VD954",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD18",
       "dailyMinutesAddress": "VD18",
-      "modeSelectAddress": "M4.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M4.3",
       "startAddress": "M4.3",
       "stopAddress": "M4.4",
       "stopAddress": "M4.4",
       "autoCommandAddress": "M5.4",
       "autoCommandAddress": "M5.4",
@@ -36,12 +47,14 @@
     {
     {
       "name": "3#泵",
       "name": "3#泵",
       "runAddress": "Q0.3",
       "runAddress": "Q0.3",
-      "faultAddress": "I0.3",
+      "faultAddress": "M17.3",
       "currentAddress": "VD58",
       "currentAddress": "VD58",
+      "ratedCurrent": 25,
       "autoStateAddress": "M8.6",
       "autoStateAddress": "M8.6",
       "dailyHoursAddress": "VD958",
       "dailyHoursAddress": "VD958",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD28",
       "dailyMinutesAddress": "VD28",
-      "modeSelectAddress": "M7.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M7.3",
       "startAddress": "M7.3",
       "stopAddress": "M7.4",
       "stopAddress": "M7.4",
       "autoCommandAddress": "M8.4",
       "autoCommandAddress": "M8.4",
@@ -53,6 +66,7 @@
     {
     {
       "name": "集水池",
       "name": "集水池",
       "levelAddress": "VD62",
       "levelAddress": "VD62",
+      "maxLevel": 3,
       "startPump1LevelAddress": "VD500",
       "startPump1LevelAddress": "VD500",
       "startPump2LevelAddress": "VD504",
       "startPump2LevelAddress": "VD504",
       "startPump3LevelAddress": "VD508",
       "startPump3LevelAddress": "VD508",
@@ -68,7 +82,7 @@
       "highSetAddress": "VD6704",
       "highSetAddress": "VD6704",
       "lowSetAddress": "VD6700",
       "lowSetAddress": "VD6700",
       "minValue": 0,
       "minValue": 0,
-      "maxValue": 1.6
+      "maxValue": 8
     },
     },
     {
     {
       "name": "流量",
       "name": "流量",
@@ -80,27 +94,37 @@
   ],
   ],
   "sensors": [
   "sensors": [
     {
     {
-      "name": "日流量",
+      "name": "总流量",
       "unit": "m³",
       "unit": "m³",
-      "address": "VD884",
+      "address": "VD7000",
       "dataType": "REAL",
       "dataType": "REAL",
       "access": "R"
       "access": "R"
     }
     }
   ],
   ],
   "digitalInputs": [
   "digitalInputs": [
+    {
+      "name": "低液位报警",
+      "address": "M19.5",
+      "remark": "1-报警 0-正常"
+    },
+    {
+      "name": "高压报警",
+      "address": "M30.1",
+      "remark": "1-报警 0-正常"
+    },
     {
     {
       "name": "泵1变频器故障",
       "name": "泵1变频器故障",
-      "address": "I0.1",
+      "address": "M3.1",
       "remark": "1-故障 0-正常"
       "remark": "1-故障 0-正常"
     },
     },
     {
     {
       "name": "泵2变频器故障",
       "name": "泵2变频器故障",
-      "address": "I0.2",
+      "address": "M6.1",
       "remark": "1-故障 0-正常"
       "remark": "1-故障 0-正常"
     },
     },
     {
     {
       "name": "泵3变频器故障",
       "name": "泵3变频器故障",
-      "address": "I0.3",
+      "address": "M9.1",
       "remark": "1-故障 0-正常"
       "remark": "1-故障 0-正常"
     }
     }
   ],
   ],

+ 90 - 10
src/YZWater.Avalonia/App.axaml.cs

@@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml.Styling;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using System.Threading;
 using System.Threading;
 using YZWater.Avalonia.Controls;
 using YZWater.Avalonia.Controls;
+using YZWater.Avalonia.Services;
 using YZWater.Avalonia.Views;
 using YZWater.Avalonia.Views;
 using YZWater.Core.Services;
 using YZWater.Core.Services;
 using YZWater.Core.ViewModels;
 using YZWater.Core.ViewModels;
@@ -26,6 +27,9 @@ public partial class App : Application
     {
     {
         LogService.Initialize();
         LogService.Initialize();
 
 
+        // 激活 HslCommunication 授权
+        HslCommunication.Authorization.SetAuthorizationCode("c765bac7-2caf-446d-9619-f67e19d6624e");
+
         AvaloniaXamlLoader.Load(this);
         AvaloniaXamlLoader.Load(this);
 
 
         _darkTheme = new ResourceInclude(new Uri("avares://YZWater.Avalonia/Themes/IndustrialTheme.axaml"))
         _darkTheme = new ResourceInclude(new Uri("avares://YZWater.Avalonia/Themes/IndustrialTheme.axaml"))
@@ -108,6 +112,40 @@ public partial class App : Application
 
 
     private void ShowLoginWindow(IClassicDesktopStyleApplicationLifetime desktop)
     private void ShowLoginWindow(IClassicDesktopStyleApplicationLifetime desktop)
     {
     {
+        var config = ConfigService.GetConfig();
+
+        // 检测是否按住 Shift 键(用于跳过自动登录)
+        var shiftPressed = KeyboardHelper.IsShiftPressed();
+
+        // 自动登录:跳过登录窗口直接登录(按住 Shift 时跳过)
+        if (!shiftPressed && config?.AutoLogin == true && !string.IsNullOrEmpty(config.SavedUserName))
+        {
+            var decryptedPwd = PasswordProtector.Unprotect(config.SavedPassword ?? "", config.SavedUserName);
+            var result = AuthService.Login(config.SavedUserName, decryptedPwd);
+            if (result.Success)
+            {
+                // 自动登录成功后,检查是否需要迁移密码格式(明文 → 加密)
+                // 如果解密后的密码与保存的密文不同,说明是旧版明文格式,需要重新加密
+                if (config.SavedPassword != PasswordProtector.Protect(decryptedPwd, config.SavedUserName))
+                {
+                    try
+                    {
+                        config.SavedPassword = PasswordProtector.Protect(decryptedPwd, config.SavedUserName);
+                        ConfigService.UpdateConfig(config);
+                        Serilog.Log.Information("自动登录:密码已从明文格式迁移为加密格式");
+                    }
+                    catch (Exception ex)
+                    {
+                        Serilog.Log.Warning(ex, "自动登录:密码格式迁移失败");
+                    }
+                }
+
+                StartServices();
+                ShowMainWindow(desktop);
+                return;
+            }
+        }
+
         var loginVm = new LoginViewModel();
         var loginVm = new LoginViewModel();
         var loginView = new LoginView { DataContext = loginVm };
         var loginView = new LoginView { DataContext = loginVm };
 
 
@@ -365,6 +403,12 @@ public partial class App : Application
                     {
                     {
                         return new Core.Models.CommandResultPayload { Success = false, Message = "无效的泵编号(需 1-5)" };
                         return new Core.Models.CommandResultPayload { Success = false, Message = "无效的泵编号(需 1-5)" };
                     }
                     }
+                    // 检查项目是否允许手动控制
+                    var profile = Core.Services.ProjectService.Current;
+                    if (profile != null && !profile.Control.AllowManualControl)
+                    {
+                        return new Core.Models.CommandResultPayload { Success = false, Message = "项目配置禁止远程手动控制" };
+                    }
                     var writeValue = command.Action == "start_pump";
                     var writeValue = command.Action == "start_pump";
                     var addr = PlcConfig.GetPumpRunAddress(pumpIdx - 1);
                     var addr = PlcConfig.GetPumpRunAddress(pumpIdx - 1);
                     var ok = await PlcService.WriteBoolAsync(addr, writeValue);
                     var ok = await PlcService.WriteBoolAsync(addr, writeValue);
@@ -377,6 +421,40 @@ public partial class App : Application
                         Message = ok ? $"泵{pumpIdx}已{verb}" : "写入失败"
                         Message = ok ? $"泵{pumpIdx}已{verb}" : "写入失败"
                     };
                     };
 
 
+                case "query_mode":
+                    var modeProfile = Core.Services.ProjectService.Current;
+                    var autoEnabled = modeProfile?.Control.EnableAutoControl ?? false;
+                    var modeDesc = modeProfile?.Control.ControlModeDescription ?? "未知";
+                    return new Core.Models.CommandResultPayload
+                    {
+                        Success = true,
+                        Message = $"自动控制: {(autoEnabled ? "启用" : "禁用")}, 模式: {modeDesc}"
+                    };
+
+                case "sync_params":
+                    var syncResult = await Core.Services.ParameterSyncService.Instance.SyncAllAsync(command.Operator ?? "远程");
+                    return new Core.Models.CommandResultPayload
+                    {
+                        Success = syncResult.TotalCount == 0 || syncResult.SuccessCount > 0,
+                        Message = syncResult.TotalCount == 0 ? "无参数需要同步" : syncResult.Summary
+                    };
+
+                case "reset_alarm":
+                    if (!int.TryParse(command.Target, out var alarmId))
+                    {
+                        return new Core.Models.CommandResultPayload { Success = false, Message = "无效的报警ID" };
+                    }
+                    var resetOk = await Core.Services.AlarmService.Instance.ResetAlarmAsync(alarmId, command.Operator ?? "远程");
+                    return new Core.Models.CommandResultPayload
+                    {
+                        Success = resetOk,
+                        Message = resetOk ? $"报警 {alarmId} 已复位" : $"未找到报警记录 {alarmId}"
+                    };
+
+                case "reset_all_alarms":
+                    await Core.Services.AlarmService.Instance.ResetAllAlarmsAsync(command.Operator ?? "远程");
+                    return new Core.Models.CommandResultPayload { Success = true, Message = "所有报警已复位" };
+
                 default:
                 default:
                     return new Core.Models.CommandResultPayload { Success = false, Message = $"未知命令: {command.Action}" };
                     return new Core.Models.CommandResultPayload { Success = false, Message = $"未知命令: {command.Action}" };
             }
             }
@@ -656,17 +734,8 @@ public partial class App : Application
             Serilog.Log.Warning(ex, "[{Elapsed}ms] SingleInstanceHelper 释放失败", sw.ElapsedMilliseconds);
             Serilog.Log.Warning(ex, "[{Elapsed}ms] SingleInstanceHelper 释放失败", sw.ElapsedMilliseconds);
         }
         }
 
 
-        try
-        {
-            DatabaseService.Dispose();
-            Serilog.Log.Information("[{Elapsed}ms] DatabaseService 已释放", sw.ElapsedMilliseconds);
-        }
-        catch (Exception ex)
-        {
-            Serilog.Log.Warning(ex, "[{Elapsed}ms] DatabaseService 释放失败", sw.ElapsedMilliseconds);
-        }
-
         // 停止 AuditService 后台写入任务(flush 剩余数据)
         // 停止 AuditService 后台写入任务(flush 剩余数据)
+        // 必须在 DatabaseService.Dispose 之前,否则后台任务在库已关闭后仍写库引发 NRE
         try
         try
         {
         {
             AuditService.Shutdown();
             AuditService.Shutdown();
@@ -688,6 +757,17 @@ public partial class App : Application
             Serilog.Log.Warning(ex, "[{Elapsed}ms] LogBufferService 释放失败", sw.ElapsedMilliseconds);
             Serilog.Log.Warning(ex, "[{Elapsed}ms] LogBufferService 释放失败", sw.ElapsedMilliseconds);
         }
         }
 
 
+        // 关闭数据库(所有后台写入方均已 flush 停止)
+        try
+        {
+            DatabaseService.Dispose();
+            Serilog.Log.Information("[{Elapsed}ms] DatabaseService 已释放", sw.ElapsedMilliseconds);
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Warning(ex, "[{Elapsed}ms] DatabaseService 释放失败", sw.ElapsedMilliseconds);
+        }
+
         sw.Stop();
         sw.Stop();
         Serilog.Log.Information("========== Cleanup 完成 (总耗时 {Elapsed}ms) ==========", sw.ElapsedMilliseconds);
         Serilog.Log.Information("========== Cleanup 完成 (总耗时 {Elapsed}ms) ==========", sw.ElapsedMilliseconds);
 
 

+ 269 - 0
src/YZWater.Avalonia/Controls/Layout/DefaultLayoutEngine.cs

@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Media;
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Controls.Layout;
+
+/// <summary>
+/// 默认布局引擎 — 泵站典型拓扑:
+/// <code>
+///     [水箱们]           ← 顶部,均分
+///        │
+///     [泵们]             ← 中部,均分
+///        │
+///   ═══出水总管═══        ← 底部水平管道
+///        │
+///     [仪表/传感器]       ← 右下角信息区
+/// </code>
+/// </summary>
+public class DefaultLayoutEngine : ILayoutEngine
+{
+    /// <summary>
+    /// 设计画布尺寸。
+    /// </summary>
+    private const double DesignW = 960;
+    private const double DesignH = 540;
+
+    // ─── 布局区域划分(比例) ───
+    private const double TankZoneTop = 0.08;      // 水箱顶部起始
+    private const double TankZoneHeight = 0.32;   // 水箱区域高度
+    private const double PumpZoneCenter = 0.52;   // 泵 Y 中心
+    private const double PipeZoneY = 0.72;        // 出水总管 Y
+    private const double InfoZoneY = 0.85;        // 仪表/传感器区域
+
+    // ─── 设备默认尺寸(设计像素) ───
+    private const double TankW = 120;
+    private const double TankH = 140;
+    private const double PumpRadius = 22;
+    private const double GaugeW = 80;
+    private const double GaugeH = 48;
+
+    // ─── 水箱颜色调色板 ───
+    private static readonly Color[] TankColors =
+    {
+        Color.FromRgb(33, 150, 243),   // Blue
+        Color.FromRgb(0, 137, 123),    // Teal
+        Color.FromRgb(126, 87, 194),   // Purple
+        Color.FromRgb(0, 150, 136),    // Green
+        Color.FromRgb(255, 152, 0),    // Orange
+    };
+
+    public LayoutResult Calculate(ProjectProfile profile, Size canvasSize)
+    {
+        var devices = new List<DeviceLayout>();
+        var pipes = new List<PipeSegment>();
+
+        var w = canvasSize.Width > 0 ? canvasSize.Width : DesignW;
+        var h = canvasSize.Height > 0 ? canvasSize.Height : DesignH;
+
+        // ─── 1. 水箱:顶部均分 ───
+        var tankCount = profile.Tanks.Count;
+        if (tankCount > 0)
+        {
+            var tankY = h * TankZoneTop;
+            var totalTankWidth = tankCount * TankW;
+            var gap = tankCount > 1
+                ? (w - totalTankWidth - 80) / (tankCount - 1)  // 两侧各留 40px
+                : (w - TankW) / 2;
+
+            for (int i = 0; i < tankCount; i++)
+            {
+                var tankX = tankCount > 1
+                    ? 40 + i * (TankW + gap)
+                    : (w - TankW) / 2;
+
+                // 计算水箱最大液位(用于归一化显示百分比)
+                var tc = profile.Tanks[i];
+                // 优先使用配置的满量程高度,否则取阈值中最大值
+                var maxLevel = tc.MaxLevel ?? 0f;
+                if (maxLevel <= 0)
+                {
+                    maxLevel = tc.ProtectionLevel ?? 10.0f;
+                    if (tc.StopLevel.HasValue && tc.StopLevel.Value > maxLevel) maxLevel = tc.StopLevel.Value;
+                }
+                if (maxLevel <= 0) maxLevel = 10.0f;
+
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = tc.Name,
+                    DeviceType = "Tank",
+                    Bounds = new Rect(tankX, tankY, TankW, TankH),
+                    Parameters = new()
+                    {
+                        ["Color"] = TankColors[i % TankColors.Length],
+                        ["Index"] = i,
+                        ["MaxLevel"] = maxLevel,
+                    }
+                });
+            }
+        }
+
+        // ─── 2. 泵:中部均分 ───
+        var pumpCount = profile.Pumps.Count;
+        if (pumpCount > 0)
+        {
+            var pumpCenterY = h * PumpZoneCenter;
+            var pumpSpacing = w / (pumpCount + 1);
+
+            for (int i = 0; i < pumpCount; i++)
+            {
+                var pumpX = pumpSpacing * (i + 1);
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Pumps[i].Name,
+                    DeviceType = "Pump",
+                    Bounds = new Rect(pumpX - PumpRadius, pumpCenterY - PumpRadius,
+                                      PumpRadius * 2, PumpRadius * 2),
+                    Parameters = new()
+                    {
+                        ["Index"] = i,
+                        ["CenterX"] = pumpX,
+                        ["CenterY"] = pumpCenterY,
+                    }
+                });
+            }
+        }
+
+        // ─── 3. 仪表 + 传感器:底部信息区统一排列 ───
+        var totalInfoCount = profile.Gauges.Count + profile.Sensors.Count;
+        if (totalInfoCount > 0)
+        {
+            var infoY = h * InfoZoneY;
+            var infoSpacing = w / (totalInfoCount + 1);
+
+            // 仪表
+            for (int i = 0; i < profile.Gauges.Count; i++)
+            {
+                var gx = infoSpacing * (i + 1) - GaugeW / 2;
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Gauges[i].Name,
+                    DeviceType = "Gauge",
+                    Bounds = new Rect(gx, infoY, GaugeW, GaugeH),
+                    Parameters = new()
+                    {
+                        ["Unit"] = profile.Gauges[i].Unit,
+                        ["Index"] = i,
+                    }
+                });
+            }
+
+            // 传感器(紧接仪表之后)
+            for (int i = 0; i < profile.Sensors.Count; i++)
+            {
+                var si = profile.Gauges.Count + i;
+                var sx = infoSpacing * (si + 1) - 45;
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Sensors[i].Name,
+                    DeviceType = "Sensor",
+                    Bounds = new Rect(sx, infoY, 90, GaugeH),
+                    Parameters = new()
+                    {
+                        ["Unit"] = profile.Sensors[i].Unit,
+                        ["Index"] = i,
+                    }
+                });
+            }
+        }
+
+        // ─── 5. 管道连接:水箱 → 泵 → 出水总管 ───
+        var pipeY = h * PipeZoneY;
+
+        // 5a. 每个水箱底部到泵区域的垂直管
+        for (int i = 0; i < tankCount; i++)
+        {
+            var tank = devices.Find(d => d.DeviceId == profile.Tanks[i].Name);
+            if (tank == null) continue;
+            var tankBottomCenter = new Point(tank.Bounds.Center.X, tank.Bounds.Bottom);
+
+            // 水箱底部 → 水平总管
+            pipes.Add(new PipeSegment
+            {
+                From = tankBottomCenter,
+                To = new Point(tankBottomCenter.X, pipeY),
+                IsFlowing = true,
+                Thickness = 10,
+            });
+        }
+
+        // 5b. 水平汇流总管(连接所有水箱的垂直管底部)
+        if (tankCount >= 2)
+        {
+            var firstTank = devices.Find(d => d.DeviceId == profile.Tanks[0].Name);
+            var lastTank = devices.Find(d => d.DeviceId == profile.Tanks[tankCount - 1].Name);
+            if (firstTank != null && lastTank != null)
+            {
+                pipes.Add(new PipeSegment
+                {
+                    From = new Point(firstTank.Bounds.Center.X, pipeY),
+                    To = new Point(lastTank.Bounds.Center.X, pipeY),
+                    IsFlowing = true,
+                    Thickness = 12,
+                });
+            }
+        }
+
+        // 5c. 泵吸入管:总管 → 泵中心
+        for (int i = 0; i < pumpCount; i++)
+        {
+            var pump = devices.Find(d => d.DeviceId == profile.Pumps[i].Name);
+            if (pump == null) continue;
+            var pumpCenter = new Point(
+                (double)pump.Parameters["CenterX"],
+                (double)pump.Parameters["CenterY"]);
+
+            // 从水平总管向上到泵底部
+            pipes.Add(new PipeSegment
+            {
+                From = new Point(pumpCenter.X, pipeY),
+                To = new Point(pumpCenter.X, pumpCenter.Y + PumpRadius),
+                IsFlowing = true,
+                Thickness = 8,
+            });
+        }
+
+        // 5d. 泵排出管:泵底部向下到出水总管
+        var outPipeY = pipeY + 40;
+        if (pumpCount > 0)
+        {
+            // 出水水平总管
+            var firstPump = devices.Find(d => d.DeviceId == profile.Pumps[0].Name);
+            var lastPump = devices.Find(d => d.DeviceId == profile.Pumps[pumpCount - 1].Name);
+            if (firstPump != null && lastPump != null)
+            {
+                var fpCx = (double)firstPump.Parameters["CenterX"];
+                var lpCx = (double)lastPump.Parameters["CenterX"];
+
+                pipes.Add(new PipeSegment
+                {
+                    From = new Point(fpCx, outPipeY),
+                    To = new Point(lpCx, outPipeY),
+                    IsFlowing = true,
+                    Thickness = 12,
+                });
+
+                // 每个泵的排出竖管
+                for (int i = 0; i < pumpCount; i++)
+                {
+                    var pump = devices.Find(d => d.DeviceId == profile.Pumps[i].Name);
+                    if (pump == null) continue;
+                    var pcx = (double)pump.Parameters["CenterX"];
+                    var pcy = (double)pump.Parameters["CenterY"];
+
+                    pipes.Add(new PipeSegment
+                    {
+                        From = new Point(pcx, pcy + PumpRadius),
+                        To = new Point(pcx, outPipeY),
+                        IsFlowing = true,
+                        Thickness = 8,
+                    });
+                }
+            }
+        }
+
+        return new LayoutResult { Devices = devices, Pipes = pipes };
+    }
+}

+ 18 - 0
src/YZWater.Avalonia/Controls/Layout/ILayoutEngine.cs

@@ -0,0 +1,18 @@
+using Avalonia;
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Controls.Layout;
+
+/// <summary>
+/// 布局引擎接口 — 根据项目档案和画布尺寸计算所有设备的位置和管道连接。
+/// </summary>
+public interface ILayoutEngine
+{
+    /// <summary>
+    /// 计算布局。
+    /// </summary>
+    /// <param name="profile">项目档案(设备清单)</param>
+    /// <param name="canvasSize">设计画布尺寸(通常为 960×540)</param>
+    /// <returns>布局结果</returns>
+    LayoutResult Calculate(ProjectProfile profile, Size canvasSize);
+}

+ 97 - 0
src/YZWater.Avalonia/Controls/Layout/LayoutData.cs

@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Controls.Layout;
+
+/// <summary>
+/// 单个设备的布局信息 — 由 ILayoutEngine 计算产出。
+/// </summary>
+public class DeviceLayout
+{
+    /// <summary>设备唯一标识(对应 ProjectProfile 中的 Name)</summary>
+    public string DeviceId { get; set; } = "";
+
+    /// <summary>
+    /// 设备类型:
+    /// "Tank"  — 水箱/水池
+    /// "Pump"  — 泵
+    /// "Fan"   — 风机(保留,目前 Profile 中无此类型)
+    /// "Valve" — 阀门(保留,目前 Profile 中无此类型)
+    /// "Gauge" — 仪表
+    /// "Sensor" — 传感器
+    /// </summary>
+    public string DeviceType { get; set; } = "";
+
+    /// <summary>设备在画布上的绘制区域(设计坐标 960×540)</summary>
+    public Rect Bounds { get; set; }
+
+    /// <summary>额外参数,供特殊设备使用(如水箱颜色、泵叶片数等)</summary>
+    public Dictionary<string, object> Parameters { get; set; } = new();
+}
+
+/// <summary>
+/// 一段管道连接(从 → 到),设计坐标。
+/// </summary>
+public class PipeSegment
+{
+    /// <summary>起点(设计坐标)</summary>
+    public Point From { get; set; }
+
+    /// <summary>终点(设计坐标)</summary>
+    public Point To { get; set; }
+
+    /// <summary>是否参与流动动画</summary>
+    public bool IsFlowing { get; set; }
+
+    /// <summary>管道粗细(设计像素)</summary>
+    public double Thickness { get; set; } = 8;
+}
+
+/// <summary>
+/// 场景装饰图元(非 PLC 设备,纯视觉):阀门符号、流量计、进水示意等。
+/// 由布局引擎产出,控件按 Kind 渲染。
+/// </summary>
+public class SceneDecoration
+{
+    /// <summary>
+    /// 装饰类型:
+    /// "CheckValve" — 止回阀(圆 + 翻板,泵出口防倒流)
+    /// "GateValve"  — 闸阀(双三角蝴蝶结符号)
+    /// "ButterflyValve" — 蝶阀(圆+翻板+手轮柄,进水口)
+    /// "FlowMeter"  — 电磁流量计(粗管段 + 双法兰 + 表头显示数值)
+    /// "PressureGauge" — 压力表(小圆表挂在管旁,带指针)
+    /// </summary>
+    public string Kind { get; set; } = "";
+
+    /// <summary>位置(设计坐标,中心点)</summary>
+    public Point Position { get; set; }
+
+    /// <summary>尺寸(设计像素,符号边长)</summary>
+    public double Size { get; set; } = 18;
+
+    /// <summary>方向:"H" 水平管上 / "V" 垂直管上(影响符号朝向)</summary>
+    public string Orientation { get; set; } = "V";
+
+    /// <summary>数据键(FlowMeter/PressureGauge 表头绑定的模拟量,如 "流量.Value")</summary>
+    public string? DataKey { get; set; }
+
+    /// <summary>单位(FlowMeter/PressureGauge 表头显示)</summary>
+    public string? Unit { get; set; }
+}
+
+/// <summary>
+/// 布局计算结果。
+/// </summary>
+public class LayoutResult
+{
+    /// <summary>所有设备的位置/尺寸</summary>
+    public List<DeviceLayout> Devices { get; set; } = new();
+
+    /// <summary>管道连接列表</summary>
+    public List<PipeSegment> Pipes { get; set; } = new();
+
+    /// <summary>场景装饰图元(阀门/流量计等纯视觉元素)</summary>
+    public List<SceneDecoration> Decorations { get; set; } = new();
+}

+ 21 - 0
src/YZWater.Avalonia/Controls/Layout/LayoutEngineFactory.cs

@@ -0,0 +1,21 @@
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Controls.Layout;
+
+/// <summary>
+/// 布局引擎工厂 — 根据项目档案的 ViewTemplate 选择合适的布局引擎。
+/// </summary>
+public static class LayoutEngineFactory
+{
+    /// <summary>
+    /// 根据项目档案创建对应的布局引擎。
+    /// </summary>
+    public static ILayoutEngine Create(ProjectProfile profile)
+    {
+        return profile.ViewTemplate switch
+        {
+            "PumpStation" => new PumpStationLayoutEngine(),
+            _ => new DefaultLayoutEngine(),
+        };
+    }
+}

+ 297 - 0
src/YZWater.Avalonia/Controls/Layout/PumpStationLayoutEngine.cs

@@ -0,0 +1,297 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Media;
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Controls.Layout;
+
+/// <summary>
+/// 泵站专用布局引擎 — 适用于 ViewTemplate="PumpStation" 的项目。
+///
+/// <para>拓扑结构(扬杰二厂典型布局):</para>
+/// <code>
+///     ─── 出水总管 ───          ← 顶部:出水水平管道
+///        │     │     │
+///      [P1]  [P2]  [P3]       ← 中部:泵组(均分)
+///        │     │     │
+///     ┌──┴──────┴─────┴──┐
+///     │     集水池        │       ← 中下部:共用湿井
+///     └──────────────────┘
+///
+///  [仪表] [仪表] [传感器]         ← 底部:信息区
+/// </code>
+/// </summary>
+public class PumpStationLayoutEngine : ILayoutEngine
+{
+    private const double DesignW = 960;
+    private const double DesignH = 540;
+
+    // ─── 布局区域(从上到下,紧凑排布) ───
+    private const double HeaderY = 0.10;          // 出水总管 Y
+    private const double PumpCenterY = 0.28;      // 泵蜗壳中心 Y(立式泵电机在上方延伸)
+    private const double TankTopY = 0.42;         // 集水池顶部 Y
+    private const double TankHeight = 190;        // 集水池高度
+    private const double InfoZoneY = 0.88;        // 仪表/传感器区域
+
+    // ─── 设备尺寸 ───
+    private const double PumpRadius = 22;
+    private const double GaugeW = 80;
+    private const double GaugeH = 48;
+
+    private static readonly Color[] TankColors =
+    {
+        Color.FromRgb(33, 150, 243),
+        Color.FromRgb(0, 137, 123),
+        Color.FromRgb(126, 87, 194),
+        Color.FromRgb(0, 150, 136),
+        Color.FromRgb(255, 152, 0),
+    };
+
+    public LayoutResult Calculate(ProjectProfile profile, Size canvasSize)
+    {
+        var devices = new List<DeviceLayout>();
+        var pipes = new List<PipeSegment>();
+
+        var w = canvasSize.Width > 0 ? canvasSize.Width : DesignW;
+        var h = canvasSize.Height > 0 ? canvasSize.Height : DesignH;
+
+        var headerY = h * HeaderY;
+        var pumpCenterY = h * PumpCenterY;
+        var tankTopY = h * TankTopY;
+
+        // ─── 1. 集水池:中下部,横向居中(加宽,剖面拟真)───
+        var tankCount = profile.Tanks.Count;
+        if (tankCount > 0)
+        {
+            // 泵站通常只有 1 个集水池,居中显示
+            var tankW = Math.Min(560, w * 0.62);
+            var tankX = (w - tankW) / 2;
+
+            for (int i = 0; i < tankCount; i++)
+            {
+                var tc = profile.Tanks[i];
+                // 优先使用配置的满量程高度,否则取阈值中最大值
+                var maxLevel = tc.MaxLevel ?? 0f;
+                if (maxLevel <= 0)
+                {
+                    maxLevel = tc.ProtectionLevel ?? 10.0f;
+                    if (tc.StopLevel.HasValue && tc.StopLevel.Value > maxLevel) maxLevel = tc.StopLevel.Value;
+                }
+                if (maxLevel <= 0) maxLevel = 10.0f;
+
+                // 多个水箱时横向排列
+                var tw = tankCount == 1 ? tankW : tankW / tankCount;
+                var tx = tankCount == 1 ? tankX : (w - tankCount * tw) / 2 + i * tw;
+
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = tc.Name,
+                    DeviceType = "Tank",
+                    Bounds = new Rect(tx, tankTopY, tw, TankHeight),
+                    Parameters = new()
+                    {
+                        ["Color"] = TankColors[i % TankColors.Length],
+                        ["Index"] = i,
+                        ["MaxLevel"] = maxLevel,
+                    }
+                });
+            }
+        }
+
+        // ─── 2. 泵组:中部均分,在集水池上方 ───
+        var pumpCount = profile.Pumps.Count;
+        if (pumpCount > 0)
+        {
+            var pumpSpacing = w / (pumpCount + 1);
+
+            for (int i = 0; i < pumpCount; i++)
+            {
+                var pumpX = pumpSpacing * (i + 1);
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Pumps[i].Name,
+                    DeviceType = "Pump",
+                    Bounds = new Rect(pumpX - PumpRadius, pumpCenterY - PumpRadius,
+                                      PumpRadius * 2, PumpRadius * 2),
+                    Parameters = new()
+                    {
+                        ["Index"] = i,
+                        ["CenterX"] = pumpX,
+                        ["CenterY"] = pumpCenterY,
+                    }
+                });
+            }
+        }
+
+        // ─── 3. 仪表 + 传感器:底部统一排列 ───
+        var totalInfoCount = profile.Gauges.Count + profile.Sensors.Count;
+        if (totalInfoCount > 0)
+        {
+            var infoY = h * InfoZoneY;
+            var infoSpacing = w / (totalInfoCount + 1);
+
+            for (int i = 0; i < profile.Gauges.Count; i++)
+            {
+                var gx = infoSpacing * (i + 1) - GaugeW / 2;
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Gauges[i].Name,
+                    DeviceType = "Gauge",
+                    Bounds = new Rect(gx, infoY, GaugeW, GaugeH),
+                    Parameters = new() { ["Unit"] = profile.Gauges[i].Unit, ["Index"] = i }
+                });
+            }
+
+            for (int i = 0; i < profile.Sensors.Count; i++)
+            {
+                var si = profile.Gauges.Count + i;
+                var sx = infoSpacing * (si + 1) - 45;
+                devices.Add(new DeviceLayout
+                {
+                    DeviceId = profile.Sensors[i].Name,
+                    DeviceType = "Sensor",
+                    Bounds = new Rect(sx, infoY, 90, GaugeH),
+                    Parameters = new() { ["Unit"] = profile.Sensors[i].Unit, ["Index"] = i }
+                });
+            }
+        }
+
+        // ─── 4. 管道连接 ───
+        var decorations = new List<SceneDecoration>();
+
+        // 4a. 出水总管(顶部水平管道)
+        if (pumpCount >= 2)
+        {
+            var firstPx = (double)devices.Find(d => d.DeviceId == profile.Pumps[0].Name)!.Parameters["CenterX"];
+            var lastPx = (double)devices.Find(d => d.DeviceId == profile.Pumps[pumpCount - 1].Name)!.Parameters["CenterX"];
+
+            // 扩展总管到两侧
+            pipes.Add(new PipeSegment
+            {
+                From = new Point(firstPx - 60, headerY),
+                To = new Point(lastPx + 60, headerY),
+                IsFlowing = true,
+                Thickness = 12,
+            });
+
+            // 管道压力标签:总管正上方(仿触摸屏"管道压力"样式)
+            var pressureGauge = profile.Gauges.FirstOrDefault(g => g.Name.Contains("压力"));
+            if (pressureGauge != null)
+            {
+                decorations.Add(new SceneDecoration
+                {
+                    Kind = "PressureGauge",
+                    Position = new Point((firstPx + lastPx) / 2, headerY - 24),
+                    Size = 18,
+                    Orientation = "H",
+                    DataKey = $"{pressureGauge.Name}.Value",
+                    Unit = pressureGauge.Unit,
+                });
+            }
+
+            // 电磁流量计:总管右段(表头显示流量值)
+            var flowGauge = profile.Gauges.FirstOrDefault(g => g.Name.Contains("流量"));
+            if (flowGauge != null)
+            {
+                decorations.Add(new SceneDecoration
+                {
+                    Kind = "FlowMeter",
+                    Position = new Point(lastPx + 30, headerY),
+                    Size = 26,
+                    Orientation = "H",
+                    DataKey = $"{flowGauge.Name}.Value",
+                    Unit = flowGauge.Unit,
+                });
+            }
+        }
+
+        // 4b. 泵排出管:电机顶部 → 出水总管(立式泵整体高度 ≈ 2.6×PumpRadius)
+        for (int i = 0; i < pumpCount; i++)
+        {
+            var pump = devices.Find(d => d.DeviceId == profile.Pumps[i].Name);
+            if (pump == null) continue;
+            var pcx = (double)pump.Parameters["CenterX"];
+            var pipeTop = pumpCenterY - PumpRadius * 2.4;
+
+            pipes.Add(new PipeSegment
+            {
+                From = new Point(pcx, pipeTop),
+                To = new Point(pcx, headerY),
+                IsFlowing = true,
+                Thickness = 8,
+            });
+
+            // 组态标配:止回阀(靠近泵,防倒转)+ 闸阀(排管中上段)
+            decorations.Add(new SceneDecoration
+            {
+                Kind = "CheckValve",
+                Position = new Point(pcx, pumpCenterY - PumpRadius * 1.55),
+                Size = 16,
+                Orientation = "V",
+            });
+            decorations.Add(new SceneDecoration
+            {
+                Kind = "GateValve",
+                Position = new Point(pcx, (pipeTop + headerY) / 2),
+                Size = 16,
+                Orientation = "V",
+            });
+        }
+
+        // 4c. 泵吸入管:集水池内 → 泵基座(立式泵吸入管插入池中)
+        if (tankCount > 0 && pumpCount > 0)
+        {
+            var tank = devices.Find(d => d.DeviceType == "Tank");
+            if (tank != null)
+            {
+                for (int i = 0; i < pumpCount; i++)
+                {
+                    var pump = devices.Find(d => d.DeviceId == profile.Pumps[i].Name);
+                    if (pump == null) continue;
+                    var pcx = (double)pump.Parameters["CenterX"];
+
+                    // 垂直管从池内液面区到泵基座
+                    pipes.Add(new PipeSegment
+                    {
+                        From = new Point(pcx, tankTopY + 30),
+                        To = new Point(pcx, pumpCenterY + PumpRadius * 1.4),
+                        IsFlowing = false,
+                        Thickness = 8,
+                    });
+                }
+            }
+        }
+
+        // 4d. 池子进水管(左侧来水,带进水闸阀)——组态标配
+        if (tankCount > 0)
+        {
+            var tank = devices.Find(d => d.DeviceType == "Tank");
+            if (tank != null)
+            {
+                var inletY = tank.Bounds.Y + tank.Bounds.Height / 2;
+                var startX = 36;
+                var endX = tank.Bounds.X + 4; // 到池左壁
+
+                pipes.Add(new PipeSegment
+                {
+                    From = new Point(startX, inletY),
+                    To = new Point(endX, inletY),
+                    IsFlowing = true,
+                    Thickness = 10,
+                });
+
+                // 进水蝶阀(蝴蝶结符号+手轮柄,水平方向)
+                decorations.Add(new SceneDecoration
+                {
+                    Kind = "ButterflyValve",
+                    Position = new Point((startX + endX) / 2, inletY),
+                    Size = 18,
+                    Orientation = "H",
+                });
+            }
+        }
+
+        return new LayoutResult { Devices = devices, Pipes = pipes, Decorations = decorations };
+    }
+}

File diff suppressed because it is too large
+ 888 - 41
src/YZWater.Avalonia/Controls/PumpStation2DControl.cs


+ 344 - 71
src/YZWater.Avalonia/Controls/PumpStation3DControl.cs

@@ -1,25 +1,70 @@
 using Avalonia;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls;
+using Avalonia.Input;
 using Avalonia.Media;
 using Avalonia.Media;
 using Avalonia.Threading;
 using Avalonia.Threading;
 using System;
 using System;
 using System.Collections.Generic;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
+using System.Linq;
+using System.Windows.Input;
 using YZWater.Core.ViewModels;
 using YZWater.Core.ViewModels;
+using YZWater.Core.Models;
+using YZWater.Avalonia.Controls.Layout;
 
 
 namespace YZWater.Avalonia.Controls;
 namespace YZWater.Avalonia.Controls;
 
 
 /// <summary>
 /// <summary>
-/// 通用污水提升泵站 2.5D 工艺总览控件。
+/// 通用污水提升泵站 2.5D 工艺总览控件(等轴测投影)。
 ///
 ///
-/// <para><b>注意:此控件已废弃,将在未来版本移除。</b></para>
-///
-/// <para>此控件使用固定布局(4水箱 + 5泵 + 2风机),仅适用于默认项目配置。</para>
-/// <para>对于动态设备数量的项目档案,请使用 <c>ProjectView</c>(基于 ItemsControl 动态生成)。</para>
+/// <para>支持两种模式:</para>
+/// <list type="bullet">
+/// <item><b>动态模式</b>:设置 <see cref="Profile"/> 后,根据项目档案自动布局渲染</item>
+/// <item><b>兼容模式</b>:使用旧版硬编码属性,保持向后兼容</item>
+/// </list>
 /// </summary>
 /// </summary>
-[Obsolete("PumpStation3DControl 使用硬编码设备数量,不支持项目档案动态适配。请使用 ProjectView 替代。")]
 public class PumpStation3DControl : Control
 public class PumpStation3DControl : Control
 {
 {
+    // ═══ 动态模式属性 ═══
+
+    public static readonly StyledProperty<ProjectProfile?> ProfileProperty =
+        AvaloniaProperty.Register<PumpStation3DControl, ProjectProfile?>(nameof(Profile));
+
+    public static readonly StyledProperty<IDictionary<string, float>?> AnalogValuesProperty =
+        AvaloniaProperty.Register<PumpStation3DControl, IDictionary<string, float>?>(nameof(AnalogValues));
+
+    public static readonly StyledProperty<IDictionary<string, bool>?> DigitalValuesProperty =
+        AvaloniaProperty.Register<PumpStation3DControl, IDictionary<string, bool>?>(nameof(DigitalValues));
+
+    public static readonly StyledProperty<ICommand?> DeviceClickedCommandProperty =
+        AvaloniaProperty.Register<PumpStation3DControl, ICommand?>(nameof(DeviceClickedCommand));
+
+    public ProjectProfile? Profile
+    {
+        get => GetValue(ProfileProperty);
+        set => SetValue(ProfileProperty, value);
+    }
+
+    public IDictionary<string, float>? AnalogValues
+    {
+        get => GetValue(AnalogValuesProperty);
+        set => SetValue(AnalogValuesProperty, value);
+    }
+
+    public IDictionary<string, bool>? DigitalValues
+    {
+        get => GetValue(DigitalValuesProperty);
+        set => SetValue(DigitalValuesProperty, value);
+    }
+
+    public ICommand? DeviceClickedCommand
+    {
+        get => GetValue(DeviceClickedCommandProperty);
+        set => SetValue(DeviceClickedCommandProperty, value);
+    }
+
+    // ═══ 旧版硬编码属性 ═══
+
     public static readonly StyledProperty<double> Tank1LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank1Level));
     public static readonly StyledProperty<double> Tank1LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank1Level));
     public static readonly StyledProperty<double> Tank2LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank2Level));
     public static readonly StyledProperty<double> Tank2LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank2Level));
     public static readonly StyledProperty<double> Tank3LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank3Level));
     public static readonly StyledProperty<double> Tank3LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank3Level));
@@ -68,10 +113,15 @@ public class PumpStation3DControl : Control
     public int RunningDeviceCount { get => GetValue(RunningDeviceCountProperty); set => SetValue(RunningDeviceCountProperty, value); }
     public int RunningDeviceCount { get => GetValue(RunningDeviceCountProperty); set => SetValue(RunningDeviceCountProperty, value); }
     public double Efficiency { get => GetValue(EfficiencyProperty); set => SetValue(EfficiencyProperty, value); }
     public double Efficiency { get => GetValue(EfficiencyProperty); set => SetValue(EfficiencyProperty, value); }
 
 
+    // ═══ 动画 & 布局 ═══
+
     private double _phase;
     private double _phase;
+    private double _faultFlash;
     private IDisposable? _timer;
     private IDisposable? _timer;
+    private ILayoutEngine _layoutEngine = new DefaultLayoutEngine();
+    private LayoutResult? _cachedLayout;
+    private Size _lastCanvasSize;
 
 
-    // ═══ 静态画刷缓存(避免每帧 new SolidColorBrush) ═══
     private static readonly SolidColorBrush FlowDotBrush3D = new(Color.FromRgb(3, 169, 244));
     private static readonly SolidColorBrush FlowDotBrush3D = new(Color.FromRgb(3, 169, 244));
     private static readonly SolidColorBrush PumpBodyBrush = new(Color.FromRgb(31, 41, 55));
     private static readonly SolidColorBrush PumpBodyBrush = new(Color.FromRgb(31, 41, 55));
 
 
@@ -84,7 +134,25 @@ public class PumpStation3DControl : Control
             Fan1RunningProperty, Fan2RunningProperty,
             Fan1RunningProperty, Fan2RunningProperty,
             Valve1StatusProperty, Valve2StatusProperty, Valve3StatusProperty, Valve4StatusProperty,
             Valve1StatusProperty, Valve2StatusProperty, Valve3StatusProperty, Valve4StatusProperty,
             IsInflowRunningProperty, IsOutflowRunningProperty, HasAlarmProperty, AlarmCountProperty,
             IsInflowRunningProperty, IsOutflowRunningProperty, HasAlarmProperty, AlarmCountProperty,
-            RunningDeviceCountProperty, EfficiencyProperty);
+            RunningDeviceCountProperty, EfficiencyProperty,
+            ProfileProperty, AnalogValuesProperty, DigitalValuesProperty);
+    }
+
+    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+    {
+        base.OnPropertyChanged(change);
+        if (change.Property == ProfileProperty)
+        {
+            if (change.NewValue is ProjectProfile newProfile)
+                _layoutEngine = LayoutEngineFactory.Create(newProfile);
+            _cachedLayout = null;
+            InvalidateVisual();
+        }
+        else if (change.Property == BoundsProperty)
+        {
+            _cachedLayout = null;
+            InvalidateVisual();
+        }
     }
     }
 
 
     protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
@@ -93,12 +161,11 @@ public class PumpStation3DControl : Control
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
         _timer = DispatcherTimer.Run(() =>
         _timer = DispatcherTimer.Run(() =>
         {
         {
-            var needsAnimation = AnyPumpRunning || Fan1Running || Fan2Running
-                || IsInflowRunning || IsOutflowRunning;
-
-            if (needsAnimation)
+            var needsAnim = IsDynamicMode ? HasDynamicAnimation : HasLegacyAnimation;
+            if (needsAnim)
             {
             {
                 _phase = (_phase + 1) % 120;
                 _phase = (_phase + 1) % 120;
+                _faultFlash = (_faultFlash + 0.025) % 1.0;
                 InvalidateVisual();
                 InvalidateVisual();
             }
             }
             return true;
             return true;
@@ -113,25 +180,243 @@ public class PumpStation3DControl : Control
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
-    private void OnThemeChanged() => InvalidateVisual();
+    private void OnThemeChanged() { _cachedLayout = null; InvalidateVisual(); }
+
+    // ═══ 模式判断 ═══
+
+    public bool IsDynamicMode => Profile != null;
+
+    private bool HasDynamicAnimation =>
+        _cachedLayout?.Devices.Any(d => d.DeviceType == "Pump" && GetDigitalValue($"{d.DeviceId}.Run")) == true;
+
+    private bool HasLegacyAnimation =>
+        AnyPumpRunning || Fan1Running || Fan2Running || IsInflowRunning || IsOutflowRunning;
+
+    private bool AnyPumpRunning =>
+        Pump1Running || Pump2Running || Pump3Running || Pump4Running || Pump5Running;
+
+    // ═══ 值读取 ═══
+
+    private float GetAnalogValue(string key) =>
+        AnalogValues != null && AnalogValues.TryGetValue(key, out var v) ? v : 0f;
+
+    private bool GetDigitalValue(string key) =>
+        DigitalValues != null && DigitalValues.TryGetValue(key, out var v) && v;
+
+    // ═══ 主渲染 ═══
 
 
     public override void Render(DrawingContext context)
     public override void Render(DrawingContext context)
     {
     {
         base.Render(context);
         base.Render(context);
-
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
-        if (bounds.Width < 120 || bounds.Height < 120)
-            return;
-
+        if (bounds.Width < 120 || bounds.Height < 120) return;
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
 
 
+        if (IsDynamicMode)
+            RenderDynamic(context, bounds);
+        else
+            RenderLegacy(context, bounds);
+    }
+
+    // ════════════════════════════════════════════════════════
+    //  动态模式
+    // ════════════════════════════════════════════════════════
+
+    private void RenderDynamic(DrawingContext context, Rect bounds)
+    {
+        var profile = Profile!;
+        var scene = new Rect(bounds.X + 22, bounds.Y + 16, Math.Max(0, bounds.Width - 44), Math.Max(0, bounds.Height - 32));
+        var scale = Math.Min(scene.Width / 960.0, scene.Height / 540.0);
+        var origin = new Point(scene.Center.X - 132 * scale, scene.Y + scene.Height * 0.10);
+
+        // 缓存布局
+        var designSize = new Size(960, 540);
+        if (_cachedLayout == null || _lastCanvasSize != designSize)
+        {
+            _cachedLayout = _layoutEngine.Calculate(profile, designSize);
+            _lastCanvasSize = designSize;
+        }
+
+        DrawFloor(context, origin, scale);
+
+        // 绘制管道 — 流动状态根据泵运行状态动态判断
+        var anyPumpRunning = _cachedLayout.Devices.Any(d =>
+            d.DeviceType == "Pump" && GetDigitalValue($"{d.DeviceId}.Run"));
+
+        foreach (var pipe in _cachedLayout.Pipes)
+        {
+            var from = new Point(pipe.From.X, pipe.From.Y);
+            var to = new Point(pipe.To.X, pipe.To.Y);
+            var flowing = pipe.IsFlowing && anyPumpRunning;
+            DrawPipe(context, origin, scale, from, to, flowing, 90);
+        }
+
+        // 绘制设备
+        foreach (var device in _cachedLayout.Devices)
+        {
+            var cx = device.Bounds.Center.X;
+            var cy = device.Bounds.Center.Y;
+
+            switch (device.DeviceType)
+            {
+                case "Tank":
+                    var level = (double)GetAnalogValue($"{device.DeviceId}.Level");
+                    DrawDynamicTank3D(context, origin, scale, cx, cy, device);
+                    break;
+                case "Pump":
+                    var running = GetDigitalValue($"{device.DeviceId}.Run");
+                    var fault = GetDigitalValue($"{device.DeviceId}.Fault");
+                    DrawDynamicPump3D(context, origin, scale, cx, cy, device.DeviceId, running, fault);
+                    break;
+                case "Gauge":
+                    var gVal = (double)GetAnalogValue($"{device.DeviceId}.Value");
+                    var unit = device.Parameters.TryGetValue("Unit", out var gu) ? gu.ToString() ?? "" : "";
+                    DrawDynamicInfo3D(context, origin, scale, cx, cy, device.DeviceId, $"{gVal:F1} {unit}");
+                    break;
+                case "Sensor":
+                    var sVal = (double)GetAnalogValue($"{device.DeviceId}.Value");
+                    var su = device.Parameters.TryGetValue("Unit", out var su2) ? su2.ToString() ?? "" : "";
+                    DrawDynamicInfo3D(context, origin, scale, cx, cy, device.DeviceId, $"{sVal:F1} {su}");
+                    break;
+                case "Fan":
+                    DrawDynamicFan3D(context, origin, scale, cx, cy, device);
+                    break;
+                case "Valve":
+                    DrawDynamicValve3D(context, origin, scale, cx, cy, device);
+                    break;
+            }
+        }
+
+        // 标题 HUD
+        var hudP = new Rect(scene.X + 10, scene.Y + 10, 220 * scale, 50 * scale);
+        context.DrawRectangle(new SolidColorBrush(GetColor(ThemeHelper.SurfaceBg), 0.9), new Pen(ThemeHelper.Border, 1), hudP, 4 * scale);
+        DrawText(context, profile.Name, hudP.Position + new Point(12 * scale, 8 * scale), 13 * scale, ThemeHelper.TextPrimary, FontWeight.Bold);
+        DrawText(context, profile.Description, hudP.Position + new Point(12 * scale, 28 * scale), 10 * scale, ThemeHelper.TextTertiary, FontWeight.Bold);
+
+        // 报警边框
+        if (GetDigitalValue("Alarm.Active") || HasAlarm)
+            context.DrawRectangle(null, new Pen(ThemeHelper.Danger, 2), scene, 4);
+    }
+
+    private void DrawDynamicTank3D(DrawingContext context, Point origin, double scale, double designX, double designY, DeviceLayout device)
+    {
+        var rawLevel = GetAnalogValue($"{device.DeviceId}.Level");
+        var maxLevel = device.Parameters.TryGetValue("MaxLevel", out var ml) ? Convert.ToSingle(ml) : 10.0f;
+        if (maxLevel <= 0) maxLevel = 10.0f;
+        var levelPct = rawLevel / maxLevel * 100.0f;
+
+        // 用 3D 盒子表示水箱
+        var w = 120.0;
+        var d = 100.0;
+        var h = 80.0;
+        var p = new Point(designX - w / 2, designY - d / 2);
+
+        DrawBox(context, origin, scale, p, w, d, h, Color.FromRgb(46, 59, 77), new SolidColorBrush(GetColor(ThemeHelper.Border)));
+
+        // 水位(百分比)
+        var waterH = h * Math.Clamp(levelPct, 0, 100) / 100.0;
+        if (waterH > 2)
+            DrawBox(context, origin, scale, new Point(p.X + 8, p.Y + 7), w - 16, d - 14, waterH,
+                Color.FromRgb(33, 150, 243), null, 0.62);
+
+        // 标签
+        var top = Iso(new Point(p.X + w * 0.52, p.Y + d * 0.22), h + 12, origin, scale);
+        DrawText(context, device.DeviceId, top + new Point(-30 * scale, -28 * scale), 12 * scale, ThemeHelper.TextPrimary, FontWeight.Bold);
+        DrawText(context, $"{rawLevel:F2}m ({levelPct:F0}%)", top + new Point(-30 * scale, -12 * scale), 11 * scale, ThemeHelper.Info, FontWeight.Bold);
+    }
+
+    private void DrawDynamicPump3D(DrawingContext context, Point origin, double scale, double designX, double designY, string name, bool running, bool fault)
+    {
+        var p = new Point(designX, designY);
+        var status = fault
+            ? (Math.Sin(_faultFlash * Math.PI * 2) > 0 ? ThemeHelper.Danger : ThemeHelper.TextDisabled)
+            : (running ? ThemeHelper.Success : ThemeHelper.TextDisabled);
+
+        // 立管(垂直管道)
+        var basePoint = Iso(p, 10, origin, scale);
+        var topPoint = Iso(p, 90, origin, scale);
+        context.DrawLine(new Pen(new SolidColorBrush(Darken(StructureColor, 0.70)), 12 * scale), basePoint, topPoint);
+        context.DrawLine(new Pen(status, 4 * scale), basePoint, topPoint);
+
+        // 泵体(椭圆 + 圆角矩形)
+        var c = Iso(p, 30, origin, scale);
+        var radius = 15 * scale;
+        var body = new Rect(c.X - radius, c.Y - radius * 1.4, radius * 2, radius * 2.25);
+        context.DrawRectangle(new SolidColorBrush(Darken(StructureColor, 0.78)),
+            new Pen(new SolidColorBrush(GetColor(status)), Math.Max(1, 1.4 * scale)), body, radius * 0.42);
+        context.DrawEllipse(new SolidColorBrush(Lighten(StructureColor, 0.12)),
+            new Pen(new SolidColorBrush(GetColor(status)), Math.Max(1, scale)),
+            new Rect(c.X - radius, c.Y - radius * 1.7, radius * 2, radius * 0.78));
+
+        // 运行指示灯
+        context.DrawEllipse(new SolidColorBrush(running ? GetColor(ThemeHelper.Success) : GetColor(ThemeHelper.TextDisabled)),
+            null, new Rect(c.X - 3 * scale, c.Y - radius * 0.55, 6 * scale, 6 * scale));
+
+        DrawText(context, name, c + new Point(-12 * scale, radius * 1.3), 10 * scale, ThemeHelper.TextPrimary, FontWeight.Bold);
+
+        // 故障标记
+        if (fault)
+            DrawText(context, "!", c + new Point(radius + 2 * scale, -radius * 1.5), 14 * scale, ThemeHelper.Danger, FontWeight.Bold);
+    }
+
+    private void DrawDynamicInfo3D(DrawingContext context, Point origin, double scale, double designX, double designY, string name, string valueText)
+    {
+        // 用 3D 盒子表示仪表/传感器
+        var bw = 80.0;
+        var bd = 40.0;
+        var bh = 30.0;
+        var p = new Point(designX - bw / 2, designY - bd / 2);
+
+        DrawBox(context, origin, scale, p, bw, bd, bh, Color.FromRgb(46, 59, 77), new SolidColorBrush(GetColor(ThemeHelper.Border)));
+
+        // 面板上的文字
+        var top = Iso(new Point(p.X + bw * 0.5, p.Y + bd * 0.3), bh + 6, origin, scale);
+        DrawText(context, name, top + new Point(-20 * scale, -18 * scale), 9 * scale, ThemeHelper.TextTertiary, FontWeight.Bold);
+        DrawText(context, valueText, top + new Point(-24 * scale, -4 * scale), 11 * scale, ThemeHelper.Success, FontWeight.Bold);
+    }
+
+    private void DrawDynamicFan3D(DrawingContext context, Point origin, double scale, double designX, double designY, DeviceLayout device)
+    {
+        var running = GetDigitalValue($"{device.DeviceId}.Run");
+        var c = Iso(new Point(designX, designY), 34, origin, scale);
+        var r = 24 * scale;
+        var status = running ? GetColor(ThemeHelper.Info) : GetColor(ThemeHelper.TextDisabled);
+
+        context.DrawEllipse(PumpBodyBrush, new Pen(new SolidColorBrush(status), 1.5 * scale), new Rect(c.X - r, c.Y - r, r * 2, r * 2));
+        for (var i = 0; i < 5; i++)
+        {
+            var a = (_phase * 7 + i * 72) * Math.PI / 180.0;
+            context.DrawLine(new Pen(new SolidColorBrush(status, running ? 0.9 : 0.45), 5 * scale), c,
+                new Point(c.X + Math.Cos(a) * r * 0.7, c.Y + Math.Sin(a) * r * 0.7));
+        }
+        context.DrawEllipse(new SolidColorBrush(status), null, new Rect(c.X - 5 * scale, c.Y - 5 * scale, 10 * scale, 10 * scale));
+        DrawText(context, device.DeviceId, c + new Point(-10 * scale, r + 5 * scale), 11 * scale, ThemeHelper.TextPrimary, FontWeight.Bold);
+    }
+
+    private void DrawDynamicValve3D(DrawingContext context, Point origin, double scale, double designX, double designY, DeviceLayout device)
+    {
+        var isOpen = GetDigitalValue($"{device.DeviceId}.Open");
+        var c = Iso(new Point(designX, designY), 42, origin, scale);
+        var color = isOpen ? GetColor(ThemeHelper.Success) : GetColor(ThemeHelper.Danger);
+        var s = 18 * scale;
+        var points = new[] { c + new Point(0, -s), c + new Point(s, 0), c + new Point(0, s), c + new Point(-s, 0) };
+        context.DrawGeometry(new SolidColorBrush(color, 0.85), new Pen(ThemeHelper.Border, 1), Polygon(points));
+        context.DrawLine(new Pen(ThemeHelper.TextPrimary, 2 * scale), c + new Point(-s * 0.55, 0), c + new Point(s * 0.55, 0));
+        DrawText(context, device.DeviceId, c + new Point(-10 * scale, s + 3 * scale), 10 * scale, ThemeHelper.TextSecondary, FontWeight.Bold);
+    }
+
+    // ════════════════════════════════════════════════════════
+    //  兼容模式(旧版硬编码)
+    // ════════════════════════════════════════════════════════
+
+    private void RenderLegacy(DrawingContext context, Rect bounds)
+    {
         var scene = new Rect(bounds.X + 22, bounds.Y + 16, Math.Max(0, bounds.Width - 44), Math.Max(0, bounds.Height - 32));
         var scene = new Rect(bounds.X + 22, bounds.Y + 16, Math.Max(0, bounds.Width - 44), Math.Max(0, bounds.Height - 32));
         var scale = Math.Min(scene.Width / 960.0, scene.Height / 540.0);
         var scale = Math.Min(scene.Width / 960.0, scene.Height / 540.0);
         var origin = new Point(scene.Center.X - 132 * scale, scene.Y + scene.Height * 0.10);
         var origin = new Point(scene.Center.X - 132 * scale, scene.Y + scene.Height * 0.10);
 
 
         DrawFloor(context, origin, scale);
         DrawFloor(context, origin, scale);
 
 
-        // 工艺主线:进水渠道 -> 格栅 -> 集水湿井 -> 潜水泵 -> 阀门井 -> 出水总管。
         DrawChannel(context, origin, scale, new Point(40, 188), "进水渠道", Tank1Level);
         DrawChannel(context, origin, scale, new Point(40, 188), "进水渠道", Tank1Level);
         DrawWetWell(context, origin, scale, new Point(255, 145), Tank2Level);
         DrawWetWell(context, origin, scale, new Point(255, 145), Tank2Level);
         DrawValveChamber(context, origin, scale, new Point(520, 172), Tank4Level);
         DrawValveChamber(context, origin, scale, new Point(520, 172), Tank4Level);
@@ -157,13 +442,41 @@ public class PumpStation3DControl : Control
         DrawHud(context, scene, scale);
         DrawHud(context, scene, scale);
     }
     }
 
 
-    private bool AnyPumpRunning => Pump1Running || Pump2Running || Pump3Running || Pump4Running || Pump5Running;
+    // ═══ 点击事件 ═══
 
 
-    private Point Iso(Point p, double z, Point origin, double scale)
+    protected override void OnPointerPressed(PointerPressedEventArgs e)
     {
     {
-        return new Point(origin.X + (p.X - p.Y) * 0.78 * scale, origin.Y + (p.X + p.Y) * 0.38 * scale - z * scale);
+        base.OnPointerPressed(e);
+        if (!IsDynamicMode || _cachedLayout == null) return;
+
+        var pos = e.GetPosition(this);
+        // 反向映射到设计坐标
+        var scene = new Rect(Bounds.X + 22, Bounds.Y + 16, Math.Max(0, Bounds.Width - 44), Math.Max(0, Bounds.Height - 32));
+        var scale = Math.Min(scene.Width / 960.0, scene.Height / 540.0);
+        var origin = new Point(scene.Center.X - 132 * scale, scene.Y + scene.Height * 0.10);
+
+        // 简单近似:检查每个设备的设计坐标范围
+        foreach (var device in _cachedLayout.Devices)
+        {
+            var dc = Iso(device.Bounds.Center, 40, origin, scale);
+            var dist = Math.Sqrt(Math.Pow(pos.X - dc.X, 2) + Math.Pow(pos.Y - dc.Y, 2));
+            if (dist < 30 * scale)
+            {
+                if (DeviceClickedCommand?.CanExecute(device.DeviceId) == true)
+                {
+                    DeviceClickedCommand.Execute(device.DeviceId);
+                    e.Handled = true;
+                }
+                return;
+            }
+        }
     }
     }
 
 
+    // ═══ 等轴测投影 & 绘制辅助(保留原有逻辑)═══
+
+    private Point Iso(Point p, double z, Point origin, double scale) =>
+        new(origin.X + (p.X - p.Y) * 0.78 * scale, origin.Y + (p.X + p.Y) * 0.38 * scale - z * scale);
+
     private void DrawFloor(DrawingContext context, Point origin, double scale)
     private void DrawFloor(DrawingContext context, Point origin, double scale)
     {
     {
         var floor = new[] { Iso(new Point(0, 85), 0, origin, scale), Iso(new Point(875, 85), 0, origin, scale), Iso(new Point(875, 430), 0, origin, scale), Iso(new Point(0, 430), 0, origin, scale) };
         var floor = new[] { Iso(new Point(0, 85), 0, origin, scale), Iso(new Point(875, 85), 0, origin, scale), Iso(new Point(875, 430), 0, origin, scale), Iso(new Point(0, 430), 0, origin, scale) };
@@ -189,9 +502,7 @@ public class PumpStation3DControl : Control
 
 
     private void DrawWetWell(DrawingContext context, Point origin, double scale, Point p, double level)
     private void DrawWetWell(DrawingContext context, Point origin, double scale, Point p, double level)
     {
     {
-        const double width = 255;
-        const double depth = 214;
-        const double wallHeight = 82;
+        const double width = 255, depth = 214, wallHeight = 82;
         DrawCutawayWellWalls(context, origin, scale, p, width, depth, wallHeight);
         DrawCutawayWellWalls(context, origin, scale, p, width, depth, wallHeight);
         var waterHeight = 14 + 74 * Math.Clamp(level, 0, 100) / 100;
         var waterHeight = 14 + 74 * Math.Clamp(level, 0, 100) / 100;
         var water = new[]
         var water = new[]
@@ -211,23 +522,10 @@ public class PumpStation3DControl : Control
     private void DrawCutawayWellWalls(DrawingContext context, Point origin, double scale, Point p, double width, double depth, double height)
     private void DrawCutawayWellWalls(DrawingContext context, Point origin, double scale, Point p, double width, double depth, double height)
     {
     {
         var wallBrush = new SolidColorBrush(StructureColor, 0.96);
         var wallBrush = new SolidColorBrush(StructureColor, 0.96);
-        var rearWall = new[]
-        {
-            Iso(p, 0, origin, scale),
-            Iso(new Point(p.X + width, p.Y), 0, origin, scale),
-            Iso(new Point(p.X + width, p.Y), height, origin, scale),
-            Iso(p, height, origin, scale)
-        };
-        var sideWall = new[]
-        {
-            Iso(new Point(p.X + width, p.Y), 0, origin, scale),
-            Iso(new Point(p.X + width, p.Y + depth), 0, origin, scale),
-            Iso(new Point(p.X + width, p.Y + depth), height, origin, scale),
-            Iso(new Point(p.X + width, p.Y), height, origin, scale)
-        };
+        var rearWall = new[] { Iso(p, 0, origin, scale), Iso(new Point(p.X + width, p.Y), 0, origin, scale), Iso(new Point(p.X + width, p.Y), height, origin, scale), Iso(p, height, origin, scale) };
+        var sideWall = new[] { Iso(new Point(p.X + width, p.Y), 0, origin, scale), Iso(new Point(p.X + width, p.Y + depth), 0, origin, scale), Iso(new Point(p.X + width, p.Y + depth), height, origin, scale), Iso(new Point(p.X + width, p.Y), height, origin, scale) };
         context.DrawGeometry(wallBrush, new Pen(ThemeHelper.Border, Math.Max(1, scale)), Polygon(rearWall));
         context.DrawGeometry(wallBrush, new Pen(ThemeHelper.Border, Math.Max(1, scale)), Polygon(rearWall));
         context.DrawGeometry(new SolidColorBrush(Darken(StructureColor, 0.82), 0.98), new Pen(ThemeHelper.Border, Math.Max(1, scale)), Polygon(sideWall));
         context.DrawGeometry(new SolidColorBrush(Darken(StructureColor, 0.82), 0.98), new Pen(ThemeHelper.Border, Math.Max(1, scale)), Polygon(sideWall));
-
         var rimPen = new Pen(ThemeHelper.TextSecondary, Math.Max(1.2, 1.5 * scale));
         var rimPen = new Pen(ThemeHelper.TextSecondary, Math.Max(1.2, 1.5 * scale));
         context.DrawLine(rimPen, Iso(p, height, origin, scale), Iso(new Point(p.X + width, p.Y), height, origin, scale));
         context.DrawLine(rimPen, Iso(p, height, origin, scale), Iso(new Point(p.X + width, p.Y), height, origin, scale));
         context.DrawLine(rimPen, Iso(new Point(p.X + width, p.Y), height, origin, scale), Iso(new Point(p.X + width, p.Y + depth), height, origin, scale));
         context.DrawLine(rimPen, Iso(new Point(p.X + width, p.Y), height, origin, scale), Iso(new Point(p.X + width, p.Y + depth), height, origin, scale));
@@ -260,26 +558,6 @@ public class PumpStation3DControl : Control
         DrawPump(context, origin, scale, p, label, running);
         DrawPump(context, origin, scale, p, label, running);
     }
     }
 
 
-    private void DrawTank(DrawingContext context, Point origin, double scale, string name, string code, Point p, double w, double d, double h, double level, Color waterColor)
-    {
-        DrawBox(context, origin, scale, p, w, d, h, Color.FromRgb(46, 59, 77), new SolidColorBrush(GetColor(ThemeHelper.Border)));
-
-        var waterHeight = h * Math.Clamp(level, 0, 100) / 100.0;
-        if (waterHeight > 2)
-            DrawBox(context, origin, scale, new Point(p.X + 8, p.Y + 7), w - 16, d - 14, waterHeight, waterColor, null, 0.62);
-
-        var top = Iso(new Point(p.X + w * 0.52, p.Y + d * 0.22), h + 12, origin, scale);
-        DrawText(context, name, top + new Point(-34 * scale, -30 * scale), 12 * scale, ThemeHelper.TextPrimary, FontWeight.Bold);
-        DrawText(context, $"{code}  {level:F1}%", top + new Point(-34 * scale, -14 * scale), 11 * scale, new SolidColorBrush(waterColor), FontWeight.Bold);
-    }
-
-    private void DrawStructure(DrawingContext context, Point origin, double scale, Point p, double w, double d, double h, string label)
-    {
-        DrawBox(context, origin, scale, p, w, d, h, Color.FromRgb(61, 72, 92), new SolidColorBrush(GetColor(ThemeHelper.Border)), 0.95);
-        var textPoint = Iso(new Point(p.X + w * 0.36, p.Y + d * 0.1), h + 8, origin, scale);
-        DrawText(context, label, textPoint + new Point(-24 * scale, -20 * scale), 11 * scale, ThemeHelper.TextSecondary, FontWeight.Bold);
-    }
-
     private void DrawBox(DrawingContext context, Point origin, double scale, Point p, double w, double d, double h, Color color, IBrush? stroke, double opacity = 1)
     private void DrawBox(DrawingContext context, Point origin, double scale, Point p, double w, double d, double h, Color color, IBrush? stroke, double opacity = 1)
     {
     {
         var top = new[] { Iso(p, h, origin, scale), Iso(new Point(p.X + w, p.Y), h, origin, scale), Iso(new Point(p.X + w, p.Y + d), h, origin, scale), Iso(new Point(p.X, p.Y + d), h, origin, scale) };
         var top = new[] { Iso(p, h, origin, scale), Iso(new Point(p.X + w, p.Y), h, origin, scale), Iso(new Point(p.X + w, p.Y + d), h, origin, scale), Iso(new Point(p.X, p.Y + d), h, origin, scale) };
@@ -299,14 +577,12 @@ public class PumpStation3DControl : Control
         context.DrawLine(new Pen(new SolidColorBrush(Darken(StructureColor, 0.70)), 16 * scale), a, b);
         context.DrawLine(new Pen(new SolidColorBrush(Darken(StructureColor, 0.70)), 16 * scale), a, b);
         context.DrawLine(new Pen(new SolidColorBrush(Lighten(StructureColor, 0.12)), 5 * scale), a, b);
         context.DrawLine(new Pen(new SolidColorBrush(Lighten(StructureColor, 0.12)), 5 * scale), a, b);
 
 
-        if (!isFlowing)
-            return;
-
+        if (!isFlowing) return;
         for (var i = 0; i < 9; i++)
         for (var i = 0; i < 9; i++)
         {
         {
             var t = ((_phase / 120.0) + i / 9.0) % 1.0;
             var t = ((_phase / 120.0) + i / 9.0) % 1.0;
-            var p = new Point(a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t);
-            context.DrawEllipse(FlowDotBrush3D, null, new Rect(p.X - 4 * scale, p.Y - 4 * scale, 8 * scale, 8 * scale));
+            var pt = new Point(a.X + (b.X - a.X) * t, a.Y + (b.Y - a.Y) * t);
+            context.DrawEllipse(FlowDotBrush3D, null, new Rect(pt.X - 4 * scale, pt.Y - 4 * scale, 8 * scale, 8 * scale));
         }
         }
     }
     }
 
 
@@ -327,7 +603,6 @@ public class PumpStation3DControl : Control
         var c = Iso(p, 34, origin, scale);
         var c = Iso(p, 34, origin, scale);
         var r = 24 * scale;
         var r = 24 * scale;
         var status = running ? GetColor(ThemeHelper.Info) : GetColor(ThemeHelper.TextDisabled);
         var status = running ? GetColor(ThemeHelper.Info) : GetColor(ThemeHelper.TextDisabled);
-
         context.DrawEllipse(PumpBodyBrush, new Pen(new SolidColorBrush(status), 1.5 * scale), new Rect(c.X - r, c.Y - r, r * 2, r * 2));
         context.DrawEllipse(PumpBodyBrush, new Pen(new SolidColorBrush(status), 1.5 * scale), new Rect(c.X - r, c.Y - r, r * 2, r * 2));
         for (var i = 0; i < 5; i++)
         for (var i = 0; i < 5; i++)
         {
         {
@@ -375,6 +650,8 @@ public class PumpStation3DControl : Control
             context.DrawRectangle(null, new Pen(ThemeHelper.Danger, 2), scene, 4);
             context.DrawRectangle(null, new Pen(ThemeHelper.Danger, 2), scene, 4);
     }
     }
 
 
+    // ═══ 通用辅助 ═══
+
     private static StreamGeometry Polygon(IReadOnlyList<Point> points)
     private static StreamGeometry Polygon(IReadOnlyList<Point> points)
     {
     {
         var geometry = new StreamGeometry();
         var geometry = new StreamGeometry();
@@ -393,18 +670,14 @@ public class PumpStation3DControl : Control
     }
     }
 
 
     private static Color GetColor(IBrush brush) => brush is SolidColorBrush solid ? solid.Color : Colors.Gray;
     private static Color GetColor(IBrush brush) => brush is SolidColorBrush solid ? solid.Color : Colors.Gray;
-
     private Color StructureColor => Lighten(GetColor(ThemeHelper.Border), 0.18);
     private Color StructureColor => Lighten(GetColor(ThemeHelper.Border), 0.18);
-
     private static Color Darken(Color color, double factor) => Color.FromRgb((byte)(color.R * factor), (byte)(color.G * factor), (byte)(color.B * factor));
     private static Color Darken(Color color, double factor) => Color.FromRgb((byte)(color.R * factor), (byte)(color.G * factor), (byte)(color.B * factor));
 
 
-    private static Color Lighten(Color color, double amount)
-    {
-        return Color.FromRgb(
+    private static Color Lighten(Color color, double amount) =>
+        Color.FromRgb(
             (byte)Math.Min(255, color.R + (255 - color.R) * amount),
             (byte)Math.Min(255, color.R + (255 - color.R) * amount),
             (byte)Math.Min(255, color.G + (255 - color.G) * amount),
             (byte)Math.Min(255, color.G + (255 - color.G) * amount),
             (byte)Math.Min(255, color.B + (255 - color.B) * amount));
             (byte)Math.Min(255, color.B + (255 - color.B) * amount));
-    }
 
 
     protected override Size MeasureOverride(Size availableSize) => new(760, 420);
     protected override Size MeasureOverride(Size availableSize) => new(760, 420);
 }
 }

+ 216 - 35
src/YZWater.Avalonia/Controls/Renderers/PipeRenderer.cs

@@ -5,7 +5,7 @@ using System;
 namespace YZWater.Avalonia.Controls.Renderers;
 namespace YZWater.Avalonia.Controls.Renderers;
 
 
 /// <summary>
 /// <summary>
-/// 管道渲染器 - 绘制双层管道(管壁 + 内管)+ 流动动画点。
+/// 管道渲染器 - 拟真管道(管壁 + 内管 + 顶部高光)+ 流动动画点 + 法兰节点。
 /// 抽取自 PumpStation2DControl,支持水平/垂直两种方向。
 /// 抽取自 PumpStation2DControl,支持水平/垂直两种方向。
 /// </summary>
 /// </summary>
 public static class PipeRenderer
 public static class PipeRenderer
@@ -13,84 +13,265 @@ public static class PipeRenderer
     // 管壁/内管/流动点画刷缓存(避免每帧 new)
     // 管壁/内管/流动点画刷缓存(避免每帧 new)
     private static readonly SolidColorBrush PipeWallBrush = new(global::Avalonia.Media.Color.FromRgb(55, 65, 81));
     private static readonly SolidColorBrush PipeWallBrush = new(global::Avalonia.Media.Color.FromRgb(55, 65, 81));
     private static readonly SolidColorBrush PipeInnerBrush = new(global::Avalonia.Media.Color.FromRgb(97, 111, 134));
     private static readonly SolidColorBrush PipeInnerBrush = new(global::Avalonia.Media.Color.FromRgb(97, 111, 134));
-    private static readonly SolidColorBrush FlowDotBrush = new(global::Avalonia.Media.Color.FromRgb(3, 169, 244), 0.85);
+    private static readonly SolidColorBrush PipeHighlightBrush = new(global::Avalonia.Media.Color.FromArgb(90, 220, 230, 240));
+    private static readonly SolidColorBrush FlangeBrush = new(global::Avalonia.Media.Color.FromRgb(110, 122, 140));
+    private static readonly SolidColorBrush FlowDotBrush = new(global::Avalonia.Media.Color.FromRgb(64, 196, 255), 0.95);
+    private static readonly SolidColorBrush JointBrush = new(global::Avalonia.Media.Color.FromRgb(78, 92, 110));
+
+    // 3D 管壁渐变画刷缓存
+    private static readonly LinearGradientBrush s_horizontalPipeBrush = new()
+    {
+        StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+        EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
+        GradientStops =
+        {
+            new GradientStop(Color.FromRgb(160, 175, 195), 0),
+            new GradientStop(Color.FromRgb(100, 115, 135), 0.3),
+            new GradientStop(Color.FromRgb(78, 92, 110), 0.5),
+            new GradientStop(Color.FromRgb(55, 65, 81), 1),
+        }
+    };
+
+    private static readonly LinearGradientBrush s_verticalPipeBrush = new()
+    {
+        StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+        EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
+        GradientStops =
+        {
+            new GradientStop(Color.FromRgb(160, 175, 195), 0),
+            new GradientStop(Color.FromRgb(100, 115, 135), 0.3),
+            new GradientStop(Color.FromRgb(78, 92, 110), 0.5),
+            new GradientStop(Color.FromRgb(55, 65, 81), 1),
+        }
+    };
+
+    private static readonly LinearGradientBrush s_elbowBrush = new()
+    {
+        StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+        EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
+        GradientStops =
+        {
+            new GradientStop(Color.FromRgb(150, 165, 185), 0),
+            new GradientStop(Color.FromRgb(90, 105, 125), 0.5),
+            new GradientStop(Color.FromRgb(55, 65, 81), 1),
+        }
+    };
 
 
     /// <summary>
     /// <summary>
-    /// 绘制水平管道。
+    /// 绘制水平管道(3D 管壁:渐变+厚度+高光 + 流动 + 法兰 + 分段接缝)。
     /// </summary>
     /// </summary>
-    /// <param name="context">绘图上下文</param>
-    /// <param name="x1">起点 X</param>
-    /// <param name="y">Y 坐标</param>
-    /// <param name="x2">终点 X</param>
-    /// <param name="thickness">管道粗细</param>
-    /// <param name="isFlowing">是否有流动动画</param>
-    /// <param name="flowPhase">流动相位 0-360</param>
     public static void DrawHorizontal(
     public static void DrawHorizontal(
         DrawingContext context,
         DrawingContext context,
         double x1, double y, double x2,
         double x1, double y, double x2,
         double thickness,
         double thickness,
         bool isFlowing,
         bool isFlowing,
-        double flowPhase)
+        double flowPhase,
+        bool drawFlanges = false)
     {
     {
-        // 管壁
-        context.DrawLine(new Pen(PipeWallBrush, thickness),
-            new Point(x1, y), new Point(x2, y));
+        var halfT = thickness / 2;
 
 
-        // 内管
-        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
+        // 3D 管壁:上半部亮、下半部暗
+        context.DrawRectangle(s_horizontalPipeBrush, null,
+            new Rect(x1, y - halfT, x2 - x1, thickness), 2, 2);
+
+        // 管壁边框
+        context.DrawLine(new Pen(JointBrush, 0.8),
+            new Point(x1, y - halfT), new Point(x2, y - halfT));
+        context.DrawLine(new Pen(JointBrush, 0.8),
+            new Point(x1, y + halfT), new Point(x2, y + halfT));
+
+        // 顶部高光条
+        context.DrawLine(new Pen(PipeHighlightBrush, Math.Max(1, thickness * 0.15)),
+            new Point(x1 + 2, y - halfT + 1), new Point(x2 - 2, y - halfT + 1));
+
+        // 内管(细线)
+        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.15),
             new Point(x1, y), new Point(x2, y));
             new Point(x1, y), new Point(x2, y));
 
 
         // 流动动画
         // 流动动画
         if (isFlowing && x2 > x1 + 2)
         if (isFlowing && x2 > x1 + 2)
-            DrawFlowDots(context, x1, y, x2, y, flowPhase);
+            DrawFlowDots(context, x1, y, x2, y, flowPhase, thickness);
+
+        // 法兰(主管两端)
+        if (drawFlanges)
+        {
+            DrawFlangeH(context, x1, y, thickness);
+            DrawFlangeH(context, x2, y, thickness);
+        }
+
+        // 分段接缝环(每 30px 一段)
+        DrawPipeJointsH(context, x1, y, x2, thickness);
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 绘制垂直管道。
+    /// 绘制垂直管道(3D 管壁:渐变+厚度+高光 + 流动 + 法兰 + 分段接缝)。
     /// </summary>
     /// </summary>
-    /// <param name="context">绘图上下文</param>
-    /// <param name="x">X 坐标</param>
-    /// <param name="y1">起点 Y</param>
-    /// <param name="y2">终点 Y</param>
-    /// <param name="thickness">管道粗细</param>
-    /// <param name="isFlowing">是否有流动动画</param>
-    /// <param name="flowPhase">流动相位 0-360</param>
     public static void DrawVertical(
     public static void DrawVertical(
         DrawingContext context,
         DrawingContext context,
         double x, double y1, double y2,
         double x, double y1, double y2,
         double thickness,
         double thickness,
         bool isFlowing,
         bool isFlowing,
-        double flowPhase)
+        double flowPhase,
+        bool drawFlanges = false)
     {
     {
-        context.DrawLine(new Pen(PipeWallBrush, thickness),
-            new Point(x, y1), new Point(x, y2));
+        var halfT = thickness / 2;
+
+        // 3D 管壁:左半部亮、右半部暗
+        context.DrawRectangle(s_verticalPipeBrush, null,
+            new Rect(x - halfT, Math.Min(y1, y2), thickness, Math.Abs(y2 - y1)), 2, 2);
+
+        // 管壁边框
+        context.DrawLine(new Pen(JointBrush, 0.8),
+            new Point(x - halfT, y1), new Point(x - halfT, y2));
+        context.DrawLine(new Pen(JointBrush, 0.8),
+            new Point(x + halfT, y1), new Point(x + halfT, y2));
 
 
-        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
+        // 侧高光
+        context.DrawLine(new Pen(PipeHighlightBrush, Math.Max(1, thickness * 0.15)),
+            new Point(x - halfT + 1, y1), new Point(x - halfT + 1, y2));
+
+        // 内管
+        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.15),
             new Point(x, y1), new Point(x, y2));
             new Point(x, y1), new Point(x, y2));
 
 
         if (isFlowing && Math.Abs(y2 - y1) > 2)
         if (isFlowing && Math.Abs(y2 - y1) > 2)
-            DrawFlowDots(context, x, y1, x, y2, flowPhase);
+            DrawFlowDots(context, x, y1, x, y2, flowPhase, thickness);
+
+        if (drawFlanges)
+        {
+            DrawFlangeV(context, x, y1, thickness);
+            DrawFlangeV(context, x, y2, thickness);
+        }
+
+        DrawPipeJointsV(context, x, y1, y2, thickness);
+    }
+
+    /// <summary>分段接缝环:水平管每 30px 一段,画一条暗色环线</summary>
+    private static void DrawPipeJointsH(DrawingContext context, double x1, double y, double x2, double thickness)
+    {
+        var halfT = thickness / 2;
+        var jointSpacing = 30;
+        var count = (int)((x2 - x1) / jointSpacing);
+        for (int i = 1; i < count; i++)
+        {
+            var jx = x1 + i * jointSpacing;
+            context.DrawLine(new Pen(JointBrush, 1.5),
+                new Point(jx, y - halfT - 1), new Point(jx, y + halfT + 1));
+        }
+    }
+
+    /// <summary>分段接缝环:垂直管每 30px 一段</summary>
+    private static void DrawPipeJointsV(DrawingContext context, double x, double y1, double y2, double thickness)
+    {
+        var halfT = thickness / 2;
+        var jointSpacing = 30;
+        var len = Math.Abs(y2 - y1);
+        var count = (int)(len / jointSpacing);
+        for (int i = 1; i < count; i++)
+        {
+            var jy = Math.Min(y1, y2) + i * jointSpacing;
+            context.DrawLine(new Pen(JointBrush, 1.5),
+                new Point(x - halfT - 1, jy), new Point(x + halfT + 1, jy));
+        }
+    }
+
+    /// <summary>水平管端法兰(竖直短粗条)</summary>
+    private static void DrawFlangeH(DrawingContext context, double x, double y, double thickness)
+    {
+        var fw = Math.Max(2, thickness * 0.22);
+        var fh = thickness * 1.35;
+        context.DrawRectangle(FlangeBrush, null,
+            new Rect(x - fw / 2, y - fh / 2, fw, fh), 1, 1);
+    }
+
+    /// <summary>垂直管端法兰(水平短粗条)</summary>
+    private static void DrawFlangeV(DrawingContext context, double x, double y, double thickness)
+    {
+        var fw = thickness * 1.35;
+        var fh = Math.Max(2, thickness * 0.22);
+        context.DrawRectangle(FlangeBrush, null,
+            new Rect(x - fw / 2, y - fh / 2, fw, fh), 1, 1);
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 绘制沿管道方向的流动点。
+    /// 绘制沿管道方向的流动点(亮色 + 尾迹,随管径缩放)。
     /// </summary>
     /// </summary>
     private static void DrawFlowDots(DrawingContext context,
     private static void DrawFlowDots(DrawingContext context,
-        double x1, double y1, double x2, double y2, double phase)
+        double x1, double y1, double x2, double y2, double phase, double thickness)
     {
     {
         var dx = x2 - x1;
         var dx = x2 - x1;
         var dy = y2 - y1;
         var dy = y2 - y1;
         var len = Math.Sqrt(dx * dx + dy * dy);
         var len = Math.Sqrt(dx * dx + dy * dy);
         if (len < 4) return;
         if (len < 4) return;
 
 
-        var count = Math.Max(2, (int)(len / 18));
+        var dotR = Math.Max(1.5, thickness * 0.22);
+        var count = Math.Max(2, (int)(len / Math.Max(14, 22 - thickness)));
         for (int i = 0; i < count; i++)
         for (int i = 0; i < count; i++)
         {
         {
             var t = ((phase / 360.0) + (double)i / count) % 1.0;
             var t = ((phase / 360.0) + (double)i / count) % 1.0;
             var px = x1 + dx * t;
             var px = x1 + dx * t;
             var py = y1 + dy * t;
             var py = y1 + dy * t;
-            var r = Math.Max(1.5, 3.0);
-            context.DrawEllipse(FlowDotBrush, null, new Rect(px - r, py - r, r * 2, r * 2));
+
+            // 尾迹(运动反方向淡条)
+            var tailT = Math.Max(0, t - 0.03);
+            context.DrawLine(new Pen(new SolidColorBrush(
+                global::Avalonia.Media.Color.FromArgb(70, 64, 196, 255)), dotR * 1.4),
+                new Point(x1 + dx * tailT, y1 + dy * tailT), new Point(px, py));
+
+            // 流动点
+            context.DrawEllipse(FlowDotBrush, null, new Rect(px - dotR, py - dotR, dotR * 2, dotR * 2));
+        }
+    }
+
+    /// <summary>
+    /// 90° 3D 圆弧弯头(粗管段平滑弯曲):渐变填充 + 流动动画。
+    /// 用于水平管转垂直管的交汇处。
+    /// </summary>
+    public static void DrawArcElbow(DrawingContext context, double cx, double cy,
+        double thickness, bool isFlowing, double flowPhase)
+    {
+        var halfT = thickness / 2;
+        var arcR = thickness * 0.85;
+
+        // 3D 弯头:对角线渐变
+        // elbowBrush 已缓存为 s_elbowBrush
+
+        // 弯头外轮廓(顺时针 90° 圆弧)
+        var geo = new StreamGeometry();
+        using (var g = geo.Open())
+        {
+            // 上边:从左侧水平段端点 → 向上圆弧 → 右侧垂直段端点
+            g.BeginFigure(new Point(cx - halfT, cy - halfT), true);
+            g.ArcTo(new Point(cx + halfT, cy - halfT - arcR),
+                new Size(arcR, arcR), 0, false, SweepDirection.Clockwise);
+            g.LineTo(new Point(cx + halfT, cy + halfT));
+            g.ArcTo(new Point(cx - halfT, cy + halfT),
+                new Size(arcR, arcR), 0, false, SweepDirection.Clockwise);
+            g.LineTo(new Point(cx - halfT, cy - halfT));
+            g.EndFigure(true);
+        }
+        context.DrawGeometry(s_elbowBrush, new Pen(JointBrush, 0.6), geo);
+
+        // 内管(圆弧中心线)
+        var innerArcGeo = new StreamGeometry();
+        using (var g = innerArcGeo.Open())
+        {
+            g.BeginFigure(new Point(cx - halfT * 0.35, cy), false);
+            g.ArcTo(new Point(cx, cy - halfT * 0.35),
+                new Size(halfT * 0.35, halfT * 0.35), 0, false, SweepDirection.Clockwise);
+        }
+        context.DrawGeometry(null, new Pen(PipeInnerBrush, thickness * 0.12), innerArcGeo);
+
+        // 流动动画(沿外弧 1 个点)
+        if (isFlowing)
+        {
+            var dotR = Math.Max(1.5, thickness * 0.2);
+            var t = (flowPhase / 360.0) % 1.0;
+            var angle = 180 + t * 90; // 180°→270°
+            var rad = angle * Math.PI / 180;
+            var dx = cx + Math.Cos(rad) * (halfT + arcR * 0.5);
+            var dy = cy + Math.Sin(rad) * (halfT + arcR * 0.5);
+            context.DrawEllipse(FlowDotBrush, null, new Rect(dx - dotR, dy - dotR, dotR * 2, dotR * 2));
         }
         }
     }
     }
 }
 }

+ 197 - 0
src/YZWater.Avalonia/Controls/Renderers/PumpRenderer.cs

@@ -78,4 +78,201 @@ public static class PumpRenderer
             context.DrawText(formatted, new Point(cx - formatted.Width / 2, cy + radius + 3));
             context.DrawText(formatted, new Point(cx - formatted.Width / 2, cy + radius + 3));
         }
         }
     }
     }
+
+    // ════════════════════════════════════════════════════════
+    //  立式泵(拟真)
+    // ════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 绘制潜水排污泵(仿污水泵站现场):顶部电机仓 + 吊环 + 泵壳圆筒(叶轮观察窗旋转)
+    /// + 底部进水喇叭口 + 自动耦合导轨。头顶一根出水管直通总管,泵体沉入集水池液面。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="voluteCenter">泵壳筒体中心(屏幕坐标)</param>
+    /// <param name="radius">筒体半径</param>
+    /// <param name="isRunning">运行中(叶轮旋转 + 光晕)</param>
+    /// <param name="statusColor">状态色(运行绿 / 停止灰 / 故障红)</param>
+    /// <param name="rotationAngle">叶轮角度(度)</param>
+    /// <param name="text">标签(泵名)</param>
+    /// <param name="borderBrush">边框画刷</param>
+    /// <param name="textBrush">文字画刷</param>
+    /// <param name="faultFlashing">故障闪烁相位 0~1(&lt;0 表示无故障)</param>
+    public static void DrawVertical(
+        DrawingContext context,
+        Point voluteCenter,
+        double radius,
+        bool isRunning,
+        Color statusColor,
+        double rotationAngle,
+        string text,
+        IBrush borderBrush,
+        IBrush textBrush,
+        double faultFlashing = -1)
+    {
+        var cx = voluteCenter.X;
+        var cy = voluteCenter.Y;
+        var statusBrush = new SolidColorBrush(statusColor);
+
+        // 圆筒金属渐变:左高光 → 中亮 → 右暗(模拟圆柱体光照)
+        var shellMetal = new LinearGradientBrush
+        {
+            StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+            EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative),
+            GradientStops =
+            {
+                new GradientStop(Color.FromRgb(198, 206, 216), 0),      // 左高光
+                new GradientStop(Color.FromRgb(150, 160, 174), 0.22),
+                new GradientStop(Color.FromRgb(120, 130, 144), 0.78),
+                new GradientStop(Color.FromRgb(88, 96, 110), 1),        // 右暗
+            }
+        };
+        var metalDark = new SolidColorBrush(Color.FromRgb(96, 105, 118));
+        var steel = new SolidColorBrush(Color.FromRgb(112, 122, 136));
+        var cavity = new SolidColorBrush(Color.FromRgb(52, 60, 72));
+
+        // ═══ 纵向尺寸规划(自顶向下)═══
+        // 顶部出管法兰   cy - 2.55r ~ cy - 2.40r
+        // 电机仓(顶盖) cy - 2.40r ~ cy - 1.85r
+        // 泵壳圆筒      cy - 1.85r ~ cy + 1.05r
+        // 进水喇叭口    cy + 1.05r ~ cy + 1.60r
+        var flangeTop = cy - radius * 2.55;
+        var motorTop = cy - radius * 2.40;
+        var shellTop = cy - radius * 1.85;
+        var shellBottom = cy + radius * 1.05;
+        var bellBottom = cy + radius * 1.60;
+
+        // ─── 顶部出管法兰(衔接排出竖管)───
+        var flangeRect = new Rect(cx - radius * 0.42, flangeTop, radius * 0.84, radius * 0.15);
+        context.DrawRectangle(metalDark, new Pen(borderBrush, 1), flangeRect, 1, 1);
+
+        // ─── 电机仓(短粗顶盖 + 散热筋 + 吊环 + 接线盒)───
+        var motorRect = new Rect(cx - radius * 0.62, motorTop, radius * 1.24, radius * 0.55);
+        context.DrawRectangle(shellMetal, new Pen(borderBrush, 1.2), motorRect, 3, 3);
+        // 散热筋(横线)
+        for (int i = 1; i <= 3; i++)
+        {
+            var fy = motorRect.Y + motorRect.Height * i / 4.0;
+            context.DrawLine(new Pen(metalDark, 1),
+                new Point(motorRect.X + 3, fy), new Point(motorRect.Right - 3, fy));
+        }
+        // 接线盒(顶盖上方小箱,带线管)
+        var boxRect = new Rect(cx - radius * 0.3, motorTop - radius * 0.22, radius * 0.6, radius * 0.22);
+        context.DrawRectangle(metalDark, new Pen(borderBrush, 1), boxRect, 2, 2);
+        // 吊环(接线盒顶,供起吊检修)
+        var hookY = motorTop - radius * 0.30;
+        context.DrawEllipse(null, new Pen(metalDark, 1.6),
+            new Rect(cx - radius * 0.13, hookY - radius * 0.2, radius * 0.26, radius * 0.4));
+
+        // ─── 运行光晕(筒体后方,呼吸脉冲)───
+        if (isRunning)
+        {
+            var breathe = 1.5 + Math.Sin((rotationAngle * Math.PI / 180) * 2) * 0.22;
+            var glowAlpha = (byte)(36 + Math.Sin((rotationAngle * Math.PI / 180) * 2) * 18);
+            var glowH = (shellBottom - shellTop) * 1.5;
+            context.DrawEllipse(new SolidColorBrush(Color.FromArgb(glowAlpha, 76, 175, 80)), null,
+                new Rect(cx - radius * breathe, cy - glowH / 2, radius * 2 * breathe, glowH));
+        }
+
+        // ─── 泵壳圆筒(主体,细长筒体 + 上下盖法兰 + 加强筋)───
+        context.DrawRectangle(shellMetal, new Pen(statusBrush, isRunning ? 2.4 : 1.4),
+            new Rect(cx - radius, shellTop, radius * 2, shellBottom - shellTop), 3, 3);
+        // 顶部/底部加强法兰环
+        context.DrawRectangle(metalDark, new Pen(borderBrush, 1),
+            new Rect(cx - radius, shellTop, radius * 2, radius * 0.14), 1, 1);
+        context.DrawRectangle(metalDark, new Pen(borderBrush, 1),
+            new Rect(cx - radius, shellBottom - radius * 0.14, radius * 2, radius * 0.14), 1, 1);
+        // 纵向加强筋(筒体表面 4 条)
+        for (int i = 1; i <= 4; i++)
+        {
+            var fx = cx - radius + radius * 2 * (i / 5.0);
+            context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(120, 200, 210, 220)), 1),
+                new Point(fx, shellTop + radius * 0.1), new Point(fx, shellBottom - radius * 0.1));
+        }
+
+        // ─── 叶轮观察窗(筒体上部圆形舱窗,透过窗看到旋转叶轮)───
+        var winCx = cx;
+        var winCy = cy - radius * 0.45;
+        var winR = radius * 0.48;
+        context.DrawEllipse(cavity, new Pen(borderBrush, 1.2),
+            new Rect(winCx - winR, winCy - winR, winR * 2, winR * 2));
+        // 窗框高光(体现鼓出的玻璃窗)
+        context.DrawEllipse(null, new Pen(new SolidColorBrush(Color.FromArgb(70, 220, 230, 240)), 1),
+            new Rect(winCx - winR * 0.86, winCy - winR * 0.86, winR * 1.72, winR * 1.72));
+        // 叶轮(运行时旋转,停止时定格 45°)
+        for (int i = 0; i < 3; i++)
+        {
+            var angle = (isRunning ? rotationAngle : 45) + i * 120;
+            var rad = angle * Math.PI / 180.0;
+            var bx = winCx + Math.Cos(rad) * winR * 0.72;
+            var by = winCy + Math.Sin(rad) * winR * 0.72;
+            var bladeBrush = new SolidColorBrush(isRunning ? Color.FromRgb(120, 220, 130) : Color.FromRgb(150, 160, 172),
+                isRunning ? 0.95 : 0.6);
+            context.DrawLine(new Pen(bladeBrush, 2), new Point(winCx, winCy), new Point(bx, by));
+        }
+        // 轮毂
+        context.DrawEllipse(statusBrush, null,
+            new Rect(winCx - radius * 0.12, winCy - radius * 0.12, radius * 0.24, radius * 0.24));
+
+        // ─── 自动耦合导轨(筒体两侧竖直导杆,直通池底)───
+        var guideBrush = new Pen(steel, Math.Max(1.2, radius * 0.09));
+        context.DrawLine(guideBrush, new Point(cx - radius * 1.18, shellTop + radius * 0.2),
+            new Point(cx - radius * 1.18, bellBottom));
+        context.DrawLine(guideBrush, new Point(cx + radius * 1.18, shellTop + radius * 0.2),
+            new Point(cx + radius * 1.18, bellBottom));
+
+        // ─── 底部进水喇叭口(锥形收口 + 滤网)───
+        var bell = new StreamGeometry();
+        using (var g = bell.Open())
+        {
+            g.BeginFigure(new Point(cx - radius, shellBottom), true);
+            g.LineTo(new Point(cx - radius * 0.78, bellBottom), false);
+            g.LineTo(new Point(cx + radius * 0.78, bellBottom), false);
+            g.LineTo(new Point(cx + radius, shellBottom), false);
+            g.EndFigure(true);
+        }
+        context.DrawGeometry(shellMetal, new Pen(borderBrush, 1.1), bell);
+        // 喇叭口箍环 + 滤网格线
+        context.DrawLine(new Pen(metalDark, 1.2),
+            new Point(cx - radius * 0.82, bellBottom - radius * 0.12),
+            new Point(cx + radius * 0.82, bellBottom - radius * 0.12));
+        for (int i = -2; i <= 2; i++)
+        {
+            context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(130, 60, 68, 80)), 1),
+                new Point(cx + i * radius * 0.18, bellBottom - radius * 0.18),
+                new Point(cx + i * radius * 0.18, bellBottom));
+        }
+
+        // ─── 故障标记(筒体左上方红色三角 + 闪烁)───
+        if (faultFlashing >= 0)
+        {
+            var flash = Math.Sin(faultFlashing * Math.PI * 2) > 0;
+            if (flash)
+            {
+                var triSize = radius * 0.5;
+                var triTop = cy - radius * 2.9;
+                var geo = new StreamGeometry();
+                using (var g = geo.Open())
+                {
+                    g.BeginFigure(new Point(cx, triTop), true);
+                    g.LineTo(new Point(cx - triSize / 2, triTop + triSize * 0.9), false);
+                    g.LineTo(new Point(cx + triSize / 2, triTop + triSize * 0.9), false);
+                    g.EndFigure(true);
+                }
+                context.DrawGeometry(new SolidColorBrush(Color.FromRgb(244, 67, 54)), null, geo);
+                var exFt = new FormattedText("!", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+                    new Typeface("Consolas", FontStyle.Normal, FontWeight.Bold), triSize * 0.8,
+                    Brushes.White);
+                context.DrawText(exFt, new Point(cx - exFt.Width / 2, triTop + triSize * 0.05));
+            }
+        }
+
+        // ─── 标签(喇叭口下方)───
+        if (!string.IsNullOrEmpty(text))
+        {
+            var fontSize = Math.Max(8, radius * 0.5);
+            var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+                new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold), fontSize, textBrush);
+            context.DrawText(formatted, new Point(cx - formatted.Width / 2, bellBottom + 2));
+        }
+    }
 }
 }

+ 264 - 15
src/YZWater.Avalonia/Controls/Renderers/TankRenderer.cs

@@ -1,31 +1,27 @@
 using Avalonia;
 using Avalonia;
 using Avalonia.Media;
 using Avalonia.Media;
 using System;
 using System;
+using System.Collections.Generic;
 using System.Globalization;
 using System.Globalization;
 
 
 namespace YZWater.Avalonia.Controls.Renderers;
 namespace YZWater.Avalonia.Controls.Renderers;
 
 
 /// <summary>
 /// <summary>
-/// 水箱渲染器 - 绘制矩形水箱 + 水位填充 + 波纹 + 百分比。
-/// 抽取自 PumpStation2DControl。
+/// 水箱渲染器。
+/// <list type="bullet">
+/// <item><see cref="Draw"/>:简单矩形水箱(兼容模式)</item>
+/// <item><see cref="DrawSection"/>:剖面池(SCADA 拟真)— 池壁质感 + 液位填充 +
+/// 正弦波浪动画 + 液位大字 + 左侧刻度尺 + 彩色阈值线(启泵/停泵/保护液位)</item>
+/// </list>
 /// </summary>
 /// </summary>
 public static class TankRenderer
 public static class TankRenderer
 {
 {
+    /// <summary>阈值线定义(液位为与 waterLevel 同单位的原始值)</summary>
+    public record ThresholdLine(double Level, Color Color, string Label);
+
     /// <summary>
     /// <summary>
-    /// 绘制一个水箱。
+    /// 绘制一个简单矩形水箱(兼容模式,保留原样式)。
     /// </summary>
     /// </summary>
-    /// <param name="context">绘图上下文</param>
-    /// <param name="bounds">水箱矩形(屏幕坐标)</param>
-    /// <param name="waterLevel">液位百分比 0-100</param>
-    /// <param name="waterColor">水体颜色</param>
-    /// <param name="name">水箱名称(如"进水池")</param>
-    /// <param name="code">水箱代号(如"IN")</param>
-    /// <param name="bgBrush">箱体背景画刷</param>
-    /// <param name="borderBrush">箱体边框画刷</param>
-    /// <param name="textPrimaryBrush">主文字画刷</param>
-    /// <param name="textTertiaryBrush">辅助文字画刷</param>
-    /// <param name="warningBrush">高液位告警色</param>
-    /// <param name="dangerBrush">低液位告警色</param>
     public static void Draw(
     public static void Draw(
         DrawingContext context,
         DrawingContext context,
         Rect bounds,
         Rect bounds,
@@ -87,6 +83,249 @@ public static class TankRenderer
         DrawTextAt(context, centerX - 18, centerY + 8, pctText, pctFontSize, pctColor, FontWeight.Bold);
         DrawTextAt(context, centerX - 18, centerY + 8, pctText, pctFontSize, pctColor, FontWeight.Bold);
     }
     }
 
 
+    // ════════════════════════════════════════════════════════
+    //  剖面池(拟真模式)
+    // ════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 绘制剖面式水池:池壁(混凝土质感)+ 液位填充 + 正弦波浪动画 +
+    /// 液位大字 + 左侧刻度尺 + 彩色阈值横线。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="bounds">池体矩形(屏幕坐标,含池壁)</param>
+    /// <param name="waterLevel">当前液位(原始值,如米)</param>
+    /// <param name="maxLevel">满量程液位(原始值)</param>
+    /// <param name="waterColor">水体颜色</param>
+    /// <param name="name">池名称</param>
+    /// <param name="wavePhase">波浪动画相位(0~360)</param>
+    /// <param name="thresholds">阈值线集合(按原始液位值)</param>
+    /// <param name="palette">主题画刷</param>
+    public static void DrawSection(
+        DrawingContext context,
+        Rect bounds,
+        double waterLevel,
+        double maxLevel,
+        Color waterColor,
+        string name,
+        double wavePhase,
+        IReadOnlyList<ThresholdLine> thresholds,
+        SectionPalette palette)
+    {
+        var wall = Math.Max(4, Math.Min(bounds.Width, bounds.Height) * 0.06); // 池壁厚度
+        var inner = new Rect(bounds.X + wall, bounds.Y + wall * 0.6,
+                             bounds.Width - wall * 2, bounds.Height - wall * 0.6 - wall * 0.5);
+
+        // ─── 池壁(外框,混凝土质感 + U 形实体壁)───
+        // 池底
+        var wallBrush = new SolidColorBrush(Color.FromArgb(180, 90, 98, 112));
+        context.DrawRectangle(wallBrush, new Pen(ThemeHelper.Border, 1.5), bounds, 2, 2);
+
+        // U 形内壁(左/右/底)
+        var innerWall = new SolidColorBrush(Color.FromArgb(140, 55, 62, 74));
+        // 左壁
+        context.DrawRectangle(innerWall, null,
+            new Rect(inner.X - wall, inner.Y, wall, inner.Height));
+        // 右壁
+        context.DrawRectangle(innerWall, null,
+            new Rect(inner.Right, inner.Y, wall, inner.Height));
+        // 池底
+        context.DrawRectangle(innerWall, null,
+            new Rect(inner.X, inner.Bottom, inner.Width, wall));
+
+        // 内壁阴影线(增强立体感)
+        context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(100, 40, 45, 55)), 1.5),
+            new Point(inner.X, inner.Y), new Point(inner.X, inner.Bottom));
+        context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(100, 40, 45, 55)), 1.5),
+            new Point(inner.Right, inner.Y), new Point(inner.Right, inner.Bottom));
+        context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(100, 40, 45, 55)), 1.5),
+            new Point(inner.X, inner.Bottom), new Point(inner.Right, inner.Bottom));
+
+        // ─── 内腔背景 ───
+        context.DrawRectangle(palette.CavityBrush, null, inner);
+
+        // ── 液位填充 + 正弦波浪 ──
+        if (maxLevel <= 0) maxLevel = 1;
+        var levelPct = Math.Clamp(waterLevel / maxLevel, 0, 1);
+        var waterTop = inner.Bottom - inner.Height * levelPct;
+
+        // 波浪幅度随池高缩放(比旧版更明显)
+        var amp1 = Math.Max(4, inner.Height * 0.045);
+
+        if (levelPct > 0.01)
+        {
+            var waterRect = new Rect(inner.X, waterTop, inner.Width, inner.Bottom - waterTop);
+            var waterBrush = new LinearGradientBrush
+            {
+                StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
+                EndPoint = new RelativePoint(0, 1, RelativeUnit.Relative),
+                GradientStops =
+                {
+                    new GradientStop(Color.FromArgb(210, waterColor.R, waterColor.G, waterColor.B), 0),
+                    new GradientStop(Color.FromArgb(120, waterColor.R, waterColor.G, waterColor.B), 1),
+                }
+            };
+            context.DrawRectangle(waterBrush, null, waterRect);
+
+            // 水面亮线(液面与空气交界处的高光)
+            context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(200, 255, 255, 255)), 1.8),
+                new Point(inner.X + 1, waterTop), new Point(inner.Right - 1, waterTop));
+
+            // 正弦波浪(3 层相位错开,半透明叠加)
+            DrawWave(context, waterTop, inner, waterColor, wavePhase, 0.55, amp1, 1.0);
+            DrawWave(context, waterTop + 2, inner, waterColor, wavePhase * 1.3 + 120, 0.32, amp1 * 1.3, 0.7);
+            DrawWave(context, waterTop + 4, inner, waterColor, wavePhase * 0.8 + 240, 0.20, amp1 * 1.6, 0.5);
+
+            // 池内气泡上升动画(6 个气泡,相位不同,持续上升)
+            DrawBubbles(context, inner, waterRect, waterColor, wavePhase, levelPct);
+        }
+        else
+        {
+            // 空池:池底积水带 + 沉积示意,避免"死白框"
+            var siltRect = new Rect(inner.X, inner.Bottom - inner.Height * 0.04, inner.Width, inner.Height * 0.04);
+            context.DrawRectangle(new SolidColorBrush(Color.FromArgb(90, 110, 120, 100)), null, siltRect);
+            var residRect = new Rect(inner.X, inner.Bottom - inner.Height * 0.02, inner.Width, inner.Height * 0.02);
+            context.DrawRectangle(new SolidColorBrush(Color.FromArgb(140, waterColor.R, waterColor.G, waterColor.B)), null, residRect);
+        }
+
+        // ─── 阈值线(画在水位之上,一眼看出距离)───
+        if (thresholds != null)
+        {
+            foreach (var th in thresholds)
+            {
+                if (th.Level <= 0 || th.Level > maxLevel) continue;
+                var ty = inner.Bottom - inner.Height * Math.Clamp(th.Level / maxLevel, 0, 1);
+                var pen = new Pen(new SolidColorBrush(th.Color, 0.9), 1.5, DashStyle.Dash);
+                context.DrawLine(pen, new Point(inner.X, ty), new Point(inner.Right, ty));
+
+                // 阈值标签(左侧小字,紧贴线上方)
+                var label = $"{th.Label} {th.Level:F2}";
+                var ft = new FormattedText(label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+                    new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold),
+                    Math.Max(8, inner.Height * 0.055),
+                    new SolidColorBrush(th.Color));
+                context.DrawText(ft, new Point(inner.X + 2, ty - ft.Height - 1));
+            }
+        }
+
+        // ─── 当前液位值(气泡浮标:跟随水面高度,贴池右侧)───
+        var valueText = $"{waterLevel:F2} m";
+        var valueSize = Math.Max(11, inner.Height * 0.085);
+        var valueFt = new FormattedText(valueText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Consolas", FontStyle.Normal, FontWeight.Bold), valueSize, palette.TextPrimaryBrush);
+        var badgeW = valueFt.Width + 14;
+        var badgeH = valueFt.Height + 8;
+        var badgeX = inner.Right - badgeW - 6;
+        var badgeY = Math.Clamp(waterTop - badgeH - 8, inner.Y + 2, inner.Bottom - badgeH - 2);
+        // 浮标牌 + 指向水面的指针
+        var badgeRect = new Rect(badgeX, badgeY, badgeW, badgeH);
+        context.DrawRectangle(palette.CavityBrush, new Pen(palette.BorderBrush, 1), badgeRect, 4, 4);
+        context.DrawLine(new Pen(palette.BorderBrush, 1.2),
+            new Point(badgeX + badgeW / 2, badgeY + badgeH),
+            new Point(badgeX + badgeW / 2, waterTop));
+        context.DrawEllipse(new SolidColorBrush(waterColor), null,
+            new Rect(badgeX + badgeW / 2 - 3, waterTop - 3, 6, 6));
+        context.DrawText(valueFt,
+            new Point(badgeRect.Center.X - valueFt.Width / 2, badgeRect.Center.Y - valueFt.Height / 2));
+
+        // ─── 池名称(顶部)───
+        var nameSize = Math.Max(9, bounds.Height * 0.07);
+        var nameFt = new FormattedText(name, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold), nameSize, palette.TextPrimaryBrush);
+        context.DrawText(nameFt, new Point(bounds.X + (bounds.Width - nameFt.Width) / 2, bounds.Y - nameFt.Height - 2));
+
+        // ─── 水面亮线(液面与空气交界处的高光)───
+        if (levelPct > 0.01)
+        {
+            var waterRect = new Rect(inner.X, waterTop, inner.Width, inner.Bottom - waterTop);
+            context.DrawLine(new Pen(new SolidColorBrush(Color.FromArgb(200, 255, 255, 255)), 1.8),
+                new Point(inner.X + 1, waterTop), new Point(inner.Right - 1, waterTop));
+        }
+
+        // ─── 左侧刻度尺 ───
+        DrawRuler(context, bounds, inner, maxLevel, palette);
+    }
+
+    /// <summary>池内气泡上升动画(6 个气泡,不同相位,从池底持续升起破裂)</summary>
+    private static void DrawBubbles(DrawingContext context, Rect inner, Rect waterRect,
+        Color waterColor, double phase, double levelPct)
+    {
+        const int bubbleCount = 6;
+        var bubbleBrush = new SolidColorBrush(Color.FromArgb(120, waterColor.R, waterColor.G, waterColor.B));
+
+        for (int i = 0; i < bubbleCount; i++)
+        {
+            // 每个气泡的相位偏移不同
+            var p = (phase + i * 67) % 360;
+            var t = p / 360.0; // 0→1 从池底到水面
+
+            var bx = inner.X + inner.Width * (0.15 + (i * 0.14 + Math.Sin(p * 0.05) * 0.08));
+            var by = inner.Bottom - inner.Height * levelPct * t - (inner.Height * 0.05); // 到水面以下
+
+            // 气泡大小随上升略缩小
+            var bSize = Math.Max(1.5, 3.5 * (1 - t * 0.3));
+            var bubbleAlpha = (byte)(140 * (1 - t * 0.6));
+
+            // 只在液面以下绘制
+            if (by >= inner.Y && by <= waterRect.Bottom)
+            {
+                context.DrawEllipse(new SolidColorBrush(Color.FromArgb(bubbleAlpha,
+                    waterColor.R, waterColor.G, waterColor.B)), null,
+                    new Rect(bx - bSize, by - bSize, bSize * 2, bSize * 2));
+            }
+        }
+    }
+
+    /// <summary>绘制单层正弦波浪(填充至池底,裁剪在内腔范围内)</summary>
+    private static void DrawWave(DrawingContext context, double topY, Rect inner,
+        Color waterColor, double phase, double alpha, double amplitude, double wavelengthScale)
+    {
+        if (topY < inner.Y - amplitude || topY > inner.Bottom) return;
+
+        const int steps = 24;
+        var geometry = new StreamGeometry();
+        using (var ctx = geometry.Open())
+        {
+            ctx.BeginFigure(new Point(inner.X, inner.Bottom), isFilled: true);
+
+            // 从左到右沿正弦曲线采样
+            for (int i = 0; i <= steps; i++)
+            {
+                var t = (double)i / steps;
+                var px = inner.X + inner.Width * t;
+                var py = topY + Math.Sin((t * Math.PI * 2 * wavelengthScale) + (phase * Math.PI / 180)) * amplitude;
+                ctx.LineTo(new Point(px, py), isStroked: false);
+            }
+
+            // 闭合到池底
+            ctx.EndFigure(true);
+        }
+
+        var brush = new SolidColorBrush(waterColor, alpha);
+        context.DrawGeometry(brush, null, geometry);
+    }
+
+    /// <summary>左侧刻度尺(0~maxLevel,按 5 段标注)</summary>
+    private static void DrawRuler(DrawingContext context, Rect bounds, Rect inner,
+        double maxLevel, SectionPalette palette)
+    {
+        const int segments = 5;
+        var tickLen = Math.Max(3, bounds.Width * 0.02);
+        var fontSize = Math.Max(7, bounds.Height * 0.045);
+        var pen = new Pen(palette.TextTertiaryBrush, 1);
+
+        for (int i = 0; i <= segments; i++)
+        {
+            var value = maxLevel * i / segments;
+            var y = inner.Bottom - inner.Height * ((double)i / segments);
+            context.DrawLine(pen, new Point(bounds.X - tickLen, y), new Point(bounds.X - 1, y));
+
+            var label = value.ToString("F1", CultureInfo.CurrentCulture);
+            var ft = new FormattedText(label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+                new Typeface("Consolas"), fontSize, palette.TextTertiaryBrush);
+            context.DrawText(ft, new Point(bounds.X - tickLen - ft.Width - 2, y - ft.Height / 2));
+        }
+    }
+
     private static void DrawTextAt(DrawingContext context, double x, double y, string text,
     private static void DrawTextAt(DrawingContext context, double x, double y, string text,
         double size, IBrush brush, FontWeight weight = FontWeight.Normal)
         double size, IBrush brush, FontWeight weight = FontWeight.Normal)
     {
     {
@@ -95,3 +334,13 @@ public static class TankRenderer
         context.DrawText(formatted, new Point(x, y));
         context.DrawText(formatted, new Point(x, y));
     }
     }
 }
 }
+
+/// <summary>剖面池主题画刷集(从 ThemeHelper 取色后传入,控件不用 DynamicResource)</summary>
+public record SectionPalette(
+    IBrush WallBrush,        // 池壁(混凝土)
+    IBrush WallShadowBrush,  // 池壁内侧阴影线
+    IBrush CavityBrush,      // 内腔背景
+    IBrush BorderBrush,      // 边框
+    IBrush TextPrimaryBrush, // 主文字
+    IBrush TextTertiaryBrush // 辅助文字(刻度)
+);

+ 155 - 0
src/YZWater.Avalonia/Controls/SparklineControl.cs

@@ -0,0 +1,155 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls;
+
+/// <summary>
+/// 迷你趋势小曲线(sparkline)— 用于液位卡等紧凑区域显示最近一段时间的数值变化。
+/// 输入是一串浮点采样(按时间顺序),自动压缩到控件宽度,纯绘图实现。
+/// </summary>
+public class SparklineControl : Control
+{
+    public static readonly StyledProperty<IReadOnlyList<float>?> SamplesProperty =
+        AvaloniaProperty.Register<SparklineControl, IReadOnlyList<float>?>(nameof(Samples));
+
+    public static readonly StyledProperty<double> MinValueProperty =
+        AvaloniaProperty.Register<SparklineControl, double>(nameof(MinValue), double.NaN);
+
+    public static readonly StyledProperty<double> MaxValueProperty =
+        AvaloniaProperty.Register<SparklineControl, double>(nameof(MaxValue), double.NaN);
+
+    public static readonly StyledProperty<IBrush?> LineBrushProperty =
+        AvaloniaProperty.Register<SparklineControl, IBrush?>(nameof(LineBrush));
+
+    public static readonly StyledProperty<IBrush?> FillBrushProperty =
+        AvaloniaProperty.Register<SparklineControl, IBrush?>(nameof(FillBrush));
+
+    /// <summary>采样序列(按时间顺序;null/空则画空态)</summary>
+    public IReadOnlyList<float>? Samples
+    {
+        get => GetValue(SamplesProperty);
+        set => SetValue(SamplesProperty, value);
+    }
+
+    /// <summary>纵轴最小值(默认自动取采样最小值)</summary>
+    public double MinValue
+    {
+        get => GetValue(MinValueProperty);
+        set => SetValue(MinValueProperty, value);
+    }
+
+    /// <summary>纵轴最大值(默认自动取采样最大值)</summary>
+    public double MaxValue
+    {
+        get => GetValue(MaxValueProperty);
+        set => SetValue(MaxValueProperty, value);
+    }
+
+    /// <summary>曲线颜色</summary>
+    public IBrush? LineBrush
+    {
+        get => GetValue(LineBrushProperty);
+        set => SetValue(LineBrushProperty, value);
+    }
+
+    /// <summary>曲线下方填充色(null 则不填充)</summary>
+    public IBrush? FillBrush
+    {
+        get => GetValue(FillBrushProperty);
+        set => SetValue(FillBrushProperty, value);
+    }
+
+    static SparklineControl()
+    {
+        AffectsRender<SparklineControl>(SamplesProperty, MinValueProperty, MaxValueProperty,
+            LineBrushProperty, FillBrushProperty);
+    }
+
+    public override void Render(DrawingContext context)
+    {
+        base.Render(context);
+
+        var w = Bounds.Width;
+        var h = Bounds.Height;
+        if (w <= 1 || h <= 1) return;
+
+        var samples = Samples;
+        if (samples == null || samples.Count < 2)
+        {
+            // 空态:虚线占位
+            var dashPen = new Pen(LineBrush ?? Brushes.Gray, 1, DashStyle.Dash);
+            context.DrawLine(dashPen, new Point(0, h / 2), new Point(w, h / 2));
+            return;
+        }
+
+        // 纵轴范围(默认自适应,带 5% 上下边距;也支持外部指定)
+        var hasFixed = !double.IsNaN(MinValue) && !double.IsNaN(MaxValue) && MaxValue > MinValue;
+        var lo = double.MaxValue;
+        var hi = double.MinValue;
+        for (int i = 0; i < samples.Count; i++)
+        {
+            var v = samples[i];
+            if (v < lo) lo = v;
+            if (v > hi) hi = v;
+        }
+        if (hi - lo < 1e-9) { hi = lo + 1; lo = lo - 1; }
+        if (!hasFixed)
+        {
+            var pad = (hi - lo) * 0.1;
+            lo -= pad;
+            hi += pad;
+        }
+        else
+        {
+            lo = MinValue;
+            hi = MaxValue;
+        }
+
+        double MapY(float v) => h - (v - lo) / (hi - lo) * (h - 4) - 2;
+
+        // 折线
+        var poly = new StreamGeometry();
+        using (var g = poly.Open())
+        {
+            g.BeginFigure(new Point(0, MapY(samples[0])), isFilled: false);
+            for (int i = 1; i < samples.Count; i++)
+            {
+                var x = w * i / (double)(samples.Count - 1);
+                g.LineTo(new Point(x, MapY(samples[i])));
+            }
+            g.EndFigure(false);
+        }
+
+        var lineBrush = LineBrush ?? new SolidColorBrush(Color.FromArgb(220, 76, 175, 80));
+        context.DrawGeometry(null, new Pen(lineBrush, 1.4), poly);
+
+        // 曲线下方渐变填充(若给定了 FillBrush)
+        if (FillBrush != null)
+        {
+            var fillGeo = new StreamGeometry();
+            using (var g = fillGeo.Open())
+            {
+                g.BeginFigure(new Point(0, h), isFilled: true);
+                g.LineTo(new Point(0, MapY(samples[0])));
+                for (int i = 1; i < samples.Count; i++)
+                {
+                    var x = w * i / (double)(samples.Count - 1);
+                    g.LineTo(new Point(x, MapY(samples[i])));
+                }
+                g.LineTo(new Point(w, h));
+                g.EndFigure(true);
+            }
+            context.DrawGeometry(FillBrush, null, fillGeo);
+        }
+
+        // 最新值圆点(描边,突出当前点)
+        var lastX = w;
+        var lastY = MapY(samples[samples.Count - 1]);
+        context.DrawEllipse(Brushes.Transparent, new Pen(lineBrush, 1.2),
+            new Rect(lastX - 2.5, lastY - 2.5, 5, 5));
+    }
+}

+ 118 - 0
src/YZWater.Avalonia/Converters/BoolConverters.cs

@@ -44,4 +44,122 @@ public static class BoolConverters
     /// </summary>
     /// </summary>
     public static readonly IValueConverter ToModeText =
     public static readonly IValueConverter ToModeText =
         new FuncValueConverter<bool, string>(active => active ? "中控" : "屏控");
         new FuncValueConverter<bool, string>(active => active ? "中控" : "屏控");
+
+    /// <summary>
+    /// true → "自动", false → "手动"
+    /// </summary>
+    public static readonly IValueConverter ToAutoModeText =
+        new FuncValueConverter<bool, string>(active => active ? "自动" : "手动");
+
+    /// <summary>
+    /// 轮换建议高亮边框:true → 琥珀色(#FFB300),false → 灰蓝(#3A4152)
+    /// </summary>
+    public static readonly IValueConverter ToSuggestBorder =
+        new FuncValueConverter<bool, IBrush>(active =>
+            active ? new SolidColorBrush(Color.Parse("#FFB300"))
+                   : new SolidColorBrush(Color.Parse("#3A4152")));
+
+    /// <summary>
+    /// 泵电流负荷:超载(true) → 危险红(#E53935),正常(false) → 成功绿(#00897B)
+    /// </summary>
+    public static readonly IValueConverter ToOverloadBrush =
+        new FuncValueConverter<bool, IBrush>(overload =>
+            overload ? new SolidColorBrush(Color.Parse("#E53935"))
+                     : new SolidColorBrush(Color.Parse("#00897B")));
+
+    /// <summary>集合数量 → 是否非空(int &gt; 0 → true)</summary>
+    public static readonly IValueConverter ToHasItems =
+        new FuncValueConverter<int, bool>(count => count > 0);
+
+    /// <summary>集合数量 → 是否为空(int ≤ 0 → true)</summary>
+    public static readonly IValueConverter ToIsEmpty =
+        new FuncValueConverter<int, bool>(count => count <= 0);
+
+    /// <summary>字符串 → 非空可见(非空字符串 → true)</summary>
+    public static readonly IValueConverter IsNotEmpty =
+        new FuncValueConverter<string?, bool>(s => !string.IsNullOrEmpty(s));
+
+    /// <summary>
+    /// 中控/屏控切换按钮文字:true(IsCentralMode) → "切屏控", false → "切中控"
+    /// 含义:点击按钮将切换到对端模式
+    /// </summary>
+    public static readonly IValueConverter ToModeToggleText =
+        new FuncValueConverter<bool, string>(isCentral => isCentral ? "切屏控" : "切中控");
+
+    /// <summary>
+    /// 控件激活态背景:true → 半透明蓝(#1A4A9EFF), false → 透明
+    /// </summary>
+    public static readonly IValueConverter ToActiveBgBrush =
+        new FuncValueConverter<bool, IBrush>(active =>
+            active ? new SolidColorBrush(Color.Parse("#1A4A9EFF"))
+                   : new SolidColorBrush(Colors.Transparent));
+
+    /// <summary>
+    /// 控件激活态背景(琥珀色):true → 半透明琥珀(#1AFFB300), false → 透明
+    /// </summary>
+    public static readonly IValueConverter ToWarningBgBrush =
+        new FuncValueConverter<bool, IBrush>(active =>
+            active ? new SolidColorBrush(Color.Parse("#1AFFB300"))
+                   : new SolidColorBrush(Colors.Transparent));
+
+    /// <summary>
+    /// 控件非激活态背景:true → 透明, false → 半透明蓝(#1A4A9EFF)
+    /// </summary>
+    public static readonly IValueConverter ToInverseBgBrush =
+        new FuncValueConverter<bool, IBrush>(active =>
+            active ? new SolidColorBrush(Colors.Transparent)
+                   : new SolidColorBrush(Color.Parse("#1A4A9EFF")));
+
+    /// <summary>
+    /// 中控/屏控切换按钮颜色:true(中控) → 信息蓝(#4A9EFF), false(屏控) → 琥珀(#FFB300)
+    /// 用颜色直观区分当前处于哪种控制模式
+    /// </summary>
+    public static readonly IValueConverter ToModeToggleBrush =
+        new FuncValueConverter<bool, IBrush>(isCentral =>
+            isCentral ? new SolidColorBrush(Color.Parse("#4A9EFF"))
+                      : new SolidColorBrush(Color.Parse("#FFB300")));
+
+    /// <summary>
+    /// 手动/自动切换按钮文字:IsAutoMode=true → "切手动", false → "切自动"
+    /// </summary>
+    public static readonly IValueConverter ToManualToggleText =
+        new FuncValueConverter<bool, string>(isAuto => isAuto ? "切手动" : "切自动");
+
+    /// <summary>
+    /// 手动/自动切换按钮颜色:IsAutoMode=true → 琥珀(#FFB300), false → 绿(#00897B)
+    /// </summary>
+    public static readonly IValueConverter ToManualToggleBrush =
+        new FuncValueConverter<bool, IBrush>(isAuto =>
+            isAuto ? new SolidColorBrush(Color.Parse("#FFB300"))
+                   : new SolidColorBrush(Color.Parse("#00897B")));
+
+    /// <summary>
+    /// 手动/自动切换按钮背景:IsAutoMode=true → 琥珀半透明, false → 绿半透明
+    /// </summary>
+    public static readonly IValueConverter ToManualToggleBgBrush =
+        new FuncValueConverter<bool, IBrush>(isAuto =>
+            isAuto ? new SolidColorBrush(Color.Parse("#1AFFB300"))
+                   : new SolidColorBrush(Color.Parse("#1A00897B")));
+
+    /// <summary>
+    /// 泵启停按钮文字:IsRunning=true → "停止", false → "启动"
+    /// </summary>
+    public static readonly IValueConverter ToPumpToggleText =
+        new FuncValueConverter<bool, string>(isRunning => isRunning ? "停止" : "启动");
+
+    /// <summary>
+    /// 泵启停按钮颜色:IsRunning=true → 停止红(#E53935), false → 启动绿(#00897B)
+    /// </summary>
+    public static readonly IValueConverter ToPumpToggleBrush =
+        new FuncValueConverter<bool, IBrush>(isRunning =>
+            isRunning ? new SolidColorBrush(Color.Parse("#E53935"))
+                      : new SolidColorBrush(Color.Parse("#00897B")));
+
+    /// <summary>
+    /// 泵启停按钮背景:IsRunning=true → 红色半透明, false → 绿色半透明
+    /// </summary>
+    public static readonly IValueConverter ToPumpToggleBgBrush =
+        new FuncValueConverter<bool, IBrush>(isRunning =>
+            isRunning ? new SolidColorBrush(Color.Parse("#1AE53935"))
+                      : new SolidColorBrush(Color.Parse("#1A00897B")));
 }
 }

+ 45 - 0
src/YZWater.Avalonia/Converters/TankStageConverters.cs

@@ -0,0 +1,45 @@
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+using YZWater.Core.Models;
+
+namespace YZWater.Avalonia.Converters;
+
+/// <summary>
+/// 液位工况档位(TankStage)转换器集合:
+/// <list type="bullet">
+/// <item><see cref="ToText"/> — 档位中文名(停泵区/启1泵/启2泵/启3泵/保护水位)</item>
+/// <item><see cref="ToBrush"/> — 档位警示色(停泵灰 / 启泵蓝绿 / 保护红)</item>
+/// </list>
+/// </summary>
+public static class TankStageConverters
+{
+    /// <summary>档位中文名</summary>
+    public static readonly IValueConverter ToText =
+        new FuncValueConverter<TankStage, string>(stage => stage switch
+        {
+            TankStage.Protection => "保护水位",
+            TankStage.Pump3 => "启3泵区",
+            TankStage.Pump2 => "启2泵区",
+            TankStage.Pump1 => "启1泵区",
+            _ => "停泵区",
+        });
+
+    /// <summary>档位警示色:保护红 / 启泵琥珀 / 停泵灰绿</summary>
+    public static readonly IValueConverter ToBrush =
+        new FuncValueConverter<TankStage, IBrush>(stage => stage switch
+        {
+            TankStage.Protection => new SolidColorBrush(Color.Parse("#E53935")),
+            TankStage.Pump3 => new SolidColorBrush(Color.Parse("#FF7043")),
+            TankStage.Pump2 => new SolidColorBrush(Color.Parse("#FFB300")),
+            TankStage.Pump1 => new SolidColorBrush(Color.Parse("#26A69A")),
+            _ => new SolidColorBrush(Color.Parse("#78909C")),
+        });
+
+    /// <summary>档位文字前景色(深色区配白字,浅色区配深字)</summary>
+    public static readonly IValueConverter ToForeground =
+        new FuncValueConverter<TankStage, IBrush>(stage => stage is TankStage.Stop or TankStage.Pump1
+            ? new SolidColorBrush(Color.Parse("#1B1F27"))
+            : new SolidColorBrush(Colors.White));
+}

+ 54 - 0
src/YZWater.Avalonia/Services/KeyboardHelper.cs

@@ -0,0 +1,54 @@
+using Avalonia.Input;
+using Serilog;
+
+namespace YZWater.Avalonia.Services;
+
+/// <summary>
+/// 键盘辅助类,用于检测启动时的修饰键状态(跨平台)
+/// </summary>
+public static class KeyboardHelper
+{
+    /// <summary>
+    /// 检测当前是否按住了 Shift 键
+    /// </summary>
+    public static bool IsShiftPressed()
+    {
+        try
+        {
+            if (OperatingSystem.IsWindows())
+            {
+                return (Win32Methods.GetAsyncKeyState(0x10) & 0x8000) != 0; // VK_SHIFT
+            }
+
+            if (OperatingSystem.IsMacOS())
+            {
+                // macOS: 通过 IOKit 检测修饰键(简化:使用环境变量检测)
+                // 在实际部署中,可通过 CGEventSourceFlagsState 实现
+                return false;
+            }
+
+            if (OperatingSystem.IsLinux())
+            {
+                // Linux: 尝试通过 X11 检测(需要 DISPLAY 环境变量)
+                // 简化实现:返回 false,记录警告
+                Log.Debug("KeyboardHelper: Linux 平台暂不支持启动时 Shift 检测");
+                return false;
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "KeyboardHelper.IsShiftPressed 检测失败");
+        }
+
+        return false;
+    }
+}
+
+/// <summary>
+/// Win32 API 调用(仅 Windows 平台)
+/// </summary>
+internal static class Win32Methods
+{
+    [System.Runtime.InteropServices.DllImport("user32.dll")]
+    public static extern short GetAsyncKeyState(int vKey);
+}

+ 5 - 2
src/YZWater.Avalonia/Views/LoginView.axaml

@@ -44,8 +44,11 @@
                              KeyDown="OnPasswordKeyDown" LostFocus="OnPasswordLostFocus"/>
                              KeyDown="OnPasswordKeyDown" LostFocus="OnPasswordLostFocus"/>
                 </StackPanel>
                 </StackPanel>
 
 
-                <!-- 记住用户名 -->
-                <CheckBox Content="{Binding RememberMeText}" IsChecked="{Binding RememberUserName}" FontSize="13" Margin="0,2,0,0"/>
+                <!-- 记住用户名 + 自动登录 -->
+                <StackPanel Orientation="Horizontal" Spacing="20" Margin="0,2,0,0">
+                    <CheckBox Content="{Binding RememberMeText}" IsChecked="{Binding RememberUserName}" FontSize="13"/>
+                    <CheckBox Content="{Binding AutoLoginText}" IsChecked="{Binding AutoLogin}" FontSize="13"/>
+                </StackPanel>
 
 
                 <!-- 错误提示 -->
                 <!-- 错误提示 -->
                 <Border IsVisible="{Binding HasError}" Background="#20FF0000" CornerRadius="6" Padding="10,6" MinHeight="28">
                 <Border IsVisible="{Binding HasError}" Background="#20FF0000" CornerRadius="6" Padding="10,6" MinHeight="28">

+ 1 - 0
src/YZWater.Avalonia/Views/MainWindow.axaml

@@ -12,6 +12,7 @@
         Width="1280" Height="800"
         Width="1280" Height="800"
         MinWidth="1024" MinHeight="700"
         MinWidth="1024" MinHeight="700"
         WindowStartupLocation="CenterScreen"
         WindowStartupLocation="CenterScreen"
+        WindowState="Maximized"
         Background="{DynamicResource AppBgBrush}">
         Background="{DynamicResource AppBgBrush}">
 
 
     <Window.DataContext>
     <Window.DataContext>

+ 541 - 182
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -1,6 +1,7 @@
 <controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
 <controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:vm="using:YZWater.Core.ViewModels"
+             xmlns:models="using:YZWater.Core.Models"
              xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:conv="using:YZWater.Avalonia.Converters"
              xmlns:conv="using:YZWater.Avalonia.Converters"
              x:Class="YZWater.Avalonia.Views.ProjectView"
              x:Class="YZWater.Avalonia.Views.ProjectView"
@@ -24,11 +25,41 @@
                     <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
                     <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
                         <Border Background="{DynamicResource ToggleBgBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="12" Padding="10,5">
                         <Border Background="{DynamicResource ToggleBgBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="12" Padding="10,5">
                             <StackPanel Orientation="Horizontal" Spacing="7">
                             <StackPanel Orientation="Horizontal" Spacing="7">
-                                <Border Width="10" Height="10" CornerRadius="5" Background="{DynamicResource SuccessBrush}"/>
+                                <Border Width="10" Height="10" CornerRadius="5"
+                                        Background="{Binding IsPlcConnected, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
                                 <TextBlock Text="{Binding PlcStatusText}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
                                 <TextBlock Text="{Binding PlcStatusText}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
-                                           Foreground="{DynamicResource SuccessBrush}" VerticalAlignment="Center"/>
+                                           Foreground="{Binding IsPlcConnected, Converter={x:Static conv:BoolConverters.ToBrush}}"
+                                           VerticalAlignment="Center"/>
                             </StackPanel>
                             </StackPanel>
                         </Border>
                         </Border>
+
+                        <!-- 中控/屏控模式徽章 -->
+                        <Border Background="{DynamicResource ToggleBgBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="12" Padding="10,5">
+                            <StackPanel Orientation="Horizontal" Spacing="7">
+                                <Border Width="10" Height="10" CornerRadius="5"
+                                        Background="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
+                                <TextBlock Text="{Binding ControlModeText}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                                           Foreground="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToBrush}}"
+                                           VerticalAlignment="Center"/>
+                            </StackPanel>
+                        </Border>
+
+                        <!-- 报警汇总角标:有活跃报警时显示红标+计数 -->
+                        <Border IsVisible="{Binding HasActiveAlarm}"
+                                Background="{DynamicResource DangerBrush}" CornerRadius="12" Padding="9,4">
+                            <StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
+                                <Border Width="8" Height="8" CornerRadius="4" Background="White"/>
+                                <TextBlock Text="报警" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                                           Foreground="White" VerticalAlignment="Center"/>
+                                <TextBlock Text="{Binding ActiveAlarmCount}" FontFamily="Consolas, monospace"
+                                           FontSize="13" FontWeight="Bold"
+                                           Foreground="White" VerticalAlignment="Center"/>
+                            </StackPanel>
+                        </Border>
+
+                        <TextBlock Text="{Binding CurrentDateText}"
+                                   FontFamily="Microsoft YaHei" FontSize="11"
+                                   Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                         <TextBlock Text="{Binding CurrentTime, StringFormat='{}{0:HH:mm:ss}'}"
                         <TextBlock Text="{Binding CurrentTime, StringFormat='{}{0:HH:mm:ss}'}"
                                    FontFamily="Consolas, monospace" FontSize="16" FontWeight="Bold"
                                    FontFamily="Consolas, monospace" FontSize="16" FontWeight="Bold"
                                    Foreground="{DynamicResource SuccessBrush}" VerticalAlignment="Center"/>
                                    Foreground="{DynamicResource SuccessBrush}" VerticalAlignment="Center"/>
@@ -36,123 +67,275 @@
                 </Grid>
                 </Grid>
             </Border>
             </Border>
 
 
-            <!-- ═══ 主内容区 ═══ -->
+            <!-- ═══ 主内容区:左侧 2D 图 + 泵控制条 | 右侧数据栏 ═══ -->
             <Grid Grid.Row="1" ColumnDefinitions="*,300" Margin="14,12">
             <Grid Grid.Row="1" ColumnDefinitions="*,300" Margin="14,12">
 
 
-                <!-- 左侧:设备布局区 -->
-                <ScrollViewer Grid.Column="0" Margin="0,0,10,0">
-                    <StackPanel Spacing="10">
+                <!-- 左列:2D 工艺图(占满)+ 底部泵控制条(一屏无滚动) -->
+                <Grid Grid.Column="0" RowDefinitions="*,Auto" Margin="0,0,10,0">
 
 
-                        <!-- 水箱/水池 -->
-                        <Border Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14">
-                            <StackPanel>
-                                <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,10">
-                                    <Border Background="{DynamicResource InfoBrush}" Width="3" Height="16" CornerRadius="1"/>
-                                    <TextBlock Text="液位监控" FontFamily="Consolas, monospace" FontSize="12"
-                                               FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                    <!-- 2D/3D 工艺流程图 -->
+                    <Border Grid.Row="0" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" ClipToBounds="True">
+                        <Grid RowDefinitions="Auto,*">
+                            <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="14,10,14,6">
+                                <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="16" CornerRadius="1"/>
+                                <TextBlock Text="工艺流程" FontFamily="Consolas, monospace" FontSize="12"
+                                           FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <!-- 2D/3D 切换按钮组:仅在配置启用 3D 时显示 -->
+                                <StackPanel Orientation="Horizontal" Spacing="4" Margin="8,0,0,0"
+                                            IsVisible="{Binding IsViewSwitchVisible}">
+                                    <Border Background="{DynamicResource BorderBrush}" Width="1" Height="16" Margin="0,0,4,0"/>
+                                    <Button Content="2D" Command="{Binding Set2DViewCommand}" IsEnabled="{Binding Is3DView}" Classes="view-switch"/>
+                                    <Button Content="3D" Command="{Binding Set3DViewCommand}" IsEnabled="{Binding Is2DView}" Classes="view-switch"/>
                                 </StackPanel>
                                 </StackPanel>
-                                <ItemsControl ItemsSource="{Binding Tanks}">
-                                    <ItemsControl.ItemTemplate>
-                                        <DataTemplate x:DataType="vm:TankItemViewModel">
-                                            <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5"
-                                                    Padding="12" Margin="0,0,0,8" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
-                                                <Grid ColumnDefinitions="140,*">
-                                                    <controls:WaterTankControl Grid.Column="0" Width="120" Height="140"
-                                                        WaterLevel="{Binding Level}" Text="{Binding Name}"/>
-                                                    <StackPanel Grid.Column="1" Margin="14,0,0,0" Spacing="4" VerticalAlignment="Center">
+                            </StackPanel>
+                            <Border Grid.Row="1" Background="{DynamicResource PanelBgBrush}" CornerRadius="5" Padding="8" Margin="8,0,8,8"
+                                    BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                <Grid>
+                                    <controls:PumpStation2DControl
+                                        IsVisible="{Binding Is2DView}"
+                                        Profile="{Binding Profile}"
+                                        AnalogValues="{Binding AnalogValues}"
+                                        DigitalValues="{Binding DigitalValues}"
+                                        DeviceClickedCommand="{Binding OnDeviceClickedCommand}"/>
+                                    <controls:PumpStation3DControl
+                                        IsVisible="{Binding Is3DView}"
+                                        Profile="{Binding Profile}"
+                                        AnalogValues="{Binding AnalogValues}"
+                                        DigitalValues="{Binding DigitalValues}"
+                                        DeviceClickedCommand="{Binding OnDeviceClickedCommand}"/>
+
+                                    <!-- 泵操作浮层:点击图内泵弹出(防误触,不再直接启停) -->
+                                    <Border IsVisible="{Binding IsPumpPopupOpen}"
+                                            Background="#80000000" CornerRadius="5">
+                                        <Border Width="270" HorizontalAlignment="Center" VerticalAlignment="Center"
+                                                Background="{DynamicResource SurfaceBgBrush}" CornerRadius="8" Padding="16,12"
+                                                BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                            <StackPanel Spacing="8">
+                                                <!-- 标题 + 关闭 -->
+                                                <Grid ColumnDefinitions="*,Auto">
+                                                    <StackPanel Orientation="Horizontal" Spacing="8">
+                                                        <Border Width="10" Height="10" CornerRadius="5" VerticalAlignment="Center"
+                                                                Background="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
+                                                        <TextBlock Text="{Binding SelectedPump.Name}" FontSize="15" FontWeight="Bold"
+                                                                   Foreground="{DynamicResource TextPrimaryBrush}"/>
+                                                    </StackPanel>
+                                                    <Button Grid.Column="1" Content="✕" Command="{Binding ClosePumpPopupCommand}"
+                                                            Padding="6,2" FontSize="11"/>
+                                                </Grid>
+
+                                                <!-- 数据 -->
+                                                <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5" Padding="10,8"
+                                                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                                    <StackPanel Spacing="4">
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="即时液位" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding Level, StringFormat='{}{0:F2} m'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="14" FontWeight="Bold"
-                                                                       Foreground="{DynamicResource SuccessBrush}"/>
+                                                            <TextBlock Text="运行状态" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
+                                                                       Foreground="{DynamicResource SuccessBrush}"
+                                                                       IsVisible="{Binding SelectedPump.IsRunning}" Text="运行中"/>
+                                                            <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12"
+                                                                       Foreground="{DynamicResource TextSecondaryBrush}"
+                                                                       IsVisible="{Binding !SelectedPump.IsRunning}" Text="已停止"/>
                                                         </Grid>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="启泵1液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding StartPump1Level, StringFormat='{}{0:F2}'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                                            <TextBlock Text="电流" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.Current, StringFormat='{}{0:F1} A'}"
+                                                                       FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
+                                                                       Foreground="{DynamicResource SuccessBrush}"/>
                                                         </Grid>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="启泵2液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding StartPump2Level, StringFormat='{}{0:F2}'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                                            <TextBlock Text="日台时" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12"
+                                                                       Foreground="{DynamicResource TextSecondaryBrush}">
+                                                                <TextBlock.Text>
+                                                                    <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
+                                                                        <Binding Path="SelectedPump.DailyHours"/>
+                                                                        <Binding Path="SelectedPump.DailyMinutes"/>
+                                                                    </MultiBinding>
+                                                                </TextBlock.Text>
+                                                            </TextBlock>
                                                         </Grid>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="启泵3液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding StartPump3Level, StringFormat='{}{0:F2}'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                                            <TextBlock Text="控制模式" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
+                                                                       FontFamily="Consolas, monospace" FontSize="12"
+                                                                       Foreground="{DynamicResource InfoBrush}"/>
                                                         </Grid>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="停泵液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding StopLevel, StringFormat='{}{0:F2}'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource WarningBrush}"/>
+                                                            <TextBlock Text="手自动" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToAutoModeText}}"
+                                                                       FontFamily="Consolas, monospace" FontSize="12"
+                                                                       Foreground="{DynamicResource WarningBrush}"/>
                                                         </Grid>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="保护液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding ProtectionLevel, StringFormat='{}{0:F2}'}"
-                                                                       FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
+                                                            <TextBlock Text="故障" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="故障!" FontSize="12" FontWeight="Bold"
+                                                                       Foreground="{DynamicResource DangerBrush}"
+                                                                       IsVisible="{Binding SelectedPump.HasFault}"/>
+                                                            <TextBlock Grid.Column="1" Text="正常" FontSize="12"
+                                                                       Foreground="{DynamicResource TextSecondaryBrush}"
+                                                                       IsVisible="{Binding !SelectedPump.HasFault}"/>
                                                         </Grid>
                                                         </Grid>
                                                     </StackPanel>
                                                     </StackPanel>
+                                                </Border>
+
+                                                <!-- 模式切换 -->
+                                                <Grid ColumnDefinitions="*,*" Margin="0,4,0,0" ColumnSpacing="6">
+                                                    <!-- 中控/屏控 状态指示 (只读,全局切换在标题栏) -->
+                                                    <Border Grid.Column="0" CornerRadius="4" Padding="0,6"
+                                                            BorderBrush="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
+                                                            BorderThickness="1"
+                                                            Background="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}">
+                                                        <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="11"
+                                                                   Text="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
+                                                                   Foreground="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
+                                                    </Border>
+                                                    <!-- 手动/自动 切换 (单控件,双向切换) -->
+                                                    <Border Grid.Column="1" CornerRadius="4" Padding="0,6"
+                                                            BorderBrush="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                            BorderThickness="1"
+                                                            Background="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}">
+                                                        <Button Content="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
+                                                               Command="{Binding SelectedPump.ToggleManualModeCommand}"
+                                                               FontSize="11" Padding="0" Margin="0"
+                                                               Background="Transparent" BorderThickness="0"
+                                                               HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
+                                                               Foreground="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"/>
+                                                    </Border>
                                                 </Grid>
                                                 </Grid>
-                                            </Border>
-                                        </DataTemplate>
-                                    </ItemsControl.ItemTemplate>
-                                </ItemsControl>
-                            </StackPanel>
-                        </Border>
 
 
-                        <!-- 泵组 -->
-                        <Border Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14">
-                            <StackPanel>
-                                <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,10">
+                                                <!-- 启停切换 (单控件) -->
+                                                <Border CornerRadius="5" Padding="0,8" Margin="0,4,0,0"
+                                                        BorderBrush="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
+                                                        BorderThickness="1"
+                                                        Background="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}">
+                                                    <Button Content="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
+                                                           Command="{Binding SelectedPump.TogglePumpCommand}"
+                                                           FontSize="13" Padding="0" Margin="0"
+                                                           Background="Transparent" BorderThickness="0"
+                                                           HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
+                                                           Foreground="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"/>
+                                                </Border>
+                                            </StackPanel>
+                                        </Border>
+                                    </Border>
+                                </Grid>
+                            </Border>
+                        </Grid>
+                    </Border>
+
+                    <!-- 底部泵控制条:看板 + 数据 + 启停一体化,无需滚动 -->
+                    <Border Grid.Row="1" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="10,8" Margin="0,10,0,0">
+                        <Grid RowDefinitions="Auto,Auto">
+                            <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="2,0,2,8">
+                                <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
                                     <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="16" CornerRadius="1"/>
                                     <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="16" CornerRadius="1"/>
-                                    <TextBlock Text="排污泵组" FontFamily="Consolas, monospace" FontSize="12"
+                                    <TextBlock Text="泵组控制" FontFamily="Consolas, monospace" FontSize="12"
                                                FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
                                                FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                    <!-- 运行台数概览 -->
+                                    <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="8" Padding="8,2"
+                                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                        <StackPanel Orientation="Horizontal" Spacing="5">
+                                            <TextBlock Text="运行" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                            <TextBlock FontFamily="Consolas, monospace" FontSize="11" FontWeight="Bold"
+                                                       Foreground="{DynamicResource SuccessBrush}">
+                                                <TextBlock.Text>
+                                                    <MultiBinding StringFormat="{}{0}/{1}">
+                                                        <Binding Path="RunningPumpCount"/>
+                                                        <Binding Path="TotalPumpCount"/>
+                                                    </MultiBinding>
+                                                </TextBlock.Text>
+                                            </TextBlock>
+                                            <TextBlock Text="台" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                        </StackPanel>
+                                    </Border>
                                 </StackPanel>
                                 </StackPanel>
-                                <ItemsControl ItemsSource="{Binding Pumps}">
-                                    <ItemsControl.ItemsPanel>
-                                        <ItemsPanelTemplate>
-                                            <WrapPanel/>
-                                        </ItemsPanelTemplate>
-                                    </ItemsControl.ItemsPanel>
-                                    <ItemsControl.ItemTemplate>
-                                        <DataTemplate x:DataType="vm:PumpItemViewModel">
-                                            <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5"
-                                                    Width="200" Margin="4" Padding="12"
-                                                    BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
-                                                <StackPanel Spacing="6">
-                                                    <!-- 泵名称 + 状态指示灯 -->
-                                                    <Grid ColumnDefinitions="*,Auto">
+
+                                <!-- 建议启动:按编号顺序,选第一个停运且无故障的泵 -->
+                                <TextBlock Grid.Column="1" Margin="10,0" VerticalAlignment="Center"
+                                           IsVisible="{Binding HasSuggestedPump}"
+                                           FontFamily="Microsoft YaHei" FontSize="11"
+                                           Foreground="{DynamicResource WarningBrush}">
+                                    <TextBlock.Text>
+                                        <MultiBinding StringFormat="建议启动 {0}(按编号顺序,优先启动)">
+                                            <Binding Path="SuggestedPumpName"/>
+                                        </MultiBinding>
+                                    </TextBlock.Text>
+                                </TextBlock>
+
+                                <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
+                                    <!-- 全局中控/屏控切换 -->
+                                    <Border CornerRadius="4" Padding="6,2"
+                                            BorderBrush="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
+                                            BorderThickness="1"
+                                            Background="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}">
+                                        <Button Content="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleText}}"
+                                               Command="{Binding ToggleControlModeCommand}"
+                                               FontSize="10" Padding="0" Margin="0"
+                                               Background="Transparent" BorderThickness="0"
+                                               Foreground="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
+                                    </Border>
+                                    <Button Content="日台时清零" Command="{Binding DailyResetAllCommand}"
+                                            Padding="10,4" FontSize="10" Classes="btn-warning"/>
+                                </StackPanel>
+                            </Grid>
+
+                            <ItemsControl Grid.Row="1" ItemsSource="{Binding Pumps}">
+                                <ItemsControl.ItemsPanel>
+                                    <ItemsPanelTemplate>
+                                        <UniformGrid Rows="1"/>
+                                    </ItemsPanelTemplate>
+                                </ItemsControl.ItemsPanel>
+                                <ItemsControl.ItemTemplate>
+                                    <DataTemplate x:DataType="vm:PumpItemViewModel">
+                                        <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5"
+                                                Margin="0,0,6,0" Padding="10,8"
+                                                BorderBrush="{Binding IsSuggested, Converter={x:Static conv:BoolConverters.ToSuggestBorder}}"
+                                                BorderThickness="1.5">
+                                            <Grid ColumnDefinitions="Auto,*,Auto">
+                                                <!-- 左:状态灯 + 名称 + 模式(建议启动 → 琥珀色角标) -->
+                                                <StackPanel Orientation="Vertical" Spacing="3" VerticalAlignment="Center">
+                                                    <StackPanel Orientation="Horizontal" Spacing="6">
+                                                        <Border Width="10" Height="10" CornerRadius="5" VerticalAlignment="Center"
+                                                                Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
                                                         <TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold"
                                                         <TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Bold"
                                                                    Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
                                                                    Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
-                                                        <Border Grid.Column="1" Width="12" Height="12" CornerRadius="6"
-                                                                Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
-                                                    </Grid>
-
-                                                    <!-- 控制模式 -->
-                                                    <Grid ColumnDefinitions="*,Auto">
-                                                        <TextBlock Text="控制模式" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                        <TextBlock Grid.Column="1" Text="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
-                                                                   FontFamily="Consolas, monospace" FontSize="10"
-                                                                   Foreground="{DynamicResource InfoBrush}"/>
-                                                    </Grid>
+                                                        <Border IsVisible="{Binding IsSuggested}" Background="#FFB300"
+                                                                CornerRadius="3" Padding="4,1">
+                                                            <TextBlock Text="建议" FontSize="9" FontWeight="Bold"
+                                                                       Foreground="#1B1F27" VerticalAlignment="Center"/>
+                                                        </Border>
+                                                        <TextBlock Text="故障!" FontSize="10" FontWeight="Bold"
+                                                                   Foreground="{DynamicResource DangerBrush}" VerticalAlignment="Center"
+                                                                   IsVisible="{Binding HasFault}"/>
+                                                    </StackPanel>
+                                                    <TextBlock Text="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
+                                                               FontFamily="Consolas, monospace" FontSize="9"
+                                                               Foreground="{DynamicResource InfoBrush}"/>
+                                                </StackPanel>
 
 
-                                                    <!-- 泵信息 -->
-                                                    <Grid ColumnDefinitions="*,Auto">
-                                                        <TextBlock Text="电流" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                <!-- 中:电流(带负荷条) / 日台时 -->
+                                                <StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
+                                                    <Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
+                                                        <TextBlock Text="电流" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
                                                         <TextBlock Grid.Column="1" Text="{Binding Current, StringFormat='{}{0:F1} A'}"
                                                         <TextBlock Grid.Column="1" Text="{Binding Current, StringFormat='{}{0:F1} A'}"
-                                                                   FontFamily="Consolas, monospace" FontSize="12"
-                                                                   Foreground="{DynamicResource SuccessBrush}"/>
+                                                                   FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
+                                                                   Foreground="{Binding IsOverload, Converter={x:Static conv:BoolConverters.ToOverloadBrush}}"/>
                                                     </Grid>
                                                     </Grid>
-                                                    <Grid ColumnDefinitions="*,Auto">
-                                                        <TextBlock Text="故障" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                        <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="11"
-                                                                   Foreground="{DynamicResource DangerBrush}"
-                                                                   IsVisible="{Binding HasFault}" Text="故障!"/>
+                                                    <!-- 电流负荷条:当前/额定,≥100% 超载变红+角标 -->
+                                                    <Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
+                                                        <ProgressBar Grid.Column="0" Height="4" Minimum="0" Maximum="1"
+                                                                     VerticalAlignment="Center"
+                                                                     Value="{Binding CurrentLoadRatio}"
+                                                                     Background="#2AFFFFFF"
+                                                                     Foreground="{Binding IsOverload, Converter={x:Static conv:BoolConverters.ToOverloadBrush}}"/>
+                                                        <TextBlock Grid.Column="1" Text="超载" FontSize="8" FontWeight="Bold"
+                                                                   Foreground="{DynamicResource DangerBrush}" VerticalAlignment="Center"
+                                                                   IsVisible="{Binding IsOverload}"/>
                                                     </Grid>
                                                     </Grid>
-                                                    <Grid ColumnDefinitions="Auto,*,Auto">
-                                                        <TextBlock Text="日台时" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <Grid ColumnDefinitions="*,Auto">
+                                                        <TextBlock Text="日台时" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
                                                         <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="11"
                                                         <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="11"
-                                                                   Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="4,0">
+                                                                   Foreground="{DynamicResource TextSecondaryBrush}">
                                                             <TextBlock.Text>
                                                             <TextBlock.Text>
                                                                 <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
                                                                 <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
                                                                     <Binding Path="DailyHours"/>
                                                                     <Binding Path="DailyHours"/>
@@ -161,34 +344,142 @@
                                                             </TextBlock.Text>
                                                             </TextBlock.Text>
                                                         </TextBlock>
                                                         </TextBlock>
                                                     </Grid>
                                                     </Grid>
+                                                </StackPanel>
 
 
-                                                    <!-- 操作按钮 -->
-                                                    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6" Margin="0,4,0,0">
-                                                        <Button Content="启动" Command="{Binding StartCommand}" Padding="10,4" FontSize="10"
-                                                                Classes="btn-success"/>
-                                                        <Button Content="停止" Command="{Binding StopCommand}" Padding="10,4" FontSize="10"
-                                                                Classes="btn-danger"/>
-                                                    </StackPanel>
+                                                <!-- 右:模式切换 + 启停按钮 -->
+                                                <StackPanel Grid.Column="2" Orientation="Vertical" Spacing="4" VerticalAlignment="Center">
+                                                    <!-- 第一行:手动/自动 切换(单控件,中控/屏控已移至全局标题栏) -->
+                                                    <Border CornerRadius="4" Padding="6,2"
+                                                            BorderBrush="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                            BorderThickness="1"
+                                                            Background="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}">
+                                                        <Button Content="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
+                                                               Command="{Binding ToggleManualModeCommand}"
+                                                               FontSize="9" Padding="0" Margin="0"
+                                                               Background="Transparent" BorderThickness="0"
+                                                               Foreground="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"/>
+                                                    </Border>
+                                                    <!-- 第二行:启停切换 (单控件,IsEnabled 由 CanExecute 驱动) -->
+                                                    <Border CornerRadius="4" Padding="9,4"
+                                                            BorderBrush="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
+                                                            BorderThickness="1"
+                                                            Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}">
+                                                        <Button Content="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
+                                                               Command="{Binding TogglePumpCommand}"
+                                                               FontSize="10" Padding="0" Margin="0"
+                                                               Background="Transparent" BorderThickness="0"
+                                                               Foreground="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"/>
+                                                    </Border>
                                                 </StackPanel>
                                                 </StackPanel>
-                                            </Border>
-                                        </DataTemplate>
-                                    </ItemsControl.ItemTemplate>
-                                </ItemsControl>
-                                <!-- 日台时统一清零(3台泵共用 M19.4) -->
-                                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8" Margin="0,8,0,0">
-                                    <TextBlock Text="3台泵共用清零地址" FontSize="9" Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center"/>
-                                    <Button Content="日台时清零" Command="{Binding DailyResetAllCommand}" Padding="10,4" FontSize="10" Classes="btn-warning"/>
-                                </StackPanel>
-                            </StackPanel>
-                        </Border>
-                    </StackPanel>
-                </ScrollViewer>
+                                            </Grid>
+                                        </Border>
+                                    </DataTemplate>
+                                </ItemsControl.ItemTemplate>
+                            </ItemsControl>
+                        </Grid>
+                    </Border>
+                </Grid>
 
 
-                <!-- 右侧面板 -->
-                <Grid Grid.Column="1" RowDefinitions="Auto,Auto,*">
+                <!-- 右侧数据栏:液位阈值 → 仪表 → 累计 → 故障 -->
+                <Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
 
 
-                    <!-- 仪表 -->
+                    <!-- 液位阈值(紧凑列表,大水箱图取消——2D 图已含水池可视化) -->
                     <Border Grid.Row="0" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12" Margin="0,0,0,8">
                     <Border Grid.Row="0" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12" Margin="0,0,0,8">
+                        <StackPanel>
+                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
+                                <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
+                                <TextBlock Text="液位监控" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                            </StackPanel>
+                            <ItemsControl ItemsSource="{Binding Tanks}">
+                                <ItemsControl.ItemTemplate>
+                                    <DataTemplate x:DataType="vm:TankItemViewModel">
+                                        <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5" Padding="10,8"
+                                                Margin="0,0,0,6" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                            <Border.Resources>
+                                                <!-- 液位趋势曲线下方半透明填充(15% 透明的主题绿) -->
+                                                <SolidColorBrush x:Key="LocalSparkFillBrush" Color="#2600897B"/>
+                                            </Border.Resources>
+                                            <StackPanel>
+                                                <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,4">
+                                                    <TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Bold"
+                                                               Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                                    <!-- 液位工况档位徽章 -->
+                                                    <Border HorizontalAlignment="Left" Margin="8,0"
+                                                            CornerRadius="3" Padding="6,2"
+                                                            Background="{Binding CurrentStage, Converter={x:Static conv:TankStageConverters.ToBrush}}">
+                                                        <TextBlock Text="{Binding CurrentStage, Converter={x:Static conv:TankStageConverters.ToText}}"
+                                                                   FontSize="9" FontWeight="Bold"
+                                                                   Foreground="{Binding CurrentStage, Converter={x:Static conv:TankStageConverters.ToForeground}}"
+                                                                   VerticalAlignment="Center"/>
+                                                    </Border>
+                                                    <TextBlock Grid.Column="2" Text="{Binding Level, StringFormat='{}{0:F2} m'}"
+                                                               FontFamily="Consolas, monospace" FontSize="15" FontWeight="Bold"
+                                                               Foreground="{DynamicResource SuccessBrush}"/>
+                                                </Grid>
+                                                <!-- 液位实时趋势小曲线(最近 90 秒) -->
+                                                <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="3"
+                                                        Padding="5,3" Margin="0,0,0,6">
+                                                    <controls:SparklineControl Height="30"
+                                                        Samples="{Binding Trend}"
+                                                        LineBrush="{DynamicResource SuccessBrush}"
+                                                        FillBrush="{StaticResource LocalSparkFillBrush}"/>
+                                                </Border>
+                                                <!-- 可编辑阈值 + 同步按钮 -->
+                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
+                                                    <TextBlock Text="启泵1" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump1Level, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
+                                                    <TextBlock Text="启泵2" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump2Level, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
+                                                    <TextBlock Text="启泵3" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump3Level, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
+                                                    <TextBlock Text="停泵液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditStopLevel, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                               Foreground="{DynamicResource WarningBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
+                                                    <TextBlock Text="保护液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditProtectionLevel, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                               Foreground="{DynamicResource DangerBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <!-- 同步到 PLC 按钮 -->
+                                                <Button Content="同步到PLC" Command="{Binding SyncThresholdsToPlcCommand}"
+                                                        IsVisible="{Binding HasWritableAddresses}"
+                                                        IsEnabled="{Binding !IsSyncing}"
+                                                        HorizontalAlignment="Right" Margin="0,6,0,0"
+                                                        FontSize="10" Padding="10,4" Height="24"
+                                                        Background="{DynamicResource InfoBrush}" Foreground="White"/>
+                                                <TextBlock Text="{Binding SyncStatusText}" FontSize="9"
+                                                           Foreground="{DynamicResource TextTertiaryBrush}" Margin="0,3,0,0"
+                                                           TextTrimming="CharacterEllipsis" IsVisible="{Binding SyncStatusText, Converter={x:Static conv:BoolConverters.IsNotEmpty}}"/>
+                                            </StackPanel>
+                                        </Border>
+                                    </DataTemplate>
+                                </ItemsControl.ItemTemplate>
+                            </ItemsControl>
+                        </StackPanel>
+                    </Border>
+
+                    <!-- 仪表 -->
+                    <Border Grid.Row="1" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12" Margin="0,0,0,8">
                         <StackPanel>
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
                                 <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -197,10 +488,39 @@
                             <ItemsControl ItemsSource="{Binding Gauges}">
                             <ItemsControl ItemsSource="{Binding Gauges}">
                                 <ItemsControl.ItemTemplate>
                                 <ItemsControl.ItemTemplate>
                                     <DataTemplate x:DataType="vm:GaugeItemViewModel">
                                     <DataTemplate x:DataType="vm:GaugeItemViewModel">
-                                        <controls:GaugeControl Width="130" Height="150" Margin="2"
-                                            Value="{Binding Value}" Title="{Binding Name}" Unit="{Binding Unit}"
-                                            MinValue="{Binding MinValue}" MaxValue="{Binding MaxValue}"
-                                            ValueColor="{DynamicResource SuccessBrush}"/>
+                                        <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="5" Padding="8,6"
+                                                Margin="0,0,0,6" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                            <StackPanel>
+                                                <controls:GaugeControl Width="130" Height="120" Margin="2"
+                                                    Value="{Binding Value}" Title="{Binding Name}" Unit="{Binding Unit}"
+                                                    MinValue="{Binding MinValue}" MaxValue="{Binding MaxValue}"
+                                                    ValueColor="{DynamicResource SuccessBrush}"/>
+                                                <!-- 可编辑阈值 -->
+                                                <Grid ColumnDefinitions="*,50,Auto" Margin="0,2" IsVisible="{Binding HasWritableAddresses}">
+                                                    <TextBlock Text="高压" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditHighAlarmSet, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="10" Padding="3,1" Height="20"
+                                                               Foreground="{DynamicResource DangerBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="{Binding Unit}" FontSize="8" Foreground="{DynamicResource TextTertiaryBrush}" Margin="3,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Grid ColumnDefinitions="*,50,Auto" Margin="0,2" IsVisible="{Binding HasWritableAddresses}">
+                                                    <TextBlock Text="低压" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                    <TextBox Grid.Column="1" Text="{Binding EditLowAlarmSet, StringFormat='{}{0:F2}'}"
+                                                               FontFamily="Consolas, monospace" FontSize="10" Padding="3,1" Height="20"
+                                                               Foreground="{DynamicResource WarningBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                    <TextBlock Grid.Column="2" Text="{Binding Unit}" FontSize="8" Foreground="{DynamicResource TextTertiaryBrush}" Margin="3,0,0,0" VerticalAlignment="Center"/>
+                                                </Grid>
+                                                <Button Content="同步" Command="{Binding SyncThresholdsToPlcCommand}"
+                                                        IsVisible="{Binding HasWritableAddresses}"
+                                                        IsEnabled="{Binding !IsSyncing}"
+                                                        HorizontalAlignment="Right" Margin="0,4,0,0"
+                                                        FontSize="9" Padding="8,3" Height="20"
+                                                        Background="{DynamicResource InfoBrush}" Foreground="White"/>
+                                                <TextBlock Text="{Binding SyncStatusText}" FontSize="8"
+                                                           Foreground="{DynamicResource TextTertiaryBrush}" Margin="0,2,0,0"
+                                                           TextTrimming="CharacterEllipsis" IsVisible="{Binding SyncStatusText, Converter={x:Static conv:BoolConverters.IsNotEmpty}}"/>
+                                            </StackPanel>
+                                        </Border>
                                     </DataTemplate>
                                     </DataTemplate>
                                 </ItemsControl.ItemTemplate>
                                 </ItemsControl.ItemTemplate>
                                 <ItemsControl.ItemsPanel>
                                 <ItemsControl.ItemsPanel>
@@ -212,8 +532,8 @@
                         </StackPanel>
                         </StackPanel>
                     </Border>
                     </Border>
 
 
-                    <!-- 传感器 -->
-                    <Border Grid.Row="1" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12" Margin="0,0,0,8">
+                    <!-- 累计数据 -->
+                    <Border Grid.Row="2" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12" Margin="0,0,0,8">
                         <StackPanel>
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -234,73 +554,112 @@
                         </StackPanel>
                         </StackPanel>
                     </Border>
                     </Border>
 
 
-                    <!-- 故障信息 -->
-                    <Border Grid.Row="2" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
-                                <Border Background="{DynamicResource DangerBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="故障状态" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
-                            </StackPanel>
+                    <!-- 故障状态 -->
+                    <Border Grid.Row="3" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12">
+                        <ScrollViewer>
+                            <StackPanel>
+                                <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
+                                    <Border Background="{DynamicResource DangerBrush}" Width="3" Height="14" CornerRadius="1"/>
+                                    <TextBlock Text="故障状态" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
+                                </StackPanel>
 
 
-                            <!-- 泵故障 -->
-                            <ItemsControl ItemsSource="{Binding Pumps}">
-                                <ItemsControl.ItemTemplate>
-                                    <DataTemplate x:DataType="vm:PumpItemViewModel">
-                                        <Grid ColumnDefinitions="*,Auto" Margin="0,3">
-                                            <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                            <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
-                                                <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource DangerBrush}"
-                                                        IsVisible="{Binding HasFault}"/>
-                                                <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource SuccessBrush}"
-                                                        IsVisible="{Binding !HasFault}"/>
-                                            </StackPanel>
+                                <!-- 最近报警(实时,最多 6 条,新报警置顶) -->
+                                <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="4" Padding="8,6" Margin="0,0,0,8">
+                                    <StackPanel>
+                                        <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,4">
+                                            <TextBlock Text="最近报警" FontFamily="Consolas, monospace" FontSize="10" FontWeight="Bold"
+                                                       Foreground="{DynamicResource DangerBrush}"/>
+                                            <Button Grid.Column="1" Content="复位" Command="{Binding ResetAllAlarmsCommand}"
+                                                    FontSize="9" Padding="6,2" Height="18" Margin="0,0,4,0"
+                                                    IsVisible="{Binding HasActiveAlarm}"
+                                                    Background="{DynamicResource WarningBrush}" Foreground="White"/>
+                                            <TextBlock Grid.Column="2" Text="{Binding RecentAlarms.Count}"
+                                                       FontFamily="Consolas,monospace" FontSize="10"
+                                                       Foreground="{DynamicResource TextDisabledBrush}"/>
                                         </Grid>
                                         </Grid>
-                                    </DataTemplate>
-                                </ItemsControl.ItemTemplate>
-                            </ItemsControl>
+                                        <TextBlock Text="暂无报警" FontSize="10" Margin="0,2"
+                                                   Foreground="{DynamicResource TextDisabledBrush}"
+                                                   IsVisible="{Binding RecentAlarms.Count, Converter={x:Static conv:BoolConverters.ToIsEmpty}}"/>
+                                        <ItemsControl ItemsSource="{Binding RecentAlarms}"
+                                                      IsVisible="{Binding RecentAlarms.Count, Converter={x:Static conv:BoolConverters.ToHasItems}}">
+                                            <ItemsControl.ItemTemplate>
+                                                <DataTemplate x:DataType="models:AlarmRecord">
+                                                    <Grid ColumnDefinitions="Auto,*,Auto" Margin="0,2">
+                                                        <TextBlock Grid.Column="0" Text="{Binding AlarmTime, StringFormat='{}{0:HH:mm:ss}'}"
+                                                                   FontFamily="Consolas,monospace" FontSize="9"
+                                                                   Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
+                                                        <TextBlock Grid.Column="1" Text="{Binding AlarmMessage}" Margin="6,0"
+                                                                   TextTrimming="CharacterEllipsis" FontSize="10"
+                                                                   Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                                        <Border Grid.Column="2" Width="7" Height="7" CornerRadius="3.5" VerticalAlignment="Center"
+                                                                Background="{DynamicResource DangerBrush}"/>
+                                                    </Grid>
+                                                </DataTemplate>
+                                            </ItemsControl.ItemTemplate>
+                                        </ItemsControl>
+                                    </StackPanel>
+                                </Border>
 
 
-                            <!-- 通用数字量输入(变频器故障等) -->
-                            <ItemsControl ItemsSource="{Binding DigitalInputs}" Margin="0,8,0,0">
-                                <ItemsControl.ItemTemplate>
-                                    <DataTemplate x:DataType="vm:DigitalInputItemViewModel">
-                                        <Grid ColumnDefinitions="*,Auto" Margin="0,3">
-                                            <StackPanel Orientation="Horizontal" Spacing="4">
-                                                <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
-                                                           Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center"/>
-                                            </StackPanel>
-                                            <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
-                                                <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource DangerBrush}"
-                                                        IsVisible="{Binding IsActive}"/>
-                                                <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource SuccessBrush}"
-                                                        IsVisible="{Binding !IsActive}"/>
-                                                <TextBlock Text="{Binding IsActive, Converter={x:Static conv:BoolConverters.ToText}}"
-                                                           FontSize="10" FontFamily="Consolas, monospace"
-                                                           Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
-                                            </StackPanel>
-                                        </Grid>
-                                    </DataTemplate>
-                                </ItemsControl.ItemTemplate>
-                            </ItemsControl>
+                                <!-- 泵故障 -->
+                                <ItemsControl ItemsSource="{Binding Pumps}">
+                                    <ItemsControl.ItemTemplate>
+                                        <DataTemplate x:DataType="vm:PumpItemViewModel">
+                                            <Grid ColumnDefinitions="*,Auto" Margin="0,3">
+                                                <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
+                                                    <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource DangerBrush}"
+                                                            IsVisible="{Binding HasFault}"/>
+                                                    <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource SuccessBrush}"
+                                                            IsVisible="{Binding !HasFault}"/>
+                                                </StackPanel>
+                                            </Grid>
+                                        </DataTemplate>
+                                    </ItemsControl.ItemTemplate>
+                                </ItemsControl>
 
 
-                            <!-- 通用数字量输出(复位按钮等) -->
-                            <ItemsControl ItemsSource="{Binding DigitalOutputs}" Margin="0,8,0,0">
-                                <ItemsControl.ItemTemplate>
-                                    <DataTemplate x:DataType="vm:DigitalOutputItemViewModel">
-                                        <Grid ColumnDefinitions="*,Auto" Margin="0,3">
-                                            <StackPanel Orientation="Horizontal" Spacing="4">
-                                                <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
-                                                           Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center"/>
-                                            </StackPanel>
-                                            <Button Grid.Column="1" Content="执行" Command="{Binding PulseCommand}"
-                                                    IsEnabled="{Binding !IsPulsing}"
-                                                    Padding="8,2" FontSize="9" Classes="btn-warning"/>
-                                        </Grid>
-                                    </DataTemplate>
-                                </ItemsControl.ItemTemplate>
-                            </ItemsControl>
-                        </StackPanel>
+                                <!-- 通用数字量输入(变频器故障等) -->
+                                <ItemsControl ItemsSource="{Binding DigitalInputs}" Margin="0,8,0,0">
+                                    <ItemsControl.ItemTemplate>
+                                        <DataTemplate x:DataType="vm:DigitalInputItemViewModel">
+                                            <Grid ColumnDefinitions="*,Auto" Margin="0,3">
+                                                <StackPanel Orientation="Vertical">
+                                                    <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                    <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
+                                                               Foreground="{DynamicResource TextDisabledBrush}"/>
+                                                </StackPanel>
+                                                <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
+                                                    <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource DangerBrush}"
+                                                            IsVisible="{Binding IsActive}"/>
+                                                    <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource SuccessBrush}"
+                                                            IsVisible="{Binding !IsActive}"/>
+                                                    <TextBlock Text="{Binding IsActive, Converter={x:Static conv:BoolConverters.ToText}}"
+                                                               FontSize="10" FontFamily="Consolas, monospace"
+                                                               Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
+                                                </StackPanel>
+                                            </Grid>
+                                        </DataTemplate>
+                                    </ItemsControl.ItemTemplate>
+                                </ItemsControl>
+
+                                <!-- 通用数字量输出(复位按钮等) -->
+                                <ItemsControl ItemsSource="{Binding DigitalOutputs}" Margin="0,8,0,0">
+                                    <ItemsControl.ItemTemplate>
+                                        <DataTemplate x:DataType="vm:DigitalOutputItemViewModel">
+                                            <Grid ColumnDefinitions="*,Auto" Margin="0,3">
+                                                <StackPanel Orientation="Vertical">
+                                                    <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                    <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
+                                                               Foreground="{DynamicResource TextDisabledBrush}"/>
+                                                </StackPanel>
+                                                <Button Grid.Column="1" Content="执行" Command="{Binding PulseCommand}"
+                                                        IsEnabled="{Binding !IsPulsing}" VerticalAlignment="Center"
+                                                        Padding="8,2" FontSize="9" Classes="btn-warning"/>
+                                            </Grid>
+                                        </DataTemplate>
+                                    </ItemsControl.ItemTemplate>
+                                </ItemsControl>
+                            </StackPanel>
+                        </ScrollViewer>
                     </Border>
                     </Border>
                 </Grid>
                 </Grid>
             </Grid>
             </Grid>

+ 7 - 47
src/YZWater.Avalonia/Views/ViewBView.axaml

@@ -22,31 +22,10 @@
 
 
             <!-- 内容 -->
             <!-- 内容 -->
             <ScrollViewer Grid.Row="1" Margin="8,4">
             <ScrollViewer Grid.Row="1" Margin="8,4">
-                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto">
-
-                    <!-- 项目档案 & 协议设置 -->
-                    <Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
-                                <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="项目档案" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
-                                           Foreground="{DynamicResource TextSecondaryBrush}"/>
-                                <TextBlock Text="{Binding CurrentProjectName, StringFormat='(当前: {0})'}" FontSize="10"
-                                           Foreground="{DynamicResource InfoBrush}" VerticalAlignment="Center" Margin="8,0,0,0"/>
-                            </StackPanel>
-                            <Grid ColumnDefinitions="120,*" RowDefinitions="Auto,Auto" Margin="4,0">
-                                <TextBlock Grid.Row="0" Grid.Column="0" Text="项目选择" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <ComboBox Grid.Row="0" Grid.Column="1" ItemsSource="{Binding AvailableProjects}" SelectedItem="{Binding ActiveProject}" Margin="0,4" HorizontalAlignment="Stretch"/>
-
-                                <TextBlock Grid.Row="1" Grid.Column="0" Text="PLC 协议" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <ComboBox Grid.Row="1" Grid.Column="1" ItemsSource="{Binding AvailableProtocols}" SelectedItem="{Binding PlcProtocol}" Margin="0,4" HorizontalAlignment="Stretch"/>
-                            </Grid>
-                        </StackPanel>
-                    </Border>
+                <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
 
 
                     <!-- PLC 连接设置 -->
                     <!-- PLC 连接设置 -->
-                    <Border Grid.Row="1" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="0" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,4,4">
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,4,4">
                         <StackPanel>
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -75,7 +54,7 @@
                     </Border>
                     </Border>
 
 
                     <!-- 报警阈值设置 -->
                     <!-- 报警阈值设置 -->
-                    <Border Grid.Row="1" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                    <Border Grid.Row="0" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,0,0,4">
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,0,0,4">
                         <StackPanel>
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -99,28 +78,9 @@
                         </StackPanel>
                         </StackPanel>
                     </Border>
                     </Border>
 
 
-                    <!-- 泵频率设置 -->
-                    <Border Grid.Row="2" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
-                                <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding PumpSettingsText}" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
-                                           Foreground="{DynamicResource TextSecondaryBrush}"/>
-                            </StackPanel>
-                            <Grid ColumnDefinitions="120,*,60" Margin="4,0">
-                                <TextBlock Grid.Column="0" Text="{Binding FrequencyText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <NumericUpDown Grid.Column="1" Value="{Binding PumpFrequency}" Minimum="0" Maximum="100" FormatString="F1" Margin="0,4"/>
-                                <TextBlock Grid.Column="2" Text="Hz" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <Button Grid.Column="1" Grid.ColumnSpan="2" Content="写入 PLC" Command="{Binding WriteFrequencyCommand}" Classes="btn-success"
-                                        Margin="0,8,0,0" HorizontalAlignment="Left"/>
-                            </Grid>
-                        </StackPanel>
-                    </Border>
-
                     <!-- 系统设置 -->
                     <!-- 系统设置 -->
-                    <Border Grid.Row="2" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
+                    <Border Grid.Row="1" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
                         <StackPanel>
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                                 <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
                                 <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -135,8 +95,8 @@
                     </Border>
                     </Border>
 
 
                     <!-- 操作按钮 -->
                     <!-- 操作按钮 -->
-                    <Border Grid.Row="3" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
+                    <Border Grid.Row="2" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
                         <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
                         <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
                             <TextBlock Text="ACTIONS" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
                             <TextBlock Text="ACTIONS" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
                                        Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"/>
                                        Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"/>

+ 1 - 0
src/YZWater.Avalonia/Views/ViewDView.axaml

@@ -42,6 +42,7 @@
                         <Button Content="{Binding QueryText}" Command="{Binding LoadAlarmRecordsCommand}" Classes="btn-info"/>
                         <Button Content="{Binding QueryText}" Command="{Binding LoadAlarmRecordsCommand}" Classes="btn-info"/>
                         <Button Content="{Binding ExportText}" Command="{Binding ExportAlarmsCommand}" Classes="btn-success"/>
                         <Button Content="{Binding ExportText}" Command="{Binding ExportAlarmsCommand}" Classes="btn-success"/>
                         <Button Content="{Binding AckAllText}" Command="{Binding ConfirmAllAlarmsCommand}" Classes="btn-warning"/>
                         <Button Content="{Binding AckAllText}" Command="{Binding ConfirmAllAlarmsCommand}" Classes="btn-warning"/>
+                        <Button Content="{Binding ResetAllText}" Command="{Binding ResetAllAlarmsCommand}" Classes="btn-danger"/>
                         <Button Content="{Binding PurgeText}" Command="{Binding ClearHistoryCommand}" Classes="btn-danger"/>
                         <Button Content="{Binding PurgeText}" Command="{Binding ClearHistoryCommand}" Classes="btn-danger"/>
                     </WrapPanel>
                     </WrapPanel>
                 </Border>
                 </Border>

+ 17 - 0
src/YZWater.Avalonia/YZWater.Avalonia.csproj

@@ -32,4 +32,21 @@
     <AvaloniaResource Include="Assets\app-icon.png" />
     <AvaloniaResource Include="Assets\app-icon.png" />
   </ItemGroup>
   </ItemGroup>
 
 
+  <!-- 构建时将根目录配置文件复制到输出目录(不覆盖已有文件) -->
+  <Target Name="CopyRootConfig" BeforeTargets="Build">
+    <Copy SourceFiles="..\..\yzwater-config.json"
+          DestinationFiles="$(OutputPath)yzwater-config.json"
+          SkipUnchangedFiles="true"
+          Condition="Exists('..\..\yzwater-config.json')" />
+    <!-- 复制 projects/ 目录到输出目录 -->
+    <MakeDir Directories="$(OutputPath)projects" Condition="Exists('..\..\projects') And !Exists('$(OutputPath)projects')" />
+    <Copy SourceFiles="@(ProjectFiles)"
+          DestinationFolder="$(OutputPath)projects"
+          SkipUnchangedFiles="true" />
+  </Target>
+
+  <ItemGroup>
+    <ProjectFiles Include="..\..\projects\*.json" />
+  </ItemGroup>
+
 </Project>
 </Project>

+ 0 - 2
src/YZWater.Core/Models/PlcDataModel.cs

@@ -111,8 +111,6 @@ public partial class PlcDataModel : ObservableObject
                 DigitalRegisters[$"{pump.Name}.Fault"] = false;
                 DigitalRegisters[$"{pump.Name}.Fault"] = false;
             if (!string.IsNullOrEmpty(pump.AutoStateAddress))
             if (!string.IsNullOrEmpty(pump.AutoStateAddress))
                 DigitalRegisters[$"{pump.Name}.AutoMode"] = false;
                 DigitalRegisters[$"{pump.Name}.AutoMode"] = false;
-            if (!string.IsNullOrEmpty(pump.ModeSelectAddress))
-                DigitalRegisters[$"{pump.Name}.ModeSelect"] = false;
             if (!string.IsNullOrEmpty(pump.DailyHoursAddress))
             if (!string.IsNullOrEmpty(pump.DailyHoursAddress))
                 AnalogRegisters[$"{pump.Name}.DailyHours"] = 0;
                 AnalogRegisters[$"{pump.Name}.DailyHours"] = 0;
             if (!string.IsNullOrEmpty(pump.DailyMinutesAddress))
             if (!string.IsNullOrEmpty(pump.DailyMinutesAddress))

+ 48 - 2
src/YZWater.Core/Models/ProjectProfile.cs

@@ -38,6 +38,34 @@ public class ProjectProfile
 
 
     /// <summary>报警阈值配置</summary>
     /// <summary>报警阈值配置</summary>
     public AlarmThresholds AlarmThresholds { get; set; } = new();
     public AlarmThresholds AlarmThresholds { get; set; } = new();
+
+    /// <summary>
+    /// 全局中控/屏控切换地址(所有泵共用,0=中控 1=屏控)
+    /// 如 M1.0 — 所有泵要么全中控,要么全屏控
+    /// </summary>
+    public string? ModeSelectAddress { get; set; }
+
+    /// <summary>控制策略配置 — 决定上位机是否参与自动控制</summary>
+    public ControlConfig Control { get; set; } = new();
+}
+
+/// <summary>
+/// 控制策略配置 — 决定上位机在该项目中的控制角色
+/// EnableAutoControl=false 时上位机仅监控,自动逻辑由 PLC 处理
+/// </summary>
+public class ControlConfig
+{
+    /// <summary>是否启用上位机自动控制(默认 false = PLC 自行处理)</summary>
+    public bool EnableAutoControl { get; set; } = false;
+
+    /// <summary>控制模式描述(UI 显示用,如 "PLC自动" / "远程中控")</summary>
+    public string ControlModeDescription { get; set; } = "PLC自动";
+
+    /// <summary>是否允许上位机手动启停泵</summary>
+    public bool AllowManualControl { get; set; } = true;
+
+    /// <summary>是否允许参数同步写入 PLC</summary>
+    public bool AllowParameterSync { get; set; } = true;
 }
 }
 
 
 /// <summary>
 /// <summary>
@@ -59,17 +87,26 @@ public class PumpConfig
     /// <summary>电流地址 (AI 区,如 AIW18 或对应的 VD 地址)</summary>
     /// <summary>电流地址 (AI 区,如 AIW18 或对应的 VD 地址)</summary>
     public string CurrentAddress { get; set; } = "";
     public string CurrentAddress { get; set; } = "";
 
 
+    /// <summary>
+    /// 额定电流(A,可选)。配置后泵卡电流条按「当前/额定」显示负荷比例,
+    /// 超载(≥100%)变红提醒;未配置(0)时电流条退化为组内相对比例。
+    /// </summary>
+    public float RatedCurrent { get; set; }
+
     /// <summary>手自动状态地址 (读,如 M2.6, 1=自动 0=手动)</summary>
     /// <summary>手自动状态地址 (读,如 M2.6, 1=自动 0=手动)</summary>
     public string AutoStateAddress { get; set; } = "";
     public string AutoStateAddress { get; set; } = "";
 
 
     /// <summary>日台时-小时地址 (读,如 VD950)</summary>
     /// <summary>日台时-小时地址 (读,如 VD950)</summary>
     public string DailyHoursAddress { get; set; } = "";
     public string DailyHoursAddress { get; set; } = "";
 
 
+    /// <summary>日台时-小时数据类型: "REAL" / "INT" / "DINT" (默认 REAL)</summary>
+    public string DailyHoursDataType { get; set; } = "REAL";
+
     /// <summary>日台时-分钟地址 (读,如 VD10)</summary>
     /// <summary>日台时-分钟地址 (读,如 VD10)</summary>
     public string DailyMinutesAddress { get; set; } = "";
     public string DailyMinutesAddress { get; set; } = "";
 
 
-    /// <summary>中控/屏控选择地址 (读,如 M1.0, 0=中控 1=屏控)</summary>
-    public string ModeSelectAddress { get; set; } = "";
+    /// <summary>日台时-分钟数据类型: "REAL" / "INT" / "DINT" (默认 REAL)</summary>
+    public string DailyMinutesDataType { get; set; } = "REAL";
 
 
     // ─── 写入地址 ───
     // ─── 写入地址 ───
 
 
@@ -115,6 +152,9 @@ public class TankConfig
     /// <summary>保护液位设定值</summary>
     /// <summary>保护液位设定值</summary>
     public float? ProtectionLevel { get; set; }
     public float? ProtectionLevel { get; set; }
 
 
+    /// <summary>池体满量程高度(米),用于 UI 液位归一化显示。未配置时自动取阈值中最大值</summary>
+    public float? MaxLevel { get; set; }
+
     /// <summary>启动 1 台泵液位地址 (读写,如 VD500)</summary>
     /// <summary>启动 1 台泵液位地址 (读写,如 VD500)</summary>
     public string StartPump1LevelAddress { get; set; } = "";
     public string StartPump1LevelAddress { get; set; } = "";
 
 
@@ -151,6 +191,12 @@ public class GaugeConfig
     /// <summary>低压/下限设置地址 (读写,如 VD6700)</summary>
     /// <summary>低压/下限设置地址 (读写,如 VD6700)</summary>
     public string LowSetAddress { get; set; } = "";
     public string LowSetAddress { get; set; } = "";
 
 
+    /// <summary>高压报警设定值(同步到 HighSetAddress)</summary>
+    public float? HighAlarmSet { get; set; }
+
+    /// <summary>低压报警设定值(同步到 LowSetAddress)</summary>
+    public float? LowAlarmSet { get; set; }
+
     /// <summary>仪表最小刻度值</summary>
     /// <summary>仪表最小刻度值</summary>
     public double MinValue { get; set; }
     public double MinValue { get; set; }
 
 

+ 20 - 2
src/YZWater.Core/Models/SystemConfig.cs

@@ -12,13 +12,13 @@ public partial class SystemConfig : ObservableObject
     /// PLC IP 地址
     /// PLC IP 地址
     /// </summary>
     /// </summary>
     [ObservableProperty]
     [ObservableProperty]
-    private string _plcIp = "192.168.0.150";
+    private string _plcIp = "192.168.2.100";
 
 
     /// <summary>
     /// <summary>
     /// PLC 端口
     /// PLC 端口
     /// </summary>
     /// </summary>
     [ObservableProperty]
     [ObservableProperty]
-    private int _plcPort = 5000;
+    private int _plcPort = 102;
 
 
     /// <summary>
     /// <summary>
     /// 是否自动连接
     /// 是否自动连接
@@ -128,6 +128,18 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     [ObservableProperty]
     private string _savedUserName = string.Empty;
     private string _savedUserName = string.Empty;
 
 
+    /// <summary>
+    /// 保存的密码(自动登录用,AES-GCM 加密存储,密钥派生自机器名+用户名)
+    /// </summary>
+    [ObservableProperty]
+    private string _savedPassword = string.Empty;
+
+    /// <summary>
+    /// 是否启用自动登录(启动时跳过登录窗口)
+    /// </summary>
+    [ObservableProperty]
+    private bool _autoLogin;
+
     /// <summary>
     /// <summary>
     /// 当前激活的项目名称(对应 projects/ 目录下的 JSON 文件名,不含后缀)
     /// 当前激活的项目名称(对应 projects/ 目录下的 JSON 文件名,不含后缀)
     /// 空字符串表示使用内置默认项目配置
     /// 空字符串表示使用内置默认项目配置
@@ -135,6 +147,12 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     [ObservableProperty]
     private string _activeProject = string.Empty;
     private string _activeProject = string.Empty;
 
 
+    /// <summary>
+    /// 是否启用 3D 工艺图视图(false = 隐藏 3D 按钮,默认仅 2D)
+    /// </summary>
+    [ObservableProperty]
+    private bool _enable3DView;
+
     /// <summary>
     /// <summary>
     /// PLC 轮询间隔(毫秒)
     /// PLC 轮询间隔(毫秒)
     /// </summary>
     /// </summary>

+ 23 - 0
src/YZWater.Core/Models/TankStage.cs

@@ -0,0 +1,23 @@
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 水池液位工况档位 — 依据当前液位与启泵/停泵/保护阈值自动判定当前处于哪个运行阶段。
+/// 泵站典型逻辑:液位下降触停泵 → 液位上升依次触启泵1/2/3 → 超过保护液位触发紧急保护。
+/// </summary>
+public enum TankStage
+{
+    /// <summary>停泵区(低于启泵1液位,所有泵停运区域)</summary>
+    Stop,
+
+    /// <summary>启泵1区(液位达到启泵1液位,需投入第 1 台泵)</summary>
+    Pump1,
+
+    /// <summary>启泵2区(液位达到启泵2液位,需投入第 2 台泵)</summary>
+    Pump2,
+
+    /// <summary>启泵3区(液位达到启泵3液位,需投入第 3 台泵)</summary>
+    Pump3,
+
+    /// <summary>保护水位(超过保护液位,应急/全停风险区)</summary>
+    Protection,
+}

+ 167 - 2
src/YZWater.Core/Services/AlarmService.cs

@@ -39,6 +39,9 @@ public partial class AlarmService : ObservableObject, IDisposable
             FullMode = BoundedChannelFullMode.DropOldest
             FullMode = BoundedChannelFullMode.DropOldest
         });
         });
 
 
+    /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
+    private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
+
     /// <summary>后台写入任务</summary>
     /// <summary>后台写入任务</summary>
     private Task _writerTask = Task.CompletedTask;
     private Task _writerTask = Task.CompletedTask;
     private CancellationTokenSource _writerCts = new();
     private CancellationTokenSource _writerCts = new();
@@ -92,10 +95,27 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     /// </summary>
     public void Start()
     public void Start()
     {
     {
+        LoadThresholdsFromConfig();
         _cts = new CancellationTokenSource();
         _cts = new CancellationTokenSource();
         Log.Information("报警服务已启动");
         Log.Information("报警服务已启动");
     }
     }
 
 
+    /// <summary>
+    /// 从配置文件加载报警阈值(修复 UI 修改不生效的问题)
+    /// </summary>
+    private void LoadThresholdsFromConfig()
+    {
+        var config = ConfigService.GetConfig();
+        if (config == null) return;
+
+        LevelHighAlarm = config.LevelHighAlarm;
+        LevelLowAlarm = config.LevelLowAlarm;
+        FlowHighAlarm = config.FlowHighAlarm;
+        FlowLowAlarm = config.FlowLowAlarm;
+        Log.Debug("报警阈值已从配置加载: 液位高={High}, 液位低={Low}, 流量高={FlowHigh}, 流量低={FlowLow}",
+            LevelHighAlarm, LevelLowAlarm, FlowHighAlarm, FlowLowAlarm);
+    }
+
     /// <summary>
     /// <summary>
     /// 停止报警监控
     /// 停止报警监控
     /// </summary>
     /// </summary>
@@ -403,23 +423,36 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 后台消费 Channel 中的报警记录,串行写入数据库
+    /// 后台消费 Channel 中的报警记录,串行写入数据库。
+    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥),
+    /// 数据库已释放或连续写入失败时退出循环。
     /// </summary>
     /// </summary>
     private async Task ProcessWriteQueueAsync(CancellationToken ct)
     private async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
     {
+        var consecutiveFailures = 0;
         try
         try
         {
         {
             await foreach (var alarm in _writeChannel.Reader.ReadAllAsync(ct))
             await foreach (var alarm in _writeChannel.Reader.ReadAllAsync(ct))
             {
             {
                 try
                 try
                 {
                 {
-                    var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
+                    var (ok, id) = await DatabaseService.ExecuteWriteAsync(
+                        _bgDb, db => db.Insertable(alarm).ExecuteReturnIdentityAsync());
+                    if (!ok) break; // 数据库已关闭,放弃剩余写入
+
                     alarm.Id = id;
                     alarm.Id = id;
+                    consecutiveFailures = 0;
                     Log.Debug("报警记录已保存: {Id}", alarm.Id);
                     Log.Debug("报警记录已保存: {Id}", alarm.Id);
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
                 {
                 {
+                    // 连续失败 3 次退出(数据库不可用)
                     Log.Error(ex, "保存报警记录失败");
                     Log.Error(ex, "保存报警记录失败");
+                    if (++consecutiveFailures >= 3)
+                    {
+                        Log.Warning("报警后台写入已停止(数据库不可用)");
+                        break;
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -508,6 +541,135 @@ public partial class AlarmService : ObservableObject, IDisposable
         }
         }
     }
     }
 
 
+    /// <summary>
+    /// 手动复位报警 — 清除活跃状态 + 点动 PLC 复位地址
+    /// 区别于确认(Acknowledge):复位直接清除报警条件本身
+    /// </summary>
+    /// <returns>true=成功复位, false=未找到报警记录</returns>
+    public async Task<bool> ResetAlarmAsync(int alarmId, string operatorName = "Operator")
+    {
+        try
+        {
+            // 查找报警记录
+            var alarm = await DatabaseService.Db.Queryable<AlarmRecord>()
+                .Where(r => r.Id == alarmId)
+                .FirstAsync();
+
+            if (alarm == null)
+            {
+                Log.Warning("报警复位失败: 未找到报警记录 {Id}", alarmId);
+                return false;
+            }
+
+            // 精确匹配 conditionId:优先精确匹配 AlarmType,其次按 AlarmMessage 包含关系匹配
+            var matchedKey = _activeAlarms.FirstOrDefault(k => k == alarm.AlarmType)
+                ?? _activeAlarms.FirstOrDefault(k => k.Contains(alarm.AlarmMessage) || alarm.AlarmMessage.Contains(k));
+
+            if (matchedKey != null)
+            {
+                _activeAlarms.Remove(matchedKey);
+                _alarmStartTimes.Remove(matchedKey);
+                _alarmTriggeredTimes.Remove(matchedKey);
+                _escalatedAlarms.Remove(matchedKey);
+            }
+            else
+            {
+                // 未找到精确匹配,清除同类型的所有活跃报警(由下次轮询重新评估)
+                var keysToRemove = _activeAlarms.Where(k => k.StartsWith(alarm.AlarmType)).ToList();
+                foreach (var key in keysToRemove)
+                {
+                    _activeAlarms.Remove(key);
+                    _alarmStartTimes.Remove(key);
+                    _alarmTriggeredTimes.Remove(key);
+                    _escalatedAlarms.Remove(key);
+                }
+                Log.Information("报警复位: 未找到精确匹配,清除 {Count} 条同类活跃报警", keysToRemove.Count);
+            }
+
+            // 更新数据库记录
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, DateTime.Now)
+                .SetColumns(r => r.ConfirmedBy, operatorName)
+                .Where(r => r.Id == alarmId)
+                .ExecuteCommandAsync();
+
+            // 点动 PLC 复位地址
+            await PulseResetAddressAsync();
+
+            UpdateAlarmState();
+            AuditService.Log(operatorName, "报警复位", $"报警ID:{alarmId} - {alarm.AlarmMessage}", alarm.AlarmType);
+            Log.Information("报警 {Id} 已复位", alarmId);
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "报警复位失败: {Id}", alarmId);
+            return false;
+        }
+    }
+    /// 复位所有活跃报警 — 清除所有活跃状态 + 点动 PLC 复位地址
+    /// </summary>
+    public async Task ResetAllAlarmsAsync(string operatorName = "Operator")
+    {
+        try
+        {
+            var count = _activeAlarms.Count;
+
+            // 清除所有活跃报警跟踪数据
+            _activeAlarms.Clear();
+            _alarmStartTimes.Clear();
+            _alarmTriggeredTimes.Clear();
+            _escalatedAlarms.Clear();
+
+            // 更新数据库:标记所有未确认报警为已确认
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, DateTime.Now)
+                .SetColumns(r => r.ConfirmedBy, operatorName)
+                .Where(r => !r.IsConfirmed)
+                .ExecuteCommandAsync();
+
+            // 点动 PLC 复位地址
+            await PulseResetAddressAsync();
+
+            UpdateAlarmState();
+            AuditService.Log(operatorName, "全部报警复位", $"清除 {count} 条活跃报警");
+            Log.Information("所有报警已复位 (共 {Count} 条)", count);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "全部报警复位失败");
+        }
+    }
+
+    /// <summary>
+    /// 点动 PLC 报警复位地址(从项目档案的 DigitalOutputs 中查找含"复位"的输出)
+    /// </summary>
+    private async Task PulseResetAddressAsync()
+    {
+        try
+        {
+            var profile = ProjectService.Current;
+            if (profile == null) return;
+
+            var resetOutput = profile.DigitalOutputs
+                .FirstOrDefault(d => d.Name.Contains("复位") || d.Name.Contains("Reset", StringComparison.OrdinalIgnoreCase));
+
+            if (resetOutput == null || string.IsNullOrEmpty(resetOutput.Address))
+                return;
+
+            await PlcService.WriteBoolAsync(resetOutput.Address, true);
+            await Task.Delay(200);
+            await PlcService.WriteBoolAsync(resetOutput.Address, false);
+            Log.Debug("报警复位脉冲: {Address}", resetOutput.Address);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "报警复位脉冲写入失败");
+        }
+    }
+
     /// <summary>
     /// <summary>
     /// 更新报警状态
     /// 更新报警状态
     /// </summary>
     /// </summary>
@@ -572,6 +734,9 @@ public partial class AlarmService : ObservableObject, IDisposable
         _writerCts.Cancel();
         _writerCts.Cancel();
         _writerCts.Dispose();
         _writerCts.Dispose();
 
 
+        // 释放后台任务独占的数据库客户端
+        try { _bgDb.Dispose(); } catch { }
+
         Log.Information("报警服务已释放");
         Log.Information("报警服务已释放");
     }
     }
 }
 }

+ 22 - 2
src/YZWater.Core/Services/AuditService.cs

@@ -24,6 +24,9 @@ public static class AuditService
             FullMode = BoundedChannelFullMode.DropOldest
             FullMode = BoundedChannelFullMode.DropOldest
         });
         });
 
 
+    /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
+    private static readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
+
     /// <summary>后台写入任务</summary>
     /// <summary>后台写入任务</summary>
     private static readonly Task _writerTask;
     private static readonly Task _writerTask;
     private static readonly CancellationTokenSource _writerCts = new();
     private static readonly CancellationTokenSource _writerCts = new();
@@ -35,21 +38,34 @@ public static class AuditService
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 后台消费 Channel 中的审计日志,串行写入数据库
+    /// 后台消费 Channel 中的审计日志,串行写入数据库。
+    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥),
+    /// 数据库已释放或连续写入失败时退出循环。
     /// </summary>
     /// </summary>
     private static async Task ProcessWriteQueueAsync(CancellationToken ct)
     private static async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
     {
+        var consecutiveFailures = 0;
         try
         try
         {
         {
             await foreach (var entry in _writeChannel.Reader.ReadAllAsync(ct))
             await foreach (var entry in _writeChannel.Reader.ReadAllAsync(ct))
             {
             {
                 try
                 try
                 {
                 {
-                    await DatabaseService.Db.Insertable(entry).ExecuteCommandAsync();
+                    var (ok, _) = await DatabaseService.ExecuteWriteAsync(
+                        _bgDb, db => db.Insertable(entry).ExecuteCommandAsync());
+                    if (!ok) break; // 数据库已关闭,放弃剩余写入
+
+                    consecutiveFailures = 0;
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
                 {
                 {
+                    // 连续失败 3 次退出(数据库不可用)
                     Serilog.Log.Error(ex, "审计日志写入失败");
                     Serilog.Log.Error(ex, "审计日志写入失败");
+                    if (++consecutiveFailures >= 3)
+                    {
+                        Serilog.Log.Warning("审计日志后台写入已停止(数据库不可用)");
+                        break;
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -141,6 +157,10 @@ public static class AuditService
                 // 超时或异常不影响退出
                 // 超时或异常不影响退出
             }
             }
             _writerCts?.Dispose();
             _writerCts?.Dispose();
+
+            // 释放后台任务独占的数据库客户端
+            try { _bgDb.Dispose(); } catch { }
+
             Serilog.Log.Debug("AuditService 后台写入任务已停止");
             Serilog.Log.Debug("AuditService 后台写入任务已停止");
         }
         }
         catch (Exception ex)
         catch (Exception ex)

+ 54 - 1
src/YZWater.Core/Services/AutoControlService.cs

@@ -34,6 +34,8 @@ public class AutoControlService : IDisposable
 
 
     private bool _disposed;
     private bool _disposed;
     private bool _isEnabled = true;
     private bool _isEnabled = true;
+    private bool _subscribed;
+    private bool _projectSubscribed;
 
 
     /// <summary>是否启用自动控制</summary>
     /// <summary>是否启用自动控制</summary>
     public bool IsEnabled
     public bool IsEnabled
@@ -81,10 +83,28 @@ public class AutoControlService : IDisposable
 
 
     /// <summary>
     /// <summary>
     /// 启动自动控制(订阅 PLC 数据更新)
     /// 启动自动控制(订阅 PLC 数据更新)
+    /// 根据项目配置决定是否实际激活
     /// </summary>
     /// </summary>
     public void Start()
     public void Start()
     {
     {
+        // 订阅项目切换事件(防重复订阅)
+        if (!_projectSubscribed)
+        {
+            ProjectService.ProjectChanged += OnProjectChanged;
+            _projectSubscribed = true;
+        }
+
+        // 检查项目级自动控制开关
+        var profile = ProjectService.Current;
+        if (profile != null && !profile.Control.EnableAutoControl)
+        {
+            _isEnabled = false;
+            Log.Information("自动控制未启用: 项目 [{Project}] 配置为 PLC 自动控制", profile.Name);
+            return;
+        }
+
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
+        _subscribed = true;
         Log.Information("自动控制系统已启动");
         Log.Information("自动控制系统已启动");
     }
     }
 
 
@@ -93,10 +113,43 @@ public class AutoControlService : IDisposable
     /// </summary>
     /// </summary>
     public void Stop()
     public void Stop()
     {
     {
-        PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+        if (_projectSubscribed)
+        {
+            ProjectService.ProjectChanged -= OnProjectChanged;
+            _projectSubscribed = false;
+        }
+
+        if (_subscribed)
+        {
+            PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+            _subscribed = false;
+        }
         Log.Information("自动控制系统已停止");
         Log.Information("自动控制系统已停止");
     }
     }
 
 
+    /// <summary>
+    /// 项目切换时重新评估自动控制状态
+    /// </summary>
+    private void OnProjectChanged(ProjectProfile? profile)
+    {
+        bool shouldBeEnabled = profile?.Control.EnableAutoControl ?? false;
+
+        if (shouldBeEnabled && !_subscribed)
+        {
+            PlcPollingService.Instance.DataUpdated += OnDataUpdated;
+            _subscribed = true;
+            _isEnabled = true;
+            Log.Information("项目切换: 自动控制已启用");
+        }
+        else if (!shouldBeEnabled && _subscribed)
+        {
+            PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+            _subscribed = false;
+            _isEnabled = false;
+            Log.Information("项目切换: 自动控制已禁用");
+        }
+    }
+
     /// <summary>
     /// <summary>
     /// PLC 数据更新回调 — 核心控制逻辑
     /// PLC 数据更新回调 — 核心控制逻辑
     /// </summary>
     /// </summary>

+ 18 - 11
src/YZWater.Core/Services/DataLoggingService.cs

@@ -18,6 +18,9 @@ public class DataLoggingService : IDisposable
     private bool _disposed;
     private bool _disposed;
     private readonly object _flowLock = new();
     private readonly object _flowLock = new();
 
 
+    /// <summary>后台记录专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
+    private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
+
     // 累计流量(软件侧累加)
     // 累计流量(软件侧累加)
     private float _totalInflow;
     private float _totalInflow;
     private float _totalOutflow;
     private float _totalOutflow;
@@ -51,7 +54,7 @@ public class DataLoggingService : IDisposable
         // 从数据库恢复上次累计值,避免重启归零
         // 从数据库恢复上次累计值,避免重启归零
         try
         try
         {
         {
-            var lastRecord = DatabaseService.Db.Queryable<FlowRecord>()
+            var lastRecord = _bgDb.Queryable<FlowRecord>()
                 .OrderByDescending(r => r.RecordTime)
                 .OrderByDescending(r => r.RecordTime)
                 .First();
                 .First();
             if (lastRecord != null)
             if (lastRecord != null)
@@ -69,13 +72,13 @@ public class DataLoggingService : IDisposable
         // 从数据库恢复设备运行小时数(使用 GroupBy + Max(Id) 只取每个设备最新记录)
         // 从数据库恢复设备运行小时数(使用 GroupBy + Max(Id) 只取每个设备最新记录)
         try
         try
         {
         {
-            var latestIds = DatabaseService.Db.Queryable<EquipmentStatus>()
+            var latestIds = _bgDb.Queryable<EquipmentStatus>()
                 .GroupBy(s => s.DeviceId)
                 .GroupBy(s => s.DeviceId)
                 .Select(s => SqlSugar.SqlFunc.AggregateMax(s.Id))
                 .Select(s => SqlSugar.SqlFunc.AggregateMax(s.Id))
                 .ToList();
                 .ToList();
 
 
             var latestByDevice = latestIds.Count > 0
             var latestByDevice = latestIds.Count > 0
-                ? DatabaseService.Db.Queryable<EquipmentStatus>()
+                ? _bgDb.Queryable<EquipmentStatus>()
                     .Where(s => latestIds.Contains(s.Id))
                     .Where(s => latestIds.Contains(s.Id))
                     .ToList()
                     .ToList()
                 : new List<EquipmentStatus>();
                 : new List<EquipmentStatus>();
@@ -245,7 +248,7 @@ public class DataLoggingService : IDisposable
                 TotalOutflow = totalOut
                 TotalOutflow = totalOut
             };
             };
 
 
-            await DatabaseService.Db.Insertable(record).ExecuteCommandAsync();
+            await _bgDb.Insertable(record).ExecuteCommandAsync();
             Log.Debug("流量数据已记录: 进水={Inflow:F1}, 出水={Outflow:F1}, 累计进={TotalIn:F1}, 累计出={TotalOut:F1}",
             Log.Debug("流量数据已记录: 进水={Inflow:F1}, 出水={Outflow:F1}, 累计进={TotalIn:F1}, 累计出={TotalOut:F1}",
                 record.InflowRate, record.OutflowRate, record.TotalInflow, record.TotalOutflow);
                 record.InflowRate, record.OutflowRate, record.TotalInflow, record.TotalOutflow);
         }
         }
@@ -280,7 +283,7 @@ public class DataLoggingService : IDisposable
                 new() { RecordTime = now, TagName = "OutflowRate", Value = data.OutflowRate },
                 new() { RecordTime = now, TagName = "OutflowRate", Value = data.OutflowRate },
             };
             };
 
 
-            await DatabaseService.Db.Insertable(records).ExecuteCommandAsync();
+            await _bgDb.Insertable(records).ExecuteCommandAsync();
             Log.Debug("模拟量数据已记录: {Count} 个标签", records.Count);
             Log.Debug("模拟量数据已记录: {Count} 个标签", records.Count);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
@@ -358,7 +361,7 @@ public class DataLoggingService : IDisposable
             }
             }
 
 
             // 批量插入数据库
             // 批量插入数据库
-            await DatabaseService.Db.Insertable(records).ExecuteCommandAsync();
+            await _bgDb.Insertable(records).ExecuteCommandAsync();
             Log.Debug("设备状态已记录: {Count} 台设备", records.Count);
             Log.Debug("设备状态已记录: {Count} 台设备", records.Count);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
@@ -376,19 +379,19 @@ public class DataLoggingService : IDisposable
         {
         {
             var cutoff = DateTime.Now.AddDays(-DataRetentionDays);
             var cutoff = DateTime.Now.AddDays(-DataRetentionDays);
 
 
-            var flowDeleted = await DatabaseService.Db.Deleteable<FlowRecord>()
+            var flowDeleted = await _bgDb.Deleteable<FlowRecord>()
                 .Where(r => r.RecordTime < cutoff)
                 .Where(r => r.RecordTime < cutoff)
                 .ExecuteCommandAsync();
                 .ExecuteCommandAsync();
 
 
-            var alarmDeleted = await DatabaseService.Db.Deleteable<AlarmRecord>()
+            var alarmDeleted = await _bgDb.Deleteable<AlarmRecord>()
                 .Where(r => r.AlarmTime < cutoff && r.IsConfirmed)
                 .Where(r => r.AlarmTime < cutoff && r.IsConfirmed)
                 .ExecuteCommandAsync();
                 .ExecuteCommandAsync();
 
 
-            var equipDeleted = await DatabaseService.Db.Deleteable<EquipmentStatus>()
+            var equipDeleted = await _bgDb.Deleteable<EquipmentStatus>()
                 .Where(r => r.LastUpdateTime < cutoff)
                 .Where(r => r.LastUpdateTime < cutoff)
                 .ExecuteCommandAsync();
                 .ExecuteCommandAsync();
 
 
-            var analogDeleted = await DatabaseService.Db.Deleteable<AnalogRecord>()
+            var analogDeleted = await _bgDb.Deleteable<AnalogRecord>()
                 .Where(r => r.RecordTime < cutoff)
                 .Where(r => r.RecordTime < cutoff)
                 .ExecuteCommandAsync();
                 .ExecuteCommandAsync();
 
 
@@ -417,7 +420,7 @@ public class DataLoggingService : IDisposable
             TotalOutflow = totalOut,
             TotalOutflow = totalOut,
             Remark = LanguageService.Instance.Get("ManualRecord")
             Remark = LanguageService.Instance.Get("ManualRecord")
         };
         };
-        await DatabaseService.Db.Insertable(record).ExecuteCommandAsync();
+        await _bgDb.Insertable(record).ExecuteCommandAsync();
     }
     }
 
 
     public void Dispose()
     public void Dispose()
@@ -428,6 +431,10 @@ public class DataLoggingService : IDisposable
         Stop();
         Stop();
         _cts?.Dispose();
         _cts?.Dispose();
         _cts = null;
         _cts = null;
+
+        // 释放后台记录独占的数据库客户端
+        try { _bgDb.Dispose(); } catch { }
+
         Log.Information("DataLoggingService 已释放");
         Log.Information("DataLoggingService 已释放");
     }
     }
 }
 }

+ 65 - 7
src/YZWater.Core/Services/DatabaseService.cs

@@ -9,14 +9,60 @@ namespace YZWater.Core.Services;
 /// </summary>
 /// </summary>
 public static class DatabaseService
 public static class DatabaseService
 {
 {
-    private static SqlSugarClient? _db;
+    private static SqlSugarScope? _db;
     private static readonly string DbPath = "yzwater.db";
     private static readonly string DbPath = "yzwater.db";
 
 
+    /// <summary>数据库是否已释放(后台写入任务退出前检查,避免用已关闭的连接写库)</summary>
+    public static bool IsDisposed { get; private set; }
+
+    /// <summary>写库门闩:后台写入与 Dispose 互斥(正在写的操作完成后才允许关库)</summary>
+    private static readonly SemaphoreSlim WriteGate = new(1, 1);
+
     /// <summary>
     /// <summary>
     /// 数据库实例
     /// 数据库实例
     /// </summary>
     /// </summary>
     public static ISqlSugarClient Db => _db ?? throw new InvalidOperationException("数据库未初始化,请先调用 Initialize()");
     public static ISqlSugarClient Db => _db ?? throw new InvalidOperationException("数据库未初始化,请先调用 Initialize()");
 
 
+    /// <summary>
+    /// 创建后台任务专用的独立数据库客户端。
+    /// SqlSugarScope 的 AsyncLocal 隔离在跨 await 的后台循环中仍可能出现
+    /// 连接内部状态互踩(SqliteConnection.Close() NRE),
+    /// 因此 Alarm/Audit/DataLogging 等常驻写入任务各自持有独占客户端,物理隔离。
+    /// </summary>
+    public static SqlSugarClient CreateBackgroundClient()
+    {
+        return new SqlSugarClient(new ConnectionConfig
+        {
+            ConnectionString = $"Data Source={DbPath}",
+            DbType = DbType.Sqlite,
+            IsAutoCloseConnection = true,
+            InitKeyType = InitKeyType.Attribute
+        });
+    }
+
+    /// <summary>
+    /// 在写库门闩内执行写入操作(供后台 Channel 任务使用,client 为任务独占的
+    /// CreateBackgroundClient() 实例)。与 Dispose 互斥:要么写完再关库,
+    /// 要么库已关直接放弃——杜绝"写库执行中途连接被释放"引发的 NRE。
+    /// </summary>
+    /// <returns>Ok=false 表示数据库已关闭,应放弃写入并退出循环</returns>
+    public static async Task<(bool Ok, T Result)> ExecuteWriteAsync<T>(SqlSugarClient client, Func<SqlSugarClient, Task<T>> operation)
+    {
+        if (IsDisposed) return (false, default!);
+
+        await WriteGate.WaitAsync();
+        try
+        {
+            if (IsDisposed) return (false, default!);
+            var result = await operation(client);
+            return (true, result);
+        }
+        finally
+        {
+            WriteGate.Release();
+        }
+    }
+
     /// <summary>
     /// <summary>
     /// 初始化数据库(需先调用 LogService.Initialize())
     /// 初始化数据库(需先调用 LogService.Initialize())
     /// </summary>
     /// </summary>
@@ -27,18 +73,26 @@ public static class DatabaseService
 
 
     private static void InitDatabase()
     private static void InitDatabase()
     {
     {
-        _db = new SqlSugarClient(new ConnectionConfig
+        // 使用 SqlSugarScope(线程安全版本):
+        // 多个后台任务(Alarm/Audit/DataLogging)+ UI 线程并发使用共享实例,
+        // SqlSugarClient 非线程安全,并发 Open/Close 同一连接会引发
+        // SqliteConnection.Close() NullReferenceException。
+        // Scope 内部按异步上下文隔离连接,调用方接口(ISqlSugarClient)不变。
+        _db = new SqlSugarScope(new ConnectionConfig
         {
         {
             ConnectionString = $"Data Source={DbPath}",
             ConnectionString = $"Data Source={DbPath}",
             DbType = DbType.Sqlite,
             DbType = DbType.Sqlite,
             IsAutoCloseConnection = true,
             IsAutoCloseConnection = true,
             InitKeyType = InitKeyType.Attribute
             InitKeyType = InitKeyType.Attribute
-        });
-
-        _db.Aop.OnLogExecuting = (sql, pars) =>
+        },
+        db =>
         {
         {
-            Log.Debug("SQL: {Sql}", sql);
-        };
+            // 每个新连接上下文创建时应用
+            db.Aop.OnLogExecuting = (sql, pars) =>
+            {
+                Log.Debug("SQL: {Sql}", sql);
+            };
+        });
 
 
         _db.DbMaintenance.CreateDatabase();
         _db.DbMaintenance.CreateDatabase();
 
 
@@ -126,6 +180,10 @@ public static class DatabaseService
 
 
     public static void Dispose()
     public static void Dispose()
     {
     {
+        // 等待正在执行的写库操作完成(最多 3 秒)再关闭,
+        // 与 ExecuteWriteAsync 互斥,避免 SqliteConnection.Close() NRE
+        try { WriteGate.Wait(TimeSpan.FromSeconds(3)); } catch { /* 超时也继续关闭 */ }
+        IsDisposed = true;
         _db?.Dispose();
         _db?.Dispose();
     }
     }
 }
 }

+ 2 - 0
src/YZWater.Core/Services/LanguageService.cs

@@ -209,6 +209,7 @@ public partial class LanguageService : ObservableObject
         ["InputUserName"] = "请输入用户名",
         ["InputUserName"] = "请输入用户名",
         ["InputPassword"] = "请输入密码",
         ["InputPassword"] = "请输入密码",
         ["RememberMe"] = "记住用户名",
         ["RememberMe"] = "记住用户名",
+        ["AutoLogin"] = "自动登录",
         ["LoginButton"] = "登 录",
         ["LoginButton"] = "登 录",
         ["DefaultAccount"] = "默认账户: admin / admin123",
         ["DefaultAccount"] = "默认账户: admin / admin123",
         // 审计日志
         // 审计日志
@@ -456,6 +457,7 @@ public partial class LanguageService : ObservableObject
         ["InputUserName"] = "Enter username",
         ["InputUserName"] = "Enter username",
         ["InputPassword"] = "Enter password",
         ["InputPassword"] = "Enter password",
         ["RememberMe"] = "Remember username",
         ["RememberMe"] = "Remember username",
+        ["AutoLogin"] = "Auto login",
         ["LoginButton"] = "LOGIN",
         ["LoginButton"] = "LOGIN",
         ["DefaultAccount"] = "Default: admin / admin123",
         ["DefaultAccount"] = "Default: admin / admin123",
         ["Total"] = "Total",
         ["Total"] = "Total",

+ 145 - 7
src/YZWater.Core/Services/MockPlcService.cs

@@ -19,6 +19,9 @@ public static class MockPlcService
     private static float _pump1Freq = 45, _pump2Freq = 0, _pump3Freq = 40, _pump4Freq = 0, _pump5Freq = 0;
     private static float _pump1Freq = 45, _pump2Freq = 0, _pump3Freq = 40, _pump4Freq = 0, _pump5Freq = 0;
     private static bool _autoMode = true;
     private static bool _autoMode = true;
 
 
+    // 项目化模拟用的故障状态(1# 泵永远不故障,2#/3# 偶尔故障演示跳过逻辑)
+    private static bool _pump2Fault, _pump3Fault;
+
     /// <summary>
     /// <summary>
     /// 是否启用 Mock 模式
     /// 是否启用 Mock 模式
     /// </summary>
     /// </summary>
@@ -54,11 +57,36 @@ public static class MockPlcService
         if (_pump1) _pump1Freq = Clamp(_pump1Freq + RandomDelta(1f), 0, 50);
         if (_pump1) _pump1Freq = Clamp(_pump1Freq + RandomDelta(1f), 0, 50);
         if (_pump3) _pump3Freq = Clamp(_pump3Freq + RandomDelta(1f), 0, 50);
         if (_pump3) _pump3Freq = Clamp(_pump3Freq + RandomDelta(1f), 0, 50);
 
 
-        // 偶尔切换设备状态
-        if (Rng.NextDouble() < 0.002) _pump2 = !_pump2;
-        if (Rng.NextDouble() < 0.002) _pump4 = !_pump4;
-        if (Rng.NextDouble() < 0.001) _pump5 = !_pump5;
-        if (Rng.NextDouble() < 0.003) _fan2 = !_fan2;
+        // 按液位逻辑自动控制泵启停(按编号顺序:1# → 2# → 3#)
+        // 阈值对应模拟归一化后的液位 0~10m
+        var stopLevel = 3.5f;
+        var startPump1 = 5.5f;
+        var startPump2 = 7.0f;
+        var startPump3 = 8.5f;
+        var level = _tank1 / 10f;   // 归一化 0~10m
+
+        if (level < stopLevel)
+        {
+            _pump1 = _pump2 = _pump3 = false;
+        }
+        else if (level >= startPump3)
+        {
+            _pump1 = _pump2 = _pump3 = true;
+        }
+        else if (level >= startPump2)
+        {
+            _pump1 = _pump2 = true;
+            _pump3 = false;
+        }
+        else if (level >= startPump1)
+        {
+            _pump1 = true;
+            _pump2 = _pump3 = false;
+        }
+
+        // 偶尔随机故障(演示故障跳过逻辑)
+        if (Rng.NextDouble() < 0.0005) _pump2Fault = !_pump2Fault;
+        if (Rng.NextDouble() < 0.0003) _pump3Fault = !_pump3Fault;
 
 
         // 更新数据模型
         // 更新数据模型
         data.Tank1Level = _tank1;
         data.Tank1Level = _tank1;
@@ -76,8 +104,8 @@ public static class MockPlcService
         data.Pump5Running = _pump5;
         data.Pump5Running = _pump5;
 
 
         data.Pump1Fault = false;
         data.Pump1Fault = false;
-        data.Pump2Fault = false;
-        data.Pump3Fault = false;
+        data.Pump2Fault = _pump2Fault;
+        data.Pump3Fault = _pump3Fault;
         data.Pump4Fault = false;
         data.Pump4Fault = false;
         data.Pump5Fault = false;
         data.Pump5Fault = false;
 
 
@@ -96,6 +124,116 @@ public static class MockPlcService
         data.LastReadTime = DateTime.Now;
         data.LastReadTime = DateTime.Now;
         data.IsDataValid = true;
         data.IsDataValid = true;
         data.IsPlcConnected = true;
         data.IsPlcConnected = true;
+
+        // 同步到项目化动态寄存器(ProjectViewModel 通过这些键读数据)
+        SyncDynamicRegisters(data);
+    }
+
+    /// <summary>
+    /// 把模拟数据同步到项目化动态寄存器(AnalogRegisters / DigitalRegisters)。
+    /// 按键名后缀模式匹配:.Level / .Current / .Value / .DailyHours 等。
+    /// 阈值类寄存器(.StartPumpNLevel / .StopLevel / .ProtectionLevel)保留合理默认值,
+    /// 保证项目化视图(如泵站首页 sparkline、负荷条等)在 Mock 模式下也能看到变化。
+    /// </summary>
+    private static void SyncDynamicRegisters(PlcDataModel data)
+    {
+        if (data.AnalogRegisters.Count == 0 && data.DigitalRegisters.Count == 0) return;
+
+        // 模拟量寄存器:按 key 后缀匹配,填充模拟值
+        foreach (var key in data.AnalogRegisters.Keys)
+        {
+            data.AnalogRegisters[key] = SimulateAnalog(key);
+        }
+
+        // 数字量寄存器:按 key 后缀匹配
+        foreach (var key in data.DigitalRegisters.Keys)
+        {
+            data.DigitalRegisters[key] = SimulateDigital(key);
+        }
+    }
+
+    private static float SimulateAnalog(string key)
+    {
+        // ── 液位(所有 tank 的 .Level 都统一用 Tank1 模拟,
+        //    对于单水箱项目如扬杰二厂刚好一一对应)──
+        if (key.EndsWith(".Level")) return _tank1 / 10f;  // 归一到 0~10m
+
+        // ── 阈值寄存器:使用合理默认值(不覆盖配置)──
+        if (key.EndsWith(".StartPump1Level")) return 5.5f;
+        if (key.EndsWith(".StartPump2Level")) return 7.0f;
+        if (key.EndsWith(".StartPump3Level")) return 8.5f;
+        if (key.EndsWith(".StopLevel"))       return 3.5f;
+        if (key.EndsWith(".ProtectionLevel")) return 9.5f;
+
+        // ── 泵电流:运行时 ~22A(接近额定 25A),停止时 0 ──
+        if (key.EndsWith(".Current"))
+        {
+            var n = ExtractPumpIndex(key);
+            return n switch
+            {
+                1 => _pump1 ? 22f + RandomDelta(0.5f) : 0f,
+                2 => _pump2 ? 23f + RandomDelta(0.5f) : 0f,
+                3 => _pump3 ? 21f + RandomDelta(0.5f) : 0f,
+                4 => _pump4 ? 20f + RandomDelta(0.5f) : 0f,
+                5 => _pump5 ? 19f + RandomDelta(0.5f) : 0f,
+                _ => 0f
+            };
+        }
+
+        // ── 日台时(静态演示值,避免每帧变化让 UI 抖动)──
+        if (key.EndsWith(".DailyHours")) return 4;
+        if (key.EndsWith(".DailyMinutes")) return 30;
+
+        // ── 仪表(按单位匹配)──
+        if (key.EndsWith(".Value"))
+        {
+            if (key.StartsWith("压力")) return Clamp(_outflow * 0.003f + 0.25f + RandomDelta(0.02f), 0f, 1.6f);
+            if (key.StartsWith("流量")) return Clamp(_inflow, 0f, 200f);
+            return 0;
+        }
+
+        return 0;
+    }
+
+    private static bool SimulateDigital(string key)
+    {
+        if (key.EndsWith(".Run"))
+        {
+            var n = ExtractPumpIndex(key);
+            return n switch
+            {
+                1 => _pump1,
+                2 => _pump2,
+                3 => _pump3,
+                4 => _pump4,
+                5 => _pump5,
+                _ => false
+            };
+        }
+        if (key.EndsWith(".Fault"))
+        {
+            var n = ExtractPumpIndex(key);
+            return n switch
+            {
+                1 => false,           // 1# 泵永远不故障(主泵)
+                2 => _pump2Fault,     // 2# 偶尔故障
+                3 => _pump3Fault,     // 3# 偶尔故障
+                _ => false
+            };
+        }
+        if (key.EndsWith(".AutoMode")) return _autoMode;
+        if (key.EndsWith(".ModeSelect")) return true;         // 1=屏控(默认屏控,上位机不可操作)
+        if (key.StartsWith("DI."))     return false;         // 默认 DI 正常
+        return false;
+    }
+
+    /// <summary>从 "1#泵.Run" 之类的 key 中抽出泵编号(1..5)</summary>
+    private static int ExtractPumpIndex(string key)
+    {
+        // 匹配 "1#..." / "2#..." 等前缀
+        if (key.Length >= 2 && key[0] >= '1' && key[0] <= '5' && key[1] == '#')
+            return key[0] - '0';
+        return 0;
     }
     }
 
 
     /// <summary>
     /// <summary>

+ 177 - 0
src/YZWater.Core/Services/ParameterSyncService.cs

@@ -0,0 +1,177 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 参数同步服务 — 将项目配置中的阈值参数写入 PLC
+/// 包括水箱液位阈值、仪表高低限、报警阈值等
+/// </summary>
+public class ParameterSyncService
+{
+    private static readonly ParameterSyncService _instance = new();
+    public static ParameterSyncService Instance => _instance;
+
+    private ParameterSyncService() { }
+
+    /// <summary>同步结果</summary>
+    public class SyncResult
+    {
+        public int TotalCount { get; set; }
+        public int SuccessCount { get; set; }
+        public List<string> Details { get; set; } = new();
+
+        public void Merge(SyncResult other)
+        {
+            TotalCount += other.TotalCount;
+            SuccessCount += other.SuccessCount;
+            Details.AddRange(other.Details);
+        }
+
+        public string Summary => $"同步完成: {SuccessCount}/{TotalCount} 项成功";
+    }
+
+    /// <summary>同步水箱液位阈值到 PLC</summary>
+    public async Task<SyncResult> SyncTankThresholdsAsync(TankConfig tank, string operatorName = "Operator")
+    {
+        var result = new SyncResult();
+
+        var addresses = new (string? addr, float? value, string label)[]
+        {
+            (tank.StartPump1LevelAddress, tank.StartPump1Level, "启泵1液位"),
+            (tank.StartPump2LevelAddress, tank.StartPump2Level, "启泵2液位"),
+            (tank.StartPump3LevelAddress, tank.StartPump3Level, "启泵3液位"),
+            (tank.StopLevelAddress, tank.StopLevel, "停泵液位"),
+            (tank.ProtectionLevelAddress, tank.ProtectionLevel, "保护液位"),
+        };
+
+        foreach (var (addr, value, label) in addresses)
+        {
+            if (string.IsNullOrEmpty(addr) || !value.HasValue) continue;
+
+            result.TotalCount++;
+            try
+            {
+                var ok = await PlcService.WriteFloatAsync(addr, value.Value);
+                if (ok)
+                {
+                    result.SuccessCount++;
+                    result.Details.Add($"✓ {tank.Name}.{label} → {addr} = {value.Value:F2}");
+                }
+                else
+                {
+                    result.Details.Add($"✗ {tank.Name}.{label} → {addr} 写入失败");
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "参数同步失败: {Tank}.{Label} → {Addr}", tank.Name, label, addr);
+                result.Details.Add($"✗ {tank.Name}.{label} → {addr} 异常: {ex.Message}");
+            }
+        }
+
+        AuditService.Log(operatorName, "参数同步", $"水箱阈值: {result.SuccessCount}/{result.TotalCount}", tank.Name,
+            result.SuccessCount == result.TotalCount ? "Success" : "Partial");
+
+        return result;
+    }
+
+    /// <summary>同步仪表阈值到 PLC</summary>
+    public async Task<SyncResult> SyncGaugeThresholdsAsync(GaugeConfig gauge, string operatorName = "Operator")
+    {
+        var result = new SyncResult();
+
+        // 高压报警上限
+        if (!string.IsNullOrEmpty(gauge.HighSetAddress) && gauge.HighAlarmSet.HasValue)
+        {
+            result.TotalCount++;
+            try
+            {
+                var ok = await PlcService.WriteFloatAsync(gauge.HighSetAddress, gauge.HighAlarmSet.Value);
+                if (ok)
+                {
+                    result.SuccessCount++;
+                    result.Details.Add($"✓ {gauge.Name}.高压设置 → {gauge.HighSetAddress} = {gauge.HighAlarmSet.Value:F2}");
+                }
+                else
+                {
+                    result.Details.Add($"✗ {gauge.Name}.高压设置 → {gauge.HighSetAddress} 写入失败");
+                }
+            }
+            catch (Exception ex)
+            {
+                result.Details.Add($"✗ {gauge.Name}.高压设置 异常: {ex.Message}");
+            }
+        }
+
+        // 低压报警下限
+        if (!string.IsNullOrEmpty(gauge.LowSetAddress) && gauge.LowAlarmSet.HasValue)
+        {
+            result.TotalCount++;
+            try
+            {
+                var ok = await PlcService.WriteFloatAsync(gauge.LowSetAddress, gauge.LowAlarmSet.Value);
+                if (ok)
+                {
+                    result.SuccessCount++;
+                    result.Details.Add($"✓ {gauge.Name}.低压设置 → {gauge.LowSetAddress} = {gauge.LowAlarmSet.Value:F2}");
+                }
+                else
+                {
+                    result.Details.Add($"✗ {gauge.Name}.低压设置 → {gauge.LowSetAddress} 写入失败");
+                }
+            }
+            catch (Exception ex)
+            {
+                result.Details.Add($"✗ {gauge.Name}.低压设置 异常: {ex.Message}");
+            }
+        }
+
+        if (result.TotalCount > 0)
+        {
+            AuditService.Log(operatorName, "参数同步", $"仪表阈值: {result.SuccessCount}/{result.TotalCount}", gauge.Name);
+        }
+
+        return result;
+    }
+
+    /// <summary>同步当前项目所有可写参数到 PLC</summary>
+    public async Task<SyncResult> SyncAllAsync(string operatorName = "Operator")
+    {
+        var profile = ProjectService.Current;
+        var result = new SyncResult();
+
+        if (profile == null)
+        {
+            result.Details.Add("✗ 未加载项目档案");
+            return result;
+        }
+
+        if (!profile.Control.AllowParameterSync)
+        {
+            result.Details.Add("✗ 项目配置禁止参数同步");
+            Log.Warning("参数同步被项目配置禁止: {Project}", profile.Name);
+            return result;
+        }
+
+        // 同步所有水箱阈值
+        foreach (var tank in profile.Tanks)
+        {
+            var tankResult = await SyncTankThresholdsAsync(tank, operatorName);
+            result.Merge(tankResult);
+        }
+
+        // 同步所有仪表阈值
+        foreach (var gauge in profile.Gauges)
+        {
+            var gaugeResult = await SyncGaugeThresholdsAsync(gauge, operatorName);
+            result.Merge(gaugeResult);
+        }
+
+        Log.Information("参数同步完成: {Project} — {Summary}", profile.Name, result.Summary);
+        AuditService.Log(operatorName, "全量参数同步", result.Summary, profile.Name,
+            result.SuccessCount == result.TotalCount ? "Success" : "Partial");
+
+        return result;
+    }
+}

+ 118 - 0
src/YZWater.Core/Services/PasswordProtector.cs

@@ -0,0 +1,118 @@
+using System.Security.Cryptography;
+using System.Text;
+using Serilog;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 密码保护工具 — 使用 AES-GCM 加密存储自动登录密码
+/// 密钥派生自机器名 + 用户名(非跨机器可移植,防止 casual 读取)
+/// </summary>
+public static class PasswordProtector
+{
+    private const int KeySize = 32; // AES-256
+    private const int NonceSize = 12; // GCM nonce
+    private const int TagSize = 16; // GCM tag
+
+    /// <summary>
+    /// 加密密码(用于保存到配置文件)
+    /// </summary>
+    public static string Protect(string plainText, string userName)
+    {
+        if (string.IsNullOrEmpty(plainText))
+            return string.Empty;
+
+        try
+        {
+            var key = DeriveKey(userName);
+            var plaintextBytes = Encoding.UTF8.GetBytes(plainText);
+            var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+            var ciphertext = new byte[plaintextBytes.Length];
+            var tag = new byte[TagSize];
+
+            using var aes = new AesGcm(key, TagSize);
+            aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
+
+            // 格式: nonce(12) + tag(16) + ciphertext
+            var result = new byte[nonce.Length + tag.Length + ciphertext.Length];
+            Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length);
+            Buffer.BlockCopy(tag, 0, result, nonce.Length, tag.Length);
+            Buffer.BlockCopy(ciphertext, 0, result, nonce.Length + tag.Length, ciphertext.Length);
+
+            return Convert.ToBase64String(result);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "密码加密失败");
+            return string.Empty;
+        }
+    }
+
+    /// <summary>
+    /// 解密密码(用于从配置文件加载)
+    /// 支持向后兼容:如果数据不是有效的加密格式,尝试作为旧版明文处理
+    /// </summary>
+    public static string Unprotect(string encryptedText, string userName)
+    {
+        if (string.IsNullOrEmpty(encryptedText))
+            return string.Empty;
+
+        try
+        {
+            var data = Convert.FromBase64String(encryptedText);
+            // 加密数据至少需要 nonce(12) + tag(16) = 28 字节
+            if (data.Length < NonceSize + TagSize)
+            {
+                // 数据太短,不是有效的加密格式
+                // 尝试作为旧版明文处理(向后兼容)
+                Log.Information("检测到旧版明文密码格式,自动迁移为加密格式");
+                return encryptedText; // 返回原始字符串作为明文密码
+            }
+
+            var key = DeriveKey(userName);
+            var nonce = new byte[NonceSize];
+            var tag = new byte[TagSize];
+            var ciphertext = new byte[data.Length - NonceSize - TagSize];
+
+            Buffer.BlockCopy(data, 0, nonce, 0, NonceSize);
+            Buffer.BlockCopy(data, NonceSize, tag, 0, TagSize);
+            Buffer.BlockCopy(data, NonceSize + TagSize, ciphertext, 0, ciphertext.Length);
+
+            var plaintext = new byte[ciphertext.Length];
+            using var aes = new AesGcm(key, TagSize);
+            aes.Decrypt(nonce, ciphertext, tag, plaintext);
+
+            return Encoding.UTF8.GetString(plaintext);
+        }
+        catch (FormatException)
+        {
+            // 不是有效的 Base64 格式,尝试作为旧版明文处理
+            Log.Information("检测到非 Base64 密码格式,作为明文处理");
+            return encryptedText;
+        }
+        catch (Exception ex)
+        {
+            // 解密失败(可能是密钥不匹配或数据损坏)
+            Log.Warning(ex, "密码解密失败,尝试作为明文处理");
+            // 最后一次尝试:返回原始字符串
+            return encryptedText;
+        }
+    }
+
+    /// <summary>
+    /// 从机器名 + 用户名派生 AES 密钥
+    /// </summary>
+    private static byte[] DeriveKey(string userName)
+    {
+        var machineId = Environment.MachineName ?? "default";
+        var salt = $"{machineId}|{userName}|yzwater-autologin";
+
+        using var derive = new Rfc2898DeriveBytes(
+            salt,
+            Encoding.UTF8.GetBytes("YZWater3.Salt.v1"), // 固定 salt(每台机器不同因 MachineName)
+            100_000, // 迭代次数
+            HashAlgorithmName.SHA256);
+
+        return derive.GetBytes(KeySize);
+    }
+}

+ 99 - 23
src/YZWater.Core/Services/PlcPollingService.cs

@@ -370,9 +370,21 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             if (!string.IsNullOrEmpty(pump.CurrentAddress))
             if (!string.IsNullOrEmpty(pump.CurrentAddress))
                 analogReads.Add(($"{pump.Name}.Current", pump.CurrentAddress));
                 analogReads.Add(($"{pump.Name}.Current", pump.CurrentAddress));
             if (!string.IsNullOrEmpty(pump.DailyHoursAddress))
             if (!string.IsNullOrEmpty(pump.DailyHoursAddress))
-                analogReads.Add(($"{pump.Name}.DailyHours", pump.DailyHoursAddress));
+            {
+                var dt = (pump.DailyHoursDataType ?? "REAL").ToUpper();
+                if (dt is "INT" or "WORD" or "DWORD" or "DINT")
+                    analogReads.Add(($"{pump.Name}.DailyHours#{dt}", pump.DailyHoursAddress));
+                else
+                    analogReads.Add(($"{pump.Name}.DailyHours", pump.DailyHoursAddress));
+            }
             if (!string.IsNullOrEmpty(pump.DailyMinutesAddress))
             if (!string.IsNullOrEmpty(pump.DailyMinutesAddress))
-                analogReads.Add(($"{pump.Name}.DailyMinutes", pump.DailyMinutesAddress));
+            {
+                var dt = (pump.DailyMinutesDataType ?? "REAL").ToUpper();
+                if (dt is "INT" or "WORD" or "DWORD" or "DINT")
+                    analogReads.Add(($"{pump.Name}.DailyMinutes#{dt}", pump.DailyMinutesAddress));
+                else
+                    analogReads.Add(($"{pump.Name}.DailyMinutes", pump.DailyMinutesAddress));
+            }
         }
         }
         foreach (var tank in profile.Tanks)
         foreach (var tank in profile.Tanks)
         {
         {
@@ -440,10 +452,12 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 digitalReads.Add(($"{pump.Name}.Fault", pump.FaultAddress));
                 digitalReads.Add(($"{pump.Name}.Fault", pump.FaultAddress));
             if (!string.IsNullOrEmpty(pump.AutoStateAddress))
             if (!string.IsNullOrEmpty(pump.AutoStateAddress))
                 digitalReads.Add(($"{pump.Name}.AutoMode", pump.AutoStateAddress));
                 digitalReads.Add(($"{pump.Name}.AutoMode", pump.AutoStateAddress));
-            if (!string.IsNullOrEmpty(pump.ModeSelectAddress))
-                digitalReads.Add(($"{pump.Name}.ModeSelect", pump.ModeSelectAddress));
         }
         }
 
 
+        // 全局中控/屏控(所有泵共用)— 以第一个泵名称为键,ProjectViewModel 从此处读取
+        if (!string.IsNullOrEmpty(profile.ModeSelectAddress) && profile.Pumps.Count > 0)
+            digitalReads.Add(($"{profile.Pumps[0].Name}.ModeSelect", profile.ModeSelectAddress));
+
         // 通用数字量输入(如变频器故障)
         // 通用数字量输入(如变频器故障)
         foreach (var di in profile.DigitalInputs)
         foreach (var di in profile.DigitalInputs)
         {
         {
@@ -456,6 +470,13 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         // 对于连续地址(如 VD500, VD504, VD508),使用批量读取优化
         // 对于连续地址(如 VD500, VD504, VD508),使用批量读取优化
         // 立即物化 Task 数组,避免 IEnumerable 双重枚举导致 PLC 读取量翻倍
         // 立即物化 Task 数组,避免 IEnumerable 双重枚举导致 PLC 读取量翻倍
 
 
+        // 调试日志:记录所有需要读取的地址
+        Log.Information("模拟量读取地址列表 ({Count} 个):", analogReads.Count);
+        foreach (var r in analogReads.Take(20)) // 只记录前20个,避免日志过多
+        {
+            Log.Information("  {Key} -> {Addr}", r.key, r.addr);
+        }
+
         // 尝试批量读取优化
         // 尝试批量读取优化
         var batchResults = await TryBatchReadAsync(analogReads, ct);
         var batchResults = await TryBatchReadAsync(analogReads, ct);
 
 
@@ -474,27 +495,53 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 }
                 }
 
 
                 // 检查是否为整数类型(key 格式:name#DATATYPE)
                 // 检查是否为整数类型(key 格式:name#DATATYPE)
-                if (key.Contains('#'))
+                // 注意:泵名称可能包含 # (如 "1#泵"),所以要找最后一个 # 作为分隔符
+                var lastHashIndex = key.LastIndexOf('#');
+                if (lastHashIndex > 0)
                 {
                 {
-                    var parts = key.Split('#');
-                    key = parts[0];
-                    var dataType = parts[1];
-
-                    // 读取字节后手动转换
-                    var bytes = await PlcService.ReadBytesAsync(r.addr, dataType is "INT" or "WORD" ? (ushort)2 : (ushort)4);
-                    if (bytes == null || bytes.Length == 0)
+                    var possibleDataType = key.Substring(lastHashIndex + 1);
+                    if (possibleDataType is "INT" or "WORD" or "DWORD" or "DINT")
                     {
                     {
-                        return (key, Value: 0f);
-                    }
+                        var actualKey = key.Substring(0, lastHashIndex);
+                        var dataType = possibleDataType;
+
+                        // 读取字节后手动转换
+                        var bytes = await PlcService.ReadBytesAsync(r.addr, dataType is "INT" or "WORD" ? (ushort)2 : (ushort)4);
+                        if (bytes == null || bytes.Length == 0)
+                        {
+                            Log.Warning("整数读取失败: {Address}, 类型={DataType}", r.addr, dataType);
+                            return (actualKey, Value: 0f);
+                        }
+
+                        // S7 PLC 使用大端序,需要转换为小端序(Windows)
+                        if (BitConverter.IsLittleEndian)
+                        {
+                            Array.Reverse(bytes);
+                        }
+
+                        value = dataType switch
+                        {
+                            "INT" => (float)BitConverter.ToInt16(bytes, 0),
+                            "WORD" => (float)BitConverter.ToUInt16(bytes, 0),
+                            "DINT" => (float)BitConverter.ToInt32(bytes, 0),
+                            "DWORD" => (float)BitConverter.ToUInt32(bytes, 0),
+                            _ => PlcService.ParseFloat(bytes, 0)
+                        };
+
+                        // 调试日志:记录运行时间读取结果
+                        if (actualKey.Contains("DailyHours") || actualKey.Contains("DailyMinutes"))
+                        {
+                            Log.Information("运行时间解析(单独): {Key} = {Value} (地址={Address}, 类型={DataType}, 原始字节={Bytes})",
+                                actualKey, value, r.addr, dataType, BitConverter.ToString(bytes));
+                        }
 
 
-                    value = dataType switch
+                        key = actualKey;
+                    }
+                    else
                     {
                     {
-                        "INT" => (float)BitConverter.ToInt16(bytes, 0),
-                        "WORD" => (float)BitConverter.ToUInt16(bytes, 0),
-                        "DINT" => (float)BitConverter.ToInt32(bytes, 0),
-                        "DWORD" => (float)BitConverter.ToUInt32(bytes, 0),
-                        _ => PlcService.ParseFloat(bytes, 0)
-                    };
+                        // 不是有效的数据类型,按 REAL 处理
+                        value = await PlcService.ReadFloatAsync(r.addr);
+                    }
                 }
                 }
                 else
                 else
                 {
                 {
@@ -558,8 +605,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         List<(string key, string addr)> analogReads, CancellationToken ct)
         List<(string key, string addr)> analogReads, CancellationToken ct)
     {
     {
         // 只对 REAL 类型的地址进行批量读取(排除带 #DATATYPE 后缀的整数类型)
         // 只对 REAL 类型的地址进行批量读取(排除带 #DATATYPE 后缀的整数类型)
+        // 注意:泵名称可能包含 # (如 "1#泵"),所以要检查的是 key 是否以 #DATATYPE 结尾
         var realReads = analogReads
         var realReads = analogReads
-            .Where(r => !r.key.Contains('#'))
+            .Where(r => !r.key.Contains("#INT") && !r.key.Contains("#WORD") && !r.key.Contains("#DWORD") && !r.key.Contains("#DINT"))
             .Select(r => (r.key, r.addr))
             .Select(r => (r.key, r.addr))
             .ToList();
             .ToList();
 
 
@@ -570,6 +618,16 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             // 分组连续地址
             // 分组连续地址
             var groups = S7AddressParser.GroupConsecutiveAddresses(realReads);
             var groups = S7AddressParser.GroupConsecutiveAddresses(realReads);
 
 
+            // 调试日志:记录分组情况
+            Log.Information("地址分组情况: REAL地址数={RealCount}, 分组数={GroupCount}",
+                realReads.Count, groups.Count);
+            foreach (var g in groups)
+            {
+                Log.Information("  分组: 起始={Start}, 总字节={TotalBytes}, 地址数={ItemCount}, 地址列表={Items}",
+                    g.StartByte, g.TotalBytes, g.Items.Count,
+                    string.Join(", ", g.Items.Select(i => i.Key)));
+            }
+
             // 只对包含多个地址的分组进行批量读取
             // 只对包含多个地址的分组进行批量读取
             var batchGroups = groups.Where(g => g.Items.Count >= 2).ToList();
             var batchGroups = groups.Where(g => g.Items.Count >= 2).ToList();
             if (batchGroups.Count == 0) return null;
             if (batchGroups.Count == 0) return null;
@@ -593,7 +651,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 try
                 try
                 {
                 {
                     var bytes = await PlcService.ReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
                     var bytes = await PlcService.ReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
-                    if (bytes == null) continue;
+                    if (bytes == null)
+                    {
+                        Log.Warning("批量读取失败: {Address}, 长度={Length}", batchAddr, group.TotalBytes);
+                        continue;
+                    }
+
+                    Log.Information("批量读取成功: {Address}, 长度={Length}, 实际字节数={ActualLength}",
+                        batchAddr, group.TotalBytes, bytes.Length);
 
 
                     // 解析每个子项
                     // 解析每个子项
                     foreach (var (key, offset) in group.Items)
                     foreach (var (key, offset) in group.Items)
@@ -601,6 +666,17 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         if (offset + 4 <= bytes.Length)
                         if (offset + 4 <= bytes.Length)
                         {
                         {
                             results[key] = PlcService.ParseFloat(bytes, offset);
                             results[key] = PlcService.ParseFloat(bytes, offset);
+                            // 调试日志:记录电流读取结果
+                            if (key.Contains("Current"))
+                            {
+                                Log.Information("电流解析(批量): {Key} = {Value} (偏移={Offset})",
+                                    key, results[key], offset);
+                            }
+                        }
+                        else
+                        {
+                            Log.Warning("批量读取解析失败: {Key}, 偏移={Offset}, 数据长度={Length}",
+                                key, offset, bytes.Length);
                         }
                         }
                     }
                     }
                 }
                 }

+ 1 - 0
src/YZWater.Core/Services/PlcService.cs

@@ -82,6 +82,7 @@ public static class PlcService
                 // 或者直接使用驱动接口
                 // 或者直接使用驱动接口
                 _plc = new SiemensS7Net(SiemensPLCS.S200Smart, plcIp)
                 _plc = new SiemensS7Net(SiemensPLCS.S200Smart, plcIp)
                 {
                 {
+                    Port = plcPort,
                     ConnectTimeOut = 3000,
                     ConnectTimeOut = 3000,
                     ReceiveTimeOut = 3000
                     ReceiveTimeOut = 3000
                 };
                 };

+ 11 - 0
src/YZWater.Core/ViewModels/LoginViewModel.cs

@@ -15,6 +15,7 @@ public partial class LoginViewModel : TranslatableViewModelBase
     [ObservableProperty] private bool _hasError;
     [ObservableProperty] private bool _hasError;
     [ObservableProperty] private bool _isLoggingIn;
     [ObservableProperty] private bool _isLoggingIn;
     [ObservableProperty] private bool _rememberUserName;
     [ObservableProperty] private bool _rememberUserName;
+    [ObservableProperty] private bool _autoLogin;
 
 
     // ─── 翻译属性(getter-only,通过基类 T() 获取翻译) ───
     // ─── 翻译属性(getter-only,通过基类 T() 获取翻译) ───
     public string LoginTitleText => T("LoginTitle");
     public string LoginTitleText => T("LoginTitle");
@@ -27,6 +28,7 @@ public partial class LoginViewModel : TranslatableViewModelBase
     public string RememberMeText => T("RememberMe");
     public string RememberMeText => T("RememberMe");
     public string LoginButtonText => T("LoginButton");
     public string LoginButtonText => T("LoginButton");
     public string DefaultAccountText => T("DefaultAccount");
     public string DefaultAccountText => T("DefaultAccount");
+    public string AutoLoginText => T("AutoLogin");
 
 
     public event Action? LoginSucceeded;
     public event Action? LoginSucceeded;
 
 
@@ -41,6 +43,7 @@ public partial class LoginViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(InputUserNameText), "InputUserName");
         RegisterTranslation(nameof(InputUserNameText), "InputUserName");
         RegisterTranslation(nameof(InputPasswordText), "InputPassword");
         RegisterTranslation(nameof(InputPasswordText), "InputPassword");
         RegisterTranslation(nameof(RememberMeText), "RememberMe");
         RegisterTranslation(nameof(RememberMeText), "RememberMe");
+        RegisterTranslation(nameof(AutoLoginText), "AutoLogin");
         RegisterTranslation(nameof(LoginButtonText), "LoginButton");
         RegisterTranslation(nameof(LoginButtonText), "LoginButton");
         RegisterTranslation(nameof(DefaultAccountText), "DefaultAccount");
         RegisterTranslation(nameof(DefaultAccountText), "DefaultAccount");
 
 
@@ -50,10 +53,16 @@ public partial class LoginViewModel : TranslatableViewModelBase
         if (config != null)
         if (config != null)
         {
         {
             RememberUserName = config.RememberUserName;
             RememberUserName = config.RememberUserName;
+            AutoLogin = config.AutoLogin;
             if (RememberUserName && !string.IsNullOrEmpty(config.SavedUserName))
             if (RememberUserName && !string.IsNullOrEmpty(config.SavedUserName))
             {
             {
                 UserName = config.SavedUserName;
                 UserName = config.SavedUserName;
             }
             }
+            if (AutoLogin && !string.IsNullOrEmpty(config.SavedPassword))
+            {
+                // 解密已保存的密码(用于预填充,非自动登录场景)
+                Password = PasswordProtector.Unprotect(config.SavedPassword, config.SavedUserName ?? "");
+            }
         }
         }
     }
     }
 
 
@@ -101,6 +110,8 @@ public partial class LoginViewModel : TranslatableViewModelBase
 
 
         config.RememberUserName = RememberUserName;
         config.RememberUserName = RememberUserName;
         config.SavedUserName = RememberUserName ? UserName : string.Empty;
         config.SavedUserName = RememberUserName ? UserName : string.Empty;
+        config.AutoLogin = AutoLogin && RememberUserName;  // 自动登录必须同时记住用户名
+        config.SavedPassword = (AutoLogin && RememberUserName) ? PasswordProtector.Protect(Password, UserName) : string.Empty;
         ConfigService.UpdateConfig(config);
         ConfigService.UpdateConfig(config);
     }
     }
 }
 }

+ 8 - 0
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -395,6 +395,14 @@ public partial class MainViewModel : TranslatableViewModelBase
     [RelayCommand]
     [RelayCommand]
     private void Logout()
     private void Logout()
     {
     {
+        // 退出时关闭自动登录,下次启动显示登录窗口
+        var config = ConfigService.GetConfig();
+        if (config != null)
+        {
+            config.AutoLogin = false;
+            config.SavedPassword = string.Empty;
+            ConfigService.UpdateConfig(config);
+        }
         AuthService.Logout();
         AuthService.Logout();
     }
     }
 
 

+ 746 - 19
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -14,6 +14,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
 {
 {
     private readonly ProjectProfile _profile;
     private readonly ProjectProfile _profile;
     private readonly PlcPollingService _pollingService;
     private readonly PlcPollingService _pollingService;
+    private readonly SynchronizationContext? _uiContext;
     private bool _disposed;
     private bool _disposed;
 
 
     [ObservableProperty] private string _title = "";
     [ObservableProperty] private string _title = "";
@@ -21,6 +22,47 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     [ObservableProperty] private bool _isPlcConnected;
     [ObservableProperty] private bool _isPlcConnected;
     [ObservableProperty] private string _plcStatusText = "PLC 未连接";
     [ObservableProperty] private string _plcStatusText = "PLC 未连接";
     [ObservableProperty] private DateTime _currentTime = DateTime.Now;
     [ObservableProperty] private DateTime _currentTime = DateTime.Now;
+    [ObservableProperty] private string _currentDateText = DateTime.Now.ToString("yyyy-MM-dd dddd");
+    [ObservableProperty] private bool _hasActiveAlarm;
+    [ObservableProperty] private int _activeAlarmCount;
+
+    /// <summary>当前控制模式文本(聚合所有泵的状态)</summary>
+    [ObservableProperty] private string _controlModeText = "";
+
+    /// <summary>是否处于中控模式</summary>
+    [ObservableProperty] private bool _isCentralControlMode;
+
+    /// <summary>全局中控/屏控切换地址(所有泵共用)</summary>
+    public string? ModeSelectAddress => _profile.ModeSelectAddress;
+
+    private System.Timers.Timer? _clockTimer;
+
+    // ─── 泵组运行概览(业务化) ───
+
+    /// <summary>当前运行泵台数</summary>
+    [ObservableProperty] private int _runningPumpCount;
+
+    /// <summary>泵组总台数</summary>
+    public int TotalPumpCount => Pumps.Count;
+
+    /// <summary>建议启动的泵名称(停运·无故障·日台时最少 → 磨损均衡轮换)</summary>
+    [ObservableProperty] private string _suggestedPumpName = "";
+
+    /// <summary>是否有建议启动的泵</summary>
+    public bool HasSuggestedPump => !string.IsNullOrEmpty(SuggestedPumpName);
+
+    // ─── 动态 2D/3D 控件绑定用字典 ───
+
+    /// <summary>项目档案(供动态布局引擎使用)</summary>
+    public ProjectProfile Profile => _profile;
+
+    /// <summary>模拟量字典 — 键为 "设备名.属性",如 "集水池.Level"</summary>
+    [ObservableProperty] private Dictionary<string, float> _analogValues = new();
+    private HashSet<string> _analogKeys = new();
+
+    /// <summary>数字量字典 — 键为 "设备名.属性",如 "1#泵.Run"</summary>
+    [ObservableProperty] private Dictionary<string, bool> _digitalValues = new();
+    private HashSet<string> _digitalKeys = new();
 
 
     // 设备列表
     // 设备列表
     public ObservableCollection<PumpItemViewModel> Pumps { get; } = new();
     public ObservableCollection<PumpItemViewModel> Pumps { get; } = new();
@@ -30,10 +72,58 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     public ObservableCollection<DigitalInputItemViewModel> DigitalInputs { get; } = new();
     public ObservableCollection<DigitalInputItemViewModel> DigitalInputs { get; } = new();
     public ObservableCollection<DigitalOutputItemViewModel> DigitalOutputs { get; } = new();
     public ObservableCollection<DigitalOutputItemViewModel> DigitalOutputs { get; } = new();
 
 
+    /// <summary>首页最近报警列表(最多 6 条,新报警置顶)</summary>
+    public ObservableCollection<AlarmRecord> RecentAlarms { get; } = new();
+
+    /// <summary>复位所有活跃报警</summary>
+    [RelayCommand]
+    private async Task ResetAllAlarmsAsync()
+    {
+        var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
+        await AlarmService.Instance.ResetAllAlarmsAsync(operatorName);
+        RunOnUi(() => RecentAlarms.Clear());
+    }
+
+    /// <summary>
+    /// 全局中控/屏控切换 — 所有泵共用同一地址
+    /// 当前中控 → 写1(切屏控), 当前屏控 → 写0(切中控)
+    /// </summary>
+    [RelayCommand]
+    private async Task ToggleControlModeAsync()
+    {
+        if (string.IsNullOrEmpty(ModeSelectAddress)) return;
+
+        var targetPanel = IsCentralControlMode; // 当前中控则切屏控, 当前屏控则切中控
+        var targetValue = targetPanel;           // true=1=屏控, false=0=中控
+
+        Log.Information("全局切换控制模式 → {Addr} (目标: {Target})",
+            ModeSelectAddress, targetPanel ? "屏控" : "中控");
+
+        await PlcService.WriteBoolAsync(ModeSelectAddress, targetValue);
+
+        // 乐观更新
+        IsCentralControlMode = !targetPanel;
+
+        // 同步所有泵的 IsCentralMode
+        foreach (var pump in Pumps)
+        {
+            pump.IsCentralMode = IsCentralControlMode;
+            pump.StartCommand?.NotifyCanExecuteChanged();
+            pump.StopCommand?.NotifyCanExecuteChanged();
+            pump.TogglePumpCommand?.NotifyCanExecuteChanged();
+            pump.ToggleManualModeCommand?.NotifyCanExecuteChanged();
+        }
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            targetPanel ? "全局切换屏控" : "全局切换中控",
+            $"→ {ModeSelectAddress}", _profile.Name);
+    }
+
     public ProjectViewModel(ProjectProfile profile)
     public ProjectViewModel(ProjectProfile profile)
     {
     {
         _profile = profile;
         _profile = profile;
         _pollingService = PlcPollingService.Instance;
         _pollingService = PlcPollingService.Instance;
+        _uiContext = SynchronizationContext.Current;
 
 
         Title = profile.Name;
         Title = profile.Name;
         SubtitleText = profile.Description;
         SubtitleText = profile.Description;
@@ -60,39 +150,201 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         // 初始化动态寄存器
         // 初始化动态寄存器
         _pollingService.Data.InitializeDynamicRegisters(profile);
         _pollingService.Data.InitializeDynamicRegisters(profile);
 
 
+        // 读取 3D 视图显隐配置(默认隐藏,仅 2D)
+        Show3DView = ConfigService.GetConfig()?.Enable3DView ?? false;
+        if (!Show3DView)
+        {
+            Is2DView = true;
+            Is3DView = false;
+        }
+
         // 订阅数据更新
         // 订阅数据更新
         _pollingService.DataUpdated += OnDataUpdated;
         _pollingService.DataUpdated += OnDataUpdated;
         _pollingService.ConnectionStateChanged += OnConnectionStateChanged;
         _pollingService.ConnectionStateChanged += OnConnectionStateChanged;
+
+        // 订阅报警状态变更(标题栏角标 + 首页概览)
+        AlarmService.Instance.AlarmStateChanged += OnAlarmStateChanged;
+        AlarmService.Instance.AlarmRaised += OnAlarmRaised;
+        OnAlarmStateChanged();
+
+        // 标题栏时钟(每秒刷新时间 + 日期)
+        _clockTimer = new System.Timers.Timer(1000);
+        _clockTimer.Elapsed += (_, _) => RunOnUi(() =>
+        {
+            var now = DateTime.Now;
+            CurrentTime = now;
+            CurrentDateText = now.ToString("yyyy-MM-dd dddd");
+        });
+        _clockTimer.Start();
+
+        // 初始化连接状态显示(Mock 模式下事件可能不触发)
+        OnConnectionStateChanged(PlcService.IsConnected);
+    }
+
+    /// <summary>新报警触发回调 — 追加到首页最近报警列表</summary>
+    private void OnAlarmRaised(AlarmRecord record)
+    {
+        if (record == null) return;
+        RunOnUi(() =>
+        {
+            RecentAlarms.Insert(0, record);
+            while (RecentAlarms.Count > 6)
+                RecentAlarms.RemoveAt(RecentAlarms.Count - 1);
+        });
+    }
+
+    /// <summary>切回 UI 线程执行(ViewModel 在 Core 层,用 SynchronizationContext 而非 Avalonia Dispatcher)</summary>
+    private void RunOnUi(Action action)
+    {
+        if (_disposed) return;
+        if (_uiContext != null && SynchronizationContext.Current != _uiContext)
+            _uiContext.Post(_ => { if (!_disposed) action(); }, null);
+        else
+            action();
+    }
+
+    /// <summary>报警状态变更回调 — 同步活跃报警数到标题栏角标</summary>
+    private void OnAlarmStateChanged()
+    {
+        HasActiveAlarm = AlarmService.Instance.HasActiveAlarm;
+        ActiveAlarmCount = AlarmService.Instance.ActiveAlarmCount;
+    }
+
+    /// <summary>SuggestedPumpName 变化时联动刷新 HasSuggestedPump</summary>
+    partial void OnSuggestedPumpNameChanged(string value)
+    {
+        OnPropertyChanged(nameof(HasSuggestedPump));
     }
     }
 
 
     private void OnDataUpdated(PlcDataModel data)
     private void OnDataUpdated(PlcDataModel data)
     {
     {
-        // 更新泵数据
+        // 更新子 ViewModel
         foreach (var pump in Pumps)
         foreach (var pump in Pumps)
             pump.UpdateFromData(data);
             pump.UpdateFromData(data);
-
-        // 更新水箱数据
         foreach (var tank in Tanks)
         foreach (var tank in Tanks)
             tank.UpdateFromData(data);
             tank.UpdateFromData(data);
-
-        // 更新仪表数据
         foreach (var gauge in Gauges)
         foreach (var gauge in Gauges)
             gauge.UpdateFromData(data);
             gauge.UpdateFromData(data);
-
-        // 更新传感器数据
         foreach (var sensor in Sensors)
         foreach (var sensor in Sensors)
             sensor.UpdateFromData(data);
             sensor.UpdateFromData(data);
-
-        // 更新数字量输入
         foreach (var di in DigitalInputs)
         foreach (var di in DigitalInputs)
             di.UpdateFromData(data);
             di.UpdateFromData(data);
-
-        // 更新数字量输出状态
         foreach (var doItem in DigitalOutputs)
         foreach (var doItem in DigitalOutputs)
             doItem.UpdateFromData(data);
             doItem.UpdateFromData(data);
 
 
+        // 泵组运行概览 + 启泵轮换建议(磨损均衡)
+        UpdatePumpOverview();
+
+        // 全局中控/屏控模式 — 从第一个泵的 ModeSelectAddress 读取(所有泵共用)
+        if (!string.IsNullOrEmpty(ModeSelectAddress) && Pumps.Count > 0)
+        {
+            var globalMode = data.GetDigitalRegister($"{Pumps[0].Name}.ModeSelect");
+            var newCentralMode = !globalMode; // 0=中控 → true
+            if (newCentralMode != IsCentralControlMode)
+            {
+                IsCentralControlMode = newCentralMode;
+                // 同步所有泵的 IsCentralMode(避免每个泵独立显示不同状态)
+                foreach (var pump in Pumps)
+                    pump.IsCentralMode = IsCentralControlMode;
+            }
+        }
+
+        // 中控/屏控模式聚合显示
+        UpdateControlMode();
+
         // 检查报警阈值
         // 检查报警阈值
         CheckAlarmThresholds(data);
         CheckAlarmThresholds(data);
+
+        // ─── 同步更新动态字典(供 PumpStation2D/3D 控件使用)───
+        UpdateDynamicDictionaries(data);
+    }
+
+    /// <summary>
+    /// 将 PlcDataModel 中的值同步到 AnalogValues / DigitalValues 字典。
+    /// 字典的键格式为 "设备名.属性",如 "集水池.Level"、"1#泵.Run"。
+    /// </summary>
+    private void UpdateDynamicDictionaries(PlcDataModel data)
+    {
+        var analogChanged = false;
+        var digitalChanged = false;
+
+        // 泵:运行、故障、电流、台时
+        foreach (var pump in Pumps)
+        {
+            SetAnalog($"{pump.Name}.Current", pump.Current, ref analogChanged);
+            SetDigital($"{pump.Name}.Run", pump.IsRunning, ref digitalChanged);
+            SetDigital($"{pump.Name}.Fault", pump.HasFault, ref digitalChanged);
+            SetDigital($"{pump.Name}.AutoMode", pump.IsAutoMode, ref digitalChanged);
+            SetDigital($"{pump.Name}.ModeSelect", pump.IsCentralMode, ref digitalChanged);
+            SetAnalog($"{pump.Name}.DailyHours", pump.DailyHours, ref analogChanged);
+            SetAnalog($"{pump.Name}.DailyMinutes", pump.DailyMinutes, ref analogChanged);
+        }
+
+        // 水箱:液位 + 阈值(阈值供 2D 剖面图绘制彩色阈值线)
+        foreach (var tank in Tanks)
+        {
+            SetAnalog($"{tank.Name}.Level", tank.Level, ref analogChanged);
+            SetAnalog($"{tank.Name}.StartPump1Level", tank.StartPump1Level, ref analogChanged);
+            SetAnalog($"{tank.Name}.StartPump2Level", tank.StartPump2Level, ref analogChanged);
+            SetAnalog($"{tank.Name}.StartPump3Level", tank.StartPump3Level, ref analogChanged);
+            SetAnalog($"{tank.Name}.StopLevel", tank.StopLevel, ref analogChanged);
+            SetAnalog($"{tank.Name}.ProtectionLevel", tank.ProtectionLevel, ref analogChanged);
+        }
+
+        // 仪表:值
+        foreach (var gauge in Gauges)
+        {
+            SetAnalog($"{gauge.Name}.Value", (float)gauge.Value, ref analogChanged);
+        }
+
+        // 传感器:值
+        foreach (var sensor in Sensors)
+        {
+            SetAnalog($"{sensor.Name}.Value", sensor.Value, ref analogChanged);
+        }
+
+        // 数字量输入
+        foreach (var di in DigitalInputs)
+        {
+            SetDigital($"DI.{di.Name}", di.IsActive, ref digitalChanged);
+        }
+
+        if (analogChanged)
+        {
+            // 仅在键集合变化时重建字典(新设备加入/移除),避免每次值更新都 new Dictionary
+            // 值变化就地更新,2D/3D 控件通过 InvalidateVisual 重绘时会重新读取
+            if (_analogValues.Count != _analogKeys.Count)
+            {
+                _analogKeys = new HashSet<string>(_analogValues.Keys);
+                AnalogValues = new Dictionary<string, float>(_analogValues);
+            }
+        }
+        if (digitalChanged)
+        {
+            if (_digitalValues.Count != _digitalKeys.Count)
+            {
+                _digitalKeys = new HashSet<string>(_digitalValues.Keys);
+                DigitalValues = new Dictionary<string, bool>(_digitalValues);
+            }
+        }
+    }
+
+    private void SetAnalog(string key, float value, ref bool changed)
+    {
+        if (!_analogValues.TryGetValue(key, out var old) || Math.Abs(old - value) > 0.001f)
+        {
+            _analogValues[key] = value;
+            changed = true;
+        }
+    }
+
+    private void SetDigital(string key, bool value, ref bool changed)
+    {
+        if (!_digitalValues.TryGetValue(key, out var old) || old != value)
+        {
+            _digitalValues[key] = value;
+            changed = true;
+        }
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -188,10 +440,120 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, false);
         await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, false);
     }
     }
 
 
+    /// <summary>
+    /// 泵组运行概览 + 建议启动泵。
+    /// 业务规则:按编号顺序选择第一个「停运且无故障」的泵作为建议启动对象。
+    /// 水位涨高时依次启动 1# → 2# → 3#,只有当前泵故障时才跳过。
+    /// 全部在运行或全部故障时不建议。
+    /// </summary>
+    private void UpdatePumpOverview()
+    {
+        RunningPumpCount = Pumps.Count(p => p.IsRunning);
+
+        // 电流负荷:已配置额定电流的按「当前/额定」,未配置的按组内最大电流相对值
+        var maxCurrent = Pumps.Count > 0 ? Pumps.Max(p => p.Current) : 0f;
+        foreach (var pump in Pumps)
+        {
+            var rated = pump.RatedCurrent;
+            pump.CurrentLoadRatio = rated > 0
+                ? pump.Current / rated
+                : maxCurrent > 0.5 ? pump.Current / maxCurrent : 0;
+        }
+
+        // 建议启动:按编号顺序(Pumps 集合已按添加顺序排列),
+        // 选第一个停运且无故障的泵
+        PumpItemViewModel? candidate = Pumps
+            .FirstOrDefault(p => !p.IsRunning && !p.HasFault);
+
+        SuggestedPumpName = candidate?.Name ?? "";
+        foreach (var pump in Pumps)
+            pump.IsSuggested = ReferenceEquals(pump, candidate);
+    }
+
+    /// <summary>聚合所有泵的中控/屏控状态,更新模式显示</summary>
+    private void UpdateControlMode()
+    {
+        if (Pumps.Count == 0) return;
+
+        var centralCount = Pumps.Count(p => p.IsCentralMode);
+        var total = Pumps.Count;
+        IsCentralControlMode = centralCount > 0;
+        ControlModeText = centralCount == total ? "中控模式"
+                        : centralCount == 0 ? "屏控模式"
+                        : $"混合模式 ({centralCount}/{total} 中控)";
+    }
+
     private void OnConnectionStateChanged(bool connected)
     private void OnConnectionStateChanged(bool connected)
     {
     {
-        IsPlcConnected = connected;
-        PlcStatusText = connected ? "PLC 已连接" : "PLC 未连接";
+        var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
+        IsPlcConnected = mode == "Mock" || connected;
+        PlcStatusText = mode switch
+        {
+            "Mock" => "模拟模式",
+            _ => connected ? "PLC 已连接" : "PLC 未连接"
+        };
+    }
+
+    // ─── 2D/3D 视图切换 ───
+
+    [ObservableProperty] private bool _is2DView = true;
+    [ObservableProperty] private bool _is3DView = false;
+
+    /// <summary>是否显示 3D 视图(由配置 Enable3DView 决定,默认隐藏仅用 2D)</summary>
+    [ObservableProperty] private bool _show3DView;
+
+    /// <summary>2D/3D 切换按钮组是否可见(仅配置启用 3D 时显示)</summary>
+    public bool IsViewSwitchVisible => Show3DView;
+
+    [RelayCommand]
+    private void Set2DView()
+    {
+        Is2DView = true;
+        Is3DView = false;
+    }
+
+    [RelayCommand]
+    private void Set3DView()
+    {
+        Is2DView = false;
+        Is3DView = true;
+    }
+
+    // ─── 设备点击命令(供 2D/3D 控件使用)───
+
+    [ObservableProperty] private string _selectedDeviceId = "";
+
+    /// <summary>当前选中的泵(图内点击弹出操作浮层)</summary>
+    [ObservableProperty] private PumpItemViewModel? _selectedPump;
+
+    /// <summary>泵操作浮层是否打开</summary>
+    [ObservableProperty] private bool _isPumpPopupOpen;
+
+    /// <summary>关闭泵操作浮层</summary>
+    [RelayCommand]
+    private void ClosePumpPopup()
+    {
+        IsPumpPopupOpen = false;
+        SelectedPump = null;
+    }
+
+    [RelayCommand]
+    private void OnDeviceClicked(string deviceId)
+    {
+        if (string.IsNullOrEmpty(deviceId)) return;
+        SelectedDeviceId = deviceId;
+        Log.Information("2D 流程图设备点击: {DeviceId}", deviceId);
+
+        // 泵设备 → 弹出操作浮层(不直接启停,防止误触)
+        var pump = Pumps.FirstOrDefault(p => p.Name == deviceId);
+        if (pump != null)
+        {
+            SelectedPump = pump;
+            IsPumpPopupOpen = true;
+            return;
+        }
+
+        // 其他设备类型 → 可扩展(弹窗、设置等)
     }
     }
 
 
     public void Dispose()
     public void Dispose()
@@ -200,6 +562,11 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         _disposed = true;
         _disposed = true;
         _pollingService.DataUpdated -= OnDataUpdated;
         _pollingService.DataUpdated -= OnDataUpdated;
         _pollingService.ConnectionStateChanged -= OnConnectionStateChanged;
         _pollingService.ConnectionStateChanged -= OnConnectionStateChanged;
+        AlarmService.Instance.AlarmStateChanged -= OnAlarmStateChanged;
+        AlarmService.Instance.AlarmRaised -= OnAlarmRaised;
+        _clockTimer?.Stop();
+        _clockTimer?.Dispose();
+        _clockTimer = null;
     }
     }
 }
 }
 
 
@@ -220,6 +587,23 @@ public partial class PumpItemViewModel : ObservableObject
     [ObservableProperty] private float _dailyHours;
     [ObservableProperty] private float _dailyHours;
     [ObservableProperty] private float _dailyMinutes;
     [ObservableProperty] private float _dailyMinutes;
 
 
+    /// <summary>是否当前建议启动的泵(轮换建议,卡片琥珀色高亮)</summary>
+    [ObservableProperty] private bool _isSuggested;
+
+    /// <summary>电流负荷比例 0~1+(当前/额定;无额定值时由组内相对值填充),超载时 UI 变红</summary>
+    [ObservableProperty] private double _currentLoadRatio;
+
+    /// <summary>是否超载(负荷 ≥ 100%)</summary>
+    public bool IsOverload => CurrentLoadRatio >= 1.0;
+
+    /// <summary>额定电流(可从配置读取)</summary>
+    public float RatedCurrent => _config.RatedCurrent;
+
+    partial void OnCurrentLoadRatioChanged(double value)
+    {
+        OnPropertyChanged(nameof(IsOverload));
+    }
+
     public PumpItemViewModel(PumpConfig config)
     public PumpItemViewModel(PumpConfig config)
     {
     {
         _config = config;
         _config = config;
@@ -230,32 +614,214 @@ public partial class PumpItemViewModel : ObservableObject
         IsRunning = data.GetDigitalRegister($"{Name}.Run");
         IsRunning = data.GetDigitalRegister($"{Name}.Run");
         HasFault = data.GetDigitalRegister($"{Name}.Fault");
         HasFault = data.GetDigitalRegister($"{Name}.Fault");
         Current = data.GetAnalogRegister($"{Name}.Current");
         Current = data.GetAnalogRegister($"{Name}.Current");
-        IsAutoMode = data.GetDigitalRegister($"{Name}.AutoMode");
-        IsCentralMode = data.GetDigitalRegister($"{Name}.ModeSelect");
+        var newAutoMode = data.GetDigitalRegister($"{Name}.AutoMode"); // AutoState: 1=自动 0=手动
+        var newCentralMode = !data.GetDigitalRegister($"{Name}.ModeSelect"); // 0=中控 → true
+
+        // 中控/手自动状态变化 → 需要刷新依赖 CanExecute 的命令(启动/停止/切手动)
+        var modeChanged = newAutoMode != IsAutoMode || newCentralMode != IsCentralMode;
+        IsAutoMode = newAutoMode;
+        IsCentralMode = newCentralMode;
+        if (modeChanged)
+        {
+            StartCommand?.NotifyCanExecuteChanged();
+            StopCommand?.NotifyCanExecuteChanged();
+            TogglePumpCommand?.NotifyCanExecuteChanged();
+            ToggleManualModeCommand?.NotifyCanExecuteChanged();
+        }
+
         DailyHours = data.GetAnalogRegister($"{Name}.DailyHours");
         DailyHours = data.GetAnalogRegister($"{Name}.DailyHours");
         DailyMinutes = data.GetAnalogRegister($"{Name}.DailyMinutes");
         DailyMinutes = data.GetAnalogRegister($"{Name}.DailyMinutes");
     }
     }
 
 
-    /// <summary>手动启动</summary>
-    [RelayCommand]
+    /// <summary>手动启动 — 需满足「中控 + 手动」双重条件</summary>
+    [RelayCommand(CanExecute = nameof(CanStartOrStop))]
     private async Task StartAsync()
     private async Task StartAsync()
     {
     {
         if (string.IsNullOrEmpty(_config.StartAddress)) return;
         if (string.IsNullOrEmpty(_config.StartAddress)) return;
+
+        // 模式联锁检查
+        var (allowed, reason) = CheckManualControlAllowed();
+        if (!allowed)
+        {
+            Log.Warning("泵启动被拒绝: {Name} — {Reason}", Name, reason);
+            return;
+        }
+
         Log.Information("泵启动: {Name} → {Addr}", Name, _config.StartAddress);
         Log.Information("泵启动: {Name} → {Addr}", Name, _config.StartAddress);
         await PlcService.WriteBoolAsync(_config.StartAddress, true);
         await PlcService.WriteBoolAsync(_config.StartAddress, true);
         await Task.Delay(200); // 点动:短暂置位后复位
         await Task.Delay(200); // 点动:短暂置位后复位
         await PlcService.WriteBoolAsync(_config.StartAddress, false);
         await PlcService.WriteBoolAsync(_config.StartAddress, false);
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            "泵启动", $"{Name} → {_config.StartAddress}", Name);
     }
     }
 
 
-    /// <summary>手动停止</summary>
-    [RelayCommand]
+    /// <summary>手动停止 — 需满足「中控 + 手动」双重条件</summary>
+    [RelayCommand(CanExecute = nameof(CanStartOrStop))]
     private async Task StopAsync()
     private async Task StopAsync()
     {
     {
         if (string.IsNullOrEmpty(_config.StopAddress)) return;
         if (string.IsNullOrEmpty(_config.StopAddress)) return;
+
+        // 模式联锁检查
+        var (allowed, reason) = CheckManualControlAllowed();
+        if (!allowed)
+        {
+            Log.Warning("泵停止被拒绝: {Name} — {Reason}", Name, reason);
+            return;
+        }
+
         Log.Information("泵停止: {Name} → {Addr}", Name, _config.StopAddress);
         Log.Information("泵停止: {Name} → {Addr}", Name, _config.StopAddress);
         await PlcService.WriteBoolAsync(_config.StopAddress, true);
         await PlcService.WriteBoolAsync(_config.StopAddress, true);
         await Task.Delay(200);
         await Task.Delay(200);
         await PlcService.WriteBoolAsync(_config.StopAddress, false);
         await PlcService.WriteBoolAsync(_config.StopAddress, false);
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            "泵停止", $"{Name} → {_config.StopAddress}", Name);
+    }
+
+    /// <summary>
+    /// 统一启停切换 — 无论当前运行状态,点击即切到对端
+    /// IsRunning=true → 停止, IsRunning=false → 启动
+    /// </summary>
+    [RelayCommand(CanExecute = nameof(CanStartOrStop))]
+    private async Task TogglePumpAsync()
+    {
+        var isStart = !IsRunning;
+        var addr = isStart ? _config.StartAddress : _config.StopAddress;
+        if (string.IsNullOrEmpty(addr)) return;
+
+        // 模式联锁检查
+        var (allowed, reason) = CheckManualControlAllowed();
+        if (!allowed)
+        {
+            Log.Warning("泵{Action}被拒绝: {Name} — {Reason}", isStart ? "启动" : "停止", Name, reason);
+            return;
+        }
+
+        Log.Information("泵{Action}: {Name} → {Addr}", isStart ? "启动" : "停止", Name, addr);
+        await PlcService.WriteBoolAsync(addr, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(addr, false);
+
+        // 乐观更新
+        IsRunning = isStart;
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            isStart ? "泵启动" : "泵停止", $"{Name} → {addr}", Name);
+    }
+
+    /// <summary>启动/停止按钮可用条件:中控模式 + 手动状态(PLC 反馈确认)</summary>
+    private bool CanStartOrStop => IsCentralMode && !IsAutoMode;
+
+    /// <summary>手动/自动切换按钮可用条件:需处于中控模式</summary>
+    private bool CanSwitchMode => IsCentralMode;
+
+    /// <summary>
+    /// 统一切换手动/自动模式 — 无论当前状态,点击即切到对端
+    /// IsAutoMode=true(当前自动) → 写手动命令, IsAutoMode=false(当前手动) → 写自动命令
+    /// </summary>
+    [RelayCommand(CanExecute = nameof(CanSwitchMode))]
+    private async Task ToggleManualModeAsync()
+    {
+        var toManual = IsAutoMode; // 当前自动则切手动, 当前手动则切自动
+        var addr = toManual ? _config.ManualCommandAddress : _config.AutoCommandAddress;
+        if (string.IsNullOrEmpty(addr)) return;
+        if (!IsCentralMode)
+        {
+            Log.Warning("切换{Mode}被拒绝: {Name} — 当前为屏控模式", toManual ? "手动" : "自动", Name);
+            return;
+        }
+
+        Log.Information("泵切换{Mode}: {Name} → {Addr}", toManual ? "手动" : "自动", Name, addr);
+        await PlcService.WriteBoolAsync(addr, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(addr, false);
+
+        // 乐观更新
+        IsAutoMode = !toManual;
+        StartCommand?.NotifyCanExecuteChanged();
+        StopCommand?.NotifyCanExecuteChanged();
+        TogglePumpCommand?.NotifyCanExecuteChanged();
+        ToggleManualModeCommand?.NotifyCanExecuteChanged();
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            toManual ? "切换手动" : "切换自动",
+            $"{Name} → {addr}", Name);
+    }
+
+    /// <summary>切换到手动模式 — 需中控模式已确认(保留供 Hub 调用)</summary>
+    [RelayCommand]
+    private async Task SwitchToManualAsync()
+    {
+        if (string.IsNullOrEmpty(_config.ManualCommandAddress)) return;
+        if (!IsCentralMode)
+        {
+            Log.Warning("切换手动被拒绝: {Name} — 当前为屏控模式", Name);
+            return;
+        }
+
+        Log.Information("泵切换手动: {Name} → {Addr}", Name, _config.ManualCommandAddress);
+        await PlcService.WriteBoolAsync(_config.ManualCommandAddress, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(_config.ManualCommandAddress, false);
+
+        // 乐观更新
+        IsAutoMode = false;
+        StartCommand?.NotifyCanExecuteChanged();
+        StopCommand?.NotifyCanExecuteChanged();
+        TogglePumpCommand?.NotifyCanExecuteChanged();
+        ToggleManualModeCommand?.NotifyCanExecuteChanged();
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            "切换手动", $"{Name} → {_config.ManualCommandAddress}", Name);
+    }
+
+    /// <summary>切换到自动模式(保留供 Hub 调用)</summary>
+    [RelayCommand]
+    private async Task SwitchToAutoAsync()
+    {
+        if (string.IsNullOrEmpty(_config.AutoCommandAddress)) return;
+
+        Log.Information("泵切换自动: {Name} → {Addr}", Name, _config.AutoCommandAddress);
+        await PlcService.WriteBoolAsync(_config.AutoCommandAddress, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(_config.AutoCommandAddress, false);
+
+        // 乐观更新
+        IsAutoMode = true;
+        StartCommand?.NotifyCanExecuteChanged();
+        StopCommand?.NotifyCanExecuteChanged();
+        TogglePumpCommand?.NotifyCanExecuteChanged();
+        ToggleManualModeCommand?.NotifyCanExecuteChanged();
+
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            "切换自动", $"{Name} → {_config.AutoCommandAddress}", Name);
+    }
+
+    /// <summary>手动模式切换按钮:仅当处于中控模式且当前为自动状态时可用</summary>
+    private bool CanSwitchToManual => IsCentralMode && IsAutoMode;
+
+    /// <summary>手动操作前置条件检查:
+    /// ① 项目配置允许手动控制
+    /// ② 当前为中控模式 (ModeSelect=0)
+    /// ③ 当前为手动状态 (AutoState=0)
+    /// </summary>
+    private (bool allowed, string reason) CheckManualControlAllowed()
+    {
+        // 检查项目配置
+        var profile = ProjectService.Current;
+        if (profile != null && !profile.Control.AllowManualControl)
+            return (false, "项目配置禁止远程手动控制");
+
+        // 检查中控模式
+        if (!IsCentralMode)
+            return (false, "当前为屏控模式,请先切换到中控");
+
+        // 检查手动状态 (AutoState: 1=自动 0=手动, IsAutoMode=true 表示自动)
+        if (IsAutoMode)
+            return (false, "当前为自动模式,请先切换到手动");
+
+        return (true, "");
     }
     }
 
 
     /// <summary>日台时清零</summary>
     /// <summary>日台时清零</summary>
@@ -276,6 +842,7 @@ public partial class PumpItemViewModel : ObservableObject
 public partial class TankItemViewModel : ObservableObject
 public partial class TankItemViewModel : ObservableObject
 {
 {
     private readonly TankConfig _config;
     private readonly TankConfig _config;
+    private bool _editInitialized;
 
 
     public string Name => _config.Name;
     public string Name => _config.Name;
 
 
@@ -286,6 +853,39 @@ public partial class TankItemViewModel : ObservableObject
     [ObservableProperty] private float _stopLevel;
     [ObservableProperty] private float _stopLevel;
     [ObservableProperty] private float _protectionLevel;
     [ObservableProperty] private float _protectionLevel;
 
 
+    // ─── 可编辑阈值(供参数同步到 PLC)───
+
+    [ObservableProperty] private float _editStartPump1Level;
+    [ObservableProperty] private float _editStartPump2Level;
+    [ObservableProperty] private float _editStartPump3Level;
+    [ObservableProperty] private float _editStopLevel;
+    [ObservableProperty] private float _editProtectionLevel;
+
+    [ObservableProperty] private bool _isSyncing;
+    [ObservableProperty] private string _syncStatusText = "";
+
+    /// <summary>是否有可写入 PLC 的地址配置</summary>
+    public bool HasWritableAddresses =>
+        !string.IsNullOrEmpty(_config.StartPump1LevelAddress) ||
+        !string.IsNullOrEmpty(_config.StartPump2LevelAddress) ||
+        !string.IsNullOrEmpty(_config.StartPump3LevelAddress) ||
+        !string.IsNullOrEmpty(_config.StopLevelAddress) ||
+        !string.IsNullOrEmpty(_config.ProtectionLevelAddress);
+
+    /// <summary>当前液位工况档位(依据液位与阈值自动判定,供 UI 徽章展示)</summary>
+    [ObservableProperty] private TankStage _currentStage = TankStage.Stop;
+
+    /// <summary>
+    /// 液位趋势采样缓冲(最近 N 个采样点,供 sparkline 小曲线绘制)。
+    /// 每帧数据更新追加一次,保留最近 90 个点(约 90 秒)。常量不可变,避免频繁 GC。
+    /// </summary>
+    public IReadOnlyList<float> Trend { get; private set; } = Array.Empty<float>();
+
+    // 趋势缓冲(固定大小环形写,随数据更新滚动)
+    private readonly float[] _trendBuffer = new float[90];
+    private int _trendIndex;
+    private int _trendCount;
+
     public TankItemViewModel(TankConfig config)
     public TankItemViewModel(TankConfig config)
     {
     {
         _config = config;
         _config = config;
@@ -299,6 +899,80 @@ public partial class TankItemViewModel : ObservableObject
         StartPump3Level = data.GetAnalogRegister($"{Name}.StartPump3Level");
         StartPump3Level = data.GetAnalogRegister($"{Name}.StartPump3Level");
         StopLevel = data.GetAnalogRegister($"{Name}.StopLevel");
         StopLevel = data.GetAnalogRegister($"{Name}.StopLevel");
         ProtectionLevel = data.GetAnalogRegister($"{Name}.ProtectionLevel");
         ProtectionLevel = data.GetAnalogRegister($"{Name}.ProtectionLevel");
+        CurrentStage = CalculateStage();
+
+        // 首次更新时初始化编辑值(后续轮询不覆盖用户输入)
+        if (!_editInitialized)
+        {
+            EditStartPump1Level = StartPump1Level;
+            EditStartPump2Level = StartPump2Level;
+            EditStartPump3Level = StartPump3Level;
+            EditStopLevel = StopLevel;
+            EditProtectionLevel = ProtectionLevel;
+            _editInitialized = true;
+        }
+
+        // 追加趋势采样(环形缓冲)
+        _trendBuffer[_trendIndex] = Level;
+        _trendIndex = (_trendIndex + 1) % _trendBuffer.Length;
+        if (_trendCount < _trendBuffer.Length) _trendCount++;
+
+        // 每次更新导出一份只读快照(按时间顺序),触发 sparkline 重绘
+        var snapshot = new float[_trendCount];
+        for (int i = 0; i < _trendCount; i++)
+            snapshot[i] = _trendBuffer[(_trendIndex - _trendCount + i + _trendBuffer.Length) % _trendBuffer.Length];
+        Trend = snapshot;
+        OnPropertyChanged(nameof(Trend));
+    }
+
+    /// <summary>
+    /// 按泵站运行逻辑判定当前液位档位:
+    /// 保护液位(最高,紧急风险)→ 启泵3 → 启泵2 → 启泵1 → 停泵区。
+    /// 阈值优先取配置值,未配置(≤0)的档位自动跳过。
+    /// </summary>
+    private TankStage CalculateStage()
+    {
+        if (ProtectionLevel > 0 && Level >= ProtectionLevel) return TankStage.Protection;
+        if (StartPump3Level > 0 && Level >= StartPump3Level) return TankStage.Pump3;
+        if (StartPump2Level > 0 && Level >= StartPump2Level) return TankStage.Pump2;
+        if (StartPump1Level > 0 && Level >= StartPump1Level) return TankStage.Pump1;
+        return TankStage.Stop;
+    }
+
+    /// <summary>将编辑的阈值同步写入 PLC</summary>
+    [RelayCommand]
+    private async Task SyncThresholdsToPlcAsync()
+    {
+        var profile = ProjectService.Current;
+        if (profile == null || !profile.Control.AllowParameterSync)
+        {
+            SyncStatusText = "项目配置禁止参数同步";
+            return;
+        }
+
+        IsSyncing = true;
+        try
+        {
+            // 更新 config 中的值
+            _config.StartPump1Level = EditStartPump1Level;
+            _config.StartPump2Level = EditStartPump2Level;
+            _config.StartPump3Level = EditStartPump3Level;
+            _config.StopLevel = EditStopLevel;
+            _config.ProtectionLevel = EditProtectionLevel;
+
+            var result = await ParameterSyncService.Instance.SyncTankThresholdsAsync(_config);
+            SyncStatusText = result.Summary;
+            Log.Information("水箱参数同步: {Tank} — {Summary}", Name, result.Summary);
+        }
+        catch (Exception ex)
+        {
+            SyncStatusText = $"同步失败: {ex.Message}";
+            Log.Error(ex, "水箱参数同步异常: {Tank}", Name);
+        }
+        finally
+        {
+            IsSyncing = false;
+        }
     }
     }
 }
 }
 
 
@@ -308,6 +982,7 @@ public partial class TankItemViewModel : ObservableObject
 public partial class GaugeItemViewModel : ObservableObject
 public partial class GaugeItemViewModel : ObservableObject
 {
 {
     private readonly GaugeConfig _config;
     private readonly GaugeConfig _config;
+    private bool _editInitialized;
 
 
     public string Name => _config.Name;
     public string Name => _config.Name;
     public string Unit => _config.Unit;
     public string Unit => _config.Unit;
@@ -318,6 +993,18 @@ public partial class GaugeItemViewModel : ObservableObject
     [ObservableProperty] private float _highSet;
     [ObservableProperty] private float _highSet;
     [ObservableProperty] private float _lowSet;
     [ObservableProperty] private float _lowSet;
 
 
+    // ─── 可编辑阈值(供参数同步到 PLC)───
+
+    [ObservableProperty] private float _editHighAlarmSet;
+    [ObservableProperty] private float _editLowAlarmSet;
+    [ObservableProperty] private bool _isSyncing;
+    [ObservableProperty] private string _syncStatusText = "";
+
+    /// <summary>是否有可写入 PLC 的地址配置</summary>
+    public bool HasWritableAddresses =>
+        !string.IsNullOrEmpty(_config.HighSetAddress) ||
+        !string.IsNullOrEmpty(_config.LowSetAddress);
+
     public GaugeItemViewModel(GaugeConfig config)
     public GaugeItemViewModel(GaugeConfig config)
     {
     {
         _config = config;
         _config = config;
@@ -328,6 +1015,46 @@ public partial class GaugeItemViewModel : ObservableObject
         Value = data.GetAnalogRegister($"{Name}.Value");
         Value = data.GetAnalogRegister($"{Name}.Value");
         HighSet = data.GetAnalogRegister($"{Name}.HighSet");
         HighSet = data.GetAnalogRegister($"{Name}.HighSet");
         LowSet = data.GetAnalogRegister($"{Name}.LowSet");
         LowSet = data.GetAnalogRegister($"{Name}.LowSet");
+
+        // 首次更新时从 PLC 读回的值初始化编辑框(与 Tank 逻辑一致)
+        if (!_editInitialized)
+        {
+            EditHighAlarmSet = HighSet;
+            EditLowAlarmSet = LowSet;
+            _editInitialized = true;
+        }
+    }
+
+    /// <summary>将编辑的阈值同步写入 PLC</summary>
+    [RelayCommand]
+    private async Task SyncThresholdsToPlcAsync()
+    {
+        var profile = ProjectService.Current;
+        if (profile == null || !profile.Control.AllowParameterSync)
+        {
+            SyncStatusText = "项目配置禁止参数同步";
+            return;
+        }
+
+        IsSyncing = true;
+        try
+        {
+            _config.HighAlarmSet = EditHighAlarmSet;
+            _config.LowAlarmSet = EditLowAlarmSet;
+
+            var result = await ParameterSyncService.Instance.SyncGaugeThresholdsAsync(_config);
+            SyncStatusText = result.Summary;
+            Log.Information("仪表参数同步: {Gauge} — {Summary}", Name, result.Summary);
+        }
+        catch (Exception ex)
+        {
+            SyncStatusText = $"同步失败: {ex.Message}";
+            Log.Error(ex, "仪表参数同步异常: {Gauge}", Name);
+        }
+        finally
+        {
+            IsSyncing = false;
+        }
     }
     }
 }
 }
 
 

+ 0 - 68
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -42,26 +42,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
     [ObservableProperty]
     [ObservableProperty]
     private bool _runAtStartup;
     private bool _runAtStartup;
 
 
-    // ─── 项目档案 & 协议 ───
-
-    [ObservableProperty]
-    private string _activeProject = "";
-
-    [ObservableProperty]
-    private string _plcProtocol = "SiemensS7";
-
-    [ObservableProperty]
-    private string _currentProjectName = "默认项目";
-
-    /// <summary>可选的项目列表</summary>
-    public ObservableCollection<string> AvailableProjects { get; } = new();
-
-    /// <summary>可选的 PLC 协议列表</summary>
-    public ObservableCollection<string> AvailableProtocols { get; } = new()
-    {
-        "SiemensS7", "MitsubishiMC", "OmronFins", "ModbusTcp", "AllenBradley"
-    };
-
     // ─── 翻译属性 ───
     // ─── 翻译属性 ───
     public string TitleText => T("Parameters");
     public string TitleText => T("Parameters");
     public string SubtitleText => T("SysConfig");
     public string SubtitleText => T("SysConfig");
@@ -75,8 +55,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
     public string LevelHighText => T("LevelHigh");
     public string LevelHighText => T("LevelHigh");
     public string LevelLowText => T("LevelLow");
     public string LevelLowText => T("LevelLow");
     public string FlowHighText => T("FlowHigh");
     public string FlowHighText => T("FlowHigh");
-    public string PumpSettingsText => T("PumpSettings");
-    public string FrequencyText => T("Frequency");
     public string SaveConfigText => T("SaveConfig");
     public string SaveConfigText => T("SaveConfig");
     public string ResetDefaultText => T("ResetDefault");
     public string ResetDefaultText => T("ResetDefault");
     public string ConnectedText => T("Connected");
     public string ConnectedText => T("Connected");
@@ -99,8 +77,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(LevelHighText), "LevelHigh");
         RegisterTranslation(nameof(LevelHighText), "LevelHigh");
         RegisterTranslation(nameof(LevelLowText), "LevelLow");
         RegisterTranslation(nameof(LevelLowText), "LevelLow");
         RegisterTranslation(nameof(FlowHighText), "FlowHigh");
         RegisterTranslation(nameof(FlowHighText), "FlowHigh");
-        RegisterTranslation(nameof(PumpSettingsText), "PumpSettings");
-        RegisterTranslation(nameof(FrequencyText), "Frequency");
         RegisterTranslation(nameof(SaveConfigText), "SaveConfig");
         RegisterTranslation(nameof(SaveConfigText), "SaveConfig");
         RegisterTranslation(nameof(ResetDefaultText), "ResetDefault");
         RegisterTranslation(nameof(ResetDefaultText), "ResetDefault");
         RegisterTranslation(nameof(ConnectedText), "Connected");
         RegisterTranslation(nameof(ConnectedText), "Connected");
@@ -111,24 +87,9 @@ public partial class ViewBViewModel : TranslatableViewModelBase
 
 
         SubscribeLanguageChanged();
         SubscribeLanguageChanged();
         ConnectionStatus = T("NotConnected");
         ConnectionStatus = T("NotConnected");
-        LoadAvailableProjects();
         LoadConfig();
         LoadConfig();
     }
     }
 
 
-    /// <summary>
-    /// 加载可用项目列表
-    /// </summary>
-    private void LoadAvailableProjects()
-    {
-        AvailableProjects.Clear();
-        AvailableProjects.Add(""); // 默认项目(空字符串)
-        foreach (var name in ProjectService.ListProjects())
-            AvailableProjects.Add(name);
-
-        var current = ProjectService.Current;
-        CurrentProjectName = current?.Name ?? "默认项目";
-    }
-
     partial void OnRunAtStartupChanged(bool value)
     partial void OnRunAtStartupChanged(bool value)
     {
     {
         var success = StartupService.SetAutoStart(value);
         var success = StartupService.SetAutoStart(value);
@@ -167,8 +128,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             FlowHighAlarm = config.FlowHighAlarm;
             FlowHighAlarm = config.FlowHighAlarm;
             PumpFrequency = config.PumpFrequency;
             PumpFrequency = config.PumpFrequency;
             RunAtStartup = config.RunAtStartup;
             RunAtStartup = config.RunAtStartup;
-            ActiveProject = config.ActiveProject ?? "";
-            PlcProtocol = config.PlcProtocol ?? "SiemensS7";
         }
         }
         Log.Debug("配置已加载");
         Log.Debug("配置已加载");
     }
     }
@@ -186,9 +145,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             return;
             return;
         }
         }
 
 
-        var oldProject = config.ActiveProject;
-        var oldProtocol = config.PlcProtocol;
-
         config.PlcIp = PlcIp;
         config.PlcIp = PlcIp;
         config.PlcPort = PlcPort;
         config.PlcPort = PlcPort;
         config.AutoConnect = AutoConnect;
         config.AutoConnect = AutoConnect;
@@ -196,32 +152,9 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         config.LevelLowAlarm = LevelLowAlarm;
         config.LevelLowAlarm = LevelLowAlarm;
         config.FlowHighAlarm = FlowHighAlarm;
         config.FlowHighAlarm = FlowHighAlarm;
         config.PumpFrequency = PumpFrequency;
         config.PumpFrequency = PumpFrequency;
-        config.ActiveProject = ActiveProject;
-        config.PlcProtocol = PlcProtocol;
 
 
         ConfigService.SaveConfig();
         ConfigService.SaveConfig();
         Log.Information("配置已保存");
         Log.Information("配置已保存");
-
-        // 检测项目档案是否变化,触发热切换
-        if (oldProject != ActiveProject)
-        {
-            var success = ProjectService.SwitchProject(ActiveProject ?? "");
-            if (success)
-            {
-                CurrentProjectName = ProjectService.Current?.Name ?? "默认项目";
-                Log.Information("项目已热切换: {Project}", string.IsNullOrEmpty(ActiveProject) ? "默认" : ActiveProject);
-            }
-            else
-            {
-                Log.Warning("项目热切换失败: {Project}", ActiveProject);
-            }
-        }
-
-        // 检测协议变化,记录日志(需要重连 PLC 生效)
-        if (oldProtocol != PlcProtocol)
-        {
-            Log.Information("PLC 协议已更改: {Old} → {New},需要重新连接 PLC", oldProtocol, PlcProtocol);
-        }
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -311,7 +244,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         LevelHighAlarm = 80f;
         LevelHighAlarm = 80f;
         LevelLowAlarm = 20f;
         LevelLowAlarm = 20f;
         FlowHighAlarm = 100f;
         FlowHighAlarm = 100f;
-        PumpFrequency = 50f;
 
 
         Log.Information("已恢复默认设置");
         Log.Information("已恢复默认设置");
     }
     }

+ 26 - 0
src/YZWater.Core/ViewModels/ViewDViewModel.cs

@@ -44,6 +44,7 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     public string QueryText => T("Query");
     public string QueryText => T("Query");
     public string ExportText => T("Export");
     public string ExportText => T("Export");
     public string AckAllText => T("AckAll");
     public string AckAllText => T("AckAll");
+    public string ResetAllText => T("ResetAll");
     public string PurgeText => T("Purge");
     public string PurgeText => T("Purge");
     public string UnconfirmedText => T("Unconfirmed");
     public string UnconfirmedText => T("Unconfirmed");
     public string AlarmDetailText => T("AlarmDetail");
     public string AlarmDetailText => T("AlarmDetail");
@@ -69,6 +70,7 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(QueryText), "Query");
         RegisterTranslation(nameof(QueryText), "Query");
         RegisterTranslation(nameof(ExportText), "Export");
         RegisterTranslation(nameof(ExportText), "Export");
         RegisterTranslation(nameof(AckAllText), "AckAll");
         RegisterTranslation(nameof(AckAllText), "AckAll");
+        RegisterTranslation(nameof(ResetAllText), "ResetAll");
         RegisterTranslation(nameof(PurgeText), "Purge");
         RegisterTranslation(nameof(PurgeText), "Purge");
         RegisterTranslation(nameof(UnconfirmedText), "Unconfirmed");
         RegisterTranslation(nameof(UnconfirmedText), "Unconfirmed");
         RegisterTranslation(nameof(AlarmDetailText), "AlarmDetail");
         RegisterTranslation(nameof(AlarmDetailText), "AlarmDetail");
@@ -180,6 +182,30 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         }
         }
     }
     }
 
 
+    /// <summary>
+    /// 复位报警(清除活跃状态 + PLC 脉冲)
+    /// </summary>
+    [RelayCommand]
+    private async Task ResetAlarmAsync(AlarmRecord? record)
+    {
+        if (record == null) return;
+
+        var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
+        await AlarmService.Instance.ResetAlarmAsync(record.Id, operatorName);
+        await LoadAlarmRecordsAsync();
+    }
+
+    /// <summary>
+    /// 复位所有活跃报警
+    /// </summary>
+    [RelayCommand]
+    private async Task ResetAllAlarmsAsync()
+    {
+        var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
+        await AlarmService.Instance.ResetAllAlarmsAsync(operatorName);
+        await LoadAlarmRecordsAsync();
+    }
+
     /// <summary>
     /// <summary>
     /// 导出报警记录
     /// 导出报警记录
     /// </summary>
     /// </summary>

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