简介:这是一套面向C#初学者与WinForm桌面开发入门者的实践型源码项目,聚焦停车场收费管理这一典型业务场景,帮助学习者掌握数据库驱动的GUI应用开发全流程。资源包含95个文件,主体为37个C#业务逻辑与界面代码文件(.cs)、13个本地化资源文件(.resx)、13个编译资源(.resources)、1个SQL Server数据库脚本(.sql)及1个完整数据库文件(.mdf),辅以解决方案(.sln)、项目配置(.csproj)和运行截图等,整体压缩包仅1.32MB,轻量易部署。已有1323人下载学习,适合通过真实模块(如车辆入场登记、按停留时间自动计费、出库结算、收费记录查询及管理员权限配置)理解数据绑定、事件驱动、SQL Server增删改查及多窗体协作等核心技能。源码结构清晰,含完整可运行exe程序与附加数据库脚本,开箱即用,是巩固C#语法、WinForm控件使用与三层逻辑分离思想的优质练手项目。
1. 这不是个“练手小Demo”,而是一套能直接插进小商场地库跑通的WinForm收费系统
上周帮某高校实验室调试一个毕业设计项目,学生拿来的就是这个「C#停车场收费系统」源码包——没文档、没README、只有几个png截图和一个.sln。我本以为又是那种“窗体拖一拖、按钮点一点、数据库连一连”的教学玩具,结果双击 Parking.sln 启动后,真把模拟车辆从进场、计时、出库、打印小票全走了一遍,连SQL Server本地实例自动附加.mdf/.ldf的逻辑都写好了。它不炫技,但每一步都踩在真实业务的痛点上:比如车型分A/B/C三类对应不同单价、临时车按小时阶梯计费(前30分钟免费,之后每15分钟1元)、月租车自动识别免收当日费用、甚至支持手动补录离场时间来处理断电重启场景。如果你正卡在“学完WinForm控件却不知道怎么串成完整流程”,或者想拿个有真实数据流、有状态管理、有异常兜底的C#桌面项目练手改写,这个压缩包里藏着的,是比教科书更硬核的落地经验。
2. 项目结构与技术栈:从 Parking.sln 到 Parking.mdf 的完整链路
2.1 解压即见真章:文件清单与职责划分
拿到停车场收费系统.zip后,解压目录结构非常干净,没有冗余文件:
Parking/ ├── Parking.sln # Visual Studio 解决方案入口 ├── Parking/ # 主项目文件夹(WinForm项目) │ ├── Properties/ │ ├── obj/ │ ├── bin/ │ ├── Parking.csproj # C#项目定义,TargetFramework="net472" │ ├── FormMain.cs # 主窗体:含菜单栏、状态栏、Tab页容器 │ ├── FrmAddCar.cs # 添加车辆窗体(进场登记) │ ├── FrmOutCar.cs # 出库结算窗体(核心计费逻辑在此) │ ├── FrmQuery.cs # 查询统计窗体(支持车牌模糊+时间范围) │ ├── FrmConfig.cs # 管理配置窗体(收费标准、车位总数等) │ ├── DBHelper.cs # 数据库操作封装类(关键!用SqlClient直连) │ ├── CarInfo.cs # 车辆实体类(含车牌、车型、入场时间等属性) │ └── FeeCalculator.cs # 收费计算器(独立类,解耦业务与UI) ├── Parking.mdf # SQL Server 主数据库文件(含表结构+初始数据) ├── Parking_log.ldf # 日志文件 ├── 附加脚本.sql # 建库建表SQL(CREATE DATABASE + CREATE TABLE) └── *.png # UI界面截图(非运行必需,仅参考布局)提示:
.mdf和.ldf是SQL Server Express LocalDB或SQL Server Desktop Engine的本地数据库文件,无需单独安装SQL Server服务端——VS自带LocalDB即可挂载。这是很多初学者卡住的第一步,后面会专门讲怎么让它们“活”起来。
2.2 技术选型为什么是 WinForm + SQL Server?
这不是历史包袱,而是精准匹配:
- WinForm:轻量、启动快、无依赖、适合内网单机部署。停车场管理终端常是老旧工控机,WinForm的.NET Framework 4.7.2兼容性远胜WPF或MAUI;
- SQL Server LocalDB:比SQLite强在事务支持和并发控制(多窗口同时查/改记录不锁死),比完整SQL Server弱在无需服务进程——
.mdf双击就能附加,开发零配置; - C# 语言特性:项目中大量使用
using (var conn = new SqlConnection(...))确保连接释放;DataTable+DataAdapter做离线数据缓存(查询页加载快);BackgroundWorker处理长耗时操作(如导出Excel)避免UI假死。
这种组合不是“过时”,而是在资源受限场景下对稳定性和可维护性的务实选择。你不会在这里看到Entity Framework的花哨映射,但每一行SqlCommand都带着参数化防注入的注释。
2.3 数据库设计:三张表撑起全部业务
打开附加脚本.sql,核心建表语句如下(已精简注释):
-- 车辆基本信息表(车牌为主键,唯一约束) CREATE TABLE CarInfo ( PlateNumber NVARCHAR(10) PRIMARY KEY, -- 车牌号,如"粤B12345" CarType INT NOT NULL, -- 车型:1=小型车, 2=中型车, 3=大型车 InTime DATETIME NOT NULL, -- 入场时间,精确到秒 OutTime DATETIME NULL, -- 出场时间,NULL表示未出场 IsMonthly BIT DEFAULT 0 -- 是否月租车(1=是,0=否) ); -- 收费标准配置表(单条记录,供FrmConfig修改) CREATE TABLE FeeConfig ( Id INT PRIMARY KEY IDENTITY(1,1), SmallCarFee DECIMAL(6,2) DEFAULT 2.00, -- 小型车每小时费用 MediumCarFee DECIMAL(6,2) DEFAULT 3.00, -- 中型车每小时费用 LargeCarFee DECIMAL(6,2) DEFAULT 5.00, -- 大型车每小时费用 FreeMinutes INT DEFAULT 30, -- 免费时长(分钟) DailyCap DECIMAL(6,2) DEFAULT 50.00 -- 日封顶金额 ); -- 停车记录流水表(每次出场生成一条,用于对账) CREATE TABLE ParkingLog ( Id INT PRIMARY KEY IDENTITY(1,1), PlateNumber NVARCHAR(10) NOT NULL, InTime DATETIME NOT NULL, OutTime DATETIME NOT NULL, DurationMinutes INT NOT NULL, FeeAmount DECIMAL(6,2) NOT NULL, PayStatus TINYINT DEFAULT 0, -- 0=未支付, 1=现金, 2=扫码, 3=月结 CreateTime DATETIME DEFAULT GETDATE() );注意:
CarInfo表用PlateNumber当主键而非自增ID,是因为业务上车牌号天然唯一且需高频查询;OutTime允许为NULL,直观表达“当前仍在场内”状态;ParkingLog是只增表,不出现在UI编辑中,所有出库操作先更新CarInfo.OutTime,再插入ParkingLog一条新记录——这是典型的状态分离设计,避免单表字段语义混乱。
2.4 核心模块通信机制:事件驱动 + 窗体间传参
WinForm项目最怕“全局变量污染”。本项目采用两种安全传参方式:
构造函数传参(推荐用于初始化)
FrmOutCar打开时,必须传入待结算的车牌号:// 在 FrmAddCar 或主窗体中调用 var outFrm = new FrmOutCar("粤B12345"); outFrm.ShowDialog();自定义事件回调(推荐用于结果回传)
FrmOutCar结算完成后,触发OnFeeCalculated事件通知主窗体刷新列表:// FrmOutCar.cs 内部 public event Action<string, decimal> OnFeeCalculated; private void btnConfirm_Click(object sender, EventArgs e) { decimal fee = calculator.Calculate(plate, inTime, outTime); OnFeeCalculated?.Invoke(plate, fee); // 通知主窗体 this.Close(); } // FormMain.cs 中订阅 var outFrm = new FrmOutCar(plate); outFrm.OnFeeCalculated += (p, f) => { RefreshCarList(); // 刷新主列表 ShowToast($"车牌{p}结算完成,费用{f:C}"); }; outFrm.ShowDialog();
这种模式让窗体职责清晰:FrmOutCar只管计算和确认,不碰数据库;FormMain负责数据加载和界面联动。改需求时,只需动一个窗体,不牵一发而动全身。
3. 运行前必做三件事:数据库附加、连接字符串修正、权限检查
3.1 让 Parking.mdf “活”起来:LocalDB附加实战
现象:双击Parking.sln→ 编译通过 → 运行报错System.Data.SqlClient.SqlException: 无法打开登录所请求的数据库 "Parking"。
原因:Visual Studio 默认 LocalDB 实例名是(localdb)\mssqllocaldb,但项目中连接字符串可能指向.\SQLEXPRESS或其他名称。
解决步骤(以 VS 2019/2022 为例):
确认 LocalDB 实例是否存在
打开命令提示符(管理员),执行:sqllocaldb info若输出包含
mssqllocaldb,说明已就绪;若无,运行sqllocaldb create mssqllocaldb创建。附加数据库文件
在 VS 中:- 视图 → SQL Server 对象资源管理器
- 右键
(localdb)\mssqllocaldb→ “附加数据库” - 点击“添加”,选择解压目录下的
Parking.mdf - 确认路径中
.ldf文件自动关联(若未出现,手动添加Parking_log.ldf) - 点击确定,数据库
Parking即出现在资源管理器中。
验证附加成功
展开Parking→ 表,应能看到CarInfo、FeeConfig、ParkingLog三张表,右键“查看顶部200行”可确认有测试数据(如车牌“粤A00001”已存在)。
提示:如果提示“数据库正在使用”,说明之前有程序占用了
.mdf。关闭所有 VS 实例,任务管理器结束sqlservr.exe进程,再重试。
3.2 修改连接字符串:从 App.config 到实际环境
项目连接字符串藏在Parking/App.config的<connectionStrings>节点中:
<add name="ParkingDB" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=Parking;Integrated Security=True" providerName="System.Data.SqlClient" />你需要根据上一步附加的实例名修改Data Source:
- 若用 LocalDB:改为
Data Source=(localdb)\mssqllocaldb - 若用 SQL Server Express:保持
.\SQLEXPRESS(需确保 SQL Server Browser 服务开启) - 若用远程服务器:填 IP+端口,如
192.168.1.100,1433
修改后,必须重启 VS才能使配置生效(App.config 在编译时被复制到bin\Debug\Parking.exe.config,运行时读取的是后者)。
3.3 权限检查:Windows账户能否访问 LocalDB?
现象:附加数据库时报错无法打开数据库 "master",错误 948或登录失败。
原因:LocalDB 默认只允许 Windows 当前用户访问,若你用管理员账户安装 VS,但日常用标准用户登录系统,就会权限不匹配。
解决方法(二选一):
- ✅ 推荐:用当前登录用户启动 VS(右键 VS 快捷方式 → “以管理员身份运行” → 不勾选,确保是普通用户权限)
- ⚠️ 次选:给 LocalDB 添加登录用户(需 DBA 权限,不推荐教学环境)
验证方式:在 SQL Server 对象资源管理器中,展开(localdb)\mssqllocaldb→ 安全性 → 登录名,确认你的 Windows 用户(如DESKTOP-XXX\YourName)已存在且状态为“启用”。
4. 避坑指南:新手跑通必踩的5个坑与血泪解法
4.1 坑1:运行时报错“未能加载文件或程序集 System.Data.SqlClient”
- 现象:启动即崩溃,错误信息含
System.Data.SqlClient, Version=4.6.1.0 - 原因:项目目标框架为 .NET Framework 4.7.2,但新版 VS 默认安装
System.Data.SqlClient5.x(.NET Core 专用),版本不兼容。 - 解决:
- 在解决方案资源管理器中,右键项目 → “管理 NuGet 包”
- 切换到“已安装”选项卡,找到
System.Data.SqlClient - 卸载它 → 返回“浏览”选项卡,搜索
System.Data.SqlClient - 选择4.8.5 版本(最后支持 .NET Framework 的稳定版)并安装
- 清理解决方案 → 重新生成
血泪经验:别信 NuGet 默认推荐的最高版本,.NET Framework 项目必须锁死 4.x 分支。
4.2 坑2:添加车辆时提示“违反了PRIMARY KEY约束”
- 现象:输入车牌“粤B12345”点击保存,弹窗报错“不能将值 NULL 插入列 'PlateNumber'”
- 原因:
CarInfo表主键是PlateNumber,但代码中未给该字段赋值,或 TextBox 控件名写错导致txtPlateNumber.Text为空。 - 排查:在
FrmAddCar.cs的保存按钮事件中,加断点观察car.PlateNumber值;检查设计器中 TextBox 的Name属性是否真是txtPlateNumber(常见误写为txtPlateNum)。 - 解决:确保
CarInfo实体类构造时校验车牌非空,并在 UI 层加前端提示:if (string.IsNullOrWhiteSpace(txtPlateNumber.Text.Trim())) { MessageBox.Show("请输入车牌号!", "输入错误", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
4.3 坑3:出库结算金额始终为0
- 现象:车辆停留2小时,应收4元,但
FrmOutCar显示“费用:¥0.00” - 原因:
FeeCalculator.Calculate()方法中,DurationMinutes计算逻辑错误。原代码用outTime.Subtract(inTime).TotalHours直接取整,导致30分钟返回0小时。 - 定位:打开
FeeCalculator.cs,找到Calculate方法,检查时间差计算段。 - 修复:改用分钟级计算,再按阶梯规则判断:
int totalMinutes = (int)(outTime - inTime).TotalMinutes; if (totalMinutes <= config.FreeMinutes) return 0; // 后续按每15分钟计费逻辑...
4.4 坑4:查询页面加载极慢,卡顿3秒以上
- 现象:点击“查询”按钮,界面假死,3秒后才显示数据
- 原因:
FrmQuery.cs中LoadData()方法直接执行SELECT * FROM CarInfo,未加WHERE条件,且未用BackgroundWorker异步加载。 - 优化:
- 给查询加默认时间范围(如近7天):
SELECT * FROM CarInfo WHERE InTime >= DATEADD(day, -7, GETDATE()) - 将数据加载逻辑移入
BackgroundWorker.DoWork事件,UI线程不阻塞。 - 加载中显示
Loading...Label,提升用户体验。
- 给查询加默认时间范围(如近7天):
4.5 坑5:修改收费标准后,下次启动仍用旧值
- 现象:在
FrmConfig中把小型车费用改成3元,点保存,重启程序又变回2元 - 原因:
FeeConfig表只有一条记录(Id=1),但FrmConfig的保存逻辑是INSERT INTO FeeConfig ...,导致插入第二条,而读取时SELECT TOP 1 * FROM FeeConfig随机取一条。 - 修复:将保存逻辑改为
UPDATE:string sql = "UPDATE FeeConfig SET SmallCarFee=@fee WHERE Id=1"; using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@fee", txtSmallFee.Text); cmd.ExecuteNonQuery(); }
5. 核心业务逻辑拆解:从进场到出库的完整计费链路
5.1 进场登记:CarInfo 表的插入与状态初始化
FrmAddCar.cs是整个流程的起点。关键逻辑在btnSave_Click事件中:
private void btnSave_Click(object sender, EventArgs e) { // 1. 实体映射(严格校验) var car = new CarInfo { PlateNumber = txtPlateNumber.Text.Trim(), CarType = cmbCarType.SelectedIndex + 1, // ComboBox索引0→车型1 InTime = DateTime.Now, IsMonthly = chkMonthly.Checked }; // 2. 数据库插入(参数化防注入) string sql = @"INSERT INTO CarInfo (PlateNumber, CarType, InTime, IsMonthly) VALUES (@plate, @type, @inTime, @isMonthly)"; using (var conn = DBHelper.GetConnection()) { using (var cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@plate", car.PlateNumber); cmd.Parameters.AddWithValue("@type", car.CarType); cmd.Parameters.AddWithValue("@inTime", car.InTime); cmd.Parameters.AddWithValue("@isMonthly", car.IsMonthly ? 1 : 0); conn.Open(); int rows = cmd.ExecuteNonQuery(); if (rows > 0) { MessageBox.Show("入场登记成功!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); this.Close(); } } } }参数说明:
@plate:强制Trim()去除首尾空格,避免“粤B12345 ”和“粤B12345”被当两个车牌;@type:ComboBox的SelectedIndex从0开始,车型值+1对齐数据库定义(1/2/3);@inTime:用DateTime.Now而非DateTime.Today,确保秒级精度,为后续计费提供依据;@isMonthly:布尔转整数(1/0),SQL ServerBIT类型只接受0或1。
5.2 出库结算:FeeCalculator 的阶梯计费算法实现
计费逻辑全部封装在FeeCalculator.cs中,这是项目最值得细读的类。核心方法Calculate如下:
public decimal Calculate(string plate, DateTime inTime, DateTime outTime, FeeConfig config) { // 1. 计算总停留分钟数(向上取整到15分钟) int totalMinutes = (int)Math.Ceiling((outTime - inTime).TotalMinutes); // 2. 获取对应车型单价 decimal baseFee = config.SmallCarFee; if (carType == 2) baseFee = config.MediumCarFee; else if (carType == 3) baseFee = config.LargeCarFee; // 3. 阶梯计费:前freeMinutes免费,之后每15分钟baseFee if (totalMinutes <= config.FreeMinutes) return 0; int chargeableMinutes = totalMinutes - config.FreeMinutes; int quarterHours = (int)Math.Ceiling((double)chargeableMinutes / 15); decimal fee = quarterHours * baseFee; // 4. 日封顶(防止长期占位) if (fee > config.DailyCap) fee = config.DailyCap; return Math.Round(fee, 2); // 保留两位小数 }关键细节:
Math.Ceiling确保31分钟按3个15分钟计(即45分钟),符合“不满15分钟按15分钟算”的行业惯例;baseFee动态获取,支持不同车型差异化定价;DailyCap是硬性封顶,避免用户故意不离场薅羊毛;Math.Round(fee, 2)防止浮点误差(如1.999999999显示为2.00)。
5.3 出库确认:CarInfo 更新 + ParkingLog 插入的事务保障
FrmOutCar.cs的结算确认是原子操作,必须保证CarInfo.OutTime更新和ParkingLog插入同时成功或同时失败:
private void btnConfirm_Click(object sender, EventArgs e) { using (var conn = DBHelper.GetConnection()) { conn.Open(); using (var trans = conn.BeginTransaction()) // 开启事务 { try { // 步骤1:更新车辆出场时间 string updateSql = "UPDATE CarInfo SET OutTime=@outTime WHERE PlateNumber=@plate"; using (var cmd = new SqlCommand(updateSql, conn, trans)) { cmd.Parameters.AddWithValue("@outTime", outTime); cmd.Parameters.AddWithValue("@plate", plate); cmd.ExecuteNonQuery(); } // 步骤2:插入停车流水 string insertSql = @"INSERT INTO ParkingLog (PlateNumber, InTime, OutTime, DurationMinutes, FeeAmount, PayStatus) VALUES (@plate, @in, @out, @dur, @fee, @pay)"; using (var cmd = new SqlCommand(insertSql, conn, trans)) { cmd.Parameters.AddWithValue("@plate", plate); cmd.Parameters.AddWithValue("@in", inTime); cmd.Parameters.AddWithValue("@out", outTime); cmd.Parameters.AddWithValue("@dur", durationMinutes); cmd.Parameters.AddWithValue("@fee", feeAmount); cmd.Parameters.AddWithValue("@pay", (byte)payStatus); cmd.ExecuteNonQuery(); } trans.Commit(); // 提交事务 MessageBox.Show($"结算成功!费用:{feeAmount:C}", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information); this.DialogResult = DialogResult.OK; } catch (Exception ex) { trans.Rollback(); // 回滚事务 MessageBox.Show($"结算失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } } } }为什么必须用事务?
假设只执行了UPDATE CarInfo,但INSERT ParkingLog因磁盘满失败,则数据库中车辆状态为“已出场”,但无流水记录,财务对不上账。事务确保二者要么全成功,要么全不发生,数据一致性有保障。
6. 二次开发实战:给系统加上微信支付回调与导出Excel功能
6.1 微信支付对接:在出库流程中嵌入扫码支付能力
原系统只支持现金和月结,要加微信支付,核心是两步:生成支付二维码、轮询支付结果。
步骤1:扩展PayStatus枚举与数据库
修改ParkingLog.PayStatus字段类型为TINYINT(已支持),新增值:
0=未支付(原值)1=现金2=微信(新增)3=月结(原值)
并在FrmOutCar.cs中,cmbPayType下拉框添加“微信支付”选项。
步骤2:生成微信预支付订单(伪代码,需对接微信API)
在btnConfirm_Click中,当选择微信支付时:
if (payStatus == 2) // 微信支付 { // 1. 调用微信统一下单接口(此处用HttpClient模拟) var wxOrder = await GenerateWxOrderAsync(plate, feeAmount); if (wxOrder.IsSuccess) { // 2. 生成二维码(用QRCoder库) var qrCode = new QRCoder.QRCodeGenerator().CreateQrCode(wxOrder.CodeUrl, QRCoder.QRCodeGenerator.ECCLevel.Q); var bitmap = new QRCoder.QRCode(qrCode).GetGraphic(20); // 3. 显示二维码窗体(FrmWxPay.cs),内置定时器每5秒查一次支付结果 var payFrm = new FrmWxPay(bitmap, wxOrder.TradeNo); if (payFrm.ShowDialog() == DialogResult.OK) { // 支付成功,更新ParkingLog.PayStatus=2 UpdatePayStatus(plate, 2, wxOrder.TradeNo); } } }关键点:
GenerateWxOrderAsync需实现微信官方unifiedorder接口,传入商户号、密钥、body(车牌号)、total_fee(单位:分);FrmWxPay窗体必须禁用关闭按钮(ControlBox=false),防止用户误关;- 支付结果轮询用
Timer,调用微信orderquery接口,查到trade_state=SUCCESS即回调主窗体。
步骤3:导出Excel报表(用 ClosedXML 库)
用户常需导出月度报表给财务。在FrmQuery.cs中添加“导出Excel”按钮:
private void btnExportExcel_Click(object sender, EventArgs e) { var dt = GetQueryResult(); // 执行查询,返回DataTable if (dt.Rows.Count == 0) return; // 使用 ClosedXML 生成Excel using (var wb = new XLWorkbook()) { var ws = wb.Worksheets.Add("停车记录"); ws.Cell(1, 1).Value = "车牌号"; ws.Cell(1, 2).Value = "入场时间"; ws.Cell(1, 3).Value = "出场时间"; ws.Cell(1, 4).Value = "停留分钟"; ws.Cell(1, 5).Value = "费用"; for (int i = 0; i < dt.Rows.Count; i++) { ws.Cell(i + 2, 1).Value = dt.Rows[i]["PlateNumber"].ToString(); ws.Cell(i + 2, 2).Value = Convert.ToDateTime(dt.Rows[i]["InTime"]); ws.Cell(i + 2, 3).Value = Convert.ToDateTime(dt.Rows[i]["OutTime"]); ws.Cell(i + 2, 4).Value = Convert.ToInt32(dt.Rows[i]["DurationMinutes"]); ws.Cell(i + 2, 5).Value = Convert.ToDecimal(dt.Rows[i]["FeeAmount"]); } // 自动列宽 ws.Columns().AdjustToContents(); // 保存文件 var saveDialog = new SaveFileDialog { Filter = "Excel文件|*.xlsx", FileName = $"停车记录_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx" }; if (saveDialog.ShowDialog() == DialogResult.OK) { wb.SaveAs(saveDialog.FileName); MessageBox.Show("导出成功!", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); } } }注意:需通过 NuGet 安装
ClosedXML(v0.95.4,兼容 .NET Framework 4.7.2),不要装最新版(要求 .NET 5+)。
6.2 我的二次开发习惯:每次改完必做三件事
改数据库必写迁移脚本
比如加微信支付字段,我不直接在 SSMS 里ALTER TABLE,而是新建V2_Add_WxPay_Column.sql:ALTER TABLE ParkingLog ADD WxTradeNo NVARCHAR(32) NULL; UPDATE ParkingLog SET WxTradeNo = '' WHERE WxTradeNo IS NULL;这样下次部署到客户机器,直接跑脚本,不担心漏改。
改UI必同步更新资源文件
FrmOutCar.resx里存着所有按钮文字。我改了“确认结算”为“微信扫码支付”,就立刻打开资源编辑器,把btnConfirm.Text的中文和英文(若有)都更新,避免中英文混杂。加新功能必补单元测试桩
FeeCalculator这种纯逻辑类,我用 NUnit 写测试用例:[Test] public void Calculate_小型车_停留45分钟_应返回2元() { var config = new FeeConfig { SmallCarFee = 2, FreeMinutes = 30 }; var fee = calculator.Calculate("粤B12345", DateTime.Now.AddMinutes(-45), DateTime.Now, config); Assert.AreEqual(2.00m, fee); }从那以后我每次重构计费逻辑,都强制走一遍这些测试用例,再敢动核心算法。
希望帮到你。
本文还有配套的精品资源,点击获取