3 İşlemeler cc191f6184 ... 30e05bca03

Yazar SHA1 Mesaj Tarih
  yangkaixiang 30e05bca03 feat(ui): 支持设备搜索实时增量更新并添加进度提示 1 ay önce
  yangkaixiang 4a7135f8a2 feat(ui): 网卡未选时显示占位提示并优化刷新逻辑 1 ay önce
  yangkaixiang abe8eae811 fix(network): 限制单接口及批量配置仅允许一个默认网关 1 ay önce

+ 2 - 1
docs/03-通信与HTTP_API.md

@@ -305,7 +305,8 @@ X-Admin-Password: Dt123$
 6. `routes[].to` 必须为 `default` 或 IPv4 CIDR
 7. `routes[].via` 必须为 IPv4 地址,且必须与任一 `addresses` 在同一子网
 8. 若任一 `addresses[].ip` 为 `169.254.x.x`,返回中文警告,不直接报错
-9. 为兼容旧客户端,Server 仍接受旧字段 `ip`、`prefix`、`gateway`,并转换为 `addresses` 与默认路由
+9. 单个接口最多只能配置 1 条 `default` 路由
+10. 批量配置中最多只能有 1 个网口配置默认网关;多个网口同时配置默认网关时校验失败
 
 ### 3.6 应用指定接口配置
 

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

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

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

@@ -505,6 +505,7 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 		return result
 	}
 	seen := make(map[string]struct{})
+	defaultRouteInterfaces := make([]string, 0, 1)
 	managementInterface := s.currentManagementInterface()
 	for _, input := range inputs {
 		name := strings.TrimSpace(input.Interface)
@@ -524,6 +525,9 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 			result.Errors = append(result.Errors, fmt.Sprintf("目标接口不存在:%s", name))
 			continue
 		}
+		if hasDefaultRouteConfig(input) {
+			defaultRouteInterfaces = append(defaultRouteInterfaces, name)
+		}
 		item := s.validatorSvc.Validate(input)
 		if name == managementInterface {
 			addManagementAddressWarning(&item, input)
@@ -538,9 +542,25 @@ func (s *Server) validateConfigs(inputs []model.InterfaceConfig) model.ValidateR
 			result.Warnings = append(result.Warnings, fmt.Sprintf("%s:%s", name, warning))
 		}
 	}
+	if len(defaultRouteInterfaces) > 1 {
+		result.Valid = false
+		result.Errors = append(result.Errors, fmt.Sprintf("只能有一个网口配置默认网关,当前检测到多个默认网关:%s。", strings.Join(defaultRouteInterfaces, "、")))
+	}
 	return result
 }
 
+func hasDefaultRouteConfig(input model.InterfaceConfig) bool {
+	if input.Dhcp4 {
+		return false
+	}
+	for _, route := range input.Routes {
+		if strings.TrimSpace(route.To) == "default" && strings.TrimSpace(route.Via) != "" {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *Server) addManagementAddressWarning(result *model.ValidateResponse, input model.InterfaceConfig) {
 	managementInterface := s.currentManagementInterface()
 	if managementInterface == "" || strings.TrimSpace(input.Interface) != managementInterface {

+ 7 - 1
server/internal/network/validator/validator.go

@@ -58,6 +58,7 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 		validNetworks = append(validNetworks, &net.IPNet{IP: ip.Mask(mask), Mask: mask})
 	}
 	seenRoutes := make(map[string]struct{})
+	defaultRouteCount := 0
 	for _, route := range routes {
 		to := strings.TrimSpace(route.To)
 		via := strings.TrimSpace(route.Via)
@@ -69,7 +70,12 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 			resp.Errors = append(resp.Errors, fmt.Sprintf("路由 %s 的下一跳不能为空。", to))
 			continue
 		}
-		if to != "default" {
+		if to == "default" {
+			defaultRouteCount++
+			if defaultRouteCount > 1 {
+				resp.Errors = append(resp.Errors, "默认网关只能配置一个。")
+			}
+		} else {
 			ip, ipNet, err := net.ParseCIDR(to)
 			if err != nil || ip == nil || ip.To4() == nil || ipNet == nil {
 				resp.Errors = append(resp.Errors, fmt.Sprintf("路由目标格式不正确:%s", to))

+ 31 - 8
windows/NetworkTool.Client/MainWindow.xaml

@@ -134,10 +134,10 @@
                                 <Grid.ColumnDefinitions>
                                     <ColumnDefinition Width="*" />
                                 </Grid.ColumnDefinitions>
-                                <ComboBox x:Name="AdapterComboBox"
-                                          MinHeight="36"
-                                          VerticalContentAlignment="Center"
-                                          SelectionChanged="AdapterComboBox_OnSelectionChanged">
+                                 <ComboBox x:Name="AdapterComboBox"
+                                           MinHeight="36"
+                                           VerticalContentAlignment="Center"
+                                           SelectionChanged="AdapterComboBox_OnSelectionChanged">
                                     <ComboBox.ItemTemplate>
                                         <DataTemplate>
                                             <Grid>
@@ -153,9 +153,15 @@
                                                            Text="{Binding IPv4Display}" />
                                             </Grid>
                                         </DataTemplate>
-                                    </ComboBox.ItemTemplate>
-                                </ComboBox>
-                            </Grid>
+                                     </ComboBox.ItemTemplate>
+                                 </ComboBox>
+                                <TextBlock x:Name="AdapterPlaceholderTextBlock"
+                                           Margin="12,0,0,0"
+                                           VerticalAlignment="Center"
+                                           Foreground="#9CA3AF"
+                                           IsHitTestVisible="False"
+                                           Text="请选择本机网卡" />
+                             </Grid>
                         </StackPanel>
                     </Border>
 
@@ -171,6 +177,8 @@
                                 <Grid.ColumnDefinitions>
                                     <ColumnDefinition Width="*" />
                                     <ColumnDefinition Width="Auto" />
+                                    <ColumnDefinition Width="Auto" />
+                                    <ColumnDefinition Width="Auto" />
                                 </Grid.ColumnDefinitions>
                                 <TextBlock VerticalAlignment="Center"
                                            FontSize="13"
@@ -178,11 +186,26 @@
                                            Foreground="#111827"
                                            Text="发现设备(双击连接)" />
                                 <Button x:Name="SearchDevicesButton"
-                                        Grid.Column="1"
+                                        Grid.Column="3"
                                         MinHeight="32"
                                         Padding="14,0"
                                         Click="SearchDevicesButton_OnClick"
                                         Content="重新搜索设备" />
+                                <ProgressBar x:Name="SearchProgressBar"
+                                             Grid.Column="1"
+                                             Width="72"
+                                             Height="6"
+                                             Margin="12,0,8,0"
+                                             VerticalAlignment="Center"
+                                             IsIndeterminate="True"
+                                             Visibility="Collapsed" />
+                                <TextBlock x:Name="SearchProgressTextBlock"
+                                           Grid.Column="2"
+                                           Margin="0,0,12,0"
+                                           VerticalAlignment="Center"
+                                           Foreground="#6B7280"
+                                           Text="搜索中"
+                                           Visibility="Collapsed" />
                             </Grid>
 
                             <ListView x:Name="DiscoveredDevicesListView"

+ 76 - 13
windows/NetworkTool.Client/MainWindow.xaml.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using System.Collections.ObjectModel;
 using System.Windows;
 using System.Windows.Controls;
 using System.Windows.Input;
@@ -18,8 +19,10 @@ public partial class MainWindow : Window
     private readonly ServerApiService _serverApiService = new();
     private IReadOnlyList<AdapterInfo> _allAdapters = [];
     private IReadOnlyList<AdapterInfo> _adapters = [];
-    private IReadOnlyList<DiscoveredDevice> _discoveredDevices = [];
+    private readonly ObservableCollection<DiscoveredDevice> _discoveredDevices = [];
     private bool _isBusy;
+    private bool _isSearchingDevices;
+    private CancellationTokenSource? _deviceSearchCts;
     private CancellationTokenSource? _statusMessageCts;
 
     public MainWindow()
@@ -50,17 +53,19 @@ public partial class MainWindow : Window
         AdapterComboBox.ItemsSource = _adapters;
 
         var selected = selectedAdapterId is null
-            ? _networkAdapterService.GetRecommendedAdapter(_adapters)
-            : _adapters.FirstOrDefault(adapter => adapter.Id == selectedAdapterId) ?? _networkAdapterService.GetRecommendedAdapter(_adapters);
+            ? null
+            : _adapters.FirstOrDefault(adapter => adapter.Id == selectedAdapterId);
 
         if (selected is not null)
         {
             AdapterComboBox.SelectedItem = selected;
         }
-        else if (_adapters.Count > 0)
+        else
         {
-            AdapterComboBox.SelectedIndex = 0;
+            AdapterComboBox.SelectedIndex = -1;
         }
+
+        UpdateAdapterPlaceholder();
     }
 
     private static bool IsUsableAdapter(AdapterInfo adapter)
@@ -72,14 +77,18 @@ public partial class MainWindow : Window
     {
         if (AdapterComboBox.SelectedItem is not AdapterInfo adapter)
         {
+            CancelDeviceSearch();
             ClearDiscoveredDevices();
+            UpdateAdapterPlaceholder();
             SetStatus("请选择一块网卡。", StatusMessageType.Warning, false);
             UpdateButtonStates();
             return;
         }
 
+        UpdateAdapterPlaceholder();
         if (!adapter.HasLink)
         {
+            CancelDeviceSearch();
             ClearDiscoveredDevices();
             SetStatus("当前网卡未检测到链路,请检查网线连接。", StatusMessageType.Warning, true);
             UpdateButtonStates();
@@ -90,12 +99,18 @@ public partial class MainWindow : Window
         _ = SearchDevicesAsync(adapter);
     }
 
+    private void UpdateAdapterPlaceholder()
+    {
+        AdapterPlaceholderTextBlock.Visibility = AdapterComboBox.SelectedItem is null ? Visibility.Visible : Visibility.Collapsed;
+    }
+
     private async void RefreshAdaptersButton_OnClick(object sender, RoutedEventArgs e)
     {
         SetBusyState(true, "正在刷新本机网卡...");
         try
         {
-            RefreshAdapters();
+            var selectedAdapterId = (AdapterComboBox.SelectedItem as AdapterInfo)?.Id;
+            RefreshAdapters(selectedAdapterId);
             SetStatus("已刷新本机网卡。", StatusMessageType.Success, true);
         }
         catch (Exception ex)
@@ -147,6 +162,7 @@ public partial class MainWindow : Window
 
         if (string.IsNullOrWhiteSpace(adapter.IPv4Address))
         {
+            CancelDeviceSearch();
             ClearDiscoveredDevices();
             SetStatus("当前网卡没有可用 IPv4,无法搜索设备。", StatusMessageType.Error, true);
             return;
@@ -154,19 +170,25 @@ public partial class MainWindow : Window
 
         if (!adapter.HasLink)
         {
+            CancelDeviceSearch();
             ClearDiscoveredDevices();
             SetStatus("当前网卡未检测到链路,请检查网线连接。", StatusMessageType.Warning, true);
             return;
         }
 
-        SetBusyState(true, "正在搜索设备...");
+        _deviceSearchCts?.Cancel();
+        var searchCts = new CancellationTokenSource();
+        _deviceSearchCts = searchCts;
+        SetDeviceSearchState(true);
         ClearDiscoveredDevices();
         await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Render);
 
         try
         {
-            _discoveredDevices = await _discoveryService.DiscoverManyAsync(adapter.IPv4Address);
-            DiscoveredDevicesListView.ItemsSource = _discoveredDevices;
+            await _discoveryService.DiscoverManyAsync(
+                adapter.IPv4Address,
+                device => Dispatcher.Invoke(() => UpsertDiscoveredDevice(device)),
+                searchCts.Token);
             if (_discoveredDevices.Count == 0)
             {
                 SetStatus("未发现 169.254 开头的设备 IP,请确认网卡、网线、远端服务和维护网段配置。", StatusMessageType.Warning, true);
@@ -175,6 +197,9 @@ public partial class MainWindow : Window
 
             SetStatus($"已发现 {_discoveredDevices.Count} 台设备,请双击 IP 连接。", StatusMessageType.Success, true);
         }
+        catch (OperationCanceledException)
+        {
+        }
         catch (Exception ex)
         {
             SetStatus($"搜索设备失败:{ex.Message}", StatusMessageType.Error, true);
@@ -182,7 +207,10 @@ public partial class MainWindow : Window
         }
         finally
         {
-            SetBusyState(false);
+            if (ReferenceEquals(_deviceSearchCts, searchCts))
+            {
+                SetDeviceSearchState(false);
+            }
         }
     }
 
@@ -200,7 +228,7 @@ public partial class MainWindow : Window
         var adapter = AdapterComboBox.SelectedItem as AdapterInfo;
         var hasAdapter = adapter is not null;
         RefreshAdaptersButton.IsEnabled = !_isBusy;
-        SearchDevicesButton.IsEnabled = !_isBusy && hasAdapter && adapter!.HasLink;
+        SearchDevicesButton.IsEnabled = !_isBusy && !_isSearchingDevices && hasAdapter && adapter!.HasLink;
     }
 
     private void RefreshAdapters(string? selectedAdapterId = null)
@@ -211,10 +239,30 @@ public partial class MainWindow : Window
 
     private void ClearDiscoveredDevices()
     {
-        _discoveredDevices = [];
+        _discoveredDevices.Clear();
         DiscoveredDevicesListView.ItemsSource = _discoveredDevices;
     }
 
+    private void UpsertDiscoveredDevice(DiscoveredDevice device)
+    {
+        var existing = _discoveredDevices.FirstOrDefault(value => string.Equals(value.Lan2Ip, device.Lan2Ip, StringComparison.OrdinalIgnoreCase));
+        if (existing is not null)
+        {
+            var index = _discoveredDevices.IndexOf(existing);
+            _discoveredDevices[index] = device;
+            return;
+        }
+
+        var insertIndex = 0;
+        while (insertIndex < _discoveredDevices.Count && string.Compare(_discoveredDevices[insertIndex].Lan2Ip, device.Lan2Ip, StringComparison.OrdinalIgnoreCase) < 0)
+        {
+            insertIndex++;
+        }
+
+        _discoveredDevices.Insert(insertIndex, device);
+        SetStatus($"已发现 {_discoveredDevices.Count} 台设备,搜索仍在继续。", StatusMessageType.Success, true);
+    }
+
     private async void DiscoveredDevicesListView_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
     {
         if (!_isBusy && DiscoveredDevicesListView.SelectedItem is DiscoveredDevice device)
@@ -408,6 +456,21 @@ public partial class MainWindow : Window
         BusyMessageTextBlock.Text = string.IsNullOrWhiteSpace(message) ? "正在处理,请稍候..." : message;
         AdapterComboBox.IsEnabled = !isBusy;
         RefreshAdaptersButton.IsEnabled = !isBusy;
-        SearchDevicesButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.HasLink;
+        SearchDevicesButton.IsEnabled = !isBusy && !_isSearchingDevices && AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.HasLink;
+    }
+
+    private void SetDeviceSearchState(bool isSearching)
+    {
+        _isSearchingDevices = isSearching;
+        SearchProgressBar.Visibility = isSearching ? Visibility.Visible : Visibility.Collapsed;
+        SearchProgressTextBlock.Visibility = isSearching ? Visibility.Visible : Visibility.Collapsed;
+        SearchDevicesButton.Content = isSearching ? "搜索中..." : "重新搜索设备";
+        UpdateButtonStates();
+    }
+
+    private void CancelDeviceSearch()
+    {
+        _deviceSearchCts?.Cancel();
+        SetDeviceSearchState(false);
     }
 }

+ 49 - 5
windows/NetworkTool.Client/Services/DiscoveryService.cs

@@ -11,11 +11,16 @@ namespace NetworkTool.Client.Services;
 public sealed class DiscoveryService
 {
     private const int DiscoveryPort = 50000;
+    private static readonly TimeSpan DiscoveryTimeout = TimeSpan.FromSeconds(5);
+    private static readonly TimeSpan DiscoveryRetryInterval = TimeSpan.FromSeconds(1);
 
     [DllImport("iphlpapi.dll", ExactSpelling = true)]
     private static extern int SendARP(int destinationIp, int sourceIp, byte[] macAddress, ref int physicalAddressLength);
 
-    public async Task<IReadOnlyList<DiscoveredDevice>> DiscoverManyAsync(string localIPv4, CancellationToken cancellationToken = default)
+    public async Task<IReadOnlyList<DiscoveredDevice>> DiscoverManyAsync(
+        string localIPv4,
+        Action<DiscoveredDevice>? onDeviceDiscovered = null,
+        CancellationToken cancellationToken = default)
     {
         if (string.IsNullOrWhiteSpace(localIPv4))
         {
@@ -37,17 +42,53 @@ public sealed class DiscoveryService
         };
 
         var payload = JsonSerializer.SerializeToUtf8Bytes(request);
-        await client.SendAsync(payload, payload.Length, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort));
+        var broadcastEndPoint = new IPEndPoint(IPAddress.Broadcast, DiscoveryPort);
+        await client.SendAsync(payload, payload.Length, broadcastEndPoint);
 
         using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-        timeoutCts.CancelAfter(TimeSpan.FromSeconds(3));
+        timeoutCts.CancelAfter(DiscoveryTimeout);
 
         var devicesByIp = new Dictionary<string, DiscoveredDevice>(StringComparer.OrdinalIgnoreCase);
+        var nextRetryAt = DateTimeOffset.UtcNow + DiscoveryRetryInterval;
         while (!timeoutCts.IsCancellationRequested)
         {
             try
             {
-                var result = await client.ReceiveAsync(timeoutCts.Token);
+                var retryDelay = nextRetryAt - DateTimeOffset.UtcNow;
+                if (retryDelay <= TimeSpan.Zero)
+                {
+                    await client.SendAsync(payload, payload.Length, broadcastEndPoint);
+                    nextRetryAt = DateTimeOffset.UtcNow + DiscoveryRetryInterval;
+                    continue;
+                }
+
+                using var receiveCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token);
+                var receiveTask = client.ReceiveAsync(receiveCts.Token).AsTask();
+                var retryDelayTask = Task.Delay(retryDelay, timeoutCts.Token);
+                var completedTask = await Task.WhenAny(receiveTask, retryDelayTask);
+
+                if (completedTask == retryDelayTask)
+                {
+                    receiveCts.Cancel();
+                    try
+                    {
+                        await receiveTask;
+                    }
+                    catch (OperationCanceledException)
+                    {
+                    }
+
+                    if (timeoutCts.IsCancellationRequested)
+                    {
+                        break;
+                    }
+
+                    await client.SendAsync(payload, payload.Length, broadcastEndPoint);
+                    nextRetryAt = DateTimeOffset.UtcNow + DiscoveryRetryInterval;
+                    continue;
+                }
+
+                var result = await receiveTask;
                 var response = JsonSerializer.Deserialize<DiscoveryResponse>(result.Buffer);
                 if (response is null
                     || response.MessageType != "discover_response"
@@ -63,7 +104,7 @@ public sealed class DiscoveryService
                     mac = ResolveMacAddress(result.RemoteEndPoint.Address);
                 }
 
-                devicesByIp[response.Lan2Ip] = new DiscoveredDevice
+                var device = new DiscoveredDevice
                 {
                     DeviceId = response.DeviceId ?? string.Empty,
                     Hostname = response.Hostname ?? string.Empty,
@@ -73,6 +114,9 @@ public sealed class DiscoveryService
                     HttpPort = response.HttpPort,
                     AuthRequired = response.AuthRequired,
                 };
+
+                devicesByIp[response.Lan2Ip] = device;
+                onDeviceDiscovered?.Invoke(device);
             }
             catch (OperationCanceledException)
             {