Pārlūkot izejas kodu

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

- 泵控联锁: 全局中控/屏控切换(M1.0共用), 统一启停/手动自动按钮
- 2D/3D控件: 布局引擎系统(LayoutEngine), 渲染器模块化拆分
- 新增服务: ParameterSyncService(参数同步到PLC), PasswordProtector
- AutoControlService: 按项目配置守卫, 默认不启用上位机自动控制
- 报警系统增强: 报警复位, 活跃状态管理
- ProjectView重构: 泵卡片统一切换按钮, BoolConverters扩展
- 文档更新: 杨杰二厂业务逻辑规格书
DESKTOP-74CLTRG\Leol 1 mēnesi atpakaļ
vecāks
revīzija
8da325383e
45 mainītis faili ar 5744 papildinājumiem un 561 dzēšanām
  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": "扬杰二厂泵站",
   "description": "3台排污泵 + 液位/压力/流量监控",
   "viewTemplate": "PumpStation",
+  "control": {
+    "enableAutoControl": false,
+    "controlModeDescription": "PLC自动(上位机仅监控)",
+    "allowManualControl": true,
+    "allowParameterSync": true
+  },
+  "modeSelectAddress": "M1.0",
   "pumps": [
     {
       "name": "1#泵",
       "runAddress": "Q0.1",
-      "faultAddress": "I0.1",
+      "faultAddress": "M17.1",
       "currentAddress": "VD50",
+      "ratedCurrent": 25,
       "autoStateAddress": "M2.6",
       "dailyHoursAddress": "VD950",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD10",
-      "modeSelectAddress": "M1.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M1.3",
       "stopAddress": "M1.4",
       "autoCommandAddress": "M2.2",
@@ -21,12 +30,14 @@
     {
       "name": "2#泵",
       "runAddress": "Q0.2",
-      "faultAddress": "I0.2",
+      "faultAddress": "M17.2",
       "currentAddress": "VD54",
+      "ratedCurrent": 25,
       "autoStateAddress": "M5.6",
       "dailyHoursAddress": "VD954",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD18",
-      "modeSelectAddress": "M4.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M4.3",
       "stopAddress": "M4.4",
       "autoCommandAddress": "M5.4",
@@ -36,12 +47,14 @@
     {
       "name": "3#泵",
       "runAddress": "Q0.3",
-      "faultAddress": "I0.3",
+      "faultAddress": "M17.3",
       "currentAddress": "VD58",
+      "ratedCurrent": 25,
       "autoStateAddress": "M8.6",
       "dailyHoursAddress": "VD958",
+      "dailyHoursDataType": "DINT",
       "dailyMinutesAddress": "VD28",
-      "modeSelectAddress": "M7.0",
+      "dailyMinutesDataType": "DINT",
       "startAddress": "M7.3",
       "stopAddress": "M7.4",
       "autoCommandAddress": "M8.4",
@@ -53,6 +66,7 @@
     {
       "name": "集水池",
       "levelAddress": "VD62",
+      "maxLevel": 3,
       "startPump1LevelAddress": "VD500",
       "startPump2LevelAddress": "VD504",
       "startPump3LevelAddress": "VD508",
@@ -68,7 +82,7 @@
       "highSetAddress": "VD6704",
       "lowSetAddress": "VD6700",
       "minValue": 0,
-      "maxValue": 1.6
+      "maxValue": 8
     },
     {
       "name": "流量",
@@ -80,27 +94,37 @@
   ],
   "sensors": [
     {
-      "name": "日流量",
+      "name": "总流量",
       "unit": "m³",
-      "address": "VD884",
+      "address": "VD7000",
       "dataType": "REAL",
       "access": "R"
     }
   ],
   "digitalInputs": [
+    {
+      "name": "低液位报警",
+      "address": "M19.5",
+      "remark": "1-报警 0-正常"
+    },
+    {
+      "name": "高压报警",
+      "address": "M30.1",
+      "remark": "1-报警 0-正常"
+    },
     {
       "name": "泵1变频器故障",
-      "address": "I0.1",
+      "address": "M3.1",
       "remark": "1-故障 0-正常"
     },
     {
       "name": "泵2变频器故障",
-      "address": "I0.2",
+      "address": "M6.1",
       "remark": "1-故障 0-正常"
     },
     {
       "name": "泵3变频器故障",
-      "address": "I0.3",
+      "address": "M9.1",
       "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 System.Threading;
 using YZWater.Avalonia.Controls;
+using YZWater.Avalonia.Services;
 using YZWater.Avalonia.Views;
 using YZWater.Core.Services;
 using YZWater.Core.ViewModels;
@@ -26,6 +27,9 @@ public partial class App : Application
     {
         LogService.Initialize();
 
+        // 激活 HslCommunication 授权
+        HslCommunication.Authorization.SetAuthorizationCode("c765bac7-2caf-446d-9619-f67e19d6624e");
+
         AvaloniaXamlLoader.Load(this);
 
         _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)
     {
+        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 loginView = new LoginView { DataContext = loginVm };
 
@@ -365,6 +403,12 @@ public partial class App : Application
                     {
                         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 addr = PlcConfig.GetPumpRunAddress(pumpIdx - 1);
                     var ok = await PlcService.WriteBoolAsync(addr, writeValue);
@@ -377,6 +421,40 @@ public partial class App : Application
                         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:
                     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);
         }
 
-        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 剩余数据)
+        // 必须在 DatabaseService.Dispose 之前,否则后台任务在库已关闭后仍写库引发 NRE
         try
         {
             AuditService.Shutdown();
@@ -688,6 +757,17 @@ public partial class App : Application
             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();
         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 };
+    }
+}

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 888 - 41
src/YZWater.Avalonia/Controls/PumpStation2DControl.cs


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

@@ -1,25 +1,70 @@
 using Avalonia;
 using Avalonia.Controls;
+using Avalonia.Input;
 using Avalonia.Media;
 using Avalonia.Threading;
 using System;
 using System.Collections.Generic;
 using System.Globalization;
+using System.Linq;
+using System.Windows.Input;
 using YZWater.Core.ViewModels;
+using YZWater.Core.Models;
+using YZWater.Avalonia.Controls.Layout;
 
 namespace YZWater.Avalonia.Controls;
 
 /// <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>
-[Obsolete("PumpStation3DControl 使用硬编码设备数量,不支持项目档案动态适配。请使用 ProjectView 替代。")]
 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> Tank2LevelProperty = AvaloniaProperty.Register<PumpStation3DControl, double>(nameof(Tank2Level));
     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 double Efficiency { get => GetValue(EfficiencyProperty); set => SetValue(EfficiencyProperty, value); }
 
+    // ═══ 动画 & 布局 ═══
+
     private double _phase;
+    private double _faultFlash;
     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 PumpBodyBrush = new(Color.FromRgb(31, 41, 55));
 
@@ -84,7 +134,25 @@ public class PumpStation3DControl : Control
             Fan1RunningProperty, Fan2RunningProperty,
             Valve1StatusProperty, Valve2StatusProperty, Valve3StatusProperty, Valve4StatusProperty,
             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)
@@ -93,12 +161,11 @@ public class PumpStation3DControl : Control
         ThemeHelper.ThemeChanged += OnThemeChanged;
         _timer = DispatcherTimer.Run(() =>
         {
-            var needsAnimation = AnyPumpRunning || Fan1Running || Fan2Running
-                || IsInflowRunning || IsOutflowRunning;
-
-            if (needsAnimation)
+            var needsAnim = IsDynamicMode ? HasDynamicAnimation : HasLegacyAnimation;
+            if (needsAnim)
             {
                 _phase = (_phase + 1) % 120;
+                _faultFlash = (_faultFlash + 0.025) % 1.0;
                 InvalidateVisual();
             }
             return true;
@@ -113,25 +180,243 @@ public class PumpStation3DControl : Control
         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)
     {
         base.Render(context);
-
         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);
 
+        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 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);
 
         DrawFloor(context, origin, scale);
 
-        // 工艺主线:进水渠道 -> 格栅 -> 集水湿井 -> 潜水泵 -> 阀门井 -> 出水总管。
         DrawChannel(context, origin, scale, new Point(40, 188), "进水渠道", Tank1Level);
         DrawWetWell(context, origin, scale, new Point(255, 145), Tank2Level);
         DrawValveChamber(context, origin, scale, new Point(520, 172), Tank4Level);
@@ -157,13 +442,41 @@ public class PumpStation3DControl : Control
         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)
     {
         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)
     {
-        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);
         var waterHeight = 14 + 74 * Math.Clamp(level, 0, 100) / 100;
         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)
     {
         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(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));
         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));
@@ -260,26 +558,6 @@ public class PumpStation3DControl : Control
         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)
     {
         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(Lighten(StructureColor, 0.12)), 5 * scale), a, b);
 
-        if (!isFlowing)
-            return;
-
+        if (!isFlowing) return;
         for (var i = 0; i < 9; i++)
         {
             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 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++)
         {
@@ -375,6 +650,8 @@ public class PumpStation3DControl : Control
             context.DrawRectangle(null, new Pen(ThemeHelper.Danger, 2), scene, 4);
     }
 
+    // ═══ 通用辅助 ═══
+
     private static StreamGeometry Polygon(IReadOnlyList<Point> points)
     {
         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 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 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.G + (255 - color.G) * amount),
             (byte)Math.Min(255, color.B + (255 - color.B) * amount));
-    }
 
     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;
 
 /// <summary>
-/// 管道渲染器 - 绘制双层管道(管壁 + 内管)+ 流动动画点。
+/// 管道渲染器 - 拟真管道(管壁 + 内管 + 顶部高光)+ 流动动画点 + 法兰节点。
 /// 抽取自 PumpStation2DControl,支持水平/垂直两种方向。
 /// </summary>
 public static class PipeRenderer
@@ -13,84 +13,265 @@ public static class PipeRenderer
     // 管壁/内管/流动点画刷缓存(避免每帧 new)
     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 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>
-    /// 绘制水平管道。
+    /// 绘制水平管道(3D 管壁:渐变+厚度+高光 + 流动 + 法兰 + 分段接缝)。
     /// </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(
         DrawingContext context,
         double x1, double y, double x2,
         double thickness,
         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));
 
         // 流动动画
         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>
-    /// 绘制垂直管道。
+    /// 绘制垂直管道(3D 管壁:渐变+厚度+高光 + 流动 + 法兰 + 分段接缝)。
     /// </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(
         DrawingContext context,
         double x, double y1, double y2,
         double thickness,
         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));
 
         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>
     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 dy = y2 - y1;
         var len = Math.Sqrt(dx * dx + dy * dy);
         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++)
         {
             var t = ((phase / 360.0) + (double)i / count) % 1.0;
             var px = x1 + dx * 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));
         }
     }
+
+    // ════════════════════════════════════════════════════════
+    //  立式泵(拟真)
+    // ════════════════════════════════════════════════════════
+
+    /// <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.Media;
 using System;
+using System.Collections.Generic;
 using System.Globalization;
 
 namespace YZWater.Avalonia.Controls.Renderers;
 
 /// <summary>
-/// 水箱渲染器 - 绘制矩形水箱 + 水位填充 + 波纹 + 百分比。
-/// 抽取自 PumpStation2DControl。
+/// 水箱渲染器。
+/// <list type="bullet">
+/// <item><see cref="Draw"/>:简单矩形水箱(兼容模式)</item>
+/// <item><see cref="DrawSection"/>:剖面池(SCADA 拟真)— 池壁质感 + 液位填充 +
+/// 正弦波浪动画 + 液位大字 + 左侧刻度尺 + 彩色阈值线(启泵/停泵/保护液位)</item>
+/// </list>
 /// </summary>
 public static class TankRenderer
 {
+    /// <summary>阈值线定义(液位为与 waterLevel 同单位的原始值)</summary>
+    public record ThresholdLine(double Level, Color Color, string Label);
+
     /// <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(
         DrawingContext context,
         Rect bounds,
@@ -87,6 +83,249 @@ public static class TankRenderer
         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,
         double size, IBrush brush, FontWeight weight = FontWeight.Normal)
     {
@@ -95,3 +334,13 @@ public static class TankRenderer
         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>
     public static readonly IValueConverter ToModeText =
         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"/>
                 </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">

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

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

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

@@ -1,6 +1,7 @@
 <controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
+             xmlns:models="using:YZWater.Core.Models"
              xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:conv="using:YZWater.Avalonia.Converters"
              x:Class="YZWater.Avalonia.Views.ProjectView"
@@ -24,11 +25,41 @@
                     <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
                         <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="{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"
-                                           Foreground="{DynamicResource SuccessBrush}" VerticalAlignment="Center"/>
+                                           Foreground="{Binding IsPlcConnected, Converter={x:Static conv:BoolConverters.ToBrush}}"
+                                           VerticalAlignment="Center"/>
                             </StackPanel>
                         </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}'}"
                                    FontFamily="Consolas, monospace" FontSize="16" FontWeight="Bold"
                                    Foreground="{DynamicResource SuccessBrush}" VerticalAlignment="Center"/>
@@ -36,123 +67,275 @@
                 </Grid>
             </Border>
 
-            <!-- ═══ 主内容区 ═══ -->
+            <!-- ═══ 主内容区:左侧 2D 图 + 泵控制条 | 右侧数据栏 ═══ -->
             <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>
-                                <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">
-                                                            <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 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 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 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 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 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>
                                                     </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>
-                                            </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"/>
-                                    <TextBlock Text="排污泵组" FontFamily="Consolas, monospace" FontSize="12"
+                                    <TextBlock Text="泵组控制" FontFamily="Consolas, monospace" FontSize="12"
                                                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>
-                                <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"
                                                                    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'}"
-                                                                   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 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 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"
-                                                                   Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="4,0">
+                                                                   Foreground="{DynamicResource TextSecondaryBrush}">
                                                             <TextBlock.Text>
                                                                 <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
                                                                     <Binding Path="DailyHours"/>
@@ -161,34 +344,142 @@
                                                             </TextBlock.Text>
                                                         </TextBlock>
                                                     </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>
-                                            </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">
+                        <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 Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -197,10 +488,39 @@
                             <ItemsControl ItemsSource="{Binding Gauges}">
                                 <ItemsControl.ItemTemplate>
                                     <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>
                                 </ItemsControl.ItemTemplate>
                                 <ItemsControl.ItemsPanel>
@@ -212,8 +532,8 @@
                         </StackPanel>
                     </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 Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -234,73 +554,112 @@
                         </StackPanel>
                     </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>
-                                    </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>
                 </Grid>
             </Grid>

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

@@ -22,31 +22,10 @@
 
             <!-- 内容 -->
             <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 连接设置 -->
-                    <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">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -75,7 +54,7 @@
                     </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">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
@@ -99,28 +78,9 @@
                         </StackPanel>
                     </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 Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                                 <Border Background="{DynamicResource PrimaryBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -135,8 +95,8 @@
                     </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">
                             <TextBlock Text="ACTIONS" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
                                        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 ExportText}" Command="{Binding ExportAlarmsCommand}" Classes="btn-success"/>
                         <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"/>
                     </WrapPanel>
                 </Border>

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

@@ -32,4 +32,21 @@
     <AvaloniaResource Include="Assets\app-icon.png" />
   </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>

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

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

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

@@ -38,6 +38,34 @@ public class ProjectProfile
 
     /// <summary>报警阈值配置</summary>
     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>
@@ -59,17 +87,26 @@ public class PumpConfig
     /// <summary>电流地址 (AI 区,如 AIW18 或对应的 VD 地址)</summary>
     public string CurrentAddress { get; set; } = "";
 
+    /// <summary>
+    /// 额定电流(A,可选)。配置后泵卡电流条按「当前/额定」显示负荷比例,
+    /// 超载(≥100%)变红提醒;未配置(0)时电流条退化为组内相对比例。
+    /// </summary>
+    public float RatedCurrent { get; set; }
+
     /// <summary>手自动状态地址 (读,如 M2.6, 1=自动 0=手动)</summary>
     public string AutoStateAddress { get; set; } = "";
 
     /// <summary>日台时-小时地址 (读,如 VD950)</summary>
     public string DailyHoursAddress { get; set; } = "";
 
+    /// <summary>日台时-小时数据类型: "REAL" / "INT" / "DINT" (默认 REAL)</summary>
+    public string DailyHoursDataType { get; set; } = "REAL";
+
     /// <summary>日台时-分钟地址 (读,如 VD10)</summary>
     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>
     public float? ProtectionLevel { get; set; }
 
+    /// <summary>池体满量程高度(米),用于 UI 液位归一化显示。未配置时自动取阈值中最大值</summary>
+    public float? MaxLevel { get; set; }
+
     /// <summary>启动 1 台泵液位地址 (读写,如 VD500)</summary>
     public string StartPump1LevelAddress { get; set; } = "";
 
@@ -151,6 +191,12 @@ public class GaugeConfig
     /// <summary>低压/下限设置地址 (读写,如 VD6700)</summary>
     public string LowSetAddress { get; set; } = "";
 
+    /// <summary>高压报警设定值(同步到 HighSetAddress)</summary>
+    public float? HighAlarmSet { get; set; }
+
+    /// <summary>低压报警设定值(同步到 LowSetAddress)</summary>
+    public float? LowAlarmSet { get; set; }
+
     /// <summary>仪表最小刻度值</summary>
     public double MinValue { get; set; }
 

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

@@ -12,13 +12,13 @@ public partial class SystemConfig : ObservableObject
     /// PLC IP 地址
     /// </summary>
     [ObservableProperty]
-    private string _plcIp = "192.168.0.150";
+    private string _plcIp = "192.168.2.100";
 
     /// <summary>
     /// PLC 端口
     /// </summary>
     [ObservableProperty]
-    private int _plcPort = 5000;
+    private int _plcPort = 102;
 
     /// <summary>
     /// 是否自动连接
@@ -128,6 +128,18 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private string _savedUserName = string.Empty;
 
+    /// <summary>
+    /// 保存的密码(自动登录用,AES-GCM 加密存储,密钥派生自机器名+用户名)
+    /// </summary>
+    [ObservableProperty]
+    private string _savedPassword = string.Empty;
+
+    /// <summary>
+    /// 是否启用自动登录(启动时跳过登录窗口)
+    /// </summary>
+    [ObservableProperty]
+    private bool _autoLogin;
+
     /// <summary>
     /// 当前激活的项目名称(对应 projects/ 目录下的 JSON 文件名,不含后缀)
     /// 空字符串表示使用内置默认项目配置
@@ -135,6 +147,12 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private string _activeProject = string.Empty;
 
+    /// <summary>
+    /// 是否启用 3D 工艺图视图(false = 隐藏 3D 按钮,默认仅 2D)
+    /// </summary>
+    [ObservableProperty]
+    private bool _enable3DView;
+
     /// <summary>
     /// PLC 轮询间隔(毫秒)
     /// </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
         });
 
+    /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
+    private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
+
     /// <summary>后台写入任务</summary>
     private Task _writerTask = Task.CompletedTask;
     private CancellationTokenSource _writerCts = new();
@@ -92,10 +95,27 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     public void Start()
     {
+        LoadThresholdsFromConfig();
         _cts = new CancellationTokenSource();
         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>
@@ -403,23 +423,36 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 后台消费 Channel 中的报警记录,串行写入数据库
+    /// 后台消费 Channel 中的报警记录,串行写入数据库。
+    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥),
+    /// 数据库已释放或连续写入失败时退出循环。
     /// </summary>
     private async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
+        var consecutiveFailures = 0;
         try
         {
             await foreach (var alarm in _writeChannel.Reader.ReadAllAsync(ct))
             {
                 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;
+                    consecutiveFailures = 0;
                     Log.Debug("报警记录已保存: {Id}", alarm.Id);
                 }
                 catch (Exception ex)
                 {
+                    // 连续失败 3 次退出(数据库不可用)
                     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>
@@ -572,6 +734,9 @@ public partial class AlarmService : ObservableObject, IDisposable
         _writerCts.Cancel();
         _writerCts.Dispose();
 
+        // 释放后台任务独占的数据库客户端
+        try { _bgDb.Dispose(); } catch { }
+
         Log.Information("报警服务已释放");
     }
 }

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

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

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

@@ -34,6 +34,8 @@ public class AutoControlService : IDisposable
 
     private bool _disposed;
     private bool _isEnabled = true;
+    private bool _subscribed;
+    private bool _projectSubscribed;
 
     /// <summary>是否启用自动控制</summary>
     public bool IsEnabled
@@ -81,10 +83,28 @@ public class AutoControlService : IDisposable
 
     /// <summary>
     /// 启动自动控制(订阅 PLC 数据更新)
+    /// 根据项目配置决定是否实际激活
     /// </summary>
     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;
+        _subscribed = true;
         Log.Information("自动控制系统已启动");
     }
 
@@ -93,10 +113,43 @@ public class AutoControlService : IDisposable
     /// </summary>
     public void Stop()
     {
-        PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+        if (_projectSubscribed)
+        {
+            ProjectService.ProjectChanged -= OnProjectChanged;
+            _projectSubscribed = false;
+        }
+
+        if (_subscribed)
+        {
+            PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+            _subscribed = false;
+        }
         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>
     /// PLC 数据更新回调 — 核心控制逻辑
     /// </summary>

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

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

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

@@ -9,14 +9,60 @@ namespace YZWater.Core.Services;
 /// </summary>
 public static class DatabaseService
 {
-    private static SqlSugarClient? _db;
+    private static SqlSugarScope? _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>
     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>
     /// 初始化数据库(需先调用 LogService.Initialize())
     /// </summary>
@@ -27,18 +73,26 @@ public static class DatabaseService
 
     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}",
             DbType = DbType.Sqlite,
             IsAutoCloseConnection = true,
             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();
 
@@ -126,6 +180,10 @@ public static class DatabaseService
 
     public static void Dispose()
     {
+        // 等待正在执行的写库操作完成(最多 3 秒)再关闭,
+        // 与 ExecuteWriteAsync 互斥,避免 SqliteConnection.Close() NRE
+        try { WriteGate.Wait(TimeSpan.FromSeconds(3)); } catch { /* 超时也继续关闭 */ }
+        IsDisposed = true;
         _db?.Dispose();
     }
 }

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

@@ -209,6 +209,7 @@ public partial class LanguageService : ObservableObject
         ["InputUserName"] = "请输入用户名",
         ["InputPassword"] = "请输入密码",
         ["RememberMe"] = "记住用户名",
+        ["AutoLogin"] = "自动登录",
         ["LoginButton"] = "登 录",
         ["DefaultAccount"] = "默认账户: admin / admin123",
         // 审计日志
@@ -456,6 +457,7 @@ public partial class LanguageService : ObservableObject
         ["InputUserName"] = "Enter username",
         ["InputPassword"] = "Enter password",
         ["RememberMe"] = "Remember username",
+        ["AutoLogin"] = "Auto login",
         ["LoginButton"] = "LOGIN",
         ["DefaultAccount"] = "Default: admin / admin123",
         ["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 bool _autoMode = true;
 
+    // 项目化模拟用的故障状态(1# 泵永远不故障,2#/3# 偶尔故障演示跳过逻辑)
+    private static bool _pump2Fault, _pump3Fault;
+
     /// <summary>
     /// 是否启用 Mock 模式
     /// </summary>
@@ -54,11 +57,36 @@ public static class MockPlcService
         if (_pump1) _pump1Freq = Clamp(_pump1Freq + 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;
@@ -76,8 +104,8 @@ public static class MockPlcService
         data.Pump5Running = _pump5;
 
         data.Pump1Fault = false;
-        data.Pump2Fault = false;
-        data.Pump3Fault = false;
+        data.Pump2Fault = _pump2Fault;
+        data.Pump3Fault = _pump3Fault;
         data.Pump4Fault = false;
         data.Pump5Fault = false;
 
@@ -96,6 +124,116 @@ public static class MockPlcService
         data.LastReadTime = DateTime.Now;
         data.IsDataValid = 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>

+ 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))
                 analogReads.Add(($"{pump.Name}.Current", pump.CurrentAddress));
             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))
-                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)
         {
@@ -440,10 +452,12 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 digitalReads.Add(($"{pump.Name}.Fault", pump.FaultAddress));
             if (!string.IsNullOrEmpty(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)
         {
@@ -456,6 +470,13 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         // 对于连续地址(如 VD500, VD504, VD508),使用批量读取优化
         // 立即物化 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);
 
@@ -474,27 +495,53 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 }
 
                 // 检查是否为整数类型(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
                 {
@@ -558,8 +605,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         List<(string key, string addr)> analogReads, CancellationToken ct)
     {
         // 只对 REAL 类型的地址进行批量读取(排除带 #DATATYPE 后缀的整数类型)
+        // 注意:泵名称可能包含 # (如 "1#泵"),所以要检查的是 key 是否以 #DATATYPE 结尾
         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))
             .ToList();
 
@@ -570,6 +618,16 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             // 分组连续地址
             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();
             if (batchGroups.Count == 0) return null;
@@ -593,7 +651,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 try
                 {
                     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)
@@ -601,6 +666,17 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         if (offset + 4 <= bytes.Length)
                         {
                             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)
                 {
+                    Port = plcPort,
                     ConnectTimeOut = 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 _isLoggingIn;
     [ObservableProperty] private bool _rememberUserName;
+    [ObservableProperty] private bool _autoLogin;
 
     // ─── 翻译属性(getter-only,通过基类 T() 获取翻译) ───
     public string LoginTitleText => T("LoginTitle");
@@ -27,6 +28,7 @@ public partial class LoginViewModel : TranslatableViewModelBase
     public string RememberMeText => T("RememberMe");
     public string LoginButtonText => T("LoginButton");
     public string DefaultAccountText => T("DefaultAccount");
+    public string AutoLoginText => T("AutoLogin");
 
     public event Action? LoginSucceeded;
 
@@ -41,6 +43,7 @@ public partial class LoginViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(InputUserNameText), "InputUserName");
         RegisterTranslation(nameof(InputPasswordText), "InputPassword");
         RegisterTranslation(nameof(RememberMeText), "RememberMe");
+        RegisterTranslation(nameof(AutoLoginText), "AutoLogin");
         RegisterTranslation(nameof(LoginButtonText), "LoginButton");
         RegisterTranslation(nameof(DefaultAccountText), "DefaultAccount");
 
@@ -50,10 +53,16 @@ public partial class LoginViewModel : TranslatableViewModelBase
         if (config != null)
         {
             RememberUserName = config.RememberUserName;
+            AutoLogin = config.AutoLogin;
             if (RememberUserName && !string.IsNullOrEmpty(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.SavedUserName = RememberUserName ? UserName : string.Empty;
+        config.AutoLogin = AutoLogin && RememberUserName;  // 自动登录必须同时记住用户名
+        config.SavedPassword = (AutoLogin && RememberUserName) ? PasswordProtector.Protect(Password, UserName) : string.Empty;
         ConfigService.UpdateConfig(config);
     }
 }

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

@@ -395,6 +395,14 @@ public partial class MainViewModel : TranslatableViewModelBase
     [RelayCommand]
     private void Logout()
     {
+        // 退出时关闭自动登录,下次启动显示登录窗口
+        var config = ConfigService.GetConfig();
+        if (config != null)
+        {
+            config.AutoLogin = false;
+            config.SavedPassword = string.Empty;
+            ConfigService.UpdateConfig(config);
+        }
         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 PlcPollingService _pollingService;
+    private readonly SynchronizationContext? _uiContext;
     private bool _disposed;
 
     [ObservableProperty] private string _title = "";
@@ -21,6 +22,47 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     [ObservableProperty] private bool _isPlcConnected;
     [ObservableProperty] private string _plcStatusText = "PLC 未连接";
     [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();
@@ -30,10 +72,58 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     public ObservableCollection<DigitalInputItemViewModel> DigitalInputs { 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)
     {
         _profile = profile;
         _pollingService = PlcPollingService.Instance;
+        _uiContext = SynchronizationContext.Current;
 
         Title = profile.Name;
         SubtitleText = profile.Description;
@@ -60,39 +150,201 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         // 初始化动态寄存器
         _pollingService.Data.InitializeDynamicRegisters(profile);
 
+        // 读取 3D 视图显隐配置(默认隐藏,仅 2D)
+        Show3DView = ConfigService.GetConfig()?.Enable3DView ?? false;
+        if (!Show3DView)
+        {
+            Is2DView = true;
+            Is3DView = false;
+        }
+
         // 订阅数据更新
         _pollingService.DataUpdated += OnDataUpdated;
         _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)
     {
-        // 更新泵数据
+        // 更新子 ViewModel
         foreach (var pump in Pumps)
             pump.UpdateFromData(data);
-
-        // 更新水箱数据
         foreach (var tank in Tanks)
             tank.UpdateFromData(data);
-
-        // 更新仪表数据
         foreach (var gauge in Gauges)
             gauge.UpdateFromData(data);
-
-        // 更新传感器数据
         foreach (var sensor in Sensors)
             sensor.UpdateFromData(data);
-
-        // 更新数字量输入
         foreach (var di in DigitalInputs)
             di.UpdateFromData(data);
-
-        // 更新数字量输出状态
         foreach (var doItem in DigitalOutputs)
             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);
+
+        // ─── 同步更新动态字典(供 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>
@@ -188,10 +440,120 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         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)
     {
-        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()
@@ -200,6 +562,11 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         _disposed = true;
         _pollingService.DataUpdated -= OnDataUpdated;
         _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 _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)
     {
         _config = config;
@@ -230,32 +614,214 @@ public partial class PumpItemViewModel : ObservableObject
         IsRunning = data.GetDigitalRegister($"{Name}.Run");
         HasFault = data.GetDigitalRegister($"{Name}.Fault");
         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");
         DailyMinutes = data.GetAnalogRegister($"{Name}.DailyMinutes");
     }
 
-    /// <summary>手动启动</summary>
-    [RelayCommand]
+    /// <summary>手动启动 — 需满足「中控 + 手动」双重条件</summary>
+    [RelayCommand(CanExecute = nameof(CanStartOrStop))]
     private async Task StartAsync()
     {
         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);
         await PlcService.WriteBoolAsync(_config.StartAddress, true);
         await Task.Delay(200); // 点动:短暂置位后复位
         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()
     {
         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);
         await PlcService.WriteBoolAsync(_config.StopAddress, true);
         await Task.Delay(200);
         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>
@@ -276,6 +842,7 @@ public partial class PumpItemViewModel : ObservableObject
 public partial class TankItemViewModel : ObservableObject
 {
     private readonly TankConfig _config;
+    private bool _editInitialized;
 
     public string Name => _config.Name;
 
@@ -286,6 +853,39 @@ public partial class TankItemViewModel : ObservableObject
     [ObservableProperty] private float _stopLevel;
     [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)
     {
         _config = config;
@@ -299,6 +899,80 @@ public partial class TankItemViewModel : ObservableObject
         StartPump3Level = data.GetAnalogRegister($"{Name}.StartPump3Level");
         StopLevel = data.GetAnalogRegister($"{Name}.StopLevel");
         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
 {
     private readonly GaugeConfig _config;
+    private bool _editInitialized;
 
     public string Name => _config.Name;
     public string Unit => _config.Unit;
@@ -318,6 +993,18 @@ public partial class GaugeItemViewModel : ObservableObject
     [ObservableProperty] private float _highSet;
     [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)
     {
         _config = config;
@@ -328,6 +1015,46 @@ public partial class GaugeItemViewModel : ObservableObject
         Value = data.GetAnalogRegister($"{Name}.Value");
         HighSet = data.GetAnalogRegister($"{Name}.HighSet");
         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]
     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 SubtitleText => T("SysConfig");
@@ -75,8 +55,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
     public string LevelHighText => T("LevelHigh");
     public string LevelLowText => T("LevelLow");
     public string FlowHighText => T("FlowHigh");
-    public string PumpSettingsText => T("PumpSettings");
-    public string FrequencyText => T("Frequency");
     public string SaveConfigText => T("SaveConfig");
     public string ResetDefaultText => T("ResetDefault");
     public string ConnectedText => T("Connected");
@@ -99,8 +77,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(LevelHighText), "LevelHigh");
         RegisterTranslation(nameof(LevelLowText), "LevelLow");
         RegisterTranslation(nameof(FlowHighText), "FlowHigh");
-        RegisterTranslation(nameof(PumpSettingsText), "PumpSettings");
-        RegisterTranslation(nameof(FrequencyText), "Frequency");
         RegisterTranslation(nameof(SaveConfigText), "SaveConfig");
         RegisterTranslation(nameof(ResetDefaultText), "ResetDefault");
         RegisterTranslation(nameof(ConnectedText), "Connected");
@@ -111,24 +87,9 @@ public partial class ViewBViewModel : TranslatableViewModelBase
 
         SubscribeLanguageChanged();
         ConnectionStatus = T("NotConnected");
-        LoadAvailableProjects();
         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)
     {
         var success = StartupService.SetAutoStart(value);
@@ -167,8 +128,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             FlowHighAlarm = config.FlowHighAlarm;
             PumpFrequency = config.PumpFrequency;
             RunAtStartup = config.RunAtStartup;
-            ActiveProject = config.ActiveProject ?? "";
-            PlcProtocol = config.PlcProtocol ?? "SiemensS7";
         }
         Log.Debug("配置已加载");
     }
@@ -186,9 +145,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             return;
         }
 
-        var oldProject = config.ActiveProject;
-        var oldProtocol = config.PlcProtocol;
-
         config.PlcIp = PlcIp;
         config.PlcPort = PlcPort;
         config.AutoConnect = AutoConnect;
@@ -196,32 +152,9 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         config.LevelLowAlarm = LevelLowAlarm;
         config.FlowHighAlarm = FlowHighAlarm;
         config.PumpFrequency = PumpFrequency;
-        config.ActiveProject = ActiveProject;
-        config.PlcProtocol = PlcProtocol;
 
         ConfigService.SaveConfig();
         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>
@@ -311,7 +244,6 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         LevelHighAlarm = 80f;
         LevelLowAlarm = 20f;
         FlowHighAlarm = 100f;
-        PumpFrequency = 50f;
 
         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 ExportText => T("Export");
     public string AckAllText => T("AckAll");
+    public string ResetAllText => T("ResetAll");
     public string PurgeText => T("Purge");
     public string UnconfirmedText => T("Unconfirmed");
     public string AlarmDetailText => T("AlarmDetail");
@@ -69,6 +70,7 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(QueryText), "Query");
         RegisterTranslation(nameof(ExportText), "Export");
         RegisterTranslation(nameof(AckAllText), "AckAll");
+        RegisterTranslation(nameof(ResetAllText), "ResetAll");
         RegisterTranslation(nameof(PurgeText), "Purge");
         RegisterTranslation(nameof(UnconfirmedText), "Unconfirmed");
         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>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels