a13rollcall/modern_ui.py
2026-10-06 18:38:55 +08:00

579 行
18 KiB
Python

# -*- coding: utf-8 -*-
"""
A13课堂点名系统 - 现代化UI组件库
包含统一的按钮、卡片、加载动画等组件,提供一致的视觉风格和微交互
"""
from PyQt5.QtWidgets import (
QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QGraphicsDropShadowEffect, QFrame, QSizePolicy
)
from PyQt5.QtCore import (
Qt, QPropertyAnimation, QEasingCurve, QTimer, QSize, pyqtSignal,
QPointF
)
from PyQt5.QtGui import (
QColor, QFont, QPainter, QBrush, QPen, QRadialGradient,
QLinearGradient, QPainterPath, QCursor
)
import math
# ==================== 颜色主题 ====================
class ThemeColors:
PRIMARY = "#0067c0"
PRIMARY_HOVER = "#106ebe"
PRIMARY_PRESSED = "#005a9e"
PRIMARY_LIGHT = "#0078d4"
SUCCESS = "#107c10"
SUCCESS_HOVER = "#0e6c0e"
SUCCESS_PRESSED = "#0b5a0b"
WARNING = "#ca5010"
WARNING_HOVER = "#b04810"
WARNING_PRESSED = "#963d0e"
DANGER = "#c42b1c"
DANGER_HOVER = "#a52517"
DANGER_PRESSED = "#8a1f13"
GHOST_BG = "transparent"
GHOST_BORDER = "#0067c0"
GHOST_HOVER_BG = "#e5f1fb"
WHITE = "#ffffff"
TEXT_PRIMARY = "#1a1a1a"
TEXT_SECONDARY = "#666666"
TEXT_DISABLED = "#999999"
BG_HOVER = "#f0f0f0"
BG_PRESSED = "#e0e0e0"
DARK_BG = "#2d2d2d"
DARK_TEXT = "#ffffff"
DARK_BORDER = "#404040"
# ==================== 现代化按钮 ====================
class ModernButton(QPushButton):
"""
现代化按钮组件
支持悬停缩放、阴影、渐变背景等微交互效果
"""
STYLE_PRIMARY = "primary"
STYLE_SUCCESS = "success"
STYLE_WARNING = "warning"
STYLE_DANGER = "danger"
STYLE_GHOST = "ghost"
STYLE_NEUTRAL = "neutral"
def __init__(self, text="", style_type=STYLE_PRIMARY, parent=None):
super().__init__(text, parent)
self.style_type = style_type
self._hover_animation = None
self._press_animation = None
self._shadow_effect = None
self._original_height = None
self._setup_style()
self._setup_animations()
def _setup_style(self):
self.setCursor(QCursor(Qt.PointingHandCursor))
self.setMinimumHeight(36)
self.setFont(QFont("Microsoft YaHei", 10, QFont.Bold))
self._update_style()
def _setup_animations(self):
self._shadow_effect = QGraphicsDropShadowEffect(self)
self._shadow_effect.setBlurRadius(0)
self._shadow_effect.setOffset(0, 0)
self._shadow_effect.setColor(QColor(0, 0, 0, 0))
self.setGraphicsEffect(self._shadow_effect)
def _get_colors(self):
styles = {
self.STYLE_PRIMARY: (
ThemeColors.PRIMARY, ThemeColors.PRIMARY_HOVER,
ThemeColors.PRIMARY_PRESSED, ThemeColors.WHITE
),
self.STYLE_SUCCESS: (
ThemeColors.SUCCESS, ThemeColors.SUCCESS_HOVER,
ThemeColors.SUCCESS_PRESSED, ThemeColors.WHITE
),
self.STYLE_WARNING: (
ThemeColors.WARNING, ThemeColors.WARNING_HOVER,
ThemeColors.WARNING_PRESSED, ThemeColors.WHITE
),
self.STYLE_DANGER: (
ThemeColors.DANGER, ThemeColors.DANGER_HOVER,
ThemeColors.DANGER_PRESSED, ThemeColors.WHITE
),
self.STYLE_GHOST: (
"transparent", ThemeColors.GHOST_HOVER_BG,
ThemeColors.BG_PRESSED, ThemeColors.PRIMARY
),
self.STYLE_NEUTRAL: (
"#f5f5f5", ThemeColors.BG_HOVER,
ThemeColors.BG_PRESSED, ThemeColors.TEXT_PRIMARY
),
}
return styles.get(self.style_type, styles[self.STYLE_PRIMARY])
def _update_style(self):
normal, hover, pressed, text_color = self._get_colors()
border = f"border: 1px solid {ThemeColors.GHOST_BORDER};" if self.style_type == self.STYLE_GHOST else "border: none;"
self.setStyleSheet(f"""
QPushButton {{
background: {normal};
color: {text_color};
{border}
border-radius: 6px;
padding: 8px 20px;
font-weight: 600;
font-size: 10pt;
}}
QPushButton:hover {{
background: {hover};
}}
QPushButton:pressed {{
background: {pressed};
}}
QPushButton:disabled {{
background: #e0e0e0;
color: #999999;
border: none;
}}
""")
def enterEvent(self, event):
super().enterEvent(event)
if self.isEnabled():
self._start_hover_animation(True)
def leaveEvent(self, event):
super().leaveEvent(event)
self._start_hover_animation(False)
def mousePressEvent(self, event):
super().mousePressEvent(event)
if self.isEnabled():
self._start_press_animation(True)
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self._start_press_animation(False)
def _start_hover_animation(self, is_hover):
try:
if self._hover_animation and self._hover_animation.state() == QPropertyAnimation.Running:
self._hover_animation.stop()
self._hover_animation = QPropertyAnimation(self._shadow_effect, b"blurRadius")
self._hover_animation.setDuration(200)
self._hover_animation.setStartValue(self._shadow_effect.blurRadius())
self._hover_animation.setEndValue(15 if is_hover else 0)
self._hover_animation.setEasingCurve(QEasingCurve.OutCubic)
self._hover_animation.start()
offset_anim = QPropertyAnimation(self._shadow_effect, b"offset")
offset_anim.setDuration(200)
offset_anim.setStartValue(self._shadow_effect.offset())
offset_anim.setEndValue(QPointF(0, 4 if is_hover else 0))
offset_anim.setEasingCurve(QEasingCurve.OutCubic)
offset_anim.start()
color = QColor(0, 103, 192, 80 if is_hover else 0)
self._shadow_effect.setColor(color)
except Exception:
pass
def _start_press_animation(self, is_pressed):
try:
if self._press_animation and self._press_animation.state() == QPropertyAnimation.Running:
self._press_animation.stop()
self._press_animation = QPropertyAnimation(self, b"minimumHeight")
self._press_animation.setDuration(100)
current_height = self.height() if self.height() > 0 else 36
self._press_animation.setStartValue(current_height)
self._press_animation.setEndValue(34 if is_pressed else 36)
self._press_animation.setEasingCurve(QEasingCurve.OutCubic)
self._press_animation.start()
except Exception:
pass
def set_style_type(self, style_type):
self.style_type = style_type
self._update_style()
# ==================== 大按钮(用于主操作) ====================
class LargeButton(ModernButton):
"""大按钮,用于主要操作,如开始抽取"""
def __init__(self, text="", style_type=ModernButton.STYLE_PRIMARY, parent=None):
super().__init__(text, style_type, parent)
self.setMinimumHeight(52)
self.setFont(QFont("Microsoft YaHei", 12, QFont.Bold))
self.setStyleSheet(self.styleSheet().replace("padding: 8px 20px;", "padding: 12px 32px;"))
# ==================== 图标按钮 ====================
class IconButton(QPushButton):
"""图标按钮,仅显示图标,用于工具栏等"""
def __init__(self, icon_path="", tooltip="", parent=None):
super().__init__(parent)
self.icon_path = icon_path
self._setup_style()
if tooltip:
self.setToolTip(tooltip)
self.setCursor(QCursor(Qt.PointingHandCursor))
self.setFixedSize(36, 36)
def _setup_style(self):
self.setStyleSheet("""
QPushButton {
background: transparent;
border: none;
border-radius: 6px;
}
QPushButton:hover {
background: #f0f0f0;
}
QPushButton:pressed {
background: #e0e0e0;
}
""")
def paintEvent(self, event):
super().paintEvent(event)
if self.icon_path and self.icon_path.endswith('.svg'):
try:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
from PyQt5.QtSvg import QSvgRenderer
renderer = QSvgRenderer(self.icon_path)
renderer.render(painter, self.rect().adjusted(8, 8, -8, -8))
painter.end()
except Exception:
pass
# ==================== 现代化卡片 ====================
class ModernCard(QFrame):
"""现代化卡片组件,带圆角和阴影"""
def __init__(self, parent=None):
super().__init__(parent)
self._setup_style()
self._setup_shadow()
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(16, 16, 16, 16)
self._layout.setSpacing(12)
def _setup_style(self):
self.setStyleSheet("""
QFrame {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
""")
self.setFrameShape(QFrame.StyledPanel)
def _setup_shadow(self):
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(20)
shadow.setOffset(0, 4)
shadow.setColor(QColor(0, 0, 0, 30))
self.setGraphicsEffect(shadow)
def add_widget(self, widget):
self._layout.addWidget(widget)
def add_layout(self, layout):
self._layout.addLayout(layout)
def add_stretch(self):
self._layout.addStretch()
# ==================== Windows风格加载动画 ====================
class WindowsLoader(QWidget):
"""
Windows 11风格加载动画
六个点依次亮起,形成流动效果
"""
def __init__(self, parent=None, size=40, color="#0067c0"):
super().__init__(parent)
self.size = size
self.color = color
self._timer = None
self._current_dot = 0
self._dots = 6
self.setFixedSize(size, size)
self._start_animation()
def _start_animation(self):
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_animation)
self._timer.start(80)
def _update_animation(self):
self._current_dot = (self._current_dot + 1) % self._dots
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
center_x = self.width() / 2
center_y = self.height() / 2
radius = self.width() * 0.35
dot_radius = self.width() * 0.08
for i in range(self._dots):
angle = (i / self._dots) * 2 * math.pi - math.pi / 2
x = int(center_x + radius * math.cos(angle))
y = int(center_y + radius * math.sin(angle))
offset = (i - self._current_dot) % self._dots
if offset == 0:
alpha = 255
scale = 1.3
elif offset == 1:
alpha = 180
scale = 1.1
elif offset == 2:
alpha = 120
scale = 1.0
else:
alpha = 60
scale = 0.9
color = QColor(self.color)
color.setAlpha(alpha)
painter.setBrush(QBrush(color))
painter.setPen(Qt.NoPen)
dot_size = int(max(1, dot_radius * scale))
painter.drawEllipse(x - dot_size, y - dot_size, dot_size * 2, dot_size * 2)
painter.end()
def stop(self):
if self._timer:
self._timer.stop()
def start(self):
if self._timer:
self._timer.start()
# ==================== 进度条加载动画 ====================
class ProgressLoader(QWidget):
"""
进度条样式加载动画
显示不确定进度的流动效果
"""
def __init__(self, parent=None, width=200, height=6, color="#0067c0"):
super().__init__(parent)
self.color = color
self._progress = 0
self._timer = None
self.setFixedSize(width, height)
self._start_animation()
def _start_animation(self):
self._timer = QTimer(self)
self._timer.timeout.connect(self._update_progress)
self._timer.start(30)
def _update_progress(self):
self._progress = (self._progress + 2) % 100
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w = self.width()
h = self.height()
bg_color = QColor("#e0e0e0")
painter.setBrush(QBrush(bg_color))
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(0, 0, w, h, 3, 3)
bar_width = int(w * 0.3)
x = int((self._progress / 100) * (w - bar_width))
gradient = QLinearGradient(x, 0, x + bar_width, 0)
gradient.setColorAt(0, QColor(self.color, 100))
gradient.setColorAt(0.5, QColor(self.color, 255))
gradient.setColorAt(1, QColor(self.color, 100))
painter.setBrush(QBrush(gradient))
painter.drawRoundedRect(x, 0, bar_width, h, 3, 3)
painter.end()
def stop(self):
if self._timer:
self._timer.stop()
def start(self):
if self._timer:
self._timer.start()
# ==================== 加载遮罩 ====================
class LoadingOverlay(QWidget):
"""加载遮罩,覆盖在父窗口上显示加载动画"""
def __init__(self, parent=None, text="加载中..."):
super().__init__(parent)
self.text = text
self._setup_ui()
self.hide()
def _setup_ui(self):
self.setAttribute(Qt.WA_TranslucentBackground)
self.setStyleSheet("background: transparent;")
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignCenter)
layout.setSpacing(16)
self.loader = WindowsLoader(size=60)
layout.addWidget(self.loader, 0, Qt.AlignCenter)
self.label = QLabel(self.text)
self.label.setStyleSheet("""
color: #0067c0;
font-size: 14px;
font-weight: 600;
background: transparent;
""")
self.label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.label)
def showEvent(self, event):
super().showEvent(event)
if self.parent():
self.setGeometry(self.parent().rect())
def set_text(self, text):
self.text = text
self.label.setText(text)
def start(self):
self.show()
self.loader.start()
def stop(self):
self.loader.stop()
self.hide()
# ==================== 状态标签 ====================
class StatusBadge(QLabel):
"""状态标签,用于显示各种状态"""
TYPE_SUCCESS = "success"
TYPE_WARNING = "warning"
TYPE_ERROR = "error"
TYPE_INFO = "info"
TYPE_NEUTRAL = "neutral"
def __init__(self, text="", badge_type=TYPE_INFO, parent=None):
super().__init__(text, parent)
self.badge_type = badge_type
self._setup_style()
def _setup_style(self):
colors = {
self.TYPE_SUCCESS: ("#107c10", "#e8f5e9"),
self.TYPE_WARNING: ("#ca5010", "#fff3e0"),
self.TYPE_ERROR: ("#c42b1c", "#ffebee"),
self.TYPE_INFO: ("#0067c0", "#e3f2fd"),
self.TYPE_NEUTRAL: ("#666666", "#f5f5f5"),
}
text_color, bg_color = colors.get(self.badge_type, colors[self.TYPE_INFO])
self.setStyleSheet(f"""
QLabel {{
background: {bg_color};
color: {text_color};
border: 1px solid {text_color};
border-radius: 10px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
}}
""")
self.setAlignment(Qt.AlignCenter)
def set_type(self, badge_type):
self.badge_type = badge_type
self._setup_style()
# ==================== 分割线 ====================
class Divider(QFrame):
"""分割线组件"""
HORIZONTAL = 0
VERTICAL = 1
def __init__(self, orientation=HORIZONTAL, parent=None):
super().__init__(parent)
if orientation == self.HORIZONTAL:
self.setFrameShape(QFrame.HLine)
self.setFixedHeight(1)
else:
self.setFrameShape(QFrame.VLine)
self.setFixedWidth(1)
self.setStyleSheet("background: #e0e0e0; border: none;")
self.setFrameShadow(QFrame.Plain)
# ==================== 工具函数 ====================
def create_primary_button(text, parent=None):
"""创建主按钮"""
return ModernButton(text, ModernButton.STYLE_PRIMARY, parent)
def create_success_button(text, parent=None):
"""创建成功按钮"""
return ModernButton(text, ModernButton.STYLE_SUCCESS, parent)
def create_warning_button(text, parent=None):
"""创建警告按钮"""
return ModernButton(text, ModernButton.STYLE_WARNING, parent)
def create_danger_button(text, parent=None):
"""创建危险按钮"""
return ModernButton(text, ModernButton.STYLE_DANGER, parent)
def create_ghost_button(text, parent=None):
"""创建幽灵按钮"""
return ModernButton(text, ModernButton.STYLE_GHOST, parent)
def create_neutral_button(text, parent=None):
"""创建中性按钮"""
return ModernButton(text, ModernButton.STYLE_NEUTRAL, parent)
def create_large_button(text, parent=None):
"""创建大按钮"""
return LargeButton(text, ModernButton.STYLE_PRIMARY, parent)