5 Revize 4228018bfc ... 3fae2b2fe8

Autor SHA1 Zpráva Datum
  yangkaixiang 3fae2b2fe8 feat(ui): 密码弹窗切换图标为SVG以优化显示 před 1 měsícem
  yangkaixiang 78453484ff feat(ui): 密码弹窗支持切换明文查看 před 1 měsícem
  yangkaixiang 20797bcaaa feat(ui): 密码弹窗支持清除已保存密码 před 1 měsícem
  yangkaixiang a48c3f8d8c docs: 统一网络地址校验提示文案为网段地址 před 1 měsícem
  yangkaixiang 40d6245133 feat(network): 校验路由目标必须为网络地址 před 1 měsícem

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

@@ -327,7 +327,7 @@ X-Admin-Password: hvAC2026#%
 3. `addresses[].prefix` 必填
 4. `dns` 选填
 5. `interface` 必填,必须是有效的真实接口名
-6. `routes[].to` 必须为 `default` 或 IPv4 CIDR
+6. `routes[].to` 必须为 `default` 或 IPv4 CIDR,且 CIDR 地址必须填写目标网段地址(网络号),例如 `192.168.50.0/24` 合法,`192.168.50.0/16` 应写为 `192.168.0.0/16`
 7. `routes[].via` 必须为 IPv4 地址,且必须与任一 `addresses` 在同一子网
 8. 若任一 `addresses[].ip` 为 `169.254.x.x`,返回中文警告,不直接报错
 9. 单个接口最多只能配置 1 条 `default` 路由

+ 1 - 1
docs/05-Server模块设计.md

@@ -174,7 +174,7 @@
 1. 校验目标接口是否存在
 2. 校验 IP 格式
 3. 校验前缀长度
-4. 校验静态 IP 不能是所在网段的网络地址或广播地址
+4. 校验静态接口 IP 不能填写所在网段的网段地址或广播地址
 5. 校验网关格式和同网段关系
 6. 校验 DNS 格式
 7. 返回警告信息和错误信息

+ 2 - 1
docs/09-使用说明与故障处理.md

@@ -181,10 +181,11 @@ hvAC2026#%
 1. 静态模式下至少需要一个 IP 地址。
 2. IP、网关、DNS 必须是合法 IPv4。
 3. 子网掩码必须合法。
-4. 静态 IP 不能是所在网段的网络地址或广播地址。
+4. 静态接口 IP 不能填写所在网段的网段地址或广播地址。
 5. 默认网关必须与对应网口 IP 在同一网段。
 6. 单个接口最多只能有一条默认路由。
 7. 所有网口中最多只能配置一个默认网关。
+8. 自定义路由目标必须填写目标网段地址(网络号),例如 `192.168.50.0/24` 合法,`192.168.50.0/16` 应写为 `192.168.0.0/16`。
 
 ### 3.8 保存配置
 

+ 0 - 0
docs/NetTool客户端使用说明文档.docx → docs/NetTool客户端使用说明文档v1.1.docx


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

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

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

@@ -49,7 +49,7 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 			continue
 		}
 		if isNetworkOrBroadcastAddress(ip, address.Prefix) {
-			resp.Errors = append(resp.Errors, fmt.Sprintf("IP 地址不能是网络地址或广播地址:%s/%d", address.IP, address.Prefix))
+			resp.Errors = append(resp.Errors, fmt.Sprintf("接口 IP 不能填写网段地址或广播地址:%s/%d", address.IP, address.Prefix))
 			continue
 		}
 		key := fmt.Sprintf("%s/%d", ip.String(), address.Prefix)
@@ -83,6 +83,13 @@ func (s *Service) Validate(input model.InterfaceConfig) model.ValidateResponse {
 			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))
+			} else {
+				ipv4 := ip.To4()
+				networkIP := ipv4.Mask(ipNet.Mask)
+				prefix, _ := ipNet.Mask.Size()
+				if !ipv4.Equal(networkIP) {
+					resp.Errors = append(resp.Errors, fmt.Sprintf("路由目标必须填写目标网段地址(网络号):当前为 %s,应为 %s/%d", to, networkIP.String(), prefix))
+				}
 			}
 		}
 		gateway := net.ParseIP(via)

+ 37 - 4
windows/NetTool.Client/DeviceDetailsWindow.xaml.cs

@@ -595,7 +595,7 @@ public partial class DeviceDetailsWindow : Window
                 if (IsNetworkOrBroadcastAddress(parts[0], cidrPrefix))
                 {
                     addresses = [];
-                    error = $"IP 地址不能是网络地址或广播地址:{parts[0]}/{cidrPrefix}";
+                    error = $"接口 IP 不能填写网段地址或广播地址:{parts[0]}/{cidrPrefix}";
                     return false;
                 }
                 row.IP = parts[0];
@@ -618,7 +618,7 @@ public partial class DeviceDetailsWindow : Window
             if (IsNetworkOrBroadcastAddress(ip, prefix))
             {
                 addresses = [];
-                error = $"IP 地址不能是网络地址或广播地址:{ip}/{prefix}";
+                error = $"接口 IP 不能填写网段地址或广播地址:{ip}/{prefix}";
                 return false;
             }
             row.Mask = PrefixToMask(prefix);
@@ -690,6 +690,18 @@ public partial class DeviceDetailsWindow : Window
                     error = $"自定义路由子网掩码格式不正确:{to} {maskText}";
                     return false;
                 }
+                if (!TryGetNetworkAddress(to, prefix, out var networkAddress))
+                {
+                    routes = [];
+                    error = $"自定义路由目标网段格式不正确:{to}";
+                    return false;
+                }
+                if (!string.Equals(to, networkAddress, StringComparison.Ordinal))
+                {
+                    routes = [];
+                    error = $"自定义路由目标必须填写目标网段地址(网络号):当前为 {to}/{prefix},应为 {networkAddress}/{prefix}";
+                    return false;
+                }
                 row.Mask = PrefixToMask(prefix);
                 result.Add(new RemoteInterfaceRouteConfig { To = $"{to}/{prefix}", Via = via });
             }
@@ -739,7 +751,7 @@ public partial class DeviceDetailsWindow : Window
                 if (IsNetworkOrBroadcastAddress(cidrParts[0], prefix))
                 {
                     addresses = [];
-                    error = $"IP 地址不能是网络地址或广播地址:{cidrParts[0]}/{prefix}";
+                    error = $"接口 IP 不能填写网段地址或广播地址:{cidrParts[0]}/{prefix}";
                     return false;
                 }
                 result.Add(new RemoteInterfaceAddressConfig { IP = cidrParts[0], Prefix = prefix });
@@ -761,7 +773,7 @@ public partial class DeviceDetailsWindow : Window
             if (IsNetworkOrBroadcastAddress(parts[0], parsedPrefix))
             {
                 addresses = [];
-                error = $"IP 地址不能是网络地址或广播地址:{parts[0]}/{parsedPrefix}";
+                error = $"接口 IP 不能填写网段地址或广播地址:{parts[0]}/{parsedPrefix}";
                 return false;
             }
             result.Add(new RemoteInterfaceAddressConfig { IP = parts[0], Prefix = parsedPrefix });
@@ -824,6 +836,27 @@ public partial class DeviceDetailsWindow : Window
         return ip == network || ip == broadcast;
     }
 
+    private static bool TryGetNetworkAddress(string ipText, int prefix, out string networkAddress)
+    {
+        networkAddress = string.Empty;
+        if (prefix < 0 || prefix > 32 || !IPAddress.TryParse(ipText, out var parsed))
+        {
+            return false;
+        }
+
+        var bytes = parsed.GetAddressBytes();
+        if (bytes.Length != 4)
+        {
+            return false;
+        }
+
+        var ip = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3];
+        var mask = prefix == 0 ? 0u : uint.MaxValue << (32 - prefix);
+        var network = ip & mask;
+        networkAddress = string.Join('.', new[] { (network >> 24) & 255, (network >> 16) & 255, (network >> 8) & 255, network & 255 });
+        return true;
+    }
+
     private static bool TryMaskOrPrefixToPrefix(string text, out int prefix)
     {
         if (int.TryParse(text, out prefix) && prefix >= 0 && prefix <= 32)

+ 17 - 13
windows/NetTool.Client/MainWindow.xaml.cs

@@ -315,19 +315,17 @@ public partial class MainWindow : Window
     {
         var deviceKey = GetDevicePasswordKey(device);
         var savedPassword = _passwordStoreService.LoadPassword(deviceKey);
-        var password = string.Empty;
-        if (!string.IsNullOrWhiteSpace(savedPassword))
+        var passwordConfirmed = TryPromptForPassword(device, savedPassword, out var password, out var clearSavedPasswordRequested);
+
+        if (clearSavedPasswordRequested)
         {
-            password = savedPassword;
+            _passwordStoreService.ClearPassword(deviceKey);
         }
-        else if (!TryPromptForPassword(device, out savedPassword))
+
+        if (!passwordConfirmed)
         {
             return;
         }
-        else
-        {
-            password = savedPassword;
-        }
 
         if (string.IsNullOrWhiteSpace(password))
         {
@@ -348,7 +346,11 @@ public partial class MainWindow : Window
                 var result = await _serverApiService.CheckHealthAsync(baseAddress, password, selectedAdapter?.IPv4Address ?? string.Empty);
                 if (result.Success)
                 {
-                    SavePasswordForDevice(device, password);
+                    if (!clearSavedPasswordRequested)
+                    {
+                        SavePasswordForDevice(device, password);
+                    }
+
                     SetStatus("连接成功。", StatusMessageType.Success, true);
                     OpenDeviceDetailsWindow(baseAddress, selectedAdapter?.IPv4Address ?? string.Empty, password);
                     return;
@@ -360,7 +362,7 @@ public partial class MainWindow : Window
                     SetStatus("管理密码错误,请重新输入。", StatusMessageType.Error, true);
                     SetBusyState(false);
                     MessageBox.Show(this, "管理密码校验失败,请重新输入管理密码。", "密码错误", MessageBoxButton.OK, MessageBoxImage.Warning);
-                    if (!TryPromptForPassword(device, out password))
+                    if (!TryPromptForPassword(device, string.Empty, out password, out _))
                     {
                         return;
                     }
@@ -393,15 +395,17 @@ public partial class MainWindow : Window
         window.ShowDialog();
     }
 
-    private bool TryPromptForPassword(DiscoveredDevice device, out string password)
+    private bool TryPromptForPassword(DiscoveredDevice device, string savedPassword, out string password, out bool clearSavedPasswordRequested)
     {
         var label = string.IsNullOrWhiteSpace(device.Mac) ? device.Lan2Ip : device.Mac;
-        var window = new PasswordPromptWindow(label)
+        var window = new PasswordPromptWindow(label, savedPassword)
         {
             Owner = this,
         };
 
-        if (window.ShowDialog() == true)
+        var dialogResult = window.ShowDialog();
+        clearSavedPasswordRequested = window.ClearSavedPasswordRequested;
+        if (dialogResult == true)
         {
             password = window.Password;
             return true;

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

@@ -8,7 +8,7 @@
     <UseWPF>true</UseWPF>
     <AssemblyName>NetTool.Client</AssemblyName>
     <RootNamespace>NetTool.Client</RootNamespace>
-    <InformationalVersion>2026.05.15.1723</InformationalVersion>
+    <InformationalVersion>2026.05.15.1834</InformationalVersion>
   </PropertyGroup>
 
 </Project>

+ 65 - 7
windows/NetTool.Client/PasswordPromptWindow.xaml

@@ -17,16 +17,74 @@
                    Foreground="#111827"
                    TextWrapping="Wrap" />
 
-        <PasswordBox x:Name="PasswordBox"
-                     Grid.Row="1"
-                     Margin="0,16,0,0"
+        <Grid Grid.Row="1"
+              Margin="0,16,0,0">
+            <PasswordBox x:Name="PasswordBox"
+                          MinHeight="36"
+                          Padding="0,0,36,0"
+                          VerticalContentAlignment="Center" />
+            <TextBox x:Name="PasswordTextBox"
                      MinHeight="36"
-                     VerticalContentAlignment="Center" />
+                     Padding="0,0,36,0"
+                     VerticalContentAlignment="Center"
+                     TextChanged="PasswordTextBox_OnTextChanged"
+                     Visibility="Collapsed" />
+            <Button x:Name="TogglePasswordVisibilityButton"
+                    Width="30"
+                    Height="30"
+                    Margin="0,3,3,3"
+                    Padding="0"
+                    HorizontalAlignment="Right"
+                    VerticalAlignment="Center"
+                    Background="Transparent"
+                    BorderThickness="0"
+                    Click="TogglePasswordVisibilityButton_OnClick"
+                    Cursor="Hand"
+                    Foreground="#6B7280"
+                    ToolTip="查看密码">
+                <Grid Width="16"
+                      Height="16">
+                    <Viewbox x:Name="ShowPasswordIcon"
+                             Stretch="Uniform">
+                        <Canvas Width="1024"
+                                Height="1024">
+                            <Path Data="M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z"
+                                  Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}" />
+                        </Canvas>
+                    </Viewbox>
+                    <Viewbox x:Name="HidePasswordIcon"
+                             Stretch="Uniform"
+                             Visibility="Collapsed">
+                        <Canvas Width="1024"
+                                Height="1024">
+                            <Path Data="M942.2 486.2Q889.47 375.11 816.7 305l-50.88 50.88C807.31 395.53 843.45 447.4 874.7 512 791.5 684.2 673.4 766 512 766q-72.67 0-133.87-22.38L323 798.75Q408 838 512 838q288.3 0 430.2-300.3a60.29 60.29 0 000-51.5zm-63.57-320.64L836 122.88a8 8 0 00-11.32 0L715.31 232.2Q624.86 186 512 186q-288.3 0-430.2 300.3a60.3 60.3 0 000 51.5q56.69 119.4 136.5 191.41L112.48 835a8 8 0 000 11.31L155.17 889a8 8 0 0011.31 0l712.15-712.12a8 8 0 000-11.32zM149.3 512C232.6 339.8 350.7 258 512 258c54.54 0 104.13 9.36 149.12 28.39l-70.3 70.3a176 176 0 00-238.13 238.13l-83.42 83.42C223.1 637.49 183.3 582.28 149.3 512zm246.7 0a112.11 112.11 0 01146.2-106.69L401.31 546.2A112 112 0 01396 512z"
+                                  Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}" />
+                            <Path Data="M508 624c-3.46 0-6.87-.16-10.25-.47l-52.82 52.82a176.09 176.09 0 00227.42-227.42l-52.82 52.82c.31 3.38.47 6.79.47 10.25a111.94 111.94 0 01-112 112z"
+                                  Fill="{Binding Foreground, RelativeSource={RelativeSource AncestorType=Button}}" />
+                        </Canvas>
+                    </Viewbox>
+                </Grid>
+            </Button>
+        </Grid>
+
+        <Button x:Name="ClearPasswordButton"
+                Grid.Row="2"
+                Margin="0,20,0,0"
+                Padding="0"
+                HorizontalAlignment="Left"
+                VerticalAlignment="Center"
+                Background="Transparent"
+                BorderThickness="0"
+                Click="ClearPasswordButton_OnClick"
+                Content="清除已保存密码"
+                Cursor="Hand"
+                FontSize="12"
+                Foreground="#6B7280" />
 
         <StackPanel Grid.Row="2"
-                    Margin="0,20,0,0"
-                    HorizontalAlignment="Right"
-                    Orientation="Horizontal">
+                     Margin="0,20,0,0"
+                     HorizontalAlignment="Right"
+                     Orientation="Horizontal">
             <Button MinWidth="78"
                     Height="36"
                     Click="CancelButton_OnClick"

+ 66 - 1
windows/NetTool.Client/PasswordPromptWindow.xaml.cs

@@ -4,15 +4,23 @@ namespace NetTool.Client;
 
 public partial class PasswordPromptWindow : Window
 {
-    public PasswordPromptWindow(string deviceLabel)
+    private bool _isPasswordVisible;
+    private bool _isSyncingPasswordText;
+
+    public PasswordPromptWindow(string deviceLabel, string savedPassword)
     {
         InitializeComponent();
         PromptTextBlock.Text = $"请输入设备 {deviceLabel} 的管理密码。";
+        PasswordBox.Password = savedPassword;
+        PasswordTextBox.Text = savedPassword;
+        ClearPasswordButton.Visibility = string.IsNullOrWhiteSpace(savedPassword) ? Visibility.Collapsed : Visibility.Visible;
         Loaded += (_, _) => PasswordBox.Focus();
     }
 
     public string Password => PasswordBox.Password;
 
+    public bool ClearSavedPasswordRequested { get; private set; }
+
     private void OkButton_OnClick(object sender, RoutedEventArgs e)
     {
         if (string.IsNullOrWhiteSpace(PasswordBox.Password))
@@ -29,4 +37,61 @@ public partial class PasswordPromptWindow : Window
     {
         DialogResult = false;
     }
+
+    private void ClearPasswordButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        ClearSavedPasswordRequested = true;
+        PasswordBox.Clear();
+        PasswordTextBox.Clear();
+        ClearPasswordButton.Visibility = Visibility.Collapsed;
+        FocusPasswordInput();
+    }
+
+    private void TogglePasswordVisibilityButton_OnClick(object sender, RoutedEventArgs e)
+    {
+        _isPasswordVisible = !_isPasswordVisible;
+        if (_isPasswordVisible)
+        {
+            PasswordTextBox.Text = PasswordBox.Password;
+            PasswordTextBox.Visibility = Visibility.Visible;
+            PasswordBox.Visibility = Visibility.Collapsed;
+            ShowPasswordIcon.Visibility = Visibility.Collapsed;
+            HidePasswordIcon.Visibility = Visibility.Visible;
+            TogglePasswordVisibilityButton.ToolTip = "隐藏密码";
+            PasswordTextBox.Focus();
+            PasswordTextBox.CaretIndex = PasswordTextBox.Text.Length;
+            return;
+        }
+
+        PasswordBox.Password = PasswordTextBox.Text;
+        PasswordBox.Visibility = Visibility.Visible;
+        PasswordTextBox.Visibility = Visibility.Collapsed;
+        ShowPasswordIcon.Visibility = Visibility.Visible;
+        HidePasswordIcon.Visibility = Visibility.Collapsed;
+        TogglePasswordVisibilityButton.ToolTip = "查看密码";
+        PasswordBox.Focus();
+    }
+
+    private void PasswordTextBox_OnTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
+    {
+        if (_isSyncingPasswordText)
+        {
+            return;
+        }
+
+        _isSyncingPasswordText = true;
+        PasswordBox.Password = PasswordTextBox.Text;
+        _isSyncingPasswordText = false;
+    }
+
+    private void FocusPasswordInput()
+    {
+        if (_isPasswordVisible)
+        {
+            PasswordTextBox.Focus();
+            return;
+        }
+
+        PasswordBox.Focus();
+    }
 }