前置条件
后端:JavaSE ,JavaWEb ,SSM框架
前端: html,css,javascript

使用工具以及环境版本

  • JDK17+
  • IDEA2021+
  • maven3.5+
  • vscode

打开cmd
查看maven版本:mvn -v
查看java版本:java -version


SpringBoot
是spring提供的一个子项目,用于快速构建Spring应用程序

快速Demo

查看demo构建过程

新建项目

  • 创建一个Spring initializr的项目,参考如下配置
名称位置语言类型打包JDK
quickstart…\workspace\springboot\javaMavenjar17+

导入spring-boot-satrt-web起步依赖

  • 选择SpringBoot3以上的版本 依赖选择Web->Spring Web
    编写Controller ->”com.hnit.controller.Controller.java
    1
    2
    3
    4
    5
    6
    7
    @RestController
    public class Controller {
    @RequestMapping("/hello")
    public String hello() {
    return "Hello World";
    }
    }

提供启动类

创建Maven工程

  • 引用如下Archetype模板
  • org.apache.maven.archetypes:maven-archetype-quickstart

pom.xml文件中添加如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--父依赖-->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.2</version>
</parent>


<!-- 添加web起步依赖 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

项目下会生成一个App启动类

  • 改一下名字格式: 项目名+Application
  • 启动类固定格式如下
    1
    2
    3
    4
    5
    6
    7
    @SpringBootApplication
    public class SpringBootCreateManualApplication{
    public static void main( String[] args ){
    //两个参数:启动类字节码文件,main方法数组参数
    SpringApplication.run(SpringBootCreateManualApplication.class,args);
    }
    }

写一个和方式一样的controller,可以启动测试成功

注意!! 启动类必须放在最外面的包中,
比如包结构为com.hnit.controller/pojo/domain 那Application应该放com.hnit下

用这种方法创建会缺少的目录和文件

  • 资源目录resource:手动创建如下目录main/resources
  • 配置文件application.properties:在resources目录下创建文件application.propertiesapplication.ymlapplication.yaml
  • 如果有多个配置文件而暂时想排除某一个,一个简单的办法是在文件扩展名后加.bak,如application.properties.bak

Application.properties配置文件

查看引用配置文件里的数据过程

application.yml中添加相关配置

1
2
3
4
5
email:
user: 2098998@qq.com
code: testaboutcode
host: smtp.qq.com
auth: true

引用只需在对应属性上加上注解
1
2
3
4
@Value("${email.user}")
private String user;
@Value("${email.code}")
private String code;

在封装类上加上注解@ConfigurationProperties(prefix="前缀")
同时保证类里面的属性和配置文件的属性名要一致

1
2
3
4
5
6
7
8
# application.yml
# 配置信息书写,值的前边必须有空格,作为分隔符
# 使用空格作为缩进表示层级关系,相同的层级左对齐
student:
name: zhangshan
password: 123456
gender: 1
age: 21

1
2
3
4
5
6
7
8
9
10
11
12
@ConfigurationProperties(prefix="student")
@Component
public class Student {
public String name;
public String password;
public Integer gender;
public Integer age;

//重写get,set方法
//重写toString方法
}

测试

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

@Autowired
private Student std;

@RequestMapping("/hello")
public String hello() {
return "Hello"+std;
}
}


整合mybatis

查看整合mybatis过程

引入mybatis和mysql依赖,以及web依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

配置数据库 application.yml
1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/springbootdb?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC&useSSL=false
username: root
password: 123456

数据层 com/hnit/pojo/Student
1
2
3
4
5
6
7
public class Student {
private Integer id;
private String name;
private Integer gender;
private Integer age;
private String phone;
}

Mapper层 com/hnit/mapper/StudentMapper
1
2
3
4
5
6
@Mapper
public interface StudentMapper {

@Select("select * from tb_user where id = #{id}")
public Student getByid(int id);
}

Service层 com/hnit/service/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// com/hnit/service/StudentService
public interface StudentService {

public Student getByid(int id);
}


// com/hnit/service/imp/StudentServiceImpl
@Service
public class StudentServiceImpl implements StudentService {

@Autowired
private StudentMapper studentMapper;

@Override
public Student getByid(int id) {
return studentMapper.getByid(id);
}
}


Controller层 com/hnit/controller/StudentController
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class StudentController {

@Autowired
private StudentService studentService;

@RequestMapping("/getByid/{id}")
public Student getByid(@PathVariable Integer id){
return studentService.getByid(id);
}
}

运行结果:http://localhost:8080/getByid/2

1
2
3
4
5
6
7
{
"id": 2,
"name": "李四",
"gender": 1,
"age": 22,
"phone": "90876543212"
}


Bean

查看整合mybatis过程
  1. Bean的扫描
    springboot默认扫描启动类所在的包及其子包

  2. Bean的注册
    先在pom文件导入,然后导入
    方式一:
    com/hnit/config/CommonConfig.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Configuration
    public class CommonConfig{
    //注入第三方Country对象
    @Bean
    public Country country(){
    return new Country();
    }

    //对象名字默认为方法名,也可以在注解中指定名字
    //@Bean("aaa")
    //如果方法的内部需要使用到ioc容器中已经存在的bean对象,那么只需要到方法上声明即可,spring会自动注入
    @Bean
    public Province province(Country country){
    System.out.println("province"+country)
    return new Province();
    }
    }

    启动类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    //@Import(CommonConfig.class)   //如果不在启动类及其子包下就import导入 
    @SpringBootApplication
    public class SpringBootCreateManualApplication{
    public static void main( String[] args ){
    ApplicationContext context=SpringApplication.run(SpringBootCreateManualApplication.class,args);

    Country country = context.getBean(Country.class);
    System.out.println(context.getBean("province"))
    }
    }

注册条件
@ConditionalOnProperty 配置文件中纯正对应的属性,才声明该bean
@ConditionalMissingBean 当不存在当前类型的bean时,才声明该bean
@ConditionalOnClass 当环境中存在指定的这个类时,才声明该bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class CommonConfig{
// @ConditionalOnProperty(prefix = "country",name = {"name","system"}) //配置文件中纯正对应的属性,才声明该bean
@Bean
public Country country(@Value("${country.name}")String name,@Value("${country.system}")String system){
return new Country();
}

// @ConditionalMissingBean(Country.class) //当不存在当前类型的bean时,才声明该bean
// @ConditionalOnClass("xxx.xxx.xxx.xx.class") //当环境中存在指定的这个类时,才声明该bean
@Bean
public Province province(){

return new Province();
}
}


SpringBoot自动配置的原理

查看原理
  1. 在主启动类上添加了SpringBootApplication注解,这个注解组合了EnableAutoConfiguration注解
  2. EnableAutoConfiguratio注解又组合了Import注解,导入了AutoConfigurationSelector类
  3. 实现selectimports方法,这个方法经过层层调用,最终会读取META-INF目录下的后缀名为imports的文件,当然,boot2.7以前的版本,读取的是spring.factories文件
  4. 读取到全类名了之后,会解析注册条件,也就是@Conditional及其衍生注解,把满足注册条件的Bean对象自动注册到IOC容器中

自定义starter

查看流程

创建两个maven工程

  • dmybatis-spring-boot-autoconfigure
  • dmybatis-spring-boot-starter

选择骨架:org.apache.maven.archetypes:maven-archetype-quickstart
组件:com.xnj

dmybatis-spring-boot-autoconfigure中提供自动配置的功能
引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>3.1.2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
<version>3.0.0</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.16</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>

创建自动配置类com/xnj/config/MybatisAutoConfig
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
@AutoConfiguration//标识当前类是一个自动配置类
public class MybatisAutoConfig {

//SqlSessionFactoryBean
@Bean
public SqlSessionFactoryBean sqlSessionFactoryBean(DataSource dataSource){
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
return sqlSessionFactoryBean;
}

//MapperScannerConfigurer
@Bean
public MapperScannerConfigurer mapperScannerConfigurer(BeanFactory beanFactory){
MapperScannerConfigurer mapperScannerConfigurer = new MapperScannerConfigurer();
//设置扫描的包路径,启动类所在的包及其子包
List<String> packages = AutoConfigurationPackages.get(beanFactory);
String p = packages.get(0);
mapperScannerConfigurer.setBasePackage(p);

//扫描的注解
mapperScannerConfigurer.setAnnotationClass(Mapper.class);
return mapperScannerConfigurer;
}
}

创建以下目录和文件 :
main/resources 资源目录
main/resources/META-INF META-INF目录
main/resources/META-INF/spring spring目录
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件
复制自动装配类资源的路径:com.xnj.config.MybatisAutoConfigorg.springframework.boot.autoconfigure.AutoConfiguration.imports文件中

dmybatis-spring-boot-starter里引入dmybatis-spring-boot-autoconfigure依赖和它的依赖

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
<dependency>
<groupId>com.xnj</groupId>
<artifactId>dmybatis-spring-boot-autoconfigure</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>3.1.2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
<version>3.0.0</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.16</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>

dmybatis-spring-boot-autoconfigure工程中的APP和test可以删除
dmybatis-spring-boot-starter工程用来管理依赖,仅保留pom.xml文件

回到前面springboot整合mybatis的项目中,注释以下依赖并添加自定义starter,运行即可

1
2
3
4
5
6
7
8
9
10
11
12
<!--    <dependency>-->
<!-- <groupId>org.mybatis.spring.boot</groupId>-->
<!-- <artifactId>mybatis-spring-boot-starter</artifactId>-->
<!-- <version>3.0.3</version>-->
<!-- </dependency>-->

<!--自定义starter-->
<dependency>
<groupId>com.xnj</groupId>
<artifactId>dmybatis-spring-boot-starter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

自定义mybatis的starter总结
创建dmybatis-spring-boot-autoconfig模块,提供自动装配功能,并自定义配置文件META-INF/spring/xxx.imports
创建dmybatis-spring-boot-starter模块,在starter中引入自动装配模块


项目 big-event 后端学习

项目包结构如下
com/xnj为外层包,App启动类在这一层,它包含以子包

controller,service及service/impl,mapper,pojo实体类
utils工具类包,interceptors自定义拦截器包exception异常处理类,config配置类

环境准备

查看环境准备工作
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
create database big_event;

use big_event;
-- 用户表
create table use
(
id int unsigned primary key auto_increment comment 'id',
username varchar(20) not null comment '用户名',
password varchar(32) comment '密码',
nickname varchar(10) default '' comment '昵称',
email varchar(128) default '' comment '邮箱',
user_pic varchar(128) default '' comment '头像',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '更新时间'
)comment '用户表';

-- 分类表
create table category
(
id int unsigned primary key auto_increment comment 'id',
category_name varchar(32) not null comment '分类名称',
category_alias varchar(32) not null comment '分类别称',
create_user int unsigned not null comment '创建人ID',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '修改时间',
constraint fk_category_user foreign key (create_user) references user (id)-- 外键
);

-- 文章表
create table article(
id int unsigned primary key auto_increment comment 'id',
title varchar(30) not null comment '标题',
content varchar(10000) not null comment '内容',
cover_img varchar(128) not null comment '封面',
state varchar(3) default '草稿' comment '文章状态,/已发布/草稿',
category_id int unsigned not null comment '分类ID',
create_user int unsigned not null comment '创建人ID',
create_time datetime not null comment '创建时间',
update_time datetime not null comment '修改时间',
constraint fk_article_category foreign key (category_id) references category (id),-- 外键
constraint fk_article_user foreign key (create_user) references user (id)-- 外键
)
  • 导入依赖
  • 创建包结构,和资源目录
    pojo,mapper,service,service/impl,controller,utils
    resource,resource/application.yml
    1
    2
    3
    4
    5
    6
    spring:
    datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/big_event?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: 123456
    • 修改启动类
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @SpringBootApplication
      public class BigEventApplication
      {
      public static void main( String[] args )
      {
      SpringApplication.run(BigEventApplication.class, args);
      }
      }

引入lombook依赖来为实体类自动生成get,set方法

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

User 用户类

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;//修改时间
}

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


Article 文章类
1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class Article {
private Integer id;//主键id
private String title;//标题
private String content;//内容
private String coverImg;//封面图
private String state;//发布状态 已发布|草稿
private Integer categoryId;//分类id
private Integer createUser;//创建人ID
private LocalDateTime createTime;//创建时间
private LocalDateTime updateTime;//修改时间
}

统一响应结果类Result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//统一响应结果
@NoArgsConstructor
@AllArgsConstructor
@Data //如果这里加@Data,在controller时就不能识别转换为json对象返回
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);
}
}

MD5加密工具类

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
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){
//判断用户是否已存在
User user=userService.findByUsername(username);
if(user==null){
//用户名没有占用,注册
userService.save(username,password);
return Result.success();
}else{
//用户名已存在
return Result.error("用户名已存在");
}
}
}
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
//UserService
public interface UserService {
//查询用户
User findByUsername(String username);

//注册用户
void save(String username, String password);
}

//UserServiceImpl
@Service
public class UserServiceImpl implements UserService {

@Autowired
private UserMapper userMapper;

//通过用户名查询用户
@Override
public User findByUsername(String username) {
return userMapper.findByUsername(username);
}

//注册用户
@Override
public void save(String username, String password) {
//对密码进行MD5加密,存储在数据库中的密码应该是加密过后的
String md5String = Md5Util.getMD5String(password);
//注册
userMapper.save(username,md5String);

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

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

//注册用户账号
@Insert("insert into user(username,password,create_time,update_time) values (#{username},#{password},now(),now())")
public void save(String username,String password);
}

对注册功能测试

这里使用的是APIPOST工具

1
2
POST: http://localhost:8080/user/register
Body.urlencoded

参数名参数值
usernamezhangshan
password123456

第一次注册

1
2
3
4
5
{
"code": 0,
"message": "操作成功",
"data": null
}

第二次注册
1
2
3
4
5
{
"code": 1,
"message": "用户名已存在",
"data": null
}

然而,上述虽然实现了注册功能,但并没有对username和password进行校验
使用Spring Validation,对注册接口的参数进行合法性校验
1.引入Spring Validation起步依赖

1
2
3
4
5
<!--   validation验证依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

2.在参数前面添加@Pattern注解
public Result register(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$") String password)
3.在Controller类上添加@Validated注解
1
2
@Validated
public class UserController

但是此时如果传来的数据不符合格式则会报如下异常
register.username: 需要匹配正则表达式”^\S{5,16}$”
前端则会收到如下数据
1
2
3
4
5
6
{
"timestamp": "2024-08-16T06:19:59.073+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/user/register"
}

但这并不合我们的返回格式,所以需要定义全局异常处理器处理效验失败的异常

全局异常处理器

创建com/xnj/exception/GlobalExceptionhandler

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

@ExceptionHandler(Exception.class)
public Result handleException(Exception e) {
e.printStackTrace();//输出异常信息到控制台
//判断异常信息是否为空(有些异常没有返回信息),返回结果
return Result.error(StringUtils.hasLength(e.getMessage())?e.getMessage():"操作失败");
}
}

再次测试接口,才能得到我们想要的返回格式
1
2
3
4
5
{
"code": 1,
"message": "register.username: 需要匹配正则表达式\"^\\S{5,16}$\"",
"data": null
}

JWT令牌

全称 JSON Web Token
定义了一种简洁的,自包含的格式,用于通信双方以json数据格式安全的传输信息
组成

第一部分:Header(头),记录令牌类型,签名算法等,如{“alg”:”HS256”,”type”:”JWT”}
第二部分:Payload(有效载荷),携带一些自定义信息,默认信息等。如{“id”:”1”,”username”:”Tom”}
第三部分:Signature(签名),防止Token被篡改,确保安全性。将header,payload,加入指定密钥,通过指定签名算法计算而来

查看jwt示例

引入测试依赖和jwt依赖

1
2
3
4
5
6
7
8
9
10
11
12
 <!-- java-jwt坐标-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>

<!-- 单元测试依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

创建test/java/com/xnj/JwtTest
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
public class JwtTest {
@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*24))//设置过期时间24小时
.sign(Algorithm.HMAC256("xnj"));//指定算法,配置密钥
System.out.println(token);
}
@Test
public void parseToken(){
//解析上面方法生成的令牌
String token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJ1c2VyIjp7ImlkIjoxLCJ1c2VybmFtZSI6IuW8oOS4iSJ9LCJleHAiOjE3MjM4ODU4OTZ9" +
".jDS7CM_vlfwMNkn2Q15HSm8P7CVYG49HTEaSjmjGPqY";

JWTVerifier verifier = JWT.require(Algorithm.HMAC256("xnj")).build();

DecodedJWT decodedJWT= verifier.verify(token);
Map<String, Claim> claims= decodedJWT.getClaims();
System.out.println(claims.get("user"));//能解析出{"id":1,"username":"张三"}
}

}


用户登录功能

查看用户登录功能实现

在utils包中引入JwtUtil工具类
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>

2.引入JwtUtil工具类 com/xnj/utils/JwtUtil
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class JwtUtil {

private static final String KEY = "xnj";

//接收业务数据,生成token并返回
public static String genToken(Map<String, Object> claims) {
return JWT.create()
.withClaim("claims", claims)
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))//有效期为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();
}

}

登录查询用户的findByUsername在注册功能时已经实现,直接调用即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@PostMapping("/login")
public Result 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("用户不存在");
}
//判断密码是否正确,loginUser里的password是密文
if(Md5Util.getMD5String(password).equals(loginUser.getPassword())){
//密码正确,生成token
Map<String,Object> claims = new HashMap<>();
claims.put("id",loginUser.getId());
claims.put("username",loginUser.getUsername());
String token = JwtUtil.genToken(claims);
//返回token
return Result.success(token);
}else{
return Result.error("密码错误");
}
}

之后的每个功能接口都要经过token校验后才能访问
所以这里应该使用拦截器

拦截器

创建com/xnj/interceptors/LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component//将我们创建的拦截器对象注入IOC容器
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;
}
}
}

还需要注册我们的拦截器
创建com/xnj/config/WebConfig实现WebMvcConfigurer接口
重写addInterceptors方法来注册我们自定义的拦截器
1
2
3
4
5
6
7
8
9
10
11
@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
2
3
4
5
6
7
8
9
10
11
//获取用户信息
@GetMapping("/userinfo")
public Result<User> userinfo(@RequestHeader("Authorization") String token){
//解析token
Map<String, Object> claims = JwtUtil.parseToken(token);
//获取登录用户的用户名
String username = (String) claims.get("username");

User user = userService.findByUsername(username);
return Result.success(user);
}

password需要隐藏 而createTime,updateTime需要开启驼峰命名

1
2
3
//注意别导错包`import com.fasterxml.jackson.annotation.JsonIgnore`
@JsonIgnore //让springmvc将当前对象转换成JSON字符串时,忽略该字段
private String password;//密码

1
2
3
mybatis:
configuration:
map-underscore-to-camel-case: true # 开启驼峰命名

但是依靠@RequestHeader("Authorization") String token来实现的话会显得臃肿,所以可以使用ThreadLocal

ThreadLocal

查看ThreadLocal演示

ThreadLocal用来提供线程局部变量

  • 用来存取数据:set()/get()
  • 使用ThreadLocal存取的数据,线程安全
  • 用完记得使用remove()方法释放

下面创建一个测试来验证和演示,test引入的依赖为前面提到过的spring-boot-starter-test

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
public class ThreadLocalTest {
@Test
public void threadLocaltest(){
ThreadLocal tl = new ThreadLocal();

//开启两个线程
new Thread(()->{
tl.set("张三");
System.out.println(Thread.currentThread().getName()+" "+tl.get());
System.out.println(Thread.currentThread().getName()+" "+tl.get());
System.out.println(Thread.currentThread().getName()+" "+tl.get());
},"线程1").start();

new Thread(()->{
tl.set("李四");
System.out.println(Thread.currentThread().getName()+" "+tl.get());
System.out.println(Thread.currentThread().getName()+" "+tl.get());
System.out.println(Thread.currentThread().getName()+" "+tl.get());
},"线程2").start();

//控制台打印结果
/*线程1 张三
线程2 李四
线程1 张三
线程2 李四
线程1 张三
线程2 李四*/
}
}

可以看到同一个ThreadLocal对象为不同的线程提供了不同的存储。

了解了ThreadLocal后就开始对我们的业务进行优化

在项目中引入com/xnj/utils/ThreadLocal工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 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();
}
}

回到拦截器com/xnj/interceptor/LoginInterceptor中
在放行前存入业务数据,在用户请求结束后,清空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
25
26
27
28
@Component//将我们创建的拦截器对象注入IOC容器
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);
//把业务数据存储到ThreadLocal中
ThreadLocalUtil.set(claims);
//放行
return true;
} catch (Exception e) {
//设置Http响应状态为401
response.setStatus(401);
//不放行
return false;
}
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//请求完成之后清除ThreadLocal中的数据
ThreadLocalUtil.remove();
}
}

现在来 完善获取用户详细信息 的业务
1
2
3
4
5
6
7
8
9
10
//获取用户信息
@GetMapping("/userinfo")
public Result<User> userinfo(){
//从ThreadLocal中获取用户信息
Map<String,Object> claims= ThreadLocalUtil.get();
String username = (String) claims.get("username");

User user = userService.findByUsername(username);
return Result.success(user);
}


用户更新功能

用户更新功能实现

com/xnj/controller/UserController

1
2
3
4
5
6
//更新用户信息
@PutMapping("/update")
public Result update(@RequestBody User user){
userService.update(user);
return Result.success();
}

com/xnj/service层
1
2
3
4
5
6
7
8
9
10
11
//com/xnj/service/UserService
//更新用户信息
void update(User user);

//com/xnj/service/impl/UserServiceImpl
//更新用户信息
@Override
public void update(User user) {
user.setUpdateTime(LocalDateTime.now());
userMapper.update(user);
}

com/xnj/mapper/UserMapper
1
2
3
//更新用户
@Update("update user set nickname=#{nickname},email=#{email},update_time=#{updateTime} where id=#{id}")
void update(User user);

测试
1
2
3
4
5
6
7
8
PUT: http://localhost:8080/user/update
Body.raw(json)
{
"id":3,
"username":"zhangsan",
"nickname":"小张三",
"email":"3838438@qq.com"
}

对用户信息更改进行优化
上面的功能并未对字段进行输入限制,通过Validated进行字段约束

com/xnj/pojo/User

1
2
3
4
5
6
7
8
@NotNull
private Integer id;//主键id
@NotEmpty
@Pattern(regexp = "^\\S{1,10}$") //1~10位的任意字母
private String nickname;//昵称
@NotEmpty
@Email //邮箱格式
private String email;//邮箱

com/xnj/controller/UserController
1
2
3
4
5
6
//更新用户信息
@PutMapping("/update")
public Result update(@RequestBody @Validated User user){
userService.update(user);
return Result.success();
}

总结实体参数效验

  • 实体类的成员变量上添加注解 @NotNull @NotEmpty @Email
  • 接口方法的实体参数上添加 @Validated注解

更新用户头像

更新用户头像功能实现

com/xnj/controller/UserController

1
2
3
4
5
6
7
8
9
//更新用户头像
@PatchMapping("/updateAvatar")
public Result updateAvatar(@RequestParam @URL String avatarurl){//@URL约束传入参数为url格式
//获取当前用户id
Map<String,Object> claims = ThreadLocalUtil.get();
Integer id = (Integer) claims.get("id");
userService.updateAvatar(id,avatarurl);
return Result.success();
}

com/xnj/service/
1
2
3
4
5
6
7
8
9
10
//com/xnj/service/UserService
//更新用户头像
void updateAvatar(Integer id,String avatarurl);

//com/xnj/service/impl/UserServiceImpl
//更新用户头像
@Override
public void updateAvatar(Integer id,String avatarurl) {
userMapper.updateAvatar(id,avatarurl);
}

com/xnj/mapper/UserMapper
1
2
3
//更新用户头像
@Update("update user set user_pic=#{avatarurl},update_time=now() where id=#{id}")
void updateAvatar(Integer id, String avatarurl);

测试

1
PATCH: http://localhost:8080/user/updateAvatar?avatarurl=https://picbed.xusir.fun/img/post_default_9.webp


更新用户密码

更新用户密码功能实现

com/xnj/controller/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
26
27
28
29
30
31
32
33
//更新用户密码
@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("密码不能为空");
}

//从ThreadLocal中获取用户信息
Map<String,Object> claims = ThreadLocalUtil.get();
String username = (String) claims.get("username");
//根据用户名查询用户
User user = userService.findByUsername(username);

if(!user.getPassword().equals(Md5Util.getMD5String(old_pwd))){
return Result.error("旧密码错误");
}

//判断两次输入的密码是否一致
if(!new_pwd.equals(re_pwd)){
return Result.error("两次输入的密码不一致");
}

//更新密码
userService.updatePwd(new_pwd);

return Result.success();
}

com/xnj/service/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//com/xnj/service/UserService
//更新密码
void updatePwd(String newPwd);


//com/xnj/service/impl/UserServiceImpl
//更新用户密码
@Override
public void updatePwd(String newPwd) {
Map<String,Object> claims= ThreadLocalUtil.get();
Integer id = (Integer) claims.get("id");
//密码需要加密后才能存储到数据库
userMapper.updatePwd(id,Md5Util.getMD5String(newPwd));
}

com/xnj/mapper/UserMapper
1
2
3
//更新用户密码
@Update("update user set password=#{newPwd},update_time=now() where id=#{id}")
void updatePwd(Integer id,String newPwd);

功能测试
1
2
3
4
5
6
7
8
PATCH: http://localhost:8080/user/updatePwd
Body.raw(json)

{
"old_pwd":"123456",
"new_pwd":"654321",
"re_pwd":"654321"
}


添加文章分类

添加文章分类功能实现

创建以下文件CategoryController,CategoryService,CategoryServiceImpl,CategoryMapper
com/xnj/controller/CategoryController

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

@Autowired
private CategoryService categoryService;

//添加文章分类
@PostMapping
public Result add(@RequestBody @Validated Category category) {
categoryService.add(category);
return Result.success();
}
}

前端只会传分类名和分类别称,所以要加约束
com/xnj/pojo/Category
1
2
3
4
@NotEmpty
private String categoryName;//分类名称
@NotEmpty
private String categoryAlias;//分类别名

com/xnj/service/CategoryService
1
2
3
4
public interface CategoryService {
//添加分类
void add(Category category);
}

com/xnj/service/impl/CategoryServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class CategoryServiceImpl implements CategoryService {

@Autowired
private CategoryMapper categoryMapper;

//添加分类
@Override
public void add(Category category) {
//设置创建时间和更新时间
category.setCreateTime(LocalDateTime.now());
category.setUpdateTime(LocalDateTime.now());
//设置创建人
Map<String,Object> claims = ThreadLocalUtil.get();
Integer id = (Integer) claims.get("id");
category.setCreateUser(id);
//添加
categoryMapper.add(category);

}
}

com/xnj/mapper/CategoryMapper
1
2
3
4
5
6
7
@Mapper
public interface CategoryMapper {

//添加文章分类
@Insert("insert into category(category_name,category_alias,create_user,create_time,update_time) values (#{categoryName},#{categoryAlias},#{createUser},#{createTime},#{updateTime})")
void add(Category category);
}

测试
1
2
3
4
5
6
POST: http://localhost:8080/category
Body.raw(json)
{
"categoryName":"人文",
"categoryAlias":"rw"
}

查询文章分类列表

查询文章分类列表功能实现

com/xnj/controller/CategoryController

1
2
3
4
5
6
//查询当前用户创建的文章分类
@GetMapping
public Result<List<Category>> list(){
List<Category> cs = categoryService.list();
return Result.success(cs);
}

com/xnj/service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//com/xnj/service/CategoryService
//查询当前用户创建的文章分类
List<Category> list();


//com/xnj/service/impl/CategoryServiceImpl
@Override
public List<Category> list() {
//获取当前用户id
Map<String,Object> claims = ThreadLocalUtil.get();
Integer id = (Integer) claims.get("id");
//查询并返回查询结果
return categoryMapper.list(id);
}

为了规范返回前端的文章分类列表的创建时间和更新时间
需要给com/xnj/pojo/Category的字段添加注解
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;//修改时间

com/xnj/mapper/CategoryMapper

1
2
3
//查询当前用户创建的文章分类
@Select("select * from category where create_user=#{id}")
List<Category> list(Integer id);

测试
1
GET: http://localhost:8080/category


查询分类详细信息

查询分类详细信息功能实现

com/xnj/controller/CategoryController

1
2
3
4
5
@GetMapping("/detail")
public Result<Category> detail(Integer id){
Category c = categoryService.detail(id);
return Result.success(c);
}

com/xnj/service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//com/xnj/service/CategoryService
//根据id查询分类详情
Category detail(Integer id);


//com/xnj/service/impl/CategoryServiceImpl
//根据id查询分类详情
@Override
public Category detail(Integer id) {
//获取当前用户id
Map<String,Object> claims = ThreadLocalUtil.get();
Integer userId = (Integer) claims.get("id");
//查询并返回查询结果
Category category = categoryMapper.detail(id,userId);
return category;
}

com/xnj/mapper/CategoryMapper
1
2
3
//根据id查询分类详情
@Select("select * from category where id=#{id} and create_user=#{userId}")
Category detail(Integer id,Integer userId);

测试
1
2
GET: http://localhost:8080/category/detail
Query: id (Integer) :2


更新分类信息

更新分类信息功能实现

com/xnj/controller/CategoryController

1
2
3
4
5
6
//更新文章分类
@PutMapping
public Result update(@RequestBody @Validated Category category){
categoryService.update(category);
return Result.success();
}

com/xnj/service
1
2
3
4
5
6
7
8
9
10
11
12
//com/xnj/service/CategoryService
//更新文章分类
void update(Category category);


//com/xnj/service/impl/CategoryServiceImpl
//更新文章分类
@Override
public void update(Category category) {
category.setUpdateTime(LocalDateTime.now());
categoryMapper.update(category);
}

校验id字段
com/xnj/pojo/Category
1
2
@NotNull
private Integer id;//主键id

com/xnj/mapper/CategoryMapper

1
2
3
//更新文章分类
@Update("update category set category_name=#{categoryName},category_alias=#{categoryAlias},update_time=#{updateTime} where id=#{id}")
void update(Category category);

测试
1
2
3
4
5
6
7
PUT: http://localhost:8080/category
Body.raw(json)
{
"id":1,
"categoryName":"人文",
"categoryAlias":"renwen"
}

更新文章分类功能是完成了,但是有个bug,此时再进行添加分类就会出现报错
因为在这里我们指定了id字段为@NotNull,该怎么解决冲突呢,使用valiated默认分组来解决
在com/xnj/pojo/Category中添加如下字段即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
public class Category {
@NotNull(groups = {Update.class})
private Integer id;//主键id
@NotEmpty
private String categoryName;//分类名称
@NotEmpty
private String categoryAlias;//分类别名

//如果某个效验项没有指定分组,默认为Default分组
//分组之间可以继承,A extends B 则A分组可以继承B中所有的效验项
//每个接口代表一个分组
public interface Add extends Default{ }
public interface Update extends Default { }
}

在com/xnj/controller/CategoryController的添加和更新功能上指定Category.分组.class
1
2
3
4
5
6
7
8
//添加分类
@PostMapping
public Result add(@RequestBody @Validated(Category.Add.class) Category category)


//更新文章分类
@PutMapping
public Result update(@RequestBody @Validated(Category.Update.class) Category category)


删除分类

删除分类功能实现

com/xnj/controller/CategoryController

1
2
3
4
5
6
//删除文章分类功能
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id){
categoryService.delete(id);
return Result.success();
}

com/xnj/service
1
2
3
4
5
6
7
8
9
10
// com/xnj/service/CategoryService
//删除文章分类
void delete(Integer id);

// com/xnj/service/impl/CategoryServiceImpl
//删除分类
@Override
public void delete(Integer id) {
categoryMapper.deleteById(id);
}

com/xnj/mapper/CategoryMapper
1
2
3
//删除分类
@Delete("delete from category where id=#{id}")
void deleteById(Integer id);

测试
1
DELETE: http://localhost:8080/category/3


新增文章

新增文章功能

新建文章的controller,com/xnj/controller/ArticleController

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

@Autowired
private ArticleService articleService;

@PostMapping
public Result add(@RequestBody Article article){
articleService.add(article);

return Result.success();
}
}

新建文章的service,com/xnj/service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//com/xnj/service/ArticleService
public interface ArticleService {
// 新增文章
void add(Article article);
}

//com/xnj/service//impl/ArticleServiceImpl
@Service
public class ArticleServiceImpl implements ArticleService {

@Autowired
private ArticleMapper articleMapper;

@Override
public void add(Article article) {
// 填充更新时间,创建时间,创建人
article.setCreateTime(LocalDateTime.now());
article.setUpdateTime(LocalDateTime.now());
Map<String,Object> claims = ThreadLocalUtil.get();
article.setCreateUser((Integer) claims.get("id"));

articleMapper.add(article);
}
}

新建文章的Mapper,com/xnj/mapper/ArticleMapper
1
2
3
4
5
6
7
@Mapper
public interface ArticleMapper {

@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);
}

自定义校验(validation)

前端传来的数据要求有title,content,coverImg,state,categoryId,所以要做非空校验

参数名称说明类型是否必须备注
title文章标题String1-10个非空字符
content文章内容String
coverImg封面图片地址String必须是url地址
state发布状态String已发布or草稿
categoryId分类IDnumber

开始为Article文章类设置校验
controller的添加方法参数上添加@Validated注解

1
public Result add(@RequestBody @Validated  Article article)

为Article文章类属性注解校验规则
1
2
3
4
5
6
7
8
9
10
@NotEmpty
@Pattern(regexp = "^\\S{1,10}$")
private String title;//标题
@NotEmpty
private String content;//内容
@NotEmpty
@URL
private String coverImg;//封面图
@NotNull
private Integer categoryId;//分类id

关于state文章发布状态字段只能是(已发布|草稿),可以自定义Validation校验注解

  1. 创建如下com/xnj/anno/State注解类
    写的时候可以参照@NotEmpty看它引入了哪些注解,以及实现前三个方法message(),groups(),payload() default

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Documented//元注解
    @Target( ElementType.FIELD)//y元注解作用于字段
    @Retention(RetentionPolicy.RUNTIME)//元注解
    @Constraint(validatedBy = {StateValidation.class})//指定提供校验规则的类
    public @interface State {
    //提供校验失败的提示信息
    String message() default "state参数只能是已发布或者草稿";
    //指定分组
    Class<?>[] groups() default {};
    //负载 获取到State注解的附加信息
    Class<? extends Payload>[] payload() default {};
    }
  2. 创建com/xnj/validation/StateValidation类来提供@State注解校验规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //ConstraintValidator<给哪个注解提供校验规则,校验的数据类型>泛型
    public class StateValidation implements ConstraintValidator<State,String> {
    /**
    *
    * @param value 将来要校验的数据
    * @param constraintValidatorContext
    * @return 返回true表示校验通过,返回false表示校验失败
    */
    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
    //提供校验规则
    if(value==null){
    return false;
    }
    if(value.equals("已发布")||value.equals("草稿")){
    return true;
    }
    return false;
    }
    }
  3. state字段添加我们自定义的@State注解

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

文章列表(条件分页)

文章列表(条件分页)功能实现
参数名称说明类型是否必须备注
pageNum当前页码number
pageSize每页条数number
categoryId分类IDnumber
state发布状态String已发布or草稿

com/xnj/controller/ArticleController
文章分类id和文章发布状态,应该为可传参数,并不必需

1
2
3
4
5
6
7
8
9
10
11
12
//文章分页查询
@GetMapping
public Result<PageBean<Article>> getPage(
Integer pageNum,//页码
Integer pageSize,//每页大小
@RequestParam(required = false) Integer categoryId,//非必要参数
@RequestParam(required = false) String state//非必要参数
){
PageBean<Article> pageBean = articleService.getPage(pageNum, pageSize, categoryId, state);
System.out.println(pageBean);
return Result.success(pageBean);
}

我们要借助pageHelper插件来完成分页查询,引入依赖
1
2
3
4
5
6
<!--  分页插件依赖-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.6</version>
</dependency>

com/xnj/service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//com/xnj/service/ArticleService
// 文章分页查询
PageBean<Article> getPage(Integer pageNum, Integer pageSize, Integer categoryId, String state);

//com/xnj/service//impl/ArticleServiceImpl
@Override
public PageBean<Article> getPage(Integer pageNum, Integer pageSize, Integer categoryId, String state) {
//1.创建PageBean对象
PageBean<Article> pageBean = new PageBean<>();
//2.开启分页查询,PageHelpper
PageHelper.startPage(pageNum,pageSize);

//3.执行mapper
Map<String,Object> claims = ThreadLocalUtil.get();
Integer createUser = (Integer) claims.get("id");
List<Article> ac = articleMapper.selectPage(createUser,categoryId,state);
//Page中提供了方法,可以获取PageHelper分页查询后,得到的总记录数和当前数据
Page<Article> p = (Page<Article>) ac;
//4.设置pageBean
pageBean.setTotal(p.getTotal());//总记录数
pageBean.setItems(p.getResult());//当前数据
System.out.println(pageBean);
return pageBean;
}

com/xnj/mapper/ArticleMapper
1
List<Article> selectPage(Integer createUser, Integer categoryId, String state);

该分页查询为条件查询,引入ArticleMapper.xml文件
resource/com/xnj/mapper/ArticleMapper.xml 注意,这里创建多级文件目录不能用.来分隔,要用/

这里有几个坑,namespace对应为mapper全路径,id对应mapper里的方法名,resultType对应返回类全路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?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.xnj.mapper.ArticleMapper">
<select id="selectPage" resultType="com.xnj.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=#{createUser}
</where>
</select>
</mapper>

测试

1
2
3
4
5
6
7
8
9
10
GET: http://localhost:8080/article?categoryId=3&state=已发布&pageNum=1&pageSize=3
Query:

|参数名|类型|参数值|
|:---:|:---:|:---:|
|pageSize|Integer|3|
|pageNum|Integer|1|
|categoryId|Integer|3|
|state|String|已发布|


获取文章详情/更新文章/删除文章

查看详情

这三个功能比较简单,直接展示完整代码
com/xnj/controller/ArticleController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//获取文章详情
@GetMapping("/{id}")
public Result<Article> detail(@PathVariable Integer id){
Article article = articleService.detail(id);
return Result.success(article);
}

//更新文章
@PutMapping
public Result update(@RequestBody @Validated Article article){
articleService.update(article);
return Result.success();
}

//删除文章
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id){
articleService.delete(id);
return Result.success();
}

com/xnj/service/ArticleService
1
2
3
4
5
6
// 文章详情
Article detail(Integer id);
//更新文章
void update(Article article);
//删除文章
void delete(Integer id);

com/xnj/service/impl/ArticleServiceImpl
这里如果要更加严谨也可以在每个功能加一个id认证,然后在mapper里加and id=#{createUser}即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//根据id查询文章详情
@Override
public Article detail(Integer id) {
Article article = articleMapper.selectById(id);

return article;
}

//更新文章
@Override
public void update(Article article) {
article.setUpdateTime(LocalDateTime.now());
articleMapper.update(article);
}
//删除文章
@Override
public void delete(Integer id) {
articleMapper.delete(id);
}

com/xnj/mapper/ArticleMapper
1
2
3
4
5
6
7
8
9
@Select("select * from article where id = #{id}")
Article selectById(Integer id);

@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);

@Delete("delete from article where id = #{id}")
void delete(Integer id);

功能测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//获取文章详情
GET: http://localhost:8080/article/3

//更新文章
PUT:http://localhost:8080/article
Body.raw(json)
{
"id":4,
"title":"巴黎奥运会运动员被骂",
"content":"严禁并预防饭圈文化,祸端之源a",
"coverImg":"https://picbed.xusir.fun/img/post_default_6.webp",
"state":"已发布",
"categoryId":3
}

//删除文章
DELETE:http://localhost:8080/article/5


文件上传

查看文件上传

创建com/xnj/controller/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 IOException {
//把文件内容存储在本地磁盘上
String originalFilename = file.getOriginalFilename();
//为了保证文件名称唯一,使用uuid
String filename= UUID.randomUUID().toString()+originalFilename.substring(originalFilename.lastIndexOf("."));
file.transferTo(new File("C:\\Users\\xnj\\Desktop\\big-event-img\\"+filename));

return Result.success("上传成功");
}
}

测试
1
2
3
4
5
6
POST: http://localhost:8080/upload
Body.form-data
参数名: file File
参数值: bg.jpg

可以看到bg.jpg文件下载到了C:\Users\xnj\Desktop\big-event-img\中,文件名为filename

阿里Oss(AliOss)

AliOss

在阿里云右上角控制台,在服务里搜索OSS即可找到
在阿里云创建对象存储OSS
创建Bucket 读取权限设置为公共读

点击右上角头像,生成AccessKey,建议下载到本地,且不要泄露给别人

对象存储OSS左侧菜单栏下方有SDK下载
详情查看·SDK示例·,在·小窗口·中选择·文档中心·打开,这里选择java
点击·安装·在Maven项目中加入依赖项(推荐方式)复制依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!--  阿里云OSS依赖-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>

<!--java9以上要导入以下依赖-->
<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>

看·对象/文件/上传文件/简单上传·看对应Demo
这里直接提供该项目根据demo而改成的工具类
com/utils/AiOssUtil
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
class AliOssUtil {
//区域节点
//点进你的bucket概览,往下可以看到访问端口
private static final String ENDPOINT = "";
//下面为你的AccessKey
private static final String ACCESS_KEY_ID = "";
private static final String SECRET_ACCESS_KEY = "";
//Bucket项目名字
private static final String BUCKET_NAME = "";

//上传文件,返回文件的公网访问地址
public static String uploadFile(String objectName, InputStream inputStream){
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(ENDPOINT,ACCESS_KEY_ID,SECRET_ACCESS_KEY);
//公文访问地址
String url = "";
try {
// 创建存储空间。
ossClient.createBucket(BUCKET_NAME);
ossClient.putObject(BUCKET_NAME, objectName, inputStream);
//url组成:https://bucket名称.区域节点/objectName
url = "https://"+BUCKET_NAME+"."+ENDPOINT.substring(ENDPOINT.lastIndexOf("/")+1)+"/"+objectName;
} 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;
}

在导入AiOss的依赖和AiOssUtil工具类,并且配置完你的信息后
回到FileLoadControllerController对代码进行如下改动

1
2
3
//file.transferTo(new File("C:\\Users\\xnj\\Desktop\\big-event-img\\"+filename));
String url = AliOssUtil.uploadFile(filename, file.getInputStream());
return Result.success(url);

在ApiPost进行接口测试,上传文件,返回”操作成功“和url
在浏览器中访问url即可下载刚刚上传的图片文件
1
2
3
4
5
{
"code": 0,
"message": "操作成功",
"data": "https://xnjbig-event.oss-cn-beijing.aliyuncs.com/b85e024a-a35d-4f2c-addb-4ad611ec2cfe.webp"
}

或者去你的阿里云的OSS,在Bucket列表里找到你的bucket,点进去可以看到文件上传上来了!
自此上传文件功能接口完成。


Redis

Redis

项目到这里还有一个bug
用户更改密码后,就算不用新生成的token,旧token在过期之前仍然有效

使用radis来让令牌主动失效

  • 登陆成功后,给浏览器响应令牌的同时,把该令牌存储到redis中
  • LoginInterceptor拦截器中,需要验证浏览器携带的令牌,并同时需要获取到redis中存储的与之相同的令牌
  • 当用户修改密码后,删除redis中存储的旧令牌

通过redis测试用例来了解redis
引入redis依赖

1
2
3
4
5
<!--redis坐标-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置文件application.yml
1
2
3
4
5
spring:
data:
redis:
host: localhost
port: 6379

test/java/com/xnj/RedisTest
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
package com.xnj;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

import java.util.concurrent.TimeUnit;

@SpringBootTest//如果在测试类上添加了这个注解,那么将来单元测试方法执行之前,会先初始化Spring容器
public class RedisTest {

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Test
public void testSet(){
//往redis中存储一个键值对 StringRedisTemplate
ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
operations.set("username","xusir");

//打开redis绿色版 双击redis-server.exe启动redis,
//运行测试类
//redis-cli.exe 连接redis,输入 get username,能看到xusir
operations.set("id","1",15, TimeUnit.SECONDS);//设置键值对并设置过期时间,15s
}

@Test
public void testGet(){
ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
String username = operations.get("username");
System.out.println(username);//控制台打印结果: xusir
}
}


使用Redis完善用户登录和更新密码

redis优化token机制
  1. 在登录功能里面,生成token时,把token存入redis中,设置并失效时间
  2. 在拦截器中,验证token时比较redis中存入的token是否一致
  3. 在更改密码功能中,修改完密码后,删除redis中存储的旧令牌
    具体实现代码如下
    com/xnj/controller/UserController
    改动:7,28~30,66~68 行
    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
    @RestController
    @RequestMapping("/user")
    @Validated
    public class UserController {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private UserService userService;

    //用户登录
    @PostMapping("/login")
    public Result 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("用户不存在");
    }
    //判断密码是否正确,loginUser里的password是密文
    if(Md5Util.getMD5String(password).equals(loginUser.getPassword())){
    //密码正确,生成token
    Map<String,Object> claims = new HashMap<>();
    //token中存入用户id和用户名username
    claims.put("id",loginUser.getId());
    claims.put("username",loginUser.getUsername());
    String token = JwtUtil.genToken(claims);
    //将token存入redis中,并设置过期时间为12小时
    ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
    operations.set(token,token,12, TimeUnit.HOURS);
    return Result.success(token);
    }else{
    return Result.error("密码错误");
    }
    }

    //更新用户密码
    @PatchMapping("/updatePwd")
    public Result updatePwd(@RequestBody Map<String,String> params,@RequestHeader("Authorization") String token){
    //获取参数
    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("密码不能为空");
    }


    //从ThreadLocal中获取用户信息
    Map<String,Object> claims = ThreadLocalUtil.get();
    String username = (String) claims.get("username");
    //根据用户名查询用户
    User user = userService.findByUsername(username);

    if(!user.getPassword().equals(Md5Util.getMD5String(old_pwd))){
    return Result.error("旧密码错误");
    }
    //判断两次输入的密码是否一致
    if(!new_pwd.equals(re_pwd)){
    return Result.error("两次输入的密码不一致");
    }
    //更新密码
    userService.updatePwd(new_pwd);
    //删除redis中的旧token
    ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
    operations.getOperations().delete(token);
    return Result.success();
    }

    }

    com/xnj/interceptors/LoginInterceptor
    改动:13~19行
    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
    @Component//将我们创建的拦截器对象注入IOC容器
    public class LoginInterceptor implements HandlerInterceptor {

    @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> operations = stringRedisTemplate.opsForValue();
    String redisToken = operations.get(token);
    if(redisToken==null){
    //token已经失效了,抛出异常,会被下面catch捕获,返回401错误
    throw new RuntimeException();
    }
    Map<String,Object> claims = JwtUtil.parseToken(token);
    //把业务数据存储到ThreadLocal中
    ThreadLocalUtil.set(claims);
    //放行
    return true;
    } catch (Exception e) {
    //设置Http响应状态为401
    response.setStatus(401);
    //不放行
    return false;
    }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    //请求完成之后清除ThreadLocal中的数据
    ThreadLocalUtil.remove();
    }
    }

到这里big-event项目的后端业务功能开发完毕了。


SpringBoot项目部署

部署流程

此处为Windows系统操作!!!
在pom.xml引入打包插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<build>
<plugins>
<!-- 打包插件-->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<!--
我打包时冒如下错误,排查了一会发现应该是我测试类里代码的问题,引入如下插件将测试错误忽略掉就好。
SpringBoot打包失败:Please refer to XXX\target\surefire-reports for the individual test results.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
</plugins>
</build>

在右侧maven中刷新后,在生命周期中双击package打包。显示BUILD SUCCESS
在idea左侧项目菜单的target目录下就能看到打包好的jar包了。big-event-1.0-SNAPSHOT.jar

注意:要重新打jar包时。删除jar包可以在maven里 mvn clean会删除加包,再mvn package即可重新打包

这里在本地模拟服务器上启动项目。。。。
找到jar包的资源管理器目录处(在idea中可以直接右键jar包,打开于资源管理器),在上方地址输入cmd
再输入 java -jar big-event-1.0-SNAPSHOT.jar按enter运行jar包. 按Ctrl+C终止

注意:jar包部署必须要有jre环境!

SpringBoot属性配置

打包好的项目是一个jar包,如果要改配置怎么办?

点开查看

方式一: 命令行参数方式 --键=值

  • 示例:希望项目运行的端口为 9999
  • java -jar big-event-1.0-SNAPSHOT.jar --server.port=9999

方式二:环境变量方式

  • 在环境变量中添加属性 server.port 8888
  • 环境变量改变后,需要重启终端。

方式三: 外部配置文件方式

  • 在jar包所在的目录下提供一个application.yml配置文件
  • 在这里面可以批量的修改配置属性,在项目启动时,会自动读取该配置文件里的配置。

关于配置的优先级

  • 命令行参数 > 操作系统环境变量 > jar包所在目录下的appliaction.yml > 项目resources目录下的appliaction.yml

SpringBoot多环境开发-Pofiles

点开查看

方式一:多环境开发的单文件使用

  • 使用--- 分隔不同环境的配置
  • spring.config.activate.on-profile 配置所属的环境
  • spring.profiles.active 激活环境
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
# 通用信息,指定生效的环境
#多环境下的共性属性
# 如果特点环境的配置和通用配置冲突,以特点环境的为准
spring:
profiles:
active: dev # 当前为开发环境
server:
servlet:
context-path: /
---
#开发环境
spring:
config:
activate:
on-profile: dev
server:
port: 8081
---
#生产环境
spring:
config:
activate:
on-profile: prod
server:
port: 9999

---
#测试环境
spring:
config:
activate:
on-profile: test
server:
port: 8082

方式二:多配置文件
文件名称为 application-环境名称.yml

  • 开发环境 application-dev.yml
  • 测试环境 application-test.yml
  • 生产环境 application-pro.yml
  • 共性配置并激活指定环境 application.yml

    激活测试环境如下

    1
    2
    3
    spring:
    profiles:
    active: test # 当前为测试环境

SpringBoot多环境开发-Pofiles分组

application-服务名称.yml
比如服务器相关配置写在: application-devServer.yml里
数据源相关配置写在: application-devDB.yml里
自定义相关配置写在: application-devSelf.yml里
在application.yml中定义如下即可使用

1
2
3
4
5
6
spring:
profiles:
active: dev #选择使用哪个分组
group: #定义分组
"dev":devServer,devDB,devSelf
"test":testServer,testvDB,testSelf


Vue3+Element-Plus—big-event前端开发

JavaScript-导入导出

点开查看

创建Vue_code文件夹,message.html文件

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>js导入导出</title>
</head>


<body>
<div id="app">
<button id="btn">点我展示信息</button>
</div>
<script type="module">
/* //简单导入 在js方法前面加`export`,这里{方法名},
import {simpleMessage} from './showMessage.js'
document.getElementById("btn").onclick= function(){
simpleMessage("我被点击了");
} */

//批量导入 在js下面写`export{方法1,方法2 as 别名}`,这里{方法名,别名},
// import {simpleMessage,cm} from './showMessage.js'
//如果这里给方法起别名,调用时就用别名
import {simpleMessage as sm,cm} from './showMessage.js'
document.getElementById("btn").onclick= function(){
// simpleMessage("批量导入,我被点击了");
cm("批量导入,被点击了");
sm("别名,被点击了")
}

/* //默认导出 此时`mesgMethods`代表了js中所有导出内容 `mesgMethods.方法`
import mesgMethods from './showMessage.js'
document.getElementById("btn").onclick= function(){
mesgMethods.complexMessage("我被点击了");
} */
</script>
</body>
</html>

同目录下showMessage.js文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//简单的展示信息
/* export */ function simpleMessage(msg){
console.log(msg)
}

//复杂的展示信息
/* export */ function complexMessage(msg){
console.log(new Date()+": "+msg)
}


//批量导出 这里起别名的话,在引用时就需要写别名
export {simpleMessage,complexMessage as cm}

//默认导出
// export default{simpleMessage,complexMessage}

在浏览器打开控制器根据查看F12 或 Fn+F12


局部使用Vue

vscode 格式化代码快捷键:alt+shift+f

点开查看

进入vue官网-》https://cn.vuejs.org/ 点击安装(使用 ES 模块构建版本)

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<h1>{{msg}}</h1>

</div>
<!--引入vue模块-->
<script type="module">
import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
//创建vue的应用实例
createApp({
data(){
return{
//定义数据
msg: 'helloVue3'
}
}
}).mount("#app");

</script>
</body>
</html>

常用指令

点开查看
指令作用
v-for列表渲染,遍历容器的元素或者对象的属性
v-bind为HTMl标签绑定属性值,如设置href,css样式
v-if/v-else-if/v-else条件性的渲染某元素,判定为true时渲染
v-show根据条件展示某元素,区别在于切换的是display属性的值
v-model为表单元素上创建双向数据绑定
v-on为HTMl标签绑定事件

v-for:

  • 语法:v-for = “(item,index) in items”
  • 参数: items 为遍历的数组;item 为遍历出来的元素;index 为索引/下标,从0开始,可省略(如”item in items”)。

v-if和v-show的区别

  • v-if是更具条件判断是创建还是移除元素节点(条件渲染) 适用于显示与隐藏切换不频繁的场景
  • v-show是根据css样式display来控制元素的显示与隐藏。 适用于显示与隐藏切换频繁的场景

v-on:
语法: v-on:事件名=”函数名” 简写为 @事件名=”函数名”

v-model:

  • 语法:v-model=”变量名” 变量名也需要在data()里面声明

常用指令演示代码

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<!-- v-model -->
文章分类:<input type="text" v-model="searchConditions.category"/><span>{{searchConditions.category}}</span>
发布状态:<input type="text" v-model="searchConditions.state"/><span>{{searchConditions.state}}</span> &nbsp;
<button>搜索</button> &nbsp;
<button @click="clear">重置</button>
<br/>
<br/>
<table border="1 solid" colspa="0" cellspacing="0">
<tr>
<th>序号</th>
<th>文章标题</th>
<th>分类</th>
<th>发表时间</th>
<th>状态</th>
<th>操作</th>
</tr>
<!-- v-for -->
<tr v-for="(item,index) in articleList ">
<td>{{index+1}}</td>
<td>{{item.title}}</td>
<td>{{item.category}}</td>
<td>{{item.time}}1</td>
<td>{{item.state}}</td>
<td>
<button>编辑</button>
<button>删除</button>
</td>
</tr>
</table>
<!-- v-bind -->
<a v-bind:href="url">黑马官网</a>--
<a :href="url">黑马官网</a>
<br />
<!-- v-if -->
衬衫的价格是:<span v-if="customer.level>=0 && customer.level<=1">9榜15便士</span>
<span v-else-if="customer.level>= 2 && customer.level <=4">19榜15便士</span>
<span v-else>29榜15便士</span>
<br />
<!-- v-show -->
毛线的价格是:<span v-show="customer.level>=0 && customer.level<=1">15便士</span>
<span v-show="customer.level>= 2 && customer.level <=4">25便士</span>
<span v-show="customer.level>=5">35便士</span>
<br />
<!-- v-on -->
<button v-on:click="money">点我有惊喜</button> &nbsp;
<button @click="love">点我有惊喜</button>
</div>
<script type="module">
//导入vue模块
import { createApp } from
'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
//创建应用实例
createApp({
data() {
return {
searchConditions:{
category:'',
state:''
},
articleList: [{
title: "医疗反腐绝非砍医护收入",
category: "时事",
time: "2023-09-5",
state: "已发布"
},
{
title: "中国男篮缘何一败涂地?",
category: "篮球",
time: "2023-09-5",
state: "草稿"
},
{
title: "华山景区已受大风影响阵风达7-8级,未来24小时将持续",
category: "旅游",
time: "2023-09-5",
state: "已发布"
}],
url: 'https://itheima.com',
customer: {
name: '张山',
level: 2
}
}
},
methods: {
money: function () {
alert("支付宝到账100¥")
},
love: function () {
alert("爱你一万年")
},
clear: function(){
//在methods对应的方法里面,用this就代表vue实例,可以使用this获取vue实例中的数据
this.searchConditions.category='';
this.searchConditions.state='';
}
}
}).mount("#app")//控制页面元素
</script>
</body>
</html>

Vue的生命周期

查看

生命周期八个阶段,每个阶段自动执行钩子函数。

状态阶段周期
beforeCreate创建前
created创建后
beforeMount载入前
mounted挂载完成
beforeUpdate数据更新前
updated数据更新后
beforeUnmount组件销毁前
unmounted组件销毁后

经常用的是mounted函数,它在使用时是和data,methods,是平级的。
应用场景:在页面加载完毕时,发起异步请求,加载数据,渲染页面。


Axios

查看Axios异步请求

Axios官网:https://www.axios-http.cn/ axios对原生的ajax进行了封装,简化书写,快速开发。

CDN引用方式:

关于跨域:在你的后端代码controller上面添加@CrossOrigin来支持跨域

常见的发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
axios({
method:'get', //请求方式
url:'http://localhost:8080/user/getall'
}).then(result=>{
//成功的回调
//result代表了服务器响应的所有的数据,包含了响应头,响应体,result.data代表接口响应的核心数据
console.log(result.data);
}).catch(err=>{
//失败的回调
console.log(err);
})

axios({
method:'post', //请求方式
url:'http://localhost:8080/user/add'
data: this.user
}).then(result=>{
//成功的回调
//result代表了服务器响应的所有的数据,包含了响应头,响应体,result.data代表接口响应的核心数据
console.log(result.data);
}).catch(err=>{
//失败的回调
console.log(err);
})

别名方式发送请求
1
2
3
4
5
6
7
8
9
10
axios.get('http://localhost:8080/user/getAll').then((result)=>{
console.log(result.data);
}).catch((err)=>{
console.log(err);
});
axios.post('http://localhost:8080/user/findById','id=1').then((result)=>{
console.log(result.data);
}).catch((err)=>{
console.log(err);
});


Vue小项目

Vue小项目

原生html vue_case.html,axios异步请求来实现渲染界面

html页面
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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>

</head>

<body>
<div id="app">

文章分类: <input type="text" v-model="searchConditions.category">

发布状态: <input type="text" v-model="searchConditions.state">

<button @click="search">搜索</button>

<br />
<br />
<table border="1 solid" colspa="0" cellspacing="0">
<tr>
<th>文章标题</th>
<th>分类</th>
<th>发表时间</th>
<th>状态</th>
<th>操作</th>
</tr>
<tr v-for="(article) in ArticleList">
<td>{{article.title}}</td>
<td>{{article.category}}</td>
<td>{{article.time}}</td>
<td>{{article.state}}</td>
<td>
<button>编辑</button>
<button>删除</button>
</td>
</tr>

</table>
</div>
<!--导入axios的js文件-->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script type="module">
//导入vue模块
import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
//创建vue应用实例
createApp({
data(){
return{
ArticleList:[],
searchConditions:{
category:'',
state:''
},
}
},
methods:{
search:function(){
//发送请求,完成搜索,携带条件
axios.get('http://localhost:8080/article/search?category='+this.searchConditions.category+'&state='+this.searchConditions.state)
.then((result)=>{
this.ArticleList=result.data;
}).catch((err)=>{
console.log(err);
})
}
},
//钩子函数mounted中,获取所有文章数据
mounted:function(){
//发送异步请求
axios.get('http://localhost:8080/article/getAll').then((result)=>{
//成功回调
this.ArticleList=result.data;
}).catch((err)=>{
//失败回调
console.log(err);
})
}
}).mount('#app');//控制id为app的html元素
</script>
</body>

</html>
后端代码
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
package com.xnj.controller;

import com.xnj.pojo.Article;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/article")
@CrossOrigin//支持跨域
public class ArticleController {

private List<Article> articleList = new ArrayList<>();

{
articleList.add(new Article("医疗反腐绝非砍医护收入", "时事", "2023-09-5", "已发布"));
articleList.add(new Article("中国男篮缘何一败涂地", "篮球", "2023-09-5", "草稿"));
articleList.add(new Article("华山景区已受大风影响阵风达7-8级", "旅游", "2023-09-5", "已发布"));
}

//新增文章
@PostMapping("/add")
public String add(@RequestBody Article article) {
articleList.add(article);
return "新增成功";
}

//获取所有文章信息
@GetMapping("/getAll")
public List<Article> getAll(HttpServletResponse response) {
return articleList;
}

//根据文章分类和发布状态搜索
@GetMapping("/search")
public List<Article> search(String category, String state) {
return articleList.stream().filter(a -> a.getCategory().equals(category) && a.getState().equals(state)).collect(Collectors.toList());
}
}


package com.xnj.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Article {
private String title;
private String category;
private String time;
private String state;

}

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.xnj</groupId>
<artifactId>vuetest</artifactId>
<version>0.0.1-SNAPSHOT</version>

<description>vuetest_demo</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

整站使用Vue(工程化使用vue)

查看教程

下载nodejs 需要16版本以上
配置运行权限和淘宝镜像。
npm config set prefix “D:\Develop\NodeJS” 路径为nodejs的安装目录
配置在管理员方式运行,保证获取足够的权限

  1. 在自定义创建的code文件夹内执行 npm init vue@latest 来创建一个工程化的Vue项目
提示含义
Project name:项目名称,默认值:vue-project,可输入想要的项目名称
Add TypeScript?是否加入TypeScript组件?默认值:No
Add JSX Support?是否加入JSX支持?默认值:No.
Add Vue Router …是否为单页应用程序开发添加Vue Router路由管理组件?默认值:No
Add Pinia ..是否添加Pinia组件来进行状态管理?默认值:No
Add Vitest …是否添加Vitest来进行单元测试?默认值:No
Add an End-to-End …是否添加端到端测试?默认值No
Add ESLint for code quality?是否添加ESLint来进行代码质量检查?默认值:No

可以按右箭头来选择yes或no,直接enter为默认。创建完成后会提示你三条命令

1
2
3
cd vue-project
npm install
npm run dev

  1. 进入项目目录cd vue-project,执行命令安装当前依赖: npm install
  2. 为了方便,使用vscode来打开,直接输入code .

vue项目-目录结构

vue-project目录内容
node_modules下载的第三方包存放目录
public公共资源
src源码存放目录,即写代码的文件夹
index.html默认首页
package-lock.json项目配置文件(无需修改)
package.json项目配置文件,包括项目名,版本号,依赖包,版本等
vite.config.jsVue项目的配置信息,如端口号等
src目录内容
assets静态资源目录,存放图片,字体等
components组件目录,存放通用组件
App.vue根组件
main.js入口文件
  1. 启动项目(默认端口为5173,关闭为Ctrl+C)
  • 方式一:在项目目录vue-project输入npm run dev 访问localhost:5173
  • 方式二:vscode左下脚NPM脚本中,点击dev 运行。
  1. Vue.app文件包含三个部分
  • <script></script>部分,用来写逻辑部分
  • <template></template>部分 用来写html元素部分
  • <style></style>部分 用来写css样式部分

Vue的API风格

查看风格

选项式API 结构更直观

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
export default{
data(){ //声明响应式数据
return{
count: 0
},
},
methods:{ //声明方法,可以通过组件实例访问
add: function(){
this.count++;
}
},
mounted(){ //声明钩子函数
console.log('Vue mounted..')
}
}
</script>
<template>
<button @click="add">count:{{count}}</button>
</template>

组合式API 使用更灵活

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>  //setup是一个标识,告诉vue让我们更简洁的使用组合式api
import {onMounted,ref} from 'Vue';
const count = ref(0); //组合式api中数据定义为响应式数据
function add(){ //声明函数
count.value++; //响应式变量,响应式对象有一个内部属性value
}
onMounted(()=>{ //声明钩子函数
console.log('Vue Mounted..')
})
</script>
<template>
<button @click="add">count:{{count}}</button>
</template>

组合式api小demo
在src下创建API.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import {ref,onMounted} from 'vue'
const count = ref(0);
function add(){
count.value++;
}
onMounted(()=>{
console.log("vue 挂载完成")
})

</script>
<template>
<button @click="add">count:{{ count }}</button>
</template>

然后在App.vue中引入API.vue

  • script里添加import ApiVue from './API.vue'
  • template里添加<ApiVue/>即可

案例练习

查看案例练习

后端代码参照《Vue小项目》章节提供的代码。

  1. 创建Article.vue文件
  • 两个input输入框,文章类别,发布状态
  • 一个搜索按钮
  • 一个表格:标题,分类,发表时间,状态,操作
  • 在App.vue中引入Article.vue
  • 和之前一样,使用axios,v-for,v-on,v-model,来完成页面
  1. 安装axios
  • 在项目目录VUE-PROJECT中输入npm install axios
  • vue会把下载的axios放在node_modules目录下
  • script 标签里添加 import axios from 'axios'
  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
    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
    <script setup>
    //导入axios npm install axios
    import axios from 'axios';
    import { ref } from "vue";
    //定义响应式数据
    const articleList = ref([]);
    //发送异步请求获取全部文章数据
    axios.get('http://localhost:8081/article/getAll').then((result)=>{
    //响应成功
    articleList.value=result.data;
    }).catch((err)=>{
    //响应失败
    console.log(err);
    })

    //定义响应式数据 searchConditions
    const searchConditions = ref({
    category:'',
    state:''
    })

    //搜索
    const search=function(){
    //发送异步请求,查询数据
    // axios.get('http://localhost:8081/article/search?category='+searchConditions.value.category+'&state='+searchConditions.value.state)
    axios.get('http://localhost:8081/article/search',{params:{...searchConditions.value}})
    .then((result)=>{
    articleList.value=result.data;
    }).catch((err)=>{
    console.log(err);
    })
    }
    </script>

    <template>
    <div>
    文章分类:<input type="text" v-model="searchConditions.category"/>
    发布状态:<input type="text" v-model="searchConditions.state"/>
    <button v-on:click="search">搜索</button>
    <br />
    <br />
    <table border="1 solid" colspa="0" cellspacing="0">
    <tr>
    <th>文章标题</th>
    <th>分类</th>
    <th>发表时间</th>
    <th>状态</th>
    <th>操作</th>
    </tr>
    <tr v-for="(article,index) in articleList" :key="index">
    <td>{{ article.title }}</td>
    <td>{{ article.category }}</td>
    <td>{{ article.time }}</td>
    <td>{{ article.state }}</td>
    <td>
    <button>编辑</button>
    <button>删除</button>
    </td>
    </tr>

    </table>
    </div>
    </template>
  2. 结构优化一

  • 为了代码的复用性,把接口请求写到一个js文件中。创建src/api/article.js
  • 同时因为异步请求,为了同步数据,需要在js和vue的方法里面都加上asyncawait
  • 定义一个变量抽取公开请求路径前缀。baseURL
    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
    //导入axios  npm install axios
    import axios from 'axios';
    //定义一个变量,记录公开前缀 baseURL
    const baseURL = 'http://localhost:8081';
    const instance = axios.create({baseURL});

    //获取所有文章数据的函数
    export async function articleGetAllService() {
    //发送异步请求,获取所有文章
    //同步等待服务器响应的结果,并返回,async await
    return await instance.get('/article/getAll')
    .then((result) => {
    return result.data;
    }).catch((err) => {
    console.log(err);
    })
    }

    //根据文章分类和发布状态搜索的函数
    export async function articleSearchService(conditions) {
    //发送异步请求,完成搜索
    // axios.get('http://localhost:8081/article/search?category='+searchConditions.value.category+'&state='+searchConditions.value.state)
    return await instance.get('/article/search', { params: conditions })
    .then((result) => {
    return result.data;
    }).catch((err) => {
    console.log(err);
    })
    }
    Article.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
    <script setup>
    //导入article.js文件,引用src里的文件用@
    import { articleGetAllService, articleSearchService } from "@/api/article.js";
    import { ref } from "vue";
    //定义响应式数据
    const articleList = ref([]);

    //获取全部文章数据
    //同步获取articleGetAllService的返回结果 async,await
    const getAllArticle=async function(){
    let data = await articleGetAllService();
    articleList.value = data;

    }
    getAllArticle()//调用

    //定义响应式数据 searchConditions
    const searchConditions = ref({
    category: "",
    state: "",
    });

    //搜索
    const search = async function() {
    let data = await articleSearchService({ ...searchConditions.value });
    articleList.value = data;
    };
    </script>
  1. 结构优化二
  • 创建src/util/request.js,创建拦截器,在请求或响应then或catch处理前拦截它们。
  • 因为拦截器本身是异步的,article.js的方法就不需要再添加async,await了,
  • .then和.catch也不需要了 只需要导入拦截器就可以,并把方法改xxx.get就行。详见若如下

src/util/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//导入axios  npm install axios
import axios from 'axios';
//定义一个变量,记录公开前缀 baseURL
const baseURL = 'http://localhost:8081';
const instance = axios.create({baseURL});

//添加响应拦截器
instance.interceptors.response.use(
result=>{
//http响应状态码为2xx会触发该函数
return result.data;
},
err=>{
//http响应状态码非2xx会触发该函数
alert('服务异常');
return Promise.reject(err);//异常的状态转化成失败的状态
}
)

export default instance;

src/api/article.js
1
2
3
4
5
6
7
8
9
10
11
12
// 导入拦截器
import request from '@/util/request.js'

//获取所有文章数据的函数
export function articleGetAllService() {
return request.get('/article/getAll')
}

//根据文章分类和发布状态搜索的函数
export function articleSearchService(conditions) {
return request.get('/article/search', { params: conditions })
}


ElementPlus

查看

element-plus官网-》https://element-plus.org/zh-CN/#zh-CN/
1.安装

  • npm方式安装:npm install element-plus --save
  • 下载完成后,vue项目的node_modules目录下会有@element-pluselement-plus
  1. 引入:在main.js中引入Element Plus(参照官方文档)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // main.ts
    import { createApp } from 'vue'//导入vue
    import ElementPlus from 'element-plus'//导入element-plus
    import 'element-plus/dist/index.css'//导入element-plus的样式
    import App from './App.vue'//导入app.vue
    import locale from 'element-plus/dist/local/zh-cn.js'//中文包,名字要求为locale

    const app = createApp(App)//创建应用实例

    app.use(ElementPlus,{locale})//使用element-plus,使用中文包
    app.mount('#app')//控制html元素
  2. 组件

  • 访问官网的组件,调整成我们需要的样子即可
  • 查看组件的API来调整属性
查看常用组件的Demo
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
<script lang="ts" setup>
//搜索表单
import { reactive } from "vue";

const formInline = reactive({
category: "",
state: "",
});

const onSubmit = () => {
//搜索按钮
console.log("submit!");
};
const clear = () => {
//重置按钮
console.log("clear!");
};
//分页条
import { ref } from "vue";
import type { ComponentSize } from "element-plus";
const currentPage = ref(4); //默认当前页
const pageSize = ref(5); //默认当前页面大小
const total = ref(20); //默认总数据多少条
const size = ref<ComponentSize>("default");
const background = ref(false);
const disabled = ref(false);

const handleSizeChange = (val: number) => {
//页面大小改变
console.log(`${val} items per page`);
};
const handleCurrentChange = (val: number) => {
//当前页改变
console.log(`current page: ${val}`);
};
import {
//按钮图标
Delete,
Edit,
} from "@element-plus/icons-vue";
const tableData = [
//表格数据
{
title: "标题1",
category: "时事",
time: "2024-08-20",
state: "已发布",
},
{
title: "标题1",
category: "时事",
time: "2024-08-20",
state: "已发布",
},
];
</script>

<template>
<!-- 卡片 -->
<el-card>
<div class="card-header">
<span>文章管理</span>
<el-button type="primary">发布文章</el-button>
</div>
<div style="margin-top: 20px">
<hr />
</div>
<!-- 搜索表单 -->
<el-form :inline="true" :model="formInline" class="demo-form-inline">
<el-form-item label="文章分类">
<el-select v-model="formInline.category" placeholder="请选择" clearable>
<el-option label="时事" value="时事" />
<el-option label="科幻" value="科幻" />
</el-select>
</el-form-item>
<el-form-item label="发布状态">
<el-select v-model="formInline.state" placeholder="请选择" clearable>
<el-option label="已发布" value="已发布" />
<el-option label="草稿" value="草稿" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">搜索</el-button>
<el-button type="default" @click="clear">重置</el-button>
</el-form-item>
</el-form>
<!-- 表单 -->
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="title" label="文章标题" />
<el-table-column prop="category" label="分类" />
<el-table-column prop="time" label="发表时间" />
<el-table-column prop="state" label="状态" />
<el-table-column label="操作" width="180">
<el-button type="primary" :icon="Edit" circle />
<el-button type="danger" :icon="Delete" circle />
</el-table-column>
</el-table>
<!-- 分页条 -->
<el-pagination
class="el-p"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[5, 10, 15, 20]"
:size="size"
:disabled="disabled"
:background="background"
layout="jumper, total, sizes, prev, pager, next"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-card>
</template>

<style scoped>
/* 卡片头 */
.card-header {
display: flex; /*流式布局 */
justify-content: space-between; /*两端布局 */
}
/* 分页条 */
.el-p {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* 搜索表单 */
.demo-form-inline .el-input {
--el-input-width: 220px;
}

.demo-form-inline .el-select {
--el-select-width: 220px;
}
</style>

src/main.js

1
2
3
4
5
6
7
8
9
10
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import locale from 'element-plus/dist/locale/zh-cn.js'

const app = createApp(App)

app.use(ElementPlus,{locale})
app.mount('#app')


BigEvent前端开发

项目预览

登录界面:能对输入数据进行验证,点击注册右边会变为显示注册界面
result01.png
文章管理界面:主体是展示当前用户文章列表
result02.png
修改文章界面:抽屉风格,比添加文章多了数据回显
result03.png
文章分类界面:主体是展示当前用户文章分类列表
result04.png
修改分类界面:弹出表单,比添加分类多了数据回显
result05.png
用户修改基本信息界面
result06.png
用户修改头像界面
result07.png
用户重置密码界面
result08.png

环境准备

查看环境准备
  1. 创建Vue工程 :npm init vue@latest
  2. 安装依赖
  • Element-Plus :npm install element-plus --save 并参照官方文档导入

    1
    2
    3
    4
    在main.js中做如下配置
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    app.use(ElementPlus)
  • Axios :npm install axios ,并将前面vue案例练习结构优化写的request.js拷贝到utils文件夹下

  • Sass:npm install sass -D ,并将main.js中引用的css改为scss
  1. 目录调整
  • 删除src/components下面自动生成的内容
  • 新建目录src/api,utils,views
  • 将静态资源图片拷贝到assets目录下(原有的不需要,下方提供了图片)
  • 删除App.uve中自动生成的内容
assets下的静态资源图片

main.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
body {
margin: 0;
background-color: #f5f5f5;
}

/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}

.fade-slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}

.fade-slide-leave-to {
transform: translateX(30px);
opacity: 0;
}

建议自行转存图片到本地并按照如下命名
avatar.jpg
avatar.jpg
cover.jpg
cover.jpg
default.png
default.png
login_bg.jpg
login_bg.jpg
login_title.png
login_title.png
logo.png
avatar.jpg
logo2.png
avatar.jpg


注册

注册

初始登录注册页面
src/views/Login.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
<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>

为注册部分绑定数据模型,并进行表单数据校验

  • el-form标签是通过rules属性,绑定校验规则
  • el-form-item标签上通过prop属性,指定校验项
    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
    <script>
    //定义数据模型,注册 registerData
    const registerData = ref({
    username:'',
    password:'',
    rePassword:''
    })
    //校验密码的函数
    const checkRePassword=(rule,value,callback)=>{
    if(value===''){
    callback(new Error('请再次确认密码'))
    }else if(value !== registerData.value.password){
    callback(new Error('两次输入的密码不一致'))
    }else{
    callback()
    }
    }

    //定义表单效验规则
    const registerRules = {
    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'}
    ]
    }
    </script>

    <!-- 注册表单 --><!--绑定注册数据 :model=>v-bind:model --><!--绑定校验规则 :rules="registerRules"-->
    <el-form ref="form" size="large" autocomplete="off" v-if="isRegister" :model="registerData" :rules="registerRules">
    <el-form-item>
    <h1>注册</h1>
    </el-form-item>
    <el-form-item prop="username"><!--校验数据prop="username" -->
    <el-input :prefix-icon="User" placeholder="请输入用户名" v-model="registerData.username"></el-input>
    </el-form-item>
    <el-form-item prop="password"><!--校验数据prop="password" -->
    <el-input :prefix-icon="Lock" type="password" placeholder="请输入密码" v-model="registerData.password"></el-input>
    </el-form-item>
    <el-form-item prop="rePassword"><!--校验数据prop="rePassword" -->
    <el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码" v-model="registerData.rePassword"></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>
    创建注册接口函数,绑定事件
    src/api/user.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //导入request.js请求工具
    import request from '@/utils/request.js';

    //提供调用注册接口的函数
    export const userregisterService = (registerData)=>{
    //借助UrlSearchParam完成传递
    const params = new URLSearchParams()
    for(let key in registerData){
    params.append(key,registerData[key]);
    }
    return request.post('user/register',params);
    }
    在Login.vue里面调用接口,为按钮绑定注册事件@click="register"
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <script>
    //调用后台接口完成注册
    import {userregisterService} from '@/api/user.js'
    const register = async()=>{
    //registerData是一个响应式对象,如果要获取值,需要 .value
    let result= await userregisterService(registerData.value);
    if(result.code===0){
    //注册成功
    alert(result.message?result.message:'注册成功');
    }else{
    //注册失败
    alert('注册失败')
    }
    }
    </script>
    <!--绑定注册事件-->
    <el-button class="button" type="primary" auto-insert-space @click="register">注册</el-button>
    现在运行后端会出现跨域问题
    跨域:由于浏览器的同源策略限制,向不同源(不同协议,不同域名,不同端口)发送ajax请求会失败
    方法一:
  • 在request.js中将baseURL 的值改为'/api';
    1
    2
    3
    //定义一个变量,记录公共的前缀  ,  baseURL
    // const baseURL = 'http://localhost:8080';
    const baseURL = '/api';
  • vite.config.js的defineConfig方法中添加server
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // https://vitejs.dev/config/
    export default defineConfig({
    plugins: [
    vue(),
    ],
    resolve: {
    alias: {
    '@': fileURLToPath(new URL('./src', import.meta.url))
    }
    },
    server:{
    proxy:{
    '/api':{//获取路径中包含了/api的请求
    target:'http://localhost:8080',//后台服务器所在的源
    changeOrigin:true,//修改源
    rewrite:(path)=>path.replace(/^\/api/,'')//api替换为''
    }
    }
    }
    })

方法二: 在contrller类上面添加@CrossOrigin注解来解决跨域


登录

登录

在vue.js中添加用户登录的后台接口函数
src/api/user.js

1
2
3
4
5
6
7
8
9
//提供调用登录接口的函数
export const userLoginService=(LoginData)=>{
//借助UrlSearchParam完成传递
const params = new URLSearchParams()
for(let key in LoginData){
params.append(key,LoginData[key]);
}
return request.post('user/login',params);
}

因为用户登录使用的还是username和password,可以复用registerData
所以数据绑定仍然为:model="registerData"以及v-model="registerData.username"v-model="registerData.password"
校验规则也可以复用,在相应位置添加prop="username",还有为登录按钮添加登录事件@click="login"
src/views/Login.vue
1
2
3
4
5
6
7
8
9
10
//登录使用的username和password以及验证直接使用注册的
//调用后台接口完成登录
const login = async()=>{
let result = await userLoginService(registerData.value);
if(result.code === 0){
alert(result.message?result.message:'登陆成功');
}else{
alert(result.message?result.message:'登录失败');
}
}

同时,因为注册和登录绑定的是同一个数据模型,所以应该在转变界面时应该清空数据
添加clearRegisterData函数,绑定在注册,返回
1
2
3
4
5
6
7
8
//清空数据registerData数据模型
const clearRegisterData=()=>{
registerData.value={
username:'',
password:'',
rePassword:''
}
}

<el-link type="info" :underline="false" @click="isRegister = true;clearRegisterData()">注册 →</el-link>
<el-link type="info" :underline="false" @click="isRegister = true;clearRegisterData()">← 返回</el-link>


优化拦截器

优化拦截器

每次在login.vue的接口中,我们每个方法都需要判断code===0来输出提示信息
将这个过程直接提取到拦截器里判断完,到login.vue中就确保已经是成功的了
src/util/request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//导入element通知
import { ElMessage } from 'element-plus'
//添加响应拦截器
instance.interceptors.response.use(
result=>{
// return result.data;
if(result.data.code===0){
return result.data;
}
//操作失败
// alert(result.data.message?result.data.message:'服务异常');
ElMessage.error(result.data.message?result.data.message:'服务异常');
//异步操作的状态转换为失败
return Promise.reject(result.data)
},
err=>{
alert('服务异常');
return Promise.reject(err);//异步的状态转化成失败的状态
}
)

在Login.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
//导入element通知
import { ElMessage } from 'element-plus'
//调用后台接口完成注册
import {userregisterService,userLoginService} from '@/api/user.js'
const register = async()=>{
//registerData是一个响应式对象,如果要获取值,需要 .value
let result= await userregisterService(registerData.value);
/* if(result.code === 0){
//注册成功
alert(result.message?result.message:'注册成功');
}else{
//注册失败
alert(result.message?result.message:'注册失败')
} */

ElMessage.success(result.message?result.message:'注册成功');
}

//登录使用的username和password以及验证直接使用注册的
//调用后台接口完成登录
const login = async()=>{
let result = await userLoginService(registerData.value);
/* if(result.code === 0){
alert(result.message?result.message:'登陆成功');
}else{
alert(result.message?result.message:'登录失败');
} */

ElMessage.success(result.message?result.message:'登陆成功');
}


主界面搭建

点击查看

创建src/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
144
145
<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>
<!--elementplus的菜单标签-->
<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>xnj</strong></div>
<!--element-plus的下拉菜单-->
<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>大事件 ©2024 Created by XNJ</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>


Vue 路由

点击查看
  1. 安装vue-router npm install vue-router@4
  2. 创建src/router/index.js,并创建路由器并导出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import { createRouter,createWebHistory } from "vue-router";

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

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

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

    //导出路由
    export default router
  3. 在main.js中引入router,并使用

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

    const app = createApp(App)
    app.use(router)
  4. 在vue应用实例中使用vue-router,声明router-view标签,展示组件内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- App.vue -->
    <script setup>
    </script>
    <template>
    <!-- 声明router-view标签 -->
    <router-view></router-view>
    </template>
    <style scoped>
    </style>
  5. 引入useRouter函数,在事件完成后跳转页面router.push('/')

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // Login.vue 
    //引入路由中,useRouter函数
    import {useRouter} from 'vue-router';
    const router = useRouter()

    //登录方法后完成跳转
    const login = async()=>{
    let result = await userLoginService(registerData.value);
    /* if(result.code === 0){
    alert(result.message?result.message:'登陆成功');
    }else{
    alert(result.message?result.message:'登录失败');
    } */
    ElMessage.success(result.message?result.message:'登陆成功');
    //跳转到首页,路由完成跳转
    router.push('/')
    }

配置子路由

因为,在Layout.vue主界面中,当点击左侧菜单时,内容要切换为相应的页面

  1. 创建views/article/(ArticleCategory.vue|ArticleManage.vue),views/user/(UserInfo.vue|UserAvatar.vue|/UserResetPassword.vue)
  2. 在router/index.js中引入,创建为Layout.vue的子路由,并配置默认重定向
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    //导入组件
    import ArticleCategoryVue from '@/views/article/ArticleCategory.vue';
    import ArticleManage from '@/views/article/ArticleManage.vue';
    import userUserAvatar from '@/views/user/UserAvatar.vue';
    import UserInfo from '@/views/user/UserInfo.vue';
    import UserResetPassword from '@/views/user/UserResetPassword.vue';

    //定义路由关系
    const routes = [
    {path: '/login',component: LoginVue},
    //配置子路由,并设置默认(重定向-》redirect:'/article/manage',)
    {path: '/',component: LayoutVue,redirect:'/article/manage',children:[
    {path: '/article/category',component: ArticleCategoryVue},
    {path: '/article/manage',component: ArticleManage},
    {path: '/user/avatar',component: userUserAvatar},
    {path: '/user/info',component: UserInfo},
    {path: '/user/restPassword',component: UserResetPassword},
    ]}
    ]
  3. 在Layout.vue中的内容展示区域引入<router-view></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>
  4. 在菜单标签<el-menu-item >里添加路由index="/article/category"

获取文章分类

点击查看

创建src/api/article.js,引入request.js,实现获取文章分类接口函数

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

export const articleCategoryListService = ()=>{
return request.get('/category');
}

在ArticleCategory.vue中调用接口函数
1
2
3
4
5
6
7
8
9
/导入
import {articleCategoryListService} from '@/api/article.js'
//声明异步函数获取所有分类
const articleCategoryList= async ()=>{
let result = await articleCategoryListService();
categorys.value = result.data;
}
//页面载入即调用
articleCategoryList();

现在页面会报401,服务异常,马上实现token


使用Pinia获取token

点击查看
  1. 安装pinia npm install pinia
  2. 在vue应用实例中(main.js)使用pinia

    1
    2
    3
    import { createPinia } from 'pinia'
    const pinia = createPinia()
    app.use(pinia)
  3. 在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
    import { defineStore } from "pinia";
    import { ref } from "vue";
    /**
    * defineStore参数描述:
    * 第一个参数:给状态起名,具有唯一性
    * 第二个参数:函数,可以把定义该状态中拥有的内容
    * defineStore返回值描述:
    * 返回的是一个函数,将来可以调用该函数,得到第二个参数中返回的内容
    */
    export const useTokenStore = defineStore('token',()=>{
    //1.定义描述token
    const token = ref('')

    //2.定义修改token的方法
    const setToken = (newToken)=>{
    token.value= newToken
    }

    //3.定义移除token的方法
    const removeToken = ()=>{
    token.value=''
    }

    return{
    token,setToken,removeToken
    }
    })
  4. 在组件中使用store
    在Login.vue中登录成功时应该设置token

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import {useTokenStore} from '@/stores/token.js' //引入token
    const tokenStore = useTokenStore();
    //登录使用的username和password以及验证直接使用注册的
    //调用后台接口完成登录
    const login = async()=>{
    let result = await userLoginService(registerData.value);
    ElMessage.success(result.message?result.message:'登陆成功');
    //把得到的token存储到pinia中
    tokenStore.setToken(result.data);
    //跳转到首页,路由完成跳转
    router.push('/')
    }

    获取文章分类列表的时候需要验证token,在article.js中添加如下

    1
    2
    3
    4
    5
    6
    7
    8
    import request from '@/utils/request.js'
    import { useTokenStore } from '@/stores/token.js';
    //文章分类查询
    export const articleCategoryListService = ()=>{
    const tokenStore = useTokenStore();
    //在pinia中定义的响应式数据,都不需要.value
    return request.get('/category',{headers:{'Authorization':tokenStore.token}});
    }

axios拦截器,拦截请求

在拦截器里对token拦截,就不需要每个方法都传递一遍token了
在src/util/request.js中添加如下请求拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useTokenStore } from '@/stores/token';
//添加请求拦截器
instance.interceptors.request.use(
(config)=>{
//请求前的回调
//添加token
const tokenStore = useTokenStore();
//判断有没有token
if(tokenStore.token){
config.headers.Authorization = tokenStore.token
}
return config;
},
(err)=>{
//请求错误的回调
Promise.reject(err)
}
)

article.js里方法token可以不用再传递了
1
2
3
4
5
6
7
8
// import { useTokenStore } from '@/stores/token.js';
//文章分类查询
export const articleCategoryListService = ()=>{
//const tokenStore = useTokenStore();
//在pinia中定义的响应式数据,都不需要.value
// return request.get('/category',{headers:{'Authorization':tokenStore.token}});
return request.get('/category');
}

Pinia持久化插件-persist

  • 上述拦截器完成后,刷新浏览器会出错
  • Pinia默认是内存存储,当刷新浏览器时会丢失数据
  • Persist插件可以将pinia中的数据持久化的存储
  1. 安装persist npm install pinia-persistedstate-plugin
  2. 在pinia中使用persist

    1
    2
    3
    4
    import { createPersistedState } from 'pinia-persistedstate-plugin'

    const pinia = createPinia();
    app.use(pinia)
  3. 定义状态Store时指定持久化配置参数
    src/stores/token.js

    1
    2
    3
    4
    5
    6
        return{
    token,setToken,removeToken
    }
    },{
    persist:true//持久化存储
    });

未登录统一处理

继续修复bug,上述用户直接访问路径就算未登录也能成功,只是报服务异常(status=401)
request.js的响应拦截器中,异常时,如果捕获到401,就应该提示重新登录,并跳转到登陆界面
注:由于浏览器有缓存,所以换一个浏览器即可感受到上述问题的修复

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
import router from '@/router'

//添加响应拦截器
instance.interceptors.response.use(
result=>{
// return result.data;
if(result.data.code===0){
return result.data;
}
//操作失败
// alert(result.data.message?result.data.message:'服务异常');
ElMessage.error(result.data.message?result.data.message:'服务异常');
//异步操作的状态转换为失败
return Promise.reject(result.data)
},
err=>{
//判断响应状态码,如果为401,则证明未登录,并跳转到登录界面
if(err.response.status===401){
ElMessage.error('请先登录');
router.push('/login')
}else{
ElMessage.error('服务异常');
}
return Promise.reject(err);//异步的状态转化成失败的状态
}
)


添加文章分类

点击查看
  1. ArticleCategory.vue中添加分类弹窗页面,绑定数据模型和校验规则

    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"> 确认 </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. 添加分类按钮单击事件

    1
    <el-button type="primary" @click="dialogVisible = true">添加分类</el-button>
  4. article.js中提供添加分类接口函数

    1
    2
    3
    4
    //添加文章分类
    export const articleAddCategoryService =(categoryData)=>{
    return request.post('category',categoryData)
    }
  5. 为添加分类单击事件调用接口添加分类,刷新界面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //导入
    import { articleAddCategoryService } from "@/api/article.js";
    //导入element通知
    import { ElMessage } from 'element-plus'

    //调用接口,添加分类表单
    const addCategory =async ()=>{
    let result = await articleAddCategoryService(categoryModel.value);
    ElMessage.success(result.message?result.message:'添加成功');
    //刷新分类列表,关闭弹窗
    articleCategoryList();
    dialogVisible.value=false;

    }

修改分类

点击查看

修改分类和添加分类的弹窗所需要的数据模型是一样的,弹窗样式也只有弹窗标题不一样

  • 为弹窗标题绑定数据模型,<el-dialog v-model="dialogVisible" :title="title" width="30%">
  • 在点击添加或修改按钮时给它不同的值title='添加分类',@click="showDialog(row)"
  • 添加分类按钮:<el-button type="primary" @click="showAddDialog">添加分类</el-button>
  • 修改分类按钮<el-button :icon="Edit" circle plain type="primary" @click="showEditDialog(row)"></el-button>
  • 当点击修改按钮时,还需要通过row来将数据回显到弹窗表单中
  • 注意:当在标签上时,响应式数据不需要.value来修改,而在其他地方需要.value!!!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//弹窗标题数据模型
const title=ref('')
//修改按钮,显示修改弹窗,数据回显
const showEditDialog =(row)=>{
title.value='修改分类';dialogVisible.value=true;
categoryModel.value.categoryName=row.categoryName;
categoryModel.value.categoryAlias=row.categoryAlias;
categoryModel.value.id=row.id;
}
//添加按钮,显示修改弹窗,清空数据
const showAddDialog =()=>{
title.value='添加分类';dialogVisible.value=true;
categoryModel.value.categoryName='';
categoryModel.value.categoryAlias='';
}
  • 在弹窗的确定按钮点击事件中,title的值来判断执行修改还是添加
    <el-button type="primary" @click="title==='添加分类'?addCategory():updateCategory()"> 确认 </el-button>

在article.js中提供修改分类接口

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

在ArticleCategory.vue中调用修改接口函数
1
2
3
4
5
6
7
8
9
10
//导入
import {articleUpdateCategoryService } from "@/api/article.js";
//调用接口,修改分类
const updateCategory =async ()=>{
let result = await articleUpdateCategoryService(categoryModel.value);
ElMessage.success(result.message?result.message:'修改成功');
//刷新分类列表,隐藏弹窗
articleCategoryList();
dialogVisible.value=false;
}


删除分类

点击查看

在article.js中创建删除分类接口

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

为删除按钮绑定点击事件,<el-button :icon="Delete" circle plain type="danger" @click="deleteCategory(row)"></el-button>
弹出提示,调用删除接口,通过row传递分类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
27
28
29
30
31
32
//导入
import { artilceDeleteCategoryService } from "@/api/article.js";
//导入确认框
import { ElMessageBox } from 'element-plus'
//删除分类提示框
const deleteCategory= (row)=>{
ElMessageBox.confirm(
'操作不可逆,确定要删除这个分类吗?',
'温馨提示:',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {//异步操作 async
//调用接口,执行删除文章操作
let result=await artilceDeleteCategoryService(row.id);
ElMessage({
type: 'success',
message: result.message?result.message:'删除成功',
})
//刷新分类列表
articleCategoryList();
})
.catch(() => {
ElMessage({
type: 'info',
message: '用户取消删除操作',
})
})
}


文章管理页面

点击查看

初始页面复制到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
<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
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"
}
])

//分页条数据模型
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" style="width:240px;">
<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" style="width:240px;">
<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>

使用中文语言包,解决分页条中文问题, 在main.js中完成
1
2
import locale from 'element-plus/dist/locale/zh-cn.js'
app.use(ElementPlus,{locale})

ArticleManage.vue搜索栏分类下拉列表:文章分类数据回显
1
2
3
4
5
6
7
8
9
//导入
import {articleCategoryListService} from '@/api/article.js'
//分类数据回显
const getArticleCategoryList = async ()=>{
let result =await articleCategoryListService();
categorys.value=result.data;
}
//页面挂载完成即调用
getArticleCategoryList();


获取文章列表

点击查看

article.js提供文章分页查询接口

1
2
3
4
//获取所有文章列表
export const articleGetAllService=(params)=>{
return request.get('/article',{params:params})
}

文章分页查询的参数包括,页码,页面大小,分类(可选),状态(可选)
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
//导入
import {articleGetAllService} from '@/api/article.js'
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 articleGetAllService(params);

//渲染视图,当前所有文章数据,文章总数
articles.value = result.data.items;
total.value=result.data.total;

//处理数据,给数据模型扩展一个属性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;
}
}
}
};
articleList();//该方法必须在获取分类列表之后运行

为表单分类这一列绑定categoryName
<el-table-column label="分类" prop="categoryName" ></el-table-column>

搜索按钮事件调用articleList()即可-》<el-button type="primary" @click="articleList()">搜索</el-button>
重置按钮事件清空数据即可<el-button @click="state='',categoryId=''">重置</el-button>
页脚的页码和页面大小同理

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


添加文章

点击查看

添加文章抽屉组件

1
2
3
4
5
6
7
8
9
10
11
import {Plus} from '@element-plus/icons-vue'
//控制抽屉是否显示
const visibleDrawer = ref(false)
//添加表单数据模型
const articleModel = ref({
title: '',
categoryId: '',
coverImg: '',
content:'',
state:''
})

将抽屉放在card卡片内
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>

添加抽屉的css样式
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
/* 抽屉样式 */
.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;
}
}
}

为添加文章按钮添加单击事件,展示抽屉<el-button type="primary" @click="visibleDrawer = true">添加文章</el-button>

富文本编辑器

输入文章内容模块使用富文本编辑器,这里咱们使用一个开源的富文本编辑器 Quill

  1. 安装 npm install @vueup/vue-quill@latest --save
  2. 在ArticleManage.vue里导入导入组件和样式:

    1
    2
    import { QuillEditor } from '@vueup/vue-quill'
    import '@vueup/vue-quill/dist/vue-quill.snow.css'
  3. 在’富文本编辑器’位置使用quill组件

    1
    2
    3
    4
    5
    6
    7
    <!-- 富文本编辑器 -->
    <quill-editor
    theme="snow"
    v-model:content="articleModel.content"
    contentType="html"
    >
    </quill-editor>
  4. 富文本编辑器的css样式美化

    1
    2
    3
    4
    5
    6
    .editor {
    width: 100%;
    :deep(.ql-editor) {
    min-height: 200px;
    }
    }

图片上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<el-form-item label="文章封面">
<!--
auto-upload:设置是否自动上传
action:设置服务器接口路径
name: 设置上传的请求头
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>
1
2
3
4
5
6
import {useTokenStore} from '@/stores/token.js'
const tokenStore = useTokenStore();

const uploadSuccess =(result)=>{
articleModel.value.coverImg=result.data;
}

功能实现

在article.js中提供接口函数

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

发布草稿按钮添加点击事件
<el-button type="primary" @click="addArticle('已发布')">发布</el-button>
<el-button type="info" @click="addArticle('草稿')">草稿</el-button>
调用接口函数,实现添加文章功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//导入接口函数
import {addArticleService} from "@/api/article.js";
//导入通知
import {ElMessage} from 'element-plus'
//添加文章
const addArticle =async (articleState)=>{
articleModel.value.state=articleState;
let result = await addArticleService(articleModel.value);
//提示信息
ElMessage.success(result.message?result.message:'添加成功');
//关闭抽屉
visibleDrawer.value=false;
//刷新文章列表
articleList();
}


文章修改,删除

点击查看

文章修改

article.js提供接口函数

1
2
3
4
//修改文章
export const updateArticleService=(articleData)=>{
return request.put('/article',articleData)
}

1
2
//导入
import {updateArticleService} from "@/api/article.js";

添加文章和修改文章可以复用同一个抽屉和数据模型
给抽屉的title绑定响应式数据,当点击添加和点击修改时给予不同的标题
同一个数据模型,点击修改按钮时要进行数据回显,点击添加时要清空数据模型的值
这也也可以在点击发布或草稿按钮时对标题判断来执行添加还是修改操作
给各按钮绑定事件
添加文章:<el-button type="primary" @click="showVisibleDrawer">添加文章</el-button>
修改文章:<el-button :icon="Edit" circle plain type="primary" @click="showupdateArticle(row)"></el-button>
抽屉标题::title="title"
发布按钮:<el-button type="primary" @click="title==='添加文章'?addArticle('已发布'):updateArticle('已发布')">发布</el-button>
草稿按钮:<el-button type="info" @click="title==='添加文章'?addArticle('草稿'):updateArticle('草稿')">草稿</el-button>
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
//回响式数据,抽屉标题
const title = ref('');
//添加文章按钮事件--清空文章表单数据
const showVisibleDrawer=()=>{
visibleDrawer.value = true;//显示抽屉
title.value='添加文章';//设置抽屉标题
//清空文章表单数据
articleModel.value.title='';
articleModel.value.categoryId='';
articleModel.value.coverImg='';
articleModel.value.content="<p></p>";
articleModel.value.state='';
}

//导入通知
import {ElMessage} from 'element-plus'
//添加文章
const addArticle =async (articleState)=>{
articleModel.value.state=articleState;
let result = await addArticleService(articleModel.value);
//提示信息
ElMessage.success(result.message?result.message:'添加成功');
//关闭抽屉
visibleDrawer.value=false;
//刷新文章列表
articleList();
}

//修改文章数据回显
const showupdateArticle = (row)=>{
title.value='修改文章'//抽屉标题
//显示抽屉
visibleDrawer.value=true;
//回显数据
articleModel.value.title=row.title;
articleModel.value.categoryId=row.categoryId;
articleModel.value.coverImg=row.coverImg;
articleModel.value.content=row.content;
articleModel.value.state=row.state;
articleModel.value.id=row.id;//别忘了传文章id
}

//调用接口 修改文章
const updateArticle = async(articleState)=>{
articleModel.value.state=articleState;
let result = await updateArticleService(articleModel.value);
//提示信息
ElMessage.success(result.message?result.message:'修改成功');
//关闭抽屉
visibleDrawer.value=false;
//刷新文章列表
articleList();
}

删除文章

article.js提供接口

1
2
3
4
//删除文章
export const deleteArticleService=(id)=>{
return request.delete('/article/'+id)
}

ArticleManage.vue
1
2
//导入
import {deleteArticleService} from "@/api/article.js";

为按钮绑定事件<el-button :icon="Delete" circle plain type="danger" @click="deleteArticle(row)"></el-button>
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
//导入确认框
import { ElMessageBox } from 'element-plus'

//调用接口 删除文章
const deleteArticle =async (row)=>{
ElMessageBox.confirm(
'操作不可逆,确定要删除这篇文章吗?',
'温馨提示:',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
//调用接口,执行删除文章操作
let result =await deleteArticleService(row.id)
ElMessage({
type: 'success',
message: result.message?result.message:'删除成功',
})
//刷新分类列表
articleList();
})
.catch(() => {
ElMessage({
type: 'info',
message: '用户取消删除操作',
})
})
}


顶部导航栏昵称和用户头像

点击查看

定义stores/userinfo.js来存储用户信息,和token.js原理一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import {defineStore} from 'pinia'
import {ref} from 'vue'
const useUserInfoStore = defineStore('userInfo',()=>{
//定义状态相关的内容
const info = ref({})

const setInfo = (newInfo)=>{
info.value=newInfo
}

const removeInfo = ()=>{
info.value={}
}

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

export default useUserInfoStore;


在api/user.js中,定义获取用户详细信息的接口
1
2
3
4
//获取用户详细信息
export const userInfoService = ()=>{
return request.get('/user/userinfo');
}

在Layout.vue中,调用接口
1
2
3
4
5
6
7
8
9
10
11
12
13
import avatar from '@/assets/default.png'//静态资源中默认头像图片

import { userInfoService } from '@/api/user.js';
import useUserInfoStore from '@/stores/userinfo.js';
const userInfoStore = useUserInfoStore();
//调用接口,获取用户详细信息
const getUserInfo =async ()=>{
//调用接口
let result =await userInfoService();
//将用户详细数据存储到pinia中
userInfoStore.setInfo(result.data);
}
getUserInfo();//挂载即调用

为用户昵称和头像绑定数据
1
2
3
4
5
6
7
8
9
10
11
 <!-- 头部区域 -->
<el-header>
<div>用户:<strong>{{ userInfoStore.info.nickname }}</strong></div>
<!--element-plus的下拉菜单-->
<el-dropdown placement="bottom-end">
<span class="el-dropdown__box"><!--判断用户头像是否设置了头像-->
<el-avatar :src="userInfoStore.info.userPic?userInfoStore.info.userPic:avatar" />
<el-icon>
<CaretBottom />
</el-icon>
</span>


用户头像下拉菜单功能

点击查看

为下拉菜单绑定command,路径的值是src/router/index.js中设置的子路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--element-plus的下拉菜单-->
<!--command:条目被点击之后触发,在事件函数上可以声明一个函数,接收条目对应的指令-->
<el-dropdown placement="bottom-end" @command="handlerCommand">
<span class="el-dropdown__box">
<el-avatar :src="userInfoStore.info.userPic?userInfoStore.info.userPic:avatar" />
<el-icon>
<CaretBottom />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="info" :icon="User">基本资料</el-dropdown-item>
<el-dropdown-item command="avatar" :icon="Crop">更换头像</el-dropdown-item>
<el-dropdown-item command="restPassword" :icon="EditPen">重置密码</el-dropdown-item>
<el-dropdown-item command="logout" :icon="SwitchButton">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>

完成功能
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
//导入
import useUserInfoStore from '@/stores/userinfo.js';
const userInfoStore = useUserInfoStore();//前两个前面已经导入过,这里只是提醒一下
import {useTokenStore} from '@/stores/token.js'
const tokenStore = useTokenStore();
import { useRouter } from 'vue-router';
const router = useRouter();
import { ElMessage,ElMessageBox } from 'element-plus';
//条目被点击之后调用的函数
const handlerCommand = (command)=>{
if(command==='logout'){//退出登录
ElMessageBox.confirm(
'确定要退出吗?',
'温馨提示:',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
) .then( () => {
//删除pinia中存储的token,和suerInfo
userInfoStore.removeInfo();
tokenStore.removeToken();
//跳转到登录页面
router.push('/login');
ElMessage({
type: 'success',
message: '退出成功',
})

}).catch(() => {
ElMessage({
type: 'info',
message: '您取消了退出',
})
})
}else{//路由切换页面
router.push('/user/'+command)
}
}


修改用户基本信息

点击查看

用户基本资料界面src/views/user/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>

回显信息:用户基本信息已经存到pinia里面了。这里只需要取出来赋值给表单数据模型即可
1
2
3
4
5
//导入
import useUserInfoStore from '@/stores/userinfo'
const UserInfo = useUserInfoStore();
//将用户信息赋值给表单数据模型
const userInfo = ref({...UserInfo.info})

user.js提供修改函数接口
1
2
3
4
//修改用户信息
export const updateUserInfoService=(userInfoData)=>{
return request.put('/user/update',userInfoData)
}

调用接口,完成修改,同时修改pinia中存储的用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
//修改用户信息
import useUserInfoStore from '@/stores/userinfo'
const UserInfo = useUserInfoStore();
//将用户信息赋值给表单数据模型
const userInfo = ref({...UserInfo.info})
import { ElMessage } from 'element-plus';
import {updateUserInfoService} from '@/api/user.js'
const updateUserInfo =async ()=>{
let result =await updateUserInfoService(userInfo.value);
ElMessage.success(result.message?result.message:'修改成功');
//修改pinia中存储的用户信息
UserInfo.setInfo(userInfo.value);
}


用户头像修改

点击查看

用户头像回显,图片上传,头像修改
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>

头像回显:从pinia中取出用户信息,头像地址赋值给imgUrl,组件已经通过v-if判断好了

1
2
3
4
5
6
//导入pinia中用户信息
import useUserInfoStore from '@/stores/userinfo.js'
const userInfoStore = useUserInfoStore();

//用户头像地址--实现用户头像回显
const imgUrl= ref(userInfoStore.info.userPic)

上传头像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!--
auto-upload:设置是否自动上传
action:设置服务器接口路径
name: 设置上传的请求头
headers: 请求头
on-success: 设置上传成功的回调函数
-->
<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>

1
2
3
4
5
6
7
//导入token
import {useTokenStore} from '@/stores/token.js'
const tokenStore = useTokenStore();
//图片上传成功的回调函数
const uploadSuccess = (result)=>{
imgUrl.value= result.data;
}

头像修改
user.js中提供修改头像接口函数

1
2
3
4
5
6
7
8
9
//修改头像
export const updateAvatarService=(avatarURL)=>{
//参数是Query类型的,可以直接通过拼接在路径后面传递
// return request.patch('/user/updateAvatar?avatarurl='+avatarURL)
//也可以通过UrlSerachParams来完成
const params=new URLSearchParams();
params.append('avatarurl',avatarURL);
return request.patch('/user/updateAvatar',params)
}

在UserAvatar.vue中调用接口,完成修改,同时修改pinia中的头像信息
1
2
3
4
5
6
7
8
9
10
//修改头像
import { ElMessage } from 'element-plus';
import {updateAvatarService} from '@/api/user.js'
const updateAvater =async ()=>{
//调用接口
let result =await updateAvatarService(imgUrl.value)
ElMessage.success(result.message?result.message:'上传成功')
//修改pinia中用户的头像信息
userInfoStore.info.userPic = imgUrl.value
}

为上传按钮绑定事件<el-button type="success" :icon="Upload" size="large" @click="updateAvater">上传头像</el-button>


重置用户密码

点击查看

use.js提供重置密码函数接口

1
2
3
4
//修改密码
export const updatePasswordService=(Password)=>{
return request.patch('/user/updatePwd',Password)
}

重置密码UserRestPassword.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
<script setup>
import { ref } from 'vue'

//导入
const PassWord = ref({
old_pwd:'',
new_pwd:'',
re_pwd:''
})

const rules = {
old_pwd: [
{ required: true, message: '请输入旧密码', trigger: 'blur' },
{
pattern: /^\S{6,20}$/,
message: '密码必须是6-10位的非空字符串',
trigger: 'blur'
}
],
new_pwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{
pattern: /^\S{6,20}$/,
message: '密码必须是6-10位的非空字符串',
trigger: 'blur'
}
],
re_pwd:[
{ required: true, message: '请确定新密码', trigger: 'blur' },
{//第二个规则是判断是否和新密码一致
validator: (rule, value, callback) => {
if (value !== PassWord.value.new_pwd) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
},
trigger: 'blur'
}
]
}


//修改用户密码
import useUserInfoStore from '@/stores/userinfo.js'
const userInfoStore = useUserInfoStore();
import {useTokenStore} from '@/stores/token.js'
const tokenStore = useTokenStore();
import { useRouter } from 'vue-router';
const router = useRouter();
import {updatePasswordService} from '@/api/user.js'
import { ElMessage } from 'element-plus';
const updatePassword = async ()=>{
let result =await updatePasswordService(PassWord.value);
ElMessage.success(result.message?result.message:'修改成功')
//删除pinia中存储的token,和suerInfo
userInfoStore.removeInfo();
tokenStore.removeToken();
//重新登录
router.push('/login')
}

</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="PassWord" :rules="rules" label-width="100px" size="large">
<el-form-item label="旧密码" prop="old_pwd">
<el-input v-model="PassWord.old_pwd" type="password"></el-input>
</el-form-item>
<el-form-item label="新密码" prop="new_pwd">
<el-input v-model="PassWord.new_pwd" type="password"></el-input>
</el-form-item>
<el-form-item label="确认密码" prop="re_pwd">
<el-input v-model="PassWord.re_pwd" type="password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updatePassword">提交修改</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</el-card>
</template>