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:
73
src/components/MobileOrderList.tsx
Normal file
73
src/components/MobileOrderList.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import OrderFilterBar from '@/components/OrderFilterBar';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
|
||||
interface MobileOrderListProps {
|
||||
isDark: boolean;
|
||||
hasToken: boolean;
|
||||
orders: MyOrder[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }: MobileOrderListProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (activeFilter === 'ALL') return orders;
|
||||
return orders.filter((item) => item.status === activeFilter);
|
||||
}, [orders, activeFilter]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>我的订单</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className={[
|
||||
'rounded-lg border px-2.5 py-1 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>
|
||||
</div>
|
||||
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
|
||||
{!hasToken ? (
|
||||
<div className={['rounded-xl border border-dashed px-4 py-8 text-center text-sm', isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700'].join(' ')}>
|
||||
当前链接未携带登录 token,无法查询"我的订单"。
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className={['rounded-xl border border-dashed px-4 py-8 text-center text-sm', isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500'].join(' ')}>
|
||||
暂无符合条件的订单记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className={['rounded-xl border px-3 py-3', isDark ? 'border-slate-700 bg-slate-900/70' : 'border-slate-200 bg-white'].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl font-semibold">¥{order.amount.toFixed(2)}</span>
|
||||
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}>
|
||||
{formatStatus(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{order.paymentType}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/OrderFilterBar.tsx
Normal file
29
src/components/OrderFilterBar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderFilterBarProps {
|
||||
isDark: boolean;
|
||||
activeFilter: OrderStatusFilter;
|
||||
onChange: (filter: OrderStatusFilter) => void;
|
||||
}
|
||||
|
||||
export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onChange(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>
|
||||
);
|
||||
}
|
||||
37
src/components/OrderSummaryCards.tsx
Normal file
37
src/components/OrderSummaryCards.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
interface Summary {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface OrderSummaryCardsProps {
|
||||
isDark: boolean;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
|
||||
const cardClass = ['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ');
|
||||
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>总订单</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>待支付</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>已完成</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>异常/关闭</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/OrderTable.tsx
Normal file
56
src/components/OrderTable.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
orders: MyOrder[];
|
||||
}
|
||||
|
||||
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
|
||||
return (
|
||||
<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>
|
||||
) : orders.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">
|
||||
{orders.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, isDark)].join(' ')}>
|
||||
{formatStatus(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/PayPageLayout.tsx
Normal file
85
src/components/PayPageLayout.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PayPageLayoutProps {
|
||||
isDark: boolean;
|
||||
isEmbedded?: boolean;
|
||||
maxWidth?: 'sm' | 'full';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PayPageLayout({
|
||||
isDark,
|
||||
isEmbedded = false,
|
||||
maxWidth = 'full',
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
}: PayPageLayoutProps) {
|
||||
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(' ')}
|
||||
>
|
||||
<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(' ')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'relative mx-auto w-full rounded-3xl border p-4 sm:p-6',
|
||||
maxWidth === 'sm' ? 'max-w-lg' : 'max-w-6xl',
|
||||
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(' ')}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ interface OrderDetailProps {
|
||||
status: string;
|
||||
paymentType: string;
|
||||
rechargeCode: string;
|
||||
zpayTradeNo: string | null;
|
||||
paymentTradeNo: string | null;
|
||||
refundAmount: number | null;
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
@@ -52,7 +52,7 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
|
||||
{ label: 'Recharge Status', value: order.rechargeStatus || '-' },
|
||||
{ label: '支付方式', value: order.paymentType === 'alipay' ? '支付宝' : '微信支付' },
|
||||
{ label: '充值码', value: order.rechargeCode },
|
||||
{ label: 'ZPAY订单号', value: order.zpayTradeNo || '-' },
|
||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
||||
|
||||
Reference in New Issue
Block a user