diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3a4bb0f..665a4ae 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -43,6 +43,7 @@ function AdminContent() { const [orders, setOrders] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); const [totalPages, setTotalPages] = useState(1); const [statusFilter, setStatusFilter] = useState(''); const [loading, setLoading] = useState(true); @@ -54,7 +55,7 @@ function AdminContent() { if (!token) return; setLoading(true); try { - const params = new URLSearchParams({ token, page: String(page), page_size: '20' }); + const params = new URLSearchParams({ token, page: String(page), page_size: String(pageSize) }); if (statusFilter) params.set('status', statusFilter); const res = await fetch(`/api/admin/orders?${params}`); @@ -75,7 +76,7 @@ function AdminContent() { } finally { setLoading(false); } - }, [token, page, statusFilter]); + }, [token, page, pageSize, statusFilter]); useEffect(() => { fetchOrders(); @@ -198,14 +199,36 @@ function AdminContent() { {/* Pagination */} - {totalPages > 1 && ( -
- 共 {total} 条记录 -
+
+
+ 共 {total} 条,每页 + {[20, 50, 100].map((s) => ( + + ))} +
+ {totalPages > 1 && ( +
+ @@ -215,13 +238,20 @@ function AdminContent() { +
-
- )} + )} +
{/* Order Detail */} {detailOrder && setDetailOrder(null)} />} diff --git a/src/app/api/orders/my/route.ts b/src/app/api/orders/my/route.ts index f7761b7..0eadabb 100644 --- a/src/app/api/orders/my/route.ts +++ b/src/app/api/orders/my/route.ts @@ -3,28 +3,44 @@ import { prisma } from '@/lib/db'; import { getCurrentUserByToken } from '@/lib/sub2api/client'; import { deriveOrderState, isRechargeRetryable } from '@/lib/order/status'; +const VALID_PAGE_SIZES = [20, 50, 100]; + export async function GET(request: NextRequest) { - const token = request.nextUrl.searchParams.get('token')?.trim(); + const searchParams = request.nextUrl.searchParams; + const token = searchParams.get('token')?.trim(); if (!token) { return NextResponse.json({ error: 'token is required' }, { status: 400 }); } + const page = Math.max(1, Number(searchParams.get('page') || '1')); + const rawPageSize = Number(searchParams.get('page_size') || '20'); + const pageSize = VALID_PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 20; + try { const user = await getCurrentUserByToken(token); - const orders = await prisma.order.findMany({ - where: { userId: user.id }, - orderBy: { createdAt: 'desc' }, - take: 20, - select: { - id: true, - amount: true, - status: true, - paymentType: true, - createdAt: true, - paidAt: true, - completedAt: true, - }, - }); + const where = { userId: user.id }; + + const [orders, total, statusGroups] = await Promise.all([ + prisma.order.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + amount: true, + status: true, + paymentType: true, + createdAt: true, + paidAt: true, + completedAt: true, + }, + }), + prisma.order.count({ where }), + prisma.order.groupBy({ by: ['status'], where, _count: true }), + ]); + + const sc = Object.fromEntries(statusGroups.map((g) => [g.status, g._count])); return NextResponse.json({ user: { @@ -48,6 +64,15 @@ export async function GET(request: NextRequest) { rechargeRetryable: isRechargeRetryable(item), }; }), + summary: { + total, + pending: sc['PENDING'] || 0, + completed: (sc['COMPLETED'] || 0) + (sc['PAID'] || 0) + (sc['RECHARGING'] || 0), + failed: (sc['FAILED'] || 0) + (sc['CANCELLED'] || 0) + (sc['EXPIRED'] || 0), + }, + page, + page_size: pageSize, + total_pages: Math.ceil(total / pageSize), }); } catch (error) { console.error('Get my orders error:', error); diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx index 94e72c9..7c756f7 100644 --- a/src/app/pay/orders/page.tsx +++ b/src/app/pay/orders/page.tsx @@ -1,13 +1,22 @@ 'use client'; import { useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { Suspense, useEffect, useState } from 'react'; import PayPageLayout from '@/components/PayPageLayout'; import OrderFilterBar from '@/components/OrderFilterBar'; import OrderSummaryCards from '@/components/OrderSummaryCards'; import OrderTable from '@/components/OrderTable'; import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils'; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + +interface Summary { + total: number; + pending: number; + completed: number; + failed: number; +} + function OrdersContent() { const searchParams = useSearchParams(); const userId = Number(searchParams.get('user_id')); @@ -20,18 +29,22 @@ function OrdersContent() { const [isMobile, setIsMobile] = useState(false); const [userInfo, setUserInfo] = useState(null); const [orders, setOrders] = useState([]); + const [summary, setSummary] = useState({ total: 0, pending: 0, completed: 0, failed: 0 }); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [activeFilter, setActiveFilter] = useState('ALL'); const [resolvedUserId, setResolvedUserId] = useState(null); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [totalPages, setTotalPages] = useState(1); + const isEmbedded = uiMode === 'embedded' && isIframeContext; const hasToken = token.length > 0; const effectiveUserId = resolvedUserId || userId; useEffect(() => { if (typeof window === 'undefined') return; - setIsIframeContext(window.self !== window.top); setIsMobile(detectDeviceIsMobile()); }, []); @@ -52,7 +65,7 @@ function OrdersContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMobile, isEmbedded, userId, token, theme, uiMode]); - const loadOrders = async () => { + const loadOrders = async (targetPage = page, targetPageSize = pageSize) => { setLoading(true); setError(''); @@ -70,7 +83,12 @@ function OrdersContent() { return; } - const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`); + const params = new URLSearchParams({ + token, + page: String(targetPage), + page_size: String(targetPageSize), + }); + const meRes = await fetch(`/api/orders/my?${params}`); if (!meRes.ok) { if (meRes.status === 401) { setError('登录态已失效,请从 Sub2API 重新进入支付页。'); @@ -84,9 +102,7 @@ function OrdersContent() { const meData = await meRes.json(); const meUser = meData.user || {}; const meId = Number(meUser.id); - if (Number.isInteger(meId) && meId > 0) { - setResolvedUserId(meId); - } + if (Number.isInteger(meId) && meId > 0) setResolvedUserId(meId); setUserInfo({ id: Number.isInteger(meId) && meId > 0 ? meId : userId, @@ -97,11 +113,10 @@ function OrdersContent() { balance: typeof meUser.balance === 'number' ? meUser.balance : 0, }); - if (Array.isArray(meData.orders)) { - setOrders(meData.orders); - } else { - setOrders([]); - } + 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); } catch { setOrders([]); setError('网络错误,请稍后重试。'); @@ -112,22 +127,23 @@ function OrdersContent() { useEffect(() => { if (isMobile && !isEmbedded) return; - loadOrders(); + loadOrders(1, pageSize); // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId, token, isMobile, isEmbedded]); - const filteredOrders = useMemo(() => { - if (activeFilter === 'ALL') return orders; - return orders.filter((item) => item.status === activeFilter); - }, [orders, activeFilter]); + const handlePageSizeChange = (newSize: number) => { + setPageSize(newSize); + setPage(1); + loadOrders(1, newSize); + }; - const summary = useMemo(() => { - const total = orders.length; - const pending = orders.filter((item) => item.status === 'PENDING').length; - const completed = orders.filter((item) => item.status === 'COMPLETED' || item.status === 'PAID').length; - const failed = orders.filter((item) => ['FAILED', 'CANCELLED', 'EXPIRED'].includes(item.status)).length; - return { total, pending, completed, failed }; - }, [orders]); + 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(); @@ -140,6 +156,13 @@ function OrdersContent() { const payUrl = buildScopedUrl('/pay'); + 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', + ].join(' '); + if (isMobile) { return (
- - + 返回充值 @@ -197,11 +203,90 @@ function OrdersContent() { > -
- + {/* 过滤 + 分页大小 */} +
+ setActiveFilter(f)} /> + +
+ 每页 + {PAGE_SIZE_OPTIONS.map((s) => ( + + ))} +
+ + {/* 分页控件 */} + {!loading && !error && totalPages > 1 && ( +
+ + 共 {summary.total} 条,第 {page} / {totalPages} 页 + +
+ + + + +
+
+ )} ); }