feat: 删除亲情卡分类并优化搜索功能
- 移除亲情卡支出分类(前端+后端) - 搜索结果添加点击编辑功能 - 提取 RecordModal 组件复用(index.js/search.js) - 添加 useMemo 优化计算性能 - 优化类别查找为 O(1) 映射 - 添加删除分类接口输入验证 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3347a256b2
commit
b5f9a238bb
+154
-17
@@ -1,17 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, searchRecords } from '../lib/api';
|
||||
import { isLoggedIn, searchRecords, updateRecord, deleteRecord } from '../lib/api';
|
||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import RecordModal from '../components/RecordModal';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
import { useConfirm } from '../components/ConfirmProvider';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
category: '',
|
||||
date: '',
|
||||
time: '00:00',
|
||||
note: '',
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
@@ -38,19 +54,127 @@ export default function Search() {
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (record) => {
|
||||
const [datePart, timePart] = record.date.split(' ');
|
||||
setEditingRecord(record);
|
||||
setFormData({
|
||||
type: record.type,
|
||||
amount: record.amount.toString(),
|
||||
category: record.category,
|
||||
date: datePart,
|
||||
time: timePart || '00:00',
|
||||
note: record.note || '',
|
||||
});
|
||||
setFormError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting || deleting) return;
|
||||
setModalOpen(false);
|
||||
setEditingRecord(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.amount || !formData.category || !formData.date) {
|
||||
setFormError('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
const data = {
|
||||
type: formData.type,
|
||||
amount: parseFloat(formData.amount),
|
||||
category: formData.category,
|
||||
date: `${formData.date} ${formData.time || '00:00'}`,
|
||||
note: formData.note,
|
||||
};
|
||||
|
||||
// 先调用 API 保存到数据库
|
||||
await updateRecord(editingRecord.id, data);
|
||||
|
||||
// 直接更新本地状态,避免重新获取
|
||||
const updatedResults = results.map(r =>
|
||||
r.id === editingRecord.id
|
||||
? { ...r, ...data, amount: parseFloat(data.amount), date: data.date }
|
||||
: r
|
||||
);
|
||||
setResults(updatedResults);
|
||||
showToast('记录已更新', { tone: 'success' });
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!editingRecord) return;
|
||||
const shouldDelete = await confirm({
|
||||
title: '删除这条记录?',
|
||||
description: '删除后将无法恢复,请确认是否继续。',
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!shouldDelete) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecord(editingRecord.id);
|
||||
// 直接从本地状态移除,避免重新获取
|
||||
const filteredResults = results.filter(r => r.id !== editingRecord.id);
|
||||
setResults(filteredResults);
|
||||
showToast('记录已删除', { tone: 'success' });
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoney = (num) => (num || 0).toFixed(2);
|
||||
|
||||
const groupedResults = results.reduce((groups, record) => {
|
||||
const date = record.date.split(' ')[0];
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(record);
|
||||
return groups;
|
||||
}, {});
|
||||
const groupedResults = useMemo(() => {
|
||||
return results.reduce((groups, record) => {
|
||||
const date = record.date.split(' ')[0];
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(record);
|
||||
return groups;
|
||||
}, {});
|
||||
}, [results]);
|
||||
|
||||
const sortedDates = Object.keys(groupedResults).sort((a, b) => b.localeCompare(a));
|
||||
const totalIncome = results.filter((record) => record.type === 'income').reduce((sum, record) => sum + record.amount, 0);
|
||||
const totalExpense = results.filter((record) => record.type === 'expense').reduce((sum, record) => sum + record.amount, 0);
|
||||
const totalBalance = totalIncome - totalExpense;
|
||||
const sortedDates = useMemo(
|
||||
() => Object.keys(groupedResults).sort((a, b) => b.localeCompare(a)),
|
||||
[groupedResults]
|
||||
);
|
||||
|
||||
const { totalIncome, totalExpense, totalBalance } = useMemo(() => {
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
for (const record of results) {
|
||||
if (record.type === 'income') income += record.amount;
|
||||
else expense += record.amount;
|
||||
}
|
||||
return { totalIncome: income, totalExpense: expense, totalBalance: income - expense };
|
||||
}, [results]);
|
||||
|
||||
// O(1) category icon lookup map
|
||||
const categoryIconMap = useMemo(() => {
|
||||
const icons = {};
|
||||
for (const type of ['expense', 'income']) {
|
||||
for (const cat of DEFAULT_CATEGORIES[type]) {
|
||||
icons[`${type}:${cat.name}`] = cat.icon;
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}, []);
|
||||
|
||||
const actions = (
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
@@ -115,12 +239,11 @@ export default function Search() {
|
||||
{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
||||
</div>
|
||||
{groupedResults[date].map((record) => {
|
||||
const categories = DEFAULT_CATEGORIES[record.type];
|
||||
const cat = categories.find((item) => item.name === record.category) || { icon: '📝' };
|
||||
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
||||
return (
|
||||
<div key={record.id} className="record-item record-item--static">
|
||||
<button key={record.id} type="button" className="record-item record-item--static" onClick={() => openEditModal(record)}>
|
||||
<div className="record-left">
|
||||
<div className="record-icon">{cat.icon}</div>
|
||||
<div className="record-icon">{icon}</div>
|
||||
<div className="record-info">
|
||||
<div className="record-category">{record.category}</div>
|
||||
{record.note && <div className="record-note">{record.note}</div>}
|
||||
@@ -130,7 +253,7 @@ export default function Search() {
|
||||
{record.type === 'income' ? '+' : '-'}
|
||||
{formatMoney(record.amount)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -148,6 +271,20 @@ export default function Search() {
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordModal
|
||||
isOpen={modalOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
formError={formError}
|
||||
submitting={submitting}
|
||||
deleting={deleting}
|
||||
editingRecord={editingRecord}
|
||||
categories={DEFAULT_CATEGORIES}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user