【SpringBoot+Vue】博客项目开发二:用户登录注册模块
后端用户模块开发
制定参数交互约束
当前,我们使用MybatisX工具快速生成的代码中,包含了一个实体类,这个类中包含我们数据表中的所有字段。
但因为有些字段,是不应该返回到前端的,比如用户密码,或者前端传递参数时,有一些字段我们根本不需要,比如登录时只需要账号密码,其他字段用不上。所以在业务模块新建 model 目录,专门存放用于前后端交换的数据模型,并创建dto和vo目录
- dto:用于接收前端请求参数的类
- vo:返回给前端的封装类
如用户功能的登录和注册,可以针对需要传入的参数,新建两个DTO
- UserLoginRequest:接收用户登录时所需传入的请求参数
@Data
public class UserLoginRequest implements Serializable {
private static final long serialVersionUID = 3132234234234234234L;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
}
- UserRegisterRequest:接收用户注册时所需传入的请求参数
@Data
public class UserRegisterRequest implements Serializable {
private static final long serialVersionUID = 3132234234234234234L;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
/**
* 校验密码
*/
private String checkPassword;
}
而我们要返回给前端指定用户信息时,只需要新建一个用户信息封装类,将要传给前端哪些字段写到该类中
- UserInfoVO
@Data
public class UserInfoVO implements Serializable {
/**
* 用户表主键
*/
private Long userId;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户邮件
*/
private String userEmail;
/**
* 用户头像
*/
private String userAvatar;
private static final long serialVersionUID = 1L;
}
完善用户信息表
用户信息表新增角色、简介、昵称等字段
# 用户信息表新增角色、简介、昵称等字段
ALTER TABLE user_info
ADD COLUMN `user_role` varchar(256) NOT NULL DEFAULT 'user' COMMENT '用户角色:USER/ADMIN',
ADD COLUMN `user_profile` varchar(256) NULL DEFAULT '' COMMENT '用户简介',
ADD COLUMN `user_name` varchar(256) NOT NULL DEFAULT '无名' COMMENT '用户昵称';
# 创建基于用户名称的索引
CREATE INDEX idx_user_name ON user_info (user_name);
此处新增字段后,你可以将原来生成的文件删除,重新用MybatisX再生成一次,如果还没改过生成文件的代码,是可以这么操作的。这里手动添加
- 修改domain/UserInfo
/**
* 用户信息表
*
* @TableName user_info
*/
@TableName(value = "user_info")
@Data
public class UserInfo implements Serializable {
/**
* 用户表主键
*/
@TableId(type = IdType.AUTO)
private Long userId;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
/**
* 用户昵称
*/
private String userName;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户角色(USER/ADMIN)
*/
private String userRole;
/**
* 用户邮件
*/
private String userEmail;
/**
* 用户头像
*/
private String userAvatar;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
/**
* 是否删除
*/
private Integer isDelete;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
- 修改resources/mapper/UserInfoMapper.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cyfy.cyblogsbackend.business.mapper.UserInfoMapper">
<resultMap id="BaseResultMap" type="com.cyfy.cyblogsbackend.business.domain.UserInfo">
<id property="userId" column="user_id" jdbcType="BIGINT"/>
<result property="userAccount" column="user_account" jdbcType="VARCHAR"/>
<result property="userPassword" column="user_password" jdbcType="VARCHAR"/>
<result property="userName" column="user_name" jdbcType="VARCHAR"/>
<result property="userProfile" column="user_profile" jdbcType="VARCHAR"/>
<result property="userRole" column="user_role" jdbcType="VARCHAR"/>
<result property="userEmail" column="user_email" jdbcType="VARCHAR"/>
<result property="userAvatar" column="user_avatar" jdbcType="VARCHAR"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
<result property="isDelete" column="is_delete" jdbcType="TINYINT"/>
</resultMap>
<sql id="Base_Column_List">
user_id
,user_account,user_password,
user_name,user_profile,user_role,
user_email,user_avatar,create_time,
update_time,is_delete
</sql>
</mapper>
库表设计很难做到一次就设计好后续不用再修改,可能随着功能的不断开发,会不停更新(增删改)库表字段,所以还是熟悉一下改变库表后,需要改哪些文件。
登录注册接口实现
对应登录用户信息,我们可以编写一个登录用户信息封装类,用于返回脱敏后的当前登录用户信息
- LoginUserVO
@Data
public class LoginUserVO implements Serializable {
/**
* 用户表主键
*/
private Long userId;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户昵称
*/
private String userName;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户角色(USER/ADMIN)
*/
private String userRole;
/**
* 用户邮件
*/
private String userEmail;
/**
* 用户头像
*/
private String userAvatar;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
private static final long serialVersionUID = 1L;
}
- UserInfoService:添加接口方法
public interface UserInfoService extends IService<UserInfo> {
/**
* 用户注册
* @param userRegisterRequest 用户注册请求参数
* @return 新注册用户id
*/
long userRegister(UserRegisterRequest userRegisterRequest);
/**
* 用户登录
* @param userLoginRequest 用户登录请求参数
* @param request
* @return 存有脱敏后的用户信息的token令牌
*/
String userLogin(UserLoginRequest userLoginRequest, HttpServletRequest request);
/**
* 用户信息脱敏处理
* @param userInfo
* @return
*/
LoginUserVO getLoginUserVO(UserInfo userInfo);
}
- UserInfoServiceImpl:接口方法实现
@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
implements UserInfoService {
@Resource
private JwtUtil jwtUtil;
/**
* 用户注册
*
* @param userRegisterRequest 用户注册请求参数
* @return 新注册用户id
*/
@Override
public long userRegister(UserRegisterRequest userRegisterRequest) {
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
// 校验
if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userAccount.length() > 25){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过长");
}
if (userPassword.length() < 8){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
if (userPassword.length() > 30){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过长");
}
if (!userPassword.equals(checkPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
// 检查账号是否已被注册
QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_account", userAccount);
long count = this.count(queryWrapper);
if(count > 0){
throw new BusinessException(ErrorCode.PARAMS_ERROR,"注册账号已存在");
}
// 密码加密
String encryptPassword = EncipherUtils.hashPsd(userPassword);
// 插入数据
UserInfo userInfo = new UserInfo();
userInfo.setUserAccount(userAccount);
userInfo.setUserPassword(encryptPassword);
userInfo.setUserName("临时名");
userInfo.setUserRole("USER");
boolean saveResult = this.save(userInfo);
if (!saveResult){
throw new BusinessException(ErrorCode.PARAMS_ERROR,"注册失败,数据库错误");
}
return userInfo.getUserId();
}
/**
* 用户登录
*
* @param userLoginRequest 用户登录请求参数
* @param request
* @return 存有脱敏后的用户信息的token令牌
*/
@Override
public String userLogin(UserLoginRequest userLoginRequest, HttpServletRequest request) {
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
// 校验
if (StrUtil.hasBlank(userAccount, userPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4
|| userPassword.length() < 8
|| userAccount.length() > 25
|| userPassword.length() > 30){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号或密码错误");
}
// 查询用户是否存在
QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_account", userAccount);
UserInfo user = this.getOne(queryWrapper);
// 用户不存在
if ( user == null ){
throw new BusinessException(ErrorCode.ACCOUNT_NOT_EXIST);
}
// 校验密码
if (!EncipherUtils.checkPsd(userPassword, user.getUserPassword())){
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
}
// 记录用户的登录态
request.getSession().setAttribute("user_login", user.getUserId());
// 转换成封装类并存入
LoginUserVO loginUserVO = this.getLoginUserVO(user);
return jwtUtil.createToken(loginUserVO);
}
/**
* 用户信息脱敏处理
*
* @param userInfo
* @return
*/
@Override
public LoginUserVO getLoginUserVO(UserInfo userInfo) {
if (userInfo == null){
return null;
}
LoginUserVO loginUserVO = new LoginUserVO();
BeanUtils.copyProperties(userInfo, loginUserVO);
return loginUserVO;
}
}
- UserController:移除之前的测试方法,编写登录注册接口
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserInfoService userInfoService;
@PostMapping("/login")
public BaseResponse<String> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request){
ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
String loginUserToken = userInfoService.userLogin(userLoginRequest, request);
return ResultUtils.success(loginUserToken);
}
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest){
ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);
long result = userInfoService.userRegister(userRegisterRequest);
return ResultUtils.success(result);
}
}
这里我们之前设置表的时候,将用户邮件和头像设置成NOT NULL
,但我们没有设置值,为了方便测试,这里修改表属性。
# 修改用户邮件和用户头像字段为可空
ALTER TABLE user_info
MODIFY COLUMN user_email varchar(256) NULL COMMENT '用户邮件',
MODIFY COLUMN user_avatar varchar(256) NULL COMMENT '用户头像';
测试接口,登录注册接口的输入参数受到约束,不再先之前那样可以输入所有UserInfo字段
因为我们用户信息已经存到token令牌中并返回给前端,后续前端需要登录用户数据,只需在token中获取即可。
你也可以不用token,而是直接将脱敏后的用户信息存到session中
获取当前登录用户
- UserInfoService:增加获取当前用户信息方法
/**
* 获取当前登录用户
* @param request
* @return
*/
LoginUserVO getCurrentLoginUser(HttpServletRequest request);
- UserInfoServiceImpl:实现getCurrentLoginUser方法
/**
* 获取当前登录用户
*
* @param request
* @return
*/
@Override
public LoginUserVO getCurrentLoginUser( HttpServletRequest request) {
// 从session中获取用户ID
Object userIdObject = request.getSession().getAttribute("user_login");
Long userId = (Long) userIdObject;
// 如果当前连接的session中不存在用户id
if (userId == null){
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "未登录");
}
// 获取前端请求头中的X-Token数据
String token = request.getHeader("X-Token");
if (StrUtil.isBlank(token)){
throw new BusinessException(ErrorCode.TOKEN_ERROR);
}
LoginUserVO currentUser;
try{
// 将token转换成对象
currentUser = jwtUtil.parseToken(token, LoginUserVO.class);
}catch (ExpiredJwtException e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR, "令牌已过期,请重新登录");
} catch (MalformedJwtException e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR, "无效令牌,请重新登录");
} catch (Exception e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR);
}
// 判断当前session中存放的用户id与token中的用户id是否一致
if (!currentUser.getUserId().equals(userId)){
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "非法登录");
}
return currentUser;
}
- ErrorCode:状态码枚举类增加令牌相关枚举值
TOKEN_ERROR(40020, "无效令牌")
- UserController:增加获取当前登录用户接口
@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request){
LoginUserVO result = userInfoService.getCurrentLoginUser(request);
return ResultUtils.success(result);
}
测试
需要先登录,获取令牌后,在全局参数设置中添加X-Token,再去调用获取接口
此时,如果获取了token后,重启服务器,session会被清掉,如果再想调用该接口,只通过token去获得用户信息是行不通的
小优化
common 模块新建constant目录,用于存放开发中用到的常量。
- 新建UserConstant,用于记录用户相关常量
/**
* 用户相关常量
*/
public interface UserConstant {
/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "user_login";
/**
* Token令牌存储键
*/
String TOKEN_KEY = "X-Token";
}
替换当前代码中,用到登录态键和Token令牌存储键的地方,改成使用常量代替,避免后续使用该常量时,编写错误
最终UserInfoServiceImpl代码
package com.cyfy.cyblogsbackend.business.service.impl;
import java.util.Date;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.cyfy.cyblogsbackend.business.domain.UserInfo;
import com.cyfy.cyblogsbackend.business.model.dto.user.UserLoginRequest;
import com.cyfy.cyblogsbackend.business.model.dto.user.UserRegisterRequest;
import com.cyfy.cyblogsbackend.business.model.vo.LoginUserVO;
import com.cyfy.cyblogsbackend.business.service.UserInfoService;
import com.cyfy.cyblogsbackend.business.mapper.UserInfoMapper;
import com.cyfy.cyblogsbackend.common.constant.UserConstant;
import com.cyfy.cyblogsbackend.common.exception.BusinessException;
import com.cyfy.cyblogsbackend.common.exception.ErrorCode;
import com.cyfy.cyblogsbackend.common.tools.EncipherUtils;
import com.cyfy.cyblogsbackend.framework.jwt.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
/**
* @author cy
* @description 针对表【user_info(用户信息表)】的数据库操作Service实现
* @createDate 2025-02-21 21:46:22
*/
@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo>
implements UserInfoService {
@Resource
private JwtUtil jwtUtil;
/**
* 用户注册
*
* @param userRegisterRequest 用户注册请求参数
* @return 新注册用户id
*/
@Override
public long userRegister(UserRegisterRequest userRegisterRequest) {
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
// 校验
if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userAccount.length() > 25){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过长");
}
if (userPassword.length() < 8){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
if (userPassword.length() > 30){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过长");
}
if (!userPassword.equals(checkPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
// 检查账号是否已被注册
QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_account", userAccount);
long count = this.count(queryWrapper);
if(count > 0){
throw new BusinessException(ErrorCode.PARAMS_ERROR,"注册账号已存在");
}
// 密码加密
String encryptPassword = EncipherUtils.hashPsd(userPassword);
// 插入数据
UserInfo userInfo = new UserInfo();
userInfo.setUserAccount(userAccount);
userInfo.setUserPassword(encryptPassword);
userInfo.setUserName("临时名");
userInfo.setUserRole("USER");
boolean saveResult = this.save(userInfo);
if (!saveResult){
throw new BusinessException(ErrorCode.PARAMS_ERROR,"注册失败,数据库错误");
}
return userInfo.getUserId();
}
/**
* 用户登录
*
* @param userLoginRequest 用户登录请求参数
* @param request
* @return 存有脱敏后的用户信息的token令牌
*/
@Override
public String userLogin(UserLoginRequest userLoginRequest, HttpServletRequest request) {
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
// 校验
if (StrUtil.hasBlank(userAccount, userPassword)){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4
|| userPassword.length() < 8
|| userAccount.length() > 25
|| userPassword.length() > 30){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号或密码错误");
}
// 查询用户是否存在
QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_account", userAccount);
UserInfo user = this.getOne(queryWrapper);
// 用户不存在
if ( user == null ){
throw new BusinessException(ErrorCode.ACCOUNT_NOT_EXIST);
}
// 校验密码
if (!EncipherUtils.checkPsd(userPassword, user.getUserPassword())){
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
}
// 记录用户的登录态
request.getSession().setAttribute(UserConstant.USER_LOGIN_STATE, user.getUserId());
// 转换成封装类并转换为令牌
LoginUserVO loginUserVO = this.getLoginUserVO(user);
return jwtUtil.createToken(loginUserVO);
}
/**
* 获取当前登录用户
*
* @param request
* @return
*/
@Override
public LoginUserVO getCurrentLoginUser( HttpServletRequest request) {
// 从session中获取用户id,用于校验令牌合法性
Object userIdObject = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
Long userId = (Long) userIdObject;
// 如果当前连接的session中存储的用户id
if (userId == null){
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "未登录");
}
// 获取前端请求头中的X-Token数据
String token = request.getHeader(UserConstant.TOKEN_KEY);
if (StrUtil.isBlank(token)){
throw new BusinessException(ErrorCode.TOKEN_ERROR);
}
LoginUserVO currentUser;
try{
// 将token转换成对象
currentUser = jwtUtil.parseToken(token, LoginUserVO.class);
}catch (ExpiredJwtException e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR, "令牌已过期,请重新登录");
} catch (MalformedJwtException e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR, "无效令牌,请重新登录");
} catch (Exception e) {
throw new BusinessException(ErrorCode.TOKEN_ERROR);
}
// 判断当前用户id是否与token中的用户id一致
if (!currentUser.getUserId().equals(userId)){
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR, "非法登录");
}
return currentUser;
}
/**
* 用户信息脱敏处理
*
* @param userInfo
* @return
*/
@Override
public LoginUserVO getLoginUserVO(UserInfo userInfo) {
if (userInfo == null){
return null;
}
LoginUserVO loginUserVO = new LoginUserVO();
BeanUtils.copyProperties(userInfo, loginUserVO);
return loginUserVO;
}
}
用户注销接口实现
- UserInfoService:增加用户注销登录方法
/**
* 用户注销
* @param request
* @return
*/
boolean userLogout(HttpServletRequest request);
- UserInfoServiceImpl:实现用户注销登录方法
/**
* 用户注销
*
* @param request
* @return
*/
@Override
public boolean userLogout(HttpServletRequest request) {
// 先判断用户是否登录
Object userIdObject = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
if (userIdObject == null){
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
// 移除登录态
request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
return true;
}
- UserController:增加注销接口
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request){
ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);
boolean result = userInfoService.userLogout(request);
return ResultUtils.success(result);
}
测试:用户登录后,注销前,可正常获取当前登录用户信息,注销后,无法获取当前登录用户信息
AOP切面编程实现权限控制
- common模块下导入依赖
<!-- spring aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 启动类添加
@EnableAspectJAutoProxy(exposeProxy = true)
注解
@SpringBootApplication
@EnableAsync
@EnableAspectJAutoProxy(exposeProxy = true)
public class MainApplication {
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
}
common模块新建annotation和enums,用于存放自定义注解和通用枚举类
- AuthCheck:权限校验注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
/**
* 必须有某个角色
*/
String mustRole() default "";
}
- UserRoleEnum:用户角色枚举类
@Getter
public enum UserRoleEnum {
USER("用户","USER"),
ADMIN("管理员","ADMIN");
private final String text;
private final String value;
UserRoleEnum(String text, String value) {
this.text = text;
this.value = value;
}
/***
* 根据value获取枚举
* @param value 枚举值的value
* @return 枚举值
*/
public static UserRoleEnum getEnumByValue(String value) {
if (ObjUtil.isEmpty(value)) {
return null;
}
for (UserRoleEnum anEnum : UserRoleEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}
}
- UserConstant:增加用户角色相关常量
public interface UserConstant {
// 登录用户相关常量
/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "user_login";
/**
* Token令牌存储键
*/
String TOKEN_KEY = "X-Token";
// 权限角色相关常量
/**
* 默认角色
*/
String DEFAULT_ROLE = "USER";
/**
* 管理员角色
*/
String ADMIN_ROLE = "ADMIN";
}
admin模块新建aop目录,编写切面编程代码
- AuthInterceptor:当访问接口有@AuthCheck注解时,进行权限判断
@Aspect
@Component
@Slf4j
public class AuthInterceptor {
@Resource
private UserInfoService userInfoService;
/**
* 执行拦截
*
* @param joinPoint 切入点
* @param authCheck 权限校验注解
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
// 获取当前访问接口所需要的权限
String mustRole = authCheck.mustRole();
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getSignature().getDeclaringTypeName();
log.info("当前访问接口:{}.{},需要权限:{}", className,methodName,mustRole);
// 不需要权限,放行
if (mustRoleEnum == null) {
return joinPoint.proceed();
}
// 需要权限,判断当前用户是否具有权限
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 获取当前登录用户
LoginUserVO currentLoginUser = userInfoService.getCurrentLoginUser(request);
// 获取当前用户具有的权限
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(currentLoginUser.getUserRole());
log.info("当前用户权限,{}", userRoleEnum);
// 没有权限,拒绝
if (userRoleEnum == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 要求必须有管理员权限,但用户没有管理员权限,拒绝
if (UserRoleEnum.ADMIN.equals(mustRoleEnum) && !UserRoleEnum.ADMIN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 通过权限校验,放行
return joinPoint.proceed();
}
}
测试:
- TestController:编写权限测试接口
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/test")
public BaseResponse<String> test() {
return ResultUtils.success("所有人可访问");
}
@GetMapping("/test1")
@AuthCheck(mustRole = UserConstant.DEFAULT_ROLE)
public BaseResponse<String> test1() {
return ResultUtils.success("普通用户访问");
}
@GetMapping("/test2")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<String> test2() {
return ResultUtils.success("管理员用户访问");
}
}
未登录账号
普通用户
管理员用户
这里需要注意,重新登录需要重新设置新的token令牌,并点击接口旁的重置按钮
这里其实有个小问题,就是修改用户权限后,重新登录,不修改token的话,用户信息还是之前的,也就是重新登录的用户,可以使用未过期的token。但怎么想也是无关痛痒的时,毕竟你本身就能正常登录。
当然,如果出现权限变更的情况,还是可能会出现点问题,毕竟我们后续都会用getCurrentLoginUser方法获取用户权限而不是重新从数据库中获取,解决方法也很简单,一是把登录键改成随机UID,并在登录用户封装类上新增登录键字段并赋相同的值,这样只要判断二者的值是否相同即可。二是将其放到Redis等缓存中,重登时覆盖缓存中对应的token即可,如果使用旧的token进行操作,缓存中没有,则抛出非法操作,这也意味着,不允许同时登同一个账号
小优化
- 修改UserInfo:userId字段由普通自增id改为雪花id,isDelete字段增加逻辑删除注解
@TableName(value = "user_info")
@Data
public class UserInfo implements Serializable {
/**
* 用户表主键
*/
@TableId(type = IdType.ASSIGN_ID)
private Long userId;
/**
* 用户账号
*/
private String userAccount;
/**
* 用户密码
*/
private String userPassword;
/**
* 用户昵称
*/
private String userName;
/**
* 用户简介
*/
private String userProfile;
/**
* 用户角色(USER/ADMIN)
*/
private String userRole;
/**
* 用户邮件
*/
private String userEmail;
/**
* 用户头像
*/
private String userAvatar;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
/**
* 是否删除
*/
@TableLogic
private Integer isDelete;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
效果:新注册账号id为雪花id
- 修改服务器配置,设置访问接口需要加一层/api
# 开发环境配置
server:
# 服务器的HTTP端口,默认为 8080
port: 8081
address: 0.0.0.0
servlet:
context-path: /api
前端同步修改
- openapi.config.js
import { generateService } from '@umijs/openapi'
generateService({
requestLibPath: "import request from '@/request'",
schemaPath: 'http://localhost:8080/api/v2/api-docs',
serversPath: './src',
})
schemaPath为host + basePath + 分组Url才对
先前使用代理转发是错误的,因为请求路径不包含/api
前端用户登录模块开发
当前前端只是简单搭建了基础的页面框架和实现了前后端交互方法,并没有什么实际的页面。
因为后端已经实现了简单的登录注册接口,所以前端先完成登录注册功能
使用 openapi 更新接口调用方法
小优化
前面我把导航栏放在 components 中,但components目录应该存放通用的、能复用的组件,而非这种只用一次的样式布局组件,所以将 GlobalHeader.vue 移植 layouts/header下
然后为了更好管理整合的组件,这里 components 目录新增组件类型目录,然后新建个 index.ts 做统一导出管理。比如登录弹窗为对话框组件,那么在存放在指定目录下,然后在 index.ts 文件中添加如下代码
// 登录弹窗
export { default as LoginModal } from '@/components/modal/LoginModal.vue'
后续需要引用该组件时,使用以下格式引入
import { LoginModal } from '@/components'
目录结构如下
实现登录弹窗
登录窗口:按理说,只要未登录,就应该能在网站任何位置打开(注:不是做成页面,而是弹窗的形式)
回到导航组件 GlobalHeader.vue 中,因为要做登录弹窗,这里希望是点击【登录】按钮,弹出登录弹窗,所以在该组件中添加弹窗代码
在 Ant Design 官网中,找到合适的对话框组件,粘贴至 GlobalHeader 中
代码:
<template>
<div class="globalHeager">
<a-row :wrap="false">
......
<a-col flex="120px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.userId">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" @click="showModal" >登录</a-button>
<a-modal v-model:open="open" width="1000px" title="Basic Modal" @ok="handleOk">
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</a-modal>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue';
......
// 登录弹窗
const open = ref<boolean>(false);
const showModal = () => {
open.value = true;
};
const handleOk = (e: MouseEvent) => {
console.log(e);
open.value = false;
};
......
</script>
运行效果:
可以在官网 下面,看到组件所有属性
这里不希望对话框有按钮,所以添加:footer="null"
并移除@ok="handleOk"
<template>
<div class="globalHeager">
<a-row :wrap="false">
......
<a-col flex="120px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.userId">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" @click="showModal" >登录</a-button>
<a-modal
v-model:open="open"
width="1000px"
title="Basic Modal"
:footer="null">
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</a-modal>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue';
......
// 登录弹窗
const open = ref<boolean>(false);
const showModal = () => {
open.value = true;
};
const handleOk = (e: MouseEvent) => {
console.log(e);
open.value = false;
};
......
</script>
运行效果:
实现登录表单
有了弹窗后,就可以开始写我们的登录表单,同样的,到官网找喜欢的表单组件
代码:
<template>
<div class="globalHeager">
<a-row :wrap="false">
......
<a-col flex="120px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.userId">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" @click="showModal">登录</a-button>
<a-modal v-model:open="open" width="1000px" title="Basic Modal" :footer="null">
<p>Some contents...</p>
<a-form :model="formState" name="normal_login" class="login-form" @finish="onFinish"
@finishFailed="onFinishFailed">
<a-form-item label="账号" name="username"
:rules="[{ required: true, message: '请输入登录账号!' }]">
<a-input v-model:value="formState.username">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="password"
:rules="[{ required: true, message: '请输入登录密码!' }]">
<a-input-password v-model:value="formState.password">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="formState.remember">记住密码</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button :disabled="disabled" type="primary" html-type="submit" class="login-form-button">
登录
</a-button>
Or
<a href="">去注册!</a>
<p>Some contents...</p>
</a-modal>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref,reactive, computed } from 'vue';
import { HomeOutlined,UserOutlined, LockOutlined } from '@ant-design/icons-vue';
......
// 登录表单
interface FormState {
username: string;
password: string;
remember: boolean;
}
const formState = reactive<FormState>({
username: '',
password: '',
remember: true,
});
const onFinish = (values: any) => {
console.log('Success:', values);
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
const disabled = computed(() => {
return !(formState.username && formState.password);
});
......
</script>
<style scoped>
......
/* 登录表单 */
#components-form-demo-normal-login .login-form {
max-width: 300px;
}
#components-form-demo-normal-login .login-form-forgot {
float: right;
}
#components-form-demo-normal-login .login-form-button {
width: 100%;
}
</style>
运行效果:
- 优化
当前弹窗宽度设置为1000px,导致输入框很长,我们可以去掉弹窗宽度, 让其暂时好看一些
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
......
</a-modal>
这里使用后端请求参数的格式约束参数类型而非自己定义
<template>
<div class="globalHeager">
......
<!-- 登录弹窗 -->
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
<p>Some contents...</p>
<a-form :model="loginFormRef"
name="normal_login"
class="login-form"
@finish="onFinish"
@finishFailed="onFinishFailed">
<a-form-item label="账号" name="userAccount"
:rules="loginFormRules.userAccount">
<a-input v-model:value="loginFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="userPassword"
:rules="loginFormRules.userPassword">
<a-input-password v-model:value="loginFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="loginFormRef.remember">记住密码</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" class="login-form-button">
登录
</a-button>
Or
<a href="">去注册!</a>
</a-form-item>
</a-form>
<p>Some contents...</p>
</a-modal>
......
</div>
</template>
<script lang="ts" setup>
......
// 登录表单
/**
* 上传到后端的表单数据
*/
const loginForm = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
});
/**
* 表单字段
*/
const loginFormRef = reactive({
...loginForm,
remember:false
})
/**
* 定义登录表单校验规则
*/
const loginFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max:30, message: '长度在 8 到 30 个字符',}
]
}
/**
* 表单校验通过时触发事件
*/
const onFinish = (values: any) => {
console.log('Success:', values);
};
/**
* 表单校验不通过失败触发事件
*/
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
......
</script>
提交表单成功或失败时,都能获取输入框中的参数
- 登录按钮加载状态
发送请求时,我们可以让登录按钮进入加载状态,告知用户正在处理登录请求
<a-button :loading="loginLoading" type="primary" html-type="submit" class="login-form-button">
登录
</a-button>
/**
* 表单校验通过时触发事件
*/
const onFinish = (values: any) => {
loginLoading.value = true;
setTimeout(() => {
console.log('登录成功')
}, 3000)
console.log('Success:', values);
loginLoading.value = false;
};
该效果可在官网 按钮组件中找到,复制想要效果即可
运行效果
- 表单提交
找到接口对应的方法
因为我们返回的是 token 令牌,需要将其存到浏览器中(这里存到localStorage中)
src目录下新建utils目录,用于存放工具类。编写auth.ts ,提供操作Token的方法
const TokenKey = 'cyfyblogkeyvalue'
// 获取本地存储的token
export function getToken() {
return localStorage.getItem(TokenKey)
}
// 将token存放到localStorage
export function setToken(token: string) {
return localStorage.setItem(TokenKey, token)
}
// 移除本地存储的token
export function removeToken() {
return localStorage.removeItem(TokenKey)
}
实现登录逻辑:onFinish 方法
import { userLoginUsingPost } from '@/api/userController'
import { setToken } from '@/utils/auth'
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
const res = await userLoginUsingPost(values)
// 登录成功
if (res.data.code === 0 && res.data.data) {
// 将token保存到cookie中
setToken(res.data.data)
// 登录成功,更新登录用户信息
await loginUserStore.fetchLoginUser()
message.success("登录成功")
// 关闭登录弹窗
open.value = false
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
修改 stores/useLoginUserStore.ts 文件,实现获取用户数据方法
import { getLoginUserUsingGet } from '@/api/userController'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useLoginUserStore = defineStore('loginUser', () => {
// 登录用户的初始值
const loginUser = ref<API.LoginUserVO>({
userName: '未登录',
})
async function fetchLoginUser() {
// 从服务器获取用户信息
const res = await getLoginUserUsingGet()
if (res.data.code === 0 && res.data.data) {
loginUser.value = res.data.data
}
}
// 设置登录用户
function setLoginUser(newLoginUser: any) {
loginUser.value = newLoginUser
}
return { loginUser, setLoginUser, fetchLoginUser }
})
修改request.ts文件,发送请求时携带token
// 全局配置拦截器
myAxios.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 获取token
const token = getToken()
if (token) {
// 将token添加到请求头中
config.headers['X-Token'] = token
}
return config
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error)
},
)
运行效果
如果后端无法获取请求头中的“X-Token”,可以尝试重启前后端项目
实现注册表单
这里注册和登录共有一个弹窗,去官网 找个好看的标签页
复制后,将登录表单代码放到标签中
<div v-else>
<a-button type="primary" @click="showModal">登录</a-button>
<!-- 登录弹窗 -->
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
<a-tabs v-model:activeKey="activeKey" type="card">
<a-tab-pane key="login_tabs" tab="登录">
<a-form :model="loginFormRef"
name="normal_login"
class="login-form"
@finish="onFinish"
@finishFailed="onFinishFailed">
<a-form-item label="账号" name="userAccount"
:rules="loginFormRules.userAccount">
<a-input v-model:value="loginFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="userPassword"
:rules="loginFormRules.userPassword">
<a-input-password v-model:value="loginFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="loginFormRef.remember">
记住密码
</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button
:loading="loginLoading"
type="primary"
html-type="submit"
class="login-form-button">
登录
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="register_tabs" tab="注册">Content of Tab Pane 2</a-tab-pane>
</a-tabs>
</a-modal>
</div>
运行效果
注:如果处于登录状态,可以去控制台 -> Application -> Local Storage 中删除token令牌
- 注册表单的实现
与登录差不多,就是找合适的表单组件,然后修改(这里因为注册只比登录多个确认密码,所以直接复用登录表单)
<!-- 注册表单 -->
<a-tab-pane key="register_tabs" tab="注册">
<a-form :model="registerFormRef" name="normal_register" class="register-form"
@finish="onRegisterFinish" @finishFailed="onRegisterFinishFailed">
<a-form-item label="登录账号"
name="userAccount"
:rules="registerFormRules.userAccount">
<a-input v-model:value="registerFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="登录密码"
name="userPassword"
:rules="registerFormRules.userPassword">
<a-input-password v-model:value="registerFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item label="确认密码"
name="checkPassword"
:rules="registerFormRules.checkPassword">
<a-input-password v-model:value="registerFormRef.checkPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button :loading="registerLoading"
type="primary"
html-type="submit"
class="register-form-button">
注册
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
// 注册表单
/**
* 上传到后端的表单数据
*/
const registerFormRef = reactive<API.UserRegisterRequest>({
userAccount: '',
userPassword: '',
checkPassword: ''
});
/**
* 注册按钮载入状态
*/
const registerLoading = ref<boolean>(false);
/**
* 校验两次密码是否一致
*/
const validateConfirmPassword = (rule: any, value: string) => {
if (value && value !== registerFormRef.userPassword) {
return Promise.reject('两次密码不一致');
}
return Promise.resolve();
};
/**
* 定义注册表单校验规则
*/
const registerFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '登录密码不能为空', trigger: 'blur' },
{ min: 8, max: 30, message: '长度在 8 到 30 个字符', }
],
checkPassword: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
/**
* 表单校验通过时触发事件
*/
const onRegisterFinish = async (values: any) => {
registerLoading.value = true;
// 注册方法
const res = await userRegisterUsingPost(values)
// 注册成功
if (res.data.code === 0 && res.data.data) {
// 注册成功
message.success("注册成功")
// 切换到登录标签,你也可以直接调用登录方法,直接帮用户登录
activeKey.value = 'login_tabs'
}
else {
message.error("注册失败:" + res.data.message)
}
registerLoading.value = false;
};
/**
* 表单校验不通过失败触发事件
*/
const onRegisterFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
运行效果
后台新增数据
小优化
目前的登录注册表单都写在一个文件里,仅仅只是两个小表单,就使得GlobalHeader.vue 文件有300多行代码,后续我们可能还会追加一些内容以及优化登录注册的代码
正如我前面说的,我不喜欢把全部代码都在一个文件中写完,虽然可以,但还是将代码各个不同模块拆分成组件更好一些
新建 layouts/header/component 目录,用于存放由导航栏(GlobalHeader)分离的组件文件
- GlobalHeader.vue
<template>
<div class="globalHeager">
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="@/assets/logo.jpeg" alt="logo" />
<div class="title">我的博客</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" @click="doMenuClick" />
</a-col>
<a-col flex="120px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.userId">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" @click="showModal">登录</a-button>
<!-- 登录弹窗 -->
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
<!-- 登录表单 -->
<a-tabs v-model:activeKey="activeKey" type="card">
<a-tab-pane key="login_tabs" tab="登录">
<LoginForm @loginSuccess="handleLoginSuccess" />
</a-tab-pane>
<!-- 注册表单 -->
<a-tab-pane key="register_tabs" tab="注册">
<RegisterForm @registerSuccess="handleRegisterSuccess" />
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue';
import { HomeOutlined } from '@ant-design/icons-vue';
import { type MenuProps } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import LoginForm from './component/LoginForm.vue';
import RegisterForm from './component/RegisterForm.vue';
const loginUserStore = useLoginUserStore()
const router = useRouter();
// 登录注册标签
const activeKey = ref('login_tabs');
// 登录弹窗
const open = ref<boolean>(false);
const showModal = () => {
open.value = true;
};
// 菜单点击事件,跳转指定路由
const doMenuClick = ({ key }: { key: string }) => {
router.push({
path: key
});
}
// 当前选中菜单
const current = ref<string[]>(['/']);
// 监听路由变化,更新当前选中菜单
router.afterEach((to) => {
current.value = [to.path];
});
// 菜单项
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '我的博客',
},
{
key: '/about',
label: '关于',
title: '关于',
},
]);
// 处理子组件事件
const handleRegisterSuccess = (key: string) => {
// 切换到登录标签
activeKey.value = key
};
const handleLoginSuccess = (bool: boolean) => {
// 关闭弹窗
open.value = bool
};
</script>
<style scoped>
.title-bar {
display: flex;
align-items: center;
}
.title {
color: black;
font-size: 18px;
margin-left: 16px;
}
.logo {
height: 48px;
}
</style>
- component/LoginForm.vue
<template>
<div class="login-form">
<a-form :model="loginFormRef" name="normal_login" class="login-form" @finish="onFinish"
@finishFailed="onFinishFailed">
<a-form-item label="账号" name="userAccount" :rules="loginFormRules.userAccount">
<a-input v-model:value="loginFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="userPassword" :rules="loginFormRules.userPassword">
<a-input-password v-model:value="loginFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="loginFormRef.remember">记住密码</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button :loading="loginLoading" type="primary" html-type="submit" class="login-form-button">
登录
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import { userLoginUsingPost } from '@/api/userController'
import { setToken } from '@/utils/auth'
const loginUserStore = useLoginUserStore()
// 登录表单
/**
* 上传到后端的表单数据
*/
const loginForm = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
});
/**
* 表单字段
*/
const loginFormRef = reactive({
...loginForm,
remember: false
})
/**
* 登录按钮载入状态
*/
const loginLoading = ref<boolean>(false);
/**
* 定义登录表单校验规则
*/
const loginFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 30, message: '长度在 8 到 30 个字符', }
]
}
const emit = defineEmits(['loginSuccess']);
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
const res = await userLoginUsingPost(values)
// 登录成功
if (res.data.code === 0 && res.data.data) {
// 将token保存到cookie中
setToken(res.data.data)
// 登录成功,更新登录用户信息
await loginUserStore.fetchLoginUser()
message.success("登录成功")
// 关闭登录弹窗
// 调用父组件的方法
emit('loginSuccess', false);
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
/**
* 表单校验不通过失败触发事件
*/
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
</script>
<style scoped>
/* 登录表单 */
#components-form-demo-normal-login .login-form {
max-width: 300px;
}
#components-form-demo-normal-login .login-form-forgot {
float: right;
}
#components-form-demo-normal-login .login-form-button {
width: 100%;
}
</style>
- component/RegisterForm.vue
<template>
<div class="register-form">
<a-form :model="registerFormRef" name="normal_register" class="register-form"
@finish="onRegisterFinish" @finishFailed="onRegisterFinishFailed">
<a-form-item label="登录账号" name="userAccount" :rules="registerFormRules.userAccount">
<a-input v-model:value="registerFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="登录密码" name="userPassword" :rules="registerFormRules.userPassword">
<a-input-password v-model:value="registerFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item label="确认密码" name="checkPassword" :rules="registerFormRules.checkPassword">
<a-input-password v-model:value="registerFormRef.checkPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button :loading="registerLoading" type="primary" html-type="submit"
class="register-form-button">
注册
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import {ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { userRegisterUsingPost } from '@/api/userController'
// 注册表单
/**
* 上传到后端的表单数据
*/
const registerFormRef = reactive<API.UserRegisterRequest>({
userAccount: '',
userPassword: '',
checkPassword: ''
});
/**
* 注册按钮载入状态
*/
const registerLoading = ref<boolean>(false);
/**
* 校验两次密码是否一致
*/
const validateConfirmPassword = (rule: any, value: string) => {
if (value && value !== registerFormRef.userPassword) {
return Promise.reject('两次密码不一致');
}
return Promise.resolve();
};
/**
* 定义注册表单校验规则
*/
const registerFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '登录密码不能为空', trigger: 'blur' },
{ min: 8, max: 30, message: '长度在 8 到 30 个字符', }
],
checkPassword: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
const emit = defineEmits(['registerSuccess']);
/**
* 表单校验通过时触发事件
*/
const onRegisterFinish = async (values: any) => {
registerLoading.value = true;
// 注册方法
const res = await userRegisterUsingPost(values)
// 注册成功
if (res.data.code === 0 && res.data.data) {
// 注册成功
message.success("注册成功")
// 切换到登录标签,你也可以直接调用登录方法,直接帮用户登录
// 调用父组件的方法
emit('registerSuccess', 'login_tabs');
}
else {
message.error("注册失败:" + res.data.message)
}
registerLoading.value = false;
};
/**
* 表单校验不通过失败触发事件
*/
const onRegisterFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
</script>
<style scoped>
/* 注册表单 */
#components-form-demo-normal-register .register-form {
max-width: 300px;
}
#components-form-demo-normal-register .register-form-forgot {
float: left;
}
#components-form-demo-normal-register .register-form-button {
width: 100%;
}
</style>
- 项目结构
此刻运行项目与之前效果差不多,但后续要修改代码时,我们可以直接找对应组件文件去修改即可
父子组件间的交互
此处使用 emit
事件来调用父组件的方法
// 定义一个名为 registerSuccess 的事件。
const emit = defineEmits(['registerSuccess']);
// 触发 registerSuccess 事件,并传递数据。
emit('registerSuccess', 'login_tabs');
在父组件通过@事件名方式接收并触发指定方法
<!-- 监听 registerSuccess 事件,并在事件触发时调用 handleRegisterSuccess 方法。 -->
<RegisterForm @registerSuccess="handleRegisterSuccess" />
如果是父组件要传值给子组件,那么直接在子组件中使用:参数名=""参数值"
<PictureList :dataList="dataList" :loading="loading"/>
子组件接收数据
// 定义接入数据的类型
interface Props{
dataList?: API.PictureVO[],
loading?: boolean
}
// 接收父组件传入的数据
const props = withDefaults(defineProps<Props>(),{
dataList:() => [],
loading: false,
})
小优化
上面子组件通过emit
派发事件的方法调用父组件中的方法,是由 AI 生成的,应该是最普遍的调用方法。这种方法并没有什么不妥,只是需要我们去定义指定的事件名,如果项目中要用到emit
的地方多了,就要想怎么规避使用到相同的事件名。
考虑到这点,我自己去各大论坛网站中找了一个我认为不错的调用方法,代码如下
// 组件传值,在类型定义中声明父组件要传入的方法
/**
* 组件属性类型定义
*/
interface Props{
registerSuccess: (v: string) => void;
}
/**
* 组件初始值
*/
const props = withDefaults(defineProps<Props>(), {
registerSuccess: (v: string) => {
console.log(v);
}
})
父组件只需要在调用子组件时,添加传入方法即可
<LoginForm :loginSuccess="handleLoginSuccess" />
这种方法的好处在于,我们不需要去考虑怎么定义派发的事件名,这对于通用的、需要复用多次的组件来说是非常好的一件事
完整代码:
- LoginForm.vue
<template>
<div class="login-form">
<a-form :model="loginFormRef" name="normal_login" class="login-form" @finish="onFinish"
@finishFailed="onFinishFailed">
<a-form-item label="账号" name="userAccount" :rules="loginFormRules.userAccount">
<a-input v-model:value="loginFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="userPassword" :rules="loginFormRules.userPassword">
<a-input-password v-model:value="loginFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="loginFormRef.remember">记住密码</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item>
<a-button :loading="loginLoading" type="primary" html-type="submit" class="login-form-button">
登录
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import { userLoginUsingPost } from '@/api/userController'
import { setToken } from '@/utils/auth'
const loginUserStore = useLoginUserStore()
// 组件传值
/**
* 组件属性类型
*/
interface Props{
loginSuccess: (v: boolean) => void;
}
/**
* 组件初始值
*/
const props = withDefaults(defineProps<Props>(), {
loginSuccess: (v: boolean) => {}
})
// 登录表单
/**
* 上传到后端的表单数据
*/
const loginForm = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
});
/**
* 表单字段
*/
const loginFormRef = reactive({
...loginForm,
remember: false
})
/**
* 登录按钮载入状态
*/
const loginLoading = ref<boolean>(false);
/**
* 定义登录表单校验规则
*/
const loginFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 30, message: '长度在 8 到 30 个字符', }
]
}
// const emit = defineEmits(['loginSuccess']);
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
const res = await userLoginUsingPost(values)
// 登录成功
if (res.data.code === 0 && res.data.data) {
// 将token保存到cookie中
setToken(res.data.data)
// 登录成功,更新登录用户信息
await loginUserStore.fetchLoginUser()
message.success("登录成功")
// 关闭登录弹窗
// 调用父组件的方法
// emit('loginSuccess', false);
props.loginSuccess(false)
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
/**
* 表单校验不通过失败触发事件
*/
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
</script>
<style scoped>
/* 登录表单 */
#components-form-demo-normal-login .login-form {
max-width: 300px;
}
#components-form-demo-normal-login .login-form-forgot {
float: right;
}
#components-form-demo-normal-login .login-form-button {
width: 100%;
}
</style>
- RegisterForm.vue
<template>
<div class="register-form">
<a-form :model="registerFormRef" name="normal_register" class="register-form"
@finish="onRegisterFinish" @finishFailed="onRegisterFinishFailed">
<a-form-item label="登录账号" name="userAccount" :rules="registerFormRules.userAccount">
<a-input v-model:value="registerFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="登录密码" name="userPassword" :rules="registerFormRules.userPassword">
<a-input-password v-model:value="registerFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item label="确认密码" name="checkPassword" :rules="registerFormRules.checkPassword">
<a-input-password v-model:value="registerFormRef.checkPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button :loading="registerLoading" type="primary" html-type="submit"
class="register-form-button">
注册
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import {ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { userRegisterUsingPost } from '@/api/userController'
// 组件传值
/**
* 组件属性类型
*/
interface Props{
registerSuccess: (v: string) => void;
}
/**
* 组件初始值
*/
const props = withDefaults(defineProps<Props>(), {
registerSuccess: (v: string) => {}
})
// 注册表单
/**
* 上传到后端的表单数据
*/
const registerFormRef = reactive<API.UserRegisterRequest>({
userAccount: '',
userPassword: '',
checkPassword: ''
});
/**
* 注册按钮载入状态
*/
const registerLoading = ref<boolean>(false);
/**
* 校验两次密码是否一致
*/
const validateConfirmPassword = (rule: any, value: string) => {
if (value && value !== registerFormRef.userPassword) {
return Promise.reject('两次密码不一致');
}
return Promise.resolve();
};
/**
* 定义注册表单校验规则
*/
const registerFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '登录密码不能为空', trigger: 'blur' },
{ min: 8, max: 30, message: '长度在 8 到 30 个字符', }
],
checkPassword: [
{ required: true, message: '确认密码不能为空', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
const emit = defineEmits(['registerSuccess']);
/**
* 表单校验通过时触发事件
*/
const onRegisterFinish = async (values: any) => {
registerLoading.value = true;
// 注册方法
const res = await userRegisterUsingPost(values)
// 注册成功
if (res.data.code === 0 && res.data.data) {
// 注册成功
message.success("注册成功")
// 切换到登录标签,你也可以直接调用登录方法,直接帮用户登录
// 调用父组件的方法
// emit('registerSuccess', 'login_tabs');
props.registerSuccess('login_tabs');
}
else {
message.error("注册失败:" + res.data.message)
}
registerLoading.value = false;
};
/**
* 表单校验不通过失败触发事件
*/
const onRegisterFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
</script>
<style scoped>
/* 注册表单 */
#components-form-demo-normal-register .register-form {
max-width: 300px;
}
#components-form-demo-normal-register .register-form-forgot {
float: left;
}
#components-form-demo-normal-register .register-form-button {
width: 100%;
}
</style>
- GlobalHeader.vue
<template>
<div class="globalHeager">
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="@/assets/logo.jpeg" alt="logo" />
<div class="title">我的博客</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" @click="doMenuClick" />
</a-col>
<a-col flex="120px">
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.userId">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" @click="showModal">登录</a-button>
<!-- 登录弹窗 -->
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
<!-- 登录表单 -->
<a-tabs v-model:activeKey="activeKey" type="card">
<a-tab-pane key="login_tabs" tab="登录">
<LoginForm :loginSuccess="handleLoginSuccess" />
</a-tab-pane>
<!-- 注册表单 -->
<a-tab-pane key="register_tabs" tab="注册">
<RegisterForm :registerSuccess="handleRegisterSuccess" />
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue';
import { HomeOutlined } from '@ant-design/icons-vue';
import { type MenuProps } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import LoginForm from './component/LoginForm.vue';
import RegisterForm from './component/RegisterForm.vue';
const loginUserStore = useLoginUserStore()
const router = useRouter();
// 登录注册标签
const activeKey = ref('login_tabs');
// 登录弹窗
const open = ref<boolean>(false);
const showModal = () => {
open.value = true;
};
// 菜单点击事件,跳转指定路由
const doMenuClick = ({ key }: { key: string }) => {
router.push({
path: key
});
}
// 当前选中菜单
const current = ref<string[]>(['/']);
// 监听路由变化,更新当前选中菜单
router.afterEach((to) => {
current.value = [to.path];
});
// 菜单项
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '我的博客',
},
{
key: '/about',
label: '关于',
title: '关于',
},
]);
// 处理子组件事件
const handleRegisterSuccess = (key: string) => {
// 切换到登录标签
activeKey.value = key
};
const handleLoginSuccess = (bool: boolean) => {
// 关闭弹窗
open.value = bool
};
</script>
<style scoped>
.title-bar {
display: flex;
align-items: center;
}
.title {
color: black;
font-size: 18px;
margin-left: 16px;
}
.logo {
height: 48px;
}
</style>
实现下拉菜单功能
老规矩,去Ant Design官网中找合适的组件,就以CSDN为例,我们希望用户注销功能是在鼠标移动到用户头像时展开的下拉列表中的
所以可以到下拉菜单组件中找一个自己喜欢的下拉菜单样式
修改名称位置
<div v-if="loginUserStore.loginUser.userId">
<a-dropdown placement="bottom">
<a-button>{{ loginUserStore.loginUser.userName ?? '无名' }}</a-button>
<template #overlay>
<a-menu>
<a-menu-item>
<a target="_blank" rel="" href="">
个人信息
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
占位
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
注销
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
运行效果:
这里需要了解的时,用户名那里,虽然官网给的示例代码是button
样式,但你可以改成任何样式,比如改成头像。
<a-dropdown placement="bottom">
<!-- 改成头像 -->
<a-avatar style="border: 1px solid black;"
size="large"
src="https://xsgames.co/randomusers/avatar.php?g=pixel&key=1" />
<template #overlay>
<a-menu>
<a-menu-item>
<a target="_blank" rel="" href="">
个人信息
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
占位
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
注销
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
修改效果
同样的,下面的菜单选项部分,你可以当做是一个特殊的弹窗,弹窗内容你可随意编写。如你把登录注册弹窗的内容写在里面
<a-dropdown placement="bottom">
<a-avatar style="border: 1px solid black;"
size="large"
src="https://xsgames.co/randomusers/avatar.php?g=pixel&key=1" />
<template #overlay>
<LoginForm :loginSuccess="handleLoginSuccess" />
</template>
</a-dropdown>
那么效果就会是这样
即,你不必局限于下拉菜单内容是menu菜单样式。它可以是任意内容。
参考CSDN的下拉菜单,我们也可以在菜单项上方加入用户信息
<template>
<template #overlay>
<a-menu class="user-dropdown-menu">
<div class="user-dropdown-menu-info">
用户名:{{ loginUserStore.loginUser.userName }}
<a-menu-divider />
其他信息
</div>
<a-menu-divider />
<a-menu-item>
<a target="_blan" rel="" href="">
个人信息
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
占位
</a>
</a-menu-item>
<a-menu-divider />
<a-menu-item>
<a target="_blank" rel="" href="">
注销
</a>
</a-menu-item>
</a-menu>
</template>
</template>
......
<style scoped>
......
/** 下拉菜单样式 */
.user-dropdown-menu {
width: 200px;
}
.user-dropdown-menu .user-dropdown-menu-info {
padding-top: 20px;
text-align: center;
height: 100px;
}
</style>
修改效果:
老规矩,进行组件化管理。我们将用户菜单这一模块代码单独抽离出来,做成登录用户模块组件
- LoginUserModule.vue
<template>
<div class="user-login-module">
<div v-if="loginUserStore.loginUser.userId">
<a-dropdown placement="bottom">
<a-avatar class="ant-dropdown-link" style="border: 1px solid black;" size="large"
src="https://xsgames.co/randomusers/avatar.php?g=pixel&key=1" />
<template #overlay>
<a-menu class="user-dropdown-menu">
<div class="user-dropdown-menu-info">
用户名:{{ loginUserStore.loginUser.userName }}
<a-menu-divider />
其他信息
</div>
<a-menu-divider />
<a-menu-item>
<a target="_blan" rel="" href="">
个人信息
</a>
</a-menu-item>
<a-menu-item>
<a target="_blank" rel="" href="">
占位
</a>
</a-menu-item>
<a-menu-divider />
<a-menu-item>
<a target="_blank" rel="" href="">
注销
</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else>
<a-button type="primary" @click="showModal">登录</a-button>
<!-- 登录弹窗 -->
<a-modal v-model:open="open" title="Basic Modal" :footer="null">
<!-- 登录表单 -->
<a-tabs v-model:activeKey="activeKey" type="card">
<a-tab-pane key="login_tabs" tab="登录">
<LoginForm :loginSuccess="handleLoginSuccess" />
</a-tab-pane>
<!-- 注册表单 -->
<a-tab-pane key="register_tabs" tab="注册">
<RegisterForm :registerSuccess="handleRegisterSuccess" />
</a-tab-pane>
</a-tabs>
</a-modal>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import LoginForm from './LoginForm.vue';
import RegisterForm from './RegisterForm.vue';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
const loginUserStore = useLoginUserStore()
// 登录注册标签
const activeKey = ref('login_tabs');
// 登录弹窗
const open = ref<boolean>(false);
const showModal = () => {
open.value = true;
};
// 处理子组件事件
const handleRegisterSuccess = (key: string) => {
// 切换到登录标签
activeKey.value = key
};
const handleLoginSuccess = (bool: boolean) => {
// 关闭弹窗
open.value = bool
};
</script>
<style scoped>
/** 下拉菜单样式 */
.user-dropdown-menu {
width: 200px;
}
.user-dropdown-menu .user-dropdown-menu-info {
padding-top: 20px;
text-align: center;
height: 100px;
}
</style>
- GlobalHeader.vue
<template>
<div class="globalHeager">
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="@/assets/logo.jpeg" alt="logo" />
<div class="title">我的博客</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" @click="doMenuClick" />
</a-col>
<a-col flex="120px">
<LoginUserModule />
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue';
import { HomeOutlined } from '@ant-design/icons-vue';
import { type MenuProps } from 'ant-design-vue';
import { useRouter } from 'vue-router';
import LoginUserModule from './component/LoginUserModule.vue';
const router = useRouter();
// 菜单点击事件,跳转指定路由
const doMenuClick = ({ key }: { key: string }) => {
router.push({
path: key
});
}
// 当前选中菜单
const current = ref<string[]>(['/']);
// 监听路由变化,更新当前选中菜单
router.afterEach((to) => {
current.value = [to.path];
});
// 菜单项
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '我的博客',
},
{
key: '/about',
label: '关于',
title: '关于',
},
]);
</script>
<style scoped>
.title-bar {
display: flex;
align-items: center;
}
.title {
color: black;
font-size: 18px;
margin-left: 16px;
}
.logo {
height: 48px;
}
</style>
后面我们要编写登录用户的下拉菜单相关的代码,只需在LoginUserModule.vue
编写即可
实现用户注销功能
到 api/UserController.ts 文件中找到注销接口的方法
接着在登录用户模块编写注销登录的方法,调用接口的同时,我们还要清除存在localStorage
中的token令牌,以及重置全局变量loginUserStore
的内容
给注销选项增加点击事件loginUserLogout
<a-menu-item @click="loginUserLogout">
退出登录
</a-menu-item>
实现退出登录逻辑
// 登录用户注销
const loginUserLogout = async () => {
const res = await userLogoutUsingPost()
if (res.data.code === 0) {
// 删除token
removeToken()
// 重置用户信息
loginUserStore.setLoginUser({
userName: '未登录',
})
message.success("退出登录成功")
// TODO 考虑用户可能在需要特定权限的页面退出登录,后续可能需要做重定向
// await router.push('/')
}else {
message.error("退出登录失败:" + res.data.message)
}
}
运行效果:
页面美化
- 弹窗样式
修改 LoginUserModule.vue 文件
<!-- 取消标题,居中显示 -->
<a-modal v-model:open="open" :footer="null" width="480px" centered>
<!-- 表单增加样式 -->
<a-tabs v-model:activeKey="activeKey" type="card" class="form-modal-tabs">
<style scoped>
......
.form-modal-tabs {
margin-top: 40px;
padding: 0px 24px;
}
</style>
<style>
/** 页签样式,写在组件样式中不生效,需写在全局中 */
/** 设置页签宽度与窗口相同 */
.form-modal-tabs .ant-tabs-nav-list,
.form-modal-tabs .ant-tabs-tab {
width: 100%;
}
</style>
修改 LoginForm.vue 文件
<!-- 按钮与父样式同宽 -->
<a-button
:loading="loginLoading"
type="primary"
html-type="submit"
block
class="login-form-button">
登录
</a-button>
<style scoped>
/** 清除无用样式,设置按钮高度 **/
.login-form-button {
height:38px;
}
</style>
修改 RegisterForm.vue 文件
<a-form-item>
<a-button
:loading="registerLoading"
type="primary"
html-type="submit"
block
class="register-form-button">
注册
</a-button>
</a-form-item>
<style scoped>
/* 注册表单 */
.register-form-button {
height: 38px;
}
</style>
实现记住密码功能
回到登录表单组件,我们表单中有“记住密码”选项,但实际这个功能逻辑并没有实现,所以需要我们在修改代码,增加实现代码。
实现方法有很多,但无非就是存储到浏览器中,像我们存储Token令牌一样,但这里我选择存到cookie中,并用jsencrypt加密密码
- 安装依赖
npm i js-cookie // cookie操作工具
npm install crypto-js -S // 加密/解密工具
新建 utils/encrypt.ts 文件,编写加密解密工具类
import CryptoJS from 'crypto-js'
// 自定义密钥和偏移量
const KEY = CryptoJS.enc.Utf8.parse('aaDJL2d9DfhLZO0z') // 密钥
const IV = CryptoJS.enc.Utf8.parse('412ADDSSFA342442') // 偏移量
/** AES加密 */
export function Encrypt(word: string) {
let srcs = CryptoJS.enc.Utf8.parse(word)
var encrypted = CryptoJS.AES.encrypt(srcs, KEY, {
iv: IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.ZeroPadding,
})
return CryptoJS.enc.Base64.stringify(encrypted.ciphertext)
}
/** AES 解密 */
export function Decrypt(word: string) {
let base64 = CryptoJS.enc.Base64.parse(word)
let src = CryptoJS.enc.Base64.stringify(base64)
var decrypt = CryptoJS.AES.decrypt(src, KEY, {
iv: IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.ZeroPadding,
})
var decryptedStr = decrypt.toString(CryptoJS.enc.Utf8)
return decryptedStr.toString()
}
编辑LoginForm.vue 文件,实现记住密码功能
- 引入 Cookie 和 加密工具
import Cookies from "js-cookie";
import { Encrypt, Decrypt } from '@/utils/encrypt'
- 修改提交表单操作,提交成功时添加用户账号密码
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
// 将登录账号存储到cookie中,有效期 30 天
Cookies.set('loginAccount', values.userAccount, { expires: 30 })
const res = await userLoginUsingPost(values)
// 登录成功
if (res.data.code === 0 && res.data.data) {
// 将token保存到cookie中
setToken(res.data.data)
// 如果用户勾选了记住密码,则将加密后的密码保存到cookie中
if (values.remember) {
// 对密码进行加密
const encryptPassword = Encrypt(JSON.stringify(values.userPassword))
// 将登录账号存储到cookie中,有效期 30 天
Cookies.set('loginPassword', encryptPassword, { expires: 30 })
} else {
// 删除cookie
Cookies.remove('loginPassword')
}
// 登录成功,更新登录用户信息
await loginUserStore.fetchLoginUser()
message.success("登录成功")
// 关闭登录弹窗
// 调用父组件的方法
props.loginSuccess(false)
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
- 增加从Cookie中加载用户数据的方法
/**
* 从Cookie中加载用户信息
*/
const resetLoginForm = async () => {
// 读取账号、密码
const userAccount = Cookies.get('loginAccount')
const userPassword = Cookies.get('loginPassword')
if (userAccount) {
loginFormRef.userAccount = userAccount
}
if (userPassword) {
// 如果存在密码,则说明用户勾选了记住密码,则自动勾选记住密码
loginFormRef.userPassword = userPassword
loginFormRef.remember = true
}
}
/**
* 打开表单时加载一次
*/
onMounted(() => {
resetLoginForm()
});
这里加密后的密码长度可能会变长,所以需要自定义校验规则,放行未操作的加密密码
/**
* 密码长度校验规则,绕过加密密码
*/
const validatePasswordLength = (rule: any, value: string) => {
const userPassword = Cookies.get('loginPassword')
// 如果当前登录密码和缓存密码一致,则通过校验
if (userPassword && value === userPassword) {
return Promise.resolve();
}
// 否则,判断长度是否符合要求
if ( value.length < 8 || value.length > 30 ) {
return Promise.reject('长度在 8 到 30 个字符');
}
return Promise.resolve();
};
/**
* 定义登录表单校验规则
*/
const loginFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePasswordLength, trigger: 'blur' } // 自定义密码长度校验
// { min: 8, max: 30, message: '长度在 8 到 30 个字符', }
]
}
因为通过读取cookie获取的密码是加密后的密码,不能直接给后端,所以需要再次修改提交表单的逻辑
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
// 将登录账号存储到cookie中,有效期 30 天
Cookies.set('loginAccount', values.userAccount, { expires: 30 })
const oldPassword = Cookies.get('loginPassword')
// 如果当前输入框中的密码与cookie中的一致,就做解密处理
if (oldPassword && oldPassword === values.userPassword){
// 解析加密后的数据
values.userPassword = await JSON.parse(Decrypt(values.userPassword));
}
const res = await userLoginUsingPost(values)
if (res.data.code === 0 && res.data.data) {
setToken(res.data.data)
if (values.remember) {
const encryptPassword = Encrypt(JSON.stringify(values.userPassword))
Cookies.set('loginPassword', encryptPassword, { expires: 30 })
} else {
Cookies.remove('loginPassword')
}
await loginUserStore.fetchLoginUser()
message.success("登录成功")
props.loginSuccess(false)
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
- LoginForm.vue完整代码
<template>
<div class="login-form">
<a-form
:model="loginFormRef"
name="normal_login"
@finish="onFinish"
>
<a-form-item label="账号" name="userAccount" :rules="loginFormRules.userAccount">
<a-input v-model:value="loginFormRef.userAccount">
<template #prefix>
<UserOutlined class="site-form-item-icon" />
</template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="userPassword" :rules="loginFormRules.userPassword">
<a-input-password v-model:value="loginFormRef.userPassword">
<template #prefix>
<LockOutlined class="site-form-item-icon" />
</template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-form-item name="remember" no-style>
<a-checkbox v-model:checked="loginFormRef.remember">记住密码</a-checkbox>
</a-form-item>
<a class="login-form-forgot" href="">忘记密码</a>
</a-form-item>
<a-form-item >
<a-button
:loading="loginLoading"
type="primary"
html-type="submit"
block
class="login-form-button">
登录
</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import { userLoginUsingPost } from '@/api/userController'
import { setToken } from '@/utils/auth'
import Cookies from "js-cookie";
import { Encrypt, Decrypt } from '@/utils/encrypt'
import { onMounted } from 'vue';
const loginUserStore = useLoginUserStore()
// 组件传值
/**
* 组件属性类型
*/
interface Props{
loginSuccess: (v: boolean) => void;
}
/**
* 组件初始值
*/
const props = withDefaults(defineProps<Props>(), {
loginSuccess: (v: boolean) => {},
})
// 登录表单
/**
* 上传到后端的表单数据
*/
const loginForm = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
});
/**
* 表单字段
*/
const loginFormRef = reactive({
...loginForm,
remember: false
})
/**
* 登录按钮载入状态
*/
const loginLoading = ref<boolean>(false);
/**
* 密码长度校验规则,绕过加密密码
*/
const validatePasswordLength = (rule: any, value: string) => {
const userPassword = Cookies.get('loginPassword')
// 如果当前登录密码和缓存密码一致,则通过校验
if (userPassword && value === userPassword) {
return Promise.resolve();
}
// 否则,判断长度是否符合要求
if ( value.length < 8 || value.length > 30 ) {
return Promise.reject('长度在 8 到 30 个字符');
}
return Promise.resolve();
};
/**
* 定义登录表单校验规则
*/
const loginFormRules = {
userAccount: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 25, message: '长度在 4 到 25 个字符', trigger: 'blur' },
],
userPassword: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ validator: validatePasswordLength, trigger: 'blur' } // 自定义密码长度校验
// { min: 8, max: 30, message: '长度在 8 到 30 个字符', }
]
}
/**
* 从Cookie中加载用户信息
*/
const resetLoginForm = async () => {
// 读取账号、密码
const userAccount = Cookies.get('loginAccount')
const userPassword = Cookies.get('loginPassword')
if (userAccount) {
loginFormRef.userAccount = userAccount
}
if (userPassword) {
// 如果存在密码,则说明用户勾选了记住密码,则自动勾选记住密码
loginFormRef.userPassword = userPassword
loginFormRef.remember = true
}
}
/**
* 打开表单时加载一次
*/
onMounted(() => {
resetLoginForm()
});
/**
* 表单校验通过时触发事件
*/
const onFinish = async (values: any) => {
loginLoading.value = true;
// 将登录账号存储到cookie中,有效期 30 天
Cookies.set('loginAccount', values.userAccount, { expires: 30 })
const oldPassword = Cookies.get('loginPassword')
// 如果当前输入框中的密码与cookie中的一致,就做解密处理
if (oldPassword && oldPassword === values.userPassword){
// 解析加密后的数据
values.userPassword = await JSON.parse(Decrypt(values.userPassword));
}
const res = await userLoginUsingPost(values)
// 登录成功
if (res.data.code === 0 && res.data.data) {
// 将token保存到cookie中
setToken(res.data.data)
// 如果用户勾选了记住密码,则将加密后的密码保存到cookie中
if (values.remember) {
// 对密码进行加密
const encryptPassword = Encrypt(JSON.stringify(values.userPassword))
// 将登录账号存储到cookie中,有效期 30 天
Cookies.set('loginPassword', encryptPassword, { expires: 30 })
} else {
// 删除cookie
Cookies.remove('loginPassword')
}
// 登录成功,更新登录用户信息
await loginUserStore.fetchLoginUser()
message.success("登录成功")
// 关闭登录弹窗
// 调用父组件的方法
props.loginSuccess(false)
}
else {
message.error("登录失败:" + res.data.message)
}
loginLoading.value = false;
};
</script>
<style scoped>
.login-form-button {
height:38px;
}
</style>
运行效果:
小优化
当前关闭弹窗后,表单信息还会存在,比如校验状态之类的,且不会重新加载一次用户数据读取。解决方法也很简单,在弹窗组件中加destroyOnClose
属性即可
<!-- 登录弹窗 -->
<a-modal
v-model:open="open"
:footer="null"
width="480px"
destroyOnClose
centered>
PS:
这里我把登录和注册写在同一弹窗中,但看现在的效果其实还行
但这主要是注册表单仅仅只有3个字段而已,实际注册还有用户名、用户头像、联系电话等内容需要填写,当字段躲起来的时候,左右切换就会出现弹窗高度不同变化的情况。
所以后面打算再花点时间,将注册做成一个页面,用户点击前往注册时,关闭窗口并跳转至注册页