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
This commit is contained in:
Your Name
2026-07-28 23:00:50 +08:00
parent 5d83348db3
commit 57092cad03
3 changed files with 240 additions and 0 deletions
+20
View File
@@ -22,6 +22,7 @@ from .workers import ConnectWorker
from .widgets import FileBrowser, MonitorPanel, AIChatPanel from .widgets import FileBrowser, MonitorPanel, AIChatPanel
from .config_dialog import AIConfigDialog from .config_dialog import AIConfigDialog
from .terminal_panel import TerminalPanel from .terminal_panel import TerminalPanel
from .theme import get_qss, get_theme, set_theme
APP_NAME = "SSHClient" APP_NAME = "SSHClient"
@@ -43,6 +44,7 @@ class MainWindow(QMainWindow):
self._build_ui() self._build_ui()
self._build_menu() self._build_menu()
self._build_statusbar() self._build_statusbar()
self._apply_theme()
self._load_hosts_to_list() self._load_hosts_to_list()
# ============================================================ # ============================================================
@@ -144,6 +146,12 @@ class MainWindow(QMainWindow):
act_clear = QAction("清空 AI 对话", self) act_clear = QAction("清空 AI 对话", self)
act_clear.triggered.connect(lambda: self.ai_panel._clear()) act_clear.triggered.connect(lambda: self.ai_panel._clear())
m_ai.addAction(act_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)") m_help = menubar.addMenu("帮助(&H)")
act_about = QAction("关于", self) act_about = QAction("关于", self)
@@ -301,6 +309,18 @@ class MainWindow(QMainWindow):
# ============================================================ # ============================================================
# AI / 关于 # 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): def _show_ai_config(self):
dlg = AIConfigDialog(self.agent, self) dlg = AIConfigDialog(self.agent, self)
if dlg.exec_(): if dlg.exec_():
+118
View File
@@ -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
+102
View File
@@ -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): class MonitorPanel(QWidget):
"""实时监控:CPU、内存、磁盘、网络""" """实时监控:CPU、内存、磁盘、网络"""
@@ -415,6 +502,14 @@ class MonitorPanel(QWidget):
grid.addWidget(self._build_card("内存", "mem_card")) grid.addWidget(self._build_card("内存", "mem_card"))
uv.addLayout(grid) 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 = QHBoxLayout()
grid2.addWidget(self._build_card("系统负载", "load_card")) grid2.addWidget(self._build_card("系统负载", "load_card"))
@@ -685,6 +780,11 @@ class MonitorPanel(QWidget):
pass pass
self.worker = None self.worker = None
self._last_net.clear() 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): def _kick_one_sample(self):
"""启动一个后台 worker 做一次采集;采完用 QTimer 调度下一次""" """启动一个后台 worker 做一次采集;采完用 QTimer 调度下一次"""
@@ -756,6 +856,7 @@ class MonitorPanel(QWidget):
# CPU # CPU
cpu = m.get("cpu", 0) cpu = m.get("cpu", 0)
self._spark_cpu.add_value(cpu)
self._value_label("cpu_card").setText(f"{cpu:.1f}%") self._value_label("cpu_card").setText(f"{cpu:.1f}%")
cores = m.get("cores", 1) cores = m.get("cores", 1)
@@ -763,6 +864,7 @@ class MonitorPanel(QWidget):
# 内存 # 内存
mp = m.get("mem_percent", 0) mp = m.get("mem_percent", 0)
self._spark_mem.add_value(mp)
mu = SystemMonitor.format_bytes(m.get("mem_used", 0)) mu = SystemMonitor.format_bytes(m.get("mem_used", 0))
mt = SystemMonitor.format_bytes(m.get("mem_total", 0)) mt = SystemMonitor.format_bytes(m.get("mem_total", 0))
self._value_label("mem_card").setText(f"{mp:.1f}%") self._value_label("mem_card").setText(f"{mp:.1f}%")