尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#停车场收费系统实战:SQLite+Winform完整实现与避坑指南

C#停车场收费系统实战:SQLite+Winform完整实现与避坑指南 简介C#停车场收费系统是一套基于Winform框架开发的桌面应用程序源码面向需要掌握C#与SQL Server数据库联用的初中级开发者解决停车场车辆进出、计费与信息管理的实际业务需求。压缩包共含95个文件大小仅1.32MB涵盖C#源文件、窗体资源与配置文件、SQL脚本及数据库文件并附可直接运行的exe程序便于源码研读与快速部署。目前已有1323人学习下载适合作为课程设计或入门实践项目。通过该项目可完整理解停车添加、车辆出库、费用计算、收费查询及管理模块的代码实现并学习事件驱动编程与数据库表结构设计、增删改查操作实践价值较高。附属运行截图与说明文档还能帮助上手收费标准也可灵活调整便于举一反三。1. 做停车场收费系统难点从来不在收费计算停车场的收费逻辑听起来很简单进来记时间出去算钱。但把这个逻辑做成C#停车场收费系统Winform项目时真正的复杂度全在状态流转上——同一辆车能不能重复入场、月租车过期当天算临时还是免费、跨天停车怎么切分计费、界面定时刷新会不会卡死收费员。这篇文章按一套能真实上线的方案来讲SQLite建三张表、收费规则引擎、进出场事务流程、Winform界面交互以及现场跑过才会碰到的坑。适合拿C#做毕设、想转上位机开发、或者正被外包停车场项目缠住的人照着一步步搭完离能演示的系统就不远了。2. 先把数据模型立住三张表撑起整个收费流程2.1 为什么数据库选SQLite而不是MySQL接到这类项目时客户手里通常是一台老Windows电脑收费窗口不可能专门装MySQL服务端出了问题也没人会维护。所以我一般优先选SQLite整个数据库就是一个.db文件放在程序目录下拷走就能用。System.Data.SQLite是官方维护的ADO.NET提供程序用起来和SqlConnection差不多C#入门阶段的人也能快速上手。有人会问停车场迟早要联网对账SQLite够用吗常见做法是“单机SQLite 定时上传”业务高峰期间数据写本地闲时通过WebService或HTTP接口同步到中心服务器这比让收费终端直连远程数据库更稳。收费窗口同时只有一个人敲键盘写入频率极低SQLite的并发弱点在这个场景里几乎不构成瓶颈真正要小心的反而是连接管理和写锁后面避坑章节会专门说。2.2 建表SQL车辆信息、入场记录、收费流水三张表是骨架所有业务都跑在它们身上。-- 车辆信息表月租车、内部车、黑名单统一维护 CREATE TABLE IF NOT EXISTS car_info ( card_no TEXT PRIMARY KEY, -- 车牌号无牌车用临时编号 car_type INTEGER DEFAULT 0, -- 0临时车 1月租车 2内部车 owner_name TEXT, phone TEXT, start_date TEXT, -- 月租生效日期 yyyy-MM-dd expire_date TEXT, -- 月租到期日期 blacklist INTEGER DEFAULT 0 -- 1表示黑名单 ); -- 入场记录表一辆车在库内最多一条未出场记录 CREATE TABLE IF NOT EXISTS park_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, card_no TEXT NOT NULL, entry_time TEXT NOT NULL, -- 入场时间 yyyy-MM-dd HH:mm:ss exit_time TEXT, -- 出场时间NULL表示仍在场内 image_path TEXT, -- 入场抓拍图片路径可为空 remark TEXT ); -- 收费流水表每次出场结算落一条对账全靠它 CREATE TABLE IF NOT EXISTS charge_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, park_id INTEGER NOT NULL, -- 关联park_record.id card_no TEXT NOT NULL, entry_time TEXT NOT NULL, exit_time TEXT NOT NULL, total_minutes INTEGER NOT NULL, charge_amount REAL NOT NULL, charge_type INTEGER DEFAULT 0, -- 0临时收费 1月租免费 2内部免费 operator TEXT ); CREATE INDEX IF NOT EXISTS idx_park_exit ON park_record(exit_time); CREATE INDEX IF NOT EXISTS idx_charge_card ON charge_record(card_no);几个容易被忽略的设计点。park_record.exit_time允许NULL是故意的WHERE exit_time IS NULL就是“在场车辆”的查询条件。收费流水单独成表不往入场记录里塞金额字段这样每笔来去都有历史审计做日报月报直接对charge_record做GROUP BY。时间字段我统一用TEXT存yyyy-MM-dd HH:mm:ss格式不用Unix时间戳。原因很朴素DBA和收费员肉眼能直接读调试时一眼看出问题导到Excel也不会乱码。索引只建在查询热点上不要给每个字段都加索引SQLite写入时维护索引有额外成本。2.3 C#侧的数据访问封装我习惯写一个极简的SQLiteHelper避免在界面事件里到处创建连接、拼SQL。关键是暴露几个静态方法ExecuteScalar查单个值、ExecuteDataTable查列表、ExecuteRow查单行、ExecuteNonQuery执行写入。using System.Data; using System.Data.SQLite; public static class SQLiteHelper { // 连接字符串Data Source指向程序目录下的parking.db public static readonly string ConnStr $Data Source{AppDomain.CurrentDomain.BaseDirectory}parking.db;Version3;; public static DataTable ExecuteDataTable(string sql, params SQLiteParameter[] args) { using var conn new SQLiteConnection(ConnStr); conn.Open(); using var cmd new SQLiteCommand(sql, conn); if (args ! null) cmd.Parameters.AddRange(args); var dt new DataTable(); using var da new SQLiteDataAdapter(cmd); da.Fill(dt); return dt; } public static object ExecuteScalar(string sql, params SQLiteParameter[] args) { using var conn new SQLiteConnection(ConnStr); conn.Open(); using var cmd new SQLiteCommand(sql, conn); if (args ! null) cmd.Parameters.AddRange(args); var result cmd.ExecuteScalar(); return result DBNull.Value ? null : result; } public static DataRow ExecuteRow(string sql, params SQLiteParameter[] args) { var dt ExecuteDataTable(sql, args); return dt.Rows.Count 0 ? dt.Rows[0] : null; } public static void ExecuteNonQuery(string sql, params SQLiteParameter[] args) { using var conn new SQLiteConnection(ConnStr); conn.Open(); using var tx conn.BeginTransaction(); using var cmd new SQLiteCommand(sql, conn, tx); if (args ! null) cmd.Parameters.AddRange(args); cmd.ExecuteNonQuery(); tx.Commit(); } }为什么每次都新建连接而不是持有一个长连接SQLite官方建议短连接、用完即关Winform程序里长连接一旦抛异常状态就很难恢复。事务放在ExecuteNonQuery里保证单条SQL失败不会留下半截数据。但这个封装有个边界遇到“更新入场记录插入流水”这种跨表写单方法里的事务就不够用了必须把连接和事务暴露出来。出场结算那一节会给出完整的事务版本。2.4 收费规则先建模别写死在按钮事件里很多新手项目把收费标准放在按钮点击事件的if里客户说“首小时从5块改6块”开发就得赶过去改代码重新编译。把收费规则抽成可配置的类是做这类项目前必须完成的设计。public class ChargeRule { public int FreeMinutes { get; set; } // 免费分钟数默认15 public decimal FirstHourAmount { get; set; } // 首小时收费默认5元 public decimal PerHourAmount { get; set; } // 超首小时后每小时默认2元 public decimal DailyCap { get; set; } // 单日封顶0表示不封顶 public bool ResetAfterMidnight { get; set; } // 跨天是否按天重新计费 // 入口计算总费用内部处理跨天分段 public decimal Calculate(DateTime entry, DateTime exit) { if (exit entry) return 0m; // 不跨天重置一整段直接算 if (!ResetAfterMidnight) { return CalcSegment(entry, exit); } // 跨天重置按自然日零点切段每天独立计算 decimal total 0m; DateTime cursor entry.Date; while (cursor exit) { DateTime segStart entry cursor ? entry : cursor; DateTime segEnd exit cursor.AddDays(1) ? exit : cursor.AddDays(1); if (segEnd segStart) { total CalcSegment(segStart, segEnd); } cursor cursor.AddDays(1); } return total; } // 单段计费总时长减免费时长首小时固定超出部分向上取整 private decimal CalcSegment(DateTime entry, DateTime exit) { int minutes (int)(exit - entry).TotalMinutes; if (minutes FreeMinutes) return 0m; int billable minutes - FreeMinutes; decimal amount; if (billable 60) { amount FirstHourAmount; } else { // 向上取整停2小时01分按3小时收这是停车场常见规则 int extraHours (int)Math.Ceiling((billable - 60) / 60.0); amount FirstHourAmount extraHours * PerHourAmount; } // 单日封顶只作用于本段两段各自封顶后相加 if (DailyCap 0 amount DailyCap) return DailyCap; return amount; } }说明几个数学细节。Math.Ceiling((billable - 60) / 60.0)向上取整是关键停2小时01分按3小时计费。免费时长只扣一次扣在总时长上而不是每小时都扣。如果客户要求“只有进场当天有免费时长第二天起不免费”就把FreeMinutes只传给第一段其余段传0改一行就能支持。边界条件也值得验证入场时间23:59:59出场第二天00:00:01跨天切出的两段都不到15分钟费用为0符合“短时间停车免费”的直觉。如果客户要求跨天最少收一天钱那是另一种规则在CalcSegment里补判断就行单日逻辑不受影响。3. 车辆进出场主流程状态流转是核心3.1 入场登记车牌校验和重复入场拦截入场按钮做的事取车牌、格式校验、重复入场拦截、黑名单拦截、插入记录。重点是用参数化SQL绝对不要字符串拼接。private void BtnEntry_Click(object sender, EventArgs e) { string cardNo txtCardNo.Text.Trim().ToUpper(); if (cardNo.Length 0) return; // 车牌长度约束普通车牌7位新能源8位正式项目可接入车牌正则库 if (cardNo.Length 7 || cardNo.Length 8) { MessageBox.Show(车牌长度异常请检查输入); return; } // 重复入场拦截同一辆车不能在库内有两笔未出场记录 object exist SQLiteHelper.ExecuteScalar( SELECT COUNT(1) FROM park_record WHERE card_nono AND exit_time IS NULL, new SQLiteParameter(no, cardNo)); if (Convert.ToInt32(exist) 0) { MessageBox.Show($车牌 {cardNo} 已在场内不能重复入场); return; } // 黑名单拦截 object black SQLiteHelper.ExecuteScalar( SELECT blacklist FROM car_info WHERE card_nono, new SQLiteParameter(no, cardNo)); if (black ! null Convert.ToInt32(black) 1) { MessageBox.Show(该车牌在黑名单中请联系管理员); return; } string entryTime DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss); SQLiteHelper.ExecuteNonQuery( INSERT INTO park_record(card_no, entry_time, image_path, remark) VALUES(no, time, img, remark), new SQLiteParameter(no, cardNo), new SQLiteParameter(time, entryTime), new SQLiteParameter(img, (object)lastCapturePath ?? DBNull.Value), new SQLiteParameter(remark, 窗口入场)); lblStatus.Text ${cardNo} 于 {entryTime} 入场; txtCardNo.Clear(); RefreshParkingList(); }参数说明ToUpper放在最前面把扫描枪可能输入的小写字母统一变成大写。用COUNT(1)判断在场比先查出数据再在C#里判断更稳查询和插入之间不会插入别的不确定操作。黑名单查出来是null说明这个车牌从没登记过走正常临时车流程。image_path允许为空没有车牌识别摄像头时传DBNull而不是空字符串避免SQLite里存储无意义的文本。这版代码在单窗口场景够用。如果两个窗口同时给同一辆车入场COUNT检查会有一瞬间的空档真正的并发兜底方案是给park_record加唯一索引避坑章节会展开。3.2 出场结算事务保证记录一致出场比入场复杂要先找到在场记录、算费、弹收款框、更新两张表。最容易翻车的地方是更新完park_record但插入charge_record失败账就对不上所以必须用事务。private void BtnExit_Click(object sender, EventArgs e) { string cardNo txtCardNo.Text.Trim().ToUpper(); if (cardNo.Length 0) return; DataRow record SQLiteHelper.ExecuteRow( SELECT * FROM park_record WHERE card_nono AND exit_time IS NULL, new SQLiteParameter(no, cardNo)); if (record null) { MessageBox.Show(未找到该车在场记录请先确认是否已出场); return; } DateTime entryTime DateTime.Parse(record[entry_time].ToString()); DateTime exitTime DateTime.Now; // 查车辆类型未登记默认临时车 int carType 0; object typeVal SQLiteHelper.ExecuteScalar( SELECT car_type FROM car_info WHERE card_nono, new SQLiteParameter(no, cardNo)); if (typeVal ! null) carType Convert.ToInt32(typeVal); decimal amount 0m; int chargeType 0; if (carType 1) { // 月租车判断到期日当天23:59:59前出场都算有效 object expire SQLiteHelper.ExecuteScalar( SELECT expire_date FROM car_info WHERE card_nono, new SQLiteParameter(no, cardNo)); bool valid expire ! null DateTime.Parse(expire.ToString()).AddDays(1) DateTime.Today; if (valid) { chargeType 1; } else { amount chargeRule.Calculate(entryTime, exitTime); } } else { amount chargeRule.Calculate(entryTime, exitTime); } // 弹收费窗收费员可以手动改金额 using var dlg new ChargeDialog(cardNo, entryTime, exitTime, amount); if (dlg.ShowDialog() ! DialogResult.OK) return; decimal finalAmount dlg.FinalAmount; // 关键同一个事务里更新出场时间插入流水 using var conn new SQLiteConnection(SQLiteHelper.ConnStr); conn.Open(); using var tx conn.BeginTransaction(); try { using var cmd conn.CreateCommand(); cmd.Transaction tx; cmd.CommandText UPDATE park_record SET exit_timeexit WHERE idid AND exit_time IS NULL; cmd.Parameters.Add(new SQLiteParameter(exit, exitTime.ToString(yyyy-MM-dd HH:mm:ss))); cmd.Parameters.Add(new SQLiteParameter(id, record[id])); if (cmd.ExecuteNonQuery() ! 1) { tx.Rollback(); MessageBox.Show(该车已被其他窗口结算请刷新列表); return; } cmd.Parameters.Clear(); cmd.CommandText INSERT INTO charge_record (park_id, card_no, entry_time, exit_time, total_minutes, charge_amount, charge_type, operator) VALUES(pid, no, entry, exit, min, amt, ctype, op); cmd.Parameters.Add(new SQLiteParameter(pid, record[id])); cmd.Parameters.Add(new SQLiteParameter(no, cardNo)); cmd.Parameters.Add(new SQLiteParameter(entry, entryTime.ToString(yyyy-MM-dd HH:mm:ss))); cmd.Parameters.Add(new SQLiteParameter(exit, exitTime.ToString(yyyy-MM-dd HH:mm:ss))); cmd.Parameters.Add(new SQLiteParameter(min, (int)(exitTime - entryTime).TotalMinutes)); cmd.Parameters.Add(new SQLiteParameter(amt, finalAmount)); cmd.Parameters.Add(new SQLiteParameter(ctype, chargeType)); cmd.Parameters.Add(new SQLiteParameter(op, currentOperator)); cmd.ExecuteNonQuery(); tx.Commit(); } catch (Exception ex) { tx.Rollback(); MessageBox.Show($结算失败{ex.Message}); return; } lblStatus.Text ${cardNo} 收费 {finalAmount:F2} 元已出场; RefreshParkingList(); }这段代码最值得抄的是UPDATE ... WHERE idid AND exit_time IS NULL这个条件。它把并发兜底做在了SQL层面如果两个窗口同时结算同一辆车第二个窗口的UPDATE影响行数是0直接回滚并提示绝不会出现同一辆车被结算两次。这是状态类系统的核心纪律——状态变更语句必须带上当前状态条件。月租判断用expire_date当天加一天再和今天比较含义是“到期日当天24点前有效”避免“到期日早上入场晚上出场被当成临时车”的纠纷。total_minutes在流水里冗余存一份方便日后对账时直接按分钟聚合不用再拿入场出场时间重新算。3.3 无牌车流程无牌车没法用车牌当唯一键。我见过最靠谱的轻量方案是入场时生成一个临时编号作为card_no打印小票给车主出场时收费员输入编号直接结算。private string GenerateTempNo() { // WPC 月日时分 三位随机数例如 WPC07141523 return $WPC{DateTime.Now:MMddHHmm}{new Random().Next(100, 999)}; }这个编号的唯一性依赖同一秒内两辆车同时入场的概率。更保险的做法是用park_record自增id做后缀先插入拿id再补更新但小型停车场没必要把简单流程搞复杂。注意Random不要每次new一个要么做成类级别字段要么在.NET 6用Random.Shared否则同一秒内可能生成相同序列。4. Winform界面与数据绑定别让数据刷新卡死收费窗口4.1 DataGridView绑定DataTable还是List在场车辆列表是主界面的核心。查询结果直接用DataTable绑DataSource最省事列标题用SQL别名映射不用手动建列。public void RefreshParkingList() { string sql SELECT p.card_no AS 车牌, p.entry_time AS 入场时间, strftime(%Y-%m-%d %H:%M:%S,now,localtime) AS 当前时间, CAST((julianday(now,localtime) - julianday(p.entry_time)) * 24 * 60 AS INTEGER) AS 停留分钟 FROM park_record p WHERE p.exit_time IS NULL ORDER BY p.entry_time DESC; dgvParking.DataSource SQLiteHelper.ExecuteDataTable(sql); dgvParking.Columns[车牌].Width 130; dgvParking.Columns[入场时间].Width 150; dgvParking.Columns[当前时间].Width 150; dgvParking.Columns[停留分钟].Width 80; lblParkingCount.Text $在场车辆{dgvParking.Rows.Count} 辆; }停留分钟用SQL直接算比把DateTime拉到C#里循环算省事。julianday相减得到天数乘以1440转分钟CAST成INTEGER抹掉小数这是SQLite里标准的时长计算写法。什么时候不适合用DataTable当某一列需要动态显示按钮、状态灯或者“0/1”要显示成复选框时DataTable的扩展性不够。常见做法是建一个视图类用BindingListT绑定配合CellFormatting做自定义渲染。比如月租车快过期时把行背景标黄在DataGridView的CellFormatting事件里判断更直接private void DgvParking_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dgvParking.Rows[e.RowIndex].Cells[停留分钟].Value is int mins mins 60 * 24) { e.CellStyle.BackColor Color.LightYellow; // 超过24小时提醒收费员注意核对 } }注意这里从Cells拿到的Value是SQL运算结果类型要先用is判断再强转防止DBNull引发异常。热词里常提到的“Winform DataGridView 把List 的一列0和1显示为checkbox”本质就是先建视图类放一个bool属性再在DataGridView里加CheckBox列把DataPropertyName指过去。4.2 定时刷新背后的线程问题在场列表需要定时刷新但每5秒重建一次DataSource会引起闪烁、选中行丢失。两个改进我同时用一是只刷新时间和数量不要频繁重置整个列表二是如果查询跑在后台线程回调里必须回到UI线程才能操作控件。private System.Windows.Forms.Timer _timer; private void InitTimer() { _timer new System.Windows.Forms.Timer { Interval 5000 }; _timer.Tick (s, e) RefreshParkingList(); // Timer本身跑在UI线程不用Invoke _timer.Start(); }System.Windows.Forms.Timer的Tick事件本身就是UI线程触发的直接调用刷新方法没问题。真正会踩坑的是用Task.Run或Thread去查库回调里直接写控件赋值然后看到“跨线程操作无效”的红色报错。正确写法是用BeginInvoke回到UI线程。Task.Run(() { DataTable dt SQLiteHelper.ExecuteDataTable(SELECT ... FROM park_record WHERE exit_time IS NULL); if (dgvParking.IsHandleCreated) { dgvParking.BeginInvoke(new Action(() { dgvParking.DataSource dt; })); } });BeginInvoke是异步排队Invoke是同步等待。刷新列表这种低频操作用哪个都行但如果你要做高频刷新比如实时显示道闸状态千万别在循环里用InvokeUI线程会被阻塞到卡死。这个区分是Winform线程模型的基本功也是上位机面试常问的点。4.3 收费弹窗用DialogResult传递结果主窗体和收费弹窗之间不要通过公共静态变量传金额那是黑匣子式写法调试起来一头雾水。我的习惯弹窗构造函数收参数确认按钮设置DialogResult主窗体通过返回值拿结果。public partial class ChargeDialog : Form { public decimal FinalAmount { get; private set; } private readonly string _cardNo; private readonly DateTime _entryTime; private readonly DateTime _exitTime; private readonly decimal _calcAmount; public ChargeDialog(string cardNo, DateTime entry, DateTime exit, decimal calcAmount) { InitializeComponent(); _cardNo cardNo; _entryTime entry; _exitTime exit; _calcAmount calcAmount; lblInfo.Text $车牌{cardNo}\n $入场{entry:yyyy-MM-dd HH:mm:ss}\n $出场{exit:yyyy-MM-dd HH:mm:ss}; txtAmount.Text calcAmount.ToString(F2); txtAmount.SelectAll(); // 默认全选收费员直接敲数字覆盖 } private void BtnConfirm_Click(object sender, EventArgs e) { if (!decimal.TryParse(txtAmount.Text, out decimal paid) || paid 0) { MessageBox.Show(请输入正确的金额); return; } FinalAmount paid; DialogResult DialogResult.OK; } private void BtnCancel_Click(object sender, EventArgs e) { DialogResult DialogResult.Cancel; } }调用方拿结果的一致性由ShowDialog的返回值保证OK才提交事务Cancel直接返回什么都不动。收费员如果觉得系统算得不对可以在弹窗里改金额但每笔手工修改都要在流水表留痕——所以charge_record应该加一个is_manual字段。这个字段在最初设计时就要规划进去等上线再补就涉及到老数据迁移了。4.4 Winform界面美化与低分辨率适配界面美化热词经常被搜到但停车场收费系统的美化要克制。收费员一天点几百次按钮界面核心是“信息密度大、误触率低”不是花哨。我常用的美化手段只有三个主窗体BackgroundImage用浅色渐变图按钮统一用FlatStyle.Flat加圆角效果字体统一设成“微软雅黑 12pt”DataGridView行高设为30px减少长时间盯屏的疲劳关键按钮用颜色区分入场绿色、出场红色、清空灰色。真正要警惕的是分辨率。很多客户电脑还是1366x768甚至1024x768界面用绝对定位的话换台电脑就会显示不全。我一般用TableLayoutPanel或AnchorMinimumSize把布局撑开并且把窗体的MinimumSize设成合理值防止收费员把窗口缩到按钮都看不见。窗体启动时判断Screen.PrimaryScreen.WorkingArea如果屏幕高度不足800px就自动切换到紧凑字体模式。5. 上真实场景前先看这五处坑避坑记录与排查思路5.1 重复入场拦截失效同一个车牌刷两次就进两辆现象快速连续刷同一辆车系统生成了两条在场记录出场时只结算到一条另一条卡在库里永远显示在场。原因入场处理只有业务层COUNT检查没有数据库约束。第一次INSERT还没提交第二次COUNT查询看不到新记录检查就漏了。这是典型的并发窗口期问题。解决业务层保留COUNT检查同时给park_record加唯一索引兜底。SQLite支持部分索引可以只对未出场记录约束CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_active ON park_record(card_no) WHERE exit_time IS NULL;加这个索引后重复入场会在INSERT时直接抛异常程序捕获后提示“该车已在场内”。注意历史脏数据要先清理否则索引建不上。线上系统改表结构前一定要备份parking.db文件这是后悔药。5.2 SQLite报“database is locked”现象多窗口同时操作时偶发database is locked异常收费员看到报错直接重启程序。原因SQLite的写锁粒度是整个数据库文件两个连接同时写就会冲突尤其是出场结算的“UPDATEINSERT”跨表事务持锁时间稍长撞车概率就被放大。解决三管齐下。第一连接串里加PRAGMA journal_modeWAL;让读写不互相阻塞第二所有写操作走同一把锁防止并发写第三把写事务的持锁时间压缩到最短像出场结算那种事务弹窗在事务外事务里只做两条SQL。private static readonly SemaphoreSlim writeLock new SemaphoreSlim(1, 1); public static void ExecuteInWriteLock(Action action) { writeLock.Wait(); try { action(); } finally { writeLock.Release(); } }把3.2节出场事务包进ExecuteInWriteLock里单机多窗口场景基本告别database is locked。读操作不需要加锁WAL模式下读不阻塞写。5.3 跨天计费算错凌晨结算和白天结算差出几倍现象晚上入场第二天出场收费金额忽高忽低用户投诉。原因最简单的实现是“总分钟数乘以单价”完全没处理按天分段。晚上23点入场、早上7点出场总共8小时有的客户规则是跨天重新按首小时计费直接乘单价会多收一倍。解决用2.4节的ChargeRule做按天分段每段用自然日零点切分。测试时把本机时间改到23:59附近分别验三种情况不跨天、跨天不足首小时、跨天刚好24小时。这里有个容易漏的边界切段时cursor exit用严格小于segEnd超过exit时取exit本身保证不会多切出0分钟的空段也不会把同一分钟算进两段。5.4 DataGridView刷新闪烁和选中行丢失现象每5秒刷新一次列表收费员用键盘上下键找车时选中行总是跳回顶部眼睛也难受。原因RefreshParkingList直接给DataSource赋新DataTable整个列表重建行状态和滚动位置全丢。DataGridView默认没开双缓冲刷新时闪烁明显。解决第一开启双缓冲。Winform的DataGridView默认DoubleBuffered是protected属性可以继承一个类后置true或者用反射开启我一般直接建一个子类public class BufferedGridView : DataGridView { public BufferedGridView() { DoubleBuffered true; // 减少刷新闪烁 } }第二刷新时记录当前选中行的主键DataSource赋值后重新定位选中行string selectedId dgvParking.CurrentRow?.Cells[id].Value?.ToString(); RefreshParkingList(); if (!string.IsNullOrEmpty(selectedId)) { foreach (DataGridViewRow row in dgvParking.Rows) { if (row.Cells[id].Value?.ToString() selectedId) { dgvParking.CurrentCell row.Cells[车牌]; break; } } }这比每次重置DataSource要顺滑得多。如果数据量几千行还要考虑只在分钟数变化时更新那一列而不是整表重绑——收费员看不出“当前时间”变化差异不重绑反而体验更好。5.5 扫描枪输入全角字母查询死活查不到现象收费员手输或扫描枪输入“京A·12345”到系统里匹配不到在场列表不显示状态栏也没反应。原因Windows中文输入法在中文状态下会把字母输成全角‘’和‘A’的Unicode码不同SQL查询精确匹配自然失败。车牌里的点号也常见中文全角版本。解决输入框统一做“全角转半角大写去空格”归一化。全角字母A的Unicode是0xFF21半角A是0x41差值固定0xFFE0。private void TxtCardNo_TextChanged(object sender, EventArgs e) { string raw txtCardNo.Text; var chars raw.Select(c { if (c c ) return (char)(c - A); // 全角大写转半角 if (c c ) return (char)(c - a); // 全角小写转半角 return c; }).ToArray(); string normalized new string(chars) .Trim() .Replace( , ) .Replace( , ) // 全角空格 .ToUpper(); if (txtCardNo.Text ! normalized) { txtCardNo.Text normalized; txtCardNo.SelectionStart txtCardNo.Text.Length; } }这段逻辑放在TextChanged里会触发重入所以用if判断防止死循环。注意转换要在ToUpper之前做全角字符ToUpper不会变成半角。这种输入规范化在车牌识别和人工双轨录入的场景里几乎是必须的否则库表里存了一堆隐形全角字符排查时肉眼根本看不出来。6. 进阶玩法串口道闸联动与下班对账6.1 用SerialPort接道闸控制器收费确认后自动抬杆是常见需求。道闸控制器一般走RS232或RS485串口波特率9600命令帧各家不同常见是固定字节头加命令码加校验。SerialPort _sp new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); private void OpenGate() { if (!_sp.IsOpen) _sp.Open(); byte[] openCmd { 0xAA, 0x00, 0x01, 0x00, 0xCC }; // 示例帧以实际设备手册为准 _sp.Write(openCmd, 0, openCmd.Length); }开闸指令必须在出场事务Commit之后再发。如果把抬杆放在收费确认前会出现“钱没到账杆子先抬了”的严重事故。串口调试时先用串口调试助手确认设备协议再写代码不要拿生产设备直接试。6.2 下班对账与数据导出对账是收费系统真正体现价值的地方。一个SQL加一个CSV导出值班收费员下班前点一下就能看到应收。private void ExportDailyReport(DateTime day) { string start day.ToString(yyyy-MM-dd 00:00:00); string end day.AddDays(1).ToString(yyyy-MM-dd 00:00:00); DataTable dt SQLiteHelper.ExecuteDataTable( SELECT card_no AS 车牌, entry_time AS 入场, exit_time AS 出场, charge_amount AS 金额, charge_type AS 类型, operator AS 操作员 FROM charge_record WHERE exit_time s AND exit_time e ORDER BY exit_time, new SQLiteParameter(s, start), new SQLiteParameter(e, end)); var sb new StringBuilder(); sb.AppendLine(车牌,入场,出场,金额,类型,操作员); foreach (DataRow r in dt.Rows) { sb.AppendLine(${r[车牌]},{r[入场]},{r[出场]},{r[金额]},{r[类型]},{r[操作员]}); } // 必须带BOM的UTF-8否则Excel打开中文会乱码 File.WriteAllText($report_{day:yyyyMMdd}.csv, sb.ToString(), new UTF8Encoding(true)); }对账SQL再加一行GROUP BY charge_type就能看出临时车和月租车各收了多少。如果金额和现金对不上逐条核对charge_record里的操作员和手工改价标记责任界定很清楚。6.3 最值得养成的验证习惯每次改完收费规则我第一件事是改系统时间跑一遍跨天边界用例再检查一遍在场数据完整性查有没有exit_time为NULL但没有对应charge_record的记录或者“已出场但流水金额为0且charge_type为0”的异常单。停车场系统的逻辑不难难在状态一致性。养成“改完先查状态再算金额”的习惯上线后半夜接电话的概率会低很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表