Initial commit: accountbook project
- Node.js backend server - Frontend application - Backup script - Project specification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import '../styles/globals.css';
|
||||
import { ToastProvider } from '../components/ToastProvider';
|
||||
import { ConfirmProvider } from '../components/ConfirmProvider';
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
return (
|
||||
<ConfirmProvider>
|
||||
<ToastProvider>
|
||||
<Component {...pageProps} />
|
||||
</ToastProvider>
|
||||
</ConfirmProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getCategories, createCategory, deleteCategory } from '../lib/api';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
import { useConfirm } from '../components/ConfirmProvider';
|
||||
|
||||
const ICONS = [
|
||||
'🍜', '🥘', '🥬', '🥩', '🍔', '🍕', '☕', '🍺', '🧋', '🍎', '🍪', '🍫',
|
||||
'🚗', '🚕', '🚌', '🚇', '⛽', '🚙', '🛵', '✈️', '🚢',
|
||||
'🛒', '👔', '👕', '👖', '👟', '👓', '🧢', '👜', '🎒', '💍', '💄',
|
||||
'🏠', '🏢', '🏦', '🏥', '🏨', '🏩', '🔧', '🔨', '🛋️', '📺', '💻', '📱',
|
||||
'📝', '💰', '🎁', '📈', '🧧', '💳', '🎯', '📚', '🎵', '🎮', '🏃', '⚽',
|
||||
'🏊', '🚴', '🎭', '🎨', '🎬', '📷', '🎥', '📺', '🧸', '🐕', '🐈', '🌸', '🌺'
|
||||
];
|
||||
|
||||
export default function Categories() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [categories, setCategories] = useState({ expense: [], income: [] });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [categoryType, setCategoryType] = useState('expense');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
icon: '📝',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadCategories();
|
||||
}, [router]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddModal = (type) => {
|
||||
setCategoryType(type);
|
||||
setFormData({ name: '', icon: '📝' });
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting) return;
|
||||
setModalOpen(false);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.icon) {
|
||||
setFormError('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
await createCategory({
|
||||
type: categoryType,
|
||||
name: formData.name,
|
||||
icon: formData.icon,
|
||||
});
|
||||
closeModal();
|
||||
showToast('分类已创建', { tone: 'success' });
|
||||
await loadCategories();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const shouldDelete = await confirm({
|
||||
title: '删除此分类?',
|
||||
description: '默认分类不可删除,自定义分类删除后将无法恢复。',
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
|
||||
try {
|
||||
await deleteCategory(id);
|
||||
showToast('分类已删除', { tone: 'success' });
|
||||
await loadCategories();
|
||||
} catch (err) {
|
||||
showToast(err.message, { tone: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const actions = (
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell title="分类" subtitle="整理常用分类与图标" actions={actions} compactHeader>
|
||||
{loading ? (
|
||||
<div className="card loading-state">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="card category-section category-section--refresh">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<p className="eyebrow">支出分类</p>
|
||||
<h2>支出</h2>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary btn-inline" onClick={() => openAddModal('expense')}>
|
||||
<span className="button-content">
|
||||
<Icon name="plus" size={16} />
|
||||
添加
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="category-list category-list--chips">
|
||||
{categories.expense.map((cat) => (
|
||||
<div key={cat.id || cat.name} className="category-tag">
|
||||
<span className="cat-icon">{cat.icon}</span>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
{!cat.is_default && (
|
||||
<button type="button" className="icon-button icon-button--ghost cat-delete" aria-label={`删除${cat.name}`} onClick={() => handleDelete(cat.id)}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card category-section category-section--refresh">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<p className="eyebrow">收入分类</p>
|
||||
<h2>收入</h2>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary btn-inline" onClick={() => openAddModal('income')}>
|
||||
<span className="button-content">
|
||||
<Icon name="plus" size={16} />
|
||||
添加
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="category-list category-list--chips">
|
||||
{categories.income.map((cat) => (
|
||||
<div key={cat.id || cat.name} className="category-tag">
|
||||
<span className="cat-icon">{cat.icon}</span>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
{!cat.is_default && (
|
||||
<button type="button" className="icon-button icon-button--ghost cat-delete" aria-label={`删除${cat.name}`} onClick={() => handleDelete(cat.id)}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="card category-tip category-tip--refresh">
|
||||
<p>默认分类无法删除,自定义分类可以删除。</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">新增分类</p>
|
||||
<h2>{categoryType === 'expense' ? '新建支出分类' : '新建收入分类'}</h2>
|
||||
</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="form-group">
|
||||
<label htmlFor="category-name">分类名称</label>
|
||||
<input
|
||||
id="category-name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="请输入分类名称"
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>选择图标</label>
|
||||
<div className="icon-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(8, minmax(0, 1fr))', gap: '8px' }}>
|
||||
{ICONS.map((icon) => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
className={`icon-btn ${formData.icon === icon ? 'active' : ''}`}
|
||||
onClick={() => setFormData({ ...formData, icon })}
|
||||
disabled={submitting}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<span className="button-content">
|
||||
<Icon name="spinner" size={16} className="is-spinning" />
|
||||
保存中...
|
||||
</span>
|
||||
) : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { login, register, isLoggedIn } from '../lib/api';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [isLoginMode, setIsLoginMode] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn()) {
|
||||
router.push('/');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isLoginMode) {
|
||||
await login(username, password);
|
||||
showToast('登录成功', { tone: 'success' });
|
||||
} else {
|
||||
await register(username, password);
|
||||
showToast('注册成功', { tone: 'success' });
|
||||
}
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="记账本" subtitle="随时记录每一笔收支" hideNav contentClassName="auth-layout" compactHeader>
|
||||
<div className="auth-page">
|
||||
<div className="auth-card auth-card--refresh">
|
||||
<div className="auth-logo auth-logo--refresh">
|
||||
<div className="auth-logo__mark" aria-hidden="true">
|
||||
<Icon name="chart" size={24} />
|
||||
</div>
|
||||
<h1>{isLoginMode ? '欢迎回来' : '创建账户'}</h1>
|
||||
<p>{isLoginMode ? '登录后继续记录每一笔收支' : '注册后即可开始管理你的账本'}</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-switch" role="tablist" aria-label="登录或注册">
|
||||
<button
|
||||
type="button"
|
||||
className={isLoginMode ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
setIsLoginMode(true);
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={!isLoginMode ? 'is-active' : ''}
|
||||
onClick={() => {
|
||||
setIsLoginMode(false);
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="3-20个字符"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="6-20个字符"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? (
|
||||
<span className="button-content">
|
||||
<Icon name="spinner" size={16} className="is-spinning" />
|
||||
处理中...
|
||||
</span>
|
||||
) : isLoginMode ? '登录' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } 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 Recurring() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [bills, setBills] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBill, setEditingBill] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [applyingId, setApplyingId] = useState(null);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
category: '',
|
||||
day: '1',
|
||||
note: '',
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadBills();
|
||||
}, [router]);
|
||||
|
||||
const loadBills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRecurringBills();
|
||||
setBills(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingBill(null);
|
||||
setFormData({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
category: '',
|
||||
day: '1',
|
||||
note: '',
|
||||
is_active: true,
|
||||
});
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (bill) => {
|
||||
setEditingBill(bill);
|
||||
setFormData({
|
||||
type: bill.type,
|
||||
amount: bill.amount.toString(),
|
||||
category: bill.category,
|
||||
day: bill.day.toString(),
|
||||
note: bill.note || '',
|
||||
is_active: !!bill.is_active,
|
||||
});
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting || deleting) return;
|
||||
setModalOpen(false);
|
||||
setEditingBill(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.amount || !formData.category || !formData.day) {
|
||||
setFormError('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
const data = {
|
||||
type: formData.type,
|
||||
amount: parseFloat(formData.amount),
|
||||
category: formData.category,
|
||||
day: parseInt(formData.day),
|
||||
note: formData.note,
|
||||
is_active: formData.is_active ? 1 : 0,
|
||||
};
|
||||
|
||||
if (editingBill) {
|
||||
await updateRecurringBill(editingBill.id, data);
|
||||
showToast('周期账单已更新', { tone: 'success' });
|
||||
} else {
|
||||
await createRecurringBill(data);
|
||||
showToast('周期账单已创建', { tone: 'success' });
|
||||
}
|
||||
closeModal();
|
||||
await loadBills();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editingBill) return;
|
||||
const shouldDelete = await confirm({
|
||||
title: '删除这条周期账单?',
|
||||
description: '删除后将无法恢复,请确认是否继续。',
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecurringBill(editingBill.id);
|
||||
closeModal();
|
||||
showToast('周期账单已删除', { tone: 'success' });
|
||||
await loadBills();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async (id) => {
|
||||
setApplyingId(id);
|
||||
try {
|
||||
await applyRecurringBill(id);
|
||||
showToast('已添加到账目', { tone: 'success' });
|
||||
} catch (err) {
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setApplyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (bill, event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await updateRecurringBill(bill.id, {
|
||||
...bill,
|
||||
is_active: bill.is_active ? 0 : 1,
|
||||
});
|
||||
showToast(bill.is_active ? '已停用周期账单' : '已启用周期账单', { tone: 'success' });
|
||||
await loadBills();
|
||||
} catch (err) {
|
||||
showToast(err.message, { tone: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const days = Array.from({ length: 28 }, (_, index) => index + 1);
|
||||
|
||||
const actions = (
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell title="周期账单" subtitle="管理固定收入与支出节奏" actions={actions} compactHeader>
|
||||
{loading ? (
|
||||
<div className="card loading-state">加载中...</div>
|
||||
) : bills.length === 0 ? (
|
||||
<div className="card empty-state empty-state--card">
|
||||
<div className="empty-state__icon"><Icon name="repeat" size={24} /></div>
|
||||
<p>暂无周期性账单</p>
|
||||
<p className="sub">点击右下角新增定期收入或支出。</p>
|
||||
</div>
|
||||
) : (
|
||||
<section className="card section-card">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">账单列表</p>
|
||||
<h2>{bills.length} 条周期账单</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bills-list">
|
||||
{bills.map((bill) => {
|
||||
const categories = DEFAULT_CATEGORIES[bill.type];
|
||||
const cat = categories.find((item) => item.name === bill.category) || { icon: '📝' };
|
||||
return (
|
||||
<button
|
||||
key={bill.id}
|
||||
type="button"
|
||||
className={`bill-item ${bill.is_active ? '' : 'inactive'}`}
|
||||
onClick={() => openEditModal(bill)}
|
||||
>
|
||||
<div className="bill-left">
|
||||
<div className="bill-icon">{cat.icon}</div>
|
||||
<div className="bill-info">
|
||||
<div className="bill-row">
|
||||
<div className="bill-category">{bill.category}</div>
|
||||
<span className={`status-chip ${bill.is_active ? 'status-chip--success' : ''}`}>
|
||||
{bill.is_active ? '启用中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bill-day">每月 {bill.day} 日</div>
|
||||
{bill.note && <div className="bill-note">{bill.note}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bill-right">
|
||||
<div className={`bill-amount ${bill.type}`}>
|
||||
{bill.type === 'income' ? '+' : '-'}{bill.amount.toFixed(2)}
|
||||
</div>
|
||||
<div className="bill-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-inline btn-small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleApply(bill.id);
|
||||
}}
|
||||
disabled={applyingId === bill.id}
|
||||
>
|
||||
{applyingId === bill.id ? '应用中...' : '应用'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-inline btn-small"
|
||||
onClick={(event) => handleToggleActive(bill, event)}
|
||||
>
|
||||
{bill.is_active ? '停用' : '启用'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<button type="button" className="fab" aria-label="新增账单" onClick={openAddModal}>
|
||||
<Icon name="plus" size={24} />
|
||||
</button>
|
||||
|
||||
{modalOpen && (
|
||||
<div className="modal-overlay" onClick={closeModal}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<p className="eyebrow">{editingBill ? '编辑账单' : '新增账单'}</p>
|
||||
<h2>{editingBill ? '更新周期账单' : '创建周期账单'}</h2>
|
||||
</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="bill-amount">金额</label>
|
||||
<input
|
||||
id="bill-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 htmlFor="bill-day">日期(每月几号)</label>
|
||||
<select id="bill-day" value={formData.day} onChange={(e) => setFormData({ ...formData, day: e.target.value })} disabled={submitting || deleting}>
|
||||
{days.map((day) => (
|
||||
<option key={day} value={day}>{day} 日</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="bill-note">备注(可选)</label>
|
||||
<input
|
||||
id="bill-note"
|
||||
type="text"
|
||||
value={formData.note}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
placeholder="添加备注..."
|
||||
disabled={submitting || deleting}
|
||||
/>
|
||||
</div>
|
||||
{editingBill && (
|
||||
<div className="form-group form-group--inline">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
disabled={submitting || deleting}
|
||||
/>
|
||||
启用此账单
|
||||
</label>
|
||||
</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>
|
||||
{editingBill && (
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={submitting || deleting}>
|
||||
{deleting ? '删除中...' : '删除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, searchRecords } from '../lib/api';
|
||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!keyword.trim()) {
|
||||
showToast('请输入搜索关键词', { tone: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await searchRecords(keyword);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoney = (num) => (num || 0).toFixed(2);
|
||||
|
||||
const groupedResults = results.reduce((groups, record) => {
|
||||
const date = record.date.split(' ')[0];
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(record);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
const sortedDates = Object.keys(groupedResults).sort((a, b) => b.localeCompare(a));
|
||||
const totalIncome = results.filter((record) => record.type === 'income').reduce((sum, record) => sum + record.amount, 0);
|
||||
const totalExpense = results.filter((record) => record.type === 'expense').reduce((sum, record) => sum + record.amount, 0);
|
||||
const totalBalance = totalIncome - totalExpense;
|
||||
|
||||
const actions = (
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
<Icon name="settings" size={18} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell title="搜索" subtitle="快速查找分类、备注与金额" actions={actions} compactHeader>
|
||||
<section className="card section-card section-card--search section-card--search-panel">
|
||||
<form className="search-form search-form--refresh" onSubmit={handleSearch}>
|
||||
<div className="search-input-wrap">
|
||||
<Icon name="search" size={18} className="search-input-wrap__icon" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索分类或备注..."
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-inline" disabled={loading}>
|
||||
{loading ? (
|
||||
<span className="button-content">
|
||||
<Icon name="spinner" size={16} className="is-spinning" />
|
||||
搜索中...
|
||||
</span>
|
||||
) : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
<div className="search-summary">
|
||||
<div className="search-stat">
|
||||
<span className="label">收入</span>
|
||||
<span className="value income">+{formatMoney(totalIncome)}</span>
|
||||
</div>
|
||||
<div className="search-stat">
|
||||
<span className="label">支出</span>
|
||||
<span className="value expense">-{formatMoney(totalExpense)}</span>
|
||||
</div>
|
||||
<div className="search-stat">
|
||||
<span className="label">结余</span>
|
||||
<span className="value">{formatMoney(totalBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="results-count">找到 {results.length} 条记录</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<div className="card loading-state">搜索中...</div>
|
||||
) : results.length > 0 ? (
|
||||
<section className="card section-card">
|
||||
<div className="records-list records-list--flush">
|
||||
{sortedDates.map((date) => (
|
||||
<div key={date} className="date-group">
|
||||
<div className="date-label">
|
||||
{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
||||
</div>
|
||||
{groupedResults[date].map((record) => {
|
||||
const categories = DEFAULT_CATEGORIES[record.type];
|
||||
const cat = categories.find((item) => item.name === record.category) || { icon: '📝' };
|
||||
return (
|
||||
<div key={record.id} className="record-item record-item--static">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : keyword ? (
|
||||
<div className="card empty-state empty-state--card">
|
||||
<div className="empty-state__icon"><Icon name="search" size={24} /></div>
|
||||
<p>未找到相关记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card empty-state empty-state--card">
|
||||
<div className="empty-state__icon"><Icon name="search" size={24} /></div>
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, exportData, importData, logout as apiLogout } from '../lib/api';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
import { useConfirm } from '../components/ConfirmProvider';
|
||||
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef(null);
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
const shouldLogout = await confirm({
|
||||
title: '退出当前账号?',
|
||||
description: '退出后需要重新登录才能继续使用。',
|
||||
confirmText: '退出登录',
|
||||
tone: 'info',
|
||||
});
|
||||
if (!shouldLogout) return;
|
||||
|
||||
setLoggingOut(true);
|
||||
apiLogout();
|
||||
showToast('已退出登录', { tone: 'success' });
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await exportData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `accountbook_backup_${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('导出成功', { tone: 'success' });
|
||||
} catch (err) {
|
||||
showToast(`导出失败: ${err.message}`, { tone: 'error' });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
await importData(data);
|
||||
showToast('导入成功', { tone: 'success' });
|
||||
e.target.value = '';
|
||||
} catch (err) {
|
||||
showToast(`导入失败: ${err.message}`, { tone: 'error' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell title="设置" subtitle="数据、账号与应用信息" compactHeader>
|
||||
<section className="card settings-section settings-section--refresh">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">数据管理</p>
|
||||
<h2>导入与导出</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<div className="settings-info">
|
||||
<div className="settings-title">导出数据</div>
|
||||
<div className="settings-desc">将所有账目导出为 JSON 文件,方便备份。</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-inline" onClick={handleExport} disabled={exporting || importing}>
|
||||
<span className="button-content">
|
||||
<Icon name="download" size={16} />
|
||||
{exporting ? '导出中...' : '导出'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<div className="settings-info">
|
||||
<div className="settings-title">导入数据</div>
|
||||
<div className="settings-desc">从 JSON 文件恢复账目数据。</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary btn-inline" onClick={() => fileInputRef.current?.click()} disabled={exporting || importing}>
|
||||
<span className="button-content">
|
||||
<Icon name="upload" size={16} />
|
||||
{importing ? '导入中...' : '导入'}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImport}
|
||||
accept=".json"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card settings-section settings-section--refresh">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">账号</p>
|
||||
<h2>登录状态</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item settings-item--stacked">
|
||||
<div className="settings-info">
|
||||
<div className="settings-title">退出登录</div>
|
||||
<div className="settings-desc">从当前设备安全退出,并返回登录页。</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger btn-inline" onClick={handleLogout} disabled={loggingOut}>
|
||||
<span className="button-content">
|
||||
<Icon name="logout" size={16} />
|
||||
{loggingOut ? '退出中...' : '退出登录'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card settings-section settings-section--refresh">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">关于</p>
|
||||
<h2>应用信息</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-info">
|
||||
<p>简易记账本 v1.0</p>
|
||||
<p className="sub">基于 React + Next.js 开发</p>
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Stats() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [period, setPeriod] = useState('month');
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [trendData, setTrendData] = useState([]);
|
||||
const [categoryData, setCategoryData] = useState([]);
|
||||
const [categoryType, setCategoryType] = useState('expense');
|
||||
const [trendLoading, setTrendLoading] = useState(true);
|
||||
const [categoryLoading, setCategoryLoading] = useState(true);
|
||||
const trendChartRef = useRef(null);
|
||||
const categoryChartRef = useRef(null);
|
||||
const trendChartInstance = useRef(null);
|
||||
const categoryChartInstance = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
loadTrendData();
|
||||
}, [router, period, currentMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) return;
|
||||
loadCategoryData();
|
||||
}, [router, currentMonth, categoryType]);
|
||||
|
||||
const getMonthStr = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const formatMonth = (date) => `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
|
||||
const loadTrendData = async () => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
const trend = await getTrendStats(period, getMonthStr(currentMonth));
|
||||
setTrendData(trend);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setTrendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategoryData = async () => {
|
||||
setCategoryLoading(true);
|
||||
try {
|
||||
const category = await getCategoryStats(getMonthStr(currentMonth), categoryType);
|
||||
setCategoryData(category);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setCategoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (trendChartInstance.current) {
|
||||
trendChartInstance.current.destroy();
|
||||
}
|
||||
if (trendChartRef.current && trendData.length > 0) {
|
||||
const ctx = trendChartRef.current.getContext('2d');
|
||||
trendChartInstance.current = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map((item) => item.period),
|
||||
datasets: [
|
||||
{
|
||||
label: '收入',
|
||||
data: trendData.map((item) => item.income),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
{
|
||||
label: '支出',
|
||||
data: trendData.map((item) => item.expense),
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [trendData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryChartInstance.current) {
|
||||
categoryChartInstance.current.destroy();
|
||||
}
|
||||
if (categoryChartRef.current && categoryData.length > 0) {
|
||||
const ctx = categoryChartRef.current.getContext('2d');
|
||||
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1'];
|
||||
categoryChartInstance.current = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: categoryData.map((item) => item.category),
|
||||
datasets: [{
|
||||
data: categoryData.map((item) => item.total),
|
||||
backgroundColor: colors.slice(0, categoryData.length),
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [categoryData]);
|
||||
|
||||
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 totalCategoryAmount = categoryData.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
const headerContent = (
|
||||
<div className="header-center-container">
|
||||
<div className="header-spacer"></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>
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell headerContent={headerContent} compactHeader>
|
||||
<section className="card section-card section-card--stats">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<p className="eyebrow">趋势</p>
|
||||
<h2>收支趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="period-tabs">
|
||||
<button type="button" className={period === 'week' ? 'active' : ''} onClick={() => setPeriod('week')}>周</button>
|
||||
<button type="button" className={period === 'month' ? 'active' : ''} onClick={() => setPeriod('month')}>月</button>
|
||||
<button type="button" className={period === 'year' ? 'active' : ''} onClick={() => setPeriod('year')}>年</button>
|
||||
</div>
|
||||
<div className="chart-container chart-container--large chart-container--stats">
|
||||
{trendLoading ? <div className="loading-state">加载中...</div> : <canvas ref={trendChartRef}></canvas>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card section-card section-card--stats">
|
||||
<div className="section-heading section-heading--compact">
|
||||
<div>
|
||||
<h2>分类明细</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="category-type-tabs">
|
||||
<button type="button" className={categoryType === 'expense' ? 'active' : ''} onClick={() => setCategoryType('expense')}>支出</button>
|
||||
<button type="button" className={categoryType === 'income' ? 'active' : ''} onClick={() => setCategoryType('income')}>收入</button>
|
||||
</div>
|
||||
<div className="category-breakdown category-breakdown--stats">
|
||||
{categoryData.length > 0 ? categoryData.map((cat, index) => {
|
||||
const percent = totalCategoryAmount ? ((cat.total / totalCategoryAmount) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<div key={index} className="stats-category-item">
|
||||
<div className="stats-category-header">
|
||||
<span className="stats-category-name">{cat.category}</span>
|
||||
<span className="stats-category-amount">{cat.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="stats-category-bar-wrap">
|
||||
<div className="stats-category-bar" style={{ width: `${percent}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="empty-state empty-state--inline">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user