1310 lines
56 KiB
Python
1310 lines
56 KiB
Python
"""需要业务服务的 Qt 对话框。"""
|
||
|
||
import json
|
||
import threading
|
||
from datetime import datetime
|
||
from dataclasses import dataclass
|
||
|
||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||
from PyQt5.QtWidgets import (
|
||
QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
|
||
QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
|
||
QButtonGroup, QMessageBox,
|
||
)
|
||
|
||
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
|
||
from .widgets import NodeLabel, ToggleSwitch
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LoginServices:
|
||
"""登录框所需的显式业务依赖,避免 UI 反向导入程序入口。"""
|
||
|
||
load_icon: object
|
||
load_auth: object
|
||
get_nodes_url: object
|
||
fetch_nodes: object
|
||
user_dir: object
|
||
encrypt_nodes: object
|
||
save_auth: object
|
||
auth_error: type
|
||
log: object
|
||
|
||
|
||
class LoginDialog(QDialog):
|
||
"""使用 SSO 获取并加密保存节点配置的登录框。"""
|
||
|
||
_done = pyqtSignal(bool, str)
|
||
|
||
def __init__(self, services: LoginServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("节点配置登录")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(420, 170)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QLineEdit {{
|
||
background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 6px 8px; color: {C_TEXT}; font-size: 12px;
|
||
}}
|
||
QPushButton#btnOk {{
|
||
background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
|
||
padding: 4px 12px; font-size: 12px; font-weight: bold;
|
||
}}
|
||
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||
QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }}
|
||
QPushButton#btnCancel {{
|
||
background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 4px 12px; font-size: 12px;
|
||
}}
|
||
""")
|
||
|
||
root = QHBoxLayout(self)
|
||
root.setSpacing(14)
|
||
root.setContentsMargins(20, 18, 20, 16)
|
||
left = QVBoxLayout()
|
||
left.setSpacing(6)
|
||
left.addStretch()
|
||
logo = QLabel()
|
||
logo.setPixmap(services.load_icon().pixmap(80, 80))
|
||
logo.setFixedSize(80, 80)
|
||
logo.setAlignment(Qt.AlignCenter)
|
||
left.addWidget(logo, 0, Qt.AlignHCenter)
|
||
brand = QLabel("R.R.P.P.")
|
||
brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;")
|
||
brand.setAlignment(Qt.AlignCenter)
|
||
left.addWidget(brand, 0, Qt.AlignHCenter)
|
||
left.addStretch()
|
||
root.addLayout(left)
|
||
|
||
form = QVBoxLayout()
|
||
form.setSpacing(6)
|
||
self.ed_user = QLineEdit()
|
||
self.ed_user.setPlaceholderText("SSO 邮箱")
|
||
form.addWidget(self.ed_user)
|
||
self.ed_pass = QLineEdit()
|
||
self.ed_pass.setPlaceholderText("密码")
|
||
self.ed_pass.setEchoMode(QLineEdit.Password)
|
||
self.ed_pass.returnPressed.connect(self._submit)
|
||
form.addWidget(self.ed_pass)
|
||
credentials = services.load_auth()
|
||
if credentials:
|
||
self.ed_user.setText(credentials["username"])
|
||
self.ed_pass.setText(credentials["password"])
|
||
|
||
self.lb_err = QLabel("")
|
||
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
self.lb_err.setFixedHeight(14)
|
||
self.lb_err.hide()
|
||
form.addWidget(self.lb_err)
|
||
bottom = QHBoxLayout()
|
||
bottom.setSpacing(10)
|
||
hint = QLabel("登录以获取节点配置")
|
||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
|
||
bottom.addWidget(hint)
|
||
bottom.addStretch()
|
||
self.btn_cancel = QPushButton("跳过登录")
|
||
self.btn_cancel.setObjectName("btnCancel")
|
||
self.btn_cancel.setToolTip("可稍后在设置中登录,或添加第三方订阅")
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
bottom.addWidget(self.btn_cancel)
|
||
self.btn_ok = QPushButton("登录")
|
||
self.btn_ok.setObjectName("btnOk")
|
||
self.btn_ok.clicked.connect(self._submit)
|
||
bottom.addWidget(self.btn_ok)
|
||
form.addLayout(bottom)
|
||
box = QWidget()
|
||
box.setLayout(form)
|
||
root.addWidget(box, 1, Qt.AlignVCenter)
|
||
self._done.connect(self._on_done)
|
||
|
||
def _submit(self):
|
||
user = self.ed_user.text().strip()
|
||
password = self.ed_pass.text()
|
||
if not user or not password:
|
||
self.lb_err.setText("请输入用户名和密码")
|
||
self.lb_err.show()
|
||
return
|
||
self.btn_ok.setEnabled(False)
|
||
self.btn_cancel.setEnabled(False)
|
||
self.btn_ok.setText("登录中")
|
||
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
|
||
|
||
def _worker(self, user, password):
|
||
try:
|
||
url = str(self._services.get_nodes_url() or "").strip()
|
||
if not url:
|
||
raise RuntimeError("config.json 未配置 nodes_url")
|
||
data = self._services.fetch_nodes(url, user, password)
|
||
if not (isinstance(data, list) and data and all(isinstance(node, dict) for node in data)
|
||
and str(data[0].get("domain") or "").strip()):
|
||
raise ValueError("返回的节点数据无效")
|
||
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||
directory = self._services.user_dir()
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
(directory / "nodes.json").write_bytes(self._services.encrypt_nodes(text))
|
||
self._services.save_auth(user, password)
|
||
self._done.emit(True, str(len(data)))
|
||
except self._services.auth_error as error:
|
||
self._done.emit(False, str(error))
|
||
except Exception as error:
|
||
self._done.emit(False, f"登录失败: {error}")
|
||
|
||
def _on_done(self, ok, info):
|
||
self.btn_ok.setEnabled(True)
|
||
self.btn_cancel.setEnabled(True)
|
||
self.btn_ok.setText("登录")
|
||
if ok:
|
||
self._services.log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)")
|
||
self.accept()
|
||
else:
|
||
self.lb_err.setText(info)
|
||
self.lb_err.show()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class WhitelistServices:
|
||
"""白名单编辑框所需的规则服务。"""
|
||
|
||
load_icon: object
|
||
load_rules: object
|
||
validate_rule: object
|
||
save_rules: object
|
||
|
||
|
||
class WhitelistDialog(QDialog):
|
||
"""编辑强制直连白名单;规则数据通过显式服务读写。"""
|
||
|
||
def __init__(self, services: WhitelistServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("代理白名单")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(380, 400)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QLineEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 6px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget::item {{ padding: 3px 6px; border-radius: 4px; }}
|
||
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||
QPushButton#btnCancel {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 4px 12px; font-size: 12px; }}
|
||
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 3px 10px; font-size: 11px; }}
|
||
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
""")
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(10)
|
||
layout.setContentsMargins(20, 16, 20, 16)
|
||
title = QLabel("代理白名单")
|
||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
hint = QLabel("命中以下规则的连接将强制直连(不经过代理)")
|
||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
hint.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(hint)
|
||
self.lb_err = QLabel("")
|
||
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
self.lb_err.setFixedHeight(14)
|
||
self.lb_err.hide()
|
||
layout.addWidget(self.lb_err)
|
||
self.list_widget = QListWidget()
|
||
self.list_widget.setToolTip("勾选启用规则;可多选后删除")
|
||
for rule in services.load_rules():
|
||
self._add_item(rule["value"], rule.get("enabled", True))
|
||
layout.addWidget(self.list_widget, 1)
|
||
add_row = QHBoxLayout()
|
||
add_row.setSpacing(8)
|
||
self.ed_add = QLineEdit()
|
||
self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com")
|
||
self.ed_add.returnPressed.connect(self._add_rule)
|
||
add_row.addWidget(self.ed_add, 1)
|
||
self.btn_add = QPushButton("添加")
|
||
self.btn_add.setObjectName("btnOk")
|
||
self.btn_add.setStyleSheet(f"""
|
||
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
|
||
padding: 6px 14px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton:hover {{ background-color: #5ed978; }}
|
||
""")
|
||
self.btn_add.clicked.connect(self._add_rule)
|
||
add_row.addWidget(self.btn_add)
|
||
layout.addLayout(add_row)
|
||
middle = QHBoxLayout()
|
||
self.btn_delete = QPushButton("删除选中")
|
||
self.btn_delete.setObjectName("btnSmall")
|
||
self.btn_delete.clicked.connect(self._delete_selected)
|
||
middle.addWidget(self.btn_delete)
|
||
middle.addStretch()
|
||
layout.addLayout(middle)
|
||
buttons = QHBoxLayout()
|
||
buttons.setSpacing(10)
|
||
buttons.addStretch()
|
||
self.btn_cancel = QPushButton("取消")
|
||
self.btn_cancel.setObjectName("btnCancel")
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
buttons.addWidget(self.btn_cancel)
|
||
self.btn_ok = QPushButton("保存")
|
||
self.btn_ok.setObjectName("btnOk")
|
||
self.btn_ok.clicked.connect(self._save)
|
||
buttons.addWidget(self.btn_ok)
|
||
layout.addLayout(buttons)
|
||
|
||
def _add_item(self, value, enabled):
|
||
item = QListWidgetItem(value)
|
||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||
item.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
|
||
self.list_widget.addItem(item)
|
||
|
||
def _add_rule(self):
|
||
value, error = self._services.validate_rule(self.ed_add.text())
|
||
if error:
|
||
self.lb_err.setText(error)
|
||
self.lb_err.show()
|
||
return
|
||
for index in range(self.list_widget.count()):
|
||
if self.list_widget.item(index).text().lower() == value.lower():
|
||
self.lb_err.setText("该规则已存在")
|
||
self.lb_err.show()
|
||
return
|
||
self.lb_err.hide()
|
||
self._add_item(value, True)
|
||
self.ed_add.clear()
|
||
|
||
def _delete_selected(self):
|
||
for item in self.list_widget.selectedItems():
|
||
self.list_widget.takeItem(self.list_widget.row(item))
|
||
|
||
def _save(self):
|
||
rules = []
|
||
for index in range(self.list_widget.count()):
|
||
item = self.list_widget.item(index)
|
||
value = str(item.text()).strip()
|
||
if value:
|
||
rules.append({"value": value, "enabled": item.checkState() == Qt.Checked})
|
||
self._services.save_rules(rules)
|
||
self.accept()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FixedRouteServices:
|
||
"""域名路由编辑框所需的规则与节点服务。"""
|
||
|
||
load_icon: object
|
||
load_rules: object
|
||
available_nodes: object
|
||
validate_domain: object
|
||
save_rules: object
|
||
|
||
|
||
class FixedRouteDialog(QDialog):
|
||
"""为域名配置强制直连或指定节点代理。"""
|
||
|
||
def __init__(self, services: FixedRouteServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("域名路由")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(620, 530)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QLineEdit, QComboBox {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 5px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget::item {{ padding: 5px 7px; border-radius: 4px; }}
|
||
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
|
||
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
""")
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(9)
|
||
layout.setContentsMargins(18, 14, 18, 14)
|
||
|
||
title = QLabel("域名路由")
|
||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
hint = QLabel("example.com 匹配根域及子域;*.example.com 仅匹配子域")
|
||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
hint.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(hint)
|
||
|
||
self.lb_err = QLabel("")
|
||
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
self.lb_err.setFixedHeight(16)
|
||
self.lb_err.hide()
|
||
layout.addWidget(self.lb_err)
|
||
|
||
self.list_widget = QListWidget()
|
||
self.list_widget.setToolTip("勾选启用规则;选中后可删除")
|
||
layout.addWidget(self.list_widget, 1)
|
||
|
||
domain_row = QHBoxLayout()
|
||
domain_row.setSpacing(7)
|
||
self.ed_domain = QLineEdit()
|
||
self.ed_domain.setPlaceholderText("域名,如 example.com 或 *.example.com")
|
||
self.ed_domain.returnPressed.connect(self._add_rule)
|
||
domain_row.addWidget(self.ed_domain, 1)
|
||
self.cb_action = QComboBox()
|
||
self.cb_action.addItem("强制不代理", "direct")
|
||
self.cb_action.addItem("强制节点代理", "node")
|
||
self.cb_action.currentIndexChanged.connect(self._on_action_changed)
|
||
domain_row.addWidget(self.cb_action)
|
||
self.cb_node = QComboBox()
|
||
self.cb_node.setMinimumWidth(190)
|
||
domain_row.addWidget(self.cb_node)
|
||
self.btn_add = QPushButton("添加")
|
||
self.btn_add.setObjectName("btnOk")
|
||
self.btn_add.clicked.connect(self._add_rule)
|
||
domain_row.addWidget(self.btn_add)
|
||
layout.addLayout(domain_row)
|
||
|
||
middle = QHBoxLayout()
|
||
self.btn_delete = QPushButton("删除选中")
|
||
self.btn_delete.setObjectName("btnSmall")
|
||
self.btn_delete.clicked.connect(self._delete_selected)
|
||
middle.addWidget(self.btn_delete)
|
||
middle.addStretch()
|
||
layout.addLayout(middle)
|
||
|
||
buttons = QHBoxLayout()
|
||
buttons.addStretch()
|
||
self.btn_cancel = QPushButton("取消")
|
||
self.btn_cancel.setObjectName("btnSmall")
|
||
self.btn_cancel.clicked.connect(self.reject)
|
||
buttons.addWidget(self.btn_cancel)
|
||
self.btn_save = QPushButton("保存")
|
||
self.btn_save.setObjectName("btnOk")
|
||
self.btn_save.clicked.connect(self._save)
|
||
buttons.addWidget(self.btn_save)
|
||
layout.addLayout(buttons)
|
||
|
||
self._reload_nodes()
|
||
for rule in services.load_rules():
|
||
self._add_item(rule)
|
||
self._on_action_changed()
|
||
|
||
def _reload_nodes(self):
|
||
self.cb_node.clear()
|
||
for node in self._services.available_nodes():
|
||
self.cb_node.addItem(node["label"], node["ref"])
|
||
if not self.cb_node.count():
|
||
self.cb_node.addItem("暂无可用节点", None)
|
||
|
||
def _on_action_changed(self, *_):
|
||
self.cb_node.setEnabled(self.cb_action.currentData() == "node")
|
||
|
||
def _add_item(self, rule):
|
||
domain = str(rule.get("domain") or rule.get("value") or "").strip()
|
||
if not domain:
|
||
return
|
||
action = str(rule.get("action") or "direct")
|
||
node_label = str(rule.get("node_label") or "节点不可用")
|
||
target = "强制不代理" if action == "direct" else f"节点代理:{node_label}"
|
||
item = QListWidgetItem(f"{domain} → {target}")
|
||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||
item.setCheckState(Qt.Checked if rule.get("enabled", True) else Qt.Unchecked)
|
||
item.setData(Qt.UserRole, {
|
||
"domain": domain,
|
||
"action": action,
|
||
"node_ref": str(rule.get("node_ref") or ""),
|
||
})
|
||
self.list_widget.addItem(item)
|
||
|
||
def _add_rule(self):
|
||
domain, error = self._services.validate_domain(self.ed_domain.text())
|
||
if error:
|
||
self.lb_err.setText(error)
|
||
self.lb_err.show()
|
||
return
|
||
action = self.cb_action.currentData()
|
||
node_ref = self.cb_node.currentData() if action == "node" else ""
|
||
if action == "node" and not node_ref:
|
||
self.lb_err.setText("请先配置至少一个可用节点")
|
||
self.lb_err.show()
|
||
return
|
||
for index in range(self.list_widget.count()):
|
||
existing = self.list_widget.item(index).data(Qt.UserRole) or {}
|
||
if str(existing.get("domain") or "").lower() == domain.lower():
|
||
self.lb_err.setText("该域名规则已存在")
|
||
self.lb_err.show()
|
||
return
|
||
self.lb_err.hide()
|
||
self._add_item({
|
||
"domain": domain,
|
||
"action": action,
|
||
"node_ref": node_ref,
|
||
"node_label": self.cb_node.currentText() if action == "node" else "",
|
||
"enabled": True,
|
||
})
|
||
self.ed_domain.clear()
|
||
|
||
def _delete_selected(self):
|
||
for item in self.list_widget.selectedItems():
|
||
self.list_widget.takeItem(self.list_widget.row(item))
|
||
|
||
def _save(self):
|
||
rules = []
|
||
for index in range(self.list_widget.count()):
|
||
item = self.list_widget.item(index)
|
||
rule = dict(item.data(Qt.UserRole) or {})
|
||
rule["enabled"] = item.checkState() == Qt.Checked
|
||
rules.append(rule)
|
||
self._services.save_rules(rules)
|
||
self.accept()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class JumpServices:
|
||
"""代理跳板管理所需的节点摘要与配置回调。"""
|
||
|
||
load_icon: object
|
||
load_profiles: object
|
||
available_nodes: object
|
||
add_profile: object
|
||
remove_profile: object
|
||
|
||
|
||
class JumpDialog(QDialog):
|
||
"""配置最多三跳的节点链路;设置中只保存节点稳定标识。"""
|
||
|
||
def __init__(self, services: JumpServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("代理跳板")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(560, 520)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget::item {{ padding: 5px 7px; border-radius: 4px; }}
|
||
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||
QPushButton#btnDanger {{ background-color: {C_RED}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
|
||
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
""")
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(8)
|
||
layout.setContentsMargins(18, 14, 18, 14)
|
||
|
||
title = QLabel("代理跳板")
|
||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
hint = QLabel("按顺序加入节点,至少 2 个、最多 3 个;最后一个节点是出口")
|
||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
hint.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(hint)
|
||
|
||
saved_label = QLabel("已保存跳板")
|
||
saved_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
layout.addWidget(saved_label)
|
||
self.profiles = QListWidget()
|
||
self.profiles.setFixedHeight(68)
|
||
self.profiles.setToolTip("选择已保存跳板后可删除;新链路在下方配置")
|
||
layout.addWidget(self.profiles)
|
||
|
||
columns = QHBoxLayout()
|
||
columns.setSpacing(8)
|
||
left = QVBoxLayout()
|
||
left.addWidget(QLabel("当前可用节点"))
|
||
self.available = QListWidget()
|
||
self.available.setToolTip("选择节点后点击“加入链路”")
|
||
left.addWidget(self.available, 1)
|
||
columns.addLayout(left, 1)
|
||
|
||
middle = QVBoxLayout()
|
||
middle.addStretch()
|
||
self.btn_add_node = QPushButton("加入链路 →")
|
||
self.btn_add_node.setObjectName("btnSmall")
|
||
self.btn_add_node.clicked.connect(self._add_node)
|
||
middle.addWidget(self.btn_add_node)
|
||
self.btn_remove_node = QPushButton("← 移除")
|
||
self.btn_remove_node.setObjectName("btnSmall")
|
||
self.btn_remove_node.clicked.connect(self._remove_node)
|
||
middle.addWidget(self.btn_remove_node)
|
||
middle.addStretch()
|
||
columns.addLayout(middle)
|
||
|
||
right = QVBoxLayout()
|
||
right.addWidget(QLabel("新跳板链路"))
|
||
self.route = QListWidget()
|
||
self.route.setToolTip("顺序即连接顺序;可用上下按钮调整")
|
||
right.addWidget(self.route, 1)
|
||
reorder = QHBoxLayout()
|
||
self.btn_up = QPushButton("上移")
|
||
self.btn_up.setObjectName("btnSmall")
|
||
self.btn_up.clicked.connect(lambda: self._move_node(-1))
|
||
reorder.addWidget(self.btn_up)
|
||
self.btn_down = QPushButton("下移")
|
||
self.btn_down.setObjectName("btnSmall")
|
||
self.btn_down.clicked.connect(lambda: self._move_node(1))
|
||
reorder.addWidget(self.btn_down)
|
||
right.addLayout(reorder)
|
||
columns.addLayout(right, 1)
|
||
layout.addLayout(columns, 1)
|
||
|
||
self.lb_status = QLabel("")
|
||
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
self.lb_status.setFixedHeight(16)
|
||
layout.addWidget(self.lb_status)
|
||
|
||
actions = QHBoxLayout()
|
||
self.btn_add_profile = QPushButton("添加")
|
||
self.btn_add_profile.setObjectName("btnOk")
|
||
self.btn_add_profile.clicked.connect(self._add_profile)
|
||
actions.addWidget(self.btn_add_profile)
|
||
self.btn_delete_profile = QPushButton("删除选中跳板")
|
||
self.btn_delete_profile.setObjectName("btnDanger")
|
||
self.btn_delete_profile.clicked.connect(self._delete_profile)
|
||
actions.addWidget(self.btn_delete_profile)
|
||
actions.addStretch()
|
||
self.btn_close = QPushButton("关闭")
|
||
self.btn_close.setObjectName("btnSmall")
|
||
self.btn_close.clicked.connect(self.accept)
|
||
actions.addWidget(self.btn_close)
|
||
layout.addLayout(actions)
|
||
|
||
self._reload()
|
||
|
||
def _reload(self):
|
||
self.available.clear()
|
||
for node in self._services.available_nodes():
|
||
item = QListWidgetItem(node["label"])
|
||
item.setData(Qt.UserRole, node["ref"])
|
||
self.available.addItem(item)
|
||
self.profiles.clear()
|
||
for profile in self._services.load_profiles():
|
||
state = "可用" if profile.get("available") else "节点不可用"
|
||
item = QListWidgetItem(f"{profile['name']} · {profile['route']} · {state}")
|
||
item.setData(Qt.UserRole, profile["id"])
|
||
self.profiles.addItem(item)
|
||
self.route.clear()
|
||
self.lb_status.setText("")
|
||
|
||
def _add_node(self):
|
||
item = self.available.currentItem()
|
||
if item is None:
|
||
self._status("请先选择一个节点", error=True)
|
||
return
|
||
ref = item.data(Qt.UserRole)
|
||
refs = {self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())}
|
||
if ref in refs:
|
||
self._status("同一条链路不能重复使用节点", error=True)
|
||
return
|
||
if self.route.count() >= 3:
|
||
self._status("最多只能配置 3 个节点", error=True)
|
||
return
|
||
copy = QListWidgetItem(item.text())
|
||
copy.setData(Qt.UserRole, ref)
|
||
self.route.addItem(copy)
|
||
self._status(f"当前链路:{self.route.count()} 个节点")
|
||
|
||
def _remove_node(self):
|
||
item = self.route.currentItem()
|
||
if item is not None:
|
||
self.route.takeItem(self.route.row(item))
|
||
|
||
def _move_node(self, offset):
|
||
row = self.route.currentRow()
|
||
new_row = row + offset
|
||
if row < 0 or not 0 <= new_row < self.route.count():
|
||
return
|
||
item = self.route.takeItem(row)
|
||
self.route.insertItem(new_row, item)
|
||
self.route.setCurrentRow(new_row)
|
||
|
||
def _add_profile(self):
|
||
if not 2 <= self.route.count() <= 3:
|
||
self._status("请先配置 2-3 个节点的链路", error=True)
|
||
return
|
||
refs = [self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())]
|
||
try:
|
||
self._services.add_profile(refs)
|
||
except Exception as error:
|
||
self._status(str(error), error=True)
|
||
return
|
||
self._reload()
|
||
self._status("跳板已添加")
|
||
|
||
def _delete_profile(self):
|
||
item = self.profiles.currentItem()
|
||
if item is None:
|
||
self._status("请先选择要删除的跳板", error=True)
|
||
return
|
||
try:
|
||
self._services.remove_profile(item.data(Qt.UserRole))
|
||
except Exception as error:
|
||
self._status(str(error), error=True)
|
||
return
|
||
self._reload()
|
||
self._status("跳板已删除")
|
||
|
||
def _status(self, message, error=False):
|
||
self.lb_status.setText(message)
|
||
color = C_RED if error else C_GREEN
|
||
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ThirdPartyServices:
|
||
"""第三方订阅管理框所需的显式业务服务。"""
|
||
|
||
load_icon: object
|
||
load_subscriptions: object
|
||
add_subscription: object
|
||
refresh_subscription: object
|
||
remove_subscription: object
|
||
|
||
|
||
class ThirdPartyDialog(QDialog):
|
||
"""管理多个第三方 RRPP Client JSON 订阅。"""
|
||
|
||
_done = pyqtSignal(str, bool, str)
|
||
|
||
def __init__(self, services: ThirdPartyServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self._busy = False
|
||
self.setWindowTitle("第三方订阅")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(520, 440)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QLineEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 5px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
|
||
QListWidget::item {{ padding: 5px 7px; border-radius: 4px; }}
|
||
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||
QPushButton#btnDanger {{ background-color: {C_RED}; color: #fff; border: none;
|
||
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
|
||
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
""")
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(9)
|
||
layout.setContentsMargins(20, 16, 20, 16)
|
||
title = QLabel("第三方订阅")
|
||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
hint = QLabel("仅接受 RRPP Client 的 JSON 节点数组;订阅地址和节点凭据会加密保存在本机")
|
||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
|
||
hint.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(hint)
|
||
self.list_widget = QListWidget()
|
||
self.list_widget.setToolTip("选择一个订阅后可立即更新或删除")
|
||
layout.addWidget(self.list_widget, 1)
|
||
self.ed_name = QLineEdit()
|
||
self.ed_name.setPlaceholderText("订阅名称,例如:我的备用节点")
|
||
layout.addWidget(self.ed_name)
|
||
self.ed_url = QLineEdit()
|
||
self.ed_url.setPlaceholderText("订阅链接(http:// 或 https://)")
|
||
self.ed_url.returnPressed.connect(self._add)
|
||
layout.addWidget(self.ed_url)
|
||
self.lb_status = QLabel("")
|
||
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
self.lb_status.setFixedHeight(16)
|
||
layout.addWidget(self.lb_status)
|
||
actions = QHBoxLayout()
|
||
self.btn_add = QPushButton("添加")
|
||
self.btn_add.setObjectName("btnOk")
|
||
self.btn_add.clicked.connect(self._add)
|
||
actions.addWidget(self.btn_add)
|
||
self.btn_refresh = QPushButton("立即更新")
|
||
self.btn_refresh.setObjectName("btnSmall")
|
||
self.btn_refresh.clicked.connect(self._refresh)
|
||
actions.addWidget(self.btn_refresh)
|
||
self.btn_delete = QPushButton("删除")
|
||
self.btn_delete.setObjectName("btnDanger")
|
||
self.btn_delete.clicked.connect(self._delete)
|
||
actions.addWidget(self.btn_delete)
|
||
actions.addStretch()
|
||
self.btn_close = QPushButton("关闭")
|
||
self.btn_close.setObjectName("btnSmall")
|
||
self.btn_close.clicked.connect(self.accept)
|
||
actions.addWidget(self.btn_close)
|
||
layout.addLayout(actions)
|
||
self._done.connect(self._on_done)
|
||
self._reload_list()
|
||
|
||
def _reload_list(self, selected_id=None):
|
||
self.list_widget.clear()
|
||
for item_data in self._services.load_subscriptions():
|
||
text = (f"{item_data['name']} · {item_data['node_count']} 个节点"
|
||
f" · {item_data.get('updated_at') or '未更新'}\n{item_data['url']}")
|
||
if item_data.get("last_error"):
|
||
text += f" · 上次更新失败:{item_data['last_error']}"
|
||
item = QListWidgetItem(text)
|
||
item.setData(Qt.UserRole, item_data["id"])
|
||
self.list_widget.addItem(item)
|
||
if selected_id and item_data["id"] == selected_id:
|
||
self.list_widget.setCurrentItem(item)
|
||
|
||
def _set_busy(self, busy, message=""):
|
||
self._busy = busy
|
||
for button in (self.btn_add, self.btn_refresh, self.btn_delete, self.btn_close):
|
||
button.setEnabled(not busy)
|
||
self.ed_name.setEnabled(not busy)
|
||
self.ed_url.setEnabled(not busy)
|
||
if message:
|
||
self.lb_status.setText(message)
|
||
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
|
||
def _add(self):
|
||
if self._busy:
|
||
return
|
||
name, url = self.ed_name.text().strip(), self.ed_url.text().strip()
|
||
if not name or not url:
|
||
self.lb_status.setText("请填写订阅名称和链接")
|
||
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
return
|
||
self._set_busy(True, "正在下载并校验订阅...")
|
||
threading.Thread(target=self._worker, args=("add", name, url), daemon=True).start()
|
||
|
||
def _selected_id(self):
|
||
item = self.list_widget.currentItem()
|
||
return item.data(Qt.UserRole) if item else None
|
||
|
||
def _refresh(self):
|
||
subscription_id = self._selected_id()
|
||
if not subscription_id or self._busy:
|
||
self.lb_status.setText("请先选择一个订阅")
|
||
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
return
|
||
self._set_busy(True, "正在更新订阅...")
|
||
threading.Thread(target=self._worker, args=("refresh", subscription_id, ""), daemon=True).start()
|
||
|
||
def _delete(self):
|
||
subscription_id = self._selected_id()
|
||
if not subscription_id or self._busy:
|
||
self.lb_status.setText("请先选择一个订阅")
|
||
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
return
|
||
self._services.remove_subscription(subscription_id)
|
||
self._reload_list()
|
||
self.lb_status.setText("已删除订阅")
|
||
self.lb_status.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||
|
||
def _worker(self, action, first, second):
|
||
try:
|
||
if action == "add":
|
||
self._services.add_subscription(first, second)
|
||
self._done.emit(action, True, "订阅已添加并导入")
|
||
else:
|
||
self._services.refresh_subscription(first)
|
||
self._done.emit(action, True, "订阅已更新")
|
||
except Exception as error:
|
||
self._done.emit(action, False, str(error))
|
||
|
||
def _on_done(self, action, ok, message):
|
||
self._set_busy(False)
|
||
if ok:
|
||
if action == "add":
|
||
self.ed_name.clear()
|
||
self.ed_url.clear()
|
||
self._reload_list(self._selected_id())
|
||
self.lb_status.setText(message)
|
||
color = C_GREEN if ok else C_RED
|
||
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LogServices:
|
||
"""运行日志框所需的显式业务依赖。"""
|
||
|
||
load_icon: object
|
||
get_logs: object
|
||
|
||
|
||
class LogDialog(QDialog):
|
||
"""显示带时间戳的运行日志;窗口打开时会持续读取最新日志。"""
|
||
|
||
def __init__(self, services: LogServices, parent=None):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("运行日志")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.resize(760, 500)
|
||
self.setMinimumSize(560, 320)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_SUBTEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; }}
|
||
QTextEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 6px 8px; color: {C_TEXT}; font-family: Consolas, "Microsoft YaHei", monospace; font-size: 11px; }}
|
||
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
|
||
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
|
||
""")
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(8)
|
||
layout.setContentsMargins(14, 12, 14, 12)
|
||
hint = QLabel("显示当前运行期间的最近日志,认证信息不会写入日志。")
|
||
layout.addWidget(hint)
|
||
|
||
self.text = QTextEdit()
|
||
self.text.setReadOnly(True)
|
||
self.text.setLineWrapMode(QTextEdit.NoWrap)
|
||
layout.addWidget(self.text, 1)
|
||
|
||
buttons = QHBoxLayout()
|
||
buttons.setSpacing(8)
|
||
buttons.addStretch()
|
||
copy = QPushButton("复制")
|
||
copy.clicked.connect(self._copy_logs)
|
||
buttons.addWidget(copy)
|
||
export = QPushButton("导出")
|
||
export.clicked.connect(self._export_logs)
|
||
buttons.addWidget(export)
|
||
close = QPushButton("关闭")
|
||
close.clicked.connect(self.accept)
|
||
buttons.addWidget(close)
|
||
layout.addLayout(buttons)
|
||
|
||
self._last_text = None
|
||
self._refresh()
|
||
self._timer = QTimer(self)
|
||
self._timer.timeout.connect(self._refresh)
|
||
self._timer.start(500)
|
||
|
||
def _log_text(self):
|
||
text = self.text.toPlainText()
|
||
return "" if text == "暂无日志" else text
|
||
|
||
def _copy_logs(self):
|
||
QApplication.clipboard().setText(self._log_text())
|
||
|
||
def _export_logs(self):
|
||
default_name = f"rrpp-log-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "导出运行日志", default_name, "文本文件 (*.txt);;所有文件 (*.*)"
|
||
)
|
||
if not path:
|
||
return
|
||
with open(path, "w", encoding="utf-8", newline="\n") as file:
|
||
file.write(self._log_text())
|
||
|
||
def _refresh(self):
|
||
text = "\n".join(self._services.get_logs()) or "暂无日志"
|
||
if text == self._last_text:
|
||
return
|
||
scrollbar = self.text.verticalScrollBar()
|
||
follow_tail = scrollbar.value() >= scrollbar.maximum() - 4
|
||
self.text.setPlainText(text)
|
||
self._last_text = text
|
||
if follow_tail:
|
||
scrollbar.setValue(scrollbar.maximum())
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SettingsServices:
|
||
"""设置框所需的配置与业务回调。"""
|
||
|
||
load_icon: object
|
||
version: str
|
||
get_port: object
|
||
get_system_pac: object
|
||
get_dns: object
|
||
get_update_via_proxy: object
|
||
get_skip_update_check: object
|
||
get_node_display_mode: object
|
||
get_close_behavior: object
|
||
parse_port: object
|
||
set_port: object
|
||
set_system_pac: object
|
||
set_dns: object
|
||
set_update_via_proxy: object
|
||
set_skip_update_check: object
|
||
set_node_display_mode: object
|
||
set_close_behavior: object
|
||
reset_user_config: object
|
||
open_whitelist: object
|
||
open_jump: object
|
||
open_third_party: object
|
||
open_logs: object
|
||
has_login: object
|
||
open_login: object
|
||
logout: object
|
||
|
||
|
||
class SettingsDialog(QDialog):
|
||
"""代理端口、更新方式、域名路由与登录状态设置框。"""
|
||
|
||
def __init__(self, services: SettingsServices, parent=None, port_locked=False):
|
||
super().__init__(parent)
|
||
self._services = services
|
||
self.setWindowTitle("设置")
|
||
self.setWindowIcon(services.load_icon())
|
||
self.setFixedSize(620, 460)
|
||
self.setStyleSheet(f"""
|
||
QDialog {{ background-color: {C_BG}; }}
|
||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||
QLineEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||
""")
|
||
layout = QVBoxLayout(self)
|
||
layout.setSpacing(7)
|
||
layout.setContentsMargins(20, 8, 20, 8)
|
||
title = QLabel("设置")
|
||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
|
||
version_row = QHBoxLayout()
|
||
version_row.addStretch()
|
||
version_label = QLabel(f"版本号: {services.version}")
|
||
version_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
version_row.addWidget(version_label)
|
||
version_row.addSpacing(4)
|
||
self.link_check_update = NodeLabel(
|
||
"检查更新", tooltip="手动检查更新",
|
||
base=f"color: {C_SUBTEXT}; font-size: 11px;",
|
||
hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;",
|
||
)
|
||
self.link_check_update.clicked.connect(self._manual_check_update)
|
||
version_row.addWidget(self.link_check_update)
|
||
version_row.addStretch()
|
||
layout.addLayout(version_row)
|
||
|
||
self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效")
|
||
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
self.lb_hint.setFixedHeight(14)
|
||
self.lb_hint.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(self.lb_hint)
|
||
layout.addSpacing(2)
|
||
|
||
columns = QHBoxLayout()
|
||
columns.setSpacing(24)
|
||
left_col = QVBoxLayout()
|
||
right_col = QVBoxLayout()
|
||
left_col.setSpacing(9)
|
||
right_col.setSpacing(9)
|
||
left_heading = QLabel("连接与分流")
|
||
right_heading = QLabel("节点与维护")
|
||
for heading in (left_heading, right_heading):
|
||
heading.setStyleSheet(f"color: {C_TITLE}; font-size: 12px; font-weight: bold;")
|
||
heading.setAlignment(Qt.AlignCenter)
|
||
left_col.addWidget(left_heading)
|
||
right_col.addWidget(right_heading)
|
||
columns.addLayout(left_col, 1)
|
||
columns.addLayout(right_col, 1)
|
||
layout.addLayout(columns, 1)
|
||
|
||
compact_button_style = f"""
|
||
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||
border-radius: 5px; padding: 1px 7px; min-width: 34px; min-height: 18px;
|
||
font-size: 10px; }}
|
||
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||
QPushButton:checked {{ background-color: {C_TITLE}; color: #ffffff; border-color: {C_TITLE}; }}
|
||
QPushButton:disabled {{ background-color: #e7eaee; color: #a8afb8; border-color: #d8dde3; }}
|
||
"""
|
||
|
||
whitelist_row = QHBoxLayout()
|
||
whitelist_label = QLabel("域名路由")
|
||
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
whitelist_label.setToolTip("为域名配置强制不代理,或强制经过指定节点;支持 *.example.com")
|
||
whitelist_row.addWidget(whitelist_label)
|
||
whitelist_row.addStretch()
|
||
self.btn_whitelist = QPushButton("配置")
|
||
self.btn_whitelist.setCursor(Qt.PointingHandCursor)
|
||
self.btn_whitelist.setStyleSheet(compact_button_style)
|
||
self.btn_whitelist.clicked.connect(self._open_whitelist)
|
||
whitelist_row.addWidget(self.btn_whitelist)
|
||
left_col.addLayout(whitelist_row)
|
||
|
||
jump_row = QHBoxLayout()
|
||
jump_label = QLabel("代理跳板")
|
||
jump_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
|
||
jump_row.addWidget(jump_label)
|
||
jump_row.addStretch()
|
||
self.btn_jump = QPushButton("配置")
|
||
self.btn_jump.setCursor(Qt.PointingHandCursor)
|
||
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
|
||
self.btn_jump.clicked.connect(self._open_jump)
|
||
jump_row.addWidget(self.btn_jump)
|
||
left_col.addLayout(jump_row)
|
||
|
||
third_party_row = QHBoxLayout()
|
||
third_party_label = QLabel("第三方订阅")
|
||
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
|
||
third_party_row.addWidget(third_party_label)
|
||
third_party_row.addStretch()
|
||
self.btn_third_party = QPushButton("配置")
|
||
self.btn_third_party.setCursor(Qt.PointingHandCursor)
|
||
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
|
||
self.btn_third_party.clicked.connect(self._open_third_party)
|
||
third_party_row.addWidget(self.btn_third_party)
|
||
right_col.addLayout(third_party_row)
|
||
|
||
log_row = QHBoxLayout()
|
||
log_label = QLabel("日志")
|
||
log_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
log_label.setToolTip("查看连接域名、规则匹配、路由、错误与流量等运行信息")
|
||
log_row.addWidget(log_label)
|
||
log_row.addStretch()
|
||
self.btn_logs = QPushButton("查看")
|
||
self.btn_logs.setCursor(Qt.PointingHandCursor)
|
||
self.btn_logs.setStyleSheet(self.btn_whitelist.styleSheet())
|
||
self.btn_logs.clicked.connect(self._open_logs)
|
||
log_row.addWidget(self.btn_logs)
|
||
port_row = QHBoxLayout()
|
||
port_label = QLabel("代理转发端口")
|
||
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
port_row.addWidget(port_label)
|
||
port_row.addStretch()
|
||
self.ed_port = QLineEdit(str(services.get_port()))
|
||
self.ed_port.setFixedSize(54, 22)
|
||
self.ed_port.setEnabled(not port_locked)
|
||
port_row.addWidget(self.ed_port)
|
||
left_col.addLayout(port_row)
|
||
|
||
system_pac_row = QHBoxLayout()
|
||
builtin_pac_label = QLabel("使用系统 PAC (系统代理)")
|
||
builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
builtin_pac_label.setToolTip(
|
||
"仅系统代理模式使用;关闭时使用内置 PAC 智能分流"
|
||
)
|
||
system_pac_row.addWidget(builtin_pac_label)
|
||
system_pac_row.addStretch()
|
||
self.cb_system_pac = ToggleSwitch(checked=services.get_system_pac())
|
||
self.cb_system_pac.setToolTip("系统代理且已选 PAC 规则模式时,向 Windows 写入本地 PAC 地址;TUN 模式忽略此项")
|
||
system_pac_row.addWidget(self.cb_system_pac)
|
||
left_col.addLayout(system_pac_row)
|
||
|
||
dns_row = QHBoxLayout()
|
||
dns_label = QLabel("自定义 DNS (虚拟网卡)")
|
||
dns_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
dns_label.setToolTip("TUN 模式使用;支持 IPv4/IPv6,请填写 1-3 个 DNS 地址")
|
||
dns_row.addWidget(dns_label)
|
||
dns_row.addStretch()
|
||
self.ed_dns = QLineEdit(", ".join(services.get_dns()))
|
||
self.ed_dns.setPlaceholderText("8.8.8.8, 1.1.1.1")
|
||
self.ed_dns.setFixedSize(120, 22)
|
||
self.ed_dns.setEnabled(not port_locked)
|
||
dns_row.addWidget(self.ed_dns)
|
||
left_col.addLayout(dns_row)
|
||
|
||
update_row = QHBoxLayout()
|
||
update_label = QLabel("使用代理更新")
|
||
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
update_row.addWidget(update_label)
|
||
update_row.addStretch()
|
||
self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy())
|
||
self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
|
||
update_row.addWidget(self.cb_update_proxy)
|
||
right_col.addLayout(update_row)
|
||
|
||
skip_row = QHBoxLayout()
|
||
skip_label = QLabel("跳过检查更新")
|
||
skip_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
skip_row.addWidget(skip_label)
|
||
skip_row.addStretch()
|
||
self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check())
|
||
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
|
||
skip_row.addWidget(self.cb_skip_update)
|
||
right_col.addLayout(skip_row)
|
||
|
||
node_display_row = QHBoxLayout()
|
||
node_display_label = QLabel("节点信息显示")
|
||
node_display_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
node_display_label.setToolTip("控制主页面节点副标题显示域名还是节点备注")
|
||
node_display_row.addWidget(node_display_label)
|
||
node_display_row.addStretch()
|
||
self.btn_node_domain = QPushButton("域名")
|
||
self.btn_node_remark = QPushButton("备注")
|
||
for button in (self.btn_node_domain, self.btn_node_remark):
|
||
button.setCheckable(True)
|
||
button.setCursor(Qt.PointingHandCursor)
|
||
button.setStyleSheet(compact_button_style)
|
||
node_display_row.addWidget(button)
|
||
self.node_display_group = QButtonGroup(self)
|
||
self.node_display_group.setExclusive(True)
|
||
self.node_display_group.addButton(self.btn_node_domain)
|
||
self.node_display_group.addButton(self.btn_node_remark)
|
||
if services.get_node_display_mode() == "remark":
|
||
self.btn_node_remark.setChecked(True)
|
||
else:
|
||
self.btn_node_domain.setChecked(True)
|
||
right_col.addLayout(node_display_row)
|
||
|
||
close_behavior_row = QHBoxLayout()
|
||
close_behavior_label = QLabel("默认关闭行为")
|
||
close_behavior_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
close_behavior_label.setToolTip("点击主窗口关闭按钮时退出程序,或隐藏到系统托盘")
|
||
close_behavior_row.addWidget(close_behavior_label)
|
||
close_behavior_row.addStretch()
|
||
self.btn_close_exit = QPushButton("退出")
|
||
self.btn_close_tray = QPushButton("托盘")
|
||
for button in (self.btn_close_exit, self.btn_close_tray):
|
||
button.setCheckable(True)
|
||
button.setCursor(Qt.PointingHandCursor)
|
||
button.setStyleSheet(compact_button_style)
|
||
close_behavior_row.addWidget(button)
|
||
self.close_behavior_group = QButtonGroup(self)
|
||
self.close_behavior_group.setExclusive(True)
|
||
self.close_behavior_group.addButton(self.btn_close_exit)
|
||
self.close_behavior_group.addButton(self.btn_close_tray)
|
||
if services.get_close_behavior() == "tray":
|
||
self.btn_close_tray.setChecked(True)
|
||
else:
|
||
self.btn_close_exit.setChecked(True)
|
||
right_col.addLayout(close_behavior_row)
|
||
|
||
reset_row = QHBoxLayout()
|
||
reset_label = QLabel("重置用户配置")
|
||
reset_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||
reset_label.setToolTip("重置本地偏好、域名路由、跳板和当前节点选择;不会删除登录和订阅")
|
||
reset_row.addWidget(reset_label)
|
||
reset_row.addStretch()
|
||
self.btn_reset = QPushButton("重置")
|
||
self.btn_reset.setCursor(Qt.PointingHandCursor)
|
||
self.btn_reset.setStyleSheet(compact_button_style)
|
||
self.btn_reset.setEnabled(not port_locked)
|
||
self.btn_reset.clicked.connect(self._reset_user_config)
|
||
reset_row.addWidget(self.btn_reset)
|
||
right_col.addLayout(reset_row)
|
||
|
||
right_col.addLayout(log_row)
|
||
left_col.addStretch()
|
||
right_col.addStretch()
|
||
|
||
buttons = QHBoxLayout()
|
||
buttons.setSpacing(10)
|
||
buttons.addStretch()
|
||
self.btn_save = QPushButton("保存更改")
|
||
self.btn_save.setStyleSheet(f"""
|
||
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
|
||
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
||
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
|
||
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
|
||
""")
|
||
self.btn_save.setEnabled(not port_locked)
|
||
self.btn_save.clicked.connect(self._save_settings)
|
||
buttons.addWidget(self.btn_save)
|
||
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
|
||
self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
|
||
self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
|
||
self.btn_logout.clicked.connect(self._logout)
|
||
buttons.addWidget(self.btn_logout)
|
||
buttons.addStretch()
|
||
layout.addLayout(buttons)
|
||
|
||
def _manual_check_update(self):
|
||
parent = self.parent()
|
||
if not (hasattr(parent, "_check_update") and hasattr(parent, "_update_done")):
|
||
return
|
||
self.lb_hint.setText("正在检查更新...")
|
||
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
try:
|
||
parent._update_done.connect(self._on_check_done)
|
||
except TypeError:
|
||
pass
|
||
threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start()
|
||
|
||
def _on_check_done(self):
|
||
self.lb_hint.setText("检查完成,结果见主界面日志")
|
||
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||
try:
|
||
self.parent()._update_done.disconnect(self._on_check_done)
|
||
except Exception:
|
||
pass
|
||
|
||
def _open_whitelist(self):
|
||
self._services.open_whitelist(self)
|
||
|
||
def _open_jump(self):
|
||
self._services.open_jump(self)
|
||
|
||
def _open_third_party(self):
|
||
self._services.open_third_party(self)
|
||
|
||
def _open_logs(self):
|
||
self._services.open_logs(self)
|
||
|
||
def _reset_user_config(self):
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"重置用户配置",
|
||
"确定要恢复本地偏好、域名路由、跳板和当前节点选择的默认值吗?\n\n"
|
||
"登录凭据、节点缓存、第三方订阅和 PAC 数据不会被删除。",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.No,
|
||
)
|
||
if reply != QMessageBox.Yes:
|
||
return
|
||
self._services.reset_user_config()
|
||
self.ed_port.setText(str(self._services.get_port()))
|
||
self.cb_system_pac.setChecked(self._services.get_system_pac())
|
||
self.ed_dns.setText(", ".join(self._services.get_dns()))
|
||
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
|
||
self.cb_skip_update.setChecked(self._services.get_skip_update_check())
|
||
self.btn_node_domain.setChecked(True)
|
||
self.btn_close_exit.setChecked(True)
|
||
self.lb_hint.setText("用户配置已重置")
|
||
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||
|
||
def _save_settings(self):
|
||
port = self._services.parse_port(self.ed_port.text())
|
||
if port is None:
|
||
self.lb_hint.setText("端口应为 1-65535 的数字")
|
||
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
return
|
||
try:
|
||
self._services.set_dns(self.ed_dns.text())
|
||
except ValueError as exc:
|
||
self.lb_hint.setText(str(exc))
|
||
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||
return
|
||
self._services.set_port(port)
|
||
self._services.set_system_pac(bool(self.cb_system_pac.isChecked()))
|
||
self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
|
||
self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
|
||
self._services.set_node_display_mode(
|
||
"remark" if self.btn_node_remark.isChecked() else "domain"
|
||
)
|
||
self._services.set_close_behavior(
|
||
"tray" if self.btn_close_tray.isChecked() else "exit"
|
||
)
|
||
self.ed_port.setText(str(port))
|
||
self.lb_hint.setText("已保存,下次开启代理生效")
|
||
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||
|
||
def _logout(self):
|
||
if self._services.has_login():
|
||
self._services.logout()
|
||
self.btn_logout.setText("登录")
|
||
else:
|
||
self._services.open_login(self)
|
||
if self._services.has_login():
|
||
self.btn_logout.setText("退出登录")
|