0%

SpringBoot和Vue的全栈开发教程

黑马最近新上了一套关于 SpringBoot3 和 Vue3 的课程,想着明年也是要写毕业论文了,就抓紧过来学习学习

课程地址:https://www.bilibili.com/video/BV14z4y1N7pg

复习一下后端

SpringBoot工程创建

  1. 新建项目,选择Spring Initializr,修改项目名称、文件路径等信息

注意,SpringBoot 需要版本为17 的 JDK 和 Java

  1. SpringBoot 项目选择3.0以上的版本,然后在起步依赖中勾选 Spring Web

  1. 编写控制类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @RestController
    public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
    System.out.println("Hello, World!");
    return "Hello, World!";
    }
    }

  2. 运行启动类

配置文件

SpringBoot 提供了多种属性配置方式,其中最常用的是 yml 文件格式 - 将 application.properties 修改为 .yml 文件后,尝试输入以下配置:

1
2
3
4
server:
port: 8081
servlet:
context-path: /start
该配置修改了项目的启动端口和虚拟路径,在浏览器访问的时候需要输入http://localhost:8081/start/

  • 配置文件中通常存在三方技术的配置信息和自定义配置信息,三方技术的配置信息可以参考技术提供者编写的文档等,而自定义配置信息需要以下步骤进行书写与获取:
  1. 比如在配置文件中自定义邮件的发送信息,注意添加前缀防止键名冲突

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    email:
    user: XXX@qq.com
    code: XXX
    host: smtp.qq.com
    auth: true

    # 数组类型
    hobbies:
    - 打篮球
    - 打游戏
    - 打豆豆

  2. 在实体类的成员属性上使用@Value("${键名}"),比如@Value("${email.user}")

    或者在类名上@ConfigurationProperties(prefix="前缀"),比如@ConfigurationProperties(prefix="email"),但是前提是实体类的成员变量名与配置文件中的键名需要保持一致

整合MyBatis

  1. 引入 MyBatis 和 MySQL的坐标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- MySQL 驱动依赖 -->
    <dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.0.31</version>
    </dependency>

    <!-- MyBatis 起步依赖 -->
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.0</version>
    </dependency>

  2. 在配置文件中配置数据源信息

    1
    2
    3
    4
    5
    6
    spring:
    datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/mybatis
    username: root
    password: 2002

大事件项目开发

用户相关接口

注册

  1. 添加 lombok 起步依赖

    1
    2
    3
    4
    5
    <!-- lombok依赖 -->
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    </dependency>

  2. 编写用户实体类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Data
    public class User {
    private Integer id;//主键ID
    private String username;//用户名
    private String password;//密码
    private String nickname;//昵称
    private String email;//邮箱
    private String userPic;//用户头像地址
    private LocalDateTime createTime;//创建时间
    private LocalDateTime updateTime;//更新时间
    }

  3. 编写通用返回结果类Result 注意,其中return new Result<>(0, "操作成功", data);等返回语句是使用了有参构造方法实例化对象,因此需要使用@AllArgsConstructor注解自动地生成有参构造方法。@Data注解不包括自动生成无参和有参构造

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    //统一响应结果
    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    public class Result<T> {
    private Integer code;//业务状态码 0-成功 1-失败
    private String message;//提示信息
    private T data;//响应数据

    //快速返回操作成功响应结果(带响应数据)
    public static <E> Result<E> success(E data) {
    return new Result<>(0, "操作成功", data);
    }

    //快速返回操作成功响应结果
    public static Result success() {
    return new Result(0, "操作成功", null);
    }

    public static Result error(String message) {
    return new Result(1, message, null);
    }
    }

  4. 编写UserController并实现注册方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @RestController
    @RequestMapping("/user")
    public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/register")
    public Result register(String username, String password){
    // 1.查询用户
    User user = userService.findByUsername(username);
    if(user == null){
    //用户名未占用,则进行注册
    userService.register(username, password);
    return Result.success();
    }else{
    //用户名已占用,则返回错误
    return Result.error("用户名已被占用");
    }
    }
    }

  5. 编写UserServiceUserServiceImpl,并提供接口

    1
    2
    3
    4
    5
    6
    7
    8
    public interface UserService {

    //根据用户名查询用户
    User findByUsername(String username);

    //用户注册
    void register(String username, String password);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Service
    public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public User findByUsername(String username) {
    return userMapper.findByUsername(username);
    }

    @Override
    public void register(String username, String password) {
    //密码加密
    String md5String = Md5Util.getMD5String(password);

    //添加用户
    userMapper.add(username, md5String);
    }
    }
    注意,这里是我们自己提供的MD5加密工具类,也可以使用Spring提供的工具类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    public class Md5Util {
    /**
    * 默认的密码字符串组合,用来将字节转换成 16 进制表示的字符,apache校验下载的文件的正确性用的就是默认的这个组合
    */
    protected static char hexDigits[] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};

    protected static MessageDigest messagedigest = null;

    static {
    try {
    messagedigest = MessageDigest.getInstance("MD5");
    } catch (NoSuchAlgorithmException nsaex) {
    System.err.println(Md5Util.class.getName() + "初始化失败,MessageDigest不支持MD5Util。");
    nsaex.printStackTrace();
    }
    }

    /**
    * 生成字符串的md5校验值
    *
    * @param s
    * @return
    */
    public static String getMD5String(String s) {
    return getMD5String(s.getBytes());
    }

    /**
    * 判断字符串的md5校验码是否与一个已知的md5码相匹配
    *
    * @param password 要校验的字符串
    * @param md5PwdStr 已知的md5校验码
    * @return
    */
    public static boolean checkPassword(String password, String md5PwdStr) {
    String s = getMD5String(password);
    return s.equals(md5PwdStr);
    }


    public static String getMD5String(byte[] bytes) {
    messagedigest.update(bytes);
    return bufferToHex(messagedigest.digest());
    }

    private static String bufferToHex(byte bytes[]) {
    return bufferToHex(bytes, 0, bytes.length);
    }

    private static String bufferToHex(byte bytes[], int m, int n) {
    StringBuffer stringbuffer = new StringBuffer(2 * n);
    int k = m + n;
    for (int l = m; l < k; l++) {
    appendHexPair(bytes[l], stringbuffer);
    }
    return stringbuffer.toString();
    }

    private static void appendHexPair(byte bt, StringBuffer stringbuffer) {
    char c0 = hexDigits[(bt & 0xf0) >> 4];// 取字节中高 4 位的数字转换, >>>
    // 为逻辑右移,将符号位一起右移,此处未发现两种符号有何不同
    char c1 = hexDigits[bt & 0xf];// 取字节中低 4 位的数字转换
    stringbuffer.append(c0);
    stringbuffer.append(c1);
    }

    }
    1
    password = DigestUtils.md5DigestAsHex(password.getBytes());

  6. 编写UserMapper,并编写SQL语句

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Mapper
    public interface UserMapper {

    //根据用户名查询用户
    @Select("select * from user where username = #{username}")
    User findByUsername(String username);

    //添加用户
    @Insert("insert into user(username, password, create_time, update_time)" +
    "values(#{username}, #{password}, now(), now())")
    void add(String username, String password);
    }
    ##### Spring Validation参数校验

  7. 引入Spring Validation依赖

    1
    2
    3
    4
    5
    <!-- Spring Validation -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

  8. 在需要参数校验的控制类上添加@Validated注解

    1
    2
    @Validated
    public class UserController {

  9. 在需要参数校验的形参前添加@Pattern注解,并在regexp参数中添加正则表达式

    1
    public Result register(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$")String password){

  10. 如果此时我们进行测试,我们会发现返回的是500异常信息,并不符合接口开发的约定,因此我们需要全局异常处理器,将异常信息封装为结果类对象的JSON字符串返回给前端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestControllerAdvice
    public class GlobalExceptionHandler {

    @ExceptionHandler
    public Result handlerException(Exception e){
    //打印异常的堆栈信息
    e.printStackTrace();
    //有些异常可能没有具体信息,需要判断
    return Result.error(StringUtils.hasLength(e.getMessage())? e.getMessage() : "操作失败");
    }
    }

登录

  1. UserController中实现基本登录方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @PostMapping("/login")
    public Result<String> login(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$")String password){
    //根据用户名查询用户
    User loginUser = userService.findByUsername(username);
    //判断用户是否存在
    if(loginUser == null){
    return Result.error("用户名错误");
    }
    //判断密码是否正确
    if (Md5Util.getMD5String(password).equals(loginUser.getPassword())) {
    //登录成功
    return Result.success("jwt token令牌");
    }
    return Result.error("密码错误");
    }
JWT详解

JWT令牌就是一段字符串,用于承载业务数据,减少后续请求查询数据库的次数,同时防篡改,保证信息的合法性和有效性 组成: 1. 第一部分:Header(头),记录令牌类型、签名算法等。例如{"alg":"HS256","type":"JWT"} 2. 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。例如{"id":"1","username":"Tom"} 3. 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来

我们需要把业务重点放在令牌的生成验证上,下面通过单元测试解释两个流程: 1. 引入JWT依赖、单元测试依赖

1
2
3
4
5
6
<!-- java jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
1
2
3
4
5
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
2. 编写测试生成的代码
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testGen(){
Map<String,Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("username", "张三");
//生成jwt
String token = JWT.create()
.withClaim("user", claims) //添加载荷
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12)) //添加过期时间
.sign(Algorithm.HMAC256("sues")); //指定算法,配置秘钥
System.out.println(token);
}
3. 编写测试验证的代码
1
2
3
4
5
6
7
8
9
@Test
public void testParse(){
//定义字符串,模拟用户传递过来的字符串
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjp7ImlkIjoxLCJ1c2VybmFtZSI6IuW8oOS4iSJ9LCJleHAiOjE2OTk4ODQzNzJ9.DNJn2YUBZWTLReGbIjGLBvwV0cnM_W_s0YkadcL-iug";
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("sues")).build(); //创建一个JWT解析器
DecodedJWT decodedJWT = jwtVerifier.verify(token); //验证token,生成一个解析后的JWT对象
Map<String, Claim> claims = decodedJWT.getClaims(); //获取载荷
System.out.println(claims);
}

登录认证
  1. 导入JWT工具类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class JwtUtil {

    private static final String KEY = "sues";

    //接收业务数据,生成token并返回
    public static String genToken(Map<String, Object> claims) {
    return JWT.create()
    .withClaim("claims", claims)
    .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))
    .sign(Algorithm.HMAC256(KEY));
    }

    //接收token,验证token,并返回业务数据
    public static Map<String, Object> parseToken(String token) {
    return JWT.require(Algorithm.HMAC256(KEY))
    .build()
    .verify(token)
    .getClaim("claims")
    .asMap();
    }
    }
  2. 在用户登录方法中添加生成token的代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //判断密码是否正确
    if (Md5Util.getMD5String(password).equals(loginUser.getPassword())) {
    //登录成功
    //将id和username放进载荷中
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", loginUser.getId());
    claims.put("username", loginUser.getUsername());
    //将载荷和其他固定的加密信息生成token
    String token = JwtUtil.genToken(claims);
    return Result.success(token);
    }
  3. 编写LoginInterceptor拦截器,重写preHandle方法,验证token的合法性
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Component
    public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //令牌验证
    String token = request.getHeader("Authorization");
    //验证token
    try {
    Map<String, Object> claims = JwtUtil.parseToken(token);
    //放行
    return true;
    }catch (Exception e){
    //http响应状态码为401
    response.setStatus(401);
    //不放行
    return false;
    }
    }
    }
  4. 编写WebConfig注册拦截器
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Configuration
    public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    //登录接口和注册接口不拦截
    registry.addInterceptor(loginInterceptor).excludePathPatterns("/user/login", "/user/register");
    }
    }

获取用户详细信息

  1. UserController中实现方法
    1
    2
    3
    4
    5
    6
    7
    8
    @GetMapping("/userInfo")
    public Result<User> userInfo(@RequestHeader(name = "Authorization")String token){
    //根据用户名查询用户
    Map<String, Object> map = JwtUtil.parseToken(token);
    String username = (String) map.get("username");
    User user = userService.findByUsername(username);
    return Result.success(user);
    }
  2. 我们可以在Postman中的collection的Pre-request中预定义一些内容,避免每次需要在请求头中修改token
    1
    pm.request.addHeader("Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGFpbXMiOnsiaWQiOjIsInVzZXJuYW1lIjoid2FuZ2JhIn0sImV4cCI6MTY5OTg5NzUwMX0._6--dUMo_zpZOFeOTn29Eqs0zZe3YrQDtt9g9QrpPy8")
  3. 观察返回的结果,我们需要避免出现密码这类敏感信息,在用户实体类中添加注解
    1
    2
    @JsonIgnore //把当前对象转为json字符串时忽略密码
    private String password;//密码
  4. 在返回结果中创建时间和更新时间也没有值,是因为数据库中的字段名和属性名没有对应上,我们需要在配置文件中添加驼峰命名和下划线命名的转换
    1
    2
    3
    mybatis:
    configuration:
    map-underscore-to-camel-case: true #开启驼峰命名和下划线命名的转换
Threadlocal优化

为了避免每次都需要验证token和在形参中写一长串代码,我们可以将数据存放进ThreadLocal中,每位用户在访问系统的时候,Tomcat都会单独为每位用户开辟一个线程,每个线程的ThreadLocal都能保证线程安全。 ThreadLocal用于存储数据:set()get();使用ThreadLocal存储的数据,线程安全;用完需要调用remove()方法释放 1. 导入ThreadLocal工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* ThreadLocal 工具类
*/
@SuppressWarnings("all")
public class ThreadLocalUtil {
//提供ThreadLocal对象
private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();

//根据键获取值
public static <T> T get(){
return (T) THREAD_LOCAL.get();
}

//存储键值对
public static void set(Object value){
THREAD_LOCAL.set(value);
}


//清除ThreadLocal 防止内存泄漏
public static void remove(){
THREAD_LOCAL.remove();
}
}
2. 在LoginIntercepter中将业务数据存储到ThreadLocal中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//令牌验证
String token = request.getHeader("Authorization");
//验证token
try {
Map<String, Object> claims = JwtUtil.parseToken(token);
//将业务数据存储到ThreadLocal中
ThreadLocalUtil.set(claims);
//放行
return true;
}catch (Exception e){
//http响应状态码为401
response.setStatus(401);
//不放行
return false;
}
}
3. 重写afterCompletion方法,清除ThreadLocal的变量
1
2
3
4
5
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//清空ThreadLocal中的数据
ThreadLocalUtil.remove();
}
4. 在UserController中通过ThreadLocal获取值
1
2
3
4
5
6
7
8
@GetMapping("/userInfo")
public Result<User> userInfo(){
//根据用户名查询用户
Map<String, Object> map = ThreadLocalUtil.get();
String username = (String) map.get("username");
User user = userService.findByUsername(username);
return Result.success(user);
}

更新用户信息

区别于上面的个别参数校验,有时候前端会传过来一个对象(实际上是json字符串),就不能在形参上对每个成员变量做校验了,因此我们可以在实体类的成员变量上添加注释,比如@NotNull不能为空、@NotEmpty不能为空的同时字符串不能为空、@Email检验邮箱字符串的合法性

  1. 在用户实体类上添加注解
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    public class User {
    @NotNull
    private Integer id;//主键ID
    private String username;//用户名
    @JsonIgnore //把当前对象转为json字符串时忽略密码
    private String password;//密码
    @NotEmpty
    @Pattern(regexp = "^\\S{1,10}$")
    private String nickname;//昵称
    @NotEmpty
    @Email
    private String email;//邮箱
    private String userPic;//用户头像地址
    private LocalDateTime createTime;//创建时间
    private LocalDateTime updateTime;//更新时间
    }
  2. UserController实现更新用户信息的方法,并且在形参上添加@Validated注解
    1
    2
    3
    4
    5
    @PutMapping("/update")
    public Result update(@RequestBody @Validated User user){
    userService.update(user);
    return Result.success();
    }
  3. UserServiceImpl中实现接口方法
    1
    2
    3
    4
    5
    @Override
    public void update(User user) {
    user.setUpdateTime(LocalDateTime.now());
    userMapper.update(user);
    }
  4. UserMapper中实现操作数据库的方法
    1
    2
    3
    //更新用户
    @Update("update user set nickname = #{nickname}, email = #{email}, update_time = #{updateTime} where id = #{id}")
    void update(User user);
更新用户头像
  1. UserController中实现方法
    1
    2
    3
    4
    5
    @PatchMapping("/updateAvatar")
    public Result updateAvatar(@RequestParam @URL String avatarUrl){
    userService.updateAvatar(avatarUrl);
    return Result.success();
    }
    其中使用validation的@URL校验传递过来的参数是否是URL
  2. UserServiceImpl中实现接口
    1
    2
    3
    4
    5
    6
    @Override
    public void updateAvatar(String avatarUrl) {
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");
    userMapper.updateAvatar(avatarUrl, id);
    }
    其中,通过ThreadLocal获取用户id
  3. UserMapper中对数据库进行操作
    1
    2
    3
    //更新头像
    @Update("update user set user_pic = #{avatarUrl}, update_time = now() where id = #{id}")
    void updateAvatar(String avatarUrl, Integer id);
更新用户密码
  1. UserController中实现方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @PatchMapping("/updatePwd")
    public Result updatePwd(@RequestBody Map<String, String> params){
    //参数校验
    String old_pwd = params.get("old_pwd");
    String new_pwd = params.get("new_pwd");
    String re_pwd = params.get("re_pwd");
    if(!StringUtils.hasLength(old_pwd) || !StringUtils.hasLength(new_pwd) || !StringUtils.hasLength(re_pwd)){
    return Result.error("缺少必要的参数");
    }
    //根据用户名获取密码
    Map<String, Object> map = ThreadLocalUtil.get();
    String username = (String) map.get("username");
    User user = userService.findByUsername(username);
    //和old_pwd进行比对
    if(!user.getPassword().equals(Md5Util.getMD5String(old_pwd))){
    return Result.error("原密码填写不正确");
    }
    //new_pwd和re_pwd是否一样
    if(!new_pwd.equals(re_pwd)){
    return Result.error("两次填写的新密码不一样");
    }
    //调用Service完成密码更新
    userService.updatePwd(new_pwd);
    return Result.success();
    }

  2. UserServiceImpl中实现接口

    1
    2
    3
    4
    5
    6
    @Override
    public void updatePwd(String new_pwd) {
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");
    userMapper.updatePwd(Md5Util.getMD5String(new_pwd), id);
    }

  3. UserMapper中对数据库进行操作

    1
    2
    3
    //更新密码
    @Update("update user set password = #{md5String}, update_time = now() where id = #{id}")
    void updatePwd(String md5String, Integer id);

分类相关接口

新增分类

  1. 导入分类实体类,其中名称和分类是用户需要填写的,应设校验

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Data
    public class Category {
    private Integer id;//主键ID
    @NotEmpty
    private String categoryName;//分类名称
    @NotEmpty
    private String categoryAlias;//分类别名
    private Integer createUser;//创建人ID
    private LocalDateTime createTime;//创建时间
    private LocalDateTime updateTime;//更新时间
    }

  2. CategoryController中实现方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @RestController
    @RequestMapping("/category")
    public class CategoryController {

    @Autowired
    private CategoryService categoryService;

    @PostMapping
    public Result addCategory(@RequestBody @Validated Category category){
    categoryService.addCategory(category);
    return Result.success();
    }
    }

  3. CategoryServiceImpl中实现接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //新增分类
    @Override
    public void addCategory(Category category) {
    //补充属性
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");
    category.setCreateTime(LocalDateTime.now());
    category.setUpdateTime(LocalDateTime.now());
    category.setCreateUser(id);

    categoryMapper.addCategory(category);

  4. CategoryMapper中操作数据库

    1
    2
    3
    4
    //新增分类
    @Insert("insert into category(category_name, category_alias, create_user, create_time, update_time)" +
    "values(#{categoryName}, #{categoryAlias}, #{createUser}, #{createTime}, #{updateTime})")
    void addCategory(Category category);

查询分类

  1. CategoryController中实现方法

    1
    2
    3
    4
    5
    @GetMapping
    public Result<List<Category>> list(){
    List<Category> categoryList = categoryService.list();
    return Result.success(categoryList);
    }

  2. CategoryServiceImpl中实现接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //查询分类列表
    @Override
    public List<Category> list() {
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");

    List<Category> categoryList = categoryMapper.list(id);
    return categoryList;
    }

  3. CategoryMapper中操作数据库

    1
    2
    3
    //查询分类列表
    @Select("select * from category where create_user = #{id}")
    List<Category> list(Integer id);

  4. 在分类实体类的创建时间和更新时间上添加注解,以展示正确的时间格式

    1
    2
    3
    4
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;//创建时间
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;//更新时间

查询分类详情

查询分类详情用于用户在点击编辑按钮的时候可以回显数据 1. 在CategoryController中实现方法

1
2
3
4
5
@GetMapping("/detail")
public Result<Category> detail(Integer id){
Category category = categoryService.findById(id);
return Result.success(category);
}
2. 在CategoryServiceImpl中实现接口
1
2
3
4
5
6
//根据id查询分类详情
@Override
public Category findById(Integer id) {
Category category = categoryMapper.findById(id);
return category;
}
3. 在CategoryMapper中操作数据库
1
2
3
//根据id查询分类详情
@Select("select * from category where id = #{id}")
Category findById(Integer id);

删除分类

  1. CategoryController中实现方法
    1
    2
    3
    4
    5
    @DeleteMapping
    public Result deleteById(Integer id){
    categoryService.deleteById(id);
    return Result.success();
    }
  2. CategoryServiceImpl中实现接口
    1
    2
    3
    4
    5
    //根据id删除分类
    @Override
    public void deleteById(Integer id) {
    categoryMapper.deleteById(id);
    }
  3. CategoryMapper中操作数据库
    1
    2
    3
    //根据id删除分类
    @Delete("delete from category where id = #{id}")
    void deleteById(Integer id);

更新分类

  1. CategoryController中实现方法
    1
    2
    3
    4
    5
    @PutMapping
    public Result update(@RequestBody @Validated Category category){
    categoryService.update(category);
    return Result.success();
    }
  2. CategoryServiceImpl中实现接口
    1
    2
    3
    4
    5
    @Override
    public void update(Category category) {
    category.setUpdateTime(LocalDateTime.now());
    categoryMapper.update(category);
    }
  3. CategoryMapper中操作数据库
    1
    2
    3
    4
    //更新分类
    @Update("update category set category_name = #{categoryName}, category_alias = #{categoryAlias}, update_time = #{updateTime} " +
    "where id = #{id}")
    void update(Category category);
  4. 在分类实体类中为 id 添加非空校验
    1
    2
    @NotNull
    private Integer id;//主键ID
分组校验

当给 id 添加非空校验后,如果提交添加分类请求就会报错,因为没有携带 id 参数。为了解决这个问题,我们需要给校验做分组,即不同参数使用不同的校验 1. 在实体类中定义分组

1
2
public interface Add{}
public interface Update{}
2. 定义校验项时指定归属的分组
1
2
3
4
5
6
@NotNull(groups = Update.class)
private Integer id;//主键ID
@NotEmpty(groups = {Update.class, Add.class})
private String categoryName;//分类名称
@NotEmpty(groups = {Update.class, Add.class})
private String categoryAlias;//分类别名
3. 校验时指定要校验的分组
1
2
3
public Result addCategory(@RequestBody @Validated(Category.Add.class) Category category)

public Result update(@RequestBody @Validated(Category.Update.class) Category category)

但是如果碰到分组较多的情况时,我们也有办法简化代码。如果某个校验项没有指定分组,默认属于Default分组;分组之间也可以继承,比如A extends B,表示A中拥有B的所有校验项

  1. 在实体类中定义分组
    1
    2
    public interface Add extends Default {}
    public interface Update extends Default {}
  2. 定义校验项时指定归属的分组
    1
    2
    3
    4
    5
    6
    @NotNull(groups = Update.class)
    private Integer id;//主键ID
    @NotEmpty
    private String categoryName;//分类名称
    @NotEmpty
    private String categoryAlias;//分类别名
  3. 校验时指定要校验的分组

文章相关接口

新增文章

  1. ArticleController中实现方法
    1
    2
    3
    4
    5
    @PostMapping
    public Result add(@RequestBody @Validated Article article){
    articleService.add(article);
    return Result.success();
    }
  2. ArticleServiceImpl中实现接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //新增文章
    @Override
    public void add(Article article) {
    //补充属性
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");
    article.setCreateTime(LocalDateTime.now());
    article.setUpdateTime(LocalDateTime.now());
    article.setCreateUser(id);

    articleMapper.add(article);
    }
  3. ArticleMapper中操作数据库
    1
    2
    3
    4
    //新增文章
    @Insert("insert into article(title, content, cover_img, state, category_id, create_user, create_time, update_time) " +
    "values(#{title}, #{content}, #{coverImg}, #{state}, #{categoryId}, #{createUser}, #{createTime}, #{updateTime})")
    void add(Article article);
  4. 在文章实体类中添加注解校验
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    private Integer id;//主键ID
    @NotEmpty
    @Pattern(regexp = "^\\S{1,10}$")
    private String title;//文章标题
    @NotEmpty
    private String content;//文章内容
    @NotEmpty
    @URL
    private String coverImg;//封面图像
    private String state;//发布状态 已发布|草稿
    @NotNull
    private Integer categoryId;//文章分类id
    private Integer createUser;//创建人ID
    private LocalDateTime createTime;//创建时间
    private LocalDateTime updateTime;//更新时间
自定义注解

我们需要为state也赋予校验,但是没有现成的注解能够提供校验,因此我们需要自定义校验 1. 创建 anno 文件夹,自定义注解State

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Documented //元注解
@Target({ElementType.FIELD}) //指定该注解在哪里可以使用
@Retention(RetentionPolicy.RUNTIME) //指定该注解在何时使用
@Constraint(
validatedBy = { StateValidation.class }
) //提供校验规则的类
public @interface State {

//提供校验失败后的展示信息
String message() default "state参数的值只能是“已发布或”或“草稿”";

//指定分组
Class<?>[] groups() default {};

//负载:获取State注解的附加信息
Class<? extends Payload>[] payload() default {};
}
注意:必须包括三个变量:messagegroupspayload

  1. 创建 validation 文件夹,自定义校验数据的类StateValidation

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class StateValidation implements ConstraintValidator<State, String>  {

    /**
    *
    * @param s 要校验的数据
    * @param constraintValidatorContext
    * @return 返回true表示校验通过,返回false表示校验不通过
    */
    @Override
    public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
    //提供校验规则
    if(s == null){
    return false;
    }
    if(s.equals("已发布") || s.equals("草稿")){
    return true;
    }
    return false;
    }
    }

  2. 在需要校验的地方使用自定义注解

    1
    2
    @State
    private String state;//发布状态 已发布|草稿

文章分页查询

  1. 编写PageBean实体类,将总条数和当前页每条数据的详情封装在一起

    1
    2
    3
    4
    5
    6
    7
    8
    //分页返回结果对象
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class PageBean <T>{
    private Long total;//总条数
    private List<T> items;//当前页数据集合
    }

  2. ArticleController中实现方法,其中categoryId文章分类和state文章状态是搜索下拉框选定的,属于可选参数,因此需要@RequestParam(required = false)

    1
    2
    3
    4
    5
    6
    7
    8
    @GetMapping
    public Result<PageBean<Article>> list(Integer pageNum,
    Integer pageSize,
    @RequestParam(required = false) Integer categoryId,
    @RequestParam(required = false) String state) {
    PageBean<Article> pb = articleService.list(pageNum, pageSize, categoryId, state);
    return Result.success(pb);
    }

  3. 导入PageHelper坐标

    1
    2
    3
    4
    5
    6
    <!-- pageHelper坐标 -->
    <dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.4.6</version>
    </dependency>

  4. ArticleServiceImpl中实现接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    //文章分页查询
    @Override
    public PageBean<Article> list(Integer pageNum, Integer pageSize, Integer categoryId, String state) {

    //创建PageBean对象
    PageBean<Article> pb = new PageBean<>();

    //开启分页查询 PageHelper
    PageHelper.startPage(pageNum, pageSize);

    //调用Mapper
    Map<String, Object> map = ThreadLocalUtil.get();
    Integer id = (Integer) map.get("id");
    List<Article> articleList = articleMapper.list(id, categoryId, state);

    Page<Article> p = (Page<Article>) articleList;
    pb.setTotal(p.getTotal());
    pb.setItems(p.getResult());

    return pb;
    }
    使用PageHelper.startPage()通过设置当前页和每页数据条数开启分页查询,然后查找文章列表,将文章列表强转为Page对象,其中包含每页的数据,还有总页数等元数据,最后将Page对象的两个属性赋值给pb返回

  5. ArticleMapper中操作数据库

    1
    2
    //文章分页查询
    List<Article> list(Integer id, Integer categoryId, String state);
    由于是动态SQL语句,使用注解的方式编写SQL语句不太方便,因此需要 MyBatis 映射文件

  6. 在 resources 文件夹下,创建com/sues/bigevent/mapper目录,注意是/,创建 ArticleMapper.xml,路径和文件名保持一致

    1
    2
    3
    4
    5
    6
    7
    <?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="">

    </mapper>
    编写动态SQL语句
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <?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.sues.bigevent.mapper.ArticleMapper">
    <!-- 指定需要映射的方法名和返回值类型 -->
    <select id="list" resultType="com.sues.bigevent.pojo.Article">
    select * from article
    <where>
    <if test="categoryId != null">
    category_id = #{categoryId}
    </if>
    <if test="state != null">
    and state = #{state}
    </if>
    and create_user = #{id}
    </where>
    </select>
    </mapper>

获取文章详情

  1. ArticleController中实现方法
    1
    2
    3
    4
    5
    @GetMapping("/detail")
    public Result<Article> findById(Integer id){
    Article article = articleService.findById(id);
    return Result.success(article);
    }
  2. ArticleServiceImpl中实现接口
    1
    2
    3
    4
    5
    @Override
    public Article findById(Integer id) {
    Article article = articleMapper.findById(id);
    return article;
    }
  3. ArticleMapper中操作数据库
    1
    2
    3
    //获取文章详情
    @Select("select * from article where id = #{id}")
    Article findById(Integer id);

更新文章

  1. ArticleController中实现方法
    1
    2
    3
    4
    5
    @PutMapping
    public Result update(@RequestBody @Validated(Article.Update.class) Article article){
    articleService.update(article);
    return Result.success();
    }
  2. ArticleServiceImpl中实现接口
    1
    2
    3
    4
    5
    @Override
    public void update(Article article) {
    article.setUpdateTime(LocalDateTime.now());
    articleMapper.update(article);
    }
  3. ArticleMapper中操作数据库
    1
    2
    3
    4
    5
    //更新文章
    @Update("update article set title = #{title}, content = #{content}, cover_img = #{coverImg}, " +
    "state = #{state}, category_id = #{categoryId}, update_time = #{updateTime} " +
    "where id = #{id}")
    void update(Article article);

删除文章

  1. ArticleController中实现方法
    1
    2
    3
    4
    5
    @DeleteMapping
    public Result delete(Integer id){
    articleService.delete(id);
    return Result.success();
    }
  2. ArticleServiceImpl中实现接口
    1
    2
    3
    4
    @Override
    public void delete(Integer id) {
    articleMapper.delete(id);
    }
  3. ArticleMapper中操作数据库
    1
    2
    3
    //删除文章
    @Delete("delete from article where id = #{id}")
    void delete(Integer id);

文件上传接口

本地存储

  1. 首先了解上传文件的前端三要素

    1
    2
    3
    4
    <form action="/upload" method="post" enctype="multipart/form-data">
    头像:<input type="file" name="image"><br/>
    <input type="submit" value="提交">
    </form>
    前端三要素:请求方法post、请求参数格式multipart/form-data、input形式file

  2. 了解MultipartFile的 API

    1
    2
    3
    4
    5
    6
    7
    8
    9
    String getOriginFilename(); //获取原始文件名

    void transferTo(File dest); //将接收的文件转存到磁盘文件中

    long getSize(); //获取文件的大小,单位:字节

    byte[] getBytes(); //获取文件内容的字节数组

    InputStream getInputStream(); //获取接收到的文件内容的输入流

  3. 新建FileUploadController

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @RestController
    public class FileUploadController {

    @PostMapping("/upload")
    public Result<String> upload(MultipartFile file) throws IOException {
    //将文件的内容存储到本地磁盘中
    String originalFilename = file.getOriginalFilename();
    //保证文件名是唯一的,防止文件覆盖:使用UUID + 原后缀名
    String filename = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
    file.transferTo(new File("D:\\Software\\IDEA Projects\\images\\" + filename));
    return Result.success("url访问地址");
    }
    }

阿里云OSS存储文件

  1. 添加阿里云OSS依赖坐标 > 参考文档:https://help.aliyun.com/zh/oss/developer-reference/java-installation

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <!-- 阿里云OSS依赖坐标 START -->
    <dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.1</version>
    </dependency>
    <dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
    </dependency>
    <dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
    </dependency>
    <!-- no more than 2.3.3-->
    <dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.3.3</version>
    </dependency>
    <!-- 阿里云OSS依赖坐标 END -->

  2. 编写代码实现文件上传 > 参考文档:https://help.aliyun.com/zh/oss/developer-reference/simple-upload-11

我们将SDK修改并封装为自己的工具类进行使用,新建AliOssUtil,获取阿里云账户的访问凭证 Access_Key 和 Access_Secret,然后创建 Bucket,获取区域节点和 Bucket 名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class AliOssUtil {

// Endpoint:华东2(上海)
private static final String ENDPOINT = "XXX";
// 访问凭证:ACCESS_KEY_ID和ACCESS_KEY_SECRET
//EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
private static final String ACCESS_KEY_ID = "XXX";
private static final String ACCESS_KEY_SECRET = "XXX";
// Bucket名称
private static final String BUCKET_NAME = "XXX";


public static String uploadFile(String objName, InputStream inputStream) throws Exception {

// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);

String url = "";

try {
// 填写字符串。
String content = "Hello OSS,你好世界";

// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, objName, inputStream);

// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);

// 上传字符串。
PutObjectResult result = ossClient.putObject(putObjectRequest);

//url组成:https://bucket名称.区域节点/文件名
url = "https://" + BUCKET_NAME + "." + ENDPOINT.substring(ENDPOINT.lastIndexOf("/") + 1) + "/" + objName;

} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}

return url;
}
}

  1. 修改FileUploadController
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @RestController
    public class FileUploadController {

    @PostMapping("/upload")
    public Result<String> upload(MultipartFile file) throws Exception {
    //将文件的内容存储到本地磁盘中
    String originalFilename = file.getOriginalFilename();
    //保证文件名是唯一的,防止文件覆盖:使用UUID + 原后缀名
    String filename = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
    //file.transferTo(new File("D:\\Software\\IDEA Projects\\images\\" + filename));
    String url = AliOssUtil.uploadFile(filename, file.getInputStream());
    return Result.success(url);
    }
    }

登录优化

我们需要解决登录成功后获取到的令牌,即使重新登陆后或者修改密码后获取到了新的令牌,旧令牌依旧可用的问题。解决思路是: 1. 登录成功后,给浏览器响应令牌的同时,把该令牌存储到redis中 2. LoginInterceptor拦截器中,需要验证浏览器携带的令牌,并同时需要获取到redis中存储的与之相同的令牌

代码实现

  1. 导入redis坐标依赖
    1
    2
    3
    4
    5
    <!-- redis坐标 -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  2. UserController中注入StringRedisTemplate
    1
    2
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
  3. login方法中,判断密码是正确的后将生成的token存储到redis中
    1
    2
    3
    4
    //把token存储到redis中
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    //token字符串既做键也做值,并且设置过期时间为1小时,和token过期时间保持一致
    ops.set(token, token, 1, TimeUnit.HOURS);
  4. updatePwd方法中添加形参@RequestHeader("Authorization") String token用于获取token,在密码修改完成后删除对应的token
    1
    2
    3
    //删除redis中对应的token
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    ops.getOperations().delete(token);
  5. LoginIntercetor中查询token是否失效,如果失效抛出异常
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //令牌验证
    String token = request.getHeader("Authorization");
    //验证token
    try {
    //在redis中获取相同的token
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    String redisToken = ops.get(token);
    if(redisToken == null){
    //token失效
    throw new RuntimeException();
    }

    ...

项目部署

项目打包

  1. 添加 maven 打包插件,注意是插件,不是依赖
    1
    2
    3
    4
    5
    6
    <!-- 打包插件 -->
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <version>3.1.3</version>
    </plugin>
  2. 运行 maven 的 package 命令

属性配置

  1. 命令行参数方式 在命令后面直接跟上--加配置项的形式,比如:

    1
    java -jar XXX.jar --server.port=8081

  2. 环境变量方式 在用户环境变量中比如添加变量名server.port,添加变量值8081,设置换成后重启黑窗口再java -jar启动项目

  3. 外部配置文件方式 在jar包的同目录下创建另一个application.yml文件,写入需要的配置项,再java -jar启动项目。但是项目中resources目录下的配置文件优先级更高

多环境开发

多环境开发有单文件和多文件的配置方式,单文件在 SpringBoot 笔记中有介绍,就是将---将不同环境的配置项分隔开。但是单文件配置方式可能会因为写在一起导致不方便维护,因此可以采用多文件配置方式。在 resources 目录下新建 application-环境名称.yml ,例如 application-dev.yml 和 application-test.yml ,然后在 application.yml 中填写共性的配置项以及需要激活的环境

1
2
3
4
#指定激活的环境,比如test
spring:
profiles:
active: test

复习一下前端

js导入导出

模块导入导出各种类型的变量,如字符串,数值,函数,类 - 导出的函数声明与类声明必须要有名称(export default 命令另外考虑) - 不仅能导出声明还能导出引用(例如函数) - export命令可以出现在模块的任何位置,但必需处于模块顶层 - import命令会提升到整个模块的头部,首先执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*-----export [test.js]-----*/
let myName = "Tom";
let myAge = 20;
export myfn = function(){
return "My name is" + myName + "! I'm '" + myAge + "years old."
}
export myClass = class myClass {
static a = "yeah!";
}
export { myName, myAge }

/*-----import [xxx.js]-----*/
import { myName, myAge, myfn, myClass } from "./test.js";
console.log(myfn());// My name is Tom! I'm 20 years old.
console.log(myAge);// 20
console.log(myName);// Tom
console.log(myClass.a );// yeah!
默认导出不需要声明变量名,导入的时候也不可以需要写花括号
1
export default {}
不同模块导出接口名称命名重复, 使用 as 重新定义变量名
1
2
3
export { myName as exportName }
//或
import { myName as name1 } from "./test1.js";

element-plus使用

  1. 在需要存放代码文件的文件夹中打开终端,输入命令创建项目,然后输入项目名称
    1
    npm init vue@latest
  2. 进入项目文件夹,安装element-plus
    1
    2
    3
    4
    5
    6
    7
    cd 项目名称

    # 用VSCode打开项目
    code .

    # 安装element-plus
    npm install element-plus --save
  3. 参考官方文档,引入element-plus
    1
    2
    3
    4
    5
    6
    7
    8
    9
    import { createApp } from 'vue'
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    import App from './App.vue'

    const app = createApp(App)

    app.use(ElementPlus)
    app.mount('#app')

大事件前端开发

前期准备

  1. 在需要存放代码文件的文件夹中打开终端,输入命令创建项目,然后输入项目名称
    1
    npm init vue@latest
  2. 进入项目文件夹,安装element-plus
    1
    2
    3
    4
    cd big-event

    # 用VSCode打开项目
    code .
  3. 安装插件
    1
    2
    3
    4
    5
    6
    7
    8
    # 安装element-plus
    npm install element-plus --save

    # 安装sass
    npm install sass -D

    # 安装axios
    npm install axios
  4. 删除 components 下自动生成的内容
  5. 新建目录 api、utils、views
  6. 将资料中的静态资源拷贝到 assets 目录下
  7. 删除App.vue中自动生成的内容
  8. 复制粘贴静态资源到 assets 目录下
  9. 将 request.js 复制粘贴到 utils 目录下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    //定制请求的实例

    //导入axios npm install axios
    import axios from 'axios';
    //定义一个变量,记录公共的前缀 , baseURL
    const baseURL = 'http://localhost:8080';
    const instance = axios.create({baseURL})


    //添加响应拦截器
    instance.interceptors.response.use(
    result=>{
    return result.data;
    },
    err=>{
    alert('服务异常');
    return Promise.reject(err);//异步的状态转化成失败的状态
    }
    )

    export default instance;
  10. 在入口文件中导入 element-plus 和 静态资源
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import './assets/main.scss'

    import { createApp } from 'vue'
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    import App from './App.vue'

    const app = createApp(App)

    app.use(ElementPlus)
    app.mount('#app')

注册

页面搭建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
//控制注册与登录表单的显示, 默认显示注册
const isRegister = ref(false)
</script>

<template>
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册表单 -->
<el-form ref="form" size="large" autocomplete="off" v-if="isRegister">
<el-form-item>
<h1>注册</h1>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="Lock" type="password" placeholder="请输入密码"></el-input>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码"></el-input>
</el-form-item>
<!-- 注册按钮 -->
<el-form-item>
<el-button class="button" type="primary" auto-insert-space>
注册
</el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = false">
← 返回
</el-link>
</el-form-item>
</el-form>
<!-- 登录表单 -->
<el-form ref="form" size="large" autocomplete="off" v-else>
<el-form-item>
<h1>登录</h1>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item>
<el-input name="password" :prefix-icon="Lock" type="password" placeholder="请输入密码"></el-input>
</el-form-item>
<el-form-item class="flex">
<div class="flex">
<el-checkbox>记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码?</el-link>
</div>
</el-form-item>
<!-- 登录按钮 -->
<el-form-item>
<el-button class="button" type="primary" auto-insert-space>登录</el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = true">
注册 →
</el-link>
</el-form-item>
</el-form>
</el-col>
</el-row>
</template>

<style lang="scss" scoped>
/* 样式 */
.login-page {
height: 100vh;
background-color: #fff;

.bg {
background: url('@/assets/logo2.png') no-repeat 60% center / 240px auto,
url('@/assets/login_bg.jpg') no-repeat center / cover;
border-radius: 0 20px 20px 0;
}

.form {
display: flex;
flex-direction: column;
justify-content: center;
user-select: none;

.title {
margin: 0 auto;
}

.button {
width: 100%;
}

.flex {
width: 100%;
display: flex;
justify-content: space-between;
}
}
}
</style>

数据绑定

  1. 添加数据模型
    1
    2
    3
    4
    5
    6
    //定义数据模型
    const registerData = ref({
    username: '',
    password: '',
    rePassword: ''
    })
  2. 为注册表单的<el-form>添加:model="registerData",为每个表单小件添加v-model,比如确认密码文本框<el-input>添加v-model="registerData.rePassword"

表单校验

  1. 参照官方文档,定义表单校验规则,其中三个文本框都是把失焦事件作为触发器
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    //定义表单校验规则
    const rules = {
    username: [
    {
    required: true,
    message: '请输入用户名',
    trigger: 'blur'
    },
    {
    min: 5,
    max: 16,
    message: '长度为5~16位非空字符',
    trigger: 'blur'
    }
    ],
    password: [
    {
    required: true,
    message: '请输入密码',
    trigger: 'blur'
    },
    {
    min: 5,
    max: 16,
    message: '长度为5~16位非空字符',
    trigger: 'blur'
    }
    ],
    rePassword: [
    {
    validator: checkRePassword,
    trigger: 'blur'
    }
    ]
  2. 其中rePassword比较特殊,参照官方文档,需要自定义校验规则,注意自定义校验函数需要写在使用者rules的前面
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //校验密码的函数
    const checkRePassword = (rule, value, callback) => {
    if (value == '') {
    callback(new Error('请确认密码'))
    } else if (value !== registerData.value.password) {
    callback(new Error('请确保两次输入的密码一样'))
    } else {
    callback()
    }
    }
  3. 通过<el-form>添加:rules="rules",绑定校验规则
  4. 通过<el-form-item>添加props,指定校验项,比如确认密码prop="rePassword"

接口调用

  1. 在 api 文件夹中创建 user.js 文件,包括所有关于用户操作的 API

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 导入request.js请求工具
    import request from '@/utils/request.js'

    // 提供调用注册接口的函数
    export const userRegisterService = (registerData)=>{
    // 借助UrlSearchParams完成传递
    const params = new URLSearchParams()
    for(let key in registerData){
    params.append(key, registerData[key])
    }

    return request.post('/user/register', params)
    }
    使用URLSearchParams是一种方便的方式,它能够帮助你将js对象转换为符合 URL 参数形式的字符串,这样就可以通过params对象得到一个符合application/x-www-form-urlencoded格式的字符串,否则axios将会自动把js对象转为json字符串请求出去

  2. 为注册按钮绑定点击事件@click="register"并具体实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import {userRegisterService} from '@/api/user.js'

    // 调用后台接口,完成注册
    const register = async()=>{
    let result = await userRegisterService(registerData.value)
    if(result.code == 0){
    //成功
    alert(result.msg?result.msg:'注册成功')
    }else{
    //失败
    alert('注册失败')
    }
    }

跨域问题

  1. 将 request.js 中的公共前缀修改为/api
    1
    const baseURL = '/api';
  2. 在 vite.config.js中添加代理
    1
    2
    3
    4
    5
    6
    7
    8
    9
    server:{
    proxy:{
    '/api':{ //获取路径中包含了/api的请求
    target:'http://localhost:8080', //后台服务所在的源
    changeOrigin:true, //修改源
    rewrite:(path)=>path.replace(/^\/api/,'') //api替换为空字符串
    }
    }
    }

登录

数据绑定

  1. 数据模型我们可以复用注册数据,为登录表单和表单组件绑定数据
    1
    2
    3
    4
    5
    6
    //定义数据模型
    const registerData = ref({
    username: '',
    password: '',
    rePassword: ''
    })
    为登录表单绑定数据
    1
    <el-form ... :model="registerData">
    为表单组件绑定数据
    1
    <el-input ... v-model="registerData.username">
  2. 由于注册表单和登录表单共用一个数据模型,因此切换表单时,原来输入的数据会留在文本框中,所以需要在切换时清除数据,为返回按钮(链接)添加点击事件(已有点击事件的情况下,由于引号内实际上是js代码,所以按js代码的形式调用函数),注册按钮(链接)同理
    1
    2
    3
    4
    5
    6
    7
    <el-link type="info" :underline="false" @click="isRegister = false; clearRegisterData()">
    ← 返回
    </el-link>

    <el-link type="info" :underline="false" @click="isRegister = true; clearRegisterData()">
    注册 →
    </el-link>
  3. 实现清除数据的函数
    1
    2
    3
    4
    5
    6
    7
    8
    //定义函数,清空数据模型的数据
    const clearRegisterData = () => {
    registerData.value = {
    username: '',
    password: '',
    rePassword: ''
    }
    }

表单校验

  1. 我们也可以复用注册表单的校验规则,为表单赋予校验规则
    1
    <el-form ... :rules="rules">
  2. 为表单组件赋予对应的校验规则
    1
    2
    3
    <el-form-item prop="username">

    <el-form-item prop="password">

接口调用

  1. 为登录按钮赋予点击事件
    1
    <el-button class="button" type="primary" auto-insert-space @click="login">登录</el-button>
  2. 实现事件函数
    1
    2
    3
    4
    5
    6
    //登录函数
    const login = async () => {
    let result = await userLoginService(registerData.value)
    //alert(result.msg ? result.msg : '登录成功')
    ElMessage.success(result.message ? result.message : '登录成功')
    }
  3. 在 user.js 中提供调用登录接口的函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //提供调用登录接口的函数
    export const userLoginService = (loginData)=>{
    // 借助UrlSearchParams完成传递
    const params = new URLSearchParams()
    for(let key in loginData){
    params.append(key, loginData[key])
    }

    return request.post('/user/login', params)
    }
    #### 优化axios响应拦截器
  4. 我们可以使用 element-plus 的消息弹出框代替原生js的警告框,首先在 request.js 中引入组件
    1
    import { ElMessage } from 'element-plus'
  5. 我们可以将每次的判断响应状态码封装到响应拦截器中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    instance.interceptors.response.use(
    result => {
    //判断业务状态码
    if (result.data.code === 0) {
    return result.data;
    }
    //操作失败
    //alert(result.data.msg ? result.data.msg : '操作失败')
    ElMessage.error(result.data.message ? result.data.message : '操作失败')
    return Promise.reject(result.data);
    },
    err => {
    ElMessage.error('请求错误')
    return Promise.reject(err);//异步的状态转化成失败的状态
    }
    )
    所以在调用接口函数的时候,可以省略状态码的判断了,直接处理拿到的数据,比如在调用注册接口的时候(别忘了引入ElMessage组件)
    1
    2
    3
    4
    5
    6
    // 调用后台接口,完成注册
    const register = async () => {
    let result = await userRegisterService(registerData.value)
    //alert(result.msg ? result.msg : '注册成功')
    ElMessage.success(result.message ? result.message : '注册成功')
    }

主页面搭建和路由

主页面搭建

创建 Layout.vue 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
<script setup>
import {
Management,
Promotion,
UserFilled,
User,
Crop,
EditPen,
SwitchButton,
CaretBottom
} from '@element-plus/icons-vue'
import avatar from '@/assets/default.png'
</script>

<template>
<el-container class="layout-container">
<!-- 左侧菜单 -->
<el-aside width="200px">
<div class="el-aside__logo"></div>
<el-menu active-text-color="#ffd04b" background-color="#232323" text-color="#fff"
router>
<el-menu-item >
<el-icon>
<Management />
</el-icon>
<span>文章分类</span>
</el-menu-item>
<el-menu-item >
<el-icon>
<Promotion />
</el-icon>
<span>文章管理</span>
</el-menu-item>
<el-sub-menu >
<template #title>
<el-icon>
<UserFilled />
</el-icon>
<span>个人中心</span>
</template>
<el-menu-item >
<el-icon>
<User />
</el-icon>
<span>基本资料</span>
</el-menu-item>
<el-menu-item >
<el-icon>
<Crop />
</el-icon>
<span>更换头像</span>
</el-menu-item>
<el-menu-item >
<el-icon>
<EditPen />
</el-icon>
<span>重置密码</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 右侧主区域 -->
<el-container>
<!-- 头部区域 -->
<el-header>
<div>黑马程序员:<strong>东哥</strong></div>
<el-dropdown placement="bottom-end">
<span class="el-dropdown__box">
<el-avatar :src="avatar" />
<el-icon>
<CaretBottom />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile" :icon="User">基本资料</el-dropdown-item>
<el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
<el-dropdown-item command="password" :icon="EditPen">重置密码</el-dropdown-item>
<el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<!-- 中间区域 -->
<el-main>
<div style="width: 1290px; height: 570px;border: 1px solid red;">
内容展示区
</div>
</el-main>
<!-- 底部区域 -->
<el-footer>大事件 ©2023 Created by 黑马程序员</el-footer>
</el-container>
</el-container>
</template>

<style lang="scss" scoped>
.layout-container {
height: 100vh;

.el-aside {
background-color: #232323;

&__logo {
height: 120px;
background: url('@/assets/logo.png') no-repeat center / 120px auto;
}

.el-menu {
border-right: none;
}
}

.el-header {
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;

.el-dropdown__box {
display: flex;
align-items: center;

.el-icon {
color: #999;
margin-left: 10px;
}

&:active,
&:focus {
outline: none;
}
}
}

.el-footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
}
}
</style>
可以看到采用的左侧菜单和右侧主区域内的头部区域、中间区域和底部区域的布局

路由的基本使用

  1. 安装 vue-router

    1
    npm install vue-router@4

  2. 在 view 目录下分别创建 article 目录和 user 目录,并在其中分别创建 ArticleCategory.vue、ArticleManage.vue、UserAvatar.vue、UserInfo.vue和UserResetPassword.vue文件

  3. src/router/index.js中创建路由器,并导出。我们这里使用history模式,即请求路径中不会出现#

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    // 导入vue-router
    import { createRouter, createWebHistory } from 'vue-router'

    // 导入组件
    import LoginVue from '@/views/Login.vue'
    import LayoutVue from '@/views/Layout.vue'

    import ArticleCategoryVue from '@/views/article/ArticleCategory.vue'
    import ArticleManageVue from '@/views/article/ArticleManage.vue'

    import UserAvatarVue from '@/views/user/UserAvatar.vue'
    import UserInfoVue from '@/views/user/UserInfo.vue'
    import UserResetPasswordVue from '@/views/user/UserResetPassword.vue'

    // 定义路由关系
    const routes = [
    { path: "/login", component: LoginVue },
    {
    path: "/",
    component: LayoutVue,
    },

    ]

    // 创键路由器
    const router = createRouter({
    history: createWebHistory(),
    routes: routes
    })

    // 导出路由
    export default router

  4. 在入口文件中导入路由器并使用,由于我们将路由器配置文件取名为index,因此可以直接路径中的/index.js

    1
    2
    3
    4
    5
    import router from '@/router'

    ...

    app.use(router)

  5. 在 App.vue 中声明<router-view>标签,展示组件内容

    1
    2
    3
    <template>
    <router-view></router-view>
    </template>

  6. 在 Login.vue 实现切换路由器组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 引入使用路由器函数
    import { useRouter } from 'vue-router'
    const router = useRouter()

    // 登录函数
    const login = async () => {

    ...

    // 跳转到首页
    router.push('/')
    }

子路由

  1. 由于 App.vue 包含两个路由 Login.vue 和 Layout.vue,其中 Layout.vue 包含 ArticleCategory.vue、ArticleManage.vue、UserAvatar.vue、UserInfo.vue和UserResetPassword.vue 子路由。因此我们需要再 Layout.vue中配置它们:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 定义路由关系
    const routes = [
    { path: "/login", component: LoginVue },
    {
    path: "/",
    component: LayoutVue,
    redirect: '/article/manage',
    children: [
    { path: '/article/category', component: ArticleCategoryVue },
    { path: '/article/manage', component: ArticleManageVue },
    { path: '/user/avatar', component: UserAvatarVue },
    { path: '/user/info', component: UserInfoVue },
    { path: '/user/resetPassword', component: UserResetPasswordVue },
    ]
    },
    ]
    其中redirect用于重定向到指定的组件,即访问路径/,应该重定向到/article/managechildren用于配置子路由,其常用配置项和一般路由相似

  2. 在 Layout.vue中声明<router-view>标签,展示组件内容

    1
    2
    3
    4
    5
    6
    7
    <!-- 中间区域 -->
    <el-main>
    <!-- <div style="width: 1290px; height: 570px;border: 1px solid red;">
    内容展示区
    </div> -->
    <router-view></router-view>
    </el-main>

  3. 在左侧菜单项添加index属性,用于设置点击后的路由路径

    1
    2
    3
    4
    5
    6
    <el-menu-item index="/article/category">
    <el-icon>
    <Management />
    </el-icon>
    <span>文章分类</span>
    </el-menu-item>

文章分类

文章分类列表查询

  1. 复制以下代码到 ArticleCategory.vue 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    <script setup>
    import {
    Edit,
    Delete
    } from '@element-plus/icons-vue'
    import { ref } from 'vue'
    import { articleCategoryListService } from '@/api/article.js'

    const categorys = ref([
    {
    "id": 3,
    "categoryName": "美食",
    "categoryAlias": "my",
    "createTime": "2023-09-02 12:06:59",
    "updateTime": "2023-09-02 12:06:59"
    },
    {
    "id": 4,
    "categoryName": "娱乐",
    "categoryAlias": "yl",
    "createTime": "2023-09-02 12:08:16",
    "updateTime": "2023-09-02 12:08:16"
    },
    {
    "id": 5,
    "categoryName": "军事",
    "categoryAlias": "js",
    "createTime": "2023-09-02 12:08:33",
    "updateTime": "2023-09-02 12:08:33"
    }
    ])
    </script>
    <template>
    <el-card class="page-container">
    <template #header>
    <div class="header">
    <span>文章分类</span>
    <div class="extra">
    <el-button type="primary">添加分类</el-button>
    </div>
    </div>
    </template>
    <el-table :data="categorys" style="width: 100%">
    <el-table-column label="序号" width="100" type="index"> </el-table-column>
    <el-table-column label="分类名称" prop="categoryName"></el-table-column>
    <el-table-column label="分类别名" prop="categoryAlias"></el-table-column>
    <el-table-column label="操作" width="100">
    <template #default="{ row }">
    <el-button :icon="Edit" circle plain type="primary"></el-button>
    <el-button :icon="Delete" circle plain type="danger"></el-button>
    </template>
    </el-table-column>
    <template #empty>
    <el-empty description="没有数据" />
    </template>
    </el-table>
    </el-card>
    </template>

    <style lang="scss" scoped>
    .page-container {
    min-height: 100%;
    box-sizing: border-box;

    .header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    }
    }
    </style>

  2. 调用接口,将返回的数据赋值给数据模型

    1
    2
    3
    4
    5
    6
    7
    // 声明异步函数
    const articleCategoryList = async () => {
    let result = await articleCategoryListService()
    categorys.value = result.data
    }

    articleCategoryList()

  3. 在 api 目录下新建 article.js ,并实现接口

    1
    2
    3
    4
    5
    6
    import request from '@/utils/request.js'

    //文章分类列表查询
    export const articleCategoryListService = ()=>{
    return request.get('/category')
    }
    如果我们此时发送查询请求,其实是无法请求的,会返回 401 未授权错误,因为我们并没有携带 token 到后端

Pinia状态管理库

Pinia是Vue的专属状态管理库,它允许你跨组件或页面共享状态 1. 安装 pinia

1
npm install pinia

  1. 在入口文件中导入 pinia并使用

    1
    2
    3
    4
    5
    6
    import { createPinia } from 'pinia'
    const pinia = createPinia()

    ...

    app.use(pinia)

  2. src/stores/token.js中定义 store

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    // 定义store
    import { defineStore } from 'pinia'
    import { ref } from 'vue'

    /**
    * useTokenStore函数
    * 第一个参数:字符串,代表名字,具有唯一性
    * 第二个参数:函数,函数内部可以定义状态的所有内容
    *
    * 返回值:函数,能调用该函数得到第二个参数函数的返回值内容
    */
    export const useTokenStore = defineStore('token', () => {
    // 定义状态的内容
    // 1. 响应式变量
    const token = ref('')

    // 2.定义一个函数,用于修改token的值
    const setToken = (newToken) => {
    token.value = newToken
    }

    // 3. 定义一个函数,用于移除token的值
    const removeToken = () => {
    token.value = ''
    }

    // 返回值
    return {
    token, setToken, removeToken
    }
    })

  3. 在 Login.vue 中将返回的 token 添加到 store 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import { useTokenStore } from '@/store/token.js'
    const tokenStore = useTokenStore()

    //登录函数
    const login = async () => {

    ...

    // 把得到的token存储到pinia中
    tokenStore.setToken(result.data)
    // 跳转到首页
    router.push('/')
    }

  4. 在 article.js 中的请求中携带 store 的 token

    1
    2
    3
    4
    5
    6
    7
    8
    9
    import request from '@/utils/request.js'
    import { useTokenStore } from '@/store/token.js'

    //文章分类列表查询
    export const articleCategoryListService = () => {
    const tokenStore = useTokenStore()
    //在pinia中定义的响应式数据不需要.value
    return request.get('/category', { headers: { 'Authorization': tokenStore.token } })
    }

axios请求拦截器

为了避免每次发起请求都要手动携带 token,我们可以定义请求拦截器,在请求发起前自动携带token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useTokenStore } from '@/store/token.js'

...

//添加请求拦截器
instance.interceptors.request.use(
config => {
//请求前的回调
//添加token
const tokenStore = useTokenStore()
//判断有没有token
if (tokenStore.token) {
config.headers.Authorization = tokenStore.token
}
return config
},
err => {
//请求错误的回调
return Promise.reject(err)
}
)

Pinia持久化插件:persist

Pinia 默认是内存存储,当刷新浏览器的时候会丢失数据。Persist 插件可以将 pinia 中的数据持久化地存储 1. 安装 persist

1
npm install pinia-persistedstate-plugin
2. 在入口文件中导入 persist 并在pinia中使用 persist
1
2
3
4
5
6
import { createPersistedState } from 'pinia-persistedstate-plugin'

...

const persist = createPersistedState()
pinia.use(persist)
3. 在 token.js 中定义状态Store的函数中赋予第三个参数,用于持久化存储
1
2
3
{
persist:true //持久化存储
}

未登录统一处理

在响应拦截器中判断响应状态码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import router from '@/router' 

...

err => {
//判断响应状态码,如果为401,则表示未登录,提示请登录,并跳转到登录页面
if(err.response.status === 401){
ElMessage.error('请先登录')
router.push('/login')
}else{
ElMessage.error('服务异常')
}
return Promise.reject(err);//异步的状态转化成失败的状态
}

添加文章分类

  1. 添加文章分类弹窗

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- 添加分类弹窗 -->
    <el-dialog v-model="dialogVisible" title="添加弹层" width="30%">
    <el-form :model="categoryModel" :rules="rules" label-width="100px" style="padding-right: 30px">
    <el-form-item label="分类名称" prop="categoryName">
    <el-input v-model="categoryModel.categoryName" minlength="1" maxlength="10"></el-input>
    </el-form-item>
    <el-form-item label="分类别名" prop="categoryAlias">
    <el-input v-model="categoryModel.categoryAlias" minlength="1" maxlength="15"></el-input>
    </el-form-item>
    </el-form>
    <template #footer>
    <span class="dialog-footer">
    <el-button @click="dialogVisible = false">取消</el-button>
    <el-button type="primary" @click="addCategory"> 确认 </el-button>
    </span>
    </template>
    </el-dialog>

  2. 给弹窗进行数据绑定和添加表单校验

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    //控制添加分类弹窗
    const dialogVisible = ref(false)

    //添加分类数据模型
    const categoryModel = ref({
    categoryName: '',
    categoryAlias: ''
    })
    //添加分类表单校验
    const rules = {
    categoryName: [
    { required: true, message: '请输入分类名称', trigger: 'blur' },
    ],
    categoryAlias: [
    { required: true, message: '请输入分类别名', trigger: 'blur' },
    ]
    }

  3. 弹窗的显示隐藏依靠布尔值dialogVisible实现,为添加分类按钮添加点击事件@click="dialogVisible = true",弹窗里的取消按钮已经添加好对应的点击事件了

  4. 为弹窗里的确认按钮添加点击事件用于添加文章分类@click="addCategory"

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 添加文章分类
    const addCategory = async () => {
    //调用接口
    let result = await articleCategoryAddService(categoryModel.value)
    ElMessage.success(result.message?result.message:'添加成功')

    //调用获取文章分类列表的函数(刷新一下)
    articleCategoryList()

    //取消弹窗
    dialogVisible.value = false
    }

  5. 在 article.js 中实现接口

    1
    2
    3
    4
    //文章分类添加
    export const articleCategoryAddService = (categoryData) => {
    return request.post('/category', categoryData)
    }

  6. 我们还需要每次点击添加分类按钮前清空数据模型

    1
    <el-button type="primary" @click="dialogVisible = true; title = '添加分类';clearModel()">添加分类</el-button>
    1
    2
    3
    4
    5
    6
    7
    //清空数据模型
    const clearModel = ()=>{
    categoryModel.value = {
    categoryName: '',
    categoryAlias: ''
    }
    }

编辑文章分类

  1. 我们可以复用添加文章分类的弹窗,但是需要在添加或编辑时切换对应的弹窗标题,首先定义响应式数据

    1
    2
    // 定义变量,用于控制弹窗标题的显示
    const title = ref('')

  2. 为弹窗绑定数据

    1
    2
    <!-- 添加分类弹窗 -->
    <el-dialog v-model="dialogVisible" :title="title" width="30%">

  3. 在点击添加文章分类按钮时,将title修改为添加分类

    1
    <el-button type="primary" @click="dialogVisible = true; title = '添加分类'">添加分类</el-button>

  4. 在点击编辑文章分类按钮时也是同理,但是我们需要点击之后在文本框中回显数据,逻辑比较多,因此封装到一个函数中。row可以获取当前行的数据,另外将行的id属性添加给分类数据模型,用于传递给后端

    1
    <el-button :icon="Edit" circle plain type="primary" @click="showDialog(row)"></el-button>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //展示编辑弹窗
    const showDialog = row => {
    dialogVisible.value = true;
    title.value = '编辑分类'
    //数据拷贝
    categoryModel.value.categoryName = row.categoryName;
    categoryModel.value.categoryAlias = row.categoryAlias;
    //添加id属性,用于传递给后端
    categoryModel.value.id = row.id
    }

  5. 由于我们添加和修改共用了弹窗,因此弹窗的确认按钮需要判断应该发送什么请求,我们可以根据title的值进行判断

    1
    <el-button type="primary" @click="title == '添加分类' ? addCategory() : updateCategory()"> 确认 </el-button>

  6. 实现updateCategory方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 编辑文章分类
    const updateCategory = () => {
    let result = articleCategoryUpdateService(categoryModel.value)
    ElMessage.success(result.message ? result.message : '修改成功')

    //调用获取文章分类列表的函数(刷新一下)
    articleCategoryList()

    //取消弹窗
    dialogVisible.value = false
    }

  7. 提供修改文章分类的接口

    1
    2
    3
    4
    //文章分类修改
    export const articleCategoryUpdateService = (categoryData) => {
    return request.put('/category', categoryData)
    }

删除文章分类

  1. 为删除按钮赋予点击事件,并传递本行数据

    1
    <el-button :icon="Delete" circle plain type="danger" @click="deleteCategory(row)"></el-button>

  2. 我们在方法中使用element-plus的消息弹出框,用于提示用户是否确认删除 引入消息提示框

    1
    import { ElMessage, ElMessageBox } from 'element-plus'
    在方法内使用,参考官方文档,将英语文本改成需要的文本,然后调用删除接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 删除文章分类
    const deleteCategory = (row) => {
    // 提示用户确认框
    ElMessageBox.confirm(
    '确认删除该文章分类吗?',
    '温馨提示',
    {
    confirmButtonText: '确认',
    cancelButtonText: '取消',
    type: 'warning',
    })
    .then(async () => {
    let result = await articleCategoryDeleteService(row.id)
    ElMessage.success(result.message ? result.message : '删除成功')

    //调用获取文章分类列表的函数(刷新一下)
    articleCategoryList()
    })
    .catch(() => { })
    }

  3. 提供删除接口,因为需要请求的格式是queryString,所以直接通过拼接字符串发起请求

    1
    2
    3
    4
    //文章分类删除
    export const articleCategoryDeleteService = (id) => {
    return request.delete('/category?id=' + id)
    }

文章

文章列表查询

  1. 页面的搭建,复制如下代码到 ArticleManage.vue 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    <script setup>
    import {
    Edit,
    Delete
    } from '@element-plus/icons-vue'

    import { ref } from 'vue'

    //文章分类数据模型
    const categorys = ref([
    {
    "id": 3,
    "categoryName": "美食",
    "categoryAlias": "my",
    "createTime": "2023-09-02 12:06:59",
    "updateTime": "2023-09-02 12:06:59"
    },
    {
    "id": 4,
    "categoryName": "娱乐",
    "categoryAlias": "yl",
    "createTime": "2023-09-02 12:08:16",
    "updateTime": "2023-09-02 12:08:16"
    },
    {
    "id": 5,
    "categoryName": "军事",
    "categoryAlias": "js",
    "createTime": "2023-09-02 12:08:33",
    "updateTime": "2023-09-02 12:08:33"
    }
    ])

    //用户搜索时选中的分类id
    const categoryId=ref('')

    //用户搜索时选中的发布状态
    const state=ref('')

    //文章列表数据模型
    const articles = ref([
    {
    "id": 5,
    "title": "陕西旅游攻略",
    "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
    "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
    "state": "草稿",
    "categoryId": 2,
    "createTime": "2023-09-03 11:55:30",
    "updateTime": "2023-09-03 11:55:30"
    },
    {
    "id": 5,
    "title": "陕西旅游攻略",
    "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
    "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
    "state": "草稿",
    "categoryId": 2,
    "createTime": "2023-09-03 11:55:30",
    "updateTime": "2023-09-03 11:55:30"
    },
    {
    "id": 5,
    "title": "陕西旅游攻略",
    "content": "兵马俑,华清池,法门寺,华山...爱去哪去哪...",
    "coverImg": "https://big-event-gwd.oss-cn-beijing.aliyuncs.com/9bf1cf5b-1420-4c1b-91ad-e0f4631cbed4.png",
    "state": "草稿",
    "categoryId": 2,
    "createTime": "2023-09-03 11:55:30",
    "updateTime": "2023-09-03 11:55:30"
    },
    ])

    //分页条数据模型
    const pageNum = ref(1)//当前页
    const total = ref(20)//总条数
    const pageSize = ref(3)//每页条数

    //当每页条数发生了变化,调用此函数
    const onSizeChange = (size) => {
    pageSize.value = size
    }
    //当前页码发生变化,调用此函数
    const onCurrentChange = (num) => {
    pageNum.value = num
    }
    </script>
    <template>
    <el-card class="page-container">
    <template #header>
    <div class="header">
    <span>文章管理</span>
    <div class="extra">
    <el-button type="primary">添加文章</el-button>
    </div>
    </div>
    </template>
    <!-- 搜索表单 -->
    <el-form inline>
    <el-form-item label="文章分类:">
    <el-select placeholder="请选择" v-model="categoryId">
    <el-option
    v-for="c in categorys"
    :key="c.id"
    :label="c.categoryName"
    :value="c.id">
    </el-option>
    </el-select>
    </el-form-item>

    <el-form-item label="发布状态:">
    <el-select placeholder="请选择" v-model="state">
    <el-option label="已发布" value="已发布"></el-option>
    <el-option label="草稿" value="草稿"></el-option>
    </el-select>
    </el-form-item>
    <el-form-item>
    <el-button type="primary">搜索</el-button>
    <el-button>重置</el-button>
    </el-form-item>
    </el-form>
    <!-- 文章列表 -->
    <el-table :data="articles" style="width: 100%">
    <el-table-column label="文章标题" width="400" prop="title"></el-table-column>
    <el-table-column label="分类" prop="categoryId"></el-table-column>
    <el-table-column label="发表时间" prop="createTime"> </el-table-column>
    <el-table-column label="状态" prop="state"></el-table-column>
    <el-table-column label="操作" width="100">
    <template #default="{ row }">
    <el-button :icon="Edit" circle plain type="primary"></el-button>
    <el-button :icon="Delete" circle plain type="danger"></el-button>
    </template>
    </el-table-column>
    <template #empty>
    <el-empty description="没有数据" />
    </template>
    </el-table>
    <!-- 分页条 -->
    <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize" :page-sizes="[3, 5 ,10, 15]"
    layout="jumper, total, sizes, prev, pager, next" background :total="total" @size-change="onSizeChange"
    @current-change="onCurrentChange" style="margin-top: 20px; justify-content: flex-end" />
    </el-card>
    </template>
    <style lang="scss" scoped>
    .page-container {
    min-height: 100%;
    box-sizing: border-box;

    .header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    }
    }
    </style>

  2. 使用中文语言包,解决分页条中文问题,在 main.js 中添加(修改)以下代码

    1
    2
    3
    import locale from 'element-plus/dist/locale/zh-cn.js'

    app.use(ElementPlus,{locale})

  3. 文章分类数据回显,调用查询文章分类接口即可

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //文章列表查询
    import { articleCategoryListService } from '@/api/article.js'

    ...

    // 回显文章分类
    const articleCategoryList = async () => {
    let result = await articleCategoryListService()
    categorys.value = result.data
    }
    //setup执行时执行一次查询
    articleCategoryList()

  4. 在 article.js 中提供获取文章列表的接口

    1
    2
    3
    4
    //文章列表查询
    export const articleListService = (params) => {
    return request.get('/article', { params: params })
    }

  5. 调用接口获取数据,其中分类id和状态是可有可无的数据,因此需要判断是否存在,如果存在则传给params对象。由于列表中展示的是分类id,我们可以通过比对的方式显示对应的分类名称

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    //获取文章分页数据
    const articleList = async () => {
    let params = {
    pageNum: pageNum.value,
    pageSize: pageSize.value,
    categoryId: categoryId.value ? categoryId.value : null,
    state: state.value ? state.value : null
    }
    let result = await articleListService(params)

    //渲染视图
    total.value = result.data.total
    articles.value = result.data.items

    //为列表中添加categoryName属性
    for (let i = 0; i < articles.value.length; i++) {
    let article = articles.value[i];
    for (let j = 0; j < categorys.value.length; j++) {
    if (article.categoryId === categorys.value[j].id) {
    article.categoryName = categorys.value[j].categoryName
    }
    }
    }
    }
    //setup执行时执行一次查询
    articleList()

  6. 可以看到分页条中有两个自定义事件,即当分页条的当前页和每页条数发生变化时,重新发起请求获取数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //当每页条数发生了变化,调用此函数
    const onSizeChange = (size) => {
    pageSize.value = size
    getArticles()
    }
    //当前页码发生变化,调用此函数
    const onCurrentChange = (num) => {
    pageNum.value = num
    getArticles()
    }

  7. 为搜索按钮绑定点击事件,调用getArticles函数即可

    1
    <el-button type="primary" @click="getArticles">搜索</el-button>

  8. 为重置按钮绑定单击事件,清除categoryIdstate的值,并调用getArticles函数

    1
    <el-button @click="categoryId = ''; state = '';articleList()">重置</el-button>

添加文章

  1. 页面搭建,通过抽屉组件添加文章

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    <!-- 抽屉 -->
    <el-drawer v-model="visibleDrawer" title="添加文章" direction="rtl" size="50%">
    <!-- 添加文章表单 -->
    <el-form :model="articleModel" label-width="100px" >
    <el-form-item label="文章标题" >
    <el-input v-model="articleModel.title" placeholder="请输入标题"></el-input>
    </el-form-item>
    <el-form-item label="文章分类">
    <el-select placeholder="请选择" v-model="articleModel.categoryId">
    <el-option v-for="c in categorys" :key="c.id" :label="c.categoryName" :value="c.id">
    </el-option>
    </el-select>
    </el-form-item>
    <el-form-item label="文章封面">

    <el-upload class="avatar-uploader" :auto-upload="false" :show-file-list="false">
    <img v-if="articleModel.coverImg" :src="articleModel.coverImg" class="avatar" />
    <el-icon v-else class="avatar-uploader-icon">
    <Plus />
    </el-icon>
    </el-upload>
    </el-form-item>
    <el-form-item label="文章内容">
    <div class="editor">富文本编辑器</div>
    </el-form-item>
    <el-form-item>
    <el-button type="primary">发布</el-button>
    <el-button type="info">草稿</el-button>
    </el-form-item>
    </el-form>
    </el-drawer>

  2. 为页面绑定数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import {Plus} from '@element-plus/icons-vue'

    ...

    //控制抽屉是否显示
    const visibleDrawer = ref(false)
    //添加表单数据模型
    const articleModel = ref({
    title: '',
    categoryId: '',
    coverImg: '',
    content:'',
    state:''
    })

  3. 为页面添加样式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    /* 抽屉样式 */
    .avatar-uploader {
    :deep() {
    .avatar {
    width: 178px;
    height: 178px;
    display: block;
    }

    .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
    }

    .el-upload:hover {
    border-color: var(--el-color-primary);
    }

    .el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 178px;
    height: 178px;
    text-align: center;
    }
    }
    }
    .editor {
    width: 100%;
    :deep(.ql-editor) {
    min-height: 200px;
    }
    }

  4. 为添加文章按钮赋予点击事件@click="visibleDrawer = true展示抽屉

  5. 文章内容中需要使用到富文本编辑器,我们这里使用开源富文本编辑器 Quill

官网地址:https://vueup.github.io/vue-quill/

安装:

1
npm install @vueup/vue-quill@latest --save

导入组件和样式(样式已经导入,就是上面的.editor)

1
2
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'

在页面中使用 Quill 组件

1
2
3
4
<div class="editor">
<quill-editor theme="snow" v-model="articleModel.content" contentType="html">
</quill-editor>
</div>

  1. 当点击“+”图标,选择本地图片后,el-upload这个组件会自动发送请求,把图片上传到指定的服务器上,而不需要我们自己使用axios发送异步请求,所以需要给el-upload标签添加一些属性,控制请求的发送,我们需要关注以下属性(API)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <el-form-item label="文章封面">
    <!--
    auto-upload:设置是否自动上传
    action:设置服务器端口路径
    name:设置上传的文件字段名
    headers:设置上传的请求头
    on-success:设置上传成功的回调函数
    -->
    <el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false" action="/api/upload"
    name="file" :headers="{ Authorization: tokenStore.token }" :on-success="uploadSuccess">
    <img v-if="articleModel.coverImg" :src="articleModel.coverImg" class="avatar" />
    <el-icon v-else class="avatar-uploader-icon">
    <Plus />
    </el-icon>
    </el-upload>
    </el-form-item>
    注意虽然是自动发起请求,但同样会有跨域问题,因此在action属性中需要添加/api

headers属性中绑定请求头的时候也需要导入 Pinia 状态

1
2
3
import { useTokenStore } from '@/store/token.js'
// 导入token
const tokenStore = useTokenStore()

on-success属性中绑定请求成功后的回调函数,这里我们接收后端返回的图片地址进行回显

1
2
3
4
// 上传成功的回调函数
const uploadSuccess = (result) => {
articleModel.value.coverImg = result.data
}

  1. 在 article.js 中提供添加文章的接口

    1
    2
    3
    4
    //文章添加
    export const articleAddService = (articleData) => {
    return request.post('/article', articleData)
    }

  2. 点击“发布”和“草稿”时都是添加文章,可以调用同一个函数,我们可以通过传参的方式区分两者

    1
    2
    <el-button type="primary" @click="addArticle('已发布')">发布</el-button>
    <el-button type="info" @click="addArticle('草稿')">草稿</el-button>

  3. 实现函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //添加文章
    const addArticle = async (articleState) => {
    //把发布状态赋值给数据模型
    articleModel.value.state = articleState

    //调用接口
    let result = await articleAddService(articleModel.value)
    ElMessage.success(result.message ? result.message : '添加成功')

    //隐藏抽屉
    visibleDrawer.value = false

    //刷新当前文章列表
    articleList()
    }

顶部导航栏个人信息

在 Layout.vue 中,页面加载完就会发送请求获取个人信息并展示,因为需要在个人中心修改信息的时候还需要使用,因此还需要将个人信息存储到 pinia 中 1. 在 user.js 中提供获取个人信息的函数

1
2
3
4
//获取个人信息
export const userInfoGetService = ()=>{
return request.get('/user/userInfo');
}

  1. src/store/userInfo.js中定义个人信息状态

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import { defineStore } from 'pinia'
    import { ref } from 'vue'

    export const useUserInfoStore = defineStore('userInfo', () => {
    //定义状态相关的内容
    const info = ref({})

    //修改用户信息状态
    const setInfo = (newInfo) => {
    info.value = newInfo
    }

    //清除用户信息状态
    const removeInfo = () => {
    info.value = {}
    }

    return {
    info,
    setInfo,
    removeInfo
    }
    }, {
    persist: true
    })

  2. 在 Layout.vue 中调用接口,将获取到的个人信息存储到 pinia 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { userInfoService } from '@/api/user.js'
    import { useUserInfoStore } from '@/store/userInfo.js'
    const userInfoStore = useUserInfoStore()

    // 获取用户详细信息
    const getUserInfo = async () => {
    let result = await userInfoService()
    //将结果存储到pinia中
    userInfoStore.setInfo(result.data)
    }
    getUserInfo()

  3. 在 Layout.vue的顶部导航栏中展示昵称和头像

    1
    <div>黑马程序员:<strong>{{ userInfoStore.info.nickname }}</strong></div>
    1
    <el-avatar :src="userInfoStore.info.userPic ? userInfoStore.info.userPic : avatar" />

个人中心

el-dropdown功能实现

el-dropdown中有四个子条目,分别是:基本资料、更换头像、重置密码、退出登录。其中前三个起到路由功能,跟左侧菜单中的是同样功能,而退出登录需要删除本地 pinia 存储的 token 以及 个人信息

参考文档:https://element-plus.org/zh-CN/component/dropdown.html#dropdown-events

  1. el-dropdown标签中绑定@command事件,表示当有条目被点击时会触发该事件
    1
    <el-dropdown placement="bottom-end" @command="handleCommand">
  2. el-dropdown-item表前中添加command属性,该属性值会作为参数携带到@command事件回调函数中
    1
    2
    3
    4
    <el-dropdown-item command="info" :icon="User">基本资料</el-dropdown-item>
    <el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
    <el-dropdown-item command="resetPassword" :icon="EditPen">重置密码</el-dropdown-item>
    <el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
  3. 实现handleCommand函数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    import {useRouter} from 'vue-router'
    import { ElMessage,ElMessageBox } from 'element-plus'
    import { useTokenStore } from '@/stores/token.js'
    const router = useRouter()
    const tokenStore = useTokenStore()

    // 下拉框条目被点击后调用的函数
    const handleCommand = (command) => {
    //判断指令
    if (command === 'logout') {

    // 提示用户确认框
    ElMessageBox.confirm(
    '确认退出?',
    '温馨提示',
    {
    confirmButtonText: '确认',
    cancelButtonText: '取消',
    type: 'warning',
    })
    //退出登录
    .then(async () => {
    //清空pinia中存储的token及个人信息
    tokenStore.removeToken()
    userInfoStore.removeInfo()

    //跳转到登录页面
    router.push('/login')

    ElMessage.success(result.message ? result.message : '退出成功')
    })
    .catch(() => { })
    } else {
    //路由跳转
    router.push('/user/' + command)
    }
    }

基本资料修改

  1. 复制以下代码到 UserInfo.vue

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    <script setup>
    import { ref } from 'vue'
    const userInfo = ref({
    id: 0,
    username: 'zhangsan',
    nickname: 'zs',
    email: 'zs@163.com',
    })
    const rules = {
    nickname: [
    { required: true, message: '请输入用户昵称', trigger: 'blur' },
    {
    pattern: /^\S{2,10}$/,
    message: '昵称必须是2-10位的非空字符串',
    trigger: 'blur'
    }
    ],
    email: [
    { required: true, message: '请输入用户邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
    ]
    }
    </script>
    <template>
    <el-card class="page-container">
    <template #header>
    <div class="header">
    <span>基本资料</span>
    </div>
    </template>
    <el-row>
    <el-col :span="12">
    <el-form :model="userInfo" :rules="rules" label-width="100px" size="large">
    <el-form-item label="登录名称">
    <el-input v-model="userInfo.username" disabled></el-input>
    </el-form-item>
    <el-form-item label="用户昵称" prop="nickname">
    <el-input v-model="userInfo.nickname"></el-input>
    </el-form-item>
    <el-form-item label="用户邮箱" prop="email">
    <el-input v-model="userInfo.email"></el-input>
    </el-form-item>
    <el-form-item>
    <el-button type="primary">提交修改</el-button>
    </el-form-item>
    </el-form>
    </el-col>
    </el-row>
    </el-card>
    </template>

  2. 表单数据回显 个人信息在一开始登录到系统中就会保存在 pinia 中,因此只需要从 pinia 中获取个人信息并替换模板数据

    1
    2
    3
    4
    import { useUserInfoStore } from '@/stores/user.js';
    const userInfoStore = useUserInfoStore()

    const userInfo = ref({...userInfoStore.info})

  3. src/api/user.js中提供修改基本资料的接口

    1
    2
    3
    4
    //修改个人信息
    export const userInfoUpdateService = (userInfo)=>{
    return request.put('/user/update',userInfo)
    }

  4. 为修改按钮绑定点击事件

    1
    <el-button type="primary" @click="updateUserInfo">提交修改</el-button>

  5. 调用接口,实现updateUserInfo函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import {userInfoUpdateService} from '@/api/user.js'
    import { ElMessage } from 'element-plus'

    // 修改用户信息
    const updateUserInfo = async () => {
    let result = await userInfoUpdateService(userInfo.value)

    ElMessage.success(result.message?result.message:'修改成功')

    //修改pinia中的用户信息
    userInfoStore.setInfo(userInfo.value)
    }

修改头像

  1. 复制以下代码到 UserAvatar.vue 中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    <script setup>
    import { Plus, Upload } from '@element-plus/icons-vue'
    import {ref} from 'vue'
    import avatar from '@/assets/default.png'
    const uploadRef = ref()

    //用户头像地址
    const imgUrl= avatar

    </script>

    <template>
    <el-card class="page-container">
    <template #header>
    <div class="header">
    <span>更换头像</span>
    </div>
    </template>
    <el-row>
    <el-col :span="12">
    <el-upload
    ref="uploadRef"
    class="avatar-uploader"
    :show-file-list="false"
    >
    <img v-if="imgUrl" :src="imgUrl" class="avatar" />
    <img v-else src="avatar" width="278" />
    </el-upload>
    <br />
    <el-button type="primary" :icon="Plus" size="large" @click="uploadRef.$el.querySelector('input').click()">
    选择图片
    </el-button>
    <el-button type="success" :icon="Upload" size="large">
    上传头像
    </el-button>
    </el-col>
    </el-row>
    </el-card>
    </template>

    <style lang="scss" scoped>
    .avatar-uploader {
    :deep() {
    .avatar {
    width: 278px;
    height: 278px;
    display: block;
    }

    .el-upload {
    border: 1px dashed var(--el-border-color);
    border-radius: 6px;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: var(--el-transition-duration-fast);
    }

    .el-upload:hover {
    border-color: var(--el-color-primary);
    }

    .el-icon.avatar-uploader-icon {
    font-size: 28px;
    color: #8c939d;
    width: 278px;
    height: 278px;
    text-align: center;
    }
    }
    }
    </style>

  2. 在 pinia 中读取用户的头像数据

    1
    2
    3
    4
    5
    6
    //读取用户信息
    import {ref} from 'vue'
    import {useUserInfoStore} from '@/stores/user.js'
    const userInfoStore = useUserInfoStore()

    const imgUrl=ref(userInfoStore.info.userPic)
    img标签上绑定图片地址
    1
    2
    <img v-if="imgUrl" :src="imgUrl" class="avatar" />
    <img v-else :src="avatar" width="278" />

  3. el-upload指定属性值,和上传文章图片一样

    1
    2
    3
    4
    5
    6
    <el-upload ref="uploadRef" class="avatar-uploader" :show-file-list="false" :auto-upload="true"
    action="/api/upload" name="file" :headers="{ 'Authorization': tokenStore.token }"
    :on-success="uploadSuccess">
    <img v-if="imgUrl" :src="imgUrl" class="avatar" />
    <img v-else :src="avatar" width="278" />
    </el-upload>

  4. 提供上传成功的回调函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //读取token信息
    import {useTokenStore} from '@/stores/token.js'
    const tokenStore = useTokenStore()

    //图片上传成功的回调
    const uploadSuccess = (result)=>{
    //回显图片
    imgUrl.value = result.data
    }

  5. 外部触发图片上传,即获取到el-upload组件,通过$el.querySelector('input')获取到el-upload对应的元素,触发click事件

    1
    2
    //获取el-upload元素
    const uploadRef = ref()
    1
    2
    3
    <el-button type="primary" :icon="Plus" size="large"  @click="uploadRef.$el.querySelector('input').click()">
    选择图片
    </el-button>

  6. 为上传头像按钮绑定点击事件

    1
    2
    3
    <el-button type="success" :icon="Upload" size="large" @click="updateAvatar">
    上传头像
    </el-button>

  7. 调用接口,实现updateAvatar函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import {userAvatarUpdateService} from '@/api/user.js'
    import {ElMessage} from 'element-plus'

    // 更新头像
    const updateAvatar = async () => {
    let result = await userAvatarUpdateService(imgUrl.value)

    //修改pinia中的状态
    userInfoStore.info.userPic = imgUrl.value

    ElMessage.success(result.message ? result.message : '更换成功')
    }

  8. 在 user.js 中提供修改头像的函数

    1
    2
    3
    4
    // 修改用户头像
    export const userAvatarUpdateService = (avatarUrl) => {
    return request.patch('/user/updateAvatar?avatarUrl=' + avatarUrl)
    }