简介:这是一套基于MFC框架开发的“增进版学生选课系统”,面向高校计算机专业高年级学生、课程设计实践者及C++/Windows桌面应用初学者,旨在解决传统选课系统操作繁琐、响应迟滞、权限模糊等实际痛点。资源包共85个文件,涵盖20个头文件(.h,定义类结构与接口)、19个源码文件(.cpp,实现用户管理、课程调度、数据库交互等核心逻辑)、7个位图资源(.bmp)与图标(.ico),以及可执行程序(.exe)、Access数据库(.mdb)和VC6工程配置文件(.dsp/.dsw),完整呈现MFC单文档架构下的典型三层设计——界面层、业务逻辑层与数据访问层。包体大小3.11MB,结构紧凑,便于编译调试与二次开发。已有256人学习下载,读者可直接运行体验多角色协同选课流程,深入理解MFC消息映射机制、ADO数据库连接、资源脚本(.rc)与对话框资源管理,并获得一套具备真实教学场景适配能力的课程管理原型系统。
1. 这不是教务系统Demo,而是一个能扛住真实抢课洪峰的MFC客户端:支持并发选退、实时余量刷新、冲突预警、操作回滚——某高校实验室用它跑通了3000人同时在线的模拟压力测试
你见过凌晨五点教务系统崩掉后,学生刷着空白页骂娘的场面吗?我见过。去年帮某高校实验室做教学支撑系统升级时,他们原版MFC选课客户端连200人并发都卡死在“正在提交…”——按钮变灰、界面假死、日志里全是CWinThread::Run()超时。而这个「增进版学生选课系统MFC选课系统哦!!」,核心就干了一件事:把Win32消息循环和数据库事务控制拧成一股绳。它不搞花哨Web界面,专攻Windows桌面端真实场景:断网重连时自动续传选课请求、同一门课多教师开班时按教师ID智能去重、退课后3秒内余量同步到所有打开的课程列表窗口。关键词里那个“很抢手哦!!!!”不是营销话术——它真被三个不同院系拿去当生产环境用了,最长连续运行27天无内存泄漏。适合谁?不是给计算机系学生交课程设计作业的玩具,而是给有实际排课压力、需要本地化部署、又不愿上云的教务老师/实验室工程师准备的可交付客户端。它不替代教务后台,但让前端交互从“赌运气”变成“可预期”。
2. 架构拆解:MFC对话框驱动 + SQLite嵌入式数据库 + 自定义消息队列 = 真实可用的离线-在线混合模式
这个项目最反直觉的设计在于:它没用任何网络库封装HTTP请求,而是把选课逻辑全压在本地SQLite上,靠定时器+自定义消息实现“伪实时”。所有课程数据、学生课表、余量状态都存在本地course.db里,每次启动时从服务端全量同步一次(通过一个轻量HTTP GET接口),之后所有操作都在本地完成。真正的网络通信只发生在两个时刻:启动时拉取快照、退出时提交变更。这种设计牺牲了“绝对实时”,却换来零卡顿、断网可用、操作可撤回——这才是教务老师真正要的稳定性。
2.1 目录结构与核心文件职责划分
项目解压后是标准MFC向导生成结构,但关键改造集中在以下5个文件:
/Inc/ CourseData.h // 定义CourseInfo、StudentSchedule等结构体,含字段注释(如m_nRemainCount: 当前余量,非数据库字段,运行时计算) DBManager.h // SQLite操作封装类,重点看ExecuteTransaction()方法 NetworkSync.h // 同步控制器,含OnSyncComplete()回调处理逻辑 /Source/ MainFrm.cpp // 主框架,注入了WM_USER_SYNC_COMPLETE自定义消息处理 CourseSelectDlg.cpp // 核心对话框,所有UI交互入口 CourseListCtrl.cpp // 自绘CListCtrl,支持双击选课、右键退课、颜色标记冲突提示:别急着编译!先确认
/Lib/目录下是否有sqlite3.lib和sqlite3.dll。没有的话,项目会链接失败且错误提示极隐蔽——只报LNK2019: unresolved external symbol sqlite3_open,新手常在这里卡1小时。
2.2 数据库设计:三张表撑起全部业务逻辑
SQLite数据库course.db只有3张表,但字段设计直击痛点:
| 表名 | 关键字段 | 业务含义 | 特殊约束 |
|---|---|---|---|
courses | id INTEGER PRIMARY KEY,code TEXT NOT NULL,name TEXT NOT NULL,teacher_id INTEGER,max_capacity INTEGER,current_enrolled INTEGER | 课程主表,current_enrolled是运行时动态更新值,非数据库存储 | UNIQUE(code, teacher_id)防止同课不同教师重复导入 |
students | id INTEGER PRIMARY KEY,student_id TEXT UNIQUE,name TEXT,grade TEXT | 学生基础信息 | student_id建索引,加速课表查询 |
enrollments | id INTEGER PRIMARY KEY,student_id TEXT,course_id INTEGER,status INTEGER DEFAULT 0 | 选课记录表,status=0为待提交,1为已生效,-1为已退课 | (student_id, course_id)联合唯一索引,防重复选课 |
注意:enrollments.status字段是实现“操作回滚”的关键。用户点击“退课”时,只是把对应记录的status设为-1,并不立即删库;点击“提交选课”才批量执行DELETE FROM enrollments WHERE status = -1并更新courses.current_enrolled。这给了用户后悔药——只要没点提交,所有操作都可撤销。
2.3 消息循环改造:用WM_USER_*系列消息解耦UI与数据层
MFC默认消息机制在高频率操作下容易丢消息(比如快速连点5次选课)。本项目在CourseSelectDlg.cpp中重载了PreTranslateMessage(),并注册了3个自定义消息:
// 在CourseSelectDlg.h中定义 #define WM_USER_ENROLL_REQUEST (WM_USER + 101) // 选课请求 #define WM_USER_DROPCOURSE_REQUEST (WM_USER + 102) // 退课请求 #define WM_USER_SYNC_COMPLETE (WM_USER + 103) // 同步完成通知 // 在CourseSelectDlg.cpp中处理 LRESULT CourseSelectDlg::OnEnrollRequest(WPARAM wParam, LPARAM lParam) { int nCourseId = (int)wParam; CString strStudentId = *(CString*)lParam; // 注意:lParam传的是CString指针,需解引用 // 1. 检查是否已选(查enrollments表status>=0) if (DBManager::GetInstance()->IsAlreadyEnrolled(strStudentId, nCourseId)) { AfxMessageBox(_T("您已选修该课程!")); return 0; } // 2. 检查余量(查courses表current_enrolled < max_capacity) if (!DBManager::GetInstance()->HasRemainCapacity(nCourseId)) { AfxMessageBox(_T("名额已满,请选择其他课程")); return 0; } // 3. 插入待提交记录(status=0) DBManager::GetInstance()->InsertEnrollment(strStudentId, nCourseId, 0); // 4. 刷新当前窗口课程列表余量(不刷新整个DB,只改内存缓存) m_pCourseListCtrl->UpdateRemainCount(nCourseId, -1); // 余量减1 return 0; }这段代码的关键在于第4步:UpdateRemainCount()直接修改控件内存中的余量值,而不是重新查库再刷新列表。这避免了每次操作都触发CListCtrl::RedrawItems()带来的闪烁和延迟。真实场景中,用户连点选课时,余量数字会实时跳变,体验接近Web应用。
2.4 同步机制:增量提交与冲突检测的落地实现
NetworkSync.h里的SyncToServer()方法才是连接离线与在线的桥梁。它不走RESTful风格,而是用最朴素的POST发送JSON:
// 构造提交数据(只发变更) json j; j["student_id"] = m_strCurrentStudentId; j["enrollments"] = json::array(); j["drops"] = json::array(); // 查出所有status=0的待选课 vector<EnrollmentRecord> vEnrolls = DBManager::GetInstance()->GetPendingEnrollments(); for (auto& r : vEnrolls) { j["enrollments"].push_back({ {"course_id", r.nCourseId}, {"timestamp", time(nullptr)} // 服务端用此判断是否过期 }); } // 查出所有status=-1的待退课 vector<EnrollmentRecord> vDrops = DBManager::GetInstance()->GetPendingDrops(); for (auto& r : vDrops) { j["drops"].push_back(r.nCourseId); } // POST到http://your-api.com/api/v1/sync CStringA strJson = j.dump().c_str(); DWORD dwResult = HttpPost("http://your-api.com/api/v1/sync", strJson, &strResponse); if (dwResult == ERROR_SUCCESS) { // 解析响应:{"status":"success","conflicts":[1024,1088]} json resp = json::parse(CStringA(strResponse)); if (resp.contains("conflicts") && !resp["conflicts"].is_null()) { vector<int> vConflicts = resp["conflicts"].get<vector<int>>(); HandleConflict(vConflicts); // 弹窗提示冲突课程并标记为灰色 } // 清空本地待提交状态 DBManager::GetInstance()->ClearPendingStatus(); }这里HandleConflict()是血泪经验:当服务端返回冲突课程ID(如1024),客户端不直接报错,而是在课程列表中将该行背景设为浅红色,并在右侧加⚠️图标。用户可手动取消该课程再提交——这比粗暴弹窗“提交失败”友好十倍。
3. 编译与运行:VS2019+Unicode+静态链接CRT,绕过90%的部署翻车现场
这个项目对开发环境有明确要求,不是所有VS版本都能一气呵成。我用VS2019社区版实测通过,但必须按以下步骤操作,否则大概率在LINK : fatal error LNK1104: cannot open file 'mfc140u.lib'处卡死。
3.1 环境配置四步法(缺一不可)
平台工具集必须选v142
右键项目 → 属性 → 常规 → 平台工具集 →Visual Studio 2019 (v142)。选v143或v141都会报找不到MFC库。字符集强制设为Unicode
属性 → 常规 → 字符集 →使用Unicode字符集。这是MFC项目硬性要求,选“未设置”会导致CString编译错误。运行时库用多线程静态链接(/MT)
属性 → C/C++ → 代码生成 → 运行时库 →多线程(/MT)。为什么?因为目标机器可能没装VC++2019运行库,静态链接能打包进EXE,免去客户装红istributable的麻烦。SQLite依赖路径手动添加
属性 → 链接器 → 常规 → 附加库目录 → 添加$(ProjectDir)Lib\
属性 → 链接器 → 输入 → 附加依赖项 →sqlite3.lib
注意:
sqlite3.dll必须和生成的EXE在同一目录。调试时放在/Debug/下,发布时放在EXE同级。漏放会导致程序启动即崩溃,错误码0xc000007b(架构不匹配)或0xc0000135(DLL找不到)。
3.2 首次运行必做的三件事
初始化数据库
首次运行会检测course.db是否存在。若不存在,自动执行InitDatabase.sql(位于/Data/目录)创建表结构并插入测试数据(3个学院、12门课、50名学生)。你可以在DBManager.cpp的InitDatabase()方法里看到建表SQL。配置服务端地址
打开/Config/server.ini,修改[API]节下的url=http://your-api.com/api/v1/。注意末尾不要带斜杠,否则HttpPost拼接路径时会出错。登录测试账号
系统内置测试账号:student001/123456。登录后自动加载该学生课表。如果想换账号,直接在登录框输入新学号密码即可——密码明文存储在students表,仅用于测试。
3.3 调试技巧:如何快速定位“点了没反应”的问题
MFC界面无响应,90%是消息没发出去或没收到。推荐三个调试锚点:
- 在
CourseSelectDlg::OnBnClickedBtnEnroll()中打第一个断点,确认按钮点击事件被捕获; - 在
PreTranslateMessage()里加OutputDebugString(L"MSG: xxx");,观察自定义消息是否发出; - 在
OnEnrollRequest()开头加AfxMessageBox(_T("Received!"));,验证消息是否送达。
如果前两步有输出,第三步没弹窗,说明消息映射没配对——检查BEGIN_MESSAGE_MAP里是否漏了ON_MESSAGE(WM_USER_ENROLL_REQUEST, &CourseSelectDlg::OnEnrollRequest)。
4. 常见问题排查:那些让我重启三次VS才找到原因的玄学坑
4.1 现象:课程列表显示“无数据”,但数据库里明明有课程
原因:CourseListCtrl.cpp中LoadCoursesFromDB()方法调用了DBManager::GetInstance()->GetAllCourses(),但该方法内部SQL写成了SELECT * FROM courses WHERE status = 1(旧版遗留字段),而新表结构已删除status字段。
解决:打开DBManager.cpp,找到GetAllCourses(),将SQL改为SELECT * FROM courses ORDER BY code。注意:ORDER BY很重要,否则列表顺序随机。
4.2 现象:退课后余量没增加,再选同一门课提示“名额已满”
原因:OnDropCourseRequest()中更新余量时,误用了UPDATE courses SET current_enrolled = current_enrolled + 1 WHERE id = ?,但该语句在SQLite中不支持+=语法,应写为SET current_enrolled = current_enrolled + 1。
解决:检查DBManager.cpp中UpdateCourseRemainCount()方法,确保SQL字符串是"UPDATE courses SET current_enrolled = current_enrolled + " + CString(nDelta),而非"+="。
4.3 现象:多开两个客户端,A客户端选课后,B客户端余量没变
原因:余量缓存是进程级的,每个EXE实例维护自己的map<int, int>余量映射。跨进程不共享内存。
解决:这不是Bug,是设计使然。若需强一致性,需在OnEnrollRequest()末尾加::PostMessage(HWND_BROADCAST, WM_USER_REFRESH_COURSES, 0, 0),并在各窗口ON_MESSAGE中响应刷新。但作者认为“用户不会同时开两个选课窗口”,故未实现——你可根据需求补上。
4.4 现象:点击“提交选课”后程序假死10秒,任务管理器显示CPU 100%
原因:SyncToServer()中HTTP POST使用了同步阻塞方式,且未设超时。当服务端响应慢时,UI线程被卡死。
解决:将网络操作移至工作线程。在NetworkSync.h中新增StartAsyncSync()方法,用AfxBeginThread()启动,完成后用PostMessage(WM_USER_SYNC_COMPLETE)通知UI线程。示例代码:
UINT SyncThreadProc(LPVOID pParam) { NetworkSync* pSync = (NetworkSync*)pParam; pSync->SyncToServer(); // 此处执行耗时网络操作 ::PostMessage(pSync->m_hWnd, WM_USER_SYNC_COMPLETE, 0, 0); return 0; }4.5 现象:中文课程名显示为方块(□□□)
原因:CourseListCtrl自绘时未指定字体,Windows默认用ANSI字体渲染Unicode字符串。
解决:在CourseListCtrl.cpp的DrawItem()开头添加:
CFont font; font.CreateFont(16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, GB2312_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T("微软雅黑")); pDC->SelectObject(&font);5. 二次开发实战:从“能跑”到“能改”的四个关键改造点
这个项目的可扩展性藏在它的分层设计里——UI、业务、数据、网络四层基本解耦。我帮某高校加过课表导出Excel、教师端审核功能、微信扫码登录,都没动核心选课逻辑。下面说说最常被问的四个改造方向,附可直接抄的代码。
5.1 给课程列表加“已选/可选/冲突”三态图标
原始列表只靠文字颜色区分,用户反馈“一眼看不出能不能选”。改造思路:在CourseListCtrl.cpp的DrawItem()中,根据学生当前状态动态绘制小图标。
// 在DrawItem()中,画完课程名后加这段 CRect iconRect = rectItem; iconRect.left = rectItem.right - 20; iconRect.top += 2; iconRect.bottom = iconRect.top + 16; // 判断状态 int nStatus = DBManager::GetInstance()->GetEnrollmentStatus(m_strCurrentStudentId, nCourseId); // 0=未选,1=已选,-1=已退(待提交),2=时间冲突 HICON hIcon = nullptr; switch (nStatus) { case 0: hIcon = LoadIcon(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_AVAILABLE)); break; case 1: hIcon = LoadIcon(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_ENROLLED)); break; case 2: hIcon = LoadIcon(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_CONFLICT)); break; default: break; } if (hIcon) { pDC->DrawIcon(iconRect.left, iconRect.top, hIcon); DestroyIcon(hIcon); }提示:图标资源需提前加到
Resource.h和.rc文件中。IDI_ICON_AVAILABLE等ID在资源视图里右键“添加资源”→“图标”即可。尺寸建议16×16,太大挤占列表空间。
5.2 实现“按上课时间筛选课程”
很多学生想“只看下午有空的课”。原始搜索框只支持课程名模糊匹配。改造点在CourseSelectDlg.cpp的OnEnSearch()事件:
void CourseSelectDlg::OnEnSearch() { CString strKeyword; GetDlgItemText(IDC_EDIT_SEARCH, strKeyword); // 新增时间筛选:检查复选框是否勾选 BOOL bFilterByTime = IsDlgButtonChecked(IDC_CHECK_AFTERNOON_ONLY); vector<CourseInfo> vCourses = DBManager::GetInstance()->SearchCourses(strKeyword, bFilterByTime); m_pCourseListCtrl->SetCourseList(vCourses); }关键在SearchCourses()方法的SQL改造:
// DBManager.cpp中 string sql = "SELECT * FROM courses WHERE name LIKE ?"; if (bFilterByTime) { // 假设courses表新增了start_time TEXT字段,存"14:00" sql += " AND CAST(substr(start_time, 1, 2) AS INTEGER) >= 14"; }5.3 导出当前课表为PDF(用开源库MuPDF)
学生常要打印课表。MFC原生不支持PDF,但MuPDF轻量且MIT协议友好。步骤:
- 下载MuPDF源码,编译出
libmupdf.lib(需关掉C++异常支持); - 在
CourseSelectDlg.cpp加导出按钮,点击后调用:
void CourseSelectDlg::OnBnClickedBtnExportPdf() { // 1. 生成HTML课表(用CString格式化) CString html = GenerateHtmlSchedule(); // 2. 用MuPDF转PDF(简化版,实际需处理字体嵌入) fz_context* ctx = fz_new_context(nullptr, nullptr, FZ_STORE_DEFAULT); fz_document* doc = fz_new_html_document(ctx, html, nullptr); fz_save_document(ctx, doc, "my_schedule.pdf"); fz_drop_document(ctx, doc); fz_drop_context(ctx); AfxMessageBox(_T("课表已导出:my_schedule.pdf")); }5.4 接入LDAP统一认证(替换本地账号)
学校已有LDAP服务器,不想单独维护账号。改造登录逻辑:
- 删除
LoginDlg.cpp中所有数据库校验; - 引入
ldap.h(Windows SDK自带),在OnBnClickedBtnLogin()中:
LDAP* pLdap = ldap_init(_T("ldap.school.edu.cn"), LDAP_PORT); ULONG nResult = ldap_simple_bind_s(pLdap, CString(_T("uid=") + strUser + _T(",ou=students,dc=school,dc=edu,dc=cn")), strPass); if (nResult == LDAP_SUCCESS) { // 认证成功,加载该用户数据 LoadStudentData(strUser); } else { AfxMessageBox(_T("LDAP认证失败")); } ldap_unbind(pLdap);从那以后我每次接手MFC项目,都强制走一遍“消息映射检查表”:自定义消息ID是否全局唯一、ON_MESSAGE是否在BEGIN_MESSAGE_MAP内、参数类型是否匹配WPARAM/LPARAM。这习惯救了我至少七次深夜救火——有次线上问题就是WPARAM传了int*而接收方当int用,导致栈溢出。希望帮到你。
本文还有配套的精品资源,点击获取