简介:这是一套基于C# WinForm开发的智能排队叫号系统完整源码,面向计算机相关专业课程设计、毕业设计学生及需要搭建叫号业务的中小型项目开发者。系统覆盖预约、取号、微信取号、绿色通道、服务评价与数据统计分析等完整流程,并整合取号端、硬件叫号器、软件叫号器、LED条屏、综合显示屏、消息服务端及语音端等多类终端;主要模块采用C/S架构,数据服务基于Remoting通信并可发布兼容WebAPI,支持消息服务自由扩展业务。数据维护模块采用B/S架构与MVC模式,支持响应式布局,可在PC、平板和手机上浏览操作;综合显示屏端为安卓原生应用内嵌B/S方式,支持实时更新与样式维护。资源包共1560个文件,以938个cs源码、135个png图片、75个resx资源、59个js脚本、52个dll及43个csproj工程文件为主,另含cshtml视图、sql脚本与配置文件,压缩包约25.55MB。已有484人学习,适合作为课程设计参考与二次开发基础。
1. 排队叫号系统在 WinForm 里到底怎么落地:从取号到叫号的完整链路
银行网点、医院门诊、政务大厅里那台吐小票的机器,背后跑的逻辑比多数人想的要朴素。基于 C# 开发(WinForm)排队叫号系统,本质是把「取号—排队—叫号—过号—统计」这条业务链路,用 WinForm 的窗体、控件和事件机制串起来,再配一个本地数据库做持久化。它不需要分布式、不需要微服务,一台取号机加一块叫号大屏就能跑通。适合谁做?做 C# 上位机、WinForm 项目案例练手的开发者,或者要给小型营业厅做一套轻量叫号工具的人。热搜里常出现的 winform 控件、c# 线程、c# 与 access 这些点,在这套系统里全都会碰到。下面按「先立住结构、再动手复现、最后讲坑」的顺序拆开讲,每一步都能照着敲。
2. 系统架构与数据模型:三端一库怎么划分
2.1 取号端、叫号端、显示端各自的职责边界
排队叫号系统拆开看就是三个角色。取号端负责生成号码、打印小票、写入排队记录;叫号端是工作人员操作的窗口,负责呼叫下一位、重呼、过号、转移;显示端是挂在大厅的屏幕,实时刷新当前叫号和服务窗口。三端可以跑在同一台机器上,也可以分机器部署,取决于网点规模。
我一般会把它们做成三个独立的 WinForm 项目,放在同一个解决方案里,共享一个数据访问类库。这样做的好处是:取号机坏了不影响叫号,叫号端升级不用动取号端。共享类库里放实体类、数据访问层和业务规则,三端各自引用。热搜词里的「c# 类库的使用」在这里就是刚需——不抽类库,三个项目各写一份 SQL,改一个字段要改三处,血泪经验。
数据模型不复杂,核心就几张表。号码表存流水号、业务类型、取号时间、状态;窗口表存窗口号、当前呼叫号码、工作人员;业务类型表存业务名称、前缀字母、是否启用。状态字段是整个系统的灵魂,用枚举值管理:0 等待、1 已呼叫、2 已办理、3 过号、4 已取消。状态流转错了,整个叫号逻辑就乱套。
2.2 用 Access 还是 SQLite:本地库选型的三个判断点
小型叫号系统用本地数据库就够了,常见做法是 Access 或 SQLite。两者都能被 C# 直接读写,但选型要看三个点。
第一,部署环境。Access 依赖 Office 驱动或 ACE 引擎,客户机没装对应组件就跑不起来,这是最常见的翻车点。SQLite 只需要一个 DLL,随程序发布,零依赖。第二,并发量。叫号系统并发极低,取号端和叫号端同时写的情况很少,两者都扛得住。第三,备份和迁移。Access 是单文件,拷走就行;SQLite 也是单文件,但需要程序释放连接后才能拷。
我的选择是 SQLite,理由是部署省心。热搜里「c#与access」出现频率高,说明很多人还在用 Access,能用,但记得在安装包里带上驱动,否则客户机上直接报「未找到提供程序」。
// 使用 System.Data.SQLite 连接本地库 // 连接字符串指向程序目录下的 queue.db string dbPath = Path.Combine(Application.StartupPath, "queue.db"); string connStr = $"Data Source={dbPath};Version=3;"; using (var conn = new SQLiteConnection(connStr)) { conn.Open(); // 建表:号码表,Status 用整数表示状态 string sql = @"CREATE TABLE IF NOT EXISTS Ticket ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Number TEXT NOT NULL, -- 完整号码,如 A001 BizType TEXT NOT NULL, -- 业务类型前缀,如 A SeqNo INTEGER NOT NULL, -- 当日流水号 Status INTEGER DEFAULT 0, -- 0等待 1已呼叫 2已办理 3过号 CreateTime TEXT NOT NULL, CallTime TEXT )"; using (var cmd = new SQLiteCommand(sql, conn)) { cmd.ExecuteNonQuery(); } }这段代码做了两件事:拼连接字符串、建号码表。Data Source指向程序运行目录,避免路径写死导致换机器就找不到库。Status用整数而不是字符串,查询和更新都更快,也避免中文状态值在不同编码下出问题。SeqNo单独存当日流水号,是为了每天重置号码时不用去解析字符串。注意CREATE TABLE IF NOT EXISTS,程序每次启动都执行一遍也不会报错,省去判断库是否存在的逻辑。
2.3 号码生成规则:前缀加流水号怎么保证不重复
号码格式一般是「字母前缀 + 三位数字」,比如 A001、B012。前缀对应业务类型,数字是当日该业务的流水号。生成逻辑是:查当天该业务的最大流水号,加一,格式化成三位。
这里有个并发陷阱。如果两个取号端同时取号,都查到最大号是 005,都生成 006,就重号了。单机单取号端不会遇到,但多台取号机就会。解决办法有两种:一是用数据库事务加锁,二是用全局唯一序列。小型系统我一般用事务包住「查询最大号 + 插入」两步,SQLite 默认的串行化隔离级别能挡住。
// 在事务中生成号码,避免并发重号 public string GenerateNumber(string bizType) { using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { // 查当日该业务最大流水号 string maxSql = "SELECT IFNULL(MAX(SeqNo),0) FROM Ticket WHERE BizType=@biz AND date(CreateTime)=date('now','localtime')"; int maxSeq; using (var cmd = new SQLiteCommand(maxSql, conn, tran)) { cmd.Parameters.AddWithValue("@biz", bizType); maxSeq = Convert.ToInt32(cmd.ExecuteScalar()); } int nextSeq = maxSeq + 1; string number = bizType + nextSeq.ToString("D3"); // D3 补零到三位 string insertSql = "INSERT INTO Ticket(Number,BizType,SeqNo,Status,CreateTime) VALUES(@n,@b,@s,0,datetime('now','localtime'))"; using (var cmd = new SQLiteCommand(insertSql, conn, tran)) { cmd.Parameters.AddWithValue("@n", number); cmd.Parameters.AddWithValue("@b", bizType); cmd.Parameters.AddWithValue("@s", nextSeq); cmd.ExecuteNonQuery(); } tran.Commit(); return number; } } }BeginTransaction把查询和插入绑成一个原子操作,其他连接在这期间写不进来。date('now','localtime')用本地时间过滤当天记录,避免 UTC 时差导致跨天判断错误。ToString("D3")保证 1 显示成 001,超过 999 会自然变成四位数,不会截断。参数化查询@biz、@n是必须的,拼接字符串既慢又有注入风险。
3. 叫号端核心逻辑:状态机与界面刷新怎么配合
3.1 呼叫下一位的完整流程与状态流转
叫号端的核心动作是「呼叫下一位」。流程是:根据当前窗口绑定的业务类型,从等待队列里取排在最前面的号码,把状态从 0 改成 1,记录呼叫时间,更新窗口当前号码,然后通知显示端刷新。
状态流转必须严格。等待(0) → 已呼叫(1) 是正常路径;已呼叫(1) → 已办理(2) 是办完;已呼叫(1) → 过号(3) 是没人来;过号(3) 可以重新入队回到等待(0)。任何一步跳过都会让队列错乱。我见过有人直接把等待改成已办理,结果统计报表里办理数虚高,排查半天。
// 呼叫下一位:取等待队列最前,更新状态 public Ticket CallNext(string bizType, string windowNo) { using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { // 取该业务等待中最小的流水号 string selSql = "SELECT Id,Number,SeqNo FROM Ticket WHERE BizType=@b AND Status=0 ORDER BY SeqNo LIMIT 1"; Ticket t = null; using (var cmd = new SQLiteCommand(selSql, conn, tran)) { cmd.Parameters.AddWithValue("@b", bizType); using (var rd = cmd.ExecuteReader()) { if (rd.Read()) { t = new Ticket { Id = rd.GetInt32(0), Number = rd.GetString(1), SeqNo = rd.GetInt32(2) }; } } } if (t == null) { tran.Rollback(); return null; } // 队列空 // 更新为已呼叫 string updSql = "UPDATE Ticket SET Status=1,CallTime=datetime('now','localtime') WHERE Id=@id"; using (var cmd = new SQLiteCommand(updSql, conn, tran)) { cmd.Parameters.AddWithValue("@id", t.Id); cmd.ExecuteNonQuery(); } tran.Commit(); return t; } } }ORDER BY SeqNo LIMIT 1保证先来先服务,不能用 Id 排序,因为 Id 是全局自增,跨业务类型会乱。Status=0过滤只取等待中的。队列空时回滚并返回 null,界面据此提示「当前无等待」。整个查询加更新在事务里,防止两个窗口同时叫到同一个号。
3.2 用 Timer 还是线程:显示端刷新的两种做法
显示端要实时反映当前叫号,刷新方式有两种:WinForm 的System.Windows.Forms.Timer定时查库,或者后台线程加事件通知。小型系统我推荐 Timer,简单可靠。
Timer 的间隔设 1000 到 2000 毫秒。太短了频繁查库没必要,太长了叫号有延迟感。在 Tick 事件里查当前各窗口的最新呼叫记录,更新 Label 或 ListView。注意 Timer 的回调在 UI 线程上,直接改控件属性没问题,不用 Invoke。
后台线程方案适合多显示端、需要推送的场景,但要处理跨线程更新控件的InvokeRequired,复杂度上去了。热搜里「c# 线程」和「winform 之 listview」经常一起出现,如果显示端用 ListView 列出所有窗口状态,Timer 加 ListView 刷新是最省事的组合。
// 显示端定时刷新:每 1.5 秒查一次各窗口当前号码 private void timerRefresh_Tick(object sender, EventArgs e) { string sql = @"SELECT w.WindowNo, w.CurrentNumber, w.BizType FROM Window w WHERE w.Enabled=1 ORDER BY w.WindowNo"; using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var cmd = new SQLiteCommand(sql, conn)) using (var rd = cmd.ExecuteReader()) { listView1.BeginUpdate(); // 暂停重绘,减少闪烁 listView1.Items.Clear(); while (rd.Read()) { var item = new ListViewItem(rd.GetString(0)); // 窗口号 item.SubItems.Add(rd.IsDBNull(1) ? "空闲" : rd.GetString(1)); // 当前号码 item.SubItems.Add(rd.GetString(2)); // 业务类型 listView1.Items.Add(item); } listView1.EndUpdate(); // 恢复重绘 } } }BeginUpdate和EndUpdate成对使用,清空和填充之间不重绘,避免列表闪烁,这是 WinForm ListView 刷新的标准做法。rd.IsDBNull(1)判断当前号码是否为空,空闲窗口显示「空闲」而不是空白。Timer 间隔在属性面板设Interval = 1500,单位毫秒。
3.3 语音播报与窗口联动:叫号后怎么通知大厅
叫到号之后,大厅要听到「请 A006 号到 3 号窗口」。WinForm 里播报有两种做法:一是用System.Speech.Synthesis.SpeechSynthesizer做 TTS 合成,二是预录好音频文件用SoundPlayer播放。TTS 灵活,号码变了不用重录,但音色机械;预录音频自然,但号码组合多,录不全。
我一般用 TTS,代码量小。叫号成功后触发播报,把号码和窗口号拼成句子传进去。注意 TTS 是异步的,连续叫号要排队,否则会叠音。用一个队列加单独线程消费,或者简单点用SpeakAsync加完成回调。
// 叫号成功后语音播报 private SpeechSynthesizer synth = new SpeechSynthesizer(); private void SpeakCall(string number, string windowNo) { // 选择中文语音,系统没装中文包会回退默认 var zhVoice = synth.GetInstalledVoices() .FirstOrDefault(v => v.VoiceInfo.Culture.Name.StartsWith("zh")); if (zhVoice != null) synth.SelectVoice(zhVoice.VoiceInfo.Name); synth.Rate = 0; // 语速,-10 到 10,0 为正常 synth.Volume = 100; // 音量 0-100 string text = $"请 {number} 号到 {windowNo} 号窗口"; synth.SpeakAsync(text); // 异步播报,不阻塞界面 }GetInstalledVoices过滤中文语音,没装中文包时用默认语音读中文会很难听,这是常见问题。Rate和Volume按现场环境调,大厅嘈杂就调高音量。SpeakAsync不阻塞 UI 线程,叫号按钮点完立刻能响应下一次操作。如果要严格排队播报,把 text 塞进ConcurrentQueue,单独起线程while取出来Speak(同步版),播完再取下一个。
4. 避坑与排查:叫号系统上线后最容易翻车的五件事
4.1 号码重号或跳号
现象:两个客户拿到同一个号,或者号码从 005 直接跳到 007。原因:重号是多取号端并发写库没加事务;跳号是插入失败但流水号已经算出来了,或者事务回滚后序列没回退。解决:号码生成必须包在事务里,查询最大号和插入要么都成功要么都失败。跳号如果只是偶尔出现,业务上可接受,不必强求连续;如果频繁跳,检查是不是有异常被吞掉了。
4.2 显示端界面卡死
现象:叫号端点了呼叫,显示端半天不刷新,或者整个窗口无响应。原因:Timer 的 Tick 里做了耗时操作,比如查库慢、网络请求超时,阻塞了 UI 线程。解决:Tick 里只做轻量查询,复杂逻辑丢到后台线程;查库加超时;ListView 刷新用 BeginUpdate/EndUpdate。如果用了Thread.Sleep在 Tick 里,那是自找的,删掉。
4.3 语音播报没声音或叠音
现象:叫号了但大厅没声音,或者两个号的声音叠在一起听不清。原因:没声音多半是系统没装中文语音包,或者Volume被设成 0,或者默认音频设备不对。叠音是连续调用SpeakAsync,前一句没播完后一句就来了。解决:先确认系统语音包,用GetInstalledVoices打印出来看;播报改成队列串行,一句播完再播下一句。
4.4 数据库文件被占用无法备份
现象:想拷贝 queue.db 做备份,提示文件正在使用。原因:SQLite 连接没释放,或者程序一直持有连接。解决:所有连接用using包住,用完即关;备份时先停程序,或者用 SQLite 的在线备份 API。Access 也有同样问题,而且 Access 的锁文件 .ldb 更麻烦。这是本地库的通病,养成「连接即开即关」的习惯。
4.5 跨天号码不重置
现象:昨天取到 A050,今天第一个号变成 A051,没从 A001 重新开始。原因:生成号码时查最大流水号没按日期过滤,把昨天的也算进去了。解决:查询条件加date(CreateTime)=date('now','localtime'),只统计当天。注意时区,用localtime而不是默认 UTC,否则凌晨会错乱。这个坑我在跨年那天踩过,血泪经验。
5. 进阶技巧:把叫号系统做成可配置、可扩展的形态
基础版跑通后,值得往上加的是配置化和扩展性。业务类型不该写死在代码里,应该做成配置表,运营人员能自己加「A 业务」「B 业务」,改前缀和名称。窗口和业务的绑定关系也做成配置,哪个窗口办哪些业务,改配置不改代码。这样一套程序能适配不同网点,不用每家重新编译。
配置化落地就是多两张表:BizType 表存业务前缀、名称、是否启用;WindowBiz 表存窗口和业务的对应关系。取号端读 BizType 生成按钮,叫号端读 WindowBiz 决定当前窗口能叫哪些业务。代码里所有硬编码的「A」「B」都换成从配置读。
再往上走是数据统计。每天的取号量、办理量、过号量、平均等待时长,这些数据对网点管理有价值。统计不实时算,每天闭店后跑一次批处理,把结果写进统计表,报表端直接读。平均等待时长 = 呼叫时间 - 取号时间,按业务类型分组求平均。过号率 = 过号数 / 取号数,超过阈值说明叫号节奏有问题,要么窗口太少,要么客户没听到。
验证一套叫号系统是否可靠,我的习惯是跑三个场景:单窗口满负荷连续叫 100 个号,看有没有重号跳号;两个取号端同时取 50 个号,看流水号是否连续无重复;跨天测试,把系统时间调到 23:59 取号,再调到 00:01 取号,看号码是否正确重置。这三个场景过了,基本能上线。
最后一个技巧:给叫号端加一个「重呼」和「转移」按钮。重呼是把当前号码再播一遍,客户没听到时用;转移是把当前号码转到另一个窗口,窗口设备故障时用。这两个功能实现简单,但现场很实用,没有它们,工作人员遇到异常只能干瞪眼。做这类系统,功能不在多,在于每个异常路径都有出口。希望帮到你。
本文还有配套的精品资源,点击获取