From cc512453bfa8b28a119c145c96aeac718362b2f0 Mon Sep 17 00:00:00 2001 From: snowgitea Date: Wed, 29 Apr 2026 10:53:32 +0800 Subject: [PATCH] chore: add frontend/src/pages/Record/index.tsx --- frontend/src/pages/Record/index.tsx | 1478 +++++++++++++++++++++++++++ 1 file changed, 1478 insertions(+) create mode 100644 frontend/src/pages/Record/index.tsx diff --git a/frontend/src/pages/Record/index.tsx b/frontend/src/pages/Record/index.tsx new file mode 100644 index 0000000..466b14c --- /dev/null +++ b/frontend/src/pages/Record/index.tsx @@ -0,0 +1,1478 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { useDataStore } from '../../stores' + +const RecordPage: React.FC = () => { + const { records, accounts, loading, fetchRecords, fetchAccounts, createRecord } = useDataStore() + // 筛选状态:全部/支出/收入,用于过滤展示 + const [filterType, setFilterType] = useState<'all' | 'expense' | 'income'>('all') + const [showModal, setShowModal] = useState(false) + const [showToast, setShowToast] = useState(false) + // ref 用于记录列表锚点,切换页码时自动滚动到列表顶部 + const recordsListRef = useRef(null); + + // 分页状态管理 + const PAGE_SIZE = 10; // 每页显示10条记录 + const [currentPage, setCurrentPage] = useState(1); + const [isPageChanging, setIsPageChanging] = useState(false); + + // 默认账户:取第一个账户的 ID,若无账户则默认 0(后端会报错提示) + const defaultAccountId = accounts.length > 0 ? accounts[0].id : 0 + + const [formData, setFormData] = useState({ + type: 'expense' as 'expense' | 'income', + category: '餐饮', + amount: 0, + date: new Date().toISOString().split('T')[0], + description: '', + accountId: defaultAccountId + }) + + const expenseCategories = ['餐饮', '交通', '购物', '娱乐', '医疗', '其他'] + const incomeCategories = ['工资', '奖金', '投资', '兼职', '理财', '其他'] + + const currentCategories = formData.type === 'expense' ? expenseCategories : incomeCategories + + // 组件挂载时拉取账户列表和记录列表,供筛选和表单使用 + useEffect(() => { + fetchAccounts() + fetchRecords() + }, [fetchAccounts, fetchRecords]) + + // 使用 useMemo 缓存筛选+排序结果,避免每次渲染重新计算 + // 按创建时间降序排列,最新记录在前 + const sortedFilteredRecords = useMemo(() => + records + .filter(record => + filterType === 'all' || record.type === filterType + ) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + [records, filterType] + ); + + // 分页逻辑 + const totalRecords = sortedFilteredRecords.length; + const totalPages = Math.ceil(totalRecords / PAGE_SIZE); + const paginatedRecords = sortedFilteredRecords.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + // 切换页码 - 带加载状态 + const handlePageChange = (page: number) => { + if (page < 1 || page > totalPages || page === currentPage) return; + setIsPageChanging(true); + setCurrentPage(page); + // 滚动到列表顶部 + recordsListRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // 模拟加载延迟,提供状态反馈 + setTimeout(() => setIsPageChanging(false), 200); + }; + + // 筛选条件变化时重置到第一页 + useEffect(() => { + setCurrentPage(1); + }, [filterType, records.length]); + + // 切换收支类型时同步更新默认分类 + // 支出默认"餐饮",收入默认"工资",减少用户选择步骤 + const handleTypeChange = (type: 'expense' | 'income') => { + setFormData(prev => ({ ...prev, type })) + if (type === 'income') { + setFormData(prev => ({ ...prev, category: '工资' })) + } else { + setFormData(prev => ({ ...prev, category: '餐饮' })) + } + } + + // 表单提交 - 创建记录成功后显示 toast 并重置表单 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await createRecord(formData) + setShowModal(false) + setShowToast(true) + setTimeout(() => setShowToast(false), 2500) + setFormData({ + type: 'expense', + category: '餐饮', + amount: 0, + date: new Date().toISOString().split('T')[0], + description: '', + accountId: defaultAccountId + }) + } catch (error) { + console.error('Failed to create record:', error) + } + } + + const handleCategorySelect = (category: string) => { + setFormData(prev => ({ ...prev, category })) + } + + // 金额格式化 - 使用 Intl.NumberFormat 标准人民币格式 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY' + }).format(amount) + } + + // 时间格式化 - 相对时间显示策略 + // 使用 createdAt 而非 date 字段,避免 UTC 时区偏移导致日期错误 + // 规则:今天显示时分、昨天显示"昨天"、更早显示"月/日" + const formatTime = (record: any) => { + const createdAt = new Date(record.createdAt) + const today = new Date() + const todayStr = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toDateString() + const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).toDateString() + + if (todayStr === recordDay) { + return createdAt.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } + + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const yesterdayStr = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()).toDateString() + if (yesterdayStr === recordDay) { + return '昨天' + } + + return createdAt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + } + + // 分类 SVG 图标映射 - 覆盖全部 12 个类别(6 支出 + 6 收入) + // 使用 SVG 组件而非 emoji,是为了与页面整体视觉风格保持一致 + const getCategoryIcon = (category: string) => { + const iconMap: Record = { + // 支出类别图标 + '餐饮': , + '交通': , + '购物': , + '娱乐': , + '医疗': , + // 收入类别图标 + '工资': , + '奖金': , + '投资': , + '兼职': , + '理财': , + // 公共类别 + '其他': + } + return iconMap[category] || + } + + // 收入类别背景色映射 - 覆盖全部12个类别(6支出+6收入) + const getCategoryBgColor = (category: string) => { + const colorMap: Record = { + // 支出类别颜色 + '餐饮': '#00c853', + '交通': '#3761ff', + '购物': '#ff3d00', + '娱乐': '#f59e0b', + '医疗': '#0052ff', + // 收入类别颜色 + '工资': '#00c853', + '奖金': '#f59e0b', + '投资': '#3761ff', + '兼职': '#9c27b0', + '理财': '#0052ff', + // 公共类别颜色 + '其他': '#737373' + } + return colorMap[category] || '#737373' + } + + return ( +
+
+
+

账单明细

+
+
+ + + +
+
+ +
+

账单记录列表

+ {loading ? ( +
+ +

加载中...

+
+ ) : sortedFilteredRecords.length === 0 ? ( +
+ +

暂无账单记录

+ +
+ ) : ( + <> +
+ {paginatedRecords.map(record => ( +
+ +
+
+ {record.category} + + {record.type === 'income' ? '+' : '-'}{formatCurrency(record.amount).replace('¥', '')} + +
+
+ {record.description} + {formatTime(record)} +
+
+
+ ))} +
+ + {/* 分页组件 */} + {totalPages > 1 && ( + + )} + + )} +
+ + {showModal && ( + + )} + + + + {showToast && ( +
+ + 保存成功 +
+ )} + + +
+ ) +} + +function EatIcon() { + return ( + + + + + + ) +} + +function CarIcon() { + return ( + + + + + + + ) +} + +function ShopIcon() { + return ( + + + + + + ) +} + +function PlayIcon() { + return ( + + + + ) +} + +function MoneyIcon() { + return ( + + + + + ) +} + +function HeartIcon() { + return ( + + + + ) +} + +function StarIcon() { + return ( + + + + ) +} + +function CloseIcon() { + return ( + + + + + ) +} + +function PlusIcon() { + return ( + + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} + +// 奖金图标 - 奖杯 +function AwardIcon() { + return ( + + + + + ) +} + +// 投资图标 - 趋势上升 +function TrendIcon() { + return ( + + + + + ) +} + +// 兼职图标 - 公文包 +function BriefcaseIcon() { + return ( + + + + + ) +} + +// 理财图标 - 硬币/钱币 +function PiggyBankIcon() { + return ( + + + + + + ) +} + +function EmptyIcon() { + return ( + + + + + + ); +} + +/** + * 分页组件 - 支持桌面端完整页码 / 移动端简化模式 + * - 桌面端:首页 上一页 1 2 3 ... 10 下一页 末页 当前页/总页数 + * - 移动端:上一页 1/10 下一页 + */ +interface PaginationProps { + currentPage: number; + totalPages: number; + totalRecords: number; + onPageChange: (page: number) => void; + isPageChanging: boolean; +} + +const Pagination: React.FC = ({ currentPage, totalPages, totalRecords, onPageChange, isPageChanging }) => { + // 页码按钮显示逻辑:最多显示7个页码 + const getPageNumbers = (): (number | string)[] => { + const pages: (number | string)[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + if (currentPage > 3) pages.push('...'); + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + for (let i = start; i <= end; i++) { + pages.push(i); + } + if (currentPage < totalPages - 2) pages.push('...'); + pages.push(totalPages); + } + return pages; + }; + + return ( +
+ {/* 桌面端:首页按钮 */} + + {/* 桌面端:上一页按钮 */} + + {/* 桌面端:页码按钮 */} +
+ {getPageNumbers().map((page, idx) => ( + page === '...' ? ( + + ) : ( + + ) + ))} +
+ {/* 桌面端:下一页按钮 */} + + {/* 桌面端:末页按钮 */} + + {/* 页码信息:当前页/总页数 + 总条数 */} +
+ {currentPage} + / + {totalPages} + | + 共 {totalRecords} 条 +
+ {/* 加载状态指示器 */} + + {/* 移动端简化:上一页 */} + + {/* 移动端简化:页码信息 */} + + {currentPage} / {totalPages} + + {/* 移动端简化:下一页 */} + +
+ ); +}; + +// 分页导航图标组件 +function ChevronsLeftIcon() { + return ( + + ); +} + +function ChevronLeftIcon() { + return ( + + ); +} + +function ChevronRightIcon() { + return ( + + ); +} + +function ChevronsRightIcon() { + return ( + + ); +} + +export default RecordPage