index.jsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  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, { useEffect, useState, useContext, useRef } from 'react';
  16. import {
  17. API,
  18. showError,
  19. showInfo,
  20. showSuccess,
  21. renderQuota,
  22. renderQuotaWithAmount,
  23. copy,
  24. getQuotaPerUnit,
  25. } from '../../helpers';
  26. import { Modal, Toast } from '@douyinfe/semi-ui';
  27. import { useTranslation } from 'react-i18next';
  28. import { UserContext } from '../../context/User';
  29. import { StatusContext } from '../../context/Status';
  30. import RechargeCard from './RechargeCard';
  31. import InvitationCard from './InvitationCard';
  32. import TransferModal from './modals/TransferModal';
  33. import PaymentConfirmModal from './modals/PaymentConfirmModal';
  34. const TopUp = () => {
  35. const { t } = useTranslation();
  36. const [userState, userDispatch] = useContext(UserContext);
  37. const [statusState] = useContext(StatusContext);
  38. const [redemptionCode, setRedemptionCode] = useState('');
  39. const [amount, setAmount] = useState(0.0);
  40. const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
  41. const [topUpCount, setTopUpCount] = useState(
  42. statusState?.status?.min_topup || 1,
  43. );
  44. const [topUpLink, setTopUpLink] = useState(
  45. statusState?.status?.top_up_link || '',
  46. );
  47. const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(
  48. statusState?.status?.enable_online_topup || false,
  49. );
  50. const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
  51. const [enableStripeTopUp, setEnableStripeTopUp] = useState(
  52. statusState?.status?.enable_stripe_topup || false,
  53. );
  54. const [statusLoading, setStatusLoading] = useState(true);
  55. const [isSubmitting, setIsSubmitting] = useState(false);
  56. const [open, setOpen] = useState(false);
  57. const [payWay, setPayWay] = useState('');
  58. const [amountLoading, setAmountLoading] = useState(false);
  59. const [paymentLoading, setPaymentLoading] = useState(false);
  60. const [confirmLoading, setConfirmLoading] = useState(false);
  61. const [payMethods, setPayMethods] = useState([]);
  62. const affFetchedRef = useRef(false);
  63. // 邀请相关状态
  64. const [affLink, setAffLink] = useState('');
  65. const [openTransfer, setOpenTransfer] = useState(false);
  66. const [transferAmount, setTransferAmount] = useState(0);
  67. // 预设充值额度选项
  68. const [presetAmounts, setPresetAmounts] = useState([]);
  69. const [selectedPreset, setSelectedPreset] = useState(null);
  70. // 充值配置信息
  71. const [topupInfo, setTopupInfo] = useState({
  72. amount_options: [],
  73. discount: {},
  74. });
  75. const topUp = async () => {
  76. if (redemptionCode === '') {
  77. showInfo(t('请输入兑换码!'));
  78. return;
  79. }
  80. setIsSubmitting(true);
  81. try {
  82. const res = await API.post('/api/user/topup', {
  83. key: redemptionCode,
  84. });
  85. const { success, message, data } = res.data;
  86. if (success) {
  87. showSuccess(t('兑换成功!'));
  88. Modal.success({
  89. title: t('兑换成功!'),
  90. content: t('成功兑换额度:') + renderQuota(data),
  91. centered: true,
  92. });
  93. if (userState.user) {
  94. const updatedUser = {
  95. ...userState.user,
  96. quota: userState.user.quota + data,
  97. };
  98. userDispatch({ type: 'login', payload: updatedUser });
  99. }
  100. setRedemptionCode('');
  101. } else {
  102. showError(message);
  103. }
  104. } catch (err) {
  105. showError(t('请求失败'));
  106. } finally {
  107. setIsSubmitting(false);
  108. }
  109. };
  110. const openTopUpLink = () => {
  111. if (!topUpLink) {
  112. showError(t('超级管理员未设置充值链接!'));
  113. return;
  114. }
  115. window.open(topUpLink, '_blank');
  116. };
  117. const preTopUp = async (payment) => {
  118. if (payment === 'stripe') {
  119. if (!enableStripeTopUp) {
  120. showError(t('管理员未开启Stripe充值!'));
  121. return;
  122. }
  123. } else {
  124. if (!enableOnlineTopUp) {
  125. showError(t('管理员未开启在线充值!'));
  126. return;
  127. }
  128. }
  129. setPayWay(payment);
  130. setPaymentLoading(true);
  131. try {
  132. if (payment === 'stripe') {
  133. await getStripeAmount();
  134. } else {
  135. await getAmount();
  136. }
  137. if (topUpCount < minTopUp) {
  138. showError(t('充值数量不能小于') + minTopUp);
  139. return;
  140. }
  141. setOpen(true);
  142. } catch (error) {
  143. showError(t('获取金额失败'));
  144. } finally {
  145. setPaymentLoading(false);
  146. }
  147. };
  148. const onlineTopUp = async () => {
  149. if (payWay === 'stripe') {
  150. // Stripe 支付处理
  151. if (amount === 0) {
  152. await getStripeAmount();
  153. }
  154. } else {
  155. // 普通支付处理
  156. if (amount === 0) {
  157. await getAmount();
  158. }
  159. }
  160. if (topUpCount < minTopUp) {
  161. showError('充值数量不能小于' + minTopUp);
  162. return;
  163. }
  164. setConfirmLoading(true);
  165. try {
  166. let res;
  167. if (payWay === 'stripe') {
  168. // Stripe 支付请求
  169. res = await API.post('/api/user/stripe/pay', {
  170. amount: parseInt(topUpCount),
  171. payment_method: 'stripe',
  172. });
  173. } else {
  174. // 普通支付请求
  175. res = await API.post('/api/user/pay', {
  176. amount: parseInt(topUpCount),
  177. payment_method: payWay,
  178. });
  179. }
  180. if (res !== undefined) {
  181. const { message, data } = res.data;
  182. if (message === 'success') {
  183. if (payWay === 'stripe') {
  184. // Stripe 支付回调处理
  185. window.open(data.pay_link, '_blank');
  186. } else {
  187. // 普通支付表单提交
  188. let params = data;
  189. let url = res.data.url;
  190. let form = document.createElement('form');
  191. form.action = url;
  192. form.method = 'POST';
  193. let isSafari =
  194. navigator.userAgent.indexOf('Safari') > -1 &&
  195. navigator.userAgent.indexOf('Chrome') < 1;
  196. if (!isSafari) {
  197. form.target = '_blank';
  198. }
  199. for (let key in params) {
  200. let input = document.createElement('input');
  201. input.type = 'hidden';
  202. input.name = key;
  203. input.value = params[key];
  204. form.appendChild(input);
  205. }
  206. document.body.appendChild(form);
  207. form.submit();
  208. document.body.removeChild(form);
  209. }
  210. } else {
  211. showError(data);
  212. }
  213. } else {
  214. showError(res);
  215. }
  216. } catch (err) {
  217. console.log(err);
  218. showError(t('支付请求失败'));
  219. } finally {
  220. setOpen(false);
  221. setConfirmLoading(false);
  222. }
  223. };
  224. const getUserQuota = async () => {
  225. let res = await API.get(`/api/user/self`);
  226. const { success, message, data } = res.data;
  227. if (success) {
  228. userDispatch({ type: 'login', payload: data });
  229. } else {
  230. showError(message);
  231. }
  232. };
  233. // 获取充值配置信息
  234. const getTopupInfo = async () => {
  235. try {
  236. const res = await API.get('/api/user/topup/info');
  237. const { message, data, success } = res.data;
  238. if (success) {
  239. setTopupInfo({
  240. amount_options: data.amount_options || [],
  241. discount: data.discount || {},
  242. });
  243. // 处理支付方式
  244. let payMethods = data.pay_methods || [];
  245. try {
  246. if (typeof payMethods === 'string') {
  247. payMethods = JSON.parse(payMethods);
  248. }
  249. if (payMethods && payMethods.length > 0) {
  250. // 检查name和type是否为空
  251. payMethods = payMethods.filter((method) => {
  252. return method.name && method.type;
  253. });
  254. // 如果没有color,则设置默认颜色
  255. payMethods = payMethods.map((method) => {
  256. // 规范化最小充值数
  257. const normalizedMinTopup = Number(method.min_topup);
  258. method.min_topup = Number.isFinite(normalizedMinTopup)
  259. ? normalizedMinTopup
  260. : 0;
  261. // Stripe 的最小充值从后端字段回填
  262. if (
  263. method.type === 'stripe' &&
  264. (!method.min_topup || method.min_topup <= 0)
  265. ) {
  266. const stripeMin = Number(data.stripe_min_topup);
  267. if (Number.isFinite(stripeMin)) {
  268. method.min_topup = stripeMin;
  269. }
  270. }
  271. if (!method.color) {
  272. if (method.type === 'alipay') {
  273. method.color = 'rgba(var(--semi-blue-5), 1)';
  274. } else if (method.type === 'wxpay') {
  275. method.color = 'rgba(var(--semi-green-5), 1)';
  276. } else if (method.type === 'stripe') {
  277. method.color = 'rgba(var(--semi-purple-5), 1)';
  278. } else {
  279. method.color = 'rgba(var(--semi-primary-5), 1)';
  280. }
  281. }
  282. return method;
  283. });
  284. } else {
  285. payMethods = [];
  286. }
  287. // 如果启用了 Stripe 支付,添加到支付方法列表
  288. // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
  289. setPayMethods(payMethods);
  290. const enableStripeTopUp = data.enable_stripe_topup || false;
  291. const enableOnlineTopUp = data.enable_online_topup || false;
  292. const minTopUpValue = enableOnlineTopUp
  293. ? data.min_topup
  294. : enableStripeTopUp
  295. ? data.stripe_min_topup
  296. : 1;
  297. setEnableOnlineTopUp(enableOnlineTopUp);
  298. setEnableStripeTopUp(enableStripeTopUp);
  299. setMinTopUp(minTopUpValue);
  300. setTopUpCount(minTopUpValue);
  301. // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
  302. if (topupInfo.amount_options.length === 0) {
  303. setPresetAmounts(generatePresetAmounts(minTopUpValue));
  304. }
  305. // 初始化显示实付金额
  306. getAmount(minTopUpValue);
  307. } catch (e) {
  308. console.log('解析支付方式失败:', e);
  309. setPayMethods([]);
  310. }
  311. // 如果有自定义充值数量选项,使用它们替换默认的预设选项
  312. if (data.amount_options && data.amount_options.length > 0) {
  313. const customPresets = data.amount_options.map((amount) => ({
  314. value: amount,
  315. discount: data.discount[amount] || 1.0,
  316. }));
  317. setPresetAmounts(customPresets);
  318. }
  319. } else {
  320. console.error('获取充值配置失败:', data);
  321. }
  322. } catch (error) {
  323. console.error('获取充值配置异常:', error);
  324. }
  325. };
  326. // 获取邀请链接
  327. const getAffLink = async () => {
  328. const res = await API.get('/api/user/aff');
  329. const { success, message, data } = res.data;
  330. if (success) {
  331. let link = `${window.location.origin}/register?aff=${data}`;
  332. setAffLink(link);
  333. } else {
  334. showError(message);
  335. }
  336. };
  337. // 划转邀请额度
  338. const transfer = async () => {
  339. if (transferAmount < getQuotaPerUnit()) {
  340. showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
  341. return;
  342. }
  343. const res = await API.post(`/api/user/aff_transfer`, {
  344. quota: transferAmount,
  345. });
  346. const { success, message } = res.data;
  347. if (success) {
  348. showSuccess(message);
  349. setOpenTransfer(false);
  350. getUserQuota().then();
  351. } else {
  352. showError(message);
  353. }
  354. };
  355. // 复制邀请链接
  356. const handleAffLinkClick = async () => {
  357. await copy(affLink);
  358. showSuccess(t('邀请链接已复制到剪切板'));
  359. };
  360. useEffect(() => {
  361. if (!userState?.user?.id) {
  362. getUserQuota().then();
  363. }
  364. setTransferAmount(getQuotaPerUnit());
  365. }, []);
  366. useEffect(() => {
  367. if (affFetchedRef.current) return;
  368. affFetchedRef.current = true;
  369. getAffLink().then();
  370. }, []);
  371. // 在 statusState 可用时获取充值信息
  372. useEffect(() => {
  373. getTopupInfo().then();
  374. }, []);
  375. useEffect(() => {
  376. if (statusState?.status) {
  377. // const minTopUpValue = statusState.status.min_topup || 1;
  378. // setMinTopUp(minTopUpValue);
  379. // setTopUpCount(minTopUpValue);
  380. setTopUpLink(statusState.status.top_up_link || '');
  381. setPriceRatio(statusState.status.price || 1);
  382. setStatusLoading(false);
  383. }
  384. }, [statusState?.status]);
  385. const renderAmount = () => {
  386. return amount + ' ' + t('元');
  387. };
  388. const getAmount = async (value) => {
  389. if (value === undefined) {
  390. value = topUpCount;
  391. }
  392. setAmountLoading(true);
  393. try {
  394. const res = await API.post('/api/user/amount', {
  395. amount: parseFloat(value),
  396. });
  397. if (res !== undefined) {
  398. const { message, data } = res.data;
  399. if (message === 'success') {
  400. setAmount(parseFloat(data));
  401. } else {
  402. setAmount(0);
  403. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  404. }
  405. } else {
  406. showError(res);
  407. }
  408. } catch (err) {
  409. console.log(err);
  410. }
  411. setAmountLoading(false);
  412. };
  413. const getStripeAmount = async (value) => {
  414. if (value === undefined) {
  415. value = topUpCount;
  416. }
  417. setAmountLoading(true);
  418. try {
  419. const res = await API.post('/api/user/stripe/amount', {
  420. amount: parseFloat(value),
  421. });
  422. if (res !== undefined) {
  423. const { message, data } = res.data;
  424. if (message === 'success') {
  425. setAmount(parseFloat(data));
  426. } else {
  427. setAmount(0);
  428. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  429. }
  430. } else {
  431. showError(res);
  432. }
  433. } catch (err) {
  434. console.log(err);
  435. } finally {
  436. setAmountLoading(false);
  437. }
  438. };
  439. const handleCancel = () => {
  440. setOpen(false);
  441. };
  442. const handleTransferCancel = () => {
  443. setOpenTransfer(false);
  444. };
  445. // 选择预设充值额度
  446. const selectPresetAmount = (preset) => {
  447. setTopUpCount(preset.value);
  448. setSelectedPreset(preset.value);
  449. // 计算实际支付金额,考虑折扣
  450. const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
  451. const discountedAmount = preset.value * priceRatio * discount;
  452. setAmount(discountedAmount);
  453. };
  454. // 格式化大数字显示
  455. const formatLargeNumber = (num) => {
  456. return num.toString();
  457. };
  458. // 根据最小充值金额生成预设充值额度选项
  459. const generatePresetAmounts = (minAmount) => {
  460. const multipliers = [1, 5, 10, 30, 50, 100, 300, 500];
  461. return multipliers.map((multiplier) => ({
  462. value: minAmount * multiplier,
  463. }));
  464. };
  465. return (
  466. <div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'>
  467. {/* 划转模态框 */}
  468. <TransferModal
  469. t={t}
  470. openTransfer={openTransfer}
  471. transfer={transfer}
  472. handleTransferCancel={handleTransferCancel}
  473. userState={userState}
  474. renderQuota={renderQuota}
  475. getQuotaPerUnit={getQuotaPerUnit}
  476. transferAmount={transferAmount}
  477. setTransferAmount={setTransferAmount}
  478. />
  479. {/* 充值确认模态框 */}
  480. <PaymentConfirmModal
  481. t={t}
  482. open={open}
  483. onlineTopUp={onlineTopUp}
  484. handleCancel={handleCancel}
  485. confirmLoading={confirmLoading}
  486. topUpCount={topUpCount}
  487. renderQuotaWithAmount={renderQuotaWithAmount}
  488. amountLoading={amountLoading}
  489. renderAmount={renderAmount}
  490. payWay={payWay}
  491. payMethods={payMethods}
  492. amountNumber={amount}
  493. discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
  494. />
  495. {/* 用户信息头部 */}
  496. <div className='space-y-6'>
  497. <div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
  498. {/* 左侧充值区域 */}
  499. <div className='lg:col-span-7 space-y-6 w-full'>
  500. <RechargeCard
  501. t={t}
  502. enableOnlineTopUp={enableOnlineTopUp}
  503. enableStripeTopUp={enableStripeTopUp}
  504. presetAmounts={presetAmounts}
  505. selectedPreset={selectedPreset}
  506. selectPresetAmount={selectPresetAmount}
  507. formatLargeNumber={formatLargeNumber}
  508. priceRatio={priceRatio}
  509. topUpCount={topUpCount}
  510. minTopUp={minTopUp}
  511. renderQuotaWithAmount={renderQuotaWithAmount}
  512. getAmount={getAmount}
  513. setTopUpCount={setTopUpCount}
  514. setSelectedPreset={setSelectedPreset}
  515. renderAmount={renderAmount}
  516. amountLoading={amountLoading}
  517. payMethods={payMethods}
  518. preTopUp={preTopUp}
  519. paymentLoading={paymentLoading}
  520. payWay={payWay}
  521. redemptionCode={redemptionCode}
  522. setRedemptionCode={setRedemptionCode}
  523. topUp={topUp}
  524. isSubmitting={isSubmitting}
  525. topUpLink={topUpLink}
  526. openTopUpLink={openTopUpLink}
  527. userState={userState}
  528. renderQuota={renderQuota}
  529. statusLoading={statusLoading}
  530. topupInfo={topupInfo}
  531. />
  532. </div>
  533. {/* 右侧信息区域 */}
  534. <div className='lg:col-span-5'>
  535. <InvitationCard
  536. t={t}
  537. userState={userState}
  538. renderQuota={renderQuota}
  539. setOpenTransfer={setOpenTransfer}
  540. affLink={affLink}
  541. handleAffLinkClick={handleAffLinkClick}
  542. />
  543. </div>
  544. </div>
  545. </div>
  546. </div>
  547. );
  548. };
  549. export default TopUp;