简介:C#停车场收费系统是一个基于Windows Forms框架的桌面应用程序,主要面向C#初中级开发者和需要完成课程设计、动手实践的读者,解决停车场景中车辆登记、计时计费、数据查询等一体化管理问题。系统后端使用SQL Server存储车辆信息、停车记录和收费记录,界面层包含添加停车、车辆出库、停车收费、收费查询以及管理模块;源码中的事件驱动交互方式、DataGridView数据展示、数据库连接与参数化查询写法,都适合初学者逐段研读。压缩包共95个文件,其中37个cs源码文件、13个resx界面资源文件和13个resources编译资源文件构成主体,另有3个exe可执行文件可直接运行,附带sql数据库脚本、mdf及ldf数据库文件,整体只有1.32MB,下载后便于完整对照学习。已有1323人学习。通过该项目的源码和数据库脚本,读者能获得一个可扩展的停车场管理系统框架,理解收费算法如何根据停放时间动态计算费用,并参考其模块划分与代码组织方式,为后续开发更完整的商业管理软件打下基础。
1. C#停车场收费系统:一个能跑起来的Winform项目
小区门口那个收费岗亭还在用纸笔记账,换班时对不上账,这是很多人想写一个停车场收费系统的起点。这份C#停车场收费系统项目是一个典型的Winform桌面应用,集成SQL Server数据库,下载后打开Parking.sln,附加数据库就能看到完整的进出场登记、计费、查询和管理功能。它不是那种只有登录页的演示项目,车辆信息表、停车记录表、收费记录表都是真实建好表结构并带数据的。适合刚学完C#语法、想找一个能跑通的Winform项目案例练手的初学者,也适合需要课程设计底稿、或者想搞明白桌面应用怎么做数据库读写的从业者。
2. 先从项目文件和数据库下手:看懂结构再谈改代码
2.1 Parking.sln与文件清单:VS解决方案里都放了什么
拿到压缩包先别急着双击运行,把文件认一遍再动手。很多人第一次打开会困惑:为什么有个.mdf还有个.ldf,为什么又有一个.sql脚本,.vs目录能不能删。这些我拆开说。
Project文件清单里核心是这样几类。Parking.sln是解决方案文件,VS的项目入口,没有它VS不知道要加载什么项目。用VS2015及以上版本打开基本不会报兼容性错误,不用纠结创建工具版本。Parking目录是实际的项目目录,里面放着.cs源文件、.Designer.cs窗体设计器文件、.resx资源文件、App.config配置文件。.vs目录是VS的本地缓存目录,存窗口布局、调试断点之类的用户态数据,删掉不影响编译,提交代码时通常忽略它。Parking.mdf和Parking_log.ldf是SQL Server的数据库主文件和事务日志文件,这两个要成对拷贝,缺一个都附加不上。附加脚本.sql是备选的初始化脚本,建库建表语句都在里面,数据库文件损坏时靠它重建结构。完成图那几张QQ图片就是运行界面截图,先看它们能快速知道系统长什么样,不用等编译完再猜界面。
提示:先看完成图,再打开解决方案,最后附加数据库,这个顺序能省不少折腾时间。
为什么用SQL Server而不是Access或SQLite?停车场收费的数据有明确的“一次进出对应一条记录”的关系,查询、统计需要按车牌聚合,SQL Server在关系数据完整性和事务支持上比Access扎实,比SQLite更适合做桌面应用的服务端存储。而且Winform项目里连接SQL Server是标准组合,代码里看到的SqlConnection、SqlCommand这些类,换到任何B/S项目里依然成立,学习迁移成本低。
2.2 数据库表设计:车辆、停车记录、收费记录三张表的分工
从摘要的业务描述能看出,系统至少包含车辆信息表、停车记录表、收费记录表三张核心表。我按这类系统最常见的设计列一下字段,你对照手里的脚本看是否对得上。
车辆信息表(VehicleInfo)
| 字段 | 类型 | 说明 |
|---|---|---|
| ID | int | 主键,自增 |
| PlateNumber | nvarchar(20) | 车牌号,唯一约束 |
| VehicleType | nvarchar(20) | 车型:小型车或大型车 |
| OwnerName | nvarchar(50) | 车主姓名 |
| Phone | nvarchar(20) | 联系电话 |
| Remark | nvarchar(200) | 备注 |
停车记录表(ParkingRecord)
| 字段 | 类型 | 说明 |
|---|---|---|
| RecordID | int | 主键,自增 |
| PlateNumber | nvarchar(20) | 车牌号,冗余存储 |
| InTime | datetime | 进场时间 |
| OutTime | datetime | 出场时间,可空 |
| DurationMinutes | int | 停车时长,单位分钟 |
| Fee | decimal(10,2) | 应收费用 |
| Status | tinyint | 0在停,1已出 |
收费记录表(ChargeRecord)
| 字段 | 类型 | 说明 |
|---|---|---|
| ChargeID | int | 主键,自增 |
| RecordID | int | 关联停车记录 |
| PlateNumber | nvarchar(20) | 冗余车牌 |
| ChargeTime | datetime | 收费时间 |
| Amount | decimal(10,2) | 实收金额 |
为什么PlateNumber在停车记录和收费记录里都冗余一份?因为车辆信息表里的车牌可能被修改,而历史流水必须保留入场那一刻的真实值。这是正规流水表该有的样子,不是为了省一次联查才这么设计。Status字段用tinyint而不是直接存字符串,是为了查询时用CASE WHEN转成中文显示,排序也更快。如果你看到的字段名和这个不完全一样,不用慌,看明白三张表的关系思路就通了:车辆信息表管静态资料,停车记录表管进出状态,收费记录表管金额流水。
2.3 附加MDF与执行SQL脚本:两条路都要会
让系统跑起来的第一步不是按F5编译,而是把数据库挂上。这里有两条路,我建议两条都会。
第一条路是直接附加MDF。打开SQL Server Management Studio,右键“数据库”节点选“附加”,把Parking.mdf加进去,系统会自动找到同级的Parking_log.ldf。附加之前把两个文件放到SQL Server能访问的目录,比如默认的DATA目录,否则会遇到权限问题,这个坑后面单独说。
第二条路是执行附加脚本.sql。在SSMS里新建查询,把整个脚本跑一遍,数据库和表就自动建好了。脚本通常包含CREATE DATABASE、CREATE TABLE和少量初始数据,适合数据库文件丢失或者需要重建库的场景。
提示:无论走哪条路,跑起来之后先执行一条查询验证数据能读,再打开项目编译。
验证数据库是否正常可以用这条:
SELECT name, database_id, state_desc FROM sys.databases WHERE name = 'Parking';这段SQL查的是系统视图sys.databases,如果返回记录的state_desc是ONLINE,说明数据库已挂载;如果出现RECOVERING或OFFLINE,说明文件有问题或者还在做恢复。之后再打开App.config看连接字符串:
<connectionStrings> <add name="ParkingDB" connectionString="Data Source=.;Initial Catalog=Parking;User ID=sa;Password=你的密码;" providerName="System.Data.SqlClient"/> </connectionStrings>Data Source是服务器实例名,小数点代表本机默认实例;Initial Catalog指定数据库名;User ID和Password是SQL Server登录账号,如果你用的是Windows身份验证,这段应该改成Integrated Security=True,并把User ID那两段去掉。这步改不对,后面所有查询都会报“无法连接数据库”。
3. 核心业务落地:进场登记、出场计费与查询的C#实现
3.1 添加停车:车牌校验与进场时间写入
添加停车这一段,代码逻辑最关键的是两件事:车牌号非空校验、进场时间由数据库服务器取而不是用户手输。常见错误是让用户在界面上挑时间,一旦电脑时钟不准,计费就全偏了。用GETDATE()让SQL Server返回服务器时间,和真实时间保持一致。
核心方法大概长这样:
private void btnAddParking_Click(object sender, EventArgs e) { string plate = txtPlateNumber.Text.Trim(); if (string.IsNullOrEmpty(plate)) { MessageBox.Show("车牌号不能为空"); return; } string connStr = ConfigurationManager.ConnectionStrings["ParkingDB"].ConnectionString; string sql = "INSERT INTO ParkingRecord (PlateNumber, InTime, Status) " + "VALUES (@plate, GETDATE(), 0)"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue("@plate", plate); conn.Open(); cmd.ExecuteNonQuery(); } MessageBox.Show(plate + " 已入场"); LoadCurrentParking(); }这段代码做了四件事:取车牌并去掉首尾空格,空车牌直接return不往下执行;拼SQL语句时用@plate占位符,而不是字符串拼接;连接在参数准备好之后再打开;执行完刷新在停列表。为什么我用AddWithValue而不是拼字符串?因为停车场系统虽然是内部工具,但车牌号被人塞一段SQL并不是玩笑,参数化查询是最基本的安全基线。初次写代码的人特别喜欢把plate直接拼进SQL字符串里,这是第一个要丢掉的习惯。
再补一句,上面这段没写try-catch,实际项目里我会在conn.Open()外面包一层,数据库连不上时弹具体错误信息而不是程序直接崩溃报一串英文堆栈。可以先写成这样,后面再慢慢加异常处理。
3.2 车辆出库与计费算法:时长计算和收费标准怎么定
出库是整套系统最核心的一段。计费逻辑一般是:入场时插入一条记录,出场时用当前时间减InTime算分钟数,再套收费标准。常见收费模型是首小时X元、之后每小时Y元、单日封顶Z元,甚至按时/按日/按次切换。
private decimal CalculateFee(DateTime inTime, DateTime outTime) { // 按分钟计算,不足一小时按一小时算 int minutes = (int)(outTime - inTime).TotalMinutes; if (minutes <= 0) minutes = 1; decimal baseRate = 5m; // 首小时5元 decimal perHour = 2m; // 之后每小时2元 decimal dayLimit = 20m; // 单日封顶20元 decimal total = baseRate; int extraHours = (int)Math.Ceiling((minutes - 60) / 60.0); if (extraHours > 0) { total += extraHours * perHour; } // 超出单日封顶按封顶收费 if (total > dayLimit) total = dayLimit; return total; }这里有几个细节值得展开。TotalMinutes转int会截断小数,如果车辆停了不到一分钟,减出来是0,所以后面补了一个if判断,避免出现0分钟却收钱的情况。Math.Ceiling向上取整,让超出的不足一小时按一小时收,这是停车场常见的规则:停1小时01分,按满2小时收。perHour、baseRate、dayLimit三组数字,真正的做法是抽成一张FeeConfig配置表,让管理员在界面上改,而不是像这样写死在代码里。摘要里提到系统应能灵活调整收费标准,指的就是这条演进方向。
出场时在代码里先调CalculateFee算出金额,再UPDATE停车记录表,写入OutTime、DurationMinutes、Fee,同时把Status改成1,最后往收费记录表插一条ChargeRecord。这三个动作要放在同一个事务里执行,否则会出现车已经开走了但记录还停在“在停”状态的脏数据。
3.3 收费查询与管理:DataGridView绑定与参数化查询
查询模块是“Winform做简单表格”最常见的场景,核心是用DataGridView绑定DataTable。你不需要一行行往DataGridViewRow里塞数据,直接用SqlDataAdapter把查询结果塞进DataTable,再赋给DataSource就行。
private void btnSearch_Click(object sender, EventArgs e) { string plate = txtSearchPlate.Text.Trim(); string sql = "SELECT PlateNumber, InTime, OutTime, Fee, " + "CASE Status WHEN 0 THEN '在停' ELSE '已出' END AS StatusText " + "FROM ParkingRecord WHERE PlateNumber LIKE @plate + '%' " + "ORDER BY InTime DESC"; DataTable dt = new DataTable(); using (SqlConnection conn = new SqlConnection(connStr)) using (SqlDataAdapter adapter = new SqlDataAdapter(sql, conn)) { adapter.SelectCommand.Parameters.AddWithValue("@plate", plate); conn.Open(); adapter.Fill(dt); } dgvResult.DataSource = dt; }这个查询用了一招:LIKE @plate + '%'实现车牌前缀模糊匹配,输入“京A”就能列出所有京A开头的车辆。%这个通配符要拼在参数值后面,而不是写死在SQL语句里,否则参数化意义就变了。SqlDataAdapter的Fill方法会自动打开和关闭连接,RunTime层面省了一步。DataGridView直接绑DataTable之后,列名默认以SELECT里的别名显示,这里把Status转成StatusText就是为了界面直接显示中文状态。
管理模块的权限控制,常见做法是在登录时把用户角色存到内存里,进入管理页面前判断角色值。Winform没有天然的权限中间件,只能在每个业务方法的入口做判断,代码写起来略啰嗦,但足够应付这种规模的桌面应用。你可以看到源码里所有按钮的Click事件第一行基本都在做角色校验,这不是冗余,是桌面应用最朴素的权限控制方式。
4. Winform界面交互:控件布局、状态栏刷新与界面美化
4.1 主窗体布局:从完成图反推控件排布
先看完成图,再对照窗体设计器,你会发现这套界面的布局思路是典型的“上搜索、中列表、下按钮”三段式。顶部放车牌输入框和查询按钮,中部放DataGridView做在停列表,底部放“添加停车”“车辆出库”“刷新”几个主操作按钮。这种布局的好处是操作路径短:输入车牌、查询、选中记录、出库,都在一屏内完成,不需要频繁切换Tab页。
拆这种窗体最好的方式是打开Form的Designer.cs文件。Winform的窗体布局不是运行时画的,在InitializeComponent()方法里能看到每个控件的Location、Size、Dock、Anchor属性。我一般会先把这些属性抄下来,再用TableLayoutPanel重排一遍。用固定Location布局的问题在于分辨率一变就乱,改成Dock + Anchor之后窗口拉伸时控件能跟着走。这套项目源码里用的是直接拖拽的固定布局,功能没问题,但分辨率适配是后续改造的重点。
4.2 状态栏与进度条:耗时操作不要卡住主线程
Winform新手最常见的翻车是:查询大数据或者导出报表时,直接在UI线程里同步执行,界面直接变成“未响应”,拖都拖不动。原因是UI线程被SQL查询占满了,消息循环處理不过来。
给这个系统加一个导出全部停车记录的按钮,如果直接在按钮Click里循环一万条数据,界面大概率卡死。正确做法是用async/await加Task.Run,把耗时操作丢到后台线程,用Progress 回传进度:
private async void btnExport_Click(object sender, EventArgs e) { btnExport.Enabled = false; var progress = new Progress<int>(p => { progressBar1.Value = p; statusStrip1.Items[0].Text = "正在导出: " + p + "%"; }); await Task.Run(() => DoExport(progress)); btnExport.Enabled = true; statusStrip1.Items[0].Text = "导出完成"; } private void DoExport(IProgress<int> progress) { for (int i = 0; i < 100; i++) { // 模拟分批导出逻辑 Thread.Sleep(50); if (progress != null) progress.Report(i + 1); } }关键点在于Progress 是在UI线程创建的,它的回调自动封送回UI线程,所以进度条和状态栏更新不会跨线程报错。Task.Run把DoExport放到线程池执行,UI线程腾出来继续响应鼠标键盘。状态栏和进度条是Winform桌面应用体验的核心,这个模式的代码值得抄走。
4.3 界面美化:给默认控件一点现代感
Winform默认控件的观感停留在XP时代,但美化不需要引入WPF或重写控件,从三件事下手就能明显改善:统一字体、统一间距、用好Panel分区。
第一,窗体字体统一设置为微软雅黑 9pt,控件默认继承父窗体的字体,不用逐个设置。第二,按钮加上FlatStyle = Flat,FlatAppearance.BorderColor选一个和主题色一致的浅灰,视觉上立刻轻量很多。第三,用Panel把界面区分成功能区、数据区、操作区三段,每个Panel设不同的BackColor,比如数据区是白色,功能区是浅灰。这套做法在Winform项目案例里很通用,不动一行业务代码,纯改属性就能见效。
主题色也可以用代码统一管理,定义一个静态类:
public static class Theme { public static readonly Color Primary = Color.FromArgb(52, 73, 94); public static readonly Color Accent = Color.FromArgb(46, 204, 113); }然后在Form_Load里给需要强调的按钮设置BackColor = Theme.Accent。这种做法让颜色定义集中在一处,后期调色不用满项目找。
5. 避坑与排查:连接失败、计费偏差、并发与打包部署
5.1 附加数据库失败或SQL登录名无法连接
现象:附加MDF时报错“无法检索此数据库的元数据”,或者代码运行时报“用户登录失败”。
原因:MDF和LDF文件放在C盘Program Files等受保护目录时,SQL Server服务账户没有读写权限。另一个常见原因是附加时只加了MDF,系统找不到同名的LDF文件,Windows资源管理器默认隐藏了扩展名,文件其实在但用户没看到。
解决:先把两个文件复制到SQL Server的DATA目录,右键文件属性,安全选项卡里给SQL Server服务账户加完全控制权限。附加时通过SSMS对话框选MDF,不要手动硬勾选LDF。如果还是失败,把文件和日志放进同一目录再重试。
5.2 计费金额不对:DateTime相减与四舍五入的边界
现象:车辆停了一分钟,费用算成0元;停了1小时01分,收了5元而不是7元;跨天停车只按小时累加,没有按天封顶。
原因:第一类问题是没有处理TotalMinutes为0的边界,直接用0乘单价,结果就是0。第二类问题是没有用Math.Ceiling向上取整,1小时01分取整成1小时,少算了。第三类是把封顶判断写成“单次超过dayLimit就按dayLimit收”,而不是“超过天数乘dayLimit”,导致停两天的车被算成一天封顶价。
解决:所有时间差都用TotalMinutes先算总分钟数,小于等于0时强制置为1。超出首小时的分钟数除以60后Math.Ceiling向上取整。封顶判断改成这样:
int days = (int)Math.Ceiling(minutes / 1440.0); decimal total = Math.Min(rawFee, dayLimit * days);1440是一天的分钟数,days算出停放天数,封顶金额等于天数和单日封顶的乘积。这样停两天就是40元封顶,而不是20元封顶。
5.3 同一车牌重复进出的并发边界
现象:同一辆车出场时明明是“在停”状态,但执行UPDATE时却影响了0行,界面提示出库成功,实际记录没更新;或者同一车牌在还没出场时又入场了一次,数据库里出现两条Status=0的记录。
原因:收费查询页面和管理员页面可能同时打开,两条操作线程读到的是同一份旧数据。出场时没有加“当前状态必须是0”的条件,直接UPDATE OutTime,如果记录已经被另一个线程改成已出场,本次更新就像打在空气上。
解决:出场更新的SQL必须带状态条件:
string sql = "UPDATE ParkingRecord SET OutTime=GETDATE(), " + "DurationMinutes=@minutes, Fee=@fee, Status=1 " + "WHERE RecordID=@id AND Status=0";然后再检查ExecuteNonQuery的返回值,等于1说明真的更新到了,等于0说明记录状态已经变了,此时弹窗提示“该车辆已出场”而不是默认出库成功。入场端同理,添加停车前先查一下有没有未出场的记录。
5.4 Winform打包成安装程序时的连接字符串问题
现象:开发环境跑得好好的,打包成安装程序装到别的机器上,打开就报“建立与SQL Server的连接时发生错误”。
原因:连接字符串里写的是本机实例名“.”,换台机器数据库实例不同,自然连不上。还有更隐蔽的:App.config在打包时没有跟着输出到发布目录,程序读不到配置,只能用代码里的默认连接字符串。
解决:打包成安装程序之前,把连接字符串改成可配置项,并确认发布目录里有exe.config文件。如果目标机器没装SQL Server,可以考虑在打包时带上LocalDB,连接字符串改成LocalDB实例,或者在安装界面加一步让用户填写服务器地址。常见做法是做一个数据库配置窗体,首次启动时让用户填“服务器名/账号/密码”,再写入App.config,这样安装包不管装到哪台机器都能适配。打包工具我常用VS自带的Installer Projects,如果要把项目里的DLL合并成一个exe,可以配合Costura.Fody这类工具,注意合并之后连接字符串配置文件不要丢失。
6. 进阶实践:先跑通再重构,把系统改成你自己的
6.1 增加一个按天封顶的收费标准
把写死在代码里的收费标准抽成一张FeeConfig表,字段就三个:ConfigKey、ConfigValue、Remark。基础费率、每超时费、单日封顶都存成记录。然后修改CalculateFee方法,启动时从表里读配置代替硬编码。这步做完,管理员就能在界面改价格,系统才算真正“灵活调整收费标准”。
6.2 用一个SQL查询验证改对了没有
重构完先不急着点界面,用一条SQL验证逻辑:
SELECT PlateNumber, InTime, OutTime, DurationMinutes, Fee, CASE WHEN Fee > DurationMinutes * 0.1 THEN '疑似偏高' WHEN Fee = 0 AND Status = 1 THEN '疑似漏收' ELSE '正常' END AS FeeCheck FROM ParkingRecord WHERE Status = 1;这条查询把已出场记录的时长和费用做粗略交叉验证,能快速找出计费异常的记录。验证通过后再跑一次界面操作,入场、出场、查询各点一遍,数据落库结果和这条SQL的检查结果一致,说明重构没有破坏原逻辑。
这套系统真正练到手的标志,不是把源码跑起来,而是把写死的值改成可配置、把同步查询改成异步带进度、把固定布局改成自适应布局。我自己的习惯是拿到任何Winform项目,先做这三步重构,再谈别的功能。从那以后我每次接手源码,都强制走一遍“先跑通、再拆表、再异步化”的流程。希望帮到你。
本文还有配套的精品资源,点击获取