简介一份基于Delphi开发的停车管理系统完整源码包面向需要完成课程设计或掌握传统数据库桌面应用开发的读者可作为从界面布局到业务逻辑的完整学习样例。压缩包内共一百九十四个文件整体约一点二三兆字节核心代码包含二十四个Pascal源文件与二十四个窗体定义文件配套同数量编译单元、备份文件及工程选项另有三份SQL建表脚本、若干BMP界面图片和帮助文档覆盖建库、编码、界面资源等完整链路。已有二百一十三人学习下载。研究这套源码可学习VCL组件拖放布局、事件处理函数编写理解ADO与SQL数据库查询、车位状态更新和记录保存等典型实现项目内的登录权限、车位查询、预约登记、计时计费、报表打印等模块均能独立运行改造时可直接参考其连接字符串、SQL语句和界面调用关系。文件分类清晰备份齐全便于对照调试是梳理Delphi工程结构和提升组件编程能力的实用参考。1. 一个 Delphi 停车管理系统源码包够不够应付真实停车场精典源码 Delphi046 这份停车管理系统从标题就能看出是两个放在一起的长青组合Delphi 写的桌面客户端外加停车场的进出场业务。别小看这种老方案——我接触过不少小型停车场、单位内部车场收费亭电脑上跑的正是这类程序界面土、没云同步但稳定跑了好几年没人敢动。它适合两类人想快速看懂经典源码包、用来练 Delphi 数据库联调的人以及接手了这类系统、需要改收费规则或修线上问题的从业者。这篇笔记把这类系统的常见做法拆开讲模块怎么分、表怎么建、进出场与跨天收费怎么写、部署到收费亭会遇到哪些坑照着这个思路去读源码、去改代码会比逐行翻文件快很多。2. 模块拆解与选型为什么这类源码清一色 ADO MDB2.1 停车管理系统常见模块边界进场、出场、车位、统计四件套一本停车管理系统的源码无论界面怎么画功能最终都会收敛到四个模块进场登记、出场结算、车位监控、查询统计。进场登记负责录入车牌和入场时间核心诉求是快收费员敲完车牌回车就要完成入库出场结算负责算时长、算钱、标记支付状态这是整个系统的钱袋子车位监控是给管理者看的大屏或者树形图本质上是「在场未出场」的流水实时聚合查询统计则承担交班对账和日报按操作员、按日期汇总应收实收。我一般会按这四个模块去读源码。如果一份源码把进出场逻辑都堆在按钮事件里说明作者当时赶工期重构时优先从这里拆如果源码已经有独立的 DataModule 管数据库连接那底子就不错。常见做法是用一个主窗体放操作区一个查询窗体做历史流水一个设置窗体读收费标准。你接手后第一件事不是看界面而是找到数据库连接组件确认它指向哪个文件、路径写没写死——这决定你能不能在自己机器上跑起来。2.2 选型逻辑ADO 组件连 MDB 文件是单机收费亭的最优解这类源码大量使用 ADO 组件连接 MDB 数据库并不是作者偷懒而是这个场景下的合理选择。ADO 是 Windows 自带的数据库访问接口Delphi 用 TADOConnection、TADOQuery 封装得很成熟不需要额外安装数据库服务端部署时拷一个 MDB 文件加 exe 就完事。停车场的收费亭电脑配置普遍不高也没有专职运维越少依赖越少故障。MDB 文件的并发能力弱但停车场的真实并发并不高一个收费亭同一时刻只有一两个操作员在录车牌。一天几百条流水、两三年也就几十万行MDB 单文件完全扛得住。相比之下如果上 MySQL 这类服务型数据库收费亭要额外装客户端、配端口、处理服务崩溃反而把简单系统搞复杂了。所以当我看到源码里是 ADO MDB第一反应不是「落后」而是「作者知道边界在哪」。真正的边界在于多收费亭同时写这个放到第 5 章专门讲。2.3 环境准备Delphi 版本与字符集是第一个坎跑这种老源码包最容易翻车的是 Delphi 版本差异。老源码多半基于 Delphi 7 编写字符串默认是 ANSIDelphi 2009 之后默认字符串变成 Unicode很多老代码在编译时并不会直接报错但中文写入数据库后读出来是「???」或者乱码这就是字符集不匹配的典型症状。我的做法是源码包里如果用了大量 PChar 和 AnsiString先不要急着升级到新版 Delphi用 Delphi 7 把程序原样编译跑通确认业务流程完整再做迁移。如果必须用新版 Delphi 编译优先全局搜索 AnsiString、PChar 这类关键字把自定义的字符串类型检查一遍。数据库连接串里也要注意MDB 文件本身的编码一般是本地系统 ANSI 代码页在简体中文系统上就是 GBK连接串里不要乱加 Character Set 参数。先把环境跑通再谈改需求。3. 把进出场登记写扎实表结构、连接串与两段核心代码3.1 表结构设计一张流水表撑起整个停车业务读源码先看表结构写改造先改表结构。这类系统最核心的表通常是 park_ticket也就是进出场流水表所有页面都在围绕它转。字段设计得好后续统计和收费改造都省力字段设计得随意改一个需求要带崩一片代码。字段名类型说明ticket_id自增整数主键一张流水一条记录plate_no文本车牌号进场时写入in_time日期时间入场时间完整存 TDateTimeout_time日期时间出场时间未出场为 NULLfee_amount数字应收金额出场结算时写入pay_status文本状态IN 在场 / PAID 已付in_operator文本进场操作员out_operator文本出场操作员需要特别强调三件事。第一入场时间和出场时间必须用完整的日期时间类型不要拆成「日期」和「时间」两个字段否则跨天结算会写得很痛苦第 4 章会展开。第二出场不要删除记录而是把 out_time 从 NULL 更新成具体时间这样历史流水、在场车辆统计都是同一张表的不同过滤条件。第三车牌号字段要建索引查询未出场记录是最高频的 SQL没有索引的 MDB 在数据到几十万行时会明显变慢。额外可以加一张 fee_rule 参数表存免费时长和单价让收费规则不进代码这个在第 4 章细讲。3.2 进场登记代码参数化查询与未出场校验进场按钮的逻辑就是把车牌写入流水表但写之前必须先做一次重复校验这是很多简化版源码没有的一步。直接插入不校验同一辆车连续两次进场出场时计费会乱。常见做法是先查该车牌是否存在 out_time IS NULL 的在场记录有就拦截。procedure TfrmMain.btnInClick(Sender: TObject); var plateNo: string; n: Integer; begin plateNo : Trim(edtPlate.Text); if plateNo then begin MessageDlg(请先输入车牌号, mtWarning, [mbOK], 0); Exit; end; // 1. 检查该车牌是否已有未出场记录 qryCheck.Close; qryCheck.SQL.Text : SELECT COUNT(*) AS CNT FROM park_ticket WHERE plate_no :p AND out_time IS NULL; qryCheck.Parameters.ParamByName(p).Value : plateNo; qryCheck.Open; n : qryCheck.FieldByName(CNT).AsInteger; if n 0 then begin MessageDlg(该车牌已在场内请先在场内车辆列表中核对, mtWarning, [mbOK], 0); Exit; end; // 2. 写入进场记录入场时间用客户端当前时间 qryUp.SQL.Text : INSERT INTO park_ticket(plate_no, in_time, in_operator, pay_status) VALUES(:p, :t, :op, IN); qryUp.Parameters.ParamByName(p).Value : plateNo; qryUp.Parameters.ParamByName(t).Value : Now; qryUp.Parameters.ParamByName(op).Value : GetCurrentUser; qryUp.ExecSQL; end;这段代码里最关键的是两组参数化查询。qryCheck 用于计数qryUp 用于插入我习惯拆成两个 TADOQuery 组件避免执行插入时把查询结果集顶掉。参数用 Parameters.ParamByName 赋值而不是字符串拼接一方面防 SQL 注入另一方面避免中文车牌在 ANSI 字符串拼接时产生乱码。入场时间直接用 Now表面上看用的是收费亭本地时间所以在多台收费亭要联网核对账目的场景里必须保证各电脑系统时间一致否则出场时长会出现负数的玄学问题。3.3 出场结算代码时长取整与金额更新出场按钮比进场多两步查出在场记录、算费、更新流水。算费逻辑单独提成一个函数方便后续加套餐和封顶价。这里给出出场流程和更新代码计算函数放到第 4 章。procedure TfrmMain.btnOutClick(Sender: TObject); var plateNo: string; inTime, outTime: TDateTime; fee: Double; begin plateNo : Trim(edtOutPlate.Text); // 1. 取出最近一条未出场记录 qryGet.Close; qryGet.SQL.Text : SELECT TOP 1 ticket_id, in_time FROM park_ticket WHERE plate_no :p AND out_time IS NULL ORDER BY in_time DESC; qryGet.Parameters.ParamByName(p).Value : plateNo; qryGet.Open; if qryGet.IsEmpty then begin MessageDlg(没有找到该车牌的未出场记录, mtWarning, [mbOK], 0); Exit; end; inTime : qryGet.FieldByName(in_time).AsDateTime; outTime : Now; fee : CalcParkingFee(inTime, outTime, FeeRule.FreeMinutes); // 2. 更新出场时间与金额不删除记录 qryUp.SQL.Text : UPDATE park_ticket SET out_time :o, fee_amount :f, pay_status PAID, out_operator :op WHERE ticket_id :id; qryUp.Parameters.ParamByName(o).Value : outTime; qryUp.Parameters.ParamByName(f).Value : fee; qryUp.Parameters.ParamByName(op).Value : GetCurrentUser; qryUp.Parameters.ParamByName(id).Value : qryGet.FieldByName(ticket_id).AsInteger; qryUp.ExecSQL; lblResult.Caption : Format(应收 %.2f 元, [fee]); end;注意出场查询用了 TOP 1 和 ORDER BY in_time DESC。为什么要排序因为如果历史数据里存在异常状态记录比如某辆车之前漏结算就可能查到旧记录。取最近一条是保住「当前在场」语义的兜底做法。UPDATE 只更新 ticket_id 匹配的那一行避免误伤。out_time 同样用 Now和进场时间来源保持一致不要在 UPDATE 语句里写 NOW() 函数否则和进场的客户端时间可能不同步。提示如果一定要用数据库时间统一计费MDB 没有服务器时钟常见替代方案是让所有收费亭开机时从一台主机同步系统时间比在 SQL 里取时间更可靠。4. 收费计算与跨天逻辑时长取整、免费时长、参数表化4.1 时长计算公式分钟差与向上取整停车收费最容易扯皮的就是边界时长停 1 分钟算不算钱59 分钟算 1 小时还是按分钟算绝大多数停车场的规则是「超过免费时长后不足 1 小时按 1 小时计」或者「按半小时计费」。这两种规则都可以用一个统一的分钟差思路实现。先把时间差转成精确到分钟的整数再扣免费时长最后对剩余时长向上取整到计费单位。下面这个函数是「超过免费时长后按小时向上取整」的经典写法可以直接放进源码包里的公共单元。function CalcParkingFee(const inTime, outTime: TDateTime; const freeMinutes: Integer; const hourlyRate: Double): Double; var totalMin, payMin, hours: Integer; fee: Double; begin // 1. 时间差转分钟Trunc 前加 0.5 做四舍五入抵消浮点误差 totalMin : Trunc((outTime - inTime) * 24 * 60 0.5); // 2. 扣除免费时长 payMin : totalMin - freeMinutes; if payMin 0 then begin Result : 0; Exit; end; // 3. 不足 1 小时按 1 小时向上取整 hours : (payMin - 1) div 60 1; fee : hours * hourlyRate; Result : fee; end;这个函数里有几个值得抄笔记的细节。第三步的取整公式(payMin - 1) div 60 1是标准的向上取整会比用 Trunc(payMin / 60 0.99) 清晰payMin 是整数避免了浮点数参与取整时的边界误差。totalMin 的四舍五入要加上去(outTime - inTime) * 24 * 60在浮点运算里可能出现 59.9999 这类值不处理的话明明停了 60 分钟会被截成 59 分钟少收一小时的钱。免费时长 freeMinutes 建议从规则表读而不是写死在函数里这样「开业前 30 分钟免费」改成「15 分钟免费」时不用重新编译 exe。4.2 跨天结算为什么容易算错DateTime 拼接的陷阱跨天结算是我见过翻车率最高的逻辑。典型场景是晚上 23:50 进场第二天 00:20 出场真正的时长是 30 分钟但很多源码会算出负数或者 0。问题出在数据类型上有人为了界面好看把进场时间存成字符串只保存了「23:50」而丢了日期出场时把「00:20」当成同一天的时间解析出场时间反而比进场时间小。// 错误写法只存时间不存日期 // inTimeText : 23:50; // outTimeText : 00:20; // inTime : StrToDateTime(2024-01-01 inTimeText); // outTime : StrToDateTime(2024-01-01 outTimeText); // 结果outTime inTime时长变负数被强行归零车辆免费抬杆 // 正确写法存完整 TDateTime // inTime : Now; // 包含日期 // outTime : Now; // 包含日期 // totalMin : Trunc((outTime - inTime) * 24 * 60 0.5);这个问题的根源不是 Delphi 的日期函数不够用而是表结构设计时把日期和时间拆开了。只要表里 in_time、out_time 是完整的 TDateTime 字段出场时直接用 Itme 更新跨天就不会出问题。DateUtils 里的 MinutesBetween 也能算分钟差但它对负值会取绝对值用在这里反而可能掩盖逻辑错误我习惯直接用 TDateTime 的数值相减再乘 1440每一分钟都看得明白。读到老源码时先搜索 StrToDateTime、FormatDateTime 的调用点凡是手工拼日期字符串的地方大概率有跨天隐患。4.3 收费规则参数表化改价不再重新编译大部分停车场的收费规则是会变的免费时长从 15 分钟变成 30 分钟单小时单价从 5 块涨到 6 块过夜封顶从 20 块调到 30 块。如果这些数字散落在源码里每次调价都要改代码、重新编译、再拷贝 exe 到收费亭非常被动。成熟的做法是建一张规则表程序启动时读进内存。我一般会把这四个基础参数放进规则表免费分钟数、基础小时单价、每日封顶金额、夜间封顶金额。启动时用一条查询加载封装成一个 FeeRule 记录类型后续所有计算函数都从它读值。下面是一个读取规则的简化示例。type TFeeRule record FreeMinutes: Integer; HourlyRate: Double; DailyCap: Double; NightFlatFee: Double; end; procedure LoadFeeRule(var rule: TFeeRule); begin qryRule.Close; qryRule.SQL.Text : SELECT rule_name, rule_value FROM fee_rule; qryRule.Open; rule.FreeMinutes : 0; rule.HourlyRate : 0; rule.DailyCap : 0; rule.NightFlatFee : 0; while not qryRule.Eof do begin if qryRule.FieldByName(rule_name).AsString free_minutes then rule.FreeMinutes : qryRule.FieldByName(rule_value).AsInteger else if qryRule.FieldByName(rule_name).AsString hourly_rate then rule.HourlyRate : qryRule.FieldByName(rule_value).AsFloat else if qryRule.FieldByName(rule_name).AsString daily_cap then rule.DailyCap : qryRule.FieldByName(rule_value).AsFloat else if qryRule.FieldByName(rule_name).AsString night_flat_fee then rule.NightFlatFee : qryRule.FieldByName(rule_value).AsFloat; qryRule.Next; end; end;这种「规则表 内存记录」的做法等于给现场人员留了一个改价通道即使不会写代码在有权限的电脑上改一下表重启程序就能生效。代价是要在界面上加一个「重新加载规则」按钮避免改完表还得重启整个程序。规则表里再加一个版本号或更新时间程序启动时对比一下能自动刷新就更省事了。5. 部署避坑字符集、只读库、重复入场与锁库的排查记录5.1 中文乱码同一个系统在不同版本 Delphi 下编译结果不同现象车牌里的中文比如「京A12345」进场登记后从数据库里读出来变成「???」或一串乱码收费员无法判断车辆身份。严重的时候连界面上打开的查询窗都是乱码。原因这种源码包在 Delphi 7 时代编写时字符串默认按 ANSI 处理如果作者用字符串拼接拼出 SQL再经过 ADO 传给 MDB字符集在写入时就丢了。后来在新版 Delphi 下重新编译字符串默认变成 Unicode但代码里某些地方还在用 AnsiString 或 PChar 做强制转换两套体系混用就会出现这种「编译不报错、运行时乱码」的怪问题。解决先确认现场编译环境。如果还用 Delphi 7那就保持源码里的字符串风格统一所有 SQL 一律改成参数化查询不给字符串拼接留机会参数值用 Parameters.ParamByName 赋值。如果已经迁到新版 Delphi全局搜索 AnsiString 和 PChar把自定义类型改成 string让整个工程统一走 Unicode 路径。改完以后用包含生僻汉字和数字字母混合的车牌做一轮回归比如「京N·12345」这种带特殊符号的最容易暴露出转换漏洞。5.2 数据库写不进MDB 放在 Program Files 下的权限问题现象程序能启动进场登记也提示成功但重启后数据全没了或者直接弹「不能更新数据库或对象为只读」。常见于把 exe 和 mdb 一起装到系统盘 Program Files 目录的部署方式。原因现代系统对 Program Files 目录有写保护普通权限下程序无法修改该目录下的文件。程序没报错是因为 ADO 连接的是副本或者被系统重定向到了虚拟存储区实际写入的文件并不是你以为的那个有的环境则直接以只读方式打开 MDB写入动作静默失败。解决MDB 文件不要放在 exe 同目录的系统盘位置放到程序目录下的 Data 子目录后启动时检测可写性。检测方式很简单程序启动时尝试打开数据库再尝试写一条空事务失败就弹窗提示管理员权限或检查目录权限。保守的做法是统一把数据库放到收费亭电脑的 D 盘数据目录配合开机自动映射网络盘单机场景就直接用相对路径定位到 exe 旁边的 Data 文件夹并在安装说明里写清楚不能装进系统盘。5.3 重复入场与幽灵车缺了未出场校验的连锁反应现象同一车牌连续进场两次都提示成功出场时只结算一次另一条记录永远留在场内月底统计在场车辆数虚高对账怎么都对不上。原因进场按钮没有查在场记录就直接 INSERT或者查了但用的是「该车牌全部记录数」而不是「未出场记录数」导致已出场的历史车辆也会被当成在场。车库里的「幽灵车」就是这么来的。解决进场前必须执行第 3 章那段「COUNT out_time IS NULL」的查询只要存在未出场记录就拦截。如果业务上允许同一车牌因入场时间不同而存在多条在场记录那就把拦截条件改成「最近 30 分钟内是否有未出场记录」这类车多半是收费员手滑重复点击。另外出场结算更新完成后界面要立刻刷新在场车辆列表避免视觉上以为记录还在。5.4 多收费亭同时写MDB 单文件的并发上限现象两三个收费亭同时出场某个亭子突然报「文件正被另一进程使用操作无法完成」过几秒又自己恢复了。高峰期频繁出现收费员只能靠重启程序应急。原因MDB 是单文件数据库Jet 引擎的锁定粒度是整个文件或表两个进程同时写一条记录时会互相阻塞。并发量一上来后到的写操作直接超时。这是 ADO MDB 方案的物理边界不是程序 bug。解决先看并发是否真的必要。很多停车场的两个收费亭是「一进一出」模式进和出不同的亭MySQL 之类的服务其实是降级。如果确实需要多亭并发写常见做法是拆库每个亭一个本地 MDB通过网络按时同步或者把收费规则、车牌白名单这类低频数据留在 MDB流水数据换成嵌入式单文件数据库引擎。如果只是偶尔报错最简单是给 ADOConnection 设置 ConnectTimeout并在写入失败处加自动重试但要知道这只是止血长期多亭并发还是得换数据层。6. 进阶改造把停车系统做成夜间套餐可配置的日结模式很多停车场的真实规则是「按时计费 夜间封顶」比如白天 5 元一小时22:00 到次日 8:00 时段最高只收 20 元。老源码往往只有单一按时计费遇到这种需求就得改代码。我习惯的做法是复用第 4 章的规则表加三个字段夜间封顶金额、夜间开始时间、夜间结束时间再在出场结算函数里加一次判断。function CalcFinalFee(const inTime, outTime: TDateTime): Double; var baseFee: Double; totalMin: Integer; begin baseFee : CalcParkingFee(inTime, outTime, FeeRule.FreeMinutes); totalMin : Trunc((outTime - inTime) * 24 * 60 0.5); // 出场时间落在夜间区间且停车超过最短时长且封顶价更便宜才启用 if FeeRule.NightFlatFee 0 and IsInNightRange(outTime) and (totalMin 120) and (FeeRule.NightFlatFee baseFee) then Result : FeeRule.NightFlatFee else Result : baseFee; end;这里「总停车时长超过 120 分钟才启用封顶」是一个业务约束防止有人只是夜里进停车场停了 10 分钟就被收整晚封顶价。实际业务怎么定你接手时可以问一下车场管理方。验证这套逻辑不要只测正常时段构造三组边界数据——进场 22:00 出场 22:10 的短停、进场 23:50 出场次日 00:20 的跨天短停、进场 21:00 出场次日 7:00 的整晚长停三组结果必须分别是按时价、按时价、封顶价才算通过。日结报表我用 HTML 输出不依赖第三方报表控件。程序按日期和操作员从流水表汇总 fee_amount生成一个带表格的 HTML 文件用默认浏览器打开收费员直接打印。交班时最关心的不是花哨图表是实收金额能不能对得上一个能导出、能打印、能存档的明细比什么都管用。我最开始也迷信可视化报表后来被现场一句「看着好看交班还是得自己拿计算器按」点醒才明白这类系统的核心是准确和简单。希望帮到你。本文还有配套的精品资源点击获取