Files
accountbook/frontend/pages/index.js
T
DeveloperandClaude Opus 4.6 98aa39cb1c refactor: 代码质量优化与安全修复
- 删除根目录重复的 server.js (830行),统一使用模块化的 server/index.js
- 修复 ESLint 错误: 移除未使用变量、修复 const 声明、处理未使用参数
- 修复 React Hooks 依赖警告: 使用 useCallback 包装函数并添加正确依赖项
- SQL LIKE 查询添加特殊字符转义,防止注入风险
- 移除 CSS 中重复的 @keyframes spin 定义

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:49:12 +08:00

352 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, useCallback } 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, formatDate, getCurrentTimeStr } 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, showToast]);
const [formData, setFormData] = useState({
type: 'expense',
amount: '',
category: '',
date: formatDate(new Date()),
time: getCurrentTimeStr(),
note: '',
});
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router, currentMonth, typeFilter]);
const resetForm = () => ({
type: 'expense',
amount: '',
category: '',
date: formatDate(new Date()),
time: getCurrentTimeStr(),
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 = useCallback(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);
}
}, [currentMonth, typeFilter, showToast]);
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 dayExpenses = useMemo(() => {
const expenses = {};
for (const date of Object.keys(groupedRecords)) {
const dayRecords = groupedRecords[date] || [];
expenses[date] = dayRecords
.filter((record) => record.type === 'expense')
.reduce((sum, record) => sum + record.amount, 0);
}
return expenses;
}, [groupedRecords]);
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(dayExpenses[date] || 0)}</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>
);
}