OCR 模型按需下载(P8 瘦身): - 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB - 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝 - OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户 - settingsStore 持久化 ocrModelVersion / ocrModelDir - 自动化页集成模型状态检查与一键下载 UI OCR 三层独立控制: - OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关 - 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision) - OCR 处理增加耗时与字符数日志 日志系统升级: - logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log) - 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx - 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理 - _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号) 原生浮层 UI 主题同步(P6): - 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生 - 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取 - FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal - FloatingHelper / FloatingTip 同步适配 - _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送 UI 与组件增强: - FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange - 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗 - 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择 - AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型 - 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗) 去重优化: - 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复 - DedupResult 新增 matchedItem 返回匹配对比项 文档重构: - README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表) - 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图) - 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md - UI 重设计文档(design spec + p1-p8)移入 docs/design/ 其他: - i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键 - ppocr Config Plugin 修复 import 注入去重;size-optimization 增强 - 新增测试:logger.test.ts、floating-ui-config.test.ts
555 lines
23 KiB
TypeScript
555 lines
23 KiB
TypeScript
/**
|
||
* 交易详情页(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 (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||
<ScreenHeader title={t('common.notFound')} />
|
||
<View style={styles.content}>
|
||
<Card title={t('transaction.notFoundTitle')}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||
{t('transaction.notFoundDesc', { id })}
|
||
</Text>
|
||
</Card>
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||
<ScreenHeader title={t('transaction.detailTitle')} />
|
||
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
|
||
{/* 摘要卡片 */}
|
||
<Card title={t('transaction.summary')}>
|
||
<View style={styles.summaryRow}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||
{tx.narration || tx.payee || t('transaction.noSummary')}
|
||
</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
||
{tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''}
|
||
</Text>
|
||
</View>
|
||
<View style={[styles.directionBadge, { backgroundColor: directionColor }]}>
|
||
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text>
|
||
</View>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* 资金流向 */}
|
||
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
|
||
<View style={{ gap: 8 }}>
|
||
{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 (
|
||
<View
|
||
key={i}
|
||
style={[
|
||
styles.flowCardItem,
|
||
{
|
||
backgroundColor: theme.colors.bgTertiary,
|
||
borderRadius: theme.radii.lg,
|
||
},
|
||
]}
|
||
>
|
||
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
|
||
<Ionicons name={iconName} size={18} color={tagColor} />
|
||
</View>
|
||
|
||
<View style={{ flex: 1, marginLeft: 10 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
|
||
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
|
||
{categoryTag}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
|
||
{accountShortName}
|
||
</Text>
|
||
|
||
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
|
||
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
||
{k}: {v}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
|
||
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
|
||
{p.amount && (
|
||
<Text
|
||
style={[
|
||
theme.typography.body,
|
||
{
|
||
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
|
||
fontWeight: '700',
|
||
},
|
||
]}
|
||
>
|
||
{p.amount} {p.currency ?? ''}
|
||
</Text>
|
||
)}
|
||
{p.cost && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||
成本: {p.cost}
|
||
</Text>
|
||
)}
|
||
{p.price && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||
单价: @ {p.price}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</Card>
|
||
|
||
{/* 标签 */}
|
||
{tx.tags.length > 0 && (
|
||
<Card title={t('transaction.tags')}>
|
||
<View style={styles.tagRow}>
|
||
{tx.tags.map(tag => (
|
||
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight }]}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.accentDark }]}>#{tag}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 链接 */}
|
||
{(tx.links.length > 0 || isEditable) && (
|
||
<Card title={t('transaction.links')}>
|
||
<View style={styles.tagRow}>
|
||
{tx.links.map(link => (
|
||
<View key={link} style={[styles.tagContainer, { backgroundColor: theme.colors.accentLight }]}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.accentDark, fontWeight: '700' }]}>^{link}</Text>
|
||
{isEditable && (
|
||
<Pressable onPress={() => handleUnlink(link)} style={{ marginLeft: 6 }}>
|
||
<Ionicons name="close-circle" size={14} color={theme.colors.accentDark} />
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{isEditable && (
|
||
<Pressable
|
||
onPress={() => setLinkModalVisible(true)}
|
||
style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]}
|
||
>
|
||
<Ionicons name="link-outline" size={16} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 6, fontWeight: '700' }]}>
|
||
{t('transaction.linkTransaction')}
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
|
||
{!isEditable && tx.links.length === 0 && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||
{t('transaction.readOnlyLinks')}
|
||
</Text>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
{/* 关联的交易列表 */}
|
||
{relatedTransactions.length > 0 && (
|
||
<Card title={t('transaction.relatedTransactions')}>
|
||
{relatedTransactions.map(item => (
|
||
<TransactionCard
|
||
key={item.id}
|
||
transaction={item}
|
||
onPress={(target) => router.push(`/transaction/${target.id}`)}
|
||
/>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
{/* 元数据 / 附加信息 */}
|
||
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
|
||
<Card
|
||
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
|
||
collapsible
|
||
defaultCollapsed
|
||
>
|
||
{Object.entries(tx.metadata).map(([k, v]) => (
|
||
<View key={k} style={styles.metaRow}>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{v}</Text>
|
||
</View>
|
||
))}
|
||
</Card>
|
||
)}
|
||
|
||
{/* 原始 .bean */}
|
||
<Card
|
||
title={t('transaction.rawBean')}
|
||
collapsible
|
||
defaultCollapsed
|
||
>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
|
||
{tx.raw}
|
||
</Text>
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||
{t('transaction.source')}: {tx.source}
|
||
</Text>
|
||
</Card>
|
||
|
||
{/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
|
||
{isEditable && (
|
||
<View style={styles.editActions}>
|
||
<Pressable
|
||
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
|
||
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
|
||
>
|
||
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 4, fontWeight: '700' }]}>
|
||
{t('transaction.edit')}
|
||
</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={() => {
|
||
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 }]}
|
||
>
|
||
<Ionicons name="trash-outline" size={16} color={theme.colors.error} />
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginLeft: 4, fontWeight: '700' }]}>
|
||
{t('transaction.delete')}
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
|
||
{/* 关联交易筛选模态框 */}
|
||
<Modal
|
||
visible={linkModalVisible}
|
||
animationType="slide"
|
||
transparent={false}
|
||
onRequestClose={() => setLinkModalVisible(false)}
|
||
>
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<View style={styles.header}>
|
||
<Pressable onPress={() => setLinkModalVisible(false)}>
|
||
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
|
||
</Pressable>
|
||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||
{t('transaction.linkMobileTx')}
|
||
</Text>
|
||
</View>
|
||
|
||
<View style={styles.searchBarContainer}>
|
||
<TextInput
|
||
style={[commonStyles.input, { height: 40 }]}
|
||
placeholder={t('transaction.searchTxPlaceholder')}
|
||
placeholderTextColor={theme.colors.fgSecondary}
|
||
value={searchQuery}
|
||
onChangeText={setSearchQuery}
|
||
/>
|
||
</View>
|
||
|
||
<FlatList
|
||
data={linkableTransactions}
|
||
keyExtractor={(item) => item.id}
|
||
renderItem={({ item }) => (
|
||
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
|
||
<TransactionCard
|
||
transaction={item}
|
||
onPress={() => handleLinkTransaction(item)}
|
||
/>
|
||
</View>
|
||
)}
|
||
ListEmptyComponent={
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
|
||
{t('transaction.noLinkableTx')}
|
||
</Text>
|
||
}
|
||
/>
|
||
</SafeAreaView>
|
||
</Modal>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
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 },
|
||
});
|