Переглянути джерело

fix: 部署前审核修复(本地化寄存器键、历史数据质量、退出二次确认)

界面语言不再影响 PLC 数据链路:
- 子设备 VM 的寄存器键统一取档案原始名 KeyName,阈值行身份改用 ConfigProperty。
  英文界面下用显示名拼键会全部查不到,而"未登记的键"不报 Bad,后果是设备反馈
  恒为 0/停机、中控写入互锁被假可信度打开、阈值以 0 同步进 PLC(保护液位归零)。

历史与运行时数据:
- 档案项目不再写全零 flow_records(进/出水测点在该模式下无数据来源)
- 逐点数据质量:单个坏点不再作废整个轮询周期,消费方跳过坏点
- 数据缺口标记、设备状态记录纳入定时清理、时间字段加索引
- 数据与配置路径锚定 exe 所在目录;Quartz 与自循环双调度导致重复记录已去除

安全与可用性:
- HubToken 改 DPAPI 加密存储并兼容明文迁移,无令牌启动记入日志
- 配置热加载真正生效,损坏配置留证据而非静默重置
- 报警管理按 ISA-18.2:搁置 / 泛溢抑制 / 首出 / 回顾
- 退出需二次确认(✕ 与 Alt+F4),OSShutdown/ApplicationShutdown 放行以免挡住关机

测试:117 例通过(新增 LocalizedTagKeyTests、ExitConfirmTextTests、TagQualityTests、
ConfigProtectionTests、AppPathsTests、AlarmManagementTests)
DESKTOP-74CLTRG\Leol 2 тижнів тому
батько
коміт
8c0da72b57
74 змінених файлів з 4800 додано та 1129 видалено
  1. 21 7
      projects/yangjie2.json
  2. 50 4
      src/YZWater.Avalonia/App.axaml.cs
  3. 2 1
      src/YZWater.Avalonia/Controls/FanControl.cs
  4. 2 1
      src/YZWater.Avalonia/Controls/PumpControl.cs
  5. 71 26
      src/YZWater.Avalonia/Controls/PumpStation2DControl.cs
  6. 2 1
      src/YZWater.Avalonia/Controls/StatusCard.cs
  7. 11 10
      src/YZWater.Avalonia/Converters/BoolConverters.cs
  8. 8 7
      src/YZWater.Avalonia/Converters/TankStageConverters.cs
  9. 67 1
      src/YZWater.Avalonia/Themes/IndustrialStyles.axaml
  10. 10 0
      src/YZWater.Avalonia/Themes/IndustrialTheme.axaml
  11. 10 0
      src/YZWater.Avalonia/Themes/IndustrialThemeLight.axaml
  12. 2 3
      src/YZWater.Avalonia/Views/LoginView.axaml
  13. 42 23
      src/YZWater.Avalonia/Views/MainWindow.axaml
  14. 49 1
      src/YZWater.Avalonia/Views/MainWindow.axaml.cs
  15. 120 146
      src/YZWater.Avalonia/Views/ProjectView.axaml
  16. 10 0
      src/YZWater.Avalonia/Views/ProjectView.axaml.cs
  17. 15 19
      src/YZWater.Avalonia/Views/ViewAView.axaml
  18. 4 29
      src/YZWater.Avalonia/Views/ViewBView.axaml
  19. 43 53
      src/YZWater.Avalonia/Views/ViewCView.axaml
  20. 24 0
      src/YZWater.Avalonia/Views/ViewCView.axaml.cs
  21. 24 9
      src/YZWater.Avalonia/Views/ViewDView.axaml
  22. 28 0
      src/YZWater.Avalonia/Views/ViewDView.axaml.cs
  23. 2 15
      src/YZWater.Avalonia/Views/ViewEView.axaml
  24. 20 12
      src/YZWater.Avalonia/Views/ViewFView.axaml
  25. 23 0
      src/YZWater.Avalonia/Views/ViewFView.axaml.cs
  26. 15 1
      src/YZWater.Avalonia/YZWater.Avalonia.csproj
  27. 68 0
      src/YZWater.Core/Models/AlarmRecord.cs
  28. 49 0
      src/YZWater.Core/Models/DataGapRecord.cs
  29. 61 2
      src/YZWater.Core/Models/PlcDataModel.cs
  30. 105 0
      src/YZWater.Core/Models/ProjectProfile.cs
  31. 33 0
      src/YZWater.Core/Models/StatisticsSummary.cs
  32. 51 0
      src/YZWater.Core/Models/SystemConfig.cs
  33. 472 51
      src/YZWater.Core/Services/AlarmService.cs
  34. 32 0
      src/YZWater.Core/Services/AppPaths.cs
  35. 33 17
      src/YZWater.Core/Services/AuditService.cs
  36. 17 3
      src/YZWater.Core/Services/AuthService.cs
  37. 124 43
      src/YZWater.Core/Services/AutoControlService.cs
  38. 3 3
      src/YZWater.Core/Services/BackupService.cs
  39. 163 10
      src/YZWater.Core/Services/ConfigService.cs
  40. 7 5
      src/YZWater.Core/Services/ConnectionStatusService.cs
  41. 229 0
      src/YZWater.Core/Services/DataGapService.cs
  42. 54 26
      src/YZWater.Core/Services/DataLoggingService.cs
  43. 31 1
      src/YZWater.Core/Services/DataRetentionService.cs
  44. 5 3
      src/YZWater.Core/Services/DatabaseService.cs
  45. 21 8
      src/YZWater.Core/Services/DeviceStateMachine.cs
  46. 375 0
      src/YZWater.Core/Services/LanguageService.cs
  47. 1 1
      src/YZWater.Core/Services/LogService.cs
  48. 2 0
      src/YZWater.Core/Services/MockPlcService.cs
  49. 24 10
      src/YZWater.Core/Services/ParameterSyncService.cs
  50. 6 6
      src/YZWater.Core/Services/PlcDrivers/ModbusTcpDriver.cs
  51. 133 54
      src/YZWater.Core/Services/PlcPollingService.cs
  52. 134 79
      src/YZWater.Core/Services/PlcService.cs
  53. 5 3
      src/YZWater.Core/Services/ProjectService.cs
  54. 82 0
      src/YZWater.Core/Services/ProtectedStringJsonConverter.cs
  55. 9 19
      src/YZWater.Core/Services/QuartzSchedulerService.cs
  56. 12 0
      src/YZWater.Core/Services/QueryTimeHelper.cs
  57. 21 6
      src/YZWater.Core/Services/S7AddressParser.cs
  58. 49 3
      src/YZWater.Core/ViewModels/MainViewModel.cs
  59. 438 192
      src/YZWater.Core/ViewModels/ProjectViewModel.cs
  60. 17 0
      src/YZWater.Core/ViewModels/TranslatableViewModelBase.cs
  61. 44 37
      src/YZWater.Core/ViewModels/ViewAViewModel.cs
  62. 15 2
      src/YZWater.Core/ViewModels/ViewBViewModel.cs
  63. 574 153
      src/YZWater.Core/ViewModels/ViewCViewModel.cs
  64. 73 7
      src/YZWater.Core/ViewModels/ViewDViewModel.cs
  65. 20 6
      src/YZWater.Core/ViewModels/ViewEViewModel.cs
  66. 18 8
      src/YZWater.Core/ViewModels/ViewFViewModel.cs
  67. 1 0
      src/YZWater.Core/YZWater.Core.csproj
  68. 2 2
      tests/YZWater.Core.Tests/Models/SystemConfigTests.cs
  69. 93 0
      tests/YZWater.Core.Tests/Models/TagQualityTests.cs
  70. 75 0
      tests/YZWater.Core.Tests/Services/AlarmManagementTests.cs
  71. 43 0
      tests/YZWater.Core.Tests/Services/AppPathsTests.cs
  72. 93 0
      tests/YZWater.Core.Tests/Services/ConfigProtectionTests.cs
  73. 33 0
      tests/YZWater.Core.Tests/Services/ExitConfirmTextTests.cs
  74. 175 0
      tests/YZWater.Core.Tests/ViewModels/LocalizedTagKeyTests.cs

+ 21 - 7
projects/yangjie2.json

@@ -1,5 +1,6 @@
 {
   "name": "扬杰二厂泵站",
+  "names": { "en": "Yangjie #2 Pump Station" },
   "description": "3台排污泵 + 液位/压力/流量监控",
   "viewTemplate": "PumpStation",
   "control": {
@@ -10,12 +11,13 @@
   },
   "layout": {
     "rightColumnWidth": 300,
-    "hiddenSections": []
+    "hiddenSections": ["Gauges", "Sensors"]
   },
   "modeSelectAddress": "M1.0",
   "pumps": [
     {
       "name": "1#泵",
+      "names": { "en": "Pump #1" },
       "runAddress": "Q0.1",
       "faultAddress": "M17.1",
       "currentAddress": "VD50",
@@ -33,6 +35,7 @@
     },
     {
       "name": "2#泵",
+      "names": { "en": "Pump #2" },
       "runAddress": "Q0.2",
       "faultAddress": "M17.2",
       "currentAddress": "VD54",
@@ -50,6 +53,7 @@
     },
     {
       "name": "3#泵",
+      "names": { "en": "Pump #3" },
       "runAddress": "Q0.3",
       "faultAddress": "M17.3",
       "currentAddress": "VD58",
@@ -69,6 +73,7 @@
   "tanks": [
     {
       "name": "集水池",
+      "names": { "en": "Collection Tank" },
       "levelAddress": "VD62",
       "maxLevel": 3,
       "startPump1LevelAddress": "VD500",
@@ -81,7 +86,8 @@
   "gauges": [
     {
       "name": "压力",
-      "unit": "MPa",
+      "names": { "en": "Pressure" },
+      "unit": "Bar",
       "valueAddress": "VD6600",
       "highSetAddress": "VD6704",
       "lowSetAddress": "VD6700",
@@ -90,6 +96,7 @@
     },
     {
       "name": "流量",
+      "names": { "en": "Flow Rate" },
       "unit": "m³/h",
       "valueAddress": "VD6604",
       "minValue": 0,
@@ -99,6 +106,7 @@
   "sensors": [
     {
       "name": "总流量",
+      "names": { "en": "Total Flow" },
       "unit": "m³",
       "address": "VD7000",
       "dataType": "REAL",
@@ -108,38 +116,39 @@
   "digitalInputs": [
     {
       "name": "低液位报警",
+      "names": { "en": "Low Level Alarm" },
       "address": "M19.5",
       "remark": "1-报警 0-正常"
     },
     {
       "name": "高压报警",
+      "names": { "en": "High Pressure Alarm" },
       "address": "M30.1",
       "remark": "1-报警 0-正常"
     },
     {
       "name": "泵1变频器故障",
+      "names": { "en": "Pump #1 VFD Fault" },
       "address": "M3.1",
       "remark": "1-故障 0-正常"
     },
     {
       "name": "泵2变频器故障",
+      "names": { "en": "Pump #2 VFD Fault" },
       "address": "M6.1",
       "remark": "1-故障 0-正常"
     },
     {
       "name": "泵3变频器故障",
+      "names": { "en": "Pump #3 VFD Fault" },
       "address": "M9.1",
       "remark": "1-故障 0-正常"
     }
   ],
   "digitalOutputs": [
-    {
-      "name": "日流量复位",
-      "address": "M19.3",
-      "remark": "点动复位"
-    },
     {
       "name": "复位按钮",
+      "names": { "en": "Reset Button" },
       "address": "M30.0"
     }
   ],
@@ -147,5 +156,10 @@
     "alarmSource": "PLC",
     "pressureHigh": 1.2,
     "pressureLow": 0.1
+  },
+  "statistics": {
+    "items": [
+      { "type": "SensorValue", "key": "总流量.Value", "displayName": "总流量", "displayNameEn": "Total Flow", "unit": "m³", "enabled": true, "chartGroup": "总流量", "aggType": "delta" }
+    ]
   }
 }

+ 50 - 4
src/YZWater.Avalonia/App.axaml.cs

@@ -3,6 +3,7 @@ using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Markup.Xaml;
 using Avalonia.Markup.Xaml.Styling;
+using Avalonia.Styling;
 using Microsoft.Extensions.DependencyInjection;
 using System.Threading;
 using YZWater.Avalonia.Controls;
@@ -22,6 +23,7 @@ public partial class App : Application
     private HubClient? _hubClient;
     private Action? _userLoggedOutHandler;
     private int _cleanupStarted;
+    private bool _frameworkInitialized; // 跟踪 OnFrameworkInitializationCompleted 是否已完成
 
     public override void Initialize()
     {
@@ -83,7 +85,10 @@ public partial class App : Application
         ThemeService.Instance.LoadFromConfig();
         LanguageService.Instance.LoadFromConfig();
         PlcConfig.LoadFromConfig();
-        ThemeHelper.SetTheme(ThemeService.Instance.IsDarkTheme);
+        // 启动时统一应用主题:切换 MergedDictionaries + RequestedThemeVariant + ThemeHelper 静态画刷
+        // 不能只调 ThemeHelper.SetTheme(),否则 DynamicResource 和 RequestedThemeVariant 不会更新,
+        // 导致暗色主题重启后部分亮色、部分暗色
+        ApplyTheme();
 
         // 加载项目档案(如果配置了 ActiveProject)
         ProjectService.EnsureProjectsDir();
@@ -107,6 +112,7 @@ public partial class App : Application
             ShowLoginWindow(desktop);
         }
 
+        _frameworkInitialized = true;
         base.OnFrameworkInitializationCompleted();
     }
 
@@ -114,6 +120,15 @@ public partial class App : Application
     {
         var config = ConfigService.GetConfig();
 
+        // 认证完全禁用:跳过登录窗口,直接启动(无用户管理模式)
+        if (config?.EnableAuth == false)
+        {
+            Serilog.Log.Information("用户认证已禁用,跳过登录窗口(无用户模式)");
+            StartServices();
+            ShowMainWindow(desktop);
+            return;
+        }
+
         // 检测是否按住 Shift 键(用于跳过自动登录)
         var shiftPressed = KeyboardHelper.IsShiftPressed();
 
@@ -164,7 +179,8 @@ public partial class App : Application
             }
         };
 
-        desktop.MainWindow = loginView;
+        // 不设置 desktop.MainWindow,让 ShowMainWindow 来设置
+        // 这样 StartCore 不会尝试显示已关闭的登录窗口
         loginView.Show();
     }
 
@@ -239,6 +255,7 @@ public partial class App : Application
         AlarmService.Instance.Start();
         _dataLogger = new DataLoggingService(PlcPollingService.Instance);
         _dataLogger.Start();
+        DataGapService.Instance.Start();
 
         // 启动看门狗服务
         StartWatchdog();
@@ -325,6 +342,15 @@ public partial class App : Application
         _hubServer.Start();
         _hubServer.CommandReceived += HandleHubCommandAsync;
 
+        // HubServer 的鉴权判据是"令牌非空才校验",令牌为空即对所有客户端开放。
+        // 中控可以下发启停命令,这种情况必须让操作员在现场看到,而不是只有一行日志。
+        if (string.IsNullOrEmpty(config.HubToken))
+        {
+            Serilog.Log.Error("中控服务已启动但未配置访问令牌,任何客户端都可下发控制命令");
+            AlarmService.Instance.RecordJournalAlarm(
+                LanguageService.Instance.Get("HubTokenMissingJournal"));
+        }
+
         // 注册到连接状态集中管理
         ConnectionStatusService.Instance.RegisterHubServer(_hubServer);
 
@@ -502,6 +528,19 @@ public partial class App : Application
             var mainWindow = new MainWindow();
             desktop.MainWindow = mainWindow;
 
+            // 认证禁用模式:确保 UI 属性正确设置(防御性设置,不依赖 ViewModel 初始化时序)
+            if (ConfigService.GetConfig()?.EnableAuth == false)
+            {
+                if (mainWindow.DataContext is MainViewModel mainVm)
+                {
+                    mainVm.ShowUserInfo = false;
+                    mainVm.CanManageUsers = false;
+                    mainVm.CanControl = true;
+                    mainVm.CanModifyParams = true;
+                }
+                Serilog.Log.Information("无认证模式:已隐藏用户管理和退出按钮");
+            }
+
             // 先取消旧的事件订阅(防止累积)
             if (_userLoggedOutHandler != null)
             {
@@ -512,11 +551,16 @@ public partial class App : Application
             {
                 StopServices();
                 ShowLoginWindow(desktop);
-                mainWindow.Close();
+                mainWindow.CloseWithoutConfirmation();
             };
             AuthService.UserLoggedOut += _userLoggedOutHandler;
 
-            mainWindow.Show();
+            // 启动时:让 ClassicDesktopStyleApplicationLifetime.StartCore 来显示 MainWindow
+            // 登出后重新登录时:StartCore 已经返回,需要手动显示
+            if (_frameworkInitialized)
+            {
+                mainWindow.Show();
+            }
         }
         catch (Exception ex)
         {
@@ -627,6 +671,7 @@ public partial class App : Application
         // 5. 停止数据日志
         try
         {
+            DataGapService.Instance.Dispose();
             _dataLogger?.Dispose();
             Serilog.Log.Information("[{Elapsed}ms] DataLoggingService 已停止", sw.ElapsedMilliseconds);
         }
@@ -796,5 +841,6 @@ public partial class App : Application
         resources.Clear();
         resources.Add(targetTheme);
         ThemeHelper.SetTheme(ThemeService.Instance.IsDarkTheme);
+        RequestedThemeVariant = ThemeService.Instance.IsDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light;
     }
 }

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

@@ -3,6 +3,7 @@ using Avalonia.Controls;
 using Avalonia.Media;
 using Avalonia.Threading;
 using System;
+using YZWater.Core.Services;
 using System.Globalization;
 
 namespace YZWater.Avalonia.Controls;
@@ -90,7 +91,7 @@ public class FanControl : Control
         if (disconnected)
         {
             context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
-            var label = new FormattedText("通信中断", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
+            var label = new FormattedText(LanguageService.Instance.Get("CommInterrupted"), CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
             context.DrawText(label, new Point(cx - label.Width / 2, 2));
         }
     }

+ 2 - 1
src/YZWater.Avalonia/Controls/PumpControl.cs

@@ -3,6 +3,7 @@ using Avalonia.Controls;
 using Avalonia.Media;
 using Avalonia.Threading;
 using System;
+using YZWater.Core.Services;
 using System.Globalization;
 
 namespace YZWater.Avalonia.Controls;
@@ -132,7 +133,7 @@ public class PumpControl : Control
         if (disconnected)
         {
             context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
-            var label = new FormattedText("通信中断", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
+            var label = new FormattedText(LanguageService.Instance.Get("CommInterrupted"), CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
             context.DrawText(label, new Point(cx - label.Width / 2, 2));
         }
     }

+ 71 - 26
src/YZWater.Avalonia/Controls/PumpStation2DControl.cs

@@ -8,6 +8,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using System.Windows.Input;
+using YZWater.Core.Services;
 using YZWater.Core.ViewModels;
 using YZWater.Core.Models;
 using YZWater.Avalonia.Controls.Layout;
@@ -337,7 +338,7 @@ public class PumpStation2DControl : Control
 
         // ─── 标题 ───
         DrawLabelAt(context, offsetX + 8, offsetY + 8,
-            profile.Name, Math.Max(12, 14 * scale),
+            profile.Name.GetDisplayName(profile.Names), Math.Max(12, 14 * scale),
             ThemeHelper.TextPrimary, FontWeight.Bold);
 
         // ─── 状态指示灯带(每泵 运行绿/故障红闪 + 报警总灯)───
@@ -379,18 +380,19 @@ public class PumpStation2DControl : Control
         }
 
         // ── 状态灯 ──
-        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.Success), "运行");
-        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.TextDisabled), "停止");
-        Add((c, s) => DrawLegendTriangle(context, c, s, ThemeHelper.Danger), "故障");
-        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.Danger), "报警");
+        var lang = LanguageService.Instance;
+        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.Success), lang.Get("LegendRunning"));
+        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.TextDisabled), lang.Get("Stopped"));
+        Add((c, s) => DrawLegendTriangle(context, c, s, ThemeHelper.Danger), lang.Get("LegendFault"));
+        Add((c, s) => DrawLegendDot(context, c, s, ThemeHelper.Danger), lang.Get("LegendAlarm"));
         // ── 分隔线 ──
         items.Add(((_, _) => { }, "|", 14));
         // ── 组态符号 ──
-        Add((c, s) => DrawLegendGateValve(context, c, s), "闸阀");
-        Add((c, s) => DrawLegendCheckValve(context, c, s), "止回阀");
-        Add((c, s) => DrawLegendButterflyValve(context, c, s), "蝶阀");
-        Add((c, s) => DrawLegendFlowMeter(context, c, s), "流量计");
-        Add((c, s) => DrawLegendPressureGauge(context, c, s), "压力表");
+        Add((c, s) => DrawLegendGateValve(context, c, s), lang.Get("LegendGateValve"));
+        Add((c, s) => DrawLegendCheckValve(context, c, s), lang.Get("LegendCheckValve"));
+        Add((c, s) => DrawLegendButterflyValve(context, c, s), lang.Get("LegendButterflyValve"));
+        Add((c, s) => DrawLegendFlowMeter(context, c, s), lang.Get("LegendFlowMeter"));
+        Add((c, s) => DrawLegendPressureGauge(context, c, s), lang.Get("LegendPressureGauge"));
 
         var contentWidth = 0.0;
         foreach (var it in items) contentWidth += it.Width;
@@ -556,7 +558,7 @@ public class PumpStation2DControl : Control
         var unit = string.IsNullOrEmpty(deco.Unit) ? "" : $" {deco.Unit}";
 
         // 标签框(上方)— 带阴影效果
-        var label = "管道压力";
+        var label = LanguageService.Instance.Get("PipePressure");
         var labelFt = new FormattedText(label, System.Globalization.CultureInfo.CurrentCulture,
             FlowDirection.LeftToRight, new Typeface("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold),
             Math.Max(8, size * 0.45), ThemeHelper.TextPrimary);
@@ -778,13 +780,14 @@ public class PumpStation2DControl : Control
             : Color.FromRgb(33, 150, 243);
 
         // ─── 彩色阈值线(启泵1/2/3 绿、停泵 蓝、保护液位 红)───
+        var lang = LanguageService.Instance;
         var thresholds = new List<TankRenderer.ThresholdLine>
         {
-            new(GetAnalogValue($"{id}.StartPump1Level"), GetColor(ThemeHelper.Success), "启泵1"),
-            new(GetAnalogValue($"{id}.StartPump2Level"), Color.FromRgb(76, 175, 80), "启泵2"),
-            new(GetAnalogValue($"{id}.StartPump3Level"), GetColor(ThemeHelper.Warning), "启泵3"),
-            new(GetAnalogValue($"{id}.StopLevel"), GetColor(ThemeHelper.Info), "停泵"),
-            new(GetAnalogValue($"{id}.ProtectionLevel"), GetColor(ThemeHelper.Danger), "保护"),
+            new(GetAnalogValue($"{id}.StartPump1Level"), GetColor(ThemeHelper.Success), string.Format(lang.Get("ThresholdStartPump"), 1)),
+            new(GetAnalogValue($"{id}.StartPump2Level"), Color.FromRgb(76, 175, 80), string.Format(lang.Get("ThresholdStartPump"), 2)),
+            new(GetAnalogValue($"{id}.StartPump3Level"), GetColor(ThemeHelper.Warning), string.Format(lang.Get("ThresholdStartPump"), 3)),
+            new(GetAnalogValue($"{id}.StopLevel"), GetColor(ThemeHelper.Info), lang.Get("ThresholdStopPump")),
+            new(GetAnalogValue($"{id}.ProtectionLevel"), GetColor(ThemeHelper.Danger), lang.Get("ThresholdProtection")),
         };
 
         // 剖面池:池壁 + 波浪动画 + 液位大字 + 阈值线 + 刻度尺
@@ -797,7 +800,7 @@ public class PumpStation2DControl : Control
             TextTertiaryBrush: ThemeHelper.TextTertiary);
 
         TankRenderer.DrawSection(context, rect, rawLevel, maxLevel, waterColor,
-            id, _phase, thresholds, palette);
+            GetDeviceDisplayName(id, Profile!), _phase, thresholds, palette);
     }
 
     /// <summary>
@@ -893,12 +896,12 @@ public class PumpStation2DControl : Control
 
         // 立式泵:电机 → 联轴器 → 蜗壳叶轮 → 基座(rect.Center 为蜗壳中心)
         PumpRenderer.DrawVertical(context, new Point(cx, cy), r, running, statusColor,
-            _pumpAngle, device.DeviceId, ThemeHelper.Border, ThemeHelper.TextPrimary,
+            _pumpAngle, GetDeviceDisplayName(device.DeviceId, Profile!), ThemeHelper.Border, ThemeHelper.TextPrimary,
             faultFlashing: fault ? _faultFlash : -1);
 
         // ─── 泵右侧电流标签(数据融入图内)───
         var current = GetAnalogValue($"{device.DeviceId}.Current");
-        var currentText = fault ? "故障" : $"{current:F1}A";
+        var currentText = fault ? LanguageService.Instance.Get("LegendFault") : $"{current:F1}A";
         var currentBrush = fault
             ? ThemeHelper.Danger
             : running ? ThemeHelper.Success : ThemeHelper.TextTertiary;
@@ -919,10 +922,14 @@ public class PumpStation2DControl : Control
         // 仪表背景框
         context.DrawRectangle(ThemeHelper.SurfaceBg, new Pen(ThemeHelper.Border, 1), rect, 3, 3);
 
-        // 数值
         var fontSize = Math.Max(9, 11 * scale);
-        DrawLabelAt(context, rect.X + 4, rect.Y + 4, device.DeviceId, fontSize * 0.85, ThemeHelper.TextTertiary);
-        DrawLabelAt(context, rect.X + 4, rect.Y + fontSize + 4, $"{value:F1} {unit}", fontSize, ThemeHelper.TextPrimary, FontWeight.Bold);
+        // 标签(设备名)
+        DrawLabelAt(context, rect.X + 4, rect.Y + 4, GetDeviceDisplayName(device.DeviceId, Profile!), fontSize * 0.85, ThemeHelper.TextTertiary);
+        // 数值(Consolas 等宽字体,与流量计/压力表统一)
+        var valText = $"{value:F1} {unit}";
+        var valFt = new FormattedText(valText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Consolas", FontStyle.Normal, FontWeight.Bold), fontSize, ThemeHelper.TextPrimary);
+        context.DrawText(valFt, new Point(rect.X + 4, rect.Y + fontSize + 4));
     }
 
     private void RenderDynamicSensor(DrawingContext context, Rect rect, DeviceLayout device, double scale)
@@ -933,8 +940,13 @@ public class PumpStation2DControl : Control
         context.DrawRectangle(ThemeHelper.SurfaceBg, new Pen(ThemeHelper.Border, 1), rect, 3, 3);
 
         var fontSize = Math.Max(9, 11 * scale);
-        DrawLabelAt(context, rect.X + 4, rect.Y + 4, device.DeviceId, fontSize * 0.85, ThemeHelper.TextTertiary);
-        DrawLabelAt(context, rect.X + 4, rect.Y + fontSize + 4, $"{value:F1} {unit}", fontSize, ThemeHelper.TextPrimary);
+        // 标签(设备名)
+        DrawLabelAt(context, rect.X + 4, rect.Y + 4, GetDeviceDisplayName(device.DeviceId, Profile!), fontSize * 0.85, ThemeHelper.TextTertiary);
+        // 数值(Consolas 等宽字体,与流量计/压力表统一)
+        var valText = $"{value:F1} {unit}";
+        var valFt = new FormattedText(valText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
+            new Typeface("Consolas", FontStyle.Normal, FontWeight.Bold), fontSize, ThemeHelper.TextPrimary);
+        context.DrawText(valFt, new Point(rect.X + 4, rect.Y + fontSize + 4));
     }
 
     private void RenderDynamicFan(DrawingContext context, Rect rect, DeviceLayout device, double scale)
@@ -945,7 +957,7 @@ public class PumpStation2DControl : Control
         var r = Math.Min(rect.Width, rect.Height) / 2;
         var statusColor = running ? GetColor(ThemeHelper.Info) : GetColor(ThemeHelper.TextDisabled);
 
-        FanRenderer.Draw(context, new Point(cx, cy), r, running, _fanAngle, device.DeviceId,
+        FanRenderer.Draw(context, new Point(cx, cy), r, running, _fanAngle, GetDeviceDisplayName(device.DeviceId, Profile!),
             statusColor, ThemeHelper.SurfaceBg, ThemeHelper.TextPrimary,
             bladeCount: 5, bladePenThickness: Math.Max(2, 3 * scale));
     }
@@ -958,7 +970,7 @@ public class PumpStation2DControl : Control
         var s = Math.Min(rect.Width, rect.Height) / 2;
         var color = isOpen ? GetColor(ThemeHelper.Success) : GetColor(ThemeHelper.Danger);
 
-        ValveRenderer.Draw(context, new Point(cx, cy), s, color, device.DeviceId,
+        ValveRenderer.Draw(context, new Point(cx, cy), s, color, GetDeviceDisplayName(device.DeviceId, Profile!),
             ThemeHelper.TextSecondary, stemPenThickness: Math.Max(1, 1.5 * scale));
     }
 
@@ -982,5 +994,38 @@ public class PumpStation2DControl : Control
 
     private static Color GetColor(IBrush brush) => brush is SolidColorBrush solid ? solid.Color : Colors.Gray;
 
+    /// <summary>
+    /// 根据设备 ID 获取本地化显示名
+    /// </summary>
+    private string GetDeviceDisplayName(string deviceId, ProjectProfile profile)
+    {
+        // 查找泵
+        var pump = profile.Pumps?.FirstOrDefault(p => p.Name == deviceId);
+        if (pump != null) return pump.Name.GetDisplayName(pump.Names);
+
+        // 查找水箱
+        var tank = profile.Tanks?.FirstOrDefault(t => t.Name == deviceId);
+        if (tank != null) return tank.Name.GetDisplayName(tank.Names);
+
+        // 查找仪表
+        var gauge = profile.Gauges?.FirstOrDefault(g => g.Name == deviceId);
+        if (gauge != null) return gauge.Name.GetDisplayName(gauge.Names);
+
+        // 查找传感器
+        var sensor = profile.Sensors?.FirstOrDefault(s => s.Name == deviceId);
+        if (sensor != null) return sensor.Name.GetDisplayName(sensor.Names);
+
+        // 查找数字量输入
+        var di = profile.DigitalInputs?.FirstOrDefault(d => d.Name == deviceId);
+        if (di != null) return di.Name.GetDisplayName(di.Names);
+
+        // 查找数字量输出
+        var dop = profile.DigitalOutputs?.FirstOrDefault(d => d.Name == deviceId);
+        if (dop != null) return dop.Name.GetDisplayName(dop.Names);
+
+        // fallback 到原始 ID
+        return deviceId;
+    }
+
     protected override Size MeasureOverride(Size availableSize) => new(760, 420);
 }

+ 2 - 1
src/YZWater.Avalonia/Controls/StatusCard.cs

@@ -2,6 +2,7 @@ using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Media;
 using System.Globalization;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Controls;
 
@@ -56,7 +57,7 @@ public class StatusCard : Control
         context.DrawText(titleText, new Point(28, 22));
 
         // 断线时状态显示"未知"而非最后已知状态
-        var displayStatus = disconnected ? "未知" : Status;
+        var displayStatus = disconnected ? LanguageService.Instance.Get("Unknown") : Status;
         var statusText = new FormattedText(displayStatus, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 9, statusColor);
         context.DrawText(statusText, new Point(bounds.Width - statusText.Width - 10, bounds.Height / 2 - statusText.Height / 2));
 

+ 11 - 10
src/YZWater.Avalonia/Converters/BoolConverters.cs

@@ -2,6 +2,7 @@ using Avalonia.Data.Converters;
 using Avalonia.Media;
 using System;
 using System.Globalization;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Converters;
 
@@ -43,13 +44,13 @@ public static class BoolConverters
     /// true → "中控", false → "屏控"
     /// </summary>
     public static readonly IValueConverter ToModeText =
-        new FuncValueConverter<bool, string>(active => active ? "中控" : "屏控");
+        new FuncValueConverter<bool, string>(active => active ? LanguageService.Instance.Get("CentralShort") : LanguageService.Instance.Get("ScreenShort"));
 
     /// <summary>
     /// true → "自动", false → "手动"
     /// </summary>
     public static readonly IValueConverter ToAutoModeText =
-        new FuncValueConverter<bool, string>(active => active ? "自动" : "手动");
+        new FuncValueConverter<bool, string>(active => active ? LanguageService.Instance.Get("AutoMode") : LanguageService.Instance.Get("ManualMode"));
 
     /// <summary>
     /// 轮换建议高亮边框:true → 琥珀色(#FFB300),false → 灰蓝(#3A4152)
@@ -84,7 +85,7 @@ public static class BoolConverters
     /// 含义:点击按钮将切换到对端模式
     /// </summary>
     public static readonly IValueConverter ToModeToggleText =
-        new FuncValueConverter<bool, string>(isCentral => isCentral ? "切屏控" : "切中控");
+        new FuncValueConverter<bool, string>(isCentral => isCentral ? LanguageService.Instance.Get("SwitchToScreen") : LanguageService.Instance.Get("SwitchToCentral"));
 
     /// <summary>
     /// 控件激活态背景:true → 半透明蓝(#1A4A9EFF), false → 透明
@@ -123,7 +124,7 @@ public static class BoolConverters
     /// 手动/自动切换按钮文字:IsAutoMode=true → "切手动", false → "切自动"
     /// </summary>
     public static readonly IValueConverter ToManualToggleText =
-        new FuncValueConverter<bool, string>(isAuto => isAuto ? "切手动" : "切自动");
+        new FuncValueConverter<bool, string>(isAuto => isAuto ? LanguageService.Instance.Get("SwitchToManual") : LanguageService.Instance.Get("SwitchToAuto"));
 
     /// <summary>
     /// 手动/自动切换按钮颜色:IsAutoMode=true → 琥珀(#FFB300), false → 绿(#00897B)
@@ -138,14 +139,14 @@ public static class BoolConverters
     /// </summary>
     public static readonly IValueConverter ToManualToggleBgBrush =
         new FuncValueConverter<bool, IBrush>(isAuto =>
-            isAuto ? new SolidColorBrush(Color.Parse("#1AFFB300"))
-                   : new SolidColorBrush(Color.Parse("#1A00897B")));
+            isAuto ? new SolidColorBrush(Color.Parse("#40FFB300"))
+                   : new SolidColorBrush(Color.Parse("#4000897B")));
 
     /// <summary>
     /// 泵启停按钮文字:IsRunning=true → "停止", false → "启动"
     /// </summary>
     public static readonly IValueConverter ToPumpToggleText =
-        new FuncValueConverter<bool, string>(isRunning => isRunning ? "停止" : "启动");
+        new FuncValueConverter<bool, string>(isRunning => isRunning ? LanguageService.Instance.Get("StopPump") : LanguageService.Instance.Get("StartPump"));
 
     /// <summary>
     /// 泵启停按钮颜色:IsRunning=true → 停止红(#E53935), false → 启动绿(#00897B)
@@ -156,10 +157,10 @@ public static class BoolConverters
                       : new SolidColorBrush(Color.Parse("#00897B")));
 
     /// <summary>
-    /// 泵启停按钮背景:IsRunning=true → 红色半透明, false → 绿色半透明
+    /// 泵启停按钮背景:IsRunning=true → 停止红(实心), false → 启动绿(实心)
     /// </summary>
     public static readonly IValueConverter ToPumpToggleBgBrush =
         new FuncValueConverter<bool, IBrush>(isRunning =>
-            isRunning ? new SolidColorBrush(Color.Parse("#1AE53935"))
-                      : new SolidColorBrush(Color.Parse("#1A00897B")));
+            isRunning ? new SolidColorBrush(Color.Parse("#C62828"))
+                      : new SolidColorBrush(Color.Parse("#00695C")));
 }

+ 8 - 7
src/YZWater.Avalonia/Converters/TankStageConverters.cs

@@ -3,27 +3,28 @@ using Avalonia.Media;
 using System;
 using System.Globalization;
 using YZWater.Core.Models;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Converters;
 
 /// <summary>
 /// 液位工况档位(TankStage)转换器集合:
 /// <list type="bullet">
-/// <item><see cref="ToText"/> — 档位中文名(停泵区/启1泵/启2泵/启3泵/保护水位)</item>
+/// <item><see cref="ToText"/> — 档位名称(随语言切换)</item>
 /// <item><see cref="ToBrush"/> — 档位警示色(停泵灰 / 启泵蓝绿 / 保护红)</item>
 /// </list>
 /// </summary>
 public static class TankStageConverters
 {
-    /// <summary>档位中文名</summary>
+    /// <summary>档位名称(随语言切换)</summary>
     public static readonly IValueConverter ToText =
         new FuncValueConverter<TankStage, string>(stage => stage switch
         {
-            TankStage.Protection => "保护水位",
-            TankStage.Pump3 => "启3泵区",
-            TankStage.Pump2 => "启2泵区",
-            TankStage.Pump1 => "启1泵区",
-            _ => "停泵区",
+            TankStage.Protection => LanguageService.Instance.Get("TankStageProtection"),
+            TankStage.Pump3 => LanguageService.Instance.Get("TankStagePump3"),
+            TankStage.Pump2 => LanguageService.Instance.Get("TankStagePump2"),
+            TankStage.Pump1 => LanguageService.Instance.Get("TankStagePump1"),
+            _ => LanguageService.Instance.Get("TankStageStop"),
         });
 
     /// <summary>档位警示色:保护红 / 启泵琥珀 / 停泵灰绿</summary>

+ 67 - 1
src/YZWater.Avalonia/Themes/IndustrialStyles.axaml

@@ -88,7 +88,7 @@
         <Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
         <Setter Property="FontSize" Value="11"/>
         <Setter Property="Padding" Value="12,6"/>
-        <Setter Property="CornerRadius" Value="2"/>
+        <Setter Property="CornerRadius" Value="5"/>
         <Setter Property="MinHeight" Value="32"/>
     </Style>
     <Style Selector="Button.btn-warning:pointerover /template/ ContentPresenter">
@@ -146,4 +146,70 @@
         <Setter Property="FontWeight" Value="Bold"/>
     </Style>
 
+    <!-- ─── 按钮: Primary (实心蓝底,主要操作) ─── -->
+    <Style Selector="Button.btn-primary">
+        <Setter Property="Background" Value="{DynamicResource InfoBrush}"/>
+        <Setter Property="Foreground" Value="White"/>
+        <Setter Property="BorderBrush" Value="{DynamicResource InfoBrush}"/>
+        <Setter Property="FontFamily" Value="{DynamicResource MonoFont}"/>
+        <Setter Property="FontSize" Value="11"/>
+        <Setter Property="FontWeight" Value="Bold"/>
+        <Setter Property="Padding" Value="12,6"/>
+        <Setter Property="CornerRadius" Value="5"/>
+        <Setter Property="MinHeight" Value="32"/>
+    </Style>
+    <Style Selector="Button.btn-primary:pointerover /template/ ContentPresenter">
+        <Setter Property="Background" Value="{DynamicResource InfoHoverBrush}"/>
+    </Style>
+    <Style Selector="Button.btn-primary:pressed /template/ ContentPresenter">
+        <Setter Property="Background" Value="{DynamicResource InfoPressedBrush}"/>
+    </Style>
+    <Style Selector="Button.btn-primary:disabled">
+        <Setter Property="Opacity" Value="0.4"/>
+    </Style>
+
+    <!-- ─── 按钮: Compact (叠加使用,小尺寸工具栏按钮) ─── -->
+    <Style Selector="Button.btn-compact">
+        <Setter Property="FontSize" Value="10"/>
+        <Setter Property="FontWeight" Value="Bold"/>
+        <Setter Property="Padding" Value="8,3"/>
+        <Setter Property="MinHeight" Value="24"/>
+        <Setter Property="CornerRadius" Value="3"/>
+    </Style>
+
+    <!-- ─── 窗口控制按钮 (最小化/最大化/关闭) ─── -->
+    <Style Selector="Button.chrome-btn">
+        <Setter Property="Background" Value="Transparent"/>
+        <Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
+        <Setter Property="BorderThickness" Value="0"/>
+        <Setter Property="Width" Value="36"/>
+        <Setter Property="Height" Value="32"/>
+        <Setter Property="FontSize" Value="14"/>
+        <Setter Property="CornerRadius" Value="0"/>
+        <Setter Property="HorizontalContentAlignment" Value="Center"/>
+    </Style>
+    <Style Selector="Button.chrome-btn:pointerover /template/ ContentPresenter">
+        <Setter Property="Background" Value="{DynamicResource ChromeHoverBrush}"/>
+    </Style>
+    <Style Selector="Button.chrome-btn:pressed /template/ ContentPresenter">
+        <Setter Property="Background" Value="{DynamicResource ChromePressedBrush}"/>
+    </Style>
+
+    <!-- ─── 底部导航标签 ─── -->
+    <Style Selector="Button.nav-tab">
+        <Setter Property="Background" Value="Transparent"/>
+        <Setter Property="BorderThickness" Value="0"/>
+        <Setter Property="Padding" Value="20,8"/>
+        <Setter Property="CornerRadius" Value="0"/>
+        <Setter Property="Cursor" Value="Hand"/>
+    </Style>
+    <Style Selector="Button.nav-tab:pointerover /template/ ContentPresenter">
+        <Setter Property="Background" Value="{DynamicResource ChromeHoverBrush}"/>
+    </Style>
+
+    <!-- ─── TableView 表头文字色(背景条在主题字典覆盖 SystemControlBackgroundChromeMediumBrush) ─── -->
+    <Style Selector="TableViewColumnHeader">
+        <Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
+    </Style>
+
 </Styles>

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

@@ -64,6 +64,11 @@
     <Color x:Key="ToggleBorderColor">#36536B</Color>
     <Color x:Key="ToggleActiveBgColor">#117A61</Color>
 
+    <!-- 窗口控件/导航 hover/pressed -->
+    <Color x:Key="ChromeHoverColor">#1A2744</Color>
+    <Color x:Key="ChromePressedColor">#1E3050</Color>
+    <Color x:Key="DangerFgColor">#E74C3C</Color>
+
     <!-- 画刷 -->
     <SolidColorBrush x:Key="AppBgBrush" Color="{StaticResource AppBgColor}"/>
     <SolidColorBrush x:Key="SurfaceBgBrush" Color="{StaticResource SurfaceBgColor}"/>
@@ -100,6 +105,11 @@
     <SolidColorBrush x:Key="ToggleBgBrush" Color="{StaticResource ToggleBgColor}"/>
     <SolidColorBrush x:Key="ToggleBorderBrush" Color="{StaticResource ToggleBorderColor}"/>
     <SolidColorBrush x:Key="ToggleActiveBgBrush" Color="{StaticResource ToggleActiveBgColor}"/>
+    <SolidColorBrush x:Key="ChromeHoverBrush" Color="{StaticResource ChromeHoverColor}"/>
+    <SolidColorBrush x:Key="ChromePressedBrush" Color="{StaticResource ChromePressedColor}"/>
+    <SolidColorBrush x:Key="DangerFgBrush" Color="{StaticResource DangerFgColor}"/>
+    <!-- 覆盖 Fluent 的 TableView 表头条默认灰(深色) -->
+    <SolidColorBrush x:Key="SystemControlBackgroundChromeMediumBrush" Color="{StaticResource ToggleBgColor}"/>
 
     <!-- 字体 -->
     <FontFamily x:Key="MonoFont">Consolas, Courier New, monospace</FontFamily>

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

@@ -64,6 +64,11 @@
     <Color x:Key="ToggleBorderColor">#AEC4D2</Color>
     <Color x:Key="ToggleActiveBgColor">#047857</Color>
 
+    <!-- 窗口控件/导航 hover/pressed -->
+    <Color x:Key="ChromeHoverColor">#D5DFE6</Color>
+    <Color x:Key="ChromePressedColor">#C0CDD6</Color>
+    <Color x:Key="DangerFgColor">#DC2626</Color>
+
     <!-- 画刷 -->
     <SolidColorBrush x:Key="AppBgBrush" Color="{StaticResource AppBgColor}"/>
     <SolidColorBrush x:Key="SurfaceBgBrush" Color="{StaticResource SurfaceBgColor}"/>
@@ -96,6 +101,11 @@
     <SolidColorBrush x:Key="ToggleBgBrush" Color="{StaticResource ToggleBgColor}"/>
     <SolidColorBrush x:Key="ToggleBorderBrush" Color="{StaticResource ToggleBorderColor}"/>
     <SolidColorBrush x:Key="ToggleActiveBgBrush" Color="{StaticResource ToggleActiveBgColor}"/>
+    <SolidColorBrush x:Key="ChromeHoverBrush" Color="{StaticResource ChromeHoverColor}"/>
+    <SolidColorBrush x:Key="ChromePressedBrush" Color="{StaticResource ChromePressedColor}"/>
+    <SolidColorBrush x:Key="DangerFgBrush" Color="{StaticResource DangerFgColor}"/>
+    <!-- 覆盖 Fluent 的 TableView 表头条默认灰(浅色) -->
+    <SolidColorBrush x:Key="SystemControlBackgroundChromeMediumBrush" Color="{StaticResource ToggleBgColor}"/>
     <SolidColorBrush x:Key="HeaderBgBrush" Color="{StaticResource HeaderBgColor}"/>
     <SolidColorBrush x:Key="HeaderTextBrush" Color="{StaticResource HeaderTextColor}"/>
     <SolidColorBrush x:Key="HeaderSubtextBrush" Color="{StaticResource HeaderSubtextColor}"/>

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

@@ -19,9 +19,8 @@
                     <Image Source="/Assets/app-icon.png" Width="18" Height="18" Margin="10,0,4,0" VerticalAlignment="Center"/>
                     <TextBlock Grid.Column="1" Text="{Binding LoginTitleText}" FontSize="12" FontWeight="SemiBold"
                                VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}"/>
-                    <Button Grid.Column="2" Content="✕" Click="OnCloseClick" Width="36" Height="32" BorderThickness="0"
-                            Background="Transparent" FontSize="14" HorizontalContentAlignment="Center"
-                            Foreground="#E74C3C"/>
+                    <Button Grid.Column="2" Content="✕" Click="OnCloseClick" Classes="chrome-btn"
+                            Foreground="{DynamicResource DangerFgBrush}"/>
                 </Grid>
             </Border>
 

+ 42 - 23
src/YZWater.Avalonia/Views/MainWindow.axaml

@@ -28,13 +28,10 @@
                 <TextBlock Grid.Column="1" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
                            VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}"/>
                 <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
-                    <Button Content="─" Click="OnMinimizeClick" Width="36" Height="32" BorderThickness="0"
-                            Background="Transparent" FontSize="14" HorizontalContentAlignment="Center"/>
-                    <Button x:Name="MaximizeBtn" Content="☐" Click="OnMaximizeClick" Width="36" Height="32" BorderThickness="0"
-                            Background="Transparent" FontSize="14" HorizontalContentAlignment="Center"/>
-                    <Button Content="✕" Click="OnCloseClick" Width="36" Height="32" BorderThickness="0"
-                            Background="Transparent" FontSize="14" HorizontalContentAlignment="Center"
-                            Foreground="#E74C3C"/>
+                    <Button Content="─" Click="OnMinimizeClick" Classes="chrome-btn"/>
+                    <Button x:Name="MaximizeBtn" Content="☐" Click="OnMaximizeClick" Classes="chrome-btn"/>
+                    <Button Content="✕" Click="OnCloseClick" Classes="chrome-btn"
+                            Foreground="{DynamicResource DangerFgBrush}"/>
                 </StackPanel>
             </Grid>
         </Border>
@@ -45,7 +42,7 @@
             <Grid ColumnDefinitions="*,Auto,Auto,Auto">
                 <!-- 导航按钮 -->
                 <StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="0">
-                    <Button Command="{Binding ShowViewACommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewACommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabAActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -55,7 +52,7 @@
                         </StackPanel>
                     </Button>
                     <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
-                    <Button Command="{Binding ShowViewBCommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewBCommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabBActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -65,7 +62,7 @@
                         </StackPanel>
                     </Button>
                     <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
-                    <Button Command="{Binding ShowViewCCommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewCCommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabCActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -75,7 +72,7 @@
                         </StackPanel>
                     </Button>
                     <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
-                    <Button Command="{Binding ShowViewDCommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewDCommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabDActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -85,7 +82,7 @@
                         </StackPanel>
                     </Button>
                     <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
-                    <Button Command="{Binding ShowViewECommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewECommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabEActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -95,7 +92,7 @@
                         </StackPanel>
                     </Button>
                     <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
-                    <Button Command="{Binding ShowViewFCommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                    <Button Command="{Binding ShowViewFCommand}" Classes="nav-tab">
                         <StackPanel Orientation="Horizontal" Spacing="6">
                             <Border Width="3" Height="14" CornerRadius="1"
                                     Background="{Binding IsTabFActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
@@ -126,22 +123,20 @@
 
                 <!-- 用户信息 + 模式指示 -->
                 <StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" Margin="8,0" VerticalAlignment="Center">
-                    <Border CornerRadius="4" Padding="6,2" Background="{DynamicResource CardBgBrush}">
-                        <StackPanel Orientation="Horizontal" Spacing="4">
-                            <Ellipse Width="8" Height="8" Fill="{Binding PlcStatusColor}" VerticalAlignment="Center"/>
-                            <TextBlock Text="{Binding PlcStatusText}" FontSize="10" Foreground="{DynamicResource TextSecondaryBrush}"/>
-                        </StackPanel>
-                    </Border>
                     <Border Background="{DynamicResource CardBgBrush}" CornerRadius="4" Padding="6,2">
                         <TextBlock Text="{Binding HubStatusText}" FontSize="10" Foreground="{DynamicResource TextSecondaryBrush}"/>
                     </Border>
-                    <TextBlock Text="{Binding CurrentUserName}" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"/>
-                    <Border Background="{DynamicResource PrimaryBrush}" CornerRadius="4" Padding="6,2">
+                    <!-- 用户信息区域(认证禁用时隐藏) -->
+                    <TextBlock Text="{Binding CurrentUserName}" FontSize="12" FontWeight="Bold" VerticalAlignment="Center"
+                               IsVisible="{Binding ShowUserInfo}"/>
+                    <Border Background="{DynamicResource PrimaryBrush}" CornerRadius="4" Padding="6,2"
+                            IsVisible="{Binding ShowUserInfo}">
                         <TextBlock Text="{Binding CurrentUserRole}" FontSize="10" Foreground="White"/>
                     </Border>
                     <Button Command="{Binding ToggleUserDialogCommand}" IsVisible="{Binding CanManageUsers}"
                             Content="{Binding UserManageText}" Padding="8,4" FontSize="11" Classes="btn-primary"/>
-                    <Button Command="{Binding LogoutCommand}" Content="{Binding LogoutText}" Padding="8,4" FontSize="11" Classes="btn-danger"/>
+                    <Button Command="{Binding LogoutCommand}" Content="{Binding LogoutText}" Padding="8,4" FontSize="11" Classes="btn-danger"
+                            IsVisible="{Binding ShowUserInfo}"/>
                 </StackPanel>
             </Grid>
         </Border>
@@ -158,7 +153,7 @@
                     IsHitTestVisible="False">
                 <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
                     <TextBlock Text="⚠" FontSize="16" VerticalAlignment="Center" Foreground="#2C3E50"/>
-                    <TextBlock Text="PLC 通信中断 — 当前数据不可信,所有控制操作已禁用"
+                    <TextBlock Text="{Binding PlcCommInterruptedText}"
                                FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Foreground="#2C3E50"/>
                 </StackPanel>
             </Border>
@@ -218,6 +213,30 @@
                     </DockPanel>
                 </Border>
             </Border>
+
+            <!-- ═══ 退出确认对话框(破坏性操作需二次确认,防止误触 ✕ 停屏) ═══ -->
+            <Border IsVisible="{Binding ShowExitDialog}" ZIndex="110" Background="#CC000000">
+                <Border Background="{DynamicResource CardBgBrush}" CornerRadius="12" Padding="24"
+                        MaxWidth="420" VerticalAlignment="Center" HorizontalAlignment="Center"
+                        BorderBrush="{DynamicResource DangerFgBrush}" BorderThickness="1">
+                    <StackPanel Spacing="16">
+                        <StackPanel Orientation="Horizontal" Spacing="10">
+                            <TextBlock Text="⚠" FontSize="18" FontFamily="Segoe UI Symbol" VerticalAlignment="Center"
+                                       Foreground="{DynamicResource DangerFgBrush}"/>
+                            <TextBlock Text="{Binding ExitConfirmTitleText}" FontSize="18" FontWeight="Bold"
+                                       VerticalAlignment="Center"/>
+                        </StackPanel>
+                        <TextBlock Text="{Binding ExitConfirmMessageText}" FontSize="13" TextWrapping="Wrap"
+                                   Foreground="{DynamicResource TextSecondaryBrush}"/>
+                        <StackPanel Orientation="Horizontal" Spacing="12" HorizontalAlignment="Right">
+                            <Button Content="{Binding CancelText}" Click="OnExitCancelClick"
+                                    Classes="btn-toggle" MinWidth="88"/>
+                            <Button Content="{Binding ConfirmText}" Click="OnExitConfirmClick"
+                                    Classes="btn-danger" MinWidth="88"/>
+                        </StackPanel>
+                    </StackPanel>
+                </Border>
+            </Border>
         </Panel>
     </DockPanel>
 </Window>

+ 49 - 1
src/YZWater.Avalonia/Views/MainWindow.axaml.cs

@@ -6,6 +6,7 @@ using Avalonia.Interactivity;
 using Avalonia.Media;
 using YZWater.Avalonia.Controls;
 using YZWater.Core.Services;
+using YZWater.Core.ViewModels;
 
 namespace YZWater.Avalonia.Views;
 
@@ -14,6 +15,9 @@ public partial class MainWindow : Window
     private Border? _navBar;
     private Button? _maximizeBtn;
 
+    // 已经过确认(或走登出等程序化关闭)时不再拦 Close,否则确认框会把自己关不掉
+    private bool _exitApproved;
+
     public MainWindow()
     {
         InitializeComponent();
@@ -91,12 +95,56 @@ public partial class MainWindow : Window
         base.OnClosed(e);
     }
 
-    // 关闭
+    // 拦截 ✕ 之外的关闭路径(Alt+F4 / WM_CLOSE),统一走确认框
+    protected override void OnClosing(WindowClosingEventArgs e)
+    {
+        // 系统关机或应用自身 Shutdown 必须放行,否则确认框会挡住 Windows 关机/重启
+        var mustNotBlock = _exitApproved
+            || e.CloseReason is WindowCloseReason.OSShutdown or WindowCloseReason.ApplicationShutdown;
+
+        if (!mustNotBlock)
+        {
+            e.Cancel = true;
+            ShowExitDialog();
+        }
+
+        base.OnClosing(e);
+    }
+
+    // 关闭按钮:先确认,不直接退出
     private void OnCloseClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
     {
+        ShowExitDialog();
+    }
+
+    private void ShowExitDialog()
+    {
+        if (DataContext is MainViewModel vm)
+            vm.ShowExitDialog = true;
+    }
+
+    private void OnExitConfirmClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        _exitApproved = true;
+        if (DataContext is MainViewModel vm)
+            vm.ShowExitDialog = false;
+
         if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
             desktop.Shutdown();
         else
             Close();
     }
+
+    private void OnExitCancelClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        if (DataContext is MainViewModel vm)
+            vm.ShowExitDialog = false;
+    }
+
+    /// <summary>程序化关闭(登出后销毁主窗口),不再要求确认</summary>
+    public void CloseWithoutConfirmation()
+    {
+        _exitApproved = true;
+        Close();
+    }
 }

+ 120 - 146
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -49,7 +49,7 @@
                                 Background="{DynamicResource DangerBrush}" CornerRadius="12" Padding="9,4">
                             <StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
                                 <Border Width="8" Height="8" CornerRadius="4" Background="White"/>
-                                <TextBlock Text="报警" FontFamily="{DynamicResource MonoFont}" FontSize="10"
+                                <TextBlock Text="{Binding AlarmBadgeText}" FontFamily="{DynamicResource MonoFont}" FontSize="10"
                                            Foreground="White" VerticalAlignment="Center"/>
                                 <TextBlock Text="{Binding ActiveAlarmCount}" FontFamily="Consolas, monospace"
                                            FontSize="13" FontWeight="Bold"
@@ -78,7 +78,7 @@
                         <Grid RowDefinitions="Auto,*">
                             <StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="14,10,14,6">
                                 <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="16" CornerRadius="1"/>
-                                <TextBlock Text="工艺流程" FontFamily="Consolas, monospace" FontSize="12"
+                                <TextBlock Text="{Binding ProcessFlowTitleText}" FontFamily="Consolas, monospace" FontSize="12"
                                            FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
                                 <!-- 2D/3D 切换按钮组:仅在配置启用 3D 时显示 -->
                                 <StackPanel Orientation="Horizontal" Spacing="4" Margin="8,0,0,0"
@@ -96,30 +96,31 @@
                                         Profile="{Binding Profile}"
                                         AnalogValues="{Binding AnalogValues}"
                                         DigitalValues="{Binding DigitalValues}"
-                                        DeviceClickedCommand="{Binding OnDeviceClickedCommand}"/>
+                                        DeviceClickedCommand="{Binding DeviceClickedCommand}"/>
                                     <controls:PumpStation3DControl
                                         IsVisible="{Binding Is3DView}"
                                         Profile="{Binding Profile}"
                                         AnalogValues="{Binding AnalogValues}"
                                         DigitalValues="{Binding DigitalValues}"
-                                        DeviceClickedCommand="{Binding OnDeviceClickedCommand}"/>
+                                        DeviceClickedCommand="{Binding DeviceClickedCommand}"/>
 
                                     <!-- 泵操作浮层:点击图内泵弹出(防误触,不再直接启停) -->
                                     <Border IsVisible="{Binding IsPumpPopupOpen}"
                                             Background="#80000000" CornerRadius="5">
                                         <Border Width="270" HorizontalAlignment="Center" VerticalAlignment="Center"
                                                 Background="{DynamicResource SurfaceBgBrush}" CornerRadius="8" Padding="16,12"
-                                                BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                                                BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
+                                                DataContext="{Binding SelectedPump}">
                                             <StackPanel Spacing="8">
                                                 <!-- 标题 + 关闭 -->
                                                 <Grid ColumnDefinitions="*,Auto">
                                                     <StackPanel Orientation="Horizontal" Spacing="8">
                                                         <Border Width="10" Height="10" CornerRadius="5" VerticalAlignment="Center"
-                                                                Background="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
-                                                        <TextBlock Text="{Binding SelectedPump.Name}" FontSize="15" FontWeight="Bold"
+                                                                Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
+                                                        <TextBlock Text="{Binding Name}" FontSize="15" FontWeight="Bold"
                                                                    Foreground="{DynamicResource TextPrimaryBrush}"/>
                                                     </StackPanel>
-                                                    <Button Grid.Column="1" Content="✕" Command="{Binding ClosePumpPopupCommand}"
+                                                    <Button Grid.Column="1" Content="✕" Click="OnClosePumpPopupClick"
                                                             Padding="6,2" FontSize="11"/>
                                                 </Grid>
 
@@ -128,52 +129,52 @@
                                                         BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
                                                     <StackPanel Spacing="4">
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="运行状态" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Text="{Binding RunningStatusText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
                                                             <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
                                                                        Foreground="{DynamicResource SuccessBrush}"
-                                                                       IsVisible="{Binding SelectedPump.IsRunning}" Text="运行中"/>
+                                                                       IsVisible="{Binding IsRunning}" Text="{Binding RunningText}"/>
                                                             <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12"
                                                                        Foreground="{DynamicResource TextSecondaryBrush}"
-                                                                       IsVisible="{Binding !SelectedPump.IsRunning}" Text="已停止"/>
+                                                                       IsVisible="{Binding !IsRunning}" Text="{Binding StoppedText}"/>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="电流" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.Current, StringFormat='{}{0:F1} A'}"
+                                                            <TextBlock Text="{Binding CurrentLabelText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding Current, StringFormat='{}{0:F1} A'}"
                                                                        FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
                                                                        Foreground="{DynamicResource SuccessBrush}"/>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="日台时" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Text="{Binding DailyHoursLabelText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
                                                             <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="12"
                                                                        Foreground="{DynamicResource TextSecondaryBrush}">
                                                                 <TextBlock.Text>
                                                                     <MultiBinding StringFormat="{}{0:F0}h {1:F0}m">
-                                                                        <Binding Path="SelectedPump.DailyHours"/>
-                                                                        <Binding Path="SelectedPump.DailyMinutes"/>
+                                                                        <Binding Path="DailyHours"/>
+                                                                        <Binding Path="DailyMinutes"/>
                                                                     </MultiBinding>
                                                                 </TextBlock.Text>
                                                             </TextBlock>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="控制模式" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
+                                                            <TextBlock Text="{Binding ControlModeLabelText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
                                                                        FontFamily="Consolas, monospace" FontSize="12"
                                                                        Foreground="{DynamicResource InfoBrush}"/>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="手自动" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToAutoModeText}}"
+                                                            <TextBlock Text="{Binding ManualAutoLabelText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToAutoModeText}}"
                                                                        FontFamily="Consolas, monospace" FontSize="12"
                                                                        Foreground="{DynamicResource WarningBrush}"/>
                                                         </Grid>
                                                         <Grid ColumnDefinitions="*,Auto">
-                                                            <TextBlock Text="故障" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
-                                                            <TextBlock Grid.Column="1" Text="故障!" FontSize="12" FontWeight="Bold"
+                                                            <TextBlock Text="{Binding FaultLabelText}" FontSize="11" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding FaultText}" FontSize="12" FontWeight="Bold"
                                                                        Foreground="{DynamicResource DangerBrush}"
-                                                                       IsVisible="{Binding SelectedPump.HasFault}"/>
-                                                            <TextBlock Grid.Column="1" Text="正常" FontSize="12"
+                                                                       IsVisible="{Binding HasFault}"/>
+                                                            <TextBlock Grid.Column="1" Text="{Binding NormalText}" FontSize="12"
                                                                        Foreground="{DynamicResource TextSecondaryBrush}"
-                                                                       IsVisible="{Binding !SelectedPump.HasFault}"/>
+                                                                       IsVisible="{Binding !HasFault}"/>
                                                         </Grid>
                                                     </StackPanel>
                                                 </Border>
@@ -182,39 +183,38 @@
                                                 <Grid ColumnDefinitions="*,*" Margin="0,4,0,0" ColumnSpacing="6">
                                                     <!-- 中控/屏控 状态指示 (只读,全局切换在标题栏) -->
                                                     <Border Grid.Column="0" CornerRadius="4" Padding="0,6"
-                                                            BorderBrush="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
+                                                            BorderBrush="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
                                                             BorderThickness="1"
-                                                            Background="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}">
+                                                            Background="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}">
                                                         <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="11"
-                                                                   Text="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
-                                                                   Foreground="{Binding SelectedPump.IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
-                                                    </Border>
-                                                    <!-- 手动/自动 切换 (单控件,双向切换) -->
-                                                    <Border Grid.Column="1" CornerRadius="4" Padding="0,6"
-                                                            BorderBrush="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
-                                                            BorderThickness="1"
-                                                            Background="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}">
-                                                        <Button Content="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
-                                                               Command="{Binding SelectedPump.ToggleManualModeCommand}"
-                                                               FontSize="11" Padding="0" Margin="0"
-                                                               Background="Transparent" BorderThickness="0"
-                                                               HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
-                                                               Foreground="{Binding SelectedPump.IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"/>
+                                                                   Text="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeText}}"
+                                                                   Foreground="{Binding IsCentralMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
                                                     </Border>
+                                                    <!-- 手动/自动 切换 -->
+                                                    <Button Grid.Column="1"
+                                                           Content="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
+                                                           Command="{Binding ToggleManualModeCommand}"
+                                                           FontSize="11" FontWeight="Bold"
+                                                           FontFamily="Consolas, monospace"
+                                                           Padding="0,6"
+                                                           Background="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}"
+                                                           BorderBrush="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                           BorderThickness="1"
+                                                           Foreground="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                           HorizontalAlignment="Stretch"/>
                                                 </Grid>
 
-                                                <!-- 启停切换 (单控件) -->
-                                                <Border CornerRadius="5" Padding="0,8" Margin="0,4,0,0"
-                                                        BorderBrush="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
-                                                        BorderThickness="1"
-                                                        Background="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}">
-                                                    <Button Content="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
-                                                           Command="{Binding SelectedPump.TogglePumpCommand}"
-                                                           FontSize="13" Padding="0" Margin="0"
-                                                           Background="Transparent" BorderThickness="0"
-                                                           HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
-                                                           Foreground="{Binding SelectedPump.IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"/>
-                                                </Border>
+                                                <!-- 启停切换 (工业实心按钮) -->
+                                                <Button Content="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
+                                                       Command="{Binding TogglePumpCommand}"
+                                                       FontSize="13" FontWeight="Bold"
+                                                       FontFamily="Consolas, monospace"
+                                                       Padding="0,8" Margin="0,4,0,0"
+                                                       Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}"
+                                                       BorderBrush="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
+                                                       BorderThickness="1"
+                                                       Foreground="White"
+                                                       HorizontalAlignment="Stretch"/>
                                             </StackPanel>
                                         </Border>
                                     </Border>
@@ -230,13 +230,13 @@
                             <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="2,0,2,8">
                                 <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
                                     <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="16" CornerRadius="1"/>
-                                    <TextBlock Text="泵组控制" FontFamily="Consolas, monospace" FontSize="12"
+                                    <TextBlock Text="{Binding PumpGroupControlText}" FontFamily="Consolas, monospace" FontSize="12"
                                                FontWeight="Bold" Foreground="{DynamicResource TextSecondaryBrush}"/>
                                     <!-- 运行台数概览 -->
                                     <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="8" Padding="8,2"
                                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
                                         <StackPanel Orientation="Horizontal" Spacing="5">
-                                            <TextBlock Text="运行" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                            <TextBlock Text="{Binding RunningCountText}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
                                             <TextBlock FontFamily="Consolas, monospace" FontSize="11" FontWeight="Bold"
                                                        Foreground="{DynamicResource SuccessBrush}">
                                                 <TextBlock.Text>
@@ -246,7 +246,7 @@
                                                     </MultiBinding>
                                                 </TextBlock.Text>
                                             </TextBlock>
-                                            <TextBlock Text="台" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                                            <TextBlock Text="{Binding UnitTaiText}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
                                         </StackPanel>
                                     </Border>
                                 </StackPanel>
@@ -255,28 +255,22 @@
                                 <TextBlock Grid.Column="1" Margin="10,0" VerticalAlignment="Center"
                                            IsVisible="{Binding HasSuggestedPump}"
                                            FontFamily="Microsoft YaHei" FontSize="11"
-                                           Foreground="{DynamicResource WarningBrush}">
-                                    <TextBlock.Text>
-                                        <MultiBinding StringFormat="建议启动 {0}(按编号顺序,优先启动)">
-                                            <Binding Path="SuggestedPumpName"/>
-                                        </MultiBinding>
-                                    </TextBlock.Text>
-                                </TextBlock>
+                                           Foreground="{DynamicResource WarningBrush}"
+                                           Text="{Binding SuggestStartText}"/>
 
                                 <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
                                     <!-- 全局中控/屏控切换 -->
-                                    <Border CornerRadius="4" Padding="6,2"
-                                            BorderBrush="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
-                                            BorderThickness="1"
-                                            Background="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}">
-                                        <Button Content="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleText}}"
-                                               Command="{Binding ToggleControlModeCommand}"
-                                               FontSize="10" Padding="0" Margin="0"
-                                               Background="Transparent" BorderThickness="0"
-                                               Foreground="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
-                                    </Border>
-                                    <Button Content="日台时清零" Command="{Binding DailyResetAllCommand}"
-                                            Padding="10,4" FontSize="10" Classes="btn-warning"/>
+                                    <Button Content="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleText}}"
+                                           Command="{Binding ToggleControlModeCommand}"
+                                           FontSize="10" FontWeight="Bold"
+                                           FontFamily="Consolas, monospace"
+                                           Padding="8,3"
+                                           Background="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToActiveBgBrush}}"
+                                           BorderBrush="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"
+                                           BorderThickness="1"
+                                           Foreground="{Binding IsCentralControlMode, Converter={x:Static conv:BoolConverters.ToModeToggleBrush}}"/>
+                                    <Button Content="{Binding DailyResetAllText}" Command="{Binding DailyResetAllCommand}"
+                                            Classes="btn-warning btn-compact"/>
                                 </StackPanel>
                             </Grid>
 
@@ -302,10 +296,10 @@
                                                                    Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
                                                         <Border IsVisible="{Binding IsSuggested}" Background="#FFB300"
                                                                 CornerRadius="3" Padding="4,1">
-                                                            <TextBlock Text="建议" FontSize="9" FontWeight="Bold"
+                                                            <TextBlock Text="{Binding SuggestText}" FontSize="9" FontWeight="Bold"
                                                                        Foreground="#1B1F27" VerticalAlignment="Center"/>
                                                         </Border>
-                                                        <TextBlock Text="故障!" FontSize="10" FontWeight="Bold"
+                                                        <TextBlock Text="{Binding FaultText}" FontSize="10" FontWeight="Bold"
                                                                    Foreground="{DynamicResource DangerBrush}" VerticalAlignment="Center"
                                                                    IsVisible="{Binding HasFault}"/>
                                                     </StackPanel>
@@ -317,7 +311,7 @@
                                                 <!-- 中:电流(带负荷条) / 日台时 -->
                                                 <StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
                                                     <Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
-                                                        <TextBlock Text="电流" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                        <TextBlock Text="{Binding CurrentLabelText}" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
                                                         <TextBlock Grid.Column="1" Text="{Binding Current, StringFormat='{}{0:F1} A'}"
                                                                    FontFamily="Consolas, monospace" FontSize="12" FontWeight="Bold"
                                                                    Foreground="{Binding IsOverload, Converter={x:Static conv:BoolConverters.ToOverloadBrush}}"/>
@@ -329,12 +323,12 @@
                                                                      Value="{Binding CurrentLoadRatio}"
                                                                      Background="#2AFFFFFF"
                                                                      Foreground="{Binding IsOverload, Converter={x:Static conv:BoolConverters.ToOverloadBrush}}"/>
-                                                        <TextBlock Grid.Column="1" Text="超载" FontSize="8" FontWeight="Bold"
+                                                        <TextBlock Grid.Column="1" Text="{Binding OverloadText}" FontSize="8" FontWeight="Bold"
                                                                    Foreground="{DynamicResource DangerBrush}" VerticalAlignment="Center"
                                                                    IsVisible="{Binding IsOverload}"/>
                                                     </Grid>
                                                     <Grid ColumnDefinitions="*,Auto">
-                                                        <TextBlock Text="日台时" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                        <TextBlock Text="{Binding DailyHoursLabelText}" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
                                                         <TextBlock Grid.Column="1" FontFamily="Consolas, monospace" FontSize="11"
                                                                    Foreground="{DynamicResource TextSecondaryBrush}">
                                                             <TextBlock.Text>
@@ -349,28 +343,28 @@
 
                                                 <!-- 右:模式切换 + 启停按钮 -->
                                                 <StackPanel Grid.Column="2" Orientation="Vertical" Spacing="4" VerticalAlignment="Center">
-                                                    <!-- 第一行:手动/自动 切换(单控件,中控/屏控已移至全局标题栏) -->
-                                                    <Border CornerRadius="4" Padding="6,2"
-                                                            BorderBrush="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
-                                                            BorderThickness="1"
-                                                            Background="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}">
-                                                        <Button Content="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
-                                                               Command="{Binding ToggleManualModeCommand}"
-                                                               FontSize="9" Padding="0" Margin="0"
-                                                               Background="Transparent" BorderThickness="0"
-                                                               Foreground="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"/>
-                                                    </Border>
-                                                    <!-- 第二行:启停切换 (单控件,IsEnabled 由 CanExecute 驱动) -->
-                                                    <Border CornerRadius="4" Padding="9,4"
-                                                            BorderBrush="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
-                                                            BorderThickness="1"
-                                                            Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}">
-                                                        <Button Content="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
-                                                               Command="{Binding TogglePumpCommand}"
-                                                               FontSize="10" Padding="0" Margin="0"
-                                                               Background="Transparent" BorderThickness="0"
-                                                               Foreground="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"/>
-                                                    </Border>
+                                                    <!-- 第一行:手动/自动 切换 -->
+                                                    <Button Content="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleText}}"
+                                                           Command="{Binding ToggleManualModeCommand}"
+                                                           FontSize="9" FontWeight="Bold"
+                                                           FontFamily="Consolas, monospace"
+                                                           Padding="8,3"
+                                                           Background="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBgBrush}}"
+                                                           BorderBrush="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                           BorderThickness="1"
+                                                           Foreground="{Binding IsAutoMode, Converter={x:Static conv:BoolConverters.ToManualToggleBrush}}"
+                                                           HorizontalAlignment="Stretch"/>
+                                                    <!-- 第二行:启停切换 (工业实心按钮) -->
+                                                    <Button Content="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleText}}"
+                                                           Command="{Binding TogglePumpCommand}"
+                                                           FontSize="10" FontWeight="Bold"
+                                                           FontFamily="Consolas, monospace"
+                                                           Padding="12,5"
+                                                           Background="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBgBrush}}"
+                                                           BorderBrush="{Binding IsRunning, Converter={x:Static conv:BoolConverters.ToPumpToggleBrush}}"
+                                                           BorderThickness="1"
+                                                           Foreground="White"
+                                                           HorizontalAlignment="Stretch"/>
                                                 </StackPanel>
                                             </Grid>
                                         </Border>
@@ -390,7 +384,7 @@
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="液位监控" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding TankMonitorText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
                             <ItemsControl ItemsSource="{Binding Tanks}">
                                 <ItemsControl.ItemTemplate>
@@ -406,7 +400,7 @@
                                                     <TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Bold"
                                                                Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
                                                     <!-- 液位工况档位徽章 -->
-                                                    <Border HorizontalAlignment="Left" Margin="8,0"
+                                                    <Border Grid.Column="1" HorizontalAlignment="Left" Margin="8,0"
                                                             CornerRadius="3" Padding="6,2"
                                                             Background="{Binding CurrentStage, Converter={x:Static conv:TankStageConverters.ToBrush}}">
                                                         <TextBlock Text="{Binding CurrentStage, Converter={x:Static conv:TankStageConverters.ToText}}"
@@ -426,31 +420,35 @@
                                                         LineBrush="{DynamicResource SuccessBrush}"
                                                         FillBrush="{StaticResource LocalSparkFillBrush}"/>
                                                 </Border>
-                                                <!-- 可编辑阈值(动态数量,跟随项目泵数)+ 同步按钮 -->
-                                                <ItemsControl ItemsSource="{Binding ThresholdRows}">
+                                                <!-- 可编辑阈值(动态数量,跟随项目泵数 + 压力)+ 同步按钮 -->
+                                                <ItemsControl ItemsSource="{Binding ThresholdRows}" Grid.IsSharedSizeScope="True">
                                                     <ItemsControl.ItemTemplate>
                                                         <DataTemplate>
-                                                            <Grid ColumnDefinitions="*,60,Auto" Margin="0,2"
-                                                                  IsVisible="{Binding IsVisible}">
+                                                            <Grid Margin="0,2" IsVisible="{Binding IsVisible}">
+                                                                <Grid.ColumnDefinitions>
+                                                                    <ColumnDefinition Width="Auto" SharedSizeGroup="label"/>
+                                                                    <ColumnDefinition Width="*"/>
+                                                                    <ColumnDefinition Width="Auto" SharedSizeGroup="unit"/>
+                                                                </Grid.ColumnDefinitions>
                                                                 <TextBlock Text="{Binding Label}" FontSize="10"
                                                                            Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
                                                                 <TextBox Grid.Column="1" Text="{Binding EditValue, StringFormat='{}{0:F2}'}"
-                                                                         IsEnabled="{Binding IsEditable}"
+                                                                         IsEnabled="{Binding IsEditable}" Width="65"
+                                                                         HorizontalAlignment="Right"
                                                                          FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
                                                                          Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                                <TextBlock Grid.Column="2" Text="m" FontSize="9"
+                                                                <TextBlock Grid.Column="2" Text="{Binding Unit}" FontSize="9"
                                                                            Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
                                                             </Grid>
                                                         </DataTemplate>
                                                     </ItemsControl.ItemTemplate>
                                                 </ItemsControl>
                                                 <!-- 同步到 PLC 按钮 -->
-                                                <Button Content="同步到PLC" Command="{Binding SyncThresholdsToPlcCommand}"
+                                                <Button Content="{Binding SyncToPlcText}" Command="{Binding SyncThresholdsToPlcCommand}"
                                                         IsVisible="{Binding HasWritableAddresses}"
                                                         IsEnabled="{Binding !IsSyncing}"
                                                         HorizontalAlignment="Right" Margin="0,6,0,0"
-                                                        FontSize="10" Padding="10,4" Height="24"
-                                                        Background="{DynamicResource InfoBrush}" Foreground="White"/>
+                                                        Classes="btn-primary btn-compact"/>
                                                 <TextBlock Text="{Binding SyncStatusText}" FontSize="9"
                                                            Foreground="{DynamicResource TextTertiaryBrush}" Margin="0,3,0,0"
                                                            TextTrimming="CharacterEllipsis" IsVisible="{Binding SyncStatusText, Converter={x:Static conv:BoolConverters.IsNotEmpty}}"/>
@@ -468,7 +466,7 @@
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="仪表监控" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding GaugeMonitorText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
                             <ItemsControl ItemsSource="{Binding Gauges}">
                                 <ItemsControl.ItemTemplate>
@@ -480,30 +478,6 @@
                                                     Value="{Binding Value}" Title="{Binding Name}" Unit="{Binding Unit}"
                                                     MinValue="{Binding MinValue}" MaxValue="{Binding MaxValue}"
                                                     ValueColor="{DynamicResource SuccessBrush}"/>
-                                                <!-- 可编辑阈值 -->
-                                                <Grid ColumnDefinitions="*,50,Auto" Margin="0,2" IsVisible="{Binding HasWritableAddresses}">
-                                                    <TextBlock Text="高压" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditHighAlarmSet, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="10" Padding="3,1" Height="20"
-                                                               Foreground="{DynamicResource DangerBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="{Binding Unit}" FontSize="8" Foreground="{DynamicResource TextTertiaryBrush}" Margin="3,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Grid ColumnDefinitions="*,50,Auto" Margin="0,2" IsVisible="{Binding HasWritableAddresses}">
-                                                    <TextBlock Text="低压" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditLowAlarmSet, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="10" Padding="3,1" Height="20"
-                                                               Foreground="{DynamicResource WarningBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="{Binding Unit}" FontSize="8" Foreground="{DynamicResource TextTertiaryBrush}" Margin="3,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Button Content="同步" Command="{Binding SyncThresholdsToPlcCommand}"
-                                                        IsVisible="{Binding HasWritableAddresses}"
-                                                        IsEnabled="{Binding !IsSyncing}"
-                                                        HorizontalAlignment="Right" Margin="0,4,0,0"
-                                                        FontSize="9" Padding="8,3" Height="20"
-                                                        Background="{DynamicResource InfoBrush}" Foreground="White"/>
-                                                <TextBlock Text="{Binding SyncStatusText}" FontSize="8"
-                                                           Foreground="{DynamicResource TextTertiaryBrush}" Margin="0,2,0,0"
-                                                           TextTrimming="CharacterEllipsis" IsVisible="{Binding SyncStatusText, Converter={x:Static conv:BoolConverters.IsNotEmpty}}"/>
                                             </StackPanel>
                                         </Border>
                                     </DataTemplate>
@@ -523,7 +497,7 @@
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="累计数据" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding CumulativeDataText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
                             <ItemsControl ItemsSource="{Binding Sensors}">
                                 <ItemsControl.ItemTemplate>
@@ -547,24 +521,24 @@
                             <StackPanel>
                                 <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
                                     <Border Background="{DynamicResource DangerBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                    <TextBlock Text="故障状态" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
+                                    <TextBlock Text="{Binding FaultStatusText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource DangerBrush}"/>
                                 </StackPanel>
 
                                 <!-- 最近报警(实时,最多 6 条,新报警置顶) -->
                                 <Border Background="{DynamicResource PanelBgBrush}" CornerRadius="4" Padding="8,6" Margin="0,0,0,8">
                                     <StackPanel>
                                         <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,0,0,4">
-                                            <TextBlock Text="最近报警" FontFamily="Consolas, monospace" FontSize="10" FontWeight="Bold"
+                                            <TextBlock Text="{Binding RecentAlarmsTitleText}" FontFamily="Consolas, monospace" FontSize="10" FontWeight="Bold"
                                                        Foreground="{DynamicResource DangerBrush}"/>
-                                            <Button Grid.Column="1" Content="复位" Command="{Binding ResetAllAlarmsCommand}"
+                                            <Button Grid.Column="1" Content="{Binding ResetAlarmText}" Command="{Binding ResetAllAlarmsCommand}"
                                                     FontSize="9" Padding="6,2" Height="18" Margin="0,0,4,0"
                                                     IsVisible="{Binding HasActiveAlarm}"
-                                                    Background="{DynamicResource WarningBrush}" Foreground="White"/>
+                                                    Classes="btn-warning btn-compact"/>
                                             <TextBlock Grid.Column="2" Text="{Binding RecentAlarms.Count}"
                                                        FontFamily="Consolas,monospace" FontSize="10"
                                                        Foreground="{DynamicResource TextDisabledBrush}"/>
                                         </Grid>
-                                        <TextBlock Text="暂无报警" FontSize="10" Margin="0,2"
+                                        <TextBlock Text="{Binding NoAlarmsText}" FontSize="10" Margin="0,2"
                                                    Foreground="{DynamicResource TextDisabledBrush}"
                                                    IsVisible="{Binding RecentAlarms.Count, Converter={x:Static conv:BoolConverters.ToIsEmpty}}"/>
                                         <ItemsControl ItemsSource="{Binding RecentAlarms}"
@@ -638,9 +612,9 @@
                                                     <TextBlock Text="{Binding Address}" FontSize="9" FontFamily="Consolas, monospace"
                                                                Foreground="{DynamicResource TextDisabledBrush}"/>
                                                 </StackPanel>
-                                                <Button Grid.Column="1" Content="执行" Command="{Binding PulseCommand}"
+                                                <Button Grid.Column="1" Content="{Binding ExecuteText}" Command="{Binding PulseCommand}"
                                                         IsEnabled="{Binding !IsPulsing}" VerticalAlignment="Center"
-                                                        Padding="8,2" FontSize="9" Classes="btn-warning"/>
+                                                        Classes="btn-warning btn-compact"/>
                                             </Grid>
                                         </DataTemplate>
                                     </ItemsControl.ItemTemplate>

+ 10 - 0
src/YZWater.Avalonia/Views/ProjectView.axaml.cs

@@ -1,5 +1,6 @@
 using System.ComponentModel;
 using Avalonia.Controls;
+using Avalonia.Interactivity;
 using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
@@ -43,4 +44,13 @@ public partial class ProjectView : ThemedUserControl
             RightPanel.IsVisible = false;
         }
     }
+
+    /// <summary>关闭泵操作浮层</summary>
+    private void OnClosePumpPopupClick(object? sender, RoutedEventArgs e)
+    {
+        if (DataContext is Core.ViewModels.ProjectViewModel vm)
+        {
+            vm.ClosePumpPopupCommand.Execute(null);
+        }
+    }
 }

+ 15 - 19
src/YZWater.Avalonia/Views/ViewAView.axaml

@@ -158,13 +158,13 @@
                                 <TextBlock Text="{Binding DeviceStatusText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
                             <StackPanel Spacing="3">
-                                <controls:StatusCard Title="1#提升泵" Status="{Binding Pump1Status}" Icon="P1" IsActive="{Binding Pump1Running}"/>
-                                <controls:StatusCard Title="2#提升泵" Status="{Binding Pump2Status}" Icon="P2" IsActive="{Binding Pump2Running}"/>
-                                <controls:StatusCard Title="3#提升泵" Status="{Binding Pump3Status}" Icon="P3" IsActive="{Binding Pump3Running}"/>
-                                <controls:StatusCard Title="4#提升泵" Status="{Binding Pump4Status}" Icon="P4" IsActive="{Binding Pump4Running}"/>
-                                <controls:StatusCard Title="5#提升泵" Status="{Binding Pump5Status}" Icon="P5" IsActive="{Binding Pump5Running}"/>
-                                <controls:StatusCard Title="风机1" Status="{Binding Fan1Status}" Icon="F1" IsActive="{Binding Fan1Running}"/>
-                                <controls:StatusCard Title="风机2" Status="{Binding Fan2Status}" Icon="F2" IsActive="{Binding Fan2Running}"/>
+                                <controls:StatusCard Title="{Binding Pump1Text}" Status="{Binding Pump1Status}" Icon="P1" IsActive="{Binding Pump1Running}"/>
+                                <controls:StatusCard Title="{Binding Pump2Text}" Status="{Binding Pump2Status}" Icon="P2" IsActive="{Binding Pump2Running}"/>
+                                <controls:StatusCard Title="{Binding Pump3Text}" Status="{Binding Pump3Status}" Icon="P3" IsActive="{Binding Pump3Running}"/>
+                                <controls:StatusCard Title="{Binding Pump4Text}" Status="{Binding Pump4Status}" Icon="P4" IsActive="{Binding Pump4Running}"/>
+                                <controls:StatusCard Title="{Binding Pump5Text}" Status="{Binding Pump5Status}" Icon="P5" IsActive="{Binding Pump5Running}"/>
+                                <controls:StatusCard Title="{Binding Fan1Text}" Status="{Binding Fan1Status}" Icon="F1" IsActive="{Binding Fan1Running}"/>
+                                <controls:StatusCard Title="{Binding Fan2Text}" Status="{Binding Fan2Status}" Icon="F2" IsActive="{Binding Fan2Running}"/>
                             </StackPanel>
                         </StackPanel>
                     </Border>
@@ -190,18 +190,14 @@
                                 <TextBlock Text="{Binding QuickOpsText}" FontFamily="Consolas, monospace" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
                             <WrapPanel HorizontalAlignment="Center">
-                                <Button Content="{Binding StartAllText}" Command="{Binding StartAllCommand}" Margin="3" Padding="10,5"
-                                        FontFamily="Consolas, monospace" FontSize="10"
-                                        Background="{DynamicResource BorderBrush}" Foreground="{DynamicResource SuccessBrush}" BorderBrush="{DynamicResource SuccessBrush}"/>
-                                <Button Content="{Binding StopAllText}" Command="{Binding StopAllCommand}" Margin="3" Padding="10,5"
-                                        FontFamily="Consolas, monospace" FontSize="10"
-                                        Background="{DynamicResource BorderBrush}" Foreground="{DynamicResource DangerBrush}" BorderBrush="{DynamicResource DangerBrush}"/>
-                                <Button Content="{Binding AckAlarmText}" Command="{Binding AckAlarmCommand}" Margin="3" Padding="10,5"
-                                        FontFamily="Consolas, monospace" FontSize="10"
-                                        Background="{DynamicResource BorderBrush}" Foreground="{DynamicResource WarningBrush}" BorderBrush="{DynamicResource WarningBrush}"/>
-                                <Button Content="{Binding ExportText}" Margin="3" Padding="10,5"
-                                        FontFamily="Consolas, monospace" FontSize="10"
-                                        Background="{DynamicResource BorderBrush}" Foreground="{DynamicResource InfoBrush}" BorderBrush="{DynamicResource InfoBrush}"/>
+                                <Button Content="{Binding StartAllText}" Command="{Binding StartAllCommand}" Margin="3"
+                                        Classes="btn-success btn-compact"/>
+                                <Button Content="{Binding StopAllText}" Command="{Binding StopAllCommand}" Margin="3"
+                                        Classes="btn-danger btn-compact"/>
+                                <Button Content="{Binding AckAlarmText}" Command="{Binding AckAlarmCommand}" Margin="3"
+                                        Classes="btn-warning btn-compact"/>
+                                <Button Content="{Binding ExportText}" Margin="3"
+                                        Classes="btn-info btn-compact"/>
                             </WrapPanel>
                         </StackPanel>
                     </Border>

+ 4 - 29
src/YZWater.Avalonia/Views/ViewBView.axaml

@@ -25,8 +25,8 @@
                 <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
 
                     <!-- PLC 连接设置 -->
-                    <Border Grid.Row="0" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,4,4">
+                    <Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
                         <StackPanel>
                             <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
@@ -53,31 +53,6 @@
                         </StackPanel>
                     </Border>
 
-                    <!-- 报警阈值设置 -->
-                    <Border Grid.Row="0" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,0,0,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,12">
-                                <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding AlarmThresholdsText}" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
-                                           Foreground="{DynamicResource TextSecondaryBrush}"/>
-                            </StackPanel>
-                            <Grid ColumnDefinitions="120,*,60" RowDefinitions="Auto,Auto,Auto" Margin="4,0">
-                                <TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding LevelHighText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding LevelHighAlarm}" Minimum="0" Maximum="100" FormatString="F1" Margin="0,4"/>
-                                <TextBlock Grid.Row="0" Grid.Column="2" Text="%" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-
-                                <TextBlock Grid.Row="1" Grid.Column="0" Text="{Binding LevelLowText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding LevelLowAlarm}" Minimum="0" Maximum="100" FormatString="F1" Margin="0,4"/>
-                                <TextBlock Grid.Row="1" Grid.Column="2" Text="%" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-
-                                <TextBlock Grid.Row="2" Grid.Column="0" Text="{Binding FlowHighText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                                <NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding FlowHighAlarm}" Minimum="0" Maximum="1000" FormatString="F1" Margin="0,4"/>
-                                <TextBlock Grid.Row="2" Grid.Column="2" Text="m³/h" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                            </Grid>
-                        </StackPanel>
-                    </Border>
-
                     <!-- 系统设置 -->
                     <Border Grid.Row="1" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
@@ -95,8 +70,8 @@
                     </Border>
 
                     <!-- 操作按钮 -->
-                    <Border Grid.Row="2" Grid.Column="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,4,4,4">
+                    <Border Grid.Row="1" Grid.Column="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="4,4,0,4">
                         <StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
                             <TextBlock Text="ACTIONS" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
                                        Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"/>

+ 43 - 53
src/YZWater.Avalonia/Views/ViewCView.axaml

@@ -44,70 +44,60 @@
                             <!-- 预设时间范围 -->
                             <StackPanel Orientation="Horizontal" Spacing="6">
                                 <TextBlock Text="{Binding QuickRangeText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
-                                <Button Content="1h" Command="{Binding SetTimeRangeCommand}" CommandParameter="1h" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
-                                <Button Content="4h" Command="{Binding SetTimeRangeCommand}" CommandParameter="4h" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
-                                <Button Content="8h" Command="{Binding SetTimeRangeCommand}" CommandParameter="8h" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
-                                <Button Content="24h" Command="{Binding SetTimeRangeCommand}" CommandParameter="24h" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
-                                <Button Content="7d" Command="{Binding SetTimeRangeCommand}" CommandParameter="7d" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
-                                <Button Content="30d" Command="{Binding SetTimeRangeCommand}" CommandParameter="30d" Padding="8,2" FontSize="11" Classes="btn-toggle"/>
+                                <Button Content="24h" Command="{Binding SetTimeRangeCommand}" CommandParameter="24h" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
+                                <Button Content="7d" Command="{Binding SetTimeRangeCommand}" CommandParameter="7d" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
+                                <Button Content="30d" Command="{Binding SetTimeRangeCommand}" CommandParameter="30d" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
                             </StackPanel>
                         </StackPanel>
                     </Border>
 
-                    <!-- 流量趋势图 -->
+                    <!-- 动态图表组(按项目统计配置生成) -->
+                    <ItemsControl ItemsSource="{Binding ChartGroups}">
+                        <ItemsControl.ItemTemplate>
+                            <DataTemplate x:DataType="vm:ChartGroupModel">
+                                <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
+                                    <StackPanel>
+                                        <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
+                                            <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="14" CornerRadius="1"/>
+                                            <TextBlock Text="{Binding Title}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                        </StackPanel>
+                                        <lvc:CartesianChart Series="{Binding Series}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="350"/>
+                                    </StackPanel>
+                                </Border>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
+
+                    <!-- 统计报表(日/月/年) -->
                     <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
-                                <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding FlowTrendText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
-                            </StackPanel>
-                            <lvc:CartesianChart Series="{Binding InflowSeries}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="180"/>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,12,0,8">
+                        <StackPanel Spacing="8">
+                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding OutflowTrendText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding StatisticsText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
-                            <lvc:CartesianChart Series="{Binding OutflowSeries}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="180"/>
-                        </StackPanel>
-                    </Border>
-
-                    <!-- 液位趋势图 -->
-                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
-                                <Border Background="{DynamicResource WarningBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding TankLevelTrendText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                            <!-- 模式切换 + 日期选择 -->
+                            <StackPanel Orientation="Horizontal" Spacing="8">
+                                <Button Content="{Binding DayReportText}" Command="{Binding SetReportModeCommand}" CommandParameter="day" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
+                                <Button Content="{Binding MonthReportText}" Command="{Binding SetReportModeCommand}" CommandParameter="month" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
+                                <Button Content="{Binding YearReportText}" Command="{Binding SetReportModeCommand}" CommandParameter="year" Padding="10,3" FontSize="11" Classes="btn-toggle"/>
+                                <DatePicker SelectedDate="{Binding ReportDate}" Width="150"/>
+                                <Button Content="{Binding QueryText}" Command="{Binding LoadStatisticsCommand}" Classes="btn-info"/>
                             </StackPanel>
-                            <lvc:CartesianChart Series="{Binding TankLevelSeries}" XAxes="{Binding XAxes}" YAxes="{Binding TankYAxes}" Height="200"/>
+                            <!-- 统计表格 -->
+                            <TableView x:Name="StatisticsTable" ItemsSource="{Binding Summaries}" Height="180" Background="{DynamicResource SurfaceBgBrush}">
+                                <TableView.Columns>
+                                    <TableViewColumn Header="统计项" Binding="{Binding DisplayName}" Width="120"/>
+                                    <TableViewColumn Header="均值" Binding="{Binding AvgValue, StringFormat='{}{0:F1}'}" Width="80"/>
+                                    <TableViewColumn Header="最大" Binding="{Binding MaxValue, StringFormat='{}{0:F1}'}" Width="80"/>
+                                    <TableViewColumn Header="最小" Binding="{Binding MinValue, StringFormat='{}{0:F1}'}" Width="80"/>
+                                    <TableViewColumn Header="总量" Binding="{Binding TotalValue, StringFormat='{}{0:F1}'}" Width="80"/>
+                                    <TableViewColumn Header="单位" Binding="{Binding Unit}" Width="60"/>
+                                </TableView.Columns>
+                            </TableView>
                         </StackPanel>
                     </Border>
-
-                    <!-- 泵频率趋势图 -->
-                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                        <StackPanel>
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">
-                                <Border Background="{DynamicResource DangerBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding PumpFreqTrendText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
-                            </StackPanel>
-                            <lvc:CartesianChart Series="{Binding PumpFreqSeries}" XAxes="{Binding XAxes}" YAxes="{Binding PumpYAxes}" Height="200"/>
-                        </StackPanel>
-                    </Border>
-
-                    <!-- 数据表格 -->
-                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="8" Margin="0,0,0,4">
-                        <TableView ItemsSource="{Binding FlowRecords}" Height="150">
-                            <TableView.Columns>
-                                <TableViewColumn Header="Time" Binding="{Binding RecordTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
-                                <TableViewColumn Header="In (m³/h)" Binding="{Binding InflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
-                                <TableViewColumn Header="Out (m³/h)" Binding="{Binding OutflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
-                                <TableViewColumn Header="Total In (m³)" Binding="{Binding TotalInflow, StringFormat='{}{0:F1}'}" Width="120"/>
-                                <TableViewColumn Header="Total Out (m³)" Binding="{Binding TotalOutflow, StringFormat='{}{0:F1}'}" Width="120"/>
-                            </TableView.Columns>
-                        </TableView>
-                    </Border>
                 </StackPanel>
             </ScrollViewer>
 

+ 24 - 0
src/YZWater.Avalonia/Views/ViewCView.axaml.cs

@@ -1,4 +1,7 @@
+using Avalonia.Controls;
+using Avalonia.Threading;
 using YZWater.Avalonia.Controls;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +10,26 @@ public partial class ViewCView : ThemedUserControl
     public ViewCView()
     {
         InitializeComponent();
+        LanguageService.Instance.LanguageChanged += OnLanguageChanged;
+        UpdateColumnHeaders();
+    }
+
+    private void OnLanguageChanged()
+    {
+        Dispatcher.UIThread.Post(() => UpdateColumnHeaders());
+    }
+
+    private void UpdateColumnHeaders()
+    {
+        var lang = LanguageService.Instance;
+        if (this.FindControl<TableView>("StatisticsTable") is { } table)
+        {
+            table.Columns[0].Header = lang.Get("ItemHeader");
+            table.Columns[1].Header = lang.Get("AvgHeader");
+            table.Columns[2].Header = lang.Get("MaxHeader");
+            table.Columns[3].Header = lang.Get("MinHeader");
+            table.Columns[4].Header = lang.Get("TotalHeaderText");
+            table.Columns[5].Header = lang.Get("UnitHeader");
+        }
     }
 }

+ 24 - 9
src/YZWater.Avalonia/Views/ViewDView.axaml

@@ -43,6 +43,8 @@
                         <Button Content="{Binding ExportText}" Command="{Binding ExportAlarmsCommand}" Classes="btn-success"/>
                         <Button Content="{Binding AckAllText}" Command="{Binding ConfirmAllAlarmsCommand}" Classes="btn-warning"/>
                         <Button Content="{Binding ResetAllText}" Command="{Binding ResetAllAlarmsCommand}" Classes="btn-danger"/>
+                        <Button Content="{Binding ShelveText}" Command="{Binding ShelveSelectedAlarmCommand}" Classes="btn-warning"/>
+                        <Button Content="{Binding UnshelveText}" Command="{Binding UnshelveSelectedAlarmCommand}" Classes="btn-info"/>
                         <Button Content="{Binding PurgeText}" Command="{Binding ClearHistoryCommand}" Classes="btn-danger"/>
                     </WrapPanel>
                 </Border>
@@ -50,15 +52,20 @@
                 <!-- 报警列表 -->
                 <Border Grid.Row="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                         BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="8" Margin="0,0,0,4">
-                    <TableView ItemsSource="{Binding AlarmRecords}">
+                    <TableView x:Name="AlarmTable" ItemsSource="{Binding AlarmRecords}"
+                               SelectedItem="{Binding SelectedRecord}"
+                               Background="{DynamicResource SurfaceBgBrush}">
                         <TableView.Columns>
-                            <TableViewColumn Header="TIME" Binding="{Binding AlarmTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
-                            <TableViewColumn Header="TYPE" Binding="{Binding AlarmType}" Width="100"/>
-                            <TableViewColumn Header="MESSAGE" Binding="{Binding AlarmMessage}" Width="250"/>
-                            <TableViewColumn Header="VALUE" Binding="{Binding AlarmValue, StringFormat='{}{0:F1}'}" Width="80"/>
-                            <TableViewColumn Header="LEVEL" Binding="{Binding AlarmLevel}" Width="60"/>
-                            <TableViewColumn Header="ACK TIME" Binding="{Binding ConfirmedTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
-                            <TableViewColumn Header="ACK BY" Binding="{Binding ConfirmedBy}" Width="80"/>
+                            <TableViewColumn Header="时间" Binding="{Binding AlarmTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
+                            <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 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"/>
+                            <TableViewColumn Header="持续时间" Binding="{Binding DurationText}" Width="100"/>
+                            <TableViewColumn Header="标记" Binding="{Binding FlagDisplay}" Width="70"/>
                         </TableView.Columns>
                     </TableView>
                 </Border>
@@ -67,7 +74,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">
+                    <Grid ColumnDefinitions="80,*" RowDefinitions="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}"/>
@@ -83,6 +90,14 @@
                         <TextBlock Grid.Row="3" Grid.Column="0" Text="{Binding LevelLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
                         <TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding DetailLevel}"
                                    FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource TextPrimaryBrush}"/>
+
+                        <TextBlock Grid.Row="4" Grid.Column="0" Text="{Binding RecoverTimeLabel}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}"/>
+                        <TextBlock Grid.Row="4" Grid.Column="1" Text="{Binding DetailRecoverTime}"
+                                   FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource SuccessBrush}"/>
+
+                        <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}"/>
                     </Grid>
                 </Border>
             </Grid>

+ 28 - 0
src/YZWater.Avalonia/Views/ViewDView.axaml.cs

@@ -1,4 +1,6 @@
+using Avalonia.Controls;
 using YZWater.Avalonia.Controls;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,31 @@ public partial class ViewDView : ThemedUserControl
     public ViewDView()
     {
         InitializeComponent();
+        LanguageService.Instance.LanguageChanged += OnLanguageChanged;
+        UpdateColumnHeaders();
+    }
+
+    private void OnLanguageChanged()
+    {
+        // 在 UI 线程更新列头
+        global::Avalonia.Threading.Dispatcher.UIThread.Post(() => UpdateColumnHeaders());
+    }
+
+    private void UpdateColumnHeaders()
+    {
+        var lang = LanguageService.Instance;
+        if (this.FindControl<TableView>("AlarmTable") is { } table)
+        {
+            table.Columns[0].Header = lang.Get("TimeHeader");
+            table.Columns[1].Header = lang.Get("TypeHeader");
+            table.Columns[2].Header = lang.Get("MessageHeader");
+            table.Columns[3].Header = lang.Get("ValueHeader");
+            table.Columns[4].Header = lang.Get("LevelHeader");
+            table.Columns[5].Header = lang.Get("AckTimeHeader");
+            table.Columns[6].Header = lang.Get("AckByHeader");
+            table.Columns[7].Header = lang.Get("RecoverTimeHeader");
+            table.Columns[8].Header = lang.Get("DurationHeader");
+            table.Columns[9].Header = lang.Get("FlagHeader");
+        }
     }
 }

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

@@ -40,20 +40,6 @@
                         </StackPanel>
                     </Border>
 
-                    <!-- 系统描述 -->
-                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="16">
-                        <StackPanel Spacing="8">
-                            <StackPanel Orientation="Horizontal" Spacing="6">
-                                <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding SystemInfoText}" FontFamily="{DynamicResource MonoFont}" FontSize="12" FontWeight="Bold"
-                                           Foreground="{DynamicResource TextSecondaryBrush}"/>
-                            </StackPanel>
-                            <TextBlock Text="{Binding Description}" TextWrapping="Wrap"
-                                       Foreground="{DynamicResource TextSecondaryBrush}" FontSize="13"/>
-                        </StackPanel>
-                    </Border>
-
                     <!-- 公司信息 -->
                     <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
                             BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="16">
@@ -78,7 +64,8 @@
 
                     <!-- 操作 -->
                     <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
-                        <Button Content="{Binding VisitWebsiteText}" Command="{Binding OpenWebsiteCommand}" Classes="btn-info"/>
+                        <Button Content="{Binding VisitWebsiteText}" Command="{Binding OpenWebsiteCommand}" Classes="btn-info"
+                                IsVisible="{Binding ShowVisitWebsiteButton}"/>
                         <Button Content="{Binding CheckUpdateText}" Command="{Binding CheckUpdateCommand}" Classes="btn-success"/>
                     </StackPanel>
 

+ 20 - 12
src/YZWater.Avalonia/Views/ViewFView.axaml

@@ -1,8 +1,10 @@
-<UserControl xmlns="https://github.com/avaloniaui"
+<controls:ThemedUserControl xmlns="https://github.com/avaloniaui"
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:controls="using:YZWater.Avalonia.Controls"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewFView"
              x:DataType="vm:ViewFViewModel">
+    <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
     <DockPanel Margin="16">
         <!-- 工作流状态区 -->
         <Border DockPanel.Dock="Top" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="4" Padding="12" Margin="0,0,0,12">
@@ -10,10 +12,10 @@
                 <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
                     <TextBlock Text="{Binding WorkflowsText}" FontSize="14" FontWeight="Bold"/>
                     <Border Background="{DynamicResource SuccessBrush}" CornerRadius="10" Padding="8,2" IsVisible="{Binding IsWorkflowEngineRunning}">
-                        <TextBlock Text="引擎运行中" FontSize="10" Foreground="White"/>
+                        <TextBlock Text="{Binding WorkflowEngineStatusText}" FontSize="10" Foreground="White"/>
                     </Border>
                     <Border Background="{DynamicResource DangerBrush}" CornerRadius="10" Padding="8,2" IsVisible="{Binding !IsWorkflowEngineRunning}">
-                        <TextBlock Text="引擎已停止" FontSize="10" Foreground="White"/>
+                        <TextBlock Text="{Binding WorkflowEngineStatusText}" FontSize="10" Foreground="White"/>
                     </Border>
                 </StackPanel>
                 <ItemsControl ItemsSource="{Binding Workflows}">
@@ -30,7 +32,12 @@
                                     <TextBlock Text="{Binding Description}" FontSize="10" Foreground="{DynamicResource TextSecondaryBrush}" TextTrimming="CharacterEllipsis"/>
                                     <StackPanel Orientation="Horizontal" Spacing="8">
                                         <TextBlock FontSize="10">
-                                            <Run Text="步骤数: "/><Run Text="{Binding StepCount}"/>
+                                            <TextBlock.Text>
+                                                <MultiBinding StringFormat="{}{0}{1}">
+                                                    <Binding Path="$parent[ItemsControl].DataContext.StepCountLabelText"/>
+                                                    <Binding Path="StepCount"/>
+                                                </MultiBinding>
+                                            </TextBlock.Text>
                                         </TextBlock>
                                         <Border Background="{DynamicResource InfoBrush}" CornerRadius="8" Padding="6,1">
                                             <TextBlock Text="{Binding Status}" FontSize="9" Foreground="White"/>
@@ -63,15 +70,16 @@
         </StackPanel>
 
         <!-- 审计日志表格 -->
-        <TableView ItemsSource="{Binding AuditLogs}">
+        <TableView x:Name="AuditTable" ItemsSource="{Binding AuditLogs}" Background="{DynamicResource SurfaceBgBrush}">
             <TableView.Columns>
-                <TableViewColumn Header="{Binding TimeHeaderText}" Binding="{Binding Timestamp, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
-                <TableViewColumn Header="{Binding UserHeaderText}" Binding="{Binding UserName}" Width="100"/>
-                <TableViewColumn Header="{Binding ActionHeaderText}" Binding="{Binding Action}" Width="100"/>
-                <TableViewColumn Header="{Binding DetailHeaderText}" Binding="{Binding Detail}" Width="*"/>
-                <TableViewColumn Header="{Binding TargetHeaderText}" Binding="{Binding Target}" Width="120"/>
-                <TableViewColumn Header="{Binding ResultHeaderText}" Binding="{Binding Result}" Width="80"/>
+                <TableViewColumn Header="时间" Binding="{Binding Timestamp, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
+                <TableViewColumn Header="用户" Binding="{Binding UserName}" Width="100"/>
+                <TableViewColumn Header="操作" Binding="{Binding Action}" Width="100"/>
+                <TableViewColumn Header="详情" Binding="{Binding Detail}" Width="*"/>
+                <TableViewColumn Header="目标" Binding="{Binding Target}" Width="120"/>
+                <TableViewColumn Header="结果" Binding="{Binding Result}" Width="80"/>
             </TableView.Columns>
         </TableView>
     </DockPanel>
-</UserControl>
+    </Border>
+</controls:ThemedUserControl>

+ 23 - 0
src/YZWater.Avalonia/Views/ViewFView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.Threading;
+using YZWater.Core.Services;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,26 @@ public partial class ViewFView : UserControl
     public ViewFView()
     {
         InitializeComponent();
+        LanguageService.Instance.LanguageChanged += OnLanguageChanged;
+        UpdateColumnHeaders();
+    }
+
+    private void OnLanguageChanged()
+    {
+        Dispatcher.UIThread.Post(() => UpdateColumnHeaders());
+    }
+
+    private void UpdateColumnHeaders()
+    {
+        var lang = LanguageService.Instance;
+        if (this.FindControl<TableView>("AuditTable") is { } table)
+        {
+            table.Columns[0].Header = lang.Get("TimeHeader");
+            table.Columns[1].Header = lang.Get("User");
+            table.Columns[2].Header = lang.Get("Action");
+            table.Columns[3].Header = lang.Get("Detail");
+            table.Columns[4].Header = lang.Get("Target");
+            table.Columns[5].Header = lang.Get("Result");
+        }
     }
 }

+ 15 - 1
src/YZWater.Avalonia/YZWater.Avalonia.csproj

@@ -34,10 +34,12 @@
 
   <!-- 构建时将根目录配置文件复制到输出目录(不覆盖已有文件) -->
   <Target Name="CopyRootConfig" BeforeTargets="Build">
+    <!-- SkipUnchangedFiles 只比较大小和时间戳:程序改过配置后必然不同,
+         结果每次重新构建都会把本机配置(含 DPAPI 加密项)冲回模板值,故这里改为仅在缺失时种入 -->
     <Copy SourceFiles="..\..\yzwater-config.json"
           DestinationFiles="$(OutputPath)yzwater-config.json"
           SkipUnchangedFiles="true"
-          Condition="Exists('..\..\yzwater-config.json')" />
+          Condition="Exists('..\..\yzwater-config.json') And !Exists('$(OutputPath)yzwater-config.json')" />
     <!-- 复制 projects/ 目录到输出目录 -->
     <MakeDir Directories="$(OutputPath)projects" Condition="Exists('..\..\projects') And !Exists('$(OutputPath)projects')" />
     <Copy SourceFiles="@(ProjectFiles)"
@@ -45,6 +47,18 @@
           SkipUnchangedFiles="true" />
   </Target>
 
+  <!-- 发布时将配置文件带入发布目录(RID 发布时 OutputPath 不直接进包) -->
+  <Target Name="CopyRootConfigToPublish" BeforeTargets="CopyFilesToPublishDirectory" Condition="Exists('$(PublishDir)')">
+    <Copy SourceFiles="..\..\yzwater-config.json"
+          DestinationFiles="$(PublishDir)yzwater-config.json"
+          SkipUnchangedFiles="true"
+          Condition="Exists('..\..\yzwater-config.json')" />
+    <MakeDir Directories="$(PublishDir)projects" Condition="Exists('..\..\projects')" />
+    <Copy SourceFiles="@(ProjectFiles)"
+          DestinationFolder="$(PublishDir)projects"
+          SkipUnchangedFiles="true" />
+  </Target>
+
   <ItemGroup>
     <ProjectFiles Include="..\..\projects\*.json" />
   </ItemGroup>

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

@@ -1,5 +1,6 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using SqlSugar;
+using YZWater.Core.Services;
 
 namespace YZWater.Core.Models;
 
@@ -62,4 +63,71 @@ public partial class AlarmRecord : ObservableObject
     [ObservableProperty]
     [property: SugarColumn(IsNullable = true)]
     private string? _confirmedBy;
+
+    /// <summary>
+    /// 报警恢复时间(用于计算异常持续时间)
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public DateTime? RecoverTime { get; set; }
+
+    /// <summary>
+    /// 报警条件唯一标识(用于精确匹配恢复事件)
+    /// 如 "Pump_1#泵_Fault"、"DI_低液位报警"、"Pressure_压力_High"
+    /// </summary>
+    [SugarColumn(IsNullable = true, ColumnDataType = "varchar(100)")]
+    public string? ConditionId { get; set; }
+
+    /// <summary>
+    /// 首出报警标记(ISA-18.2:报警风暴中最早触发的一条)
+    /// </summary>
+    [ObservableProperty]
+    private bool _isFirstOut;
+
+    /// <summary>
+    /// 泛溢抑制标记(ISA-18.2:报警泛溢期间触发,已写入但通知被抑制)
+    /// </summary>
+    [ObservableProperty]
+    private bool _isSuppressed;
+
+    /// <summary>
+    /// 首出/抑制标记显示文本(不入库,UI 绑定用)
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string FlagDisplay => IsFirstOut
+        ? LanguageService.Instance.Get("FirstOutFlag")
+        : IsSuppressed ? LanguageService.Instance.Get("SuppressedFlag") : string.Empty;
+
+    /// <summary>
+    /// 异常持续时间(人类可读格式,UI 绑定用,不入库)
+    /// </summary>
+    [ObservableProperty]
+    private string _durationText = "--";
+
+    /// <summary>
+    /// DurationText 的计算属性(不入库,供 UI 绑定)
+    /// </summary>
+    [SugarColumn(IsIgnore = true)]
+    public string DurationDisplay => DurationText;
+
+    /// <summary>
+    /// 计算并更新持续时间文本
+    /// </summary>
+    public void UpdateDurationText()
+    {
+        if (RecoverTime.HasValue)
+        {
+            var span = RecoverTime.Value - AlarmTime;
+            DurationText = span.TotalHours >= 1
+                ? $"{(int)span.TotalHours}h {span.Minutes}m {span.Seconds}s"
+                : span.TotalMinutes >= 1 ? $"{(int)span.TotalMinutes}m {span.Seconds}s"
+                : $"{span.Seconds}s";
+        }
+        else
+        {
+            var span = DateTime.Now - AlarmTime;
+            DurationText = span.TotalHours >= 1
+                ? $"{(int)span.TotalHours}h+ {span.Minutes}m"
+                : $"{(int)span.TotalMinutes}m+";
+        }
+    }
 }

+ 49 - 0
src/YZWater.Core/Models/DataGapRecord.cs

@@ -0,0 +1,49 @@
+using SqlSugar;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 数据缺口记录(PLC 通信中断期间采集停止的时间段标记)
+///
+/// S7 普通数据块无法在重连后回补中断期间数据,采用 gap 标记方案:
+/// 断连时插入(EndTime 为空),重连成功后回写 EndTime,
+/// 报表/查询遇到缺口区间可显示"该时段数据未采集"而非误认为无流量。
+/// 进程退出(正常或异常)时由 DataGapService 在下次启动/关闭时回写 EndTime,
+/// 避免遗留永久 open 的记录把之后所有时段误标为缺失。
+/// </summary>
+[SugarTable("data_gaps")]
+public class DataGapRecord
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
+    /// <summary>缺口开始时间(断连时刻)</summary>
+    public DateTime StartTime { get; set; }
+
+    /// <summary>缺口结束时间(重连时刻,空表示尚未恢复)</summary>
+    [SugarColumn(IsNullable = true)]
+    public DateTime? EndTime { get; set; }
+
+    /// <summary>缺口原因</summary>
+    [SugarColumn(IsNullable = true, ColumnDataType = "varchar(200)")]
+    public string? Reason { get; set; }
+
+    /// <summary>持续时长(人类可读,不入库,UI 绑定用)</summary>
+    [SugarColumn(IsIgnore = true)]
+    public string DurationText
+    {
+        get
+        {
+            var span = (EndTime ?? DateTime.Now) - StartTime;
+            if (span.TotalHours >= 1)
+                return $"{(int)span.TotalHours}h {span.Minutes}m";
+            if (span.TotalMinutes >= 1)
+                return $"{(int)span.TotalMinutes}m {span.Seconds}s";
+            return $"{span.Seconds}s";
+        }
+    }
+
+    /// <summary>是否仍未恢复</summary>
+    [SugarColumn(IsIgnore = true)]
+    public bool IsOpen => EndTime == null;
+}

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

@@ -93,6 +93,38 @@ public partial class PlcDataModel : ObservableObject
     /// <summary>数字量动态寄存器(名称 → 值),由项目档案驱动</summary>
     public ConcurrentDictionary<string, bool> DigitalRegisters { get; } = new();
 
+    // ═══════════════════════════════════════════════
+    //  测点级数据质量(OPC 风格的 Good/Bad)
+    // ═══════════════════════════════════════════════
+
+    /// <summary>
+    /// 测点质量(名称 → 最近一次读取是否成功)。
+    /// 读取失败的测点保留上一次有效值,只把质量位置为 Bad,
+    /// 这样单个坏地址不会把整轮数据打成零,也不会有零被当成真实值使用。
+    /// </summary>
+    private readonly ConcurrentDictionary<string, bool> _tagQuality = new();
+
+    /// <summary>
+    /// 测点质量是否为 Bad(读不到/从未读到)。质量表里没有的键视为非 Bad:
+    /// 那可能是旧版硬编码测点,或客户端模式(服务端只传寄存器值、不传质量表),
+    /// 这种情况下由调用方自己的默认值语义负责。
+    /// </summary>
+    public bool IsTagBad(string key)
+        => _tagQuality.TryGetValue(key, out var readable) && !readable;
+
+    /// <summary>
+    /// 记录测点质量。
+    /// 返回本次是否发生跳变以及跳变前的质量,调用方据此决定是否需要告警
+    /// (Good→Bad 才需要通知操作员,Bad→Good 由通信层记恢复日志)。
+    /// </summary>
+    public (bool Changed, bool WasReadable) SetTagQuality(string key, bool readable)
+    {
+        // 先取旧值再写:AddOrUpdate 返回的是"新值",拿它比较永远得不出跳变
+        var known = _tagQuality.TryGetValue(key, out var previous);
+        _tagQuality[key] = readable;
+        return (known && previous != readable, previous);
+    }
+
     /// <summary>
     /// 初始化动态寄存器(根据项目档案创建空条目)
     /// </summary>
@@ -168,6 +200,17 @@ public partial class PlcDataModel : ObservableObject
             if (!string.IsNullOrEmpty(do_.Address))
                 DigitalRegisters[$"DO.{do_.Name}"] = false;
         }
+
+        // 全局中控/屏控:所有泵共用一个地址,读键约定与 PlcPollingService 一致(挂在第一个泵名下)。
+        // 不登记这个键的话它永远"未登记",IsTagBad 判不出 Bad,首轮就会把默认 false 当成"中控"放行写操作。
+        if (!string.IsNullOrEmpty(profile.ModeSelectAddress) && profile.Pumps.Count > 0)
+            DigitalRegisters[$"{profile.Pumps[0].Name}.ModeSelect"] = false;
+
+        // 档案里的测点初始质量为 Bad:读到过一次成功才转 Good。
+        // 地址填错/被轮询跳过的测点因此一直是 Bad,消费者不会拿零值去做判断。
+        _tagQuality.Clear();
+        foreach (var key in AnalogRegisters.Keys) _tagQuality[key] = false;
+        foreach (var key in DigitalRegisters.Keys) _tagQuality[key] = false;
     }
 
     /// <summary>获取动态模拟量寄存器值</summary>
@@ -178,6 +221,22 @@ public partial class PlcDataModel : ObservableObject
     public bool GetDigitalRegister(string key, bool defaultValue = false)
         => DigitalRegisters.TryGetValue(key, out var value) ? value : defaultValue;
 
+    /// <summary>
+    /// 复制另一份模型的动态寄存器与测点质量。
+    /// UI 线程用快照取值,不能直接引用轮询线程正在写入的字典,
+    /// 但档案驱动的数据都在这两个字典里,不复制就等于读到的全是默认零值。
+    /// </summary>
+    public void CopyDynamicFrom(PlcDataModel source)
+    {
+        AnalogRegisters.Clear();
+        DigitalRegisters.Clear();
+        _tagQuality.Clear();
+
+        foreach (var (key, value) in source.AnalogRegisters) AnalogRegisters[key] = value;
+        foreach (var (key, value) in source.DigitalRegisters) DigitalRegisters[key] = value;
+        foreach (var (key, value) in source._tagQuality) _tagQuality[key] = value;
+    }
+
     // ═══════════════════════════════════════════════
     //  计算属性
     // ═══════════════════════════════════════════════
@@ -190,8 +249,8 @@ public partial class PlcDataModel : ObservableObject
     public bool HasAnyFault => Pump1Fault || Pump2Fault || Pump3Fault || Pump4Fault || Pump5Fault ||
                                Fan1Fault || Fan2Fault;
 
-    /// <summary>数据可用于记录/控制(Mock 模式视为始终有效)</summary>
-    public bool HasValidData => MockPlcService.IsEnabled || IsPlcConnected;
+    /// <summary>数据可用于记录/控制(Mock 模式视为始终有效;读取失败整轮作废时 IsDataValid=false)</summary>
+    public bool HasValidData => MockPlcService.IsEnabled || (IsPlcConnected && IsDataValid);
 
     /// <summary>
     /// 获取泵运行状态

+ 105 - 0
src/YZWater.Core/Models/ProjectProfile.cs

@@ -9,6 +9,9 @@ public class ProjectProfile
     /// <summary>项目名称(如 "扬杰二厂泵站")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名(如 { "en": "Yangjie #2 Pump Station" })</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>项目描述</summary>
     public string Description { get; set; } = "";
 
@@ -50,6 +53,9 @@ public class ProjectProfile
 
     /// <summary>页面布局配置 — 决定 ProjectView 的 UI 区域显隐和尺寸</summary>
     public PageLayoutConfig Layout { get; set; } = new();
+
+    /// <summary>统计配置 — 决定哪些数据参与历史记录和图表展示(null = 使用旧版硬编码逻辑)</summary>
+    public StatisticsConfig? Statistics { get; set; }
 }
 
 /// <summary>
@@ -94,6 +100,9 @@ public class PumpConfig
     /// <summary>泵名称(如 "1#泵")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     // ─── 读取地址 ───
 
     /// <summary>运行状态地址 (DO 区,如 Q0.1) - 起停共用地址时与 StartAddress 相同</summary>
@@ -152,6 +161,9 @@ public class TankConfig
     /// <summary>名称(如 "集水池")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>即时液位地址 (读,如 VD62)</summary>
     public string LevelAddress { get; set; } = "";
 
@@ -238,6 +250,9 @@ public class GaugeConfig
     /// <summary>名称(如 "压力"、"流量")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>单位(如 "MPa"、"m³/h")</summary>
     public string Unit { get; set; } = "";
 
@@ -271,6 +286,9 @@ public class SensorConfig
     /// <summary>名称(如 "日流量")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>单位(如 "m³")</summary>
     public string Unit { get; set; } = "";
 
@@ -292,6 +310,9 @@ public class DigitalInputConfig
     /// <summary>名称(如 "泵1变频器故障")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>地址(如 I0.1)</summary>
     public string Address { get; set; } = "";
 
@@ -307,6 +328,9 @@ public class DigitalOutputConfig
     /// <summary>名称(如 "复位按钮")</summary>
     public string Name { get; set; } = "";
 
+    /// <summary>多语言显示名</summary>
+    public Dictionary<string, string>? Names { get; set; }
+
     /// <summary>地址(如 M30.0)</summary>
     public string Address { get; set; } = "";
 
@@ -335,3 +359,84 @@ public class AlarmThresholds
     public float? PressureHigh { get; set; }
     public float? PressureLow { get; set; }
 }
+
+/// <summary>
+/// 统计配置 — 定义项目中哪些数据参与历史记录和图表展示
+/// 每项可独立启用/禁用
+/// </summary>
+public class StatisticsConfig
+{
+    /// <summary>统计项列表</summary>
+    public List<StatisticsItemConfig> Items { get; set; } = new();
+}
+
+/// <summary>
+/// 单个统计项配置
+/// </summary>
+public class StatisticsItemConfig
+{
+    /// <summary>统计项类型(TankLevel / GaugeValue / SensorValue / PumpCurrent / PumpDailyHours / PumpFreq / FlowRate)</summary>
+    public string Type { get; set; } = "";
+
+    /// <summary>
+    /// 唯一键,同时作为 AnalogRecord.TagName
+    /// </summary>
+    /// <example>"集水池.Level"、"压力.Value"、"1#泵.Current"</example>
+    public string Key { get; set; } = "";
+
+    /// <summary>显示名称(图表标题 / 表格列头)</summary>
+    public string DisplayName { get; set; } = "";
+
+    /// <summary>英文显示名称(可选,无则 fallback 到 DisplayName)</summary>
+    public string? DisplayNameEn { get; set; }
+
+    /// <summary>获取当前语言下的显示名</summary>
+    public string GetLocalizedDisplayName()
+    {
+        if (!Services.LanguageService.Instance.IsChinese && !string.IsNullOrEmpty(DisplayNameEn))
+            return DisplayNameEn;
+        return DisplayName;
+    }
+
+    /// <summary>单位(m³/h, Bar, A, m, % 等)</summary>
+    public string Unit { get; set; } = "";
+
+    /// <summary>是否启用统计(false = 不采集、不展示)</summary>
+    public bool Enabled { get; set; } = true;
+
+    /// <summary>
+    /// 图表分组名称。同组曲线共用一个图表和 Y 轴。
+    /// 为 null 时以 DisplayName 作为分组名(即每项独立一个图表)
+    /// </summary>
+    /// <example>多台泵电流共用 "泵电流" 组,共用一个图表</example>
+    public string? ChartGroup { get; set; }
+
+    /// <summary>曲线颜色(Hex,如 "#4169E1"),留空则自动分配</summary>
+    public string? Color { get; set; }
+
+    /// <summary>
+    /// 聚合方式 — 决定统计报表如何汇总该指标
+    /// </summary>
+    /// <remarks>
+    /// - <c>avg_max_min</c>(默认)— 计算均值/最大/最小,适用于瞬时量(液位、压力、流量、电流)
+    /// - <c>delta</c> — 取周期末值 − 初值 = 周期增量,适用于累计量(总流量)
+    /// - <c>max_only</c> — 仅取最大值,适用于自身已是累积值的量(日台时)
+    /// </remarks>
+    public string AggType { get; set; } = "avg_max_min";
+}
+
+/// <summary>
+/// 多语言设备名解析扩展 — 根据当前语言从 Names 字典获取显示名,
+/// 无对应语言时 fallback 到 Name 属性
+/// </summary>
+public static class LocalizedDisplayName
+{
+    /// <summary>获取当前语言下的显示名</summary>
+    public static string GetDisplayName(this string name, Dictionary<string, string>? names)
+    {
+        if (names == null || names.Count == 0) return name;
+        var lang = Services.LanguageService.Instance;
+        var key = lang.IsChinese ? "zh" : "en";
+        return names.TryGetValue(key, out var value) ? value : name;
+    }
+}

+ 33 - 0
src/YZWater.Core/Models/StatisticsSummary.cs

@@ -0,0 +1,33 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 单个统计项在某时间段内的聚合结果
+/// </summary>
+public partial class StatisticsSummary : ObservableObject
+{
+    /// <summary>TagName(对应 StatisticsItemConfig.Key / AnalogRecord.TagName)</summary>
+    [ObservableProperty] private string _tagName = "";
+
+    /// <summary>显示名称</summary>
+    [ObservableProperty] private string _displayName = "";
+
+    /// <summary>单位</summary>
+    [ObservableProperty] private string _unit = "";
+
+    /// <summary>聚合方式: avg_max_min / delta / max_only</summary>
+    [ObservableProperty] private string _aggType = "avg_max_min";
+
+    /// <summary>平均值</summary>
+    [ObservableProperty] private double? _avgValue;
+
+    /// <summary>最大值</summary>
+    [ObservableProperty] private double? _maxValue;
+
+    /// <summary>最小值</summary>
+    [ObservableProperty] private double? _minValue;
+
+    /// <summary>增量(delta 类型 = 末值 - 初值)</summary>
+    [ObservableProperty] private double? _totalValue;
+}

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

@@ -80,6 +80,12 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private string _contactPhone = "18115099090";
 
+    /// <summary>
+    /// 公司网站地址(空则隐藏访问网站按钮)
+    /// </summary>
+    [ObservableProperty]
+    private string _websiteUrl = string.Empty;
+
     /// <summary>
     /// 是否深色主题
     /// </summary>
@@ -112,8 +118,10 @@ public partial class SystemConfig : ObservableObject
 
     /// <summary>
     /// Hub 通信令牌(服务端和客户端必须匹配)
+    /// 落盘时经 DPAPI 加密,见 ProtectedStringJsonConverter;内存中始终是明文
     /// </summary>
     [ObservableProperty]
+    [property: System.Text.Json.Serialization.JsonConverter(typeof(ProtectedStringJsonConverter))]
     private string _hubToken = string.Empty;
 
     /// <summary>
@@ -140,6 +148,12 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private bool _autoLogin;
 
+    /// <summary>
+    /// 是否启用用户认证(false = 完全跳过登录,无用户管理模式,适用于简化场景)
+    /// </summary>
+    [ObservableProperty]
+    private bool _enableAuth = true;
+
     /// <summary>
     /// 当前激活的项目名称(对应 projects/ 目录下的 JSON 文件名,不含后缀)
     /// 空字符串表示使用内置默认项目配置
@@ -178,6 +192,13 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private string _plcProtocol = "SiemensS7";
 
+    // ═══ 报警管理配置(ISA-18.2)═══
+
+    /// <summary>
+    /// 报警管理配置(泛溢抑制/首出窗口等)
+    /// </summary>
+    public AlarmManagementConfig Alarm { get; set; } = new();
+
     // ═══ 数据保留配置 ═══
 
     /// <summary>
@@ -216,6 +237,36 @@ public class DataRetentionConfig
 
     /// <summary>审计日志保留天数(默认 365 天)</summary>
     public int AuditLogRetentionDays { get; set; } = 365;
+
+    /// <summary>设备状态记录保留天数(默认 360 天)</summary>
+    public int EquipmentStatusRetentionDays { get; set; } = 360;
+
+    /// <summary>数据缺口记录保留天数(默认 365 天)</summary>
+    public int DataGapRetentionDays { get; set; } = 365;
+}
+
+/// <summary>
+/// 报警管理配置(ISA-18.2 泛溢抑制 / 首出报警)
+/// </summary>
+public class AlarmManagementConfig
+{
+    /// <summary>是否启用泛溢抑制</summary>
+    public bool EnableFloodSuppression { get; set; } = true;
+
+    /// <summary>泛溢判定:时间窗口内触发报警条数阈值(默认 6 条)</summary>
+    public int FloodRaiseThreshold { get; set; } = 6;
+
+    /// <summary>泛溢判定窗口(秒,默认 60)</summary>
+    public int FloodWindowSeconds { get; set; } = 60;
+
+    /// <summary>是否启用首出报警标记</summary>
+    public bool EnableFirstOut { get; set; } = true;
+
+    /// <summary>首出判定窗口(秒,窗口内连续报警视为同一波,默认 5)</summary>
+    public int FirstOutWindowSeconds { get; set; } = 5;
+
+    /// <summary>搁置默认时长(分钟,默认 30)</summary>
+    public int DefaultShelveMinutes { get; set; } = 30;
 }
 
 /// <summary>

+ 472 - 51
src/YZWater.Core/Services/AlarmService.cs

@@ -7,6 +7,7 @@ namespace YZWater.Core.Services;
 
 /// <summary>
 /// 报警服务 - 报警检测、持久化、确认、延迟
+/// ISA-18.2 报警管理:搁置(Shelve)、泛溢抑制(Flood Suppression)、首出(First-Out)、回顾(Review)
 /// 持久化通过 Channel 异步队列后台串行写入(借鉴 AutoFMA,防报警密集时阻塞轮询线程)
 /// </summary>
 public partial class AlarmService : ObservableObject, IDisposable
@@ -42,6 +43,9 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
     private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
 
+    /// <summary>写库失败后的重试间隔</summary>
+    private static readonly TimeSpan WriteRetryDelay = TimeSpan.FromSeconds(5);
+
     /// <summary>后台写入任务</summary>
     private Task _writerTask = Task.CompletedTask;
     private CancellationTokenSource _writerCts = new();
@@ -62,6 +66,28 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     public float AlarmDeadband { get; set; } = 2f;
 
+    // ─── ISA-18.2 报警管理配置 ───
+    public bool EnableFloodSuppression { get; set; } = true;
+    public int FloodRaiseThreshold { get; set; } = 6;
+    public int FloodWindowSeconds { get; set; } = 60;
+    public bool EnableFirstOut { get; set; } = true;
+    public int FirstOutWindowSeconds { get; set; } = 5;
+
+    // ─── 搁置(Shelving)───
+    /// <summary>conditionId → 搁置到期时间(到期后自动取消搁置,下轮轮询重新评估)</summary>
+    private readonly Dictionary<string, DateTime> _shelvedConditions = new();
+    private readonly object _shelfLock = new();
+
+    // ─── 泛溢抑制(Flood Suppression)───
+    /// <summary>窗口内的报警触发时间戳队列</summary>
+    private readonly Queue<DateTime> _raiseTimes = new();
+    private bool _inFlood;
+    private DateTime _lastRaiseTime = DateTime.MinValue;
+
+    // ─── 首出报警(First-Out)───
+    /// <summary>当前报警波(窗口内连续触发的报警),第一条为首出</summary>
+    private readonly List<(AlarmRecord Record, DateTime Time)> _burstMembers = new();
+
     // ─── 状态 ───
     [ObservableProperty] private bool _hasActiveAlarm;
     [ObservableProperty] private string _activeAlarmMessage = string.Empty;
@@ -76,6 +102,9 @@ public partial class AlarmService : ObservableObject, IDisposable
     private readonly Dictionary<string, DateTime> _alarmStartTimes = new();
     private readonly HashSet<string> _activeAlarms = new();
 
+    /// <summary>活跃报警 conditionId → 数据库 AlarmRecord.Id(恢复时回写 RecoverTime 用)</summary>
+    private readonly Dictionary<string, int> _activeAlarmDbIds = new();
+
     // ─── 报警升级 ───
     public int EscalationMinutes { get; set; } = 10; // 未确认报警升级时间
     private readonly Dictionary<string, DateTime> _alarmTriggeredTimes = new(); // 报警触发时间(用于升级判断)
@@ -112,6 +141,18 @@ public partial class AlarmService : ObservableObject, IDisposable
         LevelLowAlarm = config.LevelLowAlarm;
         FlowHighAlarm = config.FlowHighAlarm;
         FlowLowAlarm = config.FlowLowAlarm;
+
+        // ISA-18.2 报警管理配置
+        var alarmCfg = config.Alarm;
+        if (alarmCfg != null)
+        {
+            EnableFloodSuppression = alarmCfg.EnableFloodSuppression;
+            FloodRaiseThreshold = alarmCfg.FloodRaiseThreshold;
+            FloodWindowSeconds = alarmCfg.FloodWindowSeconds;
+            EnableFirstOut = alarmCfg.EnableFirstOut;
+            FirstOutWindowSeconds = alarmCfg.FirstOutWindowSeconds;
+        }
+
         Log.Debug("报警阈值已从配置加载: 液位高={High}, 液位低={Low}, 流量高={FlowHigh}, 流量低={FlowLow}",
             LevelHighAlarm, LevelLowAlarm, FlowHighAlarm, FlowLowAlarm);
     }
@@ -149,6 +190,35 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         var now = DateTime.Now;
 
+        // ISA-18.2 维护:搁置到期、泛溢结束、首出波封口
+        PurgeExpiredShelves(now);
+        UpdateFloodState(now);
+        UpdateBurstState(now);
+
+        // 检查 PLC 通信(Mock 模式下跳过,因为本来就没有真实 PLC)
+        if (!MockPlcService.IsEnabled)
+        {
+            if (!data.IsPlcConnected)
+            {
+                CheckCondition("PLC_Connection", LanguageService.Instance.Get("PlcCommLost"), 1, now);
+            }
+            else
+            {
+                ClearCondition("PLC_Connection");
+            }
+        }
+        else
+        {
+            ClearCondition("PLC_Connection");
+        }
+
+        // 数据无效(未连接/读取失败)时跳过工艺量报警 — 防止全零或过期值误报
+        if (!data.IsDataValid)
+        {
+            UpdateAlarmState();
+            return;
+        }
+
         // 检查是否加载了项目档案
         var profile = ProjectService.Current;
         if (profile != null)
@@ -175,23 +245,6 @@ public partial class AlarmService : ObservableObject, IDisposable
             CheckFanFault("Fan2", data.Fan2Fault, now);
         }
 
-        // 检查 PLC 通信(Mock 模式下跳过,因为本来就没有真实 PLC)
-        if (!MockPlcService.IsEnabled)
-        {
-            if (!data.IsPlcConnected)
-            {
-                CheckCondition("PLC_Connection", LanguageService.Instance.Get("PlcCommLost"), 1, now);
-            }
-            else
-            {
-                ClearCondition("PLC_Connection");
-            }
-        }
-        else
-        {
-            ClearCondition("PLC_Connection");
-        }
-
         // 更新状态
         UpdateAlarmState();
     }
@@ -210,20 +263,24 @@ public partial class AlarmService : ObservableObject, IDisposable
         bool checkAnalog = source == "Host" || source == "Both";
         bool checkDI = source == "PLC" || source == "Both";
 
-        // ① 模拟量阈值检查(液位 + 仪表)— Host/Both 模式
+        // ① 模拟量阈值检查(液位 + 仪表 + 保护液位)— Host/Both 模式
         if (checkAnalog)
         {
             // 液位报警(从动态寄存器读取)
             foreach (var tank in profile.Tanks)
             {
-                var level = data.GetAnalogRegister($"{tank.Name}.Level");
+                var levelKey = $"{tank.Name}.Level";
+                if (data.IsTagBad(levelKey)) continue; // 质量 Bad:既不判定也不清除,保持报警现状(轮询服务已告警)
+                var level = data.GetAnalogRegister(levelKey);
                 CheckLevelAlarm(tank.Name, level, now);
             }
 
             // 仪表报警(压力/流量等)
             foreach (var gauge in profile.Gauges)
             {
-                var value = data.GetAnalogRegister($"{gauge.Name}.Value");
+                var valueKey = $"{gauge.Name}.Value";
+                if (data.IsTagBad(valueKey)) continue;
+                var value = data.GetAnalogRegister(valueKey);
                 if (thresholds?.PressureHigh.HasValue == true && value > thresholds.PressureHigh.Value)
                     CheckCondition($"Pressure_{gauge.Name}_High", $"{gauge.Name}过高: {value:F2} {gauge.Unit}", 2, now);
                 else
@@ -235,26 +292,53 @@ public partial class AlarmService : ObservableObject, IDisposable
                     ClearCondition($"Pressure_{gauge.Name}_Low");
             }
 
-            // 泵故障报警(寄存器读取)
-            foreach (var pump in profile.Pumps)
+            // 保护液位(紧急)— 从 ProjectViewModel 迁入
+            foreach (var tank in profile.Tanks)
             {
-                var fault = data.GetDigitalRegister($"{pump.Name}.Fault");
-                CheckPumpFault(pump.Name, fault, now);
+                var levelKey = $"{tank.Name}.Level";
+                var protectionKey = $"{tank.Name}.ProtectionLevel";
+                if (data.IsTagBad(levelKey) || data.IsTagBad(protectionKey)) continue;
+                var level = data.GetAnalogRegister(levelKey);
+                var protectionLevel = data.GetAnalogRegister(protectionKey);
+                if (protectionLevel > 0 && level > protectionLevel)
+                    CheckCondition($"{tank.Name}_Protection",
+                        $"{tank.Name}超过保护液位: {level:F2} m (保护值 {protectionLevel:F2})", 4, now);
+                else
+                    ClearCondition($"{tank.Name}_Protection");
             }
         }
 
-        // ② PLC DI 报警 — PLC/Both 模式
+        // ② PLC DI 报警 — PLC/Both 模式(去重:跳过地址与 pump.FaultAddress 相同的 DI)
         if (checkDI)
         {
+            var pumpFaultAddrs = new HashSet<string>(
+                profile.Pumps.Where(p => !string.IsNullOrEmpty(p.FaultAddress))
+                             .Select(p => p.FaultAddress), StringComparer.OrdinalIgnoreCase);
+
             foreach (var di in profile.DigitalInputs)
             {
-                var isActive = data.GetDigitalRegister($"DI.{di.Name}");
+                if (pumpFaultAddrs.Contains(di.Address)) continue; // 跳过与泵故障地址相同的 DI
+                var diKey = $"DI.{di.Name}";
+                if (data.IsTagBad(diKey)) continue;
+                var isActive = data.GetDigitalRegister(diKey);
                 if (isActive)
                     CheckCondition($"DI_{di.Name}", $"{di.Name} 触发", 2, now);
                 else
                     ClearCondition($"DI_{di.Name}");
             }
         }
+
+        // ③ 泵故障报警 — 始终执行(不受 AlarmSource 限制)
+        // 覆盖 PLC 模式下 pump.FaultAddress 不产生报警记录的问题
+        // Host/Both 模式下 _activeAlarms.Contains 防重入
+        foreach (var pump in profile.Pumps)
+        {
+            if (string.IsNullOrEmpty(pump.FaultAddress)) continue;
+            var faultKey = $"{pump.Name}.Fault";
+            if (data.IsTagBad(faultKey)) continue; // 故障状态读不到时保持报警现状,不用最后值清除
+            var fault = data.GetDigitalRegister(faultKey);
+            CheckPumpFault(pump.Name, fault, now);
+        }
     }
 
     /// <summary>
@@ -335,6 +419,9 @@ public partial class AlarmService : ObservableObject, IDisposable
         if (_activeAlarms.Contains(conditionId))
             return; // 已经在报警中
 
+        if (IsShelved(conditionId))
+            return; // 已搁置:不评估、不触发
+
         if (!_alarmStartTimes.ContainsKey(conditionId))
         {
             _alarmStartTimes[conditionId] = now;
@@ -349,20 +436,59 @@ public partial class AlarmService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 清除报警条件
+    /// 清除报警条件(恢复时回写 RecoverTime 到数据库)
     /// </summary>
     private void ClearCondition(string conditionId)
     {
+        // 查找并移除 DB Id 映射,用于回写恢复时间
+        int dbId;
+        lock (_activeAlarmDbIds)
+        {
+            if (!_activeAlarmDbIds.TryGetValue(conditionId, out dbId))
+            {
+                // 没有 DB 记录(未触发过报警),直接清内存
+                _alarmStartTimes.Remove(conditionId);
+                _activeAlarms.Remove(conditionId);
+                _alarmTriggeredTimes.Remove(conditionId);
+                _escalatedAlarms.Remove(conditionId);
+                return;
+            }
+            _activeAlarmDbIds.Remove(conditionId);
+        }
+
+        // 异步回写 RecoverTime
+        _ = UpdateRecoverTimeAsync(dbId);
+
         _alarmStartTimes.Remove(conditionId);
         _activeAlarms.Remove(conditionId);
         _alarmTriggeredTimes.Remove(conditionId);
         _escalatedAlarms.Remove(conditionId);
     }
 
+    /// <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);
+        }
+    }
+
     /// <summary>
     /// 触发报警
     /// </summary>
-    private void TriggerAlarm(string conditionId, string message, int level)
+    private void TriggerAlarm(string conditionId, string message, int level, bool isFloodMeta = false)
     {
         _activeAlarms.Add(conditionId);
         _alarmStartTimes.Remove(conditionId);
@@ -375,20 +501,264 @@ public partial class AlarmService : ObservableObject, IDisposable
             AlarmType = conditionId.Split('_')[0],
             AlarmMessage = message,
             AlarmLevel = level,
-            IsConfirmed = false
+            IsConfirmed = false,
+            ConditionId = conditionId
         };
 
+        if (!isFloodMeta)
+        {
+            // 泛溢检测:窗口内触发过多时抑制后续报警的通知(记录仍全部落库)
+            if (DetectFlood(alarm))
+                alarm.IsSuppressed = true;
+
+            // 首出标记:窗口内连续报警视为同一波,第一条为首出
+            if (EnableFirstOut)
+                TrackFirstOut(alarm, alarm.AlarmTime);
+        }
+
         // 持久化到数据库
         _ = SaveAlarmAsync(alarm);
 
+        // 等待落库后映射 conditionId → DB Id(恢复时回写用)
+        _ = TrackAlarmIdAsync(conditionId, alarm);
+
         // 通知
         AlarmRaised?.Invoke(alarm);
         UpdateAlarmState();
 
-        // 发送远程通知
-        _ = NotificationService.Instance.SendAlarmNotificationAsync(alarm);
+        // 发送远程通知(泛溢抑制期间跳过,防通知风暴)
+        if (!alarm.IsSuppressed)
+            _ = NotificationService.Instance.SendAlarmNotificationAsync(alarm);
+
+        Log.Warning("报警触发: {Message} (级别 {Level}{Suppressed})", message, level,
+            alarm.IsSuppressed ? " [泛溢抑制]" : string.Empty);
+    }
+
+    /// <summary>等待报警记录落库后,将 conditionId → DbId 映射存入内存字典</summary>
+    private async Task TrackAlarmIdAsync(string conditionId, AlarmRecord alarm)
+    {
+        var deadline = DateTime.Now.AddSeconds(5);
+        while (alarm.Id == 0 && DateTime.Now < deadline)
+            await Task.Delay(50);
+        if (alarm.Id > 0)
+            lock (_activeAlarmDbIds)
+                _activeAlarmDbIds[conditionId] = alarm.Id;
+    }
+
+    // ═══════════════════════════════════════════════════════════
+    // ISA-18.2 报警管理:泛溢抑制 / 首出 / 搁置 / 回顾
+    // ═══════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 记录本次触发并判断当前是否处于泛溢状态。
+    /// 返回 true 表示该报警应被抑制通知(记录仍落库并标记 IsSuppressed)。
+    /// </summary>
+    private bool DetectFlood(AlarmRecord alarm)
+    {
+        var now = alarm.AlarmTime;
+        _lastRaiseTime = now;
+        _raiseTimes.Enqueue(now);
+        while (_raiseTimes.Count > 0 && (now - _raiseTimes.Peek()).TotalSeconds > FloodWindowSeconds)
+            _raiseTimes.Dequeue();
+
+        if (!EnableFloodSuppression)
+            return _inFlood;
+
+        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);
+        }
+
+        return _inFlood;
+    }
+
+    /// <summary>窗口期无新报警则退出泛溢状态</summary>
+    private void UpdateFloodState(DateTime now)
+    {
+        if (!_inFlood) return;
+        if ((now - _lastRaiseTime).TotalSeconds < FloodWindowSeconds) return;
+
+        _inFlood = false;
+        _raiseTimes.Clear();
+        ClearCondition("Alarm_Flood");
+        var msg = string.Format(LanguageService.Instance.Get("AlarmFloodEnded"), FloodWindowSeconds);
+        Log.Information(msg);
+        AuditService.Log("系统", "AlarmFlood", msg);
+    }
+
+    /// <summary>
+    /// 首出跟踪:与新报警时间相差窗口内的已有报警属于同一波,波内第一条为首出
+    /// </summary>
+    private void TrackFirstOut(AlarmRecord alarm, DateTime now)
+    {
+        if (_burstMembers.Count > 0 &&
+            (now - _burstMembers[^1].Time).TotalSeconds > FirstOutWindowSeconds)
+        {
+            FinalizeBurst();
+        }
+
+        _burstMembers.Add((alarm, now));
+        if (_burstMembers.Count >= 2)
+            _burstMembers[0].Record.IsFirstOut = true; // 队列中未落库时属性直接生效;已落库由 FinalizeBurst 补写
+    }
+
+    /// <summary>报警波结束(窗口期无新报警)时封口,持久化首出标记</summary>
+    private void UpdateBurstState(DateTime now)
+    {
+        if (_burstMembers.Count == 0) return;
+        if ((now - _burstMembers[^1].Time).TotalSeconds < FirstOutWindowSeconds) return;
+        FinalizeBurst();
+    }
+
+    private void FinalizeBurst()
+    {
+        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);
+        }
+        _burstMembers.Clear();
+    }
+
+    /// <summary>回写首出标记到数据库(首条报警已落库的情况)</summary>
+    private async Task UpdateFirstOutAsync(int alarmId)
+    {
+        try
+        {
+            await DatabaseService.ExecuteWriteAsync(
+                _bgDb, db => db.Updateable<AlarmRecord>()
+                    .SetColumns(r => r.IsFirstOut, true)
+                    .Where(r => r.Id == alarmId)
+                    .ExecuteCommandAsync());
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "回写首出标记失败: {Id}", alarmId);
+        }
+    }
+
+    /// <summary>条件是否处于搁置期(过期视为未搁置)</summary>
+    public bool IsShelved(string conditionId)
+    {
+        lock (_shelfLock)
+        {
+            if (!_shelvedConditions.TryGetValue(conditionId, out var until))
+                return false;
+            if (until > DateTime.Now) return true;
+            _shelvedConditions.Remove(conditionId);
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// 搁置报警(ISA-18.2 Shelve):限时不再评估该条件,到期自动恢复评估。
+    /// 当前活跃记录会被关闭(回写恢复时间),搁置到期后若条件仍成立会重新触发。
+    /// 搁置为内存状态,重启后失效(防止无限期搁置被遗忘)。
+    /// </summary>
+    public void Shelve(string conditionId, int minutes, string operatorName = "Operator")
+    {
+        if (string.IsNullOrEmpty(conditionId) || minutes <= 0) return;
+
+        lock (_shelfLock)
+            _shelvedConditions[conditionId] = DateTime.Now.AddMinutes(minutes);
+
+        ClearCondition(conditionId);
+        UpdateAlarmState();
+
+        var msg = string.Format(LanguageService.Instance.Get("AlarmShelved"), conditionId, minutes);
+        Log.Information(msg);
+        AuditService.Log(operatorName, "AlarmShelve", msg);
+    }
 
-        Log.Warning("报警触发: {Message} (级别 {Level})", message, level);
+    /// <summary>取消搁置(下一轮轮询立即重新评估该条件)</summary>
+    public void Unshelve(string conditionId, string operatorName = "Operator")
+    {
+        bool removed;
+        lock (_shelfLock)
+            removed = _shelvedConditions.Remove(conditionId);
+        if (!removed) return;
+
+        var msg = string.Format(LanguageService.Instance.Get("AlarmUnshelved"), conditionId);
+        Log.Information(msg);
+        AuditService.Log(operatorName, "AlarmUnshelve", msg);
+    }
+
+    /// <summary>当前搁置中的报警条件(conditionId → 到期时间)</summary>
+    public List<(string ConditionId, DateTime Until)> GetShelvedAlarms()
+    {
+        var now = DateTime.Now;
+        lock (_shelfLock)
+            return _shelvedConditions.Where(kv => kv.Value > now)
+                .Select(kv => (kv.Key, kv.Value))
+                .OrderBy(kv => kv.Value)
+                .ToList();
+    }
+
+    /// <summary>清理到期搁置(到期后条件仍成立则下一轮自然重新触发)</summary>
+    private void PurgeExpiredShelves(DateTime now)
+    {
+        List<string>? expired = null;
+        lock (_shelfLock)
+        {
+            foreach (var (key, until) in _shelvedConditions)
+                if (until <= now) (expired ??= new List<string>()).Add(key);
+            if (expired != null)
+                foreach (var key in expired) _shelvedConditions.Remove(key);
+        }
+
+        if (expired != null)
+            foreach (var key in expired)
+                Log.Information("报警搁置到期,恢复评估: {ConditionId}", key);
+    }
+
+    /// <summary>
+    /// 报警回顾(ISA-18.2 Alarm Review):按条件分组统计时间窗内的报警频次与状态
+    /// </summary>
+    public async Task<List<AlarmReviewItem>> GetAlarmReviewAsync(DateTime start, DateTime end)
+    {
+        var records = await GetAlarmHistoryAsync(start, end);
+        return records
+            .GroupBy(r => r.ConditionId ?? r.AlarmType)
+            .Select(g => new AlarmReviewItem
+            {
+                ConditionId = g.Key,
+                SampleMessage = g.OrderByDescending(r => r.AlarmTime).First().AlarmMessage,
+                Count = g.Count(),
+                FirstTime = g.Min(r => r.AlarmTime),
+                LastTime = g.Max(r => r.AlarmTime),
+                IsActive = _activeAlarms.Contains(g.Key),
+                IsShelved = IsShelved(g.Key)
+            })
+            .OrderByDescending(i => i.Count)
+            .ToList();
+    }
+
+    /// <summary>
+    /// 记录信息级报警流水(自动确认,不占用活跃报警,用于事件提示如数据缺口恢复)
+    /// </summary>
+    public void RecordJournalAlarm(string message, int level = 1)
+    {
+        var alarm = new AlarmRecord
+        {
+            AlarmTime = DateTime.Now,
+            AlarmType = "System",
+            AlarmMessage = message,
+            AlarmLevel = level,
+            IsConfirmed = true,
+            ConfirmedTime = DateTime.Now,
+            ConfirmedBy = "系统"
+        };
+        _ = SaveAlarmAsync(alarm);
+        AlarmRaised?.Invoke(alarm);
     }
 
     /// <summary>
@@ -445,34 +815,47 @@ public partial class AlarmService : ObservableObject, IDisposable
 
     /// <summary>
     /// 后台消费 Channel 中的报警记录,串行写入数据库。
-    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥),
-    /// 数据库已释放或连续写入失败时退出循环。
+    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥)。
+    /// 数据库暂时不可用时保留当前记录并退避重试——报警历史不能静默丢失;
+    /// 仅在数据库已释放(正常退出)时结束循环。
     /// </summary>
     private async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
-        var consecutiveFailures = 0;
+        var isDegraded = false;
         try
         {
             await foreach (var alarm in _writeChannel.Reader.ReadAllAsync(ct))
             {
-                try
+                var pending = alarm;
+                while (pending != null)
                 {
-                    var (ok, id) = await DatabaseService.ExecuteWriteAsync(
-                        _bgDb, db => db.Insertable(alarm).ExecuteReturnIdentityAsync());
-                    if (!ok) break; // 数据库已关闭,放弃剩余写入
-
-                    alarm.Id = id;
-                    consecutiveFailures = 0;
-                    Log.Debug("报警记录已保存: {Id}", alarm.Id);
-                }
-                catch (Exception ex)
-                {
-                    // 连续失败 3 次退出(数据库不可用)
-                    Log.Error(ex, "保存报警记录失败");
-                    if (++consecutiveFailures >= 3)
+                    try
                     {
-                        Log.Warning("报警后台写入已停止(数据库不可用)");
-                        break;
+                        var (ok, id) = await DatabaseService.ExecuteWriteAsync(
+                            _bgDb, db => db.Insertable(pending).ExecuteReturnIdentityAsync());
+                        if (!ok) return; // 数据库已关闭,放弃剩余写入
+
+                        pending.Id = id;
+                        pending = null;
+                        if (isDegraded)
+                        {
+                            isDegraded = false;
+                            Log.Information("报警后台写入已恢复");
+                        }
+                        Log.Debug("报警记录已保存: {Id}", alarm.Id);
+                    }
+                    catch (Exception ex)
+                    {
+                        if (!isDegraded)
+                        {
+                            isDegraded = true;
+                            Log.Error(ex, "保存报警记录失败,稍后重试(数据库暂不可用)");
+                        }
+                        else
+                        {
+                            Log.Debug(ex, "报警记录重试仍失败");
+                        }
+                        await Task.Delay(WriteRetryDelay, ct);
                     }
                 }
             }
@@ -592,6 +975,12 @@ public partial class AlarmService : ObservableObject, IDisposable
                 _alarmStartTimes.Remove(matchedKey);
                 _alarmTriggeredTimes.Remove(matchedKey);
                 _escalatedAlarms.Remove(matchedKey);
+                // 清除 DB Id 映射并回写恢复时间
+                lock (_activeAlarmDbIds)
+                {
+                    if (_activeAlarmDbIds.Remove(matchedKey))
+                        _ = UpdateRecoverTimeAsync(alarmId);
+                }
             }
             else
             {
@@ -603,6 +992,14 @@ public partial class AlarmService : ObservableObject, IDisposable
                     _alarmStartTimes.Remove(key);
                     _alarmTriggeredTimes.Remove(key);
                     _escalatedAlarms.Remove(key);
+                    lock (_activeAlarmDbIds)
+                    {
+                        if (_activeAlarmDbIds.TryGetValue(key, out var id))
+                        {
+                            _activeAlarmDbIds.Remove(key);
+                            _ = UpdateRecoverTimeAsync(id);
+                        }
+                    }
                 }
                 Log.Information("报警复位: 未找到精确匹配,清除 {Count} 条同类活跃报警", keysToRemove.Count);
             }
@@ -643,6 +1040,16 @@ public partial class AlarmService : ObservableObject, IDisposable
             _alarmTriggeredTimes.Clear();
             _escalatedAlarms.Clear();
 
+            // 回写所有活跃报警的恢复时间
+            List<int> idsToRecover;
+            lock (_activeAlarmDbIds)
+            {
+                idsToRecover = _activeAlarmDbIds.Values.ToList();
+                _activeAlarmDbIds.Clear();
+            }
+            foreach (var id in idsToRecover)
+                _ = UpdateRecoverTimeAsync(id);
+
             // 更新数据库:标记所有未确认报警为已确认
             await DatabaseService.Db.Updateable<AlarmRecord>()
                 .SetColumns(r => r.IsConfirmed, true)
@@ -761,3 +1168,17 @@ public partial class AlarmService : ObservableObject, IDisposable
         Log.Information("报警服务已释放");
     }
 }
+
+/// <summary>
+/// 报警回顾条目:单个报警条件在时间窗内的统计
+/// </summary>
+public class AlarmReviewItem
+{
+    public string ConditionId { get; set; } = string.Empty;
+    public string SampleMessage { get; set; } = string.Empty;
+    public int Count { get; set; }
+    public DateTime FirstTime { get; set; }
+    public DateTime LastTime { get; set; }
+    public bool IsActive { get; set; }
+    public bool IsShelved { get; set; }
+}

+ 32 - 0
src/YZWater.Core/Services/AppPaths.cs

@@ -0,0 +1,32 @@
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 程序数据文件路径统一入口:一律锚定 exe 所在目录,而不是当前工作目录。
+///
+/// 之前各处直接写 "yzwater.db"、"projects" 这类相对路径,含义随启动方式漂移:
+/// 从快捷方式、计划任务或以其它目录为工作目录启动时,程序会在那里另建一个空数据库
+/// 和空配置,现场表现为"历史数据突然全空、报警限值回到默认",且原数据看起来像被丢弃。
+/// </summary>
+public static class AppPaths
+{
+    /// <summary>数据文件所在目录(exe 所在目录)</summary>
+    public static string Root { get; } = AppContext.BaseDirectory;
+
+    /// <summary>SQLite 数据库文件</summary>
+    public static string Database { get; } = Path.Combine(Root, "yzwater.db");
+
+    /// <summary>配置文件</summary>
+    public static string Config { get; } = Path.Combine(Root, "yzwater-config.json");
+
+    /// <summary>上一次成功读写过的配置副本,主配置损坏时的回退来源</summary>
+    public static string ConfigLastGood { get; } = Path.Combine(Root, "yzwater-config.json.lastgood");
+
+    /// <summary>项目档案目录</summary>
+    public static string Projects { get; } = Path.Combine(Root, "projects");
+
+    /// <summary>数据库备份目录</summary>
+    public static string Backups { get; } = Path.Combine(Root, "Backups");
+
+    /// <summary>日志目录</summary>
+    public static string Logs { get; } = Path.Combine(Root, "Logs");
+}

+ 33 - 17
src/YZWater.Core/Services/AuditService.cs

@@ -27,6 +27,9 @@ public static class AuditService
     /// <summary>后台写入任务专用数据库客户端(独占连接,与其他任务物理隔离)</summary>
     private static readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
 
+    /// <summary>写库失败后的重试间隔</summary>
+    private static readonly TimeSpan WriteRetryDelay = TimeSpan.FromSeconds(5);
+
     /// <summary>后台写入任务</summary>
     private static readonly Task _writerTask;
     private static readonly CancellationTokenSource _writerCts = new();
@@ -39,32 +42,45 @@ public static class AuditService
 
     /// <summary>
     /// 后台消费 Channel 中的审计日志,串行写入数据库。
-    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥),
-    /// 数据库已释放或连续写入失败时退出循环。
+    /// 写库经 DatabaseService.ExecuteWriteAsync 门闩(与关库互斥)。
+    /// 数据库暂时不可用时保留当前条目并退避重试——审计不能静默丢失;
+    /// 仅在数据库已释放(正常退出)时结束循环。
     /// </summary>
     private static async Task ProcessWriteQueueAsync(CancellationToken ct)
     {
-        var consecutiveFailures = 0;
+        var isDegraded = false;
         try
         {
             await foreach (var entry in _writeChannel.Reader.ReadAllAsync(ct))
             {
-                try
-                {
-                    var (ok, _) = await DatabaseService.ExecuteWriteAsync(
-                        _bgDb, db => db.Insertable(entry).ExecuteCommandAsync());
-                    if (!ok) break; // 数据库已关闭,放弃剩余写入
-
-                    consecutiveFailures = 0;
-                }
-                catch (Exception ex)
+                var pending = entry;
+                while (pending != null)
                 {
-                    // 连续失败 3 次退出(数据库不可用)
-                    Serilog.Log.Error(ex, "审计日志写入失败");
-                    if (++consecutiveFailures >= 3)
+                    try
+                    {
+                        var (ok, _) = await DatabaseService.ExecuteWriteAsync(
+                            _bgDb, db => db.Insertable(pending).ExecuteCommandAsync());
+                        if (!ok) return; // 数据库已关闭,放弃剩余写入
+
+                        pending = null;
+                        if (isDegraded)
+                        {
+                            isDegraded = false;
+                            Serilog.Log.Information("审计日志后台写入已恢复");
+                        }
+                    }
+                    catch (Exception ex)
                     {
-                        Serilog.Log.Warning("审计日志后台写入已停止(数据库不可用)");
-                        break;
+                        if (!isDegraded)
+                        {
+                            isDegraded = true;
+                            Serilog.Log.Error(ex, "审计日志写入失败,稍后重试(数据库暂不可用)");
+                        }
+                        else
+                        {
+                            Serilog.Log.Debug(ex, "审计日志重试仍失败");
+                        }
+                        await Task.Delay(WriteRetryDelay, ct);
                     }
                 }
             }

+ 17 - 3
src/YZWater.Core/Services/AuthService.cs

@@ -32,9 +32,21 @@ public static class AuthService
     public static User? CurrentUser => _currentUser;
 
     /// <summary>
-    /// 是否已登录
+    /// 是否已登录(认证禁用时也视为已登录)
     /// </summary>
-    public static bool IsLoggedIn => _currentUser != null;
+    public static bool IsLoggedIn => _currentUser != null || !IsAuthEnabled;
+
+    /// <summary>
+    /// 是否启用了用户认证(从配置文件读取,默认 true)
+    /// </summary>
+    public static bool IsAuthEnabled
+    {
+        get
+        {
+            var config = ConfigService.GetConfig();
+            return config?.EnableAuth ?? true;
+        }
+    }
 
     /// <summary>
     /// 会话超时时间(分钟)
@@ -203,10 +215,12 @@ public static class AuthService
     }
 
     /// <summary>
-    /// 检查当前用户是否有指定权限
+    /// 检查当前用户是否有指定权限(认证禁用时返回 true)
     /// </summary>
     public static bool HasPermission(UserRole requiredRole)
     {
+        // 认证禁用模式:所有权限开放
+        if (!IsAuthEnabled) return true;
         if (_currentUser == null) return false;
         return _currentUser.Role >= requiredRole;
     }

+ 124 - 43
src/YZWater.Core/Services/AutoControlService.cs

@@ -70,6 +70,13 @@ public class AutoControlService : IDisposable
     private readonly Dictionary<string, DateTime> _lastStartTimes = new();
     private readonly Dictionary<string, DateTime> _lastStopTimes = new();
 
+    // PLC 拒写后的重试退避:不能用轮询频率反复写(读正常、写被拒时 HasValidData 仍为 true)
+    private readonly Dictionary<string, DateTime> _controlRetryAt = new();
+    private const int ControlRetrySeconds = 15;
+
+    // 控制执行重入闸:脉冲含 Task.Delay,上一轮未结束时新轮询不得并发写同一泵
+    private int _controlInFlight;
+
     // 泵轮换跟踪:记录每台泵的累计运行时间
     private readonly Dictionary<string, float> _pumpRunningHours = new();
     private int _rotationIndex = 0;
@@ -167,13 +174,32 @@ public class AutoControlService : IDisposable
         try
         {
             var tank = profile.Tanks[0]; // 取第一个水池
-            var currentLevel = data.GetAnalogRegister($"{tank.Name}.Level");
+            var levelKey = $"{tank.Name}.Level";
+            var currentLevel = data.GetAnalogRegister(levelKey);
             var startPump1 = data.GetAnalogRegister($"{tank.Name}.StartPump1Level");
             var startPump2 = data.GetAnalogRegister($"{tank.Name}.StartPump2Level");
             var startPump3 = data.GetAnalogRegister($"{tank.Name}.StartPump3Level");
             var stopLevel = data.GetAnalogRegister($"{tank.Name}.StopLevel");
             var protectionLevel = data.GetAnalogRegister($"{tank.Name}.ProtectionLevel");
 
+            // 液位或任一启停阈值读取失败就不作决策:坏点里存的是最后一次有效值,
+            // 拿它启停泵可能踩在几分钟前的液位上(改动前读失败返回 0,会被当成"液位极低"直接启 3 台泵)
+            var unreadable = new[]
+            {
+                levelKey,
+                $"{tank.Name}.StopLevel",
+                $"{tank.Name}.ProtectionLevel",
+                $"{tank.Name}.StartPump1Level",
+                $"{tank.Name}.StartPump2Level",
+                $"{tank.Name}.StartPump3Level"
+            }.Where(data.IsTagBad).ToList();
+            if (unreadable.Count > 0)
+            {
+                // 质量跳变时轮询服务已经告警过,这里只留痕不打扰操作员
+                Log.Debug("自动控制: 测点数据质量 Bad,本轮不做泵组决策: {Keys}", string.Join(", ", unreadable));
+                return;
+            }
+
             // 计算需要的泵数量
             int requiredCount;
             if (protectionLevel > 0 && currentLevel > protectionLevel)
@@ -240,60 +266,106 @@ public class AutoControlService : IDisposable
     /// </summary>
     private async void ExecutePumpControl(ProjectProfile profile, int requiredCount, PlcDataModel data)
     {
-        var now = DateTime.Now;
-
-        // 确定应该运行的泵(考虑轮换)
-        var pumpsToRun = SelectPumpsToRun(profile, requiredCount, data);
+        // 防重入:脉冲写入含 Task.Delay,前一轮未完成时新轮询直接跳过(下轮会重新评估)
+        if (Interlocked.CompareExchange(ref _controlInFlight, 1, 0) != 0)
+        {
+            Log.Debug("自动控制: 上一轮泵指令尚未完成,本轮跳过");
+            return;
+        }
 
-        for (int i = 0; i < profile.Pumps.Count; i++)
+        try
         {
-            var pump = profile.Pumps[i];
-            var pumpKey = pump.Name;
-            var isRunning = data.GetDigitalRegister($"{pump.Name}.Run");
-            var hasFault = data.GetDigitalRegister($"{pump.Name}.Fault");
+            var now = DateTime.Now;
+
+            // 确定应该运行的泵(考虑轮换)
+            var pumpsToRun = SelectPumpsToRun(profile, requiredCount, data);
 
-            // 故障泵跳过
-            if (hasFault)
+            for (int i = 0; i < profile.Pumps.Count; i++)
             {
-                if (isRunning)
-                    Log.Warning("自动控制: {Name} 有故障但仍在运行,跳过", pump.Name);
-                continue;
-            }
+                var pump = profile.Pumps[i];
+                var pumpKey = pump.Name;
+                var isRunning = data.GetDigitalRegister($"{pump.Name}.Run");
+                var hasFault = data.GetDigitalRegister($"{pump.Name}.Fault");
 
-            var shouldRun = pumpsToRun.Contains(pump);
+                // 运行/故障状态读不到时不下发指令:无法确认设备现状就不该动它
+                if (data.IsTagBad($"{pump.Name}.Run") || data.IsTagBad($"{pump.Name}.Fault"))
+                {
+                    Log.Debug("自动控制: {Name} 运行/故障状态数据质量 Bad,本轮跳过", pump.Name);
+                    continue;
+                }
 
-            if (shouldRun && !isRunning)
-            {
-                // 需要启动但没在运行
-                if (_lastStartTimes.TryGetValue(pumpKey, out var lastStart) &&
-                    (now - lastStart).TotalSeconds < MinIntervalSeconds)
-                    continue; // 防频繁启动
-
-                Log.Information("自动控制: 启动 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
-                await PulseAddressAsync(pump.StartAddress);
-                _lastStartTimes[pumpKey] = now;
-            }
-            else if (!shouldRun && isRunning)
-            {
-                // 需要停止但在运行
-                if (_lastStopTimes.TryGetValue(pumpKey, out var lastStop) &&
-                    (now - lastStop).TotalSeconds < MinIntervalSeconds)
-                    continue; // 防频繁停止
-
-                Log.Information("自动控制: 停止 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
-                await PulseAddressAsync(pump.StopAddress);
-                _lastStopTimes[pumpKey] = now;
+                // 故障泵跳过
+                if (hasFault)
+                {
+                    if (isRunning)
+                        Log.Warning("自动控制: {Name} 有故障但仍在运行,跳过", pump.Name);
+                    continue;
+                }
+
+                var shouldRun = pumpsToRun.Contains(pump);
+
+                if (shouldRun && !isRunning)
+                {
+                    // 需要启动但没在运行
+                    if (_lastStartTimes.TryGetValue(pumpKey, out var lastStart) &&
+                        (now - lastStart).TotalSeconds < MinIntervalSeconds)
+                        continue; // 防频繁启动
+                    if (IsInRetryBackoff(pumpKey, now))
+                        continue; // 上次写入被拒,退避期内不重复下发
+
+                    Log.Information("自动控制: 启动 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
+                    // 成功才记防频繁退避;失败只按较短的重试退避,避免按轮询频率反复写
+                    if (await PulseAddressAsync(pump.StartAddress))
+                        _lastStartTimes[pumpKey] = now;
+                    else
+                    {
+                        SetRetryBackoff(pumpKey, now);
+                        Log.Error("自动控制: 启动 {Name} 写入 PLC 失败,{Sec}s 后重试", pump.Name, ControlRetrySeconds);
+                    }
+                }
+                else if (!shouldRun && isRunning)
+                {
+                    // 需要停止但在运行
+                    if (_lastStopTimes.TryGetValue(pumpKey, out var lastStop) &&
+                        (now - lastStop).TotalSeconds < MinIntervalSeconds)
+                        continue; // 防频繁停止
+                    if (IsInRetryBackoff(pumpKey, now))
+                        continue; // 上次写入被拒,退避期内不重复下发
+
+                    Log.Information("自动控制: 停止 {Name} (需要 {Required} 台)", pump.Name, requiredCount);
+                    if (await PulseAddressAsync(pump.StopAddress))
+                        _lastStopTimes[pumpKey] = now;
+                    else
+                    {
+                        SetRetryBackoff(pumpKey, now);
+                        Log.Error("自动控制: 停止 {Name} 写入 PLC 失败,{Sec}s 后重试", pump.Name, ControlRetrySeconds);
+                    }
+                }
             }
         }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "自动控制执行异常");
+        }
+        finally
+        {
+            Interlocked.Exchange(ref _controlInFlight, 0);
+        }
     }
 
+    private bool IsInRetryBackoff(string pumpKey, DateTime now) =>
+        _controlRetryAt.TryGetValue(pumpKey, out var retryAt) && (now - retryAt).TotalSeconds < ControlRetrySeconds;
+
+    private void SetRetryBackoff(string pumpKey, DateTime now) => _controlRetryAt[pumpKey] = now;
+
     /// <summary>
     /// 选择应该运行的泵(考虑轮换和故障)
     /// </summary>
     private List<PumpConfig> SelectPumpsToRun(ProjectProfile profile, int requiredCount, PlcDataModel data)
     {
+        // 故障状态读不到的泵不参与选择(质量 Bad 时寄存器里留的是最后一次有效值,不能当"无故障"用)
         var availablePumps = profile.Pumps
-            .Where(p => !data.GetDigitalRegister($"{p.Name}.Fault"))
+            .Where(p => !data.IsTagBad($"{p.Name}.Fault") && !data.GetDigitalRegister($"{p.Name}.Fault"))
             .ToList();
 
         if (requiredCount >= availablePumps.Count)
@@ -352,18 +424,27 @@ public class AutoControlService : IDisposable
     /// <summary>
     /// 点动写入(短暂置位后复位)
     /// </summary>
-    private async Task PulseAddressAsync(string? address)
+    private static async Task<bool> PulseAddressAsync(string? address)
     {
-        if (string.IsNullOrEmpty(address)) return;
+        if (string.IsNullOrEmpty(address)) return false;
         try
         {
-            await PlcService.WriteBoolAsync(address, true);
+            var on = await PlcService.WriteBoolAsync(address, true);
             await Task.Delay(200);
-            await PlcService.WriteBoolAsync(address, false);
+            var off = await PlcService.WriteBoolAsync(address, false);
+            if (!off)
+            {
+                // 复位写失败会把启动/停止位留在置位状态,必须再试一次并显式报错
+                off = await PlcService.WriteBoolAsync(address, false);
+                if (!off)
+                    Log.Error("PLC 点动复位失败,输出位可能仍置位: {Address}", address);
+            }
+            return on && off;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "点动写入失败: {Address}", address);
+            return false;
         }
     }
 

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

@@ -16,7 +16,7 @@ namespace YZWater.Core.Services;
 /// </summary>
 public static class BackupService
 {
-    private static readonly string BackupDir = "Backups";
+    private static readonly string BackupDir = AppPaths.Backups;
     private static Timer? _timer;
     private static int _backupInProgress; // 0=空闲, 1=备份中(防重入)
 
@@ -44,7 +44,7 @@ public static class BackupService
 
         try
         {
-            var dbPath = "yzwater.db";
+            var dbPath = AppPaths.Database;
             if (!File.Exists(dbPath))
             {
                 Log.Warning("数据库文件不存在,跳过备份");
@@ -126,7 +126,7 @@ public static class BackupService
                 return false;
             }
 
-            var targetPath = "yzwater.db";
+            var targetPath = AppPaths.Database;
 
             // 先备份当前数据库
             var preRestoreBackup = Path.Combine(BackupDir, $"yzwater_pre_restore_{DateTime.Now:yyyyMMdd_HHmmss}.db");

+ 163 - 10
src/YZWater.Core/Services/ConfigService.cs

@@ -23,7 +23,8 @@ public class ConfigService : IConfigService, IDisposable
     /// <summary>单例实例(供 ServiceLocator 和静态兼容方法使用)</summary>
     public static ConfigService Instance => _instance.Value;
 
-    private static readonly string ConfigFilePath = "yzwater-config.json";
+    private static readonly string ConfigFilePath = AppPaths.Config;
+    private static readonly string LastGoodConfigPath = AppPaths.ConfigLastGood;
     private SystemConfig? _config;
     private FileSystemWatcher? _watcher;
     private readonly SemaphoreSlim _ioLock = new(1, 1); // 读写互斥锁
@@ -86,6 +87,8 @@ public class ConfigService : IConfigService, IDisposable
         // 正在写入时跳过本次 FileSystemWatcher 触发(避免读到中间状态)
         if (_isSaving) return;
 
+        var migrateToProtected = false;
+        var rolledBack = false;
         _ioLock.Wait();
         try
         {
@@ -95,26 +98,57 @@ public class ConfigService : IConfigService, IDisposable
             if (File.Exists(ConfigFilePath))
             {
                 var json = File.ReadAllText(ConfigFilePath);
+                ProtectedStringJsonConverter.BeginRead();
                 _config = JsonSerializer.Deserialize<SystemConfig>(json);
+
+                // 字面量 null 也能"解析成功",但后面所有 GetConfig() 都会拿到空配置:
+                // 必须按损坏处理,否则连 .lastgood 都会被这份空配置污染
+                if (_config == null) throw new JsonException("配置文件内容为空");
+
                 Log.Information("配置文件加载成功");
+
+                // 能读出来就把它记为"上一次有效配置",下次损坏时才有可回退的副本
+                TryUpdateLastGood();
+
+                // 明文令牌等历史配置在锁外回写成密文(_ioLock 不可重入,锁内调用 Save 会死锁)
+                migrateToProtected = ProtectedStringJsonConverter.NeedsRewrite;
+
+                // 解不开的密文保持原样不覆盖,但必须让操作员在场面上知道要重填令牌
+                if (ProtectedStringJsonConverter.DecryptFailed) NotifySecretUnreadable();
             }
             else
             {
                 _config = new SystemConfig();
-                Save();
+                SaveUnlocked();
                 Log.Information("已创建默认配置文件");
             }
         }
         catch (Exception ex)
         {
             Log.Error(ex, "加载配置文件失败");
-            _config = new SystemConfig();
+            var corruptPath = PreserveCorruptConfig();
+            rolledBack = TryReadLastGood();
+            if (!rolledBack) _config = new SystemConfig();
+            if (corruptPath != null)
+                NotifyConfigCorrupt(rolledBack ? "ConfigRestoredJournal" : "ConfigCorruptJournal", corruptPath);
         }
         finally
         {
             _ioLock.Release();
         }
 
+        // 锁外回写:_ioLock 不可重入,且明文敏感配置/回退后的主文件都要落盘
+        if (rolledBack)
+        {
+            Log.Warning("已用上一次有效配置覆盖损坏的主配置文件");
+            Save();
+        }
+        else if (migrateToProtected)
+        {
+            Log.Information("检测到明文敏感配置,正在回写加密形式");
+            Save();
+        }
+
         // 触发配置变更事件
         if (_config != null)
         {
@@ -130,8 +164,29 @@ public class ConfigService : IConfigService, IDisposable
         _ioLock.Wait();
         try
         {
-            if (_config == null) return;
+            if (!SaveUnlocked()) return;
+        }
+        finally
+        {
+            _ioLock.Release();
+        }
+
+        // 触发配置变更事件
+        if (_config != null)
+            ConfigChanged?.Invoke(_config);
+    }
 
+    /// <summary>
+    /// 写入配置文件。调用者必须已持有 _ioLock——_ioLock 不可重入,
+    /// 在 Reload/Import 等已加锁的流程里再调 Save() 会自锁死。
+    /// </summary>
+    /// <returns>是否真正执行了写入</returns>
+    private bool SaveUnlocked()
+    {
+        if (_config == null) return false;
+
+        try
+        {
             _isSaving = true;
 
             // 写入临时文件 → 原子替换(避免 FileSystemWatcher 读到中间状态)
@@ -142,18 +197,114 @@ public class ConfigService : IConfigService, IDisposable
             File.Move(tempPath, ConfigFilePath, overwrite: true);
 
             Log.Information("配置文件保存成功");
-
-            // 触发配置变更事件
-            ConfigChanged?.Invoke(_config);
+            TryUpdateLastGood();
+            return true;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "保存配置文件失败");
+            return false;
         }
         finally
         {
             _isSaving = false;
-            _ioLock.Release();
+        }
+    }
+
+    /// <summary>
+    /// 把读不出来的配置文件另存一份:否则下一次保存会用默认值覆盖它,操作员改过的参数彻底丢失
+    /// </summary>
+    private string? PreserveCorruptConfig()
+    {
+        try
+        {
+            if (!File.Exists(ConfigFilePath)) return null;
+            var backup = $"{ConfigFilePath}.corrupt-{DateTime.Now:yyyyMMdd_HHmmss}";
+            File.Copy(ConfigFilePath, backup, true);
+            Log.Error("已保留无法解析的配置文件: {Path}", backup);
+            return backup;
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "保留损坏配置文件失败");
+            return null;
+        }
+    }
+
+    /// <summary>
+    /// 把当前能读出来的配置另存一份作为"上次有效配置"。
+    /// 失败只记 Debug:这是保险丝,不能反过来影响正常加载。
+    /// </summary>
+    private static void TryUpdateLastGood()
+    {
+        try
+        {
+            File.Copy(ConfigFilePath, LastGoodConfigPath, overwrite: true);
+        }
+        catch (Exception ex)
+        {
+            Log.Debug(ex, "更新配置备份副本失败");
+        }
+    }
+
+    /// <summary>
+    /// 主配置解析失败时改用"上次有效配置",避免把操作员调过的限值与液位直接归零。
+    /// </summary>
+    private bool TryReadLastGood()
+    {
+        try
+        {
+            if (!File.Exists(LastGoodConfigPath)) return false;
+
+            var json = File.ReadAllText(LastGoodConfigPath);
+            ProtectedStringJsonConverter.BeginRead();
+            var config = JsonSerializer.Deserialize<SystemConfig>(json);
+            if (config == null) return false;
+
+            _config = config;
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "读取配置备份副本失败");
+            _config = null;
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// 通知操作员"参数已回落默认值"(尽力而为,通知失败不影响配置加载)
+    /// </summary>
+    private void NotifyConfigCorrupt(string messageKey, string corruptPath)
+    {
+        try
+        {
+            var message = string.Format(LanguageService.Instance.Get(messageKey),
+                Path.GetFileName(corruptPath));
+            AlarmService.Instance.RecordJournalAlarm(message);
+            AuditService.Log("系统", "Config", message);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "配置损坏通知失败");
+        }
+    }
+
+    /// <summary>
+    /// 配置文件里的加密项解不开(通常是整目录从另一台机器/另一个 Windows 账户复制过来)。
+    /// 不回写覆盖密文,但必须让操作员看到"需要重填令牌",否则中控服务会静默变成免鉴权。
+    /// </summary>
+    private static void NotifySecretUnreadable()
+    {
+        try
+        {
+            var message = LanguageService.Instance.Get("ConfigSecretUnreadable");
+            AlarmService.Instance.RecordJournalAlarm(message);
+            AuditService.Log("系统", "Config", message);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "加密配置项不可读通知失败");
         }
     }
 
@@ -164,8 +315,10 @@ public class ConfigService : IConfigService, IDisposable
     {
         try
         {
-            var dir = Path.GetDirectoryName(ConfigFilePath);
-            var file = Path.GetFileName(ConfigFilePath);
+            // ConfigFilePath 是相对路径,Path.GetDirectoryName 会返回空串导致监听从未启动
+            var fullPath = Path.GetFullPath(ConfigFilePath);
+            var dir = Path.GetDirectoryName(fullPath);
+            var file = Path.GetFileName(fullPath);
 
             if (string.IsNullOrEmpty(dir)) return;
 

+ 7 - 5
src/YZWater.Core/Services/ConnectionStatusService.cs

@@ -50,9 +50,10 @@ public sealed class ConnectionStatusService : IDisposable
     {
         get
         {
+            var lang = LanguageService.Instance;
             var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
-            if (mode == "Mock") return "PLC: 模拟模式(无 PLC 连接)";
-            return IsPlcConnected ? "PLC: 已连接" : "PLC: 未连接";
+            if (mode == "Mock") return lang.Get("ConnPlcMock");
+            return IsPlcConnected ? lang.Get("ConnPlcConnected") : lang.Get("ConnPlcDisconnected");
         }
     }
 
@@ -60,12 +61,13 @@ public sealed class ConnectionStatusService : IDisposable
     {
         get
         {
+            var lang = LanguageService.Instance;
             var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
             return mode switch
             {
-                "Server" => IsHubServerRunning ? "中控: 服务端运行中" : "中控: 服务端已停止",
-                "Client" => IsHubClientConnected ? "中控: 已连接服务端" : "中控: 未连接",
-                _ => "中控: 直连模式"
+                "Server" => IsHubServerRunning ? lang.Get("ConnHubServerRunning") : lang.Get("ConnHubServerStopped"),
+                "Client" => IsHubClientConnected ? lang.Get("ConnHubClientConnected") : lang.Get("ConnHubClientDisconnected"),
+                _ => lang.Get("ConnHubDirect")
             };
         }
     }

+ 229 - 0
src/YZWater.Core/Services/DataGapService.cs

@@ -0,0 +1,229 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 数据缺口标记服务
+///
+/// PLC 断连期间采集停止(DataLoggingService 的 HasValidData 门闩),
+/// 断连数据无法从 S7 普通数据块回补,本服务负责把"缺失的时段"显式记录下来:
+///   1. 断连(或启动时即未连接)→ 插入 open 状态的 data_gaps 记录
+///   2. 重连成功 → 回写 EndTime,并向报警流水追加一条信息级缺口提示
+/// 缺口记录纳入 DataRetentionService 定时清理。
+/// </summary>
+public sealed class DataGapService : IDisposable
+{
+    private static DataGapService? _instance;
+    private static readonly object _lock = new();
+    public static DataGapService Instance
+    {
+        get { lock (_lock) { return _instance ??= new DataGapService(); } }
+    }
+
+    /// <summary>小于该时长的闪断不记缺口(秒)</summary>
+    private const int MinGapSeconds = 5;
+
+    private readonly SqlSugar.SqlSugarClient _bgDb = DatabaseService.CreateBackgroundClient();
+    private readonly object _gapLock = new();
+    private DataGapRecord? _openGap;
+    private bool _started;
+    private bool _disposed;
+
+    private DataGapService() { }
+
+    /// <summary>
+    /// 订阅连接状态事件并检查启动时的连接状态
+    /// (启动即断连时 PlcPollingService 不会触发状态变化事件,需主动开一条缺口)
+    /// </summary>
+    public void Start()
+    {
+        if (_started) return;
+        _started = true;
+
+        PlcPollingService.Instance.ConnectionStateChanged += OnConnectionChanged;
+
+        // 顺序执行:先闭合上次运行遗留的 open 缺口,再判断本次是否需要新开缺口,
+        // 否则并行的两步会把刚插入的本轮缺口当成遗留记录一起闭合
+        _ = StartUpAsync();
+
+        Log.Information("数据缺口标记服务已启动");
+    }
+
+    private async Task StartUpAsync()
+    {
+        await CloseOrphanGapsAsync();
+
+        if (!MockPlcService.IsEnabled && !PlcService.IsConnected)
+            await OpenGapAsync("PLC 未连接,采集停止");
+    }
+
+    /// <summary>
+    /// 闭合上次运行遗留的 open 缺口(进程被杀/崩溃时来不及回写结束时间)。
+    /// 采集已由本次启动接管,旧缺口继续挂 open 会让按时间范围查询把此后所有时段
+    /// 误判为"数据缺失",结束时间取本次启动时刻作为上界。
+    /// </summary>
+    private async Task CloseOrphanGapsAsync()
+    {
+        try
+        {
+            var now = DateTime.Now;
+            var (ok, deleted) = await DatabaseService.ExecuteWriteAsync(_bgDb,
+                db => db.Ado.ExecuteCommandAsync(
+                    "UPDATE data_gaps SET EndTime = @end, " +
+                    "Reason = COALESCE(Reason, '') || '(上次运行未正常结束)' " +
+                    "WHERE EndTime IS NULL",
+                    new { end = now }));
+            if (ok && deleted > 0)
+                Log.Warning("已闭合 {Count} 条上次运行遗留的未结束数据缺口", deleted);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "闭合遗留数据缺口失败");
+        }
+    }
+
+    private async void OnConnectionChanged(bool connected)
+    {
+        try
+        {
+            if (MockPlcService.IsEnabled) return;
+
+            if (!connected)
+            {
+                await OpenGapAsync("PLC 通信中断");
+            }
+            else
+            {
+                await CloseGapAndNotifyAsync();
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "数据缺口记录异常");
+        }
+    }
+
+    private async Task OpenGapAsync(string reason)
+    {
+        DataGapRecord gap;
+        lock (_gapLock)
+        {
+            if (_openGap != null) return; // 已在缺口中,不重复开
+            _openGap = gap = new DataGapRecord { StartTime = DateTime.Now, Reason = reason };
+        }
+
+        try
+        {
+            var (ok, id) = await DatabaseService.ExecuteWriteAsync(
+                _bgDb, db => db.Insertable(gap).ExecuteReturnIdentityAsync());
+            if (ok) gap.Id = id;
+
+            Log.Warning("数据缺口开始: {Start:yyyy-MM-dd HH:mm:ss} 原因: {Reason}", gap.StartTime, reason);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "写入数据缺口开始记录失败");
+        }
+    }
+
+    private async Task CloseGapAndNotifyAsync()
+    {
+        DataGapRecord? gap;
+        lock (_gapLock)
+        {
+            gap = _openGap;
+            _openGap = null;
+        }
+        if (gap == null) return;
+
+        var now = DateTime.Now;
+        var span = now - gap.StartTime;
+
+        try
+        {
+            if (span.TotalSeconds < MinGapSeconds)
+            {
+                // 闪断:删除已插入的 open 记录
+                if (gap.Id > 0)
+                    await DatabaseService.ExecuteWriteAsync(_bgDb, db => db.Deleteable<DataGapRecord>()
+                        .Where(r => r.Id == gap.Id).ExecuteCommandAsync());
+                Log.Information("PLC 闪断 {Sec:F1}s,不记录缺口", span.TotalSeconds);
+                return;
+            }
+
+            var id = gap.Id;
+            await DatabaseService.ExecuteWriteAsync(_bgDb, db => db.Updateable<DataGapRecord>()
+                .SetColumns(r => r.EndTime, now)
+                .Where(r => r.Id == id)
+                .ExecuteCommandAsync());
+
+            gap.EndTime = now;
+            Log.Warning("数据缺口结束: {Start:HH:mm:ss} ~ {End:HH:mm:ss}({Duration})",
+                gap.StartTime, now, gap.DurationText);
+
+            // 追加信息级报警流水,提示操作员该时段数据缺失
+            var message = string.Format(LanguageService.Instance.Get("DataGapJournal"),
+                gap.StartTime.ToString("MM-dd HH:mm:ss"), now.ToString("HH:mm:ss"), gap.DurationText);
+            AlarmService.Instance.RecordJournalAlarm(message);
+            AuditService.Log("系统", "DataGap", message);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "回写数据缺口结束时间失败");
+        }
+    }
+
+    /// <summary>
+    /// 查询指定时间范围内的数据缺口
+    /// </summary>
+    public static async Task<List<DataGapRecord>> GetGapsAsync(DateTime start, DateTime end)
+    {
+        try
+        {
+            return await DatabaseService.Db.Queryable<DataGapRecord>()
+                .Where(r => r.StartTime >= start && r.StartTime <= end)
+                .OrderByDescending(r => r.StartTime)
+                .ToListAsync();
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "查询数据缺口失败");
+            return new List<DataGapRecord>();
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        PlcPollingService.Instance.ConnectionStateChanged -= OnConnectionChanged;
+
+        // 正常退出:采集在此刻停止,按退出时刻闭合,避免留下永久 open 的记录
+        try
+        {
+            DataGapRecord? gap;
+            lock (_gapLock)
+            {
+                gap = _openGap;
+                _openGap = null;
+            }
+            if (gap != null)
+            {
+                var now = DateTime.Now;
+                DatabaseService.ExecuteWriteAsync(_bgDb, db => db.Updateable<DataGapRecord>()
+                    .SetColumns(r => r.EndTime, now)
+                    .Where(r => r.Id == gap.Id)
+                    .ExecuteCommandAsync()).GetAwaiter().GetResult();
+                Log.Information("退出时闭合数据缺口: {Start:MM-dd HH:mm:ss} ~ {End:HH:mm:ss}", gap.StartTime, now);
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "退出时闭合数据缺口失败");
+        }
+
+        try { _bgDb.Dispose(); } catch { }
+        Log.Information("数据缺口标记服务已停止");
+    }
+}

+ 54 - 26
src/YZWater.Core/Services/DataLoggingService.cs

@@ -160,8 +160,10 @@ public class DataLoggingService : IDisposable
                 if (flowTimer >= FlowLogIntervalSeconds)
                 {
                     flowTimer = 0;
-                    // 并行写入(独立 DB 操作,互不依赖)
-                    await Task.WhenAll(LogFlowDataAsync(), LogAnalogDataAsync());
+                    // 顺序写入:两者共用同一个 _bgDb(非线程安全的 SqlSugarClient),
+                    // 并发写会互踩连接内部状态(SqliteConnection NRE)
+                    await LogFlowDataAsync();
+                    await LogAnalogDataAsync();
                 }
 
                 // 定期记录设备状态
@@ -230,6 +232,15 @@ public class DataLoggingService : IDisposable
                 return;
             }
 
+            // flow_records 的进/出水字段只由默认模式的硬编码地址表(VD200/VD204)填充,
+            // 档案模式下永远是 0:写进去就是"那段时间流量一直是 0"的假历史,累计量也会冻结。
+            // 档案项目的流量趋势改由 profile.Statistics 配置的测点写入 analog_records。
+            if (ProjectService.Current != null)
+            {
+                Log.Debug("已加载项目档案,传统进/出水流量测点无数据来源,跳过流量记录");
+                return;
+            }
+
             var (totalIn, totalOut) = AccumulateFlow(data.InflowRate, data.OutflowRate);
 
             var record = new FlowRecord
@@ -281,6 +292,13 @@ public class DataLoggingService : IDisposable
                     // 从动态寄存器读取值(TagName 与 AnalogRegisters key 约定一致)
                     if (data.AnalogRegisters.TryGetValue(item.Key, out var value))
                     {
+                        // 质量 Bad 的测点里是最后一次有效值,写进历史库会变成"那段时间一直是这个值"的假数据
+                        if (data.IsTagBad(item.Key))
+                        {
+                            Log.Debug("测点 {Tag} 数据质量 Bad,本次不记录", item.Key);
+                            continue;
+                        }
+
                         records.Add(new AnalogRecord
                         {
                             RecordTime = now,
@@ -339,8 +357,13 @@ public class DataLoggingService : IDisposable
 
             var records = new List<EquipmentStatus>();
 
+            // 模板模式:按项目实际泵数记录,避免写入不存在的设备;风机仅旧版硬编码模式记录
+            var profile = ProjectService.Current;
+            bool templateMode = profile?.Pumps is { Count: > 0 };
+            int pumpCount = templateMode ? Math.Min(profile!.Pumps.Count, 5) : 5;
+
             // 记录泵状态
-            for (int i = 0; i < 5; i++)
+            for (int i = 0; i < pumpCount; i++)
             {
                 var deviceId = $"P{i + 1:D3}";
                 var isRunning = data.GetPumpRunning(i);
@@ -354,7 +377,9 @@ public class DataLoggingService : IDisposable
                 var status = new EquipmentStatus
                 {
                     DeviceId = deviceId,
-                    DeviceName = string.Format(LanguageService.Instance.Get("PumpDeviceName"), i + 1),
+                    DeviceName = templateMode && !string.IsNullOrEmpty(profile!.Pumps[i].Name)
+                        ? profile.Pumps[i].Name.GetDisplayName(profile.Pumps[i].Names)
+                        : string.Format(LanguageService.Instance.Get("PumpDeviceName"), i + 1),
                     Status = data.GetPumpFault(i) ? DeviceStatus.Fault :
                              isRunning ? DeviceStatus.Running : DeviceStatus.Stopped,
                     IsOnline = !data.GetPumpFault(i),
@@ -366,31 +391,34 @@ public class DataLoggingService : IDisposable
                 records.Add(status);
             }
 
-            // 记录风机状态
-            for (int i = 0; i < 2; i++)
+            // 记录风机状态(仅旧版无模板模式)
+            if (!templateMode)
             {
-                var deviceId = $"F{i + 1:D3}";
-                var isRunning = i == 0 ? data.Fan1Running : data.Fan2Running;
-                var hasFault = i == 0 ? data.Fan1Fault : data.Fan2Fault;
+                for (int i = 0; i < 2; i++)
+                {
+                    var deviceId = $"F{i + 1:D3}";
+                    var isRunning = i == 0 ? data.Fan1Running : data.Fan2Running;
+                    var hasFault = i == 0 ? data.Fan1Fault : data.Fan2Fault;
 
-                if (!_runningHoursCache.ContainsKey(deviceId))
-                    _runningHoursCache[deviceId] = 0;
-                if (isRunning)
-                    _runningHoursCache[deviceId] += intervalHours;
+                    if (!_runningHoursCache.ContainsKey(deviceId))
+                        _runningHoursCache[deviceId] = 0;
+                    if (isRunning)
+                        _runningHoursCache[deviceId] += intervalHours;
 
-                var status = new EquipmentStatus
-                {
-                    DeviceId = deviceId,
-                    DeviceName = string.Format(LanguageService.Instance.Get("FanDeviceName"), i + 1),
-                    Status = hasFault ? DeviceStatus.Fault :
-                             isRunning ? DeviceStatus.Running : DeviceStatus.Stopped,
-                    IsOnline = !hasFault,
-                    RunningHours = _runningHoursCache[deviceId],
-                    LastUpdateTime = now,
-                    Value = 0,
-                    Unit = ""
-                };
-                records.Add(status);
+                    var status = new EquipmentStatus
+                    {
+                        DeviceId = deviceId,
+                        DeviceName = string.Format(LanguageService.Instance.Get("FanDeviceName"), i + 1),
+                        Status = hasFault ? DeviceStatus.Fault :
+                                 isRunning ? DeviceStatus.Running : DeviceStatus.Stopped,
+                        IsOnline = !hasFault,
+                        RunningHours = _runningHoursCache[deviceId],
+                        LastUpdateTime = now,
+                        Value = 0,
+                        Unit = ""
+                    };
+                    records.Add(status);
+                }
             }
 
             // 批量插入数据库

+ 31 - 1
src/YZWater.Core/Services/DataRetentionService.cs

@@ -18,6 +18,8 @@ namespace YZWater.Core.Services;
 ///   - flow_records(流量记录,默认 360 天)
 ///   - analog_records(模拟量记录,默认 360 天)
 ///   - audit_logs(审计日志,默认 365 天)
+///   - equipment_status(设备状态记录,默认 360 天)
+///   - data_gaps(数据缺口记录,默认 365 天)
 /// </summary>
 public static class DataRetentionService
 {
@@ -95,11 +97,37 @@ public static class DataRetentionService
                 Log.Error(ex, "清理审计日志失败");
             }
 
+            // 5. 清理设备状态记录
+            try
+            {
+                var retentionDays = config?.DataRetention?.EquipmentStatusRetentionDays ?? 360;
+                var deleted = await CleanupTableAsync<EquipmentStatus>(retentionDays);
+                totalDeleted += deleted;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "清理设备状态记录失败");
+            }
+
+            // 6. 清理数据缺口记录
+            try
+            {
+                var retentionDays = config?.DataRetention?.DataGapRetentionDays ?? 365;
+                var deleted = await CleanupTableAsync<DataGapRecord>(retentionDays);
+                totalDeleted += deleted;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "清理数据缺口记录失败");
+            }
+
             Log.Information($"数据保留清理完成: 共删除 {totalDeleted} 条过期记录" +
                          $"(报警 {config?.DataRetention?.AlarmRecordRetentionDays ?? 90}天, " +
                          $"流量 {config?.DataRetention?.FlowRecordRetentionDays ?? 360}天, " +
                          $"模拟量 {config?.DataRetention?.AnalogRecordRetentionDays ?? 360}天, " +
-                         $"审计 {config?.DataRetention?.AuditLogRetentionDays ?? 365}天)");
+                         $"审计 {config?.DataRetention?.AuditLogRetentionDays ?? 365}天, " +
+                         $"设备状态 {config?.DataRetention?.EquipmentStatusRetentionDays ?? 360}天, " +
+                         $"数据缺口 {config?.DataRetention?.DataGapRetentionDays ?? 365}天)");
 
             CleanupCompleted?.Invoke(totalDeleted);
             return totalDeleted;
@@ -124,6 +152,8 @@ public static class DataRetentionService
             Type t when t == typeof(AlarmRecord) => nameof(AlarmRecord.AlarmTime),
             Type t when t == typeof(FlowRecord) => nameof(FlowRecord.RecordTime),
             Type t when t == typeof(AnalogRecord) => nameof(AnalogRecord.RecordTime),
+            Type t when t == typeof(EquipmentStatus) => nameof(EquipmentStatus.LastUpdateTime),
+            Type t when t == typeof(DataGapRecord) => nameof(DataGapRecord.StartTime),
             _ => "RecordTime"
         };
 

+ 5 - 3
src/YZWater.Core/Services/DatabaseService.cs

@@ -10,7 +10,7 @@ namespace YZWater.Core.Services;
 public static class DatabaseService
 {
     private static SqlSugarScope? _db;
-    private static readonly string DbPath = "yzwater.db";
+    private static readonly string DbPath = AppPaths.Database;
 
     /// <summary>数据库是否已释放(后台写入任务退出前检查,避免用已关闭的连接写库)</summary>
     public static bool IsDisposed { get; private set; }
@@ -113,7 +113,7 @@ public static class DatabaseService
         // 逐个初始化表,单个失败不删除数据也不影响其他表
         var tables = new[] { typeof(Person), typeof(AlarmRecord), typeof(FlowRecord),
                              typeof(EquipmentStatus), typeof(AuditLog), typeof(User),
-                             typeof(AnalogRecord) };
+                             typeof(AnalogRecord), typeof(DataGapRecord) };
 
         foreach (var tableType in tables)
         {
@@ -162,7 +162,9 @@ public static class DatabaseService
             "CREATE INDEX IF NOT EXISTS idx_equip_device_id ON equipment_status(DeviceId)",
             "CREATE INDEX IF NOT EXISTS idx_equip_update_time ON equipment_status(LastUpdateTime)",
             "CREATE INDEX IF NOT EXISTS idx_analog_record_time ON analog_records(RecordTime)",
-            "CREATE INDEX IF NOT EXISTS idx_analog_tag_name ON analog_records(TagName)"
+            "CREATE INDEX IF NOT EXISTS idx_analog_tag_name ON analog_records(TagName)",
+            "CREATE INDEX IF NOT EXISTS idx_data_gap_start ON data_gaps(StartTime)",
+            "CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(Timestamp)"
         };
 
         foreach (var sql in indexSqls)

+ 21 - 8
src/YZWater.Core/Services/DeviceStateMachine.cs

@@ -445,6 +445,10 @@ public class DeviceStateMachineManager
     /// </summary>
     private void OnPlcDataUpdated(PlcDataModel data)
     {
+        // 数据无效(断连/整轮读取失败)时冻结状态机:此时寄存器里留的是断连前的值,
+        // 若继续驱动会把断连前的真实故障误判为"已恢复",进而清除设备故障报警并谎报在线
+        if (!data.HasValidData) return;
+
         var profile = ProjectService.Current;
 
         if (profile != null)
@@ -455,8 +459,11 @@ public class DeviceStateMachineManager
                 var sm = Get($"Pump_{pump.Name}");
                 if (sm == null) continue;
 
-                var running = data.GetDigitalRegister($"{pump.Name}.Run");
-                var fault = data.GetDigitalRegister($"{pump.Name}.Fault");
+                var runKey = $"{pump.Name}.Run";
+                var faultKey = $"{pump.Name}.Fault";
+                // 质量 Bad 的点位传 null:寄存器里留的是最后一次有效值,不能当成实时状态驱动转换
+                var running = data.IsTagBad(runKey) ? (bool?)null : data.GetDigitalRegister(runKey);
+                var fault = data.IsTagBad(faultKey) ? (bool?)null : data.GetDigitalRegister(faultKey);
 
                 UpdateDeviceState(sm, running, fault);
             }
@@ -480,21 +487,27 @@ public class DeviceStateMachineManager
         if (sm != null) UpdateDeviceState(sm, running, fault);
     }
 
-    private static void UpdateDeviceState(DeviceStateMachine sm, bool running, bool fault)
+    private static void UpdateDeviceState(DeviceStateMachine sm, bool? running, bool? fault)
     {
-        if (fault && sm.CurrentState != DeviceStateMachine.DeviceState.Fault)
+        if (fault == true)
         {
-            sm.TryFire(DeviceStateMachine.DeviceTrigger.Fault);
+            if (sm.CurrentState != DeviceStateMachine.DeviceState.Fault)
+                sm.TryFire(DeviceStateMachine.DeviceTrigger.Fault);
+            return;
         }
-        else if (!fault && running && sm.CurrentState == DeviceStateMachine.DeviceState.Stopped)
+
+        // 故障位读取质量 Bad(null)时冻结:无法确认故障已消除,也不能据此判断启停
+        if (fault == null) return;
+
+        if (running == true && sm.CurrentState == DeviceStateMachine.DeviceState.Stopped)
         {
             sm.TryFire(DeviceStateMachine.DeviceTrigger.Start);
         }
-        else if (!fault && !running && sm.CurrentState == DeviceStateMachine.DeviceState.Running)
+        else if (running == false && sm.CurrentState == DeviceStateMachine.DeviceState.Running)
         {
             sm.TryFire(DeviceStateMachine.DeviceTrigger.Stop);
         }
-        else if (!fault && sm.CurrentState == DeviceStateMachine.DeviceState.Fault)
+        else if (sm.CurrentState == DeviceStateMachine.DeviceState.Fault)
         {
             sm.TryFire(DeviceStateMachine.DeviceTrigger.Recover);
         }

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

@@ -171,6 +171,25 @@ public partial class LanguageService : ObservableObject
         ["AckTime"] = "确认时间",
         ["AckBy"] = "确认人",
         ["AlarmDetail"] = "报警详情",
+        ["RecoverTime"] = "恢复时间",
+        ["Duration"] = "持续时长",
+        ["Shelve"] = "搁置",
+        ["Unshelve"] = "取消搁置",
+        ["ShelveMinutes"] = "搁置分钟",
+        ["AlarmFloodDetected"] = "报警泛溢:{0} 秒内触发 {1} 条报警,后续通知已抑制(记录仍完整保存)",
+        ["AlarmFloodEnded"] = "报警泛溢结束:{0} 秒内无新报警,通知已恢复",
+        ["AlarmShelved"] = "报警已搁置 {1} 分钟: {0}",
+        ["AlarmUnshelved"] = "报警搁置已取消: {0}",
+        ["DataGapJournal"] = "数据缺口: {0} ~ {1}({2}),期间数据未采集",
+        ["ConfigCorruptJournal"] = "配置文件读取失败,已回落默认参数,请核对报警限值与启停液位(原文件已另存为 {0})",
+        ["ConfigRestoredJournal"] = "配置文件读取失败,已自动恢复为上一次有效配置,请核对参数(原文件已另存为 {0})",
+        ["ConfigSecretUnreadable"] = "配置文件中的加密项无法解密(程序目录可能由其它电脑或其它 Windows 账户复制而来),中控令牌等需重新输入,未重填前相关功能按空值运行",
+        ["HubTokenMissingJournal"] = "中控服务已在无访问令牌的情况下启动,任意客户端均可下发控制命令,请在设置中填写令牌",
+        ["TagQualityLost"] = "测点 {0} 读取失败,界面显示为最后一次有效值,请勿据此值操作",
+        ["TagQualityRestored"] = "测点 {0} 数据质量已恢复,显示为实时值",
+        ["FirstOutFlag"] = "首出",
+        ["SuppressedFlag"] = "抑制",
+        ["FlagHeader"] = "标记",
         // ViewE
         ["About"] = "关于",
         ["SystemInfo"] = "系统信息",
@@ -202,6 +221,11 @@ public partial class LanguageService : ObservableObject
         ["Active"] = "启用",
         ["Disabled"] = "禁用",
         ["Logout"] = "退出",
+        // 退出确认
+        ["ExitConfirmTitle"] = "确认退出",
+        ["ExitConfirmMessage"] = "退出后将停止数据采集与报警监控,确定退出监控系统吗?",
+        ["Confirm"] = "确定",
+        ["Cancel"] = "取消",
         // 登录页
         ["LoginTitle"] = "登录 - 污水处理监控系统",
         ["SystemName"] = "污水处理监控系统",
@@ -223,10 +247,36 @@ public partial class LanguageService : ObservableObject
         ["QuickRange"] = "快捷:",
         ["FlowTrend"] = "流量趋势",
         ["TimeHeader"] = "时间",
+        ["TypeHeader"] = "类型",
+        ["MessageHeader"] = "消息",
+        ["ValueHeader"] = "数值",
+        ["LevelHeader"] = "级别",
+        ["AckTimeHeader"] = "确认时间",
+        ["AckByHeader"] = "确认人",
+        ["RecoverTimeHeader"] = "恢复时间",
+        ["DurationHeader"] = "持续时间",
         ["InflowHeader"] = "进水 (m³/h)",
         ["OutflowHeader"] = "出水 (m³/h)",
         ["TotalInHeader"] = "累计进水 (m³)",
         ["TotalOutHeader"] = "累计出水 (m³)",
+        // 统计报表
+        ["Statistics"] = "统计报表",
+        ["DayReport"] = "日报",
+        ["MonthReport"] = "月报",
+        ["YearReport"] = "年报",
+        ["AvgHeader"] = "均值",
+        ["MaxHeader"] = "最大",
+        ["MinHeader"] = "最小",
+        ["TotalHeaderText"] = "总量",
+        ["UnitHeader"] = "单位",
+        ["ItemHeader"] = "统计项",
+        // 进出水统计卡片
+        ["FlowSummary"] = "进出水统计",
+        ["CurrentInflow"] = "当前进水",
+        ["CurrentOutflow"] = "当前出水",
+        ["TotalInflowCard"] = "累计进水",
+        ["TotalOutflowCard"] = "累计出水",
+        ["FlowDeltaCard"] = "差值",
         // 设备
         ["Pump1Title"] = "进水泵1",
         ["Pump2Title"] = "进水泵2",
@@ -323,6 +373,144 @@ public partial class LanguageService : ObservableObject
         ["ConfigFormatError"] = "配置文件格式错误",
         ["ConfigImported"] = "配置导入成功",
         ["ImportFailed"] = "导入失败: {0}",
+        // ProjectView 区块标题
+        ["TankMonitor"] = "液位监控",
+        ["GaugeMonitor"] = "仪表监控",
+        ["CumulativeData"] = "累计数据",
+        ["FaultStatus"] = "故障状态",
+        ["RecentAlarmsTitle"] = "最近报警",
+        ["NoAlarms"] = "暂无报警",
+        // ProjectView 泵组
+        ["PumpGroupControl"] = "泵组控制",
+        ["RunningCount"] = "运行",
+        ["UnitTai"] = "台",
+        ["SuggestStart"] = "建议启动 {0}(按编号顺序,优先启动)",
+        ["Suggest"] = "建议",
+        ["DailyResetAll"] = "日台时清零",
+        ["ResetAll"] = "全部复位",
+        ["Overload"] = "超载",
+        // ProjectView 泵弹窗/卡片标签
+        ["RunningStatus"] = "运行状态",
+        ["Running"] = "运行中",
+        ["CurrentLabel"] = "电流",
+        ["DailyHoursLabel"] = "日台时",
+        ["ControlModeLabel"] = "控制模式",
+        ["ManualAutoLabel"] = "手自动",
+        ["FaultLabel"] = "故障",
+        ["FaultText"] = "故障!",
+        ["Normal"] = "正常",
+        // ProjectView 控制模式
+        ["CentralMode"] = "中控模式",
+        ["ScreenMode"] = "屏控模式",
+        ["MixedMode"] = "混合模式 ({0}/{1} 中控)",
+        ["MockModeStatus"] = "模拟模式(无 PLC 连接)",
+        // ProjectView 按钮
+        ["SyncToPlc"] = "同步到PLC",
+        ["ResetAlarm"] = "复位",
+        ["Execute"] = "执行",
+        // ProjectView 阈值行标签
+        ["StartPumpN"] = "启泵{0}",
+        ["StopLevel"] = "停泵液位",
+        ["ProtectionLevel"] = "保护液位",
+        ["PressureHigh"] = "{0}高压",
+        ["PressureLow"] = "{0}低压",
+        ["PressureHighSuffix"] = "高压",
+        ["PressureLowSuffix"] = "低压",
+        // 转换器文本
+        ["CentralShort"] = "中控",
+        ["ScreenShort"] = "屏控",
+        ["AutoMode"] = "自动",
+        ["ManualMode"] = "手动",
+        ["SwitchToScreen"] = "切屏控",
+        ["SwitchToCentral"] = "切中控",
+        ["SwitchToManual"] = "切手动",
+        ["SwitchToAuto"] = "切自动",
+        ["StopPump"] = "停止",
+        ["StartPump"] = "启动",
+        // 液位档位
+        ["TankStageStop"] = "停泵区",
+        ["TankStagePump1"] = "启1泵区",
+        ["TankStagePump2"] = "启2泵区",
+        ["TankStagePump3"] = "启3泵区",
+        ["TankStageProtection"] = "保护水位",
+        // ViewF 审计/工作流
+        ["Workflows"] = "工作流",
+        ["EngineRunning"] = "引擎运行中",
+        ["EngineStopped"] = "引擎已停止",
+        ["StepCountLabel"] = "步骤数: ",
+        // MainWindow
+        ["PlcCommInterrupted"] = "PLC 通信中断 — 当前数据不可信,所有控制操作已禁用",
+        // 2D 工艺图图例
+        ["LegendRunning"] = "运行",
+        ["LegendFault"] = "故障",
+        ["LegendAlarm"] = "报警",
+        ["LegendGateValve"] = "闸阀",
+        ["LegendCheckValve"] = "止回阀",
+        ["LegendButterflyValve"] = "蝶阀",
+        ["LegendFlowMeter"] = "流量计",
+        ["LegendPressureGauge"] = "压力表",
+        ["PipePressure"] = "管道压力",
+        ["ThresholdStartPump"] = "启泵{0}",
+        ["ThresholdStopPump"] = "停泵",
+        ["ThresholdProtection"] = "保护",
+        // 控件通用
+        ["Unknown"] = "未知",
+        ["CommInterrupted"] = "通信中断",
+        // 同步/联锁提示
+        ["SyncForbidden"] = "项目配置禁止参数同步",
+        ["SyncSkippedNoRead"] = "{0} 项未读到现值,已跳过(不会写入 0)",
+        ["SyncFailed"] = "同步失败: {0}",
+        ["ControlForbidden"] = "项目配置禁止远程手动控制",
+        ["SwitchToCentralFirst"] = "当前为屏控模式,请先切换到中控",
+        ["ModeDataUntrusted"] = "中控/屏控或手/自动状态读取失败,暂时无法下发启停命令,请检查通讯后重试",
+        ["SwitchToManualFirst"] = "当前为自动模式,请先切换到手动",
+        // ViewA 设备名
+        ["LiftPump"] = "{0}#提升泵",
+        ["Blower"] = "风机{0}",
+        // CSV 导出
+        ["CsvHeaderTime"] = "时间",
+        ["CsvHeaderUser"] = "用户",
+        ["CsvHeaderAction"] = "操作",
+        ["CsvHeaderDetail"] = "详情",
+        ["CsvHeaderTarget"] = "目标",
+        ["CsvHeaderResult"] = "结果",
+        ["CsvHeaderType"] = "类型",
+        ["CsvHeaderMessage"] = "内容",
+        ["CsvHeaderAlarmValue"] = "报警值",
+        ["CsvHeaderLevel"] = "级别",
+        ["CsvHeaderConfirmed"] = "是否已确认",
+        ["CsvHeaderAckTime"] = "确认时间",
+        ["CsvHeaderAckBy"] = "确认人",
+        ["CsvHeaderRecoverTime"] = "恢复时间",
+        ["CsvHeaderDuration"] = "持续时长",
+        ["CsvYes"] = "是",
+        ["CsvNo"] = "否",
+        ["CsvHeaderInflow"] = "进水流量(m³/h)",
+        ["CsvHeaderOutflow"] = "出水流量(m³/h)",
+        ["CsvHeaderTotalIn"] = "累计进水量(m³)",
+        ["CsvHeaderTotalOut"] = "累计出水量(m³)",
+        ["CsvHeaderRemark"] = "备注",
+        // 工作流
+        ["WorkflowPending"] = "待执行",
+        ["WorkflowReady"] = "就绪",
+        // 审计日志动作
+        ["AuditSwitchToScreen"] = "全局切换屏控",
+        ["AuditSwitchToCentral"] = "全局切换中控",
+        ["AuditPumpStart"] = "泵启动",
+        ["AuditPumpStop"] = "泵停止",
+        ["AuditSwitchToManual"] = "切换手动",
+        ["AuditSwitchToAuto"] = "切换自动",
+        ["AuditWritePumpFreq"] = "写入泵频率: {0} Hz",
+        ["AuditWritePumpFreqFailed"] = "泵频率写入失败: {0} Hz",
+        // 连接状态服务
+        ["ConnPlcConnected"] = "PLC: 已连接",
+        ["ConnPlcDisconnected"] = "PLC: 未连接",
+        ["ConnPlcMock"] = "PLC: 模拟模式(无 PLC 连接)",
+        ["ConnHubServerRunning"] = "中控: 服务端运行中",
+        ["ConnHubServerStopped"] = "中控: 服务端已停止",
+        ["ConnHubClientConnected"] = "中控: 已连接服务端",
+        ["ConnHubClientDisconnected"] = "中控: 未连接",
+        ["ConnHubDirect"] = "中控: 直连模式",
     };
 
     private static readonly Dictionary<string, string> _en = new()
@@ -422,6 +610,25 @@ public partial class LanguageService : ObservableObject
         ["AckTime"] = "ACK TIME",
         ["AckBy"] = "ACK BY",
         ["AlarmDetail"] = "ALARM DETAIL",
+        ["RecoverTime"] = "RECOVER TIME",
+        ["Duration"] = "DURATION",
+        ["Shelve"] = "SHELVE",
+        ["Unshelve"] = "UNSHELVE",
+        ["ShelveMinutes"] = "Shelve minutes",
+        ["AlarmFloodDetected"] = "Alarm flood: {1} alarms raised within {0}s, further notifications suppressed (records still saved)",
+        ["AlarmFloodEnded"] = "Alarm flood ended: no new alarm within {0}s, notifications resumed",
+        ["AlarmShelved"] = "Alarm shelved for {1} min: {0}",
+        ["AlarmUnshelved"] = "Alarm unshelved: {0}",
+        ["DataGapJournal"] = "Data gap: {0} ~ {1} ({2}), data not collected during this period",
+        ["ConfigCorruptJournal"] = "Config file unreadable, defaults restored - check alarm limits and pump levels (original saved as {0})",
+        ["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",
+        ["FirstOutFlag"] = "1st",
+        ["SuppressedFlag"] = "SUPP",
+        ["FlagHeader"] = "FLAG",
         ["About"] = "ABOUT",
         ["SystemInfo"] = "SYSTEM INFO",
         ["Description"] = "Cross-platform wastewater treatment plant monitoring system based on Avalonia UI",
@@ -451,6 +658,10 @@ public partial class LanguageService : ObservableObject
         ["Active"] = "Active",
         ["Disabled"] = "Disabled",
         ["Logout"] = "Logout",
+        ["ExitConfirmTitle"] = "Confirm Exit",
+        ["ExitConfirmMessage"] = "Data logging and alarm monitoring will stop. Exit the monitoring system?",
+        ["Confirm"] = "OK",
+        ["Cancel"] = "Cancel",
         ["LoginTitle"] = "Login - Wastewater Treatment System",
         ["SystemName"] = "Wastewater Treatment System",
         ["LoginPrompt"] = "Please login to continue",
@@ -469,10 +680,36 @@ public partial class LanguageService : ObservableObject
         ["QuickRange"] = "Quick:",
         ["FlowTrend"] = "Flow Trend",
         ["TimeHeader"] = "Time",
+        ["TypeHeader"] = "Type",
+        ["MessageHeader"] = "Message",
+        ["ValueHeader"] = "Value",
+        ["LevelHeader"] = "Level",
+        ["AckTimeHeader"] = "Ack Time",
+        ["AckByHeader"] = "Ack By",
+        ["RecoverTimeHeader"] = "Recover Time",
+        ["DurationHeader"] = "Duration",
         ["InflowHeader"] = "Inflow (m³/h)",
         ["OutflowHeader"] = "Outflow (m³/h)",
         ["TotalInHeader"] = "Total In (m³)",
         ["TotalOutHeader"] = "Total Out (m³)",
+        // Statistics
+        ["Statistics"] = "Statistics",
+        ["DayReport"] = "Daily",
+        ["MonthReport"] = "Monthly",
+        ["YearReport"] = "Yearly",
+        ["AvgHeader"] = "Avg",
+        ["MaxHeader"] = "Max",
+        ["MinHeader"] = "Min",
+        ["TotalHeaderText"] = "Total",
+        ["UnitHeader"] = "Unit",
+        ["ItemHeader"] = "Item",
+        // Flow summary card
+        ["FlowSummary"] = "Flow Summary",
+        ["CurrentInflow"] = "Inflow",
+        ["CurrentOutflow"] = "Outflow",
+        ["TotalInflowCard"] = "Total In",
+        ["TotalOutflowCard"] = "Total Out",
+        ["FlowDeltaCard"] = "Delta",
         ["Pump1Title"] = "Inlet Pump 1",
         ["Pump2Title"] = "Inlet Pump 2",
         ["RefluxPump"] = "Reflux Pump",
@@ -554,5 +791,143 @@ public partial class LanguageService : ObservableObject
         ["ConfigFormatError"] = "Invalid config file format",
         ["ConfigImported"] = "Config imported successfully",
         ["ImportFailed"] = "Import failed: {0}",
+        // ProjectView section titles
+        ["TankMonitor"] = "TANK MONITOR",
+        ["GaugeMonitor"] = "GAUGE MONITOR",
+        ["CumulativeData"] = "CUMULATIVE",
+        ["FaultStatus"] = "FAULT STATUS",
+        ["RecentAlarmsTitle"] = "RECENT ALARMS",
+        ["NoAlarms"] = "No alarms",
+        // ProjectView pump group
+        ["PumpGroupControl"] = "PUMP CONTROL",
+        ["RunningCount"] = "RUNNING",
+        ["UnitTai"] = "units",
+        ["SuggestStart"] = "Suggest start {0} (priority by number)",
+        ["Suggest"] = "SUGGEST",
+        ["DailyResetAll"] = "DAILY RESET",
+        ["ResetAll"] = "Reset All",
+        ["Overload"] = "OVERLOAD",
+        // ProjectView pump popup/card labels
+        ["RunningStatus"] = "STATUS",
+        ["Running"] = "Running",
+        ["CurrentLabel"] = "Current",
+        ["DailyHoursLabel"] = "Daily Hours",
+        ["ControlModeLabel"] = "Control Mode",
+        ["ManualAutoLabel"] = "Manual/Auto",
+        ["FaultLabel"] = "Fault",
+        ["FaultText"] = "FAULT!",
+        ["Normal"] = "Normal",
+        // ProjectView control modes
+        ["CentralMode"] = "Central Control",
+        ["ScreenMode"] = "Screen Control",
+        ["MixedMode"] = "Mixed Mode ({0}/{1} Central)",
+        ["MockModeStatus"] = "Mock Mode (No PLC Connection)",
+        // ProjectView buttons
+        ["SyncToPlc"] = "SYNC TO PLC",
+        ["ResetAlarm"] = "RESET",
+        ["Execute"] = "EXEC",
+        // ProjectView threshold row labels
+        ["StartPumpN"] = "Start Pump {0}",
+        ["StopLevel"] = "Stop Level",
+        ["ProtectionLevel"] = "Protection Level",
+        ["PressureHigh"] = "{0} High",
+        ["PressureLow"] = "{0} Low",
+        ["PressureHighSuffix"] = " High",
+        ["PressureLowSuffix"] = " Low",
+        // Converter texts
+        ["CentralShort"] = "Central",
+        ["ScreenShort"] = "Screen",
+        ["AutoMode"] = "Auto",
+        ["ManualMode"] = "Manual",
+        ["SwitchToScreen"] = "To Screen",
+        ["SwitchToCentral"] = "To Central",
+        ["SwitchToManual"] = "To Manual",
+        ["SwitchToAuto"] = "To Auto",
+        ["StopPump"] = "STOP",
+        ["StartPump"] = "START",
+        // Tank stages
+        ["TankStageStop"] = "Stop",
+        ["TankStagePump1"] = "Pump 1",
+        ["TankStagePump2"] = "Pump 2",
+        ["TankStagePump3"] = "Pump 3",
+        ["TankStageProtection"] = "Protection",
+        // ViewF Audit/Workflow
+        ["Workflows"] = "Workflows",
+        ["EngineRunning"] = "Engine Running",
+        ["EngineStopped"] = "Engine Stopped",
+        ["StepCountLabel"] = "Steps: ",
+        // MainWindow
+        ["PlcCommInterrupted"] = "PLC Communication Lost — Data unreliable, all control operations disabled",
+        // 2D Process diagram legend
+        ["LegendRunning"] = "Running",
+        ["LegendFault"] = "Fault",
+        ["LegendAlarm"] = "Alarm",
+        ["LegendGateValve"] = "Gate Valve",
+        ["LegendCheckValve"] = "Check Valve",
+        ["LegendButterflyValve"] = "Butterfly Valve",
+        ["LegendFlowMeter"] = "Flow Meter",
+        ["LegendPressureGauge"] = "Pressure Gauge",
+        ["PipePressure"] = "Pipe Pressure",
+        ["ThresholdStartPump"] = "Start Pump {0}",
+        ["ThresholdStopPump"] = "Stop Pump",
+        ["ThresholdProtection"] = "Protection",
+        // Controls
+        ["Unknown"] = "Unknown",
+        ["CommInterrupted"] = "Comm Lost",
+        // Sync/Interlock messages
+        ["SyncForbidden"] = "Sync forbidden by project config",
+        ["SyncSkippedNoRead"] = "{0} item(s) have no readable current value and were skipped (0 will not be written)",
+        ["SyncFailed"] = "Sync failed: {0}",
+        ["ControlForbidden"] = "Project config forbids manual control",
+        ["SwitchToCentralFirst"] = "Screen control mode, switch to central first",
+        ["ModeDataUntrusted"] = "Control mode / manual-auto status cannot be read - start/stop commands are blocked until communication recovers",
+        ["SwitchToManualFirst"] = "Auto mode, switch to manual first",
+        // ViewA device names
+        ["LiftPump"] = "Lift Pump {0}",
+        ["Blower"] = "Blower {0}",
+        // CSV export
+        ["CsvHeaderTime"] = "Time",
+        ["CsvHeaderUser"] = "User",
+        ["CsvHeaderAction"] = "Action",
+        ["CsvHeaderDetail"] = "Detail",
+        ["CsvHeaderTarget"] = "Target",
+        ["CsvHeaderResult"] = "Result",
+        ["CsvHeaderType"] = "Type",
+        ["CsvHeaderMessage"] = "Message",
+        ["CsvHeaderAlarmValue"] = "Alarm Value",
+        ["CsvHeaderLevel"] = "Level",
+        ["CsvHeaderConfirmed"] = "Confirmed",
+        ["CsvHeaderAckTime"] = "Ack Time",
+        ["CsvHeaderAckBy"] = "Ack By",
+        ["CsvHeaderRecoverTime"] = "Recover Time",
+        ["CsvHeaderDuration"] = "Duration",
+        ["CsvYes"] = "Yes",
+        ["CsvNo"] = "No",
+        ["CsvHeaderInflow"] = "Inflow (m³/h)",
+        ["CsvHeaderOutflow"] = "Outflow (m³/h)",
+        ["CsvHeaderTotalIn"] = "Total In (m³)",
+        ["CsvHeaderTotalOut"] = "Total Out (m³)",
+        ["CsvHeaderRemark"] = "Remark",
+        // Workflow
+        ["WorkflowPending"] = "Pending",
+        ["WorkflowReady"] = "Ready",
+        // Audit log actions
+        ["AuditSwitchToScreen"] = "Switch to screen control",
+        ["AuditSwitchToCentral"] = "Switch to central control",
+        ["AuditPumpStart"] = "Pump start",
+        ["AuditPumpStop"] = "Pump stop",
+        ["AuditSwitchToManual"] = "Switch to manual",
+        ["AuditSwitchToAuto"] = "Switch to auto",
+        ["AuditWritePumpFreq"] = "Write pump frequency: {0} Hz",
+        ["AuditWritePumpFreqFailed"] = "Write pump frequency failed: {0} Hz",
+        // Connection status service
+        ["ConnPlcConnected"] = "PLC: Connected",
+        ["ConnPlcDisconnected"] = "PLC: Disconnected",
+        ["ConnPlcMock"] = "PLC: Mock Mode (No PLC)",
+        ["ConnHubServerRunning"] = "Hub: Server Running",
+        ["ConnHubServerStopped"] = "Hub: Server Stopped",
+        ["ConnHubClientConnected"] = "Hub: Connected to Server",
+        ["ConnHubClientDisconnected"] = "Hub: Disconnected",
+        ["ConnHubDirect"] = "Hub: Direct Mode",
     };
 }

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

@@ -27,7 +27,7 @@ public static class LogService
             .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
             .MinimumLevel.Override("System", LogEventLevel.Warning)
             .WriteTo.Console()
-            .WriteTo.File("Logs/log-.txt",
+            .WriteTo.File(Path.Combine(AppPaths.Logs, "log-.txt"),
                 rollingInterval: RollingInterval.Day,
                 buffered: true,            // 启用缓冲写入,提升性能
                 retainedFileCountLimit: 30) // 保留最近 30 天日志

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

@@ -143,12 +143,14 @@ public static class MockPlcService
         foreach (var key in data.AnalogRegisters.Keys)
         {
             data.AnalogRegisters[key] = SimulateAnalog(key);
+            data.SetTagQuality(key, true); // 模拟值始终"可读",否则档案模式初始化时的 Bad 质量会让消费者全部跳过
         }
 
         // 数字量寄存器:按 key 后缀匹配
         foreach (var key in data.DigitalRegisters.Keys)
         {
             data.DigitalRegisters[key] = SimulateDigital(key);
+            data.SetTagQuality(key, true);
         }
     }
 

+ 24 - 10
src/YZWater.Core/Services/ParameterSyncService.cs

@@ -31,8 +31,8 @@ public class ParameterSyncService
         public string Summary => $"同步完成: {SuccessCount}/{TotalCount} 项成功";
     }
 
-    /// <summary>同步水箱液位阈值到 PLC</summary>
-    public async Task<SyncResult> SyncTankThresholdsAsync(TankConfig tank, int pumpCount = 3, string operatorName = "Operator")
+    /// <summary>同步水箱液位阈值 + 压力阈值到 PLC(统一入口)</summary>
+    public async Task<SyncResult> SyncTankThresholdsAsync(TankConfig tank, int pumpCount = 3, GaugeConfig? pressureGauge = null, string operatorName = "Operator")
     {
         var result = new SyncResult();
 
@@ -49,6 +49,15 @@ public class ParameterSyncService
         addresses.Add((tank.StopLevelAddress, tank.StopLevel, "停泵液位"));
         addresses.Add((tank.ProtectionLevelAddress, tank.ProtectionLevel, "保护液位"));
 
+        // 合并压力高/低压地址
+        if (pressureGauge != null)
+        {
+            if (!string.IsNullOrEmpty(pressureGauge.HighSetAddress))
+                addresses.Add((pressureGauge.HighSetAddress, pressureGauge.HighAlarmSet, $"{pressureGauge.Name}高压"));
+            if (!string.IsNullOrEmpty(pressureGauge.LowSetAddress))
+                addresses.Add((pressureGauge.LowSetAddress, pressureGauge.LowAlarmSet, $"{pressureGauge.Name}低压"));
+        }
+
         foreach (var (addr, value, label) in addresses)
         {
             if (string.IsNullOrEmpty(addr) || !value.HasValue) continue;
@@ -60,21 +69,21 @@ public class ParameterSyncService
                 if (ok)
                 {
                     result.SuccessCount++;
-                    result.Details.Add($"✓ {tank.Name}.{label} → {addr} = {value.Value:F2}");
+                    result.Details.Add($"✓ {label} → {addr} = {value.Value:F2}");
                 }
                 else
                 {
-                    result.Details.Add($"✗ {tank.Name}.{label} → {addr} 写入失败");
+                    result.Details.Add($"✗ {label} → {addr} 写入失败");
                 }
             }
             catch (Exception ex)
             {
-                Log.Error(ex, "参数同步失败: {Tank}.{Label} → {Addr}", tank.Name, label, addr);
-                result.Details.Add($"✗ {tank.Name}.{label} → {addr} 异常: {ex.Message}");
+                Log.Error(ex, "参数同步失败: {Label} → {Addr}", label, addr);
+                result.Details.Add($"✗ {label} → {addr} 异常: {ex.Message}");
             }
         }
 
-        AuditService.Log(operatorName, "参数同步", $"水箱阈值: {result.SuccessCount}/{result.TotalCount}", tank.Name,
+        AuditService.Log(operatorName, "参数同步", $"液位+压力: {result.SuccessCount}/{result.TotalCount}", tank.Name,
             result.SuccessCount == result.TotalCount ? "Success" : "Partial");
 
         return result;
@@ -158,16 +167,21 @@ public class ParameterSyncService
             return result;
         }
 
-        // 同步所有水箱阈值
+        // 压力仪表(已合并到液位同步,此处记录以跳过重复写入)
+        var pressureGauge = profile.Gauges.FirstOrDefault(g =>
+            !string.IsNullOrEmpty(g.HighSetAddress) || !string.IsNullOrEmpty(g.LowSetAddress));
+
+        // 同步所有水箱阈值(含压力高低压)
         foreach (var tank in profile.Tanks)
         {
-            var tankResult = await SyncTankThresholdsAsync(tank, profile.Pumps.Count, operatorName);
+            var tankResult = await SyncTankThresholdsAsync(tank, profile.Pumps.Count, pressureGauge, operatorName);
             result.Merge(tankResult);
         }
 
-        // 同步所有仪表阈值
+        // 同步其余仪表(跳过已合并的压力仪表)
         foreach (var gauge in profile.Gauges)
         {
+            if (gauge == pressureGauge) continue;
             var gaugeResult = await SyncGaugeThresholdsAsync(gauge, operatorName);
             result.Merge(gaugeResult);
         }

+ 6 - 6
src/YZWater.Core/Services/PlcDrivers/ModbusTcpDriver.cs

@@ -73,7 +73,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult<byte[]>> ReadBytesAsync(string address, ushort length)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult<byte[]>("PLC 未连接");
+            return new OperateResult<byte[]>(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {
@@ -88,7 +88,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult<float>> ReadFloatAsync(string address)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult<float>("PLC 未连接");
+            return new OperateResult<float>(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {
@@ -104,7 +104,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult<bool>> ReadBoolAsync(string address)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult<bool>("PLC 未连接");
+            return new OperateResult<bool>(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {
@@ -120,7 +120,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult> WriteAsync(string address, byte[] value)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult("PLC 未连接");
+            return new OperateResult(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {
@@ -135,7 +135,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult> WriteFloatAsync(string address, float value)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult("PLC 未连接");
+            return new OperateResult(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {
@@ -150,7 +150,7 @@ public class ModbusTcpDriver : IPlcDriver
     public async Task<OperateResult> WriteBoolAsync(string address, bool value)
     {
         if (_plc == null || !_isConnected)
-            return new OperateResult("PLC 未连接");
+            return new OperateResult(LanguageService.Instance.Get("ConnPlcDisconnected"));
 
         try
         {

+ 133 - 54
src/YZWater.Core/Services/PlcPollingService.cs

@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
 using CommunityToolkit.Mvvm.ComponentModel;
 using Serilog;
 using YZWater.Core.Models;
@@ -35,6 +36,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
     // PLC 读取并发限制(防止同时发起过多请求导致 PLC 过载)
     private static readonly SemaphoreSlim _plcReadSemaphore = new(4, 4);
 
+    // 已因质量转 Bad 而告警过的测点,用于恢复时补一条"已恢复"(RefreshNowAsync 可能与轮询线程并发)
+    private readonly ConcurrentDictionary<string, byte> _qualityAlarmedTags = new();
+
     // ─── 配置 ───
     public int PollingIntervalMs { get; set; } = 1000;
     public float DeadbandPercent { get; set; } = 0.5f;
@@ -108,6 +112,20 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         const int degradedModeRetryIntervalMs = 30000;  // 降级模式下 30 秒重试一次
         var mockStateNotified = false; // Mock 模式下只触发一次连接状态事件
 
+        // 连接状态变化检测 + 数据更新通知(正常轮询与连接失败路径都要走,
+        // 否则从未连接成功的会话不会触发 PLC_Connection 报警)
+        void NotifyStateAndData()
+        {
+            var currentConnected = MockPlcService.IsEnabled ? false : PlcService.IsConnected;
+            if (currentConnected != _lastNotifiedConnected)
+            {
+                _lastNotifiedConnected = currentConnected;
+                _data.IsPlcConnected = currentConnected;
+                ConnectionStateChanged?.Invoke(currentConnected);
+            }
+            DataUpdated?.Invoke(_data);
+        }
+
         while (!ct.IsCancellationRequested)
         {
             try
@@ -139,6 +157,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                             _data.IsPlcConnected = false;
                             _data.IsDataValid = false;
 
+                            // 连接失败同样要通知消费者(报警/UI),否则从未连接成功的会话不会触发 PLC_Connection 报警
+                            NotifyStateAndData();
+
                             // 进入或保持在降级模式
                             if (reconnectAttempts >= MaxReconnectAttempts)
                             {
@@ -191,18 +212,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                     await ReadAllDataAsync(ct);
                 }
 
-                // 检测连接状态变化(PlcService 在读取失败时已将 IsConnected 置为 false)
+                // 检测连接状态变化(链路中断由 ReadAllData* 在整轮读取全失败时判定)
                 // 通知订阅 ConnectionStateChanged 的 ViewModel(MainViewModel / ViewAViewModel 等)
-                var currentConnected = MockPlcService.IsEnabled ? false : PlcService.IsConnected;
-                if (currentConnected != _lastNotifiedConnected)
-                {
-                    _lastNotifiedConnected = currentConnected;
-                    _data.IsPlcConnected = currentConnected;
-                    ConnectionStateChanged?.Invoke(currentConnected);
-                }
-
-                // 数据更新事件
-                DataUpdated?.Invoke(_data);
+                NotifyStateAndData();
 
                 // 等待下一次轮询(降级模式下使用更长间隔)
                 var nextDelay = isInDegradedMode ? degradedModeRetryIntervalMs : PollingIntervalMs;
@@ -217,6 +229,9 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 Log.Error(ex, "轮询异常");
                 ErrorOccurred?.Invoke($"轮询异常: {ex.Message}");
 
+                // 异常周期数据不可信,立即标记无效(过期值不得进记录/自动控制)
+                _data.IsDataValid = false;
+
                 // 连接断开,尝试重连
                 if (!PlcService.IsConnected)
                 {
@@ -280,30 +295,35 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         if (ct.IsCancellationRequested) return;
 
         // 并行发起所有批量读取(使用 PlcConfig 中的地址常量)
-        var tankTask = PlcService.ReadBytesAsync(PlcConfig.Tank1Level, 16);   // 4 floats: 水箱液位
-        var flowTask = PlcService.ReadBytesAsync(PlcConfig.InflowRate, 8);   // 2 floats: 流量
-        var freqTask = PlcService.ReadBytesAsync(PlcConfig.Pump1Freq, 20);   // 5 floats: 泵频率
-        var qTask = PlcService.ReadBytesAsync("Q0", 1);                      // 1 byte: 泵/风机运行状态
-        var iTask = PlcService.ReadBytesAsync("I0", 1);                      // 1 byte: 泵/风机故障状态
-        var mTask = PlcService.ReadBytesAsync("M0", 1);                      // 1 byte: 系统模式
+        var tankTask = PlcService.TryReadBytesAsync(PlcConfig.Tank1Level, 16);   // 4 floats: 水箱液位
+        var flowTask = PlcService.TryReadBytesAsync(PlcConfig.InflowRate, 8);   // 2 floats: 流量
+        var freqTask = PlcService.TryReadBytesAsync(PlcConfig.Pump1Freq, 20);   // 5 floats: 泵频率
+        var qTask = PlcService.TryReadBytesAsync("Q0", 1);                      // 1 byte: 泵/风机运行状态
+        var iTask = PlcService.TryReadBytesAsync("I0", 1);                      // 1 byte: 泵/风机故障状态
+        var mTask = PlcService.TryReadBytesAsync("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;
+        var (tankOk, tankData) = tankTask.Result;
+        var (flowOk, flowData) = flowTask.Result;
+        var (freqOk, freqData) = freqTask.Result;
+        var (qOk, qData) = qTask.Result;
+        var (iOk, iData) = iTask.Result;
+        var (mOk, mData) = mTask.Result;
 
-        // 任一批量读取失败则跳过本周期(全部 6 个都检查)
-        if (tankData == null || flowData == null || freqData == null ||
+        // 任一读取失败则跳过本周期(硬编码地址表固定,部分失败说明链路不稳)
+        if (!tankOk || !flowOk || !freqOk || !qOk || !iOk || !mOk ||
+            tankData == null || flowData == null || freqData == null ||
             qData == null || iData == null || mData == null)
         {
+            var failedGroups = new[] { tankOk, flowOk, freqOk, qOk, iOk, mOk }.Count(ok => !ok);
+            if (failedGroups == 6)
+                PlcService.MarkLinkDown("本轮 6 个数据区全部读取失败");
+
             _data.IsDataValid = false;
-            _data.IsPlcConnected = PlcService.IsConnected; // 同步连接状态(PlcService 在读取失败时已更新)
+            _data.IsPlcConnected = PlcService.IsConnected; // 同步连接状态(链路中断由上面判定)
             return;
         }
 
@@ -520,13 +540,12 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             await _plcReadSemaphore.WaitAsync(ct);
             try
             {
-                float value;
                 var key = r.key;
 
-                // 优先使用批量读取结果
+                // 优先使用批量读取结果(批量读到的即为成功,质量 Good)
                 if (batchResults != null && batchResults.TryGetValue(key, out var batchValue))
                 {
-                    return (key, Value: batchValue);
+                    return (key, Value: batchValue, Readable: true);
                 }
 
                 // 检查是否为整数类型(key 格式:name#DATATYPE)
@@ -541,11 +560,10 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         var dataType = possibleDataType;
 
                         // 读取字节后手动转换
-                        var bytes = await PlcService.ReadBytesAsync(r.addr, dataType is "INT" or "WORD" ? (ushort)2 : (ushort)4);
-                        if (bytes == null || bytes.Length == 0)
+                        var (ok, bytes) = await PlcService.TryReadBytesAsync(r.addr, dataType is "INT" or "WORD" ? (ushort)2 : (ushort)4);
+                        if (!ok || bytes == null || bytes.Length == 0)
                         {
-                            Log.Warning("整数读取失败: {Address}, 类型={DataType}", r.addr, dataType);
-                            return (actualKey, Value: 0f);
+                            return (actualKey, Value: 0f, Readable: false);
                         }
 
                         // S7 PLC 使用大端序,需要转换为小端序(Windows)
@@ -554,7 +572,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                             Array.Reverse(bytes);
                         }
 
-                        value = dataType switch
+                        var parsed = dataType switch
                         {
                             "INT" => (float)BitConverter.ToInt16(bytes, 0),
                             "WORD" => (float)BitConverter.ToUInt16(bytes, 0),
@@ -567,24 +585,30 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                         if (actualKey.Contains("DailyHours") || actualKey.Contains("DailyMinutes"))
                         {
                             Log.Information("运行时间解析(单独): {Key} = {Value} (地址={Address}, 类型={DataType}, 原始字节={Bytes})",
-                                actualKey, value, r.addr, dataType, BitConverter.ToString(bytes));
+                                actualKey, parsed, r.addr, dataType, BitConverter.ToString(bytes));
                         }
 
-                        key = actualKey;
+                        return (actualKey, Value: parsed, Readable: true);
                     }
                     else
                     {
-                        // 不是有效的数据类型,按 REAL 处理
-                        value = await PlcService.ReadFloatAsync(r.addr);
+                        // 不是有效的数据类型后缀,按 REAL 处理
+                        var (ok, v) = await PlcService.TryReadFloatAsync(r.addr);
+                        return (key, Value: v, Readable: ok);
                     }
                 }
                 else
                 {
                     // REAL 类型直接读浮点数
-                    value = await PlcService.ReadFloatAsync(r.addr);
+                    var (ok, v) = await PlcService.TryReadFloatAsync(r.addr);
+                    return (key, Value: v, Readable: ok);
                 }
-
-                return (key, Value: value);
+            }
+            catch (Exception ex)
+            {
+                // 单个测点读取异常只说明该点不可信,不能污染其它测点
+                Log.Debug(ex, "测点 {Key} 读取异常", r.key);
+                return (r.key, Value: 0f, Readable: false);
             }
             finally
             {
@@ -596,8 +620,13 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             await _plcReadSemaphore.WaitAsync(ct);
             try
             {
-                var value = await PlcService.ReadBoolAsync(r.addr);
-                return (r.key, Value: value);
+                var (ok, value) = await PlcService.TryReadBoolAsync(r.addr);
+                return (r.key, Value: value, Readable: ok);
+            }
+            catch (Exception ex)
+            {
+                Log.Debug(ex, "测点 {Key} 读取异常", r.key);
+                return (r.key, Value: false, Readable: false);
             }
             finally
             {
@@ -611,28 +640,77 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
 
         if (ct.IsCancellationRequested) return;
 
-        // 更新模拟量寄存器
-        foreach (var task in analogTasks)
+        var analogResults = analogTasks.Select(t => t.Result).ToArray();
+        var digitalResults = digitalTasks.Select(t => t.Result).ToArray();
+
+        // 先判定链路:整轮全部失败说明不是个别测点的问题,而是链路断了。
+        // 这一步必须早于逐点质量告警,否则断链瞬间几十个测点会各自报一条"数据质量差"。
+        var totalTags = analogResults.Length + digitalResults.Length;
+        if (totalTags == 0)
         {
-            var (key, value) = await task;
+            // 档案里一个地址都没配:本轮什么都没读到,不能声称"数据有效",
+            // 否则记录/自动控制会拿一堆初始 0 值继续跑(界面看起来是全零的正常数据)
+            Log.Debug("当前档案未配置任何 PLC 地址,本轮数据视为无效");
+            _data.IsDataValid = false;
+            _data.IsPlcConnected = PlcService.IsConnected;
+            return;
+        }
+
+        var failedCount = analogResults.Count(r => !r.Readable) + digitalResults.Count(r => !r.Readable);
+        var linkDown = failedCount == totalTags;
+        if (linkDown)
+            PlcService.MarkLinkDown($"本轮 {totalTags} 个测点全部读取失败");
+
+        // 更新模拟量寄存器:读到的才覆盖,读不到的保留上一次有效值并标记质量为 Bad
+        foreach (var (key, value, readable) in analogResults)
+        {
+            ApplyTagQuality(key, readable, notify: !linkDown);
+            if (!readable) continue;
+
             if (_data.AnalogRegisters.TryGetValue(key, out var currentValue))
-            {
                 ApplyWithDeadband(currentValue, value, v => _data.AnalogRegisters[key] = v);
-            }
+            else
+                _data.AnalogRegisters[key] = value;
         }
 
         // 更新数字量寄存器
-        foreach (var task in digitalTasks)
+        foreach (var (key, value, readable) in digitalResults)
         {
-            var (key, value) = await task;
-            _data.DigitalRegisters[key] = value;
+            ApplyTagQuality(key, readable, notify: !linkDown);
+            if (readable) _data.DigitalRegisters[key] = value;
         }
 
+        // 个别测点读不到不影响其余数据的有效性(改动前一个坏地址会让整轮作废)
         _data.LastReadTime = DateTime.Now;
-        _data.IsDataValid = true;
+        _data.IsDataValid = PlcService.IsConnected;
         _data.IsPlcConnected = PlcService.IsConnected;
     }
 
+    /// <summary>
+    /// 记录单个测点质量。Good→Bad 跳变时通知操作员(坏点保留最后有效值,不用零值决策),
+    /// 恢复时补一条"已恢复"。档案加载时的初始 Bad 不算跳变,避免启动时刷屏。
+    /// </summary>
+    private void ApplyTagQuality(string key, bool readable, bool notify)
+    {
+        var (changed, wasReadable) = _data.SetTagQuality(key, readable);
+        if (!changed) return;
+
+        if (wasReadable)
+        {
+            Log.Warning("测点质量转为 Bad: {Key}", key);
+            if (!notify) return; // 链路中断由 PLC 连接报警统一说明
+            _qualityAlarmedTags.TryAdd(key, 0);
+            AlarmService.Instance.RecordJournalAlarm(
+                string.Format(LanguageService.Instance.Get("TagQualityLost"), key));
+        }
+        else if (_qualityAlarmedTags.TryRemove(key, out _))
+        {
+            Log.Information("测点质量恢复: {Key}", key);
+            AlarmService.Instance.RecordJournalAlarm(
+                string.Format(LanguageService.Instance.Get("TagQualityRestored"), key));
+        }
+    }
+
     /// <summary>
     /// 尝试批量读取连续地址(优化性能)
     /// </summary>
@@ -685,9 +763,10 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 await _plcReadSemaphore.WaitAsync(ct);
                 try
                 {
-                    var bytes = await PlcService.ReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
-                    if (bytes == null)
+                    var (ok, bytes) = await PlcService.TryReadBytesAsync(batchAddr, (ushort)group.TotalBytes);
+                    if (!ok || bytes == null)
                     {
+                        // 整组读失败:组内测点留给单独读取分支定质量,不在这里写成零
                         Log.Warning("批量读取失败: {Address}, 长度={Length}", batchAddr, group.TotalBytes);
                         continue;
                     }

+ 134 - 79
src/YZWater.Core/Services/PlcService.cs

@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
 using HslCommunication;
 using HslCommunication.Profinet.Siemens;
 using Serilog;
@@ -24,9 +25,16 @@ public static class PlcService
     private static bool _isConnected;
 
     /// <summary>
-    /// PLC 连接状态
+    /// 链路存疑:TCP 握得上但整轮读取全失败(CPU 停机、机架/插槽号填错、连接资源耗尽都会这样)。
+    /// 此时对操作员来说 PLC 就是断的——如果按 TCP 状态报"已连接",
+    /// 每秒一次"连上→读不到→判断"会让 PLC 通信报警反复起复(报警泛洪)。
     /// </summary>
-    public static bool IsConnected => _isConnected;
+    private static volatile bool _linkSuspect;
+
+    /// <summary>
+    /// PLC 连接状态(对外语义为"链路可用":套接字已连且读得到数据)
+    /// </summary>
+    public static bool IsConnected => _isConnected && !_linkSuspect;
 
     /// <summary>
     /// PLC 连接状态变更事件(参数为当前是否连接)
@@ -34,13 +42,49 @@ public static class PlcService
     public static event Action<bool>? ConnectionChanged;
 
     /// <summary>
-    /// 更新连接状态(状态实际变化时才触发事件)
+    /// 最近一次对外发布的连接状态(= _isConnected && !_linkSuspect)。
+    /// 事件只能按它去重:TCP 状态没变而存疑标志变了的时候,对外语义确实变化了。
+    /// </summary>
+    private static bool _lastPublishedConnected;
+
+    /// <summary>
+    /// 更新套接字连接状态并对外发布有效状态
     /// </summary>
     private static void SetConnectionState(bool connected)
     {
-        if (_isConnected == connected) return;
         _isConnected = connected;
-        ConnectionChanged?.Invoke(connected);
+        PublishConnectionState();
+    }
+
+    private static void PublishConnectionState()
+    {
+        var effective = IsConnected;
+        if (effective == _lastPublishedConnected) return;
+        _lastPublishedConnected = effective;
+        ConnectionChanged?.Invoke(effective);
+    }
+
+    /// <summary>
+    /// 由轮询循环在"整轮读取全部失败"时调用,判定链路已断。
+    /// 单个测点读不到不代表链路断开(地址填错、PLC 侧数据区越界都会只失败一个点),
+    /// 所以读取方法本身不改连接状态,连接状态只由连接流程和本方法决定。
+    /// </summary>
+    public static void MarkLinkDown(string reason)
+    {
+        if (!_isConnected || _linkSuspect) return;
+        Log.Warning("整轮 PLC 读取均失败,判定链路中断: {Reason}", reason);
+        _linkSuspect = true;
+        SetConnectionState(false);
+    }
+
+    /// <summary>
+    /// 清除链路存疑标志(重新初始化/手动断连时调用;正常恢复由读成功自动清除)
+    /// </summary>
+    public static void ClearLinkSuspicion()
+    {
+        if (!_linkSuspect) return;
+        _linkSuspect = false;
+        PublishConnectionState();
     }
 
     /// <summary>
@@ -93,6 +137,8 @@ public static class PlcService
             }
 
             Log.Information("PLC 服务初始化完成,协议: {Protocol}, IP: {Ip}:{Port}", protocol, plcIp, plcPort);
+            // 换了目标地址,上一台设备的"链路存疑"结论不再适用
+            ClearLinkSuspicion();
         }
         catch (Exception ex)
         {
@@ -122,9 +168,9 @@ public static class PlcService
             {
                 result = await _plc!.ConnectServerAsync();
             }
-            _isConnected = result.IsSuccess;
-            SetConnectionState(_isConnected);
-            if (_isConnected)
+            // 只经 SetConnectionState 改状态:先赋值会让状态"没变化",ConnectionChanged 事件永远不发
+            SetConnectionState(result.IsSuccess);
+            if (result.IsSuccess)
             {
                 Log.Information("PLC 连接成功");
             }
@@ -154,16 +200,41 @@ public static class PlcService
     }
 
     /// <summary>
-    /// 读取浮点数
+    /// 已告警过的读取失败地址:同一地址持续失败时只在首次告警,
+    /// 否则 1 秒轮询会把日志刷满(坏点会被一直读到)
     /// </summary>
-    public static async Task<float> ReadFloatAsync(string address)
+    private static readonly ConcurrentDictionary<string, byte> _loggedReadFailures = new();
+
+    private static void NoteReadFailure(string address, string message)
     {
-        if (!_isConnected)
+        if (_loggedReadFailures.TryAdd(address, 0))
+            Log.Error("PLC 读取失败,该测点将保持上一次有效值: {Address} — {Message}", address, message);
+        else
+            Log.Debug("PLC 读取仍失败: {Address} — {Message}", address, message);
+    }
+
+    private static void NoteReadSuccess(string address)
+    {
+        // 只要有一个点读到,套接字就是真的通的,撤销"链路中断"判定
+        if (_linkSuspect)
         {
-            Log.Warning("PLC 未连接,无法读取数据");
-            return 0f;
+            _linkSuspect = false;
+            Log.Information("PLC 链路恢复:测点 {Address} 读取成功", address);
+            PublishConnectionState();
         }
 
+        if (_loggedReadFailures.Remove(address, out _))
+            Log.Information("PLC 测点读取恢复: {Address}", address);
+    }
+
+    /// <summary>
+    /// 读取浮点数,返回是否成功。
+    /// 失败只说明这个地址读不到,不代表链路断开,因此不改连接状态(见 <see cref="MarkLinkDown"/>)。
+    /// </summary>
+    public static async Task<(bool Ok, float Value)> TryReadFloatAsync(string address)
+    {
+        if (!_isConnected) return (false, 0f); // 链路已断,不必发起读取
+
         try
         {
             // 优先使用 driver
@@ -172,27 +243,29 @@ public static class PlcService
                 var result = await _driver.ReadFloatAsync(address);
                 if (!result.IsSuccess)
                 {
-                    SetConnectionState(false);
-                    return 0f;
+                    NoteReadFailure(address, result.Message);
+                    return (false, 0f);
                 }
-                return result.Content;
+                NoteReadSuccess(address);
+                return (true, result.Content);
             }
 
             // 回退到 _plc(向后兼容)
-            if (_plc == null) return 0f;
+            if (_plc == null) return (false, 0f);
             var plcResult = await _plc.ReadFloatAsync(address);
             if (!plcResult.IsSuccess)
             {
-                SetConnectionState(false);
-                return 0f;
+                NoteReadFailure(address, plcResult.Message);
+                return (false, 0f);
             }
-            return plcResult.Content;
+            NoteReadSuccess(address);
+            return (true, plcResult.Content);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
-            SetConnectionState(false);
-            return 0f;
+            NoteReadFailure(address, ex.Message);
+            Log.Debug(ex, "读取 PLC 数据异常,地址: {Address}", address);
+            return (false, 0f);
         }
     }
 
@@ -213,39 +286,28 @@ public static class PlcService
             {
                 var result = await _driver.WriteFloatAsync(address, value);
                 if (!result.IsSuccess)
-                {
-                    SetConnectionState(false);
-                    return false;
-                }
-                return true;
+                    Log.Error("写入 PLC 失败,地址: {Address}, 值: {Value}: {Message}", address, value, result.Message);
+                return result.IsSuccess;
             }
             if (_plc == null) return false;
             var plcResult = await _plc.WriteAsync(address, value);
             if (!plcResult.IsSuccess)
-            {
-                SetConnectionState(false);
-                return false;
-            }
-            return true;
+                Log.Error("写入 PLC 失败,地址: {Address}, 值: {Value}: {Message}", address, value, plcResult.Message);
+            return plcResult.IsSuccess;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "写入 PLC 数据失败,地址: {Address}, 值: {Value}", address, value);
-            SetConnectionState(false);
             return false;
         }
     }
 
     /// <summary>
-    /// 读取布尔值
+    /// 读取布尔值,返回是否成功(失败不改连接状态)
     /// </summary>
-    public static async Task<bool> ReadBoolAsync(string address)
+    public static async Task<(bool Ok, bool Value)> TryReadBoolAsync(string address)
     {
-        if (!_isConnected)
-        {
-            Log.Warning("PLC 未连接,无法读取数据");
-            return false;
-        }
+        if (!_isConnected) return (false, false);
 
         try
         {
@@ -254,25 +316,27 @@ public static class PlcService
                 var result = await _driver.ReadBoolAsync(address);
                 if (!result.IsSuccess)
                 {
-                    SetConnectionState(false);
-                    return false;
+                    NoteReadFailure(address, result.Message);
+                    return (false, false);
                 }
-                return result.Content;
+                NoteReadSuccess(address);
+                return (true, result.Content);
             }
-            if (_plc == null) return false;
+            if (_plc == null) return (false, false);
             var plcResult = await _plc.ReadBoolAsync(address);
             if (!plcResult.IsSuccess)
             {
-                SetConnectionState(false);
-                return false;
+                NoteReadFailure(address, plcResult.Message);
+                return (false, false);
             }
-            return plcResult.Content;
+            NoteReadSuccess(address);
+            return (true, plcResult.Content);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
-            SetConnectionState(false);
-            return false;
+            NoteReadFailure(address, ex.Message);
+            Log.Debug(ex, "读取 PLC 数据异常,地址: {Address}", address);
+            return (false, false);
         }
     }
 
@@ -293,39 +357,28 @@ public static class PlcService
             {
                 var result = await _driver.WriteBoolAsync(address, value);
                 if (!result.IsSuccess)
-                {
-                    SetConnectionState(false);
-                    return false;
-                }
-                return true;
+                    Log.Error("写入 PLC 失败,地址: {Address}, 值: {Value}: {Message}", address, value, result.Message);
+                return result.IsSuccess;
             }
             if (_plc == null) return false;
             var plcResult = await _plc.WriteAsync(address, value);
             if (!plcResult.IsSuccess)
-            {
-                SetConnectionState(false);
-                return false;
-            }
-            return true;
+                Log.Error("写入 PLC 失败,地址: {Address}, 值: {Value}: {Message}", address, value, plcResult.Message);
+            return plcResult.IsSuccess;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "写入 PLC 数据失败,地址: {Address}, 值: {Value}", address, value);
-            SetConnectionState(false);
             return false;
         }
     }
 
     /// <summary>
-    /// 批量读取字节
+    /// 批量读取字节,返回是否成功(失败不改连接状态)
     /// </summary>
-    public static async Task<byte[]?> ReadBytesAsync(string address, ushort length)
+    public static async Task<(bool Ok, byte[]? Value)> TryReadBytesAsync(string address, ushort length)
     {
-        if (!_isConnected)
-        {
-            Log.Warning("PLC 未连接,无法读取数据");
-            return null;
-        }
+        if (!_isConnected) return (false, null);
 
         try
         {
@@ -334,25 +387,27 @@ public static class PlcService
                 var result = await _driver.ReadBytesAsync(address, length);
                 if (!result.IsSuccess)
                 {
-                    SetConnectionState(false);
-                    return null;
+                    NoteReadFailure(address, result.Message);
+                    return (false, null);
                 }
-                return result.Content;
+                NoteReadSuccess(address);
+                return (true, result.Content);
             }
-            if (_plc == null) return null;
+            if (_plc == null) return (false, null);
             var plcResult = await _plc.ReadAsync(address, length);
             if (!plcResult.IsSuccess)
             {
-                SetConnectionState(false);
-                return null;
+                NoteReadFailure(address, plcResult.Message);
+                return (false, null);
             }
-            return plcResult.Content;
+            NoteReadSuccess(address);
+            return (true, plcResult.Content);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "批量读取 PLC 数据失败,地址: {Address}, 长度: {Length}", address, length);
-            SetConnectionState(false);
-            return null;
+            NoteReadFailure(address, ex.Message);
+            Log.Debug(ex, "批量读取 PLC 数据异常,地址: {Address}, 长度: {Length}", address, length);
+            return (false, null);
         }
     }
 

+ 5 - 3
src/YZWater.Core/Services/ProjectService.cs

@@ -10,11 +10,12 @@ namespace YZWater.Core.Services;
 /// </summary>
 public static class ProjectService
 {
-    private static readonly string ProjectsDir = "projects";
+    private static readonly string ProjectsDir = AppPaths.Projects;
     private static readonly JsonSerializerOptions JsonOptions = new()
     {
         WriteIndented = true,
         PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        PropertyNameCaseInsensitive = true,
         ReadCommentHandling = JsonCommentHandling.Skip,
         AllowTrailingCommas = true
     };
@@ -90,8 +91,9 @@ public static class ProjectService
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "加载项目档案失败: {Name}", projectName);
-            _current = null;
+            // 加载失败时保留当前档案:清空会让程序退回内置默认配置(泵/水箱集合都不同),
+            // 而 SwitchProject 返回 false 不触发 ProjectChanged,UI 仍显示旧项目,操作员无从察觉
+            Log.Error(ex, "加载项目档案失败,继续使用当前档案: {Name}", projectName);
             return false;
         }
     }

+ 82 - 0
src/YZWater.Core/Services/ProtectedStringJsonConverter.cs

@@ -0,0 +1,82 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Serilog;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 敏感字符串的落盘加密转换器:用 Windows DPAPI(当前用户范围)加密后写入配置文件,
+/// 避免 yzwater-config.json 里出现明文令牌。
+///
+/// 读取端兼容三种形态:本转换器写出的 "enc:" 密文、历史遗留明文、空值。
+/// 读到明文时置 <see cref="NeedsRewrite"/>,由 ConfigService 回写一次密文完成迁移。
+/// 密文解不开(配置文件来自另一台机器或另一个 Windows 用户)时只置 <see cref="DecryptFailed"/>,
+/// <b>不</b>触发回写——回写会把解不开的密文覆盖成空值,操作员再也找不回原令牌。
+/// 非 Windows(DPAPI 不可用)时退化为明文读写,只告警不中断——配置读不出来比明文更糟。
+/// </summary>
+public sealed class ProtectedStringJsonConverter : JsonConverter<string?>
+{
+    private const string Prefix = "enc:";
+    private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("YZWater.Config.v1");
+
+    /// <summary>本次解析是否读到过未加密的历史值(需要回写密文)</summary>
+    public static bool NeedsRewrite { get; private set; }
+
+    /// <summary>本次解析是否遇到解不开的密文(需要操作员重填,禁止回写覆盖)</summary>
+    public static bool DecryptFailed { get; private set; }
+
+    /// <summary>开始一次新的读取前调用,避免上一次的结论残留</summary>
+    public static void BeginRead()
+    {
+        NeedsRewrite = false;
+        DecryptFailed = false;
+    }
+
+    private static bool IsSupported => OperatingSystem.IsWindows();
+
+    public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+    {
+        var text = reader.GetString();
+        if (string.IsNullOrEmpty(text)) return text;
+
+        if (!text.StartsWith(Prefix, StringComparison.Ordinal))
+        {
+            NeedsRewrite = true; // 历史明文,等待迁移
+            return text;
+        }
+
+        if (!IsSupported)
+        {
+            Log.Warning("当前平台不支持 DPAPI,无法解密配置中的加密项");
+            return null;
+        }
+
+        try
+        {
+            var cipher = Convert.FromBase64String(text[Prefix.Length..]);
+            return Encoding.UTF8.GetString(ProtectedData.Unprotect(cipher, Entropy, DataProtectionScope.CurrentUser));
+        }
+        catch (Exception ex)
+        {
+            // 配置文件被复制到另一台机器/另一个 Windows 用户时必然走到这里。
+            // 只报告不回写:回写会用空值覆盖掉这段密文,操作员连"原来配过什么"都查不到。
+            Log.Error(ex, "配置中的加密项无法解密(可能来自其它机器或用户),该项按空处理");
+            DecryptFailed = true;
+            return null;
+        }
+    }
+
+    public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options)
+    {
+        if (string.IsNullOrEmpty(value) || !IsSupported)
+        {
+            writer.WriteStringValue(value);
+            return;
+        }
+
+        var cipher = ProtectedData.Protect(Encoding.UTF8.GetBytes(value), Entropy, DataProtectionScope.CurrentUser);
+        writer.WriteStringValue(Prefix + Convert.ToBase64String(cipher));
+    }
+}

+ 9 - 19
src/YZWater.Core/Services/QuartzSchedulerService.cs

@@ -107,30 +107,20 @@ public class QuartzSchedulerService : IDisposable
     /// <summary>
     /// 注册默认任务
     /// </summary>
-    private async Task RegisterDefaultJobsAsync()
+    private Task RegisterDefaultJobsAsync()
     {
-        if (_scheduler == null) return;
+        if (_scheduler == null) return Task.CompletedTask;
 
         // 注意:PLC 轮询任务由 PlcPollingService 自己的 PollingLoopAsync 处理
         // 不再使用 Quartz 调度 PlcPollingJob,避免重复轮询
 
-        // ─── 数据日志任务(每秒执行,内部根据计数器决定实际写入频率) ───
-        var loggingJob = JobBuilder.Create<Jobs.DataLoggingJob>()
-            .WithIdentity("dataLoggingJob", "data")
-            .UsingJobData(Jobs.DataLoggingJob.FlowLogIntervalKey, 60)       // 每 60 秒记录一次流量
-            .UsingJobData(Jobs.DataLoggingJob.DeviceLogIntervalKey, 300)   // 每 300 秒记录一次设备状态
-            .Build();
-
-        var loggingTrigger = TriggerBuilder.Create()
-            .WithIdentity("dataLoggingTrigger", "data")
-            .StartNow()
-            .WithSimpleSchedule(x => x
-                .WithIntervalInSeconds(1)
-                .RepeatForever())
-            .Build();
-
-        await _scheduler.ScheduleJob(loggingJob, loggingTrigger);
-        Log.Information("已注册数据日志任务");
+        // 同理,数据记录由 DataLoggingService.LoggingLoopAsync 独占调度。
+        // 这里曾经同时挂着 DataLoggingJob(1 秒触发),两条调度各自每 60 秒写一次
+        // 同一批数据:历史表出现成对重复行,且两个线程并发使用同一个非线程安全的
+        // SqlSugarClient。设备状态与过期清理也分别由该循环和 DataRetentionService 覆盖,
+        // 因此不再注册默认日志任务。
+        Log.Information("默认任务注册完成(数据记录由 DataLoggingService 自身循环负责)");
+        return Task.CompletedTask;
     }
 
     /// <summary>

+ 12 - 0
src/YZWater.Core/Services/QueryTimeHelper.cs

@@ -0,0 +1,12 @@
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 查询时间范围规范化 — DatePicker/CalendarDatePicker 只能给出日期(时间恒为 0 点),
+/// 结束时间若直接参与比较会把当天记录整段滤掉。
+/// </summary>
+public static class QueryTimeHelper
+{
+    /// <summary>把用户选择的结束日期扩展到当天最后一刻;未选择时用 fallback(原样,不做扩展)</summary>
+    public static DateTime NormalizeEnd(DateTime? selectedEnd, DateTime fallback)
+        => selectedEnd == null ? fallback : selectedEnd.Value.Date.AddDays(1).AddTicks(-1);
+}

+ 21 - 6
src/YZWater.Core/Services/S7AddressParser.cs

@@ -1,4 +1,5 @@
 using System.Text.RegularExpressions;
+using Serilog;
 
 namespace YZWater.Core.Services;
 
@@ -8,6 +9,9 @@ namespace YZWater.Core.Services;
 /// </summary>
 public static class S7AddressParser
 {
+    /// <summary>已告警过的非法地址(轮询每周期都会重新分组,同一地址只报一次)</summary>
+    private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _reportedInvalid = new();
+
     /// <summary>
     /// 地址区域类型
     /// </summary>
@@ -136,13 +140,24 @@ public static class S7AddressParser
     {
         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);
+        var parsed = addresses
+            .Select(a => (a.Key, a.Address, Parsed: Parse(a.Address)))
+            .ToList();
+
+        // 按区域分组(解析失败的地址若不显式暴露,该测点会永远停在假的 0.00 上)
+        var byArea = new List<(string Key, ParsedAddress? Parsed)>();
+        foreach (var item in parsed)
+        {
+            if (item.Parsed == null)
+            {
+                if (_reportedInvalid.TryAdd(item.Key + "|" + item.Address, 0))
+                    Log.Error("S7 地址无法解析,该测点不会被轮询: {Key} = {Address}", item.Key, item.Address);
+                continue;
+            }
+            byArea.Add((item.Key, item.Parsed));
+        }
 
-        foreach (var areaGroup in byArea)
+        foreach (var areaGroup in byArea.GroupBy(a => a.Parsed!.Area))
         {
             var sorted = areaGroup
                 .OrderBy(a => a.Parsed!.ByteOffset)

+ 49 - 3
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -74,6 +74,11 @@ public partial class MainViewModel : TranslatableViewModelBase
     public string CreateText => T("Create");
     public string CloseText => T("Close");
     public string LogoutText => T("Logout");
+    public string PlcCommInterruptedText => T("PlcCommInterrupted");
+    public string ExitConfirmTitleText => T("ExitConfirmTitle");
+    public string ExitConfirmMessageText => T("ExitConfirmMessage");
+    public string ConfirmText => T("Confirm");
+    public string CancelText => T("Cancel");
 
     // ═══ 运行模式与连接状态(非纯翻译,需动态更新) ═══
     [ObservableProperty] private string _runMode = "Direct";
@@ -102,6 +107,10 @@ public partial class MainViewModel : TranslatableViewModelBase
     [ObservableProperty] private bool _canModifyParams;
     [ObservableProperty] private bool _canManageUsers;
     [ObservableProperty] private bool _showUserDialog;
+    /// <summary>退出确认框是否可见(点标题栏 ✕ 或 Alt+F4 弹出)</summary>
+    [ObservableProperty] private bool _showExitDialog;
+    /// <summary>是否显示用户信息区域(认证禁用时为 false)</summary>
+    [ObservableProperty] private bool _showUserInfo = true;
 
     public MainViewModel()
     {
@@ -136,6 +145,11 @@ public partial class MainViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(CreateText), "Create");
         RegisterTranslation(nameof(CloseText), "Close");
         RegisterTranslation(nameof(LogoutText), "Logout");
+        RegisterTranslation(nameof(PlcCommInterruptedText), "PlcCommInterrupted");
+        RegisterTranslation(nameof(ExitConfirmTitleText), "ExitConfirmTitle");
+        RegisterTranslation(nameof(ExitConfirmMessageText), "ExitConfirmMessage");
+        RegisterTranslation(nameof(ConfirmText), "Confirm");
+        RegisterTranslation(nameof(CancelText), "Cancel");
 
         SubscribeLanguageChanged();
 
@@ -145,6 +159,9 @@ public partial class MainViewModel : TranslatableViewModelBase
 
         _themeService.PropertyChanged += OnThemeServicePropertyChanged;
 
+        // 配置变更时刷新用户信息(支持热加载 EnableAuth 等配置)
+        ConfigService.Instance.ConfigChanged += OnConfigChanged;
+
         // PLC 连接状态监听
         PlcPollingService.Instance.ConnectionStateChanged += OnConnectionStateChanged;
 
@@ -242,7 +259,7 @@ public partial class MainViewModel : TranslatableViewModelBase
             if (mode == "Mock")
             {
                 // Mock 模式:明确告知用户是模拟数据,PLC 未连接
-                PlcStatusText = "模拟模式(无 PLC 连接)";
+                PlcStatusText = T("MockModeStatus");
                 PlcStatusColor = "#F39C12"; // 橙色,区分于正常的绿色和断线的红色
                 IsPlcConnected = false;
             }
@@ -280,6 +297,16 @@ public partial class MainViewModel : TranslatableViewModelBase
         IsDarkTheme = _themeService.IsDarkTheme;
         ThemeIcon = IsDarkTheme ? "🌙" : "☀️";
         LoadRunMode();
+
+        // 刷新 PLC 状态文本(基于当前连接状态)
+        if (RunMode == "Mock")
+        {
+            PlcStatusText = T("MockModeStatus");
+        }
+        else
+        {
+            PlcStatusText = IsPlcConnected ? T("PlcConnectedStatus") : T("PlcDisconnected");
+        }
     }
 
     /// <summary>
@@ -300,7 +327,7 @@ public partial class MainViewModel : TranslatableViewModelBase
         // Mock 模式下立即初始化 PLC 状态显示,避免事件未触发时状态栏空白
         if (RunMode == "Mock")
         {
-            PlcStatusText = "模拟模式(无 PLC 连接)";
+            PlcStatusText = T("MockModeStatus");
             PlcStatusColor = "#F39C12";
             IsPlcConnected = false;
         }
@@ -360,7 +387,7 @@ public partial class MainViewModel : TranslatableViewModelBase
     [RelayCommand] private void ShowViewC() { CurrentView = _viewCViewModel; SetActiveTab(2); }
     [RelayCommand] private void ShowViewD() { CurrentView = _viewDViewModel; SetActiveTab(3); }
     [RelayCommand] private void ShowViewE() { CurrentView = _viewEViewModel; SetActiveTab(4); }
-    [RelayCommand] private void ShowViewF() { CurrentView = _viewFViewModel; SetActiveTab(5); }
+    [RelayCommand] private void ShowViewF() { _viewFViewModel.LoadWorkflows(); CurrentView = _viewFViewModel; SetActiveTab(5); }
 
     private void StartTimer()
     {
@@ -382,6 +409,19 @@ public partial class MainViewModel : TranslatableViewModelBase
 
     private void RefreshUserInfo()
     {
+        // 认证禁用模式:隐藏用户信息区域,开放所有权限
+        if (!AuthService.IsAuthEnabled)
+        {
+            ShowUserInfo = false;
+            CurrentUserName = string.Empty;
+            CurrentUserRole = string.Empty;
+            CanControl = true;
+            CanModifyParams = true;
+            CanManageUsers = false; // 无认证模式下禁用用户管理
+            return;
+        }
+
+        ShowUserInfo = true;
         var user = AuthService.CurrentUser;
         if (user != null)
         {
@@ -478,6 +518,11 @@ public partial class MainViewModel : TranslatableViewModelBase
         LoadUsers();
     }
 
+    /// <summary>
+    /// 配置变更可能来自 FileSystemWatcher 线程,须切回 UI 线程再刷新绑定属性
+    /// </summary>
+    private void OnConfigChanged(SystemConfig _) => RunOnUi(RefreshUserInfo);
+
     public new void Dispose()
     {
         if (_disposed) return;
@@ -486,6 +531,7 @@ public partial class MainViewModel : TranslatableViewModelBase
         _themeService.PropertyChanged -= OnThemeServicePropertyChanged;
         PlcPollingService.Instance.ConnectionStateChanged -= OnConnectionStateChanged;
         ProjectService.ProjectChanged -= OnProjectChanged;
+        ConfigService.Instance.ConfigChanged -= OnConfigChanged;
         _viewAViewModel.Dispose();
         _viewBViewModel.Dispose();
         _viewCViewModel.Dispose();

Різницю між файлами не показано, бо вона завелика
+ 438 - 192
src/YZWater.Core/ViewModels/ProjectViewModel.cs


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

@@ -1,3 +1,4 @@
+using System.Threading;
 using CommunityToolkit.Mvvm.ComponentModel;
 using YZWater.Core.Services;
 
@@ -20,6 +21,9 @@ public abstract class TranslatableViewModelBase : ObservableObject, IDisposable
 {
     private readonly LanguageService _lang = LanguageService.Instance;
 
+    /// <summary>构造时捕获的 UI 同步上下文(Core 层不引用 Avalonia Dispatcher)</summary>
+    private readonly SynchronizationContext? _uiContext = SynchronizationContext.Current;
+
     /// <summary>属性名 → 翻译键 的映射</summary>
     private readonly Dictionary<string, string> _translationKeys = new();
 
@@ -28,6 +32,19 @@ public abstract class TranslatableViewModelBase : ObservableObject, IDisposable
     /// <summary>语言服务实例(供子类直接访问)</summary>
     protected LanguageService Lang => _lang;
 
+    /// <summary>
+    /// 切回 UI 线程执行 — 轮询线程回调(DataUpdated/AlarmRaised 等)更新绑定属性必须经此调度,
+    /// 否则触发 Avalonia VerifyAccess 异常或产生数据竞争。
+    /// </summary>
+    protected void RunOnUi(Action action)
+    {
+        if (_disposed) return;
+        if (_uiContext != null && SynchronizationContext.Current != _uiContext)
+            _uiContext.Post(_ => { if (!_disposed) action(); }, null);
+        else
+            action();
+    }
+
     /// <summary>
     /// 注册一个翻译属性映射。
     /// </summary>

+ 44 - 37
src/YZWater.Core/ViewModels/ViewAViewModel.cs

@@ -468,43 +468,50 @@ public partial class ViewAViewModel : TranslatableViewModelBase
         AlarmCount = alarmService.ActiveAlarmCount;
     }
 
-    private static PlcDataModel CreateSnapshot(PlcDataModel data) => new()
+    private static PlcDataModel CreateSnapshot(PlcDataModel data)
     {
-        Tank1Level = data.Tank1Level,
-        Tank2Level = data.Tank2Level,
-        Tank3Level = data.Tank3Level,
-        Tank4Level = data.Tank4Level,
-        InflowRate = data.InflowRate,
-        OutflowRate = data.OutflowRate,
-        FlowDelta = data.FlowDelta,
-        Pump1Running = data.Pump1Running,
-        Pump2Running = data.Pump2Running,
-        Pump3Running = data.Pump3Running,
-        Pump4Running = data.Pump4Running,
-        Pump5Running = data.Pump5Running,
-        Pump1Fault = data.Pump1Fault,
-        Pump2Fault = data.Pump2Fault,
-        Pump3Fault = data.Pump3Fault,
-        Pump4Fault = data.Pump4Fault,
-        Pump5Fault = data.Pump5Fault,
-        Pump1Freq = data.Pump1Freq,
-        Pump2Freq = data.Pump2Freq,
-        Pump3Freq = data.Pump3Freq,
-        Pump4Freq = data.Pump4Freq,
-        Pump5Freq = data.Pump5Freq,
-        Fan1Running = data.Fan1Running,
-        Fan2Running = data.Fan2Running,
-        Fan1Fault = data.Fan1Fault,
-        Fan2Fault = data.Fan2Fault,
-        Valve1Position = data.Valve1Position,
-        Valve2Position = data.Valve2Position,
-        Valve3Position = data.Valve3Position,
-        Valve4Position = data.Valve4Position,
-        IsAutoMode = data.IsAutoMode,
-        LastReadTime = data.LastReadTime,
-        IsDataValid = data.IsDataValid,
-        IsPlcConnected = data.IsPlcConnected
-    };
+        var snapshot = new PlcDataModel
+        {
+            Tank1Level = data.Tank1Level,
+            Tank2Level = data.Tank2Level,
+            Tank3Level = data.Tank3Level,
+            Tank4Level = data.Tank4Level,
+            InflowRate = data.InflowRate,
+            OutflowRate = data.OutflowRate,
+            FlowDelta = data.FlowDelta,
+            Pump1Running = data.Pump1Running,
+            Pump2Running = data.Pump2Running,
+            Pump3Running = data.Pump3Running,
+            Pump4Running = data.Pump4Running,
+            Pump5Running = data.Pump5Running,
+            Pump1Fault = data.Pump1Fault,
+            Pump2Fault = data.Pump2Fault,
+            Pump3Fault = data.Pump3Fault,
+            Pump4Fault = data.Pump4Fault,
+            Pump5Fault = data.Pump5Fault,
+            Pump1Freq = data.Pump1Freq,
+            Pump2Freq = data.Pump2Freq,
+            Pump3Freq = data.Pump3Freq,
+            Pump4Freq = data.Pump4Freq,
+            Pump5Freq = data.Pump5Freq,
+            Fan1Running = data.Fan1Running,
+            Fan2Running = data.Fan2Running,
+            Fan1Fault = data.Fan1Fault,
+            Fan2Fault = data.Fan2Fault,
+            Valve1Position = data.Valve1Position,
+            Valve2Position = data.Valve2Position,
+            Valve3Position = data.Valve3Position,
+            Valve4Position = data.Valve4Position,
+            IsAutoMode = data.IsAutoMode,
+            LastReadTime = data.LastReadTime,
+            IsDataValid = data.IsDataValid,
+            IsPlcConnected = data.IsPlcConnected
+        };
+
+        // 档案模式的数值都在动态寄存器里,漏掉复制会让界面恒显示零值
+        snapshot.CopyDynamicFrom(data);
+        return snapshot;
+    }
 
     /// <summary>
     /// 更新设备状态
@@ -514,7 +521,7 @@ public partial class ViewAViewModel : TranslatableViewModelBase
         // 断线时全部降级为灰色 + "未知"(ISA-101: 数据不可信时不显示最后已知状态)
         var disconnected = !data.IsPlcConnected;
         var gray = ThemeColors.TextDisabled;
-        var unknownStatus = "未知";
+        var unknownStatus = T("Unknown");
 
         if (disconnected)
         {

+ 15 - 2
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -90,6 +90,19 @@ public partial class ViewBViewModel : TranslatableViewModelBase
         LoadConfig();
     }
 
+    /// <summary>
+    /// 语言变更时刷新动态属性
+    /// </summary>
+    protected override void OnLanguageChanged()
+    {
+        base.OnLanguageChanged();
+        // 刷新连接状态文本(只要不在测试连接中,就刷新为当前语言的"未连接")
+        if (!_isConnecting)
+        {
+            ConnectionStatus = T("NotConnected");
+        }
+    }
+
     partial void OnRunAtStartupChanged(bool value)
     {
         var success = StartupService.SetAutoStart(value);
@@ -218,12 +231,12 @@ public partial class ViewBViewModel : TranslatableViewModelBase
             if (success)
             {
                 Log.Information("泵频率已写入 PLC: {Freq} Hz", PumpFrequency);
-                AuditService.Log("系统", "Control", $"写入泵频率: {PumpFrequency} Hz", PlcConfig.PumpFreqSetpoint);
+                AuditService.Log("系统", "Control", string.Format(T("AuditWritePumpFreq"), PumpFrequency), PlcConfig.PumpFreqSetpoint);
             }
             else
             {
                 Log.Error("泵频率写入失败");
-                AuditService.Log("系统", "Control", $"泵频率写入失败: {PumpFrequency} Hz", PlcConfig.PumpFreqSetpoint, "Failed");
+                AuditService.Log("系统", "Control", string.Format(T("AuditWritePumpFreqFailed"), PumpFrequency), PlcConfig.PumpFreqSetpoint, "Failed");
             }
         }
         catch (Exception ex)

+ 574 - 153
src/YZWater.Core/ViewModels/ViewCViewModel.cs

@@ -1,3 +1,4 @@
+using System.Collections.ObjectModel;
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using LiveChartsCore;
@@ -12,26 +13,71 @@ using YZWater.Core.Services;
 namespace YZWater.Core.ViewModels;
 
 /// <summary>
-/// 流量记录视图 ViewModel
+/// 图表分组模型 — 对应 ViewC 中的一个 CartesianChart
+/// </summary>
+public class ChartGroupModel
+{
+    /// <summary>图表标题(如"泵电流"、"液位")</summary>
+    public string Title { get; set; } = "";
+
+    /// <summary>Y 轴单位</summary>
+    public string YAxisUnit { get; set; } = "";
+
+    /// <summary>曲线系列</summary>
+    public ISeries[] Series { get; set; } = Array.Empty<ISeries>();
+
+    /// <summary>每条曲线对应的 TagName(与 Series 数组索引一一对应)</summary>
+    public List<string> TagNames { get; set; } = new();
+
+    /// <summary>Y 轴下限</summary>
+    public double? YMinLimit { get; set; }
+
+    /// <summary>Y 轴上限</summary>
+    public double? YMaxLimit { get; set; }
+
+    /// <summary>X 轴(时间轴)</summary>
+    public Axis[] XAxes { get; set; } = Array.Empty<Axis>();
+
+    /// <summary>Y 轴</summary>
+    public Axis[] YAxes { get; set; } = Array.Empty<Axis>();
+}
+
+/// <summary>
+/// 流量记录视图 ViewModel — 按项目统计配置动态生成图表
 /// </summary>
 public partial class ViewCViewModel : TranslatableViewModelBase
 {
     private bool _disposed;
-    // ═══ 图表数据 ═══
-    [ObservableProperty] private ISeries[] _inflowSeries;
-    [ObservableProperty] private ISeries[] _outflowSeries;
-    [ObservableProperty] private ISeries[] _tankLevelSeries;
-    [ObservableProperty] private ISeries[] _pumpFreqSeries;
-    [ObservableProperty] private Axis[] _xAxes;
-    [ObservableProperty] private Axis[] _yAxes;
-    [ObservableProperty] private Axis[] _tankYAxes;
-    [ObservableProperty] private Axis[] _pumpYAxes;
+
+    // ═══ 动态图表组 ═══
+    [ObservableProperty] private ObservableCollection<ChartGroupModel> _chartGroups = new();
 
     // ═══ 查询条件 ═══
     [ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-1);
     [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
     [ObservableProperty] private List<FlowRecord> _flowRecords = new();
 
+    // ═══ 统计报表 ═══
+    [ObservableProperty] private string _reportMode = "day"; // "day" / "month" / "year"
+    [ObservableProperty] private DateTimeOffset? _reportDate = DateTimeOffset.Now;
+    [ObservableProperty] private ObservableCollection<StatisticsSummary> _summaries = new();
+
+    // ═══ 进出水统计卡片 ═══
+    [ObservableProperty] private float _totalInflow;
+    [ObservableProperty] private float _totalOutflow;
+    [ObservableProperty] private float _flowDelta;
+    [ObservableProperty] private float _currentInflowRate;
+    [ObservableProperty] private float _currentOutflowRate;
+
+    // ─── 自动颜色分配 ───
+    private static readonly SKColor[] Palette =
+    {
+        SKColors.CornflowerBlue, SKColors.MediumSeaGreen, SKColors.Orange,
+        SKColors.MediumPurple, SKColors.Crimson, SKColors.Goldenrod,
+        SKColors.DodgerBlue, SKColors.HotPink, SKColors.Teal, SKColors.Sienna
+    };
+    private int _colorIndex;
+
     // ─── 翻译属性 ───
     public string TitleText => T("FlowRecords");
     public string SubtitleText => T("FlowHistory");
@@ -40,18 +86,33 @@ public partial class ViewCViewModel : TranslatableViewModelBase
     public string QueryText => T("Query");
     public string ExportCsvText => T("ExportCsv");
     public string PurgeOldText => T("PurgeOld");
-    public string InflowTrendText => T("InflowTrend");
-    public string OutflowTrendText => T("OutflowTrend");
-    public string TankLevelTrendText => T("TankLevelTrend");
-    public string PumpFreqTrendText => T("PumpFreqTrend");
     public string QuickRangeText => T("QuickRange");
-    public string FlowTrendText => T("FlowTrend");
     public string TimeHeaderText => T("TimeHeader");
     public string InflowHeaderText => T("InflowHeader");
     public string OutflowHeaderText => T("OutflowHeader");
     public string TotalInHeaderText => T("TotalInHeader");
     public string TotalOutHeaderText => T("TotalOutHeader");
 
+    // ─── 统计报表翻译 ───
+    public string StatisticsText => T("Statistics");
+    public string DayReportText => T("DayReport");
+    public string MonthReportText => T("MonthReport");
+    public string YearReportText => T("YearReport");
+    public string AvgHeaderText => T("AvgHeader");
+    public string MaxHeaderText => T("MaxHeader");
+    public string MinHeaderText => T("MinHeader");
+    public string TotalHeaderText => T("TotalHeaderText");
+    public string UnitHeaderText => T("UnitHeader");
+    public string ItemHeaderText => T("ItemHeader");
+
+    // ─── 进出水统计卡片翻译 ───
+    public string FlowSummaryText => T("FlowSummary");
+    public string CurrentInflowText => T("CurrentInflow");
+    public string CurrentOutflowText => T("CurrentOutflow");
+    public string TotalInflowText => T("TotalInflowCard");
+    public string TotalOutflowText => T("TotalOutflowCard");
+    public string FlowDeltaText => T("FlowDeltaCard");
+
     public ViewCViewModel()
     {
         RegisterTranslation(nameof(TitleText), "FlowRecords");
@@ -61,114 +122,251 @@ public partial class ViewCViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(QueryText), "Query");
         RegisterTranslation(nameof(ExportCsvText), "ExportCsv");
         RegisterTranslation(nameof(PurgeOldText), "PurgeOld");
-        RegisterTranslation(nameof(InflowTrendText), "InflowTrend");
-        RegisterTranslation(nameof(OutflowTrendText), "OutflowTrend");
-        RegisterTranslation(nameof(TankLevelTrendText), "TankLevelTrend");
-        RegisterTranslation(nameof(PumpFreqTrendText), "PumpFreqTrend");
         RegisterTranslation(nameof(QuickRangeText), "QuickRange");
-        RegisterTranslation(nameof(FlowTrendText), "FlowTrend");
         RegisterTranslation(nameof(TimeHeaderText), "TimeHeader");
         RegisterTranslation(nameof(InflowHeaderText), "InflowHeader");
         RegisterTranslation(nameof(OutflowHeaderText), "OutflowHeader");
         RegisterTranslation(nameof(TotalInHeaderText), "TotalInHeader");
         RegisterTranslation(nameof(TotalOutHeaderText), "TotalOutHeader");
 
+        // 统计报表翻译
+        RegisterTranslation(nameof(StatisticsText), "Statistics");
+        RegisterTranslation(nameof(DayReportText), "DayReport");
+        RegisterTranslation(nameof(MonthReportText), "MonthReport");
+        RegisterTranslation(nameof(YearReportText), "YearReport");
+        RegisterTranslation(nameof(AvgHeaderText), "AvgHeader");
+        RegisterTranslation(nameof(MaxHeaderText), "MaxHeader");
+        RegisterTranslation(nameof(MinHeaderText), "MinHeader");
+        RegisterTranslation(nameof(TotalHeaderText), "TotalHeaderText");
+        RegisterTranslation(nameof(UnitHeaderText), "UnitHeader");
+        RegisterTranslation(nameof(ItemHeaderText), "ItemHeader");
+
+        // 进出水统计卡片翻译
+        RegisterTranslation(nameof(FlowSummaryText), "FlowSummary");
+        RegisterTranslation(nameof(CurrentInflowText), "CurrentInflow");
+        RegisterTranslation(nameof(CurrentOutflowText), "CurrentOutflow");
+        RegisterTranslation(nameof(TotalInflowText), "TotalInflowCard");
+        RegisterTranslation(nameof(TotalOutflowText), "TotalOutflowCard");
+        RegisterTranslation(nameof(FlowDeltaText), "FlowDeltaCard");
+
         SubscribeLanguageChanged();
-        InitializeCharts();
+        BuildChartGroups();
 
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
         _ = LoadFlowRecordsAsync();
     }
 
-    private void OnDataUpdated(PlcDataModel data)
-    {
-        AddRealtimePoint(data);
-    }
+    // ═══════════════════════════════════════════════
+    //  构建图表组
+    // ═══════════════════════════════════════════════
 
     /// <summary>
-    /// 添加实时数据点
+    /// 根据项目统计配置构建图表组。
+    /// 有配置时按 ChartGroup 分组;无配置时走旧版硬编码。
     /// </summary>
-    private void AddRealtimePoint(PlcDataModel data)
+    private void BuildChartGroups()
     {
-        try
+        _colorIndex = 0;
+        var profile = ProjectService.Current;
+
+        if (profile?.Statistics?.Items is { Count: > 0 })
         {
-            var now = DateTime.Now;
-            AddPointToSeries(InflowSeries, now, data.InflowRate);
-            AddPointToSeries(OutflowSeries, now, data.OutflowRate);
+            BuildFromStatisticsConfig(profile.Statistics);
         }
-        catch (Exception ex)
+        else
         {
-            Log.Error(ex, "更新实时图表失败");
+            BuildLegacyChartGroups();
         }
     }
 
-    private static void AddPointToSeries(ISeries[] series, DateTime time, float value)
+    /// <summary>
+    /// 按 StatisticsConfig 动态构建图表组
+    /// </summary>
+    private void BuildFromStatisticsConfig(StatisticsConfig config)
     {
-        if (series?.Length > 0 && series[0] is LineSeries<DateTimePoint> lineSeries)
+        var enabledItems = config.Items.Where(i => i.Enabled).ToList();
+
+        // 按 ChartGroup 分组(ChartGroup 为 null 时用 DisplayName 作为分组名)
+        var groups = enabledItems
+            .GroupBy(i => i.ChartGroup ?? i.DisplayName)
+            .ToList();
+
+        var newGroups = new ObservableCollection<ChartGroupModel>();
+
+        foreach (var group in groups)
         {
-            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();
+            var items = group.ToList();
+            var unit = items.First().Unit;
+            var tagNames = items.Select(i => i.Key).ToList();
+
+            var series = new ISeries[items.Count];
+            for (int i = 0; i < items.Count; i++)
+            {
+                var color = ParseColor(items[i].Color) ?? NextColor();
+                series[i] = CreateLineSeries(items[i].GetLocalizedDisplayName(), color);
+            }
+
+            // 每个图表组创建独立的 X/Y 轴(LiveCharts 不允许跨图表共享 Axis 实例)
+            var timeAxis = CreateTimeAxis();
+            var yAxis = new Axis { Name = unit, MinLimit = 0 };
+
+            newGroups.Add(new ChartGroupModel
+            {
+                Title = group.Key,
+                YAxisUnit = unit,
+                Series = series,
+                TagNames = tagNames,
+                YMinLimit = 0,
+                YMaxLimit = null,
+                XAxes = new[] { timeAxis },
+                YAxes = new[] { yAxis }
+            });
         }
+
+        ChartGroups = newGroups;
+        Log.Debug("按统计配置构建了 {Count} 个图表组", newGroups.Count);
     }
 
     /// <summary>
-    /// 初始化所有图表
+    /// 旧版硬编码图表组(无统计配置时兼容)
     /// </summary>
-    private void InitializeCharts()
+    private void BuildLegacyChartGroups()
     {
-        var timeAxis = new Axis
+        // 每个图表创建独立的 Axis(LiveCharts 不允许跨图表共享 Axis 实例)
+        ChartGroups = new ObservableCollection<ChartGroupModel>
         {
-            Labeler = value => new DateTime((long)value).ToString("MM-dd HH:mm"),
-            UnitWidth = TimeSpan.FromHours(1).Ticks,
-            LabelsRotation = 45
+            // 进水流量
+            new()
+            {
+                Title = T("InflowTrend"),
+                YAxisUnit = "m³/h",
+                Series = new[] { CreateLineSeries(T("InflowSeries"), SKColors.CornflowerBlue) },
+                TagNames = new List<string> { "InflowRate" },
+                XAxes = new[] { CreateTimeAxis() },
+                YAxes = new[] { new Axis { Name = "m³/h", MinLimit = 0 } }
+            },
+            // 出水流量
+            new()
+            {
+                Title = T("OutflowTrend"),
+                YAxisUnit = "m³/h",
+                Series = new[] { CreateLineSeries(T("OutflowSeries"), SKColors.MediumSeaGreen) },
+                TagNames = new List<string> { "OutflowRate" },
+                XAxes = new[] { CreateTimeAxis() },
+                YAxes = new[] { new Axis { Name = "m³/h", MinLimit = 0 } }
+            },
+            // 液位(4 个水箱)
+            new()
+            {
+                Title = T("TankLevelTrend"),
+                YAxisUnit = "%",
+                Series = new[]
+                {
+                    CreateLineSeries(T("Tank1Name"), SKColors.CornflowerBlue),
+                    CreateLineSeries(T("Tank2Name"), SKColors.MediumSeaGreen),
+                    CreateLineSeries(T("Tank3Name"), SKColors.Orange),
+                    CreateLineSeries(T("Tank4Name"), SKColors.MediumPurple),
+                },
+                TagNames = new List<string> { "Tank1Level", "Tank2Level", "Tank3Level", "Tank4Level" },
+                XAxes = new[] { CreateTimeAxis() },
+                YAxes = new[] { new Axis { Name = "%", MinLimit = 0, MaxLimit = 100 } }
+            },
+            // 泵频率(5 个泵)
+            new()
+            {
+                Title = T("PumpFreqTrend"),
+                YAxisUnit = "Hz",
+                Series = new[]
+                {
+                    CreateLineSeries(T("Pump1Name"), SKColors.CornflowerBlue),
+                    CreateLineSeries(T("Pump2Name"), SKColors.MediumSeaGreen),
+                    CreateLineSeries(T("Pump3Name"), SKColors.Orange),
+                    CreateLineSeries(T("Pump4Name"), SKColors.MediumPurple),
+                    CreateLineSeries(T("Pump5Name"), SKColors.Crimson),
+                },
+                TagNames = new List<string> { "Pump1Freq", "Pump2Freq", "Pump3Freq", "Pump4Freq", "Pump5Freq" },
+                XAxes = new[] { CreateTimeAxis() },
+                YAxes = new[] { new Axis { Name = "Hz", MinLimit = 0 } }
+            }
         };
+    }
+
+    // ═══════════════════════════════════════════════
+    //  实时数据
+    // ═══════════════════════════════════════════════
 
-        // 流量图表
-        InflowSeries = new ISeries[] { CreateLineSeries(T("InflowSeries"), SKColors.CornflowerBlue) };
-        OutflowSeries = new ISeries[] { CreateLineSeries(T("OutflowSeries"), SKColors.MediumSeaGreen) };
-        XAxes = new[] { timeAxis };
-        YAxes = new[] { new Axis { Name = "m³/h", MinLimit = 0 } };
+    private void OnDataUpdated(PlcDataModel data)
+    {
+        // 轮询线程回调 — 图表序列为 UI 绑定内容,必须切回 UI 线程更新
+        RunOnUi(() => AddRealtimePoint(data));
+    }
 
-        // 液位图表
-        TankLevelSeries = new ISeries[]
+    private void AddRealtimePoint(PlcDataModel data)
+    {
+        try
         {
-            CreateLineSeries(T("Tank1Name"), SKColors.CornflowerBlue),
-            CreateLineSeries(T("Tank2Name"), SKColors.MediumSeaGreen),
-            CreateLineSeries(T("Tank3Name"), SKColors.Orange),
-            CreateLineSeries(T("Tank4Name"), SKColors.MediumPurple),
-        };
-        TankYAxes = new[] { new Axis { Name = "%", MinLimit = 0, MaxLimit = 100 } };
+            var now = DateTime.Now;
 
-        // 泵频率图表
-        PumpFreqSeries = new ISeries[]
+            foreach (var group in ChartGroups)
+            {
+                for (int i = 0; i < group.TagNames.Count && i < group.Series.Length; i++)
+                {
+                    var tagName = group.TagNames[i];
+
+                    // 坏点里存的是最后一次有效值,画成水平线会被读成"参数一直稳定",本周期不画
+                    if (data.IsTagBad(tagName)) continue;
+
+                    // 优先从动态寄存器读取
+                    float value;
+                    if (data.AnalogRegisters.TryGetValue(tagName, out var regValue))
+                    {
+                        value = regValue;
+                    }
+                    else
+                    {
+                        // 兼容旧 TagName(InflowRate / OutflowRate 等)
+                        value = tagName switch
+                        {
+                            "InflowRate" => data.InflowRate,
+                            "OutflowRate" => data.OutflowRate,
+                            "Tank1Level" => data.Tank1Level,
+                            "Tank2Level" => data.Tank2Level,
+                            "Tank3Level" => data.Tank3Level,
+                            "Tank4Level" => data.Tank4Level,
+                            "Pump1Freq" => data.Pump1Freq,
+                            "Pump2Freq" => data.Pump2Freq,
+                            "Pump3Freq" => data.Pump3Freq,
+                            "Pump4Freq" => data.Pump4Freq,
+                            "Pump5Freq" => data.Pump5Freq,
+                            _ => 0
+                        };
+                    }
+
+                    AddPointToSeries(group.Series[i], now, value);
+                }
+            }
+        }
+        catch (Exception ex)
         {
-            CreateLineSeries(T("Pump1Name"), SKColors.CornflowerBlue),
-            CreateLineSeries(T("Pump2Name"), SKColors.MediumSeaGreen),
-            CreateLineSeries(T("Pump3Name"), SKColors.Orange),
-            CreateLineSeries(T("Pump4Name"), SKColors.MediumPurple),
-            CreateLineSeries(T("Pump5Name"), SKColors.Crimson),
-        };
-        PumpYAxes = new[] { new Axis { Name = "Hz", MinLimit = 0 } };
+            Log.Error(ex, "更新实时图表失败");
+        }
     }
 
-    private static LineSeries<DateTimePoint> CreateLineSeries(string name, SKColor color)
+    private static void AddPointToSeries(ISeries series, DateTime time, float value)
     {
-        return new LineSeries<DateTimePoint>
+        if (series is LineSeries<DateTimePoint> lineSeries)
         {
-            Name = name,
-            Values = new List<DateTimePoint>(),
-            Stroke = new SolidColorPaint(color, 2),
-            Fill = null,
-            GeometryStroke = null,
-            GeometrySize = 0
-        };
+            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();
+        }
     }
 
-    /// <summary>
-    /// 预设时间范围
-    /// </summary>
+    // ═══════════════════════════════════════════════
+    //  历史数据查询
+    // ═══════════════════════════════════════════════
+
     [RelayCommand]
     private void SetTimeRange(string range)
     {
@@ -186,16 +384,13 @@ public partial class ViewCViewModel : TranslatableViewModelBase
         _ = LoadFlowRecordsAsync();
     }
 
-    /// <summary>
-    /// 加载历史数据
-    /// </summary>
     [RelayCommand]
     private async Task LoadFlowRecordsAsync()
     {
         try
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-1);
-            var end = EndDate?.DateTime ?? DateTime.Now;
+            var end = QueryTimeHelper.NormalizeEnd(EndDate?.DateTime, DateTime.Now);
 
             // 加载流量记录
             var records = await DatabaseService.Db.Queryable<FlowRecord>()
@@ -204,17 +399,46 @@ public partial class ViewCViewModel : TranslatableViewModelBase
                 .ToListAsync();
             FlowRecords = records;
 
-            // 加载模拟量记录
-            var analogRecords = await DatabaseService.Db.Queryable<AnalogRecord>()
-                .Where(r => r.RecordTime >= start && r.RecordTime <= end)
-                .OrderBy(r => r.RecordTime)
-                .ToListAsync();
+            // ── 计算进出水统计卡片 ──
+            if (records.Count > 0)
+            {
+                // 累计量取最新记录的值
+                var latest = records.OrderByDescending(r => r.RecordTime).First();
+                TotalInflow = latest.TotalInflow;
+                TotalOutflow = latest.TotalOutflow;
+                FlowDelta = latest.TotalInflow - latest.TotalOutflow;
+
+                // 当前瞬时流量取最新记录
+                CurrentInflowRate = latest.InflowRate;
+                CurrentOutflowRate = latest.OutflowRate;
+            }
+            else
+            {
+                TotalInflow = 0;
+                TotalOutflow = 0;
+                FlowDelta = 0;
+                CurrentInflowRate = 0;
+                CurrentOutflowRate = 0;
+            }
 
-            UpdateFlowChart(records);
-            UpdateTankLevelChart(analogRecords);
-            UpdatePumpFreqChart(analogRecords);
+            // 收集所有需要查询的 TagName
+            var allTagNames = ChartGroups.SelectMany(g => g.TagNames).Distinct().ToList();
+
+            if (allTagNames.Count > 0)
+            {
+                // 加载模拟量记录
+                var analogRecords = await DatabaseService.Db.Queryable<AnalogRecord>()
+                    .Where(r => r.RecordTime >= start && r.RecordTime <= end
+                                && allTagNames.Contains(r.TagName))
+                    .OrderBy(r => r.RecordTime)
+                    .ToListAsync();
+
+                // 按 TagName 分组后填充到各图表
+                UpdateChartsFromAnalogRecords(analogRecords);
+            }
 
-            Log.Debug("加载了 {Flow} 条流量记录, {Analog} 条模拟量记录", records.Count, analogRecords.Count);
+            Log.Debug("加载了 {Flow} 条流量记录, {ChartGroups} 个图表组已更新",
+                records.Count, ChartGroups.Count);
         }
         catch (Exception ex)
         {
@@ -222,61 +446,38 @@ public partial class ViewCViewModel : TranslatableViewModelBase
         }
     }
 
-    private void UpdateFlowChart(List<FlowRecord> records)
-    {
-        var inflowData = records
-            .GroupBy(r => GetTimeGroupKey(r.RecordTime))
-            .Select(g => new DateTimePoint(g.Key, g.Average(r => r.InflowRate)))
-            .OrderBy(p => p.DateTime)
-            .ToList();
-
-        var outflowData = records
-            .GroupBy(r => GetTimeGroupKey(r.RecordTime))
-            .Select(g => new DateTimePoint(g.Key, g.Average(r => r.OutflowRate)))
-            .OrderBy(p => p.DateTime)
-            .ToList();
-
-        InflowSeries[0].Values = inflowData;
-        OutflowSeries[0].Values = outflowData;
-    }
-
-    private void UpdateTankLevelChart(List<AnalogRecord> records)
+    /// <summary>
+    /// 根据模拟量记录更新所有图表
+    /// </summary>
+    private void UpdateChartsFromAnalogRecords(List<AnalogRecord> records)
     {
-        var tags = new[] { "Tank1Level", "Tank2Level", "Tank3Level", "Tank4Level" };
-        for (int i = 0; i < tags.Length && i < TankLevelSeries.Length; i++)
-        {
-            var data = records
-                .Where(r => r.TagName == tags[i])
-                .GroupBy(r => GetTimeGroupKey(r.RecordTime))
-                .Select(g => new DateTimePoint(g.Key, g.Average(r => r.Value)))
-                .OrderBy(p => p.DateTime)
-                .ToList();
-            TankLevelSeries[i].Values = data;
-        }
-    }
+        // 按 TagName 分组
+        var byTag = records.GroupBy(r => r.TagName).ToDictionary(g => g.Key, g => g.ToList());
 
-    private void UpdatePumpFreqChart(List<AnalogRecord> records)
-    {
-        var tags = new[] { "Pump1Freq", "Pump2Freq", "Pump3Freq", "Pump4Freq", "Pump5Freq" };
-        for (int i = 0; i < tags.Length && i < PumpFreqSeries.Length; i++)
+        foreach (var group in ChartGroups)
         {
-            var data = records
-                .Where(r => r.TagName == tags[i])
-                .GroupBy(r => GetTimeGroupKey(r.RecordTime))
-                .Select(g => new DateTimePoint(g.Key, g.Average(r => r.Value)))
-                .OrderBy(p => p.DateTime)
-                .ToList();
-            PumpFreqSeries[i].Values = data;
+            for (int i = 0; i < group.TagNames.Count && i < group.Series.Length; i++)
+            {
+                var tagName = group.TagNames[i];
+
+                if (byTag.TryGetValue(tagName, out var tagRecords))
+                {
+                    var data = tagRecords
+                        .GroupBy(r => GetTimeGroupKey(r.RecordTime))
+                        .Select(g => new DateTimePoint(g.Key, g.Average(r => r.Value)))
+                        .OrderBy(p => p.DateTime)
+                        .ToList();
+
+                    if (group.Series[i] is LineSeries<DateTimePoint> lineSeries)
+                        lineSeries.Values = data;
+                }
+            }
         }
     }
 
-    /// <summary>
-    /// 根据时间范围自动选择分组精度
-    /// </summary>
-    private static DateTime GetTimeGroupKey(DateTime time)
-    {
-        return time.Date.AddHours(time.Hour).AddMinutes(time.Minute / 5 * 5);
-    }
+    // ═══════════════════════════════════════════════
+    //  导出 / 清理
+    // ═══════════════════════════════════════════════
 
     [RelayCommand]
     private async Task ExportDataAsync()
@@ -284,22 +485,64 @@ public partial class ViewCViewModel : TranslatableViewModelBase
         try
         {
             var filePath = $"FlowRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
-            var lines = new List<string>
-            {
-                "时间,进水流量(m³/h),出水流量(m³/h),累计进水量(m³),累计出水量(m³),备注"
-            };
+            var profile = ProjectService.Current;
 
-            foreach (var record in FlowRecords)
+            var lines = new List<string>();
+
+            if (profile?.Statistics?.Items is { Count: > 0 })
+            {
+                // 动态表头(仅导出的已启用的统计项)
+                var enabledItems = profile.Statistics.Items.Where(i => i.Enabled).ToList();
+                var lang = LanguageService.Instance;
+                var header = lang.Get("CsvHeaderTime") + "," + string.Join(",", enabledItems.Select(i => $"{i.GetLocalizedDisplayName()}({i.Unit})"));
+                lines.Add(header);
+
+                // 从 AnalogRecord 查询对应时间段的数据
+                var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-1);
+                var end = QueryTimeHelper.NormalizeEnd(EndDate?.DateTime, DateTime.Now);
+                var tagNames = enabledItems.Select(i => i.Key).ToList();
+
+                var analogRecords = await DatabaseService.Db.Queryable<AnalogRecord>()
+                    .Where(r => r.RecordTime >= start && r.RecordTime <= end && tagNames.Contains(r.TagName))
+                    .OrderBy(r => r.RecordTime)
+                    .ToListAsync();
+
+                // 按时间分组
+                var timeGroups = analogRecords
+                    .GroupBy(r => GetTimeGroupKey(r.RecordTime))
+                    .OrderBy(g => g.Key);
+
+                foreach (var timeGroup in timeGroups)
+                {
+                    var valuesByTag = timeGroup.GroupBy(r => r.TagName)
+                        .ToDictionary(g => g.Key, g => g.Average(r => r.Value));
+
+                    var row = timeGroup.Key.ToString("yyyy-MM-dd HH:mm:ss");
+                    foreach (var item in enabledItems)
+                    {
+                        var val = valuesByTag.TryGetValue(item.Key, out var v) ? v : 0;
+                        row += $",{val:F1}";
+                    }
+                    lines.Add(row);
+                }
+            }
+            else
             {
-                lines.Add($"{record.RecordTime:yyyy-MM-dd HH:mm:ss},{record.InflowRate},{record.OutflowRate},{record.TotalInflow},{record.TotalOutflow},{record.Remark}");
+                // 旧版 CSV 格式
+                var lang = LanguageService.Instance;
+                lines.Add($"{lang.Get("CsvHeaderTime")},{lang.Get("CsvHeaderInflow")},{lang.Get("CsvHeaderOutflow")},{lang.Get("CsvHeaderTotalIn")},{lang.Get("CsvHeaderTotalOut")},{lang.Get("CsvHeaderRemark")}");
+                foreach (var record in FlowRecords)
+                {
+                    lines.Add($"{record.RecordTime:yyyy-MM-dd HH:mm:ss},{record.InflowRate},{record.OutflowRate},{record.TotalInflow},{record.TotalOutflow},{record.Remark}");
+                }
             }
 
             await File.WriteAllLinesAsync(filePath, lines);
-            Log.Information("流量记录已导出到: {FilePath}", filePath);
+            Log.Information("数据已导出到: {FilePath}", filePath);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "导出流量记录失败");
+            Log.Error(ex, "导出数据失败");
         }
     }
 
@@ -308,16 +551,22 @@ public partial class ViewCViewModel : TranslatableViewModelBase
     {
         try
         {
-            var cutoffDate = DateTime.Now.AddDays(-30);
+            var config = ConfigService.GetConfig();
+            var flowRetention = config?.DataRetention?.FlowRecordRetentionDays ?? 360;
+            var analogRetention = config?.DataRetention?.AnalogRecordRetentionDays ?? 360;
+
+            var flowCutoff = DateTime.Now.AddDays(-flowRetention);
+            var analogCutoff = DateTime.Now.AddDays(-analogRetention);
+
             await DatabaseService.Db.Deleteable<FlowRecord>()
-                .Where(r => r.RecordTime < cutoffDate)
+                .Where(r => r.RecordTime < flowCutoff)
                 .ExecuteCommandAsync();
             await DatabaseService.Db.Deleteable<AnalogRecord>()
-                .Where(r => r.RecordTime < cutoffDate)
+                .Where(r => r.RecordTime < analogCutoff)
                 .ExecuteCommandAsync();
 
             await LoadFlowRecordsAsync();
-            Log.Information("已清除 30 天前的历史数据");
+            Log.Information("已清除过期历史数据(流量 >{Flow}天, 模拟量 >{Analog}天)", flowRetention, analogRetention);
         }
         catch (Exception ex)
         {
@@ -325,6 +574,178 @@ public partial class ViewCViewModel : TranslatableViewModelBase
         }
     }
 
+    // ═══════════════════════════════════════════════
+    //  统计报表(日/月/年)
+    // ═══════════════════════════════════════════════
+
+    /// <summary>切换统计报表模式(day/month/year)</summary>
+    [RelayCommand]
+    private void SetReportMode(string mode)
+    {
+        ReportMode = mode;
+        _ = LoadStatisticsAsync();
+    }
+
+    /// <summary>加载统计报表</summary>
+    [RelayCommand]
+    private async Task LoadStatisticsAsync()
+    {
+        try
+        {
+            var profile = ProjectService.Current;
+            if (profile?.Statistics?.Items == null || profile.Statistics.Items.Count == 0)
+            {
+                Summaries = new ObservableCollection<StatisticsSummary>();
+                return;
+            }
+
+            var date = ReportDate?.DateTime ?? DateTime.Now;
+            var (start, end) = ReportMode switch
+            {
+                "day" => (date.Date, date.Date.AddDays(1).AddTicks(-1)),
+                "month" => (new DateTime(date.Year, date.Month, 1), new DateTime(date.Year, date.Month, DateTime.DaysInMonth(date.Year, date.Month)).AddDays(1).AddTicks(-1)),
+                "year" => (new DateTime(date.Year, 1, 1), new DateTime(date.Year, 12, 31).AddDays(1).AddTicks(-1)),
+                _ => (date.Date, date.Date.AddDays(1).AddTicks(-1))
+            };
+
+            var enabledItems = profile.Statistics.Items.Where(i => i.Enabled).ToList();
+            var summaries = new ObservableCollection<StatisticsSummary>();
+
+            foreach (var item in enabledItems)
+            {
+                var summary = new StatisticsSummary
+                {
+                    TagName = item.Key,
+                    DisplayName = item.GetLocalizedDisplayName(),
+                    Unit = item.Unit,
+                    AggType = item.AggType
+                };
+
+                switch (item.AggType)
+                {
+                    case "delta":
+                        // 累计量:取周期末值 - 初值 = 增量
+                        var firstRecord = await DatabaseService.Db.Queryable<AnalogRecord>()
+                            .Where(r => r.TagName == item.Key && r.RecordTime >= start && r.RecordTime <= end)
+                            .OrderBy(r => r.RecordTime)
+                            .FirstAsync();
+                        var lastRecord = await DatabaseService.Db.Queryable<AnalogRecord>()
+                            .Where(r => r.TagName == item.Key && r.RecordTime >= start && r.RecordTime <= end)
+                            .OrderByDescending(r => r.RecordTime)
+                            .FirstAsync();
+                        if (firstRecord != null && lastRecord != null)
+                            summary.TotalValue = lastRecord.Value - firstRecord.Value;
+                        break;
+
+                    case "max_only":
+                        // 累积值(如日台时):取最大值
+                        var maxResult = await DatabaseService.Db.Queryable<AnalogRecord>()
+                            .Where(r => r.TagName == item.Key && r.RecordTime >= start && r.RecordTime <= end)
+                            .MaxAsync(r => (double?)r.Value);
+                        summary.MaxValue = maxResult;
+                        break;
+
+                    default: // avg_max_min
+                        var records = await DatabaseService.Db.Queryable<AnalogRecord>()
+                            .Where(r => r.TagName == item.Key && r.RecordTime >= start && r.RecordTime <= end)
+                            .ToListAsync();
+                        if (records.Count > 0)
+                        {
+                            summary.AvgValue = records.Average(r => r.Value);
+                            summary.MaxValue = records.Max(r => r.Value);
+                            summary.MinValue = records.Min(r => r.Value);
+                        }
+                        break;
+                }
+
+                summaries.Add(summary);
+            }
+
+            Summaries = summaries;
+            Log.Debug("统计报表已加载: {Mode} 模式, {Count} 个统计项", ReportMode, summaries.Count);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "加载统计报表失败");
+        }
+    }
+
+    // ═══════════════════════════════════════════════
+    //  工具方法
+    // ═══════════════════════════════════════════════
+
+    private static Axis CreateTimeAxis()
+    {
+        // 初始默认显示最近 24 小时(有数据后 LiveCharts 会自动调整范围)
+        var now = DateTime.Now;
+        return new Axis
+        {
+            Labeler = value =>
+            {
+                var dt = new DateTime((long)value);
+                return dt.ToString("MM-dd HH:mm");
+            },
+            UnitWidth = TimeSpan.FromHours(1).Ticks,
+            MinLimit = now.AddHours(-24).Ticks,
+            MaxLimit = now.Ticks
+        };
+    }
+
+    private static LineSeries<DateTimePoint> CreateLineSeries(string name, SKColor color)
+    {
+        return new LineSeries<DateTimePoint>
+        {
+            Name = name,
+            Values = new List<DateTimePoint>(),
+            Stroke = new SolidColorPaint(color, 2),
+            Fill = null,
+            GeometryStroke = null,
+            GeometrySize = 0
+        };
+    }
+
+    private SKColor NextColor()
+    {
+        var color = Palette[_colorIndex % Palette.Length];
+        _colorIndex++;
+        return color;
+    }
+
+    private static SKColor? ParseColor(string? hex)
+    {
+        if (string.IsNullOrEmpty(hex)) return null;
+        try
+        {
+            // 支持 "#RRGGBB" 和 "#AARRGGBB" 格式
+            var h = hex.TrimStart('#');
+            if (h.Length == 6)
+            {
+                var r = Convert.ToByte(h[..2], 16);
+                var g = Convert.ToByte(h[2..4], 16);
+                var b = Convert.ToByte(h[4..6], 16);
+                return new SKColor(r, g, b);
+            }
+            if (h.Length == 8)
+            {
+                var a = Convert.ToByte(h[..2], 16);
+                var r = Convert.ToByte(h[2..4], 16);
+                var g = Convert.ToByte(h[4..6], 16);
+                var b = Convert.ToByte(h[6..8], 16);
+                return new SKColor(r, g, b, a);
+            }
+        }
+        catch { /* ignore parse errors */ }
+        return null;
+    }
+
+    /// <summary>
+    /// 根据时间范围自动选择分组精度(5 分钟桶)
+    /// </summary>
+    private static DateTime GetTimeGroupKey(DateTime time)
+    {
+        return time.Date.AddHours(time.Hour).AddMinutes(time.Minute / 5 * 5);
+    }
+
     public new void Dispose()
     {
         if (_disposed) return;

+ 73 - 7
src/YZWater.Core/ViewModels/ViewDViewModel.cs

@@ -35,6 +35,8 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     [ObservableProperty] private string _detailType = "--";
     [ObservableProperty] private string _detailMessage = "--";
     [ObservableProperty] private string _detailLevel = "--";
+    [ObservableProperty] private string _detailRecoverTime = "--";
+    [ObservableProperty] private string _detailDuration = "--";
 
     // ─── 翻译属性 ───
     public string TitleText => T("AlarmLog");
@@ -45,6 +47,8 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     public string ExportText => T("Export");
     public string AckAllText => T("AckAll");
     public string ResetAllText => T("ResetAll");
+    public string ShelveText => T("Shelve");
+    public string UnshelveText => T("Unshelve");
     public string PurgeText => T("Purge");
     public string UnconfirmedText => T("Unconfirmed");
     public string AlarmDetailText => T("AlarmDetail");
@@ -52,6 +56,18 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     public string TypeLabel => T("Type");
     public string MessageLabel => T("Message");
     public string LevelLabel => T("Level2");
+    public string RecoverTimeLabel => T("RecoverTime");
+    public string DurationLabel => T("Duration");
+    // 表格列头
+    public string TimeHeaderText => T("TimeHeader");
+    public string TypeHeaderText => T("TypeHeader");
+    public string MessageHeaderText => T("MessageHeader");
+    public string ValueHeaderText => T("ValueHeader");
+    public string LevelHeaderText => T("LevelHeader");
+    public string AckTimeHeaderText => T("AckTimeHeader");
+    public string AckByHeaderText => T("AckByHeader");
+    public string RecoverTimeHeaderText => T("RecoverTimeHeader");
+    public string DurationHeaderText => T("DurationHeader");
 
     partial void OnSelectedRecordChanged(AlarmRecord? value)
     {
@@ -59,6 +75,9 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         DetailType = value?.AlarmType ?? "--";
         DetailMessage = value?.AlarmMessage ?? "--";
         DetailLevel = value != null ? $"{T("Level2")} {value.AlarmLevel}" : "--";
+        DetailRecoverTime = value?.RecoverTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "--";
+        value?.UpdateDurationText();
+        DetailDuration = value?.DurationText ?? "--";
     }
 
     public ViewDViewModel()
@@ -71,6 +90,8 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(ExportText), "Export");
         RegisterTranslation(nameof(AckAllText), "AckAll");
         RegisterTranslation(nameof(ResetAllText), "ResetAll");
+        RegisterTranslation(nameof(ShelveText), "Shelve");
+        RegisterTranslation(nameof(UnshelveText), "Unshelve");
         RegisterTranslation(nameof(PurgeText), "Purge");
         RegisterTranslation(nameof(UnconfirmedText), "Unconfirmed");
         RegisterTranslation(nameof(AlarmDetailText), "AlarmDetail");
@@ -78,6 +99,17 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(TypeLabel), "Type");
         RegisterTranslation(nameof(MessageLabel), "Message");
         RegisterTranslation(nameof(LevelLabel), "Level2");
+        RegisterTranslation(nameof(RecoverTimeLabel), "RecoverTime");
+        RegisterTranslation(nameof(DurationLabel), "Duration");
+        RegisterTranslation(nameof(TimeHeaderText), "TimeHeader");
+        RegisterTranslation(nameof(TypeHeaderText), "TypeHeader");
+        RegisterTranslation(nameof(MessageHeaderText), "MessageHeader");
+        RegisterTranslation(nameof(ValueHeaderText), "ValueHeader");
+        RegisterTranslation(nameof(LevelHeaderText), "LevelHeader");
+        RegisterTranslation(nameof(AckTimeHeaderText), "AckTimeHeader");
+        RegisterTranslation(nameof(AckByHeaderText), "AckByHeader");
+        RegisterTranslation(nameof(RecoverTimeHeaderText), "RecoverTimeHeader");
+        RegisterTranslation(nameof(DurationHeaderText), "DurationHeader");
 
         SubscribeLanguageChanged();
 
@@ -88,11 +120,11 @@ public partial class ViewDViewModel : TranslatableViewModelBase
     }
 
     /// <summary>
-    /// 新报警触发回调
+    /// 新报警触发回调 — 重载列表(轮询线程回调,需切回 UI 线程)
     /// </summary>
-    private async void OnAlarmRaised(AlarmRecord alarm)
+    private void OnAlarmRaised(AlarmRecord alarm)
     {
-        await LoadAlarmRecordsAsync();
+        RunOnUi(() => _ = LoadAlarmRecordsAsync());
     }
 
     /// <summary>
@@ -104,11 +136,12 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         try
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
-            var end = EndDate?.DateTime ?? DateTime.Now;
+            var end = QueryTimeHelper.NormalizeEnd(EndDate?.DateTime, DateTime.Now);
             var query = DatabaseService.Db.Queryable<AlarmRecord>()
                 .Where(r => r.AlarmTime >= start && r.AlarmTime <= end);
 
-            if (FilterType != T("All"))
+            // 空字符串与「全部」均表示不过滤类型
+            if (!string.IsNullOrEmpty(FilterType) && FilterType != T("All"))
             {
                 query = query.Where(r => r.AlarmType == FilterType);
             }
@@ -117,6 +150,9 @@ public partial class ViewDViewModel : TranslatableViewModelBase
                 .OrderByDescending(r => r.AlarmTime)
                 .ToListAsync();
 
+            // 计算每条记录的持续时长文本
+            foreach (var r in records) r.UpdateDurationText();
+
             AlarmRecords = records;
             UnconfirmedCount = records.Count(r => !r.IsConfirmed);
 
@@ -206,6 +242,34 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         await LoadAlarmRecordsAsync();
     }
 
+    /// <summary>
+    /// 搁置选中报警(ISA-18.2 Shelve,限时抑制,到期自动恢复评估)
+    /// </summary>
+    [RelayCommand]
+    private async Task ShelveSelectedAlarmAsync()
+    {
+        var conditionId = SelectedRecord?.ConditionId;
+        if (string.IsNullOrEmpty(conditionId)) return;
+
+        var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
+        var minutes = ConfigService.GetConfig()?.Alarm?.DefaultShelveMinutes ?? 30;
+        AlarmService.Instance.Shelve(conditionId, minutes, operatorName);
+        await LoadAlarmRecordsAsync();
+    }
+
+    /// <summary>
+    /// 取消选中报警的搁置
+    /// </summary>
+    [RelayCommand]
+    private void UnshelveSelectedAlarm()
+    {
+        var conditionId = SelectedRecord?.ConditionId;
+        if (string.IsNullOrEmpty(conditionId)) return;
+
+        var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
+        AlarmService.Instance.Unshelve(conditionId, operatorName);
+    }
+
     /// <summary>
     /// 导出报警记录
     /// </summary>
@@ -215,14 +279,16 @@ public partial class ViewDViewModel : TranslatableViewModelBase
         try
         {
             var filePath = $"AlarmRecords_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
+            var lang = LanguageService.Instance;
             var lines = new List<string>
             {
-                "时间,类型,内容,报警值,级别,是否已确认,确认时间,确认人"
+                $"{lang.Get("CsvHeaderTime")},{lang.Get("CsvHeaderType")},{lang.Get("CsvHeaderMessage")},{lang.Get("CsvHeaderAlarmValue")},{lang.Get("CsvHeaderLevel")},{lang.Get("CsvHeaderConfirmed")},{lang.Get("CsvHeaderAckTime")},{lang.Get("CsvHeaderAckBy")},{lang.Get("CsvHeaderRecoverTime")},{lang.Get("CsvHeaderDuration")}"
             };
 
             foreach (var record in AlarmRecords)
             {
-                lines.Add($"{record.AlarmTime:yyyy-MM-dd HH:mm:ss},{record.AlarmType},{record.AlarmMessage},{record.AlarmValue},{record.AlarmLevel},{(record.IsConfirmed ? "是" : "否")},{record.ConfirmedTime:yyyy-MM-dd HH:mm:ss},{record.ConfirmedBy}");
+                record.UpdateDurationText();
+                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);

+ 20 - 6
src/YZWater.Core/ViewModels/ViewEViewModel.cs

@@ -11,29 +11,35 @@ namespace YZWater.Core.ViewModels;
 public partial class ViewEViewModel : TranslatableViewModelBase
 {
     [ObservableProperty]
-    private string _systemName = "污水处理监控系统";
+    private string _systemName = "";
 
     [ObservableProperty]
     private string _version = "3.0.0";
 
     [ObservableProperty]
-    private string _companyName = "扬州旭轩科技有限公司";
+    private string _companyName = "";
 
     [ObservableProperty]
-    private string _contactPerson = "赵经理";
+    private string _contactPerson = "";
 
     [ObservableProperty]
     private string _contactPhone = "18115099090";
 
     [ObservableProperty]
-    private string _copyright = $"© {DateTime.Now.Year} 扬州旭轩科技有限公司";
+    private string _copyright = "";
 
     [ObservableProperty]
-    private string _description = "基于 Avalonia UI 的跨平台污水处理厂监控系统";
+    private string _description = "";
 
     [ObservableProperty]
     private string _techStack = "Avalonia UI 12 | .NET 8 | SqlSugar | HslCommunication";
 
+    [ObservableProperty]
+    private string _websiteUrl = string.Empty;
+
+    [ObservableProperty]
+    private bool _showVisitWebsiteButton;
+
     // ─── 翻译属性 ───
     public string TitleText => T("About");
     public string SubtitleText => T("SystemInfo");
@@ -76,23 +82,31 @@ public partial class ViewEViewModel : TranslatableViewModelBase
 
     private void LoadCompanyInfo()
     {
+        // 始终从翻译设置系统名、描述、版权
+        SystemName = T("AppName");
+        Description = T("Description");
+        Copyright = T("Copyright");
+
         var config = ConfigService.GetConfig();
         if (config != null)
         {
             CompanyName = config.CompanyName;
             ContactPerson = config.ContactPerson;
             ContactPhone = config.ContactPhone;
+            WebsiteUrl = config.WebsiteUrl;
+            ShowVisitWebsiteButton = !string.IsNullOrWhiteSpace(config.WebsiteUrl);
         }
     }
 
     [RelayCommand]
     private void OpenWebsite()
     {
+        if (string.IsNullOrWhiteSpace(WebsiteUrl)) return;
         try
         {
             Process.Start(new ProcessStartInfo
             {
-                FileName = "https://www.yangzhou-xu.com",
+                FileName = WebsiteUrl,
                 UseShellExecute = true
             });
         }

+ 18 - 8
src/YZWater.Core/ViewModels/ViewFViewModel.cs

@@ -48,8 +48,9 @@ public partial class ViewFViewModel : TranslatableViewModelBase
     public string DetailHeaderText => T("Detail");
     public string TargetHeaderText => T("Target");
     public string ResultHeaderText => T("Result");
-    public string WorkflowsText => "工作流";
-    public string WorkflowEngineStatusText => IsWorkflowEngineRunning ? "运行中" : "已停止";
+    public string WorkflowsText => T("Workflows");
+    public string WorkflowEngineStatusText => IsWorkflowEngineRunning ? T("EngineRunning") : T("EngineStopped");
+    public string StepCountLabelText => T("StepCountLabel");
 
     /// <summary>
     /// 工作流信息(用于 UI 展示)
@@ -59,7 +60,7 @@ public partial class ViewFViewModel : TranslatableViewModelBase
         public string Name { get; set; } = "";
         public string Description { get; set; } = "";
         public int StepCount { get; set; }
-        public string Status { get; set; } = "待执行";
+        public string Status { get; set; } = "";
     }
 
     public ViewFViewModel()
@@ -77,16 +78,25 @@ public partial class ViewFViewModel : TranslatableViewModelBase
         RegisterTranslation(nameof(DetailHeaderText), "Detail");
         RegisterTranslation(nameof(TargetHeaderText), "Target");
         RegisterTranslation(nameof(ResultHeaderText), "Result");
+        RegisterTranslation(nameof(WorkflowsText), "Workflows");
+        RegisterTranslation(nameof(WorkflowEngineStatusText), "EngineRunning");
+        RegisterTranslation(nameof(StepCountLabelText), "StepCountLabel");
 
         SubscribeLanguageChanged();
         _ = LoadLogsAsync();
         LoadWorkflows();
     }
 
+    /// <summary>引擎运行状态变化时刷新状态文本</summary>
+    partial void OnIsWorkflowEngineRunningChanged(bool value)
+    {
+        OnPropertyChanged(nameof(WorkflowEngineStatusText));
+    }
+
     /// <summary>
-    /// 加载工作流列表
+    /// 加载工作流列表(公开:进入页面时刷新,避免启动时引擎未初始化导致状态冻结)
     /// </summary>
-    private void LoadWorkflows()
+    public void LoadWorkflows()
     {
         try
         {
@@ -99,7 +109,7 @@ public partial class ViewFViewModel : TranslatableViewModelBase
                 Name = w.Name,
                 Description = w.Description,
                 StepCount = w.Steps?.Count ?? 0,
-                Status = "就绪"
+                Status = LanguageService.Instance.Get("WorkflowReady")
             }).ToList();
 
             Log.Debug("加载了 {Count} 个工作流", Workflows.Count);
@@ -116,7 +126,7 @@ public partial class ViewFViewModel : TranslatableViewModelBase
         try
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
-            var end = EndDate?.DateTime ?? DateTime.Now;
+            var end = QueryTimeHelper.NormalizeEnd(EndDate?.DateTime, DateTime.Now);
 
             var action = FilterAction == T("All") ? null : FilterAction;
             var logs = await AuditService.QueryAsync(start, end, action: action);
@@ -139,7 +149,7 @@ public partial class ViewFViewModel : TranslatableViewModelBase
             var filePath = $"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")}"
             };
 
             foreach (var log in AuditLogs)

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

@@ -21,6 +21,7 @@
     <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
     <PackageReference Include="SkiaSharp" Version="4.151.1" />
     <PackageReference Include="Stateless" Version="5.20.1" />
+    <PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
   </ItemGroup>
 
 </Project>

+ 2 - 2
tests/YZWater.Core.Tests/Models/SystemConfigTests.cs

@@ -12,8 +12,8 @@ public class SystemConfigTests
     {
         var config = new SystemConfig();
 
-        Assert.Equal("192.168.0.150", config.PlcIp);
-        Assert.Equal(5000, config.PlcPort);
+        Assert.Equal("192.168.2.100", config.PlcIp);
+        Assert.Equal(102, config.PlcPort);
         Assert.True(config.AutoConnect);
         Assert.Equal(80f, config.LevelHighAlarm);
         Assert.Equal(20f, config.LevelLowAlarm);

+ 93 - 0
tests/YZWater.Core.Tests/Models/TagQualityTests.cs

@@ -0,0 +1,93 @@
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Tests.Models;
+
+/// <summary>
+/// 测点级数据质量(Good/Bad)语义测试:
+/// 读取失败的测点必须保留最后一次有效值并把质量标为 Bad,
+/// 而不是把 0/false 当真实值交给报警、自动控制、历史记录去用。
+/// </summary>
+public class TagQualityTests
+{
+    private static PlcDataModel NewProfileModel()
+    {
+        var data = new PlcDataModel();
+        data.InitializeDynamicRegisters(new ProjectProfile
+        {
+            Tanks = { new TankConfig { Name = "调节池", LevelAddress = "DB1,DWD0", StopLevelAddress = "DB1,DWD4" } },
+            Pumps = { new PumpConfig { Name = "1#泵", RunAddress = "Q0.0", FaultAddress = "I0.0" } }
+        });
+        return data;
+    }
+
+    [Fact]
+    public void ConfiguredTags_StartAsBadUntilFirstSuccessfulRead()
+    {
+        var data = NewProfileModel();
+
+        // 档案里登记过但一次都没读到的测点(地址填错、被轮询跳过)不能当好数据用
+        Assert.True(data.IsTagBad("调节池.Level"));
+        Assert.True(data.IsTagBad("1#泵.Run"));
+        Assert.Equal(0f, data.GetAnalogRegister("调节池.Level"));
+    }
+
+    [Fact]
+    public void UnknownKey_IsNotReportedBad()
+    {
+        var data = NewProfileModel();
+
+        // 档案里没有的键(旧版硬编码 TagName)由调用方的默认值语义负责,不谎报质量差
+        Assert.False(data.IsTagBad("InflowRate"));
+    }
+
+    [Fact]
+    public void FailedReadKeepsLastValidValueAndMarksBad()
+    {
+        var data = NewProfileModel();
+
+        data.SetTagQuality("调节池.Level", true);
+        data.AnalogRegisters["调节池.Level"] = 5.5f;
+
+        data.SetTagQuality("调节池.Level", false);
+
+        Assert.True(data.IsTagBad("调节池.Level"));
+        Assert.Equal(5.5f, data.GetAnalogRegister("调节池.Level"));
+    }
+
+    [Fact]
+    public void SetTagQuality_ReportsDirectionOfTransition()
+    {
+        var data = NewProfileModel();
+        data.SetTagQuality("调节池.Level", false);
+
+        var noChange = data.SetTagQuality("调节池.Level", false);
+        Assert.False(noChange.Changed);
+        Assert.False(noChange.WasReadable);
+
+        // 首次读成功:变化前不是 Good,属于启动阶段,不该按"恢复正常"通知操作员
+        var firstGood = data.SetTagQuality("调节池.Level", true);
+        Assert.True(firstGood.Changed);
+        Assert.False(firstGood.WasReadable);
+
+        // Good→Bad 才是要通知操作员的跳变
+        var lost = data.SetTagQuality("调节池.Level", false);
+        Assert.True(lost.Changed);
+        Assert.True(lost.WasReadable);
+    }
+
+    [Fact]
+    public void SnapshotCopiesRegistersAndQuality()
+    {
+        var source = NewProfileModel();
+        source.SetTagQuality("调节池.Level", true);
+        source.AnalogRegisters["调节池.Level"] = 3.2f;
+        source.SetTagQuality("1#泵.Run", false);
+
+        var snapshot = new PlcDataModel();
+        snapshot.CopyDynamicFrom(source);
+
+        Assert.Equal(3.2f, snapshot.GetAnalogRegister("调节池.Level"));
+        Assert.False(snapshot.IsTagBad("调节池.Level"));
+        Assert.True(snapshot.IsTagBad("1#泵.Run"));
+    }
+}

+ 75 - 0
tests/YZWater.Core.Tests/Services/AlarmManagementTests.cs

@@ -0,0 +1,75 @@
+using Xunit;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// ISA-18.2 报警管理 + 数据缺口 单元测试(搁置状态、配置默认值、缺口时长计算)
+/// </summary>
+public class AlarmManagementTests
+{
+    [Fact]
+    public void Shelve_ShouldMarkConditionAsShelved()
+    {
+        var service = AlarmService.Instance;
+        service.Shelve("TestTank_High", 30, "Tester");
+
+        Assert.True(service.IsShelved("TestTank_High"));
+        Assert.Contains(service.GetShelvedAlarms(), s => s.ConditionId == "TestTank_High");
+
+        service.Unshelve("TestTank_High", "Tester");
+        Assert.False(service.IsShelved("TestTank_High"));
+    }
+
+    [Fact]
+    public void Shelve_InvalidArgs_ShouldBeIgnored()
+    {
+        var service = AlarmService.Instance;
+        service.Shelve("TestTank_Invalid", 0);
+        Assert.False(service.IsShelved("TestTank_Invalid"));
+    }
+
+    [Fact]
+    public void IsShelved_UnknownCondition_ShouldBeFalse()
+    {
+        Assert.False(AlarmService.Instance.IsShelved("NoSuchCondition"));
+    }
+
+    [Fact]
+    public void AlarmManagementConfig_Defaults()
+    {
+        var config = new AlarmManagementConfig();
+        Assert.True(config.EnableFloodSuppression);
+        Assert.Equal(6, config.FloodRaiseThreshold);
+        Assert.Equal(60, config.FloodWindowSeconds);
+        Assert.True(config.EnableFirstOut);
+        Assert.Equal(5, config.FirstOutWindowSeconds);
+        Assert.Equal(30, config.DefaultShelveMinutes);
+    }
+
+    [Fact]
+    public void SystemConfig_ShouldExposeAlarmSection()
+    {
+        var config = new SystemConfig();
+        Assert.NotNull(config.Alarm);
+        Assert.Equal(6, config.Alarm.FloodRaiseThreshold);
+    }
+
+    [Fact]
+    public void DataGapRecord_OpenGap_DurationTextUsesNow()
+    {
+        var gap = new DataGapRecord { StartTime = DateTime.Now.AddMinutes(-5) };
+        Assert.True(gap.IsOpen);
+        Assert.Contains("m", gap.DurationText);
+    }
+
+    [Fact]
+    public void DataGapRecord_ClosedGap_DurationTextUsesRange()
+    {
+        var start = new DateTime(2026, 9, 25, 10, 0, 0);
+        var gap = new DataGapRecord { StartTime = start, EndTime = start.AddMinutes(30) };
+        Assert.False(gap.IsOpen);
+        Assert.Equal("30m 0s", gap.DurationText);
+    }
+}

+ 43 - 0
tests/YZWater.Core.Tests/Services/AppPathsTests.cs

@@ -0,0 +1,43 @@
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 路径锚定测试:数据/配置/日志必须落在程序目录下,而不是当前工作目录
+/// (当前工作目录随启动方式变化,会让程序读不到自己的数据)
+/// </summary>
+public class AppPathsTests
+{
+    [Theory]
+    [InlineData(nameof(AppPaths.Database))]
+    [InlineData(nameof(AppPaths.Config))]
+    [InlineData(nameof(AppPaths.ConfigLastGood))]
+    [InlineData(nameof(AppPaths.Projects))]
+    [InlineData(nameof(AppPaths.Backups))]
+    [InlineData(nameof(AppPaths.Logs))]
+    public void Path_IsAnchoredToExecutableDirectory(string name)
+    {
+        var value = name switch
+        {
+            nameof(AppPaths.Database) => AppPaths.Database,
+            nameof(AppPaths.Config) => AppPaths.Config,
+            nameof(AppPaths.ConfigLastGood) => AppPaths.ConfigLastGood,
+            nameof(AppPaths.Projects) => AppPaths.Projects,
+            nameof(AppPaths.Backups) => AppPaths.Backups,
+            nameof(AppPaths.Logs) => AppPaths.Logs,
+            _ => throw new ArgumentOutOfRangeException(nameof(name))
+        };
+
+        Assert.True(Path.IsPathRooted(value), $"{name} 必须是绝对路径");
+        Assert.StartsWith(Path.GetFullPath(AppContext.BaseDirectory), Path.GetFullPath(value));
+    }
+
+    [Fact]
+    public void Database_And_Config_UseExpectedFileNames()
+    {
+        Assert.Equal("yzwater.db", Path.GetFileName(AppPaths.Database));
+        Assert.Equal("yzwater-config.json", Path.GetFileName(AppPaths.Config));
+        // 备份副本必须与主配置同名加后缀:FileSystemWatcher 只监听主配置文件名
+        Assert.Equal("yzwater-config.json.lastgood", Path.GetFileName(AppPaths.ConfigLastGood));
+    }
+}

+ 93 - 0
tests/YZWater.Core.Tests/Services/ConfigProtectionTests.cs

@@ -0,0 +1,93 @@
+using System.Text.Json;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 配置文件路径锚定 + 敏感项落盘加密的回归测试
+/// </summary>
+public class ConfigProtectionTests
+{
+    private const string Token = "Hub-Token-S3cr3t";
+
+    [Fact]
+    public void HubToken_SerializedAsCiphertext()
+    {
+        if (!OperatingSystem.IsWindows()) return; // DPAPI 不可用时按明文降级
+
+        var config = new SystemConfig { HubToken = Token };
+        var json = JsonSerializer.Serialize(config);
+
+        Assert.DoesNotContain(Token, json);
+        Assert.Contains("enc:", json);
+    }
+
+    [Fact]
+    public void HubToken_SurvivesJsonRoundTrip()
+    {
+        if (!OperatingSystem.IsWindows()) return;
+
+        var json = JsonSerializer.Serialize(new SystemConfig { HubToken = Token });
+
+        ProtectedStringJsonConverter.BeginRead();
+        var restored = JsonSerializer.Deserialize<SystemConfig>(json);
+
+        Assert.Equal(Token, restored?.HubToken);
+        Assert.False(ProtectedStringJsonConverter.NeedsRewrite);
+    }
+
+    [Fact]
+    public void LegacyPlaintextHubToken_StillReadableAndMarkedForRewrite()
+    {
+        ProtectedStringJsonConverter.BeginRead();
+        var restored = JsonSerializer.Deserialize<SystemConfig>("{\"HubToken\":\"" + Token + "\"}");
+
+        Assert.Equal(Token, restored?.HubToken);
+        Assert.True(ProtectedStringJsonConverter.NeedsRewrite);
+
+        // ConfigService 据此回写一次,第二次加载就不该再要求改写
+        var rewritten = JsonSerializer.Serialize(restored);
+        ProtectedStringJsonConverter.BeginRead();
+        var reloaded = JsonSerializer.Deserialize<SystemConfig>(rewritten);
+
+        Assert.Equal(Token, reloaded?.HubToken);
+        Assert.False(ProtectedStringJsonConverter.NeedsRewrite);
+    }
+
+    [Fact]
+    public void UndecryptableCiphertext_FallsBackToEmptyInsteadOfThrowing()
+    {
+        // 配置文件从别的机器/别的 Windows 用户拷来时会走到这里
+        ProtectedStringJsonConverter.BeginRead();
+        var restored = JsonSerializer.Deserialize<SystemConfig>("{\"HubToken\":\"enc:not-valid-cipher-text\"}");
+
+        Assert.True(string.IsNullOrEmpty(restored?.HubToken));
+        Assert.True(ProtectedStringJsonConverter.DecryptFailed);
+
+        // 关键:解不开不等于"等待迁移"。若此处要求回写,ConfigService 会把这段密文
+        // 覆盖成空值(还会顺手污染 .lastgood),操作员原机上配的令牌就彻底没了。
+        Assert.False(ProtectedStringJsonConverter.NeedsRewrite);
+    }
+
+    [Fact]
+    public void LegacyPlaintext_MarksRewriteButNotDecryptFailure()
+    {
+        ProtectedStringJsonConverter.BeginRead();
+        JsonSerializer.Deserialize<SystemConfig>("{\"HubToken\":\"" + Token + "\"}");
+
+        Assert.True(ProtectedStringJsonConverter.NeedsRewrite);
+        Assert.False(ProtectedStringJsonConverter.DecryptFailed);
+    }
+
+    [Fact]
+    public void EmptyHubToken_StaysEmptyAndNeedsNoRewrite()
+    {
+        ProtectedStringJsonConverter.BeginRead();
+        var restored = JsonSerializer.Deserialize<SystemConfig>(
+            JsonSerializer.Serialize(new SystemConfig { HubToken = string.Empty }));
+
+        Assert.Equal(string.Empty, restored?.HubToken);
+        Assert.False(ProtectedStringJsonConverter.NeedsRewrite);
+    }
+}

+ 33 - 0
tests/YZWater.Core.Tests/Services/ExitConfirmTextTests.cs

@@ -0,0 +1,33 @@
+using YZWater.Core.Services;
+using Xunit;
+
+namespace YZWater.Core.Tests.Services;
+
+/// <summary>
+/// 退出确认框文案:确保中英两份字典都有键。
+/// Get() 对缺失键回退成键名本身,所以判据用"两种语言结果不同",
+/// 不能用"文本不等于键名"(["Cancel"] = "Cancel" 这类同义项是合法的)。
+/// </summary>
+[Collection("Language")]
+public class ExitConfirmTextTests : IDisposable
+{
+    private static readonly string[] Keys = { "ExitConfirmTitle", "ExitConfirmMessage", "Confirm", "Cancel" };
+
+    public void Dispose() => LanguageService.Instance.IsChinese = true;
+
+    [Fact]
+    public void ExitDialogKeys_ResolveToDifferentTextInBothLanguages()
+    {
+        foreach (var key in Keys)
+        {
+            LanguageService.Instance.IsChinese = true;
+            var zh = LanguageService.Instance.Get(key);
+            LanguageService.Instance.IsChinese = false;
+            var en = LanguageService.Instance.Get(key);
+
+            Assert.False(string.IsNullOrWhiteSpace(zh), $"{key} 中文文案为空");
+            Assert.False(string.IsNullOrWhiteSpace(en), $"{key} 英文文案为空");
+            Assert.NotEqual(zh, en);
+        }
+    }
+}

+ 175 - 0
tests/YZWater.Core.Tests/ViewModels/LocalizedTagKeyTests.cs

@@ -0,0 +1,175 @@
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+using YZWater.Core.ViewModels;
+
+namespace YZWater.Core.Tests.ViewModels;
+
+/// <summary>
+/// PLC 寄存器键必须与界面语言无关:档案里的原始设备名("1#泵")是键的一部分,
+/// 显示名(英文界面下的 "Pump #1")只能用于界面。
+/// 一旦用显示名拼键,切到英文后所有键都对不上——测点变成"未登记",
+/// IsTagBad 判不出 Bad,互锁按可信打开,阈值行还会用 0 初始化并被同步进 PLC。
+/// </summary>
+[Collection("Language")]
+public class LocalizedTagKeyTests : IDisposable
+{
+    private const string RawPump = "1#泵";
+    private const string RawTank = "集水池";
+
+    private static ProjectProfile NewProfile() => new()
+    {
+        ModeSelectAddress = "M1.0",
+        Pumps =
+        {
+            new PumpConfig
+            {
+                Name = RawPump,
+                Names = new Dictionary<string, string> { ["en"] = "Pump #1" },
+                RunAddress = "Q0.1",
+                AutoStateAddress = "M2.6",
+            }
+        },
+        Tanks =
+        {
+            new TankConfig
+            {
+                Name = RawTank,
+                Names = new Dictionary<string, string> { ["en"] = "Collection Tank" },
+                LevelAddress = "VD62",
+                StopLevelAddress = "VD528",
+            }
+        },
+    };
+
+    /// <summary>按轮询服务的约定造一份"全部读成功"的数据</summary>
+    private static PlcDataModel NewGoodData(ProjectProfile profile)
+    {
+        var data = new PlcDataModel();
+        data.InitializeDynamicRegisters(profile);
+
+        data.SetTagQuality($"{RawPump}.Run", true);
+        data.DigitalRegisters[$"{RawPump}.Run"] = true;
+        data.SetTagQuality($"{RawPump}.AutoMode", true);
+        data.SetTagQuality($"{RawTank}.Level", true);
+        data.AnalogRegisters[$"{RawTank}.Level"] = 2.4f;
+        data.SetTagQuality($"{RawTank}.StopLevel", true);
+        data.AnalogRegisters[$"{RawTank}.StopLevel"] = 1.5f;
+        return data;
+    }
+
+    public void Dispose() => LanguageService.Instance.IsChinese = true;
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void PumpCard_ReadsRegistersRegardlessOfLanguage(bool chinese)
+    {
+        LanguageService.Instance.IsChinese = chinese;
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var pump = new PumpItemViewModel(profile.Pumps[0]);
+        pump.UpdateFromData(data, modeSelectConfigured: true, centralMode: true);
+
+        Assert.True(pump.IsRunning);
+        Assert.True(pump.ModeDataTrusted);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void TankLevel_ReadsRegistersRegardlessOfLanguage(bool chinese)
+    {
+        LanguageService.Instance.IsChinese = chinese;
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var tank = new TankItemViewModel(profile.Tanks[0], pumpCount: 1);
+        tank.UpdateFromData(data);
+
+        Assert.Equal(2.4f, tank.Level);
+    }
+
+    /// <summary>
+    /// 阈值行的编辑框只能被"真正读到的值"初始化;
+    /// 用 0 初始化后操作员点同步就会把停泵/保护液位写成 0。
+    /// </summary>
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void ThresholdEdit_InitializesFromRegisterNotZero(bool chinese)
+    {
+        LanguageService.Instance.IsChinese = chinese;
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var tank = new TankItemViewModel(profile.Tanks[0], pumpCount: 1);
+        tank.UpdateFromData(data);
+
+        var stopRow = tank.ThresholdRows.First(r => r.ConfigProperty == "StopLevel");
+        Assert.True(stopRow.EditInitialized);
+        Assert.Equal(1.5f, stopRow.EditValue);
+    }
+
+    /// <summary>语言在构造之后才切换时,已经建好的键也不能失效</summary>
+    /// <summary>
+    /// 全局中控/屏控共用一个地址,键挂在第一个泵名下。
+    /// 首轮没读到之前必须是 Bad:否则寄存器默认 false 会被读成"中控",
+    /// 启停/切模式按钮在操作员根本不知道 PLC 处于就地控制时就可用。
+    /// </summary>
+    [Fact]
+    public void ModeSelectKey_IsBadUntilFirstSuccessfulRead()
+    {
+        var profile = NewProfile();
+        var data = new PlcDataModel();
+        data.InitializeDynamicRegisters(profile);
+
+        Assert.True(data.IsTagBad($"{RawPump}.ModeSelect"));
+    }
+
+    [Fact]
+    public void UnreadableModeSelect_UntrustedFeedbackDisablesControl()
+    {
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var pump = new PumpItemViewModel(profile.Pumps[0]);
+        // centralMode = null 表示本轮 ModeSelect 读不到(ProjectViewModel 里的判法)
+        pump.UpdateFromData(data, modeSelectConfigured: true, centralMode: null);
+
+        Assert.False(pump.ModeDataTrusted);
+    }
+
+    /// <summary>档案没配模式选择地址时没有就地/远程概念,不该因此锁死手动控制</summary>
+    [Fact]
+    public void MissingModeSelectAddress_KeepsControlAvailable()
+    {
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var pump = new PumpItemViewModel(profile.Pumps[0]);
+        pump.UpdateFromData(data, modeSelectConfigured: false, centralMode: null);
+
+        Assert.True(pump.ModeDataTrusted);
+        Assert.True(pump.IsCentralMode);
+    }
+
+    [Fact]
+    public void KeysStayValidWhenLanguageTogglesAfterConstruction()
+    {
+        var profile = NewProfile();
+        var data = NewGoodData(profile);
+
+        var tank = new TankItemViewModel(profile.Tanks[0], pumpCount: 1);
+        var pump = new PumpItemViewModel(profile.Pumps[0]);
+        LanguageService.Instance.IsChinese = false;
+
+        tank.UpdateFromData(data);
+        pump.UpdateFromData(data, modeSelectConfigured: true, centralMode: true);
+
+        Assert.Equal(2.4f, tank.Level);
+        Assert.True(pump.IsRunning);
+        var stopRow = tank.ThresholdRows.First(r => r.RegisterKey.EndsWith("StopLevel"));
+        Assert.Equal(1.5f, stopRow.Value);
+    }
+}

Деякі файли не було показано, через те що забагато файлів було змінено