1063 lines
32 KiB
TypeScript
1063 lines
32 KiB
TypeScript
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;
|