Files
accountbook/frontend/pages/categories.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

243 lines
8.8 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, 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: '📝',
});
const loadCategories = useCallback(async () => {
setLoading(true);
try {
const data = await getCategories();
setCategories(data);
} catch (err) {
console.error(err);
showToast(err.message, { tone: 'error' });
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
if (!isLoggedIn()) {
router.push('/login');
return;
}
loadCategories();
}, [router, loadCategories]);
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>
);
}