2026-03-01 03:04:24 +08:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
2026-03-01 17:58:08 +08:00
|
|
|
|
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
2026-03-01 21:53:09 +08:00
|
|
|
|
export interface MethodLimitInfo {
|
|
|
|
|
|
available: boolean;
|
|
|
|
|
|
remaining: number | null;
|
2026-03-01 22:51:09 +08:00
|
|
|
|
/** 单笔限额,0 = 使用全局 maxAmount */
|
|
|
|
|
|
singleMax?: number;
|
2026-03-01 21:53:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 03:04:24 +08:00
|
|
|
|
interface PaymentFormProps {
|
|
|
|
|
|
userId: number;
|
|
|
|
|
|
userName?: string;
|
|
|
|
|
|
userBalance?: number;
|
|
|
|
|
|
enabledPaymentTypes: string[];
|
2026-03-01 21:53:09 +08:00
|
|
|
|
methodLimits?: Record<string, MethodLimitInfo>;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
minAmount: number;
|
|
|
|
|
|
maxAmount: number;
|
|
|
|
|
|
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
|
dark?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500];
|
|
|
|
|
|
const AMOUNT_TEXT_PATTERN = /^\d*(\.\d{0,2})?$/;
|
|
|
|
|
|
|
|
|
|
|
|
function hasValidCentPrecision(num: number): boolean {
|
|
|
|
|
|
return Math.abs(Math.round(num * 100) - num * 100) < 1e-8;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function PaymentForm({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
userName,
|
|
|
|
|
|
userBalance,
|
|
|
|
|
|
enabledPaymentTypes,
|
2026-03-01 21:53:09 +08:00
|
|
|
|
methodLimits,
|
2026-03-01 03:04:24 +08:00
|
|
|
|
minAmount,
|
|
|
|
|
|
maxAmount,
|
|
|
|
|
|
onSubmit,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
dark = false,
|
|
|
|
|
|
}: PaymentFormProps) {
|
|
|
|
|
|
const [amount, setAmount] = useState<number | ''>('');
|
|
|
|
|
|
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
|
|
|
|
|
const [customAmount, setCustomAmount] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
const handleQuickAmount = (val: number) => {
|
|
|
|
|
|
setAmount(val);
|
|
|
|
|
|
setCustomAmount(String(val));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCustomAmountChange = (val: string) => {
|
|
|
|
|
|
if (!AMOUNT_TEXT_PATTERN.test(val)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCustomAmount(val);
|
|
|
|
|
|
|
|
|
|
|
|
if (val === '') {
|
|
|
|
|
|
setAmount('');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const num = parseFloat(val);
|
|
|
|
|
|
if (!isNaN(num) && num > 0 && hasValidCentPrecision(num)) {
|
|
|
|
|
|
setAmount(num);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setAmount('');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const selectedAmount = amount || 0;
|
2026-03-01 21:53:09 +08:00
|
|
|
|
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
2026-03-01 22:51:09 +08:00
|
|
|
|
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
|
|
|
|
|
|
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
|
|
|
|
|
|
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!isValid || loading) return;
|
|
|
|
|
|
await onSubmit(selectedAmount, paymentType);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-01 17:58:08 +08:00
|
|
|
|
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">
|
2026-03-02 01:05:01 +08:00
|
|
|
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
|
|
|
|
|
<path d="M10 3C6.13 3 3 5.58 3 8.75c0 1.7.84 3.23 2.17 4.29l-.5 2.21 2.4-1.32c.61.17 1.25.27 1.93.27.22 0 .43-.01.64-.03C9.41 13.72 9 12.88 9 12c0-3.31 3.13-6 7-6 .26 0 .51.01.76.03C15.96 3.98 13.19 3 10 3z" />
|
|
|
|
|
|
<path d="M16 8c-3.31 0-6 2.24-6 5s2.69 5 6 5c.67 0 1.31-.1 1.9-.28l2.1 1.15-.55-2.44C20.77 15.52 22 13.86 22 12c0-2.21-2.69-4-6-4z" />
|
2026-03-01 17:58:08 +08:00
|
|
|
|
</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;
|
|
|
|
|
|
};
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
|
|
|
|
{/* User Info */}
|
2026-03-01 17:58:08 +08:00
|
|
|
|
<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>
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
|
|
|
|
|
{userName || `用户 #${userId}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{userBalance !== undefined && (
|
|
|
|
|
|
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
|
|
|
|
|
当前余额: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quick Amount Selection */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
|
|
|
|
|
充值金额
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
2026-03-01 22:51:09 +08:00
|
|
|
|
{QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => (
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<button
|
|
|
|
|
|
key={val}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => handleQuickAmount(val)}
|
|
|
|
|
|
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
|
|
|
|
|
amount === val
|
|
|
|
|
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
|
|
|
|
|
: dark
|
|
|
|
|
|
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
|
|
|
|
|
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
¥{val}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Custom Amount */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
|
|
|
|
|
自定义金额
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="relative">
|
2026-03-01 17:58:08 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
|
|
|
|
|
|
' ',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
¥
|
|
|
|
|
|
</span>
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputMode="decimal"
|
|
|
|
|
|
step="0.01"
|
|
|
|
|
|
min={minAmount}
|
2026-03-01 22:51:09 +08:00
|
|
|
|
max={effectiveMax}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
value={customAmount}
|
|
|
|
|
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
2026-03-01 22:51:09 +08:00
|
|
|
|
placeholder={`${minAmount} - ${effectiveMax}`}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
className={[
|
|
|
|
|
|
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
|
|
|
|
|
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
|
|
|
|
|
].join(' ')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-01 19:41:44 +08:00
|
|
|
|
{customAmount !== '' && !isValid && (() => {
|
|
|
|
|
|
const num = parseFloat(customAmount);
|
|
|
|
|
|
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
|
|
|
|
|
if (!isNaN(num)) {
|
|
|
|
|
|
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
2026-03-01 22:51:09 +08:00
|
|
|
|
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
2026-03-01 19:41:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
|
|
|
|
|
{msg}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Payment Type */}
|
|
|
|
|
|
<div>
|
2026-03-01 17:58:08 +08:00
|
|
|
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
|
|
|
|
|
支付方式
|
|
|
|
|
|
</label>
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<div className="flex gap-3">
|
2026-03-01 17:58:08 +08:00
|
|
|
|
{enabledPaymentTypes.map((type) => {
|
|
|
|
|
|
const meta = PAYMENT_TYPE_META[type];
|
|
|
|
|
|
const isSelected = paymentType === type;
|
2026-03-01 21:53:09 +08:00
|
|
|
|
const limitInfo = methodLimits?.[type];
|
|
|
|
|
|
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
|
|
|
|
|
|
2026-03-01 17:58:08 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={type}
|
|
|
|
|
|
type="button"
|
2026-03-01 21:53:09 +08:00
|
|
|
|
disabled={isUnavailable}
|
|
|
|
|
|
onClick={() => !isUnavailable && setPaymentType(type)}
|
|
|
|
|
|
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
|
|
|
|
|
className={[
|
|
|
|
|
|
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
|
|
|
|
|
isUnavailable
|
|
|
|
|
|
? dark
|
|
|
|
|
|
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
|
|
|
|
|
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
|
|
|
|
|
: 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',
|
|
|
|
|
|
].join(' ')}
|
2026-03-01 17:58:08 +08:00
|
|
|
|
>
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<span className="flex items-center gap-2">
|
2026-03-01 17:58:08 +08:00
|
|
|
|
{renderPaymentIcon(type)}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
<span className="flex flex-col items-start leading-none">
|
2026-03-01 17:58:08 +08:00
|
|
|
|
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
2026-03-01 21:53:09 +08:00
|
|
|
|
{isUnavailable ? (
|
|
|
|
|
|
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
|
|
|
|
|
) : meta?.sublabel ? (
|
2026-03-01 17:58:08 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{meta.sublabel}
|
|
|
|
|
|
</span>
|
2026-03-01 21:53:09 +08:00
|
|
|
|
) : null}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-03-01 17:58:08 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
</div>
|
2026-03-01 21:53:09 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 当前选中渠道额度不足时的提示 */}
|
|
|
|
|
|
{(() => {
|
|
|
|
|
|
const limitInfo = methodLimits?.[paymentType];
|
|
|
|
|
|
if (!limitInfo || limitInfo.available) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
|
|
|
|
|
所选支付方式今日额度已满,请切换到其他支付方式
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2026-03-01 03:04:24 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Submit */}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={!isValid || loading}
|
|
|
|
|
|
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
|
|
|
|
|
isValid && !loading
|
2026-03-01 17:58:08 +08:00
|
|
|
|
? 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'
|
2026-03-01 03:04:24 +08:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|