Ver Fonte

feat: 报警审计增强(r12)

报警管理:
- 首页角标显示未确认数(ISA-18.2 合规)
- 报警级别显示文字而非数字
- 报警等级可按配置调整(Alarm.LevelByType)
- 数据质量事件不进报警表,只写日志
- 搁置/回顾功能接入 UI
- 清除历史记录补审计

审计与合规:
- 配置保存、清零、DO 下发补审计记录
- 远程操作审计补 ClientIp 字段
- 报警保留期默认 180 天(网安法合规)

基础设施:
- 备份文件名加时间戳,间隔/保留份数从配置读
- 报警/审计队列丢弃计数上屏
DESKTOP-74CLTRG\Leol há 1 semana atrás
pai
commit
82f3eadd15
51 ficheiros alterados com 4291 adições e 422 exclusões
  1. 2 2
      README.md
  2. 2 2
      docs/config-reference.md
  3. 25 9
      docs/扬杰二厂操作手册.md
  4. 1861 0
      docs/西门子S7通信指南.md
  5. 2 1
      projects/yangjie2.json
  6. 13 2
      src/YZWater.Avalonia/App.axaml.cs
  7. 16 4
      src/YZWater.Avalonia/Services/SingleInstanceHelper.cs
  8. 4 2
      src/YZWater.Avalonia/Themes/IndustrialStyles.axaml
  9. 5 0
      src/YZWater.Avalonia/Themes/IndustrialTheme.axaml
  10. 5 0
      src/YZWater.Avalonia/Themes/IndustrialThemeLight.axaml
  11. 12 12
      src/YZWater.Avalonia/Views/MainWindow.axaml
  12. 11 3
      src/YZWater.Avalonia/Views/ProjectView.axaml
  13. 1 1
      src/YZWater.Avalonia/Views/ViewBView.axaml
  14. 1 1
      src/YZWater.Avalonia/Views/ViewCView.axaml
  15. 45 6
      src/YZWater.Avalonia/Views/ViewDView.axaml
  16. 1 1
      src/YZWater.Avalonia/Views/ViewEView.axaml
  17. 3 0
      src/YZWater.Avalonia/Views/ViewFView.axaml
  18. 21 0
      src/YZWater.Core/Models/AlarmRecord.cs
  19. 4 0
      src/YZWater.Core/Models/HubMessage.cs
  20. 35 4
      src/YZWater.Core/Models/SystemConfig.cs
  21. 355 157
      src/YZWater.Core/Services/AlarmService.cs
  22. 25 5
      src/YZWater.Core/Services/AuditService.cs
  23. 21 17
      src/YZWater.Core/Services/BackupService.cs
  24. 17 5
      src/YZWater.Core/Services/ConfigService.cs
  25. 1 1
      src/YZWater.Core/Services/DataLoggingService.cs
  26. 3 3
      src/YZWater.Core/Services/DataRetentionService.cs
  27. 12 1
      src/YZWater.Core/Services/HubServer.cs
  28. 2 2
      src/YZWater.Core/Services/Interfaces/IConfigService.cs
  29. 64 4
      src/YZWater.Core/Services/LanguageService.cs
  30. 38 2
      src/YZWater.Core/Services/LogService.cs
  31. 6 0
      src/YZWater.Core/Services/MockPlcService.cs
  32. 166 41
      src/YZWater.Core/Services/PlcPollingService.cs
  33. 90 29
      src/YZWater.Core/Services/PlcService.cs
  34. 14 0
      src/YZWater.Core/Utils/FileTextEncoding.cs
  35. 1 1
      src/YZWater.Core/ViewModels/MainViewModel.cs
  36. 73 46
      src/YZWater.Core/ViewModels/ProjectViewModel.cs
  37. 89 20
      src/YZWater.Core/ViewModels/ViewBViewModel.cs
  38. 68 12
      src/YZWater.Core/ViewModels/ViewCViewModel.cs
  39. 121 22
      src/YZWater.Core/ViewModels/ViewDViewModel.cs
  40. 12 2
      src/YZWater.Core/ViewModels/ViewFViewModel.cs
  41. 7 0
      src/YZWater.Core/YZWater.Core.csproj
  42. 75 0
      tests/YZWater.Core.Tests/Models/AlarmDurationSemanticsTests.cs
  43. 94 0
      tests/YZWater.Core.Tests/Services/AlarmOrphanRepairTests.cs
  44. 207 0
      tests/YZWater.Core.Tests/Services/AlarmServiceConcurrencyTests.cs
  45. 82 0
      tests/YZWater.Core.Tests/Services/FileEncodingTests.cs
  46. 101 0
      tests/YZWater.Core.Tests/Services/LogLevelSwitchTests.cs
  47. 236 0
      tests/YZWater.Core.Tests/Services/PlcConnectionStateTests.cs
  48. 88 0
      tests/YZWater.Core.Tests/Services/QualityJournalTests.cs
  49. 79 0
      tests/YZWater.Core.Tests/Services/QueueDropAccountingTests.cs
  50. 73 0
      tests/YZWater.Core.Tests/ViewModels/ResetToDefaultPersistenceTests.cs
  51. 2 2
      yzwater-config.template.json

+ 2 - 2
README.md

@@ -200,8 +200,8 @@ YZWater3/
 
 ```json
 {
-  "PlcIp": "192.168.0.150",
-  "PlcPort": 5000,
+  "PlcIp": "192.168.2.100",
+  "PlcPort": 102,
   "AutoConnect": true,
   "LevelHighAlarm": 80.0,
   "LevelLowAlarm": 20.0,

+ 2 - 2
docs/config-reference.md

@@ -6,8 +6,8 @@
 
 | 字段 | 类型 | 默认值 | 说明 |
 |------|------|--------|------|
-| `PlcIp` | string | `192.168.0.150` | PLC IP 地址 |
-| `PlcPort` | int | `5000` | PLC 端口(西门子 S7 默认 102,本项目配置为 5000) |
+| `PlcIp` | string | `192.168.2.100` | PLC IP 地址(出厂即扬杰二厂现场地址,换站点时改这里) |
+| `PlcPort` | int | `102` | PLC 端口(西门子 S7 标准端口 102) |
 | `AutoConnect` | bool | `true` | 启动时是否自动连接 PLC |
 | `PlcPollingIntervalMs` | int | `1000` | PLC 轮询间隔(毫秒) |
 | `PlcAddresses` | dict | `{}` | PLC 地址覆盖。key=标签名(如 `Tank1Level`),value=地址(如 `VD100`)。留空使用内置默认地址 |

+ 25 - 9
docs/扬杰二厂操作手册.md

@@ -16,7 +16,8 @@
 
 ## 系统概述
 
-本系统用于监控扬杰二厂泵站的 3 台排污泵运行状态,实现液位自动控制、报警管理、数据统计等功能。
+本系统用于监控扬杰二厂泵站的 3 台排污泵运行状态,实现液位/流量/压力采集显示、报警管理、数据统计等功能;
+泵组启停默认由 PLC 程序完成,上位机仅监控(详见「液位自动控制」一节)。
 
 **硬件配置**:
 - PLC:西门子 S7-200 Smart
@@ -82,9 +83,14 @@
 
 ## 液位自动控制
 
-### 自动模式
+> **本项目(扬杰二厂泵站)出厂即为"PLC 自动,上位机仅监控"**:泵组启停全部由 PLC 程序完成,
+> 上位机只负责显示、报警和记录,不会向 PLC 写启停指令。
+> 现场看到的"自动模式"按钮、泵组状态都只反映 PLC 的动作结果。
+>
+> 如果现场要求改成"上位机接管启停",把 `projects/yangjie2.json` 里的
+> `control.enableAutoControl` 改为 `true` 后重启程序即可(下面的档位表就是这种情况下上位机执行的逻辑)。
 
-系统默认启用液位自动控制,逻辑如下:
+### 自动模式(上位机接管时的档位逻辑)
 
 | 液位条件 | 动作 |
 |----------|------|
@@ -94,12 +100,19 @@
 | 液位 > 停泵液位 | 全部停止 |
 | 液位 > 保护液位 | 紧急全启 + 报警 |
 
+- 判定按从上往下的顺序取第一条成立的条件,因此三个启泵液位必须满足 **启泵1 < 启泵2 < 启泵3**,
+  且都低于停泵液位,档位才不会互相盖掉。阈值填错程序不会拦,投运前需在画面核对一遍。
+- "保护液位"这一行是上位机自动控制时的动作(紧急全启),同时无条件产生一条紧急报警。
+  《扬杰二厂泵站需求》里对保护液位写的是"超限 → 紧急停机",两者不一致 —— **以 PLC 程序为准,投运前需与厂家确认**。
+
 ### 泵轮换
 
-系统启用泵轮换功能,优先启动运行时间较短的泵,均衡磨损。
+上位机接管时启用泵轮换功能,优先启动运行时间较短的泵,均衡磨损。(PLC 自动控制时轮换由 PLC 程序决定,与上位机无关。)
 
 ### 防频繁启停
 
+上位机接管时的保护参数:
+
 - 启泵延迟:3 秒
 - 停泵延迟:5 秒
 - 同一台泵最小间隔:60 秒
@@ -186,7 +199,7 @@
 
 | 参数 | 扬杰二厂默认值 |
 |------|----------------|
-| PLC IP | 192.168.0.150 |
+| PLC IP | 192.168.2.100 |
 | 端口 | 102 |
 | 协议 | SiemensS7 |
 | 自动连接 | 是 |
@@ -228,10 +241,13 @@
 
 ### Q: 自动控制不动作?
 
-**检查**:
-1. 自动控制是否启用(默认启用)
-2. 液位阈值设置是否合理
-3. 查看日志文件 `Logs/log-*.txt` 确认控制逻辑
+**本项目出厂为"PLC 自动,上位机仅监控",上位机本来就不写启停指令**,泵不启停请先查 PLC 程序和现场控制档位(中控/屏控)。
+只有把 `projects/yangjie2.json` 的 `control.enableAutoControl` 改成 `true` 后,上位机才会参与启停,这时按下面排查:
+
+1. 自动控制是否启用(本项目默认 **未** 启用)
+2. 日志里是否出现 `自动控制未启用: 项目 [...] 配置为 PLC 自动控制`
+3. 液位阈值设置是否合理:启泵1 < 启泵2 < 启泵3 且都低于停泵液位,填反了程序不会拦
+4. 查看日志文件 `Logs/log-*.txt` 确认控制逻辑
 
 ### Q: 如何备份配置?
 

+ 1861 - 0
docs/西门子S7通信指南.md

@@ -0,0 +1,1861 @@
+# 西门子 S7 PLC 通信指南
+
+> 基于 HslCommunication 库,提取自 YZWater3 项目,可直接复用于其他 .NET 项目。
+
+## 目录
+
+- [概述](#概述)
+- [授权配置](#授权配置)
+- [依赖安装](#依赖安装)
+- [连接建立](#连接建立)
+- [数据读取](#数据读取)
+- [数据写入](#数据写入)
+- [字节序处理](#字节序处理)
+- [地址配置管理 PlcConfig](#地址配置管理-plcconfig)
+- [S7 地址解析器(批量读取优化)](#s7-地址解析器批量读取优化)
+- [数据模型 PlcDataModel](#数据模型-plcdatamodel)
+- [轮询服务模板(完整版)](#轮询服务模板完整版)
+- [地址格式参考](#地址格式参考)
+- [配置文件结构](#配置文件结构)
+- [项目文件结构](#项目文件结构)
+- [使用示例](#使用示例)
+- [支持的 PLC 系列](#支持的-plc-系列)
+- [常见问题](#常见问题)
+- [许可证](#许可证)
+
+---
+
+## 概述
+
+本项目使用 **HslCommunication** 工业通信库与西门子 S7 系列 PLC 进行通信。
+
+### 核心特性
+
+- 支持 S7-200 / S7-200Smart / S7-300 / S7-400 / S7-1200 / S7-1500
+- 异步读写,支持 Float / Bool / byte[] 等数据类型
+- 批量读取优化(连续地址合并)
+- 自动重连机制
+- 大端序/小端序自动转换
+
+---
+
+## 授权配置
+
+HslCommunication 是商业授权库,使用前需要设置授权码。
+
+### 授权方式
+
+| 方式 | 说明 |
+|------|------|
+| 免费试用 | 不设置授权码,功能受限(随机断开、部分 API 不可用) |
+| 正式授权 | 购买授权码后通过 `SetAuthorizationCode()` 激活 |
+
+### 设置授权码
+
+**必须在创建 PLC 通信对象之前调用**,建议在程序启动入口(如 `Main()` 或 `App.OnStartup()`)中调用:
+
+```csharp
+using HslCommunication;
+
+// 在程序启动时设置授权码(必须在使用任何 PLC 功能之前调用)
+HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
+```
+
+### 在 Avalonia 中的应用示例
+
+```csharp
+// App.axaml.cs
+public class App : Application
+{
+    public override void OnFrameworkInitializationCompleted()
+    {
+        // 激活 HslCommunication 授权(在所有 PLC 操作之前)
+        HslCommunication.Authorization.SetAuthorizationCode(
+            "c765bac7-2caf-446d-9619-f67e19d6624e");
+
+        // 后续初始化...
+        base.OnFrameworkInitializationCompleted();
+    }
+}
+```
+
+### 在 WPF / WinForms 中的应用示例
+
+```csharp
+// Program.cs 或 App.xaml.cs
+[STAThread]
+static void Main()
+{
+    // 在 Application 启动前设置
+    HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
+
+    App domain = new App();
+    domain.InitializeComponent();
+    domain.Run();
+}
+```
+
+### 授权相关 API
+
+```csharp
+// 检查授权状态
+bool isAuthorized = HslCommunication.Authorization.IsAuthorized();
+
+// 获取授权到期时间
+DateTime expireDate = HslCommunication.Authorization.GetExpireDate();
+
+// 获取授权设备数量限制
+int deviceCount = HslCommunication.Authorization.GetDeviceCount();
+```
+
+### 未授权时的表现
+
+- 连接会随机断开(不定时丢失连接)
+- 部分高级功能不可用
+- 数据读取可能出现异常
+
+> **注意**:本项目使用的授权码 `c765bac7-2caf-446d-9619-f67e19d6624e` 仅限本项目使用。  
+> 其他项目请前往 [HslCommunication 官网](https://www.hslcommunication.cn/) 购买授权。
+
+---
+
+## 依赖安装
+
+### NuGet 包
+
+```xml
+<PackageReference Include="HslCommunication" Version="11.8.2" />
+```
+
+### 安装命令
+
+```bash
+# Package Manager
+Install-Package HslCommunication
+
+# .NET CLI
+dotnet add package HslCommunication
+```
+
+---
+
+## 连接建立
+
+### 基础连接类
+
+```csharp
+using HslCommunication;
+using HslCommunication.Profinet.Siemens;
+
+public class SiemensPlcHelper
+{
+    private SiemensS7Net? _plc;
+    private bool _isConnected;
+
+    public bool IsConnected => _isConnected;
+
+    /// <summary>
+    /// 初始化并连接 PLC
+    /// </summary>
+    public async Task<bool> ConnectAsync(string ip, int port = 102, 
+        SiemensPLCS series = SiemensPLCS.S200Smart)
+    {
+        _plc = new SiemensS7Net(series, ip)
+        {
+            Port = port,
+            ConnectTimeOut = 3000,   // 连接超时 3秒
+            ReceiveTimeOut = 3000    // 接收超时 3秒
+        };
+
+        var result = await _plc.ConnectServerAsync();
+        _isConnected = result.IsSuccess;
+        return _isConnected;
+    }
+
+    /// <summary>
+    /// 断开连接
+    /// </summary>
+    public void Disconnect()
+    {
+        _plc?.ConnectClose();
+        _isConnected = false;
+    }
+
+    /// <summary>
+    /// 释放资源
+    /// </summary>
+    public void Dispose()
+    {
+        Disconnect();
+        _plc?.Dispose();
+        _plc = null;
+    }
+}
+```
+
+### 连接参数说明
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `ip` | PLC 的 IP 地址 | 必填 |
+| `port` | 端口号 | `102` |
+| `series` | PLC 系列 | `SiemensPLCS.S200Smart` |
+| `ConnectTimeOut` | 连接超时(毫秒) | `3000` |
+| `ReceiveTimeOut` | 接收超时(毫秒) | `3000` |
+
+---
+
+## 数据读取
+
+### 读取浮点数(REAL)
+
+```csharp
+/// <summary>
+/// 读取浮点数 (4字节 REAL)
+/// </summary>
+public async Task<float> ReadFloatAsync(string address)
+{
+    if (!_isConnected || _plc == null) return 0f;
+    
+    var result = await _plc.ReadFloatAsync(address);  // 如 "VD100"
+    return result.IsSuccess ? result.Content : 0f;
+}
+```
+
+### 读取布尔值(位)
+
+```csharp
+/// <summary>
+/// 读取布尔值 (单个位)
+/// </summary>
+public async Task<bool> ReadBoolAsync(string address)
+{
+    if (!_isConnected || _plc == null) return false;
+    
+    var result = await _plc.ReadBoolAsync(address);   // 如 "M10.0"
+    return result.IsSuccess ? result.Content : false;
+}
+```
+
+### 批量读取字节(高性能)
+
+```csharp
+/// <summary>
+/// 批量读取字节数组
+/// </summary>
+public async Task<byte[]?> ReadBytesAsync(string address, ushort length)
+{
+    if (!_isConnected || _plc == null) return null;
+    
+    var result = await _plc.ReadAsync(address, length);  // 如 "V100", 20
+    return result.IsSuccess ? result.Content : null;
+}
+
+// 使用示例:一次读取 5 个连续的浮点数
+var bytes = await ReadBytesAsync("V100", 20);  // 从 V100 开始读 20 字节
+if (bytes != null)
+{
+    var value1 = ParseFloat(bytes, 0);   // VD100
+    var value2 = ParseFloat(bytes, 4);   // VD104
+    var value3 = ParseFloat(bytes, 8);   // VD108
+    var value4 = ParseFloat(bytes, 12);  // VD112
+    var value5 = ParseFloat(bytes, 16);  // VD116
+}
+```
+
+### 读取整数类型
+
+```csharp
+/// <summary>
+/// 读取整数(INT/WORD/DINT/DWORD)
+/// </summary>
+public async Task<int> ReadIntAsync(string address, string dataType = "INT")
+{
+    if (!_isConnected || _plc == null) return 0;
+    
+    ushort length = dataType switch
+    {
+        "INT" or "WORD" => 2,
+        "DINT" or "DWORD" => 4,
+        _ => 2
+    };
+    
+    var result = await _plc.ReadAsync(address, length);
+    if (!result.IsSuccess || result.Content == null) return 0;
+    
+    var bytes = result.Content;
+    if (BitConverter.IsLittleEndian)
+    {
+        Array.Reverse(bytes);
+    }
+    
+    return dataType switch
+    {
+        "INT" => BitConverter.ToInt16(bytes, 0),
+        "WORD" => BitConverter.ToUInt16(bytes, 0),
+        "DINT" => BitConverter.ToInt32(bytes, 0),
+        "DWORD" => (int)BitConverter.ToUInt32(bytes, 0),
+        _ => 0
+    };
+}
+```
+
+---
+
+## 数据写入
+
+### 写入浮点数
+
+```csharp
+/// <summary>
+/// 写入浮点数
+/// </summary>
+public async Task<bool> WriteFloatAsync(string address, float value)
+{
+    if (!_isConnected || _plc == null) return false;
+    
+    var result = await _plc.WriteAsync(address, value);  // 如 "VD100", 123.45f
+    return result.IsSuccess;
+}
+```
+
+### 写入布尔值
+
+```csharp
+/// <summary>
+/// 写入布尔值
+/// </summary>
+public async Task<bool> WriteBoolAsync(string address, bool value)
+{
+    if (!_isConnected || _plc == null) return false;
+    
+    var result = await _plc.WriteAsync(address, value);  // 如 "M10.0", true
+    return result.IsSuccess;
+}
+```
+
+### 写入整数
+
+```csharp
+/// <summary>
+/// 写入整数
+/// </summary>
+public async Task<bool> WriteIntAsync(string address, int value, string dataType = "INT")
+{
+    if (!_isConnected || _plc == null) return false;
+    
+    object data = dataType switch
+    {
+        "INT" => (short)value,
+        "WORD" => (ushort)value,
+        "DINT" => value,
+        "DWORD" => (uint)value,
+        _ => (short)value
+    };
+    
+    var result = await _plc.WriteAsync(address, data);
+    return result.IsSuccess;
+}
+```
+
+---
+
+## 字节序处理
+
+**重要**:西门子 S7 PLC 使用 **大端序(Big-Endian)**,而 Windows 使用 **小端序(Little-Endian)**,读取字节后需要进行转换。
+
+### 浮点数解析
+
+```csharp
+/// <summary>
+/// 从字节数组解析浮点数(S7 大端序 → 本地小端序)
+/// </summary>
+public static float ParseFloat(byte[] data, int offset)
+{
+    if (data == null || offset + 4 > data.Length) return 0f;
+    
+    if (BitConverter.IsLittleEndian)
+    {
+        // S7 是大端序,需要翻转字节
+        var bytes = new byte[] { 
+            data[offset + 3], data[offset + 2], 
+            data[offset + 1], data[offset] 
+        };
+        return BitConverter.ToSingle(bytes, 0);
+    }
+    return BitConverter.ToSingle(data, offset);
+}
+```
+
+### 位解析
+
+```csharp
+/// <summary>
+/// 从字节中提取特定位
+/// </summary>
+public static bool ParseBit(byte[] data, int byteOffset, int bitOffset)
+{
+    if (data == null || byteOffset >= data.Length) return false;
+    return (data[byteOffset] & (1 << bitOffset)) != 0;
+}
+```
+
+---
+
+## 地址配置管理 PlcConfig
+
+将所有 PLC 地址集中管理,支持从配置文件覆盖,避免硬编码散落各处。
+
+```csharp
+using System.Text.Json;
+
+/// <summary>
+/// PLC 地址配置 - 所有地址映射集中管理,支持配置文件覆盖
+/// </summary>
+public static class PlcConfig
+{
+    // 地址覆盖表(从配置文件加载)
+    private static Dictionary<string, string> _overrides = new();
+
+    /// <summary>
+    /// 从配置文件加载地址覆盖
+    /// </summary>
+    public static void LoadFromConfig(string configPath = "plc-addresses.json")
+    {
+        if (File.Exists(configPath))
+        {
+            var json = File.ReadAllText(configPath);
+            _overrides = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
+        }
+    }
+
+    /// <summary>
+    /// 获取地址(优先使用配置覆盖值)
+    /// </summary>
+    private static string Addr(string tagName, string defaultAddr)
+    {
+        return _overrides.TryGetValue(tagName, out var addr) ? addr : defaultAddr;
+    }
+
+    // ═══ 读取地址 ═══
+
+    // 水箱液位 (float, VD)
+    public static string Tank1Level => Addr(nameof(Tank1Level), "VD100");
+    public static string Tank2Level => Addr(nameof(Tank2Level), "VD104");
+    public static string Tank3Level => Addr(nameof(Tank3Level), "VD108");
+    public static string Tank4Level => Addr(nameof(Tank4Level), "VD112");
+
+    // 流量 (float, VD)
+    public static string InflowRate  => Addr(nameof(InflowRate),  "VD200");
+    public static string OutflowRate => Addr(nameof(OutflowRate), "VD204");
+
+    // 泵运行状态 (bool, Q)
+    public const string Pump1Run = "Q0.0";
+    public const string Pump2Run = "Q0.1";
+    public const string Pump3Run = "Q0.2";
+    public const string Pump4Run = "Q0.3";
+    public const string Pump5Run = "Q0.4";
+
+    // 泵故障 (bool, I)
+    public const string Pump1Fault = "I0.0";
+    public const string Pump2Fault = "I0.1";
+    public const string Pump3Fault = "I0.2";
+    public const string Pump4Fault = "I0.3";
+    public const string Pump5Fault = "I0.4";
+
+    // 泵频率 (float, VD)
+    public static string Pump1Freq => Addr(nameof(Pump1Freq), "VD300");
+    public static string Pump2Freq => Addr(nameof(Pump2Freq), "VD304");
+    public static string Pump3Freq => Addr(nameof(Pump3Freq), "VD308");
+    public static string Pump4Freq => Addr(nameof(Pump4Freq), "VD312");
+    public static string Pump5Freq => Addr(nameof(Pump5Freq), "VD316");
+
+    // 泵电流 (float, VD)
+    public static string Pump1Current => Addr(nameof(Pump1Current), "VD320");
+    public static string Pump2Current => Addr(nameof(Pump2Current), "VD324");
+    public static string Pump3Current => Addr(nameof(Pump3Current), "VD328");
+    public static string Pump4Current => Addr(nameof(Pump4Current), "VD332");
+    public static string Pump5Current => Addr(nameof(Pump5Current), "VD336");
+
+    // 风机 (bool)
+    public const string Fan1Run  = "Q0.5";
+    public const string Fan2Run  = "Q0.6";
+    public const string Fan1Fault = "I0.5";
+    public const string Fan2Fault = "I0.6";
+
+    // 阀门位置 (int, VD)
+    public static string Valve1Position => Addr(nameof(Valve1Position), "VD500");
+    public static string Valve2Position => Addr(nameof(Valve2Position), "VD504");
+    public static string Valve3Position => Addr(nameof(Valve3Position), "VD508");
+    public static string Valve4Position => Addr(nameof(Valve4Position), "VD512");
+
+    // 系统模式 (bool, M)
+    public const string SystemMode = "M0.0";
+
+    // ═══ 写入地址 ═══
+
+    // 泵启停控制 (bool, Q)
+    public const string Pump1Start = "Q0.0";
+    public const string Pump2Start = "Q0.1";
+    public const string Pump3Start = "Q0.2";
+    public const string Pump4Start = "Q0.3";
+    public const string Pump5Start = "Q0.4";
+
+    // 风机启停 (bool, Q)
+    public const string Fan1Start = "Q0.5";
+    public const string Fan2Start = "Q0.6";
+
+    // 阀门控制 (bool, Q)
+    public const string Valve1Control = "Q1.0";
+    public const string Valve2Control = "Q1.1";
+    public const string Valve3Control = "Q1.2";
+    public const string Valve4Control = "Q1.3";
+
+    // 泵频率设定 (float, VD)
+    public const string PumpFreqSetpoint = "VD600";
+
+    // ═══ 辅助方法 ═══
+
+    public static string GetPumpRunAddress(int index) => index switch
+    {
+        0 => Pump1Run, 1 => Pump2Run, 2 => Pump3Run, 3 => Pump4Run, 4 => Pump5Run,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+
+    public static string GetPumpFaultAddress(int index) => index switch
+    {
+        0 => Pump1Fault, 1 => Pump2Fault, 2 => Pump3Fault, 3 => Pump4Fault, 4 => Pump5Fault,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+
+    public static string GetPumpFreqAddress(int index) => index switch
+    {
+        0 => Pump1Freq, 1 => Pump2Freq, 2 => Pump3Freq, 3 => Pump4Freq, 4 => Pump5Freq,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+
+    public static string GetPumpCurrentAddress(int index) => index switch
+    {
+        0 => Pump1Current, 1 => Pump2Current, 2 => Pump3Current, 3 => Pump4Current, 4 => Pump5Current,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+
+    public static string GetValveControlAddress(int index) => index switch
+    {
+        0 => Valve1Control, 1 => Valve2Control, 2 => Valve3Control, 3 => Valve4Control,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+
+    public static string GetValvePositionAddress(int index) => index switch
+    {
+        0 => Valve1Position, 1 => Valve2Position, 2 => Valve3Position, 3 => Valve4Position,
+        _ => throw new ArgumentOutOfRangeException(nameof(index))
+    };
+}
+```
+
+### 地址覆盖配置文件(plc-addresses.json)
+
+```json
+{
+    "Tank1Level": "VD100",
+    "Tank2Level": "VD104",
+    "InflowRate": "VD200",
+    "OutflowRate": "VD204",
+    "Pump1Freq": "VD300",
+    "Pump2Freq": "VD304",
+    "Pump1Current": "VD320",
+    "Pump2Current": "VD324",
+    "Valve1Position": "VD500",
+    "Valve2Position": "VD504"
+}
+```
+
+---
+
+## S7 地址解析器(批量读取优化)
+
+将分散的地址自动分组为连续区间,减少 PLC 通信次数(从 N 次降为 G 次)。
+
+```csharp
+using System.Text.RegularExpressions;
+
+/// <summary>
+/// 西门子 S7 地址解析工具
+/// 支持格式: V/VB/VW/VD/M/MB/MW/MD/I/Q/IB/QB/IW/QW
+/// </summary>
+public static class S7AddressParser
+{
+    public enum AreaType
+    {
+        Input,     // I - 输入
+        Output,    // Q - 输出
+        Marker,    // M - 标志位
+        Variable   // V - 变量存储区
+    }
+
+    public record ParsedAddress(
+        AreaType Area,
+        int ByteOffset,
+        int BitIndex,
+        int ByteLength,
+        string OriginalAddress
+    );
+
+    /// <summary>
+    /// 解析 S7 地址
+    /// </summary>
+    public static ParsedAddress? Parse(string address)
+    {
+        if (string.IsNullOrEmpty(address)) return null;
+
+        address = address.Trim().ToUpper();
+
+        var match = Regex.Match(address,
+            @"^(I|Q|M|V|IB|QB|MB|VB|IW|QW|MW|VW|ID|QD|MD|VD)(\d+)(?:\.(\d))?$");
+        if (!match.Success) return null;
+
+        var prefix = match.Groups[1].Value;
+        var byteOffset = int.Parse(match.Groups[2].Value);
+        var bitIndex = match.Groups[3].Success ? int.Parse(match.Groups[3].Value) : -1;
+
+        AreaType area;
+        int length;
+
+        switch (prefix)
+        {
+            case "I":  case "IB": area = AreaType.Input;    length = 1; break;
+            case "IW":            area = AreaType.Input;    length = 2; break;
+            case "ID":            area = AreaType.Input;    length = 4; break;
+            case "Q":  case "QB": area = AreaType.Output;   length = 1; break;
+            case "QW":            area = AreaType.Output;   length = 2; break;
+            case "QD":            area = AreaType.Output;   length = 4; break;
+            case "M":  case "MB": area = AreaType.Marker;   length = 1; break;
+            case "MW":            area = AreaType.Marker;   length = 2; break;
+            case "MD":            area = AreaType.Marker;   length = 4; break;
+            case "V":  case "VB": area = AreaType.Variable; length = 1; break;
+            case "VW":            area = AreaType.Variable; length = 2; break;
+            case "VD":            area = AreaType.Variable; length = 4; break;
+            default: return null;
+        }
+
+        return new ParsedAddress(area, byteOffset, bitIndex, length, address);
+    }
+
+    /// <summary>
+    /// 地址组(用于批量读取)
+    /// </summary>
+    public record AddressGroup(
+        AreaType Area,
+        int StartByte,
+        int TotalBytes,
+        List<(string Key, int Offset)> Items
+    );
+
+    /// <summary>
+    /// 将地址列表分组为连续的批量读取组
+    /// </summary>
+    /// <param name="addresses">地址列表 (key, address)</param>
+    /// <param name="maxGroupBytes">单个批次的最大字节数(默认 200)</param>
+    public static List<AddressGroup> GroupConsecutiveAddresses(
+        List<(string Key, string Address)> addresses,
+        int maxGroupBytes = 200)
+    {
+        var groups = new List<AddressGroup>();
+
+        // 按区域分组
+        var byArea = addresses
+            .Select(a => (a.Key, Parsed: Parse(a.Address)))
+            .Where(a => a.Parsed != null)
+            .GroupBy(a => a.Parsed!.Area);
+
+        foreach (var areaGroup in byArea)
+        {
+            var sorted = areaGroup
+                .OrderBy(a => a.Parsed!.ByteOffset)
+                .ToList();
+
+            var currentGroup = new List<(string Key, int Offset)>();
+            int groupStart = 0;
+            int groupEnd = 0;
+
+            foreach (var item in sorted)
+            {
+                var offset = item.Parsed!.ByteOffset;
+                var length = item.Parsed.ByteLength;
+
+                if (currentGroup.Count == 0)
+                {
+                    groupStart = offset;
+                    groupEnd = offset + length;
+                    currentGroup.Add((item.Key, offset - groupStart));
+                }
+                else if (offset + length <= groupStart + maxGroupBytes &&
+                         offset <= groupEnd + 4) // 允许最多 4 字节间隔
+                {
+                    groupEnd = Math.Max(groupEnd, offset + length);
+                    currentGroup.Add((item.Key, offset - groupStart));
+                }
+                else
+                {
+                    // 保存当前组,开始新组
+                    if (currentGroup.Count > 0)
+                    {
+                        groups.Add(new AddressGroup(
+                            areaGroup.Key,
+                            groupStart,
+                            groupEnd - groupStart,
+                            currentGroup
+                        ));
+                    }
+                    groupStart = offset;
+                    groupEnd = offset + length;
+                    currentGroup = new List<(string Key, int Offset)> { (item.Key, 0) };
+                }
+            }
+
+            if (currentGroup.Count > 0)
+            {
+                groups.Add(new AddressGroup(
+                    areaGroup.Key,
+                    groupStart,
+                    groupEnd - groupStart,
+                    currentGroup
+                ));
+            }
+        }
+
+        return groups;
+    }
+
+    /// <summary>
+    /// 将区域类型转换为可识别的地址前缀
+    /// </summary>
+    public static string GetAddressPrefix(AreaType area, int byteLength)
+    {
+        var prefix = area switch
+        {
+            AreaType.Input    => "I",
+            AreaType.Output   => "Q",
+            AreaType.Marker   => "M",
+            AreaType.Variable => "V",
+            _ => "V"
+        };
+
+        return byteLength switch
+        {
+            1 => prefix,
+            2 => $"{prefix}W",
+            4 => $"{prefix}D",
+            _ => prefix
+        };
+    }
+}
+```
+
+### 批量读取使用示例
+
+```csharp
+// 待读取的地址列表
+var addresses = new List<(string key, string addr)>
+{
+    ("Tank1Level", "VD100"),
+    ("Tank2Level", "VD104"),
+    ("Tank3Level", "VD108"),
+    ("Tank4Level", "VD112"),
+    ("InflowRate", "VD200"),
+    ("OutflowRate", "VD204"),
+};
+
+// 自动分组
+var groups = S7AddressParser.GroupConsecutiveAddresses(addresses);
+// 结果:
+//   组1: VD100-VD112 (4个地址, 16字节) → 一次读取
+//   组2: VD200-VD204 (2个地址, 8字节)  → 一次读取
+// 原需 6 次通信 → 优化为 2 次
+
+// 逐组批量读取
+var results = new Dictionary<string, float>();
+foreach (var group in groups)
+{
+    var prefix = S7AddressParser.GetAddressPrefix(group.Area, group.TotalBytes switch
+    {
+        >= 4 => 4, >= 2 => 2, _ => 1
+    });
+    var batchAddr = $"{prefix}{group.StartByte}";
+
+    var bytes = await plc.ReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
+    if (bytes == null) continue;
+
+    foreach (var (key, offset) in group.Items)
+    {
+        if (offset + 4 <= bytes.Length)
+            results[key] = ParseFloat(bytes, offset);
+    }
+}
+```
+
+---
+
+## 数据模型 PlcDataModel
+
+内存数据快照,UI 通过此模型绑定显示数据。
+
+```csharp
+using System.Collections.Concurrent;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+/// <summary>
+/// PLC 数据模型 - 内存数据快照
+/// </summary>
+public partial class PlcDataModel : ObservableObject
+{
+    // ═══ 水箱液位 ═══
+    [ObservableProperty] private float _tank1Level;
+    [ObservableProperty] private float _tank2Level;
+    [ObservableProperty] private float _tank3Level;
+    [ObservableProperty] private float _tank4Level;
+
+    // ═══ 流量 ═══
+    [ObservableProperty] private float _inflowRate;
+    [ObservableProperty] private float _outflowRate;
+    [ObservableProperty] private float _flowDelta;
+
+    // ═══ 泵状态 (0-4) ═══
+    [ObservableProperty] private bool _pump1Running;
+    [ObservableProperty] private bool _pump2Running;
+    [ObservableProperty] private bool _pump3Running;
+    [ObservableProperty] private bool _pump4Running;
+    [ObservableProperty] private bool _pump5Running;
+
+    // 泵故障
+    [ObservableProperty] private bool _pump1Fault;
+    [ObservableProperty] private bool _pump2Fault;
+    [ObservableProperty] private bool _pump3Fault;
+    [ObservableProperty] private bool _pump4Fault;
+    [ObservableProperty] private bool _pump5Fault;
+
+    // 泵频率
+    [ObservableProperty] private float _pump1Freq;
+    [ObservableProperty] private float _pump2Freq;
+    [ObservableProperty] private float _pump3Freq;
+    [ObservableProperty] private float _pump4Freq;
+    [ObservableProperty] private float _pump5Freq;
+
+    // 泵电流
+    [ObservableProperty] private float _pump1Current;
+    [ObservableProperty] private float _pump2Current;
+    [ObservableProperty] private float _pump3Current;
+    [ObservableProperty] private float _pump4Current;
+    [ObservableProperty] private float _pump5Current;
+
+    // ═══ 风机状态 ═══
+    [ObservableProperty] private bool _fan1Running;
+    [ObservableProperty] private bool _fan2Running;
+    [ObservableProperty] private bool _fan1Fault;
+    [ObservableProperty] private bool _fan2Fault;
+
+    // ═══ 阀门位置 (0-100, 0=全关, 100=全开) ═══
+    [ObservableProperty] private int _valve1Position;
+    [ObservableProperty] private int _valve2Position;
+    [ObservableProperty] private int _valve3Position;
+    [ObservableProperty] private int _valve4Position;
+
+    // ═══ 系统状态 ═══
+    [ObservableProperty] private bool _isAutoMode;
+
+    // ═══ 数据质量 ═══
+    [ObservableProperty] private DateTime _lastReadTime;
+    [ObservableProperty] private bool _isDataValid;
+    [ObservableProperty] private bool _isPlcConnected;
+
+    // ═══ 动态寄存器(由项目配置驱动) ═══
+    /// <summary>模拟量动态寄存器(名称 → 值)</summary>
+    public ConcurrentDictionary<string, float> AnalogRegisters { get; } = new();
+
+    /// <summary>数字量动态寄存器(名称 → 值)</summary>
+    public ConcurrentDictionary<string, bool> DigitalRegisters { get; } = new();
+
+    public float GetAnalogRegister(string key, float defaultValue = 0)
+        => AnalogRegisters.TryGetValue(key, out var value) ? value : defaultValue;
+
+    public bool GetDigitalRegister(string key, bool defaultValue = false)
+        => DigitalRegisters.TryGetValue(key, out var value) ? value : defaultValue;
+
+    // ═══ 计算属性 ═══
+    public int RunningPumpCount => (Pump1Running ? 1 : 0) + (Pump2Running ? 1 : 0) +
+                                   (Pump3Running ? 1 : 0) + (Pump4Running ? 1 : 0) +
+                                   (Pump5Running ? 1 : 0);
+
+    public bool HasAnyFault => Pump1Fault || Pump2Fault || Pump3Fault || Pump4Fault ||
+                               Pump5Fault || Fan1Fault || Fan2Fault;
+}
+```
+
+---
+
+## 轮询服务模板(完整版)
+
+包含:**并发控制**、**死区过滤**、**降级模式**、**指数退避重连**、**批量读取优化**。
+
+```csharp
+using System.Collections.Concurrent;
+using HslCommunication;
+using HslCommunication.Profinet.Siemens;
+using Microsoft.Extensions.Logging;
+
+public class PlcPollingService : IDisposable
+{
+    private SiemensS7Net? _plc;
+    private bool _isConnected;
+    private CancellationTokenSource? _cts;
+    private Task? _pollingTask;
+    private readonly PlcDataModel _data = new();
+
+    // PLC 读取并发限制(防止同时发起过多请求导致 PLC 过载)
+    private static readonly SemaphoreSlim _plcReadSemaphore = new(4, 4);
+
+    private readonly ILogger? _logger;
+
+    // ═══ 配置 ═══
+    public int PollingIntervalMs { get; set; } = 1000;
+    public float DeadbandPercent { get; set; } = 0.5f;
+    public int MaxReconnectAttempts { get; set; } = 10;
+    public int ReconnectBaseDelayMs { get; set; } = 1000;
+
+    // ═══ 事件 ═══
+    public event Action<PlcDataModel>? DataUpdated;
+    public event Action<bool>? ConnectionStateChanged;
+    public event Action<string>? ErrorOccurred;
+
+    public PlcDataModel Data => _data;
+    public bool IsConnected => _isConnected;
+
+    public PlcPollingService(ILogger? logger = null)
+    {
+        _logger = logger;
+    }
+
+    /// <summary>启动轮询</summary>
+    public void Start(string ip, int port = 102, SiemensPLCS series = SiemensPLCS.S200Smart)
+    {
+        if (_pollingTask != null && !_pollingTask.IsCompleted) return;
+
+        _cts = new CancellationTokenSource();
+        _pollingTask = Task.Run(() => PollingLoopAsync(ip, port, series, _cts.Token));
+    }
+
+    /// <summary>停止轮询</summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        _pollingTask?.Wait(TimeSpan.FromSeconds(5));
+        _cts?.Dispose();
+        _cts = null;
+        _pollingTask = null;
+        Disconnect();
+    }
+
+    private void Disconnect()
+    {
+        _plc?.ConnectClose();
+        _plc?.Dispose();
+        _plc = null;
+        _isConnected = false;
+    }
+
+    /// <summary>轮询主循环</summary>
+    private async Task PollingLoopAsync(string ip, int port, SiemensPLCS series, CancellationToken ct)
+    {
+        var reconnectAttempts = 0;
+        var isInDegradedMode = false;
+        const int degradedModeRetryIntervalMs = 30000;
+
+        while (!ct.IsCancellationRequested)
+        {
+            try
+            {
+                // 1. 确保连接
+                if (!_isConnected)
+                {
+                    var connected = await TryConnectWithRetryAsync(ip, port, series, reconnectAttempts, ct);
+                    
+                    if (!connected)
+                    {
+                        reconnectAttempts++;
+
+                        // 进入或保持在降级模式
+                        if (reconnectAttempts >= MaxReconnectAttempts)
+                        {
+                            if (!isInDegradedMode)
+                            {
+                                isInDegradedMode = true;
+                                ErrorOccurred?.Invoke(
+                                    $"PLC 连接失败,已重试 {MaxReconnectAttempts} 次,进入降级模式(每 30 秒重试)");
+                            }
+                            await Task.Delay(degradedModeRetryIntervalMs, ct);
+                            continue;
+                        }
+
+                        // 正常模式:指数退避
+                        var delayMs = ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5);
+                        await Task.Delay(delayMs, ct);
+                        continue;
+                    }
+
+                    // 连接成功
+                    if (isInDegradedMode)
+                    {
+                        isInDegradedMode = false;
+                        ErrorOccurred?.Invoke("PLC 连接已恢复");
+                    }
+                    reconnectAttempts = 0;
+                }
+
+                // 2. 读取数据
+                await ReadAllDataAsync(ct);
+
+                // 3. 检测连接状态变化
+                ConnectionStateChanged?.Invoke(_isConnected);
+
+                // 4. 数据更新事件
+                DataUpdated?.Invoke(_data);
+
+                // 5. 等待下一周期
+                var nextDelay = isInDegradedMode ? degradedModeRetryIntervalMs : PollingIntervalMs;
+                await Task.Delay(nextDelay, ct);
+            }
+            catch (OperationCanceledException) { break; }
+            catch (Exception ex)
+            {
+                _logger?.LogError(ex, "轮询异常");
+                ErrorOccurred?.Invoke($"轮询异常: {ex.Message}");
+                if (!_isConnected) _data.IsPlcConnected = false;
+                await Task.Delay(PollingIntervalMs * 2, ct);
+            }
+        }
+    }
+
+    /// <summary>尝试连接(带重试)</summary>
+    private async Task<bool> TryConnectWithRetryAsync(
+        string ip, int port, SiemensPLCS series, int attempt, CancellationToken ct)
+    {
+        for (int i = 0; i <= attempt && i < 3; i++)
+        {
+            if (ct.IsCancellationRequested) return false;
+
+            var connected = await ConnectAsync(ip, port, series);
+            if (connected)
+            {
+                _data.IsPlcConnected = true;
+                ConnectionStateChanged?.Invoke(true);
+                return true;
+            }
+
+            if (i < attempt)
+                await Task.Delay(ReconnectBaseDelayMs * (i + 1), ct);
+        }
+        return false;
+    }
+
+    private async Task<bool> ConnectAsync(string ip, int port, SiemensPLCS series)
+    {
+        try
+        {
+            _plc = new SiemensS7Net(series, ip)
+            {
+                Port = port,
+                ConnectTimeOut = 3000,
+                ReceiveTimeOut = 3000
+            };
+
+            var result = await _plc.ConnectServerAsync();
+            _isConnected = result.IsSuccess;
+            
+            if (_isConnected)
+                _logger?.LogInformation("PLC 连接成功: {Ip}:{Port}", ip, port);
+            else
+                _logger?.LogWarning("PLC 连接失败: {Message}", result.Message);
+
+            return _isConnected;
+        }
+        catch (Exception ex)
+        {
+            _logger?.LogError(ex, "PLC 连接异常");
+            return false;
+        }
+    }
+
+    /// <summary>批量读取所有 PLC 数据</summary>
+    private async Task ReadAllDataAsync(CancellationToken ct)
+    {
+        if (ct.IsCancellationRequested) return;
+
+        // 并行发起所有批量读取
+        var tankTask  = ReadBytesAsync(PlcConfig.Tank1Level, 16);  // 4 floats: 水箱液位
+        var flowTask  = ReadBytesAsync(PlcConfig.InflowRate, 8);   // 2 floats: 流量
+        var freqTask  = ReadBytesAsync(PlcConfig.Pump1Freq, 20);   // 5 floats: 泵频率
+        var qTask     = ReadBytesAsync("Q0", 1);                   // 1 byte: 泵/风机运行状态
+        var iTask     = ReadBytesAsync("I0", 1);                   // 1 byte: 泵/风机故障状态
+        var mTask     = ReadBytesAsync("M0", 1);                   // 1 byte: 系统模式
+
+        await Task.WhenAll(tankTask, flowTask, freqTask, qTask, iTask, mTask);
+
+        if (ct.IsCancellationRequested) return;
+
+        var tankData = tankTask.Result;
+        var flowData = flowTask.Result;
+        var freqData = freqTask.Result;
+        var qData    = qTask.Result;
+        var iData    = iTask.Result;
+        var mData    = mTask.Result;
+
+        // 任一批量读取失败则跳过本周期
+        if (tankData == null || flowData == null || freqData == null ||
+            qData == null || iData == null || mData == null)
+        {
+            _data.IsDataValid = false;
+            _data.IsPlcConnected = _isConnected;
+            return;
+        }
+
+        // 解析水箱液位 (VD100-VD112, 4 floats)
+        var tank1 = ParseFloat(tankData, 0);
+        var tank2 = ParseFloat(tankData, 4);
+        var tank3 = ParseFloat(tankData, 8);
+        var tank4 = ParseFloat(tankData, 12);
+
+        // 解析流量 (VD200-VD204, 2 floats)
+        var inflow  = ParseFloat(flowData, 0);
+        var outflow = ParseFloat(flowData, 4);
+
+        // 解析泵频率 (VD300-VD316, 5 floats)
+        var f1 = ParseFloat(freqData, 0);
+        var f2 = ParseFloat(freqData, 4);
+        var f3 = ParseFloat(freqData, 8);
+        var f4 = ParseFloat(freqData, 12);
+        var f5 = ParseFloat(freqData, 16);
+
+        // 解析 Q0 字节: bit 0-4 = 泵运行, bit 5-6 = 风机运行
+        var p1   = ParseBit(qData, 0, 0);
+        var p2   = ParseBit(qData, 0, 1);
+        var p3   = ParseBit(qData, 0, 2);
+        var p4   = ParseBit(qData, 0, 3);
+        var p5   = ParseBit(qData, 0, 4);
+        var fan1 = ParseBit(qData, 0, 5);
+        var fan2 = ParseBit(qData, 0, 6);
+
+        // 解析 I0 字节: bit 0-4 = 泵故障, bit 5-6 = 风机故障
+        var pf1   = ParseBit(iData, 0, 0);
+        var pf2   = ParseBit(iData, 0, 1);
+        var pf3   = ParseBit(iData, 0, 2);
+        var pf4   = ParseBit(iData, 0, 3);
+        var pf5   = ParseBit(iData, 0, 4);
+        var fanf1 = ParseBit(iData, 0, 5);
+        var fanf2 = ParseBit(iData, 0, 6);
+
+        // 解析 M0 字节: bit 0 = 系统模式
+        var autoMode = ParseBit(mData, 0, 0);
+
+        // 应用死区过滤后更新数据
+        ApplyWithDeadband(_data.Tank1Level, tank1, v => _data.Tank1Level = v);
+        ApplyWithDeadband(_data.Tank2Level, tank2, v => _data.Tank2Level = v);
+        ApplyWithDeadband(_data.Tank3Level, tank3, v => _data.Tank3Level = v);
+        ApplyWithDeadband(_data.Tank4Level, tank4, v => _data.Tank4Level = v);
+        ApplyWithDeadband(_data.InflowRate, inflow,   v => _data.InflowRate = v);
+        ApplyWithDeadband(_data.OutflowRate, outflow, v => _data.OutflowRate = v);
+
+        _data.Pump1Running = p1;  _data.Pump2Running = p2;  _data.Pump3Running = p3;
+        _data.Pump4Running = p4;  _data.Pump5Running = p5;
+
+        _data.Pump1Fault = pf1;  _data.Pump2Fault = pf2;  _data.Pump3Fault = pf3;
+        _data.Pump4Fault = pf4;  _data.Pump5Fault = pf5;
+
+        _data.Pump1Freq = f1;  _data.Pump2Freq = f2;  _data.Pump3Freq = f3;
+        _data.Pump4Freq = f4;  _data.Pump5Freq = f5;
+
+        _data.Fan1Running = fan1;  _data.Fan2Running = fan2;
+        _data.Fan1Fault = fanf1;   _data.Fan2Fault = fanf2;
+
+        _data.IsAutoMode = autoMode;
+        _data.LastReadTime = DateTime.Now;
+        _data.IsDataValid = true;
+        _data.IsPlcConnected = _isConnected;
+        _data.FlowDelta = _data.InflowRate - _data.OutflowRate;
+    }
+
+    /// <summary>
+    /// 带并发控制的字节读取
+    /// </summary>
+    private async Task<byte[]?> ReadBytesAsync(string address, ushort length)
+    {
+        if (!_isConnected || _plc == null) return null;
+
+        await _plcReadSemaphore.WaitAsync();
+        try
+        {
+            var result = await _plc.ReadAsync(address, length);
+            if (!result.IsSuccess)
+            {
+                _isConnected = false;
+                return null;
+            }
+            return result.Content;
+        }
+        catch
+        {
+            _isConnected = false;
+            return null;
+        }
+        finally
+        {
+            _plcReadSemaphore.Release();
+        }
+    }
+
+    /// <summary>
+    /// 死区过滤:只有变化超过阈值才更新(减少 UI 无效刷新)
+    /// </summary>
+    private void ApplyWithDeadband(float currentValue, float newValue, Action<float> setter)
+    {
+        if (Math.Abs(currentValue) < 0.001f && Math.Abs(newValue) < 0.001f)
+            return; // 都是零值,跳过
+
+        var threshold = Math.Abs(currentValue) * DeadbandPercent / 100.0f;
+        if (Math.Abs(newValue - currentValue) > threshold || currentValue == 0)
+        {
+            setter(newValue);
+        }
+    }
+
+    private static float ParseFloat(byte[] data, int offset)
+    {
+        if (data == null || offset + 4 > data.Length) return 0f;
+        if (BitConverter.IsLittleEndian)
+        {
+            var bytes = new byte[] { 
+                data[offset + 3], data[offset + 2], 
+                data[offset + 1], data[offset] 
+            };
+            return BitConverter.ToSingle(bytes, 0);
+        }
+        return BitConverter.ToSingle(data, offset);
+    }
+
+    private static bool ParseBit(byte[] data, int byteOffset, int bitOffset)
+    {
+        if (data == null || byteOffset >= data.Length) return false;
+        return (data[byteOffset] & (1 << bitOffset)) != 0;
+    }
+
+    public void Dispose() => Stop();
+}
+```
+
+### 关键机制说明
+
+| 机制 | 实现方式 | 说明 |
+|------|----------|------|
+| **并发控制** | `SemaphoreSlim(4, 4)` | 限制同时发起的 PLC 读取请求数,防止 PLC 过载 |
+| **死区过滤** | `ApplyWithDeadband()` | 数据变化小于阈值(默认 0.5%)时不更新,减少 UI 无效刷新 |
+| **降级模式** | `isInDegradedMode` | 连接失败超过 `MaxReconnectAttempts` 次后进入,重试间隔从 1s 延长到 30s |
+| **指数退避** | `ReconnectBaseDelayMs * attempts` | 重连间隔逐步增加,上限 30s,避免频繁冲击 PLC |
+| **并行批量读取** | `Task.WhenAll()` | 多个地址段并行发起读取,充分利用网络带宽 |
+
+---
+
+## 地址格式参考
+
+### 存储区域
+
+| 区域 | 说明 | 地址前缀 |
+|------|------|----------|
+| V | 变量存储区(Variable) | `V` / `VB` / `VW` / `VD` |
+| M | 标志位(Marker) | `M` / `MB` / `MW` / `MD` |
+| I | 输入映像区(Input) | `I` / `IB` / `IW` / `ID` |
+| Q | 输出映像区(Output) | `Q` / `QB` / `QW` / `QD` |
+
+### 数据类型与地址格式
+
+| 数据类型 | 字节长度 | 地址格式示例 | 说明 |
+|----------|----------|--------------|------|
+| Bool(位) | 1 bit | `M10.0`, `Q0.5` | 字节偏移.位索引 |
+| Byte | 1 | `VB100`, `MB10` | 单字节 |
+| Word/INT | 2 | `VW100`, `MW10` | 16位整数 |
+| DWord/REAL | 4 | `VD100`, `MD10` | 32位浮点数 |
+| DWord/DINT | 4 | `VD100`, `MD10` | 32位整数 |
+
+### 常用地址示例
+
+```csharp
+// 浮点数读取
+"VD100"   // V区第100字节开始的双字(4字节浮点数)
+"MD50"    // M区第50字节开始的双字
+
+// 整数读取
+"VW100"   // V区第100字节开始的字(2字节)
+"MW10"    // M区第10字节开始的字
+
+// 布尔值读取
+"M10.0"   // M区第10字节的第0位
+"Q0.5"    // Q区第0字节的第5位
+"V100.3"  // V区第100字节的第3位
+
+// 字节读取
+"VB100"   // V区第100字节
+"MB10"    // M区第10字节
+```
+
+---
+
+## 配置文件结构
+
+项目使用 JSON 配置文件管理 PLC 连接参数,支持运行时修改。
+
+### 配置文件示例(yzwater-config.json)
+
+```json
+{
+    "PlcIp": "192.168.2.100",
+    "PlcPort": 102,
+    "AutoConnect": true,
+    "PlcProtocol": "SiemensS7",
+
+    "LevelHighAlarm": 80.0,
+    "LevelLowAlarm": 20.0,
+    "FlowHighAlarm": 100.0,
+    "FlowLowAlarm": 5.0,
+
+    "PlcAddresses": {
+        "Tank1Level": "VD100",
+        "Tank2Level": "VD104",
+        "InflowRate": "VD200",
+        "OutflowRate": "VD204",
+        "Pump1Freq": "VD300",
+        "Pump1Current": "VD320"
+    }
+}
+```
+
+### 配置模型类
+
+```csharp
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+public class SystemConfig
+{
+    // PLC 连接
+    public string PlcIp { get; set; } = "192.168.2.100";
+    public int PlcPort { get; set; } = 102;
+    public bool AutoConnect { get; set; } = true;
+    public string PlcProtocol { get; set; } = "SiemensS7";
+
+    // 报警阈值
+    public float LevelHighAlarm { get; set; } = 80f;
+    public float LevelLowAlarm { get; set; } = 20f;
+    public float FlowHighAlarm { get; set; } = 100f;
+    public float FlowLowAlarm { get; set; } = 5f;
+
+    // 地址覆盖
+    public Dictionary<string, string>? PlcAddresses { get; set; }
+}
+
+public static class ConfigService
+{
+    private static SystemConfig? _config;
+    private static readonly string ConfigPath = "yzwater-config.json";
+
+    public static SystemConfig GetConfig()
+    {
+        if (_config == null) Load();
+        return _config ?? new SystemConfig();
+    }
+
+    public static void Load()
+    {
+        if (File.Exists(ConfigPath))
+        {
+            var json = File.ReadAllText(ConfigPath);
+            _config = JsonSerializer.Deserialize<SystemConfig>(json);
+        }
+        _config ??= new SystemConfig();
+    }
+
+    public static void Save()
+    {
+        if (_config == null) return;
+        var options = new JsonSerializerOptions { WriteIndented = true };
+        var json = JsonSerializer.Serialize(_config, options);
+        File.WriteAllText(ConfigPath, json);
+    }
+}
+```
+
+### 配置加载顺序
+
+```csharp
+// 在程序启动时按顺序加载:
+
+// 1. 加载主配置文件
+ConfigService.Load();
+
+// 2. 加载地址覆盖(覆盖默认硬编码地址)
+PlcConfig.LoadFromConfig();
+
+// 3. 初始化 PLC 服务
+PlcService.Initialize();
+```
+
+---
+
+## 项目文件结构
+
+迁移时建议按以下结构组织文件:
+
+```
+YourProject/
+├── Services/
+│   ├── Plc/
+│   │   ├── SiemensPlcHelper.cs          # 基础连接类
+│   │   ├── PlcPollingService.cs         # 轮询服务(完整版)
+│   │   ├── PlcConfig.cs                 # 地址配置管理
+│   │   └── S7AddressParser.cs           # 地址解析器
+│   │
+│   ├── ConfigService.cs                 # 配置服务
+│   └── NotificationService.cs           # 通知服务(可选)
+│
+├── Models/
+│   ├── SystemConfig.cs                  # 配置模型
+│   └── PlcDataModel.cs                  # PLC 数据模型
+│
+├── ViewModels/                          # MVVM 项目
+│   └── MainViewModel.cs
+│
+├── Views/                               # MVVM 项目
+│   └── MainWindow.axaml
+│
+├── yzwater-config.json                  # 运行时配置
+├── plc-addresses.json                   # 地址覆盖(可选)
+└── YourProject.csproj
+```
+
+### NuGet 依赖(csproj)
+
+```xml
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net8.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <!-- PLC 通信 -->
+    <PackageReference Include="HslCommunication" Version="11.8.2" />
+    
+    <!-- MVVM(可选,WPF/Avalonia 项目) -->
+    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+    
+    <!-- 日志 -->
+    <PackageReference Include="Serilog" Version="4.0.1" />
+    <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
+    <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
+    
+    <!-- JSON 配置 -->
+    <PackageReference Include="System.Text.Json" Version="8.0.4" />
+  </ItemGroup>
+</Project>
+```
+
+---
+
+## 使用示例
+
+### 基础使用
+
+```csharp
+// 创建连接
+var plc = new SiemensPlcHelper();
+var connected = await plc.ConnectAsync("192.168.0.150", 102);
+
+if (connected)
+{
+    // 读取浮点数
+    var level = await plc.ReadFloatAsync("VD100");
+    Console.WriteLine($"液位: {level:F2}");
+
+    // 读取布尔值
+    var pumpRun = await plc.ReadBoolAsync("M10.0");
+    Console.WriteLine($"泵运行: {pumpRun}");
+
+    // 写入浮点数
+    await plc.WriteFloatAsync("VD200", 123.45f);
+
+    // 写入布尔值
+    await plc.WriteBoolAsync("M20.0", true);
+
+    // 批量读取
+    var bytes = await plc.ReadBytesAsync("V100", 20);
+    if (bytes != null)
+    {
+        for (int i = 0; i < 5; i++)
+        {
+            var value = ParseFloat(bytes, i * 4);
+            Console.WriteLine($"VD{100 + i * 4}: {value:F2}");
+        }
+    }
+}
+
+// 断开连接
+plc.Disconnect();
+```
+
+### 使用轮询服务
+
+```csharp
+// 创建轮询服务
+var polling = new PlcPollingService();
+
+// 订阅事件
+polling.DataUpdated += data => 
+{
+    Console.WriteLine($"液位: {data.GetValueOrDefault("TankLevel"):F2}");
+    Console.WriteLine($"流量: {data.GetValueOrDefault("FlowRate"):F2}");
+};
+
+polling.ConnectionStateChanged += connected =>
+{
+    Console.WriteLine(connected ? "PLC 已连接" : "PLC 已断开");
+};
+
+polling.ErrorOccurred += error =>
+{
+    Console.WriteLine($"错误: {error}");
+};
+
+// 启动(IP: 192.168.0.150, 端口: 102, S7-200Smart)
+polling.Start("192.168.0.150", 102, SiemensPLCS.S200Smart);
+
+// 程序退出时停止
+Console.WriteLine("按任意键停止...");
+Console.ReadKey();
+polling.Stop();
+```
+
+---
+
+## 多协议驱动抽象(可选扩展)
+
+如果项目未来可能需要切换 PLC 品牌(三菱/欧姆龙/Modbus 等),可以使用统一的驱动接口抽象。
+
+### IPlcDriver 接口
+
+```csharp
+using HslCommunication;
+
+/// <summary>
+/// PLC 驱动接口 - 统一的 PLC 通信抽象
+/// </summary>
+public interface IPlcDriver : IDisposable
+{
+    string Name { get; }
+    bool IsConnected { get; }
+
+    Task<OperateResult> ConnectAsync();
+    void Disconnect();
+
+    Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length);
+    Task<OperateResult<float>> ReadFloatAsync(string address);
+    Task<OperateResult<bool>> ReadBoolAsync(string address);
+
+    Task<OperateResult> WriteAsync(string address, byte[] value);
+    Task<OperateResult> WriteFloatAsync(string address, float value);
+    Task<OperateResult> WriteBoolAsync(string address, bool value);
+}
+
+/// <summary>
+/// PLC 协议类型
+/// </summary>
+public enum PlcProtocol
+{
+    SiemensS7,       // 西门子 S7
+    MitsubishiMC,    // 三菱 MC
+    OmronFins,       // 欧姆龙 Fins
+    ModbusTcp,       // Modbus TCP
+    AllenBradley     // AB EtherNet/IP
+}
+```
+
+### SiemensS7Driver 实现
+
+```csharp
+using HslCommunication;
+using HslCommunication.Profinet.Siemens;
+
+public class SiemensS7Driver : IPlcDriver
+{
+    private readonly SiemensS7Net _plc;
+    private bool _disposed;
+    private bool _isConnected;
+
+    public string Name => "Siemens S7";
+    public bool IsConnected => _isConnected && !_disposed;
+
+    public SiemensS7Driver(SiemensPLCS plcSeries = SiemensPLCS.S200Smart)
+    {
+        _plc = new SiemensS7Net(plcSeries);
+    }
+
+    public void SetConnection(string ipAddress, int port = 102)
+    {
+        _plc.IpAddress = ipAddress;
+        _plc.Port = port;
+    }
+
+    public async Task<OperateResult> ConnectAsync()
+    {
+        var result = await _plc.ConnectServerAsync();
+        _isConnected = result.IsSuccess;
+        return result;
+    }
+
+    public void Disconnect()
+    {
+        _plc.ConnectClose();
+        _isConnected = false;
+    }
+
+    public async Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length)
+        => await _plc.ReadAsync(address, length);
+
+    public async Task<OperateResult<float>> ReadFloatAsync(string address)
+        => await _plc.ReadFloatAsync(address);
+
+    public async Task<OperateResult<bool>> ReadBoolAsync(string address)
+        => await _plc.ReadBoolAsync(address);
+
+    public async Task<OperateResult> WriteAsync(string address, byte[] value)
+        => await _plc.WriteAsync(address, value);
+
+    public async Task<OperateResult> WriteFloatAsync(string address, float value)
+        => await _plc.WriteAsync(address, value);
+
+    public async Task<OperateResult> WriteBoolAsync(string address, bool value)
+        => await _plc.WriteAsync(address, value);
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Disconnect();
+        _plc.Dispose();
+    }
+}
+```
+
+### PlcDriverFactory 工厂
+
+```csharp
+public static class PlcDriverFactory
+{
+    public static IPlcDriver Create(PlcProtocol protocol, string ipAddress, int port)
+    {
+        IPlcDriver driver = protocol switch
+        {
+            PlcProtocol.SiemensS7 => CreateSiemensS7(ipAddress, port),
+            // 可扩展其他品牌...
+            _ => throw new NotSupportedException($"不支持的 PLC 协议: {protocol}")
+        };
+        return driver;
+    }
+
+    private static SiemensS7Driver CreateSiemensS7(string ipAddress, int port)
+    {
+        var driver = new SiemensS7Driver(SiemensPLCS.S200Smart);
+        driver.SetConnection(ipAddress, port);
+        return driver;
+    }
+
+    public static int GetDefaultPort(PlcProtocol protocol) => protocol switch
+    {
+        PlcProtocol.SiemensS7     => 102,
+        PlcProtocol.MitsubishiMC  => 6000,
+        PlcProtocol.OmronFins     => 9600,
+        PlcProtocol.ModbusTcp     => 502,
+        PlcProtocol.AllenBradley  => 44818,
+        _ => throw new NotSupportedException($"不支持的 PLC 协议: {protocol}")
+    };
+}
+```
+
+---
+
+## 支持的 PLC 系列
+
+| 枚举值 | PLC 型号 | 说明 |
+|--------|----------|------|
+| `SiemensPLCS.S200` | S7-200 | 老型号 |
+| `SiemensPLCS.S200Smart` | S7-200 Smart | 默认,常用 |
+| `SiemensPLCS.S300` | S7-300 | 中大型 |
+| `SiemensPLCS.S400` | S7-400 | 大型 |
+| `SiemensPLCS.S1200` | S7-1200 | 新型紧凑型 |
+| `SiemensPLCS.S1500` | S7-1500 | 新型高性能 |
+
+### 指定 PLC 系列
+
+```csharp
+// S7-1200
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S1200);
+
+// S7-1500
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S1500);
+
+// S7-300
+await plc.ConnectAsync("192.168.0.1", 102, SiemensPLCS.S300);
+```
+
+---
+
+## 常见问题
+
+### 连接失败
+
+1. 检查 IP 地址和端口是否正确
+2. 确认 PLC 与 PC 网络连通(ping 测试)
+3. 检查 PLC 是否已启用 PUT/GET 通信
+4. 确认防火墙未阻止 102 端口
+
+### 读取数据为 0
+
+1. 确认地址格式正确(如 VD100 而非 V100)
+2. 检查数据类型是否匹配(浮点数用 VD,整数用 VW)
+3. 检查字节序是否正确(使用 ParseFloat 方法)
+
+### 读取数据异常
+
+1. 确认 PLC 系列选择正确
+2. 检查地址范围是否超出 PLC 实际配置
+3. 使用 Wireshark 抓包分析通信数据
+
+### 批量读取失败
+
+1. 确认地址在同一区域内连续(不能跨区)
+2. 读取长度不要超过 PLC 限制(S7-200 最大 240 字节)
+3. 使用 `S7AddressParser.GroupConsecutiveAddresses()` 自动分组
+
+### 轮询服务 CPU 占用高
+
+1. 适当增大 `PollingIntervalMs`(默认 1000ms)
+2. 增大 `DeadbandPercent` 减少 UI 刷新次数
+3. 使用批量读取替代多次单独读取
+
+---
+
+## 日志集成(Serilog)
+
+```csharp
+using Serilog;
+
+// 初始化日志(程序启动时调用一次)
+public static void InitializeLogging()
+{
+    Log.Logger = new LoggerConfiguration()
+        .MinimumLevel.Information()
+        .WriteTo.Console()
+        .WriteTo.File(
+            path: "Logs/log-.txt",
+            rollingInterval: RollingInterval.Day,
+            outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+        .CreateLogger();
+}
+
+// 在程序退出时关闭
+public static void CloseLogging()
+{
+    Log.CloseAndFlush();
+}
+```
+
+### 日志使用示例
+
+```csharp
+Log.Information("PLC 连接成功: {Ip}:{Port}", ip, port);
+Log.Warning("PLC 连接失败: {Message}", result.Message);
+Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
+Log.Debug("批量读取优化: {Count} 个地址合并为 {Groups} 次读取", count, groups);
+```
+
+---
+
+## 快速迁移清单
+
+将以下文件复制到新项目即可完成迁移:
+
+### 必需文件(6 个)
+
+| 文件 | 功能 | 位置 |
+|------|------|------|
+| `SiemensPlcHelper.cs` | 基础连接类 | 连接建立章节 |
+| `PlcConfig.cs` | 地址配置管理 | 地址配置管理章节 |
+| `S7AddressParser.cs` | 地址解析 + 批量优化 | S7 地址解析器章节 |
+| `PlcDataModel.cs` | 数据模型 | 数据模型章节 |
+| `PlcPollingService.cs` | 轮询服务(完整版) | 轮询服务模板章节 |
+| `ConfigService.cs` + `SystemConfig.cs` | 配置服务 | 配置文件结构章节 |
+
+### 可选文件
+
+| 文件 | 功能 |
+|------|------|
+| `IPlcDriver.cs` | 多协议驱动接口 |
+| `SiemensS7Driver.cs` | S7 驱动封装 |
+| `PlcDriverFactory.cs` | 驱动工厂 |
+| `plc-addresses.json` | 地址覆盖配置 |
+
+### NuGet 依赖
+
+```xml
+<PackageReference Include="HslCommunication" Version="11.8.2" />
+<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
+<PackageReference Include="Serilog" Version="4.0.1" />
+<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
+<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
+```
+
+### 启动代码
+
+```csharp
+// 程序入口(Program.cs 或 App.OnStartup)
+public static async Task Main(string[] args)
+{
+    // 1. 初始化日志
+    InitializeLogging();
+
+    // 2. 设置 HslCommunication 授权
+    HslCommunication.Authorization.SetAuthorizationCode("你的授权码");
+
+    // 3. 加载配置
+    ConfigService.Load();
+    PlcConfig.LoadFromConfig();
+
+    // 4. 启动轮询
+    var polling = new PlcPollingService(Log.ForContext<PlcPollingService>());
+    polling.DataUpdated += data => { /* 处理数据更新 */ };
+    polling.ConnectionStateChanged += connected => { /* 处理连接状态 */ };
+    polling.ErrorOccurred += error => { /* 处理错误 */ };
+
+    var config = ConfigService.GetConfig();
+    polling.Start(config.PlcIp, config.PlcPort);
+
+    // 5. 程序退出时
+    // polling.Stop();
+    // ConfigService.Save();
+    // Log.CloseAndFlush();
+}
+```
+
+---
+
+## 许可证
+
+本文档基于 YZWater3 项目提取,仅供参考使用。HslCommunication 库请遵循其官方许可证。

+ 2 - 1
projects/yangjie2.json

@@ -159,7 +159,8 @@
   },
   "statistics": {
     "items": [
-      { "type": "SensorValue", "key": "总流量.Value", "displayName": "总流量", "displayNameEn": "Total Flow", "unit": "m³", "enabled": true, "chartGroup": "总流量", "aggType": "delta" }
+      { "type": "SensorValue", "key": "总流量.Value", "displayName": "总流量", "displayNameEn": "Total Flow", "unit": "m³", "enabled": true, "chartGroup": "总流量", "aggType": "delta" },
+      { "type": "SensorValue", "key": "流量.Value", "displayName": "瞬时流量", "displayNameEn": "Instant Flow", "unit": "m³/h", "enabled": true, "chartGroup": "流量", "aggType": "avg_max_min" }
     ]
   }
 }

+ 13 - 2
src/YZWater.Avalonia/App.axaml.cs

@@ -10,6 +10,7 @@ using YZWater.Avalonia.Controls;
 using YZWater.Avalonia.Services;
 using YZWater.Avalonia.Views;
 using YZWater.Core.Services;
+using YZWater.Core.Utils;
 using YZWater.Core.ViewModels;
 
 namespace YZWater.Avalonia;
@@ -63,7 +64,10 @@ public partial class App : Application
         try
         {
             Serilog.Log.Fatal(ex, "未处理异常 [{Source}]", source);
-            File.AppendAllText("crash.log", $"[{DateTime.Now}] {source}:\n{ex}\n\n");
+            // 锚定 exe 目录:崩溃取证最怕"文件写到了哪"猜不到。快捷方式填了别的起始位置、
+            // 计划任务启动时,裸文件名会落到那个目录(甚至 System32 直接被拒),这条线索就没了
+            File.AppendAllText(Path.Combine(AppPaths.Root, "crash.log"),
+                $"[{DateTime.Now}] {source}:\n{ex}\n\n", FileTextEncoding.Utf8WithBom);
         }
         catch { /* 日志写入失败时不能再抛异常 */ }
     }
@@ -241,6 +245,13 @@ public partial class App : Application
             PlcService.Initialize();
         }
 
+        // 轮询参数取自配置:此前 PlcPollingIntervalMs 只是存在配置里、从未接到服务上
+        if (config != null)
+        {
+            PlcPollingService.Instance.PollingIntervalMs = config.PlcPollingIntervalMs;
+            PlcPollingService.Instance.LogSnapshotIntervalSeconds = config.LogSnapshotIntervalSeconds;
+        }
+
         PlcPollingService.Instance.Start();
 
         // 初始化控件层连接状态(断线降级显示)
@@ -450,7 +461,7 @@ public partial class App : Application
                     var ok = await PlcService.WriteBoolAsync(addr, writeValue);
                     var verb = writeValue ? "启动" : "停止";
                     AuditService.Log(command.Operator ?? "远程", "Control",
-                        $"远程{verb}泵{pumpIdx}", $"P{pumpIdx}", ok ? "Success" : "Failed");
+                        $"远程{verb}泵{pumpIdx}", $"P{pumpIdx}", ok ? "Success" : "Failed", command.ClientIp);
                     return new Core.Models.CommandResultPayload
                     {
                         Success = ok,

+ 16 - 4
src/YZWater.Avalonia/Services/SingleInstanceHelper.cs

@@ -23,13 +23,25 @@ internal static class SingleInstanceHelper
     private static CancellationTokenSource? _cts;
 
     /// <summary>
-    /// 尝试获取单实例权限
+    /// 尝试获取单实例权限。true 表示这是第一个实例(或去重机制本身没建起来,照常启动)。
     /// </summary>
-    /// <returns>true 表示这是第一个实例;false 表示已有实例运行</returns>
     public static bool TryAcquire()
     {
-        _mutex = new Mutex(true, MutexName, out bool createdNew);
-        return createdNew;
+        try
+        {
+            _mutex = new Mutex(true, MutexName, out bool createdNew);
+            return createdNew;
+        }
+        catch (Exception ex)
+        {
+            // 单实例只是体验优化,不能当启动闸门。此刻 Serilog 还没初始化(在 App 构造函数里),
+            // 异常抛出去就是"双击图标没反应、一行日志都找不到"。
+            // Global\ 命名空间还受账户权限影响(受限账户/服务账户未必建得起来),
+            // 失败时放弃去重、继续启动,最多是开出两个窗口,总比开不了机强。
+            _mutex = null;
+            Console.Error.WriteLine($"单实例互斥体创建失败,跳过去重继续启动: {ex.Message}");
+            return true;
+        }
     }
 
     /// <summary>

+ 4 - 2
src/YZWater.Avalonia/Themes/IndustrialStyles.axaml

@@ -18,10 +18,12 @@
     </Style>
 
     <!-- ─── 按钮: Success ─── -->
+    <!-- 实心绿底必须配白字:浅色主题 Success 是 #087F5B,配 TextPrimary(#111827) 几乎看不清 -->
     <Style Selector="Button.btn-success">
         <Setter Property="Background" Value="{DynamicResource SuccessBrush}"/>
-        <Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
+        <Setter Property="Foreground" Value="White"/>
         <Setter Property="BorderBrush" Value="{DynamicResource SuccessBrush}"/>
+        <Setter Property="FontWeight" Value="Bold"/>
         <Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
         <Setter Property="FontSize" Value="11"/>
         <Setter Property="Padding" Value="12,6"/>
@@ -116,7 +118,7 @@
     </Style>
     <Style Selector="Button.btn-toggle:pointerover /template/ ContentPresenter">
         <Setter Property="Background" Value="{DynamicResource SuccessBrush}"/>
-        <Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
+        <Setter Property="Foreground" Value="White"/>
     </Style>
     <Style Selector="Button.btn-toggle:pressed /template/ ContentPresenter">
         <Setter Property="Background" Value="{DynamicResource SuccessPressedBrush}"/>

+ 5 - 0
src/YZWater.Avalonia/Themes/IndustrialTheme.axaml

@@ -108,6 +108,11 @@
     <SolidColorBrush x:Key="ChromeHoverBrush" Color="{StaticResource ChromeHoverColor}"/>
     <SolidColorBrush x:Key="ChromePressedBrush" Color="{StaticResource ChromePressedColor}"/>
     <SolidColorBrush x:Key="DangerFgBrush" Color="{StaticResource DangerFgColor}"/>
+    <!-- 别名:视图里按通用名引用的两个键(MainWindow 角色徽标用 PrimaryBrush,
+         LoginView 窗口底色用 WindowBackground)。这两个键原先没定义,DynamicResource 解析不到
+         就落成 Avalonia 默认色——深色主题下徽标会变成看不见的近黑色。 -->
+    <SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource InfoColor}"/>
+    <SolidColorBrush x:Key="WindowBackground" Color="{StaticResource AppBgColor}"/>
     <!-- 覆盖 Fluent 的 TableView 表头条默认灰(深色) -->
     <SolidColorBrush x:Key="SystemControlBackgroundChromeMediumBrush" Color="{StaticResource ToggleBgColor}"/>
 

+ 5 - 0
src/YZWater.Avalonia/Themes/IndustrialThemeLight.axaml

@@ -104,6 +104,11 @@
     <SolidColorBrush x:Key="ChromeHoverBrush" Color="{StaticResource ChromeHoverColor}"/>
     <SolidColorBrush x:Key="ChromePressedBrush" Color="{StaticResource ChromePressedColor}"/>
     <SolidColorBrush x:Key="DangerFgBrush" Color="{StaticResource DangerFgColor}"/>
+    <!-- 别名:视图里按通用名引用的两个键(MainWindow 角色徽标用 PrimaryBrush,
+         LoginView 窗口底色用 WindowBackground)。这两个键原先没定义,DynamicResource 解析不到
+         就落成 Avalonia 默认色——深色主题下徽标会变成看不见的近黑色。 -->
+    <SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource InfoColor}"/>
+    <SolidColorBrush x:Key="WindowBackground" Color="{StaticResource AppBgColor}"/>
     <!-- 覆盖 Fluent 的 TableView 表头条默认灰(浅色) -->
     <SolidColorBrush x:Key="SystemControlBackgroundChromeMediumBrush" Color="{StaticResource ToggleBgColor}"/>
     <SolidColorBrush x:Key="HeaderBgBrush" Color="{StaticResource HeaderBgColor}"/>

+ 12 - 12
src/YZWater.Avalonia/Views/MainWindow.axaml

@@ -141,23 +141,23 @@
             </Grid>
         </Border>
 
+        <!-- ═══ PLC 通信中断横幅(ISA-101: 断线时全局可见警告)═══
+             独立占一行,不再浮在内容区顶部:压在页面标题栏上会把标题一起糊掉,
+             而断线恰恰是最需要看清"现在在哪个页面"的时候 -->
+        <Border DockPanel.Dock="Top" IsVisible="{Binding !IsPlcConnected}"
+                Background="#F39C12" Padding="12,6">
+            <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
+                <TextBlock Text="⚠" FontSize="16" VerticalAlignment="Center" Foreground="#2C3E50"/>
+                <TextBlock Text="{Binding PlcCommInterruptedText}"
+                           FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Foreground="#2C3E50"/>
+            </StackPanel>
+        </Border>
+
         <!-- ═══ 主内容区域 ═══ -->
         <Panel>
             <!-- 视图内容(通过 CurrentView + DataTemplate 切换) -->
             <ContentControl Content="{Binding CurrentView}"/>
 
-            <!-- ═══ PLC 通信中断横幅(ISA-101: 断线时全局可见警告) ═══ -->
-            <Border IsVisible="{Binding !IsPlcConnected}" ZIndex="50"
-                    VerticalAlignment="Top" HorizontalAlignment="Stretch"
-                    Background="#CCF39C12" Padding="12,6"
-                    IsHitTestVisible="False">
-                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
-                    <TextBlock Text="⚠" FontSize="16" VerticalAlignment="Center" Foreground="#2C3E50"/>
-                    <TextBlock Text="{Binding PlcCommInterruptedText}"
-                               FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Foreground="#2C3E50"/>
-                </StackPanel>
-            </Border>
-
             <!-- ═══ 用户管理对话框 ═══ -->
             <Border IsVisible="{Binding ShowUserDialog}" ZIndex="100" Background="#CC000000">
                 <Border Background="{DynamicResource CardBgBrush}" CornerRadius="12" Padding="24"

+ 11 - 3
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -602,17 +602,25 @@
                                     </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">
+                                            <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,3">
                                                 <StackPanel Orientation="Vertical">
                                                     <TextBlock Text="{Binding Name}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
                                                     <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
                                                                Foreground="{DynamicResource TextDisabledBrush}"/>
                                                 </StackPanel>
-                                                <Button Grid.Column="1" Content="{Binding ExecuteText}" Command="{Binding PulseCommand}"
+                                                <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" Margin="0,0,8,0">
+                                                    <Border Width="8" Height="8" CornerRadius="4" Background="{DynamicResource WarningBrush}"
+                                                            IsVisible="{Binding IsPulsing}"/>
+                                                    <!-- MaxWidth + Wrap:英文提示比中文长一倍,不限宽会把"执行"按钮挤出面板 -->
+                                                    <TextBlock Text="{Binding LastPulseText}" FontSize="9" FontFamily="Consolas, monospace"
+                                                               MaxWidth="150" TextWrapping="Wrap"
+                                                               Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
+                                                </StackPanel>
+                                                <Button Grid.Column="2" Content="{Binding ExecuteText}" Command="{Binding PulseCommand}"
                                                         IsEnabled="{Binding !IsPulsing}" VerticalAlignment="Center"
                                                         Classes="btn-warning btn-compact"/>
                                             </Grid>

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

@@ -14,7 +14,7 @@
                     <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
                         <Border Background="{DynamicResource InfoBrush}" Width="3" Height="20" CornerRadius="1"/>
                         <TextBlock x:Name="TitleText" Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
-                                   Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                   Foreground="{DynamicResource HeaderTextBrush}" VerticalAlignment="Center"/>
                         <TextBlock x:Name="SubtitleText" Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                     </StackPanel>
                 </Grid>

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

@@ -14,7 +14,7 @@
                     <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
                         <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="20" CornerRadius="1"/>
                         <TextBlock Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
-                                   Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                   Foreground="{DynamicResource HeaderTextBrush}" VerticalAlignment="Center"/>
                         <TextBlock Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                     </StackPanel>
                 </Grid>

+ 45 - 6
src/YZWater.Avalonia/Views/ViewDView.axaml

@@ -14,7 +14,7 @@
                     <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
                         <Border Background="{DynamicResource DangerBrush}" Width="3" Height="20" CornerRadius="1"/>
                         <TextBlock x:Name="TitleText" Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
-                                   Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                   Foreground="{DynamicResource HeaderTextBrush}" VerticalAlignment="Center"/>
                         <TextBlock x:Name="SubtitleText" Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                         <Border Background="{DynamicResource DangerBrush}" CornerRadius="10" Padding="8,2" Margin="8,0,0,0">
                             <TextBlock Text="{Binding UnconfirmedCount, StringFormat='{}{0}'}" FontFamily="{DynamicResource MonoFont}" FontSize="11"
@@ -25,7 +25,7 @@
             </Border>
 
             <!-- 内容 -->
-            <Grid Grid.Row="1" RowDefinitions="Auto,*,Auto" Margin="8,4">
+            <Grid Grid.Row="1" RowDefinitions="Auto,*,Auto,Auto" Margin="8,4">
 
                 <!-- 查询条件 -->
                 <Border Grid.Row="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
@@ -45,6 +45,8 @@
                         <Button Content="{Binding ResetAllText}" Command="{Binding ResetAllAlarmsCommand}" Classes="btn-danger"/>
                         <Button Content="{Binding ShelveText}" Command="{Binding ShelveSelectedAlarmCommand}" Classes="btn-warning"/>
                         <Button Content="{Binding UnshelveText}" Command="{Binding UnshelveSelectedAlarmCommand}" Classes="btn-info"/>
+                        <Button Content="{Binding ShelvedListLabel}" Command="{Binding LoadShelvedListCommand}" Classes="btn-warning"/>
+                        <Button Content="{Binding ReviewLabel}" Command="{Binding LoadReviewCommand}" Classes="btn-info"/>
                         <Button Content="{Binding PurgeText}" Command="{Binding ClearHistoryCommand}" Classes="btn-danger"/>
                     </WrapPanel>
                 </Border>
@@ -60,7 +62,7 @@
                             <TableViewColumn Header="类型" Binding="{Binding AlarmType}" Width="100"/>
                             <TableViewColumn Header="消息" Binding="{Binding AlarmMessage}" Width="250"/>
                             <TableViewColumn Header="数值" Binding="{Binding AlarmValue, StringFormat='{}{0:F1}'}" Width="80"/>
-                            <TableViewColumn Header="级别" Binding="{Binding AlarmLevel}" Width="60"/>
+                            <TableViewColumn Header="级别" Binding="{Binding AlarmLevelText}" Width="60"/>
                             <TableViewColumn Header="确认时间" Binding="{Binding ConfirmedTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
                             <TableViewColumn Header="确认人" Binding="{Binding ConfirmedBy}" Width="80"/>
                             <TableViewColumn Header="恢复时间" Binding="{Binding RecoverTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
@@ -74,7 +76,7 @@
                 <Border Grid.Row="2" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                         BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12"
                         IsVisible="{Binding SelectedRecord, Converter={x:Static ObjectConverters.IsNotNull}}">
-                    <Grid ColumnDefinitions="80,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
+                    <Grid ColumnDefinitions="80,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto">
                         <TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding TimeLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
                         <TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding DetailTime}"
                                    FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource TextPrimaryBrush}"/>
@@ -98,14 +100,51 @@
                         <TextBlock Grid.Row="5" Grid.Column="0" Text="{Binding DurationLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
                         <TextBlock Grid.Row="5" Grid.Column="1" Text="{Binding DetailDuration}"
                                    FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource InfoBrush}"/>
+
+                        <!-- 单条确认/复位:ISA-18.2 要求按条确认,只有"全部确认"的话误点一次就把整屏报警压掉,
+                             而且未确认的那条会被一起抹掉 -->
+                        <StackPanel Grid.Row="6" Grid.ColumnSpan="2" Orientation="Horizontal" Spacing="8" Margin="0,8,0,0">
+                            <Button Content="{Binding ConfirmRowText}" Command="{Binding ConfirmAlarmCommand}"
+                                    CommandParameter="{Binding SelectedRecord}" Classes="btn-warning"/>
+                            <Button Content="{Binding ResetRowText}" Command="{Binding ResetAlarmCommand}"
+                                    CommandParameter="{Binding SelectedRecord}" Classes="btn-danger"/>
+                        </StackPanel>
                     </Grid>
                 </Border>
+
+                <!-- 搁置列表 / 报警回顾(点击工具栏按钮后显示) -->
+                <Border Grid.Row="3" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,0,0"
+                        IsVisible="{Binding IsInfoPanelVisible}">
+                    <StackPanel Spacing="4">
+                        <TextBlock Text="{Binding ShelvedListLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="11" FontWeight="Bold" Foreground="{DynamicResource WarningBrush}"
+                                   IsVisible="{Binding ShelvedListText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+                        <TextBlock Text="{Binding ShelvedListText}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextPrimaryBrush}" TextWrapping="Wrap"
+                                   IsVisible="{Binding ShelvedListText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+                        <TextBlock Text="{Binding ReviewLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="11" FontWeight="Bold" Foreground="{DynamicResource InfoBrush}"
+                                   IsVisible="{Binding ReviewSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+                        <TextBlock Text="{Binding ReviewSummary}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextPrimaryBrush}" TextWrapping="Wrap"
+                                   IsVisible="{Binding ReviewSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+                    </StackPanel>
+                </Border>
             </Grid>
 
             <!-- 底部 -->
             <Border x:Name="StatusBar" Grid.Row="2" Background="{DynamicResource NavBgBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0">
-                <TextBlock Text="v3.0" FontFamily="{DynamicResource MonoFont}" FontSize="10"
-                           Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center" Margin="16,0"/>
+                <DockPanel LastChildFill="False">
+                    <!-- 确认/复位这类批量操作的回执:命中 0 行时也要让操作员看到"没东西可确认",
+                         否则只会以为按钮坏了 -->
+                    <TextBlock DockPanel.Dock="Left" Text="{Binding OperationResult}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                               Foreground="{DynamicResource InfoBrush}" VerticalAlignment="Center" Margin="16,0"/>
+                    <!-- 装载限行提示:只在被截断时才有文字,避免"界面上就这些"被当成"库里就这些" -->
+                    <TextBlock DockPanel.Dock="Left" Text="{Binding RowLimitHint}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                               Foreground="{DynamicResource WarningBrush}" VerticalAlignment="Center" Margin="16,0"/>
+                    <!-- 队列丢弃计数:报警密集到写库队列塞满时会丢最旧的记录,不显示出来就等于静默丢数据 -->
+                    <TextBlock DockPanel.Dock="Left" Text="{Binding DroppedHint}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                               Foreground="{DynamicResource DangerBrush}" FontWeight="Bold" VerticalAlignment="Center" Margin="16,0"/>
+                    <TextBlock DockPanel.Dock="Right" Text="v3.0" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                               Foreground="{DynamicResource TextDisabledBrush}" VerticalAlignment="Center" Margin="0,0,16,0"/>
+                </DockPanel>
             </Border>
         </Grid>
     </Border>

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

@@ -14,7 +14,7 @@
                     <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
                         <Border Background="{DynamicResource TextSecondaryBrush}" Width="3" Height="20" CornerRadius="1"/>
                         <TextBlock x:Name="TitleText" Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
-                                   Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
+                                   Foreground="{DynamicResource HeaderTextBrush}" VerticalAlignment="Center"/>
                         <TextBlock x:Name="SubtitleText" Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                     </StackPanel>
                 </Grid>

+ 3 - 0
src/YZWater.Avalonia/Views/ViewFView.axaml

@@ -67,6 +67,9 @@
                 <Run Text=" "/>
                 <Run Text="{Binding RecordsText}"/>
             </TextBlock>
+            <!-- 审计写入队列曾满时丢过记录:不显示出来就等于静默丢审计,台账会"看起来很干净" -->
+            <TextBlock Text="{Binding DroppedHint}" VerticalAlignment="Center" Margin="20,0,0,0"
+                       FontSize="11" FontWeight="Bold" Foreground="{DynamicResource DangerBrush}"/>
         </StackPanel>
 
         <!-- 审计日志表格 -->

+ 21 - 0
src/YZWater.Core/Models/AlarmRecord.cs

@@ -97,6 +97,19 @@ public partial class AlarmRecord : ObservableObject
         ? LanguageService.Instance.Get("FirstOutFlag")
         : IsSuppressed ? LanguageService.Instance.Get("SuppressedFlag") : string.Empty;
 
+    /// <summary>
+    /// 报警级别文字(UI 绑定用,不入库)— 操作员不用背数字含义
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string AlarmLevelText => AlarmLevel switch
+    {
+        1 => LanguageService.Instance.Get("AlarmLevel1"),
+        2 => LanguageService.Instance.Get("AlarmLevel2"),
+        3 => LanguageService.Instance.Get("AlarmLevel3"),
+        4 or 5 => LanguageService.Instance.Get("AlarmLevel4"),
+        _ => AlarmLevel.ToString()
+    };
+
     /// <summary>
     /// 异常持续时间(人类可读格式,UI 绑定用,不入库)
     /// </summary>
@@ -114,6 +127,14 @@ public partial class AlarmRecord : ObservableObject
     /// </summary>
     public void UpdateDurationText()
     {
+        // ConditionId 为空的是瞬时事件(诊断流水、报警升级):它们没有"恢复"这个概念,
+        // 按 now-AlarmTime 累加会显示成"12m+",看起来像一条永远不结束的报警。
+        if (string.IsNullOrEmpty(ConditionId))
+        {
+            DurationText = "--";
+            return;
+        }
+
         if (RecoverTime.HasValue)
         {
             var span = RecoverTime.Value - AlarmTime;

+ 4 - 0
src/YZWater.Core/Models/HubMessage.cs

@@ -115,6 +115,10 @@ public class CommandPayload
 
     [JsonPropertyName("operator")]
     public string Operator { get; set; } = string.Empty;
+
+    /// <summary>服务端填充的客户端 IP(不参与 JSON 序列化)— 审计留痕用</summary>
+    [JsonIgnore]
+    public string? ClientIp { get; set; }
 }
 
 /// <summary>

+ 35 - 4
src/YZWater.Core/Models/SystemConfig.cs

@@ -8,17 +8,21 @@ namespace YZWater.Core.Models;
 /// </summary>
 public partial class SystemConfig : ObservableObject
 {
+    /// <summary>PLC 默认地址/端口(现场 S7-200 Smart;"恢复默认设置"与此保持同源)</summary>
+    public const string DefaultPlcIp = "192.168.2.100";
+    public const int DefaultPlcPort = 102;
+
     /// <summary>
     /// PLC IP 地址
     /// </summary>
     [ObservableProperty]
-    private string _plcIp = "192.168.2.100";
+    private string _plcIp = DefaultPlcIp;
 
     /// <summary>
     /// PLC 端口
     /// </summary>
     [ObservableProperty]
-    private int _plcPort = 102;
+    private int _plcPort = DefaultPlcPort;
 
     /// <summary>
     /// 是否自动连接
@@ -173,6 +177,22 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private int _plcPollingIntervalMs = 1000;
 
+    /// <summary>
+    /// 最低日志级别:Verbose/Debug/Information/Warning/Error/Fatal
+    /// 默认 Information —— 链路状态、测点质量跳变、报警、备份/清理、周期采集快照都在这个级别之上。
+    /// 现场排障要看每轮读了哪些地址、批量分组和解析值时,把它改成 Debug 即可,不必重新发布;
+    /// 单文件 20MB、最多 60 份的上限保证长时间开 Debug 也写不满磁盘。
+    /// </summary>
+    [ObservableProperty]
+    private string _minimumLogLevel = "Information";
+
+    /// <summary>
+    /// 周期采集快照的输出间隔(秒):一行 Information 记录链路状态 + 全部模拟量测点当前值。
+    /// 这是"采集侧到底读回来多少"的常驻证据,0 = 关闭。
+    /// </summary>
+    [ObservableProperty]
+    private int _logSnapshotIntervalSeconds = 60;
+
     /// <summary>
     /// 是否开机自启动
     /// </summary>
@@ -226,8 +246,12 @@ public partial class SystemConfig : ObservableObject
 /// </summary>
 public class DataRetentionConfig
 {
-    /// <summary>报警记录保留天数(默认 90 天)</summary>
-    public int AlarmRecordRetentionDays { get; set; } = 90;
+    /// <summary>
+    /// 报警记录保留天数(默认 180 天)。
+    /// 下限来自网络安全法第 21 条"网络日志留存不少于六个月",报警记录属于运行台账,
+    /// 清理是硬删除、没有归档,所以默认值就取合规下限。
+    /// </summary>
+    public int AlarmRecordRetentionDays { get; set; } = 180;
 
     /// <summary>流量记录保留天数(默认 360 天)</summary>
     public int FlowRecordRetentionDays { get; set; } = 360;
@@ -267,6 +291,13 @@ public class AlarmManagementConfig
 
     /// <summary>搁置默认时长(分钟,默认 30)</summary>
     public int DefaultShelveMinutes { get; set; } = 30;
+
+    /// <summary>
+    /// 按报警类型前缀覆盖默认级别。键匹配 ConditionId 的前缀(如 "PLC_Connection"、"Pressure"、"Pump"),
+    /// 值为级别 1-5。未列出的类型使用代码中的默认值。
+    /// 示例: { "PLC_Connection": 2, "Protection": 5 }
+    /// </summary>
+    public Dictionary<string, int> LevelByType { get; set; } = new();
 }
 
 /// <summary>

+ 355 - 157
src/YZWater.Core/Services/AlarmService.cs

@@ -34,11 +34,13 @@ public partial class AlarmService : ObservableObject, IDisposable
     private bool _disposed;
 
     /// <summary>报警写入通道(有界队列,防报警密集时线程池打满)</summary>
-    private readonly Channel<AlarmRecord> _writeChannel =
-        Channel.CreateBounded<AlarmRecord>(new BoundedChannelOptions(200)
-        {
-            FullMode = BoundedChannelFullMode.DropOldest
-        });
+    private readonly Channel<AlarmRecord> _writeChannel;
+
+    /// <summary>队列满后被丢弃(DropOldest 丢最旧)的报警条数</summary>
+    private long _droppedRecords;
+
+    /// <summary>每丢弃这么多条提示一次,避免队列持续满时告警自己变成泛溢源</summary>
+    private const int DropWarnEvery = 50;
 
     /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
     private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
@@ -79,19 +81,21 @@ public partial class AlarmService : ObservableObject, IDisposable
     private readonly object _shelfLock = new();
 
     // ─── 泛溢抑制(Flood Suppression)───
-    /// <summary>窗口内的报警触发时间戳队列</summary>
+    /// <summary>窗口内的报警触发时间戳队列(受 _stateLock 保护)</summary>
     private readonly Queue<DateTime> _raiseTimes = new();
     private bool _inFlood;
     private DateTime _lastRaiseTime = DateTime.MinValue;
 
     // ─── 首出报警(First-Out)───
-    /// <summary>当前报警波(窗口内连续触发的报警),第一条为首出</summary>
+    /// <summary>当前报警波(窗口内连续触发的报警),第一条为首出(受 _stateLock 保护)</summary>
     private readonly List<(AlarmRecord Record, DateTime Time)> _burstMembers = new();
 
     // ─── 状态 ───
     [ObservableProperty] private bool _hasActiveAlarm;
     [ObservableProperty] private string _activeAlarmMessage = string.Empty;
     [ObservableProperty] private int _activeAlarmCount;
+    /// <summary>未确认报警数 — 首页角标用,ISA-18.2 要求角标显示"需要操作员注意的数量"</summary>
+    [ObservableProperty] private int _unconfirmedAlarmCount;
 
     // ─── 事件 ───
     public event Action<AlarmRecord>? AlarmRaised;
@@ -99,10 +103,18 @@ public partial class AlarmService : ObservableObject, IDisposable
     public event Action? AlarmStateChanged;
 
     // ─── 报警跟踪 ───
+    /// <summary>
+    /// 内存报警状态锁。轮询线程(CheckAlarms)、UI 线程(ViewD 搁置/复位)、SignalR 线程(远程报警、远程复位)
+    /// 会并发读写下面这批集合,不加锁会让枚举撞上写入直接抛 InvalidOperationException。
+    /// 约定:锁内只碰内存集合,落库/事件通知/日志/审计一律在锁外做,
+    /// 否则持锁线程会被事件回调拖住,与等锁的 UI 线程互相僵住。
+    /// </summary>
+    private readonly object _stateLock = new();
+
     private readonly Dictionary<string, DateTime> _alarmStartTimes = new();
     private readonly HashSet<string> _activeAlarms = new();
 
-    /// <summary>活跃报警 conditionId → 数据库 AlarmRecord.Id(恢复时回写 RecoverTime 用)</summary>
+    /// <summary>活跃报警 conditionId → 数据库 AlarmRecord.Id(恢复时回写 RecoverTime 用,受 _stateLock 保护)</summary>
     private readonly Dictionary<string, int> _activeAlarmDbIds = new();
 
     // ─── 报警升级 ───
@@ -115,10 +127,29 @@ public partial class AlarmService : ObservableObject, IDisposable
         _pollingService = pollingService;
         _pollingService.DataUpdated += OnDataUpdated;
 
+        _writeChannel = Channel.CreateBounded<AlarmRecord>(
+            new BoundedChannelOptions(200)
+            {
+                // 队列满时丢最旧。TryWrite 在 DropOldest 下仍然返回 true,所以丢弃只能靠 itemDropped 回调观测
+                FullMode = BoundedChannelFullMode.DropOldest
+            },
+            OnRecordDropped);
+
         // 启动报警后台写入任务
         _writerTask = Task.Run(() => ProcessWriteQueueAsync(_writerCts.Token));
     }
 
+    /// <summary>队列满导致报警被丢弃:计数并周期性写日志,丢记录不能是静默的</summary>
+    private void OnRecordDropped(AlarmRecord dropped)
+    {
+        var total = Interlocked.Increment(ref _droppedRecords);
+        if (total % DropWarnEvery == 1)
+            Log.Warning("报警写入队列已满,最旧记录被丢弃(累计 {Total} 条),本条:{Message}", total, dropped.AlarmMessage);
+    }
+
+    /// <summary>因写入队列满而被丢弃的报警条数(UI 用它提示记录不完整)</summary>
+    public long DroppedRecordCount => Interlocked.Read(ref _droppedRecords);
+
     /// <summary>
     /// 启动报警监控
     /// </summary>
@@ -126,9 +157,52 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         LoadThresholdsFromConfig();
         _cts = new CancellationTokenSource();
+        _ = CloseOrphanedAlarmsAsync();
         Log.Information("报警服务已启动");
     }
 
+    /// <summary>
+    /// 从配置读取报警级别:按 ConditionId 前缀匹配,未命中则用代码默认值。
+    /// 现场可根据实际需要调整某类报警的级别,不用改代码重编译。
+    /// </summary>
+    private int ResolveLevel(string conditionId, int defaultLevel)
+    {
+        var mapping = ConfigService.GetConfig()?.Alarm?.LevelByType;
+        if (mapping == null || mapping.Count == 0) return defaultLevel;
+        foreach (var (prefix, level) in mapping)
+        {
+            if (conditionId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+                return level;
+        }
+        return defaultLevel;
+    }
+
+    /// <summary>
+    /// 收尾上次运行遗留的"未恢复"报警(进程被杀/重启时来不及回写 RecoverTime,
+    /// conditionId→DbId 的映射只存在内存里,新进程不可能再补上)。
+    /// 不收尾的话这些行在历史页里永远显示持续时间无限增长,操作员会以为设备还在报警。
+    /// 只处理条件引擎产生的记录(ConditionId 非空):诊断流水(Quality/System)是瞬时事件,
+    /// 本就没有"恢复"一说,不该被打上恢复时间。
+    /// </summary>
+    internal async Task CloseOrphanedAlarmsAsync()
+    {
+        try
+        {
+            var now = DateTime.Now;
+            var (ok, closed) = await DatabaseService.ExecuteWriteAsync(_bgDb,
+                db => db.Ado.ExecuteCommandAsync(
+                    "UPDATE alarm_records SET RecoverTime = @end " +
+                    "WHERE RecoverTime IS NULL AND ConditionId IS NOT NULL",
+                    new { end = now }));
+            if (ok && closed > 0)
+                Log.Information("已收尾 {Count} 条上次运行遗留的未恢复报警(恢复时间记为本次启动时刻)", closed);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "收尾遗留未恢复报警失败");
+        }
+    }
+
     /// <summary>
     /// 从配置文件加载报警阈值(修复 UI 修改不生效的问题)
     /// </summary>
@@ -416,23 +490,32 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     private void CheckCondition(string conditionId, string message, int level, DateTime now)
     {
-        if (_activeAlarms.Contains(conditionId))
-            return; // 已经在报警中
+        // 配置覆盖级别:现场可根据影响程度调整,不用改代码
+        level = ResolveLevel(conditionId, level);
 
-        if (IsShelved(conditionId))
-            return; // 已搁置:不评估、不触发
-
-        if (!_alarmStartTimes.ContainsKey(conditionId))
+        (AlarmRecord? Raised, int FloodRaiseCount, int FirstOutId) claim;
+        lock (_stateLock)
         {
-            _alarmStartTimes[conditionId] = now;
-            return; // 记录开始时间
-        }
+            if (_activeAlarms.Contains(conditionId))
+                return; // 已经在报警中
 
-        // 检查延迟
-        if ((now - _alarmStartTimes[conditionId]).TotalSeconds >= AlarmDelaySeconds)
-        {
-            TriggerAlarm(conditionId, message, level);
+            if (IsShelved(conditionId))
+                return; // 已搁置:不评估、不触发(_stateLock → _shelfLock 为固定顺序,反向不成立故无死锁)
+
+            if (!_alarmStartTimes.TryGetValue(conditionId, out var startTime))
+            {
+                _alarmStartTimes[conditionId] = now;
+                return; // 记录开始时间
+            }
+
+            // 检查延迟
+            if ((now - startTime).TotalSeconds < AlarmDelaySeconds)
+                return;
+
+            claim = ClaimAlarmLocked(conditionId, message, level, isFloodMeta: false);
         }
+
+        EmitAlarm(claim.Raised, claim.FloodRaiseCount, claim.FirstOutId);
     }
 
     /// <summary>
@@ -440,56 +523,49 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     private void ClearCondition(string conditionId)
     {
-        // 查找并移除 DB Id 映射,用于回写恢复时间
         int dbId;
-        lock (_activeAlarmDbIds)
+        bool hasDbId;
+        lock (_stateLock)
         {
-            if (!_activeAlarmDbIds.TryGetValue(conditionId, out dbId))
-            {
-                // 没有 DB 记录(未触发过报警),直接清内存
-                _alarmStartTimes.Remove(conditionId);
-                _activeAlarms.Remove(conditionId);
-                _alarmTriggeredTimes.Remove(conditionId);
-                _escalatedAlarms.Remove(conditionId);
-                return;
-            }
-            _activeAlarmDbIds.Remove(conditionId);
+            _alarmStartTimes.Remove(conditionId);
+            _activeAlarms.Remove(conditionId);
+            _alarmTriggeredTimes.Remove(conditionId);
+            _escalatedAlarms.Remove(conditionId);
+
+            // 取出 DB Id 映射,用于回写恢复时间;没有映射说明报警从未触发
+            hasDbId = _activeAlarmDbIds.Remove(conditionId, out dbId);
         }
 
-        // 异步回写 RecoverTime
+        if (!hasDbId) return;
         _ = UpdateRecoverTimeAsync(dbId);
-
-        _alarmStartTimes.Remove(conditionId);
-        _activeAlarms.Remove(conditionId);
-        _alarmTriggeredTimes.Remove(conditionId);
-        _escalatedAlarms.Remove(conditionId);
     }
 
     /// <summary>
-    /// 回写报警恢复时间到数据库
+    /// 触发报警(用于不经延迟判定的路径,如泛溢元报警)
     /// </summary>
-    private async Task UpdateRecoverTimeAsync(int alarmId)
+    private void TriggerAlarm(string conditionId, string message, int level, bool isFloodMeta = false)
     {
-        try
-        {
-            await DatabaseService.ExecuteWriteAsync(
-                _bgDb, db => db.Updateable<AlarmRecord>()
-                    .SetColumns(r => r.RecoverTime, DateTime.Now)
-                    .Where(r => r.Id == alarmId)
-                    .ExecuteCommandAsync());
-            Log.Debug("报警 {Id} 恢复时间已写入", alarmId);
-        }
-        catch (Exception ex)
-        {
-            Log.Error(ex, "回写报警恢复时间失败: {Id}", alarmId);
-        }
+        (AlarmRecord? Raised, int FloodRaiseCount, int FirstOutId) claim;
+        lock (_stateLock)
+            claim = ClaimAlarmLocked(conditionId, message, level, isFloodMeta);
+
+        EmitAlarm(claim.Raised, claim.FloodRaiseCount, claim.FirstOutId);
     }
 
     /// <summary>
-    /// 触发报警
+    /// 在锁内登记一次报警上升沿:占住活跃集合并生成记录。
+    /// 返回 Raised=null 表示已被其他线程触发过(重复报警丢弃)。
+    /// 泛溢判定和首出归档也在锁内完成,落库/通知留给 EmitAlarm 在锁外做。
     /// </summary>
-    private void TriggerAlarm(string conditionId, string message, int level, bool isFloodMeta = false)
+    private (AlarmRecord? Raised, int FloodRaiseCount, int FirstOutId) ClaimAlarmLocked(
+        string conditionId, string message, int level, bool isFloodMeta)
     {
+        if (_activeAlarms.Contains(conditionId))
+            return (null, 0, 0);
+
+        if (!isFloodMeta && IsShelved(conditionId))
+            return (null, 0, 0);
+
         _activeAlarms.Add(conditionId);
         _alarmStartTimes.Remove(conditionId);
         _alarmTriggeredTimes[conditionId] = DateTime.Now;
@@ -505,22 +581,41 @@ public partial class AlarmService : ObservableObject, IDisposable
             ConditionId = conditionId
         };
 
+        int floodRaiseCount = 0;
+        int firstOutId = 0;
         if (!isFloodMeta)
         {
             // 泛溢检测:窗口内触发过多时抑制后续报警的通知(记录仍全部落库)
-            if (DetectFlood(alarm))
+            if (DetectFloodLocked(alarm, out floodRaiseCount))
                 alarm.IsSuppressed = true;
 
-            // 首出标记:窗口内连续报警视为同一波,第一条为首出
+            // 首出标记:窗口内连续触发报警视为同一波,第一条为首出
             if (EnableFirstOut)
-                TrackFirstOut(alarm, alarm.AlarmTime);
+                firstOutId = TrackFirstOutLocked(alarm, alarm.AlarmTime);
         }
 
+        return (alarm, floodRaiseCount, firstOutId);
+    }
+
+    /// <summary>
+    /// 报警的对外副作用:落库、事件通知、远程通知、泛溢元报警。
+    /// 必须在锁外调用 —— 事件订阅者(UI/SignalR)可能回头调用本服务的复位/搁置,持锁等待会互锁。
+    /// </summary>
+    private void EmitAlarm(AlarmRecord? alarm, int floodRaiseCount, int firstOutIdToPersist)
+    {
+        if (firstOutIdToPersist > 0)
+            _ = UpdateFirstOutAsync(firstOutIdToPersist); // 队列中未落库时属性已生效,这里补写已落库的首出标记
+
+        if (alarm == null) return;
+
+        var conditionId = alarm.ConditionId;
+
         // 持久化到数据库
         _ = SaveAlarmAsync(alarm);
 
         // 等待落库后映射 conditionId → DB Id(恢复时回写用)
-        _ = TrackAlarmIdAsync(conditionId, alarm);
+        if (!string.IsNullOrEmpty(conditionId))
+            _ = TrackAlarmIdAsync(conditionId, alarm);
 
         // 通知
         AlarmRaised?.Invoke(alarm);
@@ -530,8 +625,16 @@ public partial class AlarmService : ObservableObject, IDisposable
         if (!alarm.IsSuppressed)
             _ = NotificationService.Instance.SendAlarmNotificationAsync(alarm);
 
-        Log.Warning("报警触发: {Message} (级别 {Level}{Suppressed})", message, level,
+        Log.Warning("报警触发: {Message} (级别 {Level}{Suppressed})", alarm.AlarmMessage, alarm.AlarmLevel,
             alarm.IsSuppressed ? " [泛溢抑制]" : string.Empty);
+
+        if (floodRaiseCount > 0)
+        {
+            var msg = string.Format(LanguageService.Instance.Get("AlarmFloodDetected"), FloodWindowSeconds, floodRaiseCount);
+            Log.Error(msg);
+            TriggerAlarm("Alarm_Flood", msg, 3, isFloodMeta: true);
+            AuditService.Log("系统", "AlarmFlood", msg);
+        }
     }
 
     /// <summary>等待报警记录落库后,将 conditionId → DbId 映射存入内存字典</summary>
@@ -540,9 +643,33 @@ public partial class AlarmService : ObservableObject, IDisposable
         var deadline = DateTime.Now.AddSeconds(5);
         while (alarm.Id == 0 && DateTime.Now < deadline)
             await Task.Delay(50);
-        if (alarm.Id > 0)
-            lock (_activeAlarmDbIds)
+        if (alarm.Id <= 0) return;
+        lock (_stateLock)
+        {
+            // 报警可能已在这段等待里被清除(恢复/复位/搁置),此时不能留下悬空映射
+            if (_activeAlarms.Contains(conditionId))
                 _activeAlarmDbIds[conditionId] = alarm.Id;
+        }
+    }
+
+    /// <summary>
+    /// 回写报警恢复时间到数据库
+    /// </summary>
+    private async Task UpdateRecoverTimeAsync(int alarmId)
+    {
+        try
+        {
+            await DatabaseService.ExecuteWriteAsync(
+                _bgDb, db => db.Updateable<AlarmRecord>()
+                    .SetColumns(r => r.RecoverTime, DateTime.Now)
+                    .Where(r => r.Id == alarmId)
+                    .ExecuteCommandAsync());
+            Log.Debug("报警 {Id} 恢复时间已写入", alarmId);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "回写报警恢复时间失败: {Id}", alarmId);
+        }
     }
 
     // ═══════════════════════════════════════════════════════════
@@ -550,11 +677,13 @@ public partial class AlarmService : ObservableObject, IDisposable
     // ═══════════════════════════════════════════════════════════
 
     /// <summary>
-    /// 记录本次触发并判断当前是否处于泛溢状态。
-    /// 返回 true 表示该报警应被抑制通知(记录仍落库并标记 IsSuppressed)。
+    /// 记录本次触发并判断当前是否处于泛溢状态(调用方须持有 _stateLock)。
+    /// 返回值表示该报警是否应被抑制通知(记录仍落库并标记 IsSuppressed);
+    /// floodRaiseCount 大于 0 表示泛溢刚开始,附带窗口内的触发条数,由锁外调用方生成元报警。
     /// </summary>
-    private bool DetectFlood(AlarmRecord alarm)
+    private bool DetectFloodLocked(AlarmRecord alarm, out int floodRaiseCount)
     {
+        floodRaiseCount = 0;
         var now = alarm.AlarmTime;
         _lastRaiseTime = now;
         _raiseTimes.Enqueue(now);
@@ -567,11 +696,7 @@ public partial class AlarmService : ObservableObject, IDisposable
         if (!_inFlood && _raiseTimes.Count > FloodRaiseThreshold)
         {
             _inFlood = true;
-            var msg = string.Format(LanguageService.Instance.Get("AlarmFloodDetected"),
-                FloodWindowSeconds, _raiseTimes.Count);
-            Log.Error(msg);
-            TriggerAlarm("Alarm_Flood", msg, 3, isFloodMeta: true);
-            AuditService.Log("系统", "AlarmFlood", msg);
+            floodRaiseCount = _raiseTimes.Count;
         }
 
         return _inFlood;
@@ -580,53 +705,75 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// <summary>窗口期无新报警则退出泛溢状态</summary>
     private void UpdateFloodState(DateTime now)
     {
-        if (!_inFlood) return;
-        if ((now - _lastRaiseTime).TotalSeconds < FloodWindowSeconds) return;
+        int floodWindow;
+        lock (_stateLock)
+        {
+            if (!_inFlood) return;
+            if ((now - _lastRaiseTime).TotalSeconds < FloodWindowSeconds) return;
 
-        _inFlood = false;
-        _raiseTimes.Clear();
+            _inFlood = false;
+            _raiseTimes.Clear();
+            floodWindow = FloodWindowSeconds;
+        }
+
+        // 泛溢元报警本身也按恢复流程关闭(回写恢复时间),锁外执行
         ClearCondition("Alarm_Flood");
-        var msg = string.Format(LanguageService.Instance.Get("AlarmFloodEnded"), FloodWindowSeconds);
+        var msg = string.Format(LanguageService.Instance.Get("AlarmFloodEnded"), floodWindow);
         Log.Information(msg);
         AuditService.Log("系统", "AlarmFlood", msg);
     }
 
     /// <summary>
     /// 首出跟踪:与新报警时间相差窗口内的已有报警属于同一波,波内第一条为首出
+    /// (调用方须持有 _stateLock,返回需要补写数据库的首出报警 Id)
     /// </summary>
-    private void TrackFirstOut(AlarmRecord alarm, DateTime now)
+    private int TrackFirstOutLocked(AlarmRecord alarm, DateTime now)
     {
+        int idToPersist = 0;
         if (_burstMembers.Count > 0 &&
             (now - _burstMembers[^1].Time).TotalSeconds > FirstOutWindowSeconds)
         {
-            FinalizeBurst();
+            idToPersist = FinalizeBurstLocked();
         }
 
         _burstMembers.Add((alarm, now));
         if (_burstMembers.Count >= 2)
-            _burstMembers[0].Record.IsFirstOut = true; // 队列中未落库时属性直接生效;已落库由 FinalizeBurst 补写
+            _burstMembers[0].Record.IsFirstOut = true; // 队列中未落库时属性直接生效;已落库由 FinalizeBurstLocked 补写
+
+        return idToPersist;
     }
 
     /// <summary>报警波结束(窗口期无新报警)时封口,持久化首出标记</summary>
     private void UpdateBurstState(DateTime now)
     {
-        if (_burstMembers.Count == 0) return;
-        if ((now - _burstMembers[^1].Time).TotalSeconds < FirstOutWindowSeconds) return;
-        FinalizeBurst();
+        int firstOutId;
+        lock (_stateLock)
+        {
+            if (_burstMembers.Count == 0) return;
+            if ((now - _burstMembers[^1].Time).TotalSeconds < FirstOutWindowSeconds) return;
+            firstOutId = FinalizeBurstLocked();
+        }
+
+        if (firstOutId > 0)
+            _ = UpdateFirstOutAsync(firstOutId);
     }
 
-    private void FinalizeBurst()
+    /// <summary>
+    /// 封存当前报警波并标记首出(调用方须持有 _stateLock)
+    /// </summary>
+    private int FinalizeBurstLocked()
     {
+        int idToPersist = 0;
         if (_burstMembers.Count >= 2)
         {
             var first = _burstMembers[0];
             first.Record.IsFirstOut = true;
             Log.Information("报警首出: {Message}({Sec}s 窗口内共 {Count} 条并发报警)",
                 first.Record.AlarmMessage, FirstOutWindowSeconds, _burstMembers.Count);
-            if (first.Record.Id > 0)
-                _ = UpdateFirstOutAsync(first.Record.Id);
+            idToPersist = first.Record.Id; // 已落库时需要补写,未落库属性已直接生效
         }
         _burstMembers.Clear();
+        return idToPersist;
     }
 
     /// <summary>回写首出标记到数据库(首条报警已落库的情况)</summary>
@@ -726,6 +873,9 @@ public partial class AlarmService : ObservableObject, IDisposable
     public async Task<List<AlarmReviewItem>> GetAlarmReviewAsync(DateTime start, DateTime end)
     {
         var records = await GetAlarmHistoryAsync(start, end);
+        HashSet<string> activeSnapshot;
+        lock (_stateLock)
+            activeSnapshot = new HashSet<string>(_activeAlarms);
         return records
             .GroupBy(r => r.ConditionId ?? r.AlarmType)
             .Select(g => new AlarmReviewItem
@@ -735,7 +885,7 @@ public partial class AlarmService : ObservableObject, IDisposable
                 Count = g.Count(),
                 FirstTime = g.Min(r => r.AlarmTime),
                 LastTime = g.Max(r => r.AlarmTime),
-                IsActive = _activeAlarms.Contains(g.Key),
+                IsActive = activeSnapshot.Contains(g.Key),
                 IsShelved = IsShelved(g.Key)
             })
             .OrderByDescending(i => i.Count)
@@ -743,20 +893,30 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 记录信息级报警流水(自动确认,不占用活跃报警,用于事件提示如数据缺口恢复)
+    /// 记录信息级报警流水(自动确认,不占用活跃报警,用于事件提示如数据缺口恢复)。
+    /// 这类行天生不参与确认/复位,所以类型要单独给(如 Quality),让报警页和导出能把它和真报警分开。
+    /// Quality 类型(测点数据质量)不进报警表——用户要求"只显示影响运行的报警",质量事件写 Serilog 即可追溯。
     /// </summary>
-    public void RecordJournalAlarm(string message, int level = 1)
+    public void RecordJournalAlarm(string message, int level = 1, string alarmType = "System")
     {
         var alarm = new AlarmRecord
         {
             AlarmTime = DateTime.Now,
-            AlarmType = "System",
+            AlarmType = alarmType,
             AlarmMessage = message,
             AlarmLevel = level,
             IsConfirmed = true,
             ConfirmedTime = DateTime.Now,
             ConfirmedBy = "系统"
         };
+
+        if (alarmType == "Quality")
+        {
+            // 质量问题不进报警表,只写日志——操作员不需要在报警列表里看到"3个测点读取失败"
+            Log.Information("[Quality] {Message}", message);
+            return;
+        }
+
         _ = SaveAlarmAsync(alarm);
         AlarmRaised?.Invoke(alarm);
     }
@@ -782,34 +942,40 @@ public partial class AlarmService : ObservableObject, IDisposable
     public void CheckEscalation()
     {
         var now = DateTime.Now;
-        foreach (var conditionId in _activeAlarms.ToList())
+        List<string> toEscalate;
+        lock (_stateLock)
         {
-            if (_escalatedAlarms.Contains(conditionId)) continue;
-            if (!_alarmTriggeredTimes.TryGetValue(conditionId, out var triggerTime)) continue;
+            toEscalate = _activeAlarms
+                .Where(conditionId => !_escalatedAlarms.Contains(conditionId)
+                                   && _alarmTriggeredTimes.TryGetValue(conditionId, out var triggerTime)
+                                   && (now - triggerTime).TotalMinutes >= EscalationMinutes)
+                .ToList();
 
-            if ((now - triggerTime).TotalMinutes >= EscalationMinutes)
-            {
+            foreach (var conditionId in toEscalate)
                 _escalatedAlarms.Add(conditionId);
-                var escalatedLevel = 4; // 升级到高级别
-                var message = string.Format(LanguageService.Instance.Get("AlarmEscalated"), conditionId, EscalationMinutes);
+        }
 
-                var alarm = new AlarmRecord
-                {
-                    AlarmTime = now,
-                    AlarmType = conditionId.Split('_')[0],
-                    AlarmMessage = message,
-                    AlarmLevel = escalatedLevel,
-                    IsConfirmed = false
-                };
+        // 升级记录落库与通知不持锁
+        foreach (var conditionId in toEscalate)
+        {
+            var message = string.Format(LanguageService.Instance.Get("AlarmEscalated"), conditionId, EscalationMinutes);
 
-                _ = SaveAlarmAsync(alarm);
-                AlarmRaised?.Invoke(alarm);
+            var alarm = new AlarmRecord
+            {
+                AlarmTime = now,
+                AlarmType = conditionId.Split('_')[0],
+                AlarmMessage = message,
+                AlarmLevel = 4, // 升级到高级别
+                IsConfirmed = false
+            };
 
-                // 发送升级通知
-                _ = NotificationService.Instance.SendEscalationNotificationAsync(alarm);
+            _ = SaveAlarmAsync(alarm);
+            AlarmRaised?.Invoke(alarm);
 
-                Log.Warning("报警升级: {ConditionId} → 级别 {Level}", conditionId, escalatedLevel);
-            }
+            // 发送升级通知
+            _ = NotificationService.Instance.SendEscalationNotificationAsync(alarm);
+
+            Log.Warning("报警升级: {ConditionId} → 级别 {Level}", conditionId, alarm.AlarmLevel);
         }
     }
 
@@ -966,44 +1132,51 @@ public partial class AlarmService : ObservableObject, IDisposable
             }
 
             // 精确匹配 conditionId:优先精确匹配 AlarmType,其次按 AlarmMessage 包含关系匹配
-            var matchedKey = _activeAlarms.FirstOrDefault(k => k == alarm.AlarmType)
-                ?? _activeAlarms.FirstOrDefault(k => k.Contains(alarm.AlarmMessage) || alarm.AlarmMessage.Contains(k));
-
-            if (matchedKey != null)
-            {
-                _activeAlarms.Remove(matchedKey);
-                _alarmStartTimes.Remove(matchedKey);
-                _alarmTriggeredTimes.Remove(matchedKey);
-                _escalatedAlarms.Remove(matchedKey);
-                // 清除 DB Id 映射并回写恢复时间
-                lock (_activeAlarmDbIds)
-                {
-                    if (_activeAlarmDbIds.Remove(matchedKey))
-                        _ = UpdateRecoverTimeAsync(alarmId);
-                }
-            }
-            else
+            string? matchedKey;
+            List<string> keysToRemove;
+            List<int> idsToRecover;
+            lock (_stateLock)
             {
-                // 未找到精确匹配,清除同类型的所有活跃报警(由下次轮询重新评估)
-                var keysToRemove = _activeAlarms.Where(k => k.StartsWith(alarm.AlarmType)).ToList();
+                matchedKey = _activeAlarms.FirstOrDefault(k => k == alarm.AlarmType)
+                    ?? _activeAlarms.FirstOrDefault(k => k.Contains(alarm.AlarmMessage) || alarm.AlarmMessage.Contains(k));
+
+                // 未找到精确匹配时,清除同类型的所有活跃报警(由下次轮询重新评估)
+                keysToRemove = matchedKey != null
+                    ? new List<string> { matchedKey }
+                    : _activeAlarms.Where(k => k.StartsWith(alarm.AlarmType)).ToList();
+
+                idsToRecover = new List<int>();
                 foreach (var key in keysToRemove)
                 {
                     _activeAlarms.Remove(key);
                     _alarmStartTimes.Remove(key);
                     _alarmTriggeredTimes.Remove(key);
                     _escalatedAlarms.Remove(key);
-                    lock (_activeAlarmDbIds)
-                    {
-                        if (_activeAlarmDbIds.TryGetValue(key, out var id))
-                        {
-                            _activeAlarmDbIds.Remove(key);
-                            _ = UpdateRecoverTimeAsync(id);
-                        }
-                    }
+
+                    // 清除 DB Id 映射并记下需要回写恢复时间的记录
+                    if (_activeAlarmDbIds.Remove(key, out var mappedId))
+                        idsToRecover.Add(matchedKey != null ? alarmId : mappedId);
                 }
-                Log.Information("报警复位: 未找到精确匹配,清除 {Count} 条同类活跃报警", keysToRemove.Count);
             }
 
+            if (matchedKey == null && keysToRemove.Count == 0)
+            {
+                // 内存里没有对应的活跃报警 = 这是一条历史记录或诊断流水,没什么可复位的。
+                // 绝不能往下走:PulseResetAddressAsync 会真的写 PLC 复位输出,
+                // 现场那个地址可能同时是"报警复位/日台时清零",空点一下就把台时清掉了。
+                Log.Information("报警复位未命中活跃报警,已忽略(不写 PLC): {Id} - {Message}", alarmId, alarm.AlarmMessage);
+                return false;
+            }
+
+            if (matchedKey == null)
+                Log.Information("报警复位: 未找到精确匹配,清除 {Count} 条同类活跃报警", keysToRemove.Count);
+
+            // 先刷新界面状态:后面的写库/写 PLC 失败不应让报警角标停在复位前的值上
+            UpdateAlarmState();
+
+            foreach (var id in idsToRecover)
+                _ = UpdateRecoverTimeAsync(id);
+
             // 更新数据库记录
             await DatabaseService.Db.Updateable<AlarmRecord>()
                 .SetColumns(r => r.IsConfirmed, true)
@@ -1015,7 +1188,6 @@ public partial class AlarmService : ObservableObject, IDisposable
             // 点动 PLC 复位地址
             await PulseResetAddressAsync();
 
-            UpdateAlarmState();
             AuditService.Log(operatorName, "报警复位", $"报警ID:{alarmId} - {alarm.AlarmMessage}", alarm.AlarmType);
             Log.Information("报警 {Id} 已复位", alarmId);
             return true;
@@ -1028,25 +1200,31 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
     /// 复位所有活跃报警 — 清除所有活跃状态 + 点动 PLC 复位地址
     /// </summary>
-    public async Task ResetAllAlarmsAsync(string operatorName = "Operator")
+    /// <returns>实际清除的活跃报警条数;为 0 时不会写 PLC 复位地址</returns>
+    public async Task<int> ResetAllAlarmsAsync(string operatorName = "Operator")
     {
         try
         {
-            var count = _activeAlarms.Count;
+            int count;
+            List<int> idsToRecover;
 
             // 清除所有活跃报警跟踪数据
-            _activeAlarms.Clear();
-            _alarmStartTimes.Clear();
-            _alarmTriggeredTimes.Clear();
-            _escalatedAlarms.Clear();
-
-            // 回写所有活跃报警的恢复时间
-            List<int> idsToRecover;
-            lock (_activeAlarmDbIds)
+            lock (_stateLock)
             {
+                count = _activeAlarms.Count;
+                _activeAlarms.Clear();
+                _alarmStartTimes.Clear();
+                _alarmTriggeredTimes.Clear();
+                _escalatedAlarms.Clear();
+
+                // 回写所有活跃报警的恢复时间
                 idsToRecover = _activeAlarmDbIds.Values.ToList();
                 _activeAlarmDbIds.Clear();
             }
+
+            // 先刷新界面状态:下面的写库/写 PLC 失败不应让报警角标停在复位前的值上
+            UpdateAlarmState();
+
             foreach (var id in idsToRecover)
                 _ = UpdateRecoverTimeAsync(id);
 
@@ -1058,16 +1236,19 @@ public partial class AlarmService : ObservableObject, IDisposable
                 .Where(r => !r.IsConfirmed)
                 .ExecuteCommandAsync();
 
-            // 点动 PLC 复位地址
-            await PulseResetAddressAsync();
+            // 一条活跃报警都没有就别碰 PLC:复位地址在现场可能兼作"日台时清零",
+            // 空点一次就把累计台时清掉了
+            if (count > 0)
+                await PulseResetAddressAsync();
 
-            UpdateAlarmState();
             AuditService.Log(operatorName, "全部报警复位", $"清除 {count} 条活跃报警");
             Log.Information("所有报警已复位 (共 {Count} 条)", count);
+            return count;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "全部报警复位失败");
+            return 0;
         }
     }
 
@@ -1103,15 +1284,32 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     private void UpdateAlarmState()
     {
-        HasActiveAlarm = _activeAlarms.Count > 0;
-        ActiveAlarmCount = _activeAlarms.Count;
-        ActiveAlarmMessage = _activeAlarms.Count > 0
-            ? string.Join("; ", _activeAlarms.Take(3))
-            : string.Empty;
+        int count;
+        string message;
+        lock (_stateLock)
+        {
+            count = _activeAlarms.Count;
+            message = count > 0 ? string.Join("; ", _activeAlarms.Take(3)) : string.Empty;
+        }
+
+        // 属性赋值会触发 PropertyChanged(订阅者多为 UI),必须在锁外
+        HasActiveAlarm = count > 0;
+        ActiveAlarmCount = count;
+        ActiveAlarmMessage = message;
+        // 未确认数从 DB 查:内存集合只跟踪活跃状态,确认状态只在 DB 里
+        try { UnconfirmedAlarmCount = DatabaseService.Db.Queryable<AlarmRecord>().Where(r => !r.IsConfirmed).Count(); }
+        catch { /* DB 暂时不可用时保留上次计数 */ }
 
         AlarmStateChanged?.Invoke();
     }
 
+    /// <summary>确认/复位后手动刷新未确认计数(这些操作不触发 UpdateAlarmState)</summary>
+    public async Task RecountUnconfirmedAsync()
+    {
+        try { UnconfirmedAlarmCount = await DatabaseService.Db.Queryable<AlarmRecord>().Where(r => !r.IsConfirmed).CountAsync(); }
+        catch { /* DB 暂时不可用时保留上次计数 */ }
+    }
+
     /// <summary>
     /// 从数据库加载未确认报警
     /// </summary>

+ 25 - 5
src/YZWater.Core/Services/AuditService.cs

@@ -18,11 +18,30 @@ namespace YZWater.Core.Services;
 public static class AuditService
 {
     /// <summary>审计日志写入通道(有界队列,防密集写入时线程池打满)</summary>
-    private static readonly Channel<AuditLog> _writeChannel =
-        Channel.CreateBounded<AuditLog>(new BoundedChannelOptions(500)
+    private static readonly Channel<AuditLog> _writeChannel = Channel.CreateBounded<AuditLog>(
+        new BoundedChannelOptions(500)
         {
+            // 队列满时丢最旧。TryWrite 在 DropOldest 下仍然返回 true,所以丢弃只能靠 itemDropped 回调观测
             FullMode = BoundedChannelFullMode.DropOldest
-        });
+        },
+        OnEntryDropped);
+
+    /// <summary>队列满后被丢弃(DropOldest 丢最旧)的审计条数</summary>
+    private static long _droppedEntries;
+
+    /// <summary>每丢弃这么多条提示一次,避免队列持续满时告警自己刷屏</summary>
+    private const int DropWarnEvery = 50;
+
+    /// <summary>因审计队列满而被丢弃的审计条数(UI 用它提示记录不完整)</summary>
+    public static long DroppedEntryCount => Interlocked.Read(ref _droppedEntries);
+
+    /// <summary>队列满导致审计被丢弃:计数并周期性写日志,丢记录不能是静默的</summary>
+    private static void OnEntryDropped(AuditLog dropped)
+    {
+        var total = Interlocked.Increment(ref _droppedEntries);
+        if (total % DropWarnEvery == 1)
+            Serilog.Log.Warning("审计写入队列已满,最旧记录被丢弃(累计 {Total} 条),本条:{Action}", total, dropped.Action);
+    }
 
     /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
     private static readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
@@ -98,7 +117,7 @@ public static class AuditService
     /// <summary>
     /// 记录操作审计(通过 Channel 异步写入,不阻塞主线程)
     /// </summary>
-    public static void Log(string userName, string action, string detail, string? target = null, string result = "Success")
+    public static void Log(string userName, string action, string detail, string? target = null, string result = "Success", string? clientIp = null)
     {
         var entry = new AuditLog
         {
@@ -107,7 +126,8 @@ public static class AuditService
             Action = action,
             Detail = detail,
             Target = target,
-            Result = result
+            Result = result,
+            ClientIp = clientIp
         };
 
         // 通过 Channel 异步写入,不阻塞主线程

+ 21 - 17
src/YZWater.Core/Services/BackupService.cs

@@ -20,11 +20,8 @@ public static class BackupService
     private static Timer? _timer;
     private static int _backupInProgress; // 0=空闲, 1=备份中(防重入)
 
-    /// <summary>备份文件名(固定,每次覆盖)</summary>
-    private const string BackupFileName = "yzwater_backup.db";
-
-    /// <summary>定时备份间隔(默认 24 小时)</summary>
-    private static readonly TimeSpan BackupInterval = TimeSpan.FromHours(24);
+    /// <summary>备份文件前缀(每次备份带时间戳,不覆盖)</summary>
+    private const string BackupFilePrefix = "yzwater_backup_";
 
     /// <summary>备份完成事件</summary>
     public static event Action<bool, string>? BackupCompleted;
@@ -53,8 +50,11 @@ public static class BackupService
 
             Directory.CreateDirectory(BackupDir);
 
-            var backupPath = Path.Combine(BackupDir, BackupFileName);
-            var tempPath = Path.Combine(BackupDir, BackupFileName + ".tmp");
+            // 每次备份带时间戳,不覆盖旧备份——数据库损坏时能回退到更早的健康副本
+            var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+            var backupFileName = $"{BackupFilePrefix}{timestamp}.db";
+            var backupPath = Path.Combine(BackupDir, backupFileName);
+            var tempPath = Path.Combine(BackupDir, backupFileName + ".tmp");
             var db = DatabaseService.Db;
 
             // 步骤 1:WAL checkpoint(TRUNCATE 模式)
@@ -85,13 +85,12 @@ public static class BackupService
                 return (false, string.Empty);
             }
 
-            // 步骤 4:原子替换——删除旧备份,临时文件重命名为最终备份
-            if (File.Exists(backupPath)) File.Delete(backupPath);
+            // 步骤 4:重命名临时文件为最终备份(每次时间戳不同,不需要先删旧文件)
             File.Move(tempPath, backupPath);
 
             var backupInfo = new FileInfo(backupPath);
             var sourceInfo = new FileInfo(dbPath);
-            var message = $"数据库备份完成: {BackupFileName}({backupInfo.Length / 1024.0:F1} KB,源文件 {sourceInfo.Length / 1024.0:F1} KB)";
+            var message = $"数据库备份完成: {backupFileName}({backupInfo.Length / 1024.0:F1} KB,源文件 {sourceInfo.Length / 1024.0:F1} KB)";
             Log.Information(message);
 
             AuditService.Log("系统", "Backup", message);
@@ -102,8 +101,9 @@ public static class BackupService
         {
             Log.Error(ex, "数据库备份失败");
             // 备份失败时清理可能残留的临时文件
-            var tempPath = Path.Combine(BackupDir, BackupFileName + ".tmp");
-            TryDelete(tempPath);
+            var tempPath = Path.Combine(BackupDir, $"{BackupFilePrefix}*.db.tmp");
+            foreach (var f in Directory.GetFiles(BackupDir, "*.tmp"))
+                TryDelete(f);
             BackupCompleted?.Invoke(false, string.Empty);
             return (false, string.Empty);
         }
@@ -208,20 +208,23 @@ public static class BackupService
     {
         if (_timer != null) return; // 防止重复启动
 
+        // 从配置读取间隔:config 不存在时用默认 24 小时
+        var intervalHours = ConfigService.GetConfig()?.Backup?.BackupIntervalHours ?? 24;
+        var interval = TimeSpan.FromHours(intervalHours);
+
         _timer = new Timer(_ =>
         {
             try
             {
-                // 同步调用 BackupAsync,等待完成
                 BackupAsync().GetAwaiter().GetResult();
             }
             catch (Exception ex)
             {
                 Log.Error(ex, "定时备份异常");
             }
-        }, null, BackupInterval, BackupInterval);
+        }, null, interval, interval);
 
-        Log.Information($"定时备份已启动,间隔 {BackupInterval.TotalHours} 小时");
+        Log.Information($"定时备份已启动,间隔 {intervalHours} 小时");
     }
 
     /// <summary>
@@ -249,10 +252,11 @@ public static class BackupService
             Log.Error(ex, "启动时备份失败");
         }
 
-        // 清理旧备份
+        // 清理旧备份(从配置读保留数)
         try
         {
-            CleanupOldBackups();
+            var keepCount = ConfigService.GetConfig()?.Backup?.KeepBackupCount ?? 7;
+            CleanupOldBackups(keepCount);
         }
         catch (Exception ex)
         {

+ 17 - 5
src/YZWater.Core/Services/ConfigService.cs

@@ -41,8 +41,8 @@ public class ConfigService : IConfigService, IDisposable
     /// <summary>加载配置(静态兼容)</summary>
     public static void LoadConfig() => Instance.Reload();
 
-    /// <summary>保存配置(静态兼容)</summary>
-    public static void SaveConfig() => Instance.Save();
+    /// <summary>保存配置(静态兼容)。返回是否真的写进了文件。</summary>
+    public static bool SaveConfig() => Instance.Save();
 
     /// <summary>更新配置(静态兼容)</summary>
     public static void UpdateConfig(SystemConfig config) => Instance._UpdateConfig(config);
@@ -152,28 +152,40 @@ public class ConfigService : IConfigService, IDisposable
         // 触发配置变更事件
         if (_config != null)
         {
+            // 日志级别在这里生效:启动加载、FileSystemWatcher 热加载都走这条路径
+            LogService.ApplyMinimumLevel(_config.MinimumLogLevel);
             ConfigChanged?.Invoke(_config);
         }
     }
 
     /// <summary>
-    /// 保存配置到文件(原子写入:临时文件 → 覆盖)
+    /// 保存配置到文件(原子写入:临时文件 → 覆盖)。
+    /// 返回是否真的落盘:调用方不能假装成功 —— 内存里的值当场就生效,
+    /// 写失败只有重启后才会暴露,操作员却已经以为参数存好了。
     /// </summary>
-    public void Save()
+    public bool Save()
     {
         _ioLock.Wait();
+        bool saved;
         try
         {
-            if (!SaveUnlocked()) return;
+            saved = SaveUnlocked();
         }
         finally
         {
             _ioLock.Release();
         }
 
+        if (!saved) return false;
+
         // 触发配置变更事件
         if (_config != null)
+        {
+            // ViewB 里改了日志级别,保存后立刻生效
+            LogService.ApplyMinimumLevel(_config.MinimumLogLevel);
             ConfigChanged?.Invoke(_config);
+        }
+        return true;
     }
 
     /// <summary>

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

@@ -442,7 +442,7 @@ public class DataLoggingService : IDisposable
             var config = ConfigService.GetConfig();
             var flowRetention = config?.DataRetention?.FlowRecordRetentionDays ?? 360;
             var analogRetention = config?.DataRetention?.AnalogRecordRetentionDays ?? 360;
-            var alarmRetention = config?.DataRetention?.AlarmRecordRetentionDays ?? 90;
+            var alarmRetention = config?.DataRetention?.AlarmRecordRetentionDays ?? 180;
 
             var flowCutoff = DateTime.Now.AddDays(-flowRetention);
             var analogCutoff = DateTime.Now.AddDays(-analogRetention);

+ 3 - 3
src/YZWater.Core/Services/DataRetentionService.cs

@@ -14,7 +14,7 @@ namespace YZWater.Core.Services;
 ///   4. 配置化保留天数(通过 SystemConfig.DataRetention 配置)
 ///
 /// 清理范围:
-///   - alarm_records(报警记录,默认 90 天)
+///   - alarm_records(报警记录,默认 180 天)
 ///   - flow_records(流量记录,默认 360 天)
 ///   - analog_records(模拟量记录,默认 360 天)
 ///   - audit_logs(审计日志,默认 365 天)
@@ -52,7 +52,7 @@ public static class DataRetentionService
             // 1. 清理报警记录
             try
             {
-                var retentionDays = config?.DataRetention?.AlarmRecordRetentionDays ?? 90;
+                var retentionDays = config?.DataRetention?.AlarmRecordRetentionDays ?? 180;
                 var deleted = await CleanupTableAsync<AlarmRecord>(retentionDays);
                 totalDeleted += deleted;
             }
@@ -122,7 +122,7 @@ public static class DataRetentionService
             }
 
             Log.Information($"数据保留清理完成: 共删除 {totalDeleted} 条过期记录" +
-                         $"(报警 {config?.DataRetention?.AlarmRecordRetentionDays ?? 90}天, " +
+                         $"(报警 {config?.DataRetention?.AlarmRecordRetentionDays ?? 180}天, " +
                          $"流量 {config?.DataRetention?.FlowRecordRetentionDays ?? 360}天, " +
                          $"模拟量 {config?.DataRetention?.AnalogRecordRetentionDays ?? 360}天, " +
                          $"审计 {config?.DataRetention?.AuditLogRetentionDays ?? 365}天, " +

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

@@ -20,6 +20,8 @@ public class HubServer : IDisposable
 
     // 已连接的客户端
     private readonly ConcurrentDictionary<string, WebSocket> _clients = new();
+    /// <summary>客户端 IP 地址(按 clientId 索引,审计留痕用)</summary>
+    private readonly ConcurrentDictionary<string, string> _clientIps = new();
 
     // 操作锁
     private string? _lockOperator;
@@ -210,6 +212,9 @@ public class HubServer : IDisposable
                     var wsContext = await context.AcceptWebSocketAsync(null);
                     var clientId = Guid.NewGuid().ToString("N")[..8];
                     var ws = wsContext.WebSocket;
+                    var clientIp = context.Request.RemoteEndPoint?.Address.ToString();
+                    if (!string.IsNullOrEmpty(clientIp))
+                        _clientIps[clientId] = clientIp;
 
                     // 令牌认证
                     if (!string.IsNullOrEmpty(Token))
@@ -305,6 +310,7 @@ public class HubServer : IDisposable
                     {
                         await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "OK", CancellationToken.None);
                         _clients.TryRemove(clientId, out _);
+                        _clientIps.TryRemove(clientId, out _);
                         ReleaseLock(clientId);
                         ClientCountChanged?.Invoke(_clients.Count);
                         ws.Dispose();
@@ -340,6 +346,7 @@ public class HubServer : IDisposable
             ArrayPool<byte>.Shared.Return(buffer);
             messageBuffer.Dispose();
             _clients.TryRemove(clientId, out _);
+            _clientIps.TryRemove(clientId, out _);
             ReleaseLock(clientId);
             Log.Information("客户端断开: {Id}, 剩余 {Count} 个客户端", clientId, _clients.Count);
             ClientCountChanged?.Invoke(_clients.Count);
@@ -352,7 +359,11 @@ public class HubServer : IDisposable
     /// </summary>
     private async Task HandleCommandAsync(string clientId, WebSocket ws, CommandPayload command)
     {
-        Log.Debug("收到命令: {Action} from {Operator}, target={Target}", command.Action, command.Operator, command.Target);
+        // 服务端填充客户端 IP,审计写入时用
+        _clientIps.TryGetValue(clientId, out var ip);
+        command.ClientIp = ip;
+
+        Log.Debug("收到命令: {Action} from {Operator}, target={Target}, ip={Ip}", command.Action, command.Operator, command.Target, ip);
 
         switch (command.Action)
         {

+ 2 - 2
src/YZWater.Core/Services/Interfaces/IConfigService.cs

@@ -16,8 +16,8 @@ public interface IConfigService
     /// <summary>重新加载配置文件</summary>
     void Reload();
 
-    /// <summary>保存配置到文件</summary>
-    void Save();
+    /// <summary>保存配置到文件,返回是否真的落盘(写失败必须让调用方知道)</summary>
+    bool Save();
 
     /// <summary>导出配置到指定文件</summary>
     (bool Success, string Message) ExportConfig(string filePath);

+ 64 - 4
src/YZWater.Core/Services/LanguageService.cs

@@ -162,6 +162,13 @@ public partial class LanguageService : ObservableObject
         ["AlarmHistory"] = "报警历史记录",
         ["Unconfirmed"] = "未确认",
         ["AckAll"] = "全部确认",
+        ["OpConfirmed"] = "已确认 {0} 条报警",
+        ["OpNothingToConfirm"] = "没有待确认的报警(诊断记录由系统自动确认,无需人工确认)",
+        ["OpReset"] = "已复位 {0} 条活跃报警",
+        ["OpNothingToReset"] = "没有可复位的活跃报警,未向 PLC 写入复位",
+        ["OpRowReset"] = "报警 #{0} 已复位",
+        ["OpRowNotResettable"] = "该行不是活跃报警(历史记录或诊断记录),未向 PLC 写入复位",
+        ["RowLimitHint"] = "范围内共 {1} 条,界面仅装载最近 {0} 条,请缩小日期范围(导出不受限制)",
         ["Purge"] = "清除",
         ["Time"] = "时间",
         ["Type"] = "类型",
@@ -185,8 +192,8 @@ public partial class LanguageService : ObservableObject
         ["ConfigRestoredJournal"] = "配置文件读取失败,已自动恢复为上一次有效配置,请核对参数(原文件已另存为 {0})",
         ["ConfigSecretUnreadable"] = "配置文件中的加密项无法解密(程序目录可能由其它电脑或其它 Windows 账户复制而来),中控令牌等需重新输入,未重填前相关功能按空值运行",
         ["HubTokenMissingJournal"] = "中控服务已在无访问令牌的情况下启动,任意客户端均可下发控制命令,请在设置中填写令牌",
-        ["TagQualityLost"] = "测点 {0} 读取失败,界面显示为最后一次有效值,请勿据此值操作",
-        ["TagQualityRestored"] = "测点 {0} 数据质量已恢复,显示为实时值",
+        ["TagQualityLostSummary"] = "{0} 个测点读取失败,界面显示为最后一次有效值,请勿据此值操作:{1}",
+        ["TagQualityRestoredSummary"] = "{0} 个测点数据质量已恢复,显示为实时值:{1}",
         ["FirstOutFlag"] = "首出",
         ["SuppressedFlag"] = "抑制",
         ["FlagHeader"] = "标记",
@@ -408,6 +415,8 @@ public partial class LanguageService : ObservableObject
         ["SyncToPlc"] = "同步到PLC",
         ["ResetAlarm"] = "复位",
         ["Execute"] = "执行",
+        ["DoPulseSent"] = "已下发 {0}",
+        ["DoPulseStuck"] = "复位写入失败,输出可能仍为 ON",
         // ProjectView 阈值行标签
         ["StartPumpN"] = "启泵{0}",
         ["StopLevel"] = "停泵液位",
@@ -502,6 +511,27 @@ public partial class LanguageService : ObservableObject
         ["AuditSwitchToAuto"] = "切换自动",
         ["AuditWritePumpFreq"] = "写入泵频率: {0} Hz",
         ["AuditWritePumpFreqFailed"] = "泵频率写入失败: {0} Hz",
+        ["AuditDailyResetAll"] = "日台时统一清零",
+        ["AuditDoPulse"] = "DO 点动下发",
+        ["AuditClearAlarmHistory"] = "清除报警历史",
+        ["AuditClearDataHistory"] = "清除数据历史",
+        ["AlarmLevel1"] = "提示",
+        ["AlarmLevel2"] = "警告",
+        ["AlarmLevel3"] = "危险",
+        ["AlarmLevel4"] = "紧急",
+        ["AlarmLevel5"] = "紧急",
+        ["ShelvedListLabel"] = "搁置中",
+        ["ReviewLabel"] = "报警回顾",
+        ["NoShelvedAlarms"] = "(无搁置报警)",
+        ["NoReviewData"] = "(范围内无报警记录)",
+        ["Until"] = "至",
+        ["Active"] = "活跃",
+        ["Recovered"] = "已恢复",
+        ["Shelved"] = "搁置中",
+        ["ConfirmRow"] = "确认此条",
+        ["ResetRow"] = "复位此条",
+        ["AlarmDroppedHint"] = "报警写入队列曾满,累计丢弃 {0} 条(报警记录不完整)",
+        ["AuditDroppedHint"] = "审计写入队列曾满,累计丢弃 {0} 条(审计记录不完整)",
         // 连接状态服务
         ["ConnPlcConnected"] = "PLC: 已连接",
         ["ConnPlcDisconnected"] = "PLC: 未连接",
@@ -601,6 +631,13 @@ public partial class LanguageService : ObservableObject
         ["AlarmHistory"] = "Alarm History",
         ["Unconfirmed"] = "Unconfirmed",
         ["AckAll"] = "ACK ALL",
+        ["OpConfirmed"] = "Confirmed {0} alarm(s)",
+        ["OpNothingToConfirm"] = "No unconfirmed alarms (diary entries are auto-confirmed by the system)",
+        ["OpReset"] = "Reset {0} active alarm(s)",
+        ["OpNothingToReset"] = "No active alarms to reset; nothing written to PLC",
+        ["OpRowReset"] = "Alarm #{0} reset",
+        ["OpRowNotResettable"] = "Not an active alarm (history or diary row); nothing written to PLC",
+        ["RowLimitHint"] = "Showing the newest {0} of {1} rows in range — narrow the date range (export is not limited)",
         ["Purge"] = "PURGE",
         ["Time"] = "TIME",
         ["Type"] = "TYPE",
@@ -624,8 +661,8 @@ public partial class LanguageService : ObservableObject
         ["ConfigRestoredJournal"] = "Config file unreadable, automatically rolled back to the last valid configuration - please verify parameters (original saved as {0})",
         ["ConfigSecretUnreadable"] = "Encrypted config entries could not be decrypted (the program folder was probably copied from another PC or another Windows account) - re-enter the hub token and other secrets; related features run with empty values until then",
         ["HubTokenMissingJournal"] = "Central control server started without an access token - any client can issue control commands; please set the token in settings",
-        ["TagQualityLost"] = "Tag {0} read failed - last valid value is displayed, do not act on it",
-        ["TagQualityRestored"] = "Tag {0} data quality restored - live value is displayed",
+        ["TagQualityLostSummary"] = "{0} tag(s) failed to read - last valid values are displayed, do not act on them: {1}",
+        ["TagQualityRestoredSummary"] = "{0} tag(s) data quality restored - live values are displayed: {1}",
         ["FirstOutFlag"] = "1st",
         ["SuppressedFlag"] = "SUPP",
         ["FlagHeader"] = "FLAG",
@@ -826,6 +863,8 @@ public partial class LanguageService : ObservableObject
         ["SyncToPlc"] = "SYNC TO PLC",
         ["ResetAlarm"] = "RESET",
         ["Execute"] = "EXEC",
+        ["DoPulseSent"] = "SENT {0}",
+        ["DoPulseStuck"] = "RESET WRITE FAILED, OUTPUT MAY STAY ON",
         // ProjectView threshold row labels
         ["StartPumpN"] = "Start Pump {0}",
         ["StopLevel"] = "Stop Level",
@@ -920,6 +959,27 @@ public partial class LanguageService : ObservableObject
         ["AuditSwitchToAuto"] = "Switch to auto",
         ["AuditWritePumpFreq"] = "Write pump frequency: {0} Hz",
         ["AuditWritePumpFreqFailed"] = "Write pump frequency failed: {0} Hz",
+        ["AuditDailyResetAll"] = "Daily runtime reset (all pumps)",
+        ["AuditDoPulse"] = "DO pulse sent",
+        ["AuditClearAlarmHistory"] = "Clear alarm history",
+        ["AuditClearDataHistory"] = "Clear data history",
+        ["AlarmLevel1"] = "Info",
+        ["AlarmLevel2"] = "Warning",
+        ["AlarmLevel3"] = "Danger",
+        ["AlarmLevel4"] = "Critical",
+        ["AlarmLevel5"] = "Critical",
+        ["ShelvedListLabel"] = "Shelved",
+        ["ReviewLabel"] = "Alarm Review",
+        ["NoShelvedAlarms"] = "(no shelved alarms)",
+        ["NoReviewData"] = "(no alarm records in range)",
+        ["Until"] = "until",
+        ["Active"] = "Active",
+        ["Recovered"] = "Recovered",
+        ["Shelved"] = "Shelved",
+        ["ConfirmRow"] = "ACK ROW",
+        ["ResetRow"] = "RESET ROW",
+        ["AlarmDroppedHint"] = "{0} alarm record(s) dropped on write-queue overflow — history incomplete",
+        ["AuditDroppedHint"] = "{0} audit record(s) dropped on write-queue overflow — log incomplete",
         // Connection status service
         ["ConnPlcConnected"] = "PLC: Connected",
         ["ConnPlcDisconnected"] = "PLC: Disconnected",

+ 38 - 2
src/YZWater.Core/Services/LogService.cs

@@ -2,6 +2,7 @@ using Serilog;
 using Serilog.Core;
 using Serilog.Events;
 using YZWater.Core.Models;
+using YZWater.Core.Utils;
 
 namespace YZWater.Core.Services;
 
@@ -12,6 +13,15 @@ public static class LogService
 {
     private static bool _initialized;
 
+    /// <summary>
+    /// 运行时级别开关。现场排障时把配置里的 MinimumLogLevel 改成 Debug 就能拿到
+    /// 每轮的地址表/分组/批量读取/解析值,不用停下来重新发包。
+    /// </summary>
+    private static readonly LoggingLevelSwitch _levelSwitch = new(LogEventLevel.Information);
+
+    /// <summary>当前生效的最低日志级别</summary>
+    public static LogEventLevel MinimumLevel => _levelSwitch.MinimumLevel;
+
     /// <summary>日志事件(用于 LogBufferService 订阅)</summary>
     public static event Action<LogEntry>? OnLogAdded;
 
@@ -23,20 +33,46 @@ public static class LogService
         if (_initialized) return;
 
         Log.Logger = new LoggerConfiguration()
-            .MinimumLevel.Information()    // 生产环境使用 Information 级别,减少日志量
+            .MinimumLevel.ControlledBy(_levelSwitch) // 默认 Information,可由 ApplyMinimumLevel 热切
             .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
             .MinimumLevel.Override("System", LogEventLevel.Warning)
             .WriteTo.Console()
             .WriteTo.File(Path.Combine(AppPaths.Logs, "log-.txt"),
                 rollingInterval: RollingInterval.Day,
+                // 带 BOM 的 UTF-8,否则中文 Windows 上打开日志全是乱码(见 FileTextEncoding)
+                encoding: FileTextEncoding.Utf8WithBom,
                 buffered: true,            // 启用缓冲写入,提升性能
-                retainedFileCountLimit: 30) // 保留最近 30 天日志
+                // 缓冲要定时落盘:默认只在攒满约 4KB 时才写盘,断电或 taskkill /F 会把缓冲整个丢掉
+                // (实测强杀后日志文件 0 字节)。按当前 Information 的量,4KB 差不多是几十行
+                // ≈ 小半小时的采集快照,而事故后要看的恰恰就是最后那几行。
+                flushToDiskInterval: TimeSpan.FromSeconds(1),
+                // 只按天滚动 + 限条数时,单个文件没有上限:一次异常刷屏就能写满磁盘
+                // (现场实测一天 251MB)。限单文件 20MB,超量转下一份,最坏 60×20MB。
+                fileSizeLimitBytes: 20 * 1024 * 1024,
+                rollOnFileSizeLimit: true,
+                retainedFileCountLimit: 60)
             .WriteTo.Sink(new LogEventSink()) // 自定义 Sink,用于触发 OnLogAdded 事件
             .CreateLogger();
 
         _initialized = true;
     }
 
+    /// <summary>
+    /// 按配置切换最低日志级别。空值或非法值忽略,保持当前级别不变。
+    /// </summary>
+    public static void ApplyMinimumLevel(string? level)
+    {
+        if (!TryParseLevel(level, out var parsed)) return;
+        if (parsed == _levelSwitch.MinimumLevel) return;
+
+        var old = _levelSwitch.MinimumLevel;
+        _levelSwitch.MinimumLevel = parsed;
+        Log.Information("日志最低级别 {Old} -> {New}", old, parsed);
+    }
+
+    public static bool TryParseLevel(string? level, out LogEventLevel parsed)
+        => Enum.TryParse(level, ignoreCase: true, out parsed);
+
     /// <summary>
     /// 关闭并刷新日志缓冲区
     /// </summary>

+ 6 - 0
src/YZWater.Core/Services/MockPlcService.cs

@@ -14,6 +14,9 @@ public static class MockPlcService
     // 模拟状态
     private static float _tank1 = 50, _tank2 = 60, _tank3 = 45, _tank4 = 55;
     private static float _inflow = 30, _outflow = 28;
+    // 累积量:真实 PLC 的总流量是单调递增的,仿真必须照做,否则本地看不到"总流量一直是 0"
+    // 到底是显示问题还是 PLC 真值
+    private static float _totalFlow = 1280;
     private static bool _pump1 = true, _pump2 = false, _pump3 = true, _pump4 = false, _pump5 = false;
     private static bool _fan1 = true, _fan2 = false;
     private static float _pump1Freq = 45, _pump2Freq = 0, _pump3Freq = 40, _pump4Freq = 0, _pump5Freq = 0;
@@ -52,6 +55,8 @@ public static class MockPlcService
         // 模拟流量波动
         _inflow = Clamp(_inflow + RandomDelta(3f), 0, 200);
         _outflow = Clamp(_outflow + RandomDelta(2.5f), 0, 200);
+        // 瞬时流量单位 m³/h,一帧按 1 秒折算成 m³ 累加
+        _totalFlow += _inflow / 3600f;
 
         // 模拟泵频率波动
         if (_pump1) _pump1Freq = Clamp(_pump1Freq + RandomDelta(1f), 0, 50);
@@ -190,6 +195,7 @@ public static class MockPlcService
         if (key.EndsWith(".Value"))
         {
             if (key.StartsWith("压力")) return Clamp(_outflow * 0.003f + 0.25f + RandomDelta(0.02f), 0f, 1.6f);
+            if (key.StartsWith("总流量")) return _totalFlow;
             if (key.StartsWith("流量")) return Clamp(_inflow, 0f, 200f);
             return 0;
         }

+ 166 - 41
src/YZWater.Core/Services/PlcPollingService.cs

@@ -1,6 +1,7 @@
 using System.Collections.Concurrent;
 using CommunityToolkit.Mvvm.ComponentModel;
 using Serilog;
+using Serilog.Events;
 using YZWater.Core.Models;
 
 namespace YZWater.Core.Services;
@@ -45,6 +46,23 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
     public int MaxReconnectAttempts { get; set; } = 10;
     public int ReconnectBaseDelayMs { get; set; } = 1000;
 
+    /// <summary>
+    /// 会话已建立但读不到数据时,重新握手的间隔(秒)。
+    /// 必须限流:每秒一次 S7 握手会耗尽 CPU 的连接资源,反而让谁也连不上。
+    /// </summary>
+    public int UnverifiedReconnectSeconds { get; set; } = 5;
+
+    private DateTime _lastUnverifiedReconnectAt = DateTime.MinValue;
+
+    /// <summary>读取互斥:轮询线程与"立即刷新"按钮共用同一份 _data,不能并行读写</summary>
+    private readonly SemaphoreSlim _readLock = new(1, 1);
+
+    /// <summary>周期采集快照间隔(秒),0 = 关闭</summary>
+    public int LogSnapshotIntervalSeconds { get; set; } = 60;
+
+    // 上一次输出采集快照的时刻(DateTime.ToUniversalTime().Ticks,0 表示从未输出)
+    private long _lastSnapshotTicks;
+
     // ─── 数据变更事件 ───
     public event Action<PlcDataModel>? DataUpdated;
     public event Action<bool>? ConnectionStateChanged;
@@ -130,6 +148,12 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         {
             try
             {
+                // 周期采集快照放在循环最前面:连接失败路径上有两处 continue(降级模式与指数退避),
+                // 放在读数据之后会让"PLC 连不上"这种最该留证的场景一条快照都不打,
+                // 现场就无法区分"包是旧的"和"包是新的但链路不通"。
+                // 这里记录的是上一轮读到的值(最多滞后一个轮询周期),行内同时给出链路与数据有效性。
+                TryLogPeriodicSnapshot();
+
                 if (MockPlcService.IsEnabled)
                 {
                     // Mock 模式:直接生成模拟数据
@@ -145,8 +169,11 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 }
                 else
                 {
-                    // 真实模式:确保连接
-                    if (!PlcService.IsConnected)
+                    // 真实模式:确保连接。这里看的是"会话是否建立"而不是对外状态——
+                    // 会话通但读不到数据时对外同样是断的,如果按对外状态每轮重连,
+                    // 一次握手成功就把状态翻成"已连接"、紧接着的读取失败又翻回"中断",
+                    // 每秒翻转一次会让通信报警和界面指示灯抖动,重试计数还会被清零导致降级模式永远进不去。
+                    if (!PlcService.IsSocketConnected)
                     {
                         var connected = await TryConnectWithRetryAsync(reconnectAttempts, ct);
                         if (!connected)
@@ -197,19 +224,45 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         // 连接成功
                         if (isInDegradedMode)
                         {
-                            // 从降级模式恢复
-                            Log.Information($"PLC 连接恢复,退出降级模式(累计重试 {reconnectAttempts} 次)");
-                            ErrorOccurred?.Invoke($"PLC 连接已恢复");
                             isInDegradedMode = false;
 
-                            // 发送 PLC 恢复通知
-                            _ = NotificationService.Instance.SendPlcConnectionNotificationAsync(true);
+                            // 会话重建了,但只有真读得到数据才算"恢复":否则刚给操作员发完恢复通知,
+                            // 下一轮读取又把链路判成中断,等于自己发了一条假的全清
+                            if (PlcService.IsConnected)
+                            {
+                                Log.Information($"PLC 连接恢复,退出降级模式(累计重试 {reconnectAttempts} 次)");
+                                ErrorOccurred?.Invoke($"PLC 连接已恢复");
+
+                                // 发送 PLC 恢复通知
+                                _ = NotificationService.Instance.SendPlcConnectionNotificationAsync(true);
+                            }
+                            else
+                            {
+                                Log.Warning($"PLC 会话已重建但仍读不到数据,保持中断判定(累计重试 {reconnectAttempts} 次)");
+                            }
                         }
                         reconnectAttempts = 0;
                     }
+                    else if (!PlcService.IsConnected &&
+                             (DateTime.Now - _lastUnverifiedReconnectAt).TotalSeconds >= UnverifiedReconnectSeconds)
+                    {
+                        // 会话在、读不到数据(PLC 在 STOP、CPU 型号/机架槽填错、连接资源被占满):
+                        // 按固定节奏重新握手,链路的结论交给本轮读取去判,不拿握手结果当"已连接"。
+                        _lastUnverifiedReconnectAt = DateTime.Now;
+                        await PlcService.ConnectAsync();
+                    }
 
-                    // 读取数据
-                    await ReadAllDataAsync(ct);
+                    // 读取数据:与"立即刷新"按钮共用同一份 _data,必须串行,
+                    // 否则两个线程同时写 AnalogRegisters/DigitalRegisters 会把字典写坏
+                    await _readLock.WaitAsync(ct);
+                    try
+                    {
+                        await ReadAllDataAsync(ct);
+                    }
+                    finally
+                    {
+                        _readLock.Release();
+                    }
                 }
 
                 // 检测连接状态变化(链路中断由 ReadAllData* 在整轮读取全失败时判定)
@@ -248,6 +301,33 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         }
     }
 
+    /// <summary>
+    /// 每 LogSnapshotIntervalSeconds 输出一行"采集侧到底读回来多少"的常驻证据。
+    /// 每轮的地址表/分组/批量解析已降到 Debug(现场要看时把 MinimumLogLevel 改成 Debug 就回来),
+    /// 但这行不能省:泵停了流量显示 0 这类问题,第一个要回答的就是 PLC 当时的真实读数,
+    /// 而数据库里的 analog_records 只覆盖档案配置的统计点,不含阈值和电流。
+    /// </summary>
+    private void TryLogPeriodicSnapshot()
+    {
+        var interval = LogSnapshotIntervalSeconds;
+        if (interval <= 0 || !Log.IsEnabled(LogEventLevel.Information)) return;
+
+        var now = DateTime.UtcNow;
+        var last = Interlocked.Read(ref _lastSnapshotTicks);
+        if (last != 0 && (now - new DateTime(last, DateTimeKind.Utc)).TotalSeconds < interval) return;
+        Interlocked.Exchange(ref _lastSnapshotTicks, now.Ticks);
+
+        var registers = _data.AnalogRegisters;
+        var badCount = registers.Keys.Count(_data.IsTagBad);
+        var values = string.Join(" ", registers.OrderBy(kv => kv.Key, StringComparer.Ordinal)
+            .Select(kv => $"{kv.Key}={kv.Value:0.###}"));
+
+        Log.Information("采集快照 链路={Link} 数据={Valid} 测点={Total} Bad={Bad} | {Values}",
+            _data.IsPlcConnected ? "正常" : "中断",
+            _data.IsDataValid ? "有效" : "无效",
+            registers.Count, badCount, values);
+    }
+
     /// <summary>
     /// 尝试连接(带重试)
     /// </summary>
@@ -260,11 +340,13 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             var connected = await PlcService.ConnectAsync();
             if (connected)
             {
-                _data.IsPlcConnected = true;
-                if (!_lastNotifiedConnected)
+                // 握手成功不等于读得到数据,状态仍取对外语义(要等首次读取确认):
+                // 在这里硬写 true 就是"每秒翻转一次连接状态"的来源
+                _data.IsPlcConnected = PlcService.IsConnected;
+                if (_data.IsPlcConnected != _lastNotifiedConnected)
                 {
-                    _lastNotifiedConnected = true;
-                    ConnectionStateChanged?.Invoke(true);
+                    _lastNotifiedConnected = _data.IsPlcConnected;
+                    ConnectionStateChanged?.Invoke(_data.IsPlcConnected);
                 }
                 return true;
             }
@@ -525,12 +607,10 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         // 对于连续地址(如 VD500, VD504, VD508),使用批量读取优化
         // 立即物化 Task 数组,避免 IEnumerable 双重枚举导致 PLC 读取量翻倍
 
-        // 调试日志:记录所有需要读取的地址
-        Log.Information("模拟量读取地址列表 ({Count} 个):", analogReads.Count);
-        foreach (var r in analogReads.Take(20)) // 只记录前20个,避免日志过多
-        {
-            Log.Information("  {Key} -> {Addr}", r.key, r.addr);
-        }
+        // 诊断:地址表只在需要排查时看,打成 Information 会让每秒一个轮询周期写 40 行日志
+        if (Log.IsEnabled(LogEventLevel.Debug))
+            Log.Debug("模拟量读取地址列表 ({Count} 个): {List}", analogReads.Count,
+                string.Join(", ", analogReads.Select(r => $"{r.key}->{r.addr}")));
 
         // 尝试批量读取优化
         var batchResults = await TryBatchReadAsync(analogReads, ct);
@@ -581,10 +661,10 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                             _ => PlcService.ParseFloat(bytes, 0)
                         };
 
-                        // 调试日志:记录运行时间读取结果
+                        // 诊断:台时/分钟每个测点每周期一行,只保留到 Debug
                         if (actualKey.Contains("DailyHours") || actualKey.Contains("DailyMinutes"))
                         {
-                            Log.Information("运行时间解析(单独): {Key} = {Value} (地址={Address}, 类型={DataType}, 原始字节={Bytes})",
+                            Log.Debug("运行时间解析(单独): {Key} = {Value} (地址={Address}, 类型={DataType}, 原始字节={Bytes})",
                                 actualKey, parsed, r.addr, dataType, BitConverter.ToString(bytes));
                         }
 
@@ -662,9 +742,11 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             PlcService.MarkLinkDown($"本轮 {totalTags} 个测点全部读取失败");
 
         // 更新模拟量寄存器:读到的才覆盖,读不到的保留上一次有效值并标记质量为 Bad
+        var qualityLost = new List<string>();
+        var qualityRestored = new List<string>();
         foreach (var (key, value, readable) in analogResults)
         {
-            ApplyTagQuality(key, readable, notify: !linkDown);
+            ApplyTagQuality(key, readable, notify: !linkDown, qualityLost, qualityRestored);
             if (!readable) continue;
 
             if (_data.AnalogRegisters.TryGetValue(key, out var currentValue))
@@ -676,10 +758,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         // 更新数字量寄存器
         foreach (var (key, value, readable) in digitalResults)
         {
-            ApplyTagQuality(key, readable, notify: !linkDown);
+            ApplyTagQuality(key, readable, notify: !linkDown, qualityLost, qualityRestored);
             if (readable) _data.DigitalRegisters[key] = value;
         }
 
+        // 一轮最多两条质量流水。逐点写的话一次抖动就是"点位数 × 2"行,
+        // 报警历史会被刷满,真报警反而看不见;逐点明细仍完整留在日志文件里
+        FlushQualityJournal(qualityLost, qualityRestored);
+
         // 个别测点读不到不影响其余数据的有效性(改动前一个坏地址会让整轮作废)
         _data.LastReadTime = DateTime.Now;
         _data.IsDataValid = PlcService.IsConnected;
@@ -689,8 +775,10 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
     /// <summary>
     /// 记录单个测点质量。Good→Bad 跳变时通知操作员(坏点保留最后有效值,不用零值决策),
     /// 恢复时补一条"已恢复"。档案加载时的初始 Bad 不算跳变,避免启动时刷屏。
+    /// 这里只把跳变收进本轮的汇总清单,落库交给 <see cref="FlushQualityJournal"/>,一轮一条。
     /// </summary>
-    private void ApplyTagQuality(string key, bool readable, bool notify)
+    private void ApplyTagQuality(string key, bool readable, bool notify,
+        List<string> qualityLost, List<string> qualityRestored)
     {
         var (changed, wasReadable) = _data.SetTagQuality(key, readable);
         if (!changed) return;
@@ -700,17 +788,49 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             Log.Warning("测点质量转为 Bad: {Key}", key);
             if (!notify) return; // 链路中断由 PLC 连接报警统一说明
             _qualityAlarmedTags.TryAdd(key, 0);
-            AlarmService.Instance.RecordJournalAlarm(
-                string.Format(LanguageService.Instance.Get("TagQualityLost"), key));
+            qualityLost.Add(key);
         }
         else if (_qualityAlarmedTags.TryRemove(key, out _))
         {
             Log.Information("测点质量恢复: {Key}", key);
-            AlarmService.Instance.RecordJournalAlarm(
-                string.Format(LanguageService.Instance.Get("TagQualityRestored"), key));
+            qualityRestored.Add(key);
         }
     }
 
+    /// <summary>
+    /// 把本轮的测点质量跳变汇总成最多两条诊断流水(明细已在日志里,这里只给操作员看规模)
+    /// </summary>
+    private static void FlushQualityJournal(List<string> qualityLost, List<string> qualityRestored)
+    {
+        var (lost, restored) = BuildQualityJournalMessages(qualityLost, qualityRestored);
+        if (lost != null)
+            AlarmService.Instance.RecordJournalAlarm(lost, level: 1, alarmType: "Quality");
+        if (restored != null)
+            AlarmService.Instance.RecordJournalAlarm(restored, level: 1, alarmType: "Quality");
+    }
+
+    internal static (string? Lost, string? Restored) BuildQualityJournalMessages(
+        List<string> qualityLost, List<string> qualityRestored)
+    {
+        var lost = qualityLost.Count > 0
+            ? string.Format(LanguageService.Instance.Get("TagQualityLostSummary"),
+                qualityLost.Count, SummarizeKeys(qualityLost))
+            : null;
+        var restored = qualityRestored.Count > 0
+            ? string.Format(LanguageService.Instance.Get("TagQualityRestoredSummary"),
+                qualityRestored.Count, SummarizeKeys(qualityRestored))
+            : null;
+        return (lost, restored);
+    }
+
+    /// <summary>
+    /// 点位清单只保留前若干个,避免一条消息把整张表刷进报警历史
+    /// </summary>
+    private static string SummarizeKeys(List<string> keys, int show = 8) =>
+        keys.Count <= show
+            ? string.Join(", ", keys)
+            : string.Join(", ", keys.Take(show)) + $" …(共 {keys.Count} 个)";
+
     /// <summary>
     /// 尝试批量读取连续地址(优化性能)
     /// </summary>
@@ -731,15 +851,12 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             // 分组连续地址
             var groups = S7AddressParser.GroupConsecutiveAddresses(realReads);
 
-            // 调试日志:记录分组情况
-            Log.Information("地址分组情况: REAL地址数={RealCount}, 分组数={GroupCount}",
-                realReads.Count, groups.Count);
-            foreach (var g in groups)
-            {
-                Log.Information("  分组: 起始={Start}, 总字节={TotalBytes}, 地址数={ItemCount}, 地址列表={Items}",
-                    g.StartByte, g.TotalBytes, g.Items.Count,
-                    string.Join(", ", g.Items.Select(i => i.Key)));
-            }
+            // 诊断:合并成单行且降到 Debug,生产日志不再被分组明细刷满
+            if (Log.IsEnabled(LogEventLevel.Debug))
+                Log.Debug("地址分组情况: REAL地址数={RealCount}, 分组数={GroupCount}, 分组={Groups}",
+                    realReads.Count, groups.Count,
+                    string.Join(" | ", groups.Select(g =>
+                        $"起始{g.StartByte}/{g.TotalBytes}B/{string.Join(",", g.Items.Select(i => i.Key))}")));
 
             // 只对包含多个地址的分组进行批量读取
             var batchGroups = groups.Where(g => g.Items.Count >= 2).ToList();
@@ -771,7 +888,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         continue;
                     }
 
-                    Log.Information("批量读取成功: {Address}, 长度={Length}, 实际字节数={ActualLength}",
+                    Log.Debug("批量读取成功: {Address}, 长度={Length}, 实际字节数={ActualLength}",
                         batchAddr, group.TotalBytes, bytes.Length);
 
                     // 解析每个子项
@@ -780,10 +897,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         if (offset + 4 <= bytes.Length)
                         {
                             results[key] = PlcService.ParseFloat(bytes, offset);
-                            // 调试日志:记录电流读取结果
                             if (key.Contains("Current"))
                             {
-                                Log.Information("电流解析(批量): {Key} = {Value} (偏移={Offset})",
+                                Log.Debug("电流解析(批量): {Key} = {Value} (偏移={Offset})",
                                     key, results[key], offset);
                             }
                         }
@@ -837,7 +953,16 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
     {
         if (PlcService.IsConnected)
         {
-            await ReadAllDataAsync(CancellationToken.None);
+            // 与轮询线程串行:两边同时读写同一份 _data 会把 Dictionary/HashSet 写坏
+            await _readLock.WaitAsync();
+            try
+            {
+                await ReadAllDataAsync(CancellationToken.None);
+            }
+            finally
+            {
+                _readLock.Release();
+            }
             DataUpdated?.Invoke(_data);
         }
     }

+ 90 - 29
src/YZWater.Core/Services/PlcService.cs

@@ -25,16 +25,25 @@ public static class PlcService
     private static bool _isConnected;
 
     /// <summary>
-    /// 链路存疑:TCP 握得上但整轮读取全失败(CPU 停机、机架/插槽号填错、连接资源耗尽都会这样)。
-    /// 此时对操作员来说 PLC 就是断的——如果按 TCP 状态报"已连接",
-    /// 每秒一次"连上→读不到→判断"会让 PLC 通信报警反复起复(报警泛洪)。
+    /// 链路是否经过读取验证:本会话至少成功读到过一次数据。
+    /// 套接字握得上不代表读得到数据(CPU 停机、机架/插槽号填错、连接资源耗尽都会这样)。
+    /// 对操作员来说读不到数据就是断的——按 TCP 状态报"已连接"会让通信报警反复起复。
+    /// 这个判定只由读取结果决定,与 <see cref="_isConnected"/>(会话是否建立)分开,
+    /// 否则"判定断了"会把"会话还活着"一起抹掉,轮询循环就分不清该不该发起重连。
     /// </summary>
-    private static volatile bool _linkSuspect;
+    private static volatile bool _readVerified;
 
     /// <summary>
-    /// PLC 连接状态(对外语义为"链路可用":套接字已连且读得到数据)
+    /// PLC 连接状态(对外语义为"链路可用":会话已建立且读得到数据)
     /// </summary>
-    public static bool IsConnected => _isConnected && !_linkSuspect;
+    public static bool IsConnected => _isConnected && _readVerified;
+
+    /// <summary>
+    /// 会话(套接字)是否已建立,不含"读不到数据"的判定。
+    /// 只给轮询循环决定"要不要发起重连"用:链路只是读不到数据时按退避节奏重试即可,
+    /// 每秒握手一次会耗尽 S7 CPU 的连接资源;真正的断连则必须立刻重连。
+    /// </summary>
+    public static bool IsSocketConnected => _isConnected;
 
     /// <summary>
     /// PLC 连接状态变更事件(参数为当前是否连接)
@@ -42,17 +51,20 @@ public static class PlcService
     public static event Action<bool>? ConnectionChanged;
 
     /// <summary>
-    /// 最近一次对外发布的连接状态(= _isConnected && !_linkSuspect)。
-    /// 事件只能按它去重:TCP 状态没变而存疑标志变了的时候,对外语义确实变化了。
+    /// 最近一次对外发布的连接状态(= <see cref="IsConnected"/>)。
+    /// 事件只能按它去重:会话状态没变而读取验证结论变了的时候,对外语义确实变化了。
     /// </summary>
     private static bool _lastPublishedConnected;
 
     /// <summary>
-    /// 更新套接字连接状态并对外发布有效状态
+    /// 更新会话连接状态并对外发布有效状态
     /// </summary>
     private static void SetConnectionState(bool connected)
     {
         _isConnected = connected;
+        // 会话断了,"读到过数据"的结论一并作废:重连成功不等于读得到数据,
+        // 必须等下一次读取来重新确认(见 _readVerified)
+        if (!connected) _readVerified = false;
         PublishConnectionState();
     }
 
@@ -71,22 +83,16 @@ public static class PlcService
     /// </summary>
     public static void MarkLinkDown(string reason)
     {
-        if (!_isConnected || _linkSuspect) return;
+        if (!_isConnected || !_readVerified) return;
         Log.Warning("整轮 PLC 读取均失败,判定链路中断: {Reason}", reason);
-        _linkSuspect = true;
-        SetConnectionState(false);
+        _readVerified = false;
+        PublishConnectionState();
     }
 
     /// <summary>
-    /// 清除链路存疑标志(重新初始化/手动断连时调用;正常恢复由读成功自动清除)
+    /// 作废"读得到数据"的结论(换目标地址/重新初始化时调用;正常恢复由读成功自动确认)。
+    /// 只降级不升级:手上没有任何读取结果却报"已连接"是假的。
     /// </summary>
-    public static void ClearLinkSuspicion()
-    {
-        if (!_linkSuspect) return;
-        _linkSuspect = false;
-        PublishConnectionState();
-    }
-
     /// <summary>
     /// PLC 实例(向后兼容)
     /// </summary>
@@ -97,6 +103,17 @@ public static class PlcService
     /// </summary>
     public static IPlcDriver? Driver => _driver;
 
+    /// <summary>
+    /// 注入通信驱动,把连接状态清零。
+    /// 存在的理由是让"会话已建立但读不到数据"这类没有真 PLC 就复现不了的状态机能被打全;
+    /// 现场代码走的是 <see cref="Initialize"/> 里的 PlcDriverFactory,不会调这里。
+    /// </summary>
+    internal static void UseDriver(IPlcDriver? driver)
+    {
+        _driver = driver;
+        SetConnectionState(false);
+    }
+
     /// <summary>
     /// 初始化 PLC 连接
     /// </summary>
@@ -116,6 +133,13 @@ public static class PlcService
                 Log.Warning("未知的 PLC 协议 '{Protocol}',使用默认 SiemensS7", protocol);
             }
 
+            // 旧会话必须先关掉:下面每次都新建驱动并丢弃旧实例,旧 socket 会一直占着 PLC 侧的
+            // 连接资源(S7-200 Smart 并发连接数很少,界面上多点几次"测试连接"就能把它占满,
+            // 之后谁也连不上)。同时把状态清零:换了目标地址,上一台设备的连接结论不再适用。
+            _driver?.Dispose();
+            _plc?.ConnectClose();
+            SetConnectionState(false);
+
             // 使用 PlcDriverFactory 创建对应驱动
             _driver = PlcDriverFactory.Create(plcProtocol, plcIp, plcPort);
 
@@ -137,8 +161,6 @@ public static class PlcService
             }
 
             Log.Information("PLC 服务初始化完成,协议: {Protocol}, IP: {Ip}:{Port}", protocol, plcIp, plcPort);
-            // 换了目标地址,上一台设备的"链路存疑"结论不再适用
-            ClearLinkSuspicion();
         }
         catch (Exception ex)
         {
@@ -147,7 +169,9 @@ public static class PlcService
     }
 
     /// <summary>
-    /// 连接 PLC
+    /// 建立 PLC 会话。
+    /// 返回值只表示"会话建起来了",不等于链可用(对外状态看 <see cref="IsConnected"/>,
+    /// 它还要等一次成功的读取来确认)——把两者混在一起是"每秒翻转一次连接状态"的根源。
     /// </summary>
     public static async Task<bool> ConnectAsync()
     {
@@ -172,13 +196,14 @@ public static class PlcService
             SetConnectionState(result.IsSuccess);
             if (result.IsSuccess)
             {
-                Log.Information("PLC 连接成功");
+                // 握手成功≠读得到数据,所以这里不宣布"已连接",等首次读取来确认
+                Log.Information("PLC 会话已建立,链路状态待首次读取确认");
             }
             else
             {
                 Log.Warning("PLC 连接失败: {Message}", result.Message);
             }
-            return _isConnected;
+            return IsSocketConnected;
         }
         catch (Exception ex)
         {
@@ -188,6 +213,42 @@ public static class PlcService
         }
     }
 
+    /// <summary>
+    /// 一次性握手探测:新建临时驱动、连完就关,不碰共享会话、不改状态、不发连接事件。
+    /// 界面上的"测试连接"必须走这里 —— 走 <see cref="Initialize"/> + <see cref="ConnectAsync"/>
+    /// 会先把正在轮询的会话 Dispose 掉再重建,等于操作员点一下按钮自己把链路剪断,
+    /// 紧接着就是一出「PLC通信丢失」报警和一个数据缺口。
+    /// </summary>
+    public static async Task<(bool Ok, string Message)> ProbeAsync(string ip, int port, string protocol)
+    {
+        IPlcDriver? probe = null;
+        try
+        {
+            if (!Enum.TryParse<PlcProtocol>(protocol, out var plcProtocol))
+                plcProtocol = PlcProtocol.SiemensS7;
+
+            probe = PlcDriverFactory.Create(plcProtocol, ip, port);
+            var result = await probe.ConnectAsync();
+            if (result.IsSuccess)
+                Log.Information("PLC 探测 {Ip}:{Port} 成功", ip, port);
+            else
+                Log.Warning("PLC 探测 {Ip}:{Port} 失败: {Message}", ip, port, result.Message);
+            return (result.IsSuccess, result.Message);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "PLC 探测 {Ip}:{Port} 异常", ip, port);
+            return (false, ex.Message);
+        }
+        finally
+        {
+            // 收尾失败不能把探测结论改掉,但也不能漏掉 socket —— 200 Smart 的连接资源很少
+            try { probe?.Disconnect(); }
+            catch (Exception ex) { Log.Debug(ex, "PLC 探测会话关闭异常"); }
+            probe?.Dispose();
+        }
+    }
+
     /// <summary>
     /// 断开 PLC 连接
     /// </summary>
@@ -215,11 +276,11 @@ public static class PlcService
 
     private static void NoteReadSuccess(string address)
     {
-        // 只要有一个点读到,套接字就是真的通的,撤销"链路中断"判定
-        if (_linkSuspect)
+        // 只要有一个点读到,链路就是真的通的,确认"已连接"
+        if (!_readVerified)
         {
-            _linkSuspect = false;
-            Log.Information("PLC 链路恢复:测点 {Address} 读取成功", address);
+            _readVerified = true;
+            Log.Information("PLC 链路确认:测点 {Address} 读取成功", address);
             PublishConnectionState();
         }
 

+ 14 - 0
src/YZWater.Core/Utils/FileTextEncoding.cs

@@ -0,0 +1,14 @@
+using System.Text;
+
+namespace YZWater.Core.Utils;
+
+/// <summary>
+/// 供人阅读的文本文件编码。
+/// 中文 Windows 上记事本、Excel 打开 CSV、PowerShell 5.1 的 Get-Content 遇到无 BOM 的文件
+/// 一律按 GBK 解释,日志里的设备名和报警文本就成乱码;带 BOM 后这些工具才认得是 UTF-8。
+/// .NET/VSCode 读取时会自动剥掉 BOM,追加写入时 StreamWriter 不会在文件中间再写一次 BOM。
+/// </summary>
+public static class FileTextEncoding
+{
+    public static readonly Encoding Utf8WithBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
+}

+ 1 - 1
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -384,7 +384,7 @@ public partial class MainViewModel : TranslatableViewModelBase
         SetActiveTab(0);
     }
     [RelayCommand] private void ShowViewB() { CurrentView = _viewBViewModel; SetActiveTab(1); }
-    [RelayCommand] private void ShowViewC() { CurrentView = _viewCViewModel; SetActiveTab(2); }
+    [RelayCommand] private void ShowViewC() { _viewCViewModel.RefreshOnShow(); CurrentView = _viewCViewModel; SetActiveTab(2); }
     [RelayCommand] private void ShowViewD() { CurrentView = _viewDViewModel; SetActiveTab(3); }
     [RelayCommand] private void ShowViewE() { CurrentView = _viewEViewModel; SetActiveTab(4); }
     [RelayCommand] private void ShowViewF() { _viewFViewModel.LoadWorkflows(); CurrentView = _viewFViewModel; SetActiveTab(5); }

+ 73 - 46
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -317,13 +317,15 @@ public partial class ProjectViewModel : TranslatableViewModelBase
             action();
     }
 
-    /// <summary>报警状态变更回调 — 同步活跃报警数到标题栏角标(轮询线程回调,需切回 UI 线程)</summary>
+    /// <summary>报警状态变更回调 — 同步未确认报警数到标题栏角标(ISA-18.2:角标显示需要操作员注意的数量)</summary>
     private void OnAlarmStateChanged()
     {
         RunOnUi(() =>
         {
-            HasActiveAlarm = AlarmService.Instance.HasActiveAlarm;
-            ActiveAlarmCount = AlarmService.Instance.ActiveAlarmCount;
+            // 角标按未确认数显示:活跃但已确认的不算,恢复但未确认的仍要算
+            var unconfirmed = AlarmService.Instance.UnconfirmedAlarmCount;
+            HasActiveAlarm = unconfirmed > 0;
+            ActiveAlarmCount = unconfirmed;
             OnPropertyChanged(nameof(HasFaultContent));
             OnPropertyChanged(nameof(ShowFaultSection));
         });
@@ -382,8 +384,6 @@ public partial class ProjectViewModel : TranslatableViewModelBase
             sensor.UpdateFromData(data);
         foreach (var di in DigitalInputs)
             di.UpdateFromData(data);
-        foreach (var doItem in DigitalOutputs)
-            doItem.UpdateFromData(data);
 
         // 泵组运行概览 + 启泵轮换建议(磨损均衡)
         UpdatePumpOverview();
@@ -506,10 +506,18 @@ public partial class ProjectViewModel : TranslatableViewModelBase
             return;
         }
 
-        Log.Information("日台时统一清零: {Addr}", firstPump.DailyResetAddress);
-        await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, true);
+        var addr = firstPump.DailyResetAddress;
+        Log.Information("日台时统一清零: {Addr}", addr);
+        var setOk = await PlcService.WriteBoolAsync(addr, true);
         await Task.Delay(200);
-        await PlcService.WriteBoolAsync(firstPump.DailyResetAddress, false);
+        var resetOk = await PlcService.WriteBoolAsync(addr, false);
+
+        // 三台泵共用这一个清零地址,审计里必须写清"影响全部泵",不然事后只看得到一个泵名
+        AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+            T("AuditDailyResetAll"),
+            $"{addr} → 全部泵(共用清零地址)",
+            string.Join(",", _profile.Pumps.Select(p => p.Name)),
+            setOk && resetOk ? "Success" : "Failed");
     }
 
     /// <summary>
@@ -555,26 +563,35 @@ public partial class ProjectViewModel : TranslatableViewModelBase
                         : string.Format(T("MixedMode"), centralCount, total);
     }
 
+    /// <summary>
+    /// 连接状态变化回调 — 轮询线程触发,必须切回 UI 线程:
+    /// NotifyCanExecuteChanged 会让 Button 重新读取 Command(Avalonia VerifyAccess),
+    /// 在轮询线程上抛异常不仅让按钮永久停在初始的置灰状态(现场表现为"切中控"点不动),
+    /// 还会中断事件的多播,使后面订阅者一起收不到通知。
+    /// </summary>
     private void OnConnectionStateChanged(bool connected)
     {
-        var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
-        // Mock 模式下不伪装连接状态,诚实显示"模拟模式"
-        IsPlcConnected = mode != "Mock" && connected;
-        PlcStatusText = mode switch
+        RunOnUi(() =>
         {
-            "Mock" => T("MockModeStatus"),
-            _ => connected ? T("PlcConnected") : T("PlcDisconnected")
-        };
+            var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
+            // Mock 模式下不伪装连接状态,诚实显示"模拟模式"
+            IsPlcConnected = mode != "Mock" && connected;
+            PlcStatusText = mode switch
+            {
+                "Mock" => T("MockModeStatus"),
+                _ => connected ? T("PlcConnected") : T("PlcDisconnected")
+            };
 
-        // 连接状态变化 → 刷新所有泵的命令可执行状态(禁用/启用控制按钮)
-        foreach (var pump in Pumps)
-        {
-            pump.StartCommand?.NotifyCanExecuteChanged();
-            pump.StopCommand?.NotifyCanExecuteChanged();
-            pump.TogglePumpCommand?.NotifyCanExecuteChanged();
-            pump.ToggleManualModeCommand?.NotifyCanExecuteChanged();
-        }
-        ToggleControlModeCommand?.NotifyCanExecuteChanged();
+            // 连接状态变化 → 刷新所有泵的命令可执行状态(禁用/启用控制按钮)
+            foreach (var pump in Pumps)
+            {
+                pump.StartCommand?.NotifyCanExecuteChanged();
+                pump.StopCommand?.NotifyCanExecuteChanged();
+                pump.TogglePumpCommand?.NotifyCanExecuteChanged();
+                pump.ToggleManualModeCommand?.NotifyCanExecuteChanged();
+            }
+            ToggleControlModeCommand?.NotifyCanExecuteChanged();
+        });
     }
 
     // ─── 2D/3D 视图切换 ───
@@ -992,17 +1009,6 @@ public partial class PumpItemViewModel : TranslatableViewModelBase
 
         return (true, "");
     }
-
-    /// <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>
@@ -1483,15 +1489,17 @@ public partial class DigitalOutputItemViewModel : TranslatableViewModelBase
 
     public string Name => _config.Name.GetDisplayName(_config.Names);
 
-    /// <summary>PLC 寄存器键用的原始设备名(见 <see cref="PumpItemViewModel.KeyName"/>)</summary>
-    public string KeyName => _config.Name;
-
     public string Address => _config.Address;
     public string Remark => _config.Remark;
 
-    [ObservableProperty] private bool _isActive;
     [ObservableProperty] private bool _isPulsing;
 
+    /// <summary>
+    /// 最近一次下发结果。DO 不在轮询表里(泵组逻辑由 PLC 程序负责),
+    /// 所以这里只能说"上位机有没有把指令写进去",不能当成 PLC 当前输出来显示。
+    /// </summary>
+    [ObservableProperty] private string _lastPulseText = "";
+
     // ─── 翻译属性 ───
     public string ExecuteText => T("Execute");
 
@@ -1508,23 +1516,42 @@ public partial class DigitalOutputItemViewModel : TranslatableViewModelBase
         OnPropertyChanged(nameof(Name));
     }
 
-    public void UpdateFromData(PlcDataModel data)
-    {
-        IsActive = data.GetDigitalRegister($"DO.{KeyName}");
-    }
-
     /// <summary>点动写入(短暂置位后复位)</summary>
     [RelayCommand]
     private async Task PulseAsync()
     {
         if (string.IsNullOrEmpty(Address) || IsPulsing) return;
         IsPulsing = true;
+        var time = DateTime.Now.ToString("HH:mm:ss");
         try
         {
             Log.Information("DO 点动: {Name} → {Addr}", Name, Address);
-            await PlcService.WriteBoolAsync(Address, true);
+            var setOk = await PlcService.WriteBoolAsync(Address, true);
             await Task.Delay(200);
-            await PlcService.WriteBoolAsync(Address, false);
+            var resetOk = await PlcService.WriteBoolAsync(Address, false);
+
+            if (!setOk)
+                LastPulseText = $"{T("WriteFailed")} {time}";
+            else if (!resetOk)
+                // 置位成功但复位没写进去,输出会一直停在 ON,必须跟"普通失败"区分开
+                LastPulseText = $"{T("DoPulseStuck")} {time}";
+            else
+                LastPulseText = string.Format(T("DoPulseSent"), time);
+
+            // 下发给现场输出的指令必须留痕,尤其"置位成功、复位失败"这种输出还卡在 ON 的情况
+            AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+                T("AuditDoPulse"),
+                setOk && !resetOk ? $"{Name} → {Address}(置位成功,复位写入失败,输出可能仍为 ON)"
+                                  : $"{Name} → {Address}",
+                Name,
+                setOk && resetOk ? "Success" : "Failed");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "DO 点动异常: {Name} → {Addr}", Name, Address);
+            LastPulseText = $"{T("WriteFailed")} {time}";
+            AuditService.Log(AuthService.CurrentUser?.DisplayName ?? "Operator",
+                T("AuditDoPulse"), $"{Name} → {Address} 异常: {ex.Message}", Name, "Failed");
         }
         finally
         {

+ 89 - 20
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Serilog;
 using System.Collections.ObjectModel;
+using YZWater.Core.Models;
 using YZWater.Core.Services;
 
 namespace YZWater.Core.ViewModels;
@@ -118,8 +119,10 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             if (config != null)
             {
                 config.RunAtStartup = value;
-                ConfigService.SaveConfig();
-                Log.Information("开机自启动已{Status}", value ? "启用" : "禁用");
+                if (ConfigService.SaveConfig())
+                    Log.Information("开机自启动已{Status}", value ? "启用" : "禁用");
+                else
+                    Log.Error("开机自启动设置未能写入配置文件,重启后回到原设置");
             }
         }
     }
@@ -158,6 +161,14 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             return;
         }
 
+        var oldPlcIp = config.PlcIp;
+        var oldPlcPort = config.PlcPort;
+        var oldAutoConnect = config.AutoConnect;
+        var oldLevelHigh = config.LevelHighAlarm;
+        var oldLevelLow = config.LevelLowAlarm;
+        var oldFlowHigh = config.FlowHighAlarm;
+        var oldFrequency = config.PumpFrequency;
+
         config.PlcIp = PlcIp;
         config.PlcPort = PlcPort;
         config.AutoConnect = AutoConnect;
@@ -166,12 +177,61 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         config.FlowHighAlarm = FlowHighAlarm;
         config.PumpFrequency = PumpFrequency;
 
-        ConfigService.SaveConfig();
-        Log.Information("配置已保存");
+        // 审计要能回答"谁把哪个参数从多少改成了多少",只记"保存过配置"事后对不上账
+        var changes = DescribeChanges(oldPlcIp, oldPlcPort, oldAutoConnect,
+            oldLevelHigh, oldLevelLow, oldFlowHigh, oldFrequency, config);
+
+        // 写不进去必须当场说:内存里的值立刻就生效了,操作员看不出任何差别,
+        // 直到重启才发现参数回到旧值
+        if (ConfigService.SaveConfig())
+        {
+            Log.Information("配置已保存");
+            AuditService.Log(AuditUser, "Config", changes, "ViewB");
+
+            // 通信参数变了才重建会话:驱动是启动时按旧地址建的,不重建的话新地址要等重启才生效
+            // (早先这个重建挂在"测试连接"上,把不该受影响的运行链路也剪了)
+            if (oldPlcIp != config.PlcIp || oldPlcPort != config.PlcPort)
+            {
+                PlcService.Initialize();
+                Log.Information("PLC 通信参数已变更为 {Ip}:{Port},会话已重建,等待下一轮轮询握手",
+                    config.PlcIp, config.PlcPort);
+            }
+        }
+        else
+        {
+            Log.Error("配置未能写入文件,重启后会回到旧值(失败原因见上一条日志)");
+            AuditService.Log(AuditUser, "Config", changes, "ViewB", "Failed");
+            AlarmService.Instance.RecordJournalAlarm("参数未能保存,重启后会回到旧值", 2);
+        }
     }
 
+    /// <summary>审计里的操作用户:EnableAuth=false 时没有登录人,记为"系统"而不是伪造一个名字</summary>
+    private static string AuditUser => AuthService.CurrentUser?.DisplayName ?? "系统";
+
+    /// <summary>列出本次实际改动的参数(旧值→新值),全没变就记"无参数变更"</summary>
+    private static string DescribeChanges(
+        string oldIp, int oldPort, bool oldAutoConnect,
+        float oldLevelHigh, float oldLevelLow, float oldFlowHigh, float oldFrequency,
+        SystemConfig config)
+    {
+        var changes = new List<string>();
+        if (oldIp != config.PlcIp) changes.Add($"PLC IP: {oldIp} → {config.PlcIp}");
+        if (oldPort != config.PlcPort) changes.Add($"PLC 端口: {oldPort} → {config.PlcPort}");
+        if (oldAutoConnect != config.AutoConnect) changes.Add($"自动连接: {oldAutoConnect} → {config.AutoConnect}");
+        if (Math.Abs(oldLevelHigh - config.LevelHighAlarm) > 0.001f) changes.Add($"液位高报: {oldLevelHigh} → {config.LevelHighAlarm}");
+        if (Math.Abs(oldLevelLow - config.LevelLowAlarm) > 0.001f) changes.Add($"液位低报: {oldLevelLow} → {config.LevelLowAlarm}");
+        if (Math.Abs(oldFlowHigh - config.FlowHighAlarm) > 0.001f) changes.Add($"流量高报: {oldFlowHigh} → {config.FlowHighAlarm}");
+        if (Math.Abs(oldFrequency - config.PumpFrequency) > 0.001f) changes.Add($"泵频率: {oldFrequency} → {config.PumpFrequency}");
+
+        return changes.Count > 0
+            ? string.Join("; ", changes)
+            : "保存参数配置(无参数变更)";
+    }
     /// <summary>
-    /// 测试连接
+    /// 测试连接 —— 只探测,不动正在跑的会话。
+    /// 早先这里是 Initialize()+ConnectAsync(),会把轮询用的驱动 Dispose 重建,
+    /// 结果操作员点一下"测试连接"就自己制造一次 PLC 断链(跟着就是一出通信中断报警 + 数据缺口)。
+    /// 换地址这件事改由「保存配置」在参数真的变了时重建会话。
     /// </summary>
     [RelayCommand]
     private async Task TestConnectionAsync()
@@ -181,19 +241,22 @@ public partial class ViewBViewModel : TranslatableViewModelBase
 
         try
         {
-            PlcService.Initialize();
+            var config = ConfigService.GetConfig();
+            // 用输入框里的当前值去探测:改了地址还没点保存时,测的必须是新地址,
+            // 而不是配置文件里那台旧设备
+            var ip = string.IsNullOrWhiteSpace(PlcIp) ? (config?.PlcIp ?? "") : PlcIp;
+            var port = PlcPort;
+            var protocol = config?.PlcProtocol ?? "SiemensS7";
 
-            var success = await PlcService.ConnectAsync();
-            ConnectionStatus = success ? T("ConnectSuccess") : T("ConnectFailed");
+            var (ok, message) = await PlcService.ProbeAsync(ip, port, protocol);
+            ConnectionStatus = ok
+                ? T("ConnectSuccess")
+                : $"{T("ConnectFailed")}:{Truncate(message, 60)}";
 
-            if (success)
-            {
-                Log.Information("PLC 连接测试成功");
-            }
+            if (ok)
+                Log.Information("PLC 连接测试成功: {Ip}:{Port}", ip, port);
             else
-            {
-                Log.Warning("PLC 连接测试失败");
-            }
+                Log.Warning("PLC 连接测试失败: {Ip}:{Port} — {Message}", ip, port, message);
         }
         catch (Exception ex)
         {
@@ -206,6 +269,9 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         }
     }
 
+    private static string Truncate(string text, int max) =>
+        string.IsNullOrEmpty(text) || text.Length <= max ? text : text[..max] + "…";
+
     /// <summary>
     /// 写入泵频率到 PLC
     /// </summary>
@@ -231,12 +297,12 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             if (success)
             {
                 Log.Information("泵频率已写入 PLC: {Freq} Hz", PumpFrequency);
-                AuditService.Log("系统", "Control", string.Format(T("AuditWritePumpFreq"), PumpFrequency), PlcConfig.PumpFreqSetpoint);
+                AuditService.Log(AuditUser, "Control", string.Format(T("AuditWritePumpFreq"), PumpFrequency), PlcConfig.PumpFreqSetpoint);
             }
             else
             {
                 Log.Error("泵频率写入失败");
-                AuditService.Log("系统", "Control", string.Format(T("AuditWritePumpFreqFailed"), PumpFrequency), PlcConfig.PumpFreqSetpoint, "Failed");
+                AuditService.Log(AuditUser, "Control", string.Format(T("AuditWritePumpFreqFailed"), PumpFrequency), PlcConfig.PumpFreqSetpoint, "Failed");
             }
         }
         catch (Exception ex)
@@ -251,10 +317,13 @@ public partial class ViewBViewModel : TranslatableViewModelBase
     [RelayCommand]
     private void ResetToDefault()
     {
-        PlcIp = "192.168.2.100";
-        PlcPort = 102;
+        PlcIp = SystemConfig.DefaultPlcIp;
+        PlcPort = SystemConfig.DefaultPlcPort;
+        Log.Information("已填入默认 PLC 连接参数: IP={PlcIp}, Port={PlcPort}", PlcIp, PlcPort);
 
-        Log.Information("已恢复默认 PLC 连接参数: IP={PlcIp}, Port={PlcPort}", PlcIp, PlcPort);
+        // 只填输入框、不写配置的话,界面看着已经换了地址,实际跑的会话和重启后的值
+        // 还是旧的 —— 现场日志里操作员因此连点 5 次"测试连接"全部失败(测的仍是 127.0.0.1:103)
+        SaveConfig();
     }
 
     public new void Dispose()

+ 68 - 12
src/YZWater.Core/ViewModels/ViewCViewModel.cs

@@ -9,6 +9,7 @@ using SkiaSharp;
 using Serilog;
 using YZWater.Core.Models;
 using YZWater.Core.Services;
+using YZWater.Core.Utils;
 
 namespace YZWater.Core.ViewModels;
 
@@ -345,6 +346,18 @@ public partial class ViewCViewModel : TranslatableViewModelBase
                     AddPointToSeries(group.Series[i], now, value);
                 }
             }
+
+            // 实时点比窗口右边界更新时把边界推出去,否则新点会被裁到画面外,
+            // 曲线看起来又是"自己消失了"
+            var nowTicks = now.Ticks;
+            foreach (var group in ChartGroups)
+            {
+                foreach (var axis in group.XAxes)
+                {
+                    if (axis.MaxLimit == null || axis.MaxLimit < nowTicks)
+                        axis.MaxLimit = nowTicks;
+                }
+            }
         }
         catch (Exception ex)
         {
@@ -354,19 +367,40 @@ public partial class ViewCViewModel : TranslatableViewModelBase
 
     private static void AddPointToSeries(ISeries series, DateTime time, float value)
     {
-        if (series is LineSeries<DateTimePoint> lineSeries)
+        if (series is not LineSeries<DateTimePoint> lineSeries) return;
+
+        var values = lineSeries.Values?.ToList() ?? new List<DateTimePoint>();
+        var bucket = GetTimeGroupKey(time);
+
+        // 轮询是 1 秒一次,历史曲线是 5 分钟一个点。直接追加的话,
+        // 上限会在两分钟内把刚查出来的历史整段挤掉,操作员看到的是"曲线自己消失了"。
+        // 所以实时点并进同一个 5 分钟桶里覆盖,不新增点。
+        var last = -1;
+        for (var i = values.Count - 1; i >= 0; i--)
         {
-            var values = lineSeries.Values?.ToList() ?? new List<DateTimePoint>();
-            values.Add(new DateTimePoint(time, value));
-            if (values.Count > 120) values.RemoveAt(0);
-            lineSeries.Values = values.ToArray();
+            if (values[i].DateTime == bucket) { last = i; break; }
+            if (values[i].DateTime < bucket) break;
         }
+
+        if (last >= 0) values[last] = new DateTimePoint(bucket, value);
+        else values.Add(new DateTimePoint(bucket, value));
+
+        // 页面长期不刷新时桶数仍会缓慢增长(选 30 天时约 8600 个),留一个足够宽的上限
+        if (values.Count > 20000) values.RemoveRange(0, values.Count - 20000);
+
+        lineSeries.Values = values;
     }
 
     // ═══════════════════════════════════════════════
     //  历史数据查询
     // ═══════════════════════════════════════════════
 
+    /// <summary>
+    /// 切到本页时重新按当前窗口取数。VM 是单例、只在启动时查一次的话,
+    /// 操作员不点"查询"看到的就是启动那一刻的旧窗口。
+    /// </summary>
+    public void RefreshOnShow() => _ = LoadFlowRecordsAsync();
+
     [RelayCommand]
     private void SetTimeRange(string range)
     {
@@ -385,8 +419,7 @@ public partial class ViewCViewModel : TranslatableViewModelBase
     }
 
     [RelayCommand]
-    private async Task LoadFlowRecordsAsync()
-    {
+    private async Task LoadFlowRecordsAsync()    {
         try
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-1);
@@ -424,6 +457,18 @@ public partial class ViewCViewModel : TranslatableViewModelBase
             // 收集所有需要查询的 TagName
             var allTagNames = ChartGroups.SelectMany(g => g.TagNames).Distinct().ToList();
 
+            // 时间轴必须跟着查询窗口走。CreateTimeAxis 里的 Min/MaxLimit 是构造时算的
+            // "启动时刻往前 24 小时",不更新的话启动之后产生的数据全被裁到窗口右边之外,
+            // 曲线看起来就是"过一会儿自己消失了"。
+            foreach (var group in ChartGroups)
+            {
+                foreach (var axis in group.XAxes)
+                {
+                    axis.MinLimit = start.Ticks;
+                    axis.MaxLimit = end.Ticks;
+                }
+            }
+
             if (allTagNames.Count > 0)
             {
                 // 加载模拟量记录
@@ -484,7 +529,10 @@ public partial class ViewCViewModel : TranslatableViewModelBase
     {
         try
         {
-            var filePath = $"FlowRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
+            // 锚定 exe 目录而不是当前工作目录:从快捷方式("起始位置"被填成别的目录)、
+            // 计划任务启动时,裸文件名会把导出落到别处甚至写失败,而日志里只记了个相对名,
+            // 操作员根本找不到文件去哪了
+            var filePath = Path.Combine(AppPaths.Root, $"FlowRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
             var profile = ProjectService.Current;
 
             var lines = new List<string>();
@@ -537,7 +585,8 @@ public partial class ViewCViewModel : TranslatableViewModelBase
                 }
             }
 
-            await File.WriteAllLinesAsync(filePath, lines);
+            // 带 BOM:Excel 打开无 BOM 的 UTF-8 CSV 会按 GBK 解释,中文列名和备注成乱码
+            await File.WriteAllLinesAsync(filePath, lines, FileTextEncoding.Utf8WithBom);
             Log.Information("数据已导出到: {FilePath}", filePath);
         }
         catch (Exception ex)
@@ -558,15 +607,22 @@ public partial class ViewCViewModel : TranslatableViewModelBase
             var flowCutoff = DateTime.Now.AddDays(-flowRetention);
             var analogCutoff = DateTime.Now.AddDays(-analogRetention);
 
-            await DatabaseService.Db.Deleteable<FlowRecord>()
+            var flowDeleted = await DatabaseService.Db.Deleteable<FlowRecord>()
                 .Where(r => r.RecordTime < flowCutoff)
                 .ExecuteCommandAsync();
-            await DatabaseService.Db.Deleteable<AnalogRecord>()
+            var analogDeleted = await DatabaseService.Db.Deleteable<AnalogRecord>()
                 .Where(r => r.RecordTime < analogCutoff)
                 .ExecuteCommandAsync();
 
+            AuditService.Log(
+                AuthService.CurrentUser?.DisplayName ?? "系统",
+                T("AuditClearDataHistory"),
+                $"流量 >{flowRetention}天 删除 {flowDeleted} 条; 模拟量 >{analogRetention}天 删除 {analogDeleted} 条",
+                "FlowRecord/AnalogRecord", "Success");
+
             await LoadFlowRecordsAsync();
-            Log.Information("已清除过期历史数据(流量 >{Flow}天, 模拟量 >{Analog}天)", flowRetention, analogRetention);
+            Log.Information("已清除过期历史数据(流量 >{Flow}天 {FlowDel}条, 模拟量 >{Analog}天 {AnalogDel}条)",
+                flowRetention, flowDeleted, analogRetention, analogDeleted);
         }
         catch (Exception ex)
         {

+ 121 - 22
src/YZWater.Core/ViewModels/ViewDViewModel.cs

@@ -1,8 +1,10 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Serilog;
+using SqlSugar;
 using YZWater.Core.Models;
 using YZWater.Core.Services;
+using YZWater.Core.Utils;
 
 namespace YZWater.Core.ViewModels;
 
@@ -27,8 +29,12 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     [ObservableProperty]
     private DateTimeOffset? _endDate = DateTimeOffset.Now;
 
-    [ObservableProperty]
-    private string _filterType = "";
+    /// <summary>
+    /// 界面最多装载的行数。每来一条新报警都会整段重载(OnAlarmRaised),
+    /// 不限行的话日期范围一宽就是几千个对象 + 几千次 UI 线程赋值,界面直接卡住。
+    /// 需要更早的记录时把日期范围缩小即可,导出 CSV 不受这个上限影响。
+    /// </summary>
+    private const int MaxDisplayRows = 1000;
 
     // 详情字段(避免 SelectedRecord 为 null 时嵌套绑定报错)
     [ObservableProperty] private string _detailTime = "--";
@@ -38,6 +44,18 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     [ObservableProperty] private string _detailRecoverTime = "--";
     [ObservableProperty] private string _detailDuration = "--";
 
+    /// <summary>
+    /// 确认/复位这类批量操作的回执。没有它,操作员点了"全部确认"而命中 0 行时界面毫无变化,
+    /// 只会认为按钮坏了。
+    /// </summary>
+    [ObservableProperty] private string _operationResult = "";
+
+    /// <summary>命中行数上限时的提示,避免"界面上就这些"被当成"范围内就这些"</summary>
+    [ObservableProperty] private string _rowLimitHint = "";
+
+    /// <summary>写入队列满导致报警记录被丢弃时的提示,避免"库里就这些"被当成"发生过这些"</summary>
+    [ObservableProperty] private string _droppedHint = "";
+
     // ─── 翻译属性 ───
     public string TitleText => T("AlarmLog");
     public string SubtitleText => T("AlarmHistory");
@@ -68,6 +86,9 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     public string AckByHeaderText => T("AckByHeader");
     public string RecoverTimeHeaderText => T("RecoverTimeHeader");
     public string DurationHeaderText => T("DurationHeader");
+    // 单条操作按钮
+    public string ConfirmRowText => T("ConfirmRow");
+    public string ResetRowText => T("ResetRow");
 
     partial void OnSelectedRecordChanged(AlarmRecord? value)
     {
@@ -110,6 +131,8 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(AckByHeaderText), "AckByHeader");
         RegisterTranslation(nameof(RecoverTimeHeaderText), "RecoverTimeHeader");
         RegisterTranslation(nameof(DurationHeaderText), "DurationHeader");
+        RegisterTranslation(nameof(ConfirmRowText), "ConfirmRow");
+        RegisterTranslation(nameof(ResetRowText), "ResetRow");
 
         SubscribeLanguageChanged();
 
@@ -137,26 +160,32 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
             var end = QueryTimeHelper.NormalizeEnd(EndDate?.DateTime, DateTime.Now);
-            var query = DatabaseService.Db.Queryable<AlarmRecord>()
-                .Where(r => r.AlarmTime >= start && r.AlarmTime <= end);
 
-            // 空字符串与「全部」均表示不过滤类型
-            if (!string.IsNullOrEmpty(FilterType) && FilterType != T("All"))
-            {
-                query = query.Where(r => r.AlarmType == FilterType);
-            }
+            // SqlSugar 的 Queryable 是同一个构建器实例,Count 和 Take 各自重建,避免条件串用
+            ISugarQueryable<AlarmRecord> BuildQuery() =>
+                DatabaseService.Db.Queryable<AlarmRecord>()
+                    .Where(r => r.AlarmTime >= start && r.AlarmTime <= end);
 
-            var records = await query
+            var total = await BuildQuery().CountAsync();
+            var records = await BuildQuery()
                 .OrderByDescending(r => r.AlarmTime)
+                .Take(MaxDisplayRows)
                 .ToListAsync();
 
             // 计算每条记录的持续时长文本
             foreach (var r in records) r.UpdateDurationText();
 
             AlarmRecords = records;
-            UnconfirmedCount = records.Count(r => !r.IsConfirmed);
-
-            Log.Debug("加载了 {Count} 条报警记录", records.Count);
+            // 未确认数按库统计:列表被截断时,顶部徽标不能只数显示出来的那部分
+            UnconfirmedCount = await BuildQuery().Where(r => !r.IsConfirmed).CountAsync();
+            RowLimitHint = total > records.Count
+                ? string.Format(T("RowLimitHint"), records.Count, total)
+                : "";
+            // 队列满丢过记录就必须说:否则"库里只有这些"会被当成"当时只发生过这些"
+            var dropped = AlarmService.Instance.DroppedRecordCount;
+            DroppedHint = dropped > 0 ? string.Format(T("AlarmDroppedHint"), dropped) : "";
+
+            Log.Debug("加载了 {Count} 条报警记录(范围内共 {Total} 条)", records.Count, total);
         }
         catch (Exception ex)
         {
@@ -186,6 +215,7 @@ public partial class ViewDViewModel : TranslatableViewModelBase
                 .ExecuteCommandAsync();
 
             await LoadAlarmRecordsAsync();
+            await AlarmService.Instance.RecountUnconfirmedAsync();
             Log.Information("报警 {Id} 已确认", record.Id);
         }
         catch (Exception ex)
@@ -202,7 +232,7 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     {
         try
         {
-            await DatabaseService.Db.Updateable<AlarmRecord>()
+            var confirmed = await DatabaseService.Db.Updateable<AlarmRecord>()
                 .SetColumns(r => r.IsConfirmed, true)
                 .SetColumns(r => r.ConfirmedTime, DateTime.Now)
                 .SetColumns(r => r.ConfirmedBy, AuthService.CurrentUser?.DisplayName ?? T("UnknownUser"))
@@ -210,7 +240,11 @@ public partial class ViewDViewModel : TranslatableViewModelBase
                 .ExecuteCommandAsync();
 
             await LoadAlarmRecordsAsync();
-            Log.Information("所有报警已确认");
+            await AlarmService.Instance.RecountUnconfirmedAsync();
+            OperationResult = confirmed > 0
+                ? string.Format(T("OpConfirmed"), confirmed)
+                : T("OpNothingToConfirm");
+            Log.Information("批量确认报警 {Count} 条", confirmed);
         }
         catch (Exception ex)
         {
@@ -227,7 +261,9 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         if (record == null) return;
 
         var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
-        await AlarmService.Instance.ResetAlarmAsync(record.Id, operatorName);
+        var ok = await AlarmService.Instance.ResetAlarmAsync(record.Id, operatorName);
+        // 未命中活跃报警时 AlarmService 已经挡住了 PLC 写入,这里只负责把结论说清楚
+        OperationResult = ok ? string.Format(T("OpRowReset"), record.Id) : T("OpRowNotResettable");
         await LoadAlarmRecordsAsync();
     }
 
@@ -238,7 +274,10 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     private async Task ResetAllAlarmsAsync()
     {
         var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
-        await AlarmService.Instance.ResetAllAlarmsAsync(operatorName);
+        var count = await AlarmService.Instance.ResetAllAlarmsAsync(operatorName);
+        OperationResult = count > 0
+            ? string.Format(T("OpReset"), count)
+            : T("OpNothingToReset");
         await LoadAlarmRecordsAsync();
     }
 
@@ -270,6 +309,54 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         AlarmService.Instance.Unshelve(conditionId, operatorName);
     }
 
+    /// <summary>当前搁置中的报警列表(ISA-18.2:操作员需要看到哪些报警被抑制了)</summary>
+    [ObservableProperty] private string _shelvedListText = "";
+
+    /// <summary>报警回顾摘要(按 ConditionId 聚合的统计)</summary>
+    [ObservableProperty] private string _reviewSummary = "";
+
+    /// <summary>搁置/回顾面板是否可见</summary>
+    [ObservableProperty] private bool _isInfoPanelVisible;
+
+    public string ShelvedListLabel => T("ShelvedListLabel");
+    public string ReviewLabel => T("ReviewLabel");
+
+    /// <summary>刷新搁置列表</summary>
+    [RelayCommand]
+    private void LoadShelvedList()
+    {
+        var shelved = AlarmService.Instance.GetShelvedAlarms();
+        if (shelved.Count == 0)
+        {
+            ShelvedListText = T("NoShelvedAlarms");
+        }
+        else
+        {
+            ShelvedListText = string.Join("\n", shelved.Select(s =>
+                $"{s.ConditionId}  →  {T("Until")} {s.Until:HH:mm:ss}"));
+        }
+        IsInfoPanelVisible = true;
+    }
+
+    /// <summary>报警回顾:按 ConditionId 聚合当前查询范围内的报警统计</summary>
+    [RelayCommand]
+    private async Task LoadReviewAsync()
+    {
+        var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
+        var end = EndDate?.DateTime ?? DateTime.Now;
+        var items = await AlarmService.Instance.GetAlarmReviewAsync(start, end);
+        if (items.Count == 0)
+        {
+            ReviewSummary = T("NoReviewData");
+        }
+        else
+        {
+            ReviewSummary = string.Join("\n", items.Take(20).Select(i =>
+                $"[{i.Count}×] {i.SampleMessage}  {(i.IsActive ? T("Active") : T("Recovered"))}  {(i.IsShelved ? T("Shelved") : "")}"));
+        }
+        IsInfoPanelVisible = true;
+    }
+
     /// <summary>
     /// 导出报警记录
     /// </summary>
@@ -278,7 +365,9 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     {
         try
         {
-            var filePath = $"AlarmRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
+            // 锚定 exe 目录:裸文件名会随启动方式落到别的目录(快捷方式起始位置、计划任务),
+            // 导出成功却找不到文件
+            var filePath = Path.Combine(AppPaths.Root, $"AlarmRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
             var lang = LanguageService.Instance;
             var lines = new List<string>
             {
@@ -291,7 +380,8 @@ public partial class ViewDViewModel : TranslatableViewModelBase
                 lines.Add($"{record.AlarmTime:yyyy-MM-dd HH:mm:ss},{record.AlarmType},{record.AlarmMessage},{record.AlarmValue},{record.AlarmLevel},{(record.IsConfirmed ? LanguageService.Instance.Get("CsvYes") : LanguageService.Instance.Get("CsvNo"))},{record.ConfirmedTime:yyyy-MM-dd HH:mm:ss},{record.ConfirmedBy},{record.RecoverTime:yyyy-MM-dd HH:mm:ss},{record.DurationText}");
             }
 
-            await File.WriteAllLinesAsync(filePath, lines);
+            // 带 BOM:Excel 打开无 BOM 的 UTF-8 CSV 会按 GBK 解释,中文报警文本成乱码
+            await File.WriteAllLinesAsync(filePath, lines, FileTextEncoding.Utf8WithBom);
             Log.Information("报警记录已导出到: {FilePath}", filePath);
         }
         catch (Exception ex)
@@ -308,13 +398,22 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     {
         try
         {
-            var cutoffDate = DateTime.Now.AddDays(-90);
-            await DatabaseService.Db.Deleteable<AlarmRecord>()
+            // 与定时清理用同一个保留期:这里写死天数的话,保留期改了也会被这个按钮按旧值提前删掉
+            var retentionDays = ConfigService.GetConfig()?.DataRetention?.AlarmRecordRetentionDays ?? 180;
+            var cutoffDate = DateTime.Now.AddDays(-retentionDays);
+            var deleted = await DatabaseService.Db.Deleteable<AlarmRecord>()
                 .Where(r => r.AlarmTime < cutoffDate && r.IsConfirmed)
                 .ExecuteCommandAsync();
 
+            // 清除操作必须留痕:事后追查"这段时间的报警怎么没了"时需要知道是谁清的、清了多少
+            AuditService.Log(
+                AuthService.CurrentUser?.DisplayName ?? "系统",
+                T("AuditClearAlarmHistory"),
+                $"{retentionDays}天前的已确认记录,实际删除 {deleted} 条",
+                "AlarmRecord", "Success");
+
             await LoadAlarmRecordsAsync();
-            Log.Information("已清除 90 天前的已确认报警记录");
+            Log.Information("已清除 {Days} 天前的已确认报警记录({Deleted} 条)", retentionDays, deleted);
         }
         catch (Exception ex)
         {

+ 12 - 2
src/YZWater.Core/ViewModels/ViewFViewModel.cs

@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
 using Serilog;
 using YZWater.Core.Models;
 using YZWater.Core.Services;
+using YZWater.Core.Utils;
 
 namespace YZWater.Core.ViewModels;
 
@@ -27,6 +28,10 @@ public partial class ViewFViewModel : TranslatableViewModelBase
     [ObservableProperty]
     private int _totalCount;
 
+    /// <summary>审计写入队列满导致记录被丢弃时的提示(丢审计必须看得见,不然台账会"看起来很干净")</summary>
+    [ObservableProperty]
+    private string _droppedHint = "";
+
     // ─── 工作流状态 ───
     [ObservableProperty]
     private List<WorkflowInfo> _workflows = new();
@@ -133,6 +138,8 @@ public partial class ViewFViewModel : TranslatableViewModelBase
 
             AuditLogs = logs;
             TotalCount = logs.Count;
+            var dropped = AuditService.DroppedEntryCount;
+            DroppedHint = dropped > 0 ? string.Format(T("AuditDroppedHint"), dropped) : "";
             Log.Debug("加载了 {Count} 条审计日志", logs.Count);
         }
         catch (Exception ex)
@@ -146,7 +153,9 @@ public partial class ViewFViewModel : TranslatableViewModelBase
     {
         try
         {
-            var filePath = $"AuditLogs_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
+            // 锚定 exe 目录:裸文件名会随启动方式落到别的目录(快捷方式起始位置、计划任务),
+            // 导出成功却找不到文件
+            var filePath = Path.Combine(AppPaths.Root, $"AuditLogs_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
             var lines = new List<string>
             {
                 $"{LanguageService.Instance.Get("CsvHeaderTime")},{LanguageService.Instance.Get("CsvHeaderUser")},{LanguageService.Instance.Get("CsvHeaderAction")},{LanguageService.Instance.Get("CsvHeaderDetail")},{LanguageService.Instance.Get("CsvHeaderTarget")},{LanguageService.Instance.Get("CsvHeaderResult")}"
@@ -157,7 +166,8 @@ public partial class ViewFViewModel : TranslatableViewModelBase
                 lines.Add($"{log.Timestamp:yyyy-MM-dd HH:mm:ss},{log.UserName},{log.Action},{log.Detail},{log.Target},{log.Result}");
             }
 
-            await File.WriteAllLinesAsync(filePath, lines);
+            // 带 BOM:Excel 打开无 BOM 的 UTF-8 CSV 会按 GBK 解释,中文操作人/详情成乱码
+            await File.WriteAllLinesAsync(filePath, lines, FileTextEncoding.Utf8WithBom);
             Log.Information("审计日志已导出到: {FilePath}", filePath);
         }
         catch (Exception ex)

+ 7 - 0
src/YZWater.Core/YZWater.Core.csproj

@@ -7,6 +7,13 @@
     <RootNamespace>YZWater.Core</RootNamespace>
   </PropertyGroup>
 
+  <ItemGroup>
+    <!-- 连接状态机里"会话通但读不到数据"这种状态没有真 PLC 复现不出来,测试用 PlcService.UseDriver 注入假驱动 -->
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
+      <_Parameter1>YZWater.Core.Tests</_Parameter1>
+    </AssemblyAttribute>
+  </ItemGroup>
+
   <ItemGroup>
     <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
     <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />

+ 75 - 0
tests/YZWater.Core.Tests/Models/AlarmDurationSemanticsTests.cs

@@ -0,0 +1,75 @@
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Tests.Models;
+
+/// <summary>
+/// 持续时间列的语义。
+/// 报警历史里"测点读取失败""报警升级"这类记录是瞬时事件,没有恢复概念;
+/// 之前它们和真报警一样按 now-AlarmTime 累加,界面上显示成"31m+",
+/// 操作员看到一整屏在无限增长的"报警",于是问"这些是啥报警,确认又确认不了"。
+/// 真报警的"进行中"计数必须保留,否则失联期间的报警看起来像已经结束。
+/// </summary>
+public class AlarmDurationSemanticsTests
+{
+    [Fact]
+    public void DiaryRecord_WithoutConditionId_ShowsNoDuration()
+    {
+        var journal = new AlarmRecord
+        {
+            AlarmType = "Quality",
+            AlarmMessage = "15 个测点读取失败",
+            AlarmTime = DateTime.Now.AddMinutes(-30)
+        };
+
+        journal.UpdateDurationText();
+
+        Assert.Equal("--", journal.DurationText);
+    }
+
+    [Fact]
+    public void EscalationRecord_AlsoPointEvent_ShowsNoDuration()
+    {
+        var escalated = new AlarmRecord
+        {
+            AlarmType = "DI",
+            AlarmMessage = "报警已升级",
+            AlarmLevel = 4,
+            AlarmTime = DateTime.Now.AddMinutes(-30)
+        };
+
+        escalated.UpdateDurationText();
+
+        Assert.Equal("--", escalated.DurationText);
+    }
+
+    [Fact]
+    public void ActiveAlarm_WithoutRecoverTime_StillCountsUp()
+    {
+        var alarm = new AlarmRecord
+        {
+            ConditionId = "DI_低液位报警",
+            AlarmMessage = "低液位报警",
+            AlarmTime = DateTime.Now.AddMinutes(-30)
+        };
+
+        alarm.UpdateDurationText();
+
+        Assert.Contains("+", alarm.DurationText);
+    }
+
+    [Fact]
+    public void RecoveredAlarm_ShowsElapsedSpan()
+    {
+        var start = DateTime.Now.AddMinutes(-5);
+        var alarm = new AlarmRecord
+        {
+            ConditionId = "DI_低液位报警",
+            AlarmTime = start,
+            RecoverTime = start.AddSeconds(40)
+        };
+
+        alarm.UpdateDurationText();
+
+        Assert.Equal("40s", alarm.DurationText);
+    }
+}

+ 94 - 0
tests/YZWater.Core.Tests/Services/AlarmOrphanRepairTests.cs

@@ -0,0 +1,94 @@
+using Serilog.Events;
+using SqlSugar;
+using Xunit;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 上次运行遗留的"未恢复"报警收尾。
+/// conditionId→DbId 的映射只在内存里,进程被杀/正常重启都来不及回写 RecoverTime:
+/// 现场库里那条 07:36:19 的 PLC_Connection 记录一直是 NULL,历史页把它显示成"进行中/无限时长",
+/// 操作员以为设备还在报警。诊断流水(ConditionId 为空)是瞬时事件,本就没有恢复一说,不该被盖上时间。
+/// </summary>
+[Collection("Serilog minimum level")]
+public class AlarmOrphanRepairTests : IDisposable
+{
+    private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
+    private readonly AlarmService _service;
+
+    public AlarmOrphanRepairTests()
+    {
+        LogService.ApplyMinimumLevel("Error");
+        DatabaseService.Initialize();
+        _service = new AlarmService(PlcPollingService.Instance);
+    }
+
+    public void Dispose()
+    {
+        _service.Dispose();
+        LogService.ApplyMinimumLevel(_originalLevel.ToString());
+    }
+
+    private int Insert(AlarmRecord record) =>
+        DatabaseService.Db.Insertable(record).ExecuteReturnIdentity();
+
+    private DateTime? ReadRecoverTime(int id) =>
+        DatabaseService.Db.Queryable<AlarmRecord>().Where(r => r.Id == id).Select(r => r.RecoverTime).First();
+
+    [Fact]
+    public async Task Startup_ClosesOrphanedRealAlarm()
+    {
+        var marker = Guid.NewGuid().ToString("N");
+        var orphanId = Insert(new AlarmRecord
+        {
+            AlarmType = "PLC",
+            AlarmMessage = $"遗留活跃报警 {marker}",
+            AlarmTime = DateTime.Now.AddHours(-2),
+            ConditionId = $"PLC_{marker}"
+        });
+        Assert.Null(ReadRecoverTime(orphanId));
+
+        await _service.CloseOrphanedAlarmsAsync();
+
+        var closed = ReadRecoverTime(orphanId);
+        Assert.NotNull(closed);
+        // 收尾时间取本次启动时刻,不是报警时间:中间这段时间设备确实处于无人监视状态
+        Assert.True(closed!.Value > DateTime.Now.AddMinutes(-5));
+    }
+
+    [Fact]
+    public async Task Startup_LeavesJournalEventsAndRecoveredRowsAlone()
+    {
+        var marker = Guid.NewGuid().ToString("N");
+
+        // 诊断流水:没有 conditionId,也没有恢复概念
+        var journalId = Insert(new AlarmRecord
+        {
+            AlarmType = "Quality",
+            AlarmMessage = $"测点质量流水 {marker}",
+            AlarmTime = DateTime.Now.AddHours(-2),
+            IsConfirmed = true,
+            ConfirmedBy = "系统"
+        });
+        // 正常已恢复的报警,恢复时间不能被覆盖成启动时刻
+        var recoveredAt = DateTime.Now.AddHours(-3);
+        var recoveredId = Insert(new AlarmRecord
+        {
+            AlarmType = "DI",
+            AlarmMessage = $"已恢复报警 {marker}",
+            AlarmTime = recoveredAt.AddHours(-1),
+            ConditionId = $"DI_{marker}",
+            RecoverTime = recoveredAt
+        });
+
+        await _service.CloseOrphanedAlarmsAsync();
+
+        Assert.Null(ReadRecoverTime(journalId));
+        // SQLite 只存到毫秒,比较放宽到 1 秒,避免亚毫秒精度造成假红
+        var kept = ReadRecoverTime(recoveredId);
+        Assert.NotNull(kept);
+        Assert.True(Math.Abs((kept!.Value - recoveredAt).TotalSeconds) < 1);
+    }
+}

+ 207 - 0
tests/YZWater.Core.Tests/Services/AlarmServiceConcurrencyTests.cs

@@ -0,0 +1,207 @@
+using Xunit;
+using Serilog.Events;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 报警内存状态的并发回归测试。
+/// 背景:轮询线程(CheckAlarms)、UI 线程(ViewD 搁置/复位)、SignalR 线程(远程复位)会同时读写
+/// _activeAlarms / _alarmStartTimes / _escalatedAlarms 这几个普通集合。改动前它们完全没有同步,
+/// 一边枚举一边被写入就直接抛 "Collection was modified",异常被 Avalonia 的 UI 兜底吞掉后
+/// 表现为"报警列表点不开 / 复位了但还在报警",界面上看不出任何线索。
+/// 这里压两件事:并发不炸不卡,且同一个条件不会被重复触发。
+/// </summary>
+[Collection("Serilog minimum level")]
+public class AlarmServiceConcurrencyTests : IDisposable
+{
+    private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
+    private readonly AlarmService _service;
+
+    public AlarmServiceConcurrencyTests()
+    {
+        // 状态变更会记 Warning(报警触发/泛溢),压掉以免污染测试输出
+        LogService.ApplyMinimumLevel("Error");
+        _service = new AlarmService(PlcPollingService.Instance);
+        _service.AlarmDelaySeconds = 0; // 立即触发,让压测真正走到触发分支而不是只登记起始时间
+    }
+
+    public void Dispose()
+    {
+        _service.Dispose();
+        LogService.ApplyMinimumLevel(_originalLevel.ToString());
+    }
+
+    /// <summary>
+    /// 评估、复位、搁置、读快照四路并发:不能抛异常,也不能卡死
+    /// (卡死意味着锁里做了落库/事件回调之类的外部调用,与等锁的 UI 线程互等)。
+    /// </summary>
+    [Fact]
+    public async Task ConcurrentEvaluateResetShelveRead_DoesNotCorruptState()
+    {
+        var workers = new List<Task>();
+
+        // ① 两路"轮询线程":反复评估条件成立,偶尔让一半条件恢复(触发清除分支)
+        for (var t = 0; t < 2; t++)
+        {
+            workers.Add(Task.Run(() =>
+            {
+                for (var round = 0; round < 200; round++)
+                {
+                    for (var i = 0; i < 20; i++)
+                        _service.CheckCustomCondition($"Tank{i}_High", true, $"液位高 {i}", 2);
+
+                    // 每 20 轮清一次:既制造"边枚举边写"的窗口,又不会把报警表灌满
+                    if (round % 20 == 19)
+                        for (var i = 0; i < 20; i += 2)
+                            _service.CheckCustomCondition($"Tank{i}_High", false, string.Empty, 0);
+                }
+            }));
+        }
+
+        // ② "UI 线程":读快照属性(内部要遍历活跃集合)
+        workers.Add(Task.Run(() =>
+        {
+            for (var round = 0; round < 2000; round++)
+            {
+                _ = _service.HasActiveAlarm;
+                _ = _service.ActiveAlarmCount;
+                _ = _service.ActiveAlarmMessage;
+            }
+        }));
+
+        // ③ "UI 线程":搁置 / 取消搁置
+        workers.Add(Task.Run(() =>
+        {
+            for (var round = 0; round < 300; round++)
+            {
+                var key = $"Shelve{round % 7}_High";
+                _service.Shelve(key, 30, "Tester");
+                _service.Unshelve(key, "Tester");
+            }
+        }));
+
+        // ④ "远程客户端":全部复位(清空同一批集合并回写数据库)
+        workers.Add(Task.Run(async () =>
+        {
+            for (var round = 0; round < 30; round++)
+                await _service.ResetAllAlarmsAsync("Tester");
+        }));
+
+        var all = Task.WhenAll(workers);
+        var finished = await Task.WhenAny(all, Task.Delay(TimeSpan.FromSeconds(60)));
+        Assert.True(finished == all, "并发压测未在 60 秒内结束,疑似锁内做外部调用导致互等");
+
+        await all; // 任一 worker 抛出 InvalidOperationException 等异常时让测试失败
+
+        // 状态没被压坏:清空后重新评估仍能触发,并如实反映到快照属性
+        await _service.ResetAllAlarmsAsync("Tester");
+        _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
+        _service.CheckCustomCondition("AfterStress_High", true, "压测后仍可触发", 2);
+
+        Assert.True(_service.HasActiveAlarm);
+        Assert.Equal(1, _service.ActiveAlarmCount);
+        Assert.Contains("AfterStress_High", _service.ActiveAlarmMessage);
+    }
+
+    /// <summary>
+    /// 同一条件被多个线程同时判定成立时只能触发一次 ——
+    /// "是否已在报警"的检查和"占住活跃集合"必须在同一把锁里,否则两边都能通过检查,
+    /// 报警列表和声音通知会收到重复的同一条报警。
+    /// </summary>
+    [Fact]
+    public async Task SameCondition_RaisedOnlyOnceUnderConcurrentThreads()
+    {
+        const string conditionId = "ConcurrentTank_High";
+        var raised = new List<AlarmRecord>();
+        var raisedLock = new object();
+        void OnRaised(AlarmRecord alarm)
+        {
+            lock (raisedLock) raised.Add(alarm);
+        }
+
+        _service.AlarmRaised += OnRaised;
+        try
+        {
+            var workers = new List<Task>();
+            for (var t = 0; t < 4; t++)
+            {
+                workers.Add(Task.Run(() =>
+                {
+                    // 第一轮只登记起始时间,之后每一轮都是"条件仍成立"的重复评估
+                    for (var round = 0; round < 300; round++)
+                        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+                }));
+            }
+            await Task.WhenAll(workers);
+        }
+        finally
+        {
+            _service.AlarmRaised -= OnRaised;
+        }
+
+        int duplicates;
+        lock (raisedLock)
+            duplicates = raised.Count(a => a.ConditionId == conditionId);
+
+        Assert.Equal(1, duplicates);
+    }
+
+    /// <summary>
+    /// 搁置期间不能因为并发而漏网触发(判定搁置与占住活跃集合在同一临界区内才成立)。
+    /// </summary>
+    [Fact]
+    public async Task ShelvedCondition_IsNotRaisedEvenWhenEvaluatedConcurrently()
+    {
+        const string conditionId = "ShelvedTank_High";
+        var raised = 0;
+        void OnRaised(AlarmRecord alarm)
+        {
+            if (alarm.ConditionId == conditionId) Interlocked.Increment(ref raised);
+        }
+
+        _service.AlarmRaised += OnRaised;
+        try
+        {
+            _service.Shelve(conditionId, 30, "Tester");
+
+            var workers = new List<Task>();
+            for (var t = 0; t < 4; t++)
+            {
+                workers.Add(Task.Run(() =>
+                {
+                    for (var round = 0; round < 300; round++)
+                        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+                }));
+            }
+            await Task.WhenAll(workers);
+        }
+        finally
+        {
+            _service.AlarmRaised -= OnRaised;
+            _service.Unshelve(conditionId, "Tester");
+        }
+
+        Assert.Equal(0, raised);
+    }
+
+    /// <summary>
+    /// 复位后条件仍成立时必须能重新触发,不能留下"已复位但界面仍报警"的脏状态。
+    /// </summary>
+    [Fact]
+    public async Task ResetWhileConditionStillActive_LetsNextPollReRaise()
+    {
+        const string conditionId = "ResetThenRaise_High";
+        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+        Assert.True(_service.HasActiveAlarm);
+
+        await _service.ResetAllAlarmsAsync("Tester");
+        Assert.False(_service.HasActiveAlarm);
+
+        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+        _service.CheckCustomCondition(conditionId, true, "液位高", 2);
+        Assert.Contains(conditionId, _service.ActiveAlarmMessage);
+    }
+}

+ 82 - 0
tests/YZWater.Core.Tests/Services/FileEncodingTests.cs

@@ -0,0 +1,82 @@
+using System.Text;
+using Serilog;
+using YZWater.Core.Utils;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 文本文件编码的回归测试。
+/// 背景:日志和 CSV 导出原先写成无 BOM 的 UTF-8,中文 Windows 上记事本、Excel、
+/// PowerShell 5.1 的 Get-Content 都会按 GBK 解释,取证时看到的是乱码。
+/// </summary>
+public class FileEncodingTests : IDisposable
+{
+    private readonly string _dir = Path.Combine(
+        Path.GetTempPath(), "yzwater-encoding-" + Guid.NewGuid().ToString("N"));
+
+    public FileEncodingTests() => Directory.CreateDirectory(_dir);
+
+    public void Dispose()
+    {
+        try { Directory.Delete(_dir, true); } catch (IOException) { }
+    }
+
+    [Fact]
+    public void Utf8WithBom_Preamble_IsTheThreeUtf8SignatureBytes()
+    {
+        Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, FileTextEncoding.Utf8WithBom.Preamble.ToArray());
+    }
+
+    [Fact]
+    public void SerilogFileSink_NewFile_StartsWithBomAndKeepsChineseReadable()
+    {
+        var previous = Serilog.Log.Logger;
+        var path = Path.Combine(_dir, "log-encoding.txt");
+        try
+        {
+            Log.Logger = new LoggerConfiguration()
+                .WriteTo.File(path, encoding: FileTextEncoding.Utf8WithBom)
+                .CreateLogger();
+            Log.Information("集水池 {Level:F2} 米,泵已停止", 3.5f);
+            Log.CloseAndFlush();
+
+            var bytes = File.ReadAllBytes(path);
+            Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes.Take(3).ToArray());
+
+            // 去 BOM 后必须是合法 UTF-8 且中文原样可读(乱码时这两条都会失败)
+            var text = File.ReadAllText(path);
+            Assert.Contains("集水池 3.50 米,泵已停止", text);
+            Assert.DoesNotContain("", text);
+        }
+        finally
+        {
+            Log.Logger = previous;
+        }
+    }
+
+    [Fact]
+    public void AppendAllText_WritesBomOnlyOnce()
+    {
+        var path = Path.Combine(_dir, "crash-sim.log");
+
+        File.AppendAllText(path, "第一段:集水池溢出\n", FileTextEncoding.Utf8WithBom);
+        File.AppendAllText(path, "第二段:泵故障\n", FileTextEncoding.Utf8WithBom);
+
+        var bytes = File.ReadAllBytes(path);
+        Assert.Equal(1, CountOccurrences(bytes, new byte[] { 0xEF, 0xBB, 0xBF }));
+        Assert.Contains("第二段:泵故障", File.ReadAllText(path));
+    }
+
+    private static int CountOccurrences(byte[] haystack, byte[] needle)
+    {
+        var count = 0;
+        for (var i = 0; i <= haystack.Length - needle.Length; i++)
+        {
+            var match = true;
+            for (var j = 0; j < needle.Length; j++)
+                if (haystack[i + j] != needle[j]) { match = false; break; }
+            if (match) count++;
+        }
+        return count;
+    }
+}

+ 101 - 0
tests/YZWater.Core.Tests/Services/LogLevelSwitchTests.cs

@@ -0,0 +1,101 @@
+using System.Text.Json;
+using Serilog.Events;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 日志级别运行时开关的回归测试。
+/// 背景:轮询诊断降到 Debug 之后,如果级别不可调,现场遇到"某个值不对"就没有任何手段
+/// 把它临时开回来取证,只能停下来重新发布 —— 降级就变成了丢能力。
+/// </summary>
+// 本类会改 LogService.MinimumLevel(静态),必须与同样改它的测试类串行,否则默认值断言偶发失败
+[Collection("Serilog minimum level")]
+public class LogLevelSwitchTests : IDisposable
+{
+    private readonly LogEventLevel _original = LogService.MinimumLevel;
+
+    [Fact]
+    public void Default_MinimumLevel_IsInformation()
+    {
+        Assert.Equal(LogEventLevel.Information, _original);
+    }
+
+    [Fact]
+    public void ApplyMinimumLevel_Debug_TakesEffect()
+    {
+        LogService.ApplyMinimumLevel("Debug");
+
+        Assert.Equal(LogEventLevel.Debug, LogService.MinimumLevel);
+    }
+
+    [Fact]
+    public void ApplyMinimumLevel_IsCaseInsensitive()
+    {
+        LogService.ApplyMinimumLevel("warning");
+
+        Assert.Equal(LogEventLevel.Warning, LogService.MinimumLevel);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("   ")]
+    [InlineData("banana")]
+    [InlineData("Infos")]
+    public void ApplyMinimumLevel_InvalidValue_KeepsCurrentLevel(string? level)
+    {
+        LogService.ApplyMinimumLevel("Debug");
+        var before = LogService.MinimumLevel;
+
+        LogService.ApplyMinimumLevel(level);
+
+        Assert.Equal(before, LogService.MinimumLevel);
+    }
+
+    [Theory]
+    [InlineData("Debug", true)]
+    [InlineData("debug", true)]
+    [InlineData("Information", true)]
+    [InlineData("Verbose", true)]
+    [InlineData("Fatal", true)]
+    [InlineData("Trace", false)]
+    // 手改配置文件容易多敲空格,Enum.TryParse 本身容忍首尾空白 —— 这里把它钉住
+    [InlineData("Debug ", true)]
+    [InlineData(" Debug", true)]
+    public void TryParseLevel_MatchesSerilogVocabulary(string level, bool expected)
+    {
+        Assert.Equal(expected, LogService.TryParseLevel(level, out _));
+    }
+
+    [Fact]
+    public void SystemConfig_LogKnobs_DefaultToProductionValues()
+    {
+        var config = new SystemConfig();
+
+        Assert.Equal("Information", config.MinimumLogLevel);
+        Assert.Equal(60, config.LogSnapshotIntervalSeconds);
+    }
+
+    [Fact]
+    public void SystemConfig_LogKnobs_RoundTripThroughJson()
+    {
+        // 现场排障就是改这两行文本,键名必须能被反序列化认出来
+        const string json = """{"MinimumLogLevel":"Debug","LogSnapshotIntervalSeconds":10}""";
+
+        var config = JsonSerializer.Deserialize<SystemConfig>(json);
+
+        Assert.Equal("Debug", config?.MinimumLogLevel);
+        Assert.Equal(10, config?.LogSnapshotIntervalSeconds);
+    }
+
+    [Fact]
+    public void PlcPollingService_SnapshotInterval_DefaultsTo60Seconds()
+    {
+        // 0 会让快照静默关闭,默认值必须是有证据的那一档
+        Assert.Equal(60, PlcPollingService.Instance.LogSnapshotIntervalSeconds);
+    }
+
+    public void Dispose() => LogService.ApplyMinimumLevel(_original.ToString());
+}

+ 236 - 0
tests/YZWater.Core.Tests/Services/PlcConnectionStateTests.cs

@@ -0,0 +1,236 @@
+using HslCommunication;
+using Serilog.Events;
+using YZWater.Core.Services;
+using YZWater.Core.Services.PlcDrivers;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 连接状态语义的回归测试。
+/// 背景:现场出现过"TCP 握得上但整轮读不到数据"(PLC 在 STOP、CPU 型号/机架槽填错、
+/// 连接资源被占满都会这样)。改动前 MarkLinkDown 把"会话是否建立"和"读得到数据"写成同一个字段,
+/// 轮询循环又按对外状态决定要不要重连,于是每秒一次"握手成功→宣布已连接→读取失败→宣布中断":
+/// 报警和界面指示灯抖动、每秒一次 S7 握手耗尽 CPU 连接资源、重试计数被清零导致降级模式永远进不去。
+/// 这里用假驱动把拆开的语义钉住:会话和"读得到数据"必须分两步确认。
+/// </summary>
+// LogService.MinimumLevel 是进程级静态,会污染 LogLevelSwitchTests 的默认值断言,必须同队列串行跑
+[Collection("Serilog minimum level")]
+public class PlcConnectionStateTests : IDisposable
+{
+    private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
+
+    public PlcConnectionStateTests()
+    {
+        // 状态翻转本身会记 Information/Warning("整轮读取均失败"),压掉以免污染测试输出
+        LogService.ApplyMinimumLevel("Error");
+    }
+
+    /// <summary>
+    /// 握手通、但一次数据都没读到:对外必须是"中断"。
+    /// 这条最容易出错 —— 握手成功看起来就是"连上了",可操作员要的是"读不读得到"。
+    /// </summary>
+    [Fact]
+    public async Task SessionUp_ButNeverReadData_IsNotConnected()
+    {
+        var driver = new FakeDriver { Reachable = true, ServesData = false };
+        PlcService.UseDriver(driver);
+
+        var connected = await PlcService.ConnectAsync();
+
+        Assert.True(connected, "会话确实建立起来了");
+        Assert.True(PlcService.IsSocketConnected);
+        Assert.False(PlcService.IsConnected, "一次数据都没读到,不能报已连接");
+    }
+
+    /// <summary>读到数据之后才算真的连通,而且状态事件只发一次。</summary>
+    [Fact]
+    public async Task ReadSuccess_ConnectsAndFiresOnce()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+
+        var transitions = new List<bool>();
+        PlcService.ConnectionChanged += OnChanged;
+        try
+        {
+            await PlcService.TryReadFloatAsync("DB1,DWD0");
+            await PlcService.TryReadFloatAsync("DB1,DWD4");
+
+            Assert.True(PlcService.IsConnected);
+            Assert.Equal(new[] { true }, transitions);
+        }
+        finally
+        {
+            PlcService.ConnectionChanged -= OnChanged;
+        }
+
+        void OnChanged(bool connected) => transitions.Add(connected);
+    }
+
+    /// <summary>
+    /// "整轮读不到"只作废读取结论,不能把会话一起抹掉 ——
+    /// 否则轮询循环分不清"该重连"和"该继续读",就会每秒握手一次。
+    /// </summary>
+    [Fact]
+    public async Task MarkLinkDown_KeepsSessionAliveForReads()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+        await PlcService.TryReadFloatAsync("DB1,DWD0");
+
+        PlcService.MarkLinkDown("本轮测点全部读取失败");
+
+        Assert.False(PlcService.IsConnected, "读不到数据,对外应报中断");
+        Assert.True(PlcService.IsSocketConnected, "会话还在,下一轮要继续读而不是重连");
+    }
+
+    /// <summary>已经判过"读不到",再判一次不该重复发事件(每秒一条会把日志和报警刷满)。</summary>
+    [Fact]
+    public async Task MarkLinkDown_DoesNotRejudgeWhileAlreadyDown()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+        await PlcService.TryReadFloatAsync("DB1,DWD0");
+        PlcService.MarkLinkDown("第一次判定");
+
+        var transitions = new List<bool>();
+        PlcService.ConnectionChanged += OnChanged;
+        try
+        {
+            PlcService.MarkLinkDown("第二次判定");
+            Assert.Empty(transitions);
+        }
+        finally
+        {
+            PlcService.ConnectionChanged -= OnChanged;
+        }
+
+        void OnChanged(bool connected) => transitions.Add(connected);
+    }
+
+    /// <summary>读不到之后又能读了:状态自动恢复,且只发一次"已连接"。</summary>
+    [Fact]
+    public async Task ReadRecovery_RestoresConnected()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+        await PlcService.TryReadFloatAsync("DB1,DWD0");
+        PlcService.MarkLinkDown("本轮全部读取失败");
+
+        var transitions = new List<bool>();
+        PlcService.ConnectionChanged += OnChanged;
+        try
+        {
+            await PlcService.TryReadFloatAsync("DB1,DWD0");
+
+            Assert.True(PlcService.IsConnected);
+            Assert.Equal(new[] { true }, transitions);
+        }
+        finally
+        {
+            PlcService.ConnectionChanged -= OnChanged;
+        }
+
+        void OnChanged(bool connected) => transitions.Add(connected);
+    }
+
+    /// <summary>
+    /// 会话断开(重连失败/手动断开)时两个结论一起作废:重连成功不等于读得到数据。
+    /// </summary>
+    [Fact]
+    public async Task SessionDown_ClearsBothVerdicts()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+        await PlcService.TryReadFloatAsync("DB1,DWD0");
+        Assert.True(PlcService.IsConnected);
+
+        driver.Reachable = false;
+        var connected = await PlcService.ConnectAsync();
+
+        Assert.False(connected);
+        Assert.False(PlcService.IsSocketConnected);
+        Assert.False(PlcService.IsConnected);
+
+        // 会话重新建立,但读取仍没确认之前不能报"已连接"
+        driver.Reachable = true;
+        driver.ServesData = false;
+        await PlcService.ConnectAsync();
+        Assert.True(PlcService.IsSocketConnected);
+        Assert.False(PlcService.IsConnected);
+    }
+
+    /// <summary>界面上改了 IP 重新初始化,不能沿用上一台设备的"已连接"结论。</summary>
+    [Fact]
+    public async Task Initialize_ResetsStaleConnectedVerdict()
+    {
+        var driver = new FakeDriver();
+        PlcService.UseDriver(driver);
+        await PlcService.ConnectAsync();
+        await PlcService.TryReadFloatAsync("DB1,DWD0");
+        Assert.True(PlcService.IsConnected);
+
+        PlcService.Initialize();
+
+        Assert.False(PlcService.IsConnected);
+        Assert.False(PlcService.IsSocketConnected);
+    }
+
+    public void Dispose()
+    {
+        PlcService.UseDriver(null);
+        LogService.ApplyMinimumLevel(_originalLevel.ToString());
+    }
+
+    /// <summary>
+    /// 可分别控制"握不握得上"和"给不给数据"的假驱动 —— 现场最难复现的就是这两个状态不一致。
+    /// </summary>
+    private sealed class FakeDriver : IPlcDriver
+    {
+        public bool Reachable { get; set; } = true;
+        public bool ServesData { get; set; } = true;
+
+        public string Name => "Fake";
+        public bool IsConnected { get; private set; }
+
+        public Task<OperateResult> ConnectAsync()
+        {
+            IsConnected = Reachable;
+            return Task.FromResult(Reachable
+                ? OperateResult.CreateSuccessResult()
+                : new OperateResult("模拟握手失败"));
+        }
+
+        public void Disconnect() => IsConnected = false;
+
+        public Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length) =>
+            Task.FromResult(ServesData
+                ? OperateResult<byte[]>.CreateSuccessResult(new byte[length])
+                : new OperateResult<byte[]>("模拟读取失败"));
+
+        public Task<OperateResult<float>> ReadFloatAsync(string address) =>
+            Task.FromResult(ServesData
+                ? OperateResult<float>.CreateSuccessResult(1.23f)
+                : new OperateResult<float>("模拟读取失败"));
+
+        public Task<OperateResult<bool>> ReadBoolAsync(string address) =>
+            Task.FromResult(ServesData
+                ? OperateResult<bool>.CreateSuccessResult(true)
+                : new OperateResult<bool>("模拟读取失败"));
+
+        public Task<OperateResult> WriteAsync(string address, byte[] value) => Ok();
+        public Task<OperateResult> WriteFloatAsync(string address, float value) => Ok();
+        public Task<OperateResult> WriteBoolAsync(string address, bool value) => Ok();
+
+        public void Dispose() => IsConnected = false;
+
+        // 注意:Hsl 的 new OperateResult() 默认是"未知错误"(IsSuccess=false),
+        // 成功结果必须用 CreateSuccessResult 造,否则假驱动永远读不到数据
+        private static Task<OperateResult> Ok() => Task.FromResult(OperateResult.CreateSuccessResult());
+    }
+}

+ 88 - 0
tests/YZWater.Core.Tests/Services/QualityJournalTests.cs

@@ -0,0 +1,88 @@
+using Serilog.Events;
+using Xunit;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 测点数据质量事件的记录方式回归。
+/// 背景:现场报警历史被"测点 X 读取失败 / 已恢复"刷满 —— 一次抖动就是"点位数 × 2"行,
+/// 真报警被埋掉,而且这些行是系统自动确认的流水,操作员既确认不了也复位不了,
+/// 误点复位还会让程序往 PLC 的复位地址写一次脉冲。
+/// 现在一轮最多两条汇总(明细仍在日志文件里),且带独立类型 Quality。
+/// </summary>
+[Collection("Serilog minimum level")]
+public class QualityJournalTests : IDisposable
+{
+    private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
+    private readonly AlarmService _service;
+
+    public QualityJournalTests()
+    {
+        LogService.ApplyMinimumLevel("Error");
+        _service = new AlarmService(PlcPollingService.Instance);
+    }
+
+    public void Dispose()
+    {
+        _service.Dispose();
+        LogService.ApplyMinimumLevel(_originalLevel.ToString());
+    }
+
+    [Fact]
+    public void TwentyBadTags_SummarizeIntoOneMessage_NotTwenty()
+    {
+        var lost = Enumerable.Range(1, 20).Select(i => $"{i}#泵.Run").ToList();
+
+        var (lostMessage, restoredMessage) =
+            PlcPollingService.BuildQualityJournalMessages(lost, new List<string>());
+
+        Assert.Null(restoredMessage);
+        Assert.NotNull(lostMessage);
+        var message = lostMessage!;
+        // 一条消息讲清规模,而不是一个点一条流水
+        Assert.Contains("20", message);
+        Assert.Contains("共 20 个", message);
+        // 清单截断到前 8 个,避免整张点位表被塞进一条记录
+        Assert.DoesNotContain("20#泵.Run", message);
+        Assert.Contains("8#泵.Run", message);
+        // 键名拼错时 LanguageService 会原样返回键,这条断言保证模板真的解析了
+        Assert.DoesNotContain("TagQualityLostSummary", message);
+    }
+
+    [Fact]
+    public void LostAndRestored_ProduceTwoSeparateMessages()
+    {
+        var (lost, restored) = PlcPollingService.BuildQualityJournalMessages(
+            new List<string> { "DI.高压报警" }, new List<string> { "1#泵.Fault", "2#泵.Fault" });
+
+        Assert.NotNull(lost);
+        Assert.Contains("DI.高压报警", lost);
+        Assert.NotNull(restored);
+        Assert.Contains("1#泵.Fault", restored);
+        Assert.DoesNotContain("DI.高压报警", restored);
+    }
+
+    [Fact]
+    public void NoTransitions_ProduceNoRows()
+    {
+        var (lost, restored) = PlcPollingService.BuildQualityJournalMessages(
+            new List<string>(), new List<string>());
+
+        Assert.Null(lost);
+        Assert.Null(restored);
+    }
+
+    /// <summary>
+    /// 诊断流水不得进入活跃报警集合 —— 否则它会占用报警位、被升级/泛溢逻辑统计,
+    /// 还会让"复位"误以为有东西可复位而去写 PLC。
+    /// </summary>
+    [Fact]
+    public void JournalAlarm_NeverBecomesAnActiveAlarm()
+    {
+        _service.RecordJournalAlarm("1 个测点读取失败:1#泵.Run", level: 1, alarmType: "Quality");
+
+        Assert.Equal(0, _service.ActiveAlarmCount);
+        Assert.False(_service.HasActiveAlarm);
+    }
+}

+ 79 - 0
tests/YZWater.Core.Tests/Services/QueueDropAccountingTests.cs

@@ -0,0 +1,79 @@
+using System.Threading.Channels;
+using Xunit;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 报警/审计写入队列"满了丢最旧"的可观测性回归。
+/// 背景:两个服务都用 BoundedChannelFullMode.DropOldest,并且忽略 TryWrite 的返回值 ——
+/// 队列塞满时记录被静默丢掉,界面和日志都不显示,事后看台账会以为"当时只发生过这些"。
+/// 现在丢弃条数被计数、周期性写日志,并在报警页/审计页底部提示。
+/// </summary>
+public class QueueDropAccountingTests
+{
+    /// <summary>
+    /// 这条测试盯的是整个改动的立足点:DropOldest 下 TryWrite 依然返回 true,
+    /// 所以"写成功"不能当成"没丢数据",唯一的丢弃入口是 itemDropped 回调。
+    /// </summary>
+    [Fact]
+    public void DropOldest_KeepsTryWriteTrue_And_ReportsDroppedItem()
+    {
+        var dropped = new List<int>();
+        var channel = Channel.CreateBounded<int>(
+            new BoundedChannelOptions(2) { FullMode = BoundedChannelFullMode.DropOldest },
+            item => dropped.Add(item));
+
+        Assert.True(channel.Writer.TryWrite(1));
+        Assert.True(channel.Writer.TryWrite(2));
+        // 队列已满:这一次写仍然"成功",但把最旧的 1 挤了出去
+        Assert.True(channel.Writer.TryWrite(3));
+
+        Assert.Equal(new[] { 1 }, dropped);
+        Assert.Equal(new[] { 2, 3 }, ReadAll(channel));
+    }
+
+    /// <summary>
+    /// 反向验证:只有 Wait 模式会在队满时让 TryWrite 返回 false,且不产生任何丢弃
+    /// (实测 .NET 8:DropWrite/DropNewest/DropOldest 三种丢弃模式下 TryWrite 全部返回 true,
+    /// 所以"写入成功"根本不能当成"没丢数据")
+    /// </summary>
+    [Fact]
+    public void WaitMode_ReportsFailureOnTryWrite_And_DropsNothing()
+    {
+        var dropped = new List<int>();
+        var channel = Channel.CreateBounded<int>(
+            new BoundedChannelOptions(2) { FullMode = BoundedChannelFullMode.Wait },
+            item => dropped.Add(item));
+
+        Assert.True(channel.Writer.TryWrite(1));
+        Assert.True(channel.Writer.TryWrite(2));
+        Assert.False(channel.Writer.TryWrite(3));
+
+        Assert.Empty(dropped);
+        Assert.Equal(new[] { 1, 2 }, ReadAll(channel));
+    }
+
+    /// <summary>正常写入不该被算成丢弃:计数只应在队列真的满过时才涨</summary>
+    [Fact]
+    public void NewAlarmService_StartsWithNoDroppedRecords()
+    {
+        var service = new AlarmService(PlcPollingService.Instance);
+        try
+        {
+            service.RecordJournalAlarm("测试用流水报警", 1);
+            Assert.Equal(0, service.DroppedRecordCount);
+        }
+        finally
+        {
+            service.Dispose();
+        }
+    }
+
+    private static int[] ReadAll(Channel<int> channel)
+    {
+        var items = new List<int>();
+        while (channel.Reader.TryRead(out var item)) items.Add(item);
+        return items.ToArray();
+    }
+}

+ 73 - 0
tests/YZWater.Core.Tests/ViewModels/ResetToDefaultPersistenceTests.cs

@@ -0,0 +1,73 @@
+using Serilog.Events;
+using Xunit;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+using YZWater.Core.ViewModels;
+
+namespace YZWater.Core.Tests.ViewModels;
+
+/// <summary>
+/// "恢复默认设置"必须真的落到运行配置和磁盘上。
+/// 现场日志(log-20261001)里操作员 07:36:24 点了恢复默认,日志写着 IP=192.168.2.100:102,
+/// 但 4 秒后服务初始化仍然是 127.0.0.1:103,随后 5 次"测试连接"全部失败,直到重启程序才好
+/// —— 旧实现只往输入框里填了值,配置对象和文件都没动过。
+/// 这里刻意先装一个"出厂模板地址",就是为了证明断言能红:漏掉落盘这一步时它必炸。
+/// </summary>
+[Collection("Serilog minimum level")]
+public class ResetToDefaultPersistenceTests : IDisposable
+{
+    private readonly LogEventLevel _originalLevel = LogService.MinimumLevel;
+    private readonly string _originalIp;
+    private readonly int _originalPort;
+
+    public ResetToDefaultPersistenceTests()
+    {
+        LogService.ApplyMinimumLevel("Error");
+        var config = ConfigService.GetConfig()!;
+        _originalIp = config.PlcIp;
+        _originalPort = config.PlcPort;
+
+        // 现场部署包里的配置就是仿真地址,测试从这里起步
+        config.PlcIp = "127.0.0.1";
+        config.PlcPort = 103;
+    }
+
+    public void Dispose()
+    {
+        var config = ConfigService.GetConfig();
+        if (config != null)
+        {
+            config.PlcIp = _originalIp;
+            config.PlcPort = _originalPort;
+        }
+        LogService.ApplyMinimumLevel(_originalLevel.ToString());
+    }
+
+    [Fact]
+    public void ResetToDefault_UpdatesRuntimeConfigAndPersistedFile()
+    {
+        var vm = new ViewBViewModel();
+        try
+        {
+            Assert.Equal("127.0.0.1", vm.PlcIp); // 构造时已从配置载入
+
+            vm.ResetToDefaultCommand.Execute(null);
+
+            Assert.Equal(SystemConfig.DefaultPlcIp, vm.PlcIp);
+            Assert.Equal(SystemConfig.DefaultPlcPort, vm.PlcPort);
+
+            // 光有输入框没用:轮询和"测试连接"读的是配置对象
+            Assert.Equal(SystemConfig.DefaultPlcIp, ConfigService.GetConfig()!.PlcIp);
+            Assert.Equal(SystemConfig.DefaultPlcPort, ConfigService.GetConfig()!.PlcPort);
+
+            // 重启后还得留得住:从磁盘重新读一遍
+            ConfigService.LoadConfig();
+            Assert.Equal(SystemConfig.DefaultPlcIp, ConfigService.GetConfig()!.PlcIp);
+            Assert.Equal(SystemConfig.DefaultPlcPort, ConfigService.GetConfig()!.PlcPort);
+        }
+        finally
+        {
+            vm.Dispose();
+        }
+    }
+}

+ 2 - 2
yzwater-config.template.json

@@ -1,6 +1,6 @@
 {
-  "PlcIp": "192.168.0.150",
-  "PlcPort": 5000,
+  "PlcIp": "192.168.2.100",
+  "PlcPort": 102,
   "AutoConnect": true,
 
   "LevelHighAlarm": 80.0,