Files
accountbook/frontend/pages/recurring.js
T
DeveloperandClaude Opus 4.6 98aa39cb1c refactor: 代码质量优化与安全修复
- 删除根目录重复的 server.js (830行),统一使用模块化的 server/index.js
- 修复 ESLint 错误: 移除未使用变量、修复 const 声明、处理未使用参数
- 修复 React Hooks 依赖警告: 使用 useCallback 包装函数并添加正确依赖项
- SQL LIKE 查询添加特殊字符转义,防止注入风险
- 移除 CSS 中重复的 @keyframes spin 定义

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

383 lines
14 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, useCallback } 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';
const DAYS = Array.from({ length: 28 }, (_, i) => i + 1);
export default function Recurring() {
const router = useRouter();
const { showToast } = useToast();
const { confirm } = useConfirm();
const { categories, error: categoriesError } = useCategories();
// 显示分类加载错误
useEffect(() => {
if (categoriesError) {
showToast('分类加载失败', { tone: 'error' });
}
}, [categoriesError, showToast]);
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,
});
const loadBills = useCallback(async () => {
setLoading(true);
try {
const data = await getRecurringBills();
setBills(data);
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
loadBills();
}, [router, loadBills]);
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 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>
);
}