在做停车场计费系统这件事上工具重要但更重要的是先把业务逻辑想透。我最近用 Matlab 从零搭了一套汽车出入库计时计费系统入口记车牌和时间出口自动算时长、按费率表算钱所有进出流水可以存下来、查得到还能导出 Excel 对账。整套东西用到了 datetime 时间体系、App Designer 界面和表格读写代码量不大但边界情况非常多很值得拿出来聊聊。如果你正在做课程设计、毕业设计或者想在上硬件之前先验证一下停车场计费算法这篇内容可以直接照搬思路。1. 系统需求拆解与总体设计思路1.1 出入库计费到底在计什么先把业务捋直。一个不需要人工干预的简易停车场核心流程只有四步车进场记录车牌和入场时间车出场找到对应入场记录用当前时间减入场时间得到停车时长按计费规则换算成金额生成流水。听起来简单但真正落地的时候很多细节会冒出来车牌是手输还是扫描离场时怎么匹配车辆同牌车反复进出怎么办“不足一小时按一小时”还是“按分钟累计”免费时长怎么扣有没有单日封顶这些如果不在设计阶段敲定等界面写完了再改改到崩溃。所以第一步是先列功能清单而不是写代码。我这个系统最终确定的目标功能有六项支持按车牌登记入场自动写入当前系统时间。支持从“未结算车辆列表”中选中车辆一键结算出场。支持配置免费分钟数、首小时费、续时费、日封顶等参数。所有进出记录和缴费记录保存到本地文件启动时自动恢复。支持把流水导出为 Excel 表格方便对账。提供一键生成测试数据的功能便于演示多种边界场景。没有做摄像头车牌识别那部分涉及图像采集和识别跟“计时计费”的主线是解耦的。后续如果要接识别模块只需要把识别结果作为车牌输入写进来后面的结算逻辑完全不用动。1.2 为什么选择 Matlab 而不是 C# / Python有人会问停车场管理系统不都是 C#、Java 写的吗用 Matlab 是不是太“学院派”我的看法是看你的目标是什么。如果你的目标是部署到真实停车场跟道闸、摄像头、支付终端对接Matlab 确实不合适编译部署和硬件接口都是弱项。但如果是快速验证算法、做一个能演示的交互原型、或者完成课程设计和毕业设计Matlab 有四个天然优势时间处理省心。datetime、duration 直接支持跨天、跨月、跨年计算不用自己写闰年和大小月判断。表格和结构体顺手。停车记录天然是表格结构table、struct 数组和矩阵操作能快速做增删改查。App Designer 拖拽生成界面。不用学 HTML/CSS/JavaScript就能做出带按钮、表格、下拉框的桌面程序。数据可视化自带。把营收曲线、停车时长分布画出来plot、bar、pie 几行代码就够了展示项目成果时非常加分。Matlab 的缺点也很明显打包后的 exe 体积大、启动慢、目标机器要装运行时。但这些对原型系统来说可以接受。老师或答辩评委更看重逻辑是否清晰而不是部署体积。1.3 三层结构界面、逻辑、存储分离代码写了几版之后我最大的体会是不要把所有东西堆到一个一千行的 m 文件里。小项目也要有最基本的“显示层—逻辑层—数据层”分离意识。显示层一个 App Designer 的 .mlapp 文件负责接收用户输入、调用函数、把结果展示到表格和标签上。逻辑层一组独立函数文件比如 calcParkingFee.m、loadRecords.m、saveRecords.m。这些函数不依赖界面可以直接用脚本调用也方便做单元测试。数据层用 .mat 文件保存系统状态包括未结算记录、已结算记录、费率配置。需要导出时用 writetable 输出 Excel。这样做的直接好处是调价时只需要改费率参数不需要动界面换存储方案时只替换数据层函数界面依然不动。模块之间通过函数接口通信可维护性会好很多。2. 核心算法停车时长与计费规则怎么定义2.1 用 datetime 而不是 datenum别给自己埋雷Matlab 里计算时间差有三种常见做法datenum、datetime、自己把字符串拆开做减法。第三种我不评价但那是初学阶段。datenum 把时间转成一个代表“天数”的双精度浮点数相减得到小数天数再乘 24 乘 3600 换算秒。这套机制在老代码里很常见但有两个问题一是浮点数精度有限出现秒级误差时不好查二是代码可读性差满屏乘 86400不知道在干嘛。我推荐用 R2014b 之后引入的 datetime 和 duration。基本用法是tIn datetime(2026-03-02 08:30:00, InputFormat, yyyy-MM-dd HH:mm:ss); tOut datetime(2026-03-02 14:15:00); parkingDuration tOut - tIn; % 得到 duration 类型 totalMinutes minutes(parkingDuration);这里有一个易错点datetime(2026-03-02 14:15:00) 是合法的因为 Matlab 能自动识别常用格式但如果输入字符串格式不统一比如 2026/3/2 14:15就可能解析失败。最稳妥的做法是给所有输入指定 InputFormat。特别是在读写外部数据时宁可多写两行也不要把解析交给自动检测。内部统一用“分钟”作为计量单位。计费粒度一般到分钟就够分钟换算成小时也更容易控制取整逻辑。如果用秒显示的时候还要二次转换没必要。2.2 一份能落地的分段计费规则我假设一个典型收费方案免费停车 30 分钟含 30 分钟整。超过免费时间后第 1 个计费小时收费 5 元。超过 1 小时后每增加 1 小时加收 3 元不足 1 小时按 1 小时算。单日连续停车 24 小时内封顶 30 元超过 24 小时按“每天封顶 剩余时间重新按规则计费”累加。这个方案覆盖了大多数停车场的基本逻辑。费率参数定义成结构体方便以后调价cfg.freeMinutes 30; % 免费分钟数 cfg.firstHourFee 5; % 超过免费期后的首个计费小时费用 cfg.extraHourFee 3; % 后续每小时费用 cfg.dailyCapFee 30; % 24小时内封顶费用计费函数的核心逻辑是先判断总停留时间是否小于等于免费时长如果大于把“可收费时长”取出来。注意免费时长是直接从总时长里扣除而不是先按整段算完再减免。例如一辆车停了 45 分钟免费 30可收费 15 分钟因为不足 1 小时按 1 小时算所以收一个首小时费 5 元。再比如停 35 分钟同样收 5 元。规则是否合理是运营的事系统只负责忠实执行。函数我写在下面代码很短但边界处理要仔细function fee calcParkingFee(totalMinutes, cfg) if totalMinutes cfg.freeMinutes fee 0; return; end billableMinutes totalMinutes - cfg.freeMinutes; billableHours ceil(billableMinutes / 60); fee cfg.firstHourFee max(0, billableHours - 1) * cfg.extraHourFee; % 小于一个“首小时”的计费就按首小时费收 end这里先只算 24 小时内的短时费用。为什么用 ceil 而不是 floor因为“不足一小时按一小时”本身就是向上取整。如果停车场按“每 15 分钟收 1 元”核心思想一样把 billableMinutes 除以 15 后向上取整再乘单价只是参数不同。2.3 超过 24 小时怎么办封顶和循环计费上面的函数没有处理超过 24 小时的情况。如果车辆停了两天直接套用短时费用公式费用会高得离谱不符合“日封顶”规则。我的做法是拆成“整天 余段”function fee calcParkingFeeWithCap(totalMinutes, cfg) if totalMinutes cfg.freeMinutes fee 0; return; end dayMinutes 24 * 60; wholeDays floor(totalMinutes / dayMinutes); % 完整的天数 remainMinutes totalMinutes - wholeDays * dayMinutes; % 剩余分钟数 fee wholeDays * cfg.dailyCapFee; if remainMinutes 0 fee fee calcParkingFee(remainMinutes, cfg); end end这个算法的含义是每天封顶 30 元超出整天的那部分重新走免费时长 首小时 后续每小时的规则。有人会问“免费 30 分钟是不是每一天都能免”按这个算法是的。比如停了 24 小时 20 分钟先按整天收 30 元剩下 20 分钟小于免费时长于是免费了。这确实会让人觉得“便宜”但这是运营规则的问题系统按照规则执行而不是自己想当然。如果停车场规定“一次连续停车只享受一次免费时长”余段计算时就要把之前没用掉的免费时长扣除。我建议在项目文档里写清楚这个规则选择答辩时被问到才不慌。2.4 计费边界条件测试表算法写出来不算完必须拿数据验证。我习惯先用命令行直接调用函数测边界再接到界面上。下面这张表是必测项场景入场时间出场时间总时长期望结果说明免费内08:0008:2929分钟0元未超过免费线免费恰好08:0008:3030分钟0元含30分钟整刚超免费08:0008:3131分钟5元首小时档跨小时边界08:0009:3090分钟8元首小时5续1小时3差一秒到2小时08:0009:59119分钟5元不足2小时按1小时满2小时整08:0010:00120分钟8元2个整小时超一天第一天 08:00第二天 08:001440分钟30元整天封顶一天多20分第一天 08:00第二天 08:201460分钟30元余20分小于免费一天多31分第一天 08:00第二天 08:311471分钟35元余31分收首小时费实际测试时第 5 行很容易被写错。如果先用总分钟数减免费分钟除以 60再用 fix 而不是 ceil119 - 30 89 分钟fix(89/60) 1结果首小时费 5 元碰巧对了。但如果是 91 分钟91 - 30 61fix(61/60) 1结果还是 5 元但实际上 61 分钟超过了 1 小时零 1 分按“不足一小时按一小时”应该算 2 小时费用 8 元。必须用 ceil(billableMinutes / 60) 而不是先除再取整。这个坑非常经典写代码时务必小心。3. 从零搭建 App Designer 界面与数据持久化3.1 界面布局一个窗口搞定全部操作打开 App Designer新建空应用拖拽组件。我推荐的布局是左侧上半部分车牌输入编辑框、车辆类型下拉框、入场时间显示只读、登记入场按钮。左侧下半部分计费参数区四个数值编辑框分别对应免费分钟数、首小时费、续时费、日封顶旁边一个“保存费率”按钮。右侧上半部分未结算车辆表格每行是一辆还没出场的车包含车牌、车型、入场时间。右侧下半部分已结算流水表格包含车牌、入场时间、出场时间、停放分钟、应收金额、实收时间。底部一行结算选中车辆按钮、模拟生成测试数据按钮、导出Excel按钮、清空历史按钮。这里的关键是入场和出场要操作两张不同的表格。车辆登记入场后出现在“未结算”表结算完成它要从“未结算”表里消失同时在“已结算”表里多一行。如果只用一个总记录表界面上很难直观看出哪些车还在场内。关于组件命名App Designer 自动生成的名称像 EditField、UITable建议改成 app.plateEdit、app.vehicleTypeDrop、app.inTable、app.outTable、app.freeMinEdit 这种有意义的名称回调代码里一眼就能看懂。3.2 登记入场输入校验和记录结构登记入场按钮的回调要做三件事校验车牌非空、记录当前时间、追加到未结算数据并刷新表格。代码大致是这样% 登记入场按钮回调 function RegisterButtonPushed(app, ~) plate strtrim(upper(app.plateEdit.Value)); if isempty(plate) uialert(app, 车牌号不能为空, 输入错误, CloseFcn, (s,e)app.plateEdit.Focus); return; end newRecord struct( ... Plate, plate, ... VehicleType, app.vehicleTypeDrop.Value, ... TimeIn, datetime(now), ... TimeOut, NaT, ... DurationMin, NaN, ... Fee, NaN, ... Settled, false); app.inData(end1) newRecord; % 结构体数组追加 refreshInTable(app); app.plateEdit.Value ; % 清空输入框方便连扫车牌 end结构体数组追加有一个坑newRecord 的字段顺序必须和 inData 完全一致否则 Matlab 报错。更稳妥的写法是把记录统一转成 table 或 cell 数组再操作但结构体数组对原型来说够用。另一个经验是每次操作完数据都要有独立的“刷新表格”函数不要直接写 app.UITable.Data app.inData因为需要转换类型和过滤。刷新函数内部要做过滤function refreshInTable(app) if isempty(app.inData) app.inTable.Data table(); else t struct2table(app.inData); t t(~t.Settled, :); % 只显示未结算 app.inTable.Data t; end end如果 inData 是空 struct 数组struct2table 会报错所以先判空。3.3 出场结算从选中行取时间算钱落库出场结算按钮的回调比入场复杂。首先要判断“未结算表格”里有没有选中行。App Designer 的 UITable 可以通过 app.inTable.Selection 获取选中行序号。我实际用的逻辑是function SettleButtonPushed(app, ~) if isempty(app.inData) uialert(app, 当前没有未结算车辆, 提示); return; end selIdx app.inTable.Selection; if isempty(selIdx) || selIdx 0 uialert(app, 请先在左侧表格选中一辆车, 提示); return; end % 选中行索引不一定等于数据数组索引需要映射 allRows struct2table(app.inData); unSettled find(~allRows.Settled); rowIndex unSettled(selIdx(1)); record app.inData(rowIndex); if record.Settled uialert(app, 这辆车已经结算过了, 提示); return; end record.TimeOut datetime(now); durationMin minutes(record.TimeOut - record.TimeIn); record.DurationMin durationMin; record.Fee calcParkingFeeWithCap(durationMin, app.cfg); record.Settled true; app.inData(rowIndex) record; % 更新原记录 refreshInTable(app); refreshOutTable(app); app.feeResultLabel.Text sprintf(停车 %d 分钟应收 %.2f 元, ... durationMin, record.Fee); end这里有几个注意点UITable 选中行索引不是数据数组的行号。如果表格里只显示未结算的过滤子集选中第 2 行Selection 返回 2但这条记录在 inData 里的实际下标可能是 7。我用逻辑索引 unSettled 做映射保证不取错车。判断是否已结算不要用“该行是否还在表格里”因为表格是过滤后的不可靠。应该用 Settled 字段。计费函数必须传完整 cfg不能在函数内部硬编码数字。这样改价时只需改界面参数核心函数不动。3.4 数据持久化用 .mat 保存状态用 Excel 做账本保存和读取是系统稳定的基础。最简单有效的方案是把数据状态保存为 .mat 文件在应用启动时读取每次数据变更后保存。保存函数function saveAllData(app) savePath fullfile(app.StartupFolder, parking_data.mat); inData app.inData; outData app.outData; cfg app.cfg; save(savePath, inData, outData, cfg, -mat); end启动时加载function startupFcn(app) app.StartupFolder fileparts(mfilename(fullpath)); loadPath fullfile(app.StartupFolder, parking_data.mat); if exist(loadPath, file) S load(loadPath); app.inData S.inData; app.cfg S.cfg; refreshInTable(app); refreshOutTable(app); else app.inData struct(Plate,{},VehicleType,{},TimeIn,{}, ... TimeOut,{},DurationMin,{},Fee,{},Settled,{}); app.cfg defaultConfig(); end end有一个小坑不要用相对路径 save(parking_data.mat)。当 Matlab 当前文件夹被切到别处时文件会出现在意外位置下次启动又找不到。用 mfilename 获取 app 所在路径再拼绝对路径才是最稳的。Excel 导出就简单了function ExportButtonPushed(app, ~) if isempty(app.inData) uialert(app, 暂无数据可导出, 提示); return; end t struct2table(app.inData); t t(t.Settled, :); t.TimeIn.Format yyyy-MM-dd HH:mm:ss; t.TimeOut.Format yyyy-MM-dd HH:mm:ss; exportPath fullfile(app.StartupFolder, ... sprintf(parking_records_%s.xlsx, datestr(now,yyyymmdd_HHMM))); writetable(t, exportPath); uialert(app, sprintf(已导出到%s, exportPath), 导出成功); end注意writetable 对 datetime 列默认可能输出成英文格式客户看起来不友好。要么先把列格式设好要么转为字符串列再导出。文件名我加了时间戳避免重复导出相互覆盖。3.5 测试数据生成答辩必备的一键造数演示的时候最怕表格空空荡荡观众不知道你在干嘛。我加了一个“模拟生成测试数据”按钮一键填充 20 条历史记录。逻辑很简单用随机数生成车牌序列TimeIn 取过去几天内的随机时间TimeOut TimeIn 随机分钟数调用计费函数算费。每次演示都能拿出一个像模像样的账本。这个功能不仅是给演示用的我自己联调界面时也常用它快速测试表格刷新速度、筛选逻辑、导出功能比手动一条一条录高效得多。注意生成的数据要放在已结算记录里且 Settled true不要污染未结算列表。function GenerateDemoData(app, ~) rng(shuffle); plates {京A12345;沪B67890;粤C55668;浙D33321;苏E88888;鲁F99887;川G10086;湘H66666}; N 20; for i 1:N rec.Plate plates{randi(length(plates))}; rec.VehicleType 小型车; rec.TimeIn datetime(now) - days(rand*5) - minutes(rand*300); rec.TimeOut rec.TimeIn minutes(randi([20, 2000])); rec.DurationMin minutes(rec.TimeOut - rec.TimeIn); rec.Fee calcParkingFeeWithCap(rec.DurationMin, app.cfg); rec.Settled true; app.inData(end1) rec; end refreshInTable(app); refreshOutTable(app); end这段代码在真实项目里可以继续扩展比如随机生成“未结算”和“已结算”两种状态模拟一个循环往复的停车场。4. 常见问题排查与避坑实录4.1 datetime 解析失败字符串格式不统一实际运行中最常见的错误是 “Unable to convert text to datetime using the format...” 或者输入被解析成很大的日期。原因几乎都是字符串没有按约定格式输入。比如代码里写了yyyy-MM-dd HH:mm:ss但用户从 Excel 读进来的字符串是2026/3/2 14:15分隔符和月份位数都不一样。解决方法是凡是外部输入一律显式指定 InputFormattIn datetime(str, InputFormat, yyyy-MM-dd HH:mm:ss);如果输入来源混杂两种格式可以先预处理判断分隔符if contains(str, /) t datetime(str, InputFormat, yyyy/MM/dd HH:mm); else t datetime(str, InputFormat, yyyy-MM-dd HH:mm); end不要依赖 datetime 的自动检测尤其批处理大量数据时一个异常格式就可能导致整批失败。4.2 计费函数里的取整陷阱我在第 2 节已经讲了 ceil 和 fix 的区别这里再补一个真实 bug。我第一次写的时候是先算总费用再减去免费金额。比如 90 分钟先按两小时算 8 元再减免费 0 元碰巧正确。但停 31 分钟时先按一小时算 5 元免费时长很难准确折价容易产生 4.99 这种数。正确顺序是先扣时长再按档计费。不要算完钱再减免费部分。另外涉及金额时不要直接用浮点数比较相等。虽然 fee 是由整数运算得到的但浮点精度可能造成 0.999999 的情况。建议统一用 round 处理费用到两位小数fee round(fee, 2);。4.3 UITable 选中行索引错位这个坑我头大了一晚上。App Designer 中 UITable 的 Selection 返回的是当前显示表格的行号不是底层数据数组的行号。如果表格只显示了 3 条未结算记录选中第 2 行Selection 返回 2但底层 inData 里这条记录的实际下标可能是 7。我的解决方式是在刷新表格时额外加一列“内部序号”存放底层行号并把这列宽度设为 0。选中的行读取该列的值就是底层索引。不过更简洁的代码方式是用逻辑索引unSettled(selIdx)映射这个在出场结算的代码里已经体现。4.4 重复点击结算按钮导致重复扣费因为是桌面应用用户双击“结算”按钮很正常。第二次点击时车辆可能还没从表格里移除于是又算了一次钱。解决思路有两条一是进入回调后先检查该行的 Settled 字段二是在回调开始时禁用按钮等整个结算流程结束再启用。我推荐两者都做双保险。检查字段的代码if app.inData(rowIndex).Settled uialert(app, 该车辆已完成结算, 提示); return; end按钮临时禁用app.settleButton.Enable false; % ... 业务逻辑 ... app.settleButton.Enable true;4.5 跨天、跨年与系统时间被手动修改跨天测试是必须做的。我一般会构造入场时间为昨天 23:50出场时间今天 00:20总时长 30 分钟费用应为 0。datetime 能正确计算这没问题。但如果有人手改了系统时间比如入场后把电脑时间调快 2 小时出场时计算的时长变成负数费用函数会因为 totalMinutes 为负而直接返回 0表面看没报错实际是错的。需要在结算逻辑里加校验if durationMin 0 uialert(app, 出场时间早于入场时间请检查系统时间, 时间异常); return; end时区问题对国内项目基本不用管如果部署到国外或者用 UTC 时间存储需要注意 datetime 的 TimeZone 属性。我建议停车场系统统一使用本地时间不做时区转换因为显示和计费都以本地自然时间为准。4.6 中文乱码与保存路径旧版 MatlabR2016a 之前中文显示容易乱码新版本基本没问题。稳妥起见所有中文字符串尽量在 App Designer 里直接输入不要复制粘贴其他编码的文本。导出 Excel 时writetable 生成的 .xlsx 一般能正确保存中文但如果生成 CSVExcel 默认用 ANSI 打开中文会乱码所以建议导出 xlsx 而不是 csv。路径问题前面提过再强调一遍不要在代码里用 pwd 获取当前路径因为 App Designer 运行时的当前文件夹可能是用户正在浏览的目录而不是 app 文件所在目录。应该用mfilename(fullpath)获取当前文件路径再用 fileparts 取目录。4.7 让这个项目更好扩展的几条忠告最后分享一个我个人的开发习惯或者是踩过几次坑之后的调整给这套系统加界面之前我先把计费函数用脚本跑了一百多遍把所有边界情况全部测完才接进 App Designer。后来调价只需要改 cfg 参数界面和函数都不用动改动成本非常低。这个“先写好纯逻辑后上界面”的工作流适用于所有以算法为核心的小项目。另一个小技巧是如果觉得手动输入车牌太慢可以在入场输入框后面加一个“扫码枪输入模式”。本质上就是要求扫码设备把扫描到的车牌当成字符串并在末尾自动加回车界面完全不用多做处理。系统的核心还是计时计费车牌只是索引。扩展方向其实很多接图像处理做车牌识别、用数据库替换 .mat、加入会员月卡计费逻辑、生成每日营收统计图。但地基要稳后面加功能才不会到处返工。停车场的逻辑本身不复杂复杂的是各种边界条件——免费临界点、跨天、重复点击、超时刷新。把这几样都处理干净你就能挺起胸脯说这套系统是我自己从零写出来的。