560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
|
import { App, Button, 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';
|
|
import { usePermission } from '../../hooks/usePermission';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useApiMutation } from '../../hooks/useApiMutation';
|
|
import { useDirtyGuard } from '../../hooks/useDirtyGuard';
|
|
import { validateResponse } from '../../utils/validate';
|
|
import { rolesSchema, usersSchema } from '../../api/schemas';
|
|
import { getErrorMessage } from '../../utils/error';
|
|
import { PASSWORD_RULES } from '../../utils/password-rules';
|
|
|
|
const USER_FIELDS = {
|
|
username: 'username',
|
|
name: 'name',
|
|
phone: 'phone',
|
|
email: 'email',
|
|
status: 'status',
|
|
} as const;
|
|
|
|
interface RoleOption {
|
|
id: number;
|
|
name: string;
|
|
status?: number;
|
|
isSystem?: boolean;
|
|
}
|
|
|
|
interface UserRecord {
|
|
id: number;
|
|
username: string;
|
|
name?: string;
|
|
phone?: string | null;
|
|
email?: string | null;
|
|
status?: number;
|
|
isArchived?: boolean;
|
|
lastLoginAt?: string | null;
|
|
createdAt?: string | null;
|
|
roles?: RoleOption[];
|
|
}
|
|
|
|
const UsersPage: React.FC = () => {
|
|
const { modal } = App.useApp();
|
|
const { hasPermission } = usePermission();
|
|
const canPurgeUser = hasPermission('user:purge');
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<UserRecord | null>(null);
|
|
const [resetTarget, setResetTarget] = useState<UserRecord | null>(null);
|
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
|
const [profileUser, setProfileUser] = useState<UserRecord | null>(null);
|
|
const [profileForm] = Form.useForm();
|
|
const [form] = Form.useForm();
|
|
const [pwdForm] = Form.useForm();
|
|
const [saving, setSaving] = useState(false);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
|
|
// 弹窗「未保存内容」保护
|
|
const accountGuard = useDirtyGuard(form);
|
|
const pwdGuard = useDirtyGuard(pwdForm);
|
|
const profileGuard = useDirtyGuard(profileForm);
|
|
|
|
const handleOpenProfile = useCallback(
|
|
async (record: UserRecord) => {
|
|
setProfileUser(record);
|
|
try {
|
|
const res = await api.get<UserProfileResponse>(`/rbac/users/${record.id}/profile`);
|
|
profileForm.setFieldsValue(userProfileResponseToFormValues(res));
|
|
} catch {
|
|
profileForm.setFieldsValue({});
|
|
}
|
|
profileGuard.snapshot();
|
|
setProfileModalOpen(true);
|
|
},
|
|
[profileForm, profileGuard],
|
|
);
|
|
|
|
const handleProfileSubmit = async () => {
|
|
if (!profileUser) return;
|
|
setSaving(true);
|
|
const values = await profileForm.validateFields();
|
|
try {
|
|
await profileMutation.mutateAsync({ id: profileUser.id, values });
|
|
message.success('档案更新成功');
|
|
setProfileModalOpen(false);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const {
|
|
data: fetchResult = { users: [], roles: [] },
|
|
isLoading,
|
|
isFetching,
|
|
} = useQuery<{ users: UserRecord[]; roles: RoleOption[] }>({
|
|
queryKey: ['rbac', 'users', showArchived],
|
|
queryFn: async () => {
|
|
try {
|
|
const [users, rolesRes] = await Promise.all([
|
|
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<UserRecord[]>,
|
|
api.get('/rbac/roles') as Promise<RoleOption[]>,
|
|
]);
|
|
return {
|
|
users: validateResponse<UserRecord[]>(usersSchema, users),
|
|
roles: validateResponse<RoleOption[]>(rolesSchema, rolesRes),
|
|
};
|
|
} catch (e: unknown) {
|
|
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
|
|
return { users: [], roles: [] };
|
|
}
|
|
},
|
|
});
|
|
const data = fetchResult.users;
|
|
const roles = fetchResult.roles;
|
|
const loading = isLoading || isFetching;
|
|
|
|
const profileMutation = useApiMutation(
|
|
async ({ id, values }: { id: number; values: Record<string, unknown> }) =>
|
|
api.put(`/rbac/users/${id}/profile`, values),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
const saveMutation = useApiMutation(
|
|
async (values: { username: string; password?: string; name: string; roleIds: number[] }) =>
|
|
editing
|
|
? api.put(`/rbac/users/${editing.id}`, values)
|
|
: api.post('/rbac/users', values),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
const archiveMutation = useApiMutation(
|
|
async ({ id, archive }: { id: number; archive: boolean }) =>
|
|
api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
const purgeMutation = useApiMutation(
|
|
async (id: number) => api.delete(`/rbac/users/${id}/permanent`),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
const pwdMutation = useApiMutation(
|
|
async ({ id, password }: { id: number; password: string }) =>
|
|
api.put(`/rbac/users/${id}/password`, { password }),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
const saveCellMutation = useApiMutation(
|
|
async ({ record, field, value }: { record: UserRecord; field: string; value: unknown }) =>
|
|
api.put(`/rbac/users/${record.id}`, { [field]: value }),
|
|
{ invalidate: [['rbac', 'users']] },
|
|
);
|
|
|
|
const handleAdd = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
accountGuard.snapshot();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleEdit = useCallback(
|
|
(record: UserRecord) => {
|
|
setEditing(record);
|
|
form.setFieldsValue({
|
|
username: record.username,
|
|
name: record.name,
|
|
roleIds: record.roles?.map((r: RoleOption) => r.id) || [],
|
|
});
|
|
accountGuard.snapshot();
|
|
setModalOpen(true);
|
|
},
|
|
[accountGuard, form],
|
|
);
|
|
|
|
const handleSubmit = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
await saveMutation.mutateAsync({
|
|
username: values.username,
|
|
password: values.password,
|
|
name: values.name,
|
|
roleIds: values.roleIds || [],
|
|
});
|
|
message.success(editing ? '更新成功' : '创建成功');
|
|
setModalOpen(false);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
const handleArchive = useCallback(
|
|
async (id: number, archive: boolean) => {
|
|
try {
|
|
await archiveMutation.mutateAsync({ id, archive });
|
|
message.success(archive ? '已归档' : '已恢复');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
[archiveMutation],
|
|
);
|
|
|
|
const handlePurge = useCallback(
|
|
(record: UserRecord) => {
|
|
modal.confirm({
|
|
title: `永久删除账号「${record.name || record.username}」?`,
|
|
content:
|
|
'删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?',
|
|
okText: '永久删除',
|
|
okButtonProps: { danger: true },
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
try {
|
|
await purgeMutation.mutateAsync(record.id);
|
|
message.success('已永久删除(不可恢复)');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
});
|
|
},
|
|
[modal, purgeMutation],
|
|
);
|
|
|
|
const handleResetPwd = useCallback(
|
|
(record: UserRecord) => {
|
|
setResetTarget(record);
|
|
pwdForm.resetFields();
|
|
pwdGuard.snapshot();
|
|
setPwdModalOpen(true);
|
|
},
|
|
[pwdForm, pwdGuard],
|
|
);
|
|
|
|
const handlePwdSubmit = async () => {
|
|
if (!resetTarget) return;
|
|
const values = await pwdForm.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password });
|
|
message.success('密码已重置');
|
|
setPwdModalOpen(false);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveCell = useCallback(
|
|
async (record: UserRecord, field: string, value: unknown) => {
|
|
try {
|
|
await saveCellMutation.mutateAsync({ record, field, value });
|
|
message.success('已保存');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
},
|
|
[saveCellMutation],
|
|
);
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
|
{
|
|
title: '用户名',
|
|
dataIndex: USER_FIELDS.username,
|
|
width: 120,
|
|
render: (v: string, r: UserRecord) => (
|
|
<EditableCell
|
|
value={v}
|
|
required
|
|
permission="user:edit"
|
|
disabled={r.isArchived}
|
|
onSave={(next) => saveCell(r, USER_FIELDS.username, next)}
|
|
>
|
|
<span
|
|
title={v}
|
|
style={{
|
|
display: 'block',
|
|
maxWidth: '100%',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{v}
|
|
</span>
|
|
</EditableCell>
|
|
),
|
|
},
|
|
{
|
|
title: '姓名',
|
|
dataIndex: USER_FIELDS.name,
|
|
width: 120,
|
|
render: (v: string, r: UserRecord) => (
|
|
<EditableCell
|
|
value={v}
|
|
required
|
|
permission="user:edit"
|
|
disabled={r.isArchived}
|
|
onSave={(next) => saveCell(r, USER_FIELDS.name, next)}
|
|
>
|
|
{v}
|
|
</EditableCell>
|
|
),
|
|
},
|
|
{
|
|
title: '角色',
|
|
dataIndex: 'roles',
|
|
width: 200,
|
|
render: (v: RoleOption[] | undefined, record: UserRecord) => (
|
|
<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: RoleOption) => (
|
|
<Tag key={r.id} color="blue">
|
|
{r.name}
|
|
</Tag>
|
|
))
|
|
) : (
|
|
<Tag color="default">无角色</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: UserRecord) => (
|
|
<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>
|
|
{canPurgeUser ? (
|
|
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
|
删除
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<Popconfirm
|
|
title="归档后可恢复,确认归档?"
|
|
onConfirm={() => handleArchive(record.id, true)}
|
|
>
|
|
<PermissionButton permission="user:edit" type="link" size="small">
|
|
归档
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
],
|
|
[
|
|
roles,
|
|
saveCell,
|
|
canPurgeUser,
|
|
handlePurge,
|
|
handleOpenProfile,
|
|
handleEdit,
|
|
handleArchive,
|
|
handleResetPwd,
|
|
],
|
|
);
|
|
|
|
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: 1150 }}
|
|
pagination={false}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? '编辑账号' : '新增账号'}
|
|
open={modalOpen}
|
|
onOk={handleSubmit}
|
|
onCancel={() => accountGuard.confirmClose(() => setModalOpen(false))}
|
|
forceRender
|
|
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={PASSWORD_RULES}
|
|
>
|
|
<Input.Password />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="roleIds"
|
|
label="角色分配"
|
|
rules={[{ required: !editing, message: '请至少选择一个角色' }]}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
placeholder="选择角色"
|
|
showSearch
|
|
optionFilterProp="label"
|
|
options={roles
|
|
.filter((r: RoleOption) => r.status !== 0)
|
|
.map((r: RoleOption) => ({
|
|
value: r.id,
|
|
label: `${r.name}${r.isSystem ? ' (系统)' : ''}`,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={`重置密码 - ${resetTarget?.username}`}
|
|
open={pwdModalOpen}
|
|
onOk={handlePwdSubmit}
|
|
onCancel={() => pwdGuard.confirmClose(() => setPwdModalOpen(false))}
|
|
forceRender
|
|
confirmLoading={saving}
|
|
>
|
|
<Form form={pwdForm} layout="vertical">
|
|
<Form.Item
|
|
name="password"
|
|
label="新密码"
|
|
rules={PASSWORD_RULES}
|
|
>
|
|
<Input.Password />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={`教师档案 - ${profileUser?.name || profileUser?.username}`}
|
|
open={profileModalOpen}
|
|
onOk={handleProfileSubmit}
|
|
onCancel={() => profileGuard.confirmClose(() => setProfileModalOpen(false))}
|
|
forceRender
|
|
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>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UsersPage;
|