UserManagement.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { Plus, Search, Upload, Download, RefreshCw, Edit2, MoreVertical } from 'lucide-react';
  3. import { userApi } from '../api/user';
  4. import { userImportApi } from '../api/user/import';
  5. import { postApi, PostVO } from '../api/Post';
  6. import { UserVO, WorkstationNode } from '../types';
  7. import { toast } from 'sonner';
  8. import { Modal, Table, Input, Button, Switch, Dropdown, Space, Tooltip, Form } from 'antd';
  9. import { ExclamationCircleOutlined, DeleteOutlined, KeyOutlined, UserSwitchOutlined } from '@ant-design/icons';
  10. import { Button as UIButton } from './ui/button';
  11. import type { ColumnsType } from 'antd/es/table';
  12. import DeptTree from './user/DeptTree';
  13. import UserForm, { UserFormRef } from './user/UserForm';
  14. import UserImportForm, { UserImportFormRef } from './user/UserImportForm';
  15. import UserAssignRoleForm, { UserAssignRoleFormRef } from './user/UserAssignRoleForm';
  16. import FaceOrFingerForm, { FaceOrFingerFormRef } from './user/FaceOrFingerForm';
  17. import { useTranslation } from 'react-i18next';
  18. import PermissionWrapper from './PermissionWrapper';
  19. import { hasPermission } from '../utils/permission';
  20. import { formatDateWithFormat } from '../utils/formatTime';
  21. interface UserManagementProps {
  22. subMenu: string;
  23. }
  24. export default function UserManagement({ subMenu }: UserManagementProps) {
  25. const { t } = useTranslation();
  26. const [loading, setLoading] = useState(true);
  27. const [list, setList] = useState<UserVO[]>([]);
  28. const [total, setTotal] = useState(0);
  29. const [postList, setPostList] = useState<PostVO[]>([]);
  30. const [queryParams, setQueryParams] = useState({
  31. pageNo: 1,
  32. pageSize: 10,
  33. nickname:'',
  34. username: '',
  35. mobile: '',
  36. status: undefined as number | undefined,
  37. deptId: undefined as number | undefined,
  38. createTime: [] as string[],
  39. });
  40. const [exportLoading, setExportLoading] = useState(false);
  41. const [resetPwdModalVisible, setResetPwdModalVisible] = useState(false);
  42. const [resetPwdUser, setResetPwdUser] = useState<UserVO | null>(null);
  43. const [resetPwdPassword, setResetPwdPassword] = useState('');
  44. const [resetPwdLoading, setResetPwdLoading] = useState(false);
  45. // 子组件引用
  46. const formRef = useRef<UserFormRef>(null);
  47. const importFormRef = useRef<UserImportFormRef>(null);
  48. const assignRoleFormRef = useRef<UserAssignRoleFormRef>(null);
  49. const faceOrFingerFormRef = useRef<FaceOrFingerFormRef>(null);
  50. // 获取用户列表
  51. const getList = async (params?: typeof queryParams) => {
  52. const currentParams = params || queryParams;
  53. setLoading(true);
  54. try {
  55. const response = await userApi.getUserPage(currentParams);
  56. setList(response.list || []);
  57. setTotal(response.total || 0);
  58. } catch (error: any) {
  59. toast.error(error.message || t('common.error') || '获取用户列表失败');
  60. } finally {
  61. setLoading(false);
  62. }
  63. };
  64. // 加载岗位列表
  65. useEffect(() => {
  66. const loadPostList = async () => {
  67. try {
  68. const response = await postApi.getSimplePostList();
  69. // 处理响应数据,可能是直接返回数组,也可能包装在 data 中
  70. const posts = (response as any)?.data || response;
  71. setPostList(Array.isArray(posts) ? posts : []);
  72. } catch (error) {
  73. console.error('加载岗位列表失败:', error);
  74. setPostList([]);
  75. }
  76. };
  77. loadPostList();
  78. }, []);
  79. useEffect(() => {
  80. getList();
  81. // eslint-disable-next-line react-hooks/exhaustive-deps
  82. }, [queryParams.pageNo, queryParams.pageSize, queryParams.deptId]);
  83. // 搜索
  84. const handleQuery = () => {
  85. const newParams = { ...queryParams, pageNo: 1 };
  86. setQueryParams(newParams);
  87. getList(newParams);
  88. };
  89. // 重置搜索
  90. const resetQuery = () => {
  91. // 先清空表格数据
  92. setList([]);
  93. setTotal(0);
  94. // 重置所有查询参数
  95. const resetParams = {
  96. pageNo: 1,
  97. pageSize: 10,
  98. nickname: '',
  99. username: '',
  100. mobile: '',
  101. status: undefined as number | undefined,
  102. deptId: undefined as number | undefined,
  103. createTime: [] as string[],
  104. };
  105. setQueryParams(resetParams);
  106. // 立即使用重置后的参数获取列表,不需要等待状态更新
  107. getList(resetParams);
  108. };
  109. // 处理部门节点点击
  110. const handleDeptNodeClick = (node: WorkstationNode) => {
  111. setQueryParams(prev => ({ ...prev, deptId: node.id, pageNo: 1 }));
  112. };
  113. // 打开表单
  114. const openForm = (type: string, id?: number) => {
  115. formRef.current?.open(type, id);
  116. };
  117. const handleUserFormSuccess = (type: 'create' | 'update') => {
  118. if (type === 'create') {
  119. const nextParams = { ...queryParams, pageNo: 1 };
  120. setQueryParams(nextParams);
  121. getList(nextParams);
  122. return;
  123. }
  124. getList();
  125. };
  126. // 用户导入
  127. const handleImport = () => {
  128. importFormRef.current?.open();
  129. };
  130. // 导出用户
  131. const handleExport = async () => {
  132. Modal.confirm({
  133. title: t('form.exportUserConfirm'),
  134. icon: <ExclamationCircleOutlined />,
  135. content: t('form.exportUserConfirmText'),
  136. okText: t('form.exportUserConfirmButton'),
  137. cancelText: t('common.cancel'),
  138. onOk: async () => {
  139. setExportLoading(true);
  140. try {
  141. const blob = await userImportApi.exportUser(queryParams);
  142. const url = window.URL.createObjectURL(blob);
  143. const link = document.createElement('a');
  144. link.href = url;
  145. link.download = t('form.userDataFileName');
  146. document.body.appendChild(link);
  147. link.click();
  148. document.body.removeChild(link);
  149. window.URL.revokeObjectURL(url);
  150. toast.success(t('form.exportUserSuccess'));
  151. } catch (error: any) {
  152. toast.error(error.message || t('form.exportUserFailed'));
  153. } finally {
  154. setExportLoading(false);
  155. }
  156. },
  157. });
  158. };
  159. // 修改用户状态
  160. const handleStatusChange = async (row: UserVO, newChecked: boolean) => {
  161. const newStatus = newChecked ? 0 : 1; // 0是开启,1是关闭
  162. const text = newStatus === 0 ? t('common.enabled') : t('common.disabled');
  163. // 使用 antd 的确认弹框
  164. Modal.confirm({
  165. title: t('common.confirmOperation') || '确认操作',
  166. icon: <ExclamationCircleOutlined />,
  167. content: `${t('common.confirmText') || '确定要'}${text}${t('common.user') || '用户'}"${row.username}"?`,
  168. okText: t('common.confirm'),
  169. cancelText: t('common.cancel'),
  170. onOk: async () => {
  171. try {
  172. await userApi.updateUserStatus(row.id, newStatus);
  173. toast.success(`用户${text}成功`);
  174. // 刷新列表以更新状态
  175. await getList();
  176. } catch (error: any) {
  177. toast.error(error.message || `用户${text}失败`);
  178. // 接口调用失败,刷新列表以恢复Switch状态
  179. await getList();
  180. }
  181. },
  182. onCancel: async () => {
  183. // 用户取消操作,立即刷新列表以恢复Switch状态
  184. // 由于Switch是受控组件,刷新列表后会自动恢复到原始状态(基于row.status)
  185. await getList();
  186. },
  187. });
  188. };
  189. // 删除用户
  190. const handleDelete = async (id: number, username?: string) => {
  191. Modal.confirm({
  192. title: t('common.confirmDelete'),
  193. icon: <ExclamationCircleOutlined />,
  194. content: (
  195. <div>
  196. <p>{t('common.confirmDeleteText')} <strong>"{username || t('common.user') || '该用户'}"</strong>?</p>
  197. <p style={{ color: '#ff4d4f', marginTop: '8px' }}>{t('common.confirmDeleteWarning')}</p>
  198. </div>
  199. ),
  200. okText: t('common.confirmDelete'),
  201. okType: 'danger',
  202. cancelText: t('common.cancel'),
  203. onOk: async () => {
  204. try {
  205. await userApi.deleteUser(id);
  206. toast.success(t('common.deleteSuccess'));
  207. await getList();
  208. } catch (error: any) {
  209. toast.error(error.message || t('common.deleteFailed'));
  210. }
  211. },
  212. });
  213. };
  214. // 重置密码
  215. const handleResetPwd = (row: UserVO) => {
  216. setResetPwdUser(row);
  217. setResetPwdPassword('');
  218. setResetPwdModalVisible(true);
  219. };
  220. // 提交重置密码
  221. const submitResetPassword = async () => {
  222. if (!resetPwdPassword.trim()) {
  223. toast.error(t('common.pleaseEnter') + t('common.newPassword') || '请输入新密码');
  224. return;
  225. }
  226. if (!resetPwdUser) {
  227. return;
  228. }
  229. setResetPwdLoading(true);
  230. try {
  231. await userApi.resetUserPassword(resetPwdUser.id, resetPwdPassword);
  232. toast.success(`${t('form.resetPasswordSuccess')}${resetPwdPassword}`);
  233. setResetPwdModalVisible(false);
  234. setResetPwdPassword('');
  235. setResetPwdUser(null);
  236. } catch (error: any) {
  237. toast.error(error.message || t('common.resetPasswordFailed') || '重置密码失败');
  238. } finally {
  239. setResetPwdLoading(false);
  240. }
  241. };
  242. // 分配角色
  243. const handleRole = (row: UserVO) => {
  244. assignRoleFormRef.current?.open(row);
  245. };
  246. // 打开指纹或人脸弹框
  247. const openFaceOrFingerForm = (type: string, row: UserVO) => {
  248. faceOrFingerFormRef.current?.open(type, row.id, row);
  249. };
  250. // 表格列配置
  251. const columns: ColumnsType<UserVO> = [
  252. {
  253. title: t('common.serialNumber'),
  254. width: '5%',
  255. render: (_: any, __: UserVO, index: number) => {
  256. return (queryParams.pageNo - 1) * queryParams.pageSize + index + 1;
  257. },
  258. },
  259. {
  260. title: t('table.userId'),
  261. dataIndex: 'id',
  262. width: '6%',
  263. },
  264. {
  265. title: t('table.username'),
  266. dataIndex: 'username',
  267. width: '10%',
  268. },
  269. {
  270. title: t('table.nickname'),
  271. dataIndex: 'nickname',
  272. width: '10%',
  273. },
  274. {
  275. title: t('table.mobile'),
  276. dataIndex: 'mobile',
  277. width: '12%',
  278. render: (text: string) => text || '-',
  279. },
  280. {
  281. title: t('table.department'),
  282. width: '10%',
  283. render: (_: any, record: UserVO) => {
  284. return (record as any).deptName || '-';
  285. },
  286. },
  287. {
  288. title: t('table.position'),
  289. width: '10%',
  290. render: (_: any, record: UserVO) => {
  291. // 根据 postIds 匹配岗位名称
  292. if (record.postIds && Array.isArray(record.postIds) && record.postIds.length > 0) {
  293. const postNames = record.postIds
  294. .map((postId: string | number) => {
  295. // 将 postId 转换为数字进行比较
  296. const id = typeof postId === 'string' ? Number(postId) : postId;
  297. const post = postList.find(p => p.id === id);
  298. return post ? post.name : null;
  299. })
  300. .filter((name: string | null) => name !== null);
  301. const displayText = postNames.length > 0 ? postNames.join(',') : '-';
  302. return (
  303. <Tooltip
  304. title={
  305. <div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
  306. {displayText}
  307. </div>
  308. }
  309. placement="topLeft"
  310. >
  311. <div
  312. style={{
  313. overflow: 'hidden',
  314. textOverflow: 'ellipsis',
  315. whiteSpace: 'nowrap',
  316. cursor: 'help',
  317. }}
  318. >
  319. {displayText}
  320. </div>
  321. </Tooltip>
  322. );
  323. }
  324. return '-';
  325. },
  326. },
  327. {
  328. title: t('table.facePhoto'),
  329. width: '6%',
  330. render: (_: any, record: UserVO) => (
  331. <Button
  332. type="link"
  333. onClick={() => openFaceOrFingerForm('face', record)}
  334. style={{ padding: 0 }}
  335. >
  336. {t('common.view')}
  337. </Button>
  338. ),
  339. },
  340. {
  341. title: t('table.status'),
  342. width: '6%',
  343. render: (_: any, record: UserVO) => (
  344. <Switch
  345. checked={record.status === 0}
  346. onChange={(checked) => handleStatusChange(record, checked)}
  347. style={{
  348. ...(record.status === 0 ? {
  349. backgroundColor: '#52c41a',
  350. } : {})
  351. }}
  352. className={record.status === 0 ? 'ant-switch-checked-green' : ''}
  353. />
  354. ),
  355. },
  356. {
  357. title: t('table.expireTime'),
  358. dataIndex: 'expireTime',
  359. width: '10%',
  360. render: (text: string | number) => text ? formatDateWithFormat(text) : '-',
  361. },
  362. {
  363. title: t('table.operation'),
  364. width: '10%',
  365. align: 'center',
  366. render: (_: any, record: UserVO) => {
  367. const menuItems = [
  368. {
  369. key: 'delete',
  370. label: t('common.delete'),
  371. icon: <DeleteOutlined />,
  372. onClick: () => handleDelete(record.id, record.username),
  373. },
  374. {
  375. key: 'resetPwd',
  376. label: t('common.resetPassword'),
  377. icon: <KeyOutlined />,
  378. onClick: () => handleResetPwd(record),
  379. },
  380. {
  381. key: 'assignRole',
  382. label: t('common.assignRole'),
  383. icon: <UserSwitchOutlined />,
  384. onClick: () => handleRole(record),
  385. },
  386. ];
  387. const filteredMenuItems = menuItems.filter(item => {
  388. if (item.key === 'delete') {
  389. return hasPermission('system:user:delete');
  390. }
  391. if (item.key === 'resetPwd') {
  392. return hasPermission('system:user:update-password');
  393. }
  394. if (item.key === 'assignRole') {
  395. return hasPermission('system:permission:assign-user-role');
  396. }
  397. return true;
  398. });
  399. return (
  400. <div className="flex items-center gap-2 justify-center">
  401. <PermissionWrapper permission="system:user:update">
  402. <UIButton
  403. variant="ghost"
  404. size="sm"
  405. onClick={() => openForm('update', record.id)}
  406. className="h-8 px-2 transition-colors hover:underline"
  407. style={{ color: '#000000' }}
  408. onMouseEnter={(e) => {
  409. e.currentTarget.style.color = '#1677ff';
  410. e.currentTarget.style.textDecoration = 'underline';
  411. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
  412. }}
  413. onMouseLeave={(e) => {
  414. e.currentTarget.style.color = '#000000';
  415. e.currentTarget.style.textDecoration = 'none';
  416. e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
  417. }}
  418. >
  419. <Edit2 className="w-4 h-4" style={{ color: '#000000' }} />
  420. <span className="ml-1">{t('common.edit')}</span>
  421. </UIButton>
  422. </PermissionWrapper>
  423. {filteredMenuItems.length > 0 && (
  424. <Dropdown
  425. menu={{ items: filteredMenuItems }}
  426. trigger={['hover']}
  427. getPopupContainer={() => document.body}
  428. placement="bottomRight"
  429. overlayStyle={{ zIndex: 1050 }}
  430. mouseEnterDelay={0.1}
  431. mouseLeaveDelay={0.1}
  432. >
  433. <a
  434. onClick={(e) => {
  435. e.stopPropagation();
  436. }}
  437. style={{
  438. display: 'inline-flex',
  439. alignItems: 'center',
  440. gap: '4px',
  441. padding: '4px 8px',
  442. cursor: 'pointer',
  443. color: '#000000',
  444. textDecoration: 'none'
  445. }}
  446. onMouseEnter={(e) => {
  447. e.currentTarget.style.color = '#1677ff';
  448. e.currentTarget.style.textDecoration = 'underline';
  449. const svg = e.currentTarget.querySelector('svg');
  450. if (svg) {
  451. svg.setAttribute('style', 'color: #1677ff');
  452. }
  453. const span = e.currentTarget.querySelector('span');
  454. if (span) {
  455. span.setAttribute('style', 'color: #1677ff');
  456. }
  457. }}
  458. onMouseLeave={(e) => {
  459. e.currentTarget.style.color = '#000000';
  460. e.currentTarget.style.textDecoration = 'none';
  461. const svg = e.currentTarget.querySelector('svg');
  462. if (svg) {
  463. svg.setAttribute('style', 'color: #000000');
  464. }
  465. const span = e.currentTarget.querySelector('span');
  466. if (span) {
  467. span.setAttribute('style', 'color: #000000');
  468. }
  469. }}
  470. >
  471. <MoreVertical className="w-4 h-4" style={{ color: '#000000' }} />
  472. <span style={{ color: '#000000' }}>{t('common.more')}</span>
  473. </a>
  474. </Dropdown>
  475. )}
  476. </div>
  477. );
  478. },
  479. },
  480. ];
  481. return (
  482. <>
  483. <style>{`
  484. .ant-switch-checked-green.ant-switch-checked {
  485. background-color: #52c41a !important;
  486. }
  487. `}</style>
  488. <div className="flex gap-6 h-full">
  489. {/* 左侧岗位树 */}
  490. <div className="w-80 flex-shrink-0">
  491. <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm h-full overflow-hidden flex flex-col">
  492. <DeptTree onNodeClick={handleDeptNodeClick} />
  493. </div>
  494. </div>
  495. {/* 右侧用户列表 */}
  496. <div className="flex-1 min-w-0">
  497. <div className="space-y-6">
  498. {/* 搜索栏:搜索条件与 新增/导入/导出 同一行,右侧按钮组;小屏可换行到第二行 */}
  499. <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-4 lg:p-5">
  500. <div className="flex items-center justify-between gap-3 flex-wrap min-w-0">
  501. {/* 搜索条件 + 搜索、重置:按原宽度 200px,不超出,可缩小 */}
  502. <div className="flex items-center gap-2 lg:gap-3 flex-wrap min-w-0">
  503. <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
  504. <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.nickname')}:</label>
  505. <Input
  506. value={queryParams.nickname}
  507. onChange={(e) => setQueryParams({ ...queryParams, nickname: e.target.value })}
  508. onPressEnter={handleQuery}
  509. placeholder={t('form.nicknamePlaceholder')}
  510. className="min-w-0 w-full"
  511. allowClear
  512. />
  513. </div>
  514. <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
  515. <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.username')}:</label>
  516. <Input
  517. value={queryParams.username}
  518. onChange={(e) => setQueryParams({ ...queryParams, username: e.target.value })}
  519. onPressEnter={handleQuery}
  520. placeholder={t('form.usernamePlaceholder')}
  521. className="min-w-0 w-full"
  522. allowClear
  523. />
  524. </div>
  525. <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
  526. <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.mobile')}:</label>
  527. <Input
  528. value={queryParams.mobile}
  529. onChange={(e) => setQueryParams({ ...queryParams, mobile: e.target.value })}
  530. onPressEnter={handleQuery}
  531. placeholder={t('form.mobilePlaceholder')}
  532. className="min-w-0 w-full"
  533. allowClear
  534. />
  535. </div>
  536. <Space className="flex-shrink-0" size="small">
  537. <PermissionWrapper permission="system:user:query">
  538. <Button
  539. type="primary"
  540. icon={<Search className="w-4 h-4" />}
  541. onClick={handleQuery}
  542. >
  543. {t('common.search')}
  544. </Button>
  545. </PermissionWrapper>
  546. <PermissionWrapper permission="system:user:query">
  547. <Button
  548. icon={<RefreshCw className="w-4 h-4" />}
  549. onClick={resetQuery}
  550. >
  551. {t('common.reset')}
  552. </Button>
  553. </PermissionWrapper>
  554. </Space>
  555. </div>
  556. {/* 新增 / 导入 / 导出:同一行靠右,空间不足时整组换到第二行 */}
  557. <div className="flex items-center flex-shrink-0 ml-auto">
  558. <Space size="small">
  559. <PermissionWrapper permission="system:user:create">
  560. <Button
  561. type="primary"
  562. icon={<Plus className="w-4 h-4" />}
  563. onClick={() => openForm('create')}
  564. >
  565. {t('common.addNew')}
  566. </Button>
  567. </PermissionWrapper>
  568. <PermissionWrapper permission="system:user:create">
  569. <Button
  570. icon={<Upload className="w-4 h-4" />}
  571. onClick={handleImport}
  572. >
  573. {t('common.import')}
  574. </Button>
  575. </PermissionWrapper>
  576. <PermissionWrapper permission="system:user:export">
  577. <Button
  578. icon={<Download className="w-4 h-4" />}
  579. onClick={handleExport}
  580. loading={exportLoading}
  581. >
  582. {t('common.export')}
  583. </Button>
  584. </PermissionWrapper>
  585. </Space>
  586. </div>
  587. </div>
  588. </div>
  589. {/* 表格容器 */}
  590. <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden min-w-0">
  591. <Table
  592. columns={columns}
  593. dataSource={list}
  594. rowKey="id"
  595. loading={loading}
  596. pagination={false}
  597. scroll={{ x: 'max-content' }}
  598. />
  599. </div>
  600. {/* 分页 */}
  601. {!loading && list.length > 0 && (
  602. <div className="bg-white rounded-lg border border-gray-200 px-6 py-4">
  603. <div className="flex items-center justify-between">
  604. <div className="text-sm text-gray-600">
  605. {t('common.total')} <span className="text-blue-600 font-medium">{total}</span> {t('common.records')}
  606. </div>
  607. <div className="flex gap-2">
  608. <Button
  609. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
  610. disabled={queryParams.pageNo <= 1}
  611. >
  612. {t('common.prevPage')}
  613. </Button>
  614. <span className="px-4 py-2 text-sm text-gray-600 flex items-center">
  615. {queryParams.pageNo} / {Math.ceil(total / queryParams.pageSize) || 1}
  616. </span>
  617. <Button
  618. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
  619. disabled={queryParams.pageNo >= Math.ceil(total / queryParams.pageSize)}
  620. >
  621. {t('common.nextPage')}
  622. </Button>
  623. </div>
  624. </div>
  625. </div>
  626. )}
  627. </div>
  628. </div>
  629. {/* 相关子组件 */}
  630. <UserForm ref={formRef} onSuccess={handleUserFormSuccess} />
  631. <UserImportForm ref={importFormRef} onSuccess={getList} />
  632. <UserAssignRoleForm ref={assignRoleFormRef} onSuccess={getList} />
  633. <FaceOrFingerForm ref={faceOrFingerFormRef} onSuccess={getList} />
  634. {/* 重置密码弹框 */}
  635. <Modal
  636. title={t('form.resetPasswordTitle')}
  637. open={resetPwdModalVisible}
  638. onOk={submitResetPassword}
  639. onCancel={() => {
  640. setResetPwdModalVisible(false);
  641. setResetPwdPassword('');
  642. setResetPwdUser(null);
  643. }}
  644. confirmLoading={resetPwdLoading}
  645. okText={t('common.confirm')}
  646. cancelText={t('common.cancel')}
  647. width={500}
  648. >
  649. <div style={{ marginTop: 16 }}>
  650. <p style={{ marginBottom: 12 }}>
  651. {t('form.resetPasswordPrompt')} <strong>"{resetPwdUser?.username}"</strong> {t('form.resetPasswordNewPassword')}
  652. </p>
  653. <Input.Password
  654. placeholder={t('form.resetPasswordPlaceholder')}
  655. value={resetPwdPassword}
  656. onChange={(e) => setResetPwdPassword(e.target.value)}
  657. onPressEnter={submitResetPassword}
  658. autoFocus
  659. />
  660. </div>
  661. </Modal>
  662. </div>
  663. </>
  664. );
  665. }