SpringBoot+Vue开发流程
这节课目的是分享一下我自己的开发流程,如果需要深入学习还是得自己在b站看视频边看边学的。
这里演示一个登录注册的小demo吧
后端项目初始化
环境准备
- JDK11或者17
- MySQL数据库8.x版本
- IDEA2024版(这个不要求)
创建SpringBoot项目
1、可以设置阿里云的初始化模板:https://start.aliyun.com/

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

3、修改配置文件application.properties

修改后缀为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 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: isDelete logic-delete-value: 1 logic-not-delete-value: 0
|
官网:入门和安装
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)
|
加上之后可以获取当前类的代理对象
- 开启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
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <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;
@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;
@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 {
public static void throwIf(boolean condition, RuntimeException runtimeException) { if (condition) { throw runtimeException; } }
public static void throwIf(boolean condition, ErrorCode errorCode) { throwIf(condition, new BusinessException(errorCode)); }
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 {
public static <T> BaseResponse<T> success(T data) { return new BaseResponse<>(0, data, "ok"); }
public static BaseResponse<?> error(ErrorCode errorCode) { return new BaseResponse<>(errorCode); }
public static BaseResponse<?> error(int code, String message) { return new BaseResponse<>(code, null, message); }
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.)
|

分页请求类
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;
@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;
@Data public class DeleteRequest implements Serializable {
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.example
和 https://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;
@Configuration public class CorsConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowCredentials(true) .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .exposedHeaders("*"); } }
|
至此我们就基本初始化完成了。
前端项目初始化
Vue.js 本身并不是强制性的单页面应用框架,但它非常适用于构建单页面应用。单页面应用是指整个应用只有一个 HTML 页面,通过动态更新这个页面的内容来模拟多页面应用的效果。Vue.js 通过其核心的响应式系统和组件化开发模式,使得开发者可以轻松地创建复杂的单页面应用。
Vue 被描述为“渐进式”的原因在于它能够逐步集成到现有项目中,并且可以根据项目的需求灵活调整使用方式。
环境准备
前端 Node.js版本安装>=18.12,安装好npm前端包管理器。
可以跟着vue官网快速创建:快速上手 | Vue.js
在终端输入命令:
npm会自动安装create-vue工具:

这里类似于springboot的快速模板创建一样。
然后我们直接安装Vue Router路由,Pinia全局状态管理等使用类库。
然后用 WebStorm 打开项目,先在终端执行 npm install
安装依赖,然后执行 npm run dev
能访问网页就成功了。
为了开发效率更高,你可能想关闭由于 ESLint 校验导致的编译错误,同样可以在开发工具中禁用 ESLint:

快速开发我们引入组件库
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

开发全局通用布局
- 基础布局结构
在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
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() { }
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() { setTimeout(() => { loginUser.value = { userName: '测试用户', id: 1 } }, 3000) }
|
页面开发流程
1、新建src/pages目录,用于存放所有的页面文件
在page目录下新建页面文件,将所有页面按照url层级进行创建,并且页面名称尽量做到“见名知意”。

其中,/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获取之前存储的用户信息(比如登录名,权限等),从而识别用户身份并执行相应的业务逻辑。

如何对用户权限进行控制?
可以将接口分为4种权限
未登录也可以使用
登录才能使用
未登录也可以使用,但是登录用户能使用更多
仅管理员才能使用
传统的权限控制方法:在每个接口内单独编写逻辑:先获取到当前登录用户信息,然后判断用户的权限是否符合要求。
这种方法最灵活,但是会写很多重复的代码,而且其他开发者无法一眼得知接口所需要的权限。
权限校验一般会通过SpringAOP切面+自定义权限校验注解实现统一的接口拦截和权限校验,如果有特殊的权限校验,再单独在接口中编码。
如果需要更复杂灵活的权限控制,可以引入Shiro/Spring Security /Sa -Token等专门的权限管理框架
三、开始后端开发
首先执行SQL脚本创建数据库
然后开发我们的数据访问层,一般包括实体类,Mybatis的Mapper类,XML类等。比起手动编写,这里使用MyBatisX代码生成插件,可以快速得到这些文件。


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

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 {
@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; }
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) { 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) { 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, "两次输入的密码不一致"); } QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userAccount", userAccount); long count = this.baseMapper.selectCount(queryWrapper); if (count > 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复"); } String encryptPassword = getEncryptPassword(userPassword); 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;
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{ @Override public long userRegister(String userAccount, String userPassword, String checkPassword) { 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, "账号过短"); QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userAccount", userAccount); long count = this.baseMapper.selectCount(queryWrapper); ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复"); String encryptPassword = getEncryptPassword(userPassword); 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(); }
@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
|
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;
@Service @Slf4j public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{ @Override public long userRegister(String userAccount, String userPassword, String checkPassword) { 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, "账号过短"); QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("userAccount", userAccount); long count = this.baseMapper.selectCount(queryWrapper); ThrowUtils.throwIf(count > 0, ErrorCode.PARAMS_ERROR, "账号重复"); String encryptPassword = getEncryptPassword(userPassword); 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(); }
@Override public String getEncryptPassword(String userPassword) { final String SALT = "oyy0v0"; return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes()); }
@Override public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) { 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, "密码错误"); } 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, "用户不存在或密码错误"); } request.getSession().setAttribute("USER_LOGIN_STATE", user); return this.getLoginUserVO(user); }
@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";
String DEFAULT_ROLE = "user";
String ADMIN_ROLE = "admin"; }
|
开发接口
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
|
@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
|
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;
@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为管理员,只有管理员才能使用。
四、前端开发
新建页面和路由

修改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>
|