| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554 |
- /*
- 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,
- 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);
- 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 ? (
- <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(preset.value)} $
- {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>
- )}
- </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;
|