"""需要业务服务的 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_builtin_pac: 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_builtin_pac: 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(300, 470) 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) 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) layout.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) layout.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) layout.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) layout.addLayout(port_row) builtin_pac_row = QHBoxLayout() builtin_pac_label = QLabel("使用内置 PAC") builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") builtin_pac_label.setToolTip( "系统使用固定的本地代理端口;转发器内部仍按 PAC 规则智能判断直连或代理" ) builtin_pac_row.addWidget(builtin_pac_label) builtin_pac_row.addStretch() self.cb_builtin_pac = ToggleSwitch(checked=services.get_builtin_pac()) self.cb_builtin_pac.setToolTip( "系统固定指向本地代理端口,转发器内部保留 PAC 智能分流" ) builtin_pac_row.addWidget(self.cb_builtin_pac) layout.addLayout(builtin_pac_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) 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) layout.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) layout.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) layout.addLayout(reset_row) layout.addLayout(log_row) layout.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_builtin_pac.setChecked(self._services.get_builtin_pac()) 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 self._services.set_port(port) self._services.set_builtin_pac(bool(self.cb_builtin_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("退出登录")