Files
accountbook/frontend/pages/categories.js
T

243 lines
8.8 KiB
JavaScript
Raw Normal View History

2026-03-26 10:49:12 +08:00
import { useState, useEffect, useCallback } from 'react';
2026-03-12 11:24:10 +08:00
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: '📝',
});
2026-03-26 10:49:12 +08:00
const loadCategories = useCallback(async () => {
2026-03-12 11:24:10 +08:00
setLoading(true);
try {
const data = await getCategories();
setCategories(data);
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
} finally {
setLoading(false);
}
2026-03-26 10:49:12 +08:00
}, [showToast]);
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
loadCategories();
}, [router, loadCategories]);
2026-03-12 11:24:10 +08:00
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>
);
}