7 コミット cbb8d2d818 ... 63e5c1685e

作者 SHA1 メッセージ 日付
  yangkaixiang 63e5c1685e refactor(ui): 优化配置提交时的忙碌状态提示 1 ヶ月 前
  yangkaixiang 5e84b41705 docs: 明确服务端与客户端版本号更新规范 1 ヶ月 前
  yangkaixiang 64b074615f refactor(server): 优化配置回滚超时提示文案 1 ヶ月 前
  yangkaixiang e572e0cce8 refactor(ui): 列表失焦时自动取消选中以优化交互 1 ヶ月 前
  yangkaixiang de4f13af00 refactor(ui): 移除网络配置手动确认弹窗改为自动确认 1 ヶ月 前
  yangkaixiang fdef36ecc4 refactor(ui): 优化网关摘要显示逻辑以区分启用状态 1 ヶ月 前
  yangkaixiang eb35355e43 refactor(ui): 统一配置项修改标记样式并右对齐 1 ヶ月 前

+ 4 - 2
AGENTS.md

@@ -3,6 +3,8 @@
 ## Build Rules
 
 - Windows 编译时,如果因进程占用导致编译失败,可以直接结束占用进程后重新编译。编译结束后重新打开程序。
-- 每次编译 server 端前,需要先修改版本号。
-- 编译win端时,需要修改主界面标题栏的版本号。
+- 每次修改 `server` 目录下会影响 Server 行为的代码时,必须同步更新 `server/internal/config/config.go` 中的 `ServerVersion`。
+- 每次修改 `windows` 目录下会影响 Windows 客户端行为或界面的代码时,必须同步更新 `windows/NetworkTool.Client/NetworkTool.Client.csproj` 中的 `InformationalVersion`,主界面标题栏会读取该版本号。
+- 编译 server 或 win 端前,需要确认对应版本号已按本次修改更新。
 - 版本号格式统一使用 `yyyy.MM.dd.HHmm`,例如 `2026.05.13.1446`。
+- 如果修改后的代码和docs里文档描述不一致,需要你来修改docs里的文档。

+ 4 - 5
docs/06-netplan修改策略.md

@@ -142,17 +142,16 @@
 
 1. 备份完成后写回修改后的文件
 2. 执行 `netplan apply`
-3. 客户端仍可连接时,在限定时间内由用户确认保留配置
-4. 未确认、取消或执行失败时恢复本次备份并再次执行 `netplan apply`
+3. 客户端仍可连接时,在限定时间内自动确认保留配置
+4. 未自动确认或执行失败时恢复本次备份并再次执行 `netplan apply`
 
 ### 7.3 回滚规则
 
 出现以下情况时执行回滚:
 
 1. `netplan apply` 执行失败
-2. 用户未在超时时间内确认保留配置
-3. 用户主动取消保留配置
-4. 其他明确判定为配置失败的情况
+2. 客户端未能在超时时间内自动确认保留配置
+3. 其他明确判定为配置失败的情况
 
 回滚步骤:
 

+ 12 - 0
docs/08-构建与编译.md

@@ -6,6 +6,18 @@
 
 ## 2. Windows 客户端编译
 
+### 2.0 Windows 客户端版本号约定
+
+每次修改 `windows` 端代码时,必须同步更新 `windows/NetworkTool.Client/NetworkTool.Client.csproj` 中的 `InformationalVersion`。
+
+版本号格式固定为当前时间:`yyyy.MM.dd.HHmm`,例如 `2026.05.13.1446`。
+
+该版本号会显示在 Windows 客户端主界面标题栏,用于排查客户端程序是否为最新版本。
+
+注意:只要改动了 `windows` 目录下会影响 Windows 客户端行为或界面的代码,就要更新该版本号。
+
+### 2.1 编译 Windows 客户端
+
 在仓库根目录执行:
 
 ```powershell

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

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

+ 2 - 2
server/internal/httpserver/server.go

@@ -433,7 +433,7 @@ func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, manage
 		s.rollbackAppliedConfig(taskID, filePath, backupPath, "用户取消保留配置")
 		return
 	case <-time.After(applyConfirmationTimeout):
-		s.rollbackAppliedConfig(taskID, filePath, backupPath, "确认超时")
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "客户端未在限定时间内确认保留配置")
 		return
 	}
 }
@@ -490,7 +490,7 @@ func (s *Server) runApplyAllTask(taskID string, inputs []model.InterfaceConfig,
 		s.rollbackAppliedConfig(taskID, filePath, backupPath, "用户取消保留配置")
 		return
 	case <-time.After(applyConfirmationTimeout):
-		s.rollbackAppliedConfig(taskID, filePath, backupPath, "确认超时")
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "客户端未在限定时间内确认保留配置")
 		return
 	}
 }

+ 33 - 41
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -65,19 +65,13 @@
             <Setter Property="BorderThickness" Value="0" />
         </Style>
 
-        <Style x:Key="ModifiedSectionBorderStyle" TargetType="Border">
+        <Style x:Key="ConfigSectionBorderStyle" 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="StaticIpv4SectionBorderStyle" TargetType="Border" BasedOn="{StaticResource ModifiedSectionBorderStyle}">
+        <Style x:Key="StaticIpv4SectionBorderStyle" TargetType="Border" BasedOn="{StaticResource ConfigSectionBorderStyle}">
             <Setter Property="Visibility" Value="Visible" />
             <Style.Triggers>
                 <DataTrigger Binding="{Binding Dhcp4}" Value="True">
@@ -94,7 +88,7 @@
             <Setter Property="FontWeight" Value="SemiBold" />
             <Setter Property="Foreground" Value="#92400E" />
             <Setter Property="Background" Value="#FEF3C7" />
-            <Setter Property="Text" Value="已修改" />
+            <Setter Property="Text" Value="*已修改" />
             <Setter Property="Visibility" Value="Collapsed" />
             <Style.Triggers>
                 <DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
@@ -156,21 +150,28 @@
                             <DataTemplate>
                                  <Border Margin="0,0,0,14" Padding="14" Background="#F8FAFC" BorderBrush="#D8DEE9" BorderThickness="1" CornerRadius="12">
                                      <StackPanel>
-                                        <StackPanel Orientation="Horizontal">
-                                             <TextBlock FontSize="15" FontWeight="SemiBold" Foreground="#0F172A" Text="{Binding DisplayLabel}" />
-                                            <TextBlock Margin="10,2,0,0" FontSize="12" Text="{Binding StatusSummary}">
-                                                <TextBlock.Style>
-                                                    <Style TargetType="TextBlock">
-                                                        <Setter Property="Foreground" Value="#6B7280" />
+                                         <Grid>
+                                             <Grid.ColumnDefinitions>
+                                                 <ColumnDefinition Width="*" />
+                                                 <ColumnDefinition Width="Auto" />
+                                             </Grid.ColumnDefinitions>
+                                             <StackPanel Orientation="Horizontal">
+                                                 <TextBlock FontSize="15" FontWeight="SemiBold" Foreground="#0F172A" Text="{Binding DisplayLabel}" />
+                                                 <TextBlock Margin="10,2,0,0" FontSize="12" Text="{Binding StatusSummary}">
+                                                 <TextBlock.Style>
+                                                     <Style TargetType="TextBlock">
+                                                         <Setter Property="Foreground" Value="#6B7280" />
                                                         <Style.Triggers>
                                                             <DataTrigger Binding="{Binding LinkUp}" Value="True">
                                                                 <Setter Property="Foreground" Value="#16A34A" />
                                                             </DataTrigger>
-                                                        </Style.Triggers>
-                                                    </Style>
-                                                </TextBlock.Style>
-                                            </TextBlock>
-                                        </StackPanel>
+                                                         </Style.Triggers>
+                                                     </Style>
+                                                 </TextBlock.Style>
+                                                 </TextBlock>
+                                             </StackPanel>
+                                             <TextBlock Grid.Column="1" Style="{StaticResource ModifiedBadgeStyle}" Tag="{Binding HasChanges}" />
+                                         </Grid>
                                         <CheckBox Margin="0,12,0,0" VerticalContentAlignment="Center" IsChecked="{Binding Dhcp4, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Checked="ConfigModeChanged_OnChanged" Unchecked="ConfigModeChanged_OnChanged" Content="使用 DHCP 自动获取 IPv4 配置" />
                                         <Grid Margin="0,12,0,0">
                                             <Grid.RowDefinitions>
@@ -179,18 +180,15 @@
                                                 <RowDefinition Height="Auto" />
                                             </Grid.RowDefinitions>
 
-                                              <Border Padding="12" CornerRadius="10" Style="{StaticResource StaticIpv4SectionBorderStyle}" Tag="{Binding IsAddressModified}">
+                                              <Border Padding="12" CornerRadius="10" Style="{StaticResource StaticIpv4SectionBorderStyle}">
                                                   <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
                                                         <RowDefinition Height="*" />
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
-                                                     <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">
+                                                      <TextBlock Style="{StaticResource SectionTitleStyle}" Text="IP 地址" />
+                                                     <DataGrid Grid.Row="1" Margin="18,10,0,0" ItemsSource="{Binding Addresses}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" LostKeyboardFocus="ConfigGrid_OnLostKeyboardFocus" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
                                                          <DataGrid.Style>
                                                              <Style TargetType="DataGrid" BasedOn="{StaticResource ConfigDataGridStyle}">
                                                                  <Setter Property="IsEnabled" Value="True" />
@@ -228,7 +226,7 @@
                                                 </Grid>
                                             </Border>
 
-                                              <Border Grid.Row="1" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource StaticIpv4SectionBorderStyle}" Tag="{Binding IsGatewayModified}">
+                                               <Border Grid.Row="1" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource StaticIpv4SectionBorderStyle}">
                                                  <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
@@ -237,10 +235,7 @@
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
                                                     <StackPanel>
-                                                         <StackPanel Orientation="Horizontal">
-                                                             <TextBlock Style="{StaticResource SectionTitleStyle}" Text="网关" />
-                                                             <TextBlock Style="{StaticResource ModifiedBadgeStyle}" Tag="{Binding IsGatewayModified}" />
-                                                         </StackPanel>
+                                                          <TextBlock Style="{StaticResource SectionTitleStyle}" Text="网关" />
                                                         <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="启用">
@@ -287,7 +282,7 @@
                                                             </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 Grid.Row="2" Margin="18,8,0,0" ItemsSource="{Binding Routes}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" LostKeyboardFocus="ConfigGrid_OnLostKeyboardFocus" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
                                                           <DataGrid.Style>
                                                               <Style TargetType="DataGrid" BasedOn="{StaticResource ConfigDataGridStyle}">
                                                                   <Setter Property="Visibility" Value="Collapsed" />
@@ -334,18 +329,15 @@
                                                 </Grid>
                                             </Border>
 
-                                             <Border Grid.Row="2" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource ModifiedSectionBorderStyle}" Tag="{Binding IsDnsModified}">
+                                             <Border Grid.Row="2" Margin="0,12,0,0" Padding="12" CornerRadius="10" Style="{StaticResource ConfigSectionBorderStyle}">
                                                  <Grid>
                                                     <Grid.RowDefinitions>
                                                         <RowDefinition Height="Auto" />
                                                         <RowDefinition Height="*" />
                                                         <RowDefinition Height="Auto" />
                                                     </Grid.RowDefinitions>
-                                                     <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">
+                                                      <TextBlock Style="{StaticResource SectionTitleStyle}" Text="DNS" />
+                                                     <DataGrid Grid.Row="1" Margin="18,10,0,0" Style="{StaticResource ConfigDataGridStyle}" ItemsSource="{Binding Dns}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" LostKeyboardFocus="ConfigGrid_OnLostKeyboardFocus" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
                                                          <DataGrid.Columns>
                                                              <DataGridTextColumn Header="DNS 地址" Binding="{Binding Address, UpdateSourceTrigger=PropertyChanged}" ElementStyle="{StaticResource ConfigDataGridTextStyle}" EditingElementStyle="{StaticResource ConfigDataGridEditingTextStyle}" Width="*" />
                                                              <DataGridTemplateColumn Header="操作" Width="72">
@@ -377,14 +369,14 @@
                                    VerticalAlignment="Center"
                                    FontSize="12"
                                    Foreground="#6B7280"
-                                   Text="先确认所有网口配置,再校验新配置,最后一次性应用。" />
+                                   Text="先确认所有网口配置,再校验新配置,最后保存配置。" />
 
                         <StackPanel Grid.Column="1" Margin="12,0,0,0" Orientation="Horizontal">
                             <Button x:Name="ValidateConfigButton"
                                      MinHeight="36"
                                      Padding="14,0"
                                      Click="ValidateConfigButton_OnClick"
-                                     Content="校验全部配置" />
+                                      Content="校验" />
                             <Button x:Name="ApplyConfigButton"
                                     Margin="10,0,0,0"
                                     MinHeight="36"
@@ -392,7 +384,7 @@
                                     FontWeight="SemiBold"
                                     Style="{StaticResource PrimaryButtonStyle}"
                                     Click="ApplyConfigButton_OnClick"
-                                    Content="应用全部配置" />
+                                    Content="保存配置" />
                         </StackPanel>
                     </Grid>
                 </Grid>

+ 87 - 129
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -14,7 +14,6 @@ namespace NetworkTool.Client;
 
 public partial class DeviceDetailsWindow : Window
 {
-    private const int ApplyConfirmationTimeoutSeconds = 20;
     private readonly ServerApiService _serverApiService = new();
     private readonly ObservableCollection<InterfaceEditor> _interfaces = [];
     private readonly string _baseAddress;
@@ -168,7 +167,7 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ReloadInterfaceConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用,重新获取会丢失未应用内容。是否继续重新获取?", "确认重新获取配置"))
+        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未保存,重新获取会丢失未保存内容。是否继续重新获取?", "确认重新获取配置"))
         {
             return;
         }
@@ -194,7 +193,7 @@ public partial class DeviceDetailsWindow : Window
 
     private void DeviceDetailsWindow_OnClosing(object? sender, CancelEventArgs e)
     {
-        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用。是否关闭窗口?", "确认关闭窗口"))
+        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未保存。是否关闭窗口?", "确认关闭窗口"))
         {
             e.Cancel = true;
         }
@@ -227,9 +226,9 @@ public partial class DeviceDetailsWindow : Window
             {
                 var warnings = result.Data.Warnings.Count > 0 ? $" 警告:{string.Join(";", result.Data.Warnings)}" : string.Empty;
                 var errors = result.Data.Errors.Count > 0 ? $" 错误:{string.Join(";", result.Data.Errors)}" : string.Empty;
-                SetConfigStateMessage(_configValidated ? "配置已校验通过,可以应用。" : "配置校验未通过,请修正后重新校验。", !_configValidated);
+                SetConfigStateMessage(_configValidated ? "配置已校验通过,可以保存。" : "配置校验未通过,请修正后重新校验。", !_configValidated);
                 ShowStatusMessage(
-                    _configValidated ? $"全部网口校验通过,可应用配置。{warnings}" : $"校验失败。{errors}{warnings}",
+                    _configValidated ? $"全部网口校验通过,可保存配置。{warnings}" : $"校验失败。{errors}{warnings}",
                     _configValidated ? StatusMessageType.Success : StatusMessageType.Error);
             }
             else
@@ -256,14 +255,14 @@ public partial class DeviceDetailsWindow : Window
 
         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)
+            ? "将要一次性保存以下网口配置:\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;
         }
 
-        SetBusyState(true, "正在提交并应用配置,请稍候...");
+        SetBusyState(true, "正在提交并保存配置,请稍候...");
         try
         {
             var applyResult = await _serverApiService.ApplyInterfaceConfigsAsync(_baseAddress, _password, _localIPv4, request);
@@ -273,7 +272,8 @@ public partial class DeviceDetailsWindow : Window
                 return;
             }
 
-            ShowStatusMessage("配置任务已提交,正在应用并等待连通确认...", StatusMessageType.Info);
+            SetBusyState(true, "已提交,等待完成...");
+            ShowStatusMessage("配置任务已提交,正在保存并等待连通确认...", StatusMessageType.Info);
             await PollTaskAsync(applyResult.Data.TaskId);
         }
         finally
@@ -315,37 +315,31 @@ public partial class DeviceDetailsWindow : Window
             if (task.Status == "running" && task.Step == "confirming" && !confirmationRequested)
             {
                 confirmationRequested = true;
-                var confirm = ShowApplyConfirmationDialog(ApplyConfirmationTimeoutSeconds);
-                if (confirm)
-                {
-                    var confirmResult = await _serverApiService.ConfirmApplyTaskAsync(_baseAddress, _password, _localIPv4, taskId);
-                    ShowStatusMessage(
-                        confirmResult.Success ? "已发送保留配置确认。" : $"发送确认失败:{confirmResult.Message}",
-                        confirmResult.Success ? StatusMessageType.Success : StatusMessageType.Error);
-                }
-                else
-                {
-                    var cancelResult = await _serverApiService.CancelApplyTaskAsync(_baseAddress, _password, _localIPv4, taskId);
-                    ShowStatusMessage(
-                        cancelResult.Success ? "已取消保留配置,正在回滚。" : $"发送取消失败:{cancelResult.Message}",
-                        cancelResult.Success ? StatusMessageType.Warning : StatusMessageType.Error);
-                }
+                var confirmResult = await _serverApiService.ConfirmApplyTaskAsync(_baseAddress, _password, _localIPv4, taskId);
+                ShowStatusMessage(
+                    confirmResult.Success ? "设备连接已恢复,已自动确认保留配置。" : $"自动确认保留配置失败:{confirmResult.Message}",
+                    confirmResult.Success ? StatusMessageType.Success : StatusMessageType.Error);
             }
 
             if (task.Status is "success" or "failed" or "rolled_back")
             {
+                if (task.Status is "success" or "rolled_back")
+                {
+                    SetBusyState(true, string.Empty);
+                }
+
                 if (task.Status == "success")
                 {
                     _configValidated = false;
                     _configDirty = false;
-                    SetConfigStateMessage("配置已应用,当前显示为设备最新配置。", false);
+                    SetConfigStateMessage("配置已保存,当前显示为设备最新配置。", false);
+                    foreach (var editor in _interfaces)
+                    {
+                        await LoadRemoteInterfaceConfigAsync(editor);
+                    }
                 }
 
                 ShowTaskCompletionDialog(task);
-                foreach (var editor in _interfaces)
-                {
-                    await LoadRemoteInterfaceConfigAsync(editor);
-                }
 
                 return;
             }
@@ -354,95 +348,6 @@ public partial class DeviceDetailsWindow : Window
         ShowStatusMessage($"任务 {taskId} 轮询超时,请稍后手动刷新。", StatusMessageType.Warning);
     }
 
-    private bool ShowApplyConfirmationDialog(int timeoutSeconds)
-    {
-        var remaining = timeoutSeconds;
-        var result = false;
-        var messageTextBlock = new TextBlock
-        {
-            Width = 420,
-            TextWrapping = TextWrapping.Wrap,
-            FontSize = 13,
-            Foreground = Brushes.Black,
-        };
-        var confirmButton = new Button
-        {
-            MinWidth = 88,
-            MinHeight = 32,
-            Margin = new Thickness(0, 0, 10, 0),
-            Content = "保留",
-            IsDefault = true,
-        };
-        confirmButton.Style = (Style)FindResource("PrimaryButtonStyle");
-        var cancelButton = new Button
-        {
-            MinWidth = 88,
-            MinHeight = 32,
-            Content = "回滚",
-            IsCancel = true,
-        };
-        var dialog = new Window
-        {
-            Title = "确认保留网络配置",
-            Owner = this,
-            WindowStartupLocation = WindowStartupLocation.CenterOwner,
-            ResizeMode = ResizeMode.NoResize,
-            SizeToContent = SizeToContent.WidthAndHeight,
-            Content = new StackPanel
-            {
-                Margin = new Thickness(18),
-                Children =
-                {
-                    messageTextBlock,
-                    new StackPanel
-                    {
-                        Margin = new Thickness(0, 18, 0, 0),
-                        HorizontalAlignment = HorizontalAlignment.Right,
-                        Orientation = Orientation.Horizontal,
-                        Children = { confirmButton, cancelButton },
-                    },
-                },
-            },
-        };
-
-        void UpdateMessage()
-        {
-            messageTextBlock.Text = "当前客户端仍可连接到设备。是否确认保留这次网络配置?\n\n超时或取消时,Linux 端会自动回滚。";
-            confirmButton.Content = $"保留({remaining}秒)";
-        }
-
-        var timer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
-        timer.Tick += (_, _) =>
-        {
-            remaining--;
-            if (remaining <= 0)
-            {
-                timer.Stop();
-                dialog.DialogResult = false;
-                dialog.Close();
-                return;
-            }
-            UpdateMessage();
-        };
-        confirmButton.Click += (_, _) =>
-        {
-            result = true;
-            dialog.DialogResult = true;
-            dialog.Close();
-        };
-        cancelButton.Click += (_, _) =>
-        {
-            dialog.DialogResult = false;
-            dialog.Close();
-        };
-        dialog.Closed += (_, _) => timer.Stop();
-
-        UpdateMessage();
-        timer.Start();
-        dialog.ShowDialog();
-        return result;
-    }
-
     private async void RebootButton_OnClick(object sender, RoutedEventArgs e)
     {
         await ExecuteSystemActionAsync(
@@ -949,6 +854,23 @@ public partial class DeviceDetailsWindow : Window
         ContentScrollViewer.RaiseEvent(eventArg);
     }
 
+    private void ConfigGrid_OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+    {
+        if (sender is not DataGrid dataGrid)
+        {
+            return;
+        }
+
+        Dispatcher.BeginInvoke(() =>
+        {
+            if (!dataGrid.IsKeyboardFocusWithin)
+            {
+                dataGrid.UnselectAllCells();
+                dataGrid.UnselectAll();
+            }
+        }, System.Windows.Threading.DispatcherPriority.Input);
+    }
+
     private void AddAddressButton_OnClick(object sender, RoutedEventArgs e)
     {
         if ((sender as FrameworkElement)?.DataContext is not InterfaceEditor editor)
@@ -1017,7 +939,7 @@ public partial class DeviceDetailsWindow : Window
         _configValidated = false;
         RefreshChangeState();
         SetConfigStateMessage(
-            _configDirty ? $"配置已修改:{FormatChangedFields()}。需重新校验后才能应用。" : "配置未修改。",
+            _configDirty ? "配置已修改,需重新校验后才能保存。" : "配置未修改。",
             _configDirty);
         UpdateButtonStates();
     }
@@ -1134,16 +1056,16 @@ public partial class DeviceDetailsWindow : Window
     {
         return task.Status switch
         {
-            "success" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置已成功应用。" : task.Detail, StatusMessageType.Success),
-            "failed" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置应用失败。" : task.Detail, StatusMessageType.Error),
-            "rolled_back" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置应用失败,已自动回滚。" : task.Detail, StatusMessageType.Error),
+            "success" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置已成功保存。" : task.Detail, StatusMessageType.Success),
+            "failed" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置保存失败。" : task.Detail, StatusMessageType.Error),
+            "rolled_back" => (string.IsNullOrWhiteSpace(task.Detail) ? "配置保存失败,已自动回滚。" : task.Detail, StatusMessageType.Error),
             _ => task.Step switch
             {
                 "validating" => ("正在校验配置...", StatusMessageType.Info),
                 "writing_netplan" => ("正在写入 Linux 网络配置...", StatusMessageType.Info),
-                "applying" => ("正在应用 Linux 网络配置...", StatusMessageType.Info),
-                "confirming" => (string.IsNullOrWhiteSpace(task.Detail) ? "等待确认保留配置..." : task.Detail, StatusMessageType.Warning),
-                "rolling_back" => ("配置应用失败,正在自动回滚...", StatusMessageType.Warning),
+                "applying" => ("正在保存 Linux 网络配置...", StatusMessageType.Info),
+                "confirming" => ("设备连接已恢复,正在自动确认保留配置...", StatusMessageType.Info),
+                "rolling_back" => ("配置保存失败,正在自动回滚...", StatusMessageType.Warning),
                 _ => (string.IsNullOrWhiteSpace(task.Detail) ? "正在处理,请稍候..." : task.Detail, StatusMessageType.Info),
             }
         };
@@ -1152,7 +1074,7 @@ public partial class DeviceDetailsWindow : Window
     private void ShowTaskCompletionDialog(RemoteTaskResult task)
     {
         var (message, _) = FormatTaskStatusMessage(task);
-        var title = task.Status == "success" ? "应用配置成功" : "应用配置失败";
+        var title = task.Status == "success" ? "保存配置成功" : "保存配置失败";
         var image = task.Status == "success" ? MessageBoxImage.Information : MessageBoxImage.Warning;
         MessageBox.Show(this, message, title, MessageBoxButton.OK, image);
     }
@@ -1172,6 +1094,7 @@ public partial class DeviceDetailsWindow : Window
     {
         _isBusy = isBusy;
         BusyOverlay.Visibility = isBusy ? Visibility.Visible : Visibility.Collapsed;
+        BusyMessageTextBlock.Visibility = isBusy && message == string.Empty ? Visibility.Collapsed : Visibility.Visible;
         BusyMessageTextBlock.Text = string.IsNullOrWhiteSpace(message) ? "正在处理,请稍候..." : message;
         UpdateButtonStates();
     }
@@ -1287,10 +1210,12 @@ public partial class DeviceDetailsWindow : Window
         private string[] GetGatewayKeys()
         {
             var keys = new List<string>();
-            if (DefaultGatewayEnabled || !string.IsNullOrWhiteSpace(DefaultGateway))
+            keys.Add($"default enabled {DefaultGatewayEnabled}");
+            if (!string.IsNullOrWhiteSpace(DefaultGateway))
             {
                 keys.Add($"default via {DefaultGateway.Trim()}");
             }
+            keys.Add($"custom routes enabled {CustomRoutesEnabled}");
             if (CustomRoutesEnabled)
             {
                 keys.AddRange(Routes
@@ -1318,7 +1243,25 @@ public partial class DeviceDetailsWindow : Window
 
         private static string FormatGatewaySummary(bool dhcp4, IReadOnlyList<string> keys)
         {
-            return dhcp4 ? "自动获取" : FormatKeys(keys);
+            if (dhcp4)
+            {
+                return "自动获取";
+            }
+
+            var defaultGatewayEnabled = keys.Contains("default enabled True");
+            var customRoutesEnabled = keys.Contains("custom routes enabled True");
+            var displayItems = new List<string>();
+            if (defaultGatewayEnabled)
+            {
+                var defaultGateway = keys.FirstOrDefault(item => item.StartsWith("default via ", StringComparison.Ordinal));
+                displayItems.Add(defaultGateway is null ? "默认网关已启用(未填写)" : $"默认网关:{defaultGateway[12..]}");
+            }
+            if (customRoutesEnabled)
+            {
+                displayItems.AddRange(keys.Where(item => !item.StartsWith("default ", StringComparison.Ordinal) && !item.StartsWith("custom routes enabled ", StringComparison.Ordinal)));
+            }
+
+            return FormatKeys(displayItems);
         }
 
         private void SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
@@ -1447,3 +1390,18 @@ public partial class DeviceDetailsWindow : Window
         }
     }
 }
+
+internal static class ApplyConfirmationDialogElementExtensions
+{
+    public static T SetGridRow<T>(this T element, int row) where T : UIElement
+    {
+        Grid.SetRow(element, row);
+        return element;
+    }
+
+    public static T SetMargin<T>(this T element, Thickness margin) where T : FrameworkElement
+    {
+        element.Margin = margin;
+        return element;
+    }
+}

+ 6 - 5
windows/NetworkTool.Client/MainWindow.xaml

@@ -211,11 +211,12 @@
                             <ListView x:Name="DiscoveredDevicesListView"
                                        Grid.Row="2"
                                        Margin="0,12,0,0"
-                                      MinHeight="220"
-                                      Style="{StaticResource DiscoveryListViewStyle}"
-                                      ItemContainerStyle="{StaticResource DiscoveryListViewItemStyle}"
-                                      MouseDoubleClick="DiscoveredDevicesListView_OnMouseDoubleClick"
-                                      SizeChanged="DiscoveredDevicesListView_OnSizeChanged">
+                                       MinHeight="220"
+                                       Style="{StaticResource DiscoveryListViewStyle}"
+                                       ItemContainerStyle="{StaticResource DiscoveryListViewItemStyle}"
+                                       LostKeyboardFocus="DiscoveredDevicesListView_OnLostKeyboardFocus"
+                                       MouseDoubleClick="DiscoveredDevicesListView_OnMouseDoubleClick"
+                                       SizeChanged="DiscoveredDevicesListView_OnSizeChanged">
                                 <ListView.View>
                                     <GridView ColumnHeaderContainerStyle="{StaticResource DiscoveryColumnHeaderStyle}">
                                         <GridViewColumn x:Name="DeviceIpColumn" Width="150" Header="IP">

+ 16 - 0
windows/NetworkTool.Client/MainWindow.xaml.cs

@@ -293,6 +293,22 @@ public partial class MainWindow : Window
         DeviceMacColumn.Width = Math.Max(180, availableWidth - DeviceIpColumn.Width - DeviceHostnameColumn.Width);
     }
 
+    private void DiscoveredDevicesListView_OnLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
+    {
+        if (sender is not ListView listView)
+        {
+            return;
+        }
+
+        Dispatcher.BeginInvoke(() =>
+        {
+            if (!listView.IsKeyboardFocusWithin)
+            {
+                listView.SelectedItem = null;
+            }
+        }, DispatcherPriority.Input);
+    }
+
     private async Task ConnectToDeviceAsync(DiscoveredDevice device)
     {
         var deviceKey = GetDevicePasswordKey(device);

+ 1 - 1
windows/NetworkTool.Client/NetworkTool.Client.csproj

@@ -6,7 +6,7 @@
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
     <UseWPF>true</UseWPF>
-    <InformationalVersion>2026.05.13.1446</InformationalVersion>
+    <InformationalVersion>2026.05.13.1729</InformationalVersion>
   </PropertyGroup>
 
 </Project>