一、为什么传统的条码盘点不够用了?
在企业资产管理(EAM)领域,每季度/每月的资产盘点是绕不开的硬骨头。传统方案是资产贴条码 + 盘点员举扫码枪逐件扫:
举枪 → 对准条码 → 滴一声 → 录入 → 举枪 → 对准 → 滴一声 ...
想象一下 5000 件资产的机房:一件一枪,对准耗时、手抖扫不上、来回走动——一个熟练盘点员往往要花上一整天,还容易出现重复扫、漏扫、错扫。
而 RFID(超高频 RFID + UHF EPC 标签)的方案则完全不同:
举读写器 → 按住扳机 → 沿货架走过去 → 几秒内读到几十/上百个 EPC → 自动比对
读写器一次能读方圆 3~5 米内的所有 EPC 标签,盘点员甚至不需要把读写器对准每一件资产——走动即盘点。这是业务流程上质的改变。
但代价是:RFID 读到的数据形态、业务逻辑、容错策略,与条码完全不同。
本文以我们团队维护的开源 首码 EAM(资产/备件一体化管理 SaaS) 中 RFID 盘点的真实实现为例,把这套工程思路讲透。
项目结构:3 层 EAM monorepo
shouma-web:Vue3 + TS PC 端(9200 端口)shouma-asset-app:uni-app 三端 App(本文主角)shouma.cloud+shouma-pro-assets:Spring Boot + Spring Cloud 微服务后端
二、核心认知:RFID 与条码是「两种业务」,不是「两种扫码方式」
很多教程一上来就讲 SDK 怎么调,但真正决定项目成败的是这条认知:
| 维度 | 条码扫码(红外) | RFID 盘点(UHF 射频) |
|---|---|---|
| 读取模式 | 一枪一码,需对准 | 一扫一片,连续读 |
| 输入 | 单个 code | 一组 EPC(去重后可能几十上百个) |
| 反馈 | 应有"扫不到/已扫过"的逐次提示 | 批量提示会刷屏,应静默处理 |
| 漏读容错 | 扫不到 = 漏没扫(直接判盘亏) | RFID 本就漏读,"没读到"≠盘亏 |
| 适用对象 | 任意资产 | 仅序列号件(批次件无 EPC,不参与) |
关键洞察:RFID 把盘点的节奏从「扫一枪标一项」改成了「批量读取 → 集合比对」。业务流程变了,代码逻辑必须跟着变。
我们把盘点的三种落点抽象成下表:
| 落点 | 含义 | 业务处理 |
|---|---|---|
| ① | 命中本单明细 | 标为已盘(实际数量+1,盘亏=0) |
| ② | 命中本仓库存但不在本单 | 自动补录为盘盈 |
| ③ | 系统查不到 | 「未识别」,不进任何桶 |
这里有一个非常反直觉的设计决策:"没读到"不能直接判盘亏。
RFID 受金属遮挡、标签朝向、读写器功率影响,会有一定漏读率。如果把"没读到"都判盘亏,就会出现大量假盘亏。正确做法是:
RFID 只处理读到的,不去推断"没读到的该怎样"。
没读到的就保持"未盘",由人工决定换角度复扫 / 条码补扫 / 手动判亏。
三、硬件抽象层:PDA 扫描的统一接入
3.1 设计原则:不驱动硬件,只接广播
PDA 上的 RFID 读写器射频开关、功率调节、扳机按键、连续盘存——这些都由厂商自带的「扫描服务」App负责。我们的 App 不直接驱动硬件,而是:
- 注册 Android 广播接收器
- 把厂商发出的广播转成 uni 事件(
RFID_EVENT/SCAN_EVENT) - 业务页只监听 uni 事件,不耦合任何厂商 SDK
这样的好处是:换 PDA 厂商时,只需要改配置(广播 action + extra 键名),不动业务代码。
3.2 关键实现
// common/pda_scan.js
export const RFID_EVENT = 'RFID_CODE'
export const SCAN_EVENT = 'SCAN_CODE'
const readExtraString = (intent, key) => {
// 防御式取值:先取 String(),取不到再试 byte[](有厂商这么塞)
let value = ''
try {
value = intent.getStringExtra(key) } catch (e) {
value = '' }
if (value) return String(value)
try {
const bytes = intent.getByteArrayExtra(key)
if (bytes) {
const text = plus.android.invoke(
plus.android.newObject('java.lang.String', bytes), 'trim'
)
if (text) return String(text)
}
} catch (e) {
/* 该 key 不是 byte[],忽略 */ }
return ''
}
这里有两个实战细节:
细节 1:Android 13+ 必须显式声明 exported
const RECEIVER_EXPORTED = 2 // Context.RECEIVER_EXPORTED
main.registerReceiver(channel.receiver, channel.filter)
// 低版本 OK,Android 13+ (targetSdk 34) 会抛 SecurityException
// 兜底:
main.registerReceiver(channel.receiver, channel.filter, RECEIVER_EXPORTED)
细节 2:键名不对是换机最常见的故障
当取不到码时,把整条广播的 extras 全部 dump 到控制台。现场实施人员照着改配置即可,不用翻代码:
if (!code) {
console.warn('[pda_scan] 广播取不到 extra,本条 extras:', dumpIntentExtras(intent))
return
}
我们还做了一个广播嗅探页,现场实施人员可以同时监听多个 action,把收到的每条广播的 action + 全部 extras 交给回调——换机时用来确认厂商到底发什么广播、码放在哪个键里。
3.3 配置化适配
不同厂商、不同型号的 PDA,广播 action 和 extra 键名都不一样。我们把这部分做成可配置:
// common/pda-scan-config.js
// 预设表 / 自动识别 / 设置页本地覆盖
const config = loadScanConfig()
设置页允许实施人员当场测试广播是否能拿到码,配置存本地,完全不需要发版。
四、业务核心:rfid-scan-mixin 的设计
把 PDA 扫码的硬件细节封装好后,业务侧的设计就清爽多了。我们抽出一个 Vue Mixin,8 个单据页共用。
4.1 三个事件、三种扫描源
| 事件 | 来源 | 用途 |
|---|---|---|
SCAN_EVENT |
红外扫码枪(一枪一码) | 单据新增、归还、查询 |
RFID_EVENT |
RFID 读写器(一扫一片) | 盘点、批量查找 |
// component-com/dynamic-field-mixin/rfid-scan-mixin.js
onShow() {
// #ifdef APP-PLUS
this.initReceiver() // 注册 uni.$on
startScan() // 启动广播接收
// #endif
},
beforeUnmount() {
stopScan() },
onHide() {
stopScan() },
注意 #ifdef APP-PLUS——PDA 扫码只在 App 端有效,H5/小程序编译时被剔除。
4.2 节流与去重
RFID 一次广播可能塞进来几十个 EPC,扫码枪短时间连按也会多发。如果每个 EPC 都立刻发一次查询请求,会把后端打爆:
const searchFunc = _.throttle(() => {
if (this.batchId) {
// RFID 盘点
this.searchCheckAsset()
} else {
this.byRefIdFindAsset()
}
}, 1000) // 1 秒最多一次请求
uni.$on(RFID_EVENT, ({
code }) => {
this.scanResList = Array.from(new Set([...this.scanResList, code]))
searchFunc()
})
Set 去重 + 1 秒节流,是 RFID 盘点场景的标配。
4.3 红外扫描的反馈语义:识别"扫不到"和"已扫过"
红外扫码枪是一枪一码,用户希望"扫一下立刻知道有没有"。但是:
searchVal是累积的去重集合- 返回的是累积的命中集合
- 单看返回值判断不出"这一枪"的结果
所以必须在扫的那一刻就留下上下文:
uni.$on(SCAN_EVENT, ({
code }) => {
this.pendingScan = {
code, scanned: this.searchVal.includes(code) }
this.searchVal = Array.from(new Set([...this.searchVal, code]))
scanSearch()
})
barCodeFindAsset() {
// 节流窗口内可能扫了好几枪,取最后一枪作为本次请求的反馈对象
const pending = this.pendingScan
this.pendingScan = null
this.searchAsset(params, pending) // 把上下文传给请求回调
}
然后在回调里判断新增数量:
const beforeCount = this.assetIdList?.length || 0
if (res.data?.assetIdList?.length) {
this.assetIdList = Array.from(new Set([...this.assetIdList, ...res.data.assetIdList]))
}
this.reportScanFeedback(scanFeedback, (this.assetIdList?.length || 0) - beforeCount)
reportScanFeedback(scanFeedback, added) {
if (!scanFeedback || added > 0) return // 带来新资产 → 不打扰
uni.showToast({
title: this.translate(scanFeedback.scanned
? 'label.assetCodeScanned' // "该编号已扫描过"
: 'label.assetNotFound'), // "未查询到资产"
icon: 'none'
})
}
注意:RFID 不传 scanFeedback,所以一律不提示——连续读多枚标签,逐枚弹会刷屏。
这套逻辑用 6 个 vitest 单测覆盖了所有边界情况:
// RFID 不传 scanFeedback → 一律不提示
ctx.searchAsset({
}) // 不传第二参
await flush()
expect(uni.showToast).not.toHaveBeenCalled()
// 同一个码再扫一次 → 提示「该编号已扫描过」
ctx.searchAsset({
}, {
code: 'A1', scanned: true })
expect(uni.showToast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'label.assetCodeScanned' })
)
4.4 资产归还单的特殊处理
不同单据的返回结构不一样。例如资产归还单返回的是 [ {billId, assetId, id} ],不是 assetIdList:
if (this.moduleCode === ModelMenuTypeEnum.assetReturn) {
if (res.data) {
const returnData = res.data.map(item => ({
billId: item.billId,
assetId: item.assetId,
id: item.id,
}))
this.returnAsset.push(...returnData)
const ids = res.data.map(item => item.assetId)
this.assetIdList = Array.from(new Set([...this.assetIdList, ...ids]))
}
}
但判断"这一枪有没有带来新资产"的核心方法是一样的——比合并前后 assetIdList 有没有变长。这种"业务层不同、判定层一致"的抽象,是这个 Mixin 能跨页复用的关键。
五、盘点流程:路由式状态判定
盘点场景中,读到的 EPC 要与本单明细比对。我们把"集合比对"单独抽成一个纯函数 routeRfidBatch,方便单测:
// component-com/spare-suite/rfid-router.js
export function routeRfidBatch(epcs, rows) {
const out = {
hits: [], misses: [] }
const list = Array.isArray(epcs) ? epcs : []
const items = Array.isArray(rows) ? rows : []
if (!list.length) return out
// 本单明细的 rfidNumber → 行。空 rfidNumber 不入表(批次件)
const byEpc = new Map()
for (const r of items) {
const key = r ? norm(r.rfidNumber) : ''
if (key && !byEpc.has(key)) byEpc.set(key, r)
}
const seen = new Set()
for (const raw of list) {
const epc = norm(raw)
if (!epc || seen.has(epc)) continue
seen.add(epc)
const item = byEpc.get(epc)
if (item) out.hits.push({
epc, item }) // 落点 ①:命中本单
else out.misses.push(epc) // 落点 ②:盘盈
}
return out
}
注意几个细节:
Map而不是普通对象:避免 EPC 中出现特殊字符干扰 key- 同 EPC 只认第一行(
!byEpc.has(key)):脏数据防护 norm函数防御null、undefined、前后空白- 批次件(rfidNumber 为空)不入表:这是 RFID 对批次件完全无效的物理限制
盘点页 UI 上,「已盘」用蓝紫色、「盘盈」用浅黄色标记,盘点员一眼就能区分:
<view class="asset-status"
:class="!item.moreOut ? 'in-batch' : 'more-batch'">
{
{ translate(!item.moreOut ? 'check.find' : 'check.moreOut') }}
</view>
<style>
.in-batch { background: #7985ec; } /* 蓝紫:命中本单 */
.more-batch { background: #f1e4a2; } /* 浅黄:盘盈 */
</style>
六、实战踩坑:这 6 个坑你早晚都会遇到
这一节是真正值钱的部分——这些坑不在任何教程里,全是我们一行一行踩出来的。
坑 1:onLoad 晚于 created(Vue3 与 Vue2 相反)
// Vue2:created() 在 onLoad 之前 → 错误写法(Vue3 已失效)
created() {
this.calculateData() // batchId 还没赋值!
}
onLoad(option) {
this.batchId = option.id
}
修法:把依赖 onLoad 数据的初始化挪到 onLoad 末尾。
onLoad(option) {
this.batchId = option.id
this.init() // calculateData 用 batchId 拼请求参数,必须在这里调用
}
坑 2:registerReceiver 必须声明 exported(Android 13+)
// 低版本 OK,Android 13+ 抛 SecurityException
main.registerReceiver(channel.receiver, channel.filter)
main.registerReceiver(channel.receiver, channel.filter, RECEIVER_EXPORTED) // 兜底
不处理这个,目标 SDK 升到 33 之后整个 App 会启动后立刻崩溃。
坑 3:onBackPress 必须 return true
uni-app 约定:返回非 true 时,框架会在你的处理之后再执行一次默认 navigateBack。
onBackPress() {
// 错误:会双重导航,员工端 reLaunch + 默认 navigateBack → 白屏
uni.reLaunch({
url: '/pages/index/index' })
}
// 正确:
onBackPress() {
uni.reLaunch({
url: '/pages/index/index' })
return true // 告诉框架"我自己处理完了,你别再走默认"
}
坑 4:Vue3 watch 数组要 deep:true
// 错误:dataList.push() 不触发 watch
watch(dataList, () => {
this.derived = ... })
// 正确:
watch(dataList, () => {
this.derived = ... }, {
deep: true })
这是 Vue2 → Vue3 响应式语义变化最隐蔽的一处。
坑 5:uView Plus 的"空壳组件"
u-swipe-action 在 uView Plus 里只是 <view><slot></slot></view> 包一层,真正的 :options/@click/:disabled 全在子组件 u-swipe-action-item。
<!-- 错误:参数全写在 u-swipe-action 上,Plus 下静默失效 -->
<u-swipe-action :options :name :disabled @click>
<view>卡片</view>
</u-swipe-action>
<!-- 正确:参数放子组件 -->
<u-swipe-action>
<u-swipe-action-item :options :name :disabled @click>
<view>卡片</view>
</u-swipe-action-item>
</u-swipe-action>
老 uView 1.x 的写法在 Plus 下编译不报错,只有冒烟能发现——典型表现是左滑删除"自迁移起一直是死的"。
坑 6:i18n 版本必须锁 9.2.x
{
"overrides": {
"@intlify/core-base": "9.2.2",
"@intlify/message-compiler": "9.2.2"
}
}
dcloudio uni-cli-shared 内置 vue-i18n(编译期)依赖 @intlify 的 handleFlatJson API,强行升到 9.14 会移除这个 API,导致 mp-weixin build 失败。我们花了整整一天排查,最后定位到这一行。
七、完整盘点链路:一张图看懂
[ PDA 读写器 ]
│ 厂商广播(含 EPC)
▼
[ Android 广播接收器 ] (common/pda_scan.js)
│ uni.$emit(RFID_EVENT, { code })
▼
[ rfid-scan-mixin ] (节流 1s + Set 去重)
│ 节流后批量请求
▼
[ 后端 checkBatch/getBatchRefListByRfid ] (Spring Boot)
│ 返回本单 EPC 命中集合
▼
[ 前端集合比对 ] (routeRfidBatch)
│ 三种落点:已盘 / 盘盈 / 未识别
▼
[ UI 渲染 ] 蓝紫/浅黄标记
│
▼
[ 确认盘点 ] (confirmCheck) → 后端 saveBatchCheck
│
▼
[ 回到批次列表,自动切到"已完成" tab ]
八、给后来人的 5 条建议
- 先把"两种扫码是两种业务"刻在脑子里,再动手写代码
- Set 去重 + throttle 节流 是 RFID 标配,别图省事
- 广播接收器要做配置化,换机时不动业务代码
- "没读到"≠盘亏,给盘点员留足人工干预空间
- Vue3 迁移时把 onLoad/created 顺序、watch deep、return true 这几条 grep 一遍,全项目反复出现
九、关于项目
首码 EAM 是一套面向中大型企业的资产/备件一体化管理 SaaS,支持:
- 资产全生命周期(采购、入库、领用、转移、盘点、报废)
- 备件批次件 + 序列号件混合管理
- RFID / 条码盘点
- BPM 流程审批
- PC 端 + 移动端 App(H5 / 微信小程序 / 原生 App)
- 三语言国际化(简中 / 繁中 / 英文)
- 国密 SM2 加密 + 多租户数据隔离
技术栈:Vue 3 + TypeScript + Vite + uni-app + Spring Boot + Spring Cloud + Nacos。
后续会写系列文章拆解动态表单引擎、国密 SM2 接入、Vue2 → Vue3 迁移避坑清单等模块,欢迎评论区告诉我你最想看哪一篇。
如果这篇文章帮你少踩了几个坑,欢迎点赞 / 收藏 / 关注三连,你的支持是我写下去的最大动力。