diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 665a4ae..349ed35 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -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() {
)}
- {/* Pagination */}
-
-
- 共 {total} 条,每页
- {[20, 50, 100].map((s) => (
-
- ))}
-
- {totalPages > 1 && (
-
-
-
-
- {page} / {totalPages}
-
-
-
-
- )}
-
+ setPage(p)}
+ onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
+ />
{/* Order Detail */}
{detailOrder && setDetailOrder(null)} />}
diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx
index 7c756f7..b9bf426 100644
--- a/src/app/pay/orders/page.tsx
+++ b/src/app/pay/orders/page.tsx
@@ -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 (
-
+
正在切换到移动端订单 Tab...
);
@@ -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 (
-
-
- 返回充值
-
+
+ 返回充值
>
}
>
- {/* 过滤 + 分页大小 */}
-
setActiveFilter(f)} />
-
-
- 每页
- {PAGE_SIZE_OPTIONS.map((s) => (
-
- ))}
-
+
- {/* 分页控件 */}
- {!loading && !error && totalPages > 1 && (
-
-
- 共 {summary.total} 条,第 {page} / {totalPages} 页
-
-
-
-
-
-
-
-
- )}
+
);
}
export default function OrdersPage() {
return (
-
- 加载中...
-
- }
- >
+ 加载中...
}>
);
diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx
index 7d53965..e9156a7 100644
--- a/src/app/pay/page.tsx
+++ b/src/app/pay/page.tsx
@@ -46,6 +46,9 @@ function PayContent() {
const [userInfo, setUserInfo] = useState(null);
const [resolvedUserId, setResolvedUserId] = useState(null);
const [myOrders, setMyOrders] = useState([]);
+ 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({
@@ -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}
/>
)
) : (
diff --git a/src/components/MobileOrderList.tsx b/src/components/MobileOrderList.tsx
index 3ba2dcb..4c0c49a 100644
--- a/src/components/MobileOrderList.tsx
+++ b/src/components/MobileOrderList.tsx
@@ -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('ALL');
+ const sentinelRef = useRef(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 (
@@ -91,6 +120,27 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
))}
+
+ {/* 无限滚动哨兵 */}
+ {hasMore && (
+
+ {loadingMore ? (
+
+ 加载中...
+
+ ) : (
+
+ 上滑加载更多
+
+ )}
+
+ )}
+
+ {!hasMore && orders.length > 0 && (
+
+ 已显示全部订单
+
+ )}
)}
diff --git a/src/components/PaginationBar.tsx b/src/components/PaginationBar.tsx
new file mode 100644
index 0000000..ea1e963
--- /dev/null
+++ b/src/components/PaginationBar.tsx
@@ -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 (
+
+ {/* 左侧:统计 + 每页大小 */}
+
+
+ 共 {total} 条
+ {totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
+
+
+ {onPageSizeChange && (
+ <>
+ 每页
+ {pageSizeOptions.map((s) => (
+
+ ))}
+ >
+ )}
+
+
+ {/* 右侧:分页导航 */}
+ {totalPages > 1 && (
+
+
+
+
+
+
+ )}
+
+ );
+}