Files
accountbook/frontend/pages/index.js
T
DeveloperandClaude Opus 4.6 3347a256b2 Initial commit: accountbook project
- Node.js backend server
- Frontend application
- Backup script
- Project specification

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

416 lines
15 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 } from 'react';
import { useRouter } from 'next/router';
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
import { DEFAULT_CATEGORIES } from '../lib/categories';
import AppShell from '../components/AppShell';
import { Icon } from '../components/icons';
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 [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] = 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] = 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 = records.reduce((groups, record) => {
const date = record.date.split(' ')[0];
if (!groups[date]) groups[date] = [];
groups[date].push(record);
return groups;
}, {});
const sortedDates = Object.keys(groupedRecords).sort((a, b) => b.localeCompare(a));
const getDayExpense = (date) => groupedRecords[date]
.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 categories = DEFAULT_CATEGORIES[record.type];
const cat = categories.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>
{modalOpen && (
<div className="modal-overlay modal-overlay--fullscreen" onClick={closeModal}>
<div className="modal modal--fullscreen" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<p className="eyebrow">{editingRecord ? '编辑记录' : '新增记录'}</p>
{editingRecord ? <h2>更新账目</h2> : null}
</div>
<button type="button" className="icon-button icon-button--ghost" aria-label="关闭弹窗" onClick={closeModal}>
<Icon name="close" size={18} />
</button>
</div>
{formError && <div className="error-msg">{formError}</div>}
<form onSubmit={handleSubmit}>
<div className="type-switch">
<button
type="button"
className={`type-btn expense ${formData.type === 'expense' ? 'active' : ''}`}
onClick={() => setFormData({ ...formData, type: 'expense', category: '' })}
disabled={submitting || deleting}
>
支出
</button>
<button
type="button"
className={`type-btn income ${formData.type === 'income' ? 'active' : ''}`}
onClick={() => setFormData({ ...formData, type: 'income', category: '' })}
disabled={submitting || deleting}
>
收入
</button>
</div>
<div className="form-group">
<label htmlFor="record-amount">金额</label>
<input
id="record-amount"
type="number"
className="amount-input"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
placeholder="0.00"
step="0.01"
required
disabled={submitting || deleting}
/>
</div>
<div className="form-group">
<label>分类</label>
<div className="category-grid">
{DEFAULT_CATEGORIES[formData.type].map((cat) => (
<button
key={cat.name}
type="button"
className={`category-btn ${formData.category === cat.name ? 'active' : ''}`}
onClick={() => setFormData({ ...formData, category: cat.name })}
disabled={submitting || deleting}
>
<span className="icon">{cat.icon}</span>
<span>{cat.name}</span>
</button>
))}
</div>
</div>
<div className="form-group">
<label>日期</label>
<div className="datetime-picker">
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
required
disabled={submitting || deleting}
/>
<input
type="time"
value={formData.time || '00:00'}
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
disabled={submitting || deleting}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="record-note">备注可选</label>
<input
id="record-note"
type="text"
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="添加备注..."
disabled={submitting || deleting}
/>
</div>
<div className="modal-actions">
<button type="submit" className="btn btn-primary" disabled={submitting || deleting}>
{submitting ? (
<span className="button-content">
<Icon name="spinner" size={16} className="is-spinning" />
保存中...
</span>
) : '保存'}
</button>
{editingRecord && (
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={submitting || deleting}>
{deleting ? '删除中...' : '删除'}
</button>
)}
</div>
</form>
</div>
</div>
)}
</AppShell>
);
}