简介:一份基于C#与MySQL的WinForm商品销售与管理系统,面向高职院校及计算机相关专业学生,能够满足课程设计或期末项目需要。系统涵盖用户、商品、订单三类数据源,完善实现了登录验证、在线状态与权限检验、用户注册;商品管理包含增删改查、条件筛选、模糊查询、批量修改与库存补充;收银台支持添加商品、调整数量、移除商品、自动计算金额及统计订单;订单管理具备浏览、模糊查询、按时间过滤等能力;用户管理覆盖浏览、资料修改、删除、权限调整及条件筛选,并对常见异常做了统一处理。压缩包共127个文件,约13.93MB,以27个C#源码文件为主,辅以49张界面或流程图片、11个resx与resources资源文件、6个xmind设计导图、1个sql数据库脚本,以及pdf/docx格式的说明文档和可直接运行的exe,便于对照调试与二次开发。目前已有343人学习或下载,是一份WinForm与MySQL结合的完整课程设计参考资料。
1. 基于 C#+MySQL 的 WinForm 商品销售与管理系统:作业之外还能跑到哪一步
这套项目是典型的“C# WinForm + MySQL”课程设计全家桶:前台用 WinForm 画界面,后台数据落在 MySQL,中间靠 ADO.NET 和 MySql.Data 牵着走。它解决的问题很具体——登录验证、在线状态与权限判断、商品增删改查、收银台算账、订单按时间过滤、用户权限修改,基本把一个超市管理后台的完整链路覆盖完了。适合正在做课程设计、或者第一次把 WinForm 和 MySQL 连起来跑通的人:你拿到的不是空壳 Demo,而是带着界面美化、异常处理、权限收敛的完整窗体工程。我拆这个项目的原因很简单:这类系统翻车点大多不在业务逻辑,而在 MySQL 连接串、中文乱码、DataGridView 数据绑定这些细节上,这篇就把这些位置一次说透。
2. 先看工程结构和三张表:从 .cache 文件到 MySQL 建表
拿到项目先别急着点开窗体,我习惯先把文件列表扫一遍。这个工程里有一堆 .cache 文件(DesignTimeResolveAssemblyReferencesInput.cache、GenerateResource.cache、AssemblyReference.cache、CoreCompileInputs.cache),外加 App.config、exe.config 和收银台.Designer.cs、商品管理.Designer.cs。Designer.cs 是 WinForm 窗体设计器自动生成的代码,拖动控件、改属性都会回写到这里,一般不手改;App.config 是源码里的配置文件,编译时会自动转成 exe.config,程序运行时真正读的是 exe.config。那些 .cache 文件是 Visual Studio 构建时生成的中间产物,留着你机器上没问题,提交代码前应该清掉,后面避坑章节我再细说。
2.1 项目文件清单:哪些是源码,哪些可以删
这里给一张对照表,按“要不要提交到代码库”分类:
| 文件/目录 | 来源 | 要不要提交 | 说明 |
|---|---|---|---|
| 商品管理系统.csproj | 项目文件 | 要 | 重新打开工程、编译发布都靠它 |
| App.config | 配置文件 | 要 | MySQL 连接字符串写在这里 |
| xxx.Designer.cs | 窗体设计器生成 | 要 | 窗口布局、控件属性的自动代码 |
| xxx.cs(如收银台.cs) | 业务代码 | 要 | 事件、查询、逻辑全在这 |
| *.cache | 编译中间产物 | 不要 | 删了下一次构建自动重建 |
| bin/、obj/ | 编译输出 | 不要 | exe、dll、pdb 都在里面 |
判断标准很简单:.cs、.config、.csproj 保留,cache、obj、bin 进忽略列表。我见过有人把 Debug 目录整个打包交上去,几十 MB 的东西,导师打开工程还要手动清理,第一印象就砸了。这种项目本身不大,源码干净一点,后面替换连接串、改窗体也省事。
2.2 三张核心表:用户表、商品表、订单表的字段设计
摘要里写得很明确:数据源就是三个——用户表、商品表、订单表,对应系统里的用户管理、商品管理、订单管理和收银台。按这套系统的常见设计,建表 SQL 我一般这样写:
CREATE DATABASE IF NOT EXISTS shop_db DEFAULT CHARACTER SET utf8mb4; USE shop_db; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password CHAR(32) NOT NULL COMMENT 'MD5密码', real_name VARCHAR(50) COMMENT '姓名', role TINYINT NOT NULL DEFAULT 1 COMMENT '0超级管理员 1收银员', is_online TINYINT NOT NULL DEFAULT 0 COMMENT '0离线 1在线', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB; CREATE TABLE products ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID', name VARCHAR(100) NOT NULL COMMENT '商品名', category VARCHAR(50) COMMENT '分类', price DECIMAL(10,2) NOT NULL COMMENT '售价', stock INT NOT NULL DEFAULT 0 COMMENT '库存', add_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上架时间' ) ENGINE=InnoDB; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', user_id INT NOT NULL COMMENT '操作收银员ID', total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额', product_info VARCHAR(255) NOT NULL COMMENT '商品ID与数量拼接', order_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB;先说几个关键设计点。角色字段用 TINYINT 不用字符串,是因为权限判断时role == 0比字符串比较快,而且不容易写错别字;is_online 是专门给“在线检验”用的,登录时置 1,退出时置 0,后面会讲怎么处理程序崩溃导致的“僵尸在线”。密码字段用 CHAR(32),正好对应 MD5 摘要的 32 位十六进制长度,课程设计里 MD5 够用,生产上建议换 BCrypt。金额和库存必须用 DECIMAL 和 INT,价格千万别用 FLOAT,二进制浮点存 0.1 会有精度误差,算总价时对不上账。
订单表里我只放了一个 product_info 字段用字符串拼接商品 ID 和数量,比如"3x2,7x1"表示商品 3 买了两件、商品 7 买了一件。这是课程设计里常见的简化做法,一张订单表就能交差。如果你后面想统计“哪个商品卖得最多”,这种结构很难查,我一般会加一张 order_items 订单明细表拆开,但那是后话,摘要说了三个数据源,就不画蛇添足。
建表编码统一用 utf8mb4,不是 utf8。原因很简单:MySQL 的 utf8 最多存 3 字节,遇到 Emoji 这种 4 字节字符会直接报Incorrect string value错误,utf8mb4 是它的超集,兼容性最好。引擎用 InnoDB,因为你收银台结账时大概率会用到事务,MyISAM 不支持行级锁和事务,到时候扣库存和写订单没法保证一起成功。
3. 登录、注册和权限校验:把在线状态和权限判断做在一处
登录是整个系统的入口,这套项目要求了“登录验证与在线/权限检验”,所以登录不能只比对用户名密码。登录成功之后要同步做三件事:更新在线状态、查出角色、把角色带到主界面去控制按钮可用性。这三件事拆开做会有一堆重复代码,我一般把登录封装成一个方法,返回布尔值和角色参数。
3.1 登录逻辑:参数化查询 + MD5 比对 + 在线状态更新
using MySql.Data.MySqlClient; using System.Security.Cryptography; using System.Text; public bool Login(string username, string password, out string role) { role = ""; string md5Pwd = GetMd5(password); using (var conn = new MySqlConnection(connStr)) { conn.Open(); string sql = @"UPDATE users SET is_online = 1 WHERE username = @u AND password = @p"; using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@u", username); cmd.Parameters.AddWithValue("@p", md5Pwd); int rows = cmd.ExecuteNonQuery(); if (rows == 0) return false; // 没更新到行 = 用户名或密码错 } string query = "SELECT role FROM users WHERE username = @u"; using (var cmd2 = new MySqlCommand(query, conn)) { cmd2.Parameters.AddWithValue("@u", username); role = cmd2.ExecuteScalar()?.ToString() ?? ""; } } return true; } static string GetMd5(string input) { using (var md5 = MD5.Create()) { byte[] bytes = Encoding.UTF8.GetBytes(input); StringBuilder sb = new StringBuilder(); foreach (byte b in md5.ComputeHash(bytes)) sb.Append(b.ToString("x2")); return sb.ToString(); } }注意这里的执行顺序是我刻意安排的:先 UPDATE 而不是先 SELECT,密码错的时候 UPDATE 影响行数是 0,直接返回 false,省一次查询;密码对了,顺手把 is_online 置 1,登录态就有了。返回的 role 字符串要存到一个能跨窗体访问的地方,常见做法是建一个静态类CurrentUser { public static string Role; public static string Name; },主窗体加载时根据 Role 决定哪些按钮可见。
这里有两个细节容易做错。第一是 MD5 的编码,一定要用Encoding.UTF8.GetBytes,不要用系统默认编码,不同机器的默认 ANSI 编码不一样,同样密码在不同机器上算出的哈希会不同。第二是 SQL 注入,登录框里输入' OR '1'='1这种内容时,字符串拼接的 SQL 会被直接注掉,所以参数化查询不是可选项,是必须项。课程设计里很多同学图省事用拼接,我觉得这个习惯越早改越好。
3.2 注册与异常处理:重复用户名、空值、密码不一致
注册界面一般是用户名、密码、确认密码、姓名四个输入框。校验顺序要固定:先查空值,再查两次密码一致,最后处理用户名重复。用户名重复不要用“先 SELECT 再 INSERT”的方式,因为并发下会漏判,直接捕获 MySQL 的 1062 重复键错误最干净:
public bool Register(string username, string password, string confirmPwd, string realName) { if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) throw new Exception("用户名和密码不能为空"); if (password != confirmPwd) throw new Exception("两次输入的密码不一致"); using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var cmd = new MySqlCommand( @"INSERT INTO users(username, password, real_name, role) VALUES(@u, @p, @r, 1)", conn)) { cmd.Parameters.AddWithValue("@u", username); cmd.Parameters.AddWithValue("@p", GetMd5(password)); cmd.Parameters.AddWithValue("@r", realName); try { cmd.ExecuteNonQuery(); } catch (MySqlException ex) when (ex.Number == 1062) { throw new Exception("用户名已存在,换一个吧"); } } } return true; }这段代码里有两个点值得说。一是新注册用户默认 role=1(收银员),超级管理员只能由已有管理员手动改权限,这是权限收敛的常见做法,避免注册入口变成开后门的地方。二是catch (MySqlException ex) when (ex.Number == 1062)这个异常过滤器,它只拦截重复键这一种错误,别的 MySQL 错误照常往外抛。如果你写成裸 catch,到时候连接断了、SQL 写错了,全都会被当成“用户名重复”处理,排查问题时会被误导。
3.3 权限判断落到界面上:按钮按角色启用或禁用
权限校验不是只在登录时做一次,界面上的按钮也要跟着角色走。我一般这么处理主窗体的加载事件:
private void MainForm_Load(object sender, EventArgs e) { if (CurrentUser.Role == "0") { btnUserManage.Visible = true; // 用户管理只有管理员能进 btnProductEdit.Visible = true; // 修改商品也只有管理员能进 } else { btnUserManage.Visible = false; btnProductEdit.Visible = false; } lblWelcome.Text = $"当前收银员:{CurrentUser.Name}"; }这样做的好处是收银员打开系统只能看到收银台和订单查询,商品管理和用户管理入口直接隐藏,比“弹窗提示没有权限”体感好。隐藏之外最好再在窗体打开时做一次校验,因为 WinForm 客户端可以被反编译、可以跳过界面操作直接调用方法,UI 隐藏只是交互层的权限,真正的权限边界要落在查询和写库的代码里。这套项目摘要里提到“用户检验、权限判断”做了两层,我猜它也是这个思路:界面一层,数据访问一层。
4. 商品管理、收银台与订单管理:增删改查、金额计算和时间过滤
进入正题,这一章讲系统最重的业务部分。商品管理模块要支持增删改查、条件筛选、模糊查询、批量修改;收银台要能添加商品、修改数量、移除商品、计算金额、统计订单;订单管理要支持浏览、条件筛选、模糊查询、按时间过滤。这三个模块都是典型的“DataGridView + SQL + 事件”结构,把套路讲顺了,剩下的窗体就是在复制这个模式。
4.1 商品管理:DataGridView 绑定 + 模糊查询 + 批量修改
商品列表的查询我习惯封装成带条件参数的方法,DataGridView 只负责展示,不直接拼 SQL:
public DataTable SearchProducts(string keyword, string category, decimal? minPrice, decimal? maxPrice) { string sql = @"SELECT id, name, category, price, stock, add_time FROM products WHERE 1 = 1"; var args = new List<MySqlParameter>(); if (!string.IsNullOrWhiteSpace(keyword)) { sql += " AND (name LIKE @kw OR category LIKE @kw)"; args.Add(new MySqlParameter("@kw", "%" + keyword + "%")); } if (!string.IsNullOrWhiteSpace(category)) { sql += " AND category = @cat"; args.Add(new MySqlParameter("@cat", category)); } if (minPrice.HasValue) { sql += " AND price >= @minP"; args.Add(new MySqlParameter("@minP", minPrice.Value)); } if (maxPrice.HasValue) { sql += " AND price <= @maxP"; args.Add(new MySqlParameter("@maxP", maxPrice.Value)); } using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.AddRange(args.ToArray()); var da = new MySqlDataAdapter(cmd); var dt = new DataTable(); da.Fill(dt); return dt; } } }WHERE 1 = 1在课程设计里经常被当成笑柄,但其实它是个实用技巧:后面每个条件都用AND开头拼接,不用去判断“这是不是第一个条件要不要改成 WHERE”。keyword 做模糊查询时,%要么和参数一起拼,要么写进 SQL 用CONCAT('%', @kw, '%'),不要直接拼到 SQL 字符串里。LIKE 查询加上参数化之后,既防注入又保证中文不乱码——乱码问题后面章专讲。
批量修改是商品管理里比较容易被忽略的功能。它通常的做法是:DataGridView 开多选,勾选几行后弹一个批量改价格的对话框,然后执行UPDATE products SET price = @p WHERE id IN (@ids)。注意 IN 后面不能用参数直接拼数组,要把 id 列表展开成@id0,@id1,@id2...这种形式,每个元素单独 Add 参数,或者干脆用事务循环 UPDATE。课程设计里数据量不大,我一般用循环 + 事务,代码可读性更好:
public void BatchUpdatePrice(List<int> ids, decimal newPrice) { using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { foreach (int id in ids) { using (var cmd = new MySqlCommand( "UPDATE products SET price = @p WHERE id = @id", conn, tx)) { cmd.Parameters.AddWithValue("@p", newPrice); cmd.Parameters.AddWithValue("@id", id); cmd.ExecuteNonQuery(); } } tx.Commit(); } catch { tx.Rollback(); throw; } } } }事务在这里的作用是保证批量修改要么全成、要么全不成,不会出现改了一半价格、另一半还是原价的状态。这种“库存黑匣子”问题在收银台结账时更明显,下面就会用到同样的事务思路。
4.2 收银台:添加商品、改数量、移除、算总额与库存扣减
收银台是这个系统的核心业务,界面一般是左边商品列表、右边当前购物车(DataGridView),下面显示总金额。加商品时把商品 ID、名称、单价、数量塞进一个内存的 List,数量可以直接改:
public class CartItem { public int ProductId { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } public decimal Subtotal => Price * Quantity; } List<CartItem> cart = new List<CartItem>(); public void AddToCart(int productId, string name, decimal price, int qty) { var exist = cart.FirstOrDefault(c => c.ProductId == productId); if (exist != null) { exist.Quantity += qty; } else { cart.Add(new CartItem { ProductId = productId, Name = name, Price = price, Quantity = qty }); } RefreshCartGrid(); // 重新绑定 cart 到 DataGridView,并刷新合计 }金额计算不要在 DataGridView 里一格格读取,直接从 cart 集合聚合,总数等于cart.Sum(c => c.Subtotal)。Decimal 类型在这里是必须的,用 double 算金额会出现 0.1 + 0.2 不等于 0.3 的超声现象,账面就不平了。
结账时重点来了:写订单和扣库存必须在一个事务里完成。要先把购物车里的商品对应库存查出来,检查够不够,不够就提示“xx 商品库存不足”,够了再扣库存并插入订单:
public bool Checkout(List<CartItem> cart) { string orderNo = DateTime.Now.ToString("yyyyMMddHHmmssfff"); decimal total = cart.Sum(c => c.Subtotal); string productInfo = string.Join(",", cart.Select(c => $"{c.ProductId}x{c.Quantity}")); using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { try { foreach (var item in cart) { using (var cmd = new MySqlCommand( "UPDATE products SET stock = stock - @qty WHERE id = @id AND stock >= @qty", conn, tx)) { cmd.Parameters.AddWithValue("@qty", item.Quantity); cmd.Parameters.AddWithValue("@id", item.ProductId); int affected = cmd.ExecuteNonQuery(); if (affected == 0) throw new Exception($"商品[{item.Name}]库存不足"); } } using (var cmd = new MySqlCommand( @"INSERT INTO orders(order_no, user_id, total_amount, product_info) VALUES(@no, @uid, @total, @info)", conn, tx)) { cmd.Parameters.AddWithValue("@no", orderNo); cmd.Parameters.AddWithValue("@uid", CurrentUser.UserId); cmd.Parameters.AddWithValue("@total", total); cmd.Parameters.AddWithValue("@info", productInfo); cmd.ExecuteNonQuery(); } tx.Commit(); return true; } catch { tx.Rollback(); throw; } } } }扣库存的 SQL 里AND stock >= @qty是关键。这是数据库层面的库存保护:即使两个收银台同时卖同一件商品,MySQL 的行锁也会让第二个 UPDATE 在第一个事务提交前等待,等它执行时库存已经被扣过,如果不够了影响行数就是 0,事务直接回滚弹提示。用这种方式比“先 SELECT 查库存再 UPDATE”安全得多,后者在高并发下会读到旧值。这个项目虽然是课程设计,但这个习惯我觉得值得直接养成。
4.3 订单管理:按时间过滤、模糊查询与 DataGridView 展示
订单管理模块工作量不大,主要是查询条件的组合:按订单号模糊查、按收银员查、按时间段过滤。这里最常用的是 DateTimePicker 控件,时间过滤的边界很容易踩坑:
public DataTable SearchOrders(string orderNo, DateTime start, DateTime end, int? userId) { string sql = @"SELECT o.id, o.order_no, u.username AS operator, o.total_amount, o.order_time FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.order_time >= @start AND o.order_time < @end"; var args = new List<MySqlParameter> { new MySqlParameter("@start", start), new MySqlParameter("@end", end.AddDays(1)) // 结束日期要加一天 }; if (!string.IsNullOrWhiteSpace(orderNo)) { sql += " AND o.order_no LIKE @no"; args.Add(new MySqlParameter("@no", "%" + orderNo + "%")); } if (userId.HasValue) { sql += " AND o.user_id = @uid"; args.Add(new MySqlParameter("@uid", userId.Value)); } using (var conn = new MySqlConnection(connStr)) { conn.Open(); using (var cmd = new MySqlCommand(sql, conn)) { cmd.Parameters.AddRange(args.ToArray()); var da = new MySqlDataAdapter(cmd); var dt = new DataTable(); da.Fill(dt); return dt; } } }时间过滤用>= @start AND < @end而不是BETWEEN,是因为 BETWEEN 在 MySQL 里是闭区间,会包含结束那天的 00:00:00,但通常用户选择结束日期时是希望查到当天最后一秒。处理方式是在代码里给 end 加一天,查询条件用左闭右开区间[start, end),这样今天选的结束日期能把今天全部订单都包含进来。DateTimePicker 如果用户没选时间,默认值是 1900 年之类,查询前要先判断checkStart.Checked,不然会把数据库里所有早期历史都扫一遍。
订单列表展示后,双击某一行可以看订单详情,详情就是根据 product_info 字段拆出商品 ID 和数量再反查商品名。反查一般用WHERE id IN (...)循环查,或者直接在详情窗体里重新 SELECT。这块代码不复杂,重点是 product_info 的格式要在写入和读取时保持一致,我用的是"{商品ID}x{数量}",读取时按逗号和 x 拆分就行。
5. 常见问题排查:MySQL 连接、中文乱码、数据绑定和文件打包的五个坑
这一章专门记录我拆这类系统时反复遇到的坑。每条我都按“现象 → 原因 → 解决”写,方便你直接对照着处理。
5.1 坑一:MySQL 8 连接失败,报错Authentication plugin 'caching_sha2_password' cannot be loaded
现象:程序一执行conn.Open()就抛异常,说加载不了 caching_sha2_password 认证插件。
原因:MySQL 8.x 默认认证插件是 caching_sha2_password,而项目里引用的 MySql.Data 版本太老,不认识这个插件;或者连接串里没有配置对应的 SSL 和公钥参数,握手阶段就断了。
解决:两个方向。一是升级 NuGet 包MySql.Data到 8.0 以上,新版驱动原生支持;二是老驱动不想升级的话,把连接串改成下面这样:
<connectionStrings> <add name="ShopDb" connectionString="Server=localhost;Port=3306;Database=shop_db;Uid=root;Pwd=你的密码;SslMode=None;AllowPublicKeyRetrieval=True;charset=utf8mb4;"/> </connectionStrings>AllowPublicKeyRetrieval=True允许客户端向服务端请求 RSA 公钥,SslMode=None关闭 SSL 加密,这两个参数同时出现才能和 MySQL 8 的默认认证对上。注意:这个配置在纯内网开发环境没问题,生产环境不建议关 SSL。
5.2 坑二:中文乱码,插入的数据在表里是问号,或者显示出来是???
现象:商品名、用户姓名存进 MySQL 后变成???,DataGridView 里读出来也是问号。
原因:三层字符集不一致。建库时用了默认 latin1 或者 utf8,连接串里没写 charset,客户端的 CodePage 和数据库对不上,中文在传输过程中被截断成问号。
解决:三层统一。建库用CREATE DATABASE ... CHARACTER SET utf8mb4(前面建表 SQL 里写了),连接串加charset=utf8mb4,MySQL 的 my.ini 里character-set-server=utf8mb4也建议改掉。查数据库字符集可以用:
SHOW VARIABLES LIKE 'character_set%';如果看到character_set_server是 latin1,说明服务端默认编码不对,改 my.ini 后重启 MySQL 服务。有些机器上改了 my.ini 还不行,那就是 MySQL 服务没重启,这个坑我踩过不止一次。
5.3 坑三:DataGridView 里 0/1 字段直接显示成数字,想显示成“是/否”或 CheckBox
现象:is_online、role 这种 TINYINT 字段绑定到 DataGridView 后显示成 0 或 1,用户看不懂,交作业也不好看。
原因:DataGridView 默认把单元格当文本列渲染,0/1 没有映射成业务语义。
解决:给列换成 DataGridViewCheckBoxColumn,并处理 CellFormatting 事件。比较省事的写法是在窗体设计器里手动加一列 CheckBoxColumn,把 DataPropertyName 指到 is_online,然后在 CellFormatting 里把 0/1 转成 bool:
private void grid_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (grid.Columns[e.ColumnIndex].Name == "colOnline" && e.Value != null) { e.Value = (Convert.ToInt32(e.Value) == 1); e.FormattingApplied = true; } }注意 DataGridViewCheckBoxColumn 绑定数据源时,列要设TrueValue=1、FalseValue=0,否则勾选状态会和数据库的 0/1 对不上。
5.4 坑四:把 .cache、bin、obj 一起打包上交,工程体积膨胀一倍
现象:压缩包里有大量 DesignTimeResolveAssemblyReferencesInput.cache、GenerateResource.cache 文件,导师解压后 Visual Studio 加载缓慢,甚至报缓存文件被占用。
原因:这些是编译器的中间产物,在你本机构建时生成,本来就不该进版本库。打包时没做忽略,全带上了。
解决:项目根目录加一个.gitignore(用 Git 管理的话),至少包含这几行:
bin/ obj/ *.cache *.user如果不交作业,直接删掉这些文件也没影响,重新用 Visual Studio 打开工程,构建一次会自动生成。这个习惯能让你在答辩时省掉很多尴尬。
5.5 坑五:程序崩溃后 is_online 永远停在 1,用户明明没在线
现象:程序异常退出(比如打开 MySQL 连接时抛异常、窗体被强制结束),数据库里用户表的 is_online 一直是 1,下次用同一账号登录不受影响,但管理员看在线列表时全是“在线”。
原因:登出操作没被执行到。WinForm 程序崩溃时不会走正常的 FormClosing 流程,UPDATE ... SET is_online=0永远不会执行。
解决:在 Program.cs 里注册 Application.ThreadException 和 AppDomain.UnhandledException,在进程退出前补一次清理。最稳妥的做法是启动时做一次“假在线”跌底:登录成功后记下 user_id,在 MainForm 的 FormClosing 事件里更新 is_online=0;再配合程序入口的 try-catch,把未处理异常统一记录。这样即使崩溃,重启后上次的在线状态也能在下次登录时被覆盖更新为 1。
提示:is_online 这种字段本质上是个“会话状态快照”,在高并发系统里不应该依赖它做唯一判断。课程设计里用来展示在线列表没问题,但数据库层面别拿它做并发控制。
6. 进阶技巧:全局异常处理和界面美化,让作业比预期多走一步
6.1 全局异常处理:让程序不白屏崩溃,弹窗告诉用户问题出在哪
这套项目摘要里专门提到“将所有可能的异常进行了异常处理”,但单靠每个按钮的 try-catch 堵不住所有窗口。WinForm 里最常出现的是未处理异常直接触发Application.ThreadException,然后程序闪退。我一般会在 Program.cs 里加一个全局兜底:
[STAThread] static void Main() { Application.ThreadException += (s, e) => { MessageBox.Show($"操作失败:{e.Exception.Message}", "系统提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); }; AppDomain.CurrentDomain.UnhandledException += (s, e) => { File.AppendAllText("error.log", $"{DateTime.Now}: {e.ExceptionObject}{Environment.NewLine}"); }; Application.Run(new LoginForm()); }ThreadException 负责 UI 线程的异常,弹窗给用户看;UnhandledException 负责非 UI 线程和其他漏网异常,写进 error.log 方便事后查。这样即使某个按钮的事件里没写 try-catch,程序也不会直接消失,这对答辩演示很重要——演示过程中最怕的就是“双击商品没反应,然后整个窗体没了”。
6.2 界面美化:从默认灰底窗体到看起来不那么“作业风”
WinForm 默认窗体确实丑,灰色背景加系统标题栏,一眼就是模板产品。这个项目在界面美化上做了功夫,我拆它的设计思路时发现其实都是低成本改动:登录窗体背景图用窗体的 BackgroundImage,按钮用 FlatStyle.Flat 去掉立体边框再改 BackColor,DataGridView 设置 BorderStyle=None 和背景色交替行。核心逻辑一句话:尽量少用系统默认控件样式,统一配色。
grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(245, 248, 250); grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(41, 128, 185); grid.ColumnHeadersDefaultCellStyle.Font = new Font("微软雅黑", 10F, FontStyle.Bold); grid.RowTemplate.Height = 32;这些代码写在窗体 Load 事件里,不需要任何第三方 UI 库,纯自绘属性调整。比起用那些重量级控件库,这种改动的好处是换机器编译不会缺依赖。
6.3 验证这套系统是否跑通的三个关键路径
拆完最后给一套验证路径,十分钟确认系统核心链路没断:
- 注册一个新用户,用该用户登录,主界面的用户管理和商品管理按钮不可见;
- 用管理员账号登录,新增一个商品,库存填 10,去收银台买 6 件,结账后回商品管理看库存变成 4;
- 去订单管理,选择“今天”作为时间过滤,能看到刚才那笔订单,金额和数量对得上。
如果这三条路径都通,这个系统的核心功能就是完整的,剩下就是界面细节和代码注释的事了。我自己每次拆这类课程设计项目,都会拿这三条链路做冒烟测试,而不是只看代码写没写。
之前做过一个库存系统,就是死在第二条链路上:收银台结账时库存扣了,订单表里没数据,后来查出来是两个 SQL 操作没包进同一个事务,结账方法一异常就回滚错对象。从那以后我每次检查这种带金额和库存的系统,都强制走一遍“下单-扣库存-查订单”的完整事务链路,确认中间任何一步失败都不会留下半截数据。希望这些拆解记录能帮到你,少走我走过的弯路。
本文还有配套的精品资源,点击获取