ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于WinForm的停车场收费系统设计与避坑指南

基于WinForm的停车场收费系统设计与避坑指南 简介这是一份采用C#语言与Windows Forms框架开发的停车场收费系统完整源码面向希望学习桌面应用开发的初学者覆盖车辆入场登记、出场计费、收费记录查询及基础管理功能。压缩包共95个文件包含.cs源码文件、.resx与.resources界面资源文件、.sql数据库脚本以及.mdf/.ldf数据库文件总大小仅1.32MB轻量易用。系统按模块划分清晰从添加停车、车辆出库、自动计费到收费查询与管理设置完整呈现了典型业务闭环。目前已有1323人学习适合通过实际项目理解Winform控件布局、事件驱动编程和SQL Server数据操作。阅读源码可掌握停车场收费流程的设计思路包括停车时长计算、收费标准配置、数据表关联查询等关键技巧同时项目内附有运行截图和工程配置文件便于快速搭建环境并对照验证。这套代码麻雀虽小五脏俱全是练习C#数据库编程和桌面UI设计的良好范本。1. 停车场收费系统为什么用 WinForm 而不是 Web在我接过的十几个 WinForm 项目里停车场收费系统是最典型的“业务不复杂、但边界条件特别多”的单机应用。一个车场一天上千次进出收费算错一次车主和保安当场就要吵起来。C# 写 WinForm 做这类项目胜在开发速度快、部署简单——一台收银电脑装个 .NET Framework 就能跑不用配 IIS、不用折腾前端框架数据库用 Access 或 SQLite 都行。适合刚入门 C# 的开发者做练手也适合小停车场、单位内部车场做轻量收费管理。这篇我按自己做过的一套方案把从界面搭建到计费引擎再到踩坑记录完整拆开讲。2. 搭界面框架先用控件属性把值守台做出来再谈业务2.1 主窗体骨架与状态栏WinForm 界面美化从布局开始很多人上来就找界面美化库其实停车场这种值守场景界面最重要的是“信息密度够、操作路径短”。我一般先做主窗体用一个MenuStrip放功能菜单StatusStrip显示当前时间、车场剩余车位、操作员姓名中间用SplitContainer把左侧车辆列表和右侧操作区分开。新建项目后主窗体加载逻辑我习惯这样写public partial class FrmMain : Form { private readonly System.Windows.Forms.Timer _timer new System.Windows.Forms.Timer(); private readonly string _operatorName admin; public FrmMain() { InitializeComponent(); InitStatusBar(); } private void InitStatusBar() { _timer.Interval 1000; _timer.Tick (s, e) { toolStripStatusLabelTime.Text DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss); }; _timer.Start(); toolStripStatusLabelOperator.Text $操作员{_operatorName}; } }这段代码做了三件事启动一个每秒触发一次的Timer刷新状态栏时间在窗体加载时给操作员标签赋值然后保持状态栏常驻。这里有个细节Timer一定要设置Interval默认是 100 毫秒如果不设会让 UI 线程频繁刷新出现界面卡顿的“玄学”问题。主窗体里推荐用TableLayoutPanel固定操作区位置避免不同分辨率屏幕下控件乱跑。操作区建议放这几个核心控件车牌号TextBox、入场/出场两个Button、收费金额Label、收费规则说明GroupBox。每个控件记得改Name属性我见过太多人用完默认名textBox1、button2后续写事件处理时根本分不清哪个是哪个。WinForm 控件属性里Name是第一个要养成的习惯。2.2 ListView 展示车辆记录列头设置与状态着色车辆列表我首选ListView因为 WinForm 原生控件里它显示行数据最直观也能通过ListViewItem.ForeColor区分不同状态。配合View.Details模式显示列头和滚动条比DataGridView轻量不少而且不用额外引 DLL。private void InitVehicleListView() { listViewVehicles.View View.Details; listViewVehicles.FullRowSelect true; listViewVehicles.GridLines true; listViewVehicles.Columns.Add(车牌号, 140); listViewVehicles.Columns.Add(入场时间, 180); listViewVehicles.Columns.Add(状态, 100); listViewVehicles.Columns.Add(车位编号, 100); } private void RefreshVehicleList(IEnumerableParkingRecord records) { listViewVehicles.BeginUpdate(); listViewVehicles.Items.Clear(); foreach (var record in records) { var item new ListViewItem(record.PlateNo); item.SubItems.Add(record.EntryTime.ToString(yyyy-MM-dd HH:mm:ss)); item.SubItems.Add(record.Status 0 ? 在场 : 已出场); item.SubItems.Add(record.BayNo); if (record.Status 0) item.ForeColor Color.Green; else item.ForeColor Color.Gray; listViewVehicles.Items.Add(item); } listViewVehicles.EndUpdate(); }BeginUpdate和EndUpdate这对方法非常实用批量刷新 ListView 时如果不包一层每条 Item 都会触发一次重绘车辆一多界面就闪。Columns.Add的前两个参数是列标题和列宽最后一个参数可以传入HorizontalAlignment控制对齐。状态用颜色区分是停车场系统里最直观的做法绿色表示在场、灰色表示已出场后续如果要加“超时未缴费”还能用红色标出。到这里界面的“壳”已经立住了。下一步要解决数据从哪来、怎么存的问题。3. 数据层设计Access 选型、Dapper 封装与三张核心表3.1 为什么选 Access单机部署场景下最顺手停车场收费系统跑在收银电脑上并发量极低但要求断电重启后数据不丢。我在这个场景下通常选 Access.accdb理由很实际文件型数据库、无需安装服务、WinForm 通过OleDb直接连整机重装系统后拷走.accdb文件就是完整备份。SQLite 也可以但要额外引System.Data.SQLite在有 Office 环境的 Windows 上 Access 驱动是现成的。不过 Access 有一个明显的短板——并发写入能力弱所以本文方案里所有写操作走同一把锁读操作不做缓存后面避坑章节会专门讲这个问题。连接字符串直接写死在配置文件里不合适我用一个静态类统一管理public static class DbConfig { // 注意Provider 版本要和本机 Office 驱动匹配 public static string ConnectionString ProviderMicrosoft.ACE.OLEDB.12.0;Data Source|DataDirectory|ParkingDb.accdb;; } public static class ParkingDb { public static string ConnStr { get; set; } static ParkingDb() { var baseDir AppDomain.CurrentDomain.BaseDirectory; var dbFile Path.Combine(baseDir, App_Data, ParkingDb.accdb); ParkingDb.ConnStr string.Format( ProviderMicrosoft.ACE.OLEDB.12.0;Data Source{0};, dbFile); } }|DataDirectory|是 WinForm 项目里常用的占位符但不显式设置的话默认指向bin\Debug会导致发布后找不到数据库文件。我一般直接拼BaseDirectory App_Data目录并把数据库文件属性设为“如果较新则复制”这样发布后数据库文件会跟着 exe 走。注意 Access 驱动有两代老的是Microsoft.Jet.OLEDB.4.0只能读 .mdb新的是Microsoft.ACE.OLEDB.12.0读写 .accdb。64 位系统装 64 位 Office 时项目平台目标要选 x64否则会报“未注册”错误。3.2 三张核心数据表停车记录、收费标准、会员表我建表的原则是“能拆就拆别把规则硬编码在 C# 里”。停车收费系统的数据表至少要有三张结构如下表名字段说明t_ParkingRecordRecordId (自增主键), PlateNo, EntryTime, ExitTime, Status, Fee, BayNo每一辆车的一次进出记录t_RateRuleRuleId, RateName, FreeMinutes, UnitMinutes, UnitPrice, DailyCap收费标准支持多条并行t_MemberMemberId, PlateNo, MemberType, ExpireDate会员车月租车出场不收费或打折这里有个关键设计计费规则不要写死在代码里。如果某天停车场老板说“白天 5 元/小时、夜间 2 元/小时”改数据库就行不用重新编译 exe。t_RateRule表里的DailyCap字段表示单日封顶比如 24 元封顶超过按 24 元算。用 Dapper 做数据访问层是 WinForm 项目里很常见的做法它轻量、性能好还支持依赖注入和参数化查询public ListParkingRecord GetAllParkingRecords() { using (var conn new OleDbConnection(ParkingDb.ConnStr)) { var sql SELECT RecordId, PlateNo, EntryTime, ExitTime, Status, Fee, BayNo FROM t_ParkingRecord ORDER BY EntryTime DESC; return conn.QueryParkingRecord(sql).ToList(); } }Dapper 的QueryT会自动把表字段映射到类属性前提是属性名和列名一致。如果列名是下划线风格比如Plate_No要加[Column(Plate_No)]特性否则映射不上。初次上手的人最容易在这里翻车查询不报错但返回的列表每项都是默认值。Dapper 在 Access 上用OleDb连接时有一点要注意——不能依赖 Dapper 的DynamicParameters做隐式类型转换Access 的 OLEDB 对参数类型要求严格日期参数必须显式指定OleDbType.Date。3.3 初始化脚本与数据校验建好表后我习惯用一个InitDatabase()方法在程序启动时自动建表而不是手动在 Access 里点来点去。自动建表的 SQL 里要特别注意AUTOINCREMENT的写法Access 的语法和 SQL Server 不同private static void EnsureTablesCreated() { using (var conn new OleDbConnection(ParkingDb.ConnStr)) { conn.Open(); var createRecordSql CREATE TABLE IF NOT EXISTS t_ParkingRecord ( RecordId AUTOINCREMENT PRIMARY KEY, PlateNo VARCHAR(20) NOT NULL, EntryTime DATETIME NOT NULL, ExitTime DATETIME, Status INTEGER DEFAULT 0, Fee CURRENCY DEFAULT 0, BayNo VARCHAR(10) ); using (var cmd new OleDbCommand(createRecordSql, conn)) { cmd.ExecuteNonQuery(); } } }Access 的CREATE TABLE IF NOT EXISTS实际是 OLEDB 驱动的行为部分版本下要捕获OleDbException判断是“表已存在”错误再忽略不能完全依赖IF NOT EXISTS。AUTOINCREMENT是 Access 的自增列写法从 1 开始步长 1。CURRENCY类型对应 C# 的decimal适合存金额比DOUBLE更精确。这个初始化方法放在Program.cs的Main入口里程序启动先建表再弹主窗体避免首次运行因为缺表而白屏。4. 计费引擎与车牌识别对接从入场到出场的完整业务链路4.1 入场处理校验车牌、查询会员、写入记录入场流程逻辑上分四步车牌号非空校验、车牌格式规范化、查会员判断是否月租车、插入停车记录。车牌识别是另一个子系统通过摄像头 SDK 回调拿到识别结果后自动填充TextBox再触发入场按钮。没有摄像头的场景下值守员手动输入车牌号也能走完整流程。车牌号截取和格式校验是新手最容易写错的地方。摄像头识别的结果经常带“无车牌”“看不清”之类的噪音甚至识别成“京A12345”这种少一位的情况。我一般先做一次字符串清洗再按中国车牌规则做个简单校验private string NormalizePlateNo(string raw) { if (string.IsNullOrWhiteSpace(raw)) return string.Empty; // 去掉首尾空格和中间的空白字符 var cleaned raw.Trim().Replace( , ); // 部分 SDK 会返回“车牌-京A12345”这种带前缀的格式 if (cleaned.Contains(京) || cleaned.Contains(沪)) { var idx cleaned.IndexOfAny(new[] { 京, 沪, 粤, 苏, 浙 }); if (idx 0) cleaned cleaned.Substring(idx); } return cleaned.ToUpperInvariant(); } private bool IsValidPlateNo(string plateNo) { if (string.IsNullOrEmpty(plateNo) || plateNo.Length 7 || plateNo.Length 8) return false; // 简易判断前两位应该是汉字大写字母其余是字母或数字 var pattern ^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$; return Regex.IsMatch(plateNo, pattern); }IndexOfAny是 C# 字符串处理里比较冷门但好用的一组截取方法适合这种需要找定位字符的场景。正则里的\u4e00-\u9fa5匹配所有汉字最后5,6位是因为新能源车牌多一位。这套校验不算严格但足以把明显错误的数据挡在门外。入场按钮点击后的逻辑里还要查一次会员表如果该车牌在有效期内入场记录直接标记为“会员车”出场时跳过收费计算。4.2 计费引擎按入场时间算清每一分钟跨天规则单独处理出场计费是整个系统最核心的部分算法设计上分成三层基础费率、免费时长、单日封顶。单位时间价格统一用“元/30分钟”或“元/小时”存储计算时把分钟数换算成计费单元不足一个单元按一个单元算——这是停车场行业最常见的“向上取整”规则。public decimal CalculateFee(DateTime entryTime, DateTime exitTime, RateRule rule, ParkingRecord record) { if (record.MemberId.HasValue record.MemberExpireDate DateTime.Now) { // 月租车免费出场但要记录 record.Fee 0; return 0; } int totalMinutes (int)(exitTime - entryTime).TotalMinutes; // 免费时长判断 if (totalMinutes rule.FreeMinutes) { record.Fee 0; return 0; } // 先减去免费时长再计费 totalMinutes - rule.FreeMinutes; // 向上取整到计费单元 int units (int)Math.Ceiling(totalMinutes / (double)rule.UnitMinutes); decimal fee units * rule.UnitPrice; // 单日封顶 decimal dailyCap rule.DailyCap 0 ? rule.DailyCap : decimal.MaxValue; return Math.Min(fee, dailyCap); }计费引擎有三个坑要提前规避。第一个是Ceiling的精度问题totalMinutes / UnitMinutes一定要先把其中一个转成double或decimal否则整数相除会直接丢掉余数。第二个是免费时长的处理顺序先判断“总时长小于免费时长则不收费”再“减去免费时长后向上取整”这两步顺序反了会把免费时长当成额外赠送算错钱。第三个是跨天问题晚上 11 点进场、凌晨 2 点出场如果只算总分钟数可能触发“夜间费率”和“单日封顶”两个规则需要拆成两段计费再累加。拆段计费的逻辑我放在CalculateFee外面属于“收费规则服务”的一部分。判断方式是检查入场日期和出场日期是否同一天跨天时从入场时间到当天 24 点按白天的规则算24 点到出场时间按夜间规则算。这里需要注意DateTime的日期比较用entryTime.Date ! exitTime.Date而不是entryTime.Day ! exitTime.Day因为跨月时 Day 会重置用Date属性最稳妥。4.3 车牌识别 SDK 的回调与 UI 更新车牌识别相机通常通过 TCP 或 HTTP 回调识别结果。SDK 的回调线程不是 UI 线程直接往TextBox里赋值会抛跨线程异常。标准做法是先用Invoke或BeginInvoke把操作封送到 UI 线程。如果用的是async/await模式比BackgroundWorker更简洁private async void BtnEntry_Click(object sender, EventArgs e) { string plateNo NormalizePlateNo(txtPlateNo.Text.Trim()); if (!IsValidPlateNo(plateNo)) { lblStatus.Text 车牌号格式不正确请重新输入; return; } var existing await Task.Run(() _parkingService.GetActiveRecordByPlateNo(plateNo)); if (existing ! null) { lblStatus.Text 该车辆已入场请勿重复操作; return; } var record new ParkingRecord { PlateNo plateNo, EntryTime DateTime.Now, Status 0, BayNo txtBayNo.Text.Trim() }; int newId await _parkingService.InsertRecordAsync(record); lblStatus.Text $入场成功记录编号 {newId}; RefreshVehicleList(_parkingService.GetAllParkingRecords()); }async void用在事件处理器里是合法的因为 WinForms 事件回调签名是void但方法内部的await Task.Run已经把耗时操作移到了线程池。事件处理器里的异常不会被外面捕获所以InsertRecordAsync内部要做好 try-catch 并返回错误信息。这里的Invoke方法在小规模 UI 更新时够用但要注意如果 UI 线程繁忙Invoke会同步阻塞回调线程导致车牌识别器继续积压数据、画面掉帧。更稳妥的方式是BeginInvoke异步封送回调线程不等 UI 处理完就返回。到这里入场到出场的链路已经完整了。但项目真正难的不是主流程是那些“一天只出现几次但每次出现都让人头大”的边界情况。5. 停车场收费系统避坑记跨天计费、线程更新与数据库并发5.1 跨过零点的收费翻车计费时段拆分是硬需求我做第一个版本的时候计费引擎直接拿(exitTime - entryTime).TotalMinutes乘单价交付后第三天就出事了一辆车晚上 11 点进场凌晨 1 点出场按 5 元/小时的规则收了 15 元但车主说夜间应该半价。我查了一下入场时间 23:10出场时间 01:30总时长 140 分钟向上取整到 3 小时15 元——车主说平时夜间收费 2 元/小时只该收 5 元。原因就是没有按时间段拆开计费。24 点前那 50 分钟属于白天的费率24 点后那 90 分钟属于夜间费率。解决方式是把计费引擎拆成“按每个自然日分块”每块内再判断所属时段public decimal CalculateFeeByDaySegment( DateTime entryTime, DateTime exitTime, RateRule dayRule, RateRule nightRule, TimeSpan nightStart, TimeSpan nightEnd) { decimal totalFee 0; DateTime cursor entryTime; while (cursor exitTime) { DateTime segmentEnd cursor.Date.AddDays(1); if (segmentEnd exitTime) segmentEnd exitTime; totalFee CalculateSegmentFee(cursor, segmentEnd, dayRule, nightRule, nightStart, nightEnd); cursor segmentEnd; } return totalFee; }while循环按天切片每天再交给CalculateSegmentFee判断具体落在白天还是夜间区间。这里的nightStart和nightEnd我建议用TimeSpan配置比如22:00到06:00而不是硬编码在方法里。如果你的停车场夜间跨两天比如晚上 10 点到早上 8 点这个方案依然适用因为日切分和时段判断互不干扰。这个坑的教训是计费规则在数据库里怎么存代码就怎么读千万别在 C# 里写“临时判断”。5.2 跨线程更新 UI 的经典报错从“闪退”到定位到原因WinForm 初学者最容易遇到的崩溃就是InvalidOperationException: 线程间操作无效从不是创建控件的线程访问它。现象是程序跑着跑着车牌识别相机的回调一进来界面就崩了有时还是偶发。原因在于 WinForm 的控件只能在创建它的线程UI 线程上操作而车牌识别 SDK 的 TCP 接收线程回调里直接执行了txtPlateNo.Text result。解决方式是统一封装一个UiHelper所有跨线程控件更新都走这个方法public static class UiHelper { public static void SetText(Control control, string text) { if (control.InvokeRequired) { control.BeginInvoke(new Action(() control.Text text)); } else { control.Text text; } } }在回调里调用UiHelper.SetText(txtPlateNo, plateNo)问题就消失了。要注意BeginInvoke是异步的如果连续快速回调多次界面可能来不及刷新极端情况下会看到文本被旧值覆盖。这种情况可以在设置前加一个时间戳比对只更新最新的识别结果。另外InvokeRequired在控件句柄未创建时可能返回 false所以回调方法里建议先判断IsHandleCreated否则在高并发回调下会偶发空引用。5.3 Access 并发写入OleDb 的锁机制让人又爱又恨Access 有一个非常隐蔽的并发问题同时打开多个连接一个线程在写另一个线程读同一条记录时会偶发“Locked”异常。现象是系统运行几小时后某个出场操作突然报错“文件正在使用中”重启程序又好了。原因是 Access 的默认锁粒度是整表锁写入期间所有其他连接都会被阻塞。解决方式有两个方向第一是全局只维护一个OleDbConnection所有操作串行执行第二是为每次操作创建短连接但遇到锁冲突时重试。我选了第二个方向因为短连接更贴近后续迁移 SQL Server 的习惯public T ExecuteWithRetryT(FuncOleDbConnection, T action, int maxRetry 3) { int retryCount 0; while (retryCount maxRetry) { using (var conn new OleDbConnection(ParkingDb.ConnStr)) { try { conn.Open(); return action(conn); } catch (OleDbException ex) when (ex.Message.Contains(Locked)) { retryCount; Thread.Sleep(200 * retryCount); } } } throw new InvalidOperationException(数据库多次锁冲突请稍后重试); }重试策略里指数退避的200 * retryCount是个经验值太小比如 50ms的话高并发下会连续撞锁太大比如 1s会让车主等得不耐烦。配合INSERT操作时最好在事务里显式提交因为OleDbCommand默认行为在部分驱动下是隐式事务异常回滚不可控。数据库层面还有个习惯每天凌晨清理历史记录只保留最近三个月的出场记录Access 文件体积太大会让全表扫描越来越慢这个操作放在程序启动时用DELETE FROM t_ParkingRecord WHERE ExitTime ?做一次即可。5.4 车牌识别不到的兜底手动输入别做成“摆设”摄像头识别率到不了 100%最常见的场景是雨雪天、车牌污损、驶入角度偏斜导致识别失败。系统不能因此卡住入口。我的设计是识别失败时状态栏变红提示同时要求值守员手动输入车牌。但手动输入也有坑——识别结果和手动输入在同一时刻触发可能会把刚输入的字符覆盖掉。解决方式是在手动输入获得焦点时禁用 SDK 回调的自动填充private void TxtPlateNo_Enter(object sender, EventArgs e) { _cameraService.EnableAutoFill false; txtPlateNo.Clear(); } private void TxtPlateNo_Leave(object sender, EventArgs e) { _cameraService.EnableAutoFill true; }这个“焦点控制自动填充”的做法比“用一个 flag 控制回调”更直观Enter事件里清空输入框能避免上次车牌残留。还有个小细节手动输入时不要限制只能输入汉字和字母因为有些特殊车牌使馆车、警车格式不同正则校验太严格会把人卡死。建议校验放宽到“长度 6~10 且不包含空格”把严格校验留给计费逻辑不在输入这一步拦截。6. 上线前调试技巧给计价规则做一套内存验证器比实车测试快十倍收费标准改一次就要在出入口一遍遍模拟进出车辆这个效率太低。我在后期给项目加了一个隐藏的“计费验证器”在菜单栏加一个FrmFeeSimulator窗体里面放开始时间、结束时间、费率三列输入点击计算后直接调用CalculateFeeByDaySegment输出结果方便快速验证边界条件。这个验证器的核心代码只有几十行但价值很大private void BtnCalc_Click(object sender, EventArgs e) { var entry DateTime.ParseExact(txtEntry.Text, yyyy-MM-dd HH:mm:ss, CultureInfo.InvariantCulture); var exit DateTime.ParseExact(txtExit.Text, yyyy-MM-dd HH:mm:ss, CultureInfo.InvariantCulture); var rule new RateRule { FreeMinutes int.Parse(txtFree.Text), UnitMinutes int.Parse(txtUnit.Text), UnitPrice decimal.Parse(txtPrice.Text), DailyCap int.Parse(txtCap.Text) }; var fee _feeEngine.CalculateFeeByDaySegment( entry, exit, new RateRule { /* 白天费率 */ }, new RateRule { /* 夜间费率 */ }, new TimeSpan(22, 0, 0), new TimeSpan(6, 0, 0)); lblResult.Text $应收{fee:F2} 元; listDebug.Items.Add(${entry:MM-dd HH:mm} ~ {exit:MM-dd HH:mm} {fee:F2} 元); }调试列表listDebug会把每次模拟结果追加在一起这样连续测试十几组边界数据后肉眼就能看出哪条规则没覆盖。我把常见的边界用例整理成了一份核对清单免费时长恰好卡在临界值、跨天跨月、单日封顶触发、月租车过期当天、新能源车牌超长识别。每次上线前按这份清单跑一遍比在现场拿真车测试要省一个小时的人工。这个调试器还有一个隐藏好处它能帮助你在和停车场管理人员沟通收费标准时直接把数字摆到桌面上验证。对方说“超过一小时按两小时算”你当场就能输入一组时间点确认规则。说“夜间免费”那就直接把夜间费率写成 0。定价规则这东西代码写得再优雅不如现场验一遍让人放心。我后来在每个 WinForm 项目里都会留一个类似的隐藏调试入口作为给自己的“后悔药”。做停车场收费系统最大的感悟是技术上不难难的是把收费规则理解透彻。规则里“不足一小时按一小时算”“免费 15 分钟”“24 小时封顶 30 元”这几句话翻译成代码时每一步都有边界。我第一版就因为跨天没拆分吃了亏后来每次写计费引擎都先花半小时把规则拆成可以验证的用例表再动手。这个顺序千万别颠倒。希望帮到你。本文还有配套的精品资源点击获取
返回列表