Qt个人日程管理桌面应用实战:SQLite存储与定时提醒完整实现 简介基于Qt框架的C个人日程管理程序源码与编译包面向需要学习Qt桌面应用开发的入门与中级开发者帮助解决日程记录、提醒设置、待办管理等常见事务处理需求。包内含完整工程文件与可执行程序共89个文件核心代码部分包含15个cpp源文件、15个h头文件、6个ui界面设计文件另有pro/sln工程配置、pdb调试信息、exe可执行程序及多个dat数据文件压缩包约10.83MB。已有469人学习下载。通过该资源可系统掌握QCalendarWidget日历展示、QTimer定时提醒、模型视图架构与SQLite数据持久化等关键实现工程内登录注册、任务添加、日程列表、数据存储等模块划分清晰辅以可直接运行的exe与测试数据文件便于对照源码理解Qt信号槽机制、布局管理与数据库交互适合作为课程设计或个人项目二次开发的参考基底。1. 从日程爆炸到桌面提醒这个 Qt 项目把「处理事务」做成了一套闭环工作日的上午手机里的日程提醒 App 和电脑上的日历各自为政会议、报销、周报、取快递散落在三个工具里漏掉一件就足以让半天节奏乱掉。这个「Qt 个人日程安排处理事务」项目解决的就是这个最具体的痛点——用 Qt Widgets 做一个桌面端的日程管理工具把「记录事项 → 设定时间 → 到期提醒 → 标记完成 → 查看历史」串成一条完整链路。它不是又一个待办清单 Demo而是把日程当作带状态机的事务对象来处理新建、编辑、删除、提醒、完成、归档每一件事都有明确的流转路径数据落在本地 SQLite 里重启不丢。适合想学 Qt 界面与业务逻辑怎么分层、想把 C 桌面应用真正做成能日常使用工具的开发者也适合拿它当骨架去接自己的提醒逻辑或数据同步。2. 项目定位与技术选型为什么是 Qt Widgets 而不是 QML数据为什么落 SQLite2.1 技术栈的取舍逻辑这个项目选的是 Qt Widgets C17而不是 QML/Qt Quick。原因很现实日程管理这种工具型软件界面元素无非是表格、日历、树形列表、按钮、输入框Widgets 的 QTableView、QCalendarWidget、QSystemTrayIcon 全都是现成且成熟的控件配合 QSS 改样式比 QML 里调布局省事得多。如果换 QML动画和触屏交互会更顺但这种「处理事务」类应用并不需要花哨的转场稳定性和可维护性优先级更高。数据层选了 SQLite通过 Qt 的 QSqlDatabase 模块访问。不选 JSON 文件或纯内存存模型的原因是日程数据天然有「按时间查询」「按状态过滤」「修改历史」需求SQL 的 WHERE 和 ORDER BY 直接解决不需要在代码里手写遍历。SQLite 又不需要独立安装服务端打包时带一个数据库文件即可个人桌面应用非常合适。项目里默认把数据库文件放在用户目录的 AppData 路径下这比丢在 exe 同目录更符合桌面软件习惯因为程序更新时不会误删用户数据。2.2 整体架构界面与业务分离的三层结构这个项目不是把所有逻辑塞进 MainWindow而是拆成了三层界面层MainWindow、各种 Dialog、业务层ScheduleManager 服务类、数据层SqliteStorage 封装。界面层只负责用户交互和数据展示不直接写 SQL业务层处理日程的增删改查、提醒触发、状态流转数据层负责连接数据库和执行 SQL。这样拆的好处是未来如果要把数据换成 MySQL或者把界面从 Widgets 换成 Web改动可以局限在某一层内。目录结构大致是qt-schedule-manager/ ├── src/ │ ├── main.cpp │ ├── MainWindow.h / MainWindow.cpp │ ├── ScheduleManager.h / ScheduleManager.cpp │ ├── SqliteStorage.h / SqliteStorage.cpp │ ├── ScheduleItem.h │ ├── RemindDialog.h / RemindDialog.cpp │ ├── AddScheduleDialog.h / AddScheduleDialog.cpp │ └── models/ │ ├── ScheduleTableModel.h / ScheduleTableModel.cpp ├── resources/ │ ├── app.qrc │ └── icons/ ├── schedule.db (运行时生成) ├── CMakeLists.txt └── README.mdScheduleItem 是核心数据结构用 struct 而非 class因为它的职责只是承载字段id、title、description、startTime、endTime、remindTime、priority、status、category。status 用枚举定义包含 Pending、Done、Expired、Cancelled 四种这样在 SQL 里存整数而不是字符串避免拼写错误也方便 ORM 化。2.3 ScheduleManager业务核心怎么设计ScheduleManager 是唯一被 MainWindow 依赖的业务类它内部持有 SqliteStorage 的实例。对外暴露的接口大致如下class ScheduleManager : public QObject { Q_OBJECT public: explicit ScheduleManager(QObject *parent nullptr); ~ScheduleManager(); bool initialize(); // 创建数据库连接和表结构 QVectorScheduleItem getAllSchedules(); // 返回全部日程按时间排序 QVectorScheduleItem getSchedulesByDate(const QDate date); QVectorScheduleItem getSchedulesByStatus(int status); bool addSchedule(const ScheduleItem item); // 新增 / 更新 bool updateSchedule(const ScheduleItem item); bool deleteSchedule(int id); bool markAsDone(int id); signals: void scheduleAdded(int id); void scheduleUpdated(int id); void scheduleDeleted(int id); void scheduleReminded(const ScheduleItem item); private: void checkReminders(); // 供定时器调用检查到期提醒 SqliteStorage *m_storage; QTimer *m_remindTimer; };这里有个设计细节值得注意所有写操作返回 bool 而不是 void。原因是 SQLite 的写入有可能因磁盘满、数据库锁等原因失败把结果返回给界面层便于在捕获到失败时弹出 QMessageBox 提示。信号 scheduleReminded 是给 RemindDialog 用的它监听这个信号有日程到期就弹出提醒窗口同时系统托盘也会闪烁图标。3. 核心模块拆解一个日程从录入到提醒再到归档的完整链路3.1 录入流程AddScheduleDialog 的校验与数据组装用户点击「新增日程」后MainWindow 会 new 一个 AddScheduleDialog这是个模态对话框。对话框里主要控件包括标题输入框、描述文本框、开始时间/结束时间QDateTimeEdit、提前提醒时间QComboBox选项为 0/5/10/30/60 分钟、优先级QComboBox高/中/低、分类QComboBox工作/生活/学习/其他、状态默认为待处理。用户点击确定时对话框做三件事校验、组项、返回结果。校验逻辑写在一个独立的 validate() 方法里bool AddScheduleDialog::validate() { if (ui-titleEdit-text().trimmed().isEmpty()) { QMessageBox::warning(this, 校验失败, 标题不能为空); return false; } if (ui-startEdit-dateTime() ui-endEdit-dateTime()) { QMessageBox::warning(this, 校验失败, 结束时间必须晚于开始时间); return false; } if (ui-remindCombo-currentIndex() 0) { QDateTime remindTime ui-startEdit-dateTime().addSecs( -ui-remindCombo-currentData().toInt() * 60); if (remindTime QDateTime::currentDateTime()) { QMessageBox::warning(this, 校验失败, 提醒时间不能早于当前时间); return false; } } return true; }这里的核心是「数据有效性优先于用户体验」哪怕界面允许用户随便填业务层也不该接脏数据。提醒时间的计算方式是「开始时间减去提前量」所以如果用户设定的是一个即将开始的事务提前量过大就会导致提醒时间已经过去这种情况必须拦截。校验通过后成员变量 m_item 会被填充对话框的 exec() 返回 QDialog::AcceptedMainWindow 拿到 m_item 后调用 manager-addSchedule()。这里没有让 Dialog 直接操作 manager而是通过返回值让 MainWindow 去调用保持界面互相解耦。3.2 存储实现SQLite 建表与参数化写入SqliteStorage 的 initialize() 方法负责打开数据库和建表。表结构设计如下CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(200) NOT NULL, description TEXT, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, remind_time DATETIME, priority INTEGER DEFAULT 1, status INTEGER DEFAULT 0, category VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );时间字段用 DATETIME 存 ISO 字符串比如 2026-05-18T09:30:00。不用 UNIX 时间戳的原因是这个项目的查询经常涉及「某一天的所有日程」ISO 字符串前缀比较可以直接用 LIKE 2026-05-18%可读性比时间戳强。当然如果数据量到百万级前缀 LIKE 会有性能问题但对个人日程来说完全够用。写入时用 QSqlQuery 的 preparebindValue 方式杜绝字符串拼接 SQLbool SqliteStorage::insertSchedule(const ScheduleItem item) { QSqlQuery query(m_db); query.prepare(INSERT INTO schedules (title, description, start_time, end_time, remind_time, priority, status, category) VALUES (?, ?, ?, ?, ?, ?, ?, ?)); query.addBindValue(item.title); query.addBindValue(item.description); query.addBindValue(item.startTime.toString(Qt::ISODate)); query.addBindValue(item.endTime.toString(Qt::ISODate)); query.addBindValue(item.remindTime.toString(Qt::ISODate)); query.addBindValue(static_castint(item.priority)); query.addBindValue(static_castint(item.status)); query.addBindValue(item.category); if (!query.exec()) { qWarning() 插入日程失败: query.lastError().text(); return false; } return true; }参数化为什么不只是好习惯而是必须如果直接拼 SQL 字符串用户输入内容里带单引号就会破坏语句结构甚至产生 SQL 注入风险。这个项目里所有用户可控文本都走了 bindValue这是写 Qt 数据库程序的最低底线。3.3 列表展示ScheduleTableModel 与 QTableView 如何绑定MainWindow 主界面左侧是日期列表或日历右侧是 QTableView 展示当日或全部日程。表格的数据模型继承 QAbstractTableModel而不是直接用 QSqlTableModel。原因有二一是 QSqlTableModel 虽然少写代码但排序、状态过滤、自定义显示文本把 status 的整数映射成「待处理/已完成/已过期」都要绕很多弯子二是自定义 Model 能更好地配合 QVector 做缓存。核心的 data() 重写方法如下QVariant ScheduleTableModel::data(const QModelIndex index, int role) const { if (!index.isValid() || index.row() m_schedules.size()) return QVariant(); const ScheduleItem item m_schedules.at(index.row()); if (role Qt::DisplayRole) { switch (index.column()) { case 0: return item.title; case 1: return item.startTime.toString(yyyy-MM-dd HH:mm); case 2: return item.endTime.toString(yyyy-MM-dd HH:mm); case 3: return priorityText(item.priority); case 4: return statusText(item.status); case 5: return item.category; } } else if (role Qt::BackgroundRole) { if (item.status ScheduleItem::Expired) return QColor(255, 235, 235); if (item.status ScheduleItem::Done) return QColor(235, 255, 235); if (item.priority ScheduleItem::High) return QColor(255, 250, 230); } else if (role Qt::TextAlignmentRole) { return Qt::AlignCenter; } return QVariant(); }这段代码把数据和显示逻辑抽离Model 里不出现「已完成」这种中文字符串而是通过 statusText() 映射。这样如果以后要做多语言只需要改 statusText() 一个函数。背景色这里用的是「偏低饱和的颜色」避免高饱和色导致文字看不清。所有颜色判断都放在 BackgroundRole 分支你可以随时关闭或调整。3.4 提醒触发机制QTimer 轮询的节奏控制提醒功能是这个项目最容易被忽视又最容易翻车的地方。常见做法是「进入程序启动一个每秒触发的 QTimer每次遍历所有日程发现到了提醒时间就弹窗」。但这样做有两个问题线程阻塞导致 UI 卡顿以及同一条提醒被触发多次。这个项目的处理方式是先按分钟粒度轮询而不是按秒。在 ScheduleManager 里m_remindTimer 的 interval 设为 60000 毫秒也就是每分钟走一次检查void ScheduleManager::checkReminders() { QDateTime now QDateTime::currentDateTime(); QVectorScheduleItem pending m_storage-fetchPendingReminders(now); for (const ScheduleItem item : pending) { QSetint notified m_notifiedIds; if (notified.contains(item.id)) continue; notified.insert(item.id); emit scheduleReminded(item); m_storage-markRemindAsNotified(item.id); } }代码中 markRemindAsNotified 是「幂等触发」的关键它把 remind_time 标记为已提醒。边界场景分析用户把日程从下午 3 点改到下午 5 点此时旧的提醒已经触发过且已标记改时间后 remind_time 变化应重新允许提醒——所以字段设计时需要在 updateSchedule 里把 notified 标记清零另一种边界是软件没启动日程时间点错过了那启动后的第一次轮询要不要补一条记录这里不做补提醒因为个人日程管理里「已错过」靠状态显示为已过期来兜底而不是靠事后弹窗弹窗反而是一种骚扰。4. 运行与配置从拿到源码到系统托盘出现提醒4.1 环境准备与构建指令项目是基于 CMake 构建的 Qt Widgets 工程。需要的前提环境如下表依赖版本建议说明Qt5.15 或 6.x推荐用 Qt 6.2 以上范例使用 C17CMake3.16Qt6 要求相对高一些编译器MSVC 2019 / MinGW / ClangWindows 推荐 MSVCLinux 用 GCC 9SQLiteQt 自带驱动确认 Qt 安装时勾选 SQL 模块拿到源码后先检查 CMakeLists 里的 Qt 模块列表。以 Qt6 为例核心部分长这样cmake_minimum_required(VERSION 3.16) project(ScheduleManager VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Widgets Sql ) add_executable(ScheduleManager src/main.cpp src/MainWindow.cpp src/ScheduleManager.cpp src/SqliteStorage.cpp src/AddScheduleDialog.cpp src/RemindDialog.cpp src/models/ScheduleTableModel.cpp resources/app.qrc ) target_link_libraries(ScheduleManager PRIVATE Qt6::Widgets Qt6::Sql)在 Windows 上最简单的构建路径是打开 Qt 的命令行环境如「Qt 6.5.0 (MSVC 2019 64-bit)」然后依次执行mkdir build cd build cmake .. -G Ninja -DCMAKE_BUILD_TYPERelease cmake --build . --config Release注意这里的 -G Ninja 不是必须的如果使用 Visual Studio 生成器cmake 会自动生成 .sln 解决方案用 VS 打开直接点本地 Windows 调试器也能跑。构建完成后会生成 ScheduleManager.exe首次运行会在用户目录自动创建 schedule.db无需手动建数据文件。4.2 数据库初始化失败时的排查路径SqliteStorage 初始化失败是跑起来时最容易遇到的问题表现为程序启动后弹数据库连接错误或所有界面加载正常但增删改查无反应。排查路径按以下顺序走第一确认 Qt 安装时勾选了 SQL 模块。在 Qt 安装目录下找 sqldrivers 文件夹看里面有没有 qsqlite.dllWindows或 libqsqlite 相关文件。没有这个 DLLQSqlDatabase::addDatabase 会提示 driver not loaded。我一般在 CMake 里临时加一行 message 打印 QSqlDatabase::drivers()看输出列表里有没有 QSQLITE。第二检查数据库文件是否可写。项目里初始化路径在 QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) 下如果这个路径不存在且程序没有提前创建会返回 false。核实一下 QDir().mkpath(dir) 是否执行了以及应用程序是否有该目录的写权限。第三排查数据库锁问题如果之前崩溃导致 journal 文件残留SQLite 会尝试恢复但可能因为权限问题恢复失败。最简单粗暴的办法是退出程序后删掉同目录下所有 .journal 文件再启动项目不会出现脏数据丢失问题因为日程数据本身不频繁写入。4.3 系统托盘和桌面提醒的配置注意点主窗口关闭时默认会退出程序但日程管理工具的理想行为是「关窗口缩到托盘后台继续跑提醒」。实现上需要 override MainWindow 的 closeEventvoid MainWindow::closeEvent(QCloseEvent *event) { if (m_trayIcon-isVisible() !m_isQuitting) { hide(); event-ignore(); } else { event-accept(); } }因为窗口 hide() 后 QApplication 不会自动退QSystemTrayIcon 会留在系统托盘。真正退出需要加一个「退出程序」的托盘菜单项里面调用 qApp-quit()。这一个环节如果漏掉用户勾掉任务栏窗口后以为程序退了结果托盘图标还在而且每周数据库文件还在增长这是桌面向导软件最常见的「假退出」问题。5. 避坑与常见问题排查日程不提醒、时间错乱、界面卡顿这些坑我都踩过5.1 提醒弹窗反复出现像复读机一样停不下来现象某条日程的提醒时间到了以后弹出提醒对话框点击关闭后一分钟内又弹无限循环。原因ScheduleManager 的 notifiedIds 是存在内存的 QSet如果程序重启或者 notifierId 没有持久化到数据库每次轮询都会重新触发。解决在 SqliteStorage 里加一个字段 notify_flag每次触发提醒后 UPDATE 该字段为 1查询可提醒日程时只查 notify_flag 0 的。我的习惯是把「是否已提醒」作为表字段而不是运行时变量因为程序随时可能被系统强制杀掉。5.2 日期时间显示比系统时间早 8 小时现象数据库里存的时间是 14:00显示出来变成 06:00。原因QDateTime::toString(Qt::ISODate) 默认用本地时间但某些 Qt 版本或系统区域设置下 toString 走的是 UTC。更常见的是从数据库读出来时没指定时区QDateTime::fromString 默认认为是本地时间。解决统一在写入时用 item.startTime.toLocalTime().toString(Qt::ISODate)读取时用 QDateTime::fromString(str, Qt::ISODate) 后不额外设置时区。设计规格数据库里存「逝去世界的绝对时刻」还是「本地钟表读数」这里选了后者因为个人日程没有跨时区开会场景。5.3 日程列表数据量大时上下滚动卡顿现象日程数量到 500 条以上滚动表格有明显迟滞。原因数据模型没有做懒加载每次用户滚动到新区域data() 方法都会去 QVector 里取数据这本身还好但如果 MainWindow 在 model 的 dataChanged 信号里又触发了一次全表刷新就会出现 O(n) 滚动性能。解决第一只在数据库增删改后发送 beginInsertRows / endInsertRows 或 dataChanged不要每次刷新都 resets。第二对已过滤的 QVector 做局部排序QTableView 的 setSortingEnabled 打开后sortByColumn 会自行整理不需要每次都从 SQL 层重查。5.4 在 Linux 上中文输入法无法输入标题现象AddScheduleDialog 的 QLineEdit 里敲不了中文英文能输入。原因大多数情况是 Qt 的输入法插件没加载或者程序用了非 ascii 编码的字体对话框。解决在 main.cpp 里设置 QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)检查 Qt 平台插件 libqinputcontextplugin.so 是否在 plugins/platforminputcontexts 目录如果还不行在 CMake 里强制链接 Qt6::Gui 的 inputcontexts 插件。这只影响输入法不影响显示所以之前有需求的中文用户反馈「能显示中文日程但输入中文就翻车」。5.5 CMake 构建时找不到 Qt 模块现象提示找不到 Qt6Widgets / Qt6::Widgets 或 include 路径不对。原因CMAKE_PREFIX_PATH 没设或者 find_package 里写的 Qt6 但系统装的是 Qt5。解决在 CMakeLists 最前面加 set(CMAKE_PREFIX_PATH C:/Qt/6.5.0/msvc2019_64)或从「Qt 的命令行」打开 CMake那样环境变量自动带好。不要一边用 Qt5 的安装目录一边用 find_package(Qt6) 这样对不上的配置报错会非常隐晦。6. 二次开发与改造思路把这套骨架扩展成你的定制提醒引擎到了二次开发阶段建议按三步走第一步做日志第二步做导入导出第三步换提醒方式。先说日志——这个项目现在最缺的是操作留痕加一个简单的 SqliteTable 命名为 schedule_log记录 id、action、time、description在 ScheduleManager 的增删改函数里各插一行日志。理由很现实你会想知道为什么某条日程被误删了以及数据从哪一天开始乱的。第二步做 iCalendar.ics导出是最常见的需求。实现思路也很直白遍历当前列表的 ScheduleItem按 RFC 5545 拼成文本写到 .ics 文件这样就能和手机日历互通。小技巧是 ICS 时间格式为「UTC 时间 Z 后缀」比直接写本地时钟更通用但若是仅在本机导入导出保持一致即可。你可以给 MainWindow 加一个「导出」的 QAction弹出 QFileDialog::getSaveFileName用户选路径后走 ExportTask 函数。导入则反过来读 .ics 并按字段映射回 ScheduleItem注意处理打卡类重复日程。第三步是提醒方式的改造。现在的弹窗提醒只停留在界面层如果你想接到企业微信机器人、邮箱或系统通知核心改动集中在 ScheduleManager 的 remindTriggered 信号之后。我一般会用一个 ReminderDispatcher 类接收 scheduleReminded然后根据 item.priority 分流高优先级弹窗 发邮件低优先级只弹托盘气泡。邮件发送用 QNetworkAccessManager 走 SendGrid API不用 SMTP 是因为 smtp 库要引入额外依赖API 方式代码量更小。改造完后的验证方式也简单把系统时间往后调 2 分钟看提醒是否在预期时间点触发日志表里 record 是否新增。从那以后我每次改提醒逻辑都会强制走一遍「改时间验证 查日志 重启应用再验证」三个动作把遗漏的触发状态问题扼杀在测试环境里而不是等用户来反馈「为什么又没提醒」。这个项目的价值不在于它多复杂而在于每一个环节都给了你改的空间想换数据层、换交互、加推送都是从一个小口子切入不伤筋动骨。希望帮到你。本文还有配套的精品资源点击获取