index.jsx 30 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  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 { useSearchParams } from 'react-router-dom';
  17. import {
  18. API,
  19. showError,
  20. showInfo,
  21. showSuccess,
  22. renderQuota,
  23. renderQuotaWithAmount,
  24. copy,
  25. getQuotaPerUnit,
  26. } from '../../helpers';
  27. import { Modal, Toast } from '@douyinfe/semi-ui';
  28. import { useTranslation } from 'react-i18next';
  29. import { UserContext } from '../../context/User';
  30. import { StatusContext } from '../../context/Status';
  31. import RechargeCard from './RechargeCard';
  32. import InvitationCard from './InvitationCard';
  33. import TransferModal from './modals/TransferModal';
  34. import PaymentConfirmModal from './modals/PaymentConfirmModal';
  35. import TopupHistoryModal from './modals/TopupHistoryModal';
  36. const TopUp = () => {
  37. const { t } = useTranslation();
  38. const [searchParams, setSearchParams] = useSearchParams();
  39. const [userState, userDispatch] = useContext(UserContext);
  40. const [statusState] = useContext(StatusContext);
  41. const [redemptionCode, setRedemptionCode] = useState('');
  42. const [amount, setAmount] = useState(0.0);
  43. const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
  44. const [topUpCount, setTopUpCount] = useState(
  45. statusState?.status?.min_topup || 1,
  46. );
  47. const [topUpLink, setTopUpLink] = useState('');
  48. const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(
  49. statusState?.status?.enable_online_topup || false,
  50. );
  51. const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
  52. const [enableStripeTopUp, setEnableStripeTopUp] = useState(
  53. statusState?.status?.enable_stripe_topup || false,
  54. );
  55. const [statusLoading, setStatusLoading] = useState(true);
  56. // Creem 相关状态
  57. const [creemProducts, setCreemProducts] = useState([]);
  58. const [enableCreemTopUp, setEnableCreemTopUp] = useState(false);
  59. const [creemOpen, setCreemOpen] = useState(false);
  60. const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
  61. // Waffo 相关状态
  62. const [enableWaffoTopUp, setEnableWaffoTopUp] = useState(false);
  63. const [waffoPayMethods, setWaffoPayMethods] = useState([]);
  64. const [waffoMinTopUp, setWaffoMinTopUp] = useState(1);
  65. const [enableWaffoPancakeTopUp, setEnableWaffoPancakeTopUp] = useState(false);
  66. const [waffoPancakeMinTopUp, setWaffoPancakeMinTopUp] = useState(1);
  67. const [isSubmitting, setIsSubmitting] = useState(false);
  68. const [open, setOpen] = useState(false);
  69. const [payWay, setPayWay] = useState('');
  70. const [amountLoading, setAmountLoading] = useState(false);
  71. const [paymentLoading, setPaymentLoading] = useState(false);
  72. const [confirmLoading, setConfirmLoading] = useState(false);
  73. const [payMethods, setPayMethods] = useState([]);
  74. const affFetchedRef = useRef(false);
  75. // 邀请相关状态
  76. const [affLink, setAffLink] = useState('');
  77. const [openTransfer, setOpenTransfer] = useState(false);
  78. const [transferAmount, setTransferAmount] = useState(0);
  79. // 账单Modal状态
  80. const [openHistory, setOpenHistory] = useState(false);
  81. // 订阅相关
  82. const [subscriptionPlans, setSubscriptionPlans] = useState([]);
  83. const [subscriptionLoading, setSubscriptionLoading] = useState(true);
  84. const [billingPreference, setBillingPreference] =
  85. useState('subscription_first');
  86. const [activeSubscriptions, setActiveSubscriptions] = useState([]);
  87. const [allSubscriptions, setAllSubscriptions] = useState([]);
  88. // 预设充值额度选项
  89. const [presetAmounts, setPresetAmounts] = useState([]);
  90. const [selectedPreset, setSelectedPreset] = useState(null);
  91. // 充值配置信息
  92. const [topupInfo, setTopupInfo] = useState({
  93. amount_options: [],
  94. discount: {},
  95. });
  96. const confirmPayMethods = [
  97. ...payMethods,
  98. ...waffoPayMethods.map((method, index) => ({
  99. ...method,
  100. type: `waffo:${index}`,
  101. min_topup: waffoMinTopUp,
  102. color: method.color || 'rgba(var(--semi-primary-5), 1)',
  103. })),
  104. ];
  105. const getPayMethodConfig = (payment) =>
  106. confirmPayMethods.find((method) => method.type === payment);
  107. const getPaymentMinTopUp = (payment) => {
  108. const configuredMinTopUp = Number(getPayMethodConfig(payment)?.min_topup);
  109. return Number.isFinite(configuredMinTopUp) && configuredMinTopUp > 0
  110. ? configuredMinTopUp
  111. : minTopUp;
  112. };
  113. const requestAmountByPayment = async (payment, value) => {
  114. if (payment === 'stripe') {
  115. return getStripeAmount(value);
  116. }
  117. if (payment === 'waffo_pancake') {
  118. return getWaffoPancakeAmount(value);
  119. }
  120. if (typeof payment === 'string' && payment.startsWith('waffo:')) {
  121. return getWaffoAmount(value);
  122. }
  123. return getAmount(value);
  124. };
  125. const topUp = async () => {
  126. if (redemptionCode === '') {
  127. showInfo(t('请输入兑换码!'));
  128. return;
  129. }
  130. setIsSubmitting(true);
  131. try {
  132. const res = await API.post('/api/user/topup', {
  133. key: redemptionCode,
  134. });
  135. const { success, message, data } = res.data;
  136. if (success) {
  137. showSuccess(t('兑换成功!'));
  138. Modal.success({
  139. title: t('兑换成功!'),
  140. content: t('成功兑换额度:') + renderQuota(data),
  141. centered: true,
  142. });
  143. if (userState.user) {
  144. const updatedUser = {
  145. ...userState.user,
  146. quota: userState.user.quota + data,
  147. };
  148. userDispatch({ type: 'login', payload: updatedUser });
  149. }
  150. setRedemptionCode('');
  151. } else {
  152. showError(message);
  153. }
  154. } catch (err) {
  155. showError(t('请求失败'));
  156. } finally {
  157. setIsSubmitting(false);
  158. }
  159. };
  160. const openTopUpLink = () => {
  161. if (!topUpLink) {
  162. showError(t('超级管理员未设置充值链接!'));
  163. return;
  164. }
  165. window.open(topUpLink, '_blank');
  166. };
  167. const preTopUp = async (payment) => {
  168. if (payment === 'stripe') {
  169. if (!enableStripeTopUp) {
  170. showError(t('管理员未开启Stripe充值!'));
  171. return;
  172. }
  173. } else if (payment === 'waffo_pancake') {
  174. if (!enableWaffoPancakeTopUp) {
  175. showError(t('管理员未开启 Waffo Pancake 充值!'));
  176. return;
  177. }
  178. } else if (payment.startsWith('waffo:')) {
  179. if (!enableWaffoTopUp) {
  180. showError(t('管理员未开启 Waffo 充值!'));
  181. return;
  182. }
  183. } else {
  184. if (!enableOnlineTopUp) {
  185. showError(t('管理员未开启在线充值!'));
  186. return;
  187. }
  188. }
  189. setPayWay(payment);
  190. setPaymentLoading(true);
  191. try {
  192. const selectedMinTopUp = getPaymentMinTopUp(payment);
  193. await requestAmountByPayment(payment);
  194. if (topUpCount < selectedMinTopUp) {
  195. showError(t('充值数量不能小于') + selectedMinTopUp);
  196. return;
  197. }
  198. setOpen(true);
  199. } catch (error) {
  200. showError(t('获取金额失败'));
  201. } finally {
  202. setPaymentLoading(false);
  203. }
  204. };
  205. const onlineTopUp = async () => {
  206. if (payWay === 'waffo_pancake') {
  207. setConfirmLoading(true);
  208. try {
  209. await waffoPancakeTopUp();
  210. } finally {
  211. setOpen(false);
  212. setConfirmLoading(false);
  213. }
  214. return;
  215. }
  216. if (payWay.startsWith('waffo:')) {
  217. const payMethodIndex = Number(payWay.split(':')[1]);
  218. setConfirmLoading(true);
  219. try {
  220. await waffoTopUp(Number.isFinite(payMethodIndex) ? payMethodIndex : 0);
  221. } finally {
  222. setOpen(false);
  223. setConfirmLoading(false);
  224. }
  225. return;
  226. }
  227. if (payWay === 'stripe') {
  228. // Stripe 支付处理
  229. if (amount === 0) {
  230. await getStripeAmount();
  231. }
  232. } else {
  233. // 普通支付处理
  234. if (amount === 0) {
  235. await getAmount();
  236. }
  237. }
  238. if (topUpCount < minTopUp) {
  239. showError('充值数量不能小于' + minTopUp);
  240. return;
  241. }
  242. setConfirmLoading(true);
  243. try {
  244. let res;
  245. if (payWay === 'stripe') {
  246. // Stripe 支付请求
  247. res = await API.post('/api/user/stripe/pay', {
  248. amount: parseInt(topUpCount),
  249. payment_method: 'stripe',
  250. });
  251. } else {
  252. // 普通支付请求
  253. res = await API.post('/api/user/pay', {
  254. amount: parseInt(topUpCount),
  255. payment_method: payWay,
  256. });
  257. }
  258. if (res !== undefined) {
  259. const { message, data } = res.data;
  260. if (message === 'success') {
  261. if (payWay === 'stripe') {
  262. // Stripe 支付回调处理
  263. window.open(data.pay_link, '_blank');
  264. } else {
  265. // 普通支付表单提交
  266. let params = data;
  267. let url = res.data.url;
  268. let form = document.createElement('form');
  269. form.action = url;
  270. form.method = 'POST';
  271. let isSafari =
  272. navigator.userAgent.indexOf('Safari') > -1 &&
  273. navigator.userAgent.indexOf('Chrome') < 1;
  274. if (!isSafari) {
  275. form.target = '_blank';
  276. }
  277. for (let key in params) {
  278. let input = document.createElement('input');
  279. input.type = 'hidden';
  280. input.name = key;
  281. input.value = params[key];
  282. form.appendChild(input);
  283. }
  284. document.body.appendChild(form);
  285. form.submit();
  286. document.body.removeChild(form);
  287. }
  288. } else {
  289. const errorMsg =
  290. typeof data === 'string' ? data : message || t('支付失败');
  291. showError(errorMsg);
  292. }
  293. } else {
  294. showError(res);
  295. }
  296. } catch (err) {
  297. showError(t('支付请求失败'));
  298. } finally {
  299. setOpen(false);
  300. setConfirmLoading(false);
  301. }
  302. };
  303. const creemPreTopUp = async (product) => {
  304. if (!enableCreemTopUp) {
  305. showError(t('管理员未开启 Creem 充值!'));
  306. return;
  307. }
  308. setSelectedCreemProduct(product);
  309. setCreemOpen(true);
  310. };
  311. const onlineCreemTopUp = async () => {
  312. if (!selectedCreemProduct) {
  313. showError(t('请选择产品'));
  314. return;
  315. }
  316. // Validate product has required fields
  317. if (!selectedCreemProduct.productId) {
  318. showError(t('产品配置错误,请联系管理员'));
  319. return;
  320. }
  321. setConfirmLoading(true);
  322. try {
  323. const res = await API.post('/api/user/creem/pay', {
  324. product_id: selectedCreemProduct.productId,
  325. payment_method: 'creem',
  326. });
  327. if (res !== undefined) {
  328. const { message, data } = res.data;
  329. if (message === 'success') {
  330. processCreemCallback(data);
  331. } else {
  332. const errorMsg =
  333. typeof data === 'string' ? data : message || t('支付失败');
  334. showError(errorMsg);
  335. }
  336. } else {
  337. showError(res);
  338. }
  339. } catch (err) {
  340. showError(t('支付请求失败'));
  341. } finally {
  342. setCreemOpen(false);
  343. setConfirmLoading(false);
  344. }
  345. };
  346. const waffoTopUp = async (payMethodIndex) => {
  347. try {
  348. if (topUpCount < waffoMinTopUp) {
  349. showError(t('充值数量不能小于') + waffoMinTopUp);
  350. return;
  351. }
  352. setPaymentLoading(true);
  353. const requestBody = {
  354. amount: parseInt(topUpCount),
  355. };
  356. if (payMethodIndex != null) {
  357. requestBody.pay_method_index = payMethodIndex;
  358. }
  359. const res = await API.post('/api/user/waffo/pay', requestBody);
  360. if (res !== undefined) {
  361. const { message, data } = res.data;
  362. if (message === 'success' && data?.payment_url) {
  363. window.open(data.payment_url, '_blank');
  364. } else {
  365. showError(data || t('支付请求失败'));
  366. }
  367. } else {
  368. showError(res);
  369. }
  370. } catch (e) {
  371. showError(t('支付请求失败'));
  372. } finally {
  373. setPaymentLoading(false);
  374. }
  375. };
  376. const getWaffoAmount = async (value) => {
  377. if (value === undefined) {
  378. value = topUpCount;
  379. }
  380. setAmountLoading(true);
  381. try {
  382. const res = await API.post('/api/user/waffo/amount', {
  383. amount: parseInt(value),
  384. });
  385. if (res !== undefined) {
  386. const { message, data } = res.data;
  387. if (message === 'success') {
  388. setAmount(parseFloat(data));
  389. } else {
  390. setAmount(0);
  391. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  392. }
  393. } else {
  394. showError(res);
  395. }
  396. } catch (err) {
  397. // amount fetch failed silently
  398. } finally {
  399. setAmountLoading(false);
  400. }
  401. };
  402. const waffoPancakeTopUp = async () => {
  403. const minTopUpValue = Number(waffoPancakeMinTopUp || 1);
  404. if (topUpCount < minTopUpValue) {
  405. showError(t('充值数量不能小于') + minTopUpValue);
  406. return;
  407. }
  408. setPaymentLoading(true);
  409. try {
  410. const res = await API.post('/api/user/waffo-pancake/pay', {
  411. amount: parseInt(topUpCount),
  412. });
  413. if (res !== undefined) {
  414. const { message, data } = res.data;
  415. if (message === 'success') {
  416. const checkoutUrl = data?.checkout_url || '';
  417. if (checkoutUrl) {
  418. window.open(checkoutUrl, '_blank');
  419. } else {
  420. showError(t('支付请求失败'));
  421. }
  422. } else {
  423. const errorMsg =
  424. typeof data === 'string' ? data : message || t('支付请求失败');
  425. showError(errorMsg);
  426. }
  427. } else {
  428. showError(res);
  429. }
  430. } catch (e) {
  431. showError(t('支付请求失败'));
  432. } finally {
  433. setPaymentLoading(false);
  434. }
  435. };
  436. const getWaffoPancakeAmount = async (value) => {
  437. if (value === undefined) {
  438. value = topUpCount;
  439. }
  440. setAmountLoading(true);
  441. try {
  442. const res = await API.post('/api/user/waffo-pancake/amount', {
  443. amount: parseInt(value),
  444. });
  445. if (res !== undefined) {
  446. const { message, data } = res.data;
  447. if (message === 'success') {
  448. setAmount(parseFloat(data));
  449. } else {
  450. setAmount(0);
  451. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  452. }
  453. } else {
  454. showError(res);
  455. }
  456. } catch (err) {
  457. // amount fetch failed silently
  458. } finally {
  459. setAmountLoading(false);
  460. }
  461. };
  462. const processCreemCallback = (data) => {
  463. // 与 Stripe 保持一致的实现方式
  464. window.open(data.checkout_url, '_blank');
  465. };
  466. const getUserQuota = async () => {
  467. let res = await API.get(`/api/user/self`);
  468. const { success, message, data } = res.data;
  469. if (success) {
  470. userDispatch({ type: 'login', payload: data });
  471. } else {
  472. showError(message);
  473. }
  474. };
  475. const getSubscriptionPlans = async () => {
  476. setSubscriptionLoading(true);
  477. try {
  478. const res = await API.get('/api/subscription/plans');
  479. if (res.data?.success) {
  480. setSubscriptionPlans(res.data.data || []);
  481. }
  482. } catch (e) {
  483. setSubscriptionPlans([]);
  484. } finally {
  485. setSubscriptionLoading(false);
  486. }
  487. };
  488. const getSubscriptionSelf = async () => {
  489. try {
  490. const res = await API.get('/api/subscription/self');
  491. if (res.data?.success) {
  492. setBillingPreference(
  493. res.data.data?.billing_preference || 'subscription_first',
  494. );
  495. // Active subscriptions
  496. const activeSubs = res.data.data?.subscriptions || [];
  497. setActiveSubscriptions(activeSubs);
  498. // All subscriptions (including expired)
  499. const allSubs = res.data.data?.all_subscriptions || [];
  500. setAllSubscriptions(allSubs);
  501. }
  502. } catch (e) {
  503. // ignore
  504. }
  505. };
  506. const updateBillingPreference = async (pref) => {
  507. const previousPref = billingPreference;
  508. setBillingPreference(pref);
  509. try {
  510. const res = await API.put('/api/subscription/self/preference', {
  511. billing_preference: pref,
  512. });
  513. if (res.data?.success) {
  514. showSuccess(t('更新成功'));
  515. const normalizedPref =
  516. res.data?.data?.billing_preference || pref || previousPref;
  517. setBillingPreference(normalizedPref);
  518. } else {
  519. showError(res.data?.message || t('更新失败'));
  520. setBillingPreference(previousPref);
  521. }
  522. } catch (e) {
  523. showError(t('请求失败'));
  524. setBillingPreference(previousPref);
  525. }
  526. };
  527. // 获取充值配置信息
  528. const getTopupInfo = async () => {
  529. try {
  530. const res = await API.get('/api/user/topup/info');
  531. const { message, data, success } = res.data;
  532. if (success) {
  533. setTopupInfo({
  534. amount_options: data.amount_options || [],
  535. discount: data.discount || {},
  536. });
  537. // 处理支付方式
  538. let payMethods = data.pay_methods || [];
  539. try {
  540. if (typeof payMethods === 'string') {
  541. payMethods = JSON.parse(payMethods);
  542. }
  543. if (payMethods && payMethods.length > 0) {
  544. // 检查name和type是否为空
  545. payMethods = payMethods.filter((method) => {
  546. return method.name && method.type;
  547. });
  548. // 如果没有color,则设置默认颜色
  549. payMethods = payMethods.map((method) => {
  550. // 规范化最小充值数
  551. const normalizedMinTopup = Number(method.min_topup);
  552. method.min_topup = Number.isFinite(normalizedMinTopup)
  553. ? normalizedMinTopup
  554. : 0;
  555. // Stripe 的最小充值从后端字段回填
  556. if (
  557. method.type === 'stripe' &&
  558. (!method.min_topup || method.min_topup <= 0)
  559. ) {
  560. const stripeMin = Number(data.stripe_min_topup);
  561. if (Number.isFinite(stripeMin)) {
  562. method.min_topup = stripeMin;
  563. }
  564. }
  565. if (!method.color) {
  566. if (method.type === 'alipay') {
  567. method.color = 'rgba(var(--semi-blue-5), 1)';
  568. } else if (method.type === 'wxpay') {
  569. method.color = 'rgba(var(--semi-green-5), 1)';
  570. } else if (method.type === 'stripe') {
  571. method.color = 'rgba(var(--semi-purple-5), 1)';
  572. } else {
  573. method.color = 'rgba(var(--semi-primary-5), 1)';
  574. }
  575. }
  576. return method;
  577. });
  578. } else {
  579. payMethods = [];
  580. }
  581. // 如果启用了 Stripe 支付,添加到支付方法列表
  582. // 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
  583. setPayMethods(payMethods);
  584. const enableStripeTopUp = data.enable_stripe_topup || false;
  585. const enableOnlineTopUp = data.enable_online_topup || false;
  586. const enableCreemTopUp = data.enable_creem_topup || false;
  587. const enableWaffoTopUp = data.enable_waffo_topup || false;
  588. const enableWaffoPancakeTopUp =
  589. data.enable_waffo_pancake_topup || false;
  590. const minTopUpValue = enableOnlineTopUp
  591. ? data.min_topup
  592. : enableStripeTopUp
  593. ? data.stripe_min_topup
  594. : enableWaffoTopUp
  595. ? data.waffo_min_topup
  596. : enableWaffoPancakeTopUp
  597. ? data.waffo_pancake_min_topup
  598. : 1;
  599. setEnableOnlineTopUp(enableOnlineTopUp);
  600. setEnableStripeTopUp(enableStripeTopUp);
  601. setEnableCreemTopUp(enableCreemTopUp);
  602. setEnableWaffoTopUp(enableWaffoTopUp);
  603. setWaffoPayMethods(data.waffo_pay_methods || []);
  604. setWaffoMinTopUp(data.waffo_min_topup || 1);
  605. setEnableWaffoPancakeTopUp(enableWaffoPancakeTopUp);
  606. setWaffoPancakeMinTopUp(data.waffo_pancake_min_topup || 1);
  607. setMinTopUp(minTopUpValue);
  608. setTopUpCount(minTopUpValue);
  609. setTopUpLink(data.topup_link || '');
  610. // 设置 Creem 产品
  611. try {
  612. const products = JSON.parse(data.creem_products || '[]');
  613. setCreemProducts(products);
  614. } catch (e) {
  615. setCreemProducts([]);
  616. }
  617. // 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
  618. if (topupInfo.amount_options.length === 0) {
  619. setPresetAmounts(generatePresetAmounts(minTopUpValue));
  620. }
  621. // 初始化显示实付金额
  622. getAmount(minTopUpValue);
  623. } catch (e) {
  624. setPayMethods([]);
  625. }
  626. // 如果有自定义充值数量选项,使用它们替换默认的预设选项
  627. if (data.amount_options && data.amount_options.length > 0) {
  628. const customPresets = data.amount_options.map((amount) => ({
  629. value: amount,
  630. discount: data.discount[amount] || 1.0,
  631. }));
  632. setPresetAmounts(customPresets);
  633. }
  634. } else {
  635. showError(data || t('获取充值配置失败'));
  636. }
  637. } catch (error) {
  638. showError(t('获取充值配置异常'));
  639. }
  640. };
  641. // 获取邀请链接
  642. const getAffLink = async () => {
  643. const res = await API.get('/api/user/aff');
  644. const { success, message, data } = res.data;
  645. if (success) {
  646. let link = `${window.location.origin}/register?aff=${data}`;
  647. setAffLink(link);
  648. } else {
  649. showError(message);
  650. }
  651. };
  652. // 划转邀请额度
  653. const transfer = async () => {
  654. if (transferAmount < getQuotaPerUnit()) {
  655. showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
  656. return;
  657. }
  658. const res = await API.post(`/api/user/aff_transfer`, {
  659. quota: transferAmount,
  660. });
  661. const { success, message } = res.data;
  662. if (success) {
  663. showSuccess(message);
  664. setOpenTransfer(false);
  665. getUserQuota().then();
  666. } else {
  667. showError(message);
  668. }
  669. };
  670. // 复制邀请链接
  671. const handleAffLinkClick = async () => {
  672. await copy(affLink);
  673. showSuccess(t('邀请链接已复制到剪切板'));
  674. };
  675. // URL 参数自动打开账单弹窗(支付回跳时触发)
  676. useEffect(() => {
  677. if (searchParams.get('show_history') === 'true') {
  678. setOpenHistory(true);
  679. searchParams.delete('show_history');
  680. setSearchParams(searchParams, { replace: true });
  681. }
  682. }, []);
  683. useEffect(() => {
  684. // 始终获取最新用户数据,确保余额等统计信息准确
  685. getUserQuota().then();
  686. setTransferAmount(getQuotaPerUnit());
  687. }, []);
  688. useEffect(() => {
  689. if (affFetchedRef.current) return;
  690. affFetchedRef.current = true;
  691. getAffLink().then();
  692. }, []);
  693. // 在 statusState 可用时获取充值信息
  694. useEffect(() => {
  695. getTopupInfo().then();
  696. getSubscriptionPlans().then();
  697. getSubscriptionSelf().then();
  698. }, []);
  699. useEffect(() => {
  700. if (statusState?.status) {
  701. // const minTopUpValue = statusState.status.min_topup || 1;
  702. // setMinTopUp(minTopUpValue);
  703. // setTopUpCount(minTopUpValue);
  704. setPriceRatio(statusState.status.price || 1);
  705. setStatusLoading(false);
  706. }
  707. }, [statusState?.status]);
  708. const renderAmount = () => {
  709. return amount + ' ' + t('元');
  710. };
  711. const getAmount = async (value) => {
  712. if (value === undefined) {
  713. value = topUpCount;
  714. }
  715. setAmountLoading(true);
  716. try {
  717. const res = await API.post('/api/user/amount', {
  718. amount: parseFloat(value),
  719. });
  720. if (res !== undefined) {
  721. const { message, data } = res.data;
  722. if (message === 'success') {
  723. setAmount(parseFloat(data));
  724. } else {
  725. setAmount(0);
  726. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  727. }
  728. } else {
  729. showError(res);
  730. }
  731. } catch (err) {
  732. // amount fetch failed silently
  733. }
  734. setAmountLoading(false);
  735. };
  736. const getStripeAmount = async (value) => {
  737. if (value === undefined) {
  738. value = topUpCount;
  739. }
  740. setAmountLoading(true);
  741. try {
  742. const res = await API.post('/api/user/stripe/amount', {
  743. amount: parseFloat(value),
  744. });
  745. if (res !== undefined) {
  746. const { message, data } = res.data;
  747. if (message === 'success') {
  748. setAmount(parseFloat(data));
  749. } else {
  750. setAmount(0);
  751. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  752. }
  753. } else {
  754. showError(res);
  755. }
  756. } catch (err) {
  757. // amount fetch failed silently
  758. } finally {
  759. setAmountLoading(false);
  760. }
  761. };
  762. const handleCancel = () => {
  763. setOpen(false);
  764. };
  765. const handleTransferCancel = () => {
  766. setOpenTransfer(false);
  767. };
  768. const handleOpenHistory = () => {
  769. setOpenHistory(true);
  770. };
  771. const handleHistoryCancel = () => {
  772. setOpenHistory(false);
  773. };
  774. const handleCreemCancel = () => {
  775. setCreemOpen(false);
  776. setSelectedCreemProduct(null);
  777. };
  778. // 选择预设充值额度
  779. const selectPresetAmount = (preset) => {
  780. setTopUpCount(preset.value);
  781. setSelectedPreset(preset.value);
  782. // 计算实际支付金额,考虑折扣
  783. const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
  784. const discountedAmount = preset.value * priceRatio * discount;
  785. setAmount(discountedAmount);
  786. };
  787. // 格式化大数字显示
  788. const formatLargeNumber = (num) => {
  789. return num.toString();
  790. };
  791. // 根据最小充值金额生成预设充值额度选项
  792. const generatePresetAmounts = (minAmount) => {
  793. const multipliers = [1, 5, 10, 30, 50, 100, 300, 500];
  794. return multipliers.map((multiplier) => ({
  795. value: minAmount * multiplier,
  796. }));
  797. };
  798. return (
  799. <div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'>
  800. {/* 划转模态框 */}
  801. <TransferModal
  802. t={t}
  803. openTransfer={openTransfer}
  804. transfer={transfer}
  805. handleTransferCancel={handleTransferCancel}
  806. userState={userState}
  807. renderQuota={renderQuota}
  808. getQuotaPerUnit={getQuotaPerUnit}
  809. transferAmount={transferAmount}
  810. setTransferAmount={setTransferAmount}
  811. />
  812. {/* 充值确认模态框 */}
  813. <PaymentConfirmModal
  814. t={t}
  815. open={open}
  816. onlineTopUp={onlineTopUp}
  817. handleCancel={handleCancel}
  818. confirmLoading={confirmLoading}
  819. topUpCount={topUpCount}
  820. renderQuotaWithAmount={renderQuotaWithAmount}
  821. amountLoading={amountLoading}
  822. renderAmount={renderAmount}
  823. payWay={payWay}
  824. payMethods={confirmPayMethods}
  825. amountNumber={amount}
  826. discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
  827. />
  828. {/* 充值账单模态框 */}
  829. <TopupHistoryModal
  830. visible={openHistory}
  831. onCancel={handleHistoryCancel}
  832. t={t}
  833. />
  834. {/* Creem 充值确认模态框 */}
  835. <Modal
  836. title={t('确定要充值 $')}
  837. visible={creemOpen}
  838. onOk={onlineCreemTopUp}
  839. onCancel={handleCreemCancel}
  840. maskClosable={false}
  841. size='small'
  842. centered
  843. confirmLoading={confirmLoading}
  844. >
  845. {selectedCreemProduct && (
  846. <>
  847. <p>
  848. {t('产品名称')}:{selectedCreemProduct.name}
  849. </p>
  850. <p>
  851. {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
  852. {selectedCreemProduct.price}
  853. </p>
  854. <p>
  855. {t('充值额度')}:{selectedCreemProduct.quota}
  856. </p>
  857. <p>{t('是否确认充值?')}</p>
  858. </>
  859. )}
  860. </Modal>
  861. {/* 主布局区域 */}
  862. <div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
  863. <RechargeCard
  864. t={t}
  865. enableOnlineTopUp={enableOnlineTopUp}
  866. enableStripeTopUp={enableStripeTopUp}
  867. enableCreemTopUp={enableCreemTopUp}
  868. creemProducts={creemProducts}
  869. creemPreTopUp={creemPreTopUp}
  870. enableWaffoTopUp={enableWaffoTopUp}
  871. enableWaffoPancakeTopUp={enableWaffoPancakeTopUp}
  872. presetAmounts={presetAmounts}
  873. selectedPreset={selectedPreset}
  874. selectPresetAmount={selectPresetAmount}
  875. formatLargeNumber={formatLargeNumber}
  876. priceRatio={priceRatio}
  877. topUpCount={topUpCount}
  878. minTopUp={minTopUp}
  879. renderQuotaWithAmount={renderQuotaWithAmount}
  880. getAmount={getAmount}
  881. setTopUpCount={setTopUpCount}
  882. setSelectedPreset={setSelectedPreset}
  883. renderAmount={renderAmount}
  884. amountLoading={amountLoading}
  885. payMethods={confirmPayMethods}
  886. preTopUp={preTopUp}
  887. paymentLoading={paymentLoading}
  888. payWay={payWay}
  889. redemptionCode={redemptionCode}
  890. setRedemptionCode={setRedemptionCode}
  891. topUp={topUp}
  892. isSubmitting={isSubmitting}
  893. topUpLink={topUpLink}
  894. openTopUpLink={openTopUpLink}
  895. userState={userState}
  896. renderQuota={renderQuota}
  897. statusLoading={statusLoading}
  898. topupInfo={topupInfo}
  899. onOpenHistory={handleOpenHistory}
  900. subscriptionLoading={subscriptionLoading}
  901. subscriptionPlans={subscriptionPlans}
  902. billingPreference={billingPreference}
  903. onChangeBillingPreference={updateBillingPreference}
  904. activeSubscriptions={activeSubscriptions}
  905. allSubscriptions={allSubscriptions}
  906. reloadSubscriptionSelf={getSubscriptionSelf}
  907. />
  908. <InvitationCard
  909. t={t}
  910. userState={userState}
  911. renderQuota={renderQuota}
  912. setOpenTransfer={setOpenTransfer}
  913. affLink={affLink}
  914. handleAffLinkClick={handleAffLinkClick}
  915. />
  916. </div>
  917. </div>
  918. );
  919. };
  920. export default TopUp;