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