feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
This commit is contained in:
parent
167adfca62
commit
f6437b83fe
9
.gitignore
vendored
9
.gitignore
vendored
@ -37,6 +37,15 @@ yarn-error.*
|
|||||||
# Outputs
|
# Outputs
|
||||||
outputs/
|
outputs/
|
||||||
|
|
||||||
|
# Example financial data (may contain real PII)
|
||||||
|
example/
|
||||||
|
|
||||||
|
# AI assistant config
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# MiMoCode
|
# MiMoCode
|
||||||
.mimocode/
|
.mimocode/
|
||||||
.agent/
|
.agent/
|
||||||
|
reference_project/
|
||||||
|
.zcode/
|
||||||
|
example/
|
||||||
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 } });
|
|
||||||
29
app.json
29
app.json
@ -3,8 +3,31 @@
|
|||||||
"name": "Bean Mobile",
|
"name": "Bean Mobile",
|
||||||
"slug": "bean-mobile",
|
"slug": "bean-mobile",
|
||||||
"scheme": "beanmobile",
|
"scheme": "beanmobile",
|
||||||
"plugins": [["expo-sqlite", { "enableFTS": true }]],
|
"plugins": [
|
||||||
"ios": { "bundleIdentifier": "com.example.beanmobile" },
|
[
|
||||||
"android": { "package": "com.example.beanmobile" }
|
"expo-sqlite",
|
||||||
|
{
|
||||||
|
"enableFTS": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-router",
|
||||||
|
"expo-localization",
|
||||||
|
"./plugins/ppocr",
|
||||||
|
"./plugins/accessibility",
|
||||||
|
"./plugins/notification-listener",
|
||||||
|
"./plugins/sms-receiver",
|
||||||
|
"./plugins/screenshot-monitor",
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"tsConfigPath": "tsconfig.json",
|
||||||
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "com.example.beanmobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.example.beanmobile"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
529
docs/android-build-guide.md
Normal file
529
docs/android-build-guide.md
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
# Android 编译与真机运行完整指南
|
||||||
|
|
||||||
|
> 本文档记录了从零开始搭建 Android 编译环境、修复 Config Plugin / Kotlin / 依赖冲突、到最终在真机上成功运行 debug APK 的完整流程。
|
||||||
|
>
|
||||||
|
> 适用环境:Windows 11 + Expo SDK 54 + React Native 0.81 + PP-OCRv5 (ONNX Runtime)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [环境准备](#1-环境准备)
|
||||||
|
2. [安装 Android SDK + NDK](#2-安装-android-sdk--ndk)
|
||||||
|
3. [ONNX 模型获取](#3-onnx-模型获取)
|
||||||
|
4. [expo prebuild](#4-expo-prebuild)
|
||||||
|
5. [Gradle 编译](#5-gradle-编译)
|
||||||
|
6. [安装到真机](#6-安装到真机)
|
||||||
|
7. [Metro + 运行](#7-metro--运行)
|
||||||
|
8. [常见问题与解决方案](#8-常见问题与解决方案)
|
||||||
|
9. [已修复的 Bug 清单](#9-已修复的-bug-清单)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 环境准备
|
||||||
|
|
||||||
|
### JDK
|
||||||
|
|
||||||
|
React Native 0.81 + Gradle 8.x 需要 **JDK 17+**。本项目使用 **JDK 21**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 验证
|
||||||
|
java -version
|
||||||
|
# 应输出: java version "21.0.x"
|
||||||
|
|
||||||
|
# 确认 JAVA_HOME 指向正确的 JDK
|
||||||
|
echo $JAVA_HOME
|
||||||
|
# 应输出: C:\Program Files\Java\jdk-21
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:如果 `JAVA_HOME` 指向了别的用户的残留目录(如 `D:\Users\yxp\JDK`),需要通过「系统属性 → 环境变量 → 系统变量」修正为实际 JDK 路径。
|
||||||
|
|
||||||
|
### Node.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v # 需要 v20+
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 安装 Android SDK + NDK
|
||||||
|
|
||||||
|
### 方式 A:Android Studio(推荐)
|
||||||
|
|
||||||
|
1. 下载 Android Studio:https://developer.android.com/studio
|
||||||
|
2. 安装时勾选 Android SDK / SDK Platform / Android Virtual Device
|
||||||
|
3. 首次启动自动下载 SDK 到默认位置 `C:\Users\<用户>\AppData\Local\Android\Sdk`
|
||||||
|
|
||||||
|
### 方式 B:命令行(轻量)
|
||||||
|
|
||||||
|
如果已装 Android Studio 但缺少 cmdline-tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SDK="$HOME/AppData/Local/Android/Sdk"
|
||||||
|
TMP="/tmp"
|
||||||
|
|
||||||
|
# 下载 cmdline-tools(官方,约 130MB)
|
||||||
|
curl -L -o "$TMP/cmdline-tools.zip" \
|
||||||
|
"https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip"
|
||||||
|
|
||||||
|
# 解压到标准位置
|
||||||
|
mkdir -p "$SDK/cmdline-tools"
|
||||||
|
unzip -q "$TMP/cmdline-tools.zip" -d "$TMP/"
|
||||||
|
cp -r "$TMP/cmdline-tools" "$SDK/cmdline-tools/latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 必装组件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME="C:/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
|
||||||
|
SDKMGR="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager.bat"
|
||||||
|
|
||||||
|
# 接受所有 license
|
||||||
|
yes | "$SDKMGR" --licenses
|
||||||
|
|
||||||
|
# 安装必需组件
|
||||||
|
yes | "$SDKMGR" \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-35" \
|
||||||
|
"build-tools;35.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `platform-tools` | adb(设备连接) |
|
||||||
|
| `platforms;android-35` | 编译目标 SDK |
|
||||||
|
| `build-tools;35.0.0` | aapt/dex/打包工具 |
|
||||||
|
| `cmdline-tools;latest` | sdkmanager(expo prebuild 需要) |
|
||||||
|
|
||||||
|
### NDK(React Native 编译 C++ 需要)
|
||||||
|
|
||||||
|
React Native 0.81 要求 **NDK 27.1.12297006**(~745MB)。
|
||||||
|
|
||||||
|
#### 国内推荐:腾讯云镜像下载
|
||||||
|
|
||||||
|
Google 官方源在国内经常断连导致 zip 损坏。**强烈推荐用腾讯云镜像**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SDK="$HOME/AppData/Local/Android/Sdk"
|
||||||
|
TMP="/tmp"
|
||||||
|
|
||||||
|
# 从腾讯云镜像下载(55MB/s 稳定,约 13 秒)
|
||||||
|
curl -L -o "$TMP/ndk.zip" \
|
||||||
|
"https://mirrors.cloud.tencent.com/AndroidSDK/android-ndk-r27b-windows.zip"
|
||||||
|
|
||||||
|
# 解压
|
||||||
|
mkdir -p "$TMP/ndk-extract"
|
||||||
|
unzip -q "$TMP/ndk.zip" -d "$TMP/ndk-extract"
|
||||||
|
|
||||||
|
# 安装到标准位置
|
||||||
|
mv "$TMP/ndk-extract/android-ndk-r27b" "$SDK/ndk/27.1.12297006"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### expo-sqlite 的 NDK 版本冲突
|
||||||
|
|
||||||
|
`expo-sqlite` 可能要求 NDK `27.0.12077973`(不同于 RN 要求的 `27.1.12297006`)。
|
||||||
|
解决方式:用 Windows 目录联接(junction)让两个版本指向同一份 NDK:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在 PowerShell 中执行
|
||||||
|
cmd /c mklink /J `
|
||||||
|
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.0.12077973" `
|
||||||
|
"C:\Users\<用户>\AppData\Local\Android\Sdk\ndk\27.1.12297006"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 两个版本号 ABI/API 兼容,联接不会导致编译问题。
|
||||||
|
|
||||||
|
#### NDK 完整性验证
|
||||||
|
|
||||||
|
NDK 解压后必须验证 arm64 目标库完整,否则 CMake 会报 "clang broken":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SDK="$HOME/AppData/Local/Android/Sdk"
|
||||||
|
NDK="$SDK/ndk/27.1.12297006"
|
||||||
|
|
||||||
|
# 关键文件必须存在
|
||||||
|
ls "$NDK/sysroot/usr/lib/aarch64-linux-android/24/crtbegin_dynamic.o" \
|
||||||
|
"$NDK/sysroot/usr/lib/aarch64-linux-android/libc.a" \
|
||||||
|
"$NDK/toolchains/llvm/prebuilt/windows-x86_64/lib/clang/18/lib/linux/libclang_rt.builtins-aarch64-android.a"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 如果 C 盘空间不足(NDK 解压后约 2.3GB),解压会中途截断,导致 `libc.a` 等文件缺失。确保至少有 **5GB 可用空间**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ONNX 模型获取
|
||||||
|
|
||||||
|
本项目使用 PP-OCRv5 + ONNX Runtime(替代 NCNN,见 plan.md 决策 7)。
|
||||||
|
|
||||||
|
### 下载社区 ONNX 模型
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/ppocr/assets
|
||||||
|
mkdir -p . && cd .
|
||||||
|
|
||||||
|
# det 模型(4.8 MB)
|
||||||
|
curl -L -o ppocrv5_det.onnx \
|
||||||
|
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx"
|
||||||
|
|
||||||
|
# rec 模型(16.6 MB)
|
||||||
|
curl -L -o ppocrv5_rec.onnx \
|
||||||
|
"https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx"
|
||||||
|
|
||||||
|
# CJK 字典(26 KB,PaddleOCR 标准字典)
|
||||||
|
curl -L -o ppocr_keys_v1.txt \
|
||||||
|
"https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证模型 I/O 规格
|
||||||
|
|
||||||
|
```python
|
||||||
|
import onnx
|
||||||
|
for name in ['det', 'rec']:
|
||||||
|
m = onnx.load(f'plugins/ppocr/assets/ppocrv5_{name}.onnx')
|
||||||
|
print(f'{name}:')
|
||||||
|
for i in m.graph.input:
|
||||||
|
shape = [d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]
|
||||||
|
print(f' input {i.name}: {shape}')
|
||||||
|
for o in m.graph.output:
|
||||||
|
shape = [d.dim_value or d.dim_param for d in o.type.tensor_type.shape.dim]
|
||||||
|
print(f' output {o.name}: {shape}')
|
||||||
|
```
|
||||||
|
|
||||||
|
预期输出:
|
||||||
|
- det: 输入 `x: [N, 3, H, W]`(动态),输出 `[N, 1, H, W]`
|
||||||
|
- rec: 输入 `x: [N, 3, 48, W]`(高固定 48),输出 `[N, T, 18385]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. expo prebuild
|
||||||
|
|
||||||
|
`expo prebuild` 把 Config Plugin 注入到原生工程(生成 `android/` 目录)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME="C:/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
|
||||||
|
|
||||||
|
npx expo prebuild --platform android --no-install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证注入结果
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kotlin 源码(应有 9 个 .kt 文件)
|
||||||
|
find android/app/src/main/java/com/beancount -name "*.kt" | sort
|
||||||
|
|
||||||
|
# ONNX 模型 + 字典
|
||||||
|
ls android/app/src/main/assets/
|
||||||
|
|
||||||
|
# MainApplication 里 OcrPackage 注册
|
||||||
|
grep "OcrPackage" android/app/src/main/java/com/example/beanmobile/MainApplication.kt
|
||||||
|
|
||||||
|
# onnxruntime gradle 依赖
|
||||||
|
grep "onnxruntime" android/app/build.gradle
|
||||||
|
|
||||||
|
# AndroidManifest 服务注册
|
||||||
|
grep "Billing" android/app/src/main/AndroidManifest.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### android/ 目录锁定问题(Windows + ZCode)
|
||||||
|
|
||||||
|
如果 `android/` 目录被进程锁定导致 prebuild 无法 `rmdir`,在临时副本项目中 prebuild 再 robocopy 回来:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建临时副本
|
||||||
|
TMP="/tmp/bm-prebuild"
|
||||||
|
mkdir -p "$TMP"
|
||||||
|
cp app.json package.json package-lock.json tsconfig.json "$TMP/"
|
||||||
|
cp -r app/ src/ plugins/ "$TMP/"
|
||||||
|
|
||||||
|
# node_modules 用 junction 共享
|
||||||
|
powershell -NoProfile -Command "cmd /c mklink /J '$TMP/node_modules' '$(pwd)/node_modules'"
|
||||||
|
|
||||||
|
# 2. 在临时项目 prebuild
|
||||||
|
cd "$TMP"
|
||||||
|
npx expo prebuild --platform android --no-install --clean
|
||||||
|
|
||||||
|
# 3. robocopy 回原项目(能处理锁定)
|
||||||
|
powershell -NoProfile -Command "robocopy '$TMP/android' '$(pwd -W || pwd)/android' /E /PURGE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Gradle 编译
|
||||||
|
|
||||||
|
### 前提:onnxruntime 版本修正
|
||||||
|
|
||||||
|
Config Plugin 注入的 `app/build.gradle` 中 onnxruntime 版本可能不匹配,每次 prebuild 后需检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认是 1.20.0(不是 1.20.1,后者不存在)
|
||||||
|
grep "onnxruntime" android/app/build.gradle
|
||||||
|
# 如果是 1.20.1,修正:
|
||||||
|
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编译 Kotlin(快速验证,不打包)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
export JAVA_HOME="C:/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
|
||||||
|
|
||||||
|
./gradlew :app:compileDebugKotlin --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整打包 APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleDebug --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
APK 输出位置:`android/app/build/outputs/apk/debug/app-debug.apk`(~250MB)
|
||||||
|
|
||||||
|
### Gradle 缓存清理(遇到诡异问题时)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 删 build 缓存
|
||||||
|
rm -rf app/build build app/.cxx
|
||||||
|
|
||||||
|
# 彻底重来(连 Gradle daemon 也杀)
|
||||||
|
powershell -NoProfile -Command "Get-Process -Name 'java' -ErrorAction SilentlyContinue | Stop-Process -Force"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安装到真机
|
||||||
|
|
||||||
|
### 开启 USB 调试
|
||||||
|
|
||||||
|
1. 手机「设置 → 关于手机」→ 连续点击「MIUI 版本」7 次 → 开启开发者选项
|
||||||
|
2. 「设置 → 更多设置 → 开发者选项」→ 打开:
|
||||||
|
- ✅ USB 调试
|
||||||
|
- ✅ **USB 安装**(红米/小米必须开,否则 `adb install` 被拒为 `INSTALL_FAILED_USER_RESTRICTED`)
|
||||||
|
3. USB 连接电脑(文件传输模式)
|
||||||
|
|
||||||
|
### adb install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME="C:/Users/<用户>/AppData/Local/Android/Sdk"
|
||||||
|
ADB="$ANDROID_HOME/platform-tools/adb"
|
||||||
|
|
||||||
|
# 确认设备连接
|
||||||
|
"$ADB" devices
|
||||||
|
# 应显示 device 状态
|
||||||
|
|
||||||
|
# 安装
|
||||||
|
"$ADB" install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Metro + 运行
|
||||||
|
|
||||||
|
Debug APK 需要从 Metro server 加载 JS bundle(不内嵌 JS)。
|
||||||
|
|
||||||
|
### 启动 Metro
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start --dev-client --port 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### adb reverse(USB 连接,手机和电脑不在同一 WiFi 时必需)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$ADB" reverse tcp:8081 tcp:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动 app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 命令行启动
|
||||||
|
"$ADB" shell am start -n com.example.beanmobile/.MainActivity
|
||||||
|
|
||||||
|
# 或直接在手机桌面点击「Bean Mobile」图标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进程存活?
|
||||||
|
"$ADB" shell pidof com.example.beanmobile
|
||||||
|
|
||||||
|
# JS 日志
|
||||||
|
"$ADB" logcat -d | grep "ReactNativeJS"
|
||||||
|
|
||||||
|
# 截图
|
||||||
|
"$ADB" shell screencap -p /sdcard/screen.png
|
||||||
|
"$ADB" pull /sdcard/screen.png /tmp/app-screen.png
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 常见问题与解决方案
|
||||||
|
|
||||||
|
### `withAndroidMainApplication is not a function`
|
||||||
|
|
||||||
|
**原因**:SDK 54 的 `@expo/config-plugins` 没有 `withAndroidMainApplication` / `withAndroidGradle`。
|
||||||
|
|
||||||
|
**解决**:改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod`。
|
||||||
|
|
||||||
|
### `Cannot read properties of undefined (reading '0')` in AndroidManifest
|
||||||
|
|
||||||
|
**原因**:`manifest.application` 在 prebuild 初始模板里可能不存在。
|
||||||
|
|
||||||
|
**解决**:操作前加防御性检查:
|
||||||
|
```javascript
|
||||||
|
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
|
||||||
|
manifest.application = [{ $: {} }];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modResults` 结构搞错导致 manifest 重复 `<root>` 标签
|
||||||
|
|
||||||
|
**原因**:`withAndroidManifest` 的 `modResults` 结构是 `{ manifest: { ... } }`,不是直接就是 manifest。
|
||||||
|
|
||||||
|
**解决**:所有权限和 application 操作通过 `modConfig.modResults.manifest`,不是 `modConfig.modResults` 本身。
|
||||||
|
|
||||||
|
### MainApplication 里 import OcrPackage 没注入
|
||||||
|
|
||||||
|
**原因**:Kotlin 的 package 声明**没有分号**(`package com.example.beanmobile`,不是 `package com.example.beanmobile;`),正则 `^(package [\w.]+;)` 匹配不到。
|
||||||
|
|
||||||
|
**解决**:正则去掉分号要求:`^(package\s+[\w.]+;?\s*)$`。
|
||||||
|
|
||||||
|
### Config Plugin 返回 undefined 导致整个 config 丢失
|
||||||
|
|
||||||
|
**原因**:插件函数最后漏了 `return config;`,导致 `withAndroidManifest` 的结果没返回,config 变成 undefined,后续所有插件崩溃。
|
||||||
|
|
||||||
|
**解决**:每个插件函数末尾必须有 `return config;`。
|
||||||
|
|
||||||
|
### `NDK at ...27.0.12077973 did not have a source.properties file`
|
||||||
|
|
||||||
|
**原因**:expo-sqlite 要求 NDK 27.0,但只装了 27.1;Gradle 自动下载 27.0 失败留下空目录。
|
||||||
|
|
||||||
|
**解决**:用 `mklink /J` 创建目录联接(见第 2 节)。
|
||||||
|
|
||||||
|
### NDK `clang broken` / `cannot open crtbegin_dynamic.o`
|
||||||
|
|
||||||
|
**原因**:NDK 解压时磁盘空间不足,arm64 目标库被截断。
|
||||||
|
|
||||||
|
**解决**:确保至少 5GB 可用空间,重新从腾讯云镜像下载并解压。
|
||||||
|
|
||||||
|
### `onnxruntime-android:1.20.1` not found
|
||||||
|
|
||||||
|
**原因**:Maven Central 上最新稳定版是 `1.20.0`,`1.20.1` 不存在。
|
||||||
|
|
||||||
|
**解决**:`sed -i 's/1.20.1/1.20.0/' android/app/build.gradle`
|
||||||
|
|
||||||
|
### `NoSuchMethodError: getDirectConverter` (expo-font 崩溃)
|
||||||
|
|
||||||
|
**原因**:`@expo/vector-icons@15.x` 拉进了 `expo-font@57.0.0`(SDK 55 版本),与 `expo-modules-core@3.0.x`(SDK 54)不兼容。
|
||||||
|
|
||||||
|
**解决**:在 `package.json` 加 npm override 强制降级:
|
||||||
|
```json
|
||||||
|
"overrides": {
|
||||||
|
"expo-font": "~14.0.12"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Unable to resolve "expo-linking"`
|
||||||
|
|
||||||
|
**原因**:expo-router 的 peer dependencies 没有全部安装。
|
||||||
|
|
||||||
|
**解决**:补全所有 peer deps:
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps \
|
||||||
|
@expo/metro-runtime expo-constants expo-linking \
|
||||||
|
react-dom react-native-gesture-handler react-native-reanimated \
|
||||||
|
@react-navigation/drawer
|
||||||
|
```
|
||||||
|
|
||||||
|
### `INSTALL_FAILED_USER_RESTRICTED` (红米/小米)
|
||||||
|
|
||||||
|
**原因**:MIUI 的「USB 安装」开关默认关闭。
|
||||||
|
|
||||||
|
**解决**:开发者选项里打开「USB 安装」。
|
||||||
|
|
||||||
|
### `EBUSY: resource busy or locked, rmdir android/`
|
||||||
|
|
||||||
|
**原因**:ZCode 的子进程(NodeBabyLinkService)持有 `android/` 目录句柄,`rmdir` 失败。
|
||||||
|
|
||||||
|
**解决**:在临时副本项目里 prebuild,再用 `robocopy /E /PURGE` 复制回原项目(见第 4 节)。
|
||||||
|
|
||||||
|
### `attribute android:canTakeScreenshots not found`
|
||||||
|
|
||||||
|
**原因**:`canTakeScreenshots` 是 API 30+ 属性,部分 compileSdk 配置下 AAPT 不识别。
|
||||||
|
|
||||||
|
**解决**:从 `accessibility_service_config.xml` 删除该属性(非必需,截图功能在 Kotlin 代码里实现)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 已修复的 Bug 清单
|
||||||
|
|
||||||
|
以下是真机编译过程中暴露并修复的全部 bug(共 26 个):
|
||||||
|
|
||||||
|
### Config Plugin Bug(7 个)
|
||||||
|
|
||||||
|
| 文件 | Bug | 修复 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `plugins/ppocr/app.plugin.js` | `withAndroidMainApplication` 不存在 | 改用 `withMainApplication` + `withAppBuildGradle` + `withDangerousMod` |
|
||||||
|
| `plugins/ppocr/app.plugin.js` | `withDangerousMod` 回调里 `modConfig.platformProjectRoot` undefined | 改为 `modConfig.modRequest.platformProjectRoot` |
|
||||||
|
| `plugins/*/app.plugin.js`(3 个) | `manifest.application[0]` 崩溃(prebuild 初始 manifest 无 application 节点) | 加 `Array.isArray` 防御 |
|
||||||
|
| `plugins/ppocr/app.plugin.js` | MainApplication import 没注入(Kotlin package 无分号) | 正则去掉分号要求 |
|
||||||
|
| `plugins/accessibility/app.plugin.js` | `return config` 丢失导致整个 config 变 undefined | 补回 `return config` |
|
||||||
|
|
||||||
|
### Kotlin 编译 Bug(11 个)
|
||||||
|
|
||||||
|
| 文件 | Bug | 修复 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `FloatingTip.kt:80` | `val layoutParams` 不可重赋值 + 类型不匹配 | 拆出 apply 块 + 用 LinearLayout.LayoutParams |
|
||||||
|
| `OcrTileService.kt:50` | `startActivityAndCollapse(intentSender)` 参数不匹配 | 改传 PendingIntent |
|
||||||
|
| `OcrModule.kt:179` | `putDouble(confidence)` Float 不匹配 | `.toDouble()` |
|
||||||
|
| `OcrModule.kt:215,235` | `longArrayOf(1,3,...)` Int vs Long | 显式 `.toLong()` + `L` 后缀 |
|
||||||
|
| `OcrModule.kt:221,242` | `detOutputs.forEach{it.close()}` 未解析 | 改用 `detOutputs.close()`(OrtSession.Result.close) |
|
||||||
|
| `OcrModule.kt:333` | det 输出维度多了 channel 维,`prob[y][x]` 是 FloatArray 不是 Float | `[0][0]` 剥掉 N+C 两维,函数签名改 `Array<FloatArray>` |
|
||||||
|
|
||||||
|
### 资源/环境 Bug(8 个)
|
||||||
|
|
||||||
|
| 问题 | 修复 |
|
||||||
|
|------|------|
|
||||||
|
| cmdline-tools 缺失 | 从 Google 官方下载安装 |
|
||||||
|
| platform 35 缺失 | sdkmanager 安装 |
|
||||||
|
| NDK 27.0 vs 27.1 版本冲突 | `mklink /J` 目录联接 |
|
||||||
|
| NDK 下载损坏(Google 源断连) | 腾讯云镜像替代 |
|
||||||
|
| `accessibility_service_config.xml` canTakeScreenshots 属性 | 删除(非必需) |
|
||||||
|
| `onnxruntime:1.20.1` 不存在 | 改用 1.20.0 |
|
||||||
|
| expo-font 57.0 vs 14.0 版本冲突 | npm overrides 强制降级 |
|
||||||
|
| expo-router peer deps 缺失 | 补全 8 个依赖包 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:完整一键流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# === 环境变量(每次新终端都要设) ===
|
||||||
|
export JAVA_HOME="C:/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="C:/Users/$USER/AppData/Local/Android/Sdk"
|
||||||
|
|
||||||
|
# === 1. 依赖 ===
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
# === 2. prebuild ===
|
||||||
|
npx expo prebuild --platform android --no-install
|
||||||
|
sed -i 's/onnxruntime-android:1.20.1/onnxruntime-android:1.20.0/' android/app/build.gradle
|
||||||
|
|
||||||
|
# === 3. 编译 ===
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleDebug --no-daemon
|
||||||
|
|
||||||
|
# === 4. 安装 ===
|
||||||
|
cd ..
|
||||||
|
"$ANDROID_HOME/platform-tools/adb" install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
||||||
|
# === 5. Metro + 运行 ===
|
||||||
|
npx expo start --dev-client --port 8081 &
|
||||||
|
sleep 10
|
||||||
|
"$ANDROID_HOME/platform-tools/adb" reverse tcp:8081 tcp:8081
|
||||||
|
"$ANDROID_HOME/platform-tools/adb" shell am start -n com.example.beanmobile/.MainActivity
|
||||||
|
```
|
||||||
2845
package-lock.json
generated
2845
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -2,7 +2,7 @@
|
|||||||
"name": "beancount-mobile",
|
"name": "beancount-mobile",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "node_modules/expo/AppEntry.js",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
@ -11,16 +11,42 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "^6.1.2",
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-navigation/drawer": "^7.5.0",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
|
"expo-constants": "^18.0.13",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.0",
|
"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-sqlite": "~16.0.0",
|
||||||
"expo-status-bar": "~3.0.0",
|
"expo-status-bar": "~3.0.0",
|
||||||
|
"i18n-js": "^4.5.3",
|
||||||
"react": "19.1.0",
|
"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-view-shot": "4.0.3",
|
||||||
|
"text-encoding-gbk": "^0.7.3",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"typescript": "~5.9.0",
|
"typescript": "~5.9.0",
|
||||||
"vitest": "^3.2.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 能力。
|
|
||||||
188
plugins/accessibility/app.plugin.js
Normal file
188
plugins/accessibility/app.plugin.js
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 无障碍服务 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. 注入 ReactContextHolder 赋值 + 注册 AccessibilityBridgePackage 到 MainApplication
|
||||||
|
config = withMainApplication(config, (modConfig) => {
|
||||||
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
|
// 2a. 注入 import(ReactContextHolder + AccessibilityBridgePackage)
|
||||||
|
if (!content.includes(`import ${PACKAGE}.ReactContextHolder`)) {
|
||||||
|
content = content.replace(
|
||||||
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
|
`$1\nimport ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||||
|
);
|
||||||
|
} else if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
|
||||||
|
content = content.replace(
|
||||||
|
`import ${PACKAGE}.ReactContextHolder`,
|
||||||
|
`import ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. 在 onCreate 方法中注入 ReactContextHolder.context = this
|
||||||
|
if (!content.includes('ReactContextHolder.context')) {
|
||||||
|
// 尝试在 super.onCreate() 后注入
|
||||||
|
if (/super\.onCreate\(\)/.test(content)) {
|
||||||
|
content = content.replace(
|
||||||
|
/(super\.onCreate\(\))/,
|
||||||
|
`$1\n // 由 Config Plugin 注入:让原生服务能访问 RN 上下文\n ReactContextHolder.context = this`,
|
||||||
|
);
|
||||||
|
} else if (/onCreate/.test(content)) {
|
||||||
|
// 有 onCreate 但没有 super.onCreate()(不太可能)
|
||||||
|
content = content.replace(
|
||||||
|
/(onCreate[^{]*\{)/,
|
||||||
|
`$1\n ReactContextHolder.context = this`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 没有 onCreate — 在类体内注入一个
|
||||||
|
content = content.replace(
|
||||||
|
/(class\s+MainApplication\s*[^{]*\{)/,
|
||||||
|
`$1\n override fun onCreate() {\n super.onCreate()\n ReactContextHolder.context = this\n }`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||||
|
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"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
78
plugins/ppocr/README.md
Normal file
78
plugins/ppocr/README.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# 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 # 文本识别模型
|
||||||
|
└── ppocr_keys_v1.txt # CJK 字典(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
|
||||||
|
|
||||||
|
# CJK 字典(26 KB,PaddleOCR 标准字典)
|
||||||
|
curl -L -o ppocr_keys_v1.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
或用 HuggingFace CLI(首次下载原生模型再转 ONNX 的方式,参见历史 git log)。
|
||||||
|
|
||||||
|
> 来源说明:[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` → `npx expo run:android`。
|
||||||
120
plugins/ppocr/app.plugin.js
Normal file
120
plugins/ppocr/app.plugin.js
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 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*{([\s\S]*?)^\s*}/m,
|
||||||
|
(m, inner) =>
|
||||||
|
`dependencies {${inner}\n // PP-OCRv5 ONNX Runtime(由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'\n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
modConfig.modResults.contents = gradle;
|
||||||
|
return modConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withPpOcr;
|
||||||
6623
plugins/ppocr/assets/ppocr_keys_v1.txt
Normal file
6623
plugins/ppocr/assets/ppocr_keys_v1.txt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
plugins/ppocr/assets/ppocrv5_det.onnx
Normal file
BIN
plugins/ppocr/assets/ppocrv5_det.onnx
Normal file
Binary file not shown.
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"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
99
src/ai/chatAssistant.ts
Normal file
99
src/ai/chatAssistant.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* AI 聊天助手(plan.md「8.1 AI 聊天助手」)。
|
||||||
|
*
|
||||||
|
* 参考 BeeCount 的 AIChatService(GLM-4):
|
||||||
|
* - 意图判定:是否为记账意图(金额关键词检测)
|
||||||
|
* - 记账模式 → processNaturalLanguage 返回草稿
|
||||||
|
* - 自由对话 → AI 自由回答
|
||||||
|
*
|
||||||
|
* 复用 domain/ai.ts 的 processNaturalLanguage + buildNaturalLanguagePrompt。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记账意图关键词(plan.md「8.1」)。 */
|
||||||
|
const TRANSACTION_KEYWORDS = ['买', '花', '消费', '支付', '记账', '付', '收入', '赚', '工资', '收'];
|
||||||
|
const AMOUNT_PATTERN = /\d+(?:\.\d+)?/;
|
||||||
|
|
||||||
|
/** 判断是否为记账意图(金额 + 关键词)。 */
|
||||||
|
export function isTransactionIntent(input: string): boolean {
|
||||||
|
const hasAmount = AMOUNT_PATTERN.test(input);
|
||||||
|
const hasKeyword = 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]);
|
||||||
|
const recentTx = useMemo(() => transactions.slice(-5).reverse(), [transactions]);
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const draft = instantiateRecurring(rec);
|
||||||
|
await addTransaction(draft);
|
||||||
|
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
|
||||||
|
updateRecurringTransaction(rec.id, { nextDueDate });
|
||||||
|
} catch {
|
||||||
|
// 静默失败,用户可在周期管理页手动处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [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 }]}>{t('app.name')}</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||||
|
|
||||||
|
{/* 净资产卡片 */}
|
||||||
|
<Card title={t('home.netWorthTitle')}>
|
||||||
|
<View style={styles.balanceRow}>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.assets')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>
|
||||||
|
{fmtAmount(netWorth.assets)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.liabilities')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>
|
||||||
|
{fmtAmount(netWorth.liabilities)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorth')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||||
|
{fmtAmount(netWorth.netWorth)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 周期性记账提醒 */}
|
||||||
|
{dueRecurring.length > 0 && (
|
||||||
|
<Card title={t('home.dueRecurring')}>
|
||||||
|
<View style={styles.recurringList}>
|
||||||
|
{dueRecurring.map(rec => (
|
||||||
|
<View key={rec.id} style={[styles.recurringRow, { borderBottomColor: theme.colors.divider }]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||||
|
{rec.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('home.dueAmount')}: {rec.draft.postings[0]?.amount} CNY
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleConfirmRecurring(rec)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.confirmBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{t('home.confirmRecurring')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 本月收支 */}
|
||||||
|
{currentMonth && (
|
||||||
|
<Card title={`${currentMonth.month} ${t('home.title')}`}>
|
||||||
|
<View style={styles.balanceRow}>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.income')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>
|
||||||
|
{currentMonth.income.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.expense')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>
|
||||||
|
{currentMonth.expense.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.balanceItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.balance')}</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{(currentMonth.income - currentMonth.expense).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 月度趋势 */}
|
||||||
|
{monthlyData.length > 0 && (
|
||||||
|
<Card title={t('home.monthlyTrend')}>
|
||||||
|
<TrendLine transactions={transactions} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 账户与资产余额树 */}
|
||||||
|
{accountNodes.length > 0 && (
|
||||||
|
<Card title={t('home.accountTree')}>
|
||||||
|
<AccountTree nodes={accountNodes} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 最近交易 */}
|
||||||
|
<Card title={`${t('home.recentTransactions')} (${recentTx.length})`}>
|
||||||
|
{recentTx.length === 0 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('home.recentEmpty')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{recentTx.map(tx => (
|
||||||
|
<TransactionCard
|
||||||
|
key={tx.id}
|
||||||
|
transaction={tx}
|
||||||
|
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
<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 },
|
||||||
|
balanceRow: { flexDirection: 'row', gap: 8 },
|
||||||
|
balanceItem: { flex: 1, alignItems: 'center', gap: 4 },
|
||||||
|
recurringList: { gap: 8 },
|
||||||
|
recurringRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: 1 },
|
||||||
|
confirmBtn: { paddingVertical: 6, paddingHorizontal: 12 },
|
||||||
|
});
|
||||||
207
src/app/(tabs)/report.tsx
Normal file
207
src/app/(tabs)/report.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* 报表页(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 { 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 } 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 { CalendarHeatmap } from '../../components/charts/CalendarHeatmap';
|
||||||
|
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 t = useT();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 月份切换状态
|
||||||
|
const now = new Date();
|
||||||
|
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
|
||||||
|
|
||||||
|
// AI 总结弹窗
|
||||||
|
const [summaryModal, setSummaryModal] = useState(false);
|
||||||
|
const [summaryText, setSummaryText] = useState('');
|
||||||
|
|
||||||
|
// 截图 ref
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||||||
|
|
||||||
|
// 净资产趋势的日期序列(基于选中月份,往前 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 = () => {
|
||||||
|
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
|
||||||
|
else setViewMonth(m => m - 1);
|
||||||
|
};
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
|
||||||
|
else setViewMonth(m => m + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出报表为图片
|
||||||
|
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 / 纯文本月度总结(AI 已配置时用真实 AI,否则降级为纯文本)
|
||||||
|
const handleMonthlySummary = async () => {
|
||||||
|
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth);
|
||||||
|
// 若 AI 已配置,调真实 AI 生成总结;否则降级纯文本
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 月份切换器 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||||
|
>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<Card title={t('report.title')}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('report.empty')}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card title={`${monthLabel} ${t('report.monthly')}`}>
|
||||||
|
<MonthlyReport transactions={transactions} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('report.categoryPie')}>
|
||||||
|
<CategoryPie transactions={transactions} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={`${monthLabel} ${t('report.heatmap')}`}>
|
||||||
|
<CalendarHeatmap transactions={transactions} year={viewYear} month={viewMonth} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('report.netWorthTrend')}>
|
||||||
|
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('report.annual', { year: viewYear })}>
|
||||||
|
<AnnualReport transactions={transactions} year={viewYear} />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* AI 月度总结弹窗 */}
|
||||||
|
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={[styles.modalCard, { backgroundColor: theme.colors.bgSecondary }]}>
|
||||||
|
<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: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
content: { padding: 16, paddingBottom: 96 },
|
||||||
|
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||||
|
modalCard: { width: '100%', borderRadius: 12, padding: 20 },
|
||||||
|
});
|
||||||
96
src/app/(tabs)/settings.tsx
Normal file
96
src/app/(tabs)/settings.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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 }]}>
|
||||||
|
{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('download-outline', t('tab.import'), '/import')}
|
||||||
|
{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('calendar', t('settings.calendar'), '/calendar')}
|
||||||
|
{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' },
|
||||||
|
});
|
||||||
222
src/app/(tabs)/transactions.tsx
Normal file
222
src/app/(tabs)/transactions.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* 交易列表页(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 } from '../../theme';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { Card } from '../../components/Card';
|
||||||
|
import { SearchBar } from '../../components/SearchBar';
|
||||||
|
import { TransactionCard } from '../../components/TransactionCard';
|
||||||
|
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
||||||
|
|
||||||
|
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
|
export default function TransactionsScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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(() => ledger?.transactions ?? [], [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={[
|
||||||
|
styles.filterChip,
|
||||||
|
{
|
||||||
|
backgroundColor: direction === tab.key ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: direction === tab.key ? theme.colors.fgInverse : theme.colors.fgPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 高级筛选切换 */}
|
||||||
|
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
|
style={({ pressed }) => [styles.filterChip, {
|
||||||
|
backgroundColor: showAdvancedFilters ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: showAdvancedFilters ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 12, fontWeight: '600' }}>
|
||||||
|
{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={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
{/* 日期范围 */}
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
value={dateFrom}
|
||||||
|
onChangeText={setDateFrom}
|
||||||
|
placeholder={t('transactions.dateFrom')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={dateTo}
|
||||||
|
onChangeText={setDateTo}
|
||||||
|
placeholder={t('transactions.dateTo')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.advInput, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, 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}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{showDiagnostics ? '▼' : '▶'} {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 },
|
||||||
|
filterChip: { paddingVertical: 5, paddingHorizontal: 12 },
|
||||||
|
advInput: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 10, paddingVertical: 6, fontSize: 13 },
|
||||||
|
content: { padding: 16, paddingTop: 8 },
|
||||||
|
diagToggle: { paddingVertical: 12, alignItems: 'center' },
|
||||||
|
});
|
||||||
110
src/app/_error.tsx
Normal file
110
src/app/_error.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
error: Error;
|
||||||
|
resetError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
const data = buildErrorRecoveryData(error);
|
||||||
|
|
||||||
|
const clearCacheAndRestart = () => {
|
||||||
|
// 模拟重置错误缓存以恢复启动
|
||||||
|
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' },
|
||||||
|
});
|
||||||
216
src/app/_layout.tsx
Normal file
216
src/app/_layout.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import React, { useEffect, 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';
|
||||||
|
|
||||||
|
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
||||||
|
const SAMPLE_LEDGER = {
|
||||||
|
path: 'main.bean',
|
||||||
|
content: `option "operating_currency" "CNY"
|
||||||
|
include "mobile.bean"
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 渠道 → 账户映射:导入账单时由规则自动匹配,此处仅提供首次缺省映射。 */
|
||||||
|
const DEMO_ACCOUNT_MAP: Record<string, string> = {};
|
||||||
|
|
||||||
|
/** 内层布局:引导/锁屏/路由三态切换。 */
|
||||||
|
function AppShell() {
|
||||||
|
const { theme, isDark } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. 初始化持久化并还原数据
|
||||||
|
initPersistence().then(() => {
|
||||||
|
// 2. 检查并读取本地真实主账本
|
||||||
|
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
||||||
|
FileSystem.getInfoAsync(mainPath).then(async (info) => {
|
||||||
|
let content = SAMPLE_LEDGER.content;
|
||||||
|
if (info.exists) {
|
||||||
|
content = await FileSystem.readAsStringAsync(mainPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = useMetadataStore.getState().rules;
|
||||||
|
const categories = useMetadataStore.getState().categories;
|
||||||
|
|
||||||
|
loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()).then(() => {
|
||||||
|
setContext({ rules, categories, accountMap: DEMO_ACCOUNT_MAP });
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// 原生事件格式:{ packageName, title, text, timestamp }
|
||||||
|
// parseNotification 复用 OCR 规则匹配提取账单信息
|
||||||
|
const { parseNotification } = require('../services/notification');
|
||||||
|
const bill = parseNotification(event);
|
||||||
|
if (bill) {
|
||||||
|
useAutomationStore.getState().addDetected('notification', bill);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DeviceEventEmitter.addListener('billingSms', (event) => {
|
||||||
|
// 原生事件格式:{ sender, body, timestamp }
|
||||||
|
// parseSms 提取金额/卡号/方向/商户
|
||||||
|
const { parseSms } = require('../services/sms');
|
||||||
|
const bill = parseSms(event);
|
||||||
|
if (bill) {
|
||||||
|
useAutomationStore.getState().addDetected('sms', bill);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DeviceEventEmitter.addListener('billingScreenshot', (event) => {
|
||||||
|
// 截图事件格式:{ base64, packageName, timestamp, ... }
|
||||||
|
// 经 OcrProcessor 三层降级(规则→OCR→AI Vision)识别为账单事件
|
||||||
|
const { processScreenshotEvent } = require('../services/automationPipeline');
|
||||||
|
processScreenshotEvent(event).catch((e: unknown) => {
|
||||||
|
console.warn('[layout] 截图 OCR 处理失败:', e);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => subscriptions.forEach(s => s.remove());
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
// 切到后台/非活跃时立即遮盖,回到前台时移除
|
||||||
|
setPrivacyOverlay(state !== 'active');
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// loading 状态
|
||||||
|
if (phase === 'loading') {
|
||||||
|
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 (
|
||||||
|
<SafeAreaView 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="calendar/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 }} />
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根布局:包裹 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' },
|
||||||
|
});
|
||||||
306
src/app/account/index.tsx
Normal file
306
src/app/account/index.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
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(':');
|
||||||
|
|
||||||
|
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('-') ? '#ff4d4f' : '#2f54eb', 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="#ff4d4f" />
|
||||||
|
<Text style={[theme.typography.caption, { color: '#ff4d4f', 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 },
|
||||||
|
});
|
||||||
199
src/app/ai/chat.tsx
Normal file
199
src/app/ai/chat.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginTop: 4 }]}>
|
||||||
|
{t('ai.tapToRecord')} →
|
||||||
|
</Text>
|
||||||
|
</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' },
|
||||||
|
});
|
||||||
397
src/app/automation/index.tsx
Normal file
397
src/app/automation/index.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* 自动记账管理页(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, 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 { useImportStore } from '../../store/importStore';
|
||||||
|
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();
|
||||||
|
}, [refreshAccessibilityState]);
|
||||||
|
|
||||||
|
const handleProcess = async () => {
|
||||||
|
if (!ledger) return;
|
||||||
|
const rules = useMetadataStore.getState().rules;
|
||||||
|
const categories = useMetadataStore.getState().categories;
|
||||||
|
const history = ledger.transactions;
|
||||||
|
const accountMap = useImportStore.getState().accountMap;
|
||||||
|
try {
|
||||||
|
await processAll(ledger, rules, categories, history, accountMap);
|
||||||
|
} 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;
|
||||||
|
// 打开系统无障碍设置页面
|
||||||
|
Linking.openURL('android.settings.ACCESSIBILITY_SETTINGS').catch(() => {
|
||||||
|
Alert.alert(t('automation.openSettingsFail'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRememberPage = async () => {
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
if (!bridge) {
|
||||||
|
Alert.alert(t('automation.bridgeUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualOcr = async () => {
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
if (!bridge) {
|
||||||
|
Alert.alert(t('automation.bridgeUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await bridge.triggerManualOcr();
|
||||||
|
Alert.alert(t('automation.manualOcrTriggered'));
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('automation.manualOcrFail'), String((e as Error)?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.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 ? '#4CAF50' : '#9E9E9E' }]} />
|
||||||
|
<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 },
|
||||||
|
});
|
||||||
155
src/app/budget/index.tsx
Normal file
155
src/app/budget/index.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* 预算管理页面(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;
|
||||||
|
|
||||||
|
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: '餐饮月预算', defaultValue: modal.budget.name },
|
||||||
|
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: '2000', 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: 'Expenses:餐饮', 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: '餐饮月预算' },
|
||||||
|
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: '2000', keyboardType: 'decimal-pad' },
|
||||||
|
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
|
||||||
|
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: 'Expenses:餐饮' },
|
||||||
|
{ 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 },
|
||||||
|
});
|
||||||
57
src/app/calendar/index.tsx
Normal file
57
src/app/calendar/index.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 日历视图页面(plan.md「1.1 calendar/index」)。
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { CalendarView } from '../../components/CalendarView';
|
||||||
|
import { TransactionCard } from '../../components/TransactionCard';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
|
||||||
|
export default function CalendarScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
|
const transactions = ledger?.transactions ?? [];
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const today = new Date();
|
||||||
|
const dayTx = selectedDate ? transactions.filter(t => t.date.slice(0, 10) === selectedDate) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||||
|
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('calendar.title')}</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<CalendarView
|
||||||
|
transactions={transactions}
|
||||||
|
year={today.getFullYear()}
|
||||||
|
month={today.getMonth() + 1}
|
||||||
|
onDayPress={setSelectedDate}
|
||||||
|
/>
|
||||||
|
{selectedDate && (
|
||||||
|
<View style={{ marginTop: 16 }}>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 8 }]}>{selectedDate}</Text>
|
||||||
|
{dayTx.map(t => <TransactionCard key={t.id} transaction={t} onPress={(tx) => router.push(`/transaction/${tx.id}`)} />)}
|
||||||
|
{dayTx.length === 0 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('calendar.noDayTx')}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||||
|
content: { padding: 16 },
|
||||||
|
});
|
||||||
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: '餐饮', defaultValue: modal.category.name },
|
||||||
|
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: 'Expenses:餐饮', defaultValue: modal.category.linkedAccount },
|
||||||
|
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: '餐, 饭, 咖啡', 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: '餐饮' },
|
||||||
|
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: 'Expenses:餐饮' },
|
||||||
|
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: '餐, 饭, 咖啡' },
|
||||||
|
]}
|
||||||
|
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 },
|
||||||
|
});
|
||||||
400
src/app/import/index.tsx
Normal file
400
src/app/import/index.tsx
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
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', {
|
||||||
|
channel: isWeChat ? t('importFlow.channelWechat') : t('importFlow.channelAlipay'),
|
||||||
|
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, channel: item.channel })}
|
||||||
|
</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: '#ff4d4f', 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="#ff4d4f" />
|
||||||
|
<Text style={[theme.typography.caption, { color: '#ff4d4f', 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,
|
||||||
|
channel: values.channel?.trim() || undefined,
|
||||||
|
counterpartyContains: values.counterpartyContains?.trim() || undefined,
|
||||||
|
memoContains: values.memoContains?.trim() || undefined,
|
||||||
|
channelAccount: values.channelAccount?.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: 'channel', label: t('rules.fieldChannel'), placeholder: 'Alipay', defaultValue: rule?.channel ?? '' },
|
||||||
|
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
|
||||||
|
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
|
||||||
|
{ key: 'channelAccount', label: t('rules.fieldChannelAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.channelAccount ?? '' },
|
||||||
|
{ 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.channel) parts.push(t('rules.condChannel', { val: rule.channel }));
|
||||||
|
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>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
→ {rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
|
||||||
|
</Text>
|
||||||
|
</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' },
|
||||||
|
});
|
||||||
434
src/app/settings/sync.tsx
Normal file
434
src/app/settings/sync.tsx
Normal file
@ -0,0 +1,434 @@
|
|||||||
|
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) || 'mobile.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: 'mobile.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('mobile.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 bundle = createBackupBundle(files, mobileBean);
|
||||||
|
const json = serializeBundle(bundle);
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
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);
|
||||||
|
// 用恢复的 mobileBean 替换当前内容
|
||||||
|
const restoredMobileBean = restoredFiles.find(f => f.path === 'mobile.bean')?.content ?? '';
|
||||||
|
await replaceMobileBean(restoredMobileBean);
|
||||||
|
Alert.alert(t('sync.restoreSuccess'), t('sync.restoreDesc', { name: file.name }));
|
||||||
|
} 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`;
|
||||||
|
// 用 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) => {
|
||||||
|
const base64 = XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
|
||||||
|
return FileSystem.writeAsStringAsync(path, base64, { encoding: FileSystem.EncodingType.Base64 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await exportToExcel(transactions, wbWrapper as any, exportPath);
|
||||||
|
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 是内存的,需要真实写入
|
||||||
|
const result = await exportRules(rules, categories, fs, exportPath);
|
||||||
|
// 获取 Mock 写入的内容,用真实 FileSystem 写
|
||||||
|
const content = await fs.read(exportPath);
|
||||||
|
await FileSystem.writeAsStringAsync(exportPath, content);
|
||||||
|
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}>
|
||||||
|
<Button label={t('sync.configWebdav')} onPress={() => setWebdavModalVisible(true)} variant="secondary" />
|
||||||
|
<Button label={t('sync.configGit')} onPress={() => setGitModalVisible(true)} variant="secondary" />
|
||||||
|
</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}>
|
||||||
|
<Button label={t('sync.backupNow')} onPress={handleBackup} />
|
||||||
|
<Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" />
|
||||||
|
</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: '#fff', 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 },
|
||||||
|
});
|
||||||
398
src/app/transaction/[id].tsx
Normal file
398
src/app/transaction/[id].tsx
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* 交易详情页(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 } from '../../theme';
|
||||||
|
import { Card } from '../../components/Card';
|
||||||
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { TransactionCard } from '../../components/TransactionCard';
|
||||||
|
|
||||||
|
export default function TransactionDetailScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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('mobile.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('mobile.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))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.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');
|
||||||
|
if (!lines[0].includes(`^${tag}`)) {
|
||||||
|
lines[0] = lines[0].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);
|
||||||
|
// raw 变了 → id 变了,更新当前页 URL 避免找不到交易
|
||||||
|
const newId = ledger?.transactions.find((txn: any) => txn.raw === newCurrentRaw)?.id;
|
||||||
|
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();
|
||||||
|
const lines = tx.raw.split('\n');
|
||||||
|
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
|
||||||
|
const newRaw = lines.join('\n');
|
||||||
|
|
||||||
|
const newContent = mobileBean.replace(tx.raw, newRaw);
|
||||||
|
await replaceMobileBean(newContent);
|
||||||
|
// raw 变了 → id 变了,更新当前页 URL
|
||||||
|
const newId = ledger?.transactions.find((txn: any) => txn.raw === newRaw)?.id;
|
||||||
|
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: '#fff', 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>
|
||||||
|
|
||||||
|
{/* 编辑/删除按钮(仅 mobile.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={[styles.searchInput, { borderColor: theme.colors.border, color: theme.colors.fgPrimary, backgroundColor: theme.colors.bgTertiary }]}
|
||||||
|
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: 1, borderRadius: 6, paddingVertical: 8 },
|
||||||
|
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
|
||||||
|
searchInput: { height: 40, borderWidth: 1, borderRadius: 6, 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: 1, borderRadius: 8, paddingVertical: 10, paddingHorizontal: 20 },
|
||||||
|
});
|
||||||
560
src/app/transaction/new.tsx
Normal file
560
src/app/transaction/new.tsx
Normal file
@ -0,0 +1,560 @@
|
|||||||
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
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 } 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 { getNativeOcrModule } from '../../services/ocrBridge';
|
||||||
|
import { OcrProcessor } from '../../domain/ocrProcessor';
|
||||||
|
import { getNativeOcrBridge } from '../../services/ocrBridge';
|
||||||
|
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 t = useT();
|
||||||
|
const router = useRouter();
|
||||||
|
const { mode, editId } = useLocalSearchParams<{ mode?: string; editId?: 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 [postings, setPostings] = useState<Posting[]>([
|
||||||
|
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
|
||||||
|
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// === 编辑模式:预填表单 ===
|
||||||
|
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: true,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
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(() => {
|
||||||
|
setStatus(t('transaction.editSuccess'));
|
||||||
|
setTimeout(() => router.back(), 600);
|
||||||
|
}).catch(e => setStatus(String(e)));
|
||||||
|
} else {
|
||||||
|
addTransaction(draft).then(() => {
|
||||||
|
setStatus(t('transaction.appended'));
|
||||||
|
setNarration('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
setTimeout(() => router.back(), 600);
|
||||||
|
}).catch(e => setStatus(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// 简单模式:根据方向生成正负 postings
|
||||||
|
const amt = amount.trim();
|
||||||
|
if (!amt) {
|
||||||
|
setStatus(t('transaction.amountRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
|
||||||
|
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: editId ? editFlag : undefined,
|
||||||
|
narration: narration.trim() || (
|
||||||
|
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
||||||
|
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
||||||
|
t('transaction.narrationDefaultTransfer')
|
||||||
|
),
|
||||||
|
postings: finalPostings,
|
||||||
|
tags: tagNames,
|
||||||
|
links: editId && editLinks.length > 0 ? editLinks : undefined,
|
||||||
|
metadata: editId ? editMetadata : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editId && oldRaw) {
|
||||||
|
// 编辑模式
|
||||||
|
const newRaw = serializeTransaction(draft);
|
||||||
|
editTransaction(oldRaw, newRaw).then(() => {
|
||||||
|
setStatus(t('transaction.editSuccess'));
|
||||||
|
setTimeout(() => router.back(), 600);
|
||||||
|
}).catch(e => setStatus(String(e)));
|
||||||
|
} else {
|
||||||
|
addTransaction(draft).then(() => {
|
||||||
|
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 }]}>
|
||||||
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||||
|
|
||||||
|
{/* 高级模式模式切换开关 */}
|
||||||
|
<View style={[styles.switchRow, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||||
|
{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 => (
|
||||||
|
<Button
|
||||||
|
key={d.key}
|
||||||
|
label={d.label}
|
||||||
|
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
|
||||||
|
variant={direction === d.key ? 'primary' : 'secondary'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
label={t('home.ocrScan')}
|
||||||
|
onPress={handleOcrScan}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card title={t('transaction.newTitle')}>
|
||||||
|
{/* 日期 */}
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
|
||||||
|
{t('transaction.formDate')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={date}
|
||||||
|
onChangeText={setDate}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 摘要 */}
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
||||||
|
{t('transaction.formNarration')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={narration}
|
||||||
|
onChangeText={setNarration}
|
||||||
|
placeholder={t('transaction.formNarration')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 收款方(简单模式) */}
|
||||||
|
{!isAdvanced && (
|
||||||
|
<>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
||||||
|
{t('transaction.formPayee')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={payee}
|
||||||
|
onChangeText={setPayee}
|
||||||
|
placeholder={t('transaction.formPayeePlaceholder')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 简单模式字段 */}
|
||||||
|
{!isAdvanced ? (
|
||||||
|
<View>
|
||||||
|
{/* 金额 */}
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
||||||
|
{t('transaction.amountPlaceholder')}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
|
<TextInput
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholder={t('transaction.amountPlaceholder')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, flex: 1 }]}
|
||||||
|
/>
|
||||||
|
{/* 币种选择 */}
|
||||||
|
<TextInput
|
||||||
|
value={currency}
|
||||||
|
onChangeText={setCurrency}
|
||||||
|
placeholder="CNY"
|
||||||
|
maxLength={3}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border, width: 70 }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 来源账户 */}
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
||||||
|
{t('transaction.formSourceAccount')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={sourceAccount}
|
||||||
|
onChangeText={setSourceAccount}
|
||||||
|
placeholder="Assets:支付宝余额"
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 目标账户 (仅在转账时显示) */}
|
||||||
|
{direction === 'transfer' && (
|
||||||
|
<View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10 }]}>
|
||||||
|
{t('transaction.targetAccount')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={targetAccount}
|
||||||
|
onChangeText={setTargetAccount}
|
||||||
|
placeholder="Assets:WeChat"
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||||||
|
{t('transaction.formAvailable')}: {assetAccounts.join(' / ')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
// 高级模式字段: 接入 PostingEditor 与 BalanceIndicator
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
||||||
|
{t('transaction.postingsList')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PostingEditor
|
||||||
|
postings={postings}
|
||||||
|
onChange={(p) => setPostings(p)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.postingBtnRow}>
|
||||||
|
<Button
|
||||||
|
label={t('transaction.addPosting')}
|
||||||
|
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
<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 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||||||
|
→ {selectedCategory.linkedAccount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</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, paddingHorizontal: 16 }]}>
|
||||||
|
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button label={t('transaction.submit')} onPress={submit} />
|
||||||
|
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>{status}</Text> : null}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
content: { padding: 16 },
|
||||||
|
directionRow: { flexDirection: 'row', gap: 8 },
|
||||||
|
input: { borderWidth: 1, borderRadius: 8, padding: 11, marginTop: 2 },
|
||||||
|
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: 8, borderWidth: 1, marginBottom: 4 },
|
||||||
|
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||||
|
});
|
||||||
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 },
|
||||||
|
});
|
||||||
31
src/components/BalanceIndicator.tsx
Normal file
31
src/components/BalanceIndicator.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
|
interface BalanceIndicatorProps {
|
||||||
|
/** 各币种余额。 */
|
||||||
|
balances: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 借贷平衡指示器(主题化)。所有币种余额为 0 时显示「平衡」。 */
|
||||||
|
export function BalanceIndicator({ balances }: BalanceIndicatorProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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 ? '✓ 平衡' : '✗ 不平衡'}
|
||||||
|
</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' },
|
||||||
|
});
|
||||||
43
src/components/Button.tsx
Normal file
43
src/components/Button.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { 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 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}
|
||||||
|
disabled={disabled}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderRadius: theme.radii.md,
|
||||||
|
opacity: pressed || disabled ? 0.6 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.text, { color: fg }]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: { marginTop: 6, paddingVertical: 12, paddingHorizontal: 16, alignItems: 'center' },
|
||||||
|
text: { fontWeight: '700', fontSize: 16 },
|
||||||
|
});
|
||||||
110
src/components/CalendarView.tsx
Normal file
110
src/components/CalendarView.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 日历视图组件(plan.md「5.3 日历视图」)。
|
||||||
|
*
|
||||||
|
* 参考 BeeCount 的 calendar_page.dart:
|
||||||
|
* - 按日期展示交易(有交易的日期标记圆点)
|
||||||
|
* - 支出/收入用不同颜色标记
|
||||||
|
* - 点击日期查看当日交易
|
||||||
|
*
|
||||||
|
* 纯 RN 实现(无第三方日历库依赖),数据计算为纯函数。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 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 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;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cell.date}
|
||||||
|
onPress={() => handlePress(cell)}
|
||||||
|
disabled={!cell.isCurrentMonth}
|
||||||
|
style={[
|
||||||
|
styles.dayCell,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? theme.colors.accent : 'transparent',
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
theme.typography.bodySmall,
|
||||||
|
{
|
||||||
|
color: !cell.isCurrentMonth
|
||||||
|
? theme.colors.border
|
||||||
|
: isSelected
|
||||||
|
? theme.colors.fgInverse
|
||||||
|
: theme.colors.fgPrimary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{cell.day}
|
||||||
|
</Text>
|
||||||
|
{/* 交易标记圆点 */}
|
||||||
|
{cell.count > 0 && cell.isCurrentMonth && (
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{cell.hasIncome && (
|
||||||
|
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.income }]} />
|
||||||
|
)}
|
||||||
|
{cell.hasExpense && (
|
||||||
|
<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 },
|
||||||
|
dots: { flexDirection: 'row', gap: 3, marginTop: 2 },
|
||||||
|
dot: { width: 5, height: 5, borderRadius: 2.5 },
|
||||||
|
});
|
||||||
39
src/components/Card.tsx
Normal file
39
src/components/Card.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片容器(主题化,参考 plan.md「1.2 组件使用方式」)。 */
|
||||||
|
export function Card({ title, children, onPress, style }: CardProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const Container = onPress ? Pressable : View;
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
onPress={onPress}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderRadius: theme.radii.lg,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
...theme.shadows.sm,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title ? <Text style={[styles.title, { color: theme.colors.fgPrimary }, theme.typography.h3]}>{title}</Text> : null}
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: { gap: 8 },
|
||||||
|
title: { marginBottom: 4 },
|
||||||
|
});
|
||||||
44
src/components/CategoryPicker.tsx
Normal file
44
src/components/CategoryPicker.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, ScrollView, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类选择器(主题化,横向滚动 chip)。 */
|
||||||
|
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.container}>
|
||||||
|
{categories.map(cat => {
|
||||||
|
const active = cat.id === selectedId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cat.id}
|
||||||
|
onPress={() => onSelect(cat)}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{
|
||||||
|
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, fontSize: 14 }}>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { paddingHorizontal: 16, gap: 8 },
|
||||||
|
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16, borderWidth: 1, marginRight: 8 },
|
||||||
|
});
|
||||||
57
src/components/DedupBanner.tsx
Normal file
57
src/components/DedupBanner.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
import type { DedupResult } from '../domain/dedup';
|
||||||
|
|
||||||
|
interface DedupBannerProps {
|
||||||
|
result: DedupResult;
|
||||||
|
onAccept?: () => void; // 接受(仍要记账)
|
||||||
|
onReject?: () => void; // 拒绝(不记)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
|
||||||
|
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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' }]}>
|
||||||
|
疑似重复
|
||||||
|
</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 }}>跳过</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
|
||||||
|
<Text style={{ color: theme.colors.fgInverse }}>仍要记账</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 },
|
||||||
|
});
|
||||||
156
src/components/FormModal.tsx
Normal file
156
src/components/FormModal.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 通用模态表单(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 } 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 [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} onPress={onCancel}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, {
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderRadius: theme.radii.lg,
|
||||||
|
}]}
|
||||||
|
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={[styles.input, {
|
||||||
|
backgroundColor: theme.colors.bgTertiary,
|
||||||
|
color: theme.colors.fgPrimary,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</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',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 11,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
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 } 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 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={[styles.input, { flex: 2, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={p.amount ?? ''}
|
||||||
|
onChangeText={v => update(i, { amount: v })}
|
||||||
|
placeholder="金额"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { flex: 1, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={p.currency ?? ''}
|
||||||
|
onChangeText={v => update(i, { currency: v })}
|
||||||
|
placeholder="币种"
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={[styles.input, { width: 60, backgroundColor: theme.colors.bgTertiary, color: theme.colors.fgPrimary, borderColor: theme.colors.border }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<BalanceIndicator balances={balances} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', gap: 6 },
|
||||||
|
input: { borderWidth: 1, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 10, fontSize: 14 },
|
||||||
|
});
|
||||||
36
src/components/SearchBar.tsx
Normal file
36
src/components/SearchBar.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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.md }]}>
|
||||||
|
<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 },
|
||||||
|
});
|
||||||
189
src/components/SpeedDial.tsx
Normal file
189
src/components/SpeedDial.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
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) => {
|
||||||
|
action.onPress();
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'rgba(0,0,0,0.3)' }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开的快捷操作列表 */}
|
||||||
|
{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,
|
||||||
|
},
|
||||||
|
});
|
||||||
44
src/components/TagPicker.tsx
Normal file
44
src/components/TagPicker.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } 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();
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{tags.map(tag => {
|
||||||
|
const active = selectedNames.includes(tag.name);
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tag.id}
|
||||||
|
onPress={() => onToggle(tag)}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{
|
||||||
|
backgroundColor: active ? tag.color : theme.colors.bgTertiary,
|
||||||
|
borderColor: active ? tag.color : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={{ 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 },
|
||||||
|
chip: { paddingVertical: 4, paddingHorizontal: 12, borderRadius: 12, borderWidth: 1 },
|
||||||
|
});
|
||||||
60
src/components/TransactionCard.tsx
Normal file
60
src/components/TransactionCard.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
import type { Transaction } from '../domain/types';
|
||||||
|
|
||||||
|
interface TransactionCardProps {
|
||||||
|
transaction: Transaction;
|
||||||
|
onPress?: (t: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
|
||||||
|
export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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.md, padding: theme.spacing.md, opacity: pressed ? 0.7 : 1 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||||||
|
{transaction.narration || transaction.payee || '(无摘要)'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{transaction.date.slice(0, 10)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.body, { color, fontWeight: '700' }]}>
|
||||||
|
{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.accentDark }]}>
|
||||||
|
#{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' },
|
||||||
|
});
|
||||||
62
src/components/charts/AnnualReport.tsx
Normal file
62
src/components/charts/AnnualReport.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 年度报告图表(plan.md「5.2 年度报告」)。
|
||||||
|
* 展示年度收支/分类/月度趋势。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
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 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 }]}>{year} 年度报告</Text>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>收入</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 }]}>支出</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 }]}>结余</Text>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.accent }]}>{report.netIncome}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
交易 {report.transactionCount} 笔 · 日均支出 {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 }]}>Top 分类</Text>
|
||||||
|
{report.topCategories.slice(0, 5).map(c => (
|
||||||
|
<View key={c.category} style={styles.catRow}>
|
||||||
|
<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, borderTopColor: 'rgba(0,0,0,0.05)', paddingTop: 8 },
|
||||||
|
});
|
||||||
72
src/components/charts/CalendarHeatmap.tsx
Normal file
72
src/components/charts/CalendarHeatmap.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 日历热力图(plan.md「5.2 日历热力图」)。
|
||||||
|
* 按日期展示消费强度(颜色深浅)。
|
||||||
|
*/
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { getMonthGrid } from '../../domain/calendarGrid';
|
||||||
|
import { dailyExpenseMap } from '../../domain/chartStats';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
|
||||||
|
/** 根据强度返回颜色(0-1)。 */
|
||||||
|
function heatColor(intensity: number, baseColor: string): string {
|
||||||
|
if (intensity <= 0) return 'transparent';
|
||||||
|
const alpha = Math.min(0.2 + intensity * 0.8, 1);
|
||||||
|
return baseColor.replace(')', `,${alpha})`).replace('rgb', 'rgba');
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
|
|
||||||
|
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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 }]}>
|
||||||
|
{year}年{month}月热力图
|
||||||
|
</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
|
||||||
|
? heatColor(intensity, 'rgb(244,67,54)')
|
||||||
|
: 'transparent',
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
opacity: cell.isCurrentMonth ? 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 },
|
||||||
|
});
|
||||||
42
src/components/charts/CategoryPie.tsx
Normal file
42
src/components/charts/CategoryPie.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 分类占比图(plan.md「5.2 图表与可视化」)。
|
||||||
|
* 纯函数 + 简单横向柱状图(避免引入 pie 库)。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { groupByCategory } from '../../domain/chartStats';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
|
||||||
|
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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.md, padding: theme.spacing.md }]}>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>分类占比</Text>
|
||||||
|
{data.map(d => (
|
||||||
|
<View key={d.category} style={styles.row}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, flex: 1 }]} numberOfLines={1}>
|
||||||
|
{d.category.replace('Expenses:', '')}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.barWrap}>
|
||||||
|
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 50, textAlign: 'right' }]}>
|
||||||
|
{d.percentage}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>暂无数据</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { gap: 6 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
barWrap: { width: 100, height: 10, backgroundColor: 'rgba(0,0,0,0.05)', borderRadius: 5, overflow: 'hidden' },
|
||||||
|
bar: { height: '100%', borderRadius: 5 },
|
||||||
|
});
|
||||||
43
src/components/charts/MonthlyReport.tsx
Normal file
43
src/components/charts/MonthlyReport.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 月度报告图表(plan.md「5.2 图表与可视化」)。
|
||||||
|
* 数据计算为纯函数,图表渲染用简单 RN 组件(避免重图表库依赖)。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { groupByMonth } from '../../domain/chartStats';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
|
||||||
|
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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 }]}>月度报告</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}>
|
||||||
|
<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 }]}>暂无数据</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { gap: 6 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
barWrap: { flex: 1, height: 12, backgroundColor: 'rgba(0,0,0,0.05)', borderRadius: 6, overflow: 'hidden' },
|
||||||
|
bar: { height: '100%', borderRadius: 6 },
|
||||||
|
});
|
||||||
47
src/components/charts/NetWorthChart.tsx
Normal file
47
src/components/charts/NetWorthChart.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 净资产趋势图(plan.md「5.2 净资产趋势」)。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { calculateNetWorthTrend } from '../../domain/netWorth';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
|
||||||
|
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
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 }]}>净资产趋势</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 }]}>
|
||||||
|
当前净资产:{points[points.length - 1].netWorth} 元
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{points.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>暂无数据</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 },
|
||||||
|
});
|
||||||
49
src/components/charts/TrendLine.tsx
Normal file
49
src/components/charts/TrendLine.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 趋势线图(plan.md「5.2 图表与可视化」)。
|
||||||
|
* 纯函数 + 简单 SVG-like 折线(用 View 高度模拟)。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import type { Transaction } from '../../domain/types';
|
||||||
|
import { groupByMonth } from '../../domain/chartStats';
|
||||||
|
|
||||||
|
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const data = groupByMonth(transactions);
|
||||||
|
const maxVal = Math.max(...data.flatMap(d => [d.income, 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 }]}>收支趋势</Text>
|
||||||
|
<View style={styles.chart}>
|
||||||
|
{data.map(d => (
|
||||||
|
<View key={d.month} style={styles.col}>
|
||||||
|
<View style={styles.bars}>
|
||||||
|
<View style={[styles.bar, { height: `${(d.income / maxVal) * 100}%`, backgroundColor: theme.colors.financial.income }]} />
|
||||||
|
<View style={[styles.bar, { height: `${(d.expense / maxVal) * 100}%`, backgroundColor: theme.colors.financial.expense }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10 }]}>{d.month.slice(5)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>收入</Text>
|
||||||
|
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>支出</Text>
|
||||||
|
</View>
|
||||||
|
{data.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>暂无数据</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { gap: 8 },
|
||||||
|
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 8 },
|
||||||
|
col: { flex: 1, alignItems: 'center', gap: 4 },
|
||||||
|
bars: { flexDirection: 'row', alignItems: 'flex-end', gap: 2, height: 100 },
|
||||||
|
bar: { width: 8, minHeight: 2, borderRadius: 2 },
|
||||||
|
legend: { flexDirection: 'row', alignItems: 'center', gap: 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}`]),
|
||||||
|
};
|
||||||
|
}
|
||||||
103
src/domain/adapters.ts
Normal file
103
src/domain/adapters.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 银行 CSV 适配器(plan.md「2.5 CSV 导入增强」)。
|
||||||
|
* 补充招商银行、工商银行、通用银行适配器。
|
||||||
|
*
|
||||||
|
* 复用 statements.ts 的 parseCsv 模式,但各银行表头字段不同,
|
||||||
|
* 需要独立的字段映射与方向解析。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { negateDecimal } from './decimal';
|
||||||
|
import type { Direction, 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() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDirection(text: string): Direction {
|
||||||
|
if (/转账|转入|转出/i.test(text)) return 'transfer';
|
||||||
|
if (/退款/i.test(text)) return 'refund';
|
||||||
|
if (/手续费|利息/i.test(text)) return 'fee';
|
||||||
|
if (/支出|付款|消费|转出/i.test(text)) return 'expense';
|
||||||
|
return 'income';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 招商银行 CSV 适配器。 */
|
||||||
|
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 = parseDirection(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,
|
||||||
|
channel: 'Bank:CMB',
|
||||||
|
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 = parseDirection(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,
|
||||||
|
channel: 'Bank:ICBC',
|
||||||
|
counterparty: pickFirst(row, ['对方户名', '对方账户', '交易对方']),
|
||||||
|
memo: pickFirst(row, ['摘要', '交易描述', '备注']),
|
||||||
|
raw: row,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用银行 CSV 适配器(未知银行格式)。 */
|
||||||
|
export function parseGenericBankCsv(content: string, channel = '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 = parseDirection(type + amount);
|
||||||
|
if (direction === 'expense' && !amount.startsWith('-')) amount = negateDecimal(amount);
|
||||||
|
const date = pickFirst(row, ['交易日期', '日期', '时间', 'Date']).replace(/[/.]/g, '-').slice(0, 10);
|
||||||
|
return {
|
||||||
|
id: `${channel}:${pickFirst(row, ['交易序号', '流水号', 'id']) || i}`,
|
||||||
|
occurredAt: date || new Date().toISOString().slice(0, 10),
|
||||||
|
amount,
|
||||||
|
currency: pickFirst(row, ['币种', 'Currency']) || 'CNY',
|
||||||
|
direction,
|
||||||
|
channel,
|
||||||
|
counterparty: pickFirst(row, ['交易对方', '对方账户', '商户', 'Counterparty']),
|
||||||
|
memo: pickFirst(row, ['摘要', '备注', '描述', 'Memo']),
|
||||||
|
raw: row,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
195
src/domain/ai.ts
Normal file
195
src/domain/ai.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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(channel: string, counterparty: string, accounts: string[]): ChatMessage[] {
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: `判断最合适的资产账户。可用账户:${accounts.join('、')}。只返回账户名称。` },
|
||||||
|
{ role: 'user', content: sanitizeForAi(`渠道: ${channel}\n对方: ${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(
|
||||||
|
channel: string,
|
||||||
|
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(channel, counterparty, accounts));
|
||||||
|
const name = removeThink(response).trim();
|
||||||
|
return accounts.find(a => a === name) ?? null;
|
||||||
|
}
|
||||||
139
src/domain/annualReport.ts
Normal file
139
src/domain/annualReport.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 年度报告(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 totalIncome = sumByDirection(yearTx, 'income');
|
||||||
|
const totalExpense = sumByDirection(yearTx, 'expense');
|
||||||
|
const netIncome = addDecimals([totalIncome, totalExpense]); // expense 是负数
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
totalIncome,
|
||||||
|
totalExpense,
|
||||||
|
netIncome,
|
||||||
|
topCategories: getTopCategories(yearTx, 10),
|
||||||
|
monthlyTrend: getMonthlyTrend(yearTx),
|
||||||
|
dayOfWeekBreakdown: getDayOfWeekBreakdown(yearTx),
|
||||||
|
largestTransaction: getLargestTransaction(yearTx),
|
||||||
|
transactionCount: yearTx.length,
|
||||||
|
averageDailyExpense: calculateAverageDaily(yearTx),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumByDirection(transactions: Transaction[], direction: 'income' | 'expense'): string {
|
||||||
|
const amounts: string[] = [];
|
||||||
|
for (const t of transactions) {
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (!p.amount) continue;
|
||||||
|
const amt = parseFloat(p.amount);
|
||||||
|
// 支出:Expenses 账户的正金额(Beancount 约定,费用账户正余额=支出)
|
||||||
|
if (direction === 'expense' && p.account.startsWith('Expenses') && amt > 0) {
|
||||||
|
amounts.push(p.amount);
|
||||||
|
}
|
||||||
|
// 收入:Income 账户的负金额(冲减收入)取绝对值,或正金额(某些约定)
|
||||||
|
if (direction === 'income' && p.account.startsWith('Income')) {
|
||||||
|
amounts.push(amt < 0 ? p.amount.slice(1) : p.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addDecimals(amounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopCategories(transactions: Transaction[], limit: number): { category: string; amount: string; percentage: number }[] {
|
||||||
|
const byCategory = new Map<string, string>();
|
||||||
|
let total = '0';
|
||||||
|
for (const t of transactions) {
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
|
||||||
|
byCategory.set(p.account, addDecimals([byCategory.get(p.account) ?? '0', p.amount]));
|
||||||
|
total = addDecimals([total, p.amount]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entries = [...byCategory.entries()]
|
||||||
|
.map(([category, amount]) => ({
|
||||||
|
category,
|
||||||
|
amount,
|
||||||
|
percentage: parseFloat(total) > 0 ? Math.round((parseFloat(amount) / parseFloat(total)) * 10000) / 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
|
||||||
|
.slice(0, limit);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthlyTrend(transactions: Transaction[]): { month: string; income: string; expense: string }[] {
|
||||||
|
const months: { month: string; income: string; expense: string }[] = [];
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
const monthStr = String(m).padStart(2, '0');
|
||||||
|
const monthTx = transactions.filter(t => t.date.slice(5, 7) === monthStr);
|
||||||
|
months.push({
|
||||||
|
month: monthStr,
|
||||||
|
income: sumByDirection(monthTx, 'income'),
|
||||||
|
expense: sumByDirection(monthTx, 'expense'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayOfWeekBreakdown(transactions: Transaction[]): { day: string; total: string }[] {
|
||||||
|
const byDay = new Array(7).fill(0).map((_, i) => ({ day: DAY_NAMES[i], total: '0' }));
|
||||||
|
for (const t of transactions) {
|
||||||
|
const date = new Date(t.date.slice(0, 10));
|
||||||
|
const dow = date.getDay();
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
|
||||||
|
byDay[dow].total = addDecimals([byDay[dow].total, p.amount]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLargestTransaction(transactions: Transaction[]): Transaction | null {
|
||||||
|
let largest: Transaction | null = null;
|
||||||
|
let maxAmount = 0;
|
||||||
|
for (const t of transactions) {
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (p.amount && p.amount.startsWith('-')) {
|
||||||
|
const abs = parseFloat(p.amount.slice(1));
|
||||||
|
if (abs > maxAmount) {
|
||||||
|
maxAmount = abs;
|
||||||
|
largest = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return largest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAverageDaily(transactions: Transaction[]): string {
|
||||||
|
const total = sumByDirection(transactions, 'expense');
|
||||||
|
const days = transactions.length > 0
|
||||||
|
? new Set(transactions.map(t => t.date.slice(0, 10))).size
|
||||||
|
: 1;
|
||||||
|
return (parseFloat(total) / days).toFixed(2);
|
||||||
|
}
|
||||||
126
src/domain/billPipeline.ts
Normal file
126
src/domain/billPipeline.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { classifyWithCategories } from './rules';
|
||||||
|
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup';
|
||||||
|
import { recognizeTransfers, type TransferConfig } from './transferRecognizer';
|
||||||
|
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[];
|
||||||
|
/** 渠道 → 账户名映射(转账识别用)。 */
|
||||||
|
accountMap: Record<string, string>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.accountMap,
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
// 4a. 转账草稿
|
||||||
|
for (const transfer of transfers) {
|
||||||
|
drafts.push({
|
||||||
|
draft: transfer.draft,
|
||||||
|
sourceEventIds: [transfer.leftEventId, transfer.rightEventId],
|
||||||
|
isTransfer: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b. 非转账事件
|
||||||
|
for (const event of afterHistoryDedup) {
|
||||||
|
const result = classifyWithCategories(event, ctx.rules, ctx.categories, ctx.ledger);
|
||||||
|
drafts.push({
|
||||||
|
draft: result.draft,
|
||||||
|
sourceEventIds: [event.id],
|
||||||
|
isTransfer: false,
|
||||||
|
categoryFallbackReason: result.categoryFallbackReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
drafts,
|
||||||
|
duplicates: [...batchDups, ...historyDups],
|
||||||
|
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 === '其他') ?? 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);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
@ -24,3 +24,8 @@ export function compareDecimals(a: string, b: string): number {
|
|||||||
return left === right ? 0 : left > right ? 1 : -1;
|
return left === right ? 0 : left > right ? 1 : -1;
|
||||||
}
|
}
|
||||||
export function negateDecimal(value: string): string { return value.startsWith('-') ? value.slice(1) : `-${value}`; }
|
export function negateDecimal(value: string): string { return value.startsWith('-') ? value.slice(1) : `-${value}`; }
|
||||||
|
export function subtractDecimals(a: string, b: string): string { return addDecimals([a, negateDecimal(b)]); }
|
||||||
|
export function multiplyDecimals(a: string, b: string): string {
|
||||||
|
const x = parseDecimal(a); const y = parseDecimal(b);
|
||||||
|
return formatDecimal(x.coefficient * y.coefficient, x.scale + y.scale);
|
||||||
|
}
|
||||||
|
|||||||
176
src/domain/dedup.ts
Normal file
176
src/domain/dedup.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { compareDecimals } from './decimal';
|
||||||
|
import type { ImportedEvent, Transaction } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多通道联合去重(参考 AutoAccounting 的 DuplicateDetector)。
|
||||||
|
*
|
||||||
|
* 与 statements.ts 的 deduplicate(基于 ID)不同,本模块处理「跨通道同一笔交易」:
|
||||||
|
* 微信通知 + 银行短信 + OCR 截图 可能识别到同一笔交易,需联合去重。
|
||||||
|
*
|
||||||
|
* 判断规则(参考 AutoAccounting):
|
||||||
|
* 1. 时间完全相同 → 重复
|
||||||
|
* 2. 双方都是 AI 生成 → 非重复(AI 不稳定)
|
||||||
|
* 3. 规则相同且渠道相同 → 非重复(同一个人多次转账)
|
||||||
|
* 4. 规则相同但渠道不同 → 重复(微信通知 + 银行通知)
|
||||||
|
* 5. 时间窗口 + 金额匹配 → 重复(低置信度)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DedupConfig {
|
||||||
|
/** 时间窗口(分钟),默认 5。 */
|
||||||
|
timeWindowMinutes: number;
|
||||||
|
/** 是否启用账户关联检查。 */
|
||||||
|
checkAccountLink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_DEDUP_CONFIG: DedupConfig = {
|
||||||
|
timeWindowMinutes: 5,
|
||||||
|
checkAccountLink: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DedupConfidence = 'high' | 'medium' | 'low' | 'none';
|
||||||
|
|
||||||
|
export interface DedupResult {
|
||||||
|
isDuplicate: boolean;
|
||||||
|
confidence: DedupConfidence;
|
||||||
|
reason?: string;
|
||||||
|
/** 重复的来源事件/交易 id。 */
|
||||||
|
duplicateOf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去重比较所需的归一化形状(从 ImportedEvent 或 Transaction 映射)。 */
|
||||||
|
export interface DedupComparable {
|
||||||
|
time: string;
|
||||||
|
amount: string;
|
||||||
|
channel?: string;
|
||||||
|
counterparty?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 ImportedEvent 归一化为去重比较形状。 */
|
||||||
|
export function toComparable(event: ImportedEvent): DedupComparable {
|
||||||
|
return { time: event.occurredAt, amount: event.amount, channel: event.channel, counterparty: event.counterparty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把传入项(ImportedEvent 或 DedupComparable)归一化为 DedupComparable。 */
|
||||||
|
function normalize(item: ImportedEvent | DedupComparable): DedupComparable {
|
||||||
|
return 'occurredAt' in item
|
||||||
|
? { time: item.occurredAt, amount: item.amount, channel: item.channel, counterparty: item.counterparty }
|
||||||
|
: item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成去重指纹(时间 + 金额 + 渠道 + 对手方)。 */
|
||||||
|
export function dedupFingerprint(event: ImportedEvent): string {
|
||||||
|
const date = event.occurredAt.slice(0, 16);
|
||||||
|
const amount = Math.abs(parseFloat(event.amount)).toFixed(2);
|
||||||
|
return `${date}|${amount}|${event.channel}|${event.counterparty}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查单笔事件是否与已存在的事件/交易重复。
|
||||||
|
* existing 可传 ImportedEvent 或已归一化的 DedupComparable(与历史交易去重时用)。
|
||||||
|
*/
|
||||||
|
export function checkDuplicate(
|
||||||
|
event: ImportedEvent,
|
||||||
|
existing: (ImportedEvent | DedupComparable)[],
|
||||||
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
||||||
|
): DedupResult {
|
||||||
|
const eventTime = Date.parse(event.occurredAt);
|
||||||
|
const eventAmountAbs = Math.abs(parseFloat(event.amount)).toFixed(2);
|
||||||
|
const maxDiff = config.timeWindowMinutes * 60 * 1000;
|
||||||
|
const existingNorm = existing.map(normalize);
|
||||||
|
|
||||||
|
for (const ex of existingNorm) {
|
||||||
|
const exTime = Date.parse(ex.time);
|
||||||
|
const timeDiff = Math.abs(eventTime - exTime);
|
||||||
|
// 金额正负号不同(例如一收一支),绝对不可能是重复交易
|
||||||
|
const eventSign = parseFloat(event.amount) >= 0 ? 1 : -1;
|
||||||
|
const exSign = parseFloat(ex.amount) >= 0 ? 1 : -1;
|
||||||
|
if (eventSign !== exSign) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间完全相同 → 高置信度重复
|
||||||
|
if (timeDiff === 0 && compareDecimals(eventAmountAbs, Math.abs(parseFloat(ex.amount)).toFixed(2)) === 0) {
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
confidence: 'high',
|
||||||
|
reason: `时间相同、金额一致(${ex.time})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间窗口内 + 金额匹配
|
||||||
|
if (timeDiff <= maxDiff && compareDecimals(eventAmountAbs, Math.abs(parseFloat(ex.amount)).toFixed(2)) === 0) {
|
||||||
|
// 渠道检查:相同渠道相同金额(可能同人多次)→ 非重复;不同渠道 → 重复
|
||||||
|
if (event.channel && ex.channel) {
|
||||||
|
if (event.channel !== ex.channel) {
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
confidence: 'medium',
|
||||||
|
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、不同渠道(${event.channel} vs ${ex.channel})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 相同渠道相同金额:可能是同一个人多次转账,不判重
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无渠道信息:低置信度重复
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDuplicate: false, confidence: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量去重:从一组事件中筛出重复的,保留首个出现的。
|
||||||
|
* 返回去重后的列表与被丢弃的重复项。
|
||||||
|
*/
|
||||||
|
export function batchDedup(
|
||||||
|
events: ImportedEvent[],
|
||||||
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
||||||
|
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
||||||
|
const accepted: ImportedEvent[] = [];
|
||||||
|
const duplicates: ImportedEvent[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const result = checkDuplicate(event, accepted, config);
|
||||||
|
if (result.isDuplicate) {
|
||||||
|
duplicates.push(event);
|
||||||
|
} else {
|
||||||
|
accepted.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted, duplicates };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与历史交易(已入账)去重。
|
||||||
|
* 用于检查新导入的事件是否已在 mobile.bean 里(避免重复入账)。
|
||||||
|
*/
|
||||||
|
export function dedupAgainstHistory(
|
||||||
|
events: ImportedEvent[],
|
||||||
|
history: Transaction[],
|
||||||
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
||||||
|
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
||||||
|
const accepted: ImportedEvent[] = [];
|
||||||
|
const duplicates: ImportedEvent[] = [];
|
||||||
|
const historyForCompare = history.map(t => ({
|
||||||
|
time: t.date,
|
||||||
|
amount: t.postings[0]?.amount ?? '0',
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const result = checkDuplicate(event, historyForCompare, config);
|
||||||
|
if (result.isDuplicate) {
|
||||||
|
duplicates.push(event);
|
||||||
|
} else {
|
||||||
|
accepted.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted, duplicates };
|
||||||
|
}
|
||||||
73
src/domain/grammar.md
Normal file
73
src/domain/grammar.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Beancount 语法覆盖率矩阵
|
||||||
|
|
||||||
|
> 本文档记录 `src/domain/ledger.ts` 解析器对 Beancount 语法的覆盖情况。
|
||||||
|
> 决策依据见 `plan.md`「已实现的核心功能」章节的 Tree-sitter 决策。
|
||||||
|
|
||||||
|
## 解析策略
|
||||||
|
|
||||||
|
- **写出的语法**:只生成 Beancount 标准语法的子集(见「写支持」列)
|
||||||
|
- **读入的语法**:尽量多识别真实用户账本的指令,不认识的记入 `unsupported`(只读保留),不阻断整体解析
|
||||||
|
- **只读保留**:`unsupported` 中的指令原样保留在 `LedgerIndex.files[].content`,导出时不变,桌面端可正常读取
|
||||||
|
|
||||||
|
## 覆盖率矩阵
|
||||||
|
|
||||||
|
### 指令(directive)
|
||||||
|
|
||||||
|
| 指令 | 读支持 | 写支持 | 说明 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| `open` 账户 | ✅ | ✅ | `2026-01-01 open Assets:Alipay CNY` |
|
||||||
|
| `close` 账户 | ✅ | ✅ | `2026-01-01 close Assets:Old` |
|
||||||
|
| 交易(`*`/`!`) | ✅ | ✅ | header + postings + metadata |
|
||||||
|
| `balance` 断言 | ✅(只读) | ❌ | 记入 `balances`,用于诊断校验 |
|
||||||
|
| `option` | ✅(只读) | ❌ | 记入 `options`(如 operating_currency) |
|
||||||
|
| `pad` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `note` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `commodity` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `price` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `event` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `document` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `query` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `custom` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `plugin` | ⚠️ 只读保留 | ❌ | 记入 `unsupported` |
|
||||||
|
| `include` | ✅(文件级) | ❌ | 由调用方处理多文件传入 |
|
||||||
|
|
||||||
|
### 交易语法
|
||||||
|
|
||||||
|
| 语法 | 读支持 | 写支持 | 示例 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| flag(`*` 已确认 / `!` 未确认) | ✅ | ✅(仅 `*`) | `2026-01-01 * "payee" "narration"` |
|
||||||
|
| payee + narration | ✅ | ✅ | 双引号 |
|
||||||
|
| 单 narration(无 payee) | ✅ | ✅ | |
|
||||||
|
| `#tag`(header 行) | ✅ | ✅ | `... "narration" #food #lunch` |
|
||||||
|
| `^link`(header 行) | ✅ | ❌ | |
|
||||||
|
| posting(account + amount + currency) | ✅ | ✅ | ` Assets:Alipay -24.50 CNY` |
|
||||||
|
| posting(无金额,elided leg) | ✅ | ❌ | 自动平衡腿 |
|
||||||
|
| `#tag`(posting 行) | ✅(部分) | ❌ | |
|
||||||
|
| metadata(交易级) | ✅ | ✅ | ` key: "value"`(header 后、posting 前) |
|
||||||
|
| metadata(posting 级) | ✅ | ❌ | ` key: "value"`(紧跟 posting 后) |
|
||||||
|
| cost `{...}` | ✅(解析为 cost 字段) | ❌ | ` Assets:Stocks 10 STOCK {120.00 CNY}` |
|
||||||
|
| price `@` | ✅(解析为 price 字段) | ❌ | ` Assets:Stocks 10 STOCK @ 130.00 CNY` |
|
||||||
|
| 多行 posting | ✅ | ✅ | |
|
||||||
|
| 注释 `;` | ✅(跳过) | ❌ | |
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
| 语法 | 读支持 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 行内注释 `; comment` | ✅ | 跳过 |
|
||||||
|
| 全行注释 `;` / `*` | ✅ | 跳过 |
|
||||||
|
| BOM | ✅ | parseLedger 逐文件处理,BOM 视为首字符(statements.ts 的 parseCsv 才显式去 BOM) |
|
||||||
|
| CRLF | ✅ | `replace(/\r\n/g, '\n')` |
|
||||||
|
|
||||||
|
## 不支持的进阶语法(需 Tree-sitter 时处理)
|
||||||
|
|
||||||
|
以下语法在当前正则方案下**不解析**(原样保留),若真实用户账本大量使用,考虑上 `tree-sitter-beancount`:
|
||||||
|
|
||||||
|
- `2026-01-01 *` 后的 multi-currency posting 复杂 cost 表达式
|
||||||
|
- `units()`、`convert()`、`value()` 等表达式
|
||||||
|
- `META` 全局元数据块
|
||||||
|
- 跨行字符串
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
覆盖率回归测试见 `tests/ledger.test.ts`。
|
||||||
@ -3,3 +3,27 @@ export * from './ledger';
|
|||||||
export * from './statements';
|
export * from './statements';
|
||||||
export * from './rules';
|
export * from './rules';
|
||||||
export * from './mobile';
|
export * from './mobile';
|
||||||
|
export * from './mobileStore';
|
||||||
|
export * from './categories';
|
||||||
|
export * from './tags';
|
||||||
|
export * from './dedup';
|
||||||
|
export * from './transferRecognizer';
|
||||||
|
export * from './billPipeline';
|
||||||
|
export * from './transactionFlags';
|
||||||
|
export * from './keywordFilter';
|
||||||
|
export * from './remarkTemplate';
|
||||||
|
export * from './ruleEngine';
|
||||||
|
export * from './budgets';
|
||||||
|
export * from './recurring';
|
||||||
|
export * from './currency';
|
||||||
|
export * from './creditCards';
|
||||||
|
export * from './netWorth';
|
||||||
|
export * from './annualReport';
|
||||||
|
export * from './accountTree';
|
||||||
|
export * from './sync';
|
||||||
|
export * from './ai';
|
||||||
|
export * from './adapters';
|
||||||
|
export * from './ocr';
|
||||||
|
export * from './ocrProcessor';
|
||||||
|
export * from './calendarGrid';
|
||||||
|
export * from './chartStats';
|
||||||
|
|||||||
42
src/domain/keywordFilter.ts
Normal file
42
src/domain/keywordFilter.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 关键词过滤系统(plan.md「2.10 关键词过滤系统」)。
|
||||||
|
* 参考 AutoAccounting 的 AnalysisUtils(白名单/黑名单)。
|
||||||
|
* 用于通知/短信监听时过滤非账单内容(广告/推广/红包封面等)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface KeywordFilter {
|
||||||
|
/** 白名单:必须包含任一关键词才通过(空列表 = 不检查白名单)。 */
|
||||||
|
whitelist: string[];
|
||||||
|
/** 黑名单:包含任一关键词则拒绝。 */
|
||||||
|
blacklist: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FILTER: KeywordFilter = {
|
||||||
|
whitelist: ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'],
|
||||||
|
blacklist: ['广告', '推广', '优惠券', '红包封面', '会员到期', '活动'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断文本是否通过关键词过滤。
|
||||||
|
* @returns true = 通过(可能是账单),false = 拒绝(非账单)
|
||||||
|
*/
|
||||||
|
export function passesKeywordFilter(text: string, filter: KeywordFilter = DEFAULT_FILTER): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
|
||||||
|
// 黑名单检查:命中任一即拒绝
|
||||||
|
if (filter.blacklist.some(kw => text.includes(kw))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单检查:空列表表示不限制
|
||||||
|
if (filter.whitelist.length === 0) return true;
|
||||||
|
return filter.whitelist.some(kw => text.includes(kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并用户自定义过滤与默认(用户优先)。 */
|
||||||
|
export function mergeFilter(custom: Partial<KeywordFilter>, base: KeywordFilter = DEFAULT_FILTER): KeywordFilter {
|
||||||
|
return {
|
||||||
|
whitelist: custom.whitelist ?? base.whitelist,
|
||||||
|
blacklist: custom.blacklist ?? base.blacklist,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,35 +1,188 @@
|
|||||||
import { addDecimals } from './decimal';
|
import { addDecimals } from './decimal';
|
||||||
import type { Account, Diagnostic, LedgerFile, LedgerIndex, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
|
import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
|
||||||
|
|
||||||
const OPEN = /^(\d{4}-\d{2}-\d{2})\s+open\s+([^\s]+)(?:\s+([A-Z][A-Z0-9_-]*))?/;
|
/**
|
||||||
const CLOSE = /^(\d{4}-\d{2}-\d{2})\s+close\s+([^\s]+)/;
|
* Beancount 解析器(增强版正则)。
|
||||||
const TXN = /^(\d{4}-\d{2}-\d{2})\s+([*!])(?:\s+"([^"]*)")?(?:\s+"([^"]*)")?/;
|
*
|
||||||
const POSTING = /^\s+([^\s;]+)(?:\s+([+-]?[\d,]+(?:\.\d+)?)(?:\s+([A-Z][A-Z0-9_-]*))?)?/;
|
* 覆盖率矩阵见 src/domain/grammar.md。不认识的高级指令记入 `unsupported`(只读保留),
|
||||||
const hash = (text: string) => { let h = 2166136261; for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return (h >>> 0).toString(16); };
|
* 保证真实用户的复杂账本不会因个别指令解析失败而整体不可用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- 指令正则 ----
|
||||||
|
const OPEN = /^(\d{4}-\d{2}-\d{2})\s+open\s+([^\s;]+)((?:\s+[A-Z][A-Z0-9_, -]*)*)/;
|
||||||
|
const CLOSE = /^(\d{4}-\d{2}-\d{2})\s+close\s+([^\s;]+)/;
|
||||||
|
const TXN = /^(\d{4}-\d{2}-\d{2})\s+([*!])(?:\s+"((?:[^"\\]|\\.)*)")?(?:\s+"((?:[^"\\]|\\.)*)")?/;
|
||||||
|
const BALANCE = /^(\d{4}-\d{2}-\d{2})\s+balance\s+([^\s;]+)\s+(-?[+-]?[\d,]+(?:\.\d+)?)\s+([A-Z][A-Z0-9_-]*)?/;
|
||||||
|
const OPTION = /^option\s+"([^"]+)"\s+"((?:[^"\\]|\\.)*)"/;
|
||||||
|
const PAD = /^(\d{4}-\d{2}-\d{2})\s+pad\s+([^\s]+)\s+([^\s]+)/;
|
||||||
|
const NOTE = /^(\d{4}-\d{2}-\d{2})\s+note\s+([^\s]+)\s+"((?:[^"\\]|\\.)*)"/;
|
||||||
|
const DATE_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(commodity|price|event|document|query|custom|location|metad|query|plugin)\b/;
|
||||||
|
// posting:账户 + 可选金额/币种 + 可选 cost {...} + 可选 price @
|
||||||
|
const POSTING = /^\s+([^\s;]+)(?:\s+(-?[+-]?[\d,]+(?:\.\d+)?))?(?:\s+([A-Z][A-Z0-9_-]*))?(.*)$/;
|
||||||
|
// posting 行的 metadata: key: "value"
|
||||||
|
const POSTING_META = /^\s+([A-Za-z_][\w-]*):\s*"([^"]*)"/;
|
||||||
|
// 交易级/指令级 metadata(紧随 header 后的缩进行,非 posting)
|
||||||
|
const META = /^\s+([A-Za-z_][\w-]*):\s*"([^"]*)"/;
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit hash,用于 id 生成与 checksum。非加密用途,碰撞率足够低。 */
|
||||||
|
export const hash = (text: string): string => {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
|
||||||
|
return (h >>> 0).toString(16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMetaLine = (line: string): [string, string] | null => {
|
||||||
|
const m = line.match(META);
|
||||||
|
return m ? [m[1], m[2]] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(open|close|balance|pad|note)\b/;
|
||||||
|
|
||||||
export function parseLedger(files: LedgerFile[]): LedgerIndex {
|
export function parseLedger(files: LedgerFile[]): LedgerIndex {
|
||||||
const accounts = new Map<string, Account>(); const transactions: Transaction[] = []; const diagnostics: Diagnostic[] = []; const unsupported: Diagnostic[] = [];
|
const accounts = new Map<string, Account>();
|
||||||
|
const transactions: Transaction[] = [];
|
||||||
|
const balances: BalanceAssertion[] = [];
|
||||||
|
const options: Option[] = [];
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
const unsupported: Diagnostic[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const lines = file.content.replace(/\r\n/g, '\n').split('\n');
|
const lines = file.content.replace(/\r\n/g, '\n').split('\n');
|
||||||
for (let index = 0; index < lines.length; index++) {
|
let index = 0;
|
||||||
const line = lines[index]; const open = line.match(OPEN); const close = line.match(CLOSE); const start = line.match(TXN);
|
while (index < lines.length) {
|
||||||
if (open) { accounts.set(open[2], { name: open[2], openedOn: open[1], currencies: open[3] ? [open[3]] : [] }); continue; }
|
const line = lines[index];
|
||||||
if (close) { const current = accounts.get(close[2]); if (current) current.closedOn = close[1]; else diagnostics.push({ level: 'warning', file: file.path, line: index + 1, message: `关闭了未开户账户 ${close[2]}` }); continue; }
|
const trimmed = line.trim();
|
||||||
if (!start) { if (/^\d{4}-\d{2}-\d{2}\s+(commodity|price|balance|pad|event|note|document|query|custom)\b/.test(line)) unsupported.push({ level: 'warning', file: file.path, line: index + 1, message: '此指令以只读兼容模式保留' }); continue; }
|
|
||||||
const rawLines = [line]; const postings: Posting[] = []; let cursor = index + 1;
|
// 空行与注释跳过
|
||||||
while (cursor < lines.length && (/^\s/.test(lines[cursor]) || lines[cursor] === '')) {
|
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('*')) {
|
||||||
rawLines.push(lines[cursor]); const posting = lines[cursor].match(POSTING);
|
index++;
|
||||||
if (posting && posting[1] && !posting[1].startsWith(';')) postings.push({ account: posting[1], amount: posting[2]?.replace(/,/g, ''), currency: posting[3] });
|
continue;
|
||||||
cursor++;
|
|
||||||
}
|
}
|
||||||
const raw = rawLines.join('\n'); transactions.push({ id: hash(`${file.path}:${index}:${raw}`), date: start[1], flag: start[2], payee: start[4] ? start[3] : undefined, narration: start[4] ?? start[3] ?? '', postings, tags: [...line.matchAll(/#([\w-]+)/g)].map(m => m[1]), links: [...line.matchAll(/\^([\w-]+)/g)].map(m => m[1]), source: file.path, raw }); index = cursor - 1;
|
|
||||||
|
// option 指令(无日期前缀)
|
||||||
|
const opt = line.match(OPTION);
|
||||||
|
if (opt) {
|
||||||
|
options.push({ key: opt[1], value: opt[2], source: file.path, line: index + 1 });
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// plugin 指令
|
||||||
|
if (/^plugin\s+"/.test(line)) {
|
||||||
|
unsupported.push({ level: 'warning', file: file.path, line: index + 1, message: `plugin 指令以只读兼容模式保留` });
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = line.match(OPEN);
|
||||||
|
if (open) {
|
||||||
|
const currencies = (open[3] || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
accounts.set(open[2], { name: open[2], openedOn: open[1], currencies });
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = line.match(CLOSE);
|
||||||
|
if (close) {
|
||||||
|
const current = accounts.get(close[2]);
|
||||||
|
if (current) current.closedOn = close[1];
|
||||||
|
else diagnostics.push({ level: 'warning', file: file.path, line: index + 1, message: `关闭了未开户账户 ${close[2]}` });
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bal = line.match(BALANCE);
|
||||||
|
if (bal) {
|
||||||
|
balances.push({ date: bal[1], account: bal[2], amount: bal[3].replace(/,/g, ''), currency: bal[4], source: file.path, line: index + 1 });
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他日期型指令(commodity/price/event/document/query/custom/pad/note)
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}\s/.test(line) && !TXN.test(line) && !SUPPORTED_DIRECTIVE.test(line)) {
|
||||||
|
const kind = line.match(/^\d{4}-\d{2}-\d{2}\s+(\w+)/)?.[1] ?? 'unknown';
|
||||||
|
unsupported.push({ level: 'warning', file: file.path, line: index + 1, message: `${kind} 指令以只读兼容模式保留` });
|
||||||
|
// pad/note 这类可能带后续缩进行,一并跳过
|
||||||
|
index++;
|
||||||
|
while (index < lines.length && (/^\s/.test(lines[index]) && lines[index].trim() !== '')) index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易
|
||||||
|
const start = line.match(TXN);
|
||||||
|
if (start) {
|
||||||
|
const rawLines = [line];
|
||||||
|
const postings: Posting[] = [];
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
const tags = [...line.matchAll(/#([\w-]+)/g)].map(m => m[1]);
|
||||||
|
const links = [...line.matchAll(/\^([\w-]+)/g)].map(m => m[1]);
|
||||||
|
let cursor = index + 1;
|
||||||
|
let sawPosting = false;
|
||||||
|
while (cursor < lines.length && (/^\s/.test(lines[cursor]) || lines[cursor] === '')) {
|
||||||
|
const sub = lines[cursor];
|
||||||
|
rawLines.push(sub);
|
||||||
|
if (sub.trim() === '' || sub.trim().startsWith(';')) { cursor++; continue; }
|
||||||
|
|
||||||
|
// 先尝试 posting(账户名 + 可选金额)
|
||||||
|
const posting = sub.match(POSTING);
|
||||||
|
const meta = parseMetaLine(sub);
|
||||||
|
if (posting && posting[1] && !posting[1].startsWith(';') && posting[2]) {
|
||||||
|
// 有金额 → posting
|
||||||
|
const p: Posting = {
|
||||||
|
account: posting[1],
|
||||||
|
amount: posting[2].replace(/,/g, ''),
|
||||||
|
currency: posting[3],
|
||||||
|
};
|
||||||
|
// 解析尾部 cost {...} / price @
|
||||||
|
const tail = posting[4] || '';
|
||||||
|
const costMatch = tail.match(/\{([^}]+)\}/);
|
||||||
|
if (costMatch) p.cost = costMatch[1].trim();
|
||||||
|
const priceMatch = tail.match(/@\s*(-?[\d,]+(?:\.\d+)?)/);
|
||||||
|
if (priceMatch) p.price = priceMatch[1].replace(/,/g, '');
|
||||||
|
postings.push(p);
|
||||||
|
sawPosting = true;
|
||||||
|
} else if (posting && posting[1] && !posting[1].startsWith(';') && !meta) {
|
||||||
|
// 无金额的 posting(如自动平衡的 elided leg)
|
||||||
|
postings.push({ account: posting[1] });
|
||||||
|
sawPosting = true;
|
||||||
|
} else if (meta) {
|
||||||
|
// metadata 行:归属到交易级(若未出现 posting)或上一个 posting(若已出现)
|
||||||
|
if (sawPosting && postings.length > 0) {
|
||||||
|
const last = postings[postings.length - 1];
|
||||||
|
last.metadata = { ...(last.metadata || {}), [meta[0]]: meta[1] };
|
||||||
|
} else {
|
||||||
|
metadata[meta[0]] = meta[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
const raw = rawLines.join('\n');
|
||||||
|
transactions.push({
|
||||||
|
id: hash(`${file.path}:${raw}`),
|
||||||
|
date: start[1], flag: start[2],
|
||||||
|
payee: start[4] ? start[3] : undefined,
|
||||||
|
narration: start[4] ?? start[3] ?? '',
|
||||||
|
postings, tags, links, source: file.path, raw,
|
||||||
|
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||||
|
});
|
||||||
|
index = cursor;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// include 指令(文件级,由调用方处理多文件)
|
||||||
|
if (/^include\s+"/.test(line)) { index++; continue; }
|
||||||
|
|
||||||
|
// 未识别行
|
||||||
|
index++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { files, accounts, transactions, diagnostics, unsupported };
|
|
||||||
|
return { files, accounts, transactions, balances, options, diagnostics, unsupported };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex): ValidationResult {
|
export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex): ValidationResult {
|
||||||
const errors: string[] = []; const grouped = new Map<string, string[]>();
|
const errors: string[] = [];
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.date)) errors.push('日期必须为 YYYY-MM-DD');
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(draft.date)) errors.push('日期必须为 YYYY-MM-DD');
|
||||||
if (draft.postings.length < 2) errors.push('交易至少需要两条分录');
|
if (draft.postings.length < 2) errors.push('交易至少需要两条分录');
|
||||||
for (const posting of draft.postings) {
|
for (const posting of draft.postings) {
|
||||||
@ -39,11 +192,91 @@ export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex
|
|||||||
if (posting.amount && posting.currency) grouped.set(posting.currency, [...(grouped.get(posting.currency) ?? []), posting.amount]);
|
if (posting.amount && posting.currency) grouped.set(posting.currency, [...(grouped.get(posting.currency) ?? []), posting.amount]);
|
||||||
}
|
}
|
||||||
const balances: Record<string, string> = {};
|
const balances: Record<string, string> = {};
|
||||||
for (const [currency, amounts] of grouped) { balances[currency] = addDecimals(amounts); if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`); }
|
for (const [currency, amounts] of grouped) {
|
||||||
|
balances[currency] = addDecimals(amounts);
|
||||||
|
if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`);
|
||||||
|
}
|
||||||
return { valid: errors.length === 0, errors, balances };
|
return { valid: errors.length === 0, errors, balances };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeTransaction(draft: TransactionDraft): string {
|
export function serializeTransaction(draft: TransactionDraft): string {
|
||||||
const header = `${draft.date} *${draft.payee ? ` "${draft.payee}"` : ''} "${draft.narration}"${(draft.tags ?? []).map(t => ` #${t}`).join('')}`;
|
const flag = draft.flag || '*';
|
||||||
return `${header}\n${draft.postings.map(p => ` ${p.account}${p.amount ? ` ${p.amount}${p.currency ? ` ${p.currency}` : ''}` : ''}`).join('\n')}\n`;
|
const metaLines = draft.metadata
|
||||||
|
? Object.entries(draft.metadata).map(([k, v]) => ` ${k}: "${v}"`).join('\n') + '\n'
|
||||||
|
: '';
|
||||||
|
const tagsStr = (draft.tags ?? []).map(t => ` #${t}`).join('');
|
||||||
|
const linksStr = (draft.links ?? []).map(l => ` ^${l}`).join('');
|
||||||
|
const header = `${draft.date} ${flag}${draft.payee ? ` "${draft.payee}"` : ''} "${draft.narration}"${tagsStr}${linksStr}`;
|
||||||
|
const postingLines = draft.postings.map(p => {
|
||||||
|
let line = ` ${p.account}`;
|
||||||
|
if (p.amount) {
|
||||||
|
line += ` ${p.amount}`;
|
||||||
|
if (p.currency) line += ` ${p.currency}`;
|
||||||
|
}
|
||||||
|
// 输出 cost {...}
|
||||||
|
if (p.cost) line += ` {${p.cost}}`;
|
||||||
|
// 输出 price @
|
||||||
|
if (p.price) line += ` @ ${p.price}`;
|
||||||
|
return line;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// 输出 posting 级 metadata(每个 posting 的 metadata 作为独立行跟在对应 posting 后)
|
||||||
|
const lines = postingLines.split('\n');
|
||||||
|
const postingMetaLines: string[] = [];
|
||||||
|
draft.postings.forEach((p, i) => {
|
||||||
|
if (p.metadata) {
|
||||||
|
for (const [k, v] of Object.entries(p.metadata)) {
|
||||||
|
// 在对应 posting 行后插入 metadata 行
|
||||||
|
lines.splice(i + 1 + postingMetaLines.length, 0, ` ${k}: "${v}"`);
|
||||||
|
postingMetaLines.push('placeholder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${header}\n${metaLines}${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算包含 balance 断言在内的各账户实时余额。 */
|
||||||
|
export function computeAccountBalances(ledger: LedgerIndex): Map<string, string> {
|
||||||
|
const balances = new Map<string, string>();
|
||||||
|
|
||||||
|
// 1. 找出每个账户最晚的 balance 余额断言
|
||||||
|
const latestBalances = new Map<string, { amount: string; date: string }>();
|
||||||
|
for (const b of ledger.balances) {
|
||||||
|
const existing = latestBalances.get(b.account);
|
||||||
|
if (!existing || b.date > existing.date) {
|
||||||
|
latestBalances.set(b.account, { amount: b.amount, date: b.date });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化为断言值
|
||||||
|
for (const [account, info] of latestBalances) {
|
||||||
|
balances.set(account, info.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 累加所有在此断言日期当天或之后的交易(若无断言则从 0 累加所有交易)
|
||||||
|
for (const t of ledger.transactions) {
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (!p.amount) continue;
|
||||||
|
|
||||||
|
const assertion = latestBalances.get(p.account);
|
||||||
|
if (assertion && t.date < assertion.date) {
|
||||||
|
continue; // 交易在断言日期之前,已被断言包含,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
balances.set(p.account, addDecimals([balances.get(p.account) ?? '0', p.amount]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从账本的 option 指令读取默认币种(operating_currency)。
|
||||||
|
* 找不到时回退到 CNY。
|
||||||
|
*/
|
||||||
|
export function getDefaultCurrency(ledger: LedgerIndex | null): string {
|
||||||
|
if (!ledger) return 'CNY';
|
||||||
|
const oc = ledger.options.find(o => o.key === 'operating_currency');
|
||||||
|
return oc?.value || 'CNY';
|
||||||
}
|
}
|
||||||
|
|||||||
165
src/domain/mobileStore.ts
Normal file
165
src/domain/mobileStore.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { commitMobileTransaction } from './mobile';
|
||||||
|
import { hash, serializeTransaction } from './ledger';
|
||||||
|
import type { LedgerIndex, TransactionDraft } from './types';
|
||||||
|
import type { MobileCommit } from './mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mobile.bean 的工程化存储:并发写入锁、原子写入、崩溃恢复。
|
||||||
|
*
|
||||||
|
* 设计要点(见 plan.md「0.5 mobile.bean 写入的并发安全与原子性」):
|
||||||
|
* - 并发写入串行化(Promise 队列),避免多通道并发导致内容交错
|
||||||
|
* - 原子写入(临时文件 + rename),中途崩溃不损坏原文件
|
||||||
|
* - 崩溃恢复(启动时检测 tmp 残留),丢弃未完成写入
|
||||||
|
*
|
||||||
|
* 持久化通过注入的 MobileBeanBackend 抽象,使本模块可单测(无需 expo-file-system)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 持久化后端抽象(生产环境用 expo-file-system,测试用内存实现)。 */
|
||||||
|
export interface MobileBeanBackend {
|
||||||
|
/** 读取当前 mobile.bean 内容;不存在返回空串。 */
|
||||||
|
read(): Promise<string>;
|
||||||
|
/** 原子写入:写 tmp → rename,保证中途崩溃不损坏原文件。 */
|
||||||
|
writeAtomic(content: string): Promise<void>;
|
||||||
|
/** 检测并清理上次崩溃的残留(启动时调用)。返回是否有残留。 */
|
||||||
|
recoverIfNeeded(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内存后端(测试与 demo 用)。不真正做原子性,但记录写入次数便于断言。
|
||||||
|
*/
|
||||||
|
export class MemoryBackend implements MobileBeanBackend {
|
||||||
|
private content = '';
|
||||||
|
private tmp: string | null = null;
|
||||||
|
public writeCount = 0;
|
||||||
|
public recovered = false;
|
||||||
|
|
||||||
|
constructor(initial = '') {
|
||||||
|
this.content = initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(): Promise<string> {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeAtomic(content: string): Promise<void> {
|
||||||
|
this.tmp = content; // 模拟写 tmp
|
||||||
|
this.content = this.tmp; // 模拟 rename
|
||||||
|
this.tmp = null;
|
||||||
|
this.writeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recoverIfNeeded(): Promise<boolean> {
|
||||||
|
// 内存后端无持久化,无残留;但若注入了 tmp(模拟崩溃),则清理
|
||||||
|
if (this.tmp !== null) {
|
||||||
|
this.tmp = null;
|
||||||
|
this.recovered = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试辅助:模拟「写了 tmp 但未 rename」的崩溃场景。 */
|
||||||
|
simulateCrashMidWrite(content: string): void {
|
||||||
|
this.tmp = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mobile.bean 存储:所有写入入口串行化,保证并发安全。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const store = new MobileBeanStore(backend);
|
||||||
|
* await store.init(); // 启动时恢复
|
||||||
|
* const commit = await store.append(draft, ledger);
|
||||||
|
*/
|
||||||
|
export class MobileBeanStore {
|
||||||
|
private current = '';
|
||||||
|
/** 串行化锁:所有写操作排队执行。 */
|
||||||
|
private chain: Promise<unknown> = Promise.resolve();
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(private readonly backend: MobileBeanBackend) {}
|
||||||
|
|
||||||
|
/** 启动时调用:恢复崩溃残留 + 加载当前内容。 */
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) return;
|
||||||
|
const hadResidue = await this.backend.recoverIfNeeded();
|
||||||
|
this.current = await this.backend.read();
|
||||||
|
this.initialized = true;
|
||||||
|
return void hadResidue; // 调用方可通过 recoverIfNeeded 的返回值感知
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前内存中的 mobile.bean 内容(init 后可用)。 */
|
||||||
|
getContent(): string {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加一笔交易到 mobile.bean。
|
||||||
|
* 串行化:多通道并发调用会排队,保证内容不交错。
|
||||||
|
*/
|
||||||
|
append(draft: TransactionDraft, ledger: LedgerIndex): Promise<MobileCommit> {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
const commit = commitMobileTransaction(draft, ledger, this.current);
|
||||||
|
this.current = commit.content;
|
||||||
|
await this.backend.writeAtomic(this.current);
|
||||||
|
return commit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用已解析的事件 id 标记来源(便于去重查询关联到已入账交易)。 */
|
||||||
|
appendWithSource(draft: TransactionDraft, ledger: LedgerIndex, sourceEventId?: string): Promise<MobileCommit> {
|
||||||
|
if (sourceEventId) draft = { ...draft, sourceEventId };
|
||||||
|
return this.append(draft, ledger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接替换内容(恢复/导入场景,绕过单笔校验)。
|
||||||
|
* 仍走串行化锁与原子写入。
|
||||||
|
*/
|
||||||
|
replace(content: string): Promise<void> {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
this.current = content;
|
||||||
|
await this.backend.writeAtomic(this.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前内容的 checksum(FNV-1a),用于缓存失效判断(替代不可靠的 content.length)。
|
||||||
|
* 反映 init() 后加载的内容;未 init 时为空串的 hash。
|
||||||
|
*/
|
||||||
|
checksum(): string {
|
||||||
|
return hash(this.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 序列化单笔 draft(不写入,仅预览)。 */
|
||||||
|
preview(draft: TransactionDraft): string {
|
||||||
|
return serializeTransaction(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 串行化执行:保证同一 store 上的写操作互斥。 */
|
||||||
|
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = this.chain.then(task, task);
|
||||||
|
// 不让单次失败阻塞后续排队(错误冒泡给调用方,但链继续)
|
||||||
|
this.chain = run.then(() => undefined, () => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从导入事件提取已入账事件 id(用于去重标记)。
|
||||||
|
* 移动端写入后,sourceEventId 即对应 imported_events.id。
|
||||||
|
*/
|
||||||
|
export function extractSourceEventId(draft: TransactionDraft): string | undefined {
|
||||||
|
return draft.sourceEventId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便捷类型:存储 + 后端 的工厂(生产环境注入 FileSystemBackend)。 */
|
||||||
|
export interface MobileStoreFactory {
|
||||||
|
backend: MobileBeanBackend;
|
||||||
|
store: MobileBeanStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMobileStore(backend: MobileBeanBackend): MobileBeanStore {
|
||||||
|
return new MobileBeanStore(backend);
|
||||||
|
}
|
||||||
60
src/domain/netWorth.ts
Normal file
60
src/domain/netWorth.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 净资产计算(plan.md「5.2 净资产趋势」)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { addDecimals, subtractDecimals } from './decimal';
|
||||||
|
import type { Transaction } from './types';
|
||||||
|
|
||||||
|
export interface NetWorthPoint {
|
||||||
|
date: string;
|
||||||
|
assets: string;
|
||||||
|
liabilities: string;
|
||||||
|
netWorth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按账户类型分组计算余额。 */
|
||||||
|
export function calculateBalanceByType(
|
||||||
|
transactions: Transaction[],
|
||||||
|
accountType: 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity',
|
||||||
|
): string {
|
||||||
|
const relevant = transactions.filter(t =>
|
||||||
|
t.postings.some(p => p.account.startsWith(accountType))
|
||||||
|
);
|
||||||
|
const amounts: string[] = [];
|
||||||
|
for (const t of relevant) {
|
||||||
|
for (const p of t.postings) {
|
||||||
|
if (p.account.startsWith(accountType) && p.amount) {
|
||||||
|
amounts.push(p.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addDecimals(amounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算某时点的资产/负债/净资产。 */
|
||||||
|
export function calculateNetWorth(
|
||||||
|
transactions: Transaction[],
|
||||||
|
asOfDate?: string,
|
||||||
|
): { assets: string; liabilities: string; netWorth: string } {
|
||||||
|
const filtered = asOfDate
|
||||||
|
? transactions.filter(t => t.date.slice(0, 10) <= asOfDate)
|
||||||
|
: transactions;
|
||||||
|
|
||||||
|
const assets = calculateBalanceByType(filtered, 'Assets');
|
||||||
|
const liabilities = calculateBalanceByType(filtered, 'Liabilities');
|
||||||
|
// 负债账户正常是负余额,取绝对值
|
||||||
|
const liabAbs = liabilities.startsWith('-') ? liabilities.slice(1) : liabilities;
|
||||||
|
const netWorth = subtractDecimals(assets, liabAbs.startsWith('-') ? liabAbs.slice(1) : liabAbs);
|
||||||
|
return { assets, liabilities: liabAbs, netWorth };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算净资产趋势(按日期序列)。 */
|
||||||
|
export function calculateNetWorthTrend(
|
||||||
|
transactions: Transaction[],
|
||||||
|
dates: string[],
|
||||||
|
): NetWorthPoint[] {
|
||||||
|
return dates.map(date => {
|
||||||
|
const { assets, liabilities, netWorth } = calculateNetWorth(transactions, date);
|
||||||
|
return { date, assets, liabilities, netWorth };
|
||||||
|
});
|
||||||
|
}
|
||||||
158
src/domain/ocr.ts
Normal file
158
src/domain/ocr.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* OCR 自动记账 - Layer 1 规则匹配与账单解析(plan.md「3.3 Layer 1」+「3.x BillParser」)。
|
||||||
|
*
|
||||||
|
* 参考 AutoAccounting 的 RuleMatcher(微信支付/支付宝/银行卡正则)+ BillParser。
|
||||||
|
* 本模块为纯函数,不依赖原生,可单测;输入是 OCR 识别文本,输出是 ImportedEvent。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Direction, ImportedEvent } from './types';
|
||||||
|
|
||||||
|
/** 规则匹配结果。 */
|
||||||
|
export interface OcrRuleMatch {
|
||||||
|
matched: boolean;
|
||||||
|
event: ImportedEvent | null;
|
||||||
|
/** 命中的规则名(微信/支付宝/银行卡/未知)。 */
|
||||||
|
ruleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcrRule {
|
||||||
|
name: string;
|
||||||
|
/** 主匹配模式(判断是否为该类账单)。 */
|
||||||
|
pattern: RegExp;
|
||||||
|
/** 字段提取器(金额/商户/时间/卡号)。 */
|
||||||
|
extractors: {
|
||||||
|
amount?: RegExp;
|
||||||
|
merchant?: RegExp;
|
||||||
|
time?: RegExp;
|
||||||
|
card?: RegExp;
|
||||||
|
direction?: RegExp;
|
||||||
|
};
|
||||||
|
/** 渠道名。 */
|
||||||
|
channel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参考 AutoAccounting RuleMatcher 的正则模式
|
||||||
|
const RULES: OcrRule[] = [
|
||||||
|
{
|
||||||
|
name: '微信支付',
|
||||||
|
pattern: /微信支付/,
|
||||||
|
extractors: {
|
||||||
|
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
|
||||||
|
merchant: /(?:商户|收款方|对方)\s*[::]?\s*([^\s,,。]+)/,
|
||||||
|
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
|
||||||
|
direction: /(收入|支出|收款|付款|退款|转账|消费)/,
|
||||||
|
},
|
||||||
|
channel: 'WeChat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '支付宝',
|
||||||
|
pattern: /支付宝|alipay/i,
|
||||||
|
extractors: {
|
||||||
|
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
|
||||||
|
merchant: /(?:收款方|对方|商户)\s*[::]?\s*([^\s,,。]+)/,
|
||||||
|
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
|
||||||
|
direction: /(收入|支出|收款|付款|退款|转账|消费)/,
|
||||||
|
},
|
||||||
|
channel: 'Alipay',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '银行卡',
|
||||||
|
pattern: /尾号\d{4}|信用卡|储蓄卡|借记卡/,
|
||||||
|
extractors: {
|
||||||
|
amount: /(?:消费|支出|金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
|
||||||
|
merchant: /(?:商户|对方|于)\s*[::]?\s*([^\s,,。]+)/,
|
||||||
|
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
|
||||||
|
card: /尾号\s*(\d{4})/,
|
||||||
|
direction: /(消费|支出|收入|转入|转出|退款|转账)/,
|
||||||
|
},
|
||||||
|
channel: 'Bank',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDirection(text: string, defaultDir: Direction = 'expense'): Direction {
|
||||||
|
// 优先级:退款 > 手续费 > 转账 > 支出 > 收入
|
||||||
|
// 注意:避免「收款方」中的「收款」被误判为收入,需检查完整词
|
||||||
|
if (/退款|退回/.test(text)) return 'refund';
|
||||||
|
if (/手续费|利息/.test(text)) return 'fee';
|
||||||
|
if (/支出|付款|消费|转出/.test(text)) return 'expense';
|
||||||
|
if (/转账|转入/.test(text)) return 'transfer';
|
||||||
|
if (/收入|到账|入账/.test(text)) return 'income';
|
||||||
|
// 「收款方」是商户标识,不判为收入;真正的收款是「向您收款」或独立「收款」
|
||||||
|
if (/收款方/.test(text)) return defaultDir; // 收款方是商户,保持默认
|
||||||
|
if (/收款/.test(text) && !/收款方/.test(text)) return 'income';
|
||||||
|
return defaultDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract(pattern: RegExp | undefined, text: string): string {
|
||||||
|
if (!pattern) return '';
|
||||||
|
return pattern.exec(text)?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer 1 规则匹配(plan.md「3.3」)。
|
||||||
|
* 快速、无成本:对 OCR 文本跑预定义正则,命中即提取。
|
||||||
|
*/
|
||||||
|
export function matchOcrRule(text: string, packageName?: string): OcrRuleMatch {
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (!rule.pattern.test(text)) continue;
|
||||||
|
|
||||||
|
const amountRaw = extract(rule.extractors.amount, text);
|
||||||
|
if (!amountRaw) continue; // 无金额不算账单
|
||||||
|
|
||||||
|
const merchant = extract(rule.extractors.merchant, text);
|
||||||
|
const time = extract(rule.extractors.time, text);
|
||||||
|
const card = extract(rule.extractors.card, text);
|
||||||
|
const dirText = extract(rule.extractors.direction, text) || text;
|
||||||
|
// 方向解析用完整文本(避免「收款方」中的「收款」被误判)
|
||||||
|
const direction = parseDirection(text);
|
||||||
|
|
||||||
|
const amount = direction === 'expense' ? `-${Math.abs(parseFloat(amountRaw))}` : String(Math.abs(parseFloat(amountRaw)));
|
||||||
|
|
||||||
|
const event: ImportedEvent = {
|
||||||
|
id: `ocr:${rule.channel}:${time || Date.now()}:${amountRaw}`,
|
||||||
|
occurredAt: time ? normalizeTime(time) : new Date().toISOString().slice(0, 16),
|
||||||
|
amount,
|
||||||
|
currency: 'CNY',
|
||||||
|
direction,
|
||||||
|
channel: card ? `${rule.channel}:${card}` : rule.channel,
|
||||||
|
counterparty: merchant,
|
||||||
|
memo: card ? `银行卡尾号${card}` : rule.name,
|
||||||
|
raw: { text, packageName: packageName ?? '' },
|
||||||
|
};
|
||||||
|
return { matched: true, event, ruleName: rule.name };
|
||||||
|
}
|
||||||
|
return { matched: false, event: null, ruleName: '未匹配' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化时间格式(支持 2026-07-13 14:30 / 2026/7/13 14:30)。 */
|
||||||
|
function normalizeTime(time: string): string {
|
||||||
|
return time.replace(/\//g, '-').replace(/-(\d)-/g, '-0$1-').replace(/-(\d) /, '-0$1 ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BillParser(plan.md「3.x」):解析 OCR 全文本为账单事件。
|
||||||
|
* 比 RuleMatcher 更宽松——尝试从任意文本中提取金额与方向。
|
||||||
|
*/
|
||||||
|
export function parseOcrBill(text: string, packageName?: string): ImportedEvent | null {
|
||||||
|
// 1. 先尝试规则匹配
|
||||||
|
const ruleMatch = matchOcrRule(text, packageName);
|
||||||
|
if (ruleMatch.matched && ruleMatch.event) return ruleMatch.event;
|
||||||
|
|
||||||
|
// 2. 退化:提取首个金额 + 推断方向
|
||||||
|
const amountMatch = text.match(/(?:¥|¥|金额|支付金额)\s*(\d+(?:\.\d+)?)/) ?? text.match(/(\d+(?:\.\d+)?)\s*(?:元)/);
|
||||||
|
if (!amountMatch) return null;
|
||||||
|
|
||||||
|
const amount = parseFloat(amountMatch[1]);
|
||||||
|
const direction = parseDirection(text);
|
||||||
|
return {
|
||||||
|
id: `ocr:fallback:${Date.now()}:${amountMatch[1]}`,
|
||||||
|
occurredAt: new Date().toISOString().slice(0, 16),
|
||||||
|
amount: direction === 'expense' ? `-${amount}` : String(amount),
|
||||||
|
currency: 'CNY',
|
||||||
|
direction,
|
||||||
|
channel: packageName ?? 'Unknown',
|
||||||
|
counterparty: '',
|
||||||
|
memo: 'OCR 退化解析',
|
||||||
|
raw: { text, packageName: packageName ?? '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
161
src/domain/ocrProcessor.ts
Normal file
161
src/domain/ocrProcessor.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* OCR 分层处理协调器(plan.md「3.1 分层处理架构」+「3.9 JS 层分层处理」)。
|
||||||
|
*
|
||||||
|
* Layer 1: 规则匹配(快速,无成本)→ 直接出账单
|
||||||
|
* Layer 2: OCR + 正则解析(中等精度,无成本)→ 出账单
|
||||||
|
* Layer 3: AI Vision(高精度,有成本)→ 可选,需用户启用
|
||||||
|
*
|
||||||
|
* 每层失败自动降级到下一层。OCR 引擎与 AI Provider 通过注入抽象。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { matchOcrRule, parseOcrBill } from './ocr';
|
||||||
|
import { hash } from './ledger';
|
||||||
|
import type { ImportedEvent } from './types';
|
||||||
|
|
||||||
|
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module,测试用 mock)。 */
|
||||||
|
export interface OcrEngine {
|
||||||
|
/** 识别图片文本,返回纯文本。 */
|
||||||
|
recognizeText(imageBase64: string): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI Vision 提供者抽象(Layer 3,可选)。 */
|
||||||
|
export interface AiVisionProvider {
|
||||||
|
/** 识别图片中的账单,返回账单事件或 null。 */
|
||||||
|
recognizeBill(imageBase64: string): Promise<ImportedEvent | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分层处理结果。 */
|
||||||
|
export interface OcrProcessResult {
|
||||||
|
/** 最终识别出的事件(可能为 null 表示三层都未识别)。 */
|
||||||
|
event: ImportedEvent | null;
|
||||||
|
/** 命中的层级。 */
|
||||||
|
layer: 'layer1-rule' | 'layer2-ocr' | 'layer3-ai' | 'none';
|
||||||
|
/** OCR 原始文本(Layer 2 命中时有值)。 */
|
||||||
|
ocrText?: string;
|
||||||
|
/** 跳过原因(去重/免打扰等)。 */
|
||||||
|
skipped?: 'dedup' | 'landscape-dnd' | 'non-bill';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrProcessorConfig {
|
||||||
|
/** 是否启用 Layer 3 AI(默认 false,需用户启用)。 */
|
||||||
|
aiVisionEnabled: boolean;
|
||||||
|
/** 横屏免打扰模式。 */
|
||||||
|
landscapeDnd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR 处理器:协调三层降级 + 去重守卫。
|
||||||
|
*
|
||||||
|
* 去重守卫参考 AutoAccounting 的 ocrDoing:防止同一截图重复触发。
|
||||||
|
*/
|
||||||
|
export class OcrProcessor {
|
||||||
|
private processedHashes = new Set<string>();
|
||||||
|
private processing = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ocrEngine: OcrEngine,
|
||||||
|
private readonly aiProvider?: AiVisionProvider,
|
||||||
|
private readonly config: OcrProcessorConfig = { aiVisionEnabled: false, landscapeDnd: false },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理一张截图/图片,返回账单事件。
|
||||||
|
* 串行化:同时只处理一张(ocrDoing 守卫)。
|
||||||
|
*/
|
||||||
|
async process(
|
||||||
|
imageBase64: string,
|
||||||
|
packageName?: string,
|
||||||
|
isLandscape = false,
|
||||||
|
): Promise<OcrProcessResult> {
|
||||||
|
// 横屏免打扰
|
||||||
|
if (this.config.landscapeDnd && isLandscape) {
|
||||||
|
return { event: null, layer: 'none', skipped: 'landscape-dnd' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重守卫:同一图片不重复处理
|
||||||
|
const imageHash = hash(imageBase64.slice(0, 1000)); // 取前 1000 字符 hash 避免大图开销
|
||||||
|
if (this.processedHashes.has(imageHash)) {
|
||||||
|
return { event: null, layer: 'none', skipped: 'dedup' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 串行化(ocrDoing)
|
||||||
|
if (this.processing) {
|
||||||
|
return { event: null, layer: 'none', skipped: 'dedup' };
|
||||||
|
}
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.processedHashes.add(imageHash);
|
||||||
|
// 限制 hash 集合大小
|
||||||
|
if (this.processedHashes.size > 200) {
|
||||||
|
const first = this.processedHashes.values().next().value;
|
||||||
|
if (first) this.processedHashes.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: OCR 识别(Layer 1 基于文本,需先 OCR)
|
||||||
|
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
|
||||||
|
if (!ocrText || !ocrText.trim()) {
|
||||||
|
// 无文本,尝试 Layer 3
|
||||||
|
return await this.tryLayer3(imageBase64, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 1: 规则匹配(基于 OCR 文本)
|
||||||
|
const ruleMatch = matchOcrRule(ocrText, packageName);
|
||||||
|
if (ruleMatch.matched && ruleMatch.event) {
|
||||||
|
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: 退化解析
|
||||||
|
const fallback = parseOcrBill(ocrText, packageName);
|
||||||
|
if (fallback) {
|
||||||
|
return { event: fallback, layer: 'layer2-ocr', ocrText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非账单内容(billGuard),尝试 Layer 3
|
||||||
|
return await this.tryLayer3(imageBase64, ocrText);
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> {
|
||||||
|
if (!this.config.aiVisionEnabled || !this.aiProvider) {
|
||||||
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const event = await this.aiProvider.recognizeBill(imageBase64);
|
||||||
|
if (event) return { event, layer: 'layer3-ai', ocrText };
|
||||||
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
|
} catch {
|
||||||
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置去重状态(切换页面/应用重启时)。 */
|
||||||
|
reset(): void {
|
||||||
|
this.processedHashes.clear();
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 OCR 引擎(测试用,返回预设文本)。 */
|
||||||
|
export class MockOcrEngine implements OcrEngine {
|
||||||
|
constructor(private responses: Map<string, string> = new Map(), public defaultResponse = '') {}
|
||||||
|
async recognizeText(imageBase64: string): Promise<string> {
|
||||||
|
// 简单匹配:按图片 hash 前缀找预设
|
||||||
|
const h = hash(imageBase64.slice(0, 100));
|
||||||
|
return this.responses.get(h) ?? this.defaultResponse;
|
||||||
|
}
|
||||||
|
/** 测试辅助:注入预设响应。 */
|
||||||
|
setResponse(imageBase64: string, text: string): void {
|
||||||
|
this.responses.set(hash(imageBase64.slice(0, 100)), text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 AI Vision Provider(测试用)。 */
|
||||||
|
export class MockAiVisionProvider implements AiVisionProvider {
|
||||||
|
constructor(private response: ImportedEvent | null = null) {}
|
||||||
|
async recognizeBill(): Promise<ImportedEvent | null> {
|
||||||
|
return this.response;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/domain/recurring.ts
Normal file
47
src/domain/recurring.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 周期性记账(plan.md「5.6 周期性记账」)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TransactionDraft } from './types';
|
||||||
|
|
||||||
|
export interface RecurringTransaction {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
draft: TransactionDraft;
|
||||||
|
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||||
|
/** 间隔(如每 2 周 = weekly + interval: 2)。 */
|
||||||
|
interval: number;
|
||||||
|
nextDueDate: string;
|
||||||
|
enabled: boolean;
|
||||||
|
/** 是否自动确认(无需手动点击)。默认 false。 */
|
||||||
|
autoConfirm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查到期的周期性交易。 */
|
||||||
|
export function getDueRecurring(
|
||||||
|
recurring: RecurringTransaction[],
|
||||||
|
currentDate: string,
|
||||||
|
): RecurringTransaction[] {
|
||||||
|
return recurring.filter(r => r.enabled && r.nextDueDate <= currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算下次到期日期。 */
|
||||||
|
export function calculateNextDueDate(
|
||||||
|
frequency: RecurringTransaction['frequency'],
|
||||||
|
interval: number,
|
||||||
|
lastDueDate: string,
|
||||||
|
): string {
|
||||||
|
const date = new Date(lastDueDate);
|
||||||
|
switch (frequency) {
|
||||||
|
case 'daily': date.setDate(date.getDate() + interval); break;
|
||||||
|
case 'weekly': date.setDate(date.getDate() + 7 * interval); break;
|
||||||
|
case 'monthly': date.setMonth(date.getMonth() + interval); break;
|
||||||
|
case 'yearly': date.setFullYear(date.getFullYear() + interval); break;
|
||||||
|
}
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成到期实例(把 nextDueDate 设为 draft.date)。 */
|
||||||
|
export function instantiateRecurring(r: RecurringTransaction): TransactionDraft {
|
||||||
|
return { ...r.draft, date: r.nextDueDate };
|
||||||
|
}
|
||||||
57
src/domain/remarkTemplate.ts
Normal file
57
src/domain/remarkTemplate.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* 备注模板系统(plan.md「2.9 备注模板系统」)。
|
||||||
|
* 参考 AutoAccounting 的 Remark Template Engine(16 个占位符 + 相邻重复归一化)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ImportedEvent } from './types';
|
||||||
|
|
||||||
|
export interface RemarkTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
template: string; // 支持占位符,如 "【商户名称】-【金额】"
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 占位符 → 解析函数(参考 AutoAccounting 的 16 个占位符)。 */
|
||||||
|
const PLACEHOLDERS: Record<string, (e: ImportedEvent) => string> = {
|
||||||
|
'【商户名称】': (e) => e.counterparty || '未知商户',
|
||||||
|
'【商品名称】': (e) => e.memo || '', // 商品说明
|
||||||
|
'【金额】': (e) => e.amount,
|
||||||
|
'【分类】': (e) => e.raw?.['category'] || '', // 分类(需预填)
|
||||||
|
'【账本】': (e) => e.raw?.['bookName'] || '', // 账本名
|
||||||
|
'【来源】': (e) => e.channel || '', // 数据来源/渠道
|
||||||
|
'【原始资产】': (e) => e.raw?.['accountFrom'] || '', // 原始资产账户
|
||||||
|
'【目标资产】': (e) => e.raw?.['accountTo'] || '', // 目标资产账户
|
||||||
|
'【渠道】': (e) => e.channel || '',
|
||||||
|
'【规则名称】': (e) => e.raw?.['ruleName'] || '', // 命中规则名
|
||||||
|
'【AI】': (e) => e.raw?.['aiSuggestion'] || '', // AI 建议备注
|
||||||
|
'【货币类型】': (e) => e.currency || 'CNY',
|
||||||
|
'【手续费】': (e) => e.raw?.['fee'] || '',
|
||||||
|
'【标签】': (e) => e.raw?.['tags'] || '',
|
||||||
|
'【交易类型】': (e) => e.direction,
|
||||||
|
'【时间】': (e) => e.occurredAt.slice(11, 16), // HH:mm
|
||||||
|
'【日期】': (e) => e.occurredAt.slice(0, 10), // YYYY-MM-DD
|
||||||
|
'【备注】': (e) => e.memo || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 生成备注:替换模板中的占位符。 */
|
||||||
|
export function generateRemark(template: string, event: ImportedEvent): string {
|
||||||
|
let remark = template;
|
||||||
|
for (const [placeholder, resolver] of Object.entries(PLACEHOLDERS)) {
|
||||||
|
// 转义【】用于正则
|
||||||
|
const escaped = placeholder.replace(/[【】]/g, '\\$&');
|
||||||
|
remark = remark.replace(new RegExp(escaped, 'g'), resolver(event));
|
||||||
|
}
|
||||||
|
// 相邻重复归一化(如"买菜买菜"→"买菜")
|
||||||
|
remark = normalizeAdjacentDuplicates(remark);
|
||||||
|
return remark.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相邻重复归一化(参考 AutoAccounting normalizeName)。
|
||||||
|
* 只处理相邻重复,避免误删(如"京东自营京东自营旗舰店"→"京东自营旗舰店")。
|
||||||
|
*/
|
||||||
|
export function normalizeAdjacentDuplicates(text: string): string {
|
||||||
|
// 匹配连续重复的 2-4 字符片段(中英文)
|
||||||
|
return text.replace(/(.{2,4})\1+/g, '$1');
|
||||||
|
}
|
||||||
141
src/domain/ruleEngine.ts
Normal file
141
src/domain/ruleEngine.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 规则引擎增强(plan.md「2.6 规则引擎增强 JS 规则执行器」)。
|
||||||
|
*
|
||||||
|
* 参考 AutoAccounting 的 RuleGenerator(QuickJS,系统/用户规则分离)。
|
||||||
|
*
|
||||||
|
* 本模块定义规则执行器抽象:
|
||||||
|
* - JsRuleExecutor:在隔离上下文执行用户自定义 JS 规则
|
||||||
|
* - 生产环境可用 QuickJS(原生模块),测试环境用 Function 构造器的轻量沙箱
|
||||||
|
*
|
||||||
|
* 规则优先级:用户规则 > 系统规则;禁用规则可阻止 AI 触发(AutoAccounting RuleScope)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ImportedEvent } from './types';
|
||||||
|
|
||||||
|
export interface EnhancedRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
isSystem: boolean; // 系统规则 vs 用户规则
|
||||||
|
// 静态匹配条件(快速预筛选)
|
||||||
|
channel?: string;
|
||||||
|
direction?: ImportedEvent['direction'];
|
||||||
|
counterpartyContains?: string;
|
||||||
|
memoContains?: string;
|
||||||
|
minAmount?: string;
|
||||||
|
maxAmount?: string;
|
||||||
|
currency?: string;
|
||||||
|
// JS 规则体(动态精确匹配 + 字段提取)
|
||||||
|
jsCode?: string;
|
||||||
|
// 输出
|
||||||
|
channelAccount: string;
|
||||||
|
categoryAccount: string;
|
||||||
|
narration?: string;
|
||||||
|
tags?: string[];
|
||||||
|
// 统计
|
||||||
|
hits: number;
|
||||||
|
lastHitAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleExecutionResult {
|
||||||
|
matched: boolean;
|
||||||
|
extracted?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规则执行器抽象(生产用 QuickJS,测试用 Function 沙箱)。 */
|
||||||
|
export interface JsRuleExecutor {
|
||||||
|
/** 在隔离上下文执行 JS 规则,注入 data 对象。 */
|
||||||
|
execute(jsCode: string, data: Record<string, unknown>): RuleExecutionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轻量 JS 沙箱(用 Function 构造器,无原生依赖)。
|
||||||
|
* 生产环境应替换为 QuickJS(更严格的隔离),但本实现满足单测需求。
|
||||||
|
*/
|
||||||
|
export class FunctionSandboxExecutor implements JsRuleExecutor {
|
||||||
|
execute(jsCode: string, data: Record<string, unknown>): RuleExecutionResult {
|
||||||
|
try {
|
||||||
|
// 注入 data 和 print,返回 JSON
|
||||||
|
const fn = new Function('data', 'print', `"use strict";\n${jsCode}`);
|
||||||
|
const output: string[] = [];
|
||||||
|
const print = (s: string) => { output.push(s); };
|
||||||
|
fn(data, print);
|
||||||
|
// 规则通过 print(JSON.stringify(result)) 输出
|
||||||
|
const json = output.find(o => o.trim().startsWith('{'));
|
||||||
|
if (!json) return { matched: false };
|
||||||
|
const extracted = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
// matched 判定:提取到 money 且 > 0
|
||||||
|
const money = extracted.money ?? extracted.amount;
|
||||||
|
const matched = money !== undefined && parseFloat(String(money)) > 0;
|
||||||
|
return { matched, extracted };
|
||||||
|
} catch {
|
||||||
|
return { matched: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则引擎:批量执行规则,返回首个命中。
|
||||||
|
* 匹配顺序:用户规则优先(isSystem=false 先),同类型按 priority 降序。
|
||||||
|
*/
|
||||||
|
export class RuleEngine {
|
||||||
|
constructor(private readonly executor: JsRuleExecutor = new FunctionSandboxExecutor()) {}
|
||||||
|
|
||||||
|
matchEvent(event: ImportedEvent, rules: EnhancedRule[]): { rule: EnhancedRule; result: RuleExecutionResult } | null {
|
||||||
|
// 排序:用户规则 > 系统规则;同类型按 priority 降序
|
||||||
|
const sorted = [...rules]
|
||||||
|
.filter(r => r.enabled)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
|
||||||
|
return b.priority - a.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rule of sorted) {
|
||||||
|
// 静态条件预筛选
|
||||||
|
if (!this.matchStaticConditions(rule, event)) continue;
|
||||||
|
|
||||||
|
// JS 规则执行(若有)
|
||||||
|
if (rule.jsCode) {
|
||||||
|
const result = this.executor.execute(rule.jsCode, this.eventToData(event));
|
||||||
|
if (result.matched) {
|
||||||
|
rule.hits++;
|
||||||
|
rule.lastHitAt = new Date().toISOString();
|
||||||
|
return { rule, result };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无 JS 代码,静态匹配成功即命中
|
||||||
|
rule.hits++;
|
||||||
|
rule.lastHitAt = new Date().toISOString();
|
||||||
|
return { rule, result: { matched: true } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静态条件预筛选(参考 rules.ts 的 matches)。 */
|
||||||
|
private matchStaticConditions(rule: EnhancedRule, event: ImportedEvent): boolean {
|
||||||
|
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
|
||||||
|
return (!rule.channel || rule.channel === event.channel)
|
||||||
|
&& (!rule.direction || rule.direction === event.direction)
|
||||||
|
&& (!rule.currency || rule.currency === event.currency)
|
||||||
|
&& contains(event.counterparty, rule.counterpartyContains)
|
||||||
|
&& contains(event.memo, rule.memoContains)
|
||||||
|
&& (!rule.minAmount || parseFloat(event.amount.replace('-', '')) >= parseFloat(rule.minAmount))
|
||||||
|
&& (!rule.maxAmount || parseFloat(event.amount.replace('-', '')) <= parseFloat(rule.maxAmount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 ImportedEvent 转为规则执行数据。 */
|
||||||
|
private eventToData(event: ImportedEvent): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
app: event.channel,
|
||||||
|
type: event.direction,
|
||||||
|
money: event.amount,
|
||||||
|
shopName: event.counterparty,
|
||||||
|
shopItem: event.memo,
|
||||||
|
time: event.occurredAt,
|
||||||
|
currency: event.currency,
|
||||||
|
...event.raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,19 @@
|
|||||||
import { compareDecimals, negateDecimal } from './decimal';
|
import { compareDecimals, negateDecimal } from './decimal';
|
||||||
|
import { matchCategory, resolveCategoryAccount, type Category } from './categories';
|
||||||
import type { Classification, ImportedEvent, LedgerIndex, Rule, TransactionDraft } from './types';
|
import type { Classification, ImportedEvent, LedgerIndex, Rule, TransactionDraft } from './types';
|
||||||
|
import { generateRemark } from './remarkTemplate';
|
||||||
|
|
||||||
|
function getNarration(ruleNarration: string | undefined, event: ImportedEvent): string {
|
||||||
|
if (ruleNarration) {
|
||||||
|
return ruleNarration.includes('【') ? generateRemark(ruleNarration, event) : ruleNarration;
|
||||||
|
}
|
||||||
|
const main = event.counterparty || event.memo;
|
||||||
|
if (!main) return '导入账单';
|
||||||
|
if (event.counterparty && event.memo) {
|
||||||
|
return generateRemark('【商户名称】 - 【商品名称】', event);
|
||||||
|
}
|
||||||
|
return main;
|
||||||
|
}
|
||||||
|
|
||||||
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
|
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
|
||||||
function matches(event: ImportedEvent, rule: Rule): boolean {
|
function matches(event: ImportedEvent, rule: Rule): boolean {
|
||||||
@ -7,24 +21,156 @@ function matches(event: ImportedEvent, rule: Rule): boolean {
|
|||||||
(!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) &&
|
(!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) &&
|
||||||
(!rule.minAmount || compareDecimals(event.amount.replace('-', ''), rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(event.amount.replace('-', ''), rule.maxAmount) <= 0);
|
(!rule.minAmount || compareDecimals(event.amount.replace('-', ''), rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(event.amount.replace('-', ''), rule.maxAmount) <= 0);
|
||||||
}
|
}
|
||||||
|
function first(row: Record<string, string>, keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (row[k] !== undefined) return row[k];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAccountSegment(name: string): string {
|
||||||
|
// 1. 将括号和中括号替换为连字符 -
|
||||||
|
let cleaned = name.replace(/[\(\)()\[\]]/g, '-');
|
||||||
|
// 2. 将连续的多个连字符合并为一个
|
||||||
|
cleaned = cleaned.replace(/-+/g, '-');
|
||||||
|
// 3. 移除首尾的连字符
|
||||||
|
cleaned = cleaned.replace(/^-|-$/g, '');
|
||||||
|
// 4. 仅保留字母、数字、中文和连字符 -
|
||||||
|
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChannelAccount(event: ImportedEvent, rule?: Rule): string {
|
||||||
|
if (rule?.channelAccount) return rule.channelAccount;
|
||||||
|
const raw = event.raw || {};
|
||||||
|
let method = first(raw, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']) || '';
|
||||||
|
|
||||||
|
if (method) {
|
||||||
|
// 拆分 & 符号,仅保留首个主要的支付通道,过滤掉红包、优惠券等促销后缀
|
||||||
|
method = method.split('&')[0].trim();
|
||||||
|
|
||||||
|
if (method === '零钱') {
|
||||||
|
method = '微信零钱';
|
||||||
|
} else if (method === '余额' || method === '账户余额') {
|
||||||
|
method = '支付宝余额';
|
||||||
|
} else if (method.includes('花呗')) {
|
||||||
|
method = '支付宝花呗';
|
||||||
|
}
|
||||||
|
method = sanitizeAccountSegment(method);
|
||||||
|
if (method) {
|
||||||
|
if (/信用卡|花呗|借呗|信用/i.test(method)) {
|
||||||
|
return `Liabilities:${method}`;
|
||||||
|
}
|
||||||
|
return `Assets:${method}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.channel === 'Alipay') {
|
||||||
|
return 'Assets:支付宝余额';
|
||||||
|
}
|
||||||
|
if (event.channel === 'WeChat') {
|
||||||
|
return 'Assets:微信零钱';
|
||||||
|
}
|
||||||
|
return `Assets:${event.channel}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function classify(event: ImportedEvent, rules: Rule[], ledger: LedgerIndex): Classification {
|
export function classify(event: ImportedEvent, rules: Rule[], ledger: LedgerIndex): Classification {
|
||||||
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item));
|
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item));
|
||||||
const channelAccount = rule?.channelAccount ?? `Assets:${event.channel}`;
|
const channelAccount = resolveChannelAccount(event, rule);
|
||||||
const categoryAccount = rule?.categoryAccount ?? (event.direction === 'income' || event.direction === 'refund' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
|
const categoryAccount = rule?.categoryAccount ?? (event.direction === 'income' ? 'Income:未分类' : 'Expenses:未分类');
|
||||||
const known = ledger.accounts.has(channelAccount) && ledger.accounts.has(categoryAccount);
|
const known = ledger.accounts.has(channelAccount) && ledger.accounts.has(categoryAccount);
|
||||||
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
|
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
|
||||||
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
|
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
|
||||||
const categoryAmount = negateDecimal(channelAmount);
|
const categoryAmount = negateDecimal(channelAmount);
|
||||||
const narration = rule?.narration ?? (event.memo || event.counterparty || '导入账单');
|
const narration = getNarration(rule?.narration, event);
|
||||||
const draft: TransactionDraft = { date: event.occurredAt, payee: event.counterparty || undefined, narration, tags: rule?.tags, sourceEventId: event.id, postings: [{ account: channelAccount, amount: channelAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency }] };
|
const draft: TransactionDraft = { date: event.occurredAt.slice(0, 10), payee: event.counterparty || undefined, narration, tags: rule?.tags, sourceEventId: event.id, postings: [{ account: channelAccount, amount: channelAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency }] };
|
||||||
return { draft, ruleId: rule?.id, confidence: rule && known ? 'high' : 'low' };
|
return { draft, ruleId: rule?.id, confidence: rule && known ? 'high' : 'low' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双轨制分类记账(plan.md「决策 1」)。
|
||||||
|
*
|
||||||
|
* 与 {@link classify} 的区别:
|
||||||
|
* - 优先级:规则匹配 > 分类匹配(关键词/模糊)> 未分类兜底
|
||||||
|
* - 分类通过 {@link Category.linkedAccount} 映射到 Beancount 账户(双轨制)
|
||||||
|
* - 账户不存在时不降级,由导入流程自动 open
|
||||||
|
*
|
||||||
|
* @param event 待分类的导入事件
|
||||||
|
* @param rules 规则列表(优先匹配)
|
||||||
|
* @param categories 分类库(规则未命中时用关键词/模糊匹配)
|
||||||
|
* @param ledger 账本(校验账户存在性)
|
||||||
|
*/
|
||||||
|
export function classifyWithCategories(
|
||||||
|
event: ImportedEvent,
|
||||||
|
rules: Rule[],
|
||||||
|
categories: Category[],
|
||||||
|
ledger: LedgerIndex,
|
||||||
|
): Classification & { categoryId?: string; categoryFallbackReason?: string } {
|
||||||
|
// 1. 先尝试规则匹配
|
||||||
|
const rule = [...rules].sort((a, b) => b.priority - a.priority).find(item => matches(event, item));
|
||||||
|
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
|
||||||
|
const channelAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
|
||||||
|
const categoryAmount = negateDecimal(channelAmount);
|
||||||
|
|
||||||
|
// 渠道账户(与 classify 一致)
|
||||||
|
const channelAccountRaw = resolveChannelAccount(event, rule);
|
||||||
|
const channelValid = ledger.accounts.has(channelAccountRaw);
|
||||||
|
|
||||||
|
// 分类账户:规则优先,否则用分类库匹配
|
||||||
|
let categoryAccount: string;
|
||||||
|
let categoryId: string | undefined;
|
||||||
|
let categoryFallbackReason: string | undefined;
|
||||||
|
let categoryValid: boolean;
|
||||||
|
|
||||||
|
if (rule) {
|
||||||
|
// 规则命中:直接用规则的 categoryAccount,不降级
|
||||||
|
categoryAccount = rule.categoryAccount;
|
||||||
|
categoryValid = ledger.accounts.has(categoryAccount);
|
||||||
|
if (!categoryValid) {
|
||||||
|
categoryFallbackReason = `账户 ${categoryAccount} 尚未开户,将在导入时自动创建`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 规则未命中:用分类库匹配
|
||||||
|
const type: 'income' | 'expense' = (event.direction === 'income') ? 'income' : 'expense';
|
||||||
|
const candidates = categories.filter(c => c.type === type);
|
||||||
|
// 匹配文本综合对手方 + 备注(关键词可能在任一字段)
|
||||||
|
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
|
||||||
|
const match = matchCategory(matchText, candidates);
|
||||||
|
const resolved = resolveCategoryAccount(match.category, type, ledger);
|
||||||
|
categoryAccount = resolved.account;
|
||||||
|
categoryValid = resolved.valid;
|
||||||
|
categoryId = match.category?.id;
|
||||||
|
if (!resolved.valid) categoryFallbackReason = resolved.fallbackReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const narration = getNarration(rule?.narration, event);
|
||||||
|
const draft: TransactionDraft = {
|
||||||
|
date: event.occurredAt.slice(0, 10),
|
||||||
|
payee: event.counterparty || undefined,
|
||||||
|
narration,
|
||||||
|
tags: rule?.tags,
|
||||||
|
sourceEventId: event.id,
|
||||||
|
postings: [
|
||||||
|
{ account: channelAccountRaw, amount: channelAmount, currency: event.currency },
|
||||||
|
{ account: categoryAccount, amount: categoryAmount, currency: event.currency },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const known = channelValid && categoryValid;
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
ruleId: rule?.id,
|
||||||
|
confidence: known ? 'high' : 'low',
|
||||||
|
categoryId,
|
||||||
|
categoryFallbackReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** A transfer is deliberately kept as a candidate until the user confirms both legs. */
|
/** A transfer is deliberately kept as a candidate until the user confirms both legs. */
|
||||||
export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent, leftAccount: string, rightAccount: string): TransactionDraft {
|
export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent, leftAccount: string, rightAccount: string): TransactionDraft {
|
||||||
if (left.currency !== right.currency || left.amount.replace('-', '') !== right.amount.replace('-', '')) throw new Error('转账候选金额或币种不一致');
|
if (left.currency !== right.currency || left.amount.replace('-', '') !== right.amount.replace('-', '')) throw new Error('转账候选金额或币种不一致');
|
||||||
const amount = left.amount.startsWith('-') ? left.amount.slice(1) : left.amount;
|
const amount = left.amount.startsWith('-') ? left.amount.slice(1) : left.amount;
|
||||||
const from = left.amount.startsWith('-') ? leftAccount : rightAccount;
|
const from = left.amount.startsWith('-') ? leftAccount : rightAccount;
|
||||||
const to = left.amount.startsWith('-') ? rightAccount : leftAccount;
|
const to = left.amount.startsWith('-') ? rightAccount : leftAccount;
|
||||||
return { date: left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt, narration: `转账:${from} → ${to}`, sourceEventId: `${left.id}|${right.id}`, postings: [{ account: from, amount: `-${amount}`, currency: left.currency }, { account: to, amount, currency: left.currency }] };
|
return { date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10), narration: `转账:${from} → ${to}`, sourceEventId: `${left.id}|${right.id}`, postings: [{ account: from, amount: `-${amount}`, currency: left.currency }, { account: to, amount, currency: left.currency }] };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,29 +9,36 @@ const digest = (value: string) => { let h = 0; for (const c of value) h = (h * 3
|
|||||||
|
|
||||||
function parseCsv(content: string): Row[] {
|
function parseCsv(content: string): Row[] {
|
||||||
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(line => line.trim());
|
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(line => line.trim());
|
||||||
const headerLine = lines.find(line => /交易|金额|收支|时间|date|amount/i.test(line)); if (!headerLine) throw new Error('未找到账单表头');
|
const headerLine = lines.find(line => /时间|date/i.test(line) && /金额|amount/i.test(line)); if (!headerLine) throw new Error('未找到账单表头');
|
||||||
const headers = columns(headerLine); return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
|
const headers = columns(headerLine); return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
|
||||||
const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
|
const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
|
||||||
}).filter(row => Object.values(row).some(Boolean));
|
}).filter(row => Object.values(row).some(Boolean));
|
||||||
}
|
}
|
||||||
function direction(value: string, amount: string): Direction {
|
function direction(flow: string, txType: string, amount: string): Direction {
|
||||||
if (/转账|转入|转出/i.test(value)) return 'transfer'; if (/退款/i.test(value)) return 'refund'; if (/手续费/i.test(value)) return 'fee';
|
const combined = `${flow}|${txType}`;
|
||||||
return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(value) ? 'expense' : 'income';
|
if (/转账|转入|转出/i.test(combined)) return 'transfer'; if (/退款/i.test(combined)) return 'refund'; if (/手续费/i.test(combined)) return 'fee';
|
||||||
|
return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(flow) ? 'expense' : 'income';
|
||||||
}
|
}
|
||||||
export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] {
|
export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] {
|
||||||
const rows = parseCsv(content); const channel = adapter.startsWith('alipay') ? 'Alipay' : adapter.startsWith('wechat') ? 'WeChat' : 'Bank';
|
const rows = parseCsv(content); const channel = adapter.startsWith('alipay') ? 'Alipay' : adapter.startsWith('wechat') ? 'WeChat' : 'Bank';
|
||||||
return rows.map((row, index) => {
|
return rows.map((row, index) => {
|
||||||
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
|
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
|
||||||
const flow = first(row, ['收/支', '收支类型', '交易类型', '资金方向', 'Type', 'type']);
|
const flow = first(row, ['收/支', '收支类型', '资金方向', 'Type', 'type']);
|
||||||
|
const txType = first(row, ['交易类型', '商品分类', '交易分类']);
|
||||||
if (!amount) throw new Error(`第 ${index + 1} 行缺少金额`);
|
if (!amount) throw new Error(`第 ${index + 1} 行缺少金额`);
|
||||||
if (/支出|付款|消费|转出/i.test(flow) && !amount.startsWith('-')) amount = negateDecimal(amount);
|
if (/支出|付款|消费|转出/i.test(flow) && !amount.startsWith('-')) amount = negateDecimal(amount);
|
||||||
const occurredAt = first(row, ['交易创建时间', '交易时间', '交易日期', '时间', 'Date', 'date']).replace(/[/.]/g, '-').slice(0, 10);
|
const occurredAt = first(row, ['交易创建时间', '交易时间', '交易日期', '时间', 'Date', 'date']).replace(/[/.]/g, '-').slice(0, 19);
|
||||||
const externalId = first(row, ['交易订单号', '交易单号', '商户订单号', '流水号', 'Transaction ID', 'id']);
|
const externalId = first(row, ['交易订单号', '交易单号', '商户订单号', '流水号', 'Transaction ID', 'id']);
|
||||||
const counterparty = first(row, ['交易对方', '交易对手', '对方账户', '商户名称', 'Counterparty', 'merchant']);
|
const counterparty = first(row, ['交易对方', '交易对手', '对方账户', '商户名称', 'Counterparty', 'merchant']);
|
||||||
const memo = first(row, ['商品说明', '商品名称', '备注', '交易描述', 'Memo', 'description']);
|
const rawMemo = first(row, ['商品', '商品说明', '商品名称', '备注', '交易描述', 'Memo', 'description']);
|
||||||
const eventDirection = direction(flow, amount);
|
const category = first(row, ['商品分类', '交易分类', '分类', 'Category', 'category']);
|
||||||
|
const method = first(row, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']);
|
||||||
|
const memo = [category, rawMemo, method ? `[${method}]` : ''].filter(s => s && s.trim() !== '').join(' - ');
|
||||||
|
const eventDirection = direction(flow, txType, amount);
|
||||||
const id = externalId ? `${channel}:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|'));
|
const id = externalId ? `${channel}:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|'));
|
||||||
return { id, occurredAt: /^\d{4}-\d{2}-\d{2}$/.test(occurredAt) ? occurredAt : new Date().toISOString().slice(0, 10), amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, channel, counterparty, memo, externalId: externalId || undefined, raw: row };
|
const isDateValid = /^\d{4}-\d{2}-\d{2}/.test(occurredAt);
|
||||||
|
const finalOccurredAt = isDateValid ? occurredAt : new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
return { id, occurredAt: finalOccurredAt, amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, channel, counterparty, memo, externalId: externalId || undefined, raw: row };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
||||||
|
|||||||
131
src/domain/sync.ts
Normal file
131
src/domain/sync.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 同步功能抽象(plan.md「Phase 6 同步功能」)。
|
||||||
|
* 定义同步引擎接口、冲突解决策略、各后端(Git/WebDAV/iCloud)抽象。
|
||||||
|
* 具体实现需文件系统/网络访问,在 services 层注入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 同步变更记录(增量同步用)。 */
|
||||||
|
export interface SyncChange {
|
||||||
|
id: string;
|
||||||
|
entityType: 'transaction' | 'rule' | 'category' | 'tag' | 'attachment';
|
||||||
|
entityId: string;
|
||||||
|
operation: 'create' | 'update' | 'delete';
|
||||||
|
payload: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冲突解决策略。 */
|
||||||
|
export type ConflictResolution = 'last-writer-wins' | 'manual' | 'prefer-local' | 'prefer-remote';
|
||||||
|
|
||||||
|
export interface SyncConflict {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
local: string;
|
||||||
|
remote: string;
|
||||||
|
resolution: ConflictResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步后端抽象。 */
|
||||||
|
export interface SyncBackend {
|
||||||
|
readonly type: 'git' | 'webdav' | 'icloud' | 'supabase' | 's3' | 'none';
|
||||||
|
/** 上传 mobile.bean 全量快照。 */
|
||||||
|
push(content: string): Promise<void>;
|
||||||
|
/** 拉取 mobile.bean 全量快照。 */
|
||||||
|
pull(): Promise<string | null>;
|
||||||
|
/** 获取远程最后修改时间。 */
|
||||||
|
getRemoteLastModified(): Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照同步(全量上传/下载,plan.md「6.1 快照同步」)。 */
|
||||||
|
export async function snapshotSync(
|
||||||
|
backend: SyncBackend,
|
||||||
|
localContent: string,
|
||||||
|
lastSyncTime: string,
|
||||||
|
onConflict?: (conflict: SyncConflict) => Promise<SyncConflict>,
|
||||||
|
): Promise<{ action: 'pushed' | 'pulled' | 'conflict' | 'noop'; content: string }> {
|
||||||
|
const remoteModified = await backend.getRemoteLastModified();
|
||||||
|
const remoteContent = await backend.pull();
|
||||||
|
|
||||||
|
// 无远程内容 → 直接推送
|
||||||
|
if (remoteContent === null) {
|
||||||
|
await backend.push(localContent);
|
||||||
|
return { action: 'pushed', content: localContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容相同 → 无操作
|
||||||
|
if (remoteContent === localContent) {
|
||||||
|
return { action: 'noop', content: localContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程有更新且本地也有更新 → 冲突
|
||||||
|
const remoteChanged = remoteModified !== null && remoteModified > lastSyncTime;
|
||||||
|
const localChanged = true; // 进入此函数说明本地有内容
|
||||||
|
|
||||||
|
if (remoteChanged && localChanged) {
|
||||||
|
if (onConflict) {
|
||||||
|
const conflict: SyncConflict = {
|
||||||
|
entityType: 'mobile.bean',
|
||||||
|
entityId: 'mobile.bean',
|
||||||
|
local: localContent,
|
||||||
|
remote: remoteContent,
|
||||||
|
resolution: 'manual',
|
||||||
|
};
|
||||||
|
const resolved = await onConflict(conflict);
|
||||||
|
const content = resolved.resolution === 'prefer-remote' ? remoteContent : localContent;
|
||||||
|
await backend.push(content);
|
||||||
|
return { action: 'conflict', content };
|
||||||
|
}
|
||||||
|
// 默认 last-writer-wins
|
||||||
|
return { action: 'conflict', content: localContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅远程更新 → 拉取
|
||||||
|
if (remoteChanged) {
|
||||||
|
return { action: 'pulled', content: remoteContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅本地更新 → 推送
|
||||||
|
await backend.push(localContent);
|
||||||
|
return { action: 'pushed', content: localContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内存同步后端(测试用)。 */
|
||||||
|
export class MemorySyncBackend implements SyncBackend {
|
||||||
|
readonly type = 'none' as const;
|
||||||
|
private content: string | null = null;
|
||||||
|
private lastModified: string | null = null;
|
||||||
|
|
||||||
|
constructor(initial?: string) {
|
||||||
|
if (initial !== undefined) {
|
||||||
|
this.content = initial;
|
||||||
|
this.lastModified = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async push(content: string): Promise<void> {
|
||||||
|
this.content = content;
|
||||||
|
this.lastModified = new Date().toISOString();
|
||||||
|
}
|
||||||
|
async pull(): Promise<string | null> {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
async getRemoteLastModified(): Promise<string | null> {
|
||||||
|
return this.lastModified;
|
||||||
|
}
|
||||||
|
/** 测试辅助:模拟远程修改。 */
|
||||||
|
simulateRemoteChange(content: string): void {
|
||||||
|
this.content = content;
|
||||||
|
this.lastModified = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步错误记录(plan.md「6.5 同步错误持久化」)。 */
|
||||||
|
export interface SyncError {
|
||||||
|
id: string;
|
||||||
|
operation: 'push' | 'pull' | 'merge';
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
resolved: 0 | 1 | 2; // 0=未解决, 1=已重试, 2=已跳过
|
||||||
|
}
|
||||||
34
src/domain/tags.ts
Normal file
34
src/domain/tags.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 标签系统(双轨制)。
|
||||||
|
*
|
||||||
|
* 设计见 plan.md「决策 1」:
|
||||||
|
* - 应用 tags 表存 UI 增强(name/color),记账时把 #tagName 写入 narration(Beancount 原生 tag 语法)
|
||||||
|
* - 与分类不同:标签会写回 .bean(用 #tag 语法),桌面端可读
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string; // 同时作为 #tag 名(写回 .bean),需符合 [\w-]
|
||||||
|
color: string; // UI 增强(仅本地,不写回 .bean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 tag 名是否合法(Beancount #tag 语法:[\w-],即字母数字下划线连字符)。 */
|
||||||
|
export function isValidTagName(name: string): boolean {
|
||||||
|
return /^[\w-]+$/.test(name) && name.length > 0 && name.length <= 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 Tag 列表序列化为 .bean 的 #tag 语法(追加到 narration 末尾)。 */
|
||||||
|
export function tagsToBeanSyntax(tags: Tag[]): string {
|
||||||
|
return tags.filter(t => isValidTagName(t.name)).map(t => ` #${t.name}`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 .bean narration 中提取 #tag 名(解析方向)。 */
|
||||||
|
export function extractTagNames(narration: string): string[] {
|
||||||
|
return [...narration.matchAll(/#([\w-]+)/g)].map(m => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 tag 名集合,从 Tag 库匹配出完整的 Tag 对象(用于 UI 展示颜色等)。 */
|
||||||
|
export function resolveTagsByName(names: string[], allTags: Tag[]): Tag[] {
|
||||||
|
const byName = new Map(allTags.map(t => [t.name, t]));
|
||||||
|
return names.map(n => byName.get(n)).filter((t): t is Tag => t !== undefined);
|
||||||
|
}
|
||||||
48
src/domain/transactionFlags.ts
Normal file
48
src/domain/transactionFlags.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 交易标志系统(plan.md「2.2 交易标志系统」)。
|
||||||
|
* 参考 AutoAccounting 的 Bill Flag System(位运算标志位)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum TransactionFlag {
|
||||||
|
NONE = 0,
|
||||||
|
EXCLUDE_FROM_STATS = 1 << 0, // 不计入统计图表(但影响余额)
|
||||||
|
EXCLUDE_FROM_BUDGET = 1 << 1, // 不计入预算使用
|
||||||
|
TRANSFER = 1 << 2, // 标记为转账(自动识别)
|
||||||
|
RECURRING = 1 << 3, // 周期性交易模板
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFlag(flags: number, flag: TransactionFlag): boolean {
|
||||||
|
return (flags & flag) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFlag(flags: number, flag: TransactionFlag): number {
|
||||||
|
return flags | flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFlag(flags: number, flag: TransactionFlag): number {
|
||||||
|
return flags & ~flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleFlag(flags: number, flag: TransactionFlag): number {
|
||||||
|
return hasFlag(flags, flag) ? clearFlag(flags, flag) : setFlag(flags, flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计过滤:是否应计入统计(排除 EXCLUDE_FROM_STATS)。 */
|
||||||
|
export function shouldCountInStats(flags: number): boolean {
|
||||||
|
return !hasFlag(flags, TransactionFlag.EXCLUDE_FROM_STATS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预算过滤:是否应计入预算(排除 EXCLUDE_FROM_BUDGET)。 */
|
||||||
|
export function shouldCountInBudget(flags: number): boolean {
|
||||||
|
return !hasFlag(flags, TransactionFlag.EXCLUDE_FROM_BUDGET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 flags 序列化为 Beancount metadata(写回 .bean 时用)。 */
|
||||||
|
export function flagsToMetadata(flags: number): Record<string, string> {
|
||||||
|
const meta: Record<string, string> = {};
|
||||||
|
if (hasFlag(flags, TransactionFlag.EXCLUDE_FROM_STATS)) meta['bm-exclude-stats'] = 'true';
|
||||||
|
if (hasFlag(flags, TransactionFlag.EXCLUDE_FROM_BUDGET)) meta['bm-exclude-budget'] = 'true';
|
||||||
|
if (hasFlag(flags, TransactionFlag.TRANSFER)) meta['bm-transfer'] = 'true';
|
||||||
|
if (hasFlag(flags, TransactionFlag.RECURRING)) meta['bm-recurring'] = 'true';
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
146
src/domain/transferRecognizer.ts
Normal file
146
src/domain/transferRecognizer.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { compareDecimals } from './decimal';
|
||||||
|
import type { ImportedEvent, TransactionDraft } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转账智能识别(参考 AutoAccounting 的 TransferRecognizer)。
|
||||||
|
*
|
||||||
|
* 核心场景:Income + Expend → Transfer(账户间转账)
|
||||||
|
* 例如:支付宝(支出 100)+ 银行卡(收入 100)= 从支付宝转到银行卡
|
||||||
|
*
|
||||||
|
* 执行顺序(plan.md「2.7」):转账识别**先于去重**。
|
||||||
|
* 一笔账单既可能是转账的一方又可能是重复时,转账识别优先(合并为单条转账)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TransferConfidence = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
export interface TransferPair {
|
||||||
|
/** 合并后的转账草稿(已 ready for 入库)。 */
|
||||||
|
draft: TransactionDraft;
|
||||||
|
/** 构成转账的两个原始事件 id。 */
|
||||||
|
leftEventId: string;
|
||||||
|
rightEventId: string;
|
||||||
|
confidence: TransferConfidence;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecognizeTransfersResult {
|
||||||
|
/** 识别出的转账(已合并为单条草稿)。 */
|
||||||
|
transfers: TransferPair[];
|
||||||
|
/** 未配对成功的事件(继续走后续去重/分类流程)。 */
|
||||||
|
remaining: ImportedEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferConfig {
|
||||||
|
/** 时间窗口(小时),默认 3 天(72 小时)。 */
|
||||||
|
timeWindowHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TRANSFER_CONFIG: TransferConfig = {
|
||||||
|
timeWindowHours: 72,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别转账配对:Income + Expend → Transfer。
|
||||||
|
*
|
||||||
|
* @param events 待识别的事件列表
|
||||||
|
* @param accountMap 渠道 → 账户名映射(如 { Alipay: 'Assets:Alipay', Bank: 'Assets:Bank:CMB' })
|
||||||
|
* @param config 配置
|
||||||
|
*/
|
||||||
|
export function recognizeTransfers(
|
||||||
|
events: ImportedEvent[],
|
||||||
|
accountMap: Record<string, string>,
|
||||||
|
config: TransferConfig = DEFAULT_TRANSFER_CONFIG,
|
||||||
|
): RecognizeTransfersResult {
|
||||||
|
const used = new Set<string>();
|
||||||
|
const transfers: TransferPair[] = [];
|
||||||
|
const windowMs = config.timeWindowHours * 3600 * 1000;
|
||||||
|
|
||||||
|
// 收入事件和支出事件分别收集(转账 = 一收一支配对)
|
||||||
|
const incomes = events.filter(e => e.direction === 'income' && !used.has(e.id));
|
||||||
|
const expenses = events.filter(e => e.direction === 'expense' && !used.has(e.id));
|
||||||
|
|
||||||
|
for (const income of incomes) {
|
||||||
|
if (used.has(income.id)) continue;
|
||||||
|
const match = findPair(income, expenses, used, windowMs, accountMap);
|
||||||
|
if (match) {
|
||||||
|
transfers.push(match);
|
||||||
|
used.add(match.leftEventId); // 支出方
|
||||||
|
used.add(match.rightEventId); // 收入方
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = events.filter(e => !used.has(e.id));
|
||||||
|
return { transfers, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPair(
|
||||||
|
income: ImportedEvent,
|
||||||
|
candidates: ImportedEvent[],
|
||||||
|
used: Set<string>,
|
||||||
|
windowMs: number,
|
||||||
|
accountMap: Record<string, string>,
|
||||||
|
): TransferPair | null {
|
||||||
|
const incomeTime = Date.parse(income.occurredAt);
|
||||||
|
const incomeAmount = Math.abs(parseFloat(income.amount)).toFixed(2);
|
||||||
|
|
||||||
|
for (const expense of candidates) {
|
||||||
|
if (used.has(expense.id)) continue;
|
||||||
|
if (income.currency !== expense.currency) continue;
|
||||||
|
|
||||||
|
// 金额匹配(绝对值相等)
|
||||||
|
const expenseAmount = Math.abs(parseFloat(expense.amount)).toFixed(2);
|
||||||
|
if (compareDecimals(incomeAmount, expenseAmount) !== 0) continue;
|
||||||
|
|
||||||
|
// 时间窗口
|
||||||
|
const expenseTime = Date.parse(expense.occurredAt);
|
||||||
|
if (Math.abs(incomeTime - expenseTime) > windowMs) continue;
|
||||||
|
|
||||||
|
// 置信度评估
|
||||||
|
const confidence = assessConfidence(income, expense);
|
||||||
|
if (confidence === null) continue; // 渠道相同不可能转账
|
||||||
|
|
||||||
|
// 构造转账草稿:从支出方账户 → 收入方账户
|
||||||
|
const fromAccount = accountMap[expense.channel] ?? `Assets:${expense.channel}`;
|
||||||
|
const toAccount = accountMap[income.channel] ?? `Assets:${income.channel}`;
|
||||||
|
const amount = incomeAmount;
|
||||||
|
|
||||||
|
const draft: TransactionDraft = {
|
||||||
|
date: (income.occurredAt < expense.occurredAt ? income.occurredAt : expense.occurredAt).slice(0, 10),
|
||||||
|
narration: `转账:${fromAccount} → ${toAccount}`,
|
||||||
|
sourceEventId: `${expense.id}|${income.id}`,
|
||||||
|
postings: [
|
||||||
|
{ account: fromAccount, amount: `-${amount}`, currency: income.currency },
|
||||||
|
{ account: toAccount, amount, currency: income.currency },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
leftEventId: expense.id,
|
||||||
|
rightEventId: income.id,
|
||||||
|
confidence: confidence.level,
|
||||||
|
reason: confidence.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评估转账置信度。相同渠道不可能是转账(null),不同渠道才有意义。 */
|
||||||
|
function assessConfidence(
|
||||||
|
income: ImportedEvent,
|
||||||
|
expense: ImportedEvent,
|
||||||
|
): { level: TransferConfidence; reason: string } | null {
|
||||||
|
// 相同渠道:不可能是转账(同渠道自己转自己无意义)
|
||||||
|
if (income.channel === expense.channel) return null;
|
||||||
|
|
||||||
|
// 不同渠道、对手方匹配 → 中置信度
|
||||||
|
if (income.counterparty && expense.counterparty) {
|
||||||
|
if (income.counterparty.includes(expense.counterparty) || expense.counterparty.includes(income.counterparty)) {
|
||||||
|
return { level: 'medium', reason: `不同渠道、对手方匹配(${income.channel} ↔ ${expense.channel})` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同渠道、仅金额时间匹配 → 低置信度
|
||||||
|
return { level: 'low', reason: `不同渠道、金额时间匹配(${income.channel} ↔ ${expense.channel})` };
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user