Initial commit: accountbook project
- Node.js backend server - Frontend application - Backup script - Project specification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Stats() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [period, setPeriod] = useState('month');
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [trendData, setTrendData] = useState([]);
|
||||
const [categoryData, setCategoryData] = useState([]);
|
||||
const [categoryType, setCategoryType] = useState('expense');
|
||||
const [trendLoading, setTrendLoading] = useState(true);
|
||||
const [categoryLoading, setCategoryLoading] = useState(true);
|
||||
const trendChartRef = useRef(null);
|
||||
const categoryChartRef = useRef(null);
|
||||
const trendChartInstance = useRef(null);
|
||||
const categoryChartInstance = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadTrendData();
|
||||
}, [router, period, currentMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
loadCategoryData();
|
||||
}, [router, currentMonth, categoryType]);
|
||||
|
||||
const getMonthStr = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const formatMonth = (date) => `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
|
||||
const loadTrendData = async () => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const trend = await getTrendStats(period, getMonthStr(currentMonth));
|
||||
setTrendData(trend);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategoryData = async () => {
|
||||
setCategoryLoading(true);
|
||||
try {
|
||||
const category = await getCategoryStats(getMonthStr(currentMonth), categoryType);
|
||||
setCategoryData(category);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setCategoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (trendChartInstance.current) {
|
||||
trendChartInstance.current.destroy();
|
||||
}
|
||||
if (trendChartRef.current && trendData.length > 0) {
|
||||
const ctx = trendChartRef.current.getContext('2d');
|
||||
trendChartInstance.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map((item) => item.period),
|
||||
datasets: [
|
||||
{
|
||||
label: '收入',
|
||||
data: trendData.map((item) => item.income),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: '支出',
|
||||
data: trendData.map((item) => item.expense),
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [trendData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryChartInstance.current) {
|
||||
categoryChartInstance.current.destroy();
|
||||
}
|
||||
if (categoryChartRef.current && categoryData.length > 0) {
|
||||
const ctx = categoryChartRef.current.getContext('2d');
|
||||
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'];
|
||||
categoryChartInstance.current = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categoryData.map((item) => item.category),
|
||||
datasets: [{
|
||||
data: categoryData.map((item) => item.total),
|
||||
backgroundColor: colors.slice(0, categoryData.length),
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [categoryData]);
|
||||
|
||||
const prevMonth = () => {
|
||||
const newDate = new Date(currentMonth);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
setCurrentMonth(newDate);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
const newDate = new Date(currentMonth);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
setCurrentMonth(newDate);
|
||||
};
|
||||
|
||||
const totalCategoryAmount = categoryData.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
const headerContent = (
|
||||
<div className="header-center-container">
|
||||
<div className="header-spacer"></div>
|
||||
<div className="month-switcher-container">
|
||||
<button type="button" className="icon-button" aria-label="上个月" onClick={prevMonth}>
|
||||
<Icon name="chevronLeft" size={18} />
|
||||
</button>
|
||||
<span className="month-switcher__label">{formatMonth(currentMonth)}</span>
|
||||
<button type="button" className="icon-button" aria-label="下个月" onClick={nextMonth}>
|
||||
<Icon name="chevronRight" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-right-actions">
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell headerContent={headerContent} compactHeader>
|
||||
<section className="card section-card section-card--stats">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">趋势</p>
|
||||
<h2>收支趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="period-tabs">
|
||||
<button type="button" className={period === 'week' ? 'active' : ''} onClick={() => setPeriod('week')}>周</button>
|
||||
<button type="button" className={period === 'month' ? 'active' : ''} onClick={() => setPeriod('month')}>月</button>
|
||||
<button type="button" className={period === 'year' ? 'active' : ''} onClick={() => setPeriod('year')}>年</button>
|
||||
</div>
|
||||
<div className="chart-container chart-container--large chart-container--stats">
|
||||
{trendLoading ? <div className="loading-state">加载中...</div> : <canvas ref={trendChartRef}></canvas>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card section-card section-card--stats">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<h2>分类明细</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-type-tabs">
|
||||
<button type="button" className={categoryType === 'expense' ? 'active' : ''} onClick={() => setCategoryType('expense')}>支出</button>
|
||||
<button type="button" className={categoryType === 'income' ? 'active' : ''} onClick={() => setCategoryType('income')}>收入</button>
|
||||
</div>
|
||||
<div className="category-breakdown category-breakdown--stats">
|
||||
{categoryData.length > 0 ? categoryData.map((cat, index) => {
|
||||
const percent = totalCategoryAmount ? ((cat.total / totalCategoryAmount) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<div key={index} className="stats-category-item">
|
||||
<div className="stats-category-header">
|
||||
<span className="stats-category-name">{cat.category}</span>
|
||||
<span className="stats-category-amount">{cat.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stats-category-bar-wrap">
|
||||
<div className="stats-category-bar" style={{ width: `${percent}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user