基于C# WPF与MVVM模式的收银系统开发实战
发布时间:2026/9/3 4:33:59 作者:尧图编辑部 阅读量:1,286

简介这是一套基于C#与WPF框架开发的完整超市收银系统源码面向.NET桌面应用初学者及中级开发者聚焦商业软件实战能力提升可直接用于课程设计、毕业项目或小型零售系统二次开发。资源包共564个文件涵盖198个C#业务逻辑文件含MVVM架构下的ViewModel与Model、32个XAML界面定义、64个BAML编译资源、72个运行依赖DLL及多个SQLite数据库文件完整呈现UI层、业务逻辑层与数据访问层的协同实现压缩包大小为57.43MB。已有240人下载学习。读者可获得结构清晰的模块化工程含商品管理、会员结算、订单处理、库存预警、支付模拟等核心功能深入理解WPF数据绑定、命令机制、样式模板及本地数据库事务处理并通过MainWindow、AccountWindow、OrdersWindow等多个窗口的BAML与XAML对照掌握真实商业项目的UI组织方式与状态管理实践。1. 项目概述一个面向真实场景的WPF收银系统最近在整理硬盘时翻出了一个几年前为本地一家小型连锁便利店做的收银系统源码。这个项目虽然不大但麻雀虽小五脏俱全从商品管理、收银结算、会员积分到简单的日结报表该有的功能一个不少。当时选择用C#和WPF来开发主要是考虑到WPF在构建现代化、高交互性桌面应用上的天然优势尤其是其强大的数据绑定和灵活的UI定制能力非常适合需要快速响应和清晰展示的收银场景。这个系统最终稳定运行了几年期间也根据客户反馈迭代了不少功能。今天我就把这个项目的核心源码和设计思路分享出来希望能给正在学习C# WPF、或是打算自己动手写一个管理系统的朋友一些实实在在的参考。无论你是想了解一个完整商业项目的架构还是想学习WPF在数据驱动UI和MVVM模式下的实战应用甚至是需要一套可运行的收银逻辑来练手这份源码都能提供一个不错的起点。2. 系统整体架构与设计思路拆解2.1 为什么选择C# WPF作为技术栈在决定技术栈时我们主要权衡了WinForms和WPF。WinForms开发速度快控件拖拽方便对于简单的表单应用很友好。但收银系统对界面的实时性、美观度和动态数据展示要求较高。比如购物车列表需要实时更新总价商品图片和价格需要清晰展示键盘快捷键操作要流畅。WPF基于DirectX渲染在图形处理上更胜一筹其矢量图形能力使得界面缩放不失真这对于不同尺寸的收银屏幕很重要。更重要的是WPF的**数据绑定Data Binding和命令Command机制与MVVMModel-View-ViewModel**模式是天作之合。在收银系统中收银员的操作如扫码、输入数量直接改变ViewModel中的数据UI会自动同步更新这种数据驱动的方式极大地简化了界面逻辑让代码更清晰、更易于测试和维护。此外.NET生态的成熟度包括对数据库访问如Entity Framework、打印驱动调用、硬件扫码枪、钱箱集成等都有良好的支持这也是选择C#的重要原因。2.2 核心业务模块与数据流设计整个收银系统围绕几个核心业务流程展开其数据流设计是关键。系统主要分为以下几个模块商品管理模块负责商品信息的增删改查包括条码、名称、单价、库存、分类等。这是所有交易的基础。收银结算模块系统的核心处理扫码/输入商品、计算金额、处理折扣会员价、促销、选择支付方式现金、扫码、刷卡、打印小票、更新库存。会员管理模块管理会员信息处理会员积分累积与兑换支持会员价结算。报表统计模块提供日结、月结报表统计销售额、毛利、畅销商品等。数据流上我们采用典型的分层架构表现层View由WPF的XAML界面构成如收银主界面、商品管理窗口。它只负责展示和用户交互不包含业务逻辑。视图模型层ViewModel这是MVVM的核心。每个View如收银界面对应一个ViewModel。它包含界面需要的数据如购物车列表、总计金额和命令如“结算命令”、“清空购物车命令”。ViewModel通过属性通知INotifyPropertyChanged机制让View能自动响应数据变化。模型层Model代表业务实体和数据如Product商品、SaleOrder销售单、Member会员。它们通常是简单的POCOPlain Old CLR Object类。数据访问层DAL封装所有数据库操作。为了简化这个项目使用了Dapper作为微型ORM它轻量、高效直接映射SQL查询结果到Model对象比Entity Framework更适用于这种对性能有轻微要求的桌面应用。注意在小型项目中有时会将业务逻辑放在ViewModel中但对于稍复杂的系统建议引入独立的业务逻辑层BLL专门处理折扣计算、库存校验等规则使ViewModel更专注于UI状态管理。2.3 数据库设计要点数据库采用SQLite因为它无需安装数据库服务器单文件部署方便非常适合桌面应用。核心表设计如下Products商品表Id主键,Barcode条码唯一索引,Name,Price,Cost成本价,Stock库存,CategoryId分类ID。SaleOrders销售主单表Id订单号,CreateTime,TotalAmount应收总额,PaidAmount实收金额,PaymentMethod支付方式,MemberId关联会员。SaleOrderDetails销售明细表Id,OrderId外键,ProductId,Quantity数量,UnitPrice成交单价,SubTotal小计。这里记录成交单价很重要因为商品可能按会员价或促销价出售。Members会员表Id,CardNumber卡号,Name,Phone,Points积分。Categories商品分类表Id,Name。设计时特别注意了事务性。一次收银包含多个操作插入订单主记录、插入多条明细记录、更新多个商品的库存、可能更新会员积分。这些操作必须作为一个原子事务要么全部成功要么全部回滚否则会导致数据不一致例如库存扣了但订单没生成。在代码中我们使用SqliteTransaction来确保这一点。3. 核心功能实现与WPF关键技术解析3.1 收银界面的MVVM实现与数据绑定收银主界面是系统的门面也是MVVM模式体现最充分的地方。我们先看ViewModel的设计public class PosViewModel : INotifyPropertyChanged { // 实现INotifyPropertyChanged接口用于属性变更通知 public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string name null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } // 购物车商品列表 private ObservableCollectionSaleItemViewModel _saleItems; public ObservableCollectionSaleItemViewModel SaleItems { get { return _saleItems; } set { _saleItems value; OnPropertyChanged(); } } // 应收总计计算属性 public decimal TotalAmount SaleItems?.Sum(item item.SubTotal) ?? 0m; // 支付命令 private ICommand _payCommand; public ICommand PayCommand _payCommand ?? (_payCommand new RelayCommand(Pay, CanPay)); private bool CanPay() SaleItems ! null SaleItems.Any(); private void Pay() { // 调用业务逻辑执行结算 // ... // 结算成功后清空购物车 SaleItems.Clear(); OnPropertyChanged(nameof(TotalAmount)); // 手动通知TotalAmount变化 } // 添加商品到购物车的方法 public void AddProductByBarcode(string barcode) { // 查询商品信息... var product _productService.GetProductByBarcode(barcode); if (product ! null) { // 检查购物车是否已有该商品 var existingItem SaleItems.FirstOrDefault(i i.ProductId product.Id); if (existingItem ! null) { existingItem.Quantity 1; } else { SaleItems.Add(new SaleItemViewModel { ProductId product.Id, ProductName product.Name, UnitPrice product.Price, Quantity 1 }); } // 由于TotalAmount是计算属性且SaleItems是ObservableCollection其变化会自动触发UI更新。 // 但TotalAmount本身不是可观察属性需要手动通知见上方的Pay方法中。 } } }对应的XAML界面核心部分如下Window x:ClassSuperMarketPOS.Views.PosView ... Grid !-- 购物车列表区域 -- DataGrid ItemsSource{Binding SaleItems} AutoGenerateColumnsFalse IsReadOnlyTrue DataGrid.Columns DataGridTextColumn Header商品名 Binding{Binding ProductName} Width*/ DataGridTextColumn Header单价 Binding{Binding UnitPrice, StringFormatC2}/ DataGridTextColumn Header数量 Binding{Binding Quantity} DataGridTextColumn.ElementStyle Style TargetTypeTextBlock Setter PropertyHorizontalAlignment ValueCenter/ /Style /DataGridTextColumn.ElementStyle /DataGridTextColumn DataGridTextColumn Header小计 Binding{Binding SubTotal, StringFormatC2}/ /DataGrid.Columns /DataGrid !-- 总计金额显示 -- TextBlock Text{Binding TotalAmount, StringFormat总计: {0:C2}} FontSize24 FontWeightBold/ !-- 结算按钮绑定到ViewModel的PayCommand -- Button Content结算 (F2) Command{Binding PayCommand} FontSize16 Padding20,10/ /Grid /Window关键点解析ObservableCollectionT这是WPF数据绑定的明星集合。当集合中的元素被添加、移除或整个列表被替换时UI会自动更新。这是实现购物车实时刷新的基础。计算属性TotalAmount它依赖于SaleItems。在WPF中简单的计算属性在SaleItems变化时如果SaleItems实现了INotifyCollectionChangedObservableCollection已实现并且绑定模式正确有时能自动更新。但更可靠的做法是在改变SaleItems后手动调用OnPropertyChanged(nameof(TotalAmount))。在上面的Pay方法中我们清空了SaleItems此时需要手动通知TotalAmount已变。命令CommandICommand接口将UI操作如按钮点击与后台逻辑解耦。RelayCommand是一个常用的简易实现它封装了执行逻辑Action和判断能否执行的逻辑Funcbool。这样按钮的IsEnabled状态会自动与CanPay方法的结果关联。输入焦点与扫码枪集成收银界面通常有一个隐藏的TextBox用于接收扫码枪输入。扫码枪模拟键盘输入最后发送一个回车。我们在该TextBox的KeyDown事件中监听回车键然后调用ViewModel的AddProductByBarcode方法。3.2 商品管理与数据持久化商品管理界面涉及对数据库的CRUD操作。我们使用Repository模式来抽象数据访问。以商品为例public interface IProductRepository { Product GetByBarcode(string barcode); IEnumerableProduct GetAll(); int Add(Product product); bool Update(Product product); bool Delete(int id); } public class ProductRepository : IProductRepository { private readonly string _connectionString; public ProductRepository(string connStr) { _connectionString connStr; } public Product GetByBarcode(string barcode) { using (var conn new SQLiteConnection(_connectionString)) { return conn.QueryFirstOrDefaultProduct( SELECT * FROM Products WHERE Barcode Barcode, new { Barcode barcode }); } } public int Add(Product product) { using (var conn new SQLiteConnection(_connectionString)) { // 使用Dapper的ExecuteScalar获取插入后的自增ID string sql INSERT INTO Products (Barcode, Name, Price, Cost, Stock, CategoryId) VALUES (Barcode, Name, Price, Cost, Stock, CategoryId); SELECT last_insert_rowid();; return conn.ExecuteScalarint(sql, product); } } // ... 其他方法 }在ViewModel中我们通过依赖注入这里为了简化可能直接实例化获得Repository的实例然后在命令中调用它们。例如“保存商品”命令public ICommand SaveProductCommand new RelayCommand(SaveProduct); private void SaveProduct() { // 验证输入... if (CurrentProduct.Id 0) // 新增 { var newId _productRepository.Add(CurrentProduct); CurrentProduct.Id newId; Products.Add(CurrentProduct); // 更新UI列表 } else // 编辑 { _productRepository.Update(CurrentProduct); // 需要更新UI列表中的对应项这里可能需要刷新列表或找到对应项更新 } // 清空表单或关闭窗口... }实操心得在桌面应用中虽然不像Web应用那样严格但适当的分层如Repository依然能让代码更清晰。Dapper的性能很好但需要手写SQL对于复杂查询要小心SQL注入务必使用参数化查询如上例中的Barcode。3.3 会员积分与折扣策略会员系统增加了业务的灵活性。我们设计了一个简单的策略模式来处理折扣。例如可能有普通价、会员价、满减促销等。public interface IDiscountStrategy { decimal CalculateFinalPrice(decimal originalPrice, Member member null); } public class MemberDiscountStrategy : IDiscountStrategy { private readonly decimal _discountRate; // 例如0.9表示9折 public decimal CalculateFinalPrice(decimal originalPrice, Member member) { if (member ! null) { return originalPrice * _discountRate; } return originalPrice; // 非会员原价 } } public class PromotionDiscountStrategy : IDiscountStrategy { // 满100减10 public decimal CalculateFinalPrice(decimal originalPrice, Member member null) { if (originalPrice 100m) { return originalPrice - 10m; } return originalPrice; } }在结算时我们可以根据商品或全局设置选择合适的策略来计算折后价。同时结算后需要更新会员积分// 在结算业务逻辑中 foreach (var item in saleItems) { var finalPrice _discountStrategy.CalculateFinalPrice(item.UnitPrice, currentMember); item.ActualUnitPrice finalPrice; item.SubTotal finalPrice * item.Quantity; totalAmount item.SubTotal; } // 更新会员积分假设1元积1分 if (currentMember ! null) { int earnedPoints (int)totalAmount; // 实际可能更复杂 currentMember.Points earnedPoints; _memberRepository.UpdatePoints(currentMember.Id, currentMember.Points); }4. 开发中的难点与解决方案实录4.1 WPF多线程与UI更新收银过程中如果执行一个耗时的操作比如从网络验证会员卡、批量导入商品直接在UI线程上做会导致界面卡死。必须使用后台线程。但WPF有个严格规定只有创建UI元素的线程通常是主UI线程才能更新这些元素。这意味着你不能在后台线程中直接修改绑定到UI的ObservableCollection。解决方案是使用Dispatcher// 错误的做法在后台线程中直接修改UI绑定的集合 Task.Run(() { var heavyData LoadHeavyDataFromDatabase(); SaleItems.Clear(); // 如果SaleItems绑定到UI这里会抛出异常 foreach(var item in heavyData) { SaleItems.Add(item); } }); // 正确的做法通过Dispatcher.Invoke回到UI线程执行更新 Task.Run(() { var heavyData LoadHeavyDataFromDatabase(); // 获取当前窗口的Dispatcher Application.Current.Dispatcher.Invoke(() { SaleItems.Clear(); foreach(var item in heavyData) { SaleItems.Add(item); } }); });对于集合的大量更新频繁调用Dispatcher.Invoke可能影响性能。可以考虑使用BindingOperations.EnableCollectionSynchronization方法为集合启用跨线程访问的同步但这需要更仔细的处理。对于简单的进度更新更常见的模式是在ViewModel中暴露一个string StatusMessage属性后台线程通过Dispatcher更新它UI通过绑定显示。4.2 处理硬件集成扫码枪与钱箱扫码枪通常被识别为键盘输入设备集成相对简单。难点在于如何区分是人工键盘输入还是扫码枪输入。常见的做法是设置一个隐藏的TextBox并让其获得焦点。监听其KeyDown事件当检测到回车键扫码枪通常在输入条码后发送回车时获取文本框内容即为扫码内容。立即清空文本框内容为下一次扫码做准备。钱箱控制一般通过串口COM或并口发送一个脉冲信号。需要调用Windows API或使用SerialPort类。这里有个大坑权限问题。在Windows 10/11上访问COM端口可能需要管理员权限。解决方案要么是以管理员身份运行程序要么在清单文件中设置requestedExecutionLevel。// 使用SerialPort控制钱箱示例具体指令需参考钱箱说明书 public bool OpenCashDrawer() { try { using (var sp new SerialPort(COM1, 9600, Parity.None, 8, StopBits.One)) { sp.Open(); // 发送开钱箱指令例如 ESC p byte[] openCmd new byte[] { 0x1B, 0x70, 0x00, 0x40, 0xFF }; sp.Write(openCmd, 0, openCmd.Length); sp.Close(); return true; } } catch (Exception ex) { // 记录日志提示用户检查钱箱连接 return false; } }4.3 打印小票的设计小票打印通常有两种方式使用Windows系统打印对话框PrintDialog或直接驱动特定型号的小票打印机如EPSON TM系列。对于收银系统更常用后者因为需要精确控制格式和切纸。我们采用直接发送ESC/POS指令到打印机的方式。这需要将小票内容文本、格式、条形码转换为打印机识别的字节流。public void PrintReceipt(SaleOrder order, ListSaleOrderDetail details) { // 1. 构建打印内容字符串 StringBuilder sb new StringBuilder(); sb.AppendLine( 某某超市); sb.AppendLine( order.CreateTime.ToString(yyyy-MM-dd HH:mm:ss)); sb.AppendLine(------------------------); foreach (var detail in details) { sb.AppendLine(${detail.ProductName.PadRight(20)}); sb.AppendLine($ {detail.Quantity} x {detail.UnitPrice:C2} {detail.SubTotal:C2}); } sb.AppendLine(------------------------); sb.AppendLine($总计{order.TotalAmount:C2}); sb.AppendLine($实收{order.PaidAmount:C2}); sb.AppendLine($找零{(order.PaidAmount - order.TotalAmount):C2}); sb.AppendLine(Environment.NewLine); // 多空几行便于撕纸 sb.AppendLine(new string(-, 32)); // 分隔线 sb.AppendLine(\x1B\x64\x03); // ESC/POS 指令切纸部分切 // 2. 发送到打印机 RawPrinterHelper.SendStringToPrinter(EPSON TM-T88V, sb.ToString()); } // RawPrinterHelper是一个封装了Win32打印API的辅助类 public static class RawPrinterHelper { [DllImport(winspool.Drv, SetLastError true)] public static extern bool SendStringToPrinter(string szPrinterName, string szString); // ... 具体实现需要调用OpenPrinter, StartDocPrinter, WritePrinter等API }注意事项小票格式如列对齐需要仔细计算字符宽度中英文混合时尤其麻烦。可以尝试使用等宽字体或直接发送制表符\t。不同的打印机型号支持的ESC/POS指令集可能有细微差别需要查阅对应型号的编程手册。打印是阻塞操作最好放在后台线程执行避免卡住UI。5. 项目部署与后期维护思考5.1 数据库部署与连接管理SQLite数据库文件.db或.db3可以放在应用程序目录下。首次运行时如果文件不存在需要执行建表SQL脚本。连接字符串通常这样写Data SourceSuperMarket.db;Version3;。为了便于管理我们将连接字符串放在App.config配置文件中。!-- App.config -- configuration connectionStrings add nameDefaultConnection connectionStringData SourceSuperMarket.db;Version3; / /connectionStrings /configuration在代码中通过ConfigurationManager读取。对于多收银端共享数据库的情况SQLite就不合适了需要升级到SQL Server Express或MySQL并将数据库文件放在网络共享位置。这时要特别注意并发访问和网络延迟问题。5.2 版本更新与数据迁移桌面应用更新是个问题。我们采用简单的办法每次发布新版本时将主程序.exe和必要的DLL打包成安装包。对于数据库架构升级如新增字段需要在程序启动时检查数据库版本可以单独建一个VersionInfo表并自动执行迁移脚本。public void InitializeDatabase() { using (var conn new SQLiteConnection(_connectionString)) { var currentVersion conn.ExecuteScalarint(SELECT Version FROM VersionInfo LIMIT 1); if (currentVersion 2) { // 执行从v1到v2的迁移脚本 conn.Execute(ALTER TABLE Products ADD COLUMN IsActive INTEGER DEFAULT 1;); conn.Execute(UPDATE VersionInfo SET Version 2;); } // ... 检查其他版本 } }5.3 日志记录与错误排查一个健壮的系统必须有日志。我们使用轻量级的NLog或log4net。在App.config中配置日志将不同级别的日志Info, Error, Warn输出到文件和控制台。private static readonly NLog.Logger Logger NLog.LogManager.GetCurrentClassLogger(); public void ProcessSale() { try { Logger.Info(开始处理销售单...); // ... 业务逻辑 Logger.Info($销售单{orderId}处理成功金额{totalAmount}); } catch (Exception ex) { Logger.Error(ex, 处理销售单时发生错误); // 给用户友好的提示 MessageBox.Show(结算失败请稍后重试或联系管理员。); } }当用户报告问题时可以请他们发送日志文件这样能快速定位是数据库连接问题、权限问题还是业务逻辑异常。5.4 性能优化与用户体验列表虚拟化商品列表或销售历史记录如果数据量大WPF的DataGrid或ListBox必须启用UI虚拟化VirtualizingStackPanel.IsVirtualizingTrue只渲染可视区域内的项极大提升滚动性能。异步操作所有可能耗时的I/O操作数据库查询、文件读写、网络请求都应使用async/await异步模式保持UI响应流畅。输入响应收银界面要确保键盘快捷键如F2结算、ESC清空响应迅速。避免在按键事件处理中做耗时操作。6. 源码结构与扩展建议分享的源码项目结构大致如下SuperMarketPOS/ ├── SuperMarketPOS.sln ├── SuperMarketPOS/ │ ├── Models/ # 实体类 (Product, SaleOrder, Member...) │ ├── ViewModels/ # 视图模型 (PosViewModel, ProductManageViewModel...) │ ├── Views/ # XAML窗口 (PosView.xaml, ProductManageView.xaml...) │ ├── Services/ # 业务逻辑层 (SaleService, MemberService...) │ ├── Repositories/ # 数据访问层 (ProductRepository...) │ ├── Utilities/ # 工具类 (RawPrinterHelper, Logger...) │ ├── App.xaml │ └── MainWindow.xaml ├── Database/ │ ├── Scripts/ # SQL建表脚本 │ └── SuperMarket.db # SQLite数据库文件初始空库或示例数据 └── README.md # 项目说明和运行指南如何运行用Visual Studio 2022或更高版本打开解决方案还原NuGet包主要是System.Data.SQLite和Dapper将Database/SuperMarket.db设置为“如果较新则复制”到输出目录然后编译运行即可。可能的扩展方向网络化与云同步将SQLite改为客户端数据库增加一个服务端可以用ASP.NET Core Web API实现多店数据同步、集中式商品管理和远程报表查看。更复杂的促销引擎实现一个可配置的促销规则引擎支持“买二赠一”、“第二件半价”、“满额减”等复杂组合促销。库存预警与采购建议根据历史销售数据设置库存阈值自动生成采购建议单。对接第三方支付集成微信支付、支付宝的扫码支付SDK实现真正的扫码收款。界面主题与皮肤利用WPF的样式和模板实现可切换的界面主题适应不同商户的审美。这个项目最宝贵的价值不在于代码本身而在于它展示了一个真实可用的WPF MVVM应用如何从设计到实现。在开发过程中我深刻体会到前期花时间在架构设计尤其是数据流和职责分离上后期增加功能或修改会轻松很多。例如因为使用了MVVM当需要将收银界面从Window改为全屏的Page或者适配触摸屏时ViewModel几乎不需要改动。另外对于硬件集成部分一定要留出足够的调试时间并准备好不同厂商的设备说明书。最后即使是一个小系统也务必从一开始就考虑日志和异常处理这在排查现场问题时能救命。本文还有配套的精品资源点击获取