- 切换到 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+ 单元测试覆盖核心逻辑
4576 lines
152 KiB
Markdown
4576 lines
152 KiB
Markdown
# 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 `<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):
|
||
|
||
```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<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 作为轻量状态管理:
|
||
|
||
```typescript
|
||
// 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 需要逐步添加:
|
||
|
||
```typescript
|
||
// 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 的错误恢复,采用轻量方案——缓存层可重建,不需要持久化错误日志:
|
||
|
||
```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<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)+ 隐私模糊:
|
||
|
||
```typescript
|
||
// 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` 中添加认证检查:
|
||
|
||
```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 的隐私模糊模式——切换应用时全屏模糊,不仅是锁屏:
|
||
|
||
```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<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 队列)**:
|
||
|
||
```typescript
|
||
// 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)**:
|
||
|
||
```typescript
|
||
// 写入中途崩溃不会损坏原文件
|
||
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)**:
|
||
|
||
```typescript
|
||
// 启动时检查:若有 .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:
|
||
|
||
```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: <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>
|
||
);
|
||
}
|
||
```
|
||
|
||
引导流程内容:
|
||
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<string, ThemeTokens> = {
|
||
light: lightTheme,
|
||
dark: darkTheme,
|
||
};
|
||
```
|
||
|
||
#### 自定义主题工厂
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```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<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);
|
||
}
|
||
```
|
||
|
||
#### 组件使用方式
|
||
|
||
```tsx
|
||
// 组件通过 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>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 主题持久化与导入/导出
|
||
|
||
```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<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;
|
||
}
|
||
```
|
||
|
||
#### 设置页面主题选择
|
||
|
||
```tsx
|
||
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 国际化
|
||
|
||
```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 (
|
||
<View>
|
||
<AccountSummary accounts={ledger?.accounts} />
|
||
<RecentTransactions transactions={ledger?.transactions.slice(0, 10)} />
|
||
<MonthlyReport transactions={ledger?.transactions} />
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### Speed Dial 快速操作
|
||
|
||
参考 BeeCount 的 Speed Dial FAB——长按添加按钮弹出快捷操作:
|
||
|
||
```tsx
|
||
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>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 记账页面(复式分录)
|
||
|
||
```tsx
|
||
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 的多维筛选(关键字/分类/账户/日期/标签):
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```typescript
|
||
// 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 依赖添加
|
||
|
||
```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<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`)。
|
||
|
||
```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 (
|
||
<View>
|
||
<FlatList
|
||
data={tags}
|
||
renderItem={({ item }) => (
|
||
<TagChip tag={item} onPress={() => filterByTag(item.id)} />
|
||
)}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 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<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`**。
|
||
|
||
```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<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 识别 | 无障碍服务截屏 | 时间窗口 + 金额 + 账户关联 |
|
||
| 通知监听 | 系统通知 | 通知内容哈希 + 时间窗口 |
|
||
| 短信监听 | 短信接收 | 短信内容哈希 + 时间窗口 |
|
||
| 手工新增 | 用户手动输入 | 时间 + 金额 + 账户 + 对手方 |
|
||
|
||
**去重条件**(必须同时满足):
|
||
|
||
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<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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```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<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 检测屏蔽。
|
||
|
||
```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<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 层分层处理
|
||
|
||
```typescript
|
||
// 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——游戏/视频时自动暂存账单:
|
||
|
||
```kotlin
|
||
// 在 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:
|
||
|
||
```kotlin
|
||
// 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)**:
|
||
|
||
```kotlin
|
||
// 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 后台执行)**:
|
||
|
||
```swift
|
||
// 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`(含去重守卫 + 关键词黑白名单):
|
||
|
||
```kotlin
|
||
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`(含关键词过滤):
|
||
|
||
```kotlin
|
||
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 短信解析器
|
||
|
||
```kotlin
|
||
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 的每日记账提醒:
|
||
|
||
```typescript
|
||
// 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 账户树与报表
|
||
|
||
```typescript
|
||
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 实现:
|
||
|
||
```tsx
|
||
// 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 的年度报告:
|
||
|
||
```typescript
|
||
// 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 的净资产追踪:
|
||
|
||
```typescript
|
||
// 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 的日历视图:
|
||
|
||
```tsx
|
||
// 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 的日历视图——按日期浏览交易:
|
||
|
||
```tsx
|
||
// 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 的信用卡管理:
|
||
|
||
```typescript
|
||
// 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 的月度总预算 + 分类预算 + 超支提醒:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// src/domain/attachments.ts
|
||
export interface Attachment {
|
||
id: string;
|
||
transactionId: string;
|
||
fileName: string;
|
||
fileUri: string;
|
||
mimeType: string;
|
||
createdAt: string;
|
||
}
|
||
```
|
||
|
||
### 5.8 多币种支持
|
||
|
||
参考 BeeCount v30 的交易级多币种:
|
||
|
||
```typescript
|
||
// 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 导出功能
|
||
|
||
```typescript
|
||
// 导出为 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:
|
||
|
||
```typescript
|
||
// 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 诊断页面
|
||
|
||
```typescript
|
||
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 的云规则分发系统:
|
||
|
||
```typescript
|
||
// 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 同步
|
||
|
||
```typescript
|
||
// 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 同步
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```sql
|
||
-- 同步失败记录表
|
||
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=已跳过
|
||
);
|
||
```
|
||
|
||
```typescript
|
||
// 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 的"双击背部触发快捷指令":
|
||
|
||
```yaml
|
||
# 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:
|
||
|
||
```typescript
|
||
// 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 集成(受限)[实验性]
|
||
|
||
```typescript
|
||
// 通过 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 抽象**:
|
||
|
||
```typescript
|
||
// 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 等敏感信息):
|
||
|
||
```typescript
|
||
// 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 前过滤敏感字段):
|
||
|
||
```typescript
|
||
// 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()`)。
|
||
|
||
```typescript
|
||
export function removeThink(text: string): string {
|
||
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||
}
|
||
```
|
||
|
||
### 8.1 AI 聊天助手
|
||
|
||
参考 BeeCount 的 AI Chat Assistant(GLM-4):
|
||
|
||
```typescript
|
||
// 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(长按说话):
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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 测试框架配置
|
||
|
||
```json
|
||
// 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/` 持续运行):
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```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<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 数据库维护
|
||
|
||
```typescript
|
||
// 数据库压缩(减小文件大小)
|
||
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 | 时间窗口 + 金额复合索引 |
|
||
|
||
**测量方法**:
|
||
|
||
```typescript
|
||
// 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
|
||
; 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 # 注册服务
|
||
``` |