Spaces:
Runtime error
Runtime error
| /* | |
| 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, { useEffect, useRef, useState } from 'react'; | |
| import { | |
| Avatar, | |
| Typography, | |
| Tag, | |
| Card, | |
| Button, | |
| Banner, | |
| Skeleton, | |
| Form, | |
| Space, | |
| Row, | |
| Col, | |
| Spin, | |
| Tooltip, | |
| Tabs, | |
| TabPane, | |
| } from '@douyinfe/semi-ui'; | |
| import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'; | |
| import { | |
| CreditCard, | |
| Coins, | |
| Wallet, | |
| BarChart2, | |
| TrendingUp, | |
| Receipt, | |
| Sparkles, | |
| } from 'lucide-react'; | |
| import { IconGift } from '@douyinfe/semi-icons'; | |
| import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; | |
| import { getCurrencyConfig } from '../../helpers/render'; | |
| import SubscriptionPlansCard from './SubscriptionPlansCard'; | |
| 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, | |
| subscriptionLoading = false, | |
| subscriptionPlans = [], | |
| billingPreference, | |
| onChangeBillingPreference, | |
| activeSubscriptions = [], | |
| allSubscriptions = [], | |
| reloadSubscriptionSelf, | |
| }) => { | |
| const onlineFormApiRef = useRef(null); | |
| const redeemFormApiRef = useRef(null); | |
| const initialTabSetRef = useRef(false); | |
| const showAmountSkeleton = useMinimumLoadingTime(amountLoading); | |
| const [activeTab, setActiveTab] = useState('topup'); | |
| const shouldShowSubscription = | |
| !subscriptionLoading && subscriptionPlans.length > 0; | |
| useEffect(() => { | |
| if (initialTabSetRef.current) return; | |
| if (subscriptionLoading) return; | |
| setActiveTab(shouldShowSubscription ? 'subscription' : 'topup'); | |
| initialTabSetRef.current = true; | |
| }, [shouldShowSubscription, subscriptionLoading]); | |
| useEffect(() => { | |
| if (!shouldShowSubscription && activeTab !== 'topup') { | |
| setActiveTab('topup'); | |
| } | |
| }, [shouldShowSubscription, activeTab]); | |
| const topupContent = ( | |
| <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> | |
| ); | |
| 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> | |
| {shouldShowSubscription ? ( | |
| <Tabs type='card' activeKey={activeTab} onChange={setActiveTab}> | |
| <TabPane | |
| tab={ | |
| <div className='flex items-center gap-2'> | |
| <Sparkles size={16} /> | |
| {t('订阅套餐')} | |
| </div> | |
| } | |
| itemKey='subscription' | |
| > | |
| <div className='py-2'> | |
| <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} | |
| /> | |
| </div> | |
| </TabPane> | |
| <TabPane | |
| tab={ | |
| <div className='flex items-center gap-2'> | |
| <Wallet size={16} /> | |
| {t('额度充值')} | |
| </div> | |
| } | |
| itemKey='topup' | |
| > | |
| <div className='py-2'>{topupContent}</div> | |
| </TabPane> | |
| </Tabs> | |
| ) : ( | |
| topupContent | |
| )} | |
| </Card> | |
| ); | |
| }; | |
| export default RechargeCard; | |