feat: 前端UI标题栏优化与配置更新
- 更新.gitignore,添加frontend/.next/忽略 - 优化RecordModal、index、recurring、search、stats页面标题栏样式 - 新增frontend/lib/utils.js工具函数 - 更新useCategories.js钩子 - 添加代码优化计划文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+33
-27
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord, getCategories } from '../lib/api';
|
||||
import { DEFAULT_CATEGORIES } from '../lib/categories';
|
||||
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
||||
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';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart } from '../lib/utils';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -22,7 +23,14 @@ 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 { categories, error: categoriesError } = useCategories();
|
||||
|
||||
// 显示分类加载错误
|
||||
useEffect(() => {
|
||||
if (categoriesError) {
|
||||
showToast('分类加载失败', { tone: 'error' });
|
||||
}
|
||||
}, [categoriesError]);
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
@@ -40,9 +48,6 @@ export default function Home() {
|
||||
loadData();
|
||||
}, [router, currentMonth, typeFilter]);
|
||||
|
||||
const getMonthStr = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const formatMoney = (num) => (num || 0).toFixed(2);
|
||||
const formatMonth = (date) => `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
|
||||
const resetForm = () => ({
|
||||
type: 'expense',
|
||||
@@ -53,27 +58,26 @@ export default function Home() {
|
||||
note: '',
|
||||
});
|
||||
|
||||
// Memoized category lookup map for O(1) icon retrieval
|
||||
const categoryIconMap = useMemo(() => {
|
||||
const icons = {};
|
||||
for (const type of ['expense', 'income']) {
|
||||
for (const cat of categories[type] || []) {
|
||||
icons[`${type}:${cat.name}`] = cat.icon;
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}, [categories]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [recordsData, statsData, categoriesData] = await Promise.all([
|
||||
const [recordsData, statsData] = 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' });
|
||||
@@ -106,14 +110,14 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const openEditModal = (record) => {
|
||||
const [datePart, timePart] = record.date.split(' ');
|
||||
const { datePart, timePart } = parseDateTime(record.date);
|
||||
setEditingRecord(record);
|
||||
setFormData({
|
||||
type: record.type,
|
||||
amount: record.amount.toString(),
|
||||
category: record.category,
|
||||
date: datePart,
|
||||
time: timePart || '00:00',
|
||||
time: timePart,
|
||||
note: record.note || '',
|
||||
});
|
||||
setFormError('');
|
||||
@@ -146,14 +150,16 @@ export default function Home() {
|
||||
};
|
||||
|
||||
if (editingRecord) {
|
||||
const updated = { ...editingRecord, ...data, amount: parseFloat(data.amount) };
|
||||
await updateRecord(editingRecord.id, data);
|
||||
setRecords(prev => prev.map(r => r.id === editingRecord.id ? updated : r));
|
||||
showToast('记录已更新', { tone: 'success' });
|
||||
} else {
|
||||
await createRecord(data);
|
||||
const newRecord = await createRecord(data);
|
||||
setRecords(prev => [...prev, newRecord]);
|
||||
showToast('记录已添加', { tone: 'success' });
|
||||
}
|
||||
closeModal();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
@@ -174,9 +180,9 @@ export default function Home() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecord(editingRecord.id);
|
||||
setRecords(prev => prev.filter(r => r.id !== editingRecord.id));
|
||||
closeModal();
|
||||
showToast('记录已删除', { tone: 'success' });
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setFormError(err.message);
|
||||
showToast(err.message, { tone: 'error' });
|
||||
@@ -187,7 +193,7 @@ export default function Home() {
|
||||
|
||||
const groupedRecords = useMemo(() => {
|
||||
return records.reduce((groups, record) => {
|
||||
const date = record.date.split(' ')[0];
|
||||
const date = getDatePart(record.date);
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(record);
|
||||
return groups;
|
||||
@@ -293,11 +299,11 @@ export default function Home() {
|
||||
<span className="day-expense">支出 {formatMoney(getDayExpense(date))}</span>
|
||||
</div>
|
||||
{groupedRecords[date].map((record) => {
|
||||
const cat = categories[record.type]?.find((item) => item.name === record.category) || { icon: '📝' };
|
||||
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
||||
return (
|
||||
<button key={record.id} type="button" className="record-item" 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>}
|
||||
|
||||
@@ -7,11 +7,20 @@ import { useToast } from '../components/ToastProvider';
|
||||
import { useConfirm } from '../components/ConfirmProvider';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
|
||||
const DAYS = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||
|
||||
export default function Recurring() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const { categories } = useCategories();
|
||||
const { categories, error: categoriesError } = useCategories();
|
||||
|
||||
// 显示分类加载错误
|
||||
useEffect(() => {
|
||||
if (categoriesError) {
|
||||
showToast('分类加载失败', { tone: 'error' });
|
||||
}
|
||||
}, [categoriesError]);
|
||||
const [bills, setBills] = useState([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBill, setEditingBill] = useState(null);
|
||||
@@ -170,8 +179,6 @@ export default function Recurring() {
|
||||
}
|
||||
};
|
||||
|
||||
const days = Array.from({ length: 28 }, (_, index) => index + 1);
|
||||
|
||||
const actions = (
|
||||
<button type="button" className="icon-button" aria-label="设置" onClick={() => router.push('/settings')}>
|
||||
<Icon name="settings" size={18} />
|
||||
@@ -322,7 +329,7 @@ export default function Recurring() {
|
||||
<div className="form-group">
|
||||
<label htmlFor="bill-day">日期(每月几号)</label>
|
||||
<select id="bill-day" value={formData.day} onChange={(e) => setFormData({ ...formData, day: e.target.value })} disabled={submitting || deleting}>
|
||||
{days.map((day) => (
|
||||
{DAYS.map((day) => (
|
||||
<option key={day} value={day}>{day} 日</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -7,12 +7,20 @@ import RecordModal from '../components/RecordModal';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
import { useConfirm } from '../components/ConfirmProvider';
|
||||
import { useCategories } from '../hooks/useCategories';
|
||||
import { formatMoney, parseDateTime, getDatePart } from '../lib/utils';
|
||||
|
||||
export default function Search() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const confirm = useConfirm();
|
||||
const { categories } = useCategories();
|
||||
const { categories, error: categoriesError } = useCategories();
|
||||
|
||||
// 显示分类加载错误
|
||||
useEffect(() => {
|
||||
if (categoriesError) {
|
||||
showToast('分类加载失败', { tone: 'error' });
|
||||
}
|
||||
}, [categoriesError]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -40,6 +48,8 @@ export default function Search() {
|
||||
useEffect(() => {
|
||||
if (router.isReady && router.query.q) {
|
||||
const q = decodeURIComponent(router.query.q);
|
||||
// Skip if already have results for this keyword (prevents double API call)
|
||||
if (keyword === q && results.length > 0) return;
|
||||
setKeyword(q);
|
||||
// 自动搜索
|
||||
const doSearch = async () => {
|
||||
@@ -56,7 +66,7 @@ export default function Search() {
|
||||
};
|
||||
doSearch();
|
||||
}
|
||||
}, [router.isReady, router.query.q]);
|
||||
}, [router.isReady, router.query.q, keyword, results]);
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -81,7 +91,7 @@ export default function Search() {
|
||||
};
|
||||
|
||||
const openEditModal = (record) => {
|
||||
const [datePart, timePart] = record.date.split(' ');
|
||||
const { datePart, timePart } = parseDateTime(record.date);
|
||||
setEditingRecord(record);
|
||||
setFormData({
|
||||
type: record.type,
|
||||
@@ -165,12 +175,10 @@ export default function Search() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoney = (num) => (num || 0).toFixed(2);
|
||||
|
||||
const groupedResults = useMemo(() => {
|
||||
if (!Array.isArray(results)) return {};
|
||||
return results.reduce((groups, record) => {
|
||||
const date = record.date.split(' ')[0];
|
||||
const date = getDatePart(record.date);
|
||||
if (!groups[date]) groups[date] = [];
|
||||
groups[date].push(record);
|
||||
return groups;
|
||||
|
||||
+20
-5
@@ -1,12 +1,30 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import AppShell from '../components/AppShell';
|
||||
import { Icon } from '../components/icons';
|
||||
import { getMonthStr, formatMonth } from '../lib/utils';
|
||||
import { useToast } from '../components/ToastProvider';
|
||||
|
||||
Chart.register(...registerables);
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend
|
||||
);
|
||||
|
||||
export default function Stats() {
|
||||
const router = useRouter();
|
||||
@@ -35,9 +53,6 @@ export default function Stats() {
|
||||
loadCategoryData();
|
||||
}, [router, currentMonth, categoryType]);
|
||||
|
||||
const getMonthStr = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const formatMonth = (date) => `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
||||
|
||||
const loadTrendData = async () => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user