- Node.js backend server - Frontend application - Backup script - Project specification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
416 lines
15 KiB
JavaScript
416 lines
15 KiB
JavaScript
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>
|
||
);
|
||
}
|