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

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
});