feat: 分页组件统一封装 + 移动端无限滚动

- 新增 PaginationBar 组件,支持 isDark / loading / 页码导航 / 每页大小切换
- 重写 pay/orders/page.tsx 使用 PaginationBar,summary 来自 API groupBy 全量统计
- admin/page.tsx 替换内联分页 UI 为 PaginationBar
- MobileOrderList 支持无限滚动:IntersectionObserver 哨兵 + hasMore/loadingMore props
- pay/page.tsx 新增 loadMoreOrders(append 模式),初始化/刷新时重置分页状态
This commit is contained in:
erio
2026-03-01 20:12:32 +08:00
parent 292c14b882
commit b4eebb0b1b
5 changed files with 251 additions and 194 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import OrderFilterBar from '@/components/OrderFilterBar';
import {
formatStatus,
@@ -14,17 +14,46 @@ interface MobileOrderListProps {
isDark: boolean;
hasToken: boolean;
orders: MyOrder[];
hasMore: boolean;
loadingMore: boolean;
onRefresh: () => void;
onLoadMore: () => void;
}
export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }: MobileOrderListProps) {
export default function MobileOrderList({
isDark,
hasToken,
orders,
hasMore,
loadingMore,
onRefresh,
onLoadMore,
}: MobileOrderListProps) {
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
const sentinelRef = useRef<HTMLDivElement>(null);
const filteredOrders = useMemo(() => {
if (activeFilter === 'ALL') return orders;
return orders.filter((item) => item.status === activeFilter);
}, [orders, activeFilter]);
useEffect(() => {
if (!hasMore || loadingMore) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
onLoadMore();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, loadingMore, onLoadMore]);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -91,6 +120,27 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
</div>
</div>
))}
{/* 无限滚动哨兵 */}
{hasMore && (
<div ref={sentinelRef} className="py-3 text-center">
{loadingMore ? (
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
...
</span>
) : (
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
</span>
)}
</div>
)}
{!hasMore && orders.length > 0 && (
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
</div>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,109 @@
interface PaginationBarProps {
page: number;
totalPages: number;
total: number;
pageSize: number;
pageSizeOptions?: number[];
isDark?: boolean;
loading?: boolean;
onPageChange: (newPage: number) => void;
onPageSizeChange?: (newSize: number) => void;
}
export default function PaginationBar({
page,
totalPages,
total,
pageSize,
pageSizeOptions = [20, 50, 100],
isDark = false,
loading = false,
onPageChange,
onPageSizeChange,
}: PaginationBarProps) {
const navBtnClass = (disabled: boolean) =>
[
'rounded border px-2.5 py-1 text-xs transition-colors',
disabled || loading ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
isDark
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ');
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
{/* 左侧:统计 + 每页大小 */}
<div className="flex items-center gap-2">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
{total}
{totalPages > 1 && `,第 ${page} / ${totalPages}`}
</span>
{onPageSizeChange && (
<>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}></span>
{pageSizeOptions.map((s) => (
<button
key={s}
type="button"
disabled={loading}
onClick={() => { onPageSizeChange(s); }}
className={[
'rounded border px-2 py-1 font-medium transition-colors',
pageSize === s
? isDark
? 'border-indigo-400 bg-indigo-500/20 text-indigo-200'
: 'border-indigo-400 bg-indigo-50 text-indigo-700'
: isDark
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
loading ? 'opacity-40 cursor-not-allowed' : '',
].join(' ')}
>
{s}
</button>
))}
</>
)}
</div>
{/* 右侧:分页导航 */}
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => onPageChange(1)}
className={navBtnClass(page <= 1)}
>
««
</button>
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => onPageChange(page - 1)}
className={navBtnClass(page <= 1)}
>
</button>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => onPageChange(page + 1)}
className={navBtnClass(page >= totalPages)}
>
</button>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => onPageChange(totalPages)}
className={navBtnClass(page >= totalPages)}
>
»»
</button>
</div>
)}
</div>
);
}