Procházet zdrojové kódy

refactor(ui): 重构接口选择为标签页并统一命名逻辑

后端移除硬编码名称,前端改用标签页展示配置
yangkaixiang před 1 měsícem
rodič
revize
632d9d7b10

+ 1 - 5
server/internal/network/interfaces/interfaces.go

@@ -30,11 +30,10 @@ func (s *Service) List() (model.InterfacesResponse, error) {
 	management := s.detectManagement(items)
 	for i := range items {
 		items[i].IsManagement = items[i].SystemName == management
+		items[i].Name = items[i].SystemName
 		if items[i].IsManagement {
-			items[i].Name = "LAN2"
 			items[i].Role = "control"
 		} else {
-			items[i].Name = "候选接口"
 			items[i].Role = "business"
 		}
 	}
@@ -42,9 +41,6 @@ func (s *Service) List() (model.InterfacesResponse, error) {
 	suggested, requiresSelection := suggestTarget(items, management)
 	for i := range items {
 		items[i].IsSuggestedTarget = items[i].SystemName == suggested
-		if items[i].IsSuggestedTarget {
-			items[i].Name = "LAN1"
-		}
 	}
 
 	return model.InterfacesResponse{

+ 231 - 95
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -55,6 +55,9 @@
                     <Grid.RowDefinitions>
                         <RowDefinition Height="Auto" />
                         <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
                     </Grid.RowDefinitions>
                     <Grid.ColumnDefinitions>
                         <ColumnDefinition Width="*" />
@@ -64,74 +67,207 @@
                     <TextBlock FontSize="13"
                                FontWeight="SemiBold"
                                Foreground="#111827"
-                               Text="目标接口" />
-
-                    <ComboBox x:Name="RemoteTargetInterfaceComboBox"
-                              Grid.Row="1"
-                              Margin="0,10,0,0"
-                              MinHeight="36"
-                              VerticalContentAlignment="Center"
-                              DisplayMemberPath="DisplayName"
-                              SelectionChanged="RemoteTargetInterfaceComboBox_OnSelectionChanged" />
+                               Text="网络配置" />
 
                     <Button x:Name="ReloadInterfaceConfigButton"
-                            Grid.Row="1"
                             Grid.Column="1"
-                            Margin="14,10,0,0"
-                            MinHeight="36"
-                            Padding="14,0"
+                            MinHeight="32"
+                            Padding="12,0"
                             VerticalAlignment="Center"
                             Click="ReloadInterfaceConfigButton_OnClick"
-                            Content="1. 读取当前配置" />
-                </Grid>
-            </Border>
+                            Content="刷新接口配置" />
 
-            <Border Grid.Row="2" Margin="0,12,0,0" Padding="14" Background="White" CornerRadius="10">
-                <Grid>
-                    <Grid.RowDefinitions>
-                        <RowDefinition Height="Auto" />
-                        <RowDefinition Height="Auto" />
-                        <RowDefinition Height="Auto" />
-                        <RowDefinition Height="Auto" />
-                    </Grid.RowDefinitions>
+                    <ListBox x:Name="RemoteTargetInterfaceTabControl"
+                             Grid.Row="1"
+                             Grid.ColumnSpan="2"
+                             Margin="0,12,0,0"
+                             MinHeight="54"
+                             Padding="4"
+                             Background="#F8FAFC"
+                             BorderBrush="#D1D5DB"
+                             BorderThickness="1"
+                             ScrollViewer.HorizontalScrollBarVisibility="Auto"
+                             ScrollViewer.VerticalScrollBarVisibility="Disabled"
+                             SelectionChanged="RemoteTargetInterfaceTabControl_OnSelectionChanged">
+                        <ListBox.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <StackPanel Orientation="Horizontal" />
+                            </ItemsPanelTemplate>
+                        </ListBox.ItemsPanel>
+                        <ListBox.ItemContainerStyle>
+                            <Style TargetType="ListBoxItem">
+                                <Setter Property="Margin" Value="0,0,4,0" />
+                                <Setter Property="Padding" Value="0" />
+                                <Setter Property="Background" Value="Transparent" />
+                                <Setter Property="BorderBrush" Value="Transparent" />
+                                <Setter Property="BorderThickness" Value="0" />
+                                <Setter Property="MinWidth" Value="126" />
+                                <Setter Property="HorizontalContentAlignment" Value="Center" />
+                                <Setter Property="VerticalContentAlignment" Value="Center" />
+                                <Setter Property="Template">
+                                    <Setter.Value>
+                                        <ControlTemplate TargetType="ListBoxItem">
+                                            <Border x:Name="ItemBorder"
+                                                    Padding="16,8,16,10"
+                                                    Background="{TemplateBinding Background}"
+                                                    BorderBrush="{TemplateBinding BorderBrush}"
+                                                    BorderThickness="1"
+                                                    CornerRadius="8">
+                                                <Grid>
+                                                    <ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
+                                                                      VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
+                                                </Grid>
+                                            </Border>
+                                            <ControlTemplate.Triggers>
+                                                <Trigger Property="IsMouseOver" Value="True">
+                                                    <Setter TargetName="ItemBorder" Property="Background" Value="#F1F5F9" />
+                                                    <Setter TargetName="ItemBorder" Property="BorderBrush" Value="#CBD5E1" />
+                                                </Trigger>
+                                                <Trigger Property="IsSelected" Value="True">
+                                                    <Setter TargetName="ItemBorder" Property="Background" Value="#EFF6FF" />
+                                                    <Setter TargetName="ItemBorder" Property="BorderBrush" Value="#60A5FA" />
+                                                </Trigger>
+                                            </ControlTemplate.Triggers>
+                                        </ControlTemplate>
+                                    </Setter.Value>
+                                </Setter>
+                                <Style.Triggers>
+                                    <Trigger Property="IsSelected" Value="True">
+                                        <Setter Property="Background" Value="#EFF6FF" />
+                                        <Setter Property="BorderBrush" Value="#93C5FD" />
+                                    </Trigger>
+                                </Style.Triggers>
+                            </Style>
+                        </ListBox.ItemContainerStyle>
+                        <ListBox.ItemTemplate>
+                            <DataTemplate>
+                                <StackPanel>
+                                    <TextBlock HorizontalAlignment="Center" FontSize="13" FontWeight="SemiBold" Text="{Binding SystemName}" />
+                                    <TextBlock Margin="0,3,0,0" FontSize="11" Foreground="#6B7280" Text="{Binding StatusSummary}" />
+                                </StackPanel>
+                            </DataTemplate>
+                        </ListBox.ItemTemplate>
+                    </ListBox>
 
-                    <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="配置调整" />
+                    <Grid Grid.Row="2" Grid.ColumnSpan="2" Margin="0,0,0,0">
+                        <Border Padding="14" Background="#F9FAFB" BorderBrush="#D1D5DB" BorderThickness="1,0,1,1" CornerRadius="0,0,10,10">
+                            <StackPanel>
+                                <CheckBox x:Name="Dhcp4CheckBox" VerticalContentAlignment="Center" Checked="ConfigModeChanged_OnChanged" Unchecked="ConfigModeChanged_OnChanged" Content="使用 DHCP 自动获取 IPv4 配置" />
+                                <Grid x:Name="TableConfigPanel" Margin="0,12,0,0">
+                                    <Grid.ColumnDefinitions>
+                                        <ColumnDefinition Width="2*" />
+                                        <ColumnDefinition Width="2*" />
+                                        <ColumnDefinition Width="1.2*" />
+                                    </Grid.ColumnDefinitions>
 
-                    <Grid Grid.Row="1" Margin="0,12,0,0">
-                        <Grid.ColumnDefinitions>
-                            <ColumnDefinition Width="*" />
-                            <ColumnDefinition Width="*" />
-                        </Grid.ColumnDefinitions>
+                                    <Border Margin="0,0,8,0" Padding="10" Background="White" BorderBrush="#E5E7EB" BorderThickness="1" CornerRadius="8">
+                                        <Grid>
+                                            <Grid.RowDefinitions>
+                                                <RowDefinition Height="Auto" />
+                                                <RowDefinition Height="*" />
+                                                <RowDefinition Height="Auto" />
+                                            </Grid.RowDefinitions>
+                                            <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="IP 地址" />
+                                            <DataGrid x:Name="AddressesDataGrid"
+                                                      Grid.Row="1"
+                                                      Margin="0,8,0,0"
+                                                      MaxHeight="260"
+                                                      AutoGenerateColumns="False"
+                                                      CanUserAddRows="False"
+                                                      HeadersVisibility="Column"
+                                                      CellEditEnding="ConfigGrid_OnCellEditEnding">
+                                                <DataGrid.Columns>
+                                                    <DataGridTextColumn Header="IP 地址" Binding="{Binding IP, UpdateSourceTrigger=PropertyChanged}" Width="*" />
+                                                    <DataGridTextColumn Header="子网掩码" Binding="{Binding Mask, UpdateSourceTrigger=PropertyChanged}" Width="120" />
+                                                    <DataGridTemplateColumn Header="操作" Width="58">
+                                                        <DataGridTemplateColumn.CellTemplate>
+                                                            <DataTemplate>
+                                                                <Button Padding="8,0" Click="DeleteAddressButton_OnClick" Content="删除" />
+                                                            </DataTemplate>
+                                                        </DataGridTemplateColumn.CellTemplate>
+                                                    </DataGridTemplateColumn>
+                                                </DataGrid.Columns>
+                                            </DataGrid>
+                                            <Button x:Name="AddAddressButton" Grid.Row="2" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddAddressButton_OnClick" Content="+ 添加 IP" />
+                                        </Grid>
+                                    </Border>
 
-                        <Border Margin="0,0,8,0" Padding="14" Background="#F9FAFB" CornerRadius="10">
-                            <StackPanel>
-                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="当前配置" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="接口名" />
-                                <TextBlock x:Name="RemoteConfigInterfaceTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前 IP" />
-                                <TextBlock x:Name="RemoteConfigIpTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前路由" />
-                                <TextBlock x:Name="RemoteConfigGatewayTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="当前 DNS" />
-                                <TextBlock x:Name="RemoteConfigDnsTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" TextWrapping="Wrap" />
-                            </StackPanel>
-                        </Border>
+                                    <Border Grid.Column="1" Margin="8,0,8,0" Padding="10" Background="White" BorderBrush="#E5E7EB" BorderThickness="1" CornerRadius="8">
+                                        <Grid>
+                                            <Grid.RowDefinitions>
+                                                <RowDefinition Height="Auto" />
+                                                <RowDefinition Height="Auto" />
+                                                <RowDefinition Height="*" />
+                                                <RowDefinition Height="Auto" />
+                                            </Grid.RowDefinitions>
+                                            <StackPanel>
+                                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="网关" />
+                                                <StackPanel Margin="0,8,0,12" Orientation="Horizontal">
+                                                    <CheckBox x:Name="DefaultGatewayCheckBox" VerticalContentAlignment="Center" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用" />
+                                                    <TextBox x:Name="DefaultGatewayTextBox" Margin="12,0,0,0" MinWidth="220" MinHeight="30" VerticalContentAlignment="Center" TextChanged="ConfigInputChanged_OnChanged" />
+                                                </StackPanel>
+                                            </StackPanel>
+                                            <CheckBox x:Name="CustomRoutesCheckBox" Grid.Row="1" Margin="0,0,0,8" VerticalContentAlignment="Center" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用自定义路由" />
+                                            <DataGrid x:Name="RoutesDataGrid"
+                                                      Grid.Row="2"
+                                                      Margin="0,8,0,0"
+                                                      MaxHeight="260"
+                                                      AutoGenerateColumns="False"
+                                                      CanUserAddRows="False"
+                                                      HeadersVisibility="Column"
+                                                      CellEditEnding="ConfigGrid_OnCellEditEnding">
+                                                <DataGrid.Columns>
+                                                    <DataGridTextColumn Header="目标网段" Binding="{Binding To, UpdateSourceTrigger=PropertyChanged}" Width="*" />
+                                                    <DataGridTextColumn Header="网关地址" Binding="{Binding Via, UpdateSourceTrigger=PropertyChanged}" Width="*" />
+                                                    <DataGridTemplateColumn Header="操作" Width="58">
+                                                        <DataGridTemplateColumn.CellTemplate>
+                                                            <DataTemplate>
+                                                                <Button Padding="8,0" Click="DeleteRouteButton_OnClick" Content="删除" />
+                                                            </DataTemplate>
+                                                        </DataGridTemplateColumn.CellTemplate>
+                                                    </DataGridTemplateColumn>
+                                                </DataGrid.Columns>
+                                            </DataGrid>
+                                            <Button x:Name="AddRouteButton" Grid.Row="3" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddRouteButton_OnClick" Content="+ 添加路由" />
+                                        </Grid>
+                                    </Border>
 
-                        <Border Grid.Column="1" Margin="8,0,0,0" Padding="14" Background="#F9FAFB" CornerRadius="10">
-                            <StackPanel>
-                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="新配置" />
-                                <CheckBox x:Name="Dhcp4CheckBox" Margin="0,12,0,0" VerticalContentAlignment="Center" Checked="ConfigModeChanged_OnChanged" Unchecked="ConfigModeChanged_OnChanged" Content="使用 DHCP 自动获取 IPv4 配置" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="IP 地址与子网掩码,每行一个:192.168.10.20 255.255.255.0 或 192.168.10.20/24" />
-                                <TextBox x:Name="NewAddressesTextBox" Margin="0,8,0,0" MinHeight="74" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="路由,每行一个:default via 192.168.10.1 或 10.10.0.0/16 via 192.168.20.1" />
-                                <TextBox x:Name="NewRoutesTextBox" Margin="0,8,0,0" MinHeight="74" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
-                                <TextBlock Margin="0,12,0,0" FontSize="12" Foreground="#6B7280" Text="DNS,每行一个" />
-                                <TextBox x:Name="NewDnsTextBox" Margin="0,8,0,0" MinHeight="54" AcceptsReturn="True" TextWrapping="NoWrap" VerticalScrollBarVisibility="Auto" TextChanged="ConfigInputChanged_OnChanged" />
+                                    <Border Grid.Column="2" Margin="8,0,0,0" Padding="10" Background="White" BorderBrush="#E5E7EB" BorderThickness="1" CornerRadius="8">
+                                        <Grid>
+                                            <Grid.RowDefinitions>
+                                                <RowDefinition Height="Auto" />
+                                                <RowDefinition Height="*" />
+                                                <RowDefinition Height="Auto" />
+                                            </Grid.RowDefinitions>
+                                            <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="DNS" />
+                                            <DataGrid x:Name="DnsDataGrid"
+                                                      Grid.Row="1"
+                                                      Margin="0,8,0,0"
+                                                      MaxHeight="260"
+                                                      AutoGenerateColumns="False"
+                                                      CanUserAddRows="False"
+                                                      HeadersVisibility="Column"
+                                                      CellEditEnding="ConfigGrid_OnCellEditEnding">
+                                                <DataGrid.Columns>
+                                                    <DataGridTextColumn Header="DNS 地址" Binding="{Binding Address, UpdateSourceTrigger=PropertyChanged}" Width="*" />
+                                                    <DataGridTemplateColumn Header="操作" Width="58">
+                                                        <DataGridTemplateColumn.CellTemplate>
+                                                            <DataTemplate>
+                                                                <Button Padding="8,0" Click="DeleteDnsButton_OnClick" Content="删除" />
+                                                            </DataTemplate>
+                                                        </DataGridTemplateColumn.CellTemplate>
+                                                    </DataGridTemplateColumn>
+                                                </DataGrid.Columns>
+                                            </DataGrid>
+                                            <Button x:Name="AddDnsButton" Grid.Row="2" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddDnsButton_OnClick" Content="+ 添加 DNS" />
+                                        </Grid>
+                                    </Border>
+                                </Grid>
                             </StackPanel>
                         </Border>
                     </Grid>
 
-                    <Grid Grid.Row="2" Margin="0,12,0,0">
+                    <Grid Grid.Row="3" Grid.ColumnSpan="2" Margin="0,12,0,0">
                         <Grid.ColumnDefinitions>
                             <ColumnDefinition Width="*" />
                             <ColumnDefinition Width="Auto" />
@@ -147,14 +283,14 @@
                                     MinHeight="36"
                                     Padding="14,0"
                                     Click="ValidateConfigButton_OnClick"
-                                    Content="2. 校验配置" />
+                                    Content="校验配置" />
                             <Button x:Name="ApplyConfigButton"
                                     Margin="10,0,0,0"
                                     MinHeight="36"
                                     Padding="14,0"
                                     FontWeight="SemiBold"
                                     Click="ApplyConfigButton_OnClick"
-                                    Content="3. 应用配置">
+                                    Content="应用配置">
                                 <Button.Style>
                                     <Style TargetType="Button">
                                         <Setter Property="Background" Value="#2563EB" />
@@ -172,49 +308,49 @@
                             </Button>
                         </StackPanel>
                     </Grid>
+                </Grid>
+            </Border>
 
-                    <Border Grid.Row="3" Margin="0,12,0,0" Padding="14" Background="#FEF2F2" CornerRadius="10">
-                        <Grid>
-                            <Grid.RowDefinitions>
-                                <RowDefinition Height="Auto" />
-                                <RowDefinition Height="Auto" />
-                            </Grid.RowDefinitions>
+            <Border Grid.Row="2" Margin="0,12,0,0" Padding="14" Background="#FEF2F2" CornerRadius="10">
+                <Grid>
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                    </Grid.RowDefinitions>
 
-                            <TextBlock FontSize="13"
-                                       FontWeight="SemiBold"
-                                       Foreground="#991B1B"
-                                       Text="高级操作" />
+                    <TextBlock FontSize="13"
+                               FontWeight="SemiBold"
+                               Foreground="#991B1B"
+                               Text="高级操作" />
 
-                            <StackPanel Grid.Row="1" Margin="0,12,0,0">
-                                <TextBlock FontSize="12"
-                                           Foreground="#991B1B"
-                                           TextWrapping="Wrap"
-                                           Text="重启或关闭设备会立即影响远端设备。执行后,当前连接可能马上断开。" />
+                    <StackPanel Grid.Row="1" Margin="0,12,0,0">
+                        <TextBlock FontSize="12"
+                                   Foreground="#991B1B"
+                                   TextWrapping="Wrap"
+                                   Text="重启或关闭设备会立即影响远端设备。执行后,当前连接可能马上断开。" />
 
-                                <StackPanel Margin="0,12,0,0" Orientation="Horizontal">
-                                    <Button x:Name="RebootButton"
-                                            MinHeight="36"
-                                            Padding="14,0"
-                                            FontWeight="SemiBold"
-                                            Background="#FFF7ED"
-                                            Foreground="#9A3412"
-                                            BorderBrush="#FDBA74"
-                                            Click="RebootButton_OnClick"
-                                            Content="重启设备" />
-                                    <Button x:Name="ShutdownButton"
-                                            Margin="10,0,0,0"
-                                            MinHeight="36"
-                                            Padding="14,0"
-                                            FontWeight="SemiBold"
-                                            Background="#DC2626"
-                                            Foreground="White"
-                                            BorderBrush="#DC2626"
-                                            Click="ShutdownButton_OnClick"
-                                            Content="关闭设备" />
-                                </StackPanel>
-                            </StackPanel>
-                        </Grid>
-                    </Border>
+                        <StackPanel Margin="0,12,0,0" Orientation="Horizontal">
+                            <Button x:Name="RebootButton"
+                                    MinHeight="36"
+                                    Padding="14,0"
+                                    FontWeight="SemiBold"
+                                    Background="#FFF7ED"
+                                    Foreground="#9A3412"
+                                    BorderBrush="#FDBA74"
+                                    Click="RebootButton_OnClick"
+                                    Content="重启设备" />
+                            <Button x:Name="ShutdownButton"
+                                    Margin="10,0,0,0"
+                                    MinHeight="36"
+                                    Padding="14,0"
+                                    FontWeight="SemiBold"
+                                    Background="#DC2626"
+                                    Foreground="White"
+                                    BorderBrush="#DC2626"
+                                    Click="ShutdownButton_OnClick"
+                                    Content="关闭设备" />
+                        </StackPanel>
+                    </StackPanel>
                 </Grid>
             </Border>
         </Grid>

+ 350 - 47
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -1,4 +1,7 @@
 using System.Globalization;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Media;
@@ -12,17 +15,26 @@ public partial class DeviceDetailsWindow : Window
 {
     private const int ApplyConfirmationTimeoutSeconds = 20;
     private readonly ServerApiService _serverApiService = new();
+    private readonly ObservableCollection<EditableAddress> _addresses = [];
+    private readonly ObservableCollection<EditableRoute> _routes = [];
+    private readonly ObservableCollection<EditableDns> _dns = [];
     private readonly string _baseAddress;
     private readonly string _localIPv4;
     private readonly string _password;
     private bool _configValidated;
+    private bool _configDirty;
     private bool _isBusy;
+    private bool _isRestoringInterfaceSelection;
     private bool _suppressConfigChangeHandling;
+    private RemoteInterfaceInfo? _currentSelectedInterface;
     private CancellationTokenSource? _statusMessageCts;
 
     public DeviceDetailsWindow(string baseAddress, string localIPv4, string password)
     {
         InitializeComponent();
+        AddressesDataGrid.ItemsSource = _addresses;
+        RoutesDataGrid.ItemsSource = _routes;
+        DnsDataGrid.ItemsSource = _dns;
         _baseAddress = baseAddress;
         _localIPv4 = localIPv4;
         _password = password;
@@ -62,15 +74,16 @@ public partial class DeviceDetailsWindow : Window
             return;
         }
 
-        ShowStatusMessage($"当前管理接口:{interfaces.ManagementInterface};建议目标接口:{interfaces.SuggestedTargetInterface};{(interfaces.RequiresTargetSelection ? "需要手动选择目标接口。" : "已自动识别建议目标接口。")}");
+        ShowStatusMessage($"当前管理接口:{interfaces.ManagementInterface}。请选择需要配置的目标接口。");
         var suggested = interfaces.Interfaces.FirstOrDefault(item => item.SystemName == interfaces.SuggestedTargetInterface)
             ?? interfaces.Interfaces.FirstOrDefault(item => item.IsSuggestedTarget)
             ?? interfaces.Interfaces.FirstOrDefault(item => !item.IsManagementInterface);
-        RemoteTargetInterfaceComboBox.ItemsSource = interfaces.Interfaces;
+        RemoteTargetInterfaceTabControl.ItemsSource = interfaces.Interfaces;
         if (suggested is not null)
         {
-            RemoteTargetInterfaceComboBox.SelectedItem = suggested;
+            RemoteTargetInterfaceTabControl.SelectedItem = suggested;
             await LoadRemoteInterfaceConfigAsync(suggested.SystemName);
+            _currentSelectedInterface = suggested;
         }
     }
 
@@ -80,28 +93,52 @@ public partial class DeviceDetailsWindow : Window
         RemoteHostnameTextBlock.Text = "-";
         RemoteOsVersionTextBlock.Text = "-";
         RemoteServerVersionTextBlock.Text = "-";
-        RemoteTargetInterfaceComboBox.ItemsSource = null;
-        RemoteConfigInterfaceTextBlock.Text = "-";
-        RemoteConfigIpTextBlock.Text = "-";
-        RemoteConfigGatewayTextBlock.Text = "-";
-        RemoteConfigDnsTextBlock.Text = "-";
-        NewAddressesTextBox.Text = string.Empty;
-        NewRoutesTextBox.Text = string.Empty;
-        NewDnsTextBox.Text = string.Empty;
+        RemoteTargetInterfaceTabControl.ItemsSource = null;
+        _addresses.Clear();
+        _routes.Clear();
+        _dns.Clear();
+        DefaultGatewayCheckBox.IsChecked = false;
+        DefaultGatewayTextBox.Text = string.Empty;
+        CustomRoutesCheckBox.IsChecked = false;
         _configValidated = false;
+        _configDirty = false;
+        _currentSelectedInterface = null;
     }
 
-    private async void RemoteTargetInterfaceComboBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+    private async void RemoteTargetInterfaceTabControl_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
     {
         try
         {
-            if (RemoteTargetInterfaceComboBox.SelectedItem is not RemoteInterfaceInfo selected)
+            if (RemoteTargetInterfaceTabControl.SelectedItem is not RemoteInterfaceInfo selected)
             {
                 UpdateButtonStates();
                 return;
             }
 
+            if (_isRestoringInterfaceSelection)
+            {
+                return;
+            }
+
+            if (_configDirty && _currentSelectedInterface is not null && selected.SystemName != _currentSelectedInterface.SystemName)
+            {
+                var result = MessageBox.Show(
+                    this,
+                    "当前配置已修改,切换接口会丢失未应用内容。是否继续?",
+                    "确认切换接口",
+                    MessageBoxButton.OKCancel,
+                    MessageBoxImage.Warning);
+                if (result != MessageBoxResult.OK)
+                {
+                    _isRestoringInterfaceSelection = true;
+                    RemoteTargetInterfaceTabControl.SelectedItem = _currentSelectedInterface;
+                    _isRestoringInterfaceSelection = false;
+                    return;
+                }
+            }
+
             await LoadRemoteInterfaceConfigAsync(selected.SystemName, useBusyState: true);
+            _currentSelectedInterface = selected;
         }
         catch (Exception ex)
         {
@@ -122,26 +159,45 @@ public partial class DeviceDetailsWindow : Window
             var result = await _serverApiService.GetInterfaceConfigAsync(_baseAddress, _password, _localIPv4, interfaceName);
             if (!result.Success || result.Data is null)
             {
-                RemoteConfigInterfaceTextBlock.Text = interfaceName;
-                RemoteConfigIpTextBlock.Text = "读取失败";
-                RemoteConfigGatewayTextBlock.Text = "读取失败";
-                RemoteConfigDnsTextBlock.Text = "读取失败";
                 ShowStatusMessage($"读取目标接口 {interfaceName} 配置失败:{result.Message}");
                 return;
             }
 
             var config = result.Data;
-            RemoteConfigInterfaceTextBlock.Text = config.Interface;
-            RemoteConfigIpTextBlock.Text = FormatCurrentIp(config);
-            RemoteConfigGatewayTextBlock.Text = FormatRoutes(config.EffectiveRoutes);
-            RemoteConfigDnsTextBlock.Text = config.DnsSummary;
             _suppressConfigChangeHandling = true;
             Dhcp4CheckBox.IsChecked = false;
-            NewAddressesTextBox.Text = string.Join(Environment.NewLine, config.EffectiveAddresses.Select(item => $"{item.IP}/{item.Prefix}"));
-            NewRoutesTextBox.Text = string.Join(Environment.NewLine, config.EffectiveRoutes.Select(item => $"{item.To} via {item.Via}"));
-            NewDnsTextBox.Text = config.Dns is null ? string.Empty : string.Join(Environment.NewLine, config.Dns);
+            _addresses.Clear();
+            foreach (var address in config.EffectiveAddresses)
+            {
+                _addresses.Add(new EditableAddress { IP = address.IP, Mask = PrefixToMask(address.Prefix) });
+            }
+            _routes.Clear();
+            DefaultGatewayCheckBox.IsChecked = false;
+            DefaultGatewayTextBox.Text = string.Empty;
+            foreach (var route in config.EffectiveRoutes)
+            {
+                if (route.To.Equals("default", StringComparison.OrdinalIgnoreCase))
+                {
+                    DefaultGatewayCheckBox.IsChecked = true;
+                    DefaultGatewayTextBox.Text = route.Via;
+                }
+                else
+                {
+                    _routes.Add(new EditableRoute { To = route.To, Via = route.Via });
+                }
+            }
+            CustomRoutesCheckBox.IsChecked = _routes.Count > 0;
+            _dns.Clear();
+            if (config.Dns is not null)
+            {
+                foreach (var dns in config.Dns)
+                {
+                    _dns.Add(new EditableDns { Address = dns });
+                }
+            }
             _suppressConfigChangeHandling = false;
             _configValidated = false;
+            _configDirty = false;
             ShowStatusMessage("已读取Linux端IP配置。");
             UpdateButtonStates();
         }
@@ -156,7 +212,7 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ReloadInterfaceConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (RemoteTargetInterfaceComboBox.SelectedItem is RemoteInterfaceInfo selected)
+        if (RemoteTargetInterfaceTabControl.SelectedItem is RemoteInterfaceInfo selected)
         {
             await LoadRemoteInterfaceConfigAsync(selected.SystemName);
         }
@@ -164,7 +220,7 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ValidateConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (RemoteTargetInterfaceComboBox.SelectedItem is not RemoteInterfaceInfo selected)
+        if (RemoteTargetInterfaceTabControl.SelectedItem is not RemoteInterfaceInfo selected)
         {
             return;
         }
@@ -201,7 +257,7 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ApplyConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (RemoteTargetInterfaceComboBox.SelectedItem is not RemoteInterfaceInfo selected)
+        if (RemoteTargetInterfaceTabControl.SelectedItem is not RemoteInterfaceInfo selected)
         {
             return;
         }
@@ -285,7 +341,7 @@ public partial class DeviceDetailsWindow : Window
             if (task.Status is "success" or "failed" or "rolled_back")
             {
                 ShowTaskCompletionDialog(task);
-                if (RemoteTargetInterfaceComboBox.SelectedItem is RemoteInterfaceInfo selected)
+                if (RemoteTargetInterfaceTabControl.SelectedItem is RemoteInterfaceInfo selected)
                 {
                     await LoadRemoteInterfaceConfigAsync(selected.SystemName);
                 }
@@ -419,28 +475,32 @@ public partial class DeviceDetailsWindow : Window
 
     private RemoteInterfaceConfig? BuildConfigRequest(string interfaceName)
     {
+        CommitConfigEdits();
         var dhcp4 = Dhcp4CheckBox.IsChecked == true;
         var addresses = Array.Empty<RemoteInterfaceAddressConfig>();
         var routes = Array.Empty<RemoteInterfaceRouteConfig>();
-        if (!dhcp4 && string.IsNullOrWhiteSpace(NewAddressesTextBox.Text))
+        if (!dhcp4)
         {
-            ShowStatusMessage("IP 地址不能为空,至少需要填写一行地址。");
-            return null;
-        }
+            if (_addresses.All(item => string.IsNullOrWhiteSpace(item.IP) && string.IsNullOrWhiteSpace(item.Mask)))
+            {
+                ShowStatusMessage("IP 地址不能为空,至少需要填写一行地址。");
+                return null;
+            }
 
-        if (!dhcp4 && !TryParseAddresses(NewAddressesTextBox.Text, out addresses, out var addressError))
-        {
-            ShowStatusMessage(addressError);
-            return null;
-        }
+            if (!TryBuildAddresses(out addresses, out var addressError))
+            {
+                ShowStatusMessage(addressError);
+                return null;
+            }
 
-        if (!dhcp4 && !TryParseRoutes(NewRoutesTextBox.Text, out routes, out var routeError))
-        {
-            ShowStatusMessage(routeError);
-            return null;
+            if (!TryBuildRoutes(out routes, out var routeError))
+            {
+                ShowStatusMessage(routeError);
+                return null;
+            }
         }
 
-        var dns = ParseListText(NewDnsTextBox.Text);
+        var dns = _dns.Select(item => item.Address.Trim()).Where(item => item != string.Empty).ToArray();
         return new RemoteInterfaceConfig
         {
             Interface = interfaceName,
@@ -451,6 +511,16 @@ public partial class DeviceDetailsWindow : Window
         };
     }
 
+    private void CommitConfigEdits()
+    {
+        AddressesDataGrid.CommitEdit(DataGridEditingUnit.Cell, true);
+        AddressesDataGrid.CommitEdit(DataGridEditingUnit.Row, true);
+        RoutesDataGrid.CommitEdit(DataGridEditingUnit.Cell, true);
+        RoutesDataGrid.CommitEdit(DataGridEditingUnit.Row, true);
+        DnsDataGrid.CommitEdit(DataGridEditingUnit.Cell, true);
+        DnsDataGrid.CommitEdit(DataGridEditingUnit.Row, true);
+    }
+
     private static string FormatCurrentIp(RemoteInterfaceConfig config)
     {
         if (config.EffectiveAddresses.Count == 0)
@@ -472,6 +542,91 @@ public partial class DeviceDetailsWindow : Window
         return routes.Count == 0 ? "无" : string.Join(Environment.NewLine, routes.Select(item => $"{item.To} via {item.Via}"));
     }
 
+    private bool TryBuildAddresses(out RemoteInterfaceAddressConfig[] addresses, out string error)
+    {
+        var result = new List<RemoteInterfaceAddressConfig>();
+        foreach (var row in _addresses)
+        {
+            var ip = row.IP.Trim();
+            var maskText = row.Mask.Trim();
+            if (ip == string.Empty && maskText == string.Empty)
+            {
+                continue;
+            }
+            if (ip.Contains('/'))
+            {
+                var parts = ip.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+                if (parts.Length != 2 || !int.TryParse(parts[1], out var cidrPrefix) || cidrPrefix < 0 || cidrPrefix > 32)
+                {
+                    addresses = [];
+                    error = $"地址格式不正确:{ip}";
+                    return false;
+                }
+                row.IP = parts[0];
+                row.Mask = PrefixToMask(cidrPrefix);
+                result.Add(new RemoteInterfaceAddressConfig { IP = parts[0], Prefix = cidrPrefix });
+                continue;
+            }
+            if (ip == string.Empty || maskText == string.Empty)
+            {
+                addresses = [];
+                error = "IP 地址和子网掩码都需要填写。";
+                return false;
+            }
+            if (!TryMaskOrPrefixToPrefix(maskText, out var prefix))
+            {
+                addresses = [];
+                error = $"子网掩码格式不正确:{ip} {maskText}";
+                return false;
+            }
+            row.Mask = PrefixToMask(prefix);
+            result.Add(new RemoteInterfaceAddressConfig { IP = ip, Prefix = prefix });
+        }
+
+        addresses = result.ToArray();
+        error = string.Empty;
+        return addresses.Length > 0;
+    }
+
+    private bool TryBuildRoutes(out RemoteInterfaceRouteConfig[] routes, out string error)
+    {
+        var result = new List<RemoteInterfaceRouteConfig>();
+        if (DefaultGatewayCheckBox.IsChecked == true)
+        {
+            var gateway = DefaultGatewayTextBox.Text.Trim();
+            if (gateway == string.Empty)
+            {
+                routes = [];
+                error = "启用默认网关时,网关地址不能为空。";
+                return false;
+            }
+            result.Add(new RemoteInterfaceRouteConfig { To = "default", Via = gateway });
+        }
+        if (CustomRoutesCheckBox.IsChecked == true)
+        {
+            foreach (var row in _routes)
+            {
+                var to = row.To.Trim();
+                var via = row.Via.Trim();
+                if (to == string.Empty && via == string.Empty)
+                {
+                    continue;
+                }
+                if (to == string.Empty || via == string.Empty)
+                {
+                    routes = [];
+                    error = "自定义路由的目标网段和网关地址都需要填写。";
+                    return false;
+                }
+                result.Add(new RemoteInterfaceRouteConfig { To = to, Via = via });
+            }
+        }
+
+        routes = result.ToArray();
+        error = string.Empty;
+        return true;
+    }
+
     private static bool TryParseAddresses(string text, out RemoteInterfaceAddressConfig[] addresses, out string error)
     {
         var result = new List<RemoteInterfaceAddressConfig>();
@@ -631,10 +786,108 @@ public partial class DeviceDetailsWindow : Window
         }
 
         _configValidated = false;
+        _configDirty = true;
         ShowStatusMessage("配置模式已变更,请重新点击“2. 校验配置”。");
         UpdateButtonStates();
     }
 
+    private void GatewayOrRouteModeChanged_OnChanged(object sender, RoutedEventArgs e)
+    {
+        if (_suppressConfigChangeHandling)
+        {
+            UpdateButtonStates();
+            return;
+        }
+
+        _configValidated = false;
+        _configDirty = true;
+        ShowStatusMessage("配置内容已变更,请重新点击“2. 校验配置”。");
+        UpdateButtonStates();
+    }
+
+    private void ConfigGrid_OnCellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
+    {
+        if (e.Row.Item is EditableAddress address)
+        {
+            NormalizeAddressRow(address);
+        }
+        MarkConfigChanged("配置内容已变更,请重新点击“2. 校验配置”。");
+    }
+
+    private void AddAddressButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        _addresses.Add(new EditableAddress { Mask = "255.255.255.0" });
+        MarkConfigChanged("已添加 IP 地址,请填写后重新校验配置。");
+    }
+
+    private void AddRouteButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        _routes.Add(new EditableRoute());
+        MarkConfigChanged("已添加路由,请填写后重新校验配置。");
+    }
+
+    private void AddDnsButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        _dns.Add(new EditableDns());
+        MarkConfigChanged("已添加 DNS,请填写后重新校验配置。");
+    }
+
+    private void DeleteAddressButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.DataContext is not EditableAddress address)
+        {
+            return;
+        }
+        _addresses.Remove(address);
+        MarkConfigChanged("已删除 IP 地址,请重新校验配置。");
+    }
+
+    private void DeleteRouteButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.DataContext is EditableRoute route)
+        {
+            _routes.Remove(route);
+            MarkConfigChanged("已删除路由,请重新校验配置。");
+        }
+    }
+
+    private void DeleteDnsButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if ((sender as FrameworkElement)?.DataContext is EditableDns dns)
+        {
+            _dns.Remove(dns);
+            MarkConfigChanged("已删除 DNS,请重新校验配置。");
+        }
+    }
+
+    private void MarkConfigChanged(string message)
+    {
+        if (_suppressConfigChangeHandling)
+        {
+            return;
+        }
+
+        _configValidated = false;
+        _configDirty = true;
+        ShowStatusMessage(message);
+        UpdateButtonStates();
+    }
+
+    private static void NormalizeAddressRow(EditableAddress row)
+    {
+        var ip = row.IP.Trim();
+        if (!ip.Contains('/'))
+        {
+            return;
+        }
+        var parts = ip.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        if (parts.Length == 2 && int.TryParse(parts[1], out var prefix) && prefix >= 0 && prefix <= 32)
+        {
+            row.IP = parts[0];
+            row.Mask = PrefixToMask(prefix);
+        }
+    }
+
     private void ShowStatusMessage(string message)
     {
         ApplyStatusMessageStyle(message);
@@ -731,17 +984,26 @@ public partial class DeviceDetailsWindow : Window
 
     private void UpdateButtonStates()
     {
-        var hasSelectedInterface = RemoteTargetInterfaceComboBox.SelectedItem is RemoteInterfaceInfo;
+        var hasSelectedInterface = RemoteTargetInterfaceTabControl.SelectedItem is RemoteInterfaceInfo;
         var canEdit = !_isBusy && hasSelectedInterface;
+        var canEditStatic = canEdit && Dhcp4CheckBox.IsChecked != true;
+        var canEditGateway = canEditStatic && DefaultGatewayCheckBox.IsChecked == true;
+        var canEditCustomRoutes = canEditStatic && CustomRoutesCheckBox.IsChecked == true;
 
-        RemoteTargetInterfaceComboBox.IsEnabled = !_isBusy && RemoteTargetInterfaceComboBox.Items.Count > 0;
+        RemoteTargetInterfaceTabControl.IsEnabled = !_isBusy && RemoteTargetInterfaceTabControl.Items.Count > 0;
         ReloadInterfaceConfigButton.IsEnabled = canEdit;
         ValidateConfigButton.IsEnabled = canEdit;
         ApplyConfigButton.IsEnabled = !_isBusy && _configValidated && hasSelectedInterface;
         Dhcp4CheckBox.IsEnabled = canEdit;
-        NewAddressesTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
-        NewRoutesTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
-        NewDnsTextBox.IsEnabled = canEdit && Dhcp4CheckBox.IsChecked != true;
+        AddressesDataGrid.IsEnabled = canEditStatic;
+        DefaultGatewayCheckBox.IsEnabled = canEditStatic;
+        DefaultGatewayTextBox.IsEnabled = canEditGateway;
+        CustomRoutesCheckBox.IsEnabled = canEditStatic;
+        RoutesDataGrid.IsEnabled = canEditCustomRoutes;
+        DnsDataGrid.IsEnabled = canEditStatic;
+        AddAddressButton.IsEnabled = canEditStatic;
+        AddRouteButton.IsEnabled = canEditCustomRoutes;
+        AddDnsButton.IsEnabled = canEditStatic;
         RebootButton.IsEnabled = !_isBusy;
         ShutdownButton.IsEnabled = !_isBusy;
     }
@@ -753,4 +1015,45 @@ public partial class DeviceDetailsWindow : Window
         BusyMessageTextBlock.Text = string.IsNullOrWhiteSpace(message) ? "正在处理,请稍候..." : message;
         UpdateButtonStates();
     }
+
+    private sealed class EditableAddress : INotifyPropertyChanged
+    {
+        private string _ip = string.Empty;
+        private string _mask = string.Empty;
+
+        public string IP
+        {
+            get => _ip;
+            set => SetField(ref _ip, value);
+        }
+
+        public string Mask
+        {
+            get => _mask;
+            set => SetField(ref _mask, 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));
+        }
+    }
+
+    private sealed class EditableRoute
+    {
+        public string To { get; set; } = string.Empty;
+        public string Via { get; set; } = string.Empty;
+    }
+
+    private sealed class EditableDns
+    {
+        public string Address { get; set; } = string.Empty;
+    }
 }

+ 5 - 1
windows/NetworkTool.Client/Models/RemoteInterfaceInfo.cs

@@ -52,7 +52,11 @@ public sealed class RemoteInterfaceInfo
         ? "无"
         : string.Join("; ", IPv4.Select(item => $"{item.Address}/{item.Prefix}"));
 
-    public string DisplayName => $"{Name} / {SystemName} / {(LinkUp ? "已连接" : "未连接")}";
+    public string StatusSummary => IsManagementInterface
+        ? $"管理接口 / {(LinkUp ? "已连接" : "未连接")}"
+        : LinkUp ? "已连接" : "未连接";
+
+    public string DisplayName => $"{SystemName} / {StatusSummary}";
 }
 
 public sealed class RemoteInterfacesInfo