From 57092cad039c27a9f716143fc65fcc71f7c096da Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Jul 2026 23:00:50 +0800 Subject: [PATCH] feat: dark theme toggle + CPU/memory sparkline trend charts - ui/theme.py: light/dark QSS stylesheets, persisted to ~/.sshclient/settings.json - main_window.py: View menu with dark theme checkbox, applies on startup - ui/widgets.py: SparklineChart widget for mini trend visualization - MonitorPanel: CPU and memory sparkline charts showing last 60 samples - Charts cleared on stop/switch host --- ui/main_window.py | 20 ++++++++ ui/theme.py | 118 ++++++++++++++++++++++++++++++++++++++++++++++ ui/widgets.py | 102 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 ui/theme.py diff --git a/ui/main_window.py b/ui/main_window.py index 509a199..065f40d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -22,6 +22,7 @@ from .workers import ConnectWorker from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .config_dialog import AIConfigDialog from .terminal_panel import TerminalPanel +from .theme import get_qss, get_theme, set_theme APP_NAME = "SSHClient" @@ -43,6 +44,7 @@ class MainWindow(QMainWindow): self._build_ui() self._build_menu() self._build_statusbar() + self._apply_theme() self._load_hosts_to_list() # ============================================================ @@ -144,6 +146,12 @@ class MainWindow(QMainWindow): act_clear = QAction("清空 AI 对话", self) act_clear.triggered.connect(lambda: self.ai_panel._clear()) m_ai.addAction(act_clear) + # 视图 + m_view = menubar.addMenu("视图(&V)") + self.act_dark = QAction("🌙 暗色主题", self, checkable=True) + self.act_dark.setChecked(get_theme() == "dark") + self.act_dark.triggered.connect(self._toggle_theme) + m_view.addAction(self.act_dark) # 帮助 m_help = menubar.addMenu("帮助(&H)") act_about = QAction("关于", self) @@ -301,6 +309,18 @@ class MainWindow(QMainWindow): # ============================================================ # AI / 关于 # ============================================================ + def _apply_theme(self): + theme = get_theme() + qss = get_qss(theme) + QApplication.instance().setStyleSheet(qss) + + def _toggle_theme(self): + new_theme = "dark" if self.act_dark.isChecked() else "light" + set_theme(new_theme) + self._apply_theme() + label = "暗色" if new_theme == "dark" else "亮色" + self.statusBar().showMessage(f"已切换到{label}主题", 3000) + def _show_ai_config(self): dlg = AIConfigDialog(self.agent, self) if dlg.exec_(): diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..d9e4021 --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,118 @@ +""" +主题管理:亮色 / 暗色 QSS 样式表 +持久化到 ~/.sshclient/settings.json +""" +import json +from pathlib import Path + +CONFIG_DIR = Path.home() / ".sshclient" +SETTINGS_FILE = CONFIG_DIR / "settings.json" + +DARK_QSS = """ +QMainWindow, QWidget { background-color: #1e1e2e; color: #cdd6f4; } +QMenuBar { background-color: #181825; color: #cdd6f4; border-bottom: 1px solid #313244; } +QMenuBar::item:selected { background-color: #313244; } +QMenu { background-color: #1e1e2e; color: #cdd6f4; border: 1px solid #313244; } +QMenu::item:selected { background-color: #45475a; } +QTabWidget::pane { border: 1px solid #313244; background: #1e1e2e; } +QTabBar::tab { background: #181825; color: #a6adc8; padding: 6px 14px; border: 1px solid #313244; border-bottom: none; } +QTabBar::tab:selected { background: #1e1e2e; color: #89b4fa; border-bottom: 2px solid #89b4fa; } +QTabBar::tab:hover:!selected { background: #313244; } +QListWidget { background-color: #181825; color: #cdd6f4; border: 1px solid #313244; alternate-background-color: #1e1e2e; } +QListWidget::item:selected { background-color: #45475a; color: #89b4fa; } +QPushButton { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 5px 12px; border-radius: 3px; } +QPushButton:hover { background-color: #45475a; border-color: #89b4fa; } +QPushButton:pressed { background-color: #585b70; } +QPushButton:disabled { color: #585b70; background-color: #1e1e2e; } +QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox, QDoubleSpinBox { + background-color: #181825; color: #cdd6f4; border: 1px solid #313244; padding: 3px 6px; border-radius: 2px; +} +QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus { border-color: #89b4fa; } +QComboBox::drop-down { border: none; } +QComboBox QAbstractItemView { background-color: #1e1e2e; color: #cdd6f4; selection-background-color: #45475a; } +QTableWidget { background-color: #181825; color: #cdd6f4; gridline-color: #313244; alternate-background-color: #1e1e2e; } +QTableWidget::item:selected { background-color: #45475a; } +QHeaderView::section { background-color: #313244; color: #cdd6f4; border: none; padding: 4px; border-right: 1px solid #45475a; } +QTreeWidget { background-color: #181825; color: #cdd6f4; border: 1px solid #313244; } +QTreeWidget::item:selected { background-color: #45475a; color: #89b4fa; } +QProgressBar { background-color: #181825; border: 1px solid #313244; border-radius: 3px; text-align: center; color: #cdd6f4; } +QProgressBar::chunk { background-color: #89b4fa; border-radius: 2px; } +QGroupBox { border: 1px solid #313244; border-radius: 4px; margin-top: 8px; padding-top: 8px; color: #89b4fa; font-weight: bold; } +QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } +QLabel { color: #cdd6f4; } +QStatusBar { background-color: #181825; color: #a6adc8; } +QSplitter::handle { background-color: #313244; } +QSplitter::handle:horizontal { width: 2px; } +QSplitter::handle:vertical { height: 2px; } +QScrollBar:vertical { background: #181825; width: 10px; border: none; } +QScrollBar::handle:vertical { background: #45475a; min-height: 20px; border-radius: 4px; } +QScrollBar::handle:vertical:hover { background: #585b70; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar:horizontal { background: #181825; height: 10px; border: none; } +QScrollBar::handle:horizontal { background: #45475a; min-width: 20px; border-radius: 4px; } +QScrollBar::handle:horizontal:hover { background: #585b70; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } +QToolTip { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 4px; } +QCheckBox { color: #cdd6f4; } +QCheckBox::indicator { width: 14px; height: 14px; } +QCheckBox::indicator:unchecked { background: #181825; border: 1px solid #45475a; border-radius: 2px; } +QCheckBox::indicator:checked { background: #89b4fa; border: 1px solid #89b4fa; border-radius: 2px; } +QDialog { background-color: #1e1e2e; } +QFileDialog { background-color: #1e1e2e; } +QMessageBox { background-color: #1e1e2e; } +QMessageBox QLabel { color: #cdd6f4; } +QToolButton { background-color: #313244; color: #cdd6f4; border: 1px solid #45475a; padding: 3px 6px; border-radius: 3px; } +QToolButton:hover { background-color: #45475a; } +""" + +LIGHT_QSS = """ +QMainWindow, QWidget { background-color: #f5f5f5; color: #1a1a2e; } +QTabWidget::pane { border: 1px solid #ddd; background: #fff; } +QTabBar::tab { background: #e8e8e8; color: #555; padding: 6px 14px; border: 1px solid #ddd; border-bottom: none; } +QTabBar::tab:selected { background: #fff; color: #1976d2; border-bottom: 2px solid #1976d2; } +QListWidget { background-color: #fff; border: 1px solid #ddd; alternate-background-color: #f9f9f9; } +QListWidget::item:selected { background-color: #e3f2fd; color: #1976d2; } +QPushButton { background-color: #fff; color: #333; border: 1px solid #ccc; padding: 5px 12px; border-radius: 3px; } +QPushButton:hover { background-color: #f0f0f0; border-color: #1976d2; } +QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox { background-color: #fff; color: #333; border: 1px solid #ccc; padding: 3px 6px; border-radius: 2px; } +QLineEdit:focus, QTextEdit:focus { border-color: #1976d2; } +QTableWidget { background-color: #fff; gridline-color: #e0e0e0; alternate-background-color: #f9f9f9; } +QTableWidget::item:selected { background-color: #e3f2fd; } +QHeaderView::section { background-color: #f0f0f0; color: #555; border: none; padding: 4px; border-right: 1px solid #ddd; } +QGroupBox { border: 1px solid #ddd; border-radius: 4px; margin-top: 8px; padding-top: 8px; color: #1976d2; font-weight: bold; } +QProgressBar { background-color: #f0f0f0; border: 1px solid #ddd; border-radius: 3px; text-align: center; } +QProgressBar::chunk { background-color: #1976d2; border-radius: 2px; } +QStatusBar { background-color: #f0f0f0; color: #666; } +""" + + +def load_settings() -> dict: + if SETTINGS_FILE.exists(): + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"theme": "light"} + + +def save_settings(settings: dict): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, ensure_ascii=False, indent=2) + + +def get_theme() -> str: + return load_settings().get("theme", "light") + + +def set_theme(theme: str): + s = load_settings() + s["theme"] = theme + save_settings(s) + + +def get_qss(theme: "str | None" = None) -> str: + if theme is None: + theme = get_theme() + return DARK_QSS if theme == "dark" else LIGHT_QSS diff --git a/ui/widgets.py b/ui/widgets.py index 34a24f7..1c16c24 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -356,6 +356,93 @@ class FileBrowser(QWidget): # ============================================================ # 监控面板 # ============================================================ +class SparklineChart(QWidget): + """迷你趋势图:绘制 0-100 的折线图,显示最近 N 个采样点""" + + def __init__(self, title: str = "", color: str = "#4caf50", max_points: int = 60, parent=None): + super().__init__(parent) + self._title = title + self._color = QColor(color) + self._max_points = max_points + self._data: list = [] + self.setMinimumHeight(64) + self.setMinimumWidth(120) + + def add_value(self, val: float): + self._data.append(max(0, min(100, val))) + if len(self._data) > self._max_points: + self._data = self._data[-self._max_points:] + self.update() + + def clear(self): + self._data.clear() + self.update() + + def paintEvent(self, _ev): + from PyQt5.QtGui import QPainter, QPen, QBrush, QPainterPath, QLinearGradient + from PyQt5.QtCore import QRectF, QPointF + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + # 背景 + p.fillRect(0, 0, w, h, QColor("#ffffff08")) + # 标题 + if self._title: + p.setPen(QPen(QColor("#888"))) + p.setFont(QFont("sans-serif", 8)) + p.drawText(4, 12, self._title) + if len(self._data) < 2: + p.setPen(QPen(QColor("#666"))) + p.setFont(QFont("sans-serif", 8)) + p.drawText(w // 2 - 20, h // 2, "等待数据...") + return + # 绘图区域(留出标题空间) + top = 16 + bottom = h - 4 + left = 4 + right = w - 4 + plot_w = right - left + plot_h = bottom - top + # 点坐标 + n = len(self._data) + step = plot_w / max(n - 1, 1) + points = [] + for i, v in enumerate(self._data): + x = left + i * step + y = bottom - (v / 100.0) * plot_h + points.append(QPointF(x, y)) + # 填充区域 + fill_path = QPainterPath() + fill_path.moveTo(points[0].x(), bottom) + for pt in points: + fill_path.lineTo(pt) + fill_path.lineTo(points[-1].x(), bottom) + fill_path.closeSubpath() + grad = QLinearGradient(0, top, 0, bottom) + c = self._color + grad.setColorAt(0, QColor(c.red(), c.green(), c.blue(), 80)) + grad.setColorAt(1, QColor(c.red(), c.green(), c.blue(), 10)) + p.fillPath(fill_path, QBrush(grad)) + # 折线 + p.setPen(QPen(self._color, 1.5)) + line_path = QPainterPath() + line_path.moveTo(points[0]) + for pt in points[1:]: + line_path.lineTo(pt) + p.drawPath(line_path) + # 当前值标签 + cur = self._data[-1] + p.setPen(QPen(self._color)) + p.setFont(QFont("sans-serif", 9, QFont.Bold)) + label = f"{cur:.1f}%" + p.drawText(right - 40, 12, label) + # 当前点圆点 + p.setBrush(QBrush(self._color)) + p.setPen(QPen(self._color, 0)) + p.drawEllipse(points[-1], 3, 3) + + class MonitorPanel(QWidget): """实时监控:CPU、内存、磁盘、网络""" @@ -415,6 +502,14 @@ class MonitorPanel(QWidget): grid.addWidget(self._build_card("内存", "mem_card")) uv.addLayout(grid) + # CPU / 内存趋势迷你图 + spark_row = QHBoxLayout() + self._spark_cpu = SparklineChart("CPU 趋势", "#4caf50") + self._spark_mem = SparklineChart("内存趋势", "#7986cb") + spark_row.addWidget(self._spark_cpu) + spark_row.addWidget(self._spark_mem) + uv.addLayout(spark_row) + # 负载 + 启动时间 grid2 = QHBoxLayout() grid2.addWidget(self._build_card("系统负载", "load_card")) @@ -685,6 +780,11 @@ class MonitorPanel(QWidget): pass self.worker = None self._last_net.clear() + # 清空趋势图 + if hasattr(self, "_spark_cpu"): + self._spark_cpu.clear() + if hasattr(self, "_spark_mem"): + self._spark_mem.clear() def _kick_one_sample(self): """启动一个后台 worker 做一次采集;采完用 QTimer 调度下一次""" @@ -756,6 +856,7 @@ class MonitorPanel(QWidget): # CPU cpu = m.get("cpu", 0) + self._spark_cpu.add_value(cpu) self._value_label("cpu_card").setText(f"{cpu:.1f}%") cores = m.get("cores", 1) @@ -763,6 +864,7 @@ class MonitorPanel(QWidget): # 内存 mp = m.get("mem_percent", 0) + self._spark_mem.add_value(mp) mu = SystemMonitor.format_bytes(m.get("mem_used", 0)) mt = SystemMonitor.format_bytes(m.get("mem_total", 0)) self._value_label("mem_card").setText(f"{mp:.1f}%")