C# WinForm停车场收费系统实战:数据库附加、计费逻辑与二次开发
简介这是一套面向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#项目定义TargetFrameworknet472 │ ├── 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 # 建库建表SQLCREATE 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或MAUISQL Server LocalDB比SQLite强在事务支持和并发控制多窗口同时查/改记录不锁死比完整SQL Server弱在无需服务进程——.mdf双击就能附加开发零配置C# 语言特性项目中大量使用using (var conn new SqlConnection(...))确保连接释放DataTableDataAdapter做离线数据缓存查询页加载快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 Actionstring, 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 nameParkingDB connectionStringData Source.\SQLEXPRESS;Initial CatalogParking;Integrated SecurityTrue providerNameSystem.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, Version4.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提升用户体验。4.5 坑5修改收费标准后下次启动仍用旧值现象在FrmConfig中把小型车费用改成3元点保存重启程序又变回2元原因FeeConfig表只有一条记录Id1但FrmConfig的保存逻辑是INSERT INTO FeeConfig ...导致插入第二条而读取时SELECT TOP 1 * FROM FeeConfig随机取一条。修复将保存逻辑改为UPDATEstring sql UPDATE FeeConfig SET SmallCarFeefee WHERE Id1; 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”被当两个车牌typeComboBox的SelectedIndex从0开始车型值1对齐数据库定义1/2/3inTime用DateTime.Now而非DateTime.Today确保秒级精度为后续计费提供依据isMonthly布尔转整数1/0SQL 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 OutTimeoutTime WHERE PlateNumberplate; 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.PayStatus2 UpdatePayStatus(plate, 2, wxOrder.TradeNo); } } }关键点GenerateWxOrderAsync需实现微信官方unifiedorder接口传入商户号、密钥、body车牌号、total_fee单位分FrmWxPay窗体必须禁用关闭按钮ControlBoxfalse防止用户误关支付结果轮询用Timer调用微信orderquery接口查到trade_stateSUCCESS即回调主窗体。步骤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 安装ClosedXMLv0.95.4兼容 .NET Framework 4.7.2不要装最新版要求 .NET 5。6.2 我的二次开发习惯每次改完必做三件事改数据库必写迁移脚本比如加微信支付字段我不直接在 SSMS 里ALTER TABLE而是新建V2_Add_WxPay_Column.sqlALTER 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); }从那以后我每次重构计费逻辑都强制走一遍这些测试用例再敢动核心算法。希望帮到你。本文还有配套的精品资源点击获取