Files
accountbook/frontend/pages/index.js
T
DeveloperandClaude Opus 4.6 b5f9a238bb feat: 删除亲情卡分类并优化搜索功能
- 移除亲情卡支出分类(前端+后端)
- 搜索结果添加点击编辑功能
- 提取 RecordModal 组件复用(index.js/search.js)
- 添加 useMemo 优化计算性能
- 优化类别查找为 O(1) 映射
- 添加删除分类接口输入验证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:19:26 +08:00

339 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/router';
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord, getCategories } 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';
export default function Home() {
const router = useRouter();
const { showToast } = useToast();
const { confirm } = useConfirm();
const [currentMonth, setCurrentMonth] = useState(new Date());
const [typeFilter, setTypeFilter] = useState('all');
const [records, setRecords] = useState([]);
const [stats, setStats] = useState({ income: 0, expense: 0, balance: 0 });
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [formError, setFormError] = useState('');
const [categories, setCategories] = useState(DEFAULT_CATEGORIES);
const [formData, setFormData] = useState({
type: 'expense',
amount: '',
category: '',
date: new Date().toISOString().split('T')[0],
time: new Date().toTimeString().slice(0, 5),
note: '',
});
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
loadData();
}, [router, currentMonth, typeFilter]);
const getMonthStr = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const formatMoney = (num) => (num || 0).toFixed(2);
const formatMonth = (date) => `${date.getFullYear()}${date.getMonth() + 1}月`;
const resetForm = () => ({
type: 'expense',
amount: '',
category: '',
date: new Date().toISOString().split('T')[0],
time: new Date().toTimeString().slice(0, 5),
note: '',
});
const loadData = async () => {
setLoading(true);
try {
const [recordsData, statsData, categoriesData] = await Promise.all([
getRecords(getMonthStr(currentMonth), typeFilter),
getStats(getMonthStr(currentMonth)),
getCategories(),
]);
setRecords(recordsData);
setStats(statsData);
// 合并默认分类和自定义分类
setCategories({
expense: [
...DEFAULT_CATEGORIES.expense,
...(categoriesData.expense || []).filter(c => !DEFAULT_CATEGORIES.expense.some(d => d.name === c.name))
],
income: [
...DEFAULT_CATEGORIES.income,
...(categoriesData.income || []).filter(c => !DEFAULT_CATEGORIES.income.some(d => d.name === c.name))
],
});
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
} finally {
setLoading(false);
}
};
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 handleCardClick = (type) => {
setTypeFilter(type === typeFilter ? 'all' : type);
};
const openAddModal = () => {
setEditingRecord(null);
setFormData(resetForm());
setFormError('');
setModalOpen(true);
};
const openEditModal = (record) => {
const [datePart, timePart] = record.date.split(' ');
setEditingRecord(record);
setFormData({
type: record.type,
amount: record.amount.toString(),
category: record.category,
date: datePart,
time: timePart || '00:00',
note: record.note || '',
});
setFormError('');
setModalOpen(true);
};
const closeModal = () => {
if (submitting || deleting) return;
setModalOpen(false);
setEditingRecord(null);
setFormError('');
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.amount || !formData.category || !formData.date) {
setFormError('请填写完整信息');
return;
}
setSubmitting(true);
setFormError('');
try {
const data = {
type: formData.type,
amount: parseFloat(formData.amount),
category: formData.category,
date: `${formData.date} ${formData.time || '00:00'}`,
note: formData.note,
};
if (editingRecord) {
await updateRecord(editingRecord.id, data);
showToast('记录已更新', { tone: 'success' });
} else {
await createRecord(data);
showToast('记录已添加', { tone: 'success' });
}
closeModal();
await loadData();
} catch (err) {
setFormError(err.message);
showToast(err.message, { tone: 'error' });
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!editingRecord) return;
const shouldDelete = await confirm({
title: '删除这条记录?',
description: '删除后将无法恢复,请确认是否继续。',
confirmText: '删除',
});
if (!shouldDelete) return;
setDeleting(true);
try {
await deleteRecord(editingRecord.id);
closeModal();
showToast('记录已删除', { tone: 'success' });
await loadData();
} catch (err) {
setFormError(err.message);
showToast(err.message, { tone: 'error' });
} finally {
setDeleting(false);
}
};
const groupedRecords = useMemo(() => {
return records.reduce((groups, record) => {
const date = record.date.split(' ')[0];
if (!groups[date]) groups[date] = [];
groups[date].push(record);
return groups;
}, {});
}, [records]);
const sortedDates = useMemo(
() => Object.keys(groupedRecords).sort((a, b) => b.localeCompare(a)),
[groupedRecords]
);
const getDayExpense = (date) => {
const dayRecords = groupedRecords[date] || [];
return dayRecords
.filter((record) => record.type === 'expense')
.reduce((sum, record) => sum + record.amount, 0);
};
const homeHeaderContent = (
<div className="header-center-container">
<div className="header-left-actions">
<button type="button" className="icon-button" aria-label="搜索记录" onClick={() => router.push('/search')}>
<Icon name="search" size={18} />
</button>
</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>
);
const modalHeaderContent = (
<div className="header-center-container">
<div className="header-left-actions">
<button type="button" className="icon-button" aria-label="关闭" onClick={closeModal}>
<Icon name="chevronLeft" size={18} />
</button>
</div>
<div className="modal-title-container">
<span className="modal-title">{editingRecord ? '编辑记录' : '新增记录'}</span>
</div>
<div className="header-right-actions"></div>
</div>
);
return (
<AppShell headerContent={modalOpen ? modalHeaderContent : homeHeaderContent} compactHeader hideNav={modalOpen}>
<section className="dashboard-hero card">
<div className="stats-grid stats-grid--hero">
<button type="button" className={`stat-card ${typeFilter === 'income' ? 'active' : ''}`} onClick={() => handleCardClick('income')}>
<span className="label">收入</span>
<span className="value income">+{formatMoney(stats.income)}</span>
</button>
<button type="button" className={`stat-card ${typeFilter === 'expense' ? 'active' : ''}`} onClick={() => handleCardClick('expense')}>
<span className="label">支出</span>
<span className="value expense">-{formatMoney(stats.expense)}</span>
</button>
<button type="button" className="stat-card stat-card--balance" onClick={() => handleCardClick('all')}>
<span className="label">结余</span>
<span className="value">{formatMoney(stats.balance)}</span>
</button>
</div>
</section>
<section className="card section-card">
<div className="section-heading section-heading--compact">
<div>
<p className="eyebrow">记录列表</p>
<h2>{typeFilter === 'all' ? '全部记录' : typeFilter === 'income' ? '仅看收入' : '仅看支出'}</h2>
</div>
<button type="button" className="btn btn-secondary btn-inline" onClick={openAddModal}>
<span className="button-content">
<Icon name="plus" size={16} />
新增记录
</span>
</button>
</div>
{loading ? (
<div className="loading-state">加载中...</div>
) : records.length === 0 ? (
<div className="empty-state empty-state--card">
<div className="empty-state__icon"><Icon name="info" size={24} /></div>
<p>暂无记录点击上方按钮添加</p>
</div>
) : (
<div className="records-list records-list--flush">
{sortedDates.map((date) => (
<div key={date} className="date-group">
<div className="date-label">
<span>{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}</span>
<span className="day-expense">支出 {formatMoney(getDayExpense(date))}</span>
</div>
{groupedRecords[date].map((record) => {
const cat = categories[record.type]?.find((item) => item.name === record.category) || { icon: '📝' };
return (
<button key={record.id} type="button" className="record-item" onClick={() => openEditModal(record)}>
<div className="record-left">
<div className="record-icon">{cat.icon}</div>
<div className="record-info">
<div className="record-category">{record.category}</div>
{record.note && <div className="record-note">{record.note}</div>}
</div>
</div>
<div className={`record-amount ${record.type}`}>
{record.type === 'income' ? '+' : '-'}
{formatMoney(record.amount)}
</div>
</button>
);
})}
</div>
))}
</div>
)}
</section>
<button type="button" className="fab" aria-label="新增记录" onClick={openAddModal}>
<Icon name="plus" size={24} />
</button>
<RecordModal
isOpen={modalOpen}
onClose={closeModal}
onSubmit={handleSubmit}
onDelete={handleDelete}
formData={formData}
setFormData={setFormData}
formError={formError}
submitting={submitting}
deleting={deleting}
editingRecord={editingRecord}
categories={categories}
/>
</AppShell>
);
}