feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能

核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
This commit is contained in:
fengmengqi 2026-07-18 18:02:45 +08:00
parent f6437b83fe
commit 76a5853ab6
133 changed files with 26279 additions and 8594 deletions

17
.gitignore vendored
View File

@ -8,8 +8,8 @@ web-build/
expo-env.d.ts expo-env.d.ts
# Native # Native
android/ /android/
ios/ /ios/
*.hprof *.hprof
# Metro # Metro
@ -35,17 +35,16 @@ yarn-error.*
.env.* .env.*
# Outputs # Outputs
outputs/ /outputs/
# Example financial data (may contain real PII) # Example financial data (may contain real PII)
example/ /example/
# AI assistant config # AI assistant config
CLAUDE.md CLAUDE.md
# MiMoCode # MiMoCode
.mimocode/ /.mimocode/
.agent/ /.agents/
reference_project/ /reference_project/
.zcode/ /.zcode/
example/

View File

@ -17,6 +17,7 @@
"./plugins/notification-listener", "./plugins/notification-listener",
"./plugins/sms-receiver", "./plugins/sms-receiver",
"./plugins/screenshot-monitor", "./plugins/screenshot-monitor",
"./plugins/size-optimization",
"expo-secure-store" "expo-secure-store"
], ],
"experiments": { "experiments": {

View File

@ -0,0 +1,206 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** beancount-mobile
**Generated:** 2026-07-16 16:32:57
**Category:** Personal Finance Tracker
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#1E40AF` | `--color-primary` |
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| Accent/CTA | `#059669` | `--color-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#FFFFFF` | `--color-foreground` |
| Muted | `#101A34` | `--color-muted` |
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#1E40AF` | `--color-ring` |
**Color Notes:** Trust blue + profit green on dark
### Typography
- **Heading Font:** Caveat
- **Body Font:** Quicksand
- **Mood:** handwritten, personal, friendly, casual, warm, charming
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E40AF;
border: 2px solid #1E40AF;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E40AF;
outline: none;
box-shadow: 0 0 0 3px #1E40AF20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** Interactive Product Demo
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Pure white backgrounds
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile

View File

@ -1,529 +1,100 @@
# Android 编译与真机运行完整指南 # Android 安装包打包与体积优化指南 (Android Build Guide)
> 本文档记录了从零开始搭建 Android 编译环境、修复 Config Plugin / Kotlin / 依赖冲突、到最终在真机上成功运行 debug APK 的完整流程 在引入机器学习引擎ONNX Runtime及 React Native 新架构后,本项目的原生 C/C++ SO 库体积大幅增加。为避免将无意义的冗余架构包打包给最终手机用户,本项目对 Android 构建配置进行了专项体积优化
>
> 适用环境:Windows 11 + Expo SDK 54 + React Native 0.81 + PP-OCRv5 (ONNX Runtime) 本篇指南将为您介绍项目中的打包机制、优化细节,以及如何通过命令行进行差异化编译
--- ---
## 目录 ## 1. 体积优化核心原理
1. [环境准备](#1-环境准备) ### 1.1 ABI 分包 (ABI Splits)
2. [安装 Android SDK + NDK](#2-安装-android-sdk--ndk) 在 Android 系统的 `build.gradle` 中,我们启用了分包编译:
3. [ONNX 模型获取](#3-onnx-模型获取) ```groovy
4. [expo prebuild](#4-expo-prebuild) splits {
5. [Gradle 编译](#5-gradle-编译) abi {
6. [安装到真机](#6-安装到真机) enable true
7. [Metro + 运行](#7-metro--运行) reset()
8. [常见问题与解决方案](#8-常见问题与解决方案) include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
9. [已修复的 Bug 清单](#9-已修复的-bug-清单) universalApk true
}
}
```
* **效果**:系统会为每种 CPU 架构单独输出一个体积小巧的 APK并额外保留一个兼容所有架构的通用包Universal APK
* **独立包大小**~35MB - ~40MB
* **通用包大小**~140MB - ~170MB
### 1.2 默认编译架构限制
我们在 `android/gradle.properties` 中指定了默认编译目标为:
```properties
reactNativeArchitectures=arm64-v8a
```
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
> [!IMPORTANT]
> 由于 `android/` 目录被 Git 忽略,**不要直接手动在其他设备上提交原生配置变更**。
> 本项目已编写了专门的本地 Config Plugin[size-optimization](file:///c:/Users/fmq/Documents/work/beancount-mobile/plugins/size-optimization/app.plugin.js)。
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置ABI 分包、默认单架构编译):
> ```bash
> npx expo prebuild --platform android
> ```
--- ---
## 1. 环境准备 ## 2. 编译命令与打包指令
### JDK 请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
React Native 0.81 + Gradle 8.x 需要 **JDK 17+**。本项目使用 **JDK 21**
```bash
# 验证
java -version
# 应输出: java version "21.0.x"
# 确认 JAVA_HOME 指向正确的 JDK
echo $JAVA_HOME
# 应输出: C:\Program Files\Java\jdk-21
```
> **注意**:如果 `JAVA_HOME` 指向了别的用户的残留目录(如 `D:\Users\yxp\JDK`),需要通过「系统属性 → 环境变量 → 系统变量」修正为实际 JDK 路径。
### Node.js
```bash
node -v # 需要 v20+
npm -v
```
---
## 2. 安装 Android SDK + NDK
### 方式 A:Android Studio(推荐)
1. 下载 Android Studio:https://developer.android.com/studio
2. 安装时勾选 Android SDK / SDK Platform / Android Virtual Device
3. 首次启动自动下载 SDK 到默认位置 `C:\Users\<用户>\AppData\Local\Android\Sdk`
### 方式 B:命令行(轻量)
如果已装 Android Studio 但缺少 cmdline-tools:
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
TMP="/tmp"
# 下载 cmdline-tools(官方,约 130MB)
curl -L -o "$TMP/cmdline-tools.zip" \
"https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip"
# 解压到标准位置
mkdir -p "$SDK/cmdline-tools"
unzip -q "$TMP/cmdline-tools.zip" -d "$TMP/"
cp -r "$TMP/cmdline-tools" "$SDK/cmdline-tools/latest"
```
### 必装组件
```bash
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
SDKMGR="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager.bat"
# 接受所有 license
yes | "$SDKMGR" --licenses
# 安装必需组件
yes | "$SDKMGR" \
"platform-tools" \
"platforms;android-35" \
"build-tools;35.0.0"
```
| 组件 | 用途 |
|------|------|
| `platform-tools` | adb(设备连接) |
| `platforms;android-35` | 编译目标 SDK |
| `build-tools;35.0.0` | aapt/dex/打包工具 |
| `cmdline-tools;latest` | sdkmanager(expo prebuild 需要) |
### NDK(React Native 编译 C++ 需要)
React Native 0.81 要求 **NDK 27.1.12297006**(~745MB)。
#### 国内推荐:腾讯云镜像下载
Google 官方源在国内经常断连导致 zip 损坏。**强烈推荐用腾讯云镜像**:
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
TMP="/tmp"
# 从腾讯云镜像下载(55MB/s 稳定,约 13 秒)
curl -L -o "$TMP/ndk.zip" \
"https://mirrors.cloud.tencent.com/AndroidSDK/android-ndk-r27b-windows.zip"
# 解压
mkdir -p "$TMP/ndk-extract"
unzip -q "$TMP/ndk.zip" -d "$TMP/ndk-extract"
# 安装到标准位置
mv "$TMP/ndk-extract/android-ndk-r27b" "$SDK/ndk/27.1.12297006"
```
#### expo-sqlite 的 NDK 版本冲突
`expo-sqlite` 可能要求 NDK `27.0.12077973`(不同于 RN 要求的 `27.1.12297006`)。
解决方式:用 Windows 目录联接(junction)让两个版本指向同一份 NDK:
### 2.1 本地测试/实体分发(仅编译 arm64-v8a最快最推荐
直接运行默认编译,会使用 `gradle.properties` 中配置 of `arm64-v8a`
```powershell ```powershell
# 在 PowerShell 中执行 # 在 Windows Powershell 下执行:
cmd /c mklink /J ` $env:JAVA_HOME="C:\Program Files\Java\jdk-21"
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.0.12077973" ` $env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.1.12297006"
```
> 两个版本号 ABI/API 兼容,联接不会导致编译问题。
#### NDK 完整性验证
NDK 解压后必须验证 arm64 目标库完整,否则 CMake 会报 "clang broken":
```bash
SDK="$HOME/AppData/Local/Android/Sdk"
NDK="$SDK/ndk/27.1.12297006"
# 关键文件必须存在
ls "$NDK/sysroot/usr/lib/aarch64-linux-android/24/crtbegin_dynamic.o" \
"$NDK/sysroot/usr/lib/aarch64-linux-android/libc.a" \
"$NDK/toolchains/llvm/prebuilt/windows-x86_64/lib/clang/18/lib/linux/libclang_rt.builtins-aarch64-android.a"
```
> ⚠️ 如果 C 盘空间不足(NDK 解压后约 2.3GB),解压会中途截断,导致 `libc.a` 等文件缺失。确保至少有 **5GB 可用空间**
---
## 3. ONNX 模型获取
本项目使用 PP-OCRv5 + ONNX Runtime(替代 NCNN,见 plan.md 决策 7)。
### 下载社区 ONNX 模型
```bash
cd plugins/ppocr/assets
mkdir -p . && cd .
# det 模型(4.8 MB)
curl -L -o ppocrv5_det.onnx \
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx"
# rec 模型(16.6 MB)
curl -L -o ppocrv5_rec.onnx \
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx"
# CJK 字典(26 KB,PaddleOCR 标准字典)
curl -L -o ppocr_keys_v1.txt \
"https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt"
```
### 验证模型 I/O 规格
```python
import onnx
for name in ['det', 'rec']:
m = onnx.load(f'plugins/ppocr/assets/ppocrv5_{name}.onnx')
print(f'{name}:')
for i in m.graph.input:
shape = [d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]
print(f' input {i.name}: {shape}')
for o in m.graph.output:
shape = [d.dim_value or d.dim_param for d in o.type.tensor_type.shape.dim]
print(f' output {o.name}: {shape}')
```
预期输出:
- det: 输入 `x: [N, 3, H, W]`(动态),输出 `[N, 1, H, W]`
- rec: 输入 `x: [N, 3, 48, W]`(高固定 48),输出 `[N, T, 18385]`
---
## 4. expo prebuild
`expo prebuild` 把 Config Plugin 注入到原生工程(生成 `android/` 目录)。
```bash
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
npx expo prebuild --platform android --no-install
```
### 验证注入结果
```bash
# Kotlin 源码(应有 9 个 .kt 文件)
find android/app/src/main/java/com/beancount -name "*.kt" | sort
# ONNX 模型 + 字典
ls android/app/src/main/assets/
# MainApplication 里 OcrPackage 注册
grep "OcrPackage" android/app/src/main/java/com/example/beanmobile/MainApplication.kt
# onnxruntime gradle 依赖
grep "onnxruntime" android/app/build.gradle
# AndroidManifest 服务注册
grep "Billing" android/app/src/main/AndroidManifest.xml
```
### android/ 目录锁定问题(Windows + ZCode)
如果 `android/` 目录被进程锁定导致 prebuild 无法 `rmdir`,在临时副本项目中 prebuild 再 robocopy 回来:
```bash
# 1. 创建临时副本
TMP="/tmp/bm-prebuild"
mkdir -p "$TMP"
cp app.json package.json package-lock.json tsconfig.json "$TMP/"
cp -r app/ src/ plugins/ "$TMP/"
# node_modules 用 junction 共享
powershell -NoProfile -Command "cmd /c mklink /J '$TMP/node_modules' '$(pwd)/node_modules'"
# 2. 在临时项目 prebuild
cd "$TMP"
npx expo prebuild --platform android --no-install --clean
# 3. robocopy 回原项目(能处理锁定)
powershell -NoProfile -Command "robocopy '$TMP/android' '$(pwd -W || pwd)/android' /E /PURGE"
```
---
## 5. Gradle 编译
### 前提:onnxruntime 版本修正
Config Plugin 注入的 `app/build.gradle` 中 onnxruntime 版本可能不匹配,每次 prebuild 后需检查:
```bash
# 确认是 1.20.0(不是 1.20.1,后者不存在)
grep "onnxruntime" android/app/build.gradle
# 如果是 1.20.1,修正:
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
```
### 编译 Kotlin(快速验证,不打包)
```bash
cd android cd android
export JAVA_HOME="C:/Program Files/Java/jdk-21" .\gradlew.bat :app:assembleRelease --offline --no-daemon
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
./gradlew :app:compileDebugKotlin --no-daemon
```
### 完整打包 APK
```bash
./gradlew :app:assembleDebug --no-daemon
```
APK 输出位置:`android/app/build/outputs/apk/debug/app-debug.apk`(~250MB)
### Gradle 缓存清理(遇到诡异问题时)
```bash
# 删 build 缓存
rm -rf app/build build app/.cxx
# 彻底重来(连 Gradle daemon 也杀)
powershell -NoProfile -Command "Get-Process -Name 'java' -ErrorAction SilentlyContinue | Stop-Process -Force"
``` ```
编译完成后,可在以下路径找到适合真机安装的轻量版 APK约 37MB
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
--- ---
## 6. 安装到真机 ### 2.2 全量分包发布(适合多设备兼容测试/全网发布)
若需要同时生成适配所有手机架构的独立 APK 以及一个通用包,可以在命令行中通过参数**覆盖默认架构**
### 开启 USB 调试 ```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
1. 手机「设置 → 关于手机」→ 连续点击「MIUI 版本」7 次 → 开启开发者选项 $env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
2. 「设置 → 更多设置 → 开发者选项」→ 打开:
- ✅ USB 调试
- ✅ **USB 安装**(红米/小米必须开,否则 `adb install` 被拒为 `INSTALL_FAILED_USER_RESTRICTED`)
3. USB 连接电脑(文件传输模式)
### adb install
```bash
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
ADB="$ANDROID_HOME/platform-tools/adb"
# 确认设备连接
"$ADB" devices
# 应显示 device 状态
# 安装
"$ADB" install -r android/app/build/outputs/apk/debug/app-debug.apk
```
---
## 7. Metro + 运行
Debug APK 需要从 Metro server 加载 JS bundle(不内嵌 JS)。
### 启动 Metro
```bash
npx expo start --dev-client --port 8081
```
### adb reverse(USB 连接,手机和电脑不在同一 WiFi 时必需)
```bash
"$ADB" reverse tcp:8081 tcp:8081
```
### 启动 app
```bash
# 命令行启动
"$ADB" shell am start -n com.example.beanmobile/.MainActivity
# 或直接在手机桌面点击「Bean Mobile」图标
```
### 验证
```bash
# 进程存活?
"$ADB" shell pidof com.example.beanmobile
# JS 日志
"$ADB" logcat -d | grep "ReactNativeJS"
# 截图
"$ADB" shell screencap -p /sdcard/screen.png
"$ADB" pull /sdcard/screen.png /tmp/app-screen.png
```
---
## 8. 常见问题与解决方案
### `withAndroidMainApplication is not a function`
**原因**:SDK 54 的 `@expo/config-plugins` 没有 `withAndroidMainApplication` / `withAndroidGradle`
**解决**:改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod`
### `Cannot read properties of undefined (reading '0')` in AndroidManifest
**原因**:`manifest.application` 在 prebuild 初始模板里可能不存在。
**解决**:操作前加防御性检查:
```javascript
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
```
### `modResults` 结构搞错导致 manifest 重复 `<root>` 标签
**原因**:`withAndroidManifest` 的 `modResults` 结构是 `{ manifest: { ... } }`,不是直接就是 manifest。
**解决**:所有权限和 application 操作通过 `modConfig.modResults.manifest`,不是 `modConfig.modResults` 本身。
### MainApplication 里 import OcrPackage 没注入
**原因**:Kotlin 的 package 声明**没有分号**(`package com.example.beanmobile`,不是 `package com.example.beanmobile;`),正则 `^(package [\w.]+;)` 匹配不到。
**解决**:正则去掉分号要求:`^(package\s+[\w.]+;?\s*)$`。
### Config Plugin 返回 undefined 导致整个 config 丢失
**原因**:插件函数最后漏了 `return config;`,导致 `withAndroidManifest` 的结果没返回,config 变成 undefined,后续所有插件崩溃。
**解决**:每个插件函数末尾必须有 `return config;`
### `NDK at ...27.0.12077973 did not have a source.properties file`
**原因**:expo-sqlite 要求 NDK 27.0,但只装了 27.1;Gradle 自动下载 27.0 失败留下空目录。
**解决**:用 `mklink /J` 创建目录联接(见第 2 节)。
### NDK `clang broken` / `cannot open crtbegin_dynamic.o`
**原因**:NDK 解压时磁盘空间不足,arm64 目标库被截断。
**解决**:确保至少 5GB 可用空间,重新从腾讯云镜像下载并解压。
### `onnxruntime-android:1.20.1` not found
**原因**:Maven Central 上最新稳定版是 `1.20.0`,`1.20.1` 不存在。
**解决**:`sed -i 's/1.20.1/1.20.0/' android/app/build.gradle`
### `NoSuchMethodError: getDirectConverter` (expo-font 崩溃)
**原因**:`@expo/vector-icons@15.x` 拉进了 `expo-font@57.0.0`(SDK 55 版本),与 `expo-modules-core@3.0.x`(SDK 54)不兼容。
**解决**:在 `package.json` 加 npm override 强制降级:
```json
"overrides": {
"expo-font": "~14.0.12"
}
```
### `Unable to resolve "expo-linking"`
**原因**:expo-router 的 peer dependencies 没有全部安装。
**解决**:补全所有 peer deps:
```bash
npm install --legacy-peer-deps \
@expo/metro-runtime expo-constants expo-linking \
react-dom react-native-gesture-handler react-native-reanimated \
@react-navigation/drawer
```
### `INSTALL_FAILED_USER_RESTRICTED` (红米/小米)
**原因**:MIUI 的「USB 安装」开关默认关闭。
**解决**:开发者选项里打开「USB 安装」。
### `EBUSY: resource busy or locked, rmdir android/`
**原因**:ZCode 的子进程(NodeBabyLinkService)持有 `android/` 目录句柄,`rmdir` 失败。
**解决**:在临时副本项目里 prebuild,再用 `robocopy /E /PURGE` 复制回原项目(见第 4 节)。
### `attribute android:canTakeScreenshots not found`
**原因**:`canTakeScreenshots` 是 API 30+ 属性,部分 compileSdk 配置下 AAPT 不识别。
**解决**:从 `accessibility_service_config.xml` 删除该属性(非必需,截图功能在 Kotlin 代码里实现)。
---
## 9. 已修复的 Bug 清单
以下是真机编译过程中暴露并修复的全部 bug(共 26 个):
### Config Plugin Bug(7 个)
| 文件 | Bug | 修复 |
|------|-----|------|
| `plugins/ppocr/app.plugin.js` | `withAndroidMainApplication` 不存在 | 改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod` |
| `plugins/ppocr/app.plugin.js` | `withDangerousMod` 回调里 `modConfig.platformProjectRoot` undefined | 改为 `modConfig.modRequest.platformProjectRoot` |
| `plugins/*/app.plugin.js`(3 个) | `manifest.application[0]` 崩溃(prebuild 初始 manifest 无 application 节点) | 加 `Array.isArray` 防御 |
| `plugins/ppocr/app.plugin.js` | MainApplication import 没注入(Kotlin package 无分号) | 正则去掉分号要求 |
| `plugins/accessibility/app.plugin.js` | `return config` 丢失导致整个 config 变 undefined | 补回 `return config` |
### Kotlin 编译 Bug(11 个)
| 文件 | Bug | 修复 |
|------|-----|------|
| `FloatingTip.kt:80` | `val layoutParams` 不可重赋值 + 类型不匹配 | 拆出 apply 块 + 用 LinearLayout.LayoutParams |
| `OcrTileService.kt:50` | `startActivityAndCollapse(intentSender)` 参数不匹配 | 改传 PendingIntent |
| `OcrModule.kt:179` | `putDouble(confidence)` Float 不匹配 | `.toDouble()` |
| `OcrModule.kt:215,235` | `longArrayOf(1,3,...)` Int vs Long | 显式 `.toLong()` + `L` 后缀 |
| `OcrModule.kt:221,242` | `detOutputs.forEach{it.close()}` 未解析 | 改用 `detOutputs.close()`(OrtSession.Result.close) |
| `OcrModule.kt:333` | det 输出维度多了 channel 维,`prob[y][x]` 是 FloatArray 不是 Float | `[0][0]` 剥掉 N+C 两维,函数签名改 `Array<FloatArray>` |
### 资源/环境 Bug(8 个)
| 问题 | 修复 |
|------|------|
| cmdline-tools 缺失 | 从 Google 官方下载安装 |
| platform 35 缺失 | sdkmanager 安装 |
| NDK 27.0 vs 27.1 版本冲突 | `mklink /J` 目录联接 |
| NDK 下载损坏(Google 源断连) | 腾讯云镜像替代 |
| `accessibility_service_config.xml` canTakeScreenshots 属性 | 删除(非必需) |
| `onnxruntime:1.20.1` 不存在 | 改用 1.20.0 |
| expo-font 57.0 vs 14.0 版本冲突 | npm overrides 强制降级 |
| expo-router peer deps 缺失 | 补全 8 个依赖包 |
---
## 附录:完整一键流程
```bash
# === 环境变量(每次新终端都要设) ===
export JAVA_HOME="C:/Program Files/Java/jdk-21"
export ANDROID_HOME="C:/Users/$USER/AppData/Local/Android/Sdk"
# === 1. 依赖 ===
npm install --legacy-peer-deps
# === 2. prebuild ===
npx expo prebuild --platform android --no-install
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
# === 3. 编译 ===
cd android cd android
./gradlew :app:assembleDebug --no-daemon .\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 --offline --no-daemon
# === 4. 安装 ===
cd ..
"$ANDROID_HOME/platform-tools/adb" install -r android/app/build/outputs/apk/debug/app-debug.apk
# === 5. Metro + 运行 ===
npx expo start --dev-client --port 8081 &
sleep 10
"$ANDROID_HOME/platform-tools/adb" reverse tcp:8081 tcp:8081
"$ANDROID_HOME/platform-tools/adb" shell am start -n com.example.beanmobile/.MainActivity
``` ```
编译完成后,在输出目录会产生 5 个文件:
1. `app-arm64-v8a-release.apk` (推荐绝大多数真机,约 37MB)
2. `app-armeabi-v7a-release.apk` (适合极少数老旧真机)
3. `app-x86-release.apk` (适合 32 位模拟器)
4. `app-x86_64-release.apk` (适合 64 位模拟器)
5. `app-universal-release.apk` (包含上述全部架构的通用胖包,约 140MB)
---
### 2.3 模拟器专用打包 (x86_64)
若需要直接打包在 Windows/macOS 的 x86_64 原生安卓模拟器上测试:
```powershell
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=x86_64 --offline --no-daemon
```
即可秒级编译出专门针对模拟器的 `app-x86_64-release.apk`
---
## 3. 高级优化项Proguard/R8 与混淆 (选填)
如需进一步将独立包体积压缩到 25MB 左右,可以考虑在 `gradle.properties` 中开启混淆并做裁剪防御。
1. 在 `gradle.properties` 中添加:
```properties
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
```
2. 注意:由于引入了 `ONNX Runtime` 动态调用,如果运行崩溃,需要在 `android/app/proguard-rules.pro` 中加入如下混淆保留白名单:
```proguard
-keep class com.microsoft.onnxruntime.** { *; }
-dontwarn com.microsoft.onnxruntime.**
```

170
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "beancount-mobile", "name": "beancount-mobile",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0", "@react-navigation/drawer": "^7.5.0",
@ -33,6 +35,7 @@
"react-native-reanimated": "^3.18.0", "react-native-reanimated": "^3.18.0",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-view-shot": "4.0.3", "react-native-view-shot": "4.0.3",
"text-encoding-gbk": "^0.7.3", "text-encoding-gbk": "^0.7.3",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
@ -2014,6 +2017,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@expo-google-fonts/caveat": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/caveat/-/caveat-0.4.2.tgz",
"integrity": "sha512-kCG35tRUX8Wi+u0Jh5ck3K1u7vZkdLnwRTuKMrEnlVmikvxQFXN1KjF5cL2vqRG7Hwm7648Zl8xe1BWAoIsPCA==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/quicksand": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/quicksand/-/quicksand-0.4.1.tgz",
"integrity": "sha512-LpGdmY+CDm/GTv29qDPy8Nz1a3idWO1R6Bb+ty7flS6vfHXsdOQTRKNz93SFHdXQj0FSvVhvMLe3wTtMgf312A==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/code-signing-certificates": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@ -5097,6 +5112,12 @@
"integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmmirror.com/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/bplist-creator/-/bplist-creator-0.1.0.tgz",
@ -5708,6 +5729,56 @@
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@ -5857,6 +5928,61 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.4.7.tgz",
@ -5925,6 +6051,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmmirror.com/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmmirror.com/env-editor/-/env-editor-0.4.2.tgz",
@ -8992,6 +9130,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-5.2.1.tgz",
@ -9567,6 +9711,18 @@
"node": "^16.14.0 || >=18.0.0" "node": "^16.14.0 || >=18.0.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/nullthrows/-/nullthrows-1.1.1.tgz",
@ -10345,6 +10501,20 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.15.5",
"resolved": "https://registry.npmmirror.com/react-native-svg/-/react-native-svg-15.15.5.tgz",
"integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-view-shot": { "node_modules/react-native-view-shot": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmmirror.com/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", "resolved": "https://registry.npmmirror.com/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz",

View File

@ -11,6 +11,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0", "@react-navigation/drawer": "^7.5.0",
@ -36,6 +38,7 @@
"react-native-reanimated": "^3.18.0", "react-native-reanimated": "^3.18.0",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-view-shot": "4.0.3", "react-native-view-shot": "4.0.3",
"text-encoding-gbk": "^0.7.3", "text-encoding-gbk": "^0.7.3",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",

View File

@ -0,0 +1,233 @@
package com.beancount.mobile.accessibility
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray
/**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
*
* BillingAccessibilityService AccessibilityService 子类 RN 模块
* 其方法无法直接从 JS 调用本模块作为中间层通过 instance 静态引用
* JS 调用委托给服务实例
*
* Config Plugin withMainApplication 注入 add(AccessibilityBridgePackage())
*/
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
init {
ReactContextHolder.context = reactContext
}
override fun invalidate() {
super.invalidate()
if (ReactContextHolder.context === reactContext) {
ReactContextHolder.context = null
}
}
override fun getName() = "AccessibilityBridge"
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
@ReactMethod
fun isServiceRunning(promise: Promise) {
promise.resolve(BillingAccessibilityService.instance != null)
}
/**
* 记住当前页面把当前顶部 App pkg|activity 加入白名单
* 之后该页面内容变化时自动截图 OCR
*/
@ReactMethod
fun rememberCurrentPage(promise: Promise) {
val service = BillingAccessibilityService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
val pkg = service.getTopPackage()
if (pkg == null) {
promise.reject("NO_TOP_PACKAGE", "当前没有检测到前台 App")
return
}
service.rememberCurrentPage()
val result = WritableNativeMap()
result.putString("package", pkg)
result.putString("activity", service.getTopActivity() ?: "")
result.putString("signature", "$pkg|${service.getTopActivity() ?: ""}")
promise.resolve(result)
}
/** 手动触发一次 OCR截取当前屏幕并发送给 JS 层处理)。 */
@ReactMethod
fun triggerManualOcr(promise: Promise) {
val service = BillingAccessibilityService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
try {
service.triggerManualOcr()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("OCR_TRIGGER_FAIL", e.message)
}
}
/** 获取所有已记住的页面签名列表。 */
@ReactMethod
fun getPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance
val sigsSet = if (service != null) {
service.getPageSignatures()
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
} catch (e: Exception) {
emptySet()
}
}
val arr = WritableNativeArray()
for (sig in sigsSet) {
val parts = sig.split("|", limit = 2)
val map = WritableNativeMap()
map.putString("signature", sig)
map.putString("package", parts.getOrNull(0) ?: "")
map.putString("activity", parts.getOrNull(1) ?: "")
arr.pushMap(map)
}
promise.resolve(arr)
}
/** 清空所有已记住的页面签名。 */
@ReactMethod
fun clearPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance
if (service != null) {
service.clearPageSignatures()
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
prefs.edit().putStringSet("page_signatures", emptySet()).apply()
} catch (e: Exception) {
promise.reject("CLEAR_PREFS_FAIL", e.message)
return
}
}
promise.resolve(true)
}
/** 删除指定页面签名。 */
@ReactMethod
fun removePageSignature(signature: String, promise: Promise) {
val service = BillingAccessibilityService.instance
if (service != null) {
service.removePageSignature(signature)
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
val saved = prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
val mutable = HashSet(saved)
if (mutable.remove(signature)) {
prefs.edit().putStringSet("page_signatures", mutable).apply()
}
} catch (e: Exception) {
promise.reject("REMOVE_PREFS_FAIL", e.message)
return
}
}
promise.resolve(true)
}
/** 获取支付 App 白名单(供 JS 端展示)。 */
@ReactMethod
fun getPaymentPackages(promise: Promise) {
val arr = WritableNativeArray()
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
arr.pushString(pkg)
}
promise.resolve(arr)
}
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
@ReactMethod
fun getTopApp(promise: Promise) {
val service = BillingAccessibilityService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
val map = WritableNativeMap()
map.putString("package", service.getTopPackage() ?: "")
map.putString("activity", service.getTopActivity() ?: "")
promise.resolve(map)
}
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
@ReactMethod
fun bringAppToForeground(promise: Promise) {
val service = BillingAccessibilityService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
try {
service.bringAppToForeground()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FAIL", e.message)
}
}
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
@ReactMethod
fun showFloatingBill(
amount: String,
merchant: String,
time: String,
packageName: String,
categories: ReadableArray,
accounts: ReadableArray,
direction: String,
draftId: String,
promise: Promise
) {
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return
}
val categoryList = mutableListOf<Map<String, String>>()
for (i in 0 until categories.size()) {
val map = categories.getMap(i)
categoryList.add(mapOf(
"id" to (map?.getString("id") ?: ""),
"name" to (map?.getString("name") ?: ""),
"account" to (map?.getString("account") ?: ""),
"type" to (map?.getString("type") ?: "")
))
}
val accountList = mutableListOf<String>()
for (i in 0 until accounts.size()) {
accountList.add(accounts.getString(i) ?: "")
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
floatingView.show()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FAIL", e.message)
}
}
}
}

View File

@ -0,0 +1,20 @@
package com.beancount.mobile.accessibility
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* ReactPackage 注册 AccessibilityBridgeModule
* Config Plugin withMainApplication 注入 add(AccessibilityBridgePackage())
*/
class AccessibilityBridgePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(AccessibilityBridgeModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -0,0 +1,578 @@
package com.beancount.mobile.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.graphics.Bitmap
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import android.view.Surface
import android.view.accessibility.AccessibilityEvent
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.WritableNativeArray
import java.io.ByteArrayOutputStream
import android.util.Base64
import android.view.accessibility.AccessibilityNodeInfo
/**
* 无障碍账单识别服务plan.md3.6 无障碍服务+决策 4 Config Plugin
*
* 参考 AutoAccounting SelectToSpeakService
* - 监听支付 App 的页面切换TYPE_WINDOW_STATE_CHANGED
* - 页面签名匹配时自动截图 OCR 推送到 JS
* - 横屏免打扰游戏/视频时不触发
* - ocrDoing 守卫防止重复触发
*
* 伪装说明plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发
* 注意本服务类名在 manifest 中声明为 BillingAccessibilityService
* 伪装为系统服务包名 com.beancount.mobile.accessibility仅在侧载版本保留
*
* 通过 DeviceEventEmitter 把识别事件推送到 JS automationStore
*
* src/domain/constants.ts 同步PAYMENT_PACKAGES
* 修改时需两边同时更新
*/
class BillingAccessibilityService : AccessibilityService() {
companion object {
private const val TAG = "BillingAccessibility"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val PREF_PAGE_SIGNATURES = "page_signatures"
@Volatile
var instance: BillingAccessibilityService? = null
private set
/** 支付 App 白名单。 */
val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone", // 支付宝
"com.tencent.mm", // 微信
"com.unionpay", // 银联
"com.cmbchina", // 招商银行
"com.icbc", // 工商银行
"com.chinamworld.main", // 中国银行
"com.ccbrcb", // 建设银行
"com.bankcomm.Bankcomm", // 交通银行
"com.tencent.mobileqq", // 手机QQ
"com.tencent.tim" // TIM
)
/** 厂商桌面包名(过滤,不触发 OCR。 */
private val LAUNCHER_PACKAGES = setOf(
"com.google.android.apps.nexuslauncher",
"com.sec.android.app.launcher",
"com.miui.home",
"com.huawei.android.launcher",
"com.oppo.launcher",
"com.bbk.launcher2",
"com.android.launcher3",
)
/** OCR 触发防抖500ms 内的内容变化合并为一次。 */
private const val CONTENT_CHANGE_DEBOUNCE_MS = 500L
}
private var ocrDoing = false
private val handler = Handler(Looper.getMainLooper())
private val debounceRunnable = Runnable { processContentChange() }
@Volatile private var topPackage: String? = null
@Volatile private var topActivity: String? = null
/** 已记住的页面签名pkg|activity匹配时自动触发 OCR。持久化到 SharedPreferences。 */
private val pageSignatures = java.util.concurrent.CopyOnWriteArraySet<String>()
private var floatingHelper: FloatingHelper? = null
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "无障碍账单识别服务已连接")
loadPageSignatures()
configureService()
}
/** 动态配置服务能力(截图 + 页面变化监听)。 */
private fun configureService() {
val info = AccessibilityServiceInfo().apply {
eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
AccessibilityServiceInfo.DEFAULT
notificationTimeout = 100L
}
serviceInfo = info
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (ocrDoing) return // 处理中,跳过
val eventPackage = event?.packageName?.toString() ?: return
when (event.eventType) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val activityName = event.className?.toString() ?: ""
if (filterPackage(eventPackage, activityName)) return
topPackage = eventPackage
topActivity = activityName
Log.d(TAG, "页面切换: $eventPackage / $activityName")
// 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage)
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
handler.postDelayed({
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", eventPackage)
putString("activity", activityName)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
// 忽略
}
}
}, 800)
}
}
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
}
}
}
private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post {
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
if (floatingHelper == null) {
floatingHelper = FloatingHelper(this)
floatingHelper?.show()
}
} else {
floatingHelper?.dismiss()
floatingHelper = null
}
}
}
/** 防抖500ms 内的多次内容变化合并。 */
private fun scheduleContentChange() {
handler.removeCallbacks(debounceRunnable)
handler.postDelayed(debounceRunnable, CONTENT_CHANGE_DEBOUNCE_MS)
}
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JSJS 控制是否 OCR 兜底)。 */
private fun processContentChange() {
if (ocrDoing) return
val pkg = topPackage ?: return
// 横屏免打扰plan.md「3.10」)
if (isLandscape()) {
Log.d(TAG, "横屏免打扰,跳过")
return
}
// 页面签名匹配(若已记住页面则触发)
val activity = topActivity ?: ""
val sigKey = "$pkg|$activity"
if (!pageSignatures.contains(sigKey)) {
return // 未记住的页面不自动触发
}
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sigKey)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
Log.e(TAG, "推送内容变化节点文本失败: ${e.message}")
}
}
}
/** 截图并触发 OCR 处理Android 11+)。 */
private fun takeScreenshotAndProcess(packageName: String) {
if (ocrDoing) return
ocrDoing = true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ocrDoing = false
return
}
try {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
try {
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
result.hardwareBuffer.close()
if (bitmap != null) {
val base64 = bitmapToBase64(bitmap)
bitmap.recycle()
// 推送到 JS 层NativeEventEmitter
sendScreenshotEvent(base64, packageName)
}
} finally {
ocrDoing = false
}
}
override fun onFailure(errorCode: Int) {
Log.e(TAG, "截图失败: errorCode=$errorCode")
ocrDoing = false
}
}
)
} catch (e: Throwable) {
Log.e(TAG, "调用 takeScreenshot 失败: ${e.message}", e)
ocrDoing = false
}
}
/** 把截图以 base64 推送到 JS 层(由 JS 端 OcrProcessor 处理)。 */
private fun sendScreenshotEvent(base64: String, packageName: String) {
val reactContext = ReactContextHolder.context ?: return
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingScreenshot", writableMapOf(
"base64" to base64,
"packageName" to packageName,
"timestamp" to System.currentTimeMillis()
))
} catch (e: Exception) {
Log.e(TAG, "推送截图事件失败: ${e.message}")
}
}
/** 手动触发一次 OCR临时隐藏悬浮窗避开遮挡并在 150ms 后触发截图)。 */
fun triggerManualOcr() {
val pkg = topPackage ?: return
handler.post {
floatingHelper?.collapse()
floatingHelper?.hideTemporarily()
}
// 150ms 后触发截图(此时悬浮窗已瞬间隐藏,避免遮挡和误匹配)
handler.postDelayed({
try {
takeScreenshotAndProcess(pkg)
} catch (e: Exception) {
Log.e(TAG, "手动触发 OCR 失败: ${e.message}")
} finally {
// 截图完成,瞬间恢复显示悬浮球
handler.post {
floatingHelper?.showTemporarily()
}
}
}, 150)
}
/** 手动触发一次节点文本提取并发送到 JS从而让 JS 优先尝试直接文本解析。 */
fun triggerManualExtraction() {
handler.post {
floatingHelper?.collapse()
}
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sigKey = "$pkg|$activity"
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sigKey)
putBoolean("isManual", true)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
Log.e(TAG, "手动触发节点文本提取失败: ${e.message}")
}
}
}
/** 记住当前页面(用户主动标记应触发 OCR 的页面)。持久化到 SharedPreferences。 */
fun rememberCurrentPage() {
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sig = "$pkg|$activity"
if (pageSignatures.add(sig)) {
savePageSignatures()
Log.i(TAG, "已记住页面: $sig")
handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
// 抓取并提取屏幕所有无障碍文本
val texts = mutableListOf<String>()
val rootNode = rootInActiveWindow
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
// 推送事件到 JS 端,使 npx expo start 终端控制台可以接收并打印日志
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sig)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingPageRemembered", map)
} catch (e: Exception) {
Log.e(TAG, "推送记住页面事件失败: ${e.message}")
}
}
} else {
handler.post {
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
}
}
/** 获取已记住的页面签名列表(供 JS 端展示)。 */
fun getPageSignatures(): Set<String> {
return pageSignatures.toSet()
}
/** 清空所有已记住的页面签名。 */
fun clearPageSignatures() {
pageSignatures.clear()
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
Log.i(TAG, "已清空全部记住的页面并从 SharedPreferences 中移除键值")
} catch (e: Exception) {
Log.e(TAG, "清空 SharedPreferences 失败: ${e.message}")
}
}
/** 删除指定页面签名。 */
fun removePageSignature(sig: String) {
if (pageSignatures.remove(sig)) {
savePageSignatures()
Log.i(TAG, "已删除页面签名: $sig")
}
}
/** 获取当前顶部包名(供 JS 判断当前页面)。 */
fun getTopPackage(): String? = topPackage
/** 获取当前顶部 Activity。 */
fun getTopActivity(): String? = topActivity
/** 从 SharedPreferences 加载已记住的页面签名。 */
private fun loadPageSignatures() {
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getStringSet(PREF_PAGE_SIGNATURES, emptySet()) ?: emptySet()
pageSignatures.clear()
pageSignatures.addAll(saved)
Log.i(TAG, "已加载 ${pageSignatures.size} 个记住的页面签名")
} catch (e: Exception) {
Log.e(TAG, "加载页面签名失败: ${e.message}")
}
}
/** 保存页面签名到 SharedPreferences。 */
private fun savePageSignatures() {
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (pageSignatures.isEmpty()) {
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
} else {
prefs.edit().putStringSet(PREF_PAGE_SIGNATURES, HashSet(pageSignatures)).apply()
}
} catch (e: Exception) {
Log.e(TAG, "保存页面签名失败: ${e.message}")
}
}
/** 横屏检测plan.md「3.10 横屏免打扰」)。 */
private fun isLandscape(): Boolean {
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
?: return false
val rotation = dm.rotation
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
}
/** 过滤系统组件/桌面(不触发 OCR。 */
private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase()
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
// 其他自身组件(如悬浮球容器 LinearLayout一律过滤避免自毁式关闭。
if (pkg == packageName) {
return className != "com.example.beanmobile.MainActivity"
}
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
return false
}
/** Bitmap → base64JPEG 质量 60参考 AutoAccounting bitmapToBase64。 */
private fun bitmapToBase64(bitmap: Bitmap): String {
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
}
val baos = ByteArrayOutputStream()
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
if (softwareBitmap !== bitmap) {
softwareBitmap.recycle()
}
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
}
/** 辅助:构造 WritableNativeMap。 */
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
val map = WritableNativeMap()
for ((k, v) in pairs) {
when (v) {
is String -> map.putString(k, v)
is Int -> map.putInt(k, v)
is Long -> map.putDouble(k, v.toDouble())
is Boolean -> map.putBoolean(k, v)
is Number -> map.putDouble(k, v.toDouble())
is WritableNativeMap -> map.putMap(k, v)
is WritableNativeArray -> map.putArray(k, v)
else -> map.putNull(k)
}
}
return map
}
/** 将当前应用拉到前台以显示确认弹窗 */
fun bringAppToForeground() {
try {
val intent = packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) {
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
startActivity(intent)
Log.i(TAG, "已成功拉起本应用到前台显示弹窗")
}
} catch (e: Exception) {
Log.e(TAG, "拉起应用到前台失败: ${e.message}")
}
}
/** 递归提取无障碍节点树的全部文本。 */
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
if (node == null) return
val text = node.text?.toString()
if (!text.isNullOrBlank()) {
list.add(text)
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue
dumpNodeTexts(child, list)
child.recycle()
}
}
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
if (node == null) return false
val text = node.text?.toString()
if (text != null) {
for (keyword in keywords) {
if (text.contains(keyword)) {
return true
}
}
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue
val found = findTextInNode(child, keywords)
child.recycle()
if (found) return true
}
return false
}
override fun onInterrupt() {
Log.w(TAG, "无障碍服务被中断")
}
override fun onUnbind(intent: android.content.Intent?): Boolean {
floatingHelper?.dismiss()
floatingHelper = null
instance = null
return super.onUnbind(intent)
}
}
/**
* RN 上下文持有者 MainApplication 注入
* 无障碍服务运行在系统进程需通过静态引用访问 RN 上下文以发送事件
*/
object ReactContextHolder {
@Volatile var context: ReactApplicationContext? = null
}

View File

@ -0,0 +1,739 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import android.widget.EditText
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
/**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
*/
class FloatingBillView(
private val context: Context,
private val draftId: String,
private val amount: String,
private val merchant: String,
private val time: String,
private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense"
) {
companion object {
private const val TAG = "FloatingBillView"
}
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
private var selectedCategoryAccount: String = ""
private var selectedSourceAccount: String = ""
private var currentDirection: String = "expense"
private var categoryLabel: TextView? = null
private var categoryContainer: LinearLayout? = null
private var accountLabel: TextView? = null
private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = null
private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density
return (value * density).toInt()
}
private fun createChipView(text: String): TextView {
return TextView(context).apply {
this.text = text
textSize = 11f
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
rightMargin = dp(6f)
}
}
}
/** 显示悬浮窗。 */
fun show() {
try {
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
val windowParams = WindowManager.LayoutParams(
dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照区
}
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 顶部标题与方向选择器布局
val headerLayout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val titleText = TextView(context).apply {
text = "调整交易草稿"
textSize = 12f
setTextColor(0xFF98A2FF.toInt())
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
headerLayout.addView(titleText)
// 三方向分段选择器 (支出 / 收入 / 转账)
val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity
}
}
val tabTexts = listOf("支出", "收入", "转账")
val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>()
fun updateTabStyle() {
for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection
tabViews[i].apply {
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
}
} else {
null
}
}
}
}
for (i in tabTexts.indices) {
val tab = TextView(context).apply {
text = tabTexts[i]
textSize = 10f
setPadding(dp(6f), dp(3f), dp(6f), dp(3f))
gravity = Gravity.CENTER
setOnClickListener {
if (currentDirection != tabDirections[i]) {
currentDirection = tabDirections[i]
updateTabStyle()
rebuildChips()
}
}
}
segmentContainer.addView(tab)
tabViews.add(tab)
}
updateTabStyle()
headerLayout.addView(segmentContainer)
container.addView(headerLayout)
// 金额编辑
val amountLabel = TextView(context).apply {
text = "金额"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, dp(4f), 0, dp(1f))
}
container.addView(amountLabel)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) // 25% 半透底色
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(amountInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
// 商户编辑
val merchantLabel = TextView(context).apply {
text = "交易对手"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
// 叙述备注编辑
val narrationLabel = TextView(context).apply {
text = "描述/备注"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = "输入交易叙述"
setHintTextColor(0xFF6B7280.toInt())
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(narrationInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// Row 1 分类/转入选择
categoryLabel = TextView(context).apply {
text = "交易分类"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(2f))
}
container.addView(categoryLabel)
categoryContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val categoryScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(categoryContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// Row 2 资金出入账户选择
accountLabel = TextView(context).apply {
text = "资金来源账户"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(2f))
}
container.addView(accountLabel)
accountContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val accountScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(accountContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
// 底部操作栏
val btnContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 1) 打开应用按钮
val openAppBtn = Button(context).apply {
text = "打开应用"
setTextColor(0xFFD1D5DB.toInt())
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
BillingAccessibilityService.instance?.bringAppToForeground()
}
}
// 2) 忽略按钮
val cancelBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
val cancelBtn = Button(context).apply {
text = "忽略"
setTextColor(0xFFD1D5DB.toInt())
background = cancelBg
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
}
}
// 3) 确认入账按钮
val saveBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF5E6AD2.toInt())
}
saveBtn = Button(context).apply {
text = "确认入账"
setTextColor(0xFFFFFFFF.toInt())
background = saveBg
textSize = 11f
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendSaveEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
}
}
btnContainer.addView(openAppBtn)
btnContainer.addView(cancelBtn)
btnContainer.addView(saveBtn)
container.addView(btnContainer)
// 构建金额安全校验
val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) {
try {
val text = s?.toString()?.trim() ?: ""
val value = text.toDoubleOrNull()
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat()
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
}
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
} catch (e: Exception) {
Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false
}
}
}
amountInput.addTextChangedListener(amountWatcher)
// 填充金额触发 Watcher 校验
amountInput.setText(amount)
// 动态初始构建两行胶囊
rebuildChips()
view = container
windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已获得输入焦点,取消自动消失定时器")
}
}
amountInput.onFocusChangeListener = cancelTimerListener
merchantInput.onFocusChangeListener = cancelTimerListener
narrationInput.onFocusChangeListener = cancelTimerListener
container.setOnTouchListener { _, _ ->
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已触摸卡片,取消自动消失定时器")
false
}
// 15 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 15000L)
} catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
}
}
/** 动态根据交易方向重绘第一行与第二行滑动的胶囊列表 */
private fun rebuildChips() {
categoryContainer?.removeAllViews()
accountContainer?.removeAllViews()
val categoryChips = mutableListOf<Pair<String, TextView>>()
val accountChips = mutableListOf<Pair<String, TextView>>()
// === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") {
categoryLabel?.text = "交易分类"
val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
} else if (currentDirection == "income") {
categoryLabel?.text = "收入分类"
val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
} else {
// transfer
categoryLabel?.text = "转入账户"
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
chip.setOnClickListener {
selectedCategoryAccount = acct
updateStyle(acct)
}
categoryContainer?.addView(chip)
categoryChips.add(acct to chip)
}
selectedCategoryAccount = if (accounts.size > 1 && accounts[0] == selectedSourceAccount) accounts[1] else (accounts.firstOrNull() ?: "")
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = "资金来源"
} else if (currentDirection == "income") {
accountLabel?.text = "存入账户"
} else {
accountLabel?.text = "转出账户"
}
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
fun updateStyle(selected: String) {
for (pair in accountChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
chip.setOnClickListener {
selectedSourceAccount = acct
updateStyle(acct)
// 若是转账,且转入转出账户冲突,自动移开转入账户
if (currentDirection == "transfer" && selectedCategoryAccount == selectedSourceAccount) {
val nextAvail = categoryChips.find { it.first != selectedSourceAccount }
if (nextAvail != null) {
selectedCategoryAccount = nextAvail.first
for (p in categoryChips) {
val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (act) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
}
}
accountContainer?.addView(chip)
accountChips.add(acct to chip)
}
selectedSourceAccount = accounts.firstOrNull() ?: ""
accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
/** 推送保存事件到 JS 层。 */
private fun sendSaveEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putBoolean("confirmed", true)
putBoolean("editRequested", false)
putBoolean("isManualEdit", true)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送修改保存事件失败: ${e.message}")
}
}
/** 推送打开应用事件到 JS 层,以便前台加载详细表单。 */
private fun sendOpenAppEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingOpenApp", map)
} catch (e: Exception) {
Log.e(TAG, "推送打开应用事件失败: ${e.message}")
}
}
/** 推送取消/忽略事件到 JS 层。 */
private fun sendCancelEvent() {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putBoolean("confirmed", false)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送取消事件失败: ${e.message}")
}
}
/** 关闭浮窗。 */
fun dismiss() {
handler.removeCallbacksAndMessages(null)
try {
view?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
view = null
}
}

View File

@ -0,0 +1,418 @@
package com.beancount.mobile.accessibility
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
/**
* 记账助手悬浮球边缘竖线胶囊面板
* 贴合在屏幕边缘采用高透超轻量竖线指示器点击后展开垂直对齐的功能菜单
*/
class FloatingHelper(
private val service: BillingAccessibilityService
) {
companion object {
private const val TAG = "FloatingHelper"
private var lastX = 0
private var lastY = 400
}
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var containerView: LinearLayout? = null
private var bubbleView: View? = null
private var menuView: LinearLayout? = null
private var isExpanded = false
private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = lastX
y = lastY
}
@SuppressLint("ClickableViewAccessibility")
fun show() {
if (containerView != null) return
try {
val context = service
val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt()
// 1. 创建整体包裹容器 (水平排列,当贴在左侧时,菜单向右展开)
containerView = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_OUTSIDE) {
if (isExpanded) {
collapse()
}
}
false
}
}
// 2. 创建高透明度悬浮球/边缘竖线 (仅 3dp 宽的极简胶囊,搭配 24dp 的宽触控区)
val bubbleLayout = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
}
val indicatorView = View(context).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
}
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER
}
}
bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
}
menuView = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
background = menuBg
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE
layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = dp(4f) // spacing with indicator line
}
}
// Vector icons
val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
// Button 1: "识别账单"
val btnOcr = TextView(context).apply {
text = "识别账单"
setTextColor(0xFFFFFFFF.toInt())
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
).apply {
bottomMargin = dp(5f)
}
setCompoundDrawablesWithIntrinsicBounds(ocrIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt())
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt())
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
service.triggerManualExtraction()
}
}
// Button 2: "记住此页"
val btnRemember = TextView(context).apply {
text = "记住此页"
setTextColor(0xFFE5E7EB.toInt())
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
)
setCompoundDrawablesWithIntrinsicBounds(pinIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt())
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt())
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
try {
service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) {
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
}
collapse()
}
}
menuView?.addView(btnOcr)
menuView?.addView(btnRemember)
containerView?.addView(bubbleView)
containerView?.addView(menuView)
// 4. 设置悬浮条拖动事件 (点击即展开,拖动则调整位置)
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
var isMoving = false
bubbleView?.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = params.x
initialY = params.y
initialTouchX = event.rawX
initialTouchY = event.rawY
isMoving = false
true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
isMoving = true
}
params.x = initialX + dx
params.y = initialY + dy
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
true
}
MotionEvent.ACTION_UP -> {
if (!isMoving) {
toggleMenu()
} else {
// 拖动抬起时自动吸附到屏幕边缘
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = 0
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
}
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
}
true
}
else -> false
}
}
windowManager.addView(containerView, params)
Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
}
}
private fun toggleMenu() {
if (isExpanded) {
collapse()
} else {
expand()
}
}
private fun expand() {
bubbleView?.visibility = View.GONE
menuView?.visibility = View.VISIBLE
isExpanded = true
}
fun collapse() {
menuView?.visibility = View.GONE
bubbleView?.visibility = View.VISIBLE
isExpanded = false
}
fun hideTemporarily() {
containerView?.visibility = View.GONE
}
fun showTemporarily() {
containerView?.visibility = View.VISIBLE
}
fun dismiss() {
try {
containerView?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
containerView = null
bubbleView = null
menuView = null
isExpanded = false
}
}
/**
* 扫码/OCR 矢量图标 drawable
*/
class ScanIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val laserColor: Int,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
paint.style = Paint.Style.STROKE
// 绘制 4 个角的扫描框
val len = w * 0.25f
val pad = strokeWidthPx
// 左上
canvas.drawLine(pad, pad, pad + len, pad, paint)
canvas.drawLine(pad, pad, pad, pad + len, paint)
// 右上
canvas.drawLine(w - pad, pad, w - pad - len, pad, paint)
canvas.drawLine(w - pad, pad, w - pad, pad + len, paint)
// 左下
canvas.drawLine(pad, h - pad, pad + len, h - pad, paint)
canvas.drawLine(pad, h - pad, pad, h - pad - len, paint)
// 右下
canvas.drawLine(w - pad, h - pad, w - pad - len, h - pad, paint)
canvas.drawLine(w - pad, h - pad, w - pad, h - pad - len, paint)
// 绘制扫描红线 (激光)
paint.style = Paint.Style.FILL
paint.color = laserColor
val laserY = h / 2f
canvas.drawRect(pad * 2f, laserY - strokeWidthPx / 2f, w - pad * 2f, laserY + strokeWidthPx / 2f, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
/**
* 图钉/记住当前页 矢量图标 drawable
*/
class PinIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
val cx = w / 2f
// 图钉头部 (帽)
paint.style = Paint.Style.FILL
val hatW = w * 0.35f
val hatH = h * 0.12f
canvas.drawRoundRect(RectF(cx - hatW, hatH, cx + hatW, hatH * 2.2f), strokeWidthPx, strokeWidthPx, paint)
// 图钉身体 (中)
val bodyW = w * 0.22f
canvas.drawRect(cx - bodyW, hatH * 2.2f, cx + bodyW, h * 0.58f, paint)
// 针尖 (底)
paint.style = Paint.Style.STROKE
canvas.drawLine(cx, h * 0.58f, cx, h - strokeWidthPx, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}

View File

@ -0,0 +1,128 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 浮窗提示plan.md3.8 浮窗账单提示
*
* 参考 AutoAccounting FloatingTip + RepeatToast
* - 轻量浮窗非全屏滑入动画自动消失
* - 三种布局顶部 / 左侧 / 右侧
* - 倒计时进度环
* - 重复账单提示RepeatToast
*
* FloatingBillView 更轻仅展示提示不交互
* SYSTEM_ALERT_WINDOW 权限 Config Plugin 注册
*/
class FloatingTip(
private val context: Context,
private val message: String,
private val position: TipPosition = TipPosition.TOP,
private val durationMs: Long = 3000L,
) {
companion object {
private const val TAG = "FloatingTip"
private const val ANIM_DURATION = 300L
}
enum class TipPosition { TOP, LEFT, RIGHT }
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
/** 显示浮窗提示。 */
fun show() {
try {
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT,
).apply {
gravity = when (position) {
TipPosition.TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
TipPosition.LEFT -> Gravity.LEFT or Gravity.CENTER_VERTICAL
TipPosition.RIGHT -> Gravity.RIGHT or Gravity.CENTER_VERTICAL
}
y = if (position == TipPosition.TOP) 100 else 0
x = if (position != TipPosition.TOP) 50 else 0
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xF0333333.toInt())
setPadding(32, 16, 32, 16)
}
val text = TextView(context).apply {
text = message
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
}
// 倒计时进度条
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
progress.max = 100
progress.progress = 100
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
setMargins(16, 0, 0, 0)
}
container.addView(text)
container.addView(progress)
view = container
windowManager.addView(view, layoutParams)
Log.d(TAG, "FloatingTip 显示: $message")
// 倒计时动画300ms 内 progress 从 100 到 0
val steps = 30
val stepMs = durationMs / steps
var currentStep = 0
handler.post(object : Runnable {
override fun run() {
currentStep++
progress.progress = 100 - (currentStep * 100 / steps)
if (currentStep < steps) {
handler.postDelayed(this, stepMs)
}
}
})
handler.postDelayed({ dismiss() }, durationMs)
} catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
}
}
/** 关闭浮窗。 */
fun dismiss() {
handler.removeCallbacksAndMessages(null)
try { view?.let { windowManager.removeView(it) } } catch (_: Exception) {}
view = null
}
}
/**
* 重复账单提示plan.md3.8+ AutoAccounting RepeatToast
* 当检测到重复账单时轻量提示用户不弹浮窗用系统 Toast 风格
*/
class RepeatToast(private val context: Context, private val message: String) {
fun show() {
val tip = FloatingTip(context, "$message", FloatingTip.TipPosition.TOP, 2000L)
tip.show()
Log.d("RepeatToast", "重复提示: $message")
}
}

View File

@ -0,0 +1,62 @@
package com.beancount.mobile.accessibility
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import android.content.Intent
import android.app.PendingIntent
/**
* 快速设置磁贴plan.md3.11 快速设置磁贴
*
* 参考 AutoAccounting OcrTileService
* - 用户下拉快速设置点击OCR 记账磁贴触发一次手动 OCR
* - Android 14+ PendingIntent + startActivityAndCollapse
*
* 触发后调用 BillingAccessibilityService.triggerManualOcr()
*/
class OcrTileService : TileService() {
companion object {
private const val TAG = "OcrTileService"
}
override fun onStartListening() {
super.onStartListening()
qsTile?.let { tile ->
tile.state = Tile.STATE_ACTIVE
tile.label = "OCR 记账"
tile.updateTile()
}
Log.d(TAG, "磁贴开始监听")
}
override fun onClick() {
super.onClick()
Log.i(TAG, "磁贴被点击,触发手动 OCR")
triggerManualOcr()
}
/** 触发手动 OCR通过 BillingAccessibilityService。 */
private fun triggerManualOcr() {
val service = BillingAccessibilityService.instance
if (service != null) {
service.triggerManualOcr()
return
}
// 服务未运行尝试启动Android 14+ 用 collapse
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pi = PendingIntent.getActivity(
this, 0,
Intent().apply {
setClassName(packageName, "com.beancount.mobile.MainActivity")
action = "com.beancount.mobile.TRIGGER_OCR"
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
PendingIntent.FLAG_IMMUTABLE,
)
startActivityAndCollapse(pi)
}
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
无障碍服务配置plan.md「3.6 无障碍服务」)。
注册监听支付 App 的页面变化 + 截图能力。
canTakeScreenshot="true" 是 Android 11+ AccessibilityService.takeScreenshot() 的前提。
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRequestEnhancedWebAccessibility="true"
android:canRetrieveWindowContent="true"
android:canTakeScreenshot="true"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows" />

View File

@ -62,44 +62,16 @@ function withAccessibilityService(config) {
}, },
]); ]);
// 2. 注入 ReactContextHolder 赋值 + 注册 AccessibilityBridgePackage 到 MainApplication // 2. 注册 AccessibilityBridgePackage 到 MainApplication
config = withMainApplication(config, (modConfig) => { config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 importReactContextHolder + AccessibilityBridgePackage // 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.ReactContextHolder`)) { if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`, `$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
); );
} else if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace(
`import ${PACKAGE}.ReactContextHolder`,
`import ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
}
// 2b. 在 onCreate 方法中注入 ReactContextHolder.context = this
if (!content.includes('ReactContextHolder.context')) {
// 尝试在 super.onCreate() 后注入
if (/super\.onCreate\(\)/.test(content)) {
content = content.replace(
/(super\.onCreate\(\))/,
`$1\n // 由 Config Plugin 注入:让原生服务能访问 RN 上下文\n ReactContextHolder.context = this`,
);
} else if (/onCreate/.test(content)) {
// 有 onCreate 但没有 super.onCreate()(不太可能)
content = content.replace(
/(onCreate[^{]*\{)/,
`$1\n ReactContextHolder.context = this`,
);
} else {
// 没有 onCreate — 在类体内注入一个
content = content.replace(
/(class\s+MainApplication\s*[^{]*\{)/,
`$1\n override fun onCreate() {\n super.onCreate()\n ReactContextHolder.context = this\n }`,
);
}
} }
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage()) // 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())

View File

@ -0,0 +1,96 @@
package com.beancount.mobile.notification
import android.app.Notification
import android.content.ComponentName
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 通知监听服务plan.md4.1 通知监听服务+决策 4 Config Plugin
*
* 参考 AutoAccounting NotificationListenerService
* - 提取支付 App 通知的 title/text
* - 白名单过滤仅支付类 App
* - 关键词黑白名单JS keywordFilter 进一步过滤
* - MD5 去重JS NotificationChannel 处理避免原生持有状态
* - onListenerDisconnected requestRebind 自动重连
*
* 通过 DeviceEventEmitter 把通知事件推送到 JS NotificationChannel
*/
class BillingNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "BillingNotification"
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
private val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
"com.cmbchina",
"com.icbc",
"com.chinamworld.main",
"com.ccbrcb",
"com.bankcomm.Bankcomm",
)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
runCatching {
val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤
if (!PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification
val extras = notification.extras
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: ""
if (title.isBlank() && text.isBlank()) return
Log.d(TAG, "收到支付通知: pkg=$packageName, title=$title")
// 推送到 JS 层
sendNotificationEvent(packageName, title, text)
}.onFailure {
Log.e(TAG, "通知处理异常: ${it.message}", it)
}
}
/**
* 监听断开时自动重连参考 AutoAccounting requestRebind
* Android Doze / App Standby 可能断开通知监听
*/
override fun onListenerDisconnected() {
super.onListenerDisconnected()
Log.w(TAG, "通知监听断开,尝试重连")
requestRebind(ComponentName(this, BillingNotificationListenerService::class.java))
}
/** 把通知事件推送到 JS 层 NotificationChannel.handleNotification。 */
private fun sendNotificationEvent(packageName: String, title: String, text: String) {
val reactContext = ReactContextHolder.context ?: run {
Log.w(TAG, "RN 上下文未就绪,丢弃通知")
return
}
try {
val map = WritableNativeMap().apply {
putString("packageName", packageName)
putString("title", title)
putString("text", text)
putDouble("timestamp", System.currentTimeMillis().toDouble())
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingNotification", map)
} catch (e: Exception) {
Log.e(TAG, "推送通知事件失败: ${e.message}")
}
}
}

View File

@ -14,8 +14,8 @@ plugins/ppocr/
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages │ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置) └── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv5_det.onnx # 文本检测模型 ├── ppocrv5_det.onnx # 文本检测模型
├── ppocrv5_rec.onnx # 文本识别模型 ├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维)
└── ppocr_keys_v1.txt # CJK 字典(CTC 解码用) └── ppocrv5_dict.txt # PP-OCRv5 多语言字典18383 字符,CTC 解码用)
``` ```
## 模型获取(一键下载) ## 模型获取(一键下载)
@ -33,12 +33,16 @@ curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/
# rec 模型16.6 MB # rec 模型16.6 MB
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx
# CJK 字典26 KBPaddleOCR 标准字典 # PP-OCRv5 多语言字典74 KB必须与上面的 rec 模型配套
curl -L -o ppocr_keys_v1.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
``` ```
或用 HuggingFace CLI首次下载原生模型再转 ONNX 的方式,参见历史 git log 或用 HuggingFace CLI首次下载原生模型再转 ONNX 的方式,参见历史 git log
> ⚠️ **字典必须与 rec 模型配套**ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
> 必须使用 `ppocrv5_dict.txt`18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。 > 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
## 性能配置(参考 AutoAccounting OcrProcessor.kt ## 性能配置(参考 AutoAccounting OcrProcessor.kt
@ -75,4 +79,6 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
- `android/OcrPackage.kt`:✅ RN Package 注册 - `android/OcrPackage.kt`:✅ RN Package 注册
- `assets/`:需自行下载放置(见上「模型获取」),版权/体积原因不入仓库 - `assets/`:需自行下载放置(见上「模型获取」),版权/体积原因不入仓库
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android``npx expo run:android` 真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。

View File

@ -0,0 +1,659 @@
package com.beancount.mobile.ppocr
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.module.annotations.ReactModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.FloatBuffer
import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max
import kotlin.math.min
/**
* PP-OCRv5 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
*
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 模型ppocrv5_det.onnx + ppocrv5_rec.onnx ilaylow/PP_OCRv5_mobile_onnx 下载
* 字典ppocrv5_dict.txtPP-OCRv5 多语言字典18383 字符rec 模型 18385 维输出 = 字典 + blank + 特殊位
*
* 流水线
* 1. det文本检测bitmap DB 后处理得到文本框
* 2. rec文本识别每个框 crop resize 48px CTC 解码
*
* 性能优化参考 AutoAccounting OcrProcessor.kt
* - 短边压缩到 720px像素量比 1440p 减少约 75%
* - CPU 执行兼容性最稳部分设备 GPU 会崩溃
* - det 最大边限制 960PaddleOCR 默认 limit_max_side_len
*
* JS 层通过 NativeModules.PpOcr.recognizeText(base64) 调用
*/
const val OCR_MODULE_NAME = "PpOcr"
@ReactModule(name = OCR_MODULE_NAME)
class OcrModule(private val context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lock = ReentrantLock()
private var ortEnv: OrtEnvironment? = null
private var detSession: OrtSession? = null
private var recSession: OrtSession? = null
private var dictionary: List<String> = emptyList()
@Volatile private var initialized = false
@Volatile private var initFailed = false
override fun getName(): String = OCR_MODULE_NAME
override fun initialize() {
super.initialize()
// 异步加载模型,避免阻塞 RN 桥初始化
scope.launch { initEngine() }
}
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() {
lock.lock()
try {
if (initialized || initFailed) return
val env = OrtEnvironment.getEnvironment()
val opts = OrtSession.SessionOptions().apply {
// CPU 线程数2 是兼容性/性能的稳妥折中(高端机可调高)
setInterOpNumThreads(2)
setIntraOpNumThreads(2)
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
}
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
val det = env.createSession(detBytes, opts)
val rec = env.createSession(recBytes, opts)
val dict = loadDictionary()
detSession = det
recSession = rec
ortEnv = env
dictionary = dict
initialized = true
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
} finally {
lock.unlock()
}
}
/** rec 推理用的 OrtEnvironment复用 ortEnv 单例)。 */
private val recEnv: OrtEnvironment? get() = ortEnv
/**
* 加载 ppocrv5_dict.txt 字典
*
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
* 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode
*/
private fun loadDictionary(): List<String> {
val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
lines.forEach { line ->
// PaddleOCR 字典每行一个字符(去掉行尾换行)
words.add(line.trimEnd('\r', '\n'))
}
}
}
return words
}
/**
* 识别图片文本
* @param imageBase64 base64 编码的图片JPEG/PNG
* @return 识别出的纯文本所有行用 \n 连接
*/
@ReactMethod
fun recognizeText(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text }
promise.resolve(text)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeText 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/**
* 识别并返回带坐标的文本块用于复杂版面
* @return JSON 数组字符串[{text, x, y, width, height, confidence}]
*/
@ReactMethod
fun recognizeTextBlocks(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
val blocks = runInference(scaled)
// 序列化为 RN WritableArray
val arr = WritableNativeArray()
for (b in blocks) {
val map: WritableMap = WritableNativeMap()
map.putString("text", b.text)
map.putDouble("x", b.x.toDouble())
map.putDouble("y", b.y.toDouble())
map.putDouble("width", b.width.toDouble())
map.putDouble("height", b.height.toDouble())
map.putDouble("confidence", b.confidence.toDouble())
arr.pushMap(map)
}
promise.resolve(arr)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeTextBlocks 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/** 引擎是否已就绪(模型加载完成)。 */
@ReactMethod
fun isReady(promise: Promise) {
promise.resolve(initialized)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
if (!initialized && !initFailed) initEngine()
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
}
/** 完整推理det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
return emptyList()
}
val rec = recSession ?: run {
Log.e(OCR_MODULE_NAME, "recSession 为空,放弃推理")
return emptyList()
}
var resized: Bitmap? = null
var detInputTensor: OnnxTensor? = null
var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>()
try {
// ---- 1. 文本检测DB----
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
val ratioX = bitmap.width.toFloat() / resized.width
val ratioY = bitmap.height.toFloat() / resized.height
val detInput = preprocessDet(resized)
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
val detInputs = mapOf("x" to detInputTensor)
detOutputs = det.run(detInputs)
@Suppress("UNCHECKED_CAST")
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
// DB 后处理threshold → 轮廓 → 最小外接矩形
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
if (boxes.isEmpty()) return emptyList()
// ---- 2. 文本识别CRNN+CTC----
for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null
var recOutputs: OrtSession.Result? = null
try {
crop = cropBox(bitmap, box)
if (crop == null) {
Log.w(OCR_MODULE_NAME, "裁剪文本框失败 (index = $idx)")
continue
}
val recInput = preprocessRec(crop)
recInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(recInput.data), longArrayOf(1L, 3L, REC_IMAGE_HEIGHT.toLong(), recInput.w.toLong()))
val recInputs = mapOf("x" to recInputTensor)
recOutputs = rec.run(recInputs)
// 输出 shape: [1, T, numClasses]
@Suppress("UNCHECKED_CAST")
val logits = (recOutputs[0].value as Array<Array<FloatArray>>)[0]
val (text, conf) = ctcGreedyDecode(logits)
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val minX = (xs.minOrNull() ?: 0f).toInt()
val minY = (ys.minOrNull() ?: 0f).toInt()
val maxX = (xs.maxOrNull() ?: 0f).toInt()
val maxY = (ys.maxOrNull() ?: 0f).toInt()
val w = maxX - minX
val h = maxY - minY
Log.i(OCR_MODULE_NAME, "文本框 $idx 识别结果 = '$text', 坐标 = ($minX, $minY, $w, $h), 置信度 = $conf")
if (text.isNotEmpty()) {
results.add(OcrBlock(text, minX.toFloat(), minY.toFloat(), w.toFloat(), h.toFloat(), conf))
}
} finally {
crop?.recycle()
recInputTensor?.close()
recOutputs?.close()
}
}
} finally {
if (resized !== bitmap) {
resized?.recycle()
}
detInputTensor?.close()
detOutputs?.close()
}
return results
}
// ============== 前处理 ==============
/** det 前处理resize → BCHW → normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])。 */
private fun preprocessDet(bmp: Bitmap): TensorData {
val w = bmp.width
val h = bmp.height
val pixels = IntArray(w * h)
bmp.getPixels(pixels, 0, w, 0, 0, w, h)
val data = FloatArray(3 * w * h)
// BCHW 顺序
val means = floatArrayOf(0.485f, 0.456f, 0.406f)
val stds = floatArrayOf(0.229f, 0.224f, 0.225f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
for (y in 0 until h) {
for (x in 0 until w) {
val px = pixels[y * w + x]
// 提取 R/G/Bc=0→R, 1→G, 2→B
val channelVal = (px shr (16 - 8 * c)) and 0xFF
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
return TensorData(data, w, h)
}
/** rec 前处理crop → resize 到 48 高(保持宽高比)→ pad 到整除 4 → normalize。 */
private fun preprocessRec(bmp: Bitmap): TensorData {
var w = bmp.width
val h = bmp.height
// resize 到高度 48宽度等比缩放
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存
resizedW = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
else Bitmap.createScaledBitmap(bmp, resizedW, REC_IMAGE_HEIGHT, true)
w = resized.width
val pixels = IntArray(w * REC_IMAGE_HEIGHT)
resized.getPixels(pixels, 0, w, 0, 0, w, REC_IMAGE_HEIGHT)
val data = FloatArray(3 * w * REC_IMAGE_HEIGHT)
val means = floatArrayOf(0.5f, 0.5f, 0.5f)
val stds = floatArrayOf(0.5f, 0.5f, 0.5f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
for (y in 0 until REC_IMAGE_HEIGHT) {
for (x in 0 until w) {
val px = pixels[y * w + x]
val channelVal = (px shr (16 - 8 * c)) and 0xFF
data[c * w * REC_IMAGE_HEIGHT + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
if (resized !== bmp) resized.recycle()
return TensorData(data, w, REC_IMAGE_HEIGHT)
}
// ============== DB 后处理(简化版) ==============
// 参考 PaddleOCR db_postprocesssigmoid → threshold → 连通域 → 最小外接矩形
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
/**
* DB 后处理sigmoid + 阈值 0.3 二值图 连通域外接矩形
* 简化版用水平投影切行 + 垂直投影切列得到矩形框对账单类版面够用
*/
private fun dbPostprocess(prob: Array<FloatArray>, h: Int, w: Int, ratioX: Float, ratioY: Float): List<List<FloatArray>> {
// 自动检测是否需要 Sigmoid
var minVal = Float.MAX_VALUE
var maxVal = Float.MIN_VALUE
for (y in 0 until h) {
for (x in 0 until w) {
val v = prob[y][x]
if (v < minVal) minVal = v
if (v > maxVal) maxVal = v
}
}
val needSigmoid = minVal < -0.05f || maxVal > 1.05f
Log.i(OCR_MODULE_NAME, "DB prob min=$minVal, max=$maxVal, needSigmoid=$needSigmoid")
// 过滤状态栏前8%和导航栏后8%),避免其上的干扰字符(如电池、时间、返回键)影响识别或导致行粘连
val startY = (h * 0.08).toInt()
val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) }
var activeCountTotal = 0
for (y in 0 until h) {
for (x in 0 until w) {
if (y < startY || y > endY) {
binMask[y][x] = 0
continue
}
val raw = prob[y][x]
val sig = if (needSigmoid) {
1.0f / (1.0f + Math.exp(-raw.toDouble()).toFloat())
} else {
raw
}
val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++
}
}
Log.i(OCR_MODULE_NAME, "二值化完成: 活跃像素 = $activeCountTotal / ${w * h}")
// 清理垂直干扰线(如滚动条、背景边框线):如果某列在文本有效区域内的活跃像素超过该区域高度的 30%,视为干扰列,整列清零
val maxColActive = ((endY - startY) * 0.3).toInt()
var clearedColsCount = 0
for (x in 0 until w) {
var colActive = 0
for (y in startY..endY) {
if (binMask[y][x] == 1) colActive++
}
if (colActive > maxColActive) {
clearedColsCount++
for (y in 0 until h) {
binMask[y][x] = 0
}
}
}
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 水平投影:按行找文本行
val rowHits = IntArray(h)
for (y in 0 until h) {
var sum = 0
for (x in 0 until w) sum += binMask[y][x]
rowHits[y] = sum
}
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
val rowRanges = mutableListOf<IntArray>()
var inLine = false
var lineStart = 0
for (y in 0 until h) {
val isText = rowHits[y] >= minRowWidth
if (isText && !inLine) { inLine = true; lineStart = y }
else if (!isText && inLine) {
rowRanges.add(intArrayOf(lineStart, y - 1))
inLine = false
}
}
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成找到行Ranges数 = ${rowRanges.size}")
val boxes = mutableListOf<List<FloatArray>>()
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
val colHits = IntArray(w)
for (x in 0 until w) {
var sum = 0
for (y in y0..y1) sum += binMask[y][x]
colHits[x] = sum
}
val minColHeight = max(1, (y1 - y0 + 1) / 12)
var inSeg = false
var segStart = 0
var segs = mutableListOf<IntArray>()
for (x in 0 until w) {
val isText = colHits[x] >= minColHeight
if (isText && !inSeg) { inSeg = true; segStart = x }
else if (!isText && inSeg) {
// 合并间隔很近的段
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = x - 1
} else {
segs.add(intArrayOf(segStart, x - 1))
}
inSeg = false
}
}
if (inSeg) {
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = w - 1
} else {
segs.add(intArrayOf(segStart, w - 1))
}
}
for ((x0, x1) in segs.map { it[0] to it[1] }) {
// 过滤过小的框
val boxW = x1 - x0 + 1
val boxH = y1 - y0 + 1
if (boxW < 4 || boxH < 2) continue
// 映射回原图坐标4 个角点)
val fx0 = x0 * ratioX
val fx1 = x1 * ratioX
val fy0 = y0 * ratioY
val fy1 = y1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
}
return boxes
}
// ============== CTC 解码 ==============
/**
* CTC greedy decode每个时间步取 argmax blank 去重复返回 (text, avgConfidence)
*
* PaddleOCR 约定logits index 0 固定是 blank字符从 index 1
* dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1
* 已用 onnxruntime 实证argmax 序列中 0 占多数 blank真实字符索引
* 90'支' idx-1 映射到 dictionary 即可正确还原中文
*/
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f
val numClasses = logits[0].size
val blankIdx = 0 // PaddleOCR CTCblank 固定在 index 0
val sb = StringBuilder()
var lastIdx = -1
var confSum = 0.0f
var confCount = 0
for (t in logits.indices) {
var maxIdx = 0
var maxVal = logits[t][0]
for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
}
// softmax 概率(用于置信度统计)
var expSum = 0.0
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
val prob = Math.exp(maxVal.toDouble()) / expSum
if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx])
confSum += prob.toFloat()
confCount++
}
}
lastIdx = maxIdx
}
val avgConf = if (confCount > 0) confSum / confCount else 0f
return sb.toString() to avgConf
}
// ============== Bitmap 工具 ==============
private fun cropBox(bmp: Bitmap, box: List<FloatArray>): Bitmap? {
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val paddingX = 4
val paddingY = 2
val minX = max(0, (xs.minOrNull() ?: 0f).toInt() - paddingX)
val minY = max(0, (ys.minOrNull() ?: 0f).toInt() - paddingY)
val maxX = min(bmp.width, ((xs.maxOrNull() ?: 0f) + 1).toInt() + paddingX)
val maxY = min(bmp.height, ((ys.maxOrNull() ?: 0f) + 1).toInt() + paddingY)
val w = maxX - minX
val h = maxY - minY
if (w < 2 || h < 2) return null
return Bitmap.createBitmap(bmp, minX, minY, w, h)
}
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
if (ratio >= 1f) return bmp
val newW = (bmp.width * ratio).toInt()
val newH = (bmp.height * ratio).toInt()
// 确保尺寸是 32 的倍数det 模型下采样要求)
val alignedW = (newW / 32) * 32
val alignedH = (newH / 32) * 32
if (alignedW < 32 || alignedH < 32) return bmp
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
}
// ============== 公共工具(复用) ==============
/** 解码 base64 图片为 Bitmap。 */
private fun decodeBase64(base64: String): Bitmap? {
return try {
// 去除 data:image/...;base64, 前缀
val data = if (base64.contains(",")) base64.substringAfter(",") else base64
val bytes = Base64.decode(data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "base64 解码失败: ${e.message}")
null
}
}
/**
* 短边压缩到 maxShortEdge参考 AutoAccounting scaleDownForOcr
* 像素量比 1440p 减少约 75%识别速度大幅提升
*/
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val shortEdge = minOf(width, height)
if (shortEdge <= maxShortEdge) return bitmap
val scale = maxShortEdge.toFloat() / shortEdge
val newWidth = (width * scale).toInt()
val newHeight = (height * scale).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
override fun onCatalystInstanceDestroy() {
super.onCatalystInstanceDestroy()
release()
}
override fun invalidate() {
super.invalidate()
release()
}
private fun release() {
lock.lock()
try {
detSession?.close()
recSession?.close()
// OrtEnvironment 是单例,不主动 close进程级
detSession = null
recSession = null
ortEnv = null
initialized = false
} catch (_: Exception) {
} finally {
lock.unlock()
}
}
/** 识别结果块。 */
private data class OcrBlock(val text: String, val x: Float, val y: Float, val width: Float, val height: Float, val confidence: Float)
/** 预处理后的张量数据 + 宽高。 */
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
companion object {
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE。 */
private const val OCR_MAX_SHORT_EDGE = 720
/** det resize 最大边PaddleOCR limit_max_side_len 默认值)。 */
private const val DET_LIMIT_MAX_SIDE = 960
/** DB 二值化阈值。 */
private const val DET_THRESH = 0.3f
/** 投影法合并相邻文本段的间隔(像素)。 */
private const val DET_MERGE_GAP = 10
/** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
// PP-OCRv5 多语言识别模型的配套字典18383 字符 + 运行时 1 blank = 18385 维输出)。
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt6623会导致 CTC 解码乱码。
private const val ASSET_DICT = "ppocrv5_dict.txt"
}
}

View File

@ -0,0 +1,24 @@
package com.beancount.mobile.ppocr
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
/**
* 注册 OcrModule React Native Packageplan.md3.2 Config Plugin
*
* app.plugin.js withMainApplication 注入到 MainApplication.getPackages() 列表
* RN 在启动时遍历所有 Package调用 createNativeModules 注册原生模块
*/
class OcrPackage : ReactPackage {
override fun createNativeModules(rc: ReactApplicationContext): List<NativeModule> {
return listOf(OcrModule(rc))
}
override fun createViewManagers(rc: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
return emptyList()
}
}

View File

@ -103,11 +103,10 @@ function withPpOcr(config) {
config = withAppBuildGradle(config, (modConfig) => { config = withAppBuildGradle(config, (modConfig) => {
let gradle = modConfig.modResults.contents; let gradle = modConfig.modResults.contents;
if (!gradle.includes('onnxruntime')) { if (!gradle.includes('onnxruntime')) {
// 在 dependencies { ... } 块末尾追加 // 在 dependencies { 开头处插入,避免嵌套花括号的正则匹配错误
gradle = gradle.replace( gradle = gradle.replace(
/dependencies\s*{([\s\S]*?)^\s*}/m, /(dependencies\s*\{)/,
(m, inner) => `$1\n // PP-OCRv5 ONNX Runtime由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'`
`dependencies {${inner}\n // PP-OCRv5 ONNX Runtime由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'\n}`,
); );
} }
modConfig.modResults.contents = gradle; modConfig.modResults.contents = gradle;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,90 @@
package com.beancount.mobile.screenshot
import android.net.Uri
import android.util.Base64
import android.provider.MediaStore
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 截图监控 RN Moduleplan.md3.13 截图自动记账通道
*
* JS 端调用 start()/stop() 控制 ContentObserver 的注册/注销
* 检测到截图时读取 base64 并通过 DeviceEventEmitter 发送到 JS
*
* 事件格式WritableNativeMap BillingAccessibilityService.sendScreenshotEvent 一致
* { base64: "data:image/jpeg;base64,...", packageName: "...", timestamp: Long, displayName: "..." }
*/
class ScreenshotModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private var observer: ScreenshotObserver? = null
override fun getName() = "ScreenshotMonitor"
override fun invalidate() {
stop()
super.invalidate()
}
/**
* 启动截图监控
* 在主线程注册 ContentObserver检测到截图时发送 billingScreenshot 事件
*/
@ReactMethod
fun start() {
if (observer != null) return
// 确保 ReactContextHolder 有上下文
ReactContextHolder.context = reactContext
observer = ScreenshotObserver(reactContext) { uri ->
sendScreenshotEvent(uri)
}
observer?.register()
}
/** 停止截图监控。 */
@ReactMethod
fun stop() {
observer?.unregister()
observer = null
}
/** 读取截图 base64 并发送到 JSWritableNativeMap 格式,与无障碍服务一致)。 */
private fun sendScreenshotEvent(uri: Uri) {
try {
val resolver = reactContext.contentResolver
// 读取图片为 base64
val inputStream = resolver.openInputStream(uri) ?: return
val bytes = inputStream.use { it.readBytes() }
inputStream.close()
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
// 同时查询显示名
val projection = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
var displayName = ""
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
displayName = cursor.getString(0) ?: ""
}
}
val map = WritableNativeMap()
map.putString("base64", base64)
map.putString("uri", uri.toString())
map.putString("packageName", "")
map.putString("displayName", displayName)
map.putDouble("timestamp", System.currentTimeMillis().toDouble())
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingScreenshot", map)
} catch (e: Exception) {
android.util.Log.e("ScreenshotModule", "发送截图事件失败: ${e.message}", e)
}
}
}

View File

@ -0,0 +1,116 @@
package com.beancount.mobile.screenshot
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
/**
* 截图监听 ContentObserverplan.md3.13 截图自动记账通道
*
* 参考 BeeCount ScreenshotObserver.kt
* - 监听 MediaStore.Images.Media.EXTERNAL_CONTENT_URI 变化
* - 关键词匹配screenshot/截屏/截图/screen_shot
* - 30 秒时间窗过滤旧截图
* - processedPaths 去重最多 200
* - 过滤小米 .pending- 临时文件
* - 500ms 防抖
*
* Config Plugin 注册触发后通过 DeviceEventEmitter 推送到 JS ScreenshotChannel
*
* src/domain/constants.ts 同步TIME_WINDOW_MSMAX_PROCESSEDSCREENSHOT_KEYWORDS
* 修改时需两边同时更新
*/
class ScreenshotObserver(
private val context: Context,
private val handler: Handler = Handler(Looper.getMainLooper()),
private val onScreenshot: (uri: Uri) -> Unit,
) : ContentObserver(handler) {
companion object {
private const val TAG = "ScreenshotObserver"
private const val TIME_WINDOW_MS = 30_000L
private const val MAX_PROCESSED = 200
private val SCREENSHOT_KEYWORDS = listOf("screenshot", "截屏", "截图", "screen_shot", "Screenshot")
}
private val resolver = context.contentResolver
private val processedPaths = LinkedHashSet<String>()
@Volatile private var lastCheckTime = System.currentTimeMillis()
/** 注册监听。 */
fun register() {
resolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true,
this,
)
Log.i(TAG, "截图监听已注册")
}
/** 注销监听。 */
fun unregister() {
resolver.unregisterContentObserver(this)
Log.i(TAG, "截图监听已注销")
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
uri ?: return
handler.post { processNewScreenshot(uri) }
}
private fun processNewScreenshot(uri: Uri) {
try {
// 查询截图信息
val projection = arrayOf(
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.DISPLAY_NAME,
)
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return
val path = cursor.getString(0) ?: ""
val dateAdded = cursor.getLong(1)
val displayName = cursor.getString(2) ?: ""
// 1. 时间窗过滤30 秒内)
val now = System.currentTimeMillis() / 1000
if (now - dateAdded > TIME_WINDOW_MS / 1000) {
Log.d(TAG, "忽略旧截图超过30秒: $displayName")
return
}
// 2. 关键词匹配(必须是截图)
if (!SCREENSHOT_KEYWORDS.any { kw -> displayName.contains(kw, true) || path.contains(kw, true) }) {
return
}
// 3. 过滤小米 .pending- 临时文件
if (path.endsWith(".pending-") || path.contains(".pending-")) {
Log.d(TAG, "过滤小米 pending 临时文件: $displayName")
return
}
// 4. 去重
if (processedPaths.contains(path)) return
processedPaths.add(path)
if (processedPaths.size > MAX_PROCESSED) {
val it = processedPaths.iterator()
if (it.hasNext()) {
it.next()
it.remove()
}
}
Log.i(TAG, "检测到新截图: $displayName")
onScreenshot(uri)
}
} catch (e: Exception) {
Log.e(TAG, "处理截图异常: ${e.message}", e)
}
}
}

View File

@ -0,0 +1,20 @@
package com.beancount.mobile.screenshot
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* ReactPackage 注册 ScreenshotModule
* Config Plugin withMainApplication 注入 add(ScreenshotPackage()) MainApplication
*/
class ScreenshotPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ScreenshotModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View File

@ -0,0 +1,48 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function withSizeOptimization(config) {
// 1. 在 prebuild 时修改 gradle.properties
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const propertiesPath = path.join(projectRoot, 'gradle.properties');
if (fs.existsSync(propertiesPath)) {
let content = fs.readFileSync(propertiesPath, 'utf8');
if (content.includes('reactNativeArchitectures=')) {
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
} else {
content += '\nreactNativeArchitectures=arm64-v8a\n';
}
fs.writeFileSync(propertiesPath, content, 'utf8');
}
return modConfig;
}
]);
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
if (fs.existsSync(buildGradlePath)) {
let content = fs.readFileSync(buildGradlePath, 'utf8');
if (!content.includes('splits {')) {
content = content.replace(
/android\s*\{/,
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
);
fs.writeFileSync(buildGradlePath, content, 'utf8');
}
}
return modConfig;
}
]);
return config;
}
module.exports = withSizeOptimization;

View File

@ -0,0 +1,4 @@
{
"name": "size-optimization",
"main": "app.plugin.js"
}

View File

@ -0,0 +1,73 @@
package com.beancount.mobile.sms
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.telephony.SmsMessage
import android.util.Log
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 短信监听 Receiverplan.md4.2 短信监听服务+决策 4 Config Plugin
*
* 参考 AutoAccounting SmsReceiver
* - 监听 SMS_RECEIVED_ACTION
* - PDU 解析发送方 + 正文
* - 关键词预过滤银行短信含交易/消费/余额JS 层进一步过滤
* - 通过 DeviceEventEmitter 推送到 JS SmsChannel
*
* 需在 manifest 注册 RECEIVE_SMS 权限 Config Plugin 注入
*/
class BillingSmsReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BillingSms"
/** 银行短信关键词(预过滤,减少 JS 层负担)。 */
private val BANK_KEYWORDS = listOf("交易", "消费", "收入", "支出", "余额", "转账", "入账", "扣款", "退款")
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
runCatching {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (message in messages) {
val sender = message.displayOriginatingAddress ?: ""
val body = message.displayMessageBody ?: ""
if (sender.isBlank() || body.isBlank()) continue
// 关键词预过滤(仅处理疑似银行短信)
if (!BANK_KEYWORDS.any { body.contains(it) }) continue
Log.d(TAG, "收到银行短信: sender=$sender")
sendSmsEvent(sender, body)
}
}.onFailure {
Log.e(TAG, "短信处理异常: ${it.message}", it)
}
}
/** 把短信事件推送到 JS 层 SmsChannel.handleSms。 */
private fun sendSmsEvent(sender: String, body: String) {
val reactContext = ReactContextHolder.context ?: run {
Log.w(TAG, "RN 上下文未就绪,丢弃短信")
return
}
try {
val map = WritableNativeMap().apply {
putString("sender", sender)
putString("body", body)
putDouble("timestamp", System.currentTimeMillis().toDouble())
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingSms", map)
} catch (e: Exception) {
Log.e(TAG, "推送短信事件失败: ${e.message}")
}
}
}

View File

@ -9,6 +9,7 @@
* domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt * domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt
*/ */
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/constants';
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai'; import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
export interface ChatMessage { export interface ChatMessage {
@ -29,14 +30,12 @@ export interface ChatResponse {
error?: string; error?: string;
} }
/** 记账意图关键词plan.md「8.1」)。 */
const TRANSACTION_KEYWORDS = ['买', '花', '消费', '支付', '记账', '付', '收入', '赚', '工资', '收'];
const AMOUNT_PATTERN = /\d+(?:\.\d+)?/; const AMOUNT_PATTERN = /\d+(?:\.\d+)?/;
/** 判断是否为记账意图(金额 + 关键词)。 */ /** 判断是否为记账意图(金额 + 关键词)。 */
export function isTransactionIntent(input: string): boolean { export function isTransactionIntent(input: string): boolean {
const hasAmount = AMOUNT_PATTERN.test(input); const hasAmount = AMOUNT_PATTERN.test(input);
const hasKeyword = TRANSACTION_KEYWORDS.some(kw => input.includes(kw)); const hasKeyword = CHAT_TRANSACTION_KEYWORDS.some(kw => input.includes(kw));
return hasAmount && hasKeyword; return hasAmount && hasKeyword;
} }

View File

@ -34,8 +34,6 @@ export default function HomeScreen() {
if (monthlyData.length === 0) return null; if (monthlyData.length === 0) return null;
return monthlyData[monthlyData.length - 1]; return monthlyData[monthlyData.length - 1];
}, [monthlyData]); }, [monthlyData]);
const recentTx = useMemo(() => transactions.slice(-5).reverse(), [transactions]);
// 1. 账户树计算 // 1. 账户树计算
const accountNodes = useMemo(() => { const accountNodes = useMemo(() => {
if (!ledger || !ledger.accounts) return []; if (!ledger || !ledger.accounts) return [];
@ -51,12 +49,14 @@ export default function HomeScreen() {
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项 // 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false); const autoConfirmed = useRef(false);
useEffect(() => { useEffect(() => {
let isMounted = true;
if (autoConfirmed.current) return; if (autoConfirmed.current) return;
const autoDue = dueRecurring.filter(r => r.autoConfirm); const autoDue = dueRecurring.filter(r => r.autoConfirm);
if (autoDue.length === 0) return; if (autoDue.length === 0) return;
autoConfirmed.current = true; autoConfirmed.current = true;
(async () => { (async () => {
for (const rec of autoDue) { for (const rec of autoDue) {
if (!isMounted) break;
try { try {
const draft = instantiateRecurring(rec); const draft = instantiateRecurring(rec);
await addTransaction(draft); await addTransaction(draft);
@ -67,6 +67,7 @@ export default function HomeScreen() {
} }
} }
})(); })();
return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]); }, [dueRecurring, addTransaction, updateRecurringTransaction]);
const handleConfirmRecurring = async (rec: any) => { const handleConfirmRecurring = async (rec: any) => {
@ -93,91 +94,104 @@ export default function HomeScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('app.name')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}>
{t('app.name')}
</Text>
</View> </View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产卡片 */} {/* 净资产 Hero 卡片 */}
<Card title={t('home.netWorthTitle')}> <Card
<View style={styles.balanceRow}> style={{
<View style={styles.balanceItem}> backgroundColor: theme.colors.accent,
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.assets')}</Text> borderColor: theme.colors.accentLight,
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}> shadowColor: theme.colors.accent,
shadowOpacity: 0.15,
shadowRadius: 10,
}}
>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: '600', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.netWorthTitle')}
</Text>
<Text style={[theme.typography.h1, { color: '#FFFFFF', fontSize: 32, fontWeight: '800', marginVertical: 8, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.netWorth)}
</Text>
<View style={styles.heroFooter}>
<View>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.assets')}
</Text>
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.assets)} {fmtAmount(netWorth.assets)}
</Text> </Text>
</View> </View>
<View style={styles.balanceItem}> <View style={{ alignItems: 'flex-end' }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.liabilities')}</Text> <Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}> {t('home.liabilities')}
{fmtAmount(netWorth.liabilities)}
</Text> </Text>
</View> <Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
<View style={styles.balanceItem}> {fmtAmount(netWorth.liabilities)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorth')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{fmtAmount(netWorth.netWorth)}
</Text> </Text>
</View> </View>
</View> </View>
</Card> </Card>
{/* 周期性记账提醒 */} {/* Bento 双格排列 */}
{dueRecurring.length > 0 && ( {(dueRecurring.length > 0 || currentMonth) && (
<Card title={t('home.dueRecurring')}> <View style={styles.bentoRow}>
{dueRecurring.length > 0 ? (
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
<View style={styles.recurringList}> <View style={styles.recurringList}>
{dueRecurring.map(rec => ( {dueRecurring.slice(0, 2).map(rec => (
<View key={rec.id} style={[styles.recurringRow, { borderBottomColor: theme.colors.divider }]}> <View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
<View style={{ flex: 1 }}> <Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{rec.name} {rec.name}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.dueAmount')}: {rec.draft.postings[0]?.amount} CNY
</Text>
</View>
<Pressable <Pressable
onPress={() => handleConfirmRecurring(rec)} onPress={() => handleConfirmRecurring(rec)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.confirmBtn, styles.confirmBtnMini,
{ {
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.bgTertiary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm, borderRadius: theme.radii.sm,
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
}, },
]} ]}
> >
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{t('home.confirmRecurring')}</Text> <Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
{rec.draft.postings[0]?.amount} CNY
</Text>
</Pressable> </Pressable>
</View> </View>
))} ))}
</View> </View>
</Card> </Card>
)} ) : null}
{/* 本月收支 */} {currentMonth ? (
{currentMonth && ( <Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
<Card title={`${currentMonth.month} ${t('home.title')}`}> <View style={styles.bentoStats}>
<View style={styles.balanceRow}> <View>
<View style={styles.balanceItem}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.income')}</Text> {t('home.income')}
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}> </Text>
{currentMonth.income.toFixed(2)} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
+{fmtAmount(currentMonth.income.toString())}
</Text> </Text>
</View> </View>
<View style={styles.balanceItem}> <View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.expense')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}> {t('home.expense')}
{currentMonth.expense.toFixed(2)}
</Text> </Text>
</View> <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
<View style={styles.balanceItem}> -{fmtAmount(currentMonth.expense.toString())}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.balance')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{(currentMonth.income - currentMonth.expense).toFixed(2)}
</Text> </Text>
</View> </View>
</View> </View>
</Card> </Card>
) : null}
</View>
)} )}
{/* 月度趋势 */} {/* 月度趋势 */}
@ -193,22 +207,6 @@ export default function HomeScreen() {
<AccountTree nodes={accountNodes} /> <AccountTree nodes={accountNodes} />
</Card> </Card>
)} )}
{/* 最近交易 */}
<Card title={`${t('home.recentTransactions')} (${recentTx.length})`}>
{recentTx.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.recentEmpty')}
</Text>
)}
{recentTx.map(tx => (
<TransactionCard
key={tx.id}
transaction={tx}
onPress={() => router.push(`/transaction/${tx.id}`)}
/>
))}
</Card>
</ScrollView> </ScrollView>
<SpeedDial actions={[ <SpeedDial actions={[
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') }, { key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') },
@ -223,9 +221,11 @@ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 96 }, content: { padding: 16, paddingBottom: 96 },
balanceRow: { flexDirection: 'row', gap: 8 }, heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 },
balanceItem: { flex: 1, alignItems: 'center', gap: 4 }, bentoRow: { flexDirection: 'row', gap: 12 },
bentoCol: { flex: 1 },
recurringList: { gap: 8 }, recurringList: { gap: 8 },
recurringRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1 }, recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
confirmBtn: { paddingVertical: 6, paddingHorizontal: 12 }, confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
}); });

View File

@ -10,18 +10,20 @@
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { captureRef } from 'react-native-view-shot'; import { captureRef } from 'react-native-view-shot';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useTheme } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport'; import { MonthlyReport } from '../../components/charts/MonthlyReport';
import { CategoryPie } from '../../components/charts/CategoryPie'; import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarHeatmap } from '../../components/charts/CalendarHeatmap'; import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard';
import { NetWorthChart } from '../../components/charts/NetWorthChart'; import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport'; import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary'; import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
@ -29,18 +31,29 @@ import { BaseOpenAIProvider } from '../../domain/ai';
export default function ReportScreen() { export default function ReportScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const aiEnabled = useSettingsStore(s => s.aiEnabled); const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey); const aiApiKey = useSettingsStore(s => s.aiApiKey);
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl); const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel); const aiModel = useSettingsStore(s => s.aiModel);
// Tab 状态
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly');
// 月份切换状态 // 月份切换状态
const now = new Date(); const now = new Date();
const [viewYear, setViewYear] = useState(now.getFullYear()); const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1); const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
// 周切换状态
const [viewWeekDate, setViewWeekDate] = useState(new Date());
// 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
// AI 总结弹窗 // AI 总结弹窗
const [summaryModal, setSummaryModal] = useState(false); const [summaryModal, setSummaryModal] = useState(false);
const [summaryText, setSummaryText] = useState(''); const [summaryText, setSummaryText] = useState('');
@ -50,6 +63,41 @@ export default function ReportScreen() {
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]); const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
// 周报表的时间范围:计算周一和周日
const weeklyRange = useMemo(() => {
const d = new Date(viewWeekDate);
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
monday.setHours(0,0,0,0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23,59,59,999);
// 计算 ISO 周数
const date = new Date(monday.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
return {
startStr: monday.toISOString().slice(0, 10),
endStr: sunday.toISOString().slice(0, 10),
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})`
};
}, [viewWeekDate]);
const changeWeek = (days: number) => {
setSelectedReportDate(null);
const next = new Date(viewWeekDate);
next.setDate(next.getDate() + days);
setViewWeekDate(next);
};
// 净资产趋势的日期序列(基于选中月份,往前 6 个月) // 净资产趋势的日期序列(基于选中月份,往前 6 个月)
const netWorthDates = useMemo(() => { const netWorthDates = useMemo(() => {
const dates: string[] = []; const dates: string[] = [];
@ -61,14 +109,72 @@ export default function ReportScreen() {
}, [viewYear, viewMonth]); }, [viewYear, viewMonth]);
const prevMonth = () => { const prevMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); } if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1); else setViewMonth(m => m - 1);
}; };
const nextMonth = () => { const nextMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); } if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1); else setViewMonth(m => m + 1);
}; };
// 过滤后的交易
const weeklyTxs = useMemo(() => {
return transactions.filter(t => t.date >= weeklyRange.startStr && t.date <= weeklyRange.endStr);
}, [transactions, weeklyRange]);
const monthlyTxs = useMemo(() => {
const prefix = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
return transactions.filter(t => t.date.startsWith(prefix));
}, [transactions, viewYear, viewMonth]);
const annualTxs = useMemo(() => {
return transactions.filter(t => t.date.startsWith(String(viewYear)));
}, [transactions, viewYear]);
// 周收支统计
const weeklyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of weeklyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: weeklyTxs.length };
}, [weeklyTxs]);
// 月度收支统计
const monthlyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of monthlyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: monthlyTxs.length };
}, [monthlyTxs]);
// 选中日期的交易明细
const selectedDayTxs = useMemo(() => {
if (!selectedReportDate) return [];
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate);
}, [monthlyTxs, selectedReportDate]);
// 千分位格式化金额
const fmtAmount = (num: number) => {
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
// 导出报表为图片 // 导出报表为图片
const handleExportImage = async () => { const handleExportImage = async () => {
try { try {
@ -84,10 +190,9 @@ export default function ReportScreen() {
} }
}; };
// AI / 纯文本月度总结AI 已配置时用真实 AI否则降级为纯文本 // AI / 纯文本月度总结
const handleMonthlySummary = async () => { const handleMonthlySummary = async () => {
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth); const stats = calculateMonthlyStats(transactions, viewYear, viewMonth);
// 若 AI 已配置,调真实 AI 生成总结;否则降级纯文本
if (aiEnabled && aiApiKey && aiBaseUrl) { if (aiEnabled && aiApiKey && aiBaseUrl) {
try { try {
const provider = new (class extends BaseOpenAIProvider {})({ const provider = new (class extends BaseOpenAIProvider {})({
@ -124,7 +229,56 @@ export default function ReportScreen() {
</View> </View>
</View> </View>
{/* 月份切换器 */} {/* Tab 选择切换栏 */}
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
{([
{ key: 'weekly', label: t('report.tabWeekly') },
{ key: 'monthly', label: t('report.tabMonthly') },
{ key: 'annual', label: t('report.tabAnnual') }
] as const).map(tab => (
<Pressable
key={tab.key}
onPress={() => {
setActiveTab(tab.key);
setSelectedReportDate(null);
}}
style={[
styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
]}
>
<Text
style={[
theme.typography.body,
{
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400',
paddingVertical: 10
}
]}
>
{tab.label}
</Text>
</Pressable>
))}
</View>
{/* 日期选择切换器 */}
{activeTab === 'weekly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{weeklyRange.label}
</Text>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'monthly' && (
<View style={styles.monthSwitcher}> <View style={styles.monthSwitcher}>
<Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> <Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} /> <Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
@ -136,12 +290,30 @@ export default function ReportScreen() {
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable> </Pressable>
</View> </View>
)}
{activeTab === 'annual' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => setViewYear(y => y - 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{viewYear}
</Text>
<Pressable onPress={() => setViewYear(y => y + 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
<ScrollView <ScrollView
ref={scrollRef} ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
> >
{transactions.length === 0 ? ( {/* 数据空值判断 */}
{((activeTab === 'weekly' && weeklyTxs.length === 0) ||
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
<Card title={t('report.title')}> <Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')} {t('report.empty')}
@ -149,33 +321,98 @@ export default function ReportScreen() {
</Card> </Card>
) : ( ) : (
<> <>
<Card title={`${monthLabel} ${t('report.monthly')}`}> {/* 周报表看板 */}
<MonthlyReport transactions={transactions} /> {activeTab === 'weekly' && (
<>
<Card title={t('report.weeklyTitle')}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })}
</Text>
</Card> </Card>
<Card title={t('report.categoryPie')}> <CategoryPie transactions={weeklyTxs} />
<CategoryPie transactions={transactions} /> </>
)}
{/* 月度报表看板 */}
{activeTab === 'monthly' && (
<>
<Card title={`${monthLabel} 收支报告`}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.monthlyStats', { count: monthlyStats.count })}
</Text>
</Card> </Card>
<Card title={`${monthLabel} ${t('report.heatmap')}`}> <CategoryPie transactions={monthlyTxs} />
<CalendarHeatmap transactions={transactions} year={viewYear} month={viewMonth} />
</Card> <CalendarView
transactions={transactions}
year={viewYear}
month={viewMonth}
onDayPress={setSelectedReportDate}
/>
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(t => (
<TransactionCard
key={t.id}
transaction={t}
onPress={() => router.push(`/transaction/${t.id}`)}
/>
))}
{selectedDayTxs.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{t('calendar.noDayTx')}
</Text>
)}
</Card>
)}
<Card title={t('report.netWorthTrend')}>
<NetWorthChart transactions={transactions} dates={netWorthDates} /> <NetWorthChart transactions={transactions} dates={netWorthDates} />
</Card> </>
)}
<Card title={t('report.annual', { year: viewYear })}> {/* 年度报表看板 */}
{activeTab === 'annual' && (
<AnnualReport transactions={transactions} year={viewYear} /> <AnnualReport transactions={transactions} year={viewYear} />
</Card> )}
</> </>
)} )}
</ScrollView> </ScrollView>
{/* AI 月度总结弹窗 */} {/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}> <Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={styles.modalOverlay}> <View style={commonStyles.modalOverlay}>
<View style={[styles.modalCard, { backgroundColor: theme.colors.bgSecondary }]}> <View style={commonStyles.modalCard}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 12 }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 12 }]}>
{monthLabel} {t('report.aiSummary')} {monthLabel} {t('report.aiSummary')}
</Text> </Text>
@ -200,8 +437,10 @@ const styles = StyleSheet.create({
headerActions: { flexDirection: 'row', gap: 16 }, headerActions: { flexDirection: 'row', gap: 16 },
iconBtn: { padding: 4 }, iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 }, monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: 1, alignItems: 'center', justifyContent: 'center' }, monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
content: { padding: 16, paddingBottom: 96 }, content: { padding: 16, paddingBottom: 96 },
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.5)' }, tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 },
modalCard: { width: '100%', borderRadius: 12, padding: 20 }, tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
}); });

View File

@ -26,7 +26,7 @@ export default function SettingsScreen() {
]} ]}
> >
<Ionicons name={icon} size={20} color={theme.colors.accent} /> <Ionicons name={icon} size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}>
{label} {label}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
@ -43,13 +43,11 @@ export default function SettingsScreen() {
{/* 数据与基础配置跳转 */} {/* 数据与基础配置跳转 */}
<Card title={t('settings.dataManagement')}> <Card title={t('settings.dataManagement')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('download-outline', t('tab.import'), '/import')}
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')} {renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')} {renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')} {renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')} {renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')} {renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
{renderNavLink('calendar', t('settings.calendar'), '/calendar')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')} {renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)} {renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)} {renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}

View File

@ -13,17 +13,19 @@ import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-na
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar'; import { SearchBar } from '../../components/SearchBar';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { Ionicons } from '@expo/vector-icons';
import { useSearch, type SearchFilters } from '../../hooks/useSearch'; import { useSearch, type SearchFilters } from '../../hooks/useSearch';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer'; type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
export default function TransactionsScreen() { export default function TransactionsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
@ -36,7 +38,17 @@ export default function TransactionsScreen() {
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const allTransactions = useMemo(() => ledger?.transactions ?? [], [ledger]); const allTransactions = useMemo(() => {
if (!ledger?.transactions) return [];
return ledger.transactions
.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => {
const dateCompare = b.tx.date.localeCompare(a.tx.date);
if (dateCompare !== 0) return dateCompare;
return b.idx - a.idx;
})
.map(item => item.tx);
}, [ledger]);
const allAccounts = useMemo(() => { const allAccounts = useMemo(() => {
const accts = new Set<string>(); const accts = new Set<string>();
for (const tx of allTransactions) { for (const tx of allTransactions) {
@ -81,18 +93,14 @@ export default function TransactionsScreen() {
key={tab.key} key={tab.key}
onPress={() => setDirection(tab.key)} onPress={() => setDirection(tab.key)}
style={[ style={[
styles.filterChip, commonStyles.chip,
{ direction === tab.key && commonStyles.chipActive,
backgroundColor: direction === tab.key ? theme.colors.accent : theme.colors.bgTertiary,
borderRadius: theme.radii.sm,
},
]} ]}
> >
<Text style={{ <Text style={[
color: direction === tab.key ? theme.colors.fgInverse : theme.colors.fgPrimary, commonStyles.chipText,
fontSize: 13, direction === tab.key && commonStyles.chipTextActive,
fontWeight: '600', ]}>
}}>
{tab.label} {tab.label}
</Text> </Text>
</Pressable> </Pressable>
@ -103,13 +111,17 @@ export default function TransactionsScreen() {
<View style={[styles.filterRow, { paddingBottom: 0 }]}> <View style={[styles.filterRow, { paddingBottom: 0 }]}>
<Pressable <Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)} onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={({ pressed }) => [styles.filterChip, { style={({ pressed }) => [
backgroundColor: showAdvancedFilters ? theme.colors.accent : theme.colors.bgTertiary, commonStyles.chip,
borderRadius: theme.radii.sm, showAdvancedFilters && commonStyles.chipActive,
opacity: pressed ? 0.6 : 1, { opacity: pressed ? 0.8 : 1 },
}]} ]}
> >
<Text style={{ color: showAdvancedFilters ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 12, fontWeight: '600' }}> <Text style={[
commonStyles.chipText,
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')} {t('transactions.advancedFilter')}
</Text> </Text>
</Pressable> </Pressable>
@ -124,7 +136,7 @@ export default function TransactionsScreen() {
onChangeText={setAccountFilter} onChangeText={setAccountFilter}
placeholder={t('transactions.accountFilterPlaceholder')} placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={commonStyles.input}
/> />
{/* 日期范围 */} {/* 日期范围 */}
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
@ -133,14 +145,14 @@ export default function TransactionsScreen() {
onChangeText={setDateFrom} onChangeText={setDateFrom}
placeholder={t('transactions.dateFrom')} placeholder={t('transactions.dateFrom')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]} style={[commonStyles.input, { flex: 1 }]}
/> />
<TextInput <TextInput
value={dateTo} value={dateTo}
onChangeText={setDateTo} onChangeText={setDateTo}
placeholder={t('transactions.dateTo')} placeholder={t('transactions.dateTo')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]} style={[commonStyles.input, { flex: 1 }]}
/> />
</View> </View>
</View> </View>
@ -178,10 +190,15 @@ export default function TransactionsScreen() {
{/* 诊断(折叠) */} {/* 诊断(折叠) */}
<Pressable <Pressable
onPress={() => setShowDiagnostics(!showDiagnostics)} onPress={() => setShowDiagnostics(!showDiagnostics)}
style={styles.diagToggle} style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
> >
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Ionicons
{showDiagnostics ? '▼' : '▶'} {t('diagnostics.title')} name={showDiagnostics ? 'chevron-down-outline' : 'chevron-forward-outline'}
size={14}
color={theme.colors.fgSecondary}
/>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('diagnostics.title')}
</Text> </Text>
</Pressable> </Pressable>
{showDiagnostics && ( {showDiagnostics && (
@ -215,8 +232,6 @@ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 }, filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 },
filterChip: { paddingVertical: 5, paddingHorizontal: 12 },
advInput: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6, fontSize: 13 },
content: { padding: 16, paddingTop: 8 }, content: { padding: 16, paddingTop: 8 },
diagToggle: { paddingVertical: 12, alignItems: 'center' }, diagToggle: { paddingVertical: 12, alignItems: 'center' },
}); });

View File

@ -5,6 +5,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { buildErrorRecoveryData, crashReporter } from '../services/crash'; import { buildErrorRecoveryData, crashReporter } from '../services/crash';
import * as FileSystem from 'expo-file-system/legacy';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
error: Error; error: Error;
@ -16,8 +17,17 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
const t = useT(); const t = useT();
const data = buildErrorRecoveryData(error); const data = buildErrorRecoveryData(error);
const clearCacheAndRestart = () => { const clearCacheAndRestart = async () => {
// 模拟重置错误缓存以恢复启动 try {
const settingsFile = FileSystem.documentDirectory + 'settings.json';
const metadataFile = FileSystem.documentDirectory + 'metadata.json';
const settingsInfo = await FileSystem.getInfoAsync(settingsFile);
if (settingsInfo.exists) await FileSystem.deleteAsync(settingsFile);
const metadataInfo = await FileSystem.getInfoAsync(metadataFile);
if (metadataInfo.exists) await FileSystem.deleteAsync(metadataFile);
} catch {
// 忽略删除错误
}
resetError(); resetError();
}; };

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native'; import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
@ -17,6 +17,27 @@ import { setupDeepLinking } from '../services/deepLink';
import { initPersistence } from '../store/storePersistence'; import { initPersistence } from '../store/storePersistence';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { parseNotification } from '../services/notification';
import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { logger } from '../utils/logger';
import {
useFonts,
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
} from '@expo-google-fonts/quicksand';
import {
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
} from '@expo-google-fonts/caveat';
let lastSignature = '';
let lastProcessedTime = 0;
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */ /** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
const SAMPLE_LEDGER = { const SAMPLE_LEDGER = {
@ -26,14 +47,22 @@ include "mobile.bean"
`, `,
}; };
/** 渠道 → 账户映射:导入账单时由规则自动匹配,此处仅提供首次缺省映射。 */
const DEMO_ACCOUNT_MAP: Record<string, string> = {};
/** 内层布局:引导/锁屏/路由三态切换。 */ /** 内层布局:引导/锁屏/路由三态切换。 */
function AppShell() { function AppShell() {
const { theme, isDark } = useTheme(); const { theme, isDark } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const [fontsLoaded, fontError] = useFonts({
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
});
const loadLedger = useLedgerStore(s => s.loadLedger); const loadLedger = useLedgerStore(s => s.loadLedger);
const setContext = useImportStore(s => s.setContext); const setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled); const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
@ -42,22 +71,79 @@ function AppShell() {
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading'); const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
const [privacyOverlay, setPrivacyOverlay] = useState(false); const [privacyOverlay, setPrivacyOverlay] = useState(false);
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
const checkAndReloadLedger = useCallback(async () => {
const mainPath = FileSystem.documentDirectory + 'main.bean';
try {
const info = await FileSystem.getInfoAsync(mainPath);
if (!info.exists) return;
const currentMtime = info.modificationTime;
const currentSize = info.size;
// 如果是第一次加载,或者文件时间/大小发生了改变,则重新读取并加载
if (
!lastMainFileRef.current ||
lastMainFileRef.current.modificationTime !== currentMtime ||
lastMainFileRef.current.size !== currentSize
) {
logger.info('layout', `检测到 main.bean 发生外部文件修改,正在重新加载... (mtime: ${currentMtime}, size: ${currentSize})`);
const content = await FileSystem.readAsStringAsync(mainPath);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
setContext({ rules, categories });
lastMainFileRef.current = { modificationTime: currentMtime, size: currentSize };
logger.info('layout', 'main.bean 重新解析并加载成功');
}
} catch (e) {
logger.error('layout', 'checkAndReloadLedger 失败', e);
}
}, [loadLedger, setContext]);
useEffect(() => { useEffect(() => {
// 1. 初始化持久化并还原数据 // 1. 初始化持久化并还原数据
initPersistence().then(() => { initPersistence().then(async () => {
// 2. 检查并读取本地真实主账本
const mainPath = FileSystem.documentDirectory + 'main.bean'; const mainPath = FileSystem.documentDirectory + 'main.bean';
const mobilePath = FileSystem.documentDirectory + 'mobile.bean';
try {
// 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它
const mobileInfo = await FileSystem.getInfoAsync(mobilePath);
if (mobileInfo.exists) {
const mobileContent = await FileSystem.readAsStringAsync(mobilePath);
if (mobileContent.trim()) {
const mainInfo = await FileSystem.getInfoAsync(mainPath);
let mainContent = '';
if (mainInfo.exists) {
mainContent = await FileSystem.readAsStringAsync(mainPath);
}
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim();
await FileSystem.writeAsStringAsync(mainPath, mergedContent);
logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean');
}
await FileSystem.deleteAsync(mobilePath, { idempotent: true });
}
} catch (e) {
logger.error('layout', '合并旧 mobile.bean 数据失败', e);
}
// 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => { FileSystem.getInfoAsync(mainPath).then(async (info) => {
let content = SAMPLE_LEDGER.content; let content = SAMPLE_LEDGER.content;
if (info.exists) { if (info.exists) {
content = await FileSystem.readAsStringAsync(mainPath); content = await FileSystem.readAsStringAsync(mainPath);
lastMainFileRef.current = { modificationTime: info.modificationTime, size: info.size };
} }
const rules = useMetadataStore.getState().rules; const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()).then(() => { loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()).then(() => {
setContext({ rules, categories, accountMap: DEMO_ACCOUNT_MAP }); setContext({ rules, categories });
const completed = useSettingsStore.getState().onboardingCompleted; const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled; const locked = useSettingsStore.getState().appLockEnabled;
@ -109,31 +195,152 @@ function AppShell() {
const subscriptions = [ const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => { DeviceEventEmitter.addListener('billingNotification', (event) => {
// 原生事件格式:{ packageName, title, text, timestamp } try {
// parseNotification 复用 OCR 规则匹配提取账单信息 logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`);
const { parseNotification } = require('../services/notification');
const bill = parseNotification(event); const bill = parseNotification(event);
if (bill) { if (bill) {
useAutomationStore.getState().addDetected('notification', bill); logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`);
}
} catch (e) {
logger.error('layout', '通知解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingSms', (event) => { DeviceEventEmitter.addListener('billingSms', (event) => {
// 原生事件格式:{ sender, body, timestamp } try {
// parseSms 提取金额/卡号/方向/商户 logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`);
const { parseSms } = require('../services/sms'); const smsEvent = {
const bill = parseSms(event); sender: event.sender || event.address || '',
body: event.body,
timestamp: event.timestamp || Date.now(),
};
const bill = parseSms(smsEvent);
if (bill) { if (bill) {
useAutomationStore.getState().addDetected('sms', bill); logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`);
handleIncomingBillEvent('sms', bill, event.body);
} else {
logger.debug('layout', `短信未匹配为账单: ${event.body}`);
}
} catch (e) {
logger.error('layout', '短信解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingScreenshot', (event) => { DeviceEventEmitter.addListener('billingScreenshot', (event) => {
// 截图事件格式:{ base64, packageName, timestamp, ... } logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
// 经 OcrProcessor 三层降级规则→OCR→AI Vision识别为账单事件
const { processScreenshotEvent } = require('../services/automationPipeline');
processScreenshotEvent(event).catch((e: unknown) => { processScreenshotEvent(event).catch((e: unknown) => {
console.warn('[layout] 截图 OCR 处理失败:', e); logger.error('layout', '截图 OCR 处理失败', e);
}); });
}), }),
DeviceEventEmitter.addListener('billingOpenApp', (res) => {
try {
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
if (res.draftId) {
try {
const { pendingDrafts } = require('../services/automationPipeline');
pendingDrafts.delete(res.draftId);
} catch (err) {
// 忽略
}
}
const amt = res.amount || '0';
const currency = 'CNY';
let postings: any[] = [];
if (res.direction === 'expense') {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} else if (res.direction === 'income') {
postings = [
{ account: res.account, amount: amt, currency },
{ account: res.category, amount: `-${amt}`, currency }
];
} else {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
}
const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined,
narration: res.narration || '',
postings
};
router.push({
pathname: '/transaction/new',
params: { draftJson: JSON.stringify(draft) }
});
} catch (e) {
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
}
}),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
}),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event;
const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`);
try {
const bridge = getAccessibilityBridge();
if (!isManual) {
if (bridge) {
const signatures = await bridge.getPageSignatures();
const isWhitelisted = signatures.some(s => s.signature === sigKey);
if (!isWhitelisted) {
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
return;
}
}
// 防抖3秒内避免对同一页面重复触发记账/截图
if (sigKey === lastSignature && Date.now() - lastProcessedTime < 3000) {
return;
}
lastSignature = sigKey;
lastProcessedTime = Date.now();
}
if (pkg === 'com.tencent.mm') {
// 微信:识别是否是详情页
const isDetail = texts.includes('交易单号') || texts.includes('退款单号') || texts.includes('本服务由财付通提供');
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别');
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
}
} else if (pkg === 'com.eg.android.AlipayGphone') {
// 支付宝:识别是否是详情页
const isDetail = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别');
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
}
} else {
// 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别
logger.info('layout', `[无障碍] 检测到白名单应用 ${pkg},直接触发 OCR 识别`);
bridge?.triggerManualOcr();
}
} catch (e) {
logger.error('layout', '无障碍调试及直接解析执行失败', e);
}
}),
]; ];
return () => subscriptions.forEach(s => s.remove()); return () => subscriptions.forEach(s => s.remove());
@ -144,12 +351,15 @@ function AppShell() {
const sub = AppState.addEventListener('change', (state) => { const sub = AppState.addEventListener('change', (state) => {
// 切到后台/非活跃时立即遮盖,回到前台时移除 // 切到后台/非活跃时立即遮盖,回到前台时移除
setPrivacyOverlay(state !== 'active'); setPrivacyOverlay(state !== 'active');
if (state === 'active' && phase === 'ready') {
checkAndReloadLedger();
}
}); });
return () => sub.remove(); return () => sub.remove();
}, []); }, [phase, checkAndReloadLedger]);
// loading 状态 // loading 状态
if (phase === 'loading') { if (phase === 'loading' || (!fontsLoaded && !fontError)) {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text> <Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
@ -176,7 +386,7 @@ function AppShell() {
// ready主应用 // ready主应用
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}> <View style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<StatusBar style={isDark ? 'light' : 'dark'} /> <StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
@ -185,7 +395,6 @@ function AppShell() {
<Stack.Screen name="category/index" options={{ headerShown: false }} /> <Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} /> <Stack.Screen name="tag/index" options={{ headerShown: false }} />
<Stack.Screen name="budget/index" options={{ headerShown: false }} /> <Stack.Screen name="budget/index" options={{ headerShown: false }} />
<Stack.Screen name="calendar/index" options={{ headerShown: false }} />
<Stack.Screen name="account/index" options={{ headerShown: false }} /> <Stack.Screen name="account/index" options={{ headerShown: false }} />
<Stack.Screen name="import/index" options={{ headerShown: false }} /> <Stack.Screen name="import/index" options={{ headerShown: false }} />
<Stack.Screen name="rules/index" options={{ headerShown: false }} /> <Stack.Screen name="rules/index" options={{ headerShown: false }} />
@ -202,7 +411,7 @@ function AppShell() {
{privacyOverlay && ( {privacyOverlay && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} /> <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} />
)} )}
</SafeAreaView> </View>
); );
} }

View File

@ -92,6 +92,13 @@ export default function AccountScreen() {
}) })
.join(':'); .join(':');
const rootType = sanitized.split(':')[0];
const validRoots = ['Assets', 'Liabilities', 'Income', 'Expenses', 'Equity'];
if (!validRoots.includes(rootType)) {
Alert.alert(t('account.addFail'), t('account.invalidRootAccount'));
return;
}
try { try {
await autoOpenAccounts([sanitized]); await autoOpenAccounts([sanitized]);
setIsAdding(false); setIsAdding(false);
@ -220,7 +227,7 @@ export default function AccountScreen() {
<Text <Text
style={[ style={[
theme.typography.body, theme.typography.body,
{ color: balance.startsWith('-') ? '#ff4d4f' : '#2f54eb', fontWeight: '700' }, { color: balance.startsWith('-') ? theme.colors.error : theme.colors.info, fontWeight: '700' },
]} ]}
> >
{balance} {currency} {balance} {currency}
@ -241,8 +248,8 @@ export default function AccountScreen() {
onPress={() => handleCloseAccount(account)} onPress={() => handleCloseAccount(account)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]} style={[styles.closeBtn, { borderColor: theme.colors.border }]}
> >
<Ionicons name="close-circle-outline" size={14} color="#ff4d4f" /> <Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: '#ff4d4f', marginLeft: 4 }]}> <Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('account.closeAccount')} {t('account.closeAccount')}
</Text> </Text>
</Pressable> </Pressable>

View File

@ -125,9 +125,12 @@ export default function AIChatScreen() {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration} {card.counterparty} · {card.narration}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginTop: 4 }]}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
{t('ai.tapToRecord')} <Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('ai.tapToRecord')}
</Text> </Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View>
</Pressable> </Pressable>
))} ))}
</View> </View>

View File

@ -16,7 +16,7 @@
* - App * - App
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -25,7 +25,6 @@ import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore'; import { useAutomationStore } from '../../store/automationStore';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore } from '../../store/metadataStore';
import { useImportStore } from '../../store/importStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import type { AutomationSource } from '../../store/automationStore'; import type { AutomationSource } from '../../store/automationStore';
@ -89,6 +88,16 @@ export default function AutomationScreen() {
useEffect(() => { useEffect(() => {
refreshAccessibilityState(); refreshAccessibilityState();
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
refreshAccessibilityState();
}
});
return () => {
subscription.remove();
};
}, [refreshAccessibilityState]); }, [refreshAccessibilityState]);
const handleProcess = async () => { const handleProcess = async () => {
@ -96,9 +105,8 @@ export default function AutomationScreen() {
const rules = useMetadataStore.getState().rules; const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
const history = ledger.transactions; const history = ledger.transactions;
const accountMap = useImportStore.getState().accountMap;
try { try {
await processAll(ledger, rules, categories, history, accountMap); await processAll(ledger, rules, categories, history);
} catch (e) { } catch (e) {
Alert.alert(String(e)); Alert.alert(String(e));
} }
@ -136,8 +144,8 @@ export default function AutomationScreen() {
const handleOpenAccessibilitySettings = () => { const handleOpenAccessibilitySettings = () => {
if (Platform.OS !== 'android') return; if (Platform.OS !== 'android') return;
// 打开系统无障碍设置页面 // 打开系统无障碍设置页面 (Intent Action)
Linking.openURL('android.settings.ACCESSIBILITY_SETTINGS').catch(() => { Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS').catch(() => {
Alert.alert(t('automation.openSettingsFail')); Alert.alert(t('automation.openSettingsFail'));
}); });
}; };
@ -148,6 +156,15 @@ export default function AutomationScreen() {
Alert.alert(t('automation.bridgeUnavailable')); Alert.alert(t('automation.bridgeUnavailable'));
return; return;
} }
Alert.alert(
'准备记录目标页面',
'点击“开始”后,请在 8 秒内切换到支付宝/微信的账单详情页,系统将在倒计时结束后自动记录该页面的签名。',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: '开始 (8s 倒计时)',
onPress: () => {
setTimeout(async () => {
try { try {
const result = await bridge.rememberCurrentPage(); const result = await bridge.rememberCurrentPage();
Alert.alert( Alert.alert(
@ -158,6 +175,11 @@ export default function AutomationScreen() {
} catch (e) { } catch (e) {
Alert.alert(t('automation.rememberPageFail'), String((e as Error)?.message ?? e)); Alert.alert(t('automation.rememberPageFail'), String((e as Error)?.message ?? e));
} }
}, 8000);
},
},
],
);
}; };
const handleManualOcr = async () => { const handleManualOcr = async () => {
@ -166,12 +188,25 @@ export default function AutomationScreen() {
Alert.alert(t('automation.bridgeUnavailable')); Alert.alert(t('automation.bridgeUnavailable'));
return; return;
} }
Alert.alert(
'准备手动识别',
'点击“开始”后,请在 8 秒内切换到你想识别的账单详情页,系统将在 8 秒后自动截图并识别该页面。',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: '开始 (8s 倒计时)',
onPress: () => {
setTimeout(async () => {
try { try {
await bridge.triggerManualOcr(); await bridge.triggerManualOcr();
Alert.alert(t('automation.manualOcrTriggered'));
} catch (e) { } catch (e) {
Alert.alert(t('automation.manualOcrFail'), String((e as Error)?.message ?? e)); Alert.alert(t('automation.manualOcrFail'), String((e as Error)?.message ?? e));
} }
}, 8000);
},
},
],
);
}; };
const handleRemoveSignature = async (sig: string) => { const handleRemoveSignature = async (sig: string) => {
@ -222,7 +257,7 @@ export default function AutomationScreen() {
<FlatList <FlatList
data={drafts} data={drafts}
keyExtractor={(item, index) => `${item.draft.date}-${index}`} keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}> <Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
@ -257,7 +292,7 @@ export default function AutomationScreen() {
<Card title={t('automation.accessibilityTitle')}> <Card title={t('automation.accessibilityTitle')}>
{/* 服务状态 */} {/* 服务状态 */}
<View style={[styles.statusRow, { marginBottom: 8 }]}> <View style={[styles.statusRow, { marginBottom: 8 }]}>
<View style={[styles.statusDot, { backgroundColor: serviceRunning ? '#4CAF50' : '#9E9E9E' }]} /> <View style={[styles.statusDot, { backgroundColor: serviceRunning ? theme.colors.success : theme.colors.fgSecondary }]} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{serviceRunning ? t('automation.serviceRunning') : t('automation.serviceStopped')} {serviceRunning ? t('automation.serviceRunning') : t('automation.serviceStopped')}
</Text> </Text>

View File

@ -50,6 +50,12 @@ export default function BudgetScreen() {
const categoryAccount = values.categoryAccount?.trim() || undefined; const categoryAccount = values.categoryAccount?.trim() || undefined;
const startDate = values.startDate?.trim() || today; const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return;
}
if (modal?.type === 'add') { if (modal?.type === 'add') {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate }); addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
} else if (modal?.type === 'edit') { } else if (modal?.type === 'edit') {
@ -61,10 +67,10 @@ export default function BudgetScreen() {
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly'); const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
const editFields: FormField[] = modal?.type === 'edit' ? [ const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('budget.fieldName'), placeholder: '餐饮月预算', defaultValue: modal.budget.name }, { key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: '2000', defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' }, { key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period }, { key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: modal.budget.categoryAccount ?? '' }, { key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: modal.budget.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate }, { key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate },
] : []; ] : [];
@ -131,10 +137,10 @@ export default function BudgetScreen() {
visible={modal !== null} visible={modal !== null}
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')} title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
fields={modal?.type === 'edit' ? editFields : [ fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('budget.fieldName'), placeholder: '餐饮月预算' }, { key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: '2000', keyboardType: 'decimal-pad' }, { key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' }, { key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: 'Expenses:餐饮' }, { key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' }, { key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
]} ]}
onConfirm={handleConfirm} onConfirm={handleConfirm}

View File

@ -1,57 +0,0 @@
/**
* plan.md1.1 calendar/index
*/
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard';
import { useT } from '../../i18n';
import { useLedgerStore } from '../../store/ledgerStore';
import type { Transaction } from '../../domain/types';
export default function CalendarScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const transactions = ledger?.transactions ?? [];
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const today = new Date();
const dayTx = selectedDate ? transactions.filter(t => t.date.slice(0, 10) === selectedDate) : [];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('calendar.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<CalendarView
transactions={transactions}
year={today.getFullYear()}
month={today.getMonth() + 1}
onDayPress={setSelectedDate}
/>
{selectedDate && (
<View style={{ marginTop: 16 }}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 8 }]}>{selectedDate}</Text>
{dayTx.map(t => <TransactionCard key={t.id} transaction={t} onPress={(tx) => router.push(`/transaction/${tx.id}`)} />)}
{dayTx.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('calendar.noDayTx')}</Text>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
});

View File

@ -80,9 +80,9 @@ export default function CategoryScreen() {
); );
const editFields: FormField[] = modal?.type === 'edit' ? [ const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('category.fieldName'), placeholder: '餐饮', defaultValue: modal.category.name }, { key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: 'Expenses:餐饮', defaultValue: modal.category.linkedAccount }, { key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: '餐, 饭, 咖啡', defaultValue: modal.category.keywords.join(', ') }, { key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
] : []; ] : [];
return ( return (
@ -115,9 +115,9 @@ export default function CategoryScreen() {
visible={modal !== null} visible={modal !== null}
title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })} title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })}
fields={modal?.type === 'edit' ? editFields : [ fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('category.fieldName'), placeholder: '餐饮' }, { key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: 'Expenses:餐饮' }, { key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: '餐, 饭, 咖啡' }, { key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
]} ]}
onConfirm={handleConfirm} onConfirm={handleConfirm}
onCancel={() => setModal(null)} onCancel={() => setModal(null)}

View File

@ -136,7 +136,6 @@ export default function ImportScreen() {
importCsv(content, adapter); importCsv(content, adapter);
setManualDuplicates([]); setManualDuplicates([]);
setStatus(t('importFlow.importSuccess', { setStatus(t('importFlow.importSuccess', {
channel: isWeChat ? t('importFlow.channelWechat') : t('importFlow.channelAlipay'),
format: isExcel ? t('importFlow.formatExcel') : t('importFlow.formatCsv'), format: isExcel ? t('importFlow.formatExcel') : t('importFlow.formatCsv'),
name: file.name, name: file.name,
})); }));
@ -361,7 +360,7 @@ export default function ImportScreen() {
{item.memo || t('importFlow.noDesc')} {item.memo || t('importFlow.noDesc')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency, channel: item.channel })} {t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text> </Text>
<DedupBanner <DedupBanner
result={result} result={result}

View File

@ -134,7 +134,7 @@ export default function RecurringScreen() {
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: '#ff4d4f', fontWeight: '700' }]}>{amount} CNY</Text> <Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View> </View>
<View style={styles.cardActions}> <View style={styles.cardActions}>
@ -151,8 +151,8 @@ export default function RecurringScreen() {
onPress={() => handleDelete(item)} onPress={() => handleDelete(item)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]} style={[styles.closeBtn, { borderColor: theme.colors.border }]}
> >
<Ionicons name="trash-outline" size={14} color="#ff4d4f" /> <Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: '#ff4d4f', marginLeft: 4 }]}> <Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')} {t('recurring.delete')}
</Text> </Text>
</Pressable> </Pressable>

View File

@ -43,10 +43,9 @@ export default function RulesScreen() {
const handleConfirm = (values: Record<string, string>) => { const handleConfirm = (values: Record<string, string>) => {
const rule: Omit<Rule, 'id' | 'hits'> = { const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0, priority: parseInt(values.priority, 10) || 0,
channel: values.channel?.trim() || undefined,
counterpartyContains: values.counterpartyContains?.trim() || undefined, counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined, memoContains: values.memoContains?.trim() || undefined,
channelAccount: values.channelAccount?.trim() || 'Assets:Unknown', sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized', categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined, narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [], tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
@ -61,10 +60,9 @@ export default function RulesScreen() {
const fieldsFor = (rule?: Rule): FormField[] => [ const fieldsFor = (rule?: Rule): FormField[] => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' }, { key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
{ key: 'channel', label: t('rules.fieldChannel'), placeholder: 'Alipay', defaultValue: rule?.channel ?? '' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' }, { key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' }, { key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
{ key: 'channelAccount', label: t('rules.fieldChannelAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.channelAccount ?? '' }, { key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' }, { key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' }, { key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' }, { key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
@ -72,7 +70,6 @@ export default function RulesScreen() {
const formatCondition = (rule: Rule): string => { const formatCondition = (rule: Rule): string => {
const parts: string[] = []; const parts: string[] = [];
if (rule.channel) parts.push(t('rules.condChannel', { val: rule.channel }));
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains })); if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
if (rule.memoContains) parts.push(t('rules.condMemo', { val: rule.memoContains })); if (rule.memoContains) parts.push(t('rules.condMemo', { val: rule.memoContains }));
return parts.length > 0 ? parts.join(' · ') : t('rules.condNone'); return parts.length > 0 ? parts.join(' · ') : t('rules.condNone');
@ -110,10 +107,13 @@ export default function RulesScreen() {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)} {formatCondition(rule)}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })}) <Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text> </Text>
</View> </View>
</View>
</Pressable> </Pressable>
))} ))}
</Card> </Card>

View File

@ -30,7 +30,7 @@ export default function SyncSettingsScreen() {
const webdavUrl = useSettingsStore(s => s.webdavUrl) || ''; const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
const webdavUsername = useSettingsStore(s => s.webdavUsername) || ''; const webdavUsername = useSettingsStore(s => s.webdavUsername) || '';
const webdavPassword = useSettingsStore(s => s.webdavPassword) || ''; const webdavPassword = useSettingsStore(s => s.webdavPassword) || '';
const webdavRemotePath = useSettingsStore(s => s.webdavRemotePath) || 'mobile.bean'; const webdavRemotePath = useSettingsStore(s => s.webdavRemotePath) || 'main.bean';
const gitRemoteUrl = useSettingsStore(s => s.gitRemoteUrl) || ''; const gitRemoteUrl = useSettingsStore(s => s.gitRemoteUrl) || '';
const gitBranch = useSettingsStore(s => s.gitBranch) || 'main'; const gitBranch = useSettingsStore(s => s.gitBranch) || 'main';
const gitUsername = useSettingsStore(s => s.gitUsername) || ''; const gitUsername = useSettingsStore(s => s.gitUsername) || '';
@ -51,7 +51,7 @@ export default function SyncSettingsScreen() {
{ key: 'url', label: t('sync.webdavUrl'), placeholder: 'https://dav.example.com/beancount/', defaultValue: webdavUrl }, { key: 'url', label: t('sync.webdavUrl'), placeholder: 'https://dav.example.com/beancount/', defaultValue: webdavUrl },
{ key: 'username', label: t('sync.username'), placeholder: 'username', defaultValue: webdavUsername }, { key: 'username', label: t('sync.username'), placeholder: 'username', defaultValue: webdavUsername },
{ key: 'password', label: t('sync.password'), placeholder: 'password', defaultValue: webdavPassword }, { key: 'password', label: t('sync.password'), placeholder: 'password', defaultValue: webdavPassword },
{ key: 'remotePath', label: t('sync.remotePath'), placeholder: 'mobile.bean', defaultValue: webdavRemotePath }, { key: 'remotePath', label: t('sync.remotePath'), placeholder: 'main.bean', defaultValue: webdavRemotePath },
]; ];
// Git credentials form fields // Git credentials form fields
@ -145,7 +145,7 @@ export default function SyncSettingsScreen() {
try { try {
const fs = new MockICloudFileSystem(); const fs = new MockICloudFileSystem();
fs.available = true; fs.available = true;
fs.setRemoteFile('mobile.bean', `${t('sync.iCloudDemoContent')}\n`, new Date().toISOString()); fs.setRemoteFile('main.bean', `${t('sync.iCloudDemoContent')}\n`, new Date().toISOString());
const result = await syncWithICloud(fs, mobileBean, new Date().toISOString()); const result = await syncWithICloud(fs, mobileBean, new Date().toISOString());
if (result.action === 'pulled' || result.action === 'conflict') { if (result.action === 'pulled' || result.action === 'conflict') {
await replaceMobileBean(result.content); await replaceMobileBean(result.content);
@ -164,9 +164,49 @@ export default function SyncSettingsScreen() {
const handleBackup = async () => { const handleBackup = async () => {
try { try {
const files = ledger?.files ?? []; const files = ledger?.files ?? [];
const bundle = createBackupBundle(files, mobileBean); // 收集设置(剔除敏感字段和函数)
const settingsState = useSettingsStore.getState();
const settings: Record<string, unknown> = {};
for (const [k, v] of Object.entries(settingsState)) {
if (typeof v !== 'function' && !['webdavPassword', 'gitPassword', 'appLockPin'].includes(k)) {
settings[k] = v;
}
}
// 收集元数据(分类/规则/预算/标签/信用卡/周期记账)
const metaState = useMetadataStore.getState();
const metadata: Record<string, unknown> = {
categories: metaState.categories,
tags: metaState.tags,
budgets: metaState.budgets,
creditCards: metaState.creditCards,
rules: metaState.rules,
recurringTransactions: metaState.recurringTransactions,
};
const bundle = createBackupBundle(files, mobileBean, settings, metadata);
const json = serializeBundle(bundle); const json = serializeBundle(bundle);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
// Android 特殊处理:使用 SAF (存储访问框架) 直接让用户选择文件夹并保存
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`backup-${timestamp}`,
'application/json',
);
await FileSystem.writeAsStringAsync(fileUri, json);
Alert.alert(t('sync.backupSuccess'), t('sync.backupSuccessFolder'));
return;
}
} catch (safError) {
// 如果 SAF 失败,降级到系统分享
console.warn('SAF backup failed, falling back to sharing:', safError);
}
}
const backupPath = FileSystem.documentDirectory + `backup-${timestamp}.json`; const backupPath = FileSystem.documentDirectory + `backup-${timestamp}.json`;
await FileSystem.writeAsStringAsync(backupPath, json); await FileSystem.writeAsStringAsync(backupPath, json);
@ -195,10 +235,33 @@ export default function SyncSettingsScreen() {
const content = await FileSystem.readAsStringAsync(file.uri); const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content); const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle); const restoredFiles = restoreFiles(bundle);
// 用恢复的 mobileBean 替换当前内容 // 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean
const restoredMobileBean = restoredFiles.find(f => f.path === 'mobile.bean')?.content ?? ''; const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || '';
await replaceMobileBean(restoredMobileBean); await replaceMobileBean(restoredMobileBean);
Alert.alert(t('sync.restoreSuccess'), t('sync.restoreDesc', { name: file.name }));
// 恢复设置v2
if (bundle.settings) {
useSettingsStore.getState().hydrate(bundle.settings as any);
// 同步持久化到文件
const settingsPath = FileSystem.documentDirectory + 'settings.json';
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
}
// 恢复元数据v2
if (bundle.metadata) {
useMetadataStore.getState().hydrate(bundle.metadata as any);
// 同步持久化到文件
const metadataPath = FileSystem.documentDirectory + 'metadata.json';
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
}
const restoredParts = [t('sync.restorePartsTx')];
if (bundle.settings) restoredParts.push(t('sync.restorePartsSettings'));
if (bundle.metadata) restoredParts.push(t('sync.restorePartsMeta'));
Alert.alert(
t('sync.restoreSuccess'),
`${t('sync.restoreDesc', { name: file.name })}${restoredParts.join('、')}`,
);
} catch (e) { } catch (e) {
Alert.alert(t('sync.restoreFail'), e instanceof Error ? e.message : String(e)); Alert.alert(t('sync.restoreFail'), e instanceof Error ? e.message : String(e));
} }
@ -282,6 +345,7 @@ export default function SyncSettingsScreen() {
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const exportPath = FileSystem.documentDirectory + `transactions-${timestamp}.xlsx`; const exportPath = FileSystem.documentDirectory + `transactions-${timestamp}.xlsx`;
let base64Content = '';
// 用 xlsx 库创建 workbook wrapper // 用 xlsx 库创建 workbook wrapper
const wbWrapper = { const wbWrapper = {
addSheet: (name: string, rows: any[]) => { addSheet: (name: string, rows: any[]) => {
@ -289,11 +353,31 @@ export default function SyncSettingsScreen() {
XLSX.utils.book_append_sheet(workbook, ws, name); XLSX.utils.book_append_sheet(workbook, ws, name);
}, },
write: (path: string) => { write: (path: string) => {
const base64 = XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' }); base64Content = XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
return FileSystem.writeAsStringAsync(path, base64, { encoding: FileSystem.EncodingType.Base64 }); return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
}, },
}; };
await exportToExcel(transactions, wbWrapper as any, exportPath); await exportToExcel(transactions, wbWrapper as any, exportPath);
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`transactions-${timestamp}`,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
await FileSystem.writeAsStringAsync(fileUri, base64Content, { encoding: FileSystem.EncodingType.Base64 });
Alert.alert(t('sync.exportSuccess'), 'Excel 账单已成功保存到所选文件夹!');
return;
}
} catch (safError) {
console.warn('SAF Excel export failed, falling back to sharing:', safError);
}
}
if (await Sharing.isAvailableAsync()) { if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); await Sharing.shareAsync(exportPath, { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
} }
@ -313,10 +397,30 @@ export default function SyncSettingsScreen() {
const exportPath = FileSystem.documentDirectory + `rules-${timestamp}.json`; const exportPath = FileSystem.documentDirectory + `rules-${timestamp}.json`;
const fs = new MockRuleFileSync(); const fs = new MockRuleFileSync();
// MockRuleFileSync 是内存的,需要真实写入 // MockRuleFileSync 是内存的,需要真实写入
const result = await exportRules(rules, categories, fs, exportPath); await exportRules(rules, categories, fs, exportPath);
// 获取 Mock 写入的内容,用真实 FileSystem 写 // 获取 Mock 写入的内容,用真实 FileSystem 写
const content = await fs.read(exportPath); const content = await fs.read(exportPath);
await FileSystem.writeAsStringAsync(exportPath, content); await FileSystem.writeAsStringAsync(exportPath, content);
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`rules-${timestamp}`,
'application/json',
);
await FileSystem.writeAsStringAsync(fileUri, content);
Alert.alert(t('sync.exportSuccess'), '匹配规则已成功保存到所选文件夹!');
return;
}
} catch (safError) {
console.warn('SAF Rules export failed, falling back to sharing:', safError);
}
}
if (await Sharing.isAvailableAsync()) { if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/json' }); await Sharing.shareAsync(exportPath, { mimeType: 'application/json' });
} }
@ -365,9 +469,13 @@ export default function SyncSettingsScreen() {
{/* 云同步配置与云端操作 */} {/* 云同步配置与云端操作 */}
<Card title={t('sync.cloudTitle')}> <Card title={t('sync.cloudTitle')}>
<View style={styles.syncBtnRow}> <View style={styles.syncBtnRow}>
<View style={{ flex: 1 }}>
<Button label={t('sync.configWebdav')} onPress={() => setWebdavModalVisible(true)} variant="secondary" /> <Button label={t('sync.configWebdav')} onPress={() => setWebdavModalVisible(true)} variant="secondary" />
</View>
<View style={{ flex: 1 }}>
<Button label={t('sync.configGit')} onPress={() => setGitModalVisible(true)} variant="secondary" /> <Button label={t('sync.configGit')} onPress={() => setGitModalVisible(true)} variant="secondary" />
</View> </View>
</View>
<View style={{ marginTop: 8, gap: 8 }}> <View style={{ marginTop: 8, gap: 8 }}>
<Button label={t('sync.webdavSyncNow')} onPress={handleWebDAVSync} /> <Button label={t('sync.webdavSyncNow')} onPress={handleWebDAVSync} />
<Button label={t('sync.gitSyncNow')} onPress={handleGitSync} variant="secondary" /> <Button label={t('sync.gitSyncNow')} onPress={handleGitSync} variant="secondary" />
@ -385,9 +493,13 @@ export default function SyncSettingsScreen() {
{/* 本地备份与维护 */} {/* 本地备份与维护 */}
<Card title={t('sync.backupTitle')}> <Card title={t('sync.backupTitle')}>
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
<View style={{ flex: 1 }}>
<Button label={t('sync.backupNow')} onPress={handleBackup} /> <Button label={t('sync.backupNow')} onPress={handleBackup} />
</View>
<View style={{ flex: 1 }}>
<Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" /> <Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" />
</View> </View>
</View>
<View style={{ gap: 8, marginTop: 8 }}> <View style={{ gap: 8, marginTop: 8 }}>
<Button label={t('sync.loadLocalLedger')} onPress={handleLoadLocalLedger} /> <Button label={t('sync.loadLocalLedger')} onPress={handleLoadLocalLedger} />
<Button label={t('sync.maintenance')} onPress={handleMaintenance} variant="secondary" /> <Button label={t('sync.maintenance')} onPress={handleMaintenance} variant="secondary" />

View File

@ -85,7 +85,7 @@ export default function TagScreen() {
onLongPress={() => handleDelete(tag)} onLongPress={() => handleDelete(tag)}
style={[styles.chip, { backgroundColor: tag.color }]} style={[styles.chip, { backgroundColor: tag.color }]}
> >
<Text style={{ color: '#fff', fontWeight: '700' }}>#{tag.name}</Text> <Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable> </Pressable>
))} ))}
{tags.length === 0 && ( {tags.length === 0 && (

View File

@ -12,14 +12,16 @@ import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput,
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { hash } from '../../domain/ledger';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
export default function TransactionDetailScreen() { export default function TransactionDetailScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@ -33,7 +35,7 @@ export default function TransactionDetailScreen() {
const [linkModalVisible, setLinkModalVisible] = useState(false); const [linkModalVisible, setLinkModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const isEditable = tx ? tx.source.endsWith('mobile.bean') : false; const isEditable = tx ? tx.source.endsWith('main.bean') : false;
// 找出所有共享同一个链接标签的关联交易 // 找出所有共享同一个链接标签的关联交易
const relatedTransactions = useMemo(() => { const relatedTransactions = useMemo(() => {
@ -47,7 +49,7 @@ export default function TransactionDetailScreen() {
const linkableTransactions = useMemo(() => { const linkableTransactions = useMemo(() => {
if (!ledger) return []; if (!ledger) return [];
return ledger.transactions return ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('mobile.bean') && !t.links.some(l => tx?.links.includes(l))) .filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => { .filter(t => {
if (!searchQuery) return true; if (!searchQuery) return true;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
@ -57,6 +59,7 @@ export default function TransactionDetailScreen() {
t.postings.some(p => p.account.toLowerCase().includes(q)) t.postings.some(p => p.account.toLowerCase().includes(q))
); );
}) })
.reverse() // 解决截断 Bug将顺序反转优先展示最新的交易
.slice(0, 30); .slice(0, 30);
}, [ledger, id, tx?.links, searchQuery]); }, [ledger, id, tx?.links, searchQuery]);
@ -73,8 +76,19 @@ export default function TransactionDetailScreen() {
const addLinkToRaw = (raw: string, tag: string) => { const addLinkToRaw = (raw: string, tag: string) => {
const lines = raw.split('\n'); const lines = raw.split('\n');
if (!lines[0].includes(`^${tag}`)) { const line = lines[0];
lines[0] = lines[0].trimEnd() + ` ^${tag}`; const commentIndex = line.indexOf(';');
if (line.includes(`^${tag}`)) {
return raw;
}
if (commentIndex !== -1) {
const beforeComment = line.substring(0, commentIndex).trimEnd();
const commentPart = line.substring(commentIndex);
lines[0] = `${beforeComment} ^${tag} ${commentPart}`;
} else {
lines[0] = line.trimEnd() + ` ^${tag}`;
} }
return lines.join('\n'); return lines.join('\n');
}; };
@ -88,8 +102,9 @@ export default function TransactionDetailScreen() {
await replaceMobileBean(newContent); await replaceMobileBean(newContent);
setLinkModalVisible(false); setLinkModalVisible(false);
// raw 变了 → id 变了,更新当前页 URL 避免找不到交易
const newId = ledger?.transactions.find((txn: any) => txn.raw === newCurrentRaw)?.id; // 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) { if (newId && newId !== id) {
router.replace(`/transaction/${newId}`); router.replace(`/transaction/${newId}`);
} }
@ -103,14 +118,30 @@ export default function TransactionDetailScreen() {
if (!tx) return; if (!tx) return;
try { try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState(); const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
const lines = tx.raw.split('\n');
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
const companionTxs = (ledger?.transactions ?? []).filter(
t => t.links.includes(linkTag) && t.source.endsWith('main.bean')
);
let newContent = mobileBean;
let newCurrentRaw = tx.raw;
for (const companion of companionTxs) {
const lines = companion.raw.split('\n');
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), ''); lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n'); const newRaw = lines.join('\n');
const newContent = mobileBean.replace(tx.raw, newRaw); newContent = newContent.replace(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
await replaceMobileBean(newContent); await replaceMobileBean(newContent);
// raw 变了 → id 变了,更新当前页 URL
const newId = ledger?.transactions.find((txn: any) => txn.raw === newRaw)?.id; // 解决 race condition直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) { if (newId && newId !== id) {
router.replace(`/transaction/${newId}`); router.replace(`/transaction/${newId}`);
} }
@ -164,7 +195,7 @@ export default function TransactionDetailScreen() {
</Text> </Text>
</View> </View>
<View style={[styles.directionBadge, { backgroundColor: directionColor }]}> <View style={[styles.directionBadge, { backgroundColor: directionColor }]}>
<Text style={{ color: '#fff', fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text> <Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text>
</View> </View>
</View> </View>
</Card> </Card>
@ -288,7 +319,7 @@ export default function TransactionDetailScreen() {
</Text> </Text>
</Card> </Card>
{/* 编辑/删除按钮(仅 mobile.bean 来源可操作) */} {/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
{isEditable && ( {isEditable && (
<View style={styles.editActions}> <View style={styles.editActions}>
<Pressable <Pressable
@ -348,7 +379,7 @@ export default function TransactionDetailScreen() {
<View style={styles.searchBarContainer}> <View style={styles.searchBarContainer}>
<TextInput <TextInput
style={[styles.searchInput, { borderColor: theme.colors.border, color: theme.colors.fgPrimary, backgroundColor: theme.colors.bgTertiary }]} style={[commonStyles.input, { height: 40 }]}
placeholder={t('transaction.searchTxPlaceholder')} placeholder={t('transaction.searchTxPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery} value={searchQuery}
@ -389,10 +420,10 @@ const styles = StyleSheet.create({
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 }, tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 }, tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderRadius: 6, paddingVertical: 8 }, linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 }, searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
searchInput: { height: 40, borderWidth: 1, borderRadius: 6, paddingHorizontal: 12, fontSize: 14 }, searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 }, metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' }, editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 8, paddingVertical: 10, paddingHorizontal: 20 }, editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
}); });

View File

@ -1,12 +1,13 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect, useRef } from 'react';
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert } from 'react-native'; import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
@ -15,20 +16,23 @@ import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor'; import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags'; import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger'; import { serializeTransaction } from '../../domain/ledger';
import { getNativeOcrModule } from '../../services/ocrBridge'; import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor'; import { OcrProcessor } from '../../domain/ocrProcessor';
import { getNativeOcrBridge } from '../../services/ocrBridge';
import type { Category } from '../../domain/categories'; import type { Category } from '../../domain/categories';
import type { Tag } from '../../domain/tags'; import type { Tag } from '../../domain/tags';
import type { Posting } from '../../domain/types'; import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer'; type Direction = 'expense' | 'income' | 'transfer';
export default function NewTransactionScreen() { export default function NewTransactionScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const { mode, editId } = useLocalSearchParams<{ mode?: string; editId?: string }>(); const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
const addTransaction = useLedgerStore(s => s.addTransaction); const addTransaction = useLedgerStore(s => s.addTransaction);
const editTransaction = useLedgerStore(s => s.editTransaction); const editTransaction = useLedgerStore(s => s.editTransaction);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
@ -52,11 +56,104 @@ export default function NewTransactionScreen() {
// 高级模式状态 // 高级模式状态
const [isAdvanced, setIsAdvanced] = useState(false); const [isAdvanced, setIsAdvanced] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [postings, setPostings] = useState<Posting[]>([ const [postings, setPostings] = useState<Posting[]>([
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' }, { account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' } { account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
]); ]);
const [isSubmitted, setIsSubmitted] = useState(false);
const navigation = useNavigation();
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const hasChanges =
amount.trim() !== '' ||
payee.trim() !== '' ||
narration.trim() !== '' ||
selectedTags.length > 0 ||
(isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
if (!hasChanges || isSubmitted) {
return;
}
e.preventDefault();
Alert.alert(
t('transaction.unsavedTitle'),
t('transaction.unsavedMessage'),
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.discard'),
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]);
// === 草稿预填模式 ===
useEffect(() => {
if (draftJson) {
try {
const draft = JSON.parse(draftJson);
setDate(draft.date ? draft.date.slice(0, 10) : today);
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses'));
const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income'));
const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'));
if (expensePosting) {
setDirection('expense');
setCurrency(expensePosting.currency || 'CNY');
setAmount((expensePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else if (incomePosting) {
setDirection('income');
setCurrency(incomePosting.currency || 'CNY');
setAmount((incomePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else {
setDirection('transfer');
if (draft.postings.length >= 2) {
setCurrency(draft.postings[0].currency || 'CNY');
setAmount((draft.postings[0].amount || '').replace(/^-/, ''));
setSourceAccount(draft.postings[0].account);
setTargetAccount(draft.postings[1].account);
}
}
if (draft.postings && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}
}, [draftJson, categories]);
// === 编辑模式:预填表单 === // === 编辑模式:预填表单 ===
const editingTx = useMemo( const editingTx = useMemo(
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined, () => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
@ -143,7 +240,7 @@ export default function NewTransactionScreen() {
// 1. 选图(相册或拍照) // 1. 选图(相册或拍照)
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, allowsEditing: false,
quality: 0.8, quality: 0.8,
base64: true, base64: true,
}); });
@ -185,6 +282,16 @@ export default function NewTransactionScreen() {
} else { } else {
setDirection('expense'); setDirection('expense');
} }
// 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致)
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const candidates = categories.filter(c => c.type === type);
const match = matchCategory(matchText, candidates);
if (match.category) {
setSelectedCategory(match.category);
}
}
setStatus(t('ocr.success')); setStatus(t('ocr.success'));
} else { } else {
setStatus(t('ocr.failed')); setStatus(t('ocr.failed'));
@ -243,11 +350,13 @@ export default function NewTransactionScreen() {
// 编辑模式:序列化新内容并替换旧块 // 编辑模式:序列化新内容并替换旧块
const newRaw = serializeTransaction(draft); const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => { editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess')); setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600); setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e))); }).catch(e => setStatus(String(e)));
} else { } else {
addTransaction(draft).then(() => { addTransaction(draft).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended')); setStatus(t('transaction.appended'));
setNarration(''); setNarration('');
setSelectedTags([]); setSelectedTags([]);
@ -256,30 +365,34 @@ export default function NewTransactionScreen() {
} }
} else { } else {
// 简单模式:根据方向生成正负 postings // 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
const amt = amount.trim(); const amt = amount.trim();
if (!amt) { if (!amt) {
setStatus(t('transaction.amountRequired')); setStatus(t('transaction.amountRequired'));
return; return;
} }
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized'; if (direction === 'transfer' && sourceAccount === targetAccount) {
let finalPostings: Posting[]; setStatus(t('transaction.sameAccountError'));
return;
}
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (editId && oldRaw) {
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
let finalPostings: Posting[];
if (direction === 'expense') { if (direction === 'expense') {
// 支出: 来源账户 扣减(负), 费用账户 增加(正)
finalPostings = [ finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency }, { account: sourceAccount, amount: `-${amt}`, currency },
{ account: categoryAccount, amount: amt, currency }, { account: categoryAccount, amount: amt, currency },
]; ];
} else if (direction === 'income') { } else if (direction === 'income') {
// 收入: 来源账户 增加(正), 收入账户 扣减(负)
finalPostings = [ finalPostings = [
{ account: sourceAccount, amount: amt, currency }, { account: sourceAccount, amount: amt, currency },
{ account: categoryAccount, amount: `-${amt}`, currency }, { account: categoryAccount, amount: `-${amt}`, currency },
]; ];
} else { } else {
// 转账: 来源账户 扣减(负), 目标账户 增加(正)
finalPostings = [ finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency }, { account: sourceAccount, amount: `-${amt}`, currency },
{ account: targetAccount, amount: amt, currency }, { account: targetAccount, amount: amt, currency },
@ -289,7 +402,7 @@ export default function NewTransactionScreen() {
const draft = { const draft = {
date, date,
payee: payee.trim() || undefined, payee: payee.trim() || undefined,
flag: editId ? editFlag : undefined, flag: editFlag,
narration: narration.trim() || ( narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') : direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') : direction === 'income' ? t('transaction.narrationDefaultIncome') :
@ -297,19 +410,36 @@ export default function NewTransactionScreen() {
), ),
postings: finalPostings, postings: finalPostings,
tags: tagNames, tags: tagNames,
links: editId && editLinks.length > 0 ? editLinks : undefined, links: editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined, metadata: editMetadata,
}; };
if (editId && oldRaw) {
// 编辑模式
const newRaw = serializeTransaction(draft); const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => { editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess')); setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600); setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e))); }).catch(e => setStatus(String(e)));
} else { } else {
addTransaction(draft).then(() => { // 新建交易:直接调用统一的共享逻辑函数
buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
}).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended')); setStatus(t('transaction.appended'));
setAmount(''); setAmount('');
setNarration(''); setNarration('');
@ -328,12 +458,11 @@ export default function NewTransactionScreen() {
]; ];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 高级模式模式切换开关 */} {/* 高级模式模式切换开关 */}
<View style={[styles.switchRow, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}> <View style={commonStyles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
{t('transaction.advancedMode')} {t('transaction.advancedMode')}
</Text> </Text>
<Switch <Switch
@ -371,90 +500,67 @@ export default function NewTransactionScreen() {
{!isAdvanced && ( {!isAdvanced && (
<View style={styles.directionRow}> <View style={styles.directionRow}>
{directions.map(d => ( {directions.map(d => (
<Button <Pressable
key={d.key} key={d.key}
label={d.label}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }} onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
variant={direction === d.key ? 'primary' : 'secondary'} style={[
/> commonStyles.chip,
direction === d.key && commonStyles.chipActive,
{ flex: 1, alignItems: 'center' }
]}
>
<Text style={[
commonStyles.chipText,
direction === d.key && commonStyles.chipTextActive,
{ fontFamily: theme.typography.caption.fontFamily }
]}>
{d.label}
</Text>
</Pressable>
))} ))}
<Button <Pressable
label={t('home.ocrScan')}
onPress={handleOcrScan} onPress={handleOcrScan}
variant="secondary" style={[
/> commonStyles.chip,
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
]}
>
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.ocrScan')}
</Text>
</Pressable>
</View> </View>
)} )}
<Card title={t('transaction.newTitle')}> {/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
/>
{/* 收款方(简单模式) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
/>
</>
)}
{/* 简单模式字段 */}
{!isAdvanced ? ( {!isAdvanced ? (
<View> <Card title={t('transaction.newTitle')}>
{/* 金额 */} {/* 金额 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.amountPlaceholder')} {t('transaction.amountPlaceholder')}
</Text> </Text>
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput <TextInput
value={amount} value={amount}
onChangeText={setAmount} onChangeText={setAmount}
autoFocus={editId ? false : true}
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholder={t('transaction.amountPlaceholder')} placeholder={t('transaction.amountPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]} style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
/> />
{/* 币种选择 */}
<TextInput <TextInput
value={currency} value={currency}
onChangeText={setCurrency} onChangeText={setCurrency}
placeholder="CNY" placeholder="CNY"
maxLength={3} maxLength={3}
autoCapitalize="characters" autoCapitalize="characters"
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, width: 70 }]} style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
/> />
</View> </View>
{/* 来源账户 */} {/* 来源账户 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formSourceAccount')} {t('transaction.formSourceAccount')}
</Text> </Text>
<TextInput <TextInput
@ -462,13 +568,40 @@ export default function NewTransactionScreen() {
onChangeText={setSourceAccount} onChangeText={setSourceAccount}
placeholder="Assets:支付宝余额" placeholder="Assets:支付宝余额"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={commonStyles.input}
/> />
{/* 快捷来源账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = sourceAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`src-${acct}`}
onPress={() => setSourceAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
{/* 目标账户 (仅在转账时显示) */} {/* 目标账户 (仅在转账时显示) */}
{direction === 'transfer' && ( {direction === 'transfer' && (
<View> <View style={{ marginTop: 10 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.targetAccount')} {t('transaction.targetAccount')}
</Text> </Text>
<TextInput <TextInput
@ -476,19 +609,40 @@ export default function NewTransactionScreen() {
onChangeText={setTargetAccount} onChangeText={setTargetAccount}
placeholder="Assets:WeChat" placeholder="Assets:WeChat"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={commonStyles.input}
/> />
{/* 快捷目标账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = targetAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`tgt-${acct}`}
onPress={() => setTargetAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
</View> </View>
)} )}
</Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('transaction.formAvailable')}: {assetAccounts.join(' / ')}
</Text>
</View>
) : ( ) : (
// 高级模式字段: 接入 PostingEditor 与 BalanceIndicator <Card title={t('transaction.newTitle')}>
<View style={{ marginTop: 12 }}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('transaction.postingsList')} {t('transaction.postingsList')}
</Text> </Text>
@ -498,11 +652,14 @@ export default function NewTransactionScreen() {
/> />
<View style={styles.postingBtnRow}> <View style={styles.postingBtnRow}>
<View style={{ flex: 1 }}>
<Button <Button
label={t('transaction.addPosting')} label={t('transaction.addPosting')}
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])} onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
variant="secondary" variant="secondary"
/> />
</View>
<View style={{ flex: 1 }}>
<Button <Button
label={t('transaction.deleteLastPosting')} label={t('transaction.deleteLastPosting')}
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))} onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
@ -510,8 +667,8 @@ export default function NewTransactionScreen() {
/> />
</View> </View>
</View> </View>
)}
</Card> </Card>
)}
{/* 简单模式下的分类选择 */} {/* 简单模式下的分类选择 */}
{!isAdvanced && direction !== 'transfer' && ( {!isAdvanced && direction !== 'transfer' && (
@ -522,13 +679,87 @@ export default function NewTransactionScreen() {
onSelect={(cat) => setSelectedCategory(cat)} onSelect={(cat) => setSelectedCategory(cat)}
/> />
{selectedCategory && ( {selectedCategory && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
{selectedCategory.linkedAccount} <Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{selectedCategory.linkedAccount}
</Text> </Text>
</View>
)} )}
</Card> </Card>
)} )}
{/* 详情与高级字段收纳区 */}
<Pressable
onPress={() => {
LayoutAnimation.easeInEaseOut();
setShowDetails(!showDetails);
}}
style={({ pressed }) => [
styles.detailsToggle,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSecondary,
opacity: pressed ? 0.8 : 1,
flexDirection: 'row',
gap: 6
}
]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
</Text>
<Ionicons
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={theme.colors.accent}
/>
</Pressable>
{showDetails && (
<View style={{ gap: theme.spacing.md }}>
<Card title={t('transaction.extraDetails')}>
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 收款方(简单模式才显示) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
</>
)}
</Card>
{/* 标签选择 */} {/* 标签选择 */}
<Card title={t('transaction.formTags')}> <Card title={t('transaction.formTags')}>
<TagPicker <TagPicker
@ -537,14 +768,16 @@ export default function NewTransactionScreen() {
onToggle={toggleTag} onToggle={toggleTag}
/> />
{selectedTags.length > 0 && ( {selectedTags.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)} {t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
</Text> </Text>
)} )}
</Card> </Card>
</View>
)}
<Button label={t('transaction.submit')} onPress={submit} /> <Button label={t('transaction.submit')} onPress={submit} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>{status}</Text> : null} {status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
@ -552,9 +785,8 @@ export default function NewTransactionScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
content: { padding: 16 }, content: { padding: 16, paddingBottom: 40 },
directionRow: { flexDirection: 'row', gap: 8 }, directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
input: { borderWidth: 1, borderRadius: 8, padding: 11, marginTop: 2 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: 8, borderWidth: 1, marginBottom: 4 },
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 }, postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
}); });

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
interface BalanceIndicatorProps { interface BalanceIndicatorProps {
/** 各币种余额。 */ /** 各币种余额。 */
@ -10,12 +11,13 @@ interface BalanceIndicatorProps {
/** 借贷平衡指示器(主题化)。所有币种余额为 0 时显示「平衡」。 */ /** 借贷平衡指示器(主题化)。所有币种余额为 0 时显示「平衡」。 */
export function BalanceIndicator({ balances }: BalanceIndicatorProps) { export function BalanceIndicator({ balances }: BalanceIndicatorProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const allBalanced = Object.values(balances).every(b => b === '0'); const allBalanced = Object.values(balances).every(b => b === '0');
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.sm }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.sm }]}>
<Text style={[theme.typography.caption, { color: allBalanced ? theme.colors.success : theme.colors.error, fontWeight: '700' }]}> <Text style={[theme.typography.caption, { color: allBalanced ? theme.colors.success : theme.colors.error, fontWeight: '700' }]}>
{allBalanced ? '✓ 平衡' : '✗ 不平衡'} {allBalanced ? t('home.balanced') : t('home.unbalanced')}
</Text> </Text>
{!allBalanced && Object.entries(balances).map(([cur, amt]) => ( {!allBalanced && Object.entries(balances).map(([cur, amt]) => (
<Text key={cur} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text key={cur} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>

View File

@ -1,5 +1,5 @@
import React from 'react'; import React, { useRef } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native'; import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
interface ButtonProps { interface ButtonProps {
@ -9,35 +9,65 @@ interface ButtonProps {
disabled?: boolean; disabled?: boolean;
} }
/** 按钮(主题化。primary=主题色secondary=次级容器danger=错误色。 */ /** 按钮(主题化,并有触控动效。primary=主题色secondary=次级容器danger=错误色。 */
export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) { export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
Animated.spring(scale, {
toValue: 0.96,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
const bg = variant === 'primary' ? theme.colors.accent const bg = variant === 'primary' ? theme.colors.accent
: variant === 'danger' ? theme.colors.error : variant === 'danger' ? theme.colors.error
: theme.colors.bgTertiary; : theme.colors.bgTertiary;
const fg = variant === 'secondary' ? theme.colors.fgPrimary : theme.colors.fgInverse; const fg = variant === 'secondary' ? theme.colors.fgPrimary : theme.colors.fgInverse;
return ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={label} accessibilityLabel={label}
accessibilityState={{ disabled: !!disabled }} accessibilityState={{ disabled: !!disabled }}
onPress={onPress} onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled} disabled={disabled}
style={({ pressed }) => [ style={{ marginTop: 6 }}
>
<Animated.View
style={[
styles.button, styles.button,
{ {
backgroundColor: bg, backgroundColor: bg,
borderRadius: theme.radii.md, borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角
opacity: pressed || disabled ? 0.6 : 1, opacity: disabled ? 0.5 : 1,
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
borderColor: theme.colors.border,
transform: [{ scale }],
}, },
]} ]}
> >
<Text style={[styles.text, { color: fg }]}>{label}</Text> <Text style={[styles.text, { color: fg, fontFamily: theme.typography.body.fontFamily }]}>{label}</Text>
</Animated.View>
</Pressable> </Pressable>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: { marginTop: 6, paddingVertical: 12, paddingHorizontal: 16, alignItems: 'center' }, button: { paddingVertical: 12, paddingHorizontal: 16, alignItems: 'center' },
text: { fontWeight: '700', fontSize: 16 }, text: { fontWeight: '700', fontSize: 16 },
}); });

View File

@ -1,18 +1,8 @@
/**
* plan.md5.3
*
* BeeCount calendar_page.dart
* -
* - /
* -
*
* RN
*/
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { getMonthGrid, type DayCell } from '../domain/calendarGrid'; import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
import { dailyExpenseMap } from '../domain/chartStats';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
interface CalendarViewProps { interface CalendarViewProps {
@ -32,6 +22,8 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
const [selectedDate, setSelectedDate] = useState<string | null>(null); const [selectedDate, setSelectedDate] = useState<string | null>(null);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]); const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]);
const handlePress = (cell: DayCell) => { const handlePress = (cell: DayCell) => {
if (!cell.isCurrentMonth) return; if (!cell.isCurrentMonth) return;
@ -54,6 +46,9 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
<View key={wi} style={styles.weekRow}> <View key={wi} style={styles.weekRow}>
{week.map(cell => { {week.map(cell => {
const isSelected = cell.date === selectedDate; const isSelected = cell.date === selectedDate;
const expense = dailyMap.get(cell.date) ?? 0;
const intensity = expense / maxDaily;
return ( return (
<Pressable <Pressable
key={cell.date} key={cell.date}
@ -67,6 +62,22 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
}, },
]} ]}
> >
{/* 消费热力图背景叠加层(未选中、当前月且有支出时显示) */}
{cell.isCurrentMonth && expense > 0 && !isSelected && (
<View
style={{
position: 'absolute',
top: 2,
bottom: 2,
left: 2,
right: 2,
borderRadius: theme.radii.sm,
backgroundColor: theme.colors.financial.expense,
opacity: 0.12 + intensity * 0.68, // 12% 浅红 ~ 80% 深红
}}
/>
)}
<Text style={[ <Text style={[
theme.typography.bodySmall, theme.typography.bodySmall,
{ {
@ -76,17 +87,20 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
? theme.colors.fgInverse ? theme.colors.fgInverse
: theme.colors.fgPrimary, : theme.colors.fgPrimary,
textAlign: 'center', textAlign: 'center',
zIndex: 1,
}, },
]}> ]}>
{cell.day} {cell.day}
</Text> </Text>
{/* 交易标记圆点 */} {/* 交易标记圆点 */}
{cell.count > 0 && cell.isCurrentMonth && ( {cell.count > 0 && cell.isCurrentMonth && (
<View style={styles.dots}> <View style={[styles.dots, { zIndex: 1 }]}>
{cell.hasIncome && ( {cell.hasIncome && (
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.income }]} /> <View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.income }]} />
)} )}
{cell.hasExpense && ( {/* 如果没有支出热力图背景,或者选中了,才渲染支出的点,防止视觉元素重复 */}
{cell.hasExpense && (expense === 0 || isSelected) && (
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.expense }]} /> <View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.expense }]} />
)} )}
</View> </View>
@ -104,7 +118,7 @@ const styles = StyleSheet.create({
container: { gap: 4 }, container: { gap: 4 },
weekdayRow: { flexDirection: 'row', marginBottom: 4 }, weekdayRow: { flexDirection: 'row', marginBottom: 4 },
weekRow: { flexDirection: 'row' }, weekRow: { flexDirection: 'row' },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 4 }, dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 4, position: 'relative' },
dots: { flexDirection: 'row', gap: 3, marginTop: 2 }, dots: { flexDirection: 'row', gap: 3, marginTop: 2 },
dot: { width: 5, height: 5, borderRadius: 2.5 }, dot: { width: 5, height: 5, borderRadius: 2.5 },
}); });

View File

@ -1,5 +1,5 @@
import React from 'react'; import React, { useRef } from 'react';
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
interface CardProps { interface CardProps {
@ -9,27 +9,91 @@ interface CardProps {
style?: ViewStyle; style?: ViewStyle;
} }
/** 卡片容器(主题化,参考 plan.md「1.2 组件使用方式」)。 */ /** 卡片容器(主题化,并有触控微交互)。 */
export function Card({ title, children, onPress, style }: CardProps) { export function Card({ title, children, onPress, style }: CardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const Container = onPress ? Pressable : View; const scale = useRef(new Animated.Value(1)).current;
return (
<Container const handlePressIn = () => {
onPress={onPress} Animated.spring(scale, {
style={[ toValue: 0.98,
styles.card, useNativeDriver: true,
{ tension: 150,
friction: 7,
}).start();
};
const handlePressOut = () => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 150,
friction: 7,
}).start();
};
const cardStyle = {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, borderRadius: theme.radii.lg,
padding: theme.spacing.md, padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
...theme.shadows.sm, ...theme.shadows.sm,
}, };
if (onPress) {
return (
<Pressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={style}
>
<Animated.View
style={[
styles.card,
cardStyle,
{ transform: [{ scale }] },
]}
>
{title ? (
<Text
style={[
styles.title,
{ color: theme.colors.fgPrimary },
theme.typography.h3,
]}
>
{title}
</Text>
) : null}
{children}
</Animated.View>
</Pressable>
);
}
return (
<View
style={[
styles.card,
cardStyle,
style, style,
]} ]}
> >
{title ? <Text style={[styles.title, { color: theme.colors.fgPrimary }, theme.typography.h3]}>{title}</Text> : null} {title ? (
<Text
style={[
styles.title,
{ color: theme.colors.fgPrimary },
theme.typography.h3,
]}
>
{title}
</Text>
) : null}
{children} {children}
</Container> </View>
); );
} }

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
@ -9,36 +9,110 @@ interface CategoryPickerProps {
onSelect: (cat: Category) => void; onSelect: (cat: Category) => void;
} }
/** 分类选择器(主题化,横向滚动 chip。 */ const CATEGORY_ICONS: Record<string, string> = {
food: '🍔',
transport: '🚗',
shopping: '🛍️',
housing_utility: '💧',
housing_rent: '🏠',
housing_communication: '📞',
entertainment: '🎮',
services: '⚙️',
personal_care: '💅',
clothing: '👕',
health: '🏥',
learning: '📚',
salary: '💰',
income_activity: '🧧',
income_investment: '📈',
fallback: '🏷️',
};
const CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B', // Amber
transport: '#3B82F6', // Blue
shopping: '#EC4899', // Pink
housing_utility: '#06B6D4',// Cyan
housing_rent: '#6366F1', // Indigo
housing_communication: '#8B5CF6', // Purple
entertainment: '#10B981', // Emerald
services: '#64748B', // Slate
personal_care: '#F43F5E', // Rose
clothing: '#14B8A6', // Teal
health: '#EF4444', // Red
learning: '#84CC16', // Lime
salary: '#22C55E', // Green
income_activity: '#EF4444',// Red/Orange
income_investment: '#F59E0B', // Amber
fallback: '#64748B',
};
/** 分类选择器(网格卡片化布局)。 */
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) { export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.container}> <View style={styles.grid}>
{categories.map(cat => { {categories.map(cat => {
const active = cat.id === selectedId; const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
return ( return (
<Pressable <Pressable
key={cat.id} key={cat.id}
onPress={() => onSelect(cat)} onPress={() => onSelect(cat)}
style={[ style={({ pressed }) => [
styles.chip, styles.item,
{ {
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary, backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border, borderColor: active ? color : theme.colors.border,
}, opacity: pressed ? 0.75 : 1,
}
]} ]}
> >
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 14 }}> <Text style={[styles.icon, { color }]}>{icon}</Text>
<Text
style={[
styles.label,
{
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontFamily: theme.typography.caption.fontFamily,
fontWeight: active ? '700' : '500',
}
]}
numberOfLines={1}
>
{cat.name} {cat.name}
</Text> </Text>
</Pressable> </Pressable>
); );
})} })}
</ScrollView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { paddingHorizontal: 16, gap: 8 }, grid: {
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16, borderWidth: 1, marginRight: 8 }, flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingVertical: 4,
},
item: {
width: '23%', // 约 4 列排布
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 1,
gap: 6,
padding: 4,
},
icon: {
fontSize: 22,
},
label: {
fontSize: 12,
},
}); });

View File

@ -2,6 +2,7 @@ import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
import type { DedupResult } from '../domain/dedup'; import type { DedupResult } from '../domain/dedup';
interface DedupBannerProps { interface DedupBannerProps {
@ -13,6 +14,7 @@ interface DedupBannerProps {
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */ /** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) { export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
if (!result.isDuplicate) return null; if (!result.isDuplicate) return null;
const confidenceColor = result.confidence === 'high' const confidenceColor = result.confidence === 'high'
@ -26,7 +28,7 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
<View style={styles.header}> <View style={styles.header}>
<Ionicons name="warning" size={18} color={confidenceColor} /> <Ionicons name="warning" size={18} color={confidenceColor} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('importFlow.suspectedDuplicate')}
</Text> </Text>
<Text style={[theme.typography.caption, { color: confidenceColor }]}> <Text style={[theme.typography.caption, { color: confidenceColor }]}>
{result.confidence} {result.confidence}
@ -39,10 +41,10 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
)} )}
<View style={styles.actions}> <View style={styles.actions}>
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}> <Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
<Text style={{ color: theme.colors.fgSecondary }}></Text> <Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
</Pressable> </Pressable>
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}> <Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
<Text style={{ color: theme.colors.fgInverse }}></Text> <Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@ -20,7 +20,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { Button } from './Button'; import { Button } from './Button';
@ -52,6 +52,7 @@ interface FormModalProps {
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) { export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const commonStyles = createCommonStyles(theme);
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化 // 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
@ -67,11 +68,13 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
return ( return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}> <Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={styles.overlay} onPress={onCancel}> <Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable <Pressable
style={[styles.sheet, { style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, borderRadius: theme.radii.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
}]} }]}
onPress={(e) => e.stopPropagation()} onPress={(e) => e.stopPropagation()}
> >
@ -95,12 +98,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'} keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline} multiline={f.multiline}
style={[styles.input, { style={commonStyles.input}
backgroundColor: theme.colors.bgTertiary,
color: theme.colors.fgPrimary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm,
}]}
/> />
</View> </View>
))} ))}
@ -121,7 +119,6 @@ const styles = StyleSheet.create({
overlay: { overlay: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.4)',
}, },
sheet: { sheet: {
maxHeight: '80%', maxHeight: '80%',
@ -142,11 +139,6 @@ const styles = StyleSheet.create({
field: { field: {
marginBottom: 12, marginBottom: 12,
}, },
input: {
borderWidth: 1,
padding: 11,
fontSize: 15,
},
actions: { actions: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native'; import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import { BalanceIndicator } from './BalanceIndicator'; import { BalanceIndicator } from './BalanceIndicator';
import type { Posting } from '../domain/types'; import type { Posting } from '../domain/types';
import { addDecimals } from '../domain/decimal'; import { addDecimals } from '../domain/decimal';
@ -12,9 +12,10 @@ interface PostingEditorProps {
accounts?: string[]; accounts?: string[];
} }
/** 分编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */ /** 分编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */
export function PostingEditor({ postings, onChange }: PostingEditorProps) { export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const update = (index: number, patch: Partial<Posting>) => { const update = (index: number, patch: Partial<Posting>) => {
onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p)); onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p));
@ -39,7 +40,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
onChangeText={v => update(i, { account: v })} onChangeText={v => update(i, { account: v })}
placeholder="账户" placeholder="账户"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { flex: 2, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={[commonStyles.input, { flex: 2 }]}
/> />
<TextInput <TextInput
value={p.amount ?? ''} value={p.amount ?? ''}
@ -47,14 +48,14 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
placeholder="金额" placeholder="金额"
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { flex: 1, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} // 金额输入也采用等宽字体更专业
/> />
<TextInput <TextInput
value={p.currency ?? ''} value={p.currency ?? ''}
onChangeText={v => update(i, { currency: v })} onChangeText={v => update(i, { currency: v })}
placeholder="币种" placeholder="币种"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { width: 60, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]} style={[commonStyles.input, { width: 60 }]}
/> />
</View> </View>
))} ))}
@ -65,5 +66,4 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 6 }, row: { flexDirection: 'row', gap: 6 },
input: { borderWidth: 1, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 10, fontSize: 14 },
}); });

View File

@ -9,11 +9,20 @@ interface SearchBarProps {
placeholder?: string; placeholder?: string;
} }
/** 搜索栏(主题化)。 */
export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps) { export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}> <View
style={[
styles.container,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg, // 升级为 lg 圆角
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
},
]}
>
<Ionicons name="search" size={18} color={theme.colors.fgSecondary} style={styles.icon} /> <Ionicons name="search" size={18} color={theme.colors.fgSecondary} style={styles.icon} />
<TextInput <TextInput
value={value} value={value}

View File

@ -44,8 +44,14 @@ export function SpeedDial({ actions }: SpeedDialProps) {
}; };
const handleAction = (action: SpeedDialAction) => { const handleAction = (action: SpeedDialAction) => {
action.onPress();
setOpen(false); setOpen(false);
Animated.spring(spin, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
action.onPress();
}; };
const mainRotation = spin.interpolate({ const mainRotation = spin.interpolate({
@ -67,7 +73,7 @@ export function SpeedDial({ actions }: SpeedDialProps) {
{hasActions && open && ( {hasActions && open && (
<Pressable <Pressable
onPress={toggle} onPress={toggle}
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.3)' }]} style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
/> />
)} )}

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import type { Tag } from '../domain/tags'; import type { Tag } from '../domain/tags';
interface TagPickerProps { interface TagPickerProps {
@ -12,6 +12,8 @@ interface TagPickerProps {
/** 标签选择器(主题化,多选 chip。 */ /** 标签选择器(主题化,多选 chip。 */
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) { export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
return ( return (
<View style={styles.container}> <View style={styles.container}>
{tags.map(tag => { {tags.map(tag => {
@ -21,14 +23,22 @@ export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
key={tag.id} key={tag.id}
onPress={() => onToggle(tag)} onPress={() => onToggle(tag)}
style={[ style={[
styles.chip, commonStyles.chip,
{ {
backgroundColor: active ? tag.color : theme.colors.bgTertiary, backgroundColor: active ? tag.color : 'transparent',
borderColor: active ? tag.color : theme.colors.border, borderColor: active ? tag.color : theme.colors.border,
}, },
]} ]}
> >
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 13 }}> <Text
style={[
commonStyles.chipText,
{
color: active ? theme.colors.fgInverse : theme.colors.fgPrimary,
fontSize: 13,
},
]}
>
#{tag.name} #{tag.name}
</Text> </Text>
</Pressable> </Pressable>
@ -40,5 +50,4 @@ export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 16 }, container: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 16 },
chip: { paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1 },
}); });

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
interface TransactionCardProps { interface TransactionCardProps {
@ -11,6 +12,7 @@ interface TransactionCardProps {
/** 交易卡片(主题化,展示日期/摘要/金额)。 */ /** 交易卡片(主题化,展示日期/摘要/金额)。 */
export function TransactionCard({ transaction, onPress }: TransactionCardProps) { export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses')); const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = transaction.postings.some(p => p.account.startsWith('Income')); const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
const color = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer; const color = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer;
@ -23,26 +25,33 @@ export function TransactionCard({ transaction, onPress }: TransactionCardProps)
onPress={() => onPress?.(transaction)} onPress={() => onPress?.(transaction)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.card, styles.card,
{ backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md, opacity: pressed ? 0.7 : 1 }, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, // 升级为大圆角
padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth, // 添加极细透白边框
borderColor: theme.colors.border,
opacity: pressed ? 0.8 : 1,
},
]} ]}
> >
<View style={styles.row}> <View style={styles.row}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]} numberOfLines={1}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
{transaction.narration || transaction.payee || '(无摘要)'} {transaction.narration || transaction.payee || t('transaction.noSummary')}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{transaction.date.slice(0, 10)} {transaction.date.slice(0, 10)}
</Text> </Text>
</View> </View>
<Text style={[theme.typography.body, { color, fontWeight: '700' }]}> <Text style={[theme.typography.body, { color, fontWeight: '700', fontFamily: 'monospace', fontSize: 15 }]}>
{primaryAmount} {primaryAmount}
</Text> </Text>
</View> </View>
{transaction.tags.length > 0 && ( {transaction.tags.length > 0 && (
<View style={styles.tags}> <View style={styles.tags}>
{transaction.tags.map(tag => ( {transaction.tags.map(tag => (
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accentDark }]}> <Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
#{tag} #{tag}
</Text> </Text>
))} ))}

View File

@ -5,42 +5,44 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { generateAnnualReport } from '../../domain/annualReport'; import { generateAnnualReport } from '../../domain/annualReport';
import { MonthlyReport } from './MonthlyReport'; import { MonthlyReport } from './MonthlyReport';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) { export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const report = generateAnnualReport(transactions, year); const report = generateAnnualReport(transactions, year);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{year} </Text> <Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{t('report.annualTitle', { year })}</Text>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>{report.totalIncome}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>{report.totalIncome}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>{report.totalExpense}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>{report.totalExpense}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent }]}>{report.netIncome}</Text> <Text style={[theme.typography.h3, { color: theme.colors.accent }]}>{report.netIncome}</Text>
</View> </View>
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{report.transactionCount} · {report.averageDailyExpense} {t('report.annualStats', { count: report.transactionCount, avg: report.averageDailyExpense })}
</Text> </Text>
</View> </View>
{report.topCategories.length > 0 && ( {report.topCategories.length > 0 && (
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>Top </Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.topCategory')}</Text>
{report.topCategories.slice(0, 5).map(c => ( {report.topCategories.slice(0, 5).map(c => (
<View key={c.category} style={styles.catRow}> <View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1 }]}>{c.category.replace('Expenses:', '')}</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1 }]}>{c.category.replace('Expenses:', '')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>{c.amount} ({c.percentage}%)</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>{c.amount} ({c.percentage}%)</Text>
</View> </View>
@ -58,5 +60,5 @@ const styles = StyleSheet.create({
card: { gap: 8 }, card: { gap: 8 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },
catRow: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: 'rgba(0,0,0,0.05)', paddingTop: 8 }, catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
}); });

View File

@ -5,21 +5,22 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { getMonthGrid } from '../../domain/calendarGrid'; import { getMonthGrid } from '../../domain/calendarGrid';
import { dailyExpenseMap } from '../../domain/chartStats'; import { dailyExpenseMap } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
/** 根据强度返回颜色0-1。 */ /** 根据强度返回颜色0-1。使用 opacity 而非字符串替换,兼容 hex 和 rgb。 */
function heatColor(intensity: number, baseColor: string): string { function heatColor(intensity: number): { opacity: number } {
if (intensity <= 0) return 'transparent'; if (intensity <= 0) return { opacity: 0 };
const alpha = Math.min(0.2 + intensity * 0.8, 1); return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
return baseColor.replace(')', `,${alpha})`).replace('rgb', 'rgba');
} }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) { export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]); const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]); const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const maxDaily = Math.max(...dailyMap.values(), 1); const maxDaily = Math.max(...dailyMap.values(), 1);
@ -27,7 +28,7 @@ export function CalendarHeatmap({ transactions, year, month }: { transactions: T
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 4 }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 4 }]}>
{year}{month} {t('report.heatmap')}
</Text> </Text>
<View style={styles.weekdayRow}> <View style={styles.weekdayRow}>
{WEEKDAYS.map(d => ( {WEEKDAYS.map(d => (
@ -46,10 +47,10 @@ export function CalendarHeatmap({ transactions, year, month }: { transactions: T
styles.cell, styles.cell,
{ {
backgroundColor: cell.isCurrentMonth && expense > 0 backgroundColor: cell.isCurrentMonth && expense > 0
? heatColor(intensity, 'rgb(244,67,54)') ? theme.colors.financial.expense
: 'transparent', : 'transparent',
borderColor: theme.colors.border, borderColor: theme.colors.border,
opacity: cell.isCurrentMonth ? 1 : 0.3, opacity: cell.isCurrentMonth ? (expense > 0 ? heatColor(intensity).opacity : 1) : 0.3,
}, },
]}> ]}>
<Text style={[theme.typography.caption, { fontSize: 10, color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { fontSize: 10, color: theme.colors.fgSecondary }]}>

View File

@ -1,42 +1,56 @@
/** /**
* plan.md5.2 * plan.md5.2
* + pie *
*/ */
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByCategory } from '../../domain/chartStats'; import { groupByCategory } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
export function CategoryPie({ transactions }: { transactions: Transaction[] }) { export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const data = groupByCategory(transactions); const data = groupByCategory(transactions);
const maxAmount = Math.max(...data.map(d => d.amount), 1); const maxAmount = Math.max(...data.map(d => d.amount), 1);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}></Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}>
{t('report.categoryTitle')}
</Text>
<View style={styles.list}>
{data.map(d => ( {data.map(d => (
<View key={d.category} style={styles.row}> <View key={d.category} style={styles.row}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, flex: 1 }]} numberOfLines={1}> <Text
{d.category.replace('Expenses:', '')} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600', fontFamily: theme.typography.bodySmall.fontFamily }]}
numberOfLines={1}
>
{d.category.replace('Expenses:', '').replace('Income:', '')}
</Text> </Text>
<View style={styles.barWrap}> <View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}>
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} /> <View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 50, textAlign: 'right' }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontFamily: 'monospace', fontSize: 13 }]}>
{d.percentage}% {d.percentage}%
</Text> </Text>
</View> </View>
))} ))}
{data.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text>} </View>
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 15 }]}>
{t('common.noData')}
</Text>
)}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { gap: 6 }, container: { gap: 10 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 }, list: { gap: 8 },
barWrap: { width: 100, height: 10, backgroundColor: 'rgba(0,0,0,0.05)', borderRadius: 5, overflow: 'hidden' }, row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
bar: { height: '100%', borderRadius: 5 }, barWrap: { width: 120, height: 8, borderRadius: 4, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 4 },
}); });

View File

@ -5,21 +5,23 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) { export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const data = groupByMonth(transactions); const data = groupByMonth(transactions);
const maxExpense = Math.max(...data.map(d => d.expense), 1); const maxExpense = Math.max(...data.map(d => d.expense), 1);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}></Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyTitle')}</Text>
{data.map(d => ( {data.map(d => (
<View key={d.month} style={styles.row}> <View key={d.month} style={styles.row}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text>
<View style={styles.barWrap}> <View style={[styles.barWrap, { backgroundColor: theme.colors.progressBg }]}>
<View style={[styles.bar, { <View style={[styles.bar, {
width: `${(d.expense / maxExpense) * 100}%`, width: `${(d.expense / maxExpense) * 100}%`,
backgroundColor: theme.colors.financial.expense, backgroundColor: theme.colors.financial.expense,
@ -29,7 +31,7 @@ export function MonthlyReport({ transactions }: { transactions: Transaction[] })
</View> </View>
))} ))}
{data.length === 0 && ( {data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>
)} )}
</View> </View>
); );
@ -38,6 +40,6 @@ export function MonthlyReport({ transactions }: { transactions: Transaction[] })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { gap: 6 }, container: { gap: 6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 }, row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barWrap: { flex: 1, height: 12, backgroundColor: 'rgba(0,0,0,0.05)', borderRadius: 6, overflow: 'hidden' }, barWrap: { flex: 1, height: 12, borderRadius: 6, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 6 }, bar: { height: '100%', borderRadius: 6 },
}); });

View File

@ -4,17 +4,19 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { calculateNetWorthTrend } from '../../domain/netWorth'; import { calculateNetWorthTrend } from '../../domain/netWorth';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) { export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const points = calculateNetWorthTrend(transactions, dates); const points = calculateNetWorthTrend(transactions, dates);
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1); const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}></Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.netWorthTitle')}</Text>
<View style={styles.chart}> <View style={styles.chart}>
{points.map(p => ( {points.map(p => (
<View key={p.date} style={styles.col}> <View key={p.date} style={styles.col}>
@ -30,10 +32,10 @@ export function NetWorthChart({ transactions, dates }: { transactions: Transacti
</View> </View>
{points.length > 0 && ( {points.length > 0 && (
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{points[points.length - 1].netWorth} {t('report.netWorthCurrent', { amount: points[points.length - 1].netWorth })}
</Text> </Text>
)} )}
{points.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text>} {points.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>}
</View> </View>
); );
} }

View File

@ -1,49 +1,189 @@
/** /**
* 线plan.md5.2 * 线plan.md5.2
* + SVG-like 线 View * react-native-svg 线
*/ */
import React from 'react'; import React from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/chartStats';
export function TrendLine({ transactions }: { transactions: Transaction[] }) { export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const data = groupByMonth(transactions); const t = useT();
const data = groupByMonth(transactions).slice(-6);
// 1. 数据配置
const width = 300;
const height = 120;
const paddingX = 20;
const paddingY = 15;
const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense]), 1); const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense]), 1);
// 2. 坐标转换计算
const pointsIncome = data.map((d, i) => {
const x = data.length > 1
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
: width / 2;
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
return { x, y };
});
const pointsExpense = data.map((d, i) => {
const x = data.length > 1
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
: width / 2;
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
return { x, y };
});
// 3. 贝塞尔曲线生成算法
const getBezierPath = (points: { x: number; y: number }[]) => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i];
const p1 = points[i + 1];
const cp1x = p0.x + (p1.x - p0.x) / 2;
const cp1y = p0.y;
const cp2x = p0.x + (p1.x - p0.x) / 2;
const cp2y = p1.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
}
return path;
};
const getClosedBezierPath = (points: { x: number; y: number }[]) => {
if (points.length === 0) return '';
const linePath = getBezierPath(points);
const first = points[0];
const last = points[points.length - 1];
return `${linePath} L ${last.x} ${height - paddingY} L ${first.x} ${height - paddingY} Z`;
};
const incomePath = getBezierPath(pointsIncome);
const incomeClosedPath = getClosedBezierPath(pointsIncome);
const expensePath = getBezierPath(pointsExpense);
const expenseClosedPath = getClosedBezierPath(pointsExpense);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}></Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}>
<View style={styles.chart}> {t('report.trendTitle')}
{data.map(d => ( </Text>
<View key={d.month} style={styles.col}>
<View style={styles.bars}> {data.length > 0 ? (
<View style={[styles.bar, { height: `${(d.income / maxVal) * 100}%`, backgroundColor: theme.colors.financial.income }]} /> <View style={styles.chartWrapper}>
<View style={[styles.bar, { height: `${(d.expense / maxVal) * 100}%`, backgroundColor: theme.colors.financial.expense }]} /> <Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
</View> <Defs>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10 }]}>{d.month.slice(5)}</Text> <LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
</View> <Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
<Stop offset="100%" stopColor={theme.colors.financial.income} stopOpacity={0.0} />
</LinearGradient>
<LinearGradient id="expenseGrad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0%" stopColor={theme.colors.financial.expense} stopOpacity={0.25} />
<Stop offset="100%" stopColor={theme.colors.financial.expense} stopOpacity={0.0} />
</LinearGradient>
</Defs>
{/* 网格线(只绘制一条底线和中线) */}
<Path
d={`M ${paddingX} ${height / 2} L ${width - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${width - paddingX} ${height - paddingY}`}
stroke={theme.colors.divider}
strokeWidth={1}
strokeDasharray="4 4"
/>
{/* 收入填充与折线 */}
{incomeClosedPath ? <Path d={incomeClosedPath} fill="url(#incomeGrad)" /> : null}
{incomePath ? (
<Path
d={incomePath}
fill="none"
stroke={theme.colors.financial.income}
strokeWidth={2.5}
strokeLinecap="round"
/>
) : null}
{/* 支出填充与折线 */}
{expenseClosedPath ? <Path d={expenseClosedPath} fill="url(#expenseGrad)" /> : null}
{expensePath ? (
<Path
d={expensePath}
fill="none"
stroke={theme.colors.financial.expense}
strokeWidth={2.5}
strokeLinecap="round"
/>
) : null}
{/* 数据点标记 */}
{pointsIncome.map((p, i) => (
<Circle
key={`income-dot-${i}`}
cx={p.x}
cy={p.y}
r={3.5}
fill={theme.colors.bgSecondary}
stroke={theme.colors.financial.income}
strokeWidth={2}
/>
))}
{pointsExpense.map((p, i) => (
<Circle
key={`expense-dot-${i}`}
cx={p.x}
cy={p.y}
r={3.5}
fill={theme.colors.bgSecondary}
stroke={theme.colors.financial.expense}
strokeWidth={2}
/>
))}
</Svg>
{/* 月度文本 X 轴 */}
<View style={styles.xAxis}>
{data.map((d, i) => (
<Text
key={`x-label-${i}`}
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10, fontFamily: theme.typography.caption.fontFamily }]}
>
{d.month.slice(5)}
</Text>
))} ))}
</View> </View>
</View>
) : (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>
{t('common.noData')}
</Text>
)}
<View style={styles.legend}> <View style={styles.legend}>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.income')}
</Text>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
</View> </View>
{data.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}></Text>}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { gap: 8 }, container: { gap: 10 },
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 8 }, chartWrapper: { marginTop: 6 },
col: { flex: 1, alignItems: 'center', gap: 4 }, xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 },
bars: { flexDirection: 'row', alignItems: 'flex-end', gap: 2, height: 100 }, legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
bar: { width: 8, minHeight: 2, borderRadius: 2 },
legend: { flexDirection: 'row', alignItems: 'center', gap: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 }, legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 },
}); });

View File

@ -6,8 +6,9 @@
* *
*/ */
import { detectDirection } from './constants';
import { negateDecimal } from './decimal'; import { negateDecimal } from './decimal';
import type { Direction, ImportedEvent } from './types'; import type { ImportedEvent } from './types';
/** 通用 CSV 行解析(与 statements.ts 一致的列提取逻辑)。 */ /** 通用 CSV 行解析(与 statements.ts 一致的列提取逻辑)。 */
function parseCsvRows(content: string): Record<string, string>[] { function parseCsvRows(content: string): Record<string, string>[] {
@ -25,21 +26,13 @@ function pickFirst(row: Record<string, string>, keys: string[]): string {
return keys.map(k => row[k]).find(v => v && v.trim())?.trim() ?? ''; return keys.map(k => row[k]).find(v => v && v.trim())?.trim() ?? '';
} }
function parseDirection(text: string): Direction {
if (/转账|转入|转出/i.test(text)) return 'transfer';
if (/退款/i.test(text)) return 'refund';
if (/手续费|利息/i.test(text)) return 'fee';
if (/支出|付款|消费|转出/i.test(text)) return 'expense';
return 'income';
}
/** 招商银行 CSV 适配器。 */ /** 招商银行 CSV 适配器。 */
export function parseCmbCsv(content: string): ImportedEvent[] { export function parseCmbCsv(content: string): ImportedEvent[] {
const rows = parseCsvRows(content); const rows = parseCsvRows(content);
return rows.map((row, i) => { return rows.map((row, i) => {
let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, ''); let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要', '收/支']); const type = pickFirst(row, ['交易类型', '摘要', '收/支']);
const direction = parseDirection(type + amount); const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount); if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易日期', '日期', '时间']).replace(/[/.]/g, '-').slice(0, 10); const date = pickFirst(row, ['交易日期', '日期', '时间']).replace(/[/.]/g, '-').slice(0, 10);
return { return {
@ -48,7 +41,6 @@ export function parseCmbCsv(content: string): ImportedEvent[] {
amount, amount,
currency: 'CNY', currency: 'CNY',
direction, direction,
channel: 'Bank:CMB',
counterparty: pickFirst(row, ['交易对方', '对方账户', '商户名称']), counterparty: pickFirst(row, ['交易对方', '对方账户', '商户名称']),
memo: pickFirst(row, ['摘要', '交易描述', '备注']), memo: pickFirst(row, ['摘要', '交易描述', '备注']),
raw: row, raw: row,
@ -62,7 +54,7 @@ export function parseIcbcCsv(content: string): ImportedEvent[] {
return rows.map((row, i) => { return rows.map((row, i) => {
let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, ''); let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要']); const type = pickFirst(row, ['交易类型', '摘要']);
const direction = parseDirection(type + amount); const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount); if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易时间', '交易日期', '日期']).replace(/[/.]/g, '-').slice(0, 10); const date = pickFirst(row, ['交易时间', '交易日期', '日期']).replace(/[/.]/g, '-').slice(0, 10);
return { return {
@ -71,7 +63,6 @@ export function parseIcbcCsv(content: string): ImportedEvent[] {
amount, amount,
currency: 'CNY', currency: 'CNY',
direction, direction,
channel: 'Bank:ICBC',
counterparty: pickFirst(row, ['对方户名', '对方账户', '交易对方']), counterparty: pickFirst(row, ['对方户名', '对方账户', '交易对方']),
memo: pickFirst(row, ['摘要', '交易描述', '备注']), memo: pickFirst(row, ['摘要', '交易描述', '备注']),
raw: row, raw: row,
@ -80,21 +71,20 @@ export function parseIcbcCsv(content: string): ImportedEvent[] {
} }
/** 通用银行 CSV 适配器(未知银行格式)。 */ /** 通用银行 CSV 适配器(未知银行格式)。 */
export function parseGenericBankCsv(content: string, channel = 'Bank'): ImportedEvent[] { export function parseGenericBankCsv(content: string, source = 'bank'): ImportedEvent[] {
const rows = parseCsvRows(content); const rows = parseCsvRows(content);
return rows.map((row, i) => { return rows.map((row, i) => {
let amount = pickFirst(row, ['金额', '交易金额', '发生额', 'Amount']).replace(/[¥¥\s]/g, '').replace(/,/g, ''); let amount = pickFirst(row, ['金额', '交易金额', '发生额', 'Amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要', '收/支', 'Type']); const type = pickFirst(row, ['交易类型', '摘要', '收/支', 'Type']);
const direction = parseDirection(type + amount); const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount); if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易日期', '日期', '时间', 'Date']).replace(/[/.]/g, '-').slice(0, 10); const date = pickFirst(row, ['交易日期', '日期', '时间', 'Date']).replace(/[/.]/g, '-').slice(0, 10);
return { return {
id: `${channel}:${pickFirst(row, ['交易序号', '流水号', 'id']) || i}`, id: `${source}:${pickFirst(row, ['交易序号', '流水号', 'id']) || i}`,
occurredAt: date || new Date().toISOString().slice(0, 10), occurredAt: date || new Date().toISOString().slice(0, 10),
amount, amount,
currency: pickFirst(row, ['币种', 'Currency']) || 'CNY', currency: pickFirst(row, ['币种', 'Currency']) || 'CNY',
direction, direction,
channel,
counterparty: pickFirst(row, ['交易对方', '对方账户', '商户', 'Counterparty']), counterparty: pickFirst(row, ['交易对方', '对方账户', '商户', 'Counterparty']),
memo: pickFirst(row, ['摘要', '备注', '描述', 'Memo']), memo: pickFirst(row, ['摘要', '备注', '描述', 'Memo']),
raw: row, raw: row,

View File

@ -145,10 +145,10 @@ export function buildCategoryRecognitionPrompt(event: ImportedEvent, categories:
} }
/** 资产映射 promptplan.md「8.5」)。 */ /** 资产映射 promptplan.md「8.5」)。 */
export function buildAssetMappingPrompt(channel: string, counterparty: string, accounts: string[]): ChatMessage[] { export function buildAssetMappingPrompt(counterparty: string, accounts: string[]): ChatMessage[] {
return [ return [
{ role: 'system', content: `判断最合适的资产账户。可用账户:${accounts.join('、')}。只返回账户名称。` }, { role: 'system', content: `判断最合适的资产账户。可用账户:${accounts.join('、')}。只返回账户名称。` },
{ role: 'user', content: sanitizeForAi(`渠道: ${channel}\n对方: ${counterparty}`) }, { role: 'user', content: sanitizeForAi(`对方: ${counterparty}`) },
]; ];
} }
@ -181,7 +181,6 @@ export async function aiRecognizeCategory(
/** AI 资产映射(注入 provider。 */ /** AI 资产映射(注入 provider。 */
export async function aiMapAsset( export async function aiMapAsset(
channel: string,
counterparty: string, counterparty: string,
ledger: LedgerIndex, ledger: LedgerIndex,
provider: AiProvider, provider: AiProvider,
@ -189,7 +188,7 @@ export async function aiMapAsset(
const accounts = [...ledger.accounts.keys()].filter(a => const accounts = [...ledger.accounts.keys()].filter(a =>
!a.startsWith('Income') && !a.startsWith('Expenses') && !a.startsWith('Equity') !a.startsWith('Income') && !a.startsWith('Expenses') && !a.startsWith('Equity')
); );
const response = await provider.chat(buildAssetMappingPrompt(channel, counterparty, accounts)); const response = await provider.chat(buildAssetMappingPrompt(counterparty, accounts));
const name = removeThink(response).trim(); const name = removeThink(response).trim();
return accounts.find(a => a === name) ?? null; return accounts.find(a => a === name) ?? null;
} }

View File

@ -26,114 +26,101 @@ export function generateAnnualReport(
): AnnualReport { ): AnnualReport {
const yearTx = transactions.filter(t => t.date.startsWith(String(year))); const yearTx = transactions.filter(t => t.date.startsWith(String(year)));
const totalIncome = sumByDirection(yearTx, 'income'); // 单次遍历:一次性计算所有聚合数据
const totalExpense = sumByDirection(yearTx, 'expense'); const incomeAmounts: string[] = [];
const netIncome = addDecimals([totalIncome, totalExpense]); // expense 是负数 const expenseAmounts: string[] = [];
const byCategory = new Map<string, string>();
const monthlyIncome = new Map<string, string[]>();
const monthlyExpense = new Map<string, string[]>();
const byDayOfWeek = new Array(7).fill(0).map(() => [] as string[]);
let largest: Transaction | null = null;
let maxAmount = 0;
const uniqueDays = new Set<string>();
for (const t of yearTx) {
uniqueDays.add(t.date.slice(0, 10));
const month = t.date.slice(5, 7);
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
// 收入
if (p.account.startsWith('Income')) {
const abs = amt < 0 ? p.amount.slice(1) : p.amount;
incomeAmounts.push(abs);
if (!monthlyIncome.has(month)) monthlyIncome.set(month, []);
monthlyIncome.get(month)!.push(abs);
}
// 支出
if (p.account.startsWith('Expenses') && amt > 0) {
expenseAmounts.push(p.amount);
const prev = byCategory.get(p.account) ?? '0';
byCategory.set(p.account, addDecimals([prev, p.amount]));
if (!monthlyExpense.has(month)) monthlyExpense.set(month, []);
monthlyExpense.get(month)!.push(p.amount);
// 星期分布
const dow = new Date(t.date.slice(0, 10)).getDay();
byDayOfWeek[dow].push(p.amount);
// 最大交易
if (amt > maxAmount) {
maxAmount = amt;
largest = t;
}
}
}
}
const totalIncome = addDecimals(incomeAmounts);
const totalExpense = addDecimals(expenseAmounts);
const netIncome = addDecimals([totalIncome, totalExpense]);
// 月度趋势(单次构建,无循环 filter
const monthlyTrend = Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, '0');
return {
month: m,
income: addDecimals(monthlyIncome.get(m) ?? []),
expense: addDecimals(monthlyExpense.get(m) ?? []),
};
});
// Top 分类
const totalExpenseForPct = expenseAmounts.length > 0 ? addDecimals(expenseAmounts) : '0';
const topCategories = [...byCategory.entries()]
.map(([category, amount]) => ({
category,
amount,
percentage: parseFloat(totalExpenseForPct) > 0
? Math.round((parseFloat(amount) / parseFloat(totalExpenseForPct)) * 10000) / 100
: 0,
}))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
.slice(0, 10);
// 星期分布
const dayOfWeekBreakdown = byDayOfWeek.map((amounts, i) => ({
day: DAY_NAMES[i],
total: addDecimals(amounts),
}));
// 日均支出
const days = uniqueDays.size || 1;
const averageDailyExpense = (parseFloat(totalExpense) / days).toFixed(2);
return { return {
year, year,
totalIncome, totalIncome,
totalExpense, totalExpense,
netIncome, netIncome,
topCategories: getTopCategories(yearTx, 10), topCategories,
monthlyTrend: getMonthlyTrend(yearTx), monthlyTrend,
dayOfWeekBreakdown: getDayOfWeekBreakdown(yearTx), dayOfWeekBreakdown,
largestTransaction: getLargestTransaction(yearTx), largestTransaction: largest,
transactionCount: yearTx.length, transactionCount: yearTx.length,
averageDailyExpense: calculateAverageDaily(yearTx), averageDailyExpense,
}; };
} }
function sumByDirection(transactions: Transaction[], direction: 'income' | 'expense'): string {
const amounts: string[] = [];
for (const t of transactions) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
// 支出Expenses 账户的正金额Beancount 约定,费用账户正余额=支出)
if (direction === 'expense' && p.account.startsWith('Expenses') && amt > 0) {
amounts.push(p.amount);
}
// 收入Income 账户的负金额(冲减收入)取绝对值,或正金额(某些约定)
if (direction === 'income' && p.account.startsWith('Income')) {
amounts.push(amt < 0 ? p.amount.slice(1) : p.amount);
}
}
}
return addDecimals(amounts);
}
function getTopCategories(transactions: Transaction[], limit: number): { category: string; amount: string; percentage: number }[] {
const byCategory = new Map<string, string>();
let total = '0';
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
byCategory.set(p.account, addDecimals([byCategory.get(p.account) ?? '0', p.amount]));
total = addDecimals([total, p.amount]);
}
}
}
const entries = [...byCategory.entries()]
.map(([category, amount]) => ({
category,
amount,
percentage: parseFloat(total) > 0 ? Math.round((parseFloat(amount) / parseFloat(total)) * 10000) / 100 : 0,
}))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
.slice(0, limit);
return entries;
}
function getMonthlyTrend(transactions: Transaction[]): { month: string; income: string; expense: string }[] {
const months: { month: string; income: string; expense: string }[] = [];
for (let m = 1; m <= 12; m++) {
const monthStr = String(m).padStart(2, '0');
const monthTx = transactions.filter(t => t.date.slice(5, 7) === monthStr);
months.push({
month: monthStr,
income: sumByDirection(monthTx, 'income'),
expense: sumByDirection(monthTx, 'expense'),
});
}
return months;
}
function getDayOfWeekBreakdown(transactions: Transaction[]): { day: string; total: string }[] {
const byDay = new Array(7).fill(0).map((_, i) => ({ day: DAY_NAMES[i], total: '0' }));
for (const t of transactions) {
const date = new Date(t.date.slice(0, 10));
const dow = date.getDay();
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
byDay[dow].total = addDecimals([byDay[dow].total, p.amount]);
}
}
}
return byDay;
}
function getLargestTransaction(transactions: Transaction[]): Transaction | null {
let largest: Transaction | null = null;
let maxAmount = 0;
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount && p.amount.startsWith('-')) {
const abs = parseFloat(p.amount.slice(1));
if (abs > maxAmount) {
maxAmount = abs;
largest = t;
}
}
}
}
return largest;
}
function calculateAverageDaily(transactions: Transaction[]): string {
const total = sumByDirection(transactions, 'expense');
const days = transactions.length > 0
? new Set(transactions.map(t => t.date.slice(0, 10))).size
: 1;
return (parseFloat(total) / days).toFixed(2);
}

View File

@ -1,6 +1,7 @@
import { classifyWithCategories } from './rules'; import { classifyWithCategories } from './rules';
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup'; import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup';
import { recognizeTransfers, type TransferConfig } from './transferRecognizer'; import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
import { compareDecimals, negateDecimal } from './decimal';
import type { Category } from './categories'; import type { Category } from './categories';
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types'; import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
@ -12,7 +13,7 @@ import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft }
* *
* *
* 1. Income+Expend Transfer * 1. Income+Expend Transfer
* 2. + + * 2. +
* 3. * 3.
* 4. + Uncategorized * 4. + Uncategorized
* 5. 稿 MobileBeanStore * 5. 稿 MobileBeanStore
@ -24,8 +25,6 @@ export interface PipelineContext {
categories: Category[]; categories: Category[];
/** 已入账的历史交易(用于跨会话去重)。 */ /** 已入账的历史交易(用于跨会话去重)。 */
history: Transaction[]; history: Transaction[];
/** 渠道 → 账户名映射(转账识别用)。 */
accountMap: Record<string, string>;
dedupConfig?: DedupConfig; dedupConfig?: DedupConfig;
transferConfig?: TransferConfig; transferConfig?: TransferConfig;
} }
@ -49,6 +48,37 @@ export interface PipelineResult {
transferCount: number; transferCount: number;
} }
/**
* 稿
* + + posting
*/
function isTransferInHistory(transfer: TransferPair, history: Transaction[]): boolean {
const draft = transfer.draft;
const draftDate = draft.date;
const draftAmount = draft.postings[1]?.amount ?? '0'; // 收入方金额(正数)
const draftFromAccount = draft.postings[0]?.account;
const draftToAccount = draft.postings[1]?.account;
if (!draftFromAccount || !draftToAccount) return false;
return history.some(t => {
if (t.date !== draftDate) return false;
if (t.postings.length !== 2) return false;
// 检查两个 posting 账户是否匹配(顺序可能不同)
const tAccounts = t.postings.map(p => p.account);
const accountsMatch =
(tAccounts[0] === draftFromAccount && tAccounts[1] === draftToAccount) ||
(tAccounts[0] === draftToAccount && tAccounts[1] === draftFromAccount);
if (!accountsMatch) return false;
// 检查金额是否匹配
const tAmount = t.postings[1]?.amount ?? '0';
return compareDecimals(tAmount, draftAmount) === 0;
});
}
export class BillPipeline { export class BillPipeline {
/** 串行化锁:整个识别→去重→分类流程互斥,保证并发正确性。 */ /** 串行化锁:整个识别→去重→分类流程互斥,保证并发正确性。 */
private mutex: Promise<unknown> = Promise.resolve(); private mutex: Promise<unknown> = Promise.resolve();
@ -70,7 +100,6 @@ export class BillPipeline {
// 1. 转账识别(先于去重) // 1. 转账识别(先于去重)
const { transfers, remaining: afterTransfer } = recognizeTransfers( const { transfers, remaining: afterTransfer } = recognizeTransfers(
events, events,
ctx.accountMap,
ctx.transferConfig, ctx.transferConfig,
); );
@ -89,19 +118,52 @@ export class BillPipeline {
// 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories // 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories
const drafts: ProcessedDraft[] = []; const drafts: ProcessedDraft[] = [];
const transferDups: ImportedEvent[] = [];
// 4a. 转账草稿 // 4a. 转账草稿(需与历史去重)
for (const transfer of transfers) { for (const transfer of transfers) {
if (!isTransferInHistory(transfer, ctx.history)) {
drafts.push({ drafts.push({
draft: transfer.draft, draft: transfer.draft,
sourceEventIds: [transfer.leftEventId, transfer.rightEventId], sourceEventIds: [transfer.leftEventId, transfer.rightEventId],
isTransfer: true, isTransfer: true,
}); });
} else {
// 转账已在历史中,将两个源事件标记为重复
const leftEvt = events.find(e => e.id === transfer.leftEventId);
const rightEvt = events.find(e => e.id === transfer.rightEventId);
if (leftEvt) transferDups.push(leftEvt);
if (rightEvt) transferDups.push(rightEvt);
}
} }
// 4b. 非转账事件 // 4b. 非转账事件
for (const event of afterHistoryDedup) { for (const event of afterHistoryDedup) {
const result = classifyWithCategories(event, ctx.rules, ctx.categories, ctx.ledger); let result: { draft: TransactionDraft; categoryFallbackReason?: string };
if (event.sourceAccount && event.categoryAccount) {
// 预填账户(手动/拍照入口):直接构造 draft跳过自动分类
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const sourceAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(sourceAmount);
result = {
draft: {
date: event.occurredAt.slice(0, 10),
payee: event.counterparty || undefined,
narration: event.memo || event.counterparty || '手动记账',
sourceEventId: event.id,
postings: [
{ account: event.sourceAccount, amount: sourceAmount, currency: event.currency },
{ account: event.categoryAccount, amount: categoryAmount, currency: event.currency },
],
},
};
} else {
// 自动分类CSV/通知/短信/截图入口)
const classified = classifyWithCategories(event, ctx.rules, ctx.categories, ctx.ledger);
result = { draft: classified.draft, categoryFallbackReason: classified.categoryFallbackReason };
}
drafts.push({ drafts.push({
draft: result.draft, draft: result.draft,
sourceEventIds: [event.id], sourceEventIds: [event.id],
@ -112,7 +174,7 @@ export class BillPipeline {
return { return {
drafts, drafts,
duplicates: [...batchDups, ...historyDups], duplicates: [...batchDups, ...historyDups, ...transferDups],
transferCount: transfers.length, transferCount: transfers.length,
}; };
} }

View File

@ -53,7 +53,7 @@ export function matchCategory(text: string, categories: Category[]): CategoryMat
if (fuzzy) return { category: fuzzy, matchedBy: 'fuzzy' }; if (fuzzy) return { category: fuzzy, matchedBy: 'fuzzy' };
// 4. 兜底"其他" // 4. 兜底"其他"
const fallback = categories.find(c => c.name === '其他' || c.name === '其他') ?? null; const fallback = categories.find(c => c.name === '其他' || c.name === 'Other') ?? null;
return { category: fallback, matchedBy: fallback ? 'fallback' : 'none' }; return { category: fallback, matchedBy: fallback ? 'fallback' : 'none' };
} }

View File

@ -0,0 +1,82 @@
/**
* Beancount
*
* Alipay/WeChat/Bank Beancount Assets:支付宝余额
*
*
* plan.md 1
*/
export interface ChannelConfig {
id: string;
/** 渠道名(与 ImportedEvent.channel 对齐,如 "Alipay", "WeChat", "Bank")。 */
name: string;
/** 默认渠道账户Beancount 科目,如 "Assets:支付宝余额")。 */
defaultAccount: string;
/** Android 包名(用于自动识别,如 "com.eg.android.AlipayGphone")。 */
packageNames?: string[];
/** UI 图标(可选)。 */
icon?: string;
/** UI 颜色(可选)。 */
color?: string;
}
/** 内置渠道默认配置(不可删除,用户可修改 defaultAccount。 */
export const BUILTIN_CHANNELS: ChannelConfig[] = [
{
id: 'ch_alipay',
name: 'Alipay',
defaultAccount: 'Assets:支付宝余额',
packageNames: ['com.eg.android.AlipayGphone', 'com.eg.android.AlipayGphone.wallet'],
icon: 'alipay',
color: '#1677FF',
},
{
id: 'ch_wechat',
name: 'WeChat',
defaultAccount: 'Assets:微信零钱',
packageNames: ['com.tencent.mm', 'com.tencent.mobileqq'],
icon: 'wechat',
color: '#07C160',
},
{
id: 'ch_bank',
name: 'Bank',
defaultAccount: 'Assets:银行卡',
packageNames: ['com.unionpay', 'com.cmbchina', 'com.icbc'],
icon: 'bank',
color: '#E53935',
},
];
/**
*
*
*/
export function resolveChannelByPackage(
packageName: string,
channels: ChannelConfig[],
): ChannelConfig | undefined {
// 先查用户自定义渠道
for (const ch of channels) {
if (ch.packageNames?.includes(packageName)) return ch;
}
// 再查内置渠道
for (const ch of BUILTIN_CHANNELS) {
if (ch.packageNames?.includes(packageName)) return ch;
}
return undefined;
}
/**
*
*
*/
export function resolveChannelByName(
name: string,
channels: ChannelConfig[],
): ChannelConfig | undefined {
const lower = name.toLowerCase();
const match = (ch: ChannelConfig) => ch.name.toLowerCase() === lower;
return channels.find(match) ?? BUILTIN_CHANNELS.find(match);
}

86
src/domain/constants.ts Normal file
View File

@ -0,0 +1,86 @@
/**
*
*
*
* import 使
*/
import type { Direction } from './types';
// ─── 支付 App 包名 ───────────────────────────────────────────────
/** 包名 → 友好显示名(用于 UI 展示)。 */
export const PAYMENT_PACKAGES: Record<string, string> = {
'com.eg.android.AlipayGphone': '支付宝',
'com.tencent.mm': '微信',
'com.unionpay': '银联/云闪付',
'com.cmbchina': '招商银行',
'com.icbc': '工商银行',
'com.chinamworld.main': '中国银行',
'com.ccbrcb': '建设银行',
'com.bankcomm.Bankcomm': '交通银行',
'com.tencent.mobileqq': '手机QQ',
'com.tencent.tim': 'TIM',
};
/** 仅包名集合(用于快速查找)。 */
export const PAYMENT_PACKAGE_SET = new Set(Object.keys(PAYMENT_PACKAGES));
// ─── 方向检测 ─────────────────────────────────────────────────────
/**
* OCR / SMS / CSV
*
* 退 > > > >
*
*/
export function detectDirection(text: string, defaultDir: Direction = 'expense'): Direction {
if (/退款|退回/.test(text)) return 'refund';
if (/手续费|利息/.test(text)) return 'fee';
// 转入 = 钱到账户,视为收入(单边事件,不需要配对)
if (/转入/.test(text)) return 'income';
if (/收入|到账|入账/.test(text)) return 'income';
// 优化:别人转给我的单向转账,应视为收入(收款端的单边入账)
if (/向你转账|收到转账/.test(text)) return 'income';
if (/支出|付款|消费|转出/.test(text)) return 'expense';
// 「转账」方向不明确,标记为 transfer 供后续识别
if (/转账/.test(text)) return 'transfer';
// 「收款方」是商户标识,不判为收入;真正的收款是「向您收款」或独立「收款」
if (/收款方/.test(text)) return defaultDir;
if (/收款/.test(text)) return 'income';
return defaultDir;
}
// ─── 截屏检测 ─────────────────────────────────────────────────────
/** 截屏文件名关键词Kotlin 侧需同步)。 */
export const SCREENSHOT_KEYWORDS = ['screenshot', '截屏', '截图', 'screen_shot', 'Screenshot'];
/** 截屏时间窗口(毫秒),过滤旧截图。 */
export const SCREENSHOT_TIME_WINDOW_MS = 30_000;
// ─── 去重 ─────────────────────────────────────────────────────────
/** 去重缓存最大条目数LRU 淘汰)。 */
export const DEDUP_CACHE_MAX = 200;
/** OCR 图片哈希截取长度(取前 N 字符 hash 避免大图开销)。 */
export const IMAGE_HASH_TRUNCATE_LEN = 1000;
// ─── 短信关键词 ───────────────────────────────────────────────────
/** 银行短信关键词(判断是否为账单短信)。 */
export const SMS_BANK_KEYWORDS = ['交易', '消费', '收入', '支出', '余额', '转账', '入账', '扣款', '退款'];
// ─── 关键词过滤 ───────────────────────────────────────────────────
/** 关键词过滤白名单(必须包含任一关键词才通过)。 */
export const KEYWORD_FILTER_WHITELIST = ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'];
/** 关键词过滤黑名单(包含任一关键词则拒绝)。 */
export const KEYWORD_FILTER_BLACKLIST = ['广告', '推广', '优惠券', '红包封面', '会员到期', '活动'];
// ─── AI 聊天意图 ──────────────────────────────────────────────────
/** AI 聊天记账意图关键词。 */
export const CHAT_TRANSACTION_KEYWORDS = ['买', '花', '消费', '支付', '记账', '付', '收入', '赚', '工资', '收'];

View File

@ -1,7 +1,8 @@
/** Exact fixed-point helpers. Values are decimal strings, never JS numbers. */ /** Exact fixed-point helpers. Values are decimal strings, never JS numbers. */
const DECIMAL_RE = /^[+-]?\d+(\.\d+)?$/;
export function parseDecimal(value: string): { coefficient: bigint; scale: number } { export function parseDecimal(value: string): { coefficient: bigint; scale: number } {
const normalized = value.trim().replace(/,/g, ''); const normalized = value.trim().replace(/,/g, '');
if (!/^[+-]?\d+(\.\d+)?$/.test(normalized)) throw new Error(`Invalid decimal: ${value}`); if (!DECIMAL_RE.test(normalized)) throw new Error(`Invalid decimal: ${value}`);
const sign = normalized.startsWith('-') ? -1n : 1n; const sign = normalized.startsWith('-') ? -1n : 1n;
const plain = normalized.replace(/^[+-]/, ''); const plain = normalized.replace(/^[+-]/, '');
const [whole, fraction = ''] = plain.split('.'); const [whole, fraction = ''] = plain.split('.');

View File

@ -7,12 +7,11 @@ import type { ImportedEvent, Transaction } from './types';
* statements.ts deduplicate ID * statements.ts deduplicate ID
* + + OCR * + + OCR
* *
* AutoAccounting *
* 1. * 1.
* 2. AI AI * 2. AI AI
* 3. * 3. + +
* 4. + * 4. +
* 5. +
*/ */
export interface DedupConfig { export interface DedupConfig {
@ -41,27 +40,26 @@ export interface DedupResult {
export interface DedupComparable { export interface DedupComparable {
time: string; time: string;
amount: string; amount: string;
channel?: string;
counterparty?: string; counterparty?: string;
} }
/** 把 ImportedEvent 归一化为去重比较形状。 */ /** 把 ImportedEvent 归一化为去重比较形状。 */
export function toComparable(event: ImportedEvent): DedupComparable { export function toComparable(event: ImportedEvent): DedupComparable {
return { time: event.occurredAt, amount: event.amount, channel: event.channel, counterparty: event.counterparty }; return { time: event.occurredAt, amount: event.amount, counterparty: event.counterparty };
} }
/** 把传入项ImportedEvent 或 DedupComparable归一化为 DedupComparable。 */ /** 把传入项ImportedEvent 或 DedupComparable归一化为 DedupComparable。 */
function normalize(item: ImportedEvent | DedupComparable): DedupComparable { function normalize(item: ImportedEvent | DedupComparable): DedupComparable {
return 'occurredAt' in item return 'occurredAt' in item
? { time: item.occurredAt, amount: item.amount, channel: item.channel, counterparty: item.counterparty } ? { time: item.occurredAt, amount: item.amount, counterparty: item.counterparty }
: item; : item;
} }
/** 生成去重指纹(时间 + 金额 + 渠道 + 对手方)。 */ /** 生成去重指纹(时间 + 金额 + 对手方)。 */
export function dedupFingerprint(event: ImportedEvent): string { export function dedupFingerprint(event: ImportedEvent): string {
const date = event.occurredAt.slice(0, 16); const date = event.occurredAt.slice(0, 16);
const amount = Math.abs(parseFloat(event.amount)).toFixed(2); const amount = Math.abs(parseFloat(event.amount)).toFixed(2);
return `${date}|${amount}|${event.channel}|${event.counterparty}`; return `${date}|${amount}|${event.counterparty}`;
} }
/** /**
@ -73,46 +71,75 @@ export function checkDuplicate(
existing: (ImportedEvent | DedupComparable)[], existing: (ImportedEvent | DedupComparable)[],
config: DedupConfig = DEFAULT_DEDUP_CONFIG, config: DedupConfig = DEFAULT_DEDUP_CONFIG,
): DedupResult { ): DedupResult {
const eventTime = Date.parse(event.occurredAt);
const eventAmountAbs = Math.abs(parseFloat(event.amount)).toFixed(2); const eventAmountAbs = Math.abs(parseFloat(event.amount)).toFixed(2);
const eventSign = parseFloat(event.amount) >= 0 ? 1 : -1;
const maxDiff = config.timeWindowMinutes * 60 * 1000; const maxDiff = config.timeWindowMinutes * 60 * 1000;
const existingNorm = existing.map(normalize); const existingNorm = existing.map(normalize);
const eventTimeStr = event.occurredAt;
const hasTimeEvent = eventTimeStr.includes(':');
for (const ex of existingNorm) { for (const ex of existingNorm) {
const exTime = Date.parse(ex.time); const exTimeStr = ex.time;
const timeDiff = Math.abs(eventTime - exTime); const hasTimeEx = exTimeStr.includes(':');
// 金额正负号不同(例如一收一支),绝对不可能是重复交易 const isSameDay = eventTimeStr.substring(0, 10) === exTimeStr.substring(0, 10);
const eventSign = parseFloat(event.amount) >= 0 ? 1 : -1; const exAmountAbs = Math.abs(parseFloat(ex.amount)).toFixed(2);
const exSign = parseFloat(ex.amount) >= 0 ? 1 : -1; const exSign = parseFloat(ex.amount) >= 0 ? 1 : -1;
if (eventSign !== exSign) { if (eventSign !== exSign) {
continue; continue;
} }
if (!hasTimeEvent || !hasTimeEx) {
// 至少一方是日期粒度(例如历史账本数据)
if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
const sameCounterparty = event.counterparty && ex.counterparty &&
event.counterparty === ex.counterparty;
if (sameCounterparty) {
return {
isDuplicate: true,
confidence: 'medium',
reason: `同天发生、金额相同、对手方一致(${exTimeStr}`,
};
}
if (!event.counterparty && !ex.counterparty) {
return {
isDuplicate: true,
confidence: 'low',
reason: `同天发生、金额相同(${exTimeStr}`,
};
}
}
continue;
}
// 双方均有时分秒,按精确时间窗匹配
const eventTime = Date.parse(eventTimeStr);
const exTime = Date.parse(exTimeStr);
const timeDiff = Math.abs(eventTime - exTime);
// 时间完全相同 → 高置信度重复 // 时间完全相同 → 高置信度重复
if (timeDiff === 0 && compareDecimals(eventAmountAbs, Math.abs(parseFloat(ex.amount)).toFixed(2)) === 0) { if (timeDiff === 0 && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
return { return {
isDuplicate: true, isDuplicate: true,
confidence: 'high', confidence: 'high',
reason: `时间相同、金额一致(${ex.time}`, reason: `时间相同、金额一致(${exTimeStr}`,
}; };
} }
// 时间窗口内 + 金额匹配 // 时间窗口内 + 金额匹配
if (timeDiff <= maxDiff && compareDecimals(eventAmountAbs, Math.abs(parseFloat(ex.amount)).toFixed(2)) === 0) { if (timeDiff <= maxDiff && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
// 渠道检查:相同渠道相同金额(可能同人多次)→ 非重复;不同渠道 → 重复 // 有对手方且一致 → 中置信度重复
if (event.channel && ex.channel) { const sameCounterparty = event.counterparty && ex.counterparty &&
if (event.channel !== ex.channel) { event.counterparty === ex.counterparty;
if (sameCounterparty) {
return { return {
isDuplicate: true, isDuplicate: true,
confidence: 'medium', confidence: 'medium',
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、不同渠道(${event.channel} vs ${ex.channel}`, reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方一致`,
}; };
} }
// 相同渠道相同金额:可能是同一个人多次转账,不判重 // 无对手方信息或不一致 → 低置信度重复
continue;
}
// 无渠道信息:低置信度重复
return { return {
isDuplicate: true, isDuplicate: true,
confidence: 'low', confidence: 'low',
@ -161,6 +188,7 @@ export function dedupAgainstHistory(
const historyForCompare = history.map(t => ({ const historyForCompare = history.map(t => ({
time: t.date, time: t.date,
amount: t.postings[0]?.amount ?? '0', amount: t.postings[0]?.amount ?? '0',
counterparty: t.payee ?? '',
})); }));
for (const event of events) { for (const event of events) {

View File

@ -1,4 +1,5 @@
export * from './types'; export * from './types';
export * from './constants';
export * from './ledger'; export * from './ledger';
export * from './statements'; export * from './statements';
export * from './rules'; export * from './rules';

View File

@ -11,9 +11,11 @@ export interface KeywordFilter {
blacklist: string[]; blacklist: string[];
} }
import { KEYWORD_FILTER_WHITELIST, KEYWORD_FILTER_BLACKLIST } from './constants';
export const DEFAULT_FILTER: KeywordFilter = { export const DEFAULT_FILTER: KeywordFilter = {
whitelist: ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'], whitelist: KEYWORD_FILTER_WHITELIST,
blacklist: ['广告', '推广', '优惠券', '红包封面', '会员到期', '活动'], blacklist: KEYWORD_FILTER_BLACKLIST,
}; };
/** /**

View File

@ -36,7 +36,7 @@ const parseMetaLine = (line: string): [string, string] | null => {
return m ? [m[1], m[2]] : null; return m ? [m[1], m[2]] : null;
}; };
const SUPPORTED_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(open|close|balance|pad|note)\b/; const SUPPORTED_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(open|close|balance)\b/;
export function parseLedger(files: LedgerFile[]): LedgerIndex { export function parseLedger(files: LedgerFile[]): LedgerIndex {
const accounts = new Map<string, Account>(); const accounts = new Map<string, Account>();
@ -54,7 +54,7 @@ export function parseLedger(files: LedgerFile[]): LedgerIndex {
const trimmed = line.trim(); const trimmed = line.trim();
// 空行与注释跳过 // 空行与注释跳过
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('*')) { if (trimmed === '' || trimmed.startsWith(';')) {
index++; index++;
continue; continue;
} }
@ -185,17 +185,30 @@ export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex
const grouped = new Map<string, string[]>(); const grouped = new Map<string, string[]>();
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.date)) errors.push('日期必须为 YYYY-MM-DD'); if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.date)) errors.push('日期必须为 YYYY-MM-DD');
if (draft.postings.length < 2) errors.push('交易至少需要两条分录'); if (draft.postings.length < 2) errors.push('交易至少需要两条分录');
let emptyAmountCount = 0;
for (const posting of draft.postings) { for (const posting of draft.postings) {
const account = ledger.accounts.get(posting.account); const account = ledger.accounts.get(posting.account);
if (!account) errors.push(`账户未 open${posting.account}`); if (!account) errors.push(`账户未 open${posting.account}`);
else if (account.closedOn && draft.date > account.closedOn) errors.push(`账户已关闭:${posting.account}`); else if (account.closedOn && draft.date > account.closedOn) errors.push(`账户已关闭:${posting.account}`);
if (posting.amount && posting.currency) grouped.set(posting.currency, [...(grouped.get(posting.currency) ?? []), posting.amount]);
if (posting.amount && posting.currency) {
grouped.set(posting.currency, [...(grouped.get(posting.currency) ?? []), posting.amount]);
} else {
emptyAmountCount++;
} }
}
const balances: Record<string, string> = {}; const balances: Record<string, string> = {};
if (emptyAmountCount === 0) {
for (const [currency, amounts] of grouped) { for (const [currency, amounts] of grouped) {
balances[currency] = addDecimals(amounts); balances[currency] = addDecimals(amounts);
if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`); if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`);
} }
} else if (emptyAmountCount > 1) {
errors.push('交易中最多只能有一条分录省略金额');
}
return { valid: errors.length === 0, errors, balances }; return { valid: errors.length === 0, errors, balances };
} }

View File

@ -77,16 +77,21 @@ export class MobileBeanStore {
/** 串行化锁:所有写操作排队执行。 */ /** 串行化锁:所有写操作排队执行。 */
private chain: Promise<unknown> = Promise.resolve(); private chain: Promise<unknown> = Promise.resolve();
private initialized = false; private initialized = false;
private _recovered = false;
constructor(private readonly backend: MobileBeanBackend) {} constructor(private readonly backend: MobileBeanBackend) {}
/** 启动时调用:恢复崩溃残留 + 加载当前内容。 */ /** 启动时调用:恢复崩溃残留 + 加载当前内容。 */
async init(): Promise<void> { async init(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
const hadResidue = await this.backend.recoverIfNeeded(); this._recovered = await this.backend.recoverIfNeeded();
this.current = await this.backend.read(); this.current = await this.backend.read();
this.initialized = true; this.initialized = true;
return void hadResidue; // 调用方可通过 recoverIfNeeded 的返回值感知 }
/** 上次 init 是否检测到崩溃残留并恢复。 */
get recovered(): boolean {
return this._recovered;
} }
/** 当前内存中的 mobile.bean 内容init 后可用)。 */ /** 当前内存中的 mobile.bean 内容init 后可用)。 */

View File

@ -5,6 +5,7 @@
* OCR ImportedEvent * OCR ImportedEvent
*/ */
import { detectDirection, PAYMENT_PACKAGE_SET } from './constants';
import type { Direction, ImportedEvent } from './types'; import type { Direction, ImportedEvent } from './types';
/** 规则匹配结果。 */ /** 规则匹配结果。 */
@ -27,8 +28,6 @@ interface OcrRule {
card?: RegExp; card?: RegExp;
direction?: RegExp; direction?: RegExp;
}; };
/** 渠道名。 */
channel: string;
} }
// 参考 AutoAccounting RuleMatcher 的正则模式 // 参考 AutoAccounting RuleMatcher 的正则模式
@ -37,52 +36,35 @@ const RULES: OcrRule[] = [
name: '微信支付', name: '微信支付',
pattern: /微信支付/, pattern: /微信支付/,
extractors: { extractors: {
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/, amount: /(?:金额|支付金额|¥|¥)\s*(-?[\d,]+(?:\.\d+)?)/,
merchant: /(?:商户|收款方|对方)\s*[:]?\s*([^\s,,。]+)/, merchant: /(?:商户|收款方|对方)\s*[:]?\s*([^\n,,。]+?)(?=\s*(?:金额|支付金额|¥|¥|元|支出|收入|付款|收款|退款|消费|转账|尾号|\d{4}[-/年]|\d{1,2}:)|$)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/, time: /((?:\d{4}[-/])?\d{1,2}[-/]\d{1,2}[日]?[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
direction: /(收入|支出|收款|付款|退款|转账|消费)/, direction: /(收入|支出|收款|付款|退款|转账|消费)/,
}, },
channel: 'WeChat',
}, },
{ {
name: '支付宝', name: '支付宝',
pattern: /支付宝|alipay/i, pattern: /支付宝|alipay/i,
extractors: { extractors: {
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/, amount: /(?:金额|支付金额|¥|¥)\s*(-?[\d,]+(?:\.\d+)?)/,
merchant: /(?:收款方|对方|商户)\s*[:]?\s*([^\s,,。]+)/, merchant: /(?:收款方|对方|商户)\s*[:]?\s*([^\n,,。]+?)(?=\s*(?:金额|支付金额|¥|¥|元|支出|收入|付款|收款|退款|消费|转账|尾号|\d{4}[-/年]|\d{1,2}:)|$)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/, time: /((?:\d{4}[-/])?\d{1,2}[-/]\d{1,2}[日]?[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
direction: /(收入|支出|收款|付款|退款|转账|消费)/, direction: /(收入|支出|收款|付款|退款|转账|消费)/,
}, },
channel: 'Alipay',
}, },
{ {
name: '银行卡', name: '银行卡',
pattern: /尾号\d{4}|信用卡|储蓄卡|借记卡/, pattern: /尾号\d{4}|信用卡|储蓄卡|借记卡/,
extractors: { extractors: {
amount: /(?:消费|支出|金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/, amount: /(?:消费|支出|金额|¥|¥)\s*(-?[\d,]+(?:\.\d+)?)/,
merchant: /(?:商户|对方|于)\s*[:]?\s*([^\s,,。]+)/, merchant: /(?:商户|对方|于)\s*[:]?\s*([^\n,,。]+?)(?=\s*(?:金额|支付金额|¥|¥|元|支出|收入|付款|收款|退款|消费|转账|尾号|\d{4}[-/年]|\d{1,2}:)|$)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/, time: /((?:\d{4}[-/])?\d{1,2}[-/]\d{1,2}[日]?[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
card: /尾号\s*(\d{4})/, card: /尾号\s*(\d{4})/,
direction: /(消费|支出|收入|转入|转出|退款|转账)/, direction: /(消费|支出|收入|转入|转出|退款|转账)/,
}, },
channel: 'Bank',
}, },
]; ];
function parseDirection(text: string, defaultDir: Direction = 'expense'): Direction {
// 优先级:退款 > 手续费 > 转账 > 支出 > 收入
// 注意:避免「收款方」中的「收款」被误判为收入,需检查完整词
if (/退款|退回/.test(text)) return 'refund';
if (/手续费|利息/.test(text)) return 'fee';
if (/支出|付款|消费|转出/.test(text)) return 'expense';
if (/转账|转入/.test(text)) return 'transfer';
if (/收入|到账|入账/.test(text)) return 'income';
// 「收款方」是商户标识,不判为收入;真正的收款是「向您收款」或独立「收款」
if (/收款方/.test(text)) return defaultDir; // 收款方是商户,保持默认
if (/收款/.test(text) && !/收款方/.test(text)) return 'income';
return defaultDir;
}
function extract(pattern: RegExp | undefined, text: string): string { function extract(pattern: RegExp | undefined, text: string): string {
if (!pattern) return ''; if (!pattern) return '';
return pattern.exec(text)?.[1] ?? ''; return pattern.exec(text)?.[1] ?? '';
@ -99,22 +81,21 @@ export function matchOcrRule(text: string, packageName?: string): OcrRuleMatch {
const amountRaw = extract(rule.extractors.amount, text); const amountRaw = extract(rule.extractors.amount, text);
if (!amountRaw) continue; // 无金额不算账单 if (!amountRaw) continue; // 无金额不算账单
const merchant = extract(rule.extractors.merchant, text); const merchant = extract(rule.extractors.merchant, text).trim();
const time = extract(rule.extractors.time, text); const time = extract(rule.extractors.time, text);
const card = extract(rule.extractors.card, text); const card = extract(rule.extractors.card, text);
const dirText = extract(rule.extractors.direction, text) || text; const direction = detectDirection(text);
// 方向解析用完整文本(避免「收款方」中的「收款」被误判)
const direction = parseDirection(text);
const amount = direction === 'expense' ? `-${Math.abs(parseFloat(amountRaw))}` : String(Math.abs(parseFloat(amountRaw))); // 过滤掉千分位逗号并解析金额
const parsedAmount = Math.abs(parseFloat(amountRaw.replace(/,/g, '')));
const amount = direction === 'expense' ? `-${parsedAmount}` : String(parsedAmount);
const event: ImportedEvent = { const event: ImportedEvent = {
id: `ocr:${rule.channel}:${time || Date.now()}:${amountRaw}`, id: `ocr:${rule.name}:${time || Date.now()}:${amountRaw}`,
occurredAt: time ? normalizeTime(time) : new Date().toISOString().slice(0, 16), occurredAt: time ? normalizeTime(time) : new Date().toISOString().slice(0, 16),
amount, amount,
currency: 'CNY', currency: 'CNY',
direction, direction,
channel: card ? `${rule.channel}:${card}` : rule.channel,
counterparty: merchant, counterparty: merchant,
memo: card ? `银行卡尾号${card}` : rule.name, memo: card ? `银行卡尾号${card}` : rule.name,
raw: { text, packageName: packageName ?? '' }, raw: { text, packageName: packageName ?? '' },
@ -124,9 +105,174 @@ export function matchOcrRule(text: string, packageName?: string): OcrRuleMatch {
return { matched: false, event: null, ruleName: '未匹配' }; return { matched: false, event: null, ruleName: '未匹配' };
} }
/** 规范化时间格式(支持 2026-07-13 14:30 / 2026/7/13 14:30)。 */ /** 规范化时间格式(支持 2026-07-13 14:30 / 2026/7/13 14:30 / 2026年7月13日 14:30 / 7-13 14:30 缺失年份)。 */
function normalizeTime(time: string): string { function normalizeTime(time: string): string {
return time.replace(/\//g, '-').replace(/-(\d)-/g, '-0$1-').replace(/-(\d) /, '-0$1 ').trim(); let normalized = time
.replace(/[年]/g, '-')
.replace(/[月]/g, '-')
.replace(/[日]/g, '')
.replace(/\//g, '-')
.trim();
// 处理缺失年份的情况 (例如 "07-13 14:30" 变为 "2026-07-13 14:30")
if (/^\d{1,2}-\d{1,2}/.test(normalized)) {
const year = new Date().getFullYear();
normalized = `${year}-${normalized}`;
}
// 规范化月份和日期 (例如 2026-7-13 -> 2026-07-13)
return normalized
.replace(/-(\d)-/g, '-0$1-')
.replace(/-(\d) /, '-0$1 ')
.trim();
}
/**
* /
*
* | | -8.51 |
* / 退
*/
const DETAIL_PAGE_LABELS = [
'付款方式', '支付时间', '交易时间', '创建时间', '完成时间',
'交易成功', '交易关闭', '支付成功', '退款成功', '待发货', '已发货',
'商品说明', '商品名称', '订单号', '商家订单号', '收款方全称', '收款万全称',
'账单分类', '支付奖励', '计入收支', '退款单号', '物流单号',
];
/** 详情页判定阈值:命中特征词数 ≥ 此值才认为是账单详情页。 */
const DETAIL_PAGE_MIN_HITS = 2;
/** 判断是否为账单详情页(特征词命中数 ≥ 2。 */
export function checkIsDetailPage(text: string): boolean {
const hits = DETAIL_PAGE_LABELS.reduce((n, label) => text.includes(label) ? n + 1 : n, 0);
return hits >= DETAIL_PAGE_MIN_HITS;
}
/**
*
*
* //
* 1. XX.XX OCR - , _
* 2. +7540
*/
function extractDetailPageAmount(text: string): string {
const segments = text.split(/[|\n]/).map(s => s.trim());
for (const seg of segments) {
if (/\d{4}[-./]\d{1,2}[-./]\d{1,2}/.test(seg)) continue; // 日期行
if (/\d{1,2}:\d{2}/.test(seg)) continue; // 时间行
if (/\d{12,}/.test(seg)) continue; // 长订单号
// OCR 纠错:将形如 898-34、898,34、898_34 等带错识的分隔符的金额段规范化为带小数点的格式
const normalized = seg.replace(/(\d+)[-—_~,.\s]+(\d{2})$/, '$1.$2');
// 1. 小数金额正则匹配(带符号或不带符号,如 898.34 或 -0.58,支持千分位点号如 1.898.34
const mDec = normalized.match(/^([+-]?[\d,.]+\.\d{2})$/);
if (mDec) return mDec[1];
// 2. 整数金额正则匹配(必须带有正负号,如 +7540 或 -50以避免误抓年份、卡尾号等
const mInt = normalized.match(/^([+-][\d,]+)$/);
if (mInt) return mInt[1];
}
return '';
}
/** 规范化详情页时间(支持 2026-07-15.09:01:18、2026年7月13日20:29:46 等各种常见时间格式)。 */
function normalizeDetailTime(raw: string): string {
let s = raw
.replace(/年/g, '-')
.replace(/月/g, '-')
.replace(/日\s*/g, ' ') // 将「日」或带空格的「日 」合并规范化为一个空格
.replace(/\//g, '-')
.trim();
// 如果没有前导四位数字年份(如 "07-15 20:13:14"),补上当前年份
if (/^\d{1,2}-\d{1,2}/.test(s) && !/^\d{4}/.test(s)) {
const year = new Date().getFullYear();
s = `${year}-${s}`;
}
// 针对可能缺失空格的 "YYYY-MM-DDHH:mm" 进行修复,如 "2026-7-1320:29:46" -> "2026-7-13 20:29:46"
s = s.replace(/^(\d{4}-\d{1,2}-\d{1,2})(\d{2}:\d{2}(?::\d{2})?)$/, '$1 $2');
// 将连接符/换行符/T 等换成统一空格,例如 2026-07-15.09:01:18 → 2026-07-15 09:01:18
s = s.replace(/^(\d{4}-\d{1,2}-\d{1,2})[.T\s]+(\d{1,2}:\d{2})(?::\d{2})?.*$/, '$1 $2');
// 补零逻辑:例如 2026-7-13 20:29 -> 2026-07-13 20:29
const m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})/);
if (m) {
const year = m[1];
const month = m[2].padStart(2, '0');
const day = m[3].padStart(2, '0');
const hour = m[4].padStart(2, '0');
const min = m[5];
return `${year}-${month}-${day} ${hour}:${min}`;
}
return s;
}
/**
* /
*
* 退
* / + /
*
* @returns ImportedEvent null退
*/
function parseDetailPageBill(text: string, packageName?: string): ImportedEvent | null {
// 特征词计数判定
const hits = DETAIL_PAGE_LABELS.reduce((n, label) => text.includes(label) ? n + 1 : n, 0);
if (hits < DETAIL_PAGE_MIN_HITS) return null;
const amountRaw = extractDetailPageAmount(text);
if (!amountRaw) return null;
// 提取付款方式(供 resolveSourceAccount 解析资金来源账户)
// 分隔符支持 |、:、:、空格(详情页标签与值之间可能是任意一种)
const method = extract(/付款方式\s*[|:\s]\s*([^\s|,]+)/, text);
// 提取支付时间
const timeRaw = extract(/(?:支付时间|交易时间|创建时间|完成时间)\s*[|:\s]\s*([^\s|,]+)/, text);
const occurredAt = timeRaw ? normalizeDetailTime(timeRaw) : new Date().toISOString().slice(0, 16);
// 提取商品说明/商户名称(供分类匹配),支持带空格的商户/商品说明
const subjectRaw = extract(/(?:商品说明|商品名称|商品)\s*[|:\s]\s*([^\n|,]+)/, text).trim();
const payeeRaw = extract(/(?:对方账户|收款全称|收款商户|收款人|收款账户|商户全称|收款方|商户)\s*[|:\s]\s*([^\n|,]+)/, text).trim();
let counterparty = subjectRaw || payeeRaw;
if (counterparty) {
// 清理 -订单编号xxxxxxxx 等交易流水号后缀,保留纯净商户名
counterparty = counterparty.replace(/-(?:订单编号|订单号|交易号|流水号|订单|\d+).*$/i, '').trim();
}
// 金额符号决定方向:负号=支出,正号=收入/退款;无符号按 detectDirection 推断
const hasSign = /^[+-]/.test(amountRaw);
const direction: Direction = hasSign
? (amountRaw.startsWith('-') ? 'expense' : 'income')
: detectDirection(text);
let absAmount = amountRaw.replace(/^[+-]/, '');
const lastDotIdx = absAmount.lastIndexOf('.');
if (lastDotIdx !== -1) {
const integerPart = absAmount.slice(0, lastDotIdx).replace(/[,.]/g, '');
const decimalPart = absAmount.slice(lastDotIdx + 1);
absAmount = `${integerPart}.${decimalPart}`;
} else {
absAmount = absAmount.replace(/[,.]/g, '');
}
const raw: Record<string, string> = { text, packageName: packageName ?? '' };
if (method) raw['收/付款方式'] = method;
return {
id: `ocr:detail:${Date.now()}:${absAmount}`,
occurredAt,
amount: direction === 'expense' || direction === 'fee' ? `-${absAmount}` : absAmount,
currency: 'CNY',
direction,
counterparty: counterparty || '',
memo: method ? `付款方式:${method}` : '账单详情页',
raw,
};
} }
/** /**
@ -134,23 +280,32 @@ function normalizeTime(time: string): string {
* RuleMatcher * RuleMatcher
*/ */
export function parseOcrBill(text: string, packageName?: string): ImportedEvent | null { export function parseOcrBill(text: string, packageName?: string): ImportedEvent | null {
// 1. 先尝试规则匹配 // 1. 先尝试规则匹配(支付弹窗等已知格式)
const ruleMatch = matchOcrRule(text, packageName); const ruleMatch = matchOcrRule(text, packageName);
if (ruleMatch.matched && ruleMatch.event) return ruleMatch.event; if (ruleMatch.matched && ruleMatch.event) return ruleMatch.event;
// 2. 退化:提取首个金额 + 推断方向 // 2. 账单详情页(结构化标签布局,金额裸露无 ¥/元 前缀)
const amountMatch = text.match(/(?:¥|¥|金额|支付金额)\s*(\d+(?:\.\d+)?)/) ?? text.match(/(\d+(?:\.\d+)?)\s*(?:元)/); const detail = parseDetailPageBill(text, packageName);
if (detail) return detail;
// 如果是在内建支付应用(如支付宝/微信等)中截图,未匹配上规则和结构化详情页,
// 则说明大概率是交易列表页、搜索页、理财页等,直接拦截防止退化解析误匹配
if (packageName && PAYMENT_PACKAGE_SET.has(packageName)) {
return null;
}
// 3. 退化:提取首个金额 + 推断方向,支持千分位逗号
const amountMatch = text.match(/(?:¥|¥|金额|支付金额)\s*([\d,]+(?:\.\d+)?)/) ?? text.match(/([\d,]+(?:\.\d+)?)\s*(?:元)/);
if (!amountMatch) return null; if (!amountMatch) return null;
const amount = parseFloat(amountMatch[1]); const amount = parseFloat(amountMatch[1].replace(/,/g, ''));
const direction = parseDirection(text); const direction = detectDirection(text);
return { return {
id: `ocr:fallback:${Date.now()}:${amountMatch[1]}`, id: `ocr:fallback:${Date.now()}:${amountMatch[1]}`,
occurredAt: new Date().toISOString().slice(0, 16), occurredAt: new Date().toISOString().slice(0, 16),
amount: direction === 'expense' ? `-${amount}` : String(amount), amount: direction === 'expense' ? `-${amount}` : String(amount),
currency: 'CNY', currency: 'CNY',
direction, direction,
channel: packageName ?? 'Unknown',
counterparty: '', counterparty: '',
memo: 'OCR 退化解析', memo: 'OCR 退化解析',
raw: { text, packageName: packageName ?? '' }, raw: { text, packageName: packageName ?? '' },

View File

@ -8,7 +8,8 @@
* OCR AI Provider * OCR AI Provider
*/ */
import { matchOcrRule, parseOcrBill } from './ocr'; import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants';
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
import { hash } from './ledger'; import { hash } from './ledger';
import type { ImportedEvent } from './types'; import type { ImportedEvent } from './types';
@ -33,7 +34,7 @@ export interface OcrProcessResult {
/** OCR 原始文本Layer 2 命中时有值)。 */ /** OCR 原始文本Layer 2 命中时有值)。 */
ocrText?: string; ocrText?: string;
/** 跳过原因(去重/免打扰等)。 */ /** 跳过原因(去重/免打扰等)。 */
skipped?: 'dedup' | 'landscape-dnd' | 'non-bill'; skipped?: 'dedup' | 'busy' | 'landscape-dnd' | 'non-bill';
} }
export interface OcrProcessorConfig { export interface OcrProcessorConfig {
@ -73,37 +74,42 @@ export class OcrProcessor {
} }
// 去重守卫:同一图片不重复处理 // 去重守卫:同一图片不重复处理
const imageHash = hash(imageBase64.slice(0, 1000)); // 取前 1000 字符 hash 避免大图开销 const imageHash = hash(imageBase64.slice(0, IMAGE_HASH_TRUNCATE_LEN));
if (this.processedHashes.has(imageHash)) { if (this.processedHashes.has(imageHash)) {
return { event: null, layer: 'none', skipped: 'dedup' }; return { event: null, layer: 'none', skipped: 'dedup' };
} }
// 串行化ocrDoing // 串行化ocrDoing
if (this.processing) { if (this.processing) {
return { event: null, layer: 'none', skipped: 'dedup' }; return { event: null, layer: 'none', skipped: 'busy' };
} }
this.processing = true; this.processing = true;
try { try {
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
// OCR 成功后才标记已处理,避免异常时永久阻断重试
this.processedHashes.add(imageHash); this.processedHashes.add(imageHash);
// 限制 hash 集合大小 if (this.processedHashes.size > DEDUP_CACHE_MAX) {
if (this.processedHashes.size > 200) {
const first = this.processedHashes.values().next().value; const first = this.processedHashes.values().next().value;
if (first) this.processedHashes.delete(first); if (first) this.processedHashes.delete(first);
} }
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
if (!ocrText || !ocrText.trim()) { if (!ocrText || !ocrText.trim()) {
// 无文本,尝试 Layer 3 // 无文本,尝试 Layer 3
return await this.tryLayer3(imageBase64, undefined); return await this.tryLayer3(imageBase64, undefined);
} }
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配,
// 以避免“银行卡”等规则在详情页上误提取例如误将“消费1次”提取为“-1元”
const isDetailPage = checkIsDetailPage(ocrText);
if (!isDetailPage) {
// Layer 1: 规则匹配(基于 OCR 文本) // Layer 1: 规则匹配(基于 OCR 文本)
const ruleMatch = matchOcrRule(ocrText, packageName); const ruleMatch = matchOcrRule(ocrText, packageName);
if (ruleMatch.matched && ruleMatch.event) { if (ruleMatch.matched && ruleMatch.event) {
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText }; return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
} }
}
// Layer 2: 退化解析 // Layer 2: 退化解析
const fallback = parseOcrBill(ocrText, packageName); const fallback = parseOcrBill(ocrText, packageName);

View File

@ -0,0 +1,10 @@
/**
* BillPipeline
*
* importStore automationStore BillPipeline
*
*/
import { BillPipeline } from './billPipeline';
export const sharedPipeline = new BillPipeline();

View File

@ -19,10 +19,8 @@ const PLACEHOLDERS: Record<string, (e: ImportedEvent) => string> = {
'【金额】': (e) => e.amount, '【金额】': (e) => e.amount,
'【分类】': (e) => e.raw?.['category'] || '', // 分类(需预填) '【分类】': (e) => e.raw?.['category'] || '', // 分类(需预填)
'【账本】': (e) => e.raw?.['bookName'] || '', // 账本名 '【账本】': (e) => e.raw?.['bookName'] || '', // 账本名
'【来源】': (e) => e.channel || '', // 数据来源/渠道
'【原始资产】': (e) => e.raw?.['accountFrom'] || '', // 原始资产账户 '【原始资产】': (e) => e.raw?.['accountFrom'] || '', // 原始资产账户
'【目标资产】': (e) => e.raw?.['accountTo'] || '', // 目标资产账户 '【目标资产】': (e) => e.raw?.['accountTo'] || '', // 目标资产账户
'【渠道】': (e) => e.channel || '',
'【规则名称】': (e) => e.raw?.['ruleName'] || '', // 命中规则名 '【规则名称】': (e) => e.raw?.['ruleName'] || '', // 命中规则名
'【AI】': (e) => e.raw?.['aiSuggestion'] || '', // AI 建议备注 '【AI】': (e) => e.raw?.['aiSuggestion'] || '', // AI 建议备注
'【货币类型】': (e) => e.currency || 'CNY', '【货币类型】': (e) => e.currency || 'CNY',

View File

@ -19,7 +19,6 @@ export interface EnhancedRule {
enabled: boolean; enabled: boolean;
isSystem: boolean; // 系统规则 vs 用户规则 isSystem: boolean; // 系统规则 vs 用户规则
// 静态匹配条件(快速预筛选) // 静态匹配条件(快速预筛选)
channel?: string;
direction?: ImportedEvent['direction']; direction?: ImportedEvent['direction'];
counterpartyContains?: string; counterpartyContains?: string;
memoContains?: string; memoContains?: string;
@ -29,7 +28,7 @@ export interface EnhancedRule {
// JS 规则体(动态精确匹配 + 字段提取) // JS 规则体(动态精确匹配 + 字段提取)
jsCode?: string; jsCode?: string;
// 输出 // 输出
channelAccount: string; sourceAccount: string;
categoryAccount: string; categoryAccount: string;
narration?: string; narration?: string;
tags?: string[]; tags?: string[];
@ -116,8 +115,7 @@ export class RuleEngine {
/** 静态条件预筛选(参考 rules.ts 的 matches。 */ /** 静态条件预筛选(参考 rules.ts 的 matches。 */
private matchStaticConditions(rule: EnhancedRule, event: ImportedEvent): boolean { private matchStaticConditions(rule: EnhancedRule, event: ImportedEvent): boolean {
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase()); const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
return (!rule.channel || rule.channel === event.channel) return (!rule.direction || rule.direction === event.direction)
&& (!rule.direction || rule.direction === event.direction)
&& (!rule.currency || rule.currency === event.currency) && (!rule.currency || rule.currency === event.currency)
&& contains(event.counterparty, rule.counterpartyContains) && contains(event.counterparty, rule.counterpartyContains)
&& contains(event.memo, rule.memoContains) && contains(event.memo, rule.memoContains)
@ -128,7 +126,7 @@ export class RuleEngine {
/** 把 ImportedEvent 转为规则执行数据。 */ /** 把 ImportedEvent 转为规则执行数据。 */
private eventToData(event: ImportedEvent): Record<string, unknown> { private eventToData(event: ImportedEvent): Record<string, unknown> {
return { return {
app: event.channel, app: event.raw?.packageName || '',
type: event.direction, type: event.direction,
money: event.amount, money: event.amount,
shopName: event.counterparty, shopName: event.counterparty,

View File

@ -17,9 +17,10 @@ function getNarration(ruleNarration: string | undefined, event: ImportedEvent):
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase()); const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
function matches(event: ImportedEvent, rule: Rule): boolean { function matches(event: ImportedEvent, rule: Rule): boolean {
return (!rule.channel || rule.channel === event.channel) && (!rule.direction || rule.direction === event.direction) && const absAmount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
return (!rule.direction || rule.direction === event.direction) &&
(!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) && (!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) &&
(!rule.minAmount || compareDecimals(event.amount.replace('-', ''), rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(event.amount.replace('-', ''), rule.maxAmount) <= 0); (!rule.minAmount || compareDecimals(absAmount, rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(absAmount, rule.maxAmount) <= 0);
} }
function first(row: Record<string, string>, keys: string[]): string { function first(row: Record<string, string>, keys: string[]): string {
for (const k of keys) { for (const k of keys) {
@ -40,13 +41,18 @@ function sanitizeAccountSegment(name: string): string {
return cleaned.trim(); return cleaned.trim();
} }
function resolveChannelAccount(event: ImportedEvent, rule?: Rule): string { export function resolveSourceAccount(
if (rule?.channelAccount) return rule.channelAccount; event: ImportedEvent,
rule?: Rule,
): string {
// 1. 规则指定的账户(最高优先级)
if (rule?.sourceAccount) return rule.sourceAccount;
// 2. raw 数据中的支付方式(如 "招商银行(2586)" → Assets:招商银行-2586
const raw = event.raw || {}; const raw = event.raw || {};
let method = first(raw, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']) || ''; let method = first(raw, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']) || '';
if (method) { if (method) {
// 拆分 & 符号,仅保留首个主要的支付通道,过滤掉红包、优惠券等促销后缀
method = method.split('&')[0].trim(); method = method.split('&')[0].trim();
if (method === '零钱') { if (method === '零钱') {
@ -65,26 +71,8 @@ function resolveChannelAccount(event: ImportedEvent, rule?: Rule): string {
} }
} }
if (event.channel === 'Alipay') { // 3. 兜底
return 'Assets:支付宝余额'; return 'Assets:Unknown';
}
if (event.channel === 'WeChat') {
return 'Assets:微信零钱';
}
return `Assets:${event.channel}`;
}
export function classify(event: ImportedEvent, rules: Rule[], ledger: LedgerIndex): Classification {
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item));
const channelAccount = resolveChannelAccount(event, rule);
const categoryAccount = rule?.categoryAccount ?? (event.direction === 'income' ? 'Income:未分类' : 'Expenses:未分类');
const known = ledger.accounts.has(channelAccount) && ledger.accounts.has(categoryAccount);
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(channelAmount);
const narration = getNarration(rule?.narration, event);
const draft: TransactionDraft = { date: event.occurredAt.slice(0, 10), payee: event.counterparty || undefined, narration, tags: rule?.tags, sourceEventId: event.id, postings: [{ account: channelAccount, amount: channelAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency }] };
return { draft, ruleId: rule?.id, confidence: rule && known ? 'high' : 'low' };
} }
/** /**
@ -107,20 +95,21 @@ export function classifyWithCategories(
ledger: LedgerIndex, ledger: LedgerIndex,
): Classification & { categoryId?: string; categoryFallbackReason?: string } { ): Classification & { categoryId?: string; categoryFallbackReason?: string } {
// 1. 先尝试规则匹配 // 1. 先尝试规则匹配
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item)); const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const rule = sorted.find(item => matches(event, item));
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount; const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount); const sourceAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(channelAmount); const categoryAmount = negateDecimal(sourceAmount);
// 渠道账户(与 classify 一致) // 资金来源账户(与 classify 一致)
const channelAccountRaw = resolveChannelAccount(event, rule); const sourceAccountRaw = resolveSourceAccount(event, rule);
const channelValid = ledger.accounts.has(channelAccountRaw); const sourceValid = ledger.accounts.has(sourceAccountRaw);
// 分类账户:规则优先,否则用分类库匹配 // 分类账户:规则优先,否则用分类库匹配
let categoryAccount: string; let categoryAccount = '';
let categoryId: string | undefined; let categoryId: string | undefined;
let categoryFallbackReason: string | undefined; let categoryFallbackReason: string | undefined;
let categoryValid: boolean; let categoryValid = false;
if (rule) { if (rule) {
// 规则命中:直接用规则的 categoryAccount不降级 // 规则命中:直接用规则的 categoryAccount不降级
@ -131,7 +120,30 @@ export function classifyWithCategories(
} }
} else { } else {
// 规则未命中:用分类库匹配 // 规则未命中:用分类库匹配
const type: 'income' | 'expense' = (event.direction === 'income') ? 'income' : 'expense'; // refund 视为 income退款是资金回流fee/transfer 视为 expense
const isIncome = event.direction === 'income' || event.direction === 'refund';
const type: 'income' | 'expense' = isIncome ? 'income' : 'expense';
// 智能还款识别(花呗还款/信用卡还款属于负债偿还,目标账户为 Liabilities
const fullText = [event.raw?.text, event.counterparty, event.memo].filter(Boolean).join(' ');
let isRepayment = false;
if (/还款/.test(fullText)) {
if (/花呗/.test(fullText)) {
categoryAccount = 'Liabilities:支付宝花呗';
categoryValid = true;
isRepayment = true;
} else {
const cardMatch = fullText.match(/([^\s|,]+信用卡(?:\d+|\(\d+\))?)/);
if (cardMatch) {
const cardName = sanitizeAccountSegment(cardMatch[1]);
categoryAccount = `Liabilities:${cardName}`;
categoryValid = true;
isRepayment = true;
}
}
}
if (!isRepayment) {
const candidates = categories.filter(c => c.type === type); const candidates = categories.filter(c => c.type === type);
// 匹配文本综合对手方 + 备注(关键词可能在任一字段) // 匹配文本综合对手方 + 备注(关键词可能在任一字段)
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' '); const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
@ -142,6 +154,7 @@ export function classifyWithCategories(
categoryId = match.category?.id; categoryId = match.category?.id;
if (!resolved.valid) categoryFallbackReason = resolved.fallbackReason; if (!resolved.valid) categoryFallbackReason = resolved.fallbackReason;
} }
}
const narration = getNarration(rule?.narration, event); const narration = getNarration(rule?.narration, event);
const draft: TransactionDraft = { const draft: TransactionDraft = {
@ -151,12 +164,12 @@ export function classifyWithCategories(
tags: rule?.tags, tags: rule?.tags,
sourceEventId: event.id, sourceEventId: event.id,
postings: [ postings: [
{ account: channelAccountRaw, amount: channelAmount, currency: event.currency }, { account: sourceAccountRaw, amount: sourceAmount, currency: event.currency },
{ account: categoryAccount, amount: categoryAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency },
], ],
}; };
const known = channelValid && categoryValid; const known = sourceValid && categoryValid;
return { return {
draft, draft,
ruleId: rule?.id, ruleId: rule?.id,

View File

@ -1,3 +1,4 @@
import { detectDirection } from './constants';
import { compareDecimals, negateDecimal } from './decimal'; import { compareDecimals, negateDecimal } from './decimal';
import type { Direction, ImportedEvent } from './types'; import type { Direction, ImportedEvent } from './types';
@ -14,13 +15,16 @@ function parseCsv(content: string): Row[] {
const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
}).filter(row => Object.values(row).some(Boolean)); }).filter(row => Object.values(row).some(Boolean));
} }
/** CSV 方向判定:先尝试统一方向检测(组合 flow+txType再回退到金额符号与 flow 关键词。 */
function direction(flow: string, txType: string, amount: string): Direction { function direction(flow: string, txType: string, amount: string): Direction {
const combined = `${flow}|${txType}`; const combined = `${flow}|${txType}`;
if (/转账|转入|转出/i.test(combined)) return 'transfer'; if (/退款/i.test(combined)) return 'refund'; if (/手续费/i.test(combined)) return 'fee'; const detected = detectDirection(combined, 'income');
if (detected !== 'income') return detected;
// CSV 特有回退:金额为负 或 flow 含支出关键词 → expense
return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(flow) ? 'expense' : 'income'; return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(flow) ? 'expense' : 'income';
} }
export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] { export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] {
const rows = parseCsv(content); const channel = adapter.startsWith('alipay') ? 'Alipay' : adapter.startsWith('wechat') ? 'WeChat' : 'Bank'; const rows = parseCsv(content);
return rows.map((row, index) => { return rows.map((row, index) => {
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, ''); let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const flow = first(row, ['收/支', '收支类型', '资金方向', 'Type', 'type']); const flow = first(row, ['收/支', '收支类型', '资金方向', 'Type', 'type']);
@ -35,10 +39,10 @@ export function importStatement(content: string, adapter: StatementAdapter): Imp
const method = first(row, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']); const method = first(row, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']);
const memo = [category, rawMemo, method ? `[${method}]` : ''].filter(s => s && s.trim() !== '').join(' - '); const memo = [category, rawMemo, method ? `[${method}]` : ''].filter(s => s && s.trim() !== '').join(' - ');
const eventDirection = direction(flow, txType, amount); const eventDirection = direction(flow, txType, amount);
const id = externalId ? `${channel}:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|')); const id = externalId ? `csv:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|'));
const isDateValid = /^\d{4}-\d{2}-\d{2}/.test(occurredAt); const isDateValid = /^\d{4}-\d{2}-\d{2}/.test(occurredAt);
const finalOccurredAt = isDateValid ? occurredAt : new Date().toISOString().slice(0, 19).replace('T', ' '); const finalOccurredAt = isDateValid ? occurredAt : new Date().toISOString().slice(0, 19).replace('T', ' ');
return { id, occurredAt: finalOccurredAt, amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, channel, counterparty, memo, externalId: externalId || undefined, raw: row }; return { id, occurredAt: finalOccurredAt, amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, counterparty, memo, externalId: externalId || undefined, raw: row };
}); });
} }
export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } { export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {

View File

@ -0,0 +1,107 @@
import { useLedgerStore } from '../store/ledgerStore';
import type { Posting, TransactionDraft } from './types';
export interface DraftInput {
date: string;
amount: string; // 纯数字字符串 (例如 "89.30")
payee?: string; // 交易对手
narration?: string; // 描述/备注
categoryAccount: string; // 费用科目 (Expenses) 或 目标账户 (Target Account)
sourceAccount: string; // 资金账户 (Assets/Liabilities)
direction: 'income' | 'expense' | 'transfer';
currency?: string; // 币种,默认为 "CNY"
tags?: string[];
links?: string[];
metadata?: Record<string, string>;
}
/**
*
* (new.tsx) 使
*/
export async function buildAndSaveTransaction(input: DraftInput, force?: boolean): Promise<void> {
const amt = input.amount.trim();
if (!amt) {
throw new Error('金额不能为空');
}
const parsedAmount = parseFloat(amt);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
throw new Error('金额数值必须大于 0');
}
// 1. 规范化日期格式,将年/月/日、斜杠、点号替换为连字符并自动补零(支持 2026/7/18、2026年7月18日等
let normalizedDate = input.date
.replace(/[年]/g, '-')
.replace(/[月]/g, '-')
.replace(/[日]/g, '')
.replace(/[\/\.]/g, '-')
.trim();
const dateParts = normalizedDate.split('-');
if (dateParts.length === 3) {
const y = dateParts[0];
const m = dateParts[1].padStart(2, '0');
const d = dateParts[2].padStart(2, '0');
normalizedDate = `${y}-${m}-${d}`;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalizedDate)) {
throw new Error('日期格式不正确,请输入标准格式(如 YYYY-MM-DD');
}
const currency = input.currency || 'CNY';
// 2. 自动开户容错:如果账本中不存在对应账户,自动调用 autoOpenAccounts 写入 open 指令
const store = useLedgerStore.getState();
const ledger = store.ledger;
if (ledger) {
const unopened: string[] = [];
if (input.sourceAccount && !ledger.accounts.has(input.sourceAccount)) {
unopened.push(input.sourceAccount);
}
if (input.categoryAccount && !ledger.accounts.has(input.categoryAccount)) {
unopened.push(input.categoryAccount);
}
if (unopened.length > 0) {
await store.autoOpenAccounts(unopened);
}
}
// 根据交易方向,自动构建标准的复式记账 postings
let postings: Posting[] = [];
if (input.direction === 'expense') {
// 支出: 来源账户 扣减(负), 费用账户 增加(正)
postings = [
{ account: input.sourceAccount, amount: `-${amt}`, currency },
{ account: input.categoryAccount, amount: amt, currency },
];
} else if (input.direction === 'income') {
// 收入: 来源账户 增加(正), 收入账户 扣减(负)
postings = [
{ account: input.sourceAccount, amount: amt, currency },
{ account: input.categoryAccount, amount: `-${amt}`, currency },
];
} else {
// 转账: 来源账户 扣减(负), 目标账户 增加(正)
postings = [
{ account: input.sourceAccount, amount: `-${amt}`, currency },
{ account: input.categoryAccount, amount: amt, currency },
];
}
const draft: TransactionDraft = {
date: normalizedDate,
payee: input.payee?.trim() || undefined,
narration: input.narration?.trim() || (
input.direction === 'expense' ? '支出交易' :
input.direction === 'income' ? '收入交易' :
'转账交易'
),
postings,
tags: input.tags,
links: input.links,
metadata: input.metadata,
};
// 调用统一的 ledgerStore 写入 Beancount 本地存储
await store.addTransaction(draft, undefined, force);
}

View File

@ -1,5 +1,6 @@
import { compareDecimals } from './decimal'; import { compareDecimals } from './decimal';
import type { ImportedEvent, TransactionDraft } from './types'; import type { ImportedEvent, TransactionDraft } from './types';
import { resolveSourceAccount } from './rules';
/** /**
* AutoAccounting TransferRecognizer * AutoAccounting TransferRecognizer
@ -7,6 +8,11 @@ import type { ImportedEvent, TransactionDraft } from './types';
* Income + Expend Transfer * Income + Expend Transfer
* 100+ 100= * 100+ 100=
* *
*
* 1.
* 2. Assets:* Liabilities:*
* Expenses:餐饮 + Assets:微信
*
* plan.md2.7**** * plan.md2.7****
* *
*/ */
@ -39,16 +45,22 @@ export const DEFAULT_TRANSFER_CONFIG: TransferConfig = {
timeWindowHours: 72, timeWindowHours: 72,
}; };
/**
* Assets Liabilities
*
*/
function isBalanceSheetAccount(account: string): boolean {
return account.startsWith('Assets:') || account.startsWith('Liabilities:');
}
/** /**
* Income + Expend Transfer * Income + Expend Transfer
* *
* @param events * @param events
* @param accountMap { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' }
* @param config * @param config
*/ */
export function recognizeTransfers( export function recognizeTransfers(
events: ImportedEvent[], events: ImportedEvent[],
accountMap: Record<string, string>,
config: TransferConfig = DEFAULT_TRANSFER_CONFIG, config: TransferConfig = DEFAULT_TRANSFER_CONFIG,
): RecognizeTransfersResult { ): RecognizeTransfersResult {
const used = new Set<string>(); const used = new Set<string>();
@ -61,7 +73,7 @@ export function recognizeTransfers(
for (const income of incomes) { for (const income of incomes) {
if (used.has(income.id)) continue; if (used.has(income.id)) continue;
const match = findPair(income, expenses, used, windowMs, accountMap); const match = findPair(income, expenses, used, windowMs);
if (match) { if (match) {
transfers.push(match); transfers.push(match);
used.add(match.leftEventId); // 支出方 used.add(match.leftEventId); // 支出方
@ -78,18 +90,18 @@ function findPair(
candidates: ImportedEvent[], candidates: ImportedEvent[],
used: Set<string>, used: Set<string>,
windowMs: number, windowMs: number,
accountMap: Record<string, string>,
): TransferPair | null { ): TransferPair | null {
const incomeTime = Date.parse(income.occurredAt); const incomeTime = Date.parse(income.occurredAt);
const incomeAmount = Math.abs(parseFloat(income.amount)).toFixed(2); // decimal-safe 绝对值:避免 parseFloat 精度丢失
const incomeAbs = income.amount.startsWith('-') ? income.amount.slice(1) : income.amount;
for (const expense of candidates) { for (const expense of candidates) {
if (used.has(expense.id)) continue; if (used.has(expense.id)) continue;
if (income.currency !== expense.currency) continue; if (income.currency !== expense.currency) continue;
// 金额匹配(绝对值相等 // 金额匹配(绝对值相等decimal-safe
const expenseAmount = Math.abs(parseFloat(expense.amount)).toFixed(2); const expenseAbs = expense.amount.startsWith('-') ? expense.amount.slice(1) : expense.amount;
if (compareDecimals(incomeAmount, expenseAmount) !== 0) continue; if (compareDecimals(incomeAbs, expenseAbs) !== 0) continue;
// 时间窗口 // 时间窗口
const expenseTime = Date.parse(expense.occurredAt); const expenseTime = Date.parse(expense.occurredAt);
@ -97,12 +109,15 @@ function findPair(
// 置信度评估 // 置信度评估
const confidence = assessConfidence(income, expense); const confidence = assessConfidence(income, expense);
if (confidence === null) continue; // 渠道相同不可能转账
// 构造转账草稿:从支出方账户 → 收入方账户 // 构造转账草稿:从支出方账户 → 收入方账户
const fromAccount = accountMap[expense.channel] ?? `Assets:${expense.channel}`; const fromAccount = resolveSourceAccount(expense);
const toAccount = accountMap[income.channel] ?? `Assets:${income.channel}`; const toAccount = resolveSourceAccount(income);
const amount = incomeAmount;
// ★ 核心校验:双方必须都是资产负债表科目
if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue;
const amount = incomeAbs;
const draft: TransactionDraft = { const draft: TransactionDraft = {
date: (income.occurredAt < expense.occurredAt ? income.occurredAt : expense.occurredAt).slice(0, 10), date: (income.occurredAt < expense.occurredAt ? income.occurredAt : expense.occurredAt).slice(0, 10),
@ -126,21 +141,18 @@ function findPair(
return null; return null;
} }
/** 评估转账置信度。相同渠道不可能是转账null不同渠道才有意义。 */ /** 评估转账置信度。 */
function assessConfidence( function assessConfidence(
income: ImportedEvent, income: ImportedEvent,
expense: ImportedEvent, expense: ImportedEvent,
): { level: TransferConfidence; reason: string } | null { ): { level: TransferConfidence; reason: string } {
// 相同渠道:不可能是转账(同渠道自己转自己无意义) // 不同对手方匹配 → 中置信度
if (income.channel === expense.channel) return null;
// 不同渠道、对手方匹配 → 中置信度
if (income.counterparty && expense.counterparty) { if (income.counterparty && expense.counterparty) {
if (income.counterparty.includes(expense.counterparty) || expense.counterparty.includes(income.counterparty)) { if (income.counterparty.includes(expense.counterparty) || expense.counterparty.includes(income.counterparty)) {
return { level: 'medium', reason: `不同渠道、对手方匹配(${income.channel}${expense.channel}` }; return { level: 'medium', reason: '对手方匹配' };
} }
} }
// 不同渠道、仅金额时间匹配 → 低置信度 // 仅金额时间匹配 → 低置信度
return { level: 'low', reason: `不同渠道、金额时间匹配(${income.channel}${expense.channel}` }; return { level: 'low', reason: '金额时间匹配' };
} }

View File

@ -30,11 +30,15 @@ export interface TransactionDraft { date: string; payee?: string; narration: str
export interface ValidationResult { valid: boolean; errors: string[]; balances: Record<string, string>; } export interface ValidationResult { valid: boolean; errors: string[]; balances: Record<string, string>; }
export interface ImportedEvent { export interface ImportedEvent {
id: string; occurredAt: string; amount: string; currency: string; direction: Direction; id: string; occurredAt: string; amount: string; currency: string; direction: Direction;
channel: string; counterparty: string; memo: string; externalId?: string; raw: Record<string, string>; counterparty: string; memo: string; externalId?: string; raw: Record<string, string>;
/** 预填资金来源账户(手动/拍照入口提供BillPipeline 分类时优先使用,跳过自动分类)。 */
sourceAccount?: string;
/** 预填分类账户(手动/拍照入口提供BillPipeline 分类时优先使用,跳过自动分类)。 */
categoryAccount?: string;
} }
export interface Rule { export interface Rule {
id: string; priority: number; channel?: string; direction?: Direction; counterpartyContains?: string; id: string; priority: number; direction?: Direction; counterpartyContains?: string;
memoContains?: string; minAmount?: string; maxAmount?: string; currency?: string; memoContains?: string; minAmount?: string; maxAmount?: string; currency?: string;
channelAccount: string; categoryAccount: string; narration?: string; tags?: string[]; hits: number; sourceAccount: string; categoryAccount: string; narration?: string; tags?: string[]; hits: number;
} }
export interface Classification { draft: TransactionDraft; ruleId?: string; confidence: 'high' | 'low'; } export interface Classification { draft: TransactionDraft; ruleId?: string; confidence: 'high' | 'low'; }

11
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
/** 防抖 hookvalue 变化后延迟 delay ms 才更新返回值。 */
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}

View File

@ -5,6 +5,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
import { useDebounce } from './useDebounce';
export interface SearchFilters { export interface SearchFilters {
keyword?: string; keyword?: string;
@ -16,36 +17,38 @@ export interface SearchFilters {
} }
export function useSearch(transactions: Transaction[], filters: SearchFilters): Transaction[] { export function useSearch(transactions: Transaction[], filters: SearchFilters): Transaction[] {
const debouncedKeyword = useDebounce(filters.keyword, 300);
const debouncedFilters = useMemo(
() => ({ ...filters, keyword: debouncedKeyword }),
[filters, debouncedKeyword],
);
return useMemo(() => { return useMemo(() => {
return transactions.filter(t => { return transactions.filter(t => {
// 关键字narration/payee if (debouncedFilters.keyword) {
if (filters.keyword) { const kw = debouncedFilters.keyword.toLowerCase();
const kw = filters.keyword.toLowerCase();
const text = `${t.narration ?? ''} ${t.payee ?? ''}`.toLowerCase(); const text = `${t.narration ?? ''} ${t.payee ?? ''}`.toLowerCase();
if (!text.includes(kw)) return false; if (!text.includes(kw)) return false;
} }
// 账户 if (debouncedFilters.account) {
if (filters.account) { if (!t.postings.some(p => p.account.includes(debouncedFilters.account!))) return false;
if (!t.postings.some(p => p.account.includes(filters.account!))) return false;
} }
// 日期范围
const date = t.date.slice(0, 10); const date = t.date.slice(0, 10);
if (filters.dateFrom && date < filters.dateFrom) return false; if (debouncedFilters.dateFrom && date < debouncedFilters.dateFrom) return false;
if (filters.dateTo && date > filters.dateTo) return false; if (debouncedFilters.dateTo && date > debouncedFilters.dateTo) return false;
// 标签 if (debouncedFilters.tags && debouncedFilters.tags.length > 0) {
if (filters.tags && filters.tags.length > 0) { if (!debouncedFilters.tags.some(tag => t.tags.includes(tag))) return false;
if (!filters.tags.some(tag => t.tags.includes(tag))) return false;
} }
// 方向 if (debouncedFilters.direction) {
if (filters.direction) {
const isExpense = t.postings.some(p => p.account.startsWith('Expenses')); const isExpense = t.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = t.postings.some(p => p.account.startsWith('Income')); const isIncome = t.postings.some(p => p.account.startsWith('Income'));
const isTransfer = !isExpense && !isIncome; const isTransfer = !isExpense && !isIncome;
if (filters.direction === 'expense' && !isExpense) return false; if (debouncedFilters.direction === 'expense' && !isExpense) return false;
if (filters.direction === 'income' && !isIncome) return false; if (debouncedFilters.direction === 'income' && !isIncome) return false;
if (filters.direction === 'transfer' && !isTransfer) return false; if (debouncedFilters.direction === 'transfer' && !isTransfer) return false;
} }
return true; return true;
}); });
}, [transactions, filters]); }, [transactions, debouncedFilters]);
} }

View File

@ -4,8 +4,8 @@
export default { export default {
app: { app: {
name: 'Bean Mobile', name: 'Bean Mobile',
tagline: 'Offline mobile bookkeeping powered by Beancount. The ledger file is the single source of truth; the app only writes to mobile.bean.', tagline: 'Simple, offline mobile bookkeeping. Your privacy is protected, and all data is securely stored locally.',
statusParsed: 'Ledger parsed. Mobile transactions are written only to mobile.bean.', statusParsed: 'Ledger loaded. Your transactions are safely stored locally.',
}, },
common: { common: {
cancel: 'Cancel', cancel: 'Cancel',
@ -19,6 +19,7 @@ export default {
untitled: 'Untitled', untitled: 'Untitled',
clickEditLongDelete: 'Tap to edit · Long-press to delete', clickEditLongDelete: 'Tap to edit · Long-press to delete',
noData: 'No data', noData: 'No data',
discard: 'Discard',
}, },
tab: { tab: {
home: 'Home', home: 'Home',
@ -30,10 +31,10 @@ export default {
}, },
home: { home: {
title: 'Ledger Status', title: 'Ledger Status',
accounts: '%{count} open accounts · %{total} desktop transactions', accounts: '%{count} open accounts · %{total} transactions',
readonlyHint: 'Main file is read-only; include "mobile.bean" is configured.', readonlyHint: 'Ledger is ready. New transactions will be saved automatically.',
mobileBean: 'mobile.bean', mobileBean: 'Local Records',
mobileBeanEmpty: 'No mobile transactions yet', mobileBeanEmpty: 'No transactions yet',
newTransaction: 'New Transaction', newTransaction: 'New Transaction',
netWorthTitle: 'Net Worth', netWorthTitle: 'Net Worth',
assets: 'Assets', assets: 'Assets',
@ -53,13 +54,15 @@ export default {
recurringSuccessDesc: 'Recurring bill "%{name}" has been recorded and next due date updated.', recurringSuccessDesc: 'Recurring bill "%{name}" has been recorded and next due date updated.',
recurringFail: 'Recurring recording failed', recurringFail: 'Recurring recording failed',
accountTree: 'Account Balance Tree', accountTree: 'Account Balance Tree',
balanced: '✓ Balanced',
unbalanced: '✗ Unbalanced',
}, },
transaction: { transaction: {
newTitle: 'Manual Double-Entry (concurrency-safe)', newTitle: 'Manual Entry',
amountPlaceholder: 'Amount, e.g. 24.50', amountPlaceholder: 'Amount, e.g. 24.50',
accountPlaceholder: 'Expense account', accountPlaceholder: 'Expense account',
submit: 'Validate and append to mobile.bean', submit: 'Save Transaction',
appended: 'Appended to mobile.bean (concurrency-safe).', appended: 'Transaction saved successfully.',
detailTitle: 'Transaction Detail', detailTitle: 'Transaction Detail',
notFoundTitle: 'Transaction Not Found', notFoundTitle: 'Transaction Not Found',
notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.', notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.',
@ -68,8 +71,8 @@ export default {
postings: 'Postings', postings: 'Postings',
tags: 'Tags', tags: 'Tags',
links: 'Links', links: 'Links',
metadata: 'Metadata', metadata: 'Additional Info',
rawBean: 'Raw Beancount Text', rawBean: 'Underlying Text',
source: 'Source', source: 'Source',
directionExpense: 'Expense', directionExpense: 'Expense',
directionIncome: 'Income', directionIncome: 'Income',
@ -85,6 +88,7 @@ export default {
formTags: 'Tags (optional)', formTags: 'Tags (optional)',
formTagsWillWrite: 'Will write', formTagsWillWrite: 'Will write',
amountRequired: 'Please enter an amount', amountRequired: 'Please enter an amount',
sameAccountError: 'Source and target accounts cannot be the same',
narrationDefaultExpense: 'Expense', narrationDefaultExpense: 'Expense',
narrationDefaultIncome: 'Income', narrationDefaultIncome: 'Income',
narrationDefaultTransfer: 'Transfer', narrationDefaultTransfer: 'Transfer',
@ -92,10 +96,16 @@ export default {
atLeast2Postings: 'Double-entry requires at least 2 valid posting lines', atLeast2Postings: 'Double-entry requires at least 2 valid posting lines',
unbalanced: 'Unbalanced! Sum of postings per currency must equal 0', unbalanced: 'Unbalanced! Sum of postings per currency must equal 0',
defaultAdvancedNarration: 'Double-entry transaction', defaultAdvancedNarration: 'Double-entry transaction',
postingsList: 'Postings', postingsList: 'Details',
addPosting: 'Add Posting', addPosting: 'Add Detail',
deleteLastPosting: 'Delete Last', deleteLastPosting: 'Delete Last',
targetAccount: 'Target Account', targetAccount: 'Target Account',
unsavedTitle: 'Discard changes?',
unsavedMessage: 'You have unsaved changes. Are you sure you want to leave?',
hideDetails: 'Hide details',
addDetails: 'Add transaction details (date, payee, tags, etc.)',
extraDetails: 'Extra details',
invalidAmount: 'Please enter a valid amount value',
linkTransaction: 'Link Another Transaction', linkTransaction: 'Link Another Transaction',
linkSuccess: 'Linked', linkSuccess: 'Linked',
linkSuccessDesc: 'Two transactions linked with tag ^%{tag}.', linkSuccessDesc: 'Two transactions linked with tag ^%{tag}.',
@ -142,7 +152,30 @@ export default {
shareTitle: 'Share Report', shareTitle: 'Share Report',
exportSuccess: 'Exported', exportSuccess: 'Exported',
exportFail: 'Export failed', exportFail: 'Export failed',
aiSummary: 'Monthly Summary', aiSummary: 'Report Summary',
trendTitle: 'Income/Expense Trend',
netWorthTitle: 'Net Worth Trend',
netWorthCurrent: 'Current Net Worth: %{amount}',
monthlyTitle: 'Monthly Report',
categoryTitle: 'Expense by Category',
annualTitle: '%{year} Annual Report',
annualIncome: 'Income',
annualExpense: 'Expense',
annualNet: 'Net',
annualStats: '%{count} transactions · Daily avg %{avg}',
topCategory: 'Top Categories',
tabWeekly: 'Weekly',
tabMonthly: 'Monthly',
tabAnnual: 'Annual',
weeklyTitle: 'Weekly Report',
weeklyIncome: 'Weekly Income',
weeklyExpense: 'Weekly Expense',
weeklyNet: 'Weekly Net',
weeklyStats: '%{count} transactions',
monthlyIncome: 'Monthly Income',
monthlyExpense: 'Monthly Expense',
monthlyNet: 'Monthly Net',
monthlyStats: '%{count} transactions',
}, },
calendar: { calendar: {
title: 'Calendar', title: 'Calendar',
@ -162,6 +195,9 @@ export default {
keywordsLabel: 'Keywords', keywordsLabel: 'Keywords',
deleteTitle: 'Delete Category', deleteTitle: 'Delete Category',
deleteConfirm: 'Delete "%{name}"?', deleteConfirm: 'Delete "%{name}"?',
namePlaceholder: 'e.g. Food',
accountPlaceholder: 'e.g. Expenses:Food',
keywordsPlaceholder: 'Match keywords, comma-separated, e.g. food, dinner, coffee',
}, },
tag: { tag: {
title: 'Tags', title: 'Tags',
@ -194,6 +230,11 @@ export default {
empty: 'No budgets yet. Tap above to add one.', empty: 'No budgets yet. Tap above to add one.',
deleteTitle: 'Delete Budget', deleteTitle: 'Delete Budget',
deleteConfirm: 'Delete "%{name}"?', deleteConfirm: 'Delete "%{name}"?',
invalidAmountTitle: 'Invalid Amount',
invalidAmountDesc: 'Please enter a budget amount greater than 0',
fieldNamePlaceholder: 'e.g. Food Budget',
fieldAmountPlaceholder: 'e.g. 2000',
fieldCategoryPlaceholder: 'e.g. Expenses:Food',
}, },
creditCard: { creditCard: {
title: 'Credit Cards', title: 'Credit Cards',
@ -223,14 +264,12 @@ export default {
editTitle: 'Edit Rule', editTitle: 'Edit Rule',
empty: 'No rules yet', empty: 'No rules yet',
fieldPriority: 'Priority (higher = matched first)', fieldPriority: 'Priority (higher = matched first)',
fieldChannel: 'Channel (optional: Alipay/WeChat/Bank)',
fieldCounterparty: 'Counterparty contains', fieldCounterparty: 'Counterparty contains',
fieldMemo: 'Memo contains (optional)', fieldMemo: 'Memo contains (optional)',
fieldChannelAccount: 'Source Account', fieldSourceAccount: 'Source Account',
fieldCategoryAccount: 'Category Account', fieldCategoryAccount: 'Category Account',
fieldNarration: 'Narration (optional)', fieldNarration: 'Narration (optional)',
fieldTags: 'Tags (comma-separated)', fieldTags: 'Tags (comma-separated)',
condChannel: 'channel=%{val}',
condCounterparty: 'payee contains "%{val}"', condCounterparty: 'payee contains "%{val}"',
condMemo: 'memo contains "%{val}"', condMemo: 'memo contains "%{val}"',
condNone: 'no conditions', condNone: 'no conditions',
@ -239,14 +278,14 @@ export default {
deleteConfirm: 'Delete "%{name}"?', deleteConfirm: 'Delete "%{name}"?',
}, },
importFlow: { importFlow: {
title: 'Bill Import (BillPipeline)', title: 'Bill Import',
hint: 'Flow: Import CSV → Process (transfer→dedup→categorize) → Confirm each.', hint: 'Flow: Import CSV → Process (transfer→dedup→categorize) → Confirm each.',
importButton: 'Import demo Alipay CSV', importButton: 'Import demo Alipay CSV',
processButton: 'Re-analyze (%{count} bills)', processButton: 'Re-analyze (%{count} bills)',
result: 'Last run: %{drafts} drafts / %{duplicates} dups / %{transfers} transfers', result: 'Last run: %{drafts} drafts / %{duplicates} dups / %{transfers} transfers',
processed: 'Done: %{drafts} drafts, %{duplicates} duplicates, %{transfers} transfers.', processed: 'Done: %{drafts} drafts, %{duplicates} duplicates, %{transfers} transfers.',
confirm: 'Confirm', confirm: 'Confirm',
confirmed: 'Written to mobile.bean.', confirmed: 'Saved locally.',
transfer: 'Transfer', transfer: 'Transfer',
trade: 'Trade', trade: 'Trade',
fallbackWarn: '⚠ %{reason}', fallbackWarn: '⚠ %{reason}',
@ -257,17 +296,15 @@ export default {
expandDuplicates: 'Expand suspected duplicates', expandDuplicates: 'Expand suspected duplicates',
duplicateReason: 'Similar time and matching amount (%{date} · payee: %{payee})', duplicateReason: 'Similar time and matching amount (%{date} · payee: %{payee})',
noDesc: 'No description', noDesc: 'No description',
duplicateAmount: 'Amount: %{amount} %{currency} (%{channel})', duplicateAmount: 'Amount: %{amount} %{currency}',
unknownPayee: 'Unknown', unknownPayee: 'Unknown',
ignored: 'Ignored this duplicate.', ignored: 'Ignored this duplicate.',
forcedImport: 'Bill forcibly imported!', forcedImport: 'Bill forcibly imported!',
decodeFail: 'Bill decode failed: %{error}', decodeFail: 'Bill decode failed: %{error}',
importSuccess: 'Imported %{channel} %{format} bill: %{name}.', importSuccess: 'Imported %{format} bill: %{name}.',
channelWechat: 'WeChat',
channelAlipay: 'Alipay',
formatExcel: 'Excel', formatExcel: 'Excel',
formatCsv: 'CSV', formatCsv: 'CSV',
pipelineRunning: 'Running Pipeline (auto-categorize, dedup)...', pipelineRunning: 'Automatically categorizing and finding duplicates...',
pipelineDone: 'Parsed! Converted %{drafts} transactions, detected %{duplicates} suspected duplicates.', pipelineDone: 'Parsed! Converted %{drafts} transactions, detected %{duplicates} suspected duplicates.',
pipelineFail: 'Analysis failed: %{error}', pipelineFail: 'Analysis failed: %{error}',
pickFail: 'File selection failed: %{error}', pickFail: 'File selection failed: %{error}',
@ -284,13 +321,16 @@ export default {
batchNoValid: 'No valid transactions. Reasons:\n%{reasons}', batchNoValid: 'No valid transactions. Reasons:\n%{reasons}',
batchFail: 'Batch commit failed: %{error}', batchFail: 'Batch commit failed: %{error}',
batchErrorTitle: 'Recording error', batchErrorTitle: 'Recording error',
suspectedDuplicate: 'Suspected duplicate',
skip: 'Skip',
forceImport: 'Import anyway',
}, },
onboarding: { onboarding: {
welcome: 'Welcome', welcome: 'Welcome',
language: 'Choose Language', language: 'Choose Language',
theme: 'Choose Theme', theme: 'Choose Theme',
ledger: 'Ledger Import', ledger: 'Ledger Import',
ledgerDesc: 'Add include "mobile.bean" to your main ledger once. The app never modifies the main file; confirmed transactions are only appended to mobile.bean.', ledgerDesc: 'The app safely stores all new transactions in a separate local file without altering your existing ledger.',
permissions: 'Feature Setup', permissions: 'Feature Setup',
permissionsDesc: 'OCR, notification listening, and SMS monitoring require manual permission grants in system settings (Accessibility/Notification access/SMS).', permissionsDesc: 'OCR, notification listening, and SMS monitoring require manual permission grants in system settings (Accessibility/Notification access/SMS).',
security: 'Security', security: 'Security',
@ -372,14 +412,14 @@ export default {
tabLiabilities: 'Liabilities', tabLiabilities: 'Liabilities',
tabExpenses: 'Expense Categories', tabExpenses: 'Expense Categories',
tabIncome: 'Income Categories', tabIncome: 'Income Categories',
addNew: 'New account (open)', addNew: 'New account',
fullName: 'Full path', fullName: 'Full path',
currentBalance: 'Current balance', currentBalance: 'Current balance',
adjustBalance: 'Adjust balance', adjustBalance: 'Calibrate balance',
closeAccount: 'Close account', closeAccount: 'Close account',
empty: 'No accounts in this category. They are auto-created when you record or import transactions.', empty: 'No accounts in this category. They are auto-created when you record or import transactions.',
addModalTitle: 'New Account (open)', addModalTitle: 'New Account',
adjustModalTitle: 'Adjust Account Balance', adjustModalTitle: 'Calibrate Account Balance',
fieldType: 'Account type', fieldType: 'Account type',
fieldName: 'Account name (English/Chinese)', fieldName: 'Account name (English/Chinese)',
fieldNamePlaceholder: 'Digits, hyphens, Chinese supported', fieldNamePlaceholder: 'Digits, hyphens, Chinese supported',
@ -388,17 +428,18 @@ export default {
fieldBalancePlaceholder: 'e.g. 10000.00', fieldBalancePlaceholder: 'e.g. 10000.00',
fieldDate: 'Effective date', fieldDate: 'Effective date',
confirmClose: 'Confirm Close', confirmClose: 'Confirm Close',
confirmCloseDesc: 'Close this account?\n\n%{name}\n\nA close directive will be appended to mobile.bean.', confirmCloseDesc: 'Close this account?\n\n%{name}\n\nThis account will be closed in your local ledger.',
btnConfirmClose: 'Close', btnConfirmClose: 'Close',
closeSuccess: 'Account closed', closeSuccess: 'Account closed',
closeSuccessDesc: 'Account has been closed in mobile.bean.', closeSuccessDesc: 'Account has been closed in the local ledger.',
closeFail: 'Close failed', closeFail: 'Close failed',
addFail: 'Add failed', addFail: 'Add failed',
addFailEmpty: 'Account type and name are required.', addFailEmpty: 'Account type and name are required.',
invalidRootAccount: 'Account must start with one of the five root account types (Assets, Liabilities, Income, Expenses, Equity)',
openSuccess: 'Account opened', openSuccess: 'Account opened',
openSuccessDesc: 'Account opened successfully:\n\n%{name}', openSuccessDesc: 'Account opened successfully:\n\n%{name}',
openFail: 'Open failed', openFail: 'Open failed',
adjustFail: 'Adjust failed', adjustFail: 'Calibrate failed',
adjustFailEmpty: 'Balance value cannot be empty.', adjustFailEmpty: 'Balance value cannot be empty.',
adjustSuccess: 'Balance adjusted', adjustSuccess: 'Balance adjusted',
adjustSuccessDesc: 'Account %{name} balance calibrated to %{balance} %{currency}.', adjustSuccessDesc: 'Account %{name} balance calibrated to %{balance} %{currency}.',
@ -475,6 +516,11 @@ export default {
demoMode: 'Demo Mode', demoMode: 'Demo Mode',
gitDemoDesc: 'Git sync currently uses simulated data. Real implementation requires isomorphic-git (planned). Run demo?', gitDemoDesc: 'Git sync currently uses simulated data. Real implementation requires isomorphic-git (planned). Run demo?',
iCloudDemoDesc: 'iCloud sync currently uses simulated data. Run demo?', iCloudDemoDesc: 'iCloud sync currently uses simulated data. Run demo?',
backupSuccessFolder: 'Backup file saved successfully to selected folder!',
restoreSuccessParts: 'Restore successful! Restored: %{parts}',
restorePartsTx: 'Transactions',
restorePartsSettings: 'User settings',
restorePartsMeta: 'Categories/Rules/Budgets',
maintenanceDemoDesc: 'Maintenance cleanup currently uses simulated data. Run demo?', maintenanceDemoDesc: 'Maintenance cleanup currently uses simulated data. Run demo?',
runDemo: 'Run Demo', runDemo: 'Run Demo',
demoSyncSuccess: 'Demo complete', demoSyncSuccess: 'Demo complete',
@ -543,8 +589,8 @@ export default {
sourceScreenshot: 'Screenshot', sourceScreenshot: 'Screenshot',
sourceOcr: 'OCR', sourceOcr: 'OCR',
sourceManual: 'Manual', sourceManual: 'Manual',
channelStats: 'Channel Stats', channelStats: 'Source Stats',
confirmed: 'Written to mobile.bean', confirmed: 'Saved successfully',
screenshotStart: 'Start Screenshot Monitor', screenshotStart: 'Start Screenshot Monitor',
screenshotStop: 'Stop Screenshot Monitor', screenshotStop: 'Stop Screenshot Monitor',
screenshotUnavailable: 'Screenshot monitor module unavailable', screenshotUnavailable: 'Screenshot monitor module unavailable',

View File

@ -6,8 +6,8 @@
export default { export default {
app: { app: {
name: 'Bean Mobile', name: 'Bean Mobile',
tagline: '基于 Beancount 的离线移动端记账应用。账本文件是唯一权威来源,应用只写入 mobile.bean。', tagline: '简单纯粹的离线移动端记账应用。保护您的隐私,所有数据安全储存在本地账本中。',
statusParsed: '账本已解析。手机交易只会写入 mobile.bean。', statusParsed: '账本已加载。您的记账记录将安全保存在本地。',
}, },
common: { common: {
cancel: '取消', cancel: '取消',
@ -21,6 +21,7 @@ export default {
untitled: '未命名', untitled: '未命名',
clickEditLongDelete: '点击编辑 · 长按删除', clickEditLongDelete: '点击编辑 · 长按删除',
noData: '暂无数据', noData: '暂无数据',
discard: '放弃',
}, },
tab: { tab: {
home: '首页', home: '首页',
@ -32,9 +33,9 @@ export default {
}, },
home: { home: {
title: '账本状态', title: '账本状态',
accounts: '%{count} 个已开户账户 · %{total} 条桌面交易', accounts: '%{count} 个已开户账户 · %{total} 条交易记录',
readonlyHint: '主文件只读;已配置 include "mobile.bean"。', readonlyHint: '账本已就绪,所有新记账将自动独立保存。',
mobileBean: 'mobile.bean', mobileBean: '本地记录',
mobileBeanEmpty: '尚无手机端交易', mobileBeanEmpty: '尚无手机端交易',
newTransaction: '记一笔', newTransaction: '记一笔',
netWorthTitle: '净资产概览', netWorthTitle: '净资产概览',
@ -55,13 +56,15 @@ export default {
recurringSuccessDesc: '周期账单 "%{name}" 已成功入账并已更新下次到期日。', recurringSuccessDesc: '周期账单 "%{name}" 已成功入账并已更新下次到期日。',
recurringFail: '周期记账失败', recurringFail: '周期记账失败',
accountTree: '账户与资产余额树', accountTree: '账户与资产余额树',
balanced: '✓ 平衡',
unbalanced: '✗ 不平衡',
}, },
transaction: { transaction: {
newTitle: '手工复式记账(并发安全)', newTitle: '手工记账',
amountPlaceholder: '金额,例如 24.50', amountPlaceholder: '金额,例如 24.50',
accountPlaceholder: '费用账户', accountPlaceholder: '费用账户',
submit: '校验并追加到 mobile.bean', submit: '保存记账',
appended: '已追加到 mobile.bean并发安全写入。', appended: '记账成功,已保存到本地。',
detailTitle: '交易详情', detailTitle: '交易详情',
notFoundTitle: '交易不存在', notFoundTitle: '交易不存在',
notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。', notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。',
@ -70,8 +73,8 @@ export default {
postings: '分录', postings: '分录',
tags: '标签', tags: '标签',
links: '链接', links: '链接',
metadata: '元数据', metadata: '附加信息',
rawBean: '原始 Beancount 文本', rawBean: '底层记账文本',
source: '来源', source: '来源',
directionExpense: '支出', directionExpense: '支出',
directionIncome: '收入', directionIncome: '收入',
@ -87,17 +90,24 @@ export default {
formTags: '标签 (可选)', formTags: '标签 (可选)',
formTagsWillWrite: '将写入', formTagsWillWrite: '将写入',
amountRequired: '请输入金额', amountRequired: '请输入金额',
sameAccountError: '来源账户和目标账户不能相同',
narrationDefaultExpense: '支出', narrationDefaultExpense: '支出',
narrationDefaultIncome: '收入', narrationDefaultIncome: '收入',
narrationDefaultTransfer: '转账', narrationDefaultTransfer: '转账',
advancedMode: '高级复式分录模式', advancedMode: '高级复式分录模式',
atLeast2Postings: '复式记账至少需要填写 2 行有效分录', atLeast2Postings: '复式记账至少需要填写 2 行有效分录',
unbalanced: '借贷不平衡,无法保存!请确保各币种 Posting 金额之和等于 0', unbalanced: '借贷不平衡,无法保存!请确保各币种金额之和等于 0',
defaultAdvancedNarration: '复式分录交易', defaultAdvancedNarration: '复式分录交易',
postingsList: '分录列表 (Postings)', postingsList: '明细列表',
addPosting: '添加 Posting', addPosting: '添加明细',
deleteLastPosting: '删除末行', deleteLastPosting: '删除末行',
targetAccount: '目标账户', targetAccount: '目标账户',
unsavedTitle: '放弃修改?',
unsavedMessage: '当前有未保存的修改,确定要离开吗?',
hideDetails: '收起附加详情',
addDetails: '添加交易详情 (日期、商户、标签等)',
extraDetails: '附加详情',
invalidAmount: '请输入有效的金额数值',
linkTransaction: '关联另一笔交易', linkTransaction: '关联另一笔交易',
linkSuccess: '关联成功', linkSuccess: '关联成功',
linkSuccessDesc: '已使用标签 ^%{tag} 关联两笔交易。', linkSuccessDesc: '已使用标签 ^%{tag} 关联两笔交易。',
@ -144,7 +154,30 @@ export default {
shareTitle: '分享报表', shareTitle: '分享报表',
exportSuccess: '导出成功', exportSuccess: '导出成功',
exportFail: '导出失败', exportFail: '导出失败',
aiSummary: '月度总结', aiSummary: '报表总结',
trendTitle: '收支趋势',
netWorthTitle: '净资产趋势',
netWorthCurrent: '当前净资产:%{amount} 元',
monthlyTitle: '月度报告',
categoryTitle: '分类占比',
annualTitle: '%{year} 年度报告',
annualIncome: '收入',
annualExpense: '支出',
annualNet: '结余',
annualStats: '交易 %{count} 笔 · 日均支出 %{avg}',
topCategory: 'Top 分类',
tabWeekly: '周报表',
tabMonthly: '月度报表',
tabAnnual: '年度报表',
weeklyTitle: '周收支报告',
weeklyIncome: '本周收入',
weeklyExpense: '本周支出',
weeklyNet: '本周结余',
weeklyStats: '交易 %{count} 笔',
monthlyIncome: '本月收入',
monthlyExpense: '本月支出',
monthlyNet: '本月结余',
monthlyStats: '交易 %{count} 笔',
}, },
calendar: { calendar: {
title: '日历', title: '日历',
@ -164,6 +197,9 @@ export default {
keywordsLabel: '关键词', keywordsLabel: '关键词',
deleteTitle: '删除分类', deleteTitle: '删除分类',
deleteConfirm: '确定删除「%{name}」吗?', deleteConfirm: '确定删除「%{name}」吗?',
namePlaceholder: '如:餐饮',
accountPlaceholder: '如Expenses:餐饮',
keywordsPlaceholder: '匹配关键词,英文逗号分隔,如:餐饮, 饭, 咖啡',
}, },
tag: { tag: {
title: '标签管理', title: '标签管理',
@ -196,6 +232,11 @@ export default {
empty: '暂无预算,点击上方添加', empty: '暂无预算,点击上方添加',
deleteTitle: '删除预算', deleteTitle: '删除预算',
deleteConfirm: '确定删除「%{name}」吗?', deleteConfirm: '确定删除「%{name}」吗?',
invalidAmountTitle: '预算金额无效',
invalidAmountDesc: '请输入大于 0 的预算金额',
fieldNamePlaceholder: '如:餐饮月预算',
fieldAmountPlaceholder: '如2000',
fieldCategoryPlaceholder: '如Expenses:餐饮',
}, },
creditCard: { creditCard: {
title: '信用卡管理', title: '信用卡管理',
@ -225,14 +266,12 @@ export default {
editTitle: '编辑规则', editTitle: '编辑规则',
empty: '暂无规则', empty: '暂无规则',
fieldPriority: '优先级 (数字越大越先匹配)', fieldPriority: '优先级 (数字越大越先匹配)',
fieldChannel: '渠道 (可选: Alipay/WeChat/Bank)',
fieldCounterparty: '对方包含', fieldCounterparty: '对方包含',
fieldMemo: '备注包含 (可选)', fieldMemo: '备注包含 (可选)',
fieldChannelAccount: '资金来源账户', fieldSourceAccount: '资金来源账户',
fieldCategoryAccount: '分类账户', fieldCategoryAccount: '分类账户',
fieldNarration: '备注摘要 (可选)', fieldNarration: '备注摘要 (可选)',
fieldTags: '标签 (逗号分隔)', fieldTags: '标签 (逗号分隔)',
condChannel: '渠道=%{val}',
condCounterparty: '对方含「%{val}」', condCounterparty: '对方含「%{val}」',
condMemo: '备注含「%{val}」', condMemo: '备注含「%{val}」',
condNone: '无条件', condNone: '无条件',
@ -241,14 +280,14 @@ export default {
deleteConfirm: '确定删除「%{name}」吗?', deleteConfirm: '确定删除「%{name}」吗?',
}, },
importFlow: { importFlow: {
title: '账单导入BillPipeline 责任链)', title: '账单导入',
hint: '流程:导入 CSV → 处理(转账识别→去重→分类)→ 逐条确认。', hint: '流程:导入 CSV → 处理(转账识别→去重→分类)→ 逐条确认。',
importButton: '导入演示支付宝 CSV', importButton: '导入演示支付宝 CSV',
processButton: '刷新分析 (%{count} 条账单)', processButton: '刷新分析 (%{count} 条账单)',
result: '最近处理:%{drafts} 草稿 / %{duplicates} 重复 / %{transfers} 转账', result: '最近处理:%{drafts} 草稿 / %{duplicates} 重复 / %{transfers} 转账',
processed: '处理完成:%{drafts} 条草稿,%{duplicates} 条重复,%{transfers} 条转账识别。', processed: '处理完成:%{drafts} 条草稿,%{duplicates} 条重复,%{transfers} 条转账识别。',
confirm: '确认入账', confirm: '确认入账',
confirmed: '已写入 mobile.bean。', confirmed: '记账成功,已保存。',
transfer: '转账', transfer: '转账',
trade: '交易', trade: '交易',
fallbackWarn: '⚠ %{reason}', fallbackWarn: '⚠ %{reason}',
@ -259,17 +298,15 @@ export default {
expandDuplicates: '展开查看疑似重复项', expandDuplicates: '展开查看疑似重复项',
duplicateReason: '时间相近且金额匹配(%{date} · 对方: %{payee}', duplicateReason: '时间相近且金额匹配(%{date} · 对方: %{payee}',
noDesc: '无描述', noDesc: '无描述',
duplicateAmount: '金额: %{amount} %{currency} (%{channel})', duplicateAmount: '金额: %{amount} %{currency}',
unknownPayee: '未知', unknownPayee: '未知',
ignored: '已忽略该笔重复账单。', ignored: '已忽略该笔重复账单。',
forcedImport: '账单已强行导入!', forcedImport: '账单已强行导入!',
decodeFail: '账单编码解析失败: %{error}', decodeFail: '账单编码解析失败: %{error}',
importSuccess: '成功导入 %{channel} %{format} 账单文件: %{name}。', importSuccess: '成功导入 %{format} 账单文件: %{name}。',
channelWechat: '微信',
channelAlipay: '支付宝',
formatExcel: 'Excel', formatExcel: 'Excel',
formatCsv: 'CSV', formatCsv: 'CSV',
pipelineRunning: '正在运行 Pipeline 自动分类、去重中...', pipelineRunning: '正在自动分类并识别重复账单...',
pipelineDone: '解析完成!成功转换 %{drafts} 笔交易,检测到 %{duplicates} 笔疑似重复交易。', pipelineDone: '解析完成!成功转换 %{drafts} 笔交易,检测到 %{duplicates} 笔疑似重复交易。',
pipelineFail: '自动分析失败: %{error}', pipelineFail: '自动分析失败: %{error}',
pickFail: '账单选取失败: %{error}', pickFail: '账单选取失败: %{error}',
@ -286,13 +323,16 @@ export default {
batchNoValid: '没有可成功入账的交易,失败原因:\n%{reasons}', batchNoValid: '没有可成功入账的交易,失败原因:\n%{reasons}',
batchFail: '批量入账失败: %{error}', batchFail: '批量入账失败: %{error}',
batchErrorTitle: '记账错误', batchErrorTitle: '记账错误',
suspectedDuplicate: '疑似重复',
skip: '跳过',
forceImport: '仍要记账',
}, },
onboarding: { onboarding: {
welcome: '欢迎', welcome: '欢迎',
language: '选择语言', language: '选择语言',
theme: '选择主题', theme: '选择主题',
ledger: '账本导入', ledger: '账本导入',
ledgerDesc: '在主账本添加一次 include "mobile.bean",应用不改写主账本。确认的交易只追加到 mobile.bean。', ledgerDesc: '应用会将所有确认的交易安全地保存在本地独立文件中,不会影响您现有的账本数据。',
permissions: '功能启用', permissions: '功能启用',
permissionsDesc: 'OCR 识别、通知监听、短信监听需在系统设置中手动授权(无障碍/通知访问/短信权限)。', permissionsDesc: 'OCR 识别、通知监听、短信监听需在系统设置中手动授权(无障碍/通知访问/短信权限)。',
security: '安全设置', security: '安全设置',
@ -374,33 +414,34 @@ export default {
tabLiabilities: '信用与负债', tabLiabilities: '信用与负债',
tabExpenses: '支出分类', tabExpenses: '支出分类',
tabIncome: '收入分类', tabIncome: '收入分类',
addNew: '新建账户并声明开户 (open)', addNew: '新建账户',
fullName: '完整路径', fullName: '完整路径',
currentBalance: '当前余额', currentBalance: '当前余额',
adjustBalance: '调整余额', adjustBalance: '校准余额',
closeAccount: '声明销户', closeAccount: '关闭账户',
empty: '该类别下暂无开户账户,可在记账或导入账单时自动创建。', empty: '该类别下暂无开户账户,可在记账或导入账单时自动创建。',
addModalTitle: '新建账户 (开户)', addModalTitle: '新建账户',
adjustModalTitle: '调整账户当前余额', adjustModalTitle: '校准账户当前余额',
fieldType: '账户类型', fieldType: '账户类型',
fieldName: '账户名称 (英文/中文)', fieldName: '账户名称 (英文/中文)',
fieldNamePlaceholder: '支持数字、连字符、中文', fieldNamePlaceholder: '支持数字、连字符、中文',
fieldCurrency: '币种', fieldCurrency: '币种',
fieldBalance: '调整后目标余额', fieldBalance: '校准后目标余额',
fieldBalancePlaceholder: '例如: 10000.00', fieldBalancePlaceholder: '例如: 10000.00',
fieldDate: '校准生效日期', fieldDate: '校准生效日期',
confirmClose: '确认户', confirmClose: '确认关闭账户',
confirmCloseDesc: '确定要销户该账户吗?\n\n%{name}\n\n确认后系统将在手机账本中追加 close 声明。', confirmCloseDesc: '确定要关闭该账户吗?\n\n%{name}\n\n确认后系统将在本地账本中关闭该账户。',
btnConfirmClose: '确定销户', btnConfirmClose: '确定关闭',
closeSuccess: '销户成功', closeSuccess: '关闭成功',
closeSuccessDesc: '已在手机账本中关闭该账户。', closeSuccessDesc: '已在本地账本中关闭该账户。',
closeFail: '销户失败', closeFail: '关闭失败',
addFail: '添加失败', addFail: '添加失败',
addFailEmpty: '账户类型和账户名称不能为空。', addFailEmpty: '账户类型和账户名称不能为空。',
invalidRootAccount: '账户必须以五大根账户之一开头 (Assets, Liabilities, Income, Expenses, Equity)',
openSuccess: '开户成功', openSuccess: '开户成功',
openSuccessDesc: '已在手机账目中成功开户该账户:\n\n%{name}', openSuccessDesc: '已在本地账本中成功开户该账户:\n\n%{name}',
openFail: '开户失败', openFail: '开户失败',
adjustFail: '调整失败', adjustFail: '校准失败',
adjustFailEmpty: '余额数值不能为空。', adjustFailEmpty: '余额数值不能为空。',
adjustSuccess: '调整成功', adjustSuccess: '调整成功',
adjustSuccessDesc: '已校准账户 %{name} 的余额为 %{balance} %{currency}。', adjustSuccessDesc: '已校准账户 %{name} 的余额为 %{balance} %{currency}。',
@ -477,6 +518,11 @@ export default {
demoMode: '演示模式', demoMode: '演示模式',
gitDemoDesc: 'Git 同步功能目前使用模拟数据演示。真实实现需要 isomorphic-git 依赖(计划中)。是否运行演示?', gitDemoDesc: 'Git 同步功能目前使用模拟数据演示。真实实现需要 isomorphic-git 依赖(计划中)。是否运行演示?',
iCloudDemoDesc: 'iCloud 同步功能目前使用模拟数据演示。是否运行演示?', iCloudDemoDesc: 'iCloud 同步功能目前使用模拟数据演示。是否运行演示?',
backupSuccessFolder: '备份文件已成功保存到所选文件夹!',
restoreSuccessParts: '还原成功!已恢复:%{parts}',
restorePartsTx: '交易数据',
restorePartsSettings: '用户设置',
restorePartsMeta: '分类/规则/预算',
maintenanceDemoDesc: '维护清理功能目前使用模拟数据演示。是否运行演示?', maintenanceDemoDesc: '维护清理功能目前使用模拟数据演示。是否运行演示?',
runDemo: '运行演示', runDemo: '运行演示',
demoSyncSuccess: '演示完成', demoSyncSuccess: '演示完成',
@ -545,8 +591,8 @@ export default {
sourceScreenshot: '截图', sourceScreenshot: '截图',
sourceOcr: 'OCR', sourceOcr: 'OCR',
sourceManual: '手动', sourceManual: '手动',
channelStats: '通道统计', channelStats: '来源统计',
confirmed: '已写入 mobile.bean', confirmed: '已保存',
screenshotStart: '开启截图监控', screenshotStart: '开启截图监控',
screenshotStop: '关闭截图监控', screenshotStop: '关闭截图监控',
screenshotUnavailable: '截图监控模块不可用', screenshotUnavailable: '截图监控模块不可用',

View File

@ -95,7 +95,6 @@ export class AiVisionProcessor implements AiVisionProvider {
amount: parsed.type === 'expense' ? `-${amount}` : String(amount), amount: parsed.type === 'expense' ? `-${amount}` : String(amount),
currency: parsed.currency || 'CNY', currency: parsed.currency || 'CNY',
direction: parsed.type === 'income' ? 'income' : parsed.type === 'transfer' ? 'transfer' : 'expense', direction: parsed.type === 'income' ? 'income' : parsed.type === 'transfer' ? 'transfer' : 'expense',
channel: 'AI',
counterparty: sanitizeForAi(parsed.counterparty || ''), counterparty: sanitizeForAi(parsed.counterparty || ''),
memo: 'AI Vision 识别', memo: 'AI Vision 识别',
raw: { source: 'ai-vision', model: this.config.model }, raw: { source: 'ai-vision', model: this.config.model },

View File

@ -47,6 +47,17 @@ export interface NativeAccessibilityBridge {
removePageSignature(signature: string): Promise<boolean>; removePageSignature(signature: string): Promise<boolean>;
getPaymentPackages(): Promise<string[]>; getPaymentPackages(): Promise<string[]>;
getTopApp(): Promise<TopAppInfo>; getTopApp(): Promise<TopAppInfo>;
bringAppToForeground(): Promise<boolean>;
showFloatingBill(
amount: string,
merchant: string,
time: string,
packageName: string,
categories: Array<{ id: string; name: string; account: string; type?: string }>,
accounts: string[],
direction: string,
draftId: string
): Promise<boolean>;
} }
/** /**
@ -65,18 +76,12 @@ export function getAccessibilityBridge(): NativeAccessibilityBridge | null {
} }
} }
/** 包名 → 友好名称映射(用于 UI 展示)。 */ /** 包名 → 友好名称映射(数据源见 constants.ts PAYMENT_PACKAGES。 */
const PACKAGE_LABELS: Record<string, string> = { import { PAYMENT_PACKAGES } from '../domain/constants';
'com.eg.android.AlipayGphone': '支付宝',
'com.tencent.mm': '微信',
'com.unionpay': '银联',
'com.cmbchina': '招商银行',
'com.icbc': '工商银行',
};
/** 根据包名获取友好名称(未知包名返回原始包名)。 */ /** 根据包名获取友好名称(未知包名返回原始包名)。 */
export function getPackageLabel(pkg: string): string { export function getPackageLabel(pkg: string): string {
return PACKAGE_LABELS[pkg] ?? pkg; return PAYMENT_PACKAGES[pkg] ?? pkg;
} }
/** 获取支付 App 白名单的友好标签列表。 */ /** 获取支付 App 白名单的友好标签列表。 */

View File

@ -10,6 +10,7 @@
*/ */
import { OcrProcessor } from '../domain/ocrProcessor'; import { OcrProcessor } from '../domain/ocrProcessor';
import { buildAndSaveTransaction } from '../domain/transactionBuilder';
import type { OcrProcessResult } from '../domain/ocrProcessor'; import type { OcrProcessResult } from '../domain/ocrProcessor';
import { getNativeOcrBridge } from './ocrBridge'; import { getNativeOcrBridge } from './ocrBridge';
import { AiVisionProcessor } from '../ocr/AiVisionProcessor'; import { AiVisionProcessor } from '../ocr/AiVisionProcessor';
@ -17,6 +18,72 @@ import type { AiVisionProvider } from '../domain/ocrProcessor';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useAutomationStore } from '../store/automationStore'; import { useAutomationStore } from '../store/automationStore';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { Alert, AppState, DeviceEventEmitter } from 'react-native';
import { router } from 'expo-router';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { sharedPipeline } from '../domain/pipelineSingleton';
import type { ImportedEvent } from '../domain/types';
import { getAccessibilityBridge } from './accessibilityBridge';
export interface PendingDraft {
draft: any;
event: any;
}
export const pendingDrafts = new Map<string, PendingDraft>();
// 全局单监听器:响应悬浮卡片的确认入账请求,支持精确 draftId 匹配和防错强制写入
DeviceEventEmitter.addListener('billingConfirmed', async (res) => {
const { draftId, confirmed } = res;
if (!draftId) return;
if (!confirmed) {
// 用户点击了忽略,清除去重缓存
const pending = pendingDrafts.get(draftId);
if (pending) {
const { event } = pending;
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
processedTxKeys.delete(txKey);
pendingDrafts.delete(draftId);
logger.info('automationPipeline', `[浮窗忽略] 用户取消入账,清除去重缓存: ${txKey}`);
}
return;
}
const pending = pendingDrafts.get(draftId);
if (pending) {
pendingDrafts.delete(draftId); // 移出 Map 防止内存泄漏
} else {
logger.warn('automationPipeline', `[浮窗入账] 未找到待挂起的确认交易草稿启用容错备选参数写入。draftId: ${draftId}`);
}
try {
const draftDate = pending?.draft?.date || (res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0]);
const currency = pending?.event?.currency || 'CNY';
const direction = (res.direction || pending?.event?.direction || 'expense') as 'expense' | 'income' | 'transfer';
const tags = pending?.draft?.tags || undefined;
const links = pending?.draft?.links || undefined;
const metadata = pending?.draft?.metadata || undefined;
// 后台入账为了防范去重机制把正常的同天等额交易阻断,遇到冲突直接调用 force 强制写入
await buildAndSaveTransaction({
date: draftDate,
amount: res.amount,
payee: res.merchant || undefined,
narration: res.narration || undefined,
categoryAccount: res.category,
sourceAccount: res.account,
direction,
currency,
tags,
links,
metadata,
}, true); // force = true
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount} CNY`);
} catch (e) {
logger.error('automationPipeline', '后台浮窗入账失败', e);
}
});
/** 原生截图事件格式BillingAccessibilityService + ScreenshotModule 推送)。 */ /** 原生截图事件格式BillingAccessibilityService + ScreenshotModule 推送)。 */
export interface NativeScreenshotEvent { export interface NativeScreenshotEvent {
@ -95,10 +162,10 @@ export async function processScreenshotEvent(event: NativeScreenshotEvent): Prom
try { try {
const result = await processor.process(base64, event.packageName, false); const result = await processor.process(base64, event.packageName, false);
if (result.event) { if (result.event) {
logger.info('automationPipeline', `截图识别成功 (${result.layer}): ${result.event.counterparty} · ${result.event.amount}`); logger.debug('automationPipeline', `截图识别成功 (${result.layer}) [时间: ${result.event.occurredAt}, 方向: ${result.event.direction === 'income' ? '收入' : '支出'}, 金额: ${result.event.amount} ${result.event.currency}, 商户: ${result.event.counterparty}, 备注: ${result.event.memo}] | 原始文本: [${(result.ocrText ?? '').replace(/\n/g, ' | ')}]`);
useAutomationStore.getState().addDetected('screenshot', result.event); await handleIncomingBillEvent('screenshot', result.event, result.ocrText ?? '');
} else { } else {
logger.debug('automationPipeline', `截图未识别为账单 (layer=${result.layer}, skipped=${result.skipped ?? 'none'})`); logger.debug('automationPipeline', `截图未识别为账单 (layer=${result.layer}, skipped=${result.skipped ?? 'none'}). OCR文本: [${(result.ocrText ?? '').replace(/\n/g, ' | ')}]`);
} }
return result; return result;
} catch (e) { } catch (e) {
@ -107,8 +174,379 @@ export async function processScreenshotEvent(event: NativeScreenshotEvent): Prom
} }
} }
/** 实时账单事件处理函数:进行分类/账户推荐匹配并直接询问用户是否入账 */
export async function handleIncomingBillEvent(source: string, event: ImportedEvent, rawText: string) {
const ledger = useLedgerStore.getState().ledger;
if (!ledger) {
logger.warn('automationPipeline', '账本未加载,跳过实时入账处理');
return;
}
// 交易级全局去重:避免对于已处理过(确认/忽略/进入过流程)的相同交易重复拉起悬浮卡片
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
if (processedTxKeys.has(txKey)) {
logger.info('automationPipeline', `[实时入账去重] 过滤相同交易: ${txKey} (当前运行已处理过,拦截弹窗)`);
return;
}
processedTxKeys.add(txKey);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions;
const dedupConfig = useSettingsStore.getState().dedupConfig;
const transferConfig = useSettingsStore.getState().transferConfig;
try {
const result = await sharedPipeline.process([event], {
ledger,
rules,
categories,
history,
dedupConfig,
transferConfig,
});
const draft = result.drafts[0];
if (!draft) {
logger.info('automationPipeline', `[实时入账] 处理结束,可能与已有交易重复已被去重过滤。`);
return;
}
const amount = (draft.draft.postings[0]?.amount ?? '0').replace(/^-/, '');
const currency = draft.draft.postings[0]?.currency ?? 'CNY';
const sourceAccount = draft.draft.postings[0]?.account ?? 'Assets:Unknown';
const targetAccount = draft.draft.postings[1]?.account ?? 'Expenses:未分类';
// 详细的日志控制台输出,包含原始 OCR 结果、OCR 清洗后结果、账单识别结果及参数填充和分类推荐
logger.info('automationPipeline',
`\n================ [新账单实时解析成功] ================\n` +
`【1. 原始 OCR/消息文本】:\n` +
` 来源: ${source}\n` +
` 内容:\n${rawText}\n\n` +
`【2. OCR 清洗后及初步识别结果】:\n` +
` - 发生时间: ${event.occurredAt}\n` +
` - 识别金额: ${event.amount} ${event.currency} (${event.direction === 'income' ? '收入' : '支出'})\n` +
` - 识别商户: ${event.counterparty || '无'}\n` +
` - 付款方式/备注: ${event.memo || '无'}\n\n` +
`【3. 账单参数填充及分类推荐结果】:\n` +
` - 记账日期 (Date): ${draft.draft.date}\n` +
` - 交易对手 (Payee): ${draft.draft.payee || '无'}\n` +
` - 叙述说明 (Narration): ${draft.draft.narration || '无'}\n` +
` - 目标分类账户 (Expenses): ${targetAccount}\n` +
` - 资金来源账户 (Assets/Liabilities): ${sourceAccount}\n` +
`=====================================================`
);
// 若 App 在后台运行,显示浮窗提示,不打扰当前应用前台进程
if (AppState.currentState !== 'active') {
logger.info('automationPipeline', `[实时入账] 检测到 App 处于后台,正在触发系统悬浮浮窗进行记账确认`);
const bridge = getAccessibilityBridge();
if (bridge) {
try {
const shortTargetAccount = targetAccount.split(':').pop() || targetAccount;
const timeStr = draft.draft.date + ' ' + (draft.draft.narration || '');
// 1. 获取最近交易的历史记录,构建分类与账户的频次字典以支持智能排序 (MRU)
const transactions = useLedgerStore.getState().ledger?.transactions || [];
const categoryFreq: Record<string, number> = {};
const accountFreq: Record<string, number> = {};
const recentTx = transactions.slice(-50);
for (const tx of recentTx) {
for (const posting of tx.postings) {
const acct = posting.account;
if (acct.startsWith('Expenses:') || acct.startsWith('Income:')) {
categoryFreq[acct] = (categoryFreq[acct] || 0) + 1;
} else if (acct.startsWith('Assets:') || acct.startsWith('Liabilities:')) {
accountFreq[acct] = (accountFreq[acct] || 0) + 1;
}
}
}
// 2. 分类按“推荐匹配 + 历史使用频次”混合打分排序
const categories = useMetadataStore.getState().categories;
const sortedCategories = [...categories].map(cat => {
let score = categoryFreq[cat.linkedAccount] || 0;
if (cat.linkedAccount === targetAccount) {
score += 1000; // 推荐分类给予最高优先级
}
return {
id: cat.id,
name: cat.name,
account: cat.linkedAccount,
type: cat.type,
score
};
}).sort((a, b) => b.score - a.score)
.map(({ id, name, account, type }) => ({ id, name, account, type }));
// 3. 资产账户按“推荐匹配 + 历史使用频次”混合打分排序
const accountsMap = ledger.accounts;
let assetAccounts = Array.from(accountsMap.keys()).filter(a => a.startsWith('Assets') || a.startsWith('Liabilities'));
if (assetAccounts.length === 0) {
assetAccounts = ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:现金'];
}
const sortedAccounts = assetAccounts.map(acct => {
let score = accountFreq[acct] || 0;
if (acct === sourceAccount) {
score += 1000; // 推荐账户给予最高优先级
}
return { acct, score };
}).sort((a, b) => b.score - a.score)
.map(({ acct }) => acct);
// 生成唯一 id 并缓存该待处理的交易草稿
const draftId = Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
pendingDrafts.set(draftId, { draft: draft.draft, event });
await bridge.showFloatingBill(
amount,
draft.draft.payee || shortTargetAccount,
timeStr,
event.raw?.packageName || '',
sortedCategories,
sortedAccounts,
event.direction || 'expense',
draftId
);
// 2分钟后自动清除缓存防止内存泄漏
setTimeout(() => {
if (pendingDrafts.has(draftId)) {
pendingDrafts.delete(draftId);
logger.info('automationPipeline', `[浮窗入账] 悬浮窗草稿超时未确认,已自动清理缓存, ID: ${draftId}`);
}
}, 120000);
return;
} catch (err) {
logger.error('automationPipeline', '显示浮窗失败,降级拉起前台弹窗', err);
}
}
// 浮窗不可用时的兜底:将 App 拉起至前台
logger.info('automationPipeline', `[实时入账] 降级拉起 App 前台...`);
const bridgeForFallback = getAccessibilityBridge();
if (bridgeForFallback) {
try {
await bridgeForFallback.bringAppToForeground();
await new Promise(resolve => setTimeout(resolve, 600));
} catch (err) {
logger.error('automationPipeline', '拉起前台失败', err);
}
}
}
// 前台模式:弹窗提示
Alert.alert(
'🌟 识别到新账单',
`【账单解析】\n` +
`日期: ${draft.draft.date}\n` +
`商户: ${draft.draft.payee ?? '无'}\n` +
`金额: ${amount} ${currency}\n` +
`分类: ${targetAccount}\n` +
`账户: ${sourceAccount}\n` +
`叙述: ${draft.draft.narration || '无'}\n\n` +
`是否确认入账?`,
[
{
text: '拒绝/丢弃',
style: 'cancel',
onPress: () => {
logger.info('automationPipeline', `[实时入账] 用户选择拒绝/丢弃交易`);
}
},
{
text: '修改并入账',
onPress: () => {
logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`);
router.push({
pathname: '/transaction/new',
params: {
draftJson: JSON.stringify(draft.draft)
}
});
}
},
{
text: '确认入账',
style: 'default',
onPress: async () => {
try {
await useLedgerStore.getState().addTransaction(draft.draft);
logger.info('automationPipeline', `[实时入账] 用户确认入账交易成功`);
} catch (e) {
Alert.alert('入账失败', e instanceof Error ? e.message : String(e));
}
}
}
]
);
} catch (e) {
logger.error('automationPipeline', `实时入账处理异常: ${e}`);
}
}
/** 重置 OcrProcessor 去重状态(切换页面/应用重启时)。 */ /** 重置 OcrProcessor 去重状态(切换页面/应用重启时)。 */
export function resetAutomationPipeline(): void { export function resetAutomationPipeline(): void {
ocrProcessor?.reset(); ocrProcessor?.reset();
ocrProcessor = null; ocrProcessor = null;
processedTxKeys.clear();
}
const processedTxKeys = new Set<string>();
/**
* OCR
*
*
* @param texts
* @param packageName
* @returns
*/
export async function parseAndProcessAccessibilityTexts(texts: string[], packageName: string): Promise<boolean> {
let event: ImportedEvent | null = null;
if (packageName === 'com.tencent.mm') {
// 微信账单详情匹配
// 特征:包含 "交易单号" 或 "退款单号" 或 "本服务由财付通提供"
if (!texts.includes('交易单号') && !texts.includes('退款单号') && !texts.includes('本服务由财付通提供')) {
return false;
}
let amount = 0;
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
// 1. 匹配金额(比如 "+15.50"、"15.5"、"15.50"
const amountPattern = /^([+-])?(\d+(\.\d{1,2})?)$/;
for (const text of texts) {
const match = text.match(amountPattern);
if (match) {
amount = parseFloat(match[2]);
if (match[1] === '+') direction = 'income';
break;
}
}
// 2. 匹配商户(微信详情金额通常在其右上方/左下方,这里取金额前面一项作为商户)
const amtIndex = texts.findIndex(t => amountPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
// 清洗退款等标识
merchant = merchant.split(',')[0].replace(/-退款$/, '');
// 3. 匹配时间
const timeLabelIndex = texts.findIndex(t => t.includes('时间'));
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
if (amount > 0 && merchant) {
event = {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amount.toFixed(2),
currency: 'CNY',
direction,
counterparty: merchant,
memo: texts.includes('已退款') ? '退款' : '微信支付',
raw: { packageName }
};
}
} else if (packageName === 'com.eg.android.AlipayGphone') {
// 支付宝账单详情匹配
// 特征:包含 "创建时间" 或 "账单详情" 或 "对此订单有疑问"
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) {
return false;
}
let amount = 0;
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
let memo = '支付宝账单';
// 1. 匹配金额(如 "0.03元"、"75.4元"、"15.50元"
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
for (const text of texts) {
const match = text.match(amtWithYuanPattern);
if (match) {
amount = parseFloat(match[2]);
if (match[1] === '+') {
direction = 'income';
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
direction = 'income';
}
break;
}
}
// 2. 匹配商户(通常是列表第一项)
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
// 3. 匹配时间
const timeLabelIndex = texts.indexOf('创建时间');
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
// 4. 提取商品说明/备注
const memoLabelIndex = texts.indexOf('商品说明');
if (memoLabelIndex !== -1 && memoLabelIndex + 1 < texts.length) {
memo = texts[memoLabelIndex + 1];
}
// 提取账单分类
const catLabelIndex = texts.indexOf('账单分类');
let category = 'Expenses:Other';
if (catLabelIndex !== -1 && catLabelIndex + 1 < texts.length) {
const rawCat = texts[catLabelIndex + 1];
if (rawCat.includes('理财') || rawCat.includes('收益') || memo.includes('收益发放') || rawCat.includes('红包')) {
category = 'Income:Investment';
direction = 'income';
}
}
if (amount > 0 && merchant) {
event = {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amount.toFixed(2),
currency: 'CNY',
direction,
counterparty: merchant,
memo,
raw: { packageName }
};
}
}
if (event) {
// 交易级去重:本次运行期间如果已处理过相同日期、金额和商户的交易,则直接忽略(避免在详情页重复拉起卡片)
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
if (processedTxKeys.has(txKey)) {
logger.info('automationPipeline', `[直接文本解析去重] 过滤相同交易: ${txKey} (已确认/忽略过,跳过浮窗)`);
return true; // 返回 true 以阻止降级触发 OCR
}
logger.info('automationPipeline', `[直接文本解析成功] 发现有效交易: ${event.counterparty} | ${event.amount} | ${event.occurredAt}`);
await handleIncomingBillEvent('accessibility_nodes', event, JSON.stringify(texts));
return true;
}
return false;
} }

View File

@ -1,6 +1,6 @@
/** /**
* plan.md0.5 * plan.md0.5
* .bean * .bean + + //
* *
* // * //
* backend * backend
@ -11,10 +11,14 @@ import type { LedgerFile } from '../domain/types';
/** 备份内容(序列化为 JSON 后打包)。 */ /** 备份内容(序列化为 JSON 后打包)。 */
export interface BackupBundle { export interface BackupBundle {
version: 1; version: 1 | 2;
createdAt: string; createdAt: string;
files: { path: string; content: string }[]; files: { path: string; content: string }[];
mobileBean: string; mobileBean: string;
/** v2 新增:用户设置(语言/主题等,不含敏感字段)。 */
settings?: Record<string, unknown>;
/** v2 新增:元数据(分类/标签/预算/信用卡/规则)。 */
metadata?: Record<string, unknown>;
} }
export interface BackupBackend { export interface BackupBackend {
@ -26,16 +30,20 @@ export interface BackupBackend {
pickDocument(): Promise<string | null>; pickDocument(): Promise<string | null>;
} }
/** 创建备份 bundle。 */ /** 创建备份 bundle(含设置和元数据)。 */
export function createBackupBundle( export function createBackupBundle(
files: LedgerFile[], files: LedgerFile[],
mobileBean: string, mobileBean: string,
settings?: Record<string, unknown>,
metadata?: Record<string, unknown>,
): BackupBundle { ): BackupBundle {
return { return {
version: 1, version: 2,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
files: files.filter(f => f.path !== 'mobile.bean'), files: files.filter(f => f.path !== 'main.bean'),
mobileBean, mobileBean,
settings,
metadata,
}; };
} }
@ -44,10 +52,10 @@ export function serializeBundle(bundle: BackupBundle): string {
return JSON.stringify(bundle, null, 2); return JSON.stringify(bundle, null, 2);
} }
/** 反序列化 + 校验。 */ /** 反序列化 + 校验(兼容 v1 和 v2。 */
export function deserializeBundle(json: string): BackupBundle { export function deserializeBundle(json: string): BackupBundle {
const data = JSON.parse(json) as BackupBundle; const data = JSON.parse(json) as BackupBundle;
if (data.version !== 1) throw new Error(`不支持的备份版本: ${data.version}`); if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组'); if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组');
if (typeof data.mobileBean !== 'string') throw new Error('备份文件格式错误mobileBean 非字符串'); if (typeof data.mobileBean !== 'string') throw new Error('备份文件格式错误mobileBean 非字符串');
return data; return data;
@ -58,10 +66,10 @@ export function bundleChecksum(bundle: BackupBundle): string {
return hash(serializeBundle(bundle)); return hash(serializeBundle(bundle));
} }
/** 从 bundle 恢复文件列表(含 mobile.bean。 */ /** 从 bundle 恢复文件列表(含 main.bean。 */
export function restoreFiles(bundle: BackupBundle): LedgerFile[] { export function restoreFiles(bundle: BackupBundle): LedgerFile[] {
return [ return [
...bundle.files, ...bundle.files,
{ path: 'mobile.bean', content: bundle.mobileBean }, { path: 'main.bean', content: bundle.mobileBean },
]; ];
} }

View File

@ -52,7 +52,8 @@ function parseDraftParams(params: string[]): Partial<TransactionDraft> {
const sp = new URLSearchParams(search); const sp = new URLSearchParams(search);
const draft: Partial<TransactionDraft> = {}; const draft: Partial<TransactionDraft> = {};
if (sp.get('narration')) draft.narration = sp.get('narration')!; if (sp.get('narration')) draft.narration = sp.get('narration')!;
if (sp.get('date')) draft.date = sp.get('date')!; const date = sp.get('date');
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) draft.date = date;
return draft; return draft;
} }

View File

@ -13,8 +13,8 @@ import * as FileSystem from 'expo-file-system/legacy';
import type { MobileBeanBackend } from '../domain/mobileStore'; import type { MobileBeanBackend } from '../domain/mobileStore';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
const FILE_PATH = (FileSystem.documentDirectory ?? '') + 'mobile.bean'; const FILE_PATH = (FileSystem.documentDirectory ?? '') + 'main.bean';
const TMP_PATH = (FileSystem.documentDirectory ?? '') + 'mobile.bean.tmp'; const TMP_PATH = (FileSystem.documentDirectory ?? '') + 'main.bean.tmp';
/** /**
* mobile.bean * mobile.bean
@ -38,14 +38,7 @@ export class FileSystemBackend implements MobileBeanBackend {
async writeAtomic(content: string): Promise<void> { async writeAtomic(content: string): Promise<void> {
// 1. 写入临时文件 // 1. 写入临时文件
await FileSystem.writeAsStringAsync(TMP_PATH, content); await FileSystem.writeAsStringAsync(TMP_PATH, content);
// 2. 重命名 tmp → 正式文件(同目录 moveAsync 覆盖目标,保证中途崩溃不损坏原文件)
// 2. 删除旧文件(如果存在)
const oldInfo = await FileSystem.getInfoAsync(FILE_PATH);
if (oldInfo.exists) {
await FileSystem.deleteAsync(FILE_PATH, { idempotent: true });
}
// 3. 重命名 tmp → 正式文件
await FileSystem.moveAsync({ from: TMP_PATH, to: FILE_PATH }); await FileSystem.moveAsync({ from: TMP_PATH, to: FILE_PATH });
} }
@ -53,8 +46,14 @@ export class FileSystemBackend implements MobileBeanBackend {
try { try {
const tmpInfo = await FileSystem.getInfoAsync(TMP_PATH); const tmpInfo = await FileSystem.getInfoAsync(TMP_PATH);
if (tmpInfo.exists) { if (tmpInfo.exists) {
// 上次写入崩溃tmp 残留:删除它,保留旧文件 const fileInfo = await FileSystem.getInfoAsync(FILE_PATH);
if (fileInfo.exists) {
// 正式文件存在tmp 是崩溃残留,丢弃
await FileSystem.deleteAsync(TMP_PATH, { idempotent: true }); await FileSystem.deleteAsync(TMP_PATH, { idempotent: true });
} else {
// 正式文件不存在rename 中崩溃,用 tmp 恢复
await FileSystem.moveAsync({ from: TMP_PATH, to: FILE_PATH });
}
logger.warn('FileSystemBackend', 'Recovered from crashed write (tmp residue cleaned)'); logger.warn('FileSystemBackend', 'Recovered from crashed write (tmp residue cleaned)');
return true; return true;
} }

View File

@ -5,6 +5,7 @@
* Config Plugin + * Config Plugin +
*/ */
import { PAYMENT_PACKAGES } from '../domain/constants';
import { hash } from '../domain/ledger'; import { hash } from '../domain/ledger';
import { passesKeywordFilter, type KeywordFilter } from '../domain/keywordFilter'; import { passesKeywordFilter, type KeywordFilter } from '../domain/keywordFilter';
import { matchOcrRule } from '../domain/ocr'; import { matchOcrRule } from '../domain/ocr';
@ -28,17 +29,8 @@ export interface NotificationListener {
setWhitelist(packages: string[]): Promise<void>; setWhitelist(packages: string[]): Promise<void>;
} }
/** 默认支付 App 白名单。 */ /** 默认支付 App 白名单(数据源见 constants.ts PAYMENT_PACKAGES。 */
export const DEFAULT_PAYMENT_PACKAGES = [ export const DEFAULT_PAYMENT_PACKAGES = Object.keys(PAYMENT_PACKAGES);
'com.eg.android.AlipayGphone', // 支付宝
'com.tencent.mm', // 微信
'com.unionpay', // 银联
'com.cmbchina', // 招商银行
'com.icbc', // 工商银行
'com.chinamworld.main', // 中国银行
'com.ccbrcb', // 建设银行
'com.bankcomm.Bankcomm', // 交通银行
];
/** /**
* plan.md4.1 title+text * plan.md4.1 title+text

Some files were not shown because too many files have changed in this diff Show More