- 各页面 useCallback/useMemo 依赖补全(modal/mutation/setter 等),避免闭包过期 - ECharts 使用 optionRef、MainLayout 缓存菜单转换函数、main.tsx 增加挂载点校验 - 学生同步结果改用 recordsCount 展示
391 lines
12 KiB
TypeScript
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;
|