SpringBoot+Vue开发流程

这节课目的是分享一下我自己的开发流程,如果需要深入学习还是得自己在b站看视频边看边学的。

这里演示一个登录注册的小demo吧

后端项目初始化

环境准备

  1. JDK11或者17
  2. MySQL数据库8.x版本
  3. IDEA2024版(这个不要求)

    创建SpringBoot项目

1、可以设置阿里云的初始化模板:https://start.aliyun.com/

image-20250321082752972

2、选择springboot的版本和导入相关的依赖,这里选择2.7.6版本

image-20250321083322773

3、修改配置文件application.properties

image-20250321083609222

修改后缀为yml格式,把名字修改成application.yml,并且添加下面内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
server:
port: 8123 #服务器的端口号
servlet:
context-path: /api #后端接口访问路径
spring:
application:
name: springboot-model #项目名称(灵魂)
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver #驱动
url: jdbc:mysql://localhost:3306/test #数据库地址
username: root #用户名
password: root #密码

然后启动一下看看是否报错

配置maven

整合依赖

MyBatis-Plus

官网:快速开始 | MyBatis-Plus

1、在pom.xml添加下面的依赖并且删除MyBatis的依赖防止依赖冲突

SpringBoot2.x

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.10.1</version>
</dependency>

SpringBoot3.x

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.10.1</version>
</dependency>

添加好依赖之后,在启动文件添加包扫描注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.zhbit;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.zhbit.mapper")
public class SpringbootModelApplication {

public static void main(String[] args) {
SpringApplication.run(SpringbootModelApplication.class, args);
}

}

配置mybatis-plus

1
2
3
4
5
6
7
8
9
10
mybatis-plus:
type-aliases-package: com.zhbit.entity #实体类包,起别名
configuration:
map-underscore-to-camel-case: false #开启驼峰命名,默认是true,改为false后数据库名和实体类名一致即可
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句在控制台上,仅在开发环境使用
global-config:
db-config:
logic-delete-field: isDelete #使用逻辑删除,数据库中必须要有isDelete字段
logic-delete-value: 1 #逻辑删除值
logic-not-delete-value: 0 #逻辑未删除值

Hutool工具类

官网:入门和安装

1、在pom.xml中添加下面的依赖

1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>

Knife4j

官网:快速开始 | Knife4j

springboot3.x

1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>

springboot2.x

1
2
3
4
5
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>

根据官网配置接口文档

1
2
3
4
5
6
7
8
9
10
11
# 接口文档配置
knife4j:
enable: true
openapi:
title: "接口文档"
version: 1.0
group:
default:
api-rule: package
api-rule-resources:
- com.zhbit.controller

访问

1
http://localhost:8123/api/doc.html

就可以看到接口文档

其他依赖

1、aop切面

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2、在主类上加上

1
@EnableAspectJAutoProxy(exposeProxy = true)

加上之后可以获取当前类的代理对象

  1. 开启Spring AOP代理的创建:使得Spring容器中的所有bean都能被AOP代理。

3、springboot热部署

热部署环境配置,需要开发者工具-DevTools

devtools 可以实现页面热部署(即页面修改后会立即生效,这个可以直接在 application.properties 文件中配置 spring.thymeleaf.cache=false 来实现),实现类文件热部署(类文件修改后不会立即生效),实现对属性文件的热部署。即 devtools 会监听 classpath 下的文件变动,并且会立即重启应用(发生在保存时机),注意:因为其采用的虚拟机机制,该项重启是很快的。配置了后在修改 java 文件后也就支持了热启动,不过这种方式是属于项目重启(速度比较快的项目重启),会清空 session 中的值,也就是如果有用户登陆的话,项目重启后需要重新登陆。

​ 默认情况下,/META-INF/maven,/META-INF/resources,/resources,/static,/templates,/public 这些文件夹下的文件修改不会使应用重启,但是会重新加载( devtools 内嵌了一个 LiveReload server,当资源发生改变时,浏览器刷新)

添加pom依赖

1
2
3
4
5
6
7
8
9
<!-- DevTools 的坐标 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<!--项目运行时生效。表示编译时不参与,但是参与项目的测试、打包。该依赖被打包时会被包含。-->
<scope>runtime</scope>
<!--设置为true热部署才生效-->
<optional>true</optional>
</dependency>

devtools配置

1
2
3
4
5
6
7
8
9
spring:
## 热部署配置
devtools:
restart:
enabled: true # 开启热部署
# 设置热启动路径,这个路径下的内容修改后不需要重启,进行热部署。
additional-paths: src/main/java
# 设置的内容为不让热部署去管理,不需要加载 (其实指的就是编译过的文件,因为热部署只管理变化的内容,编译过的没有变化的内容不加载)
exclude: WEB-INF/**

依赖配置好之后,我们就开始CRUD了吗?

先写一写,通用的基础代码。

基础代码

自定义异常

自定义异常,对错误的问题细化,便于前端统一处理。

首先:自定义错误码

在Exception包下面新建一个ErrorCode枚举类

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.zhbit.exception;

import lombok.Getter;

/**
* @author oyy0v0
* @version 1.0.0
* @create 2025/3/21 10:40
*/
@Getter
public enum ErrorCode {

SUCCESS(0, "ok"),
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");

/**
* 状态码
*/
private final int code;

/**
* 信息
*/
private final String message;

ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}

然后自定义业务异常

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
package com.zhbit.exception;

import lombok.Getter;

/**
* @author oyy0v0
* @version 1.0.0
* @create 2025/3/21 10:46
*/
@Getter
public class BusinessException extends RuntimeException {

/**
* 错误码
*/
private final int code;

public BusinessException(int code, String message) {
super(message);
this.code = code;
}

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}

public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}

}

那么我们抛异常的时候是这样抛吗?还能不能更简单一点?

1
2
3
if( 1 != 0){
new BussinessException(ErrorCode.PARAMS_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
33
34
35
public class ThrowUtils {

/**
* 条件成立则抛异常
*
* @param condition 条件
* @param runtimeException 异常
*/
public static void throwIf(boolean condition, RuntimeException runtimeException) {
if (condition) {
throw runtimeException;
}
}

/**
* 条件成立则抛异常
*
* @param condition 条件
* @param errorCode 错误码
*/
public static void throwIf(boolean condition, ErrorCode errorCode) {
throwIf(condition, new BusinessException(errorCode));
}

/**
* 条件成立则抛异常
*
* @param condition 条件
* @param errorCode 错误码
* @param message 错误信息
*/
public static void throwIf(boolean condition, ErrorCode errorCode, String message) {
throwIf(condition, new BusinessException(errorCode, message));
}
}

那我们以后抛异常是不是可以

1
ThrowUtils.throwIf(1 != 0, new Bussinession(ErrorCode.PARAMS_ERROR,"参数异常"));

通用响应类

那还有一个问题,我们返回的格式乱七八糟的,如何统一格式?这里的返回可能是String,User,或者是List(User)等等五花八门

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

@GetMapping("/hello")
@ResponseBody
public String hello() {
return "hello";
}
@GetMapping("/hello")
@ResponseBody
public int hello() {
return 1;
}
}

那么我们可以封装统一的是响应结果类,便于前端统一获取这些信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Data
public class BaseResponse<T> implements Serializable {

private int code;

private T data;

private String message;

public BaseResponse(int code, T data, String message) {
this.code = code;
this.data = data;
this.message = message;
}

public BaseResponse(int code, T data) {
this(code, data, "");
}

public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
}

以后我们相应的时候需要再装一层

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

@GetMapping("/hello")
@ResponseBody
public BaseResponse<String> hello() {
return new BaseResponse<>(200,"hello");
}
@GetMapping("/hello")
@ResponseBody
public BaseResponse<Integer> hello() {
return new BaseResponse<>(200,1);;
}
}

但是还是不够方便,每次接口返回值的时候,都要手动new一个BaseResponse对象并传入参数,比较麻烦,我们可以新建一个工具类,提供成功调用和失败调用的方法,支持灵活的传参,简化调用。

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
public class ResultUtils {

/**
* 成功
*
* @param data 数据
* @param <T> 数据类型
* @return 响应
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}

/**
* 失败
*
* @param errorCode 错误码
* @return 响应
*/
public static BaseResponse<?> error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}

/**
* 失败
*
* @param code 错误码
* @param message 错误信息
* @return 响应
*/
public static BaseResponse<?> error(int code, String message) {
return new BaseResponse<>(code, null, message);
}

/**
* 失败
*
* @param errorCode 错误码
* @return 响应
*/
public static BaseResponse<?> error(ErrorCode errorCode, String message) {
return new BaseResponse<>(errorCode.getCode(), null, message);
}
}

这个时候我们再写接口返回值的时候怎么写?

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

@GetMapping("/hello")
@ResponseBody
public BaseResponse<String> hello() {
return new BaseResponse<>(200,"hello");
}
@GetMapping("/hello")
@ResponseBody
public BaseResponse<Integer> hello() {
return ResultUtils.success("hello");
}
}

现在又有一个问题,如果我们乱七八糟的调用,是不是会返回一些乱七八糟的数据给前端,这是不允许的吧。

1
[localhost:8888/api/doc.](http://localhost:8888/api/doc.)

image-20250321133009537

分页请求类

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
package com.zhbit.common;

import lombok.Data;

/**
* 分页请求
* @author oyy0v0
* @version 1.0.0
* @create 2025/3/22 22:52
*/
@Data
public class PageRequest {

/**
* 当前页号
*/
private int current = 1;

/**
* 页面大小
*/
private int pageSize = 10;

/**
* 排序字段
*/
private String sortField;

/**
* 排序顺序(默认降序)
*/
private String sortOrder = "descend";
}

通用删除请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.zhbit.common;

import lombok.Data;

import java.io.Serializable;

/**
* 通用删除请求
* @author oyy0v0
* @version 1.0.0
* @create 2025/3/22 22:54
*/
@Data
public class DeleteRequest implements Serializable {

/**
* id
*/
private Long id;

private static final long serialVersionUID = 1L;
}

全局跨域请求配置

为什么要设置这个?

因为浏览器有一个同源策略,拦截的屎客户端发出去的请求成功后返回的数据。也就是说即使你发送请求成功了,服务器也相应了但是浏览器拒绝接受该数据。

比如我从一个前端的网址localhost:5173/user的网址访问localhost:8080/api/user/userLogin的服务,这两个是不同源的就会访问失败,所以需要设置这个全局跨域请求配置。

什么是同源

一般我们输入浏览器的网址包含协议+域名+端口号+具体的目标文件路径,前三个只要有一个不同的就是不同源的。

没有同源策略会怎么样

如果没有同源策略,任何网站都可以通过js脚本访问其他网站的数据。

例子:用户A正在使用浏览器访问他的网上银行(例如 https://bank.example),并且此时他还打开了另一个网页,该网页由攻击者控制(例如 http://evil.example)。

有同源策略:在当前存在同源策略的情况下,即使恶意网站尝试通过JavaScript脚本来读取或修改来自网上银行网站的数据,浏览器会阻止这种行为。例如,如果恶意网站试图发起一个AJAX请求到 https://bank.example/account/balance 来获取用户的账户余额信息,或者尝试通过JavaScript直接访问并篡改银行网页的DOM结构,这些操作都会被浏览器根据同源策略禁止,因为 http://evil.examplehttps://bank.example 属于不同的源(域名、协议或端口不同)。

弄个隐藏的表单,利用用户已登录的状态向银行网站提供非法转账请求。

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
package com.zhbit.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* @author oyy0v0
* @version 1.0.0
* @create 2025/3/22 22:54
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
// 覆盖所有请求
registry.addMapping("/**")
// 允许发送 Cookie
.allowCredentials(true)
// 放行哪些域名(必须用 patterns,否则 * 会和 allowCredentials 冲突)
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
}


至此我们就基本初始化完成了。

前端项目初始化

Vue.js 本身并不是强制性的单页面应用框架,但它非常适用于构建单页面应用。单页面应用是指整个应用只有一个 HTML 页面,通过动态更新这个页面的内容来模拟多页面应用的效果。Vue.js 通过其核心的响应式系统和组件化开发模式,使得开发者可以轻松地创建复杂的单页面应用。

Vue 被描述为“渐进式”的原因在于它能够逐步集成到现有项目中,并且可以根据项目的需求灵活调整使用方式。

环境准备

前端 Node.js版本安装>=18.12,安装好npm前端包管理器。

可以跟着vue官网快速创建:快速上手 | Vue.js

在终端输入命令:

1
npm create vue@3.12.1

npm会自动安装create-vue工具:

image-20250323004516847

这里类似于springboot的快速模板创建一样。

然后我们直接安装Vue Router路由,Pinia全局状态管理等使用类库。

然后用 WebStorm 打开项目,先在终端执行 npm install 安装依赖,然后执行 npm run dev 能访问网页就成功了。

为了开发效率更高,你可能想关闭由于 ESLint 校验导致的编译错误,同样可以在开发工具中禁用 ESLint:

image-20250323013611609

快速开发我们引入组件库

Ant Design Vue 组件库,这里可以参考Ant Design Vue-官方文档我这里使用的版本是 4.2.6,

执行安装

1
npm i --save ant-design-vue@4.2.6

改变主入口文件 main.ts,全局注册组件(为了方便):

1
2
3
4
5
6
7
8
9
10
11
import App from './App.vue'
import router from './router'
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";

const app = createApp(App)
app.use(Antd);
app.use(createPinia())
app.use(router)

app.mount('#app')

我们可以先尝试应用一下看看有没有引用成功。

开发规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div id="xxPage">

</div>
</template>

<script setup lang="ts">

</script>

<style scoped>
#xxPage {
}

</style>

修改页面基本信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录注册</title>

</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

还可以替换public目录下的默认的ico图标为自己的。

很多现成的ico制作网站,可以自己搜索在线制作ico图标 比特虫 - Bitbug.net

image-20250323113047093

开发全局通用布局

  1. 基础布局结构

在layouts目录下新建一个布局BasicLayout.vue,在App.vue全局页面入口文件中引入。

1
2
3
4
5
6
7
8
9
<template>
<div id="app">
<BasicLayout />
</div>
</template>

<script setup lang="ts">
import BasicLayout from "@/layouts/BasicLayout.vue";
</script>

2、开发基础页面

利用组件库的布局组件,完成基础的布局

1
2
3
4
5
<a-layout>
<a-layout-header :style="headerStyle">Header</a-layout-header>
<a-layout-content :style="contentStyle">Content</a-layout-content>
<a-layout-footer :style="footerStyle">Footer</a-layout-footer>
</a-layout>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div id="basicLayout">
<a-layout style="min-height: 100vh">
<a-layout-header>Header</a-layout-header>
<a-layout-content>Content</a-layout-content>
<a-layout-footer>Footer</a-layout-footer>
</a-layout>
</div>

</template>

<script setup lang="ts">
const msg = '开始!!!!'
</script>


<style scoped>

</style>

这里可以删除一些自动生成的样式文件,来保证我们写的不会被自动生成的css文件给污染,并且给布局文件设置100vh的高度铺满整个可见区域。

全局底部栏

通常用于展示版权信息

1
2
3
<a-layout-footer class="footer">
<a>登录注册 by oyy0v0</a>
</a-layout-footer>

样式

1
2
3
4
5
6
7
8
9
#basicLayout .footer {
background: #efefef;
padding: 16px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
}

那作为一个单页面应用,如何动态的替换内容而不改变页面呢?

这里就使用到我们的Vue Router路由库,可以在router/index.ts配置路由,能够根据访问的页面地址找到不同的文件并进行加载渲染。

修改BasicLayout

修改BasicLayout内容部分的代码

1
2
3
<a-layout-content class="content">
<router-view />
</a-layout-content>

修改样式,要和底部栏保持一定的外边距,否则页面太小的时候底部内容会被遮住

1
2
3
4
5
6
7
8
<style scoped>
#basicLayout .content {
background: linear-gradient(to right, #fefefe, #fff);
margin-bottom: 28px;
padding: 20px;
}
</style>

全局顶部栏

由于顶部栏的开发相对复杂,这里使用AntDesign的菜单组件来创建GlobalHeader全局顶部栏组件,组件统一放在components目录中

先直接复制现成的组件示例代码到GlobalHeader中

然后在基础布局中引入顶部栏组件,引入完之后发现两边有黑框,然后修改一下样式。

1
2
3
4
5
6
#basicLayout .header {
padding-inline: 20px;
margin-bottom: 16px;
color: unset;
background: white;
}

接下来修改GlobalHeader组件,完善更多内容。

1、先给菜单外套一层元素,用于整体样式控制

1
2
3
<div id="globalHeader">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" />
</div>

2、根据我们的需求修改菜单配置,key为要跳转的URL路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script lang="ts" setup>
import { h, ref } from 'vue'
import { HomeOutlined } from '@ant-design/icons-vue'
import { MenuProps } from 'ant-design-vue'

const current = ref<string[]>(['home'])
const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '主页',
},
{
key: '/about',
label: '关于',
title: '关于',
},
])
</script>

3、完善全局顶部栏,左侧补充网站图标和标题。

先把logo.png放在src/assets目录下,替换原本的默认logo

修改GlobalHeader代码,补充HTML

1
2
3
4
5
6
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="../assets/logo.png" alt="logo" />
<div class="title">登录注册</div>
</div>
</RouterLink>

其中RouterLink组件的作用是支持超链接跳转(不刷新页面)

补充css样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<style scoped>
.title-bar {
display: flex;
align-items: center;
}

.title {
color: black;
font-size: 18px;
margin-left: 16px;
}

.logo {
height: 48px;
}
</style>

4、继续完善顶部栏,右侧展示当前用户的登录状态(暂时先用登录按钮替代)

1
2
3
<div class="user-login-status">
<a-button type="primary" href="/user/login">登录</a-button>
</div>

5、优化导航栏的布局,采用栅格组件的自适应布局(左中右结构,左侧右侧宽度固定,中间菜单栏自适应)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="../assets/logo.png" alt="logo" />
<div class="title">登录注册</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu
v-model:selectedKeys="current"
mode="horizontal"
:items="items"
/>
</a-col>
<a-col flex="120px">
<div class="user-login-status">
<a-button type="primary" href="/user/login">登录</a-button>
</div>
</a-col>
</a-row>

路由配置

现在我们点击导航栏,内容并没有发生任何变化,这是因为我们没有配置好路由。

目标:点击菜单项后,可以跳转到对应的页面;并且刷新页面后,对应的菜单自动高亮

1、修改路由配置

2、路由跳转

给GlobalHeader的菜单组件绑定跳转事件

1
2
3
4
5
6
7
8
9
import { useRouter } from "vue-router";
const router = useRouter();

// 路由跳转事件
const doMenuClick = ({ key }: { key: string }) => {
router.push({
path: key,
});
};

修改HTML模板,绑定事件

1
2
3
4
5
6
7
<a-menu
v-model:selectedKeys="current"
mode="horizontal"
:items="items"
@click="doMenuClick"
/>

3、高亮同步

刷新页面后,发现当前菜单项并没有高亮,所以需要同步路由的更新到菜单项高亮。

原理:点击菜单式,组件已经通过v-model绑定了current变量实现了高亮,点击刷新页面的是偶需要获取当前URL路径,然后修改current变量的值,从而实现同步。

1
2
3
4
5
6
7
8
const router = useRouter();
// 当前选中菜单
const current = ref<string[]>([]);
// 监听路由变化,更新当前选中菜单
router.afterEach((to, from, next) => {
current.value = [to.path];
});

请求

引入Axios库

一般情况下,前端只负责界面展示和动效互动,尽量避免写复杂的逻辑。当需要获取数据时,通常是向后端提供的接口发送请求,然后后端执行操作,(比如查找用户)并响应数据给前端。

那么前端如何发送请求呢?最传统的方式AJAX技术,但是代码有些复杂,我们可以使用第三方的封装库,来简化发送请求的代码。

比如Axios.

1、请求工具库

安装Axios,Getting Started | Axios Docs

1
npm install axios

2、全局自定义请求

跟后端一样,在开发后端的时候,自己封装了一些自定义响应类,还有全局异常处理,那么前端也有类似的逻辑。

参考Axios官方文档,编写请求配置文件,request.ts,包括全局接口请求地址、超时时间、自定义请求响应拦截器等等。

响应拦截器应用场景:我们需要对接口的通用相应进行统一处理,比如从request中取出data,或者根据code取集中处理错误,这样不用在每个接口请求中去写相同的逻辑。

3、自动生成请求代码

如果采用传统的开发方式,针对每个请求都要单独编写代码,很麻烦。

推荐使用OpenAPI工具,直接自动生成即可,@umijs/openapi - npm

按照官方文档的步骤,先安装

1
npm i --save-dev @umijs/openapi

安装完之后,写配置文件

在项目的根目录创建openapi.config.js,根据自己的需求定制生成的代码。

1
2
3
4
5
6
7
import { generateService } from '@umijs/openapi'

generateService({
requestLibPath: "import request from '@/request'",
schemaPath: 'http://localhost:8123/api/v2/api-docs',
serversPath: './src',
})

注意,要将schemaPath改为自己后端服务提供的Swagger接口文档的地址

在package.json的script中添加”openapi”: “node openapi.config.js”

执行即可生成请求代码,还包括TypeScript类型,以后每次后端接口变更的时候,只需要重新生成一遍就好,非常方便。

那么有人问了:那不用api自动生成是怎么请求的呢?待会我展示一下,手动和自动生成,但是效果是一样的。

vue+axios实现登录注册-CSDN博客

全局状态管理

所有页面都需要共享的变量,而不是在某一个页面中。

举个例子:在登陆一个系统之后就不需要再次登录了,(每个页面都需要用)

Pinia是一个主流的状态管理库,相比较Vuex来说使用更简单,可以参考官方文档引入。

引入pinia

我们之前已经用脚手架引入了,无需手动引入了。

定义状态

那我们后端是怎么实现全局状态管理的?

定义一个常量类,每次需要使用的时候就调用这个常量类即可。

前端也一样。

那又有人问了,那我们写一个js文件不就好了,为什么还要用pinia?

因为假如说我们还需要有一些额外的功能,比如说加入变量的值变了之后我们同时更新页面展示的信息,因为js是写死的嘛,那要实现这个额外的功能我们就需要使用第三方的工具了。

在src/stores目录下定义user模块

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

export const useLoginUserStore = defineStore("loginUser", () => {
const loginUser = ref<any>({
userName: "未登录",
});

async function fetchLoginUser() {
// todo 由于后端还没提供接口,暂时注释
// const res = await getCurrentUser();
// if (res.data.code === 0 && res.data.data) {
// loginUser.value = res.data.data;
// }
}

function setLoginUser(newLoginUser: any) {
loginUser.value = newLoginUser;
}

return { loginUser, setLoginUser, fetchLoginUser };
});

使用状态

可以直接使用store中导出的状态变量和函数

在首次进入页面时,一般我们会尝试获取登录用户信息,修改APP.vue,编写远程获取数据代码

1
2
3
const loginUserStore = useLoginUserStore()
loginUserStore.fetchLoginUser()

修改全局顶部栏组件,应用用户信息来控制页面

1
2
3
4
5
6
7
8
9
<div class="user-login-status">
<div v-if="loginUserStore.loginUser.id">
{{ loginUserStore.loginUser.userName ?? '无名' }}
</div>
<div v-else>
<a-button type="primary" href="/user/login">登录</a-button>
</div>
</div>

可以在userStore中编写测试代码,测试用户状态的假登录

1
2
3
4
5
6
7
async function fetchLoginUser() {
// 测试用户登录,3 秒后登录
setTimeout(() => {
loginUser.value = { userName: '测试用户', id: 1 }
}, 3000)
}

页面开发流程

1、新建src/pages目录,用于存放所有的页面文件

在page目录下新建页面文件,将所有页面按照url层级进行创建,并且页面名称尽量做到“见名知意”。

image-20250324022058191

其中,/user/login地址就对应了UserLoginPage.

2、每次新建页面的时候,需要在router/index.ts中配置路由,比如欢迎页的路由为

1
2
3
4
5
6
7
8
9
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "home",
component: HomeView,
},
...
]

开发流程

一、需求分析

对于一个登录注册的用户模块,需要有什么功能?

  • 用户注册
  • 用户登录
  • 获取当前登录用户
  • 用户注销
  • 用户权限控制
  • 【管理员】管理用户

具体分析每个需求:

1、用户注册:用户可以通过输入账号,密码,确认密码进行注册

2、用户登录:用户可以通过输入账号和密码进行登录

3、获取当前登录用户:得到当前已经登录的用户信息(不用重复登录)

4、用户注销:用户可以退出登录状态

5、用户权限控制:用户可以分为普通用户和管理员,管理员拥有整个系统的最高权限,比如可以管理其他用户

6、用户管理:仅管理员可用,可以对整个系统中的用户进行管理,比如搜索用户,删除用户。(不讲)

二、方案设计

库表设计

用户登录流程

如何对用户权限进行控制?

库表设计

库名:oyy_project

表名: user(用户表)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- 用户表
create table if not exists user
(
id bigint auto_increment comment 'id' primary key,
userAccount varchar(256) not null comment '账号',
userPassword varchar(512) not null comment '密码',
userName varchar(256) null comment '用户昵称',
userAvatar varchar(1024) null comment '用户头像',
userProfile varchar(512) null comment '用户简介',
userRole varchar(256) default 'user' not null comment '用户角色:user/admin',
editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
UNIQUE KEY uk_userAccount (userAccount),
INDEX idx_userName (userName)
) comment '用户' collate = utf8mb4_unicode_ci;

给唯一值添加了唯一索引,比如账号userAccount,利用数据库天然防重复,还可以增加查询效率

给经常用于查询的字段添加索引,比如用户昵称userName,可以增加查询效率

用户登录流程

1、建立初始会话:前端与服务器建立连接后,服务器会为该客户创建一个初始的匿名Session,并将其状态保存下来。

这个Session的ID会作为唯一标识,返回给前端。

2、登录成功,更新会话信息:当前用户在前端输入正确的账号密码并提交到后端验证成功后,后端会更新该用户的Session,将该用户的登录信息(比如用户ID、用户名等)保存到与该Session关联的存储中。同时,服务器会生成一个Set-Cookie的相应头,指示前端保存该用户的Seesion ID。

3、前端保存Cookie:前端接受到后端的响应后,浏览器会自动根据Set-Cookie指令,将Session ID存储到浏览器的Cookie中,与该域名绑定。

4、带Cookie的后续请求:当前端再次向相同域名的服务器发送请求的时候,浏览器会自动在请求头中附带之前保存的Cookie,其中包含SessionID。

5、后端验证会话,服务器接收到请求后,从请求头提取SessionID,找到对应的Seesion数据

6、获取会话中存储的信息:后端通过该Session获取之前存储的用户信息(比如登录名,权限等),从而识别用户身份并执行相应的业务逻辑。

image-20250324033000319

如何对用户权限进行控制?

可以将接口分为4种权限

未登录也可以使用

登录才能使用

未登录也可以使用,但是登录用户能使用更多

仅管理员才能使用

传统的权限控制方法:在每个接口内单独编写逻辑:先获取到当前登录用户信息,然后判断用户的权限是否符合要求。

这种方法最灵活,但是会写很多重复的代码,而且其他开发者无法一眼得知接口所需要的权限。

权限校验一般会通过SpringAOP切面+自定义权限校验注解实现统一的接口拦截和权限校验,如果有特殊的权限校验,再单独在接口中编码。

如果需要更复杂灵活的权限控制,可以引入Shiro/Spring Security /Sa -Token等专门的权限管理框架

三、开始后端开发

首先执行SQL脚本创建数据库

然后开发我们的数据访问层,一般包括实体类,Mybatis的Mapper类,XML类等。比起手动编写,这里使用MyBatisX代码生成插件,可以快速得到这些文件。

image-20250324034610111

image-20250324034622707

然后我们就能看到生成的代码了,把这些代码移动到项目的对应的位置

image-20250324035853772

1、实体类修改

生成的代码也许不能完全满足我们的要求,比如数据库实体类,我们可以手动更改字段配置,制定主键策略和逻辑删除。

1、id默认是连续生成的,容易被爬虫抓取,所以我们修改策略为ASSIGN_ID雪花算法

2、数据删除的时候默认为彻底删除记录,如果数据出现误删,将难以恢复,所以采用逻辑删除–通过修改isDelete字段为1表示已经失效的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@TableName(value ="user")
@Data
public class User implements Serializable {
/**
* id(要指定主键策略)
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

// ...

/**
* 是否删除(逻辑删除)
*/
@TableLogic
private Integer isDelete;
}

2、定义枚举类

对于用户角色这样的值,数量有限个,可枚举的字段,最好定义一个枚举类,便于在项目中获取值,减少枚举值输入错误的情况。

在model.enums包下新建UserRoleEnum

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
@Getter
public enum UserRoleEnum {

USER("用户", "user"),
ADMIN("管理员", "admin");

private final String text;

private final String value;

UserRoleEnum(String text, String value) {
this.text = text;
this.value = value;
}

/**
* 根据 value 获取枚举
*
* @param value 枚举值的value
* @return 枚举值
*/
public static UserRoleEnum getEnumByValue(String value) {
if (ObjUtils.isEmpty(value)) {
return null;
}
for (UserRoleEnum anEnum : UserRoleEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}
}

如果枚举值特别多,可以Map缓存所有枚举值来加速查找,而不是遍历列表。

用户注册开始开发

1、建立数据模型

在model.dto.user下新建用于接受请求参数的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
public class UserRegisterRequest implements Serializable {

private static final long serialVersionUID = 3191241716373120793L;

/**
* 账号
*/
private String userAccount;

/**
* 密码
*/
private String userPassword;

/**
* 确认密码
*/
private String checkPassword;
}

dto表示接受对象,用于接受前端传来的值。

之后每开发一个接口都新建一个类。

为什么这样做?

1
2
3
4
5
@GetMapping("/health")
public BaseResponse<String> health(String username,String password) {
//System.out.println(1/0);
return ResultUtils.success("ok123");
}

这样不行吗?

首先是每个字段都这样写首先是麻烦,定义一个对象传递更容易,如果多个请求,有些字段你感觉可以复用,你就把一堆参数放在一个类里面,这样子前端开发的时候看到参数的时候会觉得特别多,而且也不知道哪些是有用的哪些是不必要的。

所以我们给每一个请求都定义一个请求类。

2、业务逻辑层

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
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// 1. 校验
if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userPassword.length() < 8 || checkPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
if (!userPassword.equals(checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
// 2. 检查是否重复
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
}
// 3. 加密
String encryptPassword = getEncryptPassword(userPassword);
// 4. 插入数据
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
user.setUserName("无名");
user.setUserRole(UserRoleEnum.USER.getValue());
boolean saveResult = this.save(user);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
}
return user.getId();
}

我们之前编写了ThrowUtils这里应用一下

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
package com.zhbit.service.impl;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zhbit.exception.BusinessException;
import com.zhbit.exception.ErrorCode;
import com.zhbit.exception.ThrowUtils;
import com.zhbit.model.entity.User;
import com.zhbit.model.enums.UserRoleEnum;
import com.zhbit.service.UserService;
import com.zhbit.mapper.UserMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;

/**
* @author 29258
* @description 针对表【user(用户)】的数据库操作Service实现
* @createDate 2025-03-24 03:55:23
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService{
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// 1. 校验
ThrowUtils.throwIf(StrUtil.hasBlank(userAccount, userPassword, checkPassword), ErrorCode.PARAMS_ERROR, "参数为空");
ThrowUtils.throwIf(!userPassword.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
ThrowUtils.throwIf(userAccount.length() < 4, ErrorCode.PARAMS_ERROR, "账号过短");
// 2. 检查是否重复
QueryWrapper<User> queryWrapper = new QueryWrapper<>();// 构建查询条件
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);// 查询用户数量
ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复");
// 3. 加密
String encryptPassword = getEncryptPassword(userPassword);
// 4. 插入数据
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
user.setUserName("无名");
user.setUserRole(UserRoleEnum.USER.getValue());
boolean saveResult = this.save(user);
ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
return user.getId();
}

/**
* 加密
* @param userPassword 密码
* @return 加密后密码
*/
@Override
public String getEncryptPassword(String userPassword) {
// 盐值,混淆密码
final String SALT = "oyy0v0";
return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
}
}

用户登录

1、数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
public class UserLoginRequest implements Serializable {

private static final long serialVersionUID = 3191241716373120793L;

/**
* 账号
*/
private String userAccount;

/**
* 密码
*/
private String userPassword;
}


2、服务开发

1
2
3
4
5
6
7
8
9
10
/**
* 用户登录
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param request
* @return 脱敏后的用户信息
*/
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);

为什么我这里加了个LoginUserVO类,因为这里是将数据库查到的所有信息都返回给了前端(包括密码),可能存在信息泄露的安全风险。因此,我们需要对返回结果进行脱敏处理。

实现

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
package com.zhbit.service.impl;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zhbit.exception.BusinessException;
import com.zhbit.exception.ErrorCode;
import com.zhbit.exception.ThrowUtils;
import com.zhbit.model.entity.User;
import com.zhbit.model.enums.UserRoleEnum;
import com.zhbit.model.vo.LoginUserVO;
import com.zhbit.service.UserService;
import com.zhbit.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;

import javax.servlet.http.HttpServletRequest;

/**
* @author 29258
* @description 针对表【user(用户)】的数据库操作Service实现
* @createDate 2025-03-24 03:55:23
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService{
@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// 1. 校验
ThrowUtils.throwIf(StrUtil.hasBlank(userAccount, userPassword, checkPassword), ErrorCode.PARAMS_ERROR, "参数为空");
ThrowUtils.throwIf(!userPassword.equals(checkPassword), ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
ThrowUtils.throwIf(userAccount.length() < 4, ErrorCode.PARAMS_ERROR, "账号过短");
// 2. 检查是否重复
QueryWrapper<User> queryWrapper = new QueryWrapper<>();// 构建查询条件
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);// 查询用户数量
ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复");
// 3. 加密
String encryptPassword = getEncryptPassword(userPassword);
// 4. 插入数据
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptPassword);
user.setUserName("无名");
user.setUserRole(UserRoleEnum.USER.getValue());
boolean saveResult = this.save(user);
ThrowUtils.throwIf(!saveResult, ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
return user.getId();
}

/**
* 加密
* @param userPassword 密码
* @return 加密后密码
*/
@Override
public String getEncryptPassword(String userPassword) {
// 盐值,混淆密码
final String SALT = "oyy0v0";
return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
}

/**
* 用户登录
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param request 请求
* @return 用户信息脱敏
*/
@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
// 1. 校验
if (StrUtil.hasBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
}
if (userPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
}
// 2. 加密
String encryptPassword = getEncryptPassword(userPassword);
// 查询用户是否存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
queryWrapper.eq("userPassword", encryptPassword);
User user = this.baseMapper.selectOne(queryWrapper);
// 用户不存在
if (user == null) {
log.info("user login failed, userAccount cannot match userPassword");
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
}
// 3. 记录用户的登录态
request.getSession().setAttribute("USER_LOGIN_STATE", user);
return this.getLoginUserVO(user);
}

/**
* 获取当前登录用户脱敏
* @param user 用户
* @return 用户信息脱敏
*/
@Override
public LoginUserVO getLoginUserVO(User user) {
LoginUserVO loginUserVO = new LoginUserVO();
BeanUtils.copyProperties(user, loginUserVO);
return loginUserVO;
}

}

小细节:如果在记录用户登录态的时候,程序员不小心少打一个代码,是不是就取不到这个用户登录态了

所以我们把这个key值提取为常量,便于后续获取。

可以把Session理解为一个Map,给Map设置key和value,每个不同的SessionID对应的Session存储的都是不同的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface UserConstant {

/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "user_login";

// region 权限

/**
* 默认角色
*/
String DEFAULT_ROLE = "user";

/**
* 管理员角色
*/
String ADMIN_ROLE = "admin";

// endregion
}

开发接口

1
2
3
4
5
6
7
8
9
@PostMapping("/login")
public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);
return ResultUtils.success(loginUserVO);
}

获取当前登录用户

可以从request请求对象对应的Session中直接获取到之前保存的登录用户的信息,无需其他请求参数。

1、服务开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 获取当前登录用户
* @param request 请求
* @return 用户信息
*/
@Override
public User getLoginUser(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
// 从数据库查询(追求性能的话可以注释,直接返回上述结果)
long userId = currentUser.getId();
currentUser = this.getById(userId);
if (currentUser == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return currentUser;
}

为什么这里需要再查一次数据库,这里是为了保证获取到的数据始终是最新的,先从Session中获取登录用户的id,然后再从数据库中查询最新的结果。

接口开发

1
2
3
4
5
6
7
8
/**
* 获取当前登录用户
*/
@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
User loginUser = userService.getLoginUser(request);
return ResultUtils.success(userService.getLoginUserVO(loginUser));
}

用户注销

可以从request请求对象中把Session中直接获取到之前保存的登录用户信息删除来完成注销。

1、接口

1
2
3
4
5
6
7
8
/**
* 用户注销
*
* @param request
* @return
*/
boolean userLogout(HttpServletRequest request);

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean userLogout(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
if (userObj == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录");
}
// 移除登录态
request.getSession().removeAttribute(USER_LOGIN_STATE);
return true;
}

3、接口开发

1
2
3
4
5
6
7
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);
boolean result = userService.userLogout(request);
return ResultUtils.success(result);
}

用户权限控制

1、权限校验注解

1
2
3
4
5
6
7
8
9
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {

/**
* 必须有某个角色
*/
String mustRole() default "";
}

2、权限校验切面

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
@Aspect
@Component
public class AuthInterceptor {

@Resource
private UserService userService;

/**
* 执行拦截
*
* @param joinPoint 切入点
* @param authCheck 权限校验注解
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
String mustRole = authCheck.mustRole();
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 当前登录用户
User loginUser = userService.getLoginUser(request);
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
// 不需要权限,放行
if (mustRoleEnum == null) {
return joinPoint.proceed();
}
// 以下为:必须有该权限才通过
// 获取当前用户具有的权限
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());
// 没有权限,拒绝
if (userRoleEnum == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 要求必须有管理员权限,但用户没有管理员权限,拒绝
if (UserRoleEnum.ADMIN.equals(mustRoleEnum) && !UserRoleEnum.ADMIN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 通过权限校验,放行
return joinPoint.proceed();
}
}

分三种情况:不需要登录就能使用的接口,不需要使用该注解,添加了这个注解就必须要登录,设置了mustRole为管理员,只有管理员才能使用。

四、前端开发

新建页面和路由

image-20250324165055134

修改router/index.ts的路由配置

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
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import HomePage from '@/pages/HomePage.vue'
import UserRegisterPage from '@/pages/user/UserRegisterPage.vue'
import UserLoginPage from '@/pages/user/UserLoginPage.vue'

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: '主页',
component: HomePage,
},
{
path: '/user/login',
name: '用户登录',
component: UserLoginPage,
},
{
path: '/user/register',
name: '用户注册',
component: UserRegisterPage,
},
],

})

export default router

记得执行一下openapi命令生成接口对应的请求代码,每次请求后端改动的时候都需要这么做。

获取当前登录用户

之前已经创建了前端登录用户的状态管理文件useLoginUserStore.ts。现在后端提供了获取当前登录页用户的接口,直接修改fetchLoginUser函数即可:

1
2
3
4
5
6
async function fetchLoginUser() {
const res = await getLoginUserUsingGet()
if (res.data.code === 0 && res.data.data) {
loginUser.value = res.data.data
}
}

由于之前已经生成了代码,可以看到已经帮我们生成了后端的请求对象比如LoginUserVO

1
2
3
const loginUser = ref<API.LoginUserVO>({
userName: "未登录",
});

这样我们修改一下类型。

开发用户登录页面

搜索组件库,找到表单组件,然后无脑复制。

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
import React from 'react';
import type { FormProps } from 'antd';
import { Button, Checkbox, Form, Input } from 'antd';

type FieldType = {
username?: string;
password?: string;
remember?: string;
};

const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
console.log('Success:', values);
};

const onFinishFailed: FormProps<FieldType>['onFinishFailed'] = (errorInfo) => {
console.log('Failed:', errorInfo);
};

const App: React.FC = () => (
<Form
name="basic"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }}
initialValues={{ remember: true }}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item<FieldType>
label="Username"
name="username"
rules={[{ required: true, message: 'Please input your username!' }]}
>
<Input />
</Form.Item>

<Form.Item<FieldType>
label="Password"
name="password"
rules={[{ required: true, message: 'Please input your password!' }]}
>
<Input.Password />
</Form.Item>

<Form.Item<FieldType> name="remember" valuePropName="checked" label={null}>
<Checkbox>Remember me</Checkbox>
</Form.Item>

<Form.Item label={null}>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
);

export default App;

然后进行修改

最终效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<template>
<div id="userLoginPage">
<h2 class="title">登录注册</h2>
<div class="desc">登录注册</div>
<a-form :model="formState" name="basic" autocomplete="off" @finish="handleSubmit">
<a-form-item name="userAccount" :rules="[{ required: true, message: '请输入账号' }]">
<a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
</a-form-item>
<a-form-item
name="userPassword"
:rules="[
{ required: true, message: '请输入密码' },
{ min: 8, message: '密码不能小于 8 位' },
]"
>
<a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
</a-form-item>
<div class="tips">
没有账号?
<RouterLink to="/user/register">去注册</RouterLink>
</div>
<a-form-item>
<a-button type="primary" html-type="submit" style="width: 100%">登录</a-button>
</a-form-item>
</a-form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import { userLoginUsingPost } from '@/api/userController'
import { message } from 'ant-design-vue'

//定义一个响应式变量来接受表单输入的值
const formState = reactive<API.UserLoginRequest>({
userAccount: '',
userPassword: '',
})

const router = useRouter()
const loginUserStore = useLoginUserStore()

/**
* 提交表单
* @param values
*/
const handleSubmit = async (values: any) => {
const res = await userLoginUsingPost(values)
// 登录成功,把登录态保存到全局状态中
if (res.data.code === 0 && res.data.data) {
await loginUserStore.fetchLoginUser()
message.success('登录成功')
router.push({
path: '/',
replace: true,
})
} else {
message.error('登录失败,' + res.data.message)
}
}


</script>
<style scoped>
#userLoginPage {
max-width: 360px;
margin: 0 auto;
}

.title {
text-align: center;
margin-bottom: 16px;
}

.desc {
text-align: center;
color: #bbb;
margin-bottom: 16px;
}

.tips {
margin-bottom: 16px;
color: #bbb;
font-size: 13px;
text-align: right;
}
</style>

开发用户注册页面

跟上面一样

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
<template>
<div id="userRegisterPage">
<h2 class="title">登录注册</h2>
<div class="desc">登录注册</div>
<a-form
:model="formState"
name="basic"
label-align="left"
autocomplete="off"
@finish="handleSubmit"
>
<a-form-item name="userAccount" :rules="[{ required: true, message: '请输入账号' }]">
<a-input v-model:value="formState.userAccount" placeholder="请输入账号" />
</a-form-item>
<a-form-item
name="userPassword"
:rules="[
{ required: true, message: '请输入密码' },
{ min: 8, message: '密码不能小于 8 位' },
]"
>
<a-input-password v-model:value="formState.userPassword" placeholder="请输入密码" />
</a-form-item>
<a-form-item
name="checkPassword"
:rules="[
{ required: true, message: '请输入确认密码' },
{ min: 8, message: '确认密码不能小于 8 位' },
]"
>
<a-input-password v-model:value="formState.checkPassword" placeholder="请输入确认密码" />
</a-form-item>
<div class="tips">
已有账号?
<RouterLink to="/user/login">去登录</RouterLink>
</div>
<a-form-item>
<a-button type="primary" html-type="submit" style="width: 100%">注册</a-button>
</a-form-item>
</a-form>
</div>
</template>

定义表单信息变量

1
2
3
4
5
6
const formState = reactive<API.UserRegisterRequest>({
userAccount: '',
userPassword: '',
checkPassword: '',
})

编写表单提交函数,可以增加一些前端校验,并且在注册成功后跳转到用户登录页。

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
const router = useRouter()

/**
* 提交表单
* @param values
*/
const handleSubmit = async (values: any) => {
// 判断两次输入的密码是否一致
if (formState.userPassword !== formState.checkPassword) {
message.error('二次输入的密码不一致')
return
}
const res = await userRegisterUsingPost(values)
// 注册成功,跳转到登录页面
if (res.data.code === 0 && res.data.data) {
message.success('注册成功')
router.push({
path: '/user/login',
replace: true,
})
} else {
message.error('注册失败,' + res.data.message)
}
}

用户注销

一般鼠标悬浮在右上角用户头像时,会展示包含用户注销(退出登录功能)的下拉菜单

先编写页面结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div v-if="loginUserStore.loginUser.id">
<a-dropdown>
<ASpace>
<a-avatar :src="loginUserStore.loginUser.userAvatar" />
{{ loginUserStore.loginUser.userName ?? '无名' }}
</ASpace>
<template #overlay>
<a-menu>
<a-menu-item @click="doLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>

编写用户注销事件函数,退出登录后跳转到登录页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 用户注销
const doLogout = async () => {
const res = await userLogoutUsingPost()
console.log(res)
if (res.data.code === 0) {
loginUserStore.setLoginUser({
userName: '未登录',
})
message.success('退出登录成功')
await router.push('/user/login')
} else {
message.error('退出登录失败,' + res.data.message)
}
}

最终版本的顶部栏

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
<template>
<a-row :wrap="false">
<a-col flex="200px">
<RouterLink to="/">
<div class="title-bar">
<img class="logo" src="../assets/logo.png" alt="logo" />
<div class="title">登录注册</div>
</div>
</RouterLink>
</a-col>
<a-col flex="auto">
<a-menu
v-model:selectedKeys="current"
mode="horizontal"
:items="items"
/>
</a-col>
<a-col flex="120px">
<div class="user-login-status">

<div v-if="loginUserStore.loginUser.id">
<a-dropdown>
<ASpace>
<a-avatar :src="loginUserStore.loginUser.userAvatar" />
{{ loginUserStore.loginUser.userName ?? '无名' }}
</ASpace>
<template #overlay>
<a-menu>
<a-menu-item @click="doLogout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div v-else>
<a-button type="primary" href="/user/login">登录</a-button>
</div>
</div>
</a-col>
</a-row>

</template>
<script lang="ts" setup>
import { h, ref } from 'vue'
import { HomeOutlined ,LogoutOutlined} from '@ant-design/icons-vue'
import { type MenuProps, message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useLoginUserStore } from '@/stores/useLoginUserStore'
import { userLogoutUsingPost } from '@/api/userController'


const items = ref<MenuProps['items']>([
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '主页'
},
{
key: '/about',
label: '关于',
title: '关于'
}
]);

//引入登录用户仓库
const loginUserStore = useLoginUserStore()

const router = useRouter();
//路由跳转事件
const doMenuClick = ({ key }) => {
router.push({
path: key
})
}
//当前要高亮的菜单
const current = ref<string[]>([])
//监听路由变化更新高亮菜单
router.afterEach((to,from,next) => {
current.value = [to.path]
})

// 用户注销
const doLogout = async () => {
const res = await userLogoutUsingPost()
console.log(res)
if (res.data.code === 0) {
loginUserStore.setLoginUser({
userName: '未登录',
})
message.success('退出登录成功')
await router.push('/user/login')
} else {
message.error('退出登录失败,' + res.data.message)
}
}

</script>
<style scoped>
.title-bar {
display: flex;
align-items: center;
}

.title {
color: black;
font-size: 18px;
margin-left: 16px;
}

.logo {
height: 48px;
}
</style>