ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一套源码双入口:Win32控制台与MFC对话框实现选课系统

一套源码双入口:Win32控制台与MFC对话框实现选课系统 简介这是一套基于Win32与MFC框架实现的学生选课系统代码面向学习Windows桌面程序开发和数据库课程设计的读者可用于理解经典MFC应用的组织方式。资源共121个文件整体约124.91MB内容涵盖cpp、h源码sln、vcxproj工程配置rc、res等界面资源以及编译后生成的exe、obj、pdb、tlog等文件能直观看到从源代码到可执行程序的完整工程链。目前已有855人学习下载适合有C基础、正在做选课管理类课程设计或探索MFC开发流程的读者。通过对话框窗体、控制台入口和班级类实现等模块可以学习选课系统的界面交互、数据封装与业务逻辑划分包内数据库文件和工程调试记录则有助于在实际运行中定位问题、验证功能或继续扩展课程与成绩管理等模块。1. 一套源码两个入口正好看清 Win32 与 MFC 的边界拿到这份学生选课源码包打开目录第一眼能看到 SchoolConsole.cpp 和 SchoolDlg.cpp 两个入口文件外加 School.cpp、Class.cpp、pch.cpp以及一堆 .aps、.db 和编译好的 School.exe。多数课程设计只会给你一个控制台 main 壳或者一个对话框壳而这个工程把两套界面同时放进一个解决方案里共用同一套课程与班级的业务类。这种结构对一个想摸清 MFC 对话框工程内部机制的开发者来说价值比单纯跑通一个选课流程大得多你可以直接对比控制台入口和对话框入口如何调用同一个 School 对象可以看到 MFC 的消息映射、DoDataExchange 控件绑定、CListCtrl 数据展示各自承担什么角色。另外一个反直觉的结论是School.aps 和 Browse.VC.db 这类以缓存面目出现的文件删掉之后 VS 会自动重建它们不是源码提交工程时完全可以忽略。这个项目适合两类人一是正在做 C 课程设计、需要一套能讲清原理的选课系统参考代码的人二是写过几个 MFC 小程序、但始终没搞明白对话框消息流转和控件数据交换过程的开发者。2. Class.cpp 与 School.cpp控制台版本先立住业务骨架拿到这类选课题目最容易犯的错是一上来就在 UI 里堆代码按钮点击里直接写 vector 操作。这个工程的合理之处在于把课程、班级、选课动作全部下沉到 Class 和 School 两个类里控制台和 MFC 只是两种不同的调用壳。先把业务层拆开看后面无论接哪种界面都不会乱。2.1 课程与班级的数据结构设计Class.cpp 负责维护课程的基本信息和选课学生名单。很多教科书示例喜欢用定长 char 数组存学生姓名但在这个项目里我更推荐直接用 std::string理由在选课场景下非常具体学生姓名长度不可控定长 char 数组要么截断要么浪费而 string 在赋值和拷贝时自动管理内存。一个典型的课程类骨架如下// Class.h #pragma once #include string #include vector class Course { public: Course() : m_nId(0), m_nCapacity(50), m_nSelected(0) {} Course(int id, const std::string name, int credit, int capacity); int GetId() const { return m_nId; } const std::string GetName() const { return m_strName; } bool IsFull() const { return m_nSelected m_nCapacity; } // 返回值true 表示选课成功false 表示失败具体原因通过 reason 带出 bool SelectStudent(const std::string studentName, std::string reason); private: int m_nId; // 课程编号 std::string m_strName; // 课程名称 int m_nCredit; // 学分 int m_nCapacity; // 选课容量上限 int m_nSelected; // 当前已选人数 std::vectorstd::string m_students; // 已选学生名单 };构造函数的初始化列表里直接把容量、学分、编号全部赋值避免出现未初始化的 int 成员。这个类里最关键的是 SelectStudent 成员函数它必须同时处理容量校验和重复选课校验这两条规则是选课系统的核心约束。实现时我建议用输出参数 reason 而不是抛异常因为控制台界面和 MFC 界面都需要拿到失败原因展示给用户抛异常在这种简单场景里反而增加 UI 层的负担。// Class.cpp bool Course::SelectStudent(const std::string studentName, std::string reason) { if (IsFull()) { reason 课程已满员无法选课; return false; } for (const auto name : m_students) { if (name studentName) { reason 该学生已选过此课程; return false; } } m_students.push_back(studentName); m_nSelected; reason 选课成功; return true; }这段代码先把容量满的判断放在前面再做重复性检查顺序不能反过来如果课程容量只剩一个名额同时一个已经选过课的学生又来选应该优先提示已选过而不是已满员前者是业务逻辑错误后者是资源竞争结果。遍历 m_students 使用 const auto 避免拷贝整个 string这个细节在高频选课场景下有意义。2.2 School 类多课程的统一调度入口School.cpp 的作用是管理课程集合提供按编号查找课程、展示课程列表这两个能力。控制台版本和 MFC 版本调用的都是它因此接口设计必须足够中立不依赖 cout不依赖 CListCtrl只返回数据或操作结果。// School.h #pragma once #include Class.h #include vector class School { public: void AddCourse(const Course course); Course* FindCourseById(int id); const std::vectorCourse GetAllCourses() const { return m_courses; } private: std::vectorCourse m_courses; };这里需要留意的是 FindCourseById 返回裸指针。为什么不返回迭代器或引用因为调用方在选课成功后要刷新界面上的课程列表需要再次拿到同一门课程的数据裸指针在单线程控制台场景下生命周期是安全的。如果后续扩展成多线程选课这里就要改成返回 shared_ptr 或按照 id 重新查询避免悬垂指针。SchoolConsole.cpp 主循环里的做法通常是这样的输出菜单号、读取输入、调用 School 的对应方法、根据返回值决定是否继续循环。// SchoolConsole.cpp 核心循环 int main() { School school; school.AddCourse(Course(1, 数据结构, 4, 60)); school.AddCourse(Course(2, 操作系统, 3, 50)); while (true) { std::cout 1. 查看课程 2. 选课 3. 退出\n; int cmd 0; std::cin cmd; if (cmd 3) break; if (cmd 1) { for (const auto c : school.GetAllCourses()) { std::cout c.GetId() c.GetName() std::endl; } } else if (cmd 2) { int courseId 0; std::string studentName; std::cout 输入课程号: ; std::cin courseId; std::cout 输入姓名: ; std::cin studentName; Course* pCourse school.FindCourseById(courseId); if (pCourse nullptr) { std::cout 课程不存在 std::endl; continue; } std::string reason; if (pCourse-SelectStudent(studentName, reason)) { std::cout reason std::endl; } else { std::cout 选课失败: reason std::endl; } } } return 0; }这个主循环没有把业务逻辑写在 main 里而是全部转发到 School 和 Course 的方法上这是控制台版本给后续 MFC 改造留的最大余地。cmd 命令值用了 1、2、3 魔法数字在控制台版本里尚可接受但如果想做得更严谨建议定义枚举类型或者 const 常量。另一个细节是 std::cin studentName 遇到空格会截断如果学生姓名是张三 李四这种带空格的格式需要改用 getline 读取。2.3 控制台与 MFC 双壳复用的关键不要污染业务类把这个工程里 Class.cpp、School.cpp 单独拎出来看两个类都没有包含任何 windows.h 或 afxwin.h 头文件这是整个项目最值得借鉴的地方。很多选课系统代码把 CListCtrl 的指针直接传进业务类表面上省了事实际上业务类一旦依赖 MFC 控件类型就再也无法在控制台程序、单元测试、或后续的 Qt 迁移中复用。我一般会在自己的课程项目里定一个硬性规则凡是 .h 文件出现 afxwin.h 或者 #include afxcmn.h 的类一律不允许承载纯业务逻辑。像选课这种动作输入是课程号和姓名输出是成功或失败原因天然适合做纯 C 接口。控制台和 MFC 之间的差异点不在业务类而在命令读取方式与展示方式控制台用 std::cin 循环读取MFC 用按钮点击事件触发控制台用 std::cout 输出一行文字MFC 用 MessageBox 或者列表控件刷新。这个区分做得到位后面给 SchoolDlg.cpp 填代码时会非常顺手。3. SchoolDlg.cpp 的对话框版MFC 消息映射与控件绑定的完整链路MFC 版本的入口在 SchoolDlg.cpp。一个基于对话框的 MFC 程序启动顺序是应用类 CWinApp 的 InitInstance 里创建主对话框对象然后调用 DoModal 进入模态消息循环。这个循环内部不断从消息队列里取消息并分发到窗口过程消息映射宏的作用就是把这些 Windows 消息翻译成对应的成员函数调用。3.1 DoDataExchange控件与成员变量的双向通道SchoolDlg.cpp 里一定有一个 DoDataExchange 重写这里做的事情叫 DDX 数据交换。它的核心价值在于把编辑框里的文本、列表控件的状态和对话框类的成员变量联动起来。典型代码如下// SchoolDlg.cpp 片段 void CSchoolDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Text(pDX, IDC_EDIT_NAME, m_strStudentName); DDX_Control(pDX, IDC_LIST_COURSE, m_listCourse); }DDX_Text 的第一个参数传控件 ID第三个参数传 CString 成员变量程序在 UpdateData(TRUE) 时把控件文本写入变量UpdateData(FALSE) 时反向推送。CListCtrl 控件不能直接用 DDX_Text 绑定因为它是窗口控件而不是文本数据所以这里用 DDX_Control 将控件对象关联到成员变量 m_listCourse。之后在代码里针对 m_listCourse 调 InsertColumn、InsertItem 等方法时实际操作的就是界面上的列表控件。3.2 CListCtrl 课程列表的列与数据填充列表控件在这个项目里扮演课程表格的角色需要先设置列头再插入数据。很多初学者会在 OnInitDialog 里只插入数据而忘记 InsertColumn结果运行时列表什么都不显示这是最常见的一个坑。BOOL CSchoolDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 设置列表控件列头列索引、文本、宽度、对齐方式 m_listCourse.InsertColumn(0, _T(课程编号), LVCFMT_LEFT, 80); m_listCourse.InsertColumn(1, _T(课程名称), LVCFMT_LEFT, 120); m_listCourse.InsertColumn(2, _T(学分), LVCFMT_CENTER, 60); m_listCourse.InsertColumn(3, _T(已选/容量), LVCFMT_CENTER, 90); RefreshCourseList(); return TRUE; } void CSchoolDlg::RefreshCourseList() { m_listCourse.DeleteAllItems(); const auto courses m_school.GetAllCourses(); for (size_t i 0; i courses.size(); i) { const auto c courses[i]; CString strId; strId.Format(_T(%d), c.GetId()); CString strSelected; strSelected.Format(_T(%d/%d), c.GetSelectedCount(), c.GetCapacity()); m_listCourse.InsertItem(static_castint(i), strId); m_listCourse.SetItemText(static_castint(i), 1, c.GetName().c_str()); m_listCourse.SetItemText(static_castint(i), 2, strSelected); m_listCourse.SetItemText(static_castint(i), 3, strSelected); } }InsertColumn 的第一个参数表示列索引从 0 开始与后续 SetItemText 的第二个参数一一对应。RefreshCourseList 里先 DeleteAllItems 清空旧数据再重新填充这是最简单但也最容易出现闪烁的刷新方式。如果课程数量很大建议改用 CListCtrl 的 SetRedraw(FALSE) 在填充前关闭重绘填充完再 SetRedraw(TRUE) 并 Invalidate否则拖动滚动条时能看到明显的重影。3.3 选课按钮的消息映射与业务转发点击选课按钮后MFC 通过消息映射将 BN_CLICKED 通知路由到处理函数。这里对应 SchoolDlg.cpp 中的 OnBnClickedButtonSelect 一类的方法。先做控件内容校验再调用 School 的课程查找与选课方法最后刷新列表。void CSchoolDlg::OnBnClickedButtonSelect() { UpdateData(TRUE); // 从编辑框读取学生姓名到 m_strStudentName if (m_strStudentName.IsEmpty()) { AfxMessageBox(_T(请输入学生姓名)); return; } int nItem m_listCourse.GetSelectionMark(); // 当前选中行的索引 if (nItem 0) { AfxMessageBox(_T(请先选择课程)); return; } CString strId m_listCourse.GetItemText(nItem, 0); int courseId _ttoi(strId); Course* pCourse m_school.FindCourseById(courseId); if (pCourse nullptr) { AfxMessageBox(_T(课程不存在)); return; } std::string reason; bool bOk pCourse-SelectStudent(CT2A(m_strStudentName), reason); if (bOk) { AfxMessageBox(_T(选课成功)); RefreshCourseList(); // 刷新已选人数列 } else { AfxMessageBox(CString(reason.c_str())); } }GetSelectionMark 返回的是当前高亮行索引列表控件默认允许多选时这个值不一定可靠更严谨的做法是用 GetFirstSelectedItemPosition 配合 GetNextSelectedItem 遍历选中集合。CT2A 是 MFC 中把 CString 转成 ANSI std::string 的转换宏在这个项目里课程是 string 类型所以需要这一层转换。MFC 版本还有一个容易踩的设计问题对话框类的成员变量 m_school 在 OnInitDialog 里要初始化课程数据。如果这段初始化代码写进了 OnInitDialog 而控制台版本的初始化写在 main 里两边就产生了重复。更好的做法是让 School 提供一个 LoadDefaultData 方法两个入口统一调用这才是把这个工程做成双壳的真正意义。4. pch.cpp、School.aps 与 School.exe从编译到部署的完整闭环这个源码包里的文件不全是源代码其中 pch.cpp 与编译过程强相关School.aps 和 Browse.VC.db 是 VS 自动生成的缓存文件School.exe 则是最终要部署给用户的东西。这部分如果搞不清楚常见的此项目需要 MFC 库、缺 DLL 运行不了、改了资源却看不到效果之类的问题都会找上门。4.1 pch.cpp 与预编译头机制pch.cpp 对应的是预编译头机制。VS 在创建 MFC 项目时会生成 pch.h 和 pch.cpppch.cpp 里只有一行 #include pch.h它的作用是让编译器把 pch.h 中引入的 MFC 公共头文件预先编译成 .pch 二进制缓存。之后每个 .cpp 文件在编译时通过 /Yupch.h 命令直接复用这份缓存不必重新解析几十个 afxwin.h 头文件。项目属性里的相关配置如下文件预编译头设置作用pch.cpp创建 /Ycpch.h生成 .pch 预编译缓存文件其他 .cpp使用 /Yupch.h复用缓存来加速编译全部文件预编译头默认开启系统级头文件只解析一次如果从别处拷贝这个工程并出现无法打开包括文件 pch.h的报错先检查 pch.cpp 是否存在于工程中以及其余 .cpp 的属性是否设置了使用预编译头。图示里 SchoolConsole.cpp 和 SchoolDlg.cpp 都必须 #include pch.h 放在第一行放在其他头文件之后会触发 C1010 编译错误。4.2 School.aps 与 Browse.VC.db可安全重建的缓存文件School.aps 是资源脚本编译缓存的二进制形态。每次在 VS 资源编辑器里拖拽对话框控件并保存时.rc 文件更新.aps 同步刷新。这个文件有两个特点修改 .rc 之后如果 .aps 损坏VS 会声称资源打不开但删掉 .aps打开 .rc 时会自动重建所以它不属于源码管理范畴。Browse.VC.db 是 VS 为代码导航和 IntelliSense 创建的浏览数据库记录了类视图、函数跳转、自动补全索引等信息。这个文件对编译结果没有任何影响删除后 VS 会在后台重新扫描代码生成新的。提交到 Git 或 SVN 时这两个文件都应该被忽略。值得注意的是School.exe 是编译产物而不是源码直接双击运行它需要目标机器具备对应的运行库环境否则就会遇到缺失 MFC DLL 的报错。4.3 此项目需要 MFC 库 的修复路径与运行库打包在另一台机器上打开这个工程时经常出现此项目需要 MFC 库的提示原因是项目属性的 MFC 使用方式设置为在共享 DLL 中使用 MFC而当前机器没有安装对应版本的 MFC 组件。常见修复方式有两种解决方式操作路径适用场景安装 MFC 组件VS Installer 中勾选适用于最新 v143 生成工具的 C MFC目标机器仍用于开发调试静态链接 MFC项目属性-常规-MFC 使用-在静态库中使用 MFC同时设置 C/C-代码生成-运行库为 /MT最终交付可独立运行的 exe如果选择静态链接需要在项目属性里做两个同步修改MFC 使用方式改为在静态库中使用 MFC运行库改为多线程 /MT。只改前者不改后者链接时会报 LNK2005 这类符号重复定义错误这是因为 CRT 和 MFC 的运行库应用方式不一致导致符号冲突。部署 School.exe 时要区分开发机与目标机Debug 配置生成的小 exe 依赖 MFC90D.dllVS2008或 mfc140ud.dllVS2015 及以后这些文件只存在于安装了对应版本 VS 的机器上Release 配置且使用静态链接生成的 exe大小会从几十 KB 涨到几 MB但可以在没有安装任何运行库的干净 Windows 上运行这也是mfc项目如何打包这个问题最直接的答案。4.4 从 CSDN 文章补充的工程结构参考对应摘要中的 CSDN 链接介绍了这套源码包的结构SchoolDlg.cpp 对应对话框界面SchoolConsole.cpp 对应控制台入口Class.cpp 和 School.cpp 是业务类pch.cpp 编译整个工程的公共头School.exe 是已编译好的可执行程序。这个结构说明项目同时演示了两种 Win32 桌面应用形态纯 Win32 控制台程序和三段式 MFC 对话框程序两者共用业务核心是学习 MFC 工程组织方式时一个不错的参照实例。5. 从选课系统到实际 MFC 工具两个可复制的进阶技巧前几章围绕选课流程把 Win32 控制台和 MFC 对话框版都打通了这里把视野再往前推一步如果要把 SchoolDlg 这类对话框改造成实际工作中要用的小工具还有两个高频问题值得提前处理。5.1 控件自适应分辨率MFC 在高分屏下面的经典痛点选课系统在 1366x768 的笔记本上开发调试时一切正常一旦拿到 4K 屏或缩放比例 150% 的机器上对话框里的按钮和列表控件就会错位这是因为对话框默认以像素为单位固定了控件坐标而 DPI 缩放改变后这些坐标没有换算。一个常用的处理方式是重写 WM_SIZE 消息在 OnSize 中按比例调整子控件位置void CSchoolDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_listCourse.GetSafeHwnd() nullptr) return; // 记录设计时的基准尺寸此处在 OnInitDialog 中存储 if (m_nBaseWidth 0) { CRect rc; GetClientRect(rc); m_nBaseWidth rc.Width(); m_nBaseHeight rc.Height(); } if (m_nBaseWidth 0 || m_nBaseHeight 0) return; double scaleX static_castdouble(cx) / m_nBaseWidth; double scaleY static_castdouble(cy) / m_nBaseHeight; // 将列表控件按窗口缩放比同步调整位置和大小 CRect rcList; m_listCourse.GetWindowRect(rcList); ScreenToClient(rcList); rcList.left static_castint(rcList.left * scaleX); rcList.top static_castint(rcList.top * scaleY); rcList.right static_castint(rcList.right * scaleX); rcList.bottom static_castint(rcList.bottom * scaleY); m_listCourse.MoveWindow(rcList); }GetSafeHwnd 用来判断控件是否已经创建防止在 OnInitDialog 完成之前 OnSize 就被触发导致空指针操作。这个方案同样适用于 CStatic、CButton 等控件只要把 GetWindowRect 和 MoveWindow 套进循环逐一处理即可。相比 CDialogEx 自身的 OnSize 调整这里的核心逻辑是记录基准尺寸再等比缩放它解决了 MFC 控件自适应屏幕分辨率最常见的需求。5.2 工作线程中的选课结果上报别直接碰控件如果选课系统要接入真实的选课服务器把网络请求放在 UI 线程里会直接卡死界面此时需要把请求放到工作线程中执行。MFC 中工作线程不能直接操作控件因为控件属于创建它的主 UI 线程跨线程调用会导致断言失败甚至崩溃。正确做法是在工作线程中向主窗口投递自定义消息由主窗口的处理器来刷新控件状态。#define WM_UPDATE_SELECT_RESULT (WM_APP 100) // 工作线程执行体 UINT SelectThreadProc(LPVOID pParam) { ThreadParam* p static_castThreadParam*(pParam); // 模拟耗时网络选课请求 Sleep(2000); BOOL bSuccess p-pSchool-SelectStudent(p-studentName, p-reason); // 不能直接调用 p-pDlg-RefreshCourseList(); // 把结果通过 PostMessage 投递到主线程消息队列 ::PostMessage(p-pDlg-GetSafeHwnd(), WM_UPDATE_SELECT_RESULT, bSuccess ? 1 : 0, reinterpret_castLPARAM(new CString(p-reason.c_str()))); delete p; return 0; }PostMessage 是异步的它会立即返回消息进入目标窗口所在线程的消息队列由主线程在空闲时取出并分发。在消息处理器里再执行 RefreshCourseList 和 MessageBox 就安全了。这里特别要注意把 reason 放到堆上返回如果传入栈上变量的地址等消息被处理时该变量已经失效。这套源码包里最能实际借鉴的思路是控制台与 MFC 共用一个业务核心资源文件保持最小化缓存文件不入库最后再把控件自适应和线程安全这两件事提前想好一个课程设计就能平滑演变成工作中可维护的桌面工具。本文还有配套的精品资源点击获取
返回列表