RedemptionsTable.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import React, { useEffect, useState } from 'react';
  2. import {
  3. API,
  4. copy,
  5. showError,
  6. showSuccess,
  7. timestamp2string,
  8. renderQuota
  9. } from '../../helpers';
  10. import { ITEMS_PER_PAGE } from '../../constants';
  11. import {
  12. Button,
  13. Card,
  14. Divider,
  15. Dropdown,
  16. Input,
  17. Modal,
  18. Popover,
  19. Space,
  20. Table,
  21. Tag,
  22. Typography,
  23. } from '@douyinfe/semi-ui';
  24. import {
  25. IconPlus,
  26. IconCopy,
  27. IconSearch,
  28. IconEyeOpened,
  29. IconEdit,
  30. IconDelete,
  31. IconStop,
  32. IconPlay,
  33. IconMore,
  34. } from '@douyinfe/semi-icons';
  35. import EditRedemption from '../../pages/Redemption/EditRedemption';
  36. import { useTranslation } from 'react-i18next';
  37. const { Text } = Typography;
  38. function renderTimestamp(timestamp) {
  39. return <>{timestamp2string(timestamp)}</>;
  40. }
  41. const RedemptionsTable = () => {
  42. const { t } = useTranslation();
  43. const renderStatus = (status) => {
  44. switch (status) {
  45. case 1:
  46. return (
  47. <Tag color='green' size='large' shape='circle'>
  48. {t('未使用')}
  49. </Tag>
  50. );
  51. case 2:
  52. return (
  53. <Tag color='red' size='large' shape='circle'>
  54. {t('已禁用')}
  55. </Tag>
  56. );
  57. case 3:
  58. return (
  59. <Tag color='grey' size='large' shape='circle'>
  60. {t('已使用')}
  61. </Tag>
  62. );
  63. default:
  64. return (
  65. <Tag color='black' size='large' shape='circle'>
  66. {t('未知状态')}
  67. </Tag>
  68. );
  69. }
  70. };
  71. const columns = [
  72. {
  73. title: t('ID'),
  74. dataIndex: 'id',
  75. width: 50,
  76. },
  77. {
  78. title: t('名称'),
  79. dataIndex: 'name',
  80. width: 120,
  81. },
  82. {
  83. title: t('状态'),
  84. dataIndex: 'status',
  85. key: 'status',
  86. width: 100,
  87. render: (text, record, index) => {
  88. return <div>{renderStatus(text)}</div>;
  89. },
  90. },
  91. {
  92. title: t('额度'),
  93. dataIndex: 'quota',
  94. width: 100,
  95. render: (text, record, index) => {
  96. return <div>{renderQuota(parseInt(text))}</div>;
  97. },
  98. },
  99. {
  100. title: t('创建时间'),
  101. dataIndex: 'created_time',
  102. width: 180,
  103. render: (text, record, index) => {
  104. return <div>{renderTimestamp(text)}</div>;
  105. },
  106. },
  107. {
  108. title: t('兑换人ID'),
  109. dataIndex: 'used_user_id',
  110. width: 100,
  111. render: (text, record, index) => {
  112. return <div>{text === 0 ? t('无') : text}</div>;
  113. },
  114. },
  115. {
  116. title: '',
  117. dataIndex: 'operate',
  118. width: 300,
  119. render: (text, record, index) => {
  120. // 创建更多操作的下拉菜单项
  121. const moreMenuItems = [
  122. {
  123. node: 'item',
  124. name: t('删除'),
  125. icon: <IconDelete />,
  126. type: 'danger',
  127. onClick: () => {
  128. Modal.confirm({
  129. title: t('确定是否要删除此兑换码?'),
  130. content: t('此修改将不可逆'),
  131. onOk: () => {
  132. manageRedemption(record.id, 'delete', record).then(() => {
  133. removeRecord(record.key);
  134. });
  135. },
  136. });
  137. },
  138. }
  139. ];
  140. // 动态添加启用/禁用按钮
  141. if (record.status === 1) {
  142. moreMenuItems.push({
  143. node: 'item',
  144. name: t('禁用'),
  145. icon: <IconStop />,
  146. type: 'warning',
  147. onClick: () => {
  148. manageRedemption(record.id, 'disable', record);
  149. },
  150. });
  151. } else {
  152. moreMenuItems.push({
  153. node: 'item',
  154. name: t('启用'),
  155. icon: <IconPlay />,
  156. type: 'secondary',
  157. onClick: () => {
  158. manageRedemption(record.id, 'enable', record);
  159. },
  160. disabled: record.status === 3,
  161. });
  162. }
  163. return (
  164. <Space>
  165. <Popover content={record.key} style={{ padding: 20 }} position='top'>
  166. <Button
  167. icon={<IconEyeOpened />}
  168. theme='light'
  169. type='tertiary'
  170. size="small"
  171. className="!rounded-full"
  172. >
  173. {t('查看')}
  174. </Button>
  175. </Popover>
  176. <Button
  177. icon={<IconCopy />}
  178. theme='light'
  179. type='secondary'
  180. size="small"
  181. className="!rounded-full"
  182. onClick={async () => {
  183. await copyText(record.key);
  184. }}
  185. >
  186. {t('复制')}
  187. </Button>
  188. <Button
  189. icon={<IconEdit />}
  190. theme='light'
  191. type='tertiary'
  192. size="small"
  193. className="!rounded-full"
  194. onClick={() => {
  195. setEditingRedemption(record);
  196. setShowEdit(true);
  197. }}
  198. disabled={record.status !== 1}
  199. >
  200. {t('编辑')}
  201. </Button>
  202. <Dropdown
  203. trigger='click'
  204. position='bottomRight'
  205. menu={moreMenuItems}
  206. >
  207. <Button
  208. icon={<IconMore />}
  209. theme='light'
  210. type='tertiary'
  211. size="small"
  212. className="!rounded-full"
  213. />
  214. </Dropdown>
  215. </Space>
  216. );
  217. },
  218. },
  219. ];
  220. const [redemptions, setRedemptions] = useState([]);
  221. const [loading, setLoading] = useState(true);
  222. const [activePage, setActivePage] = useState(1);
  223. const [searchKeyword, setSearchKeyword] = useState('');
  224. const [searching, setSearching] = useState(false);
  225. const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
  226. const [selectedKeys, setSelectedKeys] = useState([]);
  227. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  228. const [editingRedemption, setEditingRedemption] = useState({
  229. id: undefined,
  230. });
  231. const [showEdit, setShowEdit] = useState(false);
  232. const closeEdit = () => {
  233. setShowEdit(false);
  234. setTimeout(() => {
  235. setEditingRedemption({
  236. id: undefined,
  237. });
  238. }, 500);
  239. };
  240. const setRedemptionFormat = (redeptions) => {
  241. setRedemptions(redeptions);
  242. };
  243. const loadRedemptions = async (startIdx, pageSize) => {
  244. const res = await API.get(
  245. `/api/redemption/?p=${startIdx}&page_size=${pageSize}`,
  246. );
  247. const { success, message, data } = res.data;
  248. if (success) {
  249. const newPageData = data.items;
  250. setActivePage(data.page);
  251. setTokenCount(data.total);
  252. setRedemptionFormat(newPageData);
  253. } else {
  254. showError(message);
  255. }
  256. setLoading(false);
  257. };
  258. const removeRecord = (key) => {
  259. let newDataSource = [...redemptions];
  260. if (key != null) {
  261. let idx = newDataSource.findIndex((data) => data.key === key);
  262. if (idx > -1) {
  263. newDataSource.splice(idx, 1);
  264. setRedemptions(newDataSource);
  265. }
  266. }
  267. };
  268. const copyText = async (text) => {
  269. if (await copy(text)) {
  270. showSuccess(t('已复制到剪贴板!'));
  271. } else {
  272. Modal.error({
  273. title: t('无法复制到剪贴板,请手动复制'),
  274. content: text,
  275. size: 'large'
  276. });
  277. }
  278. };
  279. const onPaginationChange = (e, { activePage }) => {
  280. (async () => {
  281. if (activePage === Math.ceil(redemptions.length / pageSize) + 1) {
  282. await loadRedemptions(activePage - 1, pageSize);
  283. }
  284. setActivePage(activePage);
  285. })();
  286. };
  287. useEffect(() => {
  288. loadRedemptions(0, pageSize)
  289. .then()
  290. .catch((reason) => {
  291. showError(reason);
  292. });
  293. }, [pageSize]);
  294. const refresh = async () => {
  295. await loadRedemptions(activePage - 1, pageSize);
  296. };
  297. const manageRedemption = async (id, action, record) => {
  298. setLoading(true);
  299. let data = { id };
  300. let res;
  301. switch (action) {
  302. case 'delete':
  303. res = await API.delete(`/api/redemption/${id}/`);
  304. break;
  305. case 'enable':
  306. data.status = 1;
  307. res = await API.put('/api/redemption/?status_only=true', data);
  308. break;
  309. case 'disable':
  310. data.status = 2;
  311. res = await API.put('/api/redemption/?status_only=true', data);
  312. break;
  313. }
  314. const { success, message } = res.data;
  315. if (success) {
  316. showSuccess(t('操作成功完成!'));
  317. let redemption = res.data.data;
  318. let newRedemptions = [...redemptions];
  319. if (action === 'delete') {
  320. } else {
  321. record.status = redemption.status;
  322. }
  323. setRedemptions(newRedemptions);
  324. } else {
  325. showError(message);
  326. }
  327. setLoading(false);
  328. };
  329. const searchRedemptions = async (keyword, page, pageSize) => {
  330. if (searchKeyword === '') {
  331. await loadRedemptions(page, pageSize);
  332. return;
  333. }
  334. setSearching(true);
  335. const res = await API.get(
  336. `/api/redemption/search?keyword=${keyword}&p=${page}&page_size=${pageSize}`,
  337. );
  338. const { success, message, data } = res.data;
  339. if (success) {
  340. const newPageData = data.items;
  341. setActivePage(data.page);
  342. setTokenCount(data.total);
  343. setRedemptionFormat(newPageData);
  344. } else {
  345. showError(message);
  346. }
  347. setSearching(false);
  348. };
  349. const handleKeywordChange = async (value) => {
  350. setSearchKeyword(value.trim());
  351. };
  352. const sortRedemption = (key) => {
  353. if (redemptions.length === 0) return;
  354. setLoading(true);
  355. let sortedRedemptions = [...redemptions];
  356. sortedRedemptions.sort((a, b) => {
  357. return ('' + a[key]).localeCompare(b[key]);
  358. });
  359. if (sortedRedemptions[0].id === redemptions[0].id) {
  360. sortedRedemptions.reverse();
  361. }
  362. setRedemptions(sortedRedemptions);
  363. setLoading(false);
  364. };
  365. const handlePageChange = (page) => {
  366. setActivePage(page);
  367. if (searchKeyword === '') {
  368. loadRedemptions(page, pageSize).then();
  369. } else {
  370. searchRedemptions(searchKeyword, page, pageSize).then();
  371. }
  372. };
  373. let pageData = redemptions;
  374. const rowSelection = {
  375. onSelect: (record, selected) => { },
  376. onSelectAll: (selected, selectedRows) => { },
  377. onChange: (selectedRowKeys, selectedRows) => {
  378. setSelectedKeys(selectedRows);
  379. },
  380. };
  381. const handleRow = (record, index) => {
  382. if (record.status !== 1) {
  383. return {
  384. style: {
  385. background: 'var(--semi-color-disabled-border)',
  386. },
  387. };
  388. } else {
  389. return {};
  390. }
  391. };
  392. const renderHeader = () => (
  393. <div className="flex flex-col w-full">
  394. <div className="mb-2">
  395. <div className="flex items-center text-orange-500">
  396. <IconEyeOpened className="mr-2" />
  397. <Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
  398. </div>
  399. </div>
  400. <Divider margin="12px" />
  401. <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
  402. <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
  403. <Button
  404. theme='light'
  405. type='primary'
  406. icon={<IconPlus />}
  407. className="!rounded-full w-full md:w-auto"
  408. onClick={() => {
  409. setEditingRedemption({
  410. id: undefined,
  411. });
  412. setShowEdit(true);
  413. }}
  414. >
  415. {t('添加兑换码')}
  416. </Button>
  417. <Button
  418. type='warning'
  419. icon={<IconCopy />}
  420. className="!rounded-full w-full md:w-auto"
  421. onClick={async () => {
  422. if (selectedKeys.length === 0) {
  423. showError(t('请至少选择一个兑换码!'));
  424. return;
  425. }
  426. let keys = '';
  427. for (let i = 0; i < selectedKeys.length; i++) {
  428. keys +=
  429. selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
  430. }
  431. await copyText(keys);
  432. }}
  433. >
  434. {t('复制所选兑换码到剪贴板')}
  435. </Button>
  436. </div>
  437. <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
  438. <div className="relative w-full md:w-64">
  439. <Input
  440. prefix={<IconSearch />}
  441. placeholder={t('关键字(id或者名称)')}
  442. value={searchKeyword}
  443. onChange={handleKeywordChange}
  444. className="!rounded-full"
  445. showClear
  446. />
  447. </div>
  448. <Button
  449. type="primary"
  450. onClick={() => {
  451. searchRedemptions(searchKeyword, 1, pageSize).then();
  452. }}
  453. loading={searching}
  454. className="!rounded-full w-full md:w-auto"
  455. >
  456. {t('查询')}
  457. </Button>
  458. </div>
  459. </div>
  460. </div>
  461. );
  462. return (
  463. <>
  464. <EditRedemption
  465. refresh={refresh}
  466. editingRedemption={editingRedemption}
  467. visiable={showEdit}
  468. handleClose={closeEdit}
  469. ></EditRedemption>
  470. <Card
  471. className="!rounded-2xl overflow-hidden"
  472. title={renderHeader()}
  473. shadows='always'
  474. bordered={false}
  475. >
  476. <Table
  477. columns={columns}
  478. dataSource={pageData}
  479. pagination={{
  480. currentPage: activePage,
  481. pageSize: pageSize,
  482. total: tokenCount,
  483. showSizeChanger: true,
  484. pageSizeOptions: [10, 20, 50, 100],
  485. formatPageText: (page) =>
  486. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  487. start: page.currentStart,
  488. end: page.currentEnd,
  489. total: tokenCount,
  490. }),
  491. onPageSizeChange: (size) => {
  492. setPageSize(size);
  493. setActivePage(1);
  494. if (searchKeyword === '') {
  495. loadRedemptions(1, size).then();
  496. } else {
  497. searchRedemptions(searchKeyword, 1, size).then();
  498. }
  499. },
  500. onPageChange: handlePageChange,
  501. }}
  502. loading={loading}
  503. rowSelection={rowSelection}
  504. onRow={handleRow}
  505. className="rounded-xl overflow-hidden"
  506. size="middle"
  507. ></Table>
  508. </Card>
  509. </>
  510. );
  511. };
  512. export default RedemptionsTable;