简介:一套基于C++与Qt框架开发的学生选课与成绩管理系统,覆盖学生、教师、管理员三类角色,适用于高校课程设计、毕业设计或Qt入门实践。学生可登录查询成绩、自主选课;教师能够录入、修改和删除课程成绩,并可管理所授班级的教学信息;管理员则统一维护学生、教师与课程数据,支持账户创建、权限调整和密码重置等操作。压缩包共计106个文件,以cpp源文件、h头文件、ui界面文件为主,分别对应业务逻辑、类声明和窗口设计,另有pro工程配置、makefile及编译好的exe可执行程序,整体大小约6.99MB。通过阅读源码可学习Qt控件布局、信号与槽机制、SQL数据库读写以及多角色权限管理的实现方式;直接运行exe则能快速预览系统各模块的完整交互流程,便于二次开发或功能扩展。目前已有4762人学习下载,适合需要完整项目参考的初学开发者及相关专业学生。 做选课系统这种老掉牙的题目,大多数人第一反应都是“又来了”“有什么好做的”。但真把需求捋清楚、界面做得像回事、权限边界划分明白,坑其实不少。尤其用Qt来做,很多人在第一步就栽了——不会搭项目结构,或者用QWidget一把梭,最后代码全堆在一个mainwindow.cpp里,改一个需求要翻三四个文件。我这段时间正好把这么一套QT学生选课与成绩管理系统完整地写了,跑了测试,也踩了若干坑。这篇把设计思路、模块拆分、关键技术点和实际遇到的问题一次性讲清楚,给后来人留个可直接抄作业的底稿。
1. 项目整体设计与思路拆解
1.1 核心需求解析
这套系统名义上是“选课+成绩”,但拆开来看,其实是三个C端(学生、教师、管理员)和一个公共数据层之间的协作问题。学生要能登录、改密码、浏览课程列表、选课/退课、查成绩;教师要能登录、查看自己名下课程、给学生录成绩、维护课程基本信息;管理员最麻烦,要做账号管理、开课审核、数据统计、学期切换。三个角色不能越权,界面不能混用,数据必须统一落库。
很多人在这里会犯一个错误:把三种角色的界面全部塞到同一个Widget里,通过登录后的身份判断去隐藏/显示按钮。这样不是不能跑,但代码会迅速腐化。我的做法是三个独立的界面类,分别继承自QMainWindow,只通过登录控制模块决定进入哪个窗口。这样每个角色模块内部高内聚,互相之间不直接依赖,后续加功能、改样式都不容易影响其他端。
1.2 技术选型:为什么用Qt而不是Web或Electron
选技术栈这件事,本身就是一个说服需求方的过程。Qt最大的优势在于它是编译型、跨平台的桌面框架,启动快、内存占用可控,不像Electron动不动就几百MB。更重要的是,Qt的信号槽机制天然适合这种多窗口、多角色间消息传递的场景。三个窗口之间虽然各自独立,但都依赖同一个数据服务层,共享登录状态、操作日志和数据库连接,这一层同步机制用信号槽来处理非常自然。
数据库我选的是SQLite,这是做本地系统最务实的选择,部署不需要单独安装服务,QSqlDatabase原生支持。如果后续要改成MySQL,只需要在数据库创建代码里替换连接类型和相关查询方言。
1.3 项目结构:千万别把代码全堆一个文件夹
Qt的工程文件(.pro)管理程度决定了后期能走多远。我按照MVC的分层思想把目录拆成了这样:
CourseSystem/ ├── CourseSystem.pro ├── common/ # 公共工具、常量、数据库操作封装 │ ├── dbmanager.h/cpp │ ├── session.h/cpp │ └── utils.h/cpp ├── model/ # 数据模型(针对QTableView的后端模型) │ ├── coursemodel.h/cpp │ ├── stumodel.h/cpp │ └── grademodel.h/cpp ├── view/ # 界面 │ ├── loginwindow.h/cpp │ ├── studentwindow.h/cpp │ ├── teacherwindow.h/cpp │ └── adminwindow.h/cpp └── resources/ # 样式表、图标、字体资源这样切的好处是:界面部分不直接操作SQL语句,所有数据操作都通过DbManager走数据访问接口。举个实际场景:管理员想要新增“批量导入学生”的功能,只需要在DbManager里加一个事务方法,然后AdminWindow里调用并刷新表格,完全不碰其他模块的代码。
2. 核心功能模块与数据模型设计
2.1 用户登录与会话管理
登录是整个系统的入口,这块如果做不好,后面所有权限控制全是空谈。我做的登录窗口非常简单,就是一个用户名框、密码框和角色下拉框,但后台逻辑需要注意几个点。
第一,密码不能明文存储。虽然咱们这是个课程设计级别的东西,但用Qt的QCryptographicHash做一次SHA-256散列存储是基本的职业素养。注册时散列一次存库,登录时把输入用同样的算法散列后去比对,数据库里永远不出现明文密码。
第二,登录成功后的“会话状态”需要全局可访问。我在common里单独维护了一个Session单例,保存当前登录用户的ID、姓名、角色、登录时间。三个主窗口都从Session::instance()里取身份信息,而不是把用户名当作参数层层传递。这种设计在后续写“记录用户操作日志”时特别好加,因为日志模块只需要在DbManager的写操作里统一读一下Session,就知道是谁干的。
第三,按角色跳转窗口。登录验证通过后,根据role字段决定是StudentWindow、TeacherWindow还是AdminWindow,登录窗口本身hide()而非close(),保证退出登录时还能回来。这里有朋友会问为什么不直接销毁登录窗口,因为如果窗口在栈上声明,跳转后生命周期不好控制;如果在堆上new,则需要在主窗口里保存指针方便调回。我是在main函数里把LoginWindow设置为QW,用show()切换的。
2.2 数据库表设计
数据库表是整个系统的心脏。直接罗列一下我建的表结构,这个结构基本覆盖了常见的选课和成绩管理需求。
用户表(user):
- id INTEGER PRIMARY KEY AUTOINCREMENT
- username TEXT UNIQUE NOT NULL
- password TEXT NOT NULL (存SHA-256散列)
- role INTEGER NOT NULL (1学生 2教师 3管理员)
- name TEXT NOT NULL
- create_time TEXT
学生表(student):
- id INTEGER PRIMARY KEY AUTOINCREMENT
- user_id INTEGER NOT NULL REFERENCES user(id)
- student_no TEXT UNIQUE NOT NULL (学号)
- class_name TEXT
- major TEXT
教师表(teacher):
- id INTEGER PRIMARY KEY AUTOINCREMENT
- user_id INTEGER NOT NULL REFERENCES user(id)
- teacher_no TEXT UNIQUE NOT NULL (工号)
- title TEXT (职称)
课程表(course):
- id INTEGER PRIMARY KEY AUTOINCREMENT
- course_no TEXT UNIQUE NOT NULL (课程号)
- course_name TEXT NOT NULL
- teacher_id INTEGER REFERENCES teacher(id)
- credit REAL NOT NULL
- capacity INTEGER NOT NULL DEFAULT 60
- selected_count INTEGER NOT NULL DEFAULT 0
- schedule TEXT (上课时间,比如“周一3-4节”)
- status INTEGER DEFAULT 1 (1可选,0封版)
选课表(elective):
- id INTEGER PRIMARY KEY AUTOINCREMENT
- student_id INTEGER NOT NULL REFERENCES student(id)
- course_id INTEGER NOT NULL REFERENCES course(id)
- std_score REAL DEFAULT -1 (-1代表未录入成绩,期末成绩不会录入是-1)
关于selected_count这个字段,很多人在设计时会想“这个不是能从选课表里count出来吗,为什么要冗余出来”。实际情况是:列表页要显示每个课程的当前人数/容量比,如果每次都去elective表里做COUNT统计,课程一多就会卡。用冗余字段配合事务更新,在选课成功时加一、退课时减一,性能会明显更稳。后面“选课冲突检查”也依赖它。
2.3 学生模块:选课与退课的事务边界
学生模块做了三个功能:课程列表(含已选/可选状态)、我的课表、成绩查看。
课程列表用QTableView + QSqlTableModel实现,直接绑定course表,筛掉status为0的数据。但要实现“显示我是否已选”,我在查询里做了LEFT JOIN,把elective表中当前学生的选课记录合并进来。这样表格里可以多一列“状态”显示“已选/未选”。
选课操作是整个系统中最容易出并发问题的地方。两个学生同时选同一门只剩一个名额的课,可能出现超选。我用的是数据库事务+条件更新的方式:
BEGIN TRANSACTION; UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity; -- 检查是否更新成功(rowCount()是否为1) INSERT INTO elective(student_id, course_id) VALUES(?, ?); COMMIT;先执行条件更新,如果更新行数不是1,说明此时容量已满或者被其他事务抢占了,直接回滚事务并提示“课程容量已满”。这种方式比先查容量再插入靠谱得多,因为它把“检查和更新”做成了原子操作。
退课逻辑类似,先删elective记录,再对course表执行selected_count = selected_count - 1。我这边还加了个限制:如果该课程成绩已经录入(std_score不是-1),不允许退课,只能联系管理员处理。这算是一个比较贴近真实教务场景的约束。
2.4 教师模块:成绩录入的批量处理和格式校验
教师登录后,看到的界面只包含自己创建的课程。这部分我用teacher_id关联来过滤course表,杜绝教师看到别的老师的课。点击某个课程后,右侧显示选修这门课的学生列表(关联elective和student表),然后在“成绩”列录入平时分或期末总分。
成绩录入最让人头大的是格式和范围。我在QTableWidget的itemChanged信号里做了实时校验,输入不是数字直接拒绝并还原,超出0-100范围提示“成绩应在0~100之间”。有同行说这会影响输入手感,但实测下来,直接限制总比录完统一校验返工要省事。如果有批量导入成绩的需求,可以用QFileDialog选择CSV文件,按学号匹配写入,并把失败的行号记录到日志。
录完后点“提交”,我对整列数据做一次事务提交。这样一旦中间有某一行格式异常,全部回滚,不会出现录了一半就保存的尴尬。
2.5 管理模块:用户管理、数据统计与开课维护
管理员的界面元素最多,我分了三个Tab:用户管理、课程管理、数据看板。
用户管理里,管理员可以新增学生/教师账号(输入学号、姓名、初始密码,默认123456),也可以禁用某账号(把user表status置0)。这里有个注意点,禁用账号不是删除,因为删除会产生外键孤儿数据。一个学生如果已经选了课,强行删除用户记录会导致选课表里的student_id悬空。应该用status字段控制能否登录。
课程管理里,管理员可以审批教师提交的新课程申请,也可以直接创建课程、分配授课教师。分配教师时用QComboBox动态加载教师表数据,方便选择。这一块的界面设计我用了QTabWidget+ 左右分栏,左边是教师列表,右边是负责的课程列表,视觉上清晰很多。
数据看板做了三个统计卡片:总学生数、总教师数、本学期选课人次。用QChart画了一个简单的柱状图展示各门课程选课人数对比,方便管理员掌握哪些课是“爆款”哪些是“冷门”。QChart搞这个非常快,缺点是Qt Charts模块需要单独在.pro里QT += charts,万一用的低版本还要手动安装,这个在后面问题部分展开。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
我开发时用的是Qt 5.15.2 + Qt Creator + MinGW 64bit,这套组合最省心,在线安装时选择Qt 5.15.2组件包的MinGW 8.1.0 64-bit即可。国内网络下载慢就挂镜像源,qt镜像社区里找。装完记得勾选“Qt Charts”和“Qt SQL”模块,别等到编译报错再回头装。
新建项目时选Qt Widgets Application,Keeps the traditional widget-based desktop app,这也是学生系统的主流选择,不建议用QML/Qt Quick,虽然动画效果好看,但做这种密集型表单交互并不顺手,而且对新手不友好。项目名我起的是CourseSystem。
.pro文件里的核心配置如下:
QT += core gui sql charts greaterThan(QT_MAJOR_VERSION, 4): QT += widgets TARGET = CourseSystem TEMPLATE = app CONFIG += c++11 # 包含各子目录 INCLUDEPATH += common model view SOURCES += main.cpp \ common/dbmanager.cpp \ common/session.cpp \ common/utils.cpp \ view/loginwindow.cpp \ view/studentwindow.cpp \ view/teacherwindow.cpp \ view/adminwindow.cpp \ model/coursemodel.cpp HEADERS += ... RESOURCES += resources/resources.qrc3.2 数据库初始化:代码建表,不用手搓
数据库的连接和建表我全部放在DbManager::init()里。这是个静态方法,程序启动后调用一次,不存在就创建文件,表不存在就自动建。这样整个项目完成之后,把可执行文件拷到别的电脑,不用额外装数据库,双击就能跑起来。
关键代码如下示意:
bool DbManager::init() { QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); db.setDatabaseName("course_system.db"); if (!db.open()) { qDebug() << "数据库打开失败" << db.lastError().text(); return false; } QSqlQuery query; query.exec("CREATE TABLE IF NOT EXISTS user (...)"); // 其他建表语句... return true; }建表之后顺手做一件事:插入默认管理员账号。这样系统打包给其他人用时,至少能用一个初始管理员账号登进去。默认账号admin/123456,重点是在首次登录时强制修改密码。
3.3 登录窗口到主窗口的切换逻辑
main.cpp里最核心的部分就是控制登录到主界面的连接。这里用的是Qt经典的单窗口轮换模式:
int main(int argc, char *argv[]) { QApplication a(argc, argv); DbManager::init(); LoginWindow login; login.show(); return a.exec(); }LoginWindow内部的跳转逻辑放在LoginWindow::onLoginSuccess(int role)里。实例化对应的主窗口后,用stackedWidget的动态替换或者简单的指针法切换。我实际用的是:
// 给每个主窗口的内存管理留一个指针,退出时delete if (role == 1) { StudentWindow *w = new StudentWindow(); w->setAttribute(Qt::WA_DeleteOnClose); w->show(); } else if (role == 2) { ... } this->close();这里有个细节:close()之后登录窗口并没有真正销毁,因为它是栈对象,只要主事件循环还在运行它就还在。新窗口设置WA_DeleteOnClose可以防止内存泄漏。
3.4 模型视图设计:QSqlTableModel还是自定义Model
在课程列表界面,直接绑QSqlTableModel是最快的方法,但它在排序、筛选上需要跑SQL,稍显笨重。我的做法是自定义了一个CourseModel类,继承QAbstractTableModel,内部持有的数据源是QList<CourseInfo>,每次刷新从DbManager查询后填充list并beginResetModel()。
好处有两点:
- 切页、刷新很快,不用每次都重新查询;
- 可以灵活在
data()里做自定义渲染,比如已选课程用绿色前景色,人数已满用灰色。
QVariant CourseModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) return QVariant(); if (role == Qt::DisplayRole) { // 按列返回CourseInfo的字段 } else if (role == Qt::ForegroundRole) { CourseInfo info = m_courses.at(index.row()); if (info.selected) return QColor(Qt::darkGreen); if (info.selectedCount >= info.capacity) return QColor(Qt::gray); } return QVariant(); }初学者总是纠结“到底用现成的还是自己写Model”,我的建议是:凡是只读展示的场景直接上Model,凡是需要自定义颜色、图标、按钮的再继承重写,别一上来就全部自定义,浪费时间还容易出bug。
3.5 Qt Charts绘制统计图:10行代码出图
管理员数据看板里那个柱状图,其实是整个系统最不需要动脑的部分,因为QChart把它封装到了“不能再简单”的程度。
QtCharts::QBarSet *set = new QtCharts::QBarSet("选课人数"); for (int i = 0; i < courseNames.size(); i++) { *set << selectedCounts[i]; } QtCharts::QBarSeries *series = new QtCharts::QBarSeries(); series->append(set); QtCharts::QChart *chart = new QtCharts::QChart(); chart->addSeries(series); chart->setTitle("各课程选课人数对比"); chart->createDefaultAxes(); ui->chartView->setChart(chart);有个操作要点是chartView必须在界面里先放好一个QChartView控件,而不是手动new出来再layout。直接在设计器里拖一个QChartView,代码里ui->chartView->setChart(chart),刷新、替换实例都很方便。
4. 常见问题与排查技巧实录
4.1 跨平台XCB、xrandr报错:Linux下运行Qt的坎儿
如果你是在Linux上开发或部署,启动时很容易碰到类似这种报错:
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found. this application failed to start because no Qt platform plugin could be initialized. qxcbconnection: failed to initialize xrandr Qt: XKEYBOARD extension not present on the X server.原因基本都是系统缺少X11依赖库。遇到这种情况,99%是少装了libxcb-*相关库,或者XKEYBOARD扩展没开。我在Ubuntu 20.04上测试时提示XKEYBOARD extension not present,后来是装齐了这些包解决的:
sudo apt install libxcb-xinerama0 libxcb-cursor0 libxcb-icccm4 \ libxcb-keysyms1 libxcb-shape0 libxkbcommon-x11-0还有种情况是远程SSH连接时没有DISPLAY环境变量,导致Qt试图初始化GUI但找不到显示服务。本地测试还好,一旦放到开发板上要特别注意设置export DISPLAY=:0。
4.2 qsqlite数据库驱动没有编译进去
比较隐蔽的一个坑:程序在开发机上跑得好好的,换台电脑双击exe就崩,或者提示“driver not loaded”。原因通常是发布时只拷了exe,没有拷贝sqldrivers目录下的qsqlite.dll(Windows)或libqsqlite.so(Linux)。Qt的数据库驱动是插件机制,必须在程序运行时能找到对应的插件路径。
解决方式是用Qt自带的windeployqt.exe工具部署:
windeployqt CourseSystem.exe --no-translations它会自动把依赖的Qt DLL和插件目录(包括sqldrivers、platforms、styles)一并拷贝到exe所在目录。拷贝完后可以检查一下目录里是否有sqldrivers/qsqlite.dll,没有就手动从Qt安装目录复制。
4.3 QSqlQuery执行UPDATE却没有生效
这类问题我在群里帮人排查过好多次,典型的“症状”是:日志里显示执行成功,但数据库里的数据没变,或者重启程序后数据回来了。
核心原因有两个:
- 第一是忘了提交事务。如果用
QSqlDatabase::transaction()开启事务,后面必须显式commit(),否则所有改动在连接关闭时全部回滚。 - 第二是数据库路径不对。SQLite是文件数据库,如果程序的工作目录(working directory)和数据库文件实际所在目录不一致,不同模块可能打开的是不同路径下的两个文件。这个问题只在你打印
QSqlDatabase::database().connectionName()和QCoreApplication::applicationDirPath()时才会发现。
更隐蔽一点的问题:对同一个SQLite文件同时建立多个QSqlDatabase连接,并且又没设置WAL模式,会造成“database is locked”的报错。稳妥做法是全局只维护一个数据库连接,所有查询都从DbManager获取,不要在每个窗口里各自addDatabase。
4.4 选课事务导致的死锁问题
选课的高并发场景下我踩过一次事务嵌套的坑。原先我在StudentWindow::selectCourse里调了DbManager::selectCourse(),这个函数内部开了事务。但如果在调用前,界面上某个信号连接里已经手动开启了一个未提交的事务,就会嵌套。
SQLite对嵌套事务的处理是:内层BEGIN会隐式提交外层事务,这与预期不一致。最终表现是学生选课成功后,教师端录成绩时发现数据丢了,当时排查了很久。
解决办法是统一规范:所有写入操作入口都走DbManager,事务只在这层开启和提交,界面层绝不允许直接transaction()。我还在每个写事务结束后用Q_EMIT dataChanged()通知所有相关界面刷新,而不是依赖界面自动刷新。
4.5 中文字体与编码问题
Qt 5默认的UTF-8对中文支持很好,但Windows下如果用了MSVC编译器,源文件编码必须保存为UTF-8 with BOM,否则中文字符串会乱码。这个属于老生常谈,但每次换环境还会有人中招。在main.cpp顶部可以加一句QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));,源码文件统一用UTF-8编码保存,界面上的中文字符串就不会出幺蛾子。
SQLite里查询中文也同样没问题,但要注意QSqlQuery的prepare语句里中文字符串必须经过QString::fromUtf8()或者直接用QString字面量,别随手用const char*拼接SQL。
5. 发布与后续扩展建议
整个系统开发完,发布这块是很多新手的最后一堵墙。在Windows上建议用Release模式构建,编译完用windeployqt做依赖收集,数据库文件可以在程序第一次启动时自动生成。如果想要更专业的安装体验,可以用Qt Installer Framework打一个安装包,但那个学习成本不低,在课程设计或者小范围部署的语境下,绿色解压版完全够用。
代码整洁度这块多说一句:系统做完后我特意花了半天重构了所有硬编码的SQL语句,统一收拢到DbManager里。这个改动短期看只是“搬代码”,但对后续加功能(比如加一个“教师查看所授课程学生名单并导出Excel”的需求)极其有帮助。你只需要在DbManager加一个方法返回数据列表,再在界面上加一个导出按钮,调QFileDialog选择保存路径,用QAxObject或者第三方库导出Excel即可。
6. 避坑总结与个人体会
最后分享我在实际开发中的几个体会。第一个是权限控制一定要从第一步就做,不要先做功能再做权限,否则最后会发现到处都在判断身份,代码混乱到不敢动。第二个是数据库和界面的分层要彻底,界面尽量不出现SQL语句,所有的数据变更都走封装好的方法,未来加功能、换数据库都会轻松很多。第三个是不要迷信QSS美化,系统稳定的核心在数据一致性上,界面简洁干净够用就行,花太多时间调圆角和阴影对课程设计来说是本末倒置。
另外,测试环节建议多写一些“非法输入”用例。比如选课容量为负、成绩录入超过100、用户名带空格、退课时该课程成绩已录入等等。Qt的QIntValidator、QRegularExpressionValidator这些控件级校验能省下很多后端判断,但核心的数据完整性校验还是要在写入数据库前做一遍,不能只靠界面控件拦着。
这套系统目前跑下来的状态是:学生选课退课流畅、教师录成绩顺手、管理员看板直观,整个项目在同级别课程设计里属于“足够交差且有一定亮点”的水平。后面如果继续扩展,可以加的模块包括公告通知、课程评价、培养方案计划等,核心架构不用动。有正在做同类项目的朋友,如果卡在哪部分过不去,顺着我这个设计思路逐块对照排查,大概率能找到问题所在。
本文还有配套的精品资源,点击获取