Files
gerenjizhang/frontend/src/pages/Dashboard/index.tsx
T

1063 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(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 <div className="loading">...</div>;
}
if (error) {
return <div className="error">: {error}</div>;
}
// 金额格式化 - 使用 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 (
<div className="dashboard-page">
<section className="balance-card" aria-labelledby="balance-title">
<p className="balance-label" id="balance-title"></p>
<p className="balance-amount" aria-label={`余额 ${formatCurrency(dashboardSummary?.totalBalance || 0)}`}>
{formatCurrency(dashboardSummary?.totalBalance || 0)}
</p>
<div className="balance-summary">
<div className="balance-summary-item income">
<span className="label"></span>
<span className="value">+{formatCurrency(dashboardSummary?.monthIncome || 0).replace('¥', '')}</span>
</div>
<div className="balance-summary-item expense">
<span className="label"></span>
<span className="value">-{formatCurrency(dashboardSummary?.monthExpense || 0).replace('¥', '')}</span>
</div>
</div>
</section>
<section className="quick-actions" aria-label="快捷操作">
<button
className="btn btn-primary"
type="button"
aria-label="快速记账"
onClick={() => navigate('/record')}
>
<PlusIcon />
<span></span>
</button>
<button
className="btn btn-danger"
type="button"
aria-label="记支出"
onClick={() => navigate('/record')}
>
<MinusIcon />
<span></span>
</button>
</section>
<section className="stats-grid" aria-label="收支概览">
<article className="stat-card">
<p className="stat-label"></p>
<p className="stat-value income" aria-label={`收入 +${formatCurrency(dashboardSummary?.monthIncome || 0)}`}>
+{formatCurrency(dashboardSummary?.monthIncome || 0).replace('¥', '')}
</p>
</article>
<article className="stat-card">
<p className="stat-label"></p>
<p className="stat-value expense" aria-label={`支出 -${formatCurrency(dashboardSummary?.monthExpense || 0)}`}>
-{formatCurrency(dashboardSummary?.monthExpense || 0).replace('¥', '')}
</p>
</article>
</section>
<section className="budget-card" aria-labelledby="budget-title">
<h2 className="section-title" id="budget-title"></h2>
<div className="budget-list" role="list">
{dashboardSummary?.budgetUsage.map((budget) => (
<div key={budget.id} className="budget-progress-item" role="listitem">
<div className="budget-progress-header">
<div className="budget-category">
<span className="budget-category-icon" aria-hidden="true">
{getCategoryIcon(budget.category)}
</span>
<span className="budget-category-name">{budget.category}</span>
</div>
<span className="budget-amounts">
{formatCurrency(budget.spent)} / {formatCurrency(budget.amount)}
</span>
</div>
<div className="budget-progress-bar" role="progressbar" aria-valuenow={Math.min(budget.percentage, 100)} aria-valuemin={0} aria-valuemax={100} aria-label={`${budget.category}预算已使用${Math.min(budget.percentage, 100).toFixed(0)}%`}>
<div
className={`budget-progress-fill ${budget.percentage >= 100 ? 'danger' : budget.percentage >= 80 ? 'warning' : 'normal'}`}
style={{ width: `${Math.min(budget.percentage, 100)}%` }}
></div>
</div>
<p className={`budget-percentage ${budget.percentage >= 100 ? 'danger' : budget.percentage >= 80 ? 'warning' : 'normal'}`}>
{budget.percentage.toFixed(0)}%
{budget.percentage >= 100 && <span className="budget-warning-icon" aria-label="超支警告"></span>}
</p>
</div>
))}
</div>
</section>
{paginatedRecords.length > 0 && (
<section className="records-card" aria-labelledby="records-title">
<h2 className="section-title" id="records-title"></h2>
<div className="records-list" role="list" ref={recordsListRef}>
{paginatedRecords.map((record) => (
<article key={record.id} className="record-item" role="listitem">
<div className="record-icon" aria-hidden="true">{getRecordIcon(record.category)}</div>
<div className="record-content">
<p className="record-title">{record.category}</p>
<p className="record-note">{record.description}</p>
</div>
<div className="record-meta">
<p className={`record-amount ${record.type}`} aria-label={`${record.type === 'income' ? '收入' : '支出'} ${formatCurrency(record.amount)}`}>
{record.type === 'income' ? '+' : '-'}{formatCurrency(record.amount).replace('¥', '')}
</p>
<p className="record-time">{formatRecordTime(record)}</p>
</div>
</article>
))}
</div>
{/* 分页组件 */}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
onPageChange={handlePageChange}
isPageChanging={isPageChanging}
/>
)}
</section>
)}
<style>{`
:root {
--primary: #0052ff;
--primary-hover: #3761ff;
--primary-light: rgba(0, 82, 255, 0.1);
--success: #00c853;
--success-hover: #00b14a;
--danger: #ff3d00;
--danger-hover: #e63600;
--warning: #ffb300;
--bg: #f5f5f5;
--surface: #ffffff;
--border: #e5e5e5;
--text-primary: #1a1a1a;
--text-secondary: #737373;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.error {
text-align: center;
padding: 40px;
color: var(--danger);
}
.balance-card {
background: linear-gradient(135deg, var(--primary) 0%, #0041cc 100%);
border-radius: var(--radius-xl);
padding: 32px 32px 24px 32px;
color: white;
margin-bottom: var(--space-6);
box-shadow: var(--shadow-md);
}
.balance-label {
font-size: 12px;
opacity: 0.8;
margin-bottom: var(--space-2);
font-weight: 500;
}
.balance-amount {
font-size: 32px;
font-weight: 700;
font-feature-settings: 'tnum';
letter-spacing: -0.5px;
margin-top: 8px;
}
.balance-summary {
display: flex;
gap: var(--space-8);
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.balance-summary-item {
display: flex;
align-items: center;
gap: var(--space-2);
}
.balance-summary-item .label {
font-size: 12px;
opacity: 0.8;
}
.balance-summary-item .value {
font-size: 16px;
font-weight: 600;
}
.balance-summary-item.income .value {
color: #90EE90;
}
.balance-summary-item.expense .value {
color: #FFB6C1;
}
.quick-actions {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 44px;
padding: 0 var(--space-5);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: var(--space-2);
font-weight: 500;
}
.stat-value {
font-size: 24px;
font-weight: 700;
font-feature-settings: 'tnum';
}
.stat-value.income {
color: var(--success);
}
.stat-value.expense {
color: var(--danger);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-2);
}
.section-title::before {
content: '';
width: 3px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.budget-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
margin-bottom: var(--space-6);
}
.budget-progress-item {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.budget-progress-item:last-child {
border-bottom: none;
}
.budget-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.budget-category {
display: flex;
align-items: center;
gap: var(--space-2);
}
.budget-category-icon {
font-size: 16px;
}
.budget-category-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.budget-amounts {
font-size: 12px;
color: var(--text-secondary);
}
.budget-progress-bar {
height: 8px;
background: var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.budget-progress-fill {
height: 100%;
border-radius: var(--radius-sm);
transition: width 0.3s ease;
}
.budget-progress-fill.normal {
background: var(--success);
}
.budget-progress-fill.warning {
background: var(--warning);
}
.budget-progress-fill.danger {
background: var(--danger);
}
.budget-percentage {
font-size: 12px;
font-weight: 600;
margin-top: var(--space-1);
text-align: right;
}
.budget-percentage.warning {
color: var(--warning);
}
.budget-percentage.danger {
color: var(--danger);
}
.budget-warning-icon {
color: var(--warning);
margin-left: var(--space-1);
}
.records-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.record-item {
display: flex;
align-items: center;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.record-item:last-child {
border-bottom: none;
}
.record-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: var(--space-3);
background: var(--bg);
}
.record-content {
flex: 1;
}
.record-title {
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.record-note {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.record-amount {
font-weight: 600;
font-size: 14px;
font-feature-settings: 'tnum';
}
.record-amount.income {
color: var(--success);
}
.record-amount.expense {
color: var(--danger);
}
.record-time {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
text-align: right;
}
/* ===== 分页组件样式 ===== */
.pagination {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
padding: 16px;
margin-top: 16px;
border-top: 1px solid var(--border);
position: relative;
}
/* 导航按钮:首页、上一页、下一页、末页 */
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 32px;
min-height: 32px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease;
user-select: none;
}
.page-btn:hover:not(:disabled):not(.active) {
background: rgba(0, 82, 255, 0.1);
border-color: var(--primary);
color: var(--primary);
transform: translateY(-1px);
}
.page-btn:active:not(:disabled) {
transform: translateY(0);
}
.page-btn:disabled {
background: var(--bg);
color: #a3a3a3;
border-color: var(--border);
cursor: not-allowed;
opacity: 0.5;
}
.page-btn-text {
font-size: 13px;
white-space: nowrap;
}
/* 页码数字按钮容器 */
.page-numbers {
display: inline-flex;
align-items: center;
gap: 8px;
}
.page-number-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
min-height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
user-select: none;
}
.page-number-btn:hover:not(.active) {
background: rgba(0, 82, 255, 0.08);
color: var(--primary);
transform: translateY(-1px);
}
.page-number-btn.active {
background: var(--primary);
color: white;
font-weight: 600;
transform: scale(1.05);
}
.page-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
min-height: 32px;
padding: 0 8px;
color: var(--text-secondary);
font-size: 14px;
}
/* 页码信息:当前页/总页数 */
.page-info {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 32px;
padding: 0 8px;
color: var(--text-secondary);
font-size: 14px;
}
.page-current {
font-weight: 600;
color: var(--primary);
}
.page-separator {
color: var(--text-secondary);
}
.page-total {
font-weight: 500;
}
.page-divider {
margin: 0 4px;
color: var(--border);
}
.page-count {
font-size: 13px;
}
/* 加载状态指示器 */
.page-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.page-loading.show {
opacity: 1;
}
.loading-dot {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
animation: pulse 0.6s ease-in-out infinite alternate;
}
@keyframes pulse {
from { transform: scale(0.8); opacity: 0.4; }
to { transform: scale(1.2); opacity: 1; }
}
/* 移动端元素默认隐藏 */
.page-btn-mobile-prev,
.page-btn-mobile-next,
.page-info-mobile {
display: none;
}
/* 列表切换动画 */
.records-list {
transition: opacity 0.15s ease;
}
@media (max-width: 767px) {
.balance-card {
padding: 24px 20px 16px 20px;
}
.balance-amount {
font-size: 26px;
}
.balance-summary {
flex-direction: column;
gap: 12px;
}
.quick-actions {
flex-direction: column;
}
.quick-actions .btn {
width: 100%;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 20px;
}
/* 移动端分页简化:隐藏桌面端元素,显示移动端元素 */
.page-btn-first,
.page-btn-last,
.page-btn-text,
.page-numbers,
.page-info {
display: none;
}
.page-btn-mobile-prev,
.page-btn-mobile-next,
.page-info-mobile {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
user-select: none;
}
.page-btn-mobile-prev:disabled,
.page-btn-mobile-next:disabled {
background: var(--bg);
color: #a3a3a3;
cursor: not-allowed;
opacity: 0.5;
}
.page-btn-mobile-prev:active:not(:disabled),
.page-btn-mobile-next:active:not(:disabled) {
transform: scale(0.95);
}
.page-info-mobile {
border: none;
background: transparent;
color: var(--text-primary);
}
}
@media (min-width: 1440px) {
.balance-card {
padding: 40px 40px 32px 40px;
}
.balance-amount {
font-size: 36px;
}
.section-title {
font-size: 18px;
}
.stat-value {
font-size: 28px;
}
}
`}</style>
</div>
);
};
/**
* 分页组件 - 支持桌面端完整页码 / 移动端简化模式
* - 桌面端:首页 上一页 1 2 3 ... 10 下一页 末页 当前页/总页数
* - 移动端:上一页 1/10 下一页
*/
interface PaginationProps {
currentPage: number;
totalPages: number;
totalRecords: number;
onPageChange: (page: number) => void;
isPageChanging: boolean;
}
const Pagination: React.FC<PaginationProps> = ({ 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 (
<div className="pagination" role="navigation" aria-label="分页导航">
{/* 桌面端:首页按钮 */}
<button
className="page-btn page-btn-first"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}
aria-label="首页"
>
<ChevronsLeftIcon />
<span className="page-btn-text"></span>
</button>
{/* 桌面端:上一页按钮 */}
<button
className="page-btn page-btn-prev"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="上一页"
>
<ChevronLeftIcon />
<span className="page-btn-text"></span>
</button>
{/* 桌面端:页码按钮 */}
<div className="page-numbers">
{getPageNumbers().map((page, idx) => (
page === '...' ? (
<span key={`ellipsis-${idx}`} className="page-ellipsis" aria-hidden="true">...</span>
) : (
<button
key={page}
className={`page-number-btn ${currentPage === page ? 'active' : ''}`}
onClick={() => onPageChange(page as number)}
aria-label={`${page}`}
aria-current={currentPage === page ? 'page' : undefined}
>
{page}
</button>
)
))}
</div>
{/* 桌面端:下一页按钮 */}
<button
className="page-btn page-btn-next"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="下一页"
>
<span className="page-btn-text"></span>
<ChevronRightIcon />
</button>
{/* 桌面端:末页按钮 */}
<button
className="page-btn page-btn-last"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}
aria-label="末页"
>
<span className="page-btn-text"></span>
<ChevronsRightIcon />
</button>
{/* 页码信息:当前页/总页数 + 总条数 */}
<div className="page-info">
<span className="page-current">{currentPage}</span>
<span className="page-separator">/</span>
<span className="page-total">{totalPages}</span>
<span className="page-divider">|</span>
<span className="page-count"> {totalRecords} </span>
</div>
{/* 加载状态指示器 */}
<div className={`page-loading ${isPageChanging ? 'show' : ''}`} aria-hidden="true">
<span className="loading-dot"></span>
</div>
{/* 移动端简化:上一页 */}
<button
className="page-btn page-btn-mobile-prev"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="上一页"
>
<ChevronLeftIcon />
</button>
{/* 移动端简化:页码信息 */}
<span className="page-info-mobile">
{currentPage} / {totalPages}
</span>
{/* 移动端简化:下一页 */}
<button
className="page-btn page-btn-mobile-next"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="下一页"
>
<ChevronRightIcon />
</button>
</div>
);
};
// 分页导航图标组件
function ChevronsLeftIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" aria-hidden="true">
<polyline points="11 17 6 12 11 7"/>
<polyline points="18 17 13 12 18 7"/>
</svg>
);
}
function ChevronLeftIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" aria-hidden="true">
<polyline points="15 18 9 12 15 6"/>
</svg>
);
}
function ChevronRightIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" aria-hidden="true">
<polyline points="9 18 15 12 9 6"/>
</svg>
);
}
function ChevronsRightIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" aria-hidden="true">
<polyline points="13 17 18 12 13 7"/>
<polyline points="6 17 11 12 6 7"/>
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
}
function MinusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
}
export default Dashboard;