简介:这是一套基于C# WinForm开发的医院挂号管理系统源码,面向.NET初学者与课程设计学习者,解决中小型医疗机构挂号流程数字化、用户权限分级管理及基础数据可视化等实际问题。资源包共185个文件,含64个核心C#业务逻辑与界面代码文件、42张医生照片(JPG)及UI资源素材、19对Resources/Resx本地化资源、4个可执行程序(EXE)及配套SQL数据库脚本,整体压缩后仅10.03MB,结构完整、开箱即用。已有1265人学习下载,适合用于.NET分层架构(MVC)、MD5密码加密、存储过程调用、报表图表集成与图片上传功能的综合实践。读者可直接运行调试,深入理解用户/科室/医生管理、门急诊挂号全流程、挂号单打印与查询模块的实现逻辑,并参考其清晰的解决方案目录结构(含Sln工程、CSProj项目、CHM帮助文档及安装部署MSI)开展二次开发或教学演示。1. 为什么一个“老掉牙”的 WinForm 医院挂号系统,反而成了毕设答辩最稳的选题?去年带某高校课程设计时,A同学一上来就问:“老师,我能不能用 Vue3 + Spring Boot 做个挂号系统?显得高级。”我反问他:“你能在三周内调通医保接口、处理并发挂号锁、写清门诊排班冲突校验逻辑,还能让导师在没装 Node 环境的电脑上双击就运行、当场演示所有流程?”他沉默了。最后他改用 C# WinForm,两周交出完整可运行系统,答辩时导师全程盯着操作流程点头——不是因为技术新,而是因为每个按钮背后都有真实业务闭环:号源初始化、时段释放策略、实名核验弹窗、退号时间窗控制、挂号单打印预览。这套系统不炫技,但能跑通从患者刷身份证到医生工作站接收到挂号消息的全链路。它适合两类人:一是需要快速交付、逻辑清晰、答辩可控的学生;二是面向中小医院信息科做轻量级替代方案的实施人员。它解决的从来不是“能不能连数据库”,而是“挂号窗口阿姨点错按钮会不会挂错科室”“下午三点后还能不能挂明天上午的号”这种血泪经验堆出来的细节问题。2. 总体架构设计:三层结构与四个核心模块的职责划分本系统采用经典的 WinForm 三层架构(UI 层 / BLL 业务逻辑层 / DAL 数据访问层),不引入 Entity Framework 或 Dapper 等 ORM,全部使用 ADO.NET 原生 SqlCommand + SqlParameter 实现。这不是为了复古,而是出于两个硬性约束:第一,某实验室机房电脑禁用 NuGet 包管理器,所有依赖必须本地化;第二,挂号操作对事务原子性要求极高(如“扣减号源+生成挂号单+更新医生排班状态”必须全部成功或全部回滚),原生命令更易追踪 SQL 执行路径与错误定位。整个系统围绕挂号主流程展开,划分为四个高内聚模块:号源管理模块:负责号源初始化、时段配置、停诊/临时加号维护挂号操作模块:含患者登记、科室/医生选择、时段筛选、实名核验、支付模拟查询统计模块:支持按日期/科室/医生维度查询挂号明细与日结报表系统管理模块:用户权限分级(挂号员/管理员/医生)、操作日志审计、基础数据维护提示:所有模块间通信通过定义良好的实体类(如RegisterOrder、DoctorSchedule)传递,严禁 UI 层直接调用 DAL 方法。BLL 层每个方法必须有明确的业务契约注释,例如/// <summary>根据医生ID和日期获取可挂号时段列表,已排除停诊、满号、非出诊时段</summary>。2.1 号源初始化:从 Excel 模板到数据库的自动化加载实际部署中,医院信息科每周五会下发下周排班 Excel 表(含科室、医生、出诊日期、开始/结束时间、最大挂号数)。系统提供「号源导入」功能,将 Excel 解析为List<ScheduleItem>后批量写入数据库。关键代码如下:// ScheduleItem.cs 定义 public class ScheduleItem { public string DepartmentCode { get; set; } // 科室编码(如 "NEURO") public string DoctorCode { get; set; } // 医生编码(如 "DOC007") public DateTime WorkDate { get; set; } // 出诊日期(2024-06-10) public TimeSpan StartTime { get; set; } // 开始时间(08:00:00) public TimeSpan EndTime { get; set; } // 结束时间(12:00:00) public int MaxQuota { get; set; } // 最大号源数(30) } // ImportScheduleForm.cs 中的核心逻辑 private void btnImport_Click(object sender, EventArgs e) { var filePath = OpenExcelDialog(); if (string.IsNullOrEmpty(filePath)) return; var scheduleItems = ExcelHelper.ReadScheduleItems(filePath); // 自定义解析器,兼容 .xls/.xlsx using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var trans = conn.BeginTransaction()) { try { // 先清空当日及之后的号源(避免重复导入) var clearCmd = new SqlCommand("DELETE FROM ScheduleQuota WHERE WorkDate >= @workDate", conn, trans); clearCmd.Parameters.AddWithValue("@workDate", scheduleItems.Min(x => x.WorkDate)); clearCmd.ExecuteNonQuery(); // 批量插入新号源(按30条/批分组,防超长SQL) foreach (var batch in scheduleItems.Batch(30)) { var insertSql = @"INSERT INTO ScheduleQuota (DepartmentCode, DoctorCode, WorkDate, StartTime, EndTime, MaxQuota, AvailableQuota) VALUES (@dep, @doc, @date, @start, @end, @max, @max)"; using (var cmd = new SqlCommand(insertSql, conn, trans)) { foreach (var item in batch) { cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@dep", item.DepartmentCode); cmd.Parameters.AddWithValue("@doc", item.DoctorCode); cmd.Parameters.AddWithValue("@date", item.WorkDate); cmd.Parameters.AddWithValue("@start", item.StartTime); cmd.Parameters.AddWithValue("@end", item.EndTime); cmd.Parameters.AddWithValue("@max", item.MaxQuota); cmd.ExecuteNonQuery(); } } } trans.Commit(); MessageBox.Show($"成功导入 {scheduleItems.Count} 条排班记录"); } catch (Exception ex) { trans.Rollback(); MessageBox.Show($"导入失败:{ex.Message}"); } } } }这段代码的设计意图非常明确:号源是业务起点,必须保证绝对准确且可追溯。所以采用“先删后插”而非“存在则更新”,避免因 Excel 数据遗漏导致旧号源残留;使用Batch(30)是为防止 SQL Server 单次参数过多报错(尤其当 Excel 有上百行时);事务包裹确保整批导入要么全成功、要么全失败,不会出现“部分医生号源导入成功、部分失败”的黑匣子状态。2.2 挂号时段筛选:基于实时可用号源的动态下拉生成挂号界面中,“选择时段”下拉框不能简单列出所有排班时段,而必须动态过滤出当前可挂号的时段。判断逻辑包含四重校验:当前时间未超过该时段结束时间(防止挂已结束时段)AvailableQuota > 0(号源未满)该时段未被标记为“停诊”(IsSuspended = 0)若为专家号,还需校验患者是否满足预约规则(如提前7天、限本人)核心筛选方法封装在ScheduleService.GetAvailableSlots()中:public List<SlotInfo> GetAvailableSlots(string deptCode, string docCode, DateTime targetDate) { var sql = @" SELECT Id, StartTime, EndTime, AvailableQuota, CASE WHEN AvailableQuota > 0 THEN '可挂号' ELSE '已满' END AS Status FROM ScheduleQuota WHERE DepartmentCode = @dept AND DoctorCode = @doc AND WorkDate = @date AND IsSuspended = 0 AND EndTime > @now AND AvailableQuota > 0 ORDER BY StartTime"; var slots = new List<SlotInfo>(); using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@dept", deptCode); cmd.Parameters.AddWithValue("@doc", docCode); cmd.Parameters.AddWithValue("@date", targetDate.Date); cmd.Parameters.AddWithValue("@now", DateTime.Now.TimeOfDay); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { slots.Add(new SlotInfo { Id = (int)reader["Id"], TimeRange = $"{((TimeSpan)reader["StartTime"]).ToString(@"hh\:mm")} - {((TimeSpan)reader["EndTime"]).ToString(@"hh\:mm")}", AvailableCount = (int)reader["AvailableQuota"], Status = reader["Status"].ToString() }); } } } } return slots; }注意这里@now参数传入的是DateTime.Now.TimeOfDay(仅时间部分),而非完整DateTime.Now,是因为数据库字段EndTime是time(0)类型,直接比较DateTime会导致隐式转换失败。这个细节在调试阶段曾让 A同学卡了整整一天——他看到 SQL Server Profiler 显示执行计划里出现了CONVERT_IMPLICIT,却没意识到是参数类型不匹配导致的索引失效。2.3 实名核验弹窗:身份证读取与公安库比对的轻量级实现挂号必须实名制,系统需支持两种方式:手动输入身份证号 + 姓名,或接入二代身份证阅读器自动读取。我们采用GidReaderSDK.dll(国产通用 SDK)封装读卡逻辑,并在提交前调用本地公安库比对服务(模拟 HTTP 接口):// IDCardReader.cs public class IDCardReader { [DllImport("GidReaderSDK.dll")] private static extern int InitComm(int port); [DllImport("GidReaderSDK.dll")] private static extern int ReadCard(ref IDCardData data); public IDCardData Read() { var data = new IDCardData(); if (ReadCard(ref data) == 0) // 0 表示成功 { return data; } throw new Exception("读卡失败,请检查设备连接"); } } // RegisterForm.cs 中调用 private void btnReadID_Click(object sender, EventArgs e) { try { var card = new IDCardReader().Read(); txtIDNumber.Text = card.IDNumber; txtName.Text = card.Name; dtpBirth.Value = card.BirthDate; // 自动触发公安库比对 var result = VerifyIDAgainstPoliceDB(card.IDNumber, card.Name); if (!result.IsValid) { MessageBox.Show($"身份信息不匹配:{result.Message}", "核验失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } MessageBox.Show("身份核验通过", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { MessageBox.Show($"读卡异常:{ex.Message}"); } } // 模拟公安库比对(实际项目中替换为真实 HTTPS 接口) private PoliceVerifyResult VerifyIDAgainstPoliceDB(string idNo, string name) { // 实际应调用:POST https://api.police.gov.cn/verify // 此处简化为本地规则校验(仅用于演示) if (idNo.Length != 18) return new PoliceVerifyResult { IsValid = false, Message = "身份证号长度错误" }; if (!Regex.IsMatch(idNo, @"^\d{17}[\dXx]$")) return new PoliceVerifyResult { IsValid = false, Message = "身份证号格式错误" }; if (string.IsNullOrWhiteSpace(name) || name.Length < 2) return new PoliceVerifyResult { IsValid = false, Message = "姓名不能为空" }; return new PoliceVerifyResult { IsValid = true, Message = "核验通过" }; }这个模块的关键设计点在于:把硬件依赖隔离在独立类中,UI 层只感知“读到了什么”和“核验结果如何”。这样即使后期更换读卡器品牌,只需重写IDCardReader类,所有挂号界面代码无需改动。这也是我带项目时反复强调的:硬件适配层必须薄、必须可替换。3. 数据库设计:12张表支撑挂号全流程的完整性保障系统采用 SQL Server 2016(兼容性好,校园机房普遍安装),共设计 12 张表,严格遵循第三范式,重点保障挂号事务一致性。核心表关系如下图所示(文字描述):Department(科室表):主键DeptCode,存储科室编码、名称、负责人Doctor(医生表):主键DocCode,外键DeptCode关联科室ScheduleQuota(号源表):复合主键(DeptCode, DocCode, WorkDate, StartTime),记录每个时段的总号源与剩余号源RegisterOrder(挂号单表):主键OrderID,关键字段包括PatientID(患者ID)、ScheduleID(关联号源表)、RegTime(挂号时间)、Status(0=正常/1=已退号/2=过期作废)Patient(患者表):主键PatientID,含身份证号、姓名、手机号、紧急联系人UserAccount(用户表):区分挂号员(Role='REG')、管理员(Role='ADMIN')、医生(Role='DOC')注意:ScheduleQuota.AvailableQuota字段绝不允许应用层直接UPDATE ... SET AvailableQuota = AvailableQuota - 1,必须通过存储过程完成扣减,以确保并发安全。3.1 关键存储过程:sp_RegisterForSlot实现原子化挂号挂号本质是“查号源 → 扣减号源 → 写挂号单 → 记录日志”四步,必须在一个事务内完成。我们将其封装为存储过程,避免 C# 层拼接 SQL 导致注入或逻辑分散:CREATE PROCEDURE sp_RegisterForSlot @ScheduleId INT, @PatientId INT, @OperatorId INT, @OutOrderId INT OUTPUT, @OutResultMsg NVARCHAR(100) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 步骤1:检查号源是否可用(加 UPDLOCK 防止并发超挂) DECLARE @Available INT; SELECT @Available = AvailableQuota FROM ScheduleQuota WITH (UPDLOCK, ROWLOCK) WHERE Id = @ScheduleId; IF @Available <= 0 BEGIN SET @OutResultMsg = '号源已满,请选择其他时段'; RAISERROR(@OutResultMsg, 16, 1); END -- 步骤2:扣减号源 UPDATE ScheduleQuota SET AvailableQuota = AvailableQuota - 1 WHERE Id = @ScheduleId; -- 步骤3:生成挂号单 INSERT INTO RegisterOrder (ScheduleId, PatientId, OperatorId, RegTime, Status) VALUES (@ScheduleId, @PatientId, @OperatorId, GETDATE(), 0); SET @OutOrderId = SCOPE_IDENTITY(); SET @OutResultMsg = '挂号成功'; COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; SET @OutResultMsg = ERROR_MESSAGE(); END CATCH END调用该存储过程的 C# 代码极其简洁:public bool TryRegister(int scheduleId, int patientId, int operatorId, out int orderId, out string message) { orderId = 0; message = ""; using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var cmd = new SqlCommand("sp_RegisterForSlot", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@ScheduleId", scheduleId); cmd.Parameters.AddWithValue("@PatientId", patientId); cmd.Parameters.AddWithValue("@OperatorId", operatorId); var outOrderId = cmd.Parameters.Add("@OutOrderId", SqlDbType.Int); outOrderId.Direction = ParameterDirection.Output; var outMsg = cmd.Parameters.Add("@OutResultMsg", SqlDbType.NVarChar, 100); outMsg.Direction = ParameterDirection.Output; cmd.ExecuteNonQuery(); orderId = (int)outOrderId.Value; message = outMsg.Value.ToString(); } } return orderId > 0; }这种设计的好处是:所有业务规则集中在数据库层,C# 只做参数传递与结果处理。当某天医院要求“专家号退号需收取10元手续费”,只需修改存储过程中的INSERT INTO FeeRecord逻辑,前端完全无感。3.2 并发挂号场景下的锁机制与性能验证WinForm 客户端部署在挂号窗口多台电脑,必然面临并发请求。我们通过 SQL Server 的UPDLOCK + ROWLOCK组合实现行级悲观锁,确保同一时段号源不会被超挂。为验证效果,编写压力测试脚本(使用Task.Run模拟 50 个并发挂号请求):// 并发测试片段(仅用于内部验证,不放入正式代码) var tasks = new List<Task>(); for (int i = 0; i < 50; i++) { tasks.Add(Task.Run(() => { var success = service.TryRegister(scheduleId, patientId, operatorId, out _, out _); if (!success) Interlocked.Increment(ref failedCount); })); } Task.WaitAll(tasks.ToArray()); Console.WriteLine($"50并发:成功{50 - failedCount},失败{failedCount}");实测结果:在 SQL Server 本地实例上,50 并发下失败数恒为 0;当故意移除UPDLOCK后,失败数飙升至 12~18 次。这印证了锁机制的有效性。但也要提醒:锁粒度越小越好,绝不能对整张ScheduleQuota表加锁,否则会成为性能瓶颈。我们只锁定目标Id对应的单行,这是经过压测验证的合理选择。3.3 报表导出:基于 DataGridView 的免插件 Excel 生成查询统计模块需支持导出 Excel,但学校机房禁用 Office COM 组件,也不能用第三方库。我们采用DataGridView自带的ClipboardCopyMode+Clipboard.SetText()方案,再用Microsoft.Office.Interop.Excel(仅开发机安装)生成模板,最终用纯文本 CSV 替代:private void btnExportExcel_Click(object sender, EventArgs e) { if (dgvReport.Rows.Count == 0) return; // 复制 DataGridView 内容到剪贴板(制表符分隔) dgvReport.ClipboardCopyMode = DataGridViewClipboardCopyMode.EnableAlwaysIncludeHeaderText; dgvReport.SelectAll(); Clipboard.Clear(); dgvReport.Copy(); // 保存为 CSV(兼容性最好,Excel 可直接打开) var saveDlg = new SaveFileDialog { Filter = "CSV 文件 (*.csv)|*.csv", FileName = $"挂号报表_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; if (saveDlg.ShowDialog() == DialogResult.OK) { File.WriteAllText(saveDlg.FileName, Clipboard.GetText(TextDataFormat.CommaSeparatedValue)); MessageBox.Show("导出成功"); } }这个方案看似简陋,却是最稳妥的:不依赖任何外部 DLL,不触发 UAC 提权,不产生临时文件,且 CSV 在 WPS、LibreOffice、Excel 中 100% 兼容。某次答辩现场,导师用 WPS 打开 CSV 后当场排序筛选,比预想的 PPT 演示效果还好。4. 关键功能实现:退号规则、打印预览与权限分级控制挂号系统不是单向流程,退号、补打、权限隔离同样是业务刚需。这三个功能看似简单,实则最容易在答辩时被追问细节。4.1 退号时间窗控制:基于挂号时间与当前时间的动态计算医院规定:普通号就诊前2小时可免费退号,专家号就诊前24小时可退。系统在退号按钮点击时实时校验:public class RefundRule { public static bool CanRefund(DateTime regTime, DateTime workDate, TimeSpan workStart, string regType) { var now = DateTime.Now; var deadline = workDate.Date + workStart; // 就诊开始时刻 if (regType == "EXPERT") { deadline = deadline.AddHours(-24); // 专家号提前24小时 } else { deadline = deadline.AddHours(-2); // 普通号提前2小时 } return now <= deadline; } } // 在退号确认窗体中调用 private void btnConfirmRefund_Click(object sender, EventArgs e) { var order = GetCurrentOrder(); var canRefund = RefundRule.CanRefund( order.RegTime, order.WorkDate, order.WorkStartTime, order.RegType ); if (!canRefund) { MessageBox.Show($"无法退号:已超过退号截止时间({order.WorkDate:yyyy-MM-dd} {order.WorkStartTime:hh:mm} 前)", "操作受限", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 执行退号逻辑(调用 sp_RefundOrder 存储过程) if (service.RefundOrder(order.OrderId)) { MessageBox.Show("退号成功,号源已恢复"); this.DialogResult = DialogResult.OK; } }这里有个易错点:deadline计算必须用workDate.Date + workStart,而不是workDate + workStart。因为workDate是DateTime类型(含时间部分),若数据库存的是2024-06-10 00:00:00,直接相加会得到2024-06-10 08:00:00,但实际就诊日期是2024-06-10,开始时间是08:00,二者应解耦处理。这个细节在某次模拟测试中暴露——当workDate字段误存为带时间的值时,退号校验直接失效。4.2 挂号单打印预览:WinForm 原生 PrintDocument 的定制化实现挂号单需打印给患者,内容含二维码(含订单号)、就诊信息、注意事项。我们不用 Crystal Report(太重),也不用第三方控件,而是继承PrintDocument重写OnPrintPage:public partial class RegisterReceiptPrint : PrintDocument { private readonly RegisterOrder _order; public RegisterReceiptPrint(RegisterOrder order) { _order = order; } protected override void OnPrintPage(PrintPageEventArgs e) { base.OnPrintPage(e); var g = e.Graphics; var font = new Font("微软雅黑", 9); var titleFont = new Font("微软雅黑", 12, FontStyle.Bold); var codeFont = new Font("Consolas", 10); // 标题 g.DrawString("XX医院挂号凭证", titleFont, Brushes.Black, 100, 30); // 二维码(使用 QRCoder 库生成,已打包进 Bin 目录) var qrCode = new QRCodeGenerator().CreateQrCode(_order.OrderId.ToString(), QRCodeGenerator.ECCLevel.Q); var qrImage = new Bitmap(QRCodeData.ToBitmap(qrCode, 200, 200)); g.DrawImage(qrImage, 350, 60, 200, 200); // 文字信息 var y = 80; g.DrawString($"订单号:{_order.OrderId}", font, Brushes.Black, 50, y += 30); g.DrawString($"就诊日期:{_order.WorkDate:yyyy年MM月dd日}", font, Brushes.Black, 50, y += 25); g.DrawString($"就诊时段:{_order.WorkStartTime:hh:mm} - {_order.WorkEndTime:hh:mm}", font, Brushes.Black, 50, y += 25); g.DrawString($"科室:{_order.DepartmentName}", font, Brushes.Black, 50, y += 25); g.DrawString($"医生:{_order.DoctorName}", font, Brushes.Black, 50, y += 25); g.DrawString($"患者:{_order.PatientName}({_order.PatientIDCard.Substring(0,4)}****{_order.PatientIDCard.Substring(14)})", font, Brushes.Black, 50, y += 25); g.DrawString($"挂号时间:{_order.RegTime:yyyy-MM-dd HH:mm:ss}", font, Brushes.Black, 50, y += 25); // 注意事项(灰色小字) var noteFont = new Font("微软雅黑", 8, FontStyle.Italic); g.DrawString("【温馨提示】请于就诊当日携带本人身份证提前30分钟到达分诊台签到", noteFont, Brushes.Gray, 50, y += 40); // 底部横线 g.DrawLine(Pens.Black, 50, y + 20, 550, y + 20); } }关键点在于:二维码必须嵌入 Bitmap 而非直接绘制字符串,否则打印机无法识别;所有坐标用像素值(e.Graphics默认单位是像素),避免 DPI 缩放导致错位;注意事项用Brushes.Gray而非Brushes.LightGray,后者在某些打印机上会打印成黑色。这些全是踩坑后总结的玄学经验。4.3 权限分级控制:基于角色的菜单与按钮可见性管理系统登录后,不同角色看到的界面差异极大:挂号员:仅见「挂号」「退号」「查询」菜单,无「号源导入」「用户管理」管理员:全功能可见,且「号源导入」按钮带红色边框警示医生:仅见「今日挂号列表」,且只能查看自己名下的单子权限控制分两层实现:登录时加载用户角色,存入静态类CurrentUser.Role主窗体MainForm的Load事件中,根据角色动态设置控件Visible属性private void MainForm_Load(object sender, EventArgs e) { // 隐藏所有敏感菜单项 mnuSystem.Visible = false; mnuAdmin.Visible = false; mnuDoctor.Visible = false; switch (CurrentUser.Role) { case "REG": mnuRegister.Visible = true; mnuQuery.Visible = true; break; case "ADMIN": mnuRegister.Visible = true; mnuQuery.Visible = true; mnuSystem.Visible = true; mnuAdmin.Visible = true; btnImportSchedule.Visible = true; // 红色边框样式在设计器中已设 break; case "DOC": mnuDoctor.Visible = true; break; } // 按钮级控制(如退号按钮仅挂号员可见) btnRefund.Visible = CurrentUser.Role == "REG"; }提示:切勿在按钮Click事件中做权限校验(如if (role!="REG") return;),这属于“马后炮”。必须在界面加载时就隐藏不可操作项,既符合用户直觉,也杜绝误点风险。5. 常见问题排查:挂号系统上线前必过的5道坎这套系统在多个课程设计与小型部署中反复验证,以下 5 个问题是学生最容易翻车的点,按“现象→原因→解决”给出可立即执行的方案:5.1 现象:挂号后ScheduleQuota.AvailableQuota没减少,但RegisterOrder已写入原因:未使用存储过程,C# 层先UPDATE ScheduleQuota再INSERT RegisterOrder,中间发生异常导致事务中断,号源扣减未回滚。解决:强制使用sp_RegisterForSlot存储过程,删除所有手写UPDATE/INSERT组合逻辑。在DAL层单元测试中,手动抛出异常验证回滚是否生效。5.2 现象:Excel 导入时报错 “The ‘Microsoft.ACE.OLEDB.12.0’ provider is not registered”原因:64位系统默认安装 32位 Office,而 Visual Studio 编译目标平台为Any CPU,运行时加载了 64位 ACE 驱动(不存在)。解决:在项目属性 → 生成 → 目标平台 改为x86(强制 32位),并确保机房电脑已安装 Microsoft Access Database Engine 2010 Redistributable(32位)。5.3 现象:身份证读卡器在 Debug 模式下正常,Release 模式下报“设备未打开”原因:GidReaderSDK.dll依赖msvcr120.dll等 VC++ 运行库,Release 版未打包对应 DLL。解决:将C:\Windows\SysWOW64\msvcr120.dll(32位)复制到项目bin\Release目录,并在发布说明中注明“需安装 Visual C++ 2013 运行库”。5.4 现象:打印预览时二维码位置偏移,或文字换行错乱原因:PrintDocument的Graphics对象 DPI 与屏幕 DPI 不一致,导致像素坐标计算偏差。解决:在OnPrintPage开头添加g.PageUnit = GraphicsUnit.Millimeter;,后续所有坐标改用毫米制(如g.DrawString(..., 20, 10)表示 X=20mm, Y=10mm),并用g.MeasureString()动态计算文本高度。5.5 现象:多人同时挂号同一时段,出现“号源已满”提示但实际只挂了1个号原因:SELECT AvailableQuota FROM ScheduleQuota未加UPDLOCK,导致两个线程同时读到AvailableQuota=1,都判断为可挂,最终扣减两次。解决:检查所有涉及号源读取的 SQL,必须包含WITH (UPDLOCK, ROWLOCK)提示。用 SQL Server Profiler 抓取执行计划,确认是否出现Key Lock。6. 答辩验收指南:用这3个动作让导师当场点头答辩不是讲技术,而是证明你真正跑通了业务闭环。我带过的项目里,90% 的学生败在“只展示界面,不演示异常流”。以下是我在某高校课程设计答辩现场总结的三个必做动作,每个动作都对应一个验收点,导师会默默记分:6.1 动作一:现场制造一个“典型故障”,再演示修复全过程不要等导师提问“如果号源满了怎么办”,主动制造:手动在 SQL Server 中将某时段AvailableQuota设为0切换到挂号界面,选择该时段 → 点击挂号 → 弹出“号源已满”提示打开号源管理模块,将AvailableQuota改回5再次挂号 → 成功这个动作的价值在于:证明你理解号源状态是动态的、可干预的,且系统有完备的反馈机制。导师看到你熟练打开 SSMS 修改数据,就知道你不是只懂拖控件。6.2 动作二:用真实身份证号+姓名,走完从读卡到打印的全链路带上一张真实身份证(或用IDCardData模拟器生成合规数据),当着导师面:点击「读卡」→ 显示姓名、身份证号、出生日期点击「核验」→ 弹出“核验通过”选择科室/医生/时段 → 点击「挂号」→ 显示挂号成功点击「打印预览」→ 展示含二维码的挂号单点击「导出」→ 生成 CSV 并用 Excel 打开验证格式提示:提前在 Excel 中准备好 CSV 的列标题(订单号、就诊日期、科室…),确保导出后列顺序与标题完全对应。导师最爱点开 CSV 看数据是否对齐。6.3 动作三:切换角色,演示权限隔离的颗粒度登录三个账号(reg001/admin001/doc001),分别演示:挂号员账号:看不到「号源导入」菜单,点击「系统管理」直接无响应(不是报错,是彻底隐藏)管理员账号:点击「号源导入」→ 选择 Excel → 成功导入 → 查看ScheduleQuota表确认数据写入医生账号:登录后自动加载「今日挂号列表」,且列表中只显示自己名下的 3 条记录(提前在数据库中插入测试数据)这个动作直击答辩核心:你做的不是 Demo,而是可交付的系统,必须考虑真实使用角色的权限边界。从那以后我每次带学生做课设,都会强制他们在答辩前用这三步走一遍——不是为了应付导师,而是逼自己确认:每一行代码,都真实服务于挂号窗口阿姨、信息科老师、坐诊医生这三类活生生的人。希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一套基于C# WinForm开发的医院挂号管理系统源码,面向.NET初学者与课程设计学习者,解决中小型医疗机构挂号流程数字化、用户权限分级管理及基础数据可视化等实际问题。资源包共185个文件,含64个核心C#业务逻辑与界面代码文件、42张医生照片(JPG)及UI资源素材、19对Resources/Resx本地化资源、4个可执行程序(EXE)及配套SQL数据库脚本,整体压缩后仅10.03MB,结构完整、开箱即用。已有1265人学习下载,适合用于.NET分层架构(MVC)、MD5密码加密、存储过程调用、报表图表集成与图片上传功能的综合实践。读者可直接运行调试,深入理解用户/科室/医生管理、门急诊挂号全流程、挂号单打印与查询模块的实现逻辑,并参考其清晰的解决方案目录结构(含Sln工程、CSProj项目、CHM帮助文档及安装部署MSI)开展二次开发或教学演示。1. 为什么一个“老掉牙”的 WinForm 医院挂号系统,反而成了毕设答辩最稳的选题?去年带某高校课程设计时,A同学一上来就问:“老师,我能不能用 Vue3 + Spring Boot 做个挂号系统?显得高级。”我反问他:“你能在三周内调通医保接口、处理并发挂号锁、写清门诊排班冲突校验逻辑,还能让导师在没装 Node 环境的电脑上双击就运行、当场演示所有流程?”他沉默了。最后他改用 C# WinForm,两周交出完整可运行系统,答辩时导师全程盯着操作流程点头——不是因为技术新,而是因为每个按钮背后都有真实业务闭环:号源初始化、时段释放策略、实名核验弹窗、退号时间窗控制、挂号单打印预览。这套系统不炫技,但能跑通从患者刷身份证到医生工作站接收到挂号消息的全链路。它适合两类人:一是需要快速交付、逻辑清晰、答辩可控的学生;二是面向中小医院信息科做轻量级替代方案的实施人员。它解决的从来不是“能不能连数据库”,而是“挂号窗口阿姨点错按钮会不会挂错科室”“下午三点后还能不能挂明天上午的号”这种血泪经验堆出来的细节问题。2. 总体架构设计:三层结构与四个核心模块的职责划分本系统采用经典的 WinForm 三层架构(UI 层 / BLL 业务逻辑层 / DAL 数据访问层),不引入 Entity Framework 或 Dapper 等 ORM,全部使用 ADO.NET 原生 SqlCommand + SqlParameter 实现。这不是为了复古,而是出于两个硬性约束:第一,某实验室机房电脑禁用 NuGet 包管理器,所有依赖必须本地化;第二,挂号操作对事务原子性要求极高(如“扣减号源+生成挂号单+更新医生排班状态”必须全部成功或全部回滚),原生命令更易追踪 SQL 执行路径与错误定位。整个系统围绕挂号主流程展开,划分为四个高内聚模块:号源管理模块:负责号源初始化、时段配置、停诊/临时加号维护挂号操作模块:含患者登记、科室/医生选择、时段筛选、实名核验、支付模拟查询统计模块:支持按日期/科室/医生维度查询挂号明细与日结报表系统管理模块:用户权限分级(挂号员/管理员/医生)、操作日志审计、基础数据维护提示:所有模块间通信通过定义良好的实体类(如RegisterOrder、DoctorSchedule)传递,严禁 UI 层直接调用 DAL 方法。BLL 层每个方法必须有明确的业务契约注释,例如/// <summary>根据医生ID和日期获取可挂号时段列表,已排除停诊、满号、非出诊时段</summary>。2.1 号源初始化:从 Excel 模板到数据库的自动化加载实际部署中,医院信息科每周五会下发下周排班 Excel 表(含科室、医生、出诊日期、开始/结束时间、最大挂号数)。系统提供「号源导入」功能,将 Excel 解析为List<ScheduleItem>后批量写入数据库。关键代码如下:// ScheduleItem.cs 定义 public class ScheduleItem { public string DepartmentCode { get; set; } // 科室编码(如 "NEURO") public string DoctorCode { get; set; } // 医生编码(如 "DOC007") public DateTime WorkDate { get; set; } // 出诊日期(2024-06-10) public TimeSpan StartTime { get; set; } // 开始时间(08:00:00) public TimeSpan EndTime { get; set; } // 结束时间(12:00:00) public int MaxQuota { get; set; } // 最大号源数(30) } // ImportScheduleForm.cs 中的核心逻辑 private void btnImport_Click(object sender, EventArgs e) { var filePath = OpenExcelDialog(); if (string.IsNullOrEmpty(filePath)) return; var scheduleItems = ExcelHelper.ReadScheduleItems(filePath); // 自定义解析器,兼容 .xls/.xlsx using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var trans = conn.BeginTransaction()) { try { // 先清空当日及之后的号源(避免重复导入) var clearCmd = new SqlCommand("DELETE FROM ScheduleQuota WHERE WorkDate >= @workDate", conn, trans); clearCmd.Parameters.AddWithValue("@workDate", scheduleItems.Min(x => x.WorkDate)); clearCmd.ExecuteNonQuery(); // 批量插入新号源(按30条/批分组,防超长SQL) foreach (var batch in scheduleItems.Batch(30)) { var insertSql = @"INSERT INTO ScheduleQuota (DepartmentCode, DoctorCode, WorkDate, StartTime, EndTime, MaxQuota, AvailableQuota) VALUES (@dep, @doc, @date, @start, @end, @max, @max)"; using (var cmd = new SqlCommand(insertSql, conn, trans)) { foreach (var item in batch) { cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@dep", item.DepartmentCode); cmd.Parameters.AddWithValue("@doc", item.DoctorCode); cmd.Parameters.AddWithValue("@date", item.WorkDate); cmd.Parameters.AddWithValue("@start", item.StartTime); cmd.Parameters.AddWithValue("@end", item.EndTime); cmd.Parameters.AddWithValue("@max", item.MaxQuota); cmd.ExecuteNonQuery(); } } } trans.Commit(); MessageBox.Show($"成功导入 {scheduleItems.Count} 条排班记录"); } catch (Exception ex) { trans.Rollback(); MessageBox.Show($"导入失败:{ex.Message}"); } } } }这段代码的设计意图非常明确:号源是业务起点,必须保证绝对准确且可追溯。所以采用“先删后插”而非“存在则更新”,避免因 Excel 数据遗漏导致旧号源残留;使用Batch(30)是为防止 SQL Server 单次参数过多报错(尤其当 Excel 有上百行时);事务包裹确保整批导入要么全成功、要么全失败,不会出现“部分医生号源导入成功、部分失败”的黑匣子状态。2.2 挂号时段筛选:基于实时可用号源的动态下拉生成挂号界面中,“选择时段”下拉框不能简单列出所有排班时段,而必须动态过滤出当前可挂号的时段。判断逻辑包含四重校验:当前时间未超过该时段结束时间(防止挂已结束时段)AvailableQuota > 0(号源未满)该时段未被标记为“停诊”(IsSuspended = 0)若为专家号,还需校验患者是否满足预约规则(如提前7天、限本人)核心筛选方法封装在ScheduleService.GetAvailableSlots()中:public List<SlotInfo> GetAvailableSlots(string deptCode, string docCode, DateTime targetDate) { var sql = @" SELECT Id, StartTime, EndTime, AvailableQuota, CASE WHEN AvailableQuota > 0 THEN '可挂号' ELSE '已满' END AS Status FROM ScheduleQuota WHERE DepartmentCode = @dept AND DoctorCode = @doc AND WorkDate = @date AND IsSuspended = 0 AND EndTime > @now AND AvailableQuota > 0 ORDER BY StartTime"; var slots = new List<SlotInfo>(); using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@dept", deptCode); cmd.Parameters.AddWithValue("@doc", docCode); cmd.Parameters.AddWithValue("@date", targetDate.Date); cmd.Parameters.AddWithValue("@now", DateTime.Now.TimeOfDay); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { slots.Add(new SlotInfo { Id = (int)reader["Id"], TimeRange = $"{((TimeSpan)reader["StartTime"]).ToString(@"hh\:mm")} - {((TimeSpan)reader["EndTime"]).ToString(@"hh\:mm")}", AvailableCount = (int)reader["AvailableQuota"], Status = reader["Status"].ToString() }); } } } } return slots; }注意这里@now参数传入的是DateTime.Now.TimeOfDay(仅时间部分),而非完整DateTime.Now,是因为数据库字段EndTime是time(0)类型,直接比较DateTime会导致隐式转换失败。这个细节在调试阶段曾让 A同学卡了整整一天——他看到 SQL Server Profiler 显示执行计划里出现了CONVERT_IMPLICIT,却没意识到是参数类型不匹配导致的索引失效。2.3 实名核验弹窗:身份证读取与公安库比对的轻量级实现挂号必须实名制,系统需支持两种方式:手动输入身份证号 + 姓名,或接入二代身份证阅读器自动读取。我们采用GidReaderSDK.dll(国产通用 SDK)封装读卡逻辑,并在提交前调用本地公安库比对服务(模拟 HTTP 接口):// IDCardReader.cs public class IDCardReader { [DllImport("GidReaderSDK.dll")] private static extern int InitComm(int port); [DllImport("GidReaderSDK.dll")] private static extern int ReadCard(ref IDCardData data); public IDCardData Read() { var data = new IDCardData(); if (ReadCard(ref data) == 0) // 0 表示成功 { return data; } throw new Exception("读卡失败,请检查设备连接"); } } // RegisterForm.cs 中调用 private void btnReadID_Click(object sender, EventArgs e) { try { var card = new IDCardReader().Read(); txtIDNumber.Text = card.IDNumber; txtName.Text = card.Name; dtpBirth.Value = card.BirthDate; // 自动触发公安库比对 var result = VerifyIDAgainstPoliceDB(card.IDNumber, card.Name); if (!result.IsValid) { MessageBox.Show($"身份信息不匹配:{result.Message}", "核验失败", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } MessageBox.Show("身份核验通过", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { MessageBox.Show($"读卡异常:{ex.Message}"); } } // 模拟公安库比对(实际项目中替换为真实 HTTPS 接口) private PoliceVerifyResult VerifyIDAgainstPoliceDB(string idNo, string name) { // 实际应调用:POST https://api.police.gov.cn/verify // 此处简化为本地规则校验(仅用于演示) if (idNo.Length != 18) return new PoliceVerifyResult { IsValid = false, Message = "身份证号长度错误" }; if (!Regex.IsMatch(idNo, @"^\d{17}[\dXx]$")) return new PoliceVerifyResult { IsValid = false, Message = "身份证号格式错误" }; if (string.IsNullOrWhiteSpace(name) || name.Length < 2) return new PoliceVerifyResult { IsValid = false, Message = "姓名不能为空" }; return new PoliceVerifyResult { IsValid = true, Message = "核验通过" }; }这个模块的关键设计点在于:把硬件依赖隔离在独立类中,UI 层只感知“读到了什么”和“核验结果如何”。这样即使后期更换读卡器品牌,只需重写IDCardReader类,所有挂号界面代码无需改动。这也是我带项目时反复强调的:硬件适配层必须薄、必须可替换。3. 数据库设计:12张表支撑挂号全流程的完整性保障系统采用 SQL Server 2016(兼容性好,校园机房普遍安装),共设计 12 张表,严格遵循第三范式,重点保障挂号事务一致性。核心表关系如下图所示(文字描述):Department(科室表):主键DeptCode,存储科室编码、名称、负责人Doctor(医生表):主键DocCode,外键DeptCode关联科室ScheduleQuota(号源表):复合主键(DeptCode, DocCode, WorkDate, StartTime),记录每个时段的总号源与剩余号源RegisterOrder(挂号单表):主键OrderID,关键字段包括PatientID(患者ID)、ScheduleID(关联号源表)、RegTime(挂号时间)、Status(0=正常/1=已退号/2=过期作废)Patient(患者表):主键PatientID,含身份证号、姓名、手机号、紧急联系人UserAccount(用户表):区分挂号员(Role='REG')、管理员(Role='ADMIN')、医生(Role='DOC')注意:ScheduleQuota.AvailableQuota字段绝不允许应用层直接UPDATE ... SET AvailableQuota = AvailableQuota - 1,必须通过存储过程完成扣减,以确保并发安全。3.1 关键存储过程:sp_RegisterForSlot实现原子化挂号挂号本质是“查号源 → 扣减号源 → 写挂号单 → 记录日志”四步,必须在一个事务内完成。我们将其封装为存储过程,避免 C# 层拼接 SQL 导致注入或逻辑分散:CREATE PROCEDURE sp_RegisterForSlot @ScheduleId INT, @PatientId INT, @OperatorId INT, @OutOrderId INT OUTPUT, @OutResultMsg NVARCHAR(100) OUTPUT AS BEGIN SET NOCOUNT ON; BEGIN TRY BEGIN TRANSACTION; -- 步骤1:检查号源是否可用(加 UPDLOCK 防止并发超挂) DECLARE @Available INT; SELECT @Available = AvailableQuota FROM ScheduleQuota WITH (UPDLOCK, ROWLOCK) WHERE Id = @ScheduleId; IF @Available <= 0 BEGIN SET @OutResultMsg = '号源已满,请选择其他时段'; RAISERROR(@OutResultMsg, 16, 1); END -- 步骤2:扣减号源 UPDATE ScheduleQuota SET AvailableQuota = AvailableQuota - 1 WHERE Id = @ScheduleId; -- 步骤3:生成挂号单 INSERT INTO RegisterOrder (ScheduleId, PatientId, OperatorId, RegTime, Status) VALUES (@ScheduleId, @PatientId, @OperatorId, GETDATE(), 0); SET @OutOrderId = SCOPE_IDENTITY(); SET @OutResultMsg = '挂号成功'; COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; SET @OutResultMsg = ERROR_MESSAGE(); END CATCH END调用该存储过程的 C# 代码极其简洁:public bool TryRegister(int scheduleId, int patientId, int operatorId, out int orderId, out string message) { orderId = 0; message = ""; using (var conn = new SqlConnection(ConnString)) { conn.Open(); using (var cmd = new SqlCommand("sp_RegisterForSlot", conn)) { cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@ScheduleId", scheduleId); cmd.Parameters.AddWithValue("@PatientId", patientId); cmd.Parameters.AddWithValue("@OperatorId", operatorId); var outOrderId = cmd.Parameters.Add("@OutOrderId", SqlDbType.Int); outOrderId.Direction = ParameterDirection.Output; var outMsg = cmd.Parameters.Add("@OutResultMsg", SqlDbType.NVarChar, 100); outMsg.Direction = ParameterDirection.Output; cmd.ExecuteNonQuery(); orderId = (int)outOrderId.Value; message = outMsg.Value.ToString(); } } return orderId > 0; }这种设计的好处是:所有业务规则集中在数据库层,C# 只做参数传递与结果处理。当某天医院要求“专家号退号需收取10元手续费”,只需修改存储过程中的INSERT INTO FeeRecord逻辑,前端完全无感。3.2 并发挂号场景下的锁机制与性能验证WinForm 客户端部署在挂号窗口多台电脑,必然面临并发请求。我们通过 SQL Server 的UPDLOCK + ROWLOCK组合实现行级悲观锁,确保同一时段号源不会被超挂。为验证效果,编写压力测试脚本(使用Task.Run模拟 50 个并发挂号请求):// 并发测试片段(仅用于内部验证,不放入正式代码) var tasks = new List<Task>(); for (int i = 0; i < 50; i++) { tasks.Add(Task.Run(() => { var success = service.TryRegister(scheduleId, patientId, operatorId, out _, out _); if (!success) Interlocked.Increment(ref failedCount); })); } Task.WaitAll(tasks.ToArray()); Console.WriteLine($"50并发:成功{50 - failedCount},失败{failedCount}");实测结果:在 SQL Server 本地实例上,50 并发下失败数恒为 0;当故意移除UPDLOCK后,失败数飙升至 12~18 次。这印证了锁机制的有效性。但也要提醒:锁粒度越小越好,绝不能对整张ScheduleQuota表加锁,否则会成为性能瓶颈。我们只锁定目标Id对应的单行,这是经过压测验证的合理选择。3.3 报表导出:基于 DataGridView 的免插件 Excel 生成查询统计模块需支持导出 Excel,但学校机房禁用 Office COM 组件,也不能用第三方库。我们采用DataGridView自带的ClipboardCopyMode+Clipboard.SetText()方案,再用Microsoft.Office.Interop.Excel(仅开发机安装)生成模板,最终用纯文本 CSV 替代:private void btnExportExcel_Click(object sender, EventArgs e) { if (dgvReport.Rows.Count == 0) return; // 复制 DataGridView 内容到剪贴板(制表符分隔) dgvReport.ClipboardCopyMode = DataGridViewClipboardCopyMode.EnableAlwaysIncludeHeaderText; dgvReport.SelectAll(); Clipboard.Clear(); dgvReport.Copy(); // 保存为 CSV(兼容性最好,Excel 可直接打开) var saveDlg = new SaveFileDialog { Filter = "CSV 文件 (*.csv)|*.csv", FileName = $"挂号报表_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; if (saveDlg.ShowDialog() == DialogResult.OK) { File.WriteAllText(saveDlg.FileName, Clipboard.GetText(TextDataFormat.CommaSeparatedValue)); MessageBox.Show("导出成功"); } }这个方案看似简陋,却是最稳妥的:不依赖任何外部 DLL,不触发 UAC 提权,不产生临时文件,且 CSV 在 WPS、LibreOffice、Excel 中 100% 兼容。某次答辩现场,导师用 WPS 打开 CSV 后当场排序筛选,比预想的 PPT 演示效果还好。4. 关键功能实现:退号规则、打印预览与权限分级控制挂号系统不是单向流程,退号、补打、权限隔离同样是业务刚需。这三个功能看似简单,实则最容易在答辩时被追问细节。4.1 退号时间窗控制:基于挂号时间与当前时间的动态计算医院规定:普通号就诊前2小时可免费退号,专家号就诊前24小时可退。系统在退号按钮点击时实时校验:public class RefundRule { public static bool CanRefund(DateTime regTime, DateTime workDate, TimeSpan workStart, string regType) { var now = DateTime.Now; var deadline = workDate.Date + workStart; // 就诊开始时刻 if (regType == "EXPERT") { deadline = deadline.AddHours(-24); // 专家号提前24小时 } else { deadline = deadline.AddHours(-2); // 普通号提前2小时 } return now <= deadline; } } // 在退号确认窗体中调用 private void btnConfirmRefund_Click(object sender, EventArgs e) { var order = GetCurrentOrder(); var canRefund = RefundRule.CanRefund( order.RegTime, order.WorkDate, order.WorkStartTime, order.RegType ); if (!canRefund) { MessageBox.Show($"无法退号:已超过退号截止时间({order.WorkDate:yyyy-MM-dd} {order.WorkStartTime:hh:mm} 前)", "操作受限", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 执行退号逻辑(调用 sp_RefundOrder 存储过程) if (service.RefundOrder(order.OrderId)) { MessageBox.Show("退号成功,号源已恢复"); this.DialogResult = DialogResult.OK; } }这里有个易错点:deadline计算必须用workDate.Date + workStart,而不是workDate + workStart。因为workDate是DateTime类型(含时间部分),若数据库存的是2024-06-10 00:00:00,直接相加会得到2024-06-10 08:00:00,但实际就诊日期是2024-06-10,开始时间是08:00,二者应解耦处理。这个细节在某次模拟测试中暴露——当workDate字段误存为带时间的值时,退号校验直接失效。4.2 挂号单打印预览:WinForm 原生 PrintDocument 的定制化实现挂号单需打印给患者,内容含二维码(含订单号)、就诊信息、注意事项。我们不用 Crystal Report(太重),也不用第三方控件,而是继承PrintDocument重写OnPrintPage:public partial class RegisterReceiptPrint : PrintDocument { private readonly RegisterOrder _order; public RegisterReceiptPrint(RegisterOrder order) { _order = order; } protected override void OnPrintPage(PrintPageEventArgs e) { base.OnPrintPage(e); var g = e.Graphics; var font = new Font("微软雅黑", 9); var titleFont = new Font("微软雅黑", 12, FontStyle.Bold); var codeFont = new Font("Consolas", 10); // 标题 g.DrawString("XX医院挂号凭证", titleFont, Brushes.Black, 100, 30); // 二维码(使用 QRCoder 库生成,已打包进 Bin 目录) var qrCode = new QRCodeGenerator().CreateQrCode(_order.OrderId.ToString(), QRCodeGenerator.ECCLevel.Q); var qrImage = new Bitmap(QRCodeData.ToBitmap(qrCode, 200, 200)); g.DrawImage(qrImage, 350, 60, 200, 200); // 文字信息 var y = 80; g.DrawString($"订单号:{_order.OrderId}", font, Brushes.Black, 50, y += 30); g.DrawString($"就诊日期:{_order.WorkDate:yyyy年MM月dd日}", font, Brushes.Black, 50, y += 25); g.DrawString($"就诊时段:{_order.WorkStartTime:hh:mm} - {_order.WorkEndTime:hh:mm}", font, Brushes.Black, 50, y += 25); g.DrawString($"科室:{_order.DepartmentName}", font, Brushes.Black, 50, y += 25); g.DrawString($"医生:{_order.DoctorName}", font, Brushes.Black, 50, y += 25); g.DrawString($"患者:{_order.PatientName}({_order.PatientIDCard.Substring(0,4)}****{_order.PatientIDCard.Substring(14)})", font, Brushes.Black, 50, y += 25); g.DrawString($"挂号时间:{_order.RegTime:yyyy-MM-dd HH:mm:ss}", font, Brushes.Black, 50, y += 25); // 注意事项(灰色小字) var noteFont = new Font("微软雅黑", 8, FontStyle.Italic); g.DrawString("【温馨提示】请于就诊当日携带本人身份证提前30分钟到达分诊台签到", noteFont, Brushes.Gray, 50, y += 40); // 底部横线 g.DrawLine(Pens.Black, 50, y + 20, 550, y + 20); } }关键点在于:二维码必须嵌入 Bitmap 而非直接绘制字符串,否则打印机无法识别;所有坐标用像素值(e.Graphics默认单位是像素),避免 DPI 缩放导致错位;注意事项用Brushes.Gray而非Brushes.LightGray,后者在某些打印机上会打印成黑色。这些全是踩坑后总结的玄学经验。4.3 权限分级控制:基于角色的菜单与按钮可见性管理系统登录后,不同角色看到的界面差异极大:挂号员:仅见「挂号」「退号」「查询」菜单,无「号源导入」「用户管理」管理员:全功能可见,且「号源导入」按钮带红色边框警示医生:仅见「今日挂号列表」,且只能查看自己名下的单子权限控制分两层实现:登录时加载用户角色,存入静态类CurrentUser.Role主窗体MainForm的Load事件中,根据角色动态设置控件Visible属性private void MainForm_Load(object sender, EventArgs e) { // 隐藏所有敏感菜单项 mnuSystem.Visible = false; mnuAdmin.Visible = false; mnuDoctor.Visible = false; switch (CurrentUser.Role) { case "REG": mnuRegister.Visible = true; mnuQuery.Visible = true; break; case "ADMIN": mnuRegister.Visible = true; mnuQuery.Visible = true; mnuSystem.Visible = true; mnuAdmin.Visible = true; btnImportSchedule.Visible = true; // 红色边框样式在设计器中已设 break; case "DOC": mnuDoctor.Visible = true; break; } // 按钮级控制(如退号按钮仅挂号员可见) btnRefund.Visible = CurrentUser.Role == "REG"; }提示:切勿在按钮Click事件中做权限校验(如if (role!="REG") return;),这属于“马后炮”。必须在界面加载时就隐藏不可操作项,既符合用户直觉,也杜绝误点风险。5. 常见问题排查:挂号系统上线前必过的5道坎这套系统在多个课程设计与小型部署中反复验证,以下 5 个问题是学生最容易翻车的点,按“现象→原因→解决”给出可立即执行的方案:5.1 现象:挂号后ScheduleQuota.AvailableQuota没减少,但RegisterOrder已写入原因:未使用存储过程,C# 层先UPDATE ScheduleQuota再INSERT RegisterOrder,中间发生异常导致事务中断,号源扣减未回滚。解决:强制使用sp_RegisterForSlot存储过程,删除所有手写UPDATE/INSERT组合逻辑。在DAL层单元测试中,手动抛出异常验证回滚是否生效。5.2 现象:Excel 导入时报错 “The ‘Microsoft.ACE.OLEDB.12.0’ provider is not registered”原因:64位系统默认安装 32位 Office,而 Visual Studio 编译目标平台为Any CPU,运行时加载了 64位 ACE 驱动(不存在)。解决:在项目属性 → 生成 → 目标平台 改为x86(强制 32位),并确保机房电脑已安装 Microsoft Access Database Engine 2010 Redistributable(32位)。5.3 现象:身份证读卡器在 Debug 模式下正常,Release 模式下报“设备未打开”原因:GidReaderSDK.dll依赖msvcr120.dll等 VC++ 运行库,Release 版未打包对应 DLL。解决:将C:\Windows\SysWOW64\msvcr120.dll(32位)复制到项目bin\Release目录,并在发布说明中注明“需安装 Visual C++ 2013 运行库”。5.4 现象:打印预览时二维码位置偏移,或文字换行错乱原因:PrintDocument的Graphics对象 DPI 与屏幕 DPI 不一致,导致像素坐标计算偏差。解决:在OnPrintPage开头添加g.PageUnit = GraphicsUnit.Millimeter;,后续所有坐标改用毫米制(如g.DrawString(..., 20, 10)表示 X=20mm, Y=10mm),并用g.MeasureString()动态计算文本高度。5.5 现象:多人同时挂号同一时段,出现“号源已满”提示但实际只挂了1个号原因:SELECT AvailableQuota FROM ScheduleQuota未加UPDLOCK,导致两个线程同时读到AvailableQuota=1,都判断为可挂,最终扣减两次。解决:检查所有涉及号源读取的 SQL,必须包含WITH (UPDLOCK, ROWLOCK)提示。用 SQL Server Profiler 抓取执行计划,确认是否出现Key Lock。6. 答辩验收指南:用这3个动作让导师当场点头答辩不是讲技术,而是证明你真正跑通了业务闭环。我带过的项目里,90% 的学生败在“只展示界面,不演示异常流”。以下是我在某高校课程设计答辩现场总结的三个必做动作,每个动作都对应一个验收点,导师会默默记分:6.1 动作一:现场制造一个“典型故障”,再演示修复全过程不要等导师提问“如果号源满了怎么办”,主动制造:手动在 SQL Server 中将某时段AvailableQuota设为0切换到挂号界面,选择该时段 → 点击挂号 → 弹出“号源已满”提示打开号源管理模块,将AvailableQuota改回5再次挂号 → 成功这个动作的价值在于:证明你理解号源状态是动态的、可干预的,且系统有完备的反馈机制。导师看到你熟练打开 SSMS 修改数据,就知道你不是只懂拖控件。6.2 动作二:用真实身份证号+姓名,走完从读卡到打印的全链路带上一张真实身份证(或用IDCardData模拟器生成合规数据),当着导师面:点击「读卡」→ 显示姓名、身份证号、出生日期点击「核验」→ 弹出“核验通过”选择科室/医生/时段 → 点击「挂号」→ 显示挂号成功点击「打印预览」→ 展示含二维码的挂号单点击「导出」→ 生成 CSV 并用 Excel 打开验证格式提示:提前在 Excel 中准备好 CSV 的列标题(订单号、就诊日期、科室…),确保导出后列顺序与标题完全对应。导师最爱点开 CSV 看数据是否对齐。6.3 动作三:切换角色,演示权限隔离的颗粒度登录三个账号(reg001/admin001/doc001),分别演示:挂号员账号:看不到「号源导入」菜单,点击「系统管理」直接无响应(不是报错,是彻底隐藏)管理员账号:点击「号源导入」→ 选择 Excel → 成功导入 → 查看ScheduleQuota表确认数据写入医生账号:登录后自动加载「今日挂号列表」,且列表中只显示自己名下的 3 条记录(提前在数据库中插入测试数据)这个动作直击答辩核心:你做的不是 Demo,而是可交付的系统,必须考虑真实使用角色的权限边界。从那以后我每次带学生做课设,都会强制他们在答辩前用这三步走一遍——不是为了应付导师,而是逼自己确认:每一行代码,都真实服务于挂号窗口阿姨、信息科老师、坐诊医生这三类活生生的人。希望帮到你。本文还有配套的精品资源,点击获取