index.js 22 KB

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