前两节理清了需求、画了原型图,并且确定了技术选型、设计了数据库,划分了接口和模块。
这节正式开始写代码。
首先创建个 nest 项目:
nest new meeting_room_booking_system_backend在 docker desktop 里把 mysql 的容器跑起来:
详细过程可以看 mysql 的第一篇。
安装 typeorm 相关的包:
npm install --save @nestjs/typeorm typeorm mysql2在 AppModule 引入 TypeOrmModule:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "meeting_room_booking_system",
synchronize: true,
logging: true,
entities: [],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}在 mysql workbench 里创建这个 database
CREATE DATABASE meeting_room_booking_system DEFAULT CHARACTER SET utf8mb4;刷新可以看到这个 database
用户模块涉及到这些表:
创建下它们的 entity:
先在 nest-cli.json 里添加 generateOptions,设置 spec 为 false
这样生成代码的时候不会生成测试代码,和 nest g xxx --no-spec 效果一样
生成 user 模块:
nest g resource user确实没有生成测试代码。
然后添加个 src/user/entities 目录,新建 3 个实体 User、Role、Permission。
按照上节的表格来创建就好:
用户表:
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | 用户ID |
| username | VARCHAR(50) | 用户名 |
| password | VARCHAR(50) | 密码 |
| nick_name | VARCHAR(50) | 昵称 |
| VARCHAR(50) | 邮箱 | |
| head_pic | VARCHAR(100) | 头像 |
| phone_number | VARCHAR(20) | 手机号 |
| is_frozen | BOOLEAN | 是否被冻结 |
| is_admin | BOOLEAN | 是否是管理员 |
| create_time | DATETIME | 创建时间 |
| update_time | DATETIME | 更新时间 |
| 角色表 roles |
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | ID |
| name | VARCHAR(20) | 角色名 |
权限表 permissions
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | ID |
| code | VARCHAR(20) | 权限代码 |
| description | VARCHAR(100) | 权限描述 |
用户-角色中间表 user_roles
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | ID |
| user_id | INT | 用户 ID |
| role_id | INT | 角色 ID |
角色-权限中间表 role_permissions
| 字段名 | 数据类型 | 描述 |
|---|---|---|
| id | INT | ID |
| role_id | INT | 角色 ID |
| permission_id | INT | 权限 ID |
也就是这样:
import { Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Role } from "./role.entity";
@Entity({
name: 'users'
})
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 50,
comment: '用户名'
})
username: string;
@Column({
length: 50,
comment: '密码'
})
password: string;
@Column({
name: 'nick_name',
length: 50,
comment: '昵称'
})
nickName: string;
@Column({
comment: '邮箱',
length: 50
})
email: string;
@Column({
comment: '头像',
length: 100,
nullable: true
})
headPic: string;
@Column({
comment: '手机号',
length: 20,
nullable: true
})
phoneNumber: string;
@Column({
comment: '是否冻结',
default: false
})
isFrozen: boolean;
@Column({
comment: '是否是管理员',
default: false
})
isAdmin: boolean;
@CreateDateColumn()
createTime: Date;
@UpdateDateColumn()
updateTime: Date;
@ManyToMany(() => Role)
@JoinTable({
name: 'user_roles'
})
roles: Role[]
}
import { Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import { Permission } from "./permission.entity";
@Entity({
name: 'roles'
})
export class Role {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 20,
comment: '角色名'
})
name: string;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions'
})
permissions: Permission[]
}import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity({
name: 'permissions'
})
export class Permission {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 20,
comment: '权限代码'
})
code: string;
@Column({
length: 100,
comment: '权限描述'
})
description: string;
}在 entities 里引入下:
然后把项目跑起来:
npm run start:dev正好是 5 条建表 sql:
去数据库看下:
users 表没啥问题:
要注意的是 mysql 里没有 boolean 类型,使用 TINYINT 实现的,用 1、0 存储 true、false。
typeorm 会自动把它映射成 true、false。
然后是 roles 表:
permissions 表:
还有两个中间表:
没啥问题,外键也都是对的。
接下来就可以实现接口了。
上节列了 user 模块有这些接口:
| 接口路径 | 请求方式 | 描述 |
|---|---|---|
| /user/login | POST | 普通用户登录 |
| /user/register | POST | 普通用户注册 |
| /user/update | POST | 普通用户个人信息修改 |
| /user/update_password | POST | 普通用户修改密码 |
| /user/admin/login | POST | 管理员登录 |
| /user/admin/update_password | POST | 管理员修改密码 |
| /user/admin/update | POST | 管理员个人信息修改 |
| /user/list | GET | 用户列表 |
| /user/freeze | GET | 冻结用户 |
分别来实现下。
先实现下注册:
在 UserController 增加一个 post 接口:
@Post('register')
register(@Body() registerUser: RegisterUserDto) {
console.log(registerUser);
return "success"
}dto 是封装 body 里的请求参数的,根据界面上要填的信息,创建 RegisterUserDto:
export class RegisterUserDto {
username: string;
nickName: string;
password: string;
email: string;
captcha: string;
}
在 postman 里调用下试试:
{
"username": "guang",
"nickName": "神说要有光",
"password": "123456",
"email": "xxxx@xx.com",
"captcha": "abc123"
}服务端也接收到了 body 里的数据,并创建了对应的 dto 对象:
然后加一下 ValidationPipe,来对请求体做校验。
安装用到的包:
npm install --save class-validator class-transformer全局启用 ValidationPipe:
然后加一下校验规则:
import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
export class RegisterUserDto {
@IsNotEmpty({
message: "用户名不能为空"
})
username: string;
@IsNotEmpty({
message: '昵称不能为空'
})
nickName: string;
@IsNotEmpty({
message: '密码不能为空'
})
@MinLength(6, {
message: '密码不能少于 6 位'
})
password: string;
@IsNotEmpty({
message: '邮箱不能为空'
})
@IsEmail({}, {
message: '不是合法的邮箱格式'
})
email: string;
@IsNotEmpty({
message: '验证码不能为空'
})
captcha: string;
}测试下:
没啥问题。
然后实现注册的逻辑。
在 userService 里添加 register 方法:
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { md5 } from 'src/utils';
import { Repository } from 'typeorm';
import { RegisterUserDto } from './dto/register-user.dto';
import { User } from './entities/user.entity';
@Injectable()
export class UserService {
private logger = new Logger();
@InjectRepository(User)
private userRepository: Repository<User>;
async register(user: RegisterUserDto) {
}
}创建 logger 对象,注入 Repository<User>。
这里注入 Repository 需要在 UserModule 里引入下 TypeOrmModule.forFeature
注册的逻辑是这样的:
需要先封装个 redis 模块。
nest g module redis
nest g service redis安装 redis 的包:
npm install --save redis确保 redis 的 docker 容器是启动的:
添加连接 redis 的 provider
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { createClient } from 'redis';
@Global()
@Module({
providers: [
RedisService,
{
provide: 'REDIS_CLIENT',
async useFactory() {
const client = createClient({
socket: {
host: 'localhost',
port: 6379
},
database: 1
});
await client.connect();
return client;
}
}
],
exports: [RedisService]
})
export class RedisModule {}这里用 @Global() 把它声明为全局模块,这样只需要在 AppModule 里引入,别的模块不用引入也可以注入 RedisService 了。
database 指定为 1,因为之前都是用的默认的 0
redis 的 database 就是一个命名空间的概念:
把存储的 key-value 的数据放到不同命名空间下,避免冲突。
然后写下 RedisService
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLIENT')
private redisClient: RedisClientType;
async get(key: string) {
return await this.redisClient.get(key);
}
async set(key: string, value: string | number, ttl?: number) {
await this.redisClient.set(key, value);
if(ttl) {
await this.redisClient.expire(key, ttl);
}
}
}注入 redisClient,实现 get、set 方法,set 方法支持指定过期时间。
然后回过头来继续实现 register 方法。
@Inject(RedisService)
private redisService: RedisService;
async register(user: RegisterUserDto) {
const captcha = await this.redisService.get(`captcha_${user.email}`);
if(!captcha) {
throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
}
if(user.captcha !== captcha) {
throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
}
const foundUser = await this.userRepository.findOneBy({
username: user.username
});
if(foundUser) {
throw new HttpException('用户已存在', HttpStatus.BAD_REQUEST);
}
const newUser = new User();
newUser.username = user.username;
newUser.password = md5(user.password);
newUser.email = user.email;
newUser.nickName = user.nickName;
try {
await this.userRepository.save(newUser);
return '注册成功';
} catch(e) {
this.logger.error(e, UserService);
return '注册失败';
}
}根据流程图,很容易写出注册的实现逻辑。
这里的 md5 方法放在 src/utils.ts 里,用 node 内置的 crypto 包实现。
import * as crypto from 'crypto';
export function md5(str) {
const hash = crypto.createHash('md5');
hash.update(str);
return hash.digest('hex');
}在 controller 里调用下:
@Post('register')
async register(@Body() registerUser: RegisterUserDto) {
return await this.userService.register(registerUser);
}然后 postman 里测试下:
因为还没实现发送邮箱验证码的逻辑,这里手动在 redis 添加一个 key:
测试下:
带上错误的验证码,返回验证码不正确;
带上正确的验证码,返回注册成功:
这时可以在数据库里看到这条记录:
这就代表注册成功了。
然后来实现发送邮箱验证码的功能。
封装个 email 模块:
nest g resource email安装发送邮件用的包:
npm install nodemailer --save在 EmailService 里实现 sendMail 方法
import { Injectable } from '@nestjs/common';
import { createTransport, Transporter} from 'nodemailer';
@Injectable()
export class EmailService {
transporter: Transporter
constructor() {
this.transporter = createTransport({
host: "smtp.qq.com",
port: 587,
secure: false,
auth: {
user: '你的邮箱地址',
pass: '你的授权码'
},
});
}
async sendMail({ to, subject, html }) {
await this.transporter.sendMail({
from: {
name: '会议室预订系统',
address: '你的邮箱地址'
},
to,
subject,
html
});
}
}把邮箱地址和授权码改成你自己的。
这里用的 qq 邮箱,你也可以换成别的邮箱,填写对应的 smtp 服务的域名和端口就好了。
或者你也可以买专门发邮件的服务。
比如阿里云的邮件推送服务:
线上要买这种邮件推送服务来发邮件的,但这里还是用 nodemailer 自己发邮件。
把 EmailModule 声明为全局的,并且导出 EmailService
然后在 UserController 里添加一个 get 接口:
@Inject(EmailService)
private emailService: EmailService;
@Inject(RedisService)
private redisService: RedisService;
@Get('register-captcha')
async captcha(@Query('address') address: string) {
const code = Math.random().toString().slice(2,8);
await this.redisService.set(`captcha_${address}`, code, 5 * 60);
await this.emailService.sendMail({
to: address,
subject: '注册验证码',
html: `<p>你的注册验证码是 ${code}</p>`
});
return '发送成功';
}测试下:
邮件发送成功:
redis 里也保存了邮箱地址对应的验证码:
这样整个注册的流程就完成了。
这节创建了 nest 项目,并引入了 typeorm 和 redis。
创建了 User、Role、Permission 的 entity,通过 typeorm 的自动建表功能,在数据库创建了对应的 3 个表和 2 个中间表。
引入了 nodemailer 来发邮件,如果是线上可以买阿里云或者其他平台的邮件推送服务。
实现了 /user/register 和 /user/register-captcha 两个接口。
/user/register-captcha 会向邮箱地址发送一个包含验证码的邮件,并在 redis 里存一份。
/user/register 会根据邮箱地址查询 redis 中的验证码,验证通过会把用户信息保存到表中。
这样,注册功能就完成了。
上节实现了用户注册的功能,这节继续实现登录认证鉴权。
在那之前,把配置抽离一下,现在的 mysql、redis、nodemailer 等配置都直接写在代码里,不好维护。
安装 config 的包:
npm install --save @nestjs/config在 AppModule 引入下:
ConfigModule.forRoot({
isGlobal: true,
envFilePath: 'src/.env'
})设置为全局模块,指定 env 文件的位置。
然后在 src 下添加一个 .env 文件:
redis_server_host=localhost
redis_server_port=6379然后在 RedisModule 里注入 ConfigService 来读取配置:
跑一下试试:
npm run start:dev正确读取到了 .env 的配置。
有的同学会问为什么 .env 不放在根目录呢?
因为根目录下的配置文件不会自动复制到 dist 目录。
在 nest-cli.json 里加一下 assets 的配置:
assets 是指定 build 时复制的文件,watchAssets 是在 assets 变动之后自动重新复制。
把 dist 删掉,跑下 npm run build。
你会发现 .env 确实复制过去了:
然后把 .env 移动到根目录,再删掉 dist,重新跑 npm run build。
并没有自动复制 .env 过去。
如果你就是想把 .env 放在根目录,那可以手动加一下复制逻辑:
这样再跑 npm run build,就会把 .env 复制过去了:
为了用到 nest cli 的复制 assets 的功能,还是把它放在 src 下。
在 .env 里添加 redis、mysql、nodemailer 和 nest 服务的配置:
# redis 相关配置
redis_server_host=localhost
redis_server_port=6379
redis_server_db=1
# nodemailer 相关配置
nodemailer_host=smtp.qq.com
nodemailer_port=587
nodemailer_auth_user=你的邮箱
nodemailer_auth_pass=你的授权码
# mysql 相关配置
mysql_server_host=localhost
mysql_server_port=3306
mysql_server_username=root
mysql_server_password=guang
mysql_server_database=meeting_room_booking_system
# nest 服务配置
nest_server_port=3000然后把代码里的这些地方都改成读配置的方式:
const configService = app.get(ConfigService);
await app.listen(configService.get('nest_server_port'));constructor(private configService: ConfigService) {
this.transporter = createTransport({
host: this.configService.get('nodemailer_host'),
port: this.configService.get('nodemailer_port'),
secure: false,
auth: {
user: this.configService.get('nodemailer_auth_user'),
pass: this.configService.get('nodemailer_auth_pass')
},
});
}{
provide: 'REDIS_CLIENT',
async useFactory(configService: ConfigService) {
const client = createClient({
socket: {
host: configService.get('redis_server_host'),
port: configService.get('redis_server_port')
},
database: configService.get('redis_server_db')
});
await client.connect();
return client;
},TypeOrmModule.forRootAsync({
useFactory(configService: ConfigService) {
return {
type: "mysql",
host: configService.get('mysql_server_host'),
port: configService.get('mysql_server_port'),
username: configService.get('mysql_server_username'),
password: configService.get('mysql_server_password'),
database: configService.get('mysql_server_database'),
synchronize: true,
logging: true,
entities: [
User, Role, Permission
],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'caching_sha2_password',
}
}
},
inject: [ConfigService]
})然后测试下现在的功能是否正常:
没啥问题。
这样配置抽取就成功了。
接下来继续实现登录功能。
先初始化用户、角色、权限的数据。
在 UserService 注入 Role 和 Permission 的 Repository:
写个初始化数据的方法:
async initData() {
const user1 = new User();
user1.username = "zhangsan";
user1.password = md5("111111");
user1.email = "xxx@xx.com";
user1.isAdmin = true;
user1.nickName = '张三';
user1.phoneNumber = '13233323333';
const user2 = new User();
user2.username = 'lisi';
user2.password = md5("222222");
user2.email = "yy@yy.com";
user2.nickName = '李四';
const role1 = new Role();
role1.name = '管理员';
const role2 = new Role();
role2.name = '普通用户';
const permission1 = new Permission();
permission1.code = 'ccc';
permission1.description = '访问 ccc 接口';
const permission2 = new Permission();
permission2.code = 'ddd';
permission2.description = '访问 ddd 接口';
user1.roles = [role1];
user2.roles = [role2];
role1.permissions = [permission1, permission2];
role2.permissions = [permission1];
await this.permissionRepository.save([permission1, permission2]);
await this.roleRepository.save([role1, role2]);
await this.userRepository.save([user1, user2]);
}张三是管理员,有 ccc 和 ddd 接口访问权限。
李四是普通用户,只有 ccc 接口的访问权限。
然后在 UserController 添加个 handler:
@Get("init-data")
async initData() {
await this.userService.initData();
return 'done';
}浏览器访问下:
服务端打印了包裹在事务里的 insert 语句:
在 mysql workbench 里看下:
users 表:
roles 表: permissions 表: user_roles 表: role_permissions 表: 都插入成功了。
有的同学问,在生产环境也这么初始化数据么?
那肯定不会啊,开发环境可以用代码来初始化数据,然后把数据导出为 sql,生产环境可以用这个 sql 文件来初始化。
在 mysql workbench 里这样导出数据:
你可以修改生成的 sql 文件位置:
然后来实现登录:
后台管理的登录和用户端的登录是分开的:
但实现起来差别不大,来实现下。
在 UserController 添加两个接口:
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
console.log(loginUser);
return 'success';
}
@Post('admin/login')
async adminLogin(@Body() loginUser: LoginUserDto) {
console.log(loginUser);
return 'success';
}添加 src/user/dto/login-user.dto.ts:
import { IsNotEmpty } from "class-validator";
export class LoginUserDto {
@IsNotEmpty({
message: "用户名不能为空"
})
username: string;
@IsNotEmpty({
message: '密码不能为空'
})
password: string;
}测试下:
然后在 UserService 实现 login 方法:
async login(loginUserDto: LoginUserDto, isAdmin: boolean) {
const user = await this.userRepository.findOne({
where: {
username: loginUserDto.username,
isAdmin
},
relations: [ 'roles', 'roles.permissions']
});
if(!user) {
throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST);
}
if(user.password !== md5(loginUserDto.password)) {
throw new HttpException('密码错误', HttpStatus.BAD_REQUEST);
}
return user;
}根据 username 和 isAdmin 查询数据库,设置级联查询 roles 和 roles.permissions。
如果没有找到用户,返回 400 响应提示用户不存在。
如果密码不对,返回 400 响应,提示密码错误。
在 UserController 里调用下:
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
const user = await this.userService.login(loginUser, false);
return 'success';
}
@Post('admin/login')
async adminLogin(@Body() loginUser: LoginUserDto) {
const user = await this.userService.login(loginUser, true);
return 'success';
}创建个 vo (view object)对象来封装返回的数据。
dto 是接收参数的,vo 是封装返回的数据的,entity 是和数据库表对应的。
创建 src/user/vo/login-user.vo.ts
interface UserInfo {
id: number;
username: string;
nickName: string;
email: string;
headPic: string;
phoneNumber: string;
isFrozen: boolean;
isAdmin: boolean;
createTime: number;
roles: string[];
permissions: string[]
}
export class LoginUserVo {
userInfo: UserInfo;
accessToken: string;
refreshToken: string;
}
返回用户信息和两个 token。
在 UserService 的 login 方法里封装返回的数据:
permissions 是所有 roles 的 permissions 的合并,要去下重。
const vo = new LoginUserVo();
vo.userInfo = {
id: user.id,
username: user.username,
nickName: user.nickName,
email: user.email,
phoneNumber: user.phoneNumber,
headPic: user.headPic,
createTime: user.createTime.getTime(),
isFrozen: user.isFrozen,
isAdmin: user.isAdmin,
roles: user.roles.map(item => item.name),
permissions: user.roles.reduce((arr, item) => {
item.permissions.forEach(permission => {
if(arr.indexOf(permission) === -1) {
arr.push(permission);
}
})
return arr;
}, [])
}在 UserController 里返回 vo:
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
const vo = await this.userService.login(loginUser, false);
return vo;
}
@Post('admin/login')
async adminLogin(@Body() loginUser: LoginUserDto) {
const vo = await this.userService.login(loginUser, true);
return vo;
}测试下:
/user/login
/user/admin/login
然后引入 jwt 模块:
npm install --save @nestjs/jwt在 AppModule 里引入:
JwtModule.registerAsync({
global: true,
useFactory(configService: ConfigService) {
return {
secret: configService.get('jwt_secret'),
signOptions: {
expiresIn: '30m' // 默认 30 分钟
}
}
},
inject: [ConfigService]
}),这里的密钥放到 .env 里配置:
# jwt 配置
jwt_secret=guang
jwt_access_token_expires_time=30m
jwt_refresh_token_expires_time=7d这里 jwt 过期时间的语法可以看这个文档。
然后登录认证通过之后返回 access_token 和 refresh_token:
@Inject(JwtService)
private jwtService: JwtService;
@Inject(ConfigService)
private configService: ConfigService;
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
const vo = await this.userService.login(loginUser, false);
vo.accessToken = this.jwtService.sign({
userId: vo.userInfo.id,
username: vo.userInfo.username,
roles: vo.userInfo.roles,
permissions: vo.userInfo.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
vo.refreshToken = this.jwtService.sign({
userId: vo.userInfo.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return vo;
}另一个接口同样的处理。
然后再增加一个 refresh_token 的接口用来刷新 token:
@Get('refresh')
async refresh(@Query('refreshToken') refreshToken: string) {
try {
const data = this.jwtService.verify(refreshToken);
const user = await this.userService.findUserById(data.userId, false);
const access_token = this.jwtService.sign({
userId: user.id,
username: user.username,
roles: user.roles,
permissions: user.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
const refresh_token = this.jwtService.sign({
userId: user.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return {
access_token,
refresh_token
}
} catch(e) {
throw new UnauthorizedException('token 已失效,请重新登录');
}
}在 UserService 实现这个 findUserById 方法:
async findUserById(userId: number, isAdmin: boolean) {
const user = await this.userRepository.findOne({
where: {
id: userId,
isAdmin
},
relations: [ 'roles', 'roles.permissions']
});
return {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
roles: user.roles.map(item => item.name),
permissions: user.roles.reduce((arr, item) => {
item.permissions.forEach(permission => {
if(arr.indexOf(permission) === -1) {
arr.push(permission);
}
})
return arr;
}, [])
}
}同样的方式再实现一个后台管理的 refresh 接口:
@Get('admin/refresh')
async adminRefresh(@Query('refreshToken') refreshToken: string) {
try {
const data = this.jwtService.verify(refreshToken);
const user = await this.userService.findUserById(data.userId, true);
const access_token = this.jwtService.sign({
userId: user.id,
username: user.username,
roles: user.roles,
permissions: user.permissions
}, {
expiresIn: this.configService.get('jwt_access_token_expires_time') || '30m'
});
const refresh_token = this.jwtService.sign({
userId: user.id
}, {
expiresIn: this.configService.get('jwt_refresh_token_expires_time') || '7d'
});
return {
access_token,
refresh_token
}
} catch(e) {
throw new UnauthorizedException('token 已失效,请重新登录');
}
}整体测试下:
POST /user/login
GET /user/refresh
POST /user/admin/login
GET /user/admin/refresh
都没啥问题。
其实现在 controller 里有很多重复代码,比如多次生成 jwt 的代码,这个大家可以自己重构下,抽一个方法出来。
然后加上 LoginGuard 和 PermissionGuard 来做鉴权:
nest g guard login --flat --no-spec
nest g guard permission --flat --no-specLoginGuard 的实现代码如下:
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { Permission } from './user/entities/permission.entity';
interface JwtUserData {
userId: number;
username: string;
roles: string[];
permissions: Permission[]
}
declare module 'express' {
interface Request {
user: JwtUserData
}
}
@Injectable()
export class LoginGuard implements CanActivate {
@Inject()
private reflector: Reflector;
@Inject(JwtService)
private jwtService: JwtService;
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request: Request = context.switchToHttp().getRequest();
const requireLogin = this.reflector.getAllAndOverride('require-login', [
context.getClass(),
context.getHandler()
]);
if(!requireLogin) {
return true;
}
const authorization = request.headers.authorization;
if(!authorization) {
throw new UnauthorizedException('用户未登录');
}
try{
const token = authorization.split(' ')[1];
const data = this.jwtService.verify<JwtUserData>(token);
request.user = {
userId: data.userId,
username: data.username,
roles: data.roles,
permissions: data.permissions
}
return true;
} catch(e) {
throw new UnauthorizedException('token 失效,请重新登录');
}
}
}用 reflector 从目标 controller 和 handler 上拿到 require-login 的 metadata。
如果没有 metadata,就是不需要登录,返回 true 放行。
否则从 authorization 的 header 取出 jwt 来,把用户信息设置到 request,然后放行。
如果 jwt 无效,返回 401 响应,提示 token 失效,请重新登录。
(这段代码看不懂的话,回头去看下 RBAC 权限控制那节)
然后全局启用这个 Guard,在 AppModule 里添加这个 provider:
{
provide: APP_GUARD,
useClass: LoginGuard
}然后在 AppController 添加 aaa、bbb 两个接口:
@Get('aaa')
aaaa() {
return 'aaa';
}
@Get('bbb')
bbb() {
return 'bbb';
}访问下:
然后在 aaa 加上 require-login 的 metadata
@Get('aaa')
@SetMetadata('require-login', true)
aaaa() {
return 'aaa';
}会提示用户未登录:
而 bbb 还是可以直接访问的:
在 postman 里登录下,拿到 access_token:
添加到 authorization 的 header 里,就可以访问了:
然后继续实现 PermissionGuard:
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
@Injectable()
export class PermissionGuard implements CanActivate {
@Inject(Reflector)
private reflector: Reflector;
async canActivate(
context: ExecutionContext,
): Promise<boolean> {
const request: Request = context.switchToHttp().getRequest();
if(!request.user) {
return true;
}
const permissions = request.user.permissions;
const requiredPermissions = this.reflector.getAllAndOverride<string[]>('require-permission', [
context.getClass(),
context.getHandler()
])
if(!requiredPermissions) {
return true;
}
for(let i = 0; i < requiredPermissions.length; i++) {
const curPermission = requiredPermissions[i];
const found = permissions.find(item => item.code === curPermission);
if(!found) {
throw new UnauthorizedException('您没有访问该接口的权限');
}
}
return true;
}
}同样是用 reflector 取出 handler 或者 controller 上的 require-permission 的 metadata。
如果没有,就是不需要权限,直接放行,返回 true。
对于需要的每个权限,检查下用户是否拥有,没有的话就返回 401,提示没权限。
否则就放行,返回 true。
同样是全局启用这个 PermissionGuard
{
provide: APP_GUARD,
useClass: PermissionGuard
}然后在 aaa 方法上声明需要的权限:
@Get('aaa')
@SetMetadata('require-login', true)
@SetMetadata('require-permission', ['ddd'])
aaaa() {
return 'aaa';
}测试下:
访问 /user/admin/login 登录 zhangsan 账号,拿到 accessToken
带上 accessToken,是可以访问 aaa 接口的,因为 zhangsan 有这个权限
访问 /user/login 登录 lisi 账号,拿到 accessToken
带上 accessToken,就不能访问 aaa 接口的,因为 lisi 没有这个权限
这样,接口鉴权就完成了。
最后把这两个 @SetMetadata 封装成自定义装饰器
新建 src/custom.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const RequireLogin = () => SetMetadata('require-login', true);
export const RequirePermission = (...permissions: string[]) => SetMetadata('require-permission', permissions);然后给接口添加鉴权就可以这样写了:
此外还可以创建个自定义参数装饰器:
import { SetMetadata } from "@nestjs/common";
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from "express";
export const RequireLogin = () => SetMetadata('require-login', true);
export const RequirePermission = (...permissions: string[]) => SetMetadata('require-permission', permissions);
export const UserInfo = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
if(!request.user) {
return null;
}
return data ? request.user[data] : request.user;
},
)UserInfo 装饰器是用来取 user 信息传入 handler 的。
传入属性名的时候,返回对应的属性值,否则返回全部的 user 信息。
在 aaa 方法里测试下:
拿到 zhangsan 的 accessToken 来访问下 aaa 接口:
可以看到,服务端从 request 取出了 user 的值传入了 handler:
而这个 request.user 是在 LoginGuard 里设置的。
这样,就完成了鉴权和拿到用户信息的功能。
这节实现了配置抽离、基于 jwt 登录、鉴权功能。
配置抽离使用 @nestjs/config 包,把配置放在 src 下的 .env 文件里,然后代码里从 configService 读取配置。
这样可以配置 nest-cli.json 的 assets 和 watchAssets 来自动把 env 文件复制到 dist 目录下。
使用代码做的数据初始化,线上要删掉这个接口,用导出的 sql 文件来初始化。
登录成功之后,返回 access_token、refresh_token 还有用户信息、roles、permissions 等。
并支持使用 refreshToken 来刷新 token。
之后使用 LoginGuard、PermissionGuard 来做登录和权限的鉴权,根据 handler 上的 metadata 来确定要不要做鉴权、需要什么权限。
还封装了几个自定义装饰器,用于方便的设置 metadata,从 request 取数据注入 handler。
至此,注册、登录、鉴权、配置抽离等功能就完成了。
用户管理模块实现了登录、注册、认证鉴权,还剩下一些列表、更新等接口:
这节把修改密码、修改信息的接口写完。
在那之前,先加一个修改响应内容的拦截器。把响应的格式改成 {code、message、data} 这种。
nest g interceptor format-response --flat使用 map 操作符来修改响应:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { map, Observable } from 'rxjs';
@Injectable()
export class FormatResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const response = context.switchToHttp().getResponse<Response>();
return next.handle().pipe(map((data) => {
return {
code: response.statusCode,
message: 'success',
data
}
}));
}
}
全局启用它:
这时候访问 http://localhost:3000 ,可以看到响应格式变了:
这里我用了一个 JSON Formatter 的 chrome 插件来格式化。
然后再试下其它接口:
响应格式确实变了:
抛出的异常还是由内置的 Exception Filter 来处理,返回响应:
然后再加一个接口访问记录的 interceptor:
nest g interceptor invoke-record --flat记录下访问的 ip、user agent、请求的 controller、method,接口耗时、响应内容,当前登录用户等信息。
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { Request } from 'express';
import { Observable, tap } from 'rxjs';
@Injectable()
export class InvokeRecordInterceptor implements NestInterceptor {
private readonly logger = new Logger(InvokeRecordInterceptor.name);
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const userAgent = request.headers['user-agent'];
const { ip, method, path } = request;
this.logger.debug(
`${method} ${path} ${ip} ${userAgent}: ${
context.getClass().name
} ${
context.getHandler().name
} invoked...`,
);
this.logger.debug(`user: ${request.user?.userId}, ${request.user?.username}`);
const now = Date.now();
return next.handle().pipe(
tap((res) => {
this.logger.debug(
`${method} ${path} ${ip} ${userAgent}: ${response.statusCode}: ${Date.now() - now}ms`,
);
this.logger.debug(`Response: ${JSON.stringify(res)}`);
}),
);
}
}
全局启用这个 interceptor:
试一下:
访问管理员的登录的接口。
可以看到控制台打印了请求的路径、请求方法、controller、handler 等信息:
这里因为是本地访问,所以是 ::1 的 ip,相当于 localhost。
user 因为当前还没有,所以是 undefined
响应之后打印了接口耗时以及接口返回的数据:
拿着 access_token 放在 header 来访问 /aaa 接口试下:
可以看到,请求之前打印了访问的 controller、handler、user等信息:
响应之后打印了接口耗时和响应内容等信息:
为什么 interceptor 里能拿到 user 信息呢?
因为这是在 LoginGuard 里从 jwt 取出来放到 request.user 的,而 Guard 在 interceptor 之前调用:
然后实现普通用户和管理员修改密码、修改信息的接口。
涉及到这 4 个页面:
分别是 /user/update_password 和 /user/admin/update_password、/user/update、/user/admin/update
不过在修改信息之前,需要先实现查询用户信息的接口,用来回显数据。
在 UserController 添加一个 /user/info 接口:
@Get('info')
@RequireLogin()
async info(@UserInfo('userId') userId: number) {
return await this.userService.findUserDetailById(userId);
}加上 @RequireLogin 装饰,这样 LoginGuard 就会对 /user/info 的请求做登录检查,并把 user 信息放到 request 上。
然后用前面封装的自定义装饰器 @UserInfo 从 request.user 取 userId 注入 handler。
在 UserService 实现 findUserDetailById 方法:
async findUserDetailById(userId: number) {
const user = await this.userRepository.findOne({
where: {
id: userId
}
});
return user;
}测试下:
登录用户端,拿到 access_token。
然后加到 header 里访问 /user/info
成功拿到了 user 的信息。
但这些信息并不是全都需要,比如密码就不需要返回。
创建个 vo 来封装返回的结果:
创建 /user/vo/user-info.vo.ts
export class UserDetailVo {
id: number;
username: string;
nickName: string;
email: string;
headPic: string;
phoneNumber: string;
isFrozen: boolean;
createTime: Date;
}然后把 controller 里的返回值封装成 vo:
@Get('info')
@RequireLogin()
async info(@UserInfo('userId') userId: number) {
const user = await this.userService.findUserDetailById(userId);
const vo = new UserDetailVo();
vo.id = user.id;
vo.email = user.email;
vo.username = user.username;
vo.headPic = user.headPic;
vo.phoneNumber = user.phoneNumber;
vo.nickName = user.nickName;
vo.createTime = user.createTime;
vo.isFrozen = user.isFrozen;
return vo;
}测试下:
现在返回的内容就是合理的了。
然后实现修改密码的接口:
管理员和用户修改密码的页面是一样的,就用一个接口就好了。
@Post(['update_password', 'admin/update_password'])
@RequireLogin()
async updatePassword(@UserInfo('userId') userId: number, @Body() passwordDto: UpdateUserPasswordDto) {
console.log(passwordDto);
return 'success';
}@Post 写个数组,就代表数组里的这两个路由是同一个 handler 处理。
这个接口同样是需要登录的,所以加上 @RequireLogin 的装饰器。
用 @UserInfo 从 request.user 取 userId,其余的通过 dto 传。
创建 src/user/dto/update-user-password.dto.ts
import { IsEmail, IsNotEmpty, MinLength } from "class-validator";
export class UpdateUserPasswordDto {
@IsNotEmpty({
message: '密码不能为空'
})
@MinLength(6, {
message: '密码不能少于 6 位'
})
password: string;
@IsNotEmpty({
message: '邮箱不能为空'
})
@IsEmail({}, {
message: '不是合法的邮箱格式'
})
email: string;
@IsNotEmpty({
message: '验证码不能为空'
})
captcha: string;
}需要传的是邮箱、密码、验证码。
确认密码在前端和密码对比就行,不需要传到后端。
测试下:
登录用户端,拿到 access_token,然后访问 /user/update_password 和 /user/admin/update_password 接口:
都能正常接收到数据。
然后实现下具体的更新密码的逻辑:
先查询 redis 中有没有邮箱对应的验证码,没有的话就返回验证码不存在或者不正确。
查到的话再调用 Repository 去更新数据库中的用户密码。
也就是这样的:
在 UserController 里调用 UserService 的方法:
@Post(['update_password', 'admin/update_password'])
@RequireLogin()
async updatePassword(@UserInfo('userId') userId: number, @Body() passwordDto: UpdateUserPasswordDto) {
return await this.userService.updatePassword(userId, passwordDto);
}UserService 实现具体的逻辑:
async updatePassword(userId: number, passwordDto: UpdateUserPasswordDto) {
const captcha = await this.redisService.get(`update_password_captcha_${passwordDto.email}`);
if(!captcha) {
throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
}
if(passwordDto.captcha !== captcha) {
throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
}
const foundUser = await this.userRepository.findOneBy({
id: userId
});
foundUser.password = md5(passwordDto.password);
try {
await this.userRepository.save(foundUser);
return '密码修改成功';
} catch(e) {
this.logger.error(e, UserService);
return '密码修改失败';
}
}先查询 redis 中有相对应的验证码,检查通过之后根据 id 查询用户信息,修改密码之后 save。
测试下:
登录 lisi 账号,拿到 access_token
数据库中可以查到 lisi 的邮箱:
带上 access_token 访问更新密码接口:
先手动去 redis 里添加 update_password_captcha_yy@yy.com 的 key,值为 123123(注意,现在用的是 redis 的 db1)
半小时过期。
然后再试下:
修改成功。
数据库里看不到具体的密码,但也能看出确实变了:
之前是这样的:
然后登录下试试就知道了:
用之前的密码登录,会提示密码错误。
换成新密码就好了:
然后再加上这个发送邮箱验证码的接口:
@Get('update_password/captcha')
async updatePasswordCaptcha(@Query('address') address: string) {
const code = Math.random().toString().slice(2,8);
await this.redisService.set(`update_password_captcha_${address}`, code, 10 * 60);
await this.emailService.sendMail({
to: address,
subject: '更改密码验证码',
html: `<p>你的更改密码验证码是 ${code}</p>`
});
return '发送成功';
}和之前注册验证码的逻辑一样。
然后还有修改个人信息的:
对应 /user/update 和 /user/admin/update 接口。
回显数据的接口就用 /user/info 这个。
实现流程和修改密码的差不多:
@Post(['update', 'admin/update'])
@RequireLogin()
async update(@UserInfo('userId') userId: number, @Body() updateUserDto: UpdateUserDto) {
return await this.userService.update(userId, updateUserDto);
}在 UserController 定义两个 post 接口。
创建 src/user/dto/update-user.dto.ts
import { IsEmail, IsNotEmpty } from "class-validator";
export class UpdateUserDto {
headPic: string;
nickName: string;
@IsNotEmpty({
message: '邮箱不能为空'
})
@IsEmail({}, {
message: '不是合法的邮箱格式'
})
email: string;
@IsNotEmpty({
message: '验证码不能为空'
})
captcha: string;
}这里 headPic 和 nickName 就不做非空约束了,也就是说可以不改。
对应的 UserService 里的逻辑和修改密码的差不多:
async update(userId: number, updateUserDto: UpdateUserDto) {
const captcha = await this.redisService.get(`update_user_captcha_${updateUserDto.email}`);
if(!captcha) {
throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
}
if(updateUserDto.captcha !== captcha) {
throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
}
const foundUser = await this.userRepository.findOneBy({
id: userId
});
if(updateUserDto.nickName) {
foundUser.nickName = updateUserDto.nickName;
}
if(updateUserDto.headPic) {
foundUser.headPic = updateUserDto.headPic;
}
try {
await this.userRepository.save(foundUser);
return '用户信息修改成功';
} catch(e) {
this.logger.error(e, UserService);
return '用户信息修改失败';
}
}只不过现在是传了的属性才会修改,没传的不修改。
测试下:
登录 lisi 账号拿到 token
带上 token 访问 /user/update 接口:
提示验证码失效,在 redis 里添加 update_user_captcha_yy@yy.com 的 key,值为 123456
然后再试下:
用户信息修改成功了,在数据库里也可以看到确实修改了:
这样,修改接口就完成了。
然后还要加一个发验证码的接口,这个和别的发验证码的逻辑一样:
@Get('update/captcha')
async updateCaptcha(@Query('address') address: string) {
const code = Math.random().toString().slice(2,8);
await this.redisService.set(`update_user_captcha_${address}`, code, 10 * 60);
await this.emailService.sendMail({
to: address,
subject: '更改用户信息验证码',
html: `<p>你的验证码是 ${code}</p>`
});
return '发送成功';
}这节添加了 interceptor 用来对响应格式做转换,改成 {code、message、data} 的格式,用到了 map 操作符。
并且还用 interceptor 实现了接口访问的日志记录,用到 tap 操作符。
然后实现了修改信息、修改密码的接口。
这些流程都差不多,首先实现一个查询的接口用来回显数据,通过 vo 封装返回的数据。
然后提交数据进行更新,用到的 userId 通过之前封装的 @UserInfo 装饰器从 request.user 来取。
还剩个列表接口,下节再写。
用户管理模块实现了注册、登录鉴权、信息修改接口,还剩下两个接口:
这节来实现下。
在那之前先考虑个问题:
成功的响应是这样的:
但是失败的响应是这样:
并不统一。
如何让响应统一成 {code、message、data} 的格式呢?
这里就需要自定义 Exception Filter 了。
在 Guard、handler、interceptor 等处理逻辑里 throw http 异常,都会被 ExceptionFilter 处理成相应的响应。
如果想修改异常响应的格式,就要自定义了。
新建个 exception filter:
nest g filter unlogin --flat@Catch 的参数可以指定具体 catch 的异常类型:
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
export class UnLoginException{
message: string;
constructor(message?){
this.message = message;
}
}
@Catch(UnLoginException)
export class UnloginFilter implements ExceptionFilter {
catch(exception: UnLoginException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
response.json({
code: HttpStatus.UNAUTHORIZED,
message: 'fail',
data: exception.message || '用户未登录'
}).end();
}
}自定义了 UnLoginException 的异常,在 @Catch 指定捕获这个异常,返回对应的响应。
在 main.ts 引入下:
然后把 LoginGuard 里的异常改成 UnLoginException
测试下:
现在当你不带 token 访问 /aaa 的时候,返回的就是自己定义的格式了。
搜索下之前代码里抛的异常:
还有不少。
难道有多少种 exception 就定义多少种异常类和 exception filter 么?
没必要,直接修改下对 HttpException 的处理逻辑就好了。
nest g filter custom-exception --flatimport { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class CustomExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
response.statusCode = exception.getStatus();
response.json({
code: exception.getStatus(),
message: 'fail',
data: exception.message
}).end();
}
}直接 @Catch 指定 HttpException,修改返回的响应格式。
然后在 main.ts 里启用:
这样,所有的代码都不用修改,返回的响应就已经变了:
所以,如果你只是想修改默认的响应格式,直接定义个 catch HttpException 的 filter 就好了。
但这样其实还有个问题:
对于 ValidationPipe 报的错,返回的信息不准。
可以打个断点看看具体的错误信息:
创建 .vscode/launch.json 文件:
输入调试配置:
{
"name": "Launch via NPM",
"request": "launch",
"runtimeArgs": [
"run-script",
"start:dev"
],
"console": "integratedTerminal",
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node"
}然后在 exception filter 打个断点,点击调试启动:
在 postman 里再次请求这个接口,代码会在断点处断住:
可以看到,这时候 message 并不是具体的错误,具体的错误在 response.message 里:
所以可以这样改:
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch(HttpException)
export class CustomExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse<Response>();
response.statusCode = exception.getStatus();
const res = exception.getResponse() as { message: string[] };
response.json({
code: exception.getStatus(),
message: 'fail',
data: res?.message?.join ? res?.message?.join(',') : exception.message
}).end();
}
}
就是如果有 response.message 就优先用那个,否则就取 exception.message。
再试下:
这样,错误显示就对了。
然后实现冻结用户的接口,冻结的用户不能预订会议室。
这个接口非常简单,就是修改一个字段:
@Get('freeze')
async freeze(@Query('id') userId: number) {
await this.userService.freezeUserById(userId);
return 'success';
}定义 get 接口,然后从 query 取 id 参数。
其实这个接口也需要登录,而且只有管理员有调用它的权限,这个后面再统一处理。
在 UserService 定义这个 freezeUserById 方法:
async freezeUserById(id: number) {
const user = await this.userRepository.findOneBy({
id
});
user.isFrozen = true;
await this.userRepository.save(user);
}测试下:
确实冻结了。
然后实现 /user/list 用户列表接口。
@Get('list')
async list(@Query('pageNo', ParseIntPipe) pageNo: number, @Query('pageSize', ParseIntPipe) pageSize: number) {
return await this.userService.findUsersByPage(pageNo, pageSize);
}这个接口支持分页查询,传入 pageNo、pageSize,返回对应页的数据。
在 UserService 里实现下:
还记得如何用 sql 实现分页查询么?
目前 users 表有 4 条数据,先查询全部的:
select * from users然后每 2 条记录一页
查询第一页:
select * from users limit 0,2查询第二页:
select * from users limit 2,2也就是说,只要计算出当前页码跳过多少条记录,取多少条记录就好了。
也就是这样:
async findUsersByPage(pageNo: number, pageSize: number) {
const skipCount = (pageNo - 1) * pageSize;
const [users, totalCount] = await this.userRepository.findAndCount({
skip: skipCount,
take: pageSize
});
return {
users,
totalCount
}
}当前页码减一乘以 pageSize,就是要跳过的记录数,然后再取 pageSize 条。
这次用的是 findAndCount 的 api,它还会查询总记录数。
测试下:
没传 pageNo 的时候会返回 400 的错误。
这个报错信息不够友好,改一下:
@Get('list')
async list(
@Query('pageNo', new ParseIntPipe({
exceptionFactory() {
throw new BadRequestException('pageNo 应该传数字');
}
})) pageNo: number,
@Query('pageSize', new ParseIntPipe({
exceptionFactory() {
throw new BadRequestException('pageSize 应该传数字');
}
})) pageSize: number
) {
return await this.userService.findUsersByPage(pageNo, pageSize);
}现在的提示信息就友好多了。
重构下代码:
把这段代码抽离到 src/utils.ts 里:
export function generateParseIntPipe(name) {
return new ParseIntPipe({
exceptionFactory() {
throw new BadRequestException(name + ' 应该传数字');
}
})
}那 controller 的代码就可以简化成这样了:
@Get('list')
async list(
@Query('pageNo', generateParseIntPipe('pageNo')) pageNo: number,
@Query('pageSize', generateParseIntPipe('pageSize')) pageSize: number,
) {
return await this.userService.findUsersByPage(pageNo, pageSize);
}那如果没有传 pageNo 和 pageSize 的时候要设置个默认值呢?
可以使用 DefaultValuePipe:
没有传 pageNo 的时候设置为 1,没有传 pageSize 的时候设置为 2。
测试下:
现在传入 pageNo 和 pageSize 就可以查询出对应的数据,还有总条数:
服务端打印了两条 sql:
一条是分页查询,指定了 limit 2 offset 2,这个和 limit 2, 2 是一样的
一条是 count 统计,统计了用户的总条数。
这里返回的信息同样也需要做一些修改。
指定下 select 的字段:
async findUsersByPage(pageNo: number, pageSize: number) {
const skipCount = (pageNo - 1) * pageSize;
const [users, totalCount] = await this.userRepository.findAndCount({
select: ['id', 'username', 'nickName', 'email', 'phoneNumber', 'isFrozen', 'headPic', 'createTime'],
skip: skipCount,
take: pageSize
});
return {
users,
totalCount
}
}这样返回的数据就只包含 select 的字段了:
当然,如果你需要对返回的数据再做一些变换,这时候可以封装个 vo 对象。
用户列表的需求除了分页外,还需要支持根据 username、nickName、email 的搜索:
添加几个参数:
@Get('list')
async list(
@Query('pageNo', new DefaultValuePipe(1), generateParseIntPipe('pageNo')) pageNo: number,
@Query('pageSize', new DefaultValuePipe(2), generateParseIntPipe('pageSize')) pageSize: number,
@Query('username') username: string,
@Query('nickName') nickName: string,
@Query('email') email: string
) {
return await this.userService.findUsers(username, nickName, email, pageNo, pageSize);
}在 UserService 添加 findUsers 方法:
async findUsers(username: string, nickName: string, email: string, pageNo: number, pageSize: number) {
const skipCount = (pageNo - 1) * pageSize;
const condition: Record<string, any> = {};
if(username) {
condition.username = Like(`%${username}%`);
}
if(nickName) {
condition.nickName = Like(`%${nickName}%`);
}
if(email) {
condition.email = Like(`%${email}%`);
}
const [users, totalCount] = await this.userRepository.findAndCount({
select: ['id', 'username', 'nickName', 'email', 'phoneNumber', 'isFrozen', 'headPic', 'createTime'],
skip: skipCount,
take: pageSize,
where: condition
});
return {
users,
totalCount
}
}和之前的区别就是多了个 where 条件。
根据 username、nickName、email 搜索的时候,使用模糊查询。
测试下:
当我传 nickName 包含“里”的时候,服务端查询到数据只有一条。
传入 nickName 包含 “光” 的时候,返回两条数据。
这样,搜索就完成了。
这节实现了冻结用户和用户列表接口。
通过自定义 exception filter,catch 了 HTTPException,返回了自定义格式的响应,统一了响应格式。
冻结用户接口比较简单,就是修改 users 表的一个字段。
用户列表支持了分页查询和模糊搜索:
分页查询就是根据 (pageNo -1) * pageSize 计算出从哪里开始,然后取 pageSize 条。
模糊搜索就是通过 like 来匹配。
此外,ParseIntPipe 自定义了错误格式,还使用了 DefaultValuePipe 设置了默认值。
至此,用户模块的所有接口都写完了,下节用 swagger 来生成接口文档。
后端写完接口,都会提供一份接口文档给前端。
这节就来做下这件事情,通过 swagger 生成接口文档。
安装 swagger 的包:
npm install --save @nestjs/swagger在 main.ts 添加这段代码:
const config = new DocumentBuilder()
.setTitle('会议室预订系统')
.setDescription('api 接口文档')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-doc', app, document);用 SwaggerModule 生成接口文档,url 是 /api-doc
访问下:
可以看到所有接口都列出来了:
还有用到的 schema,也就是对象的结构:
只不过很多接口的文档是不对的:
比如用户列表接口,这些参数都不是必选的,而且也没有响应相关的信息:
还有 schema 也没有具体的内容。
这些需要加一些装饰器来告诉 swagger。
在 UserController 添加一个 @ApiTags
这样这个 cotroller 的接口会被单独分组:
然后一个个接口来看:
先是 /user/register-captcha 接口
@ApiQuery({
name: 'address',
type: String,
description: '邮箱地址',
required: true,
example: 'xxx@xx.com'
})
@ApiResponse({
status: HttpStatus.OK,
description: '发送成功',
type: String
})通过 @ApiQuery 描述 query 参数,通过 @ApiResponse 描述响应。
然后是 /user/register 接口:
它一共有 2 种状态码,200 和 400:
@ApiBody({type: RegisterUserDto})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '验证码已失效/验证码不正确/用户已存在',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: '注册成功/失败',
type: String
})请求体的属性需要去 dto 里标识:
然后接口文档里就可看到请求体的信息了:
下面的 schema 里的 RegisterUserDto 也有了内容:
接下来是 /user/login 接口:
它也是有 400 和 200 两种响应:
@ApiBody({
type: LoginUserDto
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '用户不存在/密码错误',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: '用户信息和 token',
type: LoginUserVo
})通过 @ApiResponse 标识两种响应,通过 @ApiBody 标识请求体。
然后在 LoginUserDto 和 LoginUserVo 里标识下属性:
LoginUserDto: LoginuserVo:
import { ApiProperty } from "@nestjs/swagger";
class UserInfo {
@ApiProperty()
id: number;
@ApiProperty({example: 'zhangsan'})
username: string;
@ApiProperty({example: '张三'})
nickName: string;
@ApiProperty({example: 'xx@xx.com'})
email: string;
@ApiProperty({example: 'xxx.png'})
headPic: string;
@ApiProperty({example: '13233333333'})
phoneNumber: string;
@ApiProperty()
isFrozen: boolean;
@ApiProperty()
isAdmin: boolean;
@ApiProperty()
createTime: number;
@ApiProperty({example: ['管理员']})
roles: string[];
@ApiProperty({example: 'query_aaa'})
permissions: string[]
}
export class LoginUserVo {
@ApiProperty()
userInfo: UserInfo;
@ApiProperty()
accessToken: string;
@ApiProperty()
refreshToken: string;
}之前这里的 UserInfo 是 interface,这里要改成 class 才能加装饰器。
测试下:
/user/admin/login 的 swagger 装饰器和 /user/login 一样。
然后继续看 /user/refresh 接口:
@ApiQuery({
name: 'refreshToken',
type: String,
description: '刷新 token',
required: true,
example: 'xxxxxxxxyyyyyyyyzzzzz'
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'token 已失效,请重新登录'
})
@ApiResponse({
status: HttpStatus.OK,
description: '刷新成功'
})用 @ApiQuery 标识 query 参数,用 @ApiResponse 标识两种响应。
但现在刷新成功的 access_token 和 refresh_token 没有显示。
所以也需要把这个返回值封装成 vo:
新建 src/user/vo/refresh-token.vo.ts
import { ApiProperty } from "@nestjs/swagger";
export class RefreshTokenVo {
@ApiProperty()
access_token: string;
@ApiProperty()
refresh_token: string;
}把返回的结果封装成 vo:
const vo = new RefreshTokenVo();
vo.access_token = access_token;
vo.refresh_token = refresh_token;
return vo;在 @ApiResponse 里标识这个 type
刷新下页面,可以看到现在接口文档里就有了返回数据的结构:
/user/admin/login 的处理方式一样。
接下来是 /user/info 接口:
加一下返回的数据的标识 @ApiResponse。
然后在 UserDetailVo 里加一下 @ApiProperty:
import { ApiProperty } from "@nestjs/swagger";
export class UserDetailVo {
@ApiProperty()
id: number;
@ApiProperty()
username: string;
@ApiProperty()
nickName: string;
@ApiProperty()
email: string;
@ApiProperty()
headPic: string;
@ApiProperty()
phoneNumber: string;
@ApiProperty()
isFrozen: boolean;
@ApiProperty()
createTime: Date;
}这样返回的数据结构就对了:
但这个接口是需要登录的,加一下标识:
然后在 main.ts 里加一下这种 bearer 的认证方式:
这时候这个接口就有了锁的标记,代表需要登录了:
点击锁,填入 access_token,这样再测试接口的时候,会自动带上 token 标识:
比如我输入 xxx,然后点击 authorize
然后点击 try it out 和 execute,可以看到浏览器发送了这个请求,并且带上了 authorization 的 header
可以在 swagger 文档里测试这个接口。
接下来是 /user/update_password
@ApiBearerAuth()
@ApiBody({
type: UpdateUserPasswordDto
})
@ApiResponse({
type: String,
description: '验证码已失效/不正确'
})在 UpdateUserPasswordDto 里加一下 @ApiProperty
接口文档没啥问题:
接下来是 /user/update_password/captcha 接口
这个接口是需要登录的,当时为了测试方便没有加,现在加一下:
@ApiBearerAuth()
@ApiQuery({
name: 'address',
description: '邮箱地址',
type: String
})
@ApiResponse({
type: String,
description: '发送成功'
})
@RequireLogin()然后是 /user/update 接口:
@ApiBearerAuth()
@ApiBody({
type: UpdateUserDto
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '验证码已失效/不正确'
})
@ApiResponse({
status: HttpStatus.OK,
description: '更新成功',
type: String
})在 UpdateUserDto 里标识下 @ApiProperty
刷新下,可以看到最新的接口文档:
然后是 /user/freeeze 接口
@ApiBearerAuth()
@ApiQuery({
name: 'id',
description: 'userId',
type: Number
})
@ApiResponse({
type: String,
description: 'success'
})
@RequireLogin()刷新下:
没啥问题。
最后,还剩下 /user/list 接口:
@ApiBearerAuth()
@ApiQuery({
name: 'pageNo',
description: '第几页',
type: Number
})
@ApiQuery({
name: 'pageSize',
description: '每页多少条',
type: Number
})
@ApiQuery({
name: 'username',
description: '用户名',
type: Number
})
@ApiQuery({
name: 'nickName',
description: '昵称',
type: Number
})
@ApiQuery({
name: 'email',
description: '邮箱地址',
type: Number
})
@ApiResponse({
type: String,
description: '用户列表'
})
@RequireLogin()这里的返回值需要封装个 vo:
创建 src/user/vo/user-list.vo.ts
import { ApiProperty } from "@nestjs/swagger";
class User {
@ApiProperty()
id: number;
@ApiProperty()
username: string;
@ApiProperty()
nickName: string;
@ApiProperty()
email: string;
@ApiProperty()
phoneNumber: string;
@ApiProperty()
isFrozen: boolean;
@ApiProperty()
headPic: string;
@ApiProperty()
createTime: Date;
}
export class UserListVo {
@ApiProperty({
type: [User]
})
users: User[];
@ApiProperty()
totalCount: number;
}注意这里标识 User 数组要用 [User]
然后把 findUsers 的返回值改为 UserListVo
const vo = new UserListVo();
vo.users = users;
vo.totalCount = totalCount;
return vo;刷新下接口文档:
没啥问题。
这样,就给所有的接口生成了 api 文档。
这节用 swagger 生成了接口文档。
在 main.ts 里调用 SwaggerModule.setup 来生成接口文档。
然后用 @ApiQuery、@ApiBody、@ApiResponse、@ApiProperty 等来标识每个接口的参数和响应。
并且通过 @ApiBearerAuth 标识需要 jwt 认证的接口。
返回对象的接口需要把它封装成 vo,然后再添加 @ApiProperty。
接口文档提供给前端之后,前端就可以基于这个来写页面了。
用户模块接口写完之后,这节开始写页面。
先写下用户端页面,涉及到到登录、注册、修改密码、修改个人信息这 4 个:
先新建个 react 项目:
npm create vite@latest meeting_room_booking_system_frontend_u -- --template react-ts进入项目目录,把开发服务跑起来:
npm run dev浏览器访问 http://localhost:5173 可以看到这个界面:
就说明 react 项目成功跑起来了。
然后添加 router:
npm install --save react-router-dom在 main.tsx 加上路由的配置:
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider, createBrowserRouter, Link, Outlet } from 'react-router-dom';
function Aaa() {
return <div>aaa</div>;
}
function Bbb(){
return <div>bbb</div>;
}
function Layout() {
return <div>
<div><Link to="/aaa">to aaa</Link></div>
<div><Link to="/bbb">to bbb</Link></div>
<div>
<Outlet/>
</div>
</div>
}
function ErrorPage() {
return <div>error</div>;
}
const routes = [
{
path: "/",
element: <Layout/>,
errorElement: <ErrorPage/>,
children: [
{
path: "aaa",
element: <Aaa />,
},
{
path: "bbb",
element: <Bbb />,
}
],
}
];
const router = createBrowserRouter(routes);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<RouterProvider router={router}/>);配置了 3 个路由:
访问 / 的时候,渲染 Layout 组件。
访问 /aaa 的时候,渲染 Aaa 组件。
访问 /bbb 的时候,渲染 Bbb 组件。
以及出错的时候,渲染 ErrorPage 组件。
其中,aaa 和 bbb 是 / 的子路由,渲染在 Layout 组件的 Outlet 的地方。
这就是 React Router 的基本用法。
测试下:
都没问题。
然后创建 3 个组件:Login、Register、UpdatePassword
把 src 目录下其余文件去掉:
然后添加这 3 个组件还有 ErrorPage 组件:
Login.tsx
export function Login() {
return <div>login</div>
}Register.tsx
export function Register() {
return <div>register</div>
}UpdatePassword.tsx
export function UpdatePassword() {
return <div>UpdatePassword</div>
}ErrorPage.tsx
export function ErrorPage() {
return <div>ErrorPage</div>
}改下 main.tsx 配置对应的路由:
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider, createBrowserRouter, Link, Outlet } from 'react-router-dom';
import { Register } from './Register';
import { Login } from './Login';
import { UpdatePassword } from './UpdatePassword';
import { ErrorPage } from './ErrorPage';
const routes = [
{
path: "/",
element: <div>index</div>,
errorElement: <ErrorPage />,
},
{
path: "login",
element: <Login />,
},
{
path: "register",
element: <Register />,
},
{
path: "update_password",
element: <UpdatePassword />,
}
];
const router = createBrowserRouter(routes);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<RouterProvider router={router}/>);
测试下:
都没啥问题。
然后来写 Login 页面,也就是这个:
引入 Ant Design 组件库:
npm install antd --save在 Login 组件引入 DatePicker 组件:
import { DatePicker } from "antd";
export function Login() {
return <div><DatePicker/></div>
}没啥问题,说明 antd 引入成功了。
然后把登录页面写一下:
import { Button, Checkbox, Form, Input } from 'antd';
import './login.css';
interface LoginUser {
username: string;
password: string;
}
const onFinish = (values: LoginUser) => {
console.log(values);
};
const layout1 = {
labelCol: { span: 4 },
wrapperCol: { span: 20 }
}
const layout2 = {
labelCol: { span: 0 },
wrapperCol: { span: 24 }
}
export function Login() {
return <div id="login-container">
<h1>会议室预订系统</h1>
<Form
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
{...layout2}
>
<div className='links'>
<a href=''>创建账号</a>
<a href=''>忘记密码</a>
</div>
</Form.Item>
<Form.Item
{...layout2}
>
<Button className='btn' type="primary" htmlType="submit">
登录
</Button>
</Form.Item>
</Form>
</div>
}其中,layout 是 label 和 wrapper 的比例分配,antd 的栅格系统一共分了 24 份。
上面两个 Form.Item 是 label 4 份,wrapper 20 份。
下面两个 Form.Item 是 label 0 份,wrapper 24 份。
colon 为 false 是去掉 label 后的冒号
然后 css 部分如下:
#login-container {
width: 400px;
margin: 100px auto 0 auto;
text-align: center;
}
#login-container .links {
display: flex;
justify-content: space-between;
}
#login-container .btn {
width: 100%;
}访问 /login,可以看到现在的登录页面:
输入用户名、密码,点击登录:
控制台打印了拿到的表单值。
在前端项目安装下 axios:
npm install --save axios然后把服务端跑起来:
跑之前先把端口配置改为 3005,因为前端开发服务用了 3000 端口:
然后在后端项目执行:
npm run dev:dev登录接口有哪些参数呢?
可以看 swagger 接口文档:
访问 http://localhost:3005/api-doc
在 postman 里测试下登录接口:
然后在点击登录按钮之后,用 axios 调用它:
在前端项目创建个 interfaces.ts
import axios from "axios";
const axiosInstance = axios.create({
baseURL: 'http://localhost:3005/',
timeout: 3000
});
export async function login(username: string, password: string) {
return await axiosInstance.post('/user/login', {
username, password
});
}在这里集中管理接口。
然后 onFinish 里调用:
const onFinish = async (values: LoginUser) => {
const res = await login(values.username, values.password);
if(res.status === 201 || res.status === 200) {
message.success('登录成功');
console.log(res.data);
} else {
message.error(res.data.data || '系统繁忙,请稍后再试');
}
};登录下:
提示跨域错误:
在服务端开启下跨域支持:
再试下:
因为这个时候接口返回 400
axios 对于 400 多、500 多的请求,都会抛出错误。
所以加一个响应的拦截器处理下:
在 interface.ts 添加:
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
return error.response;
}
);再次调用,就能正常处理了:
如果你把返回的值改为 Promise.reject
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
return Promise.reject(error.response);
}
);就又恢复原样了:
因为 axios 默认就是这样处理的,400 多和 500 多的请求,会返回 reject。
把它改回来,然后测试下正常的请求:
提示登录成功,并且返回了用户信息和 token。
再输入错误的用户名、密码试试:
会返回 400 和具体的错误。
然后完善下 onFinish 方法:
const onFinish = async (values: LoginUser) => {
const res = await login(values.username, values.password);
const { code, message: msg, data} = res.data;
if(res.status === 201 || res.status === 200) {
message.success('登录成功');
localStorage.setItem('access_token', data.accessToken);
localStorage.setItem('refresh_token', data.refreshToken);
localStorage.setItem('user_info', JSON.stringify(data.userInfo));
} else {
message.error(data || '系统繁忙,请稍后再试');
}
};返回 success 的时候,把 access_token、refresh_token、user_info 存入 localStorage
否则,提示错误。
测试下:
登录成功之后,在 Application > Local Storage 里就可以看到这三个值
用户不存在或者密码错误的时候,也提示了对应的错误:
这样,登录的前后端功能就都完成了。
然后来写下注册:
实现下 Register 页面组件:
import { Button, Form, Input, message } from 'antd';
import './register.css';
import { useForm } from 'antd/es/form/Form';
export interface RegisterUser {
username: string;
nickName: string;
password: string;
confirmPassword: string;
email: string;
captcha: string;
}
const onFinish = async (values: RegisterUser) => {
console.log(values);
};
const layout1 = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
const layout2 = {
labelCol: { span: 0 },
wrapperCol: { span: 24 }
}
export function Register() {
const [form] = useForm();
return <div id="register-container">
<h1>会议室预订系统</h1>
<Form
form={form}
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="昵称"
name="nickName"
rules={[{ required: true, message: '请输入昵称!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
rules={[{ required: true, message: '请输入确认密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱!' },
{ type: "email", message: '请输入合法邮箱地址!'}
]}
>
<Input />
</Form.Item>
<div className='captcha-wrapper'>
<Form.Item
label="验证码"
name="captcha"
rules={[{ required: true, message: '请输入验证码!' }]}
>
<Input />
</Form.Item>
<Button type="primary">发送验证码</Button>
</div>
<Form.Item
{...layout2}
>
<div className='links'>
已有账号?去<a href=''>登录</a>
</div>
</Form.Item>
<Form.Item
{...layout1}
label=" "
>
<Button className='btn' type="primary" htmlType="submit">
注册
</Button>
</Form.Item>
</Form>
</div>
}和登录页面差不多,只不过多了一个验证码。
对应的 register.css 如下:
#register-container {
width: 400px;
margin: 100px auto 0 auto;
text-align: center;
}
#register-container .links {
display: flex;
justify-content: flex-end;
}
#register-container .btn {
width: 100%;
}
#register-container .captcha-wrapper {
display: flex;
justify-content: flex-end;
}
浏览器访问下 http://localhost:5173
填入信息,点击注册,控制台会打印表单值:
然后来调用下注册接口和发送验证码的接口。
在 swagger 文档看下这俩接口:
同样,先在 postman 里调用下:
调用发送验证码接口:
邮箱收到了对应的邮件:
然后调用注册接口:
注册成功,数据库也多了一条记录:
在页面是实现这个流程:
在 interfaces.ts 里添加这两个接口:
export async function registerCaptcha(email: string) {
return await axiosInstance.get('/user/register-captcha', {
params: {
address: email
}
});
}
export async function register(registerUser: RegisterUser) {
return await axiosInstance.post('/user/register', registerUser);
}先调用发送验证码接口:
绑定点击事件。
使用 useForm 来拿到 form 的 api
async function sendCaptcha() {
const address = form.getFieldValue('email');
if(!address) {
return message.error('请输入邮箱地址');
}
const res = await registerCaptcha(address);
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error(res.data.data || '系统繁忙,请稍后再试');
}
}在 sendCaptcha 里调用接口发送验证码。
测试下:
没啥问题。
可以用 useCallback 包裹下:
const sendCaptcha = useCallback(async function () {
const address = form.getFieldValue('email');
if(!address) {
return message.error('请输入邮箱地址');
}
const res = await registerCaptcha(address);
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error(res.data.data || '系统繁忙,请稍后再试');
}
}, []);这是减少不必要渲染的一种性能优化。
然后再来调用注册接口:
const onFinish = async (values: RegisterUser) => {
if(values.password !== values.confirmPassword) {
return message.error('两次密码不一致');
}
const res = await register(values);
if(res.status === 201 || res.status === 200) {
message.success('注册成功');
} else {
message.error(res.data.data || '系统繁忙,请稍后再试');
}
}测试下:
填入信息,点击发送验证码:
去邮箱里看一下验证码:
填入验证码,点击注册:
注册成功,数据库里也看到了这条记录:
这样,注册的前后端流程就走完了。
然后给它加上跳转逻辑:
把 onFinish 移到组件内,使用 useCallback 包裹。
然后调用 useNavigate 的 navigate 方法,注册成功后导航到登录页:
const navigate = useNavigate();
const onFinish = useCallback(async (values: RegisterUser) => {
if(values.password !== values.confirmPassword) {
return message.error('两次密码不一致');
}
const res = await register(values);
if(res.status === 201 || res.status === 200) {
message.success('注册成功');
setTimeout(() => {
navigate('/login');
}, 1500);
} else {
message.error(res.data.data || '系统繁忙,请稍后再试');
}
}, []);登录也要加上导航,登录成功后导航到首页:
const navigate = useNavigate();
const onFinish = useCallback(async (values: LoginUser) => {
const res = await login(values.username, values.password);
const { code, message: msg, data} = res.data;
if(res.status === 201 || res.status === 200) {
message.success('登录成功');
localStorage.setItem('access_token', data.accessToken);
localStorage.setItem('refresh_token', data.refreshToken);
localStorage.setItem('user_info', JSON.stringify(data.userInfo));
setTimeout(() => {
navigate('/');
}, 1000);
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);还有这几个链接也要改成 router 的 Link:
测试下:
最后,再来重构下代码:
现在的代码都是平级放的,分几个目录:
添加 page 目录和 interface 目录,把对应的文件移动过去,然后修改下引用它的路径。
这样,登录和注册的前后端功能就都完成了。
这节打通了前后端,加上了登录、注册、首页等页面。
首先,引入了 React Router 来做路由,引入了 antd 来做 UI 组件库。
然后,引入了 axios 来发请求。
先在 postman 里测试了一遍接口,之后在页面里用 axios 调用了下。
经测试,发送邮件验证码正常,注册之后数据库也多了记录,登录成功之后 localStorage 也有了用户信息。
这样,注册登录就实现了完整的前后端功能。
下节,继续写其他页面。
用户相关的页面还剩下两个,这节来写一下:
首先是修改密码的页面,把注册页面的表单拿过来改改就行:
import { Button, Form, Input, message } from 'antd';
import { useForm } from 'antd/es/form/Form';
import './update_password.css';
import { useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
export interface UpdatePassword {
email: string;
captcha: string;
password: string;
confirmPassword: string;
}
const layout1 = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
const layout2 = {
labelCol: { span: 0 },
wrapperCol: { span: 24 }
}
export function UpdatePassword() {
const [form] = useForm();
const navigate = useNavigate();
const onFinish = useCallback(async (values: UpdatePassword) => {
console.log(values);
}, []);
const sendCaptcha = useCallback(async function () {
console.log('send captcha')
}, []);
return <div id="updatePassword-container">
<h1>会议室预订系统</h1>
<Form
form={form}
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱!' },
{ type: "email", message: '请输入合法邮箱地址!'}
]}
>
<Input />
</Form.Item>
<div className='captcha-wrapper'>
<Form.Item
label="验证码"
name="captcha"
rules={[{ required: true, message: '请输入验证码!' }]}
>
<Input />
</Form.Item>
<Button type="primary" onClick={sendCaptcha}>发送验证码</Button>
</div>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
rules={[{ required: true, message: '请输入确认密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
{...layout1}
label=" "
>
<Button className='btn' type="primary" htmlType="submit">
修改
</Button>
</Form.Item>
</Form>
</div>
}css 部分如下:
#updatePassword-container {
width: 400px;
margin: 100px auto 0 auto;
text-align: center;
}
#updatePassword-container .btn {
width: 100%;
}
#updatePassword-container .captcha-wrapper {
display: flex;
justify-content: flex-end;
}跑起来是这样的:
然后再调用下后端接口。
这时我发现一个问题:
之前写的 update_password 接口需要登录,然后用 @UserInfo 从 request.user 取 userId。
但其实这个页面是从这里点进来的:
这时候并没有登录,只是通过邮箱验证身份,然后修改密码。
但是邮箱并没有添加唯一约束,只有 username 是在注册的时候做了唯一检查:
所以需要通过 username 来找到修改密码的用户。
可以改成前端传 username,然后后端验证过邮箱验证码之后,再根据 username 查询要改密码的用户。
此外,在 User 的 entity 这里给 username 添加唯一约束:
然后可以看到 typeorm 确实给 username 添加了唯一索引:
在 mysql workbench 里也可以看到:
然后修改下 update_password 接口:
把 @RequireLogin、@ApiBearerAuth 还有 @UserInfo 去掉。
修改 UpdatePasswordDto,添加 username 属性:
@IsNotEmpty({
message: '用户名不能为空'
})
@ApiProperty()
username: string;然后改下 UserService 的 updatePassword 方法:
改成根据 username 查询用户,并且还要验证下邮箱是否正确。
const foundUser = await this.userRepository.findOneBy({
username: passwordDto.username
});
if(foundUser.email !== passwordDto.email) {
throw new HttpException('邮箱不正确', HttpStatus.BAD_REQUEST);
}还有,发送验证码接口的 @RequireLogin 和 @ApiBearerAuth 也要去掉:
测试下:
请求 /user/update_password/captcha 接口发送验证码:
然后请求 /user/update_password 接口:
提示修改密码成功。
在页面登录下试试:
用最新密码登录,提示登录成功。
postman 里跑通流程之后,在页面里加一下。
在更改密码页面加上 username 表单项:
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: '请输入用户名!' },
]}
>
<Input />
</Form.Item>类型中也加上 username:
然后在 interfaces.ts 添加这两个接口:
export async function updatePasswordCaptcha(email: string) {
return await axiosInstance.get('/user/update_password/captcha', {
params: {
address: email
}
});
}
export async function updatePassword(data: UpdatePassword) {
return await axiosInstance.post('/user/update_password', data);
}更改密码页面里调用下:
const sendCaptcha = useCallback(async function () {
const address = form.getFieldValue('email');
if(!address) {
return message.error('请输入邮箱地址');
}
const res = await updatePasswordCaptcha(address);
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);发送验证码接口的调用逻辑和注册时一模一样。
更改密码接口的调用逻辑也和注册差不多:
const onFinish = useCallback(async (values: UpdatePassword) => {
if(values.password !== values.confirmPassword) {
return message.error('两次密码不一致');
}
const res = await updatePassword(values);
const { message: msg, data} = res.data;
if(res.status === 201 || res.status === 200) {
message.success('密码修改成功');
setTimeout(() => {
navigate('/login');
}, 1500);
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);测试下:
填入邮箱,点击发送验证码。
提示发送成功。
邮箱里也收到了这个验证码。
然后填入新密码,点击修改密码按钮:
提示修改成功,然后会跳到登录页面:
在登录页面用新密码登录,提示登录成功。
这样,修改密码功能的前后端就都完成了。
然后还有一个更改个人信息的页面。
这个页面有所不同。
前面几个功能都是未登录时的,而这个修改个人信息的功能需要登录之后才能用。
而且界面上方是有公共的导航栏的。
所以这个页面要声明一个 /index 的路由作为父级路由,然后 /index/update_info 作为子路由。
添加一个 index/index.tsx 页面
import { UserOutlined } from "@ant-design/icons";
import { Outlet } from "react-router-dom";
import './index.css';
export function Index() {
return <div id="index-container">
<div className="header">
<h1>会议室预订系统</h1>
<UserOutlined className="icon"/>
</div>
<div className="body">
<Outlet></Outlet>
</div>
</div>
}这里用到了 antd 的 icon 组件,需要安装用到的包:
npm install @ant-design/icons --savecss 如下:
#index-container{
height: 100vh;
display: flex;
flex-direction: column;
}
#index-container .header{
height: 80px;
border-bottom: 1px solid #aaa;
line-height: 80px;
display: flex;
justify-content: space-between;
padding: 0 20px;
}
#index-container h1{
margin: 0;
}
#index-container .icon {
font-size: 40px;
margin-top: 20px;
}
#index-container .body{
flex: 1;
}整体是竖直的 flex 布局,高度 100vh,上面 80px 下面 flex:1
然后 header 部分是 水平的 flex 布局。
把它添加到 /index 的路由,并且添加两个子路由:
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: 'aaa',
element: <div>aaa</div>
},
{
path: 'bbb',
element: <div>bbb</div>
}
]
},测试下:
没啥问题。
然后在下面添加 update_info 路由,并实现 page/update_info/UpdateInfo 组件:
import { Button, Form, Input, message } from 'antd';
import { useForm } from 'antd/es/form/Form';
import { useCallback } from 'react';
import './update_info.css';
import { useNavigate } from 'react-router-dom';
export interface UserInfo {
headPic: string;
nickName: string;
email: string;
captcha: string;
}
const layout1 = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export function UpdateInfo() {
const [form] = useForm();
const navigate = useNavigate();
const onFinish = useCallback(async (values: UserInfo) => {
}, []);
const sendCaptcha = useCallback(async function () {
}, []);
return <div id="updateInfo-container">
<Form
form={form}
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="头像"
name="headPic"
rules={[
{ required: true, message: '请输入头像!' },
]}
>
<Input/>
</Form.Item>
<Form.Item
label="昵称"
name="nickName"
rules={[
{ required: true, message: '请输入昵称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱!' },
{ type: "email", message: '请输入合法邮箱地址!'}
]}
>
<Input />
</Form.Item>
<div className='captcha-wrapper'>
<Form.Item
label="验证码"
name="captcha"
rules={[{ required: true, message: '请输入验证码!' }]}
>
<Input />
</Form.Item>
<Button type="primary" onClick={sendCaptcha}>发送验证码</Button>
</div>
<Form.Item
{...layout1}
label=" "
>
<Button className='btn' type="primary" htmlType="submit">
修改
</Button>
</Form.Item>
</Form>
</div>
}和前几个页面差不多。
css:
#updateInfo-container {
width: 400px;
margin: 50px auto 0 auto;
text-align: center;
}
#updateInfo-container .btn {
width: 100%;
}
#updateInfo-container .captcha-wrapper {
display: flex;
justify-content: flex-end;
}访问 /update_info 可以看到这个页面:
这个页面涉及到 3 个接口:
/user/info 查询用户信息的接口,用来回显数据
/user/update 修改用户信息的接口,用来做修改
/user/update/captcha 发送验证码的接口
看下 swagger 接口文档:
/user/info 接口没有参数,但是需要登录:
/user/update 接口需要在 body 传这么几个参数:
/user/update/captcha 需要传一个邮箱地址:
不知道有没有同学发现这里有点不太合理:
邮箱是用户自己传的,那如果传的不是注册用的邮箱呢?
前面发送验证码需要传邮箱地址,是因为那是注册或者修改密码用,没有登录。
而现在已经登录了,就不应该再传邮箱地址了,应该是放在 jwt 里,然后在 LoginGuard 里取出来注入 controller。
改一下:
改一下这个 login 接口,在 jwt 里放 email 的信息:
同时 refresh 接口也要改下放在 jwt 里的信息:
这里要取 user.email,需要在 UserService 的 findUserById 方法里返回下 email:
然后在 LoginGuard 里从 jwt 里取出 email 来:
然后在 /user/update/captcha 就可以直接从 request.user 里拿 email 信息了:
把参数的标注去掉,其余的都不用变,这样就是从 jwt 里取登录用户的 email 来发邮件了。
测试下:
先登录:
然后用这个 accessToken 访问下发送验证码接口:
发送成功,收到了这个验证码:
然后调用下查询用户信息的接口:
之后调用修改接口:
在数据库里看下,用户信息已经改过来了:
postman 里接口的流程跑通之后,在页面调用下。
首先在 interfaces.ts 里添加这 3 个接口:
export async function getUserInfo() {
return await axiosInstance.get('/user/info');
}
export async function updateInfo(data: UserInfo) {
return await axiosInstance.post('/user/update', data);
}
export async function updateUserInfoCaptcha() {
return await axiosInstance.get('/user/update/captcha');
}然后先做用户信息的回显:
useEffect(() => {
async function query() {
const res = await getUserInfo();
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
console.log(data);
}
}
query();
}, []);调用 getUserInfo 接口,返回数据后打印下。
这个接口的访问需要登录,也就是要从 localStorage 里取出 accessToken 放到 header 里。
这种携带 header 的通用逻辑可以放在 axios 的 interceptor 里做。
并且还有 token 过期之后调用 refresh 刷新 token 的逻辑。
这个前面写过,直接拿过来放在 interfaces.ts 里:
import { message } from "antd";
const axiosInstance = axios.create({
baseURL: 'http://localhost:3005/',
timeout: 3000
});
axiosInstance.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem('access_token');
if(accessToken) {
config.headers.authorization = 'Bearer ' + accessToken;
}
return config;
})
interface PendingTask {
config: AxiosRequestConfig
resolve: Function
}
let refreshing = false;
const queue: PendingTask[] = [];
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
if(!error.response) {
return Promise.reject(error);
}
let { data, config } = error.response;
if(refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve
});
});
}
if (data.code === 401 && !config.url.includes('/user/refresh')) {
refreshing = true;
const res = await refreshToken();
refreshing = false;
if(res.status === 200 || res.status === 201) {
queue.forEach(({config, resolve}) => {
resolve(axiosInstance(config))
})
return axiosInstance(config);
} else {
message.error(res.data);
setTimeout(() => {
window.location.href = '/login';
}, 1500);
}
} else {
return error.response;
}
}
)
async function refreshToken() {
const res = await axiosInstance.get('/user/refresh', {
params: {
refresh_token: localStorage.getItem('refresh_token')
}
});
localStorage.setItem('access_token', res.data.access_token || '');
localStorage.setItem('refresh_token', res.data.refresh_token || '');
return res;
}这段逻辑就是在每次发请求之前,在 header 里加上 authorization,带上 access_token。
当响应码是 401 的时候,就刷新 token,刷新失败提示错误信息,然后跳到登录页。
并且通过 refreshing 的标记和 task 队列实现了并发请求只刷新一次。
这部分看不明白的同学建议回头看下 access_token 和 refresh_token 无感刷新那节。
其中,这段代码可能有同学有疑问:
if(!error.response) {
return Promise.reject(error);
}是为了请求没有发送成功的情况的:
请求没有发送成功时,错误对象没有 response 属性。
接下来,先登录下:
给用户图标添加跳到 /update_info 的链接:
然后可以看到控制台打印了当前登录用户的信息:
然后做下回显:
form.setFieldValue('headPic', data.headPic);
form.setFieldValue('nickName', data.nickName);
form.setFieldValue('email', data.email);这样,回显的数据就有了:
然后实现发送验证码:
const sendCaptcha = useCallback(async function () {
const res = await updateUserInfoCaptcha();
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);这里不需要填邮箱地址,服务端会从 jwt 里取。
那这里的邮箱也是不应该修改的,给它加个 disabled:
点击发送验证码。
提示发送成功,邮箱里也收到了这个验证码:
接下来就差更新接口了,调用下:
const onFinish = useCallback(async (values: UserInfo) => {
const res = await updateInfo(values);
if(res.status === 201 || res.status === 200) {
const { message: msg, data} = res.data;
if(msg === 'success') {
message.success('用户信息更新成功');
} else {
message.error(data);
}
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);测试下:
修改信息,点击发送验证码:
填入验证码,点击修改,提示用户信息更新成功。
去数据库里看一下:
确实修改成功了。
这样,修改用户信息的流程就走完了。
这节实现了修改密码和修改个人信息这两个页面。
修改密码页面不需要登录,但当时的接口是需要登录的,所以改了一下。
并且给 username 加上了唯一约束,通过 username 来查询用户,然后修改。
修改个人信息页面是登录后的,通过 React Router 的路由嵌套写了多级页面。
实现了用户信息的回显和更新的功能。
此外,还添加了 axios 拦截器来实现自动携带 token 和刷新 token,并在 refreshToken 过期时跳转登录页。
这样,用户端的用户相关功能就都完成了。
上节实现了用户信息的修改:
但是头像是直接填的路径,这里应该做成图片的展示,以及图片的上传。
需要添加个上传图片的接口:
在 UserController 里添加这个 handler:
@Post('upload')
@UseInterceptors(FileInterceptor('file', {
dest: 'uploads'
}))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log('file', file);
return file.path;
}安装用到的类型包:
npm install @types/multer在 postman 里测试下:
选择 form-data 类型,然后添加 file 字段,选择一个文件:
返回了服务端保存路径,并且打印了文件信息:
限制下只能上传图片:
import * as path from 'path';@Post('upload')
@UseInterceptors(FileInterceptor('file', {
dest: 'uploads',
fileFilter(req, file, callback) {
const extname = path.extname(file.originalname);
if(['.png', '.jpg', '.gif'].includes(extname)) {
callback(null, true);
} else {
callback(new BadRequestException('只能上传图片'), false);
}
}
}))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log('file', file);
return file.path;
}callback 的第一个参数是 error,第二个参数是是否接收文件。
然后上传一个非图片文件试一下:
返回了错误信息。
上传图片是正常的:
然后限制下图片大小,最大 3M:
limits: {
fileSize: 1024 * 1024 * 3
}当你上传超过 3M 的图片时,会提示错误:
然后改下保存的文件名,这需要自定义 storage。
前面讲 multer 文件上传那节讲过,直接拿过来(忘了的同学可以回头看一下):
添加 src/my-file-storage.ts
import * as multer from "multer";
import * as fs from 'fs';
const storage = multer.diskStorage({
destination: function (req, file, cb) {
try {
fs.mkdirSync('uploads');
}catch(e) {}
cb(null, 'uploads')
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9) + '-' + file.originalname
cb(null, uniqueSuffix)
}
});
export { storage };这个就是自己指定怎么存储,multer.diskStorage 是磁盘存储,通过 destination、filename 的参数分别指定保存的目录和文件名。
指定 storage:
然后测试下:
这样路径就能看出来是什么文件了。
把这个目录设置为静态文件目录,这样能直接访问上传的图片。
在 main.ts 里添加 uploads 目录为静态目录:
app.useStaticAssets('uploads', {
prefix: '/uploads'
});指定通过 /uploads 的前缀访问。
然后把路径复制,在浏览器访问下:
这样就可以访问到上传的文件了。
也就是说,上传头像之后,可以直接拿到图片的 url。
在页面里加一下:
在 src/page/update_info 下增加一个 HeadPicUpload.tsx
import { Button, Input } from "antd";
interface HeadPicUploadProps {
value?: string;
onChange?: Function
}
export function HeadPicUpload(props: HeadPicUploadProps) {
return props?.value ? <div>
{props.value}
<Button>上传</Button>
</div>: <div>
<Button>上传</Button>
</div>
}在上传头像的地方引入下:
为什么是 value 和 onChange 两个参数呢?
因为 antd 的 Form.Item 在渲染时会给子组件传这两个参数。
现在渲染出来的是这样的:
在 postman 里上传个图片,比如这个:
拿到它的路径:
然后手动去数据库里改一下:
点击 apply。
刷新下页面,可以看到确实变了:
然后把它改成图片:
<img src={'http://localhost:3005/' + props.value} alt="头像" width="100" height="100"/>头像就显示出来了: 然后把后面的上传按钮改为 antd 的拖拽上传组件:
import { InboxOutlined } from "@ant-design/icons";
import { Button, Input, message } from "antd";
import Dragger, { DraggerProps } from "antd/es/upload/Dragger";
interface HeadPicUploadProps {
value?: string;
onChange?: Function
}
const props: DraggerProps = {
name: 'file',
action: 'http://localhost:3005/user/upload',
onChange(info) {
const { status } = info.file;
if (status === 'done') {
console.log(info.file.response);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const dragger = <Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
export function HeadPicUpload(props: HeadPicUploadProps) {
return props?.value ? <div>
<img src={'http://localhost:3005/' + props.value} alt="头像" width="100" height="100"/>
{dragger}
</div>: <div>
{dragger}
</div>
}测试下,提示上传成功:
控制台打印了文件路径:
服务端也确实有了这个文件:
浏览器访问下:
能够正常访问。
接下来就通过 onChange 回调传给 Form 就好了。
这样表单的值就会改,触发重新渲染,就可以看到新的头像:
不过现在还没更新到数据库。
点击发送验证码:
填入验证码,点击修改:
提示更新成功。
数据库里确实更新了:
刷新下页面,可以看到依然是这个头像:
代表修改成功了。
至此,完成了用户信息修改的前后端。
这节基于 multer 实现了头像上传。
通过自定义 storage 实现了文件路径的自定义,并且限制了文件的大小和类型。
然后把上传的目录作为静态文件目录,这样可以直接访问。
这样,头像上传功能就完成了。
前面写了用户端的一些页面,这节继续来写管理端的。
涉及到这些页面:
这节来写前两个。
先新建个 react 项目:
npm create vite@latest meeting_room_booking_system_frontend_admin -- --template react-ts进入项目目录,把开发服务跑起来:
npm run dev浏览器访问 http://localhost:5173 可以看到这个界面:
就说明 react 项目成功跑起来了。
然后添加 router:
npm install --save react-router-dom在 main.tsx 加上路由的配置:
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider, createBrowserRouter, Link, Outlet } from 'react-router-dom';
function Index() {
return <div>index<Outlet></Outlet></div>
}
function ErrorPage() {
return <div>Error Page</div>
}
function UserManage() {
return <div>user manage</div>
}
function Login() {
return <div>login</div>
}
const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: 'user_manage',
element: <UserManage/>
}
]
},
{
path: "login",
element: <Login />,
}
];
const router = createBrowserRouter(routes);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<RouterProvider router={router}/>);配置了 4 个路由:
访问 /login 的时候,渲染 Login 组件。
访问 / 的时候,渲染 Index 组件。
访问 /user_manage 的时候,渲染 / 和 user_manage 的二级路由,也就是 Index + UserManage 组件。
以及出错的时候,渲染 ErrorPage 组件。
测试下:
都没问题。
把 src 目录下其余文件去掉:
然后创建 4 个组件:
src/pages/Login/Login.tsx
export function Login() {
return <div>login</div>
}src/pages/Index/Index.tsx
import { Outlet } from "react-router-dom";
export function Index() {
return <div>Index<Outlet></Outlet></div>
}src/pages/UserManage/UserManage.tsx
export function UserManage() {
return <div>UserManage</div>
}src/pages/ErrorPage/ErrorPage.tsx
export function ErrorPage() {
return <div>Error Page</div>
}改下 main.tsx 配置对应的路由:
import ReactDOM from 'react-dom/client';
import './index.css';
import { RouterProvider, createBrowserRouter, Link, Outlet } from 'react-router-dom';
import { Index } from './pages/Index/Index';
import { ErrorPage } from './pages/ErrorPage/ErrorPage';
import { UserManage } from './pages/UserManage/UserManage';
import { Login } from './pages/Login/Login';
const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: 'user_manage',
element: <UserManage/>
}
]
},
{
path: "login",
element: <Login />,
}
];
const router = createBrowserRouter(routes);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<RouterProvider router={router}/>);测试下:
都没啥问题。
然后来写 Login 页面:
引入 Ant Design 组件库:
npm install antd --save在 Login 组件引入 DatePicker 组件:
import { DatePicker } from "antd";
export function Login() {
return <div><DatePicker/></div>
}没啥问题,说明 antd 引入成功了。
然后把登录页面写一下:
import { Button, Checkbox, Form, Input } from 'antd';
import './login.css';
import { useCallback } from 'react';
interface LoginUser {
username: string;
password: string;
}
const layout1 = {
labelCol: { span: 4 },
wrapperCol: { span: 20 }
}
export function Login() {
const onFinish = useCallback((values: LoginUser) => {
console.log(values);
}, []);
return <div id="login-container">
<h1>会议室预订系统</h1>
<Form
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="用户名"
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item label=" ">
<Button className='btn' type="primary" htmlType="submit">
登录
</Button>
</Form.Item>
</Form>
</div>
}这里和用户端差不多.
login.css 如下:
#login-container {
width: 400px;
margin: 100px auto 0 auto;
text-align: center;
}
#login-container .links {
display: flex;
justify-content: space-between;
}
#login-container .btn {
width: 100%;
}访问 /login,可以看到现在的登录页面:
然后看一下接口文档 http://localhost:3005/api-doc
传入用户名、密码、返回用户信息和 token。
在 postman 里测试下登录接口:
然后在点击登录按钮之后,用 axios 调用它:
安装 axios:
npm install axios在前端项目创建个 src/interfaces/interfaces.ts
import axios from "axios";
const axiosInstance = axios.create({
baseURL: 'http://localhost:3005/',
timeout: 3000
});
export async function login(username: string, password: string) {
return await axiosInstance.post('/user/admin/login', {
username, password
});
}在这里集中管理接口。
然后 onFinish 里调用:
const navigate = useNavigate();
const onFinish = useCallback(async (values: LoginUser) => {
const res = await login(values.username, values.password);
const { code, message: msg, data} = res.data;
if(res.status === 201 || res.status === 200) {
message.success('登录成功');
localStorage.setItem('access_token', data.accessToken);
localStorage.setItem('refresh_token', data.refreshToken);
localStorage.setItem('user_info', JSON.stringify(data.userInfo));
setTimeout(() => {
navigate('/');
}, 1000);
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);这里和用户端一模一样。
登录下:
提示 400 错误没处理。
因为接口返回 400 的时候,axios 会抛异常:
加一个响应的 interceptor,返回 error.response 而不是 Promise.reject(error.response)
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
return error.response;
}
);再测试下:
当用户不存在时:
当密码错误时:
登录成功时:
都没啥问题。
这样,管理员登录的前后端功能就都完成了。
然后是用户管理的页面:
修改下 Index.tsx
import { UserOutlined } from "@ant-design/icons";
import { Outlet } from "react-router-dom";
import './index.css';
export function Index() {
return <div id="index-container">
<div className="header">
<h1>会议室预订系统-后台管理</h1>
<UserOutlined className="icon"/>
</div>
<div className="body">
<Outlet></Outlet>
</div>
</div>
}这里用到了 antd 的 icon 组件,需要安装用到的包:
npm install @ant-design/icons --savecss 如下:
#index-container{
height: 100vh;
display: flex;
flex-direction: column;
}
#index-container .header{
height: 80px;
border-bottom: 1px solid #aaa;
line-height: 80px;
display: flex;
justify-content: space-between;
padding: 0 20px;
}
#index-container h1{
margin: 0;
}
#index-container .icon {
font-size: 40px;
margin-top: 20px;
}
#index-container .body{
flex: 1;
}测试下:
没啥问题。
不知道同学们有没有发现,其实这个页面应该是三级路由:
因为左边这部分也是要多个页面共用的。
改一下路由配置:
const routes = [
{
path: "/",
element: <Index></Index>,
errorElement: <ErrorPage />,
children: [
{
path: "/",
element: <Menu></Menu>,
children: [
{
path: 'user_manage',
element: <UserManage/>
}
]
}
]
},
{
path: "login",
element: <Login />,
}
];添加 src/pages/Menu/Menu.tsx
import { Outlet } from "react-router-dom";
export function Menu() {
return <div>
menu <Outlet></Outlet>
</div>
}渲染出来是这样的:
来写一下 Menu 组件:
import { Outlet } from "react-router-dom";
import { Menu as AntdMenu, MenuProps } from 'antd';
import './menu.css';
const items: MenuProps['items'] = [
{
key: '1',
label: "会议室管理"
},
{
key: '2',
label: "预订管理"
},
{
key: '3',
label: "用户管理"
},
{
key: '4',
label: "统计"
}
];
export function Menu() {
return <div id="menu-container">
<div className="menu-area">
<AntdMenu
defaultSelectedKeys={['3']}
items={items}
/>
</div>
<div className="content-area">
<Outlet></Outlet>
</div>
</div>
}menu.css 如下:
#menu-container {
display: flex;
flex-direction: row;
}
#menu-container .menu-area {
width: 200px;
}渲染出来是这样的:
然后来写 UserManage 组件:
可以分为 2 部分,上面的搜索表单、下面的结果表格。
来写一下:
import { Button, Form, Input, Table } from "antd";
import { useCallback } from "react";
import './UserManage.css';
interface SearchUser {
username: string;
nickName: string;
email: string;
}
export function UserManage() {
const searchUser = useCallback(async (values: SearchUser) => {
console.log(values);
}, []);
return <div id="userManage-container">
<div className="userManage-form">
<Form
onFinish={searchUser}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>
<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[
{ type: "email", message: '请输入合法邮箱地址!'}
]}>
<Input/>
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table">
</div>
</div>
}UserManage.css
#userManage-container {
padding: 20px;
}先把 form 部分写完。
测试下:
然后再写 table 部分:
import { Button, Form, Input, Table } from "antd";
import { useCallback } from "react";
import './UserManage.css';
import { ColumnsType } from "antd/es/table";
interface SearchUser {
username: string;
nickName: string;
email: string;
}
interface UserSearchResult {
username: string;
nickName: string;
email: string;
headPic: string;
createTime: Date;
}
const columns: ColumnsType<UserSearchResult> = [
{
title: '用户名',
dataIndex: 'username'
},
{
title: '头像',
dataIndex: 'headPic'
},
{
title: '昵称',
dataIndex: 'nickName'
},
{
title: '邮箱',
dataIndex: 'email'
},
{
title: '注册时间',
dataIndex: 'createTime'
}
];
const data = [
{
key: '1',
username: 'xx',
headPic: 'xxx.png',
nickName: 'xxx',
email: 'xx@xx.com',
createTime: new Date()
},
{
key: '12',
username: 'yy',
headPic: 'yy.png',
nickName: 'yyy',
email: 'yy@yy.com',
createTime: new Date()
}
]
export function UserManage() {
const searchUser = useCallback(async (values: SearchUser) => {
console.log(values);
}, []);
return <div id="userManage-container">
<div className="userManage-form">
<Form
onFinish={searchUser}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>
<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[
{ type: "email", message: '请输入合法邮箱地址!'}
]}>
<Input/>
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table">
<Table columns={columns} dataSource={data} pagination={ {
pageSize: 10
}}/>
</div>
</div>
}渲染出来是这样的:
然后调用下搜索接口。
看下接口文档:
在 postman 里调用下:
这个接口是需要登录的。
先登录一下:
带上 access_token 再访问: 返回了 8 条数据。
然后在页面里调用下:
先把之前写的 axios 的 interceptors 自动添加 authorization 的 header,自动 refresh token 的逻辑拿过来:
axiosInstance.interceptors.request.use(function (config) {
const accessToken = localStorage.getItem('access_token');
if(accessToken) {
config.headers.authorization = 'Bearer ' + accessToken;
}
return config;
})
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { data, config } = error.response;
if (data.code === 401 && !config.url.includes('/user/admin/refresh')) {
const res = await refreshToken();
if(res.status === 200 || res.status === 201) {
return axiosInstance(config);
} else {
message.error(res.data);
setTimeout(() => {
window.location.href = '/login';
}, 1500);
}
} else {
return error.response;
}
}
)
async function refreshToken() {
const res = await axiosInstance.get('/user/admin/refresh', {
params: {
refresh_token: localStorage.getItem('refresh_token')
}
});
localStorage.setItem('access_token', res.data.access_token);
localStorage.setItem('refresh_token', res.data.refresh_token);
return res;
}然后添加一个接口:
export async function userSearch(username: string, nickName: string, email: string, pageNo: number, pageSize: number) {
return await axiosInstance.get('/user/list', {
params: {
username,
nickName,
email,
pageNo,
pageSize
}
});
}在页面调用下:
const [pageNo, setPageNo] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [userResult, setUserResult] = useState<UserSearchResult[]>();
const searchUser = useCallback(async (values: SearchUser) => {
const res = await userSearch(values.username,values.nickName, values.email, pageNo, pageSize);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
setUserResult(data.users.map((item: UserSearchResult) => {
return {
key: item.username,
...item
}
}))
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);使用 useState 创建 pageNo、pageSize、userResult 这三个状态。
请求接口,成功后把数据设置到 userResult。
然后修改下 table 的 dataSource:
测试下:
先登录。
然后访问 http://localhost:5173/user_manage
搜索接口对接成功。
然后再对接下分页:
<Table columns={columns} dataSource={userResult} pagination={ {
current: pageNo,
pageSize: pageSize,
onChange: changePage
}}/>设置 pageNo 和 pageSize,并监听 onChange 事件
useEffect(() => {
searchUser({
username: '',
email: '',
nickName: ''
});
}, [pageNo, pageSize]);
const changePage = useCallback(function(pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);分页设置改变的时候,设置 pageNo 和 pageSize。
并且 useEffect 监听这两个 state,在变化的时候,重新发送请求。
这样,刚进入页面的时候,就会触发一次渲染,并且在分页设置改变时也会触发:
然后修改下 headPic,改为图片:
const columns: ColumnsType<UserSearchResult> = [
{
title: '用户名',
dataIndex: 'username'
},
{
title: '头像',
dataIndex: 'headPic',
render: value => {
return value ? <Image
width={50}
src={`http://localhost:3005/${value}`}
/> : '';
}
},
{
title: '昵称',
dataIndex: 'nickName'
},
{
title: '邮箱',
dataIndex: 'email'
},
{
title: '注册时间',
dataIndex: 'createTime'
}
];这里用的是 antd 的 Image 组件,有预览的功能:
原型图还有个冻结功能:
看下接口文档:
很简单,就是个 get 接口。
在表格里加一列:
{
title: '操作',
render: (_, record) => (
<a href="#" onClick={() => {freezeUser(record.id)}}>冻结</a>
)
}这里用到了 id,在类型里加一下:
然后在 interfaces.ts 添加这个接口:
export async function freeze(id: number) {
return await axiosInstance.get('/user/freeze', {
params: {
id
}
});
}在组件里创建 freezeUser 方法:
async function freezeUser(id: number) {
const res = await freeze(id);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
message.success('冻结成功');
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}测试下:
然后还要把冻结状态显示出来:
这部分数据是返回了的:
需要添加一列:
{
title: '状态',
dataIndex: 'isFrozen',
render: (_, record) => (
record.isFrozen ? <Badge status="success">已冻结</Badge> : ''
)
},在类型部分也要添加下:
测试下:
冻结之后,刷新页面,会显示已冻结。
这里在冻结之后自动刷新下。
这需要把逻辑移到组件内:
把 columns 移到组件内,用 useMemo 包裹,这样只会创建一次:
freeezeUser 也是:
const freezeUser = useCallback(async (id: number) => {
const res = await freeze(id);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
message.success('冻结成功');
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);添加一个 num 的 state,冻结之后设置一个随机值:
把它添加到 useEffect 的依赖里,这样就能触发重新搜索:
测试下:
但其实现在这个重新搜索有问题:
我搜索之后再冻结,然后刷新就丢失了搜索条件了。
这里需要搜索的时候带上当前的条件:
用 useForm 拿到 form 的 api:
然后在搜索的时候拿到最新的表单值:
useEffect(() => {
searchUser({
username: form.getFieldValue('username'),
email: form.getFieldValue('email'),
nickName: form.getFieldValue('nickName')
});
}, [pageNo, pageSize, num]);这样就可以了:
这样,用户管理页面就写完了。
全部代码如下:
import { Badge, Button, Form, Image, Input, Table, message } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import './UserManage.css';
import { ColumnsType } from "antd/es/table";
import { freeze, userSearch } from "../../interfaces/interfaces";
import { useForm } from "antd/es/form/Form";
interface SearchUser {
username: string;
nickName: string;
email: string;
}
interface UserSearchResult {
id: number,
username: string;
nickName: string;
email: string;
headPic: string;
createTime: Date;
isFrozen: boolean;
}
export function UserManage() {
const [pageNo, setPageNo] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [userResult, setUserResult] = useState<UserSearchResult[]>();
const [num, setNum] = useState(0);
const columns: ColumnsType<UserSearchResult> = useMemo(() => [
{
title: '用户名',
dataIndex: 'username'
},
{
title: '头像',
dataIndex: 'headPic',
render: value => {
return value ? <Image
width={50}
src={`http://localhost:3005/${value}`}
/> : '';
}
},
{
title: '昵称',
dataIndex: 'nickName'
},
{
title: '邮箱',
dataIndex: 'email'
},
{
title: '注册时间',
dataIndex: 'createTime'
},
{
title: '状态',
dataIndex: 'isFrozen',
render: (_, record) => (
record.isFrozen ? <Badge status="success">已冻结</Badge> : ''
)
},
{
title: '操作',
render: (_, record) => (
<a href="#" onClick={() => {freezeUser(record.id)}}>冻结</a>
)
}
], []);
const freezeUser = useCallback(async (id: number) => {
const res = await freeze(id);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
message.success('冻结成功');
setNum(Math.random())
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);
const searchUser = useCallback(async (values: SearchUser) => {
const res = await userSearch(values.username,values.nickName, values.email, pageNo, pageSize);
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
setUserResult(data.users.map((item: UserSearchResult) => {
return {
key: item.username,
...item
}
}))
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);
const [form ] = useForm();
useEffect(() => {
searchUser({
username: form.getFieldValue('username'),
email: form.getFieldValue('email'),
nickName: form.getFieldValue('nickName')
});
}, [pageNo, pageSize, num]);
const changePage = useCallback(function(pageNo: number, pageSize: number) {
setPageNo(pageNo);
setPageSize(pageSize);
}, []);
return <div id="userManage-container">
<div className="userManage-form">
<Form
form={form}
onFinish={searchUser}
name="search"
layout='inline'
colon={false}
>
<Form.Item label="用户名" name="username">
<Input />
</Form.Item>
<Form.Item label="昵称" name="nickName">
<Input />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[
{ type: "email", message: '请输入合法邮箱地址!'}
]}>
<Input/>
</Form.Item>
<Form.Item label=" ">
<Button type="primary" htmlType="submit">
搜索用户
</Button>
</Form.Item>
</Form>
</div>
<div className="userManage-table">
<Table columns={columns} dataSource={userResult} pagination={ {
current: pageNo,
pageSize: pageSize,
onChange: changePage
}}/>
</div>
</div>
}案例代码上传了。
这节实现了管理端的登录和用户管理页面。
和用户端的一样,都是通过 axios interceptor 自动添加 header 和自动 refresh token。
这里涉及到三级路由,第一级展示上面的 header,第二级展示左侧的 menu,第三级才是具体的页面。
使用 table 组件来渲染列表,通过 useEffect 在 pageNo、pageSize 改变的时候自动重发请求。
这样,这两个页面的前后端代码都完成了。
这节,来写管理端的这两个页面:
很明显,它们是和这几个管理页面平级的,点击用户图标的时候打开:
所以,在它平级添加个路由:
{
path: "/user",
element: <ModifyMenu></ModifyMenu>,
children: [
{
path: 'info_modify',
element: <InfoModify/>
},
{
path: 'password_modify',
element: <PasswordModify/>
},
]
},然后创建这几个对应的组件:
src/pages/ModifyMenu/ModifyMenu.stx
import { Outlet } from "react-router-dom";
import { Menu as AntdMenu, MenuProps } from 'antd';
import './menu.css';
const items: MenuProps['items'] = [
{
key: '1',
label: "信息修改"
},
{
key: '2',
label: "密码修改"
}
];
export function ModifyMenu() {
return <div id="menu-container">
<div className="menu-area">
<AntdMenu
defaultSelectedKeys={['1']}
items={items}
/>
</div>
<div className="content-area">
<Outlet></Outlet>
</div>
</div>
}用到的 menu.css:
#menu-container {
display: flex;
flex-direction: row;
}
#menu-container .menu-area {
width: 200px;
}然后是
src/pages/InfoModify/InfoModify.tsx
export function InfoModify() {
return <div>InfoModify</div>
}src/pages/PasswordModify/PasswordModify.tsx
export function PasswordModify() {
return <div>PasswordModify</div>
}在 index.tsx 引入,然后跑一下:
没啥问题。
但是现在点击菜单是没反应的,给它加上 click 事件。
const handleMenuItemClick: MenuClickEventHandler = (info) => {
if(info.key === '1') {
router.navigate('/user/info_modify')
} else {
router.navigate('/user/password_modify')
}
}这里用到的 router 要在 index.tsx 里导出:
测试下:
点击菜单可以切换路由了。
但现在有个问题,页面一刷新,选中的菜单项就变了:
需要根据当前路由来决定选中哪个:
这里用到了 react-router 的 useLocation 的 hook 来拿到当前地址:
location.pathname === '/user/info_modify' ? ['1'] : ['2']这样,刷新之后选中的菜单项也是对的:
改下 Index 组件,添加两个链接:
<div className="header">
<Link to="/" className="sys_name">
<h1>会议室预订系统-后台管理</h1>
</Link>
<Link to="/user/info_modify">
<UserOutlined className="icon"/>
</Link>
</div>并且添加它的样式:
#index-container .sys_name {
text-decoration: none;
color: #000;
}这样就可以方便跳转对应的路由了:
然后,来实现信息修改页面:
之前用户端修改信息页面也是类似的,直接拿过来就行:
import { Button, Form, Input, message } from 'antd';
import { useForm } from 'antd/es/form/Form';
import { useCallback, useEffect, useState } from 'react';
import './info_modify.css';
import { useNavigate } from 'react-router-dom';
import { HeadPicUpload } from './HeadPicUpload';
export interface UserInfo {
username: string;
headPic: string;
nickName: string;
email: string;
captcha: string;
}
const layout1 = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
export function InfoModify() {
const [form] = useForm();
const navigate = useNavigate();
const onFinish = useCallback(async (values: UserInfo) => {
}, []);
const sendCaptcha = useCallback(async function () {
}, []);
useEffect(() => {
async function query() {
}
query();
}, []);
return <div id="updateInfo-container">
<Form
form={form}
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="头像"
name="headPic"
rules={[
{ required: true, message: '请输入头像!' },
]}
shouldUpdate
>
<HeadPicUpload></HeadPicUpload>
</Form.Item>
<Form.Item
label="昵称"
name="nickName"
rules={[
{ required: true, message: '请输入昵称!' },
]}
>
<Input />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱!' },
{ type: "email", message: '请输入合法邮箱地址!'}
]}
>
<Input disabled/>
</Form.Item>
<div className='captcha-wrapper'>
<Form.Item
label="验证码"
name="captcha"
rules={[{ required: true, message: '请输入验证码!' }]}
>
<Input />
</Form.Item>
<Button type="primary" onClick={sendCaptcha}>发送验证码</Button>
</div>
<Form.Item
{...layout1}
label=" "
>
<Button className='btn' type="primary" htmlType="submit">
修改
</Button>
</Form.Item>
</Form>
</div>
}css 部分如下:
#updateInfo-container {
width: 400px;
margin: 50px auto 0 auto;
text-align: center;
}
#updateInfo-container .btn {
width: 100%;
}
#updateInfo-container .captcha-wrapper {
display: flex;
justify-content: flex-end;
}用到的 HeadPicUpload 组件如下:
import { InboxOutlined } from "@ant-design/icons";
import { message } from "antd";
import Dragger, { DraggerProps } from "antd/es/upload/Dragger";
interface HeadPicUploadProps {
value?: string;
onChange?: Function
}
let onChange: Function;
const props: DraggerProps = {
name: 'file',
action: 'http://localhost:3005/user/upload',
onChange(info) {
const { status } = info.file;
if (status === 'done') {
onChange(info.file.response.data);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const dragger = <Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
export function HeadPicUpload(props: HeadPicUploadProps) {
onChange = props.onChange!
return props?.value ? <div>
<img src={'http://localhost:3005/' + props.value} alt="头像" width="100" height="100"/>
{dragger}
</div>: <div>
{dragger}
</div>
}这些都是前面写过一遍的。
渲染出来是这样的: 上传功能也是可用的:
然后还要加上回显接口、发送验证码接口、更新接口。
在 interfaces.ts 加上这三个接口:
export async function getUserInfo() {
return await axiosInstance.get('/user/info');
}
export async function updateInfo(data: UserInfo) {
return await axiosInstance.post('/user/admin/update', data);
}
export async function updateUserInfoCaptcha() {
return await axiosInstance.get('/user/update/captcha');
}然后先调用下回显接口:
async function query() {
const res = await getUserInfo();
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
form.setFieldValue('headPic', data.headPic);
form.setFieldValue('nickName', data.nickName);
form.setFieldValue('email', data.email);
}
}可以看到,正确回显了数据。
然后是发送验证码接口:
const sendCaptcha = useCallback(async function () {
const res = await updateUserInfoCaptcha();
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);不过现在的邮箱地址不是真实的,手动去数据库里改一下:
改完点击 apply。
然后需要重新登录一遍,因为现在后端会直接从 jwt 里取邮箱地址,重新登录才会更新。
邮箱收到了验证码:
然后加上更新用户信息的接口:
const onFinish = useCallback(async (values: UserInfo) => {
const res = await updateInfo(values);
if(res.status === 201 || res.status === 200) {
const { message: msg, data} = res.data;
if(msg === 'success') {
message.success('用户信息更新成功');
} else {
message.error(data);
}
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);上传头像,点击发送验证码,填入收到的验证码,点击修改:
修改成功后,刷新页面,可以看到依然是修改后的数据,就代表修改成功了:
接下来是密码修改页面:
代码如下:
import { Button, Form, Input, message } from 'antd';
import { useForm } from 'antd/es/form/Form';
import './password_modify.css';
import { useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
export interface UpdatePassword {
email: string;
captcha: string;
password: string;
confirmPassword: string;
}
const layout1 = {
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}
const layout2 = {
labelCol: { span: 0 },
wrapperCol: { span: 24 }
}
export function PasswordModify() {
const [form] = useForm();
const navigate = useNavigate();
const onFinish = useCallback(async (values: UpdatePassword) => {
}, []);
const sendCaptcha = useCallback(async function () {
}, []);
return <div id="updatePassword-container">
<Form
form={form}
{...layout1}
onFinish={onFinish}
colon={false}
autoComplete="off"
>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
rules={[{ required: true, message: '请输入确认密码!' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入邮箱!' },
{ type: "email", message: '请输入合法邮箱地址!'}
]}
>
<Input />
</Form.Item>
<div className='captcha-wrapper'>
<Form.Item
label="验证码"
name="captcha"
rules={[{ required: true, message: '请输入验证码!' }]}
>
<Input />
</Form.Item>
<Button type="primary" onClick={sendCaptcha}>发送验证码</Button>
</div>
<Form.Item
{...layout1}
label=" "
>
<Button className='btn' type="primary" htmlType="submit">
修改密码
</Button>
</Form.Item>
</Form>
</div>
}用到的 password_modify.css:
#updatePassword-container {
width: 400px;
margin: 40px auto;
text-align: center;
}
#updatePassword-container .btn {
width: 100%;
}
#updatePassword-container .captcha-wrapper {
display: flex;
justify-content: flex-end;
}渲染出来是这样的:
然后在 interfaces.ts 加上用到的发送验证码、修改密码这两个接口:
export async function updatePasswordCaptcha(email: string) {
return await axiosInstance.get('/user/update_password/captcha', {
params: {
address: email
}
});
}
export async function updatePassword(data: UpdatePassword) {
return await axiosInstance.post('/user/admin/update_password', data);
}然后先在页面调用下回显接口:
useEffect(() => {
async function query() {
const res = await getUserInfo();
const { data } = res.data;
if(res.status === 201 || res.status === 200) {
form.setFieldValue('username', data.username);
form.setFieldValue('email', data.email);
}
}
query();
}, []);并把邮箱 Input 设置为 disabled
这样邮箱地址就会回显,并且只读:
然后调用发送验证码接口:
const sendCaptcha = useCallback(async function () {
const address = form.getFieldValue('email');
if(!address) {
return message.error('邮箱地址为空');
}
const res = await updatePasswordCaptcha(address);
if(res.status === 201 || res.status === 200) {
message.success(res.data.data);
} else {
message.error('系统繁忙,请稍后再试');
}
}, []);点击发送验证码:
邮箱收到了对应的验证码:
然后加上修改密码接口:
const onFinish = useCallback(async (values: UpdatePassword) => {
if(values.password !== values.confirmPassword) {
return message.error('两次密码不一致');
}
const res = await updatePassword({
...values,
username: form.getFieldValue('username')
});
const { message: msg, data} = res.data;
if(res.status === 201 || res.status === 200) {
message.success('密码修改成功');
} else {
message.error(data || '系统繁忙,请稍后再试');
}
}, []);提示密码修改成功:
可以去登录页面,用老密码试试:
再用新密码试试:
这样,管理端的用户相关的页面就完成了。
案例代码上传了。
这节实现了管理端的用户信息修改和密码修改的页面。
首先添加了一个和管理页面平级的二级路由,然后添加了两个组件。
这两个页面都是表单,涉及到回显数据、发送验证码、上传文件、更新接口。
这也是管理系统的常见功能。
下节开始,就开始写会议室管理的功能了。