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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user