插画:账单票据与高亮金额居中,周围分摊箭头指向四个人物头像,右下角是计算器
前端单文件算法开源项目SplitLedger

一行一句话记一笔账:SplitLedger 的自动识别和 AA 结算

每次聚餐完,AA 账是最烦的一环。

谁先垫的钱、谁没付、现在到底该谁给谁转多少 —— 几个人就得算几遍,算完还得在群里对齐口径,稍有偏差就有人不高兴。

我试过用表格app 算过,太重;用群聊记账,条目多了根本翻不动;AA 计算器倒是现成的,可它不管记账。

所以我做了 SplitLedger。

它想解决的就一件事

记账这件事,应该像发消息一样随手。

所以它的输入框接受的是人话,不是表单:

午饭 18
打车 23.5
报销到账 +120
买菜 12
买牛奶 59.9 元
今天忘了带钱包

你像发消息一样打一长串,它自己拆成一条条记录、认出金额、算好合计。

识别规则:取每行最后一个数字

规则本身简单得出奇 —— 每行取最后一个数字当金额,前面的文字全当摘要。

你写的 识别为 说明
午饭 18 支出 ¥18.00 无符号,按设置算支出
打车 23.5 支出 ¥23.50 支持小数
报销到账 +120 收入 ¥120.00 + 开头算收入
客户打款 +1,200 收入 ¥1,200.00 支持千分位
买菜 12 支出 ¥12.00 全角数字也能认
买牛奶 59.9 元 支出 ¥59.90 结尾的「元 / 块 / ¥」忽略
今天忘了带钱包 — 没数字:保留展示,标注「不计入」

这张表里有两行是我在真实使用里被打出来的。

第一行是「今天忘了带钱包」。 一开始我以为没数字的行直接丢掉就行。但实际用的时候经常顺手打一句废话,那行丢掉就意味着我写的东西”消失了” —— 这感觉很差,跟聊天记录被删了一样。

所以改成:没数字的行原样保留,只在合计里标一个「不计入」。它不是一笔账,但它是我写过的话。

第二行是全角数字。 我平时用中文输入法,12 打出来就是全角。它其实是个正则里的字符类问题,但测试的时候一看到这个就懂了 —— 中文用户天天在打全角,不处理就是 bug。

为什么要区分收入支出

上面表格里 +120 是收入,18 是支出。区别在符号。

那如果我不写符号呢?报销到账 120 —— 这是收入还是支出?

这个我不能替用户决定,所以做成了可切换的设置:「无符号数字算作」支出 / 收入,默认算支出。

这个设计的理由是:默认值要覆盖多数情况。日常记支出多,所以默认支出;同时把选择权交出去,因为报收到账这类场景虽然少,但不是没有。

AA 结算:口径比公式重要

记账只是输入,AA 才是这个工具真正想解决的事。

我一开始想的是”谁请客就填谁付了多少”,后来发现不对 —— 实际场景是先有人垫钱,回来再对账。所以口径定成这样:

账本里记的是「我」付的钱,「TA 已付」那部分手填;两者都算进共同开销,再按两人平摊。

你的净垫付  mine  = 支出 − 收入
合计已付    total = mine + TA 已付
每人应承担  each  = total ÷ 2
差额        diff  = each − TA 已付

几个要点:

必须用净额。 如果这顿饭里你有报销(收入),那你的实际垫付是 支出 − 收入,不是支出。这个场景太常见了 —— 公司团建 AA,公司报一半,很常见。

「TA 已付」是手填的,不是记的。 因为对方付钱的时候你可能不在场,也可能压根没跟他说这笔。这部分你只能事后问。

结果摊开给你看。 界面不只给一个数字,而是把这四行算式展开显示。不为什么,就是让人能自己验证 —— 涉及钱的数字,我希望你能确认它是怎么来的。

一个小坑:浮点误差

AA 结算里有个经典问题。

你垫了 100,对方付了 100。数学上两清。但如果实现上走了浮点运算,diff 可能算出来是 0.0030000000000000027 —— 屏幕上显示”TA 需要还你 ¥0.00”,或者干脆显示”两清”和”还你 0.003 元”来回横跳。

所以加了个阈值:

const diff = each - taPaid;
if (Math.abs(diff) < 0.005) {
  // 视为两清,只显示文字,不显示金额
}

0.005 是个经验值。太小了挡不住浮点噪声,太大了会把真正的小额差额(几分钱)也吃掉。0.005 正好卡在中间 —— 货币本来最小单位就是分,五厘钱以下的差异没有实际意义。

还有个细节:两清的时候只显示文字、不显示金额。因为「0.00」这个数字看起来像”有笔账”,但实际上是没有。写一句话”两清”比显示 0.00 清楚。

导出:CSV 要带 BOM

导出有两个,长图和 CSV。

长图是 ⌘/Ctrl + S 直接出,适合往群里发。CSV 是给自己存档和后续处理的。

这里有个很容易踩的坑:CSV 必须写 UTF-8 BOM。

const BOM = "\uFEFF";   // 必须有,否则 Excel 打开是乱码
const blob = new Blob([BOM + csvText], { type: "text/csv;charset=utf-8" });

没有 BOM 的话,Excel 打开中文全是乱码 —— 因为它默认按 GBK 解码 UTF-8 内容。macOS 的 Numbers 反而没事(它默认按 UTF-8)。所以这个问题只有一部分用户会遇到,很典型。

一行 BOM 就能解决。

数据存哪:localStorage 与一个刻意的”不改名”

数据只存在浏览器的 localStorage 里,不上传、不联网、无账号。

存储用了两个 key:

Key 内容
ledger-mini/v1 草稿:编辑区文本、方向设置、语言、AA 结算的「TA 已付」
ledger-mini/saved/v1 已保存记录,逐条 {at, label, amount, dir}

注意 key 前缀是 ledger-mini,不是 splitledger。

这是故意的。这个工具最早叫别的名字,改名之后我特意没改 key 命名空间 —— 因为改了等于所有老用户的账本集体清零。

这类”看不见但要守住的东西”,代码里一般不写注释,但我在这儿写一句:以后谁想优化这个前缀,想清楚这意味着什么再动。

另外有个坑要提醒:localStorage 是按域名隔离的。 所以两个不同域名的入口(本站和 GitHub Pages 镜像)算两本独立的账,账本不会跟着域名走。README 里写明了,不然一定有人以为数据丢了。

单文件

整个网页端是一个文件 —— web/index.html,HTML / CSS / JS 全部内联,零依赖、零构建、零后端。

双击能用。往任何静态托管上一丢就是个可用站点。GitHub Pages 那个镜像就是这么来的,我甚至没有为它写任何构建配置。

这也是我判断”这个东西做完了”的标志:它不再需要我了。 双击能开,丢上去能跑,没有服务器要维护。

用起来

在线:kingsir.work/SplitLedger/,打不开就换 GitHub Pages 镜像(同一份单文件,换个托管位置)。本地:直接打开 web/index.html。

源码在 isnotry/SplitLedger,MIT 许可。

用得不爽就来提 issue。尤其是识别规则 —— 我知道”取最后一个数字”这个策略很粗糙,遇到某些写法一定会错。你碰到什么反例,直接告诉我,我改。