C# WinForm实现医院挂号管理系统:从数据库设计到并发控制实战
2026/9/7 2:50:52 网站建设 项目流程

简介:基于C# WinForm开发的医院挂号管理系统完整源代码包,采用C/S架构和MVC分层设计,适合.NET初学者、计算机专业学生以及需要快速搭建管理系统的开发者参考。系统覆盖用户管理、科室管理、医生管理、门急诊挂号、挂号查询、打印挂号单等核心模块,并对用户密码进行MD5加密,数据操作通过存储过程完成,还提供医生照片上传和统计报表的图文展示功能。压缩包共185个文件,以64个C#源码文件、19个资源配置文件、SQL数据库脚本及CHM帮助文档为主,另含可运行的exe、DLL及报表文件,整体约10.03MB,结构清晰便于按层查阅。已有1262人浏览学习,源码包内包含项目源码、数据库脚本、设计文档和配置说明,能够帮助学习者快速理解C/S开发思路与WinForm布局技巧,也适合作为课程设计或毕业设计的参考资料。

1. 挂号窗口的痛点:需求从哪里来,系统边界定在哪

1.1 一个挂号窗口的日常

我有一段时间在给一家社区卫生服务中心做信息化项目,天天蹲在挂号窗口旁边观察流程。早上七点半,窗口还没开,门口已经排了一条队。窗口里的护士一边问"挂哪个科、挂普通还是专家",一边在Excel里登记患者姓名和手机号,然后手写一张挂号单,患者再拿着单子去诊室门口排队。到了中午,Excel里有重复的患者,有挂错科室的单子,还有医生说"这个病人我上午没排到号"的争议。更要命的是退号——一个患者上午挂错了下午的专家号,还在窗口等着办退费,护士要翻半天Excel找记录。

这不是个别现象。很多中小医疗机构,挂号量不大不小,上大型HIS系统嫌贵,纯手工又天天出乱子。中间这批机构,恰恰是最需要一套轻量级挂号管理系统的地方。用C#加WinForm写一个医院挂号管理系统,就是在这种背景下落地最务实的方案,开发周期短、部署环境要求低、窗口操作员上手也快。

1.2 功能清单,以及我刻意"不做"的功能

做项目最怕需求蔓延。我在规划功能的时候,列了一张"要做"的清单,也列了一张"坚决不做"的清单。

要做的是这六块:

  • 科室和医生档案管理,支持按科室检索医生;
  • 医生排班和号源配置,每天能挂多少号、哪些时段出诊,提前配置好;
  • 患者建档和查询,身份证号做唯一标识,姓名、手机号、既往病历号做辅助检索;
  • 挂号与退号,普通号、专家号分别处理;
  • 挂号费收取与日终统计,挂号记录能按时间、科室、医生三个维度汇总;
  • 简单的角色权限控制,挂号员、收费员、管理员各开各的入口。

不做的是:药房库存和进销存、电子病历书写、医保实时结算接口、患者手机端预约。这些不是不重要,而是每一块拉出来都是独立系统,硬塞进WinForm里只会让项目失控。提前把边界划清楚,后面开发才有节奏,跟医院那边谈需求的时候,也能把"这个版本做什么、后续怎么扩展"一次讲明白。

1.3 用户角色和权限边界

系统的用户分成四类,权限用一张用户表和一张角色字段就能控制住,没必要上框架。管理员负责维护科室、医生、排班和用户账号;挂号员负责窗口挂号、退号、患者建档;收费员负责收取挂号费和诊查费、打印票据;医生的账号只读,主要用来查看当天自己的号源和已挂号患者列表。

权限控制在WinForm里的实现比Web端简单得多。不需要拦截器,窗体加载时判断当前登录用户的角色,决定哪些按钮、Tab页、菜单项显示还是隐藏就够了。前端控制再加一条后端校验——在执行退号、改排班这类敏感操作的DAL方法里再判断一次角色,防止有人直接拼SQL或者绕过界面调用数据层。

2. 为什么是WinForm:技术选型和项目分层的理由

2.1 WinForm vs WPF vs Web:门槛、部署和维护的现实考量

很多人一听到WinForm就觉得"老古董",但在这个场景下,它恰恰是合适的选择。我做选型时对比过三个方向:

方案开发门槛部署难度团队维护成本适合场景
WinForm低,拖控件绑事件就出界面低,拷过去配个.NET Framework就能跑低,资料多,会的人多内网单机/局域网桌面应用
WPF中高,需要理解XAML和数据绑定低,但样式踩坑多中,学习曲线陡对界面视觉要求高的桌面应用
Web/BS中高,前端框架要求高中,需要部署IIS或容器中高,前后端都要维护多院区、多终端、远程访问

医院的挂号窗口就那两三台电脑,全在同一个局域网里,没有异地访问的需求。用Web一方面要部署服务器,另一方面操作员浏览器里开页面,容易误关窗口导致挂号记录没提交。WinForm原生窗口加上Dialog模式,不点确定就切不走,反而符合窗口业务的强流程特性。.NET Framework 4.7.2在Windows系统自带,连运行库都省得另外装。

2.2 数据库选型:从SQL Server到SQLite的取舍

数据这块我直接锁定SQL Server,但不建议所有场景都跟着学。如果你的项目最终要给一家几十个诊室、日均挂号量两三千的机构用,SQL Server Express版本足够,免费的,容量上限10GB事实上用不完。如果只是校内课程设计、毕业设计Demo,或者给一个小诊所搞个单机版,SQLite完全够用,数据库就是一个文件,打包发布都不用建库脚本。

我在项目里用的是SQL Server,原因是这家单位后续大概率要接LIS、PACS的检查结果回传,那些系统对外开放的接口基本都走SQL Server或Oracle,趁早统一技术栈,后面对接少折腾。连接字符串写在App.config里,开发环境连本地LocalDB,正式环境改server和database两行就行。

2.3 项目分层:UI、BLL、DAL、Common

我用的是最经典的三层结构,没有引入任何重量级框架:

  • Common:实体类、枚举、公共扩展方法、全局登录用户信息;
  • DAL:Ado.Net直接写SQL,返回值大多是DataTable或者List ;
  • BLL:业务规则校验,比如退号的条件判断、流水号生成规则;
  • UI:WinForm窗体,只做界面展示和数据绑定,不写SQL,不直接new SqlConnection。

为什么还要分一层BLL?因为挂号退号这件事,看似简单,规则却不少。退号要求当天号才能退,过了当天只能作废标记;专家号退号要记录退号原因;已就诊的号不允许退。这些规则如果散落在窗体按钮的Click事件里,一个窗体写一遍,第二个窗体复制粘贴改一改,后面维护就是灾难。放在BLL里统一收口,任何入口调用的都是同一套校验逻辑。

3. 表结构设计:撑起挂号和收费流程的底层骨架

3.1 核心表有哪些

我建了八张表,分别是:科室表Department、医生表Doctor、排班表Schedule、患者表Patient、挂号单表Registration、收费表Payment、退号表Refund、用户表SysUser。外加一条流水号表SerialNumber,专门生成挂号单号,这个单独拎出来是有讲究的,后面说。

科室和医生是一对多关系,排班表关联医生和科室。患者表以身份证号做唯一索引,但允许空身份证,因为有些孩子还没办身份证,用监护人身份证加姓名来建档。挂号单表关联患者、排班、操作员三条线,是整个系统的交易主表。退号表相对独立,记录原挂号单号、退号原因、经办人和退号时间,方便财务对账。

3.2 挂号单表:系统里最重要的那张表

挂号单表的核心字段大概长这样:

CREATE TABLE Registration ( Id INT IDENTITY(1,1) PRIMARY KEY, RegNo VARCHAR(20) NOT NULL UNIQUE, -- 挂号单号,如20240517-0001 PatientId INT NOT NULL, -- 患者ID ScheduleId INT NOT NULL, -- 排班ID DeptId INT NOT NULL, -- 科室ID DoctorId INT NOT NULL, -- 医生ID RegType TINYINT NOT NULL DEFAULT 1, -- 1普通号 2专家号 Fee DECIMAL(8,2) NOT NULL, -- 应收费用 Status TINYINT NOT NULL DEFAULT 0, -- 0已挂号 1已就诊 2已退号 3已作废 OperatorId INT NOT NULL, -- 操作员ID CreateTime DATETIME NOT NULL DEFAULT GETDATE() );

RegNo我坚持用时间前缀加四位序号,格式"20240517-0001"。这么做有几个好处:窗口报出单号,患者能直接听懂;财务对账看这个号就知道是哪天的业务;分表迁移数据也方便。序号不直接用数据库自增主键,而是单独建一个SerialNumber表,用行锁更新保证并发下不重号。

3.3 号源并发:一张排班表里的booked_count

排班表Schedule是最容易卡脖子的地方。初版设计我天真地以为,挂号的时候查询一下已挂号数量,如果小于总数就能挂。现场两台窗口机同时操作,瞬间就挂出两个相同的号源,因为两个事务都读到"还剩一个号",然后同时插入成功。

后来改成在数据库层做原子操作,挂号扣减号源和插入挂号记录放在一个事务里,扣减号源用一条带条件的UPDATE完成:

UPDATE Schedule SET BookedCount = BookedCount + 1 WHERE Id = @ScheduleId AND BookedCount < TotalCount;

C#这边判断ExecuteNonQuery的返回值,受影响行数为0就说明号源已满,整个事务回滚,不给用户生成挂号记录。这个思路和秒杀系统的库存扣减是同一个原理,代码量不大,但彻底解决并发问题。

4. 核心功能实现:挂号主流程的代码拆解

4.1 挂号主界面:三层区域的交互布局

挂号窗体是操作员一天到晚盯着的界面,布局一定要顺。我做成左、中、右三块区域,左侧是TreeView,按科室分组展示医生;中间是DataGridView排班列表,显示近7天的可挂日期和剩余号源;右侧是患者信息面板,身份证号文本框、姓名、手机号,加上一个"挂号"按钮和一个"退号"按钮。

TreeView的数据源绑定有个容易踩的坑:WinForm的TreeView没有DataSource属性,很多人上来就绑DataTable,结果绑不上。正确做法是把数据查出来之后,用循环手动组装TreeNode。科室作为父节点,医生作为子节点,子节点的Tag里存DoctorId。点击节点的时候,从Tag里取医生ID去刷排班列表,Tag数据类型是object,取值记得强转,我最初漏了强转,跑起来经常报无效转换异常。

4.2 扫码枪触发查询:它就是键盘

很多第一次做HIS相关系统的开发者会被"扫码枪触发事件"这个需求搞懵,以为要装驱动、写串口通信。实际上市面上绝大多数一维码/二维码扫码枪,默认就是模拟键盘输入的设备——光标聚焦在哪,扫出来的字符就打到哪,最后再自动敲一个回车。

明白这一点,代码就简单了。在患者编号文本框上挂KeyDown事件,判断回车键触发查询:

private void txtPatientCode_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Enter) { SearchPatient(txtPatientCode.Text.Trim()); e.SuppressKeyPress = true; // 阻止回车切换到下一个控件 } }

这里面最关键的一行是e.SuppressKeyPress = true。如果不加,扫码枪扫完自动回车,焦点会跳到下一个控件,用户还得用鼠标点回来,非常别扭。患者查出来后,自动把焦点定位到"挂号"按钮上,操作员只需要确认信息,再按一次回车就能完成挂号,全程不用碰鼠标。

4.3 挂号事务:一个号源不能挂两个人

挂号动作涉及多张表的写入,我用C#的SqlTransaction把它们包在同一个事务里。流程是:更新排班表的BookedCount,判断是否扣减成功;插入挂号单记录;插入收费记录(成功状态);提交事务。

using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); SqlTransaction tran = conn.BeginTransaction(); try { int affected = UpdateScheduleBookedCount(conn, tran, scheduleId); if (affected == 0) { tran.Rollback(); MessageBox.Show("该号源已满,请选择其他时段"); return; } string regNo = GenerateRegNo(conn, tran); // 从流水号表取号 InsertRegistration(conn, tran, regNo, patientId, scheduleId); InsertPayment(conn, tran, regNo, fee); tran.Commit(); MessageBox.Show($"挂号成功,单号:{regNo}"); } catch { tran.Rollback(); throw; } }

有个细节要提醒:SqlConnection和SqlTransaction都实现了IDisposable,务必要用using确保释放和回滚。我见过有人手工写try-catch但finally漏了Rollback,数据库连接池里全是挂着未提交事务的连接,过一会儿系统就慢得像死机一样。

4.4 退号和退费:用状态字段管理生命周期

退号不是简单删掉挂号记录,那样财务流水全对不上。我在Registration表里用Status字段做状态机:0已挂号、1已就诊、2已退号、3已作废。

退号业务规则是:状态为"已挂号"、"未就诊"且挂号日期是今天的记录,才能退。满足条件后,把状态改成"已退号",在Refund表插入一条退号记录,同时调用退费接口把收费记录的状态改为"已退费"。如果挂号同时收了诊查费,退费金额不是简单返还原价,而是按项目拆分,挂号费全退、诊查费看医生是否已经接诊决定。这些规则写在BLL的RefundService里,窗口代码只管调用,不参与判断。

退号单号沿用原挂号单号后面加"R"后缀,比如"20240517-0001R"。窗口和财务两边对账时,按原单号反查就行,不用维护两套编号体系。

5. 界面优化的几个细节:从"能用"到"好用"

5.1 窗体缩放和Anchor/Dock:为什么控件尺寸改不了

"WinForm窗体缩放,尺寸改不了"是个常年上热搜的话题。现象很典型:窗口最大化以后,DataGridView还是固定大小,停在窗体左上角,右边和下面空出一大块白。原因很简单,WinForm窗体上的控件默认坐标是绝对定位的,不会跟窗体大小联动。

解决办法就两个属性,Anchor和Dock。Anchor决定控件跟窗体哪条边保持相对距离,比如表格控件想跟随窗体四边缩放,就设Anchor为Top、Bottom、Left、Right组合;左侧TreeView想跟随高度变化,就设Anchor为Top、Bottom、Left。Dock更适合填充式布局,比如顶部的工具栏条、底部的状态栏,直接设Dock为Top或Bottom即可。

缩放还有一个隐蔽的坑:如果窗体的AutoScaleMode设置不当,高分屏下整个界面字体会被缩放拉扯,控件文字显示不全。统一设为AutoScaleMode.Dpi,配合Font="微软雅黑, 9pt",在1080P和2K屏上实测都比较正常。

5.2 DataGridView的样式和性能调优

DataGridView是WinForm里最常用的表格控件,默认样式非常丑,深深浅浅的灰白格子,字体小,列宽失控。花点时间做三件事,界面质感立刻不一样:表头背景色设成浅蓝或深灰,字体加粗;单元格AlternatingRowsDefaultCellStyle设置交替行背景色;AutoSizeColumnsMode设为Fill,让列宽自动填满。

大数据量加载会卡,这是DataSet绑定方式最大的痛点。挂号系统的排班表通常只有几百行,问题不大,但日终统计可能要加载几千条记录。这时要把Enable双缓冲打开,继承一个自定义DataGridView重写DoubleBuffered属性为true,能明显减少刷新闪烁。列表绑定用DataTable的DefaultView,不要循环往Rows里Add,速度差一个量级。

5.3 医疗风格美化和对话框交互细节

医疗系统的界面风格,我的经验是不要用深色主题,挂号窗口整天灯光充足,深色反而刺眼。主体用白色和淡青色系,功能按钮用统一的蓝色FlatStyle,危险操作比如退号、作废用橙红色,警示但不扎眼。

窗体的边框风格决定了用户能不能缩放。FixedDialog会禁用最大化按钮,如果不用Scalable布局,干脆用FixedDialog,省得用户拉大窗体后布局出问题;如果做了Anchor适配,就用Sizable保留缩放功能。我个人推荐定点布局加FixedDialog,挂号员不需要自主调整窗口,锁死反而更稳妥。

另一个容易忽略的是模式对话框。中途弹出来的子窗体,比如患者建档、排班配置、退号确认,一律用ShowDialog(),设置StartPosition = CenterParent,确保操作顺序不被打乱。有些页面是历史遗留问题用了Show(),窗口能随意切换,结果单据建一半焦点丢了,数据没保存。

5.4 回车、焦点和Tab顺序:给操作员减负

窗口操作员一天要挂上百个号,能少点几下鼠标都是好的。把窗体的AcceptButton设成"保存"按钮,用户输入完信息直接回车就提交。Tab键顺序按操作流排:扫码枪输入患者号 → 姓名确认 → 挂号按钮 → 收费按钮,不要让焦点跳回左侧科室树,那是查询用的,不属于主流程。

身份信息校验我放在文本框的Leave事件里,就是热搜词里那个"c#中文本框失去焦点"。输入身份证号,焦点一离开就校验18位合法性,格式错误当场提示,不用等用户点挂号才发现。校验通过后,自动按身份证号去Patient表查是否已建档,已建档的自动带出姓名和手机号,未建档的弹出快速建档窗口,只用填三个字段,不打断挂号节奏。

6. 开发中踩过的坑:多线程、日期、打包和部署

6.1 UI线程被数据库查询堵死:async/await解法

初版做完内部测试,操作员反馈"点查询之后窗口变成白色,过两三秒才有反应",这就是典型UI线程被数据库同步查询阻塞。WinForm的UI跑在主线程上,主线程一旦阻塞,整个窗口无法重绘,看起来就像假死。

解决方案是把耗时操作放到Task.Run里执行,UI线程用async/await等待结果:

private async void btnSearch_Click(object sender, EventArgs e) { btnSearch.Enabled = false; try { DataTable dt = await Task.Run(() => _scheduleService.GetRecentSchedules(doctorId)); dataGridView1.DataSource = dt; } catch (Exception ex) { MessageBox.Show("查询失败:" + ex.Message); } finally { btnSearch.Enabled = true; } }

注意lambda表达式里不要直接操作控件,把业务和数据访问包进去,返回DataTable后回到UI线程再赋值给DataSource。C# 5.0之后的async/await写法很成熟,用它替代BackgroundWorker,代码量少一半,逻辑也更清晰。

6.2 日期查询漏数据的坑:时间部分没清零

日终统计的时候,我第一次按"挂号日期"查当天的记录,SQL写成WHERE CreateTime = @today,结果数据时有时无,明明是当天挂的号就是查不全。问题出在CreateTime字段包含时分秒,SQL Server里的DateTime类型默认精度是毫秒级,根本没有两条记录的时间完全相等。

正确写法是半开区间:

WHERE CreateTime >= @startDate AND CreateTime < @endDate

C#侧把开始日期设为当天0点,结束日期设为明天0点:

DateTime startDate = DateTime.Today; DateTime endDate = DateTime.Today.AddDays(1);

这个坑在报表统计和号源筛选里都会遇到,记住一个原则:日期查询永远用半开区间,别用等号判断。顺带说一句,如果查询条件的列上有索引,直接写CreateTime >= 和 < 还能让索引生效,用DATEDIFF或CONVERT包一层就废掉了索引。

6.3 打包部署:ClickOnce和配套数据库脚本

WinForm程序发布打包,我用过两种方案。第一种是Visual Studio自带的ClickOnce发布,优点是升级方便,服务器放一个发布目录,客户机点一下就自动更新;缺点是需要网络共享或IIS,而且每次版本号要手动递增。第二种是InstallShield Limited Edition,做出来的安装包更正式,能写注册表、能创建桌面快捷方式,但配置流程啰嗦,第一次玩容易卡在"系统必备组件"那一步。

对于医院挂号系统这种内网应用,我更推荐ClickOnce,原因很直接:医院窗口的电脑没有固定运维人员天天跑,点位又分散,通过共享目录自动升级能省一大半维护成本。部署时记得把数据库初始化脚本和连接字符串配置说明一起放进发布文档,不然换一台新电脑装系统,光建库就能折腾半天。

6.4 连接字符串安全

还有一个关于连接串安全的教训。最开始调试我图省事,把sa账号密码直接写在App.config的ConnectionStrings里,后来给医院的正式机部署完,第二天就有同事提醒我测试机上的数据库登录日志里被人扫过,查了一下是数据库端口暴露在公网,弱口令被爆破。虽然不是程序的锅,但这个习惯很危险。

正规做法是:开发环境用Windows身份认证,集成安全性搞定;正式环境如果必须用SQL账号,密码弄成强密码,连接串在配置文件里用DataProtectionConfigurationProvider加密。代码层面不需要特殊处理,SqlConnection只认连接串,系统在启动时自动解密。

另外数据库账号的权限务必最小化——只给该库的读写权限,不给dbowner,更不要给sysadmin。这样就算连接串泄露,攻击者拿到的也只是单库权限,损失能控制住。

再分享一个这项目做完之后的个人心得:如果你打算把这套系统真正交给一线操作员用,前期最好抽一两天时间蹲在窗口旁边看她们实际操作,而不是自己在办公室想象需求。比如退号这个功能,我原以为没人会用,结果上线当天就有人退号,还好当时把"已就诊不能退"的校验写了;又比如扫码枪带回车这个行为,第一批培训时就有操作员问"为什么扫完卡自动跳到按钮上",习惯了之后反而是她们最满意的功能点。用户不会看你的代码分层多漂亮,只看一件事:点一下,页面有没有反应,号挂没挂上,钱对不对得上。把这些体验扣到底,这套系统才算真正落地。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询