浏览代码

refactor: 业务逻辑驱动 UI 完善(P0-P2)

- P0: 泵组控制/故障状态区域按内容自动显隐(HasFaultContent)
- P1: 水箱阈值行改为动态列表(跟随项目泵数,全链路动态化)
  - TankConfig 新增按索引访问辅助方法(向后兼容)
  - 新增 TankThresholdRow 类,TankItemViewModel 重构
  - PlcPollingService / PlcDataModel / ParameterSyncService 全链路适配
  - AXAML 改为 ItemsControl 动态迭代
- P2: 阈值行级 IsVisible(有地址或有值才显示)+ IsEditable
- 注释清理: 扬杰二厂典型布局 → 典型泵站布局
- PlcService: 读取失败时更新连接状态
DESKTOP-74CLTRG\Leol 1 月之前
父节点
当前提交
7b930f83a9

+ 1 - 1
src/YZWater.Avalonia/Controls/Layout/PumpStationLayoutEngine.cs

@@ -9,7 +9,7 @@ namespace YZWater.Avalonia.Controls.Layout;
 /// <summary>
 /// 泵站专用布局引擎 — 适用于 ViewTemplate="PumpStation" 的项目。
 ///
-/// <para>拓扑结构(扬杰二厂典型布局):</para>
+/// <para>拓扑结构(典型泵站布局):</para>
 /// <code>
 ///     ─── 出水总管 ───          ← 顶部:出水水平管道
 ///        │     │     │

+ 24 - 40
src/YZWater.Avalonia/Views/ProjectView.axaml

@@ -223,8 +223,9 @@
                         </Grid>
                     </Border>
 
-                    <!-- 底部泵控制条:看板 + 数据 + 启停一体化,无需滚动 -->
-                    <Border Grid.Row="1" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="10,8" Margin="0,10,0,0">
+                    <!-- 底部泵控制条:看板 + 数据 + 启停一体化,无需滚动(无泵配置时隐藏) -->
+                    <Border Grid.Row="1" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="10,8" Margin="0,10,0,0"
+                            IsVisible="{Binding Pumps.Count, Converter={x:Static conv:BoolConverters.ToHasItems}}">
                         <Grid RowDefinitions="Auto,Auto">
                             <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="2,0,2,8">
                                 <StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
@@ -424,42 +425,24 @@
                                                         LineBrush="{DynamicResource SuccessBrush}"
                                                         FillBrush="{StaticResource LocalSparkFillBrush}"/>
                                                 </Border>
-                                                <!-- 可编辑阈值 + 同步按钮 -->
-                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
-                                                    <TextBlock Text="启泵1" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump1Level, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
-                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
-                                                    <TextBlock Text="启泵2" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump2Level, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
-                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
-                                                    <TextBlock Text="启泵3" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditStartPump3Level, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
-                                                               Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
-                                                    <TextBlock Text="停泵液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditStopLevel, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
-                                                               Foreground="{DynamicResource WarningBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
-                                                <Grid ColumnDefinitions="*,60,Auto" Margin="0,2">
-                                                    <TextBlock Text="保护液位" FontSize="10" Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
-                                                    <TextBox Grid.Column="1" Text="{Binding EditProtectionLevel, StringFormat='{}{0:F2}'}"
-                                                               FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
-                                                               Foreground="{DynamicResource DangerBrush}" Background="{DynamicResource PanelBgBrush}"/>
-                                                    <TextBlock Grid.Column="2" Text="m" FontSize="9" Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
-                                                </Grid>
+                                                <!-- 可编辑阈值(动态数量,跟随项目泵数)+ 同步按钮 -->
+                                                <ItemsControl ItemsSource="{Binding ThresholdRows}">
+                                                    <ItemsControl.ItemTemplate>
+                                                        <DataTemplate>
+                                                            <Grid ColumnDefinitions="*,60,Auto" Margin="0,2"
+                                                                  IsVisible="{Binding IsVisible}">
+                                                                <TextBlock Text="{Binding Label}" FontSize="10"
+                                                                           Foreground="{DynamicResource TextTertiaryBrush}" VerticalAlignment="Center"/>
+                                                                <TextBox Grid.Column="1" Text="{Binding EditValue, StringFormat='{}{0:F2}'}"
+                                                                         IsEnabled="{Binding IsEditable}"
+                                                                         FontFamily="Consolas, monospace" FontSize="11" Padding="4,2" Height="22"
+                                                                         Foreground="{DynamicResource TextSecondaryBrush}" Background="{DynamicResource PanelBgBrush}"/>
+                                                                <TextBlock Grid.Column="2" Text="m" FontSize="9"
+                                                                           Foreground="{DynamicResource TextTertiaryBrush}" Margin="4,0,0,0" VerticalAlignment="Center"/>
+                                                            </Grid>
+                                                        </DataTemplate>
+                                                    </ItemsControl.ItemTemplate>
+                                                </ItemsControl>
                                                 <!-- 同步到 PLC 按钮 -->
                                                 <Button Content="同步到PLC" Command="{Binding SyncThresholdsToPlcCommand}"
                                                         IsVisible="{Binding HasWritableAddresses}"
@@ -554,8 +537,9 @@
                         </StackPanel>
                     </Border>
 
-                    <!-- 故障状态 -->
-                    <Border Grid.Row="3" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12">
+                    <!-- 故障状态(无内容时隐藏:无泵/无数字量/无报警) -->
+                    <Border Grid.Row="3" Classes="surface-card" Background="{DynamicResource SurfaceBgBrush}" Padding="14,12"
+                            IsVisible="{Binding HasFaultContent}">
                         <ScrollViewer>
                             <StackPanel>
                                 <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,8">

+ 8 - 6
src/YZWater.Core/Models/PlcDataModel.cs

@@ -122,12 +122,14 @@ public partial class PlcDataModel : ObservableObject
         {
             if (!string.IsNullOrEmpty(tank.LevelAddress))
                 AnalogRegisters[$"{tank.Name}.Level"] = 0;
-            if (!string.IsNullOrEmpty(tank.StartPump1LevelAddress))
-                AnalogRegisters[$"{tank.Name}.StartPump1Level"] = 0;
-            if (!string.IsNullOrEmpty(tank.StartPump2LevelAddress))
-                AnalogRegisters[$"{tank.Name}.StartPump2Level"] = 0;
-            if (!string.IsNullOrEmpty(tank.StartPump3LevelAddress))
-                AnalogRegisters[$"{tank.Name}.StartPump3Level"] = 0;
+            // 动态启泵液位:数量跟随项目泵数(至少检查 3 个以兼容旧配置)
+            var pumpThresholdCount = Math.Max(3, profile.Pumps.Count);
+            for (int i = 1; i <= pumpThresholdCount; i++)
+            {
+                var addr = tank.GetStartPumpLevelAddress(i);
+                if (!string.IsNullOrEmpty(addr))
+                    AnalogRegisters[$"{tank.Name}.StartPump{i}Level"] = 0;
+            }
             if (!string.IsNullOrEmpty(tank.StopLevelAddress))
                 AnalogRegisters[$"{tank.Name}.StopLevel"] = 0;
             if (!string.IsNullOrEmpty(tank.ProtectionLevelAddress))

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

@@ -169,6 +169,47 @@ public class TankConfig
 
     /// <summary>保护液位地址 (读写,如 VD962)</summary>
     public string ProtectionLevelAddress { get; set; } = "";
+
+    // ─── 按索引访问(动态泵数量支持,向后兼容旧字段)───
+
+    /// <summary>获取第 N 台泵启泵液位地址(1-based,N=1/2/3 取旧字段,N≥4 取扩展列表)</summary>
+    public string GetStartPumpLevelAddress(int pumpIndex) => pumpIndex switch
+    {
+        1 => StartPump1LevelAddress,
+        2 => StartPump2LevelAddress,
+        3 => StartPump3LevelAddress,
+        _ => pumpIndex - 1 < StartPumpLevelAddresses.Count ? StartPumpLevelAddresses[pumpIndex - 1] : ""
+    };
+
+    /// <summary>获取第 N 台泵启泵液位值(1-based)</summary>
+    public float? GetStartPumpLevel(int pumpIndex) => pumpIndex switch
+    {
+        1 => StartPump1Level,
+        2 => StartPump2Level,
+        3 => StartPump3Level,
+        _ => pumpIndex - 1 < StartPumpLevels.Count ? StartPumpLevels[pumpIndex - 1] : null
+    };
+
+    /// <summary>设置第 N 台泵启泵液位值(1-based)</summary>
+    public void SetStartPumpLevel(int pumpIndex, float value)
+    {
+        switch (pumpIndex)
+        {
+            case 1: StartPump1Level = value; break;
+            case 2: StartPump2Level = value; break;
+            case 3: StartPump3Level = value; break;
+            default:
+                while (StartPumpLevels.Count < pumpIndex) StartPumpLevels.Add(null);
+                StartPumpLevels[pumpIndex - 1] = value;
+                break;
+        }
+    }
+
+    /// <summary>扩展启泵液位地址列表(N≥4 时使用)</summary>
+    public List<string> StartPumpLevelAddresses { get; set; } = new();
+
+    /// <summary>扩展启泵液位值列表(N≥4 时使用)</summary>
+    public List<float?> StartPumpLevels { get; set; } = new();
 }
 
 /// <summary>

+ 10 - 3
src/YZWater.Core/Services/AlarmService.cs

@@ -175,10 +175,17 @@ public partial class AlarmService : ObservableObject, IDisposable
             CheckFanFault("Fan2", data.Fan2Fault, now);
         }
 
-        // 检查 PLC 通信
-        if (!data.IsPlcConnected)
+        // 检查 PLC 通信(Mock 模式下跳过,因为本来就没有真实 PLC)
+        if (!MockPlcService.IsEnabled)
         {
-            CheckCondition("PLC_Connection", LanguageService.Instance.Get("PlcCommLost"), 1, now);
+            if (!data.IsPlcConnected)
+            {
+                CheckCondition("PLC_Connection", LanguageService.Instance.Get("PlcCommLost"), 1, now);
+            }
+            else
+            {
+                ClearCondition("PLC_Connection");
+            }
         }
         else
         {

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

@@ -51,7 +51,7 @@ public sealed class ConnectionStatusService : IDisposable
         get
         {
             var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
-            if (mode == "Mock") return "PLC: 模拟模式";
+            if (mode == "Mock") return "PLC: 模拟模式(无 PLC 连接)";
             return IsPlcConnected ? "PLC: 已连接" : "PLC: 未连接";
         }
     }

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

@@ -123,7 +123,7 @@ public static class MockPlcService
         data.IsAutoMode = _autoMode;
         data.LastReadTime = DateTime.Now;
         data.IsDataValid = true;
-        data.IsPlcConnected = true;
+        data.IsPlcConnected = false; // Mock 模式无真实 PLC,诚实报告断开状态
 
         // 同步到项目化动态寄存器(ProjectViewModel 通过这些键读数据)
         SyncDynamicRegisters(data);

+ 13 - 9
src/YZWater.Core/Services/ParameterSyncService.cs

@@ -32,18 +32,22 @@ public class ParameterSyncService
     }
 
     /// <summary>同步水箱液位阈值到 PLC</summary>
-    public async Task<SyncResult> SyncTankThresholdsAsync(TankConfig tank, string operatorName = "Operator")
+    public async Task<SyncResult> SyncTankThresholdsAsync(TankConfig tank, int pumpCount = 3, string operatorName = "Operator")
     {
         var result = new SyncResult();
 
-        var addresses = new (string? addr, float? value, string label)[]
+        // 动态启泵液位:数量跟随项目泵数(至少 3 以兼容旧配置)
+        var thresholdCount = Math.Max(3, pumpCount);
+        var addresses = new List<(string? addr, float? value, string label)>();
+        for (int i = 1; i <= thresholdCount; i++)
         {
-            (tank.StartPump1LevelAddress, tank.StartPump1Level, "启泵1液位"),
-            (tank.StartPump2LevelAddress, tank.StartPump2Level, "启泵2液位"),
-            (tank.StartPump3LevelAddress, tank.StartPump3Level, "启泵3液位"),
-            (tank.StopLevelAddress, tank.StopLevel, "停泵液位"),
-            (tank.ProtectionLevelAddress, tank.ProtectionLevel, "保护液位"),
-        };
+            var addr = tank.GetStartPumpLevelAddress(i);
+            var value = tank.GetStartPumpLevel(i);
+            if (!string.IsNullOrEmpty(addr))
+                addresses.Add((addr, value, $"启泵{i}液位"));
+        }
+        addresses.Add((tank.StopLevelAddress, tank.StopLevel, "停泵液位"));
+        addresses.Add((tank.ProtectionLevelAddress, tank.ProtectionLevel, "保护液位"));
 
         foreach (var (addr, value, label) in addresses)
         {
@@ -157,7 +161,7 @@ public class ParameterSyncService
         // 同步所有水箱阈值
         foreach (var tank in profile.Tanks)
         {
-            var tankResult = await SyncTankThresholdsAsync(tank, operatorName);
+            var tankResult = await SyncTankThresholdsAsync(tank, profile.Pumps.Count, operatorName);
             result.Merge(tankResult);
         }
 

+ 39 - 8
src/YZWater.Core/Services/PlcPollingService.cs

@@ -30,6 +30,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
     private CancellationTokenSource? _cts;
     private Task? _pollingTask;
     private bool _disposed;
+    private bool _lastNotifiedConnected = false; // 上次通知 UI 的连接状态(用于检测变化)
 
     // PLC 读取并发限制(防止同时发起过多请求导致 PLC 过载)
     private static readonly SemaphoreSlim _plcReadSemaphore = new(4, 4);
@@ -105,6 +106,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         var reconnectAttempts = 0;
         var isInDegradedMode = false;  // 降级模式标志
         const int degradedModeRetryIntervalMs = 30000;  // 降级模式下 30 秒重试一次
+        var mockStateNotified = false; // Mock 模式下只触发一次连接状态事件
 
         while (!ct.IsCancellationRequested)
         {
@@ -114,6 +116,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 {
                     // Mock 模式:直接生成模拟数据
                     MockPlcService.UpdateData(_data);
+
+                    // Mock 模式首次运行时通知 UI:PLC 未连接(模拟模式)
+                    if (!mockStateNotified)
+                    {
+                        mockStateNotified = true;
+                        _data.IsPlcConnected = false;
+                        ConnectionStateChanged?.Invoke(false);
+                    }
                 }
                 else
                 {
@@ -177,6 +187,16 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                     await ReadAllDataAsync(ct);
                 }
 
+                // 检测连接状态变化(PlcService 在读取失败时已将 IsConnected 置为 false)
+                // 通知订阅 ConnectionStateChanged 的 ViewModel(MainViewModel / ViewAViewModel 等)
+                var currentConnected = MockPlcService.IsEnabled ? false : PlcService.IsConnected;
+                if (currentConnected != _lastNotifiedConnected)
+                {
+                    _lastNotifiedConnected = currentConnected;
+                    _data.IsPlcConnected = currentConnected;
+                    ConnectionStateChanged?.Invoke(currentConnected);
+                }
+
                 // 数据更新事件
                 DataUpdated?.Invoke(_data);
 
@@ -197,7 +217,11 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
                 if (!PlcService.IsConnected)
                 {
                     _data.IsPlcConnected = false;
-                    ConnectionStateChanged?.Invoke(false);
+                    if (_lastNotifiedConnected)
+                    {
+                        _lastNotifiedConnected = false;
+                        ConnectionStateChanged?.Invoke(false);
+                    }
                 }
 
                 await Task.Delay(PollingIntervalMs * 2, ct);
@@ -218,7 +242,11 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             if (connected)
             {
                 _data.IsPlcConnected = true;
-                ConnectionStateChanged?.Invoke(true);
+                if (!_lastNotifiedConnected)
+                {
+                    _lastNotifiedConnected = true;
+                    ConnectionStateChanged?.Invoke(true);
+                }
                 return true;
             }
 
@@ -271,6 +299,7 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
             qData == null || iData == null || mData == null)
         {
             _data.IsDataValid = false;
+            _data.IsPlcConnected = PlcService.IsConnected; // 同步连接状态(PlcService 在读取失败时已更新)
             return;
         }
 
@@ -390,12 +419,14 @@ public partial class PlcPollingService : ObservableObject, IDisposable, IService
         {
             if (!string.IsNullOrEmpty(tank.LevelAddress))
                 analogReads.Add(($"{tank.Name}.Level", tank.LevelAddress));
-            if (!string.IsNullOrEmpty(tank.StartPump1LevelAddress))
-                analogReads.Add(($"{tank.Name}.StartPump1Level", tank.StartPump1LevelAddress));
-            if (!string.IsNullOrEmpty(tank.StartPump2LevelAddress))
-                analogReads.Add(($"{tank.Name}.StartPump2Level", tank.StartPump2LevelAddress));
-            if (!string.IsNullOrEmpty(tank.StartPump3LevelAddress))
-                analogReads.Add(($"{tank.Name}.StartPump3Level", tank.StartPump3LevelAddress));
+            // 动态启泵液位:数量跟随项目泵数(至少检查 3 个以兼容旧配置)
+            var pumpThresholdCount = Math.Max(3, profile.Pumps.Count);
+            for (int i = 1; i <= pumpThresholdCount; i++)
+            {
+                var addr = tank.GetStartPumpLevelAddress(i);
+                if (!string.IsNullOrEmpty(addr))
+                    analogReads.Add(($"{tank.Name}.StartPump{i}Level", addr));
+            }
             if (!string.IsNullOrEmpty(tank.StopLevelAddress))
                 analogReads.Add(($"{tank.Name}.StopLevel", tank.StopLevelAddress));
             if (!string.IsNullOrEmpty(tank.ProtectionLevelAddress))

+ 65 - 10
src/YZWater.Core/Services/PlcService.cs

@@ -170,17 +170,28 @@ public static class PlcService
             if (_driver != null)
             {
                 var result = await _driver.ReadFloatAsync(address);
-                return result.IsSuccess ? result.Content : 0f;
+                if (!result.IsSuccess)
+                {
+                    SetConnectionState(false);
+                    return 0f;
+                }
+                return result.Content;
             }
 
             // 回退到 _plc(向后兼容)
             if (_plc == null) return 0f;
             var plcResult = await _plc.ReadFloatAsync(address);
-            return plcResult.IsSuccess ? plcResult.Content : 0f;
+            if (!plcResult.IsSuccess)
+            {
+                SetConnectionState(false);
+                return 0f;
+            }
+            return plcResult.Content;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
+            SetConnectionState(false);
             return 0f;
         }
     }
@@ -201,15 +212,26 @@ public static class PlcService
             if (_driver != null)
             {
                 var result = await _driver.WriteFloatAsync(address, value);
-                return result.IsSuccess;
+                if (!result.IsSuccess)
+                {
+                    SetConnectionState(false);
+                    return false;
+                }
+                return true;
             }
             if (_plc == null) return false;
             var plcResult = await _plc.WriteAsync(address, value);
-            return plcResult.IsSuccess;
+            if (!plcResult.IsSuccess)
+            {
+                SetConnectionState(false);
+                return false;
+            }
+            return true;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "写入 PLC 数据失败,地址: {Address}, 值: {Value}", address, value);
+            SetConnectionState(false);
             return false;
         }
     }
@@ -230,15 +252,26 @@ public static class PlcService
             if (_driver != null)
             {
                 var result = await _driver.ReadBoolAsync(address);
-                return result.IsSuccess && result.Content;
+                if (!result.IsSuccess)
+                {
+                    SetConnectionState(false);
+                    return false;
+                }
+                return result.Content;
             }
             if (_plc == null) return false;
             var plcResult = await _plc.ReadBoolAsync(address);
-            return plcResult.IsSuccess && plcResult.Content;
+            if (!plcResult.IsSuccess)
+            {
+                SetConnectionState(false);
+                return false;
+            }
+            return plcResult.Content;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "读取 PLC 数据失败,地址: {Address}", address);
+            SetConnectionState(false);
             return false;
         }
     }
@@ -259,15 +292,26 @@ public static class PlcService
             if (_driver != null)
             {
                 var result = await _driver.WriteBoolAsync(address, value);
-                return result.IsSuccess;
+                if (!result.IsSuccess)
+                {
+                    SetConnectionState(false);
+                    return false;
+                }
+                return true;
             }
             if (_plc == null) return false;
             var plcResult = await _plc.WriteAsync(address, value);
-            return plcResult.IsSuccess;
+            if (!plcResult.IsSuccess)
+            {
+                SetConnectionState(false);
+                return false;
+            }
+            return true;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "写入 PLC 数据失败,地址: {Address}, 值: {Value}", address, value);
+            SetConnectionState(false);
             return false;
         }
     }
@@ -288,15 +332,26 @@ public static class PlcService
             if (_driver != null)
             {
                 var result = await _driver.ReadBytesAsync(address, length);
-                return result.IsSuccess ? result.Content : null;
+                if (!result.IsSuccess)
+                {
+                    SetConnectionState(false);
+                    return null;
+                }
+                return result.Content;
             }
             if (_plc == null) return null;
             var plcResult = await _plc.ReadAsync(address, length);
-            return plcResult.IsSuccess ? plcResult.Content : null;
+            if (!plcResult.IsSuccess)
+            {
+                SetConnectionState(false);
+                return null;
+            }
+            return plcResult.Content;
         }
         catch (Exception ex)
         {
             Log.Error(ex, "批量读取 PLC 数据失败,地址: {Address}, 长度: {Length}", address, length);
+            SetConnectionState(false);
             return null;
         }
     }

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

@@ -236,11 +236,22 @@ public partial class MainViewModel : TranslatableViewModelBase
 
     private void OnConnectionStateChanged(bool connected)
     {
+        var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
         RunOnUi(() =>
         {
-            PlcStatusText = connected ? T("PlcConnectedStatus") : T("PlcDisconnected");
-            PlcStatusColor = connected ? "#27AE60" : "#E74C3C";
-            IsPlcConnected = connected;
+            if (mode == "Mock")
+            {
+                // Mock 模式:明确告知用户是模拟数据,PLC 未连接
+                PlcStatusText = "模拟模式(无 PLC 连接)";
+                PlcStatusColor = "#F39C12"; // 橙色,区分于正常的绿色和断线的红色
+                IsPlcConnected = false;
+            }
+            else
+            {
+                PlcStatusText = connected ? T("PlcConnectedStatus") : T("PlcDisconnected");
+                PlcStatusColor = connected ? "#27AE60" : "#E74C3C";
+                IsPlcConnected = connected;
+            }
         });
     }
 
@@ -285,6 +296,14 @@ public partial class MainViewModel : TranslatableViewModelBase
             "Mock" => T("MockMode"),
             _ => T("DirectMode")
         };
+
+        // Mock 模式下立即初始化 PLC 状态显示,避免事件未触发时状态栏空白
+        if (RunMode == "Mock")
+        {
+            PlcStatusText = "模拟模式(无 PLC 连接)";
+            PlcStatusColor = "#F39C12";
+            IsPlcConnected = false;
+        }
     }
 
     [RelayCommand]

+ 137 - 38
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -51,6 +51,13 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     /// <summary>是否有建议启动的泵</summary>
     public bool HasSuggestedPump => !string.IsNullOrEmpty(SuggestedPumpName);
 
+    /// <summary>
+    /// 故障状态区域是否有内容可显示
+    /// (有泵 / 有数字量输入 / 有数字量输出 / 有活跃报警 → 显示,否则隐藏空面板)
+    /// </summary>
+    public bool HasFaultContent =>
+        Pumps.Count > 0 || DigitalInputs.Count > 0 || DigitalOutputs.Count > 0 || HasActiveAlarm;
+
     // ─── 动态 2D/3D 控件绑定用字典 ───
 
     /// <summary>项目档案(供动态布局引擎使用)</summary>
@@ -81,7 +88,11 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     {
         var operatorName = AuthService.CurrentUser?.DisplayName ?? "Operator";
         await AlarmService.Instance.ResetAllAlarmsAsync(operatorName);
-        RunOnUi(() => RecentAlarms.Clear());
+        RunOnUi(() =>
+        {
+            RecentAlarms.Clear();
+            OnPropertyChanged(nameof(HasFaultContent));
+        });
     }
 
     /// <summary>
@@ -133,7 +144,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
             Pumps.Add(new PumpItemViewModel(pump));
 
         foreach (var tank in profile.Tanks)
-            Tanks.Add(new TankItemViewModel(tank));
+            Tanks.Add(new TankItemViewModel(tank, profile.Pumps.Count));
 
         foreach (var gauge in profile.Gauges)
             Gauges.Add(new GaugeItemViewModel(gauge));
@@ -190,6 +201,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
             RecentAlarms.Insert(0, record);
             while (RecentAlarms.Count > 6)
                 RecentAlarms.RemoveAt(RecentAlarms.Count - 1);
+            OnPropertyChanged(nameof(HasFaultContent));
         });
     }
 
@@ -208,6 +220,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     {
         HasActiveAlarm = AlarmService.Instance.HasActiveAlarm;
         ActiveAlarmCount = AlarmService.Instance.ActiveAlarmCount;
+        OnPropertyChanged(nameof(HasFaultContent));
     }
 
     /// <summary>SuggestedPumpName 变化时联动刷新 HasSuggestedPump</summary>
@@ -486,10 +499,11 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     private void OnConnectionStateChanged(bool connected)
     {
         var mode = ConfigService.GetConfig()?.RunMode ?? "Direct";
-        IsPlcConnected = mode == "Mock" || connected;
+        // Mock 模式下不伪装连接状态,诚实显示"模拟模式"
+        IsPlcConnected = mode != "Mock" && connected;
         PlcStatusText = mode switch
         {
-            "Mock" => "模拟模式",
+            "Mock" => "模拟模式(无 PLC 连接)",
             _ => connected ? "PLC 已连接" : "PLC 未连接"
         };
     }
@@ -836,12 +850,59 @@ public partial class PumpItemViewModel : ObservableObject
     }
 }
 
+/// <summary>
+/// 水箱阈值行 — 每行对应一个可编辑的液位阈值(启泵N / 停泵 / 保护液位)
+/// </summary>
+public partial class TankThresholdRow : ObservableObject
+{
+    /// <summary>行标签(如 "启泵1"、"停泵液位"、"保护液位")</summary>
+    public string Label { get; }
+
+    /// <summary>PlcDataModel 寄存器键(如 "集水池.StartPump1Level")</summary>
+    public string RegisterKey { get; }
+
+    /// <summary>TankConfig 属性名(用于回写配置,如 "StartPump1Level")</summary>
+    public string? ConfigProperty { get; }
+
+    /// <summary>当前值(从 PLC 读取,只读显示)</summary>
+    [ObservableProperty] private float _value;
+
+    /// <summary>可编辑值(用户输入,待同步到 PLC)</summary>
+    [ObservableProperty] private float _editValue;
+
+    /// <summary>PLC 写入地址(空则隐藏该行)</summary>
+    public string Address { get; }
+
+    /// <summary>是否有可写入的地址</summary>
+    public bool HasAddress => !string.IsNullOrEmpty(Address);
+
+    /// <summary>文本颜色风格键(null = 默认,"warning" = 琥珀,"danger" = 红)</summary>
+    public string StyleKey { get; }
+
+    /// <summary>行是否可见(有地址,或有非零值)</summary>
+    [ObservableProperty] private bool _isVisible = true;
+
+    /// <summary>行是否可编辑(有地址才可编辑,否则只读显示)</summary>
+    public bool IsEditable => HasAddress;
+
+    public TankThresholdRow(string label, string registerKey, string address, string styleKey = "", string? configProperty = null)
+    {
+        Label = label;
+        RegisterKey = registerKey;
+        Address = address;
+        StyleKey = styleKey;
+        ConfigProperty = configProperty;
+        IsVisible = HasAddress; // 初始仅在有地址时可见
+    }
+}
+
 /// <summary>
 /// 水箱/水池 ViewModel
 /// </summary>
 public partial class TankItemViewModel : ObservableObject
 {
     private readonly TankConfig _config;
+    private readonly int _pumpCount;
     private bool _editInitialized;
 
     public string Name => _config.Name;
@@ -853,24 +914,16 @@ public partial class TankItemViewModel : ObservableObject
     [ObservableProperty] private float _stopLevel;
     [ObservableProperty] private float _protectionLevel;
 
-    // ─── 可编辑阈值(供参数同步到 PLC)───
+    // ─── 动态阈值行(数量跟随项目泵数 + 停泵 + 保护液位)───
 
-    [ObservableProperty] private float _editStartPump1Level;
-    [ObservableProperty] private float _editStartPump2Level;
-    [ObservableProperty] private float _editStartPump3Level;
-    [ObservableProperty] private float _editStopLevel;
-    [ObservableProperty] private float _editProtectionLevel;
+    /// <summary>可编辑阈值行集合(启泵1~N + 停泵液位 + 保护液位)</summary>
+    public ObservableCollection<TankThresholdRow> ThresholdRows { get; } = new();
 
     [ObservableProperty] private bool _isSyncing;
     [ObservableProperty] private string _syncStatusText = "";
 
-    /// <summary>是否有可写入 PLC 的地址配置</summary>
-    public bool HasWritableAddresses =>
-        !string.IsNullOrEmpty(_config.StartPump1LevelAddress) ||
-        !string.IsNullOrEmpty(_config.StartPump2LevelAddress) ||
-        !string.IsNullOrEmpty(_config.StartPump3LevelAddress) ||
-        !string.IsNullOrEmpty(_config.StopLevelAddress) ||
-        !string.IsNullOrEmpty(_config.ProtectionLevelAddress);
+    /// <summary>是否有可写入 PLC 的地址配置(任一行有地址即可)</summary>
+    public bool HasWritableAddresses => ThresholdRows.Any(r => r.HasAddress);
 
     /// <summary>当前液位工况档位(依据液位与阈值自动判定,供 UI 徽章展示)</summary>
     [ObservableProperty] private TankStage _currentStage = TankStage.Stop;
@@ -886,9 +939,42 @@ public partial class TankItemViewModel : ObservableObject
     private int _trendIndex;
     private int _trendCount;
 
-    public TankItemViewModel(TankConfig config)
+    public TankItemViewModel(TankConfig config, int pumpCount)
     {
         _config = config;
+        _pumpCount = pumpCount;
+        BuildThresholdRows();
+    }
+
+    /// <summary>根据泵数量动态构建阈值行(启泵1~N + 停泵 + 保护)</summary>
+    private void BuildThresholdRows()
+    {
+        ThresholdRows.Clear();
+        // 启泵阈值:数量 = max(3, pumpCount),兼容旧配置的 3 行
+        var thresholdCount = Math.Max(3, _pumpCount);
+        for (int i = 1; i <= thresholdCount; i++)
+        {
+            var addr = _config.GetStartPumpLevelAddress(i);
+            ThresholdRows.Add(new TankThresholdRow(
+                label: $"启泵{i}",
+                registerKey: $"{Name}.StartPump{i}Level",
+                address: addr,
+                styleKey: "",
+                configProperty: null
+            ));
+        }
+        // 停泵液位
+        ThresholdRows.Add(new TankThresholdRow(
+            label: "停泵液位",
+            registerKey: $"{Name}.StopLevel",
+            address: _config.StopLevelAddress,
+            styleKey: "warning"));
+        // 保护液位
+        ThresholdRows.Add(new TankThresholdRow(
+            label: "保护液位",
+            registerKey: $"{Name}.ProtectionLevel",
+            address: _config.ProtectionLevelAddress,
+            styleKey: "danger"));
     }
 
     public void UpdateFromData(PlcDataModel data)
@@ -901,16 +987,17 @@ public partial class TankItemViewModel : ObservableObject
         ProtectionLevel = data.GetAnalogRegister($"{Name}.ProtectionLevel");
         CurrentStage = CalculateStage();
 
-        // 首次更新时初始化编辑值(后续轮询不覆盖用户输入)
-        if (!_editInitialized)
+        // 更新 ThresholdRows 的值和可见性
+        foreach (var row in ThresholdRows)
         {
-            EditStartPump1Level = StartPump1Level;
-            EditStartPump2Level = StartPump2Level;
-            EditStartPump3Level = StartPump3Level;
-            EditStopLevel = StopLevel;
-            EditProtectionLevel = ProtectionLevel;
-            _editInitialized = true;
+            row.Value = data.GetAnalogRegister(row.RegisterKey);
+            // 可见条件:有地址 OR 有非零值(PLC 已读取到数据)
+            row.IsVisible = row.HasAddress || Math.Abs(row.Value) > 0.001f;
+            // 首次更新时初始化编辑值(后续轮询不覆盖用户输入)
+            if (!_editInitialized)
+                row.EditValue = row.Value;
         }
+        _editInitialized = true;
 
         // 追加趋势采样(环形缓冲)
         _trendBuffer[_trendIndex] = Level;
@@ -927,15 +1014,19 @@ public partial class TankItemViewModel : ObservableObject
 
     /// <summary>
     /// 按泵站运行逻辑判定当前液位档位:
-    /// 保护液位(最高,紧急风险)→ 启泵3 → 启泵2 → 启泵1 → 停泵区。
+    /// 保护液位(最高,紧急风险)→ 启泵N → ... → 启泵1 → 停泵区。
     /// 阈值优先取配置值,未配置(≤0)的档位自动跳过。
     /// </summary>
     private TankStage CalculateStage()
     {
         if (ProtectionLevel > 0 && Level >= ProtectionLevel) return TankStage.Protection;
-        if (StartPump3Level > 0 && Level >= StartPump3Level) return TankStage.Pump3;
-        if (StartPump2Level > 0 && Level >= StartPump2Level) return TankStage.Pump2;
-        if (StartPump1Level > 0 && Level >= StartPump1Level) return TankStage.Pump1;
+        // 动态检查各启泵阈值(反向遍历,从高到低)
+        for (int i = Math.Min(3, ThresholdRows.Count(r => r.Label.StartsWith("启泵"))); i >= 1; i--)
+        {
+            var level = i switch { 1 => StartPump1Level, 2 => StartPump2Level, 3 => StartPump3Level, _ => 0f };
+            if (level > 0 && Level >= level)
+                return i switch { 1 => TankStage.Pump1, 2 => TankStage.Pump2, _ => TankStage.Pump3 };
+        }
         return TankStage.Stop;
     }
 
@@ -953,14 +1044,22 @@ public partial class TankItemViewModel : ObservableObject
         IsSyncing = true;
         try
         {
-            // 更新 config 中的值
-            _config.StartPump1Level = EditStartPump1Level;
-            _config.StartPump2Level = EditStartPump2Level;
-            _config.StartPump3Level = EditStartPump3Level;
-            _config.StopLevel = EditStopLevel;
-            _config.ProtectionLevel = EditProtectionLevel;
-
-            var result = await ParameterSyncService.Instance.SyncTankThresholdsAsync(_config);
+            // 更新 config 中的值(遍历 ThresholdRows 动态回写)
+            int pumpIndex = 0;
+            foreach (var row in ThresholdRows)
+            {
+                if (row.Label.StartsWith("启泵"))
+                {
+                    pumpIndex++;
+                    _config.SetStartPumpLevel(pumpIndex, row.EditValue);
+                }
+                else if (row.Label == "停泵液位")
+                    _config.StopLevel = row.EditValue;
+                else if (row.Label == "保护液位")
+                    _config.ProtectionLevel = row.EditValue;
+            }
+
+            var result = await ParameterSyncService.Instance.SyncTankThresholdsAsync(_config, _pumpCount);
             SyncStatusText = result.Summary;
             Log.Information("水箱参数同步: {Tank} — {Summary}", Name, result.Summary);
         }