简介:一份基于Delphi开发的停车管理系统完整源码包,面向需要完成课程设计或掌握传统数据库桌面应用开发的读者,可作为从界面布局到业务逻辑的完整学习样例。压缩包内共一百九十四个文件,整体约一点二三兆字节,核心代码包含二十四个Pascal源文件与二十四个窗体定义文件,配套同数量编译单元、备份文件及工程选项,另有三份SQL建表脚本、若干BMP界面图片和帮助文档,覆盖建库、编码、界面资源等完整链路。已有二百一十三人学习下载。研究这套源码,可学习VCL组件拖放布局、事件处理函数编写,理解ADO与SQL数据库查询、车位状态更新和记录保存等典型实现;项目内的登录权限、车位查询、预约登记、计时计费、报表打印等模块均能独立运行,改造时可直接参考其连接字符串、SQL语句和界面调用关系。文件分类清晰,备份齐全,便于对照调试,是梳理Delphi工程结构和提升组件编程能力的实用参考。
1. 一个 Delphi 停车管理系统源码包,够不够应付真实停车场
精典源码 Delphi046 这份停车管理系统,从标题就能看出是两个放在一起的长青组合:Delphi 写的桌面客户端,外加停车场的进出场业务。别小看这种老方案——我接触过不少小型停车场、单位内部车场,收费亭电脑上跑的正是这类程序,界面土、没云同步,但稳定跑了好几年没人敢动。它适合两类人:想快速看懂经典源码包、用来练 Delphi + 数据库联调的人;以及接手了这类系统、需要改收费规则或修线上问题的从业者。这篇笔记把这类系统的常见做法拆开讲:模块怎么分、表怎么建、进出场与跨天收费怎么写、部署到收费亭会遇到哪些坑,照着这个思路去读源码、去改代码,会比逐行翻文件快很多。
2. 模块拆解与选型:为什么这类源码清一色 ADO + MDB
2.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 编写,字符串默认是 ANSI;Delphi 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 | 日期时间 | 入场时间,完整存 TDateTime |
| out_time | 日期时间 | 出场时间,未出场为 NULL |
| fee_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 文件,用默认浏览器打开,收费员直接打印。交班时最关心的不是花哨图表,是实收金额能不能对得上,一个能导出、能打印、能存档的明细比什么都管用。我最开始也迷信可视化报表,后来被现场一句「看着好看,交班还是得自己拿计算器按」点醒,才明白这类系统的核心是准确和简单。希望帮到你。
本文还有配套的精品资源,点击获取