TextInput + HarmonyOS 包裹核销:扫码录入去重与状态幂等【共创稿事节】

一个包裹的条码被连续扫描两次,界面上的“已核销数量”是否应该增加两次?答案显然是否定的。但在实际终端上,同一段文本既可能来自人工粘贴,也可能来自外接扫描枪构造的键盘输入;如果把 TextInput.onChange() 直接等同于一次“核销”,输入 PKG-2402 的每个字符都有机会触发业务处理,计数和错误消息很快就会混乱。
这篇文章独立构建一个小型 ParcelDesk 包裹核销页,不接摄像头权限、不假定有硬件扫描 SDK,只处理已经进入输入框的文本。工程目标是:明确何时才提交、拒绝格式错误的编号、不接受清单之外的包裹、同一编号重复提交只能有一次状态变化,并让列表、进度和提示信息保持一致。项目中的三个示例包裹码分别为 PKG-2401、PKG-2402、PKG-2403;初始只有第一个已核销。本地示例不代表仓库真实出库记录,但状态机的边界与实际业务一致。
事件层不要代替业务层做决定
输入控件有两个常用事件:onChange 告诉页面文本发生变化,onSubmit 告诉页面用户触发输入法的提交动作。它们不能混为一谈。onChange 应只维护输入缓冲区并适时清理旧提示;真正的业务校验只在 onSubmit 或“确认核销”按钮触发时执行。这也解释了为什么扫描枪如果只是构造键盘,必须先确认其是否发送末尾 Enter;不发送时用户仍可点按钮,而不能把每次输入变化当成完成扫描。
将校验规则写成固定的业务约束,而不是散落在控件回调里。本次示例接受 PKG- 加四位数字,例如 PKG-2402。为兼容人工输入,可去掉字符串两端空白,并统一转大写;中间插入空格则按错误处理。这样 pkg-2402 会被规范化为正确编号,而 PKG-24 02 不会被悄悄改写成另一个有效码。
interface PackageRecord {
code: string
recipient: string
verified: boolean
}
function normalizeCode(raw: string): string {
return raw.trim().toUpperCase()
}
function validCode(code: string): boolean {
return /^PKG-[0-9]{4}$/.test(code)
}
normalizeCode() 不应顺手执行过度修复,例如补齐缺少的位数、替换相似字符 O/0,或移除中间的非预期符号。这些动作容易把一个非法条码变成另一个真实存在的包裹。对真实物流单据还应考虑校验位、批次规则、作业仓库和数据来源,但它们属于业务协议,不是 HarmonyOS 文本框替开发者决定的事情。

把幂等性落实在记录上,不依赖按钮动画
所谓核销幂等,不是“按钮点完后变灰三秒”,而是同一个包裹编号在同一作业范围内,无论提交多少次,都不会被重复记账。本地示例用包裹码作为记录键,针对 verified 状态执行检查;已核销就只反馈重复,不再次增加计数。由于它是示例清单,数据在页面重建时恢复初始值。若要跨设备、跨班次实现真正的业务幂等,必须交给服务端的唯一约束或幂等令牌完成,客户端只能优化体验,不能替代后端判重。
页面采用 @State 保存可变记录数组和提示。进度不是单独维护的计数器,而是每次从记录派生。初始记录中 PKG-2401 已核销,其余两条待核销,因此初始进度是1/3。用户输入 PKG-2402 并提交后,应变成2/3;重复提交 PKG-2402 时仍是2/3,不能凭一次事件多算一个。
@Entry
@Component
struct ParcelPage {
@State private inputCode: string = ''
@State private feedback: string = '等待录入包裹码'
@State private records: PackageRecord[] = [
{ code: 'PKG-2401', recipient: '海棠路站点', verified: true },
{ code: 'PKG-2402', recipient: '文昌街站点', verified: false },
{ code: 'PKG-2403', recipient: '云杉路站点', verified: false }
]
get verifiedCount(): number {
return this.records.filter((item: PackageRecord) => item.verified).length
}
// submitCode() 与 build() 见后文
}
使用 @State 数组时,本例不依赖深层对象修改的隐式观察能力,而是通过 map() 创建新的数组和被改变的记录对象。这样列表依赖的顶层状态发生替换,通知路径更容易观察;如果后来把记录拆成复杂的子组件对象,需要结合组件输入和 @Observed / @ObjectLink 的语义决定状态传递方式,不能把所有嵌套属性修改都当成天然会刷新。
核心提交函数可以独立理解,它没有异步网络调用,也不假装持有仓库后端接口。真正接入时应让服务端确认后再把 verified 改为 true,请求失败时保留待核销状态。
private submitCode(): void {
const code: string = normalizeCode(this.inputCode)
if (!validCode(code)) {
this.feedback = '编号格式应为 PKG- 加四位数字'
return
}
const index: number = this.records.findIndex(
(item: PackageRecord) => item.code === code)
if (index < 0) {
this.feedback = `未在当前清单找到 ${code}`
return
}
if (this.records[index].verified) {
this.feedback = `${code} 已核销,请勿重复提交`
this.inputCode = ''
return
}
this.records = this.records.map((item: PackageRecord) =>
item.code === code ? { ...item, verified: true } : item)
this.feedback = `核销成功:${code}`
this.inputCode = ''
}
这里保留输入清洗后的值只用于判断,不直接改写所有包裹 ID。找不到记录不是“核销失败再试一次”那么简单:它可能属于其他作业批次、出现拼写错误或尚未同步清单,因此界面要清楚说是“当前清单未找到”,不要暗示远端的真实库存状态。对于格式失败保留原输入,便于人工修正;对于已核销和成功状态清空输入,便于下一次录入。
UI 不需要复杂,但每一处反馈都有明确的来源
页面的交互区在上,清单在下,进度紧贴标题。重点不是使用多少组件,而是让单据状态一眼能看懂。TextInput 的 text 绑定输入状态,.onChange() 只负责缓冲;onSubmit() 与按钮最终走同一个 submitCode(),不会出现“键盘确认走一套规则、按钮又走另一套规则”的分叉。ForEach 使用稳定的 code 作为键,而不是数组索引,这样状态改变时不会把错误样式复用到另一条记录上。
build() {
Column({ space: 18 }) {
Text('包裹核销').fontSize(28).fontWeight(FontWeight.Bold)
Text(`已核销 ${this.verifiedCount}/${this.records.length}`)
.fontSize(18).fontColor('#285CC2')
TextInput({ text: this.inputCode, placeholder: '输入或扫描包裹码' })
.onChange((value: string) => { this.inputCode = value })
.onSubmit(() => { this.submitCode() })
Button('确认核销').width('100%')
.onClick(() => { this.submitCode() })
Text(this.feedback).fontSize(14).fontColor('#3D6FB7')
ForEach(this.records, (item: PackageRecord) => {
Row() {
Column({ space: 4 }) {
Text(item.code).fontSize(18).fontWeight(FontWeight.Medium)
Text(item.recipient).fontSize(13).fontColor('#7A8596')
}
Blank()
Text(item.verified ? '已核销' : '待核销')
.fontColor(item.verified ? '#17836B' : '#A46C22')
}
.padding(15).backgroundColor(Color.White).borderRadius(14)
.width('100%')
}, (item: PackageRecord) => item.code)
}
.padding(24).width('100%').height('100%')
.backgroundColor('#F5F8FC')
}
开发时应该在 Stage 模型的 entry/src/main/ets/pages/ParcelPage.ets 放置此页面,将文件作为工程的入口页面或在现有导航中注册;不要在同一工程同时启用多个无关的 @Entry 页面后,又期待它们都在首页出现。示例只使用系统内建的 ArkUI 基础组件,不依赖第三方库,也不需要相机、蓝牙或网络权限。需要注意具体扫码枪能否触发输入焦点、是否自动回车,属于硬件与输入法组合的兼容性测试,不能从 TextInput 接口本身推断。

图中的界面以“刚刚输入 PKG-2402 并核销”为状态:顶部显示“已核销 2/3”,PKG-2401 与 PKG-2402 标为已核销,PKG-2403 仍待核销,反馈文案为“核销成功:PKG-2402”。输入框此时应为空,符合成功后清空的代码。图中不出现摄像头取景、联网成功、仓储设备配对等未实现能力;示意的是屏幕交互布局,而不是已接通真实仓库系统。
从“重复提交”扩展为可检查的故障矩阵
只测一次成功并不足以发现输入事件的陷阱。建议在 DevEco Studio 的预览或可用测试设备上逐一执行以下操作,并观察 records、feedback 与 verifiedCount,不要仅凭某张绿色提示卡片判定逻辑没有问题。
| 输入与动作 | 当前状态 | 预期反馈及数量 |
|---|---|---|
pkg-2402 后回车 | 2402待核销 | 转为已核销,2/3 |
再次输入 PKG-2402 | 2402已核销 | 提示已核销,保持2/3 |
PKG-24 03 点击按钮 | 编号不合规 | 显示格式错误,保持2/3 |
PKG-9999 后回车 | 清单中不存在 | 提示未找到,保持2/3 |
PKG-2403 点击按钮 | 2403待核销 | 转为已核销,3/3 |
| 空输入按确认 | 无有效编号 | 拒绝提交,数量不变 |
其中最值得仔细测试的是“扫码枪一次写入多次触发 onChange”的情况。只要业务处理没有放到 onChange,字符逐个进入就不会改变清单;只有提交动作才触发校验。若实际设备一次扫描可能同时发出回车和某个独立的硬件回调,产品层就要决定只采用一个提交入口;即使意外进入同一个 submitCode() 两次,也应该由 verified 门禁阻止重复计数。不过本地门禁无法保证两个进程同时核销时的跨设备原子性。

另一项容易遗漏的行为是多次切换输入焦点。用户可能扫描半截后切出页面,又重新返回;此时未提交的输入只是草稿,不该影响核销进度。若产品希望恢复草稿,需要单独保存输入缓冲并在回到页面时提醒继续编辑,而不是自动调用提交函数。对于展示中的“已核销 2/3”,分母来自当前作业清单,如果切换批次应整体替换清单和批次标识,避免把不同批次的记录混在同一组统计中。
真实线上系统还有三项不能省略的改造。其一,服务器确认:请求应携带操作员、作业批次、包裹唯一编号与幂等键,客户端等待后端确认再变更最终状态。其二,失败重试:网络异常不能直接显示“核销成功”,也不能因为 UI 超时就假设服务端没有执行;可以用同一个幂等键查询或重试。其三,审计与权限:包裹可能关联收件人隐私,实际展示应遵循最小权限原则,日志不能无条件记录完整个人身份信息。这里的“海棠路站点”等是纯示例地点标签,并无个人资料。
如果引入离线队列,必须区分“待上传”“服务器已确认”“需人工处理”三个业务态,不要把离线缓存写入成功等同于出库成功。本篇特意使用最小的 boolean verified,是为了把输入提交和幂等性讲清楚,而不是鼓励所有仓储项目只用一个布尔值解决事务。页面的记录源将来可以替换成带版本和服务器确认状态的数据结构,表单入口与反馈逻辑则无需推倒重写。
这套方案适用于键盘式扫描、人工输入和受控本地核验原型。它的价值不在于花哨的扫描动效,而在于划清三个边界:文本改变不是提交,重复提交不是重复业务,客户端反馈不是服务器事实。只要这三条规则始终成立,后续添加真实扫码硬件、同步接口或批次切换时,系统仍然有可靠的错误定位起点。
更多推荐

所有评论(0)