简介:这份人事管理系统课程设计项目基于C#与关系型数据库构建,面向学习数据库课程设计、Windows桌面应用开发及人事业务建模的学生与开发者,可用于理解员工档案、考勤、薪资、部门与登录权限等模块的完整实现。压缩包共91个文件,约4.1MB,以C#源码、WinForms窗体资源、项目配置、可执行程序及数据库文件为主,并附课程设计报告与系统运行说明文档,便于对照阅读设计思路和验证运行流程。该项目已有5724人学习下载,适合作为课程实践与项目复现的参考。读者可从中获取数据库表结构、SQL与事务处理思路、C#界面逻辑及模块联动方式,也能借助文档梳理需求分析、系统架构和测试结果,快速建立从数据库设计到桌面应用落地的整体认识。
1. 从「先建库,再写码」说起
带过几届课程设计之后我发现一个规律:最后答辩讲不清楚的项目,问题几乎都不在界面丑,而在数据库和代码各自为政。ER 图画了八张表,程序里只用了三张,剩下的靠临时拼 SQL 凑。这套 C# 人事管理系统算是把顺序倒过来了:先把完整数据库建好,字段、外键、约束、视图全部落成脚本,再让 WinForms 界面一层层贴着表去写。
它覆盖的业务不算花哨但足够完整:部门与职位维护、员工档案增删改查、考勤录入、按月生成薪资、角色权限和操作日志。适合三类人:正在做数据库课程设计、需要一个能讲清楚每张表为什么这么设计的人;想学 C# + ADO.NET 分层怎么写、而不是把所有 SQL 塞进按钮事件里的人;以及接手老式桌面管理系统、需要快速摸清结构再动手改的开发者。
2. 工程结构与跑通链路:五层目录各自管什么
第一次打开这类项目,最容易犯的错是从FrmEmployee.cs开始读。窗体代码往往是最脏的一层,按钮事件里塞着取值、校验、拼 SQL、刷新表格四件事,读完还是不知道数据从哪来。正确的入口是解决方案目录:先看清楚有几层、每层的边界在哪,再顺着一条「点击查询 → 拿到 DataTable → 绑定表格」的链路走一遍,整个项目的骨架就立起来了。这套工程按 Model / DAL / BLL / Common / UI 五层切,层与层之间只允许从上往下单向依赖,UI 不直接引用SqlConnection。
2.1 目录树:从 sln 到每个 cs 文件的职责
HrmsSolution/ ├── Hrms.sln ├── Hrms.Model/ # 实体类,字段与数据库表一一对应 │ ├── Employee.cs │ ├── Department.cs │ ├── Attendance.cs │ └── Salary.cs ├── Hrms.DAL/ # 数据访问层,只跟 SQL 打交道 │ ├── SqlHelper.cs # 连接、执行、参数封装 │ ├── EmployeeDAL.cs │ ├── AttendanceDAL.cs │ └── SalaryDAL.cs ├── Hrms.BLL/ # 业务逻辑层,校验、算薪、事务边界 │ ├── EmployeeBLL.cs │ ├── AttendanceBLL.cs │ └── SalaryBLL.cs ├── Hrms.Common/ # 工具类 │ ├── Md5Helper.cs │ ├── ExcelExporter.cs │ └── LogWriter.cs └── Hrms.UI/ # WinForms 表现层 ├── App.config # 连接字符串放这里 ├── Program.cs └── Forms/ ├── FrmLogin.cs ├── FrmMain.cs ├── FrmEmployee.cs ├── FrmAttendance.cs └── FrmSalary.cs各层职责要卡死:Model 是一组只有属性和构造函数的贫血对象,不做任何校验;DAL 里每个方法只做一件事——把一条 SQL 执行掉并返回DataTable或影响行数,绝不写if (年龄 < 18) return;BLL 是唯一可以抛业务异常的地方,也是BeginTransaction的起点;Common 里的工具类全部是static,不持有状态;UI 只负责从控件取值、调用 BLL、把结果填回控件。
这样切的好处,在你后面加字段的时候特别明显。给员工表加一个「合同到期日」,只需要动四个地方:建表脚本、Employee.cs加一个属性、EmployeeDAL的 insert/update 语句、FrmEmployee加一个 DateTimePicker。如果一开始把 SQL 写在按钮里,你就得满项目搜INSERT INTO Employee。
提示:如果手上的版本没有
Hrms.Common,MD5 和导出逻辑大概率被塞进了FrmLogin或FrmMain,改造时优先把它们抽出来。
2.2 从还原数据库到第一个窗口:跑通的最小步骤
拿到源码包之后,不要急着按 F5。顺序反了,你会看到一堆「未将对象引用设置到对象的实例」,然后开始怀疑人生。按下面四步走。
第一步,还原数据库。打开 SSMS,右键「数据库」→「还原数据库」→ 源选「设备」,指向包里的HrmsDB.bak;如果包里有的是HrmsDB.sql脚本,就新建一个空库叫HrmsDB,打开脚本执行。注意脚本开头通常有USE [HrmsDB],库名必须和脚本里写的一致,否则执行到一半报「数据库不存在」。
第二步,确认实例名和身份验证方式。本机默认实例写.或(local);Express 版必须写.\SQLEXPRESS。如果安装时选的是「SQL Server 身份验证」,连接字符串里就得用User ID=sa;Password=xxx,而不是Integrated Security=True。
第三步,改App.config里的连接字符串。这一步是新手翻车重灾区,改完记得重新生成,因为App.config会被编译成Hrms.UI.exe.config,光改源文件不重新生成是不生效的。
<configuration> <connectionStrings> <!-- Data Source 换成你自己的实例名;Initial Catalog 必须和还原的库名一致 --> <add name="HrmsConn" connectionString="Data Source=.;Initial Catalog=HrmsDB;Integrated Security=True;" providerName="System.Data.SqlClient"/> </connectionStrings> </configuration>name的值要和代码里ConfigurationManager.ConnectionStrings["HrmsConn"]的索引完全一致,大小写敏感。providerName决定用哪个数据提供程序,SQL Server 2008 以后的版本用System.Data.SqlClient就够,不需要额外装包。
第四步,设置启动项目。右键Hrms.UI→「设为启动项目」,然后 F5。默认账号一般是admin,密码在种子数据里,通常是明文 MD5 之后的 32 位串,登录页会做一次同样的哈希再比对。
2.3 连接管理:为什么所有 SQL 都要过 SqlHelper
判断一个 C# 数据库项目写得糙不糙,看它有几处new SqlConnection。理想情况是一处都没有——全部收口到一个静态类里。原因不在于「设计模式好看」,而在于连接释放。SqlConnection实现了IDisposable,不用using包住,一旦中途抛异常,连接就挂在连接池里不还,池子满了之后整个程序会卡死在打开连接那一步,而且报的错跟真实原因八竿子打不着,极其玄学。
public static class SqlHelper { // 从 App.config 读,改配置不用重新编译代码 private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["HrmsConn"].ConnectionString; // 查询:返回 DataTable,直接丢给 DataGridView.DataSource 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); return dt; } } // 增删改:返回受影响行数,BLL 靠它判断成功与否 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(); } } }Query里没有显式conn.Open(),因为SqlDataAdapter.Fill会自动打开再关闭连接;Execute则必须手动开。这两个方法的返回类型是刻意不同的:查询要的是结构化数据,用DataTable可以零转换绑到表格上;写入要的是「改了几行」,用int,BLL 里判断if (rows > 0)就能给出提示。
params SqlParameter[]这个写法让调用方可以SqlHelper.Query(sql, new SqlParameter("@id", 1)),也可以一个参数都不传。所有值都走参数,哪怕是一个内部生成的整数 ID,也别用字符串拼——课程设计里最常见的 SQL 注入演示,就是从登录框的字符串拼接开始的。
3. 数据库设计:八张表撑起一个完整人事业务
人事系统的表并不难,难在关系要闭合:员工必须挂在部门和职位下,考勤必须挂在员工下,薪资必须同时挂员工和月份,用户必须挂角色。任何一条外键缺失,后面统计的时候都会出现「查不到人」或者「同一个人算了两遍」。这一章把八张核心表、两个视图和几个关键索引拆开讲,最后给一份可以直接执行的建表脚本。
3.1 八张表的结构与外键关系
| 表名 | 作用 | 关键字段 | 外键指向 |
|---|---|---|---|
| Department | 部门树,支持多级 | DeptID, DeptName, ParentID, Manager | ParentID → Department.DeptID |
| Position | 职位,隶属部门 | PositionID, PositionName, DeptID, Grade | DeptID → Department |
| Employee | 员工主档 | EmpID, EmpNo, Name, Gender, DeptID, PositionID, HireDate, Status | DeptID, PositionID |
| SysRole | 角色 | RoleID, RoleName, Permissions | — |
| SysUser | 登录账号 | UserID, UserName, Password, RoleID, EmpID, IsEnabled | RoleID, EmpID |
| Attendance | 每日考勤 | AttID, EmpID, AttDate, CheckIn, CheckOut, Status | EmpID → Employee |
| Salary | 月度薪资 | SalaryID, EmpID, SalaryMonth, Base, Bonus, Deduct, Insurance, Tax, RealPay | EmpID → Employee |
| SysLog | 操作日志 | LogID, UserID, Action, Target, CreateTime | UserID → SysUser |
Department的ParentID自引用是这套表里唯一有点绕的地方。顶级部门的ParentID存NULL而不是 0,这样递归查询的时候终止条件明确。员工表的Status用 tinyint 存:1 在职、2 离职、3 试用,不要用中文直接存字符串,否则后面按状态筛选时索引全废。
Salary和Attendance都只存EmpID,不冗余存姓名。这是三范式的基本要求,也是后面视图存在的理由——界面要显示「张三 2024-06 实发 8620」,靠视图 join 出来,而不是在两张表里各存一份名字等着不同步。
CREATE TABLE Employee ( EmpID INT IDENTITY(1,1) PRIMARY KEY, EmpNo VARCHAR(20) NOT NULL UNIQUE, -- 工号,业务唯一键 Name NVARCHAR(30) NOT NULL, Gender CHAR(2) NOT NULL DEFAULT '男', BirthDate DATE NULL, IdCard VARCHAR(18) NULL, Phone VARCHAR(20) NULL, DeptID INT NOT NULL, PositionID INT NULL, HireDate DATE NOT NULL, Status TINYINT NOT NULL DEFAULT 1, -- 1在职 2离职 3试用 Remark NVARCHAR(200) NULL, CONSTRAINT FK_Emp_Dept FOREIGN KEY (DeptID) REFERENCES Department(DeptID), CONSTRAINT FK_Emp_Pos FOREIGN KEY (PositionID) REFERENCES Position(PositionID), CONSTRAINT CK_Emp_Gender CHECK (Gender IN ('男','女')), CONSTRAINT CK_Emp_Status CHECK (Status IN (1,2,3)) );建这张表有几个决定值得说明。EmpNo加了UNIQUE但没做主键,因为工号可能被业务要求修改(比如分公司合并后重编),主键用自增EmpID更稳。DeptID设NOT NULL而PositionID允许NULL,因为员工入职时部门必须确定,职位可能暂时空缺。两个CHECK约束是最后一道防线,界面下拉框能挡住正常操作,但挡不住直接跑 SQL 改数据的人。
3.2 索引、视图与统计查询
课程设计里最容易被忽略的是索引,因为数据量小,加不加都是毫秒级。但答辩时老师往往会问一句「如果十万条员工记录,你这个按姓名模糊查询还能不能扛住」,这时候有个明确答案会加分不少。
三个索引必须建。IX_Attendance_EmpDate建在Attendance(EmpID, AttDate)上,因为算薪时按人按月的范围查询是最热的路径;IX_Salary_EmpMonth建在Salary(EmpID, SalaryMonth)上,理由相同;IX_Employee_Dept建在Employee(DeptID)上,用于按部门筛选。注意索引列的顺序不能反:(EmpID, AttDate)能命中「某人的某段时间」,(AttDate, EmpID)只能命中「某天的所有人」,前者才是主场景。
视图的作用是把三表 join 封起来,界面层直接当单表用。
CREATE VIEW vw_EmployeeDetail AS SELECT e.EmpID, e.EmpNo, e.Name, e.Gender, e.Phone, e.HireDate, e.Status, d.DeptName, p.PositionName FROM Employee e LEFT JOIN Department d ON e.DeptID = d.DeptID LEFT JOIN Position p ON e.PositionID = p.PositionID;这里用LEFT JOIN而不是INNER JOIN是关键。如果某个员工的PositionID是NULL,INNER JOIN会把这个人在结果里直接抹掉,界面上表现为「明明加了人却查不到」,而且没有任何报错——这种静默丢数据的问题最难查。LEFT JOIN保证主表所有行都在,关联不到的字段显示为空。
注意:视图里别再套
ORDER BY,SQL Server 不允许在视图定义中排序,除非同时指定TOP。排序放在查询视图的SELECT里做。
3.3 建表脚本与种子数据
脚本执行顺序必须遵守外键依赖:先部门、职位,再员工,最后账号、考勤、薪资。倒过来执行会直接报「引用了无效的对象」。
-- 1. 建库 IF DB_ID('HrmsDB') IS NOT NULL DROP DATABASE HrmsDB; CREATE DATABASE HrmsDB; GO USE HrmsDB; GO -- 2. 基础表:先父后子 -- (Department / Position / Employee / SysRole / SysUser / Attendance / Salary / SysLog) -- 3. 种子数据:管理员账号 + 一个根部门 INSERT INTO SysRole (RoleName, Permissions) VALUES ('超级管理员', '*'); INSERT INTO Department (DeptName, ParentID) VALUES ('总公司', NULL); -- 密码统一存 MD5,明文由前端哈希后传入,库里不出现明文 INSERT INTO SysUser (UserName, Password, RoleID, IsEnabled) VALUES ('admin', '21232F297A57A5A743894A0E4A801FC3', 1, 1);种子数据的密码用的是admin的 MD5 大写串。要注意 C# 的BitConverter.ToString(hash).Replace("-", "")出来默认是大写,而有些教程里写的是小写,比对时如果不统一大小写,就会出现「密码明明输对了却登不进去」的问题,这个坑后面第 5 章会专门讲。
GO是 SSMS 的批处理分隔符,不是 T-SQL 语句。用 ADO.NET 的SqlCommand执行这个脚本时,GO会导致语法错误,必须按GO切分成多个Command依次执行,或者干脆去掉GO用;分隔。
4. 核心模块拆解:登录、档案、考勤、薪资四条主线
界面上的按钮很多,真正有逻辑的其实就四条链路:登录鉴权、员工档案的增删改查、考勤录入、按月算薪。剩下的导出 Excel、修改密码、日志查询都是外围。这一章按调用顺序把四条链路各拆一段核心代码,重点看 BLL 里做了什么、DAL 里做了什么,以及为什么这么分。
4.1 登录鉴权:哈希比对与角色分流
登录按钮事件里不该出现 SQL。UI 只做三件事:取文本框、调 BLL、根据返回值决定跳哪个窗体。
// Hrms.BLL/UserBLL.cs public UserInfo Login(string userName, string plainPwd) { if (string.IsNullOrWhiteSpace(userName)) throw new ArgumentException("用户名不能为空"); // 与建库脚本里的存法保持一致:32 位大写 MD5 string hash = Md5Helper.Hash(plainPwd).ToUpper(); DataTable dt = userDal.GetByUserName(userName); // 内部已参数化 if (dt.Rows.Count == 0) return null; // 用户不存在 DataRow row = dt.Rows[0]; if ((bool)row["IsEnabled"] == false) // 被停用的账号 throw new Exception("账号已停用,请联系管理员"); if (row["Password"].ToString() != hash) return null; // 密码错误 return new UserInfo { UserId = (int)row["UserID"], UserName = row["UserName"].ToString(), RoleId = (int)row["RoleID"], EmpId = row["EmpID"] == DBNull.Value ? 0 : (int)row["EmpID"] }; }这段代码有三个细节值得说。第一,用户不存在和密码错误都返回null,不告诉调用方具体是哪一种,避免被人拿登录框枚举账号。第二,IsEnabled判断放在密码校验之前,被停用的账号直接抛异常而不是返回null,这样界面能给出不同的提示文案。第三,EmpID允许为NULL——超级管理员账号可能不绑定具体员工,读的时候必须先判DBNull,否则(int)row["EmpID"]会直接抛InvalidCastException,这是DataTable取值最经典的翻车点。
登录成功后把UserInfo存到一个静态类Session里,主窗体根据RoleId决定哪些菜单可见。注意这只是界面层的遮罩,真正的权限校验还得在 BLL 里做一遍,否则改一下配置文件就能绕过去。
4.2 员工档案:分页查询与 DataGridView 绑定
员工列表是数据量最大的一个界面,不能一次SELECT *全拉回来。分页用ROW_NUMBER(),兼容 SQL Server 2005 以后的版本,比OFFSET FETCH适用范围广。
-- @keyword 为空串时不过滤;@start/@end 是行号区间,不是页码 SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY e.EmpID DESC) AS rn, e.EmpID, e.EmpNo, e.Name, e.Gender, e.Phone, d.DeptName, p.PositionName, e.Status FROM Employee e LEFT JOIN Department d ON e.DeptID = d.DeptID LEFT JOIN Position p ON e.PositionID = p.PositionID WHERE (@keyword = '' OR e.Name LIKE '%' + @keyword + '%' OR e.EmpNo LIKE '%' + @keyword + '%') ) t WHERE t.rn BETWEEN @start AND @end;@keyword = ''这个写法让「不带关键词」和「带关键词」共用一条 SQL,省掉在 C# 里拼两套语句。代价是LIKE '%...%'用不上索引,十万级数据会全表扫。真要优化,得上全文索引或者改成前缀匹配LIKE @keyword + '%',课程设计阶段知道这个取舍在哪就行。
C# 侧只负责算行号区间和绑定:
int pageSize = 20, pageIndex = 1; int start = (pageIndex - 1) * pageSize + 1; int end = pageIndex * pageSize; var dt = SqlHelper.Query(sql, new SqlParameter("@keyword", txtKeyword.Text.Trim()), new SqlParameter("@start", start), new SqlParameter("@end", end)); dgvEmployee.AutoGenerateColumns = false; // 关掉自动生成,按设计器里排好的列显示 dgvEmployee.DataSource = dt;AutoGenerateColumns = false这行很关键。开着的话,DataGridView会把 SQL 返回的每一列都摊出来,包括那个只用于分页的rn,而且列顺序跟你设计器里调好的完全不一样。关掉之后,在列编辑器里给每列设DataPropertyName,值必须和 SQL 里的别名逐字一致,DeptName写成deptname就会显示空白。
4.3 考勤与算薪:一个必须用事务的批量操作
算薪是整个项目里唯一真正需要事务的地方。流程是:先删掉该月已生成的草稿,再按在职员工逐个算底薪、加补贴、扣考勤、扣社保、算个税,最后批量插入。中间任何一步失败,都必须整体回滚,否则会出现「一半人有工资、一半人没有」的半成品数据。
public bool GenerateSalary(int year, int month, out string msg) { string monthStr = $"{year}-{month:D2}"; using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 清掉该月旧数据,保证重复生成不叠加 new SqlCommand("DELETE FROM Salary WHERE SalaryMonth=@m", conn, tran) { Parameters = { new SqlParameter("@m", monthStr) } } .ExecuteNonQuery(); // 2. 拉在职员工底薪 var dtEmp = SqlHelper.Query( "SELECT EmpID, BaseSalary FROM Employee WHERE Status=1"); // 3. 逐人计算 foreach (DataRow r in dtEmp.Rows) { int empId = (int)r["EmpID"]; decimal baseSalary = (decimal)r["BaseSalary"]; // 考勤扣款:迟到每次 50,事假按日薪扣 decimal deduct = CalcAttendanceDeduct(empId, year, month, baseSalary); decimal insurance = Math.Round(baseSalary * 0.105m, 2); // 个人社保比例 decimal taxable = baseSalary - insurance - 5000m; // 起征点 decimal tax = taxable <= 0 ? 0 : Math.Round(taxable * 0.03m, 2); decimal realPay = baseSalary - deduct - insurance - tax; SqlHelper.Execute( "INSERT INTO Salary(EmpID,SalaryMonth,Base,Deduct,Insurance,Tax,RealPay) " + "VALUES(@e,@m,@b,@d,@i,@t,@r)", new SqlParameter("@e", empId), new SqlParameter("@m", monthStr), new SqlParameter("@b", baseSalary), new SqlParameter("@d", deduct), new SqlParameter("@i", insurance), new SqlParameter("@t", tax), new SqlParameter("@r", realPay)); } tran.Commit(); msg = $"已生成 {dtEmp.Rows.Count} 条 {monthStr} 薪资记录"; return true; } catch (Exception ex) { tran.Rollback(); msg = "生成失败:" + ex.Message; return false; } } } }这里有个明显的瑕疵需要承认:SqlHelper.Execute内部用的是自己的连接,没有挂到当前事务上,所以严格来说tran.Rollback()回滚不掉那些已经插入的Salary行。正确做法是让SqlHelper提供一个接受SqlConnection和SqlTransaction的重载,把连接一路传下去。很多人第一次写事务都会栽在这里——以为自己回了滚,实际没回,等到数据对不上才发现。改造的时候优先补这个重载,是本项目性价比最高的一处优化。
计算规则里,社保固定按 10.5% 估,个税用最简单的 3% 一档,都写在方法里当常量。真要上线,这两块得抽成配置表,否则每年政策一调就得改代码重新发版。
5. 常见问题排查:课程设计里最容易翻车的五处
下面这五条基本覆盖了这类项目从打开到交付会遇到的绝大多数问题。每一条都按「现象、原因、解决」写,遇到对不上的先按现象检索,别急着改代码。
5.1 连接与环境类问题
现象一:一启动就报「在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误」。
原因通常是三选一。连接字符串里的Data Source写的是.,但实际装的是 Express 版,实例名是.\SQLEXPRESS;SQL Server 配置管理器里 TCP/IP 协议没启用;或者服务压根没启动。
解决顺序:先在 SSMS 里用同样的实例名连一次,SSMS 连得上说明实例名没问题;连不上就去「SQL Server 配置管理器 → SQL Server 网络配置」把 TCP/IP 改成已启用,然后重启 SQL Server 服务。如果 SSMS 用的是 Windows 身份验证,而程序里写的是sa账号,还要确认 SQL Server 已经切到「混合模式」验证——这一步需要重启服务才生效。
现象二:程序在开发机上跑得好好的,拷到同学电脑上打不开,双击没反应或者提示缺少 DLL。
原因是目标框架版本不匹配。项目如果用的是 .NET Framework 4.8,而对方机器只装了 4.5,就会直接崩。另一种情况是引用了第三方库(比如导出 Excel 用的NPOI),但只拷了exe没拷dll。
解决:右键项目 →「属性」→「目标框架」,降到 4.5 或 4.6 能覆盖绝大多数 Windows 机器;打包时把bin\Debug整个目录一起拷走,别只挑exe。如果是发布到没有 .NET 环境的机器,用「发布」功能生成带安装包的形式更稳妥。
5.2 数据与业务逻辑类问题
现象三:员工列表里部门和职位两列是空白的,但数据库里明明有值。
原因九成是DataGridView的列属性设置问题。AutoGenerateColumns没关的时候,SQL 返回什么列就显示什么列,一旦关掉,每列的DataPropertyName必须和 SQL 里的别名逐字一致。SELECT d.DeptName AS dept而列属性写的是DeptName,就是空白。
还有一种情况是DeptID存了NULL,LEFT JOIN之后关联不到部门,返回DBNull,绑定时被渲染成空字符串。解决:先检查DataPropertyName拼写,再到数据库里SELECT * FROM Employee WHERE DeptID IS NULL确认有没有脏数据。
现象四:薪资合计跟手工算的对不上,差几分钱。
原因是decimal的舍入时机。base * 0.105得到的是一个精度很高的decimal,如果在中间步骤反复四舍五入再相加,误差会累积;如果全程不四舍五入,最后显示两位小数时又跟预期不符。
解决:统一策略——中间计算保留原始精度,只在最终写库前Math.Round(x, 2, MidpointRounding.AwayFromZero)一次。数据库字段类型必须是DECIMAL(18,2),不能用FLOAT,浮点数存金额本身就是错的做法。
现象五:新增员工保存成功,但界面上看不到,要重启程序才出现。
原因是保存后没有刷新数据源。DataGridView.DataSource绑定的是一个DataTable快照,数据库里插了新行,这个DataTable不会自动同步。有些代码写的是dgv.DataSource = null; dgv.DataSource = dt;,但dt还是旧的引用,等于没刷新。
解决:保存成功后重新调一次查询方法,把返回的新DataTable赋给DataSource。如果嫌整体刷新闪烁,就定位到新插入行的索引调FirstDisplayedScrollingRowIndex滚动过去,体验会好很多。
5.3 界面与运行时类问题
现象六:登录时密码明明输对了,就是提示错误。
原因是 MD5 大小写不一致。BitConverter.ToString()输出的是大写带横杠(21-23-2F...),很多人Replace("-", "")之后忘了ToUpper()或ToLower(),而建库脚本里存的是大写串,比对自然失败。
解决:把哈希逻辑统一收口到Md5Helper.Hash()一个方法里,在方法内部固定输出大写 32 位,建库脚本、注册、登录三处都调它。这类问题一旦定下规则就不会再犯,最怕的是每处各写一遍。
注意:MD5 只适合课程设计这种教学场景,真实系统里存密码必须加盐并用 bcrypt 之类的慢哈希,别把这里的写法带到生产环境。
6. 二次开发:把课程作业改成能用的系统
答辩完还能继续往下做的人不多,但只要动手,第一件事一定是加字段。拿「合同到期日」举例,完整链路是四步:建表脚本加ContractEnd DATE NULL,Employee.cs加public DateTime? ContractEnd { get; set; },EmployeeDAL的 insert/update 语句补一个@end参数,FrmEmployee加一个DateTimePicker并在保存时把值传下去。四步少一步,要么编译不过,要么存进去是NULL。
| 改造方向 | 涉及文件 | 大致工作量 |
|---|---|---|
| 加业务字段 | 建表脚本 + Model + DAL + 窗体 | 半小时 |
| 薪资规则配置化 | 新建SalaryRule表 + BLL 改造 | 半天 |
| 导出 Excel | Common/ExcelExporter.cs+ 各列表页按钮 | 两小时 |
| 操作日志补全 | SysLog+ 各 BLL 方法入口 | 一天 |
| 换 WPF 界面 | 新增 UI 项目,DAL/BLL 原样复用 | 三天起 |
加完字段后别急着点按钮,先跑一遍验证:数据库里SELECT * FROM Employee看新列有没有值、FrmEmployee编辑已有记录时控件有没有回填、导出 Excel 时新列在不在。三处都对,才算改完。
分层结构在这里体现出价值了。换 WPF 界面听起来吓人,实际上 DAL 和 BLL 一行都不用改,因为DataTable是跟 UI 技术无关的,SqlHelper也不认识任何窗体类型。真正要重写的只有窗体文件和事件绑定。
有一条血泪经验值得单独说:改任何涉及金额或删除的逻辑之前,先备份数据库。我在一次演示前手滑把DELETE FROM Salary WHERE SalaryMonth=@m的参数传成了"2024-6",而库里存的是"2024-06",结果删除没生效,重复插了一遍,整个月的工资翻倍。从那以后我每次动这类语句,都强制先在测试库上跑一遍SELECT COUNT(*)确认影响范围,再执行。这个习惯帮我省了不止一次后悔药,希望帮到你。
本文还有配套的精品资源,点击获取