
简介基于C#MySQL实现的WinForm商品销售与管理系统定位为高校数据库或C#课程设计的完整参考方案。系统内置用户、商品、订单三类核心数据源覆盖登录验证、权限判断、用户注册、商品增删改查与模糊筛选、收银台金额计算、订单按时间过滤、用户权限调整等完整业务闭环并对所有操作加入异常处理界面也做了美化处理。资源包共127个文件压缩包约13.93MB。其中27个cs源码文件与11个resx资源文件构成WinForm主要逻辑与界面资源49个png图片覆盖界面素材与图标6个xmind思维导图助力理解设计思路sql脚本可直接初始化数据库另含pdf、docx等文档及exe可执行程序便于对照调试与演示。已有343人学习下载适合初学C#与MySQL的学生直接参考项目划分、业务分层与异常处理模式也可作为课程设计答辩前的功能演示与文档撰写素材。1. C# MySQL 的 WinForm 商品销售与管理系统课程设计里的“可落地”样本如果你正在用 C# 配合 MySQL 做 WinForm 课程设计最怕的不是功能写不出来而是写完的代码只有自己能跑通。这套商品销售与管理系统把登录校验、用户注册、商品管理、收银台、订单管理和用户权限全部串在了一个 WinForm 客户端里数据源是用户表、商品表、订单表三张表异常处理覆盖了大部分入口。它定位很实在交作业、当毕设底子、或者给刚学 C# 和 MySQL 的人当完整范例都合适。没有花哨的微服务、没有前后端分离就是一个标准的三层 WinForm 桌面程序打开就能看明白登录校验、权限判断、数据增删改查和收银结算是怎么串起来的。2. 从三张表说起用户、商品、订单的建模与连接基础2.1 三张表为什么够用权限与业务的数据底座很多课程设计一上来就设计七八张表最后代码全耗在外键关联上。这套系统用三张表就把核心业务撑住了user管登录和权限product管商品和库存order管销售流水。我在复现时按常见做法补全了字段结构如下。CREATE TABLE user ( UserId INT AUTO_INCREMENT PRIMARY KEY, UserName VARCHAR(50) NOT NULL UNIQUE, Password VARCHAR(64) NOT NULL, Role TINYINT NOT NULL DEFAULT 0 COMMENT 0普通用户1管理员, CreatedTime DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( ProductId INT AUTO_INCREMENT PRIMARY KEY, Name VARCHAR(100) NOT NULL, Category VARCHAR(50) DEFAULT NULL, Price DECIMAL(10,2) NOT NULL, Stock INT NOT NULL DEFAULT 0, Description VARCHAR(255) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order ( OrderId INT AUTO_INCREMENT PRIMARY KEY, OrderNo VARCHAR(30) NOT NULL, UserId INT NOT NULL, ProductId INT NOT NULL, Quantity INT NOT NULL, Amount DECIMAL(10,2) NOT NULL, OrderTime DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_order_time (OrderTime), CONSTRAINT fk_user FOREIGN KEY (UserId) REFERENCES user (UserId), CONSTRAINT fk_product FOREIGN KEY (ProductId) REFERENCES product (ProductId) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;Role字段用 TINYINT 而不是 VARCHAR原因是判断权限时Convert.ToInt32(row[Role]) 1比字符串比较更干净也避免用户名字段和权限字段混淆。order表里我加了OrderNo作为业务单号因为课程设计里经常要“统计订单”直接用自增主键看不出来哪几笔是同一单。外键选择保留这样删除商品或用户时 MySQL 会拒绝误删倒逼你在代码里先处理关联数据。表名order是 MySQL 的保留字所有查询语句都要用反引号包起来这点我在后面踩坑章节还会提。2.2 通用 DbHelper把 MySQL 连接和参数化查询封装成一个类在 WinForm 里每个窗体都自己new MySqlConnection会非常痛苦。我习惯把连接、查询、更新收敛成一个静态类只做一件事减少重复代码。下面是最小可用的DbHelper依赖MySql.Data库。using System; using System.Data; using System.Configuration; using MySql.Data.MySqlClient; public static class DbHelper { private static readonly string connStr ConfigurationManager.ConnectionStrings[MySqlConn].ConnectionString; public static DataTable ExecuteDataTable(string sql, params MySqlParameter[] parameters) { using (MySqlConnection conn new MySqlConnection(connStr)) { conn.Open(); using (MySqlCommand cmd new MySqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); MySqlDataAdapter adapter new MySqlDataAdapter(cmd); DataTable dt new DataTable(); adapter.Fill(dt); return dt; } } } public static int ExecuteNonQuery(string sql, params MySqlParameter[] parameters) { using (MySqlConnection conn new MySqlConnection(connStr)) { conn.Open(); using (MySqlCommand cmd new MySqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } } }这个类里我只封装了最常用的两个方法查询返回DataTable写操作返回受影响行数。using保证连接和命令用完即释放不会把 MySQL 连接池占满。params MySqlParameter[]是可变参数调用时可以直接写DbHelper.ExecuteDataTable(sql, new MySqlParameter(id, 1))也可以传一个数组。所有 SQL 都必须用name参数传值这是防 SQL 注入的底线。连接串放在 App.config 里connectionStrings add nameMySqlConn connectionStringServerlocalhost;Port3306;Databaseshop;Uidroot;Pwd123456;SslModeNone;Charsetutf8mb4; providerNameMySql.Data.MySqlClient / /connectionStringsSslModeNone是为了避开 MySQL 8.0 默认 SSL 握手导致连不上Charsetutf8mb4是为了中文不乱码。这两项都是我实际复现时必须加的少一个都会在后面的功能模块里暴露问题。另外我一般不会用AddWithValue给所有参数赋值因为它在某些场景下会推断错类型导致索引失效更稳妥的是显式指定类型比如new MySqlParameter(price, MySqlDbType.Decimal) { Value price }代价是多写几行换来的是查不出来时不用怀疑参数类型。3. 商品管理模块增删改查、模糊查询、批量修改的实现3.1 商品列表的模糊查询与条件筛选商品管理是系统的核心界面通常是一个DataGridView加一组查询条件。原文要求支持“条件筛选、模糊查询”我拆成三个维度商品名称模糊、商品分类等于、价格区间。这些条件可能同时生效也可能一个都不选所以 SQL 用WHERE 11动态拼接。private void btnSearch_Click(object sender, EventArgs e) { string sql SELECT ProductId, Name, Category, Price, Stock, Description FROM product WHERE 11; var paras new ListMySqlParameter(); if (!string.IsNullOrEmpty(txtName.Text.Trim())) { sql AND Name LIKE Name; paras.Add(new MySqlParameter(Name, % txtName.Text.Trim() %)); } if (!string.IsNullOrEmpty(cboCategory.Text)) { sql AND Category Category; paras.Add(new MySqlParameter(Category, cboCategory.Text)); } if (numPriceMin.Value 0) { sql AND Price MinPrice; paras.Add(new MySqlParameter(MinPrice, numPriceMin.Value)); } if (numPriceMax.Value 0) { sql AND Price MaxPrice; paras.Add(new MySqlParameter(MaxPrice, numPriceMax.Value)); } sql ORDER BY ProductId DESC; DataTable dt DbHelper.ExecuteDataTable(sql, paras.ToArray()); dgvProducts.DataSource new BindingSource { DataSource dt }; }LIKE Name这里的参数值我手动拼了%参数本身依然走 MySQL 的预处理机制不会因为 LIKE 而把 SQL 注入放进来。WHERE 11在小型项目里不会有性能损耗反而是动态拼条件时最不容易出错的做法。价格区间我用了两个NumericUpDown控件Value 0表示用户填写了条件如果想查 0 到 0 这种极端的空价格可以改成numPriceMin.Value ! 0按业务定义调整。条件筛选里还有个隐藏点分类下拉框的数据来源不要写死在设计器里而是从SELECT DISTINCT Category FROM product读出来。这样以后新增分类不需要改代码数据库里有就会自动出现在下拉框里。做法是在窗体Load事件里先填充cboCategory再触发一次查询。3.2 批量修改与库存补充一次更新多行“批量修改”如果逐个 UPDATE 而不开事务中途某行失败会导致前面改过的行无法回滚。WinForm 里最常见的批量修改场景是选中多行输入一个统一的新分类或者像原文里说的“补充、添加商品库存”。我用事务实现库存批量增加直接在DataGridView上让用户填写每行的AddStock数量。using (MySqlConnection conn new MySqlConnection(connStr)) { conn.Open(); using (MySqlTransaction trans conn.BeginTransaction()) { try { foreach (DataGridViewRow row in dgvProducts.SelectedRows) { int productId Convert.ToInt32(row.Cells[ProductId].Value); int addStock Convert.ToInt32(row.Cells[AddStock].Value); string sql UPDATE product SET Stock Stock addStock WHERE ProductId id; using (MySqlCommand cmd new MySqlCommand(sql, conn, trans)) { cmd.Parameters.AddWithValue(addStock, addStock); cmd.Parameters.AddWithValue(id, productId); cmd.ExecuteNonQuery(); } } trans.Commit(); MessageBox.Show(库存补充完成); } catch (Exception ex) { trans.Rollback(); MessageBox.Show(补充失败 ex.Message); } } }事务必须绑定在同一个MySqlConnection上MySqlCommand构造时把trans作为第三个参数传进去这一步很容易漏。漏了以后所有的 UPDATE 仍然会成功但事务不会覆盖它们一旦中途报错前面已经改动的行就永久生效了。我在写课程设计时就吃过这个亏代码看着没问题实际跑起来数据不一致。批量修改还有一种实现方式是用SqlDataAdapter的UpdateBatchSize配合UpdateCommand但那样要额外配置 INSERT、UPDATE、DELETE 三条命令而且 DataGridView 的单元格编辑冲突处理起来很麻烦。我给出的事务循环方案看起来“传统”但胜在逻辑清晰翻车概率低。另外AddStock列我建议在DataGridView里用单独一列不要直接让用户改Stock列否则用户把 100 库存直接改成 1你以为他是想“新增 1”实际是“覆盖成 1”。4. 收银台与订单管理金额计算、库存扣减、时间过滤4.1 收银台从添加商品到计算金额的状态管理收银台的本质是一个内存中的购物车不需要马上写库。我一般用一个DataTable作为购物车的数据源绑定到DataGridView这样增删行、合计金额都简单。每列包括商品ID、名称、单价、数量、小计。DataTable cartTable; void InitCart() { cartTable new DataTable(); cartTable.Columns.Add(ProductId, typeof(int)); cartTable.Columns.Add(Name, typeof(string)); cartTable.Columns.Add(Price, typeof(decimal)); cartTable.Columns.Add(Quantity, typeof(int)); cartTable.Columns.Add(Subtotal, typeof(decimal)); } void AddToCart(DataRow productRow) { int productId Convert.ToInt32(productRow[ProductId]); DataRow[] rows cartTable.Select(ProductId productId); if (rows.Length 0) { rows[0][Quantity] (int)rows[0][Quantity] 1; rows[0][Subtotal] (decimal)rows[0][Price] * (int)rows[0][Quantity]; } else { DataRow newRow cartTable.NewRow(); newRow[ProductId] productId; newRow[Name] productRow[Name].ToString(); newRow[Price] productRow[Price]; newRow[Quantity] 1; newRow[Subtotal] productRow[Price]; cartTable.Rows.Add(newRow); } dgvCart.DataSource cartTable; lblTotal.Text cartTable.Compute(SUM(Subtotal), ).ToString(0.00); }cartTable.Select(ProductId productId)这里有一个容易被人忽略的细节Select的过滤表达式不是 SQL而是 DataTable 自带的条件语法。因为ProductId是 int 类型直接拼字符串不会被注入所以这里可以不用参数。如果你是从文本框读商品编号那必须先Convert.ToInt32再做拼接否则用户输入一个字符串就把整个购物车查询带偏了。金额合计用Compute(SUM(Subtotal), )这是DataTable自带的聚合函数比遍历所有行累加省事还不容易错。合计结果直接绑定到Label的Text格式化用0.00保证两位小数。结算那一步是收银台的真正难点。它要同时做三件事写入订单、扣减库存、判断库存是否足够。这三件事必须在一个事务里完成否则会出现“订单写了但库存没扣”或者“库存扣了但订单没写”的脏数据。我在代码里用UPDATE ... WHERE Stock qty来实现库存检查与扣减的原子操作。public void Checkout(int userId) { using (MySqlConnection conn new MySqlConnection(connStr)) { conn.Open(); using (MySqlTransaction trans conn.BeginTransaction()) { try { string orderNo DateTime.Now.ToString(yyyyMMddHHmmssfff); foreach (DataRow row in cartTable.Rows) { int productId (int)row[ProductId]; int qty (int)row[Quantity]; decimal price (decimal)row[Price]; string sqlUpdate UPDATE product SET Stock Stock - qty WHERE ProductId id AND Stock qty; MySqlCommand cmdUpdate new MySqlCommand(sqlUpdate, conn, trans); cmdUpdate.Parameters.AddWithValue(qty, qty); cmdUpdate.Parameters.AddWithValue(id, productId); if (cmdUpdate.ExecuteNonQuery() 0) { throw new Exception(商品 row[Name] 库存不足); } string sqlInsert INSERT INTO order(OrderNo, UserId, ProductId, Quantity, Amount, OrderTime) VALUES(orderNo, userId, productId, qty, amount, NOW()); MySqlCommand cmdInsert new MySqlCommand(sqlInsert, conn, trans); cmdInsert.Parameters.AddWithValue(orderNo, orderNo); cmdInsert.Parameters.AddWithValue(userId, userId); cmdInsert.Parameters.AddWithValue(productId, productId); cmdInsert.Parameters.AddWithValue(qty, qty); cmdInsert.Parameters.AddWithValue(amount, price * qty); cmdInsert.ExecuteNonQuery(); } trans.Commit(); cartTable.Clear(); MessageBox.Show(结算成功单号 orderNo); } catch { trans.Rollback(); throw; } } } }WHERE Stock qty是关键如果库存不够UPDATE 影响行数为 0代码主动抛异常回滚。这条语句同时完成了“查库存”和“扣库存”避免了先 SELECT 再 UPDATE 中间被别人插一脚的超卖问题。OrderTime用 MySQL 的NOW()而不是 C# 的DateTime.Now是为了保证订单时间统一由数据库服务器生成以后做按时间统计时基准一致。OrderNo用毫秒级时间戳同一秒内多点收银也不会重号课程设计场景里足够用。4.2 订单管理按时间过滤与模糊查询订单管理界面通常是订单号模糊查询、下单时间范围筛选、用户或商品条件筛选。时间过滤是真正的坑。DateTimePicker选出来的日期是 0 点 0 秒如果 SQL 写成OrderTime endTime那订单时间为当天 14:23:44 时就会被排除在外。我一般把结束时间加上一天然后用半开区间也就是“开始时间 ≤ 订单时间 结束时间1天”。DateTime startTime dtpStart.Value.Date; DateTime endTime dtpEnd.Value.Date.AddDays(1); // 包含截止日的一整天 string sql SELECT OrderNo, UserName, Name, Quantity, Amount, OrderTime FROM order o JOIN user u ON o.UserId u.UserId JOIN product p ON o.ProductId p.ProductId WHERE OrderTime start AND OrderTime end; if (!string.IsNullOrEmpty(txtOrderNo.Text.Trim())) { sql AND OrderNo LIKE no; } DataTable dt DbHelper.ExecuteDataTable(sql, new MySqlParameter(start, startTime), new MySqlParameter(end, endTime), new MySqlParameter(no, % txtOrderNo.Text.Trim() %));我用而不是是因为endTime已经加了 1 天例如选了 1 月 15 日endTime 是 1 月 16 日 00:00那么 1月16日正好包含 15 日全天不多不少。这种写法比DATE(OrderTime) BETWEEN ...好在哪里DATE()函数会让 OrderTime 字段上的索引失效订单量大以后查询会明显变慢。虽然课程设计数据量小看不出来但这个习惯会延伸到以后工作里的报表查询。模糊查询这里我建议也同时支持“商品名称”和“用户名”但不要把所有条件都堆在一个文本框里。订单号、用户名、商品名都模糊搜索最后用户根本不知道自己在查什么。我在复现时是一个文本框对应一个字段查询按钮里用参数拼接和商品管理的套路一致。4.3 用户管理权限判断与资料维护用户管理里最容易出事故的是“删除用户”和“修改权限”。系统里面必须保证两点不能删除当前登录账号不能把最后一个管理员降级或删掉。这两个判断放在 UI 层只能挡住操作更稳妥的是在数据库层也校验一次。课程设计放到 UI 层就够用了我给出了双保险写法。private void btnDeleteUser_Click(object sender, EventArgs e) { int currentUserId (int)Session.CurrentUser[UserId]; if (dgvUsers.CurrentRow null) return; int targetId Convert.ToInt32(dgvUsers.CurrentRow.Cells[UserId].Value); if (targetId currentUserId) { MessageBox.Show(不能删除当前登录账号); return; } string sql SELECT COUNT(*) FROM user WHERE Role 1; object result DbHelper.ExecuteScalar(sql); int adminCount Convert.ToInt32(result); if (adminCount 1) { DataRow targetRow ((DataTable)dgvUsers.DataSource).Select(UserId targetId)[0]; if (Convert.ToInt32(targetRow[Role]) 1) { MessageBox.Show(系统至少保留一个管理员); return; } } // 删除前检查用户是否已有订单 string check SELECT COUNT(*) FROM order WHERE UserId userId; int orderCount Convert.ToInt32(DbHelper.ExecuteScalar(check, new MySqlParameter(userId, targetId))); if (orderCount 0) { DialogResult dr MessageBox.Show(该用户已有订单删除后订单将失去用户关联是否继续, 提醒, MessageBoxButtons.YesNo, MessageBoxIcon.Warning); if (dr ! DialogResult.Yes) return; // 这里可以先把订单的 UserId 置为 NULL 或删除订单按业务选择 } DbHelper.ExecuteNonQuery(DELETE FROM user WHERE UserId id, new MySqlParameter(id, targetId)); }Session这个类是我自己写的静态类保存登录用户的信息窗体之间共用。实际项目里这叫“登录状态管理”WinForm 最常见做法就是静态类或单例。上面代码里先统计管理员数量如果总数不大于 1再判断选中行是不是管理员避免把最后一个管理员降级。删除用户前查订单是很多粗心设计会漏掉的步骤。我这样做是为了防止外键约束直接抛异常给用户一个明确的业务提示而不是让程序闪退。这里还需要在DbHelper里补一个ExecuteScalar方法返回object逻辑很简单就是cmd.ExecuteScalar()。5. 避坑与常见问题排查从 MySQL 连接到 WinForm 卡死的五条经验5.1 MySQL 8.0 连接报 SSL 错误或认证方式不被支持现象程序在 WinForm 启动后第一次查询就报SSL Connection Error或者Authentication method caching_sha2_password not supported。原因MySQL 8.0 默认开启 SSL默认认证方式是caching_sha2_password而旧的MySql.Data驱动6.x 及以下不认识它。解决把连接串加上SslModeNone驱动升级到 8.0.x 或更高。如果驱动版本受限就把 MySQL 用户改回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 123456; FLUSH PRIVILEGES;连接串里加了SslModeNone不代表不安全本地课程设计的场景下数据库和客户端在同一台机器SSL 不是必要项。这个坑在客户电脑上最容易出现打包前一定要确认目标机的 MySQL 版本和驱动版本。5.2 中文乱码连接串、建库、表结构三层都要 utf8mb4现象从 MySQL 查出来的商品名称、用户姓名全部显示成问号“”。原因三层里任意一层不是 utf8MySQL 服务端默认字符集是 latin1或者连接串没指定Charset或者建表语句带了DEFAULT CHARSETlatin1。解决建库时用CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4;建表语句里的ENGINEInnoDB DEFAULT CHARSETutf8mb4;不能省略连接串加Charsetutf8mb4;。如果仍然乱码检查 my.ini 里的character-set-serverutf8mb4改完重启 MySQL 服务。我遇到最多的是建表时偷懒不写字符集结果表默认继承服务端 latin1连接串怎么折腾都白费。检查时用一句 SQL 确认SHOW CREATE TABLE product;看到CHARSETlatin1就马上ALTER TABLE product CONVERT TO CHARACTER SET utf8mb4;比在应用层转码靠谱得多。5.3 时间筛选结果漏掉截止日当天数据现象筛选 1 月 1 日到 1 月 15 日的订单15 日一笔都显示不出来但数据库里明明有。原因DateTimePicker.Value是“当天 00:00:00”SQL 写OrderTime endTime时endTime 被当成 1月15日 00:00:0015 日 10:00 的订单自然就被排除。解决结束时间加 1 天SQL 用也就是我在 4.2 里写的半开区间。这个坑不只在订单时间上任何带时分秒的时间字段都可能遇到。我后来形成习惯所有时间区间查询一律写成 start start.AddDays(1)的形式再也不单独想边界问题。5.4 数据量大时窗口卡死把数据库操作移出 UI 线程现象点击查询后窗体标题变成“正在运行”鼠标一直是转圈等了十几秒才恢复期间窗口无法拖动。原因DbHelper.ExecuteDataTable直接跑在 UI 线程adapter.Fill(dt)同步阻塞主线程。解决用await Task.Run包住数据库查询查询期间禁用按钮防止重复点击。最简单的改造是这样private async void btnQuery_Click(object sender, EventArgs e) { btnQuery.Enabled false; try { DataTable dt await Task.Run(() DbHelper.ExecuteDataTable(SELECT * FROM product)); dgvProducts.DataSource dt; } catch (Exception ex) { MessageBox.Show(查询失败 ex.Message); } finally { btnQuery.Enabled true; } }await Task.Run会让查询在后台线程执行查询完成后自动切回 UI 线程更新DataGridView。这里要注意async void只能用给事件处理函数普通方法如果也写成async void异常会被吞掉所以我的DbHelper本身仍是同步方法靠调用方用线程包一层这样改动最小。5.5 打包安装到别的电脑后连不上数据库现象开发机器一切正常用 VS 自带打包功能生成安装程序装到客户电脑上报“无法连接到 MySQL Server”。原因目标电脑根本没装 MySQL或者连接串里写死了Serverlocalhost但目标机的 MySQL 服务没启动、端口被占用。解决打包时把shop.sql初始化脚本放进安装包目标机器装好 MySQL 后手动执行一次mysql -uroot -p shop.sql程序启动时做一次数据库可用性检查不通过就弹窗提示执行脚本。我把检查逻辑放在登录窗体的Shown事件里避免用户输入了账号密码才报错体验好很多。另外连接串最好单独放一个DbConfig.ini让部署人员能直接改服务器地址不需要重新编译。这个做法我后面每做一个 C/S 项目都会带上它真的能救你于答辩现场。6. 进阶界面美化与一键打包成可安装程序6.1 用扁平化配色和统一间距告别“原生”控件WinForm 的原生控件观感很旧我不打算引入第三方皮肤库——课程设计里引入 DevExpress 或 IrisSkin要么破解要么拖慢加载还容易被老师问“你会改吗”。我只做三件事统一字体为微软雅黑 9pt把DataGridView的选中行背景改成浅蓝色表头改成深色用TabControl把登录后的四个子系统并排放在主窗体里比来回弹窗更像正经系统。表格美化有个简单而重要的小点在DataGridView的CellFormatting事件里处理“小计金额”和“金额”的对齐否则数字默认靠左看着不专业。代码不多效果立竿见影。private void dgvProducts_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (this.dgvProducts.Columns[e.ColumnIndex].Name Price || this.dgvProducts.Columns[e.ColumnIndex].Name Subtotal) { if (e.Value ! null) { e.Value Convert.ToDecimal(e.Value).ToString(0.00); e.CellStyle.ForeColor System.Drawing.Color.FromArgb(64, 64, 64); e.FormattingApplied true; } } }6.2 打包成安装程序InstallShield 和启动前数据库自检VS2015 自带的是 InstallShield Limited Edition需要单独从微软官网下载安装。打包时把 Debug 输出目录下的 exe、DLL、App.config 全部加进“Application Files”再把shop.sql放到同一个目录。安装项目里“目标机器上的安装目录”保持默认[ProgramFilesFolder]加上公司名和产品名注意不要用中文路径否则个别 Win7 机器会报权限错误。安装包生成后客户机器还需要一个独立的 MySQL 服务。我一般让目标机先装好 MySQL再用命令行导入shop.sql。程序启动时我用一个EnsureDatabase方法探测数据库是否可用探测方式是执行最小的SELECT 1public static bool CanConnectToDatabase() { try { using (MySqlConnection conn new MySqlConnection(connStr)) { conn.Open(); using (MySqlCommand cmd new MySqlCommand(SELECT 1, conn)) { cmd.ExecuteScalar(); } return true; } } catch { return false; } }这个方法的妙处是它只负责探测不做任何业务逻辑。启动时如果返回 false就弹出一个方向明确的提示告诉使用者“请先安装 MySQL 并执行 shop.sql 脚本”。从那以后我每次交付 C/S 程序前都会强制走一遍“干净机器测试”开一台新虚拟机什么都不装从安装包开始一步步操作直到能看到登录窗口。这个习惯帮我抓住了很多自以为没问题、实际换个环境就翻车的连接串和依赖项问题。希望帮到你。本文还有配套的精品资源点击获取