DriftLedger/src/app/(tabs)/report.tsx
fengmengqi 2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00

365 lines
16 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/Card';
import { Button } from '../../components/Button';
import { BottomSheet } from '../../components/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/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/decimal';
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/periodNav';
export default function ReportScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [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);
// 周期 + 单一 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 fmtAmount = (num: number) => {
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString('zh-CN', { 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, { opacity: pressed ? 0.6 : 1 }]}
accessibilityRole="button"
accessibilityLabel={t('report.menu')}
>
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
</Pressable>
</View>
{/* Tab 选择切换栏 */}
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
{([
{ key: 'weekly', label: t('report.tabWeekly') },
{ key: 'monthly', label: t('report.tabMonthly') },
{ key: 'annual', label: t('report.tabAnnual') }
] as const).map(tab => (
<Pressable
key={tab.key}
onPress={() => {
setPeriod(tab.key);
setSelectedReportDate(null);
}}
style={[
styles.tabBtn,
period === tab.key && { borderBottomColor: theme.colors.accent }
]}
>
<Text
style={[
theme.typography.body,
{
color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: period === tab.key ? '700' : '400',
paddingVertical: 10
}
]}
>
{tab.label}
</Text>
</Pressable>
))}
</View>
{/* 统一周期切换器 */}
<View style={styles.monthSwitcher}>
<Pressable onPress={() => shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{periodLabel}
</Text>
<Pressable onPress={() => shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
<ScrollView
ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
>
{/* 数据空值判断 */}
{rangeTxs.length === 0 ? (
<Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')}
</Text>
</Card>
) : (
<>
{/* 周/月收支看板 */}
{period !== 'annual' && (
<>
<Card title={t(period === 'weekly' ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })}
</Text>
</Card>
<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: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
content: { padding: 16, paddingBottom: 96 },
tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 },
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
});