Просмотр исходного кода

feat(net): 支持MAC发现、DNS校验及路由UI优化

- discovery: 根据管理IP自动获取并上报MAC地址
- netplan: 仅在有DNS配置时写入nameservers字段
- validator: 前置DNS格式校验逻辑
- ui: 简化设备信息展示,优化路由配置交互
yangkaixiang 1 месяц назад
Родитель
Сommit
91da1dcd44

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

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net"
+	"strings"
 
 	"networktool/internal/config"
 	"networktool/internal/deviceinfo"
@@ -62,6 +63,7 @@ func (s *Server) Run(ctx context.Context) error {
 			DeviceID:        device.DeviceID,
 			Hostname:        device.Hostname,
 			ServerVersion:   device.ServerVersion,
+			MAC:             findMACByIP(s.cfg.MaintenanceIP),
 			LAN2IP:          s.cfg.MaintenanceIP,
 			AuthRequired:    true,
 		}
@@ -69,3 +71,40 @@ func (s *Server) Run(ctx context.Context) error {
 		_, _ = conn.WriteToUDP(payload, remote)
 	}
 }
+
+func findMACByIP(ip string) string {
+	ifaces, err := net.Interfaces()
+	if err != nil {
+		return ""
+	}
+	var fallback string
+	for _, iface := range ifaces {
+		if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 {
+			continue
+		}
+		addrs, err := iface.Addrs()
+		if err != nil {
+			continue
+		}
+		for _, addr := range addrs {
+			var current net.IP
+			switch value := addr.(type) {
+			case *net.IPNet:
+				current = value.IP
+			case *net.IPAddr:
+				current = value.IP
+			}
+			current = current.To4()
+			if current == nil {
+				continue
+			}
+			if current.String() == ip {
+				return iface.HardwareAddr.String()
+			}
+			if fallback == "" && strings.HasPrefix(current.String(), "169.254.") {
+				fallback = iface.HardwareAddr.String()
+			}
+		}
+	}
+	return fallback
+}

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

@@ -54,6 +54,7 @@ type DiscoverResponse struct {
 	DeviceID        string `json:"device_id"`
 	Hostname        string `json:"hostname"`
 	ServerVersion   string `json:"server_version"`
+	MAC             string `json:"mac"`
 	LAN2IP          string `json:"lan2_ip"`
 	AuthRequired    bool   `json:"auth_required"`
 }

+ 6 - 1
server/internal/network/netplan/netplan.go

@@ -82,7 +82,12 @@ func (s *Service) Write(path string, targetInterface string, input model.Interfa
 		delete(target, "addresses")
 		delete(target, "gateway4")
 		delete(target, "routes")
-		delete(target, "nameservers")
+		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

+ 9 - 10
server/internal/network/validator/validator.go

@@ -20,6 +20,15 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 	if input.Interface == "" {
 		resp.Errors = append(resp.Errors, "目标接口不能为空。")
 	}
+	for _, dns := range input.DNS {
+		if dns == "" {
+			continue
+		}
+		parsed := net.ParseIP(dns)
+		if parsed == nil || parsed.To4() == nil {
+			resp.Errors = append(resp.Errors, fmt.Sprintf("DNS 格式不正确:%s", dns))
+		}
+	}
 	if input.Dhcp4 {
 		resp.Valid = len(resp.Errors) == 0
 		return resp
@@ -85,16 +94,6 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 			resp.Errors = append(resp.Errors, fmt.Sprintf("路由下一跳与任一目标接口 IP 都不在同一子网:%s", via))
 		}
 	}
-	for _, dns := range input.DNS {
-		if dns == "" {
-			continue
-		}
-		parsed := net.ParseIP(dns)
-		if parsed == nil || parsed.To4() == nil {
-			resp.Errors = append(resp.Errors, fmt.Sprintf("DNS 格式不正确:%s", dns))
-		}
-	}
-
 	resp.Valid = len(resp.Errors) == 0
 	return resp
 }

+ 31 - 34
windows/NetworkTool.Client/DeviceDetailsWindow.xaml

@@ -19,38 +19,9 @@
             <Grid.RowDefinitions>
                 <RowDefinition Height="Auto" />
                 <RowDefinition Height="Auto" />
-                <RowDefinition Height="Auto" />
-                <RowDefinition Height="*" />
             </Grid.RowDefinitions>
 
-            <UniformGrid Grid.Row="0" Columns="4">
-            <Border Margin="0,0,12,0" Padding="14" Background="White" CornerRadius="10">
-                <StackPanel>
-                    <TextBlock FontSize="12" Foreground="#6B7280" Text="设备 ID" />
-                    <TextBlock x:Name="RemoteDeviceIdTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                </StackPanel>
-            </Border>
-            <Border Margin="0,0,12,0" Padding="14" Background="White" CornerRadius="10">
-                <StackPanel>
-                    <TextBlock FontSize="12" Foreground="#6B7280" Text="主机名" />
-                    <TextBlock x:Name="RemoteHostnameTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                </StackPanel>
-            </Border>
-            <Border Margin="0,0,12,0" Padding="14" Background="White" CornerRadius="10">
-                <StackPanel>
-                    <TextBlock FontSize="12" Foreground="#6B7280" Text="Ubuntu 版本" />
-                    <TextBlock x:Name="RemoteOsVersionTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                </StackPanel>
-            </Border>
-            <Border Padding="14" Background="White" CornerRadius="10">
-                <StackPanel>
-                    <TextBlock FontSize="12" Foreground="#6B7280" Text="Server 版本" />
-                    <TextBlock x:Name="RemoteServerVersionTextBlock" Margin="0,8,0,0" FontSize="14" FontWeight="SemiBold" Foreground="#111827" Text="-" />
-                </StackPanel>
-            </Border>
-            </UniformGrid>
-
-            <Border Grid.Row="1" Margin="0,12,0,0" Padding="14" Background="White" CornerRadius="10">
+            <Border Grid.Row="0" Padding="14" Background="White" CornerRadius="10">
                 <Grid>
                     <Grid.RowDefinitions>
                         <RowDefinition Height="Auto" />
@@ -203,11 +174,15 @@
                                             <StackPanel>
                                                 <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="网关" />
                                                 <StackPanel Margin="0,8,0,12" Orientation="Horizontal">
-                                                    <CheckBox x:Name="DefaultGatewayCheckBox" VerticalContentAlignment="Center" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用" />
+                                                    <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>
-                                            <CheckBox x:Name="CustomRoutesCheckBox" Grid.Row="1" Margin="0,0,0,8" VerticalContentAlignment="Center" Checked="GatewayOrRouteModeChanged_OnChanged" Unchecked="GatewayOrRouteModeChanged_OnChanged" Content="启用自定义路由" />
+                                            <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"
@@ -216,8 +191,19 @@
                                                       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>
@@ -228,7 +214,18 @@
                                                     </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 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>
 
@@ -311,7 +308,7 @@
                 </Grid>
             </Border>
 
-            <Border Grid.Row="2" Margin="0,12,0,0" Padding="14" Background="#FEF2F2" CornerRadius="10">
+            <Border Grid.Row="1" Margin="0,12,0,0" Padding="14" Background="#FEF2F2" CornerRadius="10">
                 <Grid>
                     <Grid.RowDefinitions>
                         <RowDefinition Height="Auto" />

+ 83 - 16
windows/NetworkTool.Client/DeviceDetailsWindow.xaml.cs

@@ -19,6 +19,7 @@ public partial class DeviceDetailsWindow : Window
     private readonly ObservableCollection<EditableRoute> _routes = [];
     private readonly ObservableCollection<EditableDns> _dns = [];
     private readonly string _baseAddress;
+    private readonly string _remoteHost;
     private readonly string _localIPv4;
     private readonly string _password;
     private bool _configValidated;
@@ -36,8 +37,10 @@ public partial class DeviceDetailsWindow : Window
         RoutesDataGrid.ItemsSource = _routes;
         DnsDataGrid.ItemsSource = _dns;
         _baseAddress = baseAddress;
+        _remoteHost = GetRemoteHost(baseAddress);
         _localIPv4 = localIPv4;
         _password = password;
+        UpdateWindowTitle();
         Loaded += DeviceDetailsWindow_OnLoaded;
     }
 
@@ -61,10 +64,7 @@ public partial class DeviceDetailsWindow : Window
         var device = await _serverApiService.GetDeviceInfoAsync(_baseAddress, _password, _localIPv4);
         if (device is not null)
         {
-            RemoteDeviceIdTextBlock.Text = device.DeviceId;
-            RemoteHostnameTextBlock.Text = device.Hostname;
-            RemoteOsVersionTextBlock.Text = device.OSVersion;
-            RemoteServerVersionTextBlock.Text = device.ServerVersion;
+            UpdateWindowTitle(device.Hostname);
         }
 
         var interfaces = await _serverApiService.GetInterfacesAsync(_baseAddress, _password, _localIPv4);
@@ -89,10 +89,7 @@ public partial class DeviceDetailsWindow : Window
 
     private void ClearDetails()
     {
-        RemoteDeviceIdTextBlock.Text = "-";
-        RemoteHostnameTextBlock.Text = "-";
-        RemoteOsVersionTextBlock.Text = "-";
-        RemoteServerVersionTextBlock.Text = "-";
+        UpdateWindowTitle();
         RemoteTargetInterfaceTabControl.ItemsSource = null;
         _addresses.Clear();
         _routes.Clear();
@@ -105,6 +102,17 @@ public partial class DeviceDetailsWindow : Window
         _currentSelectedInterface = null;
     }
 
+    private void UpdateWindowTitle(string? hostname = null)
+    {
+        var hostPart = string.IsNullOrWhiteSpace(hostname) ? _remoteHost : $"{hostname} ({_remoteHost})";
+        Title = string.IsNullOrWhiteSpace(hostPart) ? "设备信息与接口配置" : $"设备信息与接口配置 - {hostPart}";
+    }
+
+    private static string GetRemoteHost(string baseAddress)
+    {
+        return Uri.TryCreate(baseAddress, UriKind.Absolute, out var uri) ? uri.Host : baseAddress;
+    }
+
     private async void RemoteTargetInterfaceTabControl_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
     {
         try
@@ -183,7 +191,7 @@ public partial class DeviceDetailsWindow : Window
                 }
                 else
                 {
-                    _routes.Add(new EditableRoute { To = route.To, Via = route.Via });
+                    _routes.Add(CreateEditableRoute(route));
                 }
             }
             CustomRoutesCheckBox.IsChecked = _routes.Count > 0;
@@ -507,7 +515,7 @@ public partial class DeviceDetailsWindow : Window
             Dhcp4 = dhcp4,
             Addresses = dhcp4 ? Array.Empty<RemoteInterfaceAddressConfig>() : addresses,
             Routes = dhcp4 ? Array.Empty<RemoteInterfaceRouteConfig>() : routes,
-            Dns = dhcp4 ? Array.Empty<string>() : dns,
+            Dns = dns,
         };
     }
 
@@ -542,6 +550,23 @@ 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)
+    {
+        var to = route.To.Trim();
+        var mask = string.Empty;
+        if (to.Contains('/'))
+        {
+            var parts = to.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+            if (parts.Length == 2 && int.TryParse(parts[1], out var prefix) && prefix >= 0 && prefix <= 32)
+            {
+                to = parts[0];
+                mask = PrefixToMask(prefix);
+            }
+        }
+
+        return new EditableRoute { To = to, Mask = mask, Via = route.Via };
+    }
+
     private bool TryBuildAddresses(out RemoteInterfaceAddressConfig[] addresses, out string error)
     {
         var result = new List<RemoteInterfaceAddressConfig>();
@@ -607,18 +632,40 @@ public partial class DeviceDetailsWindow : Window
             foreach (var row in _routes)
             {
                 var to = row.To.Trim();
+                var maskText = row.Mask.Trim();
                 var via = row.Via.Trim();
-                if (to == string.Empty && via == string.Empty)
+                if (to == string.Empty && maskText == string.Empty && via == string.Empty)
                 {
                     continue;
                 }
-                if (to == string.Empty || via == string.Empty)
+                if (to.Contains('/'))
+                {
+                    var parts = to.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+                    if (parts.Length != 2 || !int.TryParse(parts[1], out var cidrPrefix) || cidrPrefix < 0 || cidrPrefix > 32)
+                    {
+                        routes = [];
+                        error = $"自定义路由目标网段格式不正确:{to}";
+                        return false;
+                    }
+                    to = parts[0];
+                    maskText = PrefixToMask(cidrPrefix);
+                    row.To = to;
+                    row.Mask = maskText;
+                }
+                if (to == string.Empty || maskText == string.Empty || via == string.Empty)
+                {
+                    routes = [];
+                    error = "自定义路由的目标网段、子网掩码和网关地址都需要填写。";
+                    return false;
+                }
+                if (!TryMaskOrPrefixToPrefix(maskText, out var prefix))
                 {
                     routes = [];
-                    error = "自定义路由的目标网段和网关地址都需要填写。";
+                    error = $"自定义路由子网掩码格式不正确:{to} {maskText}";
                     return false;
                 }
-                result.Add(new RemoteInterfaceRouteConfig { To = to, Via = via });
+                row.Mask = PrefixToMask(prefix);
+                result.Add(new RemoteInterfaceRouteConfig { To = $"{to}/{prefix}", Via = via });
             }
         }
 
@@ -811,6 +858,10 @@ public partial class DeviceDetailsWindow : Window
         {
             NormalizeAddressRow(address);
         }
+        else if (e.Row.Item is EditableRoute route)
+        {
+            NormalizeRouteRow(route);
+        }
         MarkConfigChanged("配置内容已变更,请重新点击“2. 校验配置”。");
     }
 
@@ -888,6 +939,21 @@ public partial class DeviceDetailsWindow : Window
         }
     }
 
+    private static void NormalizeRouteRow(EditableRoute row)
+    {
+        var to = row.To.Trim();
+        if (!to.Contains('/'))
+        {
+            return;
+        }
+        var parts = to.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        if (parts.Length == 2 && int.TryParse(parts[1], out var prefix) && prefix >= 0 && prefix <= 32)
+        {
+            row.To = parts[0];
+            row.Mask = PrefixToMask(prefix);
+        }
+    }
+
     private void ShowStatusMessage(string message)
     {
         ApplyStatusMessageStyle(message);
@@ -1000,10 +1066,10 @@ public partial class DeviceDetailsWindow : Window
         DefaultGatewayTextBox.IsEnabled = canEditGateway;
         CustomRoutesCheckBox.IsEnabled = canEditStatic;
         RoutesDataGrid.IsEnabled = canEditCustomRoutes;
-        DnsDataGrid.IsEnabled = canEditStatic;
+        DnsDataGrid.IsEnabled = canEdit;
         AddAddressButton.IsEnabled = canEditStatic;
         AddRouteButton.IsEnabled = canEditCustomRoutes;
-        AddDnsButton.IsEnabled = canEditStatic;
+        AddDnsButton.IsEnabled = canEdit;
         RebootButton.IsEnabled = !_isBusy;
         ShutdownButton.IsEnabled = !_isBusy;
     }
@@ -1049,6 +1115,7 @@ public partial class DeviceDetailsWindow : Window
     private sealed class EditableRoute
     {
         public string To { get; set; } = string.Empty;
+        public string Mask { get; set; } = string.Empty;
         public string Via { get; set; } = string.Empty;
     }
 

+ 113 - 164
windows/NetworkTool.Client/MainWindow.xaml

@@ -1,189 +1,138 @@
 <Window x:Class="NetworkTool.Client.MainWindow"
-          xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
-          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
-          xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
-          xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
-         mc:Ignorable="d"
-         Title="NetworkTool"
-         Height="680"
-         Width="980"
-         MinHeight="640"
-         MinWidth="920"
-         WindowStartupLocation="CenterScreen">
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        mc:Ignorable="d"
+        Title="NetworkTool"
+        Height="680"
+        Width="860"
+        MinHeight="640"
+        MinWidth="760"
+        WindowStartupLocation="CenterScreen">
     <Grid Background="#F5F7FB">
-
-        <Grid Margin="24,24,24,24">
-            <Grid.ColumnDefinitions>
-                <ColumnDefinition Width="2.2*" />
-                <ColumnDefinition Width="1.4*" />
-            </Grid.ColumnDefinitions>
-
-            <Border Grid.Column="0"
-                    Margin="0,0,16,0"
-                    Padding="24"
-                    Background="White"
-                    CornerRadius="12">
-                <Grid>
+        <Border MaxWidth="820"
+                Margin="24"
+                Padding="24"
+                HorizontalAlignment="Center"
+                Background="White"
+                CornerRadius="12">
+            <Grid>
+                <Grid.RowDefinitions>
+                    <RowDefinition Height="Auto" />
+                    <RowDefinition Height="*" />
+                </Grid.RowDefinitions>
+
+                <TextBlock FontSize="20"
+                           FontWeight="SemiBold"
+                           Foreground="#111827"
+                           Text="设备发现与连接" />
+
+                <Grid Grid.Row="1" Margin="0,20,0,0">
                     <Grid.RowDefinitions>
                         <RowDefinition Height="Auto" />
                         <RowDefinition Height="*" />
                     </Grid.RowDefinitions>
 
-                    <TextBlock FontSize="20"
-                               FontWeight="SemiBold"
-                               Foreground="#111827"
-                               Text="① 连接准备" />
-
-                    <Grid Grid.Row="1" Margin="0,20,0,0">
-                        <Grid.RowDefinitions>
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="*" />
-                        </Grid.RowDefinitions>
-
-                        <Border Padding="16" Background="#F9FAFB" CornerRadius="10">
-                            <StackPanel>
-                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="本机网卡与连接" />
-                                <TextBlock Margin="0,12,0,0"
-                                           FontSize="13"
-                                           Foreground="#374151"
-                                           Text="本机有线网卡" />
-                                <Grid Margin="0,8,0,0">
-                                    <Grid.ColumnDefinitions>
-                                        <ColumnDefinition Width="*" />
-                                        <ColumnDefinition Width="Auto" />
-                                    </Grid.ColumnDefinitions>
-                                    <ComboBox x:Name="AdapterComboBox"
-                                              MinHeight="36"
-                                              VerticalContentAlignment="Center"
-                                              DisplayMemberPath="DisplayName"
-                                              SelectionChanged="AdapterComboBox_OnSelectionChanged" />
-                                    <Button x:Name="RefreshAdaptersButton"
-                                            Grid.Column="1"
-                                            Margin="8,0,0,0"
-                                            MinHeight="36"
-                                            Padding="14,0"
-                                            Click="RefreshAdaptersButton_OnClick"
-                                            Content="刷新" />
-                                </Grid>
-                                <Border x:Name="AdapterProbeBorder" Margin="0,16,0,0" Padding="14" Background="#F3F4F6" CornerRadius="10">
-                                    <StackPanel>
-                                        <TextBlock x:Name="AdapterProbeTitleTextBlock" FontSize="12" Foreground="#374151" Text="管理口探测结果" />
-                                        <TextBlock x:Name="AdapterProbeTextBlock"
-                                                   Margin="0,8,0,0"
-                                                   FontSize="16"
-                                                   FontWeight="SemiBold"
-                                                   Foreground="#374151"
-                                                   Text="-" />
-                                    </StackPanel>
-                                </Border>
-                            </StackPanel>
-                        </Border>
-
-                        <Border Grid.Row="1" Margin="0,16,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
-                            <StackPanel>
-                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="操作顺序" />
-                                <TextBlock Margin="0,12,0,0" Foreground="#4B5563" Text="1. 选择本机有线网卡" />
-                                <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="2. 输入或确认管理密码" />
-                                <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="3. 点击连接设备" />
-                                <TextBlock Margin="0,8,0,0" Foreground="#4B5563" Text="4. 如果连接失败,再切换到维护网络后重试" />
-                            </StackPanel>
-                        </Border>
-
-                        <Border Grid.Row="2" Margin="0,16,0,0" Padding="16" Background="#F3F4F6" CornerRadius="10">
-                            <Grid>
-                                <Grid.RowDefinitions>
-                                    <RowDefinition Height="Auto" />
-                                    <RowDefinition Height="*" />
-                                </Grid.RowDefinitions>
-                                <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="运行日志" />
-                                <ListBox x:Name="EventLogListBox"
-                                          Grid.Row="1"
-                                          Margin="0,12,0,0"
-                                          Height="108" />
-                            </Grid>
-                        </Border>
-                    </Grid>
-
-                </Grid>
-            </Border>
-
-            <Border Grid.Column="1"
-                    Padding="24"
-                    Background="White"
-                    CornerRadius="12">
-                    <Grid>
-                        <Grid.RowDefinitions>
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="Auto" />
-                            <RowDefinition Height="*" />
-                        </Grid.RowDefinitions>
-
-                    <TextBlock FontSize="20"
-                               FontWeight="SemiBold"
-                               Foreground="#111827"
-                               Text="② 连接与操作" />
-
-                    <Border Grid.Row="1" Margin="0,20,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                    <Border Padding="16" Background="#F9FAFB" CornerRadius="10">
                         <StackPanel>
-                            <TextBlock FontSize="13"
-                                       FontWeight="SemiBold"
-                                       Foreground="#111827"
-                                       Text="管理密码(必填)" />
-                            <Grid Margin="0,10,0,0">
+                            <TextBlock FontSize="13" FontWeight="SemiBold" Foreground="#111827" Text="本机网卡" />
+                            <TextBlock Margin="0,12,0,0"
+                                       FontSize="13"
+                                       Foreground="#374151"
+                                       Text="本机有线网卡" />
+                            <Grid Margin="0,8,0,0">
                                 <Grid.ColumnDefinitions>
                                     <ColumnDefinition Width="*" />
                                     <ColumnDefinition Width="Auto" />
                                 </Grid.ColumnDefinitions>
-                                <PasswordBox x:Name="PasswordBox"
-                                             MinHeight="38"
-                                             VerticalContentAlignment="Center"
-                                             PasswordChanged="PasswordBox_OnPasswordChanged"
-                                             ToolTip="请输入当前 Server 使用的管理密码。" />
-                                <TextBox x:Name="PasswordTextBox"
-                                         Visibility="Collapsed"
-                                         MinHeight="38"
-                                         VerticalContentAlignment="Center"
-                                         TextChanged="PasswordTextBox_OnTextChanged"
-                                         ToolTip="请输入当前 Server 使用的管理密码。" />
-                                <Button x:Name="TogglePasswordVisibilityButton"
+                                <ComboBox x:Name="AdapterComboBox"
+                                          MinHeight="36"
+                                          VerticalContentAlignment="Center"
+                                          SelectionChanged="AdapterComboBox_OnSelectionChanged">
+                                    <ComboBox.ItemTemplate>
+                                        <DataTemplate>
+                                            <Grid>
+                                                <Grid.ColumnDefinitions>
+                                                    <ColumnDefinition Width="Auto" />
+                                                    <ColumnDefinition Width="Auto" />
+                                                </Grid.ColumnDefinitions>
+                                                <TextBlock Grid.Column="0"
+                                                           Text="{Binding Name}"
+                                                           TextTrimming="CharacterEllipsis" />
+                                                <TextBlock Grid.Column="1"
+                                                           Margin="12,0,0,0"
+                                                           Text="{Binding IPv4Display}" />
+                                            </Grid>
+                                        </DataTemplate>
+                                    </ComboBox.ItemTemplate>
+                                </ComboBox>
+                                <Button x:Name="RefreshAdaptersButton"
                                         Grid.Column="1"
                                         Margin="8,0,0,0"
-                                        MinWidth="42"
-                                        Padding="10,0"
-                                        Click="TogglePasswordVisibilityButton_OnClick"
-                                        Content="👁" />
+                                        MinHeight="36"
+                                        Padding="14,0"
+                                        Click="RefreshAdaptersButton_OnClick"
+                                        Content="刷新" />
                             </Grid>
                         </StackPanel>
                     </Border>
 
-                    <Button x:Name="SwitchMaintenanceButton"
-                            Grid.Row="2"
-                            Margin="0,16,0,0"
-                            MinHeight="42"
-                            Click="SwitchMaintenanceButton_OnClick"
-                            Content="③ 切换到维护网络(可选)" />
+                    <Border Grid.Row="1" Margin="0,16,0,0" Padding="16" Background="#F9FAFB" CornerRadius="10">
+                        <Grid>
+                            <Grid.RowDefinitions>
+                                <RowDefinition Height="Auto" />
+                                <RowDefinition Height="Auto" />
+                                <RowDefinition Height="*" />
+                            </Grid.RowDefinitions>
 
-                    <Button x:Name="DiscoverConnectButton"
-                            Grid.Row="3"
-                            Margin="0,12,0,0"
-                            MinHeight="42"
-                            Click="DiscoverConnectButton_OnClick"
-                            Content="④ 连接设备" />
+                            <Grid>
+                                <Grid.ColumnDefinitions>
+                                    <ColumnDefinition Width="*" />
+                                    <ColumnDefinition Width="Auto" />
+                                </Grid.ColumnDefinitions>
+                                <TextBlock VerticalAlignment="Center"
+                                           FontSize="13"
+                                           FontWeight="SemiBold"
+                                           Foreground="#111827"
+                                           Text="发现设备(双击连接)" />
+                                <Button x:Name="SearchDevicesButton"
+                                        Grid.Column="1"
+                                        MinHeight="32"
+                                        Padding="14,0"
+                                        Click="SearchDevicesButton_OnClick"
+                                        Content="重新搜索设备" />
+                            </Grid>
 
-                    <Border Grid.Row="4" Margin="0,16,0,0" Padding="12" Background="#FEF3C7" CornerRadius="10">
-                        <TextBlock x:Name="AdminStateTextBlock"
-                                   FontSize="12"
-                                   Foreground="#92400E"
-                                   TextWrapping="Wrap"
-                                   Text="管理员状态:未知" />
+                            <TextBlock x:Name="DiscoveryStateTextBlock"
+                                       Grid.Row="1"
+                                       Margin="0,10,0,0"
+                                       Foreground="#4B5563"
+                                       Text="选择网卡后会自动搜索设备。"
+                                       TextWrapping="Wrap" />
+
+                            <ListView x:Name="DiscoveredDevicesListView"
+                                      Grid.Row="2"
+                                      Margin="0,12,0,0"
+                                      MinHeight="220"
+                                      HorizontalContentAlignment="Stretch"
+                                      ScrollViewer.HorizontalScrollBarVisibility="Disabled"
+                                      ScrollViewer.VerticalScrollBarVisibility="Auto"
+                                      MouseDoubleClick="DiscoveredDevicesListView_OnMouseDoubleClick"
+                                      SizeChanged="DiscoveredDevicesListView_OnSizeChanged">
+                                <ListView.View>
+                                    <GridView>
+                                        <GridViewColumn x:Name="DeviceIpColumn" Width="150" Header="IP" DisplayMemberBinding="{Binding Lan2Ip}" />
+                                        <GridViewColumn x:Name="DeviceHostnameColumn" Width="220" Header="主机名" DisplayMemberBinding="{Binding Hostname}" />
+                                        <GridViewColumn x:Name="DeviceMacColumn" Width="220" Header="MAC" DisplayMemberBinding="{Binding Mac}" />
+                                    </GridView>
+                                </ListView.View>
+                            </ListView>
+                        </Grid>
                     </Border>
-
                 </Grid>
-            </Border>
-        </Grid>
+            </Grid>
+        </Border>
 
         <Border x:Name="StatusMessageBorder"
                 Visibility="Collapsed"

+ 166 - 217
windows/NetworkTool.Client/MainWindow.xaml.cs

@@ -1,7 +1,7 @@
 using System.Collections.Generic;
-using System.Globalization;
 using System.Windows;
 using System.Windows.Controls;
+using System.Windows.Input;
 using System.Windows.Media;
 using System.Windows.Media.Animation;
 using System.Windows.Threading;
@@ -14,14 +14,11 @@ public partial class MainWindow : Window
 {
     private readonly NetworkAdapterService _networkAdapterService = new();
     private readonly PasswordStoreService _passwordStoreService = new();
-    private readonly NetworkConfigurationService _networkConfigurationService = new();
     private readonly DiscoveryService _discoveryService = new();
     private readonly ServerApiService _serverApiService = new();
-    private readonly AdminPrivilegeService _adminPrivilegeService = new();
     private IReadOnlyList<AdapterInfo> _adapters = [];
-    private bool _isShowingPassword;
+    private IReadOnlyList<DiscoveredDevice> _discoveredDevices = [];
     private bool _isBusy;
-    private bool _suppressPasswordSync;
     private CancellationTokenSource? _statusMessageCts;
 
     public MainWindow()
@@ -30,19 +27,14 @@ public partial class MainWindow : Window
         Loaded += MainWindow_OnLoaded;
     }
 
-    private async void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
+    private void MainWindow_OnLoaded(object sender, RoutedEventArgs e)
     {
-        await LoadInitialStateAsync();
+        LoadInitialState();
     }
 
-    private async Task LoadInitialStateAsync()
+    private void LoadInitialState()
     {
-        AdminStateTextBlock.Text = _adminPrivilegeService.IsAdministrator()
-            ? "管理员状态:当前已以管理员身份运行,可执行本机网卡切换。"
-            : "管理员状态:当前不是管理员运行,切换到维护网络会失败。";
-
         _adapters = _networkAdapterService.GetEthernetAdapters();
-        await _networkAdapterService.ProbeMaintenanceReachabilityAsync(_adapters);
         _adapters = _adapters
             .OrderByDescending(adapter => adapter.RecommendationScore)
             .ThenBy(adapter => adapter.Name)
@@ -51,25 +43,15 @@ public partial class MainWindow : Window
 
         var recommendedAdapter = _networkAdapterService.GetRecommendedAdapter(_adapters);
 
-        var savedPassword = _passwordStoreService.LoadPassword();
-        if (!string.IsNullOrWhiteSpace(savedPassword))
-        {
-            PasswordBox.Password = savedPassword;
-            PasswordTextBox.Text = savedPassword;
-        }
-
         if (recommendedAdapter is not null)
         {
             AdapterComboBox.SelectedItem = recommendedAdapter;
-            UpdateAdapterDetails(recommendedAdapter);
         }
         else if (_adapters.Count > 0)
         {
             AdapterComboBox.SelectedIndex = 0;
-            UpdateAdapterDetails(_adapters[0]);
         }
 
-        AppendLog("客户端已加载连接页。", true);
         UpdateButtonStates();
     }
 
@@ -77,153 +59,102 @@ public partial class MainWindow : Window
     {
         if (AdapterComboBox.SelectedItem is not AdapterInfo adapter)
         {
-            UpdateAdapterDetails(null);
+            ClearDiscoveredDevices();
             SetStatus("请选择一块有线网卡。", false);
             UpdateButtonStates();
             return;
         }
 
-        UpdateAdapterDetails(adapter);
         if (!adapter.HasLink)
         {
+            ClearDiscoveredDevices();
             SetStatus("当前网卡未检测到链路,请检查网线连接。", true);
+            UpdateButtonStates();
+            return;
         }
 
         UpdateButtonStates();
+        _ = SearchDevicesAsync(adapter);
     }
 
-    private async void SwitchMaintenanceButton_OnClick(object sender, RoutedEventArgs e)
+    private async void RefreshAdaptersButton_OnClick(object sender, RoutedEventArgs e)
     {
-        if (!_adminPrivilegeService.IsAdministrator())
-        {
-            SetStatus("当前程序未以管理员身份运行,无法修改本机网卡。", true);
-            MessageBox.Show(this, "请以管理员身份运行客户端后再切换到维护网络。", "需要管理员权限", MessageBoxButton.OK, MessageBoxImage.Warning);
-            return;
-        }
-
-        if (AdapterComboBox.SelectedItem is not AdapterInfo adapter)
-        {
-            SetStatus("请先选择一块网卡。", true);
-            return;
-        }
-
-        PersistPasswordIfNeeded();
-
-        if (adapter.IsReachableToMaintenance)
-        {
-            SetStatus("当前网卡已经可以直接访问 169.254.100.2,无需切换到维护网络。", true);
-            return;
-        }
-
         SetBusyState(true);
-        SetStatus("正在切换到维护网络,请稍候。", true);
-        await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Render);
-
         try
         {
-            await _networkConfigurationService.ConfigureMaintenanceNetworkAsync(adapter);
-            SetStatus("已切换到维护网络。", true);
-            await RefreshAdaptersAsync(adapter.Id);
+            RefreshAdapters();
+            SetStatus("已刷新本机网卡。", true);
         }
         catch (Exception ex)
         {
-            SetStatus($"切换维护网络失败:{ex.Message}", true);
-            MessageBox.Show(this, ex.Message, "切换维护网络失败", MessageBoxButton.OK, MessageBoxImage.Error);
+            SetStatus($"刷新本机网卡失败:{ex.Message}", true);
+            MessageBox.Show(this, ex.Message, "刷新失败", MessageBoxButton.OK, MessageBoxImage.Error);
         }
         finally
         {
             SetBusyState(false);
         }
-    }
 
-    private async void RefreshAdaptersButton_OnClick(object sender, RoutedEventArgs e)
-    {
-        SetBusyState(true);
-        try
+        if (AdapterComboBox.SelectedItem is AdapterInfo adapter)
         {
-            await RefreshAdaptersAsync();
-            SetStatus("已刷新本机网卡和管理口探测结果。", true);
+            await SearchDevicesAsync(adapter);
         }
-        catch (Exception ex)
+    }
+
+    private async void SearchDevicesButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if (AdapterComboBox.SelectedItem is AdapterInfo adapter)
         {
-            SetStatus($"刷新本机网卡失败:{ex.Message}", true);
-            MessageBox.Show(this, ex.Message, "刷新失败", MessageBoxButton.OK, MessageBoxImage.Error);
+            await SearchDevicesAsync(adapter);
         }
-        finally
+        else
         {
-            SetBusyState(false);
+            SetStatus("请先选择一块网卡。", true);
         }
     }
 
-    private async void DiscoverConnectButton_OnClick(object sender, RoutedEventArgs e)
+    private async Task SearchDevicesAsync(AdapterInfo adapter)
     {
-        if (string.IsNullOrWhiteSpace(GetCurrentPassword()))
+        if (_isBusy)
         {
-            SetStatus("请输入管理密码。", true);
-            MessageBox.Show(this, "请先在右侧“管理密码(必填)”区域输入密码。", "缺少管理密码", MessageBoxButton.OK, MessageBoxImage.Information);
             return;
         }
 
-        PersistPasswordIfNeeded();
+        if (string.IsNullOrWhiteSpace(adapter.IPv4Address))
+        {
+            ClearDiscoveredDevices();
+            SetStatus("当前网卡没有可用 IPv4,无法搜索设备。", true);
+            return;
+        }
+
+        if (!adapter.HasLink)
+        {
+            ClearDiscoveredDevices();
+            SetStatus("当前网卡未检测到链路,请检查网线连接。", true);
+            return;
+        }
+
         SetBusyState(true);
-        SetStatus("正在检查当前网卡是否可直接访问管理口。", true);
+        ClearDiscoveredDevices();
+        SetStatus("正在通过当前网卡广播搜索设备。", true);
         await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Render);
 
         try
         {
-            if (AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.IsReachableToMaintenance)
-            {
-                var directResult = await _serverApiService.CheckHealthAsync("http://169.254.100.2:48888", GetCurrentPassword(), adapter.IPv4Address);
-                if (directResult.Success)
-                {
-                    SetStatus("连接成功,无需切换本机网卡。", true);
-                    OpenDeviceDetailsWindow("http://169.254.100.2:48888", adapter.IPv4Address);
-                    return;
-                }
-
-                if (directResult.StatusCode == 401)
-                {
-                    SetStatus("该网卡可以直连管理口,但管理密码错误。", true);
-                    MessageBox.Show(this, "当前网卡已经可以直连 Server,但密码校验失败,请确认密码是否正确。", "密码错误", MessageBoxButton.OK, MessageBoxImage.Warning);
-                    return;
-                }
-
-                if (directResult.StatusCode == 403)
-                {
-                    SetStatus("该网卡可以直连管理口,但 Server 拒绝访问,请确认远端是否还是旧版本。", true);
-                    MessageBox.Show(this, "当前网卡已经可以直连 Server,但请求被拒绝。请确认 Linux 端是否运行的是最新版本 Server。", "访问被拒绝", MessageBoxButton.OK, MessageBoxImage.Warning);
-                    return;
-                }
-
-                SetStatus($"该网卡虽可建立连接,但直连 HTTP 校验失败:{directResult.Message}。正在尝试设备发现。", true);
-            }
-
-            SetStatus("当前网卡无法完成直连校验,正在发现设备,请稍候。", true);
-            var selectedAdapter = AdapterComboBox.SelectedItem as AdapterInfo;
-            var device = await _discoveryService.DiscoverAsync(selectedAdapter?.IPv4Address ?? string.Empty);
-            if (device is null)
+            _discoveredDevices = await _discoveryService.DiscoverManyAsync(adapter.IPv4Address);
+            DiscoveredDevicesListView.ItemsSource = _discoveredDevices;
+            if (_discoveredDevices.Count == 0)
             {
-                SetStatus("未发现设备。如果当前网卡不可达,请先切换到维护网络。", true);
+                SetStatus("未发现 169.254 开头的设备 IP,请确认网卡、网线、远端服务和维护网段配置。", true);
                 return;
             }
 
-            SetStatus("已发现设备,正在验证连接。", true);
-
-            var discoveredResult = await _serverApiService.CheckHealthAsync($"http://{device.Lan2Ip}:48888", GetCurrentPassword(), selectedAdapter?.IPv4Address ?? string.Empty);
-            if (discoveredResult.Success)
-            {
-                SetStatus("连接成功。", true);
-                OpenDeviceDetailsWindow($"http://{device.Lan2Ip}:48888", selectedAdapter?.IPv4Address ?? string.Empty);
-            }
-            else
-            {
-                SetStatus($"设备已发现,但 HTTP 验证失败:{discoveredResult.Message}", true);
-            }
+            SetStatus($"已发现 {_discoveredDevices.Count} 台设备,请双击 IP 连接。", true);
         }
         catch (Exception ex)
         {
-            SetStatus($"连接失败:{ex.Message}", true);
-            MessageBox.Show(this, ex.Message, "连接失败", MessageBoxButton.OK, MessageBoxImage.Error);
+            SetStatus($"搜索设备失败:{ex.Message}", true);
+            MessageBox.Show(this, ex.Message, "搜索设备失败", MessageBoxButton.OK, MessageBoxImage.Error);
         }
         finally
         {
@@ -231,34 +162,26 @@ public partial class MainWindow : Window
         }
     }
 
-    private void PersistPasswordIfNeeded()
+    private void SavePasswordForDevice(DiscoveredDevice device, string password)
     {
-        var password = GetCurrentPassword();
-        if (!string.IsNullOrWhiteSpace(password))
+        var deviceKey = GetDevicePasswordKey(device);
+        if (!string.IsNullOrWhiteSpace(deviceKey) && !string.IsNullOrWhiteSpace(password))
         {
-            _passwordStoreService.SavePassword(password);
-            return;
+            _passwordStoreService.SavePassword(deviceKey, password);
         }
     }
 
-    private string GetCurrentPassword()
-    {
-        return _isShowingPassword ? PasswordTextBox.Text : PasswordBox.Password;
-    }
-
     private void UpdateButtonStates()
     {
         var adapter = AdapterComboBox.SelectedItem as AdapterInfo;
         var hasAdapter = adapter is not null;
         RefreshAdaptersButton.IsEnabled = !_isBusy;
-        SwitchMaintenanceButton.IsEnabled = !_isBusy && hasAdapter;
-        DiscoverConnectButton.IsEnabled = !_isBusy && hasAdapter && adapter!.HasLink;
+        SearchDevicesButton.IsEnabled = !_isBusy && hasAdapter && adapter!.HasLink;
     }
 
-    private async Task RefreshAdaptersAsync(string? selectedAdapterId = null)
+    private void RefreshAdapters(string? selectedAdapterId = null)
     {
         _adapters = _networkAdapterService.GetEthernetAdapters();
-        await _networkAdapterService.ProbeMaintenanceReachabilityAsync(_adapters);
         _adapters = _adapters
             .OrderByDescending(adapter => adapter.RecommendationScore)
             .ThenBy(adapter => adapter.Name)
@@ -272,51 +195,139 @@ public partial class MainWindow : Window
         if (selected is not null)
         {
             AdapterComboBox.SelectedItem = selected;
-            UpdateAdapterDetails(selected);
         }
     }
 
-    private void UpdateAdapterDetails(AdapterInfo? adapter)
+    private void ClearDiscoveredDevices()
+    {
+        _discoveredDevices = [];
+        DiscoveredDevicesListView.ItemsSource = _discoveredDevices;
+        DiscoveryStateTextBlock.Text = "选择网卡后会自动搜索设备。";
+    }
+
+    private async void DiscoveredDevicesListView_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
+    {
+        if (!_isBusy && DiscoveredDevicesListView.SelectedItem is DiscoveredDevice device)
+        {
+            await ConnectToDeviceAsync(device);
+        }
+    }
+
+    private void DiscoveredDevicesListView_OnSizeChanged(object sender, SizeChangedEventArgs e)
     {
-        if (adapter is null)
+        var availableWidth = DiscoveredDevicesListView.ActualWidth - 36;
+        if (availableWidth <= 0)
         {
-            AdapterProbeTextBlock.Text = "-";
-            ApplyAdapterProbeStyle("未探测");
             return;
         }
 
-        AdapterProbeTextBlock.Text = $"{adapter.ProbeStatus} / {adapter.ProbeReason}";
-        ApplyAdapterProbeStyle(adapter.ProbeStatus);
+        DeviceIpColumn.Width = Math.Max(130, availableWidth * 0.26);
+        DeviceHostnameColumn.Width = Math.Max(150, availableWidth * 0.30);
+        DeviceMacColumn.Width = Math.Max(180, availableWidth - DeviceIpColumn.Width - DeviceHostnameColumn.Width);
     }
 
-    private void ApplyAdapterProbeStyle(string probeStatus)
+    private async Task ConnectToDeviceAsync(DiscoveredDevice device)
     {
-        var (background, foreground) = probeStatus switch
+        var deviceKey = GetDevicePasswordKey(device);
+        var savedPassword = _passwordStoreService.LoadPassword(deviceKey);
+        var password = string.Empty;
+        if (!string.IsNullOrWhiteSpace(savedPassword))
         {
-            "可达" => ("#ECFDF5", "#065F46"),
-            "未通" or "不可达" => ("#FEF3C7", "#92400E"),
-            _ => ("#F3F4F6", "#374151"),
-        };
+            password = savedPassword;
+        }
+        else if (!TryPromptForPassword(device, out savedPassword))
+        {
+            return;
+        }
+        else
+        {
+            password = savedPassword;
+        }
+
+        if (string.IsNullOrWhiteSpace(password))
+        {
+            SetStatus("请输入管理密码。", true);
+            return;
+        }
+
+        var selectedAdapter = AdapterComboBox.SelectedItem as AdapterInfo;
+        SetBusyState(true);
+        SetStatus($"正在连接 {device.Lan2Ip}。", true);
+
+        try
+        {
+            var baseAddress = $"http://{device.Lan2Ip}:48888";
+            var result = await _serverApiService.CheckHealthAsync(baseAddress, password, selectedAdapter?.IPv4Address ?? string.Empty);
+            if (result.Success)
+            {
+                SavePasswordForDevice(device, password);
+                SetStatus("连接成功。", true);
+                OpenDeviceDetailsWindow(baseAddress, selectedAdapter?.IPv4Address ?? string.Empty, password);
+                return;
+            }
+
+            if (result.StatusCode == 401)
+            {
+                SetStatus("管理密码错误,请确认密码是否正确。", true);
+                MessageBox.Show(this, "管理密码校验失败,请确认密码是否正确。", "密码错误", MessageBoxButton.OK, MessageBoxImage.Warning);
+                return;
+            }
+
+            SetStatus($"设备已发现,但 HTTP 验证失败:{result.Message}", true);
+        }
+        catch (Exception ex)
+        {
+            SetStatus($"连接失败:{ex.Message}", true);
+            MessageBox.Show(this, ex.Message, "连接失败", MessageBoxButton.OK, MessageBoxImage.Error);
+        }
+        finally
+        {
+            SetBusyState(false);
+        }
 
-        var backgroundBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(background));
-        var foregroundBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(foreground));
-        AdapterProbeBorder.Background = backgroundBrush;
-        AdapterProbeTitleTextBlock.Foreground = foregroundBrush;
-        AdapterProbeTextBlock.Foreground = foregroundBrush;
     }
 
-    private void OpenDeviceDetailsWindow(string baseAddress, string localIPv4)
+    private void OpenDeviceDetailsWindow(string baseAddress, string localIPv4, string password)
     {
-        var window = new DeviceDetailsWindow(baseAddress, localIPv4, GetCurrentPassword())
+        var window = new DeviceDetailsWindow(baseAddress, localIPv4, password)
         {
             Owner = this,
         };
         window.ShowDialog();
     }
 
+    private bool TryPromptForPassword(DiscoveredDevice device, out string password)
+    {
+        var label = string.IsNullOrWhiteSpace(device.Mac) ? device.Lan2Ip : device.Mac;
+        var window = new PasswordPromptWindow(label)
+        {
+            Owner = this,
+        };
+
+        if (window.ShowDialog() == true)
+        {
+            password = window.Password;
+            return true;
+        }
+
+        password = string.Empty;
+        return false;
+    }
+
+    private static string GetDevicePasswordKey(DiscoveredDevice device)
+    {
+        if (!string.IsNullOrWhiteSpace(device.Mac))
+        {
+            return device.Mac;
+        }
+
+        return device.DeviceId;
+    }
+
     private void SetStatus(string message, bool addLog)
     {
         ApplyStatusMessageStyle(message);
+        DiscoveryStateTextBlock.Text = message;
         StatusTextBlock.Text = message;
         StatusMessageBorder.Opacity = 0;
         StatusMessageBorder.Visibility = Visibility.Visible;
@@ -324,10 +335,7 @@ public partial class MainWindow : Window
         _statusMessageCts?.Cancel();
         _statusMessageCts = new CancellationTokenSource();
         _ = HideStatusMessageAsync(_statusMessageCts.Token);
-        if (addLog)
-        {
-            AppendLog(message, false);
-        }
+        _ = addLog;
     }
 
     private async Task HideStatusMessageAsync(CancellationToken cancellationToken)
@@ -385,70 +393,11 @@ public partial class MainWindow : Window
         return markers.Any(marker => message.Contains(marker, StringComparison.Ordinal));
     }
 
-    private void AppendLog(string message, bool isInitial)
-    {
-        var prefix = DateTime.Now.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
-        EventLogListBox.Items.Add($"[{prefix}] {message}");
-        if (!isInitial)
-        {
-            EventLogListBox.ScrollIntoView(EventLogListBox.Items[^1]);
-        }
-    }
-
     private void SetBusyState(bool isBusy)
     {
         _isBusy = isBusy;
         AdapterComboBox.IsEnabled = !isBusy;
         RefreshAdaptersButton.IsEnabled = !isBusy;
-        PasswordBox.IsEnabled = !isBusy;
-        PasswordTextBox.IsEnabled = !isBusy;
-        TogglePasswordVisibilityButton.IsEnabled = !isBusy;
-        SwitchMaintenanceButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo;
-        DiscoverConnectButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.HasLink;
-    }
-
-    private void TogglePasswordVisibilityButton_OnClick(object sender, RoutedEventArgs e)
-    {
-        _isShowingPassword = !_isShowingPassword;
-        if (_isShowingPassword)
-        {
-            PasswordTextBox.Text = PasswordBox.Password;
-            PasswordBox.Visibility = Visibility.Collapsed;
-            PasswordTextBox.Visibility = Visibility.Visible;
-            TogglePasswordVisibilityButton.Content = "🙈";
-            PasswordTextBox.Focus();
-            PasswordTextBox.CaretIndex = PasswordTextBox.Text.Length;
-            return;
-        }
-
-        PasswordBox.Password = PasswordTextBox.Text;
-        PasswordTextBox.Visibility = Visibility.Collapsed;
-        PasswordBox.Visibility = Visibility.Visible;
-        TogglePasswordVisibilityButton.Content = "👁";
-        PasswordBox.Focus();
-    }
-
-    private void PasswordBox_OnPasswordChanged(object sender, RoutedEventArgs e)
-    {
-        if (_suppressPasswordSync)
-        {
-            return;
-        }
-
-        _suppressPasswordSync = true;
-        PasswordTextBox.Text = PasswordBox.Password;
-        _suppressPasswordSync = false;
-    }
-
-    private void PasswordTextBox_OnTextChanged(object sender, TextChangedEventArgs e)
-    {
-        if (_suppressPasswordSync)
-        {
-            return;
-        }
-
-        _suppressPasswordSync = true;
-        PasswordBox.Password = PasswordTextBox.Text;
-        _suppressPasswordSync = false;
+        SearchDevicesButton.IsEnabled = !isBusy && AdapterComboBox.SelectedItem is AdapterInfo adapter && adapter.HasLink;
     }
 }

+ 3 - 4
windows/NetworkTool.Client/Models/AdapterInfo.cs

@@ -14,11 +14,10 @@ public sealed class AdapterInfo
     public required string RecommendationLabel { get; set; }
     public required string RecommendationReason { get; set; }
     public string IPv4Address { get; set; } = string.Empty;
-    public string ProbeStatus { get; set; } = "未探测";
-    public string ProbeReason { get; set; } = "尚未对 169.254.100.2 进行可达性探测。";
-    public bool IsReachableToMaintenance { get; set; }
 
-    public string DisplayName => $"{RecommendationLabel} | {Name} | {Type} | IPv4: {(string.IsNullOrWhiteSpace(IPv4Address) ? "无" : IPv4Address)} | {ProbeStatus}";
+    public string IPv4Display => $"IPv4: {(string.IsNullOrWhiteSpace(IPv4Address) ? "无" : IPv4Address)}";
+
+    public string DisplayName => $"{Name} | {IPv4Display}";
 
     public static AdapterInfo FromNetworkInterface(
         NetworkInterface adapter,

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

@@ -13,6 +13,9 @@ public sealed class DiscoveredDevice
     [JsonPropertyName("server_version")]
     public required string ServerVersion { get; init; }
 
+    [JsonPropertyName("mac")]
+    public required string Mac { get; init; }
+
     [JsonPropertyName("lan2_ip")]
     public required string Lan2Ip { get; init; }
 

+ 41 - 0
windows/NetworkTool.Client/PasswordPromptWindow.xaml

@@ -0,0 +1,41 @@
+<Window x:Class="NetworkTool.Client.PasswordPromptWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        Title="输入管理密码"
+        Width="420"
+        SizeToContent="Height"
+        ResizeMode="NoResize"
+        WindowStartupLocation="CenterOwner">
+    <Grid Margin="20,20,20,24">
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="Auto" />
+            <RowDefinition Height="Auto" />
+        </Grid.RowDefinitions>
+
+        <TextBlock x:Name="PromptTextBlock"
+                   Foreground="#111827"
+                   TextWrapping="Wrap" />
+
+        <PasswordBox x:Name="PasswordBox"
+                     Grid.Row="1"
+                     Margin="0,16,0,0"
+                     MinHeight="36"
+                     VerticalContentAlignment="Center" />
+
+        <StackPanel Grid.Row="2"
+                    Margin="0,20,0,0"
+                    HorizontalAlignment="Right"
+                    Orientation="Horizontal">
+            <Button MinWidth="78"
+                    Height="36"
+                    Click="CancelButton_OnClick"
+                    Content="取消" />
+            <Button MinWidth="78"
+                    Height="36"
+                    Margin="10,0,0,0"
+                    Click="OkButton_OnClick"
+                    Content="连接" />
+        </StackPanel>
+    </Grid>
+</Window>

+ 32 - 0
windows/NetworkTool.Client/PasswordPromptWindow.xaml.cs

@@ -0,0 +1,32 @@
+using System.Windows;
+
+namespace NetworkTool.Client;
+
+public partial class PasswordPromptWindow : Window
+{
+    public PasswordPromptWindow(string deviceLabel)
+    {
+        InitializeComponent();
+        PromptTextBlock.Text = $"请输入设备 {deviceLabel} 的管理密码。";
+        Loaded += (_, _) => PasswordBox.Focus();
+    }
+
+    public string Password => PasswordBox.Password;
+
+    private void OkButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        if (string.IsNullOrWhiteSpace(PasswordBox.Password))
+        {
+            MessageBox.Show(this, "请输入管理密码。", "缺少管理密码", MessageBoxButton.OK, MessageBoxImage.Information);
+            PasswordBox.Focus();
+            return;
+        }
+
+        DialogResult = true;
+    }
+
+    private void CancelButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        DialogResult = false;
+    }
+}

+ 70 - 17
windows/NetworkTool.Client/Services/DiscoveryService.cs

@@ -1,7 +1,9 @@
 using System.Net;
 using System.Net.Sockets;
+using System.Runtime.InteropServices;
 using System.Text;
 using System.Text.Json;
+using System.Text.Json.Serialization;
 using NetworkTool.Client.Models;
 
 namespace NetworkTool.Client.Services;
@@ -10,11 +12,14 @@ public sealed class DiscoveryService
 {
     private const int DiscoveryPort = 50000;
 
-    public async Task<DiscoveredDevice?> DiscoverAsync(string localIPv4, CancellationToken cancellationToken = default)
+    [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)
     {
         if (string.IsNullOrWhiteSpace(localIPv4))
         {
-            return null;
+            return [];
         }
 
         using var client = new UdpClient(AddressFamily.InterNetwork)
@@ -37,37 +42,85 @@ public sealed class DiscoveryService
         using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
         timeoutCts.CancelAfter(TimeSpan.FromSeconds(3));
 
-        try
+        var devicesByIp = new Dictionary<string, DiscoveredDevice>(StringComparer.OrdinalIgnoreCase);
+        while (!timeoutCts.IsCancellationRequested)
         {
-            var result = await client.ReceiveAsync(timeoutCts.Token);
-            var response = JsonSerializer.Deserialize<DiscoveryResponse>(result.Buffer);
-            if (response is null || response.MessageType != "discover_response")
+            try
             {
-                return null;
-            }
+                var result = await client.ReceiveAsync(timeoutCts.Token);
+                var response = JsonSerializer.Deserialize<DiscoveryResponse>(result.Buffer);
+                if (response is null
+                    || response.MessageType != "discover_response"
+                    || string.IsNullOrWhiteSpace(response.Lan2Ip)
+                    || !response.Lan2Ip.StartsWith("169.254.", StringComparison.Ordinal))
+                {
+                    continue;
+                }
+
+                var mac = response.Mac;
+                if (string.IsNullOrWhiteSpace(mac))
+                {
+                    mac = ResolveMacAddress(result.RemoteEndPoint.Address);
+                }
 
-            return new DiscoveredDevice
+                devicesByIp[response.Lan2Ip] = new DiscoveredDevice
+                {
+                    DeviceId = response.DeviceId ?? string.Empty,
+                    Hostname = response.Hostname ?? string.Empty,
+                    ServerVersion = response.ServerVersion ?? string.Empty,
+                    Mac = mac ?? string.Empty,
+                    Lan2Ip = response.Lan2Ip,
+                    AuthRequired = response.AuthRequired,
+                };
+            }
+            catch (OperationCanceledException)
             {
-                DeviceId = response.DeviceId ?? string.Empty,
-                Hostname = response.Hostname ?? string.Empty,
-                ServerVersion = response.ServerVersion ?? string.Empty,
-                Lan2Ip = response.Lan2Ip ?? string.Empty,
-                AuthRequired = response.AuthRequired,
-            };
+                break;
+            }
         }
-        catch (OperationCanceledException)
+
+        return devicesByIp.Values.OrderBy(device => device.Lan2Ip).ToList();
+    }
+
+    private static string ResolveMacAddress(IPAddress ipAddress)
+    {
+        if (ipAddress.AddressFamily != AddressFamily.InterNetwork)
         {
-            return null;
+            return string.Empty;
         }
+
+        var macAddress = new byte[6];
+        var macAddressLength = macAddress.Length;
+        var destinationIp = BitConverter.ToInt32(ipAddress.GetAddressBytes(), 0);
+        if (SendARP(destinationIp, 0, macAddress, ref macAddressLength) != 0 || macAddressLength <= 0)
+        {
+            return string.Empty;
+        }
+
+        return string.Join(":", macAddress.Take(macAddressLength).Select(value => value.ToString("X2")));
     }
 
     private sealed class DiscoveryResponse
     {
+        [JsonPropertyName("message_type")]
         public string? MessageType { get; set; }
+
+        [JsonPropertyName("device_id")]
         public string? DeviceId { get; set; }
+
+        [JsonPropertyName("hostname")]
         public string? Hostname { get; set; }
+
+        [JsonPropertyName("server_version")]
         public string? ServerVersion { get; set; }
+
+        [JsonPropertyName("mac")]
+        public string? Mac { get; set; }
+
+        [JsonPropertyName("lan2_ip")]
         public string? Lan2Ip { get; set; }
+
+        [JsonPropertyName("auth_required")]
         public bool AuthRequired { get; set; }
     }
 }

+ 0 - 48
windows/NetworkTool.Client/Services/NetworkAdapterService.cs

@@ -1,6 +1,5 @@
 using System.Collections.Generic;
 using System.Linq;
-using System.Net;
 using System.Net.NetworkInformation;
 using System.Net.Sockets;
 using NetworkTool.Client.Models;
@@ -28,36 +27,6 @@ public sealed class NetworkAdapterService
             .FirstOrDefault();
     }
 
-    public async Task ProbeMaintenanceReachabilityAsync(IReadOnlyList<AdapterInfo> adapters, CancellationToken cancellationToken = default)
-    {
-        foreach (var adapter in adapters)
-        {
-            if (!adapter.HasLink || string.IsNullOrWhiteSpace(adapter.IPv4Address))
-            {
-                adapter.ProbeStatus = "不可达";
-                adapter.ProbeReason = "该网卡当前没有可用的 IPv4 或链路未连接。";
-                adapter.IsReachableToMaintenance = false;
-                continue;
-            }
-
-            var reachable = await CanReachMaintenanceAsync(adapter.IPv4Address, cancellationToken);
-            adapter.IsReachableToMaintenance = reachable;
-            if (reachable)
-            {
-                adapter.ProbeStatus = "可达";
-                adapter.ProbeReason = "该网卡可连接到管理口 169.254.100.2:48888。";
-                adapter.RecommendationScore += 100;
-                adapter.RecommendationLabel = "推荐";
-                adapter.RecommendationReason = "该网卡已实际探测到可与 Linux 管理口建立连接,优先使用。";
-            }
-            else
-            {
-                adapter.ProbeStatus = "未通";
-                adapter.ProbeReason = "该网卡当前无法连接到管理口 169.254.100.2:48888。";
-            }
-        }
-    }
-
     private static bool IsSupportedEthernetAdapter(NetworkInterface adapter)
     {
         if (adapter.NetworkInterfaceType is not NetworkInterfaceType.Ethernet and not NetworkInterfaceType.GigabitEthernet)
@@ -154,21 +123,4 @@ public sealed class NetworkAdapterService
             || description.Contains("VPN", System.StringComparison.OrdinalIgnoreCase)
             || description.Contains("Loopback", System.StringComparison.OrdinalIgnoreCase);
     }
-
-    private static async Task<bool> CanReachMaintenanceAsync(string localIPv4, CancellationToken cancellationToken)
-    {
-        try
-        {
-            using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
-            socket.Bind(new IPEndPoint(IPAddress.Parse(localIPv4), 0));
-            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
-            timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(500));
-            await socket.ConnectAsync(IPAddress.Parse("169.254.100.2"), 48888, timeoutCts.Token);
-            return socket.Connected;
-        }
-        catch
-        {
-            return false;
-        }
-    }
 }

+ 34 - 7
windows/NetworkTool.Client/Services/PasswordStoreService.cs

@@ -5,23 +5,50 @@ namespace NetworkTool.Client.Services;
 public sealed class PasswordStoreService
 {
     private const string RegistryPath = @"Software\NetworkTool";
-    private const string ValueName = "SavedPassword";
+    private const string DevicePasswordPrefix = "DevicePassword_";
 
-    public string LoadPassword()
+    public string LoadPassword(string deviceKey)
     {
+        var valueName = GetValueName(deviceKey);
+        if (string.IsNullOrWhiteSpace(valueName))
+        {
+            return string.Empty;
+        }
+
         using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: false);
-        return key?.GetValue(ValueName) as string ?? string.Empty;
+        return key?.GetValue(valueName) as string ?? string.Empty;
     }
 
-    public void SavePassword(string password)
+    public void SavePassword(string deviceKey, string password)
     {
+        var valueName = GetValueName(deviceKey);
+        if (string.IsNullOrWhiteSpace(valueName))
+        {
+            return;
+        }
+
         using var key = Registry.CurrentUser.CreateSubKey(RegistryPath);
-        key.SetValue(ValueName, password, RegistryValueKind.String);
+        key.SetValue(valueName, password, RegistryValueKind.String);
     }
 
-    public void ClearPassword()
+    public void ClearPassword(string deviceKey)
     {
+        var valueName = GetValueName(deviceKey);
+        if (string.IsNullOrWhiteSpace(valueName))
+        {
+            return;
+        }
+
         using var key = Registry.CurrentUser.OpenSubKey(RegistryPath, writable: true);
-        key?.DeleteValue(ValueName, throwOnMissingValue: false);
+        key?.DeleteValue(valueName, throwOnMissingValue: false);
+    }
+
+    private static string GetValueName(string deviceKey)
+    {
+        var normalized = new string(deviceKey
+            .Where(char.IsLetterOrDigit)
+            .Select(char.ToUpperInvariant)
+            .ToArray());
+        return string.IsNullOrWhiteSpace(normalized) ? string.Empty : DevicePasswordPrefix + normalized;
     }
 }