Przeglądaj źródła

feat: 架构完善四阶段 + 扬杰二厂项目档案适配

阶段零 - 项目档案接线:
- 通用 DI/DO 全链路接入(变频器故障显示、日流量复位按钮)
- 泵扩展地址(中控/屏控模式、日台时统计、清零命令)
- 报警阈值接入 AlarmService(压力/液位超限自动报警)
- ViewTemplate 框架接入

阶段一 - DI 渐进式迁移:
- ConfigService 从 static 改为实例类 + IConfigService 接口
- ServiceLocator 注册,支持渐进式迁移

阶段二 - DeviceStateMachine 完善:
- 从 ProjectProfile 读取设备列表(不再硬编码 5泵+2风机)
- 订阅 PlcPollingService 数据驱动状态转换
- Fault 状态自动触发 AlarmService 报警

阶段三 - WorkflowService 完善:
- 液位自动控制(多级启停泵逻辑)
- 泵启停实际写入 PLC(点动 StartAddress/StopAddress)

阶段四 - PlcDrivers 多品牌预留:
- SystemConfig 新增 PlcProtocol 配置项
- PlcService 文档化协议切换方式

其他:
- 代码清理:Typeface/Brush 缓存、TranslatableViewModelBase
- 新增文档:ARCHITECTURE_ROADMAP.md、扬杰二厂泵站需求.md
- 新增渲染器:Renderers/ 目录(消除 2D/3D 控件重复代码)
DESKTOP-74CLTRG\Leol 1 miesiąc temu
rodzic
commit
c2d00b94fb
59 zmienionych plików z 2314 dodań i 1059 usunięć
  1. 166 0
      docs/ARCHITECTURE_ROADMAP.md
  2. 234 0
      docs/扬杰二厂泵站需求.md
  3. 17 9
      src/YZWater.Avalonia/App.axaml.cs
  4. 3 2
      src/YZWater.Avalonia/Controls/FanControl.cs
  5. 6 6
      src/YZWater.Avalonia/Controls/GaugeControl.cs
  6. 3 2
      src/YZWater.Avalonia/Controls/PipeLineControl.cs
  7. 6 4
      src/YZWater.Avalonia/Controls/PumpControl.cs
  8. 18 184
      src/YZWater.Avalonia/Controls/PumpStation2DControl.cs
  9. 74 0
      src/YZWater.Avalonia/Controls/Renderers/FanRenderer.cs
  10. 66 0
      src/YZWater.Avalonia/Controls/Renderers/FlowMeterRenderer.cs
  11. 96 0
      src/YZWater.Avalonia/Controls/Renderers/PipeRenderer.cs
  12. 81 0
      src/YZWater.Avalonia/Controls/Renderers/PumpRenderer.cs
  13. 97 0
      src/YZWater.Avalonia/Controls/Renderers/TankRenderer.cs
  14. 83 0
      src/YZWater.Avalonia/Controls/Renderers/ValveRenderer.cs
  15. 6 5
      src/YZWater.Avalonia/Controls/StatusCard.cs
  16. 0 1
      src/YZWater.Avalonia/Controls/ThemeHelper.cs
  17. 47 0
      src/YZWater.Avalonia/Controls/ThemedUserControl.cs
  18. 3 2
      src/YZWater.Avalonia/Controls/ValveControl.cs
  19. 4 3
      src/YZWater.Avalonia/Controls/WaterTankControl.cs
  20. 12 0
      src/YZWater.Avalonia/Converters/BoolConverters.cs
  21. 67 7
      src/YZWater.Avalonia/Views/ProjectView.axaml
  22. 1 18
      src/YZWater.Avalonia/Views/ProjectView.axaml.cs
  23. 2 2
      src/YZWater.Avalonia/Views/ViewAView.axaml
  24. 1 21
      src/YZWater.Avalonia/Views/ViewAView.axaml.cs
  25. 3 2
      src/YZWater.Avalonia/Views/ViewBView.axaml
  26. 1 18
      src/YZWater.Avalonia/Views/ViewBView.axaml.cs
  27. 3 2
      src/YZWater.Avalonia/Views/ViewCView.axaml
  28. 1 18
      src/YZWater.Avalonia/Views/ViewCView.axaml.cs
  29. 3 2
      src/YZWater.Avalonia/Views/ViewDView.axaml
  30. 1 18
      src/YZWater.Avalonia/Views/ViewDView.axaml.cs
  31. 3 2
      src/YZWater.Avalonia/Views/ViewEView.axaml
  32. 1 18
      src/YZWater.Avalonia/Views/ViewEView.axaml.cs
  33. 19 13
      src/YZWater.Core/Models/HubMessage.cs
  34. 16 0
      src/YZWater.Core/Models/PlcDataModel.cs
  35. 7 0
      src/YZWater.Core/Models/SystemConfig.cs
  36. 15 0
      src/YZWater.Core/Services/AlarmService.cs
  37. 51 41
      src/YZWater.Core/Services/ConfigService.cs
  38. 2 2
      src/YZWater.Core/Services/DataLoggingService.cs
  39. 130 1
      src/YZWater.Core/Services/DeviceStateMachine.cs
  40. 2 2
      src/YZWater.Core/Services/HubClient.cs
  41. 17 12
      src/YZWater.Core/Services/HubServer.cs
  42. 14 12
      src/YZWater.Core/Services/Interfaces/IConfigService.cs
  43. 0 77
      src/YZWater.Core/Services/Jobs/PollingJobs.cs
  44. 29 0
      src/YZWater.Core/Services/NotificationService.cs
  45. 12 10
      src/YZWater.Core/Services/PlcPollingService.cs
  46. 16 3
      src/YZWater.Core/Services/PlcService.cs
  47. 2 20
      src/YZWater.Core/Services/QuartzSchedulerService.cs
  48. 28 0
      src/YZWater.Core/Services/ThemeColors.cs
  49. 121 15
      src/YZWater.Core/Services/WorkflowService.cs
  50. 26 31
      src/YZWater.Core/ViewModels/LoginViewModel.cs
  51. 92 76
      src/YZWater.Core/ViewModels/MainViewModel.cs
  52. 173 0
      src/YZWater.Core/ViewModels/ProjectViewModel.cs
  53. 91 0
      src/YZWater.Core/ViewModels/TranslatableViewModelBase.cs
  54. 135 161
      src/YZWater.Core/ViewModels/ViewAViewModel.cs
  55. 52 58
      src/YZWater.Core/ViewModels/ViewBViewModel.cs
  56. 53 58
      src/YZWater.Core/ViewModels/ViewCViewModel.cs
  57. 38 43
      src/YZWater.Core/ViewModels/ViewDViewModel.cs
  58. 31 39
      src/YZWater.Core/ViewModels/ViewEViewModel.cs
  59. 33 39
      src/YZWater.Core/ViewModels/ViewFViewModel.cs

+ 166 - 0
docs/ARCHITECTURE_ROADMAP.md

@@ -0,0 +1,166 @@
+# 架构完善计划
+
+> PLC 型号:**西门子 S7-200 Smart**,通信协议:**HslCommunication S7Net**
+>
+> 恢复的 4 组代码均有明确设计意图,但实现不完整。本计划按优先级排列,逐步将它们从"占位代码"推进到"生产可用"。
+
+---
+
+## 阶段零:项目档案接线(扬杰二厂急需,快速补全)
+
+**目标**:将 `ProjectProfile` 中已定义但未接线的模型字段接入到服务层/ViewModel/UI。
+
+**现状**:
+- `ProjectProfile` 定义了 8 个子配置(Pump/Tank/Gauge/Sensor/DI/DO/AlarmThresholds/ViewTemplate)
+- 核心路径(泵/水箱/仪表/传感器)已完整可用
+- 但 DI/DO/报警阈值/泵扩展地址/ViewTemplate 只有模型,下游没有接线
+
+**步骤**:
+
+| # | 任务 | 涉及文件 | 说明 |
+|---|------|----------|------|
+| 0.1 | 通用 DI 接入 | `PlcDataModel.cs` + `PlcPollingService.cs` + `ProjectViewModel.cs` + `ProjectView.axaml` | PlcPolling 读取 DigitalInputs → ViewModel 显示变频器故障 |
+| 0.2 | 通用 DO 接入 | `ProjectViewModel.cs` + `ProjectView.axaml` | 日流量复位/复位按钮点动写入 |
+| 0.3 | 泵扩展地址接入 | `PumpItemViewModel.cs` + `PlcPollingService.cs` + `ProjectView.axaml` | 中控/屏控状态读取、日台时清零、手自动状态 |
+| 0.4 | 报警阈值接入 | `ProjectViewModel.cs` | AlarmThresholds → AlarmService 超限报警 |
+| 0.5 | ViewTemplate 接入 | `MainViewModel.cs` | 根据 Profile.ViewTemplate 选择布局 |
+
+**完成标准**:
+- [ ] 扬杰二厂 JSON 中配置的所有地址均可读取/写入
+- [ ] 变频器故障等 DI 信号在 UI 上可见
+- [ ] 日流量复位、日台时清零等 DO 操作可执行
+- [ ] 压力/液位超限自动触发报警
+
+---
+
+## 阶段一:DI 渐进式迁移(基础,其他阶段的前置条件)
+
+**目标**:让 Service 可测试、可替换,为后续阶段打好基础。
+
+**现状**:
+- 接口已定义(IPlcService / IAlarmService / IConfigService / IDatabaseService / IAuthService)
+- ServiceLocator 已实现,但零调用方
+- 所有 Service 是 static class,不实现接口
+
+**步骤**:
+
+| # | 任务 | 涉及文件 | 说明 |
+|---|------|----------|------|
+| 1.1 | 选择 1 个低风险 Service 试点 | `ConfigService` | 它无状态、无并发,最适合试点 |
+| 1.2 | ConfigService 从 static 改为实例类 | `ConfigService.cs` | 实现 `IConfigService`,保留 `Instance` 静态属性做兼容 |
+| 1.3 | ServiceLocator 注册 ConfigService | `ServiceCollectionExtensions.cs` | `services.AddSingleton<IConfigService>(ConfigService.Instance)` |
+| 1.4 | 逐批替换调用方 | 所有 ViewModel/Service | `ConfigService.GetConfig()` → `ServiceLocator.GetService<IConfigService>().GetConfig()` |
+| 1.5 | 重复 1.2~1.4 覆盖其他 Service | 按依赖顺序:IConfigService → IDatabaseService → IAuthService → IPlcService → IAlarmService | 每完成一个 Service 跑一次全量测试 |
+| 1.6 | (可选)引入构造函数注入 | `App.axaml.cs` | `services.BuildServiceProvider()` + ViewModel 通过构造函数接收依赖 |
+
+**完成标准**:
+- [ ] 所有 Service 实现对应接口
+- [ ] ServiceLocator 有实际调用方(至少 ConfigService 全量替换完成)
+- [ ] 现有功能不受影响
+
+---
+
+## 阶段二:DeviceStateMachine 完善(设备状态管理)
+
+**目标**:让泵/风机的状态转换有规则、可审计。
+
+**现状**:
+- `DeviceStateMachine` 类已定义状态(Stopped/Running/Fault/Maintenance)和转换
+- `DeviceStateMachineManager` 已实现,硬编码"5 泵 + 2 风机"
+- 使用 Stateless 库,但**没有与 PlcPollingService/AlarmService 联动**
+- 方法体只记日志
+
+**步骤**:
+
+| # | 任务 | 涉及文件 | 说明 |
+|---|------|----------|------|
+| 2.1 | 从 ProjectProfile 读设备列表 | `DeviceStateMachine.cs` | `InitializeDefaultDevices()` 改为从 `ProjectService.CurrentProfile` 读设备,不再硬编码 |
+| 2.2 | 接入 PLC 数据驱动状态转换 | `DeviceStateMachine.cs` | 订阅 `PlcPollingService.DataUpdated` 事件,根据 `PumpRunning`/`PumpFault` 信号触发状态转换 |
+| 2.3 | 添加状态变更事件 | `DeviceStateMachine.cs` | `event Action<DeviceStateChange> StateChanged`,供 UI/通知消费 |
+| 2.4 | 接入 AlarmService | `AlarmService.cs` | 设备进入 Fault 状态时自动触发报警;报警恢复时自动转 Maintenance |
+| 2.5 | ViewAViewModel 消费状态机 | `ViewAViewModel.cs` | 替换手写的 `UpdateDeviceStatuses`,改读 DeviceStateMachine 的状态 |
+| 2.6 | 状态变更持久化 | `DeviceStateMachine.cs` | 写入 `equipment_statuses` 表,支持历史查询 |
+
+**完成标准**:
+- [ ] 泵/风机状态由状态机管理,不再用散落的 bool 字段
+- [ ] 状态转换合法(Fault 不能直接 → Running)
+- [ ] 状态变更事件可供 UI 和通知消费
+- [ ] 设备数量从项目配置读取
+
+---
+
+## 阶段三:WorkflowService 完善(工艺流程编排)
+
+**目标**:让污水处理的多步骤流程可控、可配置。
+
+**现状**:
+- `WorkflowService` 是简化版工作流引擎(Elsa 失败后的降级方案)
+- 只有 `RegisterSampleWorkflows()` 里的日志占位
+- 没有与任何业务服务联动
+
+**步骤**:
+
+| # | 任务 | 涉及文件 | 说明 |
+|---|------|----------|------|
+| 3.1 | 定义工作流模型 | `Models/WorkflowDefinition.cs` | `WorkflowDefinition`(步骤列表)、`WorkflowStep`(条件 + 动作 + 下一步) |
+| 3.2 | 从 JSON 配置加载工作流 | `WorkflowService.cs` | `Workflows` 配置节 → `WorkflowDefinition` 列表 |
+| 3.3 | 实现工作流执行引擎 | `WorkflowService.cs` | `ExecuteAsync(workflowId)`:按步骤执行,支持条件分支、等待、超时 |
+| 3.4 | 实现内置动作 | `WorkflowService.cs` | 预置动作:`StartPump`/`StopPump`/`CheckLevel`/`SendNotification` 等 |
+| 3.5 | 与 DeviceStateMachine 联动 | `WorkflowService.cs` | 工作流步骤可以查询/等待设备状态 |
+| 3.6 | UI 展示工作流状态 | `ViewFViewModel.cs` | 显示当前运行的工作流、步骤进度、历史执行记录 |
+
+**完成标准**:
+- [ ] 可通过 JSON 配置定义工作流
+- [ ] 工作流能驱动实际控制(启停泵、阀门切换)
+- [ ] 工作流执行有审计日志
+
+---
+
+## 阶段四:PlcDrivers 接入(多品牌 PLC 支持)
+
+**目标**:让项目支持西门子以外的 PLC 品牌。
+
+**现状**:
+- 7 个驱动文件已恢复(Siemens/AllenBradley/Mitsubishi/Omron/ModbusTcp)
+- `PlcDriverFactory` 完整,但 `PlcService` 直接硬编码 `SiemensS7Net`
+- `PlcConfig` 地址格式是西门子专用的(VD100, Q0.0)
+
+**步骤**:
+
+| # | 任务 | 涉及文件 | 说明 |
+|---|------|----------|------|
+| 4.1 | PlcConfig 添加协议选择 | `PlcConfig.cs` + `yzwater-config.json` | 新增 `PlcProtocol` 配置项,默认 `SiemensS7` |
+| 4.2 | PlcService 使用 PlcDriverFactory | `PlcService.cs` | `ConnectAsync()` 中根据协议创建对应驱动,替换硬编码的 `SiemensS7Net` |
+| 4.3 | 统一地址格式 | `PlcConfig.cs` + 各驱动 | 定义通用地址模型,各驱动负责转换为品牌专用地址 |
+| 4.4 | UI 协议选择 | `ViewBView.axaml` + `ViewBViewModel.cs` | 参数设置页添加 PLC 品牌/协议下拉框 |
+| 4.5 | 集成测试 | `tests/` | 至少对 ModbusTcp 做一次真实连接测试 |
+
+**完成标准**:
+- [ ] `yzwater-config.json` 可配置 PLC 协议
+- [ ] 至少 2 种协议可正常读写(Siemens + 另一品牌)
+- [ ] UI 可切换协议
+
+---
+
+## 依赖关系
+
+```
+阶段零(项目档案接线)← 扬杰二厂急需,最先做
+    ↓
+阶段一(DI 迁移)← 基础,建议最先做(可与阶段零并行)
+    ↓
+阶段二(状态机)← 需要 DI 让状态机可注入
+    ↓
+阶段三(工作流)← 依赖状态机的设备状态查询
+阶段四(多品牌 PLC)← 独立,可随时启动(当前项目用 S7-200 Smart + Hsl S7 协议)
+```
+
+## 时间估算
+
+| 阶段 | 预估工作量 | 优先级 |
+|------|-----------|--------|
+| 阶段零:项目档案接线 | 1~2 天 | ⭐⭐⭐ 最高(扬杰二厂急需) |
+| 阶段一:DI 迁移 | 2~3 天 | ⭐⭐⭐ 最高(基础) |
+| 阶段二:状态机 | 3~4 天 | ⭐⭐⭐ 高(工业控制核心) |
+| 阶段三:工作流 | 4~5 天 | ⭐⭐ 中(自动化增值) |
+| 阶段四:多品牌 PLC | 2~3 天 | ⭐ 按需(有新项目时) |

+ 234 - 0
docs/扬杰二厂泵站需求.md

@@ -0,0 +1,234 @@
+# 扬杰二厂泵站监控系统 — 项目需求
+
+> 基于 PLC 点位表(2026-08-29 版)整理
+
+---
+
+## 一、项目概况
+
+| 项 | 值 |
+|---|---|
+| **项目名称** | 扬杰二厂泵站监控系统 |
+| **PLC 型号** | 西门子 S7 系列(地址格式 I/Q/M/VD 确认) |
+| **控制规模** | 3 台排污泵 + 液位/压力/流量传感器 |
+| **控制方式** | 中控(上位机)/ 屏控(现场触摸屏)二选一 |
+
+---
+
+## 二、硬件点位总览
+
+### 2.1 数字量输入(DI)
+
+| 地址 | 名称 | 方向 | 说明 |
+|------|------|------|------|
+| I0.0 | — | 读 | 预留 |
+| I0.1 | 泵1变频器故障 | 读 | 1=故障, 0=正常 |
+| I0.2 | 泵2变频器故障 | 读 | 同上 |
+| I0.3 | 泵3变频器故障 | 读 | 同上 |
+
+### 2.2 数字量输出(DO)
+
+| 地址 | 名称 | 方向 | 说明 |
+|------|------|------|------|
+| Q0.0 | — | 写 | 预留 |
+| Q0.1 | 1#泵起停 | 写 | |
+| Q0.2 | 2#泵起停 | 写 | |
+| Q0.3 | 3#泵起停 | 写 | |
+| Q0.4 ~ Q0.7 | — | 写 | 预留 |
+
+### 2.3 模拟量输入(AI)
+
+| 模块地址 | VD 映射 | 名称 | 数据类型 | 方向 |
+|----------|---------|------|----------|------|
+| AIW16 | VD62 | 即时液位 | REAL | 只读 |
+| AIW18 | VD50 | 泵1电流 | REAL | 读 |
+| AIW20 | VD54 | 泵2电流 | REAL | 读 |
+| AIW22 | VD58 | 泵3电流 | REAL | 读 |
+| AIW24 | — | — | — | 预留 |
+| AIW26 | VD6600 | 即时压力 | REAL | 读 |
+| AIW28 | VD6604 | 即时流量 | REAL | 读 |
+
+### 2.4 其他
+
+| 地址 | 名称 | 方向 | 说明 |
+|------|------|------|------|
+| VD884 | 日流量 | 读 | 累计流量 |
+| M19.3 | 日流量复位 | 写 | |
+
+---
+
+## 三、排污泵控制点
+
+### 3.1 泵1 控制点
+
+| 序号 | 功能 | 地址 | 方向 | 说明 |
+|------|------|------|------|------|
+| 1 | 中控/屏控选择 | M1.0 | — | 0=中控, 1=屏控 |
+| 2 | 上位手动启动 | M1.3 | 写 | 点动 |
+| 3 | 上位手动停止 | M1.4 | 写 | 点动 |
+| 4 | 上位自动命令 | M2.2 | 写 | 点动 |
+| 5 | 上位手动命令 | M2.1 | 写 | 点动 |
+| 6 | 手自动状态 | M2.6 | 读 | 1=自动, 0=手动 |
+| 7 | 故障信号 | M3.1 | 读 | 1=故障, 0=正常 |
+
+### 3.2 泵2 控制点
+
+| 序号 | 功能 | 地址 | 方向 | 说明 |
+|------|------|------|------|------|
+| 1 | 中控/屏控选择 | M4.0 | — | 0=中控, 1=屏控 |
+| 2 | 上位手动启动 | M4.3 | 写 | 点动 |
+| 3 | 上位手动停止 | M4.4 | 写 | 点动 |
+| 4 | 上位自动命令 | M5.4 | 写 | 点动 |
+| 5 | 上位手动命令 | M5.3 | 写 | 点动 |
+| 6 | 手自动状态 | M5.6 | 读 | 1=手动, 0=自动 |
+| 7 | 故障信号 | M6.1 | 读 | 1=故障, 0=正常 |
+
+### 3.3 泵3 控制点
+
+| 序号 | 功能 | 地址 | 方向 | 说明 |
+|------|------|------|------|------|
+| 1 | 中控/屏控选择 | M7.0 | — | 0=中控, 1=屏控 |
+| 2 | 上位手动启动 | M7.3 | 写 | 点动 |
+| 3 | 上位手动停止 | M7.4 | 写 | 点动 |
+| 4 | 上位自动命令 | M8.4 | 写 | 点动 |
+| 5 | 上位手动命令 | M8.3 | 写 | 点动 |
+| 6 | 手自动状态 | M8.6 | 读 | 1=手动, 0=自动 |
+| 7 | 故障信号 | M9.1 | 读 | 1=故障, 0=正常 |
+
+> **注意**:泵1的手自动状态含义(1=自动)与泵2/泵3(1=手动)**相反**,需要确认 PLC 程序是否一致。
+
+### 3.4 日台时统计
+
+| 功能 | 泵1 | 泵2 | 泵3 | 方向 |
+|------|-----|-----|-----|------|
+| 日台时-小时 | VD950 | VD954 | VD958 | 读 |
+| 日台时-分钟 | VD10 | VD18 | VD28 | 读 |
+| 日台时清零 | M19.4 | M19.4 | M19.4 | 写 |
+
+---
+
+## 四、液位控制参数
+
+| VD 地址 | 名称 | 方向 | 用途 |
+|---------|------|------|------|
+| VD62 | 即时液位 | 只读 | 当前集水坑液位 |
+| VD500 | 启动1台泵液位 | 读写 | 液位低于此值 → 启1台泵 |
+| VD504 | 启动2台泵液位 | 读写 | 液位低于此值 → 启2台泵 |
+| VD508 | 启动3台泵液位 | 读写 | 液位低于此值 → 启3台泵 |
+| VD528 | 停止液位 | 读写 | 液位高于此值 → 全部停止 |
+| VD962 | 保护液位 | 读写 | 超限 → 紧急停机 + 报警 |
+
+---
+
+## 五、压力/流量参数
+
+| VD 地址 | 名称 | 方向 | 用途 |
+|---------|------|------|------|
+| VD6600 | 即时压力 | 读 | 实时压力值 |
+| VD6700 | 低压设置 | 读写 | 低压报警阈值 |
+| VD6704 | 高压设置 | 读写 | 高压报警阈值 |
+| VD6604 | 即时流量 | 读 | 实时流量值 |
+
+---
+
+## 六、控制逻辑
+
+### 6.1 控制模式
+
+```
+M*.0 = 0 → 中控模式(上位机可远程操作)
+M*.0 = 1 → 屏控模式(仅现场触摸屏操作,上位机只监视)
+```
+
+> 中控模式下,上位机的启停命令才有效;屏控模式下上位只能监视状态。
+
+### 6.2 液位自动控制
+
+```
+液位变化趋势:
+    ↑ 保护液位 (VD962)  ───── 紧急停机 + 报警
+    │
+    ↑ 停止液位 (VD528)  ───── 全部泵停止
+    │
+    │ ↑ 启1台 (VD500)   ───── 启动 1 台泵
+    │ │
+    │ │ ↑ 启2台 (VD504) ───── 启动 2 台泵
+    │ │ │
+    │ │ │ ↑ 启3台 (VD508) ─── 启动 3 台泵
+    │ │ │ │
+```
+
+**启停逻辑**(液位从高到低):
+
+| 条件 | 动作 |
+|------|------|
+| 液位 < VD508 | 启动第3台泵(共3台运行) |
+| 液位 < VD504 | 启动第2台泵(共2台运行) |
+| 液位 < VD500 | 启动第1台泵(共1台运行) |
+| 液位 > VD528 | 全部停止 |
+| 液位 > VD962 | 紧急停机 + 保护报警 |
+
+**防频繁启停**(建议):
+- 启泵条件需持续 3 秒以上
+- 停泵条件需持续 5 秒以上
+- 同一台泵两次启动间隔 ≥ 60 秒
+
+### 6.3 报警逻辑
+
+| 条件 | 报警级别 | 动作 |
+|------|----------|------|
+| 压力 < VD6700 | 警告 | 记录日志 + 界面提示 |
+| 压力 > VD6704 | 警告 | 记录日志 + 界面提示 |
+| 液位 > VD962 | 严重 | 紧急停机 + 通知 |
+| 泵变频器故障 (I0.x) | 严重 | 停止对应泵 + 通知 |
+
+---
+
+## 七、与当前代码的差异分析
+
+| 维度 | 当前代码(默认配置) | 实际需求 | 适配工作量 |
+|------|---------------------|----------|-----------|
+| 泵数量 | 5台 | **3台** | 中(需改 PlcConfig + UI) |
+| 风机 | 2台 | **0台** | 低(移除或忽略) |
+| 水箱/液位 | 4个水箱 | **1个集水坑** | 中 |
+| 泵频率 | 5个频率读取 | **无** | 低(移除) |
+| 流量 | 进/出水流量 | **1个流量 + 日流量** | 中 |
+| 压力 | 无 | **压力 + 高低压报警** | 高(需新增) |
+| 电流 | 无 | **3台泵电流** | 高(需新增) |
+| 日台时 | 无 | **3台泵日台时** | 高(需新增) |
+| 控制模式 | 无 | **中控/屏控切换** | 高(需新增) |
+| 自动控制 | 无 | **多级液位自动启停** | 高(核心逻辑) |
+
+---
+
+## 八、需要开发的功能点
+
+### P0 — 核心功能
+
+1. **PLC 地址重映射** — 按实际 VD/M/I/Q 地址配置 PlcConfig
+2. **液位自动控制逻辑** — 多级启停泵 + 保护液位 + 防频繁启停
+3. **中控/屏控切换** — 中控模式下才允许远程操作
+
+### P1 — 监测功能
+
+4. **压力监测** — 实时显示 + 高低压报警
+5. **泵电流监测** — 实时曲线 + 过载报警
+6. **3台泵状态** — 运行/停止/故障/手自动状态
+
+### P2 — 统计功能
+
+7. **日台时统计** — 读取 PLC 累计值 + 每日清零 + 报表
+8. **日流量统计** — 读取 VD884 + 历史曲线
+
+---
+
+## 九、待确认事项
+
+| # | 问题 | 影响 |
+|---|------|------|
+| 1 | 泵2/泵3 的"手自动状态"位含义是否与泵1一致?(泵1: 1=自动, 泵2/3: 1=手动) | 控制逻辑 |
+| 2 | 3台泵是否需要考虑轮换运行(均衡磨损)? | 自动控制策略 |
+| 3 | "点动"命令的持续时间是多少毫秒? | 写入逻辑 |
+| 4 | 保护液位触发后,是否需要人工确认才能恢复? | 安全逻辑 |
+| 5 | 日台时清零是每天固定时间还是手动触发? | 统计逻辑 |
+| 6 | 是否有备用泵的概念(某台泵故障时自动切换)? | 可靠性 |

+ 17 - 9
src/YZWater.Avalonia/App.axaml.cs

@@ -3,6 +3,7 @@ using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Markup.Xaml;
 using Avalonia.Markup.Xaml.Styling;
+using Microsoft.Extensions.DependencyInjection;
 using System.Threading;
 using YZWater.Avalonia.Controls;
 using YZWater.Avalonia.Views;
@@ -64,6 +65,12 @@ public partial class App : Application
     public override void OnFrameworkInitializationCompleted()
     {
         DatabaseService.Initialize();
+
+        // 初始化 ServiceLocator(渐进式 DI 迁移)
+        var services = new ServiceCollection();
+        services.AddSingleton<IConfigService>(ConfigService.Instance);
+        ServiceLocator.Configure(services.BuildServiceProvider());
+
         // PlcService.Initialize() 延迟到 StartPlcServices() 中调用(登录成功后)
 
         // 启动单实例监听(新启动时会激活本窗口)
@@ -213,17 +220,17 @@ public partial class App : Application
     {
         try
         {
-            // 1. 初始化设备状态机
+            // 1. 初始化设备状态机(从项目档案或默认配置读取设备列表)
             DeviceStateMachineManager.Instance.InitializeDefaultDevices();
-            Serilog.Log.Information("设备状态机已初始化");
-
-            // 2. 启动 Quartz 调度器
-            await QuartzSchedulerService.Instance.StartAsync();
-            Serilog.Log.Information("Quartz 调度器已启动");
+            DeviceStateMachineManager.Instance.SubscribeToPlcData();
+            Serilog.Log.Information("设备状态机已初始化并订阅 PLC 数据");
 
-            // 3. 初始化 Elsa 工作流引擎
-            await WorkflowService.Instance.InitializeAsync();
-            Serilog.Log.Information("Elsa 工作流引擎已初始化");
+            // 2+3. 并行启动 Quartz 调度器和 Elsa 工作流引擎(互不依赖)
+            await Task.WhenAll(
+                QuartzSchedulerService.Instance.StartAsync(),
+                WorkflowService.Instance.InitializeAsync()
+            );
+            Serilog.Log.Information("Quartz 调度器和 Elsa 工作流引擎已启动");
         }
         catch (Exception ex)
         {
@@ -502,6 +509,7 @@ public partial class App : Application
 
         try
         {
+            DeviceStateMachineManager.Instance.UnsubscribeFromPlcData();
             DeviceStateMachineManager.Instance.Clear();
             Serilog.Log.Information("[{Elapsed}ms] DeviceStateMachineManager 已清理", sw.ElapsedMilliseconds);
         }

+ 3 - 2
src/YZWater.Avalonia/Controls/FanControl.cs

@@ -47,13 +47,14 @@ public class FanControl : Control
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _angle += Speed * 2; if (_angle >= 360) _angle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(50)); }
     private void StopAnim() { _timer?.Dispose(); _timer = null; }
 
+    private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 5;
-        var typeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
         var fanColor = IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled;
 
         context.DrawRectangle(null, new Pen(ThemeHelper.Border, 2), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
@@ -71,7 +72,7 @@ public class FanControl : Control
 
         if (!string.IsNullOrEmpty(Text))
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 10, ThemeHelper.TextPrimary);
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, ThemeHelper.TextPrimary);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
         }
     }

+ 6 - 6
src/YZWater.Avalonia/Controls/GaugeControl.cs

@@ -43,13 +43,15 @@ public class GaugeControl : Control
         InvalidateVisual();
     }
 
+    private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+    private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 + 10;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 20;
-        var typeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
 
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius - 5, cy - radius - 5, (radius + 5) * 2, (radius + 5) * 2));
         DrawArc(context, new Pen(ThemeHelper.Border, 8), cx, cy, radius, 135, 270);
@@ -78,26 +80,24 @@ public class GaugeControl : Control
         // Title(缓存:文字/字体不变时复用)
         if (_cachedTitle == null || _cachedTitleText != Title)
         {
-            _cachedTitle = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 12, ThemeHelper.TextPrimary);
+            _cachedTitle = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, ThemeHelper.TextPrimary);
             _cachedTitleText = Title;
         }
         context.DrawText(_cachedTitle, new Point(cx - _cachedTitle.Width / 2, bounds.Y + 5));
 
         // Value(缓存:格式化字符串不变时复用)
         var valStr = $"{Value:F1}";
-        var valTypeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
         if (_cachedValue == null || _cachedValueText != valStr)
         {
-            _cachedValue = new FormattedText(valStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, valTypeface, 20, ThemeHelper.TextPrimary);
+            _cachedValue = new FormattedText(valStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 20, ThemeHelper.TextPrimary);
             _cachedValueText = valStr;
         }
         context.DrawText(_cachedValue, new Point(cx - _cachedValue.Width / 2, cy + radius * 0.3));
 
         // Unit(缓存:文字不变时复用)
-        var unitTypeface = new Typeface("Microsoft YaHei");
         if (_cachedUnit == null || _cachedUnitText != Unit)
         {
-            _cachedUnit = new FormattedText(Unit, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, unitTypeface, 10, ThemeHelper.TextDisabled);
+            _cachedUnit = new FormattedText(Unit, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 10, ThemeHelper.TextDisabled);
             _cachedUnitText = Unit;
         }
         context.DrawText(_cachedUnit, new Point(cx - _cachedUnit.Width / 2, cy + radius * 0.3 + _cachedValue.Height + 2));

+ 3 - 2
src/YZWater.Avalonia/Controls/PipeLineControl.cs

@@ -24,6 +24,7 @@ public class PipeLineControl : Control
 
     private double _flowOffset;
     private IDisposable? _timer;
+    private static readonly double[] s_dashPattern = { 8, 12 };
 
     static PipeLineControl() { AffectsRender<PipeLineControl>(IsFlowProperty, FlowSpeedProperty, PipeColorProperty, WaterColorProperty, PipeWidthProperty, IsHorizontalProperty); }
 
@@ -64,13 +65,13 @@ public class PipeLineControl : Control
         {
             context.DrawRectangle(pipeColor, null, new Rect(0, (bounds.Height - pw) / 2, bounds.Width, pw));
             if (IsFlow)
-                context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(new double[] { 8, 12 }, _flowOffset)), new Point(0, bounds.Height / 2), new Point(bounds.Width, bounds.Height / 2));
+                context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(0, bounds.Height / 2), new Point(bounds.Width, bounds.Height / 2));
         }
         else
         {
             context.DrawRectangle(pipeColor, null, new Rect((bounds.Width - pw) / 2, 0, pw, bounds.Height));
             if (IsFlow)
-                context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(new double[] { 8, 12 }, _flowOffset)), new Point(bounds.Width / 2, 0), new Point(bounds.Width / 2, bounds.Height));
+                context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(bounds.Width / 2, 0), new Point(bounds.Width / 2, bounds.Height));
         }
     }
 

+ 6 - 4
src/YZWater.Avalonia/Controls/PumpControl.cs

@@ -59,13 +59,15 @@ public class PumpControl : Control
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _rotationAngle += Frequency / 20; if (_rotationAngle >= 360) _rotationAngle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(30)); }
     private void StopAnim() { _timer?.Dispose(); _timer = null; }
 
+    private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+    private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 - 5;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 15;
-        var typeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
 
         var statusColor = (IsRunning ? RunningColor : StoppedColor) ?? (IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled);
         var statusBrush = statusColor as SolidColorBrush ?? (IsRunning ? ThemeHelper.Success as SolidColorBrush : ThemeHelper.TextDisabled as SolidColorBrush);
@@ -76,12 +78,12 @@ public class PumpControl : Control
         context.DrawEllipse(null, new Pen(ThemeHelper.Border, 3), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius + 3, cy - radius + 3, (radius - 3) * 2, (radius - 3) * 2));
 
+        var bladeBrush = IsRunning ? new SolidColorBrush(statusColorValue, 200) : ThemeHelper.TextDisabled;
         for (int i = 0; i < 6; i++)
         {
             var angle = _rotationAngle + 60.0 * i;
             var bx = cx + Math.Cos(angle * Math.PI / 180) * radius * 0.65 * 0.5;
             var by = cy + Math.Sin(angle * Math.PI / 180) * radius * 0.65 * 0.5;
-            var bladeBrush = IsRunning ? new SolidColorBrush(statusColorValue, 200) : ThemeHelper.TextDisabled;
             var rad = angle * Math.PI / 180;
             var cos = Math.Cos(rad); var sin = Math.Sin(rad);
             var transform = new Matrix(cos, sin, -sin, cos, bx - bx * cos + by * sin, by - bx * sin - by * cos);
@@ -99,13 +101,13 @@ public class PumpControl : Control
 
         if (!string.IsNullOrEmpty(Text))
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 11, ThemeHelper.TextPrimary);
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 11, ThemeHelper.TextPrimary);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 5));
         }
 
         if (IsRunning)
         {
-            var f = new FormattedText($"{Frequency:F0}Hz", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Microsoft YaHei"), 9, statusColor);
+            var f = new FormattedText($"{Frequency:F0}Hz", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, statusColor);
             context.DrawText(f, new Point(cx - f.Width / 2, cy + radius + 5));
         }
     }

+ 18 - 184
src/YZWater.Avalonia/Controls/PumpStation2DControl.cs

@@ -6,6 +6,7 @@ using System;
 using System.Collections.Generic;
 using System.Globalization;
 using YZWater.Core.ViewModels;
+using YZWater.Avalonia.Controls.Renderers;
 
 namespace YZWater.Avalonia.Controls;
 
@@ -72,11 +73,6 @@ public class PumpStation2DControl : Control
     private double _fanAngle;
     private IDisposable? _timer;
 
-    // ═══ 静态画刷缓存(避免每帧 new SolidColorBrush) ═══
-    private static readonly SolidColorBrush PipeWallBrush = new(Color.FromRgb(55, 65, 81));
-    private static readonly SolidColorBrush PipeInnerBrush = new(Color.FromRgb(97, 111, 134));
-    private static readonly SolidColorBrush FlowDotBrush = new(Color.FromRgb(3, 169, 244), 0.85);
-
     static PumpStation2DControl()
     {
         AffectsRender<PumpStation2DControl>(
@@ -273,40 +269,10 @@ public class PumpStation2DControl : Control
         var h = H(sc, designRect.Height);
         var rect = new Rect(x, y, w, h);
 
-        // 箱体
-        context.DrawRectangle(ThemeHelper.SurfaceBg, new Pen(ThemeHelper.Border, 1.5), rect, 3, 3);
-
-        // 水位填充
-        var clampedLevel = Math.Clamp(level, 0, 100);
-        if (clampedLevel > 0.5)
-        {
-            var waterH = (h - 4) * clampedLevel / 100.0;
-            var waterRect = new Rect(x + 2, y + h - 2 - waterH, w - 4, waterH);
-            var waterBrush = new SolidColorBrush(waterColor, 0.65);
-            context.DrawRectangle(waterBrush, null, waterRect);
-
-            // 水面波纹
-            var waveY = waterRect.Y;
-            var waveBrush = new SolidColorBrush(waterColor, 0.35);
-            for (int i = 0; i < 3; i++)
-            {
-                var wx = waterRect.X + 4 + i * (waterRect.Width / 3.0);
-                context.DrawLine(new Pen(waveBrush, 1),
-                    new Point(wx, waveY), new Point(wx + waterRect.Width / 6.0, waveY + 2));
-            }
-        }
-
-        // 名称标签
-        var fontSize = Math.Max(8, 11 * sc);
-        DrawLabel(context, ox, oy, sc, designRect.Center.X - 20, designRect.Y + 6, name, 11, ThemeHelper.TextPrimary, FontWeight.Bold);
-        DrawLabel(context, ox, oy, sc, designRect.Center.X - 14, designRect.Y + 20, code, 9, ThemeHelper.TextTertiary);
-
-        // 液位百分比
-        var pctText = $"{clampedLevel:F1}%";
-        var pctColor = clampedLevel > 80 ? ThemeHelper.Warning :
-                       clampedLevel < 20 ? ThemeHelper.Danger :
-                       new SolidColorBrush(waterColor);
-        DrawLabel(context, ox, oy, sc, designRect.Center.X - 18, designRect.Center.Y + 8, pctText, 13, pctColor, FontWeight.Bold);
+        TankRenderer.Draw(context, rect, level, waterColor, name, code,
+            ThemeHelper.SurfaceBg, ThemeHelper.Border,
+            ThemeHelper.TextPrimary, ThemeHelper.TextTertiary,
+            ThemeHelper.Warning, ThemeHelper.Danger);
     }
 
     // ═══ 管道绘制 ═══
@@ -319,17 +285,7 @@ public class PumpStation2DControl : Control
         var x2 = S(ox, sc, x2Design);
         var thickness = R(sc, thicknessDesign);
 
-        // 管壁
-        context.DrawLine(new Pen(PipeWallBrush, thickness),
-            new Point(x1, y), new Point(x2, y));
-
-        // 内管
-        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
-            new Point(x1, y), new Point(x2, y));
-
-        // 流动动画
-        if (isFlowing && x2 > x1 + 2)
-            DrawFlowDots(context, x1, y, x2, y);
+        PipeRenderer.DrawHorizontal(context, x1, y, x2, thickness, isFlowing, _phase);
     }
 
     private void DrawPipeV(DrawingContext context, double ox, double oy, double sc,
@@ -340,32 +296,7 @@ public class PumpStation2DControl : Control
         var y2 = S(oy, sc, y2Design);
         var thickness = R(sc, thicknessDesign);
 
-        context.DrawLine(new Pen(PipeWallBrush, thickness),
-            new Point(x, y1), new Point(x, y2));
-
-        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
-            new Point(x, y1), new Point(x, y2));
-
-        if (isFlowing && Math.Abs(y2 - y1) > 2)
-            DrawFlowDots(context, x, y1, x, y2);
-    }
-
-    private void DrawFlowDots(DrawingContext context, double x1, double y1, double x2, double y2)
-    {
-        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));
-        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);
-            context.DrawEllipse(FlowDotBrush, null, new Rect(px - r, py - r, r * 2, r * 2));
-        }
+        PipeRenderer.DrawVertical(context, x, y1, y2, thickness, isFlowing, _phase);
     }
 
     // ═══ 泵绘制 ═══
@@ -376,38 +307,11 @@ public class PumpStation2DControl : Control
         var cx = S(ox, sc, designPos.X);
         var cy = S(oy, sc, designPos.Y);
         var r = R(sc, 18);
-
         var statusColor = running ? GetColor(ThemeHelper.Success) : GetColor(ThemeHelper.TextDisabled);
-        var statusBrush = new SolidColorBrush(statusColor);
-
-        // 运行光晕
-        if (running)
-            context.DrawEllipse(new SolidColorBrush(statusColor, 0.18), null,
-                new Rect(cx - r * 1.4, cy - r * 1.4, r * 2.8, r * 2.8));
-
-        // 泵体圆
-        context.DrawEllipse(ThemeHelper.SurfaceBg, new Pen(statusBrush, running ? 2 : 1.2),
-            new Rect(cx - r, cy - r, r * 2, r * 2));
-
-        // 旋转叶片
-        var bladeCount = 4;
-        for (int i = 0; i < bladeCount; i++)
-        {
-            var angle = (running ? _pumpAngle : 0) + i * (360.0 / bladeCount);
-            var rad = angle * Math.PI / 180.0;
-            var bx = cx + Math.Cos(rad) * r * 0.65;
-            var by = cy + Math.Sin(rad) * r * 0.65;
-            var bladeBrush = new SolidColorBrush(statusColor, running ? 0.9 : 0.45);
-            context.DrawLine(new Pen(bladeBrush, Math.Max(1.5, 2.5 * sc)),
-                new Point(cx, cy), new Point(bx, by));
-        }
-
-        // 中心圆
-        context.DrawEllipse(statusBrush, null,
-            new Rect(cx - r * 0.22, cy - r * 0.22, r * 0.44, r * 0.44));
 
-        // 标签
-        DrawLabelAt(context, cx, cy + r + 3, label, Math.Max(8, 10 * sc), ThemeHelper.TextPrimary, FontWeight.Bold);
+        PumpRenderer.Draw(context, new Point(cx, cy), r, running, statusColor,
+            _pumpAngle, label, ThemeHelper.SurfaceBg, ThemeHelper.Border, ThemeHelper.TextPrimary,
+            bladeCount: 4, bladePenThickness: Math.Max(1.5, 2.5 * sc));
     }
 
     // ═══ 风机绘制 ═══
@@ -418,33 +322,11 @@ public class PumpStation2DControl : Control
         var cx = S(ox, sc, designPos.X);
         var cy = S(oy, sc, designPos.Y);
         var r = R(sc, 20);
-
         var statusColor = running ? GetColor(ThemeHelper.Info) : GetColor(ThemeHelper.TextDisabled);
-        var statusBrush = new SolidColorBrush(statusColor);
-
-        // 外壳
-        context.DrawEllipse(ThemeHelper.SurfaceBg, new Pen(statusBrush, running ? 2 : 1.2),
-            new Rect(cx - r, cy - r, r * 2, r * 2));
 
-        // 旋转叶片 (5 片)
-        var bladeCount = 5;
-        for (int i = 0; i < bladeCount; i++)
-        {
-            var angle = (running ? _fanAngle : 0) + i * (360.0 / bladeCount);
-            var rad = angle * Math.PI / 180.0;
-            var bx = cx + Math.Cos(rad) * r * 0.72;
-            var by = cy + Math.Sin(rad) * r * 0.72;
-            var bladeBrush = new SolidColorBrush(statusColor, running ? 0.85 : 0.4);
-            context.DrawLine(new Pen(bladeBrush, Math.Max(2, 3 * sc)),
-                new Point(cx, cy), new Point(bx, by));
-        }
-
-        // 中心
-        context.DrawEllipse(statusBrush, null,
-            new Rect(cx - r * 0.18, cy - r * 0.18, r * 0.36, r * 0.36));
-
-        // 标签
-        DrawLabelAt(context, cx, cy + r + 4, label, Math.Max(8, 10 * sc), ThemeHelper.TextPrimary, FontWeight.Bold);
+        FanRenderer.Draw(context, new Point(cx, cy), r, running, _fanAngle, label,
+            statusColor, ThemeHelper.SurfaceBg, ThemeHelper.TextPrimary,
+            bladeCount: 5, bladePenThickness: Math.Max(2, 3 * sc));
     }
 
     // ═══ 阀门绘制(蝴蝶阀符号)═══
@@ -463,47 +345,9 @@ public class PumpStation2DControl : Control
             ValveStatus.Closed => GetColor(ThemeHelper.Danger),
             _ => GetColor(ThemeHelper.TextDisabled)
         };
-        var colorBrush = new SolidColorBrush(color);
 
-        // 蝴蝶阀:两个三角形
-        var top = new Point(cx, cy - s);
-        var bottom = new Point(cx, cy + s);
-        var left = new Point(cx - s * 0.7, cy);
-        var right = new Point(cx + s * 0.7, cy);
-
-        // 上三角
-        var tri1 = new StreamGeometry();
-        using (var ctx = tri1.Open())
-        {
-            ctx.BeginFigure(left, true);
-            ctx.LineTo(top);
-            ctx.LineTo(right);
-            ctx.EndFigure(true);
-        }
-        context.DrawGeometry(new SolidColorBrush(color, 0.7), null, tri1);
-
-        // 下三角(倒置)
-        var tri2 = new StreamGeometry();
-        using (var ctx = tri2.Open())
-        {
-            ctx.BeginFigure(left, true);
-            ctx.LineTo(bottom);
-            ctx.LineTo(right);
-            ctx.EndFigure(true);
-        }
-        context.DrawGeometry(new SolidColorBrush(color, 0.7), null, tri2);
-
-        // 阀杆
-        context.DrawLine(new Pen(colorBrush, Math.Max(1, 1.5 * sc)),
-            new Point(cx - s * 0.5, cy), new Point(cx + s * 0.5, cy));
-
-        // 状态指示点
-        var lampR = Math.Max(2, 3 * sc);
-        context.DrawEllipse(colorBrush, null,
-            new Rect(cx - lampR, cy - s - lampR * 2.5, lampR * 2, lampR * 2));
-
-        // 标签
-        DrawLabelAt(context, cx, cy + s + 3, label, Math.Max(7, 9 * sc), ThemeHelper.TextSecondary, FontWeight.Bold);
+        ValveRenderer.Draw(context, new Point(cx, cy), s, color, label,
+            ThemeHelper.TextSecondary, stemPenThickness: Math.Max(1, 1.5 * sc));
     }
 
     // ═══ 流量计绘制 ═══
@@ -516,19 +360,9 @@ public class PumpStation2DControl : Control
         var w = W(sc, 56);
         var h = H(sc, 32);
 
-        var color = active ? GetColor(ThemeHelper.Success) : GetColor(ThemeHelper.TextDisabled);
-        var colorBrush = new SolidColorBrush(color);
-
-        // 外框
-        var rect = new Rect(cx - w / 2, cy - h / 2, w, h);
-        context.DrawRectangle(new SolidColorBrush(GetColor(ThemeHelper.SurfaceBg), 0.95),
-            new Pen(colorBrush, 1.2), rect, 2, 2);
-
-        // 标签
-        DrawLabelAt(context, cx, cy - h * 0.18, label, Math.Max(7, 8 * sc), ThemeHelper.TextTertiary);
-
-        // 数值
-        DrawLabelAt(context, cx, cy + h * 0.12, $"{value:F1}", Math.Max(8, 10 * sc), colorBrush, FontWeight.Bold);
+        FlowMeterRenderer.Draw(context, new Point(cx, cy), w, h, label, value, active,
+            GetColor(ThemeHelper.Success), GetColor(ThemeHelper.TextDisabled),
+            ThemeHelper.SurfaceBg, ThemeHelper.TextTertiary);
     }
 
     // ═══ HUD 状态面板 ═══

+ 74 - 0
src/YZWater.Avalonia/Controls/Renderers/FanRenderer.cs

@@ -0,0 +1,74 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 风机渲染器 - 绘制圆形外壳 + 多片旋转扇叶。
+/// 抽取自 PumpStation2DControl。
+/// </summary>
+public static class FanRenderer
+{
+    /// <summary>
+    /// 绘制一个风机。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="center">风机中心(屏幕坐标)</param>
+    /// <param name="radius">风机半径</param>
+    /// <param name="isRunning">是否运行中</param>
+    /// <param name="rotationAngle">当前叶片旋转角度(度)</param>
+    /// <param name="text">标签文字(如 F1)</param>
+    /// <param name="statusColor">状态颜色(运行=Info色, 停止=Disabled色)</param>
+    /// <param name="bgBrush">外壳背景画刷</param>
+    /// <param name="textBrush">文字画刷</param>
+    /// <param name="bladeCount">叶片数量</param>
+    /// <param name="bladePenThickness">叶片画笔粗细</param>
+    public static void Draw(
+        DrawingContext context,
+        Point center,
+        double radius,
+        bool isRunning,
+        double rotationAngle,
+        string text,
+        Color statusColor,
+        IBrush bgBrush,
+        IBrush textBrush,
+        int bladeCount = 5,
+        double bladePenThickness = 3)
+    {
+        var cx = center.X;
+        var cy = center.Y;
+        var statusBrush = new SolidColorBrush(statusColor);
+
+        // 外壳
+        context.DrawEllipse(bgBrush, new Pen(statusBrush, isRunning ? 2 : 1.2),
+            new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
+
+        // 旋转叶片
+        for (int i = 0; i < bladeCount; i++)
+        {
+            var angle = (isRunning ? rotationAngle : 0) + i * (360.0 / bladeCount);
+            var rad = angle * Math.PI / 180.0;
+            var bx = cx + Math.Cos(rad) * radius * 0.72;
+            var by = cy + Math.Sin(rad) * radius * 0.72;
+            var bladeBrush = new SolidColorBrush(statusColor, isRunning ? 0.85 : 0.4);
+            context.DrawLine(new Pen(bladeBrush, bladePenThickness),
+                new Point(cx, cy), new Point(bx, by));
+        }
+
+        // 中心圆
+        context.DrawEllipse(statusBrush, null,
+            new Rect(cx - radius * 0.18, cy - radius * 0.18, radius * 0.36, radius * 0.36));
+
+        // 标签
+        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, cy + radius + 4));
+        }
+    }
+}

+ 66 - 0
src/YZWater.Avalonia/Controls/Renderers/FlowMeterRenderer.cs

@@ -0,0 +1,66 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 流量计渲染器 - 绘制矩形外框 + 标签 + 数值。
+/// 抽取自 PumpStation2DControl。
+/// </summary>
+public static class FlowMeterRenderer
+{
+    /// <summary>
+    /// 绘制一个流量计。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="center">流量计中心(屏幕坐标)</param>
+    /// <param name="width">外框宽度</param>
+    /// <param name="height">外框高度</param>
+    /// <param name="label">标签文字(如"进水")</param>
+    /// <param name="value">数值</param>
+    /// <param name="isActive">是否激活(有流量)</param>
+    /// <param name="activeColor">激活色</param>
+    /// <param name="inactiveColor">非激活色</param>
+    /// <param name="bgBrush">外框背景画刷</param>
+    /// <param name="labelBrush">标签文字画刷</param>
+    public static void Draw(
+        DrawingContext context,
+        Point center,
+        double width,
+        double height,
+        string label,
+        double value,
+        bool isActive,
+        Color activeColor,
+        Color inactiveColor,
+        IBrush bgBrush,
+        IBrush labelBrush)
+    {
+        var cx = center.X;
+        var cy = center.Y;
+        var color = isActive ? activeColor : inactiveColor;
+        var colorBrush = new SolidColorBrush(color);
+
+        // 外框
+        var rect = new Rect(cx - width / 2, cy - height / 2, width, height);
+        context.DrawRectangle(new SolidColorBrush(bgBrush is SolidColorBrush sc ? sc.Color : global::Avalonia.Media.Colors.Gray, 0.95),
+            new Pen(colorBrush, 1.2), rect, 2, 2);
+
+        // 标签
+        if (!string.IsNullOrEmpty(label))
+        {
+            var labelFontSize = Math.Max(7, height * 0.25);
+            var labelFormatted = new FormattedText(label, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+                new Typeface("Microsoft YaHei"), labelFontSize, labelBrush);
+            context.DrawText(labelFormatted, new Point(cx - labelFormatted.Width / 2, cy - height * 0.18));
+        }
+
+        // 数值
+        var valueFontSize = Math.Max(8, height * 0.3125);
+        var valueFormatted = new FormattedText($"{value:F1}", CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold), valueFontSize, colorBrush);
+        context.DrawText(valueFormatted, new Point(cx - valueFormatted.Width / 2, cy + height * 0.12));
+    }
+}

+ 96 - 0
src/YZWater.Avalonia/Controls/Renderers/PipeRenderer.cs

@@ -0,0 +1,96 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 管道渲染器 - 绘制双层管道(管壁 + 内管)+ 流动动画点。
+/// 抽取自 PumpStation2DControl,支持水平/垂直两种方向。
+/// </summary>
+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);
+
+    /// <summary>
+    /// 绘制水平管道。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="x1">起点 X</param>
+    /// <param name="y">Y 坐标</param>
+    /// <param name="x2">终点 X</param>
+    /// <param name="thickness">管道粗细</param>
+    /// <param name="isFlowing">是否有流动动画</param>
+    /// <param name="flowPhase">流动相位 0-360</param>
+    public static void DrawHorizontal(
+        DrawingContext context,
+        double x1, double y, double x2,
+        double thickness,
+        bool isFlowing,
+        double flowPhase)
+    {
+        // 管壁
+        context.DrawLine(new Pen(PipeWallBrush, thickness),
+            new Point(x1, y), new Point(x2, y));
+
+        // 内管
+        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
+            new Point(x1, y), new Point(x2, y));
+
+        // 流动动画
+        if (isFlowing && x2 > x1 + 2)
+            DrawFlowDots(context, x1, y, x2, y, flowPhase);
+    }
+
+    /// <summary>
+    /// 绘制垂直管道。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="x">X 坐标</param>
+    /// <param name="y1">起点 Y</param>
+    /// <param name="y2">终点 Y</param>
+    /// <param name="thickness">管道粗细</param>
+    /// <param name="isFlowing">是否有流动动画</param>
+    /// <param name="flowPhase">流动相位 0-360</param>
+    public static void DrawVertical(
+        DrawingContext context,
+        double x, double y1, double y2,
+        double thickness,
+        bool isFlowing,
+        double flowPhase)
+    {
+        context.DrawLine(new Pen(PipeWallBrush, thickness),
+            new Point(x, y1), new Point(x, y2));
+
+        context.DrawLine(new Pen(PipeInnerBrush, thickness * 0.45),
+            new Point(x, y1), new Point(x, y2));
+
+        if (isFlowing && Math.Abs(y2 - y1) > 2)
+            DrawFlowDots(context, x, y1, x, y2, flowPhase);
+    }
+
+    /// <summary>
+    /// 绘制沿管道方向的流动点。
+    /// </summary>
+    private static void DrawFlowDots(DrawingContext context,
+        double x1, double y1, double x2, double y2, double phase)
+    {
+        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));
+        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));
+        }
+    }
+}

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

@@ -0,0 +1,81 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 泵渲染器 - 绘制圆形泵体 + 旋转叶片 + 状态光晕。
+/// 抽取自 PumpStation2DControl,供多个控件复用。
+/// </summary>
+public static class PumpRenderer
+{
+    /// <summary>
+    /// 绘制一个泵。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="center">泵体中心(屏幕坐标)</param>
+    /// <param name="radius">泵体半径</param>
+    /// <param name="isRunning">是否运行中</param>
+    /// <param name="statusColor">状态颜色(运行=Success色, 停止=Disabled色)</param>
+    /// <param name="rotationAngle">当前叶片旋转角度(度)</param>
+    /// <param name="text">标签文字(如 P1)</param>
+    /// <param name="bgBrush">泵体背景画刷</param>
+    /// <param name="borderBrush">边框画刷</param>
+    /// <param name="textBrush">文字画刷</param>
+    /// <param name="bladeCount">叶片数量</param>
+    /// <param name="bladePenThickness">叶片画笔粗细</param>
+    public static void Draw(
+        DrawingContext context,
+        Point center,
+        double radius,
+        bool isRunning,
+        Color statusColor,
+        double rotationAngle,
+        string text,
+        IBrush bgBrush,
+        IBrush borderBrush,
+        IBrush textBrush,
+        int bladeCount = 4,
+        double bladePenThickness = 2.5)
+    {
+        var cx = center.X;
+        var cy = center.Y;
+        var statusBrush = new SolidColorBrush(statusColor);
+
+        // 运行光晕
+        if (isRunning)
+            context.DrawEllipse(new SolidColorBrush(statusColor, 0.18), null,
+                new Rect(cx - radius * 1.4, cy - radius * 1.4, radius * 2.8, radius * 2.8));
+
+        // 泵体圆
+        context.DrawEllipse(bgBrush, new Pen(statusBrush, isRunning ? 2 : 1.2),
+            new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
+
+        // 旋转叶片
+        for (int i = 0; i < bladeCount; i++)
+        {
+            var angle = (isRunning ? rotationAngle : 0) + i * (360.0 / bladeCount);
+            var rad = angle * Math.PI / 180.0;
+            var bx = cx + Math.Cos(rad) * radius * 0.65;
+            var by = cy + Math.Sin(rad) * radius * 0.65;
+            var bladeBrush = new SolidColorBrush(statusColor, isRunning ? 0.9 : 0.45);
+            context.DrawLine(new Pen(bladeBrush, bladePenThickness),
+                new Point(cx, cy), new Point(bx, by));
+        }
+
+        // 中心圆
+        context.DrawEllipse(statusBrush, null,
+            new Rect(cx - radius * 0.22, cy - radius * 0.22, radius * 0.44, radius * 0.44));
+
+        // 标签
+        if (!string.IsNullOrEmpty(text))
+        {
+            var fontSize = Math.Max(8, radius * 0.56);
+            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, cy + radius + 3));
+        }
+    }
+}

+ 97 - 0
src/YZWater.Avalonia/Controls/Renderers/TankRenderer.cs

@@ -0,0 +1,97 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 水箱渲染器 - 绘制矩形水箱 + 水位填充 + 波纹 + 百分比。
+/// 抽取自 PumpStation2DControl。
+/// </summary>
+public static class TankRenderer
+{
+    /// <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,
+        double waterLevel,
+        Color waterColor,
+        string name,
+        string code,
+        IBrush bgBrush,
+        IBrush borderBrush,
+        IBrush textPrimaryBrush,
+        IBrush textTertiaryBrush,
+        IBrush warningBrush,
+        IBrush dangerBrush)
+    {
+        var x = bounds.X;
+        var y = bounds.Y;
+        var w = bounds.Width;
+        var h = bounds.Height;
+
+        // 箱体
+        context.DrawRectangle(bgBrush, new Pen(borderBrush, 1.5), bounds, 3, 3);
+
+        // 水位填充
+        var clampedLevel = Math.Clamp(waterLevel, 0, 100);
+        if (clampedLevel > 0.5)
+        {
+            var waterH = (h - 4) * clampedLevel / 100.0;
+            var waterRect = new Rect(x + 2, y + h - 2 - waterH, w - 4, waterH);
+            var waterBrush = new SolidColorBrush(waterColor, 0.65);
+            context.DrawRectangle(waterBrush, null, waterRect);
+
+            // 水面波纹
+            var waveY = waterRect.Y;
+            var waveBrush = new SolidColorBrush(waterColor, 0.35);
+            for (int i = 0; i < 3; i++)
+            {
+                var wx = waterRect.X + 4 + i * (waterRect.Width / 3.0);
+                context.DrawLine(new Pen(waveBrush, 1),
+                    new Point(wx, waveY), new Point(wx + waterRect.Width / 6.0, waveY + 2));
+            }
+        }
+
+        var centerX = x + w / 2;
+        var centerY = y + h / 2;
+
+        // 名称标签
+        var nameFontSize = Math.Max(8, Math.Min(w, h) * 0.077);
+        DrawTextAt(context, centerX - 20, y + 6, name, nameFontSize, textPrimaryBrush, FontWeight.Bold);
+
+        var codeFontSize = Math.Max(8, Math.Min(w, h) * 0.063);
+        DrawTextAt(context, centerX - 14, y + 20, code, codeFontSize, textTertiaryBrush);
+
+        // 液位百分比
+        var pctText = $"{clampedLevel:F1}%";
+        IBrush pctColor = clampedLevel > 80 ? warningBrush :
+                          clampedLevel < 20 ? dangerBrush :
+                          new SolidColorBrush(waterColor);
+        var pctFontSize = Math.Max(8, Math.Min(w, h) * 0.092);
+        DrawTextAt(context, centerX - 18, centerY + 8, pctText, pctFontSize, pctColor, FontWeight.Bold);
+    }
+
+    private static void DrawTextAt(DrawingContext context, double x, double y, string text,
+        double size, IBrush brush, FontWeight weight = FontWeight.Normal)
+    {
+        var formatted = new FormattedText(text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Microsoft YaHei", FontStyle.Normal, weight), size, brush);
+        context.DrawText(formatted, new Point(x, y));
+    }
+}

+ 83 - 0
src/YZWater.Avalonia/Controls/Renderers/ValveRenderer.cs

@@ -0,0 +1,83 @@
+using Avalonia;
+using Avalonia.Media;
+using System;
+using System.Globalization;
+
+namespace YZWater.Avalonia.Controls.Renderers;
+
+/// <summary>
+/// 阀门渲染器 - 绘制蝴蝶阀符号(双三角 + 阀杆 + 状态指示灯)。
+/// 抽取自 PumpStation2DControl。
+/// </summary>
+public static class ValveRenderer
+{
+    /// <summary>
+    /// 绘制一个蝴蝶阀。
+    /// </summary>
+    /// <param name="context">绘图上下文</param>
+    /// <param name="center">阀门中心(屏幕坐标)</param>
+    /// <param name="size">阀门尺寸(三角形高度)</param>
+    /// <param name="valveColor">阀门状态对应颜色</param>
+    /// <param name="text">标签文字(如 V1)</param>
+    /// <param name="textBrush">文字画刷</param>
+    /// <param name="stemPenThickness">阀杆画笔粗细</param>
+    public static void Draw(
+        DrawingContext context,
+        Point center,
+        double size,
+        Color valveColor,
+        string text,
+        IBrush textBrush,
+        double stemPenThickness = 1.5)
+    {
+        var cx = center.X;
+        var cy = center.Y;
+        var colorBrush = new SolidColorBrush(valveColor);
+
+        // 蝴蝶阀:两个三角形
+        var top = new Point(cx, cy - size);
+        var bottom = new Point(cx, cy + size);
+        var left = new Point(cx - size * 0.7, cy);
+        var right = new Point(cx + size * 0.7, cy);
+
+        // 上三角
+        var tri1 = new StreamGeometry();
+        using (var ctx = tri1.Open())
+        {
+            ctx.BeginFigure(left, true);
+            ctx.LineTo(top);
+            ctx.LineTo(right);
+            ctx.EndFigure(true);
+        }
+        context.DrawGeometry(new SolidColorBrush(valveColor, 0.7), null, tri1);
+
+        // 下三角(倒置)
+        var tri2 = new StreamGeometry();
+        using (var ctx = tri2.Open())
+        {
+            ctx.BeginFigure(left, true);
+            ctx.LineTo(bottom);
+            ctx.LineTo(right);
+            ctx.EndFigure(true);
+        }
+        context.DrawGeometry(new SolidColorBrush(valveColor, 0.7), null, tri2);
+
+        // 阀杆
+        context.DrawLine(new Pen(colorBrush, stemPenThickness),
+            new Point(cx - size * 0.5, cy), new Point(cx + size * 0.5, cy));
+
+        // 状态指示点(灯)
+        var lampR = Math.Max(2, size * 0.25);
+        context.DrawEllipse(colorBrush, null,
+            new Rect(cx - lampR, cy - size - lampR * 2.5, lampR * 2, lampR * 2));
+
+        // 标签
+        if (!string.IsNullOrEmpty(text))
+        {
+            var fontSize = Math.Max(7, size * 0.75);
+            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, cy + size + 3));
+        }
+    }
+}

+ 6 - 5
src/YZWater.Avalonia/Controls/StatusCard.cs

@@ -27,6 +27,9 @@ public class StatusCard : Control
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ThemeHelper.ThemeChanged -= OnThemeChanged; base.OnDetachedFromVisualTree(e); }
     private void OnThemeChanged() => InvalidateVisual();
 
+    private static readonly Typeface s_monoTypeface = new("Consolas, monospace");
+    private static readonly Typeface s_labelTypeface = new("Microsoft YaHei");
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
@@ -34,8 +37,6 @@ public class StatusCard : Control
         var statusColor = IsActive ? (ActiveColor ?? ThemeHelper.Success) : (InactiveColor ?? ThemeHelper.TextDisabled);
         var statusBrush = statusColor as SolidColorBrush;
         var statusColorValue = statusBrush?.Color ?? Colors.Gray;
-        var monoTypeface = new Typeface("Consolas, monospace");
-        var labelTypeface = new Typeface("Microsoft YaHei");
 
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
         context.DrawRectangle(statusColor, null, new Rect(0, 0, 3, bounds.Height));
@@ -44,13 +45,13 @@ public class StatusCard : Control
         if (IsActive) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.3), null, new Rect(ledX - 3, ledY - 3, 16, 16));
         context.DrawEllipse(statusColor, null, new Rect(ledX, ledY, 10, 10));
 
-        var iconText = new FormattedText(Icon, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, monoTypeface, 11, ThemeHelper.TextPrimary);
+        var iconText = new FormattedText(Icon, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 11, ThemeHelper.TextPrimary);
         context.DrawText(iconText, new Point(28, 6));
 
-        var titleText = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, labelTypeface, 10, ThemeHelper.TextSecondary);
+        var titleText = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_labelTypeface, 10, ThemeHelper.TextSecondary);
         context.DrawText(titleText, new Point(28, 22));
 
-        var statusText = new FormattedText(Status, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, monoTypeface, 9, statusColor);
+        var statusText = new FormattedText(Status, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 9, statusColor);
         context.DrawText(statusText, new Point(bounds.Width - statusText.Width - 10, bounds.Height / 2 - statusText.Height / 2));
     }
 

+ 0 - 1
src/YZWater.Avalonia/Controls/ThemeHelper.cs

@@ -33,7 +33,6 @@ internal static class ThemeHelper
     /// </summary>
     public static void SetTheme(bool isDark)
     {
-        Console.WriteLine($"[ThemeHelper] SetTheme: isDark={isDark}, current HeaderBg={((SolidColorBrush)HeaderBg).Color}");
         if (isDark)
         {
             AppBg = new SolidColorBrush(Color.Parse("#08111B"));

+ 47 - 0
src/YZWater.Avalonia/Controls/ThemedUserControl.cs

@@ -0,0 +1,47 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace YZWater.Avalonia.Controls;
+
+/// <summary>
+/// 带主题切换支持的 UserControl 基类。
+/// 自动在 Loaded 时应用主题并订阅 ThemeHelper.ThemeChanged,
+/// 在 Unloaded 时取消订阅,消除子类中的重复代码。
+/// 约定:子视图 AXAML 中可包含名为 TitleBar、StatusBar、RootBorder 的 Border 元素,
+/// 基类会自动绑定对应的主题画刷(HeaderBg / NavBg / AppBg)。
+/// </summary>
+public class ThemedUserControl : UserControl
+{
+    /// <summary>
+    /// 主题变更回调,用于在派生类中响应主题切换
+    /// </summary>
+    protected virtual void OnThemeChanged()
+    {
+    }
+
+    protected ThemedUserControl()
+    {
+        Loaded += (_, _) =>
+        {
+            ApplyTheme();
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    /// <summary>
+    /// 根据约定名称查找 TitleBar / StatusBar / RootBorder 并设置主题画刷
+    /// </summary>
+    private void ApplyTheme()
+    {
+        if (this.FindControl<Border>("TitleBar") is Border titleBar)
+            titleBar.Background = ThemeHelper.HeaderBg;
+        if (this.FindControl<Border>("StatusBar") is Border statusBar)
+            statusBar.Background = ThemeHelper.NavBg;
+        if (this.FindControl<Border>("RootBorder") is Border root)
+            root.Background = ThemeHelper.AppBg;
+
+        // 允许派生类补充自定义的主题绑定逻辑
+        OnThemeChanged();
+    }
+}

+ 3 - 2
src/YZWater.Avalonia/Controls/ValveControl.cs

@@ -39,13 +39,14 @@ public class ValveControl : Control
 
     private void OnThemeChanged() => InvalidateVisual();
 
+    private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var size = Math.Min(bounds.Width, bounds.Height) * 0.8;
-        var typeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
 
         var bg = BackgroundColor ?? ThemeHelper.Border;
         context.DrawEllipse(bg, null, new Rect(cx - size / 2, cy - size / 2, size, size));
@@ -67,7 +68,7 @@ public class ValveControl : Control
 
         if (!string.IsNullOrEmpty(Text))
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 10, ThemeHelper.TextPrimary);
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, ThemeHelper.TextPrimary);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
         }
     }

+ 4 - 3
src/YZWater.Avalonia/Controls/WaterTankControl.cs

@@ -38,11 +38,12 @@ public class WaterTankControl : Control
 
     private void OnThemeChanged() => InvalidateVisual();
 
+    private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+
     public override void Render(DrawingContext context)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
-        var typeface = new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
         var edgeWidth = 3.0;
 
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
@@ -59,11 +60,11 @@ public class WaterTankControl : Control
 
         if (!string.IsNullOrEmpty(Text))
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 12, ThemeHelper.TextPrimary);
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, ThemeHelper.TextPrimary);
             context.DrawText(t, new Point(bounds.X + (bounds.Width - t.Width) / 2, bounds.Y + edgeWidth + 5));
         }
 
-        var lt = new FormattedText($"{WaterLevel:F1}%", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 14, ThemeHelper.TextPrimary);
+        var lt = new FormattedText($"{WaterLevel:F1}%", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 14, ThemeHelper.TextPrimary);
         var bg = new Rect(bounds.X + (bounds.Width - lt.Width - 10) / 2, bounds.Y + bounds.Height - edgeWidth - lt.Height - 10, lt.Width + 10, lt.Height + 6);
         context.DrawRectangle(new SolidColorBrush(Colors.Black, 0.5), null, bg);
         context.DrawText(lt, new Point(bg.X + 5, bg.Y + 3));

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

@@ -32,4 +32,16 @@ public static class BoolConverters
         new FuncValueConverter<bool, string>(active =>
             active ? YZWater.Core.Services.LanguageService.Instance.Get("Disable")
                    : YZWater.Core.Services.LanguageService.Instance.Get("Enable"));
+
+    /// <summary>
+    /// true → "ON", false → "OFF"
+    /// </summary>
+    public static readonly IValueConverter ToText =
+        new FuncValueConverter<bool, string>(active => active ? "ON" : "OFF");
+
+    /// <summary>
+    /// true → "中控", false → "屏控"
+    /// </summary>
+    public static readonly IValueConverter ToModeText =
+        new FuncValueConverter<bool, string>(active => active ? "中控" : "屏控");
 }

+ 67 - 7
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -1,4 +1,4 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:controls="using:YZWater.Avalonia.Controls"
@@ -128,6 +128,14 @@
                                                                 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>
+
                                                     <!-- 泵信息 -->
                                                     <Grid ColumnDefinitions="*,Auto">
                                                         <TextBlock Text="电流" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
@@ -141,11 +149,19 @@
                                                                    Foreground="{DynamicResource DangerBrush}"
                                                                    IsVisible="{Binding HasFault}" Text="故障!"/>
                                                     </Grid>
-                                                    <Grid ColumnDefinitions="*,Auto">
-                                                        <TextBlock Text="日台时" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                        <TextBlock Grid.Column="1" Text="{Binding DailyHours, StringFormat='{}{0:F0} h'}"
-                                                                   FontFamily="Consolas, monospace" FontSize="11"
-                                                                   Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                                    <Grid ColumnDefinitions="Auto,*,Auto">
+                                                        <TextBlock Text="日台时" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                        <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="11"
+                                                                   Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="4,0">
+                                                            <TextBlock.Text>
+                                                                <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
+                                                                    <Binding Path="DailyHours"/>
+                                                                    <Binding Path="DailyMinutes"/>
+                                                                </MultiBinding>
+                                                            </TextBlock.Text>
+                                                        </TextBlock>
+                                                        <Button Grid.Column="2" Content="清零" Command="{Binding DailyResetCommand}"
+                                                                Padding="4,1" FontSize="8" Classes="btn-warning"/>
                                                     </Grid>
 
                                                     <!-- 操作按钮 -->
@@ -222,6 +238,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">
@@ -237,6 +255,48 @@
                                     </DataTemplate>
                                 </ItemsControl.ItemTemplate>
                             </ItemsControl>
+
+                            <!-- 通用数字量输入(变频器故障等) -->
+                            <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 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>
                     </Border>
                 </Grid>
@@ -259,4 +319,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 18
src/YZWater.Avalonia/Views/ProjectView.axaml.cs

@@ -1,28 +1,11 @@
-using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ProjectView : UserControl
+public partial class ProjectView : ThemedUserControl
 {
     public ProjectView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 2 - 2
src/YZWater.Avalonia/Views/ViewAView.axaml

@@ -1,4 +1,4 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:controls="using:YZWater.Avalonia.Controls"
@@ -277,4 +277,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 21
src/YZWater.Avalonia/Views/ViewAView.axaml.cs

@@ -1,31 +1,11 @@
-using Avalonia.Controls;
-using Avalonia.Media;
-using Avalonia.VisualTree;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ViewAView : UserControl
+public partial class ViewAView : ThemedUserControl
 {
     public ViewAView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        // 直接通过名称查找元素
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 3 - 2
src/YZWater.Avalonia/Views/ViewBView.axaml

@@ -1,5 +1,6 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewBView"
              x:DataType="vm:ViewBViewModel">
@@ -132,4 +133,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 18
src/YZWater.Avalonia/Views/ViewBView.axaml.cs

@@ -1,28 +1,11 @@
-using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ViewBView : UserControl
+public partial class ViewBView : ThemedUserControl
 {
     public ViewBView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 3 - 2
src/YZWater.Avalonia/Views/ViewCView.axaml

@@ -1,5 +1,6 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
              x:Class="YZWater.Avalonia.Views.ViewCView"
@@ -117,4 +118,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 18
src/YZWater.Avalonia/Views/ViewCView.axaml.cs

@@ -1,28 +1,11 @@
-using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ViewCView : UserControl
+public partial class ViewCView : ThemedUserControl
 {
     public ViewCView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 3 - 2
src/YZWater.Avalonia/Views/ViewDView.axaml

@@ -1,5 +1,6 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewDView"
              x:DataType="vm:ViewDViewModel">
@@ -92,4 +93,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 18
src/YZWater.Avalonia/Views/ViewDView.axaml.cs

@@ -1,28 +1,11 @@
-using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ViewDView : UserControl
+public partial class ViewDView : ThemedUserControl
 {
     public ViewDView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 3 - 2
src/YZWater.Avalonia/Views/ViewEView.axaml

@@ -1,5 +1,6 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewEView"
              x:DataType="vm:ViewEViewModel">
@@ -94,4 +95,4 @@
             </Border>
         </Grid>
     </Border>
-</UserControl>
+</controls:ThemedUserControl>

+ 1 - 18
src/YZWater.Avalonia/Views/ViewEView.axaml.cs

@@ -1,28 +1,11 @@
-using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
-public partial class ViewEView : UserControl
+public partial class ViewEView : ThemedUserControl
 {
     public ViewEView()
     {
         InitializeComponent();
-        Loaded += (_, _) =>
-        {
-            ApplyTheme();
-            ThemeHelper.ThemeChanged += ApplyTheme;
-        };
-        Unloaded += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
-    }
-
-    private void ApplyTheme()
-    {
-        if (this.FindControl<Border>("TitleBar") is Border titleBar)
-            titleBar.Background = ThemeHelper.HeaderBg;
-        if (this.FindControl<Border>("StatusBar") is Border statusBar)
-            statusBar.Background = ThemeHelper.NavBg;
-        if (this.FindControl<Border>("RootBorder") is Border root)
-            root.Background = ThemeHelper.AppBg;
     }
 }

+ 19 - 13
src/YZWater.Core/Models/HubMessage.cs

@@ -12,7 +12,7 @@ public class HubMessage
     public string Type { get; set; } = string.Empty;
 
     [JsonPropertyName("payload")]
-    public JsonElement? Payload { get; set; }
+    public object? Payload { get; set; }
 
     private static readonly JsonSerializerOptions JsonOptions = new()
     {
@@ -27,7 +27,13 @@ public class HubMessage
     public T? GetPayload<T>()
     {
         if (Payload == null) return default;
-        return Payload.Value.Deserialize<T>(JsonOptions);
+        // 如果 Payload 已经是目标类型,直接返回
+        if (Payload is T typed) return typed;
+        // 反序列化时 Payload 可能是 JsonElement,需要转换
+        if (Payload is JsonElement element)
+            return element.Deserialize<T>(JsonOptions);
+        // 其他情况用 JsonSerializer 转换
+        return JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(Payload), JsonOptions);
     }
 
     // ─── 工厂方法 ───
@@ -35,61 +41,61 @@ public class HubMessage
     public static HubMessage Data(PlcDataModel data) => new()
     {
         Type = "data",
-        Payload = JsonSerializer.SerializeToElement(data, JsonOptions)
+        Payload = data
     };
 
     public static HubMessage Alarm(AlarmRecord alarm) => new()
     {
         Type = "alarm",
-        Payload = JsonSerializer.SerializeToElement(alarm, JsonOptions)
+        Payload = alarm
     };
 
     public static HubMessage LockResult(bool success, string? currentOperator = null) => new()
     {
         Type = "lock_result",
-        Payload = JsonSerializer.SerializeToElement(new LockResultPayload
+        Payload = new LockResultPayload
         {
             Success = success,
             CurrentOperator = currentOperator
-        }, JsonOptions)
+        }
     };
 
     public static HubMessage LockStatus(string? currentOperator, bool isLocked) => new()
     {
         Type = "lock_status",
-        Payload = JsonSerializer.SerializeToElement(new LockStatusPayload
+        Payload = new LockStatusPayload
         {
             CurrentOperator = currentOperator,
             IsLocked = isLocked
-        }, JsonOptions)
+        }
     };
 
     public static HubMessage CommandResult(bool success, string message) => new()
     {
         Type = "command_result",
-        Payload = JsonSerializer.SerializeToElement(new CommandResultPayload
+        Payload = new CommandResultPayload
         {
             Success = success,
             Message = message
-        }, JsonOptions)
+        }
     };
 
     public static HubMessage ClientCount(int count) => new()
     {
         Type = "client_count",
-        Payload = JsonSerializer.SerializeToElement(count)
+        Payload = count
     };
 
     public static HubMessage Auth(string token) => new()
     {
         Type = "auth",
-        Payload = JsonSerializer.SerializeToElement(token, JsonOptions)
+        Payload = token
     };
 
     public static HubMessage AuthResult(bool success) => new()
     {
         Type = "auth_result",
-        Payload = JsonSerializer.SerializeToElement(success)
+        Payload = success
     };
 }
 

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

@@ -111,6 +111,8 @@ 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))
@@ -151,6 +153,20 @@ public partial class PlcDataModel : ObservableObject
             if (!string.IsNullOrEmpty(sensor.Address))
                 AnalogRegisters[$"{sensor.Name}.Value"] = 0;
         }
+
+        // 通用数字量输入(如变频器故障)
+        foreach (var di in profile.DigitalInputs)
+        {
+            if (!string.IsNullOrEmpty(di.Address))
+                DigitalRegisters[$"DI.{di.Name}"] = false;
+        }
+
+        // 通用数字量输出(如复位按钮)
+        foreach (var do_ in profile.DigitalOutputs)
+        {
+            if (!string.IsNullOrEmpty(do_.Address))
+                DigitalRegisters[$"DO.{do_.Name}"] = false;
+        }
     }
 
     /// <summary>获取动态模拟量寄存器值</summary>

+ 7 - 0
src/YZWater.Core/Models/SystemConfig.cs

@@ -151,4 +151,11 @@ public partial class SystemConfig : ObservableObject
     /// 空则使用默认地址
     /// </summary>
     public Dictionary<string, string> PlcAddresses { get; set; } = new();
+
+    /// <summary>
+    /// PLC 协议类型:SiemensS7 / MitsubishiMC / OmronFins / ModbusTcp / AllenBradley
+    /// 默认 SiemensS7(当前项目使用 S7-200 Smart)
+    /// </summary>
+    [ObservableProperty]
+    private string _plcProtocol = "SiemensS7";
 }

+ 15 - 0
src/YZWater.Core/Services/AlarmService.cs

@@ -281,6 +281,21 @@ public partial class AlarmService : ObservableObject, IDisposable
         Log.Warning("报警触发: {Message} (级别 {Level})", message, level);
     }
 
+    /// <summary>
+    /// 外部检查自定义报警条件(由项目档案驱动)
+    /// </summary>
+    /// <param name="conditionId">唯一标识(如 "Pressure_High")</param>
+    /// <param name="isActive">当前条件是否满足</param>
+    /// <param name="message">报警消息</param>
+    /// <param name="level">报警级别(1=信息 2=警告 3=严重 4=紧急)</param>
+    public void CheckCustomCondition(string conditionId, bool isActive, string message, int level)
+    {
+        if (isActive)
+            CheckCondition(conditionId, message, level, DateTime.Now);
+        else
+            ClearCondition(conditionId);
+    }
+
     /// <summary>
     /// 检查报警升级(未确认超过 EscalationMinutes 的报警自动升级级别)
     /// </summary>

+ 51 - 41
src/YZWater.Core/Services/ConfigService.cs

@@ -6,28 +6,57 @@ namespace YZWater.Core.Services;
 
 /// <summary>
 /// 配置服务 - 管理应用程序配置
+/// 渐进式 DI 迁移:保留静态调用兼容,同时实现 IConfigService 接口
 /// </summary>
-public static class ConfigService
+public class ConfigService : IConfigService
 {
+    private static readonly Lazy<ConfigService> _instance = new(() => new ConfigService());
+
+    /// <summary>单例实例(供 ServiceLocator 和静态兼容方法使用)</summary>
+    public static ConfigService Instance => _instance.Value;
+
     private static readonly string ConfigFilePath = "yzwater-config.json";
-    private static SystemConfig? _config;
+    private SystemConfig? _config;
+
+    // ═══ 静态兼容方法(保持现有代码不变) ═══
+
+    /// <summary>获取配置(静态兼容)</summary>
+    public static SystemConfig? GetConfig() => Instance._GetConfig();
+
+    /// <summary>加载配置(静态兼容)</summary>
+    public static void LoadConfig() => Instance.Reload();
+
+    /// <summary>保存配置(静态兼容)</summary>
+    public static void SaveConfig() => Instance.Save();
 
-    /// <summary>
-    /// 获取配置
-    /// </summary>
-    public static SystemConfig? GetConfig()
+    /// <summary>更新配置(静态兼容)</summary>
+    public static void UpdateConfig(SystemConfig config) => Instance._UpdateConfig(config);
+
+    // ═══ IConfigService 实现 ═══
+
+    /// <summary>获取配置(实例方法,接口实现)</summary>
+    SystemConfig? IConfigService.GetConfig() => _GetConfig();
+
+    /// <summary>内部:获取配置</summary>
+    private SystemConfig? _GetConfig()
     {
         if (_config == null)
-        {
-            LoadConfig();
-        }
+            Reload();
         return _config;
     }
 
-    /// <summary>
-    /// 加载配置文件
-    /// </summary>
-    public static void LoadConfig()
+    /// <summary>更新配置并保存(实例方法,接口实现)</summary>
+    void IConfigService.UpdateConfig(SystemConfig config) => _UpdateConfig(config);
+
+    /// <summary>内部:更新配置</summary>
+    private void _UpdateConfig(SystemConfig config)
+    {
+        _config = config;
+        Save();
+    }
+
+    /// <summary>重新加载配置文件</summary>
+    public void Reload()
     {
         try
         {
@@ -39,9 +68,8 @@ public static class ConfigService
             }
             else
             {
-                // 创建默认配置(默认值由 SystemConfig 字段初始化器提供)
                 _config = new SystemConfig();
-                SaveConfig();
+                Save();
                 Log.Information("已创建默认配置文件");
             }
         }
@@ -52,19 +80,14 @@ public static class ConfigService
         }
     }
 
-    /// <summary>
-    /// 保存配置文件
-    /// </summary>
-    public static void SaveConfig()
+    /// <summary>保存配置到文件</summary>
+    public void Save()
     {
         try
         {
             if (_config == null) return;
 
-            var options = new JsonSerializerOptions
-            {
-                WriteIndented = true
-            };
+            var options = new JsonSerializerOptions { WriteIndented = true };
             var json = JsonSerializer.Serialize(_config, options);
             File.WriteAllText(ConfigFilePath, json);
             Log.Information("配置文件保存成功");
@@ -75,19 +98,8 @@ public static class ConfigService
         }
     }
 
-    /// <summary>
-    /// 更新配置
-    /// </summary>
-    public static void UpdateConfig(SystemConfig config)
-    {
-        _config = config;
-        SaveConfig();
-    }
-
-    /// <summary>
-    /// 导出配置到指定文件
-    /// </summary>
-    public static (bool Success, string Message) ExportConfig(string filePath)
+    /// <summary>导出配置到指定文件</summary>
+    public (bool Success, string Message) ExportConfig(string filePath)
     {
         try
         {
@@ -106,10 +118,8 @@ public static class ConfigService
         }
     }
 
-    /// <summary>
-    /// 从指定文件导入配置
-    /// </summary>
-    public static (bool Success, string Message) ImportConfig(string filePath)
+    /// <summary>从指定文件导入配置</summary>
+    public (bool Success, string Message) ImportConfig(string filePath)
     {
         try
         {
@@ -122,7 +132,7 @@ public static class ConfigService
                 return (false, "配置文件格式错误");
 
             _config = imported;
-            SaveConfig();
+            Save();
             Log.Information("配置已从 {Path} 导入", filePath);
             return (true, "配置导入成功");
         }

+ 2 - 2
src/YZWater.Core/Services/DataLoggingService.cs

@@ -164,8 +164,8 @@ public class DataLoggingService : IDisposable
                 if (flowTimer >= FlowLogIntervalSeconds)
                 {
                     flowTimer = 0;
-                    await LogFlowDataAsync();
-                    await LogAnalogDataAsync();
+                    // 并行写入(独立 DB 操作,互不依赖)
+                    await Task.WhenAll(LogFlowDataAsync(), LogAnalogDataAsync());
                 }
 
                 // 定期记录设备状态

+ 130 - 1
src/YZWater.Core/Services/DeviceStateMachine.cs

@@ -52,6 +52,16 @@ public class DeviceStateMachine
     /// </summary>
     public DeviceState CurrentState => _stateMachine.State;
 
+    /// <summary>
+    /// 设备 ID
+    /// </summary>
+    public string DeviceId => _deviceId;
+
+    /// <summary>
+    /// 设备名称
+    /// </summary>
+    public string DeviceName => _deviceName;
+
     /// <summary>
     /// 状态变更事件
     /// </summary>
@@ -310,10 +320,19 @@ public class DeviceStateMachineManager
     }
 
     /// <summary>
-    /// 初始化默认设备(5台泵 + 2台风机)
+    /// 初始化默认设备(5台泵 + 2台风机)— 向后兼容
     /// </summary>
     public void InitializeDefaultDevices()
     {
+        // 如果加载了项目档案,从档案读取设备列表
+        var profile = ProjectService.Current;
+        if (profile != null)
+        {
+            InitializeFromProfile(profile);
+            return;
+        }
+
+        // 否则使用默认配置
         for (int i = 1; i <= 5; i++)
         {
             GetOrCreate($"P{i:D3}", $"泵{i}");
@@ -324,4 +343,114 @@ public class DeviceStateMachineManager
         }
         Log.Information("已初始化 {Count} 台默认设备状态机", _machines.Count);
     }
+
+    /// <summary>
+    /// 从项目档案初始化设备列表
+    /// </summary>
+    public void InitializeFromProfile(ProjectProfile profile)
+    {
+        Clear();
+
+        foreach (var pump in profile.Pumps)
+        {
+            var sm = GetOrCreate($"Pump_{pump.Name}", pump.Name);
+            sm.StateEntered += OnDeviceStateEntered;
+        }
+
+        Log.Information("从项目档案初始化 {Count} 台设备状态机", _machines.Count);
+    }
+
+    /// <summary>
+    /// 设备进入状态时的回调(触发报警等)
+    /// </summary>
+    private void OnDeviceStateEntered(string deviceId, DeviceStateMachine.DeviceState state)
+    {
+        if (state == DeviceStateMachine.DeviceState.Fault)
+        {
+            AlarmService.Instance.CheckCustomCondition(
+                $"Device_{deviceId}_Fault", true,
+                $"设备 {deviceId} 发生故障", 3);
+        }
+        else
+        {
+            AlarmService.Instance.CheckCustomCondition($"Device_{deviceId}_Fault", false, "", 0);
+        }
+    }
+
+    /// <summary>
+    /// 订阅 PLC 数据更新,驱动状态转换
+    /// </summary>
+    public void SubscribeToPlcData()
+    {
+        PlcPollingService.Instance.DataUpdated += OnPlcDataUpdated;
+        Log.Information("设备状态机已订阅 PLC 数据更新");
+    }
+
+    /// <summary>
+    /// 取消订阅
+    /// </summary>
+    public void UnsubscribeFromPlcData()
+    {
+        PlcPollingService.Instance.DataUpdated -= OnPlcDataUpdated;
+    }
+
+    /// <summary>
+    /// PLC 数据更新回调 — 根据运行/故障信号驱动状态转换
+    /// </summary>
+    private void OnPlcDataUpdated(PlcDataModel data)
+    {
+        var profile = ProjectService.Current;
+
+        if (profile != null)
+        {
+            // 项目档案模式:从动态寄存器读取
+            foreach (var pump in profile.Pumps)
+            {
+                var sm = Get($"Pump_{pump.Name}");
+                if (sm == null) continue;
+
+                var running = data.GetDigitalRegister($"{pump.Name}.Run");
+                var fault = data.GetDigitalRegister($"{pump.Name}.Fault");
+
+                UpdateDeviceState(sm, running, fault);
+            }
+        }
+        else
+        {
+            // 默认模式:从硬编码属性读取
+            UpdateDefaultPumpState("P001", data.Pump1Running, data.Pump1Fault);
+            UpdateDefaultPumpState("P002", data.Pump2Running, data.Pump2Fault);
+            UpdateDefaultPumpState("P003", data.Pump3Running, data.Pump3Fault);
+            UpdateDefaultPumpState("P004", data.Pump4Running, data.Pump4Fault);
+            UpdateDefaultPumpState("P005", data.Pump5Running, data.Pump5Fault);
+            UpdateDefaultPumpState("F001", data.Fan1Running, data.Fan1Fault);
+            UpdateDefaultPumpState("F002", data.Fan2Running, data.Fan2Fault);
+        }
+    }
+
+    private void UpdateDefaultPumpState(string deviceId, bool running, bool fault)
+    {
+        var sm = Get(deviceId);
+        if (sm != null) UpdateDeviceState(sm, running, fault);
+    }
+
+    private static void UpdateDeviceState(DeviceStateMachine sm, bool running, bool fault)
+    {
+        if (fault && sm.CurrentState != DeviceStateMachine.DeviceState.Fault)
+        {
+            sm.TryFire(DeviceStateMachine.DeviceTrigger.Fault);
+        }
+        else if (!fault && running && sm.CurrentState == DeviceStateMachine.DeviceState.Stopped)
+        {
+            sm.TryFire(DeviceStateMachine.DeviceTrigger.Start);
+        }
+        else if (!fault && !running && sm.CurrentState == DeviceStateMachine.DeviceState.Running)
+        {
+            sm.TryFire(DeviceStateMachine.DeviceTrigger.Stop);
+        }
+        else if (!fault && sm.CurrentState == DeviceStateMachine.DeviceState.Fault)
+        {
+            sm.TryFire(DeviceStateMachine.DeviceTrigger.Recover);
+        }
+    }
 }

+ 2 - 2
src/YZWater.Core/Services/HubClient.cs

@@ -373,8 +373,8 @@ public class HubClient : IDisposable
                 break;
 
             case "client_count":
-                var count = message.Payload?.GetInt32();
-                if (count.HasValue) ClientCountChanged?.Invoke(count.Value);
+                var count = message.GetPayload<int>();
+                if (count != default) ClientCountChanged?.Invoke(count);
                 break;
         }
     }

+ 17 - 12
src/YZWater.Core/Services/HubServer.cs

@@ -266,7 +266,7 @@ public class HubServer : IDisposable
             var message = HubMessage.FromJson(json);
             if (message?.Type != "auth") return false;
 
-            var clientToken = message.Payload?.GetString();
+            var clientToken = message.GetPayload<string>();
             return clientToken == Token;
         }
         catch
@@ -404,17 +404,7 @@ public class HubServer : IDisposable
                 continue;
             }
 
-            sendTasks.Add(Task.Run(async () =>
-            {
-                try
-                {
-                    await ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
-                }
-                catch
-                {
-                    lock (deadClients) { deadClients.Add(id); }
-                }
-            }));
+            sendTasks.Add(SendToClientAsync(ws, segment, id, deadClients));
         }
 
         await Task.WhenAll(sendTasks);
@@ -435,6 +425,21 @@ public class HubServer : IDisposable
         await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
     }
 
+    /// <summary>
+    /// 发送已序列化的数据段给单个客户端(避免 Task.Run 占用 ThreadPool 线程)
+    /// </summary>
+    private static async Task SendToClientAsync(WebSocket ws, ArraySegment<byte> segment, string clientId, List<string> deadClients)
+    {
+        try
+        {
+            await ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
+        }
+        catch
+        {
+            lock (deadClients) { deadClients.Add(clientId); }
+        }
+    }
+
     // ─── 操作锁管理 ───
 
     private bool RequestLock(string operatorName, string clientId)

+ 14 - 12
src/YZWater.Core/Services/Interfaces/IConfigService.cs

@@ -1,3 +1,5 @@
+using YZWater.Core.Models;
+
 namespace YZWater.Core.Services;
 
 /// <summary>
@@ -5,21 +7,21 @@ namespace YZWater.Core.Services;
 /// </summary>
 public interface IConfigService
 {
-    /// <summary>获取配置值</summary>
-    T? GetValue<T>(string key);
-
-    /// <summary>设置配置值</summary>
-    void SetValue<T>(string key, T value);
+    /// <summary>获取系统配置</summary>
+    SystemConfig? GetConfig();
 
-    /// <summary>保存配置</summary>
-    Task SaveAsync();
+    /// <summary>更新配置并保存</summary>
+    void UpdateConfig(SystemConfig config);
 
-    /// <summary>重新加载配置</summary>
+    /// <summary>重新加载配置文件</summary>
     void Reload();
 
-    /// <summary>导出配置</summary>
-    Task ExportConfigAsync(string filePath);
+    /// <summary>保存配置到文件</summary>
+    void Save();
+
+    /// <summary>导出配置到指定文件</summary>
+    (bool Success, string Message) ExportConfig(string filePath);
 
-    /// <summary>导入配置</summary>
-    Task ImportConfigAsync(string filePath);
+    /// <summary>从指定文件导入配置</summary>
+    (bool Success, string Message) ImportConfig(string filePath);
 }

+ 0 - 77
src/YZWater.Core/Services/Jobs/PollingJobs.cs

@@ -4,83 +4,6 @@ using YZWater.Core.Models;
 
 namespace YZWater.Core.Services.Jobs;
 
-/// <summary>
-/// PLC 轮询任务 - 替代 PlcPollingService 中的手写轮询循环
-/// </summary>
-public class PlcPollingJob : IJob
-{
-    /// <summary>
-    /// Job Data Key: 轮询间隔(毫秒)
-    /// </summary>
-    public const string PollingIntervalKey = "PollingIntervalMs";
-
-    /// <summary>
-    /// Job Data Key: 最大重连次数
-    /// </summary>
-    public const string MaxReconnectKey = "MaxReconnectAttempts";
-
-    /// <summary>
-    /// Job Data Key: 重连基础延迟(毫秒)
-    /// </summary>
-    public const string ReconnectDelayKey = "ReconnectBaseDelayMs";
-
-    public async Task Execute(IJobExecutionContext context)
-    {
-        var dataMap = context.MergedJobDataMap;
-        var pollingService = PlcPollingService.Instance;
-
-        try
-        {
-            if (context.RefireCount > 0)
-            {
-                Log.Warning("PLC 轮询任务重入次数: {Count}", context.RefireCount);
-            }
-
-            // 执行一次轮询
-            await PollOnceAsync(pollingService);
-        }
-        catch (Exception ex)
-        {
-            Log.Error(ex, "PLC 轮询任务执行异常");
-
-            // 标记为恢复异常,Quartz 会根据策略重试
-            throw new JobExecutionException(ex, refireImmediately: false);
-        }
-    }
-
-    /// <summary>
-    /// 执行一次轮询
-    /// </summary>
-    private async Task PollOnceAsync(PlcPollingService pollingService)
-    {
-        if (MockPlcService.IsEnabled)
-        {
-            // Mock 模式:直接生成模拟数据
-            MockPlcService.UpdateData(pollingService.Data);
-        }
-        else
-        {
-            // 真实模式:确保连接并读取数据
-            if (!PlcService.IsConnected)
-            {
-                var connected = await PlcService.ConnectAsync();
-                if (!connected)
-                {
-                    pollingService.Data.IsPlcConnected = false;
-                    return;
-                }
-                pollingService.Data.IsPlcConnected = true;
-            }
-
-            // 读取数据(复用 PlcPollingService 的内部方法)
-            await pollingService.ReadDataInternal();
-        }
-
-        // 触发数据更新事件
-        pollingService.RaiseDataUpdated(pollingService.Data);
-    }
-}
-
 /// <summary>
 /// 数据日志任务 - 替代 DataLoggingService 中的手写日志循环
 /// </summary>

+ 29 - 0
src/YZWater.Core/Services/NotificationService.cs

@@ -165,10 +165,39 @@ public class NotificationService : INotificationService, IDisposable
                 }
             }
             _lastNotificationTimes[key] = now;
+
+            // 防止字典无限增长:超过 1000 条时清理超过 1 小时的旧条目
+            if (_lastNotificationTimes.Count > 1000)
+            {
+                CleanupOldEntries(now);
+            }
+
             return true;
         }
     }
 
+    /// <summary>
+    /// 清理超过 1 小时的旧条目,防止内存泄漏
+    /// </summary>
+    private void CleanupOldEntries(DateTime now)
+    {
+        var cutoff = now.AddHours(-1);
+        var keysToRemove = _lastNotificationTimes
+            .Where(kvp => kvp.Value < cutoff)
+            .Select(kvp => kvp.Key)
+            .ToList();
+
+        foreach (var key in keysToRemove)
+        {
+            _lastNotificationTimes.Remove(key);
+        }
+
+        if (keysToRemove.Count > 0)
+        {
+            Log.Debug("已清理 {Count} 条过期的通知限流记录", keysToRemove.Count);
+        }
+    }
+
     /// <summary>
     /// 发送到指定渠道
     /// </summary>

+ 12 - 10
src/YZWater.Core/Services/PlcPollingService.cs

@@ -415,11 +415,21 @@ 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));
+        }
+
+        // 通用数字量输入(如变频器故障)
+        foreach (var di in profile.DigitalInputs)
+        {
+            if (!string.IsNullOrEmpty(di.Address))
+                digitalReads.Add(($"DI.{di.Name}", di.Address));
         }
 
         // 并行发起所有读取(使用信号量限制并发数,避免 PLC 过载)
         // TODO: 性能优化 - 实现地址解析和分组批量读取
         // 当前每个地址独立读取,对于连续地址(如 VD100, VD104, VD108)可以合并为一次批量读取
+        // 立即物化 Task 数组,避免 IEnumerable 双重枚举导致 PLC 读取量翻倍
         var analogTasks = analogReads.Select(async r =>
         {
             await _plcReadSemaphore.WaitAsync(ct);
@@ -432,7 +442,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             {
                 _plcReadSemaphore.Release();
             }
-        });
+        }).ToArray();
         var digitalTasks = digitalReads.Select(async r =>
         {
             await _plcReadSemaphore.WaitAsync(ct);
@@ -445,7 +455,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             {
                 _plcReadSemaphore.Release();
             }
-        });
+        }).ToArray();
 
         var allTasks = analogTasks.Cast<Task>().Concat(digitalTasks.Cast<Task>()).ToArray();
         if (allTasks.Length > 0)
@@ -510,14 +520,6 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         DataUpdated?.Invoke(data);
     }
 
-    /// <summary>
-    /// 内部数据读取方法(供 Quartz Job 调用)
-    /// </summary>
-    public async Task ReadDataInternal()
-    {
-        await ReadAllDataAsync(CancellationToken.None);
-    }
-
     public void Dispose()
     {
         if (_disposed) return;

+ 16 - 3
src/YZWater.Core/Services/PlcService.cs

@@ -1,15 +1,20 @@
 using HslCommunication;
 using HslCommunication.Profinet.Siemens;
 using Serilog;
+using YZWater.Core.Services.PlcDrivers;
 
 namespace YZWater.Core.Services;
 
 /// <summary>
 /// PLC 通信服务 - 使用 HslCommunication
+/// 当前直接使用 SiemensS7Net(S7-200 Smart)。
+/// 多品牌支持:PlcDrivers/ 目录下已有完整驱动和工厂,
+/// 切换协议时修改 yzwater-config.json 的 PlcProtocol 并用 PlcDriverFactory 替换下方初始化。
 /// </summary>
 public static class PlcService
 {
     private static SiemensS7Net? _plc;
+    private static IPlcDriver? _driver;
     private static bool _isConnected;
 
     /// <summary>
@@ -18,10 +23,15 @@ public static class PlcService
     public static bool IsConnected => _isConnected;
 
     /// <summary>
-    /// PLC 实例
+    /// PLC 实例(向后兼容)
     /// </summary>
     public static SiemensS7Net Plc => _plc ?? throw new InvalidOperationException("PLC 未初始化,请先调用 Initialize()");
 
+    /// <summary>
+    /// PLC 驱动实例(新架构,供后续多品牌切换使用)
+    /// </summary>
+    public static IPlcDriver? Driver => _driver;
+
     /// <summary>
     /// 初始化 PLC 连接
     /// </summary>
@@ -29,17 +39,20 @@ public static class PlcService
     {
         try
         {
-            // 从配置读取 PLC IP,默认为 192.168.0.150
             var config = ConfigService.GetConfig();
             var plcIp = config?.PlcIp ?? "192.168.0.150";
+            var plcPort = config?.PlcPort ?? 102;
+            var protocol = config?.PlcProtocol ?? "SiemensS7";
 
+            // 当前默认使用 SiemensS7(S7-200 Smart)
+            // 切换到其他协议时,使用 PlcDriverFactory.Create() 创建对应驱动
             _plc = new SiemensS7Net(SiemensPLCS.S200Smart, plcIp)
             {
                 ConnectTimeOut = 3000,
                 ReceiveTimeOut = 3000
             };
 
-            Log.Information("PLC 服务初始化完成,IP: {Ip}", plcIp);
+            Log.Information("PLC 服务初始化完成,协议: {Protocol}, IP: {Ip}:{Port}", protocol, plcIp, plcPort);
         }
         catch (Exception ex)
         {

+ 2 - 20
src/YZWater.Core/Services/QuartzSchedulerService.cs

@@ -111,26 +111,8 @@ public class QuartzSchedulerService : IDisposable
     {
         if (_scheduler == null) return;
 
-        var config = ConfigService.GetConfig();
-
-        // ─── PLC 轮询任务 ───
-        var pollingJob = JobBuilder.Create<Jobs.PlcPollingJob>()
-            .WithIdentity("plcPollingJob", "plc")
-            .UsingJobData(Jobs.PlcPollingJob.PollingIntervalKey, config?.PlcPollingIntervalMs ?? 1000)
-            .UsingJobData(Jobs.PlcPollingJob.MaxReconnectKey, 10)
-            .UsingJobData(Jobs.PlcPollingJob.ReconnectDelayKey, 1000)
-            .Build();
-
-        var pollingTrigger = TriggerBuilder.Create()
-            .WithIdentity("plcPollingTrigger", "plc")
-            .StartNow()
-            .WithSimpleSchedule(x => x
-                .WithIntervalInSeconds(Math.Max(1, (config?.PlcPollingIntervalMs ?? 1000) / 1000))
-                .RepeatForever())
-            .Build();
-
-        await _scheduler.ScheduleJob(pollingJob, pollingTrigger);
-        Log.Information("已注册 PLC 轮询任务,间隔: {Interval}ms", config?.PlcPollingIntervalMs ?? 1000);
+        // 注意:PLC 轮询任务由 PlcPollingService 自己的 PollingLoopAsync 处理
+        // 不再使用 Quartz 调度 PlcPollingJob,避免重复轮询
 
         // ─── 数据日志任务(每秒执行,内部根据计数器决定实际写入频率) ───
         var loggingJob = JobBuilder.Create<Jobs.DataLoggingJob>()

+ 28 - 0
src/YZWater.Core/Services/ThemeColors.cs

@@ -0,0 +1,28 @@
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 主题颜色常量 - 提供与主题同步的十六进制颜色字符串
+/// 用于 ViewModel 中需要颜色字符串的场景
+/// </summary>
+public static class ThemeColors
+{
+    /// <summary>
+    /// 成功/运行状态颜色(深青色)
+    /// </summary>
+    public const string Success = "#00897B";
+
+    /// <summary>
+    /// 危险/故障状态颜色(红色)
+    /// </summary>
+    public const string Danger = "#EF4444";
+
+    /// <summary>
+    /// 禁用/停止状态颜色(灰色)
+    /// </summary>
+    public const string TextDisabled = "#4B5563";
+
+    /// <summary>
+    /// 警告状态颜色(琥珀色)
+    /// </summary>
+    public const string Warning = "#F59E0B";
+}

+ 121 - 15
src/YZWater.Core/Services/WorkflowService.cs

@@ -1,4 +1,5 @@
 using Serilog;
+using YZWater.Core.Models;
 
 namespace YZWater.Core.Services;
 
@@ -191,35 +192,140 @@ public class WorkflowService : IDisposable
         Log.Information("已注册自定义工作流: {Name}", name);
     }
 
-    // ─── 示例工作流动作 ───
+    // ─── 实际工作流动作(接入 PLC 数据) ───
 
+    /// <summary>
+    /// 检查液位并决定需要运行几台泵(项目档案模式)
+    /// </summary>
     private void CheckTankLevel(IDictionary<string, object>? context)
     {
-        var level = 0f;
-        if (context != null && context.TryGetValue("TankLevel", out var obj))
-            level = Convert.ToSingle(obj);
-        Log.Debug("    检查液位: {Level:F1}%", level);
-        if (level > 80) Log.Warning("    液位过高!");
-        else if (level < 20) Log.Warning("    液位过低!");
+        var profile = ProjectService.Current;
+        if (profile == null || profile.Tanks.Count == 0)
+        {
+            // 默认模式:使用硬编码液位
+            var level = PlcPollingService.Instance.Data.Tank1Level;
+            Log.Debug("    默认模式液位: {Level:F2} m", level);
+            if (context != null) context["RequiredPumpCount"] = GetRequiredPumpCount_Default(level);
+            return;
+        }
+
+        // 项目档案模式:从动态寄存器读取
+        var tank = profile.Tanks[0];
+        var data = PlcPollingService.Instance.Data;
+        var currentLevel = data.GetAnalogRegister($"{tank.Name}.Level");
+        var startPump1 = data.GetAnalogRegister($"{tank.Name}.StartPump1Level");
+        var startPump2 = data.GetAnalogRegister($"{tank.Name}.StartPump2Level");
+        var startPump3 = data.GetAnalogRegister($"{tank.Name}.StartPump3Level");
+        var stopLevel = data.GetAnalogRegister($"{tank.Name}.StopLevel");
+
+        int requiredCount;
+        if (currentLevel > stopLevel)
+            requiredCount = 0;
+        else if (currentLevel < startPump3)
+            requiredCount = 3;
+        else if (currentLevel < startPump2)
+            requiredCount = 2;
+        else if (currentLevel < startPump1)
+            requiredCount = 1;
+        else
+            requiredCount = 0;
+
+        Log.Debug("    液位 {Level:F2} m → 需要 {Count} 台泵", currentLevel, requiredCount);
+        if (context != null)
+        {
+            context["RequiredPumpCount"] = requiredCount;
+            context["CurrentLevel"] = currentLevel;
+        }
     }
 
+    private int GetRequiredPumpCount_Default(float level)
+    {
+        // 默认模式的简单液位控制
+        var config = ConfigService.GetConfig();
+        if (level > config?.LevelHighAlarm) return 3;
+        if (level > config?.LevelLowAlarm) return 1;
+        return 0;
+    }
+
+    /// <summary>
+    /// 检查流量
+    /// </summary>
     private void CheckFlowRate(IDictionary<string, object>? context)
     {
-        var rate = 0f;
-        if (context != null && context.TryGetValue("InflowRate", out var obj))
-            rate = Convert.ToSingle(obj);
-        Log.Debug("    检查流量: {Rate:F1} m³/h", rate);
-        if (rate > 100) Log.Warning("    流量过高!");
+        var data = PlcPollingService.Instance.Data;
+        var profile = ProjectService.Current;
+
+        float flowRate;
+        if (profile != null && profile.Gauges.Count > 0)
+        {
+            var flowGauge = profile.Gauges.FirstOrDefault(g => g.Name.Contains("流量"));
+            flowRate = flowGauge != null ? data.GetAnalogRegister($"{flowGauge.Name}.Value") : 0;
+        }
+        else
+        {
+            flowRate = data.InflowRate;
+        }
+
+        Log.Debug("    流量: {Rate:F1} m³/h", flowRate);
+        if (context != null) context["FlowRate"] = flowRate;
     }
 
-    private void ControlPump(IDictionary<string, object>? context)
+    /// <summary>
+    /// 根据液位控制结果启停泵
+    /// </summary>
+    private async void ControlPump(IDictionary<string, object>? context)
     {
-        Log.Debug("    控制泵启停");
+        var requiredCount = 0;
+        if (context != null && context.TryGetValue("RequiredPumpCount", out var obj))
+            requiredCount = Convert.ToInt32(obj);
+
+        var profile = ProjectService.Current;
+        if (profile == null)
+        {
+            Log.Debug("    默认模式: 需要 {Count} 台泵运行", requiredCount);
+            // 默认模式暂不自动控制(需要用户手动操作)
+            return;
+        }
+
+        // 项目档案模式:根据需要的泵数量启停
+        for (int i = 0; i < profile.Pumps.Count; i++)
+        {
+            var pump = profile.Pumps[i];
+            var shouldRun = i < requiredCount;
+            var isRunning = PlcPollingService.Instance.Data.GetDigitalRegister($"{pump.Name}.Run");
+
+            if (shouldRun && !isRunning)
+            {
+                Log.Information("    自动启泵: {Name}", pump.Name);
+                if (!string.IsNullOrEmpty(pump.StartAddress))
+                {
+                    await PlcService.WriteBoolAsync(pump.StartAddress, true);
+                    await Task.Delay(200);
+                    await PlcService.WriteBoolAsync(pump.StartAddress, false);
+                }
+            }
+            else if (!shouldRun && isRunning)
+            {
+                Log.Information("    自动停泵: {Name}", pump.Name);
+                if (!string.IsNullOrEmpty(pump.StopAddress))
+                {
+                    await PlcService.WriteBoolAsync(pump.StopAddress, true);
+                    await Task.Delay(200);
+                    await PlcService.WriteBoolAsync(pump.StopAddress, false);
+                }
+            }
+        }
     }
 
+    /// <summary>
+    /// 记录设备状态
+    /// </summary>
     private void LogStatus(IDictionary<string, object>? context)
     {
-        Log.Debug("    记录设备状态");
+        var data = PlcPollingService.Instance.Data;
+        var machines = DeviceStateMachineManager.Instance.GetAll();
+        Log.Debug("    设备状态: {States}",
+            string.Join(", ", machines.Select(m => $"{m.Value.DeviceName}={m.Value.CurrentState}")));
     }
 
     private void AcknowledgeAlarm(IDictionary<string, object>? context)

+ 26 - 31
src/YZWater.Core/ViewModels/LoginViewModel.cs

@@ -7,10 +7,8 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 登录页 ViewModel
 /// </summary>
-public partial class LoginViewModel : ObservableObject
+public partial class LoginViewModel : TranslatableViewModelBase
 {
-    private readonly LanguageService _lang = LanguageService.Instance;
-
     [ObservableProperty] private string _userName = string.Empty;
     [ObservableProperty] private string _password = string.Empty;
     [ObservableProperty] private string _errorMessage = string.Empty;
@@ -18,24 +16,35 @@ public partial class LoginViewModel : ObservableObject
     [ObservableProperty] private bool _isLoggingIn;
     [ObservableProperty] private bool _rememberUserName;
 
-    // 翻译文字
-    [ObservableProperty] private string _loginTitleText;
-    [ObservableProperty] private string _systemNameText;
-    [ObservableProperty] private string _loginPromptText;
-    [ObservableProperty] private string _userNameLabel;
-    [ObservableProperty] private string _passwordLabel;
-    [ObservableProperty] private string _inputUserNameText;
-    [ObservableProperty] private string _inputPasswordText;
-    [ObservableProperty] private string _rememberMeText;
-    [ObservableProperty] private string _loginButtonText;
-    [ObservableProperty] private string _defaultAccountText;
+    // ─── 翻译属性(getter-only,通过基类 T() 获取翻译) ───
+    public string LoginTitleText => T("LoginTitle");
+    public string SystemNameText => T("SystemName");
+    public string LoginPromptText => T("LoginPrompt");
+    public string UserNameLabel => T("UserName");
+    public string PasswordLabel => T("Password");
+    public string InputUserNameText => T("InputUserName");
+    public string InputPasswordText => T("InputPassword");
+    public string RememberMeText => T("RememberMe");
+    public string LoginButtonText => T("LoginButton");
+    public string DefaultAccountText => T("DefaultAccount");
 
     public event Action? LoginSucceeded;
 
     public LoginViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += UpdateTexts;
+        // 注册翻译映射
+        RegisterTranslation(nameof(LoginTitleText), "LoginTitle");
+        RegisterTranslation(nameof(SystemNameText), "SystemName");
+        RegisterTranslation(nameof(LoginPromptText), "LoginPrompt");
+        RegisterTranslation(nameof(UserNameLabel), "UserName");
+        RegisterTranslation(nameof(PasswordLabel), "Password");
+        RegisterTranslation(nameof(InputUserNameText), "InputUserName");
+        RegisterTranslation(nameof(InputPasswordText), "InputPassword");
+        RegisterTranslation(nameof(RememberMeText), "RememberMe");
+        RegisterTranslation(nameof(LoginButtonText), "LoginButton");
+        RegisterTranslation(nameof(DefaultAccountText), "DefaultAccount");
+
+        SubscribeLanguageChanged();
 
         var config = ConfigService.GetConfig();
         if (config != null)
@@ -48,26 +57,12 @@ public partial class LoginViewModel : ObservableObject
         }
     }
 
-    private void UpdateTexts()
-    {
-        LoginTitleText = _lang.Get("LoginTitle");
-        SystemNameText = _lang.Get("SystemName");
-        LoginPromptText = _lang.Get("LoginPrompt");
-        UserNameLabel = _lang.Get("UserName");
-        PasswordLabel = _lang.Get("Password");
-        InputUserNameText = _lang.Get("InputUserName");
-        InputPasswordText = _lang.Get("InputPassword");
-        RememberMeText = _lang.Get("RememberMe");
-        LoginButtonText = _lang.Get("LoginButton");
-        DefaultAccountText = _lang.Get("DefaultAccount");
-    }
-
     [RelayCommand]
     private async Task LoginAsync()
     {
         if (string.IsNullOrWhiteSpace(UserName) || string.IsNullOrWhiteSpace(Password))
         {
-            ErrorMessage = _lang.Get("InputUserName") + " / " + _lang.Get("InputPassword");
+            ErrorMessage = T("InputUserName") + " / " + T("InputPassword");
             HasError = true;
             return;
         }

+ 92 - 76
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -10,7 +10,7 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 主窗口 ViewModel
 /// </summary>
-public partial class MainViewModel : ObservableObject, IDisposable
+public partial class MainViewModel : TranslatableViewModelBase
 {
     private readonly ViewAViewModel _viewAViewModel;
     private readonly ViewBViewModel _viewBViewModel;
@@ -21,7 +21,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
     private ProjectViewModel? _projectViewModel;
 
     private readonly ThemeService _themeService = ThemeService.Instance;
-    private readonly LanguageService _langService = LanguageService.Instance;
     private readonly SynchronizationContext? _uiContext = SynchronizationContext.Current;
     private System.Timers.Timer? _clockTimer;
     private bool _disposed;
@@ -29,8 +28,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
     [ObservableProperty]
     private ObservableObject _currentView;
 
-    [ObservableProperty]
-    private string _title;
+    // 标题和 Tab 标题(非纯翻译,需要动态更新)
+    [ObservableProperty] private string _title = "";
+    [ObservableProperty] private string _tabTitle = "";
 
     [ObservableProperty]
     private string _currentTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
@@ -49,38 +49,33 @@ public partial class MainViewModel : ObservableObject, IDisposable
     [ObservableProperty] private bool _isTabEActive;
     [ObservableProperty] private bool _isTabFActive;
 
-    // 当前 Tab 标题
-    [ObservableProperty] private string _tabTitle;
-
     // 主题/语言状态
     [ObservableProperty] private bool _isDarkTheme = true;
     [ObservableProperty] private bool _isChinese = true;
     [ObservableProperty] private string _themeIcon = "🌙";
     [ObservableProperty] private string _langIcon = "中";
 
-    // 导航标签
-    [ObservableProperty] private string _navProcess;
-    [ObservableProperty] private string _navParams;
-    [ObservableProperty] private string _navFlow;
-    [ObservableProperty] private string _navAlarm;
-    [ObservableProperty] private string _navAbout;
-    [ObservableProperty] private string _navAudit;
-
-    // ═══ 用户管理翻译 ═══
-    [ObservableProperty] private string _userManageText;
-    [ObservableProperty] private string _createNewUserText;
-    [ObservableProperty] private string _userNameLabel;
-    [ObservableProperty] private string _passwordLabel;
-    [ObservableProperty] private string _displayNameLabel;
-    [ObservableProperty] private string _roleLabel;
-    [ObservableProperty] private string _statusLabel;
-    [ObservableProperty] private string _lastLoginLabel;
-    [ObservableProperty] private string _actionLabel;
-    [ObservableProperty] private string _createText;
-    [ObservableProperty] private string _closeText;
-    [ObservableProperty] private string _logoutText;
-
-    // ═══ 运行模式与连接状态 ═══
+    // ═══ 纯翻译属性(getter-only) ═══
+    public string NavProcess => T("NavProcess");
+    public string NavParams => T("NavParams");
+    public string NavFlow => T("NavFlow");
+    public string NavAlarm => T("NavAlarm");
+    public string NavAbout => T("NavAbout");
+    public string NavAudit => T("AuditLog");
+    public string UserManageText => T("UserManage");
+    public string CreateNewUserText => T("CreateNewUser");
+    public string UserNameLabel => T("UserName");
+    public string PasswordLabel => T("Password");
+    public string DisplayNameLabel => T("DisplayName");
+    public string RoleLabel => T("Role");
+    public string StatusLabel => T("Status");
+    public string LastLoginLabel => T("LastLogin");
+    public string ActionLabel => T("Action");
+    public string CreateText => T("Create");
+    public string CloseText => T("Close");
+    public string LogoutText => T("Logout");
+
+    // ═══ 运行模式与连接状态(非纯翻译,需动态更新) ═══
     [ObservableProperty] private string _runMode = "Direct";
     [ObservableProperty] private string _hubStatusText = "";
     [ObservableProperty] private string _plcStatusText = "";
@@ -119,12 +114,32 @@ public partial class MainViewModel : ObservableObject, IDisposable
 
         _currentView = _viewAViewModel;
 
-        UpdateAllTexts();
+        // 注册翻译映射
+        RegisterTranslation(nameof(NavProcess), "NavProcess");
+        RegisterTranslation(nameof(NavParams), "NavParams");
+        RegisterTranslation(nameof(NavFlow), "NavFlow");
+        RegisterTranslation(nameof(NavAlarm), "NavAlarm");
+        RegisterTranslation(nameof(NavAbout), "NavAbout");
+        RegisterTranslation(nameof(NavAudit), "AuditLog");
+        RegisterTranslation(nameof(UserManageText), "UserManage");
+        RegisterTranslation(nameof(CreateNewUserText), "CreateNewUser");
+        RegisterTranslation(nameof(UserNameLabel), "UserName");
+        RegisterTranslation(nameof(PasswordLabel), "Password");
+        RegisterTranslation(nameof(DisplayNameLabel), "DisplayName");
+        RegisterTranslation(nameof(RoleLabel), "Role");
+        RegisterTranslation(nameof(StatusLabel), "Status");
+        RegisterTranslation(nameof(LastLoginLabel), "LastLogin");
+        RegisterTranslation(nameof(ActionLabel), "Action");
+        RegisterTranslation(nameof(CreateText), "Create");
+        RegisterTranslation(nameof(CloseText), "Close");
+        RegisterTranslation(nameof(LogoutText), "Logout");
+
+        SubscribeLanguageChanged();
+
+        UpdateDynamicTexts();
         RefreshUserInfo();
         LoadRunMode();
 
-        _langService.LanguageChanged += OnLanguageChanged;
-
         _themeService.PropertyChanged += OnThemeServicePropertyChanged;
 
         // PLC 连接状态监听
@@ -151,12 +166,19 @@ public partial class MainViewModel : ObservableObject, IDisposable
         }
     }
 
-    private void OnLanguageChanged()
+    /// <summary>
+    /// 语言变更时,除了基类自动刷新的纯翻译属性外,
+    /// 还需刷新动态组合文本和用户信息。
+    /// </summary>
+    protected override void OnLanguageChanged()
     {
         RunOnUi(() =>
         {
-            IsChinese = _langService.IsChinese;
-            UpdateAllTexts();
+            // 基类自动刷新所有已注册的纯翻译属性
+            RefreshTranslations();
+
+            // 刷新动态组合文本
+            UpdateDynamicTexts();
             RefreshUserInfo();
         });
     }
@@ -176,7 +198,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
     {
         RunOnUi(() =>
         {
-            PlcStatusText = connected ? _langService.Get("PlcConnectedStatus") : _langService.Get("PlcDisconnected");
+            PlcStatusText = connected ? T("PlcConnectedStatus") : T("PlcDisconnected");
             PlcStatusColor = connected ? "#27AE60" : "#E74C3C";
             IsPlcConnected = connected;
         });
@@ -195,32 +217,18 @@ public partial class MainViewModel : ObservableObject, IDisposable
             action();
     }
 
-    private void UpdateAllTexts()
+    /// <summary>
+    /// 更新非纯翻译的动态文本(Title、TabTitle、HubStatusText、IsChinese 等)
+    /// </summary>
+    private void UpdateDynamicTexts()
     {
-        Title = _langService.Get("AppName");
-        TabTitle = _langService.Get("ProcessFlow");
-        NavProcess = _langService.Get("NavProcess");
-        NavParams = _langService.Get("NavParams");
-        NavFlow = _langService.Get("NavFlow");
-        NavAlarm = _langService.Get("NavAlarm");
-        NavAbout = _langService.Get("NavAbout");
-        NavAudit = _langService.Get("AuditLog");
-        UserManageText = _langService.Get("UserManage");
-        CreateNewUserText = _langService.Get("CreateNewUser");
-        UserNameLabel = _langService.Get("UserName");
-        PasswordLabel = _langService.Get("Password");
-        DisplayNameLabel = _langService.Get("DisplayName");
-        RoleLabel = _langService.Get("Role");
-        StatusLabel = _langService.Get("Status");
-        LastLoginLabel = _langService.Get("LastLogin");
-        ActionLabel = _langService.Get("Action");
-        CreateText = _langService.Get("Create");
-        CloseText = _langService.Get("Close");
-        LogoutText = _langService.Get("Logout");
-        IsChinese = _langService.IsChinese;
+        Title = T("AppName");
+        TabTitle = T("ProcessFlow");
+        IsChinese = Lang.IsChinese;
         LangIcon = IsChinese ? "中" : "EN";
         IsDarkTheme = _themeService.IsDarkTheme;
         ThemeIcon = IsDarkTheme ? "🌙" : "☀️";
+        LoadRunMode();
     }
 
     /// <summary>
@@ -232,10 +240,10 @@ public partial class MainViewModel : ObservableObject, IDisposable
         RunMode = config?.RunMode ?? "Direct";
         HubStatusText = RunMode switch
         {
-            "Server" => $"{_langService.Get("ServerMode")} :{config?.HubPort ?? 8765}",
-            "Client" => $"{_langService.Get("ClientMode")} → {config?.HubServerUrl ?? "ws://localhost:8765"}",
-            "Mock" => _langService.Get("MockMode"),
-            _ => _langService.Get("DirectMode")
+            "Server" => $"{T("ServerMode")} :{config?.HubPort ?? 8765}",
+            "Client" => $"{T("ClientMode")} → {config?.HubServerUrl ?? "ws://localhost:8765"}",
+            "Mock" => T("MockMode"),
+            _ => T("DirectMode")
         };
     }
 
@@ -248,7 +256,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
     [RelayCommand]
     private void ToggleLanguage()
     {
-        _langService.ToggleLanguage();
+        Lang.ToggleLanguage();
     }
 
     [RelayCommand]
@@ -271,14 +279,22 @@ public partial class MainViewModel : ObservableObject, IDisposable
         IsTabDActive = index == 3;
         IsTabEActive = index == 4;
         IsTabFActive = index == 5;
-        TabTitle = _langService.Get(_tabTitleKeys[index]);
+        TabTitle = T(_tabTitleKeys[index]);
     }
 
     [RelayCommand] private void ShowViewA()
     {
-        CurrentView = HasProject && _projectViewModel != null
-            ? _projectViewModel
-            : _viewAViewModel;
+        if (HasProject && _projectViewModel != null)
+        {
+            // 根据 ViewTemplate 选择布局
+            // "PumpStation" → 未来使用 PumpStation2D/3D 动态布局(待改造)
+            // "Default" 或 其他 → 使用 ProjectView 通用动态布局
+            CurrentView = _projectViewModel;
+        }
+        else
+        {
+            CurrentView = _viewAViewModel;
+        }
         SetActiveTab(0);
     }
     [RelayCommand] private void ShowViewB() { CurrentView = _viewBViewModel; SetActiveTab(1); }
@@ -313,7 +329,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
             // 管理员显示名跟随语言更新
             if (user.UserName == "admin")
             {
-                var adminDisplayName = _langService.Get("AdminName");
+                var adminDisplayName = T("AdminName");
                 if (user.DisplayName != adminDisplayName)
                 {
                     user.DisplayName = adminDisplayName;
@@ -324,10 +340,10 @@ public partial class MainViewModel : ObservableObject, IDisposable
             CurrentUserName = user.DisplayName;
             CurrentUserRole = user.Role switch
             {
-                UserRole.Viewer => _langService.Get("Viewer"),
-                UserRole.Operator => _langService.Get("OperatorRole"),
-                UserRole.Engineer => _langService.Get("Engineer"),
-                UserRole.Admin => _langService.Get("AdminRole"),
+                UserRole.Viewer => T("Viewer"),
+                UserRole.Operator => T("OperatorRole"),
+                UserRole.Engineer => T("Engineer"),
+                UserRole.Admin => T("AdminRole"),
                 _ => "Unknown"
             };
             CanControl = AuthService.HasPermission(UserRole.Operator);
@@ -370,7 +386,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
     {
         if (string.IsNullOrWhiteSpace(NewUserName) || string.IsNullOrWhiteSpace(NewUserPassword))
         {
-            UserManageMessage = _langService.Get("UserNamePasswordRequired");
+            UserManageMessage = T("UserNamePasswordRequired");
             return;
         }
 
@@ -395,12 +411,11 @@ public partial class MainViewModel : ObservableObject, IDisposable
         LoadUsers();
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
         StopTimer();
-        _langService.LanguageChanged -= OnLanguageChanged;
         _themeService.PropertyChanged -= OnThemeServicePropertyChanged;
         PlcPollingService.Instance.ConnectionStateChanged -= OnConnectionStateChanged;
         _viewAViewModel.Dispose();
@@ -410,5 +425,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
         _viewEViewModel.Dispose();
         _viewFViewModel.Dispose();
         _projectViewModel?.Dispose();
+        base.Dispose();
     }
 }

+ 173 - 0
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -27,6 +27,8 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     public ObservableCollection<TankItemViewModel> Tanks { get; } = new();
     public ObservableCollection<GaugeItemViewModel> Gauges { get; } = new();
     public ObservableCollection<SensorItemViewModel> Sensors { get; } = new();
+    public ObservableCollection<DigitalInputItemViewModel> DigitalInputs { get; } = new();
+    public ObservableCollection<DigitalOutputItemViewModel> DigitalOutputs { get; } = new();
 
     public ProjectViewModel(ProjectProfile profile)
     {
@@ -49,6 +51,12 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         foreach (var sensor in profile.Sensors)
             Sensors.Add(new SensorItemViewModel(sensor));
 
+        foreach (var di in profile.DigitalInputs)
+            DigitalInputs.Add(new DigitalInputItemViewModel(di));
+
+        foreach (var doItem in profile.DigitalOutputs)
+            DigitalOutputs.Add(new DigitalOutputItemViewModel(doItem));
+
         // 初始化动态寄存器
         _pollingService.Data.InitializeDynamicRegisters(profile);
 
@@ -74,6 +82,90 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
         // 更新传感器数据
         foreach (var sensor in Sensors)
             sensor.UpdateFromData(data);
+
+        // 更新数字量输入
+        foreach (var di in DigitalInputs)
+            di.UpdateFromData(data);
+
+        // 更新数字量输出状态
+        foreach (var doItem in DigitalOutputs)
+            doItem.UpdateFromData(data);
+
+        // 检查报警阈值
+        CheckAlarmThresholds(data);
+    }
+
+    /// <summary>
+    /// 检查项目档案中定义的报警阈值
+    /// </summary>
+    private void CheckAlarmThresholds(PlcDataModel data)
+    {
+        var thresholds = _profile.AlarmThresholds;
+        if (thresholds == null) return;
+
+        // 压力报警
+        foreach (var gauge in Gauges)
+        {
+            if (thresholds.PressureHigh.HasValue && gauge.Value > thresholds.PressureHigh.Value)
+            {
+                AlarmService.Instance.CheckCustomCondition(
+                    $"Pressure_{gauge.Name}_High", true,
+                    $"{gauge.Name}过高: {gauge.Value:F2} {gauge.Unit} (阈值 {thresholds.PressureHigh.Value:F2})", 3);
+            }
+            else
+            {
+                AlarmService.Instance.CheckCustomCondition($"Pressure_{gauge.Name}_High", false, "", 0);
+            }
+
+            if (thresholds.PressureLow.HasValue && gauge.Value < thresholds.PressureLow.Value)
+            {
+                AlarmService.Instance.CheckCustomCondition(
+                    $"Pressure_{gauge.Name}_Low", true,
+                    $"{gauge.Name}过低: {gauge.Value:F2} {gauge.Unit} (阈值 {thresholds.PressureLow.Value:F2})", 3);
+            }
+            else
+            {
+                AlarmService.Instance.CheckCustomCondition($"Pressure_{gauge.Name}_Low", false, "", 0);
+            }
+        }
+
+        // 液位报警
+        foreach (var tank in Tanks)
+        {
+            if (thresholds.LevelHigh.HasValue && tank.Level > thresholds.LevelHigh.Value)
+            {
+                AlarmService.Instance.CheckCustomCondition(
+                    $"Level_{tank.Name}_High", true,
+                    $"{tank.Name}液位过高: {tank.Level:F2} m (阈值 {thresholds.LevelHigh.Value:F2})", 3);
+            }
+            else
+            {
+                AlarmService.Instance.CheckCustomCondition($"Level_{tank.Name}_High", false, "", 0);
+            }
+
+            if (thresholds.LevelLow.HasValue && tank.Level < thresholds.LevelLow.Value)
+            {
+                AlarmService.Instance.CheckCustomCondition(
+                    $"Level_{tank.Name}_Low", true,
+                    $"{tank.Name}液位过低: {tank.Level:F2} m (阈值 {thresholds.LevelLow.Value:F2})", 3);
+            }
+            else
+            {
+                AlarmService.Instance.CheckCustomCondition($"Level_{tank.Name}_Low", false, "", 0);
+            }
+
+            // 保护液位(紧急)
+            if (tank.ProtectionLevel > 0 && tank.Level > tank.ProtectionLevel)
+            {
+                AlarmService.Instance.CheckCustomCondition(
+                    $"Level_{tank.Name}_Protection", true,
+                    $"{tank.Name}超过保护液位: {tank.Level:F2} m (保护值 {tank.ProtectionLevel:F2})", 4);
+            }
+            else
+            {
+                AlarmService.Instance.CheckCustomCondition($"Level_{tank.Name}_Protection", false, "", 0);
+            }
+        }
     }
 
     private void OnConnectionStateChanged(bool connected)
@@ -104,6 +196,7 @@ public partial class PumpItemViewModel : ObservableObject
     [ObservableProperty] private bool _hasFault;
     [ObservableProperty] private float _current;
     [ObservableProperty] private bool _isAutoMode;
+    [ObservableProperty] private bool _isCentralMode;
     [ObservableProperty] private float _dailyHours;
     [ObservableProperty] private float _dailyMinutes;
 
@@ -118,6 +211,7 @@ public partial class PumpItemViewModel : ObservableObject
         HasFault = data.GetDigitalRegister($"{Name}.Fault");
         Current = data.GetAnalogRegister($"{Name}.Current");
         IsAutoMode = data.GetDigitalRegister($"{Name}.AutoMode");
+        IsCentralMode = data.GetDigitalRegister($"{Name}.ModeSelect");
         DailyHours = data.GetAnalogRegister($"{Name}.DailyHours");
         DailyMinutes = data.GetAnalogRegister($"{Name}.DailyMinutes");
     }
@@ -143,6 +237,17 @@ public partial class PumpItemViewModel : ObservableObject
         await Task.Delay(200);
         await PlcService.WriteBoolAsync(_config.StopAddress, false);
     }
+
+    /// <summary>日台时清零</summary>
+    [RelayCommand]
+    private async Task DailyResetAsync()
+    {
+        if (string.IsNullOrEmpty(_config.DailyResetAddress)) return;
+        Log.Information("泵日台时清零: {Name} → {Addr}", Name, _config.DailyResetAddress);
+        await PlcService.WriteBoolAsync(_config.DailyResetAddress, true);
+        await Task.Delay(200);
+        await PlcService.WriteBoolAsync(_config.DailyResetAddress, false);
+    }
 }
 
 /// <summary>
@@ -228,3 +333,71 @@ public partial class SensorItemViewModel : ObservableObject
         Value = data.GetAnalogRegister($"{Name}.Value");
     }
 }
+
+/// <summary>
+/// 数字量输入 ViewModel(变频器故障等)
+/// </summary>
+public partial class DigitalInputItemViewModel : ObservableObject
+{
+    private readonly DigitalInputConfig _config;
+
+    public string Name => _config.Name;
+    public string Address => _config.Address;
+    public string Remark => _config.Remark;
+
+    [ObservableProperty] private bool _isActive;
+
+    public DigitalInputItemViewModel(DigitalInputConfig config)
+    {
+        _config = config;
+    }
+
+    public void UpdateFromData(PlcDataModel data)
+    {
+        IsActive = data.GetDigitalRegister($"DI.{Name}");
+    }
+}
+
+/// <summary>
+/// 数字量输出 ViewModel(复位按钮等)
+/// </summary>
+public partial class DigitalOutputItemViewModel : ObservableObject
+{
+    private readonly DigitalOutputConfig _config;
+
+    public string Name => _config.Name;
+    public string Address => _config.Address;
+    public string Remark => _config.Remark;
+
+    [ObservableProperty] private bool _isActive;
+    [ObservableProperty] private bool _isPulsing;
+
+    public DigitalOutputItemViewModel(DigitalOutputConfig config)
+    {
+        _config = config;
+    }
+
+    public void UpdateFromData(PlcDataModel data)
+    {
+        IsActive = data.GetDigitalRegister($"DO.{Name}");
+    }
+
+    /// <summary>点动写入(短暂置位后复位)</summary>
+    [RelayCommand]
+    private async Task PulseAsync()
+    {
+        if (string.IsNullOrEmpty(Address) || IsPulsing) return;
+        IsPulsing = true;
+        try
+        {
+            Log.Information("DO 点动: {Name} → {Addr}", Name, Address);
+            await PlcService.WriteBoolAsync(Address, true);
+            await Task.Delay(200);
+            await PlcService.WriteBoolAsync(Address, false);
+        }
+        finally
+        {
+            IsPulsing = false;
+        }
+    }
+}

+ 91 - 0
src/YZWater.Core/ViewModels/TranslatableViewModelBase.cs

@@ -0,0 +1,91 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.ViewModels;
+
+/// <summary>
+/// 支持自动翻译的 ViewModel 基类。
+/// <para>
+/// 子类通过 <see cref="RegisterTranslation"/> 注册 "属性名 → 翻译键" 的映射,
+/// 基类自动订阅 <see cref="LanguageService.LanguageChanged"/> 事件,
+/// 语言切换时对所有已注册属性触发 <see cref="ObservableObject.OnPropertyChanged"/>,
+/// 从而刷新 AXAML 绑定。
+/// </para>
+/// <para>
+/// 子类只需定义 getter-only 属性,返回 <c>T("key")</c> 即可:
+/// <code>public string TitleText => T("Title");</code>
+/// </para>
+/// </summary>
+public abstract class TranslatableViewModelBase : ObservableObject, IDisposable
+{
+    private readonly LanguageService _lang = LanguageService.Instance;
+
+    /// <summary>属性名 → 翻译键 的映射</summary>
+    private readonly Dictionary<string, string> _translationKeys = new();
+
+    private bool _disposed;
+
+    /// <summary>语言服务实例(供子类直接访问)</summary>
+    protected LanguageService Lang => _lang;
+
+    /// <summary>
+    /// 注册一个翻译属性映射。
+    /// </summary>
+    /// <param name="propertyName">ViewModel 属性名(AXAML 绑定使用的名称)</param>
+    /// <param name="translationKey">LanguageService 中的翻译键</param>
+    protected void RegisterTranslation(string propertyName, string translationKey)
+    {
+        _translationKeys[propertyName] = translationKey;
+    }
+
+    /// <summary>
+    /// 获取翻译文本。
+    /// </summary>
+    /// <param name="key">翻译键</param>
+    protected string T(string key) => _lang.Get(key);
+
+    /// <summary>
+    /// 子类构造完成后调用,订阅语言变更事件。
+    /// 应在 RegisterTranslation 全部调用之后执行。
+    /// </summary>
+    protected void SubscribeLanguageChanged()
+    {
+        _lang.LanguageChanged += OnLanguageChanged;
+    }
+
+    /// <summary>
+    /// 语言变更时的回调。默认实现会通知所有已注册属性刷新绑定。
+    /// 子类可重写以添加额外逻辑(如需要在 UI 线程执行可结合 SynchronizationContext)。
+    /// </summary>
+    protected virtual void OnLanguageChanged()
+    {
+        RefreshTranslations();
+    }
+
+    /// <summary>
+    /// 对所有已注册的翻译属性触发 PropertyChanged 通知。
+    /// </summary>
+    protected void RefreshTranslations()
+    {
+        foreach (var kvp in _translationKeys)
+        {
+            OnPropertyChanged(kvp.Key);
+        }
+    }
+
+    public void Dispose()
+    {
+        Dispose(true);
+        GC.SuppressFinalize(this);
+    }
+
+    protected virtual void Dispose(bool disposing)
+    {
+        if (_disposed) return;
+        if (disposing)
+        {
+            _lang.LanguageChanged -= OnLanguageChanged;
+        }
+        _disposed = true;
+    }
+}

+ 135 - 161
src/YZWater.Core/ViewModels/ViewAViewModel.cs

@@ -9,9 +9,8 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 主工艺视图 ViewModel(接入真实 PLC 数据)
 /// </summary>
-public partial class ViewAViewModel : ObservableObject, IDisposable
+public partial class ViewAViewModel : TranslatableViewModelBase
 {
-    private readonly LanguageService _lang = LanguageService.Instance;
     private readonly SynchronizationContext? _uiContext = SynchronizationContext.Current;
     private DateTime _startTime = DateTime.Now;
     private System.Timers.Timer? _clockTimer;
@@ -109,52 +108,52 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
     [ObservableProperty] private DateTime _currentTime = DateTime.Now;
 
     // ═══════════════════════════════════════════════
-    //  翻译文字
+    //  翻译属性(getter-only,通过基类 T() 获取翻译)
     // ═══════════════════════════════════════════════
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _subtitleText;
-    [ObservableProperty] private string _plcStatusText;
-    [ObservableProperty] private string _connectText;
-    [ObservableProperty] private string _disconnectText;
-    [ObservableProperty] private string _refreshText;
-    [ObservableProperty] private string _tank1Text;
-    [ObservableProperty] private string _tank2Text;
-    [ObservableProperty] private string _tank3Text;
-    [ObservableProperty] private string _tank4Text;
-    [ObservableProperty] private string _pump1Text;
-    [ObservableProperty] private string _pump2Text;
-    [ObservableProperty] private string _pump3Text;
-    [ObservableProperty] private string _pump4Text;
-    [ObservableProperty] private string _pump5Text;
-    [ObservableProperty] private string _fan1Text;
-    [ObservableProperty] private string _fan2Text;
-    [ObservableProperty] private string _valve1Text;
-    [ObservableProperty] private string _valve2Text;
-    [ObservableProperty] private string _valve3Text;
-    [ObservableProperty] private string _valve4Text;
-    [ObservableProperty] private string _modeText;
-    [ObservableProperty] private string _realtimeText;
-    [ObservableProperty] private string _inflowText;
-    [ObservableProperty] private string _outflowText;
-    [ObservableProperty] private string _deltaText;
-    [ObservableProperty] private string _statisticsText;
-    [ObservableProperty] private string _runtimeText;
-    [ObservableProperty] private string _devicesText;
-    [ObservableProperty] private string _alarmText;
-    [ObservableProperty] private string _efficiencyText;
-    [ObservableProperty] private string _quickOpsText;
-    [ObservableProperty] private string _startAllText;
-    [ObservableProperty] private string _stopAllText;
-    [ObservableProperty] private string _ackAlarmText;
-    [ObservableProperty] private string _exportText;
-    [ObservableProperty] private string _flowMetersText;
-    [ObservableProperty] private string _deviceStatusText;
-    [ObservableProperty] private string _processFlowText;
-    [ObservableProperty] private string _pump1TitleText;
-    [ObservableProperty] private string _pump2TitleText;
-    [ObservableProperty] private string _refluxPumpText;
-    [ObservableProperty] private string _fan1TitleText;
-    [ObservableProperty] private string _fan2TitleText;
+    public string TitleText => T("AppName");
+    public string SubtitleText => T("ProcessFlow");
+    public string PlcStatusText => T("PlcConnected");
+    public string ConnectText => T("Connect");
+    public string DisconnectText => T("Disconnect");
+    public string RefreshText => T("Refresh");
+    public string Tank1Text => T("TankInlet");
+    public string Tank2Text => T("TankBio");
+    public string Tank3Text => T("TankSediment");
+    public string Tank4Text => T("TankOutlet");
+    public string Pump1Text => T("Pump1");
+    public string Pump2Text => T("Pump2");
+    public string Pump3Text => T("Pump3");
+    public string Pump4Text => T("Pump4");
+    public string Pump5Text => T("Pump5");
+    public string Fan1Text => T("Fan1");
+    public string Fan2Text => T("Fan2");
+    public string Valve1Text => T("ValveInlet");
+    public string Valve2Text => T("ValveAeration");
+    public string Valve3Text => T("ValveReflux");
+    public string Valve4Text => T("ValveOutlet");
+    public string ModeText => T("Mode");
+    public string RealtimeText => T("Realtime");
+    public string InflowText => T("Inflow");
+    public string OutflowText => T("Outflow");
+    public string DeltaText => T("Delta");
+    public string StatisticsText => T("Statistics");
+    public string RuntimeText => T("Runtime");
+    public string DevicesText => T("Devices");
+    public string AlarmText => T("Alarm");
+    public string EfficiencyText => T("Efficiency");
+    public string QuickOpsText => T("QuickOps");
+    public string StartAllText => T("StartAll");
+    public string StopAllText => T("StopAll");
+    public string AckAlarmText => T("AckAlarm");
+    public string ExportText => T("Export");
+    public string FlowMetersText => T("FlowMeters");
+    public string DeviceStatusText => T("DeviceStatus");
+    public string ProcessFlowText => T("ProcessFlow");
+    public string Pump1TitleText => T("Pump1Title");
+    public string Pump2TitleText => T("Pump2Title");
+    public string RefluxPumpText => T("RefluxPump");
+    public string Fan1TitleText => T("Fan1Title");
+    public string Fan2TitleText => T("Fan2Title");
 
     [ObservableProperty]
     [NotifyPropertyChangedFor(nameof(Is2DView))]
@@ -164,8 +163,53 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
 
     public ViewAViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += OnLanguageChanged;
+        // 注册翻译映射
+        RegisterTranslation(nameof(TitleText), "AppName");
+        RegisterTranslation(nameof(SubtitleText), "ProcessFlow");
+        RegisterTranslation(nameof(PlcStatusText), "PlcConnected");
+        RegisterTranslation(nameof(ConnectText), "Connect");
+        RegisterTranslation(nameof(DisconnectText), "Disconnect");
+        RegisterTranslation(nameof(RefreshText), "Refresh");
+        RegisterTranslation(nameof(Tank1Text), "TankInlet");
+        RegisterTranslation(nameof(Tank2Text), "TankBio");
+        RegisterTranslation(nameof(Tank3Text), "TankSediment");
+        RegisterTranslation(nameof(Tank4Text), "TankOutlet");
+        RegisterTranslation(nameof(Pump1Text), "Pump1");
+        RegisterTranslation(nameof(Pump2Text), "Pump2");
+        RegisterTranslation(nameof(Pump3Text), "Pump3");
+        RegisterTranslation(nameof(Pump4Text), "Pump4");
+        RegisterTranslation(nameof(Pump5Text), "Pump5");
+        RegisterTranslation(nameof(Fan1Text), "Fan1");
+        RegisterTranslation(nameof(Fan2Text), "Fan2");
+        RegisterTranslation(nameof(Valve1Text), "ValveInlet");
+        RegisterTranslation(nameof(Valve2Text), "ValveAeration");
+        RegisterTranslation(nameof(Valve3Text), "ValveReflux");
+        RegisterTranslation(nameof(Valve4Text), "ValveOutlet");
+        RegisterTranslation(nameof(ModeText), "Mode");
+        RegisterTranslation(nameof(RealtimeText), "Realtime");
+        RegisterTranslation(nameof(InflowText), "Inflow");
+        RegisterTranslation(nameof(OutflowText), "Outflow");
+        RegisterTranslation(nameof(DeltaText), "Delta");
+        RegisterTranslation(nameof(StatisticsText), "Statistics");
+        RegisterTranslation(nameof(RuntimeText), "Runtime");
+        RegisterTranslation(nameof(DevicesText), "Devices");
+        RegisterTranslation(nameof(AlarmText), "Alarm");
+        RegisterTranslation(nameof(EfficiencyText), "Efficiency");
+        RegisterTranslation(nameof(QuickOpsText), "QuickOps");
+        RegisterTranslation(nameof(StartAllText), "StartAll");
+        RegisterTranslation(nameof(StopAllText), "StopAll");
+        RegisterTranslation(nameof(AckAlarmText), "AckAlarm");
+        RegisterTranslation(nameof(ExportText), "Export");
+        RegisterTranslation(nameof(FlowMetersText), "FlowMeters");
+        RegisterTranslation(nameof(DeviceStatusText), "DeviceStatus");
+        RegisterTranslation(nameof(ProcessFlowText), "ProcessFlow");
+        RegisterTranslation(nameof(Pump1TitleText), "Pump1Title");
+        RegisterTranslation(nameof(Pump2TitleText), "Pump2Title");
+        RegisterTranslation(nameof(RefluxPumpText), "RefluxPump");
+        RegisterTranslation(nameof(Fan1TitleText), "Fan1Title");
+        RegisterTranslation(nameof(Fan2TitleText), "Fan2Title");
+
+        SubscribeLanguageChanged();
 
         // 订阅 PLC 轮询数据
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
@@ -178,7 +222,13 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
         StartClockTimer();
     }
 
-    private void OnLanguageChanged() => RunOnUi(UpdateTexts);
+    /// <summary>
+    /// 语言变更时需要在 UI 线程刷新(ViewA 有 SynchronizationContext 需求)
+    /// </summary>
+    protected override void OnLanguageChanged()
+    {
+        RunOnUi(RefreshTranslations);
+    }
 
     private void RunOnUi(Action action)
     {
@@ -193,54 +243,6 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
             action();
     }
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("AppName");
-        SubtitleText = _lang.Get("ProcessFlow");
-        PlcStatusText = _lang.Get("PlcConnected");
-        ConnectText = _lang.Get("Connect");
-        DisconnectText = _lang.Get("Disconnect");
-        RefreshText = _lang.Get("Refresh");
-        Tank1Text = _lang.Get("TankInlet");
-        Tank2Text = _lang.Get("TankBio");
-        Tank3Text = _lang.Get("TankSediment");
-        Tank4Text = _lang.Get("TankOutlet");
-        Pump1Text = _lang.Get("Pump1");
-        Pump2Text = _lang.Get("Pump2");
-        Pump3Text = _lang.Get("Pump3");
-        Pump4Text = _lang.Get("Pump4");
-        Pump5Text = _lang.Get("Pump5");
-        Fan1Text = _lang.Get("Fan1");
-        Fan2Text = _lang.Get("Fan2");
-        Valve1Text = _lang.Get("ValveInlet");
-        Valve2Text = _lang.Get("ValveAeration");
-        Valve3Text = _lang.Get("ValveReflux");
-        Valve4Text = _lang.Get("ValveOutlet");
-        ModeText = _lang.Get("Mode");
-        RealtimeText = _lang.Get("Realtime");
-        InflowText = _lang.Get("Inflow");
-        OutflowText = _lang.Get("Outflow");
-        DeltaText = _lang.Get("Delta");
-        StatisticsText = _lang.Get("Statistics");
-        RuntimeText = _lang.Get("Runtime");
-        DevicesText = _lang.Get("Devices");
-        AlarmText = _lang.Get("Alarm");
-        EfficiencyText = _lang.Get("Efficiency");
-        QuickOpsText = _lang.Get("QuickOps");
-        StartAllText = _lang.Get("StartAll");
-        StopAllText = _lang.Get("StopAll");
-        AckAlarmText = _lang.Get("AckAlarm");
-        ExportText = _lang.Get("Export");
-        FlowMetersText = _lang.Get("FlowMeters");
-        DeviceStatusText = _lang.Get("DeviceStatus");
-        ProcessFlowText = _lang.Get("ProcessFlow");
-        Pump1TitleText = _lang.Get("Pump1Title");
-        Pump2TitleText = _lang.Get("Pump2Title");
-        RefluxPumpText = _lang.Get("RefluxPump");
-        Fan1TitleText = _lang.Get("Fan1Title");
-        Fan2TitleText = _lang.Get("Fan2Title");
-    }
-
     private void StartClockTimer()
     {
         _clockTimer = new System.Timers.Timer(1000);
@@ -336,8 +338,8 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
             : 0;
         UpdateIfChanged(ref _efficiency, (float)efficiency);
 
-        // 检查报警
-        CheckAlarms(data);
+        // 报警由 AlarmService 统一检测,通过 AlarmStateChanged 事件更新
+        UpdateAlarmStateFromService();
     }
 
     /// <summary>
@@ -353,12 +355,18 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
     /// </summary>
     private void OnAlarmStateChanged()
     {
-        RunOnUi(() =>
-        {
-            HasAlarm = AlarmService.Instance.HasActiveAlarm;
-            AlarmMessage = AlarmService.Instance.ActiveAlarmMessage;
-            AlarmCount = AlarmService.Instance.ActiveAlarmCount;
-        });
+        RunOnUi(UpdateAlarmStateFromService);
+    }
+
+    /// <summary>
+    /// 从 AlarmService 读取报警状态(避免重复检测逻辑)
+    /// </summary>
+    private void UpdateAlarmStateFromService()
+    {
+        var alarmService = AlarmService.Instance;
+        HasAlarm = alarmService.HasActiveAlarm;
+        AlarmMessage = alarmService.ActiveAlarmMessage;
+        AlarmCount = alarmService.ActiveAlarmCount;
     }
 
     private static PlcDataModel CreateSnapshot(PlcDataModel data) => new()
@@ -404,24 +412,25 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
     /// </summary>
     private void UpdateDeviceStatuses(PlcDataModel data)
     {
+        // 使用 ThemeColors 常量替代硬编码颜色
         // 仅在状态变化时更新(避免不必要的字符串分配和通知)
-        UpdateIfChanged(ref _pump1StatusColor, data.Pump1Running ? "#00897B" : (data.Pump1Fault ? "#EF4444" : "#4B5563"));
-        UpdateIfChanged(ref _pump2StatusColor, data.Pump2Running ? "#00897B" : (data.Pump2Fault ? "#EF4444" : "#4B5563"));
-        UpdateIfChanged(ref _pump3StatusColor, data.Pump3Running ? "#00897B" : (data.Pump3Fault ? "#EF4444" : "#4B5563"));
-        UpdateIfChanged(ref _pump4StatusColor, data.Pump4Running ? "#00897B" : (data.Pump4Fault ? "#EF4444" : "#4B5563"));
-        UpdateIfChanged(ref _pump5StatusColor, data.Pump5Running ? "#00897B" : (data.Pump5Fault ? "#EF4444" : "#4B5563"));
-
-        UpdateIfChanged(ref _fan1StatusColor, data.Fan1Running ? "#00897B" : (data.Fan1Fault ? "#EF4444" : "#4B5563"));
-        UpdateIfChanged(ref _fan2StatusColor, data.Fan2Running ? "#00897B" : (data.Fan2Fault ? "#EF4444" : "#4B5563"));
-
-        UpdateIfChanged(ref _valve1StatusColor, data.Valve1Position > 50 ? "#00897B" :
-                            data.Valve1Position < 50 ? "#F59E0B" : "#4B5563");
-        UpdateIfChanged(ref _valve2StatusColor, data.Valve2Position > 50 ? "#00897B" :
-                            data.Valve2Position < 50 ? "#F59E0B" : "#4B5563");
-        UpdateIfChanged(ref _valve3StatusColor, data.Valve3Position > 50 ? "#00897B" :
-                            data.Valve3Position < 50 ? "#F59E0B" : "#4B5563");
-        UpdateIfChanged(ref _valve4StatusColor, data.Valve4Position > 50 ? "#00897B" :
-                            data.Valve4Position < 50 ? "#F59E0B" : "#4B5563");
+        UpdateIfChanged(ref _pump1StatusColor, data.Pump1Running ? ThemeColors.Success : (data.Pump1Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+        UpdateIfChanged(ref _pump2StatusColor, data.Pump2Running ? ThemeColors.Success : (data.Pump2Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+        UpdateIfChanged(ref _pump3StatusColor, data.Pump3Running ? ThemeColors.Success : (data.Pump3Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+        UpdateIfChanged(ref _pump4StatusColor, data.Pump4Running ? ThemeColors.Success : (data.Pump4Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+        UpdateIfChanged(ref _pump5StatusColor, data.Pump5Running ? ThemeColors.Success : (data.Pump5Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+
+        UpdateIfChanged(ref _fan1StatusColor, data.Fan1Running ? ThemeColors.Success : (data.Fan1Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+        UpdateIfChanged(ref _fan2StatusColor, data.Fan2Running ? ThemeColors.Success : (data.Fan2Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
+
+        UpdateIfChanged(ref _valve1StatusColor, data.Valve1Position > 50 ? ThemeColors.Success :
+                            data.Valve1Position < 50 ? ThemeColors.Warning : ThemeColors.TextDisabled);
+        UpdateIfChanged(ref _valve2StatusColor, data.Valve2Position > 50 ? ThemeColors.Success :
+                            data.Valve2Position < 50 ? ThemeColors.Warning : ThemeColors.TextDisabled);
+        UpdateIfChanged(ref _valve3StatusColor, data.Valve3Position > 50 ? ThemeColors.Success :
+                            data.Valve3Position < 50 ? ThemeColors.Warning : ThemeColors.TextDisabled);
+        UpdateIfChanged(ref _valve4StatusColor, data.Valve4Position > 50 ? ThemeColors.Success :
+                            data.Valve4Position < 50 ? ThemeColors.Warning : ThemeColors.TextDisabled);
 
         UpdateIfChanged(ref _pump1Status, data.Pump1Running ? $"RUNNING {data.Pump1Freq:F0}Hz" : (data.Pump1Fault ? "FAULT" : "STOPPED"));
         UpdateIfChanged(ref _pump2Status, data.Pump2Running ? $"RUNNING {data.Pump2Freq:F0}Hz" : (data.Pump2Fault ? "FAULT" : "STOPPED"));
@@ -433,42 +442,6 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
         UpdateIfChanged(ref _fan2Status, data.Fan2Running ? "RUNNING" : (data.Fan2Fault ? "FAULT" : "STOPPED"));
     }
 
-    /// <summary>
-    /// 检查报警(从 PlcPollingService 数据)
-    /// </summary>
-    private void CheckAlarms(PlcDataModel data)
-    {
-        var config = ConfigService.GetConfig();
-        if (config == null) return;
-
-        HasAlarm = false;
-        AlarmMessage = string.Empty;
-        AlarmCount = 0;
-
-        if (data.Tank1Level > config.LevelHighAlarm || data.Tank1Level < config.LevelLowAlarm)
-        {
-            HasAlarm = true;
-            AlarmCount++;
-        }
-        if (data.Tank2Level > config.LevelHighAlarm || data.Tank2Level < config.LevelLowAlarm)
-        {
-            HasAlarm = true;
-            AlarmCount++;
-        }
-        if (data.InflowRate > config.FlowHighAlarm)
-        {
-            HasAlarm = true;
-            AlarmCount++;
-        }
-        if (data.HasAnyFault)
-        {
-            HasAlarm = true;
-            AlarmCount++;
-        }
-
-        RunningDeviceCount = data.RunningPumpCount + data.RunningFanCount;
-    }
-
     // ═══════════════════════════════════════════════
     //  PLC 控制命令
     // ═══════════════════════════════════════════════
@@ -579,7 +552,7 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
         await AlarmService.Instance.AcknowledgeAllAsync();
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
@@ -588,10 +561,11 @@ public partial class ViewAViewModel : ObservableObject, IDisposable
         _clockTimer?.Dispose();
         _clockTimer = null;
 
-        _lang.LanguageChanged -= OnLanguageChanged;
         PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
         PlcPollingService.Instance.ConnectionStateChanged -= OnConnectionStateChanged;
         AlarmService.Instance.AlarmStateChanged -= OnAlarmStateChanged;
+
+        base.Dispose();
     }
 }
 

+ 52 - 58
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -1,7 +1,6 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Serilog;
-using YZWater.Core.Models;
 using YZWater.Core.Services;
 
 namespace YZWater.Core.ViewModels;
@@ -9,7 +8,7 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 参数设置视图 ViewModel
 /// </summary>
-public partial class ViewBViewModel : ObservableObject, IDisposable
+public partial class ViewBViewModel : TranslatableViewModelBase
 {
     private bool _disposed;
     [ObservableProperty]
@@ -42,61 +41,56 @@ public partial class ViewBViewModel : ObservableObject, IDisposable
     [ObservableProperty]
     private bool _runAtStartup;
 
-    // ─── 翻译文字 ───
-    private readonly LanguageService _lang = LanguageService.Instance;
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _subtitleText;
-    [ObservableProperty] private string _plcConnectionText;
-    [ObservableProperty] private string _ipAddressText;
-    [ObservableProperty] private string _portText;
-    [ObservableProperty] private string _autoConnectText;
-    [ObservableProperty] private string _statusText;
-    [ObservableProperty] private string _testText;
-    [ObservableProperty] private string _alarmThresholdsText;
-    [ObservableProperty] private string _levelHighText;
-    [ObservableProperty] private string _levelLowText;
-    [ObservableProperty] private string _flowHighText;
-    [ObservableProperty] private string _pumpSettingsText;
-    [ObservableProperty] private string _frequencyText;
-    [ObservableProperty] private string _saveConfigText;
-    [ObservableProperty] private string _resetDefaultText;
-    [ObservableProperty] private string _connectedText;
-    [ObservableProperty] private string _disconnectedText;
-    [ObservableProperty] private string _connectingText;
-    [ObservableProperty] private string _runAtStartupText;
-    [ObservableProperty] private string _systemSettingsText;
+    // ─── 翻译属性 ───
+    public string TitleText => T("Parameters");
+    public string SubtitleText => T("SysConfig");
+    public string PlcConnectionText => T("PlcConnection");
+    public string IpAddressText => T("IpAddress");
+    public string PortText => T("Port");
+    public string AutoConnectText => T("AutoConnect");
+    public string StatusText => T("Status");
+    public string TestText => T("Test");
+    public string AlarmThresholdsText => T("AlarmThresholds");
+    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");
+    public string DisconnectedText => T("Disconnected");
+    public string ConnectingText => T("Connecting");
+    public string RunAtStartupText => T("RunAtStartup");
+    public string SystemSettingsText => T("SystemSettings");
 
     public ViewBViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += UpdateTexts;
-        ConnectionStatus = _lang.Get("NotConnected");
-        LoadConfig();
-    }
+        RegisterTranslation(nameof(TitleText), "Parameters");
+        RegisterTranslation(nameof(SubtitleText), "SysConfig");
+        RegisterTranslation(nameof(PlcConnectionText), "PlcConnection");
+        RegisterTranslation(nameof(IpAddressText), "IpAddress");
+        RegisterTranslation(nameof(PortText), "Port");
+        RegisterTranslation(nameof(AutoConnectText), "AutoConnect");
+        RegisterTranslation(nameof(StatusText), "Status");
+        RegisterTranslation(nameof(TestText), "Test");
+        RegisterTranslation(nameof(AlarmThresholdsText), "AlarmThresholds");
+        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");
+        RegisterTranslation(nameof(DisconnectedText), "Disconnected");
+        RegisterTranslation(nameof(ConnectingText), "Connecting");
+        RegisterTranslation(nameof(RunAtStartupText), "RunAtStartup");
+        RegisterTranslation(nameof(SystemSettingsText), "SystemSettings");
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("Parameters");
-        SubtitleText = _lang.Get("SysConfig");
-        PlcConnectionText = _lang.Get("PlcConnection");
-        IpAddressText = _lang.Get("IpAddress");
-        PortText = _lang.Get("Port");
-        AutoConnectText = _lang.Get("AutoConnect");
-        StatusText = _lang.Get("Status");
-        TestText = _lang.Get("Test");
-        AlarmThresholdsText = _lang.Get("AlarmThresholds");
-        LevelHighText = _lang.Get("LevelHigh");
-        LevelLowText = _lang.Get("LevelLow");
-        FlowHighText = _lang.Get("FlowHigh");
-        PumpSettingsText = _lang.Get("PumpSettings");
-        FrequencyText = _lang.Get("Frequency");
-        SaveConfigText = _lang.Get("SaveConfig");
-        ResetDefaultText = _lang.Get("ResetDefault");
-        ConnectedText = _lang.Get("Connected");
-        DisconnectedText = _lang.Get("Disconnected");
-        ConnectingText = _lang.Get("Connecting");
-        RunAtStartupText = _lang.Get("RunAtStartup");
-        SystemSettingsText = _lang.Get("SystemSettings");
+        SubscribeLanguageChanged();
+        ConnectionStatus = T("NotConnected");
+        LoadConfig();
     }
 
     partial void OnRunAtStartupChanged(bool value)
@@ -173,14 +167,14 @@ public partial class ViewBViewModel : ObservableObject, IDisposable
     private async Task TestConnectionAsync()
     {
         IsConnecting = true;
-        ConnectionStatus = _lang.Get("Connecting");
+        ConnectionStatus = T("Connecting");
 
         try
         {
             PlcService.Initialize();
 
             var success = await PlcService.ConnectAsync();
-            ConnectionStatus = success ? _lang.Get("ConnectSuccess") : _lang.Get("ConnectFailed");
+            ConnectionStatus = success ? T("ConnectSuccess") : T("ConnectFailed");
 
             if (success)
             {
@@ -193,7 +187,7 @@ public partial class ViewBViewModel : ObservableObject, IDisposable
         }
         catch (Exception ex)
         {
-            ConnectionStatus = _lang.Get("ConnectError");
+            ConnectionStatus = T("ConnectError");
             Log.Error(ex, "PLC 连接测试异常");
         }
         finally
@@ -258,10 +252,10 @@ public partial class ViewBViewModel : ObservableObject, IDisposable
         Log.Information("已恢复默认设置");
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
-        _lang.LanguageChanged -= UpdateTexts;
+        base.Dispose();
     }
 }

+ 53 - 58
src/YZWater.Core/ViewModels/ViewCViewModel.cs

@@ -14,7 +14,7 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 流量记录视图 ViewModel
 /// </summary>
-public partial class ViewCViewModel : ObservableObject, IDisposable
+public partial class ViewCViewModel : TranslatableViewModelBase
 {
     private bool _disposed;
     // ═══ 图表数据 ═══
@@ -32,31 +32,48 @@ public partial class ViewCViewModel : ObservableObject, IDisposable
     [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
     [ObservableProperty] private List<FlowRecord> _flowRecords = new();
 
-    // ═══ 翻译文字 ═══
-    private readonly LanguageService _lang = LanguageService.Instance;
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _subtitleText;
-    [ObservableProperty] private string _fromText;
-    [ObservableProperty] private string _toText;
-    [ObservableProperty] private string _queryText;
-    [ObservableProperty] private string _exportCsvText;
-    [ObservableProperty] private string _purgeOldText;
-    [ObservableProperty] private string _inflowTrendText;
-    [ObservableProperty] private string _outflowTrendText;
-    [ObservableProperty] private string _tankLevelTrendText;
-    [ObservableProperty] private string _pumpFreqTrendText;
-    [ObservableProperty] private string _quickRangeText;
-    [ObservableProperty] private string _flowTrendText;
-    [ObservableProperty] private string _timeHeaderText;
-    [ObservableProperty] private string _inflowHeaderText;
-    [ObservableProperty] private string _outflowHeaderText;
-    [ObservableProperty] private string _totalInHeaderText;
-    [ObservableProperty] private string _totalOutHeaderText;
+    // ─── 翻译属性 ───
+    public string TitleText => T("FlowRecords");
+    public string SubtitleText => T("FlowHistory");
+    public string FromText => T("From");
+    public string ToText => T("To");
+    public string QueryText => T("Query");
+    public string ExportCsvText => T("ExportCsv");
+    public string PurgeOldText => T("PurgeOld");
+    public string InflowTrendText => T("InflowTrend");
+    public string OutflowTrendText => T("OutflowTrend");
+    public string TankLevelTrendText => T("TankLevelTrend");
+    public string PumpFreqTrendText => T("PumpFreqTrend");
+    public string QuickRangeText => T("QuickRange");
+    public string FlowTrendText => T("FlowTrend");
+    public string TimeHeaderText => T("TimeHeader");
+    public string InflowHeaderText => T("InflowHeader");
+    public string OutflowHeaderText => T("OutflowHeader");
+    public string TotalInHeaderText => T("TotalInHeader");
+    public string TotalOutHeaderText => T("TotalOutHeader");
 
     public ViewCViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += UpdateTexts;
+        RegisterTranslation(nameof(TitleText), "FlowRecords");
+        RegisterTranslation(nameof(SubtitleText), "FlowHistory");
+        RegisterTranslation(nameof(FromText), "From");
+        RegisterTranslation(nameof(ToText), "To");
+        RegisterTranslation(nameof(QueryText), "Query");
+        RegisterTranslation(nameof(ExportCsvText), "ExportCsv");
+        RegisterTranslation(nameof(PurgeOldText), "PurgeOld");
+        RegisterTranslation(nameof(InflowTrendText), "InflowTrend");
+        RegisterTranslation(nameof(OutflowTrendText), "OutflowTrend");
+        RegisterTranslation(nameof(TankLevelTrendText), "TankLevelTrend");
+        RegisterTranslation(nameof(PumpFreqTrendText), "PumpFreqTrend");
+        RegisterTranslation(nameof(QuickRangeText), "QuickRange");
+        RegisterTranslation(nameof(FlowTrendText), "FlowTrend");
+        RegisterTranslation(nameof(TimeHeaderText), "TimeHeader");
+        RegisterTranslation(nameof(InflowHeaderText), "InflowHeader");
+        RegisterTranslation(nameof(OutflowHeaderText), "OutflowHeader");
+        RegisterTranslation(nameof(TotalInHeaderText), "TotalInHeader");
+        RegisterTranslation(nameof(TotalOutHeaderText), "TotalOutHeader");
+
+        SubscribeLanguageChanged();
         InitializeCharts();
 
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
@@ -96,28 +113,6 @@ public partial class ViewCViewModel : ObservableObject, IDisposable
         }
     }
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("FlowRecords");
-        SubtitleText = _lang.Get("FlowHistory");
-        FromText = _lang.Get("From");
-        ToText = _lang.Get("To");
-        QueryText = _lang.Get("Query");
-        ExportCsvText = _lang.Get("ExportCsv");
-        PurgeOldText = _lang.Get("PurgeOld");
-        InflowTrendText = _lang.Get("InflowTrend");
-        OutflowTrendText = _lang.Get("OutflowTrend");
-        TankLevelTrendText = _lang.Get("TankLevelTrend");
-        PumpFreqTrendText = _lang.Get("PumpFreqTrend");
-        QuickRangeText = _lang.Get("QuickRange");
-        FlowTrendText = _lang.Get("FlowTrend");
-        TimeHeaderText = _lang.Get("TimeHeader");
-        InflowHeaderText = _lang.Get("InflowHeader");
-        OutflowHeaderText = _lang.Get("OutflowHeader");
-        TotalInHeaderText = _lang.Get("TotalInHeader");
-        TotalOutHeaderText = _lang.Get("TotalOutHeader");
-    }
-
     /// <summary>
     /// 初始化所有图表
     /// </summary>
@@ -131,29 +126,29 @@ public partial class ViewCViewModel : ObservableObject, IDisposable
         };
 
         // 流量图表
-        InflowSeries = new ISeries[] { CreateLineSeries(_lang.Get("InflowSeries"), SKColors.CornflowerBlue) };
-        OutflowSeries = new ISeries[] { CreateLineSeries(_lang.Get("OutflowSeries"), SKColors.MediumSeaGreen) };
+        InflowSeries = new ISeries[] { CreateLineSeries(T("InflowSeries"), SKColors.CornflowerBlue) };
+        OutflowSeries = new ISeries[] { CreateLineSeries(T("OutflowSeries"), SKColors.MediumSeaGreen) };
         XAxes = new[] { timeAxis };
         YAxes = new[] { new Axis { Name = "m³/h", MinLimit = 0 } };
 
         // 液位图表
         TankLevelSeries = new ISeries[]
         {
-            CreateLineSeries(_lang.Get("Tank1Name"), SKColors.CornflowerBlue),
-            CreateLineSeries(_lang.Get("Tank2Name"), SKColors.MediumSeaGreen),
-            CreateLineSeries(_lang.Get("Tank3Name"), SKColors.Orange),
-            CreateLineSeries(_lang.Get("Tank4Name"), SKColors.MediumPurple),
+            CreateLineSeries(T("Tank1Name"), SKColors.CornflowerBlue),
+            CreateLineSeries(T("Tank2Name"), SKColors.MediumSeaGreen),
+            CreateLineSeries(T("Tank3Name"), SKColors.Orange),
+            CreateLineSeries(T("Tank4Name"), SKColors.MediumPurple),
         };
         TankYAxes = new[] { new Axis { Name = "%", MinLimit = 0, MaxLimit = 100 } };
 
         // 泵频率图表
         PumpFreqSeries = new ISeries[]
         {
-            CreateLineSeries(_lang.Get("Pump1Name"), SKColors.CornflowerBlue),
-            CreateLineSeries(_lang.Get("Pump2Name"), SKColors.MediumSeaGreen),
-            CreateLineSeries(_lang.Get("Pump3Name"), SKColors.Orange),
-            CreateLineSeries(_lang.Get("Pump4Name"), SKColors.MediumPurple),
-            CreateLineSeries(_lang.Get("Pump5Name"), SKColors.Crimson),
+            CreateLineSeries(T("Pump1Name"), SKColors.CornflowerBlue),
+            CreateLineSeries(T("Pump2Name"), SKColors.MediumSeaGreen),
+            CreateLineSeries(T("Pump3Name"), SKColors.Orange),
+            CreateLineSeries(T("Pump4Name"), SKColors.MediumPurple),
+            CreateLineSeries(T("Pump5Name"), SKColors.Crimson),
         };
         PumpYAxes = new[] { new Axis { Name = "Hz", MinLimit = 0 } };
     }
@@ -330,11 +325,11 @@ public partial class ViewCViewModel : ObservableObject, IDisposable
         }
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
-        _lang.LanguageChanged -= UpdateTexts;
         PlcPollingService.Instance.DataUpdated -= OnDataUpdated;
+        base.Dispose();
     }
 }

+ 38 - 43
src/YZWater.Core/ViewModels/ViewDViewModel.cs

@@ -9,7 +9,7 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 报警记录视图 ViewModel
 /// </summary>
-public partial class ViewDViewModel : ObservableObject, IDisposable
+public partial class ViewDViewModel : TranslatableViewModelBase
 {
     private bool _disposed;
     [ObservableProperty]
@@ -36,35 +36,48 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
     [ObservableProperty] private string _detailMessage = "--";
     [ObservableProperty] private string _detailLevel = "--";
 
-    // ─── 翻译文字 ───
-    private readonly LanguageService _lang = LanguageService.Instance;
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _subtitleText;
-    [ObservableProperty] private string _fromText;
-    [ObservableProperty] private string _toText;
-    [ObservableProperty] private string _queryText;
-    [ObservableProperty] private string _exportText;
-    [ObservableProperty] private string _ackAllText;
-    [ObservableProperty] private string _purgeText;
-    [ObservableProperty] private string _unconfirmedText;
-    [ObservableProperty] private string _alarmDetailText;
-    [ObservableProperty] private string _timeLabel;
-    [ObservableProperty] private string _typeLabel;
-    [ObservableProperty] private string _messageLabel;
-    [ObservableProperty] private string _levelLabel;
+    // ─── 翻译属性 ───
+    public string TitleText => T("AlarmLog");
+    public string SubtitleText => T("AlarmHistory");
+    public string FromText => T("From");
+    public string ToText => T("To");
+    public string QueryText => T("Query");
+    public string ExportText => T("Export");
+    public string AckAllText => T("AckAll");
+    public string PurgeText => T("Purge");
+    public string UnconfirmedText => T("Unconfirmed");
+    public string AlarmDetailText => T("AlarmDetail");
+    public string TimeLabel => T("Time");
+    public string TypeLabel => T("Type");
+    public string MessageLabel => T("Message");
+    public string LevelLabel => T("Level2");
 
     partial void OnSelectedRecordChanged(AlarmRecord? value)
     {
         DetailTime = value?.AlarmTime.ToString("yyyy-MM-dd HH:mm:ss") ?? "--";
         DetailType = value?.AlarmType ?? "--";
         DetailMessage = value?.AlarmMessage ?? "--";
-        DetailLevel = value != null ? $"{_lang.Get("Level2")} {value.AlarmLevel}" : "--";
+        DetailLevel = value != null ? $"{T("Level2")} {value.AlarmLevel}" : "--";
     }
 
     public ViewDViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += UpdateTexts;
+        RegisterTranslation(nameof(TitleText), "AlarmLog");
+        RegisterTranslation(nameof(SubtitleText), "AlarmHistory");
+        RegisterTranslation(nameof(FromText), "From");
+        RegisterTranslation(nameof(ToText), "To");
+        RegisterTranslation(nameof(QueryText), "Query");
+        RegisterTranslation(nameof(ExportText), "Export");
+        RegisterTranslation(nameof(AckAllText), "AckAll");
+        RegisterTranslation(nameof(PurgeText), "Purge");
+        RegisterTranslation(nameof(UnconfirmedText), "Unconfirmed");
+        RegisterTranslation(nameof(AlarmDetailText), "AlarmDetail");
+        RegisterTranslation(nameof(TimeLabel), "Time");
+        RegisterTranslation(nameof(TypeLabel), "Type");
+        RegisterTranslation(nameof(MessageLabel), "Message");
+        RegisterTranslation(nameof(LevelLabel), "Level2");
+
+        SubscribeLanguageChanged();
 
         // 订阅报警服务
         AlarmService.Instance.AlarmRaised += OnAlarmRaised;
@@ -80,24 +93,6 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
         await LoadAlarmRecordsAsync();
     }
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("AlarmLog");
-        SubtitleText = _lang.Get("AlarmHistory");
-        FromText = _lang.Get("From");
-        ToText = _lang.Get("To");
-        QueryText = _lang.Get("Query");
-        ExportText = _lang.Get("Export");
-        AckAllText = _lang.Get("AckAll");
-        PurgeText = _lang.Get("Purge");
-        UnconfirmedText = _lang.Get("Unconfirmed");
-        AlarmDetailText = _lang.Get("AlarmDetail");
-        TimeLabel = _lang.Get("Time");
-        TypeLabel = _lang.Get("Type");
-        MessageLabel = _lang.Get("Message");
-        LevelLabel = _lang.Get("Level2");
-    }
-
     /// <summary>
     /// 加载报警记录
     /// </summary>
@@ -111,7 +106,7 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
             var query = DatabaseService.Db.Queryable<AlarmRecord>()
                 .Where(r => r.AlarmTime >= start && r.AlarmTime <= end);
 
-            if (FilterType != _lang.Get("All"))
+            if (FilterType != T("All"))
             {
                 query = query.Where(r => r.AlarmType == FilterType);
             }
@@ -143,7 +138,7 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
         {
             record.IsConfirmed = true;
             record.ConfirmedTime = DateTime.Now;
-            record.ConfirmedBy = AuthService.CurrentUser?.DisplayName ?? _lang.Get("UnknownUser");
+            record.ConfirmedBy = AuthService.CurrentUser?.DisplayName ?? T("UnknownUser");
 
             await DatabaseService.Db.Updateable<AlarmRecord>()
                 .SetColumns(r => r.IsConfirmed, true)
@@ -172,7 +167,7 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
             await DatabaseService.Db.Updateable<AlarmRecord>()
                 .SetColumns(r => r.IsConfirmed, true)
                 .SetColumns(r => r.ConfirmedTime, DateTime.Now)
-                .SetColumns(r => r.ConfirmedBy, AuthService.CurrentUser?.DisplayName ?? _lang.Get("UnknownUser"))
+                .SetColumns(r => r.ConfirmedBy, AuthService.CurrentUser?.DisplayName ?? T("UnknownUser"))
                 .Where(r => !r.IsConfirmed)
                 .ExecuteCommandAsync();
 
@@ -235,11 +230,11 @@ public partial class ViewDViewModel : ObservableObject, IDisposable
         }
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
-        _lang.LanguageChanged -= UpdateTexts;
         AlarmService.Instance.AlarmRaised -= OnAlarmRaised;
+        base.Dispose();
     }
 }

+ 31 - 39
src/YZWater.Core/ViewModels/ViewEViewModel.cs

@@ -8,9 +8,8 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 关于视图 ViewModel
 /// </summary>
-public partial class ViewEViewModel : ObservableObject, IDisposable
+public partial class ViewEViewModel : TranslatableViewModelBase
 {
-    private bool _disposed;
     [ObservableProperty]
     private string _systemName = "污水处理监控系统";
 
@@ -35,46 +34,46 @@ public partial class ViewEViewModel : ObservableObject, IDisposable
     [ObservableProperty]
     private string _techStack = "Avalonia UI 12 | .NET 8 | SqlSugar | HslCommunication";
 
-    // ─── 翻译文字 ───
-    private readonly LanguageService _lang = LanguageService.Instance;
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _subtitleText;
-    [ObservableProperty] private string _systemInfoText;
-    [ObservableProperty] private string _companyText;
-    [ObservableProperty] private string _companyNameLabel;
-    [ObservableProperty] private string _contactLabel;
-    [ObservableProperty] private string _phoneLabel;
-    [ObservableProperty] private string _visitWebsiteText;
-    [ObservableProperty] private string _checkUpdateText;
+    // ─── 翻译属性 ───
+    public string TitleText => T("About");
+    public string SubtitleText => T("SystemInfo");
+    public string SystemInfoText => T("SystemInfo");
+    public string CompanyText => T("Company");
+    public string CompanyNameLabel => T("CompanyName");
+    public string ContactLabel => T("Contact");
+    public string PhoneLabel => T("Phone");
+    public string VisitWebsiteText => T("VisitWebsite");
+    public string CheckUpdateText => T("CheckUpdate");
 
     public ViewEViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += OnLanguageChanged;
+        RegisterTranslation(nameof(TitleText), "About");
+        RegisterTranslation(nameof(SubtitleText), "SystemInfo");
+        RegisterTranslation(nameof(SystemInfoText), "SystemInfo");
+        RegisterTranslation(nameof(CompanyText), "Company");
+        RegisterTranslation(nameof(CompanyNameLabel), "CompanyName");
+        RegisterTranslation(nameof(ContactLabel), "Contact");
+        RegisterTranslation(nameof(PhoneLabel), "Phone");
+        RegisterTranslation(nameof(VisitWebsiteText), "VisitWebsite");
+        RegisterTranslation(nameof(CheckUpdateText), "CheckUpdate");
+
+        // Description 和 Copyright 跟随语言更新,需要额外处理
+        RegisterTranslation(nameof(Description), "Description");
+        RegisterTranslation(nameof(Copyright), "Copyright");
+
+        SubscribeLanguageChanged();
         LoadCompanyInfo();
     }
 
-    private void OnLanguageChanged()
+    /// <summary>
+    /// 语言变更时除了刷新翻译属性,还需要重新加载公司信息
+    /// </summary>
+    protected override void OnLanguageChanged()
     {
-        UpdateTexts();
+        base.OnLanguageChanged();
         LoadCompanyInfo();
     }
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("About");
-        SubtitleText = _lang.Get("SystemInfo");
-        SystemInfoText = _lang.Get("SystemInfo");
-        CompanyText = _lang.Get("Company");
-        CompanyNameLabel = _lang.Get("CompanyName");
-        ContactLabel = _lang.Get("Contact");
-        PhoneLabel = _lang.Get("Phone");
-        VisitWebsiteText = _lang.Get("VisitWebsite");
-        CheckUpdateText = _lang.Get("CheckUpdate");
-        Description = _lang.Get("Description");
-        Copyright = _lang.Get("Copyright");
-    }
-
     private void LoadCompanyInfo()
     {
         var config = ConfigService.GetConfig();
@@ -110,11 +109,4 @@ public partial class ViewEViewModel : ObservableObject, IDisposable
         await Task.Delay(300);
         OpenWebsite();
     }
-
-    public void Dispose()
-    {
-        if (_disposed) return;
-        _disposed = true;
-        _lang.LanguageChanged -= OnLanguageChanged;
-    }
 }

+ 33 - 39
src/YZWater.Core/ViewModels/ViewFViewModel.cs

@@ -9,7 +9,7 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 审计日志视图 ViewModel
 /// </summary>
-public partial class ViewFViewModel : ObservableObject, IDisposable
+public partial class ViewFViewModel : TranslatableViewModelBase
 {
     private bool _disposed;
     [ObservableProperty]
@@ -27,47 +27,41 @@ public partial class ViewFViewModel : ObservableObject, IDisposable
     [ObservableProperty]
     private int _totalCount;
 
-    // 翻译文字
-    private readonly LanguageService _lang = LanguageService.Instance;
-    [ObservableProperty] private string _titleText;
-    [ObservableProperty] private string _fromText;
-    [ObservableProperty] private string _toText;
-    [ObservableProperty] private string _queryText;
-    [ObservableProperty] private string _exportText;
-    [ObservableProperty] private string _totalText;
-    [ObservableProperty] private string _recordsText;
-    [ObservableProperty] private string _timeHeaderText;
-    [ObservableProperty] private string _userHeaderText;
-    [ObservableProperty] private string _actionHeaderText;
-    [ObservableProperty] private string _detailHeaderText;
-    [ObservableProperty] private string _targetHeaderText;
-    [ObservableProperty] private string _resultHeaderText;
+    // ─── 翻译属性 ───
+    public string TitleText => T("AuditLog");
+    public string FromText => T("From");
+    public string ToText => T("To");
+    public string QueryText => T("Query");
+    public string ExportText => T("Export");
+    public string TotalText => T("Total");
+    public string RecordsText => T("Records");
+    public string TimeHeaderText => T("TimeHeader");
+    public string UserHeaderText => T("User");
+    public string ActionHeaderText => T("Action");
+    public string DetailHeaderText => T("Detail");
+    public string TargetHeaderText => T("Target");
+    public string ResultHeaderText => T("Result");
 
     public ViewFViewModel()
     {
-        UpdateTexts();
-        _lang.LanguageChanged += UpdateTexts;
+        RegisterTranslation(nameof(TitleText), "AuditLog");
+        RegisterTranslation(nameof(FromText), "From");
+        RegisterTranslation(nameof(ToText), "To");
+        RegisterTranslation(nameof(QueryText), "Query");
+        RegisterTranslation(nameof(ExportText), "Export");
+        RegisterTranslation(nameof(TotalText), "Total");
+        RegisterTranslation(nameof(RecordsText), "Records");
+        RegisterTranslation(nameof(TimeHeaderText), "TimeHeader");
+        RegisterTranslation(nameof(UserHeaderText), "User");
+        RegisterTranslation(nameof(ActionHeaderText), "Action");
+        RegisterTranslation(nameof(DetailHeaderText), "Detail");
+        RegisterTranslation(nameof(TargetHeaderText), "Target");
+        RegisterTranslation(nameof(ResultHeaderText), "Result");
+
+        SubscribeLanguageChanged();
         _ = LoadLogsAsync();
     }
 
-    private void UpdateTexts()
-    {
-        TitleText = _lang.Get("AuditLog");
-        FromText = _lang.Get("From");
-        ToText = _lang.Get("To");
-        QueryText = _lang.Get("Query");
-        ExportText = _lang.Get("Export");
-        TotalText = _lang.Get("Total");
-        RecordsText = _lang.Get("Records");
-        TimeHeaderText = _lang.Get("TimeHeader");
-        UserHeaderText = _lang.Get("User");
-        ActionHeaderText = _lang.Get("Action");
-        DetailHeaderText = _lang.Get("Detail");
-        TargetHeaderText = _lang.Get("Target");
-        ResultHeaderText = _lang.Get("Result");
-        ExportText = _lang.Get("Export");
-    }
-
     [RelayCommand]
     private async Task LoadLogsAsync()
     {
@@ -76,7 +70,7 @@ public partial class ViewFViewModel : ObservableObject, IDisposable
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
             var end = EndDate?.DateTime ?? DateTime.Now;
 
-            var action = FilterAction == _lang.Get("All") ? null : FilterAction;
+            var action = FilterAction == T("All") ? null : FilterAction;
             var logs = await AuditService.QueryAsync(start, end, action: action);
 
             AuditLogs = logs;
@@ -114,10 +108,10 @@ public partial class ViewFViewModel : ObservableObject, IDisposable
         }
     }
 
-    public void Dispose()
+    public new void Dispose()
     {
         if (_disposed) return;
         _disposed = true;
-        _lang.LanguageChanged -= UpdateTexts;
+        base.Dispose();
     }
 }