2026-03-17 11:19:26 +08:00
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
2026-03-12 11:24:10 +08:00
|
|
|
import { useRouter } from 'next/router';
|
2026-03-18 16:33:29 +08:00
|
|
|
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
2026-03-12 11:24:10 +08:00
|
|
|
import AppShell from '../components/AppShell';
|
|
|
|
|
import { Icon } from '../components/icons';
|
2026-03-17 11:19:26 +08:00
|
|
|
import RecordModal from '../components/RecordModal';
|
2026-03-18 16:48:42 +08:00
|
|
|
import SwipeableItem from '../components/SwipeableItem';
|
|
|
|
|
import EmptyState from '../components/EmptyState';
|
2026-03-12 11:24:10 +08:00
|
|
|
import { useToast } from '../components/ToastProvider';
|
|
|
|
|
import { useConfirm } from '../components/ConfirmProvider';
|
2026-03-18 16:33:29 +08:00
|
|
|
import { useCategories } from '../hooks/useCategories';
|
2026-03-18 16:48:42 +08:00
|
|
|
import { getMonthStr, formatMoney, formatMonth, parseDateTime } from '../lib/utils';
|
2026-03-12 11:24:10 +08:00
|
|
|
|
|
|
|
|
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('');
|
2026-03-18 16:33:29 +08:00
|
|
|
const { categories, error: categoriesError } = useCategories();
|
|
|
|
|
|
|
|
|
|
// 显示分类加载错误
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (categoriesError) {
|
|
|
|
|
showToast('分类加载失败', { tone: 'error' });
|
|
|
|
|
}
|
|
|
|
|
}, [categoriesError]);
|
2026-03-12 11:24:10 +08:00
|
|
|
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: '',
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-18 16:33:29 +08:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-03-12 11:24:10 +08:00
|
|
|
const loadData = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-03-18 16:33:29 +08:00
|
|
|
const [recordsData, statsData] = await Promise.all([
|
2026-03-12 11:24:10 +08:00
|
|
|
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) => {
|
2026-03-18 16:33:29 +08:00
|
|
|
const { datePart, timePart } = parseDateTime(record.date);
|
2026-03-12 11:24:10 +08:00
|
|
|
setEditingRecord(record);
|
|
|
|
|
setFormData({
|
|
|
|
|
type: record.type,
|
|
|
|
|
amount: record.amount.toString(),
|
|
|
|
|
category: record.category,
|
|
|
|
|
date: datePart,
|
2026-03-18 16:33:29 +08:00
|
|
|
time: timePart,
|
2026-03-12 11:24:10 +08: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) {
|
2026-03-18 16:33:29 +08:00
|
|
|
const updated = { ...editingRecord, ...data, amount: parseFloat(data.amount) };
|
2026-03-12 11:24:10 +08:00
|
|
|
await updateRecord(editingRecord.id, data);
|
2026-03-18 16:33:29 +08:00
|
|
|
setRecords(prev => prev.map(r => r.id === editingRecord.id ? updated : r));
|
2026-03-12 11:24:10 +08:00
|
|
|
showToast('记录已更新', { tone: 'success' });
|
|
|
|
|
} else {
|
2026-03-18 16:33:29 +08:00
|
|
|
const newRecord = await createRecord(data);
|
|
|
|
|
setRecords(prev => [...prev, newRecord]);
|
2026-03-12 11:24:10 +08:00
|
|
|
showToast('记录已添加', { tone: 'success' });
|
|
|
|
|
}
|
|
|
|
|
closeModal();
|
|
|
|
|
} 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);
|
2026-03-18 16:33:29 +08:00
|
|
|
setRecords(prev => prev.filter(r => r.id !== editingRecord.id));
|
2026-03-12 11:24:10 +08:00
|
|
|
closeModal();
|
|
|
|
|
showToast('记录已删除', { tone: 'success' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setFormError(err.message);
|
|
|
|
|
showToast(err.message, { tone: 'error' });
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-18 16:48:42 +08:00
|
|
|
const handleQuickDelete = async (record) => {
|
|
|
|
|
const shouldDelete = await confirm({
|
|
|
|
|
title: '删除这条记录?',
|
|
|
|
|
description: `${record.category} ${record.type === 'income' ? '+' : '-'}${formatMoney(record.amount)}`,
|
|
|
|
|
confirmText: '删除',
|
|
|
|
|
});
|
|
|
|
|
if (!shouldDelete) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await deleteRecord(record.id);
|
|
|
|
|
setRecords(prev => prev.filter(r => r.id !== record.id));
|
|
|
|
|
showToast('记录已删除', { tone: 'success' });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast(err.message, { tone: 'error' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
const groupedRecords = useMemo(() => {
|
|
|
|
|
return records.reduce((groups, record) => {
|
2026-03-18 16:33:29 +08:00
|
|
|
const date = getDatePart(record.date);
|
2026-03-17 11:19:26 +08:00
|
|
|
if (!groups[date]) groups[date] = [];
|
|
|
|
|
groups[date].push(record);
|
|
|
|
|
return groups;
|
|
|
|
|
}, {});
|
|
|
|
|
}, [records]);
|
2026-03-12 11:24:10 +08:00
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
const sortedDates = useMemo(
|
|
|
|
|
() => Object.keys(groupedRecords).sort((a, b) => b.localeCompare(a)),
|
|
|
|
|
[groupedRecords]
|
|
|
|
|
);
|
2026-03-12 11:24:10 +08:00
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
const getDayExpense = (date) => {
|
|
|
|
|
const dayRecords = groupedRecords[date] || [];
|
|
|
|
|
return dayRecords
|
|
|
|
|
.filter((record) => record.type === 'expense')
|
|
|
|
|
.reduce((sum, record) => sum + record.amount, 0);
|
|
|
|
|
};
|
2026-03-12 11:24:10 +08:00
|
|
|
|
|
|
|
|
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 ? (
|
2026-03-18 16:48:42 +08:00
|
|
|
<EmptyState
|
|
|
|
|
icon="info"
|
|
|
|
|
title="暂无记录"
|
|
|
|
|
description={typeFilter === 'all' ? '本月还没有记账,点击下方按钮添加第一笔记录' : '该类型下暂无记录'}
|
|
|
|
|
action
|
|
|
|
|
actionLabel="添加记录"
|
|
|
|
|
onAction={openAddModal}
|
|
|
|
|
/>
|
2026-03-12 11:24:10 +08:00
|
|
|
) : (
|
|
|
|
|
<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) => {
|
2026-03-18 16:33:29 +08:00
|
|
|
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
2026-03-12 11:24:10 +08:00
|
|
|
return (
|
2026-03-18 16:48:42 +08:00
|
|
|
<SwipeableItem
|
|
|
|
|
key={record.id}
|
|
|
|
|
onSwipeLeft={() => openEditModal(record)}
|
|
|
|
|
renderRightActions={(handleAction) => (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="swipeable-action swipeable-action--edit"
|
|
|
|
|
onClick={() => handleAction(() => openEditModal(record))}
|
|
|
|
|
aria-label="编辑"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="edit" size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="swipeable-action swipeable-action--delete"
|
|
|
|
|
onClick={() => handleAction(() => handleQuickDelete(record))}
|
|
|
|
|
aria-label="删除"
|
|
|
|
|
>
|
|
|
|
|
<Icon name="trash" size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button 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>
|
2026-03-12 11:24:10 +08:00
|
|
|
</div>
|
2026-03-18 16:48:42 +08:00
|
|
|
<div className={`record-amount ${record.type}`}>
|
|
|
|
|
{record.type === 'income' ? '+' : '-'}
|
|
|
|
|
{formatMoney(record.amount)}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</SwipeableItem>
|
2026-03-12 11:24:10 +08:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<button type="button" className="fab" aria-label="新增记录" onClick={openAddModal}>
|
|
|
|
|
<Icon name="plus" size={24} />
|
|
|
|
|
</button>
|
|
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
<RecordModal
|
|
|
|
|
isOpen={modalOpen}
|
|
|
|
|
onClose={closeModal}
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
onDelete={handleDelete}
|
|
|
|
|
formData={formData}
|
|
|
|
|
setFormData={setFormData}
|
|
|
|
|
formError={formError}
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
deleting={deleting}
|
|
|
|
|
editingRecord={editingRecord}
|
|
|
|
|
categories={categories}
|
|
|
|
|
/>
|
2026-03-12 11:24:10 +08:00
|
|
|
</AppShell>
|
|
|
|
|
);
|
|
|
|
|
}
|