3bc29f7b by 柴进

feat(theme): 设计令牌生成器 + 主题管理器

- TOKENS_LIGHT / TOKENS_DARK:24 个语义颜色令牌(bg/text/border/accent/status)
- SIZES:圆角/间距/控件高度/字号
- FONT_STACK:跨平台字体回退
- build_qss(mode):生成完整 QSS(覆盖 GroupBox/Button/Tab/Input/List/Scroll/CheckBox/Tooltip/Menu)
- ThemeManager:QObject,监听 styleHints().colorSchemeChanged 自动切换
- apply_theme(app):入口函数,在 main() 创建 QApplication 后调用

业务层通过 setObjectName / setProperty('variant'|'status'|'role', ...) 命中 QSS。
状态色(success/warning/danger)走 status property,不再 inline setStyleSheet。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d717f7dd
Showing 1 changed file with 680 additions and 0 deletions
"""设计系统:Apple 高级浅色主题 + 浅深双模式。
单一真相源 — 所有颜色 / 间距 / 圆角 / 字号在本模块定义,
通过 build_qss(mode) 拼成完整 QSS 字符串应用到 QApplication。
跟随系统切换:监听 QGuiApplication.styleHints().colorSchemeChanged,
macOS 按时间自动切深色 / Windows 11 系统级深浅都会自动响应。
业务代码不要写 inline setStyleSheet — 通过 setObjectName / setProperty
让全局 QSS 命中。状态色(success/warning/danger)用 setProperty('status', ...)。
"""
from __future__ import annotations
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import QApplication
# =============================================================================
# 设计令牌
# =============================================================================
FONT_STACK = (
'"-apple-system", "SF Pro Text", "PingFang SC", '
'"Microsoft YaHei UI", "Segoe UI Variable", "Segoe UI", sans-serif'
)
TOKENS_LIGHT: dict[str, str] = {
# 表面层级
"bg_canvas": "#fbfbfd",
"bg_surface": "#ffffff",
"bg_elevated": "#ffffff",
"bg_subtle": "#f4f4f7",
"bg_hover": "#f0f0f3",
# 文字层级
"text_primary": "#1d1d1f",
"text_secondary": "#6e6e73",
"text_tertiary": "#86868b",
"text_on_accent": "#ffffff",
"text_on_danger": "#ffffff",
# 边框 / 分隔
"border_default": "rgba(0, 0, 0, 0.10)",
"border_strong": "rgba(0, 0, 0, 0.18)",
"border_focus": "#0071e3",
"divider": "rgba(0, 0, 0, 0.06)",
# 主色(accent)
"accent": "#0071e3",
"accent_hover": "#0077ed",
"accent_pressed": "#0062c4",
"accent_subtle": "rgba(0, 113, 227, 0.08)",
"accent_subtle_hover": "rgba(0, 113, 227, 0.14)",
# 状态色
"success": "#34c759",
"warning": "#ff9500",
"danger": "#ff3b30",
"danger_hover": "#ff5247",
"danger_subtle": "rgba(255, 59, 48, 0.10)",
}
TOKENS_DARK: dict[str, str] = {
"bg_canvas": "#1a1a1c",
"bg_surface": "#2c2c2e",
"bg_elevated": "#3a3a3c",
"bg_subtle": "#242426",
"bg_hover": "#34343a",
"text_primary": "#ffffff",
"text_secondary": "#98989d",
"text_tertiary": "#6e6e73",
"text_on_accent": "#ffffff",
"text_on_danger": "#ffffff",
"border_default": "rgba(255, 255, 255, 0.10)",
"border_strong": "rgba(255, 255, 255, 0.20)",
"border_focus": "#0a84ff",
"divider": "rgba(255, 255, 255, 0.06)",
"accent": "#0a84ff",
"accent_hover": "#1d8cff",
"accent_pressed": "#006edc",
"accent_subtle": "rgba(10, 132, 255, 0.16)",
"accent_subtle_hover": "rgba(10, 132, 255, 0.24)",
"success": "#30d158",
"warning": "#ff9f0a",
"danger": "#ff453a",
"danger_hover": "#ff5b50",
"danger_subtle": "rgba(255, 69, 58, 0.16)",
}
# 尺寸 / 圆角 / 间距 ─ 浅深一致
SIZES: dict[str, str] = {
"radius_sm": "4px",
"radius_md": "8px",
"radius_lg": "12px",
"radius_pill": "980px",
"space_1": "4px",
"space_2": "8px",
"space_3": "12px",
"space_4": "16px",
"space_5": "20px",
"space_6": "24px",
"control_h_sm": "26px",
"control_h_md": "32px",
"control_h_lg": "40px",
"font_xs": "10pt",
"font_sm": "11pt",
"font_base": "12pt",
"font_lg": "14pt",
"font_xl": "17pt",
}
# =============================================================================
# QSS 构建
# =============================================================================
def build_qss(mode: str) -> str:
"""根据 mode('light'/'dark')拼出完整 QSS。"""
c = TOKENS_DARK if mode == "dark" else TOKENS_LIGHT
s = SIZES
f = FONT_STACK
return f"""
/* ========== 全局 ========== */
* {{
font-family: {f};
color: {c['text_primary']};
}}
QWidget {{
background-color: {c['bg_canvas']};
color: {c['text_primary']};
font-size: {s['font_base']};
}}
QMainWindow, QDialog {{
background-color: {c['bg_canvas']};
}}
/* 二级弹窗(QMessageBox 等)走 surface 颜色,凸出于主窗口 */
QMessageBox {{
background-color: {c['bg_surface']};
}}
QMessageBox QLabel {{
background: transparent;
color: {c['text_primary']};
font-size: {s['font_base']};
}}
/* ========== 文字 ========== */
QLabel {{
background: transparent;
color: {c['text_primary']};
}}
QLabel[role="secondary"] {{
color: {c['text_secondary']};
}}
QLabel[role="muted"] {{
color: {c['text_tertiary']};
font-size: {s['font_sm']};
}}
QLabel[role="caption"] {{
color: {c['text_secondary']};
font-size: {s['font_xs']};
text-transform: uppercase;
letter-spacing: 1px;
}}
QLabel[role="title"] {{
color: {c['text_primary']};
font-size: {s['font_xl']};
font-weight: 600;
}}
/* 状态色 — 业务用 setProperty('status', '...') 命中 */
QLabel[status="success"] {{ color: {c['success']}; }}
QLabel[status="warning"] {{ color: {c['warning']}; }}
QLabel[status="danger"] {{ color: {c['danger']}; }}
QLabel[status="muted"] {{ color: {c['text_tertiary']}; }}
QLabel[status="info"] {{ color: {c['accent']}; }}
/* ========== 卡片 / GroupBox ========== */
QGroupBox {{
background-color: {c['bg_surface']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
margin-top: 14px;
padding: {s['space_4']} {s['space_3']} {s['space_3']} {s['space_3']};
font-weight: 600;
font-size: {s['font_sm']};
color: {c['text_secondary']};
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
left: {s['space_3']};
padding: 0 {s['space_2']};
color: {c['text_secondary']};
background: transparent;
text-transform: uppercase;
letter-spacing: 1px;
font-size: {s['font_xs']};
}}
/* ========== 按钮 ========== */
QPushButton {{
background-color: {c['bg_surface']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
padding: 6px 14px;
min-height: {s['control_h_md']};
font-size: {s['font_sm']};
font-weight: 500;
}}
QPushButton:hover {{
background-color: {c['bg_hover']};
border-color: {c['border_strong']};
}}
QPushButton:pressed {{
background-color: {c['bg_subtle']};
}}
QPushButton:disabled {{
color: {c['text_tertiary']};
background-color: {c['bg_subtle']};
border-color: {c['border_default']};
}}
QPushButton:focus {{
outline: none;
border-color: {c['accent']};
}}
/* 主按钮("生成图片"等)— pill 圆角 + 实色背景 */
QPushButton[variant="primary"] {{
background-color: {c['accent']};
color: {c['text_on_accent']};
border: 1px solid {c['accent']};
border-radius: {s['radius_pill']};
padding: 8px 22px;
min-height: {s['control_h_lg']};
font-weight: 600;
}}
QPushButton[variant="primary"]:hover {{
background-color: {c['accent_hover']};
border-color: {c['accent_hover']};
}}
QPushButton[variant="primary"]:pressed {{
background-color: {c['accent_pressed']};
border-color: {c['accent_pressed']};
}}
QPushButton[variant="primary"]:disabled {{
background-color: {c['accent_subtle']};
color: {c['text_tertiary']};
border-color: transparent;
}}
/* 危险按钮 */
QPushButton[variant="danger"] {{
background-color: {c['bg_surface']};
color: {c['danger']};
border: 1px solid {c['danger']};
}}
QPushButton[variant="danger"]:hover {{
background-color: {c['danger_subtle']};
}}
QPushButton[variant="danger"]:pressed {{
background-color: {c['danger']};
color: {c['text_on_danger']};
}}
/* 幽灵按钮(图标按钮等) */
QPushButton[variant="ghost"] {{
background-color: transparent;
border: 1px solid transparent;
color: {c['text_secondary']};
}}
QPushButton[variant="ghost"]:hover {{
background-color: {c['accent_subtle']};
color: {c['accent']};
}}
/* 链接按钮 — 像超链接 */
QPushButton[variant="link"] {{
background-color: transparent;
border: none;
color: {c['accent']};
padding: 2px 6px;
min-height: 0;
}}
QPushButton[variant="link"]:hover {{
color: {c['accent_hover']};
text-decoration: underline;
}}
/* ========== 输入框 ========== */
QLineEdit, QTextEdit, QPlainTextEdit {{
background-color: {c['bg_surface']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
padding: 6px 10px;
selection-background-color: {c['accent']};
selection-color: {c['text_on_accent']};
font-size: {s['font_base']};
}}
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {{
border: 2px solid {c['accent']};
padding: 5px 9px;
}}
QLineEdit:disabled, QTextEdit:disabled, QPlainTextEdit:disabled {{
background-color: {c['bg_subtle']};
color: {c['text_tertiary']};
}}
QLineEdit[placeholder="true"], QTextEdit[placeholder="true"] {{
color: {c['text_tertiary']};
}}
/* ========== 下拉框 ========== */
QComboBox {{
background-color: {c['bg_surface']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
padding: 5px 10px;
min-height: {s['control_h_md']};
min-width: 100px;
}}
QComboBox:hover {{
border-color: {c['border_strong']};
}}
QComboBox:focus {{
border: 2px solid {c['accent']};
padding: 4px 9px;
}}
QComboBox::drop-down {{
width: 20px;
border: none;
background: transparent;
}}
QComboBox QAbstractItemView {{
background-color: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
selection-background-color: {c['accent_subtle']};
selection-color: {c['text_primary']};
padding: 4px;
outline: none;
}}
QComboBox QAbstractItemView::item {{
padding: 6px 10px;
border-radius: {s['radius_sm']};
}}
QComboBox QAbstractItemView::item:hover {{
background-color: {c['accent_subtle']};
}}
/* ========== Tab ========== */
QTabWidget::pane {{
border: none;
background-color: {c['bg_canvas']};
top: -1px;
}}
QTabWidget::tab-bar {{
left: 4px;
}}
QTabBar {{
background: transparent;
qproperty-drawBase: 0;
}}
QTabBar::tab {{
background: transparent;
color: {c['text_secondary']};
padding: 8px 16px;
margin: 0 2px;
border: none;
border-bottom: 2px solid transparent;
font-size: {s['font_base']};
min-width: 80px;
}}
QTabBar::tab:hover {{
color: {c['text_primary']};
}}
QTabBar::tab:selected {{
color: {c['accent']};
border-bottom: 2px solid {c['accent']};
font-weight: 600;
}}
QTabBar::tab:disabled {{
color: {c['text_tertiary']};
}}
/* ========== 列表 ========== */
QListWidget, QListView, QTreeWidget, QTreeView {{
background-color: {c['bg_surface']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
outline: none;
padding: 4px;
}}
QListWidget::item, QListView::item {{
padding: 8px;
border-radius: {s['radius_sm']};
color: {c['text_primary']};
}}
QListWidget::item:hover, QListView::item:hover {{
background-color: {c['bg_hover']};
}}
QListWidget::item:selected, QListView::item:selected {{
background-color: {c['accent_subtle']};
color: {c['text_primary']};
}}
/* ========== 滚动区 ========== */
QScrollArea {{
background-color: {c['bg_canvas']};
border: none;
}}
QScrollArea > QWidget > QWidget {{
background-color: transparent;
}}
/* ========== 滚动条(Apple 隐式风格)========== */
QScrollBar:vertical {{
background: transparent;
width: 10px;
margin: 2px;
border: none;
}}
QScrollBar::handle:vertical {{
background: {c['border_strong']};
border-radius: 3px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {c['text_tertiary']};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0;
background: transparent;
border: none;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: transparent;
}}
QScrollBar:horizontal {{
background: transparent;
height: 10px;
margin: 2px;
border: none;
}}
QScrollBar::handle:horizontal {{
background: {c['border_strong']};
border-radius: 3px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {c['text_tertiary']};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0;
background: transparent;
border: none;
}}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
background: transparent;
}}
/* ========== 进度条 ========== */
QProgressBar {{
background-color: {c['bg_subtle']};
border: none;
border-radius: 4px;
height: 6px;
text-align: center;
color: {c['text_secondary']};
font-size: {s['font_xs']};
}}
QProgressBar::chunk {{
background-color: {c['accent']};
border-radius: 4px;
}}
/* ========== 复选 / 单选 ========== */
QCheckBox, QRadioButton {{
color: {c['text_primary']};
spacing: 6px;
background: transparent;
}}
QCheckBox::indicator, QRadioButton::indicator {{
width: 16px;
height: 16px;
}}
QCheckBox::indicator:unchecked {{
background: {c['bg_surface']};
border: 1px solid {c['border_strong']};
border-radius: 3px;
}}
QCheckBox::indicator:checked {{
background: {c['accent']};
border: 1px solid {c['accent']};
border-radius: 3px;
}}
QRadioButton::indicator:unchecked {{
background: {c['bg_surface']};
border: 1px solid {c['border_strong']};
border-radius: 8px;
}}
QRadioButton::indicator:checked {{
background: {c['accent']};
border: 1px solid {c['accent']};
border-radius: 8px;
}}
/* ========== 工具提示 ========== */
QToolTip {{
background-color: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_sm']};
padding: 4px 8px;
font-size: {s['font_sm']};
}}
/* ========== 菜单 ========== */
QMenu {{
background-color: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {s['radius_md']};
padding: 4px;
}}
QMenu::item {{
padding: 6px 16px;
border-radius: {s['radius_sm']};
}}
QMenu::item:selected {{
background-color: {c['accent_subtle']};
color: {c['text_primary']};
}}
QMenu::separator {{
height: 1px;
background: {c['divider']};
margin: 4px 6px;
}}
/* ========== 分割器 ========== */
QSplitter::handle {{
background-color: {c['divider']};
}}
QSplitter::handle:horizontal {{
width: 1px;
}}
QSplitter::handle:vertical {{
height: 1px;
}}
/* ========== 任务队列 sidebar ========== */
#taskQueueSidebar {{
background-color: {c['bg_surface']};
border-left: 1px solid {c['divider']};
}}
#sidebarHeader {{
background-color: {c['bg_surface']};
border-bottom: 1px solid {c['divider']};
min-height: 40px;
max-height: 40px;
}}
#sidebarHeader QLabel {{
color: {c['text_primary']};
font-size: {s['font_base']};
font-weight: 600;
padding: 0 12px;
}}
/* ========== 登录对话框 ========== */
#loginDialog {{
background-color: {c['bg_canvas']};
}}
#loginDialog QLabel#loginTitle {{
font-size: {s['font_xl']};
font-weight: 600;
color: {c['text_primary']};
}}
#loginDialog QLabel#loginSubtitle {{
font-size: {s['font_sm']};
color: {c['text_secondary']};
}}
/* ========== 预览区占位 ========== */
#previewPlaceholder {{
color: {c['text_tertiary']};
background: {c['bg_subtle']};
border-radius: {s['radius_md']};
}}
"""
# =============================================================================
# 主题管理器
# =============================================================================
def _detect_system_mode() -> str:
"""检测当前系统色彩偏好。Qt 6.5+ 支持,回退到 light。"""
try:
scheme = QGuiApplication.styleHints().colorScheme()
if scheme == Qt.ColorScheme.Dark:
return "dark"
return "light"
except Exception:
return "light"
class ThemeManager(QObject):
"""全局主题管理器。监听系统色彩偏好变化,自动切换 QSS。
使用:在 main() 创建 QApplication 后调用 apply_theme(app) 即可。
业务代码不需要直接接触 ThemeManager。
通过 force_mode('light'|'dark'|None) 可以强制锁定,传 None 恢复跟随系统。
"""
theme_changed = Signal(str) # 'light' | 'dark'
def __init__(self, app: QApplication):
super().__init__(app)
self._app = app
self._forced_mode: str | None = None
self._current_mode: str = _detect_system_mode()
self._apply_qss()
# 监听系统色彩切换
try:
QGuiApplication.styleHints().colorSchemeChanged.connect(
self._on_system_color_scheme_changed
)
except Exception:
# Qt 版本不支持 / 平台不发射,无所谓
pass
def current_mode(self) -> str:
return self._current_mode
def force_mode(self, mode: str | None) -> None:
"""锁定主题。None = 跟随系统。"""
self._forced_mode = mode
new_mode = mode if mode in ("light", "dark") else _detect_system_mode()
if new_mode != self._current_mode:
self._current_mode = new_mode
self._apply_qss()
self.theme_changed.emit(new_mode)
def _on_system_color_scheme_changed(self, scheme) -> None:
if self._forced_mode in ("light", "dark"):
return # 用户锁定模式时不响应系统切换
new_mode = "dark" if scheme == Qt.ColorScheme.Dark else "light"
if new_mode != self._current_mode:
self._current_mode = new_mode
self._apply_qss()
self.theme_changed.emit(new_mode)
def _apply_qss(self) -> None:
self._app.setStyleSheet(build_qss(self._current_mode))
def apply_theme(app: QApplication) -> ThemeManager:
"""主题应用入口。在 main() 创建 QApplication 后立即调用。
返回 ThemeManager 实例,调用方应持有引用以保持信号连接(否则会被
Python GC 当作未引用的 QObject 回收,导致系统切换失效)。
"""
return ThemeManager(app)