feat: 添加菜单管理

master
luoer 2023-10-25 18:45:13 +08:00
parent 2902d63702
commit 3c0286fead
67 changed files with 412 additions and 90 deletions

2
.env
View File

@ -12,7 +12,7 @@ SERVER_HOST = 0.0.0.0
# 服务域名
SERVER_URL = http://127.0.0.1
# 接口地址
SERVER_OPENAPI_URL = /api
SERVER_OPENAPI_URL = /api/openapi
# ========================================================================================
# 数据库配置

View File

@ -38,6 +38,11 @@
- .dockerignore 配置哪些文件应该被忽略掉
- .gitea/workflows/depoy.yaml 流水线任务的配置文件,语法上与 Github Actions 一致
## 笔记
- createUserDto与User分开
- 涉及关系时,先用 service 查出有效关系避免存储不存在的关联ID
## 最后
如果你在使用过程中遇到问题,欢迎在 Issue 中提问。

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

View File

@ -1,20 +1,21 @@
import { Module } from '@nestjs/common';
import { PostModule } from '@/content/post';
import { RoleModule } from '@/modules/role';
import { RoleModule } from '@/system/role';
import { UploadModule } from '@/storage/upload';
import { PermissionModule } from '@/modules/permission';
import { PermissionModule } from '@/system/permission';
import { ConfigModule } from '@/config';
import { LoggerModule } from '@/common/logger';
import { ServeStaticModule } from '@/common/static';
import { DatabaseModule } from '@/database';
import { ValidationModule } from '@/common/validation';
import { AuthModule } from '@/modules/auth';
import { UserModule } from '@/modules/user';
import { AuthModule } from '@/system/auth';
import { UserModule } from '@/system/user';
import { ResponseModule } from '@/common/response';
import { SerializationModule } from '@/common/serialization';
import { CacheModule } from '@/storage/cache';
import { ScanModule } from '@/utils/scan.module';
import { ContentModule } from '@/content/content.module';
import { MenuModule } from './system/menu';
@Module({
imports: [
@ -63,7 +64,6 @@ import { ContentModule } from '@/content/content.module';
*/
DatabaseModule,
/**
*
*/
@ -81,7 +81,6 @@ import { ContentModule } from '@/content/content.module';
*/
PermissionModule,
/**
*
*/
@ -90,7 +89,8 @@ import { ContentModule } from '@/content/content.module';
*
*/
PostModule,
ContentModule
ContentModule,
MenuModule,
],
})
export class AppModule {}

View File

@ -10,12 +10,11 @@ export class LoggerInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
const { method, url } = context.switchToHttp().getRequest<Request>();
const now = Date.now();
return next.handle().pipe(
tap(() => {
const handle = () => {
const ms = Date.now() - now;
const scope = [context.getClass().name, context.getHandler().name].join('.');
this.logger.log(`${method} ${url}(${ms} ms) +1`, scope);
}),
);
};
return next.handle().pipe(tap({ next: handle, error: handle }));
}
}

View File

@ -39,4 +39,12 @@ export class PaginationDto {
@Min(0)
@Transform(({ value }) => Number(value))
size?: number;
/**
*
* @example '2020-02-02 02:02:02'
*/
@IsOptional()
@IsString()
createdFrom?: string;
}

View File

@ -1,5 +1,5 @@
export class ValidationError extends Error {
constructor(public messages: string[]) {
super('参数错误');
constructor(message: string) {
super(message);
}
}

View File

@ -10,7 +10,6 @@ export class ValidationExecptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const code = ResponseCode.PARAM_ERROR;
const message = exception.message;
const data = exception.messages;
response.status(HttpStatus.BAD_REQUEST).json({ code, message, data });
response.status(HttpStatus.BAD_REQUEST).json({ code, message, data: undefined });
}
}

View File

@ -27,14 +27,14 @@ export const validationPipeFactory = () => {
transform: true,
whitelist: true,
exceptionFactory: (errors) => {
const messages: string[] = [];
let message = '参数错误';
for (const error of errors) {
const { property, constraints } = error;
for (const [key, val] of Object.entries(constraints)) {
messages.push(map[key] ? `参数(${property})${map[key]}` : val);
message = map[key] ? `参数(${property})${map[key]}` : val;
}
}
return new ValidationError(messages);
return new ValidationError(message);
},
});
};

View File

@ -1,5 +1,5 @@
import { BaseEntity } from '@/database';
import { User } from 'src/modules/user';
import { User } from '@/system/user';
import { Column, Entity, ManyToMany } from 'typeorm';
@Entity()

View File

@ -1,3 +1,4 @@
import { ApiHideProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@ -47,6 +48,7 @@ export class BaseEntity {
* @example "2022-01-03 12:12:12"
*/
@Exclude()
@ApiHideProperty()
@DeleteDateColumn({ comment: '删除时间', select: false })
deleteddAt: Date;
@ -55,6 +57,7 @@ export class BaseEntity {
* @example 1
*/
@Exclude()
@ApiHideProperty()
@Column({ comment: '删除人', nullable: true, select: false })
deletedBy: string;
}

View File

@ -29,7 +29,7 @@ export class UploadService extends BaseService {
extension: extname(file.originalname),
});
await this.uploadRepository.save(upload);
return upload.id;
return upload;
}
findAll() {

View File

@ -9,8 +9,9 @@ export class AuthService {
constructor(private userService: UserService, private jwtService: JwtService) {}
async signIn(authUserDto: AuthUserDto) {
const user = await this.userService.findByUsername(authUserDto.username);
const user = await this.userService.findOne({ username: authUserDto.username });
if (!user) {
console.log(user, authUserDto);
throw new UnauthorizedException('用户名不存在');
}
if (user.password !== authUserDto.password) {

View File

@ -1,4 +1,4 @@
import { User } from '@/modules/user';
import { User } from '@/system/user';
import { OmitType } from '@nestjs/swagger';
export class LoginedUserVo extends OmitType(User, ['password', 'id'] as const) {

View File

@ -0,0 +1,49 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
export class CreateMenuDto {
/**
* ID
* @example 0
*/
@IsOptional()
@IsInt()
parentId: number;
/**
*
* @example '首页'
*/
@IsString()
name: string;
/**
*
* @example 'home'
*/
@IsString()
code: string;
/**
* 访
* @example '/home'
*/
@IsOptional()
@IsString()
path: string;
/**
*
* @example 'icon-park-outline-home'
*/
@IsOptional()
@IsString()
icon: string;
/**
*
* @example 1
* @description 1 2 3
*/
@IsInt()
type: number;
}

View File

@ -0,0 +1,15 @@
import { PaginationDto } from '@/common/response';
import { IntersectionType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class FindMenuDto extends IntersectionType(PaginationDto) {
/**
*
* @example false
*/
@IsOptional()
@IsBoolean()
@Transform(({ value }) => Boolean(value))
tree?: boolean;
}

View File

@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
import { CreateMenuDto } from './create-menu.dto';
export class UpdateMenuDto extends PartialType(CreateMenuDto) {}

View File

@ -0,0 +1,59 @@
import { BaseEntity } from '@/database';
import { Role } from '@/system/role';
import { ApiHideProperty } from '@nestjs/swagger';
import { Column, Entity, JoinColumn, ManyToMany, Tree, TreeChildren, TreeParent } from 'typeorm';
@Entity({ orderBy: { id: 'DESC' } })
@Tree('materialized-path')
export class Menu extends BaseEntity {
/**
*
* @example '首页'
*/
@Column({ comment: '菜单名称' })
name: string;
/**
*
* @example 'home'
*/
@Column({ comment: '标识' })
code: string;
/**
* 访
* @example '/home'
*/
@Column({ comment: '访问路径', nullable: true })
path: string;
/**
*
* @example 'icon-park-outline-home'
*/
@Column({ comment: '图标类名', nullable: true })
icon: string;
/**
*
* @example 1
* @description 1 2 3
*/
@Column({ comment: '类型(1: 目录, 2: 页面, 3: 按钮)' })
type: number;
@ApiHideProperty()
@TreeParent()
parent: Menu;
@Column({ comment: '父级ID', nullable: true, default: 0 })
parentId: number;
@ApiHideProperty()
@TreeChildren()
children: Menu[];
@ApiHideProperty()
@ManyToMany(() => Role, (role) => role.menus)
roles: Role[];
}

4
src/system/menu/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './entities/menu.entity';
export * from './menu.controller';
export * from './menu.module';
export * from './menu.service';

View File

@ -0,0 +1,49 @@
import { BaseController } from '@/common/base';
import { Respond, RespondType } from '@/common/response';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateMenuDto } from './dto/create-menu.dto';
import { FindMenuDto } from './dto/find-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
import { Menu } from './entities/menu.entity';
import { MenuService } from './menu.service';
@ApiTags('menu')
@Controller('menus')
export class MenuController extends BaseController {
constructor(private menuService: MenuService) {
super();
}
@Post()
@ApiOperation({ description: '新增菜单', operationId: 'addMenu' })
addMenu(@Body() createMenuDto: CreateMenuDto) {
return this.menuService.create(createMenuDto);
}
@Get()
@Respond(RespondType.PAGINATION)
@ApiOkResponse({ isArray: true, type: Menu })
@ApiOperation({ description: '查询菜单', operationId: 'getMenus' })
getMenus(@Query() query: FindMenuDto) {
return this.menuService.findMany(query);
}
@Get(':id')
@ApiOperation({ description: '获取菜单', operationId: 'getMenu' })
getMenu(@Param('id') id: number): Promise<Menu> {
return this.menuService.findOne(id);
}
@Patch(':id')
@ApiOperation({ description: '更新菜单', operationId: 'setMenu' })
updateMenu(@Param('id') id: number, @Body() updateMenuDto: UpdateMenuDto) {
return this.menuService.update(+id, updateMenuDto);
}
@Delete(':id')
@ApiOperation({ description: '删除菜单', operationId: 'delMenu' })
delMenu(id: number) {
return this.menuService.remove(+id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Menu } from './entities/menu.entity';
import { MenuController } from './menu.controller';
import { MenuService } from './menu.service';
@Module({
imports: [TypeOrmModule.forFeature([Menu])],
controllers: [MenuController],
providers: [MenuService],
exports: [MenuService],
})
export class MenuModule {}

View File

@ -0,0 +1,70 @@
import { BaseService } from '@/common/base';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Like, Repository } from 'typeorm';
import { CreateMenuDto } from './dto/create-menu.dto';
import { FindMenuDto } from './dto/find-menu.dto';
import { UpdateMenuDto } from './dto/update-menu.dto';
import { Menu } from './entities/menu.entity';
import { listToTree } from '@/utils/list-tree';
@Injectable()
export class MenuService extends BaseService {
constructor(@InjectRepository(Menu) private menuRepository: Repository<Menu>) {
super();
}
/**
*
*/
async create(createMenuDto: CreateMenuDto) {
const menu = this.menuRepository.create(createMenuDto);
const { parentId } = createMenuDto;
if (parentId) {
const parent = await this.menuRepository.findOne({ where: { id: parentId } });
menu.parent = parent;
}
await this.menuRepository.save(menu);
return menu;
}
/**
* /
*/
async findMany(findMenudto: FindMenuDto) {
const { page, size, tree } = findMenudto;
const { skip, take } = this.formatPagination(page, size, true);
let [data, total] = await this.menuRepository.findAndCount({ skip, take });
if (tree) {
data = listToTree(data);
}
return [data, total];
}
/**
* ID
*/
findOne(idOrOptions: number | Partial<Menu>) {
const where = typeof idOrOptions === 'number' ? { id: idOrOptions } : (idOrOptions as any);
return this.menuRepository.findOne({ where });
}
/**
* ID
*/
async update(id: number, updateMenuDto: UpdateMenuDto) {
const { parentId } = updateMenuDto;
if (parentId !== undefined) {
const parent = await this.menuRepository.findOne({ where: { id: parentId } });
updateMenuDto.parentId = parent?.id;
}
return this.menuRepository.update(id, updateMenuDto);
}
/**
* ID()
*/
remove(id: number) {
return this.menuRepository.softDelete(id);
}
}

View File

@ -1,5 +1,5 @@
import { BaseEntity } from '@/database';
import { Role } from '@/modules/role/entities/role.entity';
import { Role } from '@/system/role/entities/role.entity';
import { Column, Entity, ManyToMany } from 'typeorm';
enum PermissionType {

View File

@ -1,7 +1,7 @@
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException, forwardRef } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSION_KEY } from './permission.decorator';
import { UserService } from '@/modules/user';
import { UserService } from '@/system/user';
@Injectable()
export class PermissionGuard implements CanActivate {

View File

@ -1,4 +1,4 @@
import { Permission } from '@/modules/permission/entities/permission.entity';
import { Permission } from '@/system/permission/entities/permission.entity';
import { IsInt, IsOptional, IsString } from 'class-validator';
export class CreateRoleDto {

View File

@ -0,0 +1,12 @@
import { PaginationDto } from '@/common/response';
import { IsBoolean, IsOptional } from 'class-validator';
export class FindMenuDto extends PaginationDto {
/**
*
* @example false
*/
@IsOptional()
@IsBoolean()
tree?: boolean;
}

View File

@ -1,6 +1,8 @@
import { BaseEntity } from '@/database';
import { Permission } from '@/modules/permission/entities/permission.entity';
import { User } from 'src/modules/user';
import { Menu } from '@/system/menu';
import { Permission } from '@/system/permission/entities/permission.entity';
import { User } from '@/system/user';
import { ApiHideProperty } from '@nestjs/swagger';
import { Column, Entity, JoinTable, ManyToMany, RelationId } from 'typeorm';
@Entity()
@ -26,25 +28,20 @@ export class Role extends BaseEntity {
@Column({ comment: '角色描述', nullable: true })
description: string;
/**
*
* @example [1, 2, 3]
*/
@ApiHideProperty()
@ManyToMany(() => User, (user) => user.roles)
user: User;
@ApiHideProperty()
@JoinTable()
@ManyToMany(() => Permission, (permission) => permission.roles)
permissions: Permission[];
/**
* ID
* @example [1, 2, 3]
*/
@ApiHideProperty()
@RelationId('permissions')
permissionIds: number[];
/**
*
* @example [1, 2, 3]
*/
@ManyToMany(() => User, (user) => user.roles)
user: User;
@ApiHideProperty()
@ManyToMany(() => Menu, (menu) => menu.roles)
menus: Menu[];
}

View File

@ -8,5 +8,6 @@ import { RoleService } from './role.service';
imports: [TypeOrmModule.forFeature([Role])],
controllers: [RoleController],
providers: [RoleService],
exports: [RoleService],
})
export class RoleModule {}

View File

@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { Role } from './entities/role.entity';
@ -39,6 +39,15 @@ export class RoleService {
return this.roleRepository.update(id, updateRoleDto);
}
/**
* ID
* @param ids ID
* @returns
*/
findByIds(ids: number[]) {
return this.roleRepository.find({ where: { id: In(ids) } });
}
remove(id: number) {
return `This action removes a #${id} role`;
}

View File

@ -7,12 +7,7 @@ export class CreateUserDto {
*/
@IsString()
username: string;
/**
*
* @example '绝弹'
*/
@IsString()
nickname: string;
/**
*
* @example 'password'
@ -20,16 +15,33 @@ export class CreateUserDto {
@IsOptional()
@IsString()
password?: string;
/**
* ID
*
* @example '绝弹'
*/
@IsString()
nickname: string;
/**
*
* @example '这个人很懒,什么也没有留下!'
*/
@IsString()
@IsOptional()
description?: string;
/**
*
* @example 1
*/
@IsOptional()
@IsString()
avatarId?: number;
avatar?: string;
/**
* ID
* @example [1, 2, 3]
* @example [1]
*/
@IsOptional()
@IsInt({ each: true })

View File

@ -3,6 +3,10 @@ import { CreateUserDto } from './create-user.dto';
import { IsNumber } from 'class-validator';
export class UpdateUserDto extends PartialType(CreateUserDto) {
/**
* ID
* @example 1
*/
@IsNumber()
id: number;
}

View File

@ -1,6 +1,6 @@
import { BaseEntity } from '@/database';
import { Post } from '@/content/post';
import { Role } from '@/modules/role';
import { Role } from '@/system/role';
import { ApiHideProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
import { Column, Entity, JoinTable, ManyToMany, RelationId } from 'typeorm';
@ -14,6 +14,14 @@ export class User extends BaseEntity {
@Column({ length: 48 })
username: string;
/**
*
* @example 'password'
*/
@Exclude()
@Column({ length: 64 })
password: string;
/**
*
* @example '绝弹'
@ -29,20 +37,12 @@ export class User extends BaseEntity {
description: string;
/**
* (URL)
* @example './assets/222421415123.png '
*
* @example '/upload/assets/222421415123.png '
*/
@Column({ nullable: true })
avatar: string;
/**
*
* @example 'password'
*/
@Exclude()
@Column({ length: 64 })
password: string;
/**
*
* @example 'example@mail.com'

View File

@ -1,7 +1,7 @@
import { BaseController } from '@/common/base';
import { Respond, RespondType } from '@/common/response';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateUserDto } from './dto/create-user.dto';
import { FindUserDto } from './dto/find-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@ -15,44 +15,34 @@ export class UserController extends BaseController {
super();
}
/**
*
*/
@Post()
@ApiOperation({ description: '添加用户', operationId: 'addUser' })
addUser(@Body() createUserDto: CreateUserDto) {
return this.userService.create(createUserDto);
}
/**
* /
*/
@Get()
@Respond(RespondType.PAGINATION)
@ApiOkResponse({ type: User, isArray: true })
@ApiOperation({ description: '分页获取用户', operationId: 'getUsers' })
async getUsers(@Query() query: FindUserDto) {
return this.userService.findMany(query);
}
/**
* ID
*/
@Get(':id')
@ApiOperation({ description: '获取用户', operationId: 'getUser' })
getUser(@Param('id') id: number): Promise<User> {
return this.userService.findOne(id);
}
/**
* ID
*/
@Patch(':id')
@ApiOperation({ description: '更新用户', operationId: 'setUser' })
updateUser(@Param('id') id: number, @Body() updateUserDto: UpdateUserDto) {
return this.userService.update(+id, updateUserDto);
}
/**
* ID
*/
@Delete(':id')
@ApiOperation({ description: '删除用户', operationId: 'delUser' })
delUser(@Param('id') id: number) {
return this.userService.remove(+id);
}

View File

@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { RoleModule } from '../role';
@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User]), RoleModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],

View File

@ -1,15 +1,16 @@
import { BaseService } from '@/common/base';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Like, Repository } from 'typeorm';
import { Like, Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { FindUserDto } from './dto/find-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { RoleService } from '../role';
@Injectable()
export class UserService extends BaseService {
constructor(@InjectRepository(User) private userRepository: Repository<User>) {
constructor(@InjectRepository(User) private userRepository: Repository<User>, private roleService: RoleService) {
super();
}
@ -18,10 +19,7 @@ export class UserService extends BaseService {
*/
async create(createUserDto: CreateUserDto) {
const user = this.userRepository.create(createUserDto);
if (createUserDto.roleIds) {
user.roles = createUserDto.roleIds.map((id) => ({ id })) as any;
delete createUserDto.roleIds;
}
user.roles = await this.roleService.findByIds(user.roleIds ?? []);
await this.userRepository.save(user);
return user.id;
}
@ -31,7 +29,7 @@ export class UserService extends BaseService {
*/
async findMany(findUserdto: FindUserDto) {
const { nickname: _nickname } = findUserdto;
const nickname = _nickname && Like(`%${_nickname}%`);
const nickname = _nickname ? Like(`%${_nickname}%`) : undefined;
const { skip, take } = this.paginizate(findUserdto, { full: true });
return this.userRepository.findAndCount({ skip, take, where: { nickname } });
}

19
src/utils/list-tree.ts Normal file
View File

@ -0,0 +1,19 @@
/**
*
* @param list
* @returns
*/
export function listToTree(list: any[]) {
const listMap = new Map();
for (const item of list) {
listMap.set(item.id, item);
}
return list.filter((item) => {
const parent = listMap.get(item.parentId);
if (parent) {
parent.children = parent.children ?? [];
parent.children.push(item);
}
return !item.parentId;
});
}