DriftLedger/src/app/(tabs)/report.tsx
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

324 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 报表页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 },
});