- 添加 useEffect 在 categoryData 加载后自动选中所有分类 - 移除切换类型时清空选中的逻辑,新数据加载后自动全选 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
9.1 KiB
JavaScript
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>
|
|
);
|
|
}
|