☰
C#+MySQL实现WinForm商品销售管理系统:事务与库存扣减实战
2026/10/7 18:45:23 网站建设 项目流程

简介:这是一份基于C#与MySQL的WinForm商品销售与管理系统课程设计资源,适合做课设或毕业设计的初学者。系统覆盖用户、商品、订单三大数据源,包含登录验证、权限判断、收银台、商品/用户/订单管理等子系统,并支持增删改查、模糊查询、批量修改、库存补充和异常处理。 资源共127个文件,压缩包13.93MB,cs源文件与resx/resources构成核心工程,png为界面截图,xmind梳理模块结构,SQL脚本初始化数据库,docx与PDF提供说明参考,附带sln解决方案可在Visual Studio中直接运行。 已有343人学习浏览。下载后可获得完整源码、数据库脚本、功能结构图与界面效果图,既能节省搭建时间,也能借鉴事件处理、MySQL访问、权限控制和异常捕获等实现思路。

1. 商品销售与管理系统:C#+MySQL的WinForm方案适合谁,难点在哪

小门店老板把销货记在Excel里,月底对账全靠回忆,这是很多商品销售与管理系统立项的起点。基于C#+MySQL实现的WinForm商品销售与管理系统,用桌面窗体管商品资料、采购入库、销售开单、库存流水,数据统一落进MySQL。它适合三类人:正在找C#练手项目的学生、需要给中小门店交付进销存的一线开发、以及想参考WinForm项目案例的桌面工程师。这个组合开发速度快、部署简单、总体成本低;真正的难处不在画界面,而在数据库设计和销售扣库存的并发安全。

很多人上来就把所有代码堆进Form1.cs,按钮事件里直接拼SQL,结果项目没跑三个月,改个字段要翻遍十几个窗体。这篇文章会从工程骨架、MySQL表结构、销售事务、避坑清单一直聊到打包交付,按一条能直接复现的路线走。过程中我会把连接串参数、事务边界、行锁扣库存这些容易翻车的点单独拎出来讲。

2. 搭建WinForm骨架:工程分层、登录跳转与MySQL连接

2.1 为什么不能把业务逻辑都堆在Form1.cs里

我接过不少WinForm项目,十个里面有七个把查询、赋值、写库全写在按钮Click事件里。项目刚起步时这样写很快,但一旦要增加采购、报表、权限,代码就变成一锅粥。所以即使是这个规模的项目,我也习惯先按UI、BLL、DAL、Model四层切目录。Model放商品、订单、库存的实体;DAL只负责SQL和MySQL读写;BLL处理库存扣减、金额计算这类业务规则;UI层只做输入输出和窗体跳转。WinForm不像Web有完整的IOC容器,我不会硬上MVVM,但会写一个静态的AppService类保存当前登录用户和数据库连接工厂。

分层后的调用关系其实很简单:UI调用BLL,BLL调用DAL,DAL操作MySQL。这样做最大的好处是,将来把MySQL换成SQL Server,只需要改DAL层;想在BLL层加一个“不足库存不允许开单”的规则,也不用去DataGridView的单元格事件里找代码。VS2015和VS2019都能直接建这种普通WinForm工程,不需要额外装插件。

层典型文件职责
UILoginForm.cs, MainForm.cs, GoodsForm.cs收集输入、展示结果,不写SQL
BLLGoodsService.cs, OrderService.cs库存判断、金额计算、事务编排
DALGoodsRepository.cs, OrderRepository.cs参数化SQL、统一连接管理
ModelGoods.cs, SalesOrder.cs, CartItem.cs属性对应表字段,只存数据
CommonDbHelper.cs, AppService.cs连接读取、当前用户等公共状态

2.2 用NuGet装好MySQL驱动并把连接字符串放进App.config

连接MySQL最简单的方式是官方Connector。在项目上右键管理NuGet程序包,搜索MySql.Data安装。喜欢写轻量SQL的可以再装Dapper,但不是必须。这里有一个很常见的翻车点:Connector版本和MySQL服务器版本差太多,MySQL 8.0默认的caching_sha2_password认证插件会让旧版Connector直接报错。一般做法是装MySql.Data 8.0.x,然后在连接字符串里把两个参数写清楚。

<?xml version="1.0" encoding="utf-8"?> <configuration> <connectionStrings> <add name="ShopDb" connectionString="Server=127.0.0.1;Port=3306;Database=shop;Uid=root;Pwd=123456;Charset=utf8mb4;SslMode=none;AllowPublicKeyRetrieval=true;" providerName="MySql.Data.MySqlClient" /> </connectionStrings> </configuration>

SslMode=none是为了避开MySQL默认的SSL验证;AllowPublicKeyRetrieval=true解决MySQL 8.0第一次连接时“Public Key Retrieval is not allowed”的提示;Charset=utf8mb4保证中文不会乱码。如果MySQL装在另一台机器,Server要改成内网IP,并且MySQL账号必须允许远程主机访问。连接字符串不要直接写死在代码里,原因后面避坑章节会详细说。

读取连接字符串的代码我放在Common/DbHelper.cs里,顺便写一个创建并打开连接的方法。

using MySql.Data.MySqlClient; using System.Configuration; namespace ShopManage.Common { public static class DbHelper { public static string ConnStr => ConfigurationManager.ConnectionStrings["ShopDb"].ConnectionString; public static MySqlConnection OpenConnection() { var conn = new MySqlConnection(ConnStr); conn.Open(); return conn; } } }

ConnectionString属性用只读表达式,调用方每次拿到的都是新连接。WinForm里不要缓存同一个MySqlConnection长期用,连接池本身会复用底层连接,你只需要保证每个操作短生命周期即可。注意OpenConnection里没做try/catch,为的是让连接错误尽早暴露在调用方,而不是被吞掉后留下一堆诡异空数据。

2.3 登录窗体到主窗体:ShowDialog比Show更可控

登录是程序的门。很多新手在Program.cs里直接new MainForm().Show(),结果登录还没完成主窗体就出来了。正确做法是用ShowDialog弹登录框,等登录成功后再启动主窗体。

using System; using System.Windows.Forms; using ShopManage.UI.Forms; namespace ShopManage.UI { static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); var login = new LoginForm(); if (login.ShowDialog() != DialogResult.OK) return; Application.Run(new MainForm()); } } }

用ShowDialog弹登录框,点取消或关闭时DialogResult不是OK,就直接return,进程退出,主窗体没有机会在未登录状态下打开。LoginForm里,点击登录按钮执行的是BLL层的ValidateUser方法,内部用参数化SQL查用户表,并比对密码哈希。密码不要用明文存储,至少做一次SHA256加盐。

private void btnLogin_Click(object sender, EventArgs e) { var user = bll.Login(txtUserName.Text.Trim(), txtPassword.Text); if (user == null) { MessageBox.Show("用户名或密码错误", "登录失败"); return; } AppService.CurrentUser = user; this.DialogResult = DialogResult.OK; this.Close(); }

bll是BLL层的登录服务,Login方法内部会用MySqlCommand参数化查询,把输入的密码哈希后和数据库比对。AppService.CurrentUser是静态属性,方便主窗体显示当前操作员名称,也方便后面做菜单权限控制。主窗体在构造函数里读取CurrentUser.Role,如果是收银员角色,就把进货管理、库存调整这些菜单禁用掉。这一层控制不算复杂,但足够拦住大多数误操作。

3. 数据库设计与数据访问层:建表、事务与Dapper的使用边界

3.1 商品、销售单、明细、库存流水四张表的建表SQL

设计上我把当前库存字段放在商品表里,但每次变动都写一张库存流水表。这样查询“现在还剩多少”很快,追溯“这批货什么时候进的、哪张单出的”也有据可查。只建商品表和订单表的项目,后面要查历史库存时一定会后悔。

CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4; USE shop; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, user_name VARCHAR(50) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL, role TINYINT NOT NULL DEFAULT 2 COMMENT '1管理员 2收银员', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_goods ( id INT AUTO_INCREMENT PRIMARY KEY, goods_code VARCHAR(30) NOT NULL UNIQUE COMMENT '条码或商品编码', goods_name VARCHAR(100) NOT NULL, spec VARCHAR(50) DEFAULT '' COMMENT '规格', price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '售价', cost_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '成本价', stock_qty INT NOT NULL DEFAULT 0 COMMENT '当前库存', status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_sales_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, sale_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(200) DEFAULT '' ) ENGINE=InnoDB; CREATE TABLE t_sales_order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, goods_id INT NOT NULL, goods_name VARCHAR(100) NOT NULL COMMENT '冗余商品名,防止商品改名后历史订单混乱', price DECIMAL(10,2) NOT NULL, qty INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, KEY idx_order_id (order_id) ) ENGINE=InnoDB; CREATE TABLE t_stock_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, goods_id INT NOT NULL, change_qty INT NOT NULL COMMENT '正数入库,负数出库', order_no VARCHAR(32) DEFAULT '' COMMENT '关联单号', op_user_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_goods (goods_id) ) ENGINE=InnoDB;

订单明细表里冗余goods_name,目的是防止商品改名或删除后,历史订单里的商品名变成一串无意义的ID。这是个常见设计取舍:宁可多存一个字段,也不要以后联表查不到。字段类型用DECIMAL而不用float/double,金额精度是这类系统的生命线。InnoDB必须指定,因为后面要用事务和行锁。

3.2 C#数据访问层的基本写法:参数化SQL不能省

DAL类里我不会用字符串拼接SQL,那等于把后门打开。MySqlCommand的参数化写法并不难,但很多人图快用$""直接拼,结果一次单引号就让系统崩掉,更别提SQL注入风险。

public class GoodsRepository { public List<Goods> Search(string keyword) { var list = new List<Goods>(); using (var conn = DbHelper.OpenConnection()) using (var cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.CommandText = "SELECT id, goods_code, goods_name, price, stock_qty " + "FROM t_goods WHERE status=1 AND " + "(goods_code LIKE @kw OR goods_name LIKE @kw) " + "ORDER BY goods_code LIMIT 100"; cmd.Parameters.AddWithValue("@kw", "%" + keyword + "%"); using (var reader = cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new Goods { Id = reader.GetInt32("id"), GoodsCode = reader.GetString("goods_code"), GoodsName = reader.GetString("goods_name"), Price = reader.GetDecimal("price"), StockQty = reader.GetInt32("stock_qty") }); } } } return list; } }

using确保连接和命令都及时释放,不会把MySQL连接占满。参数化查询把keyword当成数据而不是SQL片段,这是写数据访问层的基本底线。LIMIT 100是防止用户输入一个空格就查全表,把数据库和WinForm界面一起拖死。

3.3 使用Dapper简化读取,但写事务时回到原生连接

如果不想写DataReader,可以用Dapper。我在简单查询时用Dapper,比如:

using Dapper; public Goods GetById(int id) { using (var conn = DbHelper.OpenConnection()) { return conn.QueryFirstOrDefault<Goods>( "SELECT id, goods_code, goods_name, price, stock_qty " + "FROM t_goods WHERE id=@id", new { id = id }); } }

Dapper会把查询结果映射到Goods实体,代码量少一半。但要注意:Dapper不是完整ORM,不带状态跟踪和缓存;碰上事务和行锁,我仍然用MySqlConnection原生命令,因为控制粒度更直接。很多人以为ORM万能,实际上一旦要手动加FOR UPDATE、控制事务隔离级别,Dapper用起来反而碍手碍脚。

3.4 销售开单的事务模板:同一个连接里完成三步写库

这里是最容易翻车的地方。很多新手把订单头Insert、订单明细Insert、库存Update分开执行,任何一步失败,数据库都会留下半截数据。正确做法是开一个事务,把所有写库操作放在同一个连接和同一个事务里,最后Commit,异常时Rollback。

using MySql.Data.MySqlClient; public bool CreateOrder(List<CartItem> items, string remark) { using (var conn = DbHelper.OpenConnection()) { var tx = conn.BeginTransaction(); try { var total = items.Sum(x => x.Price * x.Qty); var orderNo = DateTime.Now.ToString("yyyyMMddHHmmssfff"); long orderId; using (var cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; cmd.CommandText = "INSERT INTO t_sales_order(order_no, user_id, total_amount, remark) " + "VALUES(@orderNo, @user, @total, @rem); SELECT LAST_INSERT_ID();"; cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@user", AppService.CurrentUser.Id); cmd.Parameters.AddWithValue("@total", total); cmd.Parameters.AddWithValue("@rem", remark); orderId = Convert.ToInt64(cmd.ExecuteScalar()); } foreach (var item in items) { using (var cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; cmd.CommandText = "INSERT INTO t_sales_order_item(order_id, goods_id, goods_name, price, qty, subtotal) " + "VALUES(@oid, @gid, @name, @price, @qty, @sub);"; cmd.Parameters.AddWithValue("@oid", orderId); cmd.Parameters.AddWithValue("@gid", item.GoodsId); cmd.Parameters.AddWithValue("@name", item.GoodsName); cmd.Parameters.AddWithValue("@price", item.Price); cmd.Parameters.AddWithValue("@qty", item.Qty); cmd.Parameters.AddWithValue("@sub", item.Price * item.Qty); cmd.ExecuteNonQuery(); } using (var cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; cmd.CommandText = "UPDATE t_goods SET stock_qty = stock_qty - @qty " + "WHERE id = @gid AND stock_qty >= @qty;"; cmd.Parameters.AddWithValue("@qty", item.Qty); cmd.Parameters.AddWithValue("@gid", item.GoodsId); int affected = cmd.ExecuteNonQuery(); if (affected == 0) { throw new Exception($"商品 {item.GoodsName} 库存不足"); } } using (var cmd = new MySqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; cmd.CommandText = "INSERT INTO t_stock_log(goods_id, change_qty, order_no, op_user_id) " + "VALUES(@gid, -@qty, @orderNo, @user);"; cmd.Parameters.AddWithValue("@gid", item.GoodsId); cmd.Parameters.AddWithValue("@qty", item.Qty); cmd.Parameters.AddWithValue("@orderNo", orderNo); cmd.Parameters.AddWithValue("@user", AppService.CurrentUser.Id); cmd.ExecuteNonQuery(); } } tx.Commit(); return true; } catch { tx.Rollback(); throw; } } }

每个命令都带上cmd.Transaction,确保它们属于同一个事务。扣减库存这条UPDATE是关键:它不是先查库存再判断,而是直接在UPDATE语句里加上stock_qty >= @qty条件。如果库存不够,受影响行数为0,立刻抛异常,整个事务回滚,订单头和明细都不会残留。这种写法同时解决了并发超卖问题,后面第4章会细讲。

4. 核心业务落地:商品管理、销售开单与库存扣减的细节

4.1 商品管理窗体:DataGridView绑定与软删除

商品管理窗体的功能就是增删改查。加载时用上一章的Search方法把数据绑定到DataGridView,注意要把Id那一列设为隐藏,否则客户会看到一堆数字。

private void LoadGoods() { var list = goodsService.Search(txtKeyword.Text.Trim()); dataGridView1.DataSource = list; dataGridView1.Columns["Id"].Visible = false; dataGridView1.Columns["GoodsCode"].HeaderText = "商品编码"; dataGridView1.Columns["GoodsName"].HeaderText = "商品名称"; dataGridView1.Columns["Price"].HeaderText = "售价"; dataGridView1.Columns["StockQty"].HeaderText = "库存"; }

删除按钮事件里,我一般不会直接DELETE,而是调用BLL层的Delete方法做软删除,把status字段改成0。这样历史订单里的商品关联还能查到,只是不再出现在销售列表中。

private void btnDelete_Click(object sender, EventArgs e) { if (dataGridView1.SelectedRows.Count == 0) { MessageBox.Show("请先选择要删除的商品"); return; } int id = Convert.ToInt32(dataGridView1.SelectedRows[0].Cells["Id"].Value); if (MessageBox.Show("确认将该商品下架?", "删除确认", MessageBoxButtons.YesNo) == DialogResult.Yes) { goodsService.SoftDelete(id); LoadGoods(); } }

给用户一个确认框,减少误操作。真实场景里,手一抖点错删除按钮的事太多了,软删除就是后悔药。

4.2 销售开单:多行明细录入与金额计算

销售开单界面一般是一个TextBox用来扫描条码,一个DataGridView用来展示购物车,下面一个Label显示合计。扫描条码或手工输入编码后回车,首先要根据商品编码查出商品,再判断购物车里有没有这行,有就把数量加1,没有则新增一行。

private void txtBarcode_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode != Keys.Enter) return; var code = txtBarcode.Text.Trim(); var goods = goodsService.GetByCode(code); if (goods == null) { MessageBox.Show("没有找到这个商品"); return; } if (goods.StockQty <= 0) { MessageBox.Show("商品库存不足,不能销售"); return; } var index = FindGoodsRow(goods.Id); if (index >= 0) { dataGridView1.Rows[index].Cells["Qty"].Value = Convert.ToInt32(dataGridView1.Rows[index].Cells["Qty"].Value) + 1; } else { dataGridView1.Rows.Add(goods.Id, goods.GoodsCode, goods.GoodsName, goods.Price, goods.StockQty, 1, goods.Price); } txtBarcode.Clear(); txtBarcode.Focus(); CalcTotal(); }

FindGoodsRow就是遍历DataGridView的行,判断第一列的Id是否等于当前商品Id。数量列我用的是DataGridViewTextBoxColumn,用户可以直接改数量。改完后触发CellEndEdit事件,重新计算这行小计和整单金额。

4.3 提交开单:把UI明细交给BLL层,用异步避免界面卡死

提交按钮点击后,不能直接在UI线程里跑事务,否则数据量大时窗体就变成“未响应”。我用async/await加Task.Run把数据库操作放到线程池。

private async void btnPay_Click(object sender, EventArgs e) { var items = GetCartItems(); if (items.Count == 0) { MessageBox.Show("购物车是空的"); return; } btnPay.Enabled = false; try { var orderService = new OrderService(); var ok = await Task.Run(() => orderService.CreateOrder(items, txtRemark.Text)); if (ok) { MessageBox.Show("下单成功"); ResetCart(); } } catch (Exception ex) { MessageBox.Show($"下单失败:{ex.Message}"); } finally { btnPay.Enabled = true; } }

async void事件处理器在WinForm里很常见,但里面的异常必须自己catch,否则会直接崩掉进程。Task.Run把CreateOrder放到后台线程执行,这样界面还能响应鼠标点击。这里有一个容易被忽略的点:GetCartItems和CreateOrder不要都放到后台任务里,因为GetCartItems要访问DataGridView控件,只能在UI线程上操作。跨线程访问UILabel或DataGridView会抛System.InvalidOperationException,这是很多初学WinForm的人被吓到的地方。

4.4 金额计算一定用decimal,别用double

商品价格、折扣、小计、总计全部用decimal类型。double在0.1 + 0.2这种计算上会得到0.30000000000000004,界面显示不难看,但对账就差一分钱。C#里decimal的精度是28-29位有效数字,足够覆盖绝大多数商品销售场景。

private decimal GetSubtotal(decimal price, int qty) { return Math.Round(price * qty, 2, MidpointRounding.AwayFromZero); }

Math.Round的第二个参数是保留小数位数,第三个参数AwayFromZero表示四舍五入时远离零。银行家算法在这里反而不是门店想要的,门店要的是“四舍五入到分”。

5. 上线前避坑:MySQL连接、界面卡顿与打包安装的5个典型问题

5.1 MySQL 8.0连接报错:认证插件和SSL的双重坑

现象:程序在开发机跑得好好的,部署到客户机器上,一点查询就报“Authentication plugin 'caching_sha2_password' cannot be loaded”或者“SSL Connection Error”。

原因:MySQL 8.0默认使用caching_sha2_password认证插件,而项目引用的MySql.Data是6.x或更早版本,不认识这个插件;另外MySQL 8.0默认开启SSL,WinForm客户端如果没有正确配置,连接会被安全协议卡住。

解决:把NuGet里的MySql.Data升到8.0.x,连接字符串里加SslMode=none;AllowPublicKeyRetrieval=true;,这两个参数缺一不可。SslMode=none告诉客户端不要强求SSL握手,AllowPublicKeyRetrieval=true允许客户端从服务器取RSA公钥做密码传输。注意这个组合只适合内网生产环境,公网部署时应该反过来开启SSL验证。

5.2 中文乱码:数据库、表、连接串三处统一utf8mb4

现象:插入商品名称“耐克”变成“耐??”,或者从MySQL查到C#里变成字符串带问号。

原因:数据库建表时用了latin1,连接串也没指定字符集,C#端默认UTF-16,两边一协商就乱码。

解决:建库语句加DEFAULT CHARACTER SET utf8mb4,每个表也显式指定ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,连接字符串加Charset=utf8mb4。这三处缺一处都可能出问题。我见过有人在代码里执行SET NAMES gbk绕过,结果换个环境又乱。统一utf8mb4是现在的标准做法,MySQL 5.7.44和8.0都支持。

5.3 库存扣成负数:没有做扣减前检查

现象:两个收银员同时卖同一件商品,一个显示库存只剩1件,却开出两张单,最后库存变成-1。

原因:代码写成“先SELECT stock_qty,判断大于0,再UPDATE”,两个线程可能同时通过判断,然后都执行UPDATE,后写的把负数覆盖上去。

解决:用UPDATE t_goods SET stock_qty = stock_qty - @qty WHERE id = @gid AND stock_qty >= @qty;,执行后检查受影响行数,为0就抛异常回滚。这条语句里的条件判断和更新是原子操作,InnoDB会对命中的行加锁,后到的请求会等待,等锁释放后重新判断条件,发现库存不足就不更新。这个写法的前提是事务隔离级别不能是READ UNCOMMITTED,默认的REPEATABLE READ就够用。

5.4 WinForm界面卡顿:主线程做了耗时数据库查询

现象:点击“生成报表”后,整个窗体变成白屏,鼠标转圈,几分钟后才有响应;或者启动登录时卡顿几秒。

原因:数据库查询、Excel导出、大集合排序都在UI线程同步执行。MySQL查询本身可能只要100毫秒,但界面重绘被阻塞后,用户体感会被放大到几秒。

解决:耗时操作全部用async/await,数据库查询放到Task.Run里,定时刷新用System.Windows.Forms.Timer。更新控件时如果从后台线程操作,用Control.Invoke或BeginInvoke。我一般把耗时操作封装成Task 方法,UI层await,回调里再赋值。这里还要注意一个坑:async void事件里忘记catch异常,任务异常会直接抛到主线程导致崩溃,所以事件方法必须有try/catch/finally。

5.5 打包安装后连不上数据库:连接字符串写死在代码里

现象:在开发机上用Visual Studio跑得好好的,用打包工具装到别的电脑,启动程序就报“无法连接MySQL”。

原因:开发时为了让测试方便,把Server=127.0.0.1、Uid=root、Pwd=123456直接写在代码里。客户电脑上的MySQL不在这台机器,或者账号密码完全不同,程序当然连不上。

解决:连接字符串放在App.config,部署时根据客户环境改配置文件,不要改代码重新编译。打包安装程序时,把App.config一并带上,并在部署清单里写明MySQL的IP、端口、数据库名。更好的做法是做一个“数据库连接测试”窗口,安装后第一次启动时让用户填服务器和账号,测试通过后保存到本地配置文件。这个窗口不复杂,但能省掉大量现场调试时间。

6. 从能用到好用:报表导出、权限控制与一键打包

商品销售系统做完基础功能后,用户最常追加的需求是三个:把销售明细导出成Excel、区分收银员和管理员权限、把程序打包成能双击安装的exe。这三个点都不难,但做得不好会直接影响交付体验。

导出Excel我一般不用Office COM组件,因为客户电脑不一定装了Office。用EPPlus这个NuGet包,生成xlsx很稳。

using OfficeOpenXml; public void ExportToExcel(DataTable dt, string filePath) { using (var pkg = new ExcelPackage()) { var sheet = pkg.Workbook.Worksheets.Add("销售明细"); sheet.Cells["A1"].LoadFromDataTable(dt, true); pkg.SaveAs(new FileInfo(filePath)); } }

LoadFromDataTable会把DataTable的表头和数据一次性写入,真正的Excel文件生成逻辑只有五行。权限控制则可以在MainForm的Load事件里遍历菜单项,根据CurrentUser.Role设置Visible。

一键打包我推荐用Inno Setup,脚本里要包含三件事:发布文件夹里的exe和dll、App.config、一份部署说明。MySQL不打包进安装程序,因为服务器端软件不适合塞进桌面安装包。部署说明里第一行就写清楚:先装MySQL,再导入shop.sql,最后跑安装程序。我这个习惯是从一次交付踩出来的:客户装了程序发现数据库没建,整栋楼等着,后来每次都在安装包旁边放一个README.txt。

WinForm这条技术路线虽然看起来老,但在内网桌面收银、进销存这类场景里,部署快捷、调试直观、成本够低。我这些年做下来最大的教训是:不要小看事务边界和连接串配置,越是看起来不起眼的地方,越容易在上线当天出问题。希望这些方法和踩坑记录能帮你在自己的商品销售与管理系统里少走几步弯路,做到真正能交付、能验收、能长期维护。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询