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 && ( +
+ + + + +
+ )} +
+ ); +}