feat: 系统资源表

This commit is contained in:
2025-03-11 14:26:38 +08:00
parent e7bd916cae
commit ffb4c7691b
24 changed files with 761 additions and 532 deletions

View File

@@ -9416,7 +9416,7 @@
"version": "6.4.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.4.0.tgz", "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.4.0.tgz",
"integrity": "sha512-d4664ahzdL1QTTvmK1iI0JsrxWeJ6gn33qkYtnPg3mcn+naBLtXSgSPOe+X2vUgtgGwaAk3eiaj7gwKjjMAq+Q==", "integrity": "sha512-d4664ahzdL1QTTvmK1iI0JsrxWeJ6gn33qkYtnPg3mcn+naBLtXSgSPOe+X2vUgtgGwaAk3eiaj7gwKjjMAq+Q==",
"deprecated": "This was arguably a breaking change. Not in API, but more results can be returned. Upgrade to the next major when you are ready for that", "deprecated": "This was arguably a breaking change. Not in Api, but more results can be returned. Upgrade to the next major when you are ready for that",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.8", "@babel/runtime": "^7.23.8",

View File

@@ -1,9 +1,9 @@
import request from '@/utils/request'; import request from '@/utils/request';
export function grantPermission(id: string, menuIds: string[]) { export function grantPermission(id: string, menuIds: string[]) {
return request.patch('/permission/' + id, { menuIds }); return request.put(`/permission/roles/${id}/menus`, { menuIds });
} }
export function getRoleMenuIds(id: string) { export function getRoleMenuIds(id: string) {
return request.get('/permission/' + id + '/menu'); return request.get(`/permission/roles/${id}/menus`);
} }

View File

@@ -0,0 +1,21 @@
import request from '@/utils/request';
export function createResource(data) {
return request.post('/resource', data);
}
export function getResource() {
return request.get('/resource');
}
export function getTreeResource() {
return request.get('/resource/tree');
}
export function updateResource(data) {
return request.patch('/resource/' + data.id, data);
}
export function deleteResource(id: string) {
return request.delete('/resource/' + id);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import {
TableColumnProps,
Button,
Popconfirm,
Space,
} from '@arco-design/web-react';
export const enum TableOptions {
EDIT = 'edit',
DELETE = 'delete',
}
export function getColumns(callback: (type: TableOptions, record: any) => Promise<void>): TableColumnProps[] {
return [
{
title: '权限id',
dataIndex: 'id',
align: 'center',
},
{
title: '权限名称',
dataIndex: 'name',
align: 'center',
},
{
title: '权限字符',
dataIndex: 'permission',
align: 'center',
},
{
title: '排序',
dataIndex: 'sort',
align: 'center',
},
{
title: '操作',
dataIndex: 'operations',
align: 'center',
width: '300px',
render: (_, record) => (
<Space>
<Button size='small' onClick={() => callback(TableOptions.EDIT, record)} type="text"
status="default"></Button>
<Popconfirm
focusLock
title="删除数据"
content="确认删除该条数据吗?"
onOk={() => callback(TableOptions.DELETE, record)}
>
<Button size='small' type="text" status="danger"></Button>
</Popconfirm>
</Space>
)
}
]
}

View File

@@ -0,0 +1,101 @@
import React, {useEffect, useState} from 'react';
import {Modal, Form, Input, Message, Select, InputNumber} from '@arco-design/web-react';
import {createResource, getResource, updateResource} from '@/api/resource';
interface Props {
record: { [key: string]: any } | null,
visible: boolean,
handleConfirm: () => void,
handleCancel: () => void
}
export default function FormComponent(props: Props) {
const {visible, record, handleConfirm, handleCancel} = props;
const [confirmLoading, setConfirmLoading] = useState(false);
const onOk = async () => {
try {
setConfirmLoading(true);
const values = await form.validate();
if (record?.id) {
await updateResource(values);
} else {
await createResource(values);
}
Message.success(`${record?.id ? '更新' : '新增'}成功!`);
handleConfirm();
} catch (e) {
console.log(e);
} finally {
setConfirmLoading(false);
}
};
const [form] = Form.useForm();
const [menuList, setMenuList] = useState([]);
const fetchMenuList = async () => {
const {data} = await getResource();
const mergeMenu = [
{
id: null,
name: '无父级'
},
...data
];
setMenuList(mergeMenu);
};
useEffect(() => {
record && form.setFieldsValue({
...record
});
}, [record]);
useEffect(() => {
if (visible) {
fetchMenuList();
} else {
form.resetFields();
}
}, [visible]);
return (
<div>
<Modal
title={<span>{`${record?.id ? '更新' : '新增'}数据`}</span>}
visible={visible}
onOk={onOk}
onCancel={handleCancel}
confirmLoading={confirmLoading}
>
<Form form={form}>
<Form.Item hidden field="id">
<Input/>
</Form.Item>
<Form.Item label="父级" field="parentId">
<Select
placeholder="请选择父级"
allowClear
>
{menuList.map((option) => (
<Select.Option disabled={option.id === record?.id} key={option.id} value={option.id}>
{option.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label="权限名称" field="name" rules={[{required: true}]}>
<Input placeholder="请输入权限名称"/>
</Form.Item>
<Form.Item label="权限字符" field="permission" rules={[{required: true}]}>
<Input placeholder="请输入权限字符"/>
</Form.Item>
<Form.Item label="排序" field="sort">
<InputNumber placeholder="请输入排序"/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React, {useState} from 'react';
import {
Card,
Table,
Typography,
TableColumnProps,
Button,
Space,
Message
} from '@arco-design/web-react';
import {useQuery} from 'react-query';
import Form from './form';
import {deleteResource, getTreeResource} from '@/api/resource';
import {getColumns, TableOptions} from './constants';
import {useHistory} from "react-router";
const {Title} = Typography;
function ConfigManage() {
const reactQueryKey = useHistory().location.pathname;
const [visible, setVisible] = useState(false);
const [record, setRecord] = useState(null);
const onDelete = async ({id}) => {
await deleteResource(id);
Message.success('删除成功!');
await refetch();
};
const tableCallback = async (type: TableOptions, record: any) => {
switch (type) {
case TableOptions.EDIT:
showModal(record)
break;
case TableOptions.DELETE:
await onDelete(record);
break
}
}
const columns: TableColumnProps[] = getColumns(tableCallback)
const fetchData = async () => {
const {data} = await getTreeResource();
return data
};
const {
data,
isLoading,
isFetching,
refetch
} = useQuery(
[reactQueryKey], fetchData);
const showModal = (payload?) => {
payload && setRecord(payload);
setVisible(true);
};
const handleConfirm = async () => {
setRecord(null);
setVisible(false);
await refetch();
};
const handleCancel = () => {
setRecord(null);
setVisible(false);
};
return (
<>
<Card>
<Form
handleConfirm={handleConfirm}
handleCancel={handleCancel}
visible={visible}
record={record}
/>
<Title heading={6}></Title>
<Space style={{marginBottom: 12}}>
<Button type="primary" onClick={() => showModal()}></Button>
</Space>
<Table
rowKey="id"
loading={isLoading || isFetching}
columns={columns}
pagination={false}
data={data}
/>
</Card>
</>
);
}
export default ConfigManage;

View File

@@ -50,6 +50,10 @@ class Request {
return this.instance.patch(url, data); return this.instance.patch(url, data);
} }
put(url: string, data?: object) {
return this.instance.put(url, data);
}
delete(url: string) { delete(url: string) {
return this.instance.delete(url); return this.instance.delete(url);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
/*
Warnings:
- A unique constraint covering the columns `[permission]` on the table `sys_resource` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "sys_resource_name_type_key";
-- AlterTable
ALTER TABLE "sys_resource" ADD COLUMN "permission" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "sys_resource_permission_key" ON "sys_resource"("permission");

View File

@@ -107,15 +107,14 @@ model SysResource {
id String @id @default(cuid()) /// 主键id id String @id @default(cuid()) /// 主键id
createdAt DateTime @default(now()) @map("created_at") /// 创建时间 createdAt DateTime @default(now()) @map("created_at") /// 创建时间
updatedAt DateTime @updatedAt @map("updated_at") /// 修改时间 updatedAt DateTime @updatedAt @map("updated_at") /// 修改时间
name String name String /// 菜单名称或者接口名称
path String? /// 菜单或api路径 path String? @unique() /// 菜单或api路径
permission String? @unique() /// 权限字符
parentId String? @map("parent_id") parentId String? @map("parent_id")
sort Int @default(0) sort Int @default(0)
type Int /// 资源类型 0:菜单 1:api type Int /// 资源类型 0:菜单 1:api
sysResourcePermission SysResourcePermission[] sysResourcePermission SysResourcePermission[]
@@unique([name, type])
@@unique([path])
@@map("sys_resource") @@map("sys_resource")
} }

View File

@@ -3,7 +3,7 @@ const prisma = new PrismaClient();
enum PermissionType { enum PermissionType {
Menu, Menu,
API, Api,
} }
async function main() { async function main() {

View File

@@ -10,5 +10,5 @@ export enum RedisGroup {
export enum PermissionType { export enum PermissionType {
Menu, Menu,
API, Api,
} }

View File

@@ -1,4 +1,5 @@
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PermissionType } from '@/common/enum';
export class CreateMenuDto { export class CreateMenuDto {
@IsNotEmpty() @IsNotEmpty()
@@ -19,5 +20,5 @@ export class CreateMenuDto {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
type: number; type = PermissionType.Menu;
} }

View File

@@ -9,29 +9,21 @@ import { PermissionType } from '@/common/enum';
export class MenuService { export class MenuService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
findOne(params: { id?: string; excludeId?: string }) {
const { id, excludeId } = params;
return this.prisma.sysResource.findFirst({
where: {
type: PermissionType.Menu,
...(id ? { id } : {}),
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
}
findOneById(id: string) {
return this.prisma.sysResource.findUnique({ where: { id } });
}
async create(data: CreateMenuDto) { async create(data: CreateMenuDto) {
const doesExists = await this.prisma.sysResource.findUnique({
where: { path: data.path },
});
if (doesExists)
throw new HttpException('菜单路径不可重复!', HttpStatus.NOT_FOUND);
await this.prisma.$transaction(async (prisma) => { await this.prisma.$transaction(async (prisma) => {
const { id: resourceId } = await prisma.sysResource.create({ data }); const { id: resourceId } = await prisma.sysResource.create({ data });
// 创建菜单权限 // 创建菜单权限
const { id: permissionId } = await prisma.sysPermission.create({ const { id: permissionId } = await prisma.sysPermission.create({
data: {}, data: {},
}); });
// // 关联权限和菜单 // 关联权限和菜单
await prisma.sysResourcePermission.create({ await prisma.sysResourcePermission.create({
data: { resourceId, permissionId }, data: { resourceId, permissionId },
}); });
@@ -54,7 +46,10 @@ export class MenuService {
async getTreeMenu() { async getTreeMenu() {
const query: Prisma.SysResourceFindManyArgs = { const query: Prisma.SysResourceFindManyArgs = {
orderBy: [{ sort: 'desc' }, { createdAt: 'desc' }], orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }],
where: {
type: PermissionType.Menu,
},
}; };
const list = await this.prisma.sysResource.findMany(query); const list = await this.prisma.sysResource.findMany(query);
return this.generatorMenu(list); return this.generatorMenu(list);
@@ -62,20 +57,23 @@ export class MenuService {
findAll() { findAll() {
const query: Prisma.SysResourceFindManyArgs = { const query: Prisma.SysResourceFindManyArgs = {
select: { where: {
id: true, type: PermissionType.Menu,
name: true,
path: true,
parentId: true,
}, },
orderBy: [{ sort: 'desc' }, { createdAt: 'asc' }],
}; };
return this.prisma.sysResource.findMany(query); return this.prisma.sysResource.findMany(query);
} }
async update(id: string, data: UpdateMenuDto) { async update(id: string, data: UpdateMenuDto) {
const doesExists = await this.findOne({ id }); const doesExists = await this.prisma.sysResource.findUnique({
if (!doesExists) { where: { path: data.path },
throw new HttpException('菜单不存在!', HttpStatus.NOT_FOUND); });
console.log(doesExists);
if (doesExists) {
throw new HttpException('菜单路径不可重复!', HttpStatus.NOT_FOUND);
} }
if (data.parentId === id) { if (data.parentId === id) {
@@ -88,15 +86,10 @@ export class MenuService {
await this.prisma.sysResource.update({ data, where: { id } }); await this.prisma.sysResource.update({ data, where: { id } });
} }
async remove(resourceId: string) { async remove(id: string) {
const doesExists = await this.findOneById(resourceId);
if (!doesExists) {
throw new HttpException('菜单不存在!', HttpStatus.NOT_FOUND);
}
// 判断是否存在子菜单 // 判断是否存在子菜单
const doesExistsChildren = await this.prisma.sysResource.findFirst({ const doesExistsChildren = await this.prisma.sysResource.findFirst({
where: { parentId: resourceId }, where: { parentId: id, NOT: { id } },
}); });
if (doesExistsChildren) { if (doesExistsChildren) {
@@ -112,13 +105,13 @@ export class MenuService {
await prisma.sysPermission.deleteMany({ await prisma.sysPermission.deleteMany({
where: { where: {
sysResourcePermission: { sysResourcePermission: {
some: { resourceId }, some: { resourceId: id },
}, },
}, },
}); });
//step2 删除菜单数据 //step2 删除菜单数据
await prisma.sysResourcePermission.delete({ where: { id: resourceId } }); await prisma.sysResourcePermission.delete({ where: { id } });
}); });
} }
} }

View File

@@ -1,19 +1,19 @@
import { Controller, Body, Patch, Param, Get } from '@nestjs/common'; import { Controller, Body, Put, Param, Get } from '@nestjs/common';
import { PermissionService } from './permission.service'; import { PermissionService } from './permission.service';
import { GrantMenuDto } from '@/modules/system/permission/dto/grant-menu.dto'; import { GrantMenuDto } from './dto/grant-menu.dto';
@Controller() @Controller()
export class PermissionController { export class PermissionController {
constructor(private readonly permissionService: PermissionService) {} constructor(private readonly permissionService: PermissionService) {}
@Get(':id/menu') @Get('/roles/:roleId/menus')
getMenu(@Param('id') id: string) { getRoleMenus(@Param('roleId') roleId: string) {
return this.permissionService.getMenu(id); return this.permissionService.getRoleMenus(roleId);
} }
@Patch(':id') @Put('/roles/:roleId/menus')
grantMenu(@Param('id') id: string, @Body() dto: GrantMenuDto) { assignMenus(@Param('roleId') roleId: string, @Body() dto: GrantMenuDto) {
return this.permissionService.grantMenu(id, dto); return this.permissionService.assignMenus(roleId, dto);
} }
} }

View File

@@ -6,7 +6,7 @@ import { PrismaService } from '@/prisma/prisma.service';
export class PermissionService { export class PermissionService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async getMenu(id: string) { async getRoleMenus(roleId: string) {
const menuIds = await this.prisma.sysResourcePermission.findMany({ const menuIds = await this.prisma.sysResourcePermission.findMany({
select: { select: {
resourceId: true, resourceId: true,
@@ -14,7 +14,7 @@ export class PermissionService {
where: { where: {
permission: { permission: {
sysRolePermission: { sysRolePermission: {
some: { roleId: id }, some: { roleId },
}, },
}, },
}, },
@@ -23,11 +23,11 @@ export class PermissionService {
return menuIds.map((item) => item.resourceId); return menuIds.map((item) => item.resourceId);
} }
async grantMenu(id: string, { menuIds }: GrantMenuDto) { async assignMenus(roleId: string, { menuIds }: GrantMenuDto) {
await this.prisma.$transaction(async (prisma) => { await this.prisma.$transaction(async (prisma) => {
// 删除角色对应的权限 // 删除角色对应的权限
await prisma.sysRolePermission.deleteMany({ await prisma.sysRolePermission.deleteMany({
where: { roleId: id }, where: { roleId },
}); });
// 通过菜单id 找出所有权限id // 通过菜单id 找出所有权限id
const rolePermIds = await prisma.sysResourcePermission.findMany({ const rolePermIds = await prisma.sysResourcePermission.findMany({
@@ -37,7 +37,7 @@ export class PermissionService {
// 根据权限id 关联菜单和权限 // 根据权限id 关联菜单和权限
if (rolePermIds.length > 0) { if (rolePermIds.length > 0) {
await prisma.sysRolePermission.createMany({ await prisma.sysRolePermission.createMany({
data: rolePermIds.map((item) => ({ ...item, roleId: id })), data: rolePermIds.map((item) => ({ ...item, roleId })),
}); });
} }
}); });

View File

@@ -0,0 +1,24 @@
import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PermissionType } from '@/common/enum';
export class CreateResourceDto {
@IsNotEmpty()
@IsString()
name: string;
@IsNotEmpty()
@IsString()
permission: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsInt()
sort?: number;
@IsOptional()
@IsInt()
type = PermissionType.Api;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateResourceDto } from './create-resource.dto';
export class UpdateResourceDto extends PartialType(CreateResourceDto) {}

View File

@@ -0,0 +1,42 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { ResourceService } from './resource.service';
import { CreateResourceDto } from './dto/create-resource.dto';
import { UpdateResourceDto } from './dto/update-resource.dto';
@Controller()
export class ResourceController {
constructor(private readonly resourceService: ResourceService) {}
@Post()
create(@Body() data: CreateResourceDto) {
return this.resourceService.create(data);
}
@Get('tree')
getTreeData() {
return this.resourceService.getTreeData();
}
@Get()
findAll() {
return this.resourceService.findAll();
}
@Patch(':id')
update(@Param('id') id: string, @Body() data: UpdateResourceDto) {
return this.resourceService.update(id, data);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.resourceService.remove(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ResourceService } from './resource.service';
import { ResourceController } from './resource.controller';
@Module({
controllers: [ResourceController],
providers: [ResourceService],
})
export class ResourceModule {}

View File

@@ -0,0 +1,113 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateResourceDto } from './dto/create-resource.dto';
import { UpdateResourceDto } from './dto/update-resource.dto';
import { PrismaService } from '@/prisma/prisma.service';
import { PermissionType } from '@/common/enum';
import { Prisma } from '@prisma/client';
@Injectable()
export class ResourceService {
constructor(private readonly prisma: PrismaService) {}
async create(data: CreateResourceDto) {
const doesExists = await this.prisma.sysResource.findUnique({
where: { path: data.permission },
});
if (doesExists)
throw new HttpException('权限字符不可重复!', HttpStatus.NOT_FOUND);
await this.prisma.$transaction(async (prisma) => {
const { id: resourceId } = await prisma.sysResource.create({ data });
// 创建资源权限
const { id: permissionId } = await prisma.sysPermission.create({
data: {},
});
//关联权限和api
await prisma.sysResourcePermission.create({
data: { resourceId, permissionId },
});
});
}
generatorTreeData(data, parentId = '-') {
const list = [];
for (const item of data) {
if ((item.parentId || '-') === parentId) {
const children = this.generatorTreeData(data, item.id);
if (children.length) {
item.children = children;
}
list.push(item);
}
}
return list;
}
async getTreeData() {
const query: Prisma.SysResourceFindManyArgs = {
orderBy: [{ sort: 'desc' }, { createdAt: 'desc' }],
where: {
type: PermissionType.Api,
},
};
const list = await this.prisma.sysResource.findMany(query);
return this.generatorTreeData(list);
}
findAll() {
const query: Prisma.SysResourceFindManyArgs = {
where: {
type: PermissionType.Api,
},
};
return this.prisma.sysResource.findMany(query);
}
async update(id: string, data: UpdateResourceDto) {
const doesExists = await this.prisma.sysResource.findUnique({
where: { path: data.permission, NOT: { id } },
});
if (doesExists)
throw new HttpException('权限字符不可重复!', HttpStatus.NOT_FOUND);
if (data.parentId === id) {
throw new HttpException(
'父级id不可与自身id相同',
HttpStatus.BAD_REQUEST,
);
}
await this.prisma.sysResource.update({ data, where: { id } });
}
async remove(id: string) {
// 判断是否存在子资源
const doesExistsChildren = await this.prisma.sysResource.findFirst({
where: { parentId: id },
});
if (doesExistsChildren) {
throw new HttpException(
'删除失败:请确保当前资源下不存在子资源!',
HttpStatus.FORBIDDEN,
);
}
await this.prisma.$transaction(async (prisma) => {
// step1 通过关联表删除权限表数据 这一步必须在前
// 因为schema文件中设置了onDelete: Cascade 删除资源会删除关联表数据 则无法找出对应权限
await prisma.sysPermission.deleteMany({
where: {
sysResourcePermission: {
some: { resourceId: id },
},
},
});
//step2 删除资源数据
await prisma.sysResourcePermission.delete({ where: { id } });
});
}
}

View File

@@ -8,6 +8,7 @@ import { MenuModule } from './menu/menu.module';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { PermissionModule } from './permission/permission.module'; import { PermissionModule } from './permission/permission.module';
import { TestModule } from './test/test.module'; import { TestModule } from './test/test.module';
import { ResourceModule } from './resource/resource.module';
@Module({ @Module({
imports: [ imports: [
@@ -44,6 +45,10 @@ import { TestModule } from './test/test.module';
path: 'permission', path: 'permission',
module: PermissionModule, module: PermissionModule,
}, },
{
path: 'resource',
module: ResourceModule,
},
{ {
path: 'test', path: 'test',
module: TestModule, module: TestModule,
@@ -59,6 +64,7 @@ import { TestModule } from './test/test.module';
ConfigModule, ConfigModule,
PermissionModule, PermissionModule,
TestModule, TestModule,
ResourceModule,
], ],
}) })
export class SystemModule {} export class SystemModule {}

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class TestService { export class TestService {
findAll() { findAll() {
return `This action returns all test`; throw new HttpException('用户不存在!', HttpStatus.NOT_FOUND);
} }
} }

View File

@@ -141,9 +141,13 @@ export class UserService {
}, },
}, },
}; };
} else {
query.where = {
type: PermissionType.Menu,
};
} }
const menus = await this.prisma.sysResource.findMany(); const menus = await this.prisma.sysResource.findMany(query);
return { return {
...user, ...user,