RechargeCard.jsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useRef } from 'react';
  16. import {
  17. Avatar,
  18. Typography,
  19. Tag,
  20. Card,
  21. Button,
  22. Banner,
  23. Skeleton,
  24. Form,
  25. Space,
  26. Row,
  27. Col,
  28. Spin,
  29. Tooltip,
  30. } from '@douyinfe/semi-ui';
  31. import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
  32. import {
  33. CreditCard,
  34. Coins,
  35. Wallet,
  36. BarChart2,
  37. TrendingUp,
  38. Receipt,
  39. } from 'lucide-react';
  40. import { IconGift } from '@douyinfe/semi-icons';
  41. import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
  42. import { getCurrencyConfig } from '../../helpers/render';
  43. const { Text } = Typography;
  44. const RechargeCard = ({
  45. t,
  46. enableOnlineTopUp,
  47. enableStripeTopUp,
  48. enableCreemTopUp,
  49. creemProducts,
  50. creemPreTopUp,
  51. presetAmounts,
  52. selectedPreset,
  53. selectPresetAmount,
  54. formatLargeNumber,
  55. priceRatio,
  56. topUpCount,
  57. minTopUp,
  58. renderQuotaWithAmount,
  59. getAmount,
  60. setTopUpCount,
  61. setSelectedPreset,
  62. renderAmount,
  63. amountLoading,
  64. payMethods,
  65. preTopUp,
  66. paymentLoading,
  67. payWay,
  68. redemptionCode,
  69. setRedemptionCode,
  70. topUp,
  71. isSubmitting,
  72. topUpLink,
  73. openTopUpLink,
  74. userState,
  75. renderQuota,
  76. statusLoading,
  77. topupInfo,
  78. onOpenHistory,
  79. }) => {
  80. const onlineFormApiRef = useRef(null);
  81. const redeemFormApiRef = useRef(null);
  82. const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
  83. console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
  84. return (
  85. <Card className='!rounded-2xl shadow-sm border-0'>
  86. {/* 卡片头部 */}
  87. <div className='flex items-center justify-between mb-4'>
  88. <div className='flex items-center'>
  89. <Avatar size='small' color='blue' className='mr-3 shadow-md'>
  90. <CreditCard size={16} />
  91. </Avatar>
  92. <div>
  93. <Typography.Text className='text-lg font-medium'>
  94. {t('账户充值')}
  95. </Typography.Text>
  96. <div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
  97. </div>
  98. </div>
  99. <Button
  100. icon={<Receipt size={16} />}
  101. theme='solid'
  102. onClick={onOpenHistory}
  103. >
  104. {t('账单')}
  105. </Button>
  106. </div>
  107. <Space vertical style={{ width: '100%' }}>
  108. {/* 统计数据 */}
  109. <Card
  110. className='!rounded-xl w-full'
  111. cover={
  112. <div
  113. className='relative h-30'
  114. style={{
  115. '--palette-primary-darkerChannel': '37 99 235',
  116. backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
  117. backgroundSize: 'cover',
  118. backgroundPosition: 'center',
  119. backgroundRepeat: 'no-repeat',
  120. }}
  121. >
  122. <div className='relative z-10 h-full flex flex-col justify-between p-4'>
  123. <div className='flex justify-between items-center'>
  124. <Text strong style={{ color: 'white', fontSize: '16px' }}>
  125. {t('账户统计')}
  126. </Text>
  127. </div>
  128. {/* 统计数据 */}
  129. <div className='grid grid-cols-3 gap-6 mt-4'>
  130. {/* 当前余额 */}
  131. <div className='text-center'>
  132. <div
  133. className='text-base sm:text-2xl font-bold mb-2'
  134. style={{ color: 'white' }}
  135. >
  136. {renderQuota(userState?.user?.quota)}
  137. </div>
  138. <div className='flex items-center justify-center text-sm'>
  139. <Wallet
  140. size={14}
  141. className='mr-1'
  142. style={{ color: 'rgba(255,255,255,0.8)' }}
  143. />
  144. <Text
  145. style={{
  146. color: 'rgba(255,255,255,0.8)',
  147. fontSize: '12px',
  148. }}
  149. >
  150. {t('当前余额')}
  151. </Text>
  152. </div>
  153. </div>
  154. {/* 历史消耗 */}
  155. <div className='text-center'>
  156. <div
  157. className='text-base sm:text-2xl font-bold mb-2'
  158. style={{ color: 'white' }}
  159. >
  160. {renderQuota(userState?.user?.used_quota)}
  161. </div>
  162. <div className='flex items-center justify-center text-sm'>
  163. <TrendingUp
  164. size={14}
  165. className='mr-1'
  166. style={{ color: 'rgba(255,255,255,0.8)' }}
  167. />
  168. <Text
  169. style={{
  170. color: 'rgba(255,255,255,0.8)',
  171. fontSize: '12px',
  172. }}
  173. >
  174. {t('历史消耗')}
  175. </Text>
  176. </div>
  177. </div>
  178. {/* 请求次数 */}
  179. <div className='text-center'>
  180. <div
  181. className='text-base sm:text-2xl font-bold mb-2'
  182. style={{ color: 'white' }}
  183. >
  184. {userState?.user?.request_count || 0}
  185. </div>
  186. <div className='flex items-center justify-center text-sm'>
  187. <BarChart2
  188. size={14}
  189. className='mr-1'
  190. style={{ color: 'rgba(255,255,255,0.8)' }}
  191. />
  192. <Text
  193. style={{
  194. color: 'rgba(255,255,255,0.8)',
  195. fontSize: '12px',
  196. }}
  197. >
  198. {t('请求次数')}
  199. </Text>
  200. </div>
  201. </div>
  202. </div>
  203. </div>
  204. </div>
  205. }
  206. >
  207. {/* 在线充值表单 */}
  208. {statusLoading ? (
  209. <div className='py-8 flex justify-center'>
  210. <Spin size='large' />
  211. </div>
  212. ) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
  213. <Form
  214. getFormApi={(api) => (onlineFormApiRef.current = api)}
  215. initValues={{ topUpCount: topUpCount }}
  216. >
  217. <div className='space-y-6'>
  218. {(enableOnlineTopUp || enableStripeTopUp) && (
  219. <Row gutter={12}>
  220. <Col xs={24} sm={24} md={24} lg={10} xl={10}>
  221. <Form.InputNumber
  222. field='topUpCount'
  223. label={t('充值数量')}
  224. disabled={!enableOnlineTopUp && !enableStripeTopUp}
  225. placeholder={
  226. t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
  227. }
  228. value={topUpCount}
  229. min={minTopUp}
  230. max={999999999}
  231. step={1}
  232. precision={0}
  233. onChange={async (value) => {
  234. if (value && value >= 1) {
  235. setTopUpCount(value);
  236. setSelectedPreset(null);
  237. await getAmount(value);
  238. }
  239. }}
  240. onBlur={(e) => {
  241. const value = parseInt(e.target.value);
  242. if (!value || value < 1) {
  243. setTopUpCount(1);
  244. getAmount(1);
  245. }
  246. }}
  247. formatter={(value) => (value ? `${value}` : '')}
  248. parser={(value) =>
  249. value ? parseInt(value.replace(/[^\d]/g, '')) : 0
  250. }
  251. extraText={
  252. <Skeleton
  253. loading={showAmountSkeleton}
  254. active
  255. placeholder={
  256. <Skeleton.Title
  257. style={{
  258. width: 120,
  259. height: 20,
  260. borderRadius: 6,
  261. }}
  262. />
  263. }
  264. >
  265. <Text type='secondary' className='text-red-600'>
  266. {t('实付金额:')}
  267. <span style={{ color: 'red' }}>
  268. {renderAmount()}
  269. </span>
  270. </Text>
  271. </Skeleton>
  272. }
  273. style={{ width: '100%' }}
  274. />
  275. </Col>
  276. <Col xs={24} sm={24} md={24} lg={14} xl={14}>
  277. <Form.Slot label={t('选择支付方式')}>
  278. {payMethods && payMethods.length > 0 ? (
  279. <Space wrap>
  280. {payMethods.map((payMethod) => {
  281. const minTopupVal =
  282. Number(payMethod.min_topup) || 0;
  283. const isStripe = payMethod.type === 'stripe';
  284. const disabled =
  285. (!enableOnlineTopUp && !isStripe) ||
  286. (!enableStripeTopUp && isStripe) ||
  287. minTopupVal > Number(topUpCount || 0);
  288. const buttonEl = (
  289. <Button
  290. key={payMethod.type}
  291. theme='outline'
  292. type='tertiary'
  293. onClick={() => preTopUp(payMethod.type)}
  294. disabled={disabled}
  295. loading={
  296. paymentLoading && payWay === payMethod.type
  297. }
  298. icon={
  299. payMethod.type === 'alipay' ? (
  300. <SiAlipay size={18} color='#1677FF' />
  301. ) : payMethod.type === 'wxpay' ? (
  302. <SiWechat size={18} color='#07C160' />
  303. ) : payMethod.type === 'stripe' ? (
  304. <SiStripe size={18} color='#635BFF' />
  305. ) : (
  306. <CreditCard
  307. size={18}
  308. color={
  309. payMethod.color ||
  310. 'var(--semi-color-text-2)'
  311. }
  312. />
  313. )
  314. }
  315. className='!rounded-lg !px-4 !py-2'
  316. >
  317. {payMethod.name}
  318. </Button>
  319. );
  320. return disabled &&
  321. minTopupVal > Number(topUpCount || 0) ? (
  322. <Tooltip
  323. content={
  324. t('此支付方式最低充值金额为') +
  325. ' ' +
  326. minTopupVal
  327. }
  328. key={payMethod.type}
  329. >
  330. {buttonEl}
  331. </Tooltip>
  332. ) : (
  333. <React.Fragment key={payMethod.type}>
  334. {buttonEl}
  335. </React.Fragment>
  336. );
  337. })}
  338. </Space>
  339. ) : (
  340. <div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300'>
  341. {t('暂无可用的支付方式,请联系管理员配置')}
  342. </div>
  343. )}
  344. </Form.Slot>
  345. </Col>
  346. </Row>
  347. )}
  348. {(enableOnlineTopUp || enableStripeTopUp) && (
  349. <Form.Slot
  350. label={
  351. <div className='flex items-center gap-2'>
  352. <span>{t('选择充值额度')}</span>
  353. {(() => {
  354. const { symbol, rate, type } = getCurrencyConfig();
  355. if (type === 'USD') return null;
  356. return (
  357. <span
  358. style={{
  359. color: 'var(--semi-color-text-2)',
  360. fontSize: '12px',
  361. fontWeight: 'normal',
  362. }}
  363. >
  364. (1 $ = {rate.toFixed(2)} {symbol})
  365. </span>
  366. );
  367. })()}
  368. </div>
  369. }
  370. >
  371. <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
  372. {presetAmounts.map((preset, index) => {
  373. const discount =
  374. preset.discount ||
  375. topupInfo?.discount?.[preset.value] ||
  376. 1.0;
  377. const originalPrice = preset.value * priceRatio;
  378. const discountedPrice = originalPrice * discount;
  379. const hasDiscount = discount < 1.0;
  380. const actualPay = discountedPrice;
  381. const save = originalPrice - discountedPrice;
  382. // 根据当前货币类型换算显示金额和数量
  383. const { symbol, rate, type } = getCurrencyConfig();
  384. const statusStr = localStorage.getItem('status');
  385. let usdRate = 7; // 默认CNY汇率
  386. try {
  387. if (statusStr) {
  388. const s = JSON.parse(statusStr);
  389. usdRate = s?.usd_exchange_rate || 7;
  390. }
  391. } catch (e) {}
  392. let displayValue = preset.value; // 显示的数量
  393. let displayActualPay = actualPay;
  394. let displaySave = save;
  395. if (type === 'USD') {
  396. // 数量保持USD,价格从CNY转USD
  397. displayActualPay = actualPay / usdRate;
  398. displaySave = save / usdRate;
  399. } else if (type === 'CNY') {
  400. // 数量转CNY,价格已是CNY
  401. displayValue = preset.value * usdRate;
  402. } else if (type === 'CUSTOM') {
  403. // 数量和价格都转自定义货币
  404. displayValue = preset.value * rate;
  405. displayActualPay = (actualPay / usdRate) * rate;
  406. displaySave = (save / usdRate) * rate;
  407. }
  408. return (
  409. <Card
  410. key={index}
  411. style={{
  412. cursor: 'pointer',
  413. border:
  414. selectedPreset === preset.value
  415. ? '2px solid var(--semi-color-primary)'
  416. : '1px solid var(--semi-color-border)',
  417. height: '100%',
  418. width: '100%',
  419. }}
  420. bodyStyle={{ padding: '12px' }}
  421. onClick={() => {
  422. selectPresetAmount(preset);
  423. onlineFormApiRef.current?.setValue(
  424. 'topUpCount',
  425. preset.value,
  426. );
  427. }}
  428. >
  429. <div style={{ textAlign: 'center' }}>
  430. <Typography.Title
  431. heading={6}
  432. style={{ margin: '0 0 8px 0' }}
  433. >
  434. <Coins size={18} />
  435. {formatLargeNumber(displayValue)} {symbol}
  436. {hasDiscount && (
  437. <Tag style={{ marginLeft: 4 }} color='green'>
  438. {t('折').includes('off')
  439. ? (
  440. (1 - parseFloat(discount)) *
  441. 100
  442. ).toFixed(1)
  443. : (discount * 10).toFixed(1)}
  444. {t('折')}
  445. </Tag>
  446. )}
  447. </Typography.Title>
  448. <div
  449. style={{
  450. color: 'var(--semi-color-text-2)',
  451. fontSize: '12px',
  452. margin: '4px 0',
  453. }}
  454. >
  455. {t('实付')} {symbol}
  456. {displayActualPay.toFixed(2)},
  457. {hasDiscount
  458. ? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
  459. : `${t('节省')} ${symbol}0.00`}
  460. </div>
  461. </div>
  462. </Card>
  463. );
  464. })}
  465. </div>
  466. </Form.Slot>
  467. )}
  468. {/* Creem 充值区域 */}
  469. {enableCreemTopUp && creemProducts.length > 0 && (
  470. <Form.Slot label={t('Creem 充值')}>
  471. <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3'>
  472. {creemProducts.map((product, index) => (
  473. <Card
  474. key={index}
  475. onClick={() => creemPreTopUp(product)}
  476. className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
  477. bodyStyle={{ textAlign: 'center', padding: '16px' }}
  478. >
  479. <div className='font-medium text-lg mb-2'>
  480. {product.name}
  481. </div>
  482. <div className='text-sm text-gray-600 mb-2'>
  483. {t('充值额度')}: {product.quota}
  484. </div>
  485. <div className='text-lg font-semibold text-blue-600'>
  486. {product.currency === 'EUR' ? '€' : '$'}{product.price}
  487. </div>
  488. </Card>
  489. ))}
  490. </div>
  491. </Form.Slot>
  492. )}
  493. </div>
  494. </Form>
  495. ) : (
  496. <Banner
  497. type='info'
  498. description={t(
  499. '管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。',
  500. )}
  501. className='!rounded-xl'
  502. closeIcon={null}
  503. />
  504. )}
  505. </Card>
  506. {/* 兑换码充值 */}
  507. <Card
  508. className='!rounded-xl w-full'
  509. title={
  510. <Text type='tertiary' strong>
  511. {t('兑换码充值')}
  512. </Text>
  513. }
  514. >
  515. <Form
  516. getFormApi={(api) => (redeemFormApiRef.current = api)}
  517. initValues={{ redemptionCode: redemptionCode }}
  518. >
  519. <Form.Input
  520. field='redemptionCode'
  521. noLabel={true}
  522. placeholder={t('请输入兑换码')}
  523. value={redemptionCode}
  524. onChange={(value) => setRedemptionCode(value)}
  525. prefix={<IconGift />}
  526. suffix={
  527. <div className='flex items-center gap-2'>
  528. <Button
  529. type='primary'
  530. theme='solid'
  531. onClick={topUp}
  532. loading={isSubmitting}
  533. >
  534. {t('兑换额度')}
  535. </Button>
  536. </div>
  537. }
  538. showClear
  539. style={{ width: '100%' }}
  540. extraText={
  541. topUpLink && (
  542. <Text type='tertiary'>
  543. {t('在找兑换码?')}
  544. <Text
  545. type='secondary'
  546. underline
  547. className='cursor-pointer'
  548. onClick={openTopUpLink}
  549. >
  550. {t('购买兑换码')}
  551. </Text>
  552. </Text>
  553. )
  554. }
  555. />
  556. </Form>
  557. </Card>
  558. </Space>
  559. </Card>
  560. );
  561. };
  562. export default RechargeCard;