纾 鏇 锌褉械 4 写邪薪邪
褉芯写懈褌械褭
泻芯屑懈褌
1ff762f262
56 懈蟹屑械褮械薪懈褏 褎邪褬谢芯胁邪 褋邪 4501 写芯写邪褌芯516 褍泻谢芯褮械薪芯
  1. 124 0
      CLAUDE.md
  2. 9 0
      src/YZWater.Avalonia/App.axaml
  3. 302 21
      src/YZWater.Avalonia/App.axaml.cs
  4. BIN
      src/YZWater.Avalonia/Assets/app-icon.png
  5. 1 1
      src/YZWater.Avalonia/Controls/FanControl.cs
  6. 1 1
      src/YZWater.Avalonia/Controls/GaugeControl.cs
  7. 1 1
      src/YZWater.Avalonia/Controls/StatusCard.cs
  8. 1 1
      src/YZWater.Avalonia/Controls/ValveControl.cs
  9. 8 0
      src/YZWater.Avalonia/Converters/BoolConverters.cs
  10. 66 0
      src/YZWater.Avalonia/Views/LoginView.axaml
  11. 63 0
      src/YZWater.Avalonia/Views/LoginView.axaml.cs
  12. 116 6
      src/YZWater.Avalonia/Views/MainWindow.axaml
  13. 22 0
      src/YZWater.Avalonia/Views/MainWindow.axaml.cs
  14. 6 9
      src/YZWater.Avalonia/Views/ViewAView.axaml
  15. 1 4
      src/YZWater.Avalonia/Views/ViewBView.axaml
  16. 81 51
      src/YZWater.Avalonia/Views/ViewCView.axaml
  17. 1 4
      src/YZWater.Avalonia/Views/ViewDView.axaml
  18. 1 4
      src/YZWater.Avalonia/Views/ViewEView.axaml
  19. 41 0
      src/YZWater.Avalonia/Views/ViewFView.axaml
  20. 11 0
      src/YZWater.Avalonia/Views/ViewFView.axaml.cs
  21. 10 10
      src/YZWater.Avalonia/YZWater.Avalonia.csproj
  22. 5 3
      src/YZWater.Core/Models/AlarmRecord.cs
  23. 28 0
      src/YZWater.Core/Models/AnalogRecord.cs
  24. 50 0
      src/YZWater.Core/Models/AuditLog.cs
  25. 5 0
      src/YZWater.Core/Models/EquipmentStatus.cs
  26. 4 3
      src/YZWater.Core/Models/FlowRecord.cs
  27. 148 0
      src/YZWater.Core/Models/HubMessage.cs
  28. 4 3
      src/YZWater.Core/Models/Person.cs
  29. 48 0
      src/YZWater.Core/Models/SystemConfig.cs
  30. 75 0
      src/YZWater.Core/Models/User.cs
  31. 108 24
      src/YZWater.Core/Services/AlarmService.cs
  32. 69 0
      src/YZWater.Core/Services/AuditService.cs
  33. 351 0
      src/YZWater.Core/Services/AuthService.cs
  34. 135 0
      src/YZWater.Core/Services/BackupService.cs
  35. 49 0
      src/YZWater.Core/Services/ConfigService.cs
  36. 191 24
      src/YZWater.Core/Services/DataLoggingService.cs
  37. 50 29
      src/YZWater.Core/Services/DatabaseService.cs
  38. 379 0
      src/YZWater.Core/Services/HubClient.cs
  39. 422 0
      src/YZWater.Core/Services/HubServer.cs
  40. 252 0
      src/YZWater.Core/Services/LanguageService.cs
  41. 35 0
      src/YZWater.Core/Services/LogService.cs
  42. 135 0
      src/YZWater.Core/Services/MockPlcService.cs
  43. 43 21
      src/YZWater.Core/Services/PlcConfig.cs
  44. 110 55
      src/YZWater.Core/Services/PlcPollingService.cs
  45. 47 0
      src/YZWater.Core/Services/PlcService.cs
  46. 217 0
      src/YZWater.Core/Services/ReportService.cs
  47. 0 65
      src/YZWater.Core/Utils/Nlogger.cs
  48. 111 0
      src/YZWater.Core/ViewModels/LoginViewModel.cs
  49. 194 34
      src/YZWater.Core/ViewModels/MainViewModel.cs
  50. 10 0
      src/YZWater.Core/ViewModels/ViewAViewModel.cs
  51. 44 5
      src/YZWater.Core/ViewModels/ViewBViewModel.cs
  52. 176 110
      src/YZWater.Core/ViewModels/ViewCViewModel.cs
  53. 16 16
      src/YZWater.Core/ViewModels/ViewDViewModel.cs
  54. 115 0
      src/YZWater.Core/ViewModels/ViewFViewModel.cs
  55. 9 11
      src/YZWater.Core/YZWater.Core.csproj
  56. BIN
      xxx.xlsx

+ 124 - 0
CLAUDE.md

@@ -0,0 +1,124 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 椤圭洰姒傝堪
+
+YZWater3 鏄壃宸炴棴杞╃鎶鏈夐檺鍏徃寮鍙戠殑璺ㄥ钩鍙版薄姘村鐞嗗巶鐩戞帶绯荤粺锛屽熀浜 Avalonia UI 12 + .NET 8锛屾敮鎸 Windows/Linux/macOS銆
+
+## 甯哥敤鍛戒护
+
+```bash
+# 杩樺師渚濊禆
+dotnet restore
+
+# 鏋勫缓
+dotnet build
+
+# 杩愯锛堥粯璁や粠 src/YZWater.Avalonia 鍚姩锛
+dotnet run --project src/YZWater.Avalonia
+
+# 鍙戝竷鍗曟枃浠讹紙Windows锛
+dotnet publish src/YZWater.Avalonia -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
+```
+
+## 椤圭洰缁撴瀯
+
+涓や釜椤圭洰锛孉valonia 渚濊禆 Core锛
+
+```
+YZWater3.sln
+src/
+鈹溾攢鈹 YZWater.Core/           # 鏍稿績涓氬姟閫昏緫锛圲I 鏃犲叧锛
+鈹   鈹溾攢鈹 Models/             # 鏁版嵁妯″瀷锛圫qlSugar ORM锛
+鈹   鈹溾攢鈹 Services/           # 鎵鏈変笟鍔℃湇鍔★紙鍗曚緥妯″紡涓轰富锛
+鈹   鈹溾攢鈹 ViewModels/         # CommunityToolkit.Mvvm ViewModel
+鈹   鈹斺攢鈹 Utils/              # WinSize銆丷esolutionManager
+鈹
+鈹斺攢鈹 YZWater.Avalonia/       # UI 灞
+    鈹溾攢鈹 Views/              # AXAML 瑙嗗浘锛圴iewA~ViewF + Login + Main锛
+    鈹溾攢鈹 Controls/           # 鑷畾涔夊伐涓氭帶浠 + ThemeHelper
+    鈹溾攢鈹 Themes/             # IndustrialTheme / IndustrialThemeLight / IndustrialStyles
+    鈹溾攢鈹 Converters/
+    鈹斺攢鈹 Services/
+```
+
+## 鍏抽敭鏋舵瀯
+
+### 杩愯妯″紡锛堥氳繃 `yzwater-config.json` 鈫 `RunMode` 閰嶇疆锛
+
+| 妯″紡 | 琛屼负 |
+|------|------|
+| `Direct` | 鐩磋繛 PLC锛堥粯璁わ級 |
+| `Server` | 杩 PLC + 鍚姩 WebSocket 鏈嶅姟绔紙HubServer锛岀鍙 8765锛 |
+| `Client` | 杩炴帴 WebSocket 鏈嶅姟绔帴鏀舵暟鎹紝涓嶇洿杩 PLC |
+| `Mock` | 鏃 PLC 鏃朵娇鐢 MockPlcService 鐢熸垚妯℃嫙鏁版嵁 |
+
+### 鏈嶅姟灞傦紙YZWater.Core/Services锛
+
+- **鍗曚緥鏈嶅姟**锛堥渶 `ResetInstance()` 鍚庨噸寤猴紝鐢ㄤ簬鐧诲嚭/閲嶇櫥褰曪級锛歚PlcPollingService`銆乣AlarmService`
+- **闈欐佹湇鍔**锛歚PlcService`锛圚slCommunication 瑗块棬瀛 S7锛夈乣ConfigService`锛圝SON 閰嶇疆锛夈乣DatabaseService`锛圫qlSugar + SQLite锛夈乣AuthService`銆乣MockPlcService`
+- **HubServer / HubClient**锛歐ebSocket 涓帶閫氫俊锛屾敮鎸佹搷浣滈攣銆佽繙绋嬪懡浠わ紙`start_pump`/`stop_pump`锛
+- **PlcConfig**锛氭墍鏈 PLC 鍦板潃鏄犲皠闆嗕腑绠$悊锛屾敮鎸侀厤缃枃浠惰鐩
+
+### 鍚姩娴佺▼锛圓pp.axaml.cs锛
+
+```
+LogService.Initialize() 鈫 DatabaseService.Initialize() 鈫 鍔犺浇涓婚/璇█/PLC閰嶇疆
+  鈫 ShowLoginWindow 鈫 [鐧诲綍鎴愬姛] 鈫 StartServices() 鈫 ShowMainWindow()
+```
+
+`StartServices()` 鏍规嵁 RunMode 閫夋嫨 Direct/Server/Client锛屽潎浼氳皟鐢 `StartPlcServices()` 鍚姩 PlcPolling + Alarm + DataLogging銆
+
+### 涓婚绯荤粺
+
+鍙屼富棰橀氳繃 `ResourceInclude` 鍒囨崲 `Application.Resources.MergedDictionaries`锛
+- `Themes/IndustrialTheme.axaml`锛堟繁鑹诧級
+- `Themes/IndustrialThemeLight.axaml`锛堟祬鑹诧級
+
+鑷畾涔夋帶浠**涓**鐢 `DynamicResource`锛岃屾槸璁㈤槄 `ThemeHelper.ThemeChanged` 浜嬩欢鍚庤皟鐢 `InvalidateVisual()` 閲嶇粯銆俙ThemeHelper` 鍐呴儴鐢ㄩ潤鎬 `IBrush` 灞炴х紦瀛樺綋鍓嶄富棰橀鑹诧紝渚 `Render()` 鏂规硶鐩存帴璇诲彇銆
+
+### 鑷畾涔夊伐涓氭帶浠讹紙YZWater.Avalonia/Controls锛
+
+鍏ㄩ儴缁ф壙 `Control` 骞堕噸鍐 `Render(DrawingContext)`锛屼娇鐢 `AffectsRender<>` 澹版槑瑙﹀彂閲嶇粯鐨勫睘鎬э細
+- `WaterTankControl` - 姘寸娑蹭綅
+- `PumpControl` - 娉碉紙甯︽棆杞姩鐢伙紝OnAttachedToVisualTree 鍚姩 DispatcherTimer锛
+- `FanControl` - 椋庢墖锛堝悓涓婏級
+- `PipeLineControl` - 绠¢亾锛堣櫄绾挎祦鍔ㄥ姩鐢伙級
+- `ValveControl` - 闃闂紙涓夋侊級
+- `GaugeControl` - 浠〃
+- `StatusCard` - 鐘舵佸崱鐗
+
+### MVVM 绾﹀畾
+
+- ViewModel 缁ф壙 `ObservableObject`锛屼娇鐢 `[ObservableProperty]` 鍜 `[RelayCommand]`锛圕ommunityToolkit.Mvvm 婧愮敓鎴愬櫒锛
+- View 鍜 ViewModel 鍦 `App.axaml.cs` 鎴 `MainViewModel` 涓氳繃 `DataContext` 缁戝畾
+- `MainViewModel` 鎸佹湁鎵鏈夊瓙 ViewModel 瀹炰緥锛岄氳繃 `CurrentView` 灞炴у垏鎹 Tab
+
+### 鏁版嵁搴
+
+- ORM锛歋qlSugar锛堜笉鏄 README 涓彁鍒扮殑 FreeSql锛屽凡杩佺Щ锛
+- 鏂囦欢锛歚yzwater.db`锛圫QLite锛
+- 鍒濆鍖栵細`DatabaseService.Initialize()` 涓愪釜寤鸿〃锛屽崟琛ㄥけ璐ヤ笉褰卞搷鍏朵粬琛
+- 琛細`users`, `alarm_records`, `flow_records`, `analog_records`, `audit_logs`, `persons`, `equipment_statuses`
+
+### 璁よ瘉
+
+`AuthService` 绠$悊鐧诲綍鐘舵侊紝鏀寔 4 绉嶈鑹诧紙Viewer / Operator / Engineer / Admin锛夈傞娆″惎鍔ㄨ嚜鍔ㄥ垱寤洪粯璁 admin 璐︽埛锛坅dmin/admin123锛夈傜櫥鍑烘椂閫氳繃 `UserLoggedOut` 浜嬩欢瑙﹀彂 `StopServices()` 骞堕噸鏂版樉绀虹櫥褰曠獥鍙c
+
+### 鑷傚簲甯冨眬
+
+`AdaptiveContainer` 鎺т欢瀹炵幇绛夋瘮缂╂斁锛岃璁″熀鍑 1280脳800锛岀缉鏀捐寖鍥 0.6x鈥1.5x銆
+
+## 閰嶇疆鏂囦欢
+
+`yzwater-config.json`锛堟牴鐩綍锛岃 .gitignore 鎺掗櫎锛夛細
+- PLC 杩炴帴锛歚PlcIp`, `PlcPort`, `AutoConnect`
+- 鎶ヨ闃堝硷細`LevelHighAlarm`, `LevelLowAlarm`, `FlowHighAlarm`, `FlowLowAlarm`
+- 涓帶锛歚RunMode`, `HubPort`, `HubServerUrl`, `HubToken`
+- 涓婚/璇█锛歚IsDarkTheme`, `IsChinese`
+- PLC 鍦板潃瑕嗙洊锛歚PlcAddresses`锛圖ictionary<string, string>锛宬ey 涓 PlcConfig 涓殑鏍囩鍚嶏級
+
+## 鏃ュ織
+
+Serilog 杈撳嚭鍒 `Logs/log-{date}.txt`锛堟寜澶╂粴鍔級鍜屾帶鍒跺彴銆傚穿婧冩棩蹇楀啓鍏ユ牴鐩綍 `crash.log`銆

+ 9 - 0
src/YZWater.Avalonia/App.axaml

@@ -6,6 +6,15 @@
     <Application.Styles>
         <FluentTheme />
         <StyleInclude Source="Themes/IndustrialStyles.axaml"/>
+        <Style Selector="TextBlock">
+            <Setter Property="FontFamily" Value="WenQuanYi Zen Hei, Noto Sans CJK SC, Microsoft YaHei, SimHei, sans-serif"/>
+        </Style>
+        <Style Selector="TextBox">
+            <Setter Property="FontFamily" Value="WenQuanYi Zen Hei, Noto Sans CJK SC, Microsoft YaHei, SimHei, sans-serif"/>
+        </Style>
+        <Style Selector="Button">
+            <Setter Property="FontFamily" Value="WenQuanYi Zen Hei, Noto Sans CJK SC, Microsoft YaHei, SimHei, sans-serif"/>
+        </Style>
     </Application.Styles>
 
     <Application.Resources>

+ 302 - 21
src/YZWater.Avalonia/App.axaml.cs

@@ -1,10 +1,12 @@
 using Avalonia;
+using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Markup.Xaml;
 using Avalonia.Markup.Xaml.Styling;
 using YZWater.Avalonia.Controls;
 using YZWater.Avalonia.Views;
 using YZWater.Core.Services;
+using YZWater.Core.ViewModels;
 
 namespace YZWater.Avalonia;
 
@@ -12,9 +14,15 @@ public partial class App : Application
 {
     private ResourceInclude? _darkTheme;
     private ResourceInclude? _lightTheme;
+    private DataLoggingService? _dataLogger;
+    private HubServer? _hubServer;
+    private HubClient? _hubClient;
+    private Action? _userLoggedOutHandler;
 
     public override void Initialize()
     {
+        LogService.Initialize();
+
         AvaloniaXamlLoader.Load(this);
 
         _darkTheme = new ResourceInclude(new Uri("avares://YZWater.Avalonia/Themes/IndustrialTheme.axaml"))
@@ -27,52 +35,325 @@ public partial class App : Application
         // 鍏ㄥ眬寮傚父澶勭悊
         AppDomain.CurrentDomain.UnhandledException += (s, e) =>
         {
-            Serilog.Log.Fatal(e.ExceptionObject as Exception, "鏈鐞嗙殑寮傚父");
+            LogCrash("AppDomain.UnhandledException", e.ExceptionObject as Exception);
         };
         TaskScheduler.UnobservedTaskException += (s, e) =>
         {
-            Serilog.Log.Error(e.Exception, "鏈瀵熺殑浠诲姟寮傚父");
+            LogCrash("TaskScheduler.UnobservedTaskException", e.Exception);
             e.SetObserved();
         };
+        global::Avalonia.Threading.Dispatcher.UIThread.UnhandledException += (s, e) =>
+        {
+            LogCrash("UIThread.UnhandledException", e.Exception);
+            e.Handled = true;
+        };
+    }
+
+    private static void LogCrash(string source, Exception? ex)
+    {
+        try
+        {
+            Serilog.Log.Fatal(ex, "鏈鐞嗗紓甯 [{Source}]", source);
+            File.AppendAllText("crash.log", $"[{DateTime.Now}] {source}:\n{ex}\n\n");
+        }
+        catch { /* 鏃ュ織鍐欏叆澶辫触鏃朵笉鑳藉啀鎶涘紓甯 */ }
     }
 
     public override void OnFrameworkInitializationCompleted()
     {
-        // 鍒濆鍖栨湇鍔
         DatabaseService.Initialize();
-        PlcService.Initialize();
+        // PlcService.Initialize() 寤惰繜鍒 StartPlcServices() 涓皟鐢紙鐧诲綍鎴愬姛鍚庯級
 
-        // 鍔犺浇涓婚鍜岃瑷鍋忓ソ
         ThemeService.Instance.LoadFromConfig();
         LanguageService.Instance.LoadFromConfig();
-
-        // 搴旂敤褰撳墠涓婚
+        PlcConfig.LoadFromConfig();
         ThemeHelper.SetTheme(ThemeService.Instance.IsDarkTheme);
 
-        // 鍒濆鍖栬疆璇㈠拰鎶ヨ鏈嶅姟
+        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+        {
+            // 娉ㄥ唽鍏ㄥ眬閫鍑烘竻鐞嗭紙鏃犺浠ヤ綍绉嶆柟寮忛鍑洪兘浼氭墽琛岋級
+            desktop.ShutdownRequested += (_, _) =>
+            {
+                Cleanup();
+            };
+
+            ShowLoginWindow(desktop);
+        }
+
+        base.OnFrameworkInitializationCompleted();
+    }
+
+    private void ShowLoginWindow(IClassicDesktopStyleApplicationLifetime desktop)
+    {
+        var loginVm = new LoginViewModel();
+        var loginView = new LoginView { DataContext = loginVm };
+
+        loginVm.LoginSucceeded += () =>
+        {
+            StartServices();
+            ShowMainWindow(desktop);
+            loginView.Close();
+        };
+
+        loginView.Closed += (_, _) =>
+        {
+            if (!AuthService.IsLoggedIn)
+            {
+                desktop.Shutdown();
+            }
+        };
+
+        desktop.MainWindow = loginView;
+        loginView.Show();
+    }
+
+    private void StartServices()
+    {
+        var config = ConfigService.GetConfig();
+        var mode = config?.RunMode ?? "Direct";
+
+        switch (mode)
+        {
+            case "Server":
+                StartServerMode(config!);
+                break;
+            case "Client":
+                StartClientMode(config!);
+                break;
+            default:
+                StartDirectMode();
+                break;
+        }
+    }
+
+    /// <summary>
+    /// 鍚姩 PLC 鐩稿叧鏈嶅姟锛堢洿杩炲拰鏈嶅姟绔叡鐢級
+    /// </summary>
+    private void StartPlcServices()
+    {
+        var config = ConfigService.GetConfig();
+        if (config?.RunMode == "Mock")
+        {
+            MockPlcService.Enable();
+            Serilog.Log.Information("鍚姩 Mock 妯″紡锛堟ā鎷熸暟鎹級");
+        }
+        else
+        {
+            PlcService.Initialize();
+        }
+
         PlcPollingService.Instance.Start();
         AlarmService.Instance.Start();
+        _dataLogger = new DataLoggingService(PlcPollingService.Instance);
+        _dataLogger.Start();
+    }
 
-        // 鍚姩鏁版嵁鏃ュ織
-        var dataLogger = new DataLoggingService(PlcPollingService.Instance);
-        dataLogger.Start();
+    private void StartDirectMode()
+    {
+        StartPlcServices();
+        Serilog.Log.Information("鍚姩鐩磋繛妯″紡");
+    }
 
-        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+    private void StartServerMode(Core.Models.SystemConfig config)
+    {
+        StartPlcServices();
+
+        _hubServer = new HubServer { Port = config.HubPort, Token = config.HubToken };
+        _hubServer.Start();
+        _hubServer.CommandReceived += HandleHubCommandAsync;
+
+        PlcPollingService.Instance.DataUpdated += OnDataUpdatedForBroadcast;
+        AlarmService.Instance.AlarmRaised += OnAlarmRaisedForBroadcast;
+
+        Serilog.Log.Information("鍚姩鏈嶅姟绔ā寮忥紝绔彛: {Port}", config.HubPort);
+    }
+
+    // 瀛樺偍涓哄懡鍚嶆柟娉曚互渚垮彇娑堣闃
+    private async void OnDataUpdatedForBroadcast(Core.Models.PlcDataModel data)
+    {
+        try
+        {
+            var server = _hubServer; // 鎹曡幏灞閮ㄥ彉閲忥紝闃叉骞跺彂 Dispose
+            if (server != null)
+                await server.BroadcastAsync(Core.Models.HubMessage.Data(data));
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Warning(ex, "骞挎挱鏁版嵁澶辫触");
+        }
+    }
+
+    private async void OnAlarmRaisedForBroadcast(Core.Models.AlarmRecord alarm)
+    {
+        try
+        {
+            var server = _hubServer; // 鎹曡幏灞閮ㄥ彉閲忥紝闃叉骞跺彂 Dispose
+            if (server != null)
+                await server.BroadcastAsync(Core.Models.HubMessage.Alarm(alarm));
+        }
+        catch (Exception ex)
         {
-            desktop.MainWindow = new MainWindow();
+            Serilog.Log.Warning(ex, "骞挎挱鎶ヨ澶辫触");
+        }
+    }
 
-            // 绐楀彛鍏抽棴鏃舵竻鐞
-            desktop.ShutdownRequested += (_, _) =>
+    private void StartClientMode(Core.Models.SystemConfig config)
+    {
+        _hubClient = new HubClient { ServerUrl = config.HubServerUrl, Token = config.HubToken };
+
+        // 瀹㈡埛绔篃鍚姩鏈湴鏁版嵁鏃ュ織锛屾柇杩炲悗浠嶆湁鍘嗗彶鏁版嵁
+        _dataLogger = new DataLoggingService(PlcPollingService.Instance);
+        _dataLogger.Start();
+
+        _hubClient.DataReceived += data =>
+        {
+            var localData = PlcPollingService.Instance.Data;
+            CopyPlcData(data, localData);
+            PlcPollingService.Instance.RaiseDataUpdated(localData);
+        };
+
+        _hubClient.AlarmReceived += alarm =>
+        {
+            _ = AlarmService.Instance.ProcessRemoteAlarmAsync(alarm);
+        };
+
+        Task.Run(async () =>
+        {
+            try
+            {
+                await _hubClient.ConnectAsync();
+            }
+            catch (Exception ex)
+            {
+                Serilog.Log.Error(ex, "杩炴帴涓帶鏈嶅姟绔け璐");
+            }
+        });
+
+        Serilog.Log.Information("鍚姩瀹㈡埛绔ā寮忥紝鏈嶅姟绔: {Url}", config.HubServerUrl);
+    }
+
+    private async Task<Core.Models.CommandResultPayload> HandleHubCommandAsync(Core.Models.CommandPayload command)
+    {
+        try
+        {
+            switch (command.Action)
+            {
+                case "start_pump":
+                case "stop_pump":
+                    if (!int.TryParse(command.Target?.Replace("P", ""), out var pumpIdx) || pumpIdx < 1 || pumpIdx > 5)
+                    {
+                        return new Core.Models.CommandResultPayload { Success = false, Message = "鏃犳晥鐨勬车缂栧彿锛堥渶 1-5锛" };
+                    }
+                    var writeValue = command.Action == "start_pump";
+                    var addr = PlcConfig.GetPumpRunAddress(pumpIdx - 1);
+                    var ok = await PlcService.WriteBoolAsync(addr, writeValue);
+                    var verb = writeValue ? "鍚姩" : "鍋滄";
+                    AuditService.Log(command.Operator ?? "杩滅▼", "Control",
+                        $"杩滅▼{verb}娉祘pumpIdx}", $"P{pumpIdx}", ok ? "Success" : "Failed");
+                    return new Core.Models.CommandResultPayload
+                    {
+                        Success = ok,
+                        Message = ok ? $"娉祘pumpIdx}宸瞷verb}" : "鍐欏叆澶辫触"
+                    };
+
+                default:
+                    return new Core.Models.CommandResultPayload { Success = false, Message = $"鏈煡鍛戒护: {command.Action}" };
+            }
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Error(ex, "鎵ц鍛戒护寮傚父: {Action}", command.Action);
+            return new Core.Models.CommandResultPayload { Success = false, Message = ex.Message };
+        }
+    }
+
+    /// <summary>
+    /// 澶嶅埗 PLC 鏁版嵁鍒版湰鍦版ā鍨嬶紙JSON 搴忓垪鍖栵紝鑷姩瑕嗙洊鎵鏈夊睘鎬э級
+    /// </summary>
+    private static void CopyPlcData(Core.Models.PlcDataModel source, Core.Models.PlcDataModel target)
+    {
+        var json = System.Text.Json.JsonSerializer.Serialize(source);
+        var copy = System.Text.Json.JsonSerializer.Deserialize<Core.Models.PlcDataModel>(json);
+        if (copy == null) return;
+
+        // 浣跨敤鍙嶅皠鎵归噺璧嬪硷紝纭繚鏂板灞炴ц嚜鍔ㄨ鐩
+        var props = typeof(Core.Models.PlcDataModel).GetProperties(
+            System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+            .Where(p => p.CanRead && p.CanWrite);
+        foreach (var prop in props)
+        {
+            prop.SetValue(target, prop.GetValue(copy));
+        }
+    }
+
+    private void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
+    {
+        try
+        {
+            var mainWindow = new MainWindow();
+            desktop.MainWindow = mainWindow;
+
+            // 鍏堝彇娑堟棫鐨勪簨浠惰闃咃紙闃叉绱Н锛
+            if (_userLoggedOutHandler != null)
             {
-                PlcPollingService.Instance.Dispose();
-                AlarmService.Instance.Dispose();
-                dataLogger.Dispose();
-                PlcService.Dispose();
-                DatabaseService.Dispose();
+                AuthService.UserLoggedOut -= _userLoggedOutHandler;
+            }
+
+            _userLoggedOutHandler = () =>
+            {
+                mainWindow.Close();
+                StopServices();
+                ShowLoginWindow(desktop);
             };
+            AuthService.UserLoggedOut += _userLoggedOutHandler;
+
+            mainWindow.Closed += (_, _) =>
+            {
+                // 閲婃斁 MainViewModel 涓殑 Timer
+                if (mainWindow.DataContext is IDisposable disposable)
+                {
+                    disposable.Dispose();
+                }
+
+                if (AuthService.IsLoggedIn)
+                {
+                    desktop.Shutdown();
+                }
+            };
+
+            mainWindow.Show();
         }
+        catch (Exception ex)
+        {
+            LogCrash("ShowMainWindow", ex);
+            desktop.Shutdown();
+        }
+    }
 
-        base.OnFrameworkInitializationCompleted();
+    private void StopServices()
+    {
+        // 鍙栨秷浜嬩欢璁㈤槄锛堥槻姝㈠凡閲婃斁鐨 hub 鏀跺埌鍥炶皟锛
+        PlcPollingService.Instance.DataUpdated -= OnDataUpdatedForBroadcast;
+        AlarmService.Instance.AlarmRaised -= OnAlarmRaisedForBroadcast;
+
+        _hubClient?.Dispose();
+        _hubClient = null;
+        _hubServer?.Dispose();
+        _hubServer = null;
+        _dataLogger?.Dispose();
+        _dataLogger = null;
+
+        // 鍏堟柇寮 PLC锛屽啀閲嶇疆鍗曚緥锛堥伩鍏嶆柊瀹炰緥灏濊瘯杩炴帴宸叉柇寮鐨 PLC锛
+        PlcService.Dispose();
+        PlcPollingService.ResetInstance();
+        AlarmService.ResetInstance();
+    }
+
+    private void Cleanup()
+    {
+        StopServices();
+        DatabaseService.Dispose();
+        LogService.Shutdown();
     }
 
     private void ApplyTheme()

BIN
src/YZWater.Avalonia/Assets/app-icon.png


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

@@ -11,7 +11,7 @@ public class FanControl : Control
 {
     public static readonly StyledProperty<bool> IsRunningProperty = AvaloniaProperty.Register<FanControl, bool>(nameof(IsRunning), false);
     public static readonly StyledProperty<double> SpeedProperty = AvaloniaProperty.Register<FanControl, double>(nameof(Speed), 1.0);
-    public static readonly StyledProperty<string> TextProperty = AvaloniaProperty.Register<FanControl, string>(nameof(Text), "椋庢墖");
+    public static readonly StyledProperty<string> TextProperty = AvaloniaProperty.Register<FanControl, string>(nameof(Text), "Fan");
 
     public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
     public double Speed { get => GetValue(SpeedProperty); set => SetValue(SpeedProperty, value); }

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

@@ -11,7 +11,7 @@ public class GaugeControl : Control
     public static readonly StyledProperty<double> ValueProperty = AvaloniaProperty.Register<GaugeControl, double>(nameof(Value), 0.0);
     public static readonly StyledProperty<double> MinValueProperty = AvaloniaProperty.Register<GaugeControl, double>(nameof(MinValue), 0.0);
     public static readonly StyledProperty<double> MaxValueProperty = AvaloniaProperty.Register<GaugeControl, double>(nameof(MaxValue), 100.0);
-    public static readonly StyledProperty<string> TitleProperty = AvaloniaProperty.Register<GaugeControl, string>(nameof(Title), "娴侀噺");
+    public static readonly StyledProperty<string> TitleProperty = AvaloniaProperty.Register<GaugeControl, string>(nameof(Title), "Flow");
     public static readonly StyledProperty<string> UnitProperty = AvaloniaProperty.Register<GaugeControl, string>(nameof(Unit), "m鲁/h");
     public static readonly StyledProperty<IBrush> ValueColorProperty = AvaloniaProperty.Register<GaugeControl, IBrush>(nameof(ValueColor));
 

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

@@ -7,7 +7,7 @@ namespace YZWater.Avalonia.Controls;
 
 public class StatusCard : Control
 {
-    public static readonly StyledProperty<string> TitleProperty = AvaloniaProperty.Register<StatusCard, string>(nameof(Title), "璁惧");
+    public static readonly StyledProperty<string> TitleProperty = AvaloniaProperty.Register<StatusCard, string>(nameof(Title), "Device");
     public static readonly StyledProperty<string> StatusProperty = AvaloniaProperty.Register<StatusCard, string>(nameof(Status), "RUNNING");
     public static readonly StyledProperty<string> IconProperty = AvaloniaProperty.Register<StatusCard, string>(nameof(Icon), "P1");
     public static readonly StyledProperty<bool> IsActiveProperty = AvaloniaProperty.Register<StatusCard, bool>(nameof(IsActive), false);

+ 1 - 1
src/YZWater.Avalonia/Controls/ValveControl.cs

@@ -12,7 +12,7 @@ public class ValveControl : Control
     public static readonly StyledProperty<ValveStatus> StatusProperty =
         AvaloniaProperty.Register<ValveControl, ValveStatus>(nameof(Status), ValveStatus.Middle);
     public static readonly StyledProperty<string> TextProperty =
-        AvaloniaProperty.Register<ValveControl, string>(nameof(Text), "闃闂");
+        AvaloniaProperty.Register<ValveControl, string>(nameof(Text), "Valve");
     public static readonly StyledProperty<IBrush> ValveColorProperty =
         AvaloniaProperty.Register<ValveControl, IBrush>(nameof(ValveColor));
     public static readonly StyledProperty<IBrush> BackgroundColorProperty =

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

@@ -24,4 +24,12 @@ public static class BoolConverters
     public static readonly IValueConverter ToFontWeight =
         new FuncValueConverter<bool, FontWeight>(active =>
             active ? FontWeight.Bold : FontWeight.Normal);
+
+    /// <summary>
+    /// true 鈫 Disable, false 鈫 Enable (localized)
+    /// </summary>
+    public static readonly IValueConverter ToActiveText =
+        new FuncValueConverter<bool, string>(active =>
+            active ? YZWater.Core.Services.LanguageService.Instance.Get("Disable")
+                   : YZWater.Core.Services.LanguageService.Instance.Get("Enable"));
 }

+ 66 - 0
src/YZWater.Avalonia/Views/LoginView.axaml

@@ -0,0 +1,66 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:YZWater.Core.ViewModels"
+        x:Class="YZWater.Avalonia.Views.LoginView"
+        x:DataType="vm:LoginViewModel"
+        Title="{Binding LoginTitleText}"
+        Icon="/Assets/app-icon.png"
+        WindowDecorations="None"
+        Width="420" Height="420"
+        WindowStartupLocation="CenterScreen"
+        CanResize="False">
+    <Border CornerRadius="12" Background="{DynamicResource WindowBackground}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+        <DockPanel>
+            <!-- 鑷畾涔夋爣棰樻爮 -->
+            <Border DockPanel.Dock="Top" Background="{DynamicResource NavBgBrush}" Height="32"
+                    PointerPressed="OnTitleBarPointerPressed" Cursor="Hand"
+                    CornerRadius="12,12,0,0">
+                <Grid ColumnDefinitions="Auto,*,Auto">
+                    <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"/>
+                </Grid>
+            </Border>
+
+            <!-- 鐧诲綍鍐呭 -->
+            <StackPanel Margin="36,24,36,16" Spacing="14">
+                <!-- 鏍囬 -->
+                <TextBlock Text="{Binding SystemNameText}" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="0,0,0,2"/>
+                <TextBlock Text="{Binding LoginPromptText}" FontSize="13" Foreground="Gray" HorizontalAlignment="Center" Margin="0,0,0,6"/>
+
+                <!-- 鐢ㄦ埛鍚 -->
+                <StackPanel Spacing="4">
+                    <TextBlock Text="{Binding UserNameLabel}" FontSize="13" Foreground="Gray"/>
+                    <TextBox Text="{Binding UserName}" PlaceholderText="{Binding InputUserNameText}" FontSize="14" Padding="8,6" Height="36"/>
+                </StackPanel>
+
+                <!-- 瀵嗙爜 -->
+                <StackPanel Spacing="4">
+                    <TextBlock Text="{Binding PasswordLabel}" FontSize="13" Foreground="Gray"/>
+                    <TextBox x:Name="PasswordBox" Text="{Binding Password, Mode=TwoWay}" PlaceholderText="{Binding InputPasswordText}" PasswordChar="鈼" FontSize="14" Padding="8,6" Height="36"
+                             KeyDown="OnPasswordKeyDown" LostFocus="OnPasswordLostFocus"/>
+                </StackPanel>
+
+                <!-- 璁颁綇鐢ㄦ埛鍚 -->
+                <CheckBox Content="{Binding RememberMeText}" IsChecked="{Binding RememberUserName}" FontSize="13" Margin="0,2,0,0"/>
+
+                <!-- 閿欒鎻愮ず -->
+                <Border IsVisible="{Binding HasError}" Background="#20FF0000" CornerRadius="6" Padding="10,6" MinHeight="28">
+                    <TextBlock Text="{Binding ErrorMessage}" Foreground="#E53935" FontSize="12"/>
+                </Border>
+
+                <!-- 鐧诲綍鎸夐挳 -->
+                <Button Content="{Binding LoginButtonText}" HorizontalAlignment="Stretch" Height="40" FontSize="15" Margin="0,4,0,0"
+                        Classes="btn-success"
+                        Command="{Binding LoginCommand}"
+                        IsEnabled="{Binding !IsLoggingIn}"/>
+
+                <!-- 鎻愮ず -->
+                <TextBlock Text="{Binding DefaultAccountText}" FontSize="11" Foreground="Gray" HorizontalAlignment="Center" Margin="0,4,0,0"/>
+            </StackPanel>
+        </DockPanel>
+    </Border>
+</Window>

+ 63 - 0
src/YZWater.Avalonia/Views/LoginView.axaml.cs

@@ -0,0 +1,63 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using YZWater.Core.ViewModels;
+
+namespace YZWater.Avalonia.Views;
+
+public partial class LoginView : Window
+{
+    private TextBox? _passwordBox;
+
+    public LoginView()
+    {
+        InitializeComponent();
+        _passwordBox = this.FindControl<TextBox>("PasswordBox");
+    }
+
+    /// <summary>
+    /// 瀵嗙爜妗嗗け鍘荤劍鐐规椂鍚屾鍒 ViewModel
+    /// </summary>
+    private void OnPasswordLostFocus(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        SyncPasswordToViewModel();
+    }
+
+    /// <summary>
+    /// 鍥炶溅閿櫥褰
+    /// </summary>
+    private void OnPasswordKeyDown(object? sender, KeyEventArgs e)
+    {
+        if (e.Key == Key.Enter && DataContext is LoginViewModel vm)
+        {
+            SyncPasswordToViewModel();
+            vm.LoginCommand.Execute(null);
+        }
+    }
+
+    /// <summary>
+    /// 灏嗗瘑鐮佹鐨勫煎悓姝ュ埌 ViewModel锛堣В鍐 PasswordChar 缁戝畾寤惰繜闂锛
+    /// </summary>
+    private void SyncPasswordToViewModel()
+    {
+        if (_passwordBox != null && DataContext is LoginViewModel vm)
+        {
+            vm.Password = _passwordBox.Text ?? string.Empty;
+        }
+    }
+
+    // 鑷畾涔夋爣棰樻爮锛氭嫋鎷界Щ鍔ㄧ獥鍙
+    private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+        if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+        {
+            BeginMoveDrag(e);
+        }
+    }
+
+    // 鍏抽棴
+    private void OnCloseClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        Close();
+    }
+}

+ 116 - 6
src/YZWater.Avalonia/Views/MainWindow.axaml

@@ -7,6 +7,8 @@
         x:Class="YZWater.Avalonia.Views.MainWindow"
         x:DataType="vm:MainViewModel"
         Title="{Binding Title}"
+        Icon="/Assets/app-icon.png"
+        WindowDecorations="None"
         Width="1280" Height="800"
         MinWidth="1024" MinHeight="700"
         WindowStartupLocation="CenterScreen"
@@ -17,10 +19,27 @@
     </Window.DataContext>
 
     <DockPanel>
+        <!-- 鈺愨晲鈺 鑷畾涔夋爣棰樻爮 鈺愨晲鈺 -->
+        <Border DockPanel.Dock="Top" Background="{DynamicResource NavBgBrush}" Height="32"
+                PointerPressed="OnTitleBarPointerPressed" Cursor="Hand">
+            <Grid ColumnDefinitions="Auto,*,Auto">
+                <Image Source="/Assets/app-icon.png" Width="20" Height="20" Margin="8,0,4,0" VerticalAlignment="Center"/>
+                <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 Content="鉁" Click="OnCloseClick" Width="36" Height="32" BorderThickness="0"
+                            Background="Transparent" FontSize="14" HorizontalContentAlignment="Center"
+                            Foreground="#E74C3C"/>
+                </StackPanel>
+            </Grid>
+        </Border>
+
         <!-- 鈺愨晲鈺 搴曢儴瀵艰埅鏍 鈺愨晲鈺 -->
         <Border x:Name="NavBar" DockPanel.Dock="Bottom" Background="{DynamicResource NavBgBrush}"
                 BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0" Padding="4,0">
-            <Grid ColumnDefinitions="*,Auto,Auto">
+            <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">
@@ -72,6 +91,16 @@
                                        Foreground="{Binding IsTabEActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
                         </StackPanel>
                     </Button>
+                    <Border Background="{DynamicResource BorderBrush}" Width="1" Height="20" VerticalAlignment="Center"/>
+                    <Button Command="{Binding ShowViewFCommand}" Padding="20,8" BorderThickness="0" Background="Transparent">
+                        <StackPanel Orientation="Horizontal" Spacing="6">
+                            <Border Width="3" Height="14" CornerRadius="1"
+                                    Background="{Binding IsTabFActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
+                            <TextBlock Text="{Binding NavAudit}" FontFamily="Consolas, monospace" FontSize="11"
+                                       FontWeight="{Binding IsTabFActive, Converter={x:Static conv:BoolConverters.ToFontWeight}}"
+                                       Foreground="{Binding IsTabFActive, Converter={x:Static conv:BoolConverters.ToBrush}}"/>
+                        </StackPanel>
+                    </Button>
                 </StackPanel>
 
                 <!-- 涓婚鍒囨崲 -->
@@ -91,26 +120,107 @@
                                    Foreground="{DynamicResource TextSecondaryBrush}"/>
                     </StackPanel>
                 </Button>
+
+                <!-- 鐢ㄦ埛淇℃伅 + 妯″紡鎸囩ず -->
+                <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 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"/>
+                </StackPanel>
             </Grid>
         </Border>
 
         <!-- 鈺愨晲鈺 涓诲唴瀹瑰尯鍩 鈺愨晲鈺 -->
+        <Panel>
         <TabControl SelectedIndex="{Binding SelectedTabIndex}" Background="{DynamicResource AppBgBrush}">
             <TabItem Header="PROCESS" IsVisible="False">
-                <views:ViewAView/>
+                <views:ViewAView DataContext="{Binding ViewA}"/>
             </TabItem>
             <TabItem Header="PARAMS" IsVisible="False">
-                <views:ViewBView/>
+                <views:ViewBView DataContext="{Binding ViewB}"/>
             </TabItem>
             <TabItem Header="FLOW" IsVisible="False">
-                <views:ViewCView/>
+                <views:ViewCView DataContext="{Binding ViewC}"/>
             </TabItem>
             <TabItem Header="ALARM" IsVisible="False">
-                <views:ViewDView/>
+                <views:ViewDView DataContext="{Binding ViewD}"/>
             </TabItem>
             <TabItem Header="ABOUT" IsVisible="False">
-                <views:ViewEView/>
+                <views:ViewEView DataContext="{Binding ViewE}"/>
+            </TabItem>
+            <TabItem Header="AUDIT" IsVisible="False">
+                <views:ViewFView DataContext="{Binding ViewF}"/>
             </TabItem>
         </TabControl>
+
+        <!-- 鈺愨晲鈺 鐢ㄦ埛绠$悊瀵硅瘽妗 鈺愨晲鈺 -->
+        <Border IsVisible="{Binding ShowUserDialog}" ZIndex="100" Background="#CC000000">
+            <Border Background="{DynamicResource CardBgBrush}" CornerRadius="12" Padding="24"
+                    MaxWidth="600" MaxHeight="500" VerticalAlignment="Center" HorizontalAlignment="Center"
+                    BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
+                <DockPanel>
+                    <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="12" Margin="0,0,0,16">
+                        <TextBlock Text="{Binding UserManageText}" FontSize="18" FontWeight="Bold"/>
+                        <Button Content="{Binding CloseText}" Command="{Binding ToggleUserDialogCommand}" HorizontalAlignment="Right" Classes="btn-danger"/>
+                    </StackPanel>
+
+                    <!-- 鍒涘缓鐢ㄦ埛 -->
+                    <Border DockPanel.Dock="Top" Background="{DynamicResource CardBgBrush}" CornerRadius="8" Padding="12" Margin="0,0,0,12">
+                        <StackPanel Spacing="8">
+                            <TextBlock Text="{Binding CreateNewUserText}" FontWeight="Bold" FontSize="14"/>
+                            <StackPanel Orientation="Horizontal" Spacing="8">
+                                <TextBox Text="{Binding NewUserName}" PlaceholderText="{Binding UserNameLabel}" Width="120"/>
+                                <TextBox Text="{Binding NewUserPassword}" PlaceholderText="{Binding PasswordLabel}" PasswordChar="鈼" Width="120"/>
+                                <TextBox Text="{Binding NewUserDisplayName}" PlaceholderText="{Binding DisplayNameLabel}" Width="120"/>
+                                <ComboBox SelectedIndex="{Binding NewUserRole}" Width="100">
+                                    <ComboBoxItem Content="Viewer"/>
+                                    <ComboBoxItem Content="Operator"/>
+                                    <ComboBoxItem Content="Engineer"/>
+                                    <ComboBoxItem Content="Admin"/>
+                                </ComboBox>
+                                <Button Content="{Binding CreateText}" Command="{Binding CreateUserCommand}" Classes="btn-success"/>
+                            </StackPanel>
+                            <TextBlock Text="{Binding UserManageMessage}" Foreground="Red" FontSize="12"/>
+                        </StackPanel>
+                    </Border>
+
+                    <!-- 鐢ㄦ埛鍒楄〃 -->
+                    <DataGrid ItemsSource="{Binding UserList}" AutoGenerateColumns="False" IsReadOnly="True"
+                              GridLinesVisibility="Horizontal" CanUserResizeColumns="True">
+                        <DataGrid.Columns>
+                            <DataGridTextColumn Header="User" Binding="{Binding UserName}" Width="100"/>
+                            <DataGridTextColumn Header="Name" Binding="{Binding DisplayName}" Width="120"/>
+                            <DataGridTextColumn Header="Role" Binding="{Binding Role}" Width="80"/>
+                            <DataGridTextColumn Header="Status" Binding="{Binding IsActive}" Width="60"/>
+                            <DataGridTextColumn Header="Last Login" Binding="{Binding LastLoginTime, StringFormat='yyyy-MM-dd HH:mm'}" Width="140"/>
+                            <DataGridTemplateColumn Header="Action" Width="80">
+                                <DataGridTemplateColumn.CellTemplate>
+                                    <DataTemplate>
+                                        <Button Content="{Binding IsActive, Converter={x:Static conv:BoolConverters.ToActiveText}}"
+                                                Command="{Binding $parent[DataGrid].((vm:MainViewModel)DataContext).ToggleUserActiveCommand}"
+                                                CommandParameter="{Binding}"
+                                                Classes="btn-toggle" Padding="6,2" FontSize="11"/>
+                                    </DataTemplate>
+                                </DataGridTemplateColumn.CellTemplate>
+                            </DataGridTemplateColumn>
+                        </DataGrid.Columns>
+                    </DataGrid>
+                </DockPanel>
+            </Border>
+        </Border>
+        </Panel>
     </DockPanel>
 </Window>

+ 22 - 0
src/YZWater.Avalonia/Views/MainWindow.axaml.cs

@@ -1,4 +1,5 @@
 using Avalonia.Controls;
+using Avalonia.Input;
 using Avalonia.Media;
 using YZWater.Avalonia.Controls;
 using YZWater.Core.Services;
@@ -22,4 +23,25 @@ public partial class MainWindow : Window
         Background = ThemeHelper.AppBg;
         if (_navBar != null) _navBar.Background = ThemeHelper.NavBg;
     }
+
+    // 鑷畾涔夋爣棰樻爮锛氭嫋鎷界Щ鍔ㄧ獥鍙
+    private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
+    {
+        if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+        {
+            BeginMoveDrag(e);
+        }
+    }
+
+    // 鏈灏忓寲
+    private void OnMinimizeClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        WindowState = WindowState.Minimized;
+    }
+
+    // 鍏抽棴
+    private void OnCloseClick(object? sender, global::Avalonia.Interactivity.RoutedEventArgs e)
+    {
+        Close();
+    }
 }

+ 6 - 9
src/YZWater.Avalonia/Views/ViewAView.axaml

@@ -3,11 +3,8 @@
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:controls="using:YZWater.Avalonia.Controls"
              x:Class="YZWater.Avalonia.Views.ViewAView"
-             x:DataType="vm:ViewAViewModel">
+            >
 
-    <UserControl.DataContext>
-        <vm:ViewAViewModel/>
-    </UserControl.DataContext>
 
     <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
         <Grid RowDefinitions="48,*,32">
@@ -349,11 +346,11 @@
                                 <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="鍥炴祦娉" Status="{Binding Pump3Status}" Icon="P3" IsActive="{Binding Pump3Running}"/>
-                                <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 Pump1TitleText}" Status="{Binding Pump1Status}" Icon="P1" IsActive="{Binding Pump1Running}"/>
+                                <controls:StatusCard Title="{Binding Pump2TitleText}" Status="{Binding Pump2Status}" Icon="P2" IsActive="{Binding Pump2Running}"/>
+                                <controls:StatusCard Title="{Binding RefluxPumpText}" Status="{Binding Pump3Status}" Icon="P3" IsActive="{Binding Pump3Running}"/>
+                                <controls:StatusCard Title="{Binding Fan1TitleText}" Status="{Binding Fan1Status}" Icon="F1" IsActive="{Binding Fan1Running}"/>
+                                <controls:StatusCard Title="{Binding Fan2TitleText}" Status="{Binding Fan2Status}" Icon="F2" IsActive="{Binding Fan2Running}"/>
                             </StackPanel>
                         </StackPanel>
                     </Border>

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

@@ -2,11 +2,8 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewBView"
-             x:DataType="vm:ViewBViewModel">
+            >
 
-    <UserControl.DataContext>
-        <vm:ViewBViewModel/>
-    </UserControl.DataContext>
 
     <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
         <Grid RowDefinitions="48,*,32">

+ 81 - 51
src/YZWater.Avalonia/Views/ViewCView.axaml

@@ -2,12 +2,7 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
-             x:Class="YZWater.Avalonia.Views.ViewCView"
-             x:DataType="vm:ViewCViewModel">
-
-    <UserControl.DataContext>
-        <vm:ViewCViewModel/>
-    </UserControl.DataContext>
+             x:Class="YZWater.Avalonia.Views.ViewCView">
 
     <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
         <Grid RowDefinitions="48,*,32">
@@ -16,68 +11,103 @@
                 <Grid Margin="16,0">
                     <StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
                         <Border Background="{DynamicResource SuccessBrush}" Width="3" Height="20" CornerRadius="1"/>
-                        <TextBlock x:Name="TitleText" Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
+                        <TextBlock Text="{Binding TitleText}" FontFamily="{DynamicResource MonoFont}" FontSize="16" FontWeight="Bold"
                                    Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
-                        <TextBlock x:Name="SubtitleText" Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                        <TextBlock Text="{Binding SubtitleText}" FontSize="12" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
                     </StackPanel>
                 </Grid>
             </Border>
 
             <!-- 鍐呭 -->
-            <Grid Grid.Row="1" RowDefinitions="Auto,*,Auto" Margin="8,4">
+            <ScrollViewer Grid.Row="1">
+                <StackPanel Margin="8,4">
 
-                <!-- 鏌ヨ鏉′欢 -->
-                <Border Grid.Row="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                    <StackPanel Orientation="Horizontal" Spacing="16">
-                        <StackPanel Orientation="Horizontal" Spacing="6">
-                            <TextBlock Text="{Binding FromText}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                            <DatePicker SelectedDate="{Binding StartDate}" Width="150"/>
-                        </StackPanel>
-                        <StackPanel Orientation="Horizontal" Spacing="6">
-                            <TextBlock Text="{Binding ToText}" FontFamily="{DynamicResource MonoFont}" FontSize="10" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
-                            <DatePicker SelectedDate="{Binding EndDate}" Width="150"/>
+                    <!-- 鏌ヨ鏉′欢 + 棰勮鎸夐挳 -->
+                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
+                        <StackPanel Spacing="8">
+                            <StackPanel Orientation="Horizontal" Spacing="16">
+                                <StackPanel Orientation="Horizontal" Spacing="6">
+                                    <TextBlock Text="{Binding FromText}" FontSize="10" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                                    <DatePicker SelectedDate="{Binding StartDate}" Width="150"/>
+                                </StackPanel>
+                                <StackPanel Orientation="Horizontal" Spacing="6">
+                                    <TextBlock Text="{Binding ToText}" FontSize="10" Foreground="{DynamicResource HeaderSubtextBrush}" VerticalAlignment="Center"/>
+                                    <DatePicker SelectedDate="{Binding EndDate}" Width="150"/>
+                                </StackPanel>
+                                <Button Content="{Binding QueryText}" Command="{Binding LoadFlowRecordsCommand}" Classes="btn-info"/>
+                                <Button Content="{Binding ExportCsvText}" Command="{Binding ExportDataCommand}" Classes="btn-success"/>
+                                <Button Content="{Binding PurgeOldText}" Command="{Binding ClearOldDataCommand}" Classes="btn-danger"/>
+                            </StackPanel>
+                            <!-- 棰勮鏃堕棿鑼冨洿 -->
+                            <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"/>
+                            </StackPanel>
                         </StackPanel>
-                        <Button Content="{Binding QueryText}" Command="{Binding LoadFlowRecordsCommand}" Classes="btn-info"/>
-                        <Button Content="{Binding ExportCsvText}" Command="{Binding ExportDataCommand}" Classes="btn-success"/>
-                        <Button Content="{Binding PurgeOldText}" Command="{Binding ClearOldDataCommand}" Classes="btn-danger"/>
-                    </StackPanel>
-                </Border>
+                    </Border>
 
-                <!-- 鍥捐〃 -->
-                <Border Grid.Row="0" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="12" Margin="0,0,0,4">
-                    <ScrollViewer>
+                    <!-- 娴侀噺瓒嬪娍鍥 -->
+                    <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 InflowTrendText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding FlowTrendText}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
                             </StackPanel>
-                            <lvc:CartesianChart Series="{Binding InflowSeries}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="200"/>
-
-                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,16,0,8">
+                            <lvc:CartesianChart Series="{Binding InflowSeries}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="180"/>
+                            <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,12,0,8">
                                 <Border Background="{DynamicResource InfoBrush}" Width="3" Height="14" CornerRadius="1"/>
-                                <TextBlock Text="{Binding OutflowTrendText}" FontFamily="{DynamicResource MonoFont}" FontSize="11" Foreground="{DynamicResource TextSecondaryBrush}"/>
+                                <TextBlock Text="{Binding OutflowTrendText}" 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>
+                            <lvc:CartesianChart Series="{Binding TankLevelSeries}" XAxes="{Binding XAxes}" YAxes="{Binding TankYAxes}" Height="200"/>
+                        </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 OutflowSeries}" XAxes="{Binding XAxes}" YAxes="{Binding YAxes}" Height="200"/>
+                            <lvc:CartesianChart Series="{Binding PumpFreqSeries}" XAxes="{Binding XAxes}" YAxes="{Binding PumpYAxes}" Height="200"/>
                         </StackPanel>
-                    </ScrollViewer>
-                </Border>
+                    </Border>
 
-                <!-- 鏁版嵁琛ㄦ牸 -->
-                <Border Grid.Row="1" Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
-                        BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="8">
-                    <DataGrid ItemsSource="{Binding FlowRecords}" AutoGenerateColumns="False" IsReadOnly="True" Height="150">
-                        <DataGrid.Columns>
-                            <DataGridTextColumn Header="TIME" Binding="{Binding RecordTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
-                            <DataGridTextColumn Header="IN (m鲁/h)" Binding="{Binding InflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
-                            <DataGridTextColumn Header="OUT (m鲁/h)" Binding="{Binding OutflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
-                            <DataGridTextColumn Header="TOTAL IN (m鲁)" Binding="{Binding TotalInflow, StringFormat='{}{0:F1}'}" Width="120"/>
-                            <DataGridTextColumn Header="TOTAL OUT (m鲁)" Binding="{Binding TotalOutflow, StringFormat='{}{0:F1}'}" Width="120"/>
-                        </DataGrid.Columns>
-                    </DataGrid>
-                </Border>
-            </Grid>
+                    <!-- 鏁版嵁琛ㄦ牸 -->
+                    <Border Background="{DynamicResource SurfaceBgBrush}" CornerRadius="2"
+                            BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" Padding="8" Margin="0,0,0,4">
+                        <DataGrid ItemsSource="{Binding FlowRecords}" AutoGenerateColumns="False" IsReadOnly="True" Height="150">
+                            <DataGrid.Columns>
+                                <DataGridTextColumn Header="Time" Binding="{Binding RecordTime, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}" Width="160"/>
+                                <DataGridTextColumn Header="In (m鲁/h)" Binding="{Binding InflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
+                                <DataGridTextColumn Header="Out (m鲁/h)" Binding="{Binding OutflowRate, StringFormat='{}{0:F1}'}" Width="100"/>
+                                <DataGridTextColumn Header="Total In (m鲁)" Binding="{Binding TotalInflow, StringFormat='{}{0:F1}'}" Width="120"/>
+                                <DataGridTextColumn Header="Total Out (m鲁)" Binding="{Binding TotalOutflow, StringFormat='{}{0:F1}'}" Width="120"/>
+                            </DataGrid.Columns>
+                        </DataGrid>
+                    </Border>
+                </StackPanel>
+            </ScrollViewer>
 
             <!-- 搴曢儴 -->
             <Border x:Name="StatusBar" Grid.Row="2" Background="{DynamicResource NavBgBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,1,0,0">

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

@@ -2,11 +2,8 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewDView"
-             x:DataType="vm:ViewDViewModel">
+            >
 
-    <UserControl.DataContext>
-        <vm:ViewDViewModel/>
-    </UserControl.DataContext>
 
     <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
         <Grid RowDefinitions="48,*,32">

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

@@ -2,11 +2,8 @@
              xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
              xmlns:vm="using:YZWater.Core.ViewModels"
              x:Class="YZWater.Avalonia.Views.ViewEView"
-             x:DataType="vm:ViewEViewModel">
+            >
 
-    <UserControl.DataContext>
-        <vm:ViewEViewModel/>
-    </UserControl.DataContext>
 
     <Border x:Name="RootBorder" Background="{DynamicResource AppBgBrush}">
         <Grid RowDefinitions="48,*,32">

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

@@ -0,0 +1,41 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:vm="using:YZWater.Core.ViewModels"
+             x:Class="YZWater.Avalonia.Views.ViewFView"
+            >
+    <DockPanel Margin="16">
+        <!-- 椤堕儴宸ュ叿鏍 -->
+        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="12" Margin="0,0,0,12">
+            <TextBlock Text="{Binding TitleText}" FontSize="20" FontWeight="Bold" VerticalAlignment="Center"/>
+            <TextBlock Text="{Binding FromText}" VerticalAlignment="Center" Margin="20,0,0,0"/>
+            <DatePicker SelectedDate="{Binding StartDate}" Width="150"/>
+            <TextBlock Text="{Binding ToText}" VerticalAlignment="Center"/>
+            <DatePicker SelectedDate="{Binding EndDate}" Width="150"/>
+            <Button Content="{Binding QueryText}" Command="{Binding LoadLogsCommand}" Classes="btn-primary"/>
+            <Button Content="{Binding ExportText}" Command="{Binding ExportCommand}" Classes="btn-success"/>
+            <TextBlock VerticalAlignment="Center" Margin="20,0,0,0">
+                <Run Text="{Binding TotalText}"/>
+                <Run Text=" "/>
+                <Run Text="{Binding TotalCount}"/>
+                <Run Text=" "/>
+                <Run Text="{Binding RecordsText}"/>
+            </TextBlock>
+        </StackPanel>
+
+        <!-- 瀹¤鏃ュ織琛ㄦ牸 -->
+        <DataGrid ItemsSource="{Binding AuditLogs}"
+                  AutoGenerateColumns="False"
+                  IsReadOnly="True"
+                  GridLinesVisibility="Horizontal"
+                  CanUserResizeColumns="True">
+            <DataGrid.Columns>
+                <DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='yyyy-MM-dd HH:mm:ss'}" Width="160"/>
+                <DataGridTextColumn Header="User" Binding="{Binding UserName}" Width="100"/>
+                <DataGridTextColumn Header="Action" Binding="{Binding Action}" Width="100"/>
+                <DataGridTextColumn Header="Detail" Binding="{Binding Detail}" Width="*"/>
+                <DataGridTextColumn Header="Target" Binding="{Binding Target}" Width="120"/>
+                <DataGridTextColumn Header="Result" Binding="{Binding Result}" Width="80"/>
+            </DataGrid.Columns>
+        </DataGrid>
+    </DockPanel>
+</UserControl>

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

@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace YZWater.Avalonia.Views;
+
+public partial class ViewFView : UserControl
+{
+    public ViewFView()
+    {
+        InitializeComponent();
+    }
+}

+ 10 - 10
src/YZWater.Avalonia/YZWater.Avalonia.csproj

@@ -7,21 +7,20 @@
     <ImplicitUsings>enable</ImplicitUsings>
     <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
     <ApplicationManifest>app.manifest</ApplicationManifest>
-    <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+    <AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
     <ApplicationIcon>Assets\姹℃按澶勭悊鍘.ico</ApplicationIcon>
     <RootNamespace>YZWater.Avalonia</RootNamespace>
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Avalonia" Version="11.2.3" />
-    <PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
-    <PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
-    <PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
-    <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
-    <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.3" />
-    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
-    <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-rc4.5" />
-    <PackageReference Include="Semi.Avalonia" Version="11.2.1.4" />
+    <PackageReference Include="Avalonia" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
+    <PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
+    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
+    <PackageReference Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.1.0-dev-798" />
+    <PackageReference Include="Semi.Avalonia" Version="12.0.3" />
   </ItemGroup>
 
   <ItemGroup>
@@ -30,6 +29,7 @@
 
   <ItemGroup>
     <Resource Include="Assets\姹℃按澶勭悊鍘.ico" />
+    <AvaloniaResource Include="Assets\app-icon.png" />
   </ItemGroup>
 
 </Project>

+ 5 - 3
src/YZWater.Core/Models/AlarmRecord.cs

@@ -1,18 +1,18 @@
 using CommunityToolkit.Mvvm.ComponentModel;
-using FreeSql.DataAnnotations;
+using SqlSugar;
 
 namespace YZWater.Core.Models;
 
 /// <summary>
 /// 鎶ヨ璁板綍妯″瀷
 /// </summary>
-[Table(Name = "alarm_records")]
+[SugarTable("alarm_records")]
 public partial class AlarmRecord : ObservableObject
 {
     /// <summary>
     /// 涓婚敭 ID
     /// </summary>
-    [Column(IsIdentity = true, IsPrimary = true)]
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
     public int Id { get; set; }
 
     /// <summary>
@@ -53,11 +53,13 @@ public partial class AlarmRecord : ObservableObject
     /// <summary>
     /// 纭鏃堕棿
     /// </summary>
+    [SugarColumn(IsNullable = true)]
     public DateTime? ConfirmedTime { get; set; }
 
     /// <summary>
     /// 纭浜
     /// </summary>
     [ObservableProperty]
+    [property: SugarColumn(IsNullable = true)]
     private string? _confirmedBy;
 }

+ 28 - 0
src/YZWater.Core/Models/AnalogRecord.cs

@@ -0,0 +1,28 @@
+using SqlSugar;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 妯℃嫙閲忓巻鍙茶褰曪紙娑蹭綅銆佹车棰戠巼绛夛級
+/// </summary>
+[SugarTable("analog_records")]
+public class AnalogRecord
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
+    /// <summary>
+    /// 璁板綍鏃堕棿
+    /// </summary>
+    public DateTime RecordTime { get; set; } = DateTime.Now;
+
+    /// <summary>
+    /// 鏍囩鍚嶏紙濡 Tank1Level, Pump1Freq锛
+    /// </summary>
+    public string TagName { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鏁板
+    /// </summary>
+    public float Value { get; set; }
+}

+ 50 - 0
src/YZWater.Core/Models/AuditLog.cs

@@ -0,0 +1,50 @@
+using SqlSugar;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 瀹¤鏃ュ織妯″瀷
+/// </summary>
+[SugarTable("audit_logs")]
+public class AuditLog
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
+    /// <summary>
+    /// 鎿嶄綔鏃堕棿
+    /// </summary>
+    public DateTime Timestamp { get; set; } = DateTime.Now;
+
+    /// <summary>
+    /// 鎿嶄綔鐢ㄦ埛
+    /// </summary>
+    public string UserName { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鎿嶄綔绫诲瀷锛圠ogin/Logout/Control/Config/AlarmAck锛
+    /// </summary>
+    public string Action { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鎿嶄綔璇︽儏
+    /// </summary>
+    public string Detail { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鎿嶄綔鐩爣锛堣澶嘔D銆佸弬鏁板悕绛夛級
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public string? Target { get; set; }
+
+    /// <summary>
+    /// 鎿嶄綔缁撴灉锛圫uccess/Failed锛
+    /// </summary>
+    public string Result { get; set; } = "Success";
+
+    /// <summary>
+    /// 瀹㈡埛绔 IP
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public string? ClientIp { get; set; }
+}

+ 5 - 0
src/YZWater.Core/Models/EquipmentStatus.cs

@@ -1,4 +1,5 @@
 using CommunityToolkit.Mvvm.ComponentModel;
+using SqlSugar;
 
 namespace YZWater.Core.Models;
 
@@ -31,8 +32,12 @@ public enum DeviceStatus
 /// <summary>
 /// 璁惧鐘舵佹ā鍨
 /// </summary>
+[SugarTable("equipment_status")]
 public partial class EquipmentStatus : ObservableObject
 {
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
     /// <summary>
     /// 璁惧 ID
     /// </summary>

+ 4 - 3
src/YZWater.Core/Models/FlowRecord.cs

@@ -1,18 +1,18 @@
 using CommunityToolkit.Mvvm.ComponentModel;
-using FreeSql.DataAnnotations;
+using SqlSugar;
 
 namespace YZWater.Core.Models;
 
 /// <summary>
 /// 娴侀噺璁板綍妯″瀷
 /// </summary>
-[Table(Name = "flow_records")]
+[SugarTable("flow_records")]
 public partial class FlowRecord : ObservableObject
 {
     /// <summary>
     /// 涓婚敭 ID
     /// </summary>
-    [Column(IsIdentity = true, IsPrimary = true)]
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
     public int Id { get; set; }
 
     /// <summary>
@@ -48,5 +48,6 @@ public partial class FlowRecord : ObservableObject
     /// 澶囨敞
     /// </summary>
     [ObservableProperty]
+    [property: SugarColumn(IsNullable = true)]
     private string? _remark;
 }

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

@@ -0,0 +1,148 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 涓帶鏈嶅姟閫氫俊鍗忚娑堟伅
+/// </summary>
+public class HubMessage
+{
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = string.Empty;
+
+    [JsonPropertyName("payload")]
+    public JsonElement? Payload { get; set; }
+
+    private static readonly JsonSerializerOptions JsonOptions = new()
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        WriteIndented = false
+    };
+
+    public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+
+    public static HubMessage? FromJson(string json) => JsonSerializer.Deserialize<HubMessage>(json, JsonOptions);
+
+    public T? GetPayload<T>()
+    {
+        if (Payload == null) return default;
+        return Payload.Value.Deserialize<T>(JsonOptions);
+    }
+
+    // 鈹鈹鈹 宸ュ巶鏂规硶 鈹鈹鈹
+
+    public static HubMessage Data(PlcDataModel data) => new()
+    {
+        Type = "data",
+        Payload = JsonSerializer.SerializeToElement(data, JsonOptions)
+    };
+
+    public static HubMessage Alarm(AlarmRecord alarm) => new()
+    {
+        Type = "alarm",
+        Payload = JsonSerializer.SerializeToElement(alarm, JsonOptions)
+    };
+
+    public static HubMessage LockResult(bool success, string? currentOperator = null) => new()
+    {
+        Type = "lock_result",
+        Payload = JsonSerializer.SerializeToElement(new LockResultPayload
+        {
+            Success = success,
+            CurrentOperator = currentOperator
+        }, JsonOptions)
+    };
+
+    public static HubMessage LockStatus(string? currentOperator, bool isLocked) => new()
+    {
+        Type = "lock_status",
+        Payload = JsonSerializer.SerializeToElement(new LockStatusPayload
+        {
+            CurrentOperator = currentOperator,
+            IsLocked = isLocked
+        }, JsonOptions)
+    };
+
+    public static HubMessage CommandResult(bool success, string message) => new()
+    {
+        Type = "command_result",
+        Payload = JsonSerializer.SerializeToElement(new CommandResultPayload
+        {
+            Success = success,
+            Message = message
+        }, JsonOptions)
+    };
+
+    public static HubMessage ClientCount(int count) => new()
+    {
+        Type = "client_count",
+        Payload = JsonSerializer.SerializeToElement(count)
+    };
+
+    public static HubMessage Auth(string token) => new()
+    {
+        Type = "auth",
+        Payload = JsonSerializer.SerializeToElement(token, JsonOptions)
+    };
+
+    public static HubMessage AuthResult(bool success) => new()
+    {
+        Type = "auth_result",
+        Payload = JsonSerializer.SerializeToElement(success)
+    };
+}
+
+/// <summary>
+/// 瀹㈡埛绔彂閫佺殑鍛戒护
+/// </summary>
+public class CommandPayload
+{
+    [JsonPropertyName("action")]
+    public string Action { get; set; } = string.Empty;
+
+    [JsonPropertyName("target")]
+    public string? Target { get; set; }
+
+    [JsonPropertyName("value")]
+    public object? Value { get; set; }
+
+    [JsonPropertyName("operator")]
+    public string Operator { get; set; } = string.Empty;
+}
+
+/// <summary>
+/// 鎿嶄綔閿佺粨鏋
+/// </summary>
+public class LockResultPayload
+{
+    [JsonPropertyName("success")]
+    public bool Success { get; set; }
+
+    [JsonPropertyName("currentOperator")]
+    public string? CurrentOperator { get; set; }
+}
+
+/// <summary>
+/// 鎿嶄綔閿佺姸鎬
+/// </summary>
+public class LockStatusPayload
+{
+    [JsonPropertyName("currentOperator")]
+    public string? CurrentOperator { get; set; }
+
+    [JsonPropertyName("isLocked")]
+    public bool IsLocked { get; set; }
+}
+
+/// <summary>
+/// 鍛戒护鎵ц缁撴灉
+/// </summary>
+public class CommandResultPayload
+{
+    [JsonPropertyName("success")]
+    public bool Success { get; set; }
+
+    [JsonPropertyName("message")]
+    public string Message { get; set; } = string.Empty;
+}

+ 4 - 3
src/YZWater.Core/Models/Person.cs

@@ -1,18 +1,18 @@
 using CommunityToolkit.Mvvm.ComponentModel;
-using FreeSql.DataAnnotations;
+using SqlSugar;
 
 namespace YZWater.Core.Models;
 
 /// <summary>
 /// 浜哄憳妯″瀷
 /// </summary>
-[Table(Name = "persons")]
+[SugarTable("persons")]
 public partial class Person : ObservableObject
 {
     /// <summary>
     /// 涓婚敭 ID
     /// </summary>
-    [Column(IsIdentity = true, IsPrimary = true)]
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
     public int Id { get; set; }
 
     /// <summary>
@@ -35,5 +35,6 @@ public partial class Person : ObservableObject
     /// <summary>
     /// 鍏ㄥ悕
     /// </summary>
+    [SugarColumn(IsIgnore = true)]
     public string FullName => $"{FirstName} {LastName}";
 }

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

@@ -43,6 +43,12 @@ public partial class SystemConfig : ObservableObject
     [ObservableProperty]
     private float _flowHighAlarm = 100f;
 
+    /// <summary>
+    /// 娴侀噺浣庢姤璀﹀
+    /// </summary>
+    [ObservableProperty]
+    private float _flowLowAlarm = 5f;
+
     /// <summary>
     /// 娉甸鐜囪缃
     /// </summary>
@@ -84,4 +90,46 @@ public partial class SystemConfig : ObservableObject
     /// </summary>
     [ObservableProperty]
     private bool _isChinese = true;
+
+    /// <summary>
+    /// 涓帶鏈嶅姟绔彛
+    /// </summary>
+    [ObservableProperty]
+    private int _hubPort = 8765;
+
+    /// <summary>
+    /// 涓帶鏈嶅姟绔湴鍧锛堝鎴风妯″紡浣跨敤锛
+    /// </summary>
+    [ObservableProperty]
+    private string _hubServerUrl = "ws://localhost:8765";
+
+    /// <summary>
+    /// 杩愯妯″紡锛欴irect/Server/Client
+    /// </summary>
+    [ObservableProperty]
+    private string _runMode = "Direct";
+
+    /// <summary>
+    /// Hub 閫氫俊浠ょ墝锛堟湇鍔$鍜屽鎴风蹇呴』鍖归厤锛
+    /// </summary>
+    [ObservableProperty]
+    private string _hubToken = string.Empty;
+
+    /// <summary>
+    /// 鏄惁璁颁綇鐧诲綍鐢ㄦ埛鍚
+    /// </summary>
+    [ObservableProperty]
+    private bool _rememberUserName;
+
+    /// <summary>
+    /// 淇濆瓨鐨勭敤鎴峰悕
+    /// </summary>
+    [ObservableProperty]
+    private string _savedUserName = string.Empty;
+
+    /// <summary>
+    /// PLC 鍦板潃瑕嗙洊锛坘ey=鏍囩鍚嶅 Tank1Level, value=鍦板潃濡 VD100锛
+    /// 绌哄垯浣跨敤榛樿鍦板潃
+    /// </summary>
+    public Dictionary<string, string> PlcAddresses { get; set; } = new();
 }

+ 75 - 0
src/YZWater.Core/Models/User.cs

@@ -0,0 +1,75 @@
+using SqlSugar;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// 鐢ㄦ埛瑙掕壊
+/// </summary>
+public enum UserRole
+{
+    /// <summary>
+    /// 鍙鏌ョ湅
+    /// </summary>
+    Viewer,
+
+    /// <summary>
+    /// 鎿嶄綔鍛 - 鏌ョ湅 + 璁惧鎿嶆帶 + 鎶ヨ纭
+    /// </summary>
+    Operator,
+
+    /// <summary>
+    /// 宸ョ▼甯 - 鎿嶄綔 + 鍙傛暟淇敼
+    /// </summary>
+    Engineer,
+
+    /// <summary>
+    /// 绠$悊鍛 - 鍏ㄩ儴鏉冮檺 + 鐢ㄦ埛绠$悊
+    /// </summary>
+    Admin
+}
+
+/// <summary>
+/// 鐢ㄦ埛妯″瀷
+/// </summary>
+[SugarTable("users")]
+public class User
+{
+    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
+    public int Id { get; set; }
+
+    /// <summary>
+    /// 鐢ㄦ埛鍚
+    /// </summary>
+    public string UserName { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 瀵嗙爜鍝堝笇锛圔Crypt锛
+    /// </summary>
+    public string PasswordHash { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鏄剧ず鍚嶇О
+    /// </summary>
+    public string DisplayName { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 瑙掕壊
+    /// </summary>
+    public UserRole Role { get; set; } = UserRole.Viewer;
+
+    /// <summary>
+    /// 鏄惁鍚敤
+    /// </summary>
+    public bool IsActive { get; set; } = true;
+
+    /// <summary>
+    /// 鍒涘缓鏃堕棿
+    /// </summary>
+    public DateTime CreatedTime { get; set; } = DateTime.Now;
+
+    /// <summary>
+    /// 鏈鍚庣櫥褰曟椂闂
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public DateTime? LastLoginTime { get; set; }
+}

+ 108 - 24
src/YZWater.Core/Services/AlarmService.cs

@@ -10,7 +10,21 @@ namespace YZWater.Core.Services;
 public partial class AlarmService : ObservableObject, IDisposable
 {
     private static AlarmService? _instance;
-    public static AlarmService Instance => _instance ??= new AlarmService(PlcPollingService.Instance);
+    private static readonly object _instanceLock = new();
+    public static AlarmService Instance { get { lock (_instanceLock) { return _instance ??= new AlarmService(PlcPollingService.Instance); } } }
+
+    /// <summary>
+    /// 閲嶇疆鍗曚緥锛堢敤浜庣櫥鍑哄悗閲嶆柊鐧诲綍锛
+    /// </summary>
+    public static void ResetInstance()
+    {
+        lock (_instanceLock)
+        {
+            var old = _instance;
+            _instance = null;
+            old?.Dispose();
+        }
+    }
 
     private readonly PlcPollingService _pollingService;
     private CancellationTokenSource? _cts;
@@ -46,6 +60,11 @@ public partial class AlarmService : ObservableObject, IDisposable
     private readonly Dictionary<string, DateTime> _alarmStartTimes = new();
     private readonly HashSet<string> _activeAlarms = new();
 
+    // 鈹鈹鈹 鎶ヨ鍗囩骇 鈹鈹鈹
+    public int EscalationMinutes { get; set; } = 10; // 鏈‘璁ゆ姤璀﹀崌绾ф椂闂
+    private readonly Dictionary<string, DateTime> _alarmTriggeredTimes = new(); // 鎶ヨ瑙﹀彂鏃堕棿锛堢敤浜庡崌绾у垽鏂級
+    private readonly HashSet<string> _escalatedAlarms = new(); // 宸插崌绾х殑鎶ヨ
+
     public AlarmService(PlcPollingService pollingService)
     {
         _pollingService = pollingService;
@@ -70,12 +89,21 @@ public partial class AlarmService : ObservableObject, IDisposable
         Log.Information("鎶ヨ鏈嶅姟宸插仠姝");
     }
 
+    private int _escalationCheckCounter;
+
     /// <summary>
     /// 鏁版嵁鏇存柊鍥炶皟
     /// </summary>
     private void OnDataUpdated(PlcDataModel data)
     {
         CheckAlarms(data);
+
+        // 姣 60 娆℃暟鎹洿鏂版鏌ヤ竴娆℃姤璀﹀崌绾э紙绾 1 鍒嗛挓锛
+        if (++_escalationCheckCounter >= 60)
+        {
+            _escalationCheckCounter = 0;
+            CheckEscalation();
+        }
     }
 
     /// <summary>
@@ -108,7 +136,7 @@ public partial class AlarmService : ObservableObject, IDisposable
         // 妫鏌 PLC 閫氫俊
         if (!data.IsPlcConnected)
         {
-            CheckCondition("PLC_Connection", "PLC閫氫俊涓㈠け", 1, now);
+            CheckCondition("PLC_Connection", LanguageService.Instance.Get("PlcCommLost"), 1, now);
         }
         else
         {
@@ -126,11 +154,11 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         if (level > LevelHighAlarm + AlarmDeadband)
         {
-            CheckCondition($"{tankId}_High", $"{tankId} 娑蹭綅杩囬珮 ({level:F1}%)", 2, now);
+            CheckCondition($"{tankId}_High", string.Format(LanguageService.Instance.Get("LevelTooHigh"), tankId, level), 2, now);
         }
         else if (level < LevelLowAlarm - AlarmDeadband)
         {
-            CheckCondition($"{tankId}_Low", $"{tankId} 娑蹭綅杩囦綆 ({level:F1}%)", 2, now);
+            CheckCondition($"{tankId}_Low", string.Format(LanguageService.Instance.Get("LevelTooLow"), tankId, level), 2, now);
         }
         else
         {
@@ -146,11 +174,11 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         if (rate > FlowHighAlarm)
         {
-            CheckCondition($"{flowId}_High", $"{flowId} 娴侀噺杩囬珮 ({rate:F1} m鲁/h)", 2, now);
+            CheckCondition($"{flowId}_High", string.Format(LanguageService.Instance.Get("FlowTooHigh"), flowId, rate), 2, now);
         }
         else if (rate < FlowLowAlarm && rate > 0) // 0 琛ㄧず鏃犳暟鎹紝涓嶆姤璀
         {
-            CheckCondition($"{flowId}_Low", $"{flowId} 娴侀噺杩囦綆 ({rate:F1} m鲁/h)", 2, now);
+            CheckCondition($"{flowId}_Low", string.Format(LanguageService.Instance.Get("FlowTooLow"), flowId, rate), 2, now);
         }
         else
         {
@@ -166,7 +194,7 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         if (hasFault)
         {
-            CheckCondition($"{pumpId}_Fault", $"{pumpId} 鏁呴殰", 3, now);
+            CheckCondition($"{pumpId}_Fault", string.Format(LanguageService.Instance.Get("DeviceFault"), pumpId), 3, now);
         }
         else
         {
@@ -181,7 +209,7 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         if (hasFault)
         {
-            CheckCondition($"{fanId}_Fault", $"{fanId} 鏁呴殰", 3, now);
+            CheckCondition($"{fanId}_Fault", string.Format(LanguageService.Instance.Get("DeviceFault"), fanId), 3, now);
         }
         else
         {
@@ -217,6 +245,8 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         _alarmStartTimes.Remove(conditionId);
         _activeAlarms.Remove(conditionId);
+        _alarmTriggeredTimes.Remove(conditionId);
+        _escalatedAlarms.Remove(conditionId);
     }
 
     /// <summary>
@@ -226,6 +256,8 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         _activeAlarms.Add(conditionId);
         _alarmStartTimes.Remove(conditionId);
+        _alarmTriggeredTimes[conditionId] = DateTime.Now;
+        _escalatedAlarms.Remove(conditionId);
 
         var alarm = new AlarmRecord
         {
@@ -246,6 +278,39 @@ public partial class AlarmService : ObservableObject, IDisposable
         Log.Warning("鎶ヨ瑙﹀彂: {Message} (绾у埆 {Level})", message, level);
     }
 
+    /// <summary>
+    /// 妫鏌ユ姤璀﹀崌绾э紙鏈‘璁よ秴杩 EscalationMinutes 鐨勬姤璀﹁嚜鍔ㄥ崌绾х骇鍒級
+    /// </summary>
+    public void CheckEscalation()
+    {
+        var now = DateTime.Now;
+        foreach (var conditionId in _activeAlarms.ToList())
+        {
+            if (_escalatedAlarms.Contains(conditionId)) continue;
+            if (!_alarmTriggeredTimes.TryGetValue(conditionId, out var triggerTime)) continue;
+
+            if ((now - triggerTime).TotalMinutes >= EscalationMinutes)
+            {
+                _escalatedAlarms.Add(conditionId);
+                var escalatedLevel = 4; // 鍗囩骇鍒伴珮绾у埆
+                var message = string.Format(LanguageService.Instance.Get("AlarmEscalated"), conditionId, EscalationMinutes);
+
+                var alarm = new AlarmRecord
+                {
+                    AlarmTime = now,
+                    AlarmType = conditionId.Split('_')[0],
+                    AlarmMessage = message,
+                    AlarmLevel = escalatedLevel,
+                    IsConfirmed = false
+                };
+
+                _ = SaveAlarmAsync(alarm);
+                AlarmRaised?.Invoke(alarm);
+                Log.Warning("鎶ヨ鍗囩骇: {ConditionId} 鈫 绾у埆 {Level}", conditionId, escalatedLevel);
+            }
+        }
+    }
+
     /// <summary>
     /// 淇濆瓨鎶ヨ鍒版暟鎹簱
     /// </summary>
@@ -253,7 +318,8 @@ public partial class AlarmService : ObservableObject, IDisposable
     {
         try
         {
-            await DatabaseService.Db.Insert(alarm).ExecuteAffrowsAsync();
+            var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
+            alarm.Id = id;
             Log.Debug("鎶ヨ璁板綍宸蹭繚瀛: {Id}", alarm.Id);
         }
         catch (Exception ex)
@@ -265,18 +331,18 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// <summary>
     /// 纭鎶ヨ
     /// </summary>
-    public async Task AcknowledgeAlarmAsync(int alarmId, string operatorName = "鎿嶄綔鍛")
+    public async Task AcknowledgeAlarmAsync(int alarmId, string operatorName = "Operator")
     {
         try
         {
-            await DatabaseService.Db.Update<AlarmRecord>()
-                .Set(r => r.IsConfirmed, true)
-                .Set(r => r.ConfirmedTime, DateTime.Now)
-                .Set(r => r.ConfirmedBy, operatorName)
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, DateTime.Now)
+                .SetColumns(r => r.ConfirmedBy, operatorName)
                 .Where(r => r.Id == alarmId)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
-            var alarm = await DatabaseService.Db.Select<AlarmRecord>()
+            var alarm = await DatabaseService.Db.Queryable<AlarmRecord>()
                 .Where(r => r.Id == alarmId)
                 .FirstAsync();
 
@@ -297,16 +363,16 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// <summary>
     /// 鎵归噺纭鎵鏈夋湭纭鎶ヨ
     /// </summary>
-    public async Task AcknowledgeAllAsync(string operatorName = "鎿嶄綔鍛")
+    public async Task AcknowledgeAllAsync(string operatorName = "Operator")
     {
         try
         {
-            await DatabaseService.Db.Update<AlarmRecord>()
-                .Set(r => r.IsConfirmed, true)
-                .Set(r => r.ConfirmedTime, DateTime.Now)
-                .Set(r => r.ConfirmedBy, operatorName)
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, DateTime.Now)
+                .SetColumns(r => r.ConfirmedBy, operatorName)
                 .Where(r => !r.IsConfirmed)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
             UpdateAlarmState();
             Log.Information("鎵鏈夋姤璀﹀凡纭");
@@ -336,7 +402,7 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     public async Task<List<AlarmRecord>> GetUnacknowledgedAlarmsAsync()
     {
-        return await DatabaseService.Db.Select<AlarmRecord>()
+        return await DatabaseService.Db.Queryable<AlarmRecord>()
             .Where(r => !r.IsConfirmed)
             .OrderByDescending(r => r.AlarmTime)
             .ToListAsync();
@@ -347,12 +413,30 @@ public partial class AlarmService : ObservableObject, IDisposable
     /// </summary>
     public async Task<List<AlarmRecord>> GetAlarmHistoryAsync(DateTime start, DateTime end)
     {
-        return await DatabaseService.Db.Select<AlarmRecord>()
+        return await DatabaseService.Db.Queryable<AlarmRecord>()
             .Where(r => r.AlarmTime >= start && r.AlarmTime <= end)
             .OrderByDescending(r => r.AlarmTime)
             .ToListAsync();
     }
 
+    /// <summary>
+    /// 澶勭悊杩滅▼鎶ヨ锛堝鎴风妯″紡锛屾寔涔呭寲鍒版湰鍦版暟鎹簱锛
+    /// </summary>
+    public async Task ProcessRemoteAlarmAsync(AlarmRecord alarm)
+    {
+        try
+        {
+            var id = await DatabaseService.Db.Insertable(alarm).ExecuteReturnIdentityAsync();
+            alarm.Id = id;
+            AlarmRaised?.Invoke(alarm);
+            UpdateAlarmState();
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "澶勭悊杩滅▼鎶ヨ澶辫触");
+        }
+    }
+
     public void Dispose()
     {
         if (_disposed) return;

+ 69 - 0
src/YZWater.Core/Services/AuditService.cs

@@ -0,0 +1,69 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 瀹¤鏈嶅姟 - 璁板綍鐢ㄦ埛鎿嶄綔瀹¤鏃ュ織
+/// </summary>
+public static class AuditService
+{
+    /// <summary>
+    /// 璁板綍鎿嶄綔瀹¤
+    /// </summary>
+    public static async Task LogAsync(string userName, string action, string detail, string? target = null, string result = "Success")
+    {
+        try
+        {
+            var entry = new AuditLog
+            {
+                Timestamp = DateTime.Now,
+                UserName = userName,
+                Action = action,
+                Detail = detail,
+                Target = target,
+                Result = result
+            };
+            await DatabaseService.Db.Insertable(entry).ExecuteCommandAsync();
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Error(ex, "璁板綍瀹¤鏃ュ織澶辫触");
+        }
+    }
+
+    /// <summary>
+    /// 鍚屾璁板綍锛堢敤浜 fire-and-forget 鍦烘櫙锛
+    /// </summary>
+    public static void Log(string userName, string action, string detail, string? target = null, string result = "Success")
+    {
+        _ = LogAsync(userName, action, detail, target, result);
+    }
+
+    /// <summary>
+    /// 鏌ヨ瀹¤鏃ュ織
+    /// </summary>
+    public static async Task<List<AuditLog>> QueryAsync(DateTime start, DateTime end, string? userName = null, string? action = null)
+    {
+        var query = DatabaseService.Db.Queryable<AuditLog>()
+            .Where(r => r.Timestamp >= start && r.Timestamp <= end);
+
+        if (!string.IsNullOrEmpty(userName))
+            query = query.Where(r => r.UserName == userName);
+        if (!string.IsNullOrEmpty(action))
+            query = query.Where(r => r.Action == action);
+
+        return await query.OrderByDescending(r => r.Timestamp).ToListAsync();
+    }
+
+    /// <summary>
+    /// 娓呯悊鏃у璁℃棩蹇
+    /// </summary>
+    public static async Task<int> CleanupAsync(int retentionDays = 365)
+    {
+        var cutoff = DateTime.Now.AddDays(-retentionDays);
+        return await DatabaseService.Db.Deleteable<AuditLog>()
+            .Where(r => r.Timestamp < cutoff)
+            .ExecuteCommandAsync();
+    }
+}

+ 351 - 0
src/YZWater.Core/Services/AuthService.cs

@@ -0,0 +1,351 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 璁よ瘉鏈嶅姟 - 鐢ㄦ埛鐧诲綍銆佹潈闄愮鐞
+/// </summary>
+public static class AuthService
+{
+    private static User? _currentUser;
+    private static DateTime _lastActivityTime = DateTime.Now;
+
+    /// <summary>
+    /// 褰撳墠鐧诲綍鐢ㄦ埛
+    /// </summary>
+    public static User? CurrentUser => _currentUser;
+
+    /// <summary>
+    /// 鏄惁宸茬櫥褰
+    /// </summary>
+    public static bool IsLoggedIn => _currentUser != null;
+
+    /// <summary>
+    /// 浼氳瘽瓒呮椂鏃堕棿锛堝垎閽燂級
+    /// </summary>
+    public static int SessionTimeoutMinutes { get; set; } = 30;
+
+    /// <summary>
+    /// 鐧诲綍浜嬩欢
+    /// </summary>
+    public static event Action<User>? UserLoggedIn;
+
+    /// <summary>
+    /// 鐧诲嚭浜嬩欢
+    /// </summary>
+    public static event Action? UserLoggedOut;
+
+    /// <summary>
+    /// 鍒濆鍖栵細鍒涘缓榛樿绠$悊鍛樿处鎴凤紙濡傛灉涓嶅瓨鍦級
+    /// </summary>
+    public static void Initialize()
+    {
+        try
+        {
+            var admin = DatabaseService.Db.Queryable<User>()
+                .Where(u => u.UserName == "admin")
+                .First();
+
+            if (admin == null)
+            {
+                var defaultAdmin = new User
+                {
+                    UserName = "admin",
+                    PasswordHash = HashPassword("admin123"),
+                    DisplayName = LanguageService.Instance.Get("AdminName"),
+                    Role = UserRole.Admin,
+                    IsActive = true,
+                    CreatedTime = DateTime.Now
+                };
+                DatabaseService.Db.Insertable(defaultAdmin).ExecuteCommand();
+                Serilog.Log.Information("宸插垱寤洪粯璁ょ鐞嗗憳璐︽埛 (admin/admin123)");
+            }
+            else
+            {
+                // 纭繚绠$悊鍛樺瘑鐮佹纭紙闃叉鏃ф暟鎹簱鏍煎紡闂锛
+                ResetAdminPassword("admin123");
+
+                // 鏇存柊绠$悊鍛樻樉绀哄悕涓哄綋鍓嶈瑷
+                var currentDisplayName = LanguageService.Instance.Get("AdminName");
+                if (admin.DisplayName != currentDisplayName)
+                {
+                    admin.DisplayName = currentDisplayName;
+                    DatabaseService.Db.Updateable(admin).UpdateColumns(u => u.DisplayName).ExecuteCommand();
+                }
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鍒濆鍖栫敤鎴疯处鎴峰け璐");
+        }
+    }
+
+    /// <summary>
+    /// 鐢ㄦ埛鐧诲綍
+    /// </summary>
+    public static (bool Success, string Message) Login(string userName, string password)
+    {
+        try
+        {
+            var user = DatabaseService.Db.Queryable<User>()
+                .Where(u => u.UserName == userName)
+                .First();
+
+            if (user == null)
+            {
+                Serilog.Log.Warning("鐧诲綍澶辫触锛氱敤鎴 {UserName} 涓嶅瓨鍦", userName);
+                AuditService.Log(userName, "Login", "鐧诲綍澶辫触锛氱敤鎴蜂笉瀛樺湪", result: "Failed");
+                return (false, LanguageService.Instance.Get("InvalidCredentials"));
+            }
+
+            if (!user.IsActive)
+            {
+                Serilog.Log.Warning("鐧诲綍澶辫触锛氱敤鎴 {UserName} 宸茬鐢", userName);
+                AuditService.Log(userName, "Login", "鐧诲綍澶辫触锛氳处鎴峰凡绂佺敤", result: "Failed");
+                return (false, LanguageService.Instance.Get("AccountDisabled"));
+            }
+
+            Serilog.Log.Debug("楠岃瘉瀵嗙爜: 鐢ㄦ埛={User}, 杈撳叆瀵嗙爜闀垮害={Len}, 瀛樺偍鍝堝笇闀垮害={HashLen}, 鍝堝笇鍓嶇紑={Prefix}",
+                userName, password.Length, user.PasswordHash?.Length ?? 0, user.PasswordHash?.Substring(0, Math.Min(10, user.PasswordHash?.Length ?? 0)));
+
+            if (!VerifyPassword(password, user.PasswordHash))
+            {
+                Serilog.Log.Warning("鐧诲綍澶辫触锛氱敤鎴 {UserName} 瀵嗙爜閿欒", userName);
+                AuditService.Log(userName, "Login", "鐧诲綍澶辫触锛氬瘑鐮侀敊璇", result: "Failed");
+                return (false, LanguageService.Instance.Get("InvalidCredentials"));
+            }
+
+            _currentUser = user;
+            _lastActivityTime = DateTime.Now;
+
+            // 鏇存柊鏈鍚庣櫥褰曟椂闂
+            user.LastLoginTime = DateTime.Now;
+            DatabaseService.Db.Updateable(user)
+                .UpdateColumns(u => u.LastLoginTime)
+                .ExecuteCommand();
+
+            AuditService.Log(userName, "Login", $"鐢ㄦ埛 {user.DisplayName}({user.Role}) 鐧诲綍鎴愬姛");
+            UserLoggedIn?.Invoke(user);
+
+            Log.Information("鐢ㄦ埛 {UserName} 鐧诲綍鎴愬姛锛岃鑹: {Role}", userName, user.Role);
+            return (true, LanguageService.Instance.Get("LoginSuccess"));
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鐧诲綍寮傚父");
+            return (false, LanguageService.Instance.Get("LoginError"));
+        }
+    }
+
+    /// <summary>
+    /// 鐢ㄦ埛鐧诲嚭
+    /// </summary>
+    public static void Logout()
+    {
+        if (_currentUser != null)
+        {
+            AuditService.Log(_currentUser.UserName, "Logout", "鐢ㄦ埛鐧诲嚭");
+            Log.Information("鐢ㄦ埛 {UserName} 宸茬櫥鍑", _currentUser.UserName);
+        }
+        _currentUser = null;
+        UserLoggedOut?.Invoke();
+    }
+
+    /// <summary>
+    /// 鍒锋柊娲诲姩鏃堕棿锛堢敤浜庝細璇濊秴鏃舵娴嬶級
+    /// </summary>
+    public static void RefreshActivity()
+    {
+        _lastActivityTime = DateTime.Now;
+    }
+
+    /// <summary>
+    /// 妫鏌ヤ細璇濇槸鍚﹁秴鏃
+    /// </summary>
+    public static bool IsSessionExpired()
+    {
+        return _currentUser != null &&
+               (DateTime.Now - _lastActivityTime).TotalMinutes >= SessionTimeoutMinutes;
+    }
+
+    /// <summary>
+    /// 妫鏌ュ綋鍓嶇敤鎴锋槸鍚︽湁鎸囧畾鏉冮檺
+    /// </summary>
+    public static bool HasPermission(UserRole requiredRole)
+    {
+        if (_currentUser == null) return false;
+        return _currentUser.Role >= requiredRole;
+    }
+
+    /// <summary>
+    /// 淇敼瀵嗙爜
+    /// </summary>
+    public static (bool Success, string Message) ChangePassword(string userName, string oldPassword, string newPassword)
+    {
+        try
+        {
+            var user = DatabaseService.Db.Queryable<User>()
+                .Where(u => u.UserName == userName)
+                .First();
+
+            if (user == null)
+                return (false, LanguageService.Instance.Get("UserNotFound"));
+
+            if (!VerifyPassword(oldPassword, user.PasswordHash))
+                return (false, LanguageService.Instance.Get("OldPasswordWrong"));
+
+            if (newPassword.Length < 6)
+                return (false, LanguageService.Instance.Get("PasswordTooShort"));
+
+            user.PasswordHash = HashPassword(newPassword);
+            DatabaseService.Db.Updateable(user)
+                .UpdateColumns(u => u.PasswordHash)
+                .ExecuteCommand();
+
+            AuditService.Log(userName, "ChangePassword", "瀵嗙爜宸蹭慨鏀");
+            Log.Information("鐢ㄦ埛 {UserName} 瀵嗙爜宸蹭慨鏀", userName);
+            return (true, LanguageService.Instance.Get("PasswordChanged"));
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "淇敼瀵嗙爜寮傚父");
+            return (false, LanguageService.Instance.Get("ChangePasswordError"));
+        }
+    }
+
+    /// <summary>
+    /// 閲嶇疆绠$悊鍛樺瘑鐮侊紙搴旀ョ敤锛
+    /// </summary>
+    public static void ResetAdminPassword(string newPassword = "admin123")
+    {
+        try
+        {
+            var admin = DatabaseService.Db.Queryable<User>().Where(u => u.UserName == "admin").First();
+            if (admin != null)
+            {
+                admin.PasswordHash = HashPassword(newPassword);
+                DatabaseService.Db.Updateable(admin).UpdateColumns(u => u.PasswordHash).ExecuteCommand();
+                Serilog.Log.Information("绠$悊鍛樺瘑鐮佸凡閲嶇疆");
+            }
+            else
+            {
+                // 濡傛灉 admin 涓嶅瓨鍦紝閲嶆柊鍒涘缓
+                Initialize();
+            }
+        }
+        catch (Exception ex)
+        {
+            Serilog.Log.Error(ex, "閲嶇疆绠$悊鍛樺瘑鐮佸け璐");
+        }
+    }
+
+    /// <summary>
+    /// 鍒涘缓鐢ㄦ埛
+    /// </summary>
+    public static (bool Success, string Message) CreateUser(string userName, string password, string displayName, UserRole role)
+    {
+        try
+        {
+            var existing = DatabaseService.Db.Queryable<User>()
+                .Where(u => u.UserName == userName)
+                .First();
+
+            if (existing != null)
+                return (false, LanguageService.Instance.Get("UserExists"));
+
+            if (password.Length < 6)
+                return (false, LanguageService.Instance.Get("PasswordTooShort"));
+
+            var user = new User
+            {
+                UserName = userName,
+                PasswordHash = HashPassword(password),
+                DisplayName = displayName,
+                Role = role,
+                IsActive = true,
+                CreatedTime = DateTime.Now
+            };
+
+            DatabaseService.Db.Insertable(user).ExecuteCommand();
+            AuditService.Log(_currentUser?.UserName ?? "绯荤粺", "CreateUser", $"鍒涘缓鐢ㄦ埛: {userName}, 瑙掕壊: {role}");
+            Log.Information("宸插垱寤虹敤鎴 {UserName}, 瑙掕壊: {Role}", userName, role);
+            return (true, LanguageService.Instance.Get("UserCreated"));
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鍒涘缓鐢ㄦ埛寮傚父");
+            return (false, LanguageService.Instance.Get("CreateUserError"));
+        }
+    }
+
+    /// <summary>
+    /// 鑾峰彇鎵鏈夌敤鎴
+    /// </summary>
+    public static List<User> GetAllUsers()
+    {
+        return DatabaseService.Db.Queryable<User>().ToList();
+    }
+
+    /// <summary>
+    /// 鍚敤/绂佺敤鐢ㄦ埛
+    /// </summary>
+    public static void SetUserActive(string userName, bool isActive)
+    {
+        var user = DatabaseService.Db.Queryable<User>().Where(u => u.UserName == userName).First();
+        if (user != null)
+        {
+            user.IsActive = isActive;
+            DatabaseService.Db.Updateable(user).UpdateColumns(u => u.IsActive).ExecuteCommand();
+            AuditService.Log(_currentUser?.UserName ?? "绯荤粺", "SetUserActive",
+                $"鐢ㄦ埛 {userName} 宸瞷(isActive ? "鍚敤" : "绂佺敤")}");
+        }
+    }
+
+    /// <summary>
+    /// 瀵嗙爜鍝堝笇锛圫HA256 + 闅忔満鐩愶紝鏍煎紡: salt$hash锛
+    /// </summary>
+    public static string HashPassword(string password)
+    {
+        var saltBytes = new byte[16];
+        using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
+        {
+            rng.GetBytes(saltBytes);
+        }
+        var salt = Convert.ToBase64String(saltBytes);
+        var hash = ComputeSha256(password, salt);
+        return $"{salt}${hash}";
+    }
+
+    /// <summary>
+    /// 楠岃瘉瀵嗙爜
+    /// </summary>
+    public static bool VerifyPassword(string password, string storedHash)
+    {
+        if (string.IsNullOrEmpty(storedHash) || !storedHash.Contains('$'))
+        {
+            Serilog.Log.Warning("瀵嗙爜鍝堝笇鏍煎紡鏃犳晥: {Hash}", storedHash?.Substring(0, Math.Min(20, storedHash?.Length ?? 0)));
+            return false;
+        }
+
+        var parts = storedHash.Split('$', 2);
+        if (parts.Length != 2) return false;
+
+        var salt = parts[0];
+        var expectedHash = parts[1];
+        var actualHash = ComputeSha256(password, salt);
+
+        Serilog.Log.Debug("瀵嗙爜楠岃瘉: 鐩={Salt}, 鏈熸湜鍝堝笇={Expected}, 瀹為檯鍝堝笇={Actual}",
+            salt, expectedHash.Substring(0, Math.Min(10, expectedHash.Length)), actualHash.Substring(0, Math.Min(10, actualHash.Length)));
+
+        return expectedHash == actualHash;
+    }
+
+    private static string ComputeSha256(string password, string salt)
+    {
+        using var sha256 = System.Security.Cryptography.SHA256.Create();
+        var combined = $"{salt}:{password}";
+        var bytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined));
+        return Convert.ToBase64String(bytes);
+    }
+}

+ 135 - 0
src/YZWater.Core/Services/BackupService.cs

@@ -0,0 +1,135 @@
+using Serilog;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 鏁版嵁搴撳浠芥湇鍔
+/// </summary>
+public static class BackupService
+{
+    private static readonly string BackupDir = "Backups";
+
+    /// <summary>
+    /// 鍒涘缓鏁版嵁搴撳浠
+    /// </summary>
+    public static async Task<(bool Success, string Path)> BackupAsync()
+    {
+        try
+        {
+            Directory.CreateDirectory(BackupDir);
+
+            var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+            var backupPath = Path.Combine(BackupDir, $"yzwater_backup_{timestamp}.db");
+
+            // SQLite 澶囦唤锛氬鍒舵暟鎹簱鏂囦欢
+            var sourcePath = "yzwater.db";
+            if (!File.Exists(sourcePath))
+            {
+                Log.Warning("鏁版嵁搴撴枃浠朵笉瀛樺湪: {Path}", sourcePath);
+                return (false, string.Empty);
+            }
+
+            // 浣跨敤 SqlSugar 鐨勫浠藉姛鑳
+            var db = DatabaseService.Db;
+            await Task.Run(() =>
+            {
+                File.Copy(sourcePath, backupPath, true);
+            });
+
+            Log.Information("鏁版嵁搴撳浠芥垚鍔: {Path}", backupPath);
+            AuditService.Log("绯荤粺", "Backup", $"鏁版嵁搴撳浠: {backupPath}");
+            return (true, backupPath);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鏁版嵁搴撳浠藉け璐");
+            return (false, string.Empty);
+        }
+    }
+
+    /// <summary>
+    /// 鎭㈠鏁版嵁搴
+    /// </summary>
+    public static async Task<bool> RestoreAsync(string backupPath)
+    {
+        try
+        {
+            if (!File.Exists(backupPath))
+            {
+                Log.Warning("澶囦唤鏂囦欢涓嶅瓨鍦: {Path}", backupPath);
+                return false;
+            }
+
+            var targetPath = "yzwater.db";
+
+            // 鍏堝浠藉綋鍓嶆暟鎹簱
+            var preRestoreBackup = Path.Combine(BackupDir, $"yzwater_pre_restore_{DateTime.Now:yyyyMMdd_HHmmss}.db");
+            if (File.Exists(targetPath))
+            {
+                File.Copy(targetPath, preRestoreBackup, true);
+            }
+
+            await Task.Run(() =>
+            {
+                File.Copy(backupPath, targetPath, true);
+            });
+
+            Log.Information("鏁版嵁搴撴仮澶嶆垚鍔燂紝鎭㈠鍓嶅浠: {Path}", preRestoreBackup);
+            AuditService.Log("绯荤粺", "Restore", $"鏁版嵁搴撴仮澶: {backupPath}锛屾仮澶嶅墠澶囦唤: {preRestoreBackup}");
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鏁版嵁搴撴仮澶嶅け璐");
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// 鑾峰彇澶囦唤鍒楄〃
+    /// </summary>
+    public static List<(string Path, DateTime Time, long Size)> GetBackups()
+    {
+        if (!Directory.Exists(BackupDir))
+            return new List<(string, DateTime, long)>();
+
+        return Directory.GetFiles(BackupDir, "*.db")
+            .Select(f => new FileInfo(f))
+            .OrderByDescending(f => f.CreationTime)
+            .Select(f => (f.FullName, f.CreationTime, f.Length))
+            .ToList();
+    }
+
+    /// <summary>
+    /// 娓呯悊鏃у浠斤紙淇濈暀鏈杩 N 涓級
+    /// </summary>
+    public static int CleanupOldBackups(int keepCount = 10)
+    {
+        if (!Directory.Exists(BackupDir))
+            return 0;
+
+        var backups = Directory.GetFiles(BackupDir, "*.db")
+            .Select(f => new FileInfo(f))
+            .OrderByDescending(f => f.CreationTime)
+            .Skip(keepCount)
+            .ToList();
+
+        foreach (var backup in backups)
+        {
+            try
+            {
+                backup.Delete();
+                Log.Debug("宸插垹闄ゆ棫澶囦唤: {Path}", backup.FullName);
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "鍒犻櫎鏃у浠藉け璐: {Path}", backup.FullName);
+            }
+        }
+
+        if (backups.Count > 0)
+            Log.Information("宸叉竻鐞 {Count} 涓棫澶囦唤", backups.Count);
+
+        return backups.Count;
+    }
+}

+ 49 - 0
src/YZWater.Core/Services/ConfigService.cs

@@ -91,4 +91,53 @@ public static class ConfigService
         _config = config;
         SaveConfig();
     }
+
+    /// <summary>
+    /// 瀵煎嚭閰嶇疆鍒版寚瀹氭枃浠
+    /// </summary>
+    public static (bool Success, string Message) ExportConfig(string filePath)
+    {
+        try
+        {
+            if (_config == null) return (false, "閰嶇疆鏈姞杞");
+
+            var options = new JsonSerializerOptions { WriteIndented = true };
+            var json = JsonSerializer.Serialize(_config, options);
+            File.WriteAllText(filePath, json);
+            Log.Information("閰嶇疆宸插鍑哄埌: {Path}", filePath);
+            return (true, $"閰嶇疆宸插鍑哄埌 {filePath}");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "瀵煎嚭閰嶇疆澶辫触");
+            return (false, $"瀵煎嚭澶辫触: {ex.Message}");
+        }
+    }
+
+    /// <summary>
+    /// 浠庢寚瀹氭枃浠跺鍏ラ厤缃
+    /// </summary>
+    public static (bool Success, string Message) ImportConfig(string filePath)
+    {
+        try
+        {
+            if (!File.Exists(filePath))
+                return (false, "鏂囦欢涓嶅瓨鍦");
+
+            var json = File.ReadAllText(filePath);
+            var imported = JsonSerializer.Deserialize<SystemConfig>(json);
+            if (imported == null)
+                return (false, "閰嶇疆鏂囦欢鏍煎紡閿欒");
+
+            _config = imported;
+            SaveConfig();
+            Log.Information("閰嶇疆宸蹭粠 {Path} 瀵煎叆", filePath);
+            return (true, "閰嶇疆瀵煎叆鎴愬姛");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "瀵煎叆閰嶇疆澶辫触");
+            return (false, $"瀵煎叆澶辫触: {ex.Message}");
+        }
+    }
 }

+ 191 - 24
src/YZWater.Core/Services/DataLoggingService.cs

@@ -11,6 +11,16 @@ public class DataLoggingService : IDisposable
     private readonly PlcPollingService _pollingService;
     private CancellationTokenSource? _cts;
     private bool _disposed;
+    private readonly object _flowLock = new();
+
+    // 绱娴侀噺锛堣蒋浠朵晶绱姞锛
+    private float _totalInflow;
+    private float _totalOutflow;
+    private DateTime _lastFlowTime;
+
+    // 璁惧杩愯灏忔椂鏁扮紦瀛
+    private readonly Dictionary<string, float> _runningHoursCache = new();
+    private DateTime _lastDeviceLogTime = DateTime.Now;
 
     /// <summary>
     /// 娴侀噺璁板綍鍐欏叆闂撮殧锛堢锛
@@ -30,6 +40,45 @@ public class DataLoggingService : IDisposable
     public DataLoggingService(PlcPollingService pollingService)
     {
         _pollingService = pollingService;
+        _lastFlowTime = DateTime.Now;
+
+        // 浠庢暟鎹簱鎭㈠涓婃绱鍊硷紝閬垮厤閲嶅惎褰掗浂
+        try
+        {
+            var lastRecord = DatabaseService.Db.Queryable<FlowRecord>()
+                .OrderByDescending(r => r.RecordTime)
+                .First();
+            if (lastRecord != null)
+            {
+                _totalInflow = lastRecord.TotalInflow;
+                _totalOutflow = lastRecord.TotalOutflow;
+                Log.Information("浠庢暟鎹簱鎭㈠绱娴侀噺: 杩涙按={In:F1}m鲁, 鍑烘按={Out:F1}m鲁", _totalInflow, _totalOutflow);
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "鎭㈠绱娴侀噺澶辫触锛屼粠闆跺紑濮");
+        }
+
+        // 浠庢暟鎹簱鎭㈠璁惧杩愯灏忔椂鏁
+        try
+        {
+            var allStatuses = DatabaseService.Db.Queryable<EquipmentStatus>().ToList();
+            var latestByDevice = allStatuses
+                .GroupBy(s => s.DeviceId)
+                .Select(g => g.OrderByDescending(s => s.LastUpdateTime).First())
+                .ToList();
+            foreach (var s in latestByDevice)
+            {
+                _runningHoursCache[s.DeviceId] = s.RunningHours;
+            }
+            if (latestByDevice.Count > 0)
+                Log.Information("浠庢暟鎹簱鎭㈠ {Count} 鍙拌澶囪繍琛屽皬鏃舵暟", latestByDevice.Count);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "鎭㈠璁惧杩愯灏忔椂鏁板け璐ワ紝浠庨浂寮濮");
+        }
     }
 
     /// <summary>
@@ -70,11 +119,12 @@ public class DataLoggingService : IDisposable
                 deviceTimer++;
                 cleanupTimer++;
 
-                // 瀹氭湡璁板綍娴侀噺鏁版嵁
+                // 瀹氭湡璁板綍娴侀噺鏁版嵁鍜屾ā鎷熼噺
                 if (flowTimer >= FlowLogIntervalSeconds)
                 {
                     flowTimer = 0;
                     await LogFlowDataAsync();
+                    await LogAnalogDataAsync();
                 }
 
                 // 瀹氭湡璁板綍璁惧鐘舵
@@ -104,25 +154,59 @@ public class DataLoggingService : IDisposable
     }
 
     /// <summary>
-    /// 璁板綍娴侀噺鏁版嵁
+    /// 绱娴侀噺璁$畻锛堢嚎绋嬪畨鍏紝甯︽椂闂撮棿闅斾笂闄愰槻姝紤鐪犳仮澶嶅悗寮傚父鏀惧ぇ锛岃礋鍊间繚鎶ら槻姝㈡椂閽熷洖璋冿級
+    /// </summary>
+    private (float totalIn, float totalOut) AccumulateFlow(float inflow, float outflow)
+    {
+        lock (_flowLock)
+        {
+            var now = DateTime.Now;
+            var intervalHours = (float)(now - _lastFlowTime).TotalHours;
+
+            // 璐熷间繚鎶わ細绯荤粺鏃堕挓鍥炶皟鏃惰烦杩囨湰娆$疮璁
+            if (intervalHours < 0)
+            {
+                Log.Warning("妫娴嬪埌绯荤粺鏃堕挓鍥炶皟锛岃烦杩囨湰娆$疮璁 (闂撮殧={Interval:F1}s)", intervalHours * 3600);
+                _lastFlowTime = now;
+                return (_totalInflow, _totalOutflow);
+            }
+
+            // 涓婇檺 1 灏忔椂锛岄槻姝紤鐪犳仮澶嶅悗寮傚父鏀惧ぇ
+            if (intervalHours > 1.0f)
+                intervalHours = 1.0f;
+
+            if (intervalHours > 0)
+            {
+                _totalInflow += inflow * intervalHours;
+                _totalOutflow += outflow * intervalHours;
+            }
+            _lastFlowTime = now;
+            return (_totalInflow, _totalOutflow);
+        }
+    }
+
+    /// <summary>
+    /// 璁板綍娴侀噺鏁版嵁锛堝惈绱娴侀噺璁$畻锛
     /// </summary>
     private async Task LogFlowDataAsync()
     {
         try
         {
             var data = _pollingService.Data;
+            var (totalIn, totalOut) = AccumulateFlow(data.InflowRate, data.OutflowRate);
+
             var record = new FlowRecord
             {
                 RecordTime = DateTime.Now,
                 InflowRate = data.InflowRate,
                 OutflowRate = data.OutflowRate,
-                TotalInflow = 0, // TODO: 闇瑕佺疮璁¤绠
-                TotalOutflow = 0
+                TotalInflow = totalIn,
+                TotalOutflow = totalOut
             };
 
-            await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
-            Log.Debug("娴侀噺鏁版嵁宸茶褰: 杩涙按={Inflow:F1}, 鍑烘按={Outflow:F1}",
-                record.InflowRate, record.OutflowRate);
+            await DatabaseService.Db.Insertable(record).ExecuteCommandAsync();
+            Log.Debug("娴侀噺鏁版嵁宸茶褰: 杩涙按={Inflow:F1}, 鍑烘按={Outflow:F1}, 绱杩={TotalIn:F1}, 绱鍑={TotalOut:F1}",
+                record.InflowRate, record.OutflowRate, record.TotalInflow, record.TotalOutflow);
         }
         catch (Exception ex)
         {
@@ -131,7 +215,41 @@ public class DataLoggingService : IDisposable
     }
 
     /// <summary>
-    /// 璁板綍璁惧鐘舵
+    /// 璁板綍妯℃嫙閲忔暟鎹紙娑蹭綅銆佹车棰戠巼绛夛級
+    /// </summary>
+    private async Task LogAnalogDataAsync()
+    {
+        try
+        {
+            var data = _pollingService.Data;
+            var now = DateTime.Now;
+
+            var records = new List<AnalogRecord>
+            {
+                new() { RecordTime = now, TagName = "Tank1Level", Value = data.Tank1Level },
+                new() { RecordTime = now, TagName = "Tank2Level", Value = data.Tank2Level },
+                new() { RecordTime = now, TagName = "Tank3Level", Value = data.Tank3Level },
+                new() { RecordTime = now, TagName = "Tank4Level", Value = data.Tank4Level },
+                new() { RecordTime = now, TagName = "Pump1Freq", Value = data.Pump1Freq },
+                new() { RecordTime = now, TagName = "Pump2Freq", Value = data.Pump2Freq },
+                new() { RecordTime = now, TagName = "Pump3Freq", Value = data.Pump3Freq },
+                new() { RecordTime = now, TagName = "Pump4Freq", Value = data.Pump4Freq },
+                new() { RecordTime = now, TagName = "Pump5Freq", Value = data.Pump5Freq },
+                new() { RecordTime = now, TagName = "InflowRate", Value = data.InflowRate },
+                new() { RecordTime = now, TagName = "OutflowRate", Value = data.OutflowRate },
+            };
+
+            await DatabaseService.Db.Insertable(records).ExecuteCommandAsync();
+            Log.Debug("妯℃嫙閲忔暟鎹凡璁板綍: {Count} 涓爣绛", records.Count);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "璁板綍妯℃嫙閲忔暟鎹け璐");
+        }
+    }
+
+    /// <summary>
+    /// 璁板綍璁惧鐘舵侊紙鍚繍琛屽皬鏃舵暟绱锛
     /// </summary>
     private async Task LogDeviceStatusAsync()
     {
@@ -139,25 +257,68 @@ public class DataLoggingService : IDisposable
         {
             var data = _pollingService.Data;
             var now = DateTime.Now;
+            var intervalHours = (float)(now - _lastDeviceLogTime).TotalHours;
+            _lastDeviceLogTime = now;
+
+            var records = new List<EquipmentStatus>();
 
             // 璁板綍娉电姸鎬
             for (int i = 0; i < 5; i++)
             {
+                var deviceId = $"P{i + 1:D3}";
+                var isRunning = data.GetPumpRunning(i);
+
+                // 绱杩愯灏忔椂鏁
+                if (!_runningHoursCache.ContainsKey(deviceId))
+                    _runningHoursCache[deviceId] = 0;
+                if (isRunning)
+                    _runningHoursCache[deviceId] += intervalHours;
+
                 var status = new EquipmentStatus
                 {
-                    DeviceId = $"P{i + 1:D3}",
-                    DeviceName = $"娉祘i + 1}",
-                    Status = data.GetPumpRunning(i) ? DeviceStatus.Running : DeviceStatus.Stopped,
+                    DeviceId = deviceId,
+                    DeviceName = string.Format(LanguageService.Instance.Get("PumpDeviceName"), i + 1),
+                    Status = data.GetPumpFault(i) ? DeviceStatus.Fault :
+                             isRunning ? DeviceStatus.Running : DeviceStatus.Stopped,
                     IsOnline = !data.GetPumpFault(i),
-                    RunningHours = 0, // TODO: 闇瑕佺疮璁
+                    RunningHours = _runningHoursCache[deviceId],
                     LastUpdateTime = now,
                     Value = data.GetPumpFreq(i),
                     Unit = "Hz"
                 };
-                // TODO: 鎸佷箙鍖栬澶囩姸鎬
+                records.Add(status);
+            }
+
+            // 璁板綍椋庢満鐘舵
+            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;
+
+                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);
             }
 
-            Log.Debug("璁惧鐘舵佸凡璁板綍");
+            // 鎵归噺鎻掑叆鏁版嵁搴
+            await DatabaseService.Db.Insertable(records).ExecuteCommandAsync();
+            Log.Debug("璁惧鐘舵佸凡璁板綍: {Count} 鍙拌澶", records.Count);
         }
         catch (Exception ex)
         {
@@ -174,16 +335,20 @@ public class DataLoggingService : IDisposable
         {
             var cutoff = DateTime.Now.AddDays(-DataRetentionDays);
 
-            var flowDeleted = await DatabaseService.Db.Delete<FlowRecord>()
+            var flowDeleted = await DatabaseService.Db.Deleteable<FlowRecord>()
                 .Where(r => r.RecordTime < cutoff)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
-            var alarmDeleted = await DatabaseService.Db.Delete<AlarmRecord>()
+            var alarmDeleted = await DatabaseService.Db.Deleteable<AlarmRecord>()
                 .Where(r => r.AlarmTime < cutoff && r.IsConfirmed)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
-            Log.Information("鏁版嵁娓呯悊瀹屾垚: 鍒犻櫎 {Flow} 鏉℃祦閲忚褰, {Alarm} 鏉℃姤璀﹁褰",
-                flowDeleted, alarmDeleted);
+            var equipDeleted = await DatabaseService.Db.Deleteable<EquipmentStatus>()
+                .Where(r => r.LastUpdateTime < cutoff)
+                .ExecuteCommandAsync();
+
+            Log.Information("鏁版嵁娓呯悊瀹屾垚: 鍒犻櫎 {Flow} 鏉℃祦閲忚褰, {Alarm} 鏉℃姤璀﹁褰, {Equip} 鏉¤澶囩姸鎬",
+                flowDeleted, alarmDeleted, equipDeleted);
         }
         catch (Exception ex)
         {
@@ -196,16 +361,18 @@ public class DataLoggingService : IDisposable
     /// </summary>
     public async Task ManualLogFlowAsync(float inflow, float outflow)
     {
+        var (totalIn, totalOut) = AccumulateFlow(inflow, outflow);
+
         var record = new FlowRecord
         {
             RecordTime = DateTime.Now,
             InflowRate = inflow,
             OutflowRate = outflow,
-            TotalInflow = 0,
-            TotalOutflow = 0,
-            Remark = "鎵嬪姩璁板綍"
+            TotalInflow = totalIn,
+            TotalOutflow = totalOut,
+            Remark = LanguageService.Instance.Get("ManualRecord")
         };
-        await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
+        await DatabaseService.Db.Insertable(record).ExecuteCommandAsync();
     }
 
     public void Dispose()

+ 50 - 29
src/YZWater.Core/Services/DatabaseService.cs

@@ -1,58 +1,79 @@
-using FreeSql;
 using Serilog;
+using SqlSugar;
 using YZWater.Core.Models;
 
 namespace YZWater.Core.Services;
 
 /// <summary>
-/// 鏁版嵁搴撴湇鍔 - 浣跨敤 FreeSql + SQLite
+/// 鏁版嵁搴撴湇鍔 - 浣跨敤 SqlSugar + SQLite
 /// </summary>
 public static class DatabaseService
 {
-    private static IFreeSql? _db;
+    private static SqlSugarClient? _db;
+    private static readonly string DbPath = "yzwater.db";
 
     /// <summary>
     /// 鏁版嵁搴撳疄渚
     /// </summary>
-    public static IFreeSql Db => _db ?? throw new InvalidOperationException("鏁版嵁搴撴湭鍒濆鍖栵紝璇峰厛璋冪敤 Initialize()");
+    public static ISqlSugarClient Db => _db ?? throw new InvalidOperationException("鏁版嵁搴撴湭鍒濆鍖栵紝璇峰厛璋冪敤 Initialize()");
 
     /// <summary>
-    /// 鍒濆鍖栨暟鎹簱
+    /// 鍒濆鍖栨暟鎹簱锛堥渶鍏堣皟鐢 LogService.Initialize()锛
     /// </summary>
     public static void Initialize()
     {
-        // 閰嶇疆 Serilog
-        Log.Logger = new LoggerConfiguration()
-            .MinimumLevel.Debug()
-            .WriteTo.Console()
-            .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day)
-            .CreateLogger();
-
-        // 鍒濆鍖 FreeSql
-        _db = new FreeSqlBuilder()
-            .UseConnectionString(DataType.Sqlite, "Data Source=yzwater.db")
-            .UseAutoSyncStructure(true)
-            .UseMonitorCommand(cmd =>
+        InitDatabase();
+    }
+
+    private static void InitDatabase()
+    {
+        _db = new SqlSugarClient(new ConnectionConfig
+        {
+            ConnectionString = $"Data Source={DbPath}",
+            DbType = DbType.Sqlite,
+            IsAutoCloseConnection = true,
+            InitKeyType = InitKeyType.Attribute
+        });
+
+        _db.Aop.OnLogExecuting = (sql, pars) =>
+        {
+            Log.Debug("SQL: {Sql}", sql);
+        };
+
+        _db.DbMaintenance.CreateDatabase();
+
+        // 閫愪釜鍒濆鍖栬〃锛屽崟涓け璐ヤ笉鍒犻櫎鏁版嵁涔熶笉褰卞搷鍏朵粬琛
+        var tables = new[] { typeof(Person), typeof(AlarmRecord), typeof(FlowRecord),
+                             typeof(EquipmentStatus), typeof(AuditLog), typeof(User),
+                             typeof(AnalogRecord) };
+
+        foreach (var tableType in tables)
+        {
+            try
+            {
+                _db.CodeFirst.InitTables(tableType);
+            }
+            catch (Exception ex)
             {
-                Log.Debug("SQL: {Sql}", cmd.CommandText);
-            })
-            .Build();
+                // 浠呰褰曢敊璇紝缁х画鍒濆鍖栧叾浠栬〃锛屼笉鍒犻櫎鏁版嵁搴
+                Log.Error(ex, "鍒濆鍖栬〃 {Table} 澶辫触锛堜繚鐣欏凡鏈夋暟鎹紝璺宠繃姝よ〃锛", tableType.Name);
+            }
+        }
 
-        // 纭繚鏁版嵁搴撳凡鍒涘缓
-        _db.CodeFirst.SyncStructure(typeof(Person));
-        _db.CodeFirst.SyncStructure(typeof(AlarmRecord));
-        _db.CodeFirst.SyncStructure(typeof(FlowRecord));
-        _db.CodeFirst.SyncStructure(typeof(SystemConfig));
+        try
+        {
+            AuthService.Initialize();
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "AuthService 鍒濆鍖栧け璐ワ紙榛樿绠$悊鍛樺彲鑳芥湭鍒涘缓锛");
+        }
 
         Log.Information("鏁版嵁搴撳垵濮嬪寲瀹屾垚");
     }
 
-    /// <summary>
-    /// 閲婃斁璧勬簮
-    /// </summary>
     public static void Dispose()
     {
         _db?.Dispose();
-        Log.CloseAndFlush();
     }
 }

+ 379 - 0
src/YZWater.Core/Services/HubClient.cs

@@ -0,0 +1,379 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 涓帶瀹㈡埛绔 - 杩炴帴鍒版湇鍔$锛屾帴鏀舵暟鎹紝鍙戦佸懡浠わ紝鑷姩閲嶈繛
+/// </summary>
+public class HubClient : IDisposable
+{
+    private ClientWebSocket? _ws;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+    private volatile bool _isConnected;
+    private volatile bool _shouldReconnect = true;
+
+    /// <summary>
+    /// 鏈嶅姟绔湴鍧
+    /// </summary>
+    public string ServerUrl { get; set; } = "ws://localhost:8765";
+
+    /// <summary>
+    /// 璁よ瘉浠ょ墝
+    /// </summary>
+    public string Token { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鏄惁宸茶繛鎺
+    /// </summary>
+    public bool IsConnected => _isConnected;
+
+    /// <summary>
+    /// 鏄惁鏈夋搷浣滈攣
+    /// </summary>
+    public bool HasLock { get; private set; }
+
+    /// <summary>
+    /// 褰撳墠鎿嶄綔鍛
+    /// </summary>
+    public string? CurrentOperator { get; private set; }
+
+    public event Action<PlcDataModel>? DataReceived;
+    public event Action<AlarmRecord>? AlarmReceived;
+    public event Action<bool, string?>? LockStatusChanged;
+    public event Action<bool>? ConnectionChanged;
+    public event Action<int>? ClientCountChanged;
+    public event Action<bool, string>? CommandResult;
+
+    /// <summary>
+    /// 杩炴帴鍒版湇鍔$锛堝甫鑷姩閲嶈繛锛
+    /// </summary>
+    public async Task ConnectAsync()
+    {
+        if (_isConnected) return;
+
+        _cts = new CancellationTokenSource();
+        _shouldReconnect = true;
+
+        await ConnectInternalAsync();
+
+        // 鍚姩鎺ユ敹 + 閲嶈繛寰幆
+        _ = Task.Run(() => ConnectionLoopAsync(_cts.Token));
+    }
+
+    /// <summary>
+    /// 鏂紑杩炴帴
+    /// </summary>
+    public async Task DisconnectAsync()
+    {
+        _shouldReconnect = false;
+        _cts?.Cancel();
+
+        if (_ws?.State == WebSocketState.Open)
+        {
+            try
+            {
+                await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "瀹㈡埛绔柇寮", CancellationToken.None);
+            }
+            catch { }
+        }
+
+        _isConnected = false;
+        HasLock = false;
+        CurrentOperator = null;
+        ConnectionChanged?.Invoke(false);
+        Log.Information("宸叉柇寮涓帶鏈嶅姟绔繛鎺");
+    }
+
+    /// <summary>
+    /// 杩炴帴 + 鎺ユ敹 + 鑷姩閲嶈繛涓诲惊鐜
+    /// </summary>
+    private async Task ConnectionLoopAsync(CancellationToken ct)
+    {
+        var reconnectDelay = 1000;
+        const int maxDelay = 30000;
+
+        while (!ct.IsCancellationRequested && _shouldReconnect)
+        {
+            try
+            {
+                if (!_isConnected)
+                {
+                    await ConnectInternalAsync();
+                    reconnectDelay = 1000; // 杩炴帴鎴愬姛锛岄噸缃欢杩
+                }
+
+                // 杩涘叆鎺ユ敹寰幆
+                await ReceiveLoopAsync(ct);
+            }
+            catch (OperationCanceledException)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "杩炴帴寮傚父");
+            }
+
+            // 杩炴帴鏂紑锛岃嚜鍔ㄩ噸杩
+            if (!ct.IsCancellationRequested && _shouldReconnect)
+            {
+                _isConnected = false;
+                HasLock = false;
+                ConnectionChanged?.Invoke(false);
+
+                Log.Information("灏嗗湪 {Delay}ms 鍚庨噸杩...", reconnectDelay);
+                try
+                {
+                    await Task.Delay(reconnectDelay, ct);
+                }
+                catch (OperationCanceledException)
+                {
+                    break;
+                }
+
+                reconnectDelay = Math.Min(reconnectDelay * 2, maxDelay);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 鍐呴儴杩炴帴鏂规硶
+    /// </summary>
+    private async Task ConnectInternalAsync()
+    {
+        _ws?.Dispose();
+        _ws = new ClientWebSocket();
+
+        try
+        {
+            await _ws.ConnectAsync(new Uri(ServerUrl), _cts?.Token ?? CancellationToken.None);
+
+            // 鍙戦佽璇佷护鐗
+            if (!string.IsNullOrEmpty(Token))
+            {
+                await SendMessageAsync(HubMessage.Auth(Token));
+
+                // 绛夊緟璁よ瘉缁撴灉
+                var authResult = await ReceiveOneMessageAsync(_cts?.Token ?? CancellationToken.None);
+                if (authResult?.Type != "auth_result" || authResult.GetPayload<bool>() != true)
+                {
+                    Log.Warning("涓帶鏈嶅姟绔璇佸け璐");
+                    _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "璁よ瘉澶辫触", CancellationToken.None).Wait(1000);
+                    throw new Exception("璁よ瘉澶辫触");
+                }
+                Log.Information("涓帶鏈嶅姟绔璇佹垚鍔");
+            }
+
+            _isConnected = true;
+            ConnectionChanged?.Invoke(true);
+            Log.Information("宸茶繛鎺ュ埌涓帶鏈嶅姟绔: {Url}", ServerUrl);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "杩炴帴涓帶鏈嶅姟绔け璐: {Url}", ServerUrl);
+            _isConnected = false;
+            throw;
+        }
+    }
+
+    /// <summary>
+    /// 璇锋眰鎿嶄綔閿
+    /// </summary>
+    public async Task RequestLockAsync(string operatorName)
+    {
+        await SendMessageAsync(new HubMessage
+        {
+            Type = "command",
+            Payload = JsonSerializer.SerializeToElement(new CommandPayload
+            {
+                Action = "request_lock",
+                Operator = operatorName
+            })
+        });
+    }
+
+    /// <summary>
+    /// 閲婃斁鎿嶄綔閿
+    /// </summary>
+    public async Task ReleaseLockAsync()
+    {
+        await SendMessageAsync(new HubMessage
+        {
+            Type = "command",
+            Payload = JsonSerializer.SerializeToElement(new CommandPayload
+            {
+                Action = "release_lock",
+                Operator = CurrentOperator ?? ""
+            })
+        });
+    }
+
+    /// <summary>
+    /// 鍙戦佹帶鍒跺懡浠
+    /// </summary>
+    public async Task SendCommandAsync(string action, string? target = null, object? value = null, string? operatorName = null)
+    {
+        await SendMessageAsync(new HubMessage
+        {
+            Type = "command",
+            Payload = JsonSerializer.SerializeToElement(new CommandPayload
+            {
+                Action = action,
+                Target = target,
+                Value = value,
+                Operator = operatorName ?? CurrentOperator ?? "unknown"
+            })
+        });
+    }
+
+    /// <summary>
+    /// 鍙戦佹秷鎭
+    /// </summary>
+    private async Task SendMessageAsync(HubMessage message)
+    {
+        if (_ws?.State != WebSocketState.Open)
+        {
+            Log.Warning("鏈繛鎺ュ埌鏈嶅姟绔紝鏃犳硶鍙戦佹秷鎭");
+            return;
+        }
+
+        var bytes = Encoding.UTF8.GetBytes(message.ToJson());
+        await _ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
+    }
+
+    /// <summary>
+    /// 鎺ユ敹鍗曟潯娑堟伅锛堢敤浜庤璇佺瓑鎻℃墜鍦烘櫙锛
+    /// </summary>
+    private async Task<HubMessage?> ReceiveOneMessageAsync(CancellationToken ct)
+    {
+        if (_ws?.State != WebSocketState.Open) return null;
+
+        var buffer = new byte[65536];
+        var messageBuffer = new MemoryStream();
+        WebSocketReceiveResult result;
+        do
+        {
+            result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
+            if (result.MessageType == WebSocketMessageType.Close) return null;
+            messageBuffer.Write(buffer, 0, result.Count);
+        }
+        while (!result.EndOfMessage);
+
+        if (result.MessageType != WebSocketMessageType.Text) return null;
+        var json = Encoding.UTF8.GetString(messageBuffer.ToArray());
+        return HubMessage.FromJson(json);
+    }
+
+    /// <summary>
+    /// 鎺ユ敹娑堟伅寰幆锛堝甫鍒嗙墖澶勭悊锛
+    /// </summary>
+    private async Task ReceiveLoopAsync(CancellationToken ct)
+    {
+        var buffer = new byte[65536];
+
+        while (_ws?.State == WebSocketState.Open && !ct.IsCancellationRequested)
+        {
+            // 鍔ㄦ佺紦鍐诧細寰幆璇诲彇鐩村埌 EndOfMessage
+            var messageBuffer = new MemoryStream();
+            WebSocketReceiveResult result;
+            do
+            {
+                result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
+
+                if (result.MessageType == WebSocketMessageType.Close)
+                {
+                    return; // 瑙﹀彂閲嶈繛
+                }
+
+                messageBuffer.Write(buffer, 0, result.Count);
+            }
+            while (!result.EndOfMessage);
+
+            if (result.MessageType == WebSocketMessageType.Text)
+            {
+                var json = Encoding.UTF8.GetString(messageBuffer.ToArray());
+                var message = HubMessage.FromJson(json);
+
+                if (message != null)
+                {
+                    HandleMessage(message);
+                }
+            }
+        }
+    }
+
+    /// <summary>
+    /// 澶勭悊鎺ユ敹鍒扮殑娑堟伅
+    /// </summary>
+    private void HandleMessage(HubMessage message)
+    {
+        switch (message.Type)
+        {
+            case "data":
+                var data = message.GetPayload<PlcDataModel>();
+                if (data != null) DataReceived?.Invoke(data);
+                break;
+
+            case "alarm":
+                var alarm = message.GetPayload<AlarmRecord>();
+                if (alarm != null) AlarmReceived?.Invoke(alarm);
+                break;
+
+            case "lock_result":
+                var lockResult = message.GetPayload<LockResultPayload>();
+                if (lockResult != null)
+                {
+                    HasLock = lockResult.Success;
+                    CurrentOperator = lockResult.CurrentOperator;
+                    LockStatusChanged?.Invoke(lockResult.Success, lockResult.CurrentOperator);
+                }
+                break;
+
+            case "lock_status":
+                var lockStatus = message.GetPayload<LockStatusPayload>();
+                if (lockStatus != null)
+                {
+                    LockStatusChanged?.Invoke(lockStatus.IsLocked, lockStatus.CurrentOperator);
+                }
+                break;
+
+            case "command_result":
+                var cmdResult = message.GetPayload<CommandResultPayload>();
+                if (cmdResult != null)
+                {
+                    CommandResult?.Invoke(cmdResult.Success, cmdResult.Message);
+                }
+                break;
+
+            case "client_count":
+                var count = message.Payload?.GetInt32();
+                if (count.HasValue) ClientCountChanged?.Invoke(count.Value);
+                break;
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        _shouldReconnect = false;
+        _cts?.Cancel();
+
+        if (_ws?.State == WebSocketState.Open)
+        {
+            try
+            {
+                _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "鍏抽棴", CancellationToken.None).Wait(1000);
+            }
+            catch { }
+        }
+
+        _ws?.Dispose();
+        _cts?.Dispose();
+    }
+}

+ 422 - 0
src/YZWater.Core/Services/HubServer.cs

@@ -0,0 +1,422 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.WebSockets;
+using System.Text;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 涓帶鏈嶅姟绔 - WebSocket 鏈嶅姟鍣紝绠$悊瀹㈡埛绔繛鎺ャ佹暟鎹箍鎾佹搷浣滈攣
+/// </summary>
+public class HubServer : IDisposable
+{
+    private HttpListener? _listener;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+
+    // 宸茶繛鎺ョ殑瀹㈡埛绔
+    private readonly ConcurrentDictionary<string, WebSocket> _clients = new();
+
+    // 鎿嶄綔閿
+    private string? _lockOperator;
+    private string? _lockClientId;
+    private DateTime _lockTime;
+    private readonly object _lockObj = new();
+    private static readonly TimeSpan LockTimeout = TimeSpan.FromMinutes(5);
+
+    /// <summary>
+    /// 鐩戝惉绔彛
+    /// </summary>
+    public int Port { get; set; } = 8765;
+
+    /// <summary>
+    /// 璁よ瘉浠ょ墝锛堢┖鍒欎笉楠岃瘉锛
+    /// </summary>
+    public string Token { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 鏄惁姝e湪杩愯
+    /// </summary>
+    public bool IsRunning { get; private set; }
+
+    /// <summary>
+    /// 褰撳墠杩炴帴鐨勫鎴风鏁
+    /// </summary>
+    public int ClientCount => _clients.Count;
+
+    /// <summary>
+    /// 褰撳墠鎿嶄綔鍛
+    /// </summary>
+    public string? CurrentOperator
+    {
+        get { lock (_lockObj) return _lockOperator; }
+    }
+
+    /// <summary>
+    /// 瀹㈡埛绔暟鍙樺寲浜嬩欢
+    /// </summary>
+    public event Action<int>? ClientCountChanged;
+
+    /// <summary>
+    /// 鏀跺埌鍛戒护浜嬩欢锛堢敱澶栭儴澶勭悊瀹為檯鎵ц锛
+    /// </summary>
+    public event Func<CommandPayload, Task<CommandResultPayload>>? CommandReceived;
+
+    /// <summary>
+    /// 鍚姩鏈嶅姟绔
+    /// </summary>
+    public void Start()
+    {
+        if (IsRunning) return;
+
+        _cts = new CancellationTokenSource();
+        _listener = new HttpListener();
+        _listener.Prefixes.Add($"http://+:{Port}/");
+        try
+        {
+            _listener.Start();
+        }
+        catch (HttpListenerException)
+        {
+            // 濡傛灉 + 涓嶅彲鐢紝灏濊瘯 localhost
+            _listener = new HttpListener();
+            _listener.Prefixes.Add($"http://localhost:{Port}/");
+            _listener.Start();
+        }
+
+        IsRunning = true;
+        Task.Run(() => AcceptLoopAsync(_cts.Token));
+        Log.Information("涓帶鏈嶅姟绔凡鍚姩锛岀鍙: {Port}", Port);
+    }
+
+    /// <summary>
+    /// 鍋滄鏈嶅姟绔
+    /// </summary>
+    public void Stop()
+    {
+        if (!IsRunning) return;
+
+        _cts?.Cancel();
+        _listener?.Stop();
+
+        // 鍏抽棴鎵鏈夊鎴风杩炴帴
+        foreach (var (id, ws) in _clients)
+        {
+            try { ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "鏈嶅姟绔叧闂", CancellationToken.None).Wait(1000); }
+            catch { }
+            ws.Dispose();
+        }
+        _clients.Clear();
+
+        IsRunning = false;
+        Log.Information("涓帶鏈嶅姟绔凡鍋滄");
+    }
+
+    /// <summary>
+    /// 鎺ュ彈瀹㈡埛绔繛鎺ュ惊鐜
+    /// </summary>
+    private async Task AcceptLoopAsync(CancellationToken ct)
+    {
+        while (!ct.IsCancellationRequested && _listener?.IsListening == true)
+        {
+            try
+            {
+                var context = await _listener.GetContextAsync();
+
+                if (context.Request.IsWebSocketRequest)
+                {
+                    var wsContext = await context.AcceptWebSocketAsync(null);
+                    var clientId = Guid.NewGuid().ToString("N")[..8];
+                    var ws = wsContext.WebSocket;
+
+                    // 浠ょ墝璁よ瘉
+                    if (!string.IsNullOrEmpty(Token))
+                    {
+                        var authOk = await VerifyAuthAsync(ws, ct);
+                        if (!authOk)
+                        {
+                            await SendToClientAsync(ws, HubMessage.AuthResult(false));
+                            ws.Dispose();
+                            Log.Warning("瀹㈡埛绔 {Id} 璁よ瘉澶辫触锛屾嫆缁濊繛鎺", clientId);
+                            continue;
+                        }
+                        await SendToClientAsync(ws, HubMessage.AuthResult(true));
+                    }
+
+                    _clients[clientId] = ws;
+                    Log.Information("瀹㈡埛绔繛鎺: {Id}, 褰撳墠 {Count} 涓鎴风", clientId, _clients.Count);
+                    ClientCountChanged?.Invoke(_clients.Count);
+
+                    // 鍙戦佸綋鍓嶉攣鐘舵
+                    await SendToClientAsync(ws, HubMessage.LockStatus(_lockOperator, _lockOperator != null));
+
+                    // 鍚姩鎺ユ敹寰幆
+                    _ = Task.Run(() => ReceiveLoopAsync(clientId, ws, ct));
+                }
+                else
+                {
+                    context.Response.StatusCode = 400;
+                    context.Response.Close();
+                }
+            }
+            catch (Exception ex) when (!ct.IsCancellationRequested)
+            {
+                Log.Error(ex, "鎺ュ彈杩炴帴寮傚父");
+            }
+        }
+    }
+
+    /// <summary>
+    /// 楠岃瘉瀹㈡埛绔璇佷护鐗
+    /// </summary>
+    private async Task<bool> VerifyAuthAsync(WebSocket ws, CancellationToken ct)
+    {
+        try
+        {
+            var buffer = new byte[65536];
+            var messageBuffer = new MemoryStream();
+            WebSocketReceiveResult result;
+            do
+            {
+                result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
+                if (result.MessageType == WebSocketMessageType.Close) return false;
+                messageBuffer.Write(buffer, 0, result.Count);
+            }
+            while (!result.EndOfMessage);
+
+            if (result.MessageType != WebSocketMessageType.Text) return false;
+
+            var json = Encoding.UTF8.GetString(messageBuffer.ToArray());
+            var message = HubMessage.FromJson(json);
+            if (message?.Type != "auth") return false;
+
+            var clientToken = message.Payload?.GetString();
+            return clientToken == Token;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// 鎺ユ敹瀹㈡埛绔秷鎭惊鐜
+    /// </summary>
+    private async Task ReceiveLoopAsync(string clientId, WebSocket ws, CancellationToken ct)
+    {
+        var buffer = new byte[65536];
+
+        try
+        {
+            while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
+            {
+                // 鍔ㄦ佺紦鍐诧細寰幆璇诲彇鐩村埌 EndOfMessage
+                var messageBuffer = new MemoryStream();
+                WebSocketReceiveResult result;
+                do
+                {
+                    result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
+
+                    if (result.MessageType == WebSocketMessageType.Close)
+                    {
+                        await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "OK", CancellationToken.None);
+                        _clients.TryRemove(clientId, out _);
+                        ReleaseLock(clientId);
+                        ClientCountChanged?.Invoke(_clients.Count);
+                        ws.Dispose();
+                        return;
+                    }
+
+                    messageBuffer.Write(buffer, 0, result.Count);
+                }
+                while (!result.EndOfMessage);
+
+                if (result.MessageType == WebSocketMessageType.Text)
+                {
+                    var json = Encoding.UTF8.GetString(messageBuffer.ToArray());
+                    var message = HubMessage.FromJson(json);
+
+                    if (message?.Type == "command")
+                    {
+                        var command = message.GetPayload<CommandPayload>();
+                        if (command != null)
+                        {
+                            await HandleCommandAsync(clientId, ws, command);
+                        }
+                    }
+                }
+            }
+        }
+        catch (Exception ex) when (!ct.IsCancellationRequested)
+        {
+            Log.Warning(ex, "瀹㈡埛绔 {Id} 鎺ユ敹寮傚父", clientId);
+        }
+        finally
+        {
+            _clients.TryRemove(clientId, out _);
+            ReleaseLock(clientId);
+            Log.Information("瀹㈡埛绔柇寮: {Id}, 鍓╀綑 {Count} 涓鎴风", clientId, _clients.Count);
+            ClientCountChanged?.Invoke(_clients.Count);
+            ws.Dispose();
+        }
+    }
+
+    /// <summary>
+    /// 澶勭悊瀹㈡埛绔懡浠
+    /// </summary>
+    private async Task HandleCommandAsync(string clientId, WebSocket ws, CommandPayload command)
+    {
+        Log.Debug("鏀跺埌鍛戒护: {Action} from {Operator}, target={Target}", command.Action, command.Operator, command.Target);
+
+        switch (command.Action)
+        {
+            case "request_lock":
+                var lockResult = RequestLock(command.Operator, clientId);
+                await SendToClientAsync(ws, HubMessage.LockResult(lockResult, _lockOperator));
+                if (lockResult)
+                    await BroadcastAsync(HubMessage.LockStatus(command.Operator, true));
+                break;
+
+            case "release_lock":
+                ReleaseLock(clientId);
+                await BroadcastAsync(HubMessage.LockStatus(null, false));
+                await SendToClientAsync(ws, HubMessage.LockResult(true));
+                break;
+
+            default:
+                // 妫鏌ユ搷浣滈攣
+                if (!HasLock(clientId))
+                {
+                    await SendToClientAsync(ws, HubMessage.CommandResult(false, "鏃犳搷浣滄潈闄愶紝璇峰厛璇锋眰鎿嶄綔閿"));
+                    break;
+                }
+
+                // 鎵ц鎺у埗鍛戒护
+                if (CommandReceived != null)
+                {
+                    var result = await CommandReceived(command);
+                    await SendToClientAsync(ws, HubMessage.CommandResult(result.Success, result.Message));
+                }
+                else
+                {
+                    await SendToClientAsync(ws, HubMessage.CommandResult(false, "鍛戒护澶勭悊鏈敞鍐"));
+                }
+                break;
+        }
+    }
+
+    /// <summary>
+    /// 骞惰骞挎挱娑堟伅缁欐墍鏈夊鎴风
+    /// </summary>
+    public async Task BroadcastAsync(HubMessage message)
+    {
+        var json = message.ToJson();
+        var bytes = Encoding.UTF8.GetBytes(json);
+        var segment = new ArraySegment<byte>(bytes);
+
+        var sendTasks = new List<Task>();
+        var deadClients = new List<string>();
+
+        foreach (var (id, ws) in _clients)
+        {
+            if (ws.State != WebSocketState.Open)
+            {
+                deadClients.Add(id);
+                continue;
+            }
+
+            sendTasks.Add(Task.Run(async () =>
+            {
+                try
+                {
+                    await ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
+                }
+                catch
+                {
+                    lock (deadClients) { deadClients.Add(id); }
+                }
+            }));
+        }
+
+        await Task.WhenAll(sendTasks);
+
+        foreach (var id in deadClients)
+        {
+            _clients.TryRemove(id, out _);
+        }
+    }
+
+    /// <summary>
+    /// 鍙戦佹秷鎭粰鍗曚釜瀹㈡埛绔
+    /// </summary>
+    private static async Task SendToClientAsync(WebSocket ws, HubMessage message)
+    {
+        if (ws.State != WebSocketState.Open) return;
+        var bytes = Encoding.UTF8.GetBytes(message.ToJson());
+        await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
+    }
+
+    // 鈹鈹鈹 鎿嶄綔閿佺鐞 鈹鈹鈹
+
+    private bool RequestLock(string operatorName, string clientId)
+    {
+        lock (_lockObj)
+        {
+            // 妫鏌ユ槸鍚﹁秴鏃惰嚜鍔ㄩ噴鏀
+            if (_lockOperator != null && DateTime.Now - _lockTime > LockTimeout)
+            {
+                Log.Warning("鎿嶄綔閿佽秴鏃惰嚜鍔ㄩ噴鏀: {Operator}", _lockOperator);
+                _lockOperator = null;
+                _lockClientId = null;
+            }
+
+            if (_lockOperator == null)
+            {
+                _lockOperator = operatorName;
+                _lockClientId = clientId;
+                _lockTime = DateTime.Now;
+                Log.Information("鎿嶄綔閿佸凡鍒嗛厤: {Operator} (瀹㈡埛绔 {Id})", operatorName, clientId);
+                return true;
+            }
+
+            return _lockClientId == clientId;
+        }
+    }
+
+    private void ReleaseLock(string clientId)
+    {
+        lock (_lockObj)
+        {
+            if (_lockClientId == clientId && _lockOperator != null)
+            {
+                Log.Information("鎿嶄綔閿佸凡閲婃斁: {Operator}", _lockOperator);
+                _lockOperator = null;
+                _lockClientId = null;
+            }
+        }
+    }
+
+    private bool HasLock(string clientId)
+    {
+        lock (_lockObj)
+        {
+            if (_lockOperator == null) return false;
+            if (DateTime.Now - _lockTime > LockTimeout)
+            {
+                _lockOperator = null;
+                _lockClientId = null;
+                return false;
+            }
+            return _lockClientId == clientId;
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+    }
+}

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

@@ -73,6 +73,8 @@ public partial class LanguageService : ObservableObject
         ["NavFlow"] = "娴侀噺璁板綍",
         ["NavAlarm"] = "鎶ヨ璁板綍",
         ["NavAbout"] = "鍏充簬",
+        ["NavAudit"] = "瀹¤",
+        ["AuditLog"] = "瀹¤鏃ュ織",
         // ViewA 鏍囬鏍
         ["ProcessFlow"] = "宸ヨ壓娴佺▼",
         ["PlcConnected"] = "PLC 宸茶繛鎺",
@@ -151,6 +153,8 @@ public partial class LanguageService : ObservableObject
         ["PurgeOld"] = "娓呴櫎鏃ф暟鎹",
         ["InflowTrend"] = "杩涙按娴侀噺瓒嬪娍",
         ["OutflowTrend"] = "鍑烘按娴侀噺瓒嬪娍",
+        ["TankLevelTrend"] = "姘寸娑蹭綅瓒嬪娍",
+        ["PumpFreqTrend"] = "娉甸鐜囪秼鍔",
         // ViewD
         ["AlarmLog"] = "鎶ヨ璁板綍",
         ["AlarmHistory"] = "鎶ヨ鍘嗗彶璁板綍",
@@ -177,6 +181,55 @@ public partial class LanguageService : ObservableObject
         ["VisitWebsite"] = "璁块棶瀹樼綉",
         ["CheckUpdate"] = "妫鏌ユ洿鏂",
         ["Copyright"] = "漏 鎵窞鏃僵绉戞妧鏈夐檺鍏徃",
+        // 鐢ㄦ埛绠$悊
+        ["UserManage"] = "鐢ㄦ埛绠$悊",
+        ["CreateNewUser"] = "鍒涘缓鏂扮敤鎴",
+        ["UserName"] = "鐢ㄦ埛鍚",
+        ["Password"] = "瀵嗙爜",
+        ["DisplayName"] = "鏄剧ず鍚嶇О",
+        ["Role"] = "瑙掕壊",
+        ["Status"] = "鐘舵",
+        ["LastLogin"] = "鏈鍚庣櫥褰",
+        ["Action"] = "鎿嶄綔",
+        ["Create"] = "鍒涘缓",
+        ["Close"] = "鍏抽棴",
+        ["Viewer"] = "鏌ョ湅鍛",
+        ["OperatorRole"] = "鎿嶄綔鍛",
+        ["Engineer"] = "宸ョ▼甯",
+        ["AdminRole"] = "绠$悊鍛",
+        ["Active"] = "鍚敤",
+        ["Disabled"] = "绂佺敤",
+        ["Logout"] = "閫鍑",
+        // 鐧诲綍椤
+        ["LoginTitle"] = "鐧诲綍 - 姹℃按澶勭悊鐩戞帶绯荤粺",
+        ["SystemName"] = "姹℃按澶勭悊鐩戞帶绯荤粺",
+        ["LoginPrompt"] = "璇风櫥褰曚互缁х画",
+        ["InputUserName"] = "璇疯緭鍏ョ敤鎴峰悕",
+        ["InputPassword"] = "璇疯緭鍏ュ瘑鐮",
+        ["RememberMe"] = "璁颁綇鐢ㄦ埛鍚",
+        ["LoginButton"] = "鐧 褰",
+        ["DefaultAccount"] = "榛樿璐︽埛: admin / admin123",
+        // 瀹¤鏃ュ織
+        ["Total"] = "鍏",
+        ["Records"] = "鏉",
+        ["Detail"] = "璇︽儏",
+        ["Target"] = "鐩爣",
+        ["Result"] = "缁撴灉",
+        ["User"] = "鐢ㄦ埛",
+        // 娴侀噺璁板綍
+        ["QuickRange"] = "蹇嵎:",
+        ["FlowTrend"] = "娴侀噺瓒嬪娍",
+        ["TimeHeader"] = "鏃堕棿",
+        ["InflowHeader"] = "杩涙按 (m鲁/h)",
+        ["OutflowHeader"] = "鍑烘按 (m鲁/h)",
+        ["TotalInHeader"] = "绱杩涙按 (m鲁)",
+        ["TotalOutHeader"] = "绱鍑烘按 (m鲁)",
+        // 璁惧
+        ["Pump1Title"] = "杩涙按娉1",
+        ["Pump2Title"] = "杩涙按娉2",
+        ["RefluxPump"] = "鍥炴祦娉",
+        ["Fan1Title"] = "椋庢満1",
+        ["Fan2Title"] = "椋庢満2",
         // 鎺т欢榛樿鍊
         ["DefaultTank"] = "姘寸",
         ["DefaultPump"] = "娉",
@@ -184,6 +237,88 @@ public partial class LanguageService : ObservableObject
         ["DefaultValve"] = "闃闂",
         ["DefaultGauge"] = "浠〃",
         ["DefaultDevice"] = "璁惧",
+        // PLC 鐘舵
+        ["PlcNotConnected"] = "PLC: 鏈繛鎺",
+        ["PlcConnectedStatus"] = "PLC: 宸茶繛鎺",
+        ["PlcDisconnected"] = "PLC: 鏂紑",
+        // 杩愯妯″紡
+        ["ServerMode"] = "鏈嶅姟绔",
+        ["ClientMode"] = "瀹㈡埛绔",
+        ["DirectMode"] = "鐩磋繛 PLC",
+        ["MockMode"] = "妯℃嫙鏁版嵁",
+        // 杩炴帴鐘舵
+        ["NotConnected"] = "鏈繛鎺",
+        ["Connecting"] = "杩炴帴涓...",
+        ["ConnectSuccess"] = "杩炴帴鎴愬姛",
+        ["ConnectFailed"] = "杩炴帴澶辫触",
+        ["ConnectError"] = "杩炴帴寮傚父",
+        // 鐢ㄦ埛绠$悊娑堟伅
+        ["UserNamePasswordRequired"] = "鐢ㄦ埛鍚嶅拰瀵嗙爜涓嶈兘涓虹┖",
+        ["UnknownUser"] = "鏈煡鐢ㄦ埛",
+        // 绛涢
+        ["All"] = "鍏ㄩ儴",
+        // 鍥捐〃绯诲垪鍚
+        ["InflowSeries"] = "杩涙按娴侀噺",
+        ["OutflowSeries"] = "鍑烘按娴侀噺",
+        ["Tank1Name"] = "鍏ュ彛姹",
+        ["Tank2Name"] = "姹2",
+        ["Tank3Name"] = "姹3",
+        ["Tank4Name"] = "鍑哄彛姹",
+        ["Pump1Name"] = "娉1",
+        ["Pump2Name"] = "娉2",
+        ["Pump3Name"] = "娉3",
+        ["Pump4Name"] = "娉4",
+        ["Pump5Name"] = "娉5",
+        // 鎺т欢榛樿鍊
+        ["DefaultFanLabel"] = "椋庢墖",
+        ["DefaultGaugeTitle"] = "娴侀噺",
+        ["DefaultStatusCard"] = "璁惧",
+        ["DefaultValveLabel"] = "闃闂",
+        // 杞崲鍣
+        ["Disable"] = "绂佺敤",
+        ["Enable"] = "鍚敤",
+        // AuthService 娑堟伅
+        ["AdminName"] = "绯荤粺绠$悊鍛",
+        ["InvalidCredentials"] = "鐢ㄦ埛鍚嶆垨瀵嗙爜閿欒",
+        ["AccountDisabled"] = "璐︽埛宸茬鐢",
+        ["LoginSuccess"] = "鐧诲綍鎴愬姛",
+        ["LoginError"] = "鐧诲綍寮傚父",
+        ["UserNotFound"] = "鐢ㄦ埛涓嶅瓨鍦",
+        ["OldPasswordWrong"] = "鍘熷瘑鐮侀敊璇",
+        ["PasswordTooShort"] = "鏂板瘑鐮侀暱搴︿笉鑳藉皯浜 6 浣",
+        ["PasswordChanged"] = "瀵嗙爜淇敼鎴愬姛",
+        ["ChangePasswordError"] = "淇敼瀵嗙爜寮傚父",
+        ["UserExists"] = "鐢ㄦ埛鍚嶅凡瀛樺湪",
+        ["UserCreated"] = "鐢ㄦ埛鍒涘缓鎴愬姛",
+        ["CreateUserError"] = "鍒涘缓鐢ㄦ埛寮傚父",
+        // 鎶ヨ娑堟伅
+        ["PlcCommLost"] = "PLC閫氫俊涓㈠け",
+        ["LevelTooHigh"] = "{0} 娑蹭綅杩囬珮 ({1:F1}%)",
+        ["LevelTooLow"] = "{0} 娑蹭綅杩囦綆 ({1:F1}%)",
+        ["FlowTooHigh"] = "{0} 娴侀噺杩囬珮 ({1:F1} m鲁/h)",
+        ["FlowTooLow"] = "{0} 娴侀噺杩囦綆 ({1:F1} m鲁/h)",
+        ["DeviceFault"] = "{0} 鏁呴殰",
+        ["AlarmEscalated"] = "[鍗囩骇] {0} 瓒呰繃 {1} 鍒嗛挓鏈‘璁",
+        // 璁惧鍚嶇О
+        ["PumpDeviceName"] = "娉祘0}",
+        ["FanDeviceName"] = "椋庢満{0}",
+        ["ManualRecord"] = "鎵嬪姩璁板綍",
+        // Hub 娑堟伅
+        ["InvalidPumpIndex"] = "鏃犳晥鐨勬车缂栧彿锛堥渶 1-5锛",
+        ["StartVerb"] = "鍚姩",
+        ["StopVerb"] = "鍋滄",
+        ["WriteFailed"] = "鍐欏叆澶辫触",
+        ["UnknownCommand"] = "鏈煡鍛戒护: {0}",
+        ["NoPermission"] = "鏃犳搷浣滄潈闄愶紝璇峰厛璇锋眰鎿嶄綔閿",
+        ["CommandNotRegistered"] = "鍛戒护澶勭悊鏈敞鍐",
+        // 閰嶇疆鏈嶅姟
+        ["ConfigNotLoaded"] = "閰嶇疆鏈姞杞",
+        ["ConfigExported"] = "閰嶇疆宸插鍑哄埌 {0}",
+        ["ExportFailed"] = "瀵煎嚭澶辫触: {0}",
+        ["FileNotFound"] = "鏂囦欢涓嶅瓨鍦",
+        ["ConfigFormatError"] = "閰嶇疆鏂囦欢鏍煎紡閿欒",
+        ["ConfigImported"] = "閰嶇疆瀵煎叆鎴愬姛",
+        ["ImportFailed"] = "瀵煎叆澶辫触: {0}",
     };
 
     private static readonly Dictionary<string, string> _en = new()
@@ -264,6 +399,10 @@ public partial class LanguageService : ObservableObject
         ["PurgeOld"] = "PURGE OLD",
         ["InflowTrend"] = "INFLOW TREND",
         ["OutflowTrend"] = "OUTFLOW TREND",
+        ["TankLevelTrend"] = "TANK LEVEL TREND",
+        ["PumpFreqTrend"] = "PUMP FREQUENCY TREND",
+        ["AuditLog"] = "AUDIT LOG",
+        ["NavAudit"] = "Audit",
         ["AlarmLog"] = "ALARM LOG",
         ["AlarmHistory"] = "Alarm History",
         ["Unconfirmed"] = "Unconfirmed",
@@ -288,11 +427,124 @@ public partial class LanguageService : ObservableObject
         ["VisitWebsite"] = "VISIT WEBSITE",
         ["CheckUpdate"] = "CHECK UPDATE",
         ["Copyright"] = "漏 Yangzhou Xuxuan Technology Co., Ltd.",
+        ["UserManage"] = "User Management",
+        ["CreateNewUser"] = "Create New User",
+        ["UserName"] = "Username",
+        ["Password"] = "Password",
+        ["DisplayName"] = "Display Name",
+        ["Role"] = "Role",
+        ["Status"] = "Status",
+        ["LastLogin"] = "Last Login",
+        ["Action"] = "Action",
+        ["Create"] = "Create",
+        ["Close"] = "Close",
+        ["Viewer"] = "Viewer",
+        ["OperatorRole"] = "Operator",
+        ["Engineer"] = "Engineer",
+        ["AdminRole"] = "Admin",
+        ["Active"] = "Active",
+        ["Disabled"] = "Disabled",
+        ["Logout"] = "Logout",
+        ["LoginTitle"] = "Login - Wastewater Treatment System",
+        ["SystemName"] = "Wastewater Treatment System",
+        ["LoginPrompt"] = "Please login to continue",
+        ["InputUserName"] = "Enter username",
+        ["InputPassword"] = "Enter password",
+        ["RememberMe"] = "Remember username",
+        ["LoginButton"] = "LOGIN",
+        ["DefaultAccount"] = "Default: admin / admin123",
+        ["Total"] = "Total",
+        ["Records"] = "records",
+        ["Detail"] = "Detail",
+        ["Target"] = "Target",
+        ["Result"] = "Result",
+        ["User"] = "User",
+        ["QuickRange"] = "Quick:",
+        ["FlowTrend"] = "Flow Trend",
+        ["TimeHeader"] = "Time",
+        ["InflowHeader"] = "Inflow (m鲁/h)",
+        ["OutflowHeader"] = "Outflow (m鲁/h)",
+        ["TotalInHeader"] = "Total In (m鲁)",
+        ["TotalOutHeader"] = "Total Out (m鲁)",
+        ["Pump1Title"] = "Inlet Pump 1",
+        ["Pump2Title"] = "Inlet Pump 2",
+        ["RefluxPump"] = "Reflux Pump",
+        ["Fan1Title"] = "Fan 1",
+        ["Fan2Title"] = "Fan 2",
         ["DefaultTank"] = "Tank",
         ["DefaultPump"] = "Pump",
         ["DefaultFan"] = "Fan",
         ["DefaultValve"] = "Valve",
         ["DefaultGauge"] = "Gauge",
         ["DefaultDevice"] = "Device",
+        ["PlcNotConnected"] = "PLC: Disconnected",
+        ["PlcConnectedStatus"] = "PLC: Connected",
+        ["PlcDisconnected"] = "PLC: Disconnected",
+        ["ServerMode"] = "Server",
+        ["ClientMode"] = "Client",
+        ["DirectMode"] = "Direct PLC",
+        ["MockMode"] = "Mock Data",
+        ["NotConnected"] = "Disconnected",
+        ["Connecting"] = "Connecting...",
+        ["ConnectSuccess"] = "Connected",
+        ["ConnectFailed"] = "Connection Failed",
+        ["ConnectError"] = "Connection Error",
+        ["UserNamePasswordRequired"] = "Username and password required",
+        ["UnknownUser"] = "Unknown",
+        ["All"] = "All",
+        ["InflowSeries"] = "Inflow",
+        ["OutflowSeries"] = "Outflow",
+        ["Tank1Name"] = "Inlet",
+        ["Tank2Name"] = "Tank 2",
+        ["Tank3Name"] = "Tank 3",
+        ["Tank4Name"] = "Outlet",
+        ["Pump1Name"] = "Pump 1",
+        ["Pump2Name"] = "Pump 2",
+        ["Pump3Name"] = "Pump 3",
+        ["Pump4Name"] = "Pump 4",
+        ["Pump5Name"] = "Pump 5",
+        ["DefaultFanLabel"] = "Fan",
+        ["DefaultGaugeTitle"] = "Flow",
+        ["DefaultStatusCard"] = "Device",
+        ["DefaultValveLabel"] = "Valve",
+        ["Disable"] = "Disable",
+        ["Enable"] = "Enable",
+        ["AdminName"] = "System Admin",
+        ["InvalidCredentials"] = "Invalid username or password",
+        ["AccountDisabled"] = "Account disabled",
+        ["LoginSuccess"] = "Login successful",
+        ["LoginError"] = "Login error",
+        ["UserNotFound"] = "User not found",
+        ["OldPasswordWrong"] = "Old password is incorrect",
+        ["PasswordTooShort"] = "Password must be at least 6 characters",
+        ["PasswordChanged"] = "Password changed successfully",
+        ["ChangePasswordError"] = "Failed to change password",
+        ["UserExists"] = "Username already exists",
+        ["UserCreated"] = "User created successfully",
+        ["CreateUserError"] = "Failed to create user",
+        ["PlcCommLost"] = "PLC communication lost",
+        ["LevelTooHigh"] = "{0} level too high ({1:F1}%)",
+        ["LevelTooLow"] = "{0} level too low ({1:F1}%)",
+        ["FlowTooHigh"] = "{0} flow too high ({1:F1} m鲁/h)",
+        ["FlowTooLow"] = "{0} flow too low ({1:F1} m鲁/h)",
+        ["DeviceFault"] = "{0} fault",
+        ["AlarmEscalated"] = "[Escalated] {0} unacknowledged for {1} minutes",
+        ["PumpDeviceName"] = "Pump {0}",
+        ["FanDeviceName"] = "Fan {0}",
+        ["ManualRecord"] = "Manual record",
+        ["InvalidPumpIndex"] = "Invalid pump index (1-5 required)",
+        ["StartVerb"] = "Start",
+        ["StopVerb"] = "Stop",
+        ["WriteFailed"] = "Write failed",
+        ["UnknownCommand"] = "Unknown command: {0}",
+        ["NoPermission"] = "No permission, please request operation lock first",
+        ["CommandNotRegistered"] = "Command handler not registered",
+        ["ConfigNotLoaded"] = "Config not loaded",
+        ["ConfigExported"] = "Config exported to {0}",
+        ["ExportFailed"] = "Export failed: {0}",
+        ["FileNotFound"] = "File not found",
+        ["ConfigFormatError"] = "Invalid config file format",
+        ["ConfigImported"] = "Config imported successfully",
+        ["ImportFailed"] = "Import failed: {0}",
     };
 }

+ 35 - 0
src/YZWater.Core/Services/LogService.cs

@@ -0,0 +1,35 @@
+using Serilog;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 鏃ュ織鏈嶅姟 - Serilog 鍒濆鍖栵紙搴斿湪搴旂敤鏈鏃╅樁娈佃皟鐢級
+/// </summary>
+public static class LogService
+{
+    private static bool _initialized;
+
+    /// <summary>
+    /// 鍒濆鍖 Serilog锛屽簲鍦ㄥ簲鐢ㄥ惎鍔ㄦ椂鏈鍏堣皟鐢
+    /// </summary>
+    public static void Initialize()
+    {
+        if (_initialized) return;
+
+        Log.Logger = new LoggerConfiguration()
+            .MinimumLevel.Debug()
+            .WriteTo.Console()
+            .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day)
+            .CreateLogger();
+
+        _initialized = true;
+    }
+
+    /// <summary>
+    /// 鍏抽棴骞跺埛鏂版棩蹇楃紦鍐插尯
+    /// </summary>
+    public static void Shutdown()
+    {
+        Log.CloseAndFlush();
+    }
+}

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

@@ -0,0 +1,135 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// Mock PLC 鏈嶅姟 - 鏃犵湡瀹 PLC 鏃剁敓鎴愭ā鎷熸暟鎹敤浜庢祴璇
+/// </summary>
+public static class MockPlcService
+{
+    private static readonly Random Rng = new();
+    private static bool _enabled;
+
+    // 妯℃嫙鐘舵
+    private static float _tank1 = 50, _tank2 = 60, _tank3 = 45, _tank4 = 55;
+    private static float _inflow = 30, _outflow = 28;
+    private static bool _pump1 = true, _pump2 = false, _pump3 = true, _pump4 = false, _pump5 = false;
+    private static bool _fan1 = true, _fan2 = false;
+    private static float _pump1Freq = 45, _pump2Freq = 0, _pump3Freq = 40, _pump4Freq = 0, _pump5Freq = 0;
+    private static bool _autoMode = true;
+
+    /// <summary>
+    /// 鏄惁鍚敤 Mock 妯″紡
+    /// </summary>
+    public static bool IsEnabled => _enabled;
+
+    /// <summary>
+    /// 鍚敤 Mock 妯″紡锛堟浛浠g湡瀹 PLC 杩炴帴锛
+    /// </summary>
+    public static void Enable()
+    {
+        _enabled = true;
+        Log.Information("Mock PLC 鏁版嵁婧愬凡鍚敤");
+    }
+
+    /// <summary>
+    /// 鐢熸垚涓甯фā鎷熸暟鎹苟鏇存柊 PlcDataModel
+    /// </summary>
+    public static void UpdateData(PlcDataModel data)
+    {
+        if (!_enabled) return;
+
+        // 妯℃嫙娑蹭綅娉㈠姩
+        _tank1 = Clamp(_tank1 + RandomDelta(2f), 0, 100);
+        _tank2 = Clamp(_tank2 + RandomDelta(1.5f), 0, 100);
+        _tank3 = Clamp(_tank3 + RandomDelta(1f), 0, 100);
+        _tank4 = Clamp(_tank4 + RandomDelta(1.5f), 0, 100);
+
+        // 妯℃嫙娴侀噺娉㈠姩
+        _inflow = Clamp(_inflow + RandomDelta(3f), 0, 200);
+        _outflow = Clamp(_outflow + RandomDelta(2.5f), 0, 200);
+
+        // 妯℃嫙娉甸鐜囨尝鍔
+        if (_pump1) _pump1Freq = Clamp(_pump1Freq + RandomDelta(1f), 0, 50);
+        if (_pump3) _pump3Freq = Clamp(_pump3Freq + RandomDelta(1f), 0, 50);
+
+        // 鍋跺皵鍒囨崲璁惧鐘舵
+        if (Rng.NextDouble() < 0.002) _pump2 = !_pump2;
+        if (Rng.NextDouble() < 0.002) _pump4 = !_pump4;
+        if (Rng.NextDouble() < 0.001) _pump5 = !_pump5;
+        if (Rng.NextDouble() < 0.003) _fan2 = !_fan2;
+
+        // 鏇存柊鏁版嵁妯″瀷
+        data.Tank1Level = _tank1;
+        data.Tank2Level = _tank2;
+        data.Tank3Level = _tank3;
+        data.Tank4Level = _tank4;
+        data.InflowRate = _inflow;
+        data.OutflowRate = _outflow;
+        data.FlowDelta = _inflow - _outflow;
+
+        data.Pump1Running = _pump1;
+        data.Pump2Running = _pump2;
+        data.Pump3Running = _pump3;
+        data.Pump4Running = _pump4;
+        data.Pump5Running = _pump5;
+
+        data.Pump1Fault = false;
+        data.Pump2Fault = false;
+        data.Pump3Fault = false;
+        data.Pump4Fault = false;
+        data.Pump5Fault = false;
+
+        data.Pump1Freq = _pump1Freq;
+        data.Pump2Freq = _pump2Freq;
+        data.Pump3Freq = _pump3Freq;
+        data.Pump4Freq = _pump4Freq;
+        data.Pump5Freq = _pump5Freq;
+
+        data.Fan1Running = _fan1;
+        data.Fan2Running = _fan2;
+        data.Fan1Fault = false;
+        data.Fan2Fault = false;
+
+        data.IsAutoMode = _autoMode;
+        data.LastReadTime = DateTime.Now;
+        data.IsDataValid = true;
+        data.IsPlcConnected = true;
+    }
+
+    /// <summary>
+    /// Mock 鍐欏叆鎿嶄綔锛堟绘槸鎴愬姛锛
+    /// </summary>
+    public static Task<bool> WriteBoolAsync(string address, bool value)
+    {
+        Log.Debug("Mock 鍐欏叆: {Address} = {Value}", address, value);
+
+        // 妯℃嫙娉靛惎鍋
+        if (address == PlcConfig.Pump1Run) _pump1 = value;
+        else if (address == PlcConfig.Pump2Run) _pump2 = value;
+        else if (address == PlcConfig.Pump3Run) _pump3 = value;
+        else if (address == PlcConfig.Pump4Run) _pump4 = value;
+        else if (address == PlcConfig.Pump5Run) _pump5 = value;
+        else if (address == PlcConfig.Fan1Run) _fan1 = value;
+        else if (address == PlcConfig.Fan2Run) _fan2 = value;
+
+        return Task.FromResult(true);
+    }
+
+    public static Task<bool> WriteFloatAsync(string address, float value)
+    {
+        Log.Debug("Mock 鍐欏叆: {Address} = {Value}", address, value);
+        return Task.FromResult(true);
+    }
+
+    private static float RandomDelta(float maxDelta)
+    {
+        return (float)(Rng.NextDouble() * 2 - 1) * maxDelta;
+    }
+
+    private static float Clamp(float value, float min, float max)
+    {
+        return Math.Max(min, Math.Min(max, value));
+    }
+}

+ 43 - 21
src/YZWater.Core/Services/PlcConfig.cs

@@ -1,23 +1,45 @@
 namespace YZWater.Core.Services;
 
 /// <summary>
-/// PLC 鍦板潃閰嶇疆 - 瀹氫箟鎵鏈 PLC 璇诲啓鍦板潃鏄犲皠
+/// PLC 鍦板潃閰嶇疆 - 瀹氫箟鎵鏈 PLC 璇诲啓鍦板潃鏄犲皠锛屾敮鎸佷粠閰嶇疆鏂囦欢瑕嗙洊
 /// </summary>
 public static class PlcConfig
 {
+    // 鍦板潃瑕嗙洊琛紙浠庨厤缃枃浠跺姞杞斤級
+    private static Dictionary<string, string> _overrides = new();
+
+    /// <summary>
+    /// 浠庨厤缃枃浠跺姞杞藉湴鍧瑕嗙洊
+    /// </summary>
+    public static void LoadFromConfig()
+    {
+        var config = ConfigService.GetConfig();
+        if (config?.PlcAddresses != null)
+        {
+            _overrides = config.PlcAddresses;
+        }
+    }
+
+    /// <summary>
+    /// 鑾峰彇鍦板潃锛堜紭鍏堜娇鐢ㄩ厤缃鐩栧硷級
+    /// </summary>
+    private static string Addr(string tagName, string defaultAddr)
+    {
+        return _overrides.TryGetValue(tagName, out var addr) ? addr : defaultAddr;
+    }
     // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
     //  璇诲彇鍦板潃 (Read)
     // 鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺愨晲鈺
 
     // 鈹鈹鈹 姘寸娑蹭綅 (float) 鈹鈹鈹
-    public const string Tank1Level = "VD100";
-    public const string Tank2Level = "VD104";
-    public const string Tank3Level = "VD108";
-    public const string Tank4Level = "VD112";
+    public static string Tank1Level => Addr(nameof(Tank1Level), "VD100");
+    public static string Tank2Level => Addr(nameof(Tank2Level), "VD104");
+    public static string Tank3Level => Addr(nameof(Tank3Level), "VD108");
+    public static string Tank4Level => Addr(nameof(Tank4Level), "VD112");
 
     // 鈹鈹鈹 娴侀噺 (float) 鈹鈹鈹
-    public const string InflowRate = "VD200";
-    public const string OutflowRate = "VD204";
+    public static string InflowRate => Addr(nameof(InflowRate), "VD200");
+    public static string OutflowRate => Addr(nameof(OutflowRate), "VD204");
 
     // 鈹鈹鈹 娉电姸鎬 (bool) 鈹鈹鈹
     public const string Pump1Run = "Q0.0";
@@ -34,18 +56,18 @@ public static class PlcConfig
     public const string Pump5Fault = "I0.4";
 
     // 鈹鈹鈹 娉甸鐜囧弽棣 (float) 鈹鈹鈹
-    public const string Pump1Freq = "VD300";
-    public const string Pump2Freq = "VD304";
-    public const string Pump3Freq = "VD308";
-    public const string Pump4Freq = "VD312";
-    public const string Pump5Freq = "VD316";
+    public static string Pump1Freq => Addr(nameof(Pump1Freq), "VD300");
+    public static string Pump2Freq => Addr(nameof(Pump2Freq), "VD304");
+    public static string Pump3Freq => Addr(nameof(Pump3Freq), "VD308");
+    public static string Pump4Freq => Addr(nameof(Pump4Freq), "VD312");
+    public static string Pump5Freq => Addr(nameof(Pump5Freq), "VD316");
 
     // 鈹鈹鈹 娉电數娴 (float) 鈹鈹鈹
-    public const string Pump1Current = "VD320";
-    public const string Pump2Current = "VD324";
-    public const string Pump3Current = "VD328";
-    public const string Pump4Current = "VD332";
-    public const string Pump5Current = "VD336";
+    public static string Pump1Current => Addr(nameof(Pump1Current), "VD320");
+    public static string Pump2Current => Addr(nameof(Pump2Current), "VD324");
+    public static string Pump3Current => Addr(nameof(Pump3Current), "VD328");
+    public static string Pump4Current => Addr(nameof(Pump4Current), "VD332");
+    public static string Pump5Current => Addr(nameof(Pump5Current), "VD336");
 
     // 鈹鈹鈹 椋庢満鐘舵 (bool) 鈹鈹鈹
     public const string Fan1Run = "Q0.5";
@@ -56,10 +78,10 @@ public static class PlcConfig
     public const string Fan2Fault = "I0.6";
 
     // 鈹鈹鈹 闃闂ㄤ綅缃弽棣 (int) 鈹鈹鈹
-    public const string Valve1Position = "VD500";
-    public const string Valve2Position = "VD504";
-    public const string Valve3Position = "VD508";
-    public const string Valve4Position = "VD512";
+    public static string Valve1Position => Addr(nameof(Valve1Position), "VD500");
+    public static string Valve2Position => Addr(nameof(Valve2Position), "VD504");
+    public static string Valve3Position => Addr(nameof(Valve3Position), "VD508");
+    public static string Valve4Position => Addr(nameof(Valve4Position), "VD512");
 
     // 鈹鈹鈹 绯荤粺鐘舵 鈹鈹鈹
     public const string SystemMode = "M0.0"; // true=鑷姩, false=鎵嬪姩

+ 110 - 55
src/YZWater.Core/Services/PlcPollingService.cs

@@ -10,7 +10,21 @@ namespace YZWater.Core.Services;
 public partial class PlcPollingService : ObservableObject, IDisposable
 {
     private static PlcPollingService? _instance;
-    public static PlcPollingService Instance => _instance ??= new PlcPollingService();
+    private static readonly object _instanceLock = new();
+    public static PlcPollingService Instance { get { lock (_instanceLock) { return _instance ??= new PlcPollingService(); } } }
+
+    /// <summary>
+    /// 閲嶇疆鍗曚緥锛堢敤浜庣櫥鍑哄悗閲嶆柊鐧诲綍锛
+    /// </summary>
+    public static void ResetInstance()
+    {
+        lock (_instanceLock)
+        {
+            var old = _instance;
+            _instance = null; // 鍏堢疆 null锛岀‘淇濆苟鍙戣闂 Instance 鏃跺垱寤烘柊瀹炰緥
+            old?.Dispose();
+        }
+    }
 
     private readonly PlcDataModel _data = new();
     private CancellationTokenSource? _cts;
@@ -75,26 +89,34 @@ public partial class PlcPollingService : ObservableObject, IDisposable
         {
             try
             {
-                // 纭繚杩炴帴
-                if (!PlcService.IsConnected)
+                if (MockPlcService.IsEnabled)
+                {
+                    // Mock 妯″紡锛氱洿鎺ョ敓鎴愭ā鎷熸暟鎹
+                    MockPlcService.UpdateData(_data);
+                }
+                else
                 {
-                    var connected = await TryConnectWithRetryAsync(reconnectAttempts, ct);
-                    if (!connected)
+                    // 鐪熷疄妯″紡锛氱‘淇濊繛鎺
+                    if (!PlcService.IsConnected)
                     {
-                        reconnectAttempts++;
-                        if (reconnectAttempts >= MaxReconnectAttempts)
+                        var connected = await TryConnectWithRetryAsync(reconnectAttempts, ct);
+                        if (!connected)
                         {
-                            ErrorOccurred?.Invoke($"PLC 杩炴帴澶辫触锛屽凡閲嶈瘯 {MaxReconnectAttempts} 娆");
-                            break;
+                            reconnectAttempts++;
+                            if (reconnectAttempts >= MaxReconnectAttempts)
+                            {
+                                ErrorOccurred?.Invoke($"PLC 杩炴帴澶辫触锛屽凡閲嶈瘯 {MaxReconnectAttempts} 娆");
+                                break;
+                            }
+                            await Task.Delay(ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5), ct);
+                            continue;
                         }
-                        await Task.Delay(ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5), ct);
-                        continue;
+                        reconnectAttempts = 0;
                     }
-                    reconnectAttempts = 0;
-                }
 
-                // 璇诲彇鏁版嵁
-                await ReadAllDataAsync(ct);
+                    // 璇诲彇鏁版嵁
+                    await ReadAllDataAsync(ct);
+                }
 
                 // 鏁版嵁鏇存柊浜嬩欢
                 DataUpdated?.Invoke(_data);
@@ -147,51 +169,76 @@ public partial class PlcPollingService : ObservableObject, IDisposable
     }
 
     /// <summary>
-    /// 鎵归噺璇诲彇鎵鏈 PLC 鏁版嵁
+    /// 鎵归噺璇诲彇鎵鏈 PLC 鏁版嵁锛6 娆℃壒閲忚鍙栨浛浠 25 娆″崟鐙鍙栵級
     /// </summary>
     private async Task ReadAllDataAsync(CancellationToken ct)
     {
         if (ct.IsCancellationRequested) return;
 
-        // 姘寸娑蹭綅
-        var tank1 = await PlcService.ReadFloatAsync(PlcConfig.Tank1Level);
-        var tank2 = await PlcService.ReadFloatAsync(PlcConfig.Tank2Level);
-        var tank3 = await PlcService.ReadFloatAsync(PlcConfig.Tank3Level);
-        var tank4 = await PlcService.ReadFloatAsync(PlcConfig.Tank4Level);
-
-        // 娴侀噺
-        var inflow = await PlcService.ReadFloatAsync(PlcConfig.InflowRate);
-        var outflow = await PlcService.ReadFloatAsync(PlcConfig.OutflowRate);
-
-        // 娉电姸鎬
-        var p1 = await PlcService.ReadBoolAsync(PlcConfig.Pump1Run);
-        var p2 = await PlcService.ReadBoolAsync(PlcConfig.Pump2Run);
-        var p3 = await PlcService.ReadBoolAsync(PlcConfig.Pump3Run);
-        var p4 = await PlcService.ReadBoolAsync(PlcConfig.Pump4Run);
-        var p5 = await PlcService.ReadBoolAsync(PlcConfig.Pump5Run);
-
-        // 娉垫晠闅
-        var pf1 = await PlcService.ReadBoolAsync(PlcConfig.Pump1Fault);
-        var pf2 = await PlcService.ReadBoolAsync(PlcConfig.Pump2Fault);
-        var pf3 = await PlcService.ReadBoolAsync(PlcConfig.Pump3Fault);
-        var pf4 = await PlcService.ReadBoolAsync(PlcConfig.Pump4Fault);
-        var pf5 = await PlcService.ReadBoolAsync(PlcConfig.Pump5Fault);
-
-        // 娉甸鐜
-        var f1 = await PlcService.ReadFloatAsync(PlcConfig.Pump1Freq);
-        var f2 = await PlcService.ReadFloatAsync(PlcConfig.Pump2Freq);
-        var f3 = await PlcService.ReadFloatAsync(PlcConfig.Pump3Freq);
-        var f4 = await PlcService.ReadFloatAsync(PlcConfig.Pump4Freq);
-        var f5 = await PlcService.ReadFloatAsync(PlcConfig.Pump5Freq);
-
-        // 椋庢満
-        var fan1 = await PlcService.ReadBoolAsync(PlcConfig.Fan1Run);
-        var fan2 = await PlcService.ReadBoolAsync(PlcConfig.Fan2Run);
-        var fanf1 = await PlcService.ReadBoolAsync(PlcConfig.Fan1Fault);
-        var fanf2 = await PlcService.ReadBoolAsync(PlcConfig.Fan2Fault);
-
-        // 绯荤粺妯″紡
-        var autoMode = await PlcService.ReadBoolAsync(PlcConfig.SystemMode);
+        // 骞惰鍙戣捣鎵鏈夋壒閲忚鍙栵紙浣跨敤 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: 绯荤粺妯″紡
+
+        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;
+
+        // 浠讳竴鎵归噺璇诲彇澶辫触鍒欒烦杩囨湰鍛ㄦ湡锛堝叏閮 6 涓兘妫鏌ワ級
+        if (tankData == null || flowData == null || freqData == null ||
+            qData == null || iData == null || mData == null)
+        {
+            _data.IsDataValid = false;
+            return;
+        }
+
+        // 瑙f瀽姘寸娑蹭綅 (VD100-VD112, 4 floats)
+        var tank1 = PlcService.ParseFloat(tankData, 0);
+        var tank2 = PlcService.ParseFloat(tankData, 4);
+        var tank3 = PlcService.ParseFloat(tankData, 8);
+        var tank4 = PlcService.ParseFloat(tankData, 12);
+
+        // 瑙f瀽娴侀噺 (VD200-VD204, 2 floats)
+        var inflow = PlcService.ParseFloat(flowData, 0);
+        var outflow = PlcService.ParseFloat(flowData, 4);
+
+        // 瑙f瀽娉甸鐜 (VD300-VD316, 5 floats)
+        var f1 = PlcService.ParseFloat(freqData, 0);
+        var f2 = PlcService.ParseFloat(freqData, 4);
+        var f3 = PlcService.ParseFloat(freqData, 8);
+        var f4 = PlcService.ParseFloat(freqData, 12);
+        var f5 = PlcService.ParseFloat(freqData, 16);
+
+        // 瑙f瀽 Q0 瀛楄妭: bit 0-4 = 娉佃繍琛, bit 5-6 = 椋庢満杩愯
+        var p1 = PlcService.ParseBit(qData, 0, 0);
+        var p2 = PlcService.ParseBit(qData, 0, 1);
+        var p3 = PlcService.ParseBit(qData, 0, 2);
+        var p4 = PlcService.ParseBit(qData, 0, 3);
+        var p5 = PlcService.ParseBit(qData, 0, 4);
+        var fan1 = PlcService.ParseBit(qData, 0, 5);
+        var fan2 = PlcService.ParseBit(qData, 0, 6);
+
+        // 瑙f瀽 I0 瀛楄妭: bit 0-4 = 娉垫晠闅, bit 5-6 = 椋庢満鏁呴殰
+        var pf1 = PlcService.ParseBit(iData, 0, 0);
+        var pf2 = PlcService.ParseBit(iData, 0, 1);
+        var pf3 = PlcService.ParseBit(iData, 0, 2);
+        var pf4 = PlcService.ParseBit(iData, 0, 3);
+        var pf5 = PlcService.ParseBit(iData, 0, 4);
+        var fanf1 = PlcService.ParseBit(iData, 0, 5);
+        var fanf2 = PlcService.ParseBit(iData, 0, 6);
+
+        // 瑙f瀽 M0 瀛楄妭: bit 0 = 绯荤粺妯″紡
+        var autoMode = PlcService.ParseBit(mData, 0, 0);
 
         // 搴旂敤姝诲尯杩囨护鍚庢洿鏂版暟鎹
         ApplyWithDeadband(_data.Tank1Level, tank1, v => _data.Tank1Level = v);
@@ -261,6 +308,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable
         }
     }
 
+    /// <summary>
+    /// 瑙﹀彂鏁版嵁鏇存柊浜嬩欢锛堜緵瀹㈡埛绔ā寮忎娇鐢級
+    /// </summary>
+    public void RaiseDataUpdated(PlcDataModel data)
+    {
+        DataUpdated?.Invoke(data);
+    }
+
     public void Dispose()
     {
         if (_disposed) return;

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

@@ -182,6 +182,53 @@ public static class PlcService
         }
     }
 
+    /// <summary>
+    /// 鎵归噺璇诲彇瀛楄妭
+    /// </summary>
+    public static async Task<byte[]?> ReadBytesAsync(string address, ushort length)
+    {
+        if (_plc == null || !_isConnected)
+        {
+            Log.Warning("PLC 鏈繛鎺ワ紝鏃犳硶璇诲彇鏁版嵁");
+            return null;
+        }
+
+        try
+        {
+            var result = await _plc.ReadAsync(address, length);
+            return result.IsSuccess ? result.Content : null;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鎵归噺璇诲彇 PLC 鏁版嵁澶辫触锛屽湴鍧: {Address}, 闀垮害: {Length}", address, length);
+            return null;
+        }
+    }
+
+    /// <summary>
+    /// 浠庡瓧鑺傛暟缁勮В鏋愭诞鐐规暟锛堝ぇ绔簭锛
+    /// </summary>
+    public static float ParseFloat(byte[] data, int offset)
+    {
+        if (data == null || offset + 4 > data.Length) return 0f;
+        // S7 PLC 浣跨敤澶х搴
+        if (BitConverter.IsLittleEndian)
+        {
+            var bytes = new byte[] { data[offset + 3], data[offset + 2], data[offset + 1], data[offset] };
+            return BitConverter.ToSingle(bytes, 0);
+        }
+        return BitConverter.ToSingle(data, offset);
+    }
+
+    /// <summary>
+    /// 浠庡瓧鑺傛暟缁勪腑鎻愬彇鎸囧畾浣
+    /// </summary>
+    public static bool ParseBit(byte[]? data, int byteOffset, int bitOffset)
+    {
+        if (data == null || byteOffset >= data.Length) return false;
+        return (data[byteOffset] & (1 << bitOffset)) != 0;
+    }
+
     /// <summary>
     /// 閲婃斁璧勬簮
     /// </summary>

+ 217 - 0
src/YZWater.Core/Services/ReportService.cs

@@ -0,0 +1,217 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 鎶ヨ〃鏈嶅姟 - 鐢熸垚鏃ユ姤/鏈堟姤
+/// </summary>
+public static class ReportService
+{
+    /// <summary>
+    /// 鐢熸垚鏃ユ姤锛圚TML 鏍煎紡锛
+    /// </summary>
+    public static async Task<string> GenerateDailyReportAsync(DateTime date)
+    {
+        try
+        {
+            var start = date.Date;
+            var end = start.AddDays(1);
+
+            // 鏌ヨ娴侀噺鏁版嵁
+            var flowRecords = await DatabaseService.Db.Queryable<FlowRecord>()
+                .Where(r => r.RecordTime >= start && r.RecordTime < end)
+                .OrderBy(r => r.RecordTime)
+                .ToListAsync();
+
+            // 鏌ヨ鎶ヨ鏁版嵁
+            var alarmRecords = await DatabaseService.Db.Queryable<AlarmRecord>()
+                .Where(r => r.AlarmTime >= start && r.AlarmTime < end)
+                .OrderBy(r => r.AlarmTime)
+                .ToListAsync();
+
+            // 璁$畻缁熻
+            var avgInflow = flowRecords.Count > 0 ? flowRecords.Average(r => r.InflowRate) : 0;
+            var avgOutflow = flowRecords.Count > 0 ? flowRecords.Average(r => r.OutflowRate) : 0;
+            var maxInflow = flowRecords.Count > 0 ? flowRecords.Max(r => r.InflowRate) : 0;
+            var maxOutflow = flowRecords.Count > 0 ? flowRecords.Max(r => r.OutflowRate) : 0;
+            var totalInflow = flowRecords.Count > 0 ? flowRecords.Last().TotalInflow : 0;
+            var totalOutflow = flowRecords.Count > 0 ? flowRecords.Last().TotalOutflow : 0;
+
+            var config = ConfigService.GetConfig();
+            var companyName = config?.CompanyName ?? "姹℃按澶勭悊鍘";
+
+            var html = $@"<!DOCTYPE html>
+<html>
+<head>
+    <meta charset='utf-8'>
+    <title>鏃ユ姤 - {date:yyyy-MM-dd}</title>
+    <style>
+        body {{ font-family: 'Microsoft YaHei', sans-serif; margin: 20px; }}
+        h1 {{ color: #1976D2; border-bottom: 2px solid #1976D2; padding-bottom: 8px; }}
+        h2 {{ color: #424242; margin-top: 24px; }}
+        table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
+        th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
+        th {{ background: #f5f5f5; }}
+        .summary {{ background: #E3F2FD; padding: 16px; border-radius: 8px; margin: 16px 0; }}
+        .alarm {{ color: #E53935; }}
+        .footer {{ color: #999; font-size: 12px; margin-top: 32px; border-top: 1px solid #eee; padding-top: 8px; }}
+    </style>
+</head>
+<body>
+    <h1>{companyName} - 鏃ユ姤</h1>
+    <p>鏃ユ湡: {date:yyyy骞碝M鏈坉d鏃</p>
+
+    <h2>娴侀噺缁熻</h2>
+    <div class='summary'>
+        <p><b>绱杩涙按閲:</b> {totalInflow:F1} m鲁 | <b>绱鍑烘按閲:</b> {totalOutflow:F1} m鲁</p>
+        <p><b>骞冲潎杩涙按娴侀噺:</b> {avgInflow:F1} m鲁/h | <b>骞冲潎鍑烘按娴侀噺:</b> {avgOutflow:F1} m鲁/h</p>
+        <p><b>鏈澶ц繘姘存祦閲:</b> {maxInflow:F1} m鲁/h | <b>鏈澶у嚭姘存祦閲:</b> {maxOutflow:F1} m鲁/h</p>
+        <p><b>璁板綍鏁:</b> {flowRecords.Count} 鏉</p>
+    </div>
+
+    <h2>鎶ヨ缁熻</h2>
+    <div class='summary'>
+        <p><b>鎶ヨ鎬绘暟:</b> {alarmRecords.Count} 鏉</p>
+        <p><b>鏈‘璁:</b> {alarmRecords.Count(r => !r.IsConfirmed)} 鏉</p>
+        <p><b>宸茬‘璁:</b> {alarmRecords.Count(r => r.IsConfirmed)} 鏉</p>
+    </div>
+
+    {(alarmRecords.Count > 0 ? $@"
+    <h2>鎶ヨ璇︽儏</h2>
+    <table>
+        <tr><th>鏃堕棿</th><th>绫诲瀷</th><th>鍐呭</th><th>绾у埆</th><th>鐘舵</th></tr>
+        {string.Join("", alarmRecords.Take(50).Select(a => $@"
+        <tr>
+            <td>{a.AlarmTime:HH:mm:ss}</td>
+            <td>{a.AlarmType}</td>
+            <td>{a.AlarmMessage}</td>
+            <td>{a.AlarmLevel}</td>
+            <td>{(a.IsConfirmed ? "宸茬‘璁" : "<span class='alarm'>鏈‘璁</span>")}</td>
+        </tr>"))}
+    </table>" : "")}
+
+    <div class='footer'>
+        <p>鎶ヨ〃鐢熸垚鏃堕棿: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | YZWater 姹℃按澶勭悊鐩戞帶绯荤粺</p>
+    </div>
+</body>
+</html>";
+
+            // 淇濆瓨鏂囦欢
+            var reportsDir = "Reports";
+            Directory.CreateDirectory(reportsDir);
+            var filePath = Path.Combine(reportsDir, $"鏃ユ姤_{date:yyyyMMdd}.html");
+            await File.WriteAllTextAsync(filePath, html);
+
+            Log.Information("鏃ユ姤宸茬敓鎴: {Path}", filePath);
+            AuditService.Log("绯荤粺", "Report", $"鐢熸垚鏃ユ姤: {filePath}");
+            return filePath;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鐢熸垚鏃ユ姤澶辫触");
+            return string.Empty;
+        }
+    }
+
+    /// <summary>
+    /// 鐢熸垚鏈堟姤
+    /// </summary>
+    public static async Task<string> GenerateMonthlyReportAsync(int year, int month)
+    {
+        try
+        {
+            var start = new DateTime(year, month, 1);
+            var end = start.AddMonths(1);
+
+            var flowRecords = await DatabaseService.Db.Queryable<FlowRecord>()
+                .Where(r => r.RecordTime >= start && r.RecordTime < end)
+                .ToListAsync();
+
+            var alarmRecords = await DatabaseService.Db.Queryable<AlarmRecord>()
+                .Where(r => r.AlarmTime >= start && r.AlarmTime < end)
+                .ToListAsync();
+
+            var config = ConfigService.GetConfig();
+            var companyName = config?.CompanyName ?? "姹℃按澶勭悊鍘";
+
+            var avgInflow = flowRecords.Count > 0 ? flowRecords.Average(r => r.InflowRate) : 0;
+            var avgOutflow = flowRecords.Count > 0 ? flowRecords.Average(r => r.OutflowRate) : 0;
+            var totalInflow = flowRecords.Count > 0 ? flowRecords.Last().TotalInflow : 0;
+            var totalOutflow = flowRecords.Count > 0 ? flowRecords.Last().TotalOutflow : 0;
+
+            // 鎸夋棩缁熻
+            var dailyStats = flowRecords
+                .GroupBy(r => r.RecordTime.Date)
+                .Select(g => new
+                {
+                    Date = g.Key,
+                    AvgInflow = g.Average(r => r.InflowRate),
+                    AvgOutflow = g.Average(r => r.OutflowRate),
+                    Count = g.Count()
+                })
+                .OrderBy(d => d.Date)
+                .ToList();
+
+            var html = $@"<!DOCTYPE html>
+<html>
+<head>
+    <meta charset='utf-8'>
+    <title>鏈堟姤 - {year}骞磠month}鏈</title>
+    <style>
+        body {{ font-family: 'Microsoft YaHei', sans-serif; margin: 20px; }}
+        h1 {{ color: #1976D2; border-bottom: 2px solid #1976D2; padding-bottom: 8px; }}
+        h2 {{ color: #424242; margin-top: 24px; }}
+        table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
+        th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
+        th {{ background: #f5f5f5; }}
+        .summary {{ background: #E3F2FD; padding: 16px; border-radius: 8px; margin: 16px 0; }}
+        .footer {{ color: #999; font-size: 12px; margin-top: 32px; border-top: 1px solid #eee; padding-top: 8px; }}
+    </style>
+</head>
+<body>
+    <h1>{companyName} - 鏈堟姤</h1>
+    <p>鏈熼棿: {year}骞磠month}鏈</p>
+
+    <h2>姹囨荤粺璁</h2>
+    <div class='summary'>
+        <p><b>绱杩涙按閲:</b> {totalInflow:F1} m鲁 | <b>绱鍑烘按閲:</b> {totalOutflow:F1} m鲁</p>
+        <p><b>骞冲潎杩涙按娴侀噺:</b> {avgInflow:F1} m鲁/h | <b>骞冲潎鍑烘按娴侀噺:</b> {avgOutflow:F1} m鲁/h</p>
+        <p><b>鎶ヨ鎬绘暟:</b> {alarmRecords.Count} 鏉</p>
+        <p><b>鏈夋晥璁板綍澶╂暟:</b> {dailyStats.Count} 澶</p>
+    </div>
+
+    <h2>姣忔棩缁熻</h2>
+    <table>
+        <tr><th>鏃ユ湡</th><th>骞冲潎杩涙按 (m鲁/h)</th><th>骞冲潎鍑烘按 (m鲁/h)</th><th>璁板綍鏁</th></tr>
+        {string.Join("", dailyStats.Select(d => $@"
+        <tr>
+            <td>{d.Date:MM-dd}</td>
+            <td>{d.AvgInflow:F1}</td>
+            <td>{d.AvgOutflow:F1}</td>
+            <td>{d.Count}</td>
+        </tr>"))}
+    </table>
+
+    <div class='footer'>
+        <p>鎶ヨ〃鐢熸垚鏃堕棿: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | YZWater 姹℃按澶勭悊鐩戞帶绯荤粺</p>
+    </div>
+</body>
+</html>";
+
+            var reportsDir = "Reports";
+            Directory.CreateDirectory(reportsDir);
+            var filePath = Path.Combine(reportsDir, $"鏈堟姤_{year}{month:D2}.html");
+            await File.WriteAllTextAsync(filePath, html);
+
+            Log.Information("鏈堟姤宸茬敓鎴: {Path}", filePath);
+            AuditService.Log("绯荤粺", "Report", $"鐢熸垚鏈堟姤: {filePath}");
+            return filePath;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鐢熸垚鏈堟姤澶辫触");
+            return string.Empty;
+        }
+    }
+}

+ 0 - 65
src/YZWater.Core/Utils/Nlogger.cs

@@ -1,65 +0,0 @@
-using Serilog;
-
-namespace YZWater.Core.Utils;
-
-/// <summary>
-/// 鏃ュ織宸ュ叿绫 - 灏佽 Serilog
-/// </summary>
-public static class Nlogger
-{
-    /// <summary>
-    /// 璁板綍璋冭瘯淇℃伅
-    /// </summary>
-    public static void Debug(string message, params object[] args)
-    {
-        Log.Debug(message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍鏅氫俊鎭
-    /// </summary>
-    public static void Info(string message, params object[] args)
-    {
-        Log.Information(message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍璀﹀憡淇℃伅
-    /// </summary>
-    public static void Warn(string message, params object[] args)
-    {
-        Log.Warning(message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍閿欒淇℃伅
-    /// </summary>
-    public static void Error(string message, params object[] args)
-    {
-        Log.Error(message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍閿欒淇℃伅锛堝甫寮傚父锛
-    /// </summary>
-    public static void Error(Exception ex, string message, params object[] args)
-    {
-        Log.Error(ex, message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍鑷村懡閿欒
-    /// </summary>
-    public static void Fatal(string message, params object[] args)
-    {
-        Log.Fatal(message, args);
-    }
-
-    /// <summary>
-    /// 璁板綍鑷村懡閿欒锛堝甫寮傚父锛
-    /// </summary>
-    public static void Fatal(Exception ex, string message, params object[] args)
-    {
-        Log.Fatal(ex, message, args);
-    }
-}

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

@@ -0,0 +1,111 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.ViewModels;
+
+/// <summary>
+/// 鐧诲綍椤 ViewModel
+/// </summary>
+public partial class LoginViewModel : ObservableObject
+{
+    private readonly LanguageService _lang = LanguageService.Instance;
+
+    [ObservableProperty] private string _userName = string.Empty;
+    [ObservableProperty] private string _password = string.Empty;
+    [ObservableProperty] private string _errorMessage = string.Empty;
+    [ObservableProperty] private bool _hasError;
+    [ObservableProperty] private bool _isLoggingIn;
+    [ObservableProperty] private bool _rememberUserName;
+
+    // 缈昏瘧鏂囧瓧
+    [ObservableProperty] private string _loginTitleText;
+    [ObservableProperty] private string _systemNameText;
+    [ObservableProperty] private string _loginPromptText;
+    [ObservableProperty] private string _userNameLabel;
+    [ObservableProperty] private string _passwordLabel;
+    [ObservableProperty] private string _inputUserNameText;
+    [ObservableProperty] private string _inputPasswordText;
+    [ObservableProperty] private string _rememberMeText;
+    [ObservableProperty] private string _loginButtonText;
+    [ObservableProperty] private string _defaultAccountText;
+
+    public event Action? LoginSucceeded;
+
+    public LoginViewModel()
+    {
+        UpdateTexts();
+        _lang.LanguageChanged += UpdateTexts;
+
+        var config = ConfigService.GetConfig();
+        if (config != null)
+        {
+            RememberUserName = config.RememberUserName;
+            if (RememberUserName && !string.IsNullOrEmpty(config.SavedUserName))
+            {
+                UserName = config.SavedUserName;
+            }
+        }
+    }
+
+    private void UpdateTexts()
+    {
+        LoginTitleText = _lang.Get("LoginTitle");
+        SystemNameText = _lang.Get("SystemName");
+        LoginPromptText = _lang.Get("LoginPrompt");
+        UserNameLabel = _lang.Get("UserName");
+        PasswordLabel = _lang.Get("Password");
+        InputUserNameText = _lang.Get("InputUserName");
+        InputPasswordText = _lang.Get("InputPassword");
+        RememberMeText = _lang.Get("RememberMe");
+        LoginButtonText = _lang.Get("LoginButton");
+        DefaultAccountText = _lang.Get("DefaultAccount");
+    }
+
+    [RelayCommand]
+    private async Task LoginAsync()
+    {
+        if (string.IsNullOrWhiteSpace(UserName) || string.IsNullOrWhiteSpace(Password))
+        {
+            ErrorMessage = _lang.Get("InputUserName") + " / " + _lang.Get("InputPassword");
+            HasError = true;
+            return;
+        }
+
+        IsLoggingIn = true;
+        HasError = false;
+
+        await Task.Delay(300);
+
+        var (success, message) = AuthService.Login(UserName, Password);
+
+        if (success)
+        {
+            SaveRememberedUserName();
+            LoginSucceeded?.Invoke();
+        }
+        else
+        {
+            ErrorMessage = message;
+            HasError = true;
+        }
+
+        IsLoggingIn = false;
+    }
+
+    [RelayCommand]
+    private void ClearError()
+    {
+        HasError = false;
+    }
+
+    private void SaveRememberedUserName()
+    {
+        var config = ConfigService.GetConfig();
+        if (config == null) return;
+
+        config.RememberUserName = RememberUserName;
+        config.SavedUserName = RememberUserName ? UserName : string.Empty;
+        ConfigService.UpdateConfig(config);
+    }
+}

+ 194 - 34
src/YZWater.Core/ViewModels/MainViewModel.cs

@@ -1,6 +1,7 @@
 using CommunityToolkit.Mvvm.ComponentModel;
 using CommunityToolkit.Mvvm.Input;
 using Serilog;
+using YZWater.Core.Models;
 using YZWater.Core.Services;
 
 namespace YZWater.Core.ViewModels;
@@ -8,16 +9,20 @@ namespace YZWater.Core.ViewModels;
 /// <summary>
 /// 涓荤獥鍙 ViewModel
 /// </summary>
-public partial class MainViewModel : ObservableObject
+public partial class MainViewModel : ObservableObject, IDisposable
 {
     private readonly ViewAViewModel _viewAViewModel;
     private readonly ViewBViewModel _viewBViewModel;
     private readonly ViewCViewModel _viewCViewModel;
     private readonly ViewDViewModel _viewDViewModel;
     private readonly ViewEViewModel _viewEViewModel;
+    private readonly ViewFViewModel _viewFViewModel;
 
     private readonly ThemeService _themeService = ThemeService.Instance;
     private readonly LanguageService _langService = LanguageService.Instance;
+    private readonly SynchronizationContext? _uiContext = SynchronizationContext.Current;
+    private System.Timers.Timer? _clockTimer;
+    private bool _disposed;
 
     [ObservableProperty]
     private ObservableObject _currentView;
@@ -40,6 +45,7 @@ public partial class MainViewModel : ObservableObject
     [ObservableProperty] private bool _isTabCActive;
     [ObservableProperty] private bool _isTabDActive;
     [ObservableProperty] private bool _isTabEActive;
+    [ObservableProperty] private bool _isTabFActive;
 
     // 褰撳墠 Tab 鏍囬
     [ObservableProperty] private string _tabTitle;
@@ -56,6 +62,43 @@ public partial class MainViewModel : ObservableObject
     [ObservableProperty] private string _navFlow;
     [ObservableProperty] private string _navAlarm;
     [ObservableProperty] private string _navAbout;
+    [ObservableProperty] private string _navAudit;
+
+    // 鈺愨晲鈺 鐢ㄦ埛绠$悊缈昏瘧 鈺愨晲鈺
+    [ObservableProperty] private string _userManageText;
+    [ObservableProperty] private string _createNewUserText;
+    [ObservableProperty] private string _userNameLabel;
+    [ObservableProperty] private string _passwordLabel;
+    [ObservableProperty] private string _displayNameLabel;
+    [ObservableProperty] private string _roleLabel;
+    [ObservableProperty] private string _statusLabel;
+    [ObservableProperty] private string _lastLoginLabel;
+    [ObservableProperty] private string _actionLabel;
+    [ObservableProperty] private string _createText;
+    [ObservableProperty] private string _closeText;
+    [ObservableProperty] private string _logoutText;
+
+    // 鈺愨晲鈺 杩愯妯″紡涓庤繛鎺ョ姸鎬 鈺愨晲鈺
+    [ObservableProperty] private string _runMode = "Direct";
+    [ObservableProperty] private string _hubStatusText = "";
+    [ObservableProperty] private string _plcStatusText = "";
+    [ObservableProperty] private string _plcStatusColor = "#E74C3C";
+
+    // 鈺愨晲鈺 瀛 ViewModel锛堜緵 View 缁戝畾 DataContext锛 鈺愨晲鈺
+    public ViewAViewModel ViewA => _viewAViewModel;
+    public ViewBViewModel ViewB => _viewBViewModel;
+    public ViewCViewModel ViewC => _viewCViewModel;
+    public ViewDViewModel ViewD => _viewDViewModel;
+    public ViewEViewModel ViewE => _viewEViewModel;
+    public ViewFViewModel ViewF => _viewFViewModel;
+
+    // 鈺愨晲鈺 鐢ㄦ埛涓庢潈闄 鈺愨晲鈺
+    [ObservableProperty] private string _currentUserName = string.Empty;
+    [ObservableProperty] private string _currentUserRole = string.Empty;
+    [ObservableProperty] private bool _canControl;
+    [ObservableProperty] private bool _canModifyParams;
+    [ObservableProperty] private bool _canManageUsers;
+    [ObservableProperty] private bool _showUserDialog;
 
     public MainViewModel()
     {
@@ -64,20 +107,21 @@ public partial class MainViewModel : ObservableObject
         _viewCViewModel = new ViewCViewModel();
         _viewDViewModel = new ViewDViewModel();
         _viewEViewModel = new ViewEViewModel();
+        _viewFViewModel = new ViewFViewModel();
 
         _currentView = _viewAViewModel;
 
-        // 鍒濆鍖栨枃瀛
         UpdateAllTexts();
+        RefreshUserInfo();
+        LoadRunMode();
 
-        // 鐩戝惉璇█鍙樻洿
         _langService.LanguageChanged += () =>
         {
             IsChinese = _langService.IsChinese;
             UpdateAllTexts();
+            RefreshUserInfo();
         };
 
-        // 鐩戝惉涓婚鍙樻洿
         _themeService.PropertyChanged += (s, e) =>
         {
             if (e.PropertyName == nameof(ThemeService.IsDarkTheme))
@@ -87,12 +131,21 @@ public partial class MainViewModel : ObservableObject
             }
         };
 
+        // PLC 杩炴帴鐘舵佺洃鍚
+        PlcPollingService.Instance.ConnectionStateChanged += connected =>
+        {
+            if (_uiContext != null)
+                _uiContext.Post(_ =>
+                {
+                    PlcStatusText = connected ? _langService.Get("PlcConnectedStatus") : _langService.Get("PlcDisconnected");
+                    PlcStatusColor = connected ? "#27AE60" : "#E74C3C";
+                    IsPlcConnected = connected;
+                }, null);
+        };
+
         StartTimer();
     }
 
-    /// <summary>
-    /// 鏇存柊鎵鏈夌晫闈㈡枃瀛
-    /// </summary>
     private void UpdateAllTexts()
     {
         Title = _langService.Get("AppName");
@@ -102,27 +155,53 @@ public partial class MainViewModel : ObservableObject
         NavFlow = _langService.Get("NavFlow");
         NavAlarm = _langService.Get("NavAlarm");
         NavAbout = _langService.Get("NavAbout");
+        NavAudit = _langService.Get("AuditLog");
+        UserManageText = _langService.Get("UserManage");
+        CreateNewUserText = _langService.Get("CreateNewUser");
+        UserNameLabel = _langService.Get("UserName");
+        PasswordLabel = _langService.Get("Password");
+        DisplayNameLabel = _langService.Get("DisplayName");
+        RoleLabel = _langService.Get("Role");
+        StatusLabel = _langService.Get("Status");
+        LastLoginLabel = _langService.Get("LastLogin");
+        ActionLabel = _langService.Get("Action");
+        CreateText = _langService.Get("Create");
+        CloseText = _langService.Get("Close");
+        LogoutText = _langService.Get("Logout");
         IsChinese = _langService.IsChinese;
         LangIcon = IsChinese ? "涓" : "EN";
         IsDarkTheme = _themeService.IsDarkTheme;
         ThemeIcon = IsDarkTheme ? "馃寵" : "鈽锔";
     }
 
-    // 鈹鈹鈹 涓婚鍒囨崲 鈹鈹鈹
+    /// <summary>
+    /// 鍔犺浇杩愯妯″紡
+    /// </summary>
+    private void LoadRunMode()
+    {
+        var config = ConfigService.GetConfig();
+        RunMode = config?.RunMode ?? "Direct";
+        HubStatusText = RunMode switch
+        {
+            "Server" => $"{_langService.Get("ServerMode")} :{config?.HubPort ?? 8765}",
+            "Client" => $"{_langService.Get("ClientMode")} 鈫 {config?.HubServerUrl ?? "ws://localhost:8765"}",
+            "Mock" => _langService.Get("MockMode"),
+            _ => _langService.Get("DirectMode")
+        };
+    }
+
     [RelayCommand]
     private void ToggleTheme()
     {
         _themeService.ToggleTheme();
     }
 
-    // 鈹鈹鈹 璇█鍒囨崲 鈹鈹鈹
     [RelayCommand]
     private void ToggleLanguage()
     {
         _langService.ToggleLanguage();
     }
 
-    // 鈹鈹鈹 PLC 鎿嶄綔浠g悊 鈹鈹鈹
     [RelayCommand]
     private async Task ConnectPlcAsync() => await _viewAViewModel.ConnectPlcCommand.ExecuteAsync(null);
 
@@ -133,7 +212,7 @@ public partial class MainViewModel : ObservableObject
     private async Task RefreshDataAsync() => await _viewAViewModel.RefreshDataCommand.ExecuteAsync(null);
 
     // 鈹鈹鈹 Tab 鍒囨崲 鈹鈹鈹
-    private readonly string[] _tabTitleKeys = { "ProcessFlow", "Parameters", "FlowRecords", "AlarmLog", "About" };
+    private readonly string[] _tabTitleKeys = { "ProcessFlow", "Parameters", "FlowRecords", "AlarmLog", "About", "AuditLog" };
 
     private void SetActiveTab(int index)
     {
@@ -143,51 +222,132 @@ public partial class MainViewModel : ObservableObject
         IsTabCActive = index == 2;
         IsTabDActive = index == 3;
         IsTabEActive = index == 4;
+        IsTabFActive = index == 5;
         TabTitle = _langService.Get(_tabTitleKeys[index]);
     }
 
-    [RelayCommand]
-    private void ShowViewA()
+    [RelayCommand] private void ShowViewA() { CurrentView = _viewAViewModel; SetActiveTab(0); }
+    [RelayCommand] private void ShowViewB() { CurrentView = _viewBViewModel; SetActiveTab(1); }
+    [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); }
+
+    private void StartTimer()
+    {
+        _clockTimer = new System.Timers.Timer(1000);
+        _clockTimer.Elapsed += (s, e) =>
+        {
+            var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
+            if (_uiContext != null)
+                _uiContext.Post(_ => CurrentTime = time, null);
+            else
+                CurrentTime = time;
+        };
+        _clockTimer.Start();
+    }
+
+    private void StopTimer()
     {
-        CurrentView = _viewAViewModel;
-        SetActiveTab(0);
+        _clockTimer?.Stop();
+        _clockTimer?.Dispose();
+        _clockTimer = null;
+    }
+
+    private void RefreshUserInfo()
+    {
+        var user = AuthService.CurrentUser;
+        if (user != null)
+        {
+            // 绠$悊鍛樻樉绀哄悕璺熼殢璇█鏇存柊
+            if (user.UserName == "admin")
+            {
+                var adminDisplayName = _langService.Get("AdminName");
+                if (user.DisplayName != adminDisplayName)
+                {
+                    user.DisplayName = adminDisplayName;
+                    DatabaseService.Db.Updateable(user).UpdateColumns(u => u.DisplayName).ExecuteCommand();
+                }
+            }
+
+            CurrentUserName = user.DisplayName;
+            CurrentUserRole = user.Role switch
+            {
+                UserRole.Viewer => _langService.Get("Viewer"),
+                UserRole.Operator => _langService.Get("OperatorRole"),
+                UserRole.Engineer => _langService.Get("Engineer"),
+                UserRole.Admin => _langService.Get("AdminRole"),
+                _ => "Unknown"
+            };
+            CanControl = AuthService.HasPermission(UserRole.Operator);
+            CanModifyParams = AuthService.HasPermission(UserRole.Engineer);
+            CanManageUsers = AuthService.HasPermission(UserRole.Admin);
+        }
     }
 
     [RelayCommand]
-    private void ShowViewB()
+    private void Logout()
     {
-        CurrentView = _viewBViewModel;
-        SetActiveTab(1);
+        AuthService.Logout();
     }
 
     [RelayCommand]
-    private void ShowViewC()
+    private void ToggleUserDialog()
+    {
+        ShowUserDialog = !ShowUserDialog;
+        if (ShowUserDialog)
+        {
+            LoadUsers();
+        }
+    }
+
+    // 鈺愨晲鈺 鐢ㄦ埛绠$悊 鈺愨晲鈺
+    [ObservableProperty] private List<User> _userList = new();
+    [ObservableProperty] private string _newUserName = string.Empty;
+    [ObservableProperty] private string _newUserPassword = string.Empty;
+    [ObservableProperty] private string _newUserDisplayName = string.Empty;
+    [ObservableProperty] private UserRole _newUserRole = UserRole.Operator;
+    [ObservableProperty] private string _userManageMessage = string.Empty;
+
+    private void LoadUsers()
     {
-        CurrentView = _viewCViewModel;
-        SetActiveTab(2);
+        UserList = AuthService.GetAllUsers();
     }
 
     [RelayCommand]
-    private void ShowViewD()
+    private void CreateUser()
     {
-        CurrentView = _viewDViewModel;
-        SetActiveTab(3);
+        if (string.IsNullOrWhiteSpace(NewUserName) || string.IsNullOrWhiteSpace(NewUserPassword))
+        {
+            UserManageMessage = _langService.Get("UserNamePasswordRequired");
+            return;
+        }
+
+        var (success, message) = AuthService.CreateUser(NewUserName, NewUserPassword, NewUserDisplayName, NewUserRole);
+        UserManageMessage = message;
+
+        if (success)
+        {
+            NewUserName = string.Empty;
+            NewUserPassword = string.Empty;
+            NewUserDisplayName = string.Empty;
+            NewUserRole = UserRole.Operator;
+            LoadUsers();
+        }
     }
 
     [RelayCommand]
-    private void ShowViewE()
+    private void ToggleUserActive(User? user)
     {
-        CurrentView = _viewEViewModel;
-        SetActiveTab(4);
+        if (user == null) return;
+        AuthService.SetUserActive(user.UserName, !user.IsActive);
+        LoadUsers();
     }
 
-    private void StartTimer()
+    public void Dispose()
     {
-        var timer = new System.Timers.Timer(1000);
-        timer.Elapsed += (s, e) =>
-        {
-            CurrentTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
-        };
-        timer.Start();
+        if (_disposed) return;
+        _disposed = true;
+        StopTimer();
     }
 }

+ 10 - 0
src/YZWater.Core/ViewModels/ViewAViewModel.cs

@@ -147,6 +147,11 @@ public partial class ViewAViewModel : ObservableObject
     [ObservableProperty] private string _flowMetersText;
     [ObservableProperty] private string _deviceStatusText;
     [ObservableProperty] private string _processFlowText;
+    [ObservableProperty] private string _pump1TitleText;
+    [ObservableProperty] private string _pump2TitleText;
+    [ObservableProperty] private string _refluxPumpText;
+    [ObservableProperty] private string _fan1TitleText;
+    [ObservableProperty] private string _fan2TitleText;
 
     public ViewAViewModel()
     {
@@ -205,6 +210,11 @@ public partial class ViewAViewModel : ObservableObject
         FlowMetersText = _lang.Get("FlowMeters");
         DeviceStatusText = _lang.Get("DeviceStatus");
         ProcessFlowText = _lang.Get("ProcessFlow");
+        Pump1TitleText = _lang.Get("Pump1Title");
+        Pump2TitleText = _lang.Get("Pump2Title");
+        RefluxPumpText = _lang.Get("RefluxPump");
+        Fan1TitleText = _lang.Get("Fan1Title");
+        Fan2TitleText = _lang.Get("Fan2Title");
     }
 
     private void StartClockTimer()

+ 44 - 5
src/YZWater.Core/ViewModels/ViewBViewModel.cs

@@ -33,7 +33,7 @@ public partial class ViewBViewModel : ObservableObject
     private float _pumpFrequency = 50f;
 
     [ObservableProperty]
-    private string _connectionStatus = "鏈繛鎺";
+    private string _connectionStatus = "";
 
     [ObservableProperty]
     private bool _isConnecting;
@@ -64,6 +64,7 @@ public partial class ViewBViewModel : ObservableObject
     {
         UpdateTexts();
         _lang.LanguageChanged += UpdateTexts;
+        ConnectionStatus = _lang.Get("NotConnected");
         LoadConfig();
     }
 
@@ -138,15 +139,14 @@ public partial class ViewBViewModel : ObservableObject
     private async Task TestConnectionAsync()
     {
         IsConnecting = true;
-        ConnectionStatus = "杩炴帴涓...";
+        ConnectionStatus = _lang.Get("Connecting");
 
         try
         {
-            // 鏇存柊 PLC IP
             PlcService.Initialize();
 
             var success = await PlcService.ConnectAsync();
-            ConnectionStatus = success ? "杩炴帴鎴愬姛" : "杩炴帴澶辫触";
+            ConnectionStatus = success ? _lang.Get("ConnectSuccess") : _lang.Get("ConnectFailed");
 
             if (success)
             {
@@ -159,7 +159,7 @@ public partial class ViewBViewModel : ObservableObject
         }
         catch (Exception ex)
         {
-            ConnectionStatus = "杩炴帴寮傚父";
+            ConnectionStatus = _lang.Get("ConnectError");
             Log.Error(ex, "PLC 杩炴帴娴嬭瘯寮傚父");
         }
         finally
@@ -168,6 +168,45 @@ public partial class ViewBViewModel : ObservableObject
         }
     }
 
+    /// <summary>
+    /// 鍐欏叆娉甸鐜囧埌 PLC
+    /// </summary>
+    [RelayCommand]
+    private async Task WriteFrequencyAsync()
+    {
+        // 鑼冨洿楠岃瘉
+        if (PumpFrequency < 0 || PumpFrequency > 50)
+        {
+            Log.Warning("娉甸鐜囪瀹氬艰秴鑼冨洿: {Freq} Hz锛堝厑璁 0-50锛", PumpFrequency);
+            return;
+        }
+
+        if (!PlcService.IsConnected)
+        {
+            Log.Warning("PLC 鏈繛鎺ワ紝鏃犳硶鍐欏叆棰戠巼");
+            return;
+        }
+
+        try
+        {
+            var success = await PlcService.WriteFloatAsync(PlcConfig.PumpFreqSetpoint, PumpFrequency);
+            if (success)
+            {
+                Log.Information("娉甸鐜囧凡鍐欏叆 PLC: {Freq} Hz", PumpFrequency);
+                AuditService.Log("绯荤粺", "Control", $"鍐欏叆娉甸鐜: {PumpFrequency} Hz", PlcConfig.PumpFreqSetpoint);
+            }
+            else
+            {
+                Log.Error("娉甸鐜囧啓鍏ュけ璐");
+                AuditService.Log("绯荤粺", "Control", $"娉甸鐜囧啓鍏ュけ璐: {PumpFrequency} Hz", PlcConfig.PumpFreqSetpoint, "Failed");
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鍐欏叆娉甸鐜囧紓甯");
+        }
+    }
+
     /// <summary>
     /// 鎭㈠榛樿璁剧疆
     /// </summary>

+ 176 - 110
src/YZWater.Core/ViewModels/ViewCViewModel.cs

@@ -16,28 +16,22 @@ namespace YZWater.Core.ViewModels;
 /// </summary>
 public partial class ViewCViewModel : ObservableObject
 {
-    [ObservableProperty]
-    private ISeries[] _inflowSeries;
-
-    [ObservableProperty]
-    private ISeries[] _outflowSeries;
-
-    [ObservableProperty]
-    private Axis[] _xAxes;
-
-    [ObservableProperty]
-    private Axis[] _yAxes;
-
-    [ObservableProperty]
-    private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
-
-    [ObservableProperty]
-    private DateTimeOffset? _endDate = DateTimeOffset.Now;
-
-    [ObservableProperty]
-    private List<FlowRecord> _flowRecords = new();
-
-    // 鈹鈹鈹 缈昏瘧鏂囧瓧 鈹鈹鈹
+    // 鈺愨晲鈺 鍥捐〃鏁版嵁 鈺愨晲鈺
+    [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 DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-1);
+    [ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
+    [ObservableProperty] private List<FlowRecord> _flowRecords = new();
+
+    // 鈺愨晲鈺 缈昏瘧鏂囧瓧 鈺愨晲鈺
     private readonly LanguageService _lang = LanguageService.Instance;
     [ObservableProperty] private string _titleText;
     [ObservableProperty] private string _subtitleText;
@@ -48,62 +42,56 @@ public partial class ViewCViewModel : ObservableObject
     [ObservableProperty] private string _purgeOldText;
     [ObservableProperty] private string _inflowTrendText;
     [ObservableProperty] private string _outflowTrendText;
+    [ObservableProperty] private string _tankLevelTrendText;
+    [ObservableProperty] private string _pumpFreqTrendText;
+    [ObservableProperty] private string _quickRangeText;
+    [ObservableProperty] private string _flowTrendText;
+    [ObservableProperty] private string _timeHeaderText;
+    [ObservableProperty] private string _inflowHeaderText;
+    [ObservableProperty] private string _outflowHeaderText;
+    [ObservableProperty] private string _totalInHeaderText;
+    [ObservableProperty] private string _totalOutHeaderText;
 
     public ViewCViewModel()
     {
         UpdateTexts();
         _lang.LanguageChanged += UpdateTexts;
-        InitializeChart();
+        InitializeCharts();
 
-        // 璁㈤槄 PLC 杞鏁版嵁
         PlcPollingService.Instance.DataUpdated += OnDataUpdated;
-
         _ = LoadFlowRecordsAsync();
     }
 
-    /// <summary>
-    /// PLC 鏁版嵁鏇存柊鍥炶皟 - 璁板綍娴侀噺鏁版嵁
-    /// </summary>
     private void OnDataUpdated(PlcDataModel data)
     {
-        // 灏嗗綋鍓嶆祦閲忔暟鎹坊鍔犲埌鍥捐〃
-        AddDataPoint(data);
+        AddRealtimePoint(data);
     }
 
-    private void AddDataPoint(PlcDataModel data)
+    /// <summary>
+    /// 娣诲姞瀹炴椂鏁版嵁鐐
+    /// </summary>
+    private void AddRealtimePoint(PlcDataModel data)
     {
         try
         {
             var now = DateTime.Now;
-
-            // 鏇存柊杩涙按娴侀噺鍥捐〃
-            if (InflowSeries?.Length > 0 && InflowSeries[0] is ColumnSeries<DateTimePoint> inflowSeries)
-            {
-                var values = inflowSeries.Values?.ToList() ?? new List<DateTimePoint>();
-                values.Add(new DateTimePoint(now, data.InflowRate));
-
-                // 淇濈暀鏈杩 60 涓暟鎹偣
-                if (values.Count > 60)
-                    values.RemoveAt(0);
-
-                inflowSeries.Values = values.ToArray();
-            }
-
-            // 鏇存柊鍑烘按娴侀噺鍥捐〃
-            if (OutflowSeries?.Length > 0 && OutflowSeries[0] is ColumnSeries<DateTimePoint> outflowSeries)
-            {
-                var values = outflowSeries.Values?.ToList() ?? new List<DateTimePoint>();
-                values.Add(new DateTimePoint(now, data.OutflowRate));
-
-                if (values.Count > 60)
-                    values.RemoveAt(0);
-
-                outflowSeries.Values = values.ToArray();
-            }
+            AddPointToSeries(InflowSeries, now, data.InflowRate);
+            AddPointToSeries(OutflowSeries, now, data.OutflowRate);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "鏇存柊鍥捐〃鏁版嵁澶辫触");
+            Log.Error(ex, "鏇存柊瀹炴椂鍥捐〃澶辫触");
+        }
+    }
+
+    private static void AddPointToSeries(ISeries[] series, DateTime time, float value)
+    {
+        if (series?.Length > 0 && series[0] is LineSeries<DateTimePoint> lineSeries)
+        {
+            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();
         }
     }
 
@@ -118,93 +106,136 @@ public partial class ViewCViewModel : ObservableObject
         PurgeOldText = _lang.Get("PurgeOld");
         InflowTrendText = _lang.Get("InflowTrend");
         OutflowTrendText = _lang.Get("OutflowTrend");
+        TankLevelTrendText = _lang.Get("TankLevelTrend");
+        PumpFreqTrendText = _lang.Get("PumpFreqTrend");
+        QuickRangeText = _lang.Get("QuickRange");
+        FlowTrendText = _lang.Get("FlowTrend");
+        TimeHeaderText = _lang.Get("TimeHeader");
+        InflowHeaderText = _lang.Get("InflowHeader");
+        OutflowHeaderText = _lang.Get("OutflowHeader");
+        TotalInHeaderText = _lang.Get("TotalInHeader");
+        TotalOutHeaderText = _lang.Get("TotalOutHeader");
     }
 
     /// <summary>
-    /// 鍒濆鍖栧浘琛
+    /// 鍒濆鍖鎵鏈鍥捐〃
     /// </summary>
-    private void InitializeChart()
+    private void InitializeCharts()
     {
-        InflowSeries = new ISeries[]
+        var timeAxis = new Axis
         {
-            new ColumnSeries<DateTimePoint>
-            {
-                Name = "杩涙按娴侀噺",
-                Values = new List<DateTimePoint>(),
-                Fill = new SolidColorPaint(SKColors.CornflowerBlue),
-                Stroke = null
-            }
+            Labeler = value => new DateTime((long)value).ToString("MM-dd HH:mm"),
+            UnitWidth = TimeSpan.FromHours(1).Ticks,
+            LabelsRotation = 45
         };
 
-        OutflowSeries = new ISeries[]
+        // 娴侀噺鍥捐〃
+        InflowSeries = new ISeries[] { CreateLineSeries(_lang.Get("InflowSeries"), SKColors.CornflowerBlue) };
+        OutflowSeries = new ISeries[] { CreateLineSeries(_lang.Get("OutflowSeries"), SKColors.MediumSeaGreen) };
+        XAxes = new[] { timeAxis };
+        YAxes = new[] { new Axis { Name = "m鲁/h", MinLimit = 0 } };
+
+        // 娑蹭綅鍥捐〃
+        TankLevelSeries = new ISeries[]
         {
-            new ColumnSeries<DateTimePoint>
-            {
-                Name = "鍑烘按娴侀噺",
-                Values = new List<DateTimePoint>(),
-                Fill = new SolidColorPaint(SKColors.MediumSeaGreen),
-                Stroke = null
-            }
+            CreateLineSeries(_lang.Get("Tank1Name"), SKColors.CornflowerBlue),
+            CreateLineSeries(_lang.Get("Tank2Name"), SKColors.MediumSeaGreen),
+            CreateLineSeries(_lang.Get("Tank3Name"), SKColors.Orange),
+            CreateLineSeries(_lang.Get("Tank4Name"), SKColors.MediumPurple),
         };
+        TankYAxes = new[] { new Axis { Name = "%", MinLimit = 0, MaxLimit = 100 } };
 
-        XAxes = new Axis[]
+        // 娉甸鐜囧浘琛
+        PumpFreqSeries = new ISeries[]
         {
-            new Axis
-            {
-                Labeler = value => new DateTime((long)value).ToString("MM-dd"),
-                UnitWidth = TimeSpan.FromDays(1).Ticks,
-                LabelsRotation = 45
-            }
+            CreateLineSeries(_lang.Get("Pump1Name"), SKColors.CornflowerBlue),
+            CreateLineSeries(_lang.Get("Pump2Name"), SKColors.MediumSeaGreen),
+            CreateLineSeries(_lang.Get("Pump3Name"), SKColors.Orange),
+            CreateLineSeries(_lang.Get("Pump4Name"), SKColors.MediumPurple),
+            CreateLineSeries(_lang.Get("Pump5Name"), SKColors.Crimson),
+        };
+        PumpYAxes = new[] { new Axis { Name = "Hz", MinLimit = 0 } };
+    }
+
+    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
         };
+    }
 
-        YAxes = new Axis[]
+    /// <summary>
+    /// 棰勮鏃堕棿鑼冨洿
+    /// </summary>
+    [RelayCommand]
+    private void SetTimeRange(string range)
+    {
+        EndDate = DateTimeOffset.Now;
+        StartDate = range switch
         {
-            new Axis
-            {
-                Name = "娴侀噺 (m鲁/h)",
-                MinLimit = 0
-            }
+            "1h" => DateTimeOffset.Now.AddHours(-1),
+            "4h" => DateTimeOffset.Now.AddHours(-4),
+            "8h" => DateTimeOffset.Now.AddHours(-8),
+            "24h" => DateTimeOffset.Now.AddHours(-24),
+            "7d" => DateTimeOffset.Now.AddDays(-7),
+            "30d" => DateTimeOffset.Now.AddDays(-30),
+            _ => DateTimeOffset.Now.AddDays(-1)
         };
+        _ = LoadFlowRecordsAsync();
     }
 
     /// <summary>
-    /// 鍔犺浇娴侀噺璁板綍
+    /// 鍔犺浇鍘嗗彶鏁版嵁
     /// </summary>
     [RelayCommand]
     private async Task LoadFlowRecordsAsync()
     {
         try
         {
-            var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
+            var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-1);
             var end = EndDate?.DateTime ?? DateTime.Now;
-            var records = await DatabaseService.Db.Select<FlowRecord>()
+
+            // 鍔犺浇娴侀噺璁板綍
+            var records = await DatabaseService.Db.Queryable<FlowRecord>()
                 .Where(r => r.RecordTime >= start && r.RecordTime <= end)
                 .OrderByDescending(r => r.RecordTime)
                 .ToListAsync();
-
             FlowRecords = records;
-            UpdateChart();
-            Log.Debug("鍔犺浇浜 {Count} 鏉℃祦閲忚褰", records.Count);
+
+            // 鍔犺浇妯℃嫙閲忚褰
+            var analogRecords = await DatabaseService.Db.Queryable<AnalogRecord>()
+                .Where(r => r.RecordTime >= start && r.RecordTime <= end)
+                .OrderBy(r => r.RecordTime)
+                .ToListAsync();
+
+            UpdateFlowChart(records);
+            UpdateTankLevelChart(analogRecords);
+            UpdatePumpFreqChart(analogRecords);
+
+            Log.Debug("鍔犺浇浜 {Flow} 鏉℃祦閲忚褰, {Analog} 鏉℃ā鎷熼噺璁板綍", records.Count, analogRecords.Count);
         }
         catch (Exception ex)
         {
-            Log.Error(ex, "鍔犺浇娴侀噺璁板綍澶辫触");
+            Log.Error(ex, "鍔犺浇鍘嗗彶鏁版嵁澶辫触");
         }
     }
 
-    /// <summary>
-    /// 鏇存柊鍥捐〃鏁版嵁
-    /// </summary>
-    private void UpdateChart()
+    private void UpdateFlowChart(List<FlowRecord> records)
     {
-        var inflowData = FlowRecords
-            .GroupBy(r => r.RecordTime.Date)
+        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 = FlowRecords
-            .GroupBy(r => r.RecordTime.Date)
+        var outflowData = records
+            .GroupBy(r => GetTimeGroupKey(r.RecordTime))
             .Select(g => new DateTimePoint(g.Key, g.Average(r => r.OutflowRate)))
             .OrderBy(p => p.DateTime)
             .ToList();
@@ -213,9 +244,44 @@ public partial class ViewCViewModel : ObservableObject
         OutflowSeries[0].Values = outflowData;
     }
 
+    private void UpdateTankLevelChart(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;
+        }
+    }
+
+    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++)
+        {
+            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;
+        }
+    }
+
     /// <summary>
-    /// 瀵煎嚭鏁版嵁
+    /// 鏍规嵁鏃堕棿鑼冨洿鑷姩閫夋嫨鍒嗙粍绮惧害
     /// </summary>
+    private static DateTime GetTimeGroupKey(DateTime time)
+    {
+        return time.Date.AddHours(time.Hour).AddMinutes(time.Minute / 5 * 5);
+    }
+
     [RelayCommand]
     private async Task ExportDataAsync()
     {
@@ -241,21 +307,21 @@ public partial class ViewCViewModel : ObservableObject
         }
     }
 
-    /// <summary>
-    /// 娓呴櫎鏃ф暟鎹
-    /// </summary>
     [RelayCommand]
     private async Task ClearOldDataAsync()
     {
         try
         {
             var cutoffDate = DateTime.Now.AddDays(-30);
-            await DatabaseService.Db.Delete<FlowRecord>()
+            await DatabaseService.Db.Deleteable<FlowRecord>()
+                .Where(r => r.RecordTime < cutoffDate)
+                .ExecuteCommandAsync();
+            await DatabaseService.Db.Deleteable<AnalogRecord>()
                 .Where(r => r.RecordTime < cutoffDate)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
             await LoadFlowRecordsAsync();
-            Log.Information("宸叉竻闄 30 澶╁墠鐨娴侀噺璁板綍");
+            Log.Information("宸叉竻闄 30 澶╁墠鐨鍘嗗彶鏁版嵁");
         }
         catch (Exception ex)
         {

+ 16 - 16
src/YZWater.Core/ViewModels/ViewDViewModel.cs

@@ -27,7 +27,7 @@ public partial class ViewDViewModel : ObservableObject
     private DateTimeOffset? _endDate = DateTimeOffset.Now;
 
     [ObservableProperty]
-    private string _filterType = "鍏ㄩ儴";
+    private string _filterType = "";
 
     // 璇︽儏瀛楁锛堥伩鍏 SelectedRecord 涓 null 鏃跺祵濂楃粦瀹氭姤閿欙級
     [ObservableProperty] private string _detailTime = "--";
@@ -107,10 +107,10 @@ public partial class ViewDViewModel : ObservableObject
         {
             var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
             var end = EndDate?.DateTime ?? DateTime.Now;
-            var query = DatabaseService.Db.Select<AlarmRecord>()
+            var query = DatabaseService.Db.Queryable<AlarmRecord>()
                 .Where(r => r.AlarmTime >= start && r.AlarmTime <= end);
 
-            if (FilterType != "鍏ㄩ儴")
+            if (FilterType != _lang.Get("All"))
             {
                 query = query.Where(r => r.AlarmType == FilterType);
             }
@@ -142,14 +142,14 @@ public partial class ViewDViewModel : ObservableObject
         {
             record.IsConfirmed = true;
             record.ConfirmedTime = DateTime.Now;
-            record.ConfirmedBy = "鎿嶄綔鍛"; // 瀹為檯搴斾粠鐧诲綍鐢ㄦ埛鑾峰彇
+            record.ConfirmedBy = AuthService.CurrentUser?.DisplayName ?? _lang.Get("UnknownUser");
 
-            await DatabaseService.Db.Update<AlarmRecord>()
-                .Set(r => r.IsConfirmed, true)
-                .Set(r => r.ConfirmedTime, record.ConfirmedTime)
-                .Set(r => r.ConfirmedBy, record.ConfirmedBy)
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, record.ConfirmedTime)
+                .SetColumns(r => r.ConfirmedBy, record.ConfirmedBy)
                 .Where(r => r.Id == record.Id)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
             await LoadAlarmRecordsAsync();
             Log.Information("鎶ヨ {Id} 宸茬‘璁", record.Id);
@@ -168,12 +168,12 @@ public partial class ViewDViewModel : ObservableObject
     {
         try
         {
-            await DatabaseService.Db.Update<AlarmRecord>()
-                .Set(r => r.IsConfirmed, true)
-                .Set(r => r.ConfirmedTime, DateTime.Now)
-                .Set(r => r.ConfirmedBy, "鎿嶄綔鍛")
+            await DatabaseService.Db.Updateable<AlarmRecord>()
+                .SetColumns(r => r.IsConfirmed, true)
+                .SetColumns(r => r.ConfirmedTime, DateTime.Now)
+                .SetColumns(r => r.ConfirmedBy, AuthService.CurrentUser?.DisplayName ?? _lang.Get("UnknownUser"))
                 .Where(r => !r.IsConfirmed)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
             await LoadAlarmRecordsAsync();
             Log.Information("鎵鏈夋姤璀﹀凡纭");
@@ -221,9 +221,9 @@ public partial class ViewDViewModel : ObservableObject
         try
         {
             var cutoffDate = DateTime.Now.AddDays(-90);
-            await DatabaseService.Db.Delete<AlarmRecord>()
+            await DatabaseService.Db.Deleteable<AlarmRecord>()
                 .Where(r => r.AlarmTime < cutoffDate && r.IsConfirmed)
-                .ExecuteAffrowsAsync();
+                .ExecuteCommandAsync();
 
             await LoadAlarmRecordsAsync();
             Log.Information("宸叉竻闄 90 澶╁墠鐨勫凡纭鎶ヨ璁板綍");

+ 115 - 0
src/YZWater.Core/ViewModels/ViewFViewModel.cs

@@ -0,0 +1,115 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Serilog;
+using YZWater.Core.Models;
+using YZWater.Core.Services;
+
+namespace YZWater.Core.ViewModels;
+
+/// <summary>
+/// 瀹¤鏃ュ織瑙嗗浘 ViewModel
+/// </summary>
+public partial class ViewFViewModel : ObservableObject
+{
+    [ObservableProperty]
+    private List<AuditLog> _auditLogs = new();
+
+    [ObservableProperty]
+    private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
+
+    [ObservableProperty]
+    private DateTimeOffset? _endDate = DateTimeOffset.Now;
+
+    [ObservableProperty]
+    private string _filterAction = "";
+
+    [ObservableProperty]
+    private int _totalCount;
+
+    // 缈昏瘧鏂囧瓧
+    private readonly LanguageService _lang = LanguageService.Instance;
+    [ObservableProperty] private string _titleText;
+    [ObservableProperty] private string _fromText;
+    [ObservableProperty] private string _toText;
+    [ObservableProperty] private string _queryText;
+    [ObservableProperty] private string _exportText;
+    [ObservableProperty] private string _totalText;
+    [ObservableProperty] private string _recordsText;
+    [ObservableProperty] private string _timeHeaderText;
+    [ObservableProperty] private string _userHeaderText;
+    [ObservableProperty] private string _actionHeaderText;
+    [ObservableProperty] private string _detailHeaderText;
+    [ObservableProperty] private string _targetHeaderText;
+    [ObservableProperty] private string _resultHeaderText;
+
+    public ViewFViewModel()
+    {
+        UpdateTexts();
+        _lang.LanguageChanged += UpdateTexts;
+        _ = LoadLogsAsync();
+    }
+
+    private void UpdateTexts()
+    {
+        TitleText = _lang.Get("AuditLog");
+        FromText = _lang.Get("From");
+        ToText = _lang.Get("To");
+        QueryText = _lang.Get("Query");
+        ExportText = _lang.Get("Export");
+        TotalText = _lang.Get("Total");
+        RecordsText = _lang.Get("Records");
+        TimeHeaderText = _lang.Get("TimeHeader");
+        UserHeaderText = _lang.Get("User");
+        ActionHeaderText = _lang.Get("Action");
+        DetailHeaderText = _lang.Get("Detail");
+        TargetHeaderText = _lang.Get("Target");
+        ResultHeaderText = _lang.Get("Result");
+        ExportText = _lang.Get("Export");
+    }
+
+    [RelayCommand]
+    private async Task LoadLogsAsync()
+    {
+        try
+        {
+            var start = StartDate?.DateTime ?? DateTime.Now.AddDays(-7);
+            var end = EndDate?.DateTime ?? DateTime.Now;
+
+            var action = FilterAction == _lang.Get("All") ? null : FilterAction;
+            var logs = await AuditService.QueryAsync(start, end, action: action);
+
+            AuditLogs = logs;
+            TotalCount = logs.Count;
+            Log.Debug("鍔犺浇浜 {Count} 鏉″璁℃棩蹇", logs.Count);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "鍔犺浇瀹¤鏃ュ織澶辫触");
+        }
+    }
+
+    [RelayCommand]
+    private async Task ExportAsync()
+    {
+        try
+        {
+            var filePath = $"AuditLogs_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
+            var lines = new List<string>
+            {
+                "鏃堕棿,鐢ㄦ埛,鎿嶄綔,璇︽儏,鐩爣,缁撴灉"
+            };
+
+            foreach (var log in AuditLogs)
+            {
+                lines.Add($"{log.Timestamp:yyyy-MM-dd HH:mm:ss},{log.UserName},{log.Action},{log.Detail},{log.Target},{log.Result}");
+            }
+
+            await File.WriteAllLinesAsync(filePath, lines);
+            Log.Information("瀹¤鏃ュ織宸插鍑哄埌: {FilePath}", filePath);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "瀵煎嚭瀹¤鏃ュ織澶辫触");
+        }
+    }
+}

+ 9 - 11
src/YZWater.Core/YZWater.Core.csproj

@@ -8,17 +8,15 @@
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
-    <PackageReference Include="FreeSql.Provider.Sqlite" Version="3.2.800" />
-    <PackageReference Include="FreeSql.Repository" Version="3.2.800" />
-    <PackageReference Include="HslCommunication" Version="12.0.1" />
-    <PackageReference Include="LiveChartsCore" Version="2.0.0-rc4.5" />
-    <PackageReference Include="LiveChartsCore.SkiaSharpView" Version="2.0.0-rc4.5" />
-    <PackageReference Include="NLog" Version="5.3.4" />
-    <PackageReference Include="Serilog" Version="4.0.2" />
-    <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
-    <PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
-    <PackageReference Include="SkiaSharp" Version="2.88.9" />
+    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
+    <PackageReference Include="SqlSugarCore" Version="5.1.4.214" />
+    <PackageReference Include="HslCommunication" Version="12.8.3" />
+    <PackageReference Include="LiveChartsCore" Version="2.1.0-dev-798" />
+    <PackageReference Include="LiveChartsCore.SkiaSharpView" Version="2.1.0-dev-798" />
+    <PackageReference Include="Serilog" Version="4.3.1" />
+    <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
+    <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
+    <PackageReference Include="SkiaSharp" Version="3.119.4" />
   </ItemGroup>
 
 </Project>