简介:一份基于C++与Qt开发的图书管理系统完整项目包,面向高校C++/Qt课程设计、期末项目及毕业设计学习者,集中解决图书购入、编码、借出、还回、统计、查询等业务流如何从控制台延伸到图形界面的典型问题。压缩包共1192个文件,其中48个cpp与48个h源码文件对应各业务模块,835个txt多为图书/读者数据或运行提示,195张png为界面截图便于对照效果,另含Qt资源描述文件(qm/qss/ico)、运行所需的dll库、可直接启动的exe以及docx课程论文,整体21.41MB,目录层次清晰。资源不仅提供可编译运行的完整工程,还附带需求分析与实现说明,读者可从图书录入、读者管理、借还登记、统计查询等模块快速理解Qt窗口、信号槽与数据库操作的配合方式,并基于现有代码二次开发。目前已有565人学习,适合需要参考完整项目结构、界面设计及论文写作思路的C++/Qt初学者。
1. C++与Qt实现的图书管理系统为何值得拆开看
很多人一提“图书管理系统”,下意识就会往 JavaWeb、MySQL 上靠。但这份基于 C++ 与 Qt 的实现走的是完全相反的一条路:不需要启动数据库服务,不需要装中间件,编译产物里直接就带着可执行 exe 和配套 DLL,双击就能打开主界面。源码文件里既有 addnewbook.cpp、addbooksvariety.cpp 这种基础数据维护模块,也有 bookborroworreservemanagewidget.cpp、logs.cpp 这类承载借阅业务和审计逻辑的部件,另外还夹着一个 qcustomplot.cpp,说明统计部分没有用 Qt Charts,而是用 QCustomPlot 这个轻量绘图库来画借阅趋势和分类占比。对正在做课程设计的学生来说,这份代码最大的参考价值不在功能本身,而在于它示范了 Qt 的 Model/View 数据绑定、SQLite 事务和第三方绘图控件如何在同一个桌面应用里各司其职。对有经验的工程师,值得关注的则是借阅与预约窗口的共用状态机设计、副本表对图书库存的拆分方式,以及项目发布前的部署细节。
2. Model/View架构下的图书与读者管理界面
项目源码里凡是处理“信息列表”的文件,基本都遵循同一套分工:一个视图类持有 QTableView 负责渲染,一个模型类使用 QSqlTableModel 或 QSqlQueryModel 与数据库表通信。这个拆法其实就是 Qt 官方推荐的 Model/View 架构落地:Model 只负责数据,View 只负责展示和交互。widget.cpp 作为主窗口,在这些模块之间只做组装,自身不写 SQL,也不持有全局数据库连接。这样后续无论替换“图书列表”还是“读者列表”的实现,对其他模块的影响都会被限制在一个类内部。
2.1 widget.cpp 的主窗口挂载方式与模块边界
widget.cpp 通常是 QMainWindow 的子类。构造函数里会创建中心部分的 QTabWidget,把 bookviewwidget、readerviewwidget、借阅管理页面、统计页面分别通过 addTab 挂进去,再用 QToolBar 上的 QAction 把这些页面的增删改动作串联起来。这种写法直观但有个细节容易被忽略:视图组件要保存成成员变量,而不是在函数里 new 完就丢。
// widget.cpp 构造函数挂载模块的典型写法 Widget::Widget(QWidget *parent) : QMainWindow(parent) { QTabWidget *tab = new QTabWidget(this); bookView = new BookViewWidget(this); readerView = new ReaderViewWidget(this); borrowMgr = new BookBorrowOrReserveManageWidget(this); tab->addTab(bookView, QStringLiteral("图书管理")); tab->addTab(readerView, QStringLiteral("读者管理")); tab->addTab(borrowMgr, QStringLiteral("借阅与预约")); setCentralWidget(tab); // 工具栏动作通过成员指针触发视图内部槽函数 connect(ui->actionAddBook, &QAction::triggered, bookView, &BookViewWidget::onAddBookClicked); }这里 bookView、readerView、borrowMgr 必须声明为 Widget 的成员变量。原因是后续工具栏按钮的信号要跨模块调用 bookView 内部的槽函数,如果只是局部变量,作用域一结束,界面对象虽然还挂在 QTabWidget 上,但外部已经拿不到有效指针去 connect 了。addnewbook.cpp、addbooksvariety.cpp、addreaderbox.cpp 这几个弹窗类建议遵守同一条边界:只负责收集输入、写数据库、通知外层刷新,不直接操作主界面的其他控件。这样模块之间通过信号槽通信,而不是互相 new 对方内部组件。
2.2 bookviewwidget.cpp 的数据库表映射与刷新策略
bookviewwidget.cpp 承担图书列表的展示,最省事的做法是用 QSqlTableModel 直接映射 book 表。QSqlTableModel 的好处在于它会自动维护一行记录和数据库行的对应关系,新增、修改、删除都能映射到界面表格上。
// bookviewwidget.cpp 初始化图书表格 void BookViewWidget::initBookTable() { model = new QSqlTableModel(this); model->setTable(QStringLiteral("book")); model->setEditStrategy(QSqlTableModel::OnManualSubmit); model->setHeaderData(1, Qt::Horizontal, QStringLiteral("书名")); model->setHeaderData(2, Qt::Horizontal, QStringLiteral("作者")); model->setHeaderData(3, Qt::Horizontal, QStringLiteral("出版社")); model->select(); ui->tableBook->setModel(model); ui->tableBook->setSelectionBehavior(QAbstractItemView::SelectRows); ui->tableBook->setEditTriggers(QAbstractItemView::NoEditTriggers); }setEditStrategy 选择 OnManualSubmit 是关键取舍:如果选 OnFieldChange,用户点一下单元格改一个字符就立刻触发一次 UPDATE,很容易把没写完的半截数据写进库里。OnManualSubmit 的含义是界面上的改动先缓存在 model 里,点“保存修改”按钮后再统一调用 submitAll() 落库,退回成本更低。
setHeaderData 做的事是把数据库英文列名映射为界面表头。book 表结构里列名一般是 book_id、title、author、publisher,直接把原始列名亮给读者用户显然不合适,所以这里必须逐列设置中文表头。setSelectionBehavior 与 setEditTriggers 决定交互手感:SelectRows 让用户整行选中而不是只选中一个单元格,NoEditTriggers 禁止表格内直接编辑,把编辑入口集中到独立按钮上,避免误触修改。
2.3 readerviewwidget 与 addreaderbox 的前后端联动
readerviewwidget 展示读者表,addreaderbox.cpp 则是单独的 QDialog 弹窗,负责新增读者。两者之间最常见的联动方式是“弹窗写库、父界面刷新”,而不是直接跨对象修改控件。
// readerviewwidget.cpp 新增读者按钮的响应槽 void ReaderViewWidget::onAddReaderClicked() { AddReaderBox dlg(this); if (dlg.exec() == QDialog::Accepted) { model->select(); // 重新拉取 reader 表数据 ui->readerTable->reset(); // 重置视图状态 } }addreaderbox 弹窗内部用 QFormLayout 收集读者姓名、学号、证件信息,点击确定后往 reader 表插入记录。exec() 是模态阻塞调用,返回值 Accepted 表示用户点了确定按钮,父窗口只需在 Accepted 分支里触发 model->select(),就能让表格重新查询一次并显示新增行。readerviewwidget 不需要知道弹窗内部写的什么 SQL,职责边界非常清楚。
modules 之间的联动关系可以按文件职责梳理成一张表:
| 源码文件 | 在工程中的职责 | 核心组件 |
|---|---|---|
| widget.cpp | 主窗口模块挂载与动作分发 | QMainWindow、QTabWidget |
| bookviewwidget.cpp | 图书列表展示、刷新 | QTableView、QSqlTableModel |
| readerviewwidget.cpp | 读者列表展示、刷新 | QTableView、QSqlQueryModel |
| addreaderbox.cpp | 新增读者弹窗 | QDialog、QFormLayout |
| addnewbook.cpp | 新增图书弹窗 | QDialog、QDataWidgetMapper |
| addbooksvariety.cpp | 图书分类维护 | QDialog、QComboBox |
看到 addbooksvariety.cpp 被独立出来,基本可以判断系统的“图书编码”流程不是简单往 book 表里塞一条记录,而是先维护好分类表,再在新书录入时通过下拉框选择分类。这个设计直接决定第 4 章统计模块能不能按分类画出占比饼图——因为分类字段从源头就落到了每一条书目记录上,不需要后期再做文本匹配。
3. 借阅、归还与预约的业务闭环与数据一致性
借阅模块是整个图书管理系统里业务密度最高的一块。借出和归还两个动作都要同时动多张表:借阅记录、副本状态、日志表。如果只写一条 INSERT 或者只做一个 UPDATE,数据很快就会乱。第 2 章介绍的图书列表侧重于“读”,这一章的借阅管理则侧重于“写”,两者的代码风格和风险点完全不同。
3.1 bookborroworreservemanagewidget 的界面状态切换
文件名把 Borrow 和 Reserve 并列,说明项目没有把借用和预约拆成两个窗口,而是在一个管理页面里根据当前模式切换数据源。这样不仅节省界面资源,也让两种模式共用同一套表格操作逻辑。
// bookborroworreserve manage widget 的刷新函数 void BookBorrowOrReserveManageWidget::switchMode(bool isReserveMode) { QString sql; if (isReserveMode) { sql = "SELECT r.reserve_id, b.title, rd.name, r.reserve_date " "FROM reserve r " "JOIN book b ON b.book_id = r.book_id " "JOIN reader rd ON rd.reader_id = r.reader_id " "WHERE r.status = 'pending'"; } else { sql = "SELECT br.borrow_id, b.title, rd.name, " "br.borrow_date, br.due_date " "FROM borrow br " "JOIN book b ON b.book_id = br.book_id " "JOIN reader rd ON rd.reader_id = br.reader_id " "WHERE br.return_date IS NULL"; } queryModel->setQuery(sql, QSqlDatabase::database()); ui->tableView->setModel(queryModel); }借用记录通过 return_date 是否为空来判断是否归还,是一个信息量很高的设计。它没有单独的状态位,不带状态机,也就不会有“状态字段和实际还书动作不一致”的问题。预约记录则不同,因为预约有排队、取消、转借三种后续行为,所以必须显式加 status 字段区分。这也是从源码里能看出的团队取舍:能用数据字段推导的状态,不单独建列。
3.2 借出校验、事务提交与归还时的数量回补
借出流程的完整操作包含三个步骤:校验副本是否可借、写借阅记录、更新副本状态。这几步必须放在同一个事务里,因为任一步失败都会留下脏数据。如果只写借阅记录不更新副本状态,库存查询就会把已借走的书继续当可借副本显示。
// 借出操作的简化实现,体现事务边界 bool borrowBook(qint64 copyId, qint64 readerId) { QSqlDatabase db = QSqlDatabase::database(); if (!db.transaction()) return false; QSqlQuery q(db); // 1. 检查副本是否可借 q.prepare("SELECT status FROM book_copy WHERE copy_id = ?"); q.addBindValue(copyId); q.exec(); if (!q.next() || q.value(0).toString() != QStringLiteral("available")) { db.rollback(); return false; } // 2. 写入借阅记录,起保时间与截止时间都在这里生成 q.prepare("INSERT INTO borrow(book_copy_id, reader_id, " "borrow_date, due_date) " "VALUES(?, ?, date('now'), date('now', '+30 day'))"); q.addBindValue(copyId); q.addBindValue(readerId); // 3. 更新副本为已借出状态 q.prepare("UPDATE book_copy SET status = 'borrowed' WHERE copy_id = ?"); q.addBindValue(copyId); if (!q.exec()) { db.rollback(); return false; } return db.commit(); }这段代码把事务边界体现得很清楚。SQLite 在桌面单机环境下并发写需求低,用 transaction 包住三步写操作没有性能压力,但也正因为它只支持单写锁,事务内不要放长查询和用户弹窗等待。副本表 book_copy 的设计与第 2 章的 book 表是配套的:同一本《C++ Primer》有 5 个副本,就有 5 条 book_copy 记录,可借状态存在副本上而不是书名上。这样才能支持“查询时显示可借数量,借出后数量即刻减一”的业务要求。
归还逻辑刚好反过来:先写 return_date,再把副本状态改回 available。归还时还有一个附加动作是计算是否超期,超期天数写入借阅记录后,统计模块才能按超期原因做排行。这个计算建议用 QDate 来做,而不是在 SQL 里比较字符串日期:
// 还书时计算逾期天数 int overdueDays = rec.dueDate.daysTo(QDate::currentDate()); if (overdueDays > 0) { q.prepare("UPDATE borrow SET over_days = ? WHERE borrow_id = ?"); q.addBindValue(overdueDays); q.addBindValue(borrowId); q.exec(); }daysTo 返回负数说明未到期,只有正数才进入超期处理,逻辑上比“在 SQL 里比较字符串”直观,而且能够避开 SQLite 日期格式不一致的隐含坑。
3.3 用 logs.cpp 构建可追溯的借阅审计链
logs.cpp 不是简单的文件日志模块,它对应的是系统里的行为审计表。每条日志记录至少包含操作类型、操作对象、执行结果、时间戳这几个维度。这里最重要的设计是把日志写入统一放在业务函数的成功路径末尾,而不是散落在界面按钮响应里。这样不管借出动作是由工具栏触发、键盘快捷键触发,还是未来接入自动借还机调用,审计记录都会可靠落库。
| 日志操作类型 | 写入时机 | 关键字段 |
|---|---|---|
| LOG_BORROW | 借出事务成功后 | borrow_id、reader_id、copy_id |
| LOG_RETURN | 归还事务成功后 | borrow_id、return_date、over_days |
| LOG_BOOK_ADD | addnewbook 写入成功后 | isbn、title、copies |
| LOG_READER_ADD | addreaderbox 保存后 | reader_id、name、id_card |
借出和归还的日志可以依靠事务本身的状态来判定,事务 commit 成功才调用写日志函数,这样数据库操作和日志记录的时序天然一致,不会出现程序报错但日志显示成功的情况。
4. QCustomPlot 在借阅统计可视化中的接入
qcustomplot.cpp 是整个项目里唯一的第三方绘图库,选它而不是 Qt Charts,核心原因是集成成本低。QCustomPlot 把绘图模块压缩成一个 .cpp 和一个 .h,直接放进工程参与编译即可,不需要在 .pro 文件里追加 charts 模块。在 msvc2019 64 位环境下开发时,它的模板实例化规模比 Qt Charts 小,编译速度反馈会更直接。
4.1 为什么直接编译 qcustomplot.cpp
QCustomPlot 的接入方式是在 .pro 工程文件里把两个文件加进编译列表:
SOURCES += qcustomplot.cpp HEADERS += qcustomplot.h没有额外的动态库依赖,也没有 QML 插件需要部署。对比之下,Qt Charts 在发布时还需要把 Qt5Charts.dll 一起带上,如果部署环境没装对应版本,程序会在启动阶段直接报缺 DLL。对课程设计评审现场这类环境不可控的展示场景,QCustomPlot 的文件级分发明显更省心。如果对图表样式有定制需求,直接修改 qcustomplot.cpp 里的绘图代码也比去翻 Qt Charts 的文档来得快。
4.2 折线图与占比饼图的代码落地
借阅统计最常见的两个图是月度借阅量折线图和分类借阅占比饼图。折线图适合看趋势,饼图适合看结构。QCustomPlot 画折线图的核心路径是 addGraph、setData、replot 三步:
// 统计页按月份刷新借阅趋势 void StatsWidget::refreshMonthlyTrend(const QDate &start, const QDate &end) { QSqlQuery q; q.prepare("SELECT strftime('%Y-%m', borrow_date) AS ym, " "COUNT(*) FROM borrow " "WHERE borrow_date BETWEEN ? AND ? " "GROUP BY strftime('%Y-%m', borrow_date)"); q.addBindValue(start.toString("yyyy-MM-dd")); q.addBindValue(end.toString("yyyy-MM-dd")); q.exec(); QVector<double> xs, ys; QStringList labels; int i = 0; while (q.next()) { xs.append(i++); ys.append(q.value(1).toDouble()); labels.append(q.value(0).toString()); } ui->trendPlot->clearGraphs(); // 清空上一轮曲线 ui->trendPlot->addGraph(); // 新建一条曲线 ui->trendPlot->graph(0)->setData(xs, ys); ui->trendPlot->graph(0)->setPen(QPen(QColor(0, 120, 212), 2)); // 用月份字符串替换坐标轴刻度 ui->trendPlot->xAxis->setTickVector(xs); ui->trendPlot->xAxis->setTickVectorLabels(labels); ui->trendPlot->rescaleAxes(); ui->trendPlot->replot(); }代码里 x 轴用的是 0、1、2 这样的连续序号,而不是日期本身,这是 QCustomPlot 处理类别型横轴的常见做法。setTickVector 负责把数据索引映射到刻度位置,setTickVectorLabels 再将刻度显示文本替换成 “2025-01” 形式的月份。rescaleAxes 会按当前数据范围自动调整坐标轴上下限,省去手工计算最大值的步骤。最后 replot 必须调用,之前所有坐标轴和数据修改只是改了内部缓存,不触发一次重绘界面不会变化。
饼图部分用到的是 QCustomPlot 提供的 QCPPie 类:
// 按图书分类统计借阅占比 void StatsWidget::refreshCategoryPie() { QSqlQuery q; q.exec("SELECT c.name, COUNT(b.borrow_id) " "FROM category c " "LEFT JOIN book b ON b.category_id = c.category_id " "LEFT JOIN book_copy bc ON bc.book_id = b.book_id " "LEFT JOIN borrow br ON br.book_copy_id = bc.copy_id " "GROUP BY c.category_id"); QCPPie *pie = new QCPPie(ui->piePlot); while (q.next()) { pie->addSlice(q.value(0).toString(), q.value(1).toDouble()); } pie->setPen(QPen(Qt::white, 2)); ui->piePlot->addPlottable(pie); ui->piePlot->rescaleAxes(); ui->piePlot->replot(); }这里 SQL 用的是 LEFT JOIN 而不是 INNER JOIN,目的是把零借阅的分类也保留在结果里,否则饼图会缺失某些分类的 0 值扇区。双层 JOIN 的顺序则是 category 到 book 到 book_copy 到 borrow,与业务关系的层级完全一致:分类下有多本图书,每本书有多个副本,每个副本指向一条借阅记录。查询只要缺一层关联,统计口径就会变成“按书统计”而不是“按借阅次数统计”,这两者在有多个副本时数字会差很多。
| 统计需求 | 推荐图表类型 | 对应 QCustomPlot 类 |
|---|---|---|
| 月度借阅趋势 | 折线图 | QCPGraph |
| 分类借阅占比 | 饼图 | QCPPie |
| 热门图书 Top10 | 横向条形图 | QCPBars |
4.3 统计页的刷新时机与坐标轴自适应
统计页的刷新时机最容易踩坑的是“界面刚切换过去时图表还是空的”。常见做法是把刷新函数挂到 QTabWidget 的 currentChanged 信号上,只有当用户真的切到统计页那一瞬间才执行查询。这样做还会避免程序启动时把所有页面数据一次性全部载入导致的白屏时间。借阅操作完成后,可以主动调用一次统计页的刷新函数,或者用 QTimer::singleShot(0, ...) 延迟到主事件循环空闲时再刷新,确保当前事务已经提交完成。
提示:在 msvc2019 64 位环境下如果图表中文显示成方框,通常不是字体缺失,而是源文件编码问题。保持所有 .cpp 文件以 UTF-8 保存,并在 .pro 工程文件中追加
QMAKE_CXXFLAGS += /utf-8,可以避免大量因编码导致的中文乱码。
5. 发布部署和启动排错:windeployqt 就能解决八成的坑
Qt 程序编译出的 Release 版不能直接拷贝到别的电脑运行,这是新手最容易在交付阶段被卡住的地方。缺少 Qt5Core.dll 是显性错误,“no Qt platform plugin could be initialized”则是隐性坑。前者用 windeployqt 一把梭就能解决,后者需要理解 Qt 插件式加载机制。
5.1 windeployqt 补 DLL 与 runtime 的使用方式
在 Qt 安装目录自带的命令行环境里打开项目 Release 输出目录,执行打包命令:
mkdir deploy copy release\LibraryManager.exe deploy\ cd /d deploy C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe \ --release --compiler-runtime LibraryManager.exewindeployqt 会根据 exe 链接的 Qt 模块自动复制对应的 DLL 和 plugins 目录。--compiler-runtime 参数会额外携带 Visual C++ Redistributable 的运行库 DLL,包括 vcruntime140.dll 和 msvcp140.dll。这里需要注意 exe 路径中尽量不要出现中文目录,虽然新版 windeployqt 对中文支持有改善,但在课程设计的评审环境里没有人愿意为路径编码问题消耗时间。部署完成后把整个 deploy 目录压成 zip 分发,就是交付件的基本形态。
5.2 QT_QPA_PLATFORM_PLUGIN_PATH 环境变量与数据初始化
部署后双击 exe 如果弹出错误反复指向 “qt.qpa.plugin: Could not find the Qt platform plugin windows”,本质是程序没找到 platforms 目录下的 qwindows.dll。这个问题最直接的排查命令是开工前先看重启对话框路径:
set QT_DEBUG_PLUGINS=1 LibraryManager.exe开启 QT_DEBUG_PLUGINS 后 Qt 会在程序输出里打印它尝试加载插件目录的完整路径列表,一眼就能看出 plugins 目录是否放对了位置。常见修复是把整个 plugins 目录从 Qt 安装目录复制到 exe 同级的 plugins 文件夹下,或者在程序启动代码里用 QCoreApplication::addLibraryPath 显式指定路径。
数据库初始化同样是部署阶段的关键点。如果程序把 SQLite 数据库路径写死为开发机上的绝对路径,考到别的机器必崩。更稳的做法是基于可执行文件所在目录动态拼接:
QString appDir = QCoreApplication::applicationDirPath(); QString dbPath = appDir + QStringLiteral("/data/library.db"); QDir().mkpath(appDir + QStringLiteral("/data")); QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); db.setDatabaseName(dbPath); if (!db.open()) { QMessageBox::critical(nullptr, QStringLiteral("错误"), db.lastError().text()); return -1; }数据库文件本身不打包进 exe,而是首启动时在相对目录下自动建表:
QSqlQuery query; query.exec("CREATE TABLE IF NOT EXISTS book (" "book_id INTEGER PRIMARY KEY AUTOINCREMENT, " "title TEXT, author TEXT, publisher TEXT)");IF NOT EXISTS 语法让建表语句可以重复执行,程序升级时不会因为表已经存在而启动失败。配合 windeployqt 打包出来的完整目录,这套初始化逻辑在首次启动时自动创建数据文件,后续运行不再依赖开发环境里的任何绝对路径。发布包里只需要保留 exe、Qt DLL、plugins 和空的数据目录,就能在任意一台装过 Visual C++ 运行库或使用 --compiler-runtime 打包的 Windows 机器上直接跑起来。
本文还有配套的精品资源,点击获取