index.jsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  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) ? normalizedMinTopup : 0;
  259. // Stripe 的最小充值从后端字段回填
  260. if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
  261. const stripeMin = Number(data.stripe_min_topup);
  262. if (Number.isFinite(stripeMin)) {
  263. method.min_topup = stripeMin;
  264. }
  265. }
  266. if (!method.color) {
  267. if (method.type === 'alipay') {
  268. method.color = 'rgba(var(--semi-blue-5), 1)';
  269. } else if (method.type === 'wxpay') {
  270. method.color = 'rgba(var(--semi-green-5), 1)';
  271. } else if (method.type === 'stripe') {
  272. method.color = 'rgba(var(--semi-purple-5), 1)';
  273. } else {
  274. method.color = 'rgba(var(--semi-primary-5), 1)';
  275. }
  276. }
  277. return method;
  278. });
  279. } else {
  280. payMethods = [];
  281. }
  282. // 如果启用了 Stripe 支付,添加到支付方法列表
  283. // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
  284. setPayMethods(payMethods);
  285. const enableStripeTopUp = data.enable_stripe_topup || false;
  286. const enableOnlineTopUp = data.enable_online_topup || false;
  287. const minTopUpValue = enableOnlineTopUp? data.min_topup : enableStripeTopUp? data.stripe_min_topup : 1;
  288. setEnableOnlineTopUp(enableOnlineTopUp);
  289. setEnableStripeTopUp(enableStripeTopUp);
  290. setMinTopUp(minTopUpValue);
  291. setTopUpCount(minTopUpValue);
  292. // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
  293. if (topupInfo.amount_options.length === 0) {
  294. setPresetAmounts(generatePresetAmounts(minTopUpValue));
  295. }
  296. // 初始化显示实付金额
  297. getAmount(minTopUpValue);
  298. } catch (e) {
  299. console.log('解析支付方式失败:', e);
  300. setPayMethods([]);
  301. }
  302. // 如果有自定义充值数量选项,使用它们替换默认的预设选项
  303. if (data.amount_options && data.amount_options.length > 0) {
  304. const customPresets = data.amount_options.map(amount => ({
  305. value: amount,
  306. discount: data.discount[amount] || 1.0
  307. }));
  308. setPresetAmounts(customPresets);
  309. }
  310. } else {
  311. console.error('获取充值配置失败:', data);
  312. }
  313. } catch (error) {
  314. console.error('获取充值配置异常:', error);
  315. }
  316. };
  317. // 获取邀请链接
  318. const getAffLink = async () => {
  319. const res = await API.get('/api/user/aff');
  320. const { success, message, data } = res.data;
  321. if (success) {
  322. let link = `${window.location.origin}/register?aff=${data}`;
  323. setAffLink(link);
  324. } else {
  325. showError(message);
  326. }
  327. };
  328. // 划转邀请额度
  329. const transfer = async () => {
  330. if (transferAmount < getQuotaPerUnit()) {
  331. showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
  332. return;
  333. }
  334. const res = await API.post(`/api/user/aff_transfer`, {
  335. quota: transferAmount,
  336. });
  337. const { success, message } = res.data;
  338. if (success) {
  339. showSuccess(message);
  340. setOpenTransfer(false);
  341. getUserQuota().then();
  342. } else {
  343. showError(message);
  344. }
  345. };
  346. // 复制邀请链接
  347. const handleAffLinkClick = async () => {
  348. await copy(affLink);
  349. showSuccess(t('邀请链接已复制到剪切板'));
  350. };
  351. useEffect(() => {
  352. if (!userState?.user?.id) {
  353. getUserQuota().then();
  354. }
  355. setTransferAmount(getQuotaPerUnit());
  356. }, []);
  357. useEffect(() => {
  358. if (affFetchedRef.current) return;
  359. affFetchedRef.current = true;
  360. getAffLink().then();
  361. }, []);
  362. // 在 statusState 可用时获取充值信息
  363. useEffect(() => {
  364. getTopupInfo().then();
  365. }, []);
  366. useEffect(() => {
  367. if (statusState?.status) {
  368. // const minTopUpValue = statusState.status.min_topup || 1;
  369. // setMinTopUp(minTopUpValue);
  370. // setTopUpCount(minTopUpValue);
  371. setTopUpLink(statusState.status.top_up_link || '');
  372. setPriceRatio(statusState.status.price || 1);
  373. setStatusLoading(false);
  374. }
  375. }, [statusState?.status]);
  376. const renderAmount = () => {
  377. return amount + ' ' + t('元');
  378. };
  379. const getAmount = async (value) => {
  380. if (value === undefined) {
  381. value = topUpCount;
  382. }
  383. setAmountLoading(true);
  384. try {
  385. const res = await API.post('/api/user/amount', {
  386. amount: parseFloat(value),
  387. });
  388. if (res !== undefined) {
  389. const { message, data } = res.data;
  390. if (message === 'success') {
  391. setAmount(parseFloat(data));
  392. } else {
  393. setAmount(0);
  394. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  395. }
  396. } else {
  397. showError(res);
  398. }
  399. } catch (err) {
  400. console.log(err);
  401. }
  402. setAmountLoading(false);
  403. };
  404. const getStripeAmount = async (value) => {
  405. if (value === undefined) {
  406. value = topUpCount;
  407. }
  408. setAmountLoading(true);
  409. try {
  410. const res = await API.post('/api/user/stripe/amount', {
  411. amount: parseFloat(value),
  412. });
  413. if (res !== undefined) {
  414. const { message, data } = res.data;
  415. if (message === 'success') {
  416. setAmount(parseFloat(data));
  417. } else {
  418. setAmount(0);
  419. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  420. }
  421. } else {
  422. showError(res);
  423. }
  424. } catch (err) {
  425. console.log(err);
  426. } finally {
  427. setAmountLoading(false);
  428. }
  429. };
  430. const handleCancel = () => {
  431. setOpen(false);
  432. };
  433. const handleTransferCancel = () => {
  434. setOpenTransfer(false);
  435. };
  436. // 选择预设充值额度
  437. const selectPresetAmount = (preset) => {
  438. setTopUpCount(preset.value);
  439. setSelectedPreset(preset.value);
  440. // 计算实际支付金额,考虑折扣
  441. const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
  442. const discountedAmount = preset.value * priceRatio * discount;
  443. setAmount(discountedAmount);
  444. };
  445. // 格式化大数字显示
  446. const formatLargeNumber = (num) => {
  447. return num.toString();
  448. };
  449. // 根据最小充值金额生成预设充值额度选项
  450. const generatePresetAmounts = (minAmount) => {
  451. const multipliers = [1, 5, 10, 30, 50, 100, 300, 500];
  452. return multipliers.map((multiplier) => ({
  453. value: minAmount * multiplier,
  454. }));
  455. };
  456. return (
  457. <div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'>
  458. {/* 划转模态框 */}
  459. <TransferModal
  460. t={t}
  461. openTransfer={openTransfer}
  462. transfer={transfer}
  463. handleTransferCancel={handleTransferCancel}
  464. userState={userState}
  465. renderQuota={renderQuota}
  466. getQuotaPerUnit={getQuotaPerUnit}
  467. transferAmount={transferAmount}
  468. setTransferAmount={setTransferAmount}
  469. />
  470. {/* 充值确认模态框 */}
  471. <PaymentConfirmModal
  472. t={t}
  473. open={open}
  474. onlineTopUp={onlineTopUp}
  475. handleCancel={handleCancel}
  476. confirmLoading={confirmLoading}
  477. topUpCount={topUpCount}
  478. renderQuotaWithAmount={renderQuotaWithAmount}
  479. amountLoading={amountLoading}
  480. renderAmount={renderAmount}
  481. payWay={payWay}
  482. payMethods={payMethods}
  483. amountNumber={amount}
  484. discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
  485. />
  486. {/* 用户信息头部 */}
  487. <div className='space-y-6'>
  488. <div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
  489. {/* 左侧充值区域 */}
  490. <div className='lg:col-span-7 space-y-6 w-full'>
  491. <RechargeCard
  492. t={t}
  493. enableOnlineTopUp={enableOnlineTopUp}
  494. enableStripeTopUp={enableStripeTopUp}
  495. presetAmounts={presetAmounts}
  496. selectedPreset={selectedPreset}
  497. selectPresetAmount={selectPresetAmount}
  498. formatLargeNumber={formatLargeNumber}
  499. priceRatio={priceRatio}
  500. topUpCount={topUpCount}
  501. minTopUp={minTopUp}
  502. renderQuotaWithAmount={renderQuotaWithAmount}
  503. getAmount={getAmount}
  504. setTopUpCount={setTopUpCount}
  505. setSelectedPreset={setSelectedPreset}
  506. renderAmount={renderAmount}
  507. amountLoading={amountLoading}
  508. payMethods={payMethods}
  509. preTopUp={preTopUp}
  510. paymentLoading={paymentLoading}
  511. payWay={payWay}
  512. redemptionCode={redemptionCode}
  513. setRedemptionCode={setRedemptionCode}
  514. topUp={topUp}
  515. isSubmitting={isSubmitting}
  516. topUpLink={topUpLink}
  517. openTopUpLink={openTopUpLink}
  518. userState={userState}
  519. renderQuota={renderQuota}
  520. statusLoading={statusLoading}
  521. topupInfo={topupInfo}
  522. />
  523. </div>
  524. {/* 右侧信息区域 */}
  525. <div className='lg:col-span-5'>
  526. <InvitationCard
  527. t={t}
  528. userState={userState}
  529. renderQuota={renderQuota}
  530. setOpenTransfer={setOpenTransfer}
  531. affLink={affLink}
  532. handleAffLinkClick={handleAffLinkClick}
  533. />
  534. </div>
  535. </div>
  536. </div>
  537. </div>
  538. );
  539. };
  540. export default TopUp;