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:
parent
f6437b83fe
commit
76a5853ab6
17
.gitignore
vendored
17
.gitignore
vendored
@ -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/
|
|
||||||
1
app.json
1
app.json
@ -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": {
|
||||||
|
|||||||
206
design-system/beancount-mobile/MASTER.md
Normal file
206
design-system/beancount-mobile/MASTER.md
Normal 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
|
||||||
@ -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
170
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
233
plugins/accessibility/android/AccessibilityBridgeModule.kt
Normal file
233
plugins/accessibility/android/AccessibilityBridgeModule.kt
Normal 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.md「3.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
plugins/accessibility/android/AccessibilityBridgePackage.kt
Normal file
20
plugins/accessibility/android/AccessibilityBridgePackage.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
578
plugins/accessibility/android/BillingAccessibilityService.kt
Normal file
578
plugins/accessibility/android/BillingAccessibilityService.kt
Normal 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.md「3.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JS(JS 控制是否 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 → base64(JPEG 质量 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
|
||||||
|
}
|
||||||
739
plugins/accessibility/android/FloatingBillView.kt
Normal file
739
plugins/accessibility/android/FloatingBillView.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
418
plugins/accessibility/android/FloatingHelper.kt
Normal file
418
plugins/accessibility/android/FloatingHelper.kt
Normal 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
|
||||||
|
}
|
||||||
128
plugins/accessibility/android/FloatingTip.kt
Normal file
128
plugins/accessibility/android/FloatingTip.kt
Normal 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.md「3.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.md「3.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
plugins/accessibility/android/OcrTileService.kt
Normal file
62
plugins/accessibility/android/OcrTileService.kt
Normal 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.md「3.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" />
|
||||||
@ -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. 注入 import(ReactContextHolder + AccessibilityBridgePackage)
|
// 2a. 注入 import(AccessibilityBridgePackage)
|
||||||
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())
|
||||||
|
|||||||
@ -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.md「4.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 KB,PaddleOCR 标准字典)
|
# 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`),导致新代码找不到配套字典。
|
||||||
|
|||||||
659
plugins/ppocr/android/OcrModule.kt
Normal file
659
plugins/ppocr/android/OcrModule.kt
Normal 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 Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||||
|
*
|
||||||
|
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
||||||
|
* 模型:ppocrv5_det.onnx + ppocrv5_rec.onnx(从 ilaylow/PP_OCRv5_mobile_onnx 下载)。
|
||||||
|
* 字典:ppocrv5_dict.txt(PP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
|
||||||
|
*
|
||||||
|
* 流水线:
|
||||||
|
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
||||||
|
* 2. rec(文本识别):每个框 crop → resize 到 48px 高 → CTC 解码
|
||||||
|
*
|
||||||
|
* 性能优化(参考 AutoAccounting OcrProcessor.kt):
|
||||||
|
* - 短边压缩到 720px(像素量比 1440p 减少约 75%)
|
||||||
|
* - CPU 执行(兼容性最稳,部分设备 GPU 会崩溃)
|
||||||
|
* - det 最大边限制 960(PaddleOCR 默认 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/B(c=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_postprocess:sigmoid → 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 CTC:blank 固定在 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.txt(6623)会导致 CTC 解码乱码。
|
||||||
|
private const val ASSET_DICT = "ppocrv5_dict.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
plugins/ppocr/android/OcrPackage.kt
Normal file
24
plugins/ppocr/android/OcrPackage.kt
Normal 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 的 Package(plan.md「3.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
18383
plugins/ppocr/assets/ppocrv5_dict.txt
Normal file
18383
plugins/ppocr/assets/ppocrv5_dict.txt
Normal file
File diff suppressed because it is too large
Load Diff
90
plugins/screenshot-monitor/android/ScreenshotModule.kt
Normal file
90
plugins/screenshot-monitor/android/ScreenshotModule.kt
Normal 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 Module(plan.md「3.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 并发送到 JS(WritableNativeMap 格式,与无障碍服务一致)。 */
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
plugins/screenshot-monitor/android/ScreenshotObserver.kt
Normal file
116
plugins/screenshot-monitor/android/ScreenshotObserver.kt
Normal 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截图监听 ContentObserver(plan.md「3.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_MS、MAX_PROCESSED、SCREENSHOT_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
plugins/screenshot-monitor/android/ScreenshotPackage.kt
Normal file
20
plugins/screenshot-monitor/android/ScreenshotPackage.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
48
plugins/size-optimization/app.plugin.js
Normal file
48
plugins/size-optimization/app.plugin.js
Normal 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;
|
||||||
4
plugins/size-optimization/package.json
Normal file
4
plugins/size-optimization/package.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "size-optimization",
|
||||||
|
"main": "app.plugin.js"
|
||||||
|
}
|
||||||
73
plugins/sms-receiver/android/BillingSmsReceiver.kt
Normal file
73
plugins/sms-receiver/android/BillingSmsReceiver.kt
Normal 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 短信监听 Receiver(plan.md「4.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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}>
|
||||||
<View style={styles.recurringList}>
|
{dueRecurring.length > 0 ? (
|
||||||
{dueRecurring.map(rec => (
|
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
|
||||||
<View key={rec.id} style={[styles.recurringRow, { borderBottomColor: theme.colors.divider }]}>
|
<View style={styles.recurringList}>
|
||||||
<View style={{ flex: 1 }}>
|
{dueRecurring.slice(0, 2).map(rec => (
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
|
||||||
{rec.name}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
|
||||||
|
{rec.name}
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleConfirmRecurring(rec)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.confirmBtnMini,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgTertiary,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
|
||||||
|
{rec.draft.postings[0]?.amount} CNY
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{currentMonth ? (
|
||||||
|
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
|
||||||
|
<View style={styles.bentoStats}>
|
||||||
|
<View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{t('home.income')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
||||||
{t('home.dueAmount')}: {rec.draft.postings[0]?.amount} CNY
|
+{fmtAmount(currentMonth.income.toString())}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{t('home.expense')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
||||||
|
-{fmtAmount(currentMonth.expense.toString())}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
|
||||||
onPress={() => handleConfirmRecurring(rec)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.confirmBtn,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
borderRadius: theme.radii.sm,
|
|
||||||
opacity: pressed ? 0.7 : 1,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{t('home.confirmRecurring')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
</Card>
|
||||||
</View>
|
) : null}
|
||||||
</Card>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 本月收支 */}
|
|
||||||
{currentMonth && (
|
|
||||||
<Card title={`${currentMonth.month} ${t('home.title')}`}>
|
|
||||||
<View style={styles.balanceRow}>
|
|
||||||
<View style={styles.balanceItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.income')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>
|
|
||||||
{currentMonth.income.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.balanceItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.expense')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>
|
|
||||||
{currentMonth.expense.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.balanceItem}>
|
|
||||||
<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>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 月度趋势 */}
|
{/* 月度趋势 */}
|
||||||
@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,24 +229,91 @@ export default function ReportScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 月份切换器 */}
|
{/* Tab 选择切换栏 */}
|
||||||
<View style={styles.monthSwitcher}>
|
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
|
||||||
<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} />
|
{ key: 'weekly', label: t('report.tabWeekly') },
|
||||||
</Pressable>
|
{ key: 'monthly', label: t('report.tabMonthly') },
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
{ key: 'annual', label: t('report.tabAnnual') }
|
||||||
{monthLabel}
|
] as const).map(tab => (
|
||||||
</Text>
|
<Pressable
|
||||||
<Pressable onPress={nextMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
key={tab.key}
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
onPress={() => {
|
||||||
</Pressable>
|
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>
|
</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}>
|
||||||
|
<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} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{monthLabel}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={nextMonth} 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 === '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>
|
<>
|
||||||
|
<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 title={t('report.categoryPie')}>
|
<CategoryPie transactions={weeklyTxs} />
|
||||||
<CategoryPie transactions={transactions} />
|
</>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
<Card title={`${monthLabel} ${t('report.heatmap')}`}>
|
{/* 月度报表看板 */}
|
||||||
<CalendarHeatmap transactions={transactions} year={viewYear} month={viewMonth} />
|
{activeTab === 'monthly' && (
|
||||||
</Card>
|
<>
|
||||||
|
<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 title={t('report.netWorthTrend')}>
|
<CategoryPie transactions={monthlyTxs} />
|
||||||
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title={t('report.annual', { year: viewYear })}>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 年度报表看板 */}
|
||||||
|
{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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
|
||||||
useAutomationStore.getState().addDetected('notification', bill);
|
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 || '',
|
||||||
if (bill) {
|
body: event.body,
|
||||||
useAutomationStore.getState().addDetected('sms', bill);
|
timestamp: event.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
const bill = parseSms(smsEvent);
|
||||||
|
if (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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }]}>
|
||||||
</Text>
|
{t('ai.tapToRecord')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -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,16 +156,30 @@ export default function AutomationScreen() {
|
|||||||
Alert.alert(t('automation.bridgeUnavailable'));
|
Alert.alert(t('automation.bridgeUnavailable'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
Alert.alert(
|
||||||
const result = await bridge.rememberCurrentPage();
|
'准备记录目标页面',
|
||||||
Alert.alert(
|
'点击“开始”后,请在 8 秒内切换到支付宝/微信的账单详情页,系统将在倒计时结束后自动记录该页面的签名。',
|
||||||
t('automation.rememberPageSuccess'),
|
[
|
||||||
t('automation.rememberPageDesc', { pkg: getPackageLabel(result.package), activity: result.activity }),
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
);
|
{
|
||||||
refreshAccessibilityState();
|
text: '开始 (8s 倒计时)',
|
||||||
} catch (e) {
|
onPress: () => {
|
||||||
Alert.alert(t('automation.rememberPageFail'), String((e as Error)?.message ?? e));
|
setTimeout(async () => {
|
||||||
}
|
try {
|
||||||
|
const result = await bridge.rememberCurrentPage();
|
||||||
|
Alert.alert(
|
||||||
|
t('automation.rememberPageSuccess'),
|
||||||
|
t('automation.rememberPageDesc', { pkg: getPackageLabel(result.package), activity: result.activity }),
|
||||||
|
);
|
||||||
|
refreshAccessibilityState();
|
||||||
|
} catch (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;
|
||||||
}
|
}
|
||||||
try {
|
Alert.alert(
|
||||||
await bridge.triggerManualOcr();
|
'准备手动识别',
|
||||||
Alert.alert(t('automation.manualOcrTriggered'));
|
'点击“开始”后,请在 8 秒内切换到你想识别的账单详情页,系统将在 8 秒后自动截图并识别该页面。',
|
||||||
} catch (e) {
|
[
|
||||||
Alert.alert(t('automation.manualOcrFail'), String((e as Error)?.message ?? e));
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
}
|
{
|
||||||
|
text: '开始 (8s 倒计时)',
|
||||||
|
onPress: () => {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await bridge.triggerManualOcr();
|
||||||
|
} catch (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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* 日历视图页面(plan.md「1.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 },
|
|
||||||
});
|
|
||||||
@ -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)}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,9 +107,12 @@ 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>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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,8 +469,12 @@ export default function SyncSettingsScreen() {
|
|||||||
{/* 云同步配置与云端操作 */}
|
{/* 云同步配置与云端操作 */}
|
||||||
<Card title={t('sync.cloudTitle')}>
|
<Card title={t('sync.cloudTitle')}>
|
||||||
<View style={styles.syncBtnRow}>
|
<View style={styles.syncBtnRow}>
|
||||||
<Button label={t('sync.configWebdav')} onPress={() => setWebdavModalVisible(true)} variant="secondary" />
|
<View style={{ flex: 1 }}>
|
||||||
<Button label={t('sync.configGit')} onPress={() => setGitModalVisible(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" />
|
||||||
|
</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} />
|
||||||
@ -385,8 +493,12 @@ export default function SyncSettingsScreen() {
|
|||||||
{/* 本地备份与维护 */}
|
{/* 本地备份与维护 */}
|
||||||
<Card title={t('sync.backupTitle')}>
|
<Card title={t('sync.backupTitle')}>
|
||||||
<View style={styles.buttonRow}>
|
<View style={styles.buttonRow}>
|
||||||
<Button label={t('sync.backupNow')} onPress={handleBackup} />
|
<View style={{ flex: 1 }}>
|
||||||
<Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" />
|
<Button label={t('sync.backupNow')} onPress={handleBackup} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<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} />
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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');
|
|
||||||
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
|
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
|
||||||
const newRaw = lines.join('\n');
|
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'), '');
|
||||||
|
const newRaw = lines.join('\n');
|
||||||
|
|
||||||
|
newContent = newContent.replace(companion.raw, newRaw);
|
||||||
|
if (companion.id === tx.id) {
|
||||||
|
newCurrentRaw = newRaw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newContent = mobileBean.replace(tx.raw, 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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,60 +365,81 @@ 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;
|
||||||
if (direction === 'expense') {
|
|
||||||
// 支出: 来源账户 扣减(负), 费用账户 增加(正)
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: `-${amt}`, currency },
|
|
||||||
{ account: categoryAccount, amount: amt, currency },
|
|
||||||
];
|
|
||||||
} else if (direction === 'income') {
|
|
||||||
// 收入: 来源账户 增加(正), 收入账户 扣减(负)
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: amt, currency },
|
|
||||||
{ account: categoryAccount, amount: `-${amt}`, currency },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// 转账: 来源账户 扣减(负), 目标账户 增加(正)
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: `-${amt}`, currency },
|
|
||||||
{ account: targetAccount, amount: amt, currency },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const draft = {
|
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
|
||||||
date,
|
|
||||||
payee: payee.trim() || undefined,
|
|
||||||
flag: editId ? editFlag : undefined,
|
|
||||||
narration: narration.trim() || (
|
|
||||||
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
|
||||||
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
|
||||||
t('transaction.narrationDefaultTransfer')
|
|
||||||
),
|
|
||||||
postings: finalPostings,
|
|
||||||
tags: tagNames,
|
|
||||||
links: editId && editLinks.length > 0 ? editLinks : undefined,
|
|
||||||
metadata: editId ? editMetadata : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editId && oldRaw) {
|
if (editId && oldRaw) {
|
||||||
// 编辑模式
|
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
|
||||||
|
let finalPostings: Posting[];
|
||||||
|
if (direction === 'expense') {
|
||||||
|
finalPostings = [
|
||||||
|
{ account: sourceAccount, amount: `-${amt}`, currency },
|
||||||
|
{ account: categoryAccount, amount: amt, currency },
|
||||||
|
];
|
||||||
|
} else if (direction === 'income') {
|
||||||
|
finalPostings = [
|
||||||
|
{ account: sourceAccount, amount: amt, currency },
|
||||||
|
{ account: categoryAccount, amount: `-${amt}`, currency },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
finalPostings = [
|
||||||
|
{ account: sourceAccount, amount: `-${amt}`, currency },
|
||||||
|
{ account: targetAccount, amount: amt, currency },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
date,
|
||||||
|
payee: payee.trim() || undefined,
|
||||||
|
flag: editFlag,
|
||||||
|
narration: narration.trim() || (
|
||||||
|
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
||||||
|
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
||||||
|
t('transaction.narrationDefaultTransfer')
|
||||||
|
),
|
||||||
|
postings: finalPostings,
|
||||||
|
tags: tagNames,
|
||||||
|
links: editLinks.length > 0 ? editLinks : undefined,
|
||||||
|
metadata: editMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
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,138 +500,166 @@ 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')}>
|
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
|
||||||
{/* 日期 */}
|
{!isAdvanced ? (
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
|
<Card title={t('transaction.newTitle')}>
|
||||||
{t('transaction.formDate')}
|
{/* 金额 */}
|
||||||
</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
<TextInput
|
{t('transaction.amountPlaceholder')}
|
||||||
value={date}
|
</Text>
|
||||||
onChangeText={setDate}
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
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
|
<TextInput
|
||||||
value={payee}
|
value={amount}
|
||||||
onChangeText={setPayee}
|
onChangeText={setAmount}
|
||||||
placeholder={t('transaction.formPayeePlaceholder')}
|
autoFocus={editId ? false : true}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
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 }]}
|
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 简单模式字段 */}
|
|
||||||
{!isAdvanced ? (
|
|
||||||
<View>
|
|
||||||
{/* 金额 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
|
||||||
{t('transaction.amountPlaceholder')}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
||||||
<TextInput
|
|
||||||
value={amount}
|
|
||||||
onChangeText={setAmount}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
placeholder={t('transaction.amountPlaceholder')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]}
|
|
||||||
/>
|
|
||||||
{/* 币种选择 */}
|
|
||||||
<TextInput
|
|
||||||
value={currency}
|
|
||||||
onChangeText={setCurrency}
|
|
||||||
placeholder="CNY"
|
|
||||||
maxLength={3}
|
|
||||||
autoCapitalize="characters"
|
|
||||||
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, width: 70 }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 来源账户 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
|
||||||
{t('transaction.formSourceAccount')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
value={sourceAccount}
|
value={currency}
|
||||||
onChangeText={setSourceAccount}
|
onChangeText={setCurrency}
|
||||||
placeholder="Assets:支付宝余额"
|
placeholder="CNY"
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
maxLength={3}
|
||||||
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
autoCapitalize="characters"
|
||||||
|
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 目标账户 (仅在转账时显示) */}
|
|
||||||
{direction === 'transfer' && (
|
|
||||||
<View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
|
||||||
{t('transaction.targetAccount')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={targetAccount}
|
|
||||||
onChangeText={setTargetAccount}
|
|
||||||
placeholder="Assets:WeChat"
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
|
||||||
{t('transaction.formAvailable')}: {assetAccounts.join(' / ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
// 高级模式字段: 接入 PostingEditor 与 BalanceIndicator
|
{/* 来源账户 */}
|
||||||
<View style={{ marginTop: 12 }}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
{t('transaction.formSourceAccount')}
|
||||||
{t('transaction.postingsList')}
|
</Text>
|
||||||
</Text>
|
<TextInput
|
||||||
|
value={sourceAccount}
|
||||||
<PostingEditor
|
onChangeText={setSourceAccount}
|
||||||
postings={postings}
|
placeholder="Assets:支付宝余额"
|
||||||
onChange={(p) => setPostings(p)}
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
/>
|
style={commonStyles.input}
|
||||||
|
/>
|
||||||
<View style={styles.postingBtnRow}>
|
|
||||||
|
{/* 快捷来源账户选择 */}
|
||||||
|
<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' && (
|
||||||
|
<View style={{ marginTop: 10 }}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{t('transaction.targetAccount')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={targetAccount}
|
||||||
|
onChangeText={setTargetAccount}
|
||||||
|
placeholder="Assets:WeChat"
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card title={t('transaction.newTitle')}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{t('transaction.postingsList')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PostingEditor
|
||||||
|
postings={postings}
|
||||||
|
onChange={(p) => setPostings(p)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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,29 +679,105 @@ 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>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{selectedCategory.linkedAccount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 标签选择 */}
|
{/* 详情与高级字段收纳区 */}
|
||||||
<Card title={t('transaction.formTags')}>
|
<Pressable
|
||||||
<TagPicker
|
onPress={() => {
|
||||||
tags={tags}
|
LayoutAnimation.easeInEaseOut();
|
||||||
selectedNames={selectedTags.map(tg => tg.name)}
|
setShowDetails(!showDetails);
|
||||||
onToggle={toggleTag}
|
}}
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
{selectedTags.length > 0 && (
|
</Pressable>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16 }]}>
|
|
||||||
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
|
{showDetails && (
|
||||||
</Text>
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
)}
|
<Card title={t('transaction.extraDetails')}>
|
||||||
</Card>
|
{/* 日期 */}
|
||||||
|
<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')}>
|
||||||
|
<TagPicker
|
||||||
|
tags={tags}
|
||||||
|
selectedNames={selectedTags.map(tg => tg.name)}
|
||||||
|
onToggle={toggleTag}
|
||||||
|
/>
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||||
|
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 }]}>
|
||||||
|
|||||||
@ -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 }}
|
||||||
styles.button,
|
|
||||||
{
|
|
||||||
backgroundColor: bg,
|
|
||||||
borderRadius: theme.radii.md,
|
|
||||||
opacity: pressed || disabled ? 0.6 : 1,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Text style={[styles.text, { color: fg }]}>{label}</Text>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
transform: [{ scale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,18 +1,8 @@
|
|||||||
/**
|
|
||||||
* 日历视图组件(plan.md「5.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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
Animated.spring(scale, {
|
||||||
|
toValue: 0.98,
|
||||||
|
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,
|
||||||
|
borderRadius: theme.radii.lg,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
...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 (
|
return (
|
||||||
<Container
|
<View
|
||||||
onPress={onPress}
|
|
||||||
style={[
|
style={[
|
||||||
styles.card,
|
styles.card,
|
||||||
{
|
cardStyle,
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
borderRadius: theme.radii.lg,
|
|
||||||
padding: theme.spacing.md,
|
|
||||||
...theme.shadows.sm,
|
|
||||||
},
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 }]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 }]}>
|
||||||
|
|||||||
@ -1,42 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* 分类占比图(plan.md「5.2 图表与可视化」)。
|
* 分类占比图(plan.md「5.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 }]}>
|
||||||
{data.map(d => (
|
{t('report.categoryTitle')}
|
||||||
<View key={d.category} style={styles.row}>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, flex: 1 }]} numberOfLines={1}>
|
<View style={styles.list}>
|
||||||
{d.category.replace('Expenses:', '')}
|
{data.map(d => (
|
||||||
</Text>
|
<View key={d.category} style={styles.row}>
|
||||||
<View style={styles.barWrap}>
|
<Text
|
||||||
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
|
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>
|
||||||
|
<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>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontFamily: 'monospace', fontSize: 13 }]}>
|
||||||
|
{d.percentage}%
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 50, textAlign: 'right' }]}>
|
))}
|
||||||
{d.percentage}%
|
</View>
|
||||||
</Text>
|
{data.length === 0 && (
|
||||||
</View>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 15 }]}>
|
||||||
))}
|
{t('common.noData')}
|
||||||
{data.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>暂无数据</Text>}
|
</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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,49 +1,189 @@
|
|||||||
/**
|
/**
|
||||||
* 趋势线图(plan.md「5.2 图表与可视化」)。
|
* 趋势折线图(plan.md「5.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">
|
||||||
|
<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>
|
||||||
</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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -145,10 +145,10 @@ export function buildCategoryRecognitionPrompt(event: ImportedEvent, categories:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 资产映射 prompt(plan.md「8.5」)。 */
|
/** 资产映射 prompt(plan.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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) {
|
||||||
drafts.push({
|
if (!isTransferInHistory(transfer, ctx.history)) {
|
||||||
draft: transfer.draft,
|
drafts.push({
|
||||||
sourceEventIds: [transfer.leftEventId, transfer.rightEventId],
|
draft: transfer.draft,
|
||||||
isTransfer: true,
|
sourceEventIds: [transfer.leftEventId, transfer.rightEventId],
|
||||||
});
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
src/domain/channelConfig.ts
Normal file
82
src/domain/channelConfig.ts
Normal 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
86
src/domain/constants.ts
Normal 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 = ['买', '花', '消费', '支付', '记账', '付', '收入', '赚', '工资', '收'];
|
||||||
@ -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('.');
|
||||||
|
|||||||
@ -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;
|
||||||
return {
|
if (sameCounterparty) {
|
||||||
isDuplicate: true,
|
return {
|
||||||
confidence: 'medium',
|
isDuplicate: true,
|
||||||
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、不同渠道(${event.channel} vs ${ex.channel})`,
|
confidence: 'medium',
|
||||||
};
|
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) {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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> = {};
|
||||||
for (const [currency, amounts] of grouped) {
|
if (emptyAmountCount === 0) {
|
||||||
balances[currency] = addDecimals(amounts);
|
for (const [currency, amounts] of grouped) {
|
||||||
if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`);
|
balances[currency] = addDecimals(amounts);
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 后可用)。 */
|
||||||
|
|||||||
@ -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 ?? '' },
|
||||||
|
|||||||
@ -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,36 +74,41 @@ 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: 规则匹配(基于 OCR 文本)
|
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配,
|
||||||
const ruleMatch = matchOcrRule(ocrText, packageName);
|
// 以避免“银行卡”等规则在详情页上误提取(例如误将“消费1次”提取为“-1元”)。
|
||||||
if (ruleMatch.matched && ruleMatch.event) {
|
const isDetailPage = checkIsDetailPage(ocrText);
|
||||||
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
|
if (!isDetailPage) {
|
||||||
|
// Layer 1: 规则匹配(基于 OCR 文本)
|
||||||
|
const ruleMatch = matchOcrRule(ocrText, packageName);
|
||||||
|
if (ruleMatch.matched && ruleMatch.event) {
|
||||||
|
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 2: 退化解析
|
// Layer 2: 退化解析
|
||||||
|
|||||||
10
src/domain/pipelineSingleton.ts
Normal file
10
src/domain/pipelineSingleton.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* BillPipeline 全局单例。
|
||||||
|
*
|
||||||
|
* importStore 和 automationStore 各自创建独立的 BillPipeline 会导致
|
||||||
|
* 序列化锁不跨实例,破坏并发安全假设。本模块提供共享单例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BillPipeline } from './billPipeline';
|
||||||
|
|
||||||
|
export const sharedPipeline = new BillPipeline();
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,16 +120,40 @@ export function classifyWithCategories(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 规则未命中:用分类库匹配
|
// 规则未命中:用分类库匹配
|
||||||
const type: 'income' | 'expense' = (event.direction === 'income') ? 'income' : 'expense';
|
// refund 视为 income(退款是资金回流),fee/transfer 视为 expense
|
||||||
const candidates = categories.filter(c => c.type === type);
|
const isIncome = event.direction === 'income' || event.direction === 'refund';
|
||||||
// 匹配文本综合对手方 + 备注(关键词可能在任一字段)
|
const type: 'income' | 'expense' = isIncome ? 'income' : 'expense';
|
||||||
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
|
|
||||||
const match = matchCategory(matchText, candidates);
|
// 智能还款识别(花呗还款/信用卡还款属于负债偿还,目标账户为 Liabilities)
|
||||||
const resolved = resolveCategoryAccount(match.category, type, ledger);
|
const fullText = [event.raw?.text, event.counterparty, event.memo].filter(Boolean).join(' ');
|
||||||
categoryAccount = resolved.account;
|
let isRepayment = false;
|
||||||
categoryValid = resolved.valid;
|
if (/还款/.test(fullText)) {
|
||||||
categoryId = match.category?.id;
|
if (/花呗/.test(fullText)) {
|
||||||
if (!resolved.valid) categoryFallbackReason = resolved.fallbackReason;
|
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 matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
|
||||||
|
const match = matchCategory(matchText, candidates);
|
||||||
|
const resolved = resolveCategoryAccount(match.category, type, ledger);
|
||||||
|
categoryAccount = resolved.account;
|
||||||
|
categoryValid = resolved.valid;
|
||||||
|
categoryId = match.category?.id;
|
||||||
|
if (!resolved.valid) categoryFallbackReason = resolved.fallbackReason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const narration = getNarration(rule?.narration, event);
|
const narration = getNarration(rule?.narration, event);
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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[] } {
|
||||||
|
|||||||
107
src/domain/transactionBuilder.ts
Normal file
107
src/domain/transactionBuilder.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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.md「2.7」):转账识别**先于去重**。
|
* 执行顺序(plan.md「2.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: '金额时间匹配' };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
11
src/hooks/useDebounce.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/** 防抖 hook:value 变化后延迟 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;
|
||||||
|
}
|
||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/i18n/en.ts
114
src/i18n/en.ts
@ -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',
|
||||||
|
|||||||
128
src/i18n/zh.ts
128
src/i18n/zh.ts
@ -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: '截图监控模块不可用',
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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 白名单的友好标签列表。 */
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 本地备份与恢复(plan.md「0.5 本地备份与恢复」)。
|
* 本地备份与恢复(plan.md「0.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 },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
await FileSystem.deleteAsync(TMP_PATH, { idempotent: true });
|
if (fileInfo.exists) {
|
||||||
|
// 正式文件存在:tmp 是崩溃残留,丢弃
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.md「4.1」):从通知 title+text 提取账单事件。
|
* 通知解析器(plan.md「4.1」):从通知 title+text 提取账单事件。
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user