简介:这套增进版学生选课系统基于MFC框架开发,面向课程设计、毕业设计以及Windows桌面开发学习者,围绕多角色选课管理、课程维护、快速选课和数据统计等完整业务流程,给出可编译、可运行的实践案例。压缩包共85个文件,约3.11MB,其中20个h头文件与19个cpp源文件承载主要类实现,19个obj文件及exe便于对照编译结果,另含bmp、ico界面资源、mdb数据库、rc资源脚本和vc工程文件,方便阅读源码、复现界面与二次改造。已有257人学习,在同类MFC选课项目中具备一定参考热度。读者可重点梳理学生、教师、管理员三类权限,查看登录、选课、退课、课程发布与用户管理等模块的类结构和数据库访问方式,并借鉴选课冲突处理、消息通知、数据统计和界面布局思路,用于课程实践、项目改造和答辩准备。
1. 一份能跑起来的 MFC 选课系统:先看它解决了哪些真实排课冲突
“增进版学生选课系统”这个名头听起来像课程设计的常规选题,但真正把这套 MFC 选课系统编译起来跑一遍,会发现它要处理的不是界面花不花,而是几条硬规则必须同时成立:一门课有人数上限,一个学生同一时间段不能撞两门课,一学期学分有上下限,退课之后名额要准确地还给谁。任何一条算错,第二天教务窗口就得排队。这套工程把这些规则压在 C++ 业务层和数据库字段里,用对话框和列表控件把结果反馈出来,适合两类人:一是要做课程设计、又不想交个只能增删改查的半成品;二是手上有个老 MFC 项目要接手,需要一份能对照着看的业务骨架。下面按能复现的顺序拆开讲。
2. 拆开工程结构:对话框框架选型与模块怎么落地
2.1 为什么这类系统用对话框框架而不是文档视图
MFC 提供两套常见骨架:单文档/多文档(SDI/MDI)和对话框(CDialog/CDialogEx)。选课系统的交互形态是“表单 + 列表 + 按钮”三件套——登录、查课、选课、退课、录成绩,每一次操作都是一次明确的提交,没有“打开一个文档、持续编辑、另存为”这种长生命周期状态。硬套文档视图也能做,但要多写一层文档与视图的同步,还得处理序列化,属于给自己加活。
常见做法是建一个基于 CDialogEx 的主对话框,前面挡一个模态的登录框,登录成功后按角色决定主界面挂哪一组控件:学生端只看选课和已选,教师端只看名单和成绩,管理端才有课程维护权限。这样做的好处是权限判断集中在登录处,后面的界面代码不用到处写 if。
另一个理由是数据交换。对话框自带 DDX/DDV 机制,控件和成员变量在 DoDataExchange 里一次性对应上,改字段名不用满界面找 GetDlgItem。对需要被追问细节的交付物来说,代码可读性的权重大于框架先进性。
但对话框框架有个天然短板:状态全靠自己管。列表里选中了哪一行、当前登录的是谁、这门课还剩几个名额,都得用成员变量显式存住,刷新时手动同步。所以后面讲表结构和选课逻辑时会反复强调一句话——以数据库为准,界面只做投影,这是补短板的唯一稳妥办法。
2.2 工程目录与模块划分
一个能维护的版本,类不会全堆在 View 里。按职责拆开大致是下面这个形状,文件名允许不同,但分层关系建议保留。
| 模块 | 典型类/文件 | 职责 |
|---|---|---|
| 登录鉴权 | CLoginDlg | 校验账号密码,写入当前用户上下文(角色、学号/工号) |
| 学生端 | CStudentDlg、CCourseSelectDlg | 可选课程列表、选课、退课、已选课程与学分统计 |
| 教师端 | CTeacherDlg | 查看选课名单、录入或修改成绩 |
| 管理端 | CAdminDlg | 课程增删改、名额调整、学期开关 |
| 数据访问 | CSqlHelper / CDbManager | 连接管理、查询、执行、事务封装 |
| 业务规则 | CCourseService | 时间冲突检测、容量校验、学分校验 |
把业务规则单独抽出来的原因很实际:不少人把校验直接写在按钮响应函数里,OnBnClickedBtnSelect 一个函数两百多行,改一条规则要翻三个地方,还容易漏。抽成 service 之后,学生端选课、管理端代选、批量导入都调同一套校验,出错只改一处。
还有一个容易忽略的点是用户上下文。登录成功后不要只在对话框成员里存个 CString,建议做一个单例或者挂在 App 对象上的全局结构,至少包含角色、账号、姓名、当前学期。后面所有查询都带学期条件,否则跨学期数据会串在一起,退课退到上学期的记录上,这种问题排查起来非常费时间。
2.3 从零跑起来的最小步骤
环境准备这一步翻车率最高,按顺序走可以减少无效折腾。
- 安装开发环境时勾选“使用 C++ 的桌面开发”,并确认 MFC 组件已选中。较新版本的安装器默认不带 MFC,需要手动在单个组件里勾,漏了会在包含 afxwin.h 时直接报找不到头文件。
- 打开工程后检查项目属性里的“使用 MFC”选项。共享 DLL 版本体积小,但拷到没装运行库的机器上会缺 dll;静态库版本编译出来大一些,交作业或发给别人演示更省事,我一般选静态。
- 确认字符集。老工程多数是“使用多字节字符集”,而新版环境默认 Unicode,一编译就是一片 C2664(无法将 const char* 转换为 LPCTSTR)。要么在属性里改回多字节,要么统一用 _T() 包字符串,不要两种混着来。
- 准备数据库。Access 的 .mdb/.accdb 走 ACE OLEDB 最省事,SQL Server 只需换连接串,业务代码基本不动。
- 改连接串里的数据库路径,编译,先只跑通登录,再逐模块验证。
2.4 界面控件与数据的绑定
列表控件是这套系统里出现频率最高的控件,课程列表、已选列表、名单、成绩都靠它。绑定的关键不是把数据塞进去,而是塞之前先清空,塞之后记住行号和业务主键的对应关系。
// 对话框头文件中的成员 CListCtrl m_listCourse; // 课程列表 CComboBox m_cmbSemester; // 学期下拉框 void CCourseSelectDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_LIST_COURSE, m_listCourse); // 控件与成员绑定 DDX_Control(pDX, IDC_COMBO_SEMESTER, m_cmbSemester); } void CCourseSelectDlg::InitCourseList() { // 1. 设置风格:整行选中 + 网格线,便于核对 m_listCourse.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); // 2. 只在首次初始化时插入列,重复调用会叠加出重复列 if (m_listCourse.GetHeaderCtrl()->GetItemCount() == 0) { m_listCourse.InsertColumn(0, _T("课程号"), LVCFMT_LEFT, 90); m_listCourse.InsertColumn(1, _T("课程名"), LVCFMT_LEFT, 180); m_listCourse.InsertColumn(2, _T("学分"), LVCFMT_LEFT, 60); m_listCourse.InsertColumn(3, _T("容量"), LVCFMT_LEFT, 60); m_listCourse.InsertColumn(4, _T("已选"), LVCFMT_LEFT, 60); m_listCourse.InsertColumn(5, _T("上课时间"), LVCFMT_LEFT, 160); } m_listCourse.DeleteAllItems(); // 刷新前必须清空,否则数据会翻倍 // 3. 查询结果逐行插入,行号与结果集下标一一对应 CString strSql; strSql.Format(_T("SELECT CourseId, CourseName, Credit, Capacity, Enrolled, ") _T("DayOfWeek, StartSection, EndSection ") _T("FROM Course WHERE Semester = '%s' ORDER BY CourseId"), m_strSemester); int nRow = 0; // Query 返回二维字符串表,具体实现见数据访问封装 CStringArray arrRows = CSqlHelper::Instance().Query(strSql); for (int i = 0; i < arrRows.GetSize(); ++i) { CString strLine = arrRows[i]; // 字段以 \x01 分隔 // 拆分逻辑略,按列 InsertItem / SetItemText 即可 m_listCourse.InsertItem(nRow, strCourseId); m_listCourse.SetItemText(nRow, 1, strCourseName); // 剩余列同理 ++nRow; } }这段代码里有两个参数值得说清楚。LVS_EX_FULLROWSELECT 让整行可选中,用户点在哪一列都算选中这一行,配合 GetNextItem(-1, LVNI_SELECTED) 取行号最稳;LVS_EX_GRIDLINES 只是视觉辅助,但对核对数据很有用。列只插一次这个判断,是因为 OnInitDialog 在切换学期时会被复用,直接 InsertColumn 会看到两套重复表头。
取选中行时不要用鼠标坐标反算,统一走选中状态。行号拿到之后,必须再用行号回到数据源取主键,不能把课程名当唯一标识——现实里同名课程太常见了,用名字做删除条件迟早删错。
3. 数据层:表结构与 ADO 连接怎么定
3.1 五张表撑起选课业务
选课系统的表不需要多,但字段必须能支撑校验。下面这套是够用的最小集合,字段名可按自己的命名习惯改,语义别丢。
-- 学生表:MaxCredits 存该生本学期学分上限,不同年级可以不同 CREATE TABLE Student ( StudentId VARCHAR(20) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Password VARCHAR(64) NOT NULL, Major NVARCHAR(50), ClassName NVARCHAR(50), MaxCredits INT DEFAULT 25 ); -- 课程表:时间用一个星期几 + 起止节次 + 起止周次表达 CREATE TABLE Course ( CourseId VARCHAR(20) PRIMARY KEY, CourseName NVARCHAR(100) NOT NULL, TeacherId VARCHAR(20), Credit DECIMAL(3,1) NOT NULL, Capacity INT NOT NULL, Enrolled INT DEFAULT 0, Semester VARCHAR(20) NOT NULL, DayOfWeek INT NOT NULL, -- 1~7 StartSection INT NOT NULL, -- 1~12 EndSection INT NOT NULL, StartWeek INT NOT NULL, EndWeek INT NOT NULL ); -- 选课表:Status 区分有效与已退,退课不物理删除,留痕可查 CREATE TABLE Enroll ( EnrollId INT IDENTITY(1,1) PRIMARY KEY, StudentId VARCHAR(20) NOT NULL, CourseId VARCHAR(20) NOT NULL, EnrollTime DATETIME DEFAULT GETDATE(), Status TINYINT DEFAULT 1, -- 1 有效,0 已退 Score DECIMAL(5,1) NULL );时间建模是整个设计里最需要想清楚的地方。用“星期几 + 起止节次 + 起止周次”三组字段,就能表达绝大多数排课需求:周一的 1-2 节、3-16 周、单双周通过在周次上做约定处理。不要存“周一第1节,周三第3节”这种字符串,看着直观,一做冲突检测就得写字符串解析,纯属给自己挖坑。真有一门课多次上课的情况,就拆成多条课程记录或者加一张 CourseTime 子表,别把多个时间段压进一个字段。
Enrolled 字段是冗余设计,理论上可以每次 count(Enroll) 算出来。保留它的理由是列表页要显示剩余名额,每行都 count 一次数据库压力大;代价是必须保证它和 Enroll 表同步,这个同步的写法在第 4 章展开。
3.2 ADO 连接的初始化与参数
MFC 访问数据库的老方案有 ODBC、DAO、ADO 几种。ADO 的优势是 COM 接口封装好、支持事务、换数据库只改连接串,工程里用得多。接入前要在 stdafx.h 或 pch.h 里引入类型库。
// 引入 ADO 类型库,放在预编译头里,只引一次 #import "C:\Program Files\Common Files\System\ado\msado15.dll" \ no_namespace rename("EOF", "adoEOF") BOOL CDbManager::Open(LPCTSTR lpszDbPath) { // MFC 主线程里初始化 COM,工程里只做一次 ::CoInitialize(NULL); CString strConn; // Access 走 ACE 引擎;换 SQL Server 时只改这一行为 Provider=SQLOLEDB;... strConn.Format(_T("Provider=Microsoft.ACE.OLEDB.12.0;Data Source=%s;"), lpszDbPath); try { m_pConn.CreateInstance(__uuidof(Connection)); // 第三个参数是密码,Access 无密码时传空串 m_pConn->Open(_bstr_t(strConn), _bstr_t(""), _bstr_t(""), adModeUnknown); m_pConn->PutCursorLocation(adUseClient); // 客户端游标,便于取 RecordCount return TRUE; } catch (_com_error& e) { // 把 HRESULT 和描述一起弹出来,比只弹“连接失败”有用得多 CString strMsg; strMsg.Format(_T("连接失败(0x%08X):%s"), e.Error(), (LPCTSTR)e.Description()); AfxMessageBox(strMsg); return FALSE; } }连接串里的 Provider 是最容易出问题的一处。报“未找到提供程序。该程序可能未正确安装”通常是两件事:一是机器上没装对应版本的数据库引擎,二是程序位数和引擎位数不一致——32 位程序调不到 64 位提供程序,反之亦然。排查时先看任务管理器里进程是 32 位还是 64 位,再确认装的引擎版本,别急着重装。
PutCursorLocation 设为 adUseClient 是为了后面能直接读 RecordCount。服务端游标下 RecordCount 常常返回 -1,分页和统计会莫名其妙对不上,这个设置省掉很多困惑。
3.3 查询与执行的统一封装
数据访问层建议只暴露三个方法:Query 返回结果集、Execute 执行无返回的语句、Transaction 包事务。调用方不碰 Connection 和 Recordset 指针,出错时的资源释放才有保障。
// 执行 INSERT/UPDATE/DELETE,返回受影响行数 int CSqlHelper::Execute(LPCTSTR lpszSql) { try { _variant_t vtRows; m_pConn->Execute(_bstr_t(lpszSql), &vtRows, adCmdText); return (int)vtRows; // 注意做类型判断,部分驱动返回 VT_EMPTY } catch (_com_error& e) { TRACE(_T("SQL 执行失败:%s\nSQL:%s\n"), (LPCTSTR)e.Description(), lpszSql); return -1; } }返回值 -1 表示失败还是影响行数为负,两者要区分开。Execute 在部分提供程序下返回的不是受影响行数而是空变体,稳妥做法是把返回值类型判断写出来,或者干脆用 Recordset 的 Fields->GetItem(0) 取 @@ROWCOUNT。这个细节在容量扣减时非常关键——第 4 章的条件更新就靠它判断有没有抢到名额。
3.4 字符集与路径的两个固定坑
第一,多字节和 Unicode 混用。连接串里带中文路径时,Unicode 工程要用 _bstr_t 包宽字符,多字节工程直接传 char*,混着来会变成乱码路径,数据库当然打不开。建议工程统一一种,字符串常量全用 _T()。
第二,数据库文件不要放在 Program Files 目录下。Access 文件在受保护目录里写入会被系统重定向或直接拒绝,表现是查询正常、一保存就报“操作必须使用一个可更新的查询”。把 .accdb 放到工程目录或者用户文档目录,问题消失。这条坑我见过太多次,几乎每次都是路径惹的祸。
4. 选课核心逻辑:容量、冲突、学分三道闸
4.1 时间冲突检测:把周次和节次转成可比较的区间
冲突判断的数学本质是两个区间是否相交。同一星期几,节次区间 [s1,e1] 与 [s2,e2] 相交的条件是 s1 <= e2 且 s2 <= e1;周次同理。两个维度都相交才算真冲突。
-- 查询该生已选有效课程中,与目标课程在节次和周次上都重叠的记录 SELECT c.CourseId, c.CourseName FROM Enroll e JOIN Course c ON e.CourseId = c.CourseId WHERE e.StudentId = ? AND e.Status = 1 AND c.Semester = ? AND c.DayOfWeek = ? AND c.StartSection <= ? AND c.EndSection >= ? -- 目标课的开始节次落在已有课内 AND c.StartWeek <= ? AND c.EndWeek >= ? -- 周次同样判断四个问号对应的参数依次是:目标课的结束节次、开始节次、结束周次、开始周次。写成这个顺序看起来别扭,是因为条件里的左右两侧要对调着传,写的时候最好在代码里命名成变量再绑定,别直接往 SQL 里拼数字。
用数据库做冲突检测而不是在内存里遍历,好处是永远以已提交的数据为准,不受界面缓存影响;坏处是每选一门课多一次往返,但选课本身不是高频操作,这点开销可以接受。如果坚持在内存判断,记得判断前先按学生和学期把有效选课全查出来,不要用列表控件里已经显示的数据——那是上一次刷新的结果,可能已经过期。
4.2 容量扣减与事务边界
超卖是选课系统最经典的问题:两个人同时看到还剩 1 个名额,都点了选课,最后 Enrolled 变成 Capacity+1。根因是先查后改,中间有窗口。做法是把判断和扣减压进一条 UPDATE,用受影响行数当门票。
BEGIN TRANSACTION; -- 只有名额确实还有余量时才 +1,否则影响行数为 0 UPDATE Course SET Enrolled = Enrolled + 1 WHERE CourseId = ? AND Semester = ? AND Enrolled < Capacity; -- 业务层判断:上面的语句影响行数为 0 就直接 ROLLBACK INSERT INTO Enroll (StudentId, CourseId, Status) VALUES (?, ?, 1); COMMIT TRANSACTION;这里的关键参数是 WHERE 里的 Enrolled < Capacity。它把“检查”和“修改”合并成原子操作,数据库的行锁会保证只有一个事务能把它从 0 改成 1。业务代码拿到影响行数后判断,0 就回滚并提示“名额已满”,不要傻乎乎地继续 INSERT。退课则相反,UPDATE 用 Enrolled > 0 兜底,再把 Enroll 的 Status 置 0,两步放在同一个事务里。
事务边界要包住“扣名额”和“写选课记录”两件事,不能只包一半。只扣名额不写记录,名额会凭空消失;只写记录不扣名额,列表显示永远满员。这两个错误在课程设计里都出现过,排查时先查 Enrolled 和 count(Enroll) 是否对得上,能快速定位。
4.3 校验顺序:冲突、学分、重复,谁先谁后
顺序影响提示文案,也影响性能。推荐顺序是:重复选课 → 时间冲突 → 学分上限 → 名额。理由是从快到慢、从确定到需要计算的。
重复选课判断一条 SQL 就够,先挡掉最常见的误操作。时间冲突需要 JOIN 课程表算区间,稍贵。学分上限要把该生本学期所有有效选课的学分加起来,再做加法比较,注意 MaxCredits 从学生表取,不要写死 25。名额放最后,因为前面的都不满足时根本不需要去动 Course 表,减少锁竞争。
// 学分校验:先算已选,再加目标课 CString strSql; strSql.Format(_T("SELECT ISNULL(SUM(c.Credit),0) FROM Enroll e ") _T("JOIN Course c ON e.CourseId=c.CourseId ") _T("WHERE e.StudentId='%s' AND e.Status=1 AND c.Semester='%s'"), m_strStudentId, m_strSemester); double dSelected = CSqlHelper::Instance().QueryScalar(strSql); double dTarget = GetCourseCredit(m_strCourseId); if (dSelected + dTarget > GetStudentMaxCredits(m_strStudentId)) { AfxMessageBox(_T("超出本学期学分上限,无法选课")); return; }QueryScalar 这类封装建议返回 double 而不是 CString,避免到处做 _ttof。ISNULL(SUM(...),0) 的写法是为了没有选课记录时返回 0 而不是 NULL,否则 CString 转换会得到空串,转数字变成 0 也行,但显式一点更安全。
4.4 退课与名额回填的一致性
退课看起来简单,实际是最容易留下脏数据的地方。正确做法是:把 Enroll 的 Status 置 0 并记录退课时间,同时 Course 的 Enrolled 减 1,两个操作同一事务。不要物理删除 Enroll 行,否则成绩、退课记录、审计全部丢失,而且一旦误删无法恢复。
退课还要处理一个边界:这门课已经录了成绩还能不能退。业务上通常不允许,判断条件是 Score IS NOT NULL 就拒绝。这条规则写到 service 层,管理端代退也要走同一个入口,否则会绕过去。
刷新策略上,退课成功后直接重新执行一次列表查询,不要在前端手动把 Enrolled 减 1 再刷新界面。手动改数字看似省一次查询,但只要中间有任何一次失败,界面显示和数据库就永久对不上了,用户看到的名额是假的,比慢一点糟糕得多。
5. 避坑与排查:MFC 选课系统最常见的五类翻车
5.1 编译期:一处不改,满屏类型错误
现象:一编译几十条 C2664,提示无法将 const char [x] 转换为 LPCTSTR。
原因:工程字符集与字符串字面量不匹配,Unicode 工程里直接写了 "abc"。
解决:属性里确认字符集,然后全工程统一。要么把字面量全改成 _T("abc"),要么把字符集改回多字节。不要用强制转换 (LPCTSTR)"abc" 硬压,运行时中文会变成乱码。
5.2 运行期:本机好好的,换台机器就闪退
现象:开发机上正常运行,拷到演示机器双击就退出,没有任何提示。
原因:项目用的是“在共享 DLL 中使用 MFC”,目标机器没装对应运行库;或者数据库路径写的是绝对路径,换机器后路径不存在,连接失败后没有做空指针判断。
解决:交付版本改成静态链接 MFC;数据库路径改成相对路径,启动时先判断文件是否存在,不存在就弹出明确提示而不是继续往下走。
5.3 数据层:查询正常,保存报“可更新的查询”
现象:列表能查出来,一提交选课就报错,错误信息提到操作必须使用可更新的查询。
原因:数据库文件放在受保护目录,或者连接是以只读方式打开的,也可能是 SQL 里用了聚合或视图导致结果集不可更新。
解决:先把文件移到工程目录或用户目录并确认有写权限,再检查连接串有没有加只读属性。用 Execute 直接执行 UPDATE 而不是通过 Recordset 修改,能绕开大部分可更新性限制。
5.4 并发:最后一名额被两个人同时抢到
现象:容量 50 的课出现 51 条有效选课记录,Enrolled 也是 51。
原因:先 SELECT 查剩余名额,再在代码里判断,最后 UPDATE,三步之间没有锁,两个请求的检查都通过了。
解决:改成条件更新,UPDATE ... WHERE Enrolled < Capacity,用影响行数判断成败,并把扣名额和插记录放进同一事务。修完之后用两个客户端同时点,验证只成功一个。
5.5 界面:数据改了但列表不动,或者删除删错行
现象一:选课提示成功,列表里的已选人数没变。
原因:操作后没有重新查询,或者查了但没先 DeleteAllItems,新数据叠在旧数据上。
现象二:删课程删掉了别的记录。
原因:用列表显示文本(课程名)当条件去删,遇到同名课程就出错。
解决:删除和修改一律用隐藏的业务主键,把主键存在 ItemData 或者单独的数组成员里,按行号取主键再操作;每次数据变更后统一走一次刷新函数。
6. 二次开发与验证:从能跑到敢改的几条硬技巧
拿到这套工程,最先要做的不是加功能,而是建立一套能反复验证的检查路径。我的习惯是先造数据:两个学生、三门课,其中两门时间故意重叠,一门容量设成 1。然后用下面这张表逐项过一遍,全部通过再动代码。
| 验证项 | 操作 | 期望结果 |
|---|---|---|
| 时间冲突 | 学生选两门周一 1-2 节的课 | 第二次被拒绝,提示冲突课程名 |
| 容量边界 | 容量为 1 的课,两个学生先后选 | 只有一人成功,Enrolled 不超过 1 |
| 学分上限 | 连续选课直到超过 MaxCredits | 超限那一次被拦下,已选学分不变 |
| 退课回填 | 退掉一门已选课 | Enrolled 减 1,已选列表消失,记录 Status 变 0 |
| 事务一致性 | 手动让 INSERT 失败(如重复主键) | 名额不被扣掉,两边数据一致 |
并发这一项光靠手点验证不了,用一个最土的办法就够:写个小程序开两个线程,循环调同一个选课接口,跑两百次,最后比对 Enrolled 和有效选课条数是否相等。相等就说明条件更新生效了,不等就是事务边界没包对。
// 简易并发验证:两个线程抢同一门容量为 1 的课 UINT AFX_CDECL SelectThread(LPVOID pParam) { CString* pStuId = (CString*)pParam; for (int i = 0; i < 100; ++i) { // SelectCourse 内部走条件更新 + 事务,返回 TRUE 表示抢到 CCourseService::Instance().SelectCourse(*pStuId, _T("C001"), _T("2024-2025-1")); } return 0; } void CTestDlg::OnBnClickedStress() { CString s1 = _T("S001"), s2 = _T("S002"); AfxBeginThread(SelectThread, &s1); AfxBeginThread(SelectThread, &s2); // 等待结束后手工执行校验 SQL: // SELECT Enrolled FROM Course WHERE CourseId='C001' // SELECT COUNT(*) FROM Enroll WHERE CourseId='C001' AND Status=1 }参数上唯一要注意的是线程里不要碰任何 UI 控件,MFC 的窗口对象有线程归属,跨线程直接调 SetWindowText 会崩。需要回显就 PostMessage 到主线程,或者干脆只写日志文件,验证阶段够用。
二次开发时,改动顺序建议是先在 service 层加规则、再改 SQL、最后才动界面。反过来做的话,界面加了个按钮但底层没校验,测试时很容易漏掉那条路径。另外每次改完表结构,把建表脚本和初始化数据脚本一起更新,别只改数据库不动脚本——换台机器重建环境时,这两份脚本就是唯一的后悔药。
从那以后我每次拿到这类课程设计工程,都强制先跑一遍那张验证表再动一行代码,因为真正让人加班的从来不是功能不会写,而是改完之后不知道哪里悄悄坏了。希望帮到你。
本文还有配套的精品资源,点击获取