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,
|
Button,
|
Descriptions,
|
Drawer,
|
Form,
|
Input,
|
InputNumber,
|
Modal,
|
Select,
|
Space,
|
Tag,
|
Upload,
|
} from 'antd';
|
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';
|
|
const flattenOrgOptions = (nodes: OrgTreeNode[]): SelectOption[] =>
|
nodes.flatMap((node) => [
|
{ label: node.orgName, value: node.id },
|
...flattenOrgOptions(node.children ?? []),
|
]);
|
|
const Users = () => {
|
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);
|
|
useEffect(() => {
|
let active = true;
|
|
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();
|
setFormOpen(true);
|
}, [form]);
|
|
const closeForm = useCallback(() => {
|
setFormOpen(false);
|
setEditing(null);
|
}, []);
|
|
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 handleDetail = useCallback(async (record: EmployeeVO) => {
|
setDetail(null);
|
setDetailOpen(true);
|
setDetailLoading(true);
|
|
try {
|
const data = await getEmployeeDetail({ id: record.id });
|
setDetail(data);
|
} catch {
|
// 错误已由 request 统一提示
|
setDetailOpen(false);
|
} finally {
|
setDetailLoading(false);
|
}
|
}, []);
|
|
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 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">
|
<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;
|
|
const result = await getEmployeeList(query);
|
|
return {
|
data: result.records ?? [],
|
total: result.total ?? 0,
|
success: true,
|
};
|
}}
|
/>
|
|
<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="name"
|
label="姓名"
|
rules={[{ required: true, message: '请输入姓名' }]}
|
>
|
<Input placeholder="请输入员工姓名" />
|
</Form.Item>
|
<Form.Item
|
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
|
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>
|
);
|
};
|
|
export default Users;
|