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
@@ -0,0 +1,146 @@
|
|||||||
|
import { Icon } from './icons';
|
||||||
|
|
||||||
|
export default function RecordModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
formError,
|
||||||
|
submitting,
|
||||||
|
deleting,
|
||||||
|
editingRecord,
|
||||||
|
categories,
|
||||||
|
}) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleTypeChange = (type) => {
|
||||||
|
setFormData({ ...formData, type, category: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = submitting || deleting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay modal-overlay--fullscreen" onClick={onClose}>
|
||||||
|
<div className="modal modal--fullscreen" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{editingRecord ? '编辑记录' : '新增记录'}</p>
|
||||||
|
{editingRecord ? <h2>更新账目</h2> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button icon-button--ghost"
|
||||||
|
aria-label="关闭弹窗"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formError && <div className="error-msg">{formError}</div>}
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<div className="type-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`type-btn expense ${formData.type === 'expense' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTypeChange('expense')}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
支出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`type-btn income ${formData.type === 'income' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTypeChange('income')}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
收入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="record-amount">金额</label>
|
||||||
|
<input
|
||||||
|
id="record-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={isDisabled}
|
||||||
|
/>
|
||||||
|
</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={isDisabled}
|
||||||
|
>
|
||||||
|
<span className="icon">{cat.icon}</span>
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>日期</label>
|
||||||
|
<div className="datetime-picker">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||||
|
required
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={formData.time || '00:00'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="record-note">备注(可选)</label>
|
||||||
|
<input
|
||||||
|
id="record-note"
|
||||||
|
type="text"
|
||||||
|
value={formData.note}
|
||||||
|
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||||
|
placeholder="添加备注..."
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isDisabled}>
|
||||||
|
{submitting ? (
|
||||||
|
<span className="button-content">
|
||||||
|
<Icon name="spinner" size={16} className="is-spinning" />
|
||||||
|
保存中...
|
||||||
|
</span>
|
||||||
|
) : '保存'}
|
||||||
|
</button>
|
||||||
|
{editingRecord && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{deleting ? '删除中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,6 +91,13 @@ export async function deleteRecord(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量删除指定分类的记录
|
||||||
|
export async function deleteRecordsByCategory(category) {
|
||||||
|
return apiCall(`/api/records/by-category/${encodeURIComponent(category)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
export async function searchRecords(keyword) {
|
export async function searchRecords(keyword) {
|
||||||
return apiCall(`/api/records/search?q=${encodeURIComponent(keyword)}`);
|
return apiCall(`/api/records/search?q=${encodeURIComponent(keyword)}`);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const DEFAULT_CATEGORIES = {
|
|||||||
{ name: '娱乐', icon: '🎮' },
|
{ name: '娱乐', icon: '🎮' },
|
||||||
{ name: '房租', icon: '🏠' },
|
{ name: '房租', icon: '🏠' },
|
||||||
{ name: '房贷', icon: '🏦' },
|
{ name: '房贷', icon: '🏦' },
|
||||||
{ name: '亲情卡', icon: '💳' },
|
|
||||||
{ name: '水电', icon: '💧' },
|
{ name: '水电', icon: '💧' },
|
||||||
{ name: '衣服', icon: '👔' },
|
{ name: '衣服', icon: '👔' },
|
||||||
{ name: '通讯', icon: '📱' },
|
{ name: '通讯', icon: '📱' },
|
||||||
|
|||||||
+41
-118
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord, getCategories } from '../lib/api';
|
||||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { Icon } from '../components/icons';
|
import { Icon } from '../components/icons';
|
||||||
|
import RecordModal from '../components/RecordModal';
|
||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
import { useConfirm } from '../components/ConfirmProvider';
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function Home() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
|
const [categories, setCategories] = useState(DEFAULT_CATEGORIES);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
type: 'expense',
|
type: 'expense',
|
||||||
amount: '',
|
amount: '',
|
||||||
@@ -54,12 +56,24 @@ export default function Home() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [recordsData, statsData] = await Promise.all([
|
const [recordsData, statsData, categoriesData] = await Promise.all([
|
||||||
getRecords(getMonthStr(currentMonth), typeFilter),
|
getRecords(getMonthStr(currentMonth), typeFilter),
|
||||||
getStats(getMonthStr(currentMonth)),
|
getStats(getMonthStr(currentMonth)),
|
||||||
|
getCategories(),
|
||||||
]);
|
]);
|
||||||
setRecords(recordsData);
|
setRecords(recordsData);
|
||||||
setStats(statsData);
|
setStats(statsData);
|
||||||
|
// 合并默认分类和自定义分类
|
||||||
|
setCategories({
|
||||||
|
expense: [
|
||||||
|
...DEFAULT_CATEGORIES.expense,
|
||||||
|
...(categoriesData.expense || []).filter(c => !DEFAULT_CATEGORIES.expense.some(d => d.name === c.name))
|
||||||
|
],
|
||||||
|
income: [
|
||||||
|
...DEFAULT_CATEGORIES.income,
|
||||||
|
...(categoriesData.income || []).filter(c => !DEFAULT_CATEGORIES.income.some(d => d.name === c.name))
|
||||||
|
],
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast(err.message, { tone: 'error' });
|
showToast(err.message, { tone: 'error' });
|
||||||
@@ -171,18 +185,26 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedRecords = records.reduce((groups, record) => {
|
const groupedRecords = useMemo(() => {
|
||||||
|
return records.reduce((groups, record) => {
|
||||||
const date = record.date.split(' ')[0];
|
const date = record.date.split(' ')[0];
|
||||||
if (!groups[date]) groups[date] = [];
|
if (!groups[date]) groups[date] = [];
|
||||||
groups[date].push(record);
|
groups[date].push(record);
|
||||||
return groups;
|
return groups;
|
||||||
}, {});
|
}, {});
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
const sortedDates = Object.keys(groupedRecords).sort((a, b) => b.localeCompare(a));
|
const sortedDates = useMemo(
|
||||||
|
() => Object.keys(groupedRecords).sort((a, b) => b.localeCompare(a)),
|
||||||
|
[groupedRecords]
|
||||||
|
);
|
||||||
|
|
||||||
const getDayExpense = (date) => groupedRecords[date]
|
const getDayExpense = (date) => {
|
||||||
|
const dayRecords = groupedRecords[date] || [];
|
||||||
|
return dayRecords
|
||||||
.filter((record) => record.type === 'expense')
|
.filter((record) => record.type === 'expense')
|
||||||
.reduce((sum, record) => sum + record.amount, 0);
|
.reduce((sum, record) => sum + record.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const homeHeaderContent = (
|
const homeHeaderContent = (
|
||||||
<div className="header-center-container">
|
<div className="header-center-container">
|
||||||
@@ -271,8 +293,7 @@ export default function Home() {
|
|||||||
<span className="day-expense">支出 {formatMoney(getDayExpense(date))}</span>
|
<span className="day-expense">支出 {formatMoney(getDayExpense(date))}</span>
|
||||||
</div>
|
</div>
|
||||||
{groupedRecords[date].map((record) => {
|
{groupedRecords[date].map((record) => {
|
||||||
const categories = DEFAULT_CATEGORIES[record.type];
|
const cat = categories[record.type]?.find((item) => item.name === record.category) || { icon: '📝' };
|
||||||
const cat = categories.find((item) => item.name === record.category) || { icon: '📝' };
|
|
||||||
return (
|
return (
|
||||||
<button key={record.id} type="button" className="record-item" onClick={() => openEditModal(record)}>
|
<button key={record.id} type="button" className="record-item" onClick={() => openEditModal(record)}>
|
||||||
<div className="record-left">
|
<div className="record-left">
|
||||||
@@ -299,117 +320,19 @@ export default function Home() {
|
|||||||
<Icon name="plus" size={24} />
|
<Icon name="plus" size={24} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{modalOpen && (
|
<RecordModal
|
||||||
<div className="modal-overlay modal-overlay--fullscreen" onClick={closeModal}>
|
isOpen={modalOpen}
|
||||||
<div className="modal modal--fullscreen" onClick={(e) => e.stopPropagation()}>
|
onClose={closeModal}
|
||||||
<div className="modal-header">
|
onSubmit={handleSubmit}
|
||||||
<div>
|
onDelete={handleDelete}
|
||||||
<p className="eyebrow">{editingRecord ? '编辑记录' : '新增记录'}</p>
|
formData={formData}
|
||||||
{editingRecord ? <h2>更新账目</h2> : null}
|
setFormData={setFormData}
|
||||||
</div>
|
formError={formError}
|
||||||
<button type="button" className="icon-button icon-button--ghost" aria-label="关闭弹窗" onClick={closeModal}>
|
submitting={submitting}
|
||||||
<Icon name="close" size={18} />
|
deleting={deleting}
|
||||||
</button>
|
editingRecord={editingRecord}
|
||||||
</div>
|
categories={categories}
|
||||||
{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="record-amount">金额</label>
|
|
||||||
<input
|
|
||||||
id="record-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">
|
|
||||||
{DEFAULT_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>日期</label>
|
|
||||||
<div className="datetime-picker">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.date}
|
|
||||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
|
||||||
required
|
|
||||||
disabled={submitting || deleting}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.time || '00:00'}
|
|
||||||
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
|
|
||||||
disabled={submitting || deleting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="record-note">备注(可选)</label>
|
|
||||||
<input
|
|
||||||
id="record-note"
|
|
||||||
type="text"
|
|
||||||
value={formData.note}
|
|
||||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
|
||||||
placeholder="添加备注..."
|
|
||||||
disabled={submitting || deleting}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
{editingRecord && (
|
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDelete} disabled={submitting || deleting}>
|
|
||||||
{deleting ? '删除中...' : '删除'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+149
-12
@@ -1,17 +1,33 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
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 { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
import { Icon } from '../components/icons';
|
import { Icon } from '../components/icons';
|
||||||
|
import RecordModal from '../components/RecordModal';
|
||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) {
|
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 formatMoney = (num) => (num || 0).toFixed(2);
|
||||||
|
|
||||||
const groupedResults = results.reduce((groups, record) => {
|
const groupedResults = useMemo(() => {
|
||||||
|
return results.reduce((groups, record) => {
|
||||||
const date = record.date.split(' ')[0];
|
const date = record.date.split(' ')[0];
|
||||||
if (!groups[date]) groups[date] = [];
|
if (!groups[date]) groups[date] = [];
|
||||||
groups[date].push(record);
|
groups[date].push(record);
|
||||||
return groups;
|
return groups;
|
||||||
}, {});
|
}, {});
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
const sortedDates = Object.keys(groupedResults).sort((a, b) => b.localeCompare(a));
|
const sortedDates = useMemo(
|
||||||
const totalIncome = results.filter((record) => record.type === 'income').reduce((sum, record) => sum + record.amount, 0);
|
() => Object.keys(groupedResults).sort((a, b) => b.localeCompare(a)),
|
||||||
const totalExpense = results.filter((record) => record.type === 'expense').reduce((sum, record) => sum + record.amount, 0);
|
[groupedResults]
|
||||||
const totalBalance = totalIncome - totalExpense;
|
);
|
||||||
|
|
||||||
|
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 = (
|
const actions = (
|
||||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
<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' })}
|
{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
||||||
</div>
|
</div>
|
||||||
{groupedResults[date].map((record) => {
|
{groupedResults[date].map((record) => {
|
||||||
const categories = DEFAULT_CATEGORIES[record.type];
|
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
||||||
const cat = categories.find((item) => item.name === record.category) || { icon: '📝' };
|
|
||||||
return (
|
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-left">
|
||||||
<div className="record-icon">{cat.icon}</div>
|
<div className="record-icon">{icon}</div>
|
||||||
<div className="record-info">
|
<div className="record-info">
|
||||||
<div className="record-category">{record.category}</div>
|
<div className="record-category">{record.category}</div>
|
||||||
{record.note && <div className="record-note">{record.note}</div>}
|
{record.note && <div className="record-note">{record.note}</div>}
|
||||||
@@ -130,7 +253,7 @@ export default function Search() {
|
|||||||
{record.type === 'income' ? '+' : '-'}
|
{record.type === 'income' ? '+' : '-'}
|
||||||
{formatMoney(record.amount)}
|
{formatMoney(record.amount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +271,20 @@ export default function Search() {
|
|||||||
<p>输入关键词开始搜索</p>
|
<p>输入关键词开始搜索</p>
|
||||||
</div>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-58
@@ -74,36 +74,17 @@ export default function Settings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="设置" subtitle="数据、账号与应用信息" compactHeader>
|
<AppShell title="设置" subtitle="管理您的数据与账号" compactHeader>
|
||||||
<section className="card settings-section settings-section--refresh">
|
<div className="settings-list">
|
||||||
<div className="section-heading section-heading--compact">
|
<button type="button" className="settings-link" onClick={handleExport} disabled={exporting || importing}>
|
||||||
<div>
|
<Icon name="download" size={18} />
|
||||||
<p className="eyebrow">数据管理</p>
|
<span>导出数据</span>
|
||||||
<h2>导入与导出</h2>
|
<Icon name="chevronRight" size={16} className="chevron" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="settings-item">
|
|
||||||
<div className="settings-info">
|
|
||||||
<div className="settings-title">导出数据</div>
|
|
||||||
<div className="settings-desc">将所有账目导出为 JSON 文件,方便备份。</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-primary btn-inline" onClick={handleExport} disabled={exporting || importing}>
|
|
||||||
<span className="button-content">
|
|
||||||
<Icon name="download" size={16} />
|
|
||||||
{exporting ? '导出中...' : '导出'}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" className="settings-link" onClick={() => fileInputRef.current?.click()} disabled={exporting || importing}>
|
||||||
<div className="settings-item">
|
<Icon name="upload" size={18} />
|
||||||
<div className="settings-info">
|
<span>导入数据</span>
|
||||||
<div className="settings-title">导入数据</div>
|
<Icon name="chevronRight" size={16} className="chevron" />
|
||||||
<div className="settings-desc">从 JSON 文件恢复账目数据。</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary btn-inline" onClick={() => fileInputRef.current?.click()} disabled={exporting || importing}>
|
|
||||||
<span className="button-content">
|
|
||||||
<Icon name="upload" size={16} />
|
|
||||||
{importing ? '导入中...' : '导入'}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -113,41 +94,18 @@ export default function Settings() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card settings-section settings-section--refresh">
|
<div className="settings-list">
|
||||||
<div className="section-heading section-heading--compact">
|
<button type="button" className="settings-link settings-link--danger" onClick={handleLogout} disabled={loggingOut}>
|
||||||
<div>
|
<Icon name="logout" size={18} />
|
||||||
<p className="eyebrow">账号</p>
|
<span>{loggingOut ? '退出中...' : '退出登录'}</span>
|
||||||
<h2>登录状态</h2>
|
<Icon name="chevronRight" size={16} className="chevron" />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="settings-item settings-item--stacked">
|
|
||||||
<div className="settings-info">
|
|
||||||
<div className="settings-title">退出登录</div>
|
|
||||||
<div className="settings-desc">从当前设备安全退出,并返回登录页。</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-danger btn-inline" onClick={handleLogout} disabled={loggingOut}>
|
|
||||||
<span className="button-content">
|
|
||||||
<Icon name="logout" size={16} />
|
|
||||||
{loggingOut ? '退出中...' : '退出登录'}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card settings-section settings-section--refresh">
|
<div className="settings-footer">
|
||||||
<div className="section-heading section-heading--compact">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">关于</p>
|
|
||||||
<h2>应用信息</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="about-info">
|
|
||||||
<p>简易记账本 v1.0</p>
|
<p>简易记账本 v1.0</p>
|
||||||
<p className="sub">基于 React + Next.js 开发</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -954,7 +954,9 @@ textarea:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-card__actions .btn {
|
.confirm-card__actions .btn {
|
||||||
flex: 1 1 180px;
|
flex: 0 1 auto;
|
||||||
|
min-width: 100px;
|
||||||
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
@@ -1375,6 +1377,60 @@ textarea:focus-visible {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Simplified Settings */
|
||||||
|
.settings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link .chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-link--danger {
|
||||||
|
color: var(--expense);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.about-info p:first-child {
|
.about-info p:first-child {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -384,6 +384,20 @@ app.delete('/api/records/:id', authenticate, (req, res) => {
|
|||||||
res.json({ message: '删除成功' });
|
res.json({ message: '删除成功' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 批量删除指定分类的记录
|
||||||
|
app.delete('/api/records/by-category/:category', authenticate, (req, res) => {
|
||||||
|
const { category } = req.params;
|
||||||
|
|
||||||
|
if (!category || typeof category !== 'string' || !category.trim()) {
|
||||||
|
return res.status(400).json({ error: '分类不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?');
|
||||||
|
const result = stmt.run(category.trim(), req.userId);
|
||||||
|
|
||||||
|
res.json({ message: `已删除 ${result.changes} 条记录` });
|
||||||
|
});
|
||||||
|
|
||||||
// 获取月度统计
|
// 获取月度统计
|
||||||
app.get('/api/stats', authenticate, (req, res) => {
|
app.get('/api/stats', authenticate, (req, res) => {
|
||||||
const { month } = req.query;
|
const { month } = req.query;
|
||||||
@@ -630,7 +644,6 @@ app.get('/api/categories', authenticate, (req, res) => {
|
|||||||
{ name: '娱乐', icon: '🎮' },
|
{ name: '娱乐', icon: '🎮' },
|
||||||
{ name: '房租', icon: '🏠' },
|
{ name: '房租', icon: '🏠' },
|
||||||
{ name: '房贷', icon: '🏦' },
|
{ name: '房贷', icon: '🏦' },
|
||||||
{ name: '亲情卡', icon: '💳' },
|
|
||||||
{ name: '水电', icon: '💧' },
|
{ name: '水电', icon: '💧' },
|
||||||
{ name: '衣服', icon: '👔' },
|
{ name: '衣服', icon: '👔' },
|
||||||
{ name: '通讯', icon: '📱' },
|
{ name: '通讯', icon: '📱' },
|
||||||
|
|||||||
Reference in New Issue
Block a user