Files
gongxue-base/apps/admin/src/pages/Roles/index.tsx
wangziqi 1a1e90c72f fix(admin): 补全 React hooks 依赖并修复页面稳定性
- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期
- ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验
- 学生同步结果改用 recordsCount 展示
2026-08-05 21:14:08 +08:00

391 lines
12 KiB
TypeScript

import React, { useState, useMemo, useCallback } from 'react';
import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd';
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { message } from '../../ui/app-message';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { permissionTreeSchema, rolesSchema } from '../../api/schemas';
import { getErrorMessage } from '../../utils/error';
interface PermissionItem {
id: number;
code: string;
name: string;
group: string;
}
interface RoleItem {
id: number;
name: string;
description: string;
isSystem: boolean;
status: number;
permissions: PermissionItem[];
}
const RolesPage: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<RoleItem | null>(null);
const [form] = Form.useForm();
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const {
data: fetchResult = { roles: [], permTree: [] },
isLoading,
isFetching,
} = useQuery<{
roles: RoleItem[];
permTree: { group: string; permissions: PermissionItem[] }[];
}>({
queryKey: ['rbac', 'roles', 'permission-tree'],
queryFn: async () => {
try {
const [roles, permTree] = await Promise.all([
api.get('/rbac/roles') as Promise<RoleItem[]>,
api.get('/rbac/permissions/tree') as Promise<
{ group: string; permissions: PermissionItem[] }[]
>,
]);
return {
roles: validateResponse<RoleItem[]>(rolesSchema, roles),
permTree: validateResponse<{ group: string; permissions: PermissionItem[] }[]>(
permissionTreeSchema,
permTree,
),
};
} catch (e: unknown) {
message.error(getErrorMessage(e, '加载失败,请稍后重试'));
return { roles: [], permTree: [] };
}
},
});
const data = fetchResult.roles;
const allPerms = fetchResult.permTree;
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (values: { name: string; description?: string; permissionIds: number[] }) =>
editing
? api.put(`/rbac/roles/${editing.id}`, values)
: api.post('/rbac/roles', values),
{ invalidate: [['rbac', 'roles', 'permission-tree']] },
);
const disableMutation = useApiMutation(
async (id: number) => api.delete(`/rbac/roles/${id}`),
{ invalidate: [['rbac', 'roles', 'permission-tree']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: RoleItem; field: string; value: unknown }) =>
api.put(`/rbac/roles/${record.id}`, { [field]: value }),
{ invalidate: [['rbac', 'roles', 'permission-tree']] },
);
const handleAdd = () => {
setEditing(null);
form.resetFields();
setSelectedPermIds([]);
setModalOpen(true);
};
const handleEdit = useCallback(
(record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map((p) => p.id));
setModalOpen(true);
},
[form],
);
const handleSubmit = async () => {
setSaving(true);
const values = await form.validateFields();
try {
await saveMutation.mutateAsync({
name: values.name,
description: values.description,
permissionIds: selectedPermIds,
});
message.success(editing ? '角色更新成功' : '角色创建成功');
setModalOpen(false);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const handleDisable = useCallback(
async (id: number) => {
try {
await disableMutation.mutateAsync(id);
message.success('角色已停用');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[disableMutation],
);
const groupNames: Record<string, string> = {
dashboard: '数据面板',
notification: '通知中心',
student: '学生管理',
'student-scope': '学生范围权限',
teacher: '教师管理',
'teacher-workspace': '教师工作台',
room: '宿舍管理',
occupancy: '入住管理',
expense: '费用管理',
bill: '账单管理',
deposit: '押金管理',
wallet: '学生余额',
classroom: '教室管理',
organization: '机构管理',
rental: '租赁订单',
class: '班级管理',
schedule: '排课管理',
attendance: '考勤管理',
'attendance-scope': '考勤范围权限',
log: '操作日志',
user: '用户管理',
role: '角色管理',
sync: '数据同步',
integration: '集成配置',
ai: 'AI 配置',
'ai-chat': 'AI 助手',
};
const permissionOptions = useMemo(
() =>
allPerms
.flatMap((group) => group.permissions)
.map((item) => ({ value: item.id, label: `${item.group} · ${item.name}` })),
[allPerms],
);
const saveCell = useCallback(
async (record: RoleItem, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
[saveCellMutation],
);
const columns = useMemo(
() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '名称',
dataIndex: 'name',
width: 120,
render: (v: string, r: RoleItem) => (
<EditableCell
value={v}
required
permission="role:edit"
onSave={(next) => saveCell(r, 'name', next)}
>
{v}
</EditableCell>
),
},
{
title: '描述',
dataIndex: 'description',
width: 200,
render: (v: string, r: RoleItem) => (
<EditableCell
value={v}
editor="textarea"
permission="role:edit"
onSave={(next) => saveCell(r, 'description', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '权限标签',
dataIndex: 'permissions',
width: 150,
render: (perms: PermissionItem[], record: RoleItem) => (
<EditableCell
value={perms?.map((item) => item.id) || []}
editor="multi-select"
options={permissionOptions}
permission="role:edit"
onSave={(next) => saveCell(record, 'permissionIds', next)}
>
{perms?.length > 0 ? (
<Tag color="blue">{perms.length} </Tag>
) : (
<Tag color="default"></Tag>
)}
</EditableCell>
),
},
{
title: '系统',
dataIndex: 'isSystem',
width: 80,
render: (v: boolean) => (v ? <Tag color="orange"></Tag> : null),
},
{
title: '操作',
width: 180,
fixed: 'right' as const,
render: (_: any, record: RoleItem) => (
<Space>
<PermissionButton
permission="role:edit"
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</PermissionButton>
{!record.isSystem && (
<Popconfirm title="确认停用该角色?" onConfirm={() => handleDisable(record.id)}>
<PermissionButton
permission="role:delete"
type="link"
size="small"
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
),
},
],
[permissionOptions, saveCell, handleEdit, handleDisable],
);
const handleGroupCheckAll = (group: string, checked: boolean) => {
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
if (checked) {
setSelectedPermIds((prev) => [...new Set([...prev, ...groupPermIds])]);
} else {
setSelectedPermIds((prev) => prev.filter((id) => !groupPermIds.includes(id)));
}
};
const isGroupAllChecked = (group: string) => {
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
return groupPermIds.length > 0 && groupPermIds.every((id) => selectedPermIds.includes(id));
};
const isGroupIndeterminate = (group: string) => {
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
const checkedCount = groupPermIds.filter((id) => selectedPermIds.includes(id)).length;
return checkedCount > 0 && checkedCount < groupPermIds.length;
};
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 8,
}}
>
<h2 style={{ margin: 0 }}></h2>
<PermissionButton
permission="role:create"
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 900 }}
pagination={false}
/>
<Modal
title={editing ? '编辑角色' : '新增角色'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnHidden
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="角色名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input disabled={editing?.isSystem} />
</Form.Item>
<Form.Item name="description" label="角色描述">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="权限分配">
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{allPerms.map((group) => (
<Card
key={group.group}
size="small"
title={
<Checkbox
checked={isGroupAllChecked(group.group)}
indeterminate={isGroupIndeterminate(group.group)}
onChange={(e) => handleGroupCheckAll(group.group, e.target.checked)}
>
{groupNames[group.group] || group.group}
</Checkbox>
}
style={{ marginBottom: 8 }}
>
<Checkbox.Group
value={selectedPermIds}
onChange={(vals) => setSelectedPermIds(vals as number[])}
>
<Space wrap>
{group.permissions.map((p) => (
<Checkbox key={p.id} value={p.id}>
{p.name}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Card>
))}
</div>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;