
简介事件驱动编程是现代GUI应用的核心范式而C结合Qt框架则为此提供了成熟的落地方案。基于信号槽机制与对象树管理开发者能够高效构建响应式交互界面同时利用QSqlDatabase实现数据持久化。图书馆预约系统作为典型业务场景涉及资源冲突检测、状态流转和并发控制恰好能体现工程化设计价值。本文由基础概念切入系统讲解类模型拆分、预约流程的信号槽驱动、SQLite表结构与索引设计并针对打包发布给出可操作方案帮助读者快速掌握从零构建完整Qt应用的方法。1. 图书馆预约系统这个课设题为什么值得用 C 和 Qt 做图书馆预约系统往往是 C 课程设计里被选得最多的题目之一但很多实现停在控制台黑窗口输入一个学号、检查座位、输出“预约成功”就算交差。真实场景里图书馆预约面对的不只是单条查询还有座位与图书之间的冲突、过期未签到释放、不同时段重复预约等问题。把这些问题放到 C 和 Qt 的环境里才看得出工程化工具箱的价值Qt 的事件循环天然适合响应连续点击和状态变化它的对象树能帮你管理资源而 QSqlDatabase 又能让数据持久化不再靠手写文件读写。这套实现适合两类人。一类是想把 C 课设做成“可演示、可答辩”项目的在校生另一类是刚入职、需要快速上手 Qt 做业务界面的工程师。你不需要先掌握多少 Qt 高级特性能把 QMainWindow、QSqlTableModel、信号槽这三样用顺就能搭出一个功能完整、能跑起来的预约系统。下面会顺着这个标题里最核心的“C 和 Qt 结合”展开先讲类怎么拆再讲预约流程怎么用信号槽驱动然后解决数据库持久化和并发数据冲突最后把程序打包成可分发文件。每部分都会给可直接落地的代码和参数说明。2. 拆解预约系统的类模型与 Qt 对象树2.1 控制台到 GUI用 Qt 而不是纯 C 的理由纯 C 写控制台程序时输入、处理、输出都是串行的。预约系统一旦要求“用户点了某个座位立即看到颜色变灰”这种交互用命令行描述会非常别扭。Qt 带来的是事件驱动范式鼠标点击、列表选中、定时器到时都会变成事件投递到事件循环再由信号槽机制分发。预约系统里最常见的三个交互——“可选书检索”“预约登记”“取消预约”——本质上都是“UI 触发事件然后修改数据再刷新 UI”。Qt 的对象树是这里不能忽略的基础。当一个 QObject 以另一个 QObject 为 parent 时父对象析构会连带析构子对象。这意味着你在堆上用 new 创建多个窗口或对话框只要正确设置了 parent就不必逐一手动 delete。课设代码常犯的错误是到处写裸指针然后在窗口关闭后悬垂。用 Qt 的对象树管理界面组件再用标准 C 容器管理业务数据这就是“C 和 Qt 结合”的第一层正确姿势。另外如果你学过“C 覆盖隐藏”的区别在 Qt 里会很有用。子类重写父类的虚函数叫覆盖而定义同名非虚函数叫隐藏。在 Qt 中常见的一处隐藏陷阱是重写paintEvent时漏掉QWidget::paintEvent(event)。记住这个区别后面第 5 章做自定义列表绘制时就不会摸不着头脑。2.2 三个核心类User、Book、Reservation 的职责边界课程设计阶段类拆得太多容易绕晕太少又没法展示面向对象能力。我一般会拆成四个类User用户、Book图书、Seat座位、Reservation预约单再加一个DatabaseManager单例负责统一数据访问。前三个是纯 C 的实体类不依赖 Qt 的类型系统方便做单元测试DatabaseManager依赖 QSqlDatabase属于应用服务层。// user.h #include QString class User { public: User() default; User(QString id, QString name, QString role) : m_id(std::move(id)), m_name(std::move(name)), m_role(std::move(role)) {} QString id() const { return m_id; } QString name() const { return m_name; } QString role() const { return m_role; } private: QString m_id; QString m_name; QString m_role; };这里的QString是 Qt 的字符串类型它内部使用 UTF-16 编码能直接处理中文书名和姓名比std::string在界面展示上少很多编码转换麻烦。User只负责携带数据不负责校验学号格式或查询数据库。这样设计的好处是预约规则变化时不需要改动实体类只改服务层逻辑。Reservation则要携带预约状态常见的状态是预占pending、保留held、已借出borrowed、已取消cancelled、超时释放expired。状态用枚举表示而不是存中文串。原因是数据库和界面都要根据状态做分支判断枚举转字符串只在展示时进行避免某个状态写错一个字导致程序逻辑错乱。enum class ReservationStatus { Pending, Held, Borrowed, Cancelled, Expired }; class Reservation { public: Reservation(int userId, int bookId, QDateTime startTime, QDateTime endTime) : m_userId(userId), m_bookId(bookId), m_startTime(std::move(startTime)), m_endTime(std::move(endTime)) {} void setStatus(ReservationStatus status) { m_status status; } private: int m_userId; int m_bookId; QDateTime m_startTime; QDateTime m_endTime; ReservationStatus m_status ReservationStatus::Pending; };注意这里setStatus是一个普通方法而m_status用了默认成员初始化。这个做法比你期望的“每次构造后手动赋值”更安全因为所有构造函数分支都会拿到同一个初始状态。课设答辩时老师常问“预约单什么时候从 pending 变成 held”答案就是当用户到图书馆签到时扫描凭证后触发状态迁移而不是预约成功后立刻变为 held。这个状态机思路贯穿整个预约流程。2.3 数据层隔离与数据库封装真正操作数据库的代码不要散落在窗口类里。窗口类的职责是显示数据和接收事件如果每个按钮槽函数里都直接写QSqlQuery query;代码很快就会变成一锅粥。更好的做法是把所有 SQL 放在DatabaseManager类里窗口只调用db-createReservation(...)这样的高层接口。// databasemanager.h class DatabaseManager : public QObject { Q_OBJECT public: static DatabaseManager* instance(); bool initialize(const QString connectionName); bool createReservation(int userId, int bookId, const QDateTime start, const QDateTime end); QListReservation reservationsByUser(int userId); bool cancelReservation(int reservationId); signals: void dataChanged(); private: explicit DatabaseManager(QObject* parent nullptr); QSqlDatabase m_db; };用单例模式管理数据库连接是有争议的但在课程设计里它能避免多个窗口各自创建连接导致资源耗尽。initialize只负责打开 SQLite 数据库并建表dataChanged信号在数据写入成功后发出主界面收到后自动刷新列表。这就是“界面通过信号订阅数据变更”而不是“调用完 SQL 后手工刷新”的松耦合设计。这里有一个细节容易被忽略DatabaseManager继承了QObject所以它可以携带信号槽机制。如果它是纯 C 类就没法被注册到 Qt 元对象系统里也就不能发信号。这也是很多 Qt 项目会把“服务层类”继承QObject的原因。你不需要把所有类都变成QObject只需要让承担跨界面通知任务的类继承它。3. 预约主流程在 Qt 里的信号槽实现3.1 从列表选中到状态查询的信号链用户操作一条典型链路是主界面里有一个QTableView显示图书列表用户点中某一行再点击“预约”按钮。我们要把“选中了哪一行”这个信息从视图传到业务层。Qt 里QTableView没有直接给出“选中行的数据对象”而是通过选择模型selectionModel()告诉你是第几行哪一列。// mainwindow.cpp connect(ui-bookTable, QTableView::clicked, this, [this](const QModelIndex index) { int row index.row(); int bookId ui-bookTable-model()-index(row, 0).data(Qt::UserRole).toInt(); m_currentSelectedBookId bookId; });这里用到了 Lambda 表达式捕获this是 Qt 5 之后非常常见的写法。index.data(Qt::UserRole)表示从模型里取出业务数据而不是界面显示文本。显示文本可能被格式化成“ISBN - 书名”但UserRole存的是真正的图书主键。把主键和显示文本分离是预约系统里“选中之后操作准确”的关键。如果你在写这个连接时发现bookTable选中的行不是预期的那一行优先检查是不是使用了QSqlQueryModel而没有设置自定义角色映射。QSqlQueryModel默认只能通过Qt::DisplayRole取数据直接用Qt::UserRole会拿到无效QVariant。解决方法是继承QSqlQueryModel重写data()方法把隐藏列的值按UserRole返回。3.2 预约提交槽函数先查、后占、再写预约动作不能用户一点“预约”就直接插入一条预约记录。同一本书可能被不同人同时预约同一个时间段范围内也需要检查是否冲突。这里一定要执行“先查、后占、再写”的逻辑。void MainWindow::submitReservation() { if (m_currentSelectedBookId -1) { QMessageBox::warning(this, 预约失败, 请先在列表中选择一本书); return; } // 1. 查询该用户在目标时间段是否已有未结束的预约 QSqlQuery query(m_db); query.prepare(SELECT COUNT(*) FROM reservation WHERE user_id :uid AND end_time :start AND status NOT IN (cancelled, expired)); query.bindValue(:uid, m_currentUserId); query.bindValue(:start, ui-dateTimeStart-dateTime()); query.exec(); query.next(); int conflictCount query.value(0).toInt(); if (conflictCount 0) { QMessageBox::warning(this, 时间冲突, 该时段已有预约记录); return; } // 2. 插入预约记录 query.prepare(INSERT INTO reservation(user_id, book_id, start_time, end_time, status) VALUES(:uid, :book, :start, :end, pending)); query.bindValue(:uid, m_currentUserId); query.bindValue(:book, m_currentSelectedBookId); query.bindValue(:start, ui-dateTimeStart-dateTime().toString(Qt::ISODate)); query.bindValue(:end, ui-dateTimeEnd-dateTime().toString(Qt::ISODate)); if (!query.exec()) { QMessageBox::critical(this, 数据库错误, query.lastError().text()); return; } emit m_db-dataChanged(); }这段代码要解释一个关键点为什么冲突查询用的条件是end_time :start对比“两个区间重叠”的完整判断是start :end AND end :start。但因为这个系统里固定要求新预约只能从未来开始且已过期会单独处理所以省略start :end也不会让重叠漏进来。如果你们需求允许用户在已经开始的时间段里补登记就必须把两个条件都写全。课设答辩时把这个差异讲清楚比背代码强很多。prepare是 QSqlQuery 的参数绑定接口比直接字符串拼接 SQL 安全。即使不像公众网站那样面临恶意攻击参数绑定也能避免编码问题。比如时间字符串里带直接拼接会破坏引号结构绑定变量则把格式问题丢给 Qt 处理。3.3 线程安全与 UI 刷新让查询不卡窗口课程设计里的数据量不大很多同学就忽略阻塞问题。但预约系统一旦查询的书目属于“某张表几万行”在主线程里直接执行query.exec()会让界面卡住Windows 上还会出现“无响应”标题。常见做法是把耗时查询丢到线程里然后通过信号把结果带回 UI 线程。#include QtConcurrent/QtConcurrent void MainWindow::loadBooksAsync() { QFutureWatcherQListBook* watcher new QFutureWatcherQListBook(this); connect(watcher, QFutureWatcherQListBook::finished, this, [this, watcher]() { QListBook books watcher-result(); // 刷新 model watcher-deleteLater(); }); watcher-setFuture(QtConcurrent::run([this]() { return m_db-allBooks(); })); }核心原则是在线程里只能访问数据层方法不能直接操作任何QWidget。QtConcurrent::run会把 lambda 投递到线程池用QFutureWatcher监听结果。结束时删除 watcher避免内存泄漏。如果你只是做课设用这个模式查一次全部图书就够了以后做更复杂的系统可以用QThread管理自己需要常驻的线程。有人问 Qt 槽函数返回值怎么办。信号槽的常规调用方式不支持“槽函数返回值返回给发信号方”因为信号可以连多个槽返回值没有定义。如果你确实需要查询结果应该把结果作为槽函数的参数或者用上面这种 watcher 模式。这是 C Qt 面试题里常考的区分点。4. 用 SQLite 做持久化与预约配额控制4.1 表结构设计图书预约和座位预约分开还是一张表标题里“图书馆预约系统”一般涵盖两个场景预约图书到馆借阅以及预约图书馆座位。这俩的本质不是一回事。图书预约的粒度是“书目 时间 用户”座位预约的粒度是“座位编号 时间片 用户”且座位预约通常需要限定某个座位一天只能约一个时段。表设计时我建议分开建book_reservation和seat_reservation如果合并成一张reservation表要么产生很多空字段要么需要额外增加type字段来区分查询时还要拼条件。CREATE TABLE book_reservation ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, book_id INTEGER NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, status TEXT NOT NULL DEFAULT pending, create_time TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE TABLE seat_reservation ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, seat_no TEXT NOT NULL, reserved_date TEXT NOT NULL, begin_slot INTEGER NOT NULL, end_slot INTEGER NOT NULL, status TEXT NOT NULL DEFAULT pending );时间是存储为TEXT还是INTEGER取决于你查询的方式。SQLite 的时间函数可以直接处理TEXT类型但按日期范围查询时TEXT比较依赖格式统一建议全部用Qt::ISODate的格式保存比如2025-04-12T09:00:00。这样字符串比较和字典序一致BETWEEN查询不会出错。座位预约表里用reserved_date加上begin_slot和end_slot两个整数来代表人小时避免把上午、下午等时段散落成字符串。比如上午第一节课为 slot 1下午第一节课为 slot 5。冲突检测就变成了“相同日期下 begin_slot 小于别人的 end_slot 且 end_slot 大于别人的 begin_slot”。4.2 参数绑定与防注入课程设计的 SQL 经常是拼字符串拼出来的这会导致两个问题中文内容拼接出错和 SQL 注入。哪怕你是做单机课设也应该养成参数绑定的习惯。看看下面反例和正例的对比。// 不推荐容易出引号问题 QString sql QString(INSERT INTO book_reservation(user_id, book_id) VALUES(%1, %2)) .arg(userId).arg(bookId); // 推荐参数绑定 QSqlQuery query; query.prepare(INSERT INTO book_reservation(user_id, book_id, start_time, end_time, status) VALUES(?, ?, ?, ?, pending)); query.addBindValue(userId); query.addBindValue(bookId); query.addBindValue(startTime.toString(Qt::ISODate)); query.addBindValue(endTime.toString(Qt::ISODate)); query.exec();arg()做字符串替换时不负责处理引号和特殊字符。如果bookId来自外部输入构造出的 SQL 可能被注入别的子句。prepare和bindValue会把参数交给驱动做转义这是 Qt 跨数据库时的通用安全边界。另一个细节默认QSqlDatabase的每个连接在同一时刻只能执行一个QSqlQuery如果你在同一段代码里写了两个 query 对象但都指向同一个连接且第一个还没finish()第二个exec在某些驱动上会失败。解决方法是为不同模块建立不同连接或者在查询开始前把上一轮结果存进容器。4.3 索引、事务与并发冲突的兜底预约系统的查询热点有两个按用户查预约记录、按时间段查冲突。这两个字段组合起来建复合索引比较划算CREATE INDEX idx_book_res_user_time ON book_reservation(user_id, start_time); CREATE INDEX idx_book_res_book_time ON book_reservation(book_id, start_time); CREATE INDEX idx_seat_res_slot ON seat_reservation(reserved_date, begin_slot, end_slot);索引不是建越多越好。这个系统里单条插入和单个用户查询占主流复合索引能把随机查询变成索引扫描但会拖慢插入速度。课设量级里这种消耗可忽略不过为了答辩能讲清楚你应该说明索引为什么建在WHERE条件出现的字段上。并发冲突需要靠事务来兜底。两个用户同时预约同一个座位理论上是后提交失败。SQLite 默认串行化写事务所以直接用“先查后写”也会有问题两个事务都查到了一个空档然后先后插入第二条可能不报错但实际重复。要彻底解决需要给座位预约表加业务唯一约束。常见做法是增加一个slot_range字段用逗号拼接时段再建唯一索引。QString slotRange QString(%1-%2).arg(beginSlot).arg(endSlot); query.prepare(INSERT INTO seat_reservation(user_id, seat_no, reserved_date, slot_range) VALUES(?, ?, ?, ?)); query.addBindValue(userId); query.addBindValue(seatNo); query.addBindValue(dateStr); query.addBindValue(slotRange);然后在表结构里加UNIQUE(seat_no, reserved_date, slot_range)这样数据库会在物理层拒绝重复插入。即使你的业务层漏了检查数据库也会兜底。课设里把这一招写出来老师会认为你真的理解“并发控制”。5. 界面打磨与发布为可运行的压缩包5.1 QSS 定制与布局细节QSS 是 Qt 的样式表语言语法接近 CSS。预约系统在演示时最出效果的是“座位占用界面”可以用 QSS 把不同状态的按钮涂上不同颜色。#seatButton[statusfree] { background-color: #a5d6a7; border-radius: 6px; } #seatButton[statusoccupied] { background-color: #ef9a9a; border-radius: 6px; }[statusfree]是 Qt 样式表里的属性选择器前提是用setProperty(status, free)给按钮设置动态属性。属性变化后还需要调用style()-unpolish(btn); style()-polish(btn);刷新样式否则新属性不会马上生效。这个刷新技巧比整个窗口重新加载快得多。5.2 windeployqt 打包与平台插件路径坑发布是最后一步也是很多同学在这个题目下卡壳的地方。在 Windows 上用 Qt 自带工具打包最常见的问题是运行目标程序时弹出 “qt_qpa_platform_plugin_path” 相关的错误。这通常是platforms路径没有放在可执行文件旁边或者系统环境变量里残留了别的 Qt 版本路径。我一般这样处理先用 Qt 对应的命令行环境比如x64 Native Tools Command Prompt进入构建目录再执行windeployqt --release --no-translations LibraryReservation.exewindeployqt会把 Qt 的 DLL、插件目录、QSS 相关资源自动复制到 exe 同级目录。注意参数里加了--no-translations如果系统需要中英文切换则去掉这个参数。打包完成后记得手动检查platforms/qwindows.dll存在这是 GUI 程序启动的基本插件。还有一种情况是你本机安装过多个 Qt 版本。如果你用 Qt 5.15.2 编译却用 Qt 6 的 windeployqt 打包会出现各种版本不匹配。解决办法是打开开始菜单里对应版本 Qt 的命令行或者在命令行里先执行set PATHC:\Qt\5.15.2\msvc2019_64\bin;%PATH%再执行打包命令。5.3 预约情况可视化的一个技巧最后一个具体技巧是给图书预约列表实现“状态颜色条”。不要用复杂的自定义控件直接利用QSqlQueryModel的data()重写给Qt::BackgroundRole返回颜色即可。QVariant data(const QModelIndex index, int role) const override { if (role Qt::BackgroundRole) { QSqlRecord record sqlite_record(index.row()); QString status record.value(status).toString(); if (status pending) return QColor(#fff3cd); if (status held) return QColor(#d1ecf1); if (status cancelled) return QColor(#e2e3e5); } return QSqlQueryModel::data(index, role); }这个sqlite_record是 QSqlQueryModel 内部取整行数据的函数注意它需要在query().isActive()时调用否则会返回空记录。把状态翻译成颜色后用户扫一眼就能看出哪些预约还没处理。你还可以在此基础上做二次筛选单击某个状态列头按 status 排序。这样整个预约系统从数据表到界面反馈都形成一个闭环最后打包成.zip交作业时里面同时包含源码、可执行文件、README和建表脚本才算一个完整的课程设计成果。本文还有配套的精品资源点击获取