Files
countdown/countdown.py
xiaji 9879dee0de refactor: 重构倒计时应用程序代码结构
简化代码结构,移除冗余功能,优化状态管理逻辑
更新UI交互方式,修改微缩状态下的操作方式为双击
添加音频播放功能,使用QSoundEffect替代QMediaPlayer
优化配置对话框实现,简化设置项保存逻辑
2026-01-21 21:55:34 +08:00

394 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import os
from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QDialog, QFormLayout,
QSpinBox, QCheckBox, QSlider, QGraphicsOpacityEffect)
from PySide6.QtCore import Qt, QTimer, QPoint, QPropertyAnimation, QEasingCurve, QSequentialAnimationGroup, QVariantAnimation
from PySide6.QtGui import QFont, QColor, QMouseEvent
from PySide6.QtMultimedia import QSoundEffect
from PySide6.QtCore import QUrl
class ConfigDialog(QDialog):
def __init__(self, parent=None, config=None):
super().__init__(parent)
self.setWindowTitle("配置")
self.config = config
self.init_ui()
def init_ui(self):
layout = QFormLayout(self)
# 自定义倒计时
self.custom_time = QSpinBox()
self.custom_time.setRange(1, 3600)
self.custom_time.setValue(self.config['duration'])
btn_start = QPushButton("开始倒计时")
btn_start.setStyleSheet("background-color: #27ae60; color: white;")
btn_start.clicked.connect(self.start_countdown)
time_layout = QHBoxLayout()
time_layout.addWidget(self.custom_time)
time_layout.addWidget(btn_start)
time_widget = QWidget()
time_widget.setLayout(time_layout)
layout.addRow("自定义秒数:", time_widget)
# 提前告警
self.alarm_offset = QSpinBox()
self.alarm_offset.setRange(0, 300)
self.alarm_offset.setValue(self.config['alarm_offset'])
layout.addRow("提前告警(秒):", self.alarm_offset)
# 窗口置顶
self.stay_on_top = QCheckBox()
self.stay_on_top.setChecked(self.config['stay_on_top'])
layout.addRow("窗口置顶:", self.stay_on_top)
# 透明度
self.opacity = QSlider(Qt.Horizontal)
self.opacity.setRange(10, 100)
self.opacity.setValue(int(self.config['opacity'] * 100))
layout.addRow("透明度:", self.opacity)
# 微缩窗口位置
self.right_margin = QSpinBox()
self.right_margin.setRange(0, 1000)
self.right_margin.setValue(self.config['right_margin'])
layout.addRow("距离右边缘(像素):", self.right_margin)
self.top_margin = QSpinBox()
self.top_margin.setRange(0, 1000)
self.top_margin.setValue(self.config['top_margin'])
layout.addRow("距离上边缘(像素):", self.top_margin)
# 按钮组
btn_test = QPushButton("测试告警音")
btn_test.clicked.connect(self.parent().play_alarm)
layout.addRow(btn_test)
btn_exit = QPushButton("退出程序")
btn_exit.setStyleSheet("background-color: #ff4d4f; color: white;")
btn_exit.clicked.connect(QApplication.instance().quit)
layout.addRow(btn_exit)
save_btn = QPushButton("确认并保存")
save_btn.clicked.connect(self.save_and_close)
layout.addRow(save_btn)
def save_and_close(self):
self.config['duration'] = self.custom_time.value()
self.config['alarm_offset'] = self.alarm_offset.value()
self.config['stay_on_top'] = self.stay_on_top.isChecked()
self.config['opacity'] = self.opacity.value() / 100.0
self.config['right_margin'] = self.right_margin.value()
self.config['top_margin'] = self.top_margin.value()
self.accept()
def start_countdown(self):
self.save_and_close()
self.parent().start_countdown(self.custom_time.value())
class TimerApp(QWidget):
def __init__(self):
super().__init__()
self.config = {
'duration': 300,
'alarm_offset': 30,
'stay_on_top': True,
'opacity': 1.0,
'right_margin': 200, # 距离右边缘
'top_margin': 50 # 距离上边缘
}
self.remaining_time = 0
self.is_running = False
self.state = "NORMAL" # NORMAL or MINI
self.drag_position = QPoint()
self.init_ui()
self.setup_timers()
self.setup_audio()
self.apply_config()
def init_ui(self):
self.setWindowFlags(Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setStyleSheet("""
QWidget#MainFrame {
background-color: #2c3e50;
border-radius: 15px;
border: 2px solid #34495e;
}
QLabel { color: #ecf0f1; font-family: 'Segoe UI', Arial; }
QPushButton {
background-color: #3498db;
color: white;
border-radius: 5px;
padding: 8px;
font-weight: bold;
}
QPushButton:hover { background-color: #2980b9; }
""")
self.main_layout = QVBoxLayout(self)
self.frame = QWidget()
self.frame.setObjectName("MainFrame")
self.main_layout.addWidget(self.frame)
self.content_layout = QVBoxLayout(self.frame)
# 标题标签
self.label_title = QLabel("述职倒计时")
self.label_title.setAlignment(Qt.AlignCenter)
self.label_title.setFont(QFont("Microsoft YaHei", 24, QFont.Bold))
self.content_layout.addWidget(self.label_title)
# 倒计时显示
self.label_time = QLabel("00:00")
self.label_time.setAlignment(Qt.AlignCenter)
self.label_time.setFont(QFont("Consolas", 60, QFont.Bold))
self.label_time.setMinimumSize(120, 40) # 设置最小尺寸
self.label_time.setMaximumSize(480, 180) # 设置最大尺寸
self.content_layout.addWidget(self.label_time)
# 按钮区域
self.btn_area = QWidget()
self.btn_layout = QHBoxLayout(self.btn_area)
self.btn_5m = QPushButton("5分钟")
self.btn_6m = QPushButton("6分钟")
self.btn_other = QPushButton("其它")
self.btn_5m.clicked.connect(lambda: self.start_countdown(300))
self.btn_6m.clicked.connect(lambda: self.start_countdown(360))
self.btn_other.clicked.connect(self.open_config)
self.btn_layout.addWidget(self.btn_5m)
self.btn_layout.addWidget(self.btn_6m)
self.btn_layout.addWidget(self.btn_other)
self.content_layout.addWidget(self.btn_area)
self.set_normal_state()
def setup_timers(self):
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_countdown)
self.mini_timer = QTimer(self)
self.mini_timer.setSingleShot(True)
self.mini_timer.timeout.connect(self.set_mini_state)
def setup_audio(self):
self.sound = QSoundEffect()
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(__file__)
alarm_file = os.path.join(base_path, "alarm.wav")
if os.path.exists(alarm_file):
self.sound.setSource(QUrl.fromLocalFile(alarm_file))
self.sound.setLoopCount(3)
self.sound.setVolume(0.8)
def apply_config(self):
if self.config['stay_on_top']:
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
else:
self.setWindowFlags(self.windowFlags() & ~Qt.WindowStaysOnTopHint)
self.setWindowOpacity(self.config['opacity'])
self.show()
def set_normal_state(self):
self.state = "NORMAL"
# 解除固定尺寸限制
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
self.resize(500, 300)
self.btn_area.show()
self.label_title.show()
# 恢复布局边距
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.content_layout.setContentsMargins(10, 10, 10, 10)
self.label_time.setFont(QFont("Consolas", 60, QFont.Bold))
self.label_time.setFixedSize(480, 180)
self.center_on_screen()
def set_mini_state(self):
if not self.is_running:
return
self.state = "MINI"
self.btn_area.hide()
self.label_title.hide()
# 1. 关键将布局边距设为0否则控件会被挤出窗口
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.content_layout.setContentsMargins(5, 5, 5, 5) # 留一点小边距即可
# 2. 关键:先设置内部控件尺寸
self.label_time.setFont(QFont("Consolas", 18, QFont.Bold))
self.label_time.setFixedSize(110, 40)
# 3. 关键:强制设置整个窗口的固定大小
self.setFixedSize(120, 50)
# 定位
screen = QApplication.primaryScreen()
screen_geometry = screen.availableGeometry()
x = screen_geometry.width() - self.width() - self.config['right_margin']
y = self.config['top_margin']
self.move(x, y)
def center_on_screen(self):
screen = QApplication.primaryScreen()
screen_geometry = screen.availableGeometry()
x = (screen_geometry.width() - self.width()) // 2
y = (screen_geometry.height() - self.height()) // 2
self.move(x, y)
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
event.accept()
def mouseMoveEvent(self, event: QMouseEvent):
if event.buttons() == Qt.LeftButton and self.drag_position:
self.move(event.globalPosition().toPoint() - self.drag_position)
event.accept()
def mouseDoubleClickEvent(self, event: QMouseEvent):
if self.state == "MINI":
self.pause_countdown()
self.set_normal_state()
event.accept()
def start_countdown(self, seconds):
self.remaining_time = seconds
self.update_label()
self.timer.stop()
self.mini_timer.stop()
if hasattr(self, 'color_anim') and self.color_anim is not None:
self.color_anim.stop()
self.label_time.setStyleSheet("color: #ecf0f1;")
self.is_running = True
self.mini_timer.timeout.disconnect()
self.mini_timer.timeout.connect(self.start_timer)
self.mini_timer.start(2000)
self.set_mini_state()
def start_timer(self):
# 开始倒计时
self.timer.start(1000)
def pause_countdown(self):
self.timer.stop()
self.mini_timer.stop()
self.is_running = False
def update_countdown(self):
if self.remaining_time > 0:
self.remaining_time -= 1
self.update_label()
if self.remaining_time == self.config['alarm_offset']:
self.play_alarm()
else:
self.timer.stop()
self.is_running = False
self.show_finished_anim()
def update_label(self):
m, s = divmod(self.remaining_time, 60)
self.label_time.setText(f"{m:02d}:{s:02d}")
def play_alarm(self):
if hasattr(self, 'sound') and self.sound.source().isValid():
self.sound.play()
def show_finished_anim(self):
self.label_time.setText("时间已到")
if self.label_time.graphicsEffect():
self.label_time.graphicsEffect().deleteLater()
if self.state == "MINI":
self.color_anim = QVariantAnimation(self)
self.color_anim.setDuration(2000)
self.color_anim.setStartValue(QColor("#ff0000"))
self.color_anim.setKeyValueAt(0.5, QColor("#ffff00"))
self.color_anim.setEndValue(QColor("#ff0000"))
def update_style(color):
self.label_time.setStyleSheet(f"color: {color.name()};")
self.color_anim.valueChanged.connect(update_style)
self.color_anim.setLoopCount(-1)
self.color_anim.start()
else:
jump_offset = -20
self.jump_anim = QPropertyAnimation(self.label_time, b"pos")
self.jump_anim.setDuration(200)
curr_pos = self.label_time.pos()
self.jump_anim.setKeyValueAt(0, curr_pos)
self.jump_anim.setKeyValueAt(0.5, curr_pos + QPoint(0, jump_offset))
self.jump_anim.setKeyValueAt(1, curr_pos)
self.jump_anim.setEasingCurve(QEasingCurve.OutBounce)
opacity_effect = QGraphicsOpacityEffect(self.label_time)
self.label_time.setGraphicsEffect(opacity_effect)
self.flash_anim = QPropertyAnimation(opacity_effect, b"opacity")
self.flash_anim.setDuration(1000)
self.flash_anim.setStartValue(1.0)
self.flash_anim.setEndValue(0.3)
self.flash_anim.setLoopCount(-1)
self.anim_group = QSequentialAnimationGroup()
self.anim_group.addAnimation(self.jump_anim)
self.anim_group.addAnimation(self.flash_anim)
self.anim_group.start()
def open_config(self):
dialog = ConfigDialog(self, self.config)
if dialog.exec() == QDialog.Accepted:
self.apply_config()
custom_seconds = dialog.custom_time.value()
if custom_seconds != self.config['duration']:
self.start_countdown(custom_seconds)
def main():
app = QApplication(sys.argv)
app.setApplicationName("述职计时器")
# 设置窗口图标
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(__file__)
icon_file = os.path.join(base_path, "Timer.ico")
if os.path.exists(icon_file):
from PySide6.QtGui import QIcon
app.setWindowIcon(QIcon(icon_file))
timer_app = TimerApp()
timer_app.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()