上传文件至「lite」

This commit is contained in:
2026-08-23 02:29:27 +08:00
parent 3a521daefd
commit 6c9a04b754
+142 -13
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton, QApplication, QWidget, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
QCheckBox, QCheckBox, QMenu,
) )
from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
@@ -93,10 +93,38 @@ def _load_nodes():
log_red(f"nodes.json 加载失败: {e}") log_red(f"nodes.json 加载失败: {e}")
return _NODES return _NODES
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
def _current_node(): def _current_node():
"""当前使用的节点:默认取列表第一个;无节点时返回 None。""" """当前使用的节点:默认取列表第一个;无节点时返回 None。"""
nodes = _load_nodes() nodes = _load_nodes()
return nodes[0] if nodes else None if not nodes:
return None
i = _SELECTED_NODE
return nodes[i] if 0 <= i < len(nodes) else nodes[0]
def _selected_index():
"""当前选中节点下标。"""
return _SELECTED_NODE
def _switch_node(index):
"""切换当前节点下标;相同或越界返回 False。"""
global _SELECTED_NODE
nodes = _load_nodes()
if not nodes or not (0 <= index < len(nodes)):
log_red("节点切换失败: 列表为空或下标越界")
return False
if index == _SELECTED_NODE:
log("已在当前节点")
return False
_SELECTED_NODE = index
return True
def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES
_NODES = None
return _load_nodes()
def _node_val(key, default=""): def _node_val(key, default=""):
node = _current_node() node = _current_node()
@@ -243,7 +271,7 @@ def log_red(msg: str):
def _run(cmd, warn=True): def _run(cmd, warn=True):
"""无窗口执行子进程,失败时记录警告日志。""" """无窗口执行子进程,失败时记录警告日志。"""
r = subprocess.run(cmd, capture_output=True, text=True, r = subprocess.run(cmd, capture_output=True, text=True,
creationflags=subprocess.CREATE_NO_WINDOW) creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
if warn and r.returncode != 0: if warn and r.returncode != 0:
err = r.stderr.strip() if r.stderr else "" err = r.stderr.strip() if r.stderr else ""
log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}") log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}")
@@ -819,6 +847,35 @@ def load_icon():
img.fill(QColor(C_TITLE)) img.fill(QColor(C_TITLE))
return QIcon(QPixmap.fromImage(img)) return QIcon(QPixmap.fromImage(img))
# ── 可点击节点标签 ─────────────────────────────────────
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
_NL_HOVER = _NL_BASE + " text-decoration: underline;"
class NodeLabel(QLabel):
"""节点标签:hover 显示下划线+手型光标,点击发出 clicked 信号。"""
clicked = pyqtSignal()
def __init__(self, text=""):
super().__init__(text)
self.setStyleSheet(_NL_BASE)
self.setToolTip("点击切换节点")
def enterEvent(self, e):
self.setStyleSheet(_NL_HOVER)
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(e)
def leaveEvent(self, e):
self.setStyleSheet(_NL_BASE)
self.unsetCursor()
super().leaveEvent(e)
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(e)
# ── 状态指示灯(小圆点) ─────────────────────────────── # ── 状态指示灯(小圆点) ───────────────────────────────
class StatusDot(QLabel): class StatusDot(QLabel):
@@ -914,11 +971,17 @@ class MainWindow(QWidget):
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 节点副标题 # 节点副标题(域名可点击切换:hover 下划线+手型提示,点击弹节点列表)
subtitle = QLabel(f"节点: {_endpoint_domain()} ({_endpoint_country()})") node_row = QHBoxLayout()
subtitle.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;") node_row.addStretch()
subtitle.setAlignment(Qt.AlignCenter) prefix = QLabel("节点: ")
layout.addWidget(subtitle) prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;")
node_row.addWidget(prefix)
self.subtitle = NodeLabel(f"{_endpoint_domain()} ({_endpoint_country()})")
self.subtitle.clicked.connect(self._show_node_menu)
node_row.addWidget(self.subtitle)
node_row.addStretch()
layout.addLayout(node_row)
# 连接方式选择区 # 连接方式选择区
pac_layout = QHBoxLayout() pac_layout = QHBoxLayout()
@@ -929,7 +992,7 @@ class MainWindow(QWidget):
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
pac_layout.addWidget(self.cb_bypass_lan) pac_layout.addWidget(self.cb_bypass_lan)
self.cb_pac = QCheckBox("PAC 规则模式") self.cb_pac = QCheckBox("PAC 规则模式")
self.cb_pac.setChecked(False) # 默认关闭 PAC,由用户按需开启 self.cb_pac.setChecked(True) # 默认开启智能分流(域名表/中国IP判定已足够完善)
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。") self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
pac_layout.addWidget(self.cb_pac) pac_layout.addWidget(self.cb_pac)
@@ -1086,6 +1149,14 @@ class MainWindow(QWidget):
threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start() threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start()
def _start_thread(self, use_pac, use_bypass_lan): def _start_thread(self, use_pac, use_bypass_lan):
try: try:
self._start_proxy(use_pac, use_bypass_lan)
self._op_done.emit("start", True)
except Exception as e:
log(f"[错误] 开启失败: {e}")
self._op_done.emit("start", False)
def _start_proxy(self, use_pac, use_bypass_lan):
"""启动本地转发器并设置系统代理(PAC/固定两种模式),可在后台线程调用。"""
_traffic_reset() _traffic_reset()
log("TLS 直连模式") log("TLS 直连模式")
ep = _endpoint_domain() ep = _endpoint_domain()
@@ -1123,10 +1194,6 @@ class MainWindow(QWidget):
except Exception as e: except Exception as e:
log(f" {name} 不可达: {e}") log(f" {name} 不可达: {e}")
log("开启成功") log("开启成功")
self._op_done.emit("start", True)
except Exception as e:
log(f"[错误] 开启失败: {e}")
self._op_done.emit("start", False)
def on_stop(self): def on_stop(self):
"""禁用按钮并在后台线程执行关闭流程。""" """禁用按钮并在后台线程执行关闭流程。"""
@@ -1146,6 +1213,67 @@ class MainWindow(QWidget):
self._op_done.emit("stop", False) self._op_done.emit("stop", False)
# ── 节点切换 ──
def _show_node_menu(self):
"""弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""
if self._proxy_set_by_us:
log("代理运行中,请先关闭代理再切换节点")
return
nodes = _reload_nodes()
if not nodes:
QMessageBox.information(self, "提示", "nodes.json 中没有可用节点")
return
menu = QMenu(self)
menu.setStyleSheet(f"""
QMenu {{
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 8px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 18px;
border-radius: 6px;
color: {C_TEXT};
font-size: 12px;
}}
QMenu::item:selected {{
background-color: #e3e8ee;
}}
""")
cur = _selected_index()
for i, n in enumerate(nodes):
domain = n.get("domain") or "???"
country = n.get("country") or ""
remark = n.get("remark") or ""
text = f"{domain} ({country})"
if remark and remark != domain:
text += f" {remark}"
if i == cur:
text = "✓ " + text
menu.addAction(text).triggered.connect(
lambda _=False, idx=i: self._on_node_selected(idx))
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
def _on_node_selected(self, idx):
"""切换节点:更新标题。"""
if not _switch_node(idx):
return
node = _current_node()
self._refresh_subtitle()
log(f"已切换节点: {node.get('domain')} ({node.get('country')})")
def _refresh_subtitle(self):
"""刷新节点副标题文字。"""
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})")
def _update_node_label(self):
"""代理运行中时提示禁止切换节点。"""
if self._proxy_set_by_us:
self.subtitle.setToolTip("代理运行中,请先关闭代理再切换节点")
else:
self.subtitle.setToolTip("点击切换节点")
def _after_op(self, op, ok): def _after_op(self, op, ok):
"""开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。""" """开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。"""
self.refresh_status() self.refresh_status()
@@ -1155,6 +1283,7 @@ class MainWindow(QWidget):
is_off = (op == "stop") or (op == "start" and not ok) is_off = (op == "stop") or (op == "start" and not ok)
self.cb_pac.setEnabled(is_off) self.cb_pac.setEnabled(is_off)
self.cb_bypass_lan.setEnabled(is_off) self.cb_bypass_lan.setEnabled(is_off)
self._update_node_label()
# ── 日志 ── # ── 日志 ──
_early_logs = [] # 窗口初始化期间(log_text 创建前)到达的日志缓存 _early_logs = [] # 窗口初始化期间(log_text 创建前)到达的日志缓存