Files
accountbook/frontend/pages/recurring.js
T
DeveloperandClaude Opus 4.6 a89d4f2402 feat: 统计页面添加分类筛选与搜索优化
- 统计页面添加分类多选筛选功能
- 点击分类可跳转到搜索页面查看详情
- 搜索页面添加 URL 同步与自动搜索
- 提取 useCategories hook 消除重复代码
- 修复自定义分类图标显示问题
- 添加 useMemo 优化计算性能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 13:35:40 +08:00

376 lines
13 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, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
import AppShell from '../components/AppShell';
import { Icon } from '../components/icons';
import { useToast } from '../components/ToastProvider';
import { useConfirm } from '../components/ConfirmProvider';
import { useCategories } from '../hooks/useCategories';
export default function Recurring() {
const router = useRouter();
const { showToast } = useToast();
const { confirm } = useConfirm();
const { categories } = useCategories();
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 categoryList = categories[bill.type];
const cat = categoryList.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">
{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>
);
}