
做停车场管理系统、门禁道闸、车主自助录入这类业务的人应该都懂同一个痛车牌号输入。手机系统键盘上没有省份简称面板字母和数字要来回切换新能源牌照还要专门找 D/F 标识位用户录一个车牌要点十几次屏幕还特别容易输错。这个问题在 UNI-app Vue3 项目里尤其明显因为要同时兼容小程序、App、H5 三端直接用原生 input 很难做出统一体验。所以我干脆把车牌键盘封装成了独立组件一套代码跑三端哪里需要去哪里。这篇文章就把组件的设计思路、核心代码、调试流程和踩坑记录完整拆一遍做停车、充电桩、ETC、汽车后市场这类业务的同学可以直接抄作业。1. 组件化方案为什么不用系统键盘1.1 车牌输入的业务痛点车牌号录入不是普通文本框那种“随便敲几个字符”的业务它有一套非常严格的格式要求第一位必须是省份简称第二位是发牌机关代号字母后面的部分是字母和数字的组合而且不同车型长度还不一样。普通蓝牌是 7 位新能源绿牌是 8 位警车最后一位可能是“警”字挂车最后可能是“挂”字。这种强规则输入拿通用键盘来做就是灾难。我做过一个停车场管理小程序最早版本就是让用户在系统键盘上自己敲。结果用户反馈特别集中找省份简称要翻好几页切完数字切字母来回折腾好不容易输完了提交时才发现新能源车要求输 8 位后面 D/F 又不知道去哪找。后台数据的脏数据也很多有把“湘”输成“相”的有把数字 0 和字母 O 混淆的还有少一位多一位的。后来我统计了一下车牌输入的错误率一度超过 15%这个比例放到道闸识别场景里就是系统反复匹配失败客户体验非常糟糕。所以车牌输入这类场景最好是做一个专属键盘就像日期选择器、身份证键盘、金额键盘一样用户看到什么输什么根本不给他犯错的机会。这也解释了为什么市面上的停车 App、充电 App、车主服务小程序几乎都会自己套一个车牌键盘——不是跟风是这个业务确实有硬需求。1.2 方案选型为什么是 uni-app Vue3 自研选 uni-app 没什么悬念一套代码编译到微信小程序、支付宝小程序、H5 和 App对做垂直业务的小团队来说性价比最高。尤其是车牌键盘这种组件它的输入逻辑和校验规则是同一套UI 是三端统一的天然适合跨端封装。如果用原生小程序写一套、App 再写一套、H5 又写一套光是维护字符集和校验逻辑就能烦死人。再来说为什么用 Vue3 写。Vue3 的组合式 API 对这类有状态组件非常友好牌号当前值、光标位置、面板类型、校验规则这些逻辑用一个 setup 函数就能清晰聚拢不需要像 Vue2 那样在 data、methods、computed、watch 之间来回跳。加上 defineProps、defineEmits 这些编译宏组件对外接口一目了然。uni-app 从 HBuilderX 3.x 开始对 Vue3 的支持已经比较稳定我实际用下来在小程序端和 App 端的兼容性都达到了可以直接上生产的程度。至于为什么不引第三方库道理很简单这种垂直键盘组件第三方的实现五花八门有的只做了省份和字母数字的简单拼接有的绑死了某个设计稿的主题色有的对 Vue3 和 uni-app 的适配只停留在“能编译”的程度。与其去 patch 别人的代码不如自己写一个干净、可控、按业务配置的组件。自己写的还有一个好处就是后续要扩展“长按删除”“点击某一位修改”“接入摄像头识别车牌自动填充”这类功能都在自己手里想加就加。1.3 组件整体结构与数据流整个组件我拆成了两块展示区和键盘区。展示区是车牌号码的预览格子一行多个小方框每格对应一位字符同时光标落在哪一位哪一格就高亮。键盘区是真正可点击的按键面板根据当前输入位置自动切换省份、字母、字母数字混合这些面板。对外接口上我用了 v-model 做双向绑定父页面只需要维护一个车牌字符串。组件内部自己维护真实字符值和光标位置每次用户点击按键内部先校验再更新字符最后通过emit(update:modelValue, nextVal)把新值抛出去。这样父组件的表单校验、提交逻辑都不用关心键盘内部怎么工作只管接收值就行。数据流大概是点击按键 → 判断当前光标位置 → 判断该位置允许哪些字符 → 写入字符串对应位置 → 光标后移 → 通知父组件更新值。反过来删掉一位时按相同路径回退。整个链路很直白这也是我为什么坚持在组件内部维护“当前输入位置”的原因——如果把光标状态也交给父组件管理调用方会被迫理解车牌的格式规则那这个组件的边界就糊了。2. 车牌规则与键盘布局设计2.1 国内车牌编码规则梳理做车牌键盘之前必须先把车牌号的编码规则彻底理清楚偏掉一位都是硬伤。国内民用车牌的通用结构是“省份简称 发牌机关代号 序号”三部分组成不同号牌类型的长度和字符集不太一样。我把常见的号牌类型整理成了下面的规则表项目按需取用号牌类型总位数第1位第2位第3位及以后典型示例普通蓝牌/黄牌7省份简称字母字母或数字京A12345新能源绿牌8省份简称字母字母或数字末位常为 D/F京AD12345警用汽车7省份简称字母字母或数字末位为“警”京A1234警教练汽车7省份简称字母字母或数字末位为“学”京A1234学挂车7省份简称字母字母或数字末位为“挂”京A1234挂港澳入出7省份简称字母字母或数字末位为“港/澳”粤Z1234港省份简称这部分就是国内省级行政区的简称我做成一个常量数组京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云陕甘青宁新还有一个“渝”。实际业务里可能出现某个场景只需要某几个省份比如本地化停车场项目就可以通过provinceListprop 传入子集让键盘更精简。字母这块有个容易被忽略的细节民用号牌序号里不使用字母 I 和 O因为这两个字符跟数字 1 和 0 太像容易识别错。所以主键盘里的字母集合应该是ABCDEFGHJKLMNPQRSTUVWXYZ把 I 和 O 去掉。这个细节在车牌识别和自动抬杆场景里特别重要识别出的结果本身就容易搞混再让用户在键盘上手动输一个 I/O 出来那后台系统十有八九会判定为非法号牌。2.2 键盘分区与按键状态设计车牌键盘的按键分区最笨的做法是把所有字符平铺在一个大面板里省份字符、字母、数字混在一起用户自己找。但这样还是没解决“找字符”的效率问题。我的设计思路是按“输入位置”自动切换面板用户根本不用手动切 tab。具体来说组件维护一个cursor光标代表当前要填第几位。光标在第 0 位时面板只显示省份简称第 1 位时只显示字母第 2 位及以后显示字母和数字混合面板如果是新能源车牌且到了第 8 位则面板只显示 D 和 F当然这里也做成可配置有的场景允许填任意字母数字。用户每输一位面板就自动跳到下一组允许的字符几乎不可能输错。还要处理一个细节用户填到第 4 位时突然发现第 2 位选错了字母他不可能把后面全部删掉重来。所以组件支持点击预览区的任意一个格子光标直接跳回那一位键盘面板也随之切换到对应字符集。这个交互很关键我做的第一个版本没支持测试组小姐姐天天提 bug说“改一个字母要把后三位全删了”。加了“点格子定位”之后整体体验提升了一大截。按键状态也分几种正常可用、可选特殊面板如新能源末位的 D/F、删除键、清空键、确认键。删除和清空我放在工具栏底栏确认键放在右下角小屏手机上确认键最好用高对比色让用户一眼能看到“完成”入口。长按删除键我实现了连续删除配合震动反馈小程序端通过wx.vibrateShortApp 端通过uni.vibrateShort手感会接近系统键盘。2.3 输入状态机与校验规则车牌输入的过程天然就是一个状态机当前输入位置决定合法字符集合法字符集决定面板展示。我把这个状态机简化成几条规则光标在第 0 位时只接受省份字符。光标在第 1 位时只接受大写字母默认不含 I/O。光标在中间位时接受字母和数字。新能源模式且光标在最后一位时默认只接受 D/F可配置放开。普通模式且开启特殊字符时光标在最后一位也可接受“警/学/挂/港/澳/使/领”这些字。校验函数写成独立方法isValidKey(key)点击任何按键都先过校验不合法直接给个轻提示不写入。这样不管外层传什么值进来键盘都不会生成非法车牌。我最初把校验写在 handleTap 里面后来发现业务方想把校验逻辑抽出去给后台表单复用就把它提成了一个纯函数传入位置、字符、配置返回布尔值。这个改动很值得后面后台管理系统做 Excel 批量导入校验时直接复用同一套逻辑前后端规则完全一致。还有一个容易被忽略的场景用户把光标定位到某一位后又切换到了另一个输入框再切回来时光标状态要能正确恢复。做法是watch(visible)当键盘从隐藏变为显示时把光标重置到字符串长度位置如果字符串已满则光标停在最后一位并让最左边的空格保持可点击定位。这个“重新打开键盘后光标该停在哪”的细节我踩过 bug后面在常见问题部分再展开。3. 核心代码实现与调试3.1 组件骨架与 vue3 接口设计组件我放在components/plate-keyboard/plate-keyboard.vue利用 uni-app 的 easycom 机制页面里直接写plate-keyboard v-modelplate /就能用不需要手动 import。这里要注意easycom 要求组件目录名和文件名一致比如components/plate-keyboard/plate-keyboard.vue否则自动扫描规则匹配不到。Vue3 的组件对外接口用defineProps和defineEmits声明代码很直观script setup import { ref, computed, watch } from vue const props defineProps({ // 车牌号v-model 绑定的值 modelValue: { type: String, default: }, // 是否显示键盘 visible: { type: Boolean, default: false }, // 是否新能源车牌决定最大长度和末位 D/F 面板 newEnergy: { type: Boolean, default: false }, // 普通车牌最大长度默认 7 maxLength: { type: Number, default: 7 }, // 是否开放特殊字符警/学/挂/港/澳/使/领 supportSpecial: { type: Boolean, default: false }, // 自定义省份列表默认全部 provinceList: { type: Array, default: () [] } }) const emit defineEmits([update:modelValue, confirm, cancel]) // 内部车牌值避免直接改 props const innerValue ref(props.modelValue || ) // 光标位置 const cursor ref(innerValue.value.length || 0) // 监听外部 v-model 变化 watch(() props.modelValue, (val) { if (val ! innerValue.value) { innerValue.value val || } }) // 监听键盘开关打开时重置光标 watch(() props.visible, (val) { if (val) { const len innerValue.value.length cursor.value len props.maxLength ? props.maxLength - 1 : len } }) /script这里有个原则要守住defineProps里拿到的 props 是只读的绝对不能直接props.modelValue xxx。所有值的变更统一走 emit组件内部需要的可变副本用ref或reactive维护再通过 watch 和外部同步。这个写法和 Vue2 时代的sync修饰符思路差不多但在 Vue3 里更规范。3.2 键盘数据生成与点击逻辑字符集和面板数据我用常量表加上几个 computed 派生。省份字符来自 props 或默认数组字母和数字是固定的const DEFAULT_LETTERS ABCDEFGHJKLMNPQRSTUVWXYZ.split() const NUMBERS 0123456789.split() const SPECIAL_CHARS [警, 学, 挂, 港, 澳, 使, 领] const provinceChars computed(() { return props.provinceList.length ? props.provinceList : defaultProvinceList }) const letterChars computed(() { return props.supportSpecial cursor.value props.maxLength - 1 ? [...DEFAULT_LETTERS, ...SPECIAL_CHARS] : DEFAULT_LETTERS }) const letterNumberChars computed(() { return [...DEFAULT_LETTERS, ...NUMBERS] }) const currentKeys computed(() { const idx cursor.value if (idx 0) return provinceChars.value if (idx 1) return letterChars.value if (props.newEnergy idx props.maxLength - 1) { // 新能源末位只给 D/F return [D, F] } // 特殊字符要允许出现在末位 if (props.supportSpecial idx props.maxLength - 1) { return [...letterNumberChars.value, ...SPECIAL_CHARS] } return letterNumberChars.value })点击按键的核心函数是handleKeyTap(key)它要做三件事校验、写入、联动光标function handleKeyTap(key) { const idx cursor.value if (!isValidKey(key)) { uni.showToast({ title: 当前位不支持该字符, icon: none }) return } const arr innerValue.value.split() arr[idx] key const nextVal arr.join().slice(0, props.maxLength) innerValue.value nextVal emit(update:modelValue, nextVal) // 光标后移不越过最后一位 if (idx props.maxLength - 1) { cursor.value 1 } }删除键的逻辑就要考虑“光标所在位有值先删当前位没有值则往前跳一位再删”这样才符合“点哪改哪”的交互习惯function handleDelete() { const arr innerValue.value.split() if (arr[cursor.value]) { arr[cursor.value] } else if (cursor.value 0) { cursor.value - 1 arr[cursor.value] } const nextVal arr.join() innerValue.value nextVal emit(update:modelValue, nextVal) }isValidKey就是前面 2.3 节状态机的代码化我把它写成可独立复用的纯函数放到了utils/plate.js里。这样后台管理端做车牌批量校验也能直接 import 过去用前后端规则保持统一。实际项目里我见过最坑的情况就是前端键盘放开某个字符后台校验却不认导致用户提交后打回重填这锅不能让用户背。3.3 在微信开发者工具与模拟器中调试写 uni-app 组件最烦的就是“本地样式正常一编译到小程序就变了”。我的习惯是先用 H5 端快速调试交互再跑一套到微信开发者工具验证小程序兼容性最后再用安卓模拟器看 App 端表现。HBuilderX 里配置微信开发者工具路径很简单顶部菜单工具 - 设置 - 运行配置找到“微信开发者工具路径”填上安装目录下的可执行文件路径。然后在项目顶部菜单选择运行 - 运行到小程序模拟器 - 微信开发者工具编译完成后 HBuilderX 会自动拉起微信开发者工具并打开编译产物目录。如果一直提示“未配置路径”多半是运行配置里填的路径层级不对要选到cli.bat或微信开发者工具的主程序那层。如果用 CLI 创建的 uni-app 项目也可以直接用命令行编译npm run dev:mp-weixin编译输出的目录默认是dist/dev/mp-weixin然后用微信开发者工具手动“导入项目”选中这个目录即可。命令行方式的优点是可以接 CI比如提交代码后自动触发编译微信开发者工具里用预览二维码拉真机测试。模拟器方面HBuilderX 的运行 - 运行到手机或模拟器能直接连接 MuMu、夜神这类常见的安卓模拟器前提是模拟器开启了 ADB 调试端口。如果列表刷不出模拟器先在终端执行adb devices看到设备编号说明已连接HBuilderX 里刷新一般就能出现。调试车牌键盘时有个小技巧在微信开发者工具里打开“模拟操作”面板可以单独触发touchstart事件适合排查按键事件的触发热区。因为车牌键盘的按键我用的是touchstart而不是click这样响应更快但也更容易出现误触调试时要看一下热区是否命中到了旁边的按键。3.4 样式适配与交互细节优化车牌键盘是典型的底部弹出面板样式上要注意三点定位层级、安全区、尺寸适配。定位我用position: fixed固定在底部层级建议用足够大的z-index因为页面里可能有弹窗、蒙层、底部导航太低会被盖住。uni-app 小程序端固定定位没问题H5 端如果外层套了 transform 容器fixed 会变成相对定位这一点容易踩坑。解决方法是把键盘组件放在页面根节点下或者干脆用uni.createSelectorQuery计算容器位置。更稳妥的做法是在组件内部渲染一个独立的view节点不同端表现差异小。尺寸适配用 rpx 单位最省心rpx 在 H5、小程序、App 端都会按屏幕宽度做等比换算。键盘高度我一般控制在页面高度的 40% 到 45%height: 44vh左右比较舒适。字符按键的间距用百分比或 rpx确保大屏小屏都能点中。底部还要预留 iPhone 横条安全区用env(safe-area-inset-bottom)给工具栏加 padding不然确认键会杵到 Home 条附近不好点。交互细节上除了长按删除、点击某一位定位修改之外还有两个值得做的点。一个是按键按下去的视觉反馈压暗背景或缩小一点让用户觉得“这个键真的按到了”另一个是确认按钮的状态联动如果当前车牌长度没达到最低位确认键置灰达到后变成亮色。置灰判断用computed派生每次 innerValue 变化自动更新不需要手动调方法。4. 常见问题排查与避坑实录4.1 自定义键盘和系统输入法打架不少第一次做自定义键盘的同学会把车牌键盘和系统键盘混着用页面上放一个真实的input展示车牌号点击时弹自定义键盘结果发现系统键盘也跟着弹出来两个键盘叠在一起非常难看。原因很简单input 获得焦点就会触发系统键盘。我的处理方式是彻底不用 input 承载车牌号预览区用多个静态view格子展示字符格子外层绑tap事件来定位光标。如果业务规定必须用 input 提交表单值也把 input 设为readonly或者动态控制focus为 false避免系统键盘弹出。还有一个 trick如果 input 被某些组件库强制接管焦点可以在focus回调里执行一次event.target.blur()把焦点立刻踢掉系统键盘就不会出来了。4.2 直接修改 props 导致响应式丢失Vue3 里经常有新人直接操作 props然后发现页面怎么都不更新。比如在组件里写props.modelValue nextVal控制台直接报错或者静默失败。这是 Vue 响应式机制决定的props 是单向数据流必须通过事件通知父组件更新。我踩过的一个真实 bug 是车牌键盘内部用props.modelValue作为初始值但组件没有维护内部副本每次键盘打开直接读props.modelValue结果父组件异步加载 A 车牌数据时键盘还是显示上一次的 B 车牌字符串。最后加了一个 watch 监听 modelValue 变化并同步内部副本问题才解决。所以组件里始终要有一个“内部可变副本 外部 v-model 同步”的桥接层不要偷懒直接读写 props。4.3 多实例状态互相污染一个页面可能同时存在多个入口唤起车牌键盘比如“进场记录”弹窗里一个“手动补录”弹窗里一个。如果组件内部不小心用了模块级变量保存当前值两个实例就会互相覆盖弹出 A 的记录却显示 B 的号牌。排查方法很简单打开页面先给车牌 A 输入到一半关闭键盘打开车牌 B 的键盘看看 B 里是不是残留了 A 的字符。如果残留了说明状态定义在了组件外部。正确的做法是用setup里的ref或reactive定义组件实例私有状态而不是模块全局变量。多个键盘实例各自持有自己的 innerValue 和 cursor互不干扰。项目里有跨页面共享车牌值需求时可以引入 pinia 管理一个全局的tempPlate但那个状态只用于跨路由暂存键盘组件内部依然保持实例私有。4.4 编译报错与 wgt 热更新不生效uni-app 的 Vue3 项目偶尔会遇到编译报错最常见的是Cannot read properties of undefined (reading craft)这类信息出现在模板里某个变量还没有初始化。比如v-modelform.plate但form对象在 setup 中没有先声明或者接口返回数据还没到位模板就渲染了。解决方式是在模板渲染前给变量加兜底或者用v-if控制组件挂载时机plate-keyboard v-ifform v-modelform.plate /另外如果车牌键盘组件跟 wgt 热更新一起用有个容易踩的坑组件文件打包进了 App 的原生安装资源里单纯打 wgt 增量包覆盖业务代码原生资源层没有重新生成导致热更新后键盘还是旧版本。排查思路是确认 manifest.json 中 App 版本号和版本名称是否提升wgt 包重新编译时有没有把components/plate-keyboard目录打进去安装 wgt 包后有没有完全杀掉 App 进程再重启。实际项目里我在热更新后加了一句plus.runtime.restart()强制 App 重启加载新资源基本就能解决问题。4.5 给组件继续加料优化与扩展方向车牌键盘这种组件做完基础版之后还能继续打磨。我目前在用的版本比上面贴的基础逻辑多了几个功能支持通过 prop 传入“已识别的车牌号”并在键盘预览区高亮差异位方便用户修改 OCR 识别结果支持主题配置比如停车场项目用蓝绿色、充电桩项目用橙色通过 CSS 变量实现一键换肤还预留了一个customKeys插槽某些项目需要放“通行证”“月卡”之类的快捷按钮可以直接插到工具栏里。还有一个有价值的方向是接入车牌识别摄像头。现在很多停车场小程序都支持拍照识别识别结果直接填充到键盘预览区用户只需要核对一两位不确定的字符。这个能力如果在键盘组件层面做可以复用同一套校验逻辑避免识别结果生成一个非法车牌。做法就是给组件加一个ocrValueprop监听变化后自动把识别结果拆分成字符数组并让光标落到第一个疑似错误的位置用户一键修正即可提交。最后分享一个我个人的小习惯车牌键盘的确认键点击后不要把键盘立即关掉。很多用户输完车牌会顺手点一下“确认”随后才意识到还要核对一遍。点击确认后弹出确认弹窗或者轻微震动反馈让用户看一眼预览区的完整号牌再操作下一步能显著减少“输完了才发现错了要重来”的投诉。这个小改动不复杂但对体验提升非常明显。车牌键盘组件本身不是什么高深技术难的是把输入规则、交互细节、跨端兼容都做到位。照着上面的思路做至少停车场、充电桩、门禁这类项目里车牌录入这块不会成为用户吐槽的重灾区。