简介:面向计算机专业课程设计与桌面应用入门者的 WinForms 学生信息管理系统源码包,围绕学生信息与选课场景提供管理员、学生双角色权限控制。管理员登录后可完成成绩管理、班级管理、选课管理和用户管理,学生端则集中在个人信息维护与选课操作,配套 SQL Server 2005 数据库文件,便于还原数据并跑通业务流程。压缩包共 75 个文件,以 26 个 C# 源码文件为核心,辅以窗体资源、配置文件、解决方案与项目文件、数据库 MDF/LDF 及少量图片资源,整体约 557KB,结构紧凑、模块清楚。已有 332 人学习下载,适合用作课程设计、毕业设计原型或 WinForms 权限管理与数据库增删改查的参考实现,也可在此基础上扩展界面与业务逻辑。需注意运行环境为 SQL Server 2005,学生默认账号为杰克、密码 1,部署前应核对连接字符串与数据库附加配置。
1. WinForms 学生信息管理系统源码:桌面端做学籍台账,到底图什么
先说一个反直觉的现象:在很多学校教务科室、培训机构的机房里,最稳的学生信息管理工具不是网页系统,而是一个双击就能开的exe。断网能用、老电脑能跑、Excel 直接导,这份 WinForms 学生信息管理系统源码就是为这个场景准备的——它用 C# + .NET Framework 把学籍录入、班级管理、成绩统计、报表导出这一整套流程做成了桌面程序,数据落在 SQL Server 或 LocalDB 上,界面用 DataGridView 铺开就是一张能直接编辑的台账。
适合谁?一是课程设计和毕设阶段需要一份结构完整、能跑能改的 C# 项目的学生;二是单位里真的要维护几千条学籍记录、又不想搭服务器的开发者。它的价值不在技术新潮,而在于业务流程完整、层级清晰、改起来不牵一发动全身。
2. 解决方案结构与环境搭建:从 .sln 到第一次 F5 跑通
拿到压缩包之后,很多人第一反应是双击StudentManager.sln,然后等着看它自己转起来。实际情况通常是要先补三样东西:数据库实例、连接字符串、NuGet 包。这一章先把工程骨架摊开,再说环境怎么配。
2.1 目录与项目分层:四层还是两层,看维护人数
这套源码采用典型的四层结构,UI 只管画面和事件,BLL 放业务规则,DAL 负责拼 SQL,Model 是纯数据载体。目录大致长这样:
StudentManager/ ├── StudentManager.sln ├── src/ │ ├── StudentManager.UI/ # WinForms 主程序,唯一有窗体的项目 │ │ ├── Forms/ │ │ │ ├── LoginForm.cs # 登录 │ │ │ ├── MainForm.cs # 主框架 + 导航 │ │ │ ├── StudentListForm.cs # 学生档案列表 │ │ │ ├── StudentEditForm.cs # 新增/编辑弹窗 │ │ │ └── ScoreStatForm.cs # 成绩统计 │ │ ├── Program.cs │ │ └── App.config # 连接串放这里 │ ├── StudentManager.BLL/ # 业务规则,如学号唯一、成绩范围校验 │ ├── StudentManager.DAL/ # SqlHelper + 各表 DAO │ ├── StudentManager.Model/ # Student、Score、SysUser 等实体 │ └── StudentManager.Common/ # 加解密、Excel 导出、日志封装 ├── db/ │ ├── schema.sql # 建表脚本 │ └── seed.sql # 初始账号与测试数据 └── docs/分层不是越细越好。我见过不少同学拿到这种结构,第一件事就是把 BLL 整个删掉,让窗体直接调 DAL,理由是“就几十行代码绕来绕去”。小项目这么干确实快,但只要出现第二个入口(比如批量导入也要校验学号重复),规则就得复制一份,改一次漏一次。稳妥的做法是:窗体里不写 SQL,DAL 里不写业务判断,这条线守住,后面加功能才不至于满地找。
Common层看着像杂物间,实际上是二次开发最常动的地方。加解密、导出、日志这些横切能力集中在这里,改一处全局生效。
2.2 开发环境与依赖清单
老 WinForms 项目最容易翻车的地方就是环境,版本差一点就编译不过。下面这份是我在这类项目上验证过的组合:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| .NET Framework | 4.7.2 或 4.8 | 低于 4.5 的话 NPOI 新版会报错,建议直接升 |
| Visual Studio | 2019 / 2022 社区版 | 安装时勾选“.NET 桌面开发”工作负载 |
| 数据库 | SQL Server 2012 Express 及以上,或 LocalDB | 测试阶段用 LocalDB 最省事,不用装服务 |
| 数据访问 | System.Data.SqlClient(框架自带) | 不需要额外装包 |
| Excel 导出 | NPOI 2.5.x 及以上 | 走 NuGet 还原,别用 Office Interop |
| 报表(可选) | Microsoft Report Viewer | 只有打印模块需要,不做报表可以卸掉 |
还有一个隐蔽项:项目属性里的“目标框架”如果写的是.NET Framework 4.0,而你的 VS 没装这个版本的开发包,打开时会提示“项目不受支持”。解决办法要么在 VS Installer 里补装,要么右键项目改目标框架到 4.7.2,顺便把app.config里的supportedRuntime节点一起改掉。
2.3 数据库初始化与连接配置
db/schema.sql里是全量建表语句,直接用 SSMS 打开执行,或者命令行跑:
sqlcmd -S .\SQLEXPRESS -E -i db/schema.sql sqlcmd -S .\SQLEXPRESS -E -d SchoolDB -i db/seed.sql-E表示 Windows 身份验证,-i指定输入脚本,-d指定目标库。执行完schema.sql之后必须先建库或切库,否则表会落到 master 里,这是新手常犯的错。
连接字符串不要写死在代码里,源码里放在App.config:
<configuration> <connectionStrings> <!-- 换成你自己的实例名;用 LocalDB 时改成 (localdb)\MSSQLLocalDB --> <add name="SchoolDb" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=SchoolDB;Integrated Security=True;MultipleActiveResultSets=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>Integrated Security=True走 Windows 认证,不用填账号密码,本地开发最省心;如果部署到别人的机器上要改成User ID=sa;Password=xxx。MultipleActiveResultSets=True建议留着,同一个连接上同时开 DataReader 和发第二条命令时不会报“已有打开的与此命令相关联的 DataReader”。
2.4 首次编译运行的验证路径
按这个顺序走,基本不会卡:
- VS 打开
.sln,右键解决方案 →“还原 NuGet 包”,确认 NPOI 等包下到位(输出窗口会打)。 - 改
App.config的连接串,先执行schema.sql。 - 把
StudentManager.UI设为启动项目,生成解决方案。 - F5,出现登录窗体,用
seed.sql里的初始账号进(一般是 admin / 123456,具体看脚本注释)。 - 进去第一件事:把默认密码改掉,顺手确认主窗体左下角显示的数据库名对不对。
- 打开学生列表,随便增删改一条,关掉程序重开,数据还在就说明写入链路通了。
提示:如果编译报“未能加载文件或程序集”,九成是 NuGet 包版本和 projects 里的
HintPath对不上,右键包管理器里重新安装一次比手工改 csproj 快。
3. 核心模块拆解:登录、学生档案、成绩统计三条主链路
功能列表看着长,真正撑起整个系统的其实就三条链路:怎么证明你是谁、怎么把一条学生记录写进去再查出来、怎么把一堆成绩算成能交差的报表。把这三段读透,剩下的模块都是复制粘贴加改字段。
3.1 登录鉴权:从明文比对到加盐哈希
很多教学版源码的登录是这样的:SELECT * FROM SysUser WHERE UserName='{0}' AND Password='{1}',字符串一拼就完事。这份源码在BLL/UserService.cs里做了两件事——参数化,以及加盐哈希比对:
public class UserService { private readonly UserDal _dal = new UserDal(); public SysUser Login(string userName, string password) { // 1. 按用户名取记录,SQL 里不出现密码,避免拼串注入 var user = _dal.GetByName(userName); if (user == null || user.IsActive != 1) return null; // 2. 用记录自带的盐重新算一次,和库里的哈希比 var input = HashPassword(password, user.Salt); return input == user.PasswordHash ? user : null; } // PBKDF2,迭代次数 10000,盐 16 字节 public static string HashPassword(string pwd, string salt) { var saltBytes = Convert.FromBase64String(salt); using (var kdf = new Rfc2898DeriveBytes(pwd, saltBytes, 10000)) { return Convert.ToBase64String(kdf.GetBytes(32)); } } }Rfc2898DeriveBytes就是 .NET 框架自带的 PBKDF2,10000是迭代次数,数字越大越慢也越难暴力,桌面端登录场景给 10000 足够,给到 10 万会让登录卡顿半秒。GetBytes(32)取 32 字节输出,对应库里PasswordHash NVARCHAR(64)字段(Base64 后长度会膨胀)。
盐不是全局一个,而是每个账号注册时随机生成 16 字节存进SysUser.Salt。全局固定盐等于没有盐, rainbow table 一撞一个准。
登录成功后把SysUser塞进一个静态的CurrentUser或者MainForm的字段里,后面按钮权限判断靠RoleId,别每次都去查库。
3.2 学生档案 CRUD:DataGridView 与 DataTable 的配合
学生列表页是整套代码里最长的一个文件,也是最值得拆的部分。核心思路是:查询结果填进DataTable,DataTable挂到BindingSource,BindingSource再挂到DataGridView。三层链路的好处是增删改都可以先在内存里做,最后统一提交。
private readonly StudentDal _dal = new StudentDal(); private DataTable _cache; // 当前页数据 private int _pageIndex = 1; private const int PageSize = 50; private void LoadPage(string keyword) { int total; // keyword 为空时退化为纯分页,不要拼出 LIKE '%%' 走全表扫 _cache = _dal.Search(keyword, _pageIndex, PageSize, out total); bindingSource1.DataSource = _cache; dataGridView1.DataSource = bindingSource1; lblTotal.Text = $"共 {total} 条 / 第 {_pageIndex} 页"; }Search在 DAL 里用ROW_NUMBER()做分页,兼容 SQL Server 2008:
SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY StudentNo) AS RowNum, * FROM Student WHERE (@kw = '' OR StudentNo LIKE @kw OR Name LIKE @kw) ) t WHERE t.RowNum BETWEEN @start AND @end;@kw由上层拼成%关键字%,注意是参数传进去,不是字符串拼接。@start和@end由_pageIndex算出:start = (pageIndex-1)*PageSize + 1。
编辑这一侧,我给的建议是:别指望 DataGridView 自动更新。它确实能改单元格,但要把改动写回数据库,得靠SqlDataAdapter.Update配SqlCommandBuilder,而CommandBuilder要求查询必须带主键、不能有聚合、不能多表 JOIN——一旦你为了显示班级名加了 JOIN,它就静默失败。
更稳的做法是把编辑拆到独立弹窗StudentEditForm,双击行把实体传进去,保存时走一条明确的 UPDATE:
public int Update(Student s) { const string sql = @"UPDATE Student SET Name=@Name, Gender=@Gender, ClassId=@ClassId, Phone=@Phone, UpdateTime=GETDATE() WHERE StudentNo=@StudentNo"; return SqlHelper.Execute(sql, new SqlParameter("@Name", s.Name), new SqlParameter("@Gender", (int)s.Gender), new SqlParameter("@ClassId", s.ClassId), new SqlParameter("@Phone", (object)s.Phone ?? DBNull.Value), new SqlParameter("@StudentNo", s.StudentNo)); }(object)s.Phone ?? DBNull.Value这一句是必须的,可空字段传null给SqlParameter会报“参数化查询未提供该参数的值”,传DBNull.Value才对应 SQL 的 NULL。性别用枚举存 int,别在数据库里存“男/女”字符串,排序和统计的时候会感谢自己。
3.3 成绩录入与统计:一条分组 SQL 顶掉三层循环
成绩模块是这套源码里最能体现“桌面端优势”的地方。老师一次性导入一整个班的成绩,界面上要立刻看到平均分、最高分、及格率。很多人第一反应是查出所有记录,在 C# 里foreach累加。三千条数据没问题,三万条就开始转圈。
源码里的做法是把统计压到数据库:
SELECT s.ClassId, c.ClassName, COUNT(*) AS 人数, AVG(sc.Score * 1.0) AS 平均分, MAX(sc.Score) AS 最高分, SUM(CASE WHEN sc.Score >= 60 THEN 1 ELSE 0 END) AS 及格人数, CAST(SUM(CASE WHEN sc.Score >= 60 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS DECIMAL(5,2)) AS 及格率 FROM Score sc JOIN Student s ON s.StudentNo = sc.StudentNo JOIN Class c ON c.ClassId = s.ClassId WHERE sc.ExamId = @examId GROUP BY s.ClassId, c.ClassName ORDER BY s.ClassId;Score * 1.0是为了让整数列参与除法时提升为小数,否则AVG在某些列类型下会截断。CAST(... AS DECIMAL(5,2))把及格率固定两位小数,前端直接绑到 DataGridView 的DefaultCellStyle.Format = "0.00"就行,不用再在 C# 里Math.Round。
批量录入这一侧,逐条 INSERT 是最慢的写法。三千条数据逐条走,加上网络往返,一两分钟很正常。源码里改用SqlBulkCopy:
public void BulkImport(DataTable dt) { using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) using (var bulk = new SqlBulkCopy(conn, SqlBulkCopyOptions.Default, tran)) { bulk.DestinationTableName = "Score"; bulk.BatchSize = 1000; // 每 1000 条提交一批 bulk.BulkCopyTimeout = 120; // 大批量时默认 30 秒不够 bulk.ColumnMappings.Add("StudentNo", "StudentNo"); bulk.ColumnMappings.Add("ExamId", "ExamId"); bulk.ColumnMappings.Add("Score", "Score"); bulk.WriteToServer(dt); tran.Commit(); } } }ColumnMappings必须显式加,靠位置隐式匹配在列顺序变化时会写错字段,而且不报错,属于典型的“数据悄悄串行”。BatchSize给 1000 是经验和内存的折中,太大容易在客户端堆内存。
3.4 导出与打印:DataSet 直接喂给报表
导出 Excel 用的 NPOI,不依赖机器上装没装 Office:
public static void ExportToExcel(DataTable dt, string filePath) { IWorkbook wb = new XSSFWorkbook(); // .xlsx 用 XSSF var sheet = wb.CreateSheet("学生名单"); var head = sheet.CreateRow(0); for (int i = 0; i < dt.Columns.Count; i++) head.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName); for (int r = 0; r < dt.Rows.Count; r++) { var row = sheet.CreateRow(r + 1); for (int c = 0; c < dt.Columns.Count; c++) row.CreateCell(c).SetCellValue(dt.Rows[r][c]?.ToString()); } using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write)) wb.Write(fs); }XSSFWorkbook对应.xlsx,HSSFWorkbook对应老的.xls,导出上万行时 XSSF 内存占用明显高,可以换SXSSFWorkbook走流式写。单元格值统一ToString()是最省事也最容易出错的做法——日期会变成2024/3/1 0:00:00这种,需要按列类型做格式化,源码里留了一个FormatCell的扩展点,改的时候从这里下手。
报表打印走的是 ReportViewer,.rdlc文件绑定的数据源就是上面那个DataTable,不需要额外转实体。如果部署机器上提示找不到 ReportViewer,装一次对应的运行时就行,这是这类项目最常见的“我这能跑,他那打不开”。
4. 数据访问层设计:SqlHelper、参数化查询与事务边界
DAL 这一层是整套代码复用率最高的部分,也是二次开发时最容易改出事故的地方。写好一个工具类,后面几十个查询都能少写一半样板代码。
4.1 一个 SqlHelper 把重复代码砍掉八成
Common/SqlHelper.cs只做三件事:拿连接、开命令、处理参数。所有方法统一using,保证连接一定释放:
public static class SqlHelper { // 只读一次配置,避免每次查询都解析 App.config public static readonly string ConnStr = ConfigurationManager.ConnectionStrings["SchoolDb"].ConnectionString; 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?.Length > 0) cmd.Parameters.AddRange(ps); var dt = new DataTable(); da.Fill(dt); // 内部会自动 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?.Length > 0) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } public static object Scalar(string sql, params SqlParameter[] ps) { /* 同理 */ } }params SqlParameter[]这个写法是关键,调用方可以一路SqlHelper.Execute(sql, p1, p2, p3)写下去,不用每次 new 数组。SqlDataAdapter.Fill内部自己管连接的开关,所以Query里不用手写conn.Open();而Execute用的是SqlCommand,必须显式开。
ConnStr声明成static readonly,进程内只解析一次,比每次读ConfigurationManager快很多。代价是运行时改配置文件不会生效,改连接串要重启程序——这个取舍在桌面端完全可接受。
4.2 参数化查询不是可选项
我见过太多这样的代码:"SELECT * FROM Student WHERE Name LIKE '%" + txtName.Text + "%'"。测试时输入“张”没问题,用户输入一个单引号就崩,输入'; DROP TABLE Student--就没了。
参数化之后:
var kw = "%" + txtKeyword.Text.Trim() + "%"; const string sql = "SELECT * FROM Student WHERE Name LIKE @kw OR StudentNo LIKE @kw"; var dt = SqlHelper.Query(sql, new SqlParameter("@kw", kw));一个参数在 SQL 里出现两次完全没问题,AddRange只加一次。SqlParameter的构造函数第二个参数传值时会自动推断类型,字符串就是NVARCHAR,长度按实际值走;如果字段是VARCHAR(50)而参数推断成NVARCHAR(4000),索引可能不生效,数据量大时会有隐式转换的性能损耗。追求极致的话显式指定:new SqlParameter("@kw", SqlDbType.NVarChar, 100) { Value = kw }。
4.3 事务与批量导入:一次插入 3000 条学籍数据
批量导入必然涉及事务——中途有一条学号重复,前面插进去的就成了脏数据。事务的边界应该划在 BLL 里,而不是每个 DAL 方法内部各开各的:
public int ImportStudents(List<Student> list) { int ok = 0; using (var conn = new SqlConnection(SqlHelper.ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { foreach (var s in list) { // 每条都用同一个事务对象,出错整体回滚 _dal.Insert(conn, tran, s); ok++; } tran.Commit(); return ok; } catch (SqlException ex) when (ex.Number == 2627) // 主键冲突 { tran.Rollback(); throw new InvalidOperationException($"第 {ok + 1} 条学号重复:{list[ok].StudentNo}", ex); } } } }这里有个细节:_dal.Insert必须接收外部传入的conn和tran,如果它内部自己new SqlConnection,事务根本管不到,回滚了也白搭。所以 DAO 方法推荐写成双重重载,一个带连接一个不带,带连接的那个给事务和批量场景用。
ex.Number == 2627是 SQL Server 的主键/唯一约束冲突错误码,抓住它换成用户能看懂的中文提示,比直接弹“违反 PRIMARY KEY 约束”友好得多。
4.4 表结构与索引:别等查不动了才想起来
核心表就四张,字段不多但索引位置很关键:
| 表名 | 关键字段 | 索引建议 |
|---|---|---|
| Student | StudentNo(PK)、Name、ClassId、Gender、Phone | ClassId 建非聚集索引,按班查是高频操作 |
| Class | ClassId(PK)、ClassName、GradeId、HeadTeacher | ClassName 唯一索引,防重复建班 |
| Score | Id(PK)、StudentNo、ExamId、Score | (ExamId, StudentNo) 联合唯一索引,防同一场考试重复录入 |
| SysUser | UserId(PK)、UserName、PasswordHash、Salt、RoleId | UserName 唯一索引,登录必走 |
Score表上的(ExamId, StudentNo)联合索引一举两得:既挡住了重复录入,又让按考试查成绩的统计 SQL 直接走索引查找。很多教学项目只在主键上建索引,数据量一过十万,统计页要转好几秒。
注意:
Name LIKE '%张%'这种前置通配符用不上索引,学生数量到万级时建议改成按学号精确查或加前缀匹配,别指望加索引能救。
5. 常见问题排查:WinForms 与数据库组合最容易翻车的五处
下面这五条都是我在实际跑这类项目时撞过的,按“现象 → 原因 → 解决”记,遇到对号入座就行。
现象一:换一台机器就报“未将对象引用设置到对象的实例”,堆栈指向登录。原因通常是连接字符串写死在某个const string里,原作者的本机实例名是.\SQLEXPRESS,你的机器上叫(localdb)\MSSQLLocalDB,连不上就返回 null,后面直接.UserName就炸了。解决:全局搜一遍Data Source=,把所有硬编码的连接串收敛到App.config,代码里只从ConfigurationManager读,空值就抛明确异常。
现象二:DataGridView 里改了数据,界面上看着变了,重开程序又恢复原样。这是最经典的误解——DataGridView绑DataTable只是双向同步内存,不等于写库。要落盘必须显式调SqlDataAdapter.Update,或者像 3.2 节那样走独立编辑窗。判断方法:在FormClosing里断点看_cache.GetChanges()是不是 null,不是 null 就说明有未提交的改动。
现象三:导入一万条数据时界面假死,用户以为程序崩了直接结束进程。原因是循环和 IO 全跑在 UI 线程上,Application.DoEvents()那套只是让消息泵转起来,治标不治本。正确做法是Task.Run里跑导入,进度通过IProgress<int>回报,更新控件前判断InvokeRequired:
private void Report(int percent) { if (InvokeRequired) { BeginInvoke(new Action<int>(Report), percent); return; } progressBar1.Value = percent; // 只在 UI 线程碰控件 }BeginInvoke是异步投递,不会阻塞后台线程;Invoke是同步等待,在大量回调时容易互相卡死,优先用前者。
现象四:高分屏笔记本上界面糊成一片,或者控件全挤在左上角。WinForms 默认按 96 DPI 设计,缩放 150% 时如果不声明 DPI 感知,系统会做位图拉伸。解决是在 UI 项目加一个app.manifest,写入<dpiAware>true/pm</dpiAware>和<dpiAwareness>PerMonitorV2</dpiAwareness>,同时把窗体的AutoScaleMode设为Dpi。改完记得把所有Anchor和Dock检查一遍,硬编码的Location在缩放后会错位。
现象五:打包发给别人,对方电脑提示“应用程序中发生了无法处理的异常”,点详细信看到FileNotFoundException: NPOI。不是代码问题,是发布时只拷了exe没拷dll。bin\Debug下面一堆依赖文件都要带上,或者直接做 ClickOnce / 单文件发布。另外目标机器必须装对应版本的 .NET Framework,4.7.2 在 Win7 上要单独安装,这一点在交付前务必确认。
6. 二次开发改造:把单机台账改成能交付的系统
这套源码拿来交作业够用,但要真给一个科室用,有三处我建议先动。
第一处是数据访问的收口。现在每个 DAL 都是手写 SQL + 手拼SqlParameter,字段一多就容易漏。小步改造的话,引入 Dapper 成本最低:connection.Query<Student>("SELECT * FROM Student WHERE ClassId=@id", new { id }),实体映射自动完成,原有的SqlHelper还能继续给复杂统计用,两者共存不冲突。改造顺序建议从查询最频繁的StudentDal.Search开始,改一个验证一个,别一次性全换。如果用 Entity Framework,要注意Database.FirstOrDefault在没数据时返回 null 而不是抛异常,老代码里那些dt.Rows[0]的写法全部要重写。
第二处是权限。现在只有RoleId一个字段,实际上就是“管理员能看全部、老师只能看自己班”。真要落地,加一张RolePermission(RoleId, ModuleCode, CanRead, CanWrite)表,主窗体加载时按角色把没权限的菜单项Enabled = false,比在每个按钮的 Click 里写if判断干净得多。我一般会在MainForm的Load事件里统一刷一遍,权限变更后要求重新登录生效,避免运行中状态不一致。
第三处是日志与异常兜底。桌面程序最大的问题是出错时用户只会说“它坏了”,你拿不到堆栈。在Program.cs里挂两个全局钩子,把未处理异常写到本地文件:
static void Main() { Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); Application.ThreadException += (s, e) => LogHelper.Write(e.Exception); // UI 线程 AppDomain.CurrentDomain.UnhandledException += (s, e) => LogHelper.Write(e.ExceptionObject as Exception); // 非 UI 线程 Application.EnableVisualStyles(); Application.Run(new LoginForm()); }ThreadException只捕获 UI 线程的异常,后台线程的必须靠AppDomain.UnhandledException,两个都挂才不漏。日志按天分文件写到%AppData%下,别写程序目录,Program Files 没有写权限。
还有一个容易被忽略的点:交付前把seed.sql里的测试数据清掉,把默认管理员密码改成首次登录强制修改。我见过一个真实的反面案例,某单位内部系统上线半年,管理员账号还是admin/123456,谁都能进,直到有人误删了一个班的数据才发现。
从那以后我每次接手一个 WinForms 老项目,都会先把连接串、权限表、全局异常这三件事过一遍,再动业务代码——顺序反了,改出来的问题根本定位不到。希望帮到你。
本文还有配套的精品资源,点击获取