469 lines
20 KiB
Python
469 lines
20 KiB
Python
"""需要业务服务的 Qt 对话框。"""
|
|
|
|
import json
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
from PyQt5.QtCore import Qt, pyqtSignal
|
|
from PyQt5.QtWidgets import (
|
|
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
|
QPushButton, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
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.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 SettingsServices:
|
|
"""设置框所需的配置与业务回调。"""
|
|
|
|
load_icon: object
|
|
version: str
|
|
get_port: object
|
|
get_update_via_proxy: object
|
|
get_skip_update_check: object
|
|
parse_port: object
|
|
set_port: object
|
|
set_update_via_proxy: object
|
|
set_skip_update_check: object
|
|
open_whitelist: 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(300, 354)
|
|
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(12)
|
|
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)
|
|
|
|
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.addStretch()
|
|
|
|
whitelist_row = QHBoxLayout()
|
|
whitelist_label = QLabel("代理白名单")
|
|
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
|
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
|
|
whitelist_row.addWidget(whitelist_label)
|
|
whitelist_row.addStretch()
|
|
self.btn_whitelist = QPushButton("配置")
|
|
self.btn_whitelist.setCursor(Qt.PointingHandCursor)
|
|
self.btn_whitelist.setStyleSheet(f"""
|
|
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
|
border-radius: 6px; padding: 2px 10px; font-size: 11px; }}
|
|
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
|
""")
|
|
self.btn_whitelist.clicked.connect(self._open_whitelist)
|
|
whitelist_row.addWidget(self.btn_whitelist)
|
|
layout.addLayout(whitelist_row)
|
|
|
|
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)
|
|
layout.addLayout(port_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)
|
|
layout.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)
|
|
layout.addLayout(skip_row)
|
|
layout.addStretch()
|
|
|
|
buttons = QHBoxLayout()
|
|
buttons.setSpacing(10)
|
|
buttons.addStretch()
|
|
self.btn_save = QPushButton("保存更改")
|
|
self.btn_save.setStyleSheet(f"""
|
|
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
|
|
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
|
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
|
|
""")
|
|
self.btn_save.setEnabled(not port_locked)
|
|
self.btn_save.clicked.connect(self._save_settings)
|
|
buttons.addWidget(self.btn_save)
|
|
self.btn_logout = QPushButton("退出登录")
|
|
self.btn_logout.setStyleSheet(f"""
|
|
QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px;
|
|
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
|
QPushButton:hover {{ background-color: #ff8787; }}
|
|
""")
|
|
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 _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
|
|
self._services.set_port(port)
|
|
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.ed_port.setText(str(port))
|
|
self.lb_hint.setText("已保存,下次开启代理生效")
|
|
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
|
|
|
def _logout(self):
|
|
self._services.logout()
|
|
self.accept()
|