feat: 订单列表支持分页查询,分页大小可选 20/50/100

- /api/orders/my 新增 page/page_size 参数,返回分页信息和全局状态统计
- 用户订单页:分页控件(首/上/下/末页)+ 分页大小选择,摘要卡片改为全局统计
- 管理员订单页:分页大小选择器(20/50/100)+ 首尾页跳转按钮
This commit is contained in:
erio
2026-03-01 20:04:49 +08:00
parent e4005da38e
commit 292c14b882
3 changed files with 210 additions and 70 deletions

View File

@@ -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<UserInfo | null>(null);
const [orders, setOrders] = useState<MyOrder[]>([]);
const [summary, setSummary] = useState<Summary>({ total: 0, pending: 0, completed: 0, failed: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
const [resolvedUserId, setResolvedUserId] = useState<number | null>(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 (
<div
@@ -169,27 +192,10 @@ function OrdersContent() {
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
actions={
<>
<button
type="button"
onClick={loadOrders}
className={[
'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(' ')}
>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
</button>
<a
href={payUrl}
className={[
'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(' ')}
>
<a href={payUrl} className={btnClass}>
</a>
</>
@@ -197,11 +203,90 @@ function OrdersContent() {
>
<OrderSummaryCards isDark={isDark} summary={summary} />
<div className="mb-4">
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
{/* 过滤 + 分页大小 */}
<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>
</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>
)}
</PayPageLayout>
);
}