- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
152 KiB
beancount-mobile 开发方案
已整合 BeeCount 分层处理架构 + AutoAccounting 无障碍实现 v3 — 基于 BeeCount / AutoAccounting 参考项目全面审查修订 采用 MVP + 演进路线图模式,明确各版本边界
项目概述
beancount-mobile 是一个面向 Beancount 用户的离线移动端应用,支持 iOS 和 Android。核心原则:
- 账本文件(
.bean)是唯一权威来源 - 应用只写入
mobile.bean,不改写桌面维护的源文件 - 复式记账原生支持,完全兼容 Beancount 生态
版本策略
| 版本 | 目标 | 核心功能 |
|---|---|---|
| v1.0 MVP | 可用的移动端记账 | 基础架构 + UI + 交易管理 + CSV 导入 + 规则引擎 + 去重 + OCR(Android) |
| v1.1 增强 | 完善的记账体验 | 通知/短信监听 + 信用卡管理 + 日历视图 + 年度报告 + AI 聊天 |
| v2.0 进阶 | 高级功能 | 多币种增强 + 同步 + 首页小组件 + 语音记账 + 深度链接 |
| v2.x 演进 | 生态完善 | MCP 支持 + 共享账本 + 海报生成 + 更多同步后端 |
技术栈
| 组件 | 技术选型 | 版本 |
|---|---|---|
| 框架 | React Native + Expo CNG | 0.81.0 / ~54.0.0 |
| 语言 | TypeScript | ~5.9.0 |
| 状态管理 | Zustand | 5.x |
| 本地存储 | expo-sqlite (SQLCipher) | ~16.0.0 |
| 解析器 | 自定义正则 + Tree-sitter(可选) | - |
| OCR(Android) | Google ML Kit + PP-OCRv5 | 16.0.0 |
| 图表 | react-native-chart-kit / Victory | - |
| 国际化 | expo-localization + i18n-js | - |
| 规则引擎 | QuickJS(轻量 JS 执行器) | - |
设计决策与订正记录(v3.1 审查修订)
本章节是对参考项目(BeeCount / AutoAccounting)深度审查后的订正记录,集中说明若干影响全局的架构决策。后续各 Phase 中凡与本章冲突的旧描述,以本章节为准。
决策 1:分类/标签/预算采用「双轨制 + 映射表」
问题背景:BeeCount 的分类/标签/预算/信用卡都是数据库一等表,与账户无关。但 beancount-mobile 的权威数据源是 .bean 文件,其中"分类"本质是 Expenses:Food 这样的账户命名空间,标签是 #tag 语法,Beancount 原生没有预算概念。若直接照搬 BeeCount 的表结构,会出现"应用表里有分类,但 .bean 文件里没有对应账户"的不一致。
双轨制方案:
| 数据 | 应用本地表(UI 增强) | 是否写回 .bean |
映射方式 |
|---|---|---|---|
| 分类 | categories(name/keywords/icon/color/linkedAccount) |
否 | 记账时把 category.linkedAccount(如 Expenses:Food)作为 posting account |
| 标签 | tags(name/color) |
是 | 记账时把 #tagName 写入 narration(Beancount 原生 tag 语法) |
| 预算 | budgets(amount/period/categoryId) |
否 | 纯本地虚拟概念,仅用于统计/提醒,不影响余额 |
| 信用卡 | credit_cards(billingDay/paymentDay/creditLimit/linkedAccount) |
否(UI 字段) | 账户本身(如 Liabilities:CreditCard:CMB)在 .bean,UI 字段仅存本地 |
关键接口修正(Category 必须含 linkedAccount,否则双轨制无法落地):
// src/domain/categories.ts
export interface Category {
id: string;
name: string;
parentId?: string; // 支持层级(UI 展示用,不映射到 .bean)
type: 'income' | 'expense';
linkedAccount: string; // ★ 映射到 Beancount 账户(如 'Expenses:Food'),记账时作为 posting account
icon?: string; // UI 增强(仅本地)
color?: string; // UI 增强(仅本地)
keywords: string[]; // 自动匹配关键词(仅本地匹配逻辑用)
}
映射失效处理:
// 记账前校验 linkedAccount 是否仍存在于账本(桌面端可能删除/重命名账户)
export function resolveCategoryAccount(category: Category, ledger: LedgerIndex): {
account: string;
valid: boolean;
fallbackReason?: string;
} {
const exists = ledger.accounts.has(category.linkedAccount);
if (exists) return { account: category.linkedAccount, valid: true };
// 优雅降级:账户被删除时降级到 Uncategorized,并提示用户修复映射
const fallback = category.type === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized';
return { account: fallback, valid: false, fallbackReason: `分类「${category.name}」映射的账户 ${category.linkedAccount} 已不存在,已降级` };
}
与原 plan 描述的差异:原 2.3「参考 BeeCount 的二级分类 + CategoryMatcher」的措辞误导(BeeCount 无 .bean,分类是独立表)。本方案改为:分类匹配算法参考 BeeCount 的 CategoryMatcher,但分类与账户的关系模型采用双轨制映射,而非 BeeCount 的独立表。
决策 2:参考项目归属修正
经对两个参考项目源码的深度审查,明确各自的可参考范围与不可参考范围:
BeeCount(./reference_project/BeeCount)
| 方面 | 可参考 | 说明 |
|---|---|---|
| UI/UX、设计令牌、主题系统 | ✅ | lib/styles/tokens.dart 设计令牌体系可直接借鉴 |
| 同步架构(5 种云同步) | ✅ | SyncEngine / 冲突解决 / in-flight 单飞行锁 |
| 平台特性(Widget/Deep Link/Share Extension) | ✅ | iOS AppIntent 后台执行、Android ContentObserver |
| 截图自动记账通道 | ✅ | ScreenshotObserver.kt + AutoBillingAppIntent.swift |
| AI 三层架构 + 五通道统一 | ✅ | AiExtractionEngine / AiBookkeeper / BillCreationService |
| 应用锁 + 隐私模糊 | ✅ | AppLockService + BackdropFilter |
| 海报生成、孤儿文件 GC、日志中心 | ✅ |
| 方面 | 不可参考 | 原因 |
|---|---|---|
| Beancount 数据建模 | ❌ | BeeCount 不解析 .bean,是自有 Drift 数据模型(beecount.sqlite,schemaVersion 30)。其分类/标签/预算/信用卡表与 Beancount account 语义无关 |
| 数据库 schema 设计 | ❌ | 同上,其表结构围绕自有模型,不能照搬到".bean 为权威源"的架构 |
AutoAccounting(./reference_project/AutoAccounting)
| 方面 | 可参考 | 说明 |
|---|---|---|
| 账单处理责任链 | ✅ | BillService.analyze 的完整流程(规则→AI→资产映射→分类→备注→去重→转账) |
| 多通道联合去重 | ✅ | DuplicateDetector(时间窗口 + 金额 + 渠道规则) |
| 转账智能识别 | ✅ | TransferRecognizer(Income+Expend→Transfer,先于去重) |
| 字段合并 | ✅ | BillMerger(已知资产优先、名称长度偏好) |
| 备注模板引擎 | ✅ | 16 个占位符 + 相邻重复归一化 |
| 规则引擎(QuickJS) | ✅ | 系统/用户规则分离、禁用规则可阻止 AI |
| 浮窗账单(Channel 队列 + 节流) | ✅ | BillWindowManager(串行 + 300ms 节流 + MD5 去重) |
| OCR 性能优化 | ✅ | 短边 720px 压缩、JPEG q60、CPU 模式 |
| AI Provider 抽象 | ✅ | 8 个 Provider 走 OpenAI 兼容协议 + Gemini 独立实现 |
| Bill Flag System | ✅ | 位运算标志位(不计收支/不计预算) |
| 方面 | 不可直接照搬 | 原因 |
|---|---|---|
| 嵌入式 HTTP 服务器(localhost:52045) | ❌ | 那是为 Xposed 跨进程设计的;RN 架构下原生 Module 桥更原生(见决策 6) |
| Xposed Hooker(微信/支付宝/钱迹) | ❌ | 项目不走 Xposed 路线 |
| 无障碍服务伪装(SelectToSpeakService) | ⚠️ | 仅侧载版本保留(见决策 3) |
| OCR 降级到 ML Kit | ❌ | AutoAccounting 根本不用 ML Kit,只用 PP-OCRv5+NCNN。原 plan 的"PP-OCR 失败降级 ML Kit"是虚构降级链,已删除。注:本项目改用 ONNX Runtime 替代 NCNN(见决策 7) |
决策 3:上架策略 —— 纯开源侧载
决策:本项目不申请应用商店上架,采用 GitHub Release + 侧载分发。
| 项目 | 说明 |
|---|---|
| 分发渠道 | GitHub Release(Android APK 自签名;iOS 需自签证书或 TestFlight 替代) |
| 功能范围 | 保留无障碍伪装、通知监听、短信读取、浮窗等全功能(无商店审核约束) |
| 安装引导 | Onboarding 增加侧载安装说明 + 权限手动授予引导(无障碍/通知监听需用户到系统设置开启) |
| 签名密钥 | Android APK 签名密钥由项目维护;iOS 用户自签 |
仍需注意的合规点(侧载不等于无约束):
- 隐私脱敏:账单/截图发往第三方 AI API 前,应做脱敏(参考 BeeCount
lib/ai/privacy/),并在 UI 明确告知用户数据会上传 - 无障碍服务伪装的风险告知:
SelectToSpeakService伪装可能被部分安全软件标记,Onboarding 需说明 - 开源协议:AutoAccounting 为 GPL-3.0,若复用其代码需注意传染性(本项目倾向自研 + MIT/Apache)
对 plan 的影响:原各 Phase 中"上架审核"相关担忧(如 Google Play READ_MEDIA_IMAGES 政策)不再适用,可保留全功能。
决策 4:Expo Config Plugin 方案(最被低估的工程障碍)
问题:项目使用 Expo CNG(expo prebuild 生成原生工程)。plan.md 文件清单里画的 android/app/src/main/java/com/beancount/mobile/... 裸 Kotlin 目录树,会被 expo prebuild 清空——因为 prebuild 每次根据 app.json/config 插件重新生成原生工程。
方案:所有原生功能必须封装为 Expo Config Plugin + React Native Module,而非裸 Kotlin 文件。
| 原生功能 | Config Plugin 职责 | RN Module 职责 |
|---|---|---|
| 无障碍服务 | 注册 manifest <service> + accessibility_service_config.xml |
OcrAccessibilityService Kotlin 实现 + 桥接事件 |
| 通知监听 | 注册 manifest <service> + BIND_NOTIFICATION_LISTENER_SERVICE 权限 |
NotificationListenerService Kotlin + 事件推送 |
| 短信监听 | 注册 manifest <receiver> + RECEIVE_SMS 权限 |
SmsReceiver Kotlin + 事件推送 |
| 浮窗(TYPE_APPLICATION_OVERLAY) | 注册 SYSTEM_ALERT_WINDOW 权限 |
FloatingBillView Kotlin + show/dismiss 桥接 |
| 快速设置磁贴 | 注册 manifest <service> + BIND_QUICK_SETTINGS_TILE |
OcrTileService Kotlin |
| PP-OCRv5 (ONNX Runtime) | 打包 assets/ ONNX 模型文件 + onnxruntime-android 依赖 |
OCR 原生模块 + recognizeText 方法(见决策 7) |
| 截图监听(Android) | 注册 ContentObserver 相关配置 | ScreenshotObserver Kotlin + 事件推送 |
目录结构修正(替代原文件清单里的裸 Kotlin 树):
plugins/
├── accessibility/ # 无障碍服务 Config Plugin
│ ├── app.plugin.js # Expo Config Plugin 入口
│ └── android/ # Kotlin 实现(prebuild 时复制进原生工程)
├── notification-listener/ # 通知监听
├── sms-receiver/ # 短信监听
├── floating-window/ # 浮窗
├── ocr-tile/ # 快速设置磁贴
├── ppocr/ # PP-OCRv5 原生模块
└── screenshot-monitor/ # 截图监听
app.json 中注册:
{
"plugins": [
"./plugins/accessibility",
"./plugins/notification-listener",
"./plugins/sms-receiver",
"./plugins/ppocr"
]
}
对 plan 的影响:原 Phase 3.2「原生模块架构」的目录树需替换为上述 Plugin 结构;各 Phase 涉及原生的章节均需补 Config Plugin 说明。
决策 5:SQLCipher 密钥管理
问题:技术栈表写"expo-sqlite (SQLCipher)",但全文未讨论密钥从哪来、存哪、丢失怎么办。
方案:
首次启动:
随机生成 32 字节密钥 → 存入 expo-secure-store(iOS Keychain / Android Keystore)
→ openAsync('bean-mobile.db', { key: 密钥 }) 启用加密
后续启动:
从 SecureStore 读密钥 → openAsync 传密钥
密钥丢失(用户清空 SecureStore / 重装):
DB 打不开 → 检测到后丢弃旧 DB → 从 .bean 文件重建(与"缓存层可重建"原则一致)
→ 重新生成密钥 → 新 DB
加密意义说明:.bean 文件本身在应用沙盒明文(应用自己的数据),DB 加密主要防御 DB 文件被直接拷贝出沙盒后分析(如备份导出、设备被取证)。若需更强保护,可后续加密 .bean 文件本身(但会影响桌面端兼容,v1 不做)。
Config Plugin 配置 SQLCipher build(expo-sqlite 需特定 build flag):
// plugins/sqlcipher/app.plugin.js
module.exports = (config) => {
// 配置 expo-sqlite 的 SQLCipher build 选项
config.extra = { ...config.extra, expoSQLite: { enableFTS: true, useSQLCipher: true } };
return config;
};
对 plan 的影响:Phase 0.2(数据库 Schema)补加密启动流程;Phase 0.4(应用锁与安全)补密钥管理。
决策 6:跨进程通信(RN JS 进程 ↔ 原生服务进程)
问题:AutoAccounting 用嵌入式 HTTP 服务器(localhost:52045)让 App 端和 Xposed 注入代码共享同一份业务逻辑。但 RN 架构下,原生服务(无障碍/通知/短信)和 JS 主进程是不同进程,数据如何流通?
方案:不学 AutoAccounting 的 HTTP 服务器(那是为 Xposed 跨进程设计的)。RN 架构下用原生 Module 事件桥更原生:
原生服务进程(无障碍/通知/短信)
↓ 触发事件(如 OCR 识别到账单、收到通知)
原生 Module(Kotlin)
↓ NativeEventEmitter.emit('billDetected', payload)
RN JS 主进程
↓ 监听事件
领域层(统一入库入口)
↓ 走 billPipeline(转账识别 → 去重 → 资产映射 → 分类 → 入库)
SQLite 缓存 + mobile.bean
关键点:
- 原生服务不直接写库,只通过事件桥把原始数据推给 JS 层
- JS 层的
billPipeline是唯一入库入口,保证去重/转账/分类逻辑统一(避免 AutoAccounting 那样各通道各自处理) - 并发控制:JS 层用 Promise 队列串行化(参考 AutoAccounting 的
deduplicationMutex),见 Phase 2 的billPipeline
对 plan 的影响:Phase 3(OCR)、Phase 4(通知/短信)的"原生服务直接 sendToReactNative"描述改为"通过 NativeEventEmitter 事件桥推送给 JS 层 billPipeline"。
决策 7:OCR 引擎改用 ONNX Runtime(替代 NCNN)
问题:原方案(与参考项目 AutoAccounting 一致)用 PP-OCRv5 + NCNN。但 NCNN 的模型获取链路在 Windows 开发机上极其折腾:需 Paddle → ONNX → NCNN 两步转换,onnx2ncnn 在 Windows 上通常要自编 NCNN 源码或下预编译包,对贡献者不友好。
方案:改用 ONNX Runtime Android(com.microsoft.onnxruntime:onnxruntime-android)。
| 维度 | NCNN(原方案) | ONNX Runtime(新方案) |
|---|---|---|
| 模型获取 | Paddle → ONNX → NCNN 两步转换,Windows 地狱 |
Paddle → ONNX 一步转换,或直接下社区现成 ONNX |
| 移动端体积 | 最小(~3MB) | 中(~10MB) |
| 移动端速度 | 最快(ARM NEON/Vulkan 深度优化) | 略逊但 OCR 场景足够 |
| 跨平台 | 主推 Android/iOS | 全平台(含桌面) |
| 维护 | 腾讯小众 | 微软官方,文档好 |
已落地:
- 模型:从 ilaylow/PP_OCRv5_mobile_onnx 直接下载
ppocrv5_det.onnx/ppocrv5_rec.onnx(社区维护,基于官方 Paddle 权重转换,无质量损失) - 字典:
ppocr_keys_v1.txt(PaddleOCR 标准 CJK 字典) plugins/ppocr/android/OcrModule.kt:完全重写,含 det DB 后处理 + rec CTC 贪心解码plugins/ppocr/android/OcrPackage.kt:新增,注册到MainApplication.getPackages()plugins/ppocr/app.plugin.js:gradle 依赖改为onnxruntime-android:1.20.1
模型 I/O 规格(实测 onnx 库解析):
- det 输入
x:[N, 3, H, W]动态尺寸,输出[N, 1, H, W]概率图 - rec 输入
x:[N, 3, 48, W]固定高 48,输出[N, T, 18385](18385 = 字典 + blank 的 CTC 类数)
对 plan 的影响:本节以下所有 NCNN 描述均订正为 ONNX Runtime。模型文件名从 .ncnn.{bin,param} 改为 .onnx。
已实现的核心功能(原型验证通过)
范围说明:以下领域逻辑真实可用、有单测覆盖(
tests/accounting.test.ts),但属于原型验证级,语法覆盖率和工程化程度有限。各文件当前规模与后续增强计划见下。
src/domain/
├── types.ts ✓ 完整的类型定义(26 行)
├── ledger.ts ✓ Beancount 解析器 + 验证器(49 行,正则 demo)
├── decimal.ts ✓ 精度计算(避免浮点,26 行)
├── rules.ts ✓ 规则引擎 + 分类(30 行)
├── statements.ts ✓ CSV 账单导入 + 去重(45 行)
├── mobile.ts ✓ mobile.bean 序列化(11 行)
└── index.ts ✓ 领域接口导出
src/storage/
└── ledgerRepository.ts ✓ SQLite 缓存层(33 行)
当前解析器覆盖率与增强计划
ledger.ts 当前为正则 demo,覆盖的 Beancount 语法:
| 语法 | 当前支持 | 后续(Step 1 增强) |
|---|---|---|
open/close 账户 |
✅ | - |
交易(*/! flag + payee + narration) |
✅ | 补 metadata、多行 posting |
| posting(account + amount + currency) | ✅ | 补 cost {...}、price @ |
#tag / ^link |
✅(仅 header 行) | 补 posting 行的 tag |
option/plugin/commodity/balance/pad/event/note/document/query/custom |
⚠️ 只读保留(记入 unsupported) |
补 balance assertion 解析 |
注释(;) |
⚠️ 部分支持 | 补全 |
include |
✅(文件级) | - |
Tree-sitter 决策:MVP 阶段继续用增强版正则(Step 1),覆盖常见语法。当真实用户 .bean 的复杂语法(如嵌套 metadata、custom 指令)导致正则方案维护成本过高时,再引入 tree-sitter-beancount 原生模块(通过 Config Plugin 打包)。决策依据:先用正则方案积累真实用户账本的兼容性数据,再决定是否上 tree-sitter。
mobile.bean 写入的当前局限与增强计划
mobile.ts 当前的 commitMobileTransaction 是纯函数追加,未处理:
- 并发写入(多通道同时到达)
- 原子性(写入中途崩溃导致文件损坏)
- 大文件性能(万条交易后每次全量解析)
这些在 Step 2(mobile.bean 工程化) 解决,见 src/domain/mobileStore.ts。
数据架构:数据库作为读缓存
核心原则
.bean 文件是唯一权威数据源,SQLite 仅作为读缓存,不存储业务状态。
.bean 文件 ──(解析)──→ SQLite 缓存 ──(查询)──→ UI
↑ ↓
└────(写入 mobile.bean)───┘
- 写入:只写
mobile.bean,不直接写数据库 - 读取:从数据库读(快),数据库由解析
.bean文件填充 - 同步:文件变更时重建缓存
- 恢复:数据库可从文件随时重建,丢失无影响
为什么需要数据库
| 问题 | 纯文件方案 | 数据库缓存方案 |
|---|---|---|
| UI 渲染 | 每次滚动都重新解析,卡顿 | 从索引读取,毫秒级 |
| 交易搜索 | 全量解析 + 内存过滤,O(n) | SQL WHERE + 索引,O(log n) |
| 去重查询 | 每笔遍历全量交易 | 按时间窗口 + 金额索引查询 |
| 导入 1000 笔 | 解析 1000 次文件 | 批量 INSERT,一次解析 |
数据库表设计(最小化)
仅保留缓存必要的表,不存储任何"可从文件重建"以外的数据:
-- 缓存解析后的 .bean 文件内容
CREATE TABLE ledger_files (
path TEXT PRIMARY KEY,
content TEXT NOT NULL,
checksum TEXT NOT NULL
);
-- 缓存已导入的事件(用于去重查询)
CREATE TABLE imported_events (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
committed_transaction TEXT
);
-- 缓存用户规则(规则本身由用户管理,非 .bean 数据)
CREATE TABLE rules (
id TEXT PRIMARY KEY,
priority INTEGER NOT NULL,
payload TEXT NOT NULL
);
-- 缓存未确认的草稿(临时状态)
CREATE TABLE drafts (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
不建的表(按需在对应 Phase 中添加):
| 表 | 何时需要 | 说明 |
|---|---|---|
categories |
Phase 2 分类系统 | 用户自定义分类,非 .bean 数据 |
tags |
Phase 2 标签系统 | 用户自定义标签 |
budgets |
Phase 5 预算管理 | 用户预算配置 |
recurring |
Phase 5 周期记账 | 定期交易模板 |
attachments |
Phase 5 附件 | 交易附件元数据 |
exchange_rates |
Phase 5 多币种 | 汇率缓存 |
credit_cards |
Phase 2 信用卡 | 信用卡账单日/还款日/额度 |
sync_errors |
Phase 6 同步 | 同步失败记录 |
logs |
Phase 8 日志 | 应用日志 |
缓存失效策略
订正:原方案用
content.length作 checksum 不可靠(内容改了长度可能不变)。改为 FNV-1a hash(与ledger.ts现有的hash()一致),轻量且碰撞率足够低。
// 当 .bean 文件变更时,重建缓存
import { hash as fnvHash } from '../domain/ledger'; // 复用现有 FNV-1a
async function rebuildCache(ledger: LedgerIndex): Promise<void> {
// 1. 对比 checksum,仅重算变更文件
const cached = await db.getAllAsync<{ path: string; checksum: string }>(
'SELECT path, checksum FROM ledger_files'
);
const cachedMap = new Map(cached.map(r => [r.path, r.checksum]));
for (const file of ledger.files) {
const checksum = fnvHash(file.content); // 真实 hash,非 length
if (cachedMap.get(file.path) === checksum) continue;
// 文件变更,重新解析并更新缓存
await db.runAsync(
'INSERT OR REPLACE INTO ledger_files (path, content, checksum) VALUES (?, ?, ?)',
file.path, file.content, checksum
);
}
}
Phase 0: 基础架构(1-2 周)[P0]
奠定后续所有阶段的基础,后期改动成本极高,必须优先完成。
0.1 状态管理(Zustand)
参考 BeeCount 的 Riverpod 架构(29 个 provider 文件),采用 Zustand 作为轻量状态管理:
// src/store/ledgerStore.ts
interface LedgerState {
ledger: LedgerIndex | null;
transactions: Transaction[];
accounts: Map<string, Account>;
isLoading: boolean;
error: string | null;
// actions
loadLedger: (path: string) => Promise<void>;
addTransaction: (draft: TransactionDraft) => Promise<void>;
refresh: () => Promise<void>;
}
// src/store/importStore.ts
interface ImportState {
events: ImportedEvent[];
pendingConfirmations: TransactionDraft[];
dedupResult: DedupResult | null;
// actions
importCsv: (content: string, adapter: StatementAdapter) => Promise<void>;
confirmDraft: (draft: TransactionDraft) => Promise<void>;
rejectDraft: (id: string) => void;
}
// src/store/settingsStore.ts
interface SettingsState {
dedupConfig: DedupConfig;
aiVisionConfig: AiVisionConfig;
themeName: string; // 'light' | 'dark' | 'system' | 自定义主题名
locale: string;
// actions
updateDedupConfig: (config: Partial<DedupConfig>) => void;
setTheme: (name: string) => void;
}
0.2 数据库 Schema 版本与迁移
数据库是缓存层,表结构简单。采用版本号 + 迁移函数,但不预建未来表——按 Phase 需要逐步添加:
// src/storage/migrations.ts
const SCHEMA_VERSION = 1;
const MIGRATIONS: Record<number, string[]> = {
1: [
`CREATE TABLE IF NOT EXISTS ledger_files (path TEXT PRIMARY KEY, content TEXT NOT NULL, checksum TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS imported_events (id TEXT PRIMARY KEY, payload TEXT NOT NULL, committed_transaction TEXT)`,
`CREATE TABLE IF NOT EXISTS rules (id TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS drafts (id TEXT PRIMARY KEY, payload TEXT NOT NULL, created_at TEXT NOT NULL)`,
],
// 后续版本按 Phase 需要追加:
// 2: [`CREATE TABLE IF NOT EXISTS categories (...)`, ...],
// 3: [`CREATE TABLE IF NOT EXISTS tags (...)`, ...],
};
export async function migrateDb(db: SQLite.SQLiteDatabase): Promise<void> {
const current = await getSchemaVersion(db);
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
for (const sql of MIGRATIONS[v]) await db.execAsync(sql);
}
await setSchemaVersion(db, SCHEMA_VERSION);
}
缓存层丢失无影响——从
.bean文件重新解析即可重建。
0.3 错误处理策略
参考 AutoAccounting 的错误恢复,采用轻量方案——缓存层可重建,不需要持久化错误日志:
// src/utils/errorHandler.ts
enum ErrorCategory {
NETWORK = 'network',
DATABASE = 'database',
PARSING = 'parsing',
OCR = 'ocr',
FILE_SYSTEM = 'file_system',
}
interface AppError {
category: ErrorCategory;
message: string;
recoverable: boolean;
retryable: boolean;
context?: Record<string, unknown>;
}
// 全局错误边界 + 重试策略
function withRetry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) => {
if (retries <= 0) throw err;
return new Promise(resolve => setTimeout(resolve, delay)).then(() =>
withRetry(fn, retries - 1, delay * 2)
);
});
}
错误处理原则:
- 可恢复错误(网络超时、文件读取失败):自动重试,UI 显示提示
- 不可恢复错误(解析失败、格式错误):显示错误详情,引导用户修复
- 缓存层错误:丢弃缓存,从
.bean文件重建 - 开发阶段用
console.error,生产环境可选接入 Sentry(Phase 8)
0.4 应用锁与安全
参考 BeeCount 的 AppLockService(生物识别/PIN)+ 隐私模糊:
// src/services/security.ts
import * as LocalAuthentication from 'expo-local-authentication';
export async function authenticate(): Promise<boolean> {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) return true; // 无生物识别硬件,跳过
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!isEnrolled) return true;
return await LocalAuthentication.authenticateAsync({
promptMessage: '解锁记账应用',
disableDeviceFallback: false,
cancelLabel: '取消',
}).then(result => result.success);
}
在 _layout.tsx 中添加认证检查:
function RootLayout() {
const [authenticated, setAuthenticated] = useState(false);
useEffect(() => {
authenticate().then(setAuthenticated);
}, []);
if (!authenticated) return <LockScreen onRetry={() => authenticate().then(setAuthenticated)} />;
return <Stack />;
}
隐私模糊(Privacy Blur)
参考 BeeCount 的隐私模糊模式——切换应用时全屏模糊,不仅是锁屏:
// src/services/privacyBlur.ts
import { AppState } from 'react-native';
import { BlurView } from '@react-native-blur/blur';
export function usePrivacyBlur() {
const [isBlurred, setIsBlurred] = useState(false);
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'background') {
setIsBlurred(true); // 进入后台时模糊
} else if (state === 'active') {
// 延迟解除模糊,等待认证完成
setTimeout(() => setIsBlurred(false), 300);
}
});
return () => subscription.remove();
}, []);
return isBlurred;
}
0.5 本地备份与恢复
备份的核心是 .bean 文件(数据库可重建):
// src/services/backup.ts
import * as FileSystem from 'expo-file-system';
import * as DocumentPicker from 'expo-document-picker';
// 备份 = 导出 .bean 文件 + mobile.bean + 用户规则
export async function createBackup(ledgerPath: string): Promise<string> {
const backupDir = `${FileSystem.documentDirectory}backups/`;
await FileSystem.makeDirectoryAsync(backupDir, { intermediates: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${backupDir}backup-${timestamp}.zip`;
// 只备份源文件,数据库可从这些文件重建
const files = [ledgerPath, `${FileSystem.documentDirectory}mobile.bean`];
await createZip(files, backupPath);
return backupPath;
}
// 恢复 = 导入 .bean 文件 → 自动重建缓存
export async function restoreBackup(): Promise<boolean> {
const result = await DocumentPicker.getDocumentAsync({
type: 'application/zip',
copyToCacheDirectory: true,
});
if (result.canceled) return false;
await extractZip(result.assets[0].uri);
// 恢复后自动从 .bean 文件重建数据库缓存
await rebuildCacheFromFiles();
return true;
}
mobile.bean 写入的并发安全与原子性
新增(审查订正):
mobile.bean是唯一写入目标,多通道(手动、CSV、OCR、通知、短信)可能并发写入,必须保证并发安全与崩溃恢复。领域层的实现在 Step 2(src/domain/mobileStore.ts),此处描述设计。
并发写入锁(Promise 队列):
// src/domain/mobileStore.ts(Step 2 实现)
// 所有写入串行化,避免多通道并发导致的内容交错/丢失
class MobileBeanStore {
private writeQueue: Promise<string> = Promise.resolve('');
async append(draft: TransactionDraft, ledger: LedgerIndex): Promise<MobileCommit> {
// 串行化:每个 append 等前一个完成
this.writeQueue = this.writeQueue.then(current =>
commitMobileTransaction(draft, ledger, current).content
);
const content = await this.writeQueue;
await this.persist(content); // 原子写入
return { content, diff: serializeTransaction(draft) };
}
}
原子写入(临时文件 + rename):
// 写入中途崩溃不会损坏原文件
async function persist(content: string): Promise<void> {
const path = `${FileSystem.documentDirectory}mobile.bean`;
const tmpPath = `${path}.tmp`;
// 1. 写入临时文件
await FileSystem.writeAsStringAsync(tmpPath, content);
// 2. rename 原子替换(expo-file-system 的 moveAsync 在同分区是原子操作)
await FileSystem.deleteAsync(path, { idempotent: true });
await FileSystem.moveAsync({ from: tmpPath, to: path });
}
崩溃恢复(WAL journal):
// 启动时检查:若有 .tmp 残留,说明上次写入未完成,丢弃 tmp 保留原文件
async function recoverIfNeeded(): Promise<void> {
const tmpPath = `${FileSystem.documentDirectory}mobile.bean.tmp`;
const info = await FileSystem.getInfoAsync(tmpPath);
if (info.exists) {
// tmp 残留 = 上次写入未完成,安全删除(原文件未被替换)
await FileSystem.deleteAsync(tmpPath, { idempotent: true });
logger.warn('mobileStore', '检测到未完成的写入 tmp 文件,已清理');
}
}
大文件性能(万条交易后):
- mobile.bean 增长到一定规模(如 >5000 条)后,每次全量读写变慢
- 缓解:SQLite 缓存承担读(UI 不直接读文件),写入只做 append(不重写整个文件)
- 进一步优化(v2):mobile.bean 按月分文件(
mobile-2026-07.bean),主mobile.bean仅include各月文件
0.6 日志系统
参考 AutoAccounting 的 Logger + BeeCount 的 Log Center:
// src/utils/logger.ts
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
interface LogEntry {
timestamp: string;
level: LogLevel;
tag: string;
message: string;
data?: unknown;
}
class Logger {
private buffer: LogEntry[] = [];
private maxBuffer = 1000;
debug(tag: string, message: string, data?: unknown) {
this.log(LogLevel.DEBUG, tag, message, data);
}
info(tag: string, message: string, data?: unknown) {
this.log(LogLevel.INFO, tag, message, data);
}
warn(tag: string, message: string, data?: unknown) {
this.log(LogLevel.WARN, tag, message, data);
}
error(tag: string, message: string, data?: unknown) {
this.log(LogLevel.ERROR, tag, message, data);
}
private log(level: LogLevel, tag: string, message: string, data?: unknown) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
tag,
message,
data,
};
this.buffer.push(entry);
if (this.buffer.length > this.maxBuffer) {
this.buffer.shift();
}
// 开发环境输出到 console
if (__DEV__) {
const prefix = `[${tag}]`;
switch (level) {
case LogLevel.DEBUG: console.debug(prefix, message, data); break;
case LogLevel.INFO: console.info(prefix, message, data); break;
case LogLevel.WARN: console.warn(prefix, message, data); break;
case LogLevel.ERROR: console.error(prefix, message, data); break;
}
}
}
getLogs(): LogEntry[] { return [...this.buffer]; }
clearLogs() { this.buffer = []; }
}
export const logger = new Logger();
0.7 引导流程(Onboarding)
参考 BeeCount 的 Introduction 流程,新用户首次使用时引导完成基础配置:
// src/app/_onboarding.tsx
function OnboardingScreen() {
const [step, setStep] = useState(0);
const steps = [
{ title: '欢迎使用 beancount-mobile', description: '基于 Beancount 的移动端记账应用' },
{ title: '选择语言', component: <LanguagePicker /> },
{ title: '选择主题', component: <ThemePicker /> },
{ title: '导入账本', component: <LedgerImporter /> },
{ title: '启用功能', component: <FeatureToggle /> }, // OCR、通知监听等
{ title: '设置安全', component: <SecuritySetup /> }, // 应用锁
];
return (
<View>
<OnboardingStep step={steps[step]} />
<View style={styles.buttons}>
{step > 0 && <Button onPress={() => setStep(step - 1)}>上一步</Button>}
{step < steps.length - 1
? <Button onPress={() => setStep(step + 1)}>下一步</Button>
: <Button onPress={completeOnboarding}>开始使用</Button>
}
</View>
<DotsIndicator total={steps.length} current={step} />
</View>
);
}
引导流程内容:
- 欢迎页 + 应用介绍
- 语言选择(中/英)
- 主题选择(浅色/深色/跟随系统)
- 账本导入(选择 .bean 文件或新建)
- 功能启用(OCR、通知监听、短信监听的权限说明)
- 安全设置(应用锁、隐私模糊)
Phase 1: UI 框架搭建(2 周)[P0]
1.1 项目结构重组
src/
├── app/ # Expo Router 页面
│ ├── (tabs)/ # 底部导航
│ │ ├── index.tsx # 首页(账本概览)
│ │ ├── transactions.tsx # 交易列表(搜索/筛选)
│ │ ├── import.tsx # 导入账单
│ │ ├── rules.tsx # 规则管理
│ │ └── settings.tsx # 设置
│ ├── transaction/
│ │ ├── new.tsx # 新增交易
│ │ └── [id].tsx # 交易详情/编辑
│ ├── category/
│ │ └── index.tsx # 分类管理
│ ├── tag/
│ │ └── index.tsx # 标签管理
│ ├── budget/
│ │ └── index.tsx # 预算管理
│ ├── calendar/
│ │ └── index.tsx # 日历视图
│ ├── credit-card/
│ │ └── index.tsx # 信用卡管理
│ └── _layout.tsx # 根布局
├── components/ # 可复用组件
│ ├── TransactionCard.tsx
│ ├── AccountTree.tsx
│ ├── PostingEditor.tsx
│ ├── BalanceIndicator.tsx
│ ├── DedupBanner.tsx # 去重提示组件
│ ├── SearchBar.tsx # 搜索栏
│ ├── CategoryPicker.tsx # 分类选择器
│ ├── TagPicker.tsx # 标签选择器
│ ├── CalendarView.tsx # 日历视图组件
│ ├── SpeedDial.tsx # 快速操作按钮(长按展开)
│ └── charts/ # 图表组件
│ ├── MonthlyReport.tsx
│ ├── CategoryPie.tsx
│ ├── TrendLine.tsx
│ ├── AnnualReport.tsx # 年度报告
│ ├── NetWorthChart.tsx # 净资产趋势
│ └── CalendarHeatmap.tsx # 日历热力图
├── domain/ # 已有核心逻辑(保持不变)
├── storage/ # SQLite 缓存层(可从 .bean 重建)
│ ├── ledgerRepository.ts
│ └── migrations.ts
├── store/ # Zustand 状态管理
│ ├── ledgerStore.ts
│ ├── importStore.ts
│ └── settingsStore.ts
├── hooks/ # 自定义 Hooks
│ ├── useLedger.ts
│ ├── useImport.ts
│ ├── useRules.ts
│ ├── useSearch.ts # 交易搜索
│ ├── useCategory.ts # 分类管理
│ └── usePrivacyBlur.ts # 隐私模糊
├── services/ # 业务逻辑服务
│ ├── security.ts # 应用锁
│ ├── backup.ts # 备份恢复(备份 .bean 文件,非数据库)
│ ├── privacyBlur.ts # 隐私模糊
│ ├── deepLink.ts # 深度链接处理
│ └── crash.ts # 崩溃上报
├── i18n/ # 国际化
│ ├── index.ts
│ ├── zh.ts
│ └── en.ts
├── theme/ # 主题系统(Token 化,可扩展)
│ ├── index.ts # ThemeProvider + useTheme
│ ├── tokens.ts # ThemeTokens 接口定义
│ ├── presets.ts # light/dark 预置主题
│ ├── createTheme.ts # 自定义主题工厂
│ └── storage.ts # 主题持久化 + 导入导出
├── ocr/ # OCR 模块(Android)
│ ├── OcrBridge.ts
│ ├── OcrProcessor.ts
│ └── AiVisionProcessor.ts
└── utils/ # 工具函数
├── errorHandler.ts
└── logger.ts
1.2 主题系统(Token 化,可扩展)
参考 BeeCount 的设计令牌系统,采用 Token 化架构——组件不直接引用颜色值,而是引用语义化令牌,主题通过令牌映射实现。
设计令牌定义
// src/theme/tokens.ts
// 语义化令牌:组件引用这些名称,不关心具体颜色
export interface ThemeTokens {
// 色彩
colors: {
// 背景层级
bgPrimary: string; // 页面背景
bgSecondary: string; // 卡片/容器背景
bgTertiary: string; // 输入框/次级容器
// 前景色
fgPrimary: string; // 主要文字
fgSecondary: string; // 次要文字
fgInverse: string; // 反色文字(用于深色背景上的文字)
// 强调色(主题色)
accent: string; // 主题色(按钮、链接、选中态)
accentLight: string; // 主题色浅色变体(背景高亮)
accentDark: string; // 主题色深色变体
// 语义色
success: string;
warning: string;
error: string;
info: string;
// 财务语义色
income: string; // 收入色(默认绿色)
expense: string; // 支出色(默认红色)
transfer: string; // 转账色(默认蓝色)
// 边框与分割
border: string;
divider: string;
// 特殊场景
overlay: string; // 遮罩层
skeleton: string; // 骨架屏
blur: string; // 隐私模糊背景
};
// 间距
spacing: {
xs: number;
sm: number;
md: number;
lg: number;
xl: number;
};
// 圆角
radii: {
sm: number;
md: number;
lg: number;
full: number;
};
// 字体
typography: {
h1: { fontSize: number; fontWeight: string; lineHeight: number };
h2: { fontSize: number; fontWeight: string; lineHeight: number };
h3: { fontSize: number; fontWeight: string; lineHeight: number };
body: { fontSize: number; fontWeight: string; lineHeight: number };
bodySmall: { fontSize: number; fontWeight: string; lineHeight: number };
caption: { fontSize: number; fontWeight: string; lineHeight: number };
};
// 阴影
shadows: {
sm: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
md: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
lg: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
};
}
预置主题
// src/theme/presets.ts
import type { ThemeTokens } from './tokens';
// 浅色主题
export const lightTheme: ThemeTokens = {
colors: {
bgPrimary: '#FFFFFF',
bgSecondary: '#F5F5F5',
bgTertiary: '#EEEEEE',
fgPrimary: '#212121',
fgSecondary: '#757575',
fgInverse: '#FFFFFF',
accent: '#2196F3',
accentLight: '#E3F2FD',
accentDark: '#1565C0',
success: '#4CAF50',
warning: '#FF9800',
error: '#F44336',
info: '#2196F3',
income: '#4CAF50',
expense: '#F44336',
transfer: '#2196F3',
border: '#E0E0E0',
divider: '#EEEEEE',
overlay: 'rgba(0,0,0,0.5)',
skeleton: '#E0E0E0',
blur: 'rgba(255,255,255,0.8)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 4, md: 8, lg: 16, full: 9999 },
typography: {
h1: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
h2: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
h3: { fontSize: 16, fontWeight: '600', lineHeight: 24 },
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
},
shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 3 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 5 },
},
};
// 暗色主题(OLED 纯黑)
export const darkTheme: ThemeTokens = {
...lightTheme,
colors: {
bgPrimary: '#000000',
bgSecondary: '#121212',
bgTertiary: '#1E1E1E',
fgPrimary: '#FFFFFF',
fgSecondary: '#B0B0B0',
fgInverse: '#000000',
accent: '#64B5F6',
accentLight: '#1A237E',
accentDark: '#90CAF9',
success: '#66BB6A',
warning: '#FFB74D',
error: '#EF5350',
info: '#64B5F6',
income: '#66BB6A',
expense: '#EF5350',
transfer: '#64B5F6',
border: '#2C2C2C',
divider: '#1E1E1E',
overlay: 'rgba(0,0,0,0.7)',
skeleton: '#2C2C2C',
blur: 'rgba(0,0,0,0.8)',
},
shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.3, shadowRadius: 2, elevation: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.4, shadowRadius: 4, elevation: 3 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.5, shadowRadius: 8, elevation: 5 },
},
};
// 预置主题注册表
export const presetThemes: Record<string, ThemeTokens> = {
light: lightTheme,
dark: darkTheme,
};
自定义主题工厂
// src/theme/createTheme.ts
import type { ThemeTokens } from './tokens';
import { lightTheme } from './presets';
// 用户只需提供覆盖项,其余从基础主题继承
export function createTheme(overrides: Partial<ThemeTokens> & { name?: string }): ThemeTokens {
return {
...lightTheme,
...overrides,
colors: { ...lightTheme.colors, ...overrides.colors },
spacing: { ...lightTheme.spacing, ...overrides.spacing },
radii: { ...lightTheme.radii, ...overrides.radii },
typography: { ...lightTheme.typography, ...overrides.typography },
shadows: { ...lightTheme.shadows, ...overrides.shadows },
};
}
// 示例:用户创建"森林绿"主题
const forestTheme = createTheme({
name: 'forest',
colors: {
...lightTheme.colors,
accent: '#2E7D32',
accentLight: '#E8F5E9',
accentDark: '#1B5E20',
},
});
ThemeProvider
// src/theme/index.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useColorScheme } from 'react-native';
import type { ThemeTokens } from './tokens';
import { presetThemes } from './presets';
import { loadTheme, saveTheme } from './storage';
interface ThemeContextValue {
theme: ThemeTokens;
themeName: string;
isDark: boolean;
setTheme: (name: string) => void;
registerTheme: (name: string, tokens: ThemeTokens) => void;
}
const ThemeContext = createContext<ThemeContextValue>(null!);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemColorScheme = useColorScheme();
const [themeName, setThemeName] = useState('system');
const [customThemes, setCustomThemes] = useState<Record<string, ThemeTokens>>({});
// 加载持久化的主题偏好
useEffect(() => {
loadTheme().then(saved => {
if (saved) setThemeName(saved);
});
}, []);
// 解析实际主题
const resolvedName = themeName === 'system'
? (systemColorScheme === 'dark' ? 'dark' : 'light')
: themeName;
const theme = customThemes[resolvedName]
?? presetThemes[resolvedName]
?? lightTheme;
const isDark = resolvedName === 'dark' || theme.colors.bgPrimary === '#000000';
const setTheme = (name: string) => {
setThemeName(name);
saveTheme(name);
};
const registerTheme = (name: string, tokens: ThemeTokens) => {
setCustomThemes(prev => ({ ...prev, [name]: tokens }));
};
return (
<ThemeContext.Provider value={{ theme, themeName, isDark, setTheme, registerTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
组件使用方式
// 组件通过 useTheme() 获取令牌,不硬编码颜色
function TransactionCard({ transaction }: { transaction: Transaction }) {
const { theme } = useTheme();
return (
<View style={{
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.md,
padding: theme.spacing.md,
...theme.shadows.sm,
}}>
<Text style={{
color: theme.colors.fgPrimary,
...theme.typography.body,
}}>
{transaction.narration}
</Text>
<Text style={{
color: theme.colors.fgSecondary,
...theme.typography.caption,
}}>
{transaction.date}
</Text>
</View>
);
}
主题持久化与导入/导出
// src/theme/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
const THEME_KEY = '@theme_preference';
const CUSTOM_THEMES_KEY = '@custom_themes';
export async function loadTheme(): Promise<string | null> {
return await AsyncStorage.getItem(THEME_KEY);
}
export async function saveTheme(name: string): Promise<void> {
await AsyncStorage.setItem(THEME_KEY, name);
}
// 自定义主题持久化
export async function loadCustomThemes(): Promise<Record<string, ThemeTokens>> {
const json = await AsyncStorage.getItem(CUSTOM_THEMES_KEY);
return json ? JSON.parse(json) : {};
}
export async function saveCustomTheme(name: string, tokens: ThemeTokens): Promise<void> {
const themes = await loadCustomThemes();
themes[name] = tokens;
await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(themes));
}
// 主题导出为 JSON(用户可分享主题)
export function exportTheme(name: string, tokens: ThemeTokens): string {
return JSON.stringify({ name, tokens }, null, 2);
}
// 主题导入
export function importTheme(json: string): { name: string; tokens: ThemeTokens } {
const data = JSON.parse(json);
if (!data.name || !data.tokens) throw new Error('Invalid theme file');
return data;
}
设置页面主题选择
function ThemeSettings() {
const { themeName, setTheme, registerTheme } = useTheme();
return (
<View>
<Text>主题模式</Text>
<SegmentedControl
values={['浅色', '深色', '跟随系统']}
selectedIndex={themeName === 'dark' ? 1 : themeName === 'system' ? 2 : 0}
onChange={(index) => setTheme(['light', 'dark', 'system'][index])}
/>
<Text>自定义主题</Text>
<Button onPress={() => importAndRegisterTheme()}>导入主题</Button>
<Button onPress={() => exportCurrentTheme()}>导出当前主题</Button>
</View>
);
}
主题扩展点
| 扩展方式 | 说明 | 示例 |
|---|---|---|
| 修改令牌值 | 覆盖预置主题的颜色/间距 | createTheme({ colors: { accent: '#FF5722' } }) |
| 创建新主题 | 基于模板完全自定义 | 注册"森林"/"海洋"/"日落"等主题 |
| 动态主题 | 运行时切换 | 根据时间/天气自动切换 |
| 主题分享 | JSON 导入导出 | 用户间分享自定义主题 |
| 第三方主题 | 通过插件系统 | 社区贡献主题包 |
1.3 国际化
// src/i18n/index.ts
import { I18n } from 'i18n-js';
import zh from './zh';
import en from './en';
const i18n = new I18n({ zh, en });
i18n.defaultLocale = 'zh';
i18n.enableFallback = true;
export default i18n;
// src/i18n/zh.ts
export default {
home: { title: '首页', balance: '余额' },
transaction: { new: '记一笔', edit: '编辑交易', search: '搜索交易' },
import: { title: '导入账单', csv: 'CSV 导入', ocr: 'OCR 识别' },
category: { title: '分类管理', add: '添加分类' },
tag: { title: '标签管理', add: '添加标签' },
budget: { title: '预算管理', set: '设置预算' },
settings: { title: '设置', theme: '主题', language: '语言', backup: '备份' },
calendar: { title: '日历', today: '今天', thisMonth: '本月' },
creditCard: { title: '信用卡', billingDay: '账单日', paymentDay: '还款日' },
};
1.4 核心页面实现
首页(账本概览)
function HomeScreen() {
const { ledger } = useLedgerStore();
return (
<View>
<AccountSummary accounts={ledger?.accounts} />
<RecentTransactions transactions={ledger?.transactions.slice(0, 10)} />
<MonthlyReport transactions={ledger?.transactions} />
</View>
);
}
Speed Dial 快速操作
参考 BeeCount 的 Speed Dial FAB——长按添加按钮弹出快捷操作:
function SpeedDial() {
const [isOpen, setIsOpen] = useState(false);
return (
<View style={styles.fabContainer}>
{isOpen && (
<View style={styles.actions}>
<FAB icon="edit" onPress={addManualTransaction} label="手动记账" />
<FAB icon="camera" onPress={takePhotoOcr} label="拍照识别" />
<FAB icon="image" onPress={pickImageOcr} label="相册识别" />
<FAB icon="mic" onPress={startVoiceInput} label="语音记账" />
</View>
)}
<FAB
icon={isOpen ? 'close' : 'add'}
onPress={() => setIsOpen(!isOpen)}
onLongPress={() => setIsOpen(true)}
size="large"
/>
</View>
);
}
记账页面(复式分录)
function TransactionEditor() {
const [postings, setPostings] = useState<Posting[]>([
{ account: '', amount: '', currency: 'CNY' },
{ account: '', amount: '', currency: 'CNY' },
]);
const balance = calculateBalance(postings);
return (
<View>
<PostingsList postings={postings} onChange={setPostings} />
<BalanceIndicator balance={balance} />
<CategoryPicker onSelect={handleCategorySelect} />
<TagPicker selected={tags} onToggle={handleTagToggle} />
<Button onPress={saveTransaction}>保存</Button>
</View>
);
}
交易搜索页面
参考 BeeCount 的多维筛选(关键字/分类/账户/日期/标签):
function TransactionSearchScreen() {
const [query, setQuery] = useState('');
const [filters, setFilters] = useState<SearchFilters>({
category: undefined,
account: undefined,
dateRange: undefined,
tags: [],
direction: undefined,
});
const results = useSearch(query, filters);
return (
<View>
<SearchBar value={query} onChangeText={setQuery} />
<FilterChips filters={filters} onFilterChange={setFilters} />
<FlatList data={results} renderItem={TransactionCard} />
</View>
);
}
1.5 深度链接(Deep Link)
参考 BeeCount 的 beecount:// URL scheme:
// src/services/deepLink.ts
import * as Linking from 'expo-linking';
const PREFIX = 'beancount://';
type DeepLinkAction =
| { type: 'add-transaction'; draft?: Partial<TransactionDraft> }
| { type: 'ocr-camera' }
| { type: 'ocr-image'; imageUri?: string }
| { type: 'voice-input' }
| { type: 'import-csv' }
| { type: 'open-tab'; tab: 'home' | 'transactions' | 'import' | 'rules' | 'settings' };
export function parseDeepLink(url: string): DeepLinkAction | null {
const path = url.replace(PREFIX, '');
const [action, ...params] = path.split('/');
switch (action) {
case 'add':
return { type: 'add-transaction', draft: parseDraftParams(params) };
case 'ocr':
if (params[0] === 'camera') return { type: 'ocr-camera' };
if (params[0] === 'image') return { type: 'ocr-image', imageUri: params[1] };
return null;
case 'voice':
return { type: 'voice-input' };
case 'import':
return { type: 'import-csv' };
case 'tab':
return { type: 'open-tab', tab: (params[0] as any) ?? 'home' };
default:
return null;
}
}
// 在 App 入口注册深度链接处理
export function setupDeepLinking() {
Linking.addEventListener('url', ({ url }) => {
const action = parseDeepLink(url);
if (action) handleDeepLink(action);
});
// 处理冷启动时的深度链接
Linking.getInitialURL().then(url => {
if (url) {
const action = parseDeepLink(url);
if (action) handleDeepLink(action);
}
});
}
1.6 依赖添加
{
"dependencies": {
"expo-router": "~4.0.0",
"expo-document-picker": "~13.0.0",
"expo-file-system": "~19.0.0",
"expo-local-authentication": "~14.0.0",
"expo-localization": "~15.0.0",
"expo-linking": "~7.0.0",
"@expo/vector-icons": "^14.0.0",
"react-native-reanimated": "~3.17.0",
"react-native-gesture-handler": "~2.24.0",
"zustand": "^5.0.0",
"i18n-js": "^4.0.0",
"react-native-chart-kit": "^6.12.0",
"@react-native-async-storage/async-storage": "^2.0.0",
"quickjs-emscripten": "^0.23.0"
}
}
Phase 2: 核心功能完善(3-4 周)[P0]
2.1 账本导入流程
用户选择 .bean 文件
↓
解析主文件 + include 引用
↓
建立 LedgerIndex(账户、交易、诊断)
↓
缓存到 SQLite(带 schema 迁移检查)
↓
显示账本概览
2.2 交易管理
- 创建交易:支持支出、收入、转账、自定义分录
- 编辑交易:仅限
mobile.bean内的交易 - 删除交易:从
mobile.bean中移除 - 交易验证:实时校验借贷平衡、账户状态
- 交易搜索:多维筛选(关键字/分类/账户/日期/标签/方向)
- 交易标志:支持
excludeFromStats(不计入统计)和excludeFromBudget(不计入预算)
交易标志系统
参考 AutoAccounting 的 Bill Flag + BeeCount 的 transaction flags:
// src/domain/transactionFlags.ts
export enum TransactionFlag {
NONE = 0,
EXCLUDE_FROM_STATS = 1 << 0, // 不计入统计图表(但影响余额)
EXCLUDE_FROM_BUDGET = 1 << 1, // 不计入预算使用
TRANSFER = 1 << 2, // 标记为转账(自动识别)
RECURRING = 1 << 3, // 周期性交易模板
}
export function hasFlag(flags: number, flag: TransactionFlag): boolean {
return (flags & flag) !== 0;
}
export function setFlag(flags: number, flag: TransactionFlag): number {
return flags | flag;
}
export function clearFlag(flags: number, flag: TransactionFlag): number {
return flags & ~flag;
}
2.3 分类系统(双轨制)
订正:采用「设计决策 1」的双轨制。分类匹配算法参考 BeeCount 的
CategoryMatcher,但分类与 Beancount 账户的关系用linkedAccount映射,而非 BeeCount 的独立表。完整设计见前置章节「决策 1」。
// src/domain/categories.ts
export interface Category {
id: string;
name: string;
parentId?: string; // 层级(UI 展示用,不映射到 .bean)
type: 'income' | 'expense';
linkedAccount: string; // ★ 映射到 Beancount 账户,记账时作为 posting account
icon?: string; // UI 增强(仅本地)
color?: string; // UI 增强(仅本地)
keywords: string[]; // 自动匹配关键词(仅本地匹配逻辑)
}
// 分类匹配:精确匹配 → 关键词匹配 → 模糊匹配 → 规则匹配 → 兜底"其他"
export function matchCategory(
text: string,
categories: Category[]
): Category | null {
// 1. 精确匹配
const exact = categories.find(c => c.name === text);
if (exact) return exact;
// 2. 关键词匹配
const byKeyword = categories.find(c =>
c.keywords.some(k => text.includes(k))
);
if (byKeyword) return byKeyword;
// 3. 模糊匹配(包含关系)
const fuzzy = categories.find(c =>
text.includes(c.name) || c.name.includes(text)
);
if (fuzzy) return fuzzy;
// 4. 兜底
return categories.find(c => c.name === '其他') ?? null;
}
// ★ 映射失效处理:账户被桌面端删除/重命名时,记账前校验
export function resolveCategoryAccount(
category: Category,
ledger: LedgerIndex
): { account: string; valid: boolean; fallbackReason?: string } {
if (ledger.accounts.has(category.linkedAccount)) {
return { account: category.linkedAccount, valid: true };
}
// 优雅降级到 Uncategorized
const fallback = category.type === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized';
return {
account: fallback,
valid: false,
fallbackReason: `分类「${category.name}」映射的账户 ${category.linkedAccount} 已不存在,已降级`
};
}
分类管理页面:
function CategoryScreen() {
const [categories, setCategories] = useState<Category[]>([]);
return (
<View>
<SectionList
sections={[
{ title: '支出分类', data: categories.filter(c => c.type === 'expense') },
{ title: '收入分类', data: categories.filter(c => c.type === 'income') },
]}
renderItem={({ item }) => (
<CategoryItem
category={item}
onEdit={() => editCategory(item)}
onDelete={() => deleteCategory(item.id)}
/>
)}
renderSectionHeader={({ section }) => <Text>{section.title}</Text>}
/>
<FAB onPress={addCategory} />
</View>
);
}
2.4 标签系统(双轨制)
订正:标签采用双轨制。应用
tags表存 UI 增强(name/color),记账时写回.bean用 Beancount 原生#tag语法(见serializeTransaction)。
// src/domain/tags.ts
export interface Tag {
id: string;
name: string; // 同时作为 #tag 名(写回 .bean)
color: string; // UI 增强(仅本地,不写回 .bean)
}
// 交易标签关联(应用本地,用于 UI 筛选/统计;写回 .bean 时序列化为 #tag)
export interface TransactionTag {
transactionId: string;
tagId: string;
}
// 标签名 → #tag 语法的序列化(在 serializeTransaction 中已支持)
// 例:tagName 'food' → narration 末尾追加 ' #food'
export function tagsToBeanSyntax(tags: Tag[]): string {
return tags.map(t => ` #${t.name}`).join('');
}
标签管理 + 筛选:
function TagScreen() {
return (
<View>
<FlatList
data={tags}
renderItem={({ item }) => (
<TagChip tag={item} onPress={() => filterByTag(item.id)} />
)}
/>
</View>
);
}
2.5 CSV 导入增强
// 扩展适配器支持
type StatementAdapter =
| 'alipay-csv-v1' // 支付宝
| 'wechat-csv-v1' // 微信支付
| 'bank-csv-v1' // 通用银行
| 'cmb-csv-v1' // 招商银行
| 'icbc-csv-v1' // 工商银行
| 'custom-csv-v1'; // 自定义格式
补充招商银行和工商银行适配器解析逻辑:
// src/domain/adapters/cmb.ts
export function parseCmbCsv(content: string): ImportedEvent[] {
// 招商银行 CSV 格式:交易日期,交易时间,摘要,交易金额,余额,交易类型
const rows = parseCsv(content);
return rows.map(row => ({
id: `CMB:${row['交易序号']}`,
occurredAt: `${row['交易日期']} ${row['交易时间']}`,
amount: row['交易金额'],
currency: 'CNY',
direction: parseDirection(row['摘要']),
channel: 'Bank:CMB',
counterparty: row['交易对方'] || '',
memo: row['摘要'] || '',
raw: row,
}));
}
// src/domain/adapters/icbc.ts
export function parseIcbcCsv(content: string): ImportedEvent[] {
// 工商银行 CSV 格式:交易时间,交易类型,交易金额,余额,对方户名,对方账号,摘要
// ...类似实现
}
2.6 规则引擎增强(JS 规则执行器)
参考 AutoAccounting 的 QuickJS 规则引擎,支持用户自定义 JS 规则:
// src/domain/ruleEngine.ts
import { QuickJS } from 'quickjs-emscripten';
interface Rule {
id: string;
name: string;
priority: number;
enabled: boolean;
isSystem: boolean; // 系统规则 vs 用户规则
// 匹配条件(静态,用于快速预筛选)
channel?: string;
direction?: Direction;
counterpartyContains?: string;
memoContains?: string;
minAmount?: string;
maxAmount?: string;
currency?: string;
// JS 规则体(动态,用于精确匹配和字段提取)
jsCode?: string;
// 输出
channelAccount: string;
categoryAccount: string;
narration?: string;
tags?: string[];
// 统计
hits: number;
lastHitAt?: string;
}
// 规则执行器
class RuleEngine {
private vm: QuickJS;
async init() {
this.vm = await QuickJS.newQuickJS();
}
// 执行 JS 规则
async executeRule(rule: Rule, eventData: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (!rule.jsCode) return null;
const ctx = this.vm.newContext();
ctx.setProp(ctx.global, 'data', ctx.newObject());
// 注入事件数据
for (const [key, value] of Object.entries(eventData)) {
ctx.setProp(ctx.getProp(ctx.global, 'data'), key, ctx.newString(String(value)));
}
// 注入通用函数
ctx.setProp(ctx.global, 'print', ctx.newFunction('print', (msg) => {
console.log('[Rule]', msg);
return ctx.undefined;
}));
const result = ctx.evalCode(rule.jsCode);
if (result.error) {
console.error(`Rule ${rule.id} failed:`, result.error);
return null;
}
// 解析返回的 JSON
try {
return JSON.parse(result.value.toString());
} catch {
return null;
}
}
// 批量执行规则(用户规则优先,系统规则其次)
async matchEvent(
event: ImportedEvent,
rules: Rule[]
): Promise<{ rule: Rule; result: Record<string, unknown> } | null> {
// 按优先级排序:用户规则 > 系统规则
const sortedRules = rules
.filter(r => r.enabled)
.sort((a, b) => {
if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
return b.priority - a.priority;
});
for (const rule of sortedRules) {
// 静态条件预筛选
if (!matchStaticConditions(rule, event)) continue;
// JS 规则执行
if (rule.jsCode) {
const result = await this.executeRule(rule, eventToData(event));
if (result) {
rule.hits++;
rule.lastHitAt = new Date().toISOString();
return { rule, result };
}
} else {
// 无 JS 代码,静态匹配成功
rule.hits++;
rule.lastHitAt = new Date().toISOString();
return { rule, result: staticExtract(rule, event) };
}
}
return null;
}
}
2.7 转账智能识别
参考 AutoAccounting 的 TransferRecognizer + BillMerger。
执行顺序(审查订正):转账识别必须先于去重执行。参考 AutoAccounting 的
BillManager.groupBillInfo:先把 Income+Expend 配对识别为 Transfer,再对剩余账单去重。一笔账单既可能是转账的一方又可能是重复时,转账识别优先(合并为单条转账交易,而非丢弃重复)。完整责任链见 2.8 的billPipeline。
// src/domain/transferRecognizer.ts
import { compareDecimals, negateDecimal } from './decimal';
interface TransferPair {
left: ImportedEvent;
right: ImportedEvent;
confidence: 'high' | 'medium' | 'low';
reason: string;
}
// 转账识别:Income+Expend → Transfer
export function recognizeTransfers(events: ImportedEvent[]): TransferPair[] {
const pairs: TransferPair[] = [];
const used = new Set<string>();
for (const event of events) {
if (used.has(event.id)) continue;
// 寻找匹配的对端交易
const match = findTransferMatch(event, events, used);
if (match) {
pairs.push(match);
used.add(event.id);
used.add(match.left.id);
}
}
return pairs;
}
function findTransferMatch(
event: ImportedEvent,
allEvents: ImportedEvent[],
used: Set<string>
): TransferPair | null {
const TIME_WINDOW = 3 * 24 * 60 * 60 * 1000; // 3天
const eventTime = Date.parse(event.occurredAt);
for (const candidate of allEvents) {
if (used.has(candidate.id)) continue;
if (candidate.id === event.id) continue;
// 时间窗口检查
const candidateTime = Date.parse(candidate.occurredAt);
if (Math.abs(eventTime - candidateTime) > TIME_WINDOW) continue;
// 金额匹配(一正一负)
const eventAmount = parseFloat(event.amount);
const candidateAmount = parseFloat(candidate.amount);
if (compareDecimals(String(Math.abs(eventAmount)), String(Math.abs(candidateAmount))) !== 0) continue;
// 方向检查:必须一个是收入,一个是支出
if (event.direction === candidate.direction) continue;
// 账户关联检查
const confidence = checkAccountLink(event, candidate);
return {
left: event.direction === 'expense' ? event : candidate,
right: event.direction === 'income' ? event : candidate,
confidence: confidence.level,
reason: confidence.reason,
};
}
return null;
}
function checkAccountLink(
event: ImportedEvent,
candidate: ImportedEvent
): { level: 'high' | 'medium' | 'low'; reason: string } {
// 高置信度:已知关联账户(如支付宝→银行卡)
if (isKnownTransferChannel(event.channel, candidate.channel)) {
return { level: 'high', reason: `已知转账通道: ${event.channel} → ${candidate.channel}` };
}
// 中置信度:对手方匹配
if (event.counterparty && candidate.counterparty) {
if (event.counterparty.includes(candidate.counterparty) ||
candidate.counterparty.includes(event.counterparty)) {
return { level: 'medium', reason: `对手方匹配: ${event.counterparty}` };
}
}
// 低置信度:仅金额+时间匹配
return { level: 'low', reason: '金额+时间匹配' };
}
2.8 交易去重
覆盖场景:CSV 导入、OCR 识别、通知/短信监听、手工新增
去重方案设计
去重场景:
| 场景 | 触发方式 | 去重策略 |
|---|---|---|
| CSV 导入 | 用户选择文件 | 事件 ID + 时间窗口 + 金额匹配 |
| OCR 识别 | 无障碍服务截屏 | 时间窗口 + 金额 + 账户关联 |
| 通知监听 | 系统通知 | 通知内容哈希 + 时间窗口 |
| 短信监听 | 短信接收 | 短信内容哈希 + 时间窗口 |
| 手工新增 | 用户手动输入 | 时间 + 金额 + 账户 + 对手方 |
去重条件(必须同时满足):
- 时间窗口:同一笔交易的时间差异 ≤ 5 分钟
- 金额匹配:交易金额完全一致(绝对值比较)
- 账户关联:存在转账关系(如微信→银行卡)
多通道联合去重
参考 AutoAccounting 的跨通道去重——微信通知+银行短信+应用 Hook 对同一笔交易去重:
// src/domain/dedup.ts
import { compareDecimals } from './decimal';
import type { ImportedEvent, Transaction, TransactionDraft } from './types';
export interface DedupConfig {
enabled: boolean;
timeWindowMinutes: number; // 默认 5 分钟
accountLinks: AccountLink[]; // 账户关联配置
}
export interface AccountLink {
channel: string; // 渠道(如 'Alipay', 'WeChat')
linkedAccounts: string[]; // 关联账户(如 ['Bank:CMB', 'Bank:ICBC'])
}
export interface DedupResult {
isDuplicate: boolean;
reason?: string;
existingTransaction?: Transaction;
confidence: 'high' | 'low' | 'none';
}
// 生成去重指纹
export function generateDedupFingerprint(
date: string,
amount: string,
channel: string,
counterparty: string
): string {
const normalizedDate = date.slice(0, 16);
const normalizedAmount = Math.abs(parseFloat(amount)).toFixed(2);
const raw = `${normalizedDate}_${normalizedAmount}_${channel}_${counterparty}`;
return simpleHash(raw);
}
function simpleHash(str: string): string {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = Math.imul(31, h) + str.charCodeAt(i) | 0;
}
return (h >>> 0).toString(16);
}
// 检查是否重复(支持多通道)
export function checkDuplicate(
newEvent: ImportedEvent | TransactionDraft,
existingTransactions: Transaction[],
config: DedupConfig,
importedEvents: ImportedEvent[] = [] // 本次导入的事件(跨通道去重)
): DedupResult {
if (!config.enabled) {
return { isDuplicate: false, confidence: 'none' };
}
const newTime = 'occurredAt' in newEvent ? newEvent.occurredAt : newEvent.date;
const newAmount = 'amount' in newEvent ? newEvent.amount : newEvent.postings[0]?.amount ?? '0';
const newChannel = 'channel' in newEvent ? newEvent.channel : undefined;
const newCounterparty = 'counterparty' in newEvent ? newEvent.counterparty : newEvent.payee;
// 1. 与已有交易比较
for (const existing of existingTransactions) {
const result = compareEvents(newEvent, existing, config);
if (result.isDuplicate) return result;
}
// 2. 与本次导入的其他事件比较(跨通道去重)
for (const imported of importedEvents) {
if ('id' in newEvent && imported.id === (newEvent as ImportedEvent).id) continue;
const result = compareImportedEvents(newEvent as ImportedEvent, imported, config);
if (result.isDuplicate) return result;
}
return { isDuplicate: false, confidence: 'none' };
}
function compareEvents(
newEvent: ImportedEvent | TransactionDraft,
existing: Transaction,
config: DedupConfig
): DedupResult {
const newTime = 'occurredAt' in newEvent ? newEvent.occurredAt : newEvent.date;
const newAmount = 'amount' in newEvent ? newEvent.amount : newEvent.postings[0]?.amount ?? '0';
const newChannel = 'channel' in newEvent ? newEvent.channel : undefined;
const newCounterparty = 'counterparty' in newEvent ? newEvent.counterparty : newEvent.payee;
const timeDiff = Math.abs(Date.parse(newTime) - Date.parse(existing.date));
const maxDiff = config.timeWindowMinutes * 60 * 1000;
if (timeDiff > maxDiff) {
return { isDuplicate: false, confidence: 'none' };
}
const existingAmount = existing.postings[0]?.amount ?? '0';
if (compareDecimals(Math.abs(parseFloat(newAmount)).toFixed(2),
Math.abs(parseFloat(existingAmount)).toFixed(2)) !== 0) {
return { isDuplicate: false, confidence: 'none' };
}
// 账户关联检查
if (newChannel && config.accountLinks.length > 0) {
const isLinked = checkAccountLink(newChannel, existing, config.accountLinks);
if (!isLinked) {
return { isDuplicate: false, confidence: 'none' };
}
}
// 对手方匹配(高置信度)
if (newCounterparty && existing.payee) {
if (newCounterparty.includes(existing.payee) || existing.payee.includes(newCounterparty)) {
return {
isDuplicate: true,
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方匹配`,
existingTransaction: existing,
confidence: 'high'
};
}
}
return {
isDuplicate: true,
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`,
existingTransaction: existing,
confidence: 'low'
};
}
function checkAccountLink(
channel: string,
existing: Transaction,
accountLinks: AccountLink[]
): boolean {
const link = accountLinks.find(l => l.channel === channel);
if (!link) return true;
for (const posting of existing.postings) {
if (link.linkedAccounts.some(acc => posting.account.includes(acc))) {
return true;
}
}
return false;
}
// 批量去重(导入时使用)
export function batchDedup(
events: ImportedEvent[],
existingTransactions: Transaction[],
config: DedupConfig
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
const accepted: ImportedEvent[] = [];
const duplicates: ImportedEvent[] = [];
for (const event of events) {
const result = checkDuplicate(event, existingTransactions, config, accepted);
if (result.isDuplicate) {
duplicates.push(event);
} else {
accepted.push(event);
}
}
return { accepted, duplicates };
}
账单处理责任链(billPipeline)
新增(审查订正):参考 AutoAccounting 的
BillService.analyze+deduplicationMutex,统一所有入口(手动/CSV/OCR/通知/短信)的处理顺序与并发控制。这是决策 6「跨进程通信」中 JS 层的唯一入库入口。
执行顺序(必须严格遵循):
原始事件(手动/CSV/OCR/通知/短信)
↓
1. 转账识别(先于去重,合并 Income+Expend → Transfer)
↓
2. 多通道去重(时间窗口 + 金额 + 账户关联 + 渠道)
↓
3. 资产映射(规则 → AI → 算法兜底,确定 channelAccount)
↓
4. 分类映射(规则 → 关键词 → 模糊 → 兜底 Uncategorized,含 linkedAccount 校验)
↓
5. 备注生成(模板引擎)
↓
6. 串行入库(billPipeline 锁 + mobile.bean 原子写入)
并发串行化(参考 AutoAccounting deduplicationMutex):
// src/domain/billPipeline.ts(Step 4 实现)
export class BillPipeline {
private mutex: Promise<unknown> = Promise.resolve();
// 所有入口(手动/CSV/OCR/通知/短信)都经过此方法,保证串行
async process(events: ImportedEvent[], ctx: PipelineContext): Promise<ProcessResult> {
// withLock:串行化整个识别→去重→入库流程
return this.withLock(async () => {
// 1. 转账识别(先)
const { transfers, remaining } = recognizeTransfers(events);
// 2. 去重(后)
const { accepted } = batchDedup(remaining, ctx.existingTransactions, ctx.dedupConfig);
// 3-5. 资产映射 + 分类 + 备注
const drafts = [...transfers, ...accepted].map(e => this.enrich(e, ctx));
// 6. 串行写入 mobile.bean(MobileBeanStore 内部已有写入锁)
for (const draft of drafts) {
await ctx.mobileStore.append(draft.draft, ctx.ledger);
}
return { processed: drafts };
});
}
private withLock<T>(fn: () => Promise<T>): Promise<T> {
const run = this.mutex.then(fn, fn);
this.mutex = run.then(() => undefined, () => undefined);
return run;
}
}
为什么串行化重要:若 OCR 和通知同时识别到同一笔交易,并发处理会导致:转账识别漏配对、去重失效、mobile.bean 写入交错损坏。串行化是正确性前提,性能影响小(毫秒级)。
2.9 备注模板系统
参考 AutoAccounting 的 Remark Template Engine:
// src/domain/remarkTemplate.ts
interface RemarkTemplate {
id: string;
name: string;
template: string; // 支持占位符
enabled: boolean;
}
// 占位符定义
const PLACEHOLDERS: Record<string, (event: ImportedEvent) => string> = {
'【商户名称】': (e) => e.counterparty || '未知商户',
'【金额】': (e) => e.amount,
'【时间】': (e) => e.occurredAt.slice(11, 16), // HH:mm
'【日期】': (e) => e.occurredAt.slice(0, 10), // YYYY-MM-DD
'【渠道】': (e) => e.channel || '',
'【卡片】': (e) => e.memo?.match(/尾号(\d{4})/)?.[1] ?? '',
'【AI】': (e) => e.aiSuggestion || '', // AI 识别的备注
};
// 生成备注
export function generateRemark(
template: string,
event: ImportedEvent
): string {
let remark = template;
for (const [placeholder, resolver] of Object.entries(PLACEHOLDERS)) {
remark = remark.replace(new RegExp(placeholder.replace(/[【】]/g, '\\$&'), 'g'), resolver(event));
}
// 相邻重复归一化(如 "买菜买菜" → "买菜")
remark = normalizeAdjacentDuplicates(remark);
return remark;
}
// 相邻重复归一化
function normalizeAdjacentDuplicates(text: string): string {
// 检测连续重复的中文词组
return text.replace(/(.{2,4})\1+/g, '$1');
}
2.10 关键词过滤系统
参考 AutoAccounting 的 AnalysisUtils:
// src/domain/keywordFilter.ts
interface KeywordFilter {
whitelist: string[]; // 白名单:必须包含任一关键词
blacklist: string[]; // 黑名单:包含任一关键词则跳过
}
const DEFAULT_FILTER: KeywordFilter = {
whitelist: ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'],
blacklist: ['广告', '推广', '优惠券', '红包封面', '会员到期'],
};
export function passesKeywordFilter(
text: string,
filter: KeywordFilter = DEFAULT_FILTER
): boolean {
// 黑名单检查
if (filter.blacklist.some(kw => text.includes(kw))) {
return false;
}
// 白名单检查
if (filter.whitelist.length === 0) return true;
return filter.whitelist.some(kw => text.includes(kw));
}
Phase 3: OCR 自动记账(2-3 周,仅 Android)[P0]
参考 BeeCount 分层处理架构 + AutoAccounting 无障碍实现 PP-OCRv5 为核心 OCR 引擎,ML Kit 为备选
3.1 分层处理架构(参考 BeeCount)
输入(截图/通知/短信)
↓
Layer 1: 规则匹配(快速,无成本)
├─ 匹配成功 → 直接创建交易
└─ 匹配失败 ↓
Layer 2: 本地 OCR + 规则(中等精度,无成本)
├─ OCR 识别 → 规则匹配 → 创建交易
└─ 匹配失败 ↓
Layer 3: AI Vision(高精度,有成本)- 可选
└─ 调用云端 AI → 创建交易
设计原则:
- 优先使用快速、无成本的规则匹配
- 本地 OCR 作为主要识别手段(隐私保护)
- AI Vision 作为可选后备(需要用户启用)
- 每层失败后自动降级到下一层
3.2 原生模块架构(Config Plugin 形态)
订正:原方案的
android/app/src/main/java/...裸 Kotlin 目录会被expo prebuild清空(见决策 4)。改为 Config Plugin + RN Module 结构。
plugins/
├── accessibility/ # 无障碍服务
│ ├── app.plugin.js # Expo Config Plugin(注册 manifest service + xml)
│ └── android/
│ ├── OcrAccessibilityService.kt
│ ├── PageSignatureManager.kt
│ ├── BillParser.kt
│ ├── FloatingBillView.kt
│ ├── RuleMatcher.kt
│ └── res/xml/accessibility_service_config.xml
├── ppocr/ # PP-OCRv5 原生模块
│ ├── app.plugin.js # Config Plugin(打包 assets 模型 + native 依赖)
│ ├── android/
│ │ ├── OcrModule.kt # React Native Bridge
│ │ └── OcrManager.kt # OCR 处理(Layer 2)
│ └── assets/ # PP-OCRv5 ONNX 模型文件(det/rec 的 .onnx + 字典)
├── notification-listener/ # 通知监听
│ ├── app.plugin.js
│ └── android/NotificationListenerService.kt
├── sms-receiver/ # 短信监听
│ ├── app.plugin.js
│ └── android/SmsReceiver.kt
├── floating-window/ # 浮窗(权限注册)
│ └── app.plugin.js
└── ocr-tile/ # 快速设置磁贴
├── app.plugin.js
└── android/OcrTileService.kt
app.json 注册:
{ "plugins": ["./plugins/accessibility", "./plugins/ppocr", ...] }
每个 Config Plugin 的职责模板:
// plugins/accessibility/app.plugin.js
const { withAndroidManifest } = require('@expo/config-plugins');
module.exports = (config) => {
return withAndroidManifest(config, (config) => {
const manifest = config.modResults;
// 1. 注册无障碍 service
// 2. 添加 BIND_ACCESSIBILITY_SERVICE 权限
// 3. 复制 accessibility_service_config.xml
return config;
});
};
3.3 Layer 1: 规则预匹配(快速,无成本)
// plugins/accessibility/android/RuleMatcher.kt(Config Plugin 形态)
object RuleMatcher {
private val rules = listOf(
Rule(
name = "微信支付",
pattern = Regex("""微信支付.*?(\d+\.\d+)元.*?商户:(.+?)(?:\s|$)"""),
extractors = mapOf(
"amount" to Regex("""(\d+\.\d+)元"""),
"merchant" to Regex("""商户:(.+?)(?:\s|$)"""),
"time" to Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2})""")
)
),
Rule(
name = "支付宝",
pattern = Regex("""支付宝.*?(\d+\.\d+)元.*?收款方:(.+?)(?:\s|$)"""),
extractors = mapOf(
"amount" to Regex("""(\d+\.\d+)元"""),
"merchant" to Regex("""收款方:(.+?)(?:\s|$)""")
)
),
Rule(
name = "银行卡",
pattern = Regex("""尾号(\d{4}).*?消费.*?(\d+\.\d+)元"""),
extractors = mapOf(
"amount" to Regex("""(\d+\.\d+)元"""),
"card" to Regex("""尾号(\d{4})""")
)
)
)
fun match(text: String, appPackage: String): ImportedEvent? {
for (rule in rules) {
if (rule.pattern.containsMatchIn(text)) {
return rule.extract(text, appPackage)
}
}
return null
}
}
3.4 Layer 2: 本地 OCR(中等精度,无成本)
订正 1:删除原方案的 ML Kit 降级链(AutoAccounting 根本不用 ML Kit,只用 PP-OCRv5+NCNN)。改为单引擎 PP-OCRv5,并补充 AutoAccounting 的关键性能优化细节。
订正 2(决策 7):引擎从 NCNN 改为 ONNX Runtime。原因:NCNN 在 Windows 开发机的模型转换链路(
Paddle→ONNX→NCNN)过于折腾,ONNX Runtime 只需一步(Paddle→ONNX)或直接下社区现成模型,且微软官方维护、跨平台。完整对比见「决策 7」。
// plugins/ppocr/android/OcrModule.kt(Config Plugin 形态,见决策 4 + 决策 7)
@ReactModule(name = "PpOcr")
class OcrModule(private val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
// 单引擎:PP-OCRv5 + ONNX Runtime(替代 NCNN,见决策 7)
private var ortEnv: OrtEnvironment? = null
private var detSession: OrtSession? = null // ppocrv5_det.onnx
private var recSession: OrtSession? = null // ppocrv5_rec.onnx
@ReactMethod
fun recognizeText(imageBase64: String, promise: Promise) {
scope.launch {
val bitmap = decodeBase64(imageBase64)
val scaled = scaleDownForOcr(bitmap, 720)
// det(DB 文本检测)→ rec(CTC 文本识别)
val blocks = runInference(scaled)
promise.resolve(blocks.joinToString("\n") { it.text })
}
}
}
性能优化(参考 AutoAccounting OcrProcessor.kt / OcrService.kt):
| 优化项 | 做法 | 效果 |
|---|---|---|
| 执行器 | ONNX Runtime CPU,intraOp=2 / interOp=2 |
兼容性最稳,避免部分设备 GPU 崩溃 |
| 图像压缩 | 短边缩放到 OCR_MAX_SHORT_EDGE=720 |
像素量比 1440p 减少 75%,识别速度大幅提升 |
| det 边长限制 | DET_LIMIT_MAX_SIDE=960,尺寸对齐 32 |
PaddleOCR 默认上限,控制推理耗时 |
| rec 高度固定 | REC_IMAGE_HEIGHT=48,宽度上限 320 |
匹配模型输入规格,避免单行过长 |
| AI 输入编码 | bitmapToBase64 用 JPEG 质量 60 |
送 AI Vision 时体积减小 |
| ABI 限制 | 仅 arm64-v8a |
减小包体积(主流设备已 arm64) |
3.5 Layer 3: AI Vision(高精度,有成本)- 可选
// src/ocr/AiVisionProcessor.ts
export async function processAiVision(image: string): Promise<ImportedEvent | null> {
const config = await getConfig('ai_vision');
if (!config.enabled || !config.apiKey) return null;
const response = await fetch(config.apiUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${config.apiKey}` },
body: JSON.stringify({
image: image,
prompt: '识别账单信息,返回 JSON 格式:{amount, merchant, time, direction}'
})
});
return await response.json();
}
3.6 无障碍服务实现(参考 AutoAccounting)
重要:参考 AutoAccounting,无障碍服务需伪装为系统服务以避免被支付 App 检测屏蔽。
// 使用 SelectToSpeakService 伪装(参考 AutoAccounting)
class OcrAccessibilityService : AccessibilityService() {
private val ocrManager by lazy { OcrManager(applicationContext) }
private val ruleMatcher by lazy { RuleMatcher }
private val pageSignatureManager by lazy { PageSignatureManager(applicationContext) }
private var ocrDoing = false // 去重守卫,防止重复触发
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (ocrDoing) return // 正在处理中,跳过
when (event?.eventType) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val packageName = event.packageName?.toString() ?: return
if (isPaymentApp(packageName)) {
// 记录页面签名
pageSignatureManager.recordPage(
packageName, event.className?.toString() ?: ""
)
takeScreenshotAndProcess(packageName)
}
}
// 页面切换自动触发(参考 AutoAccounting PageSignature)
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
val packageName = event.packageName?.toString() ?: return
if (pageSignatureManager.matchesCurrentPage(packageName)) {
takeScreenshotAndProcess(packageName)
}
}
}
}
private fun takeScreenshotAndProcess(packageName: String) {
ocrDoing = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
val bitmap = Bitmap.wrapHardwareBuffer(
result.hardwareBuffer, result.colorSpace
)
result.hardwareBuffer.close()
bitmap?.let { processWithLayers(it, packageName) }
}
override fun onFailure(errorCode: Int) { ocrDoing = false }
}
)
}
}
private fun processWithLayers(bitmap: Bitmap, packageName: String) {
scope.launch {
try {
val text = ocrManager.recognizeText(bitmap)
// Layer 1: 规则预匹配
val layer1Result = ruleMatcher.match(text, packageName)
if (layer1Result != null) {
showFloatingBill(layer1Result) // 浮窗显示
sendToReactNative(layer1Result, "rule")
return@launch
}
// Layer 2: OCR + 正则解析
val layer2Result = BillParser.parse(text, packageName)
if (layer2Result != null) {
showFloatingBill(layer2Result)
sendToReactNative(layer2Result, "ocr")
return@launch
}
// Layer 3: AI Vision(可选)
sendToReactNative(null, "ai_vision_needed")
} finally {
ocrDoing = false
}
}
}
private fun showFloatingBill(event: ImportedEvent) {
// 参考 AutoAccounting 的 BillWindowManager 浮窗
val floatingView = FloatingBillView(applicationContext, event)
floatingView.show()
}
private fun isPaymentApp(packageName: String): Boolean {
return packageName in listOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
)
}
}
3.7 页面签名系统(参考 AutoAccounting)
// src/ocr/PageSignatureManager.kt
class PageSignatureManager(context: Context) {
private val prefs = context.getSharedPreferences("page_signatures", Context.MODE_PRIVATE)
fun recordPage(packageName: String, activityName: String) {
val signatures = getSignatures().toMutableList()
if (!signatures.any { it.packageName == packageName && it.activityName == activityName }) {
signatures.add(PageSignature(packageName, activityName))
saveSignatures(signatures)
}
}
fun matchesCurrentPage(packageName: String): Boolean {
return getSignatures().any { it.packageName == packageName }
}
}
3.8 浮窗账单提示(参考 AutoAccounting)
// src/ocr/FloatingBillView.kt
class FloatingBillView(context: Context, private val event: ImportedEvent) {
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val view = LayoutInflater.from(context).inflate(R.layout.floating_bill, null)
fun show() {
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
y = 100
}
// 显示金额、商户、时间
view.findViewById<TextView>(R.id.amount).text = event.amount
view.findViewById<TextView>(R.id.merchant).text = event.counterparty
view.findViewById<TextView>(R.id.time).text = event.occurredAt
// 点击确认记账
view.findViewById<Button>(R.id.confirm).setOnClickListener {
sendToReactNative(event, "confirmed")
dismiss()
}
windowManager.addView(view, params)
// 3 秒后自动消失
Handler(Looper.getMainLooper()).postDelayed({ dismiss() }, 3000)
}
private fun dismiss() {
try { windowManager.removeView(view) } catch (_: Exception) {}
}
}
3.9 JS 层分层处理
// src/ocr/OcrProcessor.ts
import { OcrBridge } from './OcrBridge';
import { processAiVision } from './AiVisionProcessor';
import { importStatement, type ImportedEvent } from '../domain';
export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
const base64Image = await OcrBridge.takeScreenshot();
if (!base64Image) return null;
const topApp = await OcrBridge.getTopApp();
if (!topApp) return null;
const ocrText = await OcrModule.recognizeText(base64Image);
if (!ocrText) return null;
const event = await OcrModule.processWithLayers(base64Image, topApp);
if (event === null && await shouldUseAiVision()) {
return await processAiVision(base64Image);
}
return event;
}
async function shouldUseAiVision(): Promise<boolean> {
const config = await getConfig('ai_vision');
return config?.enabled ?? false;
}
3.10 横屏免打扰模式
参考 AutoAccounting 的 Landscape DND——游戏/视频时自动暂存账单:
// 在 OcrAccessibilityService 中添加
private var isLandscapeDnd = false
private fun checkLandscapeDnd() {
val display = getSystemService(DisplayManager::class.java)
.getDisplay(Display.DEFAULT_DISPLAY)
val rotation = display.rotation
isLandscapeDnd = rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
if (isLandscapeDnd) {
// 暂存账单,稍后显示
pendingBills.clear()
} else {
// 恢复显示暂存的账单
showPendingBills()
}
}
3.11 快速设置磁贴
参考 AutoAccounting 的 OcrTileService:
// src/ocr/OcrTileService.kt
class OcrTileService : TileService() {
override fun onStartListening() {
super.onStartListening()
qsTile?.state = Tile.STATE_ACTIVE
qsTile?.label = "OCR 记账"
qsTile?.updateTile()
}
override fun onClick() {
super.onClick()
// 触发一次 OCR 截图
val service = getSystemService(OcrAccessibilityService::class.java)
service?.triggerManualOcr()
}
}
3.12 平台兼容性
订正:删除 ML Kit OCR 行(项目不用 ML Kit)。iOS 的 OCR 改走「截图自动记账通道 + AI Vision」(见 Phase 3.13),不依赖本地 OCR 模型。
| 功能 | Android | iOS | 说明 |
|---|---|---|---|
| 无障碍截屏 | ✓ | ✗ | iOS 限制 |
| PP-OCRv5(本地 OCR) | ✓ | ✗ | 单引擎,仅 Android |
| 截图自动记账 | ✓ | ✓ | Android ContentObserver / iOS AppIntent(见 3.13) |
| AI Vision(云端) | ✓ | ✓ | 替代本地 OCR,iOS 主要路径 |
| 通知监听 | ✓ | ✗ | iOS 限制严格 |
| 短信解析 | ✓ | ✗ | iOS 限制严格 |
| 规则匹配 | ✓ | ✓ | 纯逻辑 |
| 页面签名 | ✓ | ✗ | 仅 Android |
| 浮窗提示 | ✓ | ✗ | 仅 Android |
| 快速设置磁贴 | ✓ | ✗ | 仅 Android |
| 快速设置磁贴 | ✓ | ✗ | 仅 Android |
3.13 截图自动记账通道(新增)
新增(审查订正):这是比无障碍截屏更轻量、更合规的高频入口。用户在支付宝/微信/银行 App 截图,应用自动识别。参考 BeeCount 的
ScreenshotObserver.kt(Android)+AutoBillingAppIntent.swift(iOS)。
Android(ContentObserver 监听 MediaStore Screenshots):
// plugins/screenshot-monitor/android/ScreenshotObserver.kt
class ScreenshotObserver(
context: Context,
private val onScreenshot: (Uri) -> Unit
) : ContentObserver(Handler(Looper.getMainLooper())) {
private val resolver = context.contentResolver
private val SCREENSHOTS_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
private val processedPaths = mutableSetOf<String>() // 去重
private var lastCheckTime = System.currentTimeMillis()
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
uri ?: return
// 1. 查询新增截图(30 秒内)
val projection = arrayOf(MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_ADDED)
val selection = "${MediaStore.Images.Media.DATE_ADDED} > ?"
val cursor = resolver.query(uri, projection, selection, arrayOf((lastCheckTime / 1000 - 30).toString()), null)
cursor?.use {
while (it.moveToNext()) {
val path = it.getString(0)
// 2. 关键词匹配(过滤非截图)
if (!listOf("screenshot", "截屏", "截图", "screen_shot").any { kw -> path.contains(kw, true) }) continue
if (path.endsWith(".pending-")) continue // 过滤小米临时文件
if (processedPaths.contains(path)) continue
processedPaths.add(path)
// 3. 去抖(500ms)后回调
onScreenshot(uri)
}
}
lastCheckTime = System.currentTimeMillis()
}
}
iOS(AppIntent 后台执行):
// ios/AutoBillingAppIntent.swift(iOS 16+)
struct AutoBillingAppIntent: AppIntent {
static var title: LocalizedStringResource = "截图记账"
static var openAppWhenRun: Bool = false // 后台执行(关键)
@Parameter(title: "截图", description: "待识别的截图")
var screenshot: IntentFile
func perform() async throws -> some IntentResult {
// 1. 保存到临时目录
let tmpUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
try screenshot.data.write(to: tmpUrl)
// 2. 发事件给 RN(经 AppIntentsBridge)
await AppIntentsBridge.sendEvent([
"type": "screenshot",
"path": tmpUrl.path
])
// 3. 等待记账完成(iOS 后台窗口约 30s,实测 4-9s)
await waitForBillingComplete()
return .result()
}
}
与无障碍 OCR 的区别:
| 维度 | 无障碍截屏 OCR | 截图自动记账 |
|---|---|---|
| 触发 | 进入支付 App 自动截屏 | 用户主动截图 |
| 权限 | 无障碍(敏感) | 读媒体文件(Android)/ AppIntent(iOS) |
| 合规 | 高风险(侧载保留) | 低风险(上架也能用) |
| 频次 | 全自动 | 半自动(需用户截图) |
| iOS 支持 | ✗ | ✓(AppIntent) |
JS 层处理(统一入口):
截图到达后,与无障碍 OCR 走相同的 billPipeline(决策 6),只是输入源标记为 screenshot。
Phase 4: 通知/短信监听(1-2 周,仅 Android)[P0]
提升为 P0:实现简单、用户价值高,是重要的数据捕获通道
4.1 通知监听服务
参考 AutoAccounting 的 NotificationListenerService(含去重守卫 + 关键词黑白名单):
class NotificationListenerService : NotificationListenerService() {
private val md5Hashes = mutableSetOf<String>() // 去重守卫
private val keywordFilter = KeywordFilter() // 关键词过滤
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val packageName = sbn?.packageName ?: return
if (!isPaymentApp(packageName)) return
val notification = sbn.notification
val extras = notification.extras
val title = extras?.getString(Notification.EXTRA_TITLE) ?: ""
val text = extras?.getString(Notification.EXTRA_BIG_TEXT)
?: extras?.getString(Notification.EXTRA_TEXT) ?: ""
// 关键词过滤
if (!keywordFilter.passesFilter("$title$text")) return
// MD5 去重
val hash = md5("$title$text")
if (md5Hashes.contains(hash)) return
md5Hashes.add(hash)
val event = NotificationParser.parse(title, text, packageName)
if (event != null) {
sendToReactNative(event)
}
}
private fun isPaymentApp(packageName: String): Boolean {
return packageName in listOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
"com.cmbchina",
"com.icbc",
)
}
}
4.2 短信监听服务
参考 AutoAccounting 的 SmsReceiver(含关键词过滤):
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (message in messages) {
val sender = message.displayOriginatingAddress ?: ""
val body = message.displayMessageBody ?: ""
if (isBankSms(sender, body)) {
val event = SmsParser.parse(sender, body)
if (event != null) {
sendToReactNative(event)
}
}
}
}
private fun isBankSms(sender: String, body: String): Boolean {
val bankKeywords = listOf("交易", "消费", "收入", "支出", "余额")
return bankKeywords.any { body.contains(it) }
}
}
4.3 短信解析器
object SmsParser {
private val amountPattern = Regex("""(\d+\.\d+)元""")
private val cardPattern = Regex("""尾号(\d{4})""")
private val timePattern = Regex("""(\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2})""")
fun parse(sender: String, body: String): ImportedEvent? {
val amount = amountPattern.find(body)?.groupValues?.get(1) ?: return null
val card = cardPattern.find(body)?.groupValues?.get(1) ?: ""
val time = timePattern.find(body)?.groupValues?.get(1) ?: ""
val direction = when {
body.contains("收入") || body.contains("转入") -> "income"
body.contains("支出") || body.contains("消费") || body.contains("转出") -> "expense"
else -> "expense"
}
return ImportedEvent(
id = "sms-${System.currentTimeMillis()}",
occurredAt = parseTime(time),
amount = if (direction == "expense") "-$amount" else amount,
currency = "CNY",
direction = direction,
channel = "Bank",
counterparty = "短信通知",
memo = "银行卡${card}尾号",
raw = mapOf("sender" to sender, "body" to body)
)
}
}
4.4 每日提醒通知
参考 BeeCount 的每日记账提醒:
// src/services/reminder.ts
import * as Notifications from 'expo-notifications';
export async function setupDailyReminder(hour: number = 20, minute: number = 0) {
await Notifications.cancelAllScheduledNotificationsAsync();
await Notifications.scheduleNotificationAsync({
content: {
title: '记账提醒',
body: '今天还没有记账哦,点击开始记录',
data: { screen: 'add-transaction' },
},
trigger: {
hour,
minute,
repeats: true,
},
});
}
export async function setupCreditCardReminder(
billingDay: number,
paymentDay: number,
cardName: string
) {
// 账单日提醒(提前1天)
await Notifications.scheduleNotificationAsync({
content: {
title: `${cardName} 账单日提醒`,
body: `明天是 ${cardName} 的账单日,请确认已记账`,
},
trigger: {
day: billingDay - 1,
hour: 9,
minute: 0,
repeats: true,
},
});
// 还款日提醒(提前3天)
await Notifications.scheduleNotificationAsync({
content: {
title: `${cardName} 还款提醒`,
body: `还有3天是 ${cardName} 的还款日`,
},
trigger: {
day: paymentDay - 3,
hour: 9,
minute: 0,
repeats: true,
},
});
}
Phase 5: 高级功能(3-4 周)[P1]
5.1 账户树与报表
interface AccountTree {
Assets: {
'Alipay': AccountInfo;
'WeChat': AccountInfo;
'Bank:*': AccountInfo[];
};
Liabilities: { ... };
Income: { ... };
Expenses: { ... };
Equity: { ... };
}
function calculateReport(ledger: LedgerIndex, period: string) {
return {
income: calculateIncome(ledger, period),
expense: calculateExpense(ledger, period),
netIncome: calculateNetIncome(ledger, period),
assets: calculateAssets(ledger),
liabilities: calculateLiabilities(ledger),
netWorth: calculateNetWorth(ledger),
};
}
5.2 图表与可视化
参考 BeeCount 的 fl_chart 实现:
// src/components/charts/MonthlyReport.tsx
import { LineChart, PieChart } from 'react-native-chart-kit';
function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const monthlyData = groupByMonth(transactions);
const categoryData = groupByCategory(transactions);
return (
<View>
<Text>月度趋势</Text>
<LineChart
data={{
labels: monthlyData.map(d => d.month),
datasets: [{ data: monthlyData.map(d => d.total) }],
}}
width={Dimensions.get('window').width}
height={220}
/>
<Text>分类占比</Text>
<PieChart
data={categoryData.map(d => ({
name: d.category,
amount: d.total,
color: d.color,
legendFontColor: '#7F7F7F',
}))}
width={Dimensions.get('window').width}
height={220}
/>
</View>
);
}
年度报告
参考 BeeCount 的年度报告:
// src/domain/annualReport.ts
interface AnnualReport {
year: number;
totalIncome: string;
totalExpense: string;
netIncome: string;
topCategories: { category: string; amount: string; percentage: number }[];
monthlyTrend: { month: string; income: string; expense: string }[];
dayOfWeekBreakdown: { day: string; total: string }[]; // 周几消费分布
largestTransaction: Transaction;
averageDailyExpense: string;
}
export function generateAnnualReport(
transactions: Transaction[],
year: number
): AnnualReport {
const yearTransactions = transactions.filter(t => t.date.startsWith(String(year)));
return {
year,
totalIncome: calculateTotal(yearTransactions, 'income'),
totalExpense: calculateTotal(yearTransactions, 'expense'),
netIncome: calculateNetIncome(yearTransactions),
topCategories: getTopCategories(yearTransactions, 10),
monthlyTrend: getMonthlyTrend(yearTransactions),
dayOfWeekBreakdown: getDayOfWeekBreakdown(yearTransactions),
largestTransaction: getLargestTransaction(yearTransactions),
averageDailyExpense: calculateAverageDaily(yearTransactions),
};
}
净资产趋势
参考 BeeCount 的净资产追踪:
// src/domain/netWorth.ts
interface NetWorthPoint {
date: string;
assets: string;
liabilities: string;
netWorth: string;
}
export function calculateNetWorthTrend(
ledger: LedgerIndex,
startDate: string,
endDate: string
): NetWorthPoint[] {
const points: NetWorthPoint[] = [];
const days = getDaysBetween(startDate, endDate);
for (const day of days) {
const assets = calculateAssets(ledger, day);
const liabilities = calculateLiabilities(ledger, day);
points.push({
date: day,
assets,
liabilities,
netWorth: subtractDecimals(assets, liabilities),
});
}
return points;
}
日历热力图
参考 BeeCount 的日历视图:
// src/components/charts/CalendarHeatmap.tsx
function CalendarHeatmap({ transactions, year, month }: Props) {
const dailyTotals = groupByDay(transactions, year, month);
const maxDaily = Math.max(...Object.values(dailyTotals).map(Number));
return (
<View style={styles.calendar}>
{getDaysInMonth(year, month).map(day => {
const total = dailyTotals[day] || 0;
const intensity = maxDaily > 0 ? total / maxDaily : 0;
return (
<View
key={day}
style={[
styles.dayCell,
{ backgroundColor: getHeatColor(intensity) },
]}
>
<Text style={styles.dayText}>{day}</Text>
</View>
);
})}
</View>
);
}
5.3 日历视图
参考 BeeCount 的日历视图——按日期浏览交易:
// src/app/calendar/index.tsx
function CalendarScreen() {
const [selectedDate, setSelectedDate] = useState(new Date());
const transactions = useTransactionsForDate(selectedDate);
return (
<View>
<Calendar
onDayPress={(day) => setSelectedDate(day.date)}
markedDates={getMarkedDates(allTransactions)}
theme={calendarTheme}
/>
<TransactionList transactions={transactions} />
</View>
);
}
// 获取有交易的日期标记
function getMarkedDates(transactions: Transaction[]) {
const marked: Record<string, { dots: { color: string }[] }> = {};
for (const t of transactions) {
const date = t.date.slice(0, 10);
if (!marked[date]) marked[date] = { dots: [] };
const color = t.postings.some(p => p.amount.startsWith('-'))
? theme.colors.expense
: theme.colors.income;
marked[date].dots.push({ color });
}
return marked;
}
5.4 信用卡管理
参考 BeeCount 的信用卡管理:
// src/domain/creditCards.ts
interface CreditCard {
id: string;
name: string;
lastFour: string;
bankName: string;
billingDay: number; // 账单日(1-28)
paymentDay: number; // 还款日(1-28)
creditLimit: string; // 信用额度
currentBalance: string; // 当前欠款
currency: string;
linkedAccount: string; // 关联的 Beancount 账户
}
// 计算信用卡账单周期
function calculateBillingPeriod(
card: CreditCard,
currentDate: Date
): { periodStart: string; periodEnd: string; dueDate: string } {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 账单周期:上月账单日 ~ 本月账单日
const periodStart = new Date(year, month - 1, card.billingDay);
const periodEnd = new Date(year, month, card.billingDay);
const dueDate = new Date(year, month, card.paymentDay);
return {
periodStart: periodStart.toISOString().slice(0, 10),
periodEnd: periodEnd.toISOString().slice(0, 10),
dueDate: dueDate.toISOString().slice(0, 10),
};
}
// 计算本期应还金额
function calculateStatementAmount(
transactions: Transaction[],
card: CreditCard,
periodStart: string,
periodEnd: string
): string {
const periodTransactions = transactions.filter(t => {
const date = t.date.slice(0, 10);
return date >= periodStart && date < periodEnd &&
t.postings.some(p => p.account.includes(card.linkedAccount));
});
return periodTransactions.reduce((sum, t) => {
const expense = t.postings
.filter(p => p.amount.startsWith('-'))
.reduce((s, p) => addDecimals(s, p.amount.replace('-', '')), '0');
return addDecimals(sum, expense);
}, '0');
}
5.5 预算管理
参考 BeeCount 的月度总预算 + 分类预算 + 超支提醒:
// src/domain/budgets.ts
export interface Budget {
id: string;
name: string;
amount: string;
period: 'monthly' | 'weekly' | 'yearly';
categoryId?: string; // undefined = 总预算
startDate: string;
}
// 预算进度计算
export function calculateBudgetProgress(
budget: Budget,
transactions: Transaction[]
): { spent: string; remaining: string; percentage: number } {
const periodTransactions = filterByPeriod(transactions, budget.period);
const categoryTransactions = budget.categoryId
? periodTransactions.filter(t => t.postings.some(p => p.account.includes(budget.categoryId!)))
: periodTransactions;
const spent = categoryTransactions.reduce((sum, t) => {
const amount = t.postings.find(p => p.amount.startsWith('-'))?.amount ?? '0';
return addDecimals(sum, amount.replace('-', ''));
}, '0');
const remaining = subtractDecimals(budget.amount, spent);
const percentage = (parseFloat(spent) / parseFloat(budget.amount)) * 100;
return { spent, remaining, percentage };
}
5.6 周期性记账
参考 BeeCount 的 RecurringTransactions:
// src/domain/recurring.ts
export interface RecurringTransaction {
id: string;
name: string;
draft: TransactionDraft;
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
interval: number; // 间隔(如每2周 = weekly + interval: 2)
nextDueDate: string;
enabled: boolean;
}
// 检查到期的周期性交易
export function getDueRecurring(
recurring: RecurringTransaction[],
currentDate: string
): RecurringTransaction[] {
return recurring.filter(r =>
r.enabled && r.nextDueDate <= currentDate
);
}
// 计算下次到期日期
export function calculateNextDueDate(
frequency: string,
interval: number,
lastDueDate: string
): string {
const date = new Date(lastDueDate);
switch (frequency) {
case 'daily': date.setDate(date.getDate() + interval); break;
case 'weekly': date.setDate(date.getDate() + 7 * interval); break;
case 'monthly': date.setMonth(date.getMonth() + interval); break;
case 'yearly': date.setFullYear(date.getFullYear() + interval); break;
}
return date.toISOString().slice(0, 10);
}
5.7 附件支持
参考 BeeCount 的 TransactionAttachments:
// src/domain/attachments.ts
export interface Attachment {
id: string;
transactionId: string;
fileName: string;
fileUri: string;
mimeType: string;
createdAt: string;
}
5.8 多币种支持
参考 BeeCount v30 的交易级多币种:
// src/domain/currency.ts
export interface ExchangeRate {
from: string;
to: string;
rate: string;
date: string;
}
// 交易级多币种
export interface TransactionCurrency {
currencyCode: string; // 交易货币(如 USD)
nativeAmount: string; // 原始金额(如 100.00)
exchangeRate: string; // 记账时的汇率快照
baseCurrency: string; // 基础货币(如 CNY)
}
export function convertCurrency(
amount: string,
from: string,
to: string,
rates: ExchangeRate[]
): string {
if (from === to) return amount;
const rate = rates.find(r => r.from === from && r.to === to);
if (!rate) throw new Error(`No exchange rate for ${from} -> ${to}`);
return multiplyDecimals(amount, rate.rate);
}
// 手动汇率覆盖
export interface ExchangeRateOverride {
from: string;
to: string;
rate: string;
enabled: boolean;
}
// 获取汇率(优先使用手动覆盖)
export function getEffectiveRate(
from: string,
to: string,
autoRates: ExchangeRate[],
overrides: ExchangeRateOverride[]
): ExchangeRate | null {
// 检查手动覆盖
const override = overrides.find(o =>
o.enabled && o.from === from && o.to === to
);
if (override) {
return { from, to, rate: override.rate, date: new Date().toISOString().slice(0, 10) };
}
// 使用自动汇率
return autoRates.find(r => r.from === from && r.to === to) ?? null;
}
5.9 导出功能
// 导出为 zip 账本包
async function exportLedgerBundle(ledger: LedgerIndex, mobileBean: string) {
const files = {
...Object.fromEntries(ledger.files.map(f => [f.path, f.content])),
'mobile.bean': mobileBean,
};
const zipPath = await createZip(files);
return zipPath;
}
// 导出为 Excel(参考 BeeCount 的 Excel 导出)
async function exportToExcel(transactions: Transaction[], filePath: string) {
// 使用 xlsx 库生成 Excel
const workbook = XLSX.utils.book_new();
const data = transactions.map(t => ({
日期: t.date,
摘要: t.narration,
收入: t.postings.find(p => !p.amount.startsWith('-'))?.amount ?? '',
支出: t.postings.find(p => p.amount.startsWith('-'))?.amount ?? '',
标签: t.tags.join(', '),
}));
const sheet = XLSX.utils.json_to_sheet(data);
XLSX.utils.book_append_sheet(workbook, sheet, '交易');
XLSX.writeFile(workbook, filePath);
}
5.10 分享海报生成
参考 BeeCount 的 Share Poster:
// src/services/sharePoster.ts
import { captureRef } from 'react-native-view-shot';
interface PosterConfig {
type: 'year' | 'month' | 'ledger';
title: string;
period?: string;
transactions: Transaction[];
}
export async function generateSharePoster(config: PosterConfig): Promise<string> {
// 生成统计数据
const stats = calculatePosterStats(config);
// 渲染海报视图
const posterRef = useRef<View>(null);
// 截图保存
const uri = await captureRef(posterRef, {
format: 'png',
quality: 1.0,
});
return uri;
}
function calculatePosterStats(config: PosterConfig) {
const { transactions, period } = config;
const filtered = period ? filterByPeriod(transactions, period) : transactions;
return {
totalIncome: calculateTotal(filtered, 'income'),
totalExpense: calculateTotal(filtered, 'expense'),
transactionCount: filtered.length,
topCategory: getTopCategories(filtered, 1)[0],
averageDaily: calculateAverageDaily(filtered),
};
}
5.11 诊断页面
function DiagnosticsScreen({ ledger }: { ledger: LedgerIndex }) {
return (
<View>
<Text>语法问题:{ledger.diagnostics.length}</Text>
<Text>保留的高级指令:{ledger.unsupported.length}</Text>
<FlatList
data={ledger.diagnostics}
renderItem={({ item }) => <DiagnosticCard diagnostic={item} />}
/>
</View>
);
}
5.12 规则导入/导出
参考 AutoAccounting 的云规则分发系统:
// src/services/ruleSync.ts
export async function exportRules(rules: Rule[]): Promise<string> {
const data = JSON.stringify(rules, null, 2);
const path = `${FileSystem.documentDirectory}rules-export.json`;
await FileSystem.writeAsStringAsync(path, data);
return path;
}
export async function importRules(filePath: string): Promise<Rule[]> {
const content = await FileSystem.readAsStringAsync(filePath);
const rules = JSON.parse(content) as Rule[];
// 验证规则格式
return rules.filter(validateRule);
}
Phase 6: 同步功能(可选,3-4 周)[P2]
重写:原 Phase 6 过于简略,此处补充完整的同步架构
6.1 同步架构设计
参考 BeeCount 的双模式同步架构:
同步模式:
├── 增量同步(BeeCount Cloud 风格)
│ ├── ChangeTracker 记录本地变更
│ ├── SyncCoordinator 监听变更并触发同步
│ ├── Push: 上传本地变更
│ ├── Pull: 拉取远程变更
│ └── 冲突解决:最后写入胜出 + 手动合并
│
└── 快照同步(Supabase/WebDAV/S3 风格)
├── 全量上传 mobile.bean
├── 全量下载 mobile.bean
└── 冲突解决:用户选择保留哪个版本
6.2 Git 同步
// src/services/sync/gitSync.ts
interface GitSyncConfig {
remoteUrl: string;
branch: string;
autoSync: boolean;
syncInterval: number; // 分钟
}
export async function syncWithGit(config: GitSyncConfig) {
// 1. 拉取远程变更
await git.pull(config.remoteUrl, config.branch);
// 2. 合并 mobile.bean(解决冲突)
const conflicts = await mergeMobileBean();
if (conflicts.length > 0) {
// 显示冲突解决界面
showConflictResolver(conflicts);
return;
}
// 3. 提交并推送
await git.add('mobile.bean');
await git.commit(`Sync mobile.bean from mobile app [${new Date().toISOString()}]`);
await git.push(config.remoteUrl, config.branch);
}
6.3 WebDAV 同步
// src/services/sync/webdavSync.ts
interface WebDAVSyncConfig {
url: string;
username: string;
password: string;
remotePath: string;
}
export async function syncWithWebDAV(config: WebDAVSyncConfig) {
const client = new WebDAVClient(config.url, config.username, config.password);
// 1. 获取远程文件信息
const remoteInfo = await client.stat(config.remotePath);
// 2. 对比本地和远程
const localContent = await readMobileBean();
const remoteContent = await client.getFile(config.remotePath);
if (localContent === remoteContent) {
return; // 无变更
}
// 3. 冲突检测
if (remoteInfo.lastModified > localLastSyncTime) {
// 远程有更新,需要解决冲突
showConflictDialog(localContent, remoteContent);
} else {
// 上传本地版本
await client.putFile(config.remotePath, localContent);
}
}
// WebDAV 备份管理(参考 AutoAccounting 的 WebDAVManager)
export async function backupToWebDAV(config: WebDAVSyncConfig) {
const backupDir = `${config.remotePath}/backups/`;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${backupDir}backup-${timestamp}.zip`;
// 创建备份
const backupContent = await createBackupArchive();
await client.putFile(backupPath, backupContent);
// 清理旧备份(保留最近 10 个)
const backups = await client.listFiles(backupDir);
if (backups.length > 10) {
const sorted = backups.sort((a, b) => a.name.localeCompare(b.name));
for (const old of sorted.slice(0, backups.length - 10)) {
await client.deleteFile(old.path);
}
}
}
6.4 iCloud 同步(iOS)
// src/services/sync/icloudSync.ts
import * as FileSystem from 'expo-file-system';
export async function syncWithICloud() {
const container = await FileSystem.iCloudFileSystemAsync();
// 读取 iCloud 中的 mobile.bean
const icloudPath = `${container}/mobile.bean`;
const localPath = `${FileSystem.documentDirectory}mobile.bean`;
const icloudContent = await FileSystem.readAsStringAsync(icloudPath);
const localContent = await FileSystem.readAsStringAsync(localPath);
if (icloudContent === localContent) return;
// iCloud 优先(iOS 原生同步更可靠)
await FileSystem.writeAsStringAsync(localPath, icloudContent);
await rebuildCacheFromFiles();
}
6.5 同步错误持久化
参考 BeeCount 的 SyncPullErrors:
-- 同步失败记录表
CREATE TABLE sync_errors (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL, -- 'push' | 'pull' | 'merge'
entity_type TEXT NOT NULL, -- 'transaction' | 'rule' | 'attachment'
entity_id TEXT NOT NULL,
error_message TEXT NOT NULL,
created_at TEXT NOT NULL,
resolved INTEGER DEFAULT 0 -- 0=未解决, 1=已重试, 2=已跳过
);
// src/services/sync/syncErrorManager.ts
export async function recordSyncError(error: SyncError) {
await db.runAsync(
`INSERT INTO sync_errors (id, operation, entity_type, entity_id, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
generateId(), error.operation, error.entityType, error.entityId,
error.message, new Date().toISOString()
);
}
export async function getUnresolvedErrors(): Promise<SyncError[]> {
return db.getAllAsync<SyncError>(
'SELECT * FROM sync_errors WHERE resolved = 0 ORDER BY created_at DESC'
);
}
export async function resolveError(id: string, resolution: 'retry' | 'skip') {
await db.runAsync(
'UPDATE sync_errors SET resolved = ? WHERE id = ?',
resolution === 'retry' ? 1 : 2, id
);
}
Phase 7: iOS 自动记账方案(1-2 周)[P1]
降低预期:iOS 平台限制严重,以下方案均为实验性,标注为实验性功能
7.1 iOS 快捷指令(Shortcuts)[实验性]
参考 BeeCount 的"双击背部触发快捷指令":
# iOS Shortcuts 工作流
1. 触发方式:
- 双击背部(Back Tap)
- 主屏幕快捷指令按钮
- 分享菜单(Share Extension)
2. 工作流程:
- 截取当前屏幕(或接收分享的截图)
- 通过 AppIntent 后台发送到 beancount-mobile(见 3.13)
- 应用内走 AI Vision 识别(iOS 不依赖本地 OCR 模型)
- 经 billPipeline 入库
订正:原方案"调用 ML Kit OCR"不准确(项目不用 ML Kit)。iOS 路径走「截图 AppIntent + AI Vision」,参考 BeeCount 的
AutoBillingAppIntent.swift(openAppWhenRun=false后台执行,30s 窗口实测 4-9s)。 注意:Apple 对金融类快捷指令 API 限制多,此方案仅作为辅助手段。
7.2 分享扩展(Share Extension)[实验性]
从其他 App(支付宝/微信/银行 App)分享账单截图到 beancount-mobile:
// src/services/shareExtension.ts
export async function handleShareExtension(imageUri: string) {
// 1. OCR 识别截图
const text = await OcrModule.recognizeText(imageUri);
// 2. 解析账单信息
const event = BillParser.parse(text);
// 3. 创建交易草稿
if (event) {
await importStore.getState().addEvent(event);
}
}
7.3 SiriKit 集成(受限)[实验性]
// 通过 Siri 快捷指令触发记账
// "嘿 Siri,记一笔"
// 受限于 SiriKit Intent 定义,功能有限
7.4 iOS 通知权限(受限)
iOS 不允许第三方应用监听其他应用的通知,但可以:
- 使用应用自己的通知(每日提醒)
- 用户手动从通知中心复制账单信息
- 通过 Share Extension 接收分享的账单截图
Phase 8: AI 功能增强(2-3 周)[P1]
8.0 AI Provider 抽象层(前置基础)
新增(审查订正):原方案的
callAiProvider是黑盒,未定义 Provider 抽象、配置加密、隐私脱敏、流式、视觉模式。参考 AutoAccounting 的BaseOpenAIProvider(8 个 Provider 共用 OpenAI 兼容协议)+ Gemini 独立实现。
Provider 抽象:
// src/ai/provider.ts
export interface AiProvider {
id: string;
name: string;
// 文本对话(支持流式)
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
// 视觉(图片识别)
vision?(messages: ChatMessage[], image: string, options?: ChatOptions): Promise<string>;
}
// OpenAI 兼容协议(8 个 Provider 共用:DeepSeek/Kimi/智谱/OpenRouter/QWen/硅基流动/MiMo/ChatGPT)
export abstract class BaseOpenAIProvider implements AiProvider {
constructor(protected config: { apiKey: string; baseUrl: string; model: string }) {}
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<string> {
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({
model: this.config.model,
messages,
...options,
}),
});
const data = await response.json();
return data.choices[0].message.content;
}
async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
// SSE 流式解析
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: { /* ... */, 'Accept': 'text/event-stream' },
body: JSON.stringify({ model: this.config.model, messages, stream: true, ...options }),
});
// 解析 SSE data: {...} 行,逐块 yield
// ...
}
}
// 预置 Provider(参考 AutoAccounting 的 9 个)
export const providers: Record<string, AiProvider> = {
deepseek: new (class extends BaseOpenAIProvider {
constructor() { super({ apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' }); }
id = 'deepseek'; name = 'DeepSeek';
})(),
zhipu: new (class extends BaseOpenAIProvider {
constructor() { super({ apiKey: '', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' }); }
id = 'zhipu'; name = '智谱清言';
})(),
// ... Kimi/OpenRouter/QWen/SiliconFlow/MiMo/ChatGPT
gemini: new GeminiProvider(), // 独立实现(非 OpenAI 协议)
};
配置加密存储(apiKey 等敏感信息):
// src/ai/config.ts
import * as SecureStore from 'expo-secure-store';
export async function saveProviderConfig(providerId: string, config: { apiKey: string; baseUrl?: string; model?: string }) {
// SecureStore:iOS Keychain / Android Keystore 加密存储
await SecureStore.setItemAsync(`ai_provider_${providerId}`, JSON.stringify(config));
}
export async function loadProviderConfig(providerId: string) {
const json = await SecureStore.getItemAsync(`ai_provider_${providerId}`);
return json ? JSON.parse(json) : null;
}
隐私脱敏(账单/截图发往 AI 前过滤敏感字段):
// src/ai/privacy.ts(参考 BeeCount lib/ai/privacy/)
export function sanitizeForAi(text: string): string {
return text
.replace(/\b\d{16,19}\b/g, '[卡号]') // 银行卡号
.replace(/\b\d{17,18}(\d|X)\b/g, '[身份证]') // 身份证
.replace(/1[3-9]\d{9}/g, '[手机号]') // 手机号
.replace(/\b\d{6}\b/g, (match, offset, str) => {
// 6 位数字可能是验证码,上下文判断
return /验证码|验证|code/i.test(str.slice(Math.max(0, offset - 20), offset + 20)) ? '[验证码]' : match;
});
}
去除思考链(removeThink):部分模型(DeepSeek-R1 等)返回 <think>...</think> 思考过程,需在展示前剥离(参考 AutoAccounting removeThink())。
export function removeThink(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
8.1 AI 聊天助手
参考 BeeCount 的 AI Chat Assistant(GLM-4):
// src/ai/chatAssistant.ts
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatConversation {
id: string;
messages: ChatMessage[];
createdAt: string;
}
// 自然语言记账
export async function processNaturalLanguage(
input: string,
ledger: LedgerIndex
): Promise<TransactionDraft | string> {
const prompt = `你是一个记账助手。用户输入:"${input}"
请解析为交易信息,返回 JSON:
{
"type": "expense" | "income" | "transfer",
"amount": "数字",
"currency": "CNY",
"account": "账户名",
"counterparty": "对方",
"narration": "备注",
"tags": ["标签"]
}
如果无法解析为交易,返回建议文本。`;
const response = await callAiProvider(prompt);
try {
const draft = JSON.parse(response);
if (draft.amount && draft.type) {
return draft as TransactionDraft;
}
} catch {
// 非 JSON,返回建议文本
}
return response;
}
// 示例对话:
// 用户:"昨天在星巴克花了35块"
// AI:解析为 expense, amount: 35, counterparty: 星巴克
8.2 语音记账
参考 BeeCount 的 Voice Billing(长按说话):
// src/ai/voiceInput.ts
import { Audio } from 'expo-av';
export async function startVoiceInput(): Promise<string> {
// 1. 请求录音权限
const permission = await Audio.requestPermissionsAsync();
if (!permission.granted) throw new Error('需要录音权限');
// 2. 开始录音
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
// 3. 等待用户松手(长按模式)
// ... 用户松手后停止录音
// 4. 发送到 AI 识别
const uri = recording.getURI();
const transcription = await transcribeAudio(uri);
return transcription;
}
// 语音转文字后调用 processNaturalLanguage
export async function processVoiceInput(): Promise<TransactionDraft | null> {
const text = await startVoiceInput();
const result = await processNaturalLanguage(text, currentLedger);
if (typeof result === 'object') {
return result;
}
return null;
}
8.3 AI 月度总结
参考 AutoAccounting 的 AI Monthly Summary:
// src/ai/monthlySummary.ts
export async function generateMonthlySummary(
transactions: Transaction[],
year: number,
month: number
): Promise<string> {
const monthTransactions = transactions.filter(t => {
const date = new Date(t.date);
return date.getFullYear() === year && date.getMonth() === month - 1;
});
const stats = {
totalExpense: calculateTotal(monthTransactions, 'expense'),
totalIncome: calculateTotal(monthTransactions, 'income'),
topCategories: getTopCategories(monthTransactions, 5),
transactionCount: monthTransactions.length,
};
const prompt = `请根据以下数据生成月度财务总结:
总支出: ${stats.totalExpense}
总收入: ${stats.totalIncome}
交易笔数: ${stats.transactionCount}
主要支出分类: ${stats.topCategories.map(c => `${c.category}: ${c.amount}`).join(', ')}
请用简洁的中文总结消费习惯,给出理财建议。`;
return await callAiProvider(prompt);
}
8.4 AI 分类识别
参考 AutoAccounting 的 AI Category Recognition:
// src/ai/categoryRecognizer.ts
export async function aiRecognizeCategory(
event: ImportedEvent,
categories: Category[]
): Promise<Category | null> {
const categoryList = categories.map(c => `${c.name} (${c.type})`).join(', ');
const prompt = `根据以下交易信息,判断最合适的分类:
交易: ${event.counterparty} - ${event.amount}元 - ${event.memo}
可用分类: ${categoryList}
返回分类名称。`;
const response = await callAiProvider(prompt);
return categories.find(c => c.name === response.trim()) ?? null;
}
8.5 AI 资产映射
参考 AutoAccounting 的 AI Asset Mapping:
// src/ai/assetMapper.ts
export async function aiMapAsset(
channel: string,
counterparty: string,
accounts: Account[]
): Promise<Account | null> {
const accountList = accounts.map(a => a.name).join(', ');
const prompt = `根据以下支付信息,判断最合适的资产账户:
支付渠道: ${channel}
对方: ${counterparty}
可用账户: ${accountList}
返回账户名称。`;
const response = await callAiProvider(prompt);
return accounts.find(a => a.name === response.trim()) ?? null;
}
Phase 9: 测试与工程化(1-2 周)[P1]
9.1 测试框架配置
// package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "^3.2.0",
"@testing-library/react-native": "^13.0.0",
"msw": "^2.0.0"
}
}
9.2 单元测试
| 测试类型 | 覆盖范围 |
|---|---|
| 账务校验 | 平衡/不平衡分录、Decimal 精度、多币种 |
| 解析兼容 | 公开 Beancount 示例、中文账户名 |
| 账单适配器 | 收入、支出、退款、手续费、重复流水 |
| 规则引擎 | 优先级匹配、未匹配处理、规则统计、JS 规则执行 |
| 分类匹配 | 精确/关键词/模糊/兜底匹配 |
| 去重逻辑 | 时间窗口、金额匹配、账户关联、多通道去重 |
| 转账识别 | 金额匹配、方向检查、账户关联 |
| 备注模板 | 占位符替换、相邻重复归一化 |
| 信用卡 | 账单周期计算、应还金额 |
| 多币种 | 汇率转换、手动覆盖 |
9.3 集成测试
导入账本 → 导入支付宝账单 → 命中规则 → 修改草稿 → 确认 → 导出 → 桌面端校验通过
9.3.1 Beancount 生态兼容性回归(新增)
新增(审查订正):项目目标是"完全兼容 Beancount 生态",但原方案只有一句"桌面端校验通过"。需系统化的兼容性回归。
测试矩阵:
| 测试项 | 工具 | 验证点 |
|---|---|---|
| 语法正确性 | bean-check |
移动端写入的 mobile.bean 通过 bean-check 无报错 |
| 报表生成 | bean-report |
能生成 balance_journal/balance_account 等报表 |
| Web 查看器 | bean-web / fava |
mobile.bean 在 fava 中正常显示、可查询 |
| 真实账本导入 | 公开示例账本 | 导入 beancount 官方 example.beancount 解析无误 |
| 双轨制映射 | - | 分类 linkedAccount 映射的账户在桌面端可见 |
| 标签语法 | bean-check |
#tag 语法被桌面端正确识别为 tag |
回归用例(纳入 tests/ 持续运行):
// tests/ecosystem.test.ts
describe('Beancount 生态兼容性', () => {
it('写入的 mobile.bean 通过 bean-check 语法校验', async () => {
// 1. 用领域层生成 mobile.bean 内容
const mobileBean = generateSampleMobileBean();
// 2. 写入临时文件,调用 bean-check(CI 环境装 beancount)
const result = await runBeanCheck(mobileBean);
expect(result.errors).toEqual([]);
});
it('解析官方 example.beancount 不丢失交易', () => {
const official = readFixture('example.beancount');
const ledger = parseLedger([{ path: 'example.beancount', content: official }]);
// 不认识的指令记入 unsupported,但交易全部解析
expect(ledger.transactions.length).toBeGreaterThan(0);
});
});
9.4 端到端测试
- iOS/Android 真机验证离线记账
- 数据库加密验证
- 文件导入导出验证
- 应用锁和崩溃恢复
- 引导流程完整性
9.5 崩溃上报
参考 AutoAccounting 的 Bugly:
// src/services/crash.ts
import * as Sentry from '@sentry/react-native';
export function initCrashReporting() {
Sentry.init({
dsn: 'YOUR_SENTRY_DSN',
environment: __DEV__ ? 'development' : 'production',
tracesSampleRate: 1.0,
});
}
9.6 错误恢复 UI
参考 AutoAccounting 的 ErrorActivity:
// src/app/_error.tsx
function ErrorScreen({ error, resetError }: { error: Error; resetError: () => void }) {
return (
<View style={styles.container}>
<Icon name="error-outline" size={64} color={theme.colors.error} />
<Text style={styles.title}>应用遇到问题</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.actions}>
<Button onPress={resetError}>重试</Button>
<Button onPress={clearCacheAndRestart}>清除缓存并重启</Button>
<Button onPress={exportLogs}>导出日志</Button>
</View>
<Text style={styles.help}>
如果问题持续出现,请导出日志并提交到 GitHub Issues
</Text>
</View>
);
}
9.7 CI/CD
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run typecheck
- run: npm run test
- run: npm run lint
Phase 10: 孤儿文件清理与维护(可选)[P2]
10.1 孤儿文件扫描
参考 BeeCount 的 Orphan File GC:
// src/services/maintenance.ts
export async function scanOrphanFiles(): Promise<OrphanFile[]> {
const orphans: OrphanFile[] = [];
// 扫描孤立附件
const attachments = await getAllAttachments();
const usedUris = new Set(
(await getAllTransactions()).flatMap(t => t.attachmentUris || [])
);
for (const attachment of attachments) {
if (!usedUris.has(attachment.fileUri)) {
orphans.push({
type: 'attachment',
path: attachment.fileUri,
size: await getFileSize(attachment.fileUri),
});
}
}
// 扫描孤立缩略图
const thumbnails = await listThumbnails();
for (const thumb of thumbnails) {
if (!attachments.some(a => a.fileUri.includes(thumb.name))) {
orphans.push({
type: 'thumbnail',
path: thumb.uri,
size: thumb.size,
});
}
}
return orphans;
}
export async function cleanOrphanFiles(orphanFiles: OrphanFile[]): Promise<number> {
let cleaned = 0;
for (const file of orphanFiles) {
try {
await FileSystem.deleteAsync(file.path);
cleaned++;
} catch (e) {
logger.warn('maintenance', `Failed to clean orphan: ${file.path}`, e);
}
}
return cleaned;
}
10.2 数据库维护
// 数据库压缩(减小文件大小)
export async function compactDatabase(db: SQLite.SQLiteDatabase) {
await db.execAsync('VACUUM');
}
// 数据完整性检查
export async function checkDataIntegrity(db: SQLite.SQLiteDatabase): Promise<IntegrityReport> {
const issues: string[] = [];
// 检查孤立的 imported_events
const orphanEvents = await db.getAllAsync<{ id: string }>(
`SELECT ie.id FROM imported_events ie
LEFT JOIN ledger_files lf ON ie.committed_transaction IS NOT NULL
WHERE lf.path IS NULL`
);
if (orphanEvents.length > 0) {
issues.push(`${orphanEvents.length} orphan imported_events`);
}
// 检查损坏的 JSON payload
const invalidPayloads = await db.getAllAsync<{ id: string }>(
`SELECT id FROM imported_events WHERE json_valid(payload) = 0`
);
if (invalidPayloads.length > 0) {
issues.push(`${invalidPayloads.length} invalid JSON payloads`);
}
return { clean: issues.length === 0, issues };
}
性能预算(新增)
新增(审查订正):原方案缺少可量化的性能指标,"数据库作为读缓存"的必要性无法验证。以下为各关键路径的性能预算(中端设备基准,超标需优化)。
| 操作 | 预算 | 触发优化的阈值 | 优化手段 |
|---|---|---|---|
大账本解析(10MB .bean,~5000 交易) |
< 2s | > 3s | 分页解析 / 增量解析 / Worker 线程 |
| SQLite 缓存重建(5000 交易) | < 1s | > 2s | 批量事务 / 关闭 WAL 同步 |
| OCR 单次识别(PP-OCRv5,720px) | < 1.5s | > 3s | 短边压缩 / CPU 亲和性 |
| 冷启动到可交互 | < 2s | > 3s | 懒加载 / 缓存预热 |
| mobile.bean 追加写入(万条交易后) | < 100ms | > 300ms | 仅 append 不重写 / 按月分文件 |
| 交易列表滚动(1000 条) | 60fps | < 50fps | FlatList 虚拟化 / SQLite 分页 |
| 交易搜索(万条) | < 200ms | > 500ms | SQLite FTS5 全文索引 |
| 去重查询(万条历史) | < 100ms | > 300ms | 时间窗口 + 金额复合索引 |
测量方法:
// src/utils/perf.ts(性能埋点)
export function measure<T>(label: string, fn: () => T): T {
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
logger.debug('perf', `${label}: ${duration.toFixed(0)}ms`);
// 超预算告警
const budget = PERF_BUDGETS[label];
if (budget && duration > budget) {
logger.warn('perf', `${label} 超预算: ${duration.toFixed(0)}ms > ${budget}ms`);
}
return result;
}
回归监控:性能预算纳入 CI,关键操作每次变更后跑基准测试,超标阻断合并。
.bean 格式自身演进策略(新增)
新增(审查订正):mobile.bean 格式需要演进(如新增 metadata 字段),同时桌面端 Beancount 升级会引入新语法,移动端需跟进。
mobile.bean 自身格式演进
- 向前兼容:新版本写入的 mobile.bean 必须能被旧版本读取(旧版本忽略不认识的字段)
- 向后兼容:旧版本写入的 mobile.bean 必须能被新版本读取(新版本识别旧格式)
- 策略:mobile.bean 只用 Beancount 标准语法(不发明私有语法),演进通过 metadata(
key: "value")扩展,旧版本忽略未知 metadata - 版本标记:mobile.bean 首行可加注释
; beancount-mobile v1.2,便于诊断
; beancount-mobile v1.2
2026-07-13 * "咖啡店" "冰美式" #food
bm-category: "Food" ; 应用私有 metadata,桌面端忽略
bm-source: "ocr" ; 来源标记
Assets:Alipay -24.50 CNY
Expenses:Food 24.50 CNY
桌面端 Beancount 升级跟进
- 持续跟踪 beancount CHANGELOG
- 新增指令:先记入
unsupported(只读保留),评估使用频率后再决定是否支持解析 - 语法变更:更新
ledger.ts解析器,跑 9.3.1 生态兼容性回归
应用无障碍(Accessibility)适配(新增)
新增(审查订正):记账类应用用户群体含视障人士。本节指 VoiceOver / TalkBack 适配(非无障碍服务)。原方案全文 0 字提及。
适配要求:
| 组件 | 适配项 |
|---|---|
| 按钮控件 | accessibilityRole="button" + accessibilityLabel(语义化描述) |
| 金额输入 | accessibilityLabel="金额" + 朗读格式("24 元 5 角") |
| 交易列表项 | accessibilityLabel 拼接"日期 摘要 金额"(如"7月13日 咖啡店 支出24.5元") |
| 图表 | accessibilityLabel 提供文字摘要("本月支出 2000 元,比上月减少 15%") |
| 图标按钮 | 必须有文字 label(如相机图标 → "拍照识别") |
| 颜色信息 | 不依赖颜色传递信息(如支出/收入同时用 +/- 符号 + 颜色) |
| 焦点顺序 | 遵循阅读顺序(左到右、上到下) |
| 触摸目标 | 最小 44x44 pt(iOS)/ 48x48 dp(Android) |
测试:
- iOS:开启 VoiceOver,全流程走查
- Android:开启 TalkBack,全流程走查
- 自动化:
@testing-library/react-native的 accessibility 断言
预估工期
| 阶段 | 优先级 | 工作量 | 产出 |
|---|---|---|---|
| Phase 0: 基础架构 | P0 | 1-2 周 | 状态管理、迁移、安全、备份、错误处理、引导流程、日志 |
| Phase 1: UI 框架 | P0 | 2 周 | 基础页面、主题、国际化、搜索、深度链接、Speed Dial |
| Phase 2: 核心功能 | P0 | 3-4 周 | 交易管理、导入、规则引擎(JS)、去重、分类、标签、信用卡、备注模板、关键词过滤 |
| Phase 3: OCR 自动记账 | P0 | 2-3 周 | 分层处理、PP-OCRv5核心、无障碍、页面签名、浮窗、快速设置磁贴、横屏免打扰 |
| Phase 4: 通知/短信监听 | P0 | 1-2 周 | 实时账单捕获、每日提醒、信用卡提醒 |
| Phase 5: 高级功能 | P1 | 3-4 周 | 报表、图表、年度报告、净资产趋势、日历视图、预算、周期、附件、多币种增强、导出、海报生成 |
| Phase 6: 同步(可选) | P2 | 3-4 周 | Git/WebDAV/iCloud、冲突解决、错误持久化、增量/快照双模式 |
| Phase 7: iOS 自动记账 | P1 | 1-2 周 | 快捷指令、分享扩展、SiriKit(均为实验性) |
| Phase 8: AI 功能增强 | P1 | 2-3 周 | AI 聊天、语音记账、月度总结、分类识别、资产映射 |
| Phase 9: 测试与工程化 | P1 | 1-2 周 | 测试框架、CI/CD、崩溃上报、错误恢复UI |
| Phase 10: 维护(可选) | P2 | 1 周 | 孤儿文件清理、数据库维护 |
| 总计 | 20-28 周 |
版本里程碑
| 版本 | 包含 Phase | 预计工期 |
|---|---|---|
| v1.0 MVP | Phase 0-4 | 9-13 周 |
| v1.1 增强 | Phase 5, 7-9 | 7-11 周 |
| v2.0 进阶 | Phase 6, 10 | 4-6 周 |
参考项目
BeeCount(UI/UX + 分层架构参考)(./reference_project/BeeCount)
- 5 种云同步方案实现(Supabase/WebDAV/S3/iCloud/BeeCount Cloud)
- AI 智能记账(对话/OCR/语音/截图)
- 分层处理架构(规则匹配 → 本地 OCR → AI Vision)
- 30 版本数据库 schema 迁移
- 29 个 Riverpod provider 文件
- 9 大主题 + 暗黑模式 + 设计令牌系统
- 国际化(简中/繁中/英文/韩语)
- 二级分类 + 标签系统 + 预算管理
- 图表分析(fl_chart)
- 应用锁(生物识别/PIN)+ 隐私模糊
- 信用卡管理(账单日/还款日/额度/提醒)
- 日历视图 + 年度报告 + 净资产趋势
- 首页小组件(iOS/Android)
- 深度链接(beecount:// URL scheme)
- 分享海报生成
- 自定义月起始日
- 交易级多币种(nativeAmount + 汇率快照)
- 交易标志(excludeFromStats / excludeFromBudget)
- 孤儿文件 GC + 日志中心
- AI 聊天助手(GLM-4)+ 语音记账
AutoAccounting(OCR/自动化参考)(./reference_project/AutoAccounting)
OcrModule.kt:PP-OCRv5 本地模型(ONNX Runtime,见决策 7)NotificationListenerService.kt:通知监听(含 MD5 去重 + 关键词黑白名单)SmsReceiver.kt:短信监听RuleGenerator.kt:JS 规则引擎(QuickJS,系统/用户规则分离)RegexRuleTool.kt:AI 生成正则规则PageSignatureManager.kt:页面签名自动触发 OCRBillWindowManager.kt:浮窗账单提示(Channel 队列 + 300ms 节流)- 无障碍服务伪装(SelectToSpeakService)
- 嵌入式 Ktor HTTP 服务器(localhost:52045)
- 云规则分发系统(版本化 ZIP 包)
- TransferRecognizer:转账智能识别(Income+Expend → Transfer)
- BillMerger:智能字段合并(已知资产优先、名称长度偏好)
- DuplicateDetector:多通道联合去重
- Remark Template Engine:15+ 占位符 + 相邻重复归一化
- Bill Flag System:FLAG_NOT_COUNT / FLAG_NOT_BUDGET
- Landscape DND:横屏免打扰模式
- OcrTileService:快速设置磁贴
- ErrorActivity:崩溃恢复 UI
- WebDAVManager:WebDAV 备份 + 自动清理
- 9 AI Providers:ChatGPT/DeepSeek/Gemini/Kimi/智谱/OpenRouter/通义/硅基流动/MiMo
- Statistics Service:591 行综合统计(分类/商家/时段/同比)
文件清单
新增文件
src/
├── app/ # Expo Router 页面
│ ├── (tabs)/
│ ├── transaction/
│ ├── category/
│ ├── tag/
│ ├── budget/
│ ├── calendar/
│ └── credit-card/
├── components/ # 可复用组件
│ ├── DedupBanner.tsx
│ ├── SearchBar.tsx
│ ├── CategoryPicker.tsx
│ ├── TagPicker.tsx
│ ├── CalendarView.tsx
│ ├── SpeedDial.tsx
│ └── charts/
├── store/ # Zustand 状态管理
│ ├── ledgerStore.ts
│ ├── importStore.ts
│ └── settingsStore.ts
├── hooks/ # 自定义 Hooks
│ ├── useSearch.ts
│ ├── useCategory.ts
│ └── usePrivacyBlur.ts
├── services/ # 业务逻辑服务
│ ├── security.ts
│ ├── backup.ts
│ ├── privacyBlur.ts
│ ├── deepLink.ts
│ ├── reminder.ts
│ └── crash.ts
├── i18n/ # 国际化
│ ├── index.ts
│ ├── zh.ts
│ └── en.ts
├── theme/ # 主题系统
│ ├── tokens.ts
│ ├── presets.ts
│ ├── createTheme.ts
│ └── storage.ts
├── ocr/ # OCR 模块(Android)
│ ├── OcrBridge.ts
│ ├── OcrProcessor.ts
│ └── AiVisionProcessor.ts
├── ai/ # AI 功能
│ ├── chatAssistant.ts
│ ├── voiceInput.ts
│ ├── monthlySummary.ts
│ ├── categoryRecognizer.ts
│ └── assetMapper.ts
├── domain/
│ ├── dedup.ts
│ ├── categories.ts
│ ├── tags.ts
│ ├── budgets.ts
│ ├── recurring.ts
│ ├── attachments.ts
│ ├── currency.ts
│ ├── creditCards.ts
│ ├── transferRecognizer.ts
│ ├── remarkTemplate.ts
│ ├── keywordFilter.ts
│ ├── transactionFlags.ts
│ ├── annualReport.ts
│ ├── netWorth.ts
│ └── ruleEngine.ts
├── storage/
│ └── migrations.ts
└── utils/
├── errorHandler.ts
└── logger.ts
android/app/src/main/java/com/beancount/mobile/
├── ocr/
│ ├── OcrModule.kt
│ ├── OcrAccessibilityService.kt
│ ├── OcrManager.kt
│ ├── RuleMatcher.kt
│ ├── BillParser.kt
│ ├── PageSignatureManager.kt
│ ├── FloatingBillView.kt
│ └── OcrTileService.kt
├── notification/
│ └── NotificationListenerService.kt
├── sms/
│ └── SmsReceiver.kt
└── res/xml/
└── accessibility_service_config.xml
tests/
├── domain/
│ ├── rules.test.ts
│ ├── statements.test.ts
│ ├── dedup.test.ts
│ ├── categories.test.ts
│ ├── decimal.test.ts
│ ├── transferRecognizer.test.ts
│ ├── remarkTemplate.test.ts
│ ├── creditCards.test.ts
│ ├── currency.test.ts
│ └── ruleEngine.test.ts
├── store/
│ └── ledgerStore.test.ts
└── services/
├── security.test.ts
└── syncErrorManager.test.ts
修改文件
app.json # 添加 iOS/Android 配置
package.json # 添加依赖
tsconfig.json # 添加 paths 配置
android/app/build.gradle # 添加 ML Kit + PP-OCRv5 依赖
android/app/src/main/AndroidManifest.xml # 注册服务