4 Incheckningar 0dbb4f51d9 ... dd332033ce

Upphovsman SHA1 Meddelande Datum
  yangkaixiang dd332033ce feat(discovery): 支持动态HTTP端口发现与连接 1 månad sedan
  yangkaixiang 1970b25045 feat(ui): 设备详情页增加配置修改标识与变更摘要 1 månad sedan
  yangkaixiang 29b1a02163 style(ui): DHCP模式下禁用自定义路由控件 1 månad sedan
  yangkaixiang 67794beb94 feat(network): 增加管理接口链路本地地址校验警告 1 månad sedan

+ 1 - 1
server/internal/config/config.go

@@ -6,7 +6,7 @@ import (
 	"net"
 )
 
-const ServerVersion = "20260511152102"
+const ServerVersion = "20260511180458"
 
 type Config struct {
 	HTTPHost         string

+ 1 - 0
server/internal/discovery/discovery.go

@@ -65,6 +65,7 @@ func (s *Server) Run(ctx context.Context) error {
 			ServerVersion:   device.ServerVersion,
 			MAC:             findMACByIP(s.cfg.MaintenanceIP),
 			LAN2IP:          s.cfg.MaintenanceIP,
+			HTTPPort:        s.cfg.HTTPPort,
 			AuthRequired:    true,
 		}
 		payload, _ := json.Marshal(resp)

+ 39 - 0
server/internal/httpserver/server.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"os"
 	"strings"
@@ -217,6 +218,7 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	result := s.validatorSvc.Validate(input)
+	s.addManagementAddressWarning(&result, input)
 	if !result.Valid {
 		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
 		return
@@ -503,6 +505,7 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 		return result
 	}
 	seen := make(map[string]struct{})
+	managementInterface := s.currentManagementInterface()
 	for _, input := range inputs {
 		name := strings.TrimSpace(input.Interface)
 		if name == "" {
@@ -522,6 +525,9 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 			continue
 		}
 		item := s.validatorSvc.Validate(input)
+		if name == managementInterface {
+			addManagementAddressWarning(&item, input)
+		}
 		if !item.Valid {
 			result.Valid = false
 		}
@@ -535,6 +541,39 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 	return result
 }
 
+func (s *Server) addManagementAddressWarning(result *model.ValidateResponse, input model.InterfaceConfig) {
+	managementInterface := s.currentManagementInterface()
+	if managementInterface == "" || strings.TrimSpace(input.Interface) != managementInterface {
+		return
+	}
+	addManagementAddressWarning(result, input)
+}
+
+func addManagementAddressWarning(result *model.ValidateResponse, input model.InterfaceConfig) {
+	if hasLinkLocalAddress(input) {
+		return
+	}
+	result.Warnings = append(result.Warnings, "直连接口未配置 169.254 链路本地地址,可能导致客户端无法通过维护链路发现或连接设备。")
+}
+
+func hasLinkLocalAddress(input model.InterfaceConfig) bool {
+	addresses := input.Addresses
+	if len(addresses) == 0 && strings.TrimSpace(input.IP) != "" {
+		addresses = []model.InterfaceAddressConfig{{IP: strings.TrimSpace(input.IP), Prefix: input.Prefix}}
+	}
+	for _, address := range addresses {
+		ip := net.ParseIP(strings.TrimSpace(address.IP))
+		if ip == nil {
+			continue
+		}
+		ipv4 := ip.To4()
+		if ipv4 != nil && ipv4[0] == 169 && ipv4[1] == 254 {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *Server) rollbackAppliedConfig(taskID string, filePath string, backupPath string, reason string) {
 	s.log.Warn("apply confirmation failed, restoring netplan file", "task_id", taskID, "file", filePath, "reason", reason)
 	_ = s.netplanSvc.Restore(filePath, backupPath)

+ 1 - 0
server/internal/model/types.go

@@ -56,6 +56,7 @@ type DiscoverResponse struct {
 	ServerVersion   string `json:"server_version"`
 	MAC             string `json:"mac"`
 	LAN2IP          string `json:"lan2_ip"`
+	HTTPPort        int    `json:"http_port"`
 	AuthRequired    bool   `json:"auth_required"`
 }
 

+ 0 - 4
server/internal/network/validator/validator.go

@@ -56,10 +56,6 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 		seenAddresses[key] = struct{}{}
 		mask := net.CIDRMask(address.Prefix, 32)
 		validNetworks = append(validNetworks, &net.IPNet{IP: ip.Mask(mask), Mask: mask})
-		ipv4 := ip.To4()
-		if ipv4[0] == 169 && ipv4[1] == 254 {
-			resp.Warnings = append(resp.Warnings, "目标接口使用的是链路本地地址,通常仅适合同链路通信。")
-		}
 	}
 	seenRoutes := make(map[string]struct{})
 	for _, route := range routes {

+ 86 - 23
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="启用">
@@ -224,19 +265,34 @@
                                                     </StackPanel>
                                                     <StackPanel Grid.Row="1" Margin="18,0,0,8" Orientation="Horizontal">
                                                         <TextBlock VerticalAlignment="Center" FontSize="12" Foreground="#6B7280" Text="自定义路由:" />
-                                                        <CheckBox Margin="8,0,0,0" VerticalContentAlignment="Center" IsChecked="{Binding CustomRoutesEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用" />
+                                                        <CheckBox Margin="8,0,0,0" VerticalContentAlignment="Center" IsChecked="{Binding CustomRoutesEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用">
+                                                            <CheckBox.Style>
+                                                                <Style TargetType="CheckBox">
+                                                                    <Setter Property="IsEnabled" Value="True" />
+                                                                    <Style.Triggers>
+                                                                        <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                            <Setter Property="IsEnabled" Value="False" />
+                                                                        </DataTrigger>
+                                                                    </Style.Triggers>
+                                                                </Style>
+                                                            </CheckBox.Style>
+                                                        </CheckBox>
                                                     </StackPanel>
-                                                     <DataGrid Grid.Row="2" Margin="18,8,0,0" ItemsSource="{Binding Routes}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
-                                                         <DataGrid.Style>
-                                                             <Style TargetType="DataGrid" BasedOn="{StaticResource ConfigDataGridStyle}">
-                                                                 <Setter Property="Visibility" Value="Collapsed" />
-                                                                <Style.Triggers>
-                                                                    <DataTrigger Binding="{Binding CustomRoutesEnabled}" Value="True">
-                                                                        <Setter Property="Visibility" Value="Visible" />
-                                                                    </DataTrigger>
-                                                                </Style.Triggers>
-                                                            </Style>
-                                                        </DataGrid.Style>
+                                                      <DataGrid Grid.Row="2" Margin="18,8,0,0" ItemsSource="{Binding Routes}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
+                                                          <DataGrid.Style>
+                                                              <Style TargetType="DataGrid" BasedOn="{StaticResource ConfigDataGridStyle}">
+                                                                  <Setter Property="Visibility" Value="Collapsed" />
+                                                                  <Setter Property="IsEnabled" Value="True" />
+                                                                 <Style.Triggers>
+                                                                     <DataTrigger Binding="{Binding CustomRoutesEnabled}" Value="True">
+                                                                         <Setter Property="Visibility" Value="Visible" />
+                                                                     </DataTrigger>
+                                                                     <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                         <Setter Property="IsEnabled" Value="False" />
+                                                                     </DataTrigger>
+                                                                 </Style.Triggers>
+                                                             </Style>
+                                                         </DataGrid.Style>
                                                          <DataGrid.Columns>
                                                              <DataGridTextColumn Header="目标网段" Binding="{Binding To, UpdateSourceTrigger=PropertyChanged}" ElementStyle="{StaticResource ConfigDataGridTextStyle}" EditingElementStyle="{StaticResource ConfigDataGridEditingTextStyle}" Width="*" />
                                                              <DataGridTextColumn Header="子网掩码" Binding="{Binding Mask, UpdateSourceTrigger=PropertyChanged}" ElementStyle="{StaticResource ConfigDataGridTextStyle}" EditingElementStyle="{StaticResource ConfigDataGridEditingTextStyle}" Width="*" />
@@ -254,10 +310,14 @@
                                                         <Button.Style>
                                                             <Style TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
                                                                 <Setter Property="Visibility" Value="Collapsed" />
+                                                                <Setter Property="IsEnabled" Value="True" />
                                                                 <Style.Triggers>
                                                                     <DataTrigger Binding="{Binding CustomRoutesEnabled}" Value="True">
                                                                         <Setter Property="Visibility" Value="Visible" />
                                                                     </DataTrigger>
+                                                                    <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                        <Setter Property="IsEnabled" Value="False" />
+                                                                    </DataTrigger>
                                                                 </Style.Triggers>
                                                             </Style>
                                                         </Button.Style>
@@ -265,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();
+        }
     }
 }

+ 2 - 1
windows/NetworkTool.Client/MainWindow.xaml.cs

@@ -265,7 +265,8 @@ public partial class MainWindow : Window
 
         try
         {
-            var baseAddress = $"http://{device.Lan2Ip}:48888";
+            var httpPort = device.HttpPort > 0 ? device.HttpPort : 48888;
+            var baseAddress = $"http://{device.Lan2Ip}:{httpPort}";
             var result = await _serverApiService.CheckHealthAsync(baseAddress, password, selectedAdapter?.IPv4Address ?? string.Empty);
             if (result.Success)
             {

+ 3 - 0
windows/NetworkTool.Client/Models/DiscoveredDevice.cs

@@ -19,6 +19,9 @@ public sealed class DiscoveredDevice
     [JsonPropertyName("lan2_ip")]
     public required string Lan2Ip { get; init; }
 
+    [JsonPropertyName("http_port")]
+    public required int HttpPort { get; init; }
+
     [JsonPropertyName("auth_required")]
     public required bool AuthRequired { get; init; }
 }

+ 4 - 0
windows/NetworkTool.Client/Services/DiscoveryService.cs

@@ -70,6 +70,7 @@ public sealed class DiscoveryService
                     ServerVersion = response.ServerVersion ?? string.Empty,
                     Mac = mac ?? string.Empty,
                     Lan2Ip = response.Lan2Ip,
+                    HttpPort = response.HttpPort,
                     AuthRequired = response.AuthRequired,
                 };
             }
@@ -120,6 +121,9 @@ public sealed class DiscoveryService
         [JsonPropertyName("lan2_ip")]
         public string? Lan2Ip { get; set; }
 
+        [JsonPropertyName("http_port")]
+        public int HttpPort { get; set; }
+
         [JsonPropertyName("auth_required")]
         public bool AuthRequired { get; set; }
     }