UserManagement.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import React, { useState, useEffect, useRef } from 'react';
  2. import { Plus, Search, Upload, Download, RefreshCw } 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, EditOutlined, DeleteOutlined, KeyOutlined, UserSwitchOutlined, MoreOutlined } from '@ant-design/icons';
  10. import type { ColumnsType } from 'antd/es/table';
  11. import DeptTree from './user/DeptTree';
  12. import UserForm, { UserFormRef } from './user/UserForm';
  13. import UserImportForm, { UserImportFormRef } from './user/UserImportForm';
  14. import UserAssignRoleForm, { UserAssignRoleFormRef } from './user/UserAssignRoleForm';
  15. import FaceOrFingerForm, { FaceOrFingerFormRef } from './user/FaceOrFingerForm';
  16. interface UserManagementProps {
  17. subMenu: string;
  18. }
  19. export default function UserManagement({ subMenu }: UserManagementProps) {
  20. const [loading, setLoading] = useState(true);
  21. const [list, setList] = useState<UserVO[]>([]);
  22. const [total, setTotal] = useState(0);
  23. const [postList, setPostList] = useState<PostVO[]>([]);
  24. const [queryParams, setQueryParams] = useState({
  25. pageNo: 1,
  26. pageSize: 10,
  27. username: '',
  28. mobile: '',
  29. status: undefined as number | undefined,
  30. workstationId: undefined as number | undefined,
  31. createTime: [] as string[],
  32. });
  33. const [exportLoading, setExportLoading] = useState(false);
  34. const [resetPwdModalVisible, setResetPwdModalVisible] = useState(false);
  35. const [resetPwdUser, setResetPwdUser] = useState<UserVO | null>(null);
  36. const [resetPwdPassword, setResetPwdPassword] = useState('');
  37. const [resetPwdLoading, setResetPwdLoading] = useState(false);
  38. // 子组件引用
  39. const formRef = useRef<UserFormRef>(null);
  40. const importFormRef = useRef<UserImportFormRef>(null);
  41. const assignRoleFormRef = useRef<UserAssignRoleFormRef>(null);
  42. const faceOrFingerFormRef = useRef<FaceOrFingerFormRef>(null);
  43. // 获取用户列表
  44. const getList = async (params?: typeof queryParams) => {
  45. const currentParams = params || queryParams;
  46. setLoading(true);
  47. try {
  48. const response = await userApi.getUserPage(currentParams);
  49. setList(response.list || []);
  50. setTotal(response.total || 0);
  51. } catch (error: any) {
  52. toast.error(error.message || '获取用户列表失败');
  53. } finally {
  54. setLoading(false);
  55. }
  56. };
  57. // 加载岗位列表
  58. useEffect(() => {
  59. const loadPostList = async () => {
  60. try {
  61. const response = await postApi.getSimplePostList();
  62. // 处理响应数据,可能是直接返回数组,也可能包装在 data 中
  63. const posts = (response as any)?.data || response;
  64. setPostList(Array.isArray(posts) ? posts : []);
  65. } catch (error) {
  66. console.error('加载岗位列表失败:', error);
  67. setPostList([]);
  68. }
  69. };
  70. loadPostList();
  71. }, []);
  72. useEffect(() => {
  73. getList();
  74. // eslint-disable-next-line react-hooks/exhaustive-deps
  75. }, [queryParams.pageNo, queryParams.pageSize, queryParams.workstationId]);
  76. // 搜索
  77. const handleQuery = () => {
  78. const newParams = { ...queryParams, pageNo: 1 };
  79. setQueryParams(newParams);
  80. getList(newParams);
  81. };
  82. // 重置搜索
  83. const resetQuery = () => {
  84. // 先清空表格数据
  85. setList([]);
  86. setTotal(0);
  87. // 重置所有查询参数
  88. const resetParams = {
  89. pageNo: 1,
  90. pageSize: 10,
  91. username: '',
  92. mobile: '',
  93. status: undefined as number | undefined,
  94. workstationId: undefined as number | undefined,
  95. createTime: [] as string[],
  96. };
  97. setQueryParams(resetParams);
  98. // 立即使用重置后的参数获取列表,不需要等待状态更新
  99. getList(resetParams);
  100. };
  101. // 处理部门节点点击
  102. const handleDeptNodeClick = (node: WorkstationNode) => {
  103. setQueryParams(prev => ({ ...prev, workstationId: node.id, pageNo: 1 }));
  104. };
  105. // 打开表单
  106. const openForm = (type: string, id?: number) => {
  107. formRef.current?.open(type, id);
  108. };
  109. // 用户导入
  110. const handleImport = () => {
  111. importFormRef.current?.open();
  112. };
  113. // 导出用户
  114. const handleExport = async () => {
  115. Modal.confirm({
  116. title: '确认导出',
  117. icon: <ExclamationCircleOutlined />,
  118. content: '确定要导出用户数据吗?',
  119. okText: '确定导出',
  120. cancelText: '取消',
  121. onOk: async () => {
  122. setExportLoading(true);
  123. try {
  124. const blob = await userImportApi.exportUser(queryParams);
  125. const url = window.URL.createObjectURL(blob);
  126. const link = document.createElement('a');
  127. link.href = url;
  128. link.download = '用户数据.xls';
  129. document.body.appendChild(link);
  130. link.click();
  131. document.body.removeChild(link);
  132. window.URL.revokeObjectURL(url);
  133. toast.success('导出成功');
  134. } catch (error: any) {
  135. toast.error(error.message || '导出失败');
  136. } finally {
  137. setExportLoading(false);
  138. }
  139. },
  140. });
  141. };
  142. // 修改用户状态
  143. const handleStatusChange = async (row: UserVO, newChecked: boolean) => {
  144. const newStatus = newChecked ? 0 : 1; // 0是开启,1是关闭
  145. const text = newStatus === 0 ? '启用' : '停用';
  146. // 使用 antd 的确认弹框
  147. Modal.confirm({
  148. title: '确认操作',
  149. icon: <ExclamationCircleOutlined />,
  150. content: `确定要${text}用户"${row.username}"吗?`,
  151. okText: '确定',
  152. cancelText: '取消',
  153. onOk: async () => {
  154. try {
  155. await userApi.updateUserStatus(row.id, newStatus);
  156. toast.success(`用户${text}成功`);
  157. // 刷新列表以更新状态
  158. await getList();
  159. } catch (error: any) {
  160. toast.error(error.message || `用户${text}失败`);
  161. // 接口调用失败,刷新列表以恢复Switch状态
  162. await getList();
  163. }
  164. },
  165. onCancel: async () => {
  166. // 用户取消操作,立即刷新列表以恢复Switch状态
  167. // 由于Switch是受控组件,刷新列表后会自动恢复到原始状态(基于row.status)
  168. await getList();
  169. },
  170. });
  171. };
  172. // 删除用户
  173. const handleDelete = async (id: number, username?: string) => {
  174. Modal.confirm({
  175. title: '确认删除',
  176. icon: <ExclamationCircleOutlined />,
  177. content: (
  178. <div>
  179. <p>确定要删除用户 <strong>"{username || '该用户'}"</strong> 吗?</p>
  180. <p style={{ color: '#ff4d4f', marginTop: '8px' }}>删除后无法恢复,请谨慎操作!</p>
  181. </div>
  182. ),
  183. okText: '确定删除',
  184. okType: 'danger',
  185. cancelText: '取消',
  186. onOk: async () => {
  187. try {
  188. await userApi.deleteUser(id);
  189. toast.success('删除成功');
  190. await getList();
  191. } catch (error: any) {
  192. toast.error(error.message || '删除失败');
  193. }
  194. },
  195. });
  196. };
  197. // 重置密码
  198. const handleResetPwd = (row: UserVO) => {
  199. setResetPwdUser(row);
  200. setResetPwdPassword('');
  201. setResetPwdModalVisible(true);
  202. };
  203. // 提交重置密码
  204. const submitResetPassword = async () => {
  205. if (!resetPwdPassword.trim()) {
  206. toast.error('请输入新密码');
  207. return;
  208. }
  209. if (!resetPwdUser) {
  210. return;
  211. }
  212. setResetPwdLoading(true);
  213. try {
  214. await userApi.resetUserPassword(resetPwdUser.id, resetPwdPassword);
  215. toast.success(`修改成功,新密码是:${resetPwdPassword}`);
  216. setResetPwdModalVisible(false);
  217. setResetPwdPassword('');
  218. setResetPwdUser(null);
  219. } catch (error: any) {
  220. toast.error(error.message || '重置密码失败');
  221. } finally {
  222. setResetPwdLoading(false);
  223. }
  224. };
  225. // 分配角色
  226. const handleRole = (row: UserVO) => {
  227. assignRoleFormRef.current?.open(row);
  228. };
  229. // 打开指纹或人脸弹框
  230. const openFaceOrFingerForm = (type: string, row: UserVO) => {
  231. faceOrFingerFormRef.current?.open(type, row.id, row);
  232. };
  233. // 表格列配置
  234. const columns: ColumnsType<UserVO> = [
  235. {
  236. title: '序号',
  237. width: '5%',
  238. render: (_: any, __: UserVO, index: number) => {
  239. return (queryParams.pageNo - 1) * queryParams.pageSize + index + 1;
  240. },
  241. },
  242. {
  243. title: '用户编号',
  244. dataIndex: 'id',
  245. width: '8%',
  246. },
  247. {
  248. title: '账号',
  249. dataIndex: 'username',
  250. width: '10%',
  251. },
  252. {
  253. title: '用户昵称',
  254. dataIndex: 'nickname',
  255. width: '10%',
  256. },
  257. {
  258. title: '手机号码',
  259. dataIndex: 'mobile',
  260. width: '12%',
  261. render: (text: string) => text || '-',
  262. },
  263. {
  264. title: '部门',
  265. width: '10%',
  266. render: (_: any, record: UserVO) => {
  267. return (record as any).deptName || '-';
  268. },
  269. },
  270. {
  271. title: '岗位',
  272. width: '10%',
  273. render: (_: any, record: UserVO) => {
  274. // 根据 postIds 匹配岗位名称
  275. if (record.postIds && Array.isArray(record.postIds) && record.postIds.length > 0) {
  276. const postNames = record.postIds
  277. .map((postId: string | number) => {
  278. // 将 postId 转换为数字进行比较
  279. const id = typeof postId === 'string' ? Number(postId) : postId;
  280. const post = postList.find(p => p.id === id);
  281. return post ? post.name : null;
  282. })
  283. .filter((name: string | null) => name !== null);
  284. const displayText = postNames.length > 0 ? postNames.join(',') : '-';
  285. return (
  286. <Tooltip
  287. title={
  288. <div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
  289. {displayText}
  290. </div>
  291. }
  292. placement="topLeft"
  293. >
  294. <div
  295. style={{
  296. overflow: 'hidden',
  297. textOverflow: 'ellipsis',
  298. whiteSpace: 'nowrap',
  299. cursor: 'help',
  300. }}
  301. >
  302. {displayText}
  303. </div>
  304. </Tooltip>
  305. );
  306. }
  307. return '-';
  308. },
  309. },
  310. {
  311. title: '人脸',
  312. width: '8%',
  313. render: (_: any, record: UserVO) => (
  314. <Button
  315. type="link"
  316. onClick={() => openFaceOrFingerForm('face', record)}
  317. style={{ padding: 0 }}
  318. >
  319. 查看
  320. </Button>
  321. ),
  322. },
  323. {
  324. title: '状态',
  325. width: '8%',
  326. render: (_: any, record: UserVO) => (
  327. <Switch
  328. checked={record.status === 0}
  329. onChange={(checked) => handleStatusChange(record, checked)}
  330. />
  331. ),
  332. },
  333. {
  334. title: '操作',
  335. width: '10%',
  336. align: 'center',
  337. render: (_: any, record: UserVO) => {
  338. const menuItems = [
  339. {
  340. key: 'delete',
  341. label: '删除',
  342. icon: <DeleteOutlined />,
  343. onClick: () => handleDelete(record.id, record.username),
  344. },
  345. {
  346. key: 'resetPwd',
  347. label: '重置密码',
  348. icon: <KeyOutlined />,
  349. onClick: () => handleResetPwd(record),
  350. },
  351. {
  352. key: 'assignRole',
  353. label: '分配角色',
  354. icon: <UserSwitchOutlined />,
  355. onClick: () => handleRole(record),
  356. },
  357. ];
  358. return (
  359. <Space>
  360. <Button
  361. type="link"
  362. icon={<EditOutlined />}
  363. onClick={() => openForm('update', record.id)}
  364. title="编辑"
  365. />
  366. <Dropdown
  367. menu={{ items: menuItems }}
  368. trigger={['click']}
  369. >
  370. <Button
  371. type="link"
  372. icon={<MoreOutlined />}
  373. title="更多"
  374. />
  375. </Dropdown>
  376. </Space>
  377. );
  378. },
  379. },
  380. ];
  381. return (
  382. <div className="flex gap-6 h-full">
  383. {/* 左侧岗位树 */}
  384. <div className="w-80 flex-shrink-0">
  385. <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm h-full overflow-hidden flex flex-col">
  386. <DeptTree onNodeClick={handleDeptNodeClick} />
  387. </div>
  388. </div>
  389. {/* 右侧用户列表 */}
  390. <div className="flex-1 min-w-0">
  391. <div className="space-y-6">
  392. {/* 搜索栏 */}
  393. <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-5">
  394. <div className="flex items-center justify-between gap-4 flex-wrap">
  395. {/* 搜索输入框 */}
  396. <div className="flex items-center gap-3 flex-wrap flex-1">
  397. <div className="flex items-center gap-3">
  398. <label className="text-sm font-medium text-gray-700 whitespace-nowrap">账号:</label>
  399. <Input
  400. value={queryParams.username}
  401. onChange={(e) => setQueryParams({ ...queryParams, username: e.target.value })}
  402. onPressEnter={handleQuery}
  403. placeholder="请输入账号"
  404. style={{ width: 192 }}
  405. allowClear
  406. />
  407. </div>
  408. <div className="flex items-center gap-3">
  409. <label className="text-sm font-medium text-gray-700 whitespace-nowrap">手机号码:</label>
  410. <Input
  411. value={queryParams.mobile}
  412. onChange={(e) => setQueryParams({ ...queryParams, mobile: e.target.value })}
  413. onPressEnter={handleQuery}
  414. placeholder="请输入手机号码"
  415. style={{ width: 192 }}
  416. allowClear
  417. />
  418. </div>
  419. </div>
  420. {/* 操作按钮组 */}
  421. <Space>
  422. <Button
  423. type="primary"
  424. icon={<Search className="w-4 h-4" />}
  425. onClick={handleQuery}
  426. >
  427. 搜索
  428. </Button>
  429. <Button
  430. icon={<RefreshCw className="w-4 h-4" />}
  431. onClick={resetQuery}
  432. >
  433. 重置
  434. </Button>
  435. <Button
  436. type="primary"
  437. icon={<Plus className="w-4 h-4" />}
  438. onClick={() => openForm('create')}
  439. >
  440. 新增
  441. </Button>
  442. <Button
  443. icon={<Upload className="w-4 h-4" />}
  444. onClick={handleImport}
  445. >
  446. 导入
  447. </Button>
  448. <Button
  449. icon={<Download className="w-4 h-4" />}
  450. onClick={handleExport}
  451. loading={exportLoading}
  452. >
  453. 导出
  454. </Button>
  455. </Space>
  456. </div>
  457. </div>
  458. {/* 表格容器 */}
  459. <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden">
  460. <Table
  461. columns={columns}
  462. dataSource={list}
  463. rowKey="id"
  464. loading={loading}
  465. pagination={false}
  466. scroll={{ x: 'max-content' }}
  467. />
  468. </div>
  469. {/* 分页 */}
  470. {!loading && list.length > 0 && (
  471. <div className="bg-white rounded-lg border border-gray-200 px-6 py-4">
  472. <div className="flex items-center justify-between">
  473. <div className="text-sm text-gray-600">
  474. 共 <span className="text-blue-600 font-medium">{total}</span> 条记录
  475. </div>
  476. <div className="flex gap-2">
  477. <Button
  478. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
  479. disabled={queryParams.pageNo <= 1}
  480. >
  481. 上一页
  482. </Button>
  483. <span className="px-4 py-2 text-sm text-gray-600 flex items-center">
  484. {queryParams.pageNo} / {Math.ceil(total / queryParams.pageSize) || 1}
  485. </span>
  486. <Button
  487. onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
  488. disabled={queryParams.pageNo >= Math.ceil(total / queryParams.pageSize)}
  489. >
  490. 下一页
  491. </Button>
  492. </div>
  493. </div>
  494. </div>
  495. )}
  496. </div>
  497. </div>
  498. {/* 相关子组件 */}
  499. <UserForm ref={formRef} onSuccess={getList} />
  500. <UserImportForm ref={importFormRef} onSuccess={getList} />
  501. <UserAssignRoleForm ref={assignRoleFormRef} onSuccess={getList} />
  502. <FaceOrFingerForm ref={faceOrFingerFormRef} onSuccess={getList} />
  503. {/* 重置密码弹框 */}
  504. <Modal
  505. title="重置密码"
  506. open={resetPwdModalVisible}
  507. onOk={submitResetPassword}
  508. onCancel={() => {
  509. setResetPwdModalVisible(false);
  510. setResetPwdPassword('');
  511. setResetPwdUser(null);
  512. }}
  513. confirmLoading={resetPwdLoading}
  514. okText="确定"
  515. cancelText="取消"
  516. width={500}
  517. >
  518. <div style={{ marginTop: 16 }}>
  519. <p style={{ marginBottom: 12 }}>
  520. 请输入用户 <strong>"{resetPwdUser?.username}"</strong> 的新密码:
  521. </p>
  522. <Input.Password
  523. placeholder="请输入新密码"
  524. value={resetPwdPassword}
  525. onChange={(e) => setResetPwdPassword(e.target.value)}
  526. onPressEnter={submitResetPassword}
  527. autoFocus
  528. />
  529. </div>
  530. </Modal>
  531. </div>
  532. );
  533. }