diff --git a/frontend/src/pages/Statistics/index.tsx b/frontend/src/pages/Statistics/index.tsx new file mode 100644 index 0000000..9a36b0b --- /dev/null +++ b/frontend/src/pages/Statistics/index.tsx @@ -0,0 +1,1031 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import * as echarts from 'echarts'; +import { useDataStore } from '../../stores'; +import { exportAllStats } from '../../utils/exportHelper'; + +const StatisticsPage: React.FC = () => { + const { + loading, + records, + trendData, + monthlyCompare, + fetchRecords, + fetchDashboardSummary, + fetchTrendData, + fetchMonthlyCompare, + } = useDataStore(); + + const [chartType, setChartType] = useState<'line' | 'bar' | 'pie'>('line'); + const [period, setPeriod] = useState<'month' | 'year'>('month'); + const [showToast, setShowToast] = useState(false); + + const trendChartRef = useRef(null); + const pieChartRef = useRef(null); + const compareChartRef = useRef(null); + + const trendChartInstance = useRef(null); + const pieChartInstance = useRef(null); + const compareChartInstance = useRef(null); + + // 分类颜色映射 + const categoryColorMap: Record = { + '餐饮': '#00c853', + '交通': '#3B82F6', + '购物': '#ff3d00', + '娱乐': '#F59E0B', + '其他': '#8B5CF6', + }; + + // 获取当前年月信息 + const now = useMemo(() => new Date(), []); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth() + 1; + const lastDay = new Date(currentYear, currentMonth, 0).getDate(); + const currentMonthStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}`; + const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`; + const endDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + + // 初始化时获取趋势数据和对比数据 + useEffect(() => { + fetchTrendData(startDate, endDate); + fetchMonthlyCompare(currentMonthStr); + fetchRecords(); + fetchDashboardSummary(); + }, [fetchTrendData, fetchMonthlyCompare, fetchRecords, fetchDashboardSummary, startDate, endDate, currentMonthStr]); + + // 处理趋势数据:月度显示每日数据,年度显示每月聚合 + const displayData = useMemo(() => { + if (period === 'year') { + // 年度模式:按月聚合 + const monthlyMap: Record = {}; + trendData.forEach((d) => { + const month = d.date.substring(0, 7); // '2026-04' + if (!monthlyMap[month]) { + monthlyMap[month] = { date: month, income: 0, expense: 0 }; + } + monthlyMap[month].income += d.income; + monthlyMap[month].expense += d.expense; + }); + return Object.values(monthlyMap); + } + // 月度模式:返回每日数据 + return trendData; + }, [trendData, period]); + + // 格式化日期标签 + const formatDateLabel = (dateStr: string) => { + if (period === 'year') { + const month = parseInt(dateStr.split('-')[1]); + return `${month}月`; + } + const day = parseInt(dateStr.split('-')[2]); + return `${day}日`; + }; + + // 从 records 计算真实分类数据 + const categoryData = useMemo(() => { + // 筛选当前月份的支出记录 + const expenseRecords = records.filter((record) => { + if (record.type !== 'expense') return false; + const recordDate = new Date(record.date); + return recordDate.getFullYear() === currentYear && recordDate.getMonth() + 1 === currentMonth; + }); + + // 按分类汇总 + const categoryMap = new Map(); + expenseRecords.forEach((record) => { + const current = categoryMap.get(record.category) || 0; + categoryMap.set(record.category, current + Number(record.amount)); + }); + + const result = Array.from(categoryMap.entries()) + .map(([name, value]) => ({ + name, + value, + color: categoryColorMap[name] || '#737373', + })) + .sort((a, b) => b.value - a.value); + + if (result.length === 0) { + return [ + { name: '餐饮', value: 0, color: '#00c853' }, + { name: '交通', value: 0, color: '#3B82F6' }, + { name: '购物', value: 0, color: '#ff3d00' }, + { name: '娱乐', value: 0, color: '#F59E0B' }, + { name: '其他', value: 0, color: '#8B5CF6' }, + ]; + } + return result; + }, [records, currentYear, currentMonth]); + + const totalExpense = categoryData.reduce((sum, item) => sum + item.value, 0); + + // 数据变化时更新图表(合并初始化逻辑,避免竞态) + useEffect(() => { + // useEffect 保证 DOM 已更新,直接执行图表初始化 + if (chartType === 'pie') { + // 切换到饼图时销毁趋势图实例,确保切回时重新初始化 + if (trendChartInstance.current) { + trendChartInstance.current.dispose(); + trendChartInstance.current = null; + } + // 修复:切换到饼图时强制重新初始化(销毁旧实例) + if (pieChartInstance.current) { + pieChartInstance.current.dispose(); + pieChartInstance.current = null; + } + if (pieChartRef.current) { + pieChartInstance.current = echarts.init(pieChartRef.current); + } + initPieChart(); + } else { + // 切换到折线图/柱状图时,销毁饼图实例 + if (pieChartInstance.current) { + pieChartInstance.current.dispose(); + pieChartInstance.current = null; + } + // 切换到折线图/柱状图时,如果实例不存在或已销毁,重新初始化 + if (trendChartRef.current && !trendChartInstance.current) { + trendChartInstance.current = echarts.init(trendChartRef.current); + } + if (compareChartRef.current && !compareChartInstance.current) { + compareChartInstance.current = echarts.init(compareChartRef.current); + } + initTrendChart(); + initCompareChart(); + } + }, [chartType, categoryData, displayData, monthlyCompare, records, period]); + + // 响应式 resize 处理 + useEffect(() => { + const handleResize = () => { + trendChartInstance.current?.resize(); + pieChartInstance.current?.resize(); + compareChartInstance.current?.resize(); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + // 空数据提示组件 + const EmptyChart = ({ message }: { message: string }) => ( +
+ {message} +
+ ); + + // 初始化趋势图 - 使用真实数据 + const initTrendChart = () => { + if (!trendChartInstance.current) return; + + const option = { + animation: true, + animationDuration: 1500, + animationEasing: 'cubicOut' as const, + tooltip: { + trigger: 'axis' as const, + backgroundColor: 'rgba(26, 26, 26, 0.95)', + borderColor: 'transparent', + textStyle: { color: '#fff' }, + formatter: (params: any) => { + let result = `${params[0].axisValue}
`; + params.forEach((param: any) => { + const color = param.seriesName === '收入' ? '#00c853' : '#ff3d00'; + const prefix = param.seriesName === '收入' ? '+' : '-'; + result += ` ${param.seriesName}: ${prefix}¥${Number(param.value).toLocaleString()}
`; + }); + return result; + }, + }, + legend: { + data: ['收入', '支出'], + bottom: 0, + textStyle: { color: '#737373' }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + top: '10%', + containLabel: true, + }, + xAxis: { + type: 'category' as const, + boundaryGap: chartType === 'bar', + data: displayData.map(d => formatDateLabel(d.date)), + axisLine: { lineStyle: { color: '#e5e5e5' } }, + axisLabel: { + color: '#737373', + interval: period === 'month' ? 4 : 'auto', + fontSize: 11, + }, + }, + yAxis: { + type: 'value' as const, + axisLine: { show: false }, + splitLine: { lineStyle: { color: '#f5f5f5' } }, + axisLabel: { + color: '#737373', + formatter: (value: number) => `¥${(value / 1000).toFixed(1)}k`, + }, + }, + series: chartType === 'bar' + ? [ + { + name: '收入', + type: 'bar' as const, + barWidth: '35%', + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#00c853' }, + { offset: 1, color: '#00a844' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + data: displayData.map(d => d.income), + animationDelay: (idx: number) => idx * 50, + }, + { + name: '支出', + type: 'bar' as const, + barWidth: '35%', + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#ff3d00' }, + { offset: 1, color: '#e63600' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + data: displayData.map(d => d.expense), + animationDelay: (idx: number) => idx * 50 + 100, + }, + ] + : [ + { + name: '收入', + type: 'line' as const, + smooth: true, + symbol: 'circle', + symbolSize: 6, + lineStyle: { color: '#00c853', width: 2 }, + itemStyle: { color: '#00c853' }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(0, 200, 83, 0.3)' }, + { offset: 1, color: 'rgba(0, 200, 83, 0.05)' }, + ]), + }, + data: displayData.map(d => d.income), + animationDelay: (idx: number) => idx * 60, + }, + { + name: '支出', + type: 'line' as const, + smooth: true, + symbol: 'circle', + symbolSize: 6, + lineStyle: { color: '#ff3d00', width: 2 }, + itemStyle: { color: '#ff3d00' }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(255, 61, 0, 0.3)' }, + { offset: 1, color: 'rgba(255, 61, 0, 0.05)' }, + ]), + }, + data: displayData.map(d => d.expense), + animationDelay: (idx: number) => idx * 60 + 150, + }, + ], + }; + + trendChartInstance.current.setOption(option, { notMerge: true }); + trendChartInstance.current.resize(); + }; + + // 初始化饼图 + const initPieChart = () => { + if (!pieChartInstance.current) return; + + const option = { + animation: true, + animationDuration: 1000, + animationEasing: 'cubicOut' as const, + tooltip: { + trigger: 'item' as const, + backgroundColor: 'rgba(26, 26, 26, 0.95)', + borderColor: 'transparent', + textStyle: { color: '#fff' }, + formatter: (params: any) => `${params.name}: ¥${Number(params.value).toLocaleString()}
占比: ${params.percent}%`, + }, + series: [ + { + type: 'pie' as const, + radius: ['45%', '75%'], + center: ['50%', '50%'], + avoidLabelOverlap: false, + itemStyle: { + borderRadius: 4, + borderColor: '#fff', + borderWidth: 2, + }, + label: { + show: true, + position: 'center' as const, + formatter: () => `总支出\n¥${totalExpense.toLocaleString()}`, + fontSize: 16, + fontWeight: 600, + color: '#1a1a1a', + lineHeight: 24, + }, + emphasis: { + label: { + show: true, + fontSize: 16, + fontWeight: 600, + }, + scale: true, + scaleSize: 8, + itemStyle: { + shadowBlur: 20, + shadowColor: 'rgba(0, 0, 0, 0.3)', + }, + }, + labelLine: { show: false }, + data: categoryData, + animationDelay: (idx: number) => idx * 150, + }, + ], + }; + + pieChartInstance.current.setOption(option, { notMerge: true }); + }; + + // 初始化对比图 - 使用真实月度对比数据 + const initCompareChart = () => { + if (!compareChartInstance.current) return; + + const labels = monthlyCompare + ? [monthlyCompare.currentMonth.label, monthlyCompare.lastMonth.label] + : ['本月', '上月']; + const incomeData = monthlyCompare + ? [monthlyCompare.currentMonth.income, monthlyCompare.lastMonth.income] + : [0, 0]; + const expenseData = monthlyCompare + ? [monthlyCompare.currentMonth.expense, monthlyCompare.lastMonth.expense] + : [0, 0]; + const surplusData = monthlyCompare + ? [ + monthlyCompare.currentMonth.income - monthlyCompare.currentMonth.expense, + monthlyCompare.lastMonth.income - monthlyCompare.lastMonth.expense, + ] + : [0, 0]; + + const option = { + animation: true, + animationDuration: 900, + animationEasing: 'elasticOut' as const, + tooltip: { + trigger: 'axis' as const, + backgroundColor: 'rgba(26, 26, 26, 0.95)', + borderColor: 'transparent', + textStyle: { color: '#fff' }, + axisPointer: { type: 'shadow' as const }, + formatter: (params: any) => { + let result = `${params[0].axisValue}
`; + params.forEach((param: any) => { + let prefix = ''; + if (param.seriesName === '收入') prefix = '+'; + else if (param.seriesName === '支出') prefix = '-'; + const colorMap: { [key: string]: string } = { + '收入': '#00c853', + '支出': '#ff3d00', + '盈余': '#0052ff', + }; + result += ` ${param.seriesName}: ${prefix}¥${Number(param.value).toLocaleString()}
`; + }); + return result; + }, + }, + legend: { + data: ['收入', '支出', '盈余'], + bottom: 0, + textStyle: { color: '#737373' }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + top: '10%', + containLabel: true, + }, + xAxis: { + type: 'category' as const, + data: labels, + axisLine: { lineStyle: { color: '#e5e5e5' } }, + axisLabel: { color: '#737373', fontSize: 13 }, + }, + yAxis: { + type: 'value' as const, + axisLine: { show: false }, + splitLine: { lineStyle: { color: '#f5f5f5' } }, + axisLabel: { + color: '#737373', + formatter: (value: number) => `¥${(value / 1000).toFixed(1)}k`, + }, + }, + series: [ + { + name: '收入', + type: 'bar' as const, + barWidth: '22%', + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#00c853' }, + { offset: 1, color: '#00a844' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + data: incomeData, + animationDelay: (idx: number) => idx * 200, + }, + { + name: '支出', + type: 'bar' as const, + barWidth: '22%', + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#ff3d00' }, + { offset: 1, color: '#e63600' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + data: expenseData, + animationDelay: (idx: number) => idx * 200 + 100, + }, + { + name: '盈余', + type: 'bar' as const, + barWidth: '22%', + itemStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#0052ff' }, + { offset: 1, color: '#0041cc' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + data: surplusData, + animationDelay: (idx: number) => idx * 200 + 200, + }, + ], + }; + + compareChartInstance.current.setOption(option, { notMerge: true }); + }; + + const handleExport = () => { + try { + exportAllStats( + { + trendData: displayData, + monthlyCompare: monthlyCompare || undefined, + categoryData, + totalExpense, + }, + `个人记账报表_${currentMonthStr}` + ); + setShowToast(true); + setTimeout(() => setShowToast(false), 2500); + } catch (error) { + console.error('导出失败:', error); + // 可以添加失败 Toast + } + }; + + const chartTitleMap: { [key: string]: string } = { + line: period === 'year' ? '年度收支趋势' : '月度收支趋势', + bar: period === 'year' ? '年度收支趋势' : '月度收支趋势', + pie: '支出构成', + }; + + return ( +
+
+
+

统计报表

+
+
+ +
+ + +
+
+
+ +
+
+ + + +
+ + {chartType !== 'pie' && ( +
+

{chartTitleMap[chartType]}

+ {displayData.length === 0 && !loading ? ( + + ) : ( +
+ )} +
+ )} + + {chartType === 'pie' && ( +
+

支出构成

+
+
+
+ {categoryData.map(item => ( +
+ + {item.name} + {totalExpense > 0 ? Math.round((item.value / totalExpense) * 100) : 0}% +
+ ))} +
+
+
+ )} + +
+

月度对比

+ {monthlyCompare ? ( +
+ ) : ( + + )} +
+
+ + {showToast && ( +
+ + 导出成功 +
+ )} + + +
+ ); +}; + +function ExportIcon() { + return ( + + + + + + ); +} + +function LineIcon() { + return ( + + + + + ); +} + +function BarIcon() { + return ( + + + + + + ); +} + +function PieIcon() { + return ( + + + + + ); +} + +function CheckIcon() { + return ( + + + + + ); +} + +export default StatisticsPage;