243 lines
8.7 KiB
JavaScript
243 lines
8.7 KiB
JavaScript
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>
|
|||
|
|
);
|
|||
|
|
}
|