C# WinForms 教务系统实战:三层架构、ADO.NET 与选课事务

简介:这份压缩包面向计算机专业课程设计、毕业设计与 C# 桌面开发入门者,提供一套基于 Windows 窗体和 SQL Server 的教务管理示例,覆盖学生信息管理、成绩管理与选课三类业务。系统区分学生与管理员两类角色,管理员可维护管理员及学生资料、开设和查询课程、录入与统计成绩,适合用来理解桌面端增删改查、数据库表设计与角色权限划分。包内共 210 个文件,以 44 个 cs 源码、19 组 resources 与 resx 窗体资源、88 张 png 界面截图为主,另含 sln 解决方案、csproj 工程文件、sql 脚本、config 配置及可执行文件,压缩包约 10.61MB,结构完整,便于导入 Visual Studio 调试。已有 477 人学习,可作为课程作业参考,也能借现有窗体与数据库脚本快速搭建演示环境,对照截图和文档梳理功能模块与实现思路。

1. 一个压缩包,三门课的作业量:这套 C# 教务系统到底能干什么

学期末帮人看课程设计,解压出来一个.sln、十几个.cs、一份.sql脚本,双击打开先是登录窗体,输进去之后跳主界面,左边一排菜单:学生管理、课程管理、成绩录入、选课退选、用户管理。这就是典型的 C# Windows 窗体教务系统学生信息管理系统,学生成绩管理和学生选课全都塞在同一个解决方案里,所以它一份代码能顶三门课的大作业,甚至能撑起一个毕业设计的骨架。

它解决的问题很实在:一个管理员能维护班级、课程、教师和学生档案;一个教师能按课程录入成绩、看平均分和排名;一个学生登录进去只能看到自己的成绩单和可选课程列表,勾一门、点确定,选课人数当场加一。权限、CRUD、事务、报表导出这几件事串在一起,正好是 C# 管理系统类大作业最常被问到的部分。

适合两类人:一是手里已经拿到这个包、但卡在“数据库怎么还原”“连接字符串写哪儿”的同学;二是想拿它当底座,把 WinForms + ADO.NET 三层结构学会,之后改成绩算法、加导出、换成别的业务也能照搬套路的人。下面按我拆包的顺序讲:先看技术栈和目录,再拆四条核心链路,然后是跑起来的步骤、踩过的坑,最后讲二次开发怎么改。

2. 技术栈与整体架构:WinForms 加三层结构是怎么搭起来的

2.1 技术选型清单:为什么是 WinForms 而不是 WPF

这套代码用的技术栈非常保守,保守到有点老派,但对课程设计和毕业设计来说反而是优点——环境好装、报错好查、答辩时老师问的每一个控件你都能指出来在哪。常见组合是 C# + .NET Framework 4.x + Windows 窗体 + ADO.NET(System.Data.SqlClient)+ SQL Server。没有 Entity Framework,没有 MVVM,没有依赖注入,数据访问全部靠手写 SQL 和SqlParameter。

层次用的东西在这套代码里干什么
表现层WinForms(Form、DataGridView、ComboBox、MenuStrip)登录、主框架、各业务窗体
业务层普通 C# 类(BLL)校验、组合多次数据访问、算平均分
数据层ADO.NETSqlConnection/SqlCommand/SqlDataAdapter执行 SQL、返回DataTable
配置App.config的connectionStrings存放数据库连接串
存储SQL Server(部分版本用 LocalDB)表、视图、存储过程

选 WinForms 而不是 WPF,理由很朴素:拖控件十分钟能出一个能看的界面,DataGridView直接绑DataTable就能显示查询结果,改一列宽度、加一个按钮都不用写 XAML。代价是界面丑、高分屏发虚、复杂布局难做,这套代码里也能看到痕迹——很多窗体是绝对坐标摆出来的,换个字体大小控件就错位。

提示:如果你的机器只装了 .NET SDK 没装 .NET Framework 开发工具,打开项目会提示“目标框架不受支持”。这不是代码坏了,是环境缺件,第 4 章会讲怎么补。

2.2 三层架构与目录结构:一个查询请求要走几层

解压后的目录通常长这样,名字可能有出入,但分层逻辑基本一致:

EduSystem/ ├── EduSystem.sln ├── EduSystem.Model/ # 实体类:Student、Course、Score... ├── EduSystem.DAL/ # 数据访问:SqlHelper、StudentDAL... ├── EduSystem.BLL/ # 业务逻辑:StudentBLL、ScoreBLL... ├── EduSystem.UI/ # 窗体:FrmLogin、FrmMain、FrmStudent... ├── EduSystem.Common/ # 工具:MD5/加盐、Excel 导出、日志 └── Db/EduDb.sql # 建库建表 + 初始数据

一次“查询所有学生”的请求链路是:FrmStudent点查询按钮 →StudentBLL.GetList(keyword)做参数清洗 →StudentDAL.GetList(keyword)拼 SQL 并调SqlHelper.Query→ 返回DataTable→ 窗体dataGridView1.DataSource = dt。很多人嫌三层麻烦,直接窗体里写 SQL,短期确实快,但等到要加一个“按班级统计人数”的功能时,就会发现自己要在五个窗体里各改一遍。

真正撑起这个结构的是那个SqlHelper,它把连接、参数、异常收在一处:

// EduSystem.DAL/SqlHelper.cs public static class SqlHelper { // 连接串只在这里读一次,换服务器时不用全项目搜索替换 internal static readonly string ConnStr = ConfigurationManager.ConnectionStrings["EduDb"].ConnectionString; // 查询:返回 DataTable,直接喂给 DataGridView public static DataTable Query(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) using (var da = new SqlDataAdapter(cmd)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); // SqlDataAdapter 内部会自动 Open/Close return dt; } } // 增删改:返回受影响行数,业务层靠它判断成功与否 public static int Execute(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } // 取单值:COUNT、SUM、SCOPE_IDENTITY() 用这个 public static object Scalar(string sql, params SqlParameter[] ps) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (ps != null && ps.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteScalar(); } } }

三个方法的参数含义要分清:sql一律写成带@参数名的语句,绝不用字符串拼接;ps是SqlParameter数组,用params修饰所以可以传零个、一个或多个;返回值上Query给DataTable(适合直接绑定界面),Execute给受影响行数(>0才算成功),Scalar给object(记得Convert.ToInt32再使用,COUNT返回的一定不是null,但SUM空集时是DBNull)。

using是这里的关键写法。SqlConnection实现了IDisposable,using保证即使ExecuteNonQuery抛异常,连接也会关闭归还连接池。我见过把SqlConnection提成静态字段的写法,单机演示不出问题,一旦有两个人同时操作就报“连接未关闭”,血泪经验。

2.3 数据库设计:六张表撑起整个教务流程

业务再花哨,落地就是几张表。这套系统的主干表一般是:学生、教师、课程、班级、成绩、选课记录,外加一张用户表管登录和角色。

-- 学生表:StudentNo 是学号,业务唯一键;StudentId 是自增主键,外键都用它 CREATE TABLE Student ( StudentId INT IDENTITY(1,1) PRIMARY KEY, StudentNo VARCHAR(20) NOT NULL UNIQUE, Name NVARCHAR(20) NOT NULL, Gender CHAR(1) NULL, -- '男'/'女' ClassId INT NOT NULL, Phone VARCHAR(20) NULL, IsDeleted BIT NOT NULL DEFAULT 0 -- 逻辑删除,历史成绩才不会断链 ); -- 课程表:Capacity 是容量上限,Selected 是已选人数,选课时靠这两个字段判满 CREATE TABLE Course ( CourseId INT IDENTITY(1,1) PRIMARY KEY, CourseName NVARCHAR(50) NOT NULL, TeacherId INT NOT NULL, Credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, Capacity INT NOT NULL DEFAULT 50, Selected INT NOT NULL DEFAULT 0 ); -- 成绩表:一个学生一门课只能有一条记录,用唯一约束兜底 CREATE TABLE Score ( ScoreId INT IDENTITY(1,1) PRIMARY KEY, StudentId INT NOT NULL, CourseId INT NOT NULL, ScoreValue DECIMAL(5,1) NULL, -- 允许 NULL,表示“已选未考” Term VARCHAR(20) NOT NULL, CONSTRAINT UQ_Score UNIQUE (StudentId, CourseId, Term) ); -- 选课表:选课和成绩拆开,退课只删这里,不动成绩 CREATE TABLE Elective ( ElectiveId INT IDENTITY(1,1) PRIMARY KEY, StudentId INT NOT NULL, CourseId INT NOT NULL, ElectiveAt DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT UQ_Elective UNIQUE (StudentId, CourseId) );

几个字段值得单独说。IsDeleted用逻辑删除而不是DELETE,因为学生一旦有成绩记录,物理删除会撞外键,或者更糟——外键没建,删完学生成绩表里留下一堆孤儿行,统计出来的平均分永远对不上。Capacity和Selected是一对冗余字段,理论上可以用COUNT(*)实时算已选人数,但每次选课都全表统计,几十个人同时点会锁得难看,用冗余字段加事务扣减更稳。

ScoreValue允许NULL是有意为之:学生选了课但老师还没录成绩,这条记录就该存在,只是分数为空。如果设成NOT NULL DEFAULT 0,期末统计“不及格人数”时会把一堆没考试的人算进去,这个坑我在别的项目里踩过,改数据比改代码还麻烦。

注意:建表脚本里的Term(学期)字段别省。同一门课不同学期可以重修,唯一约束里带上Term,否则学生重修时插不进去。

3. 核心模块拆解:登录、学生信息、成绩、选课四条主线

3.1 登录与角色权限:一个按钮背后的校验链

登录窗体是整个系统的入口,也是最容易被糊弄的地方。这套代码的常见做法是:用户名密码查SysUser表,查到了把UserId、UserName、RoleId塞进一个静态的CurrentUser类,然后按角色决定主窗体显示哪些菜单。

// FrmLogin.cs private void btnLogin_Click(object sender, EventArgs e) { string user = txtUser.Text.Trim(); string pwd = txtPwd.Text; // 密码框的 PasswordChar 设为 '●' if (user.Length == 0 || pwd.Length == 0) { MessageBox.Show("请输入账号和密码", "提示"); return; } // 参数化查询:把 ' or '1'='1 这类输入挡在门外 const string sql = @"SELECT UserId, UserName, RoleId FROM SysUser WHERE UserName = @u AND Password = @p AND IsEnabled = 1"; DataTable dt = SqlHelper.Query(sql, new SqlParameter("@u", user), new SqlParameter("@p", HashHelper.Sha256(pwd))); if (dt.Rows.Count == 0) { MessageBox.Show("账号或密码不正确", "提示"); return; } CurrentUser.Id = Convert.ToInt32(dt.Rows[0]["UserId"]); CurrentUser.Name = dt.Rows[0]["UserName"].ToString(); CurrentUser.RoleId = Convert.ToInt32(dt.Rows[0]["RoleId"]); // 1管理员 2教师 3学生 new FrmMain().Show(); this.Hide(); // 用 Hide 不用 Close,否则整个进程退出 }

逻辑上分三段。第一段做空值校验,看着废话,但少了它用户点一次空登录就查一次库,日志里全是无效查询。第二段是核心,@u、@p两个参数由SqlParameter转义,杜绝 SQL 注入;IsEnabled = 1是软开关,禁用账号不用删数据。第三段写会话状态并切窗体,this.Hide()这个细节很关键——WinForms 里主窗体的关闭会触发整个Application.Run结束,如果这里Close(),主界面一闪就退出去了,新手很容易翻车。

角色权限落地通常放在FrmMain_Load里,按RoleId隐藏菜单项:

private void FrmMain_Load(object sender, EventArgs e) { switch (CurrentUser.RoleId) { case 1: // 管理员:全开 break; case 2: // 教师:只留成绩和课程 menuStudent.Visible = false; menuUser.Visible = false; break; case 3: // 学生:只留个人成绩和选课 menuStudent.Visible = false; menuTeacher.Visible = false; menuScore.Visible = false; menuUser.Visible = false; break; } toolStripStatusLabel1.Text = $"当前用户:{CurrentUser.Name}"; }

这种“隐藏菜单”的权限只防君子不防小人,用户照样能通过别的入口打开窗体。要做扎实,就得在窗体打开时再判一次RoleId,或者干脆在 BLL 层按角色过滤数据,比如学生查成绩时强制拼上AND StudentId = @currentId,不给他看别人的机会。

3.2 学生信息管理:DataGridView 绑定与参数化 CRUD

学生管理是这类系统里代码量最大的一块,也是最标准的一套增删改查。界面通常左边一个关键字输入框加“查询”按钮,中间一个DataGridView列学生信息,右边或下边一组“新增/修改/删除/保存”按钮。

查询走模糊匹配:

private void btnSearch_Click(object sender, EventArgs e) { string kw = txtKeyword.Text.Trim(); // 关键字为空时 @kw 传空串,LIKE '%%' 等价于全量查询,省一个分支 const string sql = @" SELECT s.StudentId, s.StudentNo, s.Name, s.Gender, c.ClassName, s.Phone FROM Student s LEFT JOIN Class c ON c.ClassId = s.ClassId WHERE s.IsDeleted = 0 AND (s.StudentNo LIKE @kw OR s.Name LIKE @kw) ORDER BY s.StudentNo"; dataGridView1.DataSource = SqlHelper.Query(sql, new SqlParameter("@kw", "%" + kw + "%")); dataGridView1.Columns["StudentId"].Visible = false; // 主键不显示,但要留着改数据用 }

这里用LEFT JOIN而不是INNER JOIN,因为学生的ClassId有可能是脏数据(录入时班还没建),INNER JOIN会让这些学生凭空消失,查半天查不出原因。Column["StudentId"].Visible = false是把主键藏起来——界面不显示,但DataGridView的行里还留着,双击某行取StudentId就能定位记录。

修改保存时,很多人图省事直接用SqlCommandBuilder配SqlDataAdapter.Update,一条UPDATE全表刷一遍。数据量小看不出问题,一旦表上百万,卡到界面假死。我一般会手写UPDATE,只更改变动的那几列:

private void btnSave_Click(object sender, EventArgs e) { if (dataGridView1.CurrentRow == null) return; var row = dataGridView1.CurrentRow; int id = Convert.ToInt32(row.Cells["StudentId"].Value); const string sql = @"UPDATE Student SET Name = @name, Gender = @gender, Phone = @phone WHERE StudentId = @id"; int n = SqlHelper.Execute(sql, new SqlParameter("@name", row.Cells["Name"].Value ?? DBNull.Value), new SqlParameter("@gender", row.Cells["Gender"].Value ?? DBNull.Value), new SqlParameter("@phone", row.Cells["Phone"].Value ?? (object)DBNull.Value), new SqlParameter("@id", id)); MessageBox.Show(n > 0 ? "保存成功" : "记录不存在,可能已被删除"); }

参数说明里有两点容易出问题。?? DBNull.Value是为了处理界面上的空单元格——DataGridView空值取出来是null或者空字符串,直接传给SqlParameter会变成''而不是NULL,日期和数值字段会直接抛类型转换异常。(object)DBNull.Value那个强转别省,??两边类型不一致编译器会报错,这是 C# 的老脾气。

删除建议一律做逻辑删除,UPDATE Student SET IsDeleted = 1 WHERE StudentId = @id,物理删除留给“清空测试数据”这种运维脚本。

提示:DataGridView允许用户直接在单元格里改字,但改了之后没有“保存”动作,数据库是不会变的。要避免这种误解,把ReadOnly = true打开,所有编辑走弹窗,或者加个CellEndEdit事件即时提交。

3.3 成绩录入与统计:事务、批量与排名

成绩模块有两个动作:录入和统计。录入通常是老师选一门课,界面列出所有选了这门课的学生,老师挨个填分数,最后点“保存”一次性提交。这里必须用事务,一半成功一半失败是最难排查的数据问题。

public bool SaveScores(int courseId, string term, DataTable scoreTable) { using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { foreach (DataRow row in scoreTable.Rows) { // MERGE 写法:有记录就更新分数,没记录就插入(选了课还没录分的场景) const string sql = @" MERGE Score AS t USING (SELECT @sid AS StudentId, @cid AS CourseId, @term AS Term) AS s ON t.StudentId = s.StudentId AND t.CourseId = s.CourseId AND t.Term = s.Term WHEN MATCHED THEN UPDATE SET ScoreValue = @score WHEN NOT MATCHED THEN INSERT (StudentId, CourseId, Term, ScoreValue) VALUES (@sid, @cid, @term, @score);"; using (var cmd = new SqlCommand(sql, conn, tran)) { cmd.Parameters.AddWithValue("@sid", row["StudentId"]); cmd.Parameters.AddWithValue("@cid", courseId); cmd.Parameters.AddWithValue("@term", term); cmd.Parameters.AddWithValue("@score", row["ScoreValue"] == DBNull.Value ? (object)DBNull.Value : row["ScoreValue"]); cmd.ExecuteNonQuery(); } } tran.Commit(); return true; } catch { tran.Rollback(); // 任何一条失败,整批回滚,不留半截数据 throw; } } } }

MERGE在这里比“先查后插”省一次往返,但要注意它有两个坑:并发下MERGE并不是绝对安全,需要配合唯一索引兜底;ScoreValue传NULL时WHEN MATCHED会把已有分数清空,如果业务上不允许清空,就得在 BLL 层先过滤掉空值行。

统计这边用窗口函数最省事,不用在 C# 里排序:

-- 按课程算平均分、最高分,并给每个学生排名 SELECT s.StudentNo, s.Name, sc.ScoreValue, RANK() OVER (PARTITION BY sc.CourseId ORDER BY sc.ScoreValue DESC) AS RankInCourse, AVG(sc.ScoreValue) OVER (PARTITION BY sc.CourseId) AS AvgScore FROM Score sc JOIN Student s ON s.StudentId = sc.StudentId WHERE sc.CourseId = @cid AND sc.Term = @term AND sc.ScoreValue IS NOT NULL ORDER BY sc.ScoreValue DESC;

PARTITION BY sc.CourseId是按课程分组排名,ORDER BY ... DESC决定名次方向;分数相同的两个人会拿到同一个RankInCourse,这正是RANK()和ROW_NUMBER()的区别——后者会强行排出 1、2、3,并列的人名次不同,老师看了会质疑。平均分那列用OVER而不是GROUP BY,是因为要把每一行的明细和汇总放在同一个结果集里,DataGridView直接显示,不用再查一次。

注意:AVG会自动忽略NULL,所以“没录分”的学生不会拉低平均分。如果业务要求把缺考算 0 分,得用AVG(ISNULL(ScoreValue, 0)),这两种口径在答辩时经常被追问。

3.4 选课:名额扣减与重复选课的处理

选课是这套系统里唯一有明显并发的地方,也是最能体现“会不会写事务”的模块。学生点“选课”,背后要做三件事:判断是否已选、判断名额是否已满、扣减名额并写入选课记录。三步必须在一个事务里。

public SelectResult SelectCourse(int studentId, int courseId) { using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction(IsolationLevel.ReadCommitted)) { try { // 1. 已选过就不重复插入,避免撞唯一约束 using (var chk = new SqlCommand( "SELECT COUNT(1) FROM Elective WHERE StudentId=@s AND CourseId=@c", conn, tran)) { chk.Parameters.AddWithValue("@s", studentId); chk.Parameters.AddWithValue("@c", courseId); if (Convert.ToInt32(chk.ExecuteScalar()) > 0) { tran.Rollback(); return SelectResult.AlreadySelected; } } // 2. 抢名额:判断和扣减压进一条 UPDATE,靠行锁串行化 int rows; using (var take = new SqlCommand( "UPDATE Course SET Selected = Selected + 1 WHERE CourseId=@c AND Selected < Capacity", conn, tran)) { take.Parameters.AddWithValue("@c", courseId); rows = take.ExecuteNonQuery(); } if (rows == 0) { tran.Rollback(); return SelectResult.CourseFull; // 没抢到,可能是满了,也可能是课程不存在 } // 3. 写选课记录 using (var ins = new SqlCommand( "INSERT INTO Elective(StudentId, CourseId) VALUES(@s, @c)", conn, tran)) { ins.Parameters.AddWithValue("@s", studentId); ins.Parameters.AddWithValue("@c", courseId); ins.ExecuteNonQuery(); } tran.Commit(); return SelectResult.Success; } catch (SqlException ex) when (ex.Number == 2627) // 唯一约束冲突 { tran.Rollback(); return SelectResult.AlreadySelected; } catch { tran.Rollback(); throw; } } } }

关键在于第二个UPDATE。很多人写成“先SELECT Selected, Capacity判断,再UPDATE Selected = Selected + 1”,两条语句之间有空隙,两个人同时点,都读到 49/50,都判断没满,最后 51 个人选上 50 个名额的课。写成UPDATE ... WHERE Selected < Capacity,SQL Server 会对这一行加排他锁,第二个事务必须等第一个提交后才能判断,rows == 0就代表没抢到。这是最朴素也最可靠的做法,不需要UPDLOCK提示,也不用把隔离级别提到Serializable。

第三个INSERT外面套了catch (SqlException ex) when (ex.Number == 2627),是给“两个请求几乎同时绕过第一步判重”的极端情况兜底的——唯一约束会拦下第二个,转成友好提示,而不是给用户抛一段英文异常栈。

退课则是反过来的操作,删记录、减名额,同样要事务:

BEGIN TRANSACTION; DELETE FROM Elective WHERE StudentId = @s AND CourseId = @c; IF @@ROWCOUNT > 0 UPDATE Course SET Selected = Selected - 1 WHERE CourseId = @c AND Selected > 0; COMMIT;

AND Selected > 0是个廉价保险,防止因为历史脏数据把人数减成负数,界面上显示 -1 人已选会被老师当场指出来。

4. 跑起来:从解压到第一个界面亮起的完整步骤

4.1 环境准备与数据库还原

这套代码跑不起来,九成问题出在环境上,代码本身没错。需要的三样东西:Visual Studio(带“.NET 桌面开发”工作负载,或者至少装了 .NET Framework 4.x 目标包)、SQL Server(Express 版或 LocalDB 都够用)、一个能执行 SQL 脚本的客户端。

先把Db/EduDb.sql跑一遍。脚本里一般包含CREATE DATABASE、建表、建索引和几条初始数据(管理员账号、几个班级、几门课)。用命令行最省事:

# -S 服务器地址,本机默认实例就是 localhost;-E 表示用 Windows 身份验证 # 如果脚本里没有 CREATE DATABASE,需要先手动建一个空库再加 -d 参数 sqlcmd -S localhost -E -i "Db/EduDb.sql" # 跑完验证一下表在不在 sqlcmd -S localhost -E -d EduDb -Q "SELECT name FROM sys.tables ORDER BY name"

如果机器上是 LocalDB,实例名换成(localdb)\MSSQLLocalDB;如果是 Express,通常是localhost\SQLEXPRESS。这三个名字混用是新手第一个卡点,localhost连不上不代表数据库没装,可能只是实例名不对。

脚本执行完,用客户端查一下SysUser表在不在、有几条数据。这一步不做,等会儿登录提示“账号或密码不正确”,你会以为是代码的锅。

4.2 连接字符串与首次运行

连接串在EduSystem.UI/App.config里,名字必须和SqlHelper里ConnectionStrings["EduDb"]对上:

<configuration> <connectionStrings> <!-- Integrated Security=True 走 Windows 验证,不用写账号密码 --> <add name="EduDb" connectionString="Data Source=localhost;Initial Catalog=EduDb;Integrated Security=True;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>

几个参数的意思:Data Source是实例地址,改成你实际的服务器名;Initial Catalog是库名,和脚本里建的一致;Integrated Security=True表示用当前 Windows 账号登录数据库,免去在配置里写明文密码——这是本地开发最省事的做法,部署到别人机器上再换User Id=...;Password=...。MultipleActiveResultSets=True允许一个连接上同时开多个DataReader,这套代码里其实用不太到,但留着不会出错。

改完App.config一定要重新生成,因为连接串是编译进EduSystem.UI.exe.config的,光改源文件不重新编译,运行时读到的还是旧的。

编译用 MSBuild 或直接在 VS 里按 F5。命令行方式:

# 路径按你本机 VS 版本调整,Community/Professional/Enterprise 都一样 "C:/Program Files/Microsoft Visual Studio/2022/Community/MSBuild/Current/Bin/MSBuild.exe" \ EduSystem.sln /p:Configuration=Debug /p:Platform="Any CPU" /v:m

编译通过后,输出在EduSystem.UI/bin/Debug/下,双击EduSystem.UI.exe就能看到登录窗体。第一次登录用脚本里的初始管理员账号,进去之后第一件事是改密码,第二件事是建班级——因为学生表有ClassId外键,没班级就录不了学生。

4.3 编译报错与依赖排查

几个高频报错,看到就知道往哪查:

报错信息真实原因处理方式
未能找到类型或命名空间名“SqlConnection”缺System.Data.SqlClient引用在项目引用里添加,或 NuGet 装System.Data.SqlClient
未能加载文件或程序集 Newtonsoft.Json缺 NuGet 包,packages目录没跟着拷VS 里右键解决方案“还原 NuGet 包”
目标框架版本不受支持只装了 .NET SDK,没装 .NET Framework 目标包VS 安装器里补“.NET Framework 4.x 开发工具”
未能找到类型“ConfigurationManager”缺System.Configuration引用添加引用,或在 NuGet 装System.Configuration.ConfigurationManager

还有一种不是编译错误、但更折磨人的:项目能编译,一运行就弹“应用程序中发生了未经处理的异常”。点“详细信息”看最内层异常,如果是System.Data.SqlClient.SqlException,八成是连接串的服务器名或库名不对;如果是InvalidOperationException: 连接未关闭,去看有没有把SqlConnection提成静态字段;如果是NullReferenceException,重点查窗体Load事件里有没有用到还没赋值的控件或CurrentUser。

提示:bin/Debug目录可以整个删掉重新编译,很多时候“改了代码没生效”就是旧的 dll 还在那儿。删之前记得把App.config里改过的连接串抄回来。

5. 避坑与排查:五个让人卡半天的问题

5.1 登录一点就报“未将对象引用设置到对象的实例”

现象:窗体显示正常,输完账号密码点登录,直接弹异常对话框,中断在btnLogin_Click某一行。

原因:最常见的是ConnectionStrings["EduDb"]返回null,然后.ConnectionString就炸了。根源是App.config里的name和代码里取的名字不一致,或者项目根本没用App.config(被误删、被排除出项目)。其次是dt.Rows[0]["UserName"]这类取值,查询没返回行却强行取第 0 行。

解决:先确认配置文件在项目里且“复制到输出目录”为“始终复制”,再在SqlHelper静态构造函数里加一句判空,把错误提前暴露成明确的提示,而不是一行天书异常。至于Rows[0],取之前一律if (dt.Rows.Count == 0) return;。

5.2 界面上改了数据,数据库里没变

现象:DataGridView里把电话号码改掉,点保存提示“保存成功”,重新查询还是老号码。

原因:保存时取的是dataGridView1.CurrentRow.Cells["Phone"].Value,但用户可能还在编辑状态没离开单元格,Value拿到的仍是旧值。另一种情况是DataGridView绑的是DataTable,用户直接改单元格,只改了内存里的DataRow,从没调过UPDATE。

解决:保存前先dataGridView1.EndEdit(),强制提交当前编辑;绑定时设置EditMode = DataGridViewEditMode.EditProgrammatically,禁止直接改单元格,所有编辑走弹窗,从源头上杜绝“改了没存”的困惑。

5.3 中文姓名和课程名存进去变成问号

现象:插入“张三”,数据库里查出来是??,或者界面上显示成一堆乱码。

原因:字段类型用了VARCHAR而不是NVARCHAR,VARCHAR按当前排序规则存,非拉丁字符必然丢。另一种情况是SqlParameter用AddWithValue传字符串,SQL Server 推断成VARCHAR,即使字段是NVARCHAR也会在转换时丢字。

解决:所有可能存中文的字段一律NVARCHAR;参数显式指定类型,new SqlParameter("@name", SqlDbType.NVarChar, 50) { Value = name },别偷懒用AddWithValue。

5.4 换台电脑就提示连不上数据库

现象:自己机器上跑得好好的,拷到同学电脑上,登录界面能开,一点就提示“在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误”。

原因:连接串写的是localhost,但对方机器上的 SQL Server 是 Express 实例(localhost\SQLEXPRESS)或 LocalDB,实例名不同;也可能是对方装了数据库但从没启动过服务。

解决:先让对方在 SSMS 里能连上,把“服务器名称”那一栏的字符串原样抄到App.config的Data Source里。这一步做完还连不上,就去 SQL Server 配置管理器里确认服务和 TCP/IP 协议都开着。血泪经验:不要试图在代码里猜服务器名,猜不出来。

5.5 选课人数和实际选课记录对不上

现象:课程显示“已选 48 人”,但选课表里COUNT(*)只有 46 条;或者退课之后人数不减少。

原因:扣减名额和写记录没放在同一个事务里,中间抛异常导致只成功了一半;或者历史数据是手工插的,从没维护过Selected字段。

解决:把选课、退课都包进SqlTransaction,失败整体回滚(见 3.4 的写法)。已经乱了的数据,跑一条校准 SQL 修回来:UPDATE c SET Selected = (SELECT COUNT(*) FROM Elective e WHERE e.CourseId = c.CourseId) FROM Course c;,之后所有写操作都走事务,别再手工改。

6. 二次开发:把这个课程设计改成能交付的东西

包里的代码能跑通、能演示,但离“能交付”还有距离。我一般按下面这个顺序改,改动量从小到大,每一步都能独立验证:

改造项原状态改法验证方式
密码存储明文或裸 MD5加盐 + PBKDF2/SHA256 迭代数据库里看不到明文,旧账号能登录
SQL 位置散落在窗体里全部下沉到 DAL全局搜SELECT只出现在 DAL 层
异常处理直接崩全局Application.ThreadException+ 日志文件故意输错连接串,程序不崩只提示
数据导出无用ClosedXML或EPPlus导 Excel导出的文件能被 Excel 正常打开

密码这块最该先改。原代码如果是HashHelper.Sha256(pwd)存进库,本质上和明文差不多,彩虹表一秒就查出来。改成带盐的写法,注册时生成随机盐存两列,登录时用同一份盐重算:

public static class HashHelper { // 生成 16 字节随机盐,和哈希一起存库(Salt 列、Password 列) public static string NewSalt() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); // PBKDF2:迭代 10 万次,单次登录几十毫秒,暴力破解成本高几个数量级 public static string Compute(string password, string salt) { using (var pbkdf2 = new Rfc2898DeriveBytes( password, Convert.FromBase64String(salt), 100000, HashAlgorithmName.SHA256)) { return Convert.ToBase64String(pbkdf2.GetBytes(32)); } } }

登录时先按用户名查出Salt和Password,再用Compute(输入的密码, salt)比对,而不是在 SQL 里WHERE Password = @p。这样改完,SysUser表要加一列Salt,老账号得跑一次迁移脚本重置密码——麻烦,但这是唯一正确的做法。

SQL 下沉那步最枯燥也最值钱。做法是给每个实体建一个 DAL 类,方法签名统一成GetById、GetList、Insert、Update、Delete,窗体里只留dataGridView1.DataSource = new StudentBLL().GetList(kw)这种一行调用。改完之后你会突然发现,加一个“按班级筛选”的功能只需要在 BLL 里加个参数,不用碰界面。

顺手加个全局异常处理,能省掉大量“用户截图问你这啥意思”的沟通:

// Program.cs 的 Main 里,Application.Run 之前 Application.ThreadException += (s, e) => { File.AppendAllText("error.log", $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {e.Exception}\r\n"); MessageBox.Show("操作失败,已记录日志,请联系管理员", "提示"); }; AppDomain.CurrentDomain.UnhandledException += (s, e) => File.AppendAllText("error.log", $"{DateTime.Now} {e.ExceptionObject}\r\n");

还有一个具体技巧:把FrmMain里的菜单点击统一走一个OpenForm<T>() where T : Form, new()泛型方法,判断窗体是否已经打开、已打开就激活而不是再 new 一个。这套代码里常见的问题是反复点“学生管理”,任务栏堆出七八个一模一样的窗口,用户以为程序卡了。

private void OpenForm<T>() where T : Form, new() { // 已经打开过就激活,不再新建,避免窗口越点越多 var exist = this.MdiChildren.FirstOrDefault(f => f is T); if (exist != null) { exist.Activate(); return; } var frm = new T { MdiParent = this }; frm.Show(); }

最后说个我自己的教训。刚开始接这类改造时,我总想一口气把三层架构、日志、加密、导出全加上,结果改到一半编译不过,原作者留下的业务逻辑又没摸清,最后连原来的演示流程都跑不通,只能从压缩包里重新解压一份重来。从那以后我每次动别人的项目,都强制走一遍“先原样跑通并截图存档 → 每改一处就编译运行一次 → 用 Git 留一个能回退的提交”这个流程,哪怕只是改个连接串也照做。这套 C# 窗体教务系统本身不复杂,难的是你不知道哪一行改动会牵扯到别处,留好后悔药比写得多漂亮重要得多。希望帮到你。

本文还有配套的精品资源,点击获取