简介:这是一份基于C#语言与WinForm技术栈开发的医院挂号管理系统源码项目,采用C/S架构与MVC分层设计,面向计算机专业学生、.NET初学者及需要课程设计或毕业设计参考的开发者,可用于学习桌面端信息管理系统的完整实现思路。系统功能覆盖用户管理、科室管理、医生管理、门急诊挂号、挂号查询、修改口令、挂号单打印及帮助文档等模块,表示层、业务层与数据访问层职责分明,数据库操作借助存储过程完成,用户密码采用MD5加密,统计信息以图文报表形式呈现,并支持医生照片上传以便查看详细信息。压缩包共185个文件,约10.03MB,以64个cs源码文件为核心,辅以jpg图片、resx与resources资源文件、sql脚本、exe程序及chm帮助文档等,结构较为完整。该资源已有1267人学习下载,适合作为WinForm项目实战与分层架构入门的参考案例。
1. 一个桌面挂号系统,为什么值得拿来拆
上周有个做医疗信息化的朋友问我,有没有一个能直接跑起来的 C# WinForm 挂号系统,想拿去给实习生练手。我第一反应是:这年头谁还做桌面端挂号?但仔细一想,还真有场景——社区诊所、校医院、小型专科门诊,预算有限、网络环境一般,一套本地部署的 WinForm 系统反而比 B/S 架构更省事。这个项目就是典型的「麻雀虽小五脏俱全」:患者建档、科室排班、号源分配、挂号收费、退号处理,一条业务链全在桌面端闭环。技术栈是 C# + WinForm + ADO.NET + SQL Server,没有花哨的框架,但胜在结构清晰、代码直白,新手能看懂每一行在干什么,熟手能一眼看出哪里该重构。如果你手头正好有类似的课程设计或小型门诊项目需求,这套代码的骨架值得拿来改。
2. 项目定位与技术选型:为什么是 WinForm 而不是 Web
2.1 这套系统到底解决什么问题
先把业务边界划清楚。这个挂号管理系统面向的是「单机或局域网内的小型医疗机构」,核心用户只有两类:挂号窗口的操作员和后台管理的管理员。患者不直接接触系统,所有操作由窗口人员代为完成。所以它不需要考虑高并发、不需要考虑浏览器兼容、不需要做响应式布局,只需要保证一件事:窗口人员点得快、不出错、数据不丢。
功能模块大致分四块。第一块是患者管理,包括新建患者档案(姓名、性别、身份证号、联系方式)、查询已有档案、修改信息。第二块是科室与医生管理,维护科室列表、医生所属科室、职称、出诊安排。第三块是号源管理,按日期和科室生成号源池,设置上午/下午、普通号/专家号、每个时段的号源数量。第四块是挂号与退号,选患者、选科室、选医生、选时段,生成挂号记录并计算费用,退号时回滚号源状态。
这四块串起来就是一条完整的挂号链路。看起来简单,但真正写起来,坑都在细节里:号源并发扣减怎么保证不超卖、退号后号源怎么恢复、患者重复建档怎么去重、挂号记录和收费记录怎么关联。这套代码把这些都处理了,虽然处理方式不算优雅,但逻辑是通的。
2.2 技术栈拆解:每一层用了什么
项目用的是经典的三层结构,虽然代码里没有严格分项目,但在文件夹层面做了区分。UI 层是 WinForm 窗体,每个业务模块对应一组窗体;业务逻辑层放在 BLL 文件夹下,每个类对应一个业务实体;数据访问层在 DAL 文件夹下,用 ADO.NET 直接写 SQL,没有用 Entity Framework 或 Dapper。
数据库选的是 SQL Server,连接字符串写在 App.config 里,通过 ConfigurationManager 读取。这个选择很务实——WinForm + SQL Server 是微软系最顺手的组合,Visual Studio 里调试数据库、看表结构、写查询都方便,不需要额外装工具。
| 层次 | 技术 | 职责 |
|---|---|---|
| 表现层 | WinForm + 控件事件 | 窗体渲染、用户交互、输入校验 |
| 业务层 | C# 类库 | 业务规则、数据组装、事务控制 |
| 数据层 | ADO.NET + SqlCommand | SQL 执行、参数化查询、结果映射 |
| 存储 | SQL Server | 表结构、索引、存储过程(少量) |
没有用 ORM 这件事,新手可能觉得原始,但恰恰是这个项目的教学价值所在——每一行 SQL 都看得见,参数怎么传、结果怎么读、连接怎么关,全是明面上的。等你把 ADO.NET 玩熟了,再去用 Dapper 或 EF,才知道它们帮你省了什么。
2.3 目录结构长什么样
项目根目录下大致是这么分的:
HospitalRegistration/ ├── App.config # 数据库连接字符串配置 ├── Program.cs # 入口,启动登录窗体 ├── Forms/ # 所有 WinForm 窗体 │ ├── FrmLogin.cs # 登录 │ ├── FrmMain.cs # 主界面(菜单导航) │ ├── FrmPatient.cs # 患者管理 │ ├── FrmDepartment.cs # 科室管理 │ ├── FrmDoctor.cs # 医生管理 │ ├── FrmSchedule.cs # 排班与号源 │ └── FrmRegister.cs # 挂号与退号 ├── BLL/ # 业务逻辑 │ ├── PatientBLL.cs │ ├── DepartmentBLL.cs │ ├── DoctorBLL.cs │ ├── ScheduleBLL.cs │ └── RegisterBLL.cs ├── DAL/ # 数据访问 │ ├── SqlHelper.cs # 数据库操作工具类 │ ├── PatientDAL.cs │ └── ... ├── Models/ # 实体类 │ ├── Patient.cs │ ├── Doctor.cs │ └── ... └── Utils/ # 工具类 └── Validator.cs # 输入校验这个结构不算精致,但足够清晰。SqlHelper.cs 是整个数据层的核心,封装了 ExecuteNonQuery、ExecuteScalar、ExecuteDataTable 三个方法,所有 DAL 类都调它。这种写法在老项目里很常见,好处是统一了连接管理和异常处理,坏处是 SqlHelper 本身容易变成上帝类,后面改造时值得注意。
3. 核心模块拆解:从登录到挂号的完整链路
3.1 数据库设计:五张表撑起整个业务
先看表结构,这是整个系统的地基。核心表有五张:Patient(患者)、Department(科室)、Doctor(医生)、Schedule(排班号源)、Registration(挂号记录)。
Patient 表的关键字段是 PatientId(主键,自增)、Name、Gender、IdCard(身份证号,唯一索引)、Phone、CreateTime。身份证号做唯一索引是为了防止重复建档,这个设计很关键——窗口人员手快,同一个患者可能被建两次,有了唯一约束,插入时直接报错,比事后去重省事得多。
Schedule 表是号源管理的核心,字段包括 ScheduleId、DoctorId、WorkDate、TimeSlot(上午/下午)、RegType(普通/专家)、TotalCount、RemainCount。RemainCount 是剩余号源数,挂号时减一,退号时加一。这里有个细节:RemainCount 不能小于零,所以扣减时必须用条件更新,而不是先查再改。
Registration 表记录每一笔挂号,字段有 RegId、PatientId、ScheduleId、RegTime、Fee、Status(0 已挂号 / 1 已退号)。Status 字段是退号处理的依据,退号时不是删除记录,而是把状态改成 1,同时把对应 Schedule 的 RemainCount 加回去。这种软删除的做法在业务系统里很常见,好处是留痕,坏处是查询时别忘了加 Status 条件。
-- 号源扣减:条件更新防止超卖 UPDATE Schedule SET RemainCount = RemainCount - 1 WHERE ScheduleId = @ScheduleId AND RemainCount > 0; -- 检查是否扣减成功 IF @@ROWCOUNT = 0 BEGIN RAISERROR('号源已满', 16, 1); ROLLBACK TRANSACTION; END上面这段 SQL 是整个系统最关键的逻辑之一。注意 WHERE 条件里的RemainCount > 0,它保证了只有还有余号时才扣减,配合@@ROWCOUNT判断影响行数,就能在数据库层面杜绝超卖。很多新手会写成「先 SELECT 查余号,再判断大于零,再 UPDATE」,这种写法在单机单用户下没问题,但一旦有两个窗口同时操作,就会出问题。条件更新是更稳妥的做法,血泪经验。
3.2 登录与权限:一个简单的角色区分
登录窗体 FrmLogin 的逻辑很直白:输入用户名和密码,查 User 表,验证通过后根据 Role 字段决定主界面的菜单可见性。管理员能看到所有菜单,挂号员只能看到挂号、退号、患者查询这几个。
// FrmLogin.cs 核心验证逻辑 private void btnLogin_Click(object sender, EventArgs e) { string username = txtUsername.Text.Trim(); string password = txtPassword.Text.Trim(); if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) { MessageBox.Show("用户名和密码不能为空"); return; } // 密码做 MD5 后比对,数据库里不存明文 string hashedPwd = MD5Helper.Hash(password); string sql = "SELECT UserId, Role FROM [User] WHERE UserName=@name AND Password=@pwd"; DataTable dt = SqlHelper.ExecuteDataTable(sql, new SqlParameter("@name", username), new SqlParameter("@pwd", hashedPwd)); if (dt.Rows.Count == 0) { MessageBox.Show("用户名或密码错误"); return; } int role = Convert.ToInt32(dt.Rows[0]["Role"]); FrmMain main = new FrmMain(role); main.Show(); this.Hide(); }这段代码里有两个值得说的点。第一,密码用 MD5 哈希后存储和比对,虽然 MD5 现在不算安全,但对于这种小型系统来说,至少比明文强。如果你要改造,建议换成 SHA256 加盐,改动量不大。第二,登录成功后把 role 传给主窗体,主窗体在 Load 事件里根据 role 控制菜单项的 Visible 属性。这种权限控制很粗粒度,但够用——小型门诊不需要复杂的 RBAC 模型。
参数说明:@name和@pwd是参数化查询的占位符,SqlHelper 内部会把 SqlParameter 数组塞进 SqlCommand.Parameters。这样做的好处是防 SQL 注入,别图省事用字符串拼接,翻车案例太多了。
3.3 号源生成与挂号事务:一个事务包住三步操作
挂号这个动作,表面上是「选人、选号、确认」三步,背后其实涉及三个数据库操作:插入挂号记录、扣减号源余量、可能还要更新患者就诊状态。这三步必须在一个事务里完成,否则中途失败会导致数据不一致——比如挂号记录插进去了但号源没扣,或者号源扣了但记录没插。
// RegisterBLL.cs 挂号核心逻辑 public bool Register(int patientId, int scheduleId, decimal fee) { using (SqlConnection conn = new SqlConnection(SqlHelper.ConnectionString)) { conn.Open(); using (SqlTransaction trans = conn.BeginTransaction()) { try { // 第一步:扣减号源,条件更新防超卖 string sqlUpdate = @"UPDATE Schedule SET RemainCount = RemainCount - 1 WHERE ScheduleId = @sid AND RemainCount > 0"; SqlCommand cmdUpdate = new SqlCommand(sqlUpdate, conn, trans); cmdUpdate.Parameters.AddWithValue("@sid", scheduleId); int affected = cmdUpdate.ExecuteNonQuery(); if (affected == 0) { trans.Rollback(); return false; // 号源已满 } // 第二步:插入挂号记录 string sqlInsert = @"INSERT INTO Registration (PatientId, ScheduleId, RegTime, Fee, Status) VALUES (@pid, @sid, GETDATE(), @fee, 0)"; SqlCommand cmdInsert = new SqlCommand(sqlInsert, conn, trans); cmdInsert.Parameters.AddWithValue("@pid", patientId); cmdInsert.Parameters.AddWithValue("@sid", scheduleId); cmdInsert.Parameters.AddWithValue("@fee", fee); cmdInsert.ExecuteNonQuery(); trans.Commit(); return true; } catch (Exception ex) { trans.Rollback(); throw new Exception("挂号失败:" + ex.Message); } } } }这段代码是整个项目里最值得细看的地方。它把「扣号源」和「写记录」放在同一个事务里,任何一步失败都回滚。注意扣号源那一步用的是条件更新加影响行数判断,而不是先查后改,这是防超卖的关键。参数@sid是排班记录的主键,@pid是患者主键,@fee是挂号费,由前端根据号别类型算好后传进来。
退号的逻辑反过来:先把 Registration 的 Status 改成 1,再把 Schedule 的 RemainCount 加一,同样放在一个事务里。这里有个容易忽略的点——退号时要检查当前 Status 是不是已经是 1,防止重复退号。代码里用UPDATE ... WHERE RegId=@rid AND Status=0加影响行数判断来处理,思路和扣号源一致。
3.4 患者建档的去重与校验
患者建档看起来简单,但实际用起来问题不少。窗口人员录入时,身份证号可能输错一位,或者同一个患者换了手机号又来建一次。项目里的处理方式是:身份证号做唯一索引,插入时捕获唯一约束冲突,提示「该患者已存在」;同时提供一个按身份证号模糊查询的功能,让操作员先查再建。
// PatientBLL.cs 新增患者 public bool AddPatient(Patient p) { // 先校验身份证号格式(18位,最后一位可能是X) if (!Validator.IsValidIdCard(p.IdCard)) throw new Exception("身份证号格式不正确"); // 再查是否已存在 string sqlCheck = "SELECT COUNT(1) FROM Patient WHERE IdCard=@idcard"; int count = Convert.ToInt32(SqlHelper.ExecuteScalar(sqlCheck, new SqlParameter("@idcard", p.IdCard))); if (count > 0) throw new Exception("该身份证号已建档,请直接查询"); string sql = @"INSERT INTO Patient (Name, Gender, IdCard, Phone, CreateTime) VALUES (@name, @gender, @idcard, @phone, GETDATE())"; return SqlHelper.ExecuteNonQuery(sql, new SqlParameter("@name", p.Name), new SqlParameter("@gender", p.Gender), new SqlParameter("@idcard", p.IdCard), new SqlParameter("@phone", p.Phone)) > 0; }先校验格式再查重再插入,三步顺序不能乱。格式校验放在最前面,避免脏数据进查询;查重放在插入前,给操作员一个友好的提示;唯一索引作为最后一道防线,防止并发情况下的漏网之鱼。这种「多层防御」的思路在业务系统里很实用,别指望单靠数据库约束解决所有问题,用户体验也很重要。
4. 跑起来:环境准备与首次运行
4.1 环境清单与数据库初始化
跑这个项目需要三样东西:Visual Studio(2019 或 2022 都行,社区版免费)、SQL Server(Express 版就够)、.NET Framework 4.7.2 以上。项目是 WinForm,不需要装额外的 SDK 或运行时,VS 装好就能编译。
数据库初始化分两步。第一步,在 SQL Server 里新建一个数据库,名字随意,比如 HospitalDB。第二步,执行项目里附带的建表脚本(通常在 Database 文件夹下,文件名类似 Init.sql),把五张表和初始数据建好。初始数据里一般会有一个管理员账号,用户名 admin,密码是 123456 的 MD5 值,登录后第一件事就是改密码。
连接字符串在 App.config 里,长这样:
<connectionStrings> <add name="HospitalDB" connectionString="Data Source=.;Initial Catalog=HospitalDB;Integrated Security=True" providerName="System.Data.SqlClient"/> </connectionStrings>Data Source=.表示本机默认实例,如果你装的是 SQL Server Express,要改成Data Source=.\SQLEXPRESS。Integrated Security=True表示用 Windows 身份验证,不需要在字符串里写用户名密码。如果你用的是 SQL 账号登录,改成User Id=sa;Password=你的密码。这一步改错,程序启动就会报连接失败,是最常见的翻车点。
4.2 编译与启动流程
用 VS 打开解决方案文件(.sln),先右键解决方案选「还原 NuGet 包」(如果有依赖的话,这个项目大概率没有),然后直接 F5 启动。启动后先出登录窗体,用初始管理员账号登录,进主界面。
主界面的菜单按模块排列:患者管理、科室管理、医生管理、排班管理、挂号退号。建议的首次使用顺序是:先建科室,再建医生(医生要挂到科室下),然后排班(给医生排日期和号源数量),最后才能挂号。这个顺序不能乱,因为挂号依赖排班,排班依赖医生,医生依赖科室,是一条链。
排班界面的操作逻辑是:选医生、选日期、选时段(上午/下午)、选号别(普通/专家)、填号源总数,点生成。生成后 RemainCount 等于 TotalCount。如果同一天同一时段重复生成,代码里会做覆盖或提示,具体看实现——常见做法是先删后插,或者用唯一索引约束(DoctorId + WorkDate + TimeSlot)防止重复。
4.3 一次完整挂号的操作路径
登录后点「挂号退号」,界面大致分三块:左边是患者查询区,中间是号源选择区,右边是挂号信息确认区。操作路径是:输入患者姓名或身份证号查询,选中患者;然后选科室、选医生、选日期,下方列表会显示该医生当天的号源(上午/下午、普通/专家、剩余数量);选中一个号源,点「挂号」,系统弹出费用确认,确认后生成挂号记录,号源余量减一。
退号的操作反过来:在挂号记录列表里查到该患者的挂号记录,选中,点「退号」,系统提示确认,确认后状态改为已退号,号源余量加一。注意退号一般有时限限制(比如就诊当天不能退),这个逻辑如果代码里没写,你二次开发时可以加上,在退号前判断 WorkDate 和当前日期。
5. 避坑与排查:那些跑起来才会遇到的问题
5.1 连接数据库报错「无法打开登录所请求的数据库」
现象:程序启动后点登录,弹窗报错,提示无法打开数据库。原因通常是连接字符串里的数据库名和实际建的不一致,或者 SQL Server 服务没启动。解决:先确认 SQL Server 服务在运行(服务管理器里看 SQL Server (MSSQLSERVER) 或 SQL Server (SQLEXPRESS) 的状态),再核对 App.config 里的 Initial Catalog 是否等于你建的库名。如果是 Express 版,Data Source 要写成.\SQLEXPRESS,少写这个后缀连不上。
5.2 挂号时提示「号源已满」但明明还有余号
现象:排班时设了 20 个号,挂了 5 个就提示满了。原因多半是 RemainCount 字段的初始值没设对,或者排班生成时 TotalCount 和 RemainCount 赋值搞反了。排查方法:直接去数据库里查 Schedule 表,看对应记录的 TotalCount 和 RemainCount 分别是多少。如果 RemainCount 是 0 而 TotalCount 是 20,说明生成号源时漏了赋值。解决:修生成逻辑,或者在数据库里手动把 RemainCount 更新成 TotalCount。
5.3 退号后号源没有恢复
现象:退了号,但号源余量没加回去。原因通常是退号逻辑只改了 Registration 的 Status,忘了更新 Schedule 的 RemainCount,或者更新了但事务没提交。排查:看退号方法的代码,确认是否有UPDATE Schedule SET RemainCount = RemainCount + 1这一句,以及是否和状态更新在同一个事务里。另一个可能是重复退号——第二次退号时 Status 已经是 1,条件更新影响行数为 0,但代码没判断,导致余量被多加。解决:退号时加AND Status=0条件,判断影响行数。
5.4 中文乱码或日期格式错乱
现象:患者姓名显示成问号,或者日期显示成 MM/dd/yyyy 而不是 yyyy-MM-dd。原因:数据库字段的排序规则不是中文,或者 C# 里日期格式化用了默认的当前区域设置。解决:建库时选 Chinese_PRC_CI_AS 排序规则;日期显示统一用ToString("yyyy-MM-dd")显式格式化,别依赖系统默认。这个坑在跨机器部署时特别容易踩,开发机是中文系统,部署到英文系统上就翻车。
5.5 窗体卡死或无响应
现象:点查询按钮后界面卡住几秒。原因:数据库查询在 UI 线程上同步执行,数据量大或网络慢时就会卡。这个项目大概率是同步写法,小数据量下感觉不明显,但患者表上千条后就能感觉到。解决:改造时把查询放到 Task.Run 里,或者用 async/await 异步查询,查完后用 Invoke 回到 UI 线程更新控件。这是 WinForm 的老问题,不算 bug,但体验上值得优化。
6. 二次开发:从能跑到好用要改哪几刀
这套代码跑通之后,真正花时间的是改造。我一般按三个优先级来动:先补数据一致性,再优化交互,最后考虑架构。
第一刀砍在事务边界上。原项目的挂号事务只包了扣号源和写记录两步,但实际业务里还涉及收费——如果收费记录是单独一张表,必须一起进事务。改造思路是把 BLL 层的方法签名改成接收一个业务对象,内部统一开事务,所有相关表的写操作都挂在同一个 SqlTransaction 上。验证方法很简单:在插入挂号记录后手动抛一个异常,看号源有没有回滚,回滚了说明事务边界对了。
第二刀是号源生成的幂等性。原项目如果重复点「生成号源」,可能插出重复记录。改造时给 Schedule 表加一个唯一索引(DoctorId, WorkDate, TimeSlot, RegType),插入时用IF NOT EXISTS或者捕获唯一约束异常来处理。这样即使操作员手抖点两次,也不会产生脏数据。
第三刀是查询性能。患者表和挂号表数据量上来后,按姓名模糊查询会全表扫。加索引是最直接的:Patient 表的 Name 字段加非聚集索引,Registration 表的 PatientId 和 RegTime 加复合索引。改完之后用 SQL Server 的执行计划看一眼,确认走了索引查找而不是扫描。
// 改造示例:异步查询患者,避免 UI 卡死 private async void btnSearch_Click(object sender, EventArgs e) { string keyword = txtKeyword.Text.Trim(); btnSearch.Enabled = false; try { // 把同步查询包进 Task.Run,释放 UI 线程 DataTable dt = await Task.Run(() => PatientBLL.Search(keyword)); dgvPatients.DataSource = dt; } catch (Exception ex) { MessageBox.Show("查询失败:" + ex.Message); } finally { btnSearch.Enabled = true; } }这段改造的关键是await Task.Run(...),把耗时的数据库查询挪到线程池,UI 线程继续响应。注意查完后直接给 DataSource 赋值是安全的,因为 await 之后已经回到 UI 上下文。如果你在 Task 内部直接操作控件,就会抛跨线程异常,这是 WinForm 异步改造最常见的坑。
还有一个容易被忽略的点:密码存储。原项目用 MD5,改造时换成Rfc2898DeriveBytes(PBKDF2)加盐哈希,每个用户存独立的盐值。改动集中在登录验证和用户新增两个方法,工作量不大,但安全性提升明显。数据库里加一个 Salt 字段,登录时先按用户名查出盐,再算哈希比对。
从那以后我每次接手这类桌面业务系统,都强制自己先跑一遍「挂号-退号-再挂号」的完整链路,确认号源数量在数据库里对得上,再动其他代码。这个习惯帮我省了不少回头查数据的时间。希望帮到你。
本文还有配套的精品资源,点击获取