资讯详情 基于 Qt 与 SQLite 的试题库管理系统:从课设源码到组卷引擎解析
📅 2026/10/18 19:09:57
简介面向数据库课程设计与软件工程综合实践场景这套基于 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 difficulty2也更干脆。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() 抽题”具体实现如下QListQuestion PaperGenerator::generateGroup(const GroupSpec g, const QSetint pickedIds) { QListQuestion 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是 QMapint, 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 QListScoreRecord 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 支持回滚如果表还停留在 MyISAMrollback()静默无效试卷照常写进半截数据。更现实的阻力是 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; // 每题分值 QMapint, double difficultyRatio; // 难度比例key 为 1/2/3 };界面上放三个QSpinBox控制题数和分值三个QDoubleSpinBox控制难度比例从界面收集后构建 specGroupSpec 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 道题”的卷子不满。修复思路是在抽题前先给每个知识点设置最小配额优先按知识点抽再按难度补随机。实现时我保留原有的难度比例逻辑只是把抽题顺序从“按难度”改成“先按知识点配额、再按难度配额”QListQuestion generationWithCoverage(const GroupSpec g, int minPerPoint) { QListQuestion picked; QSetint 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 个知识点的题库每个知识点设置minPerPoint1同一份卷面规格跑 20 次统计每次卷面覆盖的知识点个数。没加约束前平均覆盖 3 个左右加了这个配额逻辑后20 次全部覆盖 5 个知识点。如果生成的卷子仍然偏科大概率是某个知识点根本没有符合条件的题这种情况要在校验阶段就提示老师补题而不是等卷子生成后再补救。从那次以后我每次改完组卷规则都会强制自己跑一遍“知识点覆盖度回归”脚本确认随机引擎没有漏掉重点章节再交付而不只是看一眼“能编过、能跑起来”就完事。希望这个习惯和这段代码能帮你少走一次改完组卷却被老师打回重出的弯路。本文还有配套的精品资源点击获取