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([]); 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(); 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 ( router.back()}> {t('tab.import')} `${item.draft.sourceEventId || index}-${index}`} renderItem={({ item, index }) => ( {item.draft.narration} {item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')} {item.categoryFallbackReason && ( {t('importFlow.fallbackWarn', { reason: item.categoryFallbackReason })} )}