diff --git a/tests/test_manager_logic.py b/tests/test_manager_logic.py
index 4298768..c328086 100644
--- a/tests/test_manager_logic.py
+++ b/tests/test_manager_logic.py
@@ -43,23 +43,31 @@ class FakeProcess:
self.running = False
+def valid_snapshot_rows(rows: list[tuple[str, str, str]]) -> str:
+ csv_rows = [
+ "#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,OpenVPN_ConfigData_Base64"
+ ]
+ for index, (ip, country_long, country_short) in enumerate(rows):
+ config_text = (
+ "client\n"
+ "dev tun\n"
+ "proto udp\n"
+ f"remote {ip} 1194 udp\n"
+ "resolv-retry infinite\n"
+ "nobind\n"
+ "\nCA\n\n"
+ "\nCERT\n\n"
+ "\nKEY\n\n"
+ )
+ config = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
+ csv_rows.append(
+ f"vpn{index}.example,{ip},100,20,1000,{country_long},{country_short},1,{config}"
+ )
+ return "\n".join(csv_rows) + "\n"
+
+
def valid_snapshot(ip: str = "198.51.100.10") -> str:
- config_text = (
- "client\n"
- "dev tun\n"
- "proto udp\n"
- f"remote {ip} 1194 udp\n"
- "resolv-retry infinite\n"
- "nobind\n"
- "\nCA\n\n"
- "\nCERT\n\n"
- "\nKEY\n\n"
- )
- config = base64.b64encode(config_text.encode("utf-8")).decode("ascii")
- return (
- "#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,OpenVPN_ConfigData_Base64\n"
- f"vpn.example,{ip},100,20,1000,Japan,JP,1,{config}\n"
- )
+ return valid_snapshot_rows([(ip, "Japan", "JP")])
class ManagerLogicTests(unittest.TestCase):
@@ -327,6 +335,64 @@ class ManagerLogicTests(unittest.TestCase):
fetch_mock.call_args_list,
)
+ def test_discovery_countries_are_normalized_and_persisted(self) -> None:
+ countries = manager.persist_discovery_countries(["jp", "US", "JP", "bad", ""])
+
+ self.assertEqual(["JP", "US"], countries)
+ self.assertEqual(["JP", "US"], manager.load_ui_config()["discovery_countries"])
+ self.assertEqual(["JP", "US"], manager.get_state()["discovery_countries"])
+
+ def test_fetch_filters_country_after_source_is_accepted(self) -> None:
+ csv_text = valid_snapshot_rows(
+ [
+ ("198.51.100.60", "Japan", "JP"),
+ ("198.51.100.61", "United States", "US"),
+ ]
+ )
+ manager.persist_discovery_countries(["JP"])
+
+ with (
+ mock.patch.object(manager, "fetch_api_text", return_value=csv_text) as fetch_mock,
+ mock.patch.object(manager, "load_blacklist", return_value={}),
+ mock.patch.object(manager, "log_to_json"),
+ ):
+ nodes = manager.fetch_candidates()
+
+ self.assertEqual(["JP"], [node["country_short"] for node in nodes])
+ fetch_mock.assert_called_once_with(manager.API_HTTPS_URL, True)
+ self.assertEqual(csv_text, manager.API_CACHE_FILE.read_text(encoding="utf-8"))
+ self.assertIn("成功获取 2 个", manager.get_state()["last_fetch_message"])
+ self.assertIn("保留 1 个", manager.get_state()["last_fetch_message"])
+
+ def test_empty_country_result_does_not_fall_through_to_next_source(self) -> None:
+ csv_text = valid_snapshot_rows(
+ [
+ ("198.51.100.70", "Japan", "JP"),
+ ("198.51.100.71", "United States", "US"),
+ ]
+ )
+ manager.persist_discovery_countries(["DE"])
+
+ with (
+ mock.patch.object(manager, "fetch_api_text", return_value=csv_text) as fetch_mock,
+ mock.patch.object(manager, "load_blacklist", return_value={}),
+ mock.patch.object(manager, "log_to_json"),
+ ):
+ nodes = manager.fetch_candidates()
+
+ self.assertEqual([], nodes)
+ fetch_mock.assert_called_once_with(manager.API_HTTPS_URL, True)
+ state = manager.get_state()
+ self.assertEqual("ok", state["last_fetch_status"])
+ self.assertEqual("official_https", state["last_fetch_source"])
+ self.assertIn("保留 0 个", state["last_fetch_message"])
+
+ def test_node_table_contains_latency_country_panel_and_test_action(self) -> None:
+ self.assertIn('
延迟 | ', manager.INDEX_HTML)
+ self.assertIn('colspan="7"', manager.INDEX_HTML)
+ self.assertIn('class="country-option-input"', manager.INDEX_HTML)
+ self.assertIn('${testBtn}', manager.INDEX_HTML)
+
def test_fetch_uses_github_mirror_after_official_sources(self) -> None:
csv_text = valid_snapshot()
diff --git a/vpngate_manager.py b/vpngate_manager.py
index f8fe330..f9894cc 100644
--- a/vpngate_manager.py
+++ b/vpngate_manager.py
@@ -223,6 +223,21 @@ def generate_random_username() -> str:
if has_lower and has_upper and has_digit:
return uname
+def normalize_discovery_countries(value: Any) -> list[str]:
+ if not isinstance(value, (list, tuple, set)):
+ return []
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for item in value:
+ code = str(item or "").strip().upper()
+ if not re.fullmatch(r"[A-Z]{2}", code) or code in seen:
+ continue
+ normalized.append(code)
+ seen.add(code)
+ if len(normalized) >= 250:
+ break
+ return normalized
+
def load_ui_config() -> dict[str, Any]:
with lock:
auth_file = DATA_DIR / "ui_auth.json"
@@ -239,7 +254,8 @@ def load_ui_config() -> dict[str, Any]:
"connection_enabled": True,
"fixed_node_id": "",
"favorite_node_ids": [],
- "fav_fail_fallback": False
+ "fav_fail_fallback": False,
+ "discovery_countries": [],
}
updated = False
if auth_file.exists():
@@ -247,7 +263,7 @@ def load_ui_config() -> dict[str, Any]:
data = json.loads(auth_file.read_text(encoding="utf-8"))
for key, val in data.items():
config[key] = val
- for key in ["host", "port", "proxy_port", "routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id", "favorite_node_ids", "fav_fail_fallback"]:
+ for key in ["host", "port", "proxy_port", "routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id", "favorite_node_ids", "fav_fail_fallback", "discovery_countries"]:
if key not in data:
updated = True
except Exception:
@@ -275,6 +291,11 @@ def load_ui_config() -> dict[str, Any]:
if normalized_proxy_port != config.get("proxy_port"):
config["proxy_port"] = normalized_proxy_port
updated = True
+
+ normalized_discovery_countries = normalize_discovery_countries(config.get("discovery_countries"))
+ if normalized_discovery_countries != config.get("discovery_countries"):
+ config["discovery_countries"] = normalized_discovery_countries
+ updated = True
if not auth_file.exists() or updated:
try:
@@ -285,6 +306,18 @@ def load_ui_config() -> dict[str, Any]:
return config
+def persist_discovery_countries(value: Any) -> list[str]:
+ if not isinstance(value, list):
+ raise ValueError("国家筛选范围必须是国家代码列表")
+ countries = normalize_discovery_countries(value)
+ ui_cfg = load_ui_config()
+ ui_cfg["discovery_countries"] = countries
+ auth_file = DATA_DIR / "ui_auth.json"
+ with lock:
+ DATA_DIR.mkdir(exist_ok=True, parents=True)
+ write_json(auth_file, ui_cfg)
+ return countries
+
# 初始化时优先从 ui_auth.json 加载保存的代理出站端口和网页端口配置以覆盖环境变量
try:
_init_cfg = load_ui_config()
@@ -396,6 +429,7 @@ def get_state() -> dict[str, Any]:
state["connection_enabled"] = ui_cfg.get("connection_enabled", True)
state["fixed_node_id"] = ui_cfg.get("fixed_node_id", "")
state["favorite_node_ids"] = ui_cfg.get("favorite_node_ids", [])
+ state["discovery_countries"] = normalize_discovery_countries(ui_cfg.get("discovery_countries"))
state["fav_fail_fallback"] = False
return state
@@ -851,8 +885,24 @@ def rows_to_candidates(
seen_ips.add(ip)
return candidates
+def filter_candidates_by_discovery_countries(
+ candidates: list[dict[str, Any]],
+ country_codes: Any,
+) -> list[dict[str, Any]]:
+ selected = set(normalize_discovery_countries(country_codes))
+ if not selected:
+ return candidates
+ return [
+ candidate
+ for candidate in candidates
+ if str(candidate.get("country_short") or "").strip().upper() in selected
+ ]
+
def fetch_candidates() -> list[dict[str, Any]]:
blacklist = load_blacklist()
+ discovery_countries = normalize_discovery_countries(
+ load_ui_config().get("discovery_countries")
+ )
last_err: Exception | None = None
log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...")
@@ -873,15 +923,31 @@ def fetch_candidates() -> list[dict[str, Any]]:
if url.startswith("https://"):
cache_api_snapshot(api_text, source_name)
+ filtered_candidates = filter_candidates_by_discovery_countries(
+ candidates,
+ discovery_countries,
+ )
+ scope_message = (
+ f"按国家范围 {', '.join(discovery_countries)} 筛选后保留 {len(filtered_candidates)} 个"
+ if discovery_countries
+ else f"保留全部 {len(filtered_candidates)} 个"
+ )
+
set_state(
last_fetch_at=time.time(),
last_fetch_status="ok",
last_fetch_source=source_name,
- last_fetch_message=f"从 {source_name} 获取 {len(candidates)} 个候选节点。",
+ last_fetch_message=(
+ f"从 {source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}。"
+ ),
blacklisted_nodes=len(blacklist),
)
- log_to_json("INFO", "Main", f"节点源 {source_name} 获取成功,共 {len(candidates)} 个候选节点")
- return candidates
+ log_to_json(
+ "INFO",
+ "Main",
+ f"节点源 {source_name} 获取成功,共 {len(candidates)} 个候选节点,{scope_message}",
+ )
+ return filtered_candidates
except Exception as e:
last_err = e
print(f"[fetch_candidates] 节点源 {source_name} 失败: {e}", flush=True)
@@ -901,15 +967,31 @@ def fetch_candidates() -> list[dict[str, Any]]:
raise ValueError("本地快照没有未被屏蔽的候选节点")
if source_name == "bundled_initial" and not API_CACHE_FILE.exists():
cache_api_snapshot(api_text, source_name)
+ filtered_candidates = filter_candidates_by_discovery_countries(
+ candidates,
+ discovery_countries,
+ )
+ scope_message = (
+ f"按国家范围 {', '.join(discovery_countries)} 筛选后保留 {len(filtered_candidates)} 个"
+ if discovery_countries
+ else f"保留全部 {len(filtered_candidates)} 个"
+ )
set_state(
last_fetch_at=time.time(),
last_fetch_status="cached",
last_fetch_source=source_name,
- last_fetch_message=f"网络节点源不可用,已载入 {source_name} 的 {len(candidates)} 个候选节点。",
+ last_fetch_message=(
+ f"网络节点源不可用,已载入 {source_name} 的 {len(candidates)} 个候选节点,"
+ f"{scope_message}。"
+ ),
blacklisted_nodes=len(blacklist),
)
- log_to_json("WARNING", "Main", f"网络节点源不可用,使用 {source_name},共 {len(candidates)} 个候选节点")
- return candidates
+ log_to_json(
+ "WARNING",
+ "Main",
+ f"网络节点源不可用,使用 {source_name},共 {len(candidates)} 个候选节点,{scope_message}",
+ )
+ return filtered_candidates
except Exception as e:
last_err = e
print(f"[fetch_candidates] 本地节点源 {source_name} 失败: {e}", flush=True)
@@ -3064,6 +3146,8 @@ INDEX_HTML = r"""
}
.toolbar {
+ position: relative;
+ z-index: 50;
background: var(--bg-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -3098,7 +3182,7 @@ INDEX_HTML = r"""
background: #0f172a;
}
- .toolbar input {
+ .toolbar > input {
flex: 1;
min-width: 250px;
height: 42px;
@@ -3112,13 +3196,178 @@ INDEX_HTML = r"""
transition: all 0.2s ease;
}
- .toolbar input:focus {
+ .toolbar > input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
background: rgba(15, 23, 42, 0.8);
}
+ .country-filter {
+ position: relative;
+ width: 220px;
+ flex: 0 0 220px;
+ }
+
+ .country-filter-button {
+ width: 100%;
+ height: 42px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 0 12px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 14px;
+ cursor: pointer;
+ }
+
+ .country-filter-button:hover,
+ .country-filter-button[aria-expanded="true"] {
+ border-color: var(--primary);
+ background: rgba(15, 23, 42, 0.8);
+ }
+
+ .country-filter-button:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+ }
+
+ .country-filter-chevron {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 16px;
+ transition: transform 0.2s ease;
+ }
+
+ .country-filter-button[aria-expanded="true"] .country-filter-chevron {
+ transform: rotate(180deg);
+ }
+
+ .country-filter-panel {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ z-index: 1000;
+ width: min(320px, calc(100vw - 40px));
+ max-height: 360px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: rgba(15, 23, 42, 0.98);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
+ }
+
+ .country-filter-panel[hidden] {
+ display: none;
+ }
+
+ .country-filter-options {
+ padding: 6px;
+ overflow-y: auto;
+ }
+
+ .country-option {
+ position: relative;
+ min-height: 40px;
+ display: grid;
+ grid-template-columns: 18px 24px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 8px;
+ border-radius: 6px;
+ color: var(--text-primary);
+ cursor: pointer;
+ }
+
+ .country-option:hover {
+ background: rgba(255, 255, 255, 0.06);
+ }
+
+ .country-option-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ .country-option-box {
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(148, 163, 184, 0.7);
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.03);
+ color: white;
+ font-size: 12px;
+ line-height: 1;
+ }
+
+ .country-option-input:checked + .country-option-box {
+ border-color: var(--primary);
+ background: var(--primary);
+ }
+
+ .country-option-input:checked + .country-option-box::after {
+ content: "✓";
+ }
+
+ .country-option-input:focus-visible + .country-option-box {
+ outline: 2px solid #a5b4fc;
+ outline-offset: 2px;
+ }
+
+ .country-option-flag {
+ font-size: 18px;
+ line-height: 1;
+ text-align: center;
+ }
+
+ .country-option-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .country-option-count {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .country-filter-footer {
+ padding: 8px;
+ border-top: 1px solid var(--border-color);
+ }
+
+ .country-filter-clear {
+ width: 100%;
+ min-height: 36px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: #a5b4fc;
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+ }
+
+ .country-filter-clear:hover,
+ .country-filter-clear:focus-visible {
+ background: rgba(99, 102, 241, 0.12);
+ outline: none;
+ }
+
.table-wrapper {
background: var(--bg-surface);
backdrop-filter: blur(12px);
@@ -3135,6 +3384,7 @@ INDEX_HTML = r"""
table {
width: 100%;
+ min-width: 1120px;
border-collapse: collapse;
text-align: left;
table-layout: fixed;
@@ -3243,6 +3493,8 @@ INDEX_HTML = r"""
.table-actions {
display: flex;
gap: 8px;
+ align-items: center;
+ white-space: nowrap;
}
.connect-btn {
@@ -3323,6 +3575,19 @@ INDEX_HTML = r"""
color: #fb7185;
}
+ .latency-estimated {
+ background: rgba(148, 163, 184, 0.08);
+ color: var(--text-secondary);
+ border: 1px dashed rgba(148, 163, 184, 0.35);
+ font-weight: 500;
+ }
+
+ .latency-source {
+ margin-left: 4px;
+ font-size: 10px;
+ opacity: 0.8;
+ }
+
@media (max-width: 768px) {
header {
flex-direction: column;
@@ -3332,12 +3597,16 @@ INDEX_HTML = r"""
.btn-group {
width: 100%;
margin-top: 12px;
+ gap: 8px;
+ flex-wrap: wrap;
}
- .btn-group button, .btn-group .btn-telegram {
- flex: 1;
+ .btn-group > button,
+ .btn-group > .btn-telegram,
+ .btn-group > .dropdown {
+ flex: 1 1 calc(50% - 4px);
+ min-width: 0;
}
.btn-group .dropdown {
- flex: 1;
display: flex;
}
.btn-group .dropdown button {
@@ -3591,9 +3860,26 @@ INDEX_HTML = r"""
-
+