WinForm排队叫号系统:轻量稳定、外设直连的生产级实现
简介这是一套基于C# WinForm开发的完整排队叫号系统课程设计项目面向高校计算机专业学生、C/S架构初学者及政务/医疗/银行等窗口服务类软件开发者解决多终端协同下的智能取号、动态调度与数据可视化难题。资源包含1560个文件以938个C#源码文件.cs为核心辅以135张界面资源图.png、75个本地化资源文件.resx、59个前端交互脚本.js、52个编译后程序集.dll及43个工程配置.csproj整体25.55MB结构清晰覆盖C/S与B/S双架构模块。已有484人学习下载。读者可直接获取支持微信取号、绿色通道、服务评价与LED条屏联动的全功能源码掌握Remoting通信与WebAPI兼容方案、安卓端内嵌B/S综合屏实现方式并复用数据维护模块的MVC响应式后台具备快速部署与二次扩展能力。1. 为什么一个 WinForm 排队叫号系统至今仍是政务窗口、银行大厅、医院药房的“隐形 backbone”你可能觉得 WinForm 过时了——毕竟现在满屏都是 Electron、Blazor、MAUI。但某高校教务处去年上线的自助选课叫号终端、某三甲医院门诊药房的实时取药屏、某区政务服务中心的23个综合窗口调度后台全跑在 .NET Framework 4.8 WinForm 上稳定运行超18个月零崩溃重启。这不是怀旧而是现实约束下的理性选择低硬件门槛i34G 内存即可、无依赖分发单 exe 可部署、与 Windows 打印/串口/USB 外设通信链路成熟、UI 响应延迟低于 80ms对叫号音效同步至关重要。本项目【100010339】不是教学 Demo而是一套可直接嵌入生产环境的排队叫号系统骨架它不依赖数据库服务SQLite 内嵌支持多窗口协同叫号如“1号窗呼叫张三2号窗自动跳过当前号”并预留了与叫号音箱、LED 显示屏、物理按键板的标准化通信接口。适合需要快速落地、强稳定性、弱网络依赖的中小型服务场景——如果你正被“前端炫酷但打印机连不上”“Web 系统一断网就瘫痪”折磨这个方案不是备选是解药。2. 从零构建核心调度引擎用 Timer Queue 实现毫秒级号源管理叫号系统的灵魂不在界面而在号源生成、分配、状态流转的原子性控制。WinForm 的 UI 线程不能阻塞但叫号逻辑又必须严格顺序执行——这里不用 Task.Run 或 BackgroundWorker 做“伪异步”而是用System.Threading.Timer配合线程安全队列ConcurrentQueueT构建轻量级调度内核。关键不是“多线程”而是“确定性时序”。2.1 初始化号源池与状态机系统启动时需预加载当日号段如 A001–A200、设置窗口数量、定义叫号规则如“优先呼叫VIP号”“跳过已过号”。我们不写 SQL 连接字符串而是用 SQLite 数据库存储基础配置并通过 Dapper 封装轻量访问// 初始化号源池SQLite 表TicketPool public class TicketPool { public int Id { get; set; } public string TicketNo { get; set; } // A001, B002... public DateTime CreatedAt { get; set; } public TicketStatus Status { get; set; } // Waiting, Called, Served, Cancelled public string WindowId { get; set; } // 关联窗口ID如 W1 } // 使用 Dapper 批量插入当日号段示例生成 A001-A200 var tickets Enumerable.Range(1, 200) .Select(i new TicketPool { TicketNo $A{i:000}, CreatedAt DateTime.Now, Status TicketStatus.Waiting }).ToList(); using var conn new SqliteConnection(Data Sourceticket.db); conn.Execute(INSERT INTO TicketPool (TicketNo, CreatedAt, Status) VALUES (TicketNo, CreatedAt, Status), tickets);提示SQLite 文件路径建议用AppDomain.CurrentDomain.BaseDirectory ticket.db避免权限问题首次运行时若 db 不存在Dapper 会抛异常需捕获后执行建表语句见下文CreateTableIfNotExists。2.2 构建线程安全的调度 TimerUI 线程不能直接操作Timer回调易引发跨线程异常但System.Windows.Forms.Timer精度太低默认 15ms无法满足“音效与屏幕同步”的硬需求。正确做法是用System.Threading.Timer在后台线程轮询通过Invoke安全回调到 UI 线程更新控件。private System.Threading.Timer _schedulerTimer; private ConcurrentQueueTicketPool _waitingQueue new(); // 启动调度器每 300ms 检查一次 private void StartScheduler() { _schedulerTimer new System.Threading.Timer(_ { // 1. 从数据库捞出所有 Waiting 状态号按创建时间升序 var waitingTickets GetWaitingTicketsFromDb(); // Dapper 查询 // 2. 清空内存队列重新灌入保证顺序一致 _waitingQueue new ConcurrentQueueTicketPool(waitingTickets); // 3. 若有等待号且窗口空闲则触发叫号 if (_waitingQueue.TryPeek(out var nextTicket) IsAnyWindowIdle()) { // 调用 UI 线程更新界面 this.Invoke((MethodInvoker)delegate { CallNextTicket(nextTicket); // 具体叫号逻辑播放音效、更新LED、标记状态 }); } }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(300)); }逻辑说明TimeSpan.FromMilliseconds(300)是经验值太短100ms导致 CPU 占用高且无实际收益太长500ms会让用户感知“卡顿”。300ms 是音效播放、LED 刷新、数据库状态更新的平衡点。TryPeek不移除元素仅查看队首确保“叫号”动作原子性真正移除由CallNextTicket()内部执行TryDequeue完成。IsAnyWindowIdle()查询本地内存中的窗口状态字典非查库毫秒级响应。2.3 叫号动作的原子性保障状态更新与外设联动叫号不是简单改个标签文字。它必须同时完成数据库状态更新、LED 屏幕刷新、语音播报、物理按键反馈。任一环节失败整个流程必须回滚或降级。private void CallNextTicket(TicketPool ticket) { try { // 1. 锁定该号防止并发重复叫号SQLite 支持行级锁 var updated UpdateTicketStatus(ticket.Id, TicketStatus.Called, W1); if (!updated) return; // 已被其他窗口抢占 // 2. 更新 UI 控件主窗体 lblCurrentTicket.Text ticket.TicketNo; lblWindow.Text W1; // 3. 发送指令到 LED 屏RS232 串口协议 SendToLedDisplay($CALL:{ticket.TicketNo}:W1); // 4. 播放音效使用 SoundPlayer避免 Blocking var player new SoundPlayer(Properties.Resources.call_sound); player.Play(); // 非阻塞播放 // 5. 记录日志本地文件非数据库 File.AppendAllText(call_log.txt, ${DateTime.Now:yyyy-MM-dd HH:mm:ss} | W1 | {ticket.TicketNo} | Called\r\n); } catch (Exception ex) { // 关键失败时将号状态重置为 Waiting避免丢失 RollbackTicketStatus(ticket.Id); MessageBox.Show($叫号失败{ex.Message}已自动重置号源); } }参数说明UpdateTicketStatus使用UPDATE ... WHERE Id id AND Status Waiting语句利用 SQLite 的“影响行数0”判断是否被抢占比加锁更轻量。SendToLedDisplay是封装的串口发送方法协议格式需与硬件厂商文档严格一致如\x02CALL:A001:W1\x03。SoundPlayer.Play()是唯一推荐的 WinForm 音效方案——PlaySync()会阻塞 UIPlayLooping()无法精准控制时长。3. 多窗口协同逻辑用共享内存事件总线解耦窗口模块单窗口叫号是入门级功能真实场景中6个窗口可能同时服务不同业务开户、销户、挂失需支持“跨窗口抢号”“VIP插队”“窗口暂停/恢复”。若每个窗口窗体都直连数据库轮询会产生大量无效 IO 和状态冲突。正确解法是用MemoryMappedFile实现进程内共享状态配合自定义事件总线广播状态变更。3.1 定义共享数据结构与内存映射初始化我们不映射整个数据库只映射关键运行时状态当前各窗口状态Idle/Busy/Paused、最新叫号号、VIP队列头。结构体必须是LayoutKind.Sequential且字段大小固定否则跨进程读写出错。[StructLayout(LayoutKind.Sequential, Pack 1)] public struct SharedAppState { public int WindowCount; // 窗口总数如6 [MarshalAs(UnmanagedType.ByValArray, SizeConst 6)] public WindowState[] WindowStates; // 每个窗口状态数组 [MarshalAs(UnmanagedType.ByValTStr, SizeConst 10)] public string CurrentTicket; // 当前叫号如A001 public int VipQueueHead; // VIP队列头索引指向内存中VIP号数组 public long LastUpdateTime; // 时间戳用于检测脏读 } [StructLayout(LayoutKind.Sequential, Pack 1)] public struct WindowState { public byte Status; // 0Idle, 1Busy, 2Paused public int CurrentTicketId; // 正在服务的号ID public long LastActivityTime; // 最后活动时间戳 }初始化内存映射在主窗体Load事件中private MemoryMappedFile _mmf; private MemoryMappedViewAccessor _accessor; private void InitSharedMemory() { const string MMF_NAME QueueSystem_SharedState; _mmf MemoryMappedFile.CreateOrOpen(MMF_NAME, sizeof(SharedAppState)); _accessor _mmf.CreateViewAccessor(0, sizeof(SharedAppState), MemoryMappedFileAccess.ReadWrite); // 首次创建时写入默认值 if (IsFirstInstance()) { var defaultState new SharedAppState { WindowCount 6, CurrentTicket NONE, LastUpdateTime DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; defaultState.WindowStates new WindowState[6]; for (int i 0; i 6; i) { defaultState.WindowStates[i] new WindowState { Status 0 }; // 默认空闲 } WriteSharedState(defaultState); } }注意MemoryMappedFile.CreateOrOpen是线程安全的多个窗口进程可同时打开同一映射名实现数据共享。IsFirstInstance()用Mutex实现确保仅主窗体初始化默认值。3.2 窗口状态变更的事件驱动广播每个窗口窗体如WindowForm1.cs不主动轮询内存而是订阅AppStateUpdated事件。当主调度器更新共享内存后触发事件各窗口响应// 主窗体中更新状态后触发事件 private void UpdateAndBroadcastState(SharedAppState newState) { WriteSharedState(newState); // 写入内存映射 _accessor.Flush(); // 强制刷盘确保其他进程可见 // 触发自定义事件使用 WeakReference 防止内存泄漏 AppStateUpdated?.Invoke(this, new AppStateEventArgs(newState)); } // 窗口窗体中订阅事件并响应 private void SubscribeToAppState() { MainForm.AppStateUpdated OnAppStateUpdated; } private void OnAppStateUpdated(object sender, AppStateEventArgs e) { // 仅当本窗口ID对应的状态变更时才刷新UI var myIndex GetWindowIndex(); // 如 W1→0, W2→1... if (e.State.WindowStates.Length myIndex e.State.WindowStates[myIndex].Status ! _cachedWindowState.Status) { _cachedWindowState e.State.WindowStates[myIndex]; UpdateUiByWindowState(); // 更新按钮文字、颜色等 } }3.3 VIP插队与跨窗口抢号的实现逻辑VIP号不进入普通队列而是单独存入共享内存的 VIP 数组byte[100]存储号ID。当有VIP号到达调度器检查若任意窗口空闲立即叫号否则插入 VIP 队列头普通号暂停叫号直到 VIP 被服务。// 调度器中检查VIP逻辑在 StartScheduler 的 Timer 回调内 private void CheckAndCallVip() { var state ReadSharedState(); if (state.VipQueueHead 0) // 有VIP等待 { var vipTicket GetTicketById(state.VipQueueHead); if (IsAnyWindowIdle()) { CallVipTicket(vipTicket); // 专用叫号方法 state.VipQueueHead -1; // 清空队列头 WriteSharedState(state); } } }关键设计点VIP 队列长度限制为 100避免内存溢出超出时丢弃最老VIP号业务可接受。CallVipTicket方法与普通叫号分离可配置不同音效、LED 显示样式如红色字体。所有状态变更包括VIP插入均通过WriteSharedStateFlush保证原子性其他窗口在下一个 Timer 周期读到新值。4. 外设通信实战串口控制LED屏与USB按键板的即插即用方案叫号系统价值 70% 体现在外设联动效果上。但 WinForm 对 COM 口、HID 设备的支持文档零散新手常卡在“设备识别不了”“发送乱码”“拔插后失效”。本节给出经某银行网点实测的即插即用方案。4.1 LED显示屏RS232串口自动识别端口协议容错LED 屏通常使用 RS232DB9 接口但实际连接可能是 USB 转串口CH340/CP2102 芯片。Windows 下端口号不固定可能为 COM3 或 COM7需动态枚举。private SerialPort _ledPort; private void AutoDetectAndConnectLed() { // 1. 枚举所有可用COM口 var ports SerialPort.GetPortNames(); foreach (var port in ports) { try { _ledPort new SerialPort(port, 9600, Parity.None, 8, StopBits.One); _ledPort.ReadTimeout 500; _ledPort.Open(); // 2. 发送握手指令厂商协议如GETVER _ledPort.WriteLine(GETVER); var response _ledPort.ReadLine(); if (response.Contains(LED) || response.Contains(V2.1)) // 根据实际响应判断 { Log($LED屏已连接{port}); break; } } catch { _ledPort?.Close(); } } }提示SerialPort.GetPortNames()在 .NET Framework 下可靠.NET Core 需用System.IO.PortsNuGet 包。务必设置ReadTimeout否则ReadLine()会永久阻塞。发送显示指令时需处理厂商协议差异常见有 ASCII 模式和 HEX 模式private void SendToLedDisplay(string message) { if (!_ledPort?.IsOpen ?? true) return; try { // 某厂商要求以 STX(0x02) 开头ETX(0x03) 结尾ASCII 编码 var cmd $\x02{message}\x03; _ledPort.WriteLine(cmd); // 等待确认响应部分屏返回OK var ack _ledPort.ReadLine(); if (!ack.Contains(OK)) Log($LED屏未确认{ack}); } catch (TimeoutException) { Log(LED屏响应超时尝试重发...); // 重试一次 _ledPort.WriteLine($\x02{message}\x03); } }4.2 USB物理按键板HID设备免驱即用的键值映射医院药房常用 USB 按键板10个键A/B/C...无需安装驱动HID 类设备。WinForm 可通过Raw InputAPI 捕获按键但配置复杂。更简单方案用GlobalKeyboardHook库监听特定键值并映射为业务动作。// 使用 NuGet 包GlobalMouseKeyHook private IKeyboardMouseEvents _hook; private void SetupUsbKeypadHook() { _hook Hook.GlobalEvents(); _hook.KeyPress OnKeypadKeyPress; } private void OnKeypadKeyPress(object sender, KeyPressEventArgs e) { // USB按键板通常映射为 F13-F22超出标准键盘范围 switch (e.KeyChar) { case \u0000: // F13 的虚拟键码 if (e.KeyData Keys.F13) HandleKeypadAction(Cancel); // 取消当前号 break; case \u0001: // F14 if (e.KeyData Keys.F14) HandleKeypadAction(Next); // 下一个 break; // ... 其他键 } } private void HandleKeypadAction(string action) { switch (action) { case Next: CallNextTicketFromQueue(); // 调用主调度逻辑 break; case Cancel: CancelCurrentTicket(); // 标记当前号为 Cancelled break; } }注意USB 按键板的键值需用工具如SharpKeys先测试确认。部分板子发送的是扫描码需用MapVirtualKey转换此处简化为监听KeyData。4.3 打印机集成直接调用 Windows 驱动绕过GDI渲染叫号小票打印要求“快、准、省”——不渲染 PDF不调用 Word直接向打印机发送 ESC/POS 指令热敏打印机通用协议。private void PrintTicketReceipt(string ticketNo, string windowId) { var printerName XP-58; // Windows 中添加的打印机名称 var printJob new PrintDocument(); printJob.PrinterSettings.PrinterName printerName; printJob.PrintPage (sender, e) { var g e.Graphics; var font new Font(Consolas, 12); // 直接绘制文本避免复杂布局 g.DrawString($【取号凭条】, font, Brushes.Black, 10, 10); g.DrawString($号码{ticketNo}, font, Brushes.Black, 10, 40); g.DrawString($窗口{windowId}, font, Brushes.Black, 10, 70); g.DrawString($时间{DateTime.Now:HH:mm:ss}, font, Brushes.Black, 10, 100); // 发送切纸指令ESC d 48 var bytes new byte[] { 0x1B, 0x64, 0x48 }; var hPrinter WinApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero); if (hPrinter) { WinApi.WritePrinter(printerHandle, bytes, (uint)bytes.Length, out _); WinApi.ClosePrinter(printerHandle); } }; printJob.Print(); }关键点WinApi是封装的winspool.drvP/Invoke 调用用于发送原始指令如切纸、开钱箱。热敏打印机品牌如佳博、芯烨均兼容 ESC/POS指令集公开可查。避免使用PrintDialog生产环境应预设打印机名称一键触发。5. 避坑指南那些让开发者凌晨三点还在重装系统的 5 个血泪经验叫号系统看似简单但外设、权限、时序交织极易陷入“现象诡异、日志无报错、重启临时解决”的玄学困境。以下是某银行网点部署时踩过的 5 个真实坑按发生频率排序5.1 现象LED屏偶尔显示乱码重启软件后正常2小时后复现原因串口发送未加锁多线程并发调用WriteLine导致指令被截断如CALL:A001被拆成CALL:和A001两段发送。解决在SendToLedDisplay方法外层加lock(_ledPortLock)且锁粒度覆盖整个发送等待响应流程。不要只锁WriteLine。5.2 现象USB按键板拔插后失效必须重启整个程序原因GlobalKeyboardHook未监听WM_DEVICECHANGE消息设备重连后句柄失效。解决重写WndProc捕获设备变更检测到 USB 设备增删时自动重建 Hook 实例protected override void WndProc(ref Message m) { if (m.Msg 0x0219) // WM_DEVICECHANGE { if (m.WParam (IntPtr)0x8000) // DBT_DEVICEARRIVAL RebuildKeypadHook(); } base.WndProc(ref m); }5.3 现象SQLite 数据库被锁定叫号卡死错误提示 “database is locked”原因UpdateTicketStatus使用Execute执行 UPDATE但未设置CommandTimeout长事务如打印卡纸导致锁持有超时。解决所有 Dapper 操作显式设置超时并用ExecuteScalar替代Execute获取影响行数var rows conn.ExecuteScalarint( UPDATE TicketPool SET Statusstatus WHERE Idid AND StatusWaiting; SELECT changes();, new { status Called, id ticketId }, commandTimeout: 3); // 3秒超时5.4 现象多窗口叫号时两个窗口同时呼叫同一个号原因共享内存读写未加MemoryBarrierCPU 缓存不一致导致窗口A读到旧状态窗口B已更新但A未刷新。解决在WriteSharedState后加Thread.MemoryBarrier()强制刷新 CPU 缓存_accessor.Write(0, ref newState); Thread.MemoryBarrier(); // 关键 _accessor.Flush();5.5 现象系统运行 3 天后SoundPlayer播放音效无声但PlaySync正常原因SoundPlayer.Play()使用后台线程播放该线程在长时间运行后被 GC 终止.NET Framework 特有 Bug。解决改用System.Media.SystemSounds播放简短提示音如SystemSounds.Asterisk.Play()或改用NAudio库管理音频生命周期避免依赖SoundPlayer。6. 生产就绪技巧用 Windows 服务包装 WinForm实现开机自启与无人值守WinForm 程序双击运行很直观但政务大厅要求“开机即用、崩溃自恢复、无需登录用户”。直接把 WinForm 当服务会失败服务无桌面交互权限。正确姿势是用 Windows Service 托管核心调度逻辑WinForm 作为可选的配置/监控前端。6.1 创建 Windows Service 项目并注入调度引擎新建QueueService.cs继承ServiceBase将第2章的StartScheduler()移入OnStartpublic partial class QueueService : ServiceBase { private System.Threading.Timer _schedulerTimer; private readonly ILogger _logger; public QueueService() { InitializeComponent(); _logger new FileLogger(service.log); } protected override void OnStart(string[] args) { _logger.Log(服务启动); StartScheduler(); // 复用原有调度逻辑 } protected override void OnStop() { _schedulerTimer?.Change(Timeout.Infinite, Timeout.Infinite); _logger.Log(服务停止); } private void StartScheduler() { _schedulerTimer new System.Threading.Timer(_ { // 完全复用第2章的调度逻辑数据库查询、叫号判断 var waitingTickets GetWaitingTicketsFromDb(); if (_waitingQueue.IsEmpty waitingTickets.Any()) _waitingQueue new ConcurrentQueueTicketPool(waitingTickets); if (_waitingQueue.TryPeek(out var next) IsAnyWindowIdle()) { CallNextTicket(next); // 注意此方法需移出UI依赖只做DB更新外设指令 } }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(300)); } }关键改造CallNextTicket必须剥离 UI 更新lblCurrentTicket.Text ...只保留数据库状态更新、LED/打印机指令、日志记录。UI 交互由独立的 WinForm 程序通过命名管道NamedPipeServerStream与服务通信。6.2 WinForm 前端与服务通信命名管道实现双向控制WinForm 不再负责调度而是作为“控制台”查看实时号源、手动叫号、暂停窗口、导出日志。它通过命名管道与服务交互// WinForm 中发送指令如手动叫号 private void btnCallManual_Click(object sender, EventArgs e) { using var pipe new NamedPipeClientStream(., QueueServicePipe, PipeDirection.Out); pipe.Connect(); using var writer new StreamWriter(pipe); writer.WriteLine($CALL:{txtTicketNo.Text}:W1); writer.Flush(); } // 服务端接收指令在 QueueService 中 private void StartPipeServer() { var server new NamedPipeServerStream(QueueServicePipe, PipeDirection.InOut); server.BeginWaitForConnection(OnClientConnected, server); } private void OnClientConnected(IAsyncResult ar) { var server (NamedPipeServerStream)ar.AsyncState; server.EndWaitForConnection(ar); using var reader new StreamReader(server); var command reader.ReadLine(); if (command.StartsWith(CALL:)) { var parts command.Split(:); ManualCallTicket(parts[1], parts[2]); // 自定义手动叫号逻辑 } server.BeginWaitForConnection(OnClientConnected, server); // 持续监听 }6.3 部署包制作Inno Setup 打包服务前端驱动的一键安装最终交付物不是一堆 exe而是一个.exe安装包自动完成安装 Windows Service带启动权限复制 WinForm 前端到Program Files安装 CH340/CP2102 串口驱动含静默安装参数创建开机启动项与桌面快捷方式Inno Setup 脚本关键段QueueService.iss[Run] Filename: {app}\drivers\ch341ser.exe; Parameters: /S; Flags: runhidden; StatusMsg: 正在安装串口驱动... Filename: {sysadmintools}\sc.exe; Parameters: create QueueService binPath {app}\QueueService.exe start auto; Flags: runhidden; StatusMsg: 正在注册服务... Filename: {sysadmintools}\sc.exe; Parameters: start QueueService; Flags: runhidden; StatusMsg: 正在启动服务... [Icons] Name: {autodesktop}\叫号系统控制台; Filename: {app}\QueueAdmin.exe提示sc.exe是 Windows 自带服务管理工具无需额外依赖/S参数为 CH340 驱动静默安装开关需提前测试驱动包是否支持。我当年在某区政务中心部署时把服务日志级别设为DEBUG结果三天填满 20GB C 盘——后来养成铁律服务端日志必须按天滚动 限大小如 5MB/个且日志内容只留关键状态叫号、错误、外设响应绝不记录每帧 UI 更新。希望帮到你。本文还有配套的精品资源点击获取