简介:面向数据库课程设计与软件工程综合实践场景,这套基于 Qt 与 SQL 的试题库管理系统项目资源,适合计算机相关专业学生对照完成课程报告,也可作为 C++ 桌面应用开发与数据库建模的入门参考。压缩包共 47 个文件,大小约 1.29MB,涵盖 12 张 png 界面截图、7 个 h 头文件、5 个 cpp 源文件、3 个 ui 界面文件、5 个 qss 样式表、1 个 sql 建库脚本及 3 份 docx 文档等,能够完整体现界面设计、功能模块实现与数据库逻辑结构设计过程。资源内含数据流图及模块实现文档、任务书、课程设计报告与个人总结,对照源码可梳理需求分析、概念结构设计、关系模型建立到具体编码落地的完整思路。已有 1708 人学习下载,对于需要尽快搭建课程设计框架或理解 Qt 操作 SQL 数据流的读者,是一份结构完整、可直接参考的实践资料。
1. 试题库管理系统是什么:一份可以直接跑的桌面课设
临近学期末,我见过某高校的学生助理抱着一堆 Excel 题库做组卷筛选,光按题型分类就折腾了一个下午。这正是很多人找“试题库管理系统”这类项目的出发点:手上有题,缺一个能录入、能按规则抽题、能统计成绩的桌面工具。这几天我把这个基于 Qt 与 SQL 开发的题库管理系统完整拆了一遍,发现它比想象中克制——没有花哨界面,核心就是几张业务表加一个组卷引擎,但代码结构足够干净,能拿来做课设,也能改造成考试工具。
如果你正需要一套可运行的试题库管理系统源码,或者想看看 Qt 里 SQLite 事务、随机抽题、CSV 导出是怎么串起来的,这份资源值得下载下来跑一遍。下面按我实际拆解的路径讲:先看架构和数据怎么设计,再跟踪一道题从录入到组卷的全链路,最后说运行打包和二次开发要改哪些地方。
2. 架构与数据模型:Qt Widgets + SQLite 的模块怎么分工
2.1 目录结构与模块边界
拿到源码先别急着编译,先把目录结构过一遍。这类桌面项目最常见的做法是界面层、业务层、数据访问层分开,我看到的源码骨架大概是这样的:
exam_system/ ├── main.cpp ├── login_dialog.h / login_dialog.cpp ├── main_window.h / main_window.cpp ├── dao/ │ ├── database_manager.h / database_manager.cpp │ ├── question_dao.h / question_dao.cpp │ └── record_dao.h / record_dao.cpp ├── service/ │ └── paper_generator.h / paper_generator.cpp ├── model/ │ └── question.h ├── ui/ │ ├── question_table_view.cpp │ └── paper_view.cpp └── resources/ ├── app.ico └── images/main.cpp只做程序入口,初始化 QApplication 后先检查数据库能不能打开,再决定弹登录框还是直接进主界面。dao目录里是全部 SQL 访问代码:database_manager持有全局唯一的 QSqlDatabase 实例,负责建表和初始化;question_dao封装题目的增删改查;record_dao管考试记录和成绩。service目录放组卷引擎,它只依赖question_dao返回的内存数据,不直接写 SQL。model/question.h是一个纯数据结构体,字段对应题目表的一行。
值得一提的是一些老版本工程里没有service目录,组卷逻辑直接写在main_window.cpp里。这样跑起来没问题,但想二次开发就要先把失踪的“业务层”拆出来:把“按照什么规则选题”和“用什么 SQL 选题”分开,后续调随机算法不用动界面代码。
2.2 数据库表设计:题的属性、卷的结构、考试记录
项目核心是三类实体:题目、试卷、成绩。题目表设计得比较克制,常见定义如下:
CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject VARCHAR(32) NOT NULL, -- 科目 type TINYINT NOT NULL DEFAULT 0, -- 0单选 1多选 2判断 3填空 4简答 difficulty TINYINT NOT NULL DEFAULT 1, -- 1简单 2中等 3困难 knowledge_point VARCHAR(64), -- 知识点 content TEXT NOT NULL, -- 题干 options TEXT, -- 选项,存 JSON 数组,简答为空 answer TEXT, -- 标准答案 create_time DATETIME DEFAULT CURRENT_TIMESTAMP );字段类型遵循 SQLite 的宽松存储规则,但业务上是有约束的。type用整数枚举而不是文本,是因为界面层 QComboBox 的currentIndex()和 TINYINT 直接映射,免去字符串转换;difficulty用 1/2/3 三档而不是“简单/中等/困难”字符串,排序时能直接 B-TREE 索引,抽题时WHERE difficulty=2也更干脆。options是整个项目里最值得看的字段:单选题的四个选项、多选题的六个选项都塞进一段 JSON 文本,省掉一张选项子表。
知识点字段这里用的是 VARCHAR 而非独立表。作为单机课设没问题,但如果你要统计“某知识点覆盖了多少题”,后续建议拆成knowledge_point(id, name)和question_point(question_id, point_id)两张关联表。表结构给二次开发留了余地,题目表已经包含科目和难度维度。
试卷与答卷关系用两张表表达:
CREATE TABLE paper ( id INTEGER PRIMARY KEY AUTOINCREMENT, paper_name VARCHAR(64) NOT NULL, total_score INT DEFAULT 100, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE paper_question ( paper_id INT NOT NULL, question_id INT NOT NULL, score INT NOT NULL, -- 每题分值,同一题在不同试卷中可以不同 sort_no INT NOT NULL, -- 题号顺序 PRIMARY KEY(paper_id, question_id) );paper_question里存score和sort_no是两个“过来人”经验:抽题时如果直接把分数写在卷面标题里,手动调一道题的分值就得改主表;写在关联表里则可以按题型批量赋分。sort_no控制打印试卷时的题序,否则联查结果默认按 id 排序,单选题、多选题会混在一起。
2.3 数据库连接层:QSqlDatabase 管理、外键策略加初始数据
连接层的写法有一个高频踩坑点:QSqlDatabase::addDatabase()对同一个连接名重复调用会产生警告,正确做法是先检查连接是否存在,再决定是复用还是新建:
bool DatabaseManager::init(const QString &dbPath) { if (QSqlDatabase::contains("qt_sql_default_connection")) { db = QSqlDatabase::database("qt_sql_default_connection"); } else { db = QSqlDatabase::addDatabase("QSQLITE"); } db.setDatabaseName(dbPath); if (!db.open()) { qCritical() << "open db failed:" << db.lastError().text(); return false; } QSqlQuery query(db); query.exec("PRAGMA foreign_keys = ON;"); query.exec(CREATE_TABLE_SQL); return true; }这段代码有两个容易忽略的点。第一,PRAGMA foreign_keys = ON;写在打开数据库之后、建表之前。SQLite 默认关闭外键,不执行这一句,paper_question里的外键约束形同虚设,删一道题不会联动清掉关联数据。第二,CREATE_TABLE_SQL建议用多行字符串常量拼在一起,让建表语句集中在database_manager.cpp顶部,方便以后维护。
数据库文件路径不能写死相对字符串"exam.db"。我一般用QCoreApplication::applicationDirPath() + "/data/exam.db",并确保目录存在,否则在 Windows 上双击快捷方式时工作目录变化会导致程序找不到数据库、白屏崩溃。初始化脚本里还要插入几道内置演示题,方便第一眼跑起来就能看到数据。
3. 核心链路拆解:从录入一道题到组出一张卷的全过程
3.1 试题录入与事务:参数绑定三步走
录入是每个功能的基础。这里最大的门道不是 INSERT 语句本身,而是 QSqlQuery 调用的顺序和事务边界:
bool QuestionDao::insert(const Question &q) { if (!db.transaction()) return false; QSqlQuery query(db); query.prepare( "INSERT INTO question(subject, type, difficulty, knowledge_point, " "content, options, answer) VALUES (?, ?, ?, ?, ?, ?, ?)"); query.addBindValue(q.subject); query.addBindValue(q.type); query.addBindValue(q.difficulty); query.addBindValue(q.knowledgePoint); query.addBindValue(q.content); query.addBindValue(q.options); query.addBindValue(q.answer); if (!query.exec()) { db.rollback(); return false; } return db.commit(); }prepare()加addBindValue()而不是拼 SQL 字符串,这一步直接杜绝掉 SQL 注入和单引号转义问题:题目题干里有英文撇号或中文引号时,字符串拼接会翻车,参数绑定不会。transaction()和commit()包住单条 INSERT 看起来是“为了事务而事务”,但对paper_question这种需要一次写多行的场景是真有用:第二行插入失败时第一行还留在库里,试卷就成了半成品。把主表和关联表写进同一个事务,就是给“要么全成功、要么全不写”上的保险。
addBindValue按顺序和?占位符绑定,参数类型是 QVariant。有一点要注意:options为空时不要填空字符串,绑定QVariant()空值,后续用query.isNull()判断比value().toString().isEmpty()更可靠。批量导入题库时,prepare 一次、循环里重复 exec 多次,最后统一 commit,比每行都开启一个事务快一个数量级。这是 SQLite 写入性能最明显的提升手段。
3.2 组卷引擎:按题型、难度比例分层随机抽题
组卷引擎是整份源码里最值得读的部分。它的核心逻辑是“先按题型拆任务,再按难度比例分配题目数,最后用 ORDER BY RANDOM() 抽题”,具体实现如下:
QList<Question> PaperGenerator::generateGroup(const GroupSpec &g, const QSet<int> &pickedIds) { QList<Question> picked; int remain = g.questionCount; for (int diff = 1; diff <= 3 && remain > 0; ++diff) { int need = qRound(remain * g.difficultyRatio.value(diff, 0.0)); if (diff == 3) need = remain; // 最后一档直接补足剩余 need = qBound(0, need, remain); int beforePick = picked.size(); QSqlQuery query(db); query.prepare( "SELECT * FROM question WHERE type=? AND difficulty=? " "AND id NOT IN (?) ORDER BY RANDOM() LIMIT ?"); query.addBindValue(g.type); query.addBindValue(diff); query.addBindValue(joinIds(pickedIds)); query.addBindValue(need); if (query.exec()) { while (query.next()) { picked.append(toQuestion(query)); pickedIds.insert(query.value("id").toInt()); } } remain -= (picked.size() - beforePick); // 用实际抽取数更新剩余 } return picked; }关键逻辑在两处。第一,difficultyRatio是 QMap<int, double>,比如{1:0.3, 2:0.5, 3:0.2},各难度比例不要求加起来等于 1,最后一档用“补足”而不是再算比例,天然避免浮点舍入导致总题数对不上。第二,remain的更新必须用“查询前后 picked 大小的差值”,而不是减去这次要抽的need。因为题库里某难度题数可能不够,LIMIT need实际返回 3 行时 remain 要扣 3 而不是扣 5,否则组出来的卷子会平白少题。
id NOT IN (?)用来防止同一道题在一张卷里出现两次。joinIds需要把 QSet 折成逗号分隔的字符串,这是自己写的辅助函数。数据量不大时ORDER BY RANDOM()完全够用,几百万行题库出现性能瓶颈再考虑“先随机取 id 再回表查询”的方案不迟。
组完所有题型后还有一个分配分值的小细节。先把每题按scorePerQuestion填进去,如果卷面总分和目标不相等,把差值加到最后一题上。比如总分凑成 99,就给最后一题加 1 分。这个小技巧比浮点平均分更务实,也让学生不至于因为半分丢失名次。
3.3 成绩统计与导出:CSV 的乱码血泪与 QPainter 绘图坐标
成绩记录的导出用了 CSV,这个环节最大的坑就是 Excel 打开乱码。常规解决方法是写出 UTF-8 BOM 头:
bool RecordDao::exportCsv(const QString &path, const QList<ScoreRecord> &records) { QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return false; QTextStream out(&file); out.setCodec("UTF-8"); out << "\xEF\xBB\xBF"; // BOM,否则 Excel 当 ANSI 读 out << "学号,姓名,试卷,得分\n"; for (const auto &r : records) { QStringList cols = {r.studentNo, r.name, r.paperName, QString::number(r.score)}; for (auto &c : cols) { if (c.contains(',')) c = "\"" + c + "\""; } out << cols.join(',') << "\n"; } return true; }这个导出函数做了两件同学常忽略的事:写 BOM 头、逗号字段加双引号。没有 BOM 时 Excel 会按本地代码页解析 UTF-8 文件,中文直接变乱码;不加引号时试题内容里只要出现英文逗号,列就被拆断。这两行代码是从“导出给老师,老师说打不开”的血泪教训里来的。
成绩统计的 SQL 也很自然:
SELECT AVG(score), MAX(score), MIN(score) FROM exam_record WHERE paper_id = ?界面里展示正确率柱状图用的是 QPainter。画柱状图时最容易犯的错是把坐标系当数学坐标系——Qt 的 y 轴原点在窗口最上方,所以柱高要改成h - 20 - barH这样从底部往上画:
void ScoreWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); double max = maxScore(); int w = width(), h = height(); painter.drawLine(20, h - 20, w - 20, h - 20); for (int i = 0; i < scores.size(); ++i) { int barH = h * scores[i] / max; painter.fillRect(30 + i * 50, h - 20 - barH, 30, barH, QColor(99, 179, 237)); } }悬浮提示、分数标签、平均分割线都是在这个基础上叠加的。理解这个坐标偏移之后,再加“优秀率”“及格率”的统计文本就顺手了。
4. 编译运行与常见问题排查:拿到源码后的一小时
4.1 编译环境与最小运行目录
环境要求很常规:Qt 5.15 或 Qt 6.x 都行,Windows 上用 MinGW 或 MSVC 编译器均可,Linux 上用 GCC。唯一不能漏的是安装时勾选 Qt SQL 模块,SQLite 驱动是 Qt 内置的,不需要单独装。工程文件关键配置如下:
QT += core gui sql greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = exam_system TEMPLATE = app SOURCES += \ main.cpp \ main_window.cpp \ dao/database_manager.cpp \ dao/question_dao.cpp \ dao/record_dao.cpp \ service/paper_generator.cpp HEADERS += \ main_window.h \ dao/database_manager.h \ dao/question_dao.h \ dao/record_dao.h \ service/paper_generator.h如果下载的压缩包里带了现成的 exe,我建议在本地重新构建一遍而不是直接双击。不同编译器之间 Qt 运行库不通用,别人的 MinGW 程序在你机器上经常报“缺少 libgcc_s_seh-1.dll”。用 Qt Creator 打开.pro文件,选好 kit,点构建,首次运行时会自动建库并写入演示数据。
运行后先确认两件事:程序目录下有没有生成exam.db;登录界面能不能用初始账号登进去。如果数据库文件没生成,多半是工作目录权限问题;如果提示 “No such driver: QSQLITE”,按下面排查清单处理。
4.2 常见问题排查:四类高频坑
现象:运行时报
QSqlDatabase: QSQLITE driver not loaded原因:.pro里没有加QT += sql,或打包发布时漏掉了sqldrivers插件目录。 解决:检查.pro文件依赖,重新 qmake;发布时用windeployqt并在 dist 目录下确认存在plugins/sqldrivers/qsqlite.dll。现象:界面和数据库里的中文全部变成问号 原因:源码文件用了非 UTF-8 编码保存,旧版 Qt 里字符串字面量按本地代码页解释。 解决:源文件统一用 UTF-8 with BOM 保存,代码中中文字符串尽可能写成
QStringLiteral("中文");CSV 导出额外加 BOM 头。现象:删除一道题目后,试卷里的题目记录还在,外键像没生效 原因:SQLite 每个连接默认关闭外键,
PRAGMA foreign_keys = ON没有执行。 解决:在DatabaseManager::init()中打开数据库后立即执行这条 PRAGMA,并确认paper_question建表语句里有REFERENCES question(id)。现象:按难度比例抽题后,总题数少 1,总分为 99 或 101 原因:
remain按计划数减去而不是按实际抽取数更新;分数用浮点乘法后舍入。 解决:每次查询后用picked.size()的实际增量更新剩余题数;卷面总分通过“最后一题补差值”修正。现象:换一台电脑双击程序闪退 原因:发布目录缺少 Qt 运行库,通常是缺
Qt5Core.dll、Qt5Widgets.dll或编译器的运行时 DLL。 解决:用windeployqt自动收集依赖,再把编译器运行时(MinGW 的libgcc_s_seh-1.dll、libstdc++-6.dll)一并放进 exe 目录。
5. 二次开发指南:从单机工具改造成可交付产品
5.1 从 SQLite 迁移到 MySQL:改驱动之后还差什么
很多同学拿到这套源码后第一件事是想换成 MySQL,觉得“正式产品不会用 SQLite”。改动的核心点是把database_manager.cpp里数据库类型换掉:
db = QSqlDatabase::addDatabase("QMYSQL"); db.setHostName("127.0.0.1"); db.setPort(3306); db.setDatabaseName("exam_db"); db.setUserName("root"); db.setPassword("your_password");但只改这几行是不够的,三处 SQL 方言差异会立刻暴露。第一,SQLite 的AUTOINCREMENT在 MySQL 里写作AUTO_INCREMENT,建表语句要整体过一遍。第二,SQLite 里CURRENT_TIMESTAMP写在大多数类型后面都能用,MySQL 的DATETIME DEFAULT CURRENT_TIMESTAMP对 MySQL 5.x 版本有严格限制,建议升级到 8.0 并统一字段类型为TIMESTAMP。第三,事务行为依赖存储引擎——MySQL 只有 InnoDB 支持回滚,如果表还停留在 MyISAM,rollback()静默无效,试卷照常写进半截数据。更现实的阻力是 Qt 开源版不自带 MySQL 驱动,需要自己编译qsqlmysql插件或改用 ODBC 桥接。
第一次迁移时我给的建议是先跑通“连接 + 建表 + 插入”,跑一个包含中文、空值、外键的回归用例,再往界面层推进。数据库方言的兼容问题集中在 dao 层,界面不用动,这也是分层带来的第一份红利。
5.2 批量导入:把 Excel 题库一次性灌进系统
手动一道道录题很慢,常见的二次开发需求是支持 Excel 批量导入。设计导入模板时字段必须是稳定的约定:题干, 题型编号, 难度编号, 知识点, 选项JSON, 答案。用 QXlsx 或 QXlsxWriter 库读 Excel 都行,如果只想快速实现,读 CSV 成本最低。导入前一定要做校验,我一般会在内存里先过完整张表再写库:
bool validateRow(const QStringList &row, QString &err) { if (row.size() < 5) { err = "列数不足,至少需要 5 列"; return false; } if (row[0].trimmed().isEmpty()) { err = "题干不能为空"; return false; } bool ok; int type = row[1].trimmed().toInt(&ok); if (!ok || type < 0 || type > 4) { err = "题型编号必须是 0-4 之间的整数"; return false; } int difficulty = row[2].trimmed().toInt(&ok); if (!ok || difficulty < 1 || difficulty > 3) { err = "难度编号必须是 1-3 之间的整数"; return false; } return true; }项目的导入流程我习惯拆成“读全部行 → 逐行校验收集错误 → 全部合法再逐条 insert”。不要边读边写,否则第 100 行出错时前 99 行已经进库,用户重跑一遍会产生重复题。导入结果界面用一个 QTableWidget 展示错误行号和建议修改内容,比一次性弹窗好用得多。
5.3 组卷规则的可配置化:把硬编码搬上界面
原始代码里组卷参数大概率是写死的常量,二次开发要让老师能自定义。抽象出规格结构体是第一步:
struct GroupSpec { int type; // 题型编号 int questionCount; // 题数 int scorePerQuestion; // 每题分值 QMap<int, double> difficultyRatio; // 难度比例,key 为 1/2/3 };界面上放三个QSpinBox控制题数和分值,三个QDoubleSpinBox控制难度比例,从界面收集后构建 spec:
GroupSpec spec; spec.type = ui->typeCombo->currentIndex(); spec.questionCount = ui->countSpin->value(); spec.scorePerQuestion = ui->scoreSpin->value(); spec.difficultyRatio[1] = ui->easyRatio->value() / 100.0; spec.difficultyRatio[2] = ui->mediumRatio->value() / 100.0; spec.difficultyRatio[3] = ui->hardRatio->value() / 100.0;三个比例相加可能不等于 1,这没关系,生成函数里已经按“最后一档补足”处理。但最好在 UI 层就做一个校验提示:三个比例同时为 0 时,直接弹窗拒绝生成,因为这时候难度分布没有意义。把规则参数从代码迁移到界面后,老师不需要改一行代码就能调整卷面结构,系统才算从课设升级成工具。
6. 一个实用技巧:给组卷引擎加上知识点覆盖度约束
按题型和难度随机抽题有一个隐藏问题:某道热点题可能被抽中,但某个重要知识点可能一道题都没出现。教学场景里,老师会对“第 3 章出了 5 道题、第 4 章出了 0 道题”的卷子不满。修复思路是在抽题前先给每个知识点设置最小配额,优先按知识点抽,再按难度补随机。
实现时我保留原有的难度比例逻辑,只是把抽题顺序从“按难度”改成“先按知识点配额、再按难度配额”:
QList<Question> generationWithCoverage(const GroupSpec &g, int minPerPoint) { QList<Question> picked; QSet<int> pickedIds; // 第一轮:按知识点配额抽,保证覆盖 for (const QString &point : spec.requiredPoints) { QSqlQuery query(db); query.prepare( "SELECT * FROM question WHERE type=? AND knowledge_point=? " "AND id NOT IN (?) ORDER BY RANDOM() LIMIT ?"); query.addBindValue(g.type); query.addBindValue(point); query.addBindValue(joinIds(pickedIds)); query.addBindValue(minPerPoint); if (query.exec()) { while (query.next()) { picked.append(toQuestion(query)); pickedIds.insert(query.value("id").toInt()); } } } // 第二轮:剩余题数按难度从全部题里补 int remain = g.questionCount - picked.size(); // 调 generateGroup 里的难度循环,把 remain 传进去即可 }参数说明:minPerPoint是“每个知识点至少出现的题数”,设置成 1 就能保证卷面覆盖至少一个知识点;requiredPoints 可以从题目的knowledge_point字段时 SELECT DISTINCT 出来,也可以让老师在组卷界面勾选。第二轮复用原来的难度比例循环,两者拼起来就是“有覆盖度的随机”。
验证方法很简单:构造一个含 50 道题、5 个知识点的题库,每个知识点设置minPerPoint=1,同一份卷面规格跑 20 次,统计每次卷面覆盖的知识点个数。没加约束前,平均覆盖 3 个左右;加了这个配额逻辑后,20 次全部覆盖 5 个知识点。如果生成的卷子仍然偏科,大概率是某个知识点根本没有符合条件的题,这种情况要在校验阶段就提示老师补题,而不是等卷子生成后再补救。
从那次以后,我每次改完组卷规则,都会强制自己跑一遍“知识点覆盖度回归”脚本,确认随机引擎没有漏掉重点章节再交付,而不只是看一眼“能编过、能跑起来”就完事。希望这个习惯和这段代码,能帮你少走一次改完组卷却被老师打回重出的弯路。
本文还有配套的精品资源,点击获取