493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import {
|
|
Table,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Select,
|
|
Switch,
|
|
Space,
|
|
Tag,
|
|
Popconfirm,
|
|
message,
|
|
TreeSelect,
|
|
} from 'antd';
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, CloudDownloadOutlined, InboxOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
|
|
const UsersPage: React.FC = () => {
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [roles, setRoles] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<any>(null);
|
|
const [resetTarget, setResetTarget] = useState<any>(null);
|
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
|
const [profileUser, setProfileUser] = useState<any>(null);
|
|
const [profileForm] = Form.useForm();
|
|
const [form] = Form.useForm();
|
|
const [pwdForm] = Form.useForm();
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
// 组织树同步
|
|
const [syncDeptId, setSyncDeptId] = useState<number | undefined>(undefined);
|
|
const [orgTree, setOrgTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
|
|
|
const loadOrgTree = async () => {
|
|
try {
|
|
const res = await api.get<{ success: boolean; data: Array<{ id: number; name: string; children: Array<{ id: number; name: string; children: unknown[] }> }> }>('/sync/dingtalk/org-tree');
|
|
const toTreeNode = (nodes: Array<{ id: number; name: string; children: unknown[] }>): Array<{ title: string; value: number; children?: unknown[] }> =>
|
|
nodes.map((n) => ({
|
|
title: n.name,
|
|
value: n.id,
|
|
children: n.children?.length > 0 ? toTreeNode(n.children as Array<{ id: number; name: string; children: unknown[] }>) : undefined,
|
|
}));
|
|
setOrgTree(toTreeNode(res.data));
|
|
} catch {
|
|
// org tree may not be available
|
|
}
|
|
};
|
|
|
|
const handleOpenProfile = async (record: any) => {
|
|
setProfileUser(record);
|
|
try {
|
|
const res: any = await api.get(`/rbac/users/${record.id}/profile`);
|
|
profileForm.setFieldsValue(res);
|
|
} catch {
|
|
profileForm.setFieldsValue({});
|
|
}
|
|
setProfileModalOpen(true);
|
|
};
|
|
|
|
const handleProfileSubmit = async () => {
|
|
setSaving(true);
|
|
const values = await profileForm.validateFields();
|
|
try {
|
|
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
|
|
message.success('档案更新成功');
|
|
setProfileModalOpen(false);
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [users, rolesRes] = await Promise.all([
|
|
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
|
|
api.get('/rbac/roles') as Promise<any[]>,
|
|
]);
|
|
setData(users);
|
|
setRoles(rolesRes);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setLoading(false);
|
|
}, [showArchived]);
|
|
|
|
const handleSyncDingTalk = async () => {
|
|
setSyncing(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (syncDeptId) params.rootDeptId = String(syncDeptId);
|
|
const res = await api.post('/sync/trigger?platform=dingtalk', undefined as unknown as null, { params }) as {
|
|
synced: number; logs: Array<{ recordsCount?: number }>;
|
|
};
|
|
const log = res.logs?.[0];
|
|
message.success(`同步完成:${log?.recordsCount || 0} 条记录`);
|
|
fetchData();
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '同步失败');
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleAdd = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleEdit = (record: any) => {
|
|
setEditing(record);
|
|
form.setFieldsValue({
|
|
username: record.username,
|
|
name: record.name,
|
|
isActive: record.isActive,
|
|
roleIds: record.roles?.map((r: any) => r.id) || [],
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
setSaving(true);
|
|
const values = await form.validateFields();
|
|
try {
|
|
if (editing) {
|
|
await api.put(`/rbac/users/${editing.id}`, {
|
|
username: values.username,
|
|
name: values.name,
|
|
isActive: values.isActive,
|
|
roleIds: values.roleIds || [],
|
|
});
|
|
message.success('更新成功');
|
|
} else {
|
|
await api.post('/rbac/users', {
|
|
username: values.username,
|
|
password: values.password,
|
|
name: values.name,
|
|
roleIds: values.roleIds || [],
|
|
});
|
|
message.success('创建成功');
|
|
}
|
|
setModalOpen(false);
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
const handleArchive = async (id: number, archive: boolean) => {
|
|
try {
|
|
await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`);
|
|
message.success(archive ? '已归档' : '已恢复');
|
|
fetchData();
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '操作失败');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
try {
|
|
await api.delete(`/rbac/users/${id}`);
|
|
message.success('已删除');
|
|
fetchData();
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '删除失败');
|
|
}
|
|
};
|
|
|
|
const handleMarkStaff = async (id: number, toStaff: boolean) => {
|
|
try {
|
|
await api.put(`/rbac/users/${id}/${toStaff ? 'mark-staff' : 'mark-student'}`);
|
|
message.success(toStaff ? '已标记为教职工' : '已恢复为学员');
|
|
fetchData();
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '操作失败');
|
|
}
|
|
};
|
|
|
|
const handleResetPwd = (record: any) => {
|
|
setResetTarget(record);
|
|
pwdForm.resetFields();
|
|
setPwdModalOpen(true);
|
|
};
|
|
|
|
const handlePwdSubmit = async () => {
|
|
setSaving(true);
|
|
const values = await pwdForm.validateFields();
|
|
try {
|
|
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
|
|
message.success('密码已重置');
|
|
setPwdModalOpen(false);
|
|
} catch (e: unknown) {
|
|
const err = e as { message?: string };
|
|
message.error(err?.message || '操作失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(() => [
|
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
|
{ title: '用户名', dataIndex: 'username', width: 120 },
|
|
{ title: '姓名', dataIndex: 'name', width: 120 },
|
|
{
|
|
title: '角色',
|
|
dataIndex: 'roles',
|
|
width: 200,
|
|
render: (v: any[]) =>
|
|
v && v.length > 0 ? (
|
|
v.map((r: any) => (
|
|
<Tag key={r.id} color="blue">
|
|
{r.name}
|
|
</Tag>
|
|
))
|
|
) : (
|
|
<Tag color="default">无角色</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'isActive',
|
|
width: 80,
|
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
|
},
|
|
{
|
|
title: '最后登录',
|
|
dataIndex: 'lastLoginAt',
|
|
width: 170,
|
|
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
|
},
|
|
{
|
|
title: '创建时间',
|
|
dataIndex: 'createdAt',
|
|
width: 170,
|
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 320,
|
|
fixed: 'right' as const,
|
|
render: (_: unknown, record: any) => (
|
|
<Space>
|
|
<PermissionButton
|
|
permission="user:edit"
|
|
type="link"
|
|
size="small"
|
|
icon={<IdcardOutlined />}
|
|
onClick={() => handleOpenProfile(record)}
|
|
>
|
|
档案
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="user:edit"
|
|
type="link"
|
|
size="small"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEdit(record)}
|
|
>
|
|
编辑
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="user:reset-password"
|
|
type="link"
|
|
size="small"
|
|
icon={<KeyOutlined />}
|
|
onClick={() => handleResetPwd(record)}
|
|
>
|
|
重置密码
|
|
</PermissionButton>
|
|
{record.isArchived ? (
|
|
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(record.id, false)}>
|
|
<PermissionButton permission="user:edit" type="link" size="small">恢复</PermissionButton>
|
|
</Popconfirm>
|
|
) : (
|
|
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(record.id, true)}>
|
|
<PermissionButton permission="user:edit" type="link" size="small">归档</PermissionButton>
|
|
</Popconfirm>
|
|
)}
|
|
{record.studentStatus === 'active' && (
|
|
<Popconfirm title="确认标记为教职工?" onConfirm={() => handleMarkStaff(record.id, true)}>
|
|
<PermissionButton permission="user:edit" type="link" size="small">标记教职工</PermissionButton>
|
|
</Popconfirm>
|
|
)}
|
|
{record.studentStatus === 'staff' && (
|
|
<Popconfirm title="确认恢复为学员?" onConfirm={() => handleMarkStaff(record.id, false)}>
|
|
<PermissionButton permission="user:edit" type="link" size="small">恢复学员</PermissionButton>
|
|
</Popconfirm>
|
|
)}
|
|
{record.username !== 'admin' && (
|
|
<Popconfirm title="确认删除?需先归档" onConfirm={() => handleDelete(record.id)}>
|
|
<PermissionButton permission="user:delete" type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
删除
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
], []);
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: 16,
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
gap: 8,
|
|
}}>
|
|
<h2 style={{ margin: 0 }}>账号管理</h2>
|
|
<Space wrap>
|
|
<TreeSelect
|
|
treeData={orgTree}
|
|
value={syncDeptId}
|
|
onChange={(v) => setSyncDeptId(v)}
|
|
placeholder="选择同步部门(不选=全部)"
|
|
allowClear
|
|
treeDefaultExpandAll
|
|
style={{ minWidth: 260 }}
|
|
onDropdownVisibleChange={(open) => { if (open) loadOrgTree(); }}
|
|
/>
|
|
<PermissionButton
|
|
permission="user:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleAdd}
|
|
>
|
|
新增账号
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="sync:trigger"
|
|
icon={<CloudDownloadOutlined />}
|
|
loading={syncing}
|
|
onClick={handleSyncDingTalk}
|
|
>
|
|
同步钉钉用户
|
|
</PermissionButton>
|
|
<span style={{ marginLeft: 8 }}>
|
|
<InboxOutlined style={{ marginRight: 4 }} />
|
|
归档
|
|
<Switch
|
|
size="small"
|
|
style={{ marginLeft: 4 }}
|
|
checked={showArchived}
|
|
onChange={setShowArchived}
|
|
/>
|
|
</span>
|
|
</Space>
|
|
</div>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={data}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1250 }}
|
|
pagination={false}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? '编辑账号' : '新增账号'}
|
|
open={modalOpen}
|
|
onOk={handleSubmit}
|
|
onCancel={() => setModalOpen(false)}
|
|
destroyOnHidden
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item
|
|
name="username"
|
|
label="用户名"
|
|
rules={[{ required: true, message: '请输入用户名' }]}
|
|
>
|
|
<Input />
|
|
</Form.Item>
|
|
{!editing && (
|
|
<Form.Item
|
|
name="password"
|
|
label="密码"
|
|
rules={[{ required: true, min: 4, message: '密码至少4位' }]}
|
|
>
|
|
<Input.Password />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
{editing && (
|
|
<Form.Item name="isActive" label="状态" valuePropName="checked">
|
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item
|
|
name="roleIds"
|
|
label="角色分配"
|
|
rules={[{ required: !editing, message: '请至少选择一个角色' }]}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
placeholder="选择角色"
|
|
options={roles
|
|
.filter((r: any) => r.status !== 0)
|
|
.map((r: any) => ({
|
|
value: r.id,
|
|
label: `${r.name}${r.isSystem ? ' (系统)' : ''}`,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={`重置密码 - ${resetTarget?.username}`}
|
|
open={pwdModalOpen}
|
|
onOk={handlePwdSubmit}
|
|
onCancel={() => setPwdModalOpen(false)}
|
|
destroyOnHidden
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={pwdForm} layout="vertical">
|
|
<Form.Item
|
|
name="password"
|
|
label="新密码"
|
|
rules={[{ required: true, min: 4, message: '密码至少4位' }]}
|
|
>
|
|
<Input.Password />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={`教师档案 - ${profileUser?.name || profileUser?.username}`}
|
|
open={profileModalOpen}
|
|
onOk={handleProfileSubmit}
|
|
onCancel={() => setProfileModalOpen(false)}
|
|
destroyOnHidden
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={profileForm} layout="vertical">
|
|
<Form.Item name="joinedAt" label="入职日期">
|
|
<Input placeholder="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
<Form.Item name="qualifications" label="资质">
|
|
<Input.TextArea placeholder="教师资格证号、学历等" rows={2} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="subjects"
|
|
label="任教学科"
|
|
>
|
|
<Select
|
|
mode="tags"
|
|
placeholder="输入学科后回车添加"
|
|
options={[
|
|
{ value: '语文', label: '语文' },
|
|
{ value: '数学', label: '数学' },
|
|
{ value: '英语', label: '英语' },
|
|
{ value: '政治', label: '政治' },
|
|
{ value: '历史', label: '历史' },
|
|
{ value: '地理', label: '地理' },
|
|
{ value: '物理', label: '物理' },
|
|
{ value: '化学', label: '化学' },
|
|
{ value: '生物', label: '生物' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UsersPage;
|