Initial commit: accountbook project
- Node.js backend server - Frontend application - Backup script - Project specification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, searchRecords } from '../lib/api';
|
||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!keyword.trim()) {
|
||||
showToast('请输入搜索关键词', { tone: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await searchRecords(keyword);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
} finally {
|
||||
setLoading(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 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 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 ? (
|
||||
<>
|
||||
<div className="search-summary">
|
||||
<div className="search-stat">
|
||||
<span className="label">收入</span>
|
||||
<span className="value income">+{formatMoney(totalIncome)}</span>
|
||||
</div>
|
||||
<div className="search-stat">
|
||||
<span className="label">支出</span>
|
||||
<span className="value expense">-{formatMoney(totalExpense)}</span>
|
||||
</div>
|
||||
<div className="search-stat">
|
||||
<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) => {
|
||||
const categories = DEFAULT_CATEGORIES[record.type];
|
||||
const cat = categories.find((item) => item.name === record.category) || { icon: '📝' };
|
||||
return (
|
||||
<div key={record.id} className="record-item record-item--static">
|
||||
<div className="record-left">
|
||||
<div className="record-icon">{cat.icon}</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user