| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704 |
- import React, { useState, useEffect, useRef } from 'react';
- import { Plus, Search, Upload, Download, RefreshCw, Edit2, MoreVertical } from 'lucide-react';
- import { userApi } from '../api/user';
- import { userImportApi } from '../api/user/import';
- import { postApi, PostVO } from '../api/Post';
- import { UserVO, WorkstationNode } from '../types';
- import { toast } from 'sonner';
- import { Modal, Table, Input, Button, Switch, Dropdown, Space, Tooltip, Form } from 'antd';
- import { ExclamationCircleOutlined, DeleteOutlined, KeyOutlined, UserSwitchOutlined } from '@ant-design/icons';
- import { Button as UIButton } from './ui/button';
- import type { ColumnsType } from 'antd/es/table';
- import DeptTree from './user/DeptTree';
- import UserForm, { UserFormRef } from './user/UserForm';
- import UserImportForm, { UserImportFormRef } from './user/UserImportForm';
- import UserAssignRoleForm, { UserAssignRoleFormRef } from './user/UserAssignRoleForm';
- import FaceOrFingerForm, { FaceOrFingerFormRef } from './user/FaceOrFingerForm';
- import { useTranslation } from 'react-i18next';
- import PermissionWrapper from './PermissionWrapper';
- import { hasPermission } from '../utils/permission';
- import { formatDateWithFormat } from '../utils/formatTime';
- interface UserManagementProps {
- subMenu: string;
- }
- export default function UserManagement({ subMenu }: UserManagementProps) {
- const { t } = useTranslation();
- const [loading, setLoading] = useState(true);
- const [list, setList] = useState<UserVO[]>([]);
- const [total, setTotal] = useState(0);
- const [postList, setPostList] = useState<PostVO[]>([]);
- const [queryParams, setQueryParams] = useState({
- pageNo: 1,
- pageSize: 10,
- nickname:'',
- username: '',
- mobile: '',
- status: undefined as number | undefined,
- deptId: undefined as number | undefined,
- createTime: [] as string[],
- });
- const [exportLoading, setExportLoading] = useState(false);
- const [resetPwdModalVisible, setResetPwdModalVisible] = useState(false);
- const [resetPwdUser, setResetPwdUser] = useState<UserVO | null>(null);
- const [resetPwdPassword, setResetPwdPassword] = useState('');
- const [resetPwdLoading, setResetPwdLoading] = useState(false);
- // 子组件引用
- const formRef = useRef<UserFormRef>(null);
- const importFormRef = useRef<UserImportFormRef>(null);
- const assignRoleFormRef = useRef<UserAssignRoleFormRef>(null);
- const faceOrFingerFormRef = useRef<FaceOrFingerFormRef>(null);
- // 获取用户列表
- const getList = async (params?: typeof queryParams) => {
- const currentParams = params || queryParams;
- setLoading(true);
- try {
- const response = await userApi.getUserPage(currentParams);
- setList(response.list || []);
- setTotal(response.total || 0);
- } catch (error: any) {
- toast.error(error.message || t('common.error') || '获取用户列表失败');
- } finally {
- setLoading(false);
- }
- };
- // 加载岗位列表
- useEffect(() => {
- const loadPostList = async () => {
- try {
- const response = await postApi.getSimplePostList();
- // 处理响应数据,可能是直接返回数组,也可能包装在 data 中
- const posts = (response as any)?.data || response;
- setPostList(Array.isArray(posts) ? posts : []);
- } catch (error) {
- console.error('加载岗位列表失败:', error);
- setPostList([]);
- }
- };
- loadPostList();
- }, []);
- useEffect(() => {
- getList();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [queryParams.pageNo, queryParams.pageSize, queryParams.deptId]);
- // 搜索
- const handleQuery = () => {
- const newParams = { ...queryParams, pageNo: 1 };
- setQueryParams(newParams);
- getList(newParams);
- };
- // 重置搜索
- const resetQuery = () => {
- // 先清空表格数据
- setList([]);
- setTotal(0);
- // 重置所有查询参数
- const resetParams = {
- pageNo: 1,
- pageSize: 10,
- nickname: '',
- username: '',
- mobile: '',
- status: undefined as number | undefined,
- deptId: undefined as number | undefined,
- createTime: [] as string[],
- };
- setQueryParams(resetParams);
- // 立即使用重置后的参数获取列表,不需要等待状态更新
- getList(resetParams);
- };
- // 处理部门节点点击
- const handleDeptNodeClick = (node: WorkstationNode) => {
- setQueryParams(prev => ({ ...prev, deptId: node.id, pageNo: 1 }));
- };
- // 打开表单
- const openForm = (type: string, id?: number) => {
- formRef.current?.open(type, id);
- };
- const handleUserFormSuccess = (type: 'create' | 'update') => {
- if (type === 'create') {
- const nextParams = { ...queryParams, pageNo: 1 };
- setQueryParams(nextParams);
- getList(nextParams);
- return;
- }
- getList();
- };
- // 用户导入
- const handleImport = () => {
- importFormRef.current?.open();
- };
- // 导出用户
- const handleExport = async () => {
- Modal.confirm({
- title: t('form.exportUserConfirm'),
- icon: <ExclamationCircleOutlined />,
- content: t('form.exportUserConfirmText'),
- okText: t('form.exportUserConfirmButton'),
- cancelText: t('common.cancel'),
- onOk: async () => {
- setExportLoading(true);
- try {
- const blob = await userImportApi.exportUser(queryParams);
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = t('form.userDataFileName');
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
- toast.success(t('form.exportUserSuccess'));
- } catch (error: any) {
- toast.error(error.message || t('form.exportUserFailed'));
- } finally {
- setExportLoading(false);
- }
- },
- });
- };
- // 修改用户状态
- const handleStatusChange = async (row: UserVO, newChecked: boolean) => {
- const newStatus = newChecked ? 0 : 1; // 0是开启,1是关闭
- const text = newStatus === 0 ? t('common.enabled') : t('common.disabled');
-
- // 使用 antd 的确认弹框
- Modal.confirm({
- title: t('common.confirmOperation') || '确认操作',
- icon: <ExclamationCircleOutlined />,
- content: `${t('common.confirmText') || '确定要'}${text}${t('common.user') || '用户'}"${row.username}"?`,
- okText: t('common.confirm'),
- cancelText: t('common.cancel'),
- onOk: async () => {
- try {
- await userApi.updateUserStatus(row.id, newStatus);
- toast.success(`用户${text}成功`);
- // 刷新列表以更新状态
- await getList();
- } catch (error: any) {
- toast.error(error.message || `用户${text}失败`);
- // 接口调用失败,刷新列表以恢复Switch状态
- await getList();
- }
- },
- onCancel: async () => {
- // 用户取消操作,立即刷新列表以恢复Switch状态
- // 由于Switch是受控组件,刷新列表后会自动恢复到原始状态(基于row.status)
- await getList();
- },
- });
- };
- // 删除用户
- const handleDelete = async (id: number, username?: string) => {
- Modal.confirm({
- title: t('common.confirmDelete'),
- icon: <ExclamationCircleOutlined />,
- content: (
- <div>
- <p>{t('common.confirmDeleteText')} <strong>"{username || t('common.user') || '该用户'}"</strong>?</p>
- <p style={{ color: '#ff4d4f', marginTop: '8px' }}>{t('common.confirmDeleteWarning')}</p>
- </div>
- ),
- okText: t('common.confirmDelete'),
- okType: 'danger',
- cancelText: t('common.cancel'),
- onOk: async () => {
- try {
- await userApi.deleteUser(id);
- toast.success(t('common.deleteSuccess'));
- await getList();
- } catch (error: any) {
- toast.error(error.message || t('common.deleteFailed'));
- }
- },
- });
- };
- // 重置密码
- const handleResetPwd = (row: UserVO) => {
- setResetPwdUser(row);
- setResetPwdPassword('');
- setResetPwdModalVisible(true);
- };
- // 提交重置密码
- const submitResetPassword = async () => {
- if (!resetPwdPassword.trim()) {
- toast.error(t('common.pleaseEnter') + t('common.newPassword') || '请输入新密码');
- return;
- }
- if (!resetPwdUser) {
- return;
- }
- setResetPwdLoading(true);
- try {
- await userApi.resetUserPassword(resetPwdUser.id, resetPwdPassword);
- toast.success(`${t('form.resetPasswordSuccess')}${resetPwdPassword}`);
- setResetPwdModalVisible(false);
- setResetPwdPassword('');
- setResetPwdUser(null);
- } catch (error: any) {
- toast.error(error.message || t('common.resetPasswordFailed') || '重置密码失败');
- } finally {
- setResetPwdLoading(false);
- }
- };
- // 分配角色
- const handleRole = (row: UserVO) => {
- assignRoleFormRef.current?.open(row);
- };
- // 打开指纹或人脸弹框
- const openFaceOrFingerForm = (type: string, row: UserVO) => {
- faceOrFingerFormRef.current?.open(type, row.id, row);
- };
- // 表格列配置
- const columns: ColumnsType<UserVO> = [
- {
- title: t('common.serialNumber'),
- width: '5%',
- render: (_: any, __: UserVO, index: number) => {
- return (queryParams.pageNo - 1) * queryParams.pageSize + index + 1;
- },
- },
- {
- title: t('table.userId'),
- dataIndex: 'id',
- width: '6%',
- },
- {
- title: t('table.username'),
- dataIndex: 'username',
- width: '10%',
- },
- {
- title: t('table.nickname'),
- dataIndex: 'nickname',
- width: '10%',
- },
- {
- title: t('table.mobile'),
- dataIndex: 'mobile',
- width: '12%',
- render: (text: string) => text || '-',
- },
- {
- title: t('table.department'),
- width: '10%',
- render: (_: any, record: UserVO) => {
- return (record as any).deptName || '-';
- },
- },
- {
- title: t('table.position'),
- width: '10%',
- render: (_: any, record: UserVO) => {
- // 根据 postIds 匹配岗位名称
- if (record.postIds && Array.isArray(record.postIds) && record.postIds.length > 0) {
- const postNames = record.postIds
- .map((postId: string | number) => {
- // 将 postId 转换为数字进行比较
- const id = typeof postId === 'string' ? Number(postId) : postId;
- const post = postList.find(p => p.id === id);
- return post ? post.name : null;
- })
- .filter((name: string | null) => name !== null);
-
- const displayText = postNames.length > 0 ? postNames.join(',') : '-';
-
- return (
- <Tooltip
- title={
- <div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
- {displayText}
- </div>
- }
- placement="topLeft"
- >
- <div
- style={{
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- cursor: 'help',
- }}
- >
- {displayText}
- </div>
- </Tooltip>
- );
- }
- return '-';
- },
- },
- {
- title: t('table.facePhoto'),
- width: '6%',
- render: (_: any, record: UserVO) => (
- <Button
- type="link"
- onClick={() => openFaceOrFingerForm('face', record)}
- style={{ padding: 0 }}
- >
- {t('common.view')}
- </Button>
- ),
- },
- {
- title: t('table.status'),
- width: '6%',
- render: (_: any, record: UserVO) => (
- <Switch
- checked={record.status === 0}
- onChange={(checked) => handleStatusChange(record, checked)}
- style={{
- ...(record.status === 0 ? {
- backgroundColor: '#52c41a',
- } : {})
- }}
- className={record.status === 0 ? 'ant-switch-checked-green' : ''}
- />
- ),
- },
- {
- title: t('table.expireTime'),
- dataIndex: 'expireTime',
- width: '10%',
- render: (text: string | number) => text ? formatDateWithFormat(text) : '-',
- },
- {
- title: t('table.operation'),
- width: '10%',
- align: 'center',
- render: (_: any, record: UserVO) => {
- const menuItems = [
- {
- key: 'delete',
- label: t('common.delete'),
- icon: <DeleteOutlined />,
- onClick: () => handleDelete(record.id, record.username),
- },
- {
- key: 'resetPwd',
- label: t('common.resetPassword'),
- icon: <KeyOutlined />,
- onClick: () => handleResetPwd(record),
- },
- {
- key: 'assignRole',
- label: t('common.assignRole'),
- icon: <UserSwitchOutlined />,
- onClick: () => handleRole(record),
- },
- ];
- const filteredMenuItems = menuItems.filter(item => {
- if (item.key === 'delete') {
- return hasPermission('system:user:delete');
- }
- if (item.key === 'resetPwd') {
- return hasPermission('system:user:update-password');
- }
- if (item.key === 'assignRole') {
- return hasPermission('system:permission:assign-user-role');
- }
- return true;
- });
- return (
- <div className="flex items-center gap-2 justify-center">
- <PermissionWrapper permission="system:user:update">
- <UIButton
- variant="ghost"
- size="sm"
- onClick={() => openForm('update', record.id)}
- className="h-8 px-2 transition-colors hover:underline"
- style={{ color: '#000000' }}
- onMouseEnter={(e) => {
- e.currentTarget.style.color = '#1677ff';
- e.currentTarget.style.textDecoration = 'underline';
- e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #1677ff');
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.color = '#000000';
- e.currentTarget.style.textDecoration = 'none';
- e.currentTarget.querySelector('svg')?.setAttribute('style', 'color: #000000');
- }}
- >
- <Edit2 className="w-4 h-4" style={{ color: '#000000' }} />
- <span className="ml-1">{t('common.edit')}</span>
- </UIButton>
- </PermissionWrapper>
- {filteredMenuItems.length > 0 && (
- <Dropdown
- menu={{ items: filteredMenuItems }}
- trigger={['hover']}
- getPopupContainer={() => document.body}
- placement="bottomRight"
- overlayStyle={{ zIndex: 1050 }}
- mouseEnterDelay={0.1}
- mouseLeaveDelay={0.1}
- >
- <a
- onClick={(e) => {
- e.stopPropagation();
- }}
- style={{
- display: 'inline-flex',
- alignItems: 'center',
- gap: '4px',
- padding: '4px 8px',
- cursor: 'pointer',
- color: '#000000',
- textDecoration: 'none'
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.color = '#1677ff';
- e.currentTarget.style.textDecoration = 'underline';
- const svg = e.currentTarget.querySelector('svg');
- if (svg) {
- svg.setAttribute('style', 'color: #1677ff');
- }
- const span = e.currentTarget.querySelector('span');
- if (span) {
- span.setAttribute('style', 'color: #1677ff');
- }
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.color = '#000000';
- e.currentTarget.style.textDecoration = 'none';
- const svg = e.currentTarget.querySelector('svg');
- if (svg) {
- svg.setAttribute('style', 'color: #000000');
- }
- const span = e.currentTarget.querySelector('span');
- if (span) {
- span.setAttribute('style', 'color: #000000');
- }
- }}
- >
- <MoreVertical className="w-4 h-4" style={{ color: '#000000' }} />
- <span style={{ color: '#000000' }}>{t('common.more')}</span>
- </a>
- </Dropdown>
- )}
- </div>
- );
- },
- },
- ];
- return (
- <>
- <style>{`
- .ant-switch-checked-green.ant-switch-checked {
- background-color: #52c41a !important;
- }
- `}</style>
- <div className="flex gap-6 h-full">
- {/* 左侧岗位树 */}
- <div className="w-80 flex-shrink-0">
- <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm h-full overflow-hidden flex flex-col">
- <DeptTree onNodeClick={handleDeptNodeClick} />
- </div>
- </div>
- {/* 右侧用户列表 */}
- <div className="flex-1 min-w-0">
- <div className="space-y-6">
- {/* 搜索栏:搜索条件与 新增/导入/导出 同一行,右侧按钮组;小屏可换行到第二行 */}
- <div className="bg-white rounded-xl border border-gray-200/50 shadow-sm p-4 lg:p-5">
- <div className="flex items-center justify-between gap-3 flex-wrap min-w-0">
- {/* 搜索条件 + 搜索、重置:按原宽度 200px,不超出,可缩小 */}
- <div className="flex items-center gap-2 lg:gap-3 flex-wrap min-w-0">
- <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
- <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.nickname')}:</label>
- <Input
- value={queryParams.nickname}
- onChange={(e) => setQueryParams({ ...queryParams, nickname: e.target.value })}
- onPressEnter={handleQuery}
- placeholder={t('form.nicknamePlaceholder')}
- className="min-w-0 w-full"
- allowClear
- />
- </div>
- <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
- <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.username')}:</label>
- <Input
- value={queryParams.username}
- onChange={(e) => setQueryParams({ ...queryParams, username: e.target.value })}
- onPressEnter={handleQuery}
- placeholder={t('form.usernamePlaceholder')}
- className="min-w-0 w-full"
- allowClear
- />
- </div>
- <div className="flex items-center gap-2 lg:gap-3 w-[200px] min-w-[100px] flex-shrink">
- <label className="text-sm font-medium text-gray-700 whitespace-nowrap flex-shrink-0">{t('form.mobile')}:</label>
- <Input
- value={queryParams.mobile}
- onChange={(e) => setQueryParams({ ...queryParams, mobile: e.target.value })}
- onPressEnter={handleQuery}
- placeholder={t('form.mobilePlaceholder')}
- className="min-w-0 w-full"
- allowClear
- />
- </div>
- <Space className="flex-shrink-0" size="small">
- <PermissionWrapper permission="system:user:query">
- <Button
- type="primary"
- icon={<Search className="w-4 h-4" />}
- onClick={handleQuery}
- >
- {t('common.search')}
- </Button>
- </PermissionWrapper>
-
- <PermissionWrapper permission="system:user:query">
- <Button
- icon={<RefreshCw className="w-4 h-4" />}
- onClick={resetQuery}
- >
- {t('common.reset')}
- </Button>
- </PermissionWrapper>
- </Space>
- </div>
- {/* 新增 / 导入 / 导出:同一行靠右,空间不足时整组换到第二行 */}
- <div className="flex items-center flex-shrink-0 ml-auto">
- <Space size="small">
- <PermissionWrapper permission="system:user:create">
- <Button
- type="primary"
- icon={<Plus className="w-4 h-4" />}
- onClick={() => openForm('create')}
- >
- {t('common.addNew')}
- </Button>
- </PermissionWrapper>
-
- <PermissionWrapper permission="system:user:create">
- <Button
- icon={<Upload className="w-4 h-4" />}
- onClick={handleImport}
- >
- {t('common.import')}
- </Button>
- </PermissionWrapper>
-
- <PermissionWrapper permission="system:user:export">
- <Button
- icon={<Download className="w-4 h-4" />}
- onClick={handleExport}
- loading={exportLoading}
- >
- {t('common.export')}
- </Button>
- </PermissionWrapper>
- </Space>
- </div>
- </div>
- </div>
- {/* 表格容器 */}
- <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden min-w-0">
- <Table
- columns={columns}
- dataSource={list}
- rowKey="id"
- loading={loading}
- pagination={false}
- scroll={{ x: 'max-content' }}
- />
- </div>
- {/* 分页 */}
- {!loading && list.length > 0 && (
- <div className="bg-white rounded-lg border border-gray-200 px-6 py-4">
- <div className="flex items-center justify-between">
- <div className="text-sm text-gray-600">
- {t('common.total')} <span className="text-blue-600 font-medium">{total}</span> {t('common.records')}
- </div>
- <div className="flex gap-2">
- <Button
- onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo - 1 })}
- disabled={queryParams.pageNo <= 1}
- >
- {t('common.prevPage')}
- </Button>
- <span className="px-4 py-2 text-sm text-gray-600 flex items-center">
- {queryParams.pageNo} / {Math.ceil(total / queryParams.pageSize) || 1}
- </span>
- <Button
- onClick={() => setQueryParams({ ...queryParams, pageNo: queryParams.pageNo + 1 })}
- disabled={queryParams.pageNo >= Math.ceil(total / queryParams.pageSize)}
- >
- {t('common.nextPage')}
- </Button>
- </div>
- </div>
- </div>
- )}
- </div>
- </div>
- {/* 相关子组件 */}
- <UserForm ref={formRef} onSuccess={handleUserFormSuccess} />
- <UserImportForm ref={importFormRef} onSuccess={getList} />
- <UserAssignRoleForm ref={assignRoleFormRef} onSuccess={getList} />
- <FaceOrFingerForm ref={faceOrFingerFormRef} onSuccess={getList} />
- {/* 重置密码弹框 */}
- <Modal
- title={t('form.resetPasswordTitle')}
- open={resetPwdModalVisible}
- onOk={submitResetPassword}
- onCancel={() => {
- setResetPwdModalVisible(false);
- setResetPwdPassword('');
- setResetPwdUser(null);
- }}
- confirmLoading={resetPwdLoading}
- okText={t('common.confirm')}
- cancelText={t('common.cancel')}
- width={500}
- >
- <div style={{ marginTop: 16 }}>
- <p style={{ marginBottom: 12 }}>
- {t('form.resetPasswordPrompt')} <strong>"{resetPwdUser?.username}"</strong> {t('form.resetPasswordNewPassword')}
- </p>
- <Input.Password
- placeholder={t('form.resetPasswordPlaceholder')}
- value={resetPwdPassword}
- onChange={(e) => setResetPwdPassword(e.target.value)}
- onPressEnter={submitResetPassword}
- autoFocus
- />
- </div>
- </Modal>
- </div>
- </>
- );
- }
|