wsj
5 days ago 4a7848a4bec14542b8574d250684dd631a388c1a
feat: 用户管理接入员工管理接口

- 重构用户管理页为 ProTable,对接员工分页列表及关键词 / 状态 / 组织 / 角色筛选
- 新增员工详情、新增、编辑、删除、Excel 导入 / 导出
- 组织树与角色列表作为下拉选项数据源
- 抽取共享 PageResult 类型
- 修复用户 / 角色状态枚举缺失时的渲染报错
- 同步 README 说明

Co-Authored-By: Claude Code <noreply@anthropic.com>
5 files modified
4 files added
1196 ■■■■■ changed files
README.md 2 ●●●●● patch | view | raw | blame | history
src/pages/customers/list/types.ts 11 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/columns.tsx 24 ●●●●● patch | view | raw | blame | history
src/pages/system/roles/index.tsx 10 ●●●●● patch | view | raw | blame | history
src/pages/system/users/columns.tsx 141 ●●●●● patch | view | raw | blame | history
src/pages/system/users/index.tsx 721 ●●●●● patch | view | raw | blame | history
src/pages/system/users/server.ts 112 ●●●●● patch | view | raw | blame | history
src/pages/system/users/types.ts 167 ●●●●● patch | view | raw | blame | history
src/types/api.ts 8 ●●●●● patch | view | raw | blame | history
README.md
@@ -100,7 +100,7 @@
| 页面     | 路由                    | 说明                               |
| -------- | ----------------------- | ---------------------------------- |
| 工作台   | `/dashboard`            | 数据卡片、快捷入口、动态、最近订单 |
| 用户管理 | `/system/users`         | 搜索、新增 / 编辑 / 删除用户       |
| 用户管理 | `/system/users`         | 员工列表、详情、增删改、导入 / 导出 |
| 角色管理 | `/system/roles`         | 角色列表、详情、启用 / 停用        |
| 订单管理 | `/business/orders`      | 筛选、处理订单、导出               |
| 客户列表 | `/customers/list`       | 接口数据,按归属销售员筛选         |
src/pages/customers/list/types.ts
@@ -1,3 +1,5 @@
import type { PageResult } from '@/types/api';
/** 客户类型。 */
export type CustomerType = 'ENTERPRISE' | 'PERSONAL';
@@ -58,15 +60,6 @@
  dealAt?: string;
  lostAt?: string;
  lostReason?: string;
}
/** 通用分页响应(PageResult)。 */
export interface PageResult<T> {
  page: number;
  size: number;
  total: number;
  totalPages: number;
  records: T[];
}
/** 客户列表响应数据(ResultPageResultCustomerVO 的 data)。 */
src/pages/system/roles/columns.tsx
@@ -13,6 +13,17 @@
  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;
@@ -49,11 +60,14 @@
      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: '操作',
src/pages/system/roles/index.tsx
@@ -2,7 +2,7 @@
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';
@@ -106,13 +106,13 @@
            <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>
src/pages/system/users/columns.tsx
New file
@@ -0,0 +1,141 @@
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>
    ),
  },
];
src/pages/system/users/index.tsx
@@ -1,373 +1,450 @@
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>
  );
src/pages/system/users/server.ts
New file
@@ -0,0 +1,112 @@
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(),
  });
src/pages/system/users/types.ts
New file
@@ -0,0 +1,167 @@
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;
}
src/types/api.ts
New file
@@ -0,0 +1,8 @@
/** 后端通用分页响应结构(PageResult)。 */
export interface PageResult<T> {
  page: number;
  size: number;
  total: number;
  totalPages: number;
  records: T[];
}