Files
accountbook/frontend/pages/stats.js
T
DeveloperandClaude Opus 4.6 6c5e7dc811 fix: 统计页面分类明细默认选中所有分类
- 添加 useEffect 在 categoryData 加载后自动选中所有分类
- 移除切换类型时清空选中的逻辑,新数据加载后自动全选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:36:12 +08:00

269 lines
9.1 KiB
JavaScript

import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useRouter } from 'next/router';
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
import {
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler,
Legend,
} from 'chart.js';
import AppShell from '../components/AppShell';
import { Icon } from '../components/icons';
import { getMonthStr, formatMonth } from '../lib/utils';
import { useToast } from '../components/ToastProvider';
Chart.register(
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler,
Legend
);
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 [selectedCategories, setSelectedCategories] = useState([]);
const [trendLoading, setTrendLoading] = useState(true);
const trendChartRef = useRef(null);
const trendChartInstance = useRef(null);
const loadTrendData = useCallback(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);
}
}, [period, currentMonth, showToast]);
const loadCategoryData = useCallback(async () => {
try {
const category = await getCategoryStats(getMonthStr(currentMonth), categoryType);
setCategoryData(category);
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
}
}, [currentMonth, categoryType, showToast]);
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
// 并行加载两个数据集
Promise.all([loadTrendData(), loadCategoryData()]);
}, [router, loadTrendData, loadCategoryData]);
useEffect(() => {
return () => {
// 组件卸载时清理 Chart.js 实例
if (trendChartInstance.current) {
trendChartInstance.current.destroy();
trendChartInstance.current = null;
}
};
}, []);
useEffect(() => {
if (trendChartInstance.current) {
trendChartInstance.current.destroy();
trendChartInstance.current = null;
}
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]);
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);
};
// 当分类数据加载后,默认选中所有分类
useEffect(() => {
if (categoryData.length > 0) {
setSelectedCategories(categoryData.map(cat => cat.category));
}
}, [categoryData]);
const handleCategoryTypeChange = (type) => {
setCategoryType(type);
};
const toggleCategory = (categoryName) => {
if (selectedCategories.includes(categoryName)) {
setSelectedCategories(selectedCategories.filter(c => c !== categoryName));
} else {
setSelectedCategories([...selectedCategories, categoryName]);
}
};
// 根据选中分类过滤数据
const filteredCategoryData = useMemo(() => {
return selectedCategories.length > 0
? categoryData.filter(cat => selectedCategories.includes(cat.category))
: categoryData;
}, [selectedCategories, categoryData]);
const filteredTotalAmount = useMemo(() => {
return filteredCategoryData.reduce((sum, item) => sum + item.total, 0);
}, [filteredCategoryData]);
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={() => handleCategoryTypeChange('expense')}>支出</button>
<button type="button" className={categoryType === 'income' ? 'active' : ''} onClick={() => handleCategoryTypeChange('income')}>收入</button>
</div>
{/* 总额显示 */}
<div className="stats-total">
<span className="stats-total-label">{categoryType === 'expense' ? '支出' : '收入'}</span>
<span className="stats-total-amount">¥{filteredTotalAmount.toFixed(2)}</span>
</div>
{/* 分类选择chips */}
<div className="category-chips">
{categoryData.map((cat) => (
<button
key={cat.category}
type="button"
className={`category-chip ${selectedCategories.includes(cat.category) ? 'selected' : ''}`}
onClick={() => toggleCategory(cat.category)}
>
{cat.category}
</button>
))}
</div>
<div className="category-breakdown category-breakdown--stats">
{filteredCategoryData.length > 0 ? filteredCategoryData.map((cat, index) => {
const percent = filteredTotalAmount ? ((cat.total / filteredTotalAmount) * 100).toFixed(1) : '0.0';
return (
<button
key={index}
type="button"
className="stats-category-item stats-category-item--clickable"
onClick={() => router.push(`/search?q=${encodeURIComponent(cat.category)}`)}
>
<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>
</button>
);
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
</div>
</section>
</AppShell>
);
}