| | |
| | | import { useMemo, useState } from 'react'; |
| | | import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| | | import { |
| | | App as AntdApp, |
| | | Avatar, |
| | | Button, |
| | | Card, |
| | | Form, |
| | | Input, |
| | | Modal, |
| | | Select, |
| | | Space, |
| | | Table, |
| | | Tag, |
| | | } from 'antd'; |
| | | import type { TableProps } from 'antd'; |
| | | import dayjs from 'dayjs'; |
| | | DownloadOutlined, |
| | | InboxOutlined, |
| | | PlusOutlined, |
| | | UploadOutlined, |
| | | } from '@ant-design/icons'; |
| | | import { ProTable } from '@ant-design/pro-components'; |
| | | import type { ActionType } from '@ant-design/pro-components'; |
| | | import { App as AntdApp, Button, Modal, Upload } from 'antd'; |
| | | import type { UploadFile } from 'antd'; |
| | | import { createUserColumns } from './columns'; |
| | | import UserFormDrawer from './components/UserFormDrawer'; |
| | | import type { UserFormMode } from './components/UserFormDrawer'; |
| | | import { |
| | | deleteEmployee, |
| | | exportEmployees, |
| | | getEmployeeList, |
| | | getOrgTree, |
| | | getRoleOptions, |
| | | importEmployees, |
| | | } from './service'; |
| | | import type { |
| | | EmployeeListQuery, |
| | | EmployeeVO, |
| | | OrgTreeNode, |
| | | SelectOption, |
| | | TenantEmployeeListRequest, |
| | | } from './types'; |
| | | import './Users.scss'; |
| | | |
| | | type UserStatus = 'active' | 'disabled'; |
| | | |
| | | interface UserRecord { |
| | | key: number; |
| | | username: string; |
| | | nickname: string; |
| | | role: string; |
| | | status: UserStatus; |
| | | createdAt: string; |
| | | } |
| | | |
| | | interface UserFormValues { |
| | | username: string; |
| | | nickname: string; |
| | | role: string; |
| | | status: UserStatus; |
| | | } |
| | | |
| | | const roleOptions = [ |
| | | { value: '超级管理员', label: '超级管理员' }, |
| | | { value: '运营管理员', label: '运营管理员' }, |
| | | { value: '销售专员', label: '销售专员' }, |
| | | { value: '访客', label: '访客' }, |
| | | ]; |
| | | |
| | | const roleColor: Record<string, string> = { |
| | | 超级管理员: 'purple', |
| | | 运营管理员: 'blue', |
| | | 销售专员: 'cyan', |
| | | 访客: 'default', |
| | | }; |
| | | |
| | | const statusMeta: Record<UserStatus, { label: string; color: string }> = { |
| | | active: { label: '启用', color: 'green' }, |
| | | disabled: { label: '禁用', color: 'default' }, |
| | | }; |
| | | |
| | | const initialUsers: UserRecord[] = [ |
| | | { |
| | | key: 1, |
| | | username: 'admin', |
| | | nickname: '陈一鸣', |
| | | role: '超级管理员', |
| | | status: 'active', |
| | | createdAt: '2026-01-08 09:30', |
| | | }, |
| | | { |
| | | key: 2, |
| | | username: 'lin.xia', |
| | | nickname: '林夏', |
| | | role: '运营管理员', |
| | | status: 'active', |
| | | createdAt: '2026-03-12 14:20', |
| | | }, |
| | | { |
| | | key: 3, |
| | | username: 'zhou.yang', |
| | | nickname: '周洋', |
| | | role: '销售专员', |
| | | status: 'active', |
| | | createdAt: '2026-04-02 10:05', |
| | | }, |
| | | { |
| | | key: 4, |
| | | username: 'su.wen', |
| | | nickname: '苏雯', |
| | | role: '销售专员', |
| | | status: 'active', |
| | | createdAt: '2026-05-18 16:42', |
| | | }, |
| | | { |
| | | key: 5, |
| | | username: 'he.jun', |
| | | nickname: '何俊', |
| | | role: '运营管理员', |
| | | status: 'disabled', |
| | | createdAt: '2026-06-09 11:18', |
| | | }, |
| | | { |
| | | key: 6, |
| | | username: 'yang.li', |
| | | nickname: '杨莉', |
| | | role: '销售专员', |
| | | status: 'active', |
| | | createdAt: '2026-07-21 15:54', |
| | | }, |
| | | { |
| | | key: 7, |
| | | username: 'wang.qi', |
| | | nickname: '王琪', |
| | | role: '访客', |
| | | status: 'disabled', |
| | | createdAt: '2026-08-03 09:12', |
| | | }, |
| | | { |
| | | key: 8, |
| | | username: 'zhao.min', |
| | | nickname: '赵敏', |
| | | role: '销售专员', |
| | | status: 'active', |
| | | createdAt: '2026-09-15 13:26', |
| | | }, |
| | | ]; |
| | | const flattenOrgOptions = (nodes: OrgTreeNode[]): SelectOption[] => |
| | | nodes.flatMap((node) => [ |
| | | { label: node.orgName, value: node.id }, |
| | | ...flattenOrgOptions(node.children ?? []), |
| | | ]); |
| | | |
| | | const Users = () => { |
| | | const [users, setUsers] = useState<UserRecord[]>(initialUsers); |
| | | const [keyword, setKeyword] = useState(''); |
| | | const [status, setStatus] = useState<UserStatus | 'all'>('all'); |
| | | const [isModalOpen, setIsModalOpen] = useState(false); |
| | | const [editingUser, setEditingUser] = useState<UserRecord | null>(null); |
| | | const [form] = Form.useForm<UserFormValues>(); |
| | | const actionRef = useRef<ActionType>(); |
| | | const lastQueryRef = useRef<TenantEmployeeListRequest>({}); |
| | | const { message, modal } = AntdApp.useApp(); |
| | | const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]); |
| | | const [roleOptions, setRoleOptions] = useState<SelectOption[]>([]); |
| | | const [formOpen, setFormOpen] = useState(false); |
| | | const [formMode, setFormMode] = useState<UserFormMode>('create'); |
| | | const [activeEmployee, setActiveEmployee] = useState<EmployeeVO | null>(null); |
| | | const [importOpen, setImportOpen] = useState(false); |
| | | const [importFileList, setImportFileList] = useState<UploadFile[]>([]); |
| | | const [importing, setImporting] = useState(false); |
| | | const [exporting, setExporting] = useState(false); |
| | | |
| | | const filteredUsers = useMemo(() => { |
| | | const normalizedKeyword = keyword.trim().toLowerCase(); |
| | | return users.filter((user) => { |
| | | const matchesKeyword = |
| | | !normalizedKeyword || |
| | | user.username.toLowerCase().includes(normalizedKeyword) || |
| | | user.nickname.toLowerCase().includes(normalizedKeyword); |
| | | const matchesStatus = status === 'all' || user.status === status; |
| | | return matchesKeyword && matchesStatus; |
| | | }); |
| | | }, [users, keyword, status]); |
| | | useEffect(() => { |
| | | let active = true; |
| | | |
| | | const openCreateModal = () => { |
| | | setEditingUser(null); |
| | | form.resetFields(); |
| | | form.setFieldsValue({ status: 'active', role: '销售专员' }); |
| | | setIsModalOpen(true); |
| | | }; |
| | | const loadOptions = async () => { |
| | | try { |
| | | const [orgs, roles] = await Promise.all([getOrgTree(), getRoleOptions()]); |
| | | if (!active) return; |
| | | |
| | | const openEditModal = (record: UserRecord) => { |
| | | setEditingUser(record); |
| | | form.setFieldsValue({ |
| | | username: record.username, |
| | | nickname: record.nickname, |
| | | role: record.role, |
| | | status: record.status, |
| | | }); |
| | | setIsModalOpen(true); |
| | | }; |
| | | setOrgTree(orgs); |
| | | setRoleOptions(roles.map((role) => ({ label: role.roleName, value: role.id }))); |
| | | } catch { |
| | | // 错误已由 request 统一提示 |
| | | } |
| | | }; |
| | | |
| | | const closeModal = () => { |
| | | setIsModalOpen(false); |
| | | form.resetFields(); |
| | | }; |
| | | void loadOptions(); |
| | | |
| | | const handleSubmit = async () => { |
| | | return () => { |
| | | active = false; |
| | | }; |
| | | }, []); |
| | | |
| | | const orgOptions = useMemo(() => flattenOrgOptions(orgTree), [orgTree]); |
| | | |
| | | const orgNameMap = useMemo( |
| | | () => new Map(orgOptions.map((option) => [option.value, option.label])), |
| | | [orgOptions], |
| | | ); |
| | | |
| | | const roleNameMap = useMemo( |
| | | () => new Map(roleOptions.map((option) => [option.value, option.label])), |
| | | [roleOptions], |
| | | ); |
| | | |
| | | const openCreate = useCallback(() => { |
| | | setActiveEmployee(null); |
| | | setFormMode('create'); |
| | | setFormOpen(true); |
| | | }, []); |
| | | |
| | | const closeForm = useCallback(() => { |
| | | setFormOpen(false); |
| | | setActiveEmployee(null); |
| | | }, []); |
| | | |
| | | const handleFormSuccess = useCallback(() => { |
| | | closeForm(); |
| | | actionRef.current?.reload(); |
| | | }, [closeForm]); |
| | | |
| | | const openEdit = useCallback((record: EmployeeVO) => { |
| | | setActiveEmployee(record); |
| | | setFormMode('edit'); |
| | | setFormOpen(true); |
| | | }, []); |
| | | |
| | | const openDetail = useCallback((record: EmployeeVO) => { |
| | | setActiveEmployee(record); |
| | | setFormMode('detail'); |
| | | setFormOpen(true); |
| | | }, []); |
| | | |
| | | const handleDelete = useCallback( |
| | | (record: EmployeeVO) => { |
| | | modal.confirm({ |
| | | title: '删除员工', |
| | | content: `确定要删除员工「${record.name}」吗?删除后不可恢复。`, |
| | | okText: '删除', |
| | | cancelText: '取消', |
| | | okButtonProps: { danger: true }, |
| | | onOk: async () => { |
| | | await deleteEmployee({ id: record.id }); |
| | | message.success('员工已删除'); |
| | | actionRef.current?.reload(); |
| | | }, |
| | | }); |
| | | }, |
| | | [message, modal], |
| | | ); |
| | | |
| | | const handleExport = useCallback(async () => { |
| | | setExporting(true); |
| | | try { |
| | | const values = await form.validateFields(); |
| | | const duplicate = users.some( |
| | | (user) => user.username === values.username && user.key !== editingUser?.key, |
| | | ); |
| | | if (duplicate) { |
| | | message.error('用户名已存在,请更换一个'); |
| | | return; |
| | | } |
| | | |
| | | if (editingUser) { |
| | | setUsers((prev) => |
| | | prev.map((user) => |
| | | user.key === editingUser.key ? { ...user, ...values } : user, |
| | | ), |
| | | ); |
| | | message.success('用户信息已更新'); |
| | | } else { |
| | | setUsers((prev) => [ |
| | | { |
| | | key: Date.now(), |
| | | ...values, |
| | | createdAt: dayjs().format('YYYY-MM-DD HH:mm'), |
| | | }, |
| | | ...prev, |
| | | ]); |
| | | message.success('用户创建成功'); |
| | | } |
| | | closeModal(); |
| | | const blob = await exportEmployees({ orgId: lastQueryRef.current.orgId }); |
| | | const url = URL.createObjectURL(blob); |
| | | const link = document.createElement('a'); |
| | | link.href = url; |
| | | link.download = 'employees.xlsx'; |
| | | document.body.appendChild(link); |
| | | link.click(); |
| | | document.body.removeChild(link); |
| | | window.setTimeout(() => URL.revokeObjectURL(url), 0); |
| | | message.success('导出成功'); |
| | | } catch { |
| | | // 表单校验失败时由 antd 自动展示错误信息 |
| | | // 错误已由 request 统一提示 |
| | | } finally { |
| | | setExporting(false); |
| | | } |
| | | }; |
| | | }, [message]); |
| | | |
| | | const handleDelete = (record: UserRecord) => { |
| | | modal.confirm({ |
| | | title: '删除用户', |
| | | content: `确定要删除用户「${record.nickname}」吗?删除后不可恢复。`, |
| | | okText: '删除', |
| | | cancelText: '取消', |
| | | okButtonProps: { danger: true }, |
| | | onOk: () => { |
| | | setUsers((prev) => prev.filter((user) => user.key !== record.key)); |
| | | message.success('用户已删除'); |
| | | }, |
| | | }); |
| | | }; |
| | | const handleImport = useCallback(async () => { |
| | | const file = importFileList[0]?.originFileObj; |
| | | if (!file) { |
| | | message.warning('请先选择要导入的 Excel 文件'); |
| | | return; |
| | | } |
| | | |
| | | const columns: TableProps<UserRecord>['columns'] = [ |
| | | { |
| | | title: '用户', |
| | | dataIndex: 'nickname', |
| | | width: 220, |
| | | render: (_value, record) => ( |
| | | <div className="user-cell"> |
| | | <Avatar className="user-cell__avatar">{record.nickname.charAt(0)}</Avatar> |
| | | <div className="user-cell__meta"> |
| | | <span className="user-cell__name">{record.nickname}</span> |
| | | <span className="user-cell__username">@{record.username}</span> |
| | | </div> |
| | | </div> |
| | | ), |
| | | }, |
| | | { |
| | | title: '角色', |
| | | dataIndex: 'role', |
| | | width: 140, |
| | | render: (value: string) => ( |
| | | <Tag color={roleColor[value] ?? 'blue'} bordered={false}> |
| | | {value} |
| | | </Tag> |
| | | ), |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | | width: 100, |
| | | render: (value: UserStatus) => ( |
| | | <Tag color={statusMeta[value].color} bordered={false}> |
| | | {statusMeta[value].label} |
| | | </Tag> |
| | | ), |
| | | }, |
| | | { title: '创建时间', dataIndex: 'createdAt', width: 170 }, |
| | | { |
| | | title: '操作', |
| | | key: 'action', |
| | | width: 150, |
| | | render: (_value, record) => ( |
| | | <Space size={0}> |
| | | <Button type="link" size="small" onClick={() => openEditModal(record)}> |
| | | 编辑 |
| | | </Button> |
| | | <Button type="link" size="small" danger onClick={() => handleDelete(record)}> |
| | | 删除 |
| | | </Button> |
| | | </Space> |
| | | ), |
| | | }, |
| | | ]; |
| | | setImporting(true); |
| | | try { |
| | | const count = await importEmployees({ file }); |
| | | message.success(`成功导入 ${count} 条员工`); |
| | | setImportOpen(false); |
| | | setImportFileList([]); |
| | | actionRef.current?.reload(); |
| | | } catch { |
| | | // 错误已由 request 统一提示 |
| | | } finally { |
| | | setImporting(false); |
| | | } |
| | | }, [importFileList, message]); |
| | | |
| | | const columns = useMemo( |
| | | () => |
| | | createUserColumns({ |
| | | orgOptions, |
| | | roleOptions, |
| | | orgNameMap, |
| | | roleNameMap, |
| | | onDetail: openDetail, |
| | | onEdit: openEdit, |
| | | onDelete: handleDelete, |
| | | }), |
| | | [orgOptions, roleOptions, orgNameMap, roleNameMap, openDetail, openEdit, handleDelete], |
| | | ); |
| | | |
| | | return ( |
| | | <div className="users-page"> |
| | | <div className="page-header"> |
| | | <div> |
| | | <h1 className="page-header__title">用户管理</h1> |
| | | <p className="page-header__desc"> |
| | | 维护平台账号、角色与启用状态。 |
| | | </p> |
| | | </div> |
| | | <div className="page-header__actions"> |
| | | <Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}> |
| | | 新增用户 |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | <ProTable<EmployeeVO, EmployeeListQuery> |
| | | rowKey="id" |
| | | actionRef={actionRef} |
| | | columns={columns} |
| | | search={{ labelWidth: 'auto', defaultCollapsed: false }} |
| | | scroll={{ x: 1290 }} |
| | | options={false} |
| | | pagination={{ |
| | | defaultPageSize: 10, |
| | | showSizeChanger: true, |
| | | hideOnSinglePage: false, |
| | | showTotal: (total) => `共 ${total} 条`, |
| | | }} |
| | | toolBarRender={() => [ |
| | | <Button |
| | | key="import" |
| | | icon={<UploadOutlined />} |
| | | onClick={() => { |
| | | setImportFileList([]); |
| | | setImportOpen(true); |
| | | }} |
| | | > |
| | | 导入 |
| | | </Button>, |
| | | <Button |
| | | key="export" |
| | | icon={<DownloadOutlined />} |
| | | loading={exporting} |
| | | onClick={handleExport} |
| | | > |
| | | 导出 |
| | | </Button>, |
| | | <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreate}> |
| | | 新增员工 |
| | | </Button>, |
| | | ]} |
| | | request={async (params) => { |
| | | const query: TenantEmployeeListRequest = { |
| | | page: params.current, |
| | | size: params.pageSize, |
| | | keyword: params.keyword?.trim() || undefined, |
| | | status: params.status, |
| | | orgId: params.orgId, |
| | | roleId: params.roleId, |
| | | }; |
| | | lastQueryRef.current = query; |
| | | |
| | | <Card className="filter-card" variant="borderless"> |
| | | <div className="page-toolbar"> |
| | | <Input |
| | | allowClear |
| | | prefix={<SearchOutlined />} |
| | | placeholder="搜索用户名 / 昵称" |
| | | value={keyword} |
| | | onChange={(event) => setKeyword(event.target.value)} |
| | | style={{ width: 260 }} |
| | | /> |
| | | <Select<UserStatus | 'all'> |
| | | value={status} |
| | | onChange={setStatus} |
| | | style={{ width: 150 }} |
| | | options={[ |
| | | { value: 'all', label: '全部状态' }, |
| | | { value: 'active', label: '启用' }, |
| | | { value: 'disabled', label: '禁用' }, |
| | | ]} |
| | | /> |
| | | <span className="text-muted">共 {filteredUsers.length} 条记录</span> |
| | | </div> |
| | | </Card> |
| | | const result = await getEmployeeList(query); |
| | | |
| | | <Card className="table-card" variant="borderless"> |
| | | <Table<UserRecord> |
| | | rowKey="key" |
| | | columns={columns} |
| | | dataSource={filteredUsers} |
| | | scroll={{ x: 820 }} |
| | | pagination={{ |
| | | pageSize: 8, |
| | | showSizeChanger: false, |
| | | showTotal: (total) => `共 ${total} 条`, |
| | | }} |
| | | /> |
| | | </Card> |
| | | return { |
| | | data: result.records ?? [], |
| | | total: result.total ?? 0, |
| | | success: true, |
| | | }; |
| | | }} |
| | | /> |
| | | |
| | | <UserFormDrawer |
| | | open={formOpen} |
| | | mode={formMode} |
| | | record={activeEmployee} |
| | | orgTree={orgTree} |
| | | roleOptions={roleOptions} |
| | | onClose={closeForm} |
| | | onSuccess={handleFormSuccess} |
| | | /> |
| | | |
| | | <Modal |
| | | title={editingUser ? '编辑用户' : '新增用户'} |
| | | open={isModalOpen} |
| | | onOk={handleSubmit} |
| | | onCancel={closeModal} |
| | | okText={editingUser ? '保存' : '创建'} |
| | | title="导入员工" |
| | | open={importOpen} |
| | | onOk={handleImport} |
| | | onCancel={() => { |
| | | setImportOpen(false); |
| | | setImportFileList([]); |
| | | }} |
| | | confirmLoading={importing} |
| | | okText="导入" |
| | | cancelText="取消" |
| | | forceRender |
| | | > |
| | | <Form form={form} layout="vertical" preserve={false}> |
| | | <Form.Item |
| | | name="username" |
| | | label="用户名" |
| | | rules={[ |
| | | { required: true, message: '请输入用户名' }, |
| | | { pattern: /^[a-zA-Z][a-zA-Z0-9._-]{2,19}$/, message: '3-20 位,字母开头' }, |
| | | ]} |
| | | > |
| | | <Input placeholder="请输入登录用户名" disabled={Boolean(editingUser)} /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="nickname" |
| | | label="姓名" |
| | | rules={[{ required: true, message: '请输入姓名' }]} |
| | | > |
| | | <Input placeholder="请输入用户姓名" /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="role" |
| | | label="角色" |
| | | rules={[{ required: true, message: '请选择角色' }]} |
| | | > |
| | | <Select placeholder="请选择角色" options={roleOptions} /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="status" |
| | | label="状态" |
| | | rules={[{ required: true, message: '请选择状态' }]} |
| | | > |
| | | <Select |
| | | options={[ |
| | | { value: 'active', label: '启用' }, |
| | | { value: 'disabled', label: '禁用' }, |
| | | ]} |
| | | /> |
| | | </Form.Item> |
| | | </Form> |
| | | <Upload.Dragger |
| | | accept=".xlsx,.xls" |
| | | maxCount={1} |
| | | fileList={importFileList} |
| | | beforeUpload={() => false} |
| | | onChange={({ fileList }) => setImportFileList(fileList)} |
| | | > |
| | | <p className="ant-upload-drag-icon"> |
| | | <InboxOutlined /> |
| | | </p> |
| | | <p className="ant-upload-text">点击或拖拽 Excel 文件到此处</p> |
| | | <p className="ant-upload-hint">仅支持 .xlsx / .xls 格式</p> |
| | | </Upload.Dragger> |
| | | </Modal> |
| | | </div> |
| | | ); |