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
+37 -9
View File
@@ -1,17 +1,18 @@
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/router';
import { isLoggedIn, searchRecords, updateRecord, deleteRecord } from '../lib/api';
import { DEFAULT_CATEGORIES } from '../lib/categories';
import AppShell from '../components/AppShell';
import { Icon } from '../components/icons';
import RecordModal from '../components/RecordModal';
import { useToast } from '../components/ToastProvider';
import { useConfirm } from '../components/ConfirmProvider';
import { useCategories } from '../hooks/useCategories';
export default function Search() {
const router = useRouter();
const { showToast } = useToast();
const confirm = useConfirm();
const { categories } = useCategories();
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
@@ -35,6 +36,28 @@ export default function Search() {
}
}, [router]);
// 检查 URL 参数,自动搜索
useEffect(() => {
if (router.isReady && router.query.q) {
const q = decodeURIComponent(router.query.q);
setKeyword(q);
// 自动搜索
const doSearch = async () => {
setLoading(true);
try {
const data = await searchRecords(q);
setResults(data);
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
} finally {
setLoading(false);
}
};
doSearch();
}
}, [router.isReady, router.query.q]);
const handleSearch = async (e) => {
e.preventDefault();
if (!keyword.trim()) {
@@ -42,6 +65,9 @@ export default function Search() {
return;
}
// 更新 URL
router.push(`/search?q=${encodeURIComponent(keyword)}`, undefined, { shallow: true });
setLoading(true);
try {
const data = await searchRecords(keyword);
@@ -142,6 +168,7 @@ export default function Search() {
const formatMoney = (num) => (num || 0).toFixed(2);
const groupedResults = useMemo(() => {
if (!Array.isArray(results)) return {};
return results.reduce((groups, record) => {
const date = record.date.split(' ')[0];
if (!groups[date]) groups[date] = [];
@@ -156,6 +183,7 @@ export default function Search() {
);
const { totalIncome, totalExpense, totalBalance } = useMemo(() => {
if (!Array.isArray(results)) return { totalIncome: 0, totalExpense: 0, totalBalance: 0 };
let income = 0;
let expense = 0;
for (const record of results) {
@@ -165,16 +193,16 @@ export default function Search() {
return { totalIncome: income, totalExpense: expense, totalBalance: income - expense };
}, [results]);
// O(1) category icon lookup map
// O(1) category icon lookup map (includes custom categories)
const categoryIconMap = useMemo(() => {
const icons = {};
for (const type of ['expense', 'income']) {
for (const cat of DEFAULT_CATEGORIES[type]) {
for (const cat of categories[type]) {
icons[`${type}:${cat.name}`] = cat.icon;
}
}
return icons;
}, []);
}, [categories]);
const actions = (
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
@@ -209,16 +237,16 @@ export default function Search() {
{results.length > 0 ? (
<>
<div className="search-summary">
<div className="search-stat">
<div className="stats-grid stats-grid--hero">
<div className="stat-card">
<span className="label">收入</span>
<span className="value income">+{formatMoney(totalIncome)}</span>
</div>
<div className="search-stat">
<div className="stat-card">
<span className="label">支出</span>
<span className="value expense">-{formatMoney(totalExpense)}</span>
</div>
<div className="search-stat">
<div className="stat-card stat-card--balance">
<span className="label">结余</span>
<span className="value">{formatMoney(totalBalance)}</span>
</div>
@@ -283,7 +311,7 @@ export default function Search() {
submitting={submitting}
deleting={deleting}
editingRecord={editingRecord}
categories={DEFAULT_CATEGORIES}
categories={categories}
/>
</AppShell>
);