C++/Qt图书馆预约系统开发实战:状态机与事务控制

📅 发布时间:2026/9/16 14:51:36
C++/Qt图书馆预约系统开发实战:状态机与事务控制
简介基于C与Qt框架打造的图书馆预约系统课程设计项目兼顾C/C课程设计与毕业设计需求适合高校计算机相关专业学生入门Qt桌面应用开发。项目源码已在本地编译通过下载后按配套文档配置环境即可运行整体难度适中内容经过助教老师审定可作为可直接参考的完整样例。压缩包共26个文件主要包含8个C源文件、7个头文件、5个Qt界面文件.ui、工程配置文件.pro、资源文件.qrc及图片素材另附README说明文档与演示动图整体仅63KB目录结构清晰按登录、注册、学生管理、教师管理等模块划分便于快速定位学习。配套源码涵盖注册登录、学生预约、教师审核、数据库连接等关键环节可帮助读者掌握Qt信号槽、界面布局与MySQL基本操作并理解小型管理系统前后台交互逻辑。目前已有132人学习下载适合需要快速上手Qt桌面开发、开展课设二次修改的学习者参考使用。1. 用 CQt 实现图书馆预约系统先把状态机画明白图书馆预约系统的课程设计最容易拖到答辩前夜的问题不是按钮不好看而是同一本书被两个读者同时抢到。一个看起来完整的界面背后需要同时维护图书状态、预约单状态和读者在借数量任何一个状态没写对演示时就变成“点预约成功查列表里却没有”。基于 C 和 Qt 的桌面方案优势是不依赖容器和网络服务用 QWidgets 做界面、QSqlDatabase 接 SQLite几个类就能把预约闭环跑通。本文按课程设计常见的验收路径从表结构、界面交互、并发控制到 windeployqt 打包部署把可复现的代码和参数拆出来。适合要用 Qt 5.15/6.x 交课设的读者也想系统梳理桌面管理端常见坑的开发者。2. 基于 Qt SQL 的图书馆预约系统数据建模表结构和 SQLite 连接2.1 图书表、读者表、预约表三张表把业务状态装完图书馆预约系统的核心不是界面而是数据模型。课程设计里最常见的做法是用三张表books 保存图书基础信息和当前状态readers 保存读者信息reservations 保存预约和借阅记录。三张表之间用外键关联就能覆盖“检索图书—提交预约—到馆取书—归还”的完整流程。表关键字段状态说明booksid, title, author, isbn, location, statusstatus0 可预约1 已预约2 已借出readersid, card_no, name, phone, max_borrowcard_no 唯一用于登录和检索reservationsid, book_id, reader_id, reserve_date, deadline, statusstatus0 待取书1 已借出2 已取消3 已过期4 已归还为什么一定要有 status 字段因为图书列表要实时显示“可预约/已预约/已借出”读者要能按状态筛选。如果在界面上临时计算每次刷新都要做多表联查课设演示时容易出现结果不一致。把状态冗余在 books 表里换取的是查询简单代价是更新时要保证两个表同时提交这正好引出了后面的事务设计。reservations 表兼任预约单和借阅记录取书后把状态从 0 改成 1归还后改成 4不需要额外建 borrow_record 表。需要注意的是这种设计下同一本书的同一读者只有一条有效预约历史预约记录会被覆盖。如果想要完整历史可以再加一个 record_type 字段区分“预约记录”和“借阅记录”但课程设计阶段没必要为这个设计增加复杂度。2.2 用 QSqlDatabase 打开 SQLite驱动名和连接名最容易被忽略Qt 操作 SQLite 的代码很短但有两个参数经常让新手卡住第一个是驱动名必须写成QSQLITE第二个是 addDatabase 的第二个参数连接名。连接名不写时使用的是默认连接单窗口项目没问题后面如果要加后台线程就必须给每个线程独立的连接名否则会出现“database is locked”或崩溃。bool initDatabase(const QString dbPath) { QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE, library_conn); db.setDatabaseName(dbPath); if (!db.open()) { qWarning() open database failed: db.lastError().text(); return false; } QSqlQuery query(db); query.exec(PRAGMA foreign_keys ON); query.exec(PRAGMA journal_mode WAL); query.exec(PRAGMA busy_timeout 1000); const QString sql R( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT, isbn TEXT, location TEXT, status INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS readers ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_no TEXT UNIQUE NOT NULL, name TEXT NOT NULL, phone TEXT, max_borrow INTEGER NOT NULL DEFAULT 5 ); CREATE TABLE IF NOT EXISTS reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id INTEGER NOT NULL, reader_id INTEGER NOT NULL, reserve_date DATETIME NOT NULL, deadline DATETIME NOT NULL, status INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(book_id) REFERENCES books(id), FOREIGN KEY(reader_id) REFERENCES readers(id) ); ); bool ok query.exec(sql); if (!ok) { qWarning() create table failed: query.lastError().text(); return false; } return true; }这段代码里有三个 PRAGMA 值得解释。foreign_keys ON是让 SQLite 启用外键约束否则建表语句里的 REFERENCES 只是装饰。journal_mode WAL是让 SQLite 使用 Write-Ahead Logging读操作不会阻塞写操作在预约系统这种“频繁查书、偶尔写预约单”的场景下收益明显。busy_timeout 1000表示数据库被占用时最多等待 1000 毫秒而不是立刻返回“database is locked”。初始化时用 addDatabase 指定了library_conn后续业务代码取连接的方式是QSqlDatabase db QSqlDatabase::database(library_conn);如果看到Driver not loaded说明 Qt 的 SQLite 插件没有部署常见于只拷贝了 exe 而没有拷贝 sqldrivers 目录。这个问题到第五章打包时会再次遇到。2.3 预约动作必须放进事务先改书的状态再插入预约单预约一个动作要改两张表books.status 从 0 改成 1reservations 插入一条记录。如果先改书、插入预约单失败书就变成“已预约”但没有任何预约记录如果先插入预约单、改书失败就会出现一条没有对应图书状态的预约记录。所以这两步必须包在同一个事务里要么都成功要么都回滚。bool reserveBook(QSqlDatabase db, int bookId, int readerId, int minutes) { if (!db.transaction()) { qWarning() transaction start failed: db.lastError().text(); return false; } QSqlQuery q(db); // 关键WHERE status 0 是并发安全的抢锁条件 q.prepare(UPDATE books SET status 1 WHERE id ? AND status 0); q.addBindValue(bookId); bool ok q.exec() q.numRowsAffected() 1; if (!ok) { db.rollback(); return false; } QDateTime now QDateTime::currentDateTime(); QDateTime deadline now.addSecs(minutes * 60); q.prepare(INSERT INTO reservations(book_id, reader_id, reserve_date, deadline, status) VALUES(?, ?, ?, ?, 0)); q.addBindValue(bookId); q.addBindValue(readerId); q.addBindValue(now); q.addBindValue(deadline); ok q.exec(); if (!ok) { db.rollback(); return false; } return db.commit(); }这里的numRowsAffected() 1是整段代码的精华。UPDATE books SET status 1 WHERE status 0在 SQLite 里是原子操作即使两个读者同时提交也只有一条 UPDATE 能影响到一行另一个人得到的影响行数是 0。用影响行数判断是否成功比先 SELECT 查状态再 UPDATE 要可靠得多这也是后续并发控制的基础。minutes参数是预约保留时长课程设计里可以做成可配置项比如默认 30 分钟。到馆取书后把 reservation.status 改成 1同时把 book.status 改成 2归还时恢复 books 表为 0并设置 reservation.status 4。这些操作同样要走事务只是在 UPDATE 时不再带status 0条件而是带reservation.id和当前状态条件。3. CQt 的界面交互搜索过滤、预约按钮和槽函数设计3.1 主窗口布局用 QSplitter 把图书列表和预约信息分开Qt Widgets 做管理系统的常见布局是顶部放搜索条件中间放表格底部放状态栏和操作按钮。如果预约系统还有一个“我的预约”面板可以用 QSplitter 把窗口切成左右两部分左侧是图书列表右侧是当前选中图书的详情和预约按钮。void MainWindow::setupUi() { auto *central new QWidget(this); auto *outerLayout new QVBoxLayout(central); auto *searchEdit new QLineEdit(central); searchEdit-setPlaceholderText(输入书名、作者或 ISBN); auto *splitter new QSplitter(Qt::Horizontal, central); auto *leftPanel new QWidget(splitter); auto *rightPanel new QWidget(splitter); auto *leftLayout new QVBoxLayout(leftPanel); leftLayout-addWidget(searchEdit); bookTable new QTableView(leftPanel); bookTable-setSelectionBehavior(QAbstractItemView::SelectRows); bookTable-setSelectionMode(QAbstractItemView::SingleSelection); bookTable-setEditTriggers(QAbstractItemView::NoEditTriggers); leftLayout-addWidget(bookTable); rightPanel-setMinimumWidth(280); auto *rightLayout new QVBoxLayout(rightPanel); infoLabel new QLabel(rightPanel); reserveBtn new QPushButton(预约选中图书, rightPanel); rightLayout-addWidget(infoLabel); rightLayout-addWidget(reserveBtn); rightLayout-addStretch(); splitter-addWidget(leftPanel); splitter-addWidget(rightPanel); outerLayout-addWidget(splitter); setCentralWidget(central); }布局代码的要点是给 QTableView 设置只读和整行选中。管理员在表格里直接改数据库不是好设计课程设计的演示场景通常是“选中一行点击右侧按钮”所以把编辑行为关掉减少误操作。右侧面板的infoLabel用来显示当前选中图书的标题、馆藏位置和状态让读者在点击预约前能确认。3.2 搜索不用每次敲字都查库QSortFilterProxyModel 做内存过滤如果图书馆系统只有几千条测试数据最懒的做法是在textChanged信号里重查数据库。但每次按键都执行 SQL数据库连接频繁切换界面容易出现顿挫。更合适的做法是先用 QSqlQueryModel 从数据库加载一次数据再放在 qSortFilterProxy 模型里做内存过滤。class LibraryFilterModel : public QSortFilterProxyModel { Q_OBJECT protected: bool filterAcceptsRow(int sourceRow, const QModelIndex sourceParent) const override { QModelIndex root sourceModel()-index(sourceRow, 0, sourceParent); QString needle filterRegularExpression().pattern().toLower(); if (needle.isEmpty()) return true; for (int col 1; col 4; col) { QString cell root.siblingAtColumn(col).data().toString().toLower(); if (cell.contains(needle)) return true; } return false; } };connect 之后的过滤代码非常简单searchModel new LibraryFilterModel(this); searchModel-setSourceModel(sqlModel); bookTable-setModel(searchModel); connect(searchEdit, QLineEdit::textChanged, this, [this](const QString text) { QRegularExpression re(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption); searchModel-setFilterRegularExpression(re); });这个方案有几个好处数据库只查一次过滤在内存里完成过滤条件同时覆盖书名、作者、ISBN 和馆藏位置。filterRegularExpression可以处理中文输入注意要用QRegularExpression::escape把用户输入转义否则输入括号或星号会被当成正则元字符。课程设计里如果不想自定义代理模型也可以用 QSqlQueryModel 的setQuery重新查询但演示时打字速度一快就会看到数据库端反复触发锁等待。3.3 预约按钮的槽函数槽函数返回 void结果通过界面状态反馈预约按钮点击后要做的动作是从当前选中行取 book_id调用 2.3 的 reserveBook 函数根据返回值更新状态栏或弹窗。这里有一个 Qt 初学者常问的问题槽函数能不能返回值connect 的调用机制决定了槽函数一般写成void返回值在跨线程或队列连接时没有意义。如果需要知道“预约是否成功”用返回值不靠谱直接在槽函数内部处理结果。void MainWindow::onReserveButtonClicked() { QModelIndex current bookTable-currentIndex(); if (!current.isValid()) return; int bookId current.siblingAtColumn(0).data().toInt(); QString title current.siblingAtColumn(1).data().toString(); QSqlDatabase db QSqlDatabase::database(library_conn); if (reserveBook(db, bookId, currentReaderId, 30)) { statusLabel-setText(QString(《%1》预约成功请在 30 分钟内取书).arg(title)); refreshBookList(); } else { QMessageBox::warning(this, 预约失败, 该书可能已被其他人预约或借出请刷新列表后重试。); } }current.siblingAtColumn(0)是 QModelIndex 在多列表格里取同行其他列数据的标准写法。从 0 列取 id每列列的顺序由 SQL 查询里的 SELECT 顺序决定所以需要记住数据模型的列映射关系。在界面层直接操作数据库是课程设计可以接受的写法如果工程再复杂一些应该把这个槽函数里的业务逻辑抽到 ReservationService 类里让 UI 只负责调用服务层方法。3.4 用 tr() 包一层界面文案Qt 国际化只差三条命令图书馆预约系统如果要交中英文两个版本每处文案都写死成中文就麻烦了。Qt 官方的做法是用tr()包裹可翻译字符串再用 lupdate、linguist、lrelease 三个工具生成翻译文件。reserveBtn-setText(tr(Reserve Selected Book)); statusLabel-setText(tr(Reservation succeeded));工程文件里加上翻译文件配置TRANSLATIONS lang/library_zh_CN.ts生成和发布翻译文件的命令序列步骤命令产物提取源码里的 tr 字符串lupdate LibraryReservation.pro -ts lang/library_zh_CN.ts.ts XML 文件用 Linguist 人工翻译linguist lang/library_zh_CN.ts修改后的 .ts 文件编译成 Qt 可读格式lrelease lang/library_zh_CN.ts -qm lang/library_zh_CN.qm.qm 二进制文件程序启动时加载 .qm 文件QTranslator translator; if (translator.load(:/lang/library_zh_CN.qm)) { QCoreApplication::installTranslator(translator); }注意点tr() 只能作用于 QObject 子类或 QObject 的成员函数里普通工具类要用QCoreApplication::translate()。课程设计一般只要中英文切换在启动时根据QLocale::system()选择加载哪个 .qm 文件就能覆盖大部分需求。4. 图书馆预约系统的并发控制与 Qt 多线程先搞清楚数据库归哪个线程4.1 并发预约的经典坑先 SELECT 再 UPDATE 一定会出问题课堂作业里很多人会这样写预约逻辑先查 books 表如果 status 是 0再执行 UPDATE 改成 1。这在单用户操作时没有问题一旦两个读者同时打开同一个图书详情页都查到了 status0然后各自执行 UPDATE后一次会覆盖前一次最终图书状态是 1但预约表里出现两条记录。// 错误写法check-then-act QSqlQuery select(db); select.prepare(SELECT status FROM books WHERE id ?); select.addBindValue(bookId); select.exec(); if (select.next() select.value(0).toInt() 0) { QSqlQuery update(db); update.prepare(UPDATE books SET status 1 WHERE id ?); update.addBindValue(bookId); update.exec(); // 插入预约单... }正确写法是 2.3 里的条件 UPDATE用WHERE id ? AND status 0把“检查状态”和“修改状态”合并成一个原子操作。数据库的 UPDATE 语句本身带行锁两个事务同时执行时只会有一个改到行另一个影响行数为 0。这也是 SQLite 这种轻量级数据库能应对并发预约的关键。并发场景推荐方式不推荐两个读者抢同一本书UPDATE books SET status1 WHERE status0判断影响行数SELECT 后 UPDATE同一读者重复预约在 reservations 表加唯一索引book_id reader_id每次先全表 SELECT管理端归还事务里改 reservations.status 和 books.status两个独立 UPDATE同一读者重复预约的问题光靠代码判断不够数据库层也要兜底。可以为 reservations 表创建一个部分唯一索引只对 status 为 0 和 1 的记录生效CREATE UNIQUE INDEX idx_active_reservation ON reservations(book_id, reader_id) WHERE status IN (0, 1);这样用来防止同一个读者对同一种书存在两条有效预约。SQLite 支持部分索引这是课程设计里可以写进文档的一个细节。4.2 过期预约自动释放QTimer 定时扫描别用 sleep预约系统通常要求 30 分钟内不到馆取书预约就自动取消图书恢复为可预约。最简单的实现是在主窗口里放一个 QTimer每 60 秒扫描一次过期预约。QTimer *timer new QTimer(this); timer-setInterval(60 * 1000); connect(timer, QTimer::timeout, this, MainWindow::releaseExpiredReservations); timer-start();槽函数内部执行两个 UPDATEvoid MainWindow::releaseExpiredReservations() { QSqlDatabase db QSqlDatabase::database(library_conn); QDateTime now QDateTime::currentDateTime(); db.transaction(); QSqlQuery q(db); q.prepare(UPDATE books SET status 0 WHERE id IN (SELECT book_id FROM reservations WHERE status 0 AND deadline ?)); q.addBindValue(now); q.exec(); q.prepare(UPDATE reservations SET status 3 WHERE status 0 AND deadline ?); q.addBindValue(now); q.exec(); db.commit(); }先更新 books再更新 reservations顺序不能反过来。如果先改预约单状态为过期再释放图书时一旦第二步失败book 还是“已预约”但预约单已经变成过期会形成死数据。两个 UPDATE 放在同一个事务里才能保证释放动作完整。QTimer 的回调在 GUI 线程执行几千条记录的扫描耗时在毫秒级别不会让界面卡顿不用额外开线程更不建议在循环里QThread::sleepsleep 会直接冻结事件循环让整个界面失去响应。4.3 Qt 多线程里的 C 崩溃点QSqlDatabase 不能跨线程使用如果图书量很大或者想给课设加分可以用 QtConcurrent 或 QThread 做后台清理。很多教材会写一段看似正常的代码在主线程创建 QSqlDatabase 连接然后通过 lambda 捕获这个对象丢到另一个线程里执行。这在 Windows 上偶尔能跑起来但换一台机器大概率崩溃因为 QSqlDatabase 对象和它持有的连接句柄都绑定在创建线程上。正确做法是在工作线程里重新打开数据库class ExpiredCleanWorker : public QObject { Q_OBJECT public: explicit ExpiredCleanWorker(QString dbPath) : dbPath_(std::move(dbPath)) {} public slots: void clean() { QSqlDatabase workerDb QSqlDatabase::addDatabase(QSQLITE, clean_conn); workerDb.setDatabaseName(dbPath_); if (!workerDb.open()) { qWarning() worker db open failed; emit finished(); return; } // 执行与 4.2 相同的过期释放逻辑 workerDb.close(); QSqlDatabase::removeDatabase(clean_conn); emit finished(); } signals: void finished(); private: QString dbPath_; };worker 的启动写法auto *thread new QThread(this); auto *worker new ExpiredCleanWorker(dbPath); worker-moveToThread(thread); connect(thread, QThread::started, worker, ExpiredCleanWorker::clean); connect(worker, ExpiredCleanWorker::finished, thread, QThread::quit); connect(worker, ExpiredCleanWorker::finished, worker, QObject::deleteLater); connect(thread, QThread::finished, thread, QObject::deleteLater); thread-start();这段代码里有三个参数容易写错addDatabase的连接名必须是当前线程里没出现过的所以不能和主线程的library_conn同名removeDatabase必须在连接关闭之后再调用moveToThread必须在 connect 之前执行否则槽函数仍然跑在旧线程。Qt 的跨线程信号默认使用 QueuedConnectionworker 发 finished 信号时接收者已经不在原线程所以必须用 deleteLater 手动释放避免内存泄漏。5. windeployqt 打包与 Qt 运行路径排查把课程设计压缩包交出去5.1 从 Qt 命令行启动 windeployqt生成带插件的发布目录课程设计最后要求交一个 zip里面既要有源码也要能运行。很多同学直接把 Qt Creator 的 build 目录里的 exe 拖出来压缩结果到演示机器上双击就闪退。原因很简单Release 模式编译的 exe 依赖 Qt5Core.dll、Qt5Widgets.dll、platforms/qwindows.dll 等十几个文件这些文件不会自动出现在默认输出目录。用 Qt 自带的 windeployqt 工具生成发布目录是标准做法。先打开 Qt 命令行环境再进入 Release 构建目录call C:\Qt\5.15.2\msvc2019_64\bin\qtenv2.bat cd /d D:\projects\LibraryReservation\build-release mkdir deploy windeployqt --release --compiler-runtime --dir deploy LibraryReservation.exePowerShell 下写法略有不同 C:\Qt\5.15.2\msvc2019_64\bin\qtenv2.bat cd D:\projects\LibraryReservation\build-release New-Item -ItemType Directory -Force deploy windeployqt --release --compiler-runtime --dir deploy LibraryReservation.exewindeployqt 常用参数参数作用常见使用场景--release按 Release 模式匹配依赖课程设计只提交 Release 版--debug打包 Debug 运行库需要断点调试但机器没装 Qt 时--compiler-runtime复制 Visual C 运行库 msvcp140.dll 等目标机器没装 VC Redistributable--dir指定输出目录把运行库和 exe 隔离到 deploy 目录--no-translations不复制 Qt 自带翻译文件程序使用自己的 .qm 文件--compiler-runtime是课设演示最需要的参数。演示教室的机器很多没有安装 Visual C Redistributable不加这个参数拷贝过去的程序会报“找不到 msvcp140.dll”。加上之后windeployqt 会把运行库放到 exe 旁边不依赖目标机系统环境。5.2 处理 qt_qpa_platform_plugin_path 这类平台插件路径问题常见的部署错误是程序启动时弹出qt.qpa.plugin: Could not find the Qt platform plugin windows in 这是因为 Qt 程序找不到platforms/qwindows.dll。网上不少教程让用户手动设置环境变量QT_QPA_PLATFORM_PLUGIN_PATHD:\Qt\5.15.2\msvc2019_64\plugins\platforms这个办法确实能让程序跑起来但会把开发机的绝对路径写死换一台电脑就失效。如果课程设计 demo 上看到环境变量值还带D:\Qt\5.15.2\msvc2019_64说明发布目录里的 plugins 没有配对。正确做法是确保 exe 同目录下存在 platforms 子目录并且里面放了 qwindows.dll。运行 windeployqt 之后这个目录会自动生成。在 exe 同目录放一个 qt.conf 文件是更稳妥的约定[Paths] Plugins pluginsqt.conf 让 Qt 从 exe 所在目录的 plugins 子目录加载插件不依赖系统环境变量。注意这里的路径是相对路径相对的是 qt.conf 所在目录也就是 exe 所在目录。发布 zip 里必须包含这个文件。还有一个容易踩的坑如果系统里之前手动设置过QT_QPA_PLATFORM_PLUGIN_PATH修改环境变量之后要重新打开控制台再启动程序。环境变量的值是进程启动时快照已经运行的 QtCreator 不会读到新值。错误现象原因处理方式双击闪退无任何提示platforms/qwindows.dll 缺失windeployqt 后把整个目录一起压缩错误信息里出现 D:\Qt... 绝对路径环境变量被写死到开发机删除环境变量用相对路径和 qt.conf目标机器提示缺少 VC 运行库未加 --compiler-runtime重新执行 windeployqt 并加参数5.3 课程设计压缩包里至少要有源码、SQL 和部署目录zip 不是简单地把源码文件夹拷进去。评审老师和助教最关心两件事能不能编译能不能直接运行。一个清晰的目录结构能避免答辩现场打不开项目的尴尬LibraryReservation.zip ├── bin/ # windeployqt 输出目录 │ ├── LibraryReservation.exe │ ├── platforms/ │ ├── sqldrivers/ │ ├── qt.conf │ └── ... ├── sql/ │ └── init.sql # 建表和测试数据脚本 ├── src/ # 完整源码和 .pro 工程文件 ├── README.md # Qt 版本、编译步骤、默认账号 └── 课程设计报告.pdfREADME 里必须写清楚三件事使用的 Qt 版本是 5.15.2 还是 6.x编译器是 MinGW 还是 MSVC2019数据库文件是否会自动创建。课程设计的项目一般都用 SQLite数据库文件可以放在程序运行目录下的 db 子目录不要在代码里写死D:/Qt/...这样的绝对路径。建议把db/目录留空并在程序启动时自动建表这样任何机器上第一次点击 exe 就能直接进入登录界面。如果数据库文件需要随包分发要确认压缩前没有包含正在写入的共享内存文件比如 SQLite 的-wal和-shm文件。6. 用 QTest 验证预约状态迁移再给批量导入加一个自定义进度条6.1 QTest 只测状态迁移不碰数据库预约系统最容易出 bug 的地方是状态不对所以测试也应该围绕状态迁移。先把预约逻辑从数据库访问里解耦出来定义一个 Reservation 类保存状态然后在 Qt Test 里写内存测试。#include QtTest #include reservation.h class ReservationStateTest : public QObject { Q_OBJECT private slots: void reserveThenCancel() { Reservation r; QVERIFY(r.reserve()); QCOMPARE(r.status(), Reservation::Reserved); QVERIFY(r.cancel()); QCOMPARE(r.status(), Reservation::Available); } void reserveThenPickup() { Reservation r; r.reserve(); QVERIFY(r.pickup()); QCOMPARE(r.status(), Reservation::Borrowed); QVERIFY(!r.cancel()); // 已借出不能取消 } }; QTEST_MAIN(ReservationStateTest) #include tst_reservationstate.moc工程文件里加上QT testlib CONFIG testcase SOURCES tst_reservationstate.cppQTest 的价值在于把“预约成功不能重复预约”“借出后不能取消”这些规则变成一个可运行的文件。课设答辩时现场跑一遍测试比口头解释状态机更有说服力。6.2 批量导入预约单用进度条反馈worker 只发信号不碰控件如果系统要支持从 Excel 批量导入图书界面刷新进度条时最常见的问题是worker 线程直接调用了 QProgressBar 的 setValue导致程序崩溃。Qt 的跨线程规则是 worker 只能发信号更新控件必须由 GUI 线程完成。connect(importer, BookImporter::progressChanged, progressBar, QProgressBar::setValue); connect(importer, BookImporter::progressTextChanged, statusLabel, QLabel::setText); importer.start();自定义进度条有两种做法子类重写 paintEvent或者用 QSS 改外观。课程设计里一般不需要重写绘制逻辑给 QProgressBar 设置 chunk 颜色和边框就够用QProgressBar { border: 1px solid #999; border-radius: 6px; text-align: center; } QProgressBar::chunk { background-color: #2d8cf0; border-radius: 6px; }如果要显示“每本书导入结果”可以在 worker 里发第二个信号携带当前正在导入的书名再用 QLabel 展示。注意 worker 信号里的参数用QString而不是QWidget*信号和槽自带拷贝跨线程时才不会有悬空指针。QProgressBar 的setValue必须由主线程调用这是 Qt 事件循环机制决定的。worker 线程通过 QueuedConnection 把信号传给 GUI 线程后进度条才会进入下一次重绘。真正需要自绘的场景是边界情况比如在同一个进度条上画两条不同颜色的线代表“预约数”和“借出数”这时才需要继承 QProgressBar 并用setRange、paintEvent控制绘制。本文还有配套的精品资源点击获取