615 lines
22 KiB
JavaScript
615 lines
22 KiB
JavaScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
Typography,
|
|
Tag,
|
|
Button,
|
|
Banner,
|
|
Skeleton,
|
|
Spin,
|
|
Tooltip,
|
|
Input,
|
|
InputNumber,
|
|
Progress,
|
|
} from '@douyinfe/semi-ui';
|
|
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
|
|
import {
|
|
CreditCard,
|
|
WalletCards,
|
|
Activity,
|
|
ChartColumn,
|
|
Receipt,
|
|
Gift,
|
|
DollarSign,
|
|
Ticket,
|
|
ChevronRight,
|
|
Gauge,
|
|
TrendingUp,
|
|
} from 'lucide-react';
|
|
import { API } from '../../helpers';
|
|
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
|
import { getCurrencyConfig } from '../../helpers/render';
|
|
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
|
import './preset-amount-card.css';
|
|
|
|
const { Text } = Typography;
|
|
|
|
const RechargeCard = ({
|
|
t,
|
|
enableOnlineTopUp,
|
|
enableStripeTopUp,
|
|
enableCreemTopUp,
|
|
creemProducts,
|
|
creemPreTopUp,
|
|
presetAmounts,
|
|
selectedPreset,
|
|
selectPresetAmount,
|
|
onPresetCardClick,
|
|
formatLargeNumber,
|
|
priceRatio,
|
|
topUpCount,
|
|
minTopUp,
|
|
renderTopUpCount,
|
|
getAmount,
|
|
setTopUpCount,
|
|
setSelectedPreset,
|
|
renderAmount,
|
|
amountLoading,
|
|
payMethods,
|
|
preTopUp,
|
|
paymentLoading,
|
|
payWay,
|
|
redemptionCode,
|
|
setRedemptionCode,
|
|
topUp,
|
|
isSubmitting,
|
|
topUpLink,
|
|
openTopUpLink,
|
|
userState,
|
|
renderQuota,
|
|
statusLoading,
|
|
topupInfo,
|
|
onOpenHistory,
|
|
enableAlipayTopUp,
|
|
enableWechatPayTopUp,
|
|
enableWaffoTopUp,
|
|
waffoTopUp,
|
|
waffoPayMethods,
|
|
subscriptionLoading = false,
|
|
subscriptionPlans = [],
|
|
billingPreference,
|
|
onChangeBillingPreference,
|
|
activeSubscriptions = [],
|
|
allSubscriptions = [],
|
|
reloadSubscriptionSelf,
|
|
}) => {
|
|
const [activeTab, setActiveTab] = useState('topup');
|
|
const [customAmount, setCustomAmount] = useState(10);
|
|
const [rpmTpmData, setRpmTpmData] = useState(null);
|
|
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
|
const shouldShowSubscription =
|
|
!subscriptionLoading && subscriptionPlans.length > 0;
|
|
|
|
const statusStr = localStorage.getItem('status');
|
|
let usdRate = 7;
|
|
try {
|
|
if (statusStr) {
|
|
const s = JSON.parse(statusStr);
|
|
usdRate = s?.usd_exchange_rate || 7;
|
|
}
|
|
} catch (e) {}
|
|
|
|
const { symbol, rate, type: currencyType } = getCurrencyConfig();
|
|
|
|
const calcRmb = (usdAmount) => {
|
|
return usdAmount * (priceRatio || 1);
|
|
};
|
|
|
|
const getDiscountForAmount = (val) => {
|
|
const preset = presetAmounts.find((p) => p.value === val);
|
|
if (preset?.discount) return preset.discount;
|
|
if (topupInfo?.discount?.[val]) return topupInfo.discount[val];
|
|
return 1.0;
|
|
};
|
|
|
|
const handleCustomAmountChange = (val) => {
|
|
const num = Number(val);
|
|
if (!num || num <= 0) return;
|
|
setCustomAmount(num);
|
|
setTopUpCount(num);
|
|
setSelectedPreset(null);
|
|
getAmount(num);
|
|
};
|
|
|
|
const handlePresetClick = (preset) => {
|
|
const discount = getDiscountForAmount(preset.value);
|
|
const num = preset.value * discount;
|
|
setCustomAmount(preset.value);
|
|
if (onPresetCardClick) {
|
|
onPresetCardClick(preset);
|
|
} else {
|
|
selectPresetAmount(preset);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (subscriptionLoading) return;
|
|
if (shouldShowSubscription) {
|
|
setActiveTab('subscription');
|
|
}
|
|
}, [shouldShowSubscription, subscriptionLoading]);
|
|
|
|
const fetchRpmTpm = useCallback(async () => {
|
|
try {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const res = await API.get(
|
|
`/api/log/self/stat?start_timestamp=${now - 3600}&end_timestamp=${now}`,
|
|
);
|
|
if (res?.data?.success) {
|
|
setRpmTpmData(res.data.data);
|
|
}
|
|
} catch {
|
|
// 静默失败
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchRpmTpm();
|
|
const interval = setInterval(fetchRpmTpm, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchRpmTpm]);
|
|
|
|
const isAnyPayEnabled =
|
|
enableOnlineTopUp ||
|
|
enableStripeTopUp ||
|
|
enableCreemTopUp ||
|
|
enableAlipayTopUp ||
|
|
enableWechatPayTopUp ||
|
|
enableWaffoTopUp;
|
|
|
|
const tabs = [
|
|
{ key: 'topup', label: t('充值'), desc: t('灵活付费') },
|
|
{ key: 'subscription', label: t('订阅'), desc: t('享更低单价') },
|
|
];
|
|
|
|
const totalQuota = (userState?.user?.used_quota || 0) + (userState?.user?.quota || 0);
|
|
const usagePercent = totalQuota > 0 ? Math.round(((userState?.user?.used_quota || 0) / totalQuota) * 100) : 0;
|
|
|
|
// 统计卡片配置
|
|
const statCards = [
|
|
{
|
|
key: 'balance',
|
|
icon: WalletCards,
|
|
label: t('当前余额'),
|
|
value: userState?.user?.quota,
|
|
sublabel: `${t('已用')} ${usagePercent}%`,
|
|
highlight: true,
|
|
},
|
|
{
|
|
key: 'usage',
|
|
icon: ChartColumn,
|
|
label: t('总用量'),
|
|
value: userState?.user?.used_quota,
|
|
sublabel: t('总消耗额度'),
|
|
},
|
|
{
|
|
key: 'requests',
|
|
icon: Activity,
|
|
label: t('API 请求'),
|
|
value: userState?.user?.request_count || 0,
|
|
sublabel: t('总请求数'),
|
|
raw: true,
|
|
},
|
|
{
|
|
key: 'rpm',
|
|
icon: Gauge,
|
|
label: t('每分钟请求'),
|
|
value: rpmTpmData?.rpm,
|
|
sublabel: t('最近1分钟'),
|
|
raw: true,
|
|
live: true,
|
|
},
|
|
{
|
|
key: 'tpm',
|
|
icon: TrendingUp,
|
|
label: t('每分钟Token'),
|
|
value: rpmTpmData?.tpm,
|
|
sublabel: t('最近1分钟'),
|
|
raw: true,
|
|
live: true,
|
|
},
|
|
];
|
|
|
|
const topupContent = (
|
|
<div className='grid gap-5 lg:grid-cols-[1fr_380px] lg:items-start'>
|
|
{/* 左侧:充值卡片 */}
|
|
<div className='rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
|
|
{/* 卡片头部 */}
|
|
<div className='flex items-center gap-3 border-b border-slate-100 px-5 py-4 dark:border-white/5'>
|
|
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'>
|
|
<DollarSign className='h-4 w-4' />
|
|
</div>
|
|
<div>
|
|
<div className='text-base font-semibold text-slate-900 dark:text-white'>
|
|
{t('账户充值')}
|
|
</div>
|
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
|
{t('选择金额和支付方式完成充值')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='space-y-5 p-5'>
|
|
{isAnyPayEnabled ? (
|
|
<>
|
|
{/* 预设额度 */}
|
|
<section>
|
|
<div className='mb-3 flex items-center justify-between'>
|
|
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
|
|
{t('充值金额')}
|
|
</span>
|
|
<span className='text-xs text-slate-400 dark:text-white/40'>
|
|
USD
|
|
</span>
|
|
</div>
|
|
<div className='grid grid-cols-3 gap-2'>
|
|
{presetAmounts.map((preset, index) => {
|
|
const discount = getDiscountForAmount(preset.value);
|
|
const rmbAmount = calcRmb(preset.value * discount);
|
|
const isSelected = selectedPreset === preset.value;
|
|
return (
|
|
<button
|
|
key={index}
|
|
type='button'
|
|
onClick={() => handlePresetClick(preset)}
|
|
className={`group relative flex flex-col items-center rounded-xl border-2 px-3 py-3 transition-all duration-200 ${
|
|
isSelected
|
|
? 'border-emerald-500 bg-emerald-50/50 dark:border-emerald-400 dark:bg-emerald-500/10'
|
|
: 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-sm dark:border-white/5 dark:bg-transparent dark:hover:border-white/20'
|
|
}`}
|
|
>
|
|
{discount < 1.0 && (
|
|
<span className='absolute -top-2 right-2 rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white'>
|
|
{(discount * 10).toFixed(1)}{t('折')}
|
|
</span>
|
|
)}
|
|
<span className={`text-xl font-bold tracking-tight ${isSelected ? 'text-emerald-700 dark:text-emerald-400' : 'text-slate-900 dark:text-white'}`}>
|
|
${formatLargeNumber(preset.value)}
|
|
</span>
|
|
<span className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
|
¥{rmbAmount.toFixed(0)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* 自定义金额 */}
|
|
<section>
|
|
<div className='mb-3'>
|
|
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
|
|
{t('自定义金额')}
|
|
</span>
|
|
</div>
|
|
<div className='flex items-center gap-3'>
|
|
<div className='relative flex-1'>
|
|
<span className='pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm font-medium text-slate-400'>
|
|
$
|
|
</span>
|
|
<InputNumber
|
|
min={minTopUp}
|
|
max={999999999}
|
|
value={customAmount}
|
|
onChange={handleCustomAmountChange}
|
|
className='!h-10 !rounded-xl !pl-7 !text-base'
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
<div className='flex h-10 items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 dark:border-white/10 dark:bg-white/5'>
|
|
<span className='text-xs text-slate-400'>{t('应付')}</span>
|
|
<span className='text-sm font-semibold text-slate-700 dark:text-white'>
|
|
¥{calcRmb(customAmount * getDiscountForAmount(customAmount)).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 支付方式 */}
|
|
<section>
|
|
<div className='mb-3'>
|
|
<span className='text-sm font-medium text-slate-700 dark:text-slate-300'>
|
|
{t('支付方式')}
|
|
</span>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{payMethods
|
|
.filter((m) => m.type !== 'waffo')
|
|
.map((payMethod) => {
|
|
const minTopupVal = Number(payMethod.min_topup) || 0;
|
|
const isStripe = payMethod.type === 'stripe';
|
|
const isAlipay = payMethod.type === 'alipay';
|
|
const isWechat = payMethod.type === 'wxpay';
|
|
const disabled =
|
|
(!enableOnlineTopUp && !isStripe && !isAlipay && !isWechat) ||
|
|
(!enableStripeTopUp && isStripe) ||
|
|
(!enableAlipayTopUp && isAlipay) ||
|
|
(!enableWechatPayTopUp && isWechat) ||
|
|
minTopupVal > Number(topUpCount || 0);
|
|
|
|
const getIcon = () => {
|
|
if (isAlipay)
|
|
return <SiAlipay size={18} color='#1677FF' />;
|
|
if (isWechat)
|
|
return <SiWechat size={18} color='#07C160' />;
|
|
if (isStripe)
|
|
return <SiStripe size={18} color='#635BFF' />;
|
|
return <CreditCard size={18} />;
|
|
};
|
|
|
|
const btn = (
|
|
<Button
|
|
key={payMethod.type}
|
|
theme='outline'
|
|
type='tertiary'
|
|
onClick={() => preTopUp(payMethod.type)}
|
|
disabled={disabled}
|
|
loading={paymentLoading && payWay === payMethod.type}
|
|
icon={getIcon()}
|
|
className='!h-10 !rounded-xl !px-5 !text-sm'
|
|
>
|
|
{payMethod.name}
|
|
</Button>
|
|
);
|
|
|
|
if (disabled && minTopupVal > Number(topUpCount || 0)) {
|
|
return (
|
|
<Tooltip
|
|
content={t('此支付方式最低充值金额为') + ' ' + minTopupVal}
|
|
key={payMethod.type}
|
|
>
|
|
{btn}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return <React.Fragment key={payMethod.type}>{btn}</React.Fragment>;
|
|
})}
|
|
|
|
{enableWaffoTopUp &&
|
|
waffoPayMethods &&
|
|
waffoPayMethods.length > 0 &&
|
|
waffoPayMethods.map((method, index) => (
|
|
<Button
|
|
key={`waffo-${index}`}
|
|
theme='outline'
|
|
type='tertiary'
|
|
onClick={() => waffoTopUp(index)}
|
|
loading={paymentLoading}
|
|
icon={
|
|
method.icon ? (
|
|
<img
|
|
src={method.icon}
|
|
alt={method.name}
|
|
style={{ width: 18, height: 18, objectFit: 'contain' }}
|
|
/>
|
|
) : (
|
|
<CreditCard size={18} />
|
|
)
|
|
}
|
|
className='!h-10 !rounded-xl !px-5 !text-sm'
|
|
>
|
|
{method.name}
|
|
</Button>
|
|
))}
|
|
|
|
{enableCreemTopUp &&
|
|
creemProducts.length > 0 &&
|
|
creemProducts.map((product, index) => (
|
|
<Button
|
|
key={`creem-${index}`}
|
|
theme='outline'
|
|
type='tertiary'
|
|
onClick={() => creemPreTopUp(product)}
|
|
className='!h-10 !rounded-xl !px-5 !text-sm'
|
|
>
|
|
{product.name} - {product.currency === 'EUR' ? '€' : '$'}
|
|
{product.price}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</>
|
|
) : (
|
|
<Banner
|
|
type='info'
|
|
description={t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
|
|
className='!rounded-xl'
|
|
closeIcon={null}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右侧:兑换码 + 账单入口 */}
|
|
<div className='space-y-4'>
|
|
<div className='rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
|
|
<div className='flex items-center gap-3 border-b border-slate-100 px-5 py-4 dark:border-white/5'>
|
|
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'>
|
|
<Gift className='h-4 w-4' />
|
|
</div>
|
|
<div>
|
|
<div className='text-base font-semibold text-slate-900 dark:text-white'>
|
|
{t('兑换码')}
|
|
</div>
|
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
|
{t('输入兑换码获取额度')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='space-y-3 p-5'>
|
|
<Input
|
|
placeholder={t('请输入兑换码')}
|
|
value={redemptionCode}
|
|
onChange={setRedemptionCode}
|
|
className='!h-10 !rounded-xl'
|
|
/>
|
|
<Button
|
|
type='primary'
|
|
theme='solid'
|
|
onClick={topUp}
|
|
loading={isSubmitting}
|
|
disabled={!redemptionCode}
|
|
className='!h-10 !w-full !rounded-xl !bg-emerald-600 hover:!bg-emerald-700'
|
|
>
|
|
{t('立即兑换')}
|
|
</Button>
|
|
{topUpLink && (
|
|
<button
|
|
onClick={openTopUpLink}
|
|
className='flex w-full items-center justify-center gap-1 text-xs text-slate-400 transition-colors hover:text-slate-600 dark:text-white/40 dark:hover:text-white/70'
|
|
>
|
|
<Ticket className='h-3 w-3' />
|
|
{t('购买兑换码')}
|
|
<ChevronRight className='h-3 w-3' />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 账单入口 */}
|
|
<button
|
|
onClick={onOpenHistory}
|
|
className='flex w-full items-center justify-between rounded-2xl border border-slate-200 bg-white px-5 py-4 transition-all hover:border-slate-300 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.02] dark:hover:border-white/20'
|
|
>
|
|
<div className='flex items-center gap-3'>
|
|
<div className='flex h-9 w-9 items-center justify-center rounded-xl bg-slate-50 text-slate-500 dark:bg-white/5 dark:text-white/60'>
|
|
<Receipt className='h-4 w-4' />
|
|
</div>
|
|
<div className='text-left'>
|
|
<div className='text-sm font-medium text-slate-700 dark:text-slate-300'>
|
|
{t('充值记录')}
|
|
</div>
|
|
<div className='text-xs text-slate-400 dark:text-white/40'>
|
|
{t('查看历史账单明细')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className='h-4 w-4 text-slate-300 dark:text-white/20' />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className='flex w-full flex-col gap-5'>
|
|
{/* 统计卡片 */}
|
|
<div className='grid grid-cols-3 md:grid-cols-5 gap-0 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-white/10 dark:bg-white/[0.02]'>
|
|
{statCards.map((card, i) => (
|
|
<div
|
|
key={card.key}
|
|
className={`relative flex flex-col px-4 py-4 ${
|
|
i < 4 ? 'border-r border-slate-100 dark:border-white/5' : ''
|
|
}`}
|
|
>
|
|
<div className='flex items-center gap-2'>
|
|
<card.icon
|
|
className={`h-3.5 w-3.5 ${
|
|
card.highlight
|
|
? 'text-emerald-500 dark:text-emerald-400'
|
|
: 'text-slate-400 dark:text-white/40'
|
|
}`}
|
|
/>
|
|
<span className='text-xs font-medium text-slate-400 dark:text-white/40'>
|
|
{card.label}
|
|
{card.live && (
|
|
<span className='ml-1 inline-block h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse' />
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`mt-2 text-2xl font-bold ${
|
|
card.highlight
|
|
? 'text-emerald-600 dark:text-emerald-400'
|
|
: 'text-slate-900 dark:text-white'
|
|
}`}
|
|
>
|
|
{statusLoading && !card.live ? (
|
|
<Skeleton.Title style={{ width: 60, height: 28, margin: 0 }} />
|
|
) : card.raw ? (
|
|
card.value != null ? card.value : '—'
|
|
) : (
|
|
renderQuota(card.value)
|
|
)}
|
|
</div>
|
|
<div className='mt-1 text-xs text-slate-400 dark:text-white/40'>
|
|
{card.sublabel}
|
|
</div>
|
|
{card.key === 'balance' && (
|
|
<div className='mt-3'>
|
|
<Progress
|
|
percent={usagePercent}
|
|
strokeWidth={6}
|
|
stroke='var(--semi-color-success)'
|
|
aria-label='usage percent'
|
|
showInfo={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 标签切换 */}
|
|
{shouldShowSubscription && (
|
|
<div className='flex justify-start'>
|
|
<div className='inline-flex items-center rounded-xl bg-slate-100 p-1 dark:bg-white/5'>
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
type='button'
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={`flex items-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-all duration-200 ${
|
|
activeTab === tab.key
|
|
? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white'
|
|
: 'text-slate-500 hover:text-slate-700 dark:text-white/40 dark:hover:text-white/70'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
<span className='text-xs font-normal text-slate-400 dark:text-white/40'>
|
|
{tab.desc}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 主体内容 */}
|
|
{statusLoading ? (
|
|
<div className='flex items-center justify-center py-16'>
|
|
<Spin size='large' />
|
|
</div>
|
|
) : shouldShowSubscription && activeTab === 'subscription' ? (
|
|
<SubscriptionPlansCard
|
|
t={t}
|
|
loading={subscriptionLoading}
|
|
plans={subscriptionPlans}
|
|
payMethods={payMethods}
|
|
enableOnlineTopUp={enableOnlineTopUp}
|
|
enableStripeTopUp={enableStripeTopUp}
|
|
enableCreemTopUp={enableCreemTopUp}
|
|
billingPreference={billingPreference}
|
|
onChangeBillingPreference={onChangeBillingPreference}
|
|
activeSubscriptions={activeSubscriptions}
|
|
allSubscriptions={allSubscriptions}
|
|
reloadSubscriptionSelf={reloadSubscriptionSelf}
|
|
withCard={false}
|
|
/>
|
|
) : (
|
|
topupContent
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RechargeCard;
|