import React, { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDataStore } from '../../stores'; /** * 仪表盘页面 - Dashboard * 功能:展示用户财务概览信息,包括余额、本月收支、预算进度、最近记录 * 数据来源:通过 useDataStore 获取 dashboardSummary(仪表盘汇总数据)和 records(账单记录) * API 依赖: * - GET /api/statistics/dashboard - 获取仪表盘汇总数据 * - GET /api/records - 获取账单记录列表 */ const Dashboard: React.FC = () => { const navigate = useNavigate(); // 从全局数据状态中获取仪表盘数据和操作函数 // dashboardSummary 包含:总余额、本月收支、预算使用率等聚合数据 const { dashboardSummary, records, loading, error, fetchDashboardSummary, fetchRecords } = useDataStore(); // ref 用于记录列表锚点,切换页码时自动滚动到列表顶部 const recordsListRef = useRef(null); // 分页状态管理 const PAGE_SIZE = 10; // 每页显示10条记录 const [currentPage, setCurrentPage] = useState(1); const [isPageChanging, setIsPageChanging] = useState(false); // 组件挂载时并行请求仪表盘汇总数据和记录列表 // 选择并行而非串行是为了减少首屏加载等待时间 useEffect(() => { fetchDashboardSummary(); fetchRecords(); }, [fetchDashboardSummary, fetchRecords]); // 记录数据更新后重置到第一页,避免翻页后数据为空 useEffect(() => { setCurrentPage(1); }, [records.length]); if (loading && !dashboardSummary) { return
加载中...
; } if (error) { return
错误: {error}
; } // 金额格式化 - 使用 Intl.NumberFormat 的轻量级替代方案 // 使用模板字符串+ toLocaleString 避免 Intl 在一些旧浏览器的兼容问题 const formatCurrency = (amount: number): string => { return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }; // 预算页面的分类 emoji 图标映射 - 仅覆盖 4 个支出分类 const getCategoryIcon = (category: string): string => { const iconMap: { [key: string]: string } = { '餐饮': '🍜', '交通': '🚕', '购物': '🛒', '娱乐': '🎮', }; return iconMap[category] || '💰'; }; // 记录列表的分类 emoji 图标映射 - 覆盖全部收支分类 // 此处使用 emoji 而非 SVG 是因为列表密度高,emoji 更节省渲染开销 const getRecordIcon = (category: string): string => { const iconMap: { [key: string]: string } = { '餐饮': '🍜', '交通': '🚕', '购物': '🛒', '工资': '💼', '奖金': '🏆', '投资': '📈', '兼职': '💼', '理财': '💰', '红包': '🧧', }; return iconMap[category] || '💰'; }; // 时间格式化 - 相对时间显示策略 // 使用 createdAt 而非 date 字段,避免 UTC 时区偏移导致日期错误 // 规则:今天显示时分、昨天显示"昨天"、7天内显示"X天前"、更早显示"月/日" const formatRecordTime = (record: any): string => { const createdAt = new Date(record.createdAt); const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const recordDay = new Date(createdAt.getFullYear(), createdAt.getMonth(), createdAt.getDate()).getTime(); const diffDays = Math.floor((today - recordDay) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { // 今天:显示时分 return `${createdAt.getHours().toString().padStart(2, '0')}:${createdAt.getMinutes().toString().padStart(2, '0')}`; } else if (diffDays === 1) { return '昨天'; } else if (diffDays <= 7) { // 一周内:显示几天前 return `${diffDays}天前`; } else { // 更久:显示月日 return `${createdAt.getMonth() + 1}月${createdAt.getDate()}日`; } }; // 排序后的记录列表 const sortedRecords = records .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); // 分页逻辑 const totalRecords = sortedRecords.length; const totalPages = Math.ceil(totalRecords / PAGE_SIZE); const paginatedRecords = sortedRecords.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); }; return (

当前余额

{formatCurrency(dashboardSummary?.totalBalance || 0)}

本月收入 +{formatCurrency(dashboardSummary?.monthIncome || 0).replace('¥', '')}
本月支出 -{formatCurrency(dashboardSummary?.monthExpense || 0).replace('¥', '')}

本月收入

+{formatCurrency(dashboardSummary?.monthIncome || 0).replace('¥', '')}

本月支出

-{formatCurrency(dashboardSummary?.monthExpense || 0).replace('¥', '')}

预算进度

{dashboardSummary?.budgetUsage.map((budget) => (
{budget.category}
{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}
= 100 ? 'danger' : budget.percentage >= 80 ? 'warning' : 'normal'}`} style={{ width: `${Math.min(budget.percentage, 100)}%` }} >

= 100 ? 'danger' : budget.percentage >= 80 ? 'warning' : 'normal'}`}> {budget.percentage.toFixed(0)}% {budget.percentage >= 100 && ⚠️}

))}
{paginatedRecords.length > 0 && (

最近记录

{paginatedRecords.map((record) => (

{record.category}

{record.description}

{record.type === 'income' ? '+' : '-'}{formatCurrency(record.amount).replace('¥', '')}

{formatRecordTime(record)}

))}
{/* 分页组件 */} {totalPages > 1 && ( )}
)}
); }; /** * 分页组件 - 支持桌面端完整页码 / 移动端简化模式 * - 桌面端:首页 上一页 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 ( ); } function PlusIcon() { return ( ); } function MinusIcon() { return ( ); } export default Dashboard;