上传文件至「lite」
This commit is contained in:
+142
-13
@@ -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 创建前)到达的日志缓存
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user