/** * 交易详情页(plan.md「1.1 transaction/[id]」)。 * * 从 ledger.transactions 按 id 查找,展示完整交易信息: * - 日期 / 摘要 / 对方 / flag * - 全部 postings(账户 / 金额 / 币种 / cost / price) * - 标签 / 链接 / 元数据 * - 原始 .bean 文本 */ import React, { useMemo, useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useTheme, createCommonStyles } from '../../theme'; import { Card } from '../../components/Card'; import { useLedgerStore } from '../../store/ledgerStore'; import { useNumpadUiStore } from '../../store/numpadUiStore'; import { hash } from '../../domain/ledger'; import { useT } from '../../i18n'; import { TransactionCard } from '../../components/TransactionCard'; import { ScreenHeader } from '../../components/ScreenHeader'; import { logger } from '../../utils/logger'; export default function TransactionDetailScreen() { const { theme } = useTheme(); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const t = useT(); const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const ledger = useLedgerStore(s => s.ledger); const tx = useMemo( () => ledger?.transactions.find(txn => txn.id === id) ?? null, [ledger, id], ); const [linkModalVisible, setLinkModalVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const isEditable = tx ? tx.source.endsWith('main.bean') : false; // 找出所有共享同一个链接标签的关联交易 const relatedTransactions = useMemo(() => { if (!ledger || !tx || !tx.links || tx.links.length === 0) return []; return ledger.transactions.filter( t => t.id !== id && t.links.some(l => tx.links.includes(l)) ); }, [ledger, id, tx?.links]); // 筛选可以用来关联的其他账单 const linkableTransactions = useMemo(() => { if (!ledger) return []; const list = ledger.transactions .filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l))) .filter(t => { if (!searchQuery) return true; const q = searchQuery.toLowerCase(); return ( t.narration?.toLowerCase().includes(q) || t.payee?.toLowerCase().includes(q) || t.postings.some(p => p.account.toLowerCase().includes(q)) ); }) .sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列 // 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条 return searchQuery ? list.slice(0, 100) : list.slice(0, 50); }, [ledger, id, tx?.links, searchQuery]); const handleLinkTransaction = async (targetTx: typeof tx) => { if (!tx || !targetTx) return; let linkTag = tx.links[0] || targetTx.links[0] || ''; try { const { mobileBean, replaceMobileBean } = useLedgerStore.getState(); // 取出已有链接标签,若没有则生成一个随机哈希标签 if (!linkTag) { linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8); } const addLinkToRaw = (raw: string, tag: string) => { const lines = raw.split('\n'); const line = lines[0]; const commentIndex = line.indexOf(';'); if (line.includes(`^${tag}`)) { return raw; } if (commentIndex !== -1) { const beforeComment = line.substring(0, commentIndex).trimEnd(); const commentPart = line.substring(commentIndex); lines[0] = `${beforeComment} ^${tag} ${commentPart}`; } else { lines[0] = line.trimEnd() + ` ^${tag}`; } return lines.join('\n'); }; const newCurrentRaw = addLinkToRaw(tx.raw, linkTag); const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag); let newContent = mobileBean; newContent = newContent.replaceAll(tx.raw, newCurrentRaw); newContent = newContent.replaceAll(targetTx.raw, newTargetRaw); const formatTxSummary = (t: typeof tx) => { if (!t) return ''; const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', '); return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`; }; await replaceMobileBean(newContent); logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, { linkTag, transaction1: { id: tx.id, date: tx.date, payee: tx.payee, narration: tx.narration, postings: tx.postings, }, transaction2: { id: targetTx.id, date: targetTx.date, payee: targetTx.payee, narration: targetTx.narration, postings: targetTx.postings, }, }); setLinkModalVisible(false); // 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏 const newId = hash(`main.bean:${newCurrentRaw}`); if (newId && newId !== id) { router.replace(`/transaction/${newId}`); } Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag })); } catch (e) { logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e); Alert.alert(t('transaction.linkFail'), String(e)); } }; const handleUnlink = async (linkTag: string) => { if (!tx) return; try { const { mobileBean, replaceMobileBean } = useLedgerStore.getState(); // 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签 const companionTxs = (ledger?.transactions ?? []).filter( t => t.links.includes(linkTag) && t.source.endsWith('main.bean') ); let newContent = mobileBean; let newCurrentRaw = tx.raw; for (const companion of companionTxs) { const lines = companion.raw.split('\n'); lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), ''); const newRaw = lines.join('\n'); newContent = newContent.replaceAll(companion.raw, newRaw); if (companion.id === tx.id) { newCurrentRaw = newRaw; } } const formatTxSummary = (t: typeof tx) => { if (!t) return ''; const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', '); return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`; }; await replaceMobileBean(newContent); const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n '); logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, { linkTag, unlinkedCount: companionTxs.length, unlinkedTransactions: companionTxs.map(c => ({ id: c.id, date: c.date, payee: c.payee, narration: c.narration, postings: c.postings, })), }); // 解决 race condition:直接使用 hash() 算法计算新 ID const newId = hash(`main.bean:${newCurrentRaw}`); if (newId && newId !== id) { router.replace(`/transaction/${newId}`); } Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag })); } catch (e) { logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e); Alert.alert(t('transaction.unlinkFail'), String(e)); } }; if (!tx) { return ( {t('transaction.notFoundDesc', { id })} ); } const isExpense = tx.postings.some(p => p.account.startsWith('Expenses')); const isIncome = tx.postings.some(p => p.account.startsWith('Income')); const directionLabel = isExpense ? t('transaction.directionExpense') : isIncome ? t('transaction.directionIncome') : t('transaction.directionTransfer'); const directionColor = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer; return ( {/* 摘要卡片 */} {tx.narration || tx.payee || t('transaction.noSummary')} {tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''} {directionLabel} {/* 资金流向 */} {tx.postings.map((p, i) => { const parts = p.account.split(':'); const rootType = parts[0]; const accountShortName = parts.slice(1).join(':') || p.account; let categoryTag = t('account.title'); let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline'; let tagBg = `${theme.colors.accent}15`; let tagColor = theme.colors.accent; if (rootType === 'Assets') { categoryTag = t('account.rootTypes.Assets'); iconName = 'wallet-outline'; tagBg = `${theme.colors.financial.income}15`; tagColor = theme.colors.financial.income; } else if (rootType === 'Expenses') { categoryTag = t('account.rootTypes.Expenses'); iconName = 'cart-outline'; tagBg = `${theme.colors.financial.expense}15`; tagColor = theme.colors.financial.expense; } else if (rootType === 'Income') { categoryTag = t('account.rootTypes.Income'); iconName = 'cash-outline'; tagBg = `${theme.colors.financial.income}15`; tagColor = theme.colors.financial.income; } else if (rootType === 'Liabilities') { categoryTag = t('account.rootTypes.Liabilities'); iconName = 'card-outline'; tagBg = `${theme.colors.warning}15`; tagColor = theme.colors.warning; } else if (rootType === 'Equity') { categoryTag = t('account.rootTypes.Equity'); iconName = 'options-outline'; } const amountVal = parseFloat(p.amount ?? '0'); const isNegative = amountVal < 0; return ( {categoryTag} {accountShortName} {p.metadata && Object.entries(p.metadata).map(([k, v]) => ( {k}: {v} ))} {p.amount && ( {p.amount} {p.currency ?? ''} )} {p.cost && ( 成本: {p.cost} )} {p.price && ( 单价: @ {p.price} )} ); })} {/* 标签 */} {tx.tags.length > 0 && ( {tx.tags.map(tag => ( #{tag} ))} )} {/* 链接 */} {(tx.links.length > 0 || isEditable) && ( {tx.links.map(link => ( ^{link} {isEditable && ( handleUnlink(link)} style={{ marginLeft: 6 }}> )} ))} {isEditable && ( setLinkModalVisible(true)} style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]} > {t('transaction.linkTransaction')} )} {!isEditable && tx.links.length === 0 && ( {t('transaction.readOnlyLinks')} )} )} {/* 关联的交易列表 */} {relatedTransactions.length > 0 && ( {relatedTransactions.map(item => ( router.push(`/transaction/${target.id}`)} /> ))} )} {/* 元数据 / 附加信息 */} {tx.metadata && Object.keys(tx.metadata).length > 0 && ( {Object.entries(tx.metadata).map(([k, v]) => ( {k} {v} ))} )} {/* 原始 .bean */} {tx.raw} {t('transaction.source')}: {tx.source} {/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */} {isEditable && ( useNumpadUiStore.getState().open({ editId: id })} style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]} > {t('transaction.edit')} { Alert.alert(t('transaction.deleteTitle'), t('transaction.deleteConfirm'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('common.delete'), style: 'destructive', onPress: async () => { try { await useLedgerStore.getState().deleteTransaction(tx.raw); Alert.alert(t('transaction.deleteSuccess')); router.back(); } catch (e) { Alert.alert('Error', String(e)); } }, }, ]); }} style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.error, opacity: pressed ? 0.7 : 1 }]} > {t('transaction.delete')} )} {/* 关联交易筛选模态框 */} setLinkModalVisible(false)} > setLinkModalVisible(false)}> {t('transaction.linkMobileTx')} item.id} renderItem={({ item }) => ( handleLinkTransaction(item)} /> )} ListEmptyComponent={ {t('transaction.noLinkableTx')} } /> ); } const styles = StyleSheet.create({ page: { flex: 1 }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16 }, summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 }, postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 }, tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 }, linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 }, searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 }, searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 }, metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 }, editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' }, editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 }, flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 }, flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 }, });