"""需要业务服务的 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()