Преглед изворни кода

feat: PLC 通信中断 ISA-101 降级显示

- 新增 ConnectionStateHelper 统一断线状态通知机制
- 自定义控件断线降级:动画停止、整体变灰、叠加通信中断遮罩
  - PumpControl/FanControl 停止旋转动画
  - PipeLineControl 停止流动虚线动画
  - WaterTankControl 液位显示 ---.- %
  - GaugeControl 指针归中、数值显示 ---
  - StatusCard 状态显示未知
- 主视图添加橙色 PLC 通信中断横幅
- 禁用所有控制按钮(泵启停/手自动切换/中控切换)
- ViewAViewModel 断线时设备状态颜色全灰、文字显示未知
- ProjectViewModel 连接状态变化时刷新命令可执行状态
DESKTOP-74CLTRG\Leol пре 1 месец
родитељ
комит
aa1d651862

+ 10 - 0
src/YZWater.Avalonia/App.axaml.cs

@@ -226,6 +226,16 @@ public partial class App : Application
         }
         }
 
 
         PlcPollingService.Instance.Start();
         PlcPollingService.Instance.Start();
+
+        // 初始化控件层连接状态(断线降级显示)
+        var isMock = ConfigService.GetConfig()?.RunMode == "Mock";
+        Controls.ConnectionStateHelper.SetConnected(!isMock);
+        PlcPollingService.Instance.ConnectionStateChanged += connected =>
+        {
+            global::Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+                Controls.ConnectionStateHelper.SetConnected(connected));
+        };
+
         AlarmService.Instance.Start();
         AlarmService.Instance.Start();
         _dataLogger = new DataLoggingService(PlcPollingService.Instance);
         _dataLogger = new DataLoggingService(PlcPollingService.Instance);
         _dataLogger.Start();
         _dataLogger.Start();

+ 38 - 0
src/YZWater.Avalonia/Controls/ConnectionStateHelper.cs

@@ -0,0 +1,38 @@
+using Avalonia.Media;
+
+namespace YZWater.Avalonia.Controls;
+
+/// <summary>
+/// PLC 连接状态辅助类 - 供自定义控件订阅连接状态变化,实现断线降级显示。
+/// 遵循 ISA-101 标准:断线时控件停止动画、变灰、叠加"通信中断"标记。
+/// </summary>
+internal static class ConnectionStateHelper
+{
+    /// <summary>连接状态变化事件</summary>
+    public static event Action<bool>? StateChanged;
+
+    /// <summary>当前是否已连接</summary>
+    public static bool IsConnected { get; private set; } = true;
+
+    /// <summary>断线态灰色画刷(统一降级视觉)</summary>
+    public static IBrush DisconnectedGray { get; } = new SolidColorBrush(Color.Parse("#6B7280"));
+
+    /// <summary>断线态文字画刷</summary>
+    public static IBrush DisconnectedText { get; } = new SolidColorBrush(Color.Parse("#9CA3AF"));
+
+    /// <summary>断线警告色(橙黄)</summary>
+    public static IBrush DisconnectedWarning { get; } = new SolidColorBrush(Color.Parse("#F5A524"));
+
+    /// <summary>半透明遮罩(用于叠加在控件上表示数据失效)</summary>
+    public static IBrush OverlayBrush { get; } = new SolidColorBrush(Color.Parse("#000000"), 0.25);
+
+    /// <summary>
+    /// 设置连接状态(由 App 层在连接状态变化时调用)
+    /// </summary>
+    public static void SetConnected(bool connected)
+    {
+        if (IsConnected == connected) return;
+        IsConnected = connected;
+        StateChanged?.Invoke(connected);
+    }
+}

+ 23 - 5
src/YZWater.Avalonia/Controls/FanControl.cs

@@ -26,28 +26,37 @@ public class FanControl : Control
     {
     {
         base.OnAttachedToVisualTree(e);
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsRunning) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim();
     }
     }
 
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         StopAnim();
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsRunning) StartAnim();
+        InvalidateVisual();
+    }
 
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     {
     {
         base.OnPropertyChanged(change);
         base.OnPropertyChanged(change);
-        if (change.Property == IsRunningProperty) { if (IsRunning) StartAnim(); else { StopAnim(); _angle = 0; } }
+        if (change.Property == IsRunningProperty) { if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim(); else { StopAnim(); _angle = 0; } }
     }
     }
 
 
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _angle += Speed * 2; if (_angle >= 360) _angle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(50)); }
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _angle += Speed * 2; if (_angle >= 360) _angle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(50)); }
     private void StopAnim() { _timer?.Dispose(); _timer = null; }
     private void StopAnim() { _timer?.Dispose(); _timer = null; }
 
 
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+    private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
 
 
     public override void Render(DrawingContext context)
     public override void Render(DrawingContext context)
     {
     {
@@ -55,9 +64,10 @@ public class FanControl : Control
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 5;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 5;
-        var fanColor = IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled;
+        var disconnected = !ConnectionStateHelper.IsConnected;
+        var fanColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled);
 
 
-        context.DrawRectangle(null, new Pen(ThemeHelper.Border, 2), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
+        context.DrawRectangle(null, new Pen(disconnected ? ConnectionStateHelper.DisconnectedGray : ThemeHelper.Border, 2), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
 
 
         for (int i = 0; i < 4; i++)
         for (int i = 0; i < 4; i++)
         {
         {
@@ -72,9 +82,17 @@ public class FanControl : Control
 
 
         if (!string.IsNullOrEmpty(Text))
         if (!string.IsNullOrEmpty(Text))
         {
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, ThemeHelper.TextPrimary);
+            var textColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, textColor);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
         }
         }
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+            var label = new FormattedText("通信中断", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
+            context.DrawText(label, new Point(cx - label.Width / 2, 2));
+        }
     }
     }
 
 
     protected override Size MeasureOverride(Size availableSize) => new Size(50, 50);
     protected override Size MeasureOverride(Size availableSize) => new Size(50, 50);

+ 47 - 22
src/YZWater.Avalonia/Controls/GaugeControl.cs

@@ -32,16 +32,20 @@ public class GaugeControl : Control
     private FormattedText? _cachedValue;
     private FormattedText? _cachedValue;
     private string? _cachedValueText;
     private string? _cachedValueText;
 
 
-    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); ThemeHelper.ThemeChanged += OnThemeChanged; }
-    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ThemeHelper.ThemeChanged -= OnThemeChanged; base.OnDetachedFromVisualTree(e); }
+    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); ThemeHelper.ThemeChanged += OnThemeChanged; ConnectionStateHelper.StateChanged += OnConnectionStateChanged; }
+    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ThemeHelper.ThemeChanged -= OnThemeChanged; ConnectionStateHelper.StateChanged -= OnConnectionStateChanged; base.OnDetachedFromVisualTree(e); }
     private void OnThemeChanged()
     private void OnThemeChanged()
     {
     {
-        // 主题切换时清除 FormattedText 缓存(颜色变了)
         _cachedTitle = null; _cachedTitleText = null;
         _cachedTitle = null; _cachedTitleText = null;
         _cachedUnit = null; _cachedUnitText = null;
         _cachedUnit = null; _cachedUnitText = null;
         _cachedValue = null; _cachedValueText = null;
         _cachedValue = null; _cachedValueText = null;
         InvalidateVisual();
         InvalidateVisual();
     }
     }
+    private void OnConnectionStateChanged(bool connected)
+    {
+        _cachedValue = null; _cachedValueText = null;
+        InvalidateVisual();
+    }
 
 
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
     private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
     private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
@@ -52,14 +56,31 @@ public class GaugeControl : Control
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 + 10;
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 + 10;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 20;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 20;
+        var disconnected = !ConnectionStateHelper.IsConnected;
 
 
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius - 5, cy - radius - 5, (radius + 5) * 2, (radius + 5) * 2));
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius - 5, cy - radius - 5, (radius + 5) * 2, (radius + 5) * 2));
-        DrawArc(context, new Pen(ThemeHelper.Border, 8), cx, cy, radius, 135, 270);
+        DrawArc(context, new Pen(disconnected ? ConnectionStateHelper.DisconnectedGray : ThemeHelper.Border, 8), cx, cy, radius, 135, 270);
 
 
-        var normalized = Math.Max(0, Math.Min(1, (Value - MinValue) / (MaxValue - MinValue)));
-        var valueAngle = 135 + normalized * 270;
-        var valueColor = ValueColor ?? ThemeHelper.Success;
-        DrawArc(context, new Pen(valueColor, 8), cx, cy, radius, 135, valueAngle - 135);
+        var valueColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (ValueColor ?? ThemeHelper.Success);
+        if (!disconnected)
+        {
+            var normalized = Math.Max(0, Math.Min(1, (Value - MinValue) / (MaxValue - MinValue)));
+            var valueAngle = 135 + normalized * 270;
+            DrawArc(context, new Pen(valueColor, 8), cx, cy, radius, 135, valueAngle - 135);
+
+            var pAngle = valueAngle * Math.PI / 180;
+            var pLen = radius * 0.7;
+            context.DrawLine(new Pen(valueColor, 3), new Point(cx, cy), new Point(cx + Math.Cos(pAngle) * pLen, cy + Math.Sin(pAngle) * pLen));
+            context.DrawEllipse(valueColor, null, new Rect(cx - 6, cy - 6, 12, 12));
+        }
+        else
+        {
+            // 断线:指针停在中间位置
+            var pAngle = 270 * Math.PI / 180;
+            var pLen = radius * 0.7;
+            context.DrawLine(new Pen(valueColor, 3), new Point(cx, cy), new Point(cx + Math.Cos(pAngle) * pLen, cy + Math.Sin(pAngle) * pLen));
+            context.DrawEllipse(valueColor, null, new Rect(cx - 6, cy - 6, 12, 12));
+        }
 
 
         for (int i = 0; i <= 10; i++)
         for (int i = 0; i <= 10; i++)
         {
         {
@@ -67,40 +88,44 @@ public class GaugeControl : Control
             var rad = angle * Math.PI / 180;
             var rad = angle * Math.PI / 180;
             var isMajor = i % 5 == 0;
             var isMajor = i % 5 == 0;
             var tickLen = isMajor ? 12 : 6;
             var tickLen = isMajor ? 12 : 6;
-            context.DrawLine(new Pen(isMajor ? ThemeHelper.TextPrimary : ThemeHelper.TextDisabled, isMajor ? 2 : 1),
+            var tickColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (isMajor ? ThemeHelper.TextPrimary : ThemeHelper.TextDisabled);
+            context.DrawLine(new Pen(tickColor, isMajor ? 2 : 1),
                 new Point(cx + Math.Cos(rad) * (radius - tickLen), cy + Math.Sin(rad) * (radius - tickLen)),
                 new Point(cx + Math.Cos(rad) * (radius - tickLen), cy + Math.Sin(rad) * (radius - tickLen)),
                 new Point(cx + Math.Cos(rad) * radius, cy + Math.Sin(rad) * radius));
                 new Point(cx + Math.Cos(rad) * radius, cy + Math.Sin(rad) * radius));
         }
         }
 
 
-        var pAngle = valueAngle * Math.PI / 180;
-        var pLen = radius * 0.7;
-        context.DrawLine(new Pen(valueColor, 3), new Point(cx, cy), new Point(cx + Math.Cos(pAngle) * pLen, cy + Math.Sin(pAngle) * pLen));
-        context.DrawEllipse(valueColor, null, new Rect(cx - 6, cy - 6, 12, 12));
-
-        // Title(缓存:文字/字体不变时复用)
-        if (_cachedTitle == null || _cachedTitleText != Title)
+        // Title
+        var titleColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+        if (_cachedTitle == null || _cachedTitleText != Title || disconnected)
         {
         {
-            _cachedTitle = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, ThemeHelper.TextPrimary);
+            _cachedTitle = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, titleColor);
             _cachedTitleText = Title;
             _cachedTitleText = Title;
         }
         }
         context.DrawText(_cachedTitle, new Point(cx - _cachedTitle.Width / 2, bounds.Y + 5));
         context.DrawText(_cachedTitle, new Point(cx - _cachedTitle.Width / 2, bounds.Y + 5));
 
 
-        // Value(缓存:格式化字符串不变时复用)
-        var valStr = $"{Value:F1}";
+        // Value: 断线时显示 "---"
+        var valStr = disconnected ? "---" : $"{Value:F1}";
+        var valColor = disconnected ? ConnectionStateHelper.DisconnectedWarning : ThemeHelper.TextPrimary;
         if (_cachedValue == null || _cachedValueText != valStr)
         if (_cachedValue == null || _cachedValueText != valStr)
         {
         {
-            _cachedValue = new FormattedText(valStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 20, ThemeHelper.TextPrimary);
+            _cachedValue = new FormattedText(valStr, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 20, valColor);
             _cachedValueText = valStr;
             _cachedValueText = valStr;
         }
         }
         context.DrawText(_cachedValue, new Point(cx - _cachedValue.Width / 2, cy + radius * 0.3));
         context.DrawText(_cachedValue, new Point(cx - _cachedValue.Width / 2, cy + radius * 0.3));
 
 
-        // Unit(缓存:文字不变时复用)
+        // Unit
+        var unitColor = disconnected ? ConnectionStateHelper.DisconnectedGray : ThemeHelper.TextDisabled;
         if (_cachedUnit == null || _cachedUnitText != Unit)
         if (_cachedUnit == null || _cachedUnitText != Unit)
         {
         {
-            _cachedUnit = new FormattedText(Unit, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 10, ThemeHelper.TextDisabled);
+            _cachedUnit = new FormattedText(Unit, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 10, unitColor);
             _cachedUnitText = Unit;
             _cachedUnitText = Unit;
         }
         }
         context.DrawText(_cachedUnit, new Point(cx - _cachedUnit.Width / 2, cy + radius * 0.3 + _cachedValue.Height + 2));
         context.DrawText(_cachedUnit, new Point(cx - _cachedUnit.Width / 2, cy + radius * 0.3 + _cachedValue.Height + 2));
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, new Rect(cx - radius - 5, bounds.Y, (radius + 5) * 2, bounds.Height));
+        }
     }
     }
 
 
     private void DrawArc(DrawingContext context, IPen pen, double cx, double cy, double radius, double startAngle, double sweepAngle)
     private void DrawArc(DrawingContext context, IPen pen, double cx, double cy, double radius, double startAngle, double sweepAngle)

+ 15 - 6
src/YZWater.Avalonia/Controls/PipeLineControl.cs

@@ -32,22 +32,30 @@ public class PipeLineControl : Control
     {
     {
         base.OnAttachedToVisualTree(e);
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsFlow) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsFlow && ConnectionStateHelper.IsConnected) StartAnim();
     }
     }
 
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         StopAnim();
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsFlow) StartAnim();
+        InvalidateVisual();
+    }
 
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     {
     {
         base.OnPropertyChanged(change);
         base.OnPropertyChanged(change);
-        if (change.Property == IsFlowProperty) { if (IsFlow) StartAnim(); else { StopAnim(); _flowOffset = 0; } }
+        if (change.Property == IsFlowProperty) { if (IsFlow && ConnectionStateHelper.IsConnected) StartAnim(); else { StopAnim(); _flowOffset = 0; } }
     }
     }
 
 
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _flowOffset += FlowSpeed; if (_flowOffset >= 20) _flowOffset -= 20; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(50)); }
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _flowOffset += FlowSpeed; if (_flowOffset >= 20) _flowOffset -= 20; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(50)); }
@@ -58,19 +66,20 @@ public class PipeLineControl : Control
         base.Render(context);
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var pw = PipeWidth;
         var pw = PipeWidth;
-        var pipeColor = PipeColor ?? ThemeHelper.Border;
-        var waterColor = WaterColor ?? ThemeHelper.Success;
+        var disconnected = !ConnectionStateHelper.IsConnected;
+        var pipeColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (PipeColor ?? ThemeHelper.Border);
+        var waterColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (WaterColor ?? ThemeHelper.Success);
 
 
         if (IsHorizontal)
         if (IsHorizontal)
         {
         {
             context.DrawRectangle(pipeColor, null, new Rect(0, (bounds.Height - pw) / 2, bounds.Width, pw));
             context.DrawRectangle(pipeColor, null, new Rect(0, (bounds.Height - pw) / 2, bounds.Width, pw));
-            if (IsFlow)
+            if (IsFlow && !disconnected)
                 context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(0, bounds.Height / 2), new Point(bounds.Width, bounds.Height / 2));
                 context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(0, bounds.Height / 2), new Point(bounds.Width, bounds.Height / 2));
         }
         }
         else
         else
         {
         {
             context.DrawRectangle(pipeColor, null, new Rect((bounds.Width - pw) / 2, 0, pw, bounds.Height));
             context.DrawRectangle(pipeColor, null, new Rect((bounds.Width - pw) / 2, 0, pw, bounds.Height));
-            if (IsFlow)
+            if (IsFlow && !disconnected)
                 context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(bounds.Width / 2, 0), new Point(bounds.Width / 2, bounds.Height));
                 context.DrawLine(new Pen(waterColor, pw * 0.6, new DashStyle(s_dashPattern, _flowOffset)), new Point(bounds.Width / 2, 0), new Point(bounds.Width / 2, bounds.Height));
         }
         }
     }
     }

+ 36 - 11
src/YZWater.Avalonia/Controls/PumpControl.cs

@@ -38,22 +38,30 @@ public class PumpControl : Control
     {
     {
         base.OnAttachedToVisualTree(e);
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsRunning) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim();
     }
     }
 
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         StopAnim();
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsRunning) StartAnim();
+        InvalidateVisual();
+    }
 
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
     {
     {
         base.OnPropertyChanged(change);
         base.OnPropertyChanged(change);
-        if (change.Property == IsRunningProperty) { if (IsRunning) StartAnim(); else { StopAnim(); _rotationAngle = 0; } }
+        if (change.Property == IsRunningProperty) { if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim(); else { StopAnim(); _rotationAngle = 0; } }
     }
     }
 
 
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _rotationAngle += Frequency / 20; if (_rotationAngle >= 360) _rotationAngle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(30)); }
     private void StartAnim() { StopAnim(); _timer = DispatcherTimer.Run(() => { _rotationAngle += Frequency / 20; if (_rotationAngle >= 360) _rotationAngle -= 360; InvalidateVisual(); return true; }, TimeSpan.FromMilliseconds(30)); }
@@ -68,17 +76,25 @@ public class PumpControl : Control
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 - 5;
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 - 5;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 15;
         var radius = Math.Min(bounds.Width, bounds.Height) / 2 - 15;
+        var disconnected = !ConnectionStateHelper.IsConnected;
 
 
         var statusColor = (IsRunning ? RunningColor : StoppedColor) ?? (IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled);
         var statusColor = (IsRunning ? RunningColor : StoppedColor) ?? (IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled);
         var statusBrush = statusColor as SolidColorBrush ?? (IsRunning ? ThemeHelper.Success as SolidColorBrush : ThemeHelper.TextDisabled as SolidColorBrush);
         var statusBrush = statusColor as SolidColorBrush ?? (IsRunning ? ThemeHelper.Success as SolidColorBrush : ThemeHelper.TextDisabled as SolidColorBrush);
         var statusColorValue = (statusBrush as SolidColorBrush)?.Color ?? Colors.Gray;
         var statusColorValue = (statusBrush as SolidColorBrush)?.Color ?? Colors.Gray;
 
 
-        if (IsRunning) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.2), null, new Rect(cx - radius - 8, cy - radius - 8, (radius + 8) * 2, (radius + 8) * 2));
+        // 断线时整体颜色降级为灰色
+        if (disconnected)
+        {
+            statusColor = ConnectionStateHelper.DisconnectedGray;
+            statusColorValue = Colors.Gray;
+        }
+
+        if (IsRunning && !disconnected) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.2), null, new Rect(cx - radius - 8, cy - radius - 8, (radius + 8) * 2, (radius + 8) * 2));
 
 
-        context.DrawEllipse(null, new Pen(ThemeHelper.Border, 3), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
+        context.DrawEllipse(null, new Pen(disconnected ? ConnectionStateHelper.DisconnectedGray : ThemeHelper.Border, 3), new Rect(cx - radius, cy - radius, radius * 2, radius * 2));
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius + 3, cy - radius + 3, (radius - 3) * 2, (radius - 3) * 2));
         context.DrawEllipse(ThemeHelper.PanelBg, null, new Rect(cx - radius + 3, cy - radius + 3, (radius - 3) * 2, (radius - 3) * 2));
 
 
-        var bladeBrush = IsRunning ? new SolidColorBrush(statusColorValue, 200) : ThemeHelper.TextDisabled;
+        var bladeBrush = (IsRunning && !disconnected) ? new SolidColorBrush(statusColorValue, 200) : ConnectionStateHelper.DisconnectedGray;
         for (int i = 0; i < 6; i++)
         for (int i = 0; i < 6; i++)
         {
         {
             var angle = _rotationAngle + 60.0 * i;
             var angle = _rotationAngle + 60.0 * i;
@@ -92,24 +108,33 @@ public class PumpControl : Control
         }
         }
 
 
         var cr = radius * 0.25;
         var cr = radius * 0.25;
-        context.DrawEllipse(IsRunning ? statusColor : ThemeHelper.Border, null, new Rect(cx - cr, cy - cr, cr * 2, cr * 2));
-        context.DrawEllipse(new SolidColorBrush(Colors.White, 0.3), null, new Rect(cx - cr * 0.5, cy - cr * 0.5, cr, cr));
+        context.DrawEllipse(disconnected ? ConnectionStateHelper.DisconnectedGray : (IsRunning ? statusColor : ThemeHelper.Border), null, new Rect(cx - cr, cy - cr, cr * 2, cr * 2));
+        context.DrawEllipse(new SolidColorBrush(Colors.White, disconnected ? 0.1 : 0.3), null, new Rect(cx - cr * 0.5, cy - cr * 0.5, cr, cr));
 
 
-        var indColor = IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled;
-        if (IsRunning) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.3), null, new Rect(bounds.Width - 23, 7, 16, 16));
+        var indColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (IsRunning ? ThemeHelper.Success : ThemeHelper.TextDisabled);
+        if (IsRunning && !disconnected) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.3), null, new Rect(bounds.Width - 23, 7, 16, 16));
         context.DrawEllipse(indColor, null, new Rect(bounds.Width - 20, 10, 10, 10));
         context.DrawEllipse(indColor, null, new Rect(bounds.Width - 20, 10, 10, 10));
 
 
         if (!string.IsNullOrEmpty(Text))
         if (!string.IsNullOrEmpty(Text))
         {
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 11, ThemeHelper.TextPrimary);
+            var textColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 11, textColor);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 5));
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 5));
         }
         }
 
 
-        if (IsRunning)
+        if (IsRunning && !disconnected)
         {
         {
             var f = new FormattedText($"{Frequency:F0}Hz", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, statusColor);
             var f = new FormattedText($"{Frequency:F0}Hz", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, statusColor);
             context.DrawText(f, new Point(cx - f.Width / 2, cy + radius + 5));
             context.DrawText(f, new Point(cx - f.Width / 2, cy + radius + 5));
         }
         }
+
+        // 断线遮罩 + "通信中断" 标记
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+            var label = new FormattedText("通信中断", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typefaceNormal, 9, ConnectionStateHelper.DisconnectedWarning);
+            context.DrawText(label, new Point(cx - label.Width / 2, 2));
+        }
     }
     }
 
 
     protected override Size MeasureOverride(Size availableSize) => new Size(70, 80);
     protected override Size MeasureOverride(Size availableSize) => new Size(70, 80);

+ 18 - 7
src/YZWater.Avalonia/Controls/StatusCard.cs

@@ -23,9 +23,10 @@ public class StatusCard : Control
 
 
     static StatusCard() { AffectsRender<StatusCard>(TitleProperty, StatusProperty, IconProperty, IsActiveProperty, ActiveColorProperty, InactiveColorProperty); }
     static StatusCard() { AffectsRender<StatusCard>(TitleProperty, StatusProperty, IconProperty, IsActiveProperty, ActiveColorProperty, InactiveColorProperty); }
 
 
-    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); ThemeHelper.ThemeChanged += OnThemeChanged; }
-    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ThemeHelper.ThemeChanged -= OnThemeChanged; base.OnDetachedFromVisualTree(e); }
+    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); ThemeHelper.ThemeChanged += OnThemeChanged; ConnectionStateHelper.StateChanged += OnConnectionStateChanged; }
+    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { ThemeHelper.ThemeChanged -= OnThemeChanged; ConnectionStateHelper.StateChanged -= OnConnectionStateChanged; base.OnDetachedFromVisualTree(e); }
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
 
     private static readonly Typeface s_monoTypeface = new("Consolas, monospace");
     private static readonly Typeface s_monoTypeface = new("Consolas, monospace");
     private static readonly Typeface s_labelTypeface = new("Microsoft YaHei");
     private static readonly Typeface s_labelTypeface = new("Microsoft YaHei");
@@ -34,7 +35,8 @@ public class StatusCard : Control
     {
     {
         base.Render(context);
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
-        var statusColor = IsActive ? (ActiveColor ?? ThemeHelper.Success) : (InactiveColor ?? ThemeHelper.TextDisabled);
+        var disconnected = !ConnectionStateHelper.IsConnected;
+        var statusColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (IsActive ? (ActiveColor ?? ThemeHelper.Success) : (InactiveColor ?? ThemeHelper.TextDisabled));
         var statusBrush = statusColor as SolidColorBrush;
         var statusBrush = statusColor as SolidColorBrush;
         var statusColorValue = statusBrush?.Color ?? Colors.Gray;
         var statusColorValue = statusBrush?.Color ?? Colors.Gray;
 
 
@@ -42,17 +44,26 @@ public class StatusCard : Control
         context.DrawRectangle(statusColor, null, new Rect(0, 0, 3, bounds.Height));
         context.DrawRectangle(statusColor, null, new Rect(0, 0, 3, bounds.Height));
 
 
         var ledX = 12.0; var ledY = bounds.Height / 2 - 5;
         var ledX = 12.0; var ledY = bounds.Height / 2 - 5;
-        if (IsActive) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.3), null, new Rect(ledX - 3, ledY - 3, 16, 16));
+        if (IsActive && !disconnected) context.DrawEllipse(new SolidColorBrush(statusColorValue, 0.3), null, new Rect(ledX - 3, ledY - 3, 16, 16));
         context.DrawEllipse(statusColor, null, new Rect(ledX, ledY, 10, 10));
         context.DrawEllipse(statusColor, null, new Rect(ledX, ledY, 10, 10));
 
 
-        var iconText = new FormattedText(Icon, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 11, ThemeHelper.TextPrimary);
+        var iconColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+        var iconText = new FormattedText(Icon, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 11, iconColor);
         context.DrawText(iconText, new Point(28, 6));
         context.DrawText(iconText, new Point(28, 6));
 
 
-        var titleText = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_labelTypeface, 10, ThemeHelper.TextSecondary);
+        var titleColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextSecondary;
+        var titleText = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_labelTypeface, 10, titleColor);
         context.DrawText(titleText, new Point(28, 22));
         context.DrawText(titleText, new Point(28, 22));
 
 
-        var statusText = new FormattedText(Status, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 9, statusColor);
+        // 断线时状态显示"未知"而非最后已知状态
+        var displayStatus = disconnected ? "未知" : Status;
+        var statusText = new FormattedText(displayStatus, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_monoTypeface, 9, statusColor);
         context.DrawText(statusText, new Point(bounds.Width - statusText.Width - 10, bounds.Height / 2 - statusText.Height / 2));
         context.DrawText(statusText, new Point(bounds.Width - statusText.Width - 10, bounds.Height / 2 - statusText.Height / 2));
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
     }
 
 
     protected override Size MeasureOverride(Size availableSize) => new Size(230, 42);
     protected override Size MeasureOverride(Size availableSize) => new Size(230, 42);

+ 16 - 5
src/YZWater.Avalonia/Controls/ValveControl.cs

@@ -29,17 +29,21 @@ public class ValveControl : Control
     {
     {
         base.OnAttachedToVisualTree(e);
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
     }
     }
 
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
 
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+    private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
 
 
     public override void Render(DrawingContext context)
     public override void Render(DrawingContext context)
     {
     {
@@ -47,30 +51,37 @@ public class ValveControl : Control
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         var size = Math.Min(bounds.Width, bounds.Height) * 0.8;
         var size = Math.Min(bounds.Width, bounds.Height) * 0.8;
+        var disconnected = !ConnectionStateHelper.IsConnected;
 
 
-        var bg = BackgroundColor ?? ThemeHelper.Border;
+        var bg = disconnected ? ConnectionStateHelper.DisconnectedGray : (BackgroundColor ?? ThemeHelper.Border);
         context.DrawEllipse(bg, null, new Rect(cx - size / 2, cy - size / 2, size, size));
         context.DrawEllipse(bg, null, new Rect(cx - size / 2, cy - size / 2, size, size));
 
 
         var handleLen = size * 0.35;
         var handleLen = size * 0.35;
         var handleW = size * 0.15;
         var handleW = size * 0.15;
-        var vc = ValveColor ?? ThemeHelper.Warning;
+        var vc = disconnected ? ConnectionStateHelper.DisconnectedGray : (ValveColor ?? ThemeHelper.Warning);
         context.DrawRectangle(vc, null, new Rect(cx - handleW / 2, cy - handleLen, handleW, handleLen * 2));
         context.DrawRectangle(vc, null, new Rect(cx - handleW / 2, cy - handleLen, handleW, handleLen * 2));
 
 
         var indSize = size * 0.15;
         var indSize = size * 0.15;
-        var indColor = Status switch
+        var indColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (Status switch
         {
         {
             ValveStatus.Open => ThemeHelper.Success,
             ValveStatus.Open => ThemeHelper.Success,
             ValveStatus.Middle => ThemeHelper.Warning,
             ValveStatus.Middle => ThemeHelper.Warning,
             ValveStatus.Closed => ThemeHelper.Danger,
             ValveStatus.Closed => ThemeHelper.Danger,
             _ => ThemeHelper.TextDisabled
             _ => ThemeHelper.TextDisabled
-        };
+        });
         context.DrawEllipse(indColor, null, new Rect(cx - indSize / 2, cy - size / 2 - indSize - 5, indSize, indSize));
         context.DrawEllipse(indColor, null, new Rect(cx - indSize / 2, cy - size / 2 - indSize - 5, indSize, indSize));
 
 
         if (!string.IsNullOrEmpty(Text))
         if (!string.IsNullOrEmpty(Text))
         {
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, ThemeHelper.TextPrimary);
+            var textColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 10, textColor);
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
             context.DrawText(t, new Point(cx - t.Width / 2, bounds.Bottom - t.Height - 2));
         }
         }
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
     }
 
 
     protected override Size MeasureOverride(Size availableSize) => new Size(50, 70);
     protected override Size MeasureOverride(Size availableSize) => new Size(50, 70);

+ 19 - 6
src/YZWater.Avalonia/Controls/WaterTankControl.cs

@@ -28,46 +28,59 @@ public class WaterTankControl : Control
     {
     {
         base.OnAttachedToVisualTree(e);
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
         ThemeHelper.ThemeChanged += OnThemeChanged;
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
     }
     }
 
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         base.OnDetachedFromVisualTree(e);
         base.OnDetachedFromVisualTree(e);
     }
     }
 
 
     private void OnThemeChanged() => InvalidateVisual();
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
 
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
     private static readonly Typeface s_typeface = new("Microsoft YaHei", FontStyle.Normal, FontWeight.Bold);
+    private static readonly Typeface s_typefaceNormal = new("Microsoft YaHei");
 
 
     public override void Render(DrawingContext context)
     public override void Render(DrawingContext context)
     {
     {
         base.Render(context);
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var bounds = new Rect(Bounds.Size);
         var edgeWidth = 3.0;
         var edgeWidth = 3.0;
+        var disconnected = !ConnectionStateHelper.IsConnected;
 
 
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
         context.DrawRectangle(ThemeHelper.PanelBg, null, bounds);
-        var borderColor = BorderColor ?? ThemeHelper.Border;
+        var borderColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (BorderColor ?? ThemeHelper.Border);
         context.DrawRectangle(null, new Pen(borderColor, edgeWidth), bounds);
         context.DrawRectangle(null, new Pen(borderColor, edgeWidth), bounds);
 
 
         var waterHeight = (bounds.Height - edgeWidth * 2) * Math.Max(0, Math.Min(100, WaterLevel)) / 100.0;
         var waterHeight = (bounds.Height - edgeWidth * 2) * Math.Max(0, Math.Min(100, WaterLevel)) / 100.0;
         if (waterHeight > 0)
         if (waterHeight > 0)
         {
         {
-            var waterColor = WaterColor ?? ThemeHelper.Success;
-            var waterBrush = waterColor is SolidColorBrush sc ? new SolidColorBrush(sc.Color, 0.8) : waterColor;
+            var waterColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (WaterColor ?? ThemeHelper.Success);
+            var waterBrush = waterColor is SolidColorBrush sc ? new SolidColorBrush(sc.Color, disconnected ? 0.4 : 0.8) : waterColor;
             context.DrawRectangle(waterBrush, null, new Rect(bounds.X + edgeWidth, bounds.Y + bounds.Height - edgeWidth - waterHeight, bounds.Width - edgeWidth * 2, waterHeight));
             context.DrawRectangle(waterBrush, null, new Rect(bounds.X + edgeWidth, bounds.Y + bounds.Height - edgeWidth - waterHeight, bounds.Width - edgeWidth * 2, waterHeight));
         }
         }
 
 
         if (!string.IsNullOrEmpty(Text))
         if (!string.IsNullOrEmpty(Text))
         {
         {
-            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, ThemeHelper.TextPrimary);
+            var textColor = disconnected ? ConnectionStateHelper.DisconnectedText : ThemeHelper.TextPrimary;
+            var t = new FormattedText(Text, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 12, textColor);
             context.DrawText(t, new Point(bounds.X + (bounds.Width - t.Width) / 2, bounds.Y + edgeWidth + 5));
             context.DrawText(t, new Point(bounds.X + (bounds.Width - t.Width) / 2, bounds.Y + edgeWidth + 5));
         }
         }
 
 
-        var lt = new FormattedText($"{WaterLevel:F1}%", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 14, ThemeHelper.TextPrimary);
+        // 断线时液位显示 "---" 而非最后已知值
+        var levelText = disconnected ? "---.- %" : $"{WaterLevel:F1}%";
+        var lt = new FormattedText(levelText, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, s_typeface, 14, disconnected ? ConnectionStateHelper.DisconnectedWarning : ThemeHelper.TextPrimary);
         var bg = new Rect(bounds.X + (bounds.Width - lt.Width - 10) / 2, bounds.Y + bounds.Height - edgeWidth - lt.Height - 10, lt.Width + 10, lt.Height + 6);
         var bg = new Rect(bounds.X + (bounds.Width - lt.Width - 10) / 2, bounds.Y + bounds.Height - edgeWidth - lt.Height - 10, lt.Width + 10, lt.Height + 6);
-        context.DrawRectangle(new SolidColorBrush(Colors.Black, 0.5), null, bg);
+        context.DrawRectangle(new SolidColorBrush(Colors.Black, disconnected ? 0.7 : 0.5), null, bg);
         context.DrawText(lt, new Point(bg.X + 5, bg.Y + 3));
         context.DrawText(lt, new Point(bg.X + 5, bg.Y + 3));
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
     }
 
 
     protected override Size MeasureOverride(Size availableSize) => new Size(120, 160);
     protected override Size MeasureOverride(Size availableSize) => new Size(120, 160);

+ 12 - 0
src/YZWater.Avalonia/Views/MainWindow.axaml

@@ -151,6 +151,18 @@
             <!-- 视图内容(通过 CurrentView + DataTemplate 切换) -->
             <!-- 视图内容(通过 CurrentView + DataTemplate 切换) -->
             <ContentControl Content="{Binding CurrentView}"/>
             <ContentControl Content="{Binding CurrentView}"/>
 
 
+            <!-- ═══ PLC 通信中断横幅(ISA-101: 断线时全局可见警告) ═══ -->
+            <Border IsVisible="{Binding !IsPlcConnected}" ZIndex="50"
+                    VerticalAlignment="Top" HorizontalAlignment="Stretch"
+                    Background="#CCF39C12" Padding="12,6"
+                    IsHitTestVisible="False">
+                <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="8">
+                    <TextBlock Text="⚠" FontSize="16" VerticalAlignment="Center" Foreground="#2C3E50"/>
+                    <TextBlock Text="PLC 通信中断 — 当前数据不可信,所有控制操作已禁用"
+                               FontSize="13" FontWeight="Bold" VerticalAlignment="Center" Foreground="#2C3E50"/>
+                </StackPanel>
+            </Border>
+
             <!-- ═══ 用户管理对话框 ═══ -->
             <!-- ═══ 用户管理对话框 ═══ -->
             <Border IsVisible="{Binding ShowUserDialog}" ZIndex="100" Background="#CC000000">
             <Border IsVisible="{Binding ShowUserDialog}" ZIndex="100" Background="#CC000000">
                 <Border Background="{DynamicResource CardBgBrush}" CornerRadius="12" Padding="24"
                 <Border Background="{DynamicResource CardBgBrush}" CornerRadius="12" Padding="24"

+ 21 - 4
src/YZWater.Core/ViewModels/ProjectViewModel.cs

@@ -32,6 +32,9 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     /// <summary>是否处于中控模式</summary>
     /// <summary>是否处于中控模式</summary>
     [ObservableProperty] private bool _isCentralControlMode;
     [ObservableProperty] private bool _isCentralControlMode;
 
 
+    /// <summary>中控/屏控全局切换可用条件:PLC 已连接且非 Mock 模式</summary>
+    private bool CanToggleControlMode => Services.PlcService.IsConnected && !Services.MockPlcService.IsEnabled;
+
     /// <summary>全局中控/屏控切换地址(所有泵共用)</summary>
     /// <summary>全局中控/屏控切换地址(所有泵共用)</summary>
     public string? ModeSelectAddress => _profile.ModeSelectAddress;
     public string? ModeSelectAddress => _profile.ModeSelectAddress;
 
 
@@ -127,7 +130,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     /// 全局中控/屏控切换 — 所有泵共用同一地址
     /// 全局中控/屏控切换 — 所有泵共用同一地址
     /// 当前中控 → 写1(切屏控), 当前屏控 → 写0(切中控)
     /// 当前中控 → 写1(切屏控), 当前屏控 → 写0(切中控)
     /// </summary>
     /// </summary>
-    [RelayCommand]
+    [RelayCommand(CanExecute = nameof(CanToggleControlMode))]
     private async Task ToggleControlModeAsync()
     private async Task ToggleControlModeAsync()
     {
     {
         if (string.IsNullOrEmpty(ModeSelectAddress)) return;
         if (string.IsNullOrEmpty(ModeSelectAddress)) return;
@@ -536,6 +539,16 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
             "Mock" => "模拟模式(无 PLC 连接)",
             "Mock" => "模拟模式(无 PLC 连接)",
             _ => connected ? "PLC 已连接" : "PLC 未连接"
             _ => connected ? "PLC 已连接" : "PLC 未连接"
         };
         };
+
+        // 连接状态变化 → 刷新所有泵的命令可执行状态(禁用/启用控制按钮)
+        foreach (var pump in Pumps)
+        {
+            pump.StartCommand?.NotifyCanExecuteChanged();
+            pump.StopCommand?.NotifyCanExecuteChanged();
+            pump.TogglePumpCommand?.NotifyCanExecuteChanged();
+            pump.ToggleManualModeCommand?.NotifyCanExecuteChanged();
+        }
+        ToggleControlModeCommand?.NotifyCanExecuteChanged();
     }
     }
 
 
     // ─── 2D/3D 视图切换 ───
     // ─── 2D/3D 视图切换 ───
@@ -755,10 +768,14 @@ public partial class PumpItemViewModel : ObservableObject
     }
     }
 
 
     /// <summary>启动/停止按钮可用条件:中控模式 + 手动状态(PLC 反馈确认)</summary>
     /// <summary>启动/停止按钮可用条件:中控模式 + 手动状态(PLC 反馈确认)</summary>
-    private bool CanStartOrStop => IsCentralMode && !IsAutoMode;
+    /// <summary>启动/停止按钮可用条件:中控 + 手动 + PLC 已连接</summary>
+    private bool CanStartOrStop => IsCentralMode && !IsAutoMode && PlcService.IsConnected && !Services.MockPlcService.IsEnabled;
+
+    /// <summary>手动/自动切换按钮可用条件:需处于中控模式 + PLC 已连接</summary>
+    private bool CanSwitchMode => IsCentralMode && PlcService.IsConnected && !Services.MockPlcService.IsEnabled;
 
 
-    /// <summary>手动/自动切换按钮可用条件:需处于中控模式</summary>
-    private bool CanSwitchMode => IsCentralMode;
+    /// <summary>中控/屏控全局切换可用条件:PLC 已连接</summary>
+    private bool CanToggleControlMode => PlcService.IsConnected && !Services.MockPlcService.IsEnabled;
 
 
     /// <summary>
     /// <summary>
     /// 统一切换手动/自动模式 — 无论当前状态,点击即切到对端
     /// 统一切换手动/自动模式 — 无论当前状态,点击即切到对端

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

@@ -511,6 +511,35 @@ public partial class ViewAViewModel : TranslatableViewModelBase
     /// </summary>
     /// </summary>
     private void UpdateDeviceStatuses(PlcDataModel data)
     private void UpdateDeviceStatuses(PlcDataModel data)
     {
     {
+        // 断线时全部降级为灰色 + "未知"(ISA-101: 数据不可信时不显示最后已知状态)
+        var disconnected = !data.IsPlcConnected;
+        var gray = ThemeColors.TextDisabled;
+        var unknownStatus = "未知";
+
+        if (disconnected)
+        {
+            UpdateIfChanged(ref _pump1StatusColor, gray);
+            UpdateIfChanged(ref _pump2StatusColor, gray);
+            UpdateIfChanged(ref _pump3StatusColor, gray);
+            UpdateIfChanged(ref _pump4StatusColor, gray);
+            UpdateIfChanged(ref _pump5StatusColor, gray);
+            UpdateIfChanged(ref _fan1StatusColor, gray);
+            UpdateIfChanged(ref _fan2StatusColor, gray);
+            UpdateIfChanged(ref _valve1StatusColor, gray);
+            UpdateIfChanged(ref _valve2StatusColor, gray);
+            UpdateIfChanged(ref _valve3StatusColor, gray);
+            UpdateIfChanged(ref _valve4StatusColor, gray);
+
+            UpdateIfChanged(ref _pump1Status, unknownStatus);
+            UpdateIfChanged(ref _pump2Status, unknownStatus);
+            UpdateIfChanged(ref _pump3Status, unknownStatus);
+            UpdateIfChanged(ref _pump4Status, unknownStatus);
+            UpdateIfChanged(ref _pump5Status, unknownStatus);
+            UpdateIfChanged(ref _fan1Status, unknownStatus);
+            UpdateIfChanged(ref _fan2Status, unknownStatus);
+            return;
+        }
+
         // 使用 ThemeColors 常量替代硬编码颜色
         // 使用 ThemeColors 常量替代硬编码颜色
         // 仅在状态变化时更新(避免不必要的字符串分配和通知)
         // 仅在状态变化时更新(避免不必要的字符串分配和通知)
         UpdateIfChanged(ref _pump1StatusColor, data.Pump1Running ? ThemeColors.Success : (data.Pump1Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));
         UpdateIfChanged(ref _pump1StatusColor, data.Pump1Running ? ThemeColors.Success : (data.Pump1Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));