feat: 完善 RBAC 权限体系与权限管理页面

This commit is contained in:
2026-08-05 17:10:51 +08:00
parent 68270e7571
commit 644c35ce53
16 changed files with 1311 additions and 926 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd';
import React, { useState, useMemo, useCallback } from 'react';
import { App, Button, Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd';
import {
PlusOutlined,
EditOutlined,
@@ -13,11 +13,25 @@ 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 { validateResponse } from '../../utils/validate';
import { rolesSchema, usersSchema } from '../../api/schemas';
import { getErrorMessage } from '../../utils/error';
const USER_FIELDS = {
username: 'username',
name: 'name',
phone: 'phone',
email: 'email',
status: 'status',
} as const;
const UsersPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
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<any>(null);
@@ -45,36 +59,73 @@ const UsersPage: React.FC = () => {
setSaving(true);
const values = await profileForm.validateFields();
try {
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
await profileMutation.mutateAsync({ id: profileUser.id, values });
message.success('档案更新成功');
setProfileModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} 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]);
const {
data: fetchResult = { users: [], roles: [] },
isLoading,
isFetching,
} = useQuery<{ users: any[]; roles: any[] }>({
queryKey: ['rbac', 'users', showArchived],
queryFn: async () => {
try {
const [users, rolesRes] = await Promise.all([
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
api.get('/rbac/roles') as Promise<any[]>,
]);
return {
users: validateResponse<any[]>(usersSchema, users),
roles: validateResponse<any[]>(rolesSchema, rolesRes),
};
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return { users: [], roles: [] };
}
},
});
const data = fetchResult.users;
const roles = fetchResult.roles;
const loading = isLoading || isFetching;
useEffect(() => {
fetchData();
}, [fetchData]);
const profileMutation = useApiMutation(
async ({ id, values }: { id: number; values: any }) =>
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: any; field: string; value: unknown }) =>
api.put(`/rbac/users/${record.id}`, { [field]: value }),
{ invalidate: [['rbac', 'users']] },
);
const handleAdd = () => {
setEditing(null);
@@ -96,41 +147,48 @@ const UsersPage: React.FC = () => {
setSaving(true);
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rbac/users/${editing.id}`, {
username: values.username,
name: values.name,
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('创建成功');
}
await saveMutation.mutateAsync({
username: values.username,
password: values.password,
name: values.name,
roleIds: values.roleIds || [],
});
message.success(editing ? '更新成功' : '创建成功');
setModalOpen(false);
fetchData();
} catch (e: any) {
message.error(e.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleArchive = async (id: number, archive: boolean) => {
try {
await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`);
await archiveMutation.mutateAsync({ id, archive });
message.success(archive ? '已归档' : '已恢复');
fetchData();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: any) => {
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 统一处理
}
},
});
};
const handleResetPwd = (record: any) => {
setResetTarget(record);
pwdForm.resetFields();
@@ -141,12 +199,11 @@ const UsersPage: React.FC = () => {
setSaving(true);
const values = await pwdForm.validateFields();
try {
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password });
message.success('密码已重置');
setPwdModalOpen(false);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '操作失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
@@ -154,11 +211,14 @@ const UsersPage: React.FC = () => {
const saveCell = useCallback(
async (record: any, field: string, value: unknown) => {
await api.put(`/rbac/users/${record.id}`, { [field]: value });
message.success('已保存');
await fetchData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[fetchData],
[saveCellMutation],
);
const columns = useMemo(
@@ -166,7 +226,7 @@ const UsersPage: React.FC = () => {
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: '用户名',
dataIndex: 'username',
dataIndex: USER_FIELDS.username,
width: 120,
render: (v: string, r: any) => (
<EditableCell
@@ -174,7 +234,7 @@ const UsersPage: React.FC = () => {
required
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'username', next)}
onSave={(next) => saveCell(r, USER_FIELDS.username, next)}
>
{v}
</EditableCell>
@@ -182,7 +242,7 @@ const UsersPage: React.FC = () => {
},
{
title: '姓名',
dataIndex: 'name',
dataIndex: USER_FIELDS.name,
width: 120,
render: (v: string, r: any) => (
<EditableCell
@@ -190,7 +250,7 @@ const UsersPage: React.FC = () => {
required
permission="user:edit"
disabled={r.isArchived}
onSave={(next) => saveCell(r, 'name', next)}
onSave={(next) => saveCell(r, USER_FIELDS.name, next)}
>
{v}
</EditableCell>
@@ -267,11 +327,18 @@ const UsersPage: React.FC = () => {
</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, 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="归档后可恢复,确认归档?"
@@ -286,7 +353,7 @@ const UsersPage: React.FC = () => {
),
},
],
[roles, saveCell],
[roles, saveCell, canPurgeUser, handlePurge],
);
return (