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:
@@ -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
@@ -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
@@ -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}%")
|
||||||
|
|||||||
Reference in New Issue
Block a user