Prechádzať zdrojové kódy

feat(api): 支持批量接口配置校验与应用

- 新增 /api/network/validate-all 和 apply-all 接口
- 实现 WriteMany 以支持多接口 Netplan 写入
- 引入 ServerVersion 用于版本一致性检查
yangkaixiang 1 mesiac pred
rodič
commit
a56c1be3be

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

@@ -29,6 +29,20 @@ windows\NetworkTool.Client\bin\Debug\net9.0-windows\
 
 本节默认前提:当前 PowerShell 已位于 `D:\git\NetworkTool\server`。
 
+### 3.0 Server 版本号约定
+
+每次修改 `server` 端代码时,必须同步更新 `server/internal/config/config.go` 中的 `ServerVersion`。
+
+版本号格式固定为当前时间:`yyyyMMddHHmmss`,例如 `20260511152102`。
+
+该版本号用于排查客户端与远端 Server 是否一致:
+
+1. Server 启动日志会输出版本号
+2. `/api/device/info` 会返回 `server_version`
+3. Windows 客户端“设备信息与接口配置”标题栏会显示该版本号
+
+注意:只要改动了 `server` 目录下会影响 Server 行为的代码,就要更新该版本号。
+
 ### 3.1 编译 Linux amd64 版 Server
 
 在 `server\` 目录执行:

+ 1 - 0
server/cmd/networktool-server/main.go

@@ -26,6 +26,7 @@ func main() {
 
 	log := logger.New()
 	cfg := config.Load(os.Args[1:])
+	log.Info("server starting", "version", cfg.ServerVersion)
 	if os.Geteuid() != 0 {
 		log.Warn("server is not running as root; netplan write/apply and system actions will fail")
 	}

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

@@ -6,6 +6,8 @@ import (
 	"net"
 )
 
+const ServerVersion = "20260511152102"
+
 type Config struct {
 	HTTPHost         string
 	HTTPPort         int
@@ -27,7 +29,7 @@ func Load(args []string) Config {
 		MaintenanceIP:    "169.254.100.2",
 		MaintenanceCIDR:  "169.254.100.2/16",
 		AdminPassword:    "Dt123$",
-		ServerVersion:    "0.1.0",
+		ServerVersion:    ServerVersion,
 		DeviceIDFallback: "networktool-device",
 	}
 

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

@@ -58,7 +58,9 @@ func (s *Server) Run(ctx context.Context) error {
 	mux.Handle("/api/network/interfaces", auth.Middleware(s.cfg, http.HandlerFunc(s.handleInterfaces)))
 	mux.Handle("/api/network/config", auth.Middleware(s.cfg, http.HandlerFunc(s.handleConfig)))
 	mux.Handle("/api/network/validate", auth.Middleware(s.cfg, http.HandlerFunc(s.handleValidate)))
+	mux.Handle("/api/network/validate-all", auth.Middleware(s.cfg, http.HandlerFunc(s.handleValidateAll)))
 	mux.Handle("/api/network/apply", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApply)))
+	mux.Handle("/api/network/apply-all", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApplyAll)))
 	mux.Handle("/api/network/apply/confirm", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApplyConfirm)))
 	mux.Handle("/api/network/apply/cancel", auth.Middleware(s.cfg, http.HandlerFunc(s.handleApplyCancel)))
 	mux.Handle("/api/network/rollback", auth.Middleware(s.cfg, http.HandlerFunc(s.handleRollback)))
@@ -222,6 +224,24 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "校验通过", Data: result})
 }
 
+func (s *Server) handleValidateAll(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	var input model.InterfaceConfigsRequest
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体格式不正确。"}}})
+		return
+	}
+	result := s.validateConfigs(input.Configs)
+	if !result.Valid {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
+		return
+	}
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "校验通过", Data: result})
+}
+
 func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
@@ -255,6 +275,35 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "配置任务已提交", Data: map[string]any{"interface": input.Interface, "task_id": task.TaskID}})
 }
 
+func (s *Server) handleApplyAll(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
+		return
+	}
+	if !hasRootPrivileges() {
+		writeJSON(w, http.StatusForbidden, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"Server 未以 root 身份运行,无法写入 netplan 或执行 netplan apply。"}}})
+		return
+	}
+	var input model.InterfaceConfigsRequest
+	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 2001, Message: "参数错误", Data: map[string][]string{"errors": []string{"请求体格式不正确。"}}})
+		return
+	}
+	result := s.validateConfigs(input.Configs)
+	if !result.Valid {
+		writeJSON(w, http.StatusBadRequest, model.APIResponse{Code: 3001, Message: "配置校验失败", Data: result})
+		return
+	}
+	management := s.currentManagementInterface()
+	if management == "" {
+		writeJSON(w, http.StatusInternalServerError, model.APIResponse{Code: 4001, Message: "系统执行失败", Data: map[string][]string{"errors": []string{"未能识别管理接口。"}}})
+		return
+	}
+	task := s.taskSvc.Create()
+	go s.runApplyAllTask(task.TaskID, input.Configs, management)
+	writeJSON(w, http.StatusOK, model.APIResponse{Code: 0, Message: "配置任务已提交", Data: map[string]any{"task_id": task.TaskID}})
+}
+
 func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		writeJSON(w, http.StatusMethodNotAllowed, model.APIResponse{Code: 2002, Message: "资源不存在", Data: nil})
@@ -388,6 +437,104 @@ func (s *Server) runApplyTask(taskID string, input model.InterfaceConfig, manage
 	}
 }
 
+func (s *Server) runApplyAllTask(taskID string, inputs []model.InterfaceConfig, managementInterface string) {
+	s.taskSvc.Update(taskID, "running", "validating", "正在校验配置。", false)
+	result := s.validateConfigs(inputs)
+	if !result.Valid {
+		s.taskSvc.Update(taskID, "failed", "validating", "配置校验失败。", false)
+		return
+	}
+
+	filePath, err := s.netplanSvc.FindSingleFile()
+	if err != nil {
+		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
+		return
+	}
+
+	s.taskSvc.Update(taskID, "running", "writing_netplan", "正在写入 netplan 配置。", false)
+	backupPath, err := s.netplanSvc.Backup(filePath)
+	if err != nil {
+		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
+		return
+	}
+	if err := s.netplanSvc.WriteMany(filePath, inputs, managementInterface, s.cfg.MaintenanceCIDR); err != nil {
+		s.taskSvc.Update(taskID, "failed", "writing_netplan", err.Error(), false)
+		return
+	}
+	if writtenData, err := os.ReadFile(filePath); err != nil {
+		s.log.Error("failed to read netplan after write", "task_id", taskID, "file", filePath, "error", err.Error())
+	} else {
+		s.log.Info("netplan written", "task_id", taskID, "file", filePath, "content", string(writtenData))
+	}
+
+	control := s.registerApplyControl(taskID)
+	defer s.unregisterApplyControl(taskID)
+	s.taskSvc.Update(taskID, "running", "applying", "正在应用 netplan 配置。", false)
+	if err := s.applySvc.Apply(); err != nil {
+		s.log.Error("netplan apply failed, restoring netplan file", "task_id", taskID, "file", filePath, "error", err.Error())
+		_ = s.netplanSvc.Restore(filePath, backupPath)
+		_ = s.applySvc.Apply()
+		_ = s.interfaceSvc.EnsureMaintenanceAddress()
+		s.logNetplanFile(taskID, filePath, "netplan restored after apply failure")
+		s.taskSvc.Update(taskID, "rolled_back", "rolling_back", fmt.Sprintf("应用 netplan 失败,已自动回滚:%v", err), true)
+		return
+	}
+
+	s.taskSvc.Update(taskID, "running", "confirming", fmt.Sprintf("配置已应用,请在 %d 秒内确认保留;未确认将自动回滚。", int(applyConfirmationTimeout.Seconds())), false)
+	select {
+	case <-control.confirm:
+		_ = os.Remove(backupPath)
+		s.taskSvc.Update(taskID, "success", "completed", "配置已应用并由客户端确认保留。", false)
+		return
+	case <-control.cancel:
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "用户取消保留配置")
+		return
+	case <-time.After(applyConfirmationTimeout):
+		s.rollbackAppliedConfig(taskID, filePath, backupPath, "确认超时")
+		return
+	}
+}
+
+func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateResponse {
+	result := model.ValidateResponse{Valid: true, Warnings: []string{}, Errors: []string{}}
+	if len(inputs) == 0 {
+		result.Valid = false
+		result.Errors = append(result.Errors, "接口配置不能为空。")
+		return result
+	}
+	seen := make(map[string]struct{})
+	for _, input := range inputs {
+		name := strings.TrimSpace(input.Interface)
+		if name == "" {
+			result.Valid = false
+			result.Errors = append(result.Errors, "目标接口不能为空。")
+			continue
+		}
+		if _, ok := seen[name]; ok {
+			result.Valid = false
+			result.Errors = append(result.Errors, fmt.Sprintf("接口重复:%s", name))
+			continue
+		}
+		seen[name] = struct{}{}
+		if !s.interfaceExists(name) {
+			result.Valid = false
+			result.Errors = append(result.Errors, fmt.Sprintf("目标接口不存在:%s", name))
+			continue
+		}
+		item := s.validatorSvc.Validate(input)
+		if !item.Valid {
+			result.Valid = false
+		}
+		for _, err := range item.Errors {
+			result.Errors = append(result.Errors, fmt.Sprintf("%s:%s", name, err))
+		}
+		for _, warning := range item.Warnings {
+			result.Warnings = append(result.Warnings, fmt.Sprintf("%s:%s", name, warning))
+		}
+	}
+	return result
+}
+
 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)

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

@@ -70,6 +70,10 @@ type InterfaceConfig struct {
 	Gateway   string                   `json:"gateway,omitempty"`
 }
 
+type InterfaceConfigsRequest struct {
+	Configs []InterfaceConfig `json:"configs"`
+}
+
 type InterfaceAddressConfig struct {
 	IP     string `json:"ip"`
 	Prefix int    `json:"prefix"`

+ 41 - 35
server/internal/network/netplan/netplan.go

@@ -59,6 +59,10 @@ func (s *Service) Restore(path string, backupPath string) error {
 }
 
 func (s *Service) Write(path string, targetInterface string, input model.InterfaceConfig, managementInterface string, maintenanceCIDR string) error {
+	return s.WriteMany(path, []model.InterfaceConfig{input}, managementInterface, maintenanceCIDR)
+}
+
+func (s *Service) WriteMany(path string, inputs []model.InterfaceConfig, managementInterface string, maintenanceCIDR string) error {
 	data, err := os.ReadFile(path)
 	if err != nil {
 		return err
@@ -76,48 +80,50 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 	}
 
 	ethernets := ensureMap(cfg.Network, "ethernets")
-	target := ensureMap(ethernets, targetInterface)
-	if input.Dhcp4 {
-		target["dhcp4"] = true
-		delete(target, "addresses")
+	for _, input := range inputs {
+		targetInterface := strings.TrimSpace(input.Interface)
+		if targetInterface == "" {
+			return fmt.Errorf("目标接口不能为空")
+		}
+		target := ensureMap(ethernets, targetInterface)
+		if input.Dhcp4 {
+			target["dhcp4"] = true
+			delete(target, "addresses")
+			delete(target, "gateway4")
+			delete(target, "routes")
+			if len(input.DNS) > 0 {
+				nameservers := ensureMap(target, "nameservers")
+				nameservers["addresses"] = input.DNS
+			} else {
+				delete(target, "nameservers")
+			}
+			continue
+		}
+
+		addresses := normalizedAddresses(input)
+		routes := normalizedRoutes(input)
+		target["dhcp4"] = false
+		addressItems := make([]string, 0, len(addresses))
+		for _, address := range addresses {
+			addressItems = append(addressItems, fmt.Sprintf("%s/%d", strings.TrimSpace(address.IP), address.Prefix))
+		}
+		target["addresses"] = addressItems
 		delete(target, "gateway4")
-		delete(target, "routes")
+		if len(routes) > 0 {
+			routeItems := make([]map[string]string, 0, len(routes))
+			for _, route := range routes {
+				routeItems = append(routeItems, map[string]string{"to": strings.TrimSpace(route.To), "via": strings.TrimSpace(route.Via)})
+			}
+			target["routes"] = routeItems
+		} else {
+			delete(target, "routes")
+		}
 		if len(input.DNS) > 0 {
 			nameservers := ensureMap(target, "nameservers")
 			nameservers["addresses"] = input.DNS
 		} else {
 			delete(target, "nameservers")
 		}
-		output, err := marshalYAML(&cfg)
-		if err != nil {
-			return err
-		}
-		return os.WriteFile(path, output, 0600)
-	}
-
-	addresses := normalizedAddresses(input)
-	routes := normalizedRoutes(input)
-	target["dhcp4"] = false
-	addressItems := make([]string, 0, len(addresses))
-	for _, address := range addresses {
-		addressItems = append(addressItems, fmt.Sprintf("%s/%d", strings.TrimSpace(address.IP), address.Prefix))
-	}
-	target["addresses"] = addressItems
-	delete(target, "gateway4")
-	if len(routes) > 0 {
-		routeItems := make([]map[string]string, 0, len(routes))
-		for _, route := range routes {
-			routeItems = append(routeItems, map[string]string{"to": strings.TrimSpace(route.To), "via": strings.TrimSpace(route.Via)})
-		}
-		target["routes"] = routeItems
-	} else {
-		delete(target, "routes")
-	}
-	if len(input.DNS) > 0 {
-		nameservers := ensureMap(target, "nameservers")
-		nameservers["addresses"] = input.DNS
-	} else {
-		delete(target, "nameservers")
 	}
 
 	output, err := marshalYAML(&cfg)

+ 169 - 220
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -3,10 +3,9 @@
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         Title="设备信息与接口配置"
         Height="760"
-        Width="1100"
+        Width="960"
         MinHeight="680"
-        MinWidth="1000"
-        WindowState="Maximized"
+        MinWidth="900"
         ShowInTaskbar="False"
         WindowStartupLocation="CenterOwner">
     <Grid Background="#F5F7FB">
@@ -15,7 +14,7 @@
                       VerticalScrollBarVisibility="Auto"
                       HorizontalScrollBarVisibility="Disabled"
                       CanContentScroll="True">
-        <Grid>
+        <Grid MaxWidth="940" HorizontalAlignment="Center">
             <Grid.RowDefinitions>
                 <RowDefinition Height="Auto" />
                 <RowDefinition Height="Auto" />
@@ -46,223 +45,173 @@
                             Padding="12,0"
                             VerticalAlignment="Center"
                             Click="ReloadInterfaceConfigButton_OnClick"
-                            Content="刷新接口配置" />
+                            Content="刷新全部接口配置" />
 
-                    <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>
+                    <ItemsControl x:Name="InterfacesItemsControl" Grid.Row="1" Grid.ColumnSpan="2" Margin="0,12,0,0">
+                        <ItemsControl.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>
-
-                    <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>
-
-                                    <Border Margin="0,0,8,0" Padding="10" Background="White" BorderBrush="#E5E7EB" BorderThickness="1" CornerRadius="8">
-                                        <Grid>
+                                <Border Margin="0,0,0,12" Padding="14" Background="#F9FAFB" BorderBrush="#D1D5DB" BorderThickness="1" CornerRadius="10">
+                                    <StackPanel>
+                                        <StackPanel Orientation="Horizontal">
+                                            <TextBlock FontSize="15" FontWeight="SemiBold" Foreground="#111827" Text="{Binding SystemName}" />
+                                            <TextBlock Margin="10,2,0,0" FontSize="12" Foreground="#6B7280" Text="{Binding StatusSummary}" />
+                                        </StackPanel>
+                                        <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>
                                                 <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 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">
-                                                    <TextBlock VerticalAlignment="Center" FontSize="12" Foreground="#6B7280" Text="默认网关:" />
-                                                    <CheckBox x:Name="DefaultGatewayCheckBox" Margin="8,0,0,0" 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>
-                                            <StackPanel Grid.Row="1" Margin="0,0,0,8" Orientation="Horizontal">
-                                                <TextBlock VerticalAlignment="Center" FontSize="12" Foreground="#6B7280" Text="自定义路由:" />
-                                                <CheckBox x:Name="CustomRoutesCheckBox" Margin="8,0,0,0" VerticalContentAlignment="Center" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用" />
-                                            </StackPanel>
-                                            <DataGrid x:Name="RoutesDataGrid"
-                                                      Grid.Row="2"
-                                                      Margin="0,8,0,0"
-                                                      MaxHeight="260"
-                                                      AutoGenerateColumns="False"
-                                                      CanUserAddRows="False"
-                                                      HeadersVisibility="Column"
-                                                      CellEditEnding="ConfigGrid_OnCellEditEnding">
-                                                <DataGrid.Style>
-                                                    <Style TargetType="DataGrid">
-                                                        <Setter Property="Visibility" Value="Collapsed" />
-                                                        <Style.Triggers>
-                                                            <DataTrigger Binding="{Binding IsChecked, ElementName=CustomRoutesCheckBox}" Value="True">
-                                                                <Setter Property="Visibility" Value="Visible" />
-                                                            </DataTrigger>
-                                                        </Style.Triggers>
-                                                    </Style>
-                                                </DataGrid.Style>
-                                                <DataGrid.Columns>
-                                                    <DataGridTextColumn Header="目标网段" Binding="{Binding To, UpdateSourceTrigger=PropertyChanged}" Width="*" />
-                                                    <DataGridTextColumn Header="子网掩码" Binding="{Binding Mask, UpdateSourceTrigger=PropertyChanged}" Width="120" />
-                                                    <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="+ 添加路由">
-                                                <Button.Style>
-                                                    <Style TargetType="Button">
-                                                        <Setter Property="Visibility" Value="Collapsed" />
-                                                        <Style.Triggers>
-                                                            <DataTrigger Binding="{Binding IsChecked, ElementName=CustomRoutesCheckBox}" Value="True">
-                                                                <Setter Property="Visibility" Value="Visible" />
-                                                            </DataTrigger>
-                                                        </Style.Triggers>
-                                                    </Style>
-                                                </Button.Style>
-                                            </Button>
-                                        </Grid>
-                                    </Border>
 
-                                    <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" />
+                                            <Border 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 Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding Addresses}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
+                                                        <DataGrid.Style>
+                                                            <Style TargetType="DataGrid">
+                                                                <Setter Property="IsEnabled" Value="True" />
+                                                                <Style.Triggers>
+                                                                    <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                        <Setter Property="IsEnabled" Value="False" />
+                                                                    </DataTrigger>
+                                                                </Style.Triggers>
+                                                            </Style>
+                                                        </DataGrid.Style>
+                                                        <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 Grid.Row="2" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddAddressButton_OnClick" Content="+ 添加 IP">
+                                                        <Button.Style>
+                                                            <Style TargetType="Button">
+                                                                <Setter Property="IsEnabled" Value="True" />
+                                                                <Style.Triggers>
+                                                                    <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                        <Setter Property="IsEnabled" Value="False" />
+                                                                    </DataTrigger>
+                                                                </Style.Triggers>
+                                                            </Style>
+                                                        </Button.Style>
+                                                    </Button>
+                                                </Grid>
+                                            </Border>
+
+                                            <Border Grid.Row="1" Margin="0,12,0,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">
+                                                            <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="启用" />
+                                                            <TextBox Margin="12,0,0,0" MinWidth="220" MinHeight="30" VerticalContentAlignment="Center" Text="{Binding DefaultGateway, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextChanged="ConfigInputChanged_OnChanged">
+                                                                <TextBox.Style>
+                                                                    <Style TargetType="TextBox">
+                                                                        <Setter Property="IsEnabled" Value="True" />
+                                                                        <Style.Triggers>
+                                                                            <DataTrigger Binding="{Binding Dhcp4}" Value="True">
+                                                                                <Setter Property="IsEnabled" Value="False" />
+                                                                            </DataTrigger>
+                                                                            <DataTrigger Binding="{Binding DefaultGatewayEnabled}" Value="False">
+                                                                                <Setter Property="IsEnabled" Value="False" />
+                                                                            </DataTrigger>
+                                                                        </Style.Triggers>
+                                                                    </Style>
+                                                                </TextBox.Style>
+                                                            </TextBox>
+                                                        </StackPanel>
+                                                    </StackPanel>
+                                                    <StackPanel Grid.Row="1" Margin="0,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="启用" />
+                                                    </StackPanel>
+                                                    <DataGrid Grid.Row="2" Margin="0,8,0,0" ItemsSource="{Binding Routes}" AutoGenerateColumns="False" CanUserAddRows="False" HeadersVisibility="Column" CellEditEnding="ConfigGrid_OnCellEditEnding" PreviewMouseWheel="DataGrid_OnPreviewMouseWheel">
+                                                        <DataGrid.Style>
+                                                            <Style TargetType="DataGrid">
+                                                                <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.Columns>
+                                                            <DataGridTextColumn Header="目标网段" Binding="{Binding To, UpdateSourceTrigger=PropertyChanged}" Width="*" />
+                                                            <DataGridTextColumn Header="子网掩码" Binding="{Binding Mask, UpdateSourceTrigger=PropertyChanged}" Width="120" />
+                                                            <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 Grid.Row="3" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddRouteButton_OnClick" Content="+ 添加路由">
+                                                        <Button.Style>
+                                                            <Style TargetType="Button">
+                                                                <Setter Property="Visibility" Value="Collapsed" />
+                                                                <Style.Triggers>
+                                                                    <DataTrigger Binding="{Binding CustomRoutesEnabled}" Value="True">
+                                                                        <Setter Property="Visibility" Value="Visible" />
+                                                                    </DataTrigger>
+                                                                </Style.Triggers>
+                                                            </Style>
+                                                        </Button.Style>
+                                                    </Button>
+                                                </Grid>
+                                            </Border>
+
+                                            <Border Grid.Row="2" Margin="0,12,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 Grid.Row="1" Margin="0,8,0,0" 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}" Width="*" />
+                                                            <DataGridTemplateColumn Header="操作" Width="58">
+                                                                <DataGridTemplateColumn.CellTemplate>
+                                                                    <DataTemplate>
+                                                                        <Button Padding="8,0" Click="DeleteDnsButton_OnClick" Content="删除" />
+                                                                    </DataTemplate>
+                                                                </DataGridTemplateColumn.CellTemplate>
+                                                            </DataGridTemplateColumn>
+                                                        </DataGrid.Columns>
+                                                    </DataGrid>
+                                                    <Button Grid.Row="2" Margin="0,10,0,0" HorizontalAlignment="Left" MinHeight="30" Padding="12,0" Click="AddDnsButton_OnClick" Content="+ 添加 DNS" />
+                                                </Grid>
+                                            </Border>
                                         </Grid>
-                                    </Border>
-                                </Grid>
-                            </StackPanel>
-                        </Border>
-                    </Grid>
+                                    </StackPanel>
+                                </Border>
+                            </DataTemplate>
+                        </ItemsControl.ItemTemplate>
+                    </ItemsControl>
 
                     <Grid Grid.Row="3" Grid.ColumnSpan="2" Margin="0,12,0,0">
                         <Grid.ColumnDefinitions>
@@ -273,21 +222,21 @@
                         <TextBlock 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="校验配置" />
+                                     MinHeight="36"
+                                     Padding="14,0"
+                                     Click="ValidateConfigButton_OnClick"
+                                     Content="校验全部配置" />
                             <Button x:Name="ApplyConfigButton"
                                     Margin="10,0,0,0"
                                     MinHeight="36"
                                     Padding="14,0"
-                                    FontWeight="SemiBold"
-                                    Click="ApplyConfigButton_OnClick"
-                                    Content="应用配置">
+                                     FontWeight="SemiBold"
+                                     Click="ApplyConfigButton_OnClick"
+                                     Content="应用全部配置">
                                 <Button.Style>
                                     <Style TargetType="Button">
                                         <Setter Property="Background" Value="#2563EB" />

+ 258 - 162
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -4,6 +4,7 @@ using System.ComponentModel;
 using System.Runtime.CompilerServices;
 using System.Windows;
 using System.Windows.Controls;
+using System.Windows.Input;
 using System.Windows.Media;
 using System.Windows.Media.Animation;
 using NetworkTool.Client.Models;
@@ -15,9 +16,7 @@ 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 ObservableCollection<InterfaceEditor> _interfaces = [];
     private readonly string _baseAddress;
     private readonly string _remoteHost;
     private readonly string _localIPv4;
@@ -25,23 +24,20 @@ public partial class DeviceDetailsWindow : Window
     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;
+        InterfacesItemsControl.ItemsSource = _interfaces;
         _baseAddress = baseAddress;
         _remoteHost = GetRemoteHost(baseAddress);
         _localIPv4 = localIPv4;
         _password = password;
         UpdateWindowTitle();
         Loaded += DeviceDetailsWindow_OnLoaded;
+        Closing += DeviceDetailsWindow_OnClosing;
     }
 
     private async void DeviceDetailsWindow_OnLoaded(object sender, RoutedEventArgs e)
@@ -64,7 +60,7 @@ public partial class DeviceDetailsWindow : Window
         var device = await _serverApiService.GetDeviceInfoAsync(_baseAddress, _password, _localIPv4);
         if (device is not null)
         {
-            UpdateWindowTitle(device.Hostname);
+            UpdateWindowTitle(device.Hostname, device.ServerVersion);
         }
 
         var interfaces = await _serverApiService.GetInterfacesAsync(_baseAddress, _password, _localIPv4);
@@ -74,38 +70,32 @@ public partial class DeviceDetailsWindow : Window
             return;
         }
 
-        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);
-        RemoteTargetInterfaceTabControl.ItemsSource = interfaces.Interfaces;
-        if (suggested is not null)
+        ShowStatusMessage($"当前管理接口:{interfaces.ManagementInterface}。正在读取全部接口配置。");
+        foreach (var info in interfaces.Interfaces)
         {
-            RemoteTargetInterfaceTabControl.SelectedItem = suggested;
-            await LoadRemoteInterfaceConfigAsync(suggested.SystemName);
-            _currentSelectedInterface = suggested;
+            var editor = new InterfaceEditor(info);
+            _interfaces.Add(editor);
+            await LoadRemoteInterfaceConfigAsync(editor);
         }
+
+        _configValidated = false;
+        _configDirty = false;
+        ShowStatusMessage("已读取全部接口配置。");
     }
 
     private void ClearDetails()
     {
         UpdateWindowTitle();
-        RemoteTargetInterfaceTabControl.ItemsSource = null;
-        _addresses.Clear();
-        _routes.Clear();
-        _dns.Clear();
-        DefaultGatewayCheckBox.IsChecked = false;
-        DefaultGatewayTextBox.Text = string.Empty;
-        CustomRoutesCheckBox.IsChecked = false;
+        _interfaces.Clear();
         _configValidated = false;
         _configDirty = false;
-        _currentSelectedInterface = null;
     }
 
-    private void UpdateWindowTitle(string? hostname = null)
+    private void UpdateWindowTitle(string? hostname = null, string? serverVersion = null)
     {
         var hostPart = string.IsNullOrWhiteSpace(hostname) ? _remoteHost : $"{hostname} ({_remoteHost})";
-        Title = string.IsNullOrWhiteSpace(hostPart) ? "设备信息与接口配置" : $"设备信息与接口配置 - {hostPart}";
+        var versionPart = string.IsNullOrWhiteSpace(serverVersion) ? string.Empty : $" - Server {serverVersion}";
+        Title = string.IsNullOrWhiteSpace(hostPart) ? $"设备信息与接口配置{versionPart}" : $"设备信息与接口配置 - {hostPart}{versionPart}";
     }
 
     private static string GetRemoteHost(string baseAddress)
@@ -113,49 +103,7 @@ public partial class DeviceDetailsWindow : Window
         return Uri.TryCreate(baseAddress, UriKind.Absolute, out var uri) ? uri.Host : baseAddress;
     }
 
-    private async void RemoteTargetInterfaceTabControl_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
-    {
-        try
-        {
-            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)
-        {
-            ShowStatusMessage($"读取目标接口配置失败:{ex.Message}");
-            SetBusyState(false);
-        }
-    }
-
-    private async Task LoadRemoteInterfaceConfigAsync(string interfaceName, bool useBusyState = false)
+    private async Task LoadRemoteInterfaceConfigAsync(InterfaceEditor editor, bool useBusyState = false)
     {
         if (useBusyState)
         {
@@ -164,53 +112,51 @@ public partial class DeviceDetailsWindow : Window
 
         try
         {
-            var result = await _serverApiService.GetInterfaceConfigAsync(_baseAddress, _password, _localIPv4, interfaceName);
+            var result = await _serverApiService.GetInterfaceConfigAsync(_baseAddress, _password, _localIPv4, editor.SystemName);
             if (!result.Success || result.Data is null)
             {
-                ShowStatusMessage($"读取目标接口 {interfaceName} 配置失败:{result.Message}");
+                ShowStatusMessage($"读取目标接口 {editor.SystemName} 配置失败:{result.Message}");
                 return;
             }
 
             var config = result.Data;
             _suppressConfigChangeHandling = true;
-            Dhcp4CheckBox.IsChecked = false;
-            _addresses.Clear();
+            editor.Dhcp4 = config.Dhcp4;
+            editor.Addresses.Clear();
             foreach (var address in config.EffectiveAddresses)
             {
-                _addresses.Add(new EditableAddress { IP = address.IP, Mask = PrefixToMask(address.Prefix) });
+                editor.Addresses.Add(new EditableAddress(editor) { IP = address.IP, Mask = PrefixToMask(address.Prefix) });
             }
-            _routes.Clear();
-            DefaultGatewayCheckBox.IsChecked = false;
-            DefaultGatewayTextBox.Text = string.Empty;
+            editor.Routes.Clear();
+            editor.DefaultGatewayEnabled = false;
+            editor.DefaultGateway = string.Empty;
             foreach (var route in config.EffectiveRoutes)
             {
                 if (route.To.Equals("default", StringComparison.OrdinalIgnoreCase))
                 {
-                    DefaultGatewayCheckBox.IsChecked = true;
-                    DefaultGatewayTextBox.Text = route.Via;
+                    editor.DefaultGatewayEnabled = true;
+                    editor.DefaultGateway = route.Via;
                 }
                 else
                 {
-                    _routes.Add(CreateEditableRoute(route));
+                    editor.Routes.Add(CreateEditableRoute(editor, route));
                 }
             }
-            CustomRoutesCheckBox.IsChecked = _routes.Count > 0;
-            _dns.Clear();
+            editor.CustomRoutesEnabled = editor.Routes.Count > 0;
+            editor.Dns.Clear();
             if (config.Dns is not null)
             {
                 foreach (var dns in config.Dns)
                 {
-                    _dns.Add(new EditableDns { Address = dns });
+                    editor.Dns.Add(new EditableDns(editor) { Address = dns });
                 }
             }
             _suppressConfigChangeHandling = false;
-            _configValidated = false;
-            _configDirty = false;
-            ShowStatusMessage("已读取Linux端IP配置。");
             UpdateButtonStates();
         }
         finally
         {
+            _suppressConfigChangeHandling = false;
             if (useBusyState)
             {
                 SetBusyState(false);
@@ -220,20 +166,50 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ReloadInterfaceConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (RemoteTargetInterfaceTabControl.SelectedItem is RemoteInterfaceInfo selected)
+        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用,刷新会丢失未应用内容。是否继续刷新?", "确认刷新配置"))
         {
-            await LoadRemoteInterfaceConfigAsync(selected.SystemName);
+            return;
+        }
+
+        SetBusyState(true, "正在刷新全部接口配置...");
+        try
+        {
+            foreach (var editor in _interfaces)
+            {
+                await LoadRemoteInterfaceConfigAsync(editor);
+            }
+
+            _configValidated = false;
+            _configDirty = false;
+            ShowStatusMessage("已刷新全部接口配置。");
+        }
+        finally
+        {
+            SetBusyState(false);
         }
     }
 
-    private async void ValidateConfigButton_OnClick(object sender, RoutedEventArgs e)
+    private void DeviceDetailsWindow_OnClosing(object? sender, CancelEventArgs e)
     {
-        if (RemoteTargetInterfaceTabControl.SelectedItem is not RemoteInterfaceInfo selected)
+        if (!ConfirmDiscardPendingChanges("当前配置已修改但尚未应用。是否关闭窗口?", "确认关闭窗口"))
         {
-            return;
+            e.Cancel = true;
         }
+    }
 
-        var request = BuildConfigRequest(selected.SystemName);
+    private bool ConfirmDiscardPendingChanges(string message, string title)
+    {
+        if (!_configDirty)
+        {
+            return true;
+        }
+
+        return MessageBox.Show(this, message, title, MessageBoxButton.OKCancel, MessageBoxImage.Warning) == MessageBoxResult.OK;
+    }
+
+    private async void ValidateConfigButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        var request = BuildConfigRequests();
         if (request is null)
         {
             return;
@@ -242,13 +218,13 @@ public partial class DeviceDetailsWindow : Window
         SetBusyState(true, "正在校验配置,请稍候...");
         try
         {
-            var result = await _serverApiService.ValidateInterfaceConfigAsync(_baseAddress, _password, _localIPv4, request);
+            var result = await _serverApiService.ValidateInterfaceConfigsAsync(_baseAddress, _password, _localIPv4, request);
             _configValidated = result.Success && result.Data?.Valid == true;
             if (result.Data is not null)
             {
                 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;
-                ShowStatusMessage(_configValidated ? $"校验通过,可应用配置。{warnings}" : $"校验失败。{errors}{warnings}");
+                ShowStatusMessage(_configValidated ? $"全部接口校验通过,可应用配置。{warnings}" : $"校验失败。{errors}{warnings}");
             }
             else
             {
@@ -265,23 +241,13 @@ public partial class DeviceDetailsWindow : Window
 
     private async void ApplyConfigButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (RemoteTargetInterfaceTabControl.SelectedItem is not RemoteInterfaceInfo selected)
-        {
-            return;
-        }
-
-        var request = BuildConfigRequest(selected.SystemName);
+        var request = BuildConfigRequests();
         if (request is null)
         {
             return;
         }
 
-        var confirmMessage = $"将要把以下配置应用到接口 {selected.SystemName}:\n\n" +
-                             $"模式:{(request.Dhcp4 ? "DHCP 自动获取" : "静态 IPv4")}\n" +
-                              $"IP:{(request.Dhcp4 ? "自动获取" : FormatAddresses(request.Addresses))}\n" +
-                              $"路由:{(request.Dhcp4 ? "自动获取" : FormatRoutes(request.Routes))}\n" +
-                             $"DNS:{(request.Dns.Count == 0 ? "无" : string.Join(", ", request.Dns))}\n\n" +
-                             "请确认是否继续。";
+        var confirmMessage = "将要一次性应用以下接口配置:\n\n" + FormatConfigSummary(request) + "\n\n请确认是否继续。";
         if (MessageBox.Show(this, confirmMessage, "确认应用配置", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
         {
             return;
@@ -290,7 +256,7 @@ public partial class DeviceDetailsWindow : Window
         SetBusyState(true, "正在提交并应用配置,请稍候...");
         try
         {
-            var applyResult = await _serverApiService.ApplyInterfaceConfigAsync(_baseAddress, _password, _localIPv4, request);
+            var applyResult = await _serverApiService.ApplyInterfaceConfigsAsync(_baseAddress, _password, _localIPv4, request);
             if (!applyResult.Success || applyResult.Data is null)
             {
                 ShowStatusMessage($"提交配置任务失败:{applyResult.Message}");
@@ -349,9 +315,9 @@ public partial class DeviceDetailsWindow : Window
             if (task.Status is "success" or "failed" or "rolled_back")
             {
                 ShowTaskCompletionDialog(task);
-                if (RemoteTargetInterfaceTabControl.SelectedItem is RemoteInterfaceInfo selected)
+                foreach (var editor in _interfaces)
                 {
-                    await LoadRemoteInterfaceConfigAsync(selected.SystemName);
+                    await LoadRemoteInterfaceConfigAsync(editor);
                 }
 
                 return;
@@ -481,37 +447,60 @@ public partial class DeviceDetailsWindow : Window
         ShowStatusMessage($"{title}任务已提交:{result.Data.TaskId}。命令已发出,设备可能立即断开。");
     }
 
-    private RemoteInterfaceConfig? BuildConfigRequest(string interfaceName)
+    private RemoteInterfaceConfig[]? BuildConfigRequests()
     {
         CommitConfigEdits();
-        var dhcp4 = Dhcp4CheckBox.IsChecked == true;
+        var result = new List<RemoteInterfaceConfig>();
+        foreach (var editor in _interfaces)
+        {
+            var request = BuildConfigRequest(editor);
+            if (request is null)
+            {
+                return null;
+            }
+
+            result.Add(request);
+        }
+
+        if (result.Count == 0)
+        {
+            ShowStatusMessage("接口配置不能为空。");
+            return null;
+        }
+
+        return result.ToArray();
+    }
+
+    private RemoteInterfaceConfig? BuildConfigRequest(InterfaceEditor editor)
+    {
+        var dhcp4 = editor.Dhcp4;
         var addresses = Array.Empty<RemoteInterfaceAddressConfig>();
         var routes = Array.Empty<RemoteInterfaceRouteConfig>();
         if (!dhcp4)
         {
-            if (_addresses.All(item => string.IsNullOrWhiteSpace(item.IP) && string.IsNullOrWhiteSpace(item.Mask)))
+            if (editor.Addresses.All(item => string.IsNullOrWhiteSpace(item.IP) && string.IsNullOrWhiteSpace(item.Mask)))
             {
-                ShowStatusMessage("IP 地址不能为空,至少需要填写一行地址。");
+                ShowStatusMessage($"{editor.SystemName}:IP 地址不能为空,至少需要填写一行地址。");
                 return null;
             }
 
-            if (!TryBuildAddresses(out addresses, out var addressError))
+            if (!TryBuildAddresses(editor, out addresses, out var addressError))
             {
-                ShowStatusMessage(addressError);
+                ShowStatusMessage($"{editor.SystemName}:{addressError}");
                 return null;
             }
 
-            if (!TryBuildRoutes(out routes, out var routeError))
+            if (!TryBuildRoutes(editor, out routes, out var routeError))
             {
-                ShowStatusMessage(routeError);
+                ShowStatusMessage($"{editor.SystemName}:{routeError}");
                 return null;
             }
         }
 
-        var dns = _dns.Select(item => item.Address.Trim()).Where(item => item != string.Empty).ToArray();
+        var dns = editor.Dns.Select(item => item.Address.Trim()).Where(item => item != string.Empty).ToArray();
         return new RemoteInterfaceConfig
         {
-            Interface = interfaceName,
+            Interface = editor.SystemName,
             Dhcp4 = dhcp4,
             Addresses = dhcp4 ? Array.Empty<RemoteInterfaceAddressConfig>() : addresses,
             Routes = dhcp4 ? Array.Empty<RemoteInterfaceRouteConfig>() : routes,
@@ -521,12 +510,21 @@ 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);
+        CommitDataGridEdits(InterfacesItemsControl);
+    }
+
+    private static void CommitDataGridEdits(DependencyObject root)
+    {
+        for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
+        {
+            var child = VisualTreeHelper.GetChild(root, i);
+            if (child is DataGrid dataGrid)
+            {
+                dataGrid.CommitEdit(DataGridEditingUnit.Cell, true);
+                dataGrid.CommitEdit(DataGridEditingUnit.Row, true);
+            }
+            CommitDataGridEdits(child);
+        }
     }
 
     private static string FormatCurrentIp(RemoteInterfaceConfig config)
@@ -550,7 +548,17 @@ public partial class DeviceDetailsWindow : Window
         return routes.Count == 0 ? "无" : string.Join(Environment.NewLine, routes.Select(item => $"{item.To} via {item.Via}"));
     }
 
-    private static EditableRoute CreateEditableRoute(RemoteInterfaceRouteConfig route)
+    private static string FormatConfigSummary(IReadOnlyList<RemoteInterfaceConfig> configs)
+    {
+        return string.Join(Environment.NewLine + Environment.NewLine, configs.Select(item =>
+            $"接口:{item.Interface}\n" +
+            $"模式:{(item.Dhcp4 ? "DHCP 自动获取" : "静态 IPv4")}\n" +
+            $"IP:{(item.Dhcp4 ? "自动获取" : FormatAddresses(item.Addresses))}\n" +
+            $"路由:{(item.Dhcp4 ? "自动获取" : FormatRoutes(item.Routes))}\n" +
+            $"DNS:{(item.Dns.Count == 0 ? "无" : string.Join(", ", item.Dns))}"));
+    }
+
+    private static EditableRoute CreateEditableRoute(InterfaceEditor owner, RemoteInterfaceRouteConfig route)
     {
         var to = route.To.Trim();
         var mask = string.Empty;
@@ -564,13 +572,13 @@ public partial class DeviceDetailsWindow : Window
             }
         }
 
-        return new EditableRoute { To = to, Mask = mask, Via = route.Via };
+        return new EditableRoute(owner) { To = to, Mask = mask, Via = route.Via };
     }
 
-    private bool TryBuildAddresses(out RemoteInterfaceAddressConfig[] addresses, out string error)
+    private bool TryBuildAddresses(InterfaceEditor editor, out RemoteInterfaceAddressConfig[] addresses, out string error)
     {
         var result = new List<RemoteInterfaceAddressConfig>();
-        foreach (var row in _addresses)
+        foreach (var row in editor.Addresses)
         {
             var ip = row.IP.Trim();
             var maskText = row.Mask.Trim();
@@ -613,12 +621,12 @@ public partial class DeviceDetailsWindow : Window
         return addresses.Length > 0;
     }
 
-    private bool TryBuildRoutes(out RemoteInterfaceRouteConfig[] routes, out string error)
+    private bool TryBuildRoutes(InterfaceEditor editor, out RemoteInterfaceRouteConfig[] routes, out string error)
     {
         var result = new List<RemoteInterfaceRouteConfig>();
-        if (DefaultGatewayCheckBox.IsChecked == true)
+        if (editor.DefaultGatewayEnabled)
         {
-            var gateway = DefaultGatewayTextBox.Text.Trim();
+            var gateway = editor.DefaultGateway.Trim();
             if (gateway == string.Empty)
             {
                 routes = [];
@@ -627,9 +635,9 @@ public partial class DeviceDetailsWindow : Window
             }
             result.Add(new RemoteInterfaceRouteConfig { To = "default", Via = gateway });
         }
-        if (CustomRoutesCheckBox.IsChecked == true)
+        if (editor.CustomRoutesEnabled)
         {
-            foreach (var row in _routes)
+            foreach (var row in editor.Routes)
             {
                 var to = row.To.Trim();
                 var maskText = row.Mask.Trim();
@@ -865,21 +873,49 @@ public partial class DeviceDetailsWindow : Window
         MarkConfigChanged("配置内容已变更,请重新点击“2. 校验配置”。");
     }
 
+    private void DataGrid_OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
+    {
+        if (sender is not DataGrid dataGrid)
+        {
+            return;
+        }
+
+        e.Handled = true;
+        var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta)
+        {
+            RoutedEvent = MouseWheelEvent,
+            Source = dataGrid,
+        };
+        ContentScrollViewer.RaiseEvent(eventArg);
+    }
+
     private void AddAddressButton_OnClick(object sender, RoutedEventArgs e)
     {
-        _addresses.Add(new EditableAddress { Mask = "255.255.255.0" });
+        if ((sender as FrameworkElement)?.DataContext is not InterfaceEditor editor)
+        {
+            return;
+        }
+        editor.Addresses.Add(new EditableAddress(editor) { Mask = "255.255.255.0" });
         MarkConfigChanged("已添加 IP 地址,请填写后重新校验配置。");
     }
 
     private void AddRouteButton_OnClick(object sender, RoutedEventArgs e)
     {
-        _routes.Add(new EditableRoute());
+        if ((sender as FrameworkElement)?.DataContext is not InterfaceEditor editor)
+        {
+            return;
+        }
+        editor.Routes.Add(new EditableRoute(editor));
         MarkConfigChanged("已添加路由,请填写后重新校验配置。");
     }
 
     private void AddDnsButton_OnClick(object sender, RoutedEventArgs e)
     {
-        _dns.Add(new EditableDns());
+        if ((sender as FrameworkElement)?.DataContext is not InterfaceEditor editor)
+        {
+            return;
+        }
+        editor.Dns.Add(new EditableDns(editor));
         MarkConfigChanged("已添加 DNS,请填写后重新校验配置。");
     }
 
@@ -889,7 +925,7 @@ public partial class DeviceDetailsWindow : Window
         {
             return;
         }
-        _addresses.Remove(address);
+        address.Owner.Addresses.Remove(address);
         MarkConfigChanged("已删除 IP 地址,请重新校验配置。");
     }
 
@@ -897,7 +933,7 @@ public partial class DeviceDetailsWindow : Window
     {
         if ((sender as FrameworkElement)?.DataContext is EditableRoute route)
         {
-            _routes.Remove(route);
+            route.Owner.Routes.Remove(route);
             MarkConfigChanged("已删除路由,请重新校验配置。");
         }
     }
@@ -906,7 +942,7 @@ public partial class DeviceDetailsWindow : Window
     {
         if ((sender as FrameworkElement)?.DataContext is EditableDns dns)
         {
-            _dns.Remove(dns);
+            dns.Owner.Dns.Remove(dns);
             MarkConfigChanged("已删除 DNS,请重新校验配置。");
         }
     }
@@ -1050,26 +1086,11 @@ public partial class DeviceDetailsWindow : Window
 
     private void UpdateButtonStates()
     {
-        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;
-
-        RemoteTargetInterfaceTabControl.IsEnabled = !_isBusy && RemoteTargetInterfaceTabControl.Items.Count > 0;
-        ReloadInterfaceConfigButton.IsEnabled = canEdit;
-        ValidateConfigButton.IsEnabled = canEdit;
-        ApplyConfigButton.IsEnabled = !_isBusy && _configValidated && hasSelectedInterface;
-        Dhcp4CheckBox.IsEnabled = canEdit;
-        AddressesDataGrid.IsEnabled = canEditStatic;
-        DefaultGatewayCheckBox.IsEnabled = canEditStatic;
-        DefaultGatewayTextBox.IsEnabled = canEditGateway;
-        CustomRoutesCheckBox.IsEnabled = canEditStatic;
-        RoutesDataGrid.IsEnabled = canEditCustomRoutes;
-        DnsDataGrid.IsEnabled = canEdit;
-        AddAddressButton.IsEnabled = canEditStatic;
-        AddRouteButton.IsEnabled = canEditCustomRoutes;
-        AddDnsButton.IsEnabled = canEdit;
+        var hasInterfaces = _interfaces.Count > 0;
+        InterfacesItemsControl.IsEnabled = !_isBusy && hasInterfaces;
+        ReloadInterfaceConfigButton.IsEnabled = !_isBusy && hasInterfaces;
+        ValidateConfigButton.IsEnabled = !_isBusy && hasInterfaces;
+        ApplyConfigButton.IsEnabled = !_isBusy && _configValidated && hasInterfaces;
         RebootButton.IsEnabled = !_isBusy;
         ShutdownButton.IsEnabled = !_isBusy;
     }
@@ -1082,11 +1103,74 @@ public partial class DeviceDetailsWindow : Window
         UpdateButtonStates();
     }
 
+    private sealed class InterfaceEditor : INotifyPropertyChanged
+    {
+        private bool _dhcp4;
+        private bool _defaultGatewayEnabled;
+        private bool _customRoutesEnabled;
+        private string _defaultGateway = string.Empty;
+
+        public InterfaceEditor(RemoteInterfaceInfo info)
+        {
+            SystemName = info.SystemName;
+            StatusSummary = info.StatusSummary;
+        }
+
+        public string SystemName { get; }
+        public string StatusSummary { get; }
+        public ObservableCollection<EditableAddress> Addresses { get; } = [];
+        public ObservableCollection<EditableRoute> Routes { get; } = [];
+        public ObservableCollection<EditableDns> Dns { get; } = [];
+
+        public bool Dhcp4
+        {
+            get => _dhcp4;
+            set => SetField(ref _dhcp4, value);
+        }
+
+        public bool DefaultGatewayEnabled
+        {
+            get => _defaultGatewayEnabled;
+            set => SetField(ref _defaultGatewayEnabled, value);
+        }
+
+        public bool CustomRoutesEnabled
+        {
+            get => _customRoutesEnabled;
+            set => SetField(ref _customRoutesEnabled, value);
+        }
+
+        public string DefaultGateway
+        {
+            get => _defaultGateway;
+            set => SetField(ref _defaultGateway, 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 EditableAddress : INotifyPropertyChanged
     {
         private string _ip = string.Empty;
         private string _mask = string.Empty;
 
+        public EditableAddress(InterfaceEditor owner)
+        {
+            Owner = owner;
+        }
+
+        public InterfaceEditor Owner { get; }
+
         public string IP
         {
             get => _ip;
@@ -1114,6 +1198,12 @@ public partial class DeviceDetailsWindow : Window
 
     private sealed class EditableRoute
     {
+        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;
@@ -1121,6 +1211,12 @@ public partial class DeviceDetailsWindow : Window
 
     private sealed class EditableDns
     {
+        public EditableDns(InterfaceEditor owner)
+        {
+            Owner = owner;
+        }
+
+        public InterfaceEditor Owner { get; }
         public string Address { get; set; } = string.Empty;
     }
 }

+ 6 - 0
windows/NetworkTool.Client/Models/RemoteInterfaceConfig.cs

@@ -59,3 +59,9 @@ public sealed class RemoteInterfaceConfig
         ? Routes
         : string.IsNullOrWhiteSpace(Gateway) ? [] : [new RemoteInterfaceRouteConfig { To = "default", Via = Gateway }];
 }
+
+public sealed class RemoteInterfaceConfigsRequest
+{
+    [JsonPropertyName("configs")]
+    public IReadOnlyList<RemoteInterfaceConfig> Configs { get; init; } = [];
+}

+ 105 - 0
windows/NetworkTool.Client/Services/ServerApiService.cs

@@ -168,6 +168,42 @@ public sealed class ServerApiService
         }
     }
 
+    public Task<ApiCallResult<RemoteValidateResult>> ValidateInterfaceConfigsAsync(string baseAddress, string password, string localIPv4, IReadOnlyList<RemoteInterfaceConfig> inputs, CancellationToken cancellationToken = default)
+    {
+        return ValidateInterfaceConfigsAsync(baseAddress, password, localIPv4, new RemoteInterfaceConfigsRequest { Configs = inputs }, cancellationToken);
+    }
+
+    private async Task<ApiCallResult<RemoteValidateResult>> ValidateInterfaceConfigsAsync(string baseAddress, string password, string localIPv4, RemoteInterfaceConfigsRequest input, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            using var client = CreateClient(baseAddress, password, localIPv4);
+            using var response = await client.PostAsJsonAsync("/api/network/validate-all", input, _jsonOptions, cancellationToken);
+            var content = await response.Content.ReadAsStringAsync(cancellationToken);
+            var wrapper = DeserializeEnvelope<RemoteValidateResult>(content);
+            if (wrapper is null)
+            {
+                return CreateInvalidJsonResult<RemoteValidateResult>(response.StatusCode, content, "批量校验");
+            }
+
+            return new ApiCallResult<RemoteValidateResult>
+            {
+                Success = response.IsSuccessStatusCode && wrapper?.Data is not null,
+                StatusCode = (int)response.StatusCode,
+                Message = wrapper?.Message ?? (response.IsSuccessStatusCode ? "校验通过" : $"校验失败,HTTP 状态码 {(int)response.StatusCode}。"),
+                Data = wrapper?.Data,
+            };
+        }
+        catch (Exception ex)
+        {
+            return new ApiCallResult<RemoteValidateResult>
+            {
+                Success = false,
+                Message = ex.Message,
+            };
+        }
+    }
+
     public async Task<ApiCallResult<RemoteApplyTaskResponse>> ApplyInterfaceConfigAsync(string baseAddress, string password, string localIPv4, RemoteInterfaceConfig input, CancellationToken cancellationToken = default)
     {
         try
@@ -195,6 +231,42 @@ public sealed class ServerApiService
         }
     }
 
+    public Task<ApiCallResult<RemoteApplyTaskResponse>> ApplyInterfaceConfigsAsync(string baseAddress, string password, string localIPv4, IReadOnlyList<RemoteInterfaceConfig> inputs, CancellationToken cancellationToken = default)
+    {
+        return ApplyInterfaceConfigsAsync(baseAddress, password, localIPv4, new RemoteInterfaceConfigsRequest { Configs = inputs }, cancellationToken);
+    }
+
+    private async Task<ApiCallResult<RemoteApplyTaskResponse>> ApplyInterfaceConfigsAsync(string baseAddress, string password, string localIPv4, RemoteInterfaceConfigsRequest input, CancellationToken cancellationToken = default)
+    {
+        try
+        {
+            using var client = CreateClient(baseAddress, password, localIPv4);
+            using var response = await client.PostAsJsonAsync("/api/network/apply-all", input, _jsonOptions, cancellationToken);
+            var content = await response.Content.ReadAsStringAsync(cancellationToken);
+            var wrapper = DeserializeEnvelope<RemoteApplyTaskResponse>(content);
+            if (wrapper is null)
+            {
+                return CreateInvalidJsonResult<RemoteApplyTaskResponse>(response.StatusCode, content, "批量应用");
+            }
+
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = response.IsSuccessStatusCode && wrapper?.Data is not null,
+                StatusCode = (int)response.StatusCode,
+                Message = wrapper?.Message ?? (response.IsSuccessStatusCode ? "配置任务已提交" : $"提交失败,HTTP 状态码 {(int)response.StatusCode}。"),
+                Data = wrapper?.Data,
+            };
+        }
+        catch (Exception ex)
+        {
+            return new ApiCallResult<RemoteApplyTaskResponse>
+            {
+                Success = false,
+                Message = ex.Message,
+            };
+        }
+    }
+
     public async Task<ApiCallResult<RemoteTaskResult>> GetTaskAsync(string baseAddress, string password, string localIPv4, string taskId, CancellationToken cancellationToken = default)
     {
         try
@@ -338,4 +410,37 @@ public sealed class ServerApiService
         public string Message { get; set; } = string.Empty;
         public T? Data { get; set; }
     }
+
+    private ApiEnvelope<T>? DeserializeEnvelope<T>(string content)
+    {
+        try
+        {
+            return JsonSerializer.Deserialize<ApiEnvelope<T>>(content, _jsonOptions);
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+    }
+
+    private static ApiCallResult<T> CreateInvalidJsonResult<T>(HttpStatusCode statusCode, string content, string actionName)
+    {
+        var body = string.IsNullOrWhiteSpace(content) ? "响应为空" : content.Trim();
+        if (body.Length > 160)
+        {
+            body = body[..160] + "...";
+        }
+
+        var status = (int)statusCode;
+        var hint = status == 404
+            ? $"Linux 端 Server 可能还未更新,不支持{actionName}接口。请重新发布并启动最新 Server。"
+            : $"Linux 端 Server 返回了无法解析的{actionName}响应。";
+
+        return new ApiCallResult<T>
+        {
+            Success = false,
+            StatusCode = status,
+            Message = $"{hint}HTTP {status}:{body}",
+        };
+    }
 }