【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_infoADD 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 mapperPUBLIC "-//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 {@Resourceprivate JwtUtil jwtUtil;/*** 用户注册** @param userRegisterRequest 用户注册请求参数* @return 新注册用户id*/@Overridepublic 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令牌*/@Overridepublic 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*/@Overridepublic 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 {@Resourceprivate 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_infoMODIFY 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*/@Overridepublic LoginUserVO getCurrentLoginUser( HttpServletRequest request) {// 从session中获取用户IDObject userIdObject = request.getSession().getAttribute("user_login");Long userId = (Long) userIdObject;// 如果当前连接的session中不存在用户idif (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 {@Resourceprivate JwtUtil jwtUtil;/*** 用户注册** @param userRegisterRequest 用户注册请求参数* @return 新注册用户id*/@Overridepublic 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令牌*/@Overridepublic 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*/@Overridepublic LoginUserVO getCurrentLoginUser( HttpServletRequest request) {// 从session中获取用户id,用于校验令牌合法性Object userIdObject = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);Long userId = (Long) userIdObject;// 如果当前连接的session中存储的用户idif (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*/@Overridepublic 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*/@Overridepublic 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 {@Resourceprivate 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;/*** 是否删除*/@TableLogicprivate Integer isDelete;@TableField(exist = false)private static final long serialVersionUID = 1L;
}
效果:新注册账号id为雪花id
- 修改服务器配置,设置访问接口需要加一层/api
# 开发环境配置
server:# 服务器的HTTP端口,默认为 8080port: 8081address: 0.0.0.0servlet: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-modalv-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) => {// 在发送请求之前做些什么// 获取tokenconst 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) {// 删除tokenremoveToken()// 重置用户信息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"blockclass="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 {// 删除cookieCookies.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 = userPasswordloginFormRef.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"blockclass="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 = userPasswordloginFormRef.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 {// 删除cookieCookies.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-modalv-model:open="open":footer="null"width="480px"destroyOnClosecentered>
PS:
这里我把登录和注册写在同一弹窗中,但看现在的效果其实还行
但这主要是注册表单仅仅只有3个字段而已,实际注册还有用户名、用户头像、联系电话等内容需要填写,当字段躲起来的时候,左右切换就会出现弹窗高度不同变化的情况。
所以后面打算再花点时间,将注册做成一个页面,用户点击前往注册时,关闭窗口并跳转至注册页
相关文章:

【SpringBoot+Vue】博客项目开发二:用户登录注册模块
后端用户模块开发 制定参数交互约束 当前,我们使用MybatisX工具快速生成的代码中,包含了一个实体类,这个类中包含我们数据表中的所有字段。 但因为有些字段,是不应该返回到前端的,比如用户密码,或者前端传…...

(十 二)趣学设计模式 之 享元模式!
目录 一、 啥是享元模式?二、 为什么要用享元模式?三、 享元模式的实现方式四、 享元模式的优缺点五、 享元模式的应用场景六、 总结 🌟我的其他文章也讲解的比较有趣😁,如果喜欢博主的讲解方式,可以多多支…...

leetcode第77题组合
原题出于leetcode第77题https://leetcode.cn/problems/combinations/ 1.树型结构 2.回溯三部曲 递归函数的参数和返回值 确定终止条件 单层递归逻辑 3.代码 二维数组result 一维数组path void backtracking(n,k,startindex){if(path.sizek){result.append(path);return ;}…...

Linux | Ubuntu 与 Windows 双系统安装 / 高频故障 / UEFI 安全引导禁用
注:本文为 “buntu 与 Windows 双系统及高频故障解决” 相关文章合辑。 英文引文,机翻未校。 How to install Ubuntu 20.04 and dual boot alongside Windows 10 如何将 Ubuntu 20.04 和双启动与 Windows 10 一起安装 Dave’s RoboShack Published in…...

Docker入门指南:Windows下docker配置镜像源加速下载
Windows下docker配置镜像源加速下载 docker的官方镜像是海外仓库,默认下载耗时较长,而且经常出现断站的现象,因此需要配置国内镜像源。 国内镜像源概述 国内现有如下镜像源可以使用 "http://hub-mirror.c.163.com", "http…...

web前端基础修炼手册
目录 引言 1. 安装插件 2. 前端三剑客 3. 开发者模式 第一章 HTML 1.文件结构 2. 常见标签 2.1 注释标签 2.2 标题标签 2.3 段落标签 2.4 换行标签 2.5 格式化标签 2.6 图片标签 2.7 超链接标签 2.8 表格标签 2.9 列表标签 2.10 form标签 2.11 input 标签 2.12 la…...
【无标题】Ubuntu22.04编译视觉十四讲slambook2 ch4时fmt库的报错
Ubuntu22.04编译视觉十四讲slambook2 ch4时fmt库的报错 cmake ..顺利,make后出现如下报错: in function std::make_unsigned<int>::type fmt::v8::detail::to_unsigned<int>(int): trajectoryError.cpp:(.text._ZN3fmt2v86detail11to_unsi…...

macos下myslq图形化工具之Sequel Ace
什么是Sequel Ace 官方github:https://github.com/Sequel-Ace/Sequel-Ace Sequel Ace 是一款快速、易于使用的 Mac 数据库管理应用程序,用于处理 MySQL 和 MariaDB 数据库。 Sequel Ace 是一款开源项目,采用 MIT 许可证。用户可以通过 Ope…...
【AHK】资源管理器自动化办公实例/自动连点设置
此处为一个自动连续点击打开检查的自动化操作案例,没有quicker的鼠键录制,不常用了,做个备份 #MaxThreadsPerHotkey 2 ; 这个是核心!!!!确保可以同时运行多个热键或标签global isRunning : tru…...
通用查询类接口数据更新的另类实现
文章目录 一、简要概述二、java工程实现1. 定义main方法2. 测试运行3. 源码放送 一、简要概述 我们在通用查询类接口开发的另类思路中,关于接口数据的更新,提出了两种方案: 文件监听 #mermaid-svg-oJQjD6jQ8T19XlHA {font-family:"tre…...
Linux ls 命令
Linux ls(英文全拼: list directory contents)命令用于显示指定工作目录下之内容(列出目前工作目录所含的文件及子目录)。 语法 ls [-alrtAFR] [name...] 参数 : -a 显示所有文件及目录 (. 开头的隐藏文件也会列出)-d 只列出目…...

【问题记录】Go项目Docker中的consul访问主机8080端口被拒绝
【问题记录】Go项目Docker中的consul访问主机8080端口被拒绝 问题展示解决办法 问题展示 在使用docker中的consul服务的时候,通过命令行注册相应的服务(比如cloudwego项目的demo_proto以及user服务)失败。 解决办法 经过分析,是…...
面试题:说一下你对DDD的了解?
面试题:说一下你对DDD的了解? 在面试中,关于 DDD(领域驱动设计,Domain-Driven Design) 的问题是一个常见的技术考察点。DDD 是一种软件设计方法论,旨在通过深入理解业务领域来构建复杂的软件系统。以下是一个清晰、详细的回答模板,帮助你在面试中脱颖而出: DDD 的定义…...

React低代码项目:问卷编辑器 I
问卷编辑器 Date: February 20, 2025 4:17 PM (GMT8) 目标 完成问卷编辑器的设计和开发完成复杂系统的 UI 组件拆分完成复杂系统的数据结构设计 内容 需求分析技术方案设计开发 注意事项: 需求指导设计,设计指导开发。前两步很重要页面复杂的话&…...
蓝桥杯2024年真题java B组 【H.拼十字】
蓝桥杯2024年真题java B组 【H.拼十字】 原题链接:拼十字 思路: 使用树状数组或线段树解决。 先将输入的信息存入到一个n行3列的数组中,将信息排序,按照长度小到大,长相同时,宽度小到大 排序。 建立三个…...

Spring MVC 程序开发(1)
目录 1、什么是 SpringMVC2、返回数据2.1、返回 JSON 对象2.2、请求转发2.3、请求重定向2.4、自定义返回的内容 1、什么是 SpringMVC 1、Tomcat 和 Servlet 分别是什么?有什么关系? Servlet 是 java 官方定义的 web 开发的标准规范;Tomcat 是…...

PyCharm接入本地部署DeepSeek 实现AI编程!【支持windows与linux】
今天尝试在pycharm上接入了本地部署的deepseek,实现了AI编程,体验还是很棒的。下面详细叙述整个安装过程。 本次搭建的框架组合是 DeepSeek-r1:1.5b/7b Pycharm专业版或者社区版 Proxy AI(CodeGPT) 首先了解不同版本的deepsee…...

Linux服务升级:Almalinux 升级 DeepSeek-R1
目录 一、实验 1.环境 2.Almalinux 部署 Ollama 3.Almalinux 升级 DeepSeek-R1 4.Almalinux 部署 docker 5. docker 部署 DeepSeek-R1 6.Almalinux 部署 Cpolar (内网穿透) 7.使用cpolar内网穿透 二、问题 1.构建容器失败 一、实验 1.环境 (1)…...

Linux操作系统5- 补充知识(可重入函数,volatile关键字,SIGCHLD信号)
上篇文章:Linux操作系统5-进程信号3(信号的捕捉流程,信号集,sigaction)-CSDN博客 本篇Gitee仓库:myLerningCode/l26 橘子真甜/Linux操作系统与网络编程学习 - 码云 - 开源中国 (gitee.com) 目录 一. 可重入…...

ctfshow刷题笔记—栈溢出—pwn61~pwn64
目录 前言 一、pwn61(输出了什么?) 二、pwn62(短了一点) 三、pwn63(又短了一点) 四、pwn64(有时候开启某种保护并不代表这条路不通) 五、一些shellcode 前言 这几道都是与shellcode有关的题,实在是…...
vscode里如何用git
打开vs终端执行如下: 1 初始化 Git 仓库(如果尚未初始化) git init 2 添加文件到 Git 仓库 git add . 3 使用 git commit 命令来提交你的更改。确保在提交时加上一个有用的消息。 git commit -m "备注信息" 4 …...

【Python】 -- 趣味代码 - 小恐龙游戏
文章目录 文章目录 00 小恐龙游戏程序设计框架代码结构和功能游戏流程总结01 小恐龙游戏程序设计02 百度网盘地址00 小恐龙游戏程序设计框架 这段代码是一个基于 Pygame 的简易跑酷游戏的完整实现,玩家控制一个角色(龙)躲避障碍物(仙人掌和乌鸦)。以下是代码的详细介绍:…...
2024年赣州旅游投资集团社会招聘笔试真
2024年赣州旅游投资集团社会招聘笔试真 题 ( 满 分 1 0 0 分 时 间 1 2 0 分 钟 ) 一、单选题(每题只有一个正确答案,答错、不答或多答均不得分) 1.纪要的特点不包括()。 A.概括重点 B.指导传达 C. 客观纪实 D.有言必录 【答案】: D 2.1864年,()预言了电磁波的存在,并指出…...

STM32标准库-DMA直接存储器存取
文章目录 一、DMA1.1简介1.2存储器映像1.3DMA框图1.4DMA基本结构1.5DMA请求1.6数据宽度与对齐1.7数据转运DMA1.8ADC扫描模式DMA 二、数据转运DMA2.1接线图2.2代码2.3相关API 一、DMA 1.1简介 DMA(Direct Memory Access)直接存储器存取 DMA可以提供外设…...

新能源汽车智慧充电桩管理方案:新能源充电桩散热问题及消防安全监管方案
随着新能源汽车的快速普及,充电桩作为核心配套设施,其安全性与可靠性备受关注。然而,在高温、高负荷运行环境下,充电桩的散热问题与消防安全隐患日益凸显,成为制约行业发展的关键瓶颈。 如何通过智慧化管理手段优化散…...
C++.OpenGL (10/64)基础光照(Basic Lighting)
基础光照(Basic Lighting) 冯氏光照模型(Phong Lighting Model) #mermaid-svg-GLdskXwWINxNGHso {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-GLdskXwWINxNGHso .error-icon{fill:#552222;}#mermaid-svg-GLd…...
【RockeMQ】第2节|RocketMQ快速实战以及核⼼概念详解(二)
升级Dledger高可用集群 一、主从架构的不足与Dledger的定位 主从架构缺陷 数据备份依赖Slave节点,但无自动故障转移能力,Master宕机后需人工切换,期间消息可能无法读取。Slave仅存储数据,无法主动升级为Master响应请求ÿ…...

Mac下Android Studio扫描根目录卡死问题记录
环境信息 操作系统: macOS 15.5 (Apple M2芯片)Android Studio版本: Meerkat Feature Drop | 2024.3.2 Patch 1 (Build #AI-243.26053.27.2432.13536105, 2025年5月22日构建) 问题现象 在项目开发过程中,提示一个依赖外部头文件的cpp源文件需要同步,点…...
Python 包管理器 uv 介绍
Python 包管理器 uv 全面介绍 uv 是由 Astral(热门工具 Ruff 的开发者)推出的下一代高性能 Python 包管理器和构建工具,用 Rust 编写。它旨在解决传统工具(如 pip、virtualenv、pip-tools)的性能瓶颈,同时…...
IP如何挑?2025年海外专线IP如何购买?
你花了时间和预算买了IP,结果IP质量不佳,项目效率低下不说,还可能带来莫名的网络问题,是不是太闹心了?尤其是在面对海外专线IP时,到底怎么才能买到适合自己的呢?所以,挑IP绝对是个技…...