/** * 报表页(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('monthly'); const [anchor, setAnchor] = useState(() => toDateString(new Date())); // ⋯ 菜单 const [menuOpen, setMenuOpen] = useState(false); // 日历选中日期用于明细展开 const [selectedReportDate, setSelectedReportDate] = useState(null); // AI 总结弹窗 const [summaryModal, setSummaryModal] = useState(false); const [summaryText, setSummaryText] = useState(''); // 截图 ref const scrollRef = useRef(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 ( {t('tab.report')} setMenuOpen(true)} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]} accessibilityRole="button" accessibilityLabel={t('report.menu')} > {/* Tab 选择切换栏 */} {([ { key: 'weekly', label: t('report.tabWeekly') }, { key: 'monthly', label: t('report.tabMonthly') }, { key: 'annual', label: t('report.tabAnnual') } ] as const).map(tab => ( { setPeriod(tab.key); setSelectedReportDate(null); }} style={[ styles.tabBtn, period === tab.key && { borderBottomColor: theme.colors.accent } ]} > {tab.label} ))} {/* 统一周期切换器 */} shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> {periodLabel} shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> {/* 数据空值判断 */} {rangeTxs.length === 0 ? ( {t('report.empty')} ) : ( <> {/* 周/月收支看板 */} {period !== 'annual' && ( <> {t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')} {fmtAmount(rangeStats.income)} {t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')} {fmtAmount(-rangeStats.expense)} {t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')} {fmtAmount(rangeStats.net)} {t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })} {/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */} {period === 'monthly' && ( <> {selectedReportDate && selectedReportDate.startsWith(monthLabel) && ( {selectedDayTxs.map(tx => ( router.push(`/transaction/${tx.id}`)} /> ))} {selectedDayTxs.length === 0 && ( {t('calendar.noDayTx')} )} )} )} )} {/* 年度报表看板 */} {period === 'annual' && ( )} )} {/* ⋯ 菜单 */} setMenuOpen(false)} title={t('report.menu')}> [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]} onPress={() => { setMenuOpen(false); handleMonthlySummary(); }} accessibilityRole="button" accessibilityLabel={t('report.aiSummary')} > {t('report.aiSummary')} [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]} onPress={() => { setMenuOpen(false); handleExportImage(); }} accessibilityRole="button" accessibilityLabel={t('report.exportImage')} > {t('report.exportImage')} {/* AI 总结弹窗 */} setSummaryModal(false)}> {monthLabel} {t('report.aiSummary')} {summaryText}