简介:这套基于C#开发的火锅馆点菜系统源码工程,面向餐饮管理系统学习者与课程设计人员,完整覆盖菜品展示、购物车、订单生成、支付结算及小票打印等核心流程。压缩包共71个文件,体积约1.55MB,以20个C#源码文件、7个窗体资源文件和7个资源映射文件为主,同时附带可直接运行的exe/dll程序、SQL数据库(mdf/ldf)以及Crystal报表文件,便于直接运行和二次开发。已有141人浏览学习。源码采用MVC思想与事件驱动机制,结合Windows窗体设计、ADO.NET数据访问、DataSet数据集和异常处理等关键知识点,能帮助读者掌握桌面应用从界面到数据库的完整实现方法。通过调试该工程,还可学习到订单数据结构设计、报表输出以及Visual Studio安装部署配置等实战技能。
1. C#点菜系统到底解决什么问题:火锅店场景的三大痛点
火锅店点菜跟快餐、正餐都不一样:锅底先上桌,毛肚鸭肠是边吃边涮,客人吃到一半加菜是常态,服务员手里攥着手写单从前厅跑到后厨。这种场景下,C#点菜系统解决的问题很具体——把点菜、传菜、收银三件事统一到一套局域网程序里:前厅服务员在客户端点菜下单,数据实时进数据库,后厨自动出单,收银端随时看桌台状态和消费金额。纸质菜单最大的毛病是信息不同步:后厨不知道哪桌刚加了菜,收银台不知道哪桌送了果盘,结账时账单对不上,翻台高峰期更是兵荒马乱。
这篇笔记面向三类人:想给自家门店上系统、但不想被动辄上万的点餐软件套牢的老板;接外包的C#开发者,需要一套能改能交付的底子;以及用C#写过小工具、想拿完整项目练手的入门者。后面从技术选型讲到核心模块、数据库设计和踩坑记录,照做能省下不少试错时间。
2. 技术选型与项目骨架:C/S架构、WinForms与数据库怎么定
2.1 为什么点菜系统几乎都用C/S架构,而不是浏览器访问
做点菜系统第一个决策不是选控件,而是选C/S还是B/S。常见做法是C/S,理由很实际:火锅店后厨环境油腻、Wi-Fi不稳定、PC配置偏低,服务员要的是"点一下就能用"的响应速度,不是打开浏览器等加载。C/S架构里,客户端直接连数据库或通过TCP连服务端,局域网内一次往返几十毫秒,体验接近桌面软件。
再一个原因是打印机环节。后厨出单要接ESC/POS小票打印机,C/S客户端直接驱动本地打印端口(USB或网口9100),稳定性和兼容性都比浏览器里调打印成熟。如果走B/S,还得做打印中间件才能把浏览器的打印请求转发到本地打印机,中间多一层就多一个故障点。这不是说Web做不了点菜系统,而是以火锅店的现场条件,C/S容错率高、部署直接。
| 对比项 | C/S客户端 | B/S浏览器 |
|---|---|---|
| 部署 | 每台机器安装一次 | 浏览器访问,免安装 |
| 断网表现 | 本地缓存可继续点菜 | 直接不可用 |
| 打印控制 | 直接驱动本地打印机 | 需打印中间件转发 |
| 升级 | 覆盖安装或绿色升级 | 改服务器即生效 |
| 适合规模 | 单店、区域连锁 | 多店远程管理 |
界面层面也有取舍。WinForms在低配收银机上(2G内存、集显)启动快、占用低,控件开发效率高;WPF界面好看、动画流畅,但同样的列表页在老机器上渲染会有明显卡顿。我一般会选WinForms做前厅点菜端和后厨出单端,把UI做简洁:左侧菜品分类,中间菜品列表带价格和估清标记,右侧购物车,底部是桌台和下单按钮。这套布局在1024×768分辨率的老触摸屏上也点得准,比花哨的WPF效果更实用。
2.2 SQLite和SQL Server怎么选:先看桌位数和并发量
数据库用哪个,不是看"哪个高级",而是看门店规模和并发量。单店10到20桌,前厅2到3个点菜客户端,后厨1个出单端,再加收银端,同时在线客户端不超过10个,SQLite完全够用。SQLite是文件型数据库,零配置、免安装,升级直接用文件替换,不会出现"服务没装好连不上"这种外包项目最常见的事故。
如果场景变成连锁多店、菜品上千、高峰期同时下单超过50笔,SQLite在写入锁上就会成为瓶颈——它同一时间只允许一个写者,多客户端并发写库会频繁报"database is locked"。这种情况换成SQL Server或MySQL更合适。SQL Server在Windows生态里和C#配合最顺,安装一个Express版就能撑住单店几百桌的规模,连接字符串和管理工具都成熟。
一个折中方案是:开发阶段用SQLite跑通逻辑,数据访问层做成接口,发布时通过配置文件切换数据库。数据访问层只依赖SQL参数化查询,SQLite和SQL Server的语法差异集中在建表和自增列上,切换成本很小。后文所有代码示例以SQLite为准给出,同时标注SQL Server对应的写法差异。
2.3 把项目骨架搭起来的完整结构
一个能交付的点菜系统,我一般拆成四个项目,用.NET WinForms方案组织:
DianCai.sln ├── DianCai.Client # 前厅点菜客户端(WinForms) ├── DianCai.Kitchen # 后厨出单端(WinForms) ├── DianCai.DataAccess # 数据访问层(Dapper + SQLite) └── DianCai.Common # 实体类、枚举、公共扩展四个项目各司其职:Client管点菜交互,Kitchen管后厨出单和估清提醒,DataAccess封装所有SQL,Common放菜品、订单、桌台实体和状态枚举。分层对新手最重要的意义是:改界面不碰SQL,改SQL不碰界面,出问题一查就知道是哪一层。
数据库连接字符串放在客户端项目的App.config里,发布时只改配置文件就能切换数据库,不用重新编译。SQLite版连接串长这样:
<connectionStrings> <add name="DianCaiDb" connectionString="Data Source=D:\DianCaiData\DianCai.db;Pooling=True;Max Pool Size=20;" providerName="System.Data.SQLite" /> </connectionStrings>Data Source是数据库文件路径,建议放到程序目录之外(比如D:\DianCaiData),避免卸载重装时把数据一起清了。Pooling=True表示启用连接池,Max Pool Size=20限制最多20个并发连接,防止高峰期连接数失控。如果是SQL Server,连接字符串改成Server=192.168.1.100;Database=DianCaiDB;User ID=sa;Password=xxx;Pooling=True;即可,DataAccess层通过工厂模式读配置决定用哪个数据库驱动。
数据库初始化在程序启动时做一次:如果文件不存在就创建库和四张表,然后加载菜品数据到客户端缓存。这一步用启动检查的方式,避免把建库脚本散落在各处,门店新增一台收银机时也能一键初始化。初始化逻辑放在DataAccess项目里,界面启动事件只调用一个Init方法,不要在这个方法里塞UI逻辑。
提示:App.config里的连接字符串默认是明文。如果门店服务器密码泄露风险高,可以用DPAPI对密码字段做加密,解密逻辑放在DataAccess里。小项目不必上,但接政企单时这个点是加分项。
3. 核心功能拆解:桌台状态、菜品估清、下单流程三块硬骨头
3.1 桌台状态机:空台、入座、用餐、结账的流转规则
桌台是整个系统的"主键",所有订单都挂在桌台下。火锅店的桌台流程比快餐复杂,因为一桌客人可能循环多次加菜。我一般用枚举表达桌台状态:
public enum TableStatus { Empty = 0, // 空台 Occupied = 1, // 客人已入座,尚未下单 Dining = 2, // 已下单,用餐中(可加菜) Checkout = 3, // 客人要求结账,收银中 Cleaning = 4 // 结账完毕,服务员在清理 }五个状态之间允许的转换路径:Empty进Occupied只有"开台"操作;Occupied和Dining之间是"首次下单"和"加菜"操作;Dining到Checkout是"客人要求结账";Checkout到Empty是"结账完成并清台",清台后才能重新开台。状态流转用独立方法封装,不在按钮点击事件里散写,这是后期改需求时避免改漏的关键。
public bool ChangeTableStatus(int tableId, TableStatus from, TableStatus to) { const string sql = @"UPDATE Tables SET Status = @to WHERE TableId = @id AND Status = @from"; int rows = _conn.Execute(sql, new { id = tableId, from = (int)from, to = (int)to }); return rows == 1; }这段SQL是典型的"乐观更新"写法:UPDATE时带上前一个状态作为条件,如果桌台状态已经被别的窗口改过,受影响行数是0,ChangeTableStatus返回false,界面提示"桌台状态已变化,请刷新"。这样就不会出现两个服务员同时对一张桌子开台、导致状态互相覆盖的情况。注意from和to传的是枚举的int值,SQLite里没有枚举类型,存整数最直接。
在Client界面上,桌台用一个DataGridView展示,每行一张桌子,状态列显示为约定好的颜色:空台灰色、入座蓝色、用餐中绿色、结账红色、清理中橙色。颜色逻辑放在桌台实体的属性里计算,不放在UI事件里,这样厨房端、收银端引用同一个实体类,颜色显示不会两端不一致。
3.2 菜品估清:火锅店"卖完了"怎么用代码表达
火锅店的估清逻辑比普通餐厅频繁:毛肚、鸭肠、鲜牛肉这类菜品每天备货量有限,卖完就要在菜单上置灰,不能再下单。估清的难点在于"当日限量"和"实时扣减"两个要求。菜品表里我一般加一个StockToday字段,表示当天可售数量,默认值是每天的备货量。每天开门营业前,用一个定时任务或手动按钮把所有菜品的StockToday重置为默认备货量。
点菜端读取菜品时只加载StockToday大于0的菜品:
public List<Dish> GetAvailableDishes(int categoryId) { const string sql = @"SELECT * FROM Dish WHERE CategoryId = @cid AND Status = 1 AND StockToday > 0 ORDER BY SortNo ASC"; return _conn.Query<Dish>(sql, new { cid = categoryId }).ToList(); }Status=1表示上架状态,下架菜品直接Status=0,不用删数据;SortNo是排序号,控制锅底、荤菜、素菜、饮料的展示顺序。这样做的好处是:估清只影响当天的可售数量,不影响菜品档案,第二天重置StockToday后菜品自动恢复上架。
如果不想用数量,只做"估清/有货"二值标记也可以,但火锅店加菜频繁,二值标记无法回答"还有几份"这个问题。所以建议用数量。数量扣减放在下单事务里,和订单明细一起提交,这个在3.3小节演示。客户端界面上,StockToday等于0的菜品直接置灰并显示"估清",等于1到5的显示"仅剩N份"提示,让服务员在客人问询时能直接回答。
每日重置的定时任务我一般放在收银端,因为收银端是每天最早开机的机器。收银端启动时检查当天有没有执行过重置,没执行就弹窗提醒店长确认后执行。千万不要让每个客户端都自动重置,否则后开的机器会把当天已经卖掉的库存又补回去——这个坑我踩过,后面避坑章节还会提。
3.3 下单事务:一条SQL守住库存,别让两桌抢最后一盘毛肚
下单是点菜系统里最核心的写操作,它同时做三件事:写订单头、写订单明细、扣减菜品当日可售数量。这三件事必须在一个事务里完成,任何一个失败都要整体回滚,否则会出现"订单存上了但库存没扣"或者"库存扣了但订单丢了"的脏数据。
public int CreateOrder(Order order, List<OrderItem> items) { using var tran = _conn.BeginTransaction(); try { // 1. 写订单头 const string sqlOrder = @"INSERT INTO Orders(TableId, TotalAmount, Status, CreateTime) VALUES(@tableId, @amount, 1, @now); SELECT last_insert_rowid();"; int orderId = _conn.ExecuteScalar<int>(sqlOrder, new { tableId = order.TableId, amount = order.TotalAmount, now = DateTime.Now }, tran); // 2. 写订单明细并扣库存(同一条SQL完成) foreach (var item in items) { const string sqlItem = @"INSERT INTO OrderItems(OrderId, DishId, DishName, Price, Count, Amount) VALUES(@orderId, @dishId, @dishName, @price, @count, @amount)"; _conn.Execute(sqlItem, new { orderId, dishId = item.DishId, dishName = item.DishName, price = item.Price, count = item.Count, amount = item.Price * item.Count }, tran); const string sqlStock = @"UPDATE Dish SET StockToday = StockToday - @count WHERE DishId = @dishId AND StockToday >= @count"; int rows = _conn.Execute(sqlStock, new { count = item.Count, dishId = item.DishId }, tran); if (rows == 0) throw new Exception($"菜品 [{item.DishName}] 库存不足,请刷新菜单"); } tran.Commit(); return orderId; } catch { tran.Rollback(); throw; } }参数说明:BeginTransaction开启事务;ExecuteScalar拿到自增列的新订单号;UPDATE语句带了StockToday >= @count条件,这是库存防超卖的关键——如果剩余可售数量不够,受影响行数为0,立即抛异常回滚整个订单。这样即使两张桌子同时提交"各点一份最后剩下的毛肚",数据库层面也只会让一单成功,另一单拿到库存不足的明确提示。
事务提交后,客户端要做两件事:把订单号显示在界面上,并触发后厨出单。出单不建议在下单事务里同步做——打印失败不应该回滚已提交的订单,正确的做法是事务提交后,把打印任务扔进队列,由后台线程处理,3秒内没打进队列就弹提示让服务员手工补单。这个方案在第6章展开。加菜场景复用CreateOrder方法,但思路不同:加菜是往已存在的订单里插入新明细,不建新订单头,所以可以拆一个AddItemsToOrder方法,逻辑放在同一事务里,同样扣库存。
4. 数据库设计与数据访问层:四张表撑起整个点菜系统
4.1 四张核心表的字段设计:菜品、桌台、订单、订单明细
点菜系统再复杂,核心表就四张:菜品表Dish、桌台表Tables、订单表Orders、订单明细表OrderItems。其他像员工表、菜品分类表是锦上添花,先把这四张建好,系统就能跑起来。建表用SQLite语法写一个初始化脚本:
-- 菜品表:一个火锅菜品一条记录 CREATE TABLE IF NOT EXISTS Dish ( DishId INTEGER PRIMARY KEY AUTOINCREMENT, DishName NVARCHAR(50) NOT NULL, CategoryId INT NOT NULL DEFAULT 1, -- 1锅底 2荤菜 3素菜 4饮料 Price DECIMAL(8,2) NOT NULL, -- 单价 StockToday INT DEFAULT 999, -- 当日可售数量 SortNo INT DEFAULT 0, -- 排序号 Status INT DEFAULT 1 -- 1上架 0下架 ); -- 桌台表:门店物理桌位 CREATE TABLE IF NOT EXISTS Tables ( TableId INT PRIMARY KEY, -- 桌号,如201 TableName NVARCHAR(20) NOT NULL, Capacity INT DEFAULT 4, -- 可坐人数 Status INT DEFAULT 0 -- 0空台 1入座 2用餐 3结账 4清理 ); -- 订单表:一桌一次消费一张主单 CREATE TABLE IF NOT EXISTS Orders ( OrderId INTEGER PRIMARY KEY AUTOINCREMENT, TableId INT NOT NULL, TotalAmount DECIMAL(10,2) DEFAULT 0, Status INT DEFAULT 0, -- 0进行中 1已提交 2已结账 3已作废 CreateTime DATETIME DEFAULT CURRENT_TIMESTAMP, PayTime DATETIME ); -- 订单明细:订单下的每一道菜 CREATE TABLE IF NOT EXISTS OrderItems ( ItemId INTEGER PRIMARY KEY AUTOINCREMENT, OrderId INT NOT NULL, DishId INT NOT NULL, DishName NVARCHAR(50) NOT NULL, -- 冗余菜品名,防止菜品改名影响历史单 Price DECIMAL(8,2) NOT NULL, -- 下单时单价,防止后续改价影响历史单 Count INT DEFAULT 1, Amount DECIMAL(10,2) DEFAULT 0 );字段设计上有两个冗余是故意的:OrderItems里冗余DishName和Price,是因为火锅店菜单会频繁改价、改菜名,如果用JOIN去菜品表取名称,历史订单打印时菜名和价格会跟着变,影响对账。Orders里保留TableId而不直接存桌名,也是因为桌台可能改名,订单只要守住桌号就够。
数据类型注意:SQLite没有专门的DateTime类型,CURRENT_TIMESTAMP存成文本格式"yyyy-MM-dd HH:mm:ss",排序和比较都按字符串规则,实际用没问题;SQL Server环境就把这些字段换成DATETIME DEFAULT GETDATE()。DECIMAL(8,2)表示最大可存999999.99,单价完全够用,TotalAmount用DECIMAL(10,2)。
建表之后补两个索引,一个是OrderItems的OrderId,一个是Orders的TableId和CreateTime组合索引。点菜系统查询最多的场景是"某桌今日订单"和"某时段全部订单",没有索引的话数据量过万后明细查询会明显变慢:
CREATE INDEX IF NOT EXISTS idx_orderitems_orderid ON OrderItems(OrderId); CREATE INDEX IF NOT EXISTS idx_orders_table_time ON Orders(TableId, CreateTime);索引不是越多越好,点菜系统表小,两三个索引足够。索引加多了反而拖慢INSERT写入,得不偿失。
4.2 用Dapper还是EF Core:小项目我选Dapper的理由
数据访问层我用得比较多的是Dapper,不是EF Core。理由就三条:SQL可控、性能好、学习成本低。Dapper是轻量ORM,SQL还是自己写,它只负责把查询结果映射成实体对象,几乎不产生额外开销。EF Core虽然开发效率高,但生成的SQL对新手是个黑匣子,出问题不容易定位,尤其在做复杂的库存扣减、联表报表时,不如直接写在SQL里看得清楚。
以按分类查菜品为例,Dapper的写法是这样:
public List<Dish> GetByCategory(int categoryId) { using var conn = new SQLiteConnection(_connString); const string sql = @"SELECT DishId, DishName, Price, StockToday, CategoryId FROM Dish WHERE CategoryId = @categoryId AND Status = 1 ORDER BY SortNo"; return conn.Query<Dish>(sql, new { categoryId }).ToList(); }Dapper用@categoryId参数化查询,杜绝拼接SQL的注入风险。Query 泛型方法自动把每行映射成Dish对象,前提是实体属性名和字段名一致,我在Common项目里按字段一一对应定义实体。conn用using声明确保连接用完即关,连接池会复用底层连接,所以频繁开关连接不会有性能问题。
用Dapper还要注意一点:它不追踪实体状态,所有更新都是显式写UPDATE语句。这既是缺点也是优点——不会有EF那种"改了一个字段,整个实体都被更新"的隐患。点菜系统的更新逻辑都很明确(改状态、加菜、改库存),显式SQL反而更安全。真正复杂的报表,比如"某天每桌的翻台率和人均消费",我也是写联表SQL然后用Dapper映射成统计DTO,不引入额外的报表框架。
-- 按桌汇总当日消费和菜品数,常用于结账和营业日报 SELECT t.TableName, COUNT(DISTINCT o.OrderId) AS OrderCnt, SUM(oi.Amount) AS TotalAmount FROM Tables t LEFT JOIN Orders o ON t.TableId = o.TableId AND o.CreateTime >= @today AND o.Status != 3 LEFT JOIN OrderItems oi ON o.OrderId = oi.OrderId GROUP BY t.TableId, t.TableName这条SQL在收银端每天用很多次。LEFT JOIN保证没开台的桌子也出现在结果里,Amount显示为NULL,C#端读取时用result.TotalAmount ?? 0处理即可。Status != 3排除作废订单,Group By桌号汇总,要点是COUNT(DISTINCT o.OrderId)而不是COUNT(*),否则一张桌多轮消费会被重复计算。
5. 点菜系统避坑:并发、卡死、乱码、丢单,我都踩过
5.1 两桌同时抢最后一份肥牛,库存变负数
现象:高峰期两桌客人同时点最后一份肥牛,点菜端都显示"有货",提交后查看库存变成了-1。
原因:客户端是先查询StockToday,判断大于0后显示可点,提交时再执行UPDATE减库存。但两个客户端查询到的是同一份库存,提交时没有在SQL层面对库存做条件限制,"先查后改"在并发下必然出错。
解决:下单事务里带上StockToday >= @count条件,让数据库决定是否成功,而不是用代码判断。这是3.3小节写的方案——受影响行数为0就抛异常回滚。配套在点菜端加一层本地缓存同步:每次打开菜单时刷新一次菜品列表,提交失败时立即重新拉取菜品数据,界面提示"该菜库存不足,已刷新清单"。血泪经验:永远不要让应用层去"判断",数据库的乐观锁才是最后一道防线。
5.2 点"下单"按钮后界面卡成白屏
现象:WinForms点菜端,点击下单按钮后整个窗口卡住,鼠标转圈,几秒后才恢复。
原因:下单操作在UI线程里同步执行,数据库写入、事务提交都阻塞了界面消息循环,期间界面无法响应重绘和点击。火锅店高峰期数据库响应一慢,界面就看起来像死了。
解决:把下单逻辑放进async/await异步方法,UI线程只负责发起和收结果,数据库操作在线程池执行:
private async void btnSubmit_Click(object sender, EventArgs e) { btnSubmit.Enabled = false; try { int orderId = await Task.Run(() => _orderService.CreateOrder(order, items)); lblOrderId.Text = $"下单成功,单号:{orderId}"; } catch (Exception ex) { MessageBox.Show(ex.Message, "下单失败"); } finally { btnSubmit.Enabled = true; } }await Task.Run把CreateOrder放到后台线程执行,界面保持响应;btnSubmit.Enabled=false防止重复点击产生两笔订单。注意async void只用于事件处理器,返回Task的方法里继续用async Task,不然异常会丢失。这条规则同样适用于打印、刷新列表等所有耗时操作。C#多线程的知识在这里体现得很直接,UI线程和后台线程的职责分清楚,界面卡死的问题能消灭一大半。
5.3 后厨打印机打出黑方块和乱码
现象:后厨小票打印机打印出来的单子,中文变成了黑方块或乱码,英文数字正常。
原因:ESC/POS小票打印机默认用GBK/GB2312编码,而C#的字符串默认是Unicode(UTF-16)。直接把字符串交给打印机的文本接口,编码不匹配就乱码。
解决:拼接打印内容时,显式转成GBK编码的字节数组再发送,别让打印机猜编码:
// 需引用 System.Text.Encoding byte[] GetPrintBytes(string content) { Encoding gbk = Encoding.GetEncoding("GBK"); return gbk.GetBytes(content); } // 发送时使用:printPort.Write(bytes, 0, bytes.Length);如果打印机支持网口(TCP 9100端口),用Socket发送同样的字节流即可。还有一个配套坑:部分打印机在中文模式下不认某些特殊字符,比如"①"这类符号,菜名里尽量避免归档。开发时先用厂家自带的打印测试工具跑一遍中文,确认编码正常后再接程序,这一步能省很多现场调试点。我当时是在后厨蹲了一下午,翻来覆去试了Unicode、ASCII、UTF-8都不行,最后换成GBK一次就通了,这种问题不看厂商文档基本是玄学。
5.4 晚上断电,第二天的订单数据没了
现象:门店晚上营业结束直接断总闸,第二天发现当天一部分订单和明细不见了,SQLite文件损坏。
原因:SQLite默认的journal mode是DELETE,没有开WAL(Write-Ahead Logging)。事务提交后数据先写日志,断电发生在日志回放前就会丢数据,极端情况下主库文件损坏。
解决:初始化数据库时执行三条PRAGMA,把SQLite切到WAL模式:
using var conn = new SQLiteConnection(_connString); conn.Execute("PRAGMA journal_mode=WAL;"); conn.Execute("PRAGMA synchronous=NORMAL;"); conn.Execute("PRAGMA busy_timeout=5000;");WAL模式让读写可以并发,写入性能更好,断电恢复能力更强;synchronous=NORMAL在WAL下保证数据安全的同时减少磁盘写次数;busy_timeout=5000让并发写冲突时等待5秒而不是直接报"database is locked"。建议再加一条:每天营业结束后把DianCai.db和DianCai.db-wal两个文件一起备份到另一个盘,比任何数据库修复工具都管用。WAL模式下还会生成DianCai.db-shm文件,备份时只要拷数据库主文件和-wal文件就够了。
5.5 门店换了个路由,所有客户端连不上服务器
现象:门店Wi-Fi路由器更换后,所有点菜客户端都报"无法连接数据库",收银端正常。
原因:数据库连接字符串里写死了服务器的固定IP(比如192.168.1.100),路由器的DHCP重新分配了IP,服务器变成了192.168.1.105,客户端自然连不上。
解决:两个层面。第一,数据库服务器(或主机)在路由器里做IP和MAC地址绑定,保证固定IP不变——这一步要写进部署文档。第二,客户端把连接字符串放到App.config,并提供"数据库设置"界面,让门店在IP变了时自己能改,不依赖开发人员到场。连接字符串允许运行时初始化,不要编译死在代码里:
var config = ConfigurationManager.ConnectionStrings["DianCaiDb"].ConnectionString; // 启动时检查连接,失败则弹出设置窗口,让用户填写新的连接字符串并保存另外建议在点菜端做"服务器心跳检测":启动时后台线程每3秒Ping一次数据库服务器IP,连续3次失败就弹黄色提示条"数据库连接已断开",而不是等服务员点菜时才报错。这样网络问题在营业前就能发现,而不是高峰期才暴露。用C#的TcpClient连一下服务器端口做个简单的连通性检查,比Ping更可靠,因为有些网络环境禁ICMP但TCP端口是通的。
6. 进阶玩法:离线同步、打印队列和分屏叫号怎么落地
6.1 客户端离线缓存:断网也能下单,联网自动补单
Wi-Fi再好的火锅店也有断网的时候。进阶做法是把下单请求先写进本地SQLite队列,网络恢复后逐条同步到主库。本地库表和主库OrderItems同构,多一个SyncFlag字段,0表示未同步,1表示已同步。同步程序用一个后台线程每10秒扫描一次本地未同步订单,逐条调用CreateOrder方法,成功就把SyncFlag置1。
这个方案要注意冲突:离线期间的订单如果扣了本地库存,而主库库存早已被其他桌台用完,同步时会抛出"库存不足"。处理策略是同步失败时把订单标记为"待人工处理",后厨端显示红色警告,让服务员找厨师长手工确认能不能出菜。离线同步的价值在于"营业不中断",而不是"数据零冲突",这点要和门店讲清楚。
6.2 打印消息队列:后厨多台打印机不打架
后厨可能有多台打印机:一个出锅底、一个出荤菜。下单后把所有打印任务放进内存队列,后台打印线程按队列顺序分发到对应打印机,出单端不用等打印完成。用BlockingCollection 实现最简单,它是线程安全的阻塞队列,入队和出队天然线程安全:
var printQueue = new BlockingCollection<PrintJob>(); // 下单成功后:printQueue.Add(new PrintJob(tableId, content, printerName)); // 后台线程:foreach (var job in printQueue.GetConsumingEnumerable()) { SendToPrinter(job); }GetConsumingEnumerable会在队列为空时阻塞等待,不会空转CPU。打印失败的任务放进重试队列,重试3次仍失败就弹窗提醒服务员手工补单。这个队列模式同样适用于分屏叫号:取号逻辑生成一个号码,菜品做好后叫号显示在大屏上,本质也是生产者-消费者。在C#委托和事件的配合下,下单端通过事件把订单号广播给打印服务,打印服务再根据菜品分类决定分发到哪台打印机,各端解耦,后期加新设备不用改下单代码。
做点菜系统这些年,我最大的教训是:程序90%的时间不是在写新功能,而是在防着现场出意外。库存防超卖、断电防丢数据、断网防营业中断,这些"防"比"做"更花功夫。先把这几点想明白,再回头写界面和按钮,心态会完全不一样。希望帮到你。
本文还有配套的精品资源,点击获取