Files
new-api-hunter/web/src/components/topup/RechargeCard.jsx
2025-10-28 18:37:32 +09:00

585 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useRef } from 'react';
import {
Avatar,
Typography,
Tag,
Card,
Button,
Banner,
Skeleton,
Form,
Space,
Row,
Col,
Spin,
Tooltip,
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import {
CreditCard,
Coins,
Wallet,
BarChart2,
TrendingUp,
Receipt,
} from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render';
const { Text } = Typography;
const RechargeCard = ({
t,
enableOnlineTopUp,
enableStripeTopUp,
enableCreemTopUp,
creemProducts,
creemPreTopUp,
presetAmounts,
selectedPreset,
selectPresetAmount,
formatLargeNumber,
priceRatio,
topUpCount,
minTopUp,
renderQuotaWithAmount,
getAmount,
setTopUpCount,
setSelectedPreset,
renderAmount,
amountLoading,
payMethods,
preTopUp,
paymentLoading,
payWay,
redemptionCode,
setRedemptionCode,
topUp,
isSubmitting,
topUpLink,
openTopUpLink,
userState,
renderQuota,
statusLoading,
topupInfo,
onOpenHistory,
}) => {
const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
<div className='flex items-center justify-between mb-4'>
<div className='flex items-center'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
<CreditCard size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('账户充值')}
</Typography.Text>
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
</div>
</div>
<Button
icon={<Receipt size={16} />}
theme='solid'
onClick={onOpenHistory}
>
{t('账单')}
</Button>
</div>
<Space vertical style={{ width: '100%' }}>
{/* 统计数据 */}
<Card
className='!rounded-xl w-full'
cover={
<div
className='relative h-30'
style={{
'--palette-primary-darkerChannel': '37 99 235',
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='relative z-10 h-full flex flex-col justify-between p-4'>
<div className='flex justify-between items-center'>
<Text strong style={{ color: 'white', fontSize: '16px' }}>
{t('账户统计')}
</Text>
</div>
{/* 统计数据 */}
<div className='grid grid-cols-3 gap-6 mt-4'>
{/* 当前余额 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{renderQuota(userState?.user?.quota)}
</div>
<div className='flex items-center justify-center text-sm'>
<Wallet
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('当前余额')}
</Text>
</div>
</div>
{/* 历史消耗 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{renderQuota(userState?.user?.used_quota)}
</div>
<div className='flex items-center justify-center text-sm'>
<TrendingUp
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('历史消耗')}
</Text>
</div>
</div>
{/* 请求次数 */}
<div className='text-center'>
<div
className='text-base sm:text-2xl font-bold mb-2'
style={{ color: 'white' }}
>
{userState?.user?.request_count || 0}
</div>
<div className='flex items-center justify-center text-sm'>
<BarChart2
size={14}
className='mr-1'
style={{ color: 'rgba(255,255,255,0.8)' }}
/>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: '12px',
}}
>
{t('请求次数')}
</Text>
</div>
</div>
</div>
</div>
</div>
}
>
{/* 在线充值表单 */}
{statusLoading ? (
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
>
<div className='space-y-6'>
{(enableOnlineTopUp || enableStripeTopUp) && (
<Row gutter={12}>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.InputNumber
field='topUpCount'
label={t('充值数量')}
disabled={!enableOnlineTopUp && !enableStripeTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
}
value={topUpCount}
min={minTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
if (value && value >= 1) {
setTopUpCount(value);
setSelectedPreset(null);
await getAmount(value);
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (!value || value < 1) {
setTopUpCount(1);
getAmount(1);
}
}}
formatter={(value) => (value ? `${value}` : '')}
parser={(value) =>
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
}
extraText={
<Skeleton
loading={showAmountSkeleton}
active
placeholder={
<Skeleton.Title
style={{
width: 120,
height: 20,
borderRadius: 6,
}}
/>
}
>
<Text type='secondary' className='text-red-600'>
{t('实付金额:')}
<span style={{ color: 'red' }}>
{renderAmount()}
</span>
</Text>
</Skeleton>
}
style={{ width: '100%' }}
/>
</Col>
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
<Form.Slot label={t('选择支付方式')}>
{payMethods && payMethods.length > 0 ? (
<Space wrap>
{payMethods.map((payMethod) => {
const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
(!enableOnlineTopUp && !isStripe) ||
(!enableStripeTopUp && isStripe) ||
minTopupVal > Number(topUpCount || 0);
const buttonEl = (
<Button
key={payMethod.type}
theme='outline'
type='tertiary'
onClick={() => preTopUp(payMethod.type)}
disabled={disabled}
loading={
paymentLoading && payWay === payMethod.type
}
icon={
payMethod.type === 'alipay' ? (
<SiAlipay size={18} color='#1677FF' />
) : payMethod.type === 'wxpay' ? (
<SiWechat size={18} color='#07C160' />
) : payMethod.type === 'stripe' ? (
<SiStripe size={18} color='#635BFF' />
) : (
<CreditCard
size={18}
color={
payMethod.color ||
'var(--semi-color-text-2)'
}
/>
)
}
className='!rounded-lg !px-4 !py-2'
>
{payMethod.name}
</Button>
);
return disabled &&
minTopupVal > Number(topUpCount || 0) ? (
<Tooltip
content={
t('此支付方式最低充值金额为') +
' ' +
minTopupVal
}
key={payMethod.type}
>
{buttonEl}
</Tooltip>
) : (
<React.Fragment key={payMethod.type}>
{buttonEl}
</React.Fragment>
);
})}
</Space>
) : (
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
{t('暂无可用的支付方式,请联系管理员配置')}
</div>
)}
</Form.Slot>
</Col>
</Row>
)}
{(enableOnlineTopUp || enableStripeTopUp) && (
<Form.Slot
label={
<div className='flex items-center gap-2'>
<span>{t('选择充值额度')}</span>
{(() => {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'USD') return null;
return (
<span
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'normal',
}}
>
(1 $ = {rate.toFixed(2)} {symbol})
</span>
);
})()}
</div>
}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => {
const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0;
const actualPay = discountedPrice;
const save = originalPrice - discountedPrice;
// 根据当前货币类型换算显示金额和数量
const { symbol, rate, type } = getCurrencyConfig();
const statusStr = localStorage.getItem('status');
let usdRate = 7; // 默认CNY汇率
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
let displaySave = save;
if (type === 'USD') {
// 数量保持USD价格从CNY转USD
displayActualPay = actualPay / usdRate;
displaySave = save / usdRate;
} else if (type === 'CNY') {
// 数量转CNY价格已是CNY
displayValue = preset.value * usdRate;
} else if (type === 'CUSTOM') {
// 数量和价格都转自定义货币
displayValue = preset.value * rate;
displayActualPay = (actualPay / usdRate) * rate;
displaySave = (save / usdRate) * rate;
}
return (
<Card
key={index}
style={{
cursor: 'pointer',
border:
selectedPreset === preset.value
? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
height: '100%',
width: '100%',
}}
bodyStyle={{ padding: '12px' }}
onClick={() => {
selectPresetAmount(preset);
onlineFormApiRef.current?.setValue(
'topUpCount',
preset.value,
);
}}
>
<div style={{ textAlign: 'center' }}>
<Typography.Title
heading={6}
style={{ margin: '0 0 8px 0' }}
>
<Coins size={18} />
{formatLargeNumber(displayValue)} {symbol}
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
)}
</Typography.Title>
<div
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
margin: '4px 0',
}}
>
{t('实付')} {symbol}
{displayActualPay.toFixed(2)}
{hasDiscount
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
: `${t('节省')} ${symbol}0.00`}
</div>
</div>
</Card>
);
})}
</div>
</Form.Slot>
)}
{/* Creem 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3'>
{creemProducts.map((product, index) => (
<Card
key={index}
onClick={() => creemPreTopUp(product)}
className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
bodyStyle={{ textAlign: 'center', padding: '16px' }}
>
<div className='font-medium text-lg mb-2'>
{product.name}
</div>
<div className='text-sm text-gray-600 mb-2'>
{t('充值额度')}: {product.quota}
</div>
<div className='text-lg font-semibold text-blue-600'>
{product.currency === 'EUR' ? '€' : '$'}{product.price}
</div>
</Card>
))}
</div>
</Form.Slot>
)}
</div>
</Form>
) : (
<Banner
type='info'
description={t(
'管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。',
)}
className='!rounded-xl'
closeIcon={null}
/>
)}
</Card>
{/* 兑换码充值 */}
<Card
className='!rounded-xl w-full'
title={
<Text type='tertiary' strong>
{t('兑换码充值')}
</Text>
}
>
<Form
getFormApi={(api) => (redeemFormApiRef.current = api)}
initValues={{ redemptionCode: redemptionCode }}
>
<Form.Input
field='redemptionCode'
noLabel={true}
placeholder={t('请输入兑换码')}
value={redemptionCode}
onChange={(value) => setRedemptionCode(value)}
prefix={<IconGift />}
suffix={
<div className='flex items-center gap-2'>
<Button
type='primary'
theme='solid'
onClick={topUp}
loading={isSubmitting}
>
{t('兑换额度')}
</Button>
</div>
}
showClear
style={{ width: '100%' }}
extraText={
topUpLink && (
<Text type='tertiary'>
{t('在找兑换码?')}
<Text
type='secondary'
underline
className='cursor-pointer'
onClick={openTopUpLink}
>
{t('购买兑换码')}
</Text>
</Text>
)
}
/>
</Form>
</Card>
</Space>
</Card>
);
};
export default RechargeCard;