SpringBoot+Vue开发流程 这节课目的是分享一下我自己的开发流程,如果需要深入学习还是得自己在b站看视频边看边学的。
这里演示一个登录注册的小demo吧
后端项目初始化 环境准备 然后启动一下看看是否报错
配置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.)
这时候需要一个全局异常处理的东西,利用AOP切面对全局业务异常和RuntimeException进行捕获
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public BaseResponse<?> businessExceptionHandler(BusinessException e) { log.error("BusinessException" , e); return ResultUtils.error(e.getCode(), e.getMessage()); } @ExceptionHandler(RuntimeException.class) public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) { log.error("RuntimeException" , e); return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误" ); } }
全局跨域请求配置 为什么要设置这个?
因为浏览器有一个同源策略,拦截的屎客户端发出去的请求成功后返回的数据。也就是说即使你发送请求成功了,服务器也相应了但是浏览器拒绝接受该数据。
比如我从一个前端的网址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
前端要向后端发送很多请求
比如
1 2 3 localhost:8080/api/user/get localhost:8080/api/user/login localhost:8080/api/user/register
那么如果这时候地址改变了,是不是全部都要重新改。
2、全局自定义请求 跟后端一样,在开发后端的时候,自己封装了一些自定义响应类,还有全局异常处理,那么前端也有类似的逻辑 。
参考Axios官方文档,编写请求配置文件,request.ts,包括全局接口请求地址、超时时间、自定义请求响应拦截器等等。
响应拦截器应用场景:我们需要对接口的通用相应进行统一处理,比如从request中取出data,或者根据code取集中处理错误,这样不用在每个接口请求中去写相同的逻辑。
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 import axios from "axios" ;const myAxios = axios.create ({ baseURL : "http://localhost:8123" , timeout : 10000 , withCredentials : true , }); myAxios.interceptors .request .use ( function (config ) { return config; }, function (error ) { return Promise .reject (error); } ); myAxios.interceptors .response .use ( function (response ) { console .log (response); const { data } = response; console .log (data); if (data.code === 40100 ) { if ( !response.request .responseURL .includes ("user/current" ) && !window .location .pathname .includes ("/user/login" ) ) { window .location .href = `/user/login?redirect=${window .location.href} ` ; } } return response; }, function (error ) { return Promise .reject (error); } ); export default myAxios;
我们测试一下,在src目录下新建api/user.ts,存放所有有关用户的API接口。
1 2 3 4 5 6 7 8 import myAxios from '@/request' export const testHealth = async (params : any ) => { const res = myAxios.request ({ url : "/api/health" , method : 'get' , }) }
然后在主页面用一个按钮测试一下
1 <button @click="testHealth">测试按钮</button>
然后按f12查看是否发送请求成功。
以后但凡要请求都要自己编辑一个,比较麻烦,然后就有了自动请求代码的工具了。
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中导出的状态变量和函数
在首次进入页面时,一般我们会尝试获取登录用户信息,修改globalHeader.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、用户权限控制:用户可以分为普通用户和管理员,管理员拥有整个系统的最高权限,比如可以管理其他用户
二、方案设计 库表设计
用户登录流程
如何对用户权限进行控制?
库表设计
库名: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,可以增加查询效率
用户登录流程
在讲这个之前,先了解一下cookie和session。
为什么要用到cookie和session?
http是一个无状态,即每次关闭页面再打开,每次都要重新登录。我们之前开发的项目中每次都是需要一次登录才能使用功能。
那么如果我想要十天后或者一个星期后才需要再次登录,期间一直可以保持登录态呢?
那么每次http请求都自动携带数据给服务器的技术就是cookie了。
流程是这样的,浏览器向后端发送请求,后端会返回给浏览器一个set-cookie携带name和value,然后浏览器会保存为cookie以后每次都会携带这个数据发送http请求。
好了那么又有一个问题出现了?我要把我的账号和密码都写在cookie里面吗?是不是会不安全?
那么为了解决这个问题又有了一个东西叫session.
浏览器访问后端就是会话的开始,那么会话的结束时间是自己定义的。因此不同的网站对每个用户的会话都设定了时间以及唯一的SessionID。
好现在又有个问题?为什么有了用户名还要弄个SessionID?
我们回顾一下登录流程,第一次登录的时候发送账号和密码,然后服务器这个时候利用set-cookie把信息放到cookie里面但是放进去的不是用户名和密码了,而是放会话结束时间和SessionID这样我们的用户名和密码就不会直接记录在cookie里面了,以后的每次访问都会携带这个cookie来发送请求。
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获取之前存储的用户信息(比如登录名,权限等),从而识别用户身份并执行相应的业务逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 sequenceDiagram participant 前端 participant 后端 participant 浏览器 前端->>后端: 1. 建立连接 后端->>后端: 创建匿名Session 后端-->>前端: 2. 返回SessionID(Set-Cookie) 浏览器->>浏览器: 3. 存储SessionID到Cookie 前端->>后端: 4. 提交账号密码 后端->>后端: 5. 验证密码,更新Session 后端-->>前端: 6. 登录成功响应 前端->>后端: 7. 后续请求(带Cookie) 后端->>后端: 8. 提取SessionID,验证会话 后端->>后端: 9. 获取用户权限数据 后端-->>前端: 10. 执行业务逻辑并响应
如何对用户权限进行控制?
可以将接口分为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(); }
条件构造器 | MyBatis-Plus
我们之前编写了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 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 { @Resource private UserService userService; @PostMapping("/register") public BaseResponse<Long> userRegister (@RequestBody UserRegisterRequest userRegisterRequest) { ThrowUtils.throwIf(userRegisterRequest == null , ErrorCode.PARAMS_ERROR); String userAccount = userRegisterRequest.getUserAccount(); String userPassword = userRegisterRequest.getUserPassword(); String checkPassword = userRegisterRequest.getCheckPassword(); long result = userService.userRegister(userAccount, userPassword, checkPassword); return ResultUtils.success(result); } }
用户登录 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 @Data public class LoginUserVO implements Serializable { private Long id; private String userAccount; private String userName; private String userAvatar; private String userProfile; private String userRole; private Date createTime; private Date updateTime; private static final long serialVersionUID = 1L ; }
实现
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); } long userId = currentUser.getId(); currentUser = this .getById(userId); if (currentUser == null ) { throw new BusinessException (ErrorCode.NOT_LOGIN); } 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>