diff --git a/core/Function/device_discovery_fun.py b/core/Function/device_discovery_fun.py index 1d8f26f..88e9249 100644 --- a/core/Function/device_discovery_fun.py +++ b/core/Function/device_discovery_fun.py @@ -2,8 +2,8 @@ import concurrent.futures import csv import ipaddress import json +import unicodedata import re -import socket import threading import time from dataclasses import asdict, dataclass, field @@ -75,7 +75,6 @@ class DeviceDiscoveryOptions: class DeviceInfo: ip: str mac: str - hostname: str vendor: str adapter: str interface_index: str @@ -112,6 +111,12 @@ class DeviceDiscovery: adapters = self._active_adapters(self.network.get_network_info()) return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters] + def get_adapter_choices_and_default_range(self) -> tuple[list[str], str, str]: + adapters = self._active_adapters(self.network.get_network_info()) + choices = [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters] + default_adapter = preferred_discovery_adapter(adapters) + return choices, adapter_to_safe_range(default_adapter) if default_adapter else "", default_adapter.get("name", "") if default_adapter else "" + def default_scan_range(self, adapter_name: str = ALL_ADAPTERS) -> str: adapters = self._select_adapters(adapter_name) if not adapters: @@ -136,7 +141,7 @@ class DeviceDiscovery: self.last_summary = "" self.output( f"开始局域网设备发现: {adapter_name or ALL_ADAPTERS}," - f"目标 {len(targets)} 个,并发 {discovery_options.workers},超时 {discovery_options.timeout_ms}ms\n", + f"目标 {len(targets)} 个,并发 {min(discovery_options.workers, 24, max(1, len(targets)))},超时 {discovery_options.timeout_ms}ms\n", "muted", ) self.status(self._status("扫描中", adapter_name or ALL_ADAPTERS, describe_targets(targets), len(targets), 0, 0, 0)) @@ -196,13 +201,14 @@ class DeviceDiscovery: def scan_targets(self, targets: list[str], options: DeviceDiscoveryOptions, started: float) -> dict[str, dict]: results = {} completed = 0 + workers = min(options.workers, 24, max(1, len(targets))) def task(ip: str) -> dict: if self.stop_event.is_set(): return {"ip": ip, "ok": False, "rtt": 0.0} return ping_once(ip, options.timeout_ms) - with concurrent.futures.ThreadPoolExecutor(max_workers=options.workers) as executor: + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: futures = {executor.submit(task, ip): ip for ip in targets} for future in concurrent.futures.as_completed(futures): completed += 1 @@ -241,7 +247,6 @@ class DeviceDiscovery: devices[local_ip] = DeviceInfo( ip=local_ip, mac=normalize_mac(adapter.get("mac", "")), - hostname=resolve_hostname(local_ip), vendor=vendor_name(adapter.get("mac", "")), adapter=adapter.get("name", ""), interface_index=str(adapter.get("interface_index", "")), @@ -270,7 +275,6 @@ class DeviceDiscovery: devices[ip] = DeviceInfo( ip=ip, mac=mac, - hostname=resolve_hostname(ip), vendor=vendor_name(mac), adapter=adapter.get("name", ""), interface_index=str(adapter.get("interface_index", "")), @@ -285,20 +289,15 @@ class DeviceDiscovery: adapter = adapter_for_ip(ip, adapters) if not adapter: continue - # Ping succeeded but no valid ARP MAC was visible; keep it out of - # the asset list unless it is a known local/gateway address. - if ip not in gateways and ip not in local_ips: - continue devices[ip] = DeviceInfo( ip=ip, mac="", - hostname=resolve_hostname(ip), vendor="未知", adapter=adapter.get("name", ""), interface_index=str(adapter.get("interface_index", "")), latency_ms=ping.get("rtt", 0.0), method="在线", - note="网关" if ip in gateways else "本机", + note="网关" if ip in gateways else "本机" if ip in local_ips else "未读取到 MAC", ) return list(devices.values()) @@ -346,7 +345,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA def build_scan_targets(self, adapters: list[dict], options: DeviceDiscoveryOptions) -> list[str]: if options.scan_range: - return parse_target_range(options.scan_range, options.max_hosts) + return filter_targets_for_adapters(parse_target_range(options.scan_range, options.max_hosts), adapters) targets = [] for adapter in adapters: targets.extend(parse_target_range(adapter_to_safe_range(adapter), options.max_hosts)) @@ -361,7 +360,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA def export_results(self, path: str) -> None: if not self.last_results: raise RuntimeError("还没有可导出的设备发现结果") - fields = ["ip", "mac", "hostname", "vendor", "adapter", "interface_index", "latency_ms", "method", "note", "checked_at"] + fields = ["ip", "mac", "vendor", "adapter", "interface_index", "latency_ms", "method", "note", "checked_at"] with open(path, "w", newline="", encoding="utf-8-sig") as file: writer = csv.DictWriter(file, fieldnames=fields) writer.writeheader() @@ -372,7 +371,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA if not self.last_results: return self.last_summary return "\n".join( - f"{row.get('ip', '')}\t{row.get('mac', '')}\t{row.get('hostname', '')}\t{row.get('vendor', '')}\t{row.get('method', '')}" + f"{row.get('ip', '')}\t{row.get('mac', '')}\t{row.get('vendor', '')}\t{row.get('method', '')}" for row in sorted(self.last_results, key=lambda item: ip_sort_key(item.get("ip", ""))) ) @@ -389,7 +388,8 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA adapters = self._active_adapters(self.network.get_network_info()) if not adapter_name or adapter_name == ALL_ADAPTERS: return adapters - return [adapter for adapter in adapters if adapter.get("name") == adapter_name] + selected = normalize_adapter_name(adapter_name) + return [adapter for adapter in adapters if normalize_adapter_name(adapter.get("name", "")) == selected] def _active_adapters(self, adapters: list[dict]) -> list[dict]: active = [] @@ -397,7 +397,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA if not adapter.get("ipv4"): continue status = str(adapter.get("status", "")).lower() - if "disconnect" in status or "断开" in status: + if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")): continue active.append(adapter) return active @@ -419,7 +419,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA def ping_once(ip: str, timeout_ms: int) -> dict: try: - result = run_hidden(["ping", ip, "-n", "1", "-w", str(timeout_ms)], timeout=max(2, timeout_ms / 1000 + 2)) + result = run_hidden(["ping", ip, "-n", "1", "-w", str(timeout_ms)], timeout=max(1.2, timeout_ms / 1000 + 0.8)) output = result.stdout if re.search(r"\bTTL=", output, re.IGNORECASE): match = re.search(r"(?:time|时间)[=<]?\s*(\d+(?:\.\d+)?)\s*(?:ms|毫秒)", output, re.IGNORECASE) @@ -480,6 +480,49 @@ def adapter_to_safe_range(adapter: dict) -> str: return ".".join(parts[:3]) + ".0/24" if len(parts) == 4 else "" +def preferred_discovery_adapter(adapters: list[dict]) -> Optional[dict]: + if not adapters: + return None + for adapter in adapters: + if adapter.get("gateway"): + return adapter + for adapter in adapters: + prefix = adapter.get("prefix_length") + try: + if prefix not in ("", None) and int(prefix) <= 24: + return adapter + except (TypeError, ValueError): + continue + return adapters[0] + + +def filter_targets_for_adapters(targets: list[str], adapters: list[dict]) -> list[str]: + return [target for target in targets if target_in_adapter_network(target, adapters)] + + +def target_in_adapter_network(target: str, adapters: list[dict]) -> bool: + try: + address = ipaddress.ip_address(target) + except ValueError: + return False + for adapter in adapters: + local_ip = adapter.get("ipv4", "") + if not local_ip: + continue + prefix = adapter.get("prefix_length") or netmask_to_prefix(adapter.get("netmask", "")) or 24 + try: + if address in ipaddress.ip_network(f"{local_ip}/{prefix}", strict=False): + return True + except Exception: + continue + return False + + +def normalize_adapter_name(value: str) -> str: + normalized = unicodedata.normalize("NFKC", str(value or "")) + return re.sub(r"[\s\u200b-\u200d\ufeff]+", " ", normalized).strip().casefold() + + def netmask_to_prefix(netmask: str) -> Optional[int]: if not netmask: return None @@ -499,27 +542,6 @@ def normalize_neighbor(item: dict) -> dict: } -def resolve_hostname(ip: str) -> str: - try: - old_timeout = socket.getdefaulttimeout() - socket.setdefaulttimeout(0.8) - try: - return socket.gethostbyaddr(ip)[0] - finally: - socket.setdefaulttimeout(old_timeout) - except Exception: - pass - try: - result = run_hidden(["nbtstat", "-A", ip], timeout=2) - for line in result.stdout.splitlines(): - match = re.match(r"\s*([^\s<]+)\s+<00>\s+UNIQUE", line, re.IGNORECASE) - if match: - return match.group(1).strip() - except Exception: - pass - return "" - - def vendor_name(mac: str) -> str: normalized = normalize_mac(mac) if len(normalized) < 8: diff --git a/core/Function/dns_diag_fun.py b/core/Function/dns_diag_fun.py index 6c8eaf1..74781e9 100644 --- a/core/Function/dns_diag_fun.py +++ b/core/Function/dns_diag_fun.py @@ -68,7 +68,7 @@ class DnsDiagnostic: self.done = done or (lambda: None) self.status = status or (lambda _stats: None) self.result = result or (lambda _row: None) - self.network = NetworkManager(lambda _text, _tag=None: None) + self.network = NetworkManager(output) self.stop_event = threading.Event() self.worker = None self.last_results: list[dict] = [] @@ -76,14 +76,35 @@ class DnsDiagnostic: self.local_dns_servers: set[str] = set() def get_adapter_choices(self) -> list[str]: - adapters = self._active_adapters(self.network.get_network_info()) + adapters = self.network.get_network_info() return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters] + def get_adapter_choices_and_default_dns(self) -> tuple[list[str], str]: + adapters = self.network.get_network_info() + choices = [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters] + servers = collect_adapter_dns(self._active_adapters(adapters)) + default_dns = ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS))) + return choices, default_dns + def default_dns_servers(self, adapter_name: str = ALL_ADAPTERS) -> str: - adapters = self._select_adapters(adapter_name) + adapters = self._select_dns_source_adapters(adapter_name) servers = collect_adapter_dns(adapters) return ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS))) + def repair_abnormal_dns(self, adapter_name: str = ALL_ADAPTERS) -> dict: + if self.is_running(): + raise RuntimeError("DNS 诊断正在运行,请先停止当前任务") + + adapters = self._select_repair_adapters(adapter_name) + if not adapters: + raise ValueError("没有找到可修复的活动网卡") + + servers = PUBLIC_DNS_SERVERS[:2] + for adapter in adapters: + self.network.set_dns_servers(adapter["name"], servers) + self.network.flush_dns_cache() + return {"adapters": [adapter["name"] for adapter in adapters], "servers": servers} + def start_diagnosis(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None: if self.is_running(): raise RuntimeError("DNS 诊断正在运行,请先停止当前任务") @@ -302,13 +323,25 @@ class DnsDiagnostic: return adapters return [adapter for adapter in adapters if adapter.get("name") == adapter_name] + def _select_dns_source_adapters(self, adapter_name: str) -> list[dict]: + adapters = self.network.get_network_info() + if not adapter_name or adapter_name == ALL_ADAPTERS: + return self._active_adapters(adapters) + return [adapter for adapter in adapters if adapter.get("name") == adapter_name] + + def _select_repair_adapters(self, adapter_name: str) -> list[dict]: + adapters = self._select_adapters(adapter_name) + if adapter_name and adapter_name != ALL_ADAPTERS: + return adapters + return [adapter for adapter in adapters if adapter.get("gateway") or adapter.get("dns1") or adapter.get("dns2")] + def _active_adapters(self, adapters: list[dict]) -> list[dict]: active = [] for adapter in adapters: if not adapter.get("ipv4"): continue status = str(adapter.get("status", "")).lower() - if "disconnect" in status or "断开" in status: + if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")): continue active.append(adapter) return active diff --git a/core/Function/ip_conflict_fun.py b/core/Function/ip_conflict_fun.py index 7b5bba9..45625ef 100644 --- a/core/Function/ip_conflict_fun.py +++ b/core/Function/ip_conflict_fun.py @@ -487,7 +487,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA if not adapter.get("ipv4"): continue status = str(adapter.get("status", "")).lower() - if "disconnect" in status or "断开" in status: + if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")): continue active.append(adapter) return active diff --git a/core/Function/loop_fun.py b/core/Function/loop_fun.py index df75f5d..727095a 100644 --- a/core/Function/loop_fun.py +++ b/core/Function/loop_fun.py @@ -353,7 +353,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA if not adapter.get("ipv4"): continue status = str(adapter.get("status", "")).lower() - if "disconnect" in status or "断开" in status: + if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")): continue active.append(adapter) return active diff --git a/core/Function/network_fun.py b/core/Function/network_fun.py index 7a77ec6..fa71af7 100644 --- a/core/Function/network_fun.py +++ b/core/Function/network_fun.py @@ -24,33 +24,55 @@ class NetworkManager: def get_network_info(self) -> list[dict]: script = r""" $ErrorActionPreference = "SilentlyContinue" -$items = Get-NetIPConfiguration | ForEach-Object { - $alias = $_.InterfaceAlias - $index = $_.InterfaceIndex - $adapter = Get-NetAdapter -InterfaceAlias $alias -ErrorAction SilentlyContinue - $ipif = Get-NetIPInterface -InterfaceAlias $alias -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1 - $cim = Get-CimInstance Win32_NetworkAdapterConfiguration -Filter "IPEnabled=True" -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceIndex -eq $index } | Select-Object -First 1 +$ipconfigs = @{} +Get-NetIPConfiguration | ForEach-Object { $ipconfigs[[string]$_.InterfaceIndex] = $_ } + +$ipifs = @{} +Get-NetIPInterface -AddressFamily IPv4 | ForEach-Object { + $key = [string]$_.InterfaceIndex + if (-not $ipifs.ContainsKey($key)) { $ipifs[$key] = $_ } +} + +$cims = @{} +Get-CimInstance Win32_NetworkAdapterConfiguration | ForEach-Object { + if ($_.InterfaceIndex -ne $null) { $cims[[string]$_.InterfaceIndex] = $_ } +} + +$dnsMap = @{} +Get-DnsClientServerAddress -AddressFamily IPv4 | ForEach-Object { + $dnsMap[[string]$_.InterfaceIndex] = @($_.ServerAddresses) +} + +$items = Get-NetAdapter | Sort-Object Name | ForEach-Object { + $adapter = $_ + $alias = $adapter.Name + $index = $adapter.ifIndex + $key = [string]$index + $ipconfig = $ipconfigs[$key] + $ipif = $ipifs[$key] + $cim = $cims[$key] + $dns = @($dnsMap[$key]) [PSCustomObject]@{ name = $alias - description = $_.InterfaceDescription - mac = if ($adapter) { $adapter.MacAddress } else { "" } - status = if ($adapter) { [string]$adapter.Status } else { "" } - link_speed = if ($adapter) { [string]$adapter.LinkSpeed } else { "" } + description = $adapter.InterfaceDescription + mac = $adapter.MacAddress + status = [string]$adapter.Status + link_speed = [string]$adapter.LinkSpeed interface_index = $index - ipv4 = @($_.IPv4Address | Select-Object -ExpandProperty IPAddress)[0] - ipv6 = @($_.IPv6Address | Select-Object -ExpandProperty IPAddress) - prefix_length = @($_.IPv4Address | Select-Object -ExpandProperty PrefixLength)[0] - gateway = @($_.IPv4DefaultGateway | Select-Object -ExpandProperty NextHop)[0] - dns = @($_.DNSServer.ServerAddresses) + ipv4 = @($ipconfig.IPv4Address | Select-Object -ExpandProperty IPAddress)[0] + ipv6 = @($ipconfig.IPv6Address | Select-Object -ExpandProperty IPAddress) + prefix_length = @($ipconfig.IPv4Address | Select-Object -ExpandProperty PrefixLength)[0] + gateway = @($ipconfig.IPv4DefaultGateway | Select-Object -ExpandProperty NextHop)[0] + dns = $dns dhcp_server = if ($cim) { $cim.DHCPServer } else { "" } dhcp_lease_obtained = if ($cim) { [string]$cim.DHCPLeaseObtained } else { "" } dhcp_lease_expires = if ($cim) { [string]$cim.DHCPLeaseExpires } else { "" } - dhcp_enabled = if ($ipif) { [string]$ipif.Dhcp -eq "Enabled" } else { $false } + dhcp_enabled = if ($ipif) { [string]$ipif.Dhcp -eq "Enabled" } elseif ($cim) { [bool]$cim.DHCPEnabled } else { $false } } } $items | ConvertTo-Json -Depth 5 -Compress """ - result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=15) + result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=30) if result.returncode != 0: raise RuntimeError(result.stdout.strip() or "PowerShell 获取网卡信息失败") @@ -60,6 +82,8 @@ $items | ConvertTo-Json -Depth 5 -Compress return self._get_network_info_from_ipconfig() data = json.loads(output[json_start:]) + if data is None: + return [] if isinstance(data, dict): data = [data] @@ -263,6 +287,54 @@ $items | ConvertTo-Json -Depth 5 -Compress self.output("静态 IPv4 配置已应用\n", "success") + def set_dns_servers(self, name: str, dns_servers: list[str]) -> None: + adapter_name = name.strip() + if not adapter_name: + raise ValueError("请选择网卡") + + servers = [validate_ip(server) for server in dns_servers if str(server).strip()] + if not servers: + raise ValueError("请输入至少一个 DNS 服务器") + + self._run_netsh( + [ + "interface", + "ip", + "set", + "dnsservers", + f"name={adapter_name}", + "source=static", + f"address={servers[0]}", + "index=1", + ] + ) + for index, server in enumerate(servers[1:], start=2): + self._run_netsh(["interface", "ip", "add", "dnsservers", f"name={adapter_name}", f"address={server}", f"index={index}"]) + self.output(f"已设置 DNS: {adapter_name} -> {', '.join(servers)}\n", "success") + + def flush_dns_cache(self) -> None: + result = run_hidden(["ipconfig", "/flushdns"], timeout=10) + if result.returncode != 0: + raise RuntimeError(result.stdout.strip() or "刷新 DNS 缓存失败") + self.output("已刷新 DNS 缓存\n", "success") + + def set_adapter_enabled(self, name: str, enabled: bool) -> None: + adapter_name = name.strip() + if not adapter_name: + raise ValueError("请选择网卡") + + action = "启用" if enabled else "禁用" + self.output(f"准备{action}网卡: {adapter_name}\n", "warning") + command = "Enable-NetAdapter" if enabled else "Disable-NetAdapter" + script = f""" +$ErrorActionPreference = "Stop" +{command} -Name {self._ps_quote(adapter_name)} -Confirm:$false +""" + result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=20) + if result.returncode != 0: + raise RuntimeError(result.stdout.strip() or f"{action}网卡失败,请确认已用管理员权限运行") + self.output(f"网卡已{action}: {adapter_name}\n", "success") + def load_profiles(self) -> dict: if not os.path.exists(self.profiles_file): return {} @@ -301,3 +373,6 @@ $items | ConvertTo-Json -Depth 5 -Compress if result.returncode != 0: message = (result.stdout + result.stderr).strip() raise RuntimeError(message or "netsh 命令执行失败,请确认已用管理员权限运行") + + def _ps_quote(self, value: str) -> str: + return "'" + value.replace("'", "''") + "'" diff --git a/core/ui/components.py b/core/ui/components.py index e5d5afd..bab7031 100644 --- a/core/ui/components.py +++ b/core/ui/components.py @@ -1,36 +1,72 @@ import tkinter as tk -from tkinter import scrolledtext, ttk +from tkinter import ttk from typing import Callable, Optional -from core.ui.theme import COLORS, FONT_MONO, FONT_SECTION, FONT_SMALL +from core.ui.theme import COLORS, FONT_MONO, FONT_SMALL + + +BUTTON_ICONS = { + "应用": "✓", + "开始": "▶", + "批量": "▶", + "测试": "▶", + "停止": "■", + "刷新": "⟳", + "重新": "⟳", + "自动": "◎", + "启用": "▷", + "禁用": "⊗", + "修复": "◇", + "复制": "⧉", + "导出": "⇩", + "导入": "⇧", + "保存": "+", + "删除": "×", +} class Console: def __init__(self, parent, height: int = 16): - self.widget = scrolledtext.ScrolledText( - parent, + self.frame = tk.Frame(parent, bg=COLORS["console_bg"], bd=0, highlightthickness=0) + self.frame.rowconfigure(0, weight=1) + self.frame.columnconfigure(0, weight=1) + + self.widget = tk.Text( + self.frame, height=height, wrap="word", bg=COLORS["console_bg"], fg=COLORS["console_fg"], insertbackground=COLORS["console_fg"], - selectbackground="#2b4a6f", + selectbackground="#17385c", relief="flat", borderwidth=0, font=FONT_MONO, - padx=14, - pady=12, + padx=16, + pady=14, ) + self.widget.grid(row=0, column=0, sticky="nsew") + + scrollbar = ttk.Scrollbar( + self.frame, + orient="vertical", + command=self.widget.yview, + style="Modern.Vertical.TScrollbar", + ) + scrollbar.grid(row=0, column=1, sticky="ns") + self.widget.configure(yscrollcommand=scrollbar.set) + self.widget.tag_config("muted", foreground=COLORS["console_muted"]) + self.widget.tag_config("accent", foreground=COLORS["console_accent"]) self.widget.tag_config("success", foreground="#86efac") self.widget.tag_config("warning", foreground="#fde68a") self.widget.tag_config("error", foreground="#fca5a5") def grid(self, **kwargs): - self.widget.grid(**kwargs) + self.frame.grid(**kwargs) def pack(self, **kwargs): - self.widget.pack(**kwargs) + self.frame.pack(**kwargs) def clear(self) -> None: self.widget.delete("1.0", tk.END) @@ -45,30 +81,35 @@ class Console: class Page(ttk.Frame): def __init__(self, parent, title: str, subtitle: str): - super().__init__(parent, style="Panel.TFrame") + super().__init__(parent, style="Page.TFrame") self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) - header = ttk.Frame(self, style="Panel.TFrame") - header.grid(row=0, column=0, sticky="ew", padx=28, pady=(24, 8)) + header = ttk.Frame(self, style="Page.TFrame") + header.grid(row=0, column=0, sticky="ew", padx=28, pady=(28, 12)) header.columnconfigure(0, weight=1) ttk.Label(header, text=title, style="Title.TLabel").grid(row=0, column=0, sticky="w") - ttk.Label(header, text=subtitle, style="Muted.TLabel").grid(row=1, column=0, sticky="w", pady=(4, 0)) + ttk.Label(header, text=subtitle, style="PageMuted.TLabel").grid(row=1, column=0, sticky="w", pady=(8, 0)) - body_shell = ttk.Frame(self, style="Panel.TFrame") - body_shell.grid(row=1, column=0, sticky="nsew", padx=28, pady=(4, 20)) + body_shell = ttk.Frame(self, style="Page.TFrame") + body_shell.grid(row=1, column=0, sticky="nsew", padx=28, pady=(0, 22)) body_shell.columnconfigure(0, weight=1) body_shell.rowconfigure(0, weight=1) - self._canvas = tk.Canvas(body_shell, bg=COLORS["panel"], highlightthickness=0, bd=0) + self._canvas = tk.Canvas(body_shell, bg=COLORS["bg"], highlightthickness=0, bd=0) self._canvas.grid(row=0, column=0, sticky="nsew") - scrollbar = ttk.Scrollbar(body_shell, orient="vertical", command=self._canvas.yview) - scrollbar.grid(row=0, column=1, sticky="ns", padx=(8, 0)) - self._canvas.configure(yscrollcommand=scrollbar.set) + self._scrollbar = ttk.Scrollbar( + body_shell, + orient="vertical", + command=self._canvas.yview, + style="Modern.Vertical.TScrollbar", + ) + self._scrollbar.grid(row=0, column=1, sticky="ns", padx=(8, 0)) + self._canvas.configure(yscrollcommand=self._sync_scrollbar) - self.body = ttk.Frame(self._canvas, style="Panel.TFrame") + self.body = ttk.Frame(self._canvas, style="Page.TFrame") self.body.columnconfigure(0, weight=1) self._body_window = self._canvas.create_window((0, 0), window=self.body, anchor="nw") @@ -77,14 +118,22 @@ class Page(ttk.Frame): self._bind_mousewheel(self._canvas) def section(self, title: str, row: int, columns: int = 4): - frame = tk.Frame(self.body, bg=COLORS["panel"], highlightbackground=COLORS["border"], highlightthickness=1) - frame.grid(row=row, column=0, sticky="ew", pady=(0, 14)) + frame = tk.Frame( + self.body, + bg=COLORS["panel"], + highlightbackground=COLORS["border"], + highlightcolor=COLORS["border"], + highlightthickness=1, + bd=0, + ) + frame.grid(row=row, column=0, sticky="ew", pady=(0, 16)) for col in range(columns): frame.columnconfigure(col, weight=1) - ttk.Label(frame, text=title, style="Section.TLabel").grid( - row=0, column=0, columnspan=columns, sticky="w", padx=18, pady=(14, 8) - ) + title_bar = ttk.Frame(frame, style="Panel.TFrame") + title_bar.grid(row=0, column=0, columnspan=columns, sticky="ew", padx=22, pady=(18, 10)) + ttk.Label(title_bar, text="▣", style="Section.TLabel", foreground=COLORS["primary"]).pack(side="left", padx=(0, 10)) + ttk.Label(title_bar, text=title, style="Section.TLabel").pack(side="left") return frame def _update_scroll_region(self, _event=None) -> None: @@ -93,6 +142,13 @@ class Page(ttk.Frame): def _resize_body(self, event) -> None: self._canvas.itemconfigure(self._body_window, width=event.width) + def _sync_scrollbar(self, first: str, last: str) -> None: + self._scrollbar.set(first, last) + if float(first) <= 0 and float(last) >= 1: + self._scrollbar.grid_remove() + else: + self._scrollbar.grid() + def _bind_mousewheel(self, widget) -> None: widget.bind("", lambda _event: widget.bind_all("", self._on_mousewheel)) widget.bind("", lambda _event: widget.unbind_all("")) @@ -105,10 +161,10 @@ class Page(ttk.Frame): def field(parent, label: str, row: int, column: int, value: str = "", width: int = 24, colspan: int = 1): frame = ttk.Frame(parent, style="Panel.TFrame") - frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=18, pady=(4, 14)) + frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=22, pady=(4, 16)) frame.columnconfigure(0, weight=1) - ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 5)) + ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 7)) var = tk.StringVar(value=value) entry = ttk.Entry(frame, textvariable=var, width=width) entry.grid(row=1, column=0, sticky="ew") @@ -118,10 +174,10 @@ def field(parent, label: str, row: int, column: int, value: str = "", width: int def combo(parent, label: str, row: int, column: int, values=None, value: str = "", width: int = 24, colspan: int = 1): values = values or [] frame = ttk.Frame(parent, style="Panel.TFrame") - frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=18, pady=(4, 14)) + frame.grid(row=row, column=column, columnspan=colspan, sticky="ew", padx=22, pady=(4, 16)) frame.columnconfigure(0, weight=1) - ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 5)) + ttk.Label(frame, text=label, style="Muted.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 7)) var = tk.StringVar(value=value) control = ttk.Combobox(frame, textvariable=var, values=values, width=width, state="readonly") control.grid(row=1, column=0, sticky="ew") @@ -130,15 +186,25 @@ def combo(parent, label: str, row: int, column: int, values=None, value: str = " def action_bar(parent, row: int, columnspan: int = 4): frame = ttk.Frame(parent, style="Panel.TFrame") - frame.grid(row=row, column=0, columnspan=columnspan, sticky="ew", padx=18, pady=(0, 16)) + frame.grid(row=row, column=0, columnspan=columnspan, sticky="ew", padx=22, pady=(0, 18)) return frame def button(parent, text: str, command: Callable, style: str = "Secondary.TButton"): - btn = ttk.Button(parent, text=text, command=command, style=style) - btn.pack(side="left", padx=(0, 10)) + btn = ttk.Button(parent, text=_button_label(text), command=command, style=style) + btn.pack(side="left", padx=(0, 12)) return btn +def _button_label(text: str) -> str: + stripped = text.strip() + if not stripped: + return text + for keyword, icon in BUTTON_ICONS.items(): + if stripped.startswith(keyword): + return f"{icon} {stripped}" + return stripped + + def set_entry_state(item, enabled: bool) -> None: item["entry"].configure(state="normal" if enabled else "disabled") diff --git a/core/ui/tab_device_discovery.py b/core/ui/tab_device_discovery.py index 0d259a8..7304d44 100644 --- a/core/ui/tab_device_discovery.py +++ b/core/ui/tab_device_discovery.py @@ -3,13 +3,14 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk from core.Function.device_discovery_fun import ALL_ADAPTERS, DeviceDiscovery -from core.ui.components import Console, Page, action_bar, button, combo, field +from core.ui.components import Page, action_bar, button, combo, field class DeviceDiscoveryTab(Page): - def __init__(self, parent): - super().__init__(parent, "设备发现", "发现局域网在线与 ARP 可见设备,整理 IP、MAC、主机名、厂商与来源网卡。") - self.body.rowconfigure(3, weight=1) + def __init__(self, parent, console): + super().__init__(parent, "设备发现", "发现局域网在线与 ARP 可见设备,整理 IP、MAC、厂商与来源网卡。") + self.console = console + self.body.rowconfigure(2, weight=1) status = self.section("实时状态", 0, columns=6) self.state = field(status, "状态", 1, 0, "等待", 10) @@ -23,31 +24,33 @@ class DeviceDiscoveryTab(Page): params = self.section("发现参数", 1, columns=6) self.adapter = combo(params, "检测网卡", 1, 0, [ALL_ADAPTERS], ALL_ADAPTERS, 22) + self.adapter["combobox"].bind("<>", lambda _event: self.fill_default_range(silent=True)) self.scan_range = field(params, "扫描范围", 1, 1, "", 34, colspan=2) - self.workers = field(params, "并发数", 1, 3, "64", 10) + self.workers = field(params, "并发数", 1, 3, "24", 10) self.timeout = field(params, "超时 ms", 1, 4, "500", 10) self.max_hosts = field(params, "最大地址数", 1, 5, "254", 10) - actions = action_bar(params, 2, 6) - self.start_btn = button(actions, "开始发现", self.start_discovery, "Primary.TButton") - self.stop_btn = button(actions, "停止", self.stop_discovery, "Danger.TButton") - self.refresh_btn = button(actions, "刷新网卡", self.load_adapters, "Secondary.TButton") - self.auto_range_btn = button(actions, "自动范围", self.fill_default_range, "Secondary.TButton") - self.copy_btn = button(actions, "复制清单", self.copy_inventory, "Secondary.TButton") - self.export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton") + primary_actions = action_bar(params, 2, 6) + self.start_btn = button(primary_actions, "开始发现", self.start_discovery, "Primary.TButton") + self.stop_btn = button(primary_actions, "停止", self.stop_discovery, "Danger.TButton") + self.refresh_btn = button(primary_actions, "刷新网卡", self.load_adapters, "Secondary.TButton") + self.auto_range_btn = button(primary_actions, "自动范围", self.fill_default_range, "Secondary.TButton") + self.stop_btn.configure(state="disabled") + secondary_actions = action_bar(params, 3, 6) + self.copy_btn = button(secondary_actions, "复制清单", self.copy_inventory, "Secondary.TButton") + self.export_btn = button(secondary_actions, "导出CSV", self.export_results, "Secondary.TButton") results = self.section("发现结果", 2, columns=1) results.rowconfigure(1, weight=1) results.columnconfigure(0, weight=1) self.results_tree = ttk.Treeview( results, - columns=("ip", "mac", "hostname", "vendor", "adapter", "latency", "method", "note"), + columns=("ip", "mac", "vendor", "adapter", "latency", "method", "note"), show="headings", height=10, ) headings = { "ip": "IP", "mac": "MAC", - "hostname": "主机名", "vendor": "厂商", "adapter": "来源网卡", "latency": "延迟", @@ -55,14 +58,13 @@ class DeviceDiscoveryTab(Page): "note": "备注", } widths = { - "ip": 130, - "mac": 150, - "hostname": 180, - "vendor": 130, + "ip": 112, + "mac": 132, + "vendor": 120, "adapter": 150, - "latency": 85, - "method": 90, - "note": 130, + "latency": 72, + "method": 80, + "note": 140, } for column, title in headings.items(): self.results_tree.heading(column, text=title) @@ -73,13 +75,9 @@ class DeviceDiscoveryTab(Page): self.results_tree.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.results_tree.yview) result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18)) - self.results_tree.configure(yscrollcommand=result_scroll.set) - - output = self.section("发现控制台", 3, columns=1) - output.rowconfigure(1, weight=1) - output.columnconfigure(0, weight=1) - self.console = Console(output, height=12) - self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) + x_scroll = ttk.Scrollbar(results, orient="horizontal", command=self.results_tree.xview) + x_scroll.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18)) + self.results_tree.configure(yscrollcommand=result_scroll.set, xscrollcommand=x_scroll.set) self.discovery = DeviceDiscovery(self.write, self.on_task_done, self.update_status, self.add_result) self.after(350, self.load_adapters) @@ -106,48 +104,58 @@ class DeviceDiscoveryTab(Page): def load_adapters(self): def worker(): try: - values = self.discovery.get_adapter_choices() - default_range = self.discovery.default_scan_range(ALL_ADAPTERS) + values, default_range, default_adapter = self.discovery.get_adapter_choices_and_default_range() except Exception as exc: values = [ALL_ADAPTERS] default_range = "" + default_adapter = "" self.write(f"读取网卡失败: {exc}\n", "warning") - self.after(0, lambda: self.apply_adapters(values, default_range)) + self.after(0, lambda: self.apply_adapters(values, default_range, default_adapter)) threading.Thread(target=worker, daemon=True).start() - def apply_adapters(self, values, default_range): + def apply_adapters(self, values, default_range, default_adapter=""): values = values or [ALL_ADAPTERS] values = list(dict.fromkeys(values)) self.adapter["combobox"]["values"] = values - if self.adapter["var"].get() not in values: - self.adapter["var"].set(values[1] if len(values) > 1 else values[0]) + if self.adapter["var"].get() not in values or self.adapter["var"].get() == ALL_ADAPTERS: + self.adapter["var"].set(default_adapter if default_adapter in values else values[1] if len(values) > 1 else values[0]) if not self.scan_range["var"].get() and default_range: self.scan_range["var"].set(default_range) - def fill_default_range(self): + def fill_default_range(self, silent=False): try: value = self.discovery.default_scan_range(self.adapter["var"].get()) if not value: - messagebox.showinfo("提示", "未能根据当前网卡生成扫描范围") + if not silent: + messagebox.showinfo("提示", "未能根据当前网卡生成扫描范围") return self.scan_range["var"].set(value) - self.write(f"已生成安全扫描范围: {value}\n", "success") + if not silent: + self.write(f"已生成安全扫描范围: {value}\n", "success") except Exception as exc: - messagebox.showwarning("生成失败", str(exc)) + if not silent: + messagebox.showwarning("生成失败", str(exc)) def start_discovery(self): try: self.clear() self.start_btn.configure(state="disabled") + self.stop_btn.configure(state="normal") + self.refresh_btn.configure(state="disabled") + self.auto_range_btn.configure(state="disabled") self.discovery.start_discovery(self.adapter["var"].get(), self.options()) except Exception as exc: self.start_btn.configure(state="normal") + self.stop_btn.configure(state="disabled") + self.refresh_btn.configure(state="normal") + self.auto_range_btn.configure(state="normal") messagebox.showwarning("无法开始设备发现", str(exc)) def stop_discovery(self): try: self.discovery.stop_discovery() + self.stop_btn.configure(state="disabled") except Exception as exc: messagebox.showinfo("提示", str(exc)) @@ -217,7 +225,6 @@ class DeviceDiscoveryTab(Page): values=( row.get("ip", ""), row.get("mac", ""), - row.get("hostname", ""), row.get("vendor", ""), row.get("adapter", ""), latency_text, @@ -230,4 +237,12 @@ class DeviceDiscoveryTab(Page): self.after(0, apply) def on_task_done(self): - self.after(0, lambda: self.start_btn.configure(state="normal")) + self.after( + 0, + lambda: ( + self.start_btn.configure(state="normal"), + self.stop_btn.configure(state="disabled"), + self.refresh_btn.configure(state="normal"), + self.auto_range_btn.configure(state="normal"), + ), + ) diff --git a/core/ui/tab_dns.py b/core/ui/tab_dns.py index 2c55153..8737c3c 100644 --- a/core/ui/tab_dns.py +++ b/core/ui/tab_dns.py @@ -3,13 +3,14 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk from core.Function.dns_diag_fun import ALL_ADAPTERS, DEFAULT_DOMAINS, DEFAULT_RECORD_TYPES, DnsDiagnostic -from core.ui.components import Console, Page, action_bar, button, combo, field +from core.ui.components import Page, action_bar, button, combo, field class DnsTab(Page): - def __init__(self, parent): + def __init__(self, parent, console): super().__init__(parent, "DNS 诊断", "对比本机 DNS 与常用 DNS 的解析结果、耗时和失败原因。") - self.body.rowconfigure(3, weight=1) + self.console = console + self.body.rowconfigure(2, weight=1) status = self.section("实时状态", 0, columns=6) self.state = field(status, "状态", 1, 0, "等待", 10) @@ -33,6 +34,7 @@ class DnsTab(Page): self.stop_btn = button(actions, "停止", self.stop_diagnosis, "Danger.TButton") self.refresh_btn = button(actions, "刷新网卡", self.load_adapters, "Secondary.TButton") self.auto_dns_btn = button(actions, "自动 DNS", self.fill_default_dns, "Secondary.TButton") + self.repair_btn = button(actions, "修复异常", self.repair_dns, "Secondary.TButton") self.copy_btn = button(actions, "复制摘要", self.copy_summary, "Secondary.TButton") self.export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton") @@ -55,13 +57,13 @@ class DnsTab(Page): "verdict": "错误 / 判断", } widths = { - "domain": 160, - "type": 70, - "server": 130, - "status": 80, - "elapsed": 85, - "values": 300, - "verdict": 360, + "domain": 130, + "type": 56, + "server": 116, + "status": 64, + "elapsed": 76, + "values": 220, + "verdict": 260, } for column, title in headings.items(): self.results_tree.heading(column, text=title) @@ -72,13 +74,9 @@ class DnsTab(Page): self.results_tree.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.results_tree.yview) result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18)) - self.results_tree.configure(yscrollcommand=result_scroll.set) - - output = self.section("诊断控制台", 3, columns=1) - output.rowconfigure(1, weight=1) - output.columnconfigure(0, weight=1) - self.console = Console(output, height=12) - self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) + x_scroll = ttk.Scrollbar(results, orient="horizontal", command=self.results_tree.xview) + x_scroll.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18)) + self.results_tree.configure(yscrollcommand=result_scroll.set, xscrollcommand=x_scroll.set) self.diagnostic = DnsDiagnostic(self.write, self.on_task_done, self.update_status, self.add_result) self.after(350, self.load_adapters) @@ -104,8 +102,7 @@ class DnsTab(Page): def load_adapters(self): def worker(): try: - values = self.diagnostic.get_adapter_choices() - default_dns = self.diagnostic.default_dns_servers(values[1] if len(values) > 1 else ALL_ADAPTERS) + values, default_dns = self.diagnostic.get_adapter_choices_and_default_dns() except Exception as exc: values = [ALL_ADAPTERS] default_dns = "" @@ -119,7 +116,7 @@ class DnsTab(Page): values = list(dict.fromkeys(values)) self.adapter["combobox"]["values"] = values if self.adapter["var"].get() not in values: - self.adapter["var"].set(values[1] if len(values) > 1 else values[0]) + self.adapter["var"].set(ALL_ADAPTERS) if not self.dns_servers["var"].get() and default_dns: self.dns_servers["var"].set(default_dns) @@ -138,9 +135,11 @@ class DnsTab(Page): try: self.clear() self.start_btn.configure(state="disabled") + self.repair_btn.configure(state="disabled") self.diagnostic.start_diagnosis(self.adapter["var"].get(), self.options()) except Exception as exc: self.start_btn.configure(state="normal") + self.repair_btn.configure(state="normal") messagebox.showwarning("无法开始 DNS 诊断", str(exc)) def stop_diagnosis(self): @@ -172,6 +171,42 @@ class DnsTab(Page): except Exception as exc: messagebox.showwarning("导出失败", str(exc)) + def repair_dns(self): + adapter = self.adapter["var"].get() or ALL_ADAPTERS + if not messagebox.askyesno( + "确认修复 DNS", + f"将把“{adapter}”的 DNS 设置为 223.5.5.5 和 114.114.114.114,并刷新 DNS 缓存。\n\n此操作需要管理员权限,确定继续吗?", + ): + return + + self.repair_btn.configure(state="disabled") + self.start_btn.configure(state="disabled") + self.write("\n开始修复 DNS 异常...\n", "warning") + + def worker(): + try: + result = self.diagnostic.repair_abnormal_dns(adapter) + self.after(0, lambda: self.on_repair_success(result)) + except Exception as exc: + self.after(0, lambda: self.on_repair_failed(exc)) + + threading.Thread(target=worker, daemon=True).start() + + def on_repair_success(self, result): + names = "、".join(result.get("adapters", [])) + servers = ",".join(result.get("servers", [])) + self.write(f"DNS 修复完成: {names} -> {servers}\n", "success") + self.dns_servers["var"].set(servers) + self.load_adapters() + self.start_btn.configure(state="normal") + self.repair_btn.configure(state="normal") + + def on_repair_failed(self, exc): + self.write(f"DNS 修复失败: {exc}\n", "error") + self.start_btn.configure(state="normal") + self.repair_btn.configure(state="normal") + messagebox.showerror("DNS 修复失败", f"{exc}\n\n请确认程序已用管理员权限运行。") + def copy_summary(self): text = self.diagnostic.copy_summary() if not text: @@ -226,4 +261,4 @@ class DnsTab(Page): self.after(0, apply) def on_task_done(self): - self.after(0, lambda: self.start_btn.configure(state="normal")) + self.after(0, lambda: (self.start_btn.configure(state="normal"), self.repair_btn.configure(state="normal"))) diff --git a/core/ui/tab_ip_conflict.py b/core/ui/tab_ip_conflict.py index a055938..613372e 100644 --- a/core/ui/tab_ip_conflict.py +++ b/core/ui/tab_ip_conflict.py @@ -3,13 +3,14 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk from core.Function.ip_conflict_fun import ALL_ADAPTERS, MODE_BOTH, MODE_LOCAL, MODE_SUBNET, IpConflictDetector -from core.ui.components import Console, Page, action_bar, button, combo, field +from core.ui.components import Page, action_bar, button, combo, field class IpConflictTab(Page): - def __init__(self, parent): + def __init__(self, parent, console): super().__init__(parent, "IP 冲突", "检测本机 IP 是否被占用,并安全扫描网段内 IP/MAC 异常。") - self.body.rowconfigure(3, weight=1) + self.console = console + self.body.rowconfigure(2, weight=1) status = self.section("实时状态", 0, columns=6) self.state = field(status, "状态", 1, 0, "等待", 10) @@ -74,12 +75,6 @@ class IpConflictTab(Page): result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18)) self.results_tree.configure(yscrollcommand=result_scroll.set) - output = self.section("诊断控制台", 3, columns=1) - output.rowconfigure(1, weight=1) - output.columnconfigure(0, weight=1) - self.console = Console(output, height=12) - self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) - self.detector = IpConflictDetector(self.write, self.on_task_done, self.update_status, self.add_result) self.after(350, self.load_adapters) diff --git a/core/ui/tab_loop.py b/core/ui/tab_loop.py index f5d492b..cc7c4fe 100644 --- a/core/ui/tab_loop.py +++ b/core/ui/tab_loop.py @@ -3,13 +3,14 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk from core.Function.loop_fun import ALL_ADAPTERS, LoopDetector -from core.ui.components import Console, Page, action_bar, button, combo, field +from core.ui.components import Page, action_bar, button, combo, field class LoopTab(Page): - def __init__(self, parent): + def __init__(self, parent, console): super().__init__(parent, "环网检测", "基于本机证据判断疑似二层环路、广播风暴和网关抖动风险。") - self.body.rowconfigure(3, weight=1) + self.console = console + self.body.rowconfigure(2, weight=1) status = self.section("实时状态", 0, columns=6) self.state = field(status, "状态", 1, 0, "等待", 10) @@ -79,12 +80,6 @@ class LoopTab(Page): result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18)) self.results_tree.configure(yscrollcommand=result_scroll.set) - output = self.section("诊断控制台", 3, columns=1) - output.rowconfigure(1, weight=1) - output.columnconfigure(0, weight=1) - self.console = Console(output, height=12) - self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) - self.detector = LoopDetector(self.write, self.on_task_done, self.update_status, self.add_result) self.after(350, self.load_adapters) diff --git a/core/ui/tab_network.py b/core/ui/tab_network.py index 3b991f8..16e7b9a 100644 --- a/core/ui/tab_network.py +++ b/core/ui/tab_network.py @@ -2,13 +2,14 @@ import threading from tkinter import messagebox from core.Function.network_fun import NetworkManager -from core.ui.components import Console, Page, action_bar, button, combo, field, set_entry_state +from core.ui.components import Page, action_bar, button, combo, field, set_entry_state class NetworkTab(Page): - def __init__(self, parent): + def __init__(self, parent, console): super().__init__(parent, "网卡配置", "查看本机网卡信息,切换 DHCP,或写入静态 IPv4 / DNS 配置。") - self.body.rowconfigure(4, weight=1) + self.console = console + self.body.rowconfigure(3, weight=1) self.adapters = [] self.profiles = {} @@ -23,6 +24,9 @@ class NetworkTab(Page): identity = self.section("网卡信息", 1, columns=4) self.description = field(identity, "设备描述", 1, 0, "", 46, colspan=2) self.mac = field(identity, "MAC 地址", 1, 2, "", 24) + adapter_actions = action_bar(identity, 2, 4) + self.enable_adapter_btn = button(adapter_actions, "启用网卡", self.enable_adapter, "Primary.TButton") + self.disable_adapter_btn = button(adapter_actions, "禁用网卡", self.disable_adapter, "Danger.TButton") for item in (self.description, self.mac): item["entry"].configure(state="disabled") @@ -46,12 +50,6 @@ class NetworkTab(Page): self.apply_profile_btn = button(profile_actions, "套用模板到表单", self.apply_profile_to_form, "Secondary.TButton") self.delete_profile_btn = button(profile_actions, "删除模板", self.delete_profile, "Danger.TButton") - output = self.section("输出控制台", 4, columns=1) - output.rowconfigure(1, weight=1) - output.columnconfigure(0, weight=1) - self.console = Console(output, height=12) - self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18)) - self.netmgr = NetworkManager(self.write) self.load_profiles() self.after(250, self.refresh_adapters) @@ -62,6 +60,8 @@ class NetworkTab(Page): def refresh_adapters(self, clear=True): self.refresh_btn.configure(state="disabled") self.reload_btn.configure(state="disabled") + self.enable_adapter_btn.configure(state="disabled") + self.disable_adapter_btn.configure(state="disabled") if clear: self.console.clear() self.write("正在读取本机网卡信息...\n", "muted") @@ -86,6 +86,7 @@ class NetworkTab(Page): self.write(f"读取完成,共发现 {len(names)} 个网卡\n", "success") else: self.write("未发现可用网卡\n", "warning") + self.update_adapter_action_state() self.refresh_btn.configure(state="normal") self.reload_btn.configure(state="normal") @@ -93,6 +94,7 @@ class NetworkTab(Page): self.write(f"读取网卡失败: {exc}\n", "error") self.refresh_btn.configure(state="normal") self.reload_btn.configure(state="normal") + self.update_adapter_action_state() messagebox.showerror("读取网卡失败", str(exc)) def load_selected_adapter(self): @@ -118,6 +120,7 @@ class NetworkTab(Page): self.description["entry"].configure(state="disabled") self.mac["entry"].configure(state="disabled") self.update_entry_state() + self.update_adapter_action_state(adapter) self.write_current_adapter(adapter) def current_adapter(self): @@ -132,6 +135,59 @@ class NetworkTab(Page): for item in (self.ipv4, self.netmask, self.gateway, self.dns1, self.dns2): set_entry_state(item, static) + def update_adapter_action_state(self, adapter=None): + adapter = adapter or self.current_adapter() + if not adapter: + self.enable_adapter_btn.configure(state="disabled") + self.disable_adapter_btn.configure(state="disabled") + return + + status = str(adapter.get("status", "")).lower() + disabled = "disabled" in status or "禁用" in status + self.enable_adapter_btn.configure(state="normal" if disabled else "disabled") + self.disable_adapter_btn.configure(state="disabled" if disabled else "normal") + + def enable_adapter(self): + self.set_adapter_enabled(True) + + def disable_adapter(self): + adapter = self.current_adapter() + if not adapter: + messagebox.showwarning("无法禁用网卡", "请先选择网卡") + return + name = adapter.get("name", "") + if not messagebox.askyesno("确认禁用网卡", f"确定禁用网卡“{name}”吗?\n\n这可能会中断当前网络连接。"): + return + self.set_adapter_enabled(False) + + def set_adapter_enabled(self, enabled: bool): + adapter = self.current_adapter() + if not adapter: + messagebox.showwarning("无法操作网卡", "请先选择网卡") + return + + self.enable_adapter_btn.configure(state="disabled") + self.disable_adapter_btn.configure(state="disabled") + action = "启用" if enabled else "禁用" + + def worker(): + try: + self.netmgr.set_adapter_enabled(adapter.get("name", ""), enabled) + self.after(0, lambda: self.on_adapter_action_success(action)) + except Exception as exc: + self.after(0, lambda: self.on_adapter_action_failed(action, exc)) + + threading.Thread(target=worker, daemon=True).start() + + def on_adapter_action_success(self, action: str): + self.write(f"网卡{action}完成,正在刷新网卡信息...\n", "success") + self.refresh_adapters(clear=False) + + def on_adapter_action_failed(self, action: str, exc): + self.write(f"网卡{action}失败: {exc}\n", "error") + self.update_adapter_action_state() + messagebox.showerror(f"网卡{action}失败", f"{exc}\n\n请确认程序已用管理员权限运行。") + def write_current_adapter(self, adapter): self.write("\n当前网卡:\n", "muted") rows = [ diff --git a/core/ui/tab_ping.py b/core/ui/tab_ping.py index 10baf18..a30da8a 100644 --- a/core/ui/tab_ping.py +++ b/core/ui/tab_ping.py @@ -5,7 +5,7 @@ from tkinter import filedialog, messagebox, ttk from core.Function.network_fun import NetworkManager from core.Function.ping_fun import PingFun -from core.ui.components import Console, Page, action_bar, button, combo, field +from core.ui.components import Page, action_bar, button, combo, field DEFAULT_SOURCE = "默认路由" @@ -13,9 +13,10 @@ IP_PATTERN = re.compile(r"(? None: style.configure("TFrame", background=COLORS["bg"]) style.configure("Panel.TFrame", background=COLORS["panel"]) style.configure("Alt.TFrame", background=COLORS["panel_alt"]) + style.configure("Page.TFrame", background=COLORS["bg"]) style.configure("TLabel", background=COLORS["panel"], foreground=COLORS["text"]) style.configure("Muted.TLabel", background=COLORS["panel"], foreground=COLORS["muted"], font=FONT_SMALL) - style.configure("Title.TLabel", background=COLORS["panel"], foreground=COLORS["text"], font=FONT_TITLE) + style.configure("PageMuted.TLabel", background=COLORS["bg"], foreground=COLORS["muted"], font=FONT_SMALL) + style.configure("Title.TLabel", background=COLORS["bg"], foreground=COLORS["text"], font=FONT_TITLE) style.configure("Section.TLabel", background=COLORS["panel"], foreground=COLORS["text"], font=FONT_SECTION) style.configure( @@ -55,44 +63,104 @@ def apply_theme(root: tk.Tk) -> None: bordercolor=COLORS["border"], lightcolor=COLORS["border"], darkcolor=COLORS["border"], - padding=7, + relief="solid", + padding=(10, 8), ) + style.map("TEntry", bordercolor=[("focus", COLORS["primary"]), ("disabled", COLORS["border"])]) style.configure( "TCombobox", fieldbackground="#ffffff", foreground=COLORS["text"], bordercolor=COLORS["border"], arrowcolor=COLORS["muted"], - padding=6, + relief="solid", + padding=(10, 7), ) + style.map("TCombobox", bordercolor=[("focus", COLORS["primary"])], fieldbackground=[("readonly", "#ffffff")]) style.configure( "Primary.TButton", background=COLORS["primary"], foreground="#ffffff", - borderwidth=0, + borderwidth=1, + bordercolor=COLORS["primary"], focusthickness=0, - padding=(14, 8), + padding=(16, 9), + ) + style.map( + "Primary.TButton", + background=[("active", COLORS["primary_dark"]), ("disabled", "#a9c0f4")], + bordercolor=[("active", COLORS["primary_dark"]), ("disabled", "#a9c0f4")], + foreground=[("disabled", "#eef4ff")], ) - style.map("Primary.TButton", background=[("active", COLORS["primary_dark"]), ("disabled", "#9bb7f5")]) style.configure( "Secondary.TButton", - background="#e7eef8", - foreground=COLORS["text"], - borderwidth=0, - padding=(14, 8), + background="#ffffff", + foreground=COLORS["primary"], + borderwidth=1, + bordercolor="#bcd0ff", + lightcolor="#bcd0ff", + darkcolor="#bcd0ff", + focusthickness=0, + padding=(16, 9), + ) + style.map( + "Secondary.TButton", + background=[("active", COLORS["primary_soft"]), ("disabled", "#f1f5f9")], + foreground=[("disabled", "#9aa8ba")], + bordercolor=[("active", COLORS["primary"]), ("disabled", COLORS["border"])], ) - style.map("Secondary.TButton", background=[("active", "#d6e2f3")]) style.configure( "Danger.TButton", - background=COLORS["danger"], - foreground="#ffffff", - borderwidth=0, - padding=(14, 8), + background="#ffffff", + foreground=COLORS["danger"], + borderwidth=1, + bordercolor="#fca5a5", + lightcolor="#fca5a5", + darkcolor="#fca5a5", + focusthickness=0, + padding=(16, 9), ) - style.map("Danger.TButton", background=[("active", "#bd2929")]) + style.map("Danger.TButton", background=[("active", COLORS["danger_soft"])], bordercolor=[("active", COLORS["danger"])]) + + style.configure( + "Treeview", + background="#ffffff", + fieldbackground="#ffffff", + foreground=COLORS["text"], + rowheight=30, + bordercolor=COLORS["border"], + borderwidth=1, + ) + style.configure( + "Treeview.Heading", + background=COLORS["panel_alt"], + foreground=COLORS["muted"], + font=("Microsoft YaHei UI", 9, "bold"), + relief="flat", + padding=(8, 7), + ) + style.map("Treeview", background=[("selected", COLORS["primary_soft"])], foreground=[("selected", COLORS["text"])]) + + style.configure("TNotebook", background=COLORS["panel"], borderwidth=0) + style.configure("TNotebook.Tab", padding=(18, 8), background=COLORS["panel_alt"], foreground=COLORS["muted"]) + style.map("TNotebook.Tab", background=[("selected", "#ffffff")], foreground=[("selected", COLORS["primary"])]) + + style.configure( + "Modern.Vertical.TScrollbar", + gripcount=0, + background="#d7e0ec", + darkcolor="#d7e0ec", + lightcolor="#d7e0ec", + troughcolor=COLORS["bg"], + bordercolor=COLORS["bg"], + arrowcolor=COLORS["muted"], + relief="flat", + width=10, + ) + style.map("Modern.Vertical.TScrollbar", background=[("active", "#bfccdc")]) def build_app_icon(size: int = 64) -> tk.PhotoImage: diff --git a/core/ui/ui_main.py b/core/ui/ui_main.py index 4155265..2e94c82 100644 --- a/core/ui/ui_main.py +++ b/core/ui/ui_main.py @@ -1,6 +1,10 @@ +import ctypes +import platform +import socket import tkinter as tk from tkinter import ttk +from core.ui.components import Console from core.ui.tab_device_discovery import DeviceDiscoveryTab from core.ui.tab_dns import DnsTab from core.ui.tab_ip_conflict import IpConflictTab @@ -17,33 +21,80 @@ class MainUI: self.root = root self.base_dir = base_dir self.root.title("NetPilot 网络调试工具") - self.root.geometry("1160x720") + self.root.geometry("1440x820") + self.root.minsize(1280, 760) apply_theme(root) self.icon_image = build_app_icon() self.root.iconphoto(True, self.icon_image) self.root.columnconfigure(1, weight=1) - self.root.rowconfigure(0, weight=1) + self.root.rowconfigure(1, weight=1) - self.sidebar = tk.Frame(root, width=232, bg=COLORS["sidebar"]) - self.sidebar.grid(row=0, column=0, sticky="ns") + self._build_topbar() + + self.sidebar = tk.Frame(root, width=260, bg=COLORS["sidebar"]) + self.sidebar.grid(row=1, column=0, rowspan=2, sticky="ns") self.sidebar.grid_propagate(False) - self.content = ttk.Frame(root, style="Panel.TFrame") - self.content.grid(row=0, column=1, sticky="nsew") + self.content = ttk.Frame(root, style="Page.TFrame") + self.content.grid(row=1, column=1, sticky="nsew") self.content.rowconfigure(0, weight=1) self.content.columnconfigure(0, weight=1) + self.console_shell = tk.Frame(root, width=410, bg=COLORS["bg"]) + self.console_shell.grid(row=1, column=2, sticky="nsew", padx=(0, 16), pady=(16, 0)) + self.console_shell.grid_propagate(False) + self.console_shell.rowconfigure(0, weight=1) + self.console_shell.columnconfigure(0, weight=1) + + self.console_panel = tk.Frame( + self.console_shell, + bg=COLORS["panel"], + highlightbackground=COLORS["border"], + highlightcolor=COLORS["border"], + highlightthickness=1, + bd=0, + ) + self.console_panel.grid(row=0, column=0, sticky="nsew") + self.console_panel.grid_propagate(False) + self.console_panel.rowconfigure(1, weight=1) + self.console_panel.columnconfigure(0, weight=1) + self._build_console_panel() + self._build_statusbar() + self.pages = {} self.nav_buttons = {} self._build_sidebar() self._build_pages() self.show_page("network") + def _build_topbar(self) -> None: + bar = tk.Frame(self.root, height=44, bg=COLORS["topbar"]) + bar.grid(row=0, column=0, columnspan=3, sticky="ew") + bar.grid_propagate(False) + + icon = tk.Label( + bar, + text="NP", + bg=COLORS["primary"], + fg="#ffffff", + font=("Microsoft YaHei UI", 9, "bold"), + width=3, + height=1, + ) + icon.pack(side="left", padx=(24, 12), pady=8) + tk.Label( + bar, + text="NetPilot 网络调试工具", + bg=COLORS["topbar"], + fg="#ffffff", + font=("Microsoft YaHei UI", 11, "bold"), + ).pack(side="left") + def _build_sidebar(self) -> None: brand = tk.Frame(self.sidebar, bg=COLORS["sidebar"]) - brand.pack(fill="x", padx=20, pady=(24, 26)) + brand.pack(fill="x", padx=28, pady=(28, 28)) logo = tk.Label( brand, @@ -52,14 +103,14 @@ class MainUI: height=2, bg=COLORS["primary"], fg="#ffffff", - font=("Microsoft YaHei UI", 14, "bold"), + font=("Microsoft YaHei UI", 18, "bold"), ) logo.pack(side="left") title = tk.Frame(brand, bg=COLORS["sidebar"]) title.pack(side="left", padx=12) - tk.Label(title, text="NetPilot", bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 16, "bold")).pack(anchor="w") - tk.Label(title, text="网络调试控制台", bg=COLORS["sidebar"], fg="#9fb4d0", font=("Microsoft YaHei UI", 9)).pack(anchor="w") + tk.Label(title, text="NetPilot", bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 17, "bold")).pack(anchor="w") + tk.Label(title, text="网络调试控制台", bg=COLORS["sidebar"], fg="#9fb4d0", font=("Microsoft YaHei UI", 10)).pack(anchor="w", pady=(4, 0)) items = [ ("network", "◎", "网卡配置", "IP / DNS / DHCP"), @@ -85,16 +136,16 @@ class MainUI: def _nav_button(self, key: str, icon: str, title: str, subtitle: str) -> tk.Frame: frame = tk.Frame(self.sidebar, bg=COLORS["sidebar"], cursor="hand2") - frame.pack(fill="x", padx=14, pady=4) + frame.pack(fill="x", padx=16, pady=5) - icon_label = tk.Label(frame, text=icon, width=3, bg=COLORS["sidebar"], fg="#c8d7ec", font=("Microsoft YaHei UI", 18)) - icon_label.pack(side="left", padx=(8, 6), pady=10) + icon_label = tk.Label(frame, text=icon, width=3, bg=COLORS["sidebar"], fg="#d9e6f7", font=("Microsoft YaHei UI", 18)) + icon_label.pack(side="left", padx=(12, 8), pady=12) text_frame = tk.Frame(frame, bg=COLORS["sidebar"]) text_frame.pack(side="left", fill="x", expand=True) - title_label = tk.Label(text_frame, text=title, bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 10, "bold")) + title_label = tk.Label(text_frame, text=title, bg=COLORS["sidebar"], fg="#ffffff", font=("Microsoft YaHei UI", 11, "bold")) title_label.pack(anchor="w") - subtitle_label = tk.Label(text_frame, text=subtitle, bg=COLORS["sidebar"], fg="#9fb4d0", font=("Microsoft YaHei UI", 8)) + subtitle_label = tk.Label(text_frame, text=subtitle, bg=COLORS["sidebar"], fg="#a6b7ce", font=("Microsoft YaHei UI", 9)) subtitle_label.pack(anchor="w", pady=(2, 0)) widgets = (frame, icon_label, text_frame, title_label, subtitle_label) @@ -107,14 +158,14 @@ class MainUI: def _build_pages(self) -> None: self.pages = { - "network": NetworkTab(self.content), - "dns": DnsTab(self.content), - "ping": PingTab(self.content), - "ports": TelnetTab(self.content), - "trace": TracertTab(self.content), - "loop": LoopTab(self.content), - "ip_conflict": IpConflictTab(self.content), - "devices": DeviceDiscoveryTab(self.content), + "network": NetworkTab(self.content, self.console), + "dns": DnsTab(self.content, self.console), + "ping": PingTab(self.content, self.console), + "ports": TelnetTab(self.content, self.console), + "trace": TracertTab(self.content, self.console), + "loop": LoopTab(self.content, self.console), + "ip_conflict": IpConflictTab(self.content, self.console), + "devices": DeviceDiscoveryTab(self.content, self.console), } for page in self.pages.values(): page.grid(row=0, column=0, sticky="nsew") @@ -129,8 +180,69 @@ class MainUI: active = getattr(self, "active_page", None) == key bg = COLORS["sidebar_active"] if active else COLORS["sidebar_hover"] if hover else COLORS["sidebar"] fg = "#ffffff" if active or hover else "#c8d7ec" - muted = "#dce9ff" if active else "#9fb4d0" + muted = "#dce9ff" if active else "#a6b7ce" for index, widget in enumerate(widgets): widget.configure(bg=bg) if isinstance(widget, tk.Label): widget.configure(fg=fg if index in (1, 3) else muted) + + def _build_console_panel(self) -> None: + header = tk.Frame(self.console_panel, bg=COLORS["panel"]) + header.grid(row=0, column=0, sticky="ew", padx=18, pady=(16, 12)) + header.columnconfigure(0, weight=1) + + tk.Label( + header, + text="输出控制台", + bg=COLORS["panel"], + fg=COLORS["text"], + font=("Microsoft YaHei UI", 13, "bold"), + ).grid(row=0, column=0, sticky="w") + + ttk.Button(header, text="⌫ 清空", command=lambda: self.console.clear(), style="Secondary.TButton").grid(row=0, column=1, sticky="e") + + self.console = Console(self.console_panel) + self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 16)) + + def _build_statusbar(self) -> None: + status = tk.Frame( + self.root, + height=40, + bg=COLORS["panel"], + highlightbackground=COLORS["border"], + highlightthickness=1, + bd=0, + ) + status.grid(row=2, column=1, columnspan=2, sticky="ew") + status.grid_propagate(False) + status.columnconfigure(1, weight=1) + + left = tk.Frame(status, bg=COLORS["panel"]) + left.grid(row=0, column=0, sticky="w", padx=28, pady=9) + self._status_dot(left) + self._status_label(left, "就绪", color=COLORS["muted"]) + self._status_separator(left) + self._status_label(left, "本地连接正常", color=COLORS["muted"]) + + right = tk.Frame(status, bg=COLORS["panel"]) + right.grid(row=0, column=1, sticky="e", padx=28, pady=9) + self._status_label(right, f"本机名:{socket.gethostname()}", color=COLORS["muted"]) + self._status_separator(right) + self._status_label(right, f"操作系统:{platform.system()} {platform.release()}", color=COLORS["muted"]) + self._status_separator(right) + self._status_label(right, "管理员权限" if self._is_admin() else "普通权限", color=COLORS["muted"]) + + def _status_dot(self, parent) -> None: + tk.Label(parent, text="●", bg=COLORS["panel"], fg="#22c55e", font=("Microsoft YaHei UI", 10)).pack(side="left", padx=(0, 10)) + + def _status_label(self, parent, text: str, color: str) -> None: + tk.Label(parent, text=text, bg=COLORS["panel"], fg=color, font=("Microsoft YaHei UI", 9)).pack(side="left") + + def _status_separator(self, parent) -> None: + tk.Label(parent, text="|", bg=COLORS["panel"], fg=COLORS["border_dark"], font=("Microsoft YaHei UI", 9)).pack(side="left", padx=14) + + def _is_admin(self) -> bool: + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + return False diff --git a/main.py b/main.py index e410935..ec8762a 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ def get_base_dir() -> str: def main() -> None: root = tk.Tk() - root.minsize(960, 600) + root.minsize(1200, 640) MainUI(root, base_dir=get_base_dir()) root.mainloop()