import React, { useMemo, useState, useEffect, useRef } from 'react'; import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import * as FileSystem from 'expo-file-system/legacy'; import { useLedgerStore } from '../../store/ledgerStore'; import { useMetadataStore } from '../../store/metadataStore'; import { useTheme, createCommonStyles } from '../../theme'; import { useT } from '../../i18n'; import { Card } from '../../components/Card'; import { Button } from '../../components/Button'; import { CategoryPicker } from '../../components/CategoryPicker'; import { TagPicker } from '../../components/TagPicker'; import { PostingEditor } from '../../components/PostingEditor'; import { tagsToBeanSyntax } from '../../domain/tags'; import { serializeTransaction } from '../../domain/ledger'; import { buildAndSaveTransaction } from '../../domain/transactionBuilder'; import { matchCategory } from '../../domain/categories'; import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge'; import { OcrProcessor } from '../../domain/ocrProcessor'; import type { Category } from '../../domain/categories'; import type { Tag } from '../../domain/tags'; import type { Posting } from '../../domain/types'; type Direction = 'expense' | 'income' | 'transfer'; export default function NewTransactionScreen() { const { theme } = useTheme(); const commonStyles = createCommonStyles(theme); const t = useT(); const router = useRouter(); const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>(); const addTransaction = useLedgerStore(s => s.addTransaction); const editTransaction = useLedgerStore(s => s.editTransaction); const ledger = useLedgerStore(s => s.ledger); const categories = useMetadataStore(s => s.categories); const tags = useMetadataStore(s => s.tags); const today = new Date().toISOString().slice(0, 10); // 基础表单状态 const [date, setDate] = useState(today); const [narration, setNarration] = useState(''); const [payee, setPayee] = useState(''); const [currency, setCurrency] = useState('CNY'); const [amount, setAmount] = useState(''); const [direction, setDirection] = useState('expense'); const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额'); const [targetAccount, setTargetAccount] = useState('Assets:微信零钱'); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedTags, setSelectedTags] = useState([]); const [status, setStatus] = useState(''); // 高级模式状态 const [isAdvanced, setIsAdvanced] = useState(false); const [showDetails, setShowDetails] = useState(false); const [postings, setPostings] = useState([ { account: 'Assets:支付宝余额', amount: '', currency: 'CNY' }, { account: 'Expenses:餐饮', amount: '', currency: 'CNY' } ]); const [isSubmitted, setIsSubmitted] = useState(false); const navigation = useNavigation(); useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', (e) => { const hasChanges = amount.trim() !== '' || payee.trim() !== '' || narration.trim() !== '' || selectedTags.length > 0 || (isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== '')); if (!hasChanges || isSubmitted) { return; } e.preventDefault(); Alert.alert( t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [ { text: t('common.cancel'), style: 'cancel', onPress: () => {} }, { text: t('common.discard'), style: 'destructive', onPress: () => navigation.dispatch(e.data.action), }, ] ); }); return unsubscribe; }, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]); // === 草稿预填模式 === useEffect(() => { if (draftJson) { try { const draft = JSON.parse(draftJson); setDate(draft.date ? draft.date.slice(0, 10) : today); setNarration(draft.narration || ''); setPayee(draft.payee || ''); const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses')); const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income')); const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities')); if (expensePosting) { setDirection('expense'); setCurrency(expensePosting.currency || 'CNY'); setAmount((expensePosting.amount || '').replace(/^-/, '')); const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account); if (matchedCat) setSelectedCategory(matchedCat); if (assetsPostings.length > 0) { setSourceAccount(assetsPostings[0].account); } } else if (incomePosting) { setDirection('income'); setCurrency(incomePosting.currency || 'CNY'); setAmount((incomePosting.amount || '').replace(/^-/, '')); const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account); if (matchedCat) setSelectedCategory(matchedCat); if (assetsPostings.length > 0) { setSourceAccount(assetsPostings[0].account); } } else { setDirection('transfer'); if (draft.postings.length >= 2) { setCurrency(draft.postings[0].currency || 'CNY'); setAmount((draft.postings[0].amount || '').replace(/^-/, '')); setSourceAccount(draft.postings[0].account); setTargetAccount(draft.postings[1].account); } } if (draft.postings && draft.postings.length > 0) { setPostings(draft.postings.map((p: any) => ({ account: p.account, amount: p.amount, currency: p.currency, cost: p.cost, price: p.price, metadata: p.metadata, }))); } } catch (e) { console.error('Failed to parse draftJson', e); } } }, [draftJson, categories]); // === 编辑模式:预填表单 === const editingTx = useMemo( () => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined, [editId, ledger], ); const [oldRaw, setOldRaw] = useState(''); const [editFlag, setEditFlag] = useState('*'); const [editLinks, setEditLinks] = useState([]); const [editMetadata, setEditMetadata] = useState | undefined>(undefined); useEffect(() => { if (editingTx) { setOldRaw(editingTx.raw); setDate(editingTx.date.slice(0, 10)); setNarration(editingTx.narration || ''); setPayee(editingTx.payee || ''); setCurrency(editingTx.postings[0]?.currency || 'CNY'); // 保留编辑前的 flag/links/metadata setEditFlag(editingTx.flag || '*'); setEditLinks(editingTx.links || []); setEditMetadata(editingTx.metadata); // 从 postings 推断方向和金额 const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses')); const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income')); if (expensePosting) { setDirection('expense'); setAmount(expensePosting.amount?.replace(/^-/, '') || ''); setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay'); // 预填分类:匹配 Expenses 账户对应的 category const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account); if (matchedCat) setSelectedCategory(matchedCat); } else if (incomePosting) { setDirection('income'); setAmount(incomePosting.amount?.replace(/^-/, '') || ''); setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay'); // 预填分类 const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account); if (matchedCat) setSelectedCategory(matchedCat); } else { setDirection('transfer'); setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || ''); setSourceAccount(editingTx.postings[0]?.account || ''); setTargetAccount(editingTx.postings[1]?.account || ''); } // 预填 postings(高级模式,含 cost/price/metadata) setPostings(editingTx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency, cost: p.cost, price: p.price, metadata: p.metadata, }))); // 预填标签 if (editingTx.tags.length > 0) { setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' }))); } } }, [editingTx, categories]); // 按方向过滤分类 const availableCategories = useMemo( () => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')), [categories, direction], ); // 来源账户列表(从 ledger 提取 Assets 账户) const assetAccounts = useMemo(() => { if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586']; const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets')); return accts.length > 0 ? accts : ['Assets:支付宝余额']; }, [ledger]); const toggleTag = (tag: Tag) => { setSelectedTags(prev => prev.some(t => t.name === tag.name) ? prev.filter(t => t.name !== tag.name) : [...prev, tag], ); }; // === OCR 拍照识账 === const handleOcrScan = async () => { try { // 1. 选图(相册或拍照) const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: false, quality: 0.8, base64: true, }); if (result.canceled || !result.assets?.[0]?.base64) return; const base64 = result.assets[0].base64; // 2. 检查 OCR 引擎是否就绪 const nativeModule = getNativeOcrModule(); if (nativeModule) { const ready = await nativeModule.isReady(); if (!ready) { Alert.alert(t('ocr.notReady')); return; } } setStatus(t('ocr.scanning')); // 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI) const ocrBridge = getNativeOcrBridge(); const processor = new OcrProcessor(ocrBridge); const ocrResult = await processor.process(base64); if (ocrResult.event) { // 4. 预填表单 const event = ocrResult.event; if (event.amount) { const absAmount = event.amount.replace(/^-/, ''); setAmount(absAmount); } if (event.counterparty || event.memo) { setNarration([event.counterparty, event.memo].filter(Boolean).join(' - ')); } if (event.direction === 'income') { setDirection('income'); } else if (event.direction === 'transfer') { setDirection('transfer'); } else { setDirection('expense'); } // 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致) const matchText = [event.counterparty, event.memo].filter(Boolean).join(' '); if (matchText) { const type = event.direction === 'income' ? 'income' : 'expense'; const candidates = categories.filter(c => c.type === type); const match = matchCategory(matchText, candidates); if (match.category) { setSelectedCategory(match.category); } } setStatus(t('ocr.success')); } else { setStatus(t('ocr.failed')); } } catch (e) { setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e))); } }; // 从 SpeedDial OCR 入口进入时自动触发 useEffect(() => { if (mode === 'ocr') { handleOcrScan(); } }, [mode]); const submit = () => { const tagNames = selectedTags.map(tg => tg.name); if (isAdvanced) { // 高级模式:校验多 postings const validPostings = postings.filter(p => p.account.trim()); if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); return; } // 实时借贷平衡验证 const currencySum: Record = {}; for (const p of validPostings) { if (p.amount && p.currency) { const val = parseFloat(p.amount); if (!isNaN(val)) { currencySum[p.currency] = (currencySum[p.currency] || 0) + val; } } } const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001); if (!isBalanced) { setStatus(t('transaction.unbalanced')); return; } const draft = { date, flag: editId ? editFlag : undefined, narration: narration.trim() || t('transaction.defaultAdvancedNarration'), postings: validPostings, tags: tagNames, links: editId && editLinks.length > 0 ? editLinks : undefined, metadata: editId ? editMetadata : undefined, }; if (editId && oldRaw) { // 编辑模式:序列化新内容并替换旧块 const newRaw = serializeTransaction(draft); editTransaction(oldRaw, newRaw).then(() => { setIsSubmitted(true); setStatus(t('transaction.editSuccess')); setTimeout(() => router.back(), 600); }).catch(e => setStatus(String(e))); } else { addTransaction(draft).then(() => { setIsSubmitted(true); setStatus(t('transaction.appended')); setNarration(''); setSelectedTags([]); setTimeout(() => router.back(), 600); }).catch(e => setStatus(String(e))); } } else { // 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存 const amt = amount.trim(); if (!amt) { setStatus(t('transaction.amountRequired')); return; } if (direction === 'transfer' && sourceAccount === targetAccount) { setStatus(t('transaction.sameAccountError')); return; } const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized'; if (editId && oldRaw) { // 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction let finalPostings: Posting[]; if (direction === 'expense') { finalPostings = [ { account: sourceAccount, amount: `-${amt}`, currency }, { account: categoryAccount, amount: amt, currency }, ]; } else if (direction === 'income') { finalPostings = [ { account: sourceAccount, amount: amt, currency }, { account: categoryAccount, amount: `-${amt}`, currency }, ]; } else { finalPostings = [ { account: sourceAccount, amount: `-${amt}`, currency }, { account: targetAccount, amount: amt, currency }, ]; } const draft = { date, payee: payee.trim() || undefined, flag: editFlag, narration: narration.trim() || ( direction === 'expense' ? t('transaction.narrationDefaultExpense') : direction === 'income' ? t('transaction.narrationDefaultIncome') : t('transaction.narrationDefaultTransfer') ), postings: finalPostings, tags: tagNames, links: editLinks.length > 0 ? editLinks : undefined, metadata: editMetadata, }; const newRaw = serializeTransaction(draft); editTransaction(oldRaw, newRaw).then(() => { setIsSubmitted(true); setStatus(t('transaction.editSuccess')); setTimeout(() => router.back(), 600); }).catch(e => setStatus(String(e))); } else { // 新建交易:直接调用统一的共享逻辑函数 buildAndSaveTransaction({ date, amount: amt, payee: payee.trim() || undefined, narration: narration.trim() || ( direction === 'expense' ? t('transaction.narrationDefaultExpense') : direction === 'income' ? t('transaction.narrationDefaultIncome') : t('transaction.narrationDefaultTransfer') ), categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount, sourceAccount, direction, currency, tags: tagNames, links: editLinks.length > 0 ? editLinks : undefined, metadata: editMetadata, }).then(() => { setIsSubmitted(true); setStatus(t('transaction.appended')); setAmount(''); setNarration(''); setSelectedCategory(null); setSelectedTags([]); setTimeout(() => router.back(), 600); }).catch(e => setStatus(String(e))); } } }; const directions: { key: Direction; label: string; color: string }[] = [ { key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense }, { key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income }, { key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer }, ]; return ( {/* 高级模式模式切换开关 */} {t('transaction.advancedMode')} { setIsAdvanced(val); // 同步初始化当前分录 if (val) { const amt = amount.trim(); const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized'; if (direction === 'expense') { setPostings([ { account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' }, { account: categoryAccount, amount: amt || '', currency: 'CNY' }, ]); } else if (direction === 'income') { setPostings([ { account: sourceAccount, amount: amt || '', currency: 'CNY' }, { account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' }, ]); } else { setPostings([ { account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' }, { account: targetAccount, amount: amt || '', currency: 'CNY' }, ]); } } }} trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }} thumbColor={theme.colors.fgInverse} /> {/* 简单模式下的方向选择 */} {!isAdvanced && ( {directions.map(d => ( { setDirection(d.key); setSelectedCategory(null); }} style={[ commonStyles.chip, direction === d.key && commonStyles.chipActive, { flex: 1, alignItems: 'center' } ]} > {d.label} ))} {t('home.ocrScan')} )} {/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */} {!isAdvanced ? ( {/* 金额 */} {t('transaction.amountPlaceholder')} {/* 来源账户 */} {t('transaction.formSourceAccount')} {/* 快捷来源账户选择 */} {assetAccounts.map(acct => { const active = sourceAccount === acct; const shortName = acct.split(':').pop() || acct; return ( setSourceAccount(acct)} style={[ commonStyles.chip, active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent }, { paddingVertical: 4, paddingHorizontal: 8 } ]} > {shortName} ); })} {/* 目标账户 (仅在转账时显示) */} {direction === 'transfer' && ( {t('transaction.targetAccount')} {/* 快捷目标账户选择 */} {assetAccounts.map(acct => { const active = targetAccount === acct; const shortName = acct.split(':').pop() || acct; return ( setTargetAccount(acct)} style={[ commonStyles.chip, active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent }, { paddingVertical: 4, paddingHorizontal: 8 } ]} > {shortName} ); })} )} ) : ( {t('transaction.postingsList')} setPostings(p)} />