C#餐厅点餐系统高分实践:扫码枪集成、状态机与SignalR实时协同 简介这是一套基于C#开发的完整餐厅点餐系统源码专为计算机专业本科生课程设计、毕业设计及期末大作业打造面向初学者与进阶学习者解决从需求分析到界面交互、数据管理全流程实践难题。资源共242个文件涵盖49个核心C#业务逻辑文件如HomeController.cs、MembersController.cs、30个Razor视图cshtml、19个前端交互脚本js、18个实体模型生成模板tt、9个数据库关系图diagram及9个EDMX数据模型文件辅以CSS、字体、图片等资源完整呈现MVC架构下的典型三层结构压缩包仅8.4MB轻量易部署。已有65人下载学习代码注释详尽、界面美观、操作直观含全局配置Global.asax、Web配置体系Web.config及其Release/Debug变体及标准项目文件sln、csproj开箱即用是理解ASP.NET Web Forms/MVC混合模式、数据库绑定与权限管理的高分作业范本。1. 这不是又一个 WinForm 点餐 Demo它是一套可落地的 C# 餐厅业务闭环覆盖扫码枪触发、多终端协同、实时库存扣减与订单状态机驱动很多同学交“餐厅点餐系统”大作业时只做了个带几个按钮和 DataGridView 的窗体点一下“下单”就弹个 MessageBox——这根本不是系统是流程图的 UI 模拟。真正高分的 C# 餐厅点餐系统尤其压缩包名明确标注“高分大作业”必须体现三层能力业务建模的严谨性如菜品分类、套餐嵌套、折扣规则、会员等级、硬件交互的真实性扫码枪非键盘模拟需捕获 HID 设备原始数据流、状态流转的可靠性从“待接单”到“制作中”再到“已出餐”每个环节有唯一来源、不可跳过、可追溯。它面向的是中小型实体餐饮场景前台扫码快速录单、后厨屏实时刷新、收银端独立结算、经理端查看营业报表。如果你正用 VS2019/2022 开发目标不是交差而是理解“如何用 C# 做出有业务纵深的桌面应用”这篇就是为你写的——我们不讲 WinForm 基础控件怎么拖而是拆解为什么用SerialPort而非TextBox.KeyDown接扫码枪为什么订单状态必须用枚举状态机而非布尔字段库存扣减为何要加数据库行级锁而非简单UPDATE SET stock stock - 1这些才是高分与及格的分水岭。2. 用 C# 构建可扩展的餐厅领域模型从 Entity Framework Core 迁移脚本到带约束的订单状态机2.1 为什么不用 DataSet DataTableEF Core 是业务逻辑的底盘很多旧版点餐系统用DataSet直接绑定 DataGridView看似快但一加“会员积分抵扣”或“满 100 减 20”规则代码就散落在十几个事件里。高分作业必须用 EF Core 6.NET 6 项目标配它强制你定义清晰的实体关系。核心实体至少包含public class Dish { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } public int Stock { get; set; } // 注意这是当前可用库存非总进货量 public bool IsAvailable { get; set; } // 是否上架控制前台可见性 public int CategoryId { get; set; } } public class Order { public int Id { get; set; } public string OrderNumber { get; set; } Guid.NewGuid().ToString(N).Substring(0, 8); public OrderStatus Status { get; set; } OrderStatus.Pending; // 关键状态枚举 public DateTime CreatedAt { get; set; } DateTime.Now; public int? TableNumber { get; set; } // 可为空支持外卖单 public string CustomerName { get; set; } string.Empty; public ListOrderItem Items { get; set; } new(); } public enum OrderStatus { Pending 0, // 待接单刚提交未被厨房确认 Accepted 1, // 已接单厨房开始备料 Preparing 2, // 制作中部分菜品完成 Ready 3, // 已出餐全部完成等待上桌 Completed 4, // 已完成顾客确认结账完成 Cancelled -1 // 已取消不可逆 }提示OrderStatus必须是enum而非string或int字段。EF Core 迁移脚本会自动生成检查约束CHECK constraint数据库层就能阻止非法状态写入比如UPDATE orders SET status 5 WHERE id 123会直接报错。这是业务安全的第一道防线。2.2 生成迁移脚本并初始化种子数据用 Package Manager Console 三步到位在 Visual Studio 的Package Manager Console中确保默认项目为你的 DbContext 所在项目如Restaurant.Data执行# 第一步创建初始迁移名称任意建议含日期 Add-Migration InitialCreate -Context RestaurantDbContext # 第二步应用迁移到本地数据库假设连接字符串名为 DefaultConnection Update-Database -Context RestaurantDbContext # 第三步添加种子数据在 DbContext 的 OnModelCreating 中 protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityDishCategory().HasData( new DishCategory { Id 1, Name 主食 }, new DishCategory { Id 2, Name 热菜 }, new DishCategory { Id 3, Name 凉菜 } ); modelBuilder.EntityDish().HasData( new Dish { Id 1, Name 宫保鸡丁, Price 38.0m, Stock 50, IsAvailable true, CategoryId 2 }, new Dish { Id 2, Name 米饭, Price 2.0m, Stock 200, IsAvailable true, CategoryId 1 } ); }执行完后打开 SQL Server Management Studio 或 SQLite Browser你会看到Dishes表中Stock字段有默认值且IsAvailable为true。关键点在于种子数据不是演示用的假数据而是真实开业前必须配置的基础菜品库。如果作业要求“支持套餐”就在Dish实体中加bool IsCombo { get; set; }并在OrderItem中增加ListComboDetail导航属性——这才是高分作业的扩展性体现。2.3 订单状态机用 State Pattern 防止业务逻辑泄漏把状态变更写成一堆if (order.Status Pending) order.Status Accepted;是灾难。高分实现用策略模式封装状态行为// 定义状态处理器接口 public interface IOrderStateHandler { void Handle(Order order, RestaurantDbContext context); } // 具体实现待接单状态只允许“接单”或“取消” public class PendingStateHandler : IOrderStateHandler { public void Handle(Order order, RestaurantDbContext context) { // 业务规则接单前校验所有菜品库存 var itemIds order.Items.Select(i i.DishId).ToList(); var stocks context.Dishes .Where(d itemIds.Contains(d.Id)) .Select(d new { d.Id, d.Stock, d.Name }) .ToDictionary(x x.Id, x x); foreach (var item in order.Items) { if (!stocks.ContainsKey(item.DishId) || stocks[item.DishId].Stock item.Quantity) { throw new InvalidOperationException($菜品 {stocks[item.DishId].Name} 库存不足仅剩 {stocks[item.DishId].Stock} 份); } } // 扣减库存注意此处用数据库事务保证原子性 foreach (var item in order.Items) { context.Dishes .Where(d d.Id item.DishId) .ExecuteUpdate(d d.SetProperty(x x.Stock, x x.Stock - item.Quantity)); } order.Status OrderStatus.Accepted; order.AcceptedAt DateTime.Now; } } // 在 Order 类中注入状态处理器 public class Order { // ... 其他属性 private static readonly DictionaryOrderStatus, IOrderStateHandler _handlers new() { { OrderStatus.Pending, new PendingStateHandler() }, { OrderStatus.Accepted, new AcceptedStateHandler() }, { OrderStatus.Preparing, new PreparingStateHandler() } }; public void TransitionTo(OrderStatus targetStatus, RestaurantDbContext context) { if (!_handlers.TryGetValue(targetStatus, out var handler)) throw new NotSupportedException($不支持的状态转换{Status} → {targetStatus}); handler.Handle(this, context); } }注意ExecuteUpdate是 EF Core 7 的原生 SQL 更新方法绕过实体跟踪性能远高于FindAsync SaveChanges。它直接生成UPDATE dishes SET stock stock - p0 WHERE id p1避免了 N1 查询。这是处理高频点餐场景的关键优化。3. 扫码枪不是键盘用 HID API 捕获原始设备数据流实现毫秒级菜品识别3.1 为什么 TextBox.KeyDown 不可靠扫码枪的本质是 HID 键盘设备绝大多数同学用TextBoxKeyDown事件接收扫码枪输入这存在三个致命缺陷焦点依赖扫码枪扫出的字符必须聚焦在某个 TextBox 上而实际场景中用户可能正点击“清空订单”按钮干扰字符扫码枪常在条码前后输出回车符\r\n或制表符导致TextBox.Text多出换行无设备标识无法区分是扫码枪还是真人在键盘输入无法做权限控制如禁止服务员用键盘伪造扫码。高分作业必须用 Windows API 直接监听 HID 设备。核心是HidD_GetPreparsedData和ReadFile但 C# 封装更简单使用开源库HidLibraryNuGet 包HidLibrary。3.2 用 HidLibrary 监听扫码枪设备过滤与数据解析安装 NuGet 包后在窗体Load事件中初始化private HidDevice _scannerDevice; private void Form1_Load(object sender, EventArgs e) { // 查找 VID0x05E0霍尼韦尔常用且 PID0x1200 的 HID 设备 var devices HidDevices.Enumerate(0x05E0, 0x1200); if (devices.Any()) { _scannerDevice devices.First(); _scannerDevice.Open(); _scannerDevice.MonitorDeviceEvents true; _scannerDevice.ReadReport(OnScanReport); // 注册异步读取回调 } else { MessageBox.Show(未检测到扫码枪请检查设备连接, 设备错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } } private void OnScanReport(HidReport report) { // 扫码枪报告数据通常为 8 字节前 2 字节为修饰键一般为 0后 6 字节为按键扫描码 var data report.Data; if (data.Length 8) return; var barcode ParseBarcodeFromHidReport(data); if (!string.IsNullOrEmpty(barcode)) { // 在 UI 线程中更新界面避免跨线程异常 this.Invoke((MethodInvoker)delegate { AddDishByBarcode(barcode); // 核心业务根据条码查菜品并加入订单 }); } } private string ParseBarcodeFromHidReport(byte[] reportData) { var keyCodes new Listbyte(); // 跳过前2字节修饰键从第3字节开始读取按键码最多6个 for (int i 2; i Math.Min(8, reportData.Length); i) { if (reportData[i] ! 0) // 非空按键码 { keyCodes.Add(reportData[i]); } } // 将扫描码映射为 ASCII 字符需根据扫码枪键盘布局此处以 US QWERTY 为例 var barcodeBuilder new StringBuilder(); foreach (var code in keyCodes) { if (code 4 code 29) // a-z 对应 4-29 barcodeBuilder.Append((char)(a code - 4)); else if (code 30 code 39) // 1-0 对应 30-39 barcodeBuilder.Append((char)(1 code - 30)); // 实际项目需完整映射表此处简化 } return barcodeBuilder.ToString(); }提示ParseBarcodeFromHidReport中的映射表必须与你的扫码枪固件设置一致。常见扫码枪如 Zebra DS2208可在厂商工具中导出“键盘布局映射表”。若作业要求“支持多种扫码枪”则需将映射表抽象为IKeyboardLayout接口按设备 PID 动态加载实现类——这就是高分作业的架构意识。3.3 扫码触发订单行添加带防抖与重复检测的健壮逻辑单纯AddDishByBarcode会因扫码枪多次触发或网络延迟导致重复加单。必须加两层防护private readonly HashSetstring _scanningBarcodes new(); // 内存级去重 private readonly object _scanLock new(); private void AddDishByBarcode(string barcode) { lock (_scanLock) { if (_scanningBarcodes.Contains(barcode)) { // 0.5 秒内重复扫码忽略 return; } _scanningBarcodes.Add(barcode); Task.Run(() { Thread.Sleep(500); // 模拟防抖窗口 _scanningBarcodes.Remove(barcode); }); } try { using var context new RestaurantDbContext(); var dish context.Dishes.FirstOrDefault(d d.Barcode barcode); // 假设 Dish 实体有 Barcode 字段 if (dish null) { MessageBox.Show($未找到条码 {barcode} 对应的菜品, 扫码失败); return; } // 检查库存并添加到当前订单此处 currentOrder 为窗体级变量 if (dish.Stock 1) { MessageBox.Show(${dish.Name} 库存不足, 库存告警); return; } var orderItem new OrderItem { DishId dish.Id, Quantity 1, Price dish.Price }; currentOrder.Items.Add(orderItem); UpdateOrderSummary(); // 刷新界面上的总价、数量 } catch (Exception ex) { MessageBox.Show($扫码添加失败{ex.Message}); } }4. 多终端协同用 SignalR 实现实时订单广播与后厨屏状态同步4.1 为什么不用轮询SignalR 是 .NET 生态的实时通信标准餐厅场景中前台下单后后厨屏必须秒级显示新订单且多个后厨屏如热菜区、凉菜区需按菜品分类接收。轮询每隔 2 秒查一次数据库会造成服务器压力、延迟累积、状态不一致。高分作业必须集成 ASP.NET Core SignalR它基于 WebSocket降级为 Server-Sent Events 或 Long Polling是微软官方推荐的实时方案。4.2 搭建 SignalR Hub 并定义强类型消息契约在服务端ASP.NET Core Web API 项目创建OrderHub.cspublic class OrderHub : HubIOrderClient { // 客户端接口定义消息结构 public interface IOrderClient { Task ReceiveNewOrder(OrderDto order); Task UpdateOrderStatus(int orderId, OrderStatus newStatus); Task NotifyStockAlert(string dishName, int currentStock); } // 服务端方法前台调用广播新订单 public async Task SubmitOrder(OrderDto orderDto) { // 1. 保存订单到数据库 using var context new RestaurantDbContext(); var order new Order { OrderNumber orderDto.OrderNumber, Status OrderStatus.Pending, CreatedAt DateTime.Now, TableNumber orderDto.TableNumber, CustomerName orderDto.CustomerName }; order.Items orderDto.Items.Select(i new OrderItem { DishId i.DishId, Quantity i.Quantity, Price i.Price }).ToList(); await context.Orders.AddAsync(order); await context.SaveChangesAsync(); // 2. 广播给所有后厨屏除发送方 await Clients.AllExcept(Context.ConnectionId).ReceiveNewOrder(orderDto); } // 服务端方法后厨屏调用更新订单状态 public async Task UpdateOrder(int orderId, OrderStatus status) { using var context new RestaurantDbContext(); var order await context.Orders.FindAsync(orderId); if (order ! null IsValidStatusTransition(order.Status, status)) { order.Status status; await context.SaveChangesAsync(); await Clients.All.UpdateOrderStatus(orderId, status); // 广播给所有终端 } } private bool IsValidStatusTransition(OrderStatus from, OrderStatus to) (from, to) switch { (OrderStatus.Pending, OrderStatus.Accepted) true, (OrderStatus.Accepted, OrderStatus.Preparing) true, (OrderStatus.Preparing, OrderStatus.Ready) true, (OrderStatus.Ready, OrderStatus.Completed) true, _ false }; }4.3 客户端接入WinForm 中用 SignalR .NET Client 连接 Hub在前台点餐窗体中private HubConnection _hubConnection; private async void Form1_Load(object sender, EventArgs e) { _hubConnection new HubConnectionBuilder() .WithUrl(https://localhost:5001/orderhub) // 指向你的 API 地址 .Build(); // 注册接收新订单的回调 _hubConnection.OnOrderDto(ReceiveNewOrder, order { this.Invoke((MethodInvoker)delegate { ShowNewOrderNotification(order); // 弹窗或托盘提示 }); }); // 注册状态更新回调 _hubConnection.Onint, OrderStatus(UpdateOrderStatus, (orderId, status) { this.Invoke((MethodInvoker)delegate { UpdateOrderInListView(orderId, status); // 刷新订单列表状态列 }); }); try { await _hubConnection.StartAsync(); Console.WriteLine(SignalR 连接成功); } catch (Exception ex) { MessageBox.Show($SignalR 连接失败{ex.Message}); } } // 前台提交订单时调用 private async void btnSubmitOrder_Click(object sender, EventArgs e) { var orderDto new OrderDto { OrderNumber currentOrder.OrderNumber, TableNumber currentOrder.TableNumber, CustomerName currentOrder.CustomerName, Items currentOrder.Items.Select(i new OrderItemDto { DishId i.DishId, Quantity i.Quantity, Price i.Price }).ToList() }; try { await _hubConnection.InvokeAsync(SubmitOrder, orderDto); MessageBox.Show(订单已提交后厨正在处理); ClearCurrentOrder(); // 清空当前订单 } catch (Exception ex) { MessageBox.Show($提交失败{ex.Message}); } }注意HubConnection必须在窗体生命周期内保持存活如作为窗体字段并在FormClosing事件中调用_hubConnection.DisposeAsync()。SignalR 连接字符串中的端口5001需与你的 ASP.NET Core 项目launchSettings.json中的 HTTPS 端口一致。5. 性能与稳定性加固解决 C# 循环数据采集和 UI 刷新卡顿的三大实战技巧5.1 卡顿根源分析UI 线程阻塞的典型场景C# 餐厅系统卡顿最常见于两类操作循环查询数据库如后厨屏每秒SELECT * FROM orders WHERE status Preparing高频 UI 更新如扫码枪连续扫 10 个菜每次调用listView.Items.Add()触发重绘。这两者都会让 WinForm 的 UI 线程STA 线程忙于计算和渲染导致界面冻结、鼠标无响应。高分作业必须用以下三招破局。5.2 技巧一用 Timer 后台线程查数据结果通过 Invoke 回 UI替代while(true) { Thread.Sleep(1000); LoadOrders(); }的野蛮轮询private readonly System.Windows.Forms.Timer _refreshTimer new(); private CancellationTokenSource _cts; private void SetupRefreshTimer() { _refreshTimer.Interval 3000; // 3 秒刷新一次 _refreshTimer.Tick async (s, e) await RefreshOrdersAsync(); _refreshTimer.Start(); } private async Task RefreshOrdersAsync() { _refreshTimer.Stop(); // 防止重入 _cts?.Cancel(); _cts new CancellationTokenSource(); try { // 在后台线程查询不阻塞 UI var orders await Task.Run(() { using var context new RestaurantDbContext(); return context.Orders .Include(o o.Items) .ThenInclude(i i.Dish) .Where(o o.Status is OrderStatus.Pending or OrderStatus.Accepted or OrderStatus.Preparing) .OrderByDescending(o o.CreatedAt) .Take(20) .ToList(); }, _cts.Token); // 安全地更新 UIInvoke 确保在 UI 线程 this.Invoke((MethodInvoker)delegate { UpdateOrderListView(orders); }); } catch (OperationCanceledException) { // 被取消忽略 } finally { _refreshTimer.Start(); } }5.3 技巧二ListView 虚拟模式 批量更新万级订单不卡当订单量超 1000 条listView.Items.Add()会指数级变慢。启用虚拟模式VirtualModeprivate void InitializeVirtualListView() { listView1.VirtualMode true; listView1.RetrieveVirtualItem ListView1_RetrieveVirtualItem; listView1.CacheVirtualItems ListView1_CacheVirtualItems; // 预加载首屏数据如前 50 条 _cachedOrders LoadOrdersPage(0, 50); listView1.VirtualListSize _cachedOrders.Count; // 总数可动态更新 } private void ListView1_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { // 只在需要显示时才提供数据不预加载全部 if (e.ItemIndex 0 e.ItemIndex _cachedOrders.Count) { var order _cachedOrders[e.ItemIndex]; e.Item new ListViewItem(new[] { order.OrderNumber, order.TableNumber?.ToString() ?? 外卖, order.Status.ToString(), order.CreatedAt.ToString(HH:mm:ss) }); } }5.4 技巧三用 BlockingCollection 实现扫码枪数据管道解耦采集与处理将扫码枪原始数据、解析、业务处理彻底分离private readonly BlockingCollectionstring _scanQueue new(); // 在扫码回调中只做最轻量的入队 private void OnScanReport(HidReport report) { var barcode ParseBarcodeFromHidReport(report.Data); if (!string.IsNullOrEmpty(barcode)) { _scanQueue.Add(barcode); // 立即返回不阻塞设备读取 } } // 启动后台消费者线程 private void StartScanConsumer() { Task.Run(() { foreach (var barcode in _scanQueue.GetConsumingEnumerable()) { try { // 在后台线程处理扫码业务查库、加单等 ProcessBarcode(barcode); } catch (Exception ex) { // 记录日志不抛出避免中断管道 LogError(ex); } } }); }这种生产者-消费者模式让扫码枪数据流稳定即使ProcessBarcode因数据库慢而延迟也不会丢失扫码事件——BlockingCollection的内部队列会暂存数据直到消费者处理完。这是工业级应用的标配设计。本文还有配套的精品资源点击获取