feat: 用户管理接入员工管理接口
- 重构用户管理页为 ProTable,对接员工分页列表及关键词 / 状态 / 组织 / 角色筛选
- 新增员工详情、新增、编辑、删除、Excel 导入 / 导出
- 组织树与角色列表作为下拉选项数据源
- 抽取共享 PageResult 类型
- 修复用户 / 角色状态枚举缺失时的渲染报错
- 同步 README 说明
Co-Authored-By: Claude Code <noreply@anthropic.com>
5 files modified
4 files added
| | |
| | | | 页面 | 路由 | 说明 | |
| | | | -------- | ----------------------- | ---------------------------------- | |
| | | | 工作台 | `/dashboard` | 数据卡片、快捷入口、动态、最近订单 | |
| | | | 用户管理 | `/system/users` | 搜索、新增 / 编辑 / 删除用户 | |
| | | | 用户管理 | `/system/users` | 员工列表、详情、增删改、导入 / 导出 | |
| | | | 角色管理 | `/system/roles` | 角色列表、详情、启用 / 停用 | |
| | | | 订单管理 | `/business/orders` | 筛选、处理订单、导出 | |
| | | | 客户列表 | `/customers/list` | 接口数据,按归属销售员筛选 | |
| | |
| | | import type { PageResult } from '@/types/api'; |
| | | |
| | | /** 客户类型。 */ |
| | | export type CustomerType = 'ENTERPRISE' | 'PERSONAL'; |
| | | |
| | |
| | | dealAt?: string; |
| | | lostAt?: string; |
| | | lostReason?: string; |
| | | } |
| | | |
| | | /** 通用分页响应(PageResult)。 */ |
| | | export interface PageResult<T> { |
| | | page: number; |
| | | size: number; |
| | | total: number; |
| | | totalPages: number; |
| | | records: T[]; |
| | | } |
| | | |
| | | /** 客户列表响应数据(ResultPageResultCustomerVO 的 data)。 */ |
| | |
| | | DELETED: { text: '已删除', color: 'red' }, |
| | | }; |
| | | |
| | | /** 兼容后端返回未知或小写枚举,避免直接索引得到 undefined。 */ |
| | | export const getRoleTypeMeta = (roleType?: string) => { |
| | | const key = String(roleType ?? '').toUpperCase() as RoleType; |
| | | return roleTypeMeta[key] ?? { text: roleType || '-', color: 'default' }; |
| | | }; |
| | | |
| | | export const getRoleStatusMeta = (status?: string) => { |
| | | const key = String(status ?? '').toUpperCase() as RoleStatus; |
| | | return statusMeta[key] ?? { text: status || '-', color: 'default' }; |
| | | }; |
| | | |
| | | export const createRoleColumns = (options: { |
| | | onDetail: (record: RoleVO) => void; |
| | | onToggle: (record: RoleVO) => void; |
| | |
| | | DISABLED: { text: '禁用' }, |
| | | DELETED: { text: '已删除' }, |
| | | }, |
| | | render: (_value, record) => ( |
| | | <Tag color={statusMeta[record.status].color} bordered={false}> |
| | | {statusMeta[record.status].text} |
| | | </Tag> |
| | | ), |
| | | render: (_value, record) => { |
| | | const meta = getRoleStatusMeta(record.status); |
| | | return ( |
| | | <Tag color={meta.color} bordered={false}> |
| | | {meta.text} |
| | | </Tag> |
| | | ); |
| | | }, |
| | | }, |
| | | { |
| | | title: '操作', |
| | |
| | | import { ProTable } from '@ant-design/pro-components'; |
| | | import type { ActionType } from '@ant-design/pro-components'; |
| | | import { App as AntdApp, Descriptions, Drawer, Tag } from 'antd'; |
| | | import { createRoleColumns, roleTypeMeta, statusMeta } from './columns'; |
| | | import { createRoleColumns, getRoleStatusMeta, getRoleTypeMeta } from './columns'; |
| | | import { getRoleDetail, getRoleList, toggleRoleStatus } from './server'; |
| | | import type { RoleListQuery, RoleVO } from './types'; |
| | | |
| | |
| | | <Descriptions.Item label="角色名称">{detail.roleName}</Descriptions.Item> |
| | | <Descriptions.Item label="角色编码">{detail.roleCode}</Descriptions.Item> |
| | | <Descriptions.Item label="角色类型"> |
| | | <Tag color={roleTypeMeta[detail.roleType].color} bordered={false}> |
| | | {roleTypeMeta[detail.roleType].text} |
| | | <Tag color={getRoleTypeMeta(detail.roleType).color} bordered={false}> |
| | | {getRoleTypeMeta(detail.roleType).text} |
| | | </Tag> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="状态"> |
| | | <Tag color={statusMeta[detail.status].color} bordered={false}> |
| | | {statusMeta[detail.status].text} |
| | | <Tag color={getRoleStatusMeta(detail.status).color} bordered={false}> |
| | | {getRoleStatusMeta(detail.status).text} |
| | | </Tag> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="描述">{detail.description || '-'}</Descriptions.Item> |
| New file |
| | |
| | | import { Avatar, Button, Space, Tag } from 'antd'; |
| | | import type { ProColumns } from '@ant-design/pro-components'; |
| | | import type { EmployeeStatus, EmployeeVO, SelectOption } from './types'; |
| | | |
| | | export const employeeStatusMeta: Record<EmployeeStatus, { text: string; color: string }> = { |
| | | ACTIVE: { text: '启用', color: 'green' }, |
| | | DISABLED: { text: '禁用', color: 'default' }, |
| | | }; |
| | | |
| | | /** 兼容后端返回未知或小写状态,避免直接索引得到 undefined。 */ |
| | | export const getEmployeeStatusMeta = (status?: string) => { |
| | | const key = String(status ?? '').toUpperCase() as EmployeeStatus; |
| | | return employeeStatusMeta[key] ?? { text: status || '-', color: 'default' }; |
| | | }; |
| | | |
| | | export const createUserColumns = (options: { |
| | | orgOptions: SelectOption[]; |
| | | roleOptions: SelectOption[]; |
| | | orgNameMap: Map<number, string>; |
| | | roleNameMap: Map<number, string>; |
| | | onDetail: (record: EmployeeVO) => void; |
| | | onEdit: (record: EmployeeVO) => void; |
| | | onDelete: (record: EmployeeVO) => void; |
| | | }): ProColumns<EmployeeVO>[] => [ |
| | | { |
| | | title: '员工', |
| | | dataIndex: 'name', |
| | | width: 220, |
| | | ellipsis: true, |
| | | fieldProps: { placeholder: '姓名 / 手机号' }, |
| | | search: { |
| | | transform: (value) => ({ keyword: value }), |
| | | }, |
| | | render: (_value, record) => ( |
| | | <div className="user-cell"> |
| | | <Avatar className="user-cell__avatar" src={record.avatarUrl} size={36}> |
| | | {record.name?.charAt(0)} |
| | | </Avatar> |
| | | <div className="user-cell__meta"> |
| | | <span className="user-cell__name">{record.name}</span> |
| | | <span className="user-cell__username">{record.phone}</span> |
| | | </div> |
| | | </div> |
| | | ), |
| | | }, |
| | | { |
| | | title: '所属组织', |
| | | dataIndex: 'orgId', |
| | | width: 170, |
| | | valueType: 'select', |
| | | fieldProps: { |
| | | options: options.orgOptions, |
| | | showSearch: true, |
| | | optionFilterProp: 'label', |
| | | allowClear: true, |
| | | placeholder: '请选择组织', |
| | | }, |
| | | render: (_value, record) => |
| | | options.orgNameMap.get(record.orgId) ?? (record.orgId ? `#${record.orgId}` : '-'), |
| | | }, |
| | | { |
| | | title: '角色', |
| | | dataIndex: 'roleId', |
| | | width: 150, |
| | | valueType: 'select', |
| | | fieldProps: { |
| | | options: options.roleOptions, |
| | | showSearch: true, |
| | | optionFilterProp: 'label', |
| | | allowClear: true, |
| | | placeholder: '请选择角色', |
| | | }, |
| | | render: (_value, record) => |
| | | options.roleNameMap.get(record.roleId) ?? (record.roleId ? `#${record.roleId}` : '-'), |
| | | }, |
| | | { |
| | | title: '职位', |
| | | dataIndex: 'position', |
| | | width: 120, |
| | | search: false, |
| | | render: (_value, record) => record.position || '-', |
| | | }, |
| | | { |
| | | title: '负责区域', |
| | | dataIndex: 'region', |
| | | width: 120, |
| | | search: false, |
| | | render: (_value, record) => record.region || '-', |
| | | }, |
| | | { |
| | | title: '客户上限', |
| | | dataIndex: 'holdLimit', |
| | | width: 100, |
| | | search: false, |
| | | render: (_value, record) => record.holdLimit ?? '-', |
| | | }, |
| | | { |
| | | title: '状态', |
| | | dataIndex: 'status', |
| | | width: 100, |
| | | valueType: 'select', |
| | | valueEnum: { |
| | | ACTIVE: { text: '启用' }, |
| | | DISABLED: { text: '禁用' }, |
| | | }, |
| | | render: (_value, record) => { |
| | | const meta = getEmployeeStatusMeta(record.status); |
| | | return ( |
| | | <Tag color={meta.color} bordered={false}> |
| | | {meta.text} |
| | | </Tag> |
| | | ); |
| | | }, |
| | | }, |
| | | { |
| | | title: '入职日期', |
| | | dataIndex: 'hireDate', |
| | | width: 130, |
| | | search: false, |
| | | render: (_value, record) => record.hireDate || '-', |
| | | }, |
| | | { |
| | | title: '操作', |
| | | key: 'action', |
| | | valueType: 'option', |
| | | width: 180, |
| | | render: (_value, record) => ( |
| | | <Space size={0}> |
| | | <Button type="link" size="small" onClick={() => options.onDetail(record)}> |
| | | 详情 |
| | | </Button> |
| | | <Button type="link" size="small" onClick={() => options.onEdit(record)}> |
| | | 编辑 |
| | | </Button> |
| | | <Button type="link" size="small" danger onClick={() => options.onDelete(record)}> |
| | | 删除 |
| | | </Button> |
| | | </Space> |
| | | ), |
| | | }, |
| | | ]; |
| | |
| | | import { useMemo, useState } from 'react'; |
| | | import { PlusOutlined, SearchOutlined } from '@ant-design/icons'; |
| | | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| | | import { |
| | | 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, |
| | | Avatar, |
| | | Button, |
| | | Card, |
| | | Descriptions, |
| | | Drawer, |
| | | Form, |
| | | Input, |
| | | InputNumber, |
| | | Modal, |
| | | Select, |
| | | Space, |
| | | Table, |
| | | Tag, |
| | | Upload, |
| | | } from 'antd'; |
| | | import type { TableProps } from 'antd'; |
| | | import dayjs from 'dayjs'; |
| | | import type { UploadFile } from 'antd'; |
| | | import { createUserColumns, getEmployeeStatusMeta } from './columns'; |
| | | import { |
| | | createEmployee, |
| | | deleteEmployee, |
| | | exportEmployees, |
| | | getEmployeeDetail, |
| | | getEmployeeList, |
| | | getOrgTree, |
| | | getRoleOptions, |
| | | importEmployees, |
| | | updateEmployee, |
| | | } from './server'; |
| | | import type { |
| | | EmployeeFormValues, |
| | | 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 [form] = Form.useForm<EmployeeFormValues>(); |
| | | const [orgOptions, setOrgOptions] = useState<SelectOption[]>([]); |
| | | const [roleOptions, setRoleOptions] = useState<SelectOption[]>([]); |
| | | const [formOpen, setFormOpen] = useState(false); |
| | | const [editing, setEditing] = useState<EmployeeVO | null>(null); |
| | | const [submitting, setSubmitting] = useState(false); |
| | | const [detail, setDetail] = useState<EmployeeVO | null>(null); |
| | | const [detailOpen, setDetailOpen] = useState(false); |
| | | const [detailLoading, setDetailLoading] = useState(false); |
| | | 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); |
| | | const loadOptions = async () => { |
| | | try { |
| | | const [orgs, roles] = await Promise.all([getOrgTree(), getRoleOptions()]); |
| | | if (!active) return; |
| | | |
| | | setOrgOptions(flattenOrgOptions(orgs)); |
| | | setRoleOptions(roles.map((role) => ({ label: role.roleName, value: role.id }))); |
| | | } catch { |
| | | // 错误已由 request 统一提示 |
| | | } |
| | | }; |
| | | |
| | | void loadOptions(); |
| | | |
| | | return () => { |
| | | active = false; |
| | | }; |
| | | }, []); |
| | | |
| | | 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(() => { |
| | | setEditing(null); |
| | | form.resetFields(); |
| | | form.setFieldsValue({ status: 'active', role: '销售专员' }); |
| | | setIsModalOpen(true); |
| | | }; |
| | | setFormOpen(true); |
| | | }, [form]); |
| | | |
| | | const openEditModal = (record: UserRecord) => { |
| | | setEditingUser(record); |
| | | form.setFieldsValue({ |
| | | username: record.username, |
| | | nickname: record.nickname, |
| | | role: record.role, |
| | | status: record.status, |
| | | }); |
| | | setIsModalOpen(true); |
| | | }; |
| | | const closeForm = useCallback(() => { |
| | | setFormOpen(false); |
| | | setEditing(null); |
| | | }, []); |
| | | |
| | | const closeModal = () => { |
| | | setIsModalOpen(false); |
| | | form.resetFields(); |
| | | }; |
| | | const openEdit = useCallback( |
| | | (record: EmployeeVO) => { |
| | | setEditing(record); |
| | | form.setFieldsValue({ |
| | | name: record.name, |
| | | phone: record.phone, |
| | | orgId: record.orgId, |
| | | roleId: record.roleId, |
| | | position: record.position, |
| | | email: record.email, |
| | | holdLimit: record.holdLimit, |
| | | region: record.region, |
| | | }); |
| | | setFormOpen(true); |
| | | }, |
| | | [form], |
| | | ); |
| | | |
| | | const handleSubmit = async () => { |
| | | const handleDetail = useCallback(async (record: EmployeeVO) => { |
| | | setDetail(null); |
| | | setDetailOpen(true); |
| | | setDetailLoading(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 data = await getEmployeeDetail({ id: record.id }); |
| | | setDetail(data); |
| | | } catch { |
| | | // 表单校验失败时由 antd 自动展示错误信息 |
| | | // 错误已由 request 统一提示 |
| | | setDetailOpen(false); |
| | | } finally { |
| | | setDetailLoading(false); |
| | | } |
| | | }; |
| | | }, []); |
| | | |
| | | 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 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 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> |
| | | ), |
| | | }, |
| | | ]; |
| | | const handleSubmit = useCallback(async () => { |
| | | let values: EmployeeFormValues; |
| | | try { |
| | | values = await form.validateFields(); |
| | | } catch { |
| | | return; |
| | | } |
| | | |
| | | setSubmitting(true); |
| | | try { |
| | | if (editing) { |
| | | await updateEmployee({ id: editing.id, ...values }); |
| | | message.success('员工信息已更新'); |
| | | } else { |
| | | await createEmployee(values); |
| | | message.success('员工创建成功'); |
| | | } |
| | | |
| | | setFormOpen(false); |
| | | setEditing(null); |
| | | form.resetFields(); |
| | | actionRef.current?.reload(); |
| | | } catch { |
| | | // 错误已由 request 统一提示 |
| | | } finally { |
| | | setSubmitting(false); |
| | | } |
| | | }, [editing, form, message]); |
| | | |
| | | const handleExport = useCallback(async () => { |
| | | setExporting(true); |
| | | try { |
| | | 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 { |
| | | // 错误已由 request 统一提示 |
| | | } finally { |
| | | setExporting(false); |
| | | } |
| | | }, [message]); |
| | | |
| | | const handleImport = useCallback(async () => { |
| | | const file = importFileList[0]?.originFileObj; |
| | | if (!file) { |
| | | message.warning('请先选择要导入的 Excel 文件'); |
| | | return; |
| | | } |
| | | |
| | | 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: handleDetail, |
| | | onEdit: openEdit, |
| | | onDelete: handleDelete, |
| | | }), |
| | | [orgOptions, roleOptions, orgNameMap, roleNameMap, handleDetail, 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} |
| | | cardProps={{ className: 'table-card' }} |
| | | 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, |
| | | }; |
| | | }} |
| | | /> |
| | | |
| | | <Modal |
| | | title={editingUser ? '编辑用户' : '新增用户'} |
| | | open={isModalOpen} |
| | | onOk={handleSubmit} |
| | | onCancel={closeModal} |
| | | okText={editingUser ? '保存' : '创建'} |
| | | cancelText="取消" |
| | | <Drawer |
| | | title={editing ? '编辑员工' : '新增员工'} |
| | | width={420} |
| | | open={formOpen} |
| | | forceRender |
| | | onClose={closeForm} |
| | | footer={ |
| | | <Space style={{ display: 'flex', justifyContent: 'flex-end' }}> |
| | | <Button onClick={closeForm}>取消</Button> |
| | | <Button type="primary" loading={submitting} onClick={handleSubmit}> |
| | | {editing ? '保存' : '创建'} |
| | | </Button> |
| | | </Space> |
| | | } |
| | | > |
| | | <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" |
| | | name="name" |
| | | label="姓名" |
| | | rules={[{ required: true, message: '请输入姓名' }]} |
| | | > |
| | | <Input placeholder="请输入用户姓名" /> |
| | | <Input placeholder="请输入员工姓名" /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="role" |
| | | name="phone" |
| | | label="手机号" |
| | | rules={[ |
| | | { required: true, message: '请输入手机号' }, |
| | | { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }, |
| | | ]} |
| | | > |
| | | <Input placeholder="请输入手机号" maxLength={11} /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="orgId" |
| | | label="所属组织" |
| | | rules={[{ required: true, message: '请选择所属组织' }]} |
| | | > |
| | | <Select |
| | | showSearch |
| | | optionFilterProp="label" |
| | | options={orgOptions} |
| | | placeholder="请选择所属组织" |
| | | /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="roleId" |
| | | 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: '禁用' }, |
| | | ]} |
| | | showSearch |
| | | optionFilterProp="label" |
| | | options={roleOptions} |
| | | placeholder="请选择角色" |
| | | /> |
| | | </Form.Item> |
| | | <Form.Item name="position" label="职位"> |
| | | <Input placeholder="请输入职位" /> |
| | | </Form.Item> |
| | | <Form.Item name="region" label="负责区域"> |
| | | <Input placeholder="请输入负责区域" /> |
| | | </Form.Item> |
| | | <Form.Item name="holdLimit" label="客户持有上限"> |
| | | <InputNumber min={0} precision={0} style={{ width: '100%' }} /> |
| | | </Form.Item> |
| | | <Form.Item |
| | | name="email" |
| | | label="邮箱" |
| | | rules={[{ type: 'email', message: '请输入正确的邮箱' }]} |
| | | > |
| | | <Input placeholder="请输入邮箱" /> |
| | | </Form.Item> |
| | | </Form> |
| | | </Drawer> |
| | | |
| | | <Drawer |
| | | title="员工详情" |
| | | width={480} |
| | | open={detailOpen} |
| | | loading={detailLoading} |
| | | onClose={() => { |
| | | setDetailOpen(false); |
| | | setDetail(null); |
| | | }} |
| | | > |
| | | {detail && ( |
| | | <Descriptions column={1} bordered size="small"> |
| | | <Descriptions.Item label="姓名">{detail.name}</Descriptions.Item> |
| | | <Descriptions.Item label="手机号">{detail.phone}</Descriptions.Item> |
| | | <Descriptions.Item label="所属组织"> |
| | | {orgNameMap.get(detail.orgId) ?? `#${detail.orgId}`} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="角色"> |
| | | {roleNameMap.get(detail.roleId) ?? `#${detail.roleId}`} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="职位">{detail.position || '-'}</Descriptions.Item> |
| | | <Descriptions.Item label="邮箱">{detail.email || '-'}</Descriptions.Item> |
| | | <Descriptions.Item label="负责区域">{detail.region || '-'}</Descriptions.Item> |
| | | <Descriptions.Item label="客户持有上限"> |
| | | {detail.holdLimit ?? '-'} |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="状态"> |
| | | <Tag color={getEmployeeStatusMeta(detail.status).color} bordered={false}> |
| | | {getEmployeeStatusMeta(detail.status).text} |
| | | </Tag> |
| | | </Descriptions.Item> |
| | | <Descriptions.Item label="入职日期">{detail.hireDate || '-'}</Descriptions.Item> |
| | | <Descriptions.Item label="创建时间">{detail.createdAt || '-'}</Descriptions.Item> |
| | | </Descriptions> |
| | | )} |
| | | </Drawer> |
| | | |
| | | <Modal |
| | | title="导入员工" |
| | | open={importOpen} |
| | | onOk={handleImport} |
| | | onCancel={() => { |
| | | setImportOpen(false); |
| | | setImportFileList([]); |
| | | }} |
| | | confirmLoading={importing} |
| | | okText="导入" |
| | | cancelText="取消" |
| | | > |
| | | <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> |
| | | ); |
| New file |
| | |
| | | import { getAuthHeaders } from '@/api/authHeaders'; |
| | | import { request } from '@/api/request'; |
| | | import type { |
| | | TenantEmployeeCreateRequest, |
| | | TenantEmployeeCreateResponse, |
| | | TenantEmployeeDeleteRequest, |
| | | TenantEmployeeDeleteResponse, |
| | | TenantEmployeeDetailRequest, |
| | | TenantEmployeeDetailResponse, |
| | | TenantEmployeeExportRequest, |
| | | TenantEmployeeExportResponse, |
| | | TenantEmployeeImportRequest, |
| | | TenantEmployeeImportResponse, |
| | | TenantEmployeeListRequest, |
| | | TenantEmployeeListResponse, |
| | | TenantEmployeeUpdateBody, |
| | | TenantEmployeeUpdateRequest, |
| | | TenantEmployeeUpdateResponse, |
| | | TenantOrgTreeRequest, |
| | | TenantOrgTreeResponse, |
| | | TenantRoleOptionRequest, |
| | | TenantRoleOptionResponse, |
| | | } from './types'; |
| | | |
| | | /** 员工分页列表。 */ |
| | | export const getEmployeeList = (params: TenantEmployeeListRequest) => |
| | | request<TenantEmployeeListRequest, TenantEmployeeListResponse>({ |
| | | url: '/tenant/employees', |
| | | method: 'GET', |
| | | params, |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 创建员工。 */ |
| | | export const createEmployee = (data: TenantEmployeeCreateRequest) => |
| | | request<TenantEmployeeCreateRequest, TenantEmployeeCreateResponse>({ |
| | | url: '/tenant/employees', |
| | | method: 'POST', |
| | | data, |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 员工详情。 */ |
| | | export const getEmployeeDetail = ({ id }: TenantEmployeeDetailRequest) => |
| | | request<TenantEmployeeDetailRequest, TenantEmployeeDetailResponse>({ |
| | | url: `/tenant/employees/${id}`, |
| | | method: 'GET', |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 更新员工基本信息。 */ |
| | | export const updateEmployee = ({ id, ...data }: TenantEmployeeUpdateRequest) => |
| | | request<TenantEmployeeUpdateBody, TenantEmployeeUpdateResponse>({ |
| | | url: `/tenant/employees/${id}`, |
| | | method: 'PUT', |
| | | data, |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 删除员工。 */ |
| | | export const deleteEmployee = ({ id }: TenantEmployeeDeleteRequest) => |
| | | request<TenantEmployeeDeleteRequest, TenantEmployeeDeleteResponse>({ |
| | | url: `/tenant/employees/${id}`, |
| | | method: 'DELETE', |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 导出员工 Excel 文件流。 */ |
| | | export const exportEmployees = (params: TenantEmployeeExportRequest = {}) => |
| | | request<TenantEmployeeExportRequest, TenantEmployeeExportResponse>({ |
| | | url: '/tenant/employees/export', |
| | | method: 'GET', |
| | | params, |
| | | responseType: 'blob', |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 批量导入员工 Excel。 */ |
| | | export const importEmployees = ({ file }: TenantEmployeeImportRequest) => { |
| | | const formData = new FormData(); |
| | | formData.append('file', file); |
| | | |
| | | return request<FormData, TenantEmployeeImportResponse>( |
| | | { |
| | | url: '/tenant/employees/import', |
| | | method: 'POST', |
| | | data: formData, |
| | | headers: getAuthHeaders(), |
| | | }, |
| | | { |
| | | // 不同文件请求体无法通过 FormData 区分,关闭合并。 |
| | | merge: false, |
| | | }, |
| | | ); |
| | | }; |
| | | |
| | | /** 组织树,用于组织下拉选项。 */ |
| | | export const getOrgTree = (params: TenantOrgTreeRequest = {}) => |
| | | request<TenantOrgTreeRequest, TenantOrgTreeResponse>({ |
| | | url: '/tenant/organizations/tree', |
| | | method: 'GET', |
| | | params, |
| | | headers: getAuthHeaders(), |
| | | }); |
| | | |
| | | /** 角色列表,用于角色下拉选项。 */ |
| | | export const getRoleOptions = () => |
| | | request<TenantRoleOptionRequest, TenantRoleOptionResponse>({ |
| | | url: '/tenant/roles', |
| | | method: 'GET', |
| | | headers: getAuthHeaders(), |
| | | }); |
| New file |
| | |
| | | import type { PageResult } from '@/types/api'; |
| | | |
| | | /** 员工状态。 */ |
| | | export type EmployeeStatus = 'ACTIVE' | 'DISABLED'; |
| | | |
| | | /** 员工响应体(EmployeeVO)。 */ |
| | | export interface EmployeeVO { |
| | | id: number; |
| | | createdAt?: string; |
| | | updatedAt?: string; |
| | | createdBy?: string; |
| | | updatedBy?: string; |
| | | tenantId?: string; |
| | | deleted?: number; |
| | | name: string; |
| | | phone: string; |
| | | orgId: number; |
| | | roleId: number; |
| | | position?: string; |
| | | email?: string; |
| | | avatarUrl?: string; |
| | | status: EmployeeStatus; |
| | | holdLimit?: number; |
| | | region?: string; |
| | | hireDate?: string; |
| | | } |
| | | |
| | | /** GET /tenant/employees 请求参数(EmployeePageQuery)。 */ |
| | | export interface TenantEmployeeListRequest { |
| | | page?: number; |
| | | size?: number; |
| | | sortBy?: string; |
| | | sortDir?: string; |
| | | orgId?: number; |
| | | roleId?: number; |
| | | status?: EmployeeStatus; |
| | | keyword?: string; |
| | | } |
| | | |
| | | /** GET /tenant/employees 响应数据(ResultPageResultEmployeeVO 的 data)。 */ |
| | | export type TenantEmployeeListResponse = PageResult<EmployeeVO>; |
| | | |
| | | /** POST /tenant/employees 请求体(EmployeeCreateDTO)。 */ |
| | | export interface TenantEmployeeCreateRequest { |
| | | name: string; |
| | | phone: string; |
| | | orgId: number; |
| | | roleId: number; |
| | | position?: string; |
| | | email?: string; |
| | | avatarUrl?: string; |
| | | holdLimit?: number; |
| | | region?: string; |
| | | } |
| | | |
| | | /** POST /tenant/employees 响应数据(ResultEmployeeVO 的 data)。 */ |
| | | export type TenantEmployeeCreateResponse = EmployeeVO; |
| | | |
| | | /** GET /tenant/employees/{id} 请求参数。 */ |
| | | export interface TenantEmployeeDetailRequest { |
| | | /** 员工 ID(路径参数)。 */ |
| | | id: number; |
| | | } |
| | | |
| | | /** GET /tenant/employees/{id} 响应数据(ResultEmployeeVO 的 data)。 */ |
| | | export type TenantEmployeeDetailResponse = EmployeeVO; |
| | | |
| | | /** PUT /tenant/employees/{id} 请求参数(路径参数 + EmployeeUpdateDTO)。 */ |
| | | export interface TenantEmployeeUpdateRequest { |
| | | /** 员工 ID(路径参数)。 */ |
| | | id: number; |
| | | name?: string; |
| | | phone?: string; |
| | | orgId?: number; |
| | | roleId?: number; |
| | | position?: string; |
| | | email?: string; |
| | | holdLimit?: number; |
| | | region?: string; |
| | | } |
| | | |
| | | /** PUT /tenant/employees/{id} 请求体。 */ |
| | | export type TenantEmployeeUpdateBody = Omit<TenantEmployeeUpdateRequest, 'id'>; |
| | | |
| | | /** PUT /tenant/employees/{id} 响应数据(ResultEmployeeVO 的 data)。 */ |
| | | export type TenantEmployeeUpdateResponse = EmployeeVO; |
| | | |
| | | /** DELETE /tenant/employees/{id} 请求参数。 */ |
| | | export interface TenantEmployeeDeleteRequest { |
| | | /** 员工 ID(路径参数)。 */ |
| | | id: number; |
| | | } |
| | | |
| | | /** DELETE /tenant/employees/{id} 响应数据(ResultVoid 的 data)。 */ |
| | | export type TenantEmployeeDeleteResponse = void; |
| | | |
| | | /** GET /tenant/employees/export 请求参数。 */ |
| | | export interface TenantEmployeeExportRequest { |
| | | /** 不填导出全部,填则仅导出指定组织。 */ |
| | | orgId?: number; |
| | | } |
| | | |
| | | /** GET /tenant/employees/export 响应数据(Excel 文件流)。 */ |
| | | export type TenantEmployeeExportResponse = Blob; |
| | | |
| | | /** POST /tenant/employees/import 请求参数(multipart/form-data,字段名 file)。 */ |
| | | export interface TenantEmployeeImportRequest { |
| | | file: File; |
| | | } |
| | | |
| | | /** POST /tenant/employees/import 响应数据(成功导入行数)。 */ |
| | | export type TenantEmployeeImportResponse = number; |
| | | |
| | | /** GET /tenant/organizations/tree 请求参数。 */ |
| | | export interface TenantOrgTreeRequest { |
| | | orgId?: number; |
| | | } |
| | | |
| | | /** 组织树节点(OrgVO 子集)。 */ |
| | | export interface OrgTreeNode { |
| | | id: number; |
| | | orgName: string; |
| | | status?: boolean; |
| | | children?: OrgTreeNode[]; |
| | | } |
| | | |
| | | /** GET /tenant/organizations/tree 响应数据(ResultListOrgVO 的 data)。 */ |
| | | export type TenantOrgTreeResponse = OrgTreeNode[]; |
| | | |
| | | /** GET /tenant/roles 请求参数:该接口未定义查询参数。 */ |
| | | export type TenantRoleOptionRequest = Record<string, never>; |
| | | |
| | | /** 角色选项(RoleVO 子集)。 */ |
| | | export interface RoleOption { |
| | | id: number; |
| | | roleName: string; |
| | | status?: string; |
| | | } |
| | | |
| | | /** GET /tenant/roles 响应数据(ResultListRoleVO 的 data)。 */ |
| | | export type TenantRoleOptionResponse = RoleOption[]; |
| | | |
| | | /** ProTable 搜索参数,与 EmployeePageQuery 中可筛选字段一致。 */ |
| | | export interface EmployeeListQuery { |
| | | keyword?: string; |
| | | status?: EmployeeStatus; |
| | | orgId?: number; |
| | | roleId?: number; |
| | | } |
| | | |
| | | /** 新增 / 编辑员工表单值。 */ |
| | | export interface EmployeeFormValues { |
| | | name: string; |
| | | phone: string; |
| | | orgId: number; |
| | | roleId: number; |
| | | position?: string; |
| | | email?: string; |
| | | holdLimit?: number; |
| | | region?: string; |
| | | } |
| | | |
| | | /** 下拉选项。 */ |
| | | export interface SelectOption { |
| | | label: string; |
| | | value: number; |
| | | } |
| New file |
| | |
| | | /** 后端通用分页响应结构(PageResult)。 */ |
| | | export interface PageResult<T> { |
| | | page: number; |
| | | size: number; |
| | | total: number; |
| | | totalPages: number; |
| | | records: T[]; |
| | | } |