UsersTable.js 19 KB

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