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

feat(ui): 设备详情页增加配置修改标识与变更摘要

- 新增“已修改”徽章高亮显示 IP/网关/DNS 变动
- 应用配置前展示具体变更项摘要,提升操作透明度
yangkaixiang пре 1 месец
родитељ
комит
1970b25045

+ 55 - 11
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -64,6 +64,35 @@
             <Setter Property="Padding" Value="8,0" />
             <Setter Property="BorderThickness" Value="0" />
         </Style>
+
+        <Style x:Key="ModifiedSectionBorderStyle" TargetType="Border">
+            <Setter Property="Background" Value="White" />
+            <Setter Property="BorderBrush" Value="#E2E8F0" />
+            <Setter Property="BorderThickness" Value="1" />
+            <Style.Triggers>
+                <DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
+                    <Setter Property="Background" Value="#FFFBEB" />
+                    <Setter Property="BorderBrush" Value="#F59E0B" />
+                </DataTrigger>
+            </Style.Triggers>
+        </Style>
+
+        <Style x:Key="ModifiedBadgeStyle" TargetType="TextBlock">
+            <Setter Property="Margin" Value="8,0,0,0" />
+            <Setter Property="Padding" Value="6,1" />
+            <Setter Property="VerticalAlignment" Value="Center" />
+            <Setter Property="FontSize" Value="11" />
+            <Setter Property="FontWeight" Value="SemiBold" />
+            <Setter Property="Foreground" Value="#92400E" />
+            <Setter Property="Background" Value="#FEF3C7" />
+            <Setter Property="Text" Value="已修改" />
+            <Setter Property="Visibility" Value="Collapsed" />
+            <Style.Triggers>
+                <DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
+                    <Setter Property="Visibility" Value="Visible" />
+                </DataTrigger>
+            </Style.Triggers>
+        </Style>
     </Window.Resources>
     <Grid Background="#F5F7FB">
         <ScrollViewer x:Name="ContentScrollViewer"
@@ -94,10 +123,16 @@
                         <ColumnDefinition Width="Auto" />
                     </Grid.ColumnDefinitions>
 
-                    <TextBlock FontSize="16"
-                               FontWeight="SemiBold"
-                               Foreground="#0F172A"
-                               Text="网络配置" />
+                    <StackPanel Orientation="Horizontal">
+                        <TextBlock FontSize="16"
+                                   FontWeight="SemiBold"
+                                   Foreground="#0F172A"
+                                   Text="网络配置" />
+                        <TextBlock Margin="8,2,0,0"
+                                   FontSize="12"
+                                   Foreground="#6B7280"
+                                   Text="{Binding ElementName=InterfacesItemsControl, Path=Items.Count, StringFormat=({0} 个接口)}" />
+                    </StackPanel>
 
                     <Button x:Name="ReloadInterfaceConfigButton"
                             Grid.Column="1"
@@ -135,14 +170,17 @@
                                                 <RowDefinition Height="Auto" />
                                             </Grid.RowDefinitions>
 
-                                             <Border Padding="12" Background="White" BorderBrush="#E2E8F0" BorderThickness="1" CornerRadius="10">
-                                                 <Grid>
+                                             <Border Padding="12" CornerRadius="10" Style="{StaticResource ModifiedSectionBorderStyle}" Tag="{Binding IsAddressModified}">
+                                                  <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
                                                         <RowDefinition Height="*" />
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
-                                                     <TextBlock Style="{StaticResource SectionTitleStyle}" Text="IP 地址" />
+                                                     <StackPanel Orientation="Horizontal">
+                                                         <TextBlock Style="{StaticResource SectionTitleStyle}" Text="IP 地址" />
+                                                         <TextBlock Style="{StaticResource ModifiedBadgeStyle}" Tag="{Binding IsAddressModified}" />
+                                                     </StackPanel>
                                                      <DataGrid Grid.Row="1" Margin="18,10,0,0" ItemsSource="{Binding Addresses}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
                                                          <DataGrid.Style>
                                                              <Style TargetType="DataGrid" BasedOn="{StaticResource ConfigDataGridStyle}">
@@ -181,7 +219,7 @@
                                                 </Grid>
                                             </Border>
 
-                                             <Border Grid.Row="1" Margin="0,12,0,0" Padding="12" Background="White" BorderBrush="#E2E8F0" BorderThickness="1" CornerRadius="10">
+                                             <Border Grid.Row="1" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource ModifiedSectionBorderStyle}" Tag="{Binding IsGatewayModified}">
                                                  <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
@@ -190,7 +228,10 @@
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
                                                     <StackPanel>
-                                                         <TextBlock Style="{StaticResource SectionTitleStyle}" Text="网关" />
+                                                         <StackPanel Orientation="Horizontal">
+                                                             <TextBlock Style="{StaticResource SectionTitleStyle}" Text="网关" />
+                                                             <TextBlock Style="{StaticResource ModifiedBadgeStyle}" Tag="{Binding IsGatewayModified}" />
+                                                         </StackPanel>
                                                         <StackPanel Margin="18,8,0,12" Orientation="Horizontal">
                                                             <TextBlock VerticalAlignment="Center" FontSize="12" Foreground="#6B7280" Text="默认网关:" />
                                                             <CheckBox Margin="8,0,0,0" VerticalContentAlignment="Center" IsChecked="{Binding DefaultGatewayEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用">
@@ -284,14 +325,17 @@
                                                 </Grid>
                                             </Border>
 
-                                             <Border Grid.Row="2" Margin="0,12,0,0" Padding="12" Background="White" BorderBrush="#E2E8F0" BorderThickness="1" CornerRadius="10">
+                                             <Border Grid.Row="2" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource ModifiedSectionBorderStyle}" Tag="{Binding IsDnsModified}">
                                                  <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
                                                         <RowDefinition Height="*" />
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
-                                                     <TextBlock Style="{StaticResource SectionTitleStyle}" Text="DNS" />
+                                                     <StackPanel Orientation="Horizontal">
+                                                         <TextBlock Style="{StaticResource SectionTitleStyle}" Text="DNS" />
+                                                         <TextBlock Style="{StaticResource ModifiedBadgeStyle}" Tag="{Binding IsDnsModified}" />
+                                                     </StackPanel>
                                                      <DataGrid Grid.Row="1" Margin="18,10,0,0" Style="{StaticResource ConfigDataGridStyle}" ItemsSource="{Binding Dns}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
                                                          <DataGrid.Columns>
                                                              <DataGridTextColumn Header="DNS 地址" Binding="{Binding Address, UpdateSourceTrigger=PropertyChanged}" ElementStyle="{StaticResource ConfigDataGridTextStyle}" EditingElementStyle="{StaticResource ConfigDataGridEditingTextStyle}" Width="*" />

+ 180 - 26
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -152,6 +152,7 @@ public partial class DeviceDetailsWindow : Window
                     editor.Dns.Add(new EditableDns(editor) { Address = dns });
                 }
             }
+            editor.CaptureOriginalConfiguration();
             _suppressConfigChangeHandling = false;
             UpdateButtonStates();
         }
@@ -253,7 +254,10 @@ public partial class DeviceDetailsWindow : Window
             return;
         }
 
-        var confirmMessage = "将要一次性应用以下接口配置:\n\n" + FormatConfigSummary(request) + "\n\n请确认是否继续。";
+        var changeSummary = FormatChangeSummary();
+        var confirmMessage = string.IsNullOrWhiteSpace(changeSummary)
+            ? "将要一次性应用以下接口配置:\n\n" + FormatConfigSummary(request) + "\n\n请确认是否继续。"
+            : "将要一次性应用以下已修改配置:\n\n" + changeSummary + "\n\n完整目标配置:\n\n" + FormatConfigSummary(request) + "\n\n请确认是否继续。";
         if (MessageBox.Show(this, confirmMessage, "确认应用配置", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
         {
             return;
@@ -582,6 +586,12 @@ public partial class DeviceDetailsWindow : Window
             $"DNS:{(item.Dns.Count == 0 ? "无" : string.Join(", ", item.Dns))}"));
     }
 
+    private string FormatChangeSummary()
+    {
+        var changed = _interfaces.Where(item => item.HasChanges).Select(item => item.FormatChangeSummary()).Where(item => item != string.Empty);
+        return string.Join(Environment.NewLine + Environment.NewLine, changed);
+    }
+
     private static EditableRoute CreateEditableRoute(InterfaceEditor owner, RemoteInterfaceRouteConfig route)
     {
         var to = route.To.Trim();
@@ -846,15 +856,7 @@ public partial class DeviceDetailsWindow : Window
 
     private void ConfigInputChanged_OnChanged(object sender, TextChangedEventArgs e)
     {
-        if (_suppressConfigChangeHandling)
-        {
-            return;
-        }
-
-        _configValidated = false;
-        _configDirty = true;
-        SetConfigStateMessage("配置已修改,需重新校验后才能应用。", true);
-        UpdateButtonStates();
+        MarkConfigChanged();
     }
 
     private void ConfigModeChanged_OnChanged(object sender, RoutedEventArgs e)
@@ -865,10 +867,7 @@ public partial class DeviceDetailsWindow : Window
             return;
         }
 
-        _configValidated = false;
-        _configDirty = true;
-        SetConfigStateMessage("配置已修改,需重新校验后才能应用。", true);
-        UpdateButtonStates();
+        MarkConfigChanged();
     }
 
     private void GatewayOrRouteModeChanged_OnChanged(object sender, RoutedEventArgs e)
@@ -879,10 +878,7 @@ public partial class DeviceDetailsWindow : Window
             return;
         }
 
-        _configValidated = false;
-        _configDirty = true;
-        SetConfigStateMessage("配置已修改,需重新校验后才能应用。", true);
-        UpdateButtonStates();
+        MarkConfigChanged();
     }
 
     private void ConfigGrid_OnCellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
@@ -980,11 +976,29 @@ public partial class DeviceDetailsWindow : Window
         }
 
         _configValidated = false;
-        _configDirty = true;
-        SetConfigStateMessage("配置已修改,需重新校验后才能应用。", true);
+        RefreshChangeState();
+        SetConfigStateMessage(
+            _configDirty ? $"配置已修改:{FormatChangedFields()}。需重新校验后才能应用。" : "配置未修改。",
+            _configDirty);
         UpdateButtonStates();
     }
 
+    private void RefreshChangeState()
+    {
+        foreach (var editor in _interfaces)
+        {
+            editor.NotifyChangeState();
+        }
+
+        _configDirty = _interfaces.Any(item => item.HasChanges);
+    }
+
+    private string FormatChangedFields()
+    {
+        var fields = _interfaces.Where(item => item.HasChanges).Select(item => $"{item.SystemName} 的 {item.ChangedFieldsText}");
+        return string.Join(";", fields);
+    }
+
     private void SetConfigStateMessage(string message, bool requiresAttention)
     {
         ConfigStateTextBlock.Text = message;
@@ -1129,6 +1143,10 @@ public partial class DeviceDetailsWindow : Window
         private bool _defaultGatewayEnabled;
         private bool _customRoutesEnabled;
         private string _defaultGateway = string.Empty;
+        private bool _originalDhcp4;
+        private string[] _originalAddressKeys = [];
+        private string[] _originalGatewayKeys = [];
+        private string[] _originalDnsKeys = [];
 
         public InterfaceEditor(RemoteInterfaceInfo info)
         {
@@ -1143,6 +1161,16 @@ public partial class DeviceDetailsWindow : Window
         public ObservableCollection<EditableAddress> Addresses { get; } = [];
         public ObservableCollection<EditableRoute> Routes { get; } = [];
         public ObservableCollection<EditableDns> Dns { get; } = [];
+        public bool IsAddressModified => _originalDhcp4 != Dhcp4 || !GetAddressKeys().SequenceEqual(_originalAddressKeys);
+        public bool IsGatewayModified => _originalDhcp4 != Dhcp4 || !GetGatewayKeys().SequenceEqual(_originalGatewayKeys);
+        public bool IsDnsModified => !GetDnsKeys().SequenceEqual(_originalDnsKeys);
+        public bool HasChanges => IsAddressModified || IsGatewayModified || IsDnsModified;
+        public string ChangedFieldsText => string.Join("、", new[]
+        {
+            IsAddressModified ? "IP 地址" : string.Empty,
+            IsGatewayModified ? "网关" : string.Empty,
+            IsDnsModified ? "DNS" : string.Empty,
+        }.Where(item => item != string.Empty));
 
         public bool Dhcp4
         {
@@ -1170,6 +1198,78 @@ public partial class DeviceDetailsWindow : Window
 
         public event PropertyChangedEventHandler? PropertyChanged;
 
+        public void CaptureOriginalConfiguration()
+        {
+            _originalDhcp4 = Dhcp4;
+            _originalAddressKeys = GetAddressKeys();
+            _originalGatewayKeys = GetGatewayKeys();
+            _originalDnsKeys = GetDnsKeys();
+            NotifyChangeState();
+        }
+
+        public void NotifyChangeState()
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsAddressModified)));
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsGatewayModified)));
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDnsModified)));
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasChanges)));
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ChangedFieldsText)));
+        }
+
+        public string FormatChangeSummary()
+        {
+            var lines = new List<string> { $"接口:{SystemName}" };
+            if (IsAddressModified)
+            {
+                lines.Add($"IP:{FormatKeys(_originalAddressKeys)} -> {FormatKeys(GetAddressKeys())}");
+            }
+            if (IsGatewayModified)
+            {
+                lines.Add($"网关:{FormatKeys(_originalGatewayKeys)} -> {FormatKeys(GetGatewayKeys())}");
+            }
+            if (IsDnsModified)
+            {
+                lines.Add($"DNS:{FormatKeys(_originalDnsKeys)} -> {FormatKeys(GetDnsKeys())}");
+            }
+
+            return lines.Count == 1 ? string.Empty : string.Join(Environment.NewLine, lines);
+        }
+
+        private string[] GetAddressKeys()
+        {
+            return Addresses
+                .Select(item => $"{item.IP.Trim()}/{item.Mask.Trim()}")
+                .Where(item => item != "/")
+                .ToArray();
+        }
+
+        private string[] GetGatewayKeys()
+        {
+            var keys = new List<string>();
+            if (DefaultGatewayEnabled || !string.IsNullOrWhiteSpace(DefaultGateway))
+            {
+                keys.Add($"default via {DefaultGateway.Trim()}");
+            }
+            if (CustomRoutesEnabled)
+            {
+                keys.AddRange(Routes
+                    .Select(item => $"{item.To.Trim()}/{item.Mask.Trim()} via {item.Via.Trim()}")
+                    .Where(item => item != "/ via"));
+            }
+
+            return keys.ToArray();
+        }
+
+        private string[] GetDnsKeys()
+        {
+            return Dns.Select(item => item.Address.Trim()).Where(item => item != string.Empty).ToArray();
+        }
+
+        private static string FormatKeys(IReadOnlyList<string> keys)
+        {
+            return keys.Count == 0 ? "无" : string.Join(", ", keys);
+        }
+
         private void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
         {
             if (EqualityComparer<T>.Default.Equals(field, value))
@@ -1178,6 +1278,7 @@ public partial class DeviceDetailsWindow : Window
             }
             field = value;
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+            NotifyChangeState();
         }
     }
 
@@ -1215,30 +1316,83 @@ public partial class DeviceDetailsWindow : Window
             }
             field = value;
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+            Owner.NotifyChangeState();
         }
     }
 
-    private sealed class EditableRoute
+    private sealed class EditableRoute : INotifyPropertyChanged
     {
+        private string _to = string.Empty;
+        private string _mask = string.Empty;
+        private string _via = string.Empty;
+
         public EditableRoute(InterfaceEditor owner)
         {
             Owner = owner;
         }
 
         public InterfaceEditor Owner { get; }
-        public string To { get; set; } = string.Empty;
-        public string Mask { get; set; } = string.Empty;
-        public string Via { get; set; } = string.Empty;
+
+        public string To
+        {
+            get => _to;
+            set => SetField(ref _to, value);
+        }
+
+        public string Mask
+        {
+            get => _mask;
+            set => SetField(ref _mask, value);
+        }
+
+        public string Via
+        {
+            get => _via;
+            set => SetField(ref _via, value);
+        }
+
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        private void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
+        {
+            if (EqualityComparer<T>.Default.Equals(field, value))
+            {
+                return;
+            }
+            field = value;
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+            Owner.NotifyChangeState();
+        }
     }
 
-    private sealed class EditableDns
+    private sealed class EditableDns : INotifyPropertyChanged
     {
+        private string _address = string.Empty;
+
         public EditableDns(InterfaceEditor owner)
         {
             Owner = owner;
         }
 
         public InterfaceEditor Owner { get; }
-        public string Address { get; set; } = string.Empty;
+
+        public string Address
+        {
+            get => _address;
+            set => SetField(ref _address, value);
+        }
+
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        private void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
+        {
+            if (EqualityComparer<T>.Default.Equals(field, value))
+            {
+                return;
+            }
+            field = value;
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+            Owner.NotifyChangeState();
+        }
     }
 }