- 切换到 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+ 单元测试覆盖核心逻辑
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
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 },
|
||
});
|