Forráskód Böngészése

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

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

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

@@ -226,6 +226,16 @@ public partial class App : Application
         }
 
         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();
         _dataLogger = new DataLoggingService(PlcPollingService.Instance);
         _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);
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsRunning) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim();
     }
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         base.OnDetachedFromVisualTree(e);
     }
 
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsRunning) StartAnim();
+        InvalidateVisual();
+    }
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs 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 StopAnim() { _timer?.Dispose(); _timer = null; }
 
     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)
     {
@@ -55,9 +64,10 @@ public class FanControl : Control
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         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++)
         {
@@ -72,9 +82,17 @@ public class FanControl : Control
 
         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));
         }
+
+        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);

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

@@ -32,16 +32,20 @@ public class GaugeControl : Control
     private FormattedText? _cachedValue;
     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()
     {
-        // 主题切换时清除 FormattedText 缓存(颜色变了)
         _cachedTitle = null; _cachedTitleText = null;
         _cachedUnit = null; _cachedUnitText = null;
         _cachedValue = null; _cachedValueText = null;
         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_typefaceNormal = new("Microsoft YaHei");
@@ -52,14 +56,31 @@ public class GaugeControl : Control
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 + 10;
         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));
-        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++)
         {
@@ -67,40 +88,44 @@ public class GaugeControl : Control
             var rad = angle * Math.PI / 180;
             var isMajor = i % 5 == 0;
             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, 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;
         }
         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)
         {
-            _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;
         }
         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)
         {
-            _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;
         }
         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)

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

@@ -32,22 +32,30 @@ public class PipeLineControl : Control
     {
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsFlow) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsFlow && ConnectionStateHelper.IsConnected) StartAnim();
     }
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         base.OnDetachedFromVisualTree(e);
     }
 
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsFlow) StartAnim();
+        InvalidateVisual();
+    }
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs 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)); }
@@ -58,19 +66,20 @@ public class PipeLineControl : Control
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         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)
         {
             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));
         }
         else
         {
             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));
         }
     }

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

@@ -38,22 +38,30 @@ public class PumpControl : Control
     {
         base.OnAttachedToVisualTree(e);
         ThemeHelper.ThemeChanged += OnThemeChanged;
-        if (IsRunning) StartAnim();
+        ConnectionStateHelper.StateChanged += OnConnectionStateChanged;
+        if (IsRunning && ConnectionStateHelper.IsConnected) StartAnim();
     }
 
     protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
     {
         ThemeHelper.ThemeChanged -= OnThemeChanged;
+        ConnectionStateHelper.StateChanged -= OnConnectionStateChanged;
         StopAnim();
         base.OnDetachedFromVisualTree(e);
     }
 
     private void OnThemeChanged() => InvalidateVisual();
+    private void OnConnectionStateChanged(bool connected)
+    {
+        if (!connected) StopAnim();
+        else if (IsRunning) StartAnim();
+        InvalidateVisual();
+    }
 
     protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs 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)); }
@@ -68,17 +76,25 @@ public class PumpControl : Control
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2 - 5;
         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 statusBrush = statusColor as SolidColorBrush ?? (IsRunning ? ThemeHelper.Success as SolidColorBrush : ThemeHelper.TextDisabled as SolidColorBrush);
         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));
 
-        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++)
         {
             var angle = _rotationAngle + 60.0 * i;
@@ -92,24 +108,33 @@ public class PumpControl : Control
         }
 
         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));
 
         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));
         }
 
-        if (IsRunning)
+        if (IsRunning && !disconnected)
         {
             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));
         }
+
+        // 断线遮罩 + "通信中断" 标记
+        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);

+ 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); }
 
-    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 OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
     private static readonly Typeface s_monoTypeface = new("Consolas, monospace");
     private static readonly Typeface s_labelTypeface = new("Microsoft YaHei");
@@ -34,7 +35,8 @@ public class StatusCard : Control
     {
         base.Render(context);
         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 statusColorValue = statusBrush?.Color ?? Colors.Gray;
 
@@ -42,17 +44,26 @@ public class StatusCard : Control
         context.DrawRectangle(statusColor, null, new Rect(0, 0, 3, bounds.Height));
 
         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));
 
-        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));
 
-        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));
 
-        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));
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
 
     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);
         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 OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
     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)
     {
@@ -47,30 +51,37 @@ public class ValveControl : Control
         var bounds = new Rect(Bounds.Size);
         var cx = bounds.Width / 2; var cy = bounds.Height / 2;
         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));
 
         var handleLen = size * 0.35;
         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));
 
         var indSize = size * 0.15;
-        var indColor = Status switch
+        var indColor = disconnected ? ConnectionStateHelper.DisconnectedGray : (Status switch
         {
             ValveStatus.Open => ThemeHelper.Success,
             ValveStatus.Middle => ThemeHelper.Warning,
             ValveStatus.Closed => ThemeHelper.Danger,
             _ => ThemeHelper.TextDisabled
-        };
+        });
         context.DrawEllipse(indColor, null, new Rect(cx - indSize / 2, cy - size / 2 - indSize - 5, indSize, indSize));
 
         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));
         }
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
 
     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);
         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 OnConnectionStateChanged(bool connected) => InvalidateVisual();
 
     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)
     {
         base.Render(context);
         var bounds = new Rect(Bounds.Size);
         var edgeWidth = 3.0;
+        var disconnected = !ConnectionStateHelper.IsConnected;
 
         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);
 
         var waterHeight = (bounds.Height - edgeWidth * 2) * Math.Max(0, Math.Min(100, WaterLevel)) / 100.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));
         }
 
         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));
         }
 
-        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);
-        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));
+
+        if (disconnected)
+        {
+            context.DrawRectangle(ConnectionStateHelper.OverlayBrush, null, bounds);
+        }
     }
 
     protected override Size MeasureOverride(Size availableSize) => new Size(120, 160);

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

@@ -151,6 +151,18 @@
             <!-- 视图内容(通过 CurrentView + DataTemplate 切换) -->
             <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 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>
     [ObservableProperty] private bool _isCentralControlMode;
 
+    /// <summary>中控/屏控全局切换可用条件:PLC 已连接且非 Mock 模式</summary>
+    private bool CanToggleControlMode => Services.PlcService.IsConnected && !Services.MockPlcService.IsEnabled;
+
     /// <summary>全局中控/屏控切换地址(所有泵共用)</summary>
     public string? ModeSelectAddress => _profile.ModeSelectAddress;
 
@@ -127,7 +130,7 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
     /// 全局中控/屏控切换 — 所有泵共用同一地址
     /// 当前中控 → 写1(切屏控), 当前屏控 → 写0(切中控)
     /// </summary>
-    [RelayCommand]
+    [RelayCommand(CanExecute = nameof(CanToggleControlMode))]
     private async Task ToggleControlModeAsync()
     {
         if (string.IsNullOrEmpty(ModeSelectAddress)) return;
@@ -536,6 +539,16 @@ public partial class ProjectViewModel : ObservableObject, IDisposable
             "Mock" => "模拟模式(无 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 视图切换 ───
@@ -755,10 +768,14 @@ public partial class PumpItemViewModel : ObservableObject
     }
 
     /// <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>
     /// 统一切换手动/自动模式 — 无论当前状态,点击即切到对端

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

@@ -511,6 +511,35 @@ public partial class ViewAViewModel : TranslatableViewModelBase
     /// </summary>
     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 常量替代硬编码颜色
         // 仅在状态变化时更新(避免不必要的字符串分配和通知)
         UpdateIfChanged(ref _pump1StatusColor, data.Pump1Running ? ThemeColors.Success : (data.Pump1Fault ? ThemeColors.Danger : ThemeColors.TextDisabled));