vue3整合SpringSecurity加JWT實現(xiàn)登錄認證
前段時間寫了一篇spring security的詳細入門,但是沒有聯(lián)系實際。
所以這次在真實的項目中來演示一下怎樣使用springsecurity來實現(xiàn)我們最常用的登錄校驗。本次演示使用現(xiàn)在市面上最常見的開發(fā)方式,前后端分離開發(fā)。前端使用vue3進行構建,用到了element-plus組件庫、axios封裝、pinia狀態(tài)管理、Router路由跳轉等技術。后端還是spring boot整合springsecurity+JWT來實現(xiàn)登錄校驗。
本文適合有一定基礎的人來看,如果你對springsecurity安全框架還不是很了解,建議你先去看一下我之前寫過的spring security框架的快速入門:
springboot3整合SpringSecurity實現(xiàn)登錄校驗與權限認證(萬字超詳細講解)
技術棧版本:vue3.3.11、springboot3.1.5、spring security6.x
業(yè)務流程:
可以看到整個業(yè)務的流程還是比較簡單的,那么接下來就基于這個業(yè)務流程來進行我們具體代碼的編寫和實現(xiàn);
前端:
新建一個vue項目,并引入一些具體的依賴;我們本次項目用到的有:element-plus、axios、pinia狀態(tài)管理、Router路由跳轉(注意我們在項目中使用到的pinia要引入持久化插件)
在vue項目中新建兩個組件:Login.vue(登錄組件,負責登錄頁面的展示)、Layout.vue(布局頁面,負責整體項目的布局,登錄成功之后就是跳轉到這個頁面)
路由的定義:在router文件夾下新建index.ts文件
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'login', component: () => import('@/components/Login.vue') }, { path: '/layout', name: 'layout', component: () => import('@/components/Layout.vue') } ] }) export default router
定義Login登錄組件為默認的組件,并定義Layout組件;
useToken的狀態(tài)封裝:在stoers文件夾下新建useToken.ts
import { defineStore } from 'pinia' import { ref } from 'vue' const useTokenStore = defineStore('token', ()=>{ const token=ref() const removeToken=()=>{ token.value='' } return {token,removeToken} }, {persist: true} ) export default useTokenStore
axios的封裝:在utils文件夾在新建request.ts文件
import axios from "axios"; import useTokenStore from '@/stores/useToken' import { ElMessage } from 'element-plus'; // 先建一個api const api = axios.create({ baseURL: "http://localhost:8888", timeout: 5000 }); // 發(fā)送請求前攔截 api.interceptors.request.use( config =>{ const useToken = useTokenStore(); if(useToken.token){ console.log("請求頭toekn=====>", useToken.token); // 設置請求頭 // config.headers['token'] = useToken.token; config.headers.token = useToken.token; } return config; }, error =>{ return Promise.reject(error); } ) // 響應前攔截 api.interceptors.response.use( response =>{ console.log("響應數(shù)據(jù)", response); if(response.data.code !=200){ ElMessage.error(response.data.message); } return response; }, error =>{ return Promise.reject(error); } ) export default api;
在請求前攔截,主要是為了在請求頭中新增token。在request.ts中引入了useToken,并判斷如果token不為空,那么在請求頭中新增token。
在響應前也進行了一次攔截,如果后端返回的狀態(tài)碼不為200,那么就打印出錯誤信息;
接下來就可以在Login.vue中進行我們的登錄邏輯的具體編寫了(我直接將組件內容進行復制了,也不是什么太難的東西,主要還是element-plus的表單):
<template> <div class="background" style="font-family:kaiti" > <!-- 注冊表單 --> <el-dialog v-model="isRegister" title="用戶注冊" width="30%"> <el-form label-width="120px" v-model="registerForm"> <el-form-item label="用戶名"> <el-input type="text" v-model="registerForm.username" > <template #prefix> <el-icon><Avatar /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="密碼"> <el-input type="password" v-model="registerForm.password" > <template #prefix> <el-icon><Lock /></el-icon> </template> </el-input> </el-form-item> <el-form-item> <el-button type="primary" @click="registerAdd" >提交</el-button> <el-button @click="isRegister = false">取消</el-button> </el-form-item> </el-form> </el-dialog> <!-- 登陸框 --> <div class="login-box"> <el-form label-width="100px" :model="loginFrom" style="max-width: 460px" :rules="Loginrules" ref="ruleFormRef" > <el-form-item label="用戶名" prop="username"> <el-input v-model="loginFrom.username" clearable > <template #prefix> <el-icon><Avatar /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="密碼" prop="password"> <el-input v-model="loginFrom.password" show-password clearable type="password" > <template #prefix> <el-icon><Lock /></el-icon> </template> </el-input> </el-form-item> <el-form-item label="驗證碼" prop="codeValue"> <el-input v-model="loginFrom.codeValue" style="width: 100px;" clearable > </el-input> <img :src="codeImage" @click="getCode" style="transform: scale(0.9);"/> </el-form-item> <el-button type="success" @click="getLogin(ruleFormRef)" style="transform: translateX(50px)" class="my-button">登錄</el-button> <el-button type="primary" @click="isRegister=true" class="my-button">注冊</el-button> </el-form> </div> </div> </template> <script lang="ts" setup> import { ref,onMounted,reactive } from 'vue'; import { useRouter } from 'vue-router'; import { ElMessage } from 'element-plus'; import useTokenStore from '@/stores/useToken' import api from '@/utils/request' import type { FormInstance, FormRules } from 'element-plus' const ruleFormRef = ref<FormInstance>() const loginFrom=ref({ username:'', password:'', codeKey:'', codeValue:'' }) const Loginrules=reactive({ username: [ { required: true, message: '請輸入用戶名', trigger: 'blur' } ], password: [ { required: true, message: '請輸入密碼', trigger: 'blur' }, { min: 6, max: 12, message: '長度在 6 到 12 個字符', trigger: 'blur'} ], codeValue: [ { required: true, message: '請輸入驗證碼', trigger: 'blur' } ] }) const registerForm=ref({ username:'', password:'' }) const codeImage=ref('') const isRegister=ref(false) const tokenStore = useTokenStore(); const router = useRouter() const getLogin = async(formEl: FormInstance | undefined) => { if (!formEl) return await formEl.validate((valid, fields) => { if (valid) { console.log('submit!') } else { ElMessage('請輸入完整信息') return; } }) let {data}=await api.post('/user/login',loginFrom.value) if(data.code==200){ ElMessage('登錄成功') console.log(data); tokenStore.token=data.data router.replace({name:'layout'}) }else{ ElMessage('登錄失敗') } } const getCode=async()=>{ let {data}=await api.get('/getCaptcha') loginFrom.value.codeKey=data.data.codeKey codeImage.value=data.data.codeValue } const registerAdd=async()=>{ let {data}=await api.post('/user/register',registerForm.value) if(data.code==200){ ElMessage('注冊成功') isRegister.value=false }else{ ElMessage('注冊失敗') isRegister.value=false } } // 頁面加載完成獲取驗證碼 onMounted(()=>{ getCode() }) </script>
這個頁面中,我還加入了一個圖形驗證碼。還有一個注冊的表單。其他的就和普通的登錄一樣了;
這個頁面的最終效果如圖:
Layout.vue頁面中,我們只進行兩個方法的測試;一個是獲取當前用戶的具體信息,一個是退出登錄的按鈕;
<template> <div class="common-layout"> <el-container> <el-header height="100px"> 頭部 <el-button type="primary" @click="getUserInfo">獲取用戶信息</el-button> <el-button type="success" @click="Logout">退出登錄</el-button> </el-header> <el-container> <el-aside width="200px"> 菜單欄 </el-aside> <el-main> 展示區(qū) </el-main> </el-container> </el-container> </div> </template> <script lang="ts" setup name="Layout"> import { ref } from 'vue' import api from '@/utils/request' import {ElMessage} from 'element-plus' import { useRouter } from 'vue-router' import useToeknStore from '@/stores/useToken' const router = useRouter() const Logout =async () => { let data= api.get("/user/logout") if(data.data.code==200){ ElMessage.success('退出成功') // 清除token useToeknStore().removeToken router.replace({name:'login'}) } else{ ElMessage.error('退出失敗') } } const getUserInfo = async() => { let data=await api.get("/user/info") console.log('@',data); } </script>
數(shù)據(jù)庫:
我新建一個數(shù)據(jù)表,用于登錄校驗:
CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, status INT DEFAULT 0 );
這張表中只有簡單的用戶名,密碼,和用戶是否過期等字段;
后端:
新建一個spring boot項目,并導入以下的依賴:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.3.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.18</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>2.0.21</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
后端使用MybatisPlus做用戶的增、刪、改、查等?;A的controller、service、mapper,我就不再這里進行贅述了;
新建一個類MyTUserDetail ,繼承UserDetail:
@Data public class MyTUserDetail implements Serializable, UserDetails { private static final long serialVersionUID = 1L; private Users Users; @JsonIgnore //json忽略 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @JsonIgnore @Override public String getPassword() { return this.getUsers().getPassword(); } @JsonIgnore @Override public String getUsername() { return this.getUsers().getUsername(); } @JsonIgnore @Override public boolean isAccountNonExpired() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isAccountNonLocked() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return this.getUsers().getStatus()==0; } @JsonIgnore @Override public boolean isEnabled() { return this.getUsers().getStatus()==0; } }
新建一個類MyUserDetailServerImpl,實現(xiàn)MyUserDetailServer接口的loadUserByUsername方法
@Service public class MyUserDetailServerImpl implements MyUserDetailServer { @Autowired UserMapper userService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userService.selectOne(new LambdaQueryWrapper<User>(). eq(username != null, User::getUsername, username)); if (tUser == null) { throw new UsernameNotFoundException("用戶名不存在"); } MyTUserDetail myTUserDetail=new MyTUserDetail(); myTUserDetail.setUser(user); return myTUserDetail; } }
新建一個JwtUtils的工具類,來生成token;
@Component public class JwtUtil { private final String secret="zhangqiao"; private final Long expiration=36000000L; public String generateToken(Integer id) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); Algorithm algorithm = Algorithm.HMAC256(secret); return JWT.create() .withSubject(String.valueOf(id)) .withIssuedAt(now) .withExpiresAt(expiryDate) .sign(algorithm); } public Integer getUsernameFromToken(String token) { try { DecodedJWT jwt = JWT.decode(token); return Integer.valueOf(jwt.getSubject()); } catch (JWTDecodeException e) { return null; } } /* * 判斷token是否過期 * */ public boolean isTokenValid(String token) { try { Algorithm algorithm = Algorithm.HMAC256(secret); JWT.require(algorithm).build().verify(token); return true; } catch (Exception e) { return false; } } /* * 刷新token * */ public String refreshToken(String token) { try { DecodedJWT jwt = JWT.decode(token); String username = jwt.getSubject(); Algorithm algorithm = Algorithm.HMAC256(secret); Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return JWT.create() .withSubject(username) .withIssuedAt(now) .withExpiresAt(expiryDate) .sign(algorithm); } catch (JWTDecodeException e) { return null; } } }
新建一個Jwt的攔截類,繼承一個OncePerRequestFilter類,用來在每次請求前攔截請求,并從中獲取token,并判斷這個token是否是我們用戶表中的token;
如果是,那么將用戶信息存儲到security中,這樣后面的過濾器就可以獲取到用戶信息了,如果不是,那么直接放行。我們會將這個攔截器加入到UsernamePasswordAuthenticationFilter過濾器之前。
@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //獲取請求頭中的token String token = request.getHeader("token"); System.out.println("前端的token信息=======>"+token); //如果token為空直接放行,由于用戶信息沒有存放在SecurityContextHolder.getContext()中所以后面的過濾器依舊認證失敗符合要求 if(!StringUtils.hasText(token)){ filterChain.doFilter(request,response); return; } // 解析Jwt中的用戶id Integer userId = jwtUtil.getUsernameFromToken(token); //從redis中獲取用戶信息 String redisUser = redisTemplate.opsForValue().get(String.valueOf(userId)); if(!StringUtils.hasText(redisUser)){ filterChain.doFilter(request,response); return; } MyTUserDetail myTUserDetail= JSON.parseObject(redisUser, MyTUserDetail.class); //將用戶信息存放在SecurityContextHolder.getContext(),后面的過濾器就可以獲得用戶信息了。這表明當前這個用戶是登錄過的,后續(xù)的攔截器就不用再攔截了 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken=new UsernamePasswordAuthenticationToken(myTUserDetail,null,null); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); filterChain.doFilter(request,response); } }
security配置類的設置:
(由于我們本次采用前后端分離的方式來進行開發(fā),所以不在需要使用spring security默認提供的formLogin
方法)
formLogin
方法是 Spring Security 中用于配置基于表單的登錄認證的一種方式。它通常用于傳統(tǒng)的 Web 應用程序,其中前端頁面由后端動態(tài)生成,并且用戶在頁面中輸入用戶名和密碼來進行登錄。在這種情況下,Spring Security 負責處理登錄請求、驗證用戶身份、生成會話等操作。
但是,在前后端分離的開發(fā)模式中,前端和后端是完全分離的,前端負責渲染界面和處理用戶交互,后端負責提供 API 接口和數(shù)據(jù)服務。因此,通常不會使用 formLogin
方法,因為我們的前端不會通過后端渲染的頁面來進行登錄。后端只需要返回一些相應的數(shù)據(jù)和狀態(tài),有關頁面的跳轉和渲染是由前端(vue3)來實現(xiàn)的。
@Configuration @EnableWebSecurity public class MyServiceConfig { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; /* * security的過濾器鏈 * */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http)throws Exception { http.csrf(AbstractHttpConfigurer::disable); http.authorizeHttpRequests((auth) -> auth .requestMatchers("/getCaptcha","user/login","user/register").permitAll() .anyRequest().authenticated() ); http.cors(cors->{ cors.configurationSource(corsConfigurationSource()); }); //自定義過濾器放在UsernamePasswordAuthenticationFilter過濾器之前 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Autowired private MyUserDetailServerImpl myUserDetailsService; /* * 驗證管理器 * */ @Bean public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder){ DaoAuthenticationProvider provider=new DaoAuthenticationProvider(); //將編寫的UserDetailsService注入進來 provider.setUserDetailsService(myUserDetailsService); //將使用的密碼編譯器加入進來 provider.setPasswordEncoder(passwordEncoder); //將provider放置到AuthenticationManager 中 ProviderManager providerManager=new ProviderManager(provider); return providerManager; } //跨域配置 @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList("*")); configuration.setAllowedMethods(Arrays.asList("*")); configuration.setAllowedHeaders(Arrays.asList("*")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } /* * 密碼加密器*/ @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } }
在security的配置類中,設置了跨域問題、攔截器鏈的配置(并將一些需要放行的接口放行,將我們自定義的Jwt攔截器加入了security攔截鏈)、密碼編譯器、AuthenticationManager 驗證管理等等一系列配置;
Usercontroller控制器:
@RestController @RequestMapping("/user") public class UsersController { @Autowired private IUsersService userService; @Autowired private PasswordEncoder passwordEncoder; @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtils jwtUtils; @PostMapping("/login") public Result<String> login(@RequestBody DtoLogin dtoLogin) { System.out.println(dtoLogin); String token = userService.login(dtoLogin); return Result.successData(token); } @PostMapping("/register") public Result register(@RequestBody DtoLogin dtoLogin) { System.out.println(dtoLogin); Users users = new Users(); users.setUsername(dtoLogin.getUsername()); users.setPassword(passwordEncoder.encode(dtoLogin.getPassword())); userService.save(users); return Result.success(); } @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired private JwtUtil jwtUtil; @GetMapping("/info") public Result info(@RequestHeader("token")String token){ System.out.println("controller層獲取到的token=======>"+token); Integer id = jwtUtil.getUsernameFromToken(token); String redisUser = redisTemplate.opsForValue().get(String.valueOf(id)); MyTUserDetail myTUserDetail = JSON.parseObject(redisUser, MyTUserDetail.class); return Result.successData(myTUserDetail); } @GetMapping("user/logout") public Result logout(@RequestHeader("token")String token){ // 解析Jwt中的用戶id Integer userId = jwtUtil.getUsernameFromToken(token); //清除SpringSecurity上下文 SecurityContextHolder.clearContext(); //刪除redis中存儲的用戶數(shù)據(jù) redisTemplate.delete(Integer.toString(userId)); return Result.success(); } }
在UserController控制器中,由于登錄方法比較復雜,我將登錄方法重新在service中重寫了,剩下的獲取用戶信息、用戶注冊、退出登錄都直接在UseController中實現(xiàn)了;
service中重寫的登錄方法:
@Service public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService { @Autowired private RedisTemplate<String,String> redisTemplate; @Autowired AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @Override public String login(DtoLogin dtoLogin) { String codeRedis = redisTemplate.opsForValue().get(dtoLogin.getCodeKey()); if (!dtoLogin.getCodeValue().equals(codeRedis)){ throw new ResultException(400,"驗證碼錯誤"); } // 驗證碼正確,刪除redis中的驗證碼 redisTemplate.delete(dtoLogin.getCodeKey()); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(dtoLogin.getUsername(),dtoLogin.getPassword()); Authentication authenticate = authenticationManager.authenticate(authenticationToken); if(authenticate==null){ throw new ResultException(400,"用戶名或密碼錯誤"); } // 獲取返回的用戶信息 Object principal = authenticate.getPrincipal(); MyTUserDetail myTUserDetail=(MyTUserDetail) principal; System.out.println(myTUserDetail); // 使用Jwt生成token,并將用戶的id傳入 String token = jwtUtil.generateToken(myTUserDetail.getUsers().getId()); redisTemplate.opsForValue(). set(String.valueOf(myTUserDetail.getUsers().getId()), JSON.toJSONString(myTUserDetail),1, TimeUnit.DAYS); return token; } }
由于我們還是用了驗證碼,所以在這個登錄方法中先判斷了驗證碼、如果驗證碼正確。那么在判斷傳回來的用戶名和密碼。如果都正確,那么用Jwt返回一個token,token中攜帶的是用戶的id;
至此,我們所有的前后端代碼都已經(jīng)寫完了。那么,讓我們具體的實驗一下;
運行:
由于我剛創(chuàng)建的表,還沒有添加數(shù)據(jù),那么我現(xiàn)在前端點擊注冊,寫入幾條用戶信息;
寫入信息之后,我使用剛注冊過的用戶來登錄一下:
注冊成功之后,就會進入到我們自定義個Layout.vue組件內:
現(xiàn)在,我點擊“獲取用戶信息”按鈕,因為這個路徑我們并沒有放行,那么他訪問時就會被我們自定義的Jwt攔截器攔截,并驗證它請求頭中攜帶的token是否正確。如果正確,則放行。如果不正確,那么就會放行到登錄攔截器中。
可以看到,在控制臺中打印出了用戶的信息。這是肯定的,因為它這次請求攜帶的token是正確的,那么如果我們修改一下token的值,他還能正常訪問到用戶信息這個接口嗎?
我修改了請求頭中的token信息,可以看到立馬這個請求就被攔截了。并爆出了403錯誤;
現(xiàn)在,我點擊“退出登錄”按鈕,它應該刪除useToken中的token值,并且后端也會刪除redis中的值,并且跳轉到登錄頁面。后端也會刪除redsi中存儲的用戶數(shù)據(jù);
現(xiàn)在,我們所有的任務都已經(jīng)完成了。
具體的前后端源碼放在碼云上面了,有需要的可以自行下載:
我再整體理一下具體的思路:
前端發(fā)送請求后端,如果是登錄請求,那么直接走登錄接口即可,我將登錄接口進行了方行,任何人都可以訪問到登錄接口,并且執(zhí)行登錄接口的邏輯;如果登錄成功,會返回一個token,前后會將這個token存到useToken中,并且再以后的每次請求中都攜帶token;如果登錄失敗,返回一個報錯信息即可。
如果前端發(fā)送的不是登錄接口,但是前端攜帶可正確的token,那么會被我們自定義的Jwt攔截器攔截,并從中讀取用戶信息,放到security中共后續(xù)的攔截器使用;如果沒有攜帶token,或者token不正確,那么后端會直接返回403的狀態(tài)碼提示;
后續(xù):權限校驗
前后端分離,使用vue3整合SpringSecurity加JWT實現(xiàn)權限校驗-CSDN博客
到此這篇關于vue3整合SpringSecurity加JWT實現(xiàn)登錄認證的文章就介紹到這了,更多相關vue3 SpringSecurity登錄認證內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
vue中的eventBus會不會產(chǎn)生內存泄漏你知道嗎
這篇文章主要為大家詳細介紹了vue中的eventBus會不會產(chǎn)生內存泄漏,文中示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來幫助2022-02-02vue3中defineEmits與defineProps的用法實例
這篇文章主要介紹了vue3中defineEmits/defineProps的用法實例,需要的朋友可以參考下2023-12-12Vue+LogicFlow+Flowable實現(xiàn)工作流
本文主要介紹了Vue+LogicFlow+Flowable實現(xiàn)工作流,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-12-12