Files
accountbook/frontend/pages/index.js
T
DeveloperandClaude Opus 4.6 3975bbed73 perf: 性能优化与代码质量改进
- 添加4个数据库索引提升查询性能
- 启用ESLint并配置React支持
- 修复ESLint错误(未使用的导入和状态)
- 简化Toast组件UI样式
- 移除重复文件(server.js.backup)
- 清理.gitignore中的.next目录

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

346 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 } from '../lib/api';
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';
import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart } from '../lib/utils';
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, error: categoriesError } = useCategories();
// 显示分类加载错误
useEffect(() => {
if (categoriesError) {
showToast('分类加载失败', { tone: 'error' });
}
}, [categoriesError]);
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 resetForm = () => ({
type: 'expense',
amount: '',
category: '',
date: new Date().toISOString().split('T')[0],
time: new Date().toTimeString().slice(0, 5),
note: '',
});
// Memoized category lookup map for O(1) icon retrieval
const categoryIconMap = useMemo(() => {
const icons = {};
for (const type of ['expense', 'income']) {
for (const cat of categories[type] || []) {
icons[`${type}:${cat.name}`] = cat.icon;
}
}
return icons;
}, [categories]);
const loadData = async () => {
setLoading(true);
try {
const [recordsData, statsData] = await Promise.all([
getRecords(getMonthStr(currentMonth), typeFilter),
getStats(getMonthStr(currentMonth)),
]);
setRecords(recordsData);
setStats(statsData);
} 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 } = parseDateTime(record.date);
setEditingRecord(record);
setFormData({
type: record.type,
amount: record.amount.toString(),
category: record.category,
date: datePart,
time: timePart,
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) {
const updated = { ...editingRecord, ...data, amount: parseFloat(data.amount) };
await updateRecord(editingRecord.id, data);
setRecords(prev => prev.map(r => r.id === editingRecord.id ? updated : r));
showToast('记录已更新', { tone: 'success' });
} else {
const newRecord = await createRecord(data);
setRecords(prev => [...prev, newRecord]);
}
closeModal();
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);
setRecords(prev => prev.filter(r => r.id !== editingRecord.id));
closeModal();
showToast('记录已删除', { tone: 'success' });
loadData();
} catch (err) {
setFormError(err.message);
showToast(err.message, { tone: 'error' });
} finally {
setDeleting(false);
}
};
const groupedRecords = useMemo(() => {
return records.reduce((groups, record) => {
const date = getDatePart(record.date);
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 icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
return (
<button key={record.id} type="button" className="record-item" onClick={() => openEditModal(record)}>
<div className="record-left">
<div className="record-icon">{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>
);
}