Compare commits

...
3 changed files with 240 additions and 20 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "999909181618", "version": "999909181706",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest" "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
+121 -3
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -14,7 +15,8 @@ from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT
from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch
from rrpp_ui.dialogs import ( from rrpp_ui.dialogs import (
LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices, LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices,
ThirdPartyDialog, ThirdPartyServices,
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices, WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
JumpDialog, JumpServices, JumpDialog, JumpServices,
) )
@@ -933,17 +935,62 @@ def _stop_pac_server():
# ── 日志输出 ────────────────────────────────────────── # ── 日志输出 ──────────────────────────────────────────
# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit # GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit
_log_func = None _log_func = None
_LOG_HISTORY_LIMIT = 2000
_LOG_HISTORY = []
_LOG_HISTORY_LOCK = threading.RLock()
def _plain_log_message(msg: str) -> str:
"""去掉红色警告日志的 HTML 包装,供日志弹窗显示纯文本。"""
return re.sub(r"<[^>]+>", "", str(msg))
def _record_log(msg: str) -> str:
"""记录带毫秒时间戳的日志并返回弹窗使用的纯文本行。"""
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
line = f"[{timestamp}] {_plain_log_message(msg)}"
with _LOG_HISTORY_LOCK:
_LOG_HISTORY.append(line)
if len(_LOG_HISTORY) > _LOG_HISTORY_LIMIT:
del _LOG_HISTORY[:-_LOG_HISTORY_LIMIT]
return line
def _log_history() -> list[str]:
"""返回日志快照,避免日志弹窗读取时阻塞连接线程。"""
with _LOG_HISTORY_LOCK:
return list(_LOG_HISTORY)
def log(msg: str): def log(msg: str):
"""输出一行日志到 GUI 日志区。""" """输出一行日志到 GUI 日志区。"""
_record_log(msg)
if _log_func: if _log_func:
_log_func(msg) _log_func(msg)
def log_detail(msg: str):
"""仅写入详细日志历史,不打扰主界面的简要日志区。"""
_record_log(msg)
def log_red(msg: str): def log_red(msg: str):
"""输出一行红色警告日志到 GUI 日志区。""" """输出一行红色警告日志到 GUI 日志区。"""
_record_log(msg)
if _log_func: if _log_func:
_log_func(f'<span style="color:{C_RED};">{msg}</span>') _log_func(f'<span style="color:{C_RED};">{msg}</span>')
_CONNECTION_ID = 0
_CONNECTION_ID_LOCK = threading.Lock()
def _next_connection_id() -> int:
"""为每条转发连接分配单调递增的进程内编号。"""
global _CONNECTION_ID
with _CONNECTION_ID_LOCK:
_CONNECTION_ID += 1
return _CONNECTION_ID
# ── 通用工具函数 ────────────────────────────────────── # ── 通用工具函数 ──────────────────────────────────────
def _run(cmd, warn=True): def _run(cmd, warn=True):
@@ -2192,10 +2239,30 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
jump_chain=None): jump_chain=None):
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。""" """处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
import socket, select import socket, select
connection_id = _next_connection_id()
started_at = time.monotonic()
try:
peer = client.getpeername()
peer_text = f"{peer[0]}:{peer[1]}" if isinstance(peer, tuple) and len(peer) >= 2 else str(peer)
except Exception:
peer_text = "未知"
def _chain_detail(chain):
return "→".join(
f"{node.get('domain') or '?'}:{_node_http_port(node)}"
for node in (chain or [])
)
client.settimeout(2.0) client.settimeout(2.0)
buf = b"" buf = b""
is_http = None is_http = None
is_connect = False is_connect = False
first_line = ""
target = None
request_type = "未知请求"
target_text = "未解析"
rule_match = "否"
route_summary = f"上游节点 {_chain_detail(jump_chain) or f'{remote_host}:{remote_port}'}"
injector = None injector = None
direct_target = None direct_target = None
allow_direct_fallback = False allow_direct_fallback = False
@@ -2226,16 +2293,24 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if is_http and b"\r\n\r\n" in buf: if is_http and b"\r\n\r\n" in buf:
is_connect = buf.startswith(b"CONNECT ") is_connect = buf.startswith(b"CONNECT ")
first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace") first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace")
request_type = "CONNECT" if is_connect else (first_line.split(" ", 1)[0] or "HTTP")
if is_connect: if is_connect:
target = _parse_connect_authority(first_line) target = _parse_connect_authority(first_line)
else: else:
target = _parse_http_absolute(first_line) target = _parse_http_absolute(first_line)
if target: if target:
target_text = f"{target[0]}:{target[1]}"
log_detail(f"[连接 #{connection_id}] 收到请求: {request_type} {target_text},客户端={peer_text}")
forced_rule = _route_rule_for_host(target[0]) forced_rule = _route_rule_for_host(target[0])
if forced_rule and forced_rule.get("action") == "direct": if forced_rule and forced_rule.get("action") == "direct":
rule_match = f"是({forced_rule.get('domain') or target[0]},强制直连)"
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
# 显式强制不代理优先于 PAC 的域名表和 IP 判定。 # 显式强制不代理优先于 PAC 的域名表和 IP 判定。
direct_target = (target[0], target[1]) direct_target = (target[0], target[1])
route_summary = f"强制直连 {target_text}"
elif forced_rule and forced_rule.get("action") == "node": elif forced_rule and forced_rule.get("action") == "node":
rule_match = f"是({forced_rule.get('domain') or target[0]},强制节点)"
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
forced_node = _fixed_route_node(forced_rule) forced_node = _fixed_route_node(forced_rule)
if forced_node is None: if forced_node is None:
raise RuntimeError( raise RuntimeError(
@@ -2247,22 +2322,44 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
route_jump_chain = _jump_chain(forced_node) route_jump_chain = _jump_chain(forced_node)
# 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。 # 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。
direct_target = None direct_target = None
route_summary = (
f"强制多跳 {_chain_detail(route_jump_chain)}"
if route_jump_chain else
f"强制节点 {route_remote_host}:{route_remote_port}"
)
else: else:
log_detail(f"[连接 #{connection_id}] 规则匹配: 否(未命中自定义域名路由)")
direct_target = _direct_connect_target( direct_target = _direct_connect_target(
target[0], target[1], bypass_lan, smart) target[0], target[1], bypass_lan, smart)
allow_direct_fallback = (direct_target is not None and allow_direct_fallback = (direct_target is not None and
_is_smart_china_direct(target[0], bypass_lan, smart)) _is_smart_china_direct(target[0], bypass_lan, smart))
if direct_target:
policy = "PAC/智能分流直连" if allow_direct_fallback else "直连策略"
route_summary = f"{policy} {direct_target[0]}:{direct_target[1]}"
else:
route_summary = (
f"多跳上游 {_chain_detail(route_jump_chain)}"
if route_jump_chain else
f"上游节点 {route_remote_host}:{route_remote_port}"
)
log_detail(f"[连接 #{connection_id}] 路由决策: {route_summary},TLS={'是' if use_tls else '否'}")
else:
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
except Exception as e: except Exception as e:
log(f"[转发器] 读取首包失败: {e}") log(f"[转发器] 读取首包失败: {e}")
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
client.close() client.close()
return return
remote = None remote = None
up_bytes = 0
down_bytes = 0
try: try:
remote, via_tunnel = _connect_route( remote, via_tunnel = _connect_route(
direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls, direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls,
jump_chain=route_jump_chain) jump_chain=route_jump_chain)
count_traffic = via_tunnel count_traffic = via_tunnel
log_detail(f"[连接 #{connection_id}] 连接成功: 目标={target_text},路径={route_summary},承载={'隧道' if via_tunnel else '直连'}")
if not via_tunnel: if not via_tunnel:
if is_connect: if is_connect:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
@@ -2278,10 +2375,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
) )
if buf: if buf:
remote.sendall(buf) remote.sendall(buf)
up_bytes += len(buf)
if via_tunnel: if via_tunnel:
_traffic_add(up=len(buf)) _traffic_add(up=len(buf))
except Exception as e: except Exception as e:
log(f"[转发器] 连接目标失败: {e}") log(f"[转发器] 连接目标失败: {e}")
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
try: try:
client.close() client.close()
except Exception: except Exception:
@@ -2298,6 +2397,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
# CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时; # CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时;
# 明文 HTTP 连接空闲 120 秒回收 # 明文 HTTP 连接空闲 120 秒回收
idle_timeout = None if is_connect else 120 idle_timeout = None if is_connect else 120
close_reason = "对端关闭"
try: try:
while True: while True:
if isinstance(remote, _LayeredTlsTransport): if isinstance(remote, _LayeredTlsTransport):
@@ -2306,33 +2406,40 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if data is None: if data is None:
break break
if not data: if not data:
close_reason = "上游关闭"
return return
client.sendall(data) client.sendall(data)
down_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(down=len(data)) _traffic_add(down=len(data))
remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote
r, _, _ = select.select([client, remote_wait], [], [], idle_timeout) r, _, _ = select.select([client, remote_wait], [], [], idle_timeout)
if not r: if not r:
close_reason = f"空闲超时({idle_timeout} 秒)"
break break
if client in r: if client in r:
data = client.recv(65536) data = client.recv(65536)
if not data: if not data:
close_reason = "客户端关闭"
break break
if injector is not None: if injector is not None:
data = injector(data) data = injector(data)
if data: if data:
remote.sendall(data) remote.sendall(data)
up_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(up=len(data)) _traffic_add(up=len(data))
if remote_wait in r: if remote_wait in r:
data = remote.recv(65536) data = remote.recv(65536)
if not data: if not data:
close_reason = "上游关闭"
break break
client.sendall(data) client.sendall(data)
down_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(down=len(data)) _traffic_add(down=len(data))
except Exception: except Exception as e:
pass close_reason = f"转发异常: {e}"
finally: finally:
try: try:
client.close() client.close()
@@ -2342,6 +2449,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
remote.close() remote.close()
except Exception: except Exception:
pass pass
elapsed = time.monotonic() - started_at
log_detail(
f"[连接 #{connection_id}] 已关闭: 目标={target_text},规则匹配={rule_match},"
f"原因={close_reason},上行={_fmt_bytes(up_bytes)},下行={_fmt_bytes(down_bytes)},"
f"持续={elapsed:.3f} 秒"
)
def _tls_stop(): def _tls_stop():
"""停止 TLS 转发器监听线程。""" """停止 TLS 转发器监听线程。"""
global _tls_forwarder global _tls_forwarder
@@ -2478,6 +2591,10 @@ def _open_login_dialog(parent=None):
return False return False
def _log_services():
return LogServices(load_icon=load_icon, get_logs=_log_history)
def _logout_user_data(): def _logout_user_data():
"""清除官方登录与节点缓存;第三方订阅保持不变。""" """清除官方登录与节点缓存;第三方订阅保持不变。"""
global _NODES global _NODES
@@ -2504,6 +2621,7 @@ def _settings_services():
open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(), open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(),
open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(), open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(),
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(), open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
open_logs=lambda parent: LogDialog(_log_services(), parent).exec_(),
has_login=lambda: _auth_load() is not None, has_login=lambda: _auth_load() is not None,
open_login=_open_login_dialog, open_login=_open_login_dialog,
logout=_logout_user_data, logout=_logout_user_data,
+118 -16
View File
@@ -2,12 +2,13 @@
import json import json
import threading import threading
from datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QVBoxLayout, QWidget, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
) )
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
@@ -834,6 +835,94 @@ class ThirdPartyDialog(QDialog):
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;") 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) @dataclass(frozen=True)
class SettingsServices: class SettingsServices:
"""设置框所需的配置与业务回调。""" """设置框所需的配置与业务回调。"""
@@ -850,6 +939,7 @@ class SettingsServices:
open_whitelist: object open_whitelist: object
open_jump: object open_jump: object
open_third_party: object open_third_party: object
open_logs: object
has_login: object has_login: object
open_login: object open_login: object
logout: object logout: object
@@ -863,7 +953,7 @@ class SettingsDialog(QDialog):
self._services = services self._services = services
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon()) self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 430) self.setFixedSize(300, 350)
self.setStyleSheet(f""" self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }} QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
@@ -871,8 +961,8 @@ class SettingsDialog(QDialog):
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }} padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
""") """)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(12) layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 10, 20, 10)
title = QLabel("设置") title = QLabel("设置")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
@@ -924,7 +1014,7 @@ class SettingsDialog(QDialog):
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口") jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
jump_row.addWidget(jump_label) jump_row.addWidget(jump_label)
jump_row.addStretch() jump_row.addStretch()
self.btn_jump = QPushButton("添加") self.btn_jump = QPushButton("配置")
self.btn_jump.setCursor(Qt.PointingHandCursor) self.btn_jump.setCursor(Qt.PointingHandCursor)
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_jump.clicked.connect(self._open_jump) self.btn_jump.clicked.connect(self._open_jump)
@@ -932,18 +1022,29 @@ class SettingsDialog(QDialog):
layout.addLayout(jump_row) layout.addLayout(jump_row)
third_party_row = QHBoxLayout() third_party_row = QHBoxLayout()
third_party_label = QLabel("第三方") third_party_label = QLabel("第三方订阅")
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅") third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
third_party_row.addWidget(third_party_label) third_party_row.addWidget(third_party_label)
third_party_row.addStretch() third_party_row.addStretch()
self.btn_third_party = QPushButton("添加") self.btn_third_party = QPushButton("配置")
self.btn_third_party.setCursor(Qt.PointingHandCursor) self.btn_third_party.setCursor(Qt.PointingHandCursor)
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_third_party.clicked.connect(self._open_third_party) self.btn_third_party.clicked.connect(self._open_third_party)
third_party_row.addWidget(self.btn_third_party) third_party_row.addWidget(self.btn_third_party)
layout.addLayout(third_party_row) 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_row = QHBoxLayout()
port_label = QLabel("代理转发端口") port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -974,6 +1075,7 @@ class SettingsDialog(QDialog):
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update) skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row) layout.addLayout(skip_row)
layout.addLayout(log_row)
layout.addStretch() layout.addStretch()
buttons = QHBoxLayout() buttons = QHBoxLayout()
@@ -981,19 +1083,16 @@ class SettingsDialog(QDialog):
buttons.addStretch() buttons.addStretch()
self.btn_save = QPushButton("保存更改") self.btn_save = QPushButton("保存更改")
self.btn_save.setStyleSheet(f""" self.btn_save.setStyleSheet(f"""
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }} padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
""") """)
self.btn_save.setEnabled(not port_locked) self.btn_save.setEnabled(not port_locked)
self.btn_save.clicked.connect(self._save_settings) self.btn_save.clicked.connect(self._save_settings)
buttons.addWidget(self.btn_save) buttons.addWidget(self.btn_save)
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录") self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
self.btn_logout.setStyleSheet(f""" self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
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.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
self.btn_logout.clicked.connect(self._logout) self.btn_logout.clicked.connect(self._logout)
buttons.addWidget(self.btn_logout) buttons.addWidget(self.btn_logout)
@@ -1029,6 +1128,9 @@ class SettingsDialog(QDialog):
def _open_third_party(self): def _open_third_party(self):
self._services.open_third_party(self) self._services.open_third_party(self)
def _open_logs(self):
self._services.open_logs(self)
def _save_settings(self): def _save_settings(self):
port = self._services.parse_port(self.ed_port.text()) port = self._services.parse_port(self.ed_port.text())
if port is None: if port is None: