diff --git a/frontend/components/RecordModal.js b/frontend/components/RecordModal.js new file mode 100644 index 0000000..87af908 --- /dev/null +++ b/frontend/components/RecordModal.js @@ -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 ( +
+
e.stopPropagation()}> +
+
+

{editingRecord ? '编辑记录' : '新增记录'}

+ {editingRecord ?

更新账目

: null} +
+ +
+ {formError &&
{formError}
} +
+
+ + +
+
+ + setFormData({ ...formData, amount: e.target.value })} + placeholder="0.00" + step="0.01" + required + disabled={isDisabled} + /> +
+
+ +
+ {categories[formData.type].map((cat) => ( + + ))} +
+
+
+ +
+ setFormData({ ...formData, date: e.target.value })} + required + disabled={isDisabled} + /> + setFormData({ ...formData, time: e.target.value })} + disabled={isDisabled} + /> +
+
+
+ + setFormData({ ...formData, note: e.target.value })} + placeholder="添加备注..." + disabled={isDisabled} + /> +
+
+ + {editingRecord && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/lib/api.js b/frontend/lib/api.js index 467d6d4..bf244ec 100644 --- a/frontend/lib/api.js +++ b/frontend/lib/api.js @@ -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) { return apiCall(`/api/records/search?q=${encodeURIComponent(keyword)}`); diff --git a/frontend/lib/categories.js b/frontend/lib/categories.js index cdd8735..93ebd39 100644 --- a/frontend/lib/categories.js +++ b/frontend/lib/categories.js @@ -7,7 +7,6 @@ export const DEFAULT_CATEGORIES = { { name: '娱乐', icon: '🎮' }, { name: '房租', icon: '🏠' }, { name: '房贷', icon: '🏦' }, - { name: '亲情卡', icon: '💳' }, { name: '水电', icon: '💧' }, { name: '衣服', icon: '👔' }, { name: '通讯', icon: '📱' }, diff --git a/frontend/pages/index.js b/frontend/pages/index.js index 65fa1c8..b762c96 100644 --- a/frontend/pages/index.js +++ b/frontend/pages/index.js @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; 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 AppShell from '../components/AppShell'; import { Icon } from '../components/icons'; +import RecordModal from '../components/RecordModal'; import { useToast } from '../components/ToastProvider'; import { useConfirm } from '../components/ConfirmProvider'; @@ -21,6 +22,7 @@ export default function Home() { const [submitting, setSubmitting] = useState(false); const [deleting, setDeleting] = useState(false); const [formError, setFormError] = useState(''); + const [categories, setCategories] = useState(DEFAULT_CATEGORIES); const [formData, setFormData] = useState({ type: 'expense', amount: '', @@ -54,12 +56,24 @@ export default function Home() { const loadData = async () => { setLoading(true); try { - const [recordsData, statsData] = await Promise.all([ + const [recordsData, statsData, categoriesData] = await Promise.all([ getRecords(getMonthStr(currentMonth), typeFilter), getStats(getMonthStr(currentMonth)), + getCategories(), ]); setRecords(recordsData); 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) { console.error(err); showToast(err.message, { tone: 'error' }); @@ -171,18 +185,26 @@ export default function Home() { } }; - const groupedRecords = records.reduce((groups, record) => { - const date = record.date.split(' ')[0]; - if (!groups[date]) groups[date] = []; - groups[date].push(record); - return groups; - }, {}); + const groupedRecords = useMemo(() => { + return records.reduce((groups, record) => { + const date = record.date.split(' ')[0]; + if (!groups[date]) groups[date] = []; + groups[date].push(record); + 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] - .filter((record) => record.type === 'expense') - .reduce((sum, record) => sum + record.amount, 0); + const getDayExpense = (date) => { + const dayRecords = groupedRecords[date] || []; + return dayRecords + .filter((record) => record.type === 'expense') + .reduce((sum, record) => sum + record.amount, 0); + }; const homeHeaderContent = (
@@ -271,8 +293,7 @@ export default function Home() { 支出 {formatMoney(getDayExpense(date))}
{groupedRecords[date].map((record) => { - const categories = DEFAULT_CATEGORIES[record.type]; - const cat = categories.find((item) => item.name === record.category) || { icon: '📝' }; + const cat = categories[record.type]?.find((item) => item.name === record.category) || { icon: '📝' }; return ( - {modalOpen && ( -
-
e.stopPropagation()}> -
-
-

{editingRecord ? '编辑记录' : '新增记录'}

- {editingRecord ?

更新账目

: null} -
- -
- {formError &&
{formError}
} -
-
- - -
-
- - setFormData({ ...formData, amount: e.target.value })} - placeholder="0.00" - step="0.01" - required - disabled={submitting || deleting} - /> -
-
- -
- {DEFAULT_CATEGORIES[formData.type].map((cat) => ( - - ))} -
-
-
- -
- setFormData({ ...formData, date: e.target.value })} - required - disabled={submitting || deleting} - /> - setFormData({ ...formData, time: e.target.value })} - disabled={submitting || deleting} - /> -
-
-
- - setFormData({ ...formData, note: e.target.value })} - placeholder="添加备注..." - disabled={submitting || deleting} - /> -
-
- - {editingRecord && ( - - )} -
-
-
-
- )} + ); } diff --git a/frontend/pages/search.js b/frontend/pages/search.js index a217552..966d790 100644 --- a/frontend/pages/search.js +++ b/frontend/pages/search.js @@ -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 = ( ); })} @@ -148,6 +271,20 @@ export default function Search() {

输入关键词开始搜索

)} + + ); } diff --git a/frontend/pages/settings.js b/frontend/pages/settings.js index 61b0216..7f628e4 100644 --- a/frontend/pages/settings.js +++ b/frontend/pages/settings.js @@ -74,80 +74,38 @@ export default function Settings() { }; return ( - -
-
-
-

数据管理

-

导入与导出

-
-
-
-
-
导出数据
-
将所有账目导出为 JSON 文件,方便备份。
-
- -
-
-
-
导入数据
-
从 JSON 文件恢复账目数据。
-
- - -
-
+ +
+ + + +
-
-
-
-

账号

-

登录状态

-
-
-
-
-
退出登录
-
从当前设备安全退出,并返回登录页。
-
- -
-
+
+ +
-
-
-
-

关于

-

应用信息

-
-
-
-

简易记账本 v1.0

-

基于 React + Next.js 开发

-
-
+
+

简易记账本 v1.0

+
); } diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index 8ba1ea1..683a75a 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -954,7 +954,9 @@ textarea:focus-visible { } .confirm-card__actions .btn { - flex: 1 1 180px; + flex: 0 1 auto; + min-width: 100px; + min-height: 40px; } .error-msg { @@ -1375,6 +1377,60 @@ textarea:focus-visible { 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 { color: var(--text); font-weight: 700; diff --git a/server.js b/server.js index faf53b1..3867592 100644 --- a/server.js +++ b/server.js @@ -384,6 +384,20 @@ app.delete('/api/records/:id', authenticate, (req, res) => { 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) => { 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: '📱' },