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