DriftLedger/src/app/import/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

401 lines
17 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 { 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 },
});