
定时提醒小助手2.0是我最近花时间重写的一款本地待办提醒工具它的三个关键词很直白半透明、指定时间、语音提示。说白了就是在桌面上放一块几乎不占地方的半透明面板你往里丢几条待办事项设定好时间到点它就用系统语音开口提醒你而不是像普通弹窗一样悄无声息地消失。这版2.0重点解决了上一版“提醒太轻、界面太吵、数据不可靠”三个问题也让整个工具从“一个脚本”变成了“一款真正能每天挂机运行的桌面软件”。如果你经常用待办清单却总是错过提醒或者想做一款小而美的桌面小工具这篇内容会比较适合你。我先说个背景我自己习惯每天列待办但市面上的待办工具大多走“账号同步多端App”路线为了记一件事还得打开手机、等同步。而本地桌面工具又往往只有简单弹窗提醒弹在角落里根本没注意。所以这款软件的核心定位一直是本地优先、离线可用、到点开口不搞复杂功能。2.0新增的半透明常驻界面和聚合语音播报都是为了一个目标——让提醒真的被看见、被听见。1. 项目定位与整体方案设计1.1 核心需求拆解从“弹窗通知”到“音画同振”做这个项目之前我先把手头几个常用提醒工具的缺点列了一下基本集中在三件事上。第一是提醒形式太轻。系统通知弹出来过几秒自动消失人还坐在屏幕前盯着文档可注意力根本没分给那条通知。第二是操作成本太高。很多工具要做完一次提醒得先打开App、登录、等同步再新建待办。第三是缺少语音通道。人眼在专注工作时很难分神去看屏幕但耳朵是可以接收信息的合适的语音播报能强制把注意力拉回来。所以2.0的功能需求其实很明确可以整理成一张功能矩阵功能模块具体需求优先级待办管理添加、编辑、删除、标记完成必须定时规则指定时间触发、每日重复、间隔重复必须提醒方式半透明弹窗 语音播报同时触发核心特色常驻体验托盘运行、开机自启、置顶显示必要数据存储本地保存重启不丢必须多端同步账号体系、云同步2.0不做这个版本我明确砍掉了多端同步和自然语言解析。原因很简单单机场景下本地SQLite足够可靠删掉同步逻辑可以让项目保持轻量也不会出现“服务器挂了提醒就失效”的荒诞情况。自然语言解析则属于体验增强放到后续版本再看。小工具的规划逻辑就应该是这样先把稳定性和核心提醒链路打磨好再做加分项。1.2 技术选型为什么仍然选Python PySide6关于桌面工具的选型我在几个方案之间犹豫过一阵。Electron那套做半透明界面确实好看但启动速度和安装包体积都太“豪华”了一个提醒工具打包出来上百兆实在说不过去。Tauri轻量很多但调试成本高而且Windows下的系统语音调用绕一层系统桥出问题不好排查。C#的WPF很合适不过跨平台就差一些Mac和Linux用户没法直接用。最终我选了Python加PySide6的方案。四个原因PySide6对半透明窗口有原生支持WA_TranslucentBackground加上无边框标志就能做出毛玻璃效果不需要额外引第三方库。语音模块直接调系统TTS引擎离线可用延迟低不依赖网络。Python的开发速度快改需求、写脚本、跑实验都方便尤其是初期频繁调整提醒逻辑时改完就能跑。项目维护成本低。哪怕用户不懂代码也能自己打开脚本改播报模板、调透明度。当然Python方案也有明显问题比如打包体积大、性能上限低。但用在待办提醒这类低负载场景里这些缺点几乎不构成影响。合理的工程选择不是“用最强的语言”而是“用最匹配场景的方案”。1.3 界面与交互设计半透明不是为了炫技半透明界面经常被误解成视觉效果其实放在常驻桌面工具里它更多是一种交互策略。如果提醒面板完全不透明放在右下角就会挡内容如果做成纯透明信息又看不清。半透明的意义是降低视觉侵入感让面板一直存在、但不打断当前工作。2.0的界面设计遵循几个原则面板默认透明度在0.6到0.7之间文字保持高对比保证一瞥就能读清。鼠标滚轮可以直接调节透明度临时需要专注时调低需要看清时调高。窗口支持无边框拖拽可以贴靠在屏幕边缘双击标题栏切换置顶状态。提醒触发时面板边框会短暂高亮配合语音形成“音画同振”的效果。我测试过很多透明度数值低于0.45之后文字可读性明显下降高于0.85又和普通窗口没区别。所以默认0.65是一个比较舒服的平衡点同时允许用户自行调节上下限。2. 功能拆解与核心技术点2.1 待办事项管理用SQLite把数据固定住待办管理是整个软件的数据底座。这个版本我坚定不移地选了SQLite而不是JSON文件。很多人觉得“就一个提醒工具用JSON存就够了”但实际用起来待办数据会被反复读写、修改状态JSON在频繁写入和进程异常退出时很容易损坏。SQLite有事务和崩溃恢复机制抗折腾能力强得多。数据库表设计很直接一张todos表就够CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, remind_at TEXT NOT NULL, remind_type TEXT DEFAULT once, repeat_interval INTEGER DEFAULT 0, priority INTEGER DEFAULT 1, status TEXT DEFAULT pending, created_at TEXT DEFAULT (datetime(now, localtime)), reminded_at TEXT, completed_at TEXT ); CREATE INDEX idx_todos_due ON todos(status, remind_at);字段说明remind_typeonce表示单次daily表示每日重复interval表示按固定间隔重复。repeat_interval配合interval类型使用单位是分钟。比如每隔90分钟提醒一次。statuspending待提醒done已完成expired过期未处理。这里有个设计上容易犯的错误很多人会把“每日重复”做成“提醒完之后把状态改回pending”这样看起来简单但一旦中间断了一天历史记录就乱了而且无法判断这条待办当天是否真的提醒过。我采用的做法是单次任务一旦触发生成一条新的pending记录旧记录状态改为done并记录completed_at。这样每天的任务都有独立记录统计和补发都方便。时间格式建议统一用%Y-%m-%d %H:%M:%S字符串存储不要为了“省几个字节”去存时间戳。虽然字符串看起来占空间但可读性好调试排查时直接用SQL就能看明白不需要层层转换。2.2 定时调度引擎单次定时器加到期重算定时调度是最容易出问题的模块也是整个软件的灵魂。很多新手会写一个死循环每秒查一次数据库这个方案不是不行只是太粗暴。每秒轮询不仅持续占CPU还会频繁唤醒系统而且在系统休眠恢复后容易出现定时不准的连锁问题。2.0的调度引擎换成了“单次定时器加到期重算”方案。核心思路就是每次都只算下一个最近的提醒时间给QTimer设置一个单次触发触发后再从数据库拉取下一批到期任务循环往复。代码骨架大概是这样的class Scheduler(QObject): remind_triggered Signal(list) def __init__(self): super().__init__() self.timer QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.schedule_next) def schedule_next(self): now datetime.now() due_items fetch_due_todos(now) if due_items: self.remind_triggered.emit(due_items) mark_reminded(due_items) next_time fetch_next_deadline(now) if next_time is None: self.timer.stop() return delay_ms int((next_time - datetime.now()).total_seconds() * 1000) if delay_ms 0: delay_ms 0 self.timer.start(delay_ms)这个方案的好处很明显没有定时器的时间不需要占用CPU触发时机也精确到毫秒级。还有几点细节必须注意如果下一条提醒在24小时以上delay_ms可能超过部分系统定时器的上限。虽然QTimer在多数平台上支持很大的间隔但为了稳妥可以分段启动先休眠较短间隔再重算。系统休眠是调度的天敌。休眠期间QTimer不会触发唤醒后可能一次性涌入一堆到期任务。所以不仅要启动时重算最好在系统唤醒事件里也强制调用一次schedule_next用墙钟时间重新校准。任务触发后不能只更新内存状态必须写回数据库。因为语音播报和UI展示用的都是一个数据源数据源一旦不一致后面补发、统计都会错乱。2.3 语音提示模块让待办事项真正“开口”语音提示是这次升级的重点。上一版只有弹窗效果太差这一版我用pyttsx3调用系统内置的TTS引擎。选它有两个原因一是完全离线不依赖任何网络服务隐私和安全都更可控二是延迟低触发后几十毫秒就能开口。语音播报的内容模板很重要。最初我写的是“到时间了请处理你的待办事项”实验了几轮发现这样的播报信息量太低。后来改成“先报总数再逐条念标题”的聚合模式import pyttsx3 class Speaker: def __init__(self): self.engine pyttsx3.init() self.engine.setProperty(rate, 180) self.engine.setProperty(volume, 0.9) def announce_todos(self, todos): if not todos: return now datetime.now().strftime(%H:%M) lines [f现在是{now}你有{len(todos)}项待办需要处理。] for i, item in enumerate(todos, 1): lines.append(f第{i}项{item[content]}) text 。.join(lines) self.engine.say(text) self.engine.runAndWait()播报示例就是“现在是09:30你有3项待办需要处理。第1项给客户回复方案邮件。第2项整理项目周报。第3项预约会议室。”这个模板看起来简单实际打磨过很多次。一开始是一件事播报一件事连续三条语音要播几分钟听的人根本没耐心播到第三条已经分了神。改成聚合播报后用户能先知道总量再选择性接收细节注意力浪费小很多。还有一个必须踩的坑runAndWait是阻塞调用直接放在主线程会让整个UI卡死。解决方式也简单把播报放到一个单独的QThread里主线程只负责接收信号和更新界面状态。另外如果语音引擎初始化失败、或者机器上没有任何可用语音要自动降级成弹窗提醒加系统提示音保证提醒不会因为TTS故障而丢失。2.4 半透明置顶窗口Qt样式表与无边框的配合半透明窗口的核心代码并不复杂难点在于组合配置。窗口要用无边框、置顶、工具窗口三种标志一起配合少任何一个都会出问题class TipWindow(QWidget): def __init__(self): super().__init__() self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setStyleSheet( QWidget#root { background-color: rgba(10, 12, 16, 0.65); border-radius: 10px; } )三个标志位各有用处FramelessWindowHint去掉系统边框否则无法实现圆角和半透明。WindowStaysOnTopHint让面板始终置顶保证提醒弹出时能被看到。Tool让窗口不占用任务栏位置更像一个小挂件而不是一个独立应用。WA_TranslucentBackground是让窗口背景真正透明的前提。没有这个属性直接设置半透明样式表窗口底层往往是黑色或者灰色矩形非常难看。有了它之后再配合样式表里rgba背景色和圆角视觉上才是一块圆角半透明面板。无边框窗口有个副作用是不能拖动需要自己实现鼠标事件。我的做法是在mousePressEvent里记录按下位置在mouseMoveEvent里移动窗口def mousePressEvent(self, event): if event.button() Qt.MouseButton.LeftButton: self._drag_start event.globalPosition().toPoint() - self.frameGeometry().topLeft() event.accept() def mouseMoveEvent(self, event): if event.buttons() Qt.MouseButton.LeftButton: self.move(event.globalPosition().toPoint() - self._drag_start) event.accept()滚动调节透明度则通过重写wheelEvent实现每次滚动将root的background-color透明度增减几个百分点并限制在0.45到0.85之间。这块看似小功能但实测下来是用户反馈最多、最喜欢的小细节。3. 从零复现构建一个可用的2.0版本3.1 项目骨架与环境准备整个项目文件不多我按功能拆成几个模块timer_helper/ ├── main.py # 程序入口负责QApplication和信号连接 ├── db.py # SQLite初始化与待办增删改查 ├── scheduler.py # 定时调度器 ├── speaker.py # 语音播报模块 ├── ui_main.py # 半透明主界面 └── assets/ # 图标、样式等环境准备就三步。建议用虚拟环境避免污染全局的Python环境mkdir timer_helper cd timer_helper python -m venv venv source venv/bin/activate # 桌面操作系统下也可能直接进入虚拟环境目录的Scripts文件夹 pip install PySide6 pyttsx3这里有两个容易踩的坑一是不要在QApplication创建之前就初始化UI或者语音引擎PySide6的很多组件依赖QApplication实例二是在某些Linux环境下需要额外安装TTS语音包否则pyttsx3.init()会直接报错。Windows环境一般开箱即用系统自带语音引擎就能工作。3.2 数据库模块增删改查与到期查询db.py里最核心的是两个查询函数。一个是查到期任务一个是查下一条即将到期的任务import sqlite3 from datetime import datetime DB_PATH todos.db def fetch_due_todos(now): now_str now.strftime(%Y-%m-%d %H:%M:%S) conn sqlite3.connect(DB_PATH) rows conn.execute( SELECT * FROM todos WHERE statuspending AND remind_at ?, (now_str,) ).fetchall() conn.close() return [dict(row) for row in rows] def fetch_next_deadline(now): now_str now.strftime(%Y-%m-%d %H:%M:%S) conn sqlite3.connect(DB_PATH) row conn.execute( SELECT MIN(remind_at) FROM todos WHERE statuspending AND remind_at ?, (now_str,) ).fetchone() conn.close() return parse_time(row[0]) if row and row[0] else None注意连接数据库要记得关闭。项目规模小很多人不在乎连接管理但长期运行的桌面程序一旦连接泄漏数据库文件会被锁住到后面连写入都失败。插入待办时还要处理重复规则。如果是daily类型插入时先用当天的日期算出目标时间如果是interval类型就根据当前时间和间隔生成未来的remind_at。这块逻辑不复杂但字段之间容易互相冲突我的建议是写一个统一的create_todo(content, remind_at, remind_type, repeat_interval)函数所有入口都走这一个函数不可以在UI里直接拼接SQL。3.3 调度器与语音播报联动调度器触发后难点在于不阻塞UI、又能完成“标记已提醒、生成下一轮重复任务、播报”这一连串动作。我的实现是让调度器发出remind_triggered信号界面层收到信号后先刷新列表再启动一个后台播报线程class ReminderApp: def __init__(self): self.scheduler Scheduler() self.scheduler.remind_triggered.connect(self.on_remind) self.speaker Speaker() self.speaker_thread None def on_remind(self, todos): self.ui.refresh_list() self.speaker_thread threading.Thread(targetself.speaker.announce_todos, args(todos,)) self.speaker_thread.start()这里用threading.Thread而不是每次都创建QThread是因为语音播报是一次性任务用完就结束没必要引入线程生命周期管理。但在关闭程序时主线程里要join一下这个线程避免语音还没播完程序就退出。还有一点如果同一时间涌入多批任务语音线程会叠在一起。我在模块里加了一个is_speaking标志如果当前已经在播报新任务只记入待播队列等上一轮播完再取最新的队列内容。这样不会有多个声音同时开口的灾难。3.4 半透明界面主面板、待办列表、快捷添加主界面的布局不复杂顶部是标题栏中间是待办列表底部是快捷输入框和按钮。因为这个面板是常驻桌面角落的所以整体高度控制在300到400像素之间宽度克制在280到320像素避免挡住太多屏幕。界面里最需要注意的坑是样式表的作用域。半透明背景不要设置在主窗口上而是设置在主窗口内的一个根QWidget上。因为主窗口本身一旦设置了不透明背景WA_TranslucentBackground就失效了。代码结构就是上面给的根QWidget使用rgba背景、圆角列表和文字放在这个根容器上。另外待办列表本身要透明。QListWidget默认有白色背景如果不设置透明样式它会在半透明面板上盖出一块白底self.list_widget.setStyleSheet( QListWidget { background: transparent; border: none; } QListWidget::item { color: #F2F3F5; padding: 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } )快捷添加框用QLineEdit按回车就插入待办。输入框里可以做一个简单解析如果包含“明天”“下午3点”之类的关键词就自动转换成具体时间如果只是普通文字则默认使用快捷输入框右侧的时间控件来定时。这些解析逻辑不放进2.0核心但做一层轻量的支持会让日常使用舒服很多。3.5 打包与开机自启设置调试完成后用PyInstaller打包。命令里有两个隐藏导入必须要写否则打包出来的程序在别的机器上可能无法语音播报pyinstaller -F -w --hidden-importpyttsx3.drivers --hidden-importpyttsx3.drivers.sapi5 -i assets/app.ico main.py-F生成单个可执行文件-w不显示控制台窗口。打包产物通常会到几十兆这是Python方案绕不过去的体积但换来的是开发和维护效率可以接受。开机自启我用了最稳妥的“启动文件夹”方案。往当前用户的启动目录放一个快捷方式不需要管理员权限也不会被安全软件拦。实现思路是获取用户主目录下的启动文件夹路径然后把打包后的exe路径写进一个.lnk快捷方式文件。如果有版本更新直接替换启动目录里的快捷方式指向即可。4. 实测过程中踩过的坑与排查思路4.1 定时为什么不准了第一个版本我用的是“每秒轮询数据库”方案结果发现两个问题第一程序挂着的时候CPU占用虽然不高但每次查询都会唤醒数据库整体耗电偏高第二系统休眠恢复后轮询点会跳过导致一批提醒在唤醒后同时弹出来。换用单次QTimer之后定时精度提升非常明显。但还有一个坑是事件循环阻塞。如果主线程里做了耗时操作比如语音播报没放线程QTimer的定时信号就会被排队看起来就像定时器没有触发。所以我把所有可能阻塞的调用全部放到了线程里保证主线程的事件循环始终通畅。还有一个隐蔽问题某些系统上QTimer的间隔设置成很大值时会溢出。实测中有一条跨了30天的待办计算出的毫秒数超过了32位整数范围。解决方法是拆段定时比如延时超过一天就分成多段每段最多12小时触发后重新计算和重设。4.2 语音播报为什么不出声或者破音语音模块常见的故障有三个。第一是系统里没有任何可用的中文语音。此时pyttsx3虽然能初始化但say之后毫无反应。排查方法很简单写一个枚举脚本看看当前有哪些语音import pyttsx3 engine pyttsx3.init() for voice in engine.getProperty(voices): print(voice.id, voice.name)如果列表是空的就要去系统设置里安装对应语言的基础语音包。第二是音量太小或被系统静音。我最初把播放音量卡在0.9结果在部分设备上听起来很闷。后来改成播报前先查询系统默认音量如果低于某个阈值就临时抬高播完再恢复。这个逻辑比较适合提醒场景既能保证被听到又不会永久改动用户的音量设置。第三是语速太快导致“破音”。TTS引擎在语速超过200后中英文混排的内容经常会吞字。我最后把语速固定在170到185之间在清晰度和效率之间取平衡。4.3 半透明背景变成纯黑或纯白矩形我刚开始写半透明窗口时犯了一个很典型的错误直接把样式表的background-color设在了主窗口上结果窗口底部是一块黑色矩形圆角和透明度都没有。原因是setAttribute(WA_TranslucentBackground)生效后主窗口本身需要保持透明绘制工作应该交给子控件。我的处理方式是让主窗口不设置任何背景在界面里单独放一个QWidget作为根容器所有背景、圆角、阴影都写在根容器的样式表里。子控件可以继续使用普通样式不必担心透明穿透。另外如果调试时发现整个窗口完全看不见先检查一下窗口大小是否为零或者窗口坐标是否跑到屏幕外去了。这类问题不是样式的问题而是布局错误。4.4 程序关闭后待办丢失这个坑出现在我用相对路径创建数据库的时候。PyInstaller打包后程序的工作目录可能和预期不一样导致SQLite文件被创建到了临时目录里程序一关文件就被清理所有待办消失。解决方法是让数据库路径固定指向用户主目录下的应用配置目录不随工作目录变化import os from pathlib import Path APP_DIR Path.home() / .timer_helper APP_DIR.mkdir(exist_okTrue) DB_PATH str(APP_DIR / todos.db)这样即便程序被安装到任意位置待办数据都会稳定落在同一个地方。还有个小习惯每天首次启动时对自己做一次数据库完整性检查用PRAGMA integrity_check如果发现损坏就自动备份旧文件并重建这事我在版本迭代中遇到过几次提前加了这个保护之后省了很多事。4.5 常见问题速查表问题可能原因处理方式到点不提醒调度器线程阻塞或QTimer被延迟检查主线程是否卡顿耗时任务放到线程语音无声音系统没有可用语音、音量被静音枚举voices安装语言包播放前检测音量只弹窗不播报TTS初始化失败初始化时加异常捕获失败时降级为系统提示音半透明背景发黑主窗口直接设置了不透明背景改用根QWidget承载背景样式保持主窗口透明面板被其他窗口遮挡置顶标志丢失检查WindowStaysOnTopHint是否被重置必要时重设程序退出后待办丢失数据库路径在工作目录数据库固定到用户主目录的应用配置目录重复任务不续期触发后没有生成下一条记录在状态流转时先插入新任务再标记旧任务完成开机自启失效快捷方式指向了旧路径重新生成启动目录快捷方式确认exe路径无变化表格里最后一条我在实测中真遇到过打包新版本后自启快捷方式还指向上一次编译的旧exe路径旧文件被删掉之后启动目录就失效了。所以打包流程里开机自启快捷方式要在安装完成后重新刷新而不是只创建一次。我自己用下来最大的感受是提醒工具的核心不是功能列表长短而是稳定性。到点必须响隔天不能丢这两件事做到位比任何花哨的界面都重要。半透明界面只是让人愿意把它挂在桌面上真正留住用户的是每一次提醒都恰到好处。如果继续做3.0我大概率会优先加跨设备同步和自然语言输入但从2.0这个版本看本地方案已经能覆盖绝大多数日常待办场景值得每个写小工具的开发者动手复刻一次。