2026-03-17 11:19:26 +08:00
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
2026-03-12 11:24:10 +08:00
|
|
|
import { useRouter } from 'next/router';
|
2026-03-17 11:19:26 +08:00
|
|
|
import { isLoggedIn, searchRecords, updateRecord, deleteRecord } from '../lib/api';
|
2026-03-12 11:24:10 +08:00
|
|
|
import AppShell from '../components/AppShell';
|
|
|
|
|
import { Icon } from '../components/icons';
|
2026-03-17 11:19:26 +08:00
|
|
|
import RecordModal from '../components/RecordModal';
|
2026-03-12 11:24:10 +08:00
|
|
|
import { useToast } from '../components/ToastProvider';
|
2026-03-17 11:19:26 +08:00
|
|
|
import { useConfirm } from '../components/ConfirmProvider';
|
2026-03-17 13:35:40 +08:00
|
|
|
import { useCategories } from '../hooks/useCategories';
|
2026-03-12 11:24:10 +08:00
|
|
|
|
|
|
|
|
export default function Search() {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const { showToast } = useToast();
|
2026-03-17 11:19:26 +08:00
|
|
|
const confirm = useConfirm();
|
2026-03-17 13:35:40 +08:00
|
|
|
const { categories } = useCategories();
|
2026-03-12 11:24:10 +08:00
|
|
|
const [keyword, setKeyword] = useState('');
|
|
|
|
|
const [results, setResults] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
2026-03-17 11:19:26 +08:00
|
|
|
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);
|
2026-03-12 11:24:10 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isLoggedIn()) {
|
|
|
|
|
router.push('/login');
|
|
|
|
|
}
|
|
|
|
|
}, [router]);
|
|
|
|
|
|
2026-03-17 13:35:40 +08:00
|
|
|
// 检查 URL 参数,自动搜索
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (router.isReady && router.query.q) {
|
|
|
|
|
const q = decodeURIComponent(router.query.q);
|
|
|
|
|
setKeyword(q);
|
|
|
|
|
// 自动搜索
|
|
|
|
|
const doSearch = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await searchRecords(q);
|
|
|
|
|
setResults(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
showToast(err.message, { tone: 'error' });
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
doSearch();
|
|
|
|
|
}
|
|
|
|
|
}, [router.isReady, router.query.q]);
|
|
|
|
|
|
2026-03-12 11:24:10 +08:00
|
|
|
const handleSearch = async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!keyword.trim()) {
|
|
|
|
|
showToast('请输入搜索关键词', { tone: 'info' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 13:35:40 +08:00
|
|
|
// 更新 URL
|
|
|
|
|
router.push(`/search?q=${encodeURIComponent(keyword)}`, undefined, { shallow: true });
|
|
|
|
|
|
2026-03-12 11:24:10 +08:00
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await searchRecords(keyword);
|
|
|
|
|
setResults(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
showToast(err.message, { tone: 'error' });
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-12 11:24:10 +08:00
|
|
|
const formatMoney = (num) => (num || 0).toFixed(2);
|
|
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
const groupedResults = useMemo(() => {
|
2026-03-17 13:35:40 +08:00
|
|
|
if (!Array.isArray(results)) return {};
|
2026-03-17 11:19:26 +08:00
|
|
|
return results.reduce((groups, record) => {
|
|
|
|
|
const date = record.date.split(' ')[0];
|
|
|
|
|
if (!groups[date]) groups[date] = [];
|
|
|
|
|
groups[date].push(record);
|
|
|
|
|
return groups;
|
|
|
|
|
}, {});
|
|
|
|
|
}, [results]);
|
2026-03-12 11:24:10 +08:00
|
|
|
|
2026-03-17 11:19:26 +08:00
|
|
|
const sortedDates = useMemo(
|
|
|
|
|
() => Object.keys(groupedResults).sort((a, b) => b.localeCompare(a)),
|
|
|
|
|
[groupedResults]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const { totalIncome, totalExpense, totalBalance } = useMemo(() => {
|
2026-03-17 13:35:40 +08:00
|
|
|
if (!Array.isArray(results)) return { totalIncome: 0, totalExpense: 0, totalBalance: 0 };
|
2026-03-17 11:19:26 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-17 13:35:40 +08:00
|
|
|
// O(1) category icon lookup map (includes custom categories)
|
2026-03-17 11:19:26 +08:00
|
|
|
const categoryIconMap = useMemo(() => {
|
|
|
|
|
const icons = {};
|
|
|
|
|
for (const type of ['expense', 'income']) {
|
2026-03-17 13:35:40 +08:00
|
|
|
for (const cat of categories[type]) {
|
2026-03-17 11:19:26 +08:00
|
|
|
icons[`${type}:${cat.name}`] = cat.icon;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return icons;
|
2026-03-17 13:35:40 +08:00
|
|
|
}, [categories]);
|
2026-03-12 11:24:10 +08:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
<section className="card section-card section-card--search section-card--search-panel">
|
|
|
|
|
<form className="search-form search-form--refresh" onSubmit={handleSearch}>
|
|
|
|
|
<div className="search-input-wrap">
|
|
|
|
|
<Icon name="search" size={18} className="search-input-wrap__icon" />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={keyword}
|
|
|
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
|
|
|
placeholder="搜索分类或备注..."
|
|
|
|
|
autoFocus
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" className="btn btn-primary btn-inline" disabled={loading}>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<span className="button-content">
|
|
|
|
|
<Icon name="spinner" size={16} className="is-spinning" />
|
|
|
|
|
搜索中...
|
|
|
|
|
</span>
|
|
|
|
|
) : '搜索'}
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
{results.length > 0 ? (
|
|
|
|
|
<>
|
2026-03-17 13:35:40 +08:00
|
|
|
<div className="stats-grid stats-grid--hero">
|
|
|
|
|
<div className="stat-card">
|
2026-03-12 11:24:10 +08:00
|
|
|
<span className="label">收入</span>
|
|
|
|
|
<span className="value income">+{formatMoney(totalIncome)}</span>
|
|
|
|
|
</div>
|
2026-03-17 13:35:40 +08:00
|
|
|
<div className="stat-card">
|
2026-03-12 11:24:10 +08:00
|
|
|
<span className="label">支出</span>
|
|
|
|
|
<span className="value expense">-{formatMoney(totalExpense)}</span>
|
|
|
|
|
</div>
|
2026-03-17 13:35:40 +08:00
|
|
|
<div className="stat-card stat-card--balance">
|
2026-03-12 11:24:10 +08:00
|
|
|
<span className="label">结余</span>
|
|
|
|
|
<span className="value">{formatMoney(totalBalance)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="results-count">找到 {results.length} 条记录</div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="card loading-state">搜索中...</div>
|
|
|
|
|
) : results.length > 0 ? (
|
|
|
|
|
<section className="card section-card">
|
|
|
|
|
<div className="records-list records-list--flush">
|
|
|
|
|
{sortedDates.map((date) => (
|
|
|
|
|
<div key={date} className="date-group">
|
|
|
|
|
<div className="date-label">
|
|
|
|
|
{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}
|
|
|
|
|
</div>
|
|
|
|
|
{groupedResults[date].map((record) => {
|
2026-03-17 11:19:26 +08:00
|
|
|
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
2026-03-12 11:24:10 +08:00
|
|
|
return (
|
2026-03-17 11:19:26 +08:00
|
|
|
<button key={record.id} type="button" className="record-item record-item--static" onClick={() => openEditModal(record)}>
|
2026-03-12 11:24:10 +08:00
|
|
|
<div className="record-left">
|
2026-03-17 11:19:26 +08:00
|
|
|
<div className="record-icon">{icon}</div>
|
2026-03-12 11:24:10 +08:00
|
|
|
<div className="record-info">
|
|
|
|
|
<div className="record-category">{record.category}</div>
|
|
|
|
|
{record.note && <div className="record-note">{record.note}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`record-amount ${record.type}`}>
|
|
|
|
|
{record.type === 'income' ? '+' : '-'}
|
|
|
|
|
{formatMoney(record.amount)}
|
|
|
|
|
</div>
|
2026-03-17 11:19:26 +08:00
|
|
|
</button>
|
2026-03-12 11:24:10 +08:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
) : keyword ? (
|
|
|
|
|
<div className="card empty-state empty-state--card">
|
|
|
|
|
<div className="empty-state__icon"><Icon name="search" size={24} /></div>
|
|
|
|
|
<p>未找到相关记录</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="card empty-state empty-state--card">
|
|
|
|
|
<div className="empty-state__icon"><Icon name="search" size={24} /></div>
|
|
|
|
|
<p>输入关键词开始搜索</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-17 11:19:26 +08:00
|
|
|
|
|
|
|
|
<RecordModal
|
|
|
|
|
isOpen={modalOpen}
|
|
|
|
|
onClose={closeModal}
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
onDelete={handleDelete}
|
|
|
|
|
formData={formData}
|
|
|
|
|
setFormData={setFormData}
|
|
|
|
|
formError={formError}
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
deleting={deleting}
|
|
|
|
|
editingRecord={editingRecord}
|
2026-03-17 13:35:40 +08:00
|
|
|
categories={categories}
|
2026-03-17 11:19:26 +08:00
|
|
|
/>
|
2026-03-12 11:24:10 +08:00
|
|
|
</AppShell>
|
|
|
|
|
);
|
|
|
|
|
}
|