feat: 统计页面添加分类筛选与搜索优化

- 统计页面添加分类多选筛选功能
- 点击分类可跳转到搜索页面查看详情
- 搜索页面添加 URL 同步与自动搜索
- 提取 useCategories hook 消除重复代码
- 修复自定义分类图标显示问题
- 添加 useMemo 优化计算性能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-17 13:35:40 +08:00
co-authored by Claude Opus 4.6
parent b5f9a238bb
commit a89d4f2402
6 changed files with 345 additions and 60 deletions
+57 -39
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useRouter } from 'next/router';
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
import { Chart, registerables } from 'chart.js';
@@ -16,12 +16,11 @@ export default function Stats() {
const [trendData, setTrendData] = useState([]);
const [categoryData, setCategoryData] = useState([]);
const [categoryType, setCategoryType] = useState('expense');
const [selectedCategories, setSelectedCategories] = useState([]);
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()) {
@@ -112,35 +111,6 @@ export default function Stats() {
}
}, [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);
@@ -153,7 +123,29 @@ export default function Stats() {
setCurrentMonth(newDate);
};
const totalCategoryAmount = categoryData.reduce((sum, item) => sum + item.total, 0);
const handleCategoryTypeChange = (type) => {
setCategoryType(type);
setSelectedCategories([]); // 切换类型时清空选中
};
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">
@@ -201,14 +193,40 @@ export default function Stats() {
</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>
<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">
{categoryData.length > 0 ? categoryData.map((cat, index) => {
const percent = totalCategoryAmount ? ((cat.total / totalCategoryAmount) * 100).toFixed(1) : '0.0';
{filteredCategoryData.length > 0 ? filteredCategoryData.map((cat, index) => {
const percent = filteredTotalAmount ? ((cat.total / filteredTotalAmount) * 100).toFixed(1) : '0.0';
return (
<div key={index} className="stats-category-item">
<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>
@@ -216,7 +234,7 @@ export default function Stats() {
<div className="stats-category-bar-wrap">
<div className="stats-category-bar" style={{ width: `${percent}%` }}></div>
</div>
</div>
</button>
);
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
</div>