feat: integrate Stripe payment with bugfixes and active timeout cancellation
- Add Stripe payment provider with Checkout Session flow - Payment provider abstraction layer (EasyPay + Stripe unified interface) - Stripe webhook with proper raw body handling and signature verification - Frontend: Stripe button with URL validation, anti-duplicate click, noopener - Active timeout cancellation: query platform before expiring, recover paid orders - Singleton Stripe client, idempotency keys, Math.round for amounts - Handle async_payment events, return null for unknown webhook events - Set Checkout Session expires_at aligned with order timeout - Add cancelPayment to provider interface (Stripe: sessions.expire, EasyPay: no-op) - Enable stripe in frontend payment type list
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import OrderFilterBar from '@/components/OrderFilterBar';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
getStatusBadgeClass,
|
||||
type MyOrder,
|
||||
type OrderStatusFilter,
|
||||
} from '@/lib/pay-utils';
|
||||
|
||||
interface MobileOrderListProps {
|
||||
isDark: boolean;
|
||||
@@ -22,13 +28,17 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
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>
|
||||
<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',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
@@ -38,11 +48,21 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
<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
|
||||
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
|
||||
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>
|
||||
) : (
|
||||
@@ -50,11 +70,16 @@ export default function MobileOrderList({ isDark, hasToken, orders, onRefresh }:
|
||||
{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(' ')}
|
||||
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(' ')}>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,12 @@ export default function OrderFilterBar({ isDark, activeFilter, onChange }: Order
|
||||
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'),
|
||||
? 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}
|
||||
|
||||
@@ -11,7 +11,10 @@ interface OrderSummaryCardsProps {
|
||||
}
|
||||
|
||||
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 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 (
|
||||
|
||||
@@ -9,22 +9,47 @@ interface OrderTableProps {
|
||||
|
||||
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(' ')}>
|
||||
<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
|
||||
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(' ')}>
|
||||
<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
|
||||
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(' ')}>
|
||||
<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>
|
||||
@@ -35,13 +60,20 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
{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(' ')}
|
||||
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(' ')}>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -60,22 +60,15 @@ export default function PayPageLayout({
|
||||
Sub2API Secure Pay
|
||||
</div>
|
||||
<h1
|
||||
className={[
|
||||
'text-2xl font-semibold tracking-tight',
|
||||
isDark ? 'text-slate-100' : 'text-slate-900',
|
||||
].join(' ')}
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
|
||||
interface PaymentFormProps {
|
||||
userId: number;
|
||||
@@ -70,13 +71,62 @@ export default function PaymentForm({
|
||||
await onSubmit(selectedAmount, paymentType);
|
||||
};
|
||||
|
||||
const isAlipay = (type: string) => type === 'alipay';
|
||||
const renderPaymentIcon = (type: string) => {
|
||||
if (type === 'alipay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'wxpay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
||||
<path
|
||||
d="M5 12.5 10.2 17 19 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'stripe') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className={['rounded-xl border p-4', dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>充值账户</div>
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
充值账户
|
||||
</div>
|
||||
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
</div>
|
||||
@@ -118,7 +168,13 @@ export default function PaymentForm({
|
||||
自定义金额
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>¥</span>
|
||||
<span
|
||||
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
¥
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
@@ -138,51 +194,50 @@ export default function PaymentForm({
|
||||
|
||||
{customAmount !== '' && !isValid && (
|
||||
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||
{'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'}
|
||||
{
|
||||
'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Type */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>支付方式</label>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{enabledPaymentTypes.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
paymentType === type
|
||||
? isAlipay(type)
|
||||
? 'border-cyan-400 bg-cyan-50 text-slate-900 shadow-sm'
|
||||
: 'border-green-500 bg-green-50 text-slate-900 shadow-sm'
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isAlipay(type) ? (
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = paymentType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setPaymentType(type)}
|
||||
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||
isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">支付宝</span>
|
||||
<span className="text-[10px] tracking-[0.25em] text-slate-600">ALIPAY</span>
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{meta?.sublabel && (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
||||
<path d="M5 12.5 10.2 17 19 8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xl font-semibold tracking-tight">微信支付</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,8 +247,12 @@ export default function PaymentForm({
|
||||
disabled={!isValid || loading}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
isValid && !loading
|
||||
? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300'
|
||||
? paymentType === 'stripe'
|
||||
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-300'
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
||||
|
||||
@@ -7,7 +7,8 @@ interface PaymentQRCodeProps {
|
||||
orderId: string;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay';
|
||||
checkoutUrl?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
||||
amount: number;
|
||||
expiresAt: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
@@ -23,10 +24,20 @@ const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
function isSafeCheckoutUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' && parsed.hostname.endsWith('.stripe.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
payUrl,
|
||||
qrCode,
|
||||
checkoutUrl,
|
||||
paymentType,
|
||||
amount,
|
||||
expiresAt,
|
||||
@@ -38,6 +49,7 @@ export default function PaymentQRCode({
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [stripeOpened, setStripeOpened] = useState(false);
|
||||
|
||||
const qrPayload = useMemo(() => {
|
||||
const value = (qrCode || payUrl || '').trim();
|
||||
@@ -124,13 +136,14 @@ export default function PaymentQRCode({
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await fetch(`/api/orders/${orderId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: data.user_id }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const cancelRes = await fetch(`/api/orders/${orderId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: data.user_id }),
|
||||
});
|
||||
if (cancelRes.ok) {
|
||||
onStatusChange('CANCELLED');
|
||||
}
|
||||
} catch {
|
||||
@@ -138,10 +151,11 @@ export default function PaymentQRCode({
|
||||
}
|
||||
};
|
||||
|
||||
const isStripe = paymentType === 'stripe';
|
||||
const isWx = paymentType === 'wxpay';
|
||||
const iconSrc = isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
const iconBgClass = isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
@@ -154,44 +168,91 @@ export default function PaymentQRCode({
|
||||
|
||||
{!expired && (
|
||||
<>
|
||||
{qrDataUrl && (
|
||||
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
{isStripe ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened}
|
||||
onClick={() => {
|
||||
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
|
||||
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
|
||||
setStripeOpened(true);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors',
|
||||
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'}
|
||||
</button>
|
||||
{stripeOpened && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
|
||||
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')}
|
||||
>
|
||||
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'}
|
||||
</button>
|
||||
)}
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl)
|
||||
? '\u652F\u4ED8\u94FE\u63A5\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u8FD4\u56DE\u91CD\u8BD5'
|
||||
: '\u5728\u65B0\u7A97\u53E3\u5B8C\u6210\u652F\u4ED8\u540E\uFF0C\u6B64\u9875\u9762\u5C06\u81EA\u52A8\u66F4\u65B0'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{qrDataUrl && (
|
||||
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
|
||||
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
|
||||
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!qrDataUrl && payUrl && (
|
||||
<a
|
||||
href={payUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{TEXT_GO_PAY}
|
||||
</a>
|
||||
)}
|
||||
{!qrDataUrl && payUrl && (
|
||||
<a
|
||||
href={payUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{TEXT_GO_PAY}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!qrDataUrl && !payUrl && (
|
||||
<div className="text-center">
|
||||
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!qrDataUrl && !payUrl && (
|
||||
<div className="text-center">
|
||||
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
|
||||
</p>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -75,11 +75,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">订单详情</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -99,21 +101,13 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
|
||||
<div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
<div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className="mt-1 text-xs text-gray-400">操作者: {log.operator}</div>
|
||||
)}
|
||||
{log.detail && <div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>}
|
||||
{log.operator && <div className="mt-1 text-xs text-gray-400">操作者: {log.operator}</div>}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className="text-center text-sm text-gray-400">暂无日志</div>
|
||||
)}
|
||||
{order.auditLogs.length === 0 && <div className="text-center text-sm text-gray-400">暂无日志</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,14 +55,14 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{orders.map((order) => {
|
||||
const statusInfo = STATUS_LABELS[order.status] || { label: order.status, className: 'bg-gray-100 text-gray-800' };
|
||||
const statusInfo = STATUS_LABELS[order.status] || {
|
||||
label: order.status,
|
||||
className: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
return (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => onViewDetail(order.id)}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
<button onClick={() => onViewDetail(order.id)} className="text-blue-600 hover:underline">
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
@@ -70,9 +70,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
<div>{order.userName || '-'}</div>
|
||||
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{order.amount.toFixed(2)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
@@ -109,9 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500">暂无订单</div>
|
||||
)}
|
||||
{orders.length === 0 && <div className="py-12 text-center text-gray-500">暂无订单</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function RefundDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-bold text-gray-900">确认退款</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -48,11 +48,7 @@ export default function RefundDialog({
|
||||
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
<div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
|
||||
{warning}
|
||||
</div>
|
||||
)}
|
||||
{warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">退款原因</label>
|
||||
|
||||
Reference in New Issue
Block a user