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

@@ -4,6 +4,7 @@ import { useSearchParams } from 'next/navigation';
import { useState, useEffect, useCallback, Suspense } from 'react';
import OrderTable from '@/components/admin/OrderTable';
import OrderDetail from '@/components/admin/OrderDetail';
import PaginationBar from '@/components/PaginationBar';
interface AdminOrder {
id: string;
@@ -198,60 +199,15 @@ function AdminContent() {
)}
</div>
{/* Pagination */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500">
<div className="flex items-center gap-2">
<span> {total} </span>
{[20, 50, 100].map((s) => (
<button
key={s}
onClick={() => { setPageSize(s); setPage(1); }}
className={`rounded border px-2.5 py-1 text-xs font-medium transition-colors ${
pageSize === s
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 text-gray-600 hover:bg-gray-100'
}`}
>
{s}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
onClick={() => setPage(1)}
disabled={page <= 1}
className="rounded border px-2.5 py-1 disabled:opacity-40 hover:bg-gray-100"
>
««
</button>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded border px-3 py-1 disabled:opacity-40 hover:bg-gray-100"
>
</button>
<span className="px-3 py-1">
{page} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="rounded border px-3 py-1 disabled:opacity-40 hover:bg-gray-100"
>
</button>
<button
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
className="rounded border px-2.5 py-1 disabled:opacity-40 hover:bg-gray-100"
>
»»
</button>
</div>
)}
</div>
<PaginationBar
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
loading={loading}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
/>
{/* Order Detail */}
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />}

View File

@@ -6,6 +6,7 @@ import PayPageLayout from '@/components/PayPageLayout';
import OrderFilterBar from '@/components/OrderFilterBar';
import OrderSummaryCards from '@/components/OrderSummaryCards';
import OrderTable from '@/components/OrderTable';
import PaginationBar from '@/components/PaginationBar';
import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
const PAGE_SIZE_OPTIONS = [20, 50, 100];
@@ -49,33 +50,27 @@ function OrdersContent() {
setIsMobile(detectDeviceIsMobile());
}, []);
const buildMobilePayOrdersTabUrl = () => {
useEffect(() => {
if (!isMobile || isEmbedded || typeof window === 'undefined') return;
const params = new URLSearchParams();
if (userId && !Number.isNaN(userId)) params.set('user_id', String(userId));
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
params.set('tab', 'orders');
return `/pay?${params.toString()}`;
};
useEffect(() => {
if (!isMobile || isEmbedded || typeof window === 'undefined') return;
window.location.replace(buildMobilePayOrdersTabUrl());
window.location.replace(`/pay?${params.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isEmbedded, userId, token, theme, uiMode]);
}, [isMobile, isEmbedded]);
const loadOrders = async (targetPage = page, targetPageSize = pageSize) => {
setLoading(true);
setError('');
try {
if (!userId || Number.isNaN(userId) || userId <= 0) {
setError('无效的用户 ID');
setOrders([]);
return;
}
if (!hasToken) {
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
setOrders([]);
@@ -88,19 +83,15 @@ function OrdersContent() {
page: String(targetPage),
page_size: String(targetPageSize),
});
const meRes = await fetch(`/api/orders/my?${params}`);
if (!meRes.ok) {
if (meRes.status === 401) {
setError('登录态已失效,请从 Sub2API 重新进入支付页。');
} else {
setError('订单加载失败,请稍后重试。');
}
const res = await fetch(`/api/orders/my?${params}`);
if (!res.ok) {
setError(res.status === 401 ? '登录态已失效,请从 Sub2API 重新进入支付页。' : '订单加载失败,请稍后重试。');
setOrders([]);
return;
}
const meData = await meRes.json();
const meUser = meData.user || {};
const data = await res.json();
const meUser = data.user || {};
const meId = Number(meUser.id);
if (Number.isInteger(meId) && meId > 0) setResolvedUserId(meId);
@@ -113,10 +104,10 @@ function OrdersContent() {
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
});
setOrders(Array.isArray(meData.orders) ? meData.orders : []);
setSummary(meData.summary ?? { total: 0, pending: 0, completed: 0, failed: 0 });
setPage(meData.page ?? targetPage);
setTotalPages(meData.total_pages ?? 1);
setOrders(Array.isArray(data.orders) ? data.orders : []);
setSummary(data.summary ?? { total: 0, pending: 0, completed: 0, failed: 0 });
setPage(data.page ?? targetPage);
setTotalPages(data.total_pages ?? 1);
} catch {
setOrders([]);
setError('网络错误,请稍后重试。');
@@ -131,43 +122,28 @@ function OrdersContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, token, isMobile, isEmbedded]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
loadOrders(newPage, pageSize);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPage(1);
loadOrders(1, newSize);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
loadOrders(newPage, pageSize);
};
const filteredOrders =
activeFilter === 'ALL' ? orders : orders.filter((item) => item.status === activeFilter);
const buildScopedUrl = (path: string) => {
const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
return `${path}?${params.toString()}`;
};
const payUrl = buildScopedUrl('/pay');
activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
const btnClass = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
if (isMobile) {
return (
<div
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
>
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
Tab...
</div>
);
@@ -184,6 +160,15 @@ function OrdersContent() {
);
}
const buildScopedUrl = (path: string) => {
const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
return `${path}?${params.toString()}`;
};
return (
<PayPageLayout
isDark={isDark}
@@ -192,114 +177,37 @@ function OrdersContent() {
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
actions={
<>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
</button>
<a href={payUrl} className={btnClass}>
</a>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button>
<a href={buildScopedUrl('/pay')} className={btnClass}></a>
</>
}
>
<OrderSummaryCards isDark={isDark} summary={summary} />
{/* 过滤 + 分页大小 */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={(f) => setActiveFilter(f)} />
<div className="flex items-center gap-1.5">
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></span>
{PAGE_SIZE_OPTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => handlePageSizeChange(s)}
className={[
'rounded border px-2 py-1 text-xs 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',
].join(' ')}
>
{s}
</button>
))}
</div>
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
</div>
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
{/* 分页控件 */}
{!loading && !error && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
{summary.total} {page} / {totalPages}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => handlePageChange(1)}
disabled={page <= 1}
className={[
'rounded border px-2 py-1 transition-colors disabled:opacity-40',
isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ')}
>
««
</button>
<button
type="button"
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className={[
'rounded border px-2 py-1 transition-colors disabled:opacity-40',
isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ')}
>
</button>
<button
type="button"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className={[
'rounded border px-2 py-1 transition-colors disabled:opacity-40',
isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ')}
>
</button>
<button
type="button"
onClick={() => handlePageChange(totalPages)}
disabled={page >= totalPages}
className={[
'rounded border px-2 py-1 transition-colors disabled:opacity-40',
isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ')}
>
»»
</button>
</div>
</div>
)}
<PaginationBar
page={page}
totalPages={totalPages}
total={summary.total}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
isDark={isDark}
loading={loading}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</PayPageLayout>
);
}
export default function OrdersPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><div className="text-gray-500">...</div></div>}>
<OrdersContent />
</Suspense>
);

View File

@@ -46,6 +46,9 @@ function PayContent() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
const [ordersPage, setOrdersPage] = useState(1);
const [ordersHasMore, setOrdersHasMore] = useState(false);
const [ordersLoadingMore, setOrdersLoadingMore] = useState(false);
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config, setConfig] = useState<AppConfig>({
@@ -113,8 +116,12 @@ function PayContent() {
if (Array.isArray(meData.orders)) {
setMyOrders(meData.orders);
setOrdersPage(1);
setOrdersHasMore((meData.total_pages ?? 1) > 1);
} else {
setMyOrders([]);
setOrdersPage(1);
setOrdersHasMore(false);
}
return;
}
@@ -123,11 +130,35 @@ function PayContent() {
// 无 token 或 token 失效:只显示用户 ID不展示隐私信息
setUserInfo({ id: userId, username: `用户 #${userId}`, balance: 0 });
setMyOrders([]);
setOrdersPage(1);
setOrdersHasMore(false);
} catch {
// ignore and keep page usable
}
};
const loadMoreOrders = async () => {
if (!token || ordersLoadingMore || !ordersHasMore) return;
const nextPage = ordersPage + 1;
setOrdersLoadingMore(true);
try {
const res = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}&page=${nextPage}&page_size=20`);
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.orders) && data.orders.length > 0) {
setMyOrders((prev) => [...prev, ...data.orders]);
setOrdersPage(nextPage);
setOrdersHasMore(nextPage < (data.total_pages ?? 1));
} else {
setOrdersHasMore(false);
}
} catch {
// ignore
} finally {
setOrdersLoadingMore(false);
}
};
useEffect(() => {
loadUserAndOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -329,7 +360,10 @@ function PayContent() {
isDark={isDark}
hasToken={hasToken}
orders={myOrders}
hasMore={ordersHasMore}
loadingMore={ordersLoadingMore}
onRefresh={loadUserAndOrders}
onLoadMore={loadMoreOrders}
/>
)
) : (

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>
);
}