小钱袋:说一句「午饭十八块五」,账单就记录到Rokid AI眼镜上啦
1. 为什么要做
手机上各种账单软件层出不穷,有的手机厂商还集成自己的记账助手,但是日常生活中掏手机记账:解锁、找 App、输金额,这一通操作下来复制的很,要是刚好赶上吃饭的空挡记账,饭都该凉了。然后AIUI结合Rokid AI眼镜给了我这个启发, 我想试试只说一句话行不行:对眼镜说「午饭十八块五」,镜片上的大字变成 ¥18.50,最近一笔写着午饭,语音跟一句「已记:午饭 ¥18.50」,就这还真的能实现。
这是 Rokid AIUI 上的单页应用,一个页面、四个模块。数据只在本机,不联网,也没做云同步。下面按一笔消费的完整经历来讲:如何使用,语音解析过程,账单存储、显示和撤销等功能实现。

2. 语音入口
用户可以通过镜腿的按键触发「记一笔」按钮,点击后开始收音,说完单击结束。
页内收音是一个独立的 voice.js 模块,只导出一个 openRecognition,返回 finish 和 abort 两个方法。三个时限:10 秒没起来报「语音未就绪」,出声后 20 秒没新结果自动收,点结束后再等 1.8 秒取最后一段文本。
点「记一笔」时状态行固定显示「语音说:说「记一笔咖啡9.9元」」,中间的转写过程盖住不露,不然状态行会半句半句地跳。页面侧用几个状态变量加一个代际号管生命周期:代际号每停一次自增,晚到的旧回调一看 isActive 是假就自己作废;cleanup 把四个回调全解绑再 abort,一次收音只结算一次。
3. 话怎么解析
app.json 里定义了规范,查看不传参数。记录时优先给 item 和 amount,也可以整句 text 交给页面拆;category 只有餐饮、交通、购物、其他四个值,可以不传。
const item = hasItem ? normalizeItem(options.item) : parsed.item;
const amountCents = hasAmount ? parseAmount(options.amount) : parsed.amountCents;
const category = provided(options.category)
? normalizeCategory(options.category)
: inferCategory(item);
return {item, amountCents, category};
这里有个细节:空字符串算没传,但传了不合法的值不算没传。category 给了个「餐饮食品」,直接报错,不会悄悄退回去猜一个。
金额解析在 money.js。「十八块五」是 1800 加 50,1850 分;「九块九」是 990;「十八块零五」按分算,1805;「9.9元」也认,是 990。范围就是常见的一两位中文数字加元、块、圆,没做百千万,做不完也没必要。
「打车18.5」不带单位,报「没识别到金额」。因为原话解析认的是「十八块五」「9.9元」这类带单位的说法,把所有裸数字都收进来,误记率会高很多。
一句话里两个金额,报「一次只记一笔,请分开说明」。不猜哪笔是对的,让用户重说一遍就好。
类别就是张关键词表,顺序固定:咖啡、奶茶、外卖、水果先归餐饮,打车、地铁、公交归交通,衣服、鞋、超市归购物,都落空就是其他。「超市奶茶」会归成餐饮,因为先命中了奶茶。这没什么可辩解的,规则就十几行,想要智能分类就上模型,我没上。
4. 存储与容错
18.50 存 1850,9.90 存 990,显示时再拼回去。账本里从头到尾没有浮点数,合计是整数分直接加,不存在 0.1 加 0.2 变 0.30000004 这种事。
export function formatYuan(cents) {
if (!Number.isSafeInteger(cents) || cents < 0) fail('金额数据不正确');
return String(Math.floor(cents / 100)) + '.' + String(cents % 100).padStart(2, '0');
}
存储结构:
export function emptyState() {
return {version: 1, revision: 0, records: [], undo: null};
}
读取时逐项校验版本、字段、编号唯一性,能 parse 的 JSON 不等于合法账本。上限单笔 999999.99 元、一共 100 条,到顶报错,本次不进行存储。
写这个应用花时间最多的地方是存储的容错。
最省事的做法是读不到就当空账本,页面照常显示。但「没读到」有两种可能:真没有数据,或者存储坏了。把坏了当空的,用户接下来记的那笔会把整个账本覆盖掉,旧数据就没了。
所以只有读到 null 才算空账本。getStorageSync 抛异常、JSON 坏了、校验不过,全走错误页,只留「重新读取」和「退出」两个按钮。这时金额那格虽然还显示 ¥0.00,但标题是「账本未就绪」,不会出现「还没有消费记录」的空账本提示。
写入在 commit 里,新增和撤销共用:
if (current !== raw) {
ready = false;
throw storageError('账本已在其他页面变化,请重新读取后再操作');
}
const value = JSON.stringify(next);
try {
storage.write(value);
} catch (error) {
ready = false;
throw storageError('保存失败,正式账本未更新,请重新读取');
}
state = next;
raw = value;
ready = true;
写之前先读一遍,跟上次保存的原始串比对,不一样说明别的页面改过这份账,不让写,让用户重读。写完还要回读一次,回读不一致也报错。
写入失败后我没做「内存回滚」。底层那次 setStorageSync 到底写没写进去,页面根本不知道,硬在内存里假装写成功才是自欺欺人。能做的就是把状态标成不可信,让用户重读。

5. 界面与按键
这里我改过一次。第一版大字放的是今日合计,测试跨过零点,大字突然变 0,下面还挂着昨天记的两条,看上去像数据丢了。后来大字改成账本总合计,今日金额挪到标签行,两个数字各管各的,就不打架了。
合计每次渲染都现算,不往存储里写冗余字段。totalCents 全加;todayTotal 按本机时区取当天日期,只加今天的。最近三条取数组末尾,不按时间排序,纯粹是显示窗口,列表里看不到的记录照样在合计里。
按键都在 onKeyUp 里:方向键挪焦点,Enter 确认,Backspace 退出,event.repeat 忽略,长按不会连着撤销。收音时所有按键都只负责结束收音。TTS 包了容错,播报失败不影响记账,成功的依据是账本调用返回,不是那声语音。
6. 撤销
撤销走的是和新增同一条 commit 路径,没有单独一套逻辑。没有历史栈,就一层:新增前把记录数组存进 undo,撤销时取出来覆盖回去,清掉 undo,修订号加一。
撤销完再按撤销,提示「当前没有可撤销的记录」。有人可能觉得一层太少,但按错一次的概率远大于连错三次,一层够用,实现也简单到不会写错。

7. 当前进度与后续升级
本地调试壳和浏览器里跑过:说「午饭十八块五」记入 18.50,再记「咖啡9.9元」,合计 28.40,和手算一致;撤销后回到 18.50。页内语音用 Node 里的 SpeechRecognition 桩跑过状态机,聆听中按键、点按钮都只收尾不执行动作,具体看录屏交互就好了。
demo
录音授权弹窗、getStorageSync 的真实容量、playTTS 的时延、480×360 的观感,还得在 AIUI Studio 和眼镜上过一遍。
后续的迭代我想做接下来的升级:
-
英语识别。金额口语支持英文说法,比如「lunch twelve point five」「coffee nine ninety」,先覆盖常见的两位数和点分小数,和中文金额并到同一条解析出口,不另起一套。
-
语音查询。现在只能记,问不了。补上「今天花了多少」「这周奶茶花了多少」,账本从只写变成也能读。
-
月度预算。设一个月度数,当月合计快到时语音提一句。眼镜适合在合适的时机开口,而不是一直沉默。
我开发的小钱袋的应用不大,但做的事情却是最为日常和解放用户双手的操作,当然随着AIUI的逐渐壮大,慢慢通过Rokid AI眼镜解放双手的智能体会层出不穷。
更多推荐



所有评论(0)