UsersTable.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. import React, { useEffect, useState } from 'react';
  2. import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
  3. import {
  4. User,
  5. Shield,
  6. Crown,
  7. HelpCircle,
  8. CheckCircle,
  9. XCircle,
  10. Minus,
  11. Coins,
  12. Activity,
  13. Users,
  14. DollarSign,
  15. UserPlus,
  16. } from 'lucide-react';
  17. import {
  18. Button,
  19. Card,
  20. Divider,
  21. Dropdown,
  22. Empty,
  23. Form,
  24. Modal,
  25. Space,
  26. Table,
  27. Tag,
  28. Tooltip,
  29. Typography
  30. } from '@douyinfe/semi-ui';
  31. import {
  32. IllustrationNoResult,
  33. IllustrationNoResultDark
  34. } from '@douyinfe/semi-illustrations';
  35. import {
  36. IconPlus,
  37. IconSearch,
  38. IconEdit,
  39. IconDelete,
  40. IconStop,
  41. IconPlay,
  42. IconMore,
  43. IconUserAdd,
  44. IconArrowUp,
  45. IconArrowDown,
  46. IconDescend
  47. } from '@douyinfe/semi-icons';
  48. import { ITEMS_PER_PAGE } from '../../constants';
  49. import AddUser from '../../pages/User/AddUser';
  50. import EditUser from '../../pages/User/EditUser';
  51. import { useTranslation } from 'react-i18next';
  52. import { useTableCompactMode } from '../../hooks/useTableCompactMode';
  53. const { Text } = Typography;
  54. const UsersTable = () => {
  55. const { t } = useTranslation();
  56. const [compactMode, setCompactMode] = useTableCompactMode('users');
  57. function renderRole(role) {
  58. switch (role) {
  59. case 1:
  60. return (
  61. <Tag size='large' color='blue' shape='circle' prefixIcon={<User size={14} />}>
  62. {t('普通用户')}
  63. </Tag>
  64. );
  65. case 10:
  66. return (
  67. <Tag color='yellow' size='large' shape='circle' prefixIcon={<Shield size={14} />}>
  68. {t('管理员')}
  69. </Tag>
  70. );
  71. case 100:
  72. return (
  73. <Tag color='orange' size='large' shape='circle' prefixIcon={<Crown size={14} />}>
  74. {t('超级管理员')}
  75. </Tag>
  76. );
  77. default:
  78. return (
  79. <Tag color='red' size='large' shape='circle' prefixIcon={<HelpCircle size={14} />}>
  80. {t('未知身份')}
  81. </Tag>
  82. );
  83. }
  84. }
  85. const renderStatus = (status) => {
  86. switch (status) {
  87. case 1:
  88. return <Tag size='large' color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>{t('已激活')}</Tag>;
  89. case 2:
  90. return (
  91. <Tag size='large' color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
  92. {t('已封禁')}
  93. </Tag>
  94. );
  95. default:
  96. return (
  97. <Tag size='large' color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
  98. {t('未知状态')}
  99. </Tag>
  100. );
  101. }
  102. };
  103. const columns = [
  104. {
  105. title: 'ID',
  106. dataIndex: 'id',
  107. },
  108. {
  109. title: t('用户名'),
  110. dataIndex: 'username',
  111. render: (text, record) => {
  112. const remark = record.remark;
  113. if (!remark) {
  114. return <span>{text}</span>;
  115. }
  116. const maxLen = 10;
  117. const displayRemark = remark.length > maxLen ? remark.slice(0, maxLen) + '…' : remark;
  118. return (
  119. <Space spacing={2}>
  120. <span>{text}</span>
  121. <Tooltip content={remark} position="top" showArrow>
  122. <Tag color='white' size='large' shape='circle' className="!text-xs">
  123. <div className="flex items-center gap-1">
  124. <div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: '#10b981' }} />
  125. {displayRemark}
  126. </div>
  127. </Tag>
  128. </Tooltip>
  129. </Space>
  130. );
  131. },
  132. },
  133. {
  134. title: t('分组'),
  135. dataIndex: 'group',
  136. render: (text, record, index) => {
  137. return <div>{renderGroup(text)}</div>;
  138. },
  139. },
  140. {
  141. title: t('统计信息'),
  142. dataIndex: 'info',
  143. render: (text, record, index) => {
  144. return (
  145. <div>
  146. <Space spacing={1}>
  147. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
  148. {t('剩余')}: {renderQuota(record.quota)}
  149. </Tag>
  150. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Coins size={14} />}>
  151. {t('已用')}: {renderQuota(record.used_quota)}
  152. </Tag>
  153. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Activity size={14} />}>
  154. {t('调用')}: {renderNumber(record.request_count)}
  155. </Tag>
  156. </Space>
  157. </div>
  158. );
  159. },
  160. },
  161. {
  162. title: t('邀请信息'),
  163. dataIndex: 'invite',
  164. render: (text, record, index) => {
  165. return (
  166. <div>
  167. <Space spacing={1}>
  168. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<Users size={14} />}>
  169. {t('邀请')}: {renderNumber(record.aff_count)}
  170. </Tag>
  171. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<DollarSign size={14} />}>
  172. {t('收益')}: {renderQuota(record.aff_history_quota)}
  173. </Tag>
  174. <Tag color='white' size='large' shape='circle' className="!text-xs" prefixIcon={<UserPlus size={14} />}>
  175. {record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
  176. </Tag>
  177. </Space>
  178. </div>
  179. );
  180. },
  181. },
  182. {
  183. title: t('角色'),
  184. dataIndex: 'role',
  185. render: (text, record, index) => {
  186. return <div>{renderRole(text)}</div>;
  187. },
  188. },
  189. {
  190. title: t('状态'),
  191. dataIndex: 'status',
  192. render: (text, record, index) => {
  193. return (
  194. <div>
  195. {record.DeletedAt !== null ? (
  196. <Tag color='red' shape='circle' prefixIcon={<Minus size={14} />}>{t('已注销')}</Tag>
  197. ) : (
  198. renderStatus(text)
  199. )}
  200. </div>
  201. );
  202. },
  203. },
  204. {
  205. title: '',
  206. dataIndex: 'operate',
  207. fixed: 'right',
  208. render: (text, record, index) => {
  209. if (record.DeletedAt !== null) {
  210. return <></>;
  211. }
  212. // 创建更多操作的下拉菜单项
  213. const moreMenuItems = [
  214. {
  215. node: 'item',
  216. name: t('提升'),
  217. icon: <IconArrowUp />,
  218. type: 'warning',
  219. onClick: () => {
  220. Modal.confirm({
  221. title: t('确定要提升此用户吗?'),
  222. content: t('此操作将提升用户的权限级别'),
  223. onOk: () => {
  224. manageUser(record.id, 'promote', record);
  225. },
  226. });
  227. },
  228. },
  229. {
  230. node: 'item',
  231. name: t('降级'),
  232. icon: <IconArrowDown />,
  233. type: 'secondary',
  234. onClick: () => {
  235. Modal.confirm({
  236. title: t('确定要降级此用户吗?'),
  237. content: t('此操作将降低用户的权限级别'),
  238. onOk: () => {
  239. manageUser(record.id, 'demote', record);
  240. },
  241. });
  242. },
  243. },
  244. {
  245. node: 'item',
  246. name: t('注销'),
  247. icon: <IconDelete />,
  248. type: 'danger',
  249. onClick: () => {
  250. Modal.confirm({
  251. title: t('确定是否要注销此用户?'),
  252. content: t('相当于删除用户,此修改将不可逆'),
  253. onOk: () => {
  254. manageUser(record.id, 'delete', record).then(() => {
  255. removeRecord(record.id);
  256. });
  257. },
  258. });
  259. },
  260. }
  261. ];
  262. // 动态添加启用/禁用按钮
  263. if (record.status === 1) {
  264. moreMenuItems.splice(-1, 0, {
  265. node: 'item',
  266. name: t('禁用'),
  267. icon: <IconStop />,
  268. type: 'warning',
  269. onClick: () => {
  270. manageUser(record.id, 'disable', record);
  271. },
  272. });
  273. } else {
  274. moreMenuItems.splice(-1, 0, {
  275. node: 'item',
  276. name: t('启用'),
  277. icon: <IconPlay />,
  278. type: 'secondary',
  279. onClick: () => {
  280. manageUser(record.id, 'enable', record);
  281. },
  282. disabled: record.status === 3,
  283. });
  284. }
  285. return (
  286. <Space>
  287. <Button
  288. icon={<IconEdit />}
  289. theme='light'
  290. type='tertiary'
  291. size="small"
  292. className="!rounded-full"
  293. onClick={() => {
  294. setEditingUser(record);
  295. setShowEditUser(true);
  296. }}
  297. >
  298. {t('编辑')}
  299. </Button>
  300. <Dropdown
  301. trigger='click'
  302. position='bottomRight'
  303. menu={moreMenuItems}
  304. >
  305. <Button
  306. icon={<IconMore />}
  307. theme='light'
  308. type='tertiary'
  309. size="small"
  310. className="!rounded-full"
  311. />
  312. </Dropdown>
  313. </Space>
  314. );
  315. },
  316. },
  317. ];
  318. const [users, setUsers] = useState([]);
  319. const [loading, setLoading] = useState(true);
  320. const [activePage, setActivePage] = useState(1);
  321. const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
  322. const [searching, setSearching] = useState(false);
  323. const [groupOptions, setGroupOptions] = useState([]);
  324. const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
  325. const [showAddUser, setShowAddUser] = useState(false);
  326. const [showEditUser, setShowEditUser] = useState(false);
  327. const [editingUser, setEditingUser] = useState({
  328. id: undefined,
  329. });
  330. // Form 初始值
  331. const formInitValues = {
  332. searchKeyword: '',
  333. searchGroup: '',
  334. };
  335. // Form API 引用
  336. const [formApi, setFormApi] = useState(null);
  337. // 获取表单值的辅助函数
  338. const getFormValues = () => {
  339. const formValues = formApi ? formApi.getValues() : {};
  340. return {
  341. searchKeyword: formValues.searchKeyword || '',
  342. searchGroup: formValues.searchGroup || '',
  343. };
  344. };
  345. const removeRecord = (key) => {
  346. let newDataSource = [...users];
  347. if (key != null) {
  348. let idx = newDataSource.findIndex((data) => data.id === key);
  349. if (idx > -1) {
  350. // update deletedAt
  351. newDataSource[idx].DeletedAt = new Date();
  352. setUsers(newDataSource);
  353. }
  354. }
  355. };
  356. const setUserFormat = (users) => {
  357. for (let i = 0; i < users.length; i++) {
  358. users[i].key = users[i].id;
  359. }
  360. setUsers(users);
  361. };
  362. const loadUsers = async (startIdx, pageSize) => {
  363. const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
  364. const { success, message, data } = res.data;
  365. if (success) {
  366. const newPageData = data.items;
  367. setActivePage(data.page);
  368. setUserCount(data.total);
  369. setUserFormat(newPageData);
  370. } else {
  371. showError(message);
  372. }
  373. setLoading(false);
  374. };
  375. useEffect(() => {
  376. loadUsers(0, pageSize)
  377. .then()
  378. .catch((reason) => {
  379. showError(reason);
  380. });
  381. fetchGroups().then();
  382. }, []);
  383. const manageUser = async (userId, action, record) => {
  384. const res = await API.post('/api/user/manage', {
  385. id: userId,
  386. action,
  387. });
  388. const { success, message } = res.data;
  389. if (success) {
  390. showSuccess('操作成功完成!');
  391. let user = res.data.data;
  392. let newUsers = [...users];
  393. if (action === 'delete') {
  394. } else {
  395. record.status = user.status;
  396. record.role = user.role;
  397. }
  398. setUsers(newUsers);
  399. } else {
  400. showError(message);
  401. }
  402. };
  403. const searchUsers = async (
  404. startIdx,
  405. pageSize,
  406. searchKeyword = null,
  407. searchGroup = null,
  408. ) => {
  409. // 如果没有传递参数,从表单获取值
  410. if (searchKeyword === null || searchGroup === null) {
  411. const formValues = getFormValues();
  412. searchKeyword = formValues.searchKeyword;
  413. searchGroup = formValues.searchGroup;
  414. }
  415. if (searchKeyword === '' && searchGroup === '') {
  416. // if keyword is blank, load files instead.
  417. await loadUsers(startIdx, pageSize);
  418. return;
  419. }
  420. setSearching(true);
  421. const res = await API.get(
  422. `/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
  423. );
  424. const { success, message, data } = res.data;
  425. if (success) {
  426. const newPageData = data.items;
  427. setActivePage(data.page);
  428. setUserCount(data.total);
  429. setUserFormat(newPageData);
  430. } else {
  431. showError(message);
  432. }
  433. setSearching(false);
  434. };
  435. const handlePageChange = (page) => {
  436. setActivePage(page);
  437. const { searchKeyword, searchGroup } = getFormValues();
  438. if (searchKeyword === '' && searchGroup === '') {
  439. loadUsers(page, pageSize).then();
  440. } else {
  441. searchUsers(page, pageSize, searchKeyword, searchGroup).then();
  442. }
  443. };
  444. const closeAddUser = () => {
  445. setShowAddUser(false);
  446. };
  447. const closeEditUser = () => {
  448. setShowEditUser(false);
  449. setEditingUser({
  450. id: undefined,
  451. });
  452. };
  453. const refresh = async () => {
  454. setActivePage(1);
  455. const { searchKeyword, searchGroup } = getFormValues();
  456. if (searchKeyword === '' && searchGroup === '') {
  457. await loadUsers(1, pageSize);
  458. } else {
  459. await searchUsers(1, pageSize, searchKeyword, searchGroup);
  460. }
  461. };
  462. const fetchGroups = async () => {
  463. try {
  464. let res = await API.get(`/api/group/`);
  465. // add 'all' option
  466. // res.data.data.unshift('all');
  467. if (res === undefined) {
  468. return;
  469. }
  470. setGroupOptions(
  471. res.data.data.map((group) => ({
  472. label: group,
  473. value: group,
  474. })),
  475. );
  476. } catch (error) {
  477. showError(error.message);
  478. }
  479. };
  480. const handlePageSizeChange = async (size) => {
  481. localStorage.setItem('page-size', size + '');
  482. setPageSize(size);
  483. setActivePage(1);
  484. loadUsers(activePage, size)
  485. .then()
  486. .catch((reason) => {
  487. showError(reason);
  488. });
  489. };
  490. const handleRow = (record, index) => {
  491. if (record.DeletedAt !== null || record.status !== 1) {
  492. return {
  493. style: {
  494. background: 'var(--semi-color-disabled-border)',
  495. },
  496. };
  497. } else {
  498. return {};
  499. }
  500. };
  501. const renderHeader = () => (
  502. <div className="flex flex-col w-full">
  503. <div className="mb-2">
  504. <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
  505. <div className="flex items-center text-blue-500">
  506. <IconUserAdd className="mr-2" />
  507. <Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
  508. </div>
  509. <Button
  510. theme='light'
  511. type='secondary'
  512. icon={<IconDescend />}
  513. className="!rounded-full w-full md:w-auto"
  514. onClick={() => setCompactMode(!compactMode)}
  515. >
  516. {compactMode ? t('自适应列表') : t('紧凑列表')}
  517. </Button>
  518. </div>
  519. </div>
  520. <Divider margin="12px" />
  521. <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
  522. <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
  523. <Button
  524. theme='light'
  525. type='primary'
  526. icon={<IconPlus />}
  527. className="!rounded-full w-full md:w-auto"
  528. onClick={() => {
  529. setShowAddUser(true);
  530. }}
  531. >
  532. {t('添加用户')}
  533. </Button>
  534. </div>
  535. <Form
  536. initValues={formInitValues}
  537. getFormApi={(api) => setFormApi(api)}
  538. onSubmit={() => {
  539. setActivePage(1);
  540. searchUsers(1, pageSize);
  541. }}
  542. allowEmpty={true}
  543. autoComplete="off"
  544. layout="horizontal"
  545. trigger="change"
  546. stopValidateWithError={false}
  547. className="w-full md:w-auto order-1 md:order-2"
  548. >
  549. <div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto">
  550. <div className="relative w-full md:w-64">
  551. <Form.Input
  552. field="searchKeyword"
  553. prefix={<IconSearch />}
  554. placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
  555. className="!rounded-full"
  556. showClear
  557. pure
  558. />
  559. </div>
  560. <div className="w-full md:w-48">
  561. <Form.Select
  562. field="searchGroup"
  563. placeholder={t('选择分组')}
  564. optionList={groupOptions}
  565. onChange={(value) => {
  566. // 分组变化时自动搜索
  567. setTimeout(() => {
  568. setActivePage(1);
  569. searchUsers(1, pageSize);
  570. }, 100);
  571. }}
  572. className="!rounded-full w-full"
  573. showClear
  574. pure
  575. />
  576. </div>
  577. <div className="flex gap-2 w-full md:w-auto">
  578. <Button
  579. type="primary"
  580. htmlType="submit"
  581. loading={loading || searching}
  582. className="!rounded-full flex-1 md:flex-initial md:w-auto"
  583. >
  584. {t('查询')}
  585. </Button>
  586. <Button
  587. theme="light"
  588. onClick={() => {
  589. if (formApi) {
  590. formApi.reset();
  591. // 重置后立即查询,使用setTimeout确保表单重置完成
  592. setTimeout(() => {
  593. setActivePage(1);
  594. loadUsers(1, pageSize);
  595. }, 100);
  596. }
  597. }}
  598. className="!rounded-full flex-1 md:flex-initial md:w-auto"
  599. >
  600. {t('重置')}
  601. </Button>
  602. </div>
  603. </div>
  604. </Form>
  605. </div>
  606. </div>
  607. );
  608. return (
  609. <>
  610. <AddUser
  611. refresh={refresh}
  612. visible={showAddUser}
  613. handleClose={closeAddUser}
  614. ></AddUser>
  615. <EditUser
  616. refresh={refresh}
  617. visible={showEditUser}
  618. handleClose={closeEditUser}
  619. editingUser={editingUser}
  620. ></EditUser>
  621. <Card
  622. className="!rounded-2xl"
  623. title={renderHeader()}
  624. shadows='always'
  625. bordered={false}
  626. >
  627. <Table
  628. columns={compactMode ? columns.map(({ fixed, ...rest }) => rest) : columns}
  629. dataSource={users}
  630. scroll={compactMode ? undefined : { x: 'max-content' }}
  631. pagination={{
  632. formatPageText: (page) =>
  633. t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
  634. start: page.currentStart,
  635. end: page.currentEnd,
  636. total: userCount,
  637. }),
  638. currentPage: activePage,
  639. pageSize: pageSize,
  640. total: userCount,
  641. pageSizeOpts: [10, 20, 50, 100],
  642. showSizeChanger: true,
  643. onPageSizeChange: (size) => {
  644. handlePageSizeChange(size);
  645. },
  646. onPageChange: handlePageChange,
  647. }}
  648. loading={loading}
  649. onRow={handleRow}
  650. empty={
  651. <Empty
  652. image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
  653. darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
  654. description={t('搜索无结果')}
  655. style={{ padding: 30 }}
  656. />
  657. }
  658. className="rounded-xl overflow-hidden"
  659. size="middle"
  660. />
  661. </Card>
  662. </>
  663. );
  664. };
  665. export default UsersTable;