从扫码枪到射频读卡器:手把手实现企业级 RFID 资产盘点系统

简介: 本文详解RFID盘点在EAM系统中的工程落地:对比条码“一枪一码”,RFID实现“走动即盘点”,但需重构业务逻辑——聚焦批量读取、静默处理、漏读容错与集合比对。以开源首码EAM为例,剖析PDA硬件抽象、节流去重、路由式状态判定及6大实战坑点,助力企业高效升级资产盘点。(239字)

一、为什么传统的条码盘点不够用了?

在企业资产管理(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 不直接驱动硬件,而是:

  1. 注册 Android 广播接收器
  2. 把厂商发出的广播转成 uni 事件(RFID_EVENT / SCAN_EVENT
  3. 业务页只监听 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 函数防御 nullundefined、前后空白
  • 批次件(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(编译期)依赖 @intlifyhandleFlatJson 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 条建议

  1. 先把"两种扫码是两种业务"刻在脑子里,再动手写代码
  2. Set 去重 + throttle 节流 是 RFID 标配,别图省事
  3. 广播接收器要做配置化,换机时不动业务代码
  4. "没读到"≠盘亏,给盘点员留足人工干预空间
  5. 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 迁移避坑清单等模块,欢迎评论区告诉我你最想看哪一篇。


如果这篇文章帮你少踩了几个坑,欢迎点赞 / 收藏 / 关注三连,你的支持是我写下去的最大动力。

目录
相关文章
|
11天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
11天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
17天前
|
网络协议 Linux iOS开发
【2026实测】Wireshark下载+安装+汉化+使用教程(图文版,巨详细)
Wireshark 是一款免费开源的网络协议分析工具,可实时捕获、解析并可视化数据包,助你诊断网络故障、分析通信协议(如HTTP、DNS、TCP等)。支持Windows/macOS/Linux,含中文界面,新手入门便捷。(239字)
|
10天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1128 1
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
11天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
1951 15
|
12天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
16天前
|
人工智能 运维 BI
阿里云千问办公QwenWork深度解析:基于Qwen3.8,六大核心能力重构企业全自动化工作流与计费选型指南
传统AI办公工具大多停留在对话问答、文档摘要、简单文案生成层面,只能完成单点碎片化任务,无法自主拆解复杂业务流程,很难串联多工具、多文档、外部业务系统完成端到端完整工作交付。很多企业在落地AI办公的时候,需要组合多款不同工具,来回切换界面,手动复制粘贴中间结果,智能化改造落地门槛居高不下。千问办公QwenWork是整合多款智能体产品能力打造的一体化企业办公智能体平台,底层基座依托Qwen3.8大模型,打通桌面端Agent、云端Agent、企业协同Agent三种运行形态,不再局限简单问答,接收业务目标之后自主拆解任务步骤,调用各类工具,处理文档、表格、浏览器自动化、数据查询,直接输出可交付的办公
1679 4
|
18天前
|
缓存 数据可视化 开发工具
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
DeepSeek Harness 的更新分两层:本体更新(npx 自动最新、npm update -g、源码 git pull)与插件更新(插件市场点更新、命令行覆盖安装)。本文按「准备 → 更新本体 → 更新插件 → 更新后检查」四步走,覆盖新手常见疑问。
1952 1
DeepSeek Harness 怎么更新?dsh 更新完整指南:更新本体(npx、npm、源码)与更新插件两种方式
|
13天前
|
SQL 人工智能 前端开发
QoderWake 1.0 正式发布:从桌面里的 Agent,到工作现场的数字员工
QoderWake v1.0正式发布:企业级数字员工团队平台。支持“一句话建岗”,预置10类特训岗位;Waker常驻钉钉/飞书群,@即响应、自动协作、跨任务记忆;具备定时/事件/API多触发方式与统一任务看板;已沉淀27.6万条记忆、12.3万项技能,助力组织实现人机协同增效。
848 2
|
10天前
|
缓存 测试技术 API
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)
DeepSeek V4.1 Flash 内测不用申请,base_url 不变、改个模型名就能调,9/10 到期。本文讲清接入、计费限流与多模态注意点。
852 0
DeepSeek V4.1 Flash 内测接入:改个模型名即可调用(附代码)

热门文章

最新文章