Files
app_02_bill/components/StatisticsPage.tsx
karlcarlo c06e74bd01 feat(app): 实现完整的财务管家记账聊天与统计功能
- 新增App主组件,实现聊天、账本、统计三大功能模块切换
- 支持语音及文本输入,自动解析记账信息并生成交易记录
- 交易记录支持编辑、删除、提醒等操作,数据本地持久化
- 管家性格预设和自定义设定,包含头像上传和语气调整
- 会话展示支持关联交易显示,样式区分收入与支出
- 账本页添加日期分组交易列表及饼图统计展示
- 消息列表实现渐变背景和交互动画,提升用户体验
- 支持图片上传并同步发送给AI进行解析记账
- 底部导航实现三标签切换并同步显示对应内容
- 全局事务编辑弹窗支持直接调用和删除操作
- 增加环境变量示例和.gitignore规则,完善工程配置
2026-01-19 20:20:54 +08:00

274 lines
13 KiB
TypeScript

import React, { useMemo, useState, useEffect } from 'react';
import { Transaction, TransactionType, ExpenseCategory } from '../types';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, LineChart, Line, CartesianGrid, ReferenceLine } from 'recharts';
interface StatisticsPageProps {
transactions: Transaction[];
}
const COLORS = [
'#6366f1', // Indigo
'#ec4899', // Pink
'#10b981', // Emerald
'#f59e0b', // Amber
'#8b5cf6', // Violet
'#ef4444', // Red
'#3b82f6', // Blue
'#14b8a6', // Teal
'#f97316', // Orange
'#84cc16', // Lime
'#06b6d4', // Cyan
'#d946ef', // Fuchsia
'#64748b', // Slate
'#a855f7', // Purple
'#fbbf24' // Yellow
];
const StatisticsPage: React.FC<StatisticsPageProps> = ({ transactions }) => {
const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7));
const [trendCategory, setTrendCategory] = useState<string>(ExpenseCategory.FOOD);
const monthlyTransactions = useMemo(() => {
return transactions.filter(t => t.date.startsWith(selectedMonth));
}, [transactions, selectedMonth]);
const totalIncome = useMemo(() =>
monthlyTransactions.filter(t => t.type === TransactionType.INCOME).reduce((sum, t) => sum + t.amount, 0),
[monthlyTransactions]);
const totalExpense = useMemo(() =>
monthlyTransactions.filter(t => t.type === TransactionType.EXPENSE).reduce((sum, t) => sum + t.amount, 0),
[monthlyTransactions]);
const monthlyBalance = useMemo(() => totalIncome - totalExpense, [totalIncome, totalExpense]);
// Extract month number for display
const monthLabel = useMemo(() => {
const month = parseInt(selectedMonth.split('-')[1]);
return `${month}月`;
}, [selectedMonth]);
const barData = [
{ name: '收入', amount: totalIncome, fill: '#10b981' },
{ name: '支出', amount: totalExpense, fill: '#ec4899' },
{ name: '结余', amount: monthlyBalance, fill: '#6366f1' }
];
// Category Stats (Used for Ranking and Detail)
const categoryStats = useMemo(() => {
const map = new Map<string, { count: number; amount: number }>();
monthlyTransactions
.filter(t => t.type === TransactionType.EXPENSE)
.forEach(t => {
const existing = map.get(t.category) || { count: 0, amount: 0 };
existing.count += 1;
existing.amount += t.amount;
map.set(t.category, existing);
});
return Array.from(map.entries())
.map(([name, stat], index) => ({
name,
...stat,
color: COLORS[index % COLORS.length]
}))
.sort((a, b) => b.amount - a.amount);
}, [monthlyTransactions]);
// Set default trend category to top expense of current month if available
useEffect(() => {
if (categoryStats.length > 0) {
setTrendCategory(categoryStats[0].name);
}
}, [categoryStats.length > 0 ? categoryStats[0].name : null]);
// Top 5 Ranking (Now based on Categories)
const rankingData = useMemo(() => {
return categoryStats.slice(0, 5);
}, [categoryStats]);
// Calculate 6-month trend for the selected category
const trendData = useMemo(() => {
const data = [];
const today = new Date();
// Look back 6 months
for (let i = 5; i >= 0; i--) {
const d = new Date(today.getFullYear(), today.getMonth() - i, 1);
const monthStr = d.toISOString().slice(0, 7); // YYYY-MM
const displayMonth = `${d.getMonth() + 1}月`;
const total = transactions
.filter(t =>
t.type === TransactionType.EXPENSE &&
t.category === trendCategory &&
t.date.startsWith(monthStr)
)
.reduce((sum, t) => sum + t.amount, 0);
data.push({
name: displayMonth,
amount: total,
fullDate: monthStr
});
}
return data;
}, [transactions, trendCategory]);
return (
<div className="h-full bg-gray-50 overflow-y-auto no-scrollbar pb-24 font-sans">
{/* Header */}
<div className="bg-white p-5 sticky top-0 z-10 shadow-sm">
<div className="flex justify-between items-center">
<h1 className="text-xl font-black text-gray-800">收支统计</h1>
<input
type="month"
value={selectedMonth}
onChange={e => setSelectedMonth(e.target.value)}
className="bg-gray-100 rounded-lg px-3 py-1 text-xs font-bold text-gray-500 outline-none border-none shadow-sm focus:ring-1 focus:ring-indigo-300 transition-all"
/>
</div>
</div>
<div className="p-5 space-y-6">
{/* Monthly Overview Card */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-6 flex items-center gap-2">
<span className="w-1.5 h-4 bg-indigo-500 rounded-full"></span>
{monthLabel}收支总览
</h2>
<div className="grid grid-cols-3 gap-2 mb-6 px-1">
<div className="text-center border-r border-gray-50">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总收入</div>
<div className="text-sm font-black text-emerald-500 font-mono truncate">+¥{totalIncome.toFixed(0)}</div>
</div>
<div className="text-center border-r border-gray-50">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">总支出</div>
<div className="text-sm font-black text-rose-500 font-mono truncate">-¥{totalExpense.toFixed(0)}</div>
</div>
<div className="text-center">
<div className="text-[10px] text-gray-400 font-bold mb-1 uppercase tracking-tight">本月结余</div>
<div className={`text-sm font-black font-mono truncate ${monthlyBalance >= 0 ? 'text-indigo-600' : 'text-orange-500'}`}>
{monthlyBalance < 0 ? '-' : ''}¥{Math.abs(monthlyBalance).toFixed(0)}
</div>
</div>
</div>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} layout="vertical" margin={{top:0, left:0, right:30, bottom:0}}>
<XAxis type="number" hide domain={['auto', 'auto']} />
<YAxis dataKey="name" type="category" width={40} tick={{fontSize: 12, fontWeight: 'bold'}} axisLine={false} tickLine={false} />
<Tooltip formatter={(val: number) => `¥${val.toFixed(2)}`} cursor={{fill: 'transparent'}} contentStyle={{borderRadius: '8px'}} />
<ReferenceLine x={0} stroke="#f3f4f6" />
<Bar dataKey="amount" barSize={24} radius={[0, 4, 4, 0]}>
{barData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Top Expenses Ranking (By Category) */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-orange-500 rounded-full"></span>
{monthLabel}分类消费排行榜 (Top 5)
</h2>
<div className="space-y-3">
{rankingData.length > 0 ? rankingData.map((cat, idx) => (
<div key={cat.name} className="flex items-center justify-between">
<div className="flex items-center gap-3 overflow-hidden">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0
${idx === 0 ? 'bg-yellow-400' : (idx === 1 ? 'bg-gray-400' : (idx === 2 ? 'bg-orange-400' : 'bg-indigo-100 text-indigo-500'))}`}>
{idx + 1}
</div>
<span className="text-sm text-gray-700 font-medium truncate">{cat.name}</span>
</div>
<div className="flex flex-col items-end">
<span className="font-mono font-bold text-gray-900 text-sm">¥{cat.amount.toFixed(0)}</span>
<span className="text-[10px] text-gray-400">{cat.count}笔</span>
</div>
</div>
)) : (
<div className="text-center text-gray-400 text-xs py-4">暂无支出</div>
)}
</div>
</div>
{/* Category Breakdown Details */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<h2 className="text-sm font-bold text-gray-700 mb-4 flex items-center gap-2">
<span className="w-1.5 h-4 bg-purple-500 rounded-full"></span>
{monthLabel}分类统计详情
</h2>
<div className="space-y-4">
{categoryStats.length > 0 ? categoryStats.map((cat) => (
<div key={cat.name} className="relative">
<div className="flex justify-between items-center mb-1 text-sm relative z-10">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-700">{cat.name}</span>
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded-md">{cat.count}笔</span>
</div>
<span className="font-mono font-bold text-gray-900">¥{cat.amount.toFixed(0)}</span>
</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${(cat.amount / Math.max(totalExpense, 1)) * 100}%`, backgroundColor: cat.color }}
></div>
</div>
</div>
)) : (
<div className="text-center text-gray-400 text-xs py-4">暂无数据</div>
)}
</div>
</div>
{/* Trend Analysis Chart */}
<div className="bg-white rounded-2xl p-5 shadow-sm border border-gray-100">
<div className="flex justify-between items-center mb-4">
<h2 className="text-sm font-bold text-gray-700 flex items-center gap-2">
<span className="w-1.5 h-4 bg-blue-500 rounded-full"></span>
分类趋势 (近6个月)
</h2>
<select
value={trendCategory}
onChange={(e) => setTrendCategory(e.target.value)}
className="text-xs bg-gray-50 border-none rounded-lg py-1 px-2 text-gray-600 font-medium outline-none"
>
{Object.values(ExpenseCategory).map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div className="h-48 w-full -ml-2">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{fontSize: 10, fill: '#9ca3af'}} />
<Tooltip
contentStyle={{borderRadius: '12px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}
itemStyle={{color: '#4f46e5', fontWeight: 'bold'}}
/>
<Line
type="monotone"
dataKey="amount"
stroke="#6366f1"
strokeWidth={3}
dot={{fill: '#6366f1', strokeWidth: 2, r: 4, stroke: '#fff'}}
activeDot={{r: 6}}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
};
export default StatisticsPage;