Files
gongxue-base/apps/admin/src/pages/Users/index.tsx

467 lines
14 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd';
import {
PlusOutlined,
EditOutlined,
KeyOutlined,
IdcardOutlined,
InboxOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form';
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 [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const handleOpenProfile = async (record: any) => {
setProfileUser(record);
try {
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
profileForm.setFieldsValue(userProfileResponseToFormValues(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: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [showArchived]);
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 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 saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
await api.put(`/rbac/users/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
},
[fetchData],
);
const columns = useMemo(
() => [
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: '用户名',
dataIndex: 'username',
width: 120,
render: (v: string, r: any) => (
<EditableCell
value={v}
required
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'username', next)}
>
{v}
</EditableCell>
),
},
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v: string, r: any) => (
<EditableCell
value={v}
required
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'name', next)}
>
{v}
</EditableCell>
),
},
{
title: '角色',
dataIndex: 'roles',
width: 200,
render: (v: any[], record: any) => (
<EditableCell
value={v?.map((item) => item.id) || []}
editor="multi-select"
options={roles.map((item) => ({ value: item.id, label: item.name }))}
permission="user:edit"
disabled={record.isArchived}
onSave={(next) => saveCell(record, 'roleIds', next)}
>
{v && v.length > 0 ? (
v.map((r: any) => (
<Tag key={r.id} color="blue">
{r.name}
</Tag>
))
) : (
<Tag color="default"></Tag>
)}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean, r: any) => (
<EditableCell
value={String(v)}
editor="select"
options={[
{ value: 'true', label: '启用' },
{ value: 'false', label: '禁用' },
]}
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
>
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
</EditableCell>
),
},
{
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: 240,
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>
)}
</Space>
),
},
],
[roles, saveCell],
);
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<PermissionButton
permission="user:create"
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
</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;