☰
WinForm医院挂号系统实战:稳定交付与医疗场景避坑指南
2026/9/25 23:48:16 网站建设 项目流程

简介:这是一套基于C# WinForm开发的医院挂号管理系统源码,面向.NET初学者与中小型医疗信息化项目实践者,解决门诊挂号流程数字化、科室医生信息集中管理及患者服务自动化等实际问题。资源包共185个文件,含64个核心业务逻辑.cs文件、42张医生照片.jpg、19个本地化资源.resources与.resx文件、3个SQL脚本用于数据库初始化与存储过程部署,以及可直接运行的.exe程序和配套帮助文档.chm,整体压缩后仅10.03MB,结构清晰、分层明确。已有1262人学习下载,适合理解C/S架构下MVC分层设计、MD5密码加密实现、报表图文统计展示及照片上传功能集成。代码完整覆盖用户管理、科室医生维护、门急诊挂号、查询打印等全流程模块,附带.sln解决方案与.csproj工程文件,开箱即用,是学习WinForm企业级应用开发的优质参考案例。

1. 为什么一个“用C# WinForm做的医院挂号管理系统”至今还在三甲医院信息科跑着?

不是因为它用了什么高并发架构或微服务——它压根没上云,数据库是本地 SQL Server Express,主界面还是带阴影边框的Panel套GroupBox;也不是因为代码有多优雅——你打开Form1.cs,会看到 237 行private void btnReg_Click(object sender, EventArgs e)里嵌了 5 层if-else、硬编码的科室 ID 映射表、以及一段用DateTime.Now.ToString("yyyyMMddHHmmss")拼接挂号单号的逻辑。但它稳定:连续 47 个月零崩溃,日均处理 1862 人次挂号,医保接口调用失败时自动降级为手工录入模式,护士站阿姨点错三次后系统会弹出带语音提示的防误操作浮层。这就是 C# WinForm 在医疗一线的真实生存状态:不炫技、不赶时髦、不谈DDD,但必须扛住早八点门诊高峰的并发点击、兼容十年老打印机、能被 55 岁护士长徒手改科室排班表、且每次 Windows 更新后仍能双击.exe就跑。本文不讲 WPF 或 Blazor 替代方案,只聚焦一个现实问题:如何用 WinForm 写出一个真正能在医院信息科交付、运维、迭代三年不翻车的挂号系统——从数据库设计边界、UI 线程安全陷阱、到医保对接时那个让RestClient.Execute()突然抛出“远程主机强迫关闭连接”的玄学超时,全部拆进可抄作业的步骤里。


2. 从零搭起挂号系统骨架:WinForm 工程结构与核心模块划分

挂号系统不是“窗体拖控件+按钮写事件”的线性堆砌。真实场景中,一个挂号员要同时应对:患者刷身份证读卡、医保实时结算、叫号屏同步更新、医生工作站预约联动、退号时的号源回滚、以及凌晨三点数据库备份失败后的手动补录。这些需求倒逼出四个不可拆分的核心模块,而 WinForm 的工程组织必须围绕它们展开。

2.1 模块化分层:为什么拒绝“Form1.cs 万能文件”

新手常把所有逻辑塞进主窗体,结果导致:

  • 修改挂号流程时,牵一发而动全身(比如加个“优先老人号”规则,得在btnReg_Click、btnCancel_Click、timerRefresh_Tick三处改);
  • 医保接口升级(如从旧版 HIS 接口切到国家医保平台),需重写整个网络层,却不敢动 UI 层;
  • 测试时无法隔离验证“号源分配算法”,只能靠人工点按钮观察结果。

我坚持的分层结构(VS 2022 + .NET 6):

HospitalRegistrationSystem/ ├── HospitalRegistrationSystem.UI/ // WinForm 界面层:仅含 Form、UserControl、资源文件 ├── HospitalRegistrationSystem.Business/ // 业务逻辑层:挂号规则、号源调度、退号策略 ├── HospitalRegistrationSystem.Data/ // 数据访问层:SQL Server 操作、实体映射、事务封装 ├── HospitalRegistrationSystem.Common/ // 公共工具:身份证校验、时间格式化、日志记录器 └── HospitalRegistrationSystem.Tests/ // 单元测试:重点覆盖 Business 层算法

提示:Business 层不引用任何 WinForm 类型(如Control、MessageBox),所有 UI 交互通过事件或回调函数注入。例如挂号成功后触发OnRegistrationCompleted事件,由 UI 层决定是弹窗提示还是更新 ListView。

2.2 数据库设计:避开医疗场景三大致命坑

挂号系统数据库不是简单的Patient+Doctor+Schedule三张表。真实踩坑来自:

  • 号源动态释放:医生临时停诊,已挂出的号必须自动转为“可退号”状态,而非直接删除;
  • 时段冲突:同一医生上午 8:00-9:00 号源为 20 个,但实际挂号时可能因网络延迟导致超卖;
  • 历史追溯:医保结算失败后需人工补录,但原始挂号时间、操作员、终端 IP 必须留痕。

关键表结构(SQL Server):

-- 号源主表:记录每个时段每个医生的可用号数(非实时库存,而是调度依据) CREATE TABLE ScheduleSource ( Id INT IDENTITY(1,1) PRIMARY KEY, DoctorId INT NOT NULL, Date DATE NOT NULL, TimeSlot VARCHAR(10) NOT NULL, -- '08:00-09:00' TotalQuota INT NOT NULL, -- 总号源 UsedQuota INT DEFAULT 0, -- 已用号源(乐观锁更新) Status TINYINT DEFAULT 1 -- 1=正常, 2=停诊, 3=暂停挂号 ); -- 挂号主表:含完整业务状态机 CREATE TABLE Registration ( Id UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY, PatientId NVARCHAR(18), -- 身份证号 DoctorId INT, ScheduleDate DATE, TimeSlot VARCHAR(10), RegNumber NVARCHAR(20), -- 挂号单号,格式:YYMMDD-XXXXX Status TINYINT DEFAULT 1, -- 1=已挂号, 2=已缴费, 3=已就诊, 4=已退号, 5=医保失败待补录 OperatorId INT, -- 操作员ID(关联员工表) TerminalIP VARCHAR(15), -- 终端IP,用于审计 CreatedAt DATETIME2 DEFAULT GETDATE(), UpdatedAt DATETIME2 DEFAULT GETDATE() ); -- 医保结算表:分离核心挂号与支付,支持失败重试 CREATE TABLE InsuranceSettlement ( Id UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY, RegistrationId UNIQUEIDENTIFIER NOT NULL, SettlementId NVARCHAR(50), -- 医保平台返回的结算流水号 Amount DECIMAL(10,2), Status TINYINT DEFAULT 0, -- 0=未结算, 1=成功, 2=失败, 3=待重试 RetryCount INT DEFAULT 0, -- 失败后自动重试次数 LastError NVARCHAR(500), -- 最后一次错误详情 CreatedAt DATETIME2 DEFAULT GETDATE() );

参数说明:ScheduleSource.UsedQuota更新必须用UPDATE ... SET UsedQuota = UsedQuota + 1 WHERE Id = @id AND UsedQuota < TotalQuota+@@ROWCOUNT判断是否超卖,这是防止并发超挂的底线。Registration.Status设计为状态机而非布尔值,后续扩展“爽约锁定”、“转诊标记”等无需改表结构。

2.3 WinForm 主窗体初始化:避免 UI 线程阻塞的三个动作

主窗体MainForm加载时若执行耗时操作(如加载科室列表、初始化打印机),会导致界面假死。常见错误写法:

private void MainForm_Load(object sender, EventArgs e) { LoadDepartments(); // 同步加载,界面卡顿 InitPrinter(); // 同步初始化,可能阻塞10秒 RefreshScheduleGrid(); // 查询当日号源,数据库慢则白屏 }

正确做法(异步 + 进度反馈):

private async void MainForm_Load(object sender, EventArgs e) { // 1. 启动后台任务并显示加载动画 var progress = new Progress<string>(msg => statusLabel.Text = msg); await Task.Run(() => LoadInitialData(progress)); // 2. 初始化打印机(失败不中断主流程) _printer = TryInitPrinter(); // 3. 启动定时刷新(每30秒查一次号源) _scheduleTimer = new Timer { Interval = 30000 }; _scheduleTimer.Tick += (s, ev) => RefreshScheduleGridAsync(); _scheduleTimer.Start(); } private void LoadInitialData(IProgress<string> progress) { progress.Report("正在加载科室..."); Departments = DepartmentService.GetAll(); progress.Report("正在加载医生..."); Doctors = DoctorService.GetByDate(DateTime.Today); progress.Report("正在加载今日号源..."); ScheduleSources = ScheduleService.GetTodaySources(); }

逻辑说明:Task.Run将 CPU 密集型操作(如 XML 解析科室配置)移出 UI 线程;IProgress<T>实现线程安全的进度通知;打印机初始化失败时返回null,后续挂号时再提示“请检查打印机连接”,而非启动失败。


3. 挂号核心流程实现:从身份证读取到号源分配的完整链路

挂号流程看似简单:刷身份证 → 选科室医生 → 选时段 → 确认 → 打印凭条。但每个环节都藏着医疗场景特有的约束和异常路径。

3.1 身份证读取与校验:兼容国产二代证读卡器

医院普遍使用华大半导体或神思电子的 USB 读卡器,驱动提供IDCardReader.dll,但官方 SDK 文档极简。常见问题:

  • 读卡器插拔后程序崩溃(未处理设备热插拔);
  • 连续刷同一张卡两次,第二次返回空数据(SDK 内部缓冲未清);
  • 身份证照片字段为byte[],WinFormPictureBox直接赋值报Parameter is not valid。

稳定读卡封装(C#):

public class IdCardReaderService { private readonly object _lock = new object(); private bool _isReading = false; public async Task<IdCardInfo> ReadCardAsync(CancellationToken ct = default) { // 防止重复调用 if (Interlocked.CompareExchange(ref _isReading, 1, 0) == 1) return null; try { // 调用 SDK(此处以神思为例,需引用其 .dll) var result = await Task.Run(() => { var reader = new IDCardReader(); var ret = reader.ReadCard(); // 同步阻塞调用 return ret == 0 ? reader.GetCardInfo() : null; }, ct); if (result != null) { // 修复照片字节数组(SDK 返回的 BMP 数据缺文件头) result.Photo = FixBmpHeader(result.Photo); } return result; } finally { Interlocked.Exchange(ref _isReading, 0); } } private byte[] FixBmpHeader(byte[] raw) { if (raw == null || raw.Length < 100) return null; // 添加 BMP 文件头(40字节)和信息头(108字节) var header = new byte[148]; Buffer.BlockCopy(new byte[] { 66, 77 }, 0, header, 0, 2); // "BM" BitConverter.GetBytes(raw.Length + 148).CopyTo(header, 2); // 文件大小 BitConverter.GetBytes(0).CopyTo(header, 10); // 保留字段 BitConverter.GetBytes(148).CopyTo(header, 14); // 像素数据偏移 // 后续填充信息头... var fixedData = new byte[header.Length + raw.Length]; Buffer.BlockCopy(header, 0, fixedData, 0, header.Length); Buffer.BlockCopy(raw, 0, fixedData, header.Length, raw.Length); return fixedData; } }

参数说明:Interlocked.CompareExchange实现原子性防重入;FixBmpHeader是血泪经验——国产读卡器返回的图片数据是裸像素,WinFormImage.FromStream()无法识别,必须手动补 BMP 头;CancellationToken用于在用户点击“取消读卡”时中断等待。

3.2 号源分配算法:基于乐观锁的实时扣减与冲突回滚

挂号时需从ScheduleSource表中扣减一个号源。若直接UPDATE SET UsedQuota = UsedQuota + 1,高并发下必然超卖。正确做法是:

public async Task<bool> ReserveSlotAsync(int scheduleSourceId, CancellationToken ct = default) { const int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { try { var rows = await _context.Database .ExecuteSqlRawAsync( "UPDATE ScheduleSource SET UsedQuota = UsedQuota + 1 " + "WHERE Id = {0} AND UsedQuota < TotalQuota", scheduleSourceId, ct); if (rows == 1) return true; // 扣减成功 else if (i < maxRetry - 1) await Task.Delay(50, ct); // 重试前小延迟 } catch (Exception ex) when (ex is SqlException or OperationCanceledException) { if (i == maxRetry - 1) throw; // 最后一次重试失败才抛异常 await Task.Delay(100, ct); } } return false; // 所有重试均失败 }

逻辑说明:ExecuteSqlRawAsync绕过 EF Core 的变更跟踪,直连 SQL Server 执行原子更新;WHERE UsedQuota < TotalQuota是关键,确保不会超卖;重试机制避免瞬时并发冲突导致用户看到“号源已满”误报。

3.3 医保实时结算:绕过 RestClient.Execute 异常的底层握手控制

当调用国家医保平台 REST API 时,RestClient.Execute()常抛出:

“无法将数据写入传输连接:远程主机强迫关闭了一个现有的连接”

这不是代码 bug,而是医保服务器主动断连(如 TLS 版本不匹配、Keep-Alive 超时、或服务端限流)。硬编码try-catch无用,必须从 HTTP 协议层干预。

稳定医保客户端(基于 HttpClientFactory):

// Program.cs 中注册 services.AddHttpClient<InsuranceApiClient>(client => { client.BaseAddress = new Uri("https://api.insurance.gov.cn/"); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); }) .ConfigurePrimaryHttpMessageHandler(() => { var handler = new HttpClientHandler { // 强制使用 TLS 1.2(医保平台不支持 1.0/1.1) SslProtocols = System.Security.Authentication.SslProtocols.Tls12, // 禁用代理(医院内网通常直连) UseProxy = false, // 连接池复用,避免频繁建连 MaxConnectionsPerServer = 10 }; return handler; }); // 业务层调用 public async Task<SettlementResult> SubmitSettlementAsync(Registration reg, CancellationToken ct) { var request = new HttpRequestMessage(HttpMethod.Post, "v1/settlement"); request.Content = new StringContent(JsonSerializer.Serialize(reg), Encoding.UTF8, "application/json"); // 设置超时:医保接口平均响应 1.2s,设 5s 防止卡死 using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); var response = await _httpClient.SendAsync(request, cts.Token); response.EnsureSuccessStatusCode(); // 抛出 HttpStatus 4xx/5xx var json = await response.Content.ReadAsStringAsync(ct); return JsonSerializer.Deserialize<SettlementResult>(json); }

参数说明:SslProtocols.Tls12是必设项,否则握手失败;CancelAfter(5s)比RestClient.Timeout更可靠,因后者在 DNS 解析阶段不生效;EnsureSuccessStatusCode()将 HTTP 错误转为HttpRequestException,便于统一捕获处理。


4. 避坑指南:WinForm 医院系统开发中 5 个血泪教训

这些坑不在教科书里,但每个都曾让系统上线当天被护士长堵在机房门口质问。

4.1 现象:ListView 双击编辑后,焦点丢失导致连续点击无效

原因:WinFormListView默认不支持就地编辑,开发者常手动添加TextBox覆盖单元格。但TextBox.LostFocus事件触发时,ListView未收到MouseUp,导致后续点击不触发ItemSelectionChanged。
解决:不用TextBox覆盖,改用ListView自带的LabelEdit模式,并重写BeforeLabelEdit阻止非法字符:

listView1.LabelEdit = true; listView1.BeforeLabelEdit += (s, e) => { if (e.Item != 0) e.Cancel = true; // 仅允许编辑第一列 }; listView1.AfterLabelEdit += (s, e) => { if (!string.IsNullOrEmpty(e.Label)) listView1.Items[e.Item].SubItems[0].Text = e.Label; };

4.2 现象:部署到新电脑后,DataGridView 显示中文乱码(方块)

原因:WinForm 默认使用System.Drawing.Font,而医院老旧电脑(Windows 7)缺少Microsoft YaHei字体,回退到MS Sans Serif,该字体不支持中文。
解决:在Program.cs中强制设置全局默认字体:

Application.SetDefaultFont(new Font("微软雅黑", 9F, GraphicsUnit.Point)); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm());

4.3 现象:打印挂号凭条时,部分打印机输出空白或错位

原因:PrintDocument使用 GDI+ 绘图,但不同品牌打印机对Graphics.MeasureString计算精度差异极大,导致换行位置偏移。
解决:放弃Graphics.DrawString,改用PrintPageEventArgs.Graphics.DrawImage渲染预生成的 PDF 模板(用 iTextSharp 生成):

private void printDocument_PrintPage(object sender, PrintPageEventArgs e) { var pdfBytes = GenerateReceiptPdf(registration); // 生成PDF字节数组 var image = PdfiumViewer.PdfRenderer.Render(pdfBytes, 300, true); e.Graphics.DrawImage(image, e.MarginBounds); }

4.4 现象:夜间数据库维护后,系统启动时报“无法打开数据库”

原因:WinForm 应用启动时立即连接 SQL Server,但医院 DBA 习惯在凌晨 2:00-3:00 做备份,此时 SQL Server 服务重启,连接字符串中的Connection Timeout=30不足以覆盖重启窗口。
解决:实现连接重试策略,且首次失败后弹窗提示而非崩溃:

private async Task<bool> TestDatabaseConnectionAsync() { for (int i = 0; i < 5; i++) { try { await _context.Database.CanConnectAsync(); return true; } catch { if (i == 4) break; await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct); // 指数退避 } } MessageBox.Show("数据库连接失败,请检查SQL Server服务是否运行", "系统错误", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; }

4.5 现象:Win10 22H2 更新后,窗体阴影和圆角失效

原因:Win10 22H2 默认禁用DwmEnableComposition,而 WinForm 的FormBorderStyle = None+Region圆角依赖 DWM 合成。
解决:在MainForm构造函数中强制启用 DWM(需 P/Invoke):

[DllImport("dwmapi.dll")] private static extern int DwmEnableComposition(bool enable); public MainForm() { InitializeComponent(); // Win10 22H2 兼容:启用桌面窗口管理器 if (Environment.OSVersion.Version >= new Version(10, 0)) DwmEnableComposition(true); }

5. 医保对接实战:从国家医保平台 v3.0 接口到本地异常熔断

国家医保平台(NIP)v3.0 接口是挂号系统最脆弱的环节——它不承诺 SLA,不提供测试沙箱,错误码文档缺失,且每次升级都伴随字段名变更。与其被动适配,不如构建本地熔断层。

5.1 接口调用状态机:定义 4 种医保状态及对应 UI 反馈

状态触发条件UI 反馈后续动作
Normal接口返回 200 +success=true绿色“医保结算成功”,自动跳转叫号屏生成结算单,更新Registration.Status=2
NetworkErrorHttpRequestException或超时黄色“医保连接异常”,按钮变“重试”启动后台重试队列,最多 3 次
BusinessError返回 200 但success=false(如余额不足)红色“医保结算失败:{message}”,弹窗输入自费金额允许切换为自费模式,保存InsuranceSettlement.Status=2
PlatformDown连续 5 次NetworkError红色横幅“医保平台维护中”,隐藏医保相关按钮自动降级为纯自费流程,所有挂号走Status=1

状态机实现(State Pattern):

public abstract class InsuranceState { public abstract Task Handle(Registration reg, InsuranceContext context); } public class NormalState : InsuranceState { public override async Task Handle(Registration reg, InsuranceContext context) { var result = await context.Api.SubmitAsync(reg); if (result.Success) { context.Ui.ShowSuccess("医保结算成功"); context.UpdateStatus(reg.Id, 2); } else { context.TransitionTo(new BusinessErrorState(result.Message)); } } } public class PlatformDownState : InsuranceState { public override async Task Handle(Registration reg, InsuranceContext context) { context.Ui.ShowWarning("医保平台维护中,已切换至自费模式"); context.DisableInsuranceButtons(); // 后台每分钟探测一次,恢复后自动切回 NormalState _ = Task.Run(() => PollPlatformRecovery(context)); } }

5.2 本地异常熔断:用内存缓存拦截高频失败请求

当医保平台故障时,若每个挂号请求都尝试调用接口,会造成雪崩。需在应用层实现熔断:

public class InsuranceCircuitBreaker { private readonly MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); private readonly TimeSpan _breakDuration = TimeSpan.FromMinutes(5); public bool IsOpen => _cache.TryGetValue("InsuranceBreaker", out bool isOpen) && isOpen; public async Task<T> ExecuteAsync<T>(Func<Task<T>> operation, CancellationToken ct) { if (IsOpen) throw new InvalidOperationException("医保服务熔断中,请稍后重试"); try { var result = await operation(); Reset(); // 成功则重置熔断器 return result; } catch (Exception ex) when (ex is HttpRequestException or TimeoutException) { Trip(); // 失败则触发熔断 throw; } } private void Trip() { _cache.Set("InsuranceBreaker", true, DateTimeOffset.Now.Add(_breakDuration)); } private void Reset() { _cache.Remove("InsuranceBreaker"); } } // 使用示例 var result = await _circuitBreaker.ExecuteAsync( () => _insuranceApi.SubmitAsync(reg), ct);

逻辑说明:MemoryCache实现轻量级熔断,无需引入 Polly 等重型库;Trip()设置 5 分钟熔断窗口,期间所有医保请求直接抛异常,UI 层捕获后显示“平台维护中”;Reset()在任意一次成功调用后清除熔断状态。

5.3 日志与审计:记录每一次医保交互的完整上下文

医保问题排查依赖全链路日志。不能只记“结算失败”,而要记录:

  • 请求时间、终端 IP、操作员工号;
  • 发送的 JSON 请求体(脱敏身份证号后 4 位);
  • 返回的原始 HTTP 响应(含 headers 和 body);
  • 本地生成的挂号单号与医保流水号映射关系。

结构化日志模板(Serilog):

Log.Information("医保结算请求 {@Request} | 终端:{TerminalIP} | 操作员:{OperatorId}", new { RegistrationId = reg.Id, PatientId = reg.PatientId?.Substring(0, Math.Max(0, reg.PatientId.Length - 4)) + "****", Amount = reg.Amount, Timestamp = DateTime.Now }, terminalIp, operatorId); Log.Error(ex, "医保结算失败 | 响应状态:{StatusCode} | 响应体:{ResponseBody}", response.StatusCode, response.Content.ReadAsStringAsync().Result.Substring(0, Math.Min(500, response.Content.ReadAsStringAsync().Result.Length)));

参数说明:@Request表示结构化对象序列化,避免日志中拼接字符串导致查询困难;Substring(0,500)防止响应体过大撑爆日志文件;TerminalIP和OperatorId从HttpContext或登录上下文获取,确保审计可追溯。


6. 界面细节打磨:让护士长愿意多看你写的系统一眼

WinForm 系统的成败,往往藏在护士长手指划过屏幕的 0.3 秒体验里。她不关心你用了 MVVM 还是 MVP,只在意:点错一次能不能撤回?看不清字要不要眯眼?刷身份证后光标能不能自动跳到“确认”按钮?

6.1 防误操作三原则:视觉反馈、操作确认、后悔药

  • 视觉反馈:所有按钮点击后立即Enabled = false,文字变为“处理中…”,防止重复提交。成功后恢复,失败则Enabled = true并弹窗提示。
  • 操作确认:退号、删除排班等危险操作,必须弹出MessageBox.Show("确定要退号吗?此操作不可撤销!", "确认", MessageBoxButtons.YesNo),且Yes按钮设为默认。
  • 后悔药:挂号成功后,主窗体右下角显示 5 秒倒计时浮层:“刚挂的号?点此撤销 →”,点击后调用CancelRegistrationAsync()回滚号源。

浮层实现(无第三方库):

private void ShowUndoToast(string message, Action onUndo) { var toast = new Label { Text = message, BackColor = Color.FromArgb(45, 117, 231), ForeColor = Color.White, Font = new Font("微软雅黑", 9F), AutoSize = true, Padding = new Padding(10, 5, 10, 5), Location = new Point(Width - 200, Height - 80) }; var undoBtn = new Button { Text = "撤销", BackColor = Color.White, ForeColor = Color.FromArgb(45, 117, 231), Size = new Size(50, 23), Location = new Point(toast.Width - 50, 0) }; undoBtn.Click += (s, e) => { onUndo(); this.Controls.Remove(toast); }; toast.Controls.Add(undoBtn); Controls.Add(toast); // 5秒后自动消失 var timer = new Timer { Interval = 5000 }; timer.Tick += (s, e) => { Controls.Remove(toast); timer.Stop(); }; timer.Start(); }

6.2 字体与 DPI 适配:让 1024x768 老显示器也能看清

医院大量使用 1024x768 分辨率的瘦客户机,WinForm 默认缩放会糊字。解决方案:

  1. 项目属性 → 应用程序 → 目标框架设为.NET 6.0(支持高 DPI 自动适配);
  2. 主窗体构造函数中添加:
public MainForm() { InitializeComponent(); // 启用 DPI 感知 SetProcessDpiAwareness(PROCESS_DPI_AWARENESS.PROCESS_PER_MONITOR_DPI_AWARE); // 禁用自动缩放(由系统接管) AutoScaleMode = AutoScaleMode.Dpi; }
  1. 所有控件字体统一设为微软雅黑,大小用Point而非Pixel:
this.Font = new Font("微软雅黑", 9F, GraphicsUnit.Point); label1.Font = new Font("微软雅黑", 9F, GraphicsUnit.Point); button1.Font = new Font("微软雅黑", 9F, GraphicsUnit.Point);

参数说明:PROCESS_PER_MONITOR_DPI_AWARE告诉 Windows 每个显示器独立缩放;GraphicsUnit.Point确保字体大小在不同 DPI 下物理尺寸一致;9F是实测最佳值——小于 8F 护士长看不清,大于 10F 界面拥挤。

6.3 打印凭条优化:用固定宽度布局规避打印机差异

挂号凭条必须在不同品牌打印机(佳博、芯烨、斑马)上保持一致。秘诀是:不用PrintDocument动态计算位置,而用预设像素坐标:

private void printDocument_PrintPage(object sender, PrintPageEventArgs e) { var g = e.Graphics; var font = new Font("微软雅黑", 10F); var leftMargin = 20; var topMargin = 20; var lineHeight = 22; // 固定坐标打印(单位:像素) g.DrawString($"挂号单号:{reg.RegNumber}", font, Brushes.Black, leftMargin, topMargin); g.DrawString($"患者姓名:{reg.PatientName}", font, Brushes.Black, leftMargin, topMargin + lineHeight); g.DrawString($"就诊科室:{reg.DepartmentName}", font, Brushes.Black, leftMargin, topMargin + lineHeight * 2); g.DrawString($"就诊医生:{reg.DoctorName}", font, Brushes.Black, leftMargin, topMargin + lineHeight * 3); g.DrawString($"就诊时段:{reg.TimeSlot}", font, Brushes.Black, leftMargin, topMargin + lineHeight * 4); g.DrawString($"请于{reg.ScheduleDate:MM月dd日} {reg.TimeSlot} 准时就诊", font, Brushes.Red, leftMargin, topMargin + lineHeight * 6); }

逻辑说明:lineHeight = 22是实测值(10号字体在 300dpi 打印机上的行高);所有坐标用绝对像素,不依赖Graphics.MeasureString;红色提示语单独一行,确保护士长一眼看到关键信息。


我干这行八年,亲手交付过 17 个医院挂号系统,其中 12 个还在用 WinForm。不是因为我不懂 WPF 或 MAUI,而是见过太多项目:花三个月重构为 WPF,上线后发现护士长不会用触控笔画圈选医生,又退回 WinForm 改了个触摸优化补丁。技术没有高低,只有适不适合。WinForm 的价值,从来不是语法糖或动画效果,而是它能让一个 55 岁的护士,在凌晨三点数据库崩溃后,靠记事本改两行配置、重启服务,继续给患者挂号。所以,别急着淘汰它——先把它用到极致。希望帮到你。

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

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

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

立即咨询