简介:这是一份基于C# WinForm开发的医院挂号管理系统源码,采用C/S架构与MVC分层设计,面向计算机专业学生、.NET初学者及需要课程设计或毕业设计参考的开发者,可用于学习桌面端信息管理系统的完整实现思路。系统功能覆盖用户管理、科室与医生管理、门急诊挂号、挂号查询、修改口令、挂号单打印及帮助文档,表示层、业务层与数据访问层分离,数据库操作借助存储过程完成,用户密码采用MD5加密,统计信息以图文报表形式呈现,并支持医生照片上传以便查看详细信息。压缩包共185个文件,约10.03MB,以64个cs源码文件为核心,辅以jpg图片、resx与resources资源、sql脚本、chm帮助文档及exe、dll等运行文件,结构较为完整。已有1267人学习下载,适合作为WinForm项目实战与分层架构入门的参考素材。
1. 从一次门诊排队的混乱说起:这套 C# WinForms 挂号系统到底解决什么问题
早上七点半,门诊大厅已经排起长队,挂号窗口的护士一边翻纸质登记本一边接电话,两个患者因为同一个号源吵了起来——这种场景在很多基层医疗机构并不陌生。这套用 C# WinForms 写的医院挂号管理系统,瞄准的就是这个痛点:把科室、医生、号源、患者、挂号记录、收费退号这几件事塞进一个桌面客户端里,让一台普通 Windows 电脑就能承担起门诊挂号台的日常工作。它适合谁?适合正在做 WinForms 课程设计的学生、需要给小型诊所或社区门诊搭一套轻量挂号工具的开发,以及想找一个完整 CRUD + 业务规则案例来练手桌面开发的人。整套系统不依赖浏览器、不依赖云服务,双击 exe 就能跑,数据落在本地数据库里,部署成本几乎为零。接下来我按「能干什么 → 怎么跑起来 → 核心代码怎么写的 → 怎么改」的顺序,把这份源码拆开讲清楚。
2. 技术栈与工程结构:WinForms + ADO.NET 的三层落地方式
2.1 为什么是 WinForms 而不是 Web
先把这个选型说透,因为很多人一看到「医院挂号」就默认要做 Web 系统。WinForms 的优势在于:挂号台是固定工位、固定人员、固定操作,不需要多端访问;窗体控件拖拽即可完成界面,开发周期短;对本地打印机、读卡器、扫码枪这类外设的调用比浏览器直接得多。代价是它只能跑在 Windows 上,且不适合做患者自助端。所以这套系统的定位很明确——院内挂号工作站,不是面向患者的互联网挂号平台。理解这一点,后面看代码就不会困惑为什么没有登录鉴权体系、没有分布式事务。
技术栈大致是这样几层:
| 层次 | 技术选型 | 作用 |
|---|---|---|
| 界面层 | WinForms(Form + DataGridView + ComboBox) | 挂号、退号、查询、医生排班等窗体 |
| 业务层 | C# 类库(BLL 命名空间下的 Service 类) | 号源校验、挂号状态流转、费用计算 |
| 数据访问层 | ADO.NET(SqlConnection / SqlCommand / SqlDataAdapter) | 参数化 SQL、事务提交 |
| 数据存储 | SQL Server(LocalDB 或 Express 均可) | 科室、医生、排班、号源、挂号记录表 |
| 配置 | App.config 连接字符串 | 换库只改一处 |
这里有个血泪经验:不少同学拿到这类源码,第一反应是把连接字符串硬编码在Program.cs里,换台机器就翻车。正规做法是放App.config,用ConfigurationManager.ConnectionStrings读取,后面第 5 章会讲怎么改。
2.2 目录结构长什么样
一份典型的工程目录大致如下(不同版本命名略有差异,但分层逻辑一致):
HospitalRegistration/ ├── HospitalRegistration.sln ├── HospitalRegistration/ │ ├── Program.cs // 入口,启动登录窗体 │ ├── App.config // 连接字符串 │ ├── Forms/ // 所有窗体 │ │ ├── FrmLogin.cs │ │ ├── FrmMain.cs // 主框架,菜单导航 │ │ ├── FrmRegister.cs // 挂号核心窗体 │ │ ├── FrmRefund.cs // 退号窗体 │ │ ├── FrmSchedule.cs // 医生排班维护 │ │ └── FrmQuery.cs // 挂号记录查询 │ ├── Models/ // 实体类 │ │ ├── Department.cs │ │ ├── Doctor.cs │ │ ├── Schedule.cs │ │ └── Registration.cs │ ├── DAL/ // 数据访问 │ │ ├── SqlHelper.cs // 公共执行封装 │ │ ├── DepartmentDal.cs │ │ ├── DoctorDal.cs │ │ └── RegistrationDal.cs │ └── BLL/ // 业务逻辑 │ ├── RegisterService.cs │ └── RefundService.cs └── Database/ └── init.sql // 建库建表脚本 + 初始数据这个结构的关键在于DAL 与 BLL 分离。窗体只负责收集输入、调用 Service、把结果绑到控件上;Service 负责业务规则(比如「同一医生同一时段剩余号源为 0 时不允许挂号」);DAL 只负责拼 SQL 和执行。新手最容易犯的错是把 SQL 直接写在按钮点击事件里,代码一长就没法维护,也没法复用。
2.3 数据库表设计要点
挂号系统的核心表不多,但字段设计直接决定后面好不好改。常见做法是四张主表加一张流水表:
-- 科室表 CREATE TABLE Department ( DeptId INT IDENTITY(1,1) PRIMARY KEY, DeptName NVARCHAR(50) NOT NULL, IsActive BIT NOT NULL DEFAULT 1 ); -- 医生表 CREATE TABLE Doctor ( DoctorId INT IDENTITY(1,1) PRIMARY KEY, DoctorName NVARCHAR(50) NOT NULL, DeptId INT NOT NULL, Title NVARCHAR(20), -- 职称:主任/副主任/主治 RegFee DECIMAL(10,2) NOT NULL -- 挂号费 ); -- 排班表:一个医生一天一个时段一条记录 CREATE TABLE Schedule ( ScheduleId INT IDENTITY(1,1) PRIMARY KEY, DoctorId INT NOT NULL, WorkDate DATE NOT NULL, TimeSlot NVARCHAR(10) NOT NULL, -- 上午/下午 TotalQuota INT NOT NULL, -- 总号源 UsedQuota INT NOT NULL DEFAULT 0 -- 已用号源 ); -- 挂号记录表 CREATE TABLE Registration ( RegId INT IDENTITY(1,1) PRIMARY KEY, PatientName NVARCHAR(50) NOT NULL, IdCard NVARCHAR(18), ScheduleId INT NOT NULL, RegTime DATETIME NOT NULL DEFAULT GETDATE(), Fee DECIMAL(10,2) NOT NULL, Status TINYINT NOT NULL DEFAULT 0 -- 0 正常 1 已退号 );Schedule表里的TotalQuota和UsedQuota是整套系统的命门。挂号本质上是「占用一个号源」,退号是「归还一个号源」,所有并发问题都围绕这两个字段。把号源独立成表、用UsedQuota计数,比在Registration表里COUNT(*)要快得多,也更容易加锁控制。
3. 从零跑起来:环境准备、建库与首次启动
3.1 环境清单与版本选择
跑这套系统需要三样东西:Visual Studio(2019 或 2022 社区版即可,安装时勾选「.NET 桌面开发」工作负载)、SQL Server(Express 或 LocalDB 都行)、以及 .NET Framework 4.7.2 以上的运行时。项目本身是 .NET Framework 工程,不是 .NET Core/.NET 6+,所以别用dotnet run去跑,老老实实打开.sln用 VS 编译。
提示:如果机器上只装了 .NET 8 SDK 没装 .NET Framework 开发组件,打开项目会提示「目标框架不受支持」,回到 VS Installer 补装「.NET Framework 4.x 开发工具」即可。
3.2 建库与初始化数据
打开 SQL Server Management Studio,新建一个数据库(比如叫HospitalDB),然后执行Database/init.sql。脚本里除了建表,通常还会插入几条基础数据:内科、外科、儿科三个科室,每个科室两三个医生,以及未来一周的排班。这一步不能省,否则第一次打开挂号窗体,科室下拉框是空的,会让人误以为程序坏了。
执行完可以用一条查询验证:
SELECT d.DeptName, doc.DoctorName, s.WorkDate, s.TimeSlot, s.TotalQuota - s.UsedQuota AS RemainQuota FROM Schedule s JOIN Doctor doc ON s.DoctorId = doc.DoctorId JOIN Department d ON doc.DeptId = d.DeptId WHERE s.WorkDate >= CAST(GETDATE() AS DATE) ORDER BY s.WorkDate, d.DeptName;这条 SQL 返回的就是「未来还有多少号可挂」,也是挂号窗体加载时下拉框的数据来源。如果查出来是空集,先检查WorkDate是不是都插成了过去日期——这是初始化脚本最常见的坑。
3.3 改连接字符串并首次运行
打开App.config,把连接字符串改成自己机器的实例名:
<configuration> <connectionStrings> <add name="HospitalConn" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=HospitalDB;Integrated Security=True" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>Data Source填实例名,.\SQLEXPRESS表示本机默认 SQLEXPRESS 实例;如果用 LocalDB 则写成(localdb)\MSSQLLocalDB;Integrated Security=True表示用 Windows 身份验证,省去账号密码。改完按 F5,应该先弹出登录窗体,用脚本里预置的管理员账号登录后进入主界面。如果启动就报「未将对象引用设置到对象的实例」,九成是连接字符串名字和代码里ConfigurationManager.ConnectionStrings["HospitalConn"]的 key 对不上,去SqlHelper里核对一下。
3.4 一次完整挂号的操作路径
跑通之后,按这个顺序走一遍,能验证大部分功能是否正常:主界面点「挂号」→ 选科室 → 选医生 → 选日期和时段(下拉框只显示有剩余号源的排班)→ 填患者姓名和身份证 → 点「确认挂号」→ 弹出费用并写入记录 → 回到查询页能看到这条记录,同时该时段剩余号源减一。再走一遍退号:查询到记录 → 点「退号」→ 状态变为已退号、号源加回。这两条链路走通,说明 DAL、BLL、UI 三层是通的,后面改代码才有底。
4. 核心代码拆解:号源校验、事务挂号与退号回滚
4.1 SqlHelper:把重复的 ADO.NET 样板收口
每个 DAL 类都要开连接、建 Command、加参数、关连接,如果每处都手写,代码会膨胀得没法看。所以工程里一般有一个SqlHelper,把「执行增删改返回影响行数」「执行查询返回 DataTable」这两类操作封装掉:
public static class SqlHelper { private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["HospitalConn"].ConnectionString; // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] paras) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (paras != null && paras.Length > 0) cmd.Parameters.AddRange(paras); conn.Open(); return cmd.ExecuteNonQuery(); } } // 执行查询,返回 DataTable,方便直接绑 DataGridView public static DataTable ExecuteDataTable(string sql, params SqlParameter[] paras) { using (var conn = new SqlConnection(ConnStr)) using (var adapter = new SqlDataAdapter(sql, conn)) { if (paras != null && paras.Length > 0) adapter.SelectCommand.Parameters.AddRange(paras); var dt = new DataTable(); adapter.Fill(dt); return dt; } } }using块保证连接和命令对象即使抛异常也会释放,这是 ADO.NET 的基本功。params SqlParameter[]让调用方可以传任意个参数,配合参数化 SQL 天然防注入。注意这里没有做连接池之外的额外缓存,因为挂号系统的并发量很低,没必要引入复杂度。
4.2 挂号业务:先校验号源,再事务写入
挂号最容易出问题的地方是「两个人同时抢最后一个号」。正确顺序是:在同一个事务里先UPDATE Schedule SET UsedQuota = UsedQuota + 1 WHERE ScheduleId = @id AND UsedQuota < TotalQuota,看影响行数是否为 1;为 1 才插入挂号记录,否则回滚并提示「号源已满」。这样把「判断 + 扣减」合成一条原子 SQL,避免先查后改的竞态:
public class RegisterService { // 返回 0 成功,-1 号源已满,-2 其他异常 public int Register(string patientName, string idCard, int scheduleId, decimal fee) { using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 原子扣减:只有还有余号时才会更新成功 string updateSql = @"UPDATE Schedule SET UsedQuota = UsedQuota + 1 WHERE ScheduleId = @sid AND UsedQuota < TotalQuota"; using (var cmd = new SqlCommand(updateSql, conn, tran)) { cmd.Parameters.AddWithValue("@sid", scheduleId); if (cmd.ExecuteNonQuery() == 0) { tran.Rollback(); return -1; // 号源已满 } } // 扣减成功后再写挂号流水 string insertSql = @"INSERT INTO Registration (PatientName, IdCard, ScheduleId, Fee, Status) VALUES (@name, @idcard, @sid, @fee, 0)"; using (var cmd = new SqlCommand(insertSql, conn, tran)) { cmd.Parameters.AddWithValue("@name", patientName); cmd.Parameters.AddWithValue("@idcard", idCard ?? ""); cmd.Parameters.AddWithValue("@sid", scheduleId); cmd.Parameters.AddWithValue("@fee", fee); cmd.ExecuteNonQuery(); } tran.Commit(); return 0; } catch { tran.Rollback(); return -2; } } } } }关键点有三个:WHERE UsedQuota < TotalQuota是防超卖的核心条件;BeginTransaction保证扣号和写流水要么都成功要么都不做;返回值用整数编码而不是抛异常,是为了让窗体层能直接switch出不同提示语。参数fee由窗体从医生表带过来,不在 SQL 里再查一次,减少一次往返。
4.3 退号业务:状态流转与号源归还
退号不是删记录,而是把Status改成 1 并把号源加回去,同样要放在事务里,且要防止重复退号:
public int Refund(int regId) { using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 只有状态为 0(正常)的记录才允许退 string sql = @"UPDATE Registration SET Status = 1 WHERE RegId = @rid AND Status = 0"; using (var cmd = new SqlCommand(sql, conn, tran)) { cmd.Parameters.AddWithValue("@rid", regId); if (cmd.ExecuteNonQuery() == 0) { tran.Rollback(); return -1; // 已退过或记录不存在 } } // 归还号源 string backSql = @"UPDATE Schedule SET UsedQuota = UsedQuota - 1 WHERE ScheduleId = (SELECT ScheduleId FROM Registration WHERE RegId = @rid)"; using (var cmd = new SqlCommand(backSql, conn, tran)) { cmd.Parameters.AddWithValue("@rid", regId); cmd.ExecuteNonQuery(); } tran.Commit(); return 0; } catch { tran.Rollback(); return -2; } } } }WHERE Status = 0这个条件就是防重复退号的锁。如果用户连点两次退号按钮,第二次影响行数为 0,直接返回 -1,号源不会被多减。窗体层拿到 -1 就提示「该记录已退号,请刷新列表」。
4.4 窗体层怎么调:以挂号窗体为例
窗体只做三件事:加载下拉数据、收集输入、调 Service 并根据返回值提示。以「确认挂号」按钮为例:
private void btnConfirm_Click(object sender, EventArgs e) { if (cboSchedule.SelectedValue == null) { MessageBox.Show("请先选择排班时段"); return; } int scheduleId = Convert.ToInt32(cboSchedule.SelectedValue); decimal fee = Convert.ToDecimal(lblFee.Text); var service = new RegisterService(); int result = service.Register(txtPatientName.Text.Trim(), txtIdCard.Text.Trim(), scheduleId, fee); switch (result) { case 0: MessageBox.Show("挂号成功"); LoadSchedules(); // 刷新号源下拉,剩余数会变 break; case -1: MessageBox.Show("该时段号源已满,请选择其他时段"); break; default: MessageBox.Show("系统繁忙,请稍后重试"); break; } }cboSchedule.SelectedValue取的是下拉框绑定的ScheduleId,前提是绑定时设置了ValueMember。LoadSchedules()重新查库刷新,保证界面上看到的剩余号源和数据库一致。这种「窗体薄、Service 厚」的写法,后面加新业务(比如预约、改约)时只需要在 Service 里加方法,窗体改动很小。
5. 避坑与排查:跑不起来、号源对不上、退号异常
5.1 启动报「连接字符串未初始化」
现象:程序一启动就抛NullReferenceException,堆栈指向SqlHelper的静态字段。原因通常是App.config里的name和代码里取的 key 不一致,或者配置文件根本没被复制到输出目录。解决:核对 key 拼写;在 VS 里右键App.config→ 属性 → 「复制到输出目录」设为「始终复制」;再不行就在SqlHelper里加一句if (ConnStr == null) throw new Exception("连接字符串未配置"),让报错更直白。
5.2 号源显示和实际能挂的对不上
现象:下拉框显示还剩 3 个号,点挂号却提示号源已满。原因多半是界面缓存了旧的DataTable,而数据库里的UsedQuota已经被别的窗口改过。解决:每次打开挂号窗体或点「刷新」都重新查库,不要复用上次的 DataTable;如果确实要做多工位并发,就在查询时加WITH (NOLOCK)之外还要接受短暂不一致,最终以UPDATE的影响行数为准。
5.3 退号后号源变成负数
现象:某时段UsedQuota出现 -1。原因是退号逻辑没有校验Status,或者手工在数据库里改过数据导致状态和号源不同步。解决:退号 SQL 必须带AND Status = 0;另外在Schedule表上加一个CHECK (UsedQuota >= 0 AND UsedQuota <= TotalQuota)约束兜底,让脏数据在写入时就被数据库拒绝。
5.4 DataGridView 绑定后修改不生效
现象:在表格里改了患者姓名,点保存没反应。原因是直接绑定了DataTable但没走Update,或者绑的是List<T>而实体没实现通知。解决:要么用SqlDataAdapter+SqlCommandBuilder走标准更新流程,要么在保存按钮里遍历DataGridView.Rows逐条拼UPDATE。前者省事,后者可控,小系统里我一般选后者,逻辑一目了然。
5.5 换机器后中文乱码
现象:患者姓名存进去变成问号。原因是数据库字段用了VARCHAR而不是NVARCHAR,或者连接字符串没指定字符集。解决:所有存中文的列一律NVARCHAR;连接字符串保持默认即可,SQL Server 的SqlClient对 Unicode 支持是默认开启的,问题基本都出在建表时偷懒用了VARCHAR。
6. 二次开发改造:从单机挂号台到可维护的小型门诊系统
拿到这套源码,多数人不会止步于「能跑」,而是想加点自己的东西。下面几条改造路径按投入产出比排序,都是我在类似项目里实际走过的。
第一条,把号源释放做成定时任务。现在的逻辑是「不退号就永远占着」,但真实门诊里患者爽约很常见。可以在Schedule表加一个ExpireTime字段,写一个System.Windows.Forms.Timer或后台线程,每隔十分钟把「已过就诊时段且状态仍为正常」的记录批量置为爽约、归还号源。改造时注意批量 SQL 要放在事务里,且先SELECT出待处理 ID 再逐条更新,避免长事务锁表。
第二条,把费用计算从窗体挪进 Service。现在fee是窗体从医生表带过来的,一旦要做「普通号/专家号差异化收费」「医保减免」就会散落在各处。正确做法是在RegisterService.Register内部根据scheduleId反查医生职称和科室,统一算费。这样以后加收费规则只改一个方法。
第三条,给查询页加分页。挂号记录一多,DataGridView一次性加载几千行会卡。常见做法是用ROW_NUMBER()分页:
SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY RegTime DESC) AS RowNum, * FROM Registration WHERE Status = 0 ) t WHERE t.RowNum BETWEEN @start AND @end;配合窗体上的「上一页/下一页」按钮,@start和@end由页码算出。这一步做完,系统才算能长期用。
第四条,也是最容易被忽略的:把连接字符串和业务参数外置。我见过太多同学把数据库密码、医院名称、打印模板路径写死在代码里,交付时改一处要重新编译。统一收进App.config的appSettings节点,用ConfigurationManager.AppSettings["xxx"]读取,改配置不用动代码。
验证改造是否成功,我的习惯是每次改完都走一遍「挂号 → 查询 → 退号 → 再挂号」的闭环,并故意在退号后立刻再点一次退号,确认不会重复归还号源。这个动作看起来笨,但能挡住大部分状态流转的 bug。从那以后我每次动这类带号源计数的系统,都强制自己先跑一遍并发挂号的手动测试——开两个客户端同时抢最后一个号,看是否只有一个成功。希望这套拆解能帮你少走点弯路,把这份源码真正改成自己能用的东西。
本文还有配套的精品资源,点击获取