一、目标
- 实现一个基础的角色与菜单系统
- 基于 Prisma + Remix + antd
- 角色与菜单关联
- 菜单需要自引用
- Primsa 处理数据库
- Remix 架构前后端
- Antd 提供 UI 支持
本文适合想要想要学习 React 全栈的开发者。本文不是实现 RABC, 知识实现了基本的角色与菜单关联,目标在表关联操作和数据
二、难点
- Remix 是一个基于 React 的前端框架,需要了解 React + Node.js 和 Remix 框架相关的知识。
- Prisma 是下一代 ORM 库,需要简单的数据库的基本知识(本文使用 sqlite)。
- Ant Design UI 组件库设计。
三、设计
- 角色表:存放不同的角色的,可以自定义角色
- 菜单表:存放在菜单列表,树状结构,自引用。
- 前端UI: 使用 prot-table 展示列表,使用弹出添加辅助 CRUD 操作。
四、初始化
sh
npx create-remix@latest sqlite-menu
pnpm i && pnpm add remix-utils
- remix-utils 使用
ClientOnly
组件渲染在仅仅在客户端渲染的组件。
五、安装 Primsa
sh
pnpm add prisma
npx prisma init --datasource-provider sqlite # 使用 sqlite 初始化
定义 prisma
prisma
// 定义一个名为 Role 的数据模型
model Role {
id Int @id @default(autoincrement())
name String @unique // 角色名称,唯一
desc String?
// 可以添加其他角色相关的字段
menus Menu[] // 角色可以有多个菜单
}
// 定义一个名为 Menu 的数据模型
model Menu {
id Int @id @default(autoincrement())
name String @unique // 菜单名称,唯一
path String
icon String
component String
parentMenu Menu? @relation("ChildMenus", fields: [parentId], references: [id]) // 指向父菜单
parentId Int? // 存储父菜单的 ID
roles Role[] // 菜单可以被多个角色访问
childMenus Menu[] @relation("ChildMenus") // 子菜单
}
六、prisma 执行迁移
sh
npx prisma migrate dev --name init
cd prisma
touch seed.ts # 根据自己的需求配置自己的种子文件
npx prisma studio # 运行 studio
七、Prisma 创建客户端
点击查看:创建全局客户端
ts
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
var __db__: PrismaClient;
}
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.__db__) {
global.__db__ = new PrismaClient();
}
prisma = global.__db__;
prisma.$connect();
}
export default prisma;
八、Prisma 操作 role 表
操作以下是 primsa 对表的基本操作:
- 创建
- 查找所有 role
- 根据 id 查找 role
- 分页查找
- 根据 id 查询
- 根据 id 删除
- 查询数量
点击查看:prisma 操作 role 表
ts
import prisma from "./server";
export const createRole = ({ name, desc, menus }: { name: string, desc?: string, menus: any[] }) => {
return prisma.role.create({
data: {
name,
desc,
menus: {
connect: menus.map(m => ({ id: m.id })), // 根据 id 关联到 menu表
}
},
});
};
export const findRoleAll = async () => {
const roles = await prisma.role.findMany()
const count = await prisma.role.count(); // 记录总数
return {
roles,
count
}
};
export const findRoleById = (id: number) => {
return prisma.role.findUnique({
where: { id },
});
}
export const findRoleByPage = async ({ page, pageSize }: { page: number, pageSize: number }) => {
const roles = await prisma.role.findMany({
skip: (page - 1) * pageSize, // 跳过前面的记录
take: pageSize, // 每页显示的记录数
orderBy: {
id: 'desc', // 这里的 'asc' 表示升序,'desc' 表示降序
},
include: {
menus: true
}
});
const count = await prisma.role.count(); // 记录总数
return {
roles,
count
}
}
export const updateRoleById = async ({ id, desc, menus }: { id: number, desc: string, menus: any[] }) => {
const menusArr = await prisma.menu.findMany()
const roles = await prisma.role.update({
where: { id },
data: {
desc,
menus: {
connect: menus.map(m => ({ id: m.id })),
disconnect: menusArr.map((i) => {
return menus.some((item) => item.id === i.id) ? '' : i
}).filter((i) => i !== '') as any
}
},
});
return roles
}
export const deleteRoleById = (id: number) => {
return prisma.role.delete({
where: { id },
})
}
export const deleteRolesByIds = (ids: number[]) => {
return prisma.role.deleteMany({
where: { id: {
in: ids
} },
})
}
export const count = () => {
return prisma.role.count()
}
九、菜单接口
- 创建菜单
- 更新菜单
- 获取所有菜单
点击查看:菜单接口
ts
import prisma from "./server";
type CreateMenu = {
name: string;
parentId?: number;
path: string;
icon: string;
roles: any[] | string;
component: string;
};
export const createMenu = ({
name,
parentId,
path,
icon,
roles,
component,
}: CreateMenu) => {
let _roles: any[] = [];
if (typeof roles === "string") {
_roles = roles?.split(",").map((i: any) => parseInt(i, 10));
} else {
_roles = roles as any[];
}
return prisma.menu.create({
data: {
name,
parentId,
path,
icon,
component,
roles: {
connect: _roles.map((roleId: any) => ({ id: roleId })),
},
},
include: {
roles: true, // 包含关联的角色信息
},
});
};
export const updateMenu = (id: number, data: any) => {
return prisma.menu.update({
where: { id },
data,
});
};
export const findAllMenu = async () => {
const menu = await prisma.menu.findMany({
include: {
roles: true,
},
});
const count = await prisma.menu.count();
const roles = await prisma.role.findMany();
return { roles, menu, count };
};
十、仅在页面端渲染
_admin.tsx
tsx
import { Outlet } from "@remix-run/react";
import { ClientOnly } from "remix-utils/client-only";
export default function Admin() {
return <ClientOnly>{() => <Outlet />}</ClientOnly>;
}
十一、角色列表 UI
ts
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import {
Form,
useActionData,
useLoaderData,
useSubmit,
} from "@remix-run/react";
import { json } from "@remix-run/node";
import { useEffect, useState } from "react";
import * as _icons from "@ant-design/icons";
import { Button, Popconfirm, Space, Tree, message } from "antd";
import {
ModalForm,
ProForm,
ProFormText,
ProTable,
} from "@ant-design/pro-components";
import {
createRole,
deleteRoleById,
findRoleAll,
updateRoleById,
} from "~/db/role";
import { findAllMenu } from "~/db/menu";
const { DeleteOutlined, EditOutlined } = _icons;
export const action = async ({ request }: ActionFunctionArgs) => {
const method = request.method;
const dataJson = await request.json();
switch (method) {
case "POST":
try {
const { name, desc, menus } = dataJson;
const data = await createRole({ name, desc, menus: menus ?? [] });
return json({ code: 0, data, message: "ok" });
} catch (error) {
return json({ code: 1, data: [], message: error?.toString() });
}
case "PUT":
try {
const { id, desc, menus } = dataJson;
const data = await updateRoleById({
id: Number(id),
desc,
menus: menus ?? [],
});
return json({ code: 0, data, message: "ok" });
} catch (error) {
return json({ code: 1, data: [], message: error?.toString() });
}
case "DELETE":
const { id } = dataJson;
try {
const data = await deleteRoleById(Number(id));
return json({ code: 0, data, message: "ok" });
} catch (error) {
return json({ code: 1, data: [], message: error?.toString() });
}
case "default":
return json({ code: 0, data: [], message: "ok" });
}
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { roles, count } = await findRoleAll();
const { menu } = await findAllMenu();
const toTree = (flatData: any[]) => {
const map: any = {};
const tree: any[] = [];
// 将每个项的id作为key建立map以便快速查找
flatData.forEach((item) => {
map[item.id] = {
title: item.name,
key: item.name,
...item,
children: [],
};
});
// 遍历数据,将每个节点放到其父节点下面
flatData.forEach((item) => {
if (item.parentId) {
map[item.parentId].children.push(map[item.id]);
} else {
tree.push(map[item.id]);
}
});
return tree;
};
const _menu = toTree(menu);
return json({ dataSource: roles, total: count, menu: _menu, rawMenu: menu });
};
export default function RoleRoute() {
const submit = useSubmit();
const actionData = useActionData<typeof action>();
const {
rawMenu = [],
menu = [],
dataSource = [],
total = 0,
} = useLoaderData<typeof loader>();
// data
const [showCreateModel, setShowCreateModel] = useState(false);
const [record, setRecord] = useState<any>({});
const [showModModel, setShowModModel] = useState(false);
const onModRole = async ({
record,
values,
}: {
record: any;
values: { name?: string; desc: string; menus: any[] };
}) => {
await submit(
{ id: record.id, ...values },
{ method: "PUT", encType: "application/json" }
);
return true;
};
const onCreateRole = async (values: {
name: string;
desc?: string;
menus: any[];
}) => {
await submit(values, { method: "POST", encType: "application/json" });
};
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
},
{
title: "Name",
dataIndex: "name",
key: "name",
},
{
title: "Desc",
dataIndex: "desc",
key: "desc",
},
{
title: "操作",
render(_: any, record: any) {
return (
<Space size="large">
<Button
shape="circle"
icon={<EditOutlined />}
onClick={() => {
setRecord(record);
setShowModModel(true);
}}
></Button>
<Form>
<Popconfirm
title="删除角色"
description="确定要删除此角色?"
okText="Yes"
cancelText="No"
onConfirm={() => {
submit(
{ id: record.id },
{
method: "DELETE",
encType: "application/json",
}
);
}}
>
<Button shape="circle" icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Form>
</Space>
);
},
},
];
useEffect(() => {
if (actionData && actionData.code === 1) {
message.error(actionData.message);
}
}, [actionData]);
return (
<>
<ProTable
// search={false}
columns={columns}
dataSource={dataSource as any}
toolBarRender={() => [
<Button
type="primary"
key={"create"}
onClick={() => {
setShowCreateModel(!showCreateModel);
}}
>
创建角色
</Button>,
]}
pagination={{
total,
defaultPageSize: 9999,
pageSize: 99999,
// current: Number(searchParams.get("page") || 1),
// onChange(page, pageSize) {
// navigate(`/role?page=${page}&pageSize=${pageSize}`);
// },
}}
key="id"
/>
{showCreateModel && (
<CreateForm
open={showCreateModel}
setOpen={setShowCreateModel}
onCreateRole={onCreateRole}
menu={menu}
rawMenu={rawMenu}
/>
)}
{showModModel && (
<ModForm
record={record}
open={showModModel}
setOpen={setShowModModel}
onModRole={onModRole}
menu={menu}
rawMenu={rawMenu}
/>
)}
</>
);
}
function CreateForm(props: {
open: boolean;
setOpen: (show: boolean) => void;
onCreateRole: (value: { name: string, menus: any[] }) => any;
menu: any;
rawMenu: any;
}) {
const [checkedKeys, setCheckedKeys] = useState([]);
const [checkedNodes, setCheckedNodes] = useState([]);
const onCheck = (checkedKeys: any, info: any) => {
setCheckedKeys(checkedKeys);
setCheckedNodes(info.checkedNodes);
};
return (
<>
<ModalForm
title="创建角色"
open={props.open}
onFinish={(values: { name: string }) => {
props.onCreateRole({ ...values, menus: checkedNodes });
props.setOpen(false);
return Promise.resolve(true);
}}
modalProps={{
destroyOnClose: true,
onCancel: () => props.setOpen(false),
}}
>
<ProFormText name="name" label="角色名" />
<ProFormText name="desc" label="描述" />
<ProForm.Item name="menus" label="菜单">
<Tree
checkable
treeData={props.menu}
onCheck={onCheck}
checkedKeys={checkedKeys}
/>
</ProForm.Item>
</ModalForm>
</>
);
}
function ModForm(props: {
record: any;
open: boolean;
setOpen: (show: boolean) => void;
onModRole: ({
record,
values,
}: {
record: any;
values: { name?: string; desc: string; menus: any[] };
}) => any;
menu: any;
rawMenu: any;
}) {
const initKeys = props.record.menus?.map((m: any) => m.name)
const [checkedKeys, setCheckedKeys] = useState(initKeys || []);
const [checkedNodes, setCheckedNodes] = useState([]);
const onCheck = (checkedKeys: any, info: any) => {
setCheckedKeys(checkedKeys);
setCheckedNodes(info.checkedNodes);
};
return (
<>
<ModalForm
title="修改角色"
open={props.open}
initialValues={{
name: props.record.name,
desc: props.record.desc,
}}
onFinish={(values: { name?: string; desc: string, menus: any[] }) => {
values.menus = checkedNodes;
props.onModRole({
record: props.record,
values,
});
props.setOpen(false);
return Promise.resolve(true);
}}
modalProps={{
destroyOnClose: true,
onCancel: () => props.setOpen(false),
}}
>
<ProFormText name="name" label="角色名" disabled />
<ProFormText name="desc" label="描述" />
<ProForm.Item label="菜单" name="menus">
<Tree
checkable
treeData={props.menu}
onCheck={onCheck}
checkedKeys={checkedKeys}
/>
</ProForm.Item>
</ModalForm>
</>
);
}
十二、菜单列表 UI
jsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import {
Form,
useActionData,
useLoaderData,
useSubmit,
} from "@remix-run/react";
import { json } from "@remix-run/node";
import { useEffect, useState } from "react";
import * as _icons from "@ant-design/icons";
import { Button, Popconfirm, Space, Tag, TreeSelect, message } from "antd";
import {
ModalForm,
ProFormText,
ProTable,
ProFormSelect,
ProForm,
} from "@ant-design/pro-components";
import { deleteRoleById } from "~/db/role";
import { findAllMenu, createMenu, updateMenu } from "~/db/menu";
const { DeleteOutlined, EditOutlined } = _icons;
export const action = async ({ request }: ActionFunctionArgs) => {
const method = request.method;
const formData = await request.formData();
switch (method) {
case "POST":
try {
const name = formData.get("name") as string;
const path = formData.get("path") as string;
const icon = formData.get("icon") as string;
const component = formData.get("component") as string;
const parentId = formData.get("parentId") as string;
const roles = formData.get("roles");
const _data: any = {
name,
icon,
path,
component,
roles: roles,
};
if (parentId) {
_data.parentId = Number(parentId);
}
const data = await createMenu(_data);
return json({ code: 0, data, message: "ok" });
} catch (error) {
console.log(error);
return json({ code: 1, data: [], message: error?.toString() });
}
case "PUT":
try {
const id = formData.get("id") as string;
const desc = formData.get("desc") as string;
const data = await updateMenu(Number(id), {
desc,
});
return json({ code: 0, data, message: "ok" });
} catch (error) {
return json({ code: 1, data: [], message: error?.toString() });
}
case "DELETE":
const id = formData.get("id") as string;
try {
const data = await deleteRoleById(Number(id));
return json({ code: 0, data, message: "ok" });
} catch (error) {
return json({ code: 1, data: [], message: error?.toString() });
}
case "default":
return json({ code: 0, data: [], message: "ok" });
}
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { menu, count, roles } = await findAllMenu();
const toTree = (flatData: any[]) => {
const map: any = {};
const tree: any[] = [];
// 将每个项的id作为key建立map以便快速查找
flatData.forEach((item) => {
map[item.id] = {
key: item.name,
title: item.name,
value: item.id,
...item,
children: [],
};
});
// 遍历数据,将每个节点放到其父节点下面
flatData.forEach((item) => {
if (item.parentId) {
map[item.parentId].children.push(map[item.id]);
} else {
tree.push(map[item.id]);
}
});
return tree;
};
return json({ roles, dataSource: toTree(menu), total: count });
};
type IItem = {
name?: string;
desc: string;
path: string;
icon: string;
parentId: number;
roles: any[];
component: string;
};
export default function MenuRoute() {
const submit = useSubmit();
const { roles, dataSource = [], total = 0 } = useLoaderData<typeof loader>();
// data
const [showCreateModel, setShowCreateModel] = useState(false);
const [record, setRecord] = useState<any>({});
const [showModModel, setShowModModel] = useState(false);
const onModMenu = async ({
record,
values,
}: {
record: any;
values: IItem;
}) => {
await submit({ id: record.id, ...values }, { method: "PUT" });
return true;
};
const onCreateMenu = async (values: IItem) => {
if (!values.roles) {
values.roles = [];
}
await submit(values, { method: "POST" });
return true;
};
const columns = [
{
title: "Name",
dataIndex: "name",
key: "name",
},
{
title: "路径",
dataIndex: "path",
key: "path",
},
{
title: "图表",
dataIndex: "icon",
key: "icon",
},
{
title: "组件",
dataIndex: "component",
key: "component",
},
{
title: "角色",
dataIndex: "roles",
key: "roles",
render(_: any, record: any) {
return (
<Space>
{record.roles.map((r: any, index: number) => {
return <Tag key={index}>{r.desc}</Tag>;
})}
</Space>
);
},
},
{
title: "id/父id",
dataIndex: "parentId",
key: "parentId",
render(_: any, record: any) {
return <div>
<Tag>{record.id}/{record.parentId ?? '无'}</Tag>
</div>;
},
},
{
title: "操作",
render(_: any, record: any) {
return (
<Space size="large">
<Button
shape="circle"
icon={<EditOutlined />}
onClick={() => {
setRecord(record);
setShowModModel(true);
}}
></Button>
<Form>
<Popconfirm
title="删除角色"
description="确定要删除此角色?"
okText="Yes"
cancelText="No"
onConfirm={() => {
submit(
{ id: record.id },
{
method: "DELETE",
}
);
}}
>
<Button shape="circle" icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Form>
</Space>
);
},
},
];
useEffect(() => {
if (actionData && actionData.code === 1) {
message.error(actionData.message);
}
}, [actionData]);
return (
<>
<ProTable
search={false}
columns={columns}
dataSource={dataSource as any}
toolBarRender={() => [
<Button
type="primary"
key={"create"}
onClick={() => {
setShowCreateModel(!showCreateModel);
}}
>
创建菜单
</Button>,
]}
pagination={{
total,
defaultPageSize: 9999,
pageSize: 99999,
// pageSize: 10,
// current: Number(searchParams.get("page") || 1),
// onChange(page, pageSize) {
// navigate(`/role?page=${page}&pageSize=${pageSize}`);
// },
}}
key="id"
/>
{showCreateModel && (
<CreateForm
open={showCreateModel}
setOpen={setShowCreateModel}
onCreateMenu={onCreateMenu}
dataSource={dataSource}
roles={roles}
/>
)}
{showModModel && (
<ModForm
record={record}
open={showModModel}
setOpen={setShowModModel}
onModRole={onModMenu}
dataSource={dataSource}
roles={roles}
/>
)}
</>
);
}
function CreateForm(props: {
open: boolean;
setOpen: (show: boolean) => void;
onCreateMenu: (value: IItem) => any;
dataSource: any[];
roles?: any[];
}) {
return (
<>
<ModalForm
title="创建菜单项目"
open={props.open}
onFinish={(values: IItem) => {
props.onCreateMenu(values);
props.setOpen(false);
return Promise.resolve(true);
}}
modalProps={{
destroyOnClose: true,
onCancel: () => props.setOpen(false),
}}
>
<ProFormText name="name" label="菜单名" />
<ProFormText name="path" label="路径" />
<ProFormText name="icon" label="图标" />
<ProFormText name="component" label="组件" />
<ProFormSelect
name="roles"
label="角色"
mode="multiple"
options={props.roles?.map((item) => {
return { label: item.name, value: item.id };
})}
/>
<ProForm.Item name="parentId" label="父ID">
<TreeSelect
showSearch
style={{ width: "100%" }}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Please select"
allowClear
treeDefaultExpandAll
treeData={props.dataSource}
/>
</ProForm.Item>
</ModalForm>
</>
);
}
function ModForm(props: {
record: any;
open: boolean;
setOpen: (show: boolean) => void;
onModRole: ({ record, values }: { record: any; values: IItem }) => any;
dataSource: any[];
roles: any[];
}) {
return (
<>
<ModalForm
title="修改菜单"
open={props.open}
initialValues={{
name: props.record.name,
path: props.record.path,
icon: props.record.icon,
component: props.record.component,
roles: props.record.roles.map((r: any) => r.id),
parentId:
props.record.parentId === null ? undefined : props.record.parentId,
}}
onFinish={(values: IItem) => {
props.onModRole({ record: props.record, values });
props.setOpen(false);
return Promise.resolve(true);
}}
modalProps={{
destroyOnClose: true,
onCancel: () => props.setOpen(false),
}}
>
<ProFormText name="name" label="菜单名" disabled />
<ProFormText name="path" label="路径" />
<ProFormText name="icon" label="图标" />
<ProFormText name="component" label="组件" />
<ProFormSelect
name="roles"
label="角色"
mode="multiple"
options={props.roles?.map((item) => {
return { label: item.name, value: item.id };
})}
/>
<ProForm.Item
name="parentId"
label="父 ID"
rules={[
{
required: true,
message: "Please select nodes",
},
]}
>
{/* 使用 TreeSelect 在表单中进行选择 */}
<TreeSelect
treeData={props.dataSource}
placeholder="Please select"
treeDefaultExpandAll
treeCheckable
showCheckedStrategy={TreeSelect.SHOW_ALL}
style={{ width: "100%" }}
disabled={props.record.parentId === null ? true : false}
/>
</ProForm.Item>
</ModalForm>
</>
);
}
十三、录入数据与效果
十四、小结
本文以 Remix + Prisma + Ant Design 设计一个简单的角色和菜单系统。总体上有以下需要主题点:
- 技术选型(方向是快速的打通前后端)
- Prisma 模型设计(如何书写模型)
- 使用 Primsa Client 提供的 API 设计接口
- Remix 打通前后端数据与页面渲染
- 菜单数据结构的扁平化与树形结构转换
- UI 接口对接以及自测
- 后期可以考虑实现简单版本的 RABC 基于角色管理系统。