# 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`,否则双轨制无法落地): ```typescript // 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[]; // 自动匹配关键词(仅本地匹配逻辑用) } ``` **映射失效处理**: ```typescript // 记账前校验 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 `` + `accessibility_service_config.xml` | `OcrAccessibilityService` Kotlin 实现 + 桥接事件 | | 通知监听 | 注册 manifest `` + `BIND_NOTIFICATION_LISTENER_SERVICE` 权限 | `NotificationListenerService` Kotlin + 事件推送 | | 短信监听 | 注册 manifest `` + `RECEIVE_SMS` 权限 | `SmsReceiver` Kotlin + 事件推送 | | 浮窗(TYPE_APPLICATION_OVERLAY) | 注册 `SYSTEM_ALERT_WINDOW` 权限 | `FloatingBillView` Kotlin + show/dismiss 桥接 | | 快速设置磁贴 | 注册 manifest `` + `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): ```javascript // 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](https://huggingface.co/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,一次解析 | ### 数据库表设计(最小化) 仅保留缓存必要的表,不存储任何"可从文件重建"以外的数据: ```sql -- 缓存解析后的 .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()` 一致),轻量且碰撞率足够低。 ```typescript // 当 .bean 文件变更时,重建缓存 import { hash as fnvHash } from '../domain/ledger'; // 复用现有 FNV-1a async function rebuildCache(ledger: LedgerIndex): Promise { // 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 作为轻量状态管理: ```typescript // src/store/ledgerStore.ts interface LedgerState { ledger: LedgerIndex | null; transactions: Transaction[]; accounts: Map; isLoading: boolean; error: string | null; // actions loadLedger: (path: string) => Promise; addTransaction: (draft: TransactionDraft) => Promise; refresh: () => Promise; } // src/store/importStore.ts interface ImportState { events: ImportedEvent[]; pendingConfirmations: TransactionDraft[]; dedupResult: DedupResult | null; // actions importCsv: (content: string, adapter: StatementAdapter) => Promise; confirmDraft: (draft: TransactionDraft) => Promise; 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) => void; setTheme: (name: string) => void; } ``` ### 0.2 数据库 Schema 版本与迁移 数据库是缓存层,表结构简单。采用版本号 + 迁移函数,但不预建未来表——按 Phase 需要逐步添加: ```typescript // src/storage/migrations.ts const SCHEMA_VERSION = 1; const MIGRATIONS: Record = { 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 { 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 的错误恢复,采用轻量方案——缓存层可重建,不需要持久化错误日志: ```typescript // 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; } // 全局错误边界 + 重试策略 function withRetry(fn: () => Promise, retries = 3, delay = 1000): Promise { 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)+ 隐私模糊: ```typescript // src/services/security.ts import * as LocalAuthentication from 'expo-local-authentication'; export async function authenticate(): Promise { 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` 中添加认证检查: ```tsx function RootLayout() { const [authenticated, setAuthenticated] = useState(false); useEffect(() => { authenticate().then(setAuthenticated); }, []); if (!authenticated) return authenticate().then(setAuthenticated)} />; return ; } ``` #### 隐私模糊(Privacy Blur) 参考 BeeCount 的隐私模糊模式——切换应用时全屏模糊,不仅是锁屏: ```typescript // 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` 文件(数据库可重建): ```typescript // 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 { 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 { 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 队列)**: ```typescript // src/domain/mobileStore.ts(Step 2 实现) // 所有写入串行化,避免多通道并发导致的内容交错/丢失 class MobileBeanStore { private writeQueue: Promise = Promise.resolve(''); async append(draft: TransactionDraft, ledger: LedgerIndex): Promise { // 串行化:每个 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)**: ```typescript // 写入中途崩溃不会损坏原文件 async function persist(content: string): Promise { 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)**: ```typescript // 启动时检查:若有 .tmp 残留,说明上次写入未完成,丢弃 tmp 保留原文件 async function recoverIfNeeded(): Promise { 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: ```typescript // 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 流程,新用户首次使用时引导完成基础配置: ```tsx // src/app/_onboarding.tsx function OnboardingScreen() { const [step, setStep] = useState(0); const steps = [ { title: '欢迎使用 beancount-mobile', description: '基于 Beancount 的移动端记账应用' }, { title: '选择语言', component: }, { title: '选择主题', component: }, { title: '导入账本', component: }, { title: '启用功能', component: }, // OCR、通知监听等 { title: '设置安全', component: }, // 应用锁 ]; return ( {step > 0 && } {step < steps.length - 1 ? : } ); } ``` 引导流程内容: 1. 欢迎页 + 应用介绍 2. 语言选择(中/英) 3. 主题选择(浅色/深色/跟随系统) 4. 账本导入(选择 .bean 文件或新建) 5. 功能启用(OCR、通知监听、短信监听的权限说明) 6. 安全设置(应用锁、隐私模糊) --- ## 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 化架构**——组件不直接引用颜色值,而是引用语义化令牌,主题通过令牌映射实现。 #### 设计令牌定义 ```typescript // 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 }; }; } ``` #### 预置主题 ```typescript // 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 = { light: lightTheme, dark: darkTheme, }; ``` #### 自定义主题工厂 ```typescript // src/theme/createTheme.ts import type { ThemeTokens } from './tokens'; import { lightTheme } from './presets'; // 用户只需提供覆盖项,其余从基础主题继承 export function createTheme(overrides: Partial & { 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 ```typescript // 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(null!); export function ThemeProvider({ children }: { children: React.ReactNode }) { const systemColorScheme = useColorScheme(); const [themeName, setThemeName] = useState('system'); const [customThemes, setCustomThemes] = useState>({}); // 加载持久化的主题偏好 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 ( {children} ); } export function useTheme() { return useContext(ThemeContext); } ``` #### 组件使用方式 ```tsx // 组件通过 useTheme() 获取令牌,不硬编码颜色 function TransactionCard({ transaction }: { transaction: Transaction }) { const { theme } = useTheme(); return ( {transaction.narration} {transaction.date} ); } ``` #### 主题持久化与导入/导出 ```typescript // 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 { return await AsyncStorage.getItem(THEME_KEY); } export async function saveTheme(name: string): Promise { await AsyncStorage.setItem(THEME_KEY, name); } // 自定义主题持久化 export async function loadCustomThemes(): Promise> { const json = await AsyncStorage.getItem(CUSTOM_THEMES_KEY); return json ? JSON.parse(json) : {}; } export async function saveCustomTheme(name: string, tokens: ThemeTokens): Promise { 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; } ``` #### 设置页面主题选择 ```tsx function ThemeSettings() { const { themeName, setTheme, registerTheme } = useTheme(); return ( 主题模式 setTheme(['light', 'dark', 'system'][index])} /> 自定义主题 ); } ``` #### 主题扩展点 | 扩展方式 | 说明 | 示例 | |---------|------|------| | 修改令牌值 | 覆盖预置主题的颜色/间距 | `createTheme({ colors: { accent: '#FF5722' } })` | | 创建新主题 | 基于模板完全自定义 | 注册"森林"/"海洋"/"日落"等主题 | | 动态主题 | 运行时切换 | 根据时间/天气自动切换 | | 主题分享 | JSON 导入导出 | 用户间分享自定义主题 | | 第三方主题 | 通过插件系统 | 社区贡献主题包 | ### 1.3 国际化 ```typescript // 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 核心页面实现 #### 首页(账本概览) ```tsx function HomeScreen() { const { ledger } = useLedgerStore(); return ( ); } ``` #### Speed Dial 快速操作 参考 BeeCount 的 Speed Dial FAB——长按添加按钮弹出快捷操作: ```tsx function SpeedDial() { const [isOpen, setIsOpen] = useState(false); return ( {isOpen && ( )} setIsOpen(!isOpen)} onLongPress={() => setIsOpen(true)} size="large" /> ); } ``` #### 记账页面(复式分录) ```tsx function TransactionEditor() { const [postings, setPostings] = useState([ { account: '', amount: '', currency: 'CNY' }, { account: '', amount: '', currency: 'CNY' }, ]); const balance = calculateBalance(postings); return ( ); } ``` #### 交易搜索页面 参考 BeeCount 的多维筛选(关键字/分类/账户/日期/标签): ```tsx function TransactionSearchScreen() { const [query, setQuery] = useState(''); const [filters, setFilters] = useState({ category: undefined, account: undefined, dateRange: undefined, tags: [], direction: undefined, }); const results = useSearch(query, filters); return ( ); } ``` ### 1.5 深度链接(Deep Link) 参考 BeeCount 的 `beecount://` URL scheme: ```typescript // src/services/deepLink.ts import * as Linking from 'expo-linking'; const PREFIX = 'beancount://'; type DeepLinkAction = | { type: 'add-transaction'; draft?: Partial } | { 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 依赖添加 ```json { "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: ```typescript // 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」。 ```typescript // 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} 已不存在,已降级` }; } ``` 分类管理页面: ```tsx function CategoryScreen() { const [categories, setCategories] = useState([]); return ( c.type === 'expense') }, { title: '收入分类', data: categories.filter(c => c.type === 'income') }, ]} renderItem={({ item }) => ( editCategory(item)} onDelete={() => deleteCategory(item.id)} /> )} renderSectionHeader={({ section }) => {section.title}} /> ); } ``` ### 2.4 标签系统(双轨制) > **订正**:标签采用双轨制。应用 `tags` 表存 UI 增强(name/color),记账时写回 `.bean` 用 Beancount 原生 `#tag` 语法(见 `serializeTransaction`)。 ```typescript // 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(''); } ``` 标签管理 + 筛选: ```tsx function TagScreen() { return ( ( filterByTag(item.id)} /> )} /> ); } ``` ### 2.5 CSV 导入增强 ```typescript // 扩展适配器支持 type StatementAdapter = | 'alipay-csv-v1' // 支付宝 | 'wechat-csv-v1' // 微信支付 | 'bank-csv-v1' // 通用银行 | 'cmb-csv-v1' // 招商银行 | 'icbc-csv-v1' // 工商银行 | 'custom-csv-v1'; // 自定义格式 ``` 补充招商银行和工商银行适配器解析逻辑: ```typescript // 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 规则: ```typescript // 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): Promise | 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 } | 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`**。 ```typescript // 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(); 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 ): 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 识别 | 无障碍服务截屏 | 时间窗口 + 金额 + 账户关联 | | 通知监听 | 系统通知 | 通知内容哈希 + 时间窗口 | | 短信监听 | 短信接收 | 短信内容哈希 + 时间窗口 | | 手工新增 | 用户手动输入 | 时间 + 金额 + 账户 + 对手方 | **去重条件**(必须同时满足): 1. **时间窗口**:同一笔交易的时间差异 ≤ 5 分钟 2. **金额匹配**:交易金额完全一致(绝对值比较) 3. **账户关联**:存在转账关系(如微信→银行卡) #### 多通道联合去重 参考 AutoAccounting 的跨通道去重——微信通知+银行短信+应用 Hook 对同一笔交易去重: ```typescript // 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`): ```typescript // src/domain/billPipeline.ts(Step 4 实现) export class BillPipeline { private mutex: Promise = Promise.resolve(); // 所有入口(手动/CSV/OCR/通知/短信)都经过此方法,保证串行 async process(events: ImportedEvent[], ctx: PipelineContext): Promise { // 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(fn: () => Promise): Promise { const run = this.mutex.then(fn, fn); this.mutex = run.then(() => undefined, () => undefined); return run; } } ``` **为什么串行化重要**:若 OCR 和通知同时识别到同一笔交易,并发处理会导致:转账识别漏配对、去重失效、mobile.bean 写入交错损坏。串行化是正确性前提,性能影响小(毫秒级)。 ### 2.9 备注模板系统 参考 AutoAccounting 的 Remark Template Engine: ```typescript // src/domain/remarkTemplate.ts interface RemarkTemplate { id: string; name: string; template: string; // 支持占位符 enabled: boolean; } // 占位符定义 const PLACEHOLDERS: Record 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: ```typescript // 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 的职责模板**: ```javascript // 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: 规则预匹配(快速,无成本) ```kotlin // 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」。 ```kotlin // 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(高精度,有成本)- 可选 ```typescript // src/ocr/AiVisionProcessor.ts export async function processAiVision(image: string): Promise { 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 检测屏蔽。 ```kotlin // 使用 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) ```kotlin // 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) ```kotlin // 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(R.id.amount).text = event.amount view.findViewById(R.id.merchant).text = event.counterparty view.findViewById(R.id.time).text = event.occurredAt // 点击确认记账 view.findViewById 如果问题持续出现,请导出日志并提交到 GitHub Issues ); } ``` ### 9.7 CI/CD ```yaml # .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: ```typescript // src/services/maintenance.ts export async function scanOrphanFiles(): Promise { 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 { 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 数据库维护 ```typescript // 数据库压缩(减小文件大小) export async function compactDatabase(db: SQLite.SQLiteDatabase) { await db.execAsync('VACUUM'); } // 数据完整性检查 export async function checkDataIntegrity(db: SQLite.SQLiteDatabase): Promise { 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 | 时间窗口 + 金额复合索引 | **测量方法**: ```typescript // src/utils/perf.ts(性能埋点) export function measure(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 ; 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](https://github.com/beancount/beancount/blob/master/CHANGES) - 新增指令:先记入 `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`:页面签名自动触发 OCR - `BillWindowManager.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 # 注册服务 ```