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:
@@ -0,0 +1,2 @@
|
||||
-- Rename zpay_trade_no to payment_trade_no
|
||||
ALTER TABLE "orders" RENAME COLUMN "zpay_trade_no" TO "payment_trade_no";
|
||||
@@ -16,7 +16,7 @@ model Order {
|
||||
status OrderStatus @default(PENDING)
|
||||
paymentType String @map("payment_type")
|
||||
|
||||
zpayTradeNo String? @map("zpay_trade_no")
|
||||
paymentTradeNo String? @map("payment_trade_no")
|
||||
payUrl String? @map("pay_url")
|
||||
qrCode String? @map("qr_code")
|
||||
qrCodeImg String? @map("qr_code_img")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateSign, verifySign } from '@/lib/zpay/sign';
|
||||
|
||||
describe('ZPAY Sign', () => {
|
||||
const pkey = 'YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k';
|
||||
|
||||
it('should generate correct sign with sorted params', () => {
|
||||
const params = {
|
||||
pid: '2026022720004756',
|
||||
type: 'alipay',
|
||||
out_trade_no: '20160806151343349',
|
||||
notify_url: 'http://www.aaa.com/notify_url.php',
|
||||
name: 'test product',
|
||||
money: '1.00',
|
||||
return_url: 'http://www.aaa.com/return_url.php',
|
||||
};
|
||||
const sign = generateSign(params, pkey);
|
||||
expect(sign).toMatch(/^[a-f0-9]{32}$/); // md5 lowercase hex
|
||||
});
|
||||
|
||||
it('should filter out empty values, sign and sign_type', () => {
|
||||
const params = {
|
||||
a: '1',
|
||||
b: '',
|
||||
sign: 'xxx',
|
||||
sign_type: 'MD5',
|
||||
c: '3',
|
||||
};
|
||||
const sign = generateSign(params, pkey);
|
||||
// Should only use a=1&c=3 + pkey
|
||||
const expected = generateSign({ a: '1', c: '3' }, pkey);
|
||||
expect(sign).toBe(expected);
|
||||
});
|
||||
|
||||
it('should sort params by ASCII order', () => {
|
||||
const params1 = { z: '1', a: '2', m: '3' };
|
||||
const params2 = { a: '2', m: '3', z: '1' };
|
||||
expect(generateSign(params1, pkey)).toBe(generateSign(params2, pkey));
|
||||
});
|
||||
|
||||
it('should verify valid signature', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
const sign = generateSign(params, pkey);
|
||||
expect(verifySign(params, pkey, sign)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid signature', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
expect(verifySign(params, pkey, 'invalidsignature1234567890123456')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject signature with wrong length', () => {
|
||||
const params = { a: '1', b: '2' };
|
||||
expect(verifySign(params, pkey, 'short')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
|
||||
const params: EasyPayNotifyParams = {
|
||||
pid: searchParams.get('pid') || '',
|
||||
name: searchParams.get('name') || '',
|
||||
money: searchParams.get('money') || '',
|
||||
out_trade_no: searchParams.get('out_trade_no') || '',
|
||||
trade_no: searchParams.get('trade_no') || '',
|
||||
param: searchParams.get('param') || '',
|
||||
trade_status: searchParams.get('trade_status') || '',
|
||||
type: searchParams.get('type') || '',
|
||||
sign: searchParams.get('sign') || '',
|
||||
sign_type: searchParams.get('sign_type') || '',
|
||||
};
|
||||
|
||||
const success = await handlePaymentNotify(params);
|
||||
|
||||
// ZPAY requires plain text response
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ZPAY notify error:', error);
|
||||
return new Response('fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useState, useEffect, Suspense, useMemo } from 'react';
|
||||
import PaymentForm from '@/components/PaymentForm';
|
||||
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import MobileOrderList from '@/components/MobileOrderList';
|
||||
import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderResult {
|
||||
orderId: string;
|
||||
@@ -16,60 +19,12 @@ interface OrderResult {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id?: number;
|
||||
username: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
interface MyOrder {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
enabledPaymentTypes: string[];
|
||||
minAmount: number;
|
||||
maxAmount: number;
|
||||
}
|
||||
|
||||
type OrderStatusFilter = 'ALL' | 'PENDING' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED';
|
||||
|
||||
const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
{ key: 'EXPIRED', label: '已超时' },
|
||||
];
|
||||
|
||||
const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '失败',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
REFUND_FAILED: '退款失败',
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const userId = Number(searchParams.get('user_id'));
|
||||
@@ -90,7 +45,6 @@ function PayContent() {
|
||||
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||
|
||||
const [config] = useState<AppConfig>({
|
||||
enabledPaymentTypes: ['alipay', 'wxpay'],
|
||||
@@ -177,32 +131,6 @@ function PayContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, token]);
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (activeFilter === 'ALL') return myOrders;
|
||||
return myOrders.filter((item) => item.status === activeFilter);
|
||||
}, [myOrders, activeFilter]);
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -306,212 +234,107 @@ function PayContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, finalStatus]);
|
||||
|
||||
const renderMobileOrders = () => (
|
||||
<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={loadUserAndOrders}
|
||||
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>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_OPTIONS.map((item) => (
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth={isMobile ? 'sm' : 'full'}
|
||||
title="Sub2API 余额充值"
|
||||
subtitle="安全支付,自动到账"
|
||||
actions={!isMobile ? (
|
||||
<>
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveFilter(item.key)}
|
||||
onClick={loadUserAndOrders}
|
||||
className={[
|
||||
'rounded-full border px-3 py-1 text-xs font-medium',
|
||||
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' : 'border-slate-300 text-slate-600'),
|
||||
'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(' ')}
|
||||
>
|
||||
{item.label}
|
||||
刷新
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!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)].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>
|
||||
))}
|
||||
<a
|
||||
href={ordersUrl}
|
||||
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>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
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-5',
|
||||
isMobile ? '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(' ')}
|
||||
>
|
||||
{'Sub2API '}{'\u4F59\u989D\u5145\u503C'}
|
||||
</h1>
|
||||
<p className={['mt-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{'\u5B89\u5168\u652F\u4ED8\uFF0C\u81EA\u52A8\u5230\u8D26'}
|
||||
</p>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadUserAndOrders}
|
||||
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>
|
||||
<a
|
||||
href={ordersUrl}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && isMobile && (
|
||||
<div
|
||||
{step === 'form' && isMobile && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('pay')}
|
||||
className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? (isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
].join(' ')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('pay')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? (isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
].join(' ')}
|
||||
>
|
||||
充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('orders')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? (isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMobileTab('orders')}
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? (isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
{step === 'form' && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
<PaymentForm
|
||||
userId={effectiveUserId}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
isDark={isDark}
|
||||
hasToken={hasToken}
|
||||
orders={myOrders}
|
||||
onRefresh={loadUserAndOrders}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||
<div className="min-w-0">
|
||||
<PaymentForm
|
||||
userId={effectiveUserId}
|
||||
userName={userInfo?.username}
|
||||
@@ -523,76 +346,58 @@ function PayContent() {
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
/>
|
||||
) : (
|
||||
renderMobileOrders()
|
||||
)
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||
<div className="min-w-0">
|
||||
<PaymentForm
|
||||
userId={effectiveUserId}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className={['rounded-2xl border p-4', 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>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看“我的订单”</li>
|
||||
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasHelpContent && (
|
||||
<div className={['rounded-2xl border p-4', 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(' ')}>Support</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt='help'
|
||||
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2'
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className={['rounded-2xl border p-4', 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>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看"我的订单"</li>
|
||||
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{step === 'paying' && orderResult && (
|
||||
<PaymentQRCode
|
||||
orderId={orderResult.orderId}
|
||||
payUrl={orderResult.payUrl}
|
||||
qrCode={orderResult.qrCode}
|
||||
paymentType={orderResult.paymentType}
|
||||
amount={orderResult.amount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
/>
|
||||
)}
|
||||
{hasHelpContent && (
|
||||
<div className={['rounded-2xl border p-4', 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(' ')}>Support</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt='help'
|
||||
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2'
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{step === 'paying' && orderResult && (
|
||||
<PaymentQRCode
|
||||
orderId={orderResult.orderId}
|
||||
payUrl={orderResult.payUrl}
|
||||
qrCode={orderResult.qrCode}
|
||||
paymentType={orderResult.paymentType}
|
||||
amount={orderResult.amount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
||||
)}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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') },
|
||||
|
||||
@@ -6,30 +6,21 @@ const optionalTrimmedString = z.preprocess((value) => {
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
}, z.string().optional());
|
||||
|
||||
const rawEnvSchema = z.object({
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
||||
SUB2API_BASE_URL: z.string().url(),
|
||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||
|
||||
EASY_PAY_PID: optionalTrimmedString,
|
||||
EASY_PAY_PKEY: optionalTrimmedString,
|
||||
EASY_PAY_API_BASE: optionalTrimmedString,
|
||||
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
|
||||
EASY_PAY_RETURN_URL: optionalTrimmedString,
|
||||
EASY_PAY_PID: z.string().min(1),
|
||||
EASY_PAY_PKEY: z.string().min(1),
|
||||
EASY_PAY_API_BASE: z.string().url(),
|
||||
EASY_PAY_NOTIFY_URL: z.string().url(),
|
||||
EASY_PAY_RETURN_URL: z.string().url(),
|
||||
EASY_PAY_CID: optionalTrimmedString,
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
ZPAY_PID: optionalTrimmedString,
|
||||
ZPAY_PKEY: optionalTrimmedString,
|
||||
ZPAY_API_BASE: optionalTrimmedString,
|
||||
ZPAY_NOTIFY_URL: optionalTrimmedString,
|
||||
ZPAY_RETURN_URL: optionalTrimmedString,
|
||||
ZPAY_CID: optionalTrimmedString,
|
||||
ZPAY_CID_ALIPAY: optionalTrimmedString,
|
||||
ZPAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||
@@ -44,96 +35,19 @@ const rawEnvSchema = z.object({
|
||||
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
|
||||
});
|
||||
|
||||
const resolvedEnvSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SUB2API_BASE_URL: z.string().url(),
|
||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||
|
||||
EASY_PAY_PID: z.string().min(1),
|
||||
EASY_PAY_PKEY: z.string().min(1),
|
||||
EASY_PAY_API_BASE: z.string().url(),
|
||||
EASY_PAY_NOTIFY_URL: z.string().url(),
|
||||
EASY_PAY_RETURN_URL: z.string().url(),
|
||||
EASY_PAY_CID: optionalTrimmedString,
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
ENABLED_PAYMENT_TYPES: z.array(z.string()),
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: z.number().int().positive(),
|
||||
MIN_RECHARGE_AMOUNT: z.number().positive(),
|
||||
MAX_RECHARGE_AMOUNT: z.number().positive(),
|
||||
PRODUCT_NAME: z.string(),
|
||||
|
||||
ADMIN_TOKEN: z.string().min(1),
|
||||
|
||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof resolvedEnvSchema>;
|
||||
|
||||
type RawEnv = z.infer<typeof rawEnvSchema>;
|
||||
|
||||
function pickRequired(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string {
|
||||
const value = raw[key] ?? raw[fallbackKey];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env: ${String(key)} (fallback: ${String(fallbackKey)})`);
|
||||
}
|
||||
return value as string;
|
||||
}
|
||||
|
||||
function pickOptional(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string | undefined {
|
||||
return (raw[key] ?? raw[fallbackKey] ?? undefined) as string | undefined;
|
||||
}
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
let cachedEnv: Env | null = null;
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (cachedEnv) return cachedEnv;
|
||||
|
||||
const parsed = rawEnvSchema.safeParse(process.env);
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment variables');
|
||||
}
|
||||
|
||||
const raw = parsed.data;
|
||||
const resolved = {
|
||||
DATABASE_URL: raw.DATABASE_URL,
|
||||
SUB2API_BASE_URL: raw.SUB2API_BASE_URL,
|
||||
SUB2API_ADMIN_API_KEY: raw.SUB2API_ADMIN_API_KEY,
|
||||
|
||||
EASY_PAY_PID: pickRequired(raw, 'EASY_PAY_PID', 'ZPAY_PID'),
|
||||
EASY_PAY_PKEY: pickRequired(raw, 'EASY_PAY_PKEY', 'ZPAY_PKEY'),
|
||||
EASY_PAY_API_BASE: pickRequired(raw, 'EASY_PAY_API_BASE', 'ZPAY_API_BASE'),
|
||||
EASY_PAY_NOTIFY_URL: pickRequired(raw, 'EASY_PAY_NOTIFY_URL', 'ZPAY_NOTIFY_URL'),
|
||||
EASY_PAY_RETURN_URL: pickRequired(raw, 'EASY_PAY_RETURN_URL', 'ZPAY_RETURN_URL'),
|
||||
EASY_PAY_CID: pickOptional(raw, 'EASY_PAY_CID', 'ZPAY_CID'),
|
||||
EASY_PAY_CID_ALIPAY: pickOptional(raw, 'EASY_PAY_CID_ALIPAY', 'ZPAY_CID_ALIPAY'),
|
||||
EASY_PAY_CID_WXPAY: pickOptional(raw, 'EASY_PAY_CID_WXPAY', 'ZPAY_CID_WXPAY'),
|
||||
|
||||
ENABLED_PAYMENT_TYPES: raw.ENABLED_PAYMENT_TYPES,
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: raw.ORDER_TIMEOUT_MINUTES,
|
||||
MIN_RECHARGE_AMOUNT: raw.MIN_RECHARGE_AMOUNT,
|
||||
MAX_RECHARGE_AMOUNT: raw.MAX_RECHARGE_AMOUNT,
|
||||
PRODUCT_NAME: raw.PRODUCT_NAME,
|
||||
|
||||
ADMIN_TOKEN: raw.ADMIN_TOKEN,
|
||||
|
||||
NEXT_PUBLIC_APP_URL: raw.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: raw.NEXT_PUBLIC_PAY_HELP_IMAGE_URL,
|
||||
NEXT_PUBLIC_PAY_HELP_TEXT: raw.NEXT_PUBLIC_PAY_HELP_TEXT,
|
||||
};
|
||||
|
||||
const resolvedParsed = resolvedEnvSchema.safeParse(resolved);
|
||||
if (!resolvedParsed.success) {
|
||||
console.error('Invalid resolved env variables:', resolvedParsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid resolved env variables');
|
||||
}
|
||||
|
||||
cachedEnv = resolvedParsed.data;
|
||||
cachedEnv = parsed.data;
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
zpayTradeNo: easyPayResult.trade_no,
|
||||
paymentTradeNo: easyPayResult.trade_no,
|
||||
payUrl: easyPayResult.payurl || null,
|
||||
qrCode: easyPayResult.qrcode || null,
|
||||
},
|
||||
@@ -205,7 +205,7 @@ export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<
|
||||
data: {
|
||||
status: 'PAID',
|
||||
amount: paidAmount,
|
||||
zpayTradeNo: params.trade_no,
|
||||
paymentTradeNo: params.trade_no,
|
||||
paidAt: new Date(),
|
||||
failedAt: null,
|
||||
failedReason: null,
|
||||
@@ -441,8 +441,8 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (order.zpayTradeNo) {
|
||||
await easyPayRefund(order.zpayTradeNo, order.id, amount.toFixed(2));
|
||||
if (order.paymentTradeNo) {
|
||||
await easyPayRefund(order.paymentTradeNo, order.id, amount.toFixed(2));
|
||||
}
|
||||
|
||||
await subtractBalance(
|
||||
|
||||
70
src/lib/pay-utils.ts
Normal file
70
src/lib/pay-utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export interface UserInfo {
|
||||
id?: number;
|
||||
username: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface MyOrder {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
|
||||
|
||||
export const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '失败',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
REFUND_FAILED: '退款失败',
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
{ key: 'EXPIRED', label: '已超时' },
|
||||
];
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function formatStatus(status: string): string {
|
||||
return STATUS_TEXT_MAP[status] || status;
|
||||
}
|
||||
|
||||
export function formatCreatedAt(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function getStatusBadgeClass(status: string, isDark: boolean): 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';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
const signStr = queryString + pkey;
|
||||
return crypto.createHash('md5').update(signStr).digest('hex');
|
||||
}
|
||||
|
||||
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
|
||||
const expected = generateSign(params, pkey);
|
||||
if (expected.length !== sign.length) return false;
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(sign);
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
export interface ZPayCreateParams {
|
||||
pid: string;
|
||||
type: 'alipay' | 'wxpay';
|
||||
out_trade_no: string;
|
||||
notify_url: string;
|
||||
name: string;
|
||||
money: string;
|
||||
clientip: string;
|
||||
return_url: string;
|
||||
sign?: string;
|
||||
sign_type?: string;
|
||||
}
|
||||
|
||||
export interface ZPayCreateResponse {
|
||||
code: number;
|
||||
msg?: string;
|
||||
trade_no: string;
|
||||
O_id?: string;
|
||||
payurl?: string;
|
||||
qrcode?: string;
|
||||
img?: string;
|
||||
}
|
||||
|
||||
export interface ZPayNotifyParams {
|
||||
pid: string;
|
||||
name: string;
|
||||
money: string;
|
||||
out_trade_no: string;
|
||||
trade_no: string;
|
||||
param?: string;
|
||||
trade_status: string;
|
||||
type: string;
|
||||
sign: string;
|
||||
sign_type: string;
|
||||
}
|
||||
|
||||
export interface ZPayQueryResponse {
|
||||
code: number;
|
||||
msg?: string;
|
||||
trade_no: string;
|
||||
out_trade_no: string;
|
||||
type: string;
|
||||
pid: string;
|
||||
addtime: string;
|
||||
endtime: string;
|
||||
name: string;
|
||||
money: string;
|
||||
status: number;
|
||||
param?: string;
|
||||
buyer?: string;
|
||||
}
|
||||
|
||||
export interface ZPayRefundResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
}
|
||||
148
zpay.md
148
zpay.md
@@ -1,148 +0,0 @@
|
||||
如果您的网站已经集成了易支付接口,那么您可以直接使用该API信息,无需另外开发。
|
||||
API信息(兼容 易支付 接口)
|
||||
接口地址:https://zpayz.cn/
|
||||
|
||||
商户ID(PID):2026022720004756
|
||||
|
||||
商户密钥(PKEY):YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k
|
||||
|
||||
页面跳转支付
|
||||
请求URL
|
||||
https://zpayz.cn/submit.php
|
||||
请求方法
|
||||
POST 或 GET(推荐POST,不容易被劫持或屏蔽)
|
||||
此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。
|
||||
请求参数
|
||||
参数 名称 类型 是否必填 描述 范例
|
||||
name 商品名称 String 是 需体现出具体售卖的商品,否则容易被封 iPhone17苹果手机
|
||||
money 订单金额 String 是 最多保留两位小数 5.67
|
||||
type 支付方式 String 是 支付宝:alipay 微信支付:wxpay alipay
|
||||
out_trade_no 商户订单号 Num 是 每个商品不可重复,最多32位 201911914837526544601
|
||||
notify_url 异步通知页面 String 是 交易信息回调页面,不支持带参数 http://www.aaa.com/bbb.php
|
||||
pid 商户唯一标识 String 是 一串字母数字组合 201901151314084206659771
|
||||
cid 支付渠道ID String 否 支持填写多个,使用,隔开,如果不填则随机调用 1234
|
||||
param 附加内容 String 否 会通过notify_url原样返回 金色 256G
|
||||
return_url 跳转页面 String 是 交易完成后浏览器跳转,不支持带参数 http://www.aaa.com/ccc.php
|
||||
sign 签名(参考本页签名算法) String 是 用于验证信息正确性,采用md5加密 28f9583617d9caf66834292b6ab1cc89
|
||||
sign_type 签名方法 String 是 默认为MD5 MD5
|
||||
用法举例
|
||||
https://zpayz.cn/submit.php?name=iphone xs Max 一台&money=0.03&out_trade_no=201911914837526544601¬ify_url=http://www.aaa.com/notify_url.php&pid=201901151314084206659771¶m=金色 256G&return_url=http://www.baidu.com&sign=28f9583617d9caf66834292b6ab1cc89&sign_type=MD5&type=alipay
|
||||
|
||||
成功返回
|
||||
直接跳转到付款页面
|
||||
说明:该页面为收银台,直接访问这个url即可进行付款
|
||||
失败返回
|
||||
{"code":"error","msg":"具体的错误信息"}
|
||||
API接口支付
|
||||
请求URL
|
||||
https://zpayz.cn/mapi.php
|
||||
请求方法
|
||||
POST(方式为form-data)
|
||||
请求参数
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 String 1001
|
||||
支付渠道ID cid 否 String 1234 支持填写多个,使用,隔开,如果不填则随机调用
|
||||
支付方式 type 是 String alipay 支付宝:alipay 微信支付:wxpay
|
||||
商户订单号 out_trade_no 是 String 20160806151343349 每个商品不可重复,最多32位
|
||||
异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址
|
||||
商品名称 name 是 String iPhone17苹果手机 需体现出具体售卖的商品,否则容易被封
|
||||
商品金额 money 是 String 1.00 单位:元,最大2位小数
|
||||
用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址
|
||||
设备类型 device 否 String pc 根据当前用户浏览器的UA判断,
|
||||
传入用户所使用的浏览器
|
||||
或设备类型,默认为pc
|
||||
业务扩展参数 param 否 String 没有请留空 支付后原样返回
|
||||
签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法参考本页底部
|
||||
签名类型 sign_type 是 String MD5 默认为MD5
|
||||
成功返回
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 失败时返回原因
|
||||
订单号 trade_no String 20160806151343349 支付订单号
|
||||
ZPAY内部订单号 O_id String 123456 ZPAY内部订单号
|
||||
支付跳转url payurl String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付
|
||||
二维码链接 qrcode String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则根据该url生成二维码
|
||||
二维码图片 img String https://zpayz.cn/qrcode/123.jpg 该字段为付款二维码的图片地址
|
||||
失败返回
|
||||
{"code":"error","msg":"具体的错误信息"}
|
||||
查询单个订单
|
||||
请求URL
|
||||
https://zpayz.cn/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号}
|
||||
请求方法
|
||||
GET
|
||||
请求参数
|
||||
参数 名称 类型 必填 描述 范例
|
||||
act 操作类型 String 是 此API固定值 order
|
||||
pid 商户ID String 是 20220715225121
|
||||
key 商户密钥 String 是 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
trade_no 系统订单号 String 选择 20160806151343312
|
||||
out_trade_no 商户订单号 String 选择 20160806151343349
|
||||
返回结果
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 查询订单号成功!
|
||||
易支付订单号 trade_no String 2016080622555342651 易支付订单号
|
||||
商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号
|
||||
支付方式 type String alipay 支付宝:alipay 微信支付:wxpay
|
||||
商户ID pid String 20220715225121 发起支付的商户ID
|
||||
创建订单时间 addtime String 2016-08-06 22:55:52
|
||||
完成交易时间 endtime String 2016-08-06 22:55:52
|
||||
商品名称 name String VIP会员
|
||||
商品金额 money String 1.00
|
||||
支付状态 status Int 0 1为支付成功,0为未支付
|
||||
业务扩展参数 param String 默认留空
|
||||
支付者账号 buyer String 默认留空
|
||||
提交订单退款
|
||||
请求URL
|
||||
https://zpayz.cn/api.php?act=refund
|
||||
请求方法
|
||||
POST
|
||||
请求参数
|
||||
字段名 变量名 必填 类型 示例值 描述
|
||||
商户ID pid 是 String 20220715225121
|
||||
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||
易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号
|
||||
商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一
|
||||
退款金额 money 是 String 1.50 大多数通道需要与原订单金额一致
|
||||
返回结果
|
||||
字段名 变量名 类型 示例值 描述
|
||||
返回状态码 code Int 1 1为成功,其它值为失败
|
||||
返回信息 msg String 退款成功
|
||||
支付结果通知
|
||||
请求URL
|
||||
服务器异步通知(notify_url)、页面跳转通知(return_url)
|
||||
请求方法
|
||||
GET
|
||||
请求参数
|
||||
参数 名称 类型 描述 范例
|
||||
pid 商户ID Int 201901151314084206659771
|
||||
name 商品名称 String 商品名称不超过100字 iphone
|
||||
money 订单金额 String 最多保留两位小数 5.67
|
||||
out_trade_no 商户订单号 Num 商户系统内部的订单号 201901191324552185692680
|
||||
trade_no 易支付订单号 String 易支付订单号 2019011922001418111011411195
|
||||
param 业务扩展参数 String 会通过notify_url原样返回 金色 256G
|
||||
trade_status 支付状态 String 只有TRADE_SUCCESS是成功 TRADE_SUCCESS
|
||||
type 支付方式 String 包括支付宝、微信 alipay
|
||||
sign 签名(参考本页签名算法) String 用于验证接受信息的正确性 ef6e3c5c6ff45018e8c82fd66fb056dc
|
||||
sign_type 签名类型 String 默认为MD5 MD5
|
||||
如何验证
|
||||
请根据签名算法,验证自己生成的签名与参数中传入的签名是否一致,如果一致则说明是由官方向您发送的真实信息
|
||||
注意事项
|
||||
1.收到回调信息后请返回“success”,否则程序将判定您的回调地址未正确通知到。
|
||||
|
||||
2.同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。
|
||||
|
||||
3.推荐的做法是,当收到通知进行处理时,首先检查对应业务数据的状态,判断该通知是否已经处理过,如果没有处理过再进行处理,如果处理过直接返回结果成功。在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。
|
||||
|
||||
4.特别提醒:商户系统对于支付结果通知的内容一定要做签名验证,并校验返回的订单金额是否与商户侧的订单金额一致,防止数据泄漏导致出现“假通知”,造成资金损失。
|
||||
|
||||
5.对后台通知交互时,如果平台收到商户的应答不是纯字符串success或超过5秒后返回时,平台认为通知失败,平台会通过一定的策略(通知频率为0/15/15/30/180/1800/1800/1800/1800/3600,单位:秒)间接性重新发起通知,尽可能提高通知的成功率,但不保证通知最终能成功。
|
||||
|
||||
MD5签名算法
|
||||
1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名!
|
||||
|
||||
2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。
|
||||
|
||||
3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。
|
||||
|
||||
4、具体签名与发起支付的示例代码可下载SDK查看。
|
||||
Reference in New Issue
Block a user