Compare commits

...

2 Commits

Author SHA1 Message Date
fengmengqi
76a5853ab6 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 设计系统文档
- 测试全面更新覆盖以上所有变更
2026-07-18 18:02:45 +08:00
fengmengqi
f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00
195 changed files with 52644 additions and 1251 deletions

18
.gitignore vendored
View File

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

46
App.tsx
View File

@ -1,46 +0,0 @@
import { useMemo, useState } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { classify, commitMobileTransaction, importStatement, parseLedger, type ImportedEvent, type Rule, validateTransaction } from './src/domain';
const sampleLedger = `option "operating_currency" "CNY"
2026-01-01 open Assets:Alipay CNY
2026-01-01 open Assets:WeChat CNY
2026-01-01 open Expenses:Food CNY
2026-01-01 open Expenses:Uncategorized CNY
2026-01-01 open Income:Uncategorized CNY
include "mobile.bean"
`;
const sampleStatement = '交易时间,金额(元),收/支,交易对方,商品说明,交易订单号\n2026-07-01,24.50,支出,咖啡店,冰美式,ORDER-1\n2026-07-02,5000,收入,公司,工资,ORDER-2';
const rules: Rule[] = [{ id: 'coffee', priority: 100, channel: 'Alipay', counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', narration: '咖啡', tags: ['food'], hits: 0 }];
type Tab = '账本' | '记账' | '导入' | '规则' | '诊断';
export default function App() {
const ledger = useMemo(() => parseLedger([{ path: 'main.bean', content: sampleLedger }]), []);
const [tab, setTab] = useState<Tab>('账本'); const [mobileBean, setMobileBean] = useState(''); const [events, setEvents] = useState<ImportedEvent[]>([]);
const [message, setMessage] = useState('账本已解析。手机交易只会写入 mobile.bean。');
const [amount, setAmount] = useState(''); const [account, setAccount] = useState('Expenses:Food');
const addManual = () => {
const draft = { date: '2026-07-10', narration: '手工记账', postings: [{ account: 'Assets:Alipay', amount: `-${amount}`, currency: 'CNY' }, { account, amount, currency: 'CNY' }] };
const result = validateTransaction(draft, ledger); if (!result.valid) return setMessage(result.errors.join(''));
try { const commit = commitMobileTransaction(draft, ledger, mobileBean); setMobileBean(commit.content); setAmount(''); setMessage('已追加到 mobile.bean。'); } catch (error) { setMessage(String(error)); }
};
const loadDemo = () => { setEvents(importStatement(sampleStatement, 'alipay-csv-v1')); setMessage('已导入 2 条账单;请逐条确认。'); };
const confirm = (event: ImportedEvent) => {
const candidate = classify(event, rules, ledger); try { const commit = commitMobileTransaction(candidate.draft, ledger, mobileBean); setMobileBean(commit.content); setEvents(current => current.filter(item => item.id !== event.id)); setMessage(candidate.ruleId ? '已按规则确认并写入 mobile.bean。' : '未匹配规则,已确认未分类分录。'); } catch (error) { setMessage(String(error)); }
};
return <SafeAreaView style={styles.page}><StatusBar style="dark" />
<View style={styles.header}><Text style={styles.title}>Bean Mobile</Text><Text style={styles.subtitle}>{message}</Text></View>
<View style={styles.tabs}>{(['账本', '记账', '导入', '规则', '诊断'] as Tab[]).map(item => <Pressable key={item} onPress={() => setTab(item)} style={[styles.tab, tab === item && styles.activeTab]}><Text style={tab === item ? styles.activeText : styles.tabText}>{item}</Text></Pressable>)}</View>
<ScrollView contentContainerStyle={styles.content}>
{tab === '账本' && <><Card title="账本状态"><Text>{ledger.accounts.size} · {ledger.transactions.length} </Text><Text style={styles.muted}> include "mobile.bean"</Text></Card><Card title="mobile.bean"><Text>{mobileBean || '尚无手机端交易'}</Text></Card></>}
{tab === '记账' && <Card title="手工复式记账"><TextInput value={amount} onChangeText={setAmount} keyboardType="decimal-pad" placeholder="金额,例如 24.50" style={styles.input}/><TextInput value={account} onChangeText={setAccount} placeholder="费用账户" style={styles.input}/><Button label="校验并追加到 mobile.bean" onPress={addManual}/></Card>}
{tab === '导入' && <><Card title="账单自动记账"><Text style={styles.muted}> CSV</Text><Button label="导入演示支付宝 CSV" onPress={loadDemo}/></Card>{events.map(event => { const candidate = classify(event, rules, ledger); return <Card key={event.id} title={`${event.occurredAt} · ${event.amount} ${event.currency}`}><Text>{event.counterparty} · {event.memo}</Text><Text style={styles.muted}>{candidate.ruleId ? `规则:${candidate.ruleId}` : '未匹配:将使用 Uncategorized'}</Text><Text>{candidate.draft.postings.map(posting => `${posting.account} ${posting.amount} ${posting.currency}`).join('\n')}</Text><Button label="确认入账" onPress={() => confirm(event)}/></Card>; })}</>}
{tab === '规则' && <Card title="自动分类规则">{rules.map(rule => <View key={rule.id} style={styles.row}><Text>{rule.id}</Text><Text style={styles.muted}>{rule.counterpartyContains} {rule.categoryAccount}</Text></View>)}</Card>}
{tab === '诊断' && <Card title="只读兼容与诊断"><Text>{ledger.diagnostics.length}</Text><Text>{ledger.unsupported.length}</Text><Text style={styles.muted}></Text></Card>}
</ScrollView>
</SafeAreaView>;
}
function Card({ title, children }: { title: string; children: React.ReactNode }) { return <View style={styles.card}><Text style={styles.cardTitle}>{title}</Text>{children}</View>; }
function Button({ label, onPress }: { label: string; onPress: () => void }) { return <Pressable accessibilityRole="button" onPress={onPress} style={styles.button}><Text style={styles.buttonText}>{label}</Text></Pressable>; }
const styles = StyleSheet.create({ page: { flex: 1, backgroundColor: '#f6f7f9' }, header: { padding: 20, paddingBottom: 12 }, title: { fontSize: 28, fontWeight: '700' }, subtitle: { color: '#57606a', marginTop: 6 }, tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' }, tab: { flex: 1, paddingVertical: 12, alignItems: 'center' }, activeTab: { borderBottomWidth: 2, borderColor: '#146c43' }, tabText: { color: '#57606a' }, activeText: { color: '#146c43', fontWeight: '700' }, content: { padding: 16, gap: 12 }, card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, gap: 8, shadowColor: '#000', shadowOpacity: .04, shadowRadius: 6 }, cardTitle: { fontSize: 18, fontWeight: '700' }, muted: { color: '#687078', lineHeight: 20 }, input: { borderWidth: 1, borderColor: '#d0d7de', borderRadius: 8, padding: 11, backgroundColor: '#fff' }, button: { marginTop: 6, backgroundColor: '#146c43', padding: 12, borderRadius: 8, alignItems: 'center' }, buttonText: { color: '#fff', fontWeight: '700' }, row: { borderTopWidth: 1, borderColor: '#eef0f2', paddingTop: 10, gap: 3 } });

View File

@ -3,8 +3,32 @@
"name": "Bean Mobile",
"slug": "bean-mobile",
"scheme": "beanmobile",
"plugins": [["expo-sqlite", { "enableFTS": true }]],
"ios": { "bundleIdentifier": "com.example.beanmobile" },
"android": { "package": "com.example.beanmobile" }
"plugins": [
[
"expo-sqlite",
{
"enableFTS": true
}
],
"expo-router",
"expo-localization",
"./plugins/ppocr",
"./plugins/accessibility",
"./plugins/notification-listener",
"./plugins/sms-receiver",
"./plugins/screenshot-monitor",
"./plugins/size-optimization",
"expo-secure-store"
],
"experiments": {
"tsConfigPath": "tsconfig.json",
"typedRoutes": true
},
"ios": {
"bundleIdentifier": "com.example.beanmobile"
},
"android": {
"package": "com.example.beanmobile"
}
}
}

View File

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

100
docs/android-build-guide.md Normal file
View File

@ -0,0 +1,100 @@
# Android 安装包打包与体积优化指南 (Android Build Guide)
在引入机器学习引擎ONNX Runtime及 React Native 新架构后,本项目的原生 C/C++ SO 库体积大幅增加。为避免将无意义的冗余架构包打包给最终手机用户,本项目对 Android 构建配置进行了专项体积优化。
本篇指南将为您介绍项目中的打包机制、优化细节,以及如何通过命令行进行差异化编译。
---
## 1. 体积优化核心原理
### 1.1 ABI 分包 (ABI Splits)
在 Android 系统的 `build.gradle` 中,我们启用了分包编译:
```groovy
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
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
> ```
---
## 2. 编译命令与打包指令
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
### 2.1 本地测试/实体分发(仅编译 arm64-v8a最快最推荐
直接运行默认编译,会使用 `gradle.properties` 中配置 of `arm64-v8a`
```powershell
# 在 Windows Powershell 下执行:
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease --offline --no-daemon
```
编译完成后,可在以下路径找到适合真机安装的轻量版 APK约 37MB
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
---
### 2.2 全量分包发布(适合多设备兼容测试/全网发布)
若需要同时生成适配所有手机架构的独立 APK 以及一个通用包,可以在命令行中通过参数**覆盖默认架构**
```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 --offline --no-daemon
```
编译完成后,在输出目录会产生 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.**
```

3015
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "beancount-mobile",
"version": "0.1.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
@ -11,16 +11,45 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0",
"expo": "~54.0.0",
"expo-constants": "^18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.0",
"expo-image-picker": "~17.0.11",
"expo-linking": "^8.0.12",
"expo-local-authentication": "~17.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.24",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-sqlite": "~16.0.0",
"expo-status-bar": "~3.0.0",
"i18n-js": "^4.5.3",
"react": "19.1.0",
"react-native": "0.81.0"
"react-dom": "^19.1.0",
"react-native": "0.81.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-reanimated": "^3.18.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-view-shot": "4.0.3",
"text-encoding-gbk": "^0.7.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.0",
"vitest": "^3.2.0"
},
"overrides": {
"expo-font": "~14.0.12"
}
}

View File

@ -1,458 +0,0 @@
# OCR 自动记账集成方案
## 摘要
借鉴 AutoAccounting 项目的 OCR 模式,为 beancount-mobile 增加屏幕识别自动记账能力。用户在支付宝/微信/银行 App 付款后,通过无障碍服务截屏 → OCR 识别 → 解析账单 → 生成 Beancount 复式分录 → 用户确认入账。
**核心原则**:不修改任何应用、不需要 Root、不需要 Shizuku仅依赖 Android 无障碍权限。
## 技术方案
### 架构概览
```
用户在支付 App 完成付款
Android 无障碍服务检测到页面变化
AccessibilityService.takeScreenshot() 截屏API 30+
Google ML Kit OCR 识别文字
正则 + 规则引擎解析为 ImportedEvent
复用现有 classify() → TransactionDraft
用户确认 → commitMobileTransaction() → mobile.bean
```
### 依赖 AutoAccounting 的部分
| AutoAccounting 组件 | 用途 | beancount-mobile 替代方案 |
| --------------------------- | ------------------------- | ---------------------------------- |
| `OcrTools.kt` | 无障碍截屏 + 前台应用检测 | 原生模块重写,逻辑一致 |
| `OcrProcessor.kt` | PP-OCRv5 文字识别 | Google ML Kit免费无需 AAR |
| `JsExecutor.kt` | QuickJS 规则引擎 | 复用现有`importStatement` + 规则 |
| `BillService.kt` | 账单分析流程 | 复用现有`classify()` 流程 |
| `PageSignatureManager.kt` | 页面特征匹配 | 可选,首版不做 |
| `FlipDetector.kt` | 翻转触发 | 改为悬浮按钮触发 |
### 不依赖的部分
- Shizuku SDK无障碍模式不需要
- Xposed/LSPatch 框架
- Ktor 嵌入式服务器
- Room 数据库
- MMKV 配置存储
- TapBack 双击背部模块
## 实现计划
### 阶段一原生模块搭建3 天)
#### 1.1 创建 Android 原生模块目录结构
```
android/app/src/main/java/com/beancount/mobile/
├── ocr/
│ ├── OcrModule.kt # React Native 原生模块
│ ├── OcrAccessibilityService.kt # 无障碍服务
│ └── OcrManager.kt # OCR 处理管理器
```
#### 1.2 实现 OcrAccessibilityService.kt
参考 AutoAccounting 的 `OcrTools.kt`,实现:
```kotlin
class OcrAccessibilityService : AccessibilityService() {
// 1. 监听窗口变化事件
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// 检测前台应用变化
// 触发截屏回调
}
// 2. 截屏功能API 30+
fun takeScreenshot(callback: (Bitmap?) -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
val bitmap = Bitmap.wrapHardwareBuffer(
result.hardwareBuffer, result.colorSpace
)
callback(bitmap)
result.hardwareBuffer.close()
}
override fun onFailure(errorCode: Int) {
callback(null)
}
}
)
}
}
// 3. 获取前台应用包名
fun getTopPackage(): String? {
// 通过 rootInActiveWindow 获取
}
}
```
#### 1.3 实现 OcrModule.ktReact Native Bridge
```kotlin
@ReactModule(name = "OcrModule")
class OcrModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@ReactMethod
fun startOcrService(promise: Promise) {
// 启动无障碍服务
}
@ReactMethod
fun stopOcrService(promise: Promise) {
// 停止无障碍服务
}
@ReactMethod
fun takeScreenshot(promise: Promise) {
// 调用无障碍服务截屏
// 返回 base64 编码的图片
}
@ReactMethod
fun getTopApp(promise: Promise) {
// 返回前台应用包名
}
@ReactMethod
fun isServiceEnabled(promise: Promise) {
// 检查无障碍服务是否已启用
}
}
```
#### 1.4 配置 AndroidManifest.xml
```xml
<service
android:name=".ocr.OcrAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
```
#### 1.5 创建无障碍服务配置
`android/app/src/main/res/xml/accessibility_service_config.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/ocr_service_description"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canTakeScreenshot="true"
android:canRetrieveWindowContent="false" />
```
### 阶段二OCR 引擎集成2 天)
#### 2.1 添加 ML Kit 依赖
`android/app/build.gradle`:
```gradle
dependencies {
// Google ML Kit OCR
implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}
```
#### 2.2 实现 OcrManager.kt
```kotlin
class OcrManager(private val context: Context) {
private val recognizer = TextRecognition.getClient(
ChineseTextRecognizerOptions.Builder().build()
)
suspend fun recognizeText(bitmap: Bitmap): String {
val image = InputImage.fromBitmap(bitmap, 0)
val result = recognizer.process(image).await()
return result.text
}
}
```
#### 2.3 金额/商户正则解析
参考 AutoAccounting 的 JS 规则,用 Kotlin 正则实现:
```kotlin
object BillParser {
// 金额匹配¥100.00 / 100.00元 / -50.50
private val amountPattern = Regex("""[¥¥]?\s*(-?\d+\.?\d*)\s*元?""")
// 时间匹配2026-07-10 14:30:00 / 07-10 14:30
private val timePattern = Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}(?::\d{2})?)""")
// 商户匹配:支付成功至 XXX / 商户名称XXX
private val merchantPattern = Regex("""(?:商户|商家|收款方)[:]\s*(.+?)(?:\s|$)""")
fun parse(ocrText: String, appPackage: String): ImportedEvent? {
val amount = amountPattern.find(ocrText)?.groupValues?.get(1) ?: return null
val time = timePattern.find(ocrText)?.groupValues?.get(1) ?: return null
val merchant = merchantPattern.find(ocrText)?.groupValues?.get(1) ?: "未知商户"
// 根据 appPackage 判断渠道
val channel = when {
appPackage.contains("alipay") -> "Alipay"
appPackage.contains("wechat") -> "WeChat"
else -> "Bank"
}
return ImportedEvent(
id = "ocr-${System.currentTimeMillis()}",
occurredAt = time.replace("/", "-").take(10),
amount = amount,
currency = "CNY",
direction = if (amount.startsWith("-")) "expense" else "income",
channel = channel,
counterparty = merchant,
memo = "OCR 自动识别",
raw = mapOf("ocrText" to ocrText)
)
}
}
```
### 阶段三JS 层集成2 天)
#### 3.1 创建 OCR Bridge 模块
`src/ocr/OcrBridge.ts`:
```typescript
import { NativeModules, Platform } from 'react-native';
const { OcrModule } = NativeModules;
export interface OcrResult {
text: string;
imagePath: string;
}
export class OcrBridge {
static async isAvailable(): Promise<boolean> {
if (Platform.OS !== 'android') return false;
return await OcrModule?.isServiceEnabled() ?? false;
}
static async startService(): Promise<void> {
await OcrModule?.startOcrService();
}
static async takeScreenshot(): Promise<string | null> {
return await OcrModule?.takeScreenshot();
}
static async getTopApp(): Promise<string | null> {
return await OcrModule?.getTopApp();
}
}
```
#### 3.2 OCR 识别流程
`src/ocr/OcrProcessor.ts`:
```typescript
import { OcrBridge } from './OcrBridge';
import { importStatement, type ImportedEvent } from '../domain';
export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
// 1. 截屏
const base64Image = await OcrBridge.takeScreenshot();
if (!base64Image) return null;
// 2. 获取前台应用
const topApp = await OcrBridge.getTopApp();
if (!topApp) return null;
// 3. OCR 识别(通过原生模块)
const ocrText = await OcrModule.recognizeText(base64Image);
if (!ocrText) return null;
// 4. 解析为 ImportedEvent
const event = parseOcrText(ocrText, topApp);
return event;
}
```
#### 3.3 集成到现有导入流程
修改 `App.tsx` 的导入标签页,添加 OCR 入口:
```typescript
const handleOcrImport = async () => {
const event = await processOcrScreenshot();
if (event) {
setEvents(current => [...current, event]);
setMessage('OCR 识别成功,请确认账单。');
} else {
setMessage('OCR 识别失败,请重试。');
}
};
```
### 阶段四UI 适配2 天)
#### 4.1 添加 OCR 触发按钮
在导入标签页添加"屏幕识别"按钮:
```tsx
{tab === '导入' && (
<>
<Card title="自动记账">
<Button label="屏幕识别OCR" onPress={handleOcrImport} />
<Button label="导入 CSV 文件" onPress={loadDemo} />
</Card>
{/* 现有的事件列表 */}
</>
)}
```
#### 4.2 OCR 结果确认界面
展示识别结果,允许用户修正:
```tsx
<Card title={`OCR 识别 · ${event.amount} ${event.currency}`}>
<Text>{event.counterparty} · {event.memo}</Text>
<Text style={styles.muted}>来源:{event.channel} · {event.occurredAt}</Text>
{/* 编辑按钮 */}
<Button label="确认入账" onPress={() => confirm(event)} />
</Card>
```
#### 4.3 无障碍权限引导
首次使用时引导用户开启无障碍权限:
```tsx
const enableOcr = async () => {
const available = await OcrBridge.isAvailable();
if (!available) {
// 打开系统无障碍设置
await OcrBridge.startService();
}
};
```
### 阶段五测试与优化2 天)
#### 5.1 测试用例
| 测试场景 | 预期结果 |
| ---------------------- | ------------------------ |
| 支付宝付款成功页 OCR | 正确识别金额、商户、时间 |
| 微信支付凭证页 OCR | 正确识别金额、商户 |
| 银行卡扣款通知页 OCR | 正确识别金额、来源 |
| 识别失败(非支付页面) | 返回 null提示重试 |
| 重复识别同一笔交易 | 去重,提示已存在 |
#### 5.2 性能优化
- 截屏后立即回收 Bitmap避免内存泄漏
- OCR 识别在后台线程执行
- 缓存最近识别结果,避免重复处理
#### 5.3 兼容性处理
- Android 11 以下不支持 `takeScreenshot()`,降级为提示用户手动截图
- 不同支付 App 的页面布局差异,通过正则适配
## 文件清单
### 新增文件
```
android/app/src/main/java/com/beancount/mobile/ocr/
├── OcrModule.kt # React Native 原生模块
├── OcrAccessibilityService.kt # 无障碍服务
└── OcrManager.kt # OCR 处理管理器
android/app/src/main/res/xml/
└── accessibility_service_config.xml # 无障碍服务配置
src/ocr/
├── OcrBridge.ts # JS 层 Bridge
└── OcrProcessor.ts # OCR 处理逻辑
```
### 修改文件
```
android/app/build.gradle # 添加 ML Kit 依赖
android/app/src/main/AndroidManifest.xml # 注册无障碍服务
App.tsx # 添加 OCR 入口
package.json # 无变化(纯原生模块)
```
## 依赖清单
| 依赖 | 版本 | 用途 | 必需 |
| ------------------------------ | ------- | -------- | ---- |
| Google ML Kit Text Recognition | 16.0.0 | 中文 OCR | 是 |
| React Native | 0.81.0 | 框架 | 是 |
| Expo | ~54.0.0 | 开发框架 | 是 |
**不需要的依赖**
- Shizuku SDK
- Xposed/LSPatch
- PP-OCR AAR
- QuickJS
## 风险与限制
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------ | ------------------ |
| Android < 11 不支持截屏 API | 低版本设备无法使用 | 降级为手动截图导入 |
| 支付 App 页面更新 | OCR 正则失效 | 正则设计为宽松匹配 |
| 无障碍权限被系统回收 | 服务停止 | 前台通知保活 |
| OCR 识别准确率 | 可能识别错误 | 用户确认环节兜底 |
## 预估工期
| 阶段 | 工作量 | 产出 |
| ---------------- | --------------- | ----------------- |
| 阶段一:原生模块 | 3 天 | 无障碍服务 + 截屏 |
| 阶段二OCR 引擎 | 2 天 | ML Kit 集成 |
| 阶段三JS 集成 | 2 天 | Bridge + 解析 |
| 阶段四UI 适配 | 2 天 | 按钮 + 确认界面 |
| 阶段五:测试优化 | 2 天 | 测试用例 + 兼容性 |
| **总计** | **11 天** | |
## 与 plan.md 的关系
本方案是 plan.md 的扩展,补充了 OCR 自动记账能力。plan.md 中明确"首版不做 OCR",本方案作为第二阶段实现。
两份文档的关系:
- `plan.md`:核心架构 + CSV 导入 + 手工记账(首版)
- `plan-ocr.md`OCR 屏幕识别 + 自动记账(第二阶段)
实现顺序:先完成 plan.md 的核心功能,再实现 plan-ocr.md 的 OCR 能力。

4610
plan.md

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,160 @@
/**
* 无障碍服务 Config Pluginplan.md3.6 无障碍服务+决策 4
*
* expo prebuild 时注册 Android 无障碍服务manifest service + xml 配置
* 参考 AutoAccounting SelectToSpeakService 伪装机制侧载保留plan.md 决策 3
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.accessibility';
/** 递归复制目录。 */
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) copyDir(s, d);
else fs.copyFileSync(s, d);
}
}
function withAccessibilityService(config) {
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility');
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f));
}
}
// res/xml 资源
const resSrc = path.join(__dirname, 'android/res');
if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
}
// 确保有 accessibility_service_description 字符串资源
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
if (fs.existsSync(stringsXmlPath)) {
let content = fs.readFileSync(stringsXmlPath, 'utf8');
if (!content.includes('accessibility_service_description')) {
content = content.replace(
/<\/resources>/,
' <string name="accessibility_service_description">自动识别支付账单页面,辅助快速记账</string>\n</resources>',
);
fs.writeFileSync(stringsXmlPath, content, 'utf8');
}
}
return modConfig;
},
]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
}
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n add(AccessibilityBridgePackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(AccessibilityBridgePackage()) }`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 3. 注册服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 1. 添加无障碍服务声明
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别',
'android:exported': 'false',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.accessibilityservice.AccessibilityService' } }],
}],
'meta-data': [{
$: {
'android:name': 'android.accessibilityservice',
'android:resource': '@xml/accessibility_service_config',
},
}],
};
// 2. 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.service) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
);
if (!exists) {
app.service.push(serviceNode);
}
// 3. 添加 OcrTileService 声明快速设置磁贴plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.quicksettings.action.QS_TILE' } }],
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService'
);
if (!tileExists) {
app.service.push(tileServiceNode);
}
return modConfig;
});
return config;
}
module.exports = withAccessibilityService;

View File

@ -0,0 +1,6 @@
{
"name": "beancount-mobile-plugin-accessibility",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}

View File

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

View File

@ -0,0 +1,69 @@
/**
* 通知监听 Config Pluginplan.md4.1 通知监听服务+决策 4
*
* expo prebuild 时注册 Android NotificationListenerServicemanifest service + 权限
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application
*/
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function withNotificationListener(config) {
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
}
}
return modConfig;
},
]);
// 2. 注册服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 1. 添加通知监听服务
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService',
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
'android:exported': 'false',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
}],
};
// 2. 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.service) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService'
);
if (!exists) {
app.service.push(serviceNode);
}
return modConfig;
});
return config;
}
module.exports = withNotificationListener;

View File

@ -0,0 +1,6 @@
{
"name": "beancount-mobile-plugin-notification-listener",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}

84
plugins/ppocr/README.md Normal file
View File

@ -0,0 +1,84 @@
# PP-OCRv5 (ONNX Runtime) Config Plugin
本插件在 `expo prebuild` 时注入 PP-OCRv5 本地 OCR 原生模块plan.md 决策 4
引擎选用 **ONNX Runtime**跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
## 文件结构
```
plugins/ppocr/
├── app.plugin.js # Config Plugin 入口prebuild 时执行)
├── android/ # Kotlin 原生实现prebuild 时复制进原生工程)
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv5_det.onnx # 文本检测模型
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维)
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典18383 字符CTC 解码用)
```
## 模型获取(一键下载)
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失):
```bash
# 在项目根目录执行
mkdir -p plugins/ppocr/assets
cd plugins/ppocr/assets
# 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
# PP-OCRv5 多语言字典74 KB必须与上面的 rec 模型配套)
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
```
或用 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) 转换而来。
## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 |
|--------|------|
| 引擎 | ONNX Runtime Android 1.20.1 |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px短边压缩 720px |
| rec 图像 | 固定高度 48px |
| ABI | arm64-v8a主流设备 |
## 使用
`app.json` 注册插件:
```json
{
"plugins": ["./plugins/ppocr"]
}
```
JS 层通过 `src/services/ocrBridge.ts``NativeOcrBridge` 调用,桥接到 `NativeModules.PpOcr`
- `recognizeText(base64)` → 返回纯文本(多行用 `\n` 连接)
- `recognizeTextBlocks(base64)` → 返回带坐标的文本块数组 `[{text, x, y, width, height, confidence}]`
- `isReady()` → 模型是否加载完成
## 当前状态
- `app.plugin.js`:✅ Config Plugin 逻辑prebuild 注入 Kotlin + 模型 + gradle 依赖 + MainApplication 注册)
- `android/OcrModule.kt`:✅ ONNX Runtime 推理det DB 后处理 + rec CTC 解码)
- `android/OcrPackage.kt`:✅ RN Package 注册
- `assets/`:需自行下载放置(见上「模型获取」),版权/体积原因不入仓库
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。

View File

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

View File

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

119
plugins/ppocr/app.plugin.js Normal file
View File

@ -0,0 +1,119 @@
/**
* PP-OCRv5 (ONNX Runtime) Config Pluginplan.md决策 4 Expo Config Plugin 方案+3.4 Layer 2
*
* 本插件在 expo prebuild
* 1. 复制 Kotlin 源码OcrModule.kt + OcrPackage.kt android/app/src/main/java/...
* 2. 复制 ONNX 模型与字典到 android/app/src/main/assets
* 3. 注册 OcrPackage MainApplicationgetPackages
* 4. 添加 onnxruntime-android 依赖app/build.gradle
*
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 注意SDK 54 @expo/config-plugins 暴露的是 withMainApplication / withAppBuildGradle
* 没有 withAndroidMainApplication / withAndroidGradle文件复制用 withDangerousMod
*
* 模型文件放 plugins/ppocr/assets/版权/体积原因不入仓库需自行下载 README.md
*/
const {
withMainApplication,
withAppBuildGradle,
withDangerousMod,
} = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.ppocr';
/** 递归复制目录prebuild 阶段执行一次。 */
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) copyDir(s, d);
else fs.copyFileSync(s, d);
}
}
function withPpOcr(config) {
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
// platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录)
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码OcrModule.kt + OcrPackage.kt
copyDir(
path.join(__dirname, 'android'),
path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'),
);
// ONNX 模型 + 字典(若已下载)
const assetsSrc = path.join(__dirname, 'assets');
if (fs.existsSync(assetsSrc)) {
copyDir(
assetsSrc,
path.join(projectRoot, 'app/src/main/assets'),
);
}
return modConfig;
},
]);
// 3. 注册 OcrPackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 3a. 注入 import在 package 声明行后插入)
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`,
);
}
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
if (!content.includes('add(OcrPackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
// 新架构 KotlinSDK 54 默认):模板已有空 .apply {} 块,在块开头注入
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n // PP-OCRv5 ONNX 原生模块(由 Config Plugin 注入)\n add(OcrPackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
// 新架构变体:.packages 后没有 .apply包一层
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(OcrPackage()) }`,
);
} else if (/\breturn\s+packages\s*;/.test(content)) {
// 老架构 Java在 return packages; 前插入
content = content.replace(
/(\breturn\s+packages\s*;)/,
`packages.add(new OcrPackage());\n $1`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 4. 配置 app/build.gradle 依赖ONNX Runtime Android
config = withAppBuildGradle(config, (modConfig) => {
let gradle = modConfig.modResults.contents;
if (!gradle.includes('onnxruntime')) {
// 在 dependencies { 开头处插入,避免嵌套花括号的正则匹配错误
gradle = gradle.replace(
/(dependencies\s*\{)/,
`$1\n // PP-OCRv5 ONNX Runtime由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'`
);
}
modConfig.modResults.contents = gradle;
return modConfig;
});
return config;
}
module.exports = withPpOcr;

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,6 @@
{
"name": "beancount-mobile-plugin-ppocr",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,88 @@
/**
* 截图监听 Config Pluginplan.md3.13 截图自动记账通道+决策 4
*
* Android: ContentObserver 监听 MediaStore Screenshots READ_MEDIA_IMAGES 权限
* iOS: AppIntent 自动记账通过 expo 配置原生 Swift 实现
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作权限必须通过 modResults.manifest['uses-permission']
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.screenshot';
function withScreenshotMonitor(config) {
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
}
}
return modConfig;
},
]);
// 2. 注册 ScreenshotPackage 到 MainApplication复制 ppocr 模式)
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 import
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
);
}
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
if (!content.includes('add(ScreenshotPackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n add(ScreenshotPackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(ScreenshotPackage()) }`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 3. 添加 READ_MEDIA_IMAGES 权限Android 13+
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const perms = [
'android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_EXTERNAL_STORAGE',
];
for (const perm of perms) {
const exists = manifest['uses-permission'].some(p => p.$['android:name'] === perm);
if (!exists) {
manifest['uses-permission'].push({ $: { 'android:name': perm } });
}
}
return modConfig;
});
return config;
}
module.exports = withScreenshotMonitor;

View File

@ -0,0 +1,6 @@
{
"name": "beancount-mobile-plugin-screenshot-monitor",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,80 @@
/**
* 短信监听 Config Pluginplan.md4.2 短信监听服务+决策 4
*
* expo prebuild 时注册 Android SmsReceivermanifest receiver + RECEIVE_SMS 权限
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作权限/application 必须通过 modResults.manifest不是 modResults 本身
*/
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function withSmsReceiver(config) {
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
}
}
return modConfig;
},
]);
// 2. 注册 receiver + 权限到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 1. 添加 RECEIVE_SMS 权限
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const perms = ['android.permission.RECEIVE_SMS'];
for (const perm of perms) {
const exists = manifest['uses-permission'].some(p => p.$['android:name'] === perm);
if (!exists) {
manifest['uses-permission'].push({ $: { 'android:name': perm } });
}
}
// 2. 添加短信 Receiver
const receiverNode = {
$: {
'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.provider.Telephony.SMS_RECEIVED' } }],
}],
};
// 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.receiver) {
app.receiver = [];
}
const exists = app.receiver.some(
r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver'
);
if (!exists) {
app.receiver.push(receiverNode);
}
return modConfig;
});
return config;
}
module.exports = withSmsReceiver;

View File

@ -0,0 +1,6 @@
{
"name": "beancount-mobile-plugin-sms-receiver",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}

98
src/ai/chatAssistant.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* AI plan.md8.1 AI
*
* BeeCount AIChatServiceGLM-4
* -
* - processNaturalLanguage 稿
* - AI
*
* domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt
*/
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/constants';
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatConversation {
id: string;
messages: ChatMessage[];
createdAt: string;
}
export interface ChatResponse {
type: 'bill_card' | 'text' | 'error';
text?: string;
billCards?: AiBillResult[];
error?: string;
}
const AMOUNT_PATTERN = /\d+(?:\.\d+)?/;
/** 判断是否为记账意图(金额 + 关键词)。 */
export function isTransactionIntent(input: string): boolean {
const hasAmount = AMOUNT_PATTERN.test(input);
const hasKeyword = CHAT_TRANSACTION_KEYWORDS.some(kw => input.includes(kw));
return hasAmount && hasKeyword;
}
/**
*
*/
export async function processChatMessage(
input: string,
provider: AiProvider,
conversation?: ChatConversation,
): Promise<ChatResponse> {
try {
// 意图判定
if (isTransactionIntent(input)) {
const result = await processNaturalLanguage(input, provider);
if (result.type === 'draft') {
return { type: 'bill_card', billCards: [result.draft] };
}
// 非 draft 但有意图 → 返回 AI 文本
return { type: 'text', text: result.text };
}
// 自由对话
const messages = buildChatContext(input, conversation);
const response = await provider.chat(messages);
return { type: 'text', text: removeThink(response) };
} catch (e) {
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
}
}
/** 构建对话上下文(含历史消息)。 */
function buildChatContext(input: string, conversation?: ChatConversation) {
const systemPrompt = {
role: 'system' as const,
content: '你是一个记账助手。用户描述消费/收入时,帮助解析为交易。其他问题正常回答。回答简洁。',
};
const history = (conversation?.messages ?? []).slice(-10).map(m => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
content: m.content,
}));
return [systemPrompt, ...history, { role: 'user' as const, content: input }];
}
/** 创建新对话。 */
export function createConversation(): ChatConversation {
return {
id: `chat-${Date.now()}`,
messages: [],
createdAt: new Date().toISOString(),
};
}
/** 添加消息到对话。 */
export function appendMessage(conversation: ChatConversation, message: ChatMessage): ChatConversation {
return {
...conversation,
messages: [...conversation.messages, message],
};
}

94
src/ai/monthlySummary.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* AI plan.md8.3 AI
*
* AutoAccounting SummaryService
* - ///
* - prompt
* - AI
*
* domain/ai.ts buildMonthlySummaryPrompt + annualReport
*/
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
import { generateAnnualReport } from '../domain/annualReport';
import { removeThink } from '../domain/ai';
import type { Transaction } from '../domain/types';
export interface MonthlyStats {
year: number;
month: number;
totalExpense: string;
totalIncome: string;
transactionCount: number;
topCategories: { category: string; amount: string }[];
}
export interface MonthlySummaryResult {
stats: MonthlyStats;
/** AI 生成的总结文本。 */
summary: string;
}
/** 计算月度统计(纯函数)。 */
export function calculateMonthlyStats(
transactions: Transaction[],
year: number,
month: number,
): MonthlyStats {
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
const monthTx = transactions.filter(t => t.date.startsWith(monthStr));
// 复用年度报告的统计逻辑(过滤到当月)
const report = generateAnnualReport(monthTx, year);
return {
year,
month,
totalExpense: report.totalExpense,
totalIncome: report.totalIncome,
transactionCount: report.transactionCount,
topCategories: report.topCategories.slice(0, 5).map(c => ({ category: c.category, amount: c.amount })),
};
}
/**
* AI
*/
export async function generateMonthlySummary(
transactions: Transaction[],
year: number,
month: number,
provider: AiProvider,
): Promise<MonthlySummaryResult> {
const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({
totalExpense: stats.totalExpense,
totalIncome: stats.totalIncome,
transactionCount: stats.transactionCount,
topCategories: stats.topCategories,
});
const response = await provider.chat(messages);
return {
stats,
summary: removeThink(response),
};
}
/** 无 AI 的纯文本总结(降级,不调 AI。 */
export function generatePlainTextSummary(stats: MonthlyStats): string {
const lines: string[] = [];
lines.push(`📊 ${stats.year}${stats.month}月财务总结`);
lines.push('');
lines.push(`💰 总收入:${stats.totalIncome}`);
lines.push(`💸 总支出:${stats.totalExpense}`);
lines.push(`📝 交易笔数:${stats.transactionCount}`);
lines.push('');
if (stats.topCategories.length > 0) {
lines.push('🏷️ 主要支出分类:');
for (const cat of stats.topCategories) {
lines.push(` ${cat.category}: ${cat.amount}`);
}
}
return lines.join('\n');
}

186
src/ai/voiceInput.ts Normal file
View File

@ -0,0 +1,186 @@
/**
* plan.md8.2
*
* BeeCount VoiceBillingHelper /
*
* / expo-av + AI Provider audio/transcriptions
* mock processNaturalLanguage 稿
*/
import { processNaturalLanguage, type AiProvider, type AiBillResult } from '../domain/ai';
import { logger } from '../utils/logger';
/** 录音器抽象(生产用 expo-av测试用 mock。 */
export interface AudioRecorder {
/** 请求录音权限,返回是否授权。 */
requestPermission(): Promise<boolean>;
/** 开始录音,返回录音句柄。 */
start(): Promise<RecordingHandle>;
}
export interface RecordingHandle {
/** 停止录音,返回音频 URI。 */
stop(): Promise<string>;
}
/** 语音转文字抽象(生产用 AI Provider 的 audio/transcriptions测试用 mock。 */
export interface SpeechToText {
transcribe(audioUri: string): Promise<string>;
}
export type VoiceTriggerMode = 'auto-detect' | 'press-to-talk';
export interface VoiceInputConfig {
mode: VoiceTriggerMode;
/** 自动检测模式的静音超时(毫秒,默认 2000。 */
silenceTimeoutMs: number;
/** 最大录音时长(毫秒,默认 60000。 */
maxDurationMs: number;
/** 振幅阈值自动检测开始说话0-1默认 0.58 ≈ -25dB。 */
amplitudeThreshold: number;
}
export const DEFAULT_VOICE_CONFIG: VoiceInputConfig = {
mode: 'press-to-talk',
silenceTimeoutMs: 2000,
maxDurationMs: 60_000,
amplitudeThreshold: 0.58,
};
export interface VoiceBillingResult {
/** 转写的文字。 */
transcription: string;
/** 解析结果draft 表示识别为交易text 表示自由文本)。 */
parsed: { type: 'draft'; draft: AiBillResult } | { type: 'text'; text: string } | null;
}
/**
*
*
*
* const voice = new VoiceInput(recorder, stt, aiProvider);
* const result = await voice.processPressToTalk();
*/
export class VoiceInput {
private currentRecording: RecordingHandle | null = null;
constructor(
private readonly recorder: AudioRecorder,
private readonly stt: SpeechToText,
private readonly aiProvider: AiProvider,
private readonly config: VoiceInputConfig = DEFAULT_VOICE_CONFIG,
) {}
/**
*
* UI start/stop
*/
async startRecording(): Promise<void> {
const granted = await this.recorder.requestPermission();
if (!granted) throw new Error('需要录音权限');
this.currentRecording = await this.recorder.start();
logger.info('voice', '开始录音');
}
/** 停止录音并处理。 */
async stopAndProcess(): Promise<VoiceBillingResult> {
if (!this.currentRecording) throw new Error('未在录音中');
const audioUri = await this.currentRecording.stop();
this.currentRecording = null;
logger.info('voice', `录音完成: ${audioUri}`);
const transcription = await this.stt.transcribe(audioUri);
logger.info('voice', `转写结果: ${transcription}`);
if (!transcription.trim()) {
return { transcription: '', parsed: null };
}
const parsed = await processNaturalLanguage(transcription, this.aiProvider);
return { transcription, parsed };
}
/**
*
* start/stop +
*/
async processAutoDetect(
getAmplitude: () => number,
): Promise<VoiceBillingResult> {
await this.startRecording();
// 等待开始说话(振幅超阈值)
const startDeadline = Date.now() + this.config.maxDurationMs;
let speakingFrames = 0;
while (Date.now() < startDeadline) {
const amp = getAmplitude();
if (amp >= this.config.amplitudeThreshold) {
speakingFrames++;
if (speakingFrames >= 5) break; // 连续 5 帧判定开始说话
} else {
speakingFrames = 0;
}
await sleep(100);
}
// 等待静音(振幅低于阈值超过 silenceTimeoutMs
let lastLoudTime = Date.now();
const stopDeadline = Date.now() + this.config.maxDurationMs;
while (Date.now() < stopDeadline) {
const amp = getAmplitude();
if (amp >= this.config.amplitudeThreshold) {
lastLoudTime = Date.now();
} else if (Date.now() - lastLoudTime > this.config.silenceTimeoutMs) {
break; // 静音超时,停止
}
await sleep(100);
}
return this.stopAndProcess();
}
/** 取消当前录音。 */
cancel(): void {
this.currentRecording = null;
logger.info('voice', '录音已取消');
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** 模拟录音器(测试用)。 */
export class MockAudioRecorder implements AudioRecorder {
public permissionGranted = true;
public startCount = 0;
private stopHandler: (() => string) | null = null;
requestPermission(): Promise<boolean> {
return Promise.resolve(this.permissionGranted);
}
async start(): Promise<RecordingHandle> {
this.startCount++;
return {
stop: async () => {
return this.stopHandler?.() ?? 'mock://audio.wav';
},
};
}
/** 测试辅助:设置 stop 返回的 URI。 */
setStopResult(uri: string): void {
this.stopHandler = () => uri;
}
}
/** 模拟语音转文字(测试用)。 */
export class MockSpeechToText implements SpeechToText {
constructor(private response: string = '') {}
async transcribe(): Promise<string> {
return this.response;
}
/** 测试辅助。 */
setResponse(text: string): void {
this.response = text;
}
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */
export default function TabsLayout() {
const { theme } = useTheme();
const t = useT();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.colors.accent,
tabBarInactiveTintColor: theme.colors.fgSecondary,
tabBarStyle: {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: t('tab.home'),
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="transactions"
options={{
title: t('tab.transactions'),
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="report"
options={{
title: t('tab.report'),
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: t('tab.settings'),
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}

231
src/app/(tabs)/index.tsx Normal file
View File

@ -0,0 +1,231 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SpeedDial } from '../../components/SpeedDial';
import { TransactionCard } from '../../components/TransactionCard';
import { AccountTree } from '../../components/AccountTree';
import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats';
import { buildAccountTree } from '../../domain/accountTree';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
export default function HomeScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => {
if (monthlyData.length === 0) return null;
return monthlyData[monthlyData.length - 1];
}, [monthlyData]);
// 1. 账户树计算
const accountNodes = useMemo(() => {
if (!ledger || !ledger.accounts) return [];
return buildAccountTree(ledger.accounts, transactions, ledger);
}, [ledger, transactions]);
// 2. 周期性记账到期计算
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []);
const dueRecurring = useMemo(() => {
return getDueRecurring(recurringTransactions, todayStr);
}, [recurringTransactions, todayStr]);
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false);
useEffect(() => {
let isMounted = true;
if (autoConfirmed.current) return;
const autoDue = dueRecurring.filter(r => r.autoConfirm);
if (autoDue.length === 0) return;
autoConfirmed.current = true;
(async () => {
for (const rec of autoDue) {
if (!isMounted) break;
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
} catch {
// 静默失败,用户可在周期管理页手动处理
}
}
})();
return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
const handleConfirmRecurring = async (rec: any) => {
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
};
// 3. 千分位格式化金额
const fmtAmount = (s: string) => {
if (!s || s === '0') return '¥0.00';
const num = parseFloat(s);
if (isNaN(num)) return s;
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}>
{t('app.name')}
</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产 Hero 卡片 */}
<Card
style={{
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accentLight,
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)}
</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.liabilities')}
</Text>
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.liabilities)}
</Text>
</View>
</View>
</Card>
{/* Bento 双格排列 */}
{(dueRecurring.length > 0 || currentMonth) && (
<View style={styles.bentoRow}>
{dueRecurring.length > 0 ? (
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
<View style={styles.recurringList}>
{dueRecurring.slice(0, 2).map(rec => (
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
<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 style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
+{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>
</View>
</View>
</Card>
) : null}
</View>
)}
{/* 月度趋势 */}
{monthlyData.length > 0 && (
<Card title={t('home.monthlyTrend')}>
<TrendLine transactions={transactions} />
</Card>
)}
{/* 账户与资产余额树 */}
{accountNodes.length > 0 && (
<Card title={t('home.accountTree')}>
<AccountTree nodes={accountNodes} />
</Card>
)}
</ScrollView>
<SpeedDial actions={[
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') },
{ key: 'ocr', icon: 'camera-outline', label: t('home.ocrScan'), onPress: () => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }) },
{ key: 'import', icon: 'download-outline', label: t('tab.import'), onPress: () => router.push('/import') },
]} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 96 },
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 },
bentoRow: { flexDirection: 'row', gap: 12 },
bentoCol: { flex: 1 },
recurringList: { gap: 8 },
recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
});

446
src/app/(tabs)/report.tsx Normal file
View File

@ -0,0 +1,446 @@
/**
* plan.md5.2 +5.3
*
*
* - /
* - ////
* - react-native-view-shot
* - AI generatePlainTextSummary / generateMonthlySummary
*/
import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, 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 { captureRef } from 'react-native-view-shot';
import * as Sharing from 'expo-sharing';
import { useLedgerStore } from '../../store/ledgerStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport';
import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard';
import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
import { BaseOpenAIProvider } from '../../domain/ai';
export default function ReportScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey);
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel);
// Tab 状态
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly');
// 月份切换状态
const now = new Date();
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
// 周切换状态
const [viewWeekDate, setViewWeekDate] = useState(new Date());
// 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
// AI 总结弹窗
const [summaryModal, setSummaryModal] = useState(false);
const [summaryText, setSummaryText] = useState('');
// 截图 ref
const scrollRef = useRef<ScrollView>(null);
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 个月)
const netWorthDates = useMemo(() => {
const dates: string[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(viewYear, viewMonth - 1 - i, 1);
dates.push(d.toISOString().slice(0, 10));
}
return dates;
}, [viewYear, viewMonth]);
const prevMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1);
};
const nextMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 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 () => {
try {
if (!scrollRef.current) return;
const uri = await captureRef(scrollRef, { format: 'png', quality: 0.9 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: 'image/png', dialogTitle: t('report.shareTitle') });
} else {
Alert.alert(t('report.exportSuccess'), uri);
}
} catch (e) {
Alert.alert(t('report.exportFail'), String(e));
}
};
// AI / 纯文本月度总结
const handleMonthlySummary = async () => {
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth);
if (aiEnabled && aiApiKey && aiBaseUrl) {
try {
const provider = new (class extends BaseOpenAIProvider {})({
id: 'report-summary',
name: 'Report Summary',
apiKey: aiApiKey,
baseUrl: aiBaseUrl,
model: aiModel || 'glm-4-flash',
});
const result = await generateMonthlySummary(transactions, viewYear, viewMonth, provider);
setSummaryText(result.summary || generatePlainTextSummary(stats));
} catch {
setSummaryText(generatePlainTextSummary(stats));
}
} else {
setSummaryText(generatePlainTextSummary(stats));
}
setSummaryModal(true);
};
const monthLabel = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
<View style={styles.headerActions}>
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} />
</Pressable>
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
</View>
</View>
{/* Tab 选择切换栏 */}
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
{([
{ key: 'weekly', label: t('report.tabWeekly') },
{ key: 'monthly', label: t('report.tabMonthly') },
{ key: 'annual', label: t('report.tabAnnual') }
] as const).map(tab => (
<Pressable
key={tab.key}
onPress={() => {
setActiveTab(tab.key);
setSelectedReportDate(null);
}}
style={[
styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
]}
>
<Text
style={[
theme.typography.body,
{
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400',
paddingVertical: 10
}
]}
>
{tab.label}
</Text>
</Pressable>
))}
</View>
{/* 日期选择切换器 */}
{activeTab === 'weekly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{weeklyRange.label}
</Text>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'monthly' && (
<View style={styles.monthSwitcher}>
<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
ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
>
{/* 数据空值判断 */}
{((activeTab === 'weekly' && weeklyTxs.length === 0) ||
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
<Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')}
</Text>
</Card>
) : (
<>
{/* 周报表看板 */}
{activeTab === 'weekly' && (
<>
<Card title={t('report.weeklyTitle')}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })}
</Text>
</Card>
<CategoryPie transactions={weeklyTxs} />
</>
)}
{/* 月度报表看板 */}
{activeTab === 'monthly' && (
<>
<Card title={`${monthLabel} 收支报告`}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.monthlyStats', { count: monthlyStats.count })}
</Text>
</Card>
<CategoryPie transactions={monthlyTxs} />
<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} />
)}
</>
)}
</ScrollView>
{/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={commonStyles.modalOverlay}>
<View style={commonStyles.modalCard}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 12 }]}>
{monthLabel} {t('report.aiSummary')}
</Text>
<ScrollView style={{ maxHeight: 400 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, lineHeight: 22 }]}>
{summaryText}
</Text>
</ScrollView>
<View style={{ marginTop: 16 }}>
<Button label={t('common.confirm')} onPress={() => setSummaryModal(false)} />
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
headerActions: { flexDirection: 'row', gap: 16 },
iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
content: { padding: 16, paddingBottom: 96 },
tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 },
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
});

View File

@ -0,0 +1,94 @@
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
export default function SettingsScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
<Pressable
onPress={() => router.push(path)}
accessibilityRole="link"
accessibilityLabel={label}
style={({ pressed }) => [
styles.navRow,
{
borderBottomColor: theme.colors.divider,
opacity: pressed ? 0.6 : 1,
},
]}
>
<Ionicons name={icon} size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}>
{label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('settings.title')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 数据与基础配置跳转 */}
<Card title={t('settings.dataManagement')}>
<View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
</View>
</Card>
{/* 系统及高级配置跳转 */}
<Card title={t('settings.systemFeaturesTitle')}>
<View style={styles.listGroup}>
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
{renderNavLink('cloud-upload-outline', t('settings.syncSettingsTitle'), '/settings/sync')}
{renderNavLink('phone-portrait-outline', t('settings.preferencesTitle'), '/settings/preferences')}
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
</View>
</Card>
{/* 关于 */}
<Card title={t('settings.aboutTitle')}>
<View style={styles.aboutRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('app.name')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('settings.aboutVersion')}
</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
{t('app.tagline')}
</Text>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 },
listGroup: { borderRadius: 6, overflow: 'hidden' },
navRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1 },
aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
});

View File

@ -0,0 +1,237 @@
/**
* plan.md1.4
*
* :
* - SearchBar (narration/payee)
* - (///)
* - useSearch
* - TransactionCard +
* -
*/
import React, { useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar';
import { TransactionCard } from '../../components/TransactionCard';
import { Ionicons } from '@expo/vector-icons';
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
export default function TransactionsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all');
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [accountFilter, setAccountFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
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 accts = new Set<string>();
for (const tx of allTransactions) {
for (const p of tx.postings) accts.add(p.account);
}
return Array.from(accts).sort();
}, [allTransactions]);
const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction,
account: accountFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
}), [keyword, direction, accountFilter, dateFrom, dateTo]);
const filtered = useSearch(allTransactions, filters);
const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') },
{ key: 'expense', label: t('transactions.filterExpense') },
{ key: 'income', label: t('transactions.filterIncome') },
{ key: 'transfer', label: t('transactions.filterTransfer') },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View>
<SearchBar
value={keyword}
onChangeText={setKeyword}
placeholder={t('transactions.searchPlaceholder')}
/>
{/* 方向筛选 */}
<View style={styles.filterRow}>
{directionTabs.map(tab => (
<Pressable
key={tab.key}
onPress={() => setDirection(tab.key)}
style={[
commonStyles.chip,
direction === tab.key && commonStyles.chipActive,
]}
>
<Text style={[
commonStyles.chipText,
direction === tab.key && commonStyles.chipTextActive,
]}>
{tab.label}
</Text>
</Pressable>
))}
</View>
{/* 高级筛选切换 */}
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
<Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={({ pressed }) => [
commonStyles.chip,
showAdvancedFilters && commonStyles.chipActive,
{ opacity: pressed ? 0.8 : 1 },
]}
>
<Text style={[
commonStyles.chipText,
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')}
</Text>
</Pressable>
</View>
{/* 高级筛选面板 */}
{showAdvancedFilters && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}>
{/* 账户筛选 */}
<TextInput
value={accountFilter}
onChangeText={setAccountFilter}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 日期范围 */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={dateFrom}
onChangeText={setDateFrom}
placeholder={t('transactions.dateFrom')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
<TextInput
value={dateTo}
onChangeText={setDateTo}
placeholder={t('transactions.dateTo')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
</View>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={({ item: tx }) => (
<TransactionCard
transaction={tx}
onPress={() => router.push(`/transaction/${tx.id}`)}
/>
)}
ListHeaderComponent={
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text>
</View>
}
ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{allTransactions.length === 0
? t('transactions.noMatchEmpty')
: t('transactions.noMatchFiltered')}
</Text>
</Card>
}
ListFooterComponent={
<View>
{/* 诊断(折叠) */}
<Pressable
onPress={() => setShowDiagnostics(!showDiagnostics)}
style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
>
<Ionicons
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>
</Pressable>
{showDiagnostics && (
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('diagnostics.hint')}
</Text>
</Card>
)}
</View>
}
contentContainerStyle={styles.content}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 },
content: { padding: 16, paddingTop: 8 },
diagToggle: { paddingVertical: 12, alignItems: 'center' },
});

120
src/app/_error.tsx Normal file
View File

@ -0,0 +1,120 @@
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Share, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { buildErrorRecoveryData, crashReporter } from '../services/crash';
import * as FileSystem from 'expo-file-system/legacy';
interface ErrorBoundaryProps {
error: Error;
resetError: () => void;
}
export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
const { theme } = useTheme();
const t = useT();
const data = buildErrorRecoveryData(error);
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();
};
const exportLogs = async () => {
try {
const logs = crashReporter.exportAsText();
if (!logs) {
Alert.alert(
t('error.noLogs') || '提示',
t('error.noLogsMsg') || '暂无崩溃日志记录'
);
return;
}
await Share.share({
message: logs,
title: 'Bean Mobile Crash Logs',
});
} catch (e) {
Alert.alert(
t('error.exportFail') || '导出失败',
e instanceof Error ? e.message : String(e)
);
}
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}>
<View style={[styles.iconWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<Ionicons name="warning" size={64} color={theme.colors.error} />
</View>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginTop: 16, textAlign: 'center' }]}>
{data.title}
</Text>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 8, textAlign: 'center' }]}>
{data.message}
</Text>
{data.stack && (
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
{data.stack}
</Text>
</View>
)}
<View style={styles.actions}>
<Pressable
onPress={resetError}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>
{t('error.retry') || '重试'}
</Text>
</Pressable>
<Pressable
onPress={clearCacheAndRestart}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: 1, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{t('error.clearCache') || '清除缓存并重启'}
</Text>
</Pressable>
<Pressable
onPress={exportLogs}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: 1, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{t('error.exportLogs') || '导出日志'}
</Text>
</Pressable>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 24, textAlign: 'center' }]}>
{t('error.subTitle') || '如果问题持续出现,请导出日志并提交至 GitHub'}
</Text>
</ScrollView>
</SafeAreaView>
);
}
export default function ErrorBoundary({ error, resetError }: ErrorBoundaryProps) {
return <ErrorScreen error={error} resetError={resetError} />;
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
iconWrap: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center' },
stackBox: { width: '100%', marginTop: 16, padding: 12, borderRadius: 8, borderWidth: 1, maxHeight: 200 },
actions: { marginTop: 24, gap: 10, width: '100%' },
btn: { width: '100%', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
});

425
src/app/_layout.tsx Normal file
View File

@ -0,0 +1,425 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemeProvider, useTheme } from '../theme';
import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore';
import { useSettingsStore } from '../store/settingsStore';
import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend';
import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen';
import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink';
import { initPersistence } from '../store/storePersistence';
import * as FileSystem from 'expo-file-system/legacy';
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 用内置)。 */
const SAMPLE_LEDGER = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
include "mobile.bean"
`,
};
/** 内层布局:引导/锁屏/路由三态切换。 */
function AppShell() {
const { theme, isDark } = useTheme();
const t = useT();
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 setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const onboardingCompleted = useSettingsStore(s => s.onboardingCompleted);
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
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(() => {
// 1. 初始化持久化并还原数据
initPersistence().then(async () => {
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) => {
let content = SAMPLE_LEDGER.content;
if (info.exists) {
content = await FileSystem.readAsStringAsync(mainPath);
lastMainFileRef.current = { modificationTime: info.modificationTime, size: info.size };
}
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()).then(() => {
setContext({ rules, categories });
const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled;
if (!completed) {
setPhase('onboarding');
} else if (locked) {
setPhase('locked');
} else {
setPhase('ready');
}
}).catch((e) => {
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
console.warn('[layout] Ledger load failed:', e);
setPhase('onboarding');
});
});
});
}, [loadLedger, setContext]);
// 3. 注册深度链接监听plan.md「1.5」),注入真实的 router 导航行为
useEffect(() => {
if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') {
router.push('/transaction/new');
} else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
else if (action.tab === 'import') router.push('/import');
else if (action.tab === 'rules') router.push('/rules');
else if (action.tab === 'settings') router.push('/(tabs)/settings');
} else if (action.type === 'import-csv') {
router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } });
} else if (action.type === 'voice-input') {
router.push('/(tabs)/settings');
}
});
return cleanup;
}
}, [phase, router]);
// 4. 监听原生自动化事件(通知/短信/截图)→ automationStore
// 通知/短信事件是原始文本,需先经 parseNotification/parseSms 解析为 ImportedEvent
// 截图事件是原始 base64 图片,需先经 OcrProcessor 识别为 ImportedEvent
useEffect(() => {
if (phase !== 'ready' || Platform.OS !== 'android') return;
const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => {
try {
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`);
const bill = parseNotification(event);
if (bill) {
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`);
}
} catch (e) {
logger.error('layout', '通知解析失败', e);
}
}),
DeviceEventEmitter.addListener('billingSms', (event) => {
try {
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`);
const smsEvent = {
sender: event.sender || event.address || '',
body: event.body,
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) => {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
processScreenshotEvent(event).catch((e: unknown) => {
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());
}, [phase]);
// 5. 隐私模糊plan.md「0.4 隐私模糊」App 进入后台/非活跃时遮盖内容
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
// 切到后台/非活跃时立即遮盖,回到前台时移除
setPrivacyOverlay(state !== 'active');
if (state === 'active' && phase === 'ready') {
checkAndReloadLedger();
}
});
return () => sub.remove();
}, [phase, checkAndReloadLedger]);
// loading 状态
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
</SafeAreaView>
);
}
// 引导流程
if (phase === 'onboarding') {
return (
<ThemeProvider>
<OnboardingScreen onComplete={() => {
setOnboardingCompleted(true);
setPhase(appLockEnabled ? 'locked' : 'ready');
}} />
</ThemeProvider>
);
}
// 锁屏
if (phase === 'locked') {
return <LockScreen onUnlock={() => setPhase('ready')} />;
}
// ready主应用
return (
<View style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: true, title: t('home.newTransaction'), headerTintColor: theme.colors.fgPrimary, headerStyle: { backgroundColor: theme.colors.bgPrimary } }} />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
<Stack.Screen name="budget/index" options={{ headerShown: false }} />
<Stack.Screen name="account/index" options={{ headerShown: false }} />
<Stack.Screen name="import/index" options={{ headerShown: false }} />
<Stack.Screen name="rules/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/ai" options={{ headerShown: false }} />
<Stack.Screen name="settings/sync" options={{ headerShown: false }} />
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
<Stack.Screen name="recurring/index" options={{ headerShown: false }} />
<Stack.Screen name="ai/chat" options={{ headerShown: false }} />
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack>
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容 */}
{privacyOverlay && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} />
)}
</View>
);
}
/** 根布局:包裹 ThemeProvider。 */
export default function RootLayout() {
return (
<ThemeProvider>
<AppShell />
</ThemeProvider>
);
}

149
src/app/_onboarding.tsx Normal file
View File

@ -0,0 +1,149 @@
/**
* Onboardingplan.md0.7
*
* BeeCount Introduction 使
* 1. +
* 2. /
* 3. //
* 4.
* 5. OCR//
* 6.
*
* onboarding
*/
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
import { Button } from '../components/Button';
export interface OnboardingStep {
key: string;
title: string;
description?: string;
}
interface OnboardingProps {
onComplete: () => void;
}
export function OnboardingScreen({ onComplete }: OnboardingProps) {
const { theme } = useTheme();
const t = useT();
const setLocale = useSettingsStore(s => s.setLocale);
const setThemeMode = useSettingsStore(s => s.setThemeMode);
const setAppLockEnabled = useSettingsStore(s => s.setAppLockEnabled);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const [step, setStep] = useState(0);
const steps: OnboardingStep[] = [
{ key: 'welcome', title: t('app.name'), description: t('app.tagline') },
{ key: 'language', title: t('onboarding.language') },
{ key: 'theme', title: t('onboarding.theme') },
{ key: 'ledger', title: t('onboarding.ledger'), description: t('onboarding.ledgerDesc') },
{ key: 'permissions', title: t('onboarding.permissions'), description: t('onboarding.permissionsDesc') },
{ key: 'security', title: t('onboarding.security'), description: t('onboarding.securityDesc') },
];
const current = steps[step];
const isLast = step === steps.length - 1;
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.iconWrap}>
<Ionicons name="book-outline" size={64} color={theme.colors.accent} />
</View>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, textAlign: 'center' }]}>
{current.title}
</Text>
{current.description && (
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 12 }]}>
{current.description}
</Text>
)}
{current.key === 'language' && (
<View style={styles.options}>
{(['zh', 'en'] as Locale[]).map(loc => (
<Pressable
key={loc}
onPress={() => setLocale(loc)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>{loc === 'zh' ? t('settings.langZh') : t('settings.langEn')}</Text>
</Pressable>
))}
</View>
)}
{current.key === 'theme' && (
<View style={styles.options}>
{(['light', 'dark', 'system'] as ThemeMode[]).map(mode => (
<Pressable
key={mode}
onPress={() => setThemeMode(mode)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{mode === 'light' ? t('settings.themeLight') : mode === 'dark' ? t('settings.themeDark') : t('settings.themeSystem')}
</Text>
</Pressable>
))}
</View>
)}
{current.key === 'security' && (
<Pressable
onPress={() => setAppLockEnabled(!appLockEnabled)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 16 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{appLockEnabled ? t('onboarding.lockEnabled') : t('onboarding.enableLock')}
</Text>
</Pressable>
)}
</ScrollView>
<View style={[styles.footer, { borderTopColor: theme.colors.border }]}>
<View style={styles.dots}>
{steps.map((_, i) => (
<View
key={i}
style={[styles.dot, { backgroundColor: i === step ? theme.colors.accent : theme.colors.border }]}
/>
))}
</View>
<View style={styles.actions}>
{step > 0 && (
<Button label={t('onboarding.prev')} onPress={() => setStep(step - 1)} variant="secondary" />
)}
{isLast ? (
<Button label={t('onboarding.start')} onPress={onComplete} />
) : (
<Button label={t('onboarding.next')} onPress={() => setStep(step + 1)} />
)}
</View>
</View>
</SafeAreaView>
);
}
/** 默认导出expo-router 兼容性消除「missing default export」警告。 */
export default OnboardingScreen;
const styles = StyleSheet.create({
page: { flex: 1 },
content: { flex: 1, padding: 24, alignItems: 'center', justifyContent: 'center' },
iconWrap: { marginBottom: 24 },
options: { flexDirection: 'row', gap: 12, marginTop: 24, flexWrap: 'wrap', justifyContent: 'center' },
option: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12, borderWidth: 1 },
footer: { padding: 16, borderTopWidth: 1 },
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
dot: { width: 8, height: 8, borderRadius: 4 },
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
});

313
src/app/account/index.tsx Normal file
View File

@ -0,0 +1,313 @@
import React, { useState } from 'react';
import { Alert, 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 { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { addDecimals } from '../../domain/decimal';
import { computeAccountBalances } from '../../domain/ledger';
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
export default function AccountScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const autoCloseAccount = useLedgerStore(s => s.autoCloseAccount);
const adjustAccountBalance = useLedgerStore(s => s.adjustAccountBalance);
const [tab, setTab] = useState<TabType>('assets');
const [isAdding, setIsAdding] = useState(false);
const [adjustingAccount, setAdjustingAccount] = useState<string | null>(null);
const accounts = Array.from(ledger?.accounts.keys() || []).sort();
// 计算每个账户的余额(包含 balance 断言)
const balances = ledger ? computeAccountBalances(ledger) : new Map<string, string>();
// 根据当前选择的 Tab 过滤账户
const prefix =
tab === 'assets'
? 'Assets:'
: tab === 'liabilities'
? 'Liabilities:'
: tab === 'expenses'
? 'Expenses:'
: 'Income:';
const filteredAccounts = accounts.filter(a => a.startsWith(prefix));
const handleCloseAccount = (accountName: string) => {
Alert.alert(
t('account.confirmClose'),
t('account.confirmCloseDesc', { name: accountName }),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('account.btnConfirmClose'),
style: 'destructive',
onPress: async () => {
try {
await autoCloseAccount(accountName);
Alert.alert(t('account.closeSuccess'), t('account.closeSuccessDesc'));
} catch (e) {
Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e));
}
},
},
]
);
};
const handleAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim();
const nameVal = values.name?.trim();
const currencyVal = values.currency?.trim() || 'CNY';
if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
return;
}
// 拼接成 Beancount 账户名并清洗特殊字符
const rawAccount = `${typeVal}:${nameVal}`;
let sanitized = rawAccount.replace(//g, ':');
sanitized = sanitized
.split(':')
.map((seg, idx) => {
if (idx === 0) return seg; // 保留顶级前缀如 Assets
// 将中英文括号和中括号转换为 - 连字符
let cleaned = seg.replace(/[\(\)\[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
return cleaned;
})
.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 {
await autoOpenAccounts([sanitized]);
setIsAdding(false);
Alert.alert(t('account.openSuccess'), t('account.openSuccessDesc', { name: sanitized }));
} catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
}
};
const handleConfirmAdjust = async (values: Record<string, string>) => {
if (!adjustingAccount) return;
const balanceVal = values.balance?.trim();
const dateVal = values.date?.trim() || new Date().toISOString().slice(0, 10);
if (!balanceVal) {
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
return;
}
try {
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
setAdjustingAccount(null);
Alert.alert(t('account.adjustSuccess'), t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' }));
} catch (e) {
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
}
};
const tabs: { key: TabType; label: string }[] = [
{ key: 'assets', label: t('account.tabAssets') },
{ key: 'liabilities', label: t('account.tabLiabilities') },
{ key: 'expenses', label: t('account.tabExpenses') },
{ key: 'income', label: t('account.tabIncome') },
];
const fields: FormField[] = [
{
key: 'type',
label: t('account.fieldType'),
placeholder: 'Assets / Liabilities / Expenses / Income',
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
},
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
},
{
key: 'currency',
label: t('account.fieldCurrency'),
placeholder: 'CNY',
defaultValue: 'CNY',
},
];
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('account.title')}
</Text>
</View>
{/* Tab 选项卡 */}
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
{tabs.map(item => {
const isActive = tab === item.key;
return (
<Pressable
key={item.key}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
accessibilityLabel={item.label}
style={[
styles.tabItem,
isActive && { borderBottomColor: theme.colors.accent, borderBottomWidth: 2 },
]}
onPress={() => setTab(item.key)}
>
<Text
style={[
theme.typography.bodySmall,
{ color: isActive ? theme.colors.accent : theme.colors.fgSecondary, fontWeight: isActive ? '700' : '400' },
]}
>
{item.label}
</Text>
</Pressable>
);
})}
</View>
{/* 账户列表 */}
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('account.addNew')}
</Text>
</Pressable>
{filteredAccounts.map(account => {
const shortName = account.slice(prefix.length);
const balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
return (
<Card key={account} title={shortName}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('account.fullName')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('account.currentBalance')}
</Text>
<Text
style={[
theme.typography.body,
{ color: balance.startsWith('-') ? theme.colors.error : theme.colors.info, fontWeight: '700' },
]}
>
{balance} {currency}
</Text>
</View>
<View style={styles.cardActions}>
<Pressable
onPress={() => setAdjustingAccount(account)}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('account.adjustBalance')}
</Text>
</Pressable>
<Pressable
onPress={() => handleCloseAccount(account)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('account.closeAccount')}
</Text>
</Pressable>
</View>
</Card>
);
})}
{filteredAccounts.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('account.empty')}
</Text>
)}
</ScrollView>
{/* 新开户对话框 */}
<FormModal
visible={isAdding}
title={t('account.addModalTitle')}
fields={fields}
onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)}
/>
{/* 调整余额对话框 */}
<FormModal
visible={adjustingAccount !== null}
title={t('account.adjustModalTitle')}
fields={[
{
key: 'balance',
label: t('account.fieldBalance'),
placeholder: t('account.fieldBalancePlaceholder'),
defaultValue: adjustingAccount ? (balances.get(adjustingAccount) ?? '0.00') : '0.00',
},
{
key: 'date',
label: t('account.fieldDate'),
placeholder: 'YYYY-MM-DD',
defaultValue: new Date().toISOString().slice(0, 10),
},
]}
onConfirm={handleConfirmAdjust}
onCancel={() => setAdjustingAccount(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
});

202
src/app/ai/chat.tsx Normal file
View File

@ -0,0 +1,202 @@
/**
* AI plan.md8.1 AI
*
*
* - "昨天星巴克花了35"
* - AI
* - new.tsx
*
* 使 chatAssistant.processChatMessage + BaseOpenAIProviderOpenAI
* AI
*/
import React, { useState, useCallback, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
interface UiMessage {
role: 'user' | 'assistant';
content: string;
billCards?: ChatResponse['billCards'];
}
/** 构造 AiProvider从 settingsStore。 */
function buildProvider(get: ReturnType<typeof useSettingsStore.getState>): AiProvider | null {
if (!get.aiEnabled || !get.aiApiKey) return null;
const config: AiProviderConfig = {
id: get.aiProviderId,
name: get.aiProviderId,
apiKey: get.aiApiKey,
baseUrl: get.aiBaseUrl || 'https://api.openai.com/v1',
model: get.aiModel || 'gpt-4o-mini',
};
// BaseOpenAIProvider 是 abstract 但 chat 方法已实现,创建匿名子类
return new (class extends BaseOpenAIProvider {})(config);
}
export default function AIChatScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<UiMessage[]>([
{ role: 'assistant', content: t('ai.welcome') },
]);
const conversationRef = useRef<ChatConversation>(createConversation());
const listRef = useRef<FlatList<UiMessage>>(null);
const sendMessage = useCallback(async () => {
const text = input.trim();
if (!text || loading) return;
// 添加用户消息
const userMsg: UiMessage = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const provider = buildProvider(useSettingsStore.getState());
if (!provider) {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.notConfigured') }]);
return;
}
const response = await processChatMessage(text, provider, conversationRef.current);
conversationRef.current = appendMessage(conversationRef.current, { role: 'user', content: text });
if (response.type === 'bill_card' && response.billCards && response.billCards.length > 0) {
const assistantMsg: UiMessage = {
role: 'assistant',
content: t('ai.billDetected'),
billCards: response.billCards,
};
setMessages(prev => [...prev, assistantMsg]);
conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text || '' });
} else if (response.type === 'text' && response.text) {
setMessages(prev => [...prev, { role: 'assistant', content: response.text! }]);
conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text });
} else if (response.type === 'error') {
setMessages(prev => [...prev, { role: 'assistant', content: response.error || t('ai.error') }]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: response.text || t('ai.error') }]);
}
} catch (e) {
setMessages(prev => [...prev, { role: 'assistant', content: `${t('ai.error')}: ${e instanceof Error ? e.message : String(e)}` }]);
} finally {
setLoading(false);
setTimeout(() => listRef.current?.scrollToEnd(), 100);
}
}, [input, loading, t]);
const renderMessage = ({ item }: { item: UiMessage }) => {
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}>
<View style={[
styles.bubble,
{
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: theme.colors.border,
},
]}>
<Text style={{ color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 15, lineHeight: 20 }}>
{item.content}
</Text>
{/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => (
<Pressable
key={i}
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('ai.tapToRecord')}
</Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View>
</Pressable>
))}
</View>
</View>
);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<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('ai.chatTitle')}
</Text>
</View>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item, index) => `${index}`}
renderItem={renderMessage}
contentContainerStyle={{ padding: 16, gap: 8 }}
onContentSizeChange={() => listRef.current?.scrollToEnd()}
/>
{loading && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', paddingBottom: 4 }]}>
{t('ai.thinking')}
</Text>
)}
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
<TextInput
value={input}
onChangeText={setInput}
placeholder={t('ai.inputPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { color: theme.colors.fgPrimary }]}
multiline
/>
<Pressable
onPress={sendMessage}
disabled={!input.trim() || loading}
style={({ pressed }) => [
styles.sendBtn,
{ backgroundColor: theme.colors.accent, opacity: (!input.trim() || loading) ? 0.4 : pressed ? 0.7 : 1 },
]}
>
<Ionicons name="send" size={18} color={theme.colors.fgInverse} />
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
msgRow: { flexDirection: 'row', maxWidth: '100%' },
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
input: { flex: 1, maxHeight: 100, fontSize: 15, paddingVertical: 8 },
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
});

View File

@ -0,0 +1,432 @@
/**
* plan.md3.x +3.6
*
*
* - 5 ///OCR/
* -
* - "处理全部" BillPipeline 稿
* - /稿 mobile.bean
* - ScreenshotMonitor
* -
* - /
* -
* - OCR
* - OCR
* -
* - App
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, 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 { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import type { AutomationSource } from '../../store/automationStore';
import {
getAccessibilityBridge,
getPackageLabel,
type PageSignature,
} from '../../services/accessibilityBridge';
const SOURCE_LABELS: Record<AutomationSource, string> = {
notification: 'automation.sourceNotification',
sms: 'automation.sourceSms',
screenshot: 'automation.sourceScreenshot',
ocr: 'automation.sourceOcr',
manual: 'automation.sourceManual',
};
export default function AutomationScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const detected = useAutomationStore(s => s.detected);
const drafts = useAutomationStore(s => s.drafts);
const stats = useAutomationStore(s => s.stats);
const processAll = useAutomationStore(s => s.processAll);
const confirmDraft = useAutomationStore(s => s.confirmDraft);
const rejectDraft = useAutomationStore(s => s.rejectDraft);
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
// 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false);
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
const [paymentPackages, setPaymentPackages] = useState<{ package: string; label: string }[]>([]);
const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]);
// 刷新无障碍状态
const refreshAccessibilityState = useCallback(async () => {
const bridge = getAccessibilityBridge();
if (!bridge) return;
try {
const [running, sigs, pkgs, top] = await Promise.all([
bridge.isServiceRunning().catch(() => false),
bridge.getPageSignatures().catch(() => [] as PageSignature[]),
bridge.getPaymentPackages().catch(() => [] as string[]),
bridge.getTopApp().catch(() => ({ package: '', activity: '' })),
]);
setServiceRunning(running);
setPageSignatures(sigs);
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
setTopApp(top && top.package ? top : null);
} catch {
// 静默
}
}, []);
useEffect(() => {
refreshAccessibilityState();
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
refreshAccessibilityState();
}
});
return () => {
subscription.remove();
};
}, [refreshAccessibilityState]);
const handleProcess = async () => {
if (!ledger) return;
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions;
try {
await processAll(ledger, rules, categories, history);
} catch (e) {
Alert.alert(String(e));
}
};
const handleConfirm = async (index: number) => {
const draft = confirmDraft(index);
if (!draft) return;
try {
await addTransaction(draft.draft);
Alert.alert(t('automation.confirmed'));
} catch (e) {
Alert.alert(String(e));
}
};
const handleScreenshotToggle = () => {
if (Platform.OS !== 'android') {
Alert.alert('Android only');
return;
}
const module = (NativeModules as { ScreenshotMonitor?: { start: () => void; stop: () => void } }).ScreenshotMonitor;
if (!module) {
Alert.alert(t('automation.screenshotUnavailable'));
return;
}
if (screenshotActive) {
module.stop();
setScreenshotActive(false);
} else {
module.start();
setScreenshotActive(true);
}
};
const handleOpenAccessibilitySettings = () => {
if (Platform.OS !== 'android') return;
// 打开系统无障碍设置页面 (Intent Action)
Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS').catch(() => {
Alert.alert(t('automation.openSettingsFail'));
});
};
const handleRememberPage = async () => {
const bridge = getAccessibilityBridge();
if (!bridge) {
Alert.alert(t('automation.bridgeUnavailable'));
return;
}
Alert.alert(
'准备记录目标页面',
'点击“开始”后,请在 8 秒内切换到支付宝/微信的账单详情页,系统将在倒计时结束后自动记录该页面的签名。',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: '开始 (8s 倒计时)',
onPress: () => {
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 bridge = getAccessibilityBridge();
if (!bridge) {
Alert.alert(t('automation.bridgeUnavailable'));
return;
}
Alert.alert(
'准备手动识别',
'点击“开始”后,请在 8 秒内切换到你想识别的账单详情页,系统将在 8 秒后自动截图并识别该页面。',
[
{ 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 bridge = getAccessibilityBridge();
if (!bridge) return;
try {
await bridge.removePageSignature(sig);
refreshAccessibilityState();
} catch {
// 静默
}
};
const handleClearSignatures = () => {
Alert.alert(
t('automation.clearPagesTitle'),
t('automation.clearPagesConfirm'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.confirm'),
style: 'destructive',
onPress: async () => {
const bridge = getAccessibilityBridge();
if (!bridge) return;
try {
await bridge.clearPageSignatures();
refreshAccessibilityState();
} catch {
// 静默
}
},
},
],
);
};
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('automation.title')}
</Text>
</View>
<FlatList
data={drafts}
keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
</View>
</Card>
)}
ListHeaderComponent={
<View style={{ gap: 12, marginBottom: 12 }}>
{/* 通道统计 */}
<Card title={t('automation.channelStats')}>
<View style={styles.statsRow}>
{sourceEntries.map(([source, count]) => (
<View key={source} style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t(SOURCE_LABELS[source])}
</Text>
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
{count}
</Text>
</View>
))}
</View>
</Card>
{/* 无障碍服务状态与控制 */}
{Platform.OS === 'android' && (
<Card title={t('automation.accessibilityTitle')}>
{/* 服务状态 */}
<View style={[styles.statusRow, { marginBottom: 8 }]}>
<View style={[styles.statusDot, { backgroundColor: serviceRunning ? theme.colors.success : theme.colors.fgSecondary }]} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{serviceRunning ? t('automation.serviceRunning') : t('automation.serviceStopped')}
</Text>
</View>
{topApp && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('automation.currentApp')}: {getPackageLabel(topApp.package)}
</Text>
)}
{/* 操作按钮 */}
<View style={{ gap: 8 }}>
{!serviceRunning && (
<Button label={t('automation.openSettings')} onPress={handleOpenAccessibilitySettings} variant="secondary" />
)}
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<Button
label={t('automation.rememberPage')}
onPress={handleRememberPage}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('automation.manualOcr')}
onPress={handleManualOcr}
/>
</View>
</View>
</View>
{/* 支付 App 白名单 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 12, marginBottom: 4 }]}>
{t('automation.whitelistTitle')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4 }}>
{paymentPackages.length === 0 ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.whitelistEmpty')}
</Text>
) : (
paymentPackages.map(pkg => (
<View key={pkg.package} style={[styles.chip, { backgroundColor: theme.colors.divider }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{pkg.label}
</Text>
</View>
))
)}
</View>
{/* 已记住的页面 */}
{pageSignatures.length > 0 && (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.rememberedPages', { count: pageSignatures.length })}
</Text>
<Pressable onPress={handleClearSignatures}>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('automation.clearAll')}
</Text>
</Pressable>
</View>
{pageSignatures.map(sig => (
<View key={sig.signature} style={styles.sigRow}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1 }]}>
{getPackageLabel(sig.package)} · {sig.activity.split('.').pop() || sig.activity}
</Text>
<Pressable onPress={() => handleRemoveSignature(sig.signature)}>
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
</Pressable>
</View>
))}
</View>
)}
{/* 截图监控开关 */}
<View style={{ marginTop: 12 }}>
<Button
label={screenshotActive ? t('automation.screenshotStop') : t('automation.screenshotStart')}
onPress={handleScreenshotToggle}
variant={screenshotActive ? 'primary' : 'secondary'}
/>
</View>
</Card>
)}
{/* 检测到的事件 */}
<Card title={t('automation.detectedEvents', { count: detected.length })}>
{detected.length === 0 ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.noEvents')}
</Text>
) : (
<View style={{ gap: 4 }}>
{detected.map(ev => (
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency}
</Text>
))}
<View style={{ marginTop: 8 }}>
<Button label={t('automation.process')} onPress={handleProcess} />
</View>
</View>
)}
</Card>
{/* 草稿标题 */}
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('automation.drafts', { count: drafts.length })}
</Text>
</View>
}
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('automation.noDrafts')}
</Text>
}
contentContainerStyle={styles.content}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
});

161
src/app/budget/index.tsx Normal file
View File

@ -0,0 +1,161 @@
/**
* plan.md1.1 budget/index+ 1
*
* () + //
* calculateBudgetProgress ,//
*/
import React, { useState } from 'react';
import { Alert, 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 { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { calculateBudgetProgress } from '../../domain/budgets';
import type { Budget } from '../../domain/budgets';
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
export default function BudgetScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const budgets = useMetadataStore(s => s.budgets);
const addBudget = useMetadataStore(s => s.addBudget);
const updateBudget = useMetadataStore(s => s.updateBudget);
const removeBudget = useMetadataStore(s => s.removeBudget);
const ledger = useLedgerStore(s => s.ledger);
const [modal, setModal] = useState<ModalMode>(null);
const transactions = ledger?.transactions ?? [];
const today = new Date().toISOString().slice(0, 10);
const handleDelete = (budget: Budget) => {
Alert.alert(t('budget.deleteTitle'), t('budget.deleteConfirm', { name: budget.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeBudget(budget.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('budget.unnamed');
const amount = values.amount?.trim() || '0';
const period = (values.period as Budget['period']) || 'monthly';
const categoryAccount = values.categoryAccount?.trim() || undefined;
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') {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
} else if (modal?.type === 'edit') {
updateBudget(modal.budget.id, { name, amount, period, categoryAccount, startDate });
}
setModal(null);
};
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
{ 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: '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 },
] : [];
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('budget.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
</Pressable>
{budgets.map(budget => {
const progress = calculateBudgetProgress(budget, transactions, today);
return (
<Pressable
key={budget.id}
onPress={() => setModal({ type: 'edit', budget })}
onLongPress={() => handleDelete(budget)}
>
<Card title={budget.name}>
<View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
</Text>
<Text style={[theme.typography.caption, {
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
}]}>
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
</Text>
</View>
{/* 进度条 */}
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[
styles.bar,
{
width: `${Math.min(progress.percentage, 100)}%`,
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
},
]} />
</View>
{budget.categoryAccount && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{budget.categoryAccount}
</Text>
)}
</Card>
</Pressable>
);
})}
{budgets.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('budget.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
bar: { height: '100%', borderRadius: 4 },
});

135
src/app/category/index.tsx Normal file
View File

@ -0,0 +1,135 @@
/**
* plan.md1.1 category/index+ 1
*
* /+ //
* metadataStorelinkedAccount Beancount
*/
import React, { useState } from 'react';
import { Alert, 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 { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import type { Category } from '../../domain/categories';
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
export default function CategoryScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const categories = useMetadataStore(s => s.categories);
const addCategory = useMetadataStore(s => s.addCategory);
const updateCategory = useMetadataStore(s => s.updateCategory);
const removeCategory = useMetadataStore(s => s.removeCategory);
const [modal, setModal] = useState<ModalMode>(null);
const expenseCats = categories.filter(c => c.type === 'expense');
const incomeCats = categories.filter(c => c.type === 'income');
const handleDelete = (cat: Category) => {
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
if (modal?.type === 'add') {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: modal.catType,
linkedAccount: values.linkedAccount || 'Expenses:Uncategorized',
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else if (modal?.type === 'edit') {
updateCategory(modal.category.id, {
name: values.name || modal.category.name,
linkedAccount: values.linkedAccount || modal.category.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
setModal(null);
};
const renderRow = (cat: Category) => (
<Pressable
key={cat.id}
onPress={() => setModal({ type: 'edit', category: cat })}
onLongPress={() => handleDelete(cat)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
);
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
] : [];
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('category.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('category.expense')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text>
</Pressable>
{expenseCats.map(renderRow)}
</Card>
<Card title={t('category.income')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text>
</Pressable>
{incomeCats.map(renderRow)}
</Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
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 : [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
});

View File

@ -0,0 +1,180 @@
/**
* plan.md1.1 credit-card/index+ 1
*
* (////) + //
* linkedAccount Beancount Liabilities
*/
import React, { useState } from 'react';
import { Alert, 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 { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import type { CreditCard } from '../../domain/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
export default function CreditCardScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const creditCards = useMetadataStore(s => s.creditCards);
const addCreditCard = useMetadataStore(s => s.addCreditCard);
const updateCreditCard = useMetadataStore(s => s.updateCreditCard);
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger);
const transactions = ledger?.transactions ?? [];
const [modal, setModal] = useState<ModalMode>(null);
/** 计算某账户的当前余额(从交易过账汇总)。 */
const getAccountBalance = (account: string): string => {
let balance = 0;
for (const tx of transactions) {
for (const p of tx.postings) {
if (p.account === account && p.amount) {
balance += parseFloat(p.amount);
}
}
}
return String(balance.toFixed(2));
};
const handleDelete = (card: CreditCard) => {
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCreditCard(card.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (modal?.type === 'add') {
addCreditCard({ ...card, id: generateId('cc') });
} else if (modal?.type === 'edit') {
updateCreditCard(modal.card.id, card);
}
setModal(null);
};
const fieldsFor = (card?: CreditCard): FormField[] => [
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: card?.name },
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: card?.bankName },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: card?.lastFour, keyboardType: 'numeric' },
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: card ? String(card.billingDay) : '', keyboardType: 'numeric' },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: card ? String(card.paymentDay) : '', keyboardType: 'numeric' },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: card?.creditLimit, keyboardType: 'decimal-pad' },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: card?.currency },
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: card?.linkedAccount },
];
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('creditCard.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text>
</Pressable>
{creditCards.map(card => {
const period = calculateBillingPeriod(card, new Date());
const statementAmount = calculateStatementAmount(transactions, card, period);
const currentBalance = getAccountBalance(card.linkedAccount);
const availableCredit = calculateAvailableCredit(card, currentBalance);
return (
<Pressable
key={card.id}
onPress={() => setModal({ type: 'edit', card })}
onLongPress={() => handleDelete(card)}
>
<Card title={card.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
{/* 账单周期与应还 */}
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
</View>
</Card>
</Pressable>
);
})}
{creditCards.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('creditCard.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
});

399
src/app/import/index.tsx Normal file
View File

@ -0,0 +1,399 @@
import React, { useState } from 'react';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as XLSX from 'xlsx';
// @ts-ignore
const GbkTextDecoder = (() => {
// @ts-ignore
const origDecoder = global.TextDecoder;
// @ts-ignore
const origEncoder = global.TextEncoder;
// @ts-ignore
global.TextDecoder = undefined;
// @ts-ignore
global.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
// @ts-ignore
global.TextDecoder = origDecoder;
// @ts-ignore
global.TextEncoder = origEncoder;
return dec;
})();
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { DedupBanner } from '../../components/DedupBanner';
import { classifyWithCategories } from '../../domain/rules';
import { validateTransaction } from '../../domain/ledger';
import type { ImportedEvent } from '../../domain/types';
// 纯 JS 实现 Base64 解码为字节数组
function base64ToBytes(base64: string): Uint8Array {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const base64code1 = lookup[base64.charCodeAt(i)];
const base64code2 = lookup[base64.charCodeAt(i + 1)];
const base64code3 = lookup[base64.charCodeAt(i + 2)];
const base64code4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (base64code1 << 2) | (base64code2 >> 4);
if (p < bufferLength) bytes[p++] = ((base64code2 & 15) << 4) | (base64code3 >> 2);
if (p < bufferLength) bytes[p++] = ((base64code3 & 3) << 6) | (base64code4 & 63);
}
return bytes;
}
export default function ImportScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const events = useImportStore(s => s.events);
const pendingDrafts = useImportStore(s => s.pendingDrafts);
const lastResult = useImportStore(s => s.lastResult);
const importCsv = useImportStore(s => s.importCsv);
const processEvents = useImportStore(s => s.process);
const confirmDraft = useImportStore(s => s.confirmDraft);
const confirmDrafts = useImportStore(s => s.confirmDrafts);
const [status, setStatus] = useState('');
// 疑似重复账单交互状态
const [manualDuplicates, setManualDuplicates] = useState<ImportedEvent[]>([]);
const [showDuplicates, setShowDuplicates] = useState(false);
const handleSelectFile = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: '*/*', // 允许所有类型,防部分手机系统限制
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const isExcel = file.name.toLowerCase().endsWith('.xlsx') || file.name.toLowerCase().endsWith('.xls');
// 1. 读取为 Base64 以保证字节完整
const base64 = await FileSystem.readAsStringAsync(file.uri, {
encoding: FileSystem.EncodingType.Base64,
});
let content = '';
if (isExcel) {
// 2a. Excel 格式:直接使用 xlsx 库解析并转换为内存中的 CSV 字符串
const workbook = XLSX.read(base64, { type: 'base64' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
content = XLSX.utils.sheet_to_csv(worksheet);
} else {
// 2b. 解码为 Uint8Array 进行 CSV 文本处理
const bytes = base64ToBytes(base64);
// 3. 编码自适应解码:优先尝试 UTF-8若出错则回退至中文 GBK
try {
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
content = utf8Decoder.decode(bytes);
} catch (e) {
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
try {
const gbkDecoder = new GbkTextDecoder('gbk');
content = gbkDecoder.decode(bytes);
} catch (err) {
throw new Error(t('importFlow.decodeFail', { error: err instanceof Error ? err.message : String(err) }));
}
}
}
// 4. 自适应解析通道:根据文件名或内容关键字区分微信/支付宝
const isWeChat = file.name.includes('微信') || file.name.toLowerCase().includes('wechat') || content.includes('微信支付');
const adapter = isWeChat ? 'wechat-csv-v1' : 'alipay-csv-v1';
// 5. 导入数据并清空历史状态
importCsv(content, adapter);
setManualDuplicates([]);
setStatus(t('importFlow.importSuccess', {
format: isExcel ? t('importFlow.formatExcel') : t('importFlow.formatCsv'),
name: file.name,
}));
// 6. 自动执行 Pipeline用户无需手动查找运行按钮
if (ledger) {
setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => {
setStatus(t('importFlow.pipelineFail', { error: String(e) }));
});
}
} catch (e) {
setStatus(t('importFlow.pickFail', { error: e instanceof Error ? e.message : String(e) }));
Alert.alert(t('importFlow.importFailTitle'), e instanceof Error ? e.message : String(e));
}
};
const runPipeline = () => {
if (!ledger) return;
processEvents(ledger, []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e)));
};
const onConfirm = async (index: number) => {
if (!ledger) return;
const item = pendingDrafts[index];
if (!item) return;
try {
// 自动开户:收集所有草稿中的账户,检查哪些未 open
const currentLedger = useLedgerStore.getState().ledger!;
const unopened = item.draft.postings
.map(p => p.account)
.filter(acc => !currentLedger.accounts.has(acc));
if (unopened.length > 0) {
await autoOpenAccounts(unopened);
}
await addTransaction(item.draft);
confirmDraft(index);
setStatus(t('importFlow.confirmed'));
} catch (e) {
const errStr = e instanceof Error ? e.message : String(e);
setStatus(t('importFlow.commitFail', { error: errStr }));
Alert.alert(t('importFlow.commitFailTitle'), errStr);
}
};
const onConfirmAll = async () => {
const currentLedger = useLedgerStore.getState().ledger;
if (!currentLedger || pendingDrafts.length === 0) return;
setStatus(t('importFlow.batchPreparing'));
// 1. 收集所有草稿中引用的账户,静默自动开户
const allAccounts = new Set<string>();
for (const item of pendingDrafts) {
for (const posting of item.draft.postings) {
allAccounts.add(posting.account);
}
}
const unopened = Array.from(allAccounts).filter(acc => !currentLedger.accounts.has(acc));
if (unopened.length > 0) {
try {
await autoOpenAccounts(unopened);
} catch (err) {
setStatus(t('importFlow.autoOpenFail', { error: err instanceof Error ? err.message : String(err) }));
Alert.alert(t('importFlow.autoOpenFailTitle'), err instanceof Error ? err.message : String(err));
return;
}
}
// 2. 重新获取 ledger开户后已更新再次校验
const updatedLedger = useLedgerStore.getState().ledger!;
const validIndices: number[] = [];
const validDrafts: typeof pendingDrafts = [];
const failedPayees: string[] = [];
for (let i = 0; i < pendingDrafts.length; i++) {
const item = pendingDrafts[i];
const validation = validateTransaction(item.draft, updatedLedger);
if (validation.valid) {
validIndices.push(i);
validDrafts.push(item);
} else {
failedPayees.push(`${item.draft.payee || item.draft.narration || t('common.untitled')}: ${validation.errors.join('; ')}`);
}
}
// 3. 执行真正的批量入账
if (validDrafts.length > 0) {
await commitBatch(validIndices, validDrafts, failedPayees);
} else if (failedPayees.length > 0) {
Alert.alert(t('importFlow.commitFailTitle'), t('importFlow.batchNoValid', { reasons: failedPayees.join('\n') }));
}
};
const commitBatch = async (indices: number[], drafts: typeof pendingDrafts, failedPayees: string[]) => {
try {
// 使用 ledgerStore 的 appendTransactionsBatch在互斥锁内完成读取+拼接+写入)
const appendTransactionsBatch = useLedgerStore.getState().appendTransactionsBatch;
await appendTransactionsBatch(drafts.map(d => d.draft));
// 从待确认中批量移除
confirmDrafts(indices);
const successCount = drafts.length;
const failCount = pendingDrafts.length - successCount;
if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
Alert.alert(t('importFlow.confirmed'), t('importFlow.batchAllSuccess', { count: successCount }));
} else {
setStatus(t('importFlow.batchPartial', { success: successCount, fail: failCount }));
Alert.alert(
t('importFlow.batchResultTitle'),
t('importFlow.batchResultBody', { success: successCount, fail: failCount, reasons: failedPayees.join('\n') })
);
}
} catch (e) {
setStatus(t('importFlow.batchFail', { error: e instanceof Error ? e.message : String(e) }));
Alert.alert(t('importFlow.batchErrorTitle'), e instanceof Error ? e.message : String(e));
}
};
const handleAcceptDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport'));
} catch (err) {
setStatus(String(err));
}
};
const handleRejectDuplicate = (event: ImportedEvent) => {
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.ignored'));
};
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.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
</View>
<FlatList
data={pendingDrafts}
keyExtractor={(item, index) => `${item.draft.sourceEventId || index}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>
<Text style={[theme.typography.bodySmall, styles.mono, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
{item.categoryFallbackReason && (
<Text style={[theme.typography.caption, { color: theme.colors.warning, marginTop: 4 }]}>
{t('importFlow.fallbackWarn', { reason: item.categoryFallbackReason })}
</Text>
)}
<View style={{ marginTop: 8 }}>
<Button label={t('importFlow.confirm')} onPress={() => onConfirm(index)} />
</View>
</Card>
)}
ListHeaderComponent={
<View style={{ gap: 12, marginBottom: 12 }}>
<Card title={t('importFlow.title')}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 12 }]}>
{t('importFlow.hint')}
</Text>
<View style={styles.buttonCol}>
<Button label={t('importFlow.selectFile')} onPress={handleSelectFile} />
</View>
{events.length > 0 && (
<View style={{ marginTop: 12, gap: 8 }}>
<Button label={t('importFlow.processButton', { count: events.length })} onPress={runPipeline} variant="secondary" />
{pendingDrafts.length > 0 && (
<Button label={t('importFlow.confirmAll', { count: pendingDrafts.length })} onPress={onConfirmAll} variant="primary" />
)}
</View>
)}
{lastResult && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('importFlow.result', { drafts: lastResult.drafts.length, duplicates: lastResult.duplicates.length, transfers: lastResult.transferCount })}
</Text>
)}
</Card>
{/* 疑似重复账单展示面板 */}
{manualDuplicates.length > 0 && (
<Card title={t('importFlow.duplicatesTitle', { count: manualDuplicates.length })}>
<Pressable
onPress={() => setShowDuplicates(!showDuplicates)}
style={styles.collapseHeader}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>
{showDuplicates ? t('importFlow.collapseList') : t('importFlow.expandDuplicates')}
</Text>
<Ionicons name={showDuplicates ? 'chevron-up' : 'chevron-down'} size={16} color={theme.colors.accent} />
</Pressable>
{showDuplicates && (
<View style={styles.duplicatesList}>
{manualDuplicates.map((item) => {
const result = {
isDuplicate: true,
confidence: 'medium' as const,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') })
};
return (
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text>
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
/>
</Card>
);
})}
</View>
)}
</Card>
)}
</View>
}
ListFooterComponent={
status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>{status}</Text> : null
}
contentContainerStyle={styles.content}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 },
buttonCol: { gap: 8, marginTop: 4 },
mono: { fontFamily: 'monospace', lineHeight: 20, marginTop: 8 },
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
duplicatesList: { gap: 12, marginTop: 8 },
});

191
src/app/recurring/index.tsx Normal file
View File

@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { Alert, 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 { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import type { RecurringTransaction } from '../../domain/recurring';
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
export default function RecurringScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (item: RecurringTransaction) => {
Alert.alert(t('recurring.deleteTitle'), t('recurring.deleteConfirm', { name: item.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => removeRecurring(item.id),
},
]);
};
const handleConfirm = (values: Record<string, string>) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as any;
const dateVal = values.nextDueDate?.trim() || new Date().toISOString().slice(0, 10);
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return;
}
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (modal?.type === 'add') {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
setModal(null);
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
} else if (modal?.type === 'edit') {
updateRecurring(modal.item.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
setModal(null);
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
}
};
const fieldsFor = (item?: RecurringTransaction): FormField[] => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: item?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: item?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: item?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: item?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: item?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: item?.nextDueDate || new Date().toISOString().slice(0, 10) },
];
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('recurring.title')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('recurring.add')}
</Text>
</Pressable>
{recurringTransactions.map(item => {
const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || '';
return (
<Card key={item.id} title={item.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View>
<View style={styles.cardActions}>
<Pressable
onPress={() => setModal({ type: 'edit', item })}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('recurring.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => handleDelete(item)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')}
</Text>
</Pressable>
</View>
</Card>
);
})}
{recurringTransactions.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('recurring.empty')}
</Text>
)}
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')}
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
});

View File

@ -0,0 +1,106 @@
/**
*
* ${placeholder}
*/
import React, { useState } from 'react';
import { Alert, 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 { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
type ModalMode = { type: 'add' } | { type: 'edit'; tpl: { id: string; name: string; template: string } } | null;
export default function RemarkTemplateScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const templates = useMetadataStore(s => s.remarkTemplates);
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (tpl: { id: string; name: string }) => {
Alert.alert(t('remark.deleteTitle'), t('remark.deleteConfirm', { name: tpl.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTemplate(tpl.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (modal?.type === 'add') {
addTemplate({ id: generateId('tpl'), name, template });
} else if (modal?.type === 'edit') {
updateTemplate(modal.tpl.id, { name, template });
}
setModal(null);
};
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: modal.tpl.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: modal.tpl.template },
] : [];
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('remark.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text>
</Pressable>
{templates.map(tpl => (
<Pressable
key={tpl.id}
onPress={() => setModal({ type: 'edit', tpl })}
onLongPress={() => handleDelete(tpl)}
>
<Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}>
{tpl.template}
</Text>
</Card>
</Pressable>
))}
{templates.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('remark.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('remark.editTitle') : t('remark.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮' },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
});

143
src/app/rules/index.tsx Normal file
View File

@ -0,0 +1,143 @@
/**
* plan.md1.1 rules+
*
* ( ) + //
* BillPipeline ( AutoAccounting RuleGenerator)
*/
import React, { useState } from 'react';
import { Alert, 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 { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import type { Rule } from '../../domain/types';
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
export default function RulesScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const rules = useMetadataStore(s => s.rules);
const addRule = useMetadataStore(s => s.addRule);
const updateRule = useMetadataStore(s => s.updateRule);
const removeRule = useMetadataStore(s => s.removeRule);
const [modal, setModal] = useState<ModalMode>(null);
// 按 priority 降序排列
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const handleDelete = (rule: Rule) => {
Alert.alert(t('rules.deleteTitle'), t('rules.deleteConfirm', { name: rule.id }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeRule(rule.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (modal?.type === 'add') {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
} else if (modal?.type === 'edit') {
updateRule(modal.rule.id, rule);
}
setModal(null);
};
const fieldsFor = (rule?: Rule): FormField[] => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
];
const formatCondition = (rule: Rule): string => {
const parts: string[] = [];
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
if (rule.memoContains) parts.push(t('rules.condMemo', { val: rule.memoContains }));
return parts.length > 0 ? parts.join(' · ') : t('rules.condNone');
};
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.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text>
</Pressable>
<Card title={t('rules.title')}>
{sorted.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
)}
{sorted.map(rule => (
<Pressable
key={rule.id}
onPress={() => setModal({ type: 'edit', rule })}
onLongPress={() => handleDelete(rule)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
>
<View style={{ flex: 1 }}>
<View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text>
</View>
</View>
</Pressable>
))}
</Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('rules.editTitle') : t('rules.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.rule : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
row: { borderTopWidth: 1, paddingTop: 10, gap: 3 },
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
});

156
src/app/settings/ai.tsx Normal file
View File

@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } 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 { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal';
export default function AISettingsScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// Settings State
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1';
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const [aiModalVisible, setAiModalVisible] = useState(false);
const aiFields: FormField[] = [
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey },
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl },
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel },
];
const saveAI = (values: Record<string, string>) => {
updateAiConfig({
aiApiKey: values.apiKey,
aiBaseUrl: values.baseUrl,
aiModel: values.model,
});
setAiModalVisible(false);
};
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('settings.aiSettingsTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */}
<Card title={t('settings.algorithmConfig')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* AI 智能助理配置 */}
<Card title={t('settings.aiAssistantTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')}
</Text>
<Switch
value={aiEnabled}
onValueChange={(val) => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{aiEnabled && (
<View style={{ marginTop: 12, gap: 8 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('settings.aiProvider')}
</Text>
<View style={styles.optionRow}>
{['openai', 'gemini', 'deepseek'].map((provider) => {
const active = aiProviderId === provider;
return (
<Pressable
key={provider}
onPress={() => updateAiConfig({ aiProviderId: provider as any })}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{provider.toUpperCase()}
</Text>
</Pressable>
);
})}
</View>
<View style={{ marginTop: 4 }}>
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })}
</Text>
</View>
)}
</Card>
</ScrollView>
{/* AI 配置模态框 */}
<FormModal
visible={aiModalVisible}
title={t('settings.aiConfigTitle')}
fields={aiFields}
onConfirm={saveAI}
onCancel={() => setAiModalVisible(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
});

View File

@ -0,0 +1,218 @@
import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } 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 { useT } from '../../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
import { Card } from '../../components/Card';
export default function PreferencesScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// Settings State
const themeMode = useSettingsStore(s => s.themeMode);
const setThemeMode = useSettingsStore(s => s.setThemeMode);
const locale = useSettingsStore(s => s.locale);
const setLocale = useSettingsStore(s => s.setLocale);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const setAppLockEnabled = useSettingsStore(s => s.setAppLockEnabled);
const reminderEnabled = useSettingsStore(s => s.reminderEnabled);
const reminderHour = useSettingsStore(s => s.reminderHour);
const reminderMinute = useSettingsStore(s => s.reminderMinute);
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
{ mode: 'light', labelKey: 'settings.themeLight' },
{ mode: 'dark', labelKey: 'settings.themeDark' },
{ mode: 'system', labelKey: 'settings.themeSystem' },
];
const LOCALE_OPTIONS: { locale: Locale; labelKey: string }[] = [
{ locale: 'zh', labelKey: 'settings.langZh' },
{ locale: 'en', labelKey: 'settings.langEn' },
];
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('settings.preferencesTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
{/* 外观设置 */}
<Card title={t('settings.appearance')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('settings.themeMode')}
</Text>
<View style={styles.optionRow}>
{THEME_OPTIONS.map(opt => {
const active = themeMode === opt.mode;
return (
<Pressable
key={opt.mode}
onPress={() => setThemeMode(opt.mode)}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{t(opt.labelKey)}
</Text>
</Pressable>
);
})}
</View>
</Card>
{/* 语言偏好 */}
<Card title={t('settings.language')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('settings.selectLang')}
</Text>
<View style={styles.optionRow}>
{LOCALE_OPTIONS.map(opt => {
const active = locale === opt.locale;
return (
<Pressable
key={opt.locale}
onPress={() => setLocale(opt.locale)}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{t(opt.labelKey)}
</Text>
</Pressable>
);
})}
</View>
</Card>
{/* 安全设置 */}
<Card title={t('settings.security')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.appLock')}
</Text>
<Switch
value={appLockEnabled}
onValueChange={async (val) => {
if (val) {
// 开启:需要设置 PIN
const { hasPinSet, setPin } = await import('../../components/LockScreen');
const hasPin = await hasPinSet();
if (!hasPin) {
// Alert.prompt 仅 iOS 可用Android 直接开启(用户首次锁屏时设置)
// 这里简化处理:直接开启,用户在锁屏界面首次输入时即设置 PIN
// 完整版应弹出一个 Modal 含 TextInput留后续优化
Alert.alert(
t('settings.setPinDesc'),
undefined,
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.confirm'),
onPress: async () => {
// 设置一个默认空 PIN用户可在锁屏界面修改
// 真实实现应弹出 PIN 输入 Modal
setAppLockEnabled(true);
},
},
],
);
} else {
setAppLockEnabled(true);
}
} else {
// 关闭:清除 PIN
const { clearPin } = await import('../../components/LockScreen');
await clearPin();
setAppLockEnabled(false);
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 每日记账提醒 */}
<Card title={t('settings.reminderTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.reminderToggle')}
</Text>
<Switch
value={reminderEnabled}
onValueChange={async (val) => {
updateReminderConfig({ reminderEnabled: val });
try {
const { RealNotificationScheduler, setupDailyReminder, cancelAllReminders } = await import('../../services/reminder');
const scheduler = new RealNotificationScheduler();
if (val) {
await setupDailyReminder(scheduler, reminderHour, reminderMinute);
} else {
await cancelAllReminders(scheduler);
}
} catch (e) {
Alert.alert(String(e));
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{reminderEnabled && (
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.reminderTime')}
</Text>
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text>
<Pressable
onPress={async () => {
// 简单的时间调整:小时 +1 循环
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ marginLeft: 12, padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable>
</View>
)}
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
});

546
src/app/settings/sync.tsx Normal file
View File

@ -0,0 +1,546 @@
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal';
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/backup';
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/maintenance';
export default function SyncSettingsScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// Sync Configuration
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
const webdavUsername = useSettingsStore(s => s.webdavUsername) || '';
const webdavPassword = useSettingsStore(s => s.webdavPassword) || '';
const webdavRemotePath = useSettingsStore(s => s.webdavRemotePath) || 'main.bean';
const gitRemoteUrl = useSettingsStore(s => s.gitRemoteUrl) || '';
const gitBranch = useSettingsStore(s => s.gitBranch) || 'main';
const gitUsername = useSettingsStore(s => s.gitUsername) || '';
const gitPassword = useSettingsStore(s => s.gitPassword) || '';
const updateSyncConfig = useSettingsStore(s => s.updateSyncConfig);
// Ledger state
const ledger = useLedgerStore(s => s.ledger);
const mobileBean = useLedgerStore(s => s.mobileBean);
const replaceMobileBean = useLedgerStore(s => s.replaceMobileBean);
// Modal Visibility
const [webdavModalVisible, setWebdavModalVisible] = useState(false);
const [gitModalVisible, setGitModalVisible] = useState(false);
// WebDAV credentials form fields
const webdavFields: FormField[] = [
{ 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: 'password', label: t('sync.password'), placeholder: 'password', defaultValue: webdavPassword },
{ key: 'remotePath', label: t('sync.remotePath'), placeholder: 'main.bean', defaultValue: webdavRemotePath },
];
// Git credentials form fields
const gitFields: FormField[] = [
{ key: 'remoteUrl', label: 'Git Remote URL', placeholder: 'https://github.com/user/ledger.git', defaultValue: gitRemoteUrl },
{ key: 'branch', label: t('sync.branch'), placeholder: 'main', defaultValue: gitBranch },
{ key: 'username', label: t('sync.username'), placeholder: 'gituser', defaultValue: gitUsername },
{ key: 'password', label: t('sync.passwordToken'), placeholder: 'token', defaultValue: gitPassword },
];
const saveWebDAV = (values: Record<string, string>) => {
updateSyncConfig({
webdavUrl: values.url,
webdavUsername: values.username,
webdavPassword: values.password,
webdavRemotePath: values.remotePath,
});
setWebdavModalVisible(false);
};
const saveGit = (values: Record<string, string>) => {
updateSyncConfig({
gitRemoteUrl: values.remoteUrl,
gitBranch: values.branch,
gitUsername: values.username,
gitPassword: values.password,
});
setGitModalVisible(false);
};
// === WebDAV 同步(真实) ===
const handleWebDAVSync = async () => {
if (!webdavUrl) {
Alert.alert(t('sync.hint'), t('sync.webdavNotConfigured'));
return;
}
try {
const client = new WebDAVClient({
url: webdavUrl,
username: webdavUsername,
password: webdavPassword,
remotePath: webdavRemotePath,
backupKeepCount: 3,
}, fetch as unknown as FetchImpl);
const result = await syncWithWebDAV(client, mobileBean, new Date().toISOString());
if (result.action === 'pulled' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
Alert.alert(t('sync.syncSuccess'), t('sync.webdvResult', { action: result.action }));
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
};
// === Git 同步(真实 — ExpoGitBackend 基于 HTTP 单文件同步) ===
const handleGitSync = async () => {
if (!gitRemoteUrl) {
Alert.alert(t('sync.hint'), t('sync.gitNotConfigured'));
return;
}
try {
const backend = new ExpoGitBackend(gitRemoteUrl, gitUsername, gitPassword, fetch);
const result = await syncWithGit(backend, {
remoteUrl: gitRemoteUrl,
branch: gitBranch,
autoSync: false,
syncIntervalMin: 30,
}, mobileBean);
if (result.action === 'pulled' || result.action === 'merged' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
const msgKey = result.conflicts && result.conflicts.length > 0
? t('sync.gitConflict', { count: result.conflicts.length })
: t('sync.gitResult', { action: result.action });
Alert.alert(t('sync.syncSuccess'), msgKey);
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
};
// === iCloud 同步(演示模式 — Mock 后端Android 上隐藏) ===
const handleICloudSync = async () => {
Alert.alert(
t('sync.demoMode'),
t('sync.iCloudDemoDesc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('sync.runDemo'),
onPress: async () => {
try {
const fs = new MockICloudFileSystem();
fs.available = true;
fs.setRemoteFile('main.bean', `${t('sync.iCloudDemoContent')}\n`, new Date().toISOString());
const result = await syncWithICloud(fs, mobileBean, new Date().toISOString());
if (result.action === 'pulled' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
Alert.alert(t('sync.demoSyncSuccess'), t('sync.iCloudResult', { action: result.action }));
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
},
},
],
);
};
// === 本地备份(真实写文件 + 分享) ===
const handleBackup = async () => {
try {
const files = ledger?.files ?? [];
// 收集设置(剔除敏感字段和函数)
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 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`;
await FileSystem.writeAsStringAsync(backupPath, json);
// 尝试分享(用户可保存到云盘/发送)
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(backupPath, {
mimeType: 'application/json',
dialogTitle: t('sync.backupShareTitle'),
});
}
Alert.alert(t('sync.backupSuccess'), t('sync.backupSuccessDesc', { path: backupPath }));
} catch (e) {
Alert.alert(t('sync.backupFail'), String(e));
}
};
// === 从备份文件恢复(真实) ===
const handleRestore = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: 'application/json',
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle);
// 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean
const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || '';
await replaceMobileBean(restoredMobileBean);
// 恢复设置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) {
Alert.alert(t('sync.restoreFail'), e instanceof Error ? e.message : String(e));
}
};
// === 加载本地 .bean 账本(真实) ===
const handleLoadLocalLedger = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: '*/*',
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
// 持久化到 documentDirectory下次启动自动加载
const mainPath = FileSystem.documentDirectory + 'main.bean';
await FileSystem.writeAsStringAsync(mainPath, content);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const loadLedger = useLedgerStore.getState().loadLedger;
const setContext = useImportStore.getState().setContext;
const { FileSystemBackend } = await import('../../services/fileSystemBackend');
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
setContext({ rules, categories });
Alert.alert(t('sync.loadSuccess'), t('sync.loadDesc', { name: file.name }));
} catch (e) {
Alert.alert(t('sync.loadFail'), e instanceof Error ? e.message : String(e));
}
};
// === 维护清理(真实 — 扫描并清理 documentDirectory 下的孤儿文件) ===
const handleMaintenance = async () => {
Alert.alert(
t('sync.maintenanceTitle'),
t('sync.maintenanceConfirm'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.confirm'),
onPress: async () => {
try {
const baseDir = FileSystem.documentDirectory ?? '';
const cacheDir = FileSystem.cacheDirectory ?? '';
const fs = new ExpoMaintenanceFs();
const db = new ExpoMaintenanceDb(mobileBean);
const report = await runMaintenance(
fs, db, new Set(),
{
attachments: `${baseDir}attachments/`,
thumbnails: `${baseDir}thumbnails/`,
cache: cacheDir,
},
);
Alert.alert(
t('sync.syncSuccess'),
t('sync.maintenanceResult', {
count: report.cleanedCount,
size: report.totalOrphanSize,
}),
);
} catch (e) {
Alert.alert(t('sync.optimizeFail'), String(e));
}
},
},
],
);
};
// === 导出 Excel ===
const handleExportExcel = async () => {
try {
const XLSX = (await import('xlsx')).default;
const { exportToExcel } = await import('../../services/exportToExcel');
const { transactions } = ledger!;
const workbook = XLSX.utils.book_new();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const exportPath = FileSystem.documentDirectory + `transactions-${timestamp}.xlsx`;
let base64Content = '';
// 用 xlsx 库创建 workbook wrapper
const wbWrapper = {
addSheet: (name: string, rows: any[]) => {
const ws = XLSX.utils.json_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, ws, name);
},
write: (path: string) => {
base64Content = XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
},
};
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()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
Alert.alert(t('sync.exportSuccess'));
} catch (e) {
Alert.alert(t('sync.exportFail'), String(e));
}
};
// === 导出规则 ===
const handleExportRules = async () => {
try {
const { exportRules, MockRuleFileSync } = await import('../../services/ruleSync');
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const exportPath = FileSystem.documentDirectory + `rules-${timestamp}.json`;
const fs = new MockRuleFileSync();
// MockRuleFileSync 是内存的,需要真实写入
await exportRules(rules, categories, fs, exportPath);
// 获取 Mock 写入的内容,用真实 FileSystem 写
const content = await fs.read(exportPath);
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()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/json' });
}
Alert.alert(t('sync.exportSuccess'));
} catch (e) {
Alert.alert(t('sync.exportFail'), String(e));
}
};
// === 导入规则 ===
const handleImportRules = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({ type: 'application/json', copyToCacheDirectory: true });
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
const { parseRuleBundle, mergeRules } = await import('../../services/ruleSync');
const bundle = parseRuleBundle(content);
const existingRules = useMetadataStore.getState().rules;
const merged = mergeRules(existingRules, bundle.rules);
// 用合并后的规则更新 store需要逐条添加新规则
const newRules = merged.filter(r => !existingRules.some(e => e.id === r.id));
for (const rule of newRules) {
useMetadataStore.getState().addRule(rule);
}
Alert.alert(t('sync.importRulesSuccess', { count: newRules.length }));
} catch (e) {
Alert.alert(t('sync.exportFail'), e instanceof Error ? e.message : String(e));
}
};
const isAndroid = Platform.OS === 'android';
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('sync.title')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
{/* 云同步配置与云端操作 */}
<Card title={t('sync.cloudTitle')}>
<View style={styles.syncBtnRow}>
<View style={{ flex: 1 }}>
<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 style={{ marginTop: 8, gap: 8 }}>
<Button label={t('sync.webdavSyncNow')} onPress={handleWebDAVSync} />
<Button label={t('sync.gitSyncNow')} onPress={handleGitSync} variant="secondary" />
{!isAndroid && (
<Button label={t('sync.iCloudSync')} onPress={handleICloudSync} variant="secondary" />
)}
</View>
{isAndroid && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('sync.iCloudUnavailable')}
</Text>
)}
</Card>
{/* 本地备份与维护 */}
<Card title={t('sync.backupTitle')}>
<View style={styles.buttonRow}>
<View style={{ flex: 1 }}>
<Button label={t('sync.backupNow')} onPress={handleBackup} />
</View>
<View style={{ flex: 1 }}>
<Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" />
</View>
</View>
<View style={{ gap: 8, marginTop: 8 }}>
<Button label={t('sync.loadLocalLedger')} onPress={handleLoadLocalLedger} />
<Button label={t('sync.maintenance')} onPress={handleMaintenance} variant="secondary" />
</View>
</Card>
{/* 数据导出与分享 */}
<Card title={t('sync.exportTitle')}>
<View style={{ gap: 8 }}>
<Button label={t('sync.exportExcel')} onPress={handleExportExcel} />
<Button label={t('sync.exportRules')} onPress={handleExportRules} variant="secondary" />
<Button label={t('sync.importRules')} onPress={handleImportRules} variant="secondary" />
</View>
</Card>
</ScrollView>
{/* WebDAV 凭据配置 */}
<FormModal
visible={webdavModalVisible}
title={t('sync.webdavConfigTitle')}
fields={webdavFields}
onConfirm={saveWebDAV}
onCancel={() => setWebdavModalVisible(false)}
/>
{/* Git 凭据配置 */}
<FormModal
visible={gitModalVisible}
title={t('sync.gitConfigTitle')}
fields={gitFields}
onConfirm={saveGit}
onCancel={() => setGitModalVisible(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
syncBtnRow: { flexDirection: 'row', gap: 8 },
buttonRow: { flexDirection: 'row', gap: 8 },
});

141
src/app/tag/index.tsx Normal file
View File

@ -0,0 +1,141 @@
/**
* plan.md1.1 tag/index+ 1
*
* () + //
* .bean #tag , [\w-]
*/
import React, { useState } from 'react';
import { Alert, 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 { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
type ModalMode = { type: 'add' } | { type: 'edit'; tag: Tag } | null;
const COLORS = ['#F44336', '#E91E63', '#9C27B0', '#2196F3', '#00BCD4', '#4CAF50', '#FF9800', '#795548'];
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [modal, setModal] = useState<ModalMode>(null);
const [selectedColor, setSelectedColor] = useState(COLORS[0]);
const handleDelete = (tag: Tag) => {
Alert.alert(t('tag.deleteTitle'), t('tag.deleteConfirm', { name: tag.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTag(tag.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return;
}
if (modal?.type === 'add') {
addTag({ id: generateId('tag'), name, color: selectedColor });
} else if (modal?.type === 'edit') {
updateTag(modal.tag.id, { name, color: selectedColor });
}
setModal(null);
};
const openModal = (mode: ModalMode) => {
if (mode?.type === 'edit') setSelectedColor(mode.tag.color);
else setSelectedColor(COLORS[0]);
setModal(mode);
};
const editFields: FormField[] = modal?.type === 'edit'
? [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: modal.tag.name }]
: [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food' }];
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('tag.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('tag.count', { count: tags.length })}>
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text>
</Pressable>
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
{tags.map(tag => (
<Pressable
key={tag.id}
onPress={() => openModal({ type: 'edit', tag })}
onLongPress={() => handleDelete(tag)}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
))}
{tags.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
)}
</View>
</Card>
{/* 颜色选择器(模态打开时显示) */}
{modal && (
<Card title={t('tag.selectColor')}>
<View style={styles.colorRow}>
{COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
</Card>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('tag.editTitle') : t('tag.add')}
fields={editFields}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
grid: { flexDirection: 'row', flexWrap: 'wrap' },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});

View File

@ -0,0 +1,429 @@
/**
* plan.md1.1 transaction/[id]
*
* ledger.transactions id ,:
* - / / / flag
* - postings( / / / cost / price)
* - / /
* - .bean
*/
import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore';
import { hash } from '../../domain/ledger';
import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const ledger = useLedgerStore(s => s.ledger);
const tx = useMemo(
() => ledger?.transactions.find(txn => txn.id === id) ?? null,
[ledger, id],
);
const [linkModalVisible, setLinkModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isEditable = tx ? tx.source.endsWith('main.bean') : false;
// 找出所有共享同一个链接标签的关联交易
const relatedTransactions = useMemo(() => {
if (!ledger || !tx || !tx.links || tx.links.length === 0) return [];
return ledger.transactions.filter(
t => t.id !== id && t.links.some(l => tx.links.includes(l))
);
}, [ledger, id, tx?.links]);
// 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => {
if (!ledger) return [];
return ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
t.narration?.toLowerCase().includes(q) ||
t.payee?.toLowerCase().includes(q) ||
t.postings.some(p => p.account.toLowerCase().includes(q))
);
})
.reverse() // 解决截断 Bug将顺序反转优先展示最新的交易
.slice(0, 30);
}, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return;
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签
let linkTag = tx.links[0] || targetTx.links[0];
if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
}
const addLinkToRaw = (raw: string, tag: string) => {
const lines = raw.split('\n');
const line = lines[0];
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');
};
const newCurrentRaw = addLinkToRaw(tx.raw, linkTag);
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replace(tx.raw, newCurrentRaw);
newContent = newContent.replace(targetTx.raw, newTargetRaw);
await replaceMobileBean(newContent);
setLinkModalVisible(false);
// 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) {
Alert.alert(t('transaction.linkFail'), String(e));
}
};
const handleUnlink = async (linkTag: string) => {
if (!tx) return;
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
const companionTxs = (ledger?.transactions ?? []).filter(
t => t.links.includes(linkTag) && t.source.endsWith('main.bean')
);
let newContent = mobileBean;
let newCurrentRaw = tx.raw;
for (const companion of companionTxs) {
const lines = companion.raw.split('\n');
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replace(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
await replaceMobileBean(newContent);
// 解决 race condition直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) {
Alert.alert(t('transaction.unlinkFail'), String(e));
}
};
if (!tx) {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<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('common.notFound')}</Text>
</View>
<View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('transaction.notFoundDesc', { id })}
</Text>
</Card>
</View>
</SafeAreaView>
);
}
const isExpense = tx.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = tx.postings.some(p => p.account.startsWith('Income'));
const directionLabel = isExpense ? t('transaction.directionExpense') : isIncome ? t('transaction.directionIncome') : t('transaction.directionTransfer');
const directionColor = isExpense ? theme.colors.financial.expense
: isIncome ? theme.colors.financial.income
: theme.colors.financial.transfer;
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<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('transaction.detailTitle')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */}
<Card title={t('transaction.summary')}>
<View style={styles.summaryRow}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{tx.narration || tx.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''}
</Text>
</View>
<View style={[styles.directionBadge, { backgroundColor: directionColor }]}>
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text>
</View>
</View>
</Card>
{/* Postings */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
{tx.postings.map((p, i) => (
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{p.account}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end' }}>
{p.amount && (
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
@ {p.price}
</Text>
)}
</View>
</View>
))}
</Card>
{/* 标签 */}
{tx.tags.length > 0 && (
<Card title={t('transaction.tags')}>
<View style={styles.tagRow}>
{tx.tags.map(tag => (
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark }]}>#{tag}</Text>
</View>
))}
</View>
</Card>
)}
{/* 链接 */}
{(tx.links.length > 0 || isEditable) && (
<Card title={t('transaction.links')}>
<View style={styles.tagRow}>
{tx.links.map(link => (
<View key={link} style={[styles.tagContainer, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark, fontWeight: '700' }]}>^{link}</Text>
{isEditable && (
<Pressable onPress={() => handleUnlink(link)} style={{ marginLeft: 6 }}>
<Ionicons name="close-circle" size={14} color={theme.colors.accentDark} />
</Pressable>
)}
</View>
))}
</View>
{isEditable && (
<Pressable
onPress={() => setLinkModalVisible(true)}
style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]}
>
<Ionicons name="link-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 6, fontWeight: '700' }]}>
{t('transaction.linkTransaction')}
</Text>
</Pressable>
)}
{!isEditable && tx.links.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('transaction.readOnlyLinks')}
</Text>
)}
</Card>
)}
{/* 关联的交易列表 */}
{relatedTransactions.length > 0 && (
<Card title={t('transaction.relatedTransactions')}>
{relatedTransactions.map(item => (
<TransactionCard
key={item.id}
transaction={item}
onPress={(target) => router.push(`/transaction/${target.id}`)}
/>
))}
</Card>
)}
{/* 元数据 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card title={t('transaction.metadata')}>
{Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{v}</Text>
</View>
))}
</Card>
)}
{/* 原始 .bean */}
<Card title={t('transaction.rawBean')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontFamily: 'monospace', lineHeight: 20 }]}>
{tx.raw}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('transaction.source')}: {tx.source}
</Text>
</Card>
{/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
{isEditable && (
<View style={styles.editActions}>
<Pressable
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => {
Alert.alert(t('transaction.deleteTitle'), t('transaction.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'), style: 'destructive',
onPress: async () => {
try {
await useLedgerStore.getState().deleteTransaction(tx.raw);
Alert.alert(t('transaction.deleteSuccess'));
router.back();
} catch (e) {
Alert.alert('Error', String(e));
}
},
},
]);
}}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.error, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="trash-outline" size={16} color={theme.colors.error} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.delete')}
</Text>
</Pressable>
</View>
)}
</ScrollView>
{/* 关联交易筛选模态框 */}
<Modal
visible={linkModalVisible}
animationType="slide"
transparent={false}
onRequestClose={() => setLinkModalVisible(false)}
>
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => setLinkModalVisible(false)}>
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('transaction.linkMobileTx')}
</Text>
</View>
<View style={styles.searchBarContainer}>
<TextInput
style={[commonStyles.input, { height: 40 }]}
placeholder={t('transaction.searchTxPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<FlatList
data={linkableTransactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
<TransactionCard
transaction={item}
onPress={() => handleLinkTransaction(item)}
/>
</View>
)}
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('transaction.noLinkableTx')}
</Text>
}
/>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { 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: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
});

792
src/app/transaction/new.tsx Normal file
View File

@ -0,0 +1,792 @@
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { CategoryPicker } from '../../components/CategoryPicker';
import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger';
import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor';
import type { Category } from '../../domain/categories';
import type { Tag } from '../../domain/tags';
import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
export default function NewTransactionScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
const addTransaction = useLedgerStore(s => s.addTransaction);
const editTransaction = useLedgerStore(s => s.editTransaction);
const ledger = useLedgerStore(s => s.ledger);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const today = new Date().toISOString().slice(0, 10);
// 基础表单状态
const [date, setDate] = useState(today);
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [currency, setCurrency] = useState('CNY');
const [amount, setAmount] = useState('');
const [direction, setDirection] = useState<Direction>('expense');
const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额');
const [targetAccount, setTargetAccount] = useState('Assets:微信零钱');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [status, setStatus] = useState('');
// 高级模式状态
const [isAdvanced, setIsAdvanced] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [postings, setPostings] = useState<Posting[]>([
{ account: 'Assets:支付宝余额', 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(
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
[editId, ledger],
);
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string>('*');
const [editLinks, setEditLinks] = useState<string[]>([]);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
useEffect(() => {
if (editingTx) {
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setCurrency(editingTx.postings[0]?.currency || 'CNY');
// 保留编辑前的 flag/links/metadata
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
// 从 postings 推断方向和金额
const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses'));
const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income'));
if (expensePosting) {
setDirection('expense');
setAmount(expensePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类:匹配 Expenses 账户对应的 category
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else if (incomePosting) {
setDirection('income');
setAmount(incomePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else {
setDirection('transfer');
setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings[0]?.account || '');
setTargetAccount(editingTx.postings[1]?.account || '');
}
// 预填 postings高级模式含 cost/price/metadata
setPostings(editingTx.postings.map(p => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
// 预填标签
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' })));
}
}
}, [editingTx, categories]);
// 按方向过滤分类
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
// 来源账户列表(从 ledger 提取 Assets 账户)
const assetAccounts = useMemo(() => {
if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586'];
const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets'));
return accts.length > 0 ? accts : ['Assets:支付宝余额'];
}, [ledger]);
const toggleTag = (tag: Tag) => {
setSelectedTags(prev =>
prev.some(t => t.name === tag.name)
? prev.filter(t => t.name !== tag.name)
: [...prev, tag],
);
};
// === OCR 拍照识账 ===
const handleOcrScan = async () => {
try {
// 1. 选图(相册或拍照)
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets?.[0]?.base64) return;
const base64 = result.assets[0].base64;
// 2. 检查 OCR 引擎是否就绪
const nativeModule = getNativeOcrModule();
if (nativeModule) {
const ready = await nativeModule.isReady();
if (!ready) {
Alert.alert(t('ocr.notReady'));
return;
}
}
setStatus(t('ocr.scanning'));
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
const ocrBridge = getNativeOcrBridge();
const processor = new OcrProcessor(ocrBridge);
const ocrResult = await processor.process(base64);
if (ocrResult.event) {
// 4. 预填表单
const event = ocrResult.event;
if (event.amount) {
const absAmount = event.amount.replace(/^-/, '');
setAmount(absAmount);
}
if (event.counterparty || event.memo) {
setNarration([event.counterparty, event.memo].filter(Boolean).join(' - '));
}
if (event.direction === 'income') {
setDirection('income');
} else if (event.direction === 'transfer') {
setDirection('transfer');
} else {
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'));
} else {
setStatus(t('ocr.failed'));
}
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
}
};
// 从 SpeedDial OCR 入口进入时自动触发
useEffect(() => {
if (mode === 'ocr') {
handleOcrScan();
}
}, [mode]);
const submit = () => {
const tagNames = selectedTags.map(tg => tg.name);
if (isAdvanced) {
// 高级模式:校验多 postings
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) {
setStatus(t('transaction.atLeast2Postings'));
return;
}
// 实时借贷平衡验证
const currencySum: Record<string, number> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
const val = parseFloat(p.amount);
if (!isNaN(val)) {
currencySum[p.currency] = (currencySum[p.currency] || 0) + val;
}
}
}
const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001);
if (!isBalanced) {
setStatus(t('transaction.unbalanced'));
return;
}
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
// 编辑模式:序列化新内容并替换旧块
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
addTransaction(draft).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended'));
setNarration('');
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
} else {
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
const amt = amount.trim();
if (!amt) {
setStatus(t('transaction.amountRequired'));
return;
}
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
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);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
// 新建交易:直接调用统一的共享逻辑函数
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'));
setAmount('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
}
};
const directions: { key: Direction; label: string; color: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense },
{ key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income },
{ key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 高级模式模式切换开关 */}
<View style={commonStyles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
{t('transaction.advancedMode')}
</Text>
<Switch
value={isAdvanced}
onValueChange={(val) => {
setIsAdvanced(val);
// 同步初始化当前分录
if (val) {
const amt = amount.trim();
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (direction === 'expense') {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
]);
} else if (direction === 'income') {
setPostings([
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
]);
} else {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
]);
}
}
}}
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 简单模式下的方向选择 */}
{!isAdvanced && (
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
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>
))}
<Pressable
onPress={handleOcrScan}
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>
)}
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
{!isAdvanced ? (
<Card title={t('transaction.newTitle')}>
{/* 金额 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.amountPlaceholder')}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={amount}
onChangeText={setAmount}
autoFocus={editId ? false : true}
keyboardType="decimal-pad"
placeholder={t('transaction.amountPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
/>
<TextInput
value={currency}
onChangeText={setCurrency}
placeholder="CNY"
maxLength={3}
autoCapitalize="characters"
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
/>
</View>
{/* 来源账户 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formSourceAccount')}
</Text>
<TextInput
value={sourceAccount}
onChangeText={setSourceAccount}
placeholder="Assets:支付宝余额"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷来源账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = sourceAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`src-${acct}`}
onPress={() => setSourceAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
{/* 目标账户 (仅在转账时显示) */}
{direction === 'transfer' && (
<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
label={t('transaction.addPosting')}
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.deleteLastPosting')}
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
variant="secondary"
/>
</View>
</View>
</Card>
)}
{/* 简单模式下的分类选择 */}
{!isAdvanced && direction !== 'transfer' && (
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => setSelectedCategory(cat)}
/>
{selectedCategory && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{selectedCategory.linkedAccount}
</Text>
</View>
)}
</Card>
)}
{/* 详情与高级字段收纳区 */}
<Pressable
onPress={() => {
LayoutAnimation.easeInEaseOut();
setShowDetails(!showDetails);
}}
style={({ pressed }) => [
styles.detailsToggle,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSecondary,
opacity: pressed ? 0.8 : 1,
flexDirection: 'row',
gap: 6
}
]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
</Text>
<Ionicons
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={theme.colors.accent}
/>
</Pressable>
{showDetails && (
<View style={{ gap: theme.spacing.md }}>
<Card title={t('transaction.extraDetails')}>
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 收款方(简单模式才显示) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
</>
)}
</Card>
{/* 标签选择 */}
<Card title={t('transaction.formTags')}>
<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} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 40 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
});

View File

@ -0,0 +1,47 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
interface AccountTreeProps {
nodes: AccountNode[];
/** 最大展示深度(默认全部)。 */
maxDepth?: number;
}
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const { theme } = useTheme();
return (
<View style={{ gap: 4 }}>
{nodes.map(node => (
<AccountTreeNode key={node.fullName} node={node} depth={0} maxDepth={maxDepth} />
))}
</View>
);
}
function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme();
const total = calculateAccountTotalBalance(node);
const isLeaf = node.children.length === 0;
return (
<View>
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
{isLeaf ? '• ' : ''}{node.name}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{total}
</Text>
</View>
{depth < maxDepth && node.children.map(child => (
<AccountTreeNode key={child.fullName} node={child} depth={depth + 1} maxDepth={maxDepth} />
))}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
});

View File

@ -0,0 +1,33 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
interface BalanceIndicatorProps {
/** 各币种余额。 */
balances: Record<string, string>;
}
/** 借贷平衡指示器(主题化)。所有币种余额为 0 时显示「平衡」。 */
export function BalanceIndicator({ balances }: BalanceIndicatorProps) {
const { theme } = useTheme();
const t = useT();
const allBalanced = Object.values(balances).every(b => b === '0');
return (
<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' }]}>
{allBalanced ? t('home.balanced') : t('home.unbalanced')}
</Text>
{!allBalanced && Object.entries(balances).map(([cur, amt]) => (
<Text key={cur} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{cur}: {amt}
</Text>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 8, gap: 2, flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' },
});

73
src/components/Button.tsx Normal file
View File

@ -0,0 +1,73 @@
import React, { useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../theme';
interface ButtonProps {
label: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
}
/** 按钮主题化并有触控动效。primary=主题色secondary=次级容器danger=错误色。 */
export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) {
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
: variant === 'danger' ? theme.colors.error
: theme.colors.bgTertiary;
const fg = variant === 'secondary' ? theme.colors.fgPrimary : theme.colors.fgInverse;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled: !!disabled }}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={{ marginTop: 6 }}
>
<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>
);
}
const styles = StyleSheet.create({
button: { paddingVertical: 12, paddingHorizontal: 16, alignItems: 'center' },
text: { fontWeight: '700', fontSize: 16 },
});

View File

@ -0,0 +1,124 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
import { dailyExpenseMap } from '../domain/chartStats';
import type { Transaction } from '../domain/types';
interface CalendarViewProps {
transactions: Transaction[];
/** 当前展示的年份。 */
year: number;
/** 当前展示的月份1-12。 */
month: number;
/** 选中日期变化回调YYYY-MM-DD。 */
onDayPress?: (date: string) => void;
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) {
const { theme } = useTheme();
const [selectedDate, setSelectedDate] = useState<string | null>(null);
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) => {
if (!cell.isCurrentMonth) return;
setSelectedDate(cell.date);
onDayPress?.(cell.date);
};
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
{/* 星期表头 */}
<View style={styles.weekdayRow}>
{WEEKDAYS.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1 }]}>
{d}
</Text>
))}
</View>
{/* 日期网格 */}
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const isSelected = cell.date === selectedDate;
const expense = dailyMap.get(cell.date) ?? 0;
const intensity = expense / maxDaily;
return (
<Pressable
key={cell.date}
onPress={() => handlePress(cell)}
disabled={!cell.isCurrentMonth}
style={[
styles.dayCell,
{
backgroundColor: isSelected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.sm,
},
]}
>
{/* 消费热力图背景叠加层(未选中、当前月且有支出时显示) */}
{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={[
theme.typography.bodySmall,
{
color: !cell.isCurrentMonth
? theme.colors.border
: isSelected
? theme.colors.fgInverse
: theme.colors.fgPrimary,
textAlign: 'center',
zIndex: 1,
},
]}>
{cell.day}
</Text>
{/* 交易标记圆点 */}
{cell.count > 0 && cell.isCurrentMonth && (
<View style={[styles.dots, { zIndex: 1 }]}>
{cell.hasIncome && (
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.income }]} />
)}
{/* 如果没有支出热力图背景,或者选中了,才渲染支出的点,防止视觉元素重复 */}
{cell.hasExpense && (expense === 0 || isSelected) && (
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.expense }]} />
)}
</View>
)}
</Pressable>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 4 },
weekdayRow: { flexDirection: 'row', marginBottom: 4 },
weekRow: { flexDirection: 'row' },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 4, position: 'relative' },
dots: { flexDirection: 'row', gap: 3, marginTop: 2 },
dot: { width: 5, height: 5, borderRadius: 2.5 },
});

103
src/components/Card.tsx Normal file
View File

@ -0,0 +1,103 @@
import React, { useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { useTheme } from '../theme';
interface CardProps {
title?: string;
children: React.ReactNode;
onPress?: () => void;
style?: ViewStyle;
}
/** 卡片容器(主题化,并有触控微交互)。 */
export function Card({ title, children, onPress, style }: CardProps) {
const { theme } = useTheme();
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 (
<View
style={[
styles.card,
cardStyle,
style,
]}
>
{title ? (
<Text
style={[
styles.title,
{ color: theme.colors.fgPrimary },
theme.typography.h3,
]}
>
{title}
</Text>
) : null}
{children}
</View>
);
}
const styles = StyleSheet.create({
card: { gap: 8 },
title: { marginBottom: 4 },
});

View File

@ -0,0 +1,118 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import type { Category } from '../domain/categories';
interface CategoryPickerProps {
categories: Category[];
selectedId?: string;
onSelect: (cat: Category) => void;
}
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) {
const { theme } = useTheme();
return (
<View style={styles.grid}>
{categories.map(cat => {
const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
return (
<Pressable
key={cat.id}
onPress={() => onSelect(cat)}
style={({ pressed }) => [
styles.item,
{
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
}
]}
>
<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}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingVertical: 4,
},
item: {
width: '23%', // 约 4 列排布
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 1,
gap: 6,
padding: 4,
},
icon: {
fontSize: 22,
},
label: {
fontSize: 12,
},
});

View File

@ -0,0 +1,59 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import type { DedupResult } from '../domain/dedup';
interface DedupBannerProps {
result: DedupResult;
onAccept?: () => void; // 接受(仍要记账)
onReject?: () => void; // 拒绝(不记)
}
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
const { theme } = useTheme();
const t = useT();
if (!result.isDuplicate) return null;
const confidenceColor = result.confidence === 'high'
? theme.colors.error
: result.confidence === 'medium'
? theme.colors.warning
: theme.colors.fgSecondary;
return (
<View style={[styles.banner, { backgroundColor: theme.colors.bgTertiary, borderLeftColor: confidenceColor }]}>
<View style={styles.header}>
<Ionicons name="warning" size={18} color={confidenceColor} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('importFlow.suspectedDuplicate')}
</Text>
<Text style={[theme.typography.caption, { color: confidenceColor }]}>
{result.confidence}
</Text>
</View>
{result.reason && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{result.reason}
</Text>
)}
<View style={styles.actions}>
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
</Pressable>
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
<Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 },
header: { flexDirection: 'row', alignItems: 'center', gap: 6 },
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
btn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 6, borderWidth: 1 },
});

View File

@ -0,0 +1,148 @@
/**
* plan.md CRUD
*
* , + /
* /////
*
* :
* <FormModal
* visible={true}
* title="添加分类"
* fields={[
* { key: 'name', label: '名称', placeholder: '餐饮' },
* { key: 'linkedAccount', label: '关联账户', placeholder: 'Expenses:餐饮' },
* ]}
* initialValues={{ name: '', linkedAccount: '' }}
* onConfirm={(values) => { /* *\/ }}
* onCancel={() => { /* *\/ }}
* />
*/
import React, { useEffect, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { Button } from './Button';
export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */
key: string;
/** 显示标签。 */
label: string;
/** 占位提示。 */
placeholder?: string;
/** 默认值(新增时为空,编辑时预填)。 */
defaultValue?: string;
/** 键盘类型。 */
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
/** 多行输入。 */
multiline?: boolean;
}
interface FormModalProps {
visible: boolean;
title: string;
fields: FormField[];
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string;
}
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const commonStyles = createCommonStyles(theme);
const [values, setValues] = useState<Record<string, string>>({});
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => {
if (visible) {
const init: Record<string, string> = {};
for (const f of fields) {
init[f.key] = f.defaultValue ?? '';
}
setValues(init);
}
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</View>
<ScrollView style={styles.body}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
style={commonStyles.input}
/>
</View>
))}
</ScrollView>
<View style={styles.actions}>
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
<View style={{ flex: 1 }} />
<Button label={confirmLabel ?? t('common.confirm')} onPress={() => onConfirm(values)} />
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
maxHeight: '80%',
padding: 20,
paddingBottom: 36,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 12,
borderBottomWidth: 1,
marginBottom: 12,
},
body: {
maxHeight: 400,
},
field: {
marginBottom: 12,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
},
});

View File

@ -0,0 +1,260 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as SecureStore from 'expo-secure-store';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/security';
/** SecureStore 中存储 PIN 哈希的 key。 */
const PIN_HASH_KEY = 'app_pin_hash';
interface LockScreenProps {
onUnlock: () => void;
}
export function LockScreen({ onUnlock }: LockScreenProps) {
const { theme } = useTheme();
const t = useT();
const [error, setError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const [showPinInput, setShowPinInput] = useState(false);
const [pin, setPin] = useState('');
const [hasPin, setHasPin] = useState(true); // 是否已设置过 PIN
const handleAuth = useCallback(async () => {
setRetrying(true);
setError(null);
const provider = new RealAuthProvider();
const result = await authenticate(provider);
if (result.success) {
onUnlock();
} else {
setError(t('lockScreen.authFail'));
setShowPinInput(true);
}
setRetrying(false);
}, [onUnlock, t]);
useEffect(() => {
// 检查是否已设置 PIN
SecureStore.getItemAsync(PIN_HASH_KEY).then(hash => {
setHasPin(hash !== null);
});
// 尝试生物识别
handleAuth();
}, []);
const handleKeyPress = async (num: string) => {
setError(null);
if (pin.length < 4) {
const nextPin = pin + num;
setPin(nextPin);
if (nextPin.length === 4) {
// 验证 PIN
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
if (storedHash) {
const valid = await verifyPin(nextPin, storedHash);
if (valid) {
onUnlock();
} else {
setError(t('lockScreen.pinFail'));
setTimeout(() => setPin(''), 500);
}
} else {
// 未设置 PIN直接放行不应发生——首次设置在 preferences 页)
onUnlock();
}
}
}
};
const handleBackspace = () => {
setPin(prev => prev.slice(0, -1));
};
const handleClear = () => {
setPin('');
};
const renderKey = (val: string) => (
<Pressable
key={val}
onPress={() => handleKeyPress(val)}
accessibilityRole="button"
accessibilityLabel={val}
style={({ pressed }) => [
styles.keyBtn,
{
backgroundColor: theme.colors.bgTertiary,
borderColor: theme.colors.border,
opacity: pressed ? 0.6 : 1,
},
]}
>
<Text style={[styles.keyText, { color: theme.colors.fgPrimary }]}>{val}</Text>
</Pressable>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.content}>
{!showPinInput ? (
<View style={styles.centerSection}>
<View style={[styles.iconWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<Ionicons name="lock-closed" size={48} color={theme.colors.accent} />
</View>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginTop: 16 }]}>
{t('lockScreen.locked')}
</Text>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 8, textAlign: 'center' }]}>
{t('lockScreen.bioPrompt')}
</Text>
{error && (
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginTop: 12 }]}>
{error}
</Text>
)}
<View style={styles.actionRow}>
<Pressable
onPress={handleAuth}
disabled={retrying}
accessibilityRole="button"
accessibilityLabel={retrying ? t('lockScreen.authenticating') : t('lockScreen.clickUnlock')}
style={({ pressed }) => [
styles.unlockBtn,
{ backgroundColor: theme.colors.accent, opacity: pressed || retrying ? 0.6 : 1 },
]}
>
<Text style={[theme.typography.body, { color: theme.colors.fgInverse, fontWeight: '700' }]}>
{retrying ? t('lockScreen.authenticating') : t('lockScreen.clickUnlock')}
</Text>
</Pressable>
{hasPin && (
<Pressable
onPress={() => setShowPinInput(true)}
accessibilityRole="button"
accessibilityLabel={t('lockScreen.usePin')}
style={({ pressed }) => [
styles.unlockBtn,
{ backgroundColor: theme.colors.bgTertiary, borderWidth: 1, borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 },
]}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('lockScreen.usePin')}
</Text>
</Pressable>
)}
</View>
</View>
) : (
<View style={styles.pinSection}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginBottom: 8 }]}>
{t('lockScreen.enterPin')}
</Text>
{/* Dots */}
<View style={styles.dotsRow}>
{[0, 1, 2, 3].map(i => (
<View
key={i}
style={[
styles.dot,
{
backgroundColor: pin.length > i ? theme.colors.accent : theme.colors.border,
},
]}
/>
))}
</View>
{error && (
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginBottom: 16 }]}>
{error}
</Text>
)}
{/* Keypad Grid */}
<View style={styles.grid}>
<View style={styles.gridRow}>
{renderKey('1')}
{renderKey('2')}
{renderKey('3')}
</View>
<View style={styles.gridRow}>
{renderKey('4')}
{renderKey('5')}
{renderKey('6')}
</View>
<View style={styles.gridRow}>
{renderKey('7')}
{renderKey('8')}
{renderKey('9')}
</View>
<View style={styles.gridRow}>
<Pressable onPress={handleClear} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel={t('lockScreen.clear')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('lockScreen.clear')}
</Text>
</Pressable>
{renderKey('0')}
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel="Backspace">
<Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} />
</Pressable>
</View>
</View>
<Pressable
onPress={() => {
setShowPinInput(false);
setError(null);
setPin('');
}}
style={styles.switchBackBtn}
accessibilityRole="button"
accessibilityLabel={t('lockScreen.useBio')}
>
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
{t('lockScreen.useBio')}
</Text>
</Pressable>
</View>
)}
</View>
</SafeAreaView>
);
}
/** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */
export async function setPin(pin: string): Promise<void> {
const hash = await hashPin(pin);
await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
}
export async function clearPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_HASH_KEY);
}
export async function hasPinSet(): Promise<boolean> {
const hash = await SecureStore.getItemAsync(PIN_HASH_KEY);
return hash !== null;
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
centerSection: { alignItems: 'center', justifyContent: 'center' },
iconWrap: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center' },
actionRow: { flexDirection: 'row', gap: 12, marginTop: 32 },
unlockBtn: { paddingVertical: 14, paddingHorizontal: 28, borderRadius: 12, minWidth: 120, alignItems: 'center' },
pinSection: { alignItems: 'center', width: '100%' },
dotsRow: { flexDirection: 'row', gap: 20, marginBottom: 24 },
dot: { width: 16, height: 16, borderRadius: 8 },
grid: { width: '80%', gap: 12 },
gridRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
keyBtn: { flex: 1, height: 60, borderRadius: 30, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
keyText: { fontSize: 24, fontWeight: '700' },
utilityBtn: { flex: 1, height: 60, alignItems: 'center', justifyContent: 'center' },
switchBackBtn: { marginTop: 32, padding: 8 },
});

View File

@ -0,0 +1,69 @@
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { BalanceIndicator } from './BalanceIndicator';
import type { Posting } from '../domain/types';
import { addDecimals } from '../domain/decimal';
interface PostingEditorProps {
postings: Posting[];
onChange: (postings: Posting[]) => void;
/** 可选账户列表(用于自动补全提示,当前为自由输入)。 */
accounts?: string[];
}
/** 分类编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */
export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const update = (index: number, patch: Partial<Posting>) => {
onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p));
};
// 计算各币种余额
const grouped = new Map<string, string[]>();
for (const p of postings) {
if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
}
}
const balances: Record<string, string> = {};
for (const [cur, amts] of grouped) balances[cur] = addDecimals(amts);
return (
<View style={{ gap: 8 }}>
{postings.map((p, i) => (
<View key={i} style={styles.row}>
<TextInput
value={p.account}
onChangeText={v => update(i, { account: v })}
placeholder="账户"
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 2 }]}
/>
<TextInput
value={p.amount ?? ''}
onChangeText={v => update(i, { amount: v })}
placeholder="金额"
keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} // 金额输入也采用等宽字体更专业
/>
<TextInput
value={p.currency ?? ''}
onChangeText={v => update(i, { currency: v })}
placeholder="币种"
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { width: 60 }]}
/>
</View>
))}
<BalanceIndicator balances={balances} />
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 6 },
});

View File

@ -0,0 +1,45 @@
import React from 'react';
import { StyleSheet, TextInput, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
interface SearchBarProps {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
}
export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps) {
const { theme } = useTheme();
return (
<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} />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { color: theme.colors.fgPrimary }]}
/>
{value ? (
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} onPress={() => onChangeText('')} />
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, marginHorizontal: 16, marginBottom: 8 },
icon: { marginRight: 8 },
input: { flex: 1, padding: 0, fontSize: 16 },
});

View File

@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { useT } from '../i18n';
/** 快捷操作项。 */
export interface SpeedDialAction {
key: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
}
interface SpeedDialProps {
/** 展开式快捷操作列表。不传时保持单按钮行为(记一笔)。 */
actions?: SpeedDialAction[];
}
/**
*
* - actions "+"
* - actions mini-FAB //
*/
export function SpeedDial({ actions }: SpeedDialProps) {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const [open, setOpen] = useState(false);
const [spin] = useState(new Animated.Value(0));
const hasActions = actions && actions.length > 0;
const toggle = () => {
const next = !open;
setOpen(next);
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
};
const handleAction = (action: SpeedDialAction) => {
setOpen(false);
Animated.spring(spin, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
action.onPress();
};
const mainRotation = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '45deg'],
});
const mainIcon = hasActions ? (
<Animated.View style={{ transform: [{ rotate: mainRotation }] }}>
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
</Animated.View>
) : (
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
);
return (
<>
{/* 遮罩层:展开时点击空白收起 */}
{hasActions && open && (
<Pressable
onPress={toggle}
style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
/>
)}
{/* 展开的快捷操作列表 */}
{hasActions && (
<View style={styles.actionsContainer} pointerEvents={open ? 'auto' : 'none'}>
{actions!.map((action, i) => {
const offset = 72 + 56 * (actions!.length - 1 - i);
const translateY = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, -offset],
});
const opacity = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
const scale = spin.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
});
return (
<Animated.View
key={action.key}
style={[
styles.actionRow,
{ transform: [{ translateY }, { scale }], opacity },
]}
>
<View style={[styles.actionLabel, { backgroundColor: theme.colors.bgSecondary }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{action.label}
</Text>
</View>
<Pressable
onPress={() => handleAction(action)}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.miniFab,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.accent} />
</Pressable>
</Animated.View>
);
})}
</View>
)}
{/* 主按钮 */}
<Pressable
onPress={hasActions ? toggle : () => router.push('/transaction/new')}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: theme.colors.accent,
opacity: pressed ? 0.85 : 1,
...theme.shadows.lg,
},
]}
accessibilityRole="button"
accessibilityLabel={t('home.newTransaction')}
accessibilityHint={hasActions ? t('speedDial.expandHint') : undefined}
>
{mainIcon}
</Pressable>
</>
);
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9998,
},
fab: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
actionsContainer: {
position: 'absolute',
right: 28,
bottom: 28,
zIndex: 9999,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 8,
marginBottom: 8,
},
actionLabel: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
},
miniFab: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
});

View File

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

View File

@ -0,0 +1,69 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import type { Transaction } from '../domain/types';
interface TransactionCardProps {
transaction: Transaction;
onPress?: (t: Transaction) => void;
}
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
const { theme } = useTheme();
const t = useT();
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
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 primaryAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${primaryAmount} ${transaction.date.slice(0, 10)}`}
onPress={() => onPress?.(transaction)}
style={({ pressed }) => [
styles.card,
{
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={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
{transaction.narration || transaction.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{transaction.date.slice(0, 10)}
</Text>
</View>
<Text style={[theme.typography.body, { color, fontWeight: '700', fontFamily: 'monospace', fontSize: 15 }]}>
{primaryAmount}
</Text>
</View>
{transaction.tags.length > 0 && (
<View style={styles.tags}>
{transaction.tags.map(tag => (
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
#{tag}
</Text>
))}
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
card: { marginBottom: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
tags: { flexDirection: 'row', gap: 4, marginTop: 6, flexWrap: 'wrap' },
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
});

View File

@ -0,0 +1,64 @@
/**
* plan.md5.2
* //
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { generateAnnualReport } from '../../domain/annualReport';
import { MonthlyReport } from './MonthlyReport';
import type { Transaction } from '../../domain/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme();
const t = useT();
const report = generateAnnualReport(transactions, year);
return (
<View style={styles.container}>
<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 }]}>{t('report.annualTitle', { year })}</Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<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>
</View>
<View style={styles.statItem}>
<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>
</View>
<View style={styles.statItem}>
<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>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('report.annualStats', { count: report.transactionCount, avg: report.averageDailyExpense })}
</Text>
</View>
{report.topCategories.length > 0 && (
<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 }]}>{t('report.topCategory')}</Text>
{report.topCategories.slice(0, 5).map(c => (
<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.fgSecondary }]}>{c.amount} ({c.percentage}%)</Text>
</View>
))}
</View>
)}
<MonthlyReport transactions={transactions.filter(t => t.date.startsWith(String(year)))} />
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 12 },
card: { gap: 8 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 },
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
});

View File

@ -0,0 +1,73 @@
/**
* plan.md5.2
*
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { getMonthGrid } from '../../domain/calendarGrid';
import { dailyExpenseMap } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
/** 根据强度返回颜色0-1。使用 opacity 而非字符串替换,兼容 hex 和 rgb。 */
function heatColor(intensity: number): { opacity: number } {
if (intensity <= 0) return { opacity: 0 };
return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
const { theme } = useTheme();
const t = useT();
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const maxDaily = Math.max(...dailyMap.values(), 1);
return (
<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 }]}>
{t('report.heatmap')}
</Text>
<View style={styles.weekdayRow}>
{WEEKDAYS.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1, fontSize: 10 }]}>
{d}
</Text>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const expense = dailyMap.get(cell.date) ?? 0;
const intensity = expense / maxDaily;
return (
<View key={cell.date} style={[
styles.cell,
{
backgroundColor: cell.isCurrentMonth && expense > 0
? theme.colors.financial.expense
: 'transparent',
borderColor: theme.colors.border,
opacity: cell.isCurrentMonth ? (expense > 0 ? heatColor(intensity).opacity : 1) : 0.3,
},
]}>
<Text style={[theme.typography.caption, { fontSize: 10, color: theme.colors.fgSecondary }]}>
{cell.day}
</Text>
</View>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 3 },
weekdayRow: { flexDirection: 'row', marginBottom: 2 },
weekRow: { flexDirection: 'row', gap: 2 },
cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: 3, borderWidth: 0.5 },
});

View File

@ -0,0 +1,56 @@
/**
* plan.md5.2
*
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByCategory } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByCategory(transactions);
const maxAmount = Math.max(...data.map(d => d.amount), 1);
return (
<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, fontFamily: theme.typography.h3.fontFamily }]}>
{t('report.categoryTitle')}
</Text>
<View style={styles.list}>
{data.map(d => (
<View key={d.category} style={styles.row}>
<Text
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>
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 15 }]}>
{t('common.noData')}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
list: { gap: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
barWrap: { width: 120, height: 8, borderRadius: 4, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 4 },
});

View File

@ -0,0 +1,45 @@
/**
* plan.md5.2
* RN
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByMonth } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByMonth(transactions);
const maxExpense = Math.max(...data.map(d => d.expense), 1);
return (
<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 }]}>{t('report.monthlyTitle')}</Text>
{data.map(d => (
<View key={d.month} style={styles.row}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text>
<View style={[styles.barWrap, { backgroundColor: theme.colors.progressBg }]}>
<View style={[styles.bar, {
width: `${(d.expense / maxExpense) * 100}%`,
backgroundColor: theme.colors.financial.expense,
}]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.financial.expense }]}>{d.expense.toFixed(0)}</Text>
</View>
))}
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barWrap: { flex: 1, height: 12, borderRadius: 6, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 6 },
});

View File

@ -0,0 +1,49 @@
/**
* plan.md5.2
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { calculateNetWorthTrend } from '../../domain/netWorth';
import type { Transaction } from '../../domain/types';
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
const { theme } = useTheme();
const t = useT();
const points = calculateNetWorthTrend(transactions, dates);
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
return (
<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 }]}>{t('report.netWorthTitle')}</Text>
<View style={styles.chart}>
{points.map(p => (
<View key={p.date} style={styles.col}>
<View style={styles.barWrap}>
<View style={[styles.bar, {
height: `${(Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100}%`,
backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
}]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 9 }]}>{p.date.slice(5)}</Text>
</View>
))}
</View>
{points.length > 0 && (
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('report.netWorthCurrent', { amount: points[points.length - 1].netWorth })}
</Text>
)}
{points.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 8 },
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
col: { flex: 1, alignItems: 'center', gap: 4 },
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
bar: { width: 12, borderRadius: 3 },
});

View File

@ -0,0 +1,189 @@
/**
* 线plan.md5.2
* react-native-svg 线
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import type { Transaction } from '../../domain/types';
import { groupByMonth } from '../../domain/chartStats';
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
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);
// 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 (
<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, fontFamily: theme.typography.h3.fontFamily }]}>
{t('report.trendTitle')}
</Text>
{data.length > 0 ? (
<View style={styles.chartWrapper}>
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
<Defs>
<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>
) : (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>
{t('common.noData')}
</Text>
)}
<View style={styles.legend}>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
<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 }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
chartWrapper: { marginTop: 6 },
xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 },
legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 },
});

127
src/domain/accountTree.ts Normal file
View File

@ -0,0 +1,127 @@
/**
* plan.md5.1
*/
import { addDecimals } from './decimal';
import { computeAccountBalances } from './ledger';
import type { Account, LedgerIndex, Transaction } from './types';
export interface AccountNode {
name: string;
fullName: string;
balance: string;
children: AccountNode[];
depth: number;
}
/** 构建账户树(按冒号分层)。 */
export function buildAccountTree(
accounts: Map<string, Account>,
transactions: Transaction[],
ledger?: LedgerIndex,
): AccountNode[] {
// 计算每个账户余额
let balances: Map<string, string>;
if (ledger) {
balances = computeAccountBalances(ledger);
} else {
balances = new Map<string, string>();
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount) {
balances.set(p.account, addDecimals([balances.get(p.account) ?? '0', p.amount]));
}
}
}
}
// 按顶层类型分组Assets/Liabilities/Income/Expenses/Equity
const roots: AccountNode[] = [];
const topLevel = new Set<string>();
for (const name of accounts.keys()) {
topLevel.add(name.split(':')[0]);
}
for (const root of [...topLevel].sort()) {
const rootAccounts = [...accounts.keys()].filter(a => a.startsWith(root + ':') || a === root);
roots.push(buildNode(root, root, rootAccounts, balances, 0));
}
return roots;
}
function buildNode(
segment: string,
fullName: string,
allAccounts: string[],
balances: Map<string, string>,
depth: number,
): AccountNode {
const directBalance = balances.get(fullName) ?? '0';
// 子账户 = 全名以 fullName: 开头的下一层
const childPrefix = fullName + ':';
const childSegments = new Set<string>();
for (const a of allAccounts) {
if (a.startsWith(childPrefix)) {
const rest = a.slice(childPrefix.length);
const nextSeg = rest.split(':')[0];
childSegments.add(nextSeg);
}
}
const children = [...childSegments].sort().map(seg =>
buildNode(seg, `${fullName}:${seg}`, allAccounts, balances, depth + 1)
);
return { name: segment, fullName, balance: directBalance, children, depth };
}
/** 计算账户总余额(含子账户)。 */
export function calculateAccountTotalBalance(node: AccountNode): string {
const childSum = node.children.reduce((sum, c) => {
return addDecimals([sum, calculateAccountTotalBalance(c)]);
}, '0');
return addDecimals([node.balance, childSum]);
}
/** 报表计算。 */
export interface LedgerReport {
income: string;
expense: string;
netIncome: string;
assets: string;
liabilities: string;
netWorth: string;
}
export function calculateReport(
ledger: LedgerIndex,
periodStart?: string,
periodEnd?: string,
): LedgerReport {
let tx = ledger.transactions;
if (periodStart && periodEnd) {
tx = tx.filter(t => {
const d = t.date.slice(0, 10);
return d >= periodStart && d <= periodEnd;
});
}
let income = '0', expense = '0', assets = '0', liabilities = '0';
for (const t of tx) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income = addDecimals([income, amt < 0 ? p.amount.slice(1) : p.amount]);
if (p.account.startsWith('Expenses') && amt > 0) expense = addDecimals([expense, p.amount]);
if (p.account.startsWith('Assets')) assets = addDecimals([assets, p.amount]);
if (p.account.startsWith('Liabilities')) liabilities = addDecimals([liabilities, p.amount]);
}
}
return {
income,
expense,
netIncome: addDecimals([income, `-${expense}`]),
assets,
liabilities: liabilities.startsWith('-') ? liabilities.slice(1) : liabilities,
netWorth: addDecimals([assets, liabilities.startsWith('-') ? liabilities.slice(1) : `-${liabilities}`]),
};
}

93
src/domain/adapters.ts Normal file
View File

@ -0,0 +1,93 @@
/**
* CSV plan.md2.5 CSV
*
*
* statements.ts parseCsv
*
*/
import { detectDirection } from './constants';
import { negateDecimal } from './decimal';
import type { ImportedEvent } from './types';
/** 通用 CSV 行解析(与 statements.ts 一致的列提取逻辑)。 */
function parseCsvRows(content: string): Record<string, string>[] {
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim());
const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l));
if (!headerLine) throw new Error('未找到账单表头');
const headers = headerLine.split(',').map(h => h.trim().replace(/^"|"$/g, ''));
return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''));
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? '']));
}).filter(r => Object.values(r).some(Boolean));
}
function pickFirst(row: Record<string, string>, keys: string[]): string {
return keys.map(k => row[k]).find(v => v && v.trim())?.trim() ?? '';
}
/** 招商银行 CSV 适配器。 */
export function parseCmbCsv(content: string): ImportedEvent[] {
const rows = parseCsvRows(content);
return rows.map((row, i) => {
let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要', '收/支']);
const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易日期', '日期', '时间']).replace(/[/.]/g, '-').slice(0, 10);
return {
id: `CMB:${pickFirst(row, ['交易序号', '凭证号']) || i}`,
occurredAt: date || new Date().toISOString().slice(0, 10),
amount,
currency: 'CNY',
direction,
counterparty: pickFirst(row, ['交易对方', '对方账户', '商户名称']),
memo: pickFirst(row, ['摘要', '交易描述', '备注']),
raw: row,
};
});
}
/** 工商银行 CSV 适配器。 */
export function parseIcbcCsv(content: string): ImportedEvent[] {
const rows = parseCsvRows(content);
return rows.map((row, i) => {
let amount = pickFirst(row, ['交易金额', '金额', '发生额']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要']);
const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易时间', '交易日期', '日期']).replace(/[/.]/g, '-').slice(0, 10);
return {
id: `ICBC:${pickFirst(row, ['流水号', '交易序号']) || i}`,
occurredAt: date || new Date().toISOString().slice(0, 10),
amount,
currency: 'CNY',
direction,
counterparty: pickFirst(row, ['对方户名', '对方账户', '交易对方']),
memo: pickFirst(row, ['摘要', '交易描述', '备注']),
raw: row,
};
});
}
/** 通用银行 CSV 适配器(未知银行格式)。 */
export function parseGenericBankCsv(content: string, source = 'bank'): ImportedEvent[] {
const rows = parseCsvRows(content);
return rows.map((row, i) => {
let amount = pickFirst(row, ['金额', '交易金额', '发生额', 'Amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const type = pickFirst(row, ['交易类型', '摘要', '收/支', 'Type']);
const direction = detectDirection(type + amount);
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
const date = pickFirst(row, ['交易日期', '日期', '时间', 'Date']).replace(/[/.]/g, '-').slice(0, 10);
return {
id: `${source}:${pickFirst(row, ['交易序号', '流水号', 'id']) || i}`,
occurredAt: date || new Date().toISOString().slice(0, 10),
amount,
currency: pickFirst(row, ['币种', 'Currency']) || 'CNY',
direction,
counterparty: pickFirst(row, ['交易对方', '对方账户', '商户', 'Counterparty']),
memo: pickFirst(row, ['摘要', '备注', '描述', 'Memo']),
raw: row,
};
});
}

194
src/domain/ai.ts Normal file
View File

@ -0,0 +1,194 @@
/**
* AI plan.mdPhase 8 AI +8.0 AI Provider
*
* AutoAccounting BaseOpenAIProvider8 Provider OpenAI + Gemini
* prompt /JSON //
* fetch 使
*/
import type { ImportedEvent, LedgerIndex } from './types';
import type { Category } from './categories';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface AiProviderConfig {
id: string;
name: string;
apiKey: string;
baseUrl: string;
model: string;
}
/** AI Provider 抽象。 */
export interface AiProvider {
readonly config: AiProviderConfig;
chat(messages: ChatMessage[]): Promise<string>;
}
/** 账单识别结果。 */
export interface AiBillResult {
type: 'income' | 'expense' | 'transfer';
amount: string;
currency: string;
counterparty: string;
narration: string;
time: string;
}
// ============ Provider 实现 ============
/** OpenAI 兼容协议DeepSeek/Kimi/智谱/OpenRouter/QWen/硅基流动/MiMo/ChatGPT 共用)。 */
export abstract class BaseOpenAIProvider implements AiProvider {
constructor(public readonly config: AiProviderConfig) {}
protected fetchImpl: (url: string, init: RequestInit) => Promise<Response> = fetch;
async chat(messages: ChatMessage[]): Promise<string> {
const response = await this.fetchImpl(`${this.config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
},
body: JSON.stringify({ model: this.config.model, messages, temperature: 0.1 }),
});
if (!response.ok) throw new Error(`AI 请求失败: ${response.status}`);
const data = await response.json() as { choices: { message: { content: string } }[] };
return data.choices[0].message.content;
}
}
// ============ 纯逻辑工具 ============
/** 去除思考链DeepSeek-R1 等模型的 <think>...</think>)。 */
export function removeThink(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
/** 隐私脱敏plan.md「8.0 隐私脱敏」)。 */
export function sanitizeForAi(text: string): string {
return text
.replace(/\b\d{16,19}\b/g, '[卡号]') // 银行卡号
.replace(/\b\d{17,18}(\d|X)\b/g, '[身份证]') // 身份证
.replace(/1[3-9]\d{9}/g, '[手机号]') // 手机号
.replace(/\b\d{6}\b/g, (match, offset, str) => {
const ctx = String(str).slice(Math.max(0, offset - 20), offset + 20);
return /验证码|验证|code/i.test(ctx) ? '[验证码]' : match;
});
}
/** 从 AI 响应中解析 JSON容错。 */
export function parseAiJson<T>(response: string): T | null {
const cleaned = removeThink(response);
// 尝试提取 ```json ... ``` 或裸 JSON
const jsonMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/) ?? cleaned.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (!jsonMatch) return null;
try {
return JSON.parse(jsonMatch[1].trim()) as T;
} catch {
return null;
}
}
/** 构建账单识别 promptplan.md「8.0 Prompt 工程」)。 */
export function buildBillRecognitionPrompt(ocrText: string, categories: Category[]): ChatMessage[] {
const categoryList = categories.map(c => `${c.name}(${c.type})`).join('、');
return [
{
role: 'system',
content: `你是一个账单识别助手。从用户输入中提取账单信息,返回 JSON 数组。
type(income/expense/transfer)amount()currency(CNY)counterpartynarration(15)time(ISO8601)
${categoryList} [] JSON`,
},
{ role: 'user', content: sanitizeForAi(ocrText) },
];
}
/** 自然语言记账plan.md「8.1」)。 */
export function buildNaturalLanguagePrompt(input: string): ChatMessage[] {
return [
{
role: 'system',
content: `你是一个记账助手。解析用户输入为交易 JSON{type,amount,currency,account,counterparty,narration,tags[]}。
JSON`,
},
{ role: 'user', content: sanitizeForAi(input) },
];
}
/** 月度总结 promptplan.md「8.3」)。 */
export function buildMonthlySummaryPrompt(stats: {
totalExpense: string; totalIncome: string; transactionCount: number;
topCategories: { category: string; amount: string }[];
}): ChatMessage[] {
return [
{
role: 'system',
content: '请根据数据生成简洁的中文月度财务总结,给出消费习惯分析和理财建议。',
},
{
role: 'user',
content: `总支出: ${stats.totalExpense}\n总收入: ${stats.totalIncome}\n交易笔数: ${stats.transactionCount}\n主要支出: ${stats.topCategories.map(c => `${c.category}:${c.amount}`).join(', ')}`,
},
];
}
/** 分类识别 promptplan.md「8.4」)。 */
export function buildCategoryRecognitionPrompt(event: ImportedEvent, categories: Category[]): ChatMessage[] {
const categoryList = categories.map(c => `${c.name}(${c.type})`).join('、');
return [
{ role: 'system', content: `判断交易最合适的分类。可用分类:${categoryList}。只返回分类名称。` },
{ role: 'user', content: sanitizeForAi(`${event.counterparty} ${event.amount}${event.memo}`) },
];
}
/** 资产映射 promptplan.md「8.5」)。 */
export function buildAssetMappingPrompt(counterparty: string, accounts: string[]): ChatMessage[] {
return [
{ role: 'system', content: `判断最合适的资产账户。可用账户:${accounts.join('、')}。只返回账户名称。` },
{ role: 'user', content: sanitizeForAi(`对方: ${counterparty}`) },
];
}
// ============ 高阶函数(注入 provider ============
/** 自然语言记账(注入 provider。 */
export async function processNaturalLanguage(
input: string,
provider: AiProvider,
): Promise<{ type: 'draft'; draft: AiBillResult } | { type: 'text'; text: string }> {
const messages = buildNaturalLanguagePrompt(input);
const response = await provider.chat(messages);
const parsed = parseAiJson<AiBillResult>(response);
if (parsed && parsed.amount && parsed.type) {
return { type: 'draft', draft: parsed };
}
return { type: 'text', text: removeThink(response) };
}
/** AI 分类识别(注入 provider。 */
export async function aiRecognizeCategory(
event: ImportedEvent,
categories: Category[],
provider: AiProvider,
): Promise<Category | null> {
const response = await provider.chat(buildCategoryRecognitionPrompt(event, categories));
const name = removeThink(response).trim();
return categories.find(c => c.name === name) ?? null;
}
/** AI 资产映射(注入 provider。 */
export async function aiMapAsset(
counterparty: string,
ledger: LedgerIndex,
provider: AiProvider,
): Promise<string | null> {
const accounts = [...ledger.accounts.keys()].filter(a =>
!a.startsWith('Income') && !a.startsWith('Expenses') && !a.startsWith('Equity')
);
const response = await provider.chat(buildAssetMappingPrompt(counterparty, accounts));
const name = removeThink(response).trim();
return accounts.find(a => a === name) ?? null;
}

126
src/domain/annualReport.ts Normal file
View File

@ -0,0 +1,126 @@
/**
* plan.md5.2
*/
import { addDecimals } from './decimal';
import type { Transaction } from './types';
export interface AnnualReport {
year: number;
totalIncome: string;
totalExpense: string;
netIncome: string;
topCategories: { category: string; amount: string; percentage: number }[];
monthlyTrend: { month: string; income: string; expense: string }[];
dayOfWeekBreakdown: { day: string; total: string }[];
largestTransaction: Transaction | null;
transactionCount: number;
averageDailyExpense: string;
}
const DAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export function generateAnnualReport(
transactions: Transaction[],
year: number,
): AnnualReport {
const yearTx = transactions.filter(t => t.date.startsWith(String(year)));
// 单次遍历:一次性计算所有聚合数据
const incomeAmounts: string[] = [];
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 {
year,
totalIncome,
totalExpense,
netIncome,
topCategories,
monthlyTrend,
dayOfWeekBreakdown,
largestTransaction: largest,
transactionCount: yearTx.length,
averageDailyExpense,
};
}

188
src/domain/billPipeline.ts Normal file
View File

@ -0,0 +1,188 @@
import { classifyWithCategories } from './rules';
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup';
import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
import { compareDecimals, negateDecimal } from './decimal';
import type { Category } from './categories';
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
/**
* AutoAccounting BillService.analyze + deduplicationMutex
*
* /CSV/OCR///
* plan.md2.8 6
*
*
* 1. Income+Expend Transfer
* 2. +
* 3.
* 4. + Uncategorized
* 5. 稿 MobileBeanStore
*/
export interface PipelineContext {
ledger: LedgerIndex;
rules: Rule[];
categories: Category[];
/** 已入账的历史交易(用于跨会话去重)。 */
history: Transaction[];
dedupConfig?: DedupConfig;
transferConfig?: TransferConfig;
}
export interface ProcessedDraft {
draft: TransactionDraft;
/** 来源事件 id用于去重标记。 */
sourceEventIds: string[];
/** 是否为转账合并。 */
isTransfer: boolean;
/** 分类降级原因(若有)。 */
categoryFallbackReason?: string;
}
export interface PipelineResult {
/** 待入库的草稿。 */
drafts: ProcessedDraft[];
/** 被去重丢弃的事件。 */
duplicates: ImportedEvent[];
/** 转账识别统计。 */
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 {
/** 串行化锁:整个识别→去重→分类流程互斥,保证并发正确性。 */
private mutex: Promise<unknown> = Promise.resolve();
/**
*
* /
*/
process(events: ImportedEvent[], ctx: PipelineContext): Promise<PipelineResult> {
return this.enqueue(() => this.run(events, ctx));
}
/** 实际处理逻辑(无锁,由 process 保证串行)。 */
private async run(events: ImportedEvent[], ctx: PipelineContext): Promise<PipelineResult> {
if (events.length === 0) {
return { drafts: [], duplicates: [], transferCount: 0 };
}
// 1. 转账识别(先于去重)
const { transfers, remaining: afterTransfer } = recognizeTransfers(
events,
ctx.transferConfig,
);
// 2. 多通道去重(剩余事件之间)
const { accepted: afterBatchDedup, duplicates: batchDups } = batchDedup(
afterTransfer,
ctx.dedupConfig,
);
// 3. 与历史交易去重(避免重复入账)
const { accepted: afterHistoryDedup, duplicates: historyDups } = dedupAgainstHistory(
afterBatchDedup,
ctx.history,
ctx.dedupConfig,
);
// 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories
const drafts: ProcessedDraft[] = [];
const transferDups: ImportedEvent[] = [];
// 4a. 转账草稿(需与历史去重)
for (const transfer of transfers) {
if (!isTransferInHistory(transfer, ctx.history)) {
drafts.push({
draft: transfer.draft,
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. 非转账事件
for (const event of afterHistoryDedup) {
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({
draft: result.draft,
sourceEventIds: [event.id],
isTransfer: false,
categoryFallbackReason: result.categoryFallbackReason,
});
}
return {
drafts,
duplicates: [...batchDups, ...historyDups, ...transferDups],
transferCount: transfers.length,
};
}
/** 串行化执行:保证 process 互斥。 */
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const run = this.mutex.then(task, task);
this.mutex = run.then(() => undefined, () => undefined);
return run;
}
}

97
src/domain/budgets.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* plan.md5.5
* 1 .bean/
*/
import { addDecimals, subtractDecimals } from './decimal';
import type { Transaction } from './types';
export interface Budget {
id: string;
name: string;
amount: string;
period: 'monthly' | 'weekly' | 'yearly';
/** 关联的分类账户undefined = 总预算)。 */
categoryAccount?: string;
startDate: string;
}
export interface BudgetProgress {
budget: Budget;
spent: string;
remaining: string;
/** 使用百分比0-100+)。 */
percentage: number;
/** 是否超支。 */
overBudget: boolean;
}
/** 计算单个预算在指定交易集合上的进度。 */
export function calculateBudgetProgress(
budget: Budget,
transactions: Transaction[],
currentDate: string,
): BudgetProgress {
const periodTx = filterByPeriod(transactions, budget.period, currentDate, budget.categoryAccount);
const spent = periodTx.reduce((sum, t) => {
const expense = t.postings
.filter(p => p.amount && p.amount.startsWith('-'))
.reduce((s, p) => addDecimals([s, p.amount!.replace('-', '')]), '0');
return addDecimals([sum, expense]);
}, '0');
const remaining = subtractDecimals(budget.amount, spent);
const percentage = parseFloat(budget.amount) > 0 ? (parseFloat(spent) / parseFloat(budget.amount)) * 100 : 0;
return {
budget,
spent,
remaining,
percentage: Math.round(percentage * 100) / 100,
overBudget: parseFloat(remaining) < 0,
};
}
/** 按周期 + 分类过滤交易。 */
function filterByPeriod(
transactions: Transaction[],
period: Budget['period'],
currentDate: string,
categoryAccount?: string,
): Transaction[] {
const range = getPeriodRange(period, currentDate);
return transactions.filter(t => {
const date = t.date.slice(0, 10);
if (date < range.start || date > range.end) return false;
if (categoryAccount) {
return t.postings.some(p => p.account === categoryAccount || p.account.startsWith(categoryAccount));
}
return true;
});
}
/** 计算周期的起止日期。 */
export function getPeriodRange(period: Budget['period'], currentDate: string): { start: string; end: string } {
const d = new Date(currentDate);
const year = d.getFullYear();
const month = d.getMonth();
switch (period) {
case 'weekly': {
const day = d.getDay() || 7; // 周日=7
const start = new Date(d);
start.setDate(d.getDate() - day + 1);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { start: toDateString(start), end: toDateString(end) };
}
case 'yearly':
return { start: `${year}-01-01`, end: `${year}-12-31` };
case 'monthly':
default:
return { start: `${year}-${String(month + 1).padStart(2, '0')}-01`, end: `${year}-${String(month + 1).padStart(2, '0')}-31` };
}
}
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

View File

@ -0,0 +1,78 @@
/**
* plan.md5.3
* CalendarView 使 react-native
*/
import type { Transaction } from './types';
export interface DayCell {
day: number;
date: string; // YYYY-MM-DD
isCurrentMonth: boolean;
hasIncome: boolean;
hasExpense: boolean;
count: number;
}
/** 获取某月日历网格含上下月填充7 列)。 */
export function getMonthGrid(year: number, month: number, transactions: Transaction[]): DayCell[][] {
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
const daysInMonth = lastDay.getDate();
const startWeekday = firstDay.getDay(); // 0=周日
// 按日期索引交易
const txByDate = new Map<string, Transaction[]>();
for (const t of transactions) {
const date = t.date.slice(0, 10);
if (!txByDate.has(date)) txByDate.set(date, []);
txByDate.get(date)!.push(t);
}
const cells: DayCell[] = [];
// 上月填充
const prevMonthDays = new Date(year, month - 1, 0).getDate();
for (let i = startWeekday - 1; i >= 0; i--) {
const day = prevMonthDays - i;
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
cells.push(buildCell(prevYear, prevMonth, day, txByDate, false));
}
// 当月
for (let day = 1; day <= daysInMonth; day++) {
cells.push(buildCell(year, month, day, txByDate, true));
}
// 下月填充
const totalCells = Math.ceil(cells.length / 7) * 7;
let nextDay = 1;
const nextMonth = month === 12 ? 1 : month + 1;
const nextYear = month === 12 ? year + 1 : year;
while (cells.length < totalCells) {
cells.push(buildCell(nextYear, nextMonth, nextDay, txByDate, false));
nextDay++;
}
// 切分为周
const weeks: DayCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return weeks;
}
function buildCell(
year: number, month: number, day: number,
txByDate: Map<string, Transaction[]>,
isCurrentMonth: boolean,
): DayCell {
const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const tx = txByDate.get(date) ?? [];
return {
day,
date,
isCurrentMonth,
hasIncome: tx.some(t => t.postings.some(p => p.account.startsWith('Income'))),
hasExpense: tx.some(t => t.postings.some(p => p.account.startsWith('Expenses'))),
count: tx.length,
};
}

101
src/domain/categories.ts Normal file
View File

@ -0,0 +1,101 @@
import type { LedgerIndex } from './types';
/**
*
*
* plan.md 1
* - name/keywords/icon/color linkedAccount Beancount
* - Beancount .bean linkedAccount posting account
* - BeeCount CategoryMatcher
*/
export interface Category {
id: string;
name: string;
parentId?: string; // 层级UI 展示用,不映射到 .bean
type: 'income' | 'expense';
linkedAccount: string; // ★ 映射到 Beancount 账户,记账时作为 posting account
icon?: string; // UI 增强(仅本地)
color?: string; // UI 增强(仅本地)
keywords: string[]; // 自动匹配关键词(仅本地匹配逻辑)
}
/** 分类匹配结果。 */
export interface CategoryMatch {
category: Category | null;
/** 匹配方式,便于调试/置信度判断。 */
matchedBy: 'exact' | 'keyword' | 'fuzzy' | 'fallback' | 'none';
}
/**
* "其他"
* @param text /
* @param categories
*/
export function matchCategory(text: string, categories: Category[]): CategoryMatch {
const normalized = text.trim();
if (!normalized) return { category: null, matchedBy: 'none' };
// 1. 精确匹配(分类名等于文本)
const exact = categories.find(c => c.name === normalized);
if (exact) return { category: exact, matchedBy: 'exact' };
// 2. 关键词匹配(任一关键词被文本包含)
const byKeyword = categories.find(c =>
c.keywords.some(k => k && normalized.includes(k))
);
if (byKeyword) return { category: byKeyword, matchedBy: 'keyword' };
// 3. 模糊匹配(包含关系,双向)
const fuzzy = categories.find(c =>
normalized.includes(c.name) || c.name.includes(normalized)
);
if (fuzzy) return { category: fuzzy, matchedBy: 'fuzzy' };
// 4. 兜底"其他"
const fallback = categories.find(c => c.name === '其他' || c.name === 'Other') ?? null;
return { category: fallback, matchedBy: fallback ? 'fallback' : 'none' };
}
/**
* Beancount
*
* Uncategorized
* valid=false open
*/
export function resolveCategoryAccount(
category: Category | null,
type: 'income' | 'expense',
ledger: LedgerIndex,
): { account: string; valid: boolean; fallbackReason?: string } {
if (!category) {
// 未匹配分类时使用兜底账户
const fallback = type === 'income' ? 'Income:未分类' : 'Expenses:未分类';
return {
account: fallback,
valid: ledger.accounts.has(fallback),
fallbackReason: '未匹配到分类',
};
}
const exists = ledger.accounts.has(category.linkedAccount);
return {
account: category.linkedAccount,
valid: exists,
fallbackReason: exists ? undefined : `账户 ${category.linkedAccount} 尚未开户,将在导入时自动创建`,
};
}
/** 根据类型筛选分类(支出/收入)。 */
export function filterByType(categories: Category[], type: 'income' | 'expense'): Category[] {
return categories.filter(c => c.type === type);
}
/** 获取子分类(层级)。 */
export function getSubCategories(categories: Category[], parentId: string): Category[] {
return categories.filter(c => c.parentId === parentId);
}
/** 获取顶层分类(无 parentId。 */
export function getTopLevelCategories(categories: Category[]): Category[] {
return categories.filter(c => !c.parentId);
}

View File

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

63
src/domain/chartStats.ts Normal file
View File

@ -0,0 +1,63 @@
/**
* plan.md5.2
* components/charts/ 使 react-native
*/
import type { Transaction } from './types';
/** 按月分组统计。 */
export function groupByMonth(transactions: Transaction[]): { month: string; income: number; expense: number }[] {
const byMonth = new Map<string, { income: number; expense: number }>();
for (const t of transactions) {
const month = t.date.slice(0, 7);
const cur = byMonth.get(month) ?? { income: 0, expense: 0 };
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) cur.income += -amt;
if (p.account.startsWith('Expenses')) cur.expense += amt;
}
byMonth.set(month, cur);
}
return [...byMonth.entries()]
.map(([month, v]) => ({ month, ...v }))
.sort((a, b) => a.month.localeCompare(b.month));
}
/** 按分类统计支出。 */
export function groupByCategory(transactions: Transaction[]): { category: string; amount: number; percentage: number }[] {
const byCategory = new Map<string, number>();
let total = 0;
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses')) {
const amt = parseFloat(p.amount);
byCategory.set(p.account, (byCategory.get(p.account) ?? 0) + amt);
total += amt;
}
}
}
return [...byCategory.entries()]
.map(([category, amount]) => ({
category,
amount,
percentage: total > 0 ? Math.round((amount / total) * 100) : 0,
}))
.sort((a, b) => b.amount - a.amount);
}
/** 按日期统计支出。 */
export function dailyExpenseMap(transactions: Transaction[]): Map<string, number> {
const byDate = new Map<string, number>();
for (const t of transactions) {
const date = t.date.slice(0, 10);
let expense = byDate.get(date) ?? 0;
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
expense += parseFloat(p.amount);
}
}
if (expense > 0) byDate.set(date, expense);
}
return byDate;
}

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

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

75
src/domain/creditCards.ts Normal file
View File

@ -0,0 +1,75 @@
/**
* plan.md5.4
* .bean Liabilities:CreditCard:CMB
* UI // .bean
*/
import { addDecimals } from './decimal';
import type { Transaction } from './types';
export interface CreditCard {
id: string;
name: string;
lastFour: string;
bankName: string;
billingDay: number; // 账单日1-28
paymentDay: number; // 还款日1-28
creditLimit: string;
currency: string;
/** 关联的 Beancount 账户。 */
linkedAccount: string;
}
export interface BillingPeriod {
periodStart: string;
periodEnd: string;
dueDate: string;
}
/** 计算信用卡账单周期。 */
export function calculateBillingPeriod(card: CreditCard, currentDate: Date): BillingPeriod {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 账单周期:上月账单日 ~ 本月账单日
const periodStart = new Date(year, month - 1, card.billingDay);
const periodEnd = new Date(year, month, card.billingDay);
const dueDate = new Date(year, month, card.paymentDay);
return {
periodStart: toDateString(periodStart),
periodEnd: toDateString(periodEnd),
dueDate: toDateString(dueDate),
};
}
/** 计算本期应还金额。 */
export function calculateStatementAmount(
transactions: Transaction[],
card: CreditCard,
period: BillingPeriod,
): string {
const periodTx = transactions.filter(t => {
const date = t.date.slice(0, 10);
return date >= period.periodStart && date < period.periodEnd
&& t.postings.some(p => p.account.includes(card.linkedAccount));
});
return periodTx.reduce((sum, t) => {
const expense = t.postings
.filter(p => p.amount && p.amount.startsWith('-'))
.reduce((s, p) => addDecimals([s, p.amount!.replace('-', '')]), '0');
return addDecimals([sum, expense]);
}, '0');
}
/** 计算可用额度(额度 - 当前欠款)。 */
export function calculateAvailableCredit(
card: CreditCard,
currentBalance: string,
): string {
// balance 为欠款(正数),可用 = 额度 - 欠款
const available = parseFloat(card.creditLimit) - parseFloat(currentBalance.replace('-', ''));
return available.toFixed(2);
}
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

94
src/domain/currency.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* plan.md5.8
* BeeCount v30 nativeAmount +
*/
import { compareDecimals } from './decimal';
export interface ExchangeRate {
from: string;
to: string;
rate: string;
date: string;
}
/** 手动汇率覆盖(用户优先)。 */
export interface ExchangeRateOverride {
from: string;
to: string;
rate: string;
enabled: boolean;
}
/** 交易级多币种快照。 */
export interface TransactionCurrency {
currencyCode: string; // 交易货币(如 USD
nativeAmount: string; // 原始金额
exchangeRate: string; // 记账时的汇率快照
baseCurrency: string; // 基础货币(如 CNY
}
/** 货币转换。 */
export function convertCurrency(
amount: string,
from: string,
to: string,
rates: ExchangeRate[],
): string {
if (from === to) return amount;
const rate = rates.find(r => r.from === from && r.to === to);
if (!rate) throw new Error(`${from}${to} 的汇率`);
return (parseFloat(amount) * parseFloat(rate.rate)).toFixed(2);
}
/**
*
*/
export function getEffectiveRate(
from: string,
to: string,
autoRates: ExchangeRate[],
overrides: ExchangeRateOverride[],
): ExchangeRate | null {
// 手动覆盖优先
const override = overrides.find(o => o.enabled && o.from === from && o.to === to);
if (override) {
return { from, to, rate: override.rate, date: new Date().toISOString().slice(0, 10) };
}
return autoRates.find(r => r.from === from && r.to === to) ?? null;
}
/** 构造交易级多币种快照(记账时调用)。 */
export function buildTransactionCurrency(
nativeAmount: string,
currencyCode: string,
baseCurrency: string,
rates: ExchangeRate[],
): TransactionCurrency {
if (currencyCode === baseCurrency) {
return { currencyCode, nativeAmount, exchangeRate: '1', baseCurrency };
}
const rate = rates.find(r => r.from === currencyCode && r.to === baseCurrency)
?? rates.find(r => r.from === baseCurrency && r.to === currencyCode);
if (!rate) throw new Error(`${currencyCode}/${baseCurrency} 汇率`);
// 正向汇率直接用,反向取倒数
const effectiveRate = rate.from === currencyCode ? rate.rate : (1 / parseFloat(rate.rate)).toFixed(6);
return { currencyCode, nativeAmount, exchangeRate: effectiveRate, baseCurrency };
}
/** 校验汇率列表完整性(基础货币对所有其他货币都有汇率)。 */
export function validateRateCoverage(
baseCurrency: string,
currencies: string[],
rates: ExchangeRate[],
): string[] {
const missing: string[] = [];
for (const cur of currencies) {
if (cur === baseCurrency) continue;
const has = rates.some(r =>
(r.from === cur && r.to === baseCurrency) || (r.from === baseCurrency && r.to === cur)
);
if (!has) missing.push(cur);
}
return missing;
}

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