diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx new file mode 100644 index 0000000..0e30f95 --- /dev/null +++ b/frontend/src/pages/Dashboard/index.tsx @@ -0,0 +1,1062 @@ +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;