Compare commits
2 Commits
167adfca62
...
76a5853ab6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a5853ab6 | ||
|
|
f6437b83fe |
18
.gitignore
vendored
18
.gitignore
vendored
@ -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
46
App.tsx
@ -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 } });
|
||||
30
app.json
30
app.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
206
design-system/beancount-mobile/MASTER.md
Normal file
206
design-system/beancount-mobile/MASTER.md
Normal file
@ -0,0 +1,206 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** beancount-mobile
|
||||
**Generated:** 2026-07-16 16:32:57
|
||||
**Category:** Personal Finance Tracker
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#1E40AF` | `--color-primary` |
|
||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||
| Secondary | `#3B82F6` | `--color-secondary` |
|
||||
| Accent/CTA | `#059669` | `--color-accent` |
|
||||
| Background | `#0F172A` | `--color-background` |
|
||||
| Foreground | `#FFFFFF` | `--color-foreground` |
|
||||
| Muted | `#101A34` | `--color-muted` |
|
||||
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
|
||||
| Destructive | `#DC2626` | `--color-destructive` |
|
||||
| Ring | `#1E40AF` | `--color-ring` |
|
||||
|
||||
**Color Notes:** Trust blue + profit green on dark
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Caveat
|
||||
- **Body Font:** Quicksand
|
||||
- **Mood:** handwritten, personal, friendly, casual, warm, charming
|
||||
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #1E40AF;
|
||||
border: 2px solid #1E40AF;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #0F172A;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1E40AF;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #1E40AF20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Dark Mode (OLED)
|
||||
|
||||
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
|
||||
|
||||
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
|
||||
|
||||
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Interactive Product Demo
|
||||
|
||||
- **CTA Placement:** Above fold
|
||||
- **Section Order:** Hero > Features > CTA
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Pure white backgrounds
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
100
docs/android-build-guide.md
Normal file
100
docs/android-build-guide.md
Normal 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
3015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
458
plan-ocr.md
458
plan-ocr.md
@ -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.kt(React 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 能力。
|
||||
233
plugins/accessibility/android/AccessibilityBridgeModule.kt
Normal file
233
plugins/accessibility/android/AccessibilityBridgeModule.kt
Normal file
@ -0,0 +1,233 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
|
||||
/**
|
||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||
*
|
||||
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
||||
* 把 JS 调用委托给服务实例。
|
||||
*
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(AccessibilityBridgePackage())。
|
||||
*/
|
||||
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
init {
|
||||
ReactContextHolder.context = reactContext
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
if (ReactContextHolder.context === reactContext) {
|
||||
ReactContextHolder.context = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getName() = "AccessibilityBridge"
|
||||
|
||||
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
||||
@ReactMethod
|
||||
fun isServiceRunning(promise: Promise) {
|
||||
promise.resolve(BillingAccessibilityService.instance != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住当前页面:把当前顶部 App 的 pkg|activity 加入白名单,
|
||||
* 之后该页面内容变化时自动截图 → OCR。
|
||||
*/
|
||||
@ReactMethod
|
||||
fun rememberCurrentPage(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
val pkg = service.getTopPackage()
|
||||
if (pkg == null) {
|
||||
promise.reject("NO_TOP_PACKAGE", "当前没有检测到前台 App")
|
||||
return
|
||||
}
|
||||
service.rememberCurrentPage()
|
||||
val result = WritableNativeMap()
|
||||
result.putString("package", pkg)
|
||||
result.putString("activity", service.getTopActivity() ?: "")
|
||||
result.putString("signature", "$pkg|${service.getTopActivity() ?: ""}")
|
||||
promise.resolve(result)
|
||||
}
|
||||
|
||||
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
||||
@ReactMethod
|
||||
fun triggerManualOcr(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
try {
|
||||
service.triggerManualOcr()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("OCR_TRIGGER_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有已记住的页面签名列表。 */
|
||||
@ReactMethod
|
||||
fun getPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val sigsSet = if (service != null) {
|
||||
service.getPageSignatures()
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
val arr = WritableNativeArray()
|
||||
for (sig in sigsSet) {
|
||||
val parts = sig.split("|", limit = 2)
|
||||
val map = WritableNativeMap()
|
||||
map.putString("signature", sig)
|
||||
map.putString("package", parts.getOrNull(0) ?: "")
|
||||
map.putString("activity", parts.getOrNull(1) ?: "")
|
||||
arr.pushMap(map)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
}
|
||||
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
@ReactMethod
|
||||
fun clearPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.clearPageSignatures()
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
prefs.edit().putStringSet("page_signatures", emptySet()).apply()
|
||||
} catch (e: Exception) {
|
||||
promise.reject("CLEAR_PREFS_FAIL", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** 删除指定页面签名。 */
|
||||
@ReactMethod
|
||||
fun removePageSignature(signature: String, promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.removePageSignature(signature)
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val saved = prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
|
||||
val mutable = HashSet(saved)
|
||||
if (mutable.remove(signature)) {
|
||||
prefs.edit().putStringSet("page_signatures", mutable).apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
promise.reject("REMOVE_PREFS_FAIL", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** 获取支付 App 白名单(供 JS 端展示)。 */
|
||||
@ReactMethod
|
||||
fun getPaymentPackages(promise: Promise) {
|
||||
val arr = WritableNativeArray()
|
||||
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
|
||||
arr.pushString(pkg)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
}
|
||||
|
||||
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
||||
@ReactMethod
|
||||
fun getTopApp(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
val map = WritableNativeMap()
|
||||
map.putString("package", service.getTopPackage() ?: "")
|
||||
map.putString("activity", service.getTopActivity() ?: "")
|
||||
promise.resolve(map)
|
||||
}
|
||||
|
||||
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
||||
@ReactMethod
|
||||
fun bringAppToForeground(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
try {
|
||||
service.bringAppToForeground()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FAIL", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
|
||||
@ReactMethod
|
||||
fun showFloatingBill(
|
||||
amount: String,
|
||||
merchant: String,
|
||||
time: String,
|
||||
packageName: String,
|
||||
categories: ReadableArray,
|
||||
accounts: ReadableArray,
|
||||
direction: String,
|
||||
draftId: String,
|
||||
promise: Promise
|
||||
) {
|
||||
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
|
||||
if (context == null) {
|
||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||
return
|
||||
}
|
||||
|
||||
val categoryList = mutableListOf<Map<String, String>>()
|
||||
for (i in 0 until categories.size()) {
|
||||
val map = categories.getMap(i)
|
||||
categoryList.add(mapOf(
|
||||
"id" to (map?.getString("id") ?: ""),
|
||||
"name" to (map?.getString("name") ?: ""),
|
||||
"account" to (map?.getString("account") ?: ""),
|
||||
"type" to (map?.getString("type") ?: "")
|
||||
))
|
||||
}
|
||||
|
||||
val accountList = mutableListOf<String>()
|
||||
for (i in 0 until accounts.size()) {
|
||||
accountList.add(accounts.getString(i) ?: "")
|
||||
}
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
|
||||
floatingView.show()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FAIL", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
plugins/accessibility/android/AccessibilityBridgePackage.kt
Normal file
20
plugins/accessibility/android/AccessibilityBridgePackage.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* ReactPackage 注册 AccessibilityBridgeModule。
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(AccessibilityBridgePackage())。
|
||||
*/
|
||||
class AccessibilityBridgePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(AccessibilityBridgeModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
578
plugins/accessibility/android/BillingAccessibilityService.kt
Normal file
578
plugins/accessibility/android/BillingAccessibilityService.kt
Normal file
@ -0,0 +1,578 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
/**
|
||||
* 无障碍账单识别服务(plan.md「3.6 无障碍服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 SelectToSpeakService:
|
||||
* - 监听支付 App 的页面切换(TYPE_WINDOW_STATE_CHANGED)
|
||||
* - 页面签名匹配时自动截图 → OCR → 推送到 JS 层
|
||||
* - 横屏免打扰(游戏/视频时不触发)
|
||||
* - ocrDoing 守卫(防止重复触发)
|
||||
*
|
||||
* 伪装说明:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||
* 注意:本服务类名在 manifest 中声明为 BillingAccessibilityService,
|
||||
* 伪装为系统服务(包名 com.beancount.mobile.accessibility)仅在侧载版本保留。
|
||||
*
|
||||
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
||||
*
|
||||
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
||||
* 修改时需两边同时更新。
|
||||
*/
|
||||
class BillingAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingAccessibility"
|
||||
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||||
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
||||
|
||||
@Volatile
|
||||
var instance: BillingAccessibilityService? = null
|
||||
private set
|
||||
|
||||
/** 支付 App 白名单。 */
|
||||
val PAYMENT_PACKAGES = setOf(
|
||||
"com.eg.android.AlipayGphone", // 支付宝
|
||||
"com.tencent.mm", // 微信
|
||||
"com.unionpay", // 银联
|
||||
"com.cmbchina", // 招商银行
|
||||
"com.icbc", // 工商银行
|
||||
"com.chinamworld.main", // 中国银行
|
||||
"com.ccbrcb", // 建设银行
|
||||
"com.bankcomm.Bankcomm", // 交通银行
|
||||
"com.tencent.mobileqq", // 手机QQ
|
||||
"com.tencent.tim" // TIM
|
||||
)
|
||||
|
||||
/** 厂商桌面包名(过滤,不触发 OCR)。 */
|
||||
private val LAUNCHER_PACKAGES = setOf(
|
||||
"com.google.android.apps.nexuslauncher",
|
||||
"com.sec.android.app.launcher",
|
||||
"com.miui.home",
|
||||
"com.huawei.android.launcher",
|
||||
"com.oppo.launcher",
|
||||
"com.bbk.launcher2",
|
||||
"com.android.launcher3",
|
||||
)
|
||||
|
||||
/** OCR 触发防抖:500ms 内的内容变化合并为一次。 */
|
||||
private const val CONTENT_CHANGE_DEBOUNCE_MS = 500L
|
||||
}
|
||||
|
||||
private var ocrDoing = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val debounceRunnable = Runnable { processContentChange() }
|
||||
@Volatile private var topPackage: String? = null
|
||||
@Volatile private var topActivity: String? = null
|
||||
/** 已记住的页面签名(pkg|activity),匹配时自动触发 OCR。持久化到 SharedPreferences。 */
|
||||
private val pageSignatures = java.util.concurrent.CopyOnWriteArraySet<String>()
|
||||
private var floatingHelper: FloatingHelper? = null
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
Log.i(TAG, "无障碍账单识别服务已连接")
|
||||
loadPageSignatures()
|
||||
configureService()
|
||||
}
|
||||
|
||||
/** 动态配置服务能力(截图 + 页面变化监听)。 */
|
||||
private fun configureService() {
|
||||
val info = AccessibilityServiceInfo().apply {
|
||||
eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
|
||||
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
|
||||
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
|
||||
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
|
||||
AccessibilityServiceInfo.DEFAULT
|
||||
notificationTimeout = 100L
|
||||
}
|
||||
serviceInfo = info
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (ocrDoing) return // 处理中,跳过
|
||||
val eventPackage = event?.packageName?.toString() ?: return
|
||||
|
||||
when (event.eventType) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val activityName = event.className?.toString() ?: ""
|
||||
if (filterPackage(eventPackage, activityName)) return
|
||||
topPackage = eventPackage
|
||||
topActivity = activityName
|
||||
Log.d(TAG, "页面切换: $eventPackage / $activityName")
|
||||
|
||||
// 更新悬浮窗助手状态
|
||||
updateFloatingHelperVisibility(eventPackage)
|
||||
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
scheduleContentChange()
|
||||
}
|
||||
|
||||
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
|
||||
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
|
||||
handler.postDelayed({
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("package", eventPackage)
|
||||
putString("activity", activityName)
|
||||
val array = WritableNativeArray()
|
||||
for (t in texts) {
|
||||
array.pushString(t)
|
||||
}
|
||||
putArray("texts", array)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingDebugNodes", map)
|
||||
} catch (e: Exception) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
scheduleContentChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFloatingHelperVisibility(pkg: String?) {
|
||||
handler.post {
|
||||
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
|
||||
if (floatingHelper == null) {
|
||||
floatingHelper = FloatingHelper(this)
|
||||
floatingHelper?.show()
|
||||
}
|
||||
} else {
|
||||
floatingHelper?.dismiss()
|
||||
floatingHelper = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 防抖:500ms 内的多次内容变化合并。 */
|
||||
private fun scheduleContentChange() {
|
||||
handler.removeCallbacks(debounceRunnable)
|
||||
handler.postDelayed(debounceRunnable, CONTENT_CHANGE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JS(JS 控制是否 OCR 兜底)。 */
|
||||
private fun processContentChange() {
|
||||
if (ocrDoing) return
|
||||
val pkg = topPackage ?: return
|
||||
|
||||
// 横屏免打扰(plan.md「3.10」)
|
||||
if (isLandscape()) {
|
||||
Log.d(TAG, "横屏免打扰,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
// 页面签名匹配(若已记住页面则触发)
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
if (!pageSignatures.contains(sigKey)) {
|
||||
return // 未记住的页面不自动触发
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("package", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sigKey)
|
||||
val array = WritableNativeArray()
|
||||
for (t in texts) {
|
||||
array.pushString(t)
|
||||
}
|
||||
putArray("texts", array)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingDebugNodes", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送内容变化节点文本失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 截图并触发 OCR 处理(Android 11+)。 */
|
||||
private fun takeScreenshotAndProcess(packageName: String) {
|
||||
if (ocrDoing) return
|
||||
ocrDoing = true
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
ocrDoing = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
takeScreenshot(
|
||||
Display.DEFAULT_DISPLAY,
|
||||
mainExecutor,
|
||||
object : TakeScreenshotCallback {
|
||||
override fun onSuccess(result: ScreenshotResult) {
|
||||
try {
|
||||
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||
result.hardwareBuffer.close()
|
||||
if (bitmap != null) {
|
||||
val base64 = bitmapToBase64(bitmap)
|
||||
bitmap.recycle()
|
||||
// 推送到 JS 层(NativeEventEmitter)
|
||||
sendScreenshotEvent(base64, packageName)
|
||||
}
|
||||
} finally {
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
override fun onFailure(errorCode: Int) {
|
||||
Log.e(TAG, "截图失败: errorCode=$errorCode")
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "调用 takeScreenshot 失败: ${e.message}", e)
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把截图以 base64 推送到 JS 层(由 JS 端 OcrProcessor 处理)。 */
|
||||
private fun sendScreenshotEvent(base64: String, packageName: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingScreenshot", writableMapOf(
|
||||
"base64" to base64,
|
||||
"packageName" to packageName,
|
||||
"timestamp" to System.currentTimeMillis()
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送截图事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动触发一次 OCR(临时隐藏悬浮窗避开遮挡,并在 150ms 后触发截图)。 */
|
||||
fun triggerManualOcr() {
|
||||
val pkg = topPackage ?: return
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
floatingHelper?.hideTemporarily()
|
||||
}
|
||||
|
||||
// 150ms 后触发截图(此时悬浮窗已瞬间隐藏,避免遮挡和误匹配)
|
||||
handler.postDelayed({
|
||||
try {
|
||||
takeScreenshotAndProcess(pkg)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "手动触发 OCR 失败: ${e.message}")
|
||||
} finally {
|
||||
// 截图完成,瞬间恢复显示悬浮球
|
||||
handler.post {
|
||||
floatingHelper?.showTemporarily()
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
|
||||
fun triggerManualExtraction() {
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
}
|
||||
val pkg = topPackage ?: return
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("package", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sigKey)
|
||||
putBoolean("isManual", true)
|
||||
val array = WritableNativeArray()
|
||||
for (t in texts) {
|
||||
array.pushString(t)
|
||||
}
|
||||
putArray("texts", array)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingDebugNodes", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "手动触发节点文本提取失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住当前页面(用户主动标记应触发 OCR 的页面)。持久化到 SharedPreferences。 */
|
||||
fun rememberCurrentPage() {
|
||||
val pkg = topPackage ?: return
|
||||
val activity = topActivity ?: ""
|
||||
val sig = "$pkg|$activity"
|
||||
if (pageSignatures.add(sig)) {
|
||||
savePageSignatures()
|
||||
Log.i(TAG, "已记住页面: $sig")
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本
|
||||
val texts = mutableListOf<String>()
|
||||
val rootNode = rootInActiveWindow
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
|
||||
// 推送事件到 JS 端,使 npx expo start 终端控制台可以接收并打印日志
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("package", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sig)
|
||||
val array = WritableNativeArray()
|
||||
for (t in texts) {
|
||||
array.pushString(t)
|
||||
}
|
||||
putArray("texts", array)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingPageRemembered", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送记住页面事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取已记住的页面签名列表(供 JS 端展示)。 */
|
||||
fun getPageSignatures(): Set<String> {
|
||||
return pageSignatures.toSet()
|
||||
}
|
||||
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
fun clearPageSignatures() {
|
||||
pageSignatures.clear()
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
|
||||
Log.i(TAG, "已清空全部记住的页面并从 SharedPreferences 中移除键值")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "清空 SharedPreferences 失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除指定页面签名。 */
|
||||
fun removePageSignature(sig: String) {
|
||||
if (pageSignatures.remove(sig)) {
|
||||
savePageSignatures()
|
||||
Log.i(TAG, "已删除页面签名: $sig")
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前顶部包名(供 JS 判断当前页面)。 */
|
||||
fun getTopPackage(): String? = topPackage
|
||||
|
||||
/** 获取当前顶部 Activity。 */
|
||||
fun getTopActivity(): String? = topActivity
|
||||
|
||||
/** 从 SharedPreferences 加载已记住的页面签名。 */
|
||||
private fun loadPageSignatures() {
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getStringSet(PREF_PAGE_SIGNATURES, emptySet()) ?: emptySet()
|
||||
pageSignatures.clear()
|
||||
pageSignatures.addAll(saved)
|
||||
Log.i(TAG, "已加载 ${pageSignatures.size} 个记住的页面签名")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "加载页面签名失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存页面签名到 SharedPreferences。 */
|
||||
private fun savePageSignatures() {
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (pageSignatures.isEmpty()) {
|
||||
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
|
||||
} else {
|
||||
prefs.edit().putStringSet(PREF_PAGE_SIGNATURES, HashSet(pageSignatures)).apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "保存页面签名失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
|
||||
private fun isLandscape(): Boolean {
|
||||
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
?: return false
|
||||
val rotation = dm.rotation
|
||||
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
|
||||
}
|
||||
|
||||
/** 过滤系统组件/桌面(不触发 OCR)。 */
|
||||
private fun filterPackage(pkg: String, className: String): Boolean {
|
||||
val p = pkg.lowercase()
|
||||
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
|
||||
// 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。
|
||||
if (pkg == packageName) {
|
||||
return className != "com.example.beanmobile.MainActivity"
|
||||
}
|
||||
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
|
||||
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
|
||||
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||||
private fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
|
||||
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
|
||||
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
val baos = ByteArrayOutputStream()
|
||||
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
||||
if (softwareBitmap !== bitmap) {
|
||||
softwareBitmap.recycle()
|
||||
}
|
||||
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** 辅助:构造 WritableNativeMap。 */
|
||||
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
||||
val map = WritableNativeMap()
|
||||
for ((k, v) in pairs) {
|
||||
when (v) {
|
||||
is String -> map.putString(k, v)
|
||||
is Int -> map.putInt(k, v)
|
||||
is Long -> map.putDouble(k, v.toDouble())
|
||||
is Boolean -> map.putBoolean(k, v)
|
||||
is Number -> map.putDouble(k, v.toDouble())
|
||||
is WritableNativeMap -> map.putMap(k, v)
|
||||
is WritableNativeArray -> map.putArray(k, v)
|
||||
else -> map.putNull(k)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 将当前应用拉到前台以显示确认弹窗 */
|
||||
fun bringAppToForeground() {
|
||||
try {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) {
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
startActivity(intent)
|
||||
Log.i(TAG, "已成功拉起本应用到前台显示弹窗")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "拉起应用到前台失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归提取无障碍节点树的全部文本。 */
|
||||
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
||||
if (node == null) return
|
||||
val text = node.text?.toString()
|
||||
if (!text.isNullOrBlank()) {
|
||||
list.add(text)
|
||||
}
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
dumpNodeTexts(child, list)
|
||||
child.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
|
||||
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
|
||||
if (node == null) return false
|
||||
val text = node.text?.toString()
|
||||
if (text != null) {
|
||||
for (keyword in keywords) {
|
||||
if (text.contains(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
val found = findTextInNode(child, keywords)
|
||||
child.recycle()
|
||||
if (found) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
Log.w(TAG, "无障碍服务被中断")
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: android.content.Intent?): Boolean {
|
||||
floatingHelper?.dismiss()
|
||||
floatingHelper = null
|
||||
instance = null
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RN 上下文持有者(由 MainApplication 注入)。
|
||||
* 无障碍服务运行在系统进程,需通过静态引用访问 RN 上下文以发送事件。
|
||||
*/
|
||||
object ReactContextHolder {
|
||||
@Volatile var context: ReactApplicationContext? = null
|
||||
}
|
||||
739
plugins/accessibility/android/FloatingBillView.kt
Normal file
739
plugins/accessibility/android/FloatingBillView.kt
Normal file
@ -0,0 +1,739 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.EditText
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.text.InputType
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
|
||||
/**
|
||||
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
||||
*/
|
||||
class FloatingBillView(
|
||||
private val context: Context,
|
||||
private val draftId: String,
|
||||
private val amount: String,
|
||||
private val merchant: String,
|
||||
private val time: String,
|
||||
private val packageName: String,
|
||||
private val categories: List<Map<String, String>> = emptyList(),
|
||||
private val accounts: List<String> = emptyList(),
|
||||
private val initialDirection: String = "expense"
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingBillView"
|
||||
}
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var view: View? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var selectedCategoryAccount: String = ""
|
||||
private var selectedSourceAccount: String = ""
|
||||
private var currentDirection: String = "expense"
|
||||
|
||||
private var categoryLabel: TextView? = null
|
||||
private var categoryContainer: LinearLayout? = null
|
||||
private var accountLabel: TextView? = null
|
||||
private var accountContainer: LinearLayout? = null
|
||||
private var saveBtn: Button? = null
|
||||
|
||||
private fun dp(value: Float): Int {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return (value * density).toInt()
|
||||
}
|
||||
|
||||
private fun createChipView(text: String): TextView {
|
||||
return TextView(context).apply {
|
||||
this.text = text
|
||||
textSize = 11f
|
||||
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
rightMargin = dp(6f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示悬浮窗。 */
|
||||
fun show() {
|
||||
try {
|
||||
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
||||
|
||||
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
||||
val windowParams = WindowManager.LayoutParams(
|
||||
dp(310f),
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
0, // 0 标志代表可获取焦点
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
y = dp(12f) // 尽量靠下以留出上方账单对照区
|
||||
}
|
||||
|
||||
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
|
||||
val containerBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
|
||||
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
|
||||
}
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = containerBg
|
||||
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
// 顶部标题与方向选择器布局
|
||||
val headerLayout = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
val titleText = TextView(context).apply {
|
||||
text = "调整交易草稿"
|
||||
textSize = 12f
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
headerLayout.addView(titleText)
|
||||
|
||||
// 三方向分段选择器 (支出 / 收入 / 转账)
|
||||
val segmentContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0x20FFFFFF.toInt()) // 12% white opacity
|
||||
}
|
||||
}
|
||||
|
||||
val tabTexts = listOf("支出", "收入", "转账")
|
||||
val tabDirections = listOf("expense", "income", "transfer")
|
||||
val tabViews = mutableListOf<TextView>()
|
||||
|
||||
fun updateTabStyle() {
|
||||
for (i in tabViews.indices) {
|
||||
val active = tabDirections[i] == currentDirection
|
||||
tabViews[i].apply {
|
||||
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
background = if (active) {
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i in tabTexts.indices) {
|
||||
val tab = TextView(context).apply {
|
||||
text = tabTexts[i]
|
||||
textSize = 10f
|
||||
setPadding(dp(6f), dp(3f), dp(6f), dp(3f))
|
||||
gravity = Gravity.CENTER
|
||||
setOnClickListener {
|
||||
if (currentDirection != tabDirections[i]) {
|
||||
currentDirection = tabDirections[i]
|
||||
updateTabStyle()
|
||||
rebuildChips()
|
||||
}
|
||||
}
|
||||
}
|
||||
segmentContainer.addView(tab)
|
||||
tabViews.add(tab)
|
||||
}
|
||||
updateTabStyle()
|
||||
headerLayout.addView(segmentContainer)
|
||||
container.addView(headerLayout)
|
||||
|
||||
// 金额编辑
|
||||
val amountLabel = TextView(context).apply {
|
||||
text = "金额"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, dp(4f), 0, dp(1f))
|
||||
}
|
||||
container.addView(amountLabel)
|
||||
|
||||
val amountInput = EditText(context).apply {
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt()) // 25% 半透底色
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(amountInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
|
||||
// 商户编辑
|
||||
val merchantLabel = TextView(context).apply {
|
||||
text = "交易对手"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(merchantLabel)
|
||||
|
||||
val merchantInput = EditText(context).apply {
|
||||
setText(merchant)
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(merchantInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
|
||||
// 叙述备注编辑
|
||||
val narrationLabel = TextView(context).apply {
|
||||
text = "描述/备注"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(narrationLabel)
|
||||
|
||||
val narrationInput = EditText(context).apply {
|
||||
hint = "输入交易叙述"
|
||||
setHintTextColor(0xFF6B7280.toInt())
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(narrationInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// Row 1 分类/转入选择
|
||||
categoryLabel = TextView(context).apply {
|
||||
text = "交易分类"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(categoryLabel)
|
||||
|
||||
categoryContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
}
|
||||
|
||||
val categoryScroll = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
addView(categoryContainer)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(categoryScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// Row 2 资金出入账户选择
|
||||
accountLabel = TextView(context).apply {
|
||||
text = "资金来源账户"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(accountLabel)
|
||||
|
||||
accountContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
}
|
||||
|
||||
val accountScroll = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
addView(accountContainer)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(accountScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
|
||||
|
||||
// 底部操作栏
|
||||
val btnContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
// 1) 打开应用按钮
|
||||
val openAppBtn = Button(context).apply {
|
||||
text = "打开应用"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
}
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
val newNarration = narrationInput.text.toString().trim()
|
||||
|
||||
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
BillingAccessibilityService.instance?.bringAppToForeground()
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 忽略按钮
|
||||
val cancelBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
}
|
||||
val cancelBtn = Button(context).apply {
|
||||
text = "忽略"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
background = cancelBg
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 确认入账按钮
|
||||
val saveBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt())
|
||||
}
|
||||
saveBtn = Button(context).apply {
|
||||
text = "确认入账"
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = saveBg
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
val newNarration = narrationInput.text.toString().trim()
|
||||
|
||||
sendSaveEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
btnContainer.addView(openAppBtn)
|
||||
btnContainer.addView(cancelBtn)
|
||||
btnContainer.addView(saveBtn)
|
||||
container.addView(btnContainer)
|
||||
|
||||
// 构建金额安全校验
|
||||
val amountWatcher = object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: android.text.Editable?) {
|
||||
try {
|
||||
val text = s?.toString()?.trim() ?: ""
|
||||
val value = text.toDoubleOrNull()
|
||||
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
|
||||
saveBtn?.isEnabled = isValid
|
||||
saveBtn?.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
|
||||
}
|
||||
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "金额校验异常", e)
|
||||
saveBtn?.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
amountInput.addTextChangedListener(amountWatcher)
|
||||
|
||||
// 填充金额触发 Watcher 校验
|
||||
amountInput.setText(amount)
|
||||
|
||||
// 动态初始构建两行胶囊
|
||||
rebuildChips()
|
||||
|
||||
view = container
|
||||
windowManager.addView(view, windowParams)
|
||||
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
|
||||
|
||||
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
||||
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
Log.d(TAG, "已获得输入焦点,取消自动消失定时器")
|
||||
}
|
||||
}
|
||||
amountInput.onFocusChangeListener = cancelTimerListener
|
||||
merchantInput.onFocusChangeListener = cancelTimerListener
|
||||
narrationInput.onFocusChangeListener = cancelTimerListener
|
||||
|
||||
container.setOnTouchListener { _, _ ->
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
Log.d(TAG, "已触摸卡片,取消自动消失定时器")
|
||||
false
|
||||
}
|
||||
|
||||
// 15 秒无操作自动消失(如果用户没有交互的话)
|
||||
handler.postDelayed({
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
}, 15000L)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态根据交易方向重绘第一行与第二行滑动的胶囊列表 */
|
||||
private fun rebuildChips() {
|
||||
categoryContainer?.removeAllViews()
|
||||
accountContainer?.removeAllViews()
|
||||
|
||||
val categoryChips = mutableListOf<Pair<String, TextView>>()
|
||||
val accountChips = mutableListOf<Pair<String, TextView>>()
|
||||
|
||||
// === 1. 绘制第一行 (分类 / 转入) ===
|
||||
if (currentDirection == "expense") {
|
||||
categoryLabel?.text = "交易分类"
|
||||
val filteredCats = categories.filter { it["type"] == "expense" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
val catName = cat["name"] ?: ""
|
||||
val chip = createChipView(catName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = catAccount
|
||||
updateStyle(catAccount)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(catAccount to chip)
|
||||
}
|
||||
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
|
||||
} else if (currentDirection == "income") {
|
||||
categoryLabel?.text = "收入分类"
|
||||
val filteredCats = categories.filter { it["type"] == "income" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
val catName = cat["name"] ?: ""
|
||||
val chip = createChipView(catName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = catAccount
|
||||
updateStyle(catAccount)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(catAccount to chip)
|
||||
}
|
||||
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
|
||||
} else {
|
||||
// transfer
|
||||
categoryLabel?.text = "转入账户"
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = acct
|
||||
updateStyle(acct)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(acct to chip)
|
||||
}
|
||||
selectedCategoryAccount = if (accounts.size > 1 && accounts[0] == selectedSourceAccount) accounts[1] else (accounts.firstOrNull() ?: "")
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
// === 2. 绘制第二行 (资金账户) ===
|
||||
if (currentDirection == "expense") {
|
||||
accountLabel?.text = "资金来源"
|
||||
} else if (currentDirection == "income") {
|
||||
accountLabel?.text = "存入账户"
|
||||
} else {
|
||||
accountLabel?.text = "转出账户"
|
||||
}
|
||||
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in accountChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedSourceAccount = acct
|
||||
updateStyle(acct)
|
||||
|
||||
// 若是转账,且转入转出账户冲突,自动移开转入账户
|
||||
if (currentDirection == "transfer" && selectedCategoryAccount == selectedSourceAccount) {
|
||||
val nextAvail = categoryChips.find { it.first != selectedSourceAccount }
|
||||
if (nextAvail != null) {
|
||||
selectedCategoryAccount = nextAvail.first
|
||||
for (p in categoryChips) {
|
||||
val act = p.first == selectedCategoryAccount
|
||||
p.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (act) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accountContainer?.addView(chip)
|
||||
accountChips.add(acct to chip)
|
||||
}
|
||||
|
||||
selectedSourceAccount = accounts.firstOrNull() ?: ""
|
||||
accountChips.forEach { pair ->
|
||||
val active = pair.first == selectedSourceAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送保存事件到 JS 层。 */
|
||||
private fun sendSaveEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putString("amount", newAmount)
|
||||
putString("merchant", newPayee)
|
||||
putString("narration", newNarration)
|
||||
putString("category", categoryAccount)
|
||||
putString("account", sourceAccount)
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
putBoolean("confirmed", true)
|
||||
putBoolean("editRequested", false)
|
||||
putBoolean("isManualEdit", true)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingConfirmed", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送修改保存事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送打开应用事件到 JS 层,以便前台加载详细表单。 */
|
||||
private fun sendOpenAppEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putString("amount", newAmount)
|
||||
putString("merchant", newPayee)
|
||||
putString("narration", newNarration)
|
||||
putString("category", categoryAccount)
|
||||
putString("account", sourceAccount)
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingOpenApp", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送打开应用事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送取消/忽略事件到 JS 层。 */
|
||||
private fun sendCancelEvent() {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putBoolean("confirmed", false)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingConfirmed", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送取消事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭浮窗。 */
|
||||
fun dismiss() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
try {
|
||||
view?.let { windowManager.removeView(it) }
|
||||
} catch (_: Exception) {}
|
||||
view = null
|
||||
}
|
||||
}
|
||||
418
plugins/accessibility/android/FloatingHelper.kt
Normal file
418
plugins/accessibility/android/FloatingHelper.kt
Normal file
@ -0,0 +1,418 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
|
||||
/**
|
||||
* 记账助手悬浮球(边缘竖线胶囊面板)。
|
||||
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
|
||||
*/
|
||||
class FloatingHelper(
|
||||
private val service: BillingAccessibilityService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingHelper"
|
||||
private var lastX = 0
|
||||
private var lastY = 400
|
||||
}
|
||||
|
||||
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var containerView: LinearLayout? = null
|
||||
private var bubbleView: View? = null
|
||||
private var menuView: LinearLayout? = null
|
||||
private var isExpanded = false
|
||||
|
||||
private val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
x = lastX
|
||||
y = lastY
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun show() {
|
||||
if (containerView != null) return
|
||||
|
||||
try {
|
||||
val context = service
|
||||
|
||||
val density = service.resources.displayMetrics.density
|
||||
fun dp(value: Float) = (value * density).toInt()
|
||||
|
||||
// 1. 创建整体包裹容器 (水平排列,当贴在左侧时,菜单向右展开)
|
||||
containerView = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_OUTSIDE) {
|
||||
if (isExpanded) {
|
||||
collapse()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建高透明度悬浮球/边缘竖线 (仅 3dp 宽的极简胶囊,搭配 24dp 的宽触控区)
|
||||
val bubbleLayout = FrameLayout(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
||||
}
|
||||
|
||||
val indicatorView = View(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(1.5f).toFloat() // 高度圆润
|
||||
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
|
||||
}
|
||||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
bubbleLayout.addView(indicatorView)
|
||||
bubbleView = bubbleLayout
|
||||
|
||||
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
||||
val menuBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(10f).toFloat()
|
||||
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
|
||||
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
|
||||
}
|
||||
|
||||
menuView = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
background = menuBg
|
||||
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
|
||||
visibility = View.GONE
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
dp(96f), // ultra-compact width: 96dp
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = dp(4f) // spacing with indicator line
|
||||
}
|
||||
}
|
||||
|
||||
// Vector icons
|
||||
val sizePx = dp(14f)
|
||||
val strokePx = dp(1.4f).toFloat()
|
||||
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
|
||||
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
|
||||
|
||||
// Button 1: "识别账单"
|
||||
val btnOcr = TextView(context).apply {
|
||||
text = "识别账单"
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(6f), 0, dp(6f), 0)
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dp(32f)
|
||||
).apply {
|
||||
bottomMargin = dp(5f)
|
||||
}
|
||||
setCompoundDrawablesWithIntrinsicBounds(ocrIcon, null, null, null)
|
||||
compoundDrawablePadding = dp(4f)
|
||||
setOnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x405E6AD2.toInt())
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x1F5E6AD2.toInt())
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
view.performClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnClickListener {
|
||||
service.triggerManualExtraction()
|
||||
}
|
||||
}
|
||||
|
||||
// Button 2: "记住此页"
|
||||
val btnRemember = TextView(context).apply {
|
||||
text = "记住此页"
|
||||
setTextColor(0xFFE5E7EB.toInt())
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(6f), 0, dp(6f), 0)
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dp(32f)
|
||||
)
|
||||
setCompoundDrawablesWithIntrinsicBounds(pinIcon, null, null, null)
|
||||
compoundDrawablePadding = dp(4f)
|
||||
setOnTouchListener { view, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x30FFFFFF.toInt())
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x15FFFFFF.toInt())
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
view.performClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnClickListener {
|
||||
try {
|
||||
service.rememberCurrentPage()
|
||||
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
} catch (e: Exception) {
|
||||
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
}
|
||||
collapse()
|
||||
}
|
||||
}
|
||||
|
||||
menuView?.addView(btnOcr)
|
||||
menuView?.addView(btnRemember)
|
||||
|
||||
containerView?.addView(bubbleView)
|
||||
containerView?.addView(menuView)
|
||||
|
||||
// 4. 设置悬浮条拖动事件 (点击即展开,拖动则调整位置)
|
||||
var initialX = 0
|
||||
var initialY = 0
|
||||
var initialTouchX = 0f
|
||||
var initialTouchY = 0f
|
||||
var isMoving = false
|
||||
|
||||
bubbleView?.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
initialX = params.x
|
||||
initialY = params.y
|
||||
initialTouchX = event.rawX
|
||||
initialTouchY = event.rawY
|
||||
isMoving = false
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dx = (event.rawX - initialTouchX).toInt()
|
||||
val dy = (event.rawY - initialTouchY).toInt()
|
||||
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
|
||||
isMoving = true
|
||||
}
|
||||
params.x = initialX + dx
|
||||
params.y = initialY + dy
|
||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||
|
||||
lastX = params.x
|
||||
lastY = params.y
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isMoving) {
|
||||
toggleMenu()
|
||||
} else {
|
||||
// 拖动抬起时自动吸附到屏幕边缘
|
||||
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
||||
params.x = 0
|
||||
} else {
|
||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
|
||||
}
|
||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||
|
||||
lastX = params.x
|
||||
lastY = params.y
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
windowManager.addView(containerView, params)
|
||||
Log.i(TAG, "记账助手悬浮窗显示成功")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleMenu() {
|
||||
if (isExpanded) {
|
||||
collapse()
|
||||
} else {
|
||||
expand()
|
||||
}
|
||||
}
|
||||
|
||||
private fun expand() {
|
||||
bubbleView?.visibility = View.GONE
|
||||
menuView?.visibility = View.VISIBLE
|
||||
isExpanded = true
|
||||
}
|
||||
|
||||
fun collapse() {
|
||||
menuView?.visibility = View.GONE
|
||||
bubbleView?.visibility = View.VISIBLE
|
||||
isExpanded = false
|
||||
}
|
||||
|
||||
fun hideTemporarily() {
|
||||
containerView?.visibility = View.GONE
|
||||
}
|
||||
|
||||
fun showTemporarily() {
|
||||
containerView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
try {
|
||||
containerView?.let { windowManager.removeView(it) }
|
||||
} catch (_: Exception) {}
|
||||
containerView = null
|
||||
bubbleView = null
|
||||
menuView = null
|
||||
isExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码/OCR 矢量图标 drawable。
|
||||
*/
|
||||
class ScanIconDrawable(
|
||||
private val color: Int,
|
||||
private val strokeWidthPx: Float,
|
||||
private val laserColor: Int,
|
||||
private val sizePx: Int
|
||||
) : Drawable() {
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = strokeWidthPx
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val w = bounds.width().toFloat()
|
||||
val h = bounds.height().toFloat()
|
||||
paint.color = color
|
||||
paint.style = Paint.Style.STROKE
|
||||
|
||||
// 绘制 4 个角的扫描框
|
||||
val len = w * 0.25f
|
||||
val pad = strokeWidthPx
|
||||
|
||||
// 左上
|
||||
canvas.drawLine(pad, pad, pad + len, pad, paint)
|
||||
canvas.drawLine(pad, pad, pad, pad + len, paint)
|
||||
|
||||
// 右上
|
||||
canvas.drawLine(w - pad, pad, w - pad - len, pad, paint)
|
||||
canvas.drawLine(w - pad, pad, w - pad, pad + len, paint)
|
||||
|
||||
// 左下
|
||||
canvas.drawLine(pad, h - pad, pad + len, h - pad, paint)
|
||||
canvas.drawLine(pad, h - pad, pad, h - pad - len, paint)
|
||||
|
||||
// 右下
|
||||
canvas.drawLine(w - pad, h - pad, w - pad - len, h - pad, paint)
|
||||
canvas.drawLine(w - pad, h - pad, w - pad, h - pad - len, paint)
|
||||
|
||||
// 绘制扫描红线 (激光)
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = laserColor
|
||||
val laserY = h / 2f
|
||||
canvas.drawRect(pad * 2f, laserY - strokeWidthPx / 2f, w - pad * 2f, laserY + strokeWidthPx / 2f, paint)
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth() = sizePx
|
||||
override fun getIntrinsicHeight() = sizePx
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
|
||||
override fun getOpacity() = PixelFormat.TRANSLUCENT
|
||||
}
|
||||
|
||||
/**
|
||||
* 图钉/记住当前页 矢量图标 drawable。
|
||||
*/
|
||||
class PinIconDrawable(
|
||||
private val color: Int,
|
||||
private val strokeWidthPx: Float,
|
||||
private val sizePx: Int
|
||||
) : Drawable() {
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = strokeWidthPx
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val w = bounds.width().toFloat()
|
||||
val h = bounds.height().toFloat()
|
||||
paint.color = color
|
||||
val cx = w / 2f
|
||||
|
||||
// 图钉头部 (帽)
|
||||
paint.style = Paint.Style.FILL
|
||||
val hatW = w * 0.35f
|
||||
val hatH = h * 0.12f
|
||||
canvas.drawRoundRect(RectF(cx - hatW, hatH, cx + hatW, hatH * 2.2f), strokeWidthPx, strokeWidthPx, paint)
|
||||
|
||||
// 图钉身体 (中)
|
||||
val bodyW = w * 0.22f
|
||||
canvas.drawRect(cx - bodyW, hatH * 2.2f, cx + bodyW, h * 0.58f, paint)
|
||||
|
||||
// 针尖 (底)
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawLine(cx, h * 0.58f, cx, h - strokeWidthPx, paint)
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth() = sizePx
|
||||
override fun getIntrinsicHeight() = sizePx
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
|
||||
override fun getOpacity() = PixelFormat.TRANSLUCENT
|
||||
}
|
||||
128
plugins/accessibility/android/FloatingTip.kt
Normal file
128
plugins/accessibility/android/FloatingTip.kt
Normal file
@ -0,0 +1,128 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 浮窗提示(plan.md「3.8 浮窗账单提示」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 FloatingTip + RepeatToast:
|
||||
* - 轻量浮窗(非全屏),滑入动画,自动消失
|
||||
* - 三种布局:顶部 / 左侧 / 右侧
|
||||
* - 倒计时进度环
|
||||
* - 重复账单提示(RepeatToast)
|
||||
*
|
||||
* 比 FloatingBillView 更轻:仅展示提示,不交互。
|
||||
* 需 SYSTEM_ALERT_WINDOW 权限(由 Config Plugin 注册)。
|
||||
*/
|
||||
class FloatingTip(
|
||||
private val context: Context,
|
||||
private val message: String,
|
||||
private val position: TipPosition = TipPosition.TOP,
|
||||
private val durationMs: Long = 3000L,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingTip"
|
||||
private const val ANIM_DURATION = 300L
|
||||
}
|
||||
|
||||
enum class TipPosition { TOP, LEFT, RIGHT }
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var view: View? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** 显示浮窗提示。 */
|
||||
fun show() {
|
||||
try {
|
||||
val layoutParams = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = when (position) {
|
||||
TipPosition.TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
TipPosition.LEFT -> Gravity.LEFT or Gravity.CENTER_VERTICAL
|
||||
TipPosition.RIGHT -> Gravity.RIGHT or Gravity.CENTER_VERTICAL
|
||||
}
|
||||
y = if (position == TipPosition.TOP) 100 else 0
|
||||
x = if (position != TipPosition.TOP) 50 else 0
|
||||
}
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setBackgroundColor(0xF0333333.toInt())
|
||||
setPadding(32, 16, 32, 16)
|
||||
}
|
||||
val text = TextView(context).apply {
|
||||
text = message
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
}
|
||||
// 倒计时进度条
|
||||
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
|
||||
progress.max = 100
|
||||
progress.progress = 100
|
||||
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
|
||||
setMargins(16, 0, 0, 0)
|
||||
}
|
||||
container.addView(text)
|
||||
container.addView(progress)
|
||||
view = container
|
||||
|
||||
windowManager.addView(view, layoutParams)
|
||||
Log.d(TAG, "FloatingTip 显示: $message")
|
||||
|
||||
// 倒计时动画(300ms 内 progress 从 100 到 0)
|
||||
val steps = 30
|
||||
val stepMs = durationMs / steps
|
||||
var currentStep = 0
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
currentStep++
|
||||
progress.progress = 100 - (currentStep * 100 / steps)
|
||||
if (currentStep < steps) {
|
||||
handler.postDelayed(this, stepMs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handler.postDelayed({ dismiss() }, durationMs)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭浮窗。 */
|
||||
fun dismiss() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
try { view?.let { windowManager.removeView(it) } } catch (_: Exception) {}
|
||||
view = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复账单提示(plan.md「3.8」+ AutoAccounting RepeatToast)。
|
||||
* 当检测到重复账单时,轻量提示用户(不弹浮窗,用系统 Toast 风格)。
|
||||
*/
|
||||
class RepeatToast(private val context: Context, private val message: String) {
|
||||
fun show() {
|
||||
val tip = FloatingTip(context, "⚠ $message", FloatingTip.TipPosition.TOP, 2000L)
|
||||
tip.show()
|
||||
Log.d("RepeatToast", "重复提示: $message")
|
||||
}
|
||||
}
|
||||
62
plugins/accessibility/android/OcrTileService.kt
Normal file
62
plugins/accessibility/android/OcrTileService.kt
Normal file
@ -0,0 +1,62 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import android.content.Intent
|
||||
import android.app.PendingIntent
|
||||
|
||||
/**
|
||||
* 快速设置磁贴(plan.md「3.11 快速设置磁贴」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 OcrTileService:
|
||||
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
||||
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
||||
*
|
||||
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
|
||||
*/
|
||||
class OcrTileService : TileService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OcrTileService"
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
qsTile?.let { tile ->
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "OCR 记账"
|
||||
tile.updateTile()
|
||||
}
|
||||
Log.d(TAG, "磁贴开始监听")
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
Log.i(TAG, "磁贴被点击,触发手动 OCR")
|
||||
triggerManualOcr()
|
||||
}
|
||||
|
||||
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
|
||||
private fun triggerManualOcr() {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.triggerManualOcr()
|
||||
return
|
||||
}
|
||||
// 服务未运行,尝试启动(Android 14+ 用 collapse)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent().apply {
|
||||
setClassName(packageName, "com.beancount.mobile.MainActivity")
|
||||
action = "com.beancount.mobile.TRIGGER_OCR"
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
startActivityAndCollapse(pi)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
无障碍服务配置(plan.md「3.6 无障碍服务」)。
|
||||
注册监听支付 App 的页面变化 + 截图能力。
|
||||
canTakeScreenshot="true" 是 Android 11+ AccessibilityService.takeScreenshot() 的前提。
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/accessibility_service_description"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:notificationTimeout="100"
|
||||
android:canRequestEnhancedWebAccessibility="true"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canTakeScreenshot="true"
|
||||
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows" />
|
||||
160
plugins/accessibility/app.plugin.js
Normal file
160
plugins/accessibility/app.plugin.js
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 无障碍服务 Config Plugin(plan.md「3.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. 注入 import(AccessibilityBridgePackage)
|
||||
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;
|
||||
6
plugins/accessibility/package.json
Normal file
6
plugins/accessibility/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-accessibility",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package com.beancount.mobile.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.ComponentName
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 通知监听服务(plan.md「4.1 通知监听服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 NotificationListenerService:
|
||||
* - 提取支付 App 通知的 title/text
|
||||
* - 白名单过滤(仅支付类 App)
|
||||
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
|
||||
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
|
||||
* - onListenerDisconnected 时 requestRebind 自动重连
|
||||
*
|
||||
* 通过 DeviceEventEmitter 把通知事件推送到 JS 层 NotificationChannel。
|
||||
*/
|
||||
class BillingNotificationListenerService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingNotification"
|
||||
|
||||
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
|
||||
private val PAYMENT_PACKAGES = setOf(
|
||||
"com.eg.android.AlipayGphone",
|
||||
"com.tencent.mm",
|
||||
"com.unionpay",
|
||||
"com.cmbchina",
|
||||
"com.icbc",
|
||||
"com.chinamworld.main",
|
||||
"com.ccbrcb",
|
||||
"com.bankcomm.Bankcomm",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
runCatching {
|
||||
val packageName = sbn?.packageName?.toString() ?: return
|
||||
// 白名单过滤
|
||||
if (!PAYMENT_PACKAGES.contains(packageName)) return
|
||||
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
|
||||
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: ""
|
||||
|
||||
if (title.isBlank() && text.isBlank()) return
|
||||
|
||||
Log.d(TAG, "收到支付通知: pkg=$packageName, title=$title")
|
||||
// 推送到 JS 层
|
||||
sendNotificationEvent(packageName, title, text)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "通知处理异常: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听断开时自动重连(参考 AutoAccounting requestRebind)。
|
||||
* Android Doze / App Standby 可能断开通知监听。
|
||||
*/
|
||||
override fun onListenerDisconnected() {
|
||||
super.onListenerDisconnected()
|
||||
Log.w(TAG, "通知监听断开,尝试重连")
|
||||
requestRebind(ComponentName(this, BillingNotificationListenerService::class.java))
|
||||
}
|
||||
|
||||
/** 把通知事件推送到 JS 层 NotificationChannel.handleNotification。 */
|
||||
private fun sendNotificationEvent(packageName: String, title: String, text: String) {
|
||||
val reactContext = ReactContextHolder.context ?: run {
|
||||
Log.w(TAG, "RN 上下文未就绪,丢弃通知")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("packageName", packageName)
|
||||
putString("title", title)
|
||||
putString("text", text)
|
||||
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingNotification", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送通知事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
69
plugins/notification-listener/app.plugin.js
Normal file
69
plugins/notification-listener/app.plugin.js
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 通知监听 Config Plugin(plan.md「4.1 通知监听服务」+「决策 4」)。
|
||||
*
|
||||
* 在 expo prebuild 时注册 Android NotificationListenerService(manifest 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;
|
||||
6
plugins/notification-listener/package.json
Normal file
6
plugins/notification-listener/package.json
Normal 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
84
plugins/ppocr/README.md
Normal 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 Bridge:ONNX 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`),导致新代码找不到配套字典。
|
||||
659
plugins/ppocr/android/OcrModule.kt
Normal file
659
plugins/ppocr/android/OcrModule.kt
Normal file
@ -0,0 +1,659 @@
|
||||
package com.beancount.mobile.ppocr
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.module.annotations.ReactModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* PP-OCRv5 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
||||
* 模型:ppocrv5_det.onnx + ppocrv5_rec.onnx(从 ilaylow/PP_OCRv5_mobile_onnx 下载)。
|
||||
* 字典:ppocrv5_dict.txt(PP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
|
||||
*
|
||||
* 流水线:
|
||||
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
||||
* 2. rec(文本识别):每个框 crop → resize 到 48px 高 → CTC 解码
|
||||
*
|
||||
* 性能优化(参考 AutoAccounting OcrProcessor.kt):
|
||||
* - 短边压缩到 720px(像素量比 1440p 减少约 75%)
|
||||
* - CPU 执行(兼容性最稳,部分设备 GPU 会崩溃)
|
||||
* - det 最大边限制 960(PaddleOCR 默认 limit_max_side_len)
|
||||
*
|
||||
* JS 层通过 NativeModules.PpOcr.recognizeText(base64) 调用。
|
||||
*/
|
||||
const val OCR_MODULE_NAME = "PpOcr"
|
||||
|
||||
@ReactModule(name = OCR_MODULE_NAME)
|
||||
class OcrModule(private val context: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(context) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
private var ortEnv: OrtEnvironment? = null
|
||||
private var detSession: OrtSession? = null
|
||||
private var recSession: OrtSession? = null
|
||||
private var dictionary: List<String> = emptyList()
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var initFailed = false
|
||||
|
||||
override fun getName(): String = OCR_MODULE_NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
// 异步加载模型,避免阻塞 RN 桥初始化
|
||||
scope.launch { initEngine() }
|
||||
}
|
||||
|
||||
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
|
||||
private fun initEngine() {
|
||||
lock.lock()
|
||||
try {
|
||||
if (initialized || initFailed) return
|
||||
val env = OrtEnvironment.getEnvironment()
|
||||
val opts = OrtSession.SessionOptions().apply {
|
||||
// CPU 线程数:2 是兼容性/性能的稳妥折中(高端机可调高)
|
||||
setInterOpNumThreads(2)
|
||||
setIntraOpNumThreads(2)
|
||||
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
||||
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
||||
}
|
||||
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
|
||||
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
|
||||
val det = env.createSession(detBytes, opts)
|
||||
val rec = env.createSession(recBytes, opts)
|
||||
val dict = loadDictionary()
|
||||
|
||||
detSession = det
|
||||
recSession = rec
|
||||
ortEnv = env
|
||||
dictionary = dict
|
||||
initialized = true
|
||||
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功(det+rec, dict=${dict.size})")
|
||||
} catch (e: Exception) {
|
||||
initFailed = true
|
||||
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
||||
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/** rec 推理用的 OrtEnvironment(复用 ortEnv 单例)。 */
|
||||
private val recEnv: OrtEnvironment? get() = ortEnv
|
||||
|
||||
/**
|
||||
* 加载 ppocrv5_dict.txt 字典。
|
||||
*
|
||||
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
||||
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
||||
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
||||
*/
|
||||
private fun loadDictionary(): List<String> {
|
||||
val words = mutableListOf<String>()
|
||||
context.assets.open(ASSET_DICT).use { stream ->
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
// PaddleOCR 字典每行一个字符(去掉行尾换行)
|
||||
words.add(line.trimEnd('\r', '\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别图片文本。
|
||||
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
||||
* @return 识别出的纯文本(所有行用 \n 连接)
|
||||
*/
|
||||
@ReactMethod
|
||||
fun recognizeText(imageBase64: String, promise: Promise) {
|
||||
scope.launch {
|
||||
var bitmap: Bitmap? = null
|
||||
var scaled: Bitmap? = null
|
||||
try {
|
||||
ensureReady()
|
||||
bitmap = decodeBase64(imageBase64)
|
||||
if (bitmap == null) {
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
val text = blocks.joinToString("\n") { it.text }
|
||||
promise.resolve(text)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "recognizeText 异常: ${e.message}", e)
|
||||
promise.reject("OCR_ERROR", e.message)
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
if (scaled !== bitmap) {
|
||||
scaled?.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别并返回带坐标的文本块(用于复杂版面)。
|
||||
* @return JSON 数组字符串:[{text, x, y, width, height, confidence}]
|
||||
*/
|
||||
@ReactMethod
|
||||
fun recognizeTextBlocks(imageBase64: String, promise: Promise) {
|
||||
scope.launch {
|
||||
var bitmap: Bitmap? = null
|
||||
var scaled: Bitmap? = null
|
||||
try {
|
||||
ensureReady()
|
||||
bitmap = decodeBase64(imageBase64)
|
||||
if (bitmap == null) {
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
// 序列化为 RN WritableArray
|
||||
val arr = WritableNativeArray()
|
||||
for (b in blocks) {
|
||||
val map: WritableMap = WritableNativeMap()
|
||||
map.putString("text", b.text)
|
||||
map.putDouble("x", b.x.toDouble())
|
||||
map.putDouble("y", b.y.toDouble())
|
||||
map.putDouble("width", b.width.toDouble())
|
||||
map.putDouble("height", b.height.toDouble())
|
||||
map.putDouble("confidence", b.confidence.toDouble())
|
||||
arr.pushMap(map)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "recognizeTextBlocks 异常: ${e.message}", e)
|
||||
promise.reject("OCR_ERROR", e.message)
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
if (scaled !== bitmap) {
|
||||
scaled?.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 引擎是否已就绪(模型加载完成)。 */
|
||||
@ReactMethod
|
||||
fun isReady(promise: Promise) {
|
||||
promise.resolve(initialized)
|
||||
}
|
||||
|
||||
// ============== 推理流水线 ==============
|
||||
|
||||
private fun ensureReady() {
|
||||
if (!initialized && !initFailed) initEngine()
|
||||
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"})")
|
||||
}
|
||||
|
||||
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
|
||||
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
|
||||
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
|
||||
val det = detSession ?: run {
|
||||
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
|
||||
return emptyList()
|
||||
}
|
||||
val rec = recSession ?: run {
|
||||
Log.e(OCR_MODULE_NAME, "recSession 为空,放弃推理")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var resized: Bitmap? = null
|
||||
var detInputTensor: OnnxTensor? = null
|
||||
var detOutputs: OrtSession.Result? = null
|
||||
val results = mutableListOf<OcrBlock>()
|
||||
|
||||
try {
|
||||
// ---- 1. 文本检测(DB)----
|
||||
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
|
||||
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
|
||||
val ratioX = bitmap.width.toFloat() / resized.width
|
||||
val ratioY = bitmap.height.toFloat() / resized.height
|
||||
|
||||
val detInput = preprocessDet(resized)
|
||||
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
|
||||
val detInputs = mapOf("x" to detInputTensor)
|
||||
detOutputs = det.run(detInputs)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
|
||||
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
|
||||
|
||||
// DB 后处理:threshold → 轮廓 → 最小外接矩形
|
||||
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
|
||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
|
||||
|
||||
if (boxes.isEmpty()) return emptyList()
|
||||
|
||||
// ---- 2. 文本识别(CRNN+CTC)----
|
||||
for ((idx, box) in boxes.withIndex()) {
|
||||
var crop: Bitmap? = null
|
||||
var recInputTensor: OnnxTensor? = null
|
||||
var recOutputs: OrtSession.Result? = null
|
||||
try {
|
||||
crop = cropBox(bitmap, box)
|
||||
if (crop == null) {
|
||||
Log.w(OCR_MODULE_NAME, "裁剪文本框失败 (index = $idx)")
|
||||
continue
|
||||
}
|
||||
val recInput = preprocessRec(crop)
|
||||
recInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(recInput.data), longArrayOf(1L, 3L, REC_IMAGE_HEIGHT.toLong(), recInput.w.toLong()))
|
||||
val recInputs = mapOf("x" to recInputTensor)
|
||||
recOutputs = rec.run(recInputs)
|
||||
// 输出 shape: [1, T, numClasses]
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val logits = (recOutputs[0].value as Array<Array<FloatArray>>)[0]
|
||||
|
||||
val (text, conf) = ctcGreedyDecode(logits)
|
||||
val xs = box.map { it[0] }
|
||||
val ys = box.map { it[1] }
|
||||
val minX = (xs.minOrNull() ?: 0f).toInt()
|
||||
val minY = (ys.minOrNull() ?: 0f).toInt()
|
||||
val maxX = (xs.maxOrNull() ?: 0f).toInt()
|
||||
val maxY = (ys.maxOrNull() ?: 0f).toInt()
|
||||
val w = maxX - minX
|
||||
val h = maxY - minY
|
||||
Log.i(OCR_MODULE_NAME, "文本框 $idx 识别结果 = '$text', 坐标 = ($minX, $minY, $w, $h), 置信度 = $conf")
|
||||
if (text.isNotEmpty()) {
|
||||
results.add(OcrBlock(text, minX.toFloat(), minY.toFloat(), w.toFloat(), h.toFloat(), conf))
|
||||
}
|
||||
} finally {
|
||||
crop?.recycle()
|
||||
recInputTensor?.close()
|
||||
recOutputs?.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (resized !== bitmap) {
|
||||
resized?.recycle()
|
||||
}
|
||||
detInputTensor?.close()
|
||||
detOutputs?.close()
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ============== 前处理 ==============
|
||||
|
||||
/** det 前处理:resize → BCHW → normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])。 */
|
||||
private fun preprocessDet(bmp: Bitmap): TensorData {
|
||||
val w = bmp.width
|
||||
val h = bmp.height
|
||||
val pixels = IntArray(w * h)
|
||||
bmp.getPixels(pixels, 0, w, 0, 0, w, h)
|
||||
val data = FloatArray(3 * w * h)
|
||||
// BCHW 顺序
|
||||
val means = floatArrayOf(0.485f, 0.456f, 0.406f)
|
||||
val stds = floatArrayOf(0.229f, 0.224f, 0.225f)
|
||||
for (c in 0..2) {
|
||||
val mean = means[c]
|
||||
val std = stds[c]
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val px = pixels[y * w + x]
|
||||
// 提取 R/G/B(c=0→R, 1→G, 2→B)
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||
}
|
||||
}
|
||||
}
|
||||
return TensorData(data, w, h)
|
||||
}
|
||||
|
||||
/** rec 前处理:crop → resize 到 48 高(保持宽高比)→ pad 到整除 4 → normalize。 */
|
||||
private fun preprocessRec(bmp: Bitmap): TensorData {
|
||||
var w = bmp.width
|
||||
val h = bmp.height
|
||||
// resize 到高度 48,宽度等比缩放
|
||||
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
|
||||
// 宽度上限,避免单行过长爆显存
|
||||
resizedW = min(resizedW, REC_MAX_WIDTH)
|
||||
resizedW = max(resizedW, 1)
|
||||
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
|
||||
else Bitmap.createScaledBitmap(bmp, resizedW, REC_IMAGE_HEIGHT, true)
|
||||
w = resized.width
|
||||
val pixels = IntArray(w * REC_IMAGE_HEIGHT)
|
||||
resized.getPixels(pixels, 0, w, 0, 0, w, REC_IMAGE_HEIGHT)
|
||||
val data = FloatArray(3 * w * REC_IMAGE_HEIGHT)
|
||||
val means = floatArrayOf(0.5f, 0.5f, 0.5f)
|
||||
val stds = floatArrayOf(0.5f, 0.5f, 0.5f)
|
||||
for (c in 0..2) {
|
||||
val mean = means[c]
|
||||
val std = stds[c]
|
||||
for (y in 0 until REC_IMAGE_HEIGHT) {
|
||||
for (x in 0 until w) {
|
||||
val px = pixels[y * w + x]
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
data[c * w * REC_IMAGE_HEIGHT + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resized !== bmp) resized.recycle()
|
||||
return TensorData(data, w, REC_IMAGE_HEIGHT)
|
||||
}
|
||||
|
||||
// ============== DB 后处理(简化版) ==============
|
||||
// 参考 PaddleOCR db_postprocess:sigmoid → threshold → 连通域 → 最小外接矩形
|
||||
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
|
||||
|
||||
/**
|
||||
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
|
||||
* 简化版:用水平投影切行 + 垂直投影切列,得到矩形框(对账单类版面够用)。
|
||||
*/
|
||||
private fun dbPostprocess(prob: Array<FloatArray>, h: Int, w: Int, ratioX: Float, ratioY: Float): List<List<FloatArray>> {
|
||||
// 自动检测是否需要 Sigmoid
|
||||
var minVal = Float.MAX_VALUE
|
||||
var maxVal = Float.MIN_VALUE
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val v = prob[y][x]
|
||||
if (v < minVal) minVal = v
|
||||
if (v > maxVal) maxVal = v
|
||||
}
|
||||
}
|
||||
val needSigmoid = minVal < -0.05f || maxVal > 1.05f
|
||||
Log.i(OCR_MODULE_NAME, "DB prob min=$minVal, max=$maxVal, needSigmoid=$needSigmoid")
|
||||
|
||||
// 过滤状态栏(前8%)和导航栏(后8%),避免其上的干扰字符(如电池、时间、返回键)影响识别或导致行粘连
|
||||
val startY = (h * 0.08).toInt()
|
||||
val endY = (h * 0.92).toInt()
|
||||
|
||||
val binMask = Array(h) { IntArray(w) }
|
||||
var activeCountTotal = 0
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
if (y < startY || y > endY) {
|
||||
binMask[y][x] = 0
|
||||
continue
|
||||
}
|
||||
val raw = prob[y][x]
|
||||
val sig = if (needSigmoid) {
|
||||
1.0f / (1.0f + Math.exp(-raw.toDouble()).toFloat())
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
val isActive = if (sig > DET_THRESH) 1 else 0
|
||||
binMask[y][x] = isActive
|
||||
if (isActive == 1) activeCountTotal++
|
||||
}
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "二值化完成: 活跃像素 = $activeCountTotal / ${w * h}")
|
||||
|
||||
// 清理垂直干扰线(如滚动条、背景边框线):如果某列在文本有效区域内的活跃像素超过该区域高度的 30%,视为干扰列,整列清零
|
||||
val maxColActive = ((endY - startY) * 0.3).toInt()
|
||||
var clearedColsCount = 0
|
||||
for (x in 0 until w) {
|
||||
var colActive = 0
|
||||
for (y in startY..endY) {
|
||||
if (binMask[y][x] == 1) colActive++
|
||||
}
|
||||
if (colActive > maxColActive) {
|
||||
clearedColsCount++
|
||||
for (y in 0 until h) {
|
||||
binMask[y][x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w 列")
|
||||
|
||||
// 水平投影:按行找文本行
|
||||
val rowHits = IntArray(h)
|
||||
for (y in 0 until h) {
|
||||
var sum = 0
|
||||
for (x in 0 until w) sum += binMask[y][x]
|
||||
rowHits[y] = sum
|
||||
}
|
||||
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
|
||||
val rowRanges = mutableListOf<IntArray>()
|
||||
var inLine = false
|
||||
var lineStart = 0
|
||||
for (y in 0 until h) {
|
||||
val isText = rowHits[y] >= minRowWidth
|
||||
if (isText && !inLine) { inLine = true; lineStart = y }
|
||||
else if (!isText && inLine) {
|
||||
rowRanges.add(intArrayOf(lineStart, y - 1))
|
||||
inLine = false
|
||||
}
|
||||
}
|
||||
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
|
||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}")
|
||||
|
||||
val boxes = mutableListOf<List<FloatArray>>()
|
||||
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
|
||||
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
|
||||
val colHits = IntArray(w)
|
||||
for (x in 0 until w) {
|
||||
var sum = 0
|
||||
for (y in y0..y1) sum += binMask[y][x]
|
||||
colHits[x] = sum
|
||||
}
|
||||
val minColHeight = max(1, (y1 - y0 + 1) / 12)
|
||||
var inSeg = false
|
||||
var segStart = 0
|
||||
var segs = mutableListOf<IntArray>()
|
||||
for (x in 0 until w) {
|
||||
val isText = colHits[x] >= minColHeight
|
||||
if (isText && !inSeg) { inSeg = true; segStart = x }
|
||||
else if (!isText && inSeg) {
|
||||
// 合并间隔很近的段
|
||||
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
|
||||
segs.last()[1] = x - 1
|
||||
} else {
|
||||
segs.add(intArrayOf(segStart, x - 1))
|
||||
}
|
||||
inSeg = false
|
||||
}
|
||||
}
|
||||
if (inSeg) {
|
||||
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
|
||||
segs.last()[1] = w - 1
|
||||
} else {
|
||||
segs.add(intArrayOf(segStart, w - 1))
|
||||
}
|
||||
}
|
||||
for ((x0, x1) in segs.map { it[0] to it[1] }) {
|
||||
// 过滤过小的框
|
||||
val boxW = x1 - x0 + 1
|
||||
val boxH = y1 - y0 + 1
|
||||
if (boxW < 4 || boxH < 2) continue
|
||||
// 映射回原图坐标(4 个角点)
|
||||
val fx0 = x0 * ratioX
|
||||
val fx1 = x1 * ratioX
|
||||
val fy0 = y0 * ratioY
|
||||
val fy1 = y1 * ratioY
|
||||
boxes.add(listOf(
|
||||
floatArrayOf(fx0, fy0),
|
||||
floatArrayOf(fx1, fy0),
|
||||
floatArrayOf(fx1, fy1),
|
||||
floatArrayOf(fx0, fy1),
|
||||
))
|
||||
}
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
|
||||
// ============== CTC 解码 ==============
|
||||
|
||||
/**
|
||||
* CTC greedy decode:每个时间步取 argmax,去 blank 去重复。返回 (text, avgConfidence)。
|
||||
*
|
||||
* PaddleOCR 约定:logits 的 index 0 固定是 blank,字符从 index 1 起,
|
||||
* dictionary[i] 对应模型输出 index i+1。因此 dictIdx = argmaxIdx - 1。
|
||||
* 已用 onnxruntime 实证:argmax 序列中 0 占多数(即 blank),真实字符索引
|
||||
* (如 90→'支')按 idx-1 映射到 dictionary 即可正确还原中文。
|
||||
*/
|
||||
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
|
||||
if (logits.isEmpty()) return "" to 0f
|
||||
val numClasses = logits[0].size
|
||||
val blankIdx = 0 // PaddleOCR CTC:blank 固定在 index 0
|
||||
|
||||
val sb = StringBuilder()
|
||||
var lastIdx = -1
|
||||
var confSum = 0.0f
|
||||
var confCount = 0
|
||||
for (t in logits.indices) {
|
||||
var maxIdx = 0
|
||||
var maxVal = logits[t][0]
|
||||
for (i in 1 until numClasses) {
|
||||
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
|
||||
}
|
||||
// softmax 概率(用于置信度统计)
|
||||
var expSum = 0.0
|
||||
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
|
||||
val prob = Math.exp(maxVal.toDouble()) / expSum
|
||||
|
||||
if (maxIdx != blankIdx && maxIdx != lastIdx) {
|
||||
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
|
||||
if (dictIdx in 0 until dictionary.size) {
|
||||
sb.append(dictionary[dictIdx])
|
||||
confSum += prob.toFloat()
|
||||
confCount++
|
||||
}
|
||||
}
|
||||
lastIdx = maxIdx
|
||||
}
|
||||
val avgConf = if (confCount > 0) confSum / confCount else 0f
|
||||
return sb.toString() to avgConf
|
||||
}
|
||||
|
||||
// ============== Bitmap 工具 ==============
|
||||
|
||||
private fun cropBox(bmp: Bitmap, box: List<FloatArray>): Bitmap? {
|
||||
val xs = box.map { it[0] }
|
||||
val ys = box.map { it[1] }
|
||||
val paddingX = 4
|
||||
val paddingY = 2
|
||||
val minX = max(0, (xs.minOrNull() ?: 0f).toInt() - paddingX)
|
||||
val minY = max(0, (ys.minOrNull() ?: 0f).toInt() - paddingY)
|
||||
val maxX = min(bmp.width, ((xs.maxOrNull() ?: 0f) + 1).toInt() + paddingX)
|
||||
val maxY = min(bmp.height, ((ys.maxOrNull() ?: 0f) + 1).toInt() + paddingY)
|
||||
val w = maxX - minX
|
||||
val h = maxY - minY
|
||||
if (w < 2 || h < 2) return null
|
||||
return Bitmap.createBitmap(bmp, minX, minY, w, h)
|
||||
}
|
||||
|
||||
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
|
||||
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
|
||||
if (ratio >= 1f) return bmp
|
||||
val newW = (bmp.width * ratio).toInt()
|
||||
val newH = (bmp.height * ratio).toInt()
|
||||
// 确保尺寸是 32 的倍数(det 模型下采样要求)
|
||||
val alignedW = (newW / 32) * 32
|
||||
val alignedH = (newH / 32) * 32
|
||||
if (alignedW < 32 || alignedH < 32) return bmp
|
||||
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
|
||||
}
|
||||
|
||||
// ============== 公共工具(复用) ==============
|
||||
|
||||
/** 解码 base64 图片为 Bitmap。 */
|
||||
private fun decodeBase64(base64: String): Bitmap? {
|
||||
return try {
|
||||
// 去除 data:image/...;base64, 前缀
|
||||
val data = if (base64.contains(",")) base64.substringAfter(",") else base64
|
||||
val bytes = Base64.decode(data, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "base64 解码失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr)。
|
||||
* 像素量比 1440p 减少约 75%,识别速度大幅提升。
|
||||
*/
|
||||
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val shortEdge = minOf(width, height)
|
||||
if (shortEdge <= maxShortEdge) return bitmap
|
||||
val scale = maxShortEdge.toFloat() / shortEdge
|
||||
val newWidth = (width * scale).toInt()
|
||||
val newHeight = (height * scale).toInt()
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
}
|
||||
|
||||
override fun onCatalystInstanceDestroy() {
|
||||
super.onCatalystInstanceDestroy()
|
||||
release()
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
release()
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
lock.lock()
|
||||
try {
|
||||
detSession?.close()
|
||||
recSession?.close()
|
||||
// OrtEnvironment 是单例,不主动 close(进程级)
|
||||
detSession = null
|
||||
recSession = null
|
||||
ortEnv = null
|
||||
initialized = false
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/** 识别结果块。 */
|
||||
private data class OcrBlock(val text: String, val x: Float, val y: Float, val width: Float, val height: Float, val confidence: Float)
|
||||
/** 预处理后的张量数据 + 宽高。 */
|
||||
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
|
||||
|
||||
companion object {
|
||||
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */
|
||||
private const val OCR_MAX_SHORT_EDGE = 720
|
||||
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */
|
||||
private const val DET_LIMIT_MAX_SIDE = 960
|
||||
/** DB 二值化阈值。 */
|
||||
private const val DET_THRESH = 0.3f
|
||||
/** 投影法合并相邻文本段的间隔(像素)。 */
|
||||
private const val DET_MERGE_GAP = 10
|
||||
/** rec 固定图像高度。 */
|
||||
private const val REC_IMAGE_HEIGHT = 48
|
||||
/** rec 单行最大宽度。 */
|
||||
private const val REC_MAX_WIDTH = 320
|
||||
/** assets 中的模型/字典文件名。 */
|
||||
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
|
||||
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
|
||||
// PP-OCRv5 多语言识别模型的配套字典(18383 字符 + 运行时 1 blank = 18385 维输出)。
|
||||
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt(6623)会导致 CTC 解码乱码。
|
||||
private const val ASSET_DICT = "ppocrv5_dict.txt"
|
||||
}
|
||||
}
|
||||
24
plugins/ppocr/android/OcrPackage.kt
Normal file
24
plugins/ppocr/android/OcrPackage.kt
Normal file
@ -0,0 +1,24 @@
|
||||
package com.beancount.mobile.ppocr
|
||||
|
||||
import android.view.View
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ReactShadowNode
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* 注册 OcrModule 到 React Native 的 Package(plan.md「3.2 Config Plugin」)。
|
||||
*
|
||||
* 由 app.plugin.js 的 withMainApplication 注入到 MainApplication.getPackages() 列表。
|
||||
* RN 在启动时遍历所有 Package,调用 createNativeModules 注册原生模块。
|
||||
*/
|
||||
class OcrPackage : ReactPackage {
|
||||
override fun createNativeModules(rc: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(OcrModule(rc))
|
||||
}
|
||||
|
||||
override fun createViewManagers(rc: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
119
plugins/ppocr/app.plugin.js
Normal file
119
plugins/ppocr/app.plugin.js
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* PP-OCRv5 (ONNX Runtime) Config Plugin(plan.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 到 MainApplication(getPackages)
|
||||
* 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)) {
|
||||
// 新架构 Kotlin(SDK 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;
|
||||
BIN
plugins/ppocr/assets/ppocrv5_det.onnx
Normal file
BIN
plugins/ppocr/assets/ppocrv5_det.onnx
Normal file
Binary file not shown.
18383
plugins/ppocr/assets/ppocrv5_dict.txt
Normal file
18383
plugins/ppocr/assets/ppocrv5_dict.txt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
plugins/ppocr/assets/ppocrv5_rec.onnx
Normal file
BIN
plugins/ppocr/assets/ppocrv5_rec.onnx
Normal file
Binary file not shown.
6
plugins/ppocr/package.json
Normal file
6
plugins/ppocr/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-ppocr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
90
plugins/screenshot-monitor/android/ScreenshotModule.kt
Normal file
90
plugins/screenshot-monitor/android/ScreenshotModule.kt
Normal file
@ -0,0 +1,90 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.provider.MediaStore
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 截图监控 RN Module(plan.md「3.13 截图自动记账通道」)。
|
||||
*
|
||||
* 由 JS 端调用 start()/stop() 控制 ContentObserver 的注册/注销。
|
||||
* 检测到截图时读取 base64 并通过 DeviceEventEmitter 发送到 JS。
|
||||
*
|
||||
* 事件格式(WritableNativeMap)与 BillingAccessibilityService.sendScreenshotEvent 一致:
|
||||
* { base64: "data:image/jpeg;base64,...", packageName: "...", timestamp: Long, displayName: "..." }
|
||||
*/
|
||||
class ScreenshotModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private var observer: ScreenshotObserver? = null
|
||||
|
||||
override fun getName() = "ScreenshotMonitor"
|
||||
|
||||
override fun invalidate() {
|
||||
stop()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动截图监控。
|
||||
* 在主线程注册 ContentObserver,检测到截图时发送 billingScreenshot 事件。
|
||||
*/
|
||||
@ReactMethod
|
||||
fun start() {
|
||||
if (observer != null) return
|
||||
// 确保 ReactContextHolder 有上下文
|
||||
ReactContextHolder.context = reactContext
|
||||
observer = ScreenshotObserver(reactContext) { uri ->
|
||||
sendScreenshotEvent(uri)
|
||||
}
|
||||
observer?.register()
|
||||
}
|
||||
|
||||
/** 停止截图监控。 */
|
||||
@ReactMethod
|
||||
fun stop() {
|
||||
observer?.unregister()
|
||||
observer = null
|
||||
}
|
||||
|
||||
/** 读取截图 base64 并发送到 JS(WritableNativeMap 格式,与无障碍服务一致)。 */
|
||||
private fun sendScreenshotEvent(uri: Uri) {
|
||||
try {
|
||||
val resolver = reactContext.contentResolver
|
||||
// 读取图片为 base64
|
||||
val inputStream = resolver.openInputStream(uri) ?: return
|
||||
val bytes = inputStream.use { it.readBytes() }
|
||||
inputStream.close()
|
||||
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
|
||||
// 同时查询显示名
|
||||
val projection = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
var displayName = ""
|
||||
resolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
displayName = cursor.getString(0) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
val map = WritableNativeMap()
|
||||
map.putString("base64", base64)
|
||||
map.putString("uri", uri.toString())
|
||||
map.putString("packageName", "")
|
||||
map.putString("displayName", displayName)
|
||||
map.putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingScreenshot", map)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ScreenshotModule", "发送截图事件失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
116
plugins/screenshot-monitor/android/ScreenshotObserver.kt
Normal file
116
plugins/screenshot-monitor/android/ScreenshotObserver.kt
Normal file
@ -0,0 +1,116 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* 截图监听 ContentObserver(plan.md「3.13 截图自动记账通道」)。
|
||||
*
|
||||
* 参考 BeeCount 的 ScreenshotObserver.kt:
|
||||
* - 监听 MediaStore.Images.Media.EXTERNAL_CONTENT_URI 变化
|
||||
* - 关键词匹配(screenshot/截屏/截图/screen_shot)
|
||||
* - 30 秒时间窗(过滤旧截图)
|
||||
* - processedPaths 去重(最多 200 条)
|
||||
* - 过滤小米 .pending- 临时文件
|
||||
* - 500ms 防抖
|
||||
*
|
||||
* 由 Config Plugin 注册,触发后通过 DeviceEventEmitter 推送到 JS 层 ScreenshotChannel。
|
||||
*
|
||||
* ⚠️ 与 src/domain/constants.ts 同步:TIME_WINDOW_MS、MAX_PROCESSED、SCREENSHOT_KEYWORDS
|
||||
* 修改时需两边同时更新。
|
||||
*/
|
||||
class ScreenshotObserver(
|
||||
private val context: Context,
|
||||
private val handler: Handler = Handler(Looper.getMainLooper()),
|
||||
private val onScreenshot: (uri: Uri) -> Unit,
|
||||
) : ContentObserver(handler) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScreenshotObserver"
|
||||
private const val TIME_WINDOW_MS = 30_000L
|
||||
private const val MAX_PROCESSED = 200
|
||||
private val SCREENSHOT_KEYWORDS = listOf("screenshot", "截屏", "截图", "screen_shot", "Screenshot")
|
||||
}
|
||||
|
||||
private val resolver = context.contentResolver
|
||||
private val processedPaths = LinkedHashSet<String>()
|
||||
@Volatile private var lastCheckTime = System.currentTimeMillis()
|
||||
|
||||
/** 注册监听。 */
|
||||
fun register() {
|
||||
resolver.registerContentObserver(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
this,
|
||||
)
|
||||
Log.i(TAG, "截图监听已注册")
|
||||
}
|
||||
|
||||
/** 注销监听。 */
|
||||
fun unregister() {
|
||||
resolver.unregisterContentObserver(this)
|
||||
Log.i(TAG, "截图监听已注销")
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
super.onChange(selfChange, uri)
|
||||
uri ?: return
|
||||
handler.post { processNewScreenshot(uri) }
|
||||
}
|
||||
|
||||
private fun processNewScreenshot(uri: Uri) {
|
||||
try {
|
||||
// 查询截图信息
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATA,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return
|
||||
val path = cursor.getString(0) ?: ""
|
||||
val dateAdded = cursor.getLong(1)
|
||||
val displayName = cursor.getString(2) ?: ""
|
||||
|
||||
// 1. 时间窗过滤(30 秒内)
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
if (now - dateAdded > TIME_WINDOW_MS / 1000) {
|
||||
Log.d(TAG, "忽略旧截图(超过30秒): $displayName")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 关键词匹配(必须是截图)
|
||||
if (!SCREENSHOT_KEYWORDS.any { kw -> displayName.contains(kw, true) || path.contains(kw, true) }) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 过滤小米 .pending- 临时文件
|
||||
if (path.endsWith(".pending-") || path.contains(".pending-")) {
|
||||
Log.d(TAG, "过滤小米 pending 临时文件: $displayName")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 去重
|
||||
if (processedPaths.contains(path)) return
|
||||
processedPaths.add(path)
|
||||
if (processedPaths.size > MAX_PROCESSED) {
|
||||
val it = processedPaths.iterator()
|
||||
if (it.hasNext()) {
|
||||
it.next()
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "检测到新截图: $displayName")
|
||||
onScreenshot(uri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理截图异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
plugins/screenshot-monitor/android/ScreenshotPackage.kt
Normal file
20
plugins/screenshot-monitor/android/ScreenshotPackage.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* ReactPackage 注册 ScreenshotModule。
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(ScreenshotPackage()) 到 MainApplication。
|
||||
*/
|
||||
class ScreenshotPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ScreenshotModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
88
plugins/screenshot-monitor/app.plugin.js
Normal file
88
plugins/screenshot-monitor/app.plugin.js
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 截图监听 Config Plugin(plan.md「3.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;
|
||||
6
plugins/screenshot-monitor/package.json
Normal file
6
plugins/screenshot-monitor/package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-screenshot-monitor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
48
plugins/size-optimization/app.plugin.js
Normal file
48
plugins/size-optimization/app.plugin.js
Normal file
@ -0,0 +1,48 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function withSizeOptimization(config) {
|
||||
// 1. 在 prebuild 时修改 gradle.properties
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||
const propertiesPath = path.join(projectRoot, 'gradle.properties');
|
||||
if (fs.existsSync(propertiesPath)) {
|
||||
let content = fs.readFileSync(propertiesPath, 'utf8');
|
||||
if (content.includes('reactNativeArchitectures=')) {
|
||||
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
||||
} else {
|
||||
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
||||
}
|
||||
fs.writeFileSync(propertiesPath, content, 'utf8');
|
||||
}
|
||||
return modConfig;
|
||||
}
|
||||
]);
|
||||
|
||||
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
|
||||
if (fs.existsSync(buildGradlePath)) {
|
||||
let content = fs.readFileSync(buildGradlePath, 'utf8');
|
||||
if (!content.includes('splits {')) {
|
||||
content = content.replace(
|
||||
/android\s*\{/,
|
||||
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
|
||||
);
|
||||
fs.writeFileSync(buildGradlePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
return modConfig;
|
||||
}
|
||||
]);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = withSizeOptimization;
|
||||
4
plugins/size-optimization/package.json
Normal file
4
plugins/size-optimization/package.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "size-optimization",
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
73
plugins/sms-receiver/android/BillingSmsReceiver.kt
Normal file
73
plugins/sms-receiver/android/BillingSmsReceiver.kt
Normal file
@ -0,0 +1,73 @@
|
||||
package com.beancount.mobile.sms
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.telephony.SmsMessage
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 短信监听 Receiver(plan.md「4.2 短信监听服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 SmsReceiver:
|
||||
* - 监听 SMS_RECEIVED_ACTION
|
||||
* - 从 PDU 解析发送方 + 正文
|
||||
* - 关键词预过滤(银行短信含「交易/消费/余额」等,JS 层进一步过滤)
|
||||
* - 通过 DeviceEventEmitter 推送到 JS 层 SmsChannel
|
||||
*
|
||||
* 需在 manifest 注册 RECEIVE_SMS 权限(由 Config Plugin 注入)。
|
||||
*/
|
||||
class BillingSmsReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingSms"
|
||||
|
||||
/** 银行短信关键词(预过滤,减少 JS 层负担)。 */
|
||||
private val BANK_KEYWORDS = listOf("交易", "消费", "收入", "支出", "余额", "转账", "入账", "扣款", "退款")
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
|
||||
|
||||
runCatching {
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
|
||||
for (message in messages) {
|
||||
val sender = message.displayOriginatingAddress ?: ""
|
||||
val body = message.displayMessageBody ?: ""
|
||||
if (sender.isBlank() || body.isBlank()) continue
|
||||
|
||||
// 关键词预过滤(仅处理疑似银行短信)
|
||||
if (!BANK_KEYWORDS.any { body.contains(it) }) continue
|
||||
|
||||
Log.d(TAG, "收到银行短信: sender=$sender")
|
||||
sendSmsEvent(sender, body)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "短信处理异常: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把短信事件推送到 JS 层 SmsChannel.handleSms。 */
|
||||
private fun sendSmsEvent(sender: String, body: String) {
|
||||
val reactContext = ReactContextHolder.context ?: run {
|
||||
Log.w(TAG, "RN 上下文未就绪,丢弃短信")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("sender", sender)
|
||||
putString("body", body)
|
||||
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingSms", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送短信事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
80
plugins/sms-receiver/app.plugin.js
Normal file
80
plugins/sms-receiver/app.plugin.js
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 短信监听 Config Plugin(plan.md「4.2 短信监听服务」+「决策 4」)。
|
||||
*
|
||||
* 在 expo prebuild 时注册 Android SmsReceiver(manifest 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;
|
||||
6
plugins/sms-receiver/package.json
Normal file
6
plugins/sms-receiver/package.json
Normal 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
98
src/ai/chatAssistant.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* AI 聊天助手(plan.md「8.1 AI 聊天助手」)。
|
||||
*
|
||||
* 参考 BeeCount 的 AIChatService(GLM-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
94
src/ai/monthlySummary.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* AI 月度总结(plan.md「8.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
186
src/ai/voiceInput.ts
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 语音记账(plan.md「8.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;
|
||||
}
|
||||
}
|
||||
54
src/app/(tabs)/_layout.tsx
Normal file
54
src/app/(tabs)/_layout.tsx
Normal 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
231
src/app/(tabs)/index.tsx
Normal 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
446
src/app/(tabs)/report.tsx
Normal file
@ -0,0 +1,446 @@
|
||||
/**
|
||||
* 报表页(plan.md「5.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 },
|
||||
});
|
||||
94
src/app/(tabs)/settings.tsx
Normal file
94
src/app/(tabs)/settings.tsx
Normal 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' },
|
||||
});
|
||||
237
src/app/(tabs)/transactions.tsx
Normal file
237
src/app/(tabs)/transactions.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 交易列表页(plan.md「1.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
120
src/app/_error.tsx
Normal 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
425
src/app/_layout.tsx
Normal 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
149
src/app/_onboarding.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 引导流程 Onboarding(plan.md「0.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
313
src/app/account/index.tsx
Normal 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
202
src/app/ai/chat.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
/**
|
||||
* AI 聊天助手(plan.md「8.1 AI 聊天助手」)。
|
||||
*
|
||||
* 功能:
|
||||
* - 自然语言记账("昨天星巴克花了35" → 生成账单卡片)
|
||||
* - 自由聊天(AI 回复)
|
||||
* - 账单卡片点击跳转 new.tsx 预填
|
||||
*
|
||||
* 使用 chatAssistant.processChatMessage + BaseOpenAIProvider(OpenAI 兼容协议)。
|
||||
* 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' },
|
||||
});
|
||||
432
src/app/automation/index.tsx
Normal file
432
src/app/automation/index.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
/**
|
||||
* 自动记账管理页(plan.md「3.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
161
src/app/budget/index.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 预算管理页面(plan.md「1.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
135
src/app/category/index.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。
|
||||
*
|
||||
* 功能:分类列表(支出/收入分组)+ 添加/编辑/删除。
|
||||
* 数据来源:metadataStore(持久化),linkedAccount 映射到 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 },
|
||||
});
|
||||
180
src/app/credit-card/index.tsx
Normal file
180
src/app/credit-card/index.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 信用卡管理页面(plan.md「1.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
399
src/app/import/index.tsx
Normal 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
191
src/app/recurring/index.tsx
Normal 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 },
|
||||
});
|
||||
106
src/app/remark-template/index.tsx
Normal file
106
src/app/remark-template/index.tsx
Normal 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
143
src/app/rules/index.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 规则管理页面(plan.md「1.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
156
src/app/settings/ai.tsx
Normal 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 },
|
||||
});
|
||||
218
src/app/settings/preferences.tsx
Normal file
218
src/app/settings/preferences.tsx
Normal 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
546
src/app/settings/sync.tsx
Normal 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
141
src/app/tag/index.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 标签管理页面(plan.md「1.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 },
|
||||
});
|
||||
429
src/app/transaction/[id].tsx
Normal file
429
src/app/transaction/[id].tsx
Normal file
@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 交易详情页(plan.md「1.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
792
src/app/transaction/new.tsx
Normal 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 },
|
||||
});
|
||||
47
src/components/AccountTree.tsx
Normal file
47
src/components/AccountTree.tsx
Normal 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 },
|
||||
});
|
||||
33
src/components/BalanceIndicator.tsx
Normal file
33
src/components/BalanceIndicator.tsx
Normal 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
73
src/components/Button.tsx
Normal 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 },
|
||||
});
|
||||
124
src/components/CalendarView.tsx
Normal file
124
src/components/CalendarView.tsx
Normal 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
103
src/components/Card.tsx
Normal 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 },
|
||||
});
|
||||
118
src/components/CategoryPicker.tsx
Normal file
118
src/components/CategoryPicker.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
59
src/components/DedupBanner.tsx
Normal file
59
src/components/DedupBanner.tsx
Normal 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 },
|
||||
});
|
||||
148
src/components/FormModal.tsx
Normal file
148
src/components/FormModal.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
260
src/components/LockScreen.tsx
Normal file
260
src/components/LockScreen.tsx
Normal 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 },
|
||||
});
|
||||
69
src/components/PostingEditor.tsx
Normal file
69
src/components/PostingEditor.tsx
Normal 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 },
|
||||
});
|
||||
45
src/components/SearchBar.tsx
Normal file
45
src/components/SearchBar.tsx
Normal 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 },
|
||||
});
|
||||
195
src/components/SpeedDial.tsx
Normal file
195
src/components/SpeedDial.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
53
src/components/TagPicker.tsx
Normal file
53
src/components/TagPicker.tsx
Normal 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 },
|
||||
});
|
||||
69
src/components/TransactionCard.tsx
Normal file
69
src/components/TransactionCard.tsx
Normal 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' },
|
||||
});
|
||||
64
src/components/charts/AnnualReport.tsx
Normal file
64
src/components/charts/AnnualReport.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 年度报告图表(plan.md「5.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 },
|
||||
});
|
||||
73
src/components/charts/CalendarHeatmap.tsx
Normal file
73
src/components/charts/CalendarHeatmap.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 日历热力图(plan.md「5.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 },
|
||||
});
|
||||
56
src/components/charts/CategoryPie.tsx
Normal file
56
src/components/charts/CategoryPie.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 分类占比图(plan.md「5.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 },
|
||||
});
|
||||
45
src/components/charts/MonthlyReport.tsx
Normal file
45
src/components/charts/MonthlyReport.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 月度报告图表(plan.md「5.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 },
|
||||
});
|
||||
49
src/components/charts/NetWorthChart.tsx
Normal file
49
src/components/charts/NetWorthChart.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 净资产趋势图(plan.md「5.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 },
|
||||
});
|
||||
189
src/components/charts/TrendLine.tsx
Normal file
189
src/components/charts/TrendLine.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 趋势折线图(plan.md「5.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
127
src/domain/accountTree.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 账户树与报表计算(plan.md「5.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
93
src/domain/adapters.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 银行 CSV 适配器(plan.md「2.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
194
src/domain/ai.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* AI 功能抽象(plan.md「Phase 8 AI 功能增强」+「8.0 AI Provider 抽象层」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 BaseOpenAIProvider(8 个 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建账单识别 prompt(plan.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)、counterparty、narration(≤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) },
|
||||
];
|
||||
}
|
||||
|
||||
/** 月度总结 prompt(plan.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(', ')}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 分类识别 prompt(plan.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}`) },
|
||||
];
|
||||
}
|
||||
|
||||
/** 资产映射 prompt(plan.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
126
src/domain/annualReport.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 年度报告(plan.md「5.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
188
src/domain/billPipeline.ts
Normal 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.md「2.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
97
src/domain/budgets.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 预算管理(plan.md「5.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')}`;
|
||||
}
|
||||
78
src/domain/calendarGrid.ts
Normal file
78
src/domain/calendarGrid.ts
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 日历网格计算(plan.md「5.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
101
src/domain/categories.ts
Normal 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);
|
||||
}
|
||||
82
src/domain/channelConfig.ts
Normal file
82
src/domain/channelConfig.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 渠道配置(双轨制:渠道 → Beancount 账户映射)。
|
||||
*
|
||||
* 渠道是支付平台标识(Alipay/WeChat/Bank),账户是 Beancount 科目(Assets:支付宝余额)。
|
||||
* 导入时通过渠道配置将渠道映射到正确的账户,而非硬编码。
|
||||
*
|
||||
* 设计见 plan.md「决策 1 双轨制」。
|
||||
*/
|
||||
|
||||
export interface ChannelConfig {
|
||||
id: string;
|
||||
/** 渠道名(与 ImportedEvent.channel 对齐,如 "Alipay", "WeChat", "Bank")。 */
|
||||
name: string;
|
||||
/** 默认渠道账户(Beancount 科目,如 "Assets:支付宝余额")。 */
|
||||
defaultAccount: string;
|
||||
/** Android 包名(用于自动识别,如 "com.eg.android.AlipayGphone")。 */
|
||||
packageNames?: string[];
|
||||
/** UI 图标(可选)。 */
|
||||
icon?: string;
|
||||
/** UI 颜色(可选)。 */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** 内置渠道默认配置(不可删除,用户可修改 defaultAccount)。 */
|
||||
export const BUILTIN_CHANNELS: ChannelConfig[] = [
|
||||
{
|
||||
id: 'ch_alipay',
|
||||
name: 'Alipay',
|
||||
defaultAccount: 'Assets:支付宝余额',
|
||||
packageNames: ['com.eg.android.AlipayGphone', 'com.eg.android.AlipayGphone.wallet'],
|
||||
icon: 'alipay',
|
||||
color: '#1677FF',
|
||||
},
|
||||
{
|
||||
id: 'ch_wechat',
|
||||
name: 'WeChat',
|
||||
defaultAccount: 'Assets:微信零钱',
|
||||
packageNames: ['com.tencent.mm', 'com.tencent.mobileqq'],
|
||||
icon: 'wechat',
|
||||
color: '#07C160',
|
||||
},
|
||||
{
|
||||
id: 'ch_bank',
|
||||
name: 'Bank',
|
||||
defaultAccount: 'Assets:银行卡',
|
||||
packageNames: ['com.unionpay', 'com.cmbchina', 'com.icbc'],
|
||||
icon: 'bank',
|
||||
color: '#E53935',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 从包名反查渠道配置。
|
||||
* 优先查用户配置,再查内置配置。
|
||||
*/
|
||||
export function resolveChannelByPackage(
|
||||
packageName: string,
|
||||
channels: ChannelConfig[],
|
||||
): ChannelConfig | undefined {
|
||||
// 先查用户自定义渠道
|
||||
for (const ch of channels) {
|
||||
if (ch.packageNames?.includes(packageName)) return ch;
|
||||
}
|
||||
// 再查内置渠道
|
||||
for (const ch of BUILTIN_CHANNELS) {
|
||||
if (ch.packageNames?.includes(packageName)) return ch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从渠道名查找渠道配置。
|
||||
* 优先查用户配置,再查内置配置。
|
||||
*/
|
||||
export function resolveChannelByName(
|
||||
name: string,
|
||||
channels: ChannelConfig[],
|
||||
): ChannelConfig | undefined {
|
||||
const lower = name.toLowerCase();
|
||||
const match = (ch: ChannelConfig) => ch.name.toLowerCase() === lower;
|
||||
return channels.find(match) ?? BUILTIN_CHANNELS.find(match);
|
||||
}
|
||||
63
src/domain/chartStats.ts
Normal file
63
src/domain/chartStats.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 图表统计纯函数(plan.md「5.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
86
src/domain/constants.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 统一规则常量(单一数据源)。
|
||||
*
|
||||
* 本模块集中定义跨文件共享的业务规则常量,消除重复定义。
|
||||
* 各模块 import 使用,不再各自硬编码。
|
||||
*/
|
||||
|
||||
import type { Direction } from './types';
|
||||
|
||||
// ─── 支付 App 包名 ───────────────────────────────────────────────
|
||||
|
||||
/** 包名 → 友好显示名(用于 UI 展示)。 */
|
||||
export const PAYMENT_PACKAGES: Record<string, string> = {
|
||||
'com.eg.android.AlipayGphone': '支付宝',
|
||||
'com.tencent.mm': '微信',
|
||||
'com.unionpay': '银联/云闪付',
|
||||
'com.cmbchina': '招商银行',
|
||||
'com.icbc': '工商银行',
|
||||
'com.chinamworld.main': '中国银行',
|
||||
'com.ccbrcb': '建设银行',
|
||||
'com.bankcomm.Bankcomm': '交通银行',
|
||||
'com.tencent.mobileqq': '手机QQ',
|
||||
'com.tencent.tim': 'TIM',
|
||||
};
|
||||
|
||||
/** 仅包名集合(用于快速查找)。 */
|
||||
export const PAYMENT_PACKAGE_SET = new Set(Object.keys(PAYMENT_PACKAGES));
|
||||
|
||||
// ─── 方向检测 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 统一方向检测函数(合并 OCR / SMS / CSV 各处的规则)。
|
||||
*
|
||||
* 优先级:退款 > 手续费 > 收入 > 支出 > 转账
|
||||
* 注意:避免「收款方」中的「收款」被误判为收入。
|
||||
*/
|
||||
export function detectDirection(text: string, defaultDir: Direction = 'expense'): Direction {
|
||||
if (/退款|退回/.test(text)) return 'refund';
|
||||
if (/手续费|利息/.test(text)) return 'fee';
|
||||
// 转入 = 钱到账户,视为收入(单边事件,不需要配对)
|
||||
if (/转入/.test(text)) return 'income';
|
||||
if (/收入|到账|入账/.test(text)) return 'income';
|
||||
// 优化:别人转给我的单向转账,应视为收入(收款端的单边入账)
|
||||
if (/向你转账|收到转账/.test(text)) return 'income';
|
||||
if (/支出|付款|消费|转出/.test(text)) return 'expense';
|
||||
// 「转账」方向不明确,标记为 transfer 供后续识别
|
||||
if (/转账/.test(text)) return 'transfer';
|
||||
// 「收款方」是商户标识,不判为收入;真正的收款是「向您收款」或独立「收款」
|
||||
if (/收款方/.test(text)) return defaultDir;
|
||||
if (/收款/.test(text)) return 'income';
|
||||
return defaultDir;
|
||||
}
|
||||
|
||||
// ─── 截屏检测 ─────────────────────────────────────────────────────
|
||||
|
||||
/** 截屏文件名关键词(Kotlin 侧需同步)。 */
|
||||
export const SCREENSHOT_KEYWORDS = ['screenshot', '截屏', '截图', 'screen_shot', 'Screenshot'];
|
||||
|
||||
/** 截屏时间窗口(毫秒),过滤旧截图。 */
|
||||
export const SCREENSHOT_TIME_WINDOW_MS = 30_000;
|
||||
|
||||
// ─── 去重 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** 去重缓存最大条目数(LRU 淘汰)。 */
|
||||
export const DEDUP_CACHE_MAX = 200;
|
||||
|
||||
/** OCR 图片哈希截取长度(取前 N 字符 hash 避免大图开销)。 */
|
||||
export const IMAGE_HASH_TRUNCATE_LEN = 1000;
|
||||
|
||||
// ─── 短信关键词 ───────────────────────────────────────────────────
|
||||
|
||||
/** 银行短信关键词(判断是否为账单短信)。 */
|
||||
export const SMS_BANK_KEYWORDS = ['交易', '消费', '收入', '支出', '余额', '转账', '入账', '扣款', '退款'];
|
||||
|
||||
// ─── 关键词过滤 ───────────────────────────────────────────────────
|
||||
|
||||
/** 关键词过滤白名单(必须包含任一关键词才通过)。 */
|
||||
export const KEYWORD_FILTER_WHITELIST = ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'];
|
||||
|
||||
/** 关键词过滤黑名单(包含任一关键词则拒绝)。 */
|
||||
export const KEYWORD_FILTER_BLACKLIST = ['广告', '推广', '优惠券', '红包封面', '会员到期', '活动'];
|
||||
|
||||
// ─── AI 聊天意图 ──────────────────────────────────────────────────
|
||||
|
||||
/** AI 聊天记账意图关键词。 */
|
||||
export const CHAT_TRANSACTION_KEYWORDS = ['买', '花', '消费', '支付', '记账', '付', '收入', '赚', '工资', '收'];
|
||||
75
src/domain/creditCards.ts
Normal file
75
src/domain/creditCards.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 信用卡管理(plan.md「5.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
94
src/domain/currency.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 多币种支持(plan.md「5.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
Loading…
Reference in New Issue
Block a user