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
365 lines
16 KiB
TypeScript
365 lines
16 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/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 },
|
||
});
|