磊 曹 hace 4 meses
padre
commit
1ff762f262
Se han modificado 56 ficheros con 4501 adiciones y 516 borrados
  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
+```
+
+## 项目结构
+
+两个项目,Avalonia 依赖 Core:
+
+```
+YZWater3.sln
+src/
+├── YZWater.Core/           # 核心业务逻辑(UI 无关)
+│   ├── Models/             # 数据模型(SqlSugar ORM)
+│   ├── Services/           # 所有业务服务(单例模式为主)
+│   ├── ViewModels/         # CommunityToolkit.Mvvm ViewModel
+│   └── Utils/              # WinSize、ResolutionManager
+│
+└── YZWater.Avalonia/       # UI 层
+    ├── Views/              # AXAML 视图(ViewA~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`(HslCommunication 西门子 S7)、`ConfigService`(JSON 配置)、`DatabaseService`(SqlSugar + SQLite)、`AuthService`、`MockPlcService`
+- **HubServer / HubClient**:WebSocket 中控通信,支持操作锁、远程命令(`start_pump`/`stop_pump`)
+- **PlcConfig**:所有 PLC 地址映射集中管理,支持配置文件覆盖
+
+### 启动流程(App.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]`(CommunityToolkit.Mvvm 源生成器)
+- View 和 ViewModel 在 `App.axaml.cs` 或 `MainViewModel` 中通过 `DataContext` 绑定
+- `MainViewModel` 持有所有子 ViewModel 实例,通过 `CurrentView` 属性切换 Tab
+
+### 数据库
+
+- ORM:SqlSugar(不是 README 中提到的 FreeSql,已迁移)
+- 文件:`yzwater.db`(SQLite)
+- 初始化:`DatabaseService.Initialize()` 中逐个建表,单表失败不影响其他表
+- 表:`users`, `alarm_records`, `flow_records`, `analog_records`, `audit_logs`, `persons`, `equipment_statuses`
+
+### 认证
+
+`AuthService` 管理登录状态,支持 4 种角色(Viewer / Operator / Engineer / Admin)。首次启动自动创建默认 admin 账户(admin/admin123)。登出时通过 `UserLoggedOut` 事件触发 `StopServices()` 并重新显示登录窗口。
+
+### 自适应布局
+
+`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`(Dictionary<string, string>,key 为 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>
+    /// 操作类型(Login/Logout/Control/Config/AlarmAck)
+    /// </summary>
+    public string Action { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 操作详情
+    /// </summary>
+    public string Detail { get; set; } = string.Empty;
+
+    /// <summary>
+    /// 操作目标(设备ID、参数名等)
+    /// </summary>
+    [SugarColumn(IsNullable = true)]
+    public string? Target { get; set; }
+
+    /// <summary>
+    /// 操作结果(Success/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>
+    /// 运行模式:Direct/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 地址覆盖(key=标签名如 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>
+    /// 密码哈希(BCrypt)
+    /// </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>
+    /// 密码哈希(SHA256 + 随机盐,格式: 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>
+    /// 是否正在运行
+    /// </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 模式(替代真实 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;
+        }
+
+        // 解析水箱液位 (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);
+
+        // 解析流量 (VD200-VD204, 2 floats)
+        var inflow = PlcService.ParseFloat(flowData, 0);
+        var outflow = PlcService.ParseFloat(flowData, 4);
+
+        // 解析泵频率 (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);
+
+        // 解析 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);
+
+        // 解析 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);
+
+        // 解析 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>
+    /// 生成日报(HTML 格式)
+    /// </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年MM月dd日}</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 操作代理 ───
     [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>

BIN
xxx.xlsx