refactor: extract pay page components and migrate zpay → easypay

Components:
- Add PayPageLayout, OrderFilterBar, MobileOrderList, OrderTable, OrderSummaryCards
- Extract shared pay-utils (types, constants, helper functions)
- Simplify pay/page.tsx and orders/page.tsx

EasyPay migration:
- Remove src/lib/zpay/, api/zpay/notify, zpay test, zpay.md
- Simplify config.ts: single envSchema, no ZPAY_* fallback
- Rename DB field zpay_trade_no → payment_trade_no (migration added)
- Update OrderDetail label: ZPAY订单号 → 支付单号
- Update CLAUDE.md project structure
This commit is contained in:
erio
2026-03-01 15:55:43 +08:00
parent d2e856b89c
commit 2f45044073
18 changed files with 548 additions and 965 deletions

View File

@@ -2,54 +2,11 @@
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react';
interface UserInfo {
id?: number;
username: string;
balance: number;
}
interface MyOrder {
id: string;
amount: number;
status: string;
paymentType: string;
createdAt: string;
}
type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
const STATUS_TEXT_MAP: Record<string, string> = {
PENDING: '待支付',
PAID: '已支付',
RECHARGING: '充值中',
COMPLETED: '已完成',
EXPIRED: '已超时',
CANCELLED: '已取消',
FAILED: '失败',
REFUNDING: '退款中',
REFUNDED: '已退款',
REFUND_FAILED: '退款失败',
};
const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待支付' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'CANCELLED', label: '已取消' },
{ key: 'EXPIRED', label: '已超时' },
];
function detectDeviceIsMobile(): boolean {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent || '';
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
const touchCapable = navigator.maxTouchPoints > 1;
return mobileUA || (touchCapable && smallPhysicalScreen);
}
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';
function OrdersContent() {
const searchParams = useSearchParams();
@@ -121,7 +78,7 @@ function OrdersContent() {
});
}
setOrders([]);
setError('当前链接未携带登录 token无法查询我的订单。');
setError('当前链接未携带登录 token无法查询"我的订单"。');
return;
}
@@ -184,27 +141,6 @@ function OrdersContent() {
return { total, pending, completed, failed };
}, [orders]);
const formatStatus = (status: string) => STATUS_TEXT_MAP[status] || status;
const formatCreatedAt = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const getStatusBadgeClass = (status: string) => {
if (['COMPLETED', 'PAID'].includes(status)) {
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
}
if (status === 'PENDING') {
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
}
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700';
}
return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';
};
const buildScopedUrl = (path: string) => {
const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
@@ -236,154 +172,43 @@ function OrdersContent() {
}
return (
<div
className={[
'relative min-h-screen w-full overflow-hidden p-3 sm:p-4',
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
].join(' ')}
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
title="我的订单"
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
actions={
<>
<button
type="button"
onClick={loadOrders}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</button>
<a
href={payUrl}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</a>
</>
}
>
<div
className={[
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl',
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35',
].join(' ')}
/>
<div
className={[
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl',
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45',
].join(' ')}
/>
<OrderSummaryCards isDark={isDark} summary={summary} />
<div
className={[
'relative mx-auto w-full max-w-6xl rounded-3xl border p-4 sm:p-6',
isDark
? 'border-slate-700/70 bg-slate-900/85 shadow-2xl shadow-black/35'
: 'border-slate-200/90 bg-white/95 shadow-2xl shadow-slate-300/45',
isEmbedded ? '' : 'mt-6',
].join(' ')}
>
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div
className={[
'mb-2 inline-flex items-center rounded-full px-3 py-1 text-[11px] font-medium',
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
].join(' ')}
>
Sub2API Secure Pay
</div>
<h1 className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
</h1>
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{userInfo?.username || `用户 #${effectiveUserId}`}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={loadOrders}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</button>
<a
href={payUrl}
className={[
'rounded-lg border px-3 py-2 text-xs font-medium',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</a>
</div>
</div>
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div>
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
</div>
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div>
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
</div>
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div>
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
</div>
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>/</div>
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{FILTER_OPTIONS.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setActiveFilter(item.key)}
className={[
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
activeFilter === item.key
? (isDark ? 'border-slate-500 bg-slate-700 text-slate-100' : 'border-slate-400 bg-slate-900 text-white')
: (isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100'),
].join(' ')}
>
{item.label}
</button>
))}
</div>
<div className={['rounded-2xl border p-3 sm:p-4', isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80'].join(' ')}>
{loading ? (
<div className="flex items-center justify-center py-10">
<div className={['h-6 w-6 animate-spin rounded-full border-2 border-t-transparent', isDark ? 'border-slate-400' : 'border-slate-500'].join(' ')} />
</div>
) : error ? (
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700'].join(' ')}>
{error}
</div>
) : filteredOrders.length === 0 ? (
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500'].join(' ')}>
</div>
) : (
<>
<div className={['hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr]', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="space-y-2 md:space-y-0">
{filteredOrders.map((order) => (
<div key={order.id} className={['border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr] md:items-center', isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700'].join(' ')}>
<div className="font-medium">#{order.id.slice(0, 12)}</div>
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
<div>{order.paymentType}</div>
<div>
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status)].join(' ')}>
{formatStatus(order.status)}
</span>
</div>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
</div>
))}
</div>
</>
)}
</div>
<div className="mb-4">
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
</div>
</div>
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
</PayPageLayout>
);
}