
简介这是一份基于C#语言与WinForms技术实现的医院挂号管理系统采用C/S架构和MVC分层设计适合正在学习桌面应用开发或需要完成课程设计、毕业设计的读者参考。系统覆盖用户管理、科室管理、医生管理、门急诊挂号、挂号查询、修改口令、打印挂号单及帮助文档等模块借助存储过程封装数据库操作并使用MD5加密保护用户密码同时以报表形式直观展示统计信息还支持医生照片上传与详情查看。压缩包内含185个文件10.03MB其中包含64个C#源码文件、19个资源及resx配置、多个SQL脚本与报表文件另有CHM帮助文档和可执行的exe程序方便直接运行与二次开发。已有1263人学习下载项目结构清晰适合初学者对照源码理解业务分层和数据库访问逻辑也能为相关系统的快速搭建提供参考。1. C# WinForms 医院挂号管理系统窗口场景里最该被复现的那套流程在一堆管理系统选题里医院挂号管理系统是 C# WinForms 课程设计和内部系统里出现频率最高的一类但它真正难的不是画几个窗体而是“窗口 A 刚挂完号窗口 B 显示的还是旧余号”这种一致性问题。这套资源按三层架构把科室、医生、排班、挂号、退号串成了一条完整流程覆盖按日期查余号、生成挂号单、退号回补、当日统计和简单权限控制。适合正在做相关课程设计的人也适合想快速搭一套内部挂号后台的开发者。下文所有 SQL 和 C# 代码都按可直接复现的方式给到照着顺序跑就能搭出一个能用的版本。2. 先把地基打对三层架构与五张核心表表结构这样设计才不返工2.1 三层架构的选型理由与项目目录很多人在做这类系统时习惯把所有 SQL 直接写在 Form 的按钮事件里。窗体少的时候没问题一旦开始加退号、加统计、加权限代码就全缠在一起改一个查询要翻三个窗体。我一般会按三层拆UI 层只放 WinForms 窗体BLL 层负责挂号、退号、统计这类业务规则DAL 层只做数据访问。这样窗口换样式、数据库换位置都不会动业务逻辑。模拟项目X里的目录结构是按这样分的HospitalRegSystem/ ├── HospitalReg.UI/ # WinForms 窗体 │ ├── FrmLogin.cs │ ├── FrmMain.cs │ ├── FrmRegister.cs │ └── FrmStatistics.cs ├── HospitalReg.BLL/ # 业务逻辑 │ ├── RegistrationService.cs │ ├── DoctorService.cs │ └── UserService.cs ├── HospitalReg.DAL/ # 数据访问 │ ├── BaseDAL.cs │ └── SqlHelper.cs ├── HospitalReg.Models/ # 实体类 │ ├── Doctor.cs │ ├── Schedule.cs │ └── Registration.cs └── HospitalReg.Common/ # 通用工具 ├── Session.cs # 当前登录用户 └── SecurityHelper.cs # 密码哈希这段结构里需要注意的点Models 里不要写数据库字段相关的字符串常量否则 BLL 和 DAL 都会依赖它Common 里的 Session 是静态类整个进程内全局可读适合单机窗口模式但如果系统以后要改成 Web 或分布式就要换成注入方式。2.2 五张表的设计与关键字段挂号系统的核心数据其实就五张表科室、医生、排班、挂号记录、系统用户。很多初学者会漏掉排班表直接把号源数量写在医生表里结果是所有日期共用一份余号完全没法做“按日期挂号”。核心表设计如下表名关键字段作用DepartmentId, DeptName, Sort科室列表挂号第一步先选科室DoctorId, DeptId, DoctorName, Title, IsEnabled医生关联科室DoctorScheduleId, DoctorId, WorkDate, TotalSlots, UsedSlots某医生某天的号源总量与已用量RegistrationId, ScheduleId, PatientName, PatientPhone, Status, CreateTime挂号记录Status 存 0/1/2SysUserId, UserName, PwdHash, Role登录账号Role 区分管理员与窗口操作员排班表是整个系统的节拍器挂号时只改这张表里的 UsedSlots。Registration 里不直接存号源数字而是通过 ScheduleId 关联。建表 SQL 可以直接用主键统一用自增 int日期字段用 datetime2 而不是 varchar这样统计时不会被字符串格式坑到CREATE TABLE Department ( Id INT IDENTITY(1,1) PRIMARY KEY, DeptName NVARCHAR(50) NOT NULL, Sort INT NOT NULL DEFAULT 0 ); CREATE TABLE Doctor ( Id INT IDENTITY(1,1) PRIMARY KEY, DeptId INT NOT NULL REFERENCES Department(Id), DoctorName NVARCHAR(20) NOT NULL, Title NVARCHAR(20) DEFAULT 主治医师, IsEnabled BIT NOT NULL DEFAULT 1 ); CREATE TABLE DoctorSchedule ( Id INT IDENTITY(1,1) PRIMARY KEY, DoctorId INT NOT NULL REFERENCES Doctor(Id), WorkDate DATE NOT NULL, TotalSlots INT NOT NULL DEFAULT 30, UsedSlots INT NOT NULL DEFAULT 0, UNIQUE (DoctorId, WorkDate) ); CREATE TABLE Registration ( Id INT IDENTITY(1,1) PRIMARY KEY, ScheduleId INT NOT NULL REFERENCES DoctorSchedule(Id), PatientName NVARCHAR(20) NOT NULL, PatientPhone NVARCHAR(20) NULL, Status INT NOT NULL DEFAULT 0, OperatorId INT NULL, CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME() );这里有个容易被忽略的设计Registration 表里没有冗余存医生和科室名。统计时需要连表查但换来的是改医生科室不用回刷历史数据。如果担心查询性能可以在 DoctorSchedule 上建 WorkDate 的索引而不是把科室名直接塞进挂号记录。2.3 连接串与 DAL 基础封装连接串我建议直接用 SQL Server 身份验证而不是 Windows 身份验证因为部署到别的机器时 Windows 账号不一定存在。下面这条连接串适合开发机直连默认实例string connStr Server.;DatabaseHospitalRegDB;User Idsa;Password你的密码;TrustServerCertificateTrue;Connect Timeout15;;参数说明TrustServerCertificateTrue是给本机开发用的连远程库且没有正规证书时不加这个会直接报 SSL 错误Connect Timeout15是连接超时秒数窗口环境下写上比默认 15 秒更短的值比如 10省得高峰期卡在连线阶段。DAL 层不用上 EF 这类重量级框架用原生 SqlConnection 加几个静态方法就够了。BaseDAL 里最常用的两个方法这样封装public static int ExecuteNonQuery(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(connStr)) using (var cmd new SqlCommand(sql, conn)) { if (ps ! null) cmd.Parameters.AddRange(ps); conn.Open(); return cmd.ExecuteNonQuery(); } } public static DataTable ExecuteTable(string sql, params SqlParameter[] ps) { using (var conn new SqlConnection(connStr)) using (var da new SqlDataAdapter(sql, conn)) { if (ps ! null) da.SelectCommand.Parameters.AddRange(ps); var dt new DataTable(); da.Fill(dt); return dt; } }using会把连接和命令一起释放省去手写 finally 的麻烦。所有外部输入患者姓名、手机号、查询条件必须走 SqlParameter不能拼字符串。参数化不仅能防注入还能让 SQL Server 缓存执行计划频繁挂号的场景下性能差别很明显。3. 挂号主流程落地科室联动、号源扣减与退号回滚的 WinForms 实现3.1 界面结构与科室-医生联动主窗体布局就三块左侧一个 ListBox 放科室右侧一个 DataGridView 放医生下面一个 DataGridView 放选中医生的出诊排班。逻辑很直接——选科室→过滤医生→选医生→加载该医生未来 7 天的排班。这三步是三个独立的加载方法不要写在一个事件里互相嵌套。加载科室和医生的核心代码private void LoadDepartments() { DataTable dt BaseDAL.ExecuteTable( SELECT Id, DeptName FROM Department ORDER BY Sort); listBoxDept.DataSource dt; listBoxDept.DisplayMember DeptName; listBoxDept.ValueMember Id; } private void LoadDoctors(int deptId) { DataTable dt BaseDAL.ExecuteTable( SELECT Id, DoctorName, Title FROM Doctor WHERE DeptId deptId AND IsEnabled 1, new SqlParameter(deptId, deptId)); dataGridViewDoctor.DataSource dt; }这里要避一个新手坑DataSource直接绑定 DataTable 后再次查询时不能直接赋新的 DataTable否则界面会花。稳妥做法是给 DataGridView 的DataSource先设null再去赋值。另外ValueMember绑定的是内存表里的列名必须和 SQL 里的别名一致用 Select 出来的Id而不是数据库原始列名否则 SelectedValue 一直返回 null。3.2 号源展示与挂号落库一条 UPDATE 解决超卖选中医生后加载排班数据的 SQL 长这样SELECT s.Id, s.WorkDate, s.TotalSlots - s.UsedSlots AS RemainSlots, s.UsedSlots, s.TotalSlots FROM DoctorSchedule s WHERE s.DoctorId doctorId AND s.WorkDate CAST(GETDATE() AS DATE) ORDER BY s.WorkDate;挂号窗体的设计比较关键弹出的 FrmRegister 窗体里只显示号源信息医生、日期、余号真正执行挂号时调用 BLL 层的一个方法。这个方法必须放在事务里先锁住排班行做条件扣减再插入挂号记录public static string Register(int scheduleId, string patientName, string patientPhone, int operatorId) { using (var conn new SqlConnection(connStr)) { conn.Open(); var tx conn.BeginTransaction(); try { // 1. 条件扣减号源只有 UsedSlots TotalSlots 才会执行成功 string updateSql UPDATE DoctorSchedule SET UsedSlots UsedSlots 1 WHERE Id scheduleId AND UsedSlots TotalSlots; using (var cmd new SqlCommand(updateSql, conn, tx)) { cmd.Parameters.AddWithValue(scheduleId, scheduleId); int affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); return 该时段号源已满请选择其他时段; } } // 2. 插入挂号记录 string insertSql INSERT INTO Registration(ScheduleId, PatientName, PatientPhone, OperatorId) VALUES(scheduleId, name, phone, operatorId); using (var cmd new SqlCommand(insertSql, conn, tx)) { cmd.Parameters.AddWithValue(scheduleId, scheduleId); cmd.Parameters.AddWithValue(name, patientName); cmd.Parameters.AddWithValue(phone, patientPhone); cmd.Parameters.AddWithValue(operatorId, operatorId); cmd.ExecuteNonQuery(); } tx.Commit(); return OK; } catch (SqlException ex) { tx.Rollback(); return 挂号失败 ex.Message; } } }这套写法和“先 SELECT 余号再 INSERT”最大的区别在于扣号这个动作是原子的两个窗口同时点挂号时只有一个 UPDATE 能在UsedSlots TotalSlots条件下成功另一个受影响行数为 0 就直接回滚。很多人在这类系统里把超卖问题归咎于 SQL Server 并发配置其实根子在业务代码没用条件更新。事务隔离级别这里用默认的即可不需要调成 Serializable因为单条 UPDATE 本身就带行级锁。3.3 退号回补与改号事务顺序决定了数据对不对退号的 SQL 是挂号的逆操作但有一个容易写反的顺序问题。正确做法是先把挂号记录状态改成已退号再回补排班余号。如果你把回补放在前面万一更新 Registration 时失败余号已经加了回去患者实际还挂着一个有效号数据就对不上了public static string CancelRegistration(int regId, int scheduleId) { using (var conn new SqlConnection(connStr)) { conn.Open(); var tx conn.BeginTransaction(); try { // 1. 先把挂号记录置为已退号 string sql1 UPDATE Registration SET Status 2 WHERE Id regId AND Status 0; // 2. 再回补余号判断 UsedSlots 0 防止负数 string sql2 UPDATE DoctorSchedule SET UsedSlots UsedSlots - 1 WHERE Id scheduleId AND UsedSlots 0; // 执行两个命令后 Commit tx.Commit(); return OK; } catch (SqlException ex) { tx.Rollback(); return 退号失败 ex.Message; } } }Status 0这个条件很重要。如果挂号记录已经被医生看过Status 1就不允许退如果你不管状态直接改患者凭一张旧单子可以反复退号。我在实际项目里都会在退号按钮的入口再加一道判断只有状态为 0 的记录才弹确认框从 UI 层就拦住一多半误操作。4. 容易翻车的五个地方并发超卖、刷新异常与日期格式排查记录中间这个章节不聊理论直接给排错记录。以下五个坑都是这套系统里实际踩过的按“现象—原因—解决”写清楚。4.1 并发与数据刷新类余号超卖和 DataGridView 未提交异常坑一余号显示“还有 1 个”两个窗口同时挂号却都成功了。现象高峰期窗口 A 和窗口 B 同时给同一个医生挂号页面都显示余号 1点完挂号后两个患者都拿到了号排班表的 UsedSlots 只加了 1因为第二次 UPDATE 条件不成立回滚了但两个患者都收到了成功提示。原因问题出在“先查余号再插入”的经典写法上。先 SELECT 余号再执行 INSERT这个间隙里另一个请求也读到了同样的余号双方都认为有号可挂两个 INSERT 都成功了。解决使用上一章的事务条件扣减方案UPDATE ... WHERE UsedSlots TotalSlots以受影响行数判断是否成功。排查时先看 Registration 表里同一 ScheduleId 是否有多条记录再看 OperatorId 是否是同一个窗口区分是逻辑问题还是操作员重复点击。坑二DataGridView 绑定新数据时抛 ArgumentOutOfRangeException。现象挂完号后调用 LoadSchedule() 刷新排班表不定时抛“索引超出范围”异常偶尔界面卡住。原因原行处于编辑状态直接改 DataSource 会导致绑定的位置索引和内部数据结构错位。有些机器上表现为偶发特别容易在快速点击后触发。解决刷新前先提交正在编辑的单元格再把 DataSource 置空private void RefreshGrid(DataGridView grid, DataTable dt) { grid.EndEdit(); // 提交未完成的编辑 grid.DataSource null; // 断开绑定 grid.DataSource dt; // 重新绑定 }这条经验对所有 WinForms 列表页通用。凡是“点按钮刷新表格”的操作都强制走这三行别嫌麻烦。4.2 日期、传值与连接类查询少数据、用户切换和 sa 登录失败坑三按日期筛选挂号记录某几天的数据查不到。现象统计 7 月 5 日的挂号量结果比实际少十几条换成“近 7 天”查询又正常。原因Registration.CreateTime 字段被存储成了 varchar写入时用了yyyy-MM-dd HH:mm:ss查询时用了yyyy-MM-dd这种短格式SQL Server 自动转换时把时分秒按 00:00:00 处理边界数据全被过滤掉。这类问题在开发时不容易暴露因为本机数据量小日期格式恰好一致。解决把 CreateTime 字段改成 datetime2查询一律用 DateTime 参数传入。如果历史库已经存了字符串先UPDATE Registration SET CreateTime CONVERT(datetime2, CreateTime)刷一遍格式再把列类型改掉。从那以后我建表凡是时间字段一律 datetime2不再用 varchar 存时间。坑四退出登录后重新登录主窗体显示的还是上一个账号的菜单和数据。现象管理员退出换窗口操作员登录菜单栏的“统计报表”还在双击还能打开。原因登录成功只是隐藏了登录窗体FrmMain 实例没有重建菜单权限只初始化一次。用户变了界面状态没变。解决退出登录时直接关闭主窗体重新new FrmMain()并传入新用户。不要在同一个主窗体实例上做状态重置那只会越改越乱。Session 静态类的当前用户属性在登录时统一赋值public static class Session { public static int UserId { get; set; } public static string UserName { get; set; } public static string Role { get; set; } }坑五开发机连不上本地数据库报“用户 sa 登录失败”。现象刚装的 SQL Server代码里连接串写的是User Idsa一运行就报错但 SSMS 里用 Windows 身份验证能登上。原因SQL Server 默认不开 sa 账号安装时选的是 Windows 身份验证模式。解决用 Windows 身份验证登录 SSMS执行ALTER LOGIN sa WITH PASSWORD 新密码再把服务器属性里的身份验证模式改为“混合模式”重启 SQL 服务。如果还是失败检查 SQL Server 配置管理器里的 TCP/IP 协议是否已启用。5. 权限与日结统计登录角色控制菜单、挂号量与退号率报表5.1 登录验证与角色菜单显隐登录窗体用参数化查询比对账号密码。密码在库里不能存明文常见做法是 SHA256 加盐后存哈希public static string HashPassword(string password, string salt) { using (var sha System.Security.Cryptography.SHA256.Create()) { var bytes System.Text.Encoding.UTF8.GetBytes(salt password); byte[] hash sha.ComputeHash(bytes); return Convert.ToHexString(hash); } }登录 SQL 这样写string sql SELECT Id, UserName, Role FROM SysUser WHERE UserName name AND PwdHash hash; DataTable dt BaseDAL.ExecuteTable(sql, new SqlParameter(name, userName), new SqlParameter(hash, HashPassword(password, 固定盐值)));查不到用户就提示账号或密码错误不要区分“用户不存在”和“密码错误”避免被探测。登录成功后把用户信息塞进 Session。菜单权限按下发策略管理员显示全部菜单窗口操作员只显示“挂号管理”和“当日查询”医生角色只能查看自己的排班。实现时用一个方法遍历菜单项控制 Visibleprivate void ApplyMenuPermission() { menuStatistics.Visible Session.Role Admin; menuDoctorSchedule.Visible Session.Role Doctor; menuLogout.Visible true; }菜单权限是“显示层面”的控制如果系统会暴露接口给其他端必须在 BLL 层再校验一次角色。WinForms 单机场景压力不大但养成习惯没坏处。5.2 当日挂号量统计与退号率日结统计要回答三个问题今天挂了多少号、每个科室多少、退号率多高。一条 SQL 就能全出SELECT d.DeptName, COUNT(r.Id) AS TotalCount, SUM(CASE WHEN r.Status 2 THEN 1 ELSE 0 END) AS CancelCount, CAST(SUM(CASE WHEN r.Status 2 THEN 1.0 ELSE 0 END) / COUNT(r.Id) * 100 AS DECIMAL(5,2)) AS CancelRate FROM Registration r JOIN DoctorSchedule s ON r.ScheduleId s.Id JOIN Doctor doc ON s.DoctorId doc.Id JOIN Department d ON doc.DeptId d.Id WHERE r.CreateTime start AND r.CreateTime end GROUP BY d.DeptName ORDER BY TotalCount DESC;几个参数细节start传当天零点end传第二天零点用加包住一天避免漏掉 23:59:59 的尾单。CASE WHEN Status 2 THEN 1.0写成 1.0 是为了让除法的结果变成小数DB 默认整数相除会直接截尾成整数百分比。COUNT(r.Id) 不会统计 NULL如果要用 LEFT JOIN 补零科室记得把 COUNT(r.Id) 换成 COUNT(r.Id) 并配合 ISNULL。统计结果直接绑到 DataGridView 会连带显示小数位最好再设置列格式grid.Columns[CancelRate].DefaultCellStyle.Format 0.00。窗口操作员看的日报格式做到位比样式好看更重要因为数据要复核。5.3 报表导出 CSV不引入第三方库很多环境不能装 Excel COM 组件导出 CSV 是代价最小的方案。生成 CSV 时有一个坑Excel 打开 UTF-8 编码的 CSV 会乱码必须在文件头写 BOMpublic static void ExportDataTableToCsv(DataTable dt, string filePath) { using (var sw new System.IO.StreamWriter(filePath, false, new System.Text.UTF8Encoding(true))) { // 写表头 sw.WriteLine(string.Join(,, dt.Columns.CastDataColumn().Select(c c.ColumnName))); // 写行数据字段含逗号时加双引号 foreach (DataRow row in dt.Rows) { var fields row.ItemArray.Select(f f.ToString().Contains(,) ? \ f.ToString() \ : f.ToString()); sw.WriteLine(string.Join(,, fields)); } } }new UTF8Encoding(true)的true就是写入 BOM。字段里如果本身包含双引号还需要替换成双引号再加引号包裹这里为了篇幅只处理了逗号场景。导出后建议用“记事本打开看编码”和“Excel 打开看列数”两步验证防止数字长串被 Excel 当科学计数法。6. 交付前最后一件事并发压测脚本与 DataGridView 卡顿优化这套系统交付前我最后做的不是功能测试而是一个压测脚本专门验证余号一致性。做法是从 UI 里抽出一个纯逻辑入口模拟 10 个窗口同时抢最后 10 个号int successCount 0; var tasks Enumerable.Range(1, 12).Select(i Task.Run(() { var result RegistrationService.Register( scheduleId: 5, patientName: 压测 i, patientPhone: , operatorId: 1); if (result OK) Interlocked.Increment(ref successCount); })); Task.WaitAll(tasks.ToArray()); Console.WriteLine($实际成功 {successCount}号源 10应成功 10);如果脚本跑出 12 个全成功说明事务条件扣减没生效回去查 UPDATE 的 WHERE 条件如果成功数小于 10查是不是有死锁回滚。这个脚本的重点是“用并发去验证逻辑而不是用眼睛看界面”。WinForms 的按钮事件在 UI 线程上跑手动点几百次也触发不了真正的竞争条件必须用 Task 把请求打进去。第二个优化是 DataGridView 卡顿。排班表数据量不大但挂号历史记录几十万条时直接绑定 DataTable 会明显拖拽。先开双缓冲一行代码解决大部分刷新闪烁typeof(DataGridView).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(dataGridViewHistory, true);数据量继续变大就去分页SQL 里用ORDER BY Id OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY不要一次性 Load 全部。尤其是“当前窗口挂号记录”这类页面Date 倒序取前 100 条足够用用户真的要查历史再按条件搜索。还有一个值得提的显示类问题WinForms 在 150% 缩放的屏幕上会糊正式部署前在 app.manifest 里加dpiAwaretrue/dpiAware并在 Main 方法开头调一次SetProcessDPIAware()。这个不作为主功能但窗口操作员的电脑大多是办公本加外接屏缩放比例各不一样装上能少接很多“字看不清”的投诉。整套系统我在模拟项目X上按“建表→封装 DAL→写挂号事务→做权限→压测”这个顺序走了一遍真正耗时间的部分不是窗体拖拽而是事务边界和数据格式。从那以后我每次交付这类管理系统都强制先写并发压测脚本再交代码至少能挡住一半以上运行期问题。希望帮到你。本文还有配套的精品资源点击获取