Prechádzať zdrojové kódy

阶段1: 创建PLC轮询/数据日志/报警服务 - 数据层打通

磊 曹 3 mesiacov pred
rodič
commit
bde702057a

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

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewAView : UserControl
     public ViewAView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewBView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewBView : UserControl
     public ViewBView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

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

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewCView : UserControl
     public ViewCView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

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

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewDView : UserControl
     public ViewDView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

+ 21 - 0
src/YZWater.Avalonia/Views/ViewEView.axaml.cs

@@ -1,4 +1,6 @@
 using Avalonia.Controls;
+using Avalonia.VisualTree;
+using YZWater.Avalonia.Controls;
 
 namespace YZWater.Avalonia.Views;
 
@@ -7,5 +9,24 @@ public partial class ViewEView : UserControl
     public ViewEView()
     {
         InitializeComponent();
+        AttachedToVisualTree += (_, _) =>
+        {
+            foreach (var v in this.GetVisualDescendants())
+            {
+                if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+                else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+            }
+            ThemeHelper.ThemeChanged += ApplyTheme;
+        };
+        DetachedFromVisualTree += (_, _) => ThemeHelper.ThemeChanged -= ApplyTheme;
+    }
+
+    private void ApplyTheme()
+    {
+        foreach (var v in this.GetVisualDescendants())
+        {
+            if (v is Border b && b.Name == "TitleBar") b.Background = ThemeHelper.HeaderBg;
+            else if (v is Border b2 && b2.Name == "StatusBar") b2.Background = ThemeHelper.NavBg;
+        }
     }
 }

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

@@ -0,0 +1,147 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace YZWater.Core.Models;
+
+/// <summary>
+/// PLC 数据模型 - 内存数据快照
+/// </summary>
+public partial class PlcDataModel : ObservableObject
+{
+    // ═══════════════════════════════════════════════
+    //  水箱液位
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private float _tank1Level;
+    [ObservableProperty] private float _tank2Level;
+    [ObservableProperty] private float _tank3Level;
+    [ObservableProperty] private float _tank4Level;
+
+    // ═══════════════════════════════════════════════
+    //  流量
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private float _inflowRate;
+    [ObservableProperty] private float _outflowRate;
+    [ObservableProperty] private float _flowDelta;
+
+    // ═══════════════════════════════════════════════
+    //  泵状态 (0-4)
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private bool _pump1Running;
+    [ObservableProperty] private bool _pump2Running;
+    [ObservableProperty] private bool _pump3Running;
+    [ObservableProperty] private bool _pump4Running;
+    [ObservableProperty] private bool _pump5Running;
+
+    // 泵故障
+    [ObservableProperty] private bool _pump1Fault;
+    [ObservableProperty] private bool _pump2Fault;
+    [ObservableProperty] private bool _pump3Fault;
+    [ObservableProperty] private bool _pump4Fault;
+    [ObservableProperty] private bool _pump5Fault;
+
+    // 泵频率
+    [ObservableProperty] private float _pump1Freq;
+    [ObservableProperty] private float _pump2Freq;
+    [ObservableProperty] private float _pump3Freq;
+    [ObservableProperty] private float _pump4Freq;
+    [ObservableProperty] private float _pump5Freq;
+
+    // 泵电流
+    [ObservableProperty] private float _pump1Current;
+    [ObservableProperty] private float _pump2Current;
+    [ObservableProperty] private float _pump3Current;
+    [ObservableProperty] private float _pump4Current;
+    [ObservableProperty] private float _pump5Current;
+
+    // ═══════════════════════════════════════════════
+    //  风机状态
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private bool _fan1Running;
+    [ObservableProperty] private bool _fan2Running;
+
+    [ObservableProperty] private bool _fan1Fault;
+    [ObservableProperty] private bool _fan2Fault;
+
+    // ═══════════════════════════════════════════════
+    //  阀门位置 (0-3, 0=100=全关, 100=全开)
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private int _valve1Position;
+    [ObservableProperty] private int _valve2Position;
+    [ObservableProperty] private int _valve3Position;
+    [ObservableProperty] private int _valve4Position;
+
+    // ═══════════════════════════════════════════════
+    //  系统状态
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private bool _isAutoMode;
+
+    // ═══════════════════════════════════════════════
+    //  数据质量
+    // ═══════════════════════════════════════════════
+    [ObservableProperty] private DateTime _lastReadTime;
+    [ObservableProperty] private bool _isDataValid;
+    [ObservableProperty] private bool _isPlcConnected;
+
+    // ═══════════════════════════════════════════════
+    //  计算属性
+    // ═══════════════════════════════════════════════
+    public int RunningPumpCount => (Pump1Running ? 1 : 0) + (Pump2Running ? 1 : 0) +
+                                   (Pump3Running ? 1 : 0) + (Pump4Running ? 1 : 0) +
+                                   (Pump5Running ? 1 : 0);
+
+    public int RunningFanCount => (Fan1Running ? 1 : 0) + (Fan2Running ? 1 : 0);
+
+    public bool HasAnyFault => Pump1Fault || Pump2Fault || Pump3Fault || Pump4Fault || Pump5Fault ||
+                               Fan1Fault || Fan2Fault;
+
+    /// <summary>
+    /// 获取泵运行状态
+    /// </summary>
+    public bool GetPumpRunning(int index) => index switch
+    {
+        0 => Pump1Running,
+        1 => Pump2Running,
+        2 => Pump3Running,
+        3 => Pump4Running,
+        4 => Pump5Running,
+        _ => false
+    };
+
+    /// <summary>
+    /// 获取泵故障状态
+    /// </summary>
+    public bool GetPumpFault(int index) => index switch
+    {
+        0 => Pump1Fault,
+        1 => Pump2Fault,
+        2 => Pump3Fault,
+        3 => Pump4Fault,
+        4 => Pump5Fault,
+        _ => false
+    };
+
+    /// <summary>
+    /// 获取泵频率
+    /// </summary>
+    public float GetPumpFreq(int index) => index switch
+    {
+        0 => Pump1Freq,
+        1 => Pump2Freq,
+        2 => Pump3Freq,
+        3 => Pump4Freq,
+        4 => Pump5Freq,
+        _ => 0
+    };
+
+    /// <summary>
+    /// 获取泵电流
+    /// </summary>
+    public float GetPumpCurrent(int index) => index switch
+    {
+        0 => Pump1Current,
+        1 => Pump2Current,
+        2 => Pump3Current,
+        3 => Pump4Current,
+        4 => Pump5Current,
+        _ => 0
+    };
+}

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

@@ -0,0 +1,361 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 报警服务 - 报警检测、持久化、确认、延迟
+/// </summary>
+public partial class AlarmService : ObservableObject, IDisposable
+{
+    private readonly PlcPollingService _pollingService;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+
+    // ─── 报警配置 ───
+    public float LevelHighAlarm { get; set; } = 80f;
+    public float LevelLowAlarm { get; set; } = 20f;
+    public float FlowHighAlarm { get; set; } = 100f;
+    public float FlowLowAlarm { get; set; } = 5f;
+
+    /// <summary>
+    /// 报警延迟(秒)- 持续超过此时间才触发
+    /// </summary>
+    public int AlarmDelaySeconds { get; set; } = 3;
+
+    /// <summary>
+    /// 报警死区(防止频繁触发)
+    /// </summary>
+    public float AlarmDeadband { get; set; } = 2f;
+
+    // ─── 状态 ───
+    [ObservableProperty] private bool _hasActiveAlarm;
+    [ObservableProperty] private string _activeAlarmMessage = string.Empty;
+    [ObservableProperty] private int _activeAlarmCount;
+
+    // ─── 事件 ───
+    public event Action<AlarmRecord>? AlarmRaised;
+    public event Action<AlarmRecord>? AlarmAcknowledged;
+    public event Action? AlarmStateChanged;
+
+    // ─── 报警跟踪 ───
+    private readonly Dictionary<string, DateTime> _alarmStartTimes = new();
+    private readonly HashSet<string> _activeAlarms = new();
+
+    public AlarmService(PlcPollingService pollingService)
+    {
+        _pollingService = pollingService;
+        _pollingService.DataUpdated += OnDataUpdated;
+    }
+
+    /// <summary>
+    /// 启动报警监控
+    /// </summary>
+    public void Start()
+    {
+        _cts = new CancellationTokenSource();
+        Log.Information("报警服务已启动");
+    }
+
+    /// <summary>
+    /// 停止报警监控
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        Log.Information("报警服务已停止");
+    }
+
+    /// <summary>
+    /// 数据更新回调
+    /// </summary>
+    private void OnDataUpdated(PlcDataModel data)
+    {
+        CheckAlarms(data);
+    }
+
+    /// <summary>
+    /// 检查所有报警条件
+    /// </summary>
+    private void CheckAlarms(PlcDataModel data)
+    {
+        var now = DateTime.Now;
+
+        // 检查水箱液位
+        CheckLevelAlarm("Tank1", data.Tank1Level, now);
+        CheckLevelAlarm("Tank2", data.Tank2Level, now);
+        CheckLevelAlarm("Tank3", data.Tank3Level, now);
+        CheckLevelAlarm("Tank4", data.Tank4Level, now);
+
+        // 检查流量
+        CheckFlowAlarm("Inflow", data.InflowRate, now);
+
+        // 检查泵故障
+        CheckPumpFault("Pump1", data.Pump1Fault, now);
+        CheckPumpFault("Pump2", data.Pump2Fault, now);
+        CheckPumpFault("Pump3", data.Pump3Fault, now);
+        CheckPumpFault("Pump4", data.Pump4Fault, now);
+        CheckPumpFault("Pump5", data.Pump5Fault, now);
+
+        // 检查风机故障
+        CheckFanFault("Fan1", data.Fan1Fault, now);
+        CheckFanFault("Fan2", data.Fan2Fault, now);
+
+        // 检查 PLC 通信
+        if (!data.IsPlcConnected)
+        {
+            CheckCondition("PLC_Connection", "PLC通信丢失", 1, now);
+        }
+        else
+        {
+            ClearCondition("PLC_Connection");
+        }
+
+        // 更新状态
+        UpdateAlarmState();
+    }
+
+    /// <summary>
+    /// 检查液位报警
+    /// </summary>
+    private void CheckLevelAlarm(string tankId, float level, DateTime now)
+    {
+        if (level > LevelHighAlarm + AlarmDeadband)
+        {
+            CheckCondition($"{tankId}_High", $"{tankId} 液位过高 ({level:F1}%)", 2, now);
+        }
+        else if (level < LevelLowAlarm - AlarmDeadband)
+        {
+            CheckCondition($"{tankId}_Low", $"{tankId} 液位过低 ({level:F1}%)", 2, now);
+        }
+        else
+        {
+            ClearCondition($"{tankId}_High");
+            ClearCondition($"{tankId}_Low");
+        }
+    }
+
+    /// <summary>
+    /// 检查流量报警
+    /// </summary>
+    private void CheckFlowAlarm(string flowId, float rate, DateTime now)
+    {
+        if (rate > FlowHighAlarm)
+        {
+            CheckCondition($"{flowId}_High", $"{flowId} 流量过高 ({rate:F1} m³/h)", 2, now);
+        }
+        else if (rate < FlowLowAlarm && rate > 0) // 0 表示无数据,不报警
+        {
+            CheckCondition($"{flowId}_Low", $"{flowId} 流量过低 ({rate:F1} m³/h)", 2, now);
+        }
+        else
+        {
+            ClearCondition($"{flowId}_High");
+            ClearCondition($"{flowId}_Low");
+        }
+    }
+
+    /// <summary>
+    /// 检查泵故障
+    /// </summary>
+    private void CheckPumpFault(string pumpId, bool hasFault, DateTime now)
+    {
+        if (hasFault)
+        {
+            CheckCondition($"{pumpId}_Fault", $"{pumpId} 故障", 3, now);
+        }
+        else
+        {
+            ClearCondition($"{pumpId}_Fault");
+        }
+    }
+
+    /// <summary>
+    /// 检查风机故障
+    /// </summary>
+    private void CheckFanFault(string fanId, bool hasFault, DateTime now)
+    {
+        if (hasFault)
+        {
+            CheckCondition($"{fanId}_Fault", $"{fanId} 故障", 3, now);
+        }
+        else
+        {
+            ClearCondition($"{fanId}_Fault");
+        }
+    }
+
+    /// <summary>
+    /// 检查报警条件(带延迟)
+    /// </summary>
+    private void CheckCondition(string conditionId, string message, int level, DateTime now)
+    {
+        if (_activeAlarms.Contains(conditionId))
+            return; // 已经在报警中
+
+        if (!_alarmStartTimes.ContainsKey(conditionId))
+        {
+            _alarmStartTimes[conditionId] = now;
+            return; // 记录开始时间
+        }
+
+        // 检查延迟
+        if ((now - _alarmStartTimes[conditionId]).TotalSeconds >= AlarmDelaySeconds)
+        {
+            TriggerAlarm(conditionId, message, level);
+        }
+    }
+
+    /// <summary>
+    /// 清除报警条件
+    /// </summary>
+    private void ClearCondition(string conditionId)
+    {
+        _alarmStartTimes.Remove(conditionId);
+        _activeAlarms.Remove(conditionId);
+    }
+
+    /// <summary>
+    /// 触发报警
+    /// </summary>
+    private void TriggerAlarm(string conditionId, string message, int level)
+    {
+        _activeAlarms.Add(conditionId);
+        _alarmStartTimes.Remove(conditionId);
+
+        var alarm = new AlarmRecord
+        {
+            AlarmTime = DateTime.Now,
+            AlarmType = conditionId.Split('_')[0],
+            AlarmMessage = message,
+            AlarmLevel = level,
+            IsConfirmed = false
+        };
+
+        // 持久化到数据库
+        _ = SaveAlarmAsync(alarm);
+
+        // 通知
+        AlarmRaised?.Invoke(alarm);
+        UpdateAlarmState();
+
+        Log.Warning("报警触发: {Message} (级别 {Level})", message, level);
+    }
+
+    /// <summary>
+    /// 保存报警到数据库
+    /// </summary>
+    private async Task SaveAlarmAsync(AlarmRecord alarm)
+    {
+        try
+        {
+            await DatabaseService.Db.Insert(alarm).ExecuteAffrowsAsync();
+            Log.Debug("报警记录已保存: {Id}", alarm.Id);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "保存报警记录失败");
+        }
+    }
+
+    /// <summary>
+    /// 确认报警
+    /// </summary>
+    public async Task AcknowledgeAlarmAsync(int alarmId, string operatorName = "操作员")
+    {
+        try
+        {
+            await DatabaseService.Db.Update<AlarmRecord>()
+                .Set(r => r.IsConfirmed, true)
+                .Set(r => r.ConfirmedTime, DateTime.Now)
+                .Set(r => r.ConfirmedBy, operatorName)
+                .Where(r => r.Id == alarmId)
+                .ExecuteAffrowsAsync();
+
+            var alarm = await DatabaseService.Db.Select<AlarmRecord>()
+                .Where(r => r.Id == alarmId)
+                .FirstAsync();
+
+            if (alarm != null)
+            {
+                AlarmAcknowledged?.Invoke(alarm);
+                UpdateAlarmState();
+            }
+
+            Log.Information("报警 {Id} 已确认", alarmId);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "确认报警失败");
+        }
+    }
+
+    /// <summary>
+    /// 批量确认所有未确认报警
+    /// </summary>
+    public async Task AcknowledgeAllAsync(string operatorName = "操作员")
+    {
+        try
+        {
+            await DatabaseService.Db.Update<AlarmRecord>()
+                .Set(r => r.IsConfirmed, true)
+                .Set(r => r.ConfirmedTime, DateTime.Now)
+                .Set(r => r.ConfirmedBy, operatorName)
+                .Where(r => !r.IsConfirmed)
+                .ExecuteAffrowsAsync();
+
+            UpdateAlarmState();
+            Log.Information("所有报警已确认");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "批量确认报警失败");
+        }
+    }
+
+    /// <summary>
+    /// 更新报警状态
+    /// </summary>
+    private void UpdateAlarmState()
+    {
+        HasActiveAlarm = _activeAlarms.Count > 0;
+        ActiveAlarmCount = _activeAlarms.Count;
+        ActiveAlarmMessage = _activeAlarms.Count > 0
+            ? string.Join("; ", _activeAlarms.Take(3))
+            : string.Empty;
+
+        AlarmStateChanged?.Invoke();
+    }
+
+    /// <summary>
+    /// 从数据库加载未确认报警
+    /// </summary>
+    public async Task<List<AlarmRecord>> GetUnacknowledgedAlarmsAsync()
+    {
+        return await DatabaseService.Db.Select<AlarmRecord>()
+            .Where(r => !r.IsConfirmed)
+            .OrderByDescending(r => r.AlarmTime)
+            .ToListAsync();
+    }
+
+    /// <summary>
+    /// 获取报警历史
+    /// </summary>
+    public async Task<List<AlarmRecord>> GetAlarmHistoryAsync(DateTime start, DateTime end)
+    {
+        return await DatabaseService.Db.Select<AlarmRecord>()
+            .Where(r => r.AlarmTime >= start && r.AlarmTime <= end)
+            .OrderByDescending(r => r.AlarmTime)
+            .ToListAsync();
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        _pollingService.DataUpdated -= OnDataUpdated;
+        Stop();
+        Log.Information("报警服务已释放");
+    }
+}

+ 218 - 0
src/YZWater.Core/Services/DataLoggingService.cs

@@ -0,0 +1,218 @@
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// 数据日志服务 - 将 PLC 数据写入数据库
+/// </summary>
+public class DataLoggingService : IDisposable
+{
+    private readonly PlcPollingService _pollingService;
+    private CancellationTokenSource? _cts;
+    private bool _disposed;
+
+    /// <summary>
+    /// 流量记录写入间隔(秒)
+    /// </summary>
+    public int FlowLogIntervalSeconds { get; set; } = 60;
+
+    /// <summary>
+    /// 设备状态写入间隔(秒)
+    /// </summary>
+    public int DeviceStatusLogIntervalSeconds { get; set; } = 300;
+
+    /// <summary>
+    /// 数据保留天数
+    /// </summary>
+    public int DataRetentionDays { get; set; } = 30;
+
+    public DataLoggingService(PlcPollingService pollingService)
+    {
+        _pollingService = pollingService;
+    }
+
+    /// <summary>
+    /// 启动数据记录
+    /// </summary>
+    public void Start()
+    {
+        _cts = new CancellationTokenSource();
+        Task.Run(() => LoggingLoopAsync(_cts.Token));
+        Log.Information("数据日志服务已启动");
+    }
+
+    /// <summary>
+    /// 停止数据记录
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        Log.Information("数据日志服务已停止");
+    }
+
+    /// <summary>
+    /// 记录循环
+    /// </summary>
+    private async Task LoggingLoopAsync(CancellationToken ct)
+    {
+        var flowTimer = 0;
+        var deviceTimer = 0;
+        var cleanupTimer = 0;
+
+        while (!ct.IsCancellationRequested)
+        {
+            try
+            {
+                await Task.Delay(1000, ct);
+
+                flowTimer++;
+                deviceTimer++;
+                cleanupTimer++;
+
+                // 定期记录流量数据
+                if (flowTimer >= FlowLogIntervalSeconds)
+                {
+                    flowTimer = 0;
+                    await LogFlowDataAsync();
+                }
+
+                // 定期记录设备状态
+                if (deviceTimer >= DeviceStatusLogIntervalSeconds)
+                {
+                    deviceTimer = 0;
+                    await LogDeviceStatusAsync();
+                }
+
+                // 每天清理一次过期数据
+                if (cleanupTimer >= 86400)
+                {
+                    cleanupTimer = 0;
+                    await CleanupOldDataAsync();
+                }
+            }
+            catch (OperationCanceledException)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "数据记录异常");
+                await Task.Delay(5000, ct);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 记录流量数据
+    /// </summary>
+    private async Task LogFlowDataAsync()
+    {
+        try
+        {
+            var data = _pollingService.Data;
+            var record = new FlowRecord
+            {
+                RecordTime = DateTime.Now,
+                InflowRate = data.InflowRate,
+                OutflowRate = data.OutflowRate,
+                TotalInflow = 0, // TODO: 需要累计计算
+                TotalOutflow = 0
+            };
+
+            await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
+            Log.Debug("流量数据已记录: 进水={Inflow:F1}, 出水={Outflow:F1}",
+                record.InflowRate, record.OutflowRate);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "记录流量数据失败");
+        }
+    }
+
+    /// <summary>
+    /// 记录设备状态
+    /// </summary>
+    private async Task LogDeviceStatusAsync()
+    {
+        try
+        {
+            var data = _pollingService.Data;
+            var now = DateTime.Now;
+
+            // 记录泵状态
+            for (int i = 0; i < 5; i++)
+            {
+                var status = new EquipmentStatus
+                {
+                    DeviceId = $"P{i + 1:D3}",
+                    DeviceName = $"泵{i + 1}",
+                    Status = data.GetPumpRunning(i) ? DeviceStatus.Running : DeviceStatus.Stopped,
+                    IsOnline = !data.GetPumpFault(i),
+                    RunningHours = 0, // TODO: 需要累计
+                    LastUpdateTime = now,
+                    Value = data.GetPumpFreq(i),
+                    Unit = "Hz"
+                };
+                // TODO: 持久化设备状态
+            }
+
+            Log.Debug("设备状态已记录");
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "记录设备状态失败");
+        }
+    }
+
+    /// <summary>
+    /// 清理过期数据
+    /// </summary>
+    private async Task CleanupOldDataAsync()
+    {
+        try
+        {
+            var cutoff = DateTime.Now.AddDays(-DataRetentionDays);
+
+            var flowDeleted = await DatabaseService.Db.Delete<FlowRecord>()
+                .Where(r => r.RecordTime < cutoff)
+                .ExecuteAffrowsAsync();
+
+            var alarmDeleted = await DatabaseService.Db.Delete<AlarmRecord>()
+                .Where(r => r.AlarmTime < cutoff && r.IsConfirmed)
+                .ExecuteAffrowsAsync();
+
+            Log.Information("数据清理完成: 删除 {Flow} 条流量记录, {Alarm} 条报警记录",
+                flowDeleted, alarmDeleted);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "清理过期数据失败");
+        }
+    }
+
+    /// <summary>
+    /// 手动写入一条流量记录
+    /// </summary>
+    public async Task ManualLogFlowAsync(float inflow, float outflow)
+    {
+        var record = new FlowRecord
+        {
+            RecordTime = DateTime.Now,
+            InflowRate = inflow,
+            OutflowRate = outflow,
+            TotalInflow = 0,
+            TotalOutflow = 0,
+            Remark = "手动记录"
+        };
+        await DatabaseService.Db.Insert(record).ExecuteAffrowsAsync();
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+        Log.Information("数据日志服务已释放");
+    }
+}

+ 170 - 0
src/YZWater.Core/Services/PlcConfig.cs

@@ -0,0 +1,170 @@
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// PLC 地址配置 - 定义所有 PLC 读写地址映射
+/// </summary>
+public static class PlcConfig
+{
+    // ═══════════════════════════════════════════════════════════════
+    //  读取地址 (Read)
+    // ═══════════════════════════════════════════════════════════════
+
+    // ─── 水箱液位 (float) ───
+    public const string Tank1Level = "VD100";
+    public const string Tank2Level = "VD104";
+    public const string Tank3Level = "VD108";
+    public const string Tank4Level = "VD112";
+
+    // ─── 流量 (float) ───
+    public const string InflowRate = "VD200";
+    public const string OutflowRate = "VD204";
+
+    // ─── 泵状态 (bool) ───
+    public const string Pump1Run = "Q0.0";
+    public const string Pump2Run = "Q0.1";
+    public const string Pump3Run = "Q0.2";
+    public const string Pump4Run = "Q0.3";
+    public const string Pump5Run = "Q0.4";
+
+    // ─── 泵故障 (bool) ───
+    public const string Pump1Fault = "I0.0";
+    public const string Pump2Fault = "I0.1";
+    public const string Pump3Fault = "I0.2";
+    public const string Pump4Fault = "I0.3";
+    public const string Pump5Fault = "I0.4";
+
+    // ─── 泵频率反馈 (float) ───
+    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";
+
+    // ─── 泵电流 (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";
+
+    // ─── 风机状态 (bool) ───
+    public const string Fan1Run = "Q0.5";
+    public const string Fan2Run = "Q0.6";
+
+    // ─── 风机故障 (bool) ───
+    public const string Fan1Fault = "I0.5";
+    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 const string SystemMode = "M0.0"; // true=自动, false=手动
+
+    // ═══════════════════════════════════════════════════════════════
+    //  写入地址 (Write)
+    // ═══════════════════════════════════════════════════════════════
+
+    // ─── 泵启停控制 (bool) ───
+    public const string Pump1Start = "Q0.0";
+    public const string Pump2Start = "Q0.1";
+    public const string Pump3Start = "Q0.2";
+    public const string Pump4Start = "Q0.3";
+    public const string Pump5Start = "Q0.4";
+
+    // ─── 风机启停控制 (bool) ───
+    public const string Fan1Start = "Q0.5";
+    public const string Fan2Start = "Q0.6";
+
+    // ─── 阀门控制 (bool) ───
+    public const string Valve1Control = "Q1.0";
+    public const string Valve2Control = "Q1.1";
+    public const string Valve3Control = "Q1.2";
+    public const string Valve4Control = "Q1.3";
+
+    // ─── 泵频率设定 (float) ───
+    public const string PumpFreqSetpoint = "VD600";
+
+    // ═══════════════════════════════════════════════════════════════
+    //  辅助方法
+    // ═══════════════════════════════════════════════════════════════
+
+    /// <summary>
+    /// 获取泵运行地址
+    /// </summary>
+    public static string GetPumpRunAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Run,
+        1 => Pump2Run,
+        2 => Pump3Run,
+        3 => Pump4Run,
+        4 => Pump5Run,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 获取泵故障地址
+    /// </summary>
+    public static string GetPumpFaultAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Fault,
+        1 => Pump2Fault,
+        2 => Pump3Fault,
+        3 => Pump4Fault,
+        4 => Pump5Fault,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 获取泵频率地址
+    /// </summary>
+    public static string GetPumpFreqAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Freq,
+        1 => Pump2Freq,
+        2 => Pump3Freq,
+        3 => Pump4Freq,
+        4 => Pump5Freq,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 获取泵电流地址
+    /// </summary>
+    public static string GetPumpCurrentAddress(int pumpIndex) => pumpIndex switch
+    {
+        0 => Pump1Current,
+        1 => Pump2Current,
+        2 => Pump3Current,
+        3 => Pump4Current,
+        4 => Pump5Current,
+        _ => throw new ArgumentOutOfRangeException(nameof(pumpIndex))
+    };
+
+    /// <summary>
+    /// 获取阀门控制地址
+    /// </summary>
+    public static string GetValveControlAddress(int valveIndex) => valveIndex switch
+    {
+        0 => Valve1Control,
+        1 => Valve2Control,
+        2 => Valve3Control,
+        3 => Valve4Control,
+        _ => throw new ArgumentOutOfRangeException(nameof(valveIndex))
+    };
+
+    /// <summary>
+    /// 获取阀门位置反馈地址
+    /// </summary>
+    public static string GetValvePositionAddress(int valveIndex) => valveIndex switch
+    {
+        0 => Valve1Position,
+        1 => Valve2Position,
+        2 => Valve3Position,
+        3 => Valve4Position,
+        _ => throw new ArgumentOutOfRangeException(nameof(valveIndex))
+    };
+}

+ 271 - 0
src/YZWater.Core/Services/PlcPollingService.cs

@@ -0,0 +1,271 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Serilog;
+using YZWater.Core.Models;
+
+namespace YZWater.Core.Services;
+
+/// <summary>
+/// PLC 轮询服务 - 负责实时数据采集、自动重连、数据分发
+/// </summary>
+public partial class PlcPollingService : ObservableObject, IDisposable
+{
+    private static PlcPollingService? _instance;
+    public static PlcPollingService Instance => _instance ??= new PlcPollingService();
+
+    private readonly PlcDataModel _data = new();
+    private CancellationTokenSource? _cts;
+    private Task? _pollingTask;
+    private bool _disposed;
+
+    // ─── 配置 ───
+    public int PollingIntervalMs { get; set; } = 1000;
+    public float DeadbandPercent { get; set; } = 0.5f;
+    public int MaxReconnectAttempts { get; set; } = 10;
+    public int ReconnectBaseDelayMs { get; set; } = 1000;
+
+    // ─── 数据变更事件 ───
+    public event Action<PlcDataModel>? DataUpdated;
+    public event Action<bool>? ConnectionStateChanged;
+    public event Action<string>? ErrorOccurred;
+
+    /// <summary>
+    /// 当前数据模型
+    /// </summary>
+    public PlcDataModel Data => _data;
+
+    private PlcPollingService() { }
+
+    /// <summary>
+    /// 启动轮询
+    /// </summary>
+    public void Start()
+    {
+        if (_pollingTask != null && !_pollingTask.IsCompleted)
+        {
+            Log.Warning("轮询已在运行中");
+            return;
+        }
+
+        _cts = new CancellationTokenSource();
+        _pollingTask = Task.Run(() => PollingLoopAsync(_cts.Token));
+        Log.Information("PLC 轮询服务已启动");
+    }
+
+    /// <summary>
+    /// 停止轮询
+    /// </summary>
+    public void Stop()
+    {
+        _cts?.Cancel();
+        _pollingTask?.Wait(TimeSpan.FromSeconds(5));
+        _cts?.Dispose();
+        _cts = null;
+        _pollingTask = null;
+        Log.Information("PLC 轮询服务已停止");
+    }
+
+    /// <summary>
+    /// 轮询主循环
+    /// </summary>
+    private async Task PollingLoopAsync(CancellationToken ct)
+    {
+        var reconnectAttempts = 0;
+
+        while (!ct.IsCancellationRequested)
+        {
+            try
+            {
+                // 确保连接
+                if (!PlcService.IsConnected)
+                {
+                    var connected = await TryConnectWithRetryAsync(reconnectAttempts, ct);
+                    if (!connected)
+                    {
+                        reconnectAttempts++;
+                        if (reconnectAttempts >= MaxReconnectAttempts)
+                        {
+                            ErrorOccurred?.Invoke($"PLC 连接失败,已重试 {MaxReconnectAttempts} 次");
+                            break;
+                        }
+                        await Task.Delay(ReconnectBaseDelayMs * Math.Min(reconnectAttempts, 5), ct);
+                        continue;
+                    }
+                    reconnectAttempts = 0;
+                }
+
+                // 读取数据
+                await ReadAllDataAsync(ct);
+
+                // 数据更新事件
+                DataUpdated?.Invoke(_data);
+
+                // 等待下一次轮询
+                await Task.Delay(PollingIntervalMs, ct);
+            }
+            catch (OperationCanceledException)
+            {
+                break;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "轮询异常");
+                ErrorOccurred?.Invoke($"轮询异常: {ex.Message}");
+
+                // 连接断开,尝试重连
+                if (!PlcService.IsConnected)
+                {
+                    _data.IsPlcConnected = false;
+                    ConnectionStateChanged?.Invoke(false);
+                }
+
+                await Task.Delay(PollingIntervalMs * 2, ct);
+            }
+        }
+    }
+
+    /// <summary>
+    /// 尝试连接(带重试)
+    /// </summary>
+    private async Task<bool> TryConnectWithRetryAsync(int attempt, CancellationToken ct)
+    {
+        for (int i = 0; i <= attempt && i < 3; i++)
+        {
+            if (ct.IsCancellationRequested) return false;
+
+            var connected = await PlcService.ConnectAsync();
+            if (connected)
+            {
+                _data.IsPlcConnected = true;
+                ConnectionStateChanged?.Invoke(true);
+                return true;
+            }
+
+            if (i < attempt)
+                await Task.Delay(ReconnectBaseDelayMs * (i + 1), ct);
+        }
+        return false;
+    }
+
+    /// <summary>
+    /// 批量读取所有 PLC 数据
+    /// </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);
+
+        // 应用死区过滤后更新数据
+        ApplyWithDeadband(_data.Tank1Level, tank1, v => _data.Tank1Level = v);
+        ApplyWithDeadband(_data.Tank2Level, tank2, v => _data.Tank2Level = v);
+        ApplyWithDeadband(_data.Tank3Level, tank3, v => _data.Tank3Level = v);
+        ApplyWithDeadband(_data.Tank4Level, tank4, v => _data.Tank4Level = v);
+        ApplyWithDeadband(_data.InflowRate, inflow, v => _data.InflowRate = v);
+        ApplyWithDeadband(_data.OutflowRate, outflow, v => _data.OutflowRate = v);
+
+        _data.Pump1Running = p1;
+        _data.Pump2Running = p2;
+        _data.Pump3Running = p3;
+        _data.Pump4Running = p4;
+        _data.Pump5Running = p5;
+
+        _data.Pump1Fault = pf1;
+        _data.Pump2Fault = pf2;
+        _data.Pump3Fault = pf3;
+        _data.Pump4Fault = pf4;
+        _data.Pump5Fault = pf5;
+
+        _data.Pump1Freq = f1;
+        _data.Pump2Freq = f2;
+        _data.Pump3Freq = f3;
+        _data.Pump4Freq = f4;
+        _data.Pump5Freq = f5;
+
+        _data.Fan1Running = fan1;
+        _data.Fan2Running = fan2;
+        _data.Fan1Fault = fanf1;
+        _data.Fan2Fault = fanf2;
+
+        _data.IsAutoMode = autoMode;
+        _data.LastReadTime = DateTime.Now;
+        _data.IsDataValid = true;
+        _data.IsPlcConnected = PlcService.IsConnected;
+        _data.FlowDelta = _data.InflowRate - _data.OutflowRate;
+
+        // 触发计算属性更新
+        OnPropertyChanged(nameof(PlcDataModel.FlowDelta));
+    }
+
+    /// <summary>
+    /// 死区过滤:只有变化超过阈值才更新
+    /// </summary>
+    private void ApplyWithDeadband(float currentValue, float newValue, Action<float> setter)
+    {
+        if (Math.Abs(currentValue) < 0.001f && Math.Abs(newValue) < 0.001f)
+            return; // 都是零值,跳过
+
+        var threshold = Math.Abs(currentValue) * DeadbandPercent / 100.0f;
+        if (Math.Abs(newValue - currentValue) > threshold || currentValue == 0)
+        {
+            setter(newValue);
+        }
+    }
+
+    /// <summary>
+    /// 立即读取一次(手动刷新)
+    /// </summary>
+    public async Task RefreshNowAsync()
+    {
+        if (PlcService.IsConnected)
+        {
+            await ReadAllDataAsync(CancellationToken.None);
+            DataUpdated?.Invoke(_data);
+        }
+    }
+
+    public void Dispose()
+    {
+        if (_disposed) return;
+        _disposed = true;
+        Stop();
+        Log.Information("PLC 轮询服务已释放");
+    }
+}