### 架构重构:三层分层目录化 - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform) - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction) - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取 - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦 ### 微信账单抓取:节点混淆绕过(核心突破) - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务 - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验 - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动 - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错) - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录) ### 新 UI 组件体系 - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失) - ErrorBoundary:React class 错误边界(降级 UI + 重试) - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让 - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField) - 新增 PeriodSwitcher、RangeStatsCard 独立组件 ### 新 Hooks & 工具 - useBottomInset — 统一底部安全区留白 - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案) - sanitize.ts — 日志脱敏工具提取 ### 账本增删改增强 - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean) - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」 - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误) - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致 ### OCR 原生模块增强 - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」 - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别) - 图片缩放策略重命名(scaleDownForOcr → capLongEdge) ### OCR 模型按需下载 - 移除了启动时自动下载 ~30MB OCR 模型的逻辑 - 改为首次使用 OCR 时才触发下载 ### 设置页重设计 - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头) - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片 ### 首页优化 - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条) - 日期/金额格式化增加 locale 感知(zh/en) ### 通知管道增强 - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除 - 增加 debug 日志输出(过滤原因/包名) ### ESLint - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集) - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies ### 文档 - accessibility-wechat-guide.md — 微信无障碍伪装完整方案 - modal-keyboard-guide.md — 弹窗键盘避让方案 - ocr-pipeline-guide.md — OCR 三层层级管线 - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
/**
|
||
* 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」;P4 spec §7.3 重写)。
|
||
*
|
||
* 功能:
|
||
* - 单一 anchor 日期 + 周期(周/月/年)统一导航(periodNav),替代三套独立切换器
|
||
* - 收支看板/分类占比/日历/净资产趋势/年度报告
|
||
* - ⋯ 菜单:AI 月度总结、导出报表为图片(react-native-view-shot)
|
||
*/
|
||
import React, { useMemo, useRef, useState } from 'react';
|
||
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { useRouter } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { captureRef } from 'react-native-view-shot';
|
||
import * as Sharing from 'expo-sharing';
|
||
import { useLedgerStore } from '../../store/ledgerStore';
|
||
import { useSettingsStore } from '../../store/settingsStore';
|
||
import { useTheme, createCommonStyles } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import { Card } from '../../components/ui/Card';
|
||
import { Button } from '../../components/ui/Button';
|
||
import { BottomSheet } from '../../components/ui/BottomSheet';
|
||
import { CategoryPie } from '../../components/charts/CategoryPie';
|
||
import { CalendarView } from '../../components/stats/CalendarView';
|
||
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||
import { NetWorthChart } from '../../components/charts/NetWorthChart';
|
||
import { AnnualReport } from '../../components/charts/AnnualReport';
|
||
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
|
||
import { BaseOpenAIProvider } from '../../domain/ai';
|
||
import { addDecimals, negateDecimal, toDateString } from '../../domain/core/decimal';
|
||
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/stats/periodNav';
|
||
import { SegmentedControl } from '../../components/ui/SegmentedControl';
|
||
import { EmptyState } from '../../components/ui/EmptyState';
|
||
import { PeriodSwitcher } from '../../components/stats/PeriodSwitcher';
|
||
import { RangeStatsCard } from '../../components/stats/RangeStatsCard';
|
||
|
||
export default function ReportScreen() {
|
||
const { theme } = useTheme();
|
||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||
// 动态样式:使用主题 token 替代硬编码值
|
||
const dynamicStyles = useMemo(() => ({
|
||
monthBtn: { borderRadius: theme.radii.lg, borderColor: theme.colors.border },
|
||
iconBtn: { padding: theme.spacing.xs },
|
||
}), [theme]);
|
||
const t = useT();
|
||
const router = useRouter();
|
||
const ledger = useLedgerStore(s => s.ledger);
|
||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
|
||
const aiModel = useSettingsStore(s => s.aiModel);
|
||
const locale = useSettingsStore(s => s.locale);
|
||
|
||
// 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态)
|
||
const [period, setPeriod] = useState<ReportPeriod>('monthly');
|
||
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
|
||
|
||
// ⋯ 菜单
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
|
||
// 日历选中日期用于明细展开
|
||
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
|
||
|
||
// AI 总结弹窗
|
||
const [summaryModal, setSummaryModal] = useState(false);
|
||
const [summaryText, setSummaryText] = useState('');
|
||
|
||
// 截图 ref
|
||
const scrollRef = useRef<ScrollView>(null);
|
||
|
||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||
|
||
// anchor 所在周期的起止日期与周期内交易
|
||
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
|
||
const rangeTxs = useMemo(
|
||
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
|
||
[transactions, range],
|
||
);
|
||
|
||
// 周期收支统计(Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
|
||
const rangeStats = useMemo(() => {
|
||
const incomeAmounts: string[] = [];
|
||
const expenseAmounts: string[] = [];
|
||
for (const tx of rangeTxs) {
|
||
for (const p of tx.postings) {
|
||
if (!p.amount) continue;
|
||
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
|
||
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
|
||
}
|
||
}
|
||
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
|
||
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
|
||
return { income, expense, net: income - expense, count: rangeTxs.length };
|
||
}, [rangeTxs]);
|
||
|
||
// 周期切换器 label
|
||
const periodLabel = useMemo(() => {
|
||
if (period === 'weekly') {
|
||
const monday = range.start;
|
||
const sunday = range.end;
|
||
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
|
||
}
|
||
if (period === 'monthly') return anchor.slice(0, 7);
|
||
return `${anchor.slice(0, 4)}年`;
|
||
}, [period, anchor, range]);
|
||
|
||
// 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
|
||
const netWorthDates = useMemo(() => {
|
||
const dates: string[] = [];
|
||
for (let i = 5; i >= 0; i--) {
|
||
const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
|
||
dates.push(toDateString(d));
|
||
}
|
||
return dates;
|
||
}, [anchor]);
|
||
|
||
const shift = (delta: number) => {
|
||
setSelectedReportDate(null);
|
||
setAnchor(a => shiftAnchor(a, period, delta));
|
||
};
|
||
|
||
// 选中日期的交易明细
|
||
const selectedDayTxs = useMemo(() => {
|
||
if (!selectedReportDate) return [];
|
||
return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
|
||
}, [rangeTxs, selectedReportDate]);
|
||
|
||
// 千分位格式化金额
|
||
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
|
||
const fmtAmount = (num: number) => {
|
||
const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||
};
|
||
|
||
// 导出报表为图片
|
||
const handleExportImage = async () => {
|
||
try {
|
||
if (!scrollRef.current) return;
|
||
const uri = await captureRef(scrollRef, { format: 'png', quality: 0.9 });
|
||
if (await Sharing.isAvailableAsync()) {
|
||
await Sharing.shareAsync(uri, { mimeType: 'image/png', dialogTitle: t('report.shareTitle') });
|
||
} else {
|
||
Alert.alert(t('report.exportSuccess'), uri);
|
||
}
|
||
} catch (e) {
|
||
Alert.alert(t('report.exportFail'), String(e));
|
||
}
|
||
};
|
||
|
||
// AI / 纯文本月度总结(基于 anchor 所在月)
|
||
const handleMonthlySummary = async () => {
|
||
const year = Number(anchor.slice(0, 4));
|
||
const month = Number(anchor.slice(5, 7));
|
||
const stats = calculateMonthlyStats(transactions, year, month);
|
||
if (aiEnabled && aiApiKey && aiBaseUrl) {
|
||
try {
|
||
const provider = new (class extends BaseOpenAIProvider {})({
|
||
id: 'report-summary',
|
||
name: 'Report Summary',
|
||
apiKey: aiApiKey,
|
||
baseUrl: aiBaseUrl,
|
||
model: aiModel || 'glm-4-flash',
|
||
});
|
||
const result = await generateMonthlySummary(transactions, year, month, provider);
|
||
setSummaryText(result.summary || generatePlainTextSummary(stats));
|
||
} catch {
|
||
setSummaryText(generatePlainTextSummary(stats));
|
||
}
|
||
} else {
|
||
setSummaryText(generatePlainTextSummary(stats));
|
||
}
|
||
setSummaryModal(true);
|
||
};
|
||
|
||
const monthLabel = anchor.slice(0, 7);
|
||
const anchorYear = Number(anchor.slice(0, 4));
|
||
const anchorMonth = Number(anchor.slice(5, 7));
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<View style={styles.header}>
|
||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
|
||
<Pressable
|
||
onPress={() => setMenuOpen(true)}
|
||
style={({ pressed }) => [styles.iconBtn, dynamicStyles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('report.menu')}
|
||
>
|
||
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
|
||
</Pressable>
|
||
</View>
|
||
|
||
{/* Tab 选择切换栏(统一分段选择器) */}
|
||
<SegmentedControl
|
||
options={[
|
||
{ key: 'weekly', label: t('report.tabWeekly') },
|
||
{ key: 'monthly', label: t('report.tabMonthly') },
|
||
{ key: 'annual', label: t('report.tabAnnual') },
|
||
]}
|
||
value={period}
|
||
onChange={key => {
|
||
setPeriod(key as ReportPeriod);
|
||
setSelectedReportDate(null);
|
||
}}
|
||
/>
|
||
|
||
{/* 统一周期切换器 */}
|
||
<PeriodSwitcher label={periodLabel} onPrev={() => shift(-1)} onNext={() => shift(1)} />
|
||
|
||
<ScrollView
|
||
ref={scrollRef}
|
||
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||
>
|
||
{/* 数据空值判断 */}
|
||
{rangeTxs.length === 0 ? (
|
||
<EmptyState icon="bar-chart-outline" title={t('report.title')} description={t('report.empty')} />
|
||
) : (
|
||
<>
|
||
{/* 周/月收支看板 */}
|
||
{period !== 'annual' && (
|
||
<>
|
||
<RangeStatsCard
|
||
period={period === 'weekly' ? 'weekly' : 'monthly'}
|
||
incomeText={fmtAmount(rangeStats.income)}
|
||
expenseText={fmtAmount(-rangeStats.expense)}
|
||
netText={fmtAmount(rangeStats.net)}
|
||
count={rangeStats.count}
|
||
/>
|
||
|
||
<CategoryPie transactions={rangeTxs} />
|
||
|
||
{/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
|
||
{period === 'monthly' && (
|
||
<>
|
||
<CalendarView
|
||
transactions={transactions}
|
||
year={anchorYear}
|
||
month={anchorMonth}
|
||
onDayPress={setSelectedReportDate}
|
||
/>
|
||
|
||
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
|
||
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
|
||
{selectedDayTxs.map(tx => (
|
||
<TransactionCard
|
||
key={tx.id}
|
||
transaction={tx}
|
||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||
/>
|
||
))}
|
||
{selectedDayTxs.length === 0 && (
|
||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
|
||
{t('calendar.noDayTx')}
|
||
</Text>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* 年度报表看板 */}
|
||
{period === 'annual' && (
|
||
<AnnualReport transactions={transactions} year={anchorYear} />
|
||
)}
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
|
||
{/* ⋯ 菜单 */}
|
||
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
|
||
<Pressable
|
||
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
|
||
onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('report.aiSummary')}
|
||
>
|
||
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.aiSummary')}</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
|
||
onPress={() => { setMenuOpen(false); handleExportImage(); }}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('report.exportImage')}
|
||
>
|
||
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.exportImage')}</Text>
|
||
</Pressable>
|
||
</BottomSheet>
|
||
|
||
{/* AI 总结弹窗 */}
|
||
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
|
||
<View style={commonStyles.modalOverlay}>
|
||
<View style={commonStyles.modalCard}>
|
||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 12 }]}>
|
||
{monthLabel} {t('report.aiSummary')}
|
||
</Text>
|
||
<ScrollView style={{ maxHeight: 400 }}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, lineHeight: 22 }]}>
|
||
{summaryText}
|
||
</Text>
|
||
</ScrollView>
|
||
<View style={{ marginTop: 16 }}>
|
||
<Button label={t('common.confirm')} onPress={() => setSummaryModal(false)} />
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||
iconBtn: {},
|
||
content: { padding: 16, paddingBottom: 96 },
|
||
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
|
||
});
|