index.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import React, { useEffect, useState, useContext } from 'react';
  2. import {
  3. API,
  4. showError,
  5. showInfo,
  6. showSuccess,
  7. renderQuota,
  8. renderQuotaWithAmount,
  9. stringToColor
  10. } from '../../helpers';
  11. import {
  12. Layout,
  13. Typography,
  14. Card,
  15. Button,
  16. Modal,
  17. Toast,
  18. Input,
  19. InputNumber,
  20. Banner,
  21. Skeleton,
  22. } from '@douyinfe/semi-ui';
  23. import {
  24. IconCreditCard,
  25. IconGift,
  26. IconPlus,
  27. IconLink,
  28. } from '@douyinfe/semi-icons';
  29. import { SiAlipay, SiWechat } from 'react-icons/si';
  30. import { useTranslation } from 'react-i18next';
  31. import { UserContext } from '../../context/User';
  32. import { StatusContext } from '../../context/Status/index.js';
  33. const { Text } = Typography;
  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 [topUpCode, setTopUpCode] = useState('');
  40. const [amount, setAmount] = useState(0.0);
  41. const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
  42. const [topUpCount, setTopUpCount] = useState(statusState?.status?.min_topup || 1);
  43. const [topUpLink, setTopUpLink] = useState(statusState?.status?.top_up_link || '');
  44. const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(statusState?.status?.enable_online_topup || false);
  45. const [userQuota, setUserQuota] = useState(0);
  46. const [isSubmitting, setIsSubmitting] = useState(false);
  47. const [open, setOpen] = useState(false);
  48. const [payWay, setPayWay] = useState('');
  49. const [userDataLoading, setUserDataLoading] = useState(true);
  50. const [amountLoading, setAmountLoading] = useState(false);
  51. const [paymentLoading, setPaymentLoading] = useState(false);
  52. const [confirmLoading, setConfirmLoading] = useState(false);
  53. const getUsername = () => {
  54. if (userState.user) {
  55. return userState.user.username;
  56. } else {
  57. return 'null';
  58. }
  59. };
  60. const getUserRole = () => {
  61. if (!userState.user) return t('普通用户');
  62. switch (userState.user.role) {
  63. case 100:
  64. return t('超级管理员');
  65. case 10:
  66. return t('管理员');
  67. case 0:
  68. default:
  69. return t('普通用户');
  70. }
  71. };
  72. const topUp = async () => {
  73. if (redemptionCode === '') {
  74. showInfo(t('请输入兑换码!'));
  75. return;
  76. }
  77. setIsSubmitting(true);
  78. try {
  79. const res = await API.post('/api/user/topup', {
  80. key: redemptionCode,
  81. });
  82. const { success, message, data } = res.data;
  83. if (success) {
  84. showSuccess(t('兑换成功!'));
  85. Modal.success({
  86. title: t('兑换成功!'),
  87. content: t('成功兑换额度:') + renderQuota(data),
  88. centered: true,
  89. });
  90. setUserQuota((quota) => {
  91. return quota + data;
  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 (!enableOnlineTopUp) {
  119. showError(t('管理员未开启在线充值!'));
  120. return;
  121. }
  122. setPaymentLoading(true);
  123. try {
  124. await getAmount();
  125. if (topUpCount < minTopUp) {
  126. showError(t('充值数量不能小于') + minTopUp);
  127. return;
  128. }
  129. setPayWay(payment);
  130. setOpen(true);
  131. } catch (error) {
  132. showError(t('获取金额失败'));
  133. } finally {
  134. setPaymentLoading(false);
  135. }
  136. };
  137. const onlineTopUp = async () => {
  138. if (amount === 0) {
  139. await getAmount();
  140. }
  141. if (topUpCount < minTopUp) {
  142. showError('充值数量不能小于' + minTopUp);
  143. return;
  144. }
  145. setConfirmLoading(true);
  146. setOpen(false);
  147. try {
  148. const res = await API.post('/api/user/pay', {
  149. amount: parseInt(topUpCount),
  150. top_up_code: topUpCode,
  151. payment_method: payWay,
  152. });
  153. if (res !== undefined) {
  154. const { message, data } = res.data;
  155. if (message === 'success') {
  156. let params = data;
  157. let url = res.data.url;
  158. let form = document.createElement('form');
  159. form.action = url;
  160. form.method = 'POST';
  161. let isSafari =
  162. navigator.userAgent.indexOf('Safari') > -1 &&
  163. navigator.userAgent.indexOf('Chrome') < 1;
  164. if (!isSafari) {
  165. form.target = '_blank';
  166. }
  167. for (let key in params) {
  168. let input = document.createElement('input');
  169. input.type = 'hidden';
  170. input.name = key;
  171. input.value = params[key];
  172. form.appendChild(input);
  173. }
  174. document.body.appendChild(form);
  175. form.submit();
  176. document.body.removeChild(form);
  177. } else {
  178. showError(data);
  179. }
  180. } else {
  181. showError(res);
  182. }
  183. } catch (err) {
  184. console.log(err);
  185. showError(t('支付请求失败'));
  186. } finally {
  187. setConfirmLoading(false);
  188. }
  189. };
  190. const getUserQuota = async () => {
  191. setUserDataLoading(true);
  192. let res = await API.get(`/api/user/self`);
  193. const { success, message, data } = res.data;
  194. if (success) {
  195. setUserQuota(data.quota);
  196. userDispatch({ type: 'login', payload: data });
  197. } else {
  198. showError(message);
  199. }
  200. setUserDataLoading(false);
  201. };
  202. useEffect(() => {
  203. if (userState?.user?.id) {
  204. setUserDataLoading(false);
  205. setUserQuota(userState.user.quota);
  206. } else {
  207. getUserQuota().then();
  208. }
  209. }, []);
  210. useEffect(() => {
  211. if (statusState?.status) {
  212. setMinTopUp(statusState.status.min_topup || 1);
  213. setTopUpCount(statusState.status.min_topup || 1);
  214. setTopUpLink(statusState.status.top_up_link || '');
  215. setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
  216. }
  217. }, [statusState?.status]);
  218. const renderAmount = () => {
  219. return amount + ' ' + t('元');
  220. };
  221. const getAmount = async (value) => {
  222. if (value === undefined) {
  223. value = topUpCount;
  224. }
  225. setAmountLoading(true);
  226. try {
  227. const res = await API.post('/api/user/amount', {
  228. amount: parseFloat(value),
  229. top_up_code: topUpCode,
  230. });
  231. if (res !== undefined) {
  232. const { message, data } = res.data;
  233. if (message === 'success') {
  234. setAmount(parseFloat(data));
  235. } else {
  236. setAmount(0);
  237. Toast.error({ content: '错误:' + data, id: 'getAmount' });
  238. }
  239. } else {
  240. showError(res);
  241. }
  242. } catch (err) {
  243. console.log(err);
  244. }
  245. setAmountLoading(false);
  246. };
  247. const handleCancel = () => {
  248. setOpen(false);
  249. };
  250. return (
  251. <div className="bg-gray-50">
  252. <Layout>
  253. <Layout.Content>
  254. <Modal
  255. title={
  256. <div className="flex items-center">
  257. <IconGift className="mr-2" />
  258. {t('充值确认')}
  259. </div>
  260. }
  261. visible={open}
  262. onOk={onlineTopUp}
  263. onCancel={handleCancel}
  264. maskClosable={false}
  265. size={'small'}
  266. centered={true}
  267. confirmLoading={confirmLoading}
  268. >
  269. <div className="space-y-3 py-4">
  270. <div className="flex justify-between">
  271. <Text strong>{t('充值数量')}:</Text>
  272. <Text>{topUpCount}</Text>
  273. </div>
  274. <div className="flex justify-between">
  275. <Text strong>{t('实付金额')}:</Text>
  276. {amountLoading ? (
  277. <Skeleton.Title style={{ width: '60px', height: '16px' }} />
  278. ) : (
  279. <Text type="danger">{renderAmount()}</Text>
  280. )}
  281. </div>
  282. </div>
  283. </Modal>
  284. <div className="flex justify-center">
  285. <div className="w-full max-w-4xl">
  286. <Card className="!rounded-2xl shadow-lg border-0">
  287. <Card
  288. className="!rounded-2xl !border-0 !shadow-2xl overflow-hidden"
  289. style={{
  290. background: 'linear-gradient(135deg, #1e3a8a 0%, #1e40af 25%, #2563eb 50%, #3b82f6 75%, #60a5fa 100%)',
  291. position: 'relative'
  292. }}
  293. bodyStyle={{ padding: 0 }}
  294. >
  295. <div className="absolute inset-0 overflow-hidden">
  296. <div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
  297. <div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
  298. <div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
  299. </div>
  300. <div className="relative p-4 sm:p-6 md:p-8" style={{ color: 'white' }}>
  301. <div className="flex justify-between items-start mb-4 sm:mb-6">
  302. <div className="flex-1 min-w-0">
  303. {userDataLoading ? (
  304. <Skeleton.Title style={{ width: '200px', height: '20px' }} />
  305. ) : (
  306. <div className="text-base sm:text-lg font-semibold truncate" style={{ color: 'white' }}>
  307. {t('尊敬的')} {getUsername()}
  308. </div>
  309. )}
  310. </div>
  311. <div
  312. className="w-10 h-10 sm:w-12 sm:h-12 rounded-lg flex items-center justify-center shadow-lg flex-shrink-0 ml-2"
  313. style={{
  314. background: `linear-gradient(135deg, ${stringToColor(getUsername())} 0%, #f59e0b 100%)`
  315. }}
  316. >
  317. <IconCreditCard size="default" style={{ color: 'white' }} />
  318. </div>
  319. </div>
  320. <div className="mb-4 sm:mb-6">
  321. <div className="text-xs sm:text-sm mb-1 sm:mb-2" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
  322. {t('当前余额')}
  323. </div>
  324. {userDataLoading ? (
  325. <Skeleton.Title style={{ width: '180px', height: '32px' }} />
  326. ) : (
  327. <div className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide" style={{ color: 'white' }}>
  328. {renderQuota(userState?.user?.quota || userQuota)}
  329. </div>
  330. )}
  331. </div>
  332. <div className="flex flex-col sm:flex-row sm:justify-between sm:items-end">
  333. <div className="grid grid-cols-3 gap-2 sm:flex sm:space-x-6 lg:space-x-8 mb-3 sm:mb-0">
  334. <div className="text-center sm:text-left">
  335. <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
  336. {t('历史消耗')}
  337. </div>
  338. {userDataLoading ? (
  339. <Skeleton.Title style={{ width: '60px', height: '14px' }} />
  340. ) : (
  341. <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
  342. {renderQuota(userState?.user?.used_quota || 0)}
  343. </div>
  344. )}
  345. </div>
  346. <div className="text-center sm:text-left">
  347. <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
  348. {t('用户分组')}
  349. </div>
  350. {userDataLoading ? (
  351. <Skeleton.Title style={{ width: '50px', height: '14px' }} />
  352. ) : (
  353. <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
  354. {userState?.user?.group || t('默认')}
  355. </div>
  356. )}
  357. </div>
  358. <div className="text-center sm:text-left">
  359. <div className="text-xs" style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
  360. {t('用户角色')}
  361. </div>
  362. {userDataLoading ? (
  363. <Skeleton.Title style={{ width: '60px', height: '14px' }} />
  364. ) : (
  365. <div className="text-xs sm:text-sm font-medium truncate" style={{ color: 'white' }}>
  366. {getUserRole()}
  367. </div>
  368. )}
  369. </div>
  370. </div>
  371. <div className="self-end sm:self-auto">
  372. {userDataLoading ? (
  373. <Skeleton.Title style={{ width: '50px', height: '24px' }} />
  374. ) : (
  375. <div
  376. className="px-2 py-1 sm:px-3 rounded-md text-xs sm:text-sm font-medium inline-block"
  377. style={{
  378. backgroundColor: 'rgba(255, 255, 255, 0.2)',
  379. color: 'white',
  380. backdropFilter: 'blur(10px)'
  381. }}
  382. >
  383. ID: {userState?.user?.id || '---'}
  384. </div>
  385. )}
  386. </div>
  387. </div>
  388. <div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
  389. </div>
  390. </Card>
  391. <div className="p-6">
  392. <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
  393. <div>
  394. <div className="flex items-center mb-6">
  395. <div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mr-4">
  396. <IconGift size="large" className="text-green-500" />
  397. </div>
  398. <div>
  399. <Text className="text-xl font-semibold">{t('兑换余额')}</Text>
  400. <div className="text-gray-500 text-sm">{t('使用兑换码充值余额')}</div>
  401. </div>
  402. </div>
  403. <div className="space-y-4">
  404. <div>
  405. <Text strong className="block mb-2">{t('兑换码')}</Text>
  406. <Input
  407. placeholder={t('请输入兑换码')}
  408. value={redemptionCode}
  409. onChange={(value) => setRedemptionCode(value)}
  410. size="large"
  411. className="!rounded-lg"
  412. prefix={<IconGift />}
  413. />
  414. </div>
  415. <div className="flex flex-col sm:flex-row gap-3">
  416. {topUpLink && (
  417. <Button
  418. type="primary"
  419. theme="solid"
  420. onClick={openTopUpLink}
  421. size="large"
  422. className="!rounded-lg flex-1"
  423. icon={<IconLink />}
  424. >
  425. {t('获取兑换码')}
  426. </Button>
  427. )}
  428. <Button
  429. type="warning"
  430. theme="solid"
  431. onClick={topUp}
  432. disabled={isSubmitting}
  433. loading={isSubmitting}
  434. size="large"
  435. className="!rounded-lg flex-1"
  436. >
  437. {isSubmitting ? t('兑换中...') : t('兑换')}
  438. </Button>
  439. </div>
  440. </div>
  441. </div>
  442. <div>
  443. <div className="flex items-center mb-6">
  444. <div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mr-4">
  445. <IconPlus size="large" className="text-blue-500" />
  446. </div>
  447. <div>
  448. <Text className="text-xl font-semibold">{t('在线充值')}</Text>
  449. <div className="text-gray-500 text-sm">{t('支持多种支付方式')}</div>
  450. </div>
  451. </div>
  452. <div className="space-y-4">
  453. <div>
  454. <div className="flex justify-between mb-2">
  455. <Text strong>{t('充值数量')}</Text>
  456. {amountLoading ? (
  457. <Skeleton.Title style={{ width: '80px', height: '14px' }} />
  458. ) : (
  459. <Text type="tertiary">{t('实付金额:') + ' ' + renderAmount()}</Text>
  460. )}
  461. </div>
  462. <InputNumber
  463. disabled={!enableOnlineTopUp}
  464. placeholder={
  465. t('充值数量,最低 ') + renderQuotaWithAmount(minTopUp)
  466. }
  467. value={topUpCount}
  468. min={minTopUp}
  469. max={999999999}
  470. step={1}
  471. precision={0}
  472. onChange={async (value) => {
  473. if (value && value >= 1) {
  474. setTopUpCount(value);
  475. await getAmount(value);
  476. }
  477. }}
  478. onBlur={(e) => {
  479. const value = parseInt(e.target.value);
  480. if (!value || value < 1) {
  481. setTopUpCount(1);
  482. getAmount(1);
  483. }
  484. }}
  485. size="large"
  486. className="!rounded-lg w-full"
  487. prefix={<IconCreditCard />}
  488. formatter={(value) => value ? `${value}` : ''}
  489. parser={(value) => value ? parseInt(value.replace(/[^\d]/g, '')) : 0}
  490. />
  491. </div>
  492. <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
  493. <Button
  494. type="primary"
  495. theme="solid"
  496. onClick={async () => {
  497. preTopUp('zfb');
  498. }}
  499. size="large"
  500. className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 h-14"
  501. disabled={!enableOnlineTopUp}
  502. loading={paymentLoading}
  503. icon={<SiAlipay size={20} />}
  504. >
  505. <span className="ml-2">{t('支付宝')}</span>
  506. </Button>
  507. <Button
  508. type="primary"
  509. theme="solid"
  510. onClick={async () => {
  511. preTopUp('wx');
  512. }}
  513. size="large"
  514. className="!rounded-lg !bg-green-500 hover:!bg-green-600 h-14"
  515. disabled={!enableOnlineTopUp}
  516. loading={paymentLoading}
  517. icon={<SiWechat size={20} />}
  518. >
  519. <span className="ml-2">{t('微信')}</span>
  520. </Button>
  521. </div>
  522. {!enableOnlineTopUp && (
  523. <Banner
  524. fullMode={false}
  525. type="warning"
  526. icon={null}
  527. closeIcon={null}
  528. className="!rounded-lg"
  529. title={
  530. <div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
  531. {t('在线充值功能未开启')}
  532. </div>
  533. }
  534. description={
  535. <div>
  536. {t('管理员未开启在线充值功能,请联系管理员开启或使用兑换码充值。')}
  537. </div>
  538. }
  539. />
  540. )}
  541. </div>
  542. </div>
  543. </div>
  544. </div>
  545. </Card>
  546. </div>
  547. </div>
  548. </Layout.Content>
  549. </Layout>
  550. </div>
  551. );
  552. };
  553. export default TopUp;