1. 简介
登录做为一个系统的门面,也是阻挡外界的一道防线,那在vuestic-ui中如何做登录功能呢。在这里就之间沿用初始版本的Login页面,作为一个演示模板,后续需要改进的读者可以在此篇文章的基础上修改。
2. 登录接口相关api 与 type编写
在上一篇获取动态数据中 我们已经定义好了与ts整合的axios,实现发送异步请求与远程服务器交互(对于ts语法像是函数定义、基本数据类型还是有不懂的读者可以跳转到上一篇的2.1再学习学习)这里就直接引入登录接口的api编写, 具体位置如下我个人习惯创建一个api文件夹,里面专门存放一些与后端交互的api方法和类型定义(初始quickstart版本是写在上面的page中的,就看个人的编写习惯吧😁只要功能实现了就没问题)
对于index.ts
中主要实现了三个基本的方法,登录登出和获取用户信息
这里没有实现注册功能,因为我实现该网站主要是做一个流量监控的系统,注册功能对于用户不多的情况下其实不太需要,管理员可以直接操作加入数据库中,要是对这快感兴趣的读者也可以自己尝试尝试注册模块的功能实现
本质就是add一个user到数据库中,不过需要注意的是添加验证码等防护措施,防止有不法分子大量注册短时间内打爆服务器!!
import { http } from '../../../utils/request'
import type { LoginData , UserInfoRes} from './types'const requestContent = '/simple/cloud/access'
/*** 登录*/
export function login(loginVo: LoginData) {return http.post<UserInfoRes>(`${requestContent}/login`, loginVo);
}/*** 获取登录用户信息*/
export function getUserInfo() {return http.post<UserInfoRes>(`${requestContent}/info`)
} /*** 退出登录*/
export function logout() {return http.post<string>(`${requestContent}/logout`)
}
假设访问的后端服务器使用URL - http:localhost:9001/simple/cloud/access/login 这里由于uri前缀是一样的都是 ‘/simple/cloud/access’ 所以把它提取出来做为一个常量简化编写。只需要在使用的地方通过变量占位符引入就好啦(注意不是 ‘’ ,刚开始也踩过这个坑在vscode中看到上面的requestContent 由灰色变成高亮则说明引用成功)
`${key}`
而对于api中引入的数据类型定义在types.ts
中,其中的返回值类型就根据后端提供的接口方法来写,像是我后端返回的类型为一个Map<String,Object> 类型的对象如下图所示,那我就根据这个map中的key和value一一对应写出如下的接口UserInfoRes
,然后使用export导出给外部使用
注意编写的过程中只用指定ts基本的类型(java的List对应的就是ts中的数组 - 使用 [ ] 进行初始化 ),而要是需要返回一个User类型的对象,那就需要重新定义一个UserInfoInterface ,或者在user对应的api处定义types.ts 再在该文件下在引入(我是更推荐这种做法👍)
/* 登录接口参数类型 */
export interface LoginData {email: string,password: string,
}/* 用户信息接口返回值类型 */
export interface UserInfoRes {routers: [],buttons: [],roles: [],name: string,token: string,
}
3. 修改Login.vue
定义好与后端交互的方法api后,我们就可以回到前面的Login.vue处修改具体登录逻辑啦,由于初始版本使用的全是静态数据,所以很多功能其实都是不用的,具体删除修改后的模板如下(只保留了一个忘记密码的选项,该功能后续再完善😭先把主要的逻辑跑通先,感兴趣的读者可以先占个坑,后续我一定会回来填坑的!)
<template><VaForm ref="form" @submit.prevent="submit"><h1 class="font-semibold text-4xl mb-4">Log in</h1><VaInputv-model="formData.email":rules="[validators.required, validators.email]"class="mb-4"label="Email"type="email"/><VaValue v-slot="isPasswordVisible" :default-value="false"><VaInputv-model="formData.password":rules="[validators.required]":type="isPasswordVisible.value ? 'text' : 'password'"class="mb-4"label="Password"@clickAppendInner.stop="isPasswordVisible.value = !isPasswordVisible.value"><template #appendInner><VaIcon:name="isPasswordVisible.value ? 'mso-visibility_off' : 'mso-visibility'"class="cursor-pointer"color="secondary"/></template></VaInput></VaValue><div class="auth-layout__options flex flex-col sm:flex-row items-start sm:items-center justify-between"><RouterLink :to="{ name: 'recover-password' }" class="mt-2 sm:mt-0 sm:ml-1 font-semibold text-primary">Forgot password?</RouterLink></div><div class="flex justify-center mt-4"><VaButton class="w-full" @click="submit"> Login</VaButton></div></VaForm>
</template>
重写绑定的submit
点击事件逻辑
const submit = () => {if (validate()) {login(formData).then((data: UserInfoRes) => {if (data) {// 在这里添加需要执行的操作const token = data.token;// 将token存储到authStore中const authStore = useAuthStore()authStore.setToken(token)authStore.setIsAuthenticated(true)window.sessionStorage.setItem('isAuthenticated', 'true')authStore.setName(data.name)authStore.setButtons(data.buttons)authStore.setRoles(data.roles)authStore.setRouters(data.routers)init({ message: "logged in success", color: 'success' });// 登陆成功后就重定向到主页面dashboardpush({ name: 'dashboard' })}}).catch(() => {init({ message: "logged in fail , please check carefully!", color: '#FF0000' });});}else{Message.error('error submit!!')return false}
}
看到这里我相信你肯定会疑惑,为什么我需要获取到数据又存储到store中,那这个store又在哪里定义的呢,作者也没讲啊😡
别急别急,请听我细细道来
4. store实现
在Vue应用程序中,当需要管理共享状态时,通常会使用Vuex库,而store就是Vuex中用于存储这些状态的地方,而我们登录后自然需要围护当前登录角色的一些关键信息(权限,姓名等等)需要的时候就直接到store中拿去,而不是反复的去数据库中查找,废话不多说下面就来定义一个store ,在初始版本中就已经定义好了store,只不过这个store里面是没东西的,如下图所示
那我们就可以在原有的基础上添加修改,下面的代码都是在index.ts中实现的,如下代码就是一个模板,对应pinia库的描述如下
Pinia是Vue的另一种状态管理方案,与Vuex类似,但设计上更简洁、更易于上手。以下是关于Pinia的一些详细说明:
- 简单易用:Pinia的目标是提供一个更简单的状态管理解决方案,它的API设计非常直观,使得开发者可以快速上手并有效地管理状态。
- 独立模块:与Vuex不同,Pinia中的每个store都是一个独立的模块,它们可以单独导入和导出,这有助于更好地组织和维护代码。
- 响应式:Pinia中的状态是响应式的,当状态发生变化时,依赖于这些状态的组件会自动更新。
- Devtools支持:Pinia具有良好的Devtools支持,可以帮助开发者更方便地跟踪和调试状态变化。
- 插件化:Pinia被设计为一个插件,可以轻松地集成到现有的Vue应用中。
- 与Vuex兼容:虽然Pinia是一个全新的状态管理库,但它也允许与Vuex共存于同一个项目中,方便开发者逐步迁移。
本次项目中store就基于Pinia实现,首先通过defineStore方法定义一个全局可供调用的store, 其中包括了一些属性像是
- id (自己设定,但是要保证全局唯一)
- state (定义的所有状态)
- getters (获取状态的方法)
- actions (有获取肯定就有设置的方法啦)
// store.ts
import { createPinia, defineStore } from 'pinia'export const useAuthStore = defineStore({id: 'auth',state: () => ({}),getters: {},actions: {},
})export default createPinia()
4.1 state
在state中定义的状态就是在一个浏览器会话内需要存储的用户信息(登录后赋值,登出或者会话结束就销毁)根据第2点中types定义的UserInfoRes 可以设计出来, 由于ts不像js一样是弱语言,ts是有类型的上一讲也提到过,所以为了能在后续的get set中拿到指定和设置其中的属性值,我们需要通过as 参数类型
的方式来指定
isAuthenticated 本意是为了阻止用户登录前就访问其他的页面(会被驳回,重定向到登录页面)后面发现存到浏览器缓存中也是可以的,这里就做个备选,看读者喜欢哪一种方式
state: () => ({token : '',isAuthenticated : false,routers : [] as RouterVo[],buttons : [] as string[],name : '',roles : [] as RoleData[],}),
这里的RoleDta和RouterVo就分别对应了角色和菜单列表,具体实现如下(编写在types.ts文件中,具体位置看下边4.4的总体代码)
/* sysUser参数类型 */
export interface RoleData {id: number,roleName: string,roleCode: string,description: string
}/* RouterVo参数类型 */
export interface RouterVo {path: string,hidden: boolean,alwaysShow: boolean,meta: MetaVo,children: RouterVo[],
}
4.2 getters
根据如下的指定格式获取存在store中的参数
getters: {getButtons: (state) => state.buttons,getToken: (state) => state.token,getIsAuthenticated: (state) => state.isAuthenticated,getRouters: (state) => state.routers,getName: (state) => state.name,getRoles: (state) => state.roles,
},
4.3 actions
actions中定义了一系列set方法,可以发现这里()内的参数都是指定类型的,如果我们在定义的时候不指定类型这就会报错!!
actions: {setRoles(roles : RoleData[]) {this.roles = roles},setButtons(buttons : string[]) {this.buttons = buttons},setRouters(routers : RouterVo[]) {this.routers = routers},setName(name : string) {this.name = name},setToken(token : string) {this.token = token},setIsAuthenticated(isAuthenticated : boolean){this.isAuthenticated = isAuthenticated},// 登出后的资源重置reset(){this.roles = []this.name = ''this.buttons = []this.routers = []this.isAuthenticated = falsethis.token = ''},
},
4.4 总体代码
// store.ts
import { createPinia, defineStore } from 'pinia'
import { RoleData } from '@/api/system/sysRole/types'
import { RouterVo } from '@/api/system/sysMenu/types'export const useAuthStore = defineStore({id: 'auth',state: () => ({token : '',isAuthenticated : false,routers : [] as RouterVo[],buttons : [] as string[],name : '',roles : [] as RoleData[],}),getters: {getButtons: (state) => state.buttons,getToken: (state) => state.token,getIsAuthenticated: (state) => state.isAuthenticated,getRouters: (state) => state.routers,getName: (state) => state.name,getRoles: (state) => state.roles,},actions: {setRoles(roles : RoleData[]) {this.roles = roles},setButtons(buttons : string[]) {this.buttons = buttons},setRouters(routers : RouterVo[]) {this.routers = routers},setName(name : string) {this.name = name},setToken(token : string) {this.token = token},setIsAuthenticated(isAuthenticated : boolean){this.isAuthenticated = isAuthenticated},reset(){this.roles = []this.name = ''this.buttons = []this.routers = []this.isAuthenticated = falsethis.token = ''},},
})
// 记得要导出,不在就白定义了 外部通过调用createPinia() 获取示例
export default createPinia()
4.5 main.ts中App引入
在Vue中引入App是因为App.vue通常作为项目的主组件和页面入口文件,负责构建定义及页面组件的归集和切换。定义的组件自然要添加到其中,在初始化的时候就一同创建。在文件原有基础上添加如下代码
import stores from './stores'
import { createPinia } from 'pinia'app.use(createPinia)
app.use(stores)
最后保存就好啦,到这里在回看第3点的submit
方法是不是就一目了然
这里提炼出使用store的核心代码,有需要的读者可以直接复制使用😁
// 导入刚刚定义的方法
import { useAuthStore } from '@/stores'// 外部调用创建一个示例(唯一的)
const authStore = useAuthStore()
// 在对应的操作方法里面使用我们在getters和actions中定义的方法
// set
authStore.setToken(token)
// get
const token = authStore.getToken
5. vue限制实现不登录无法进入其他页面
这个模块可用的方法有很多网上也是有各种各样的教程,在这里使用的是设置路由守卫的方法,在router/index.ts下修改,具体做三种判断
- 防止重复登录: 登录后的用户不能在登录了,只能主动退出或者关闭浏览器(token失效也是一个,这个后面讲)
- 白名单直接放行:对于可以供给全部用户访问的一些静态资源、页面(比如登录页面,和一些docs帮助文档是可以直接访问的)
- 没有登录:对于没有登录的用户无法访问系统的资源,为了提防有些通过导航栏修改URL的方法访问
// 设置哪些页面是属于白名单的
const witheList = ["/auth/login"];function isWitheRoute(path : string) {return witheList.includes(path);
}// 全局前置守卫
router.beforeEach((to, from, next) => {const isAuthenticated = window.sessionStorage.getItem('isAuthenticated');//防止重复登录if (isAuthenticated && (to.path === "/auth/login")) {Message.info("You have successfully logged in. Please avoid logging in repeatedly! (You can log out if you wish)");return next({ path: from.path ? from.path : "/" });}// 判断如果是白名单就直接放行if (isWitheRoute(to.path)) {next();return;}// 没有登录,强制跳转到登录页面if (!isAuthenticated && to.path != "/auth/login") {Message.info("Please logging first");next({ path: "/auth/login" });return;} next()
});
5.1. 浏览器缓存
上边埋了一个坑,可以使用浏览器缓存的方法实现该功能,上边代码也看到了window.sessionStorage. 那么这到底是嘛玩意,作用范围生命周期又是什么呢?下面将一一解答:
- sessionStorage为Web开发者提供了一种在用户的浏览器中临时存储数据的方式。这种存储方式特定于用户打开的特定窗口或标签页,并且数据只在这个特定的窗口或标签页有效。当用户关闭这个窗口或标签页时,存储在sessionStorage中的所有数据将被清除。这就意味着不同的浏览器窗口或标签页,即使是打开相同的网页,它们之间的sessionStorage数据是不共享的。
- sessionStorage的生命周期与用户打开的窗口或标签页的持续时间同步。只要窗口或标签页保持打开状态,即便是进行页面刷新或切换到同源的其他页面,sessionStorage中的数据都将持续存在。然而,一旦窗口或标签页被关闭,sessionStorage中的所有数据将立即失效并被清除。
可以见得通过该方法保存用户的登录状态也是不错之选,而且非正常退出时候也不用担心数据泄露(会自动销毁,后端的数据就需要通过勾子函数回调,或者直接设置redis过期时间就等它自动过期)下边就是三个常用的方法:
对于我们的登录功能来说,在登录成功后设置为true,此时路由守卫判断时候就能获取到该值,而在登出的时候就删除掉该数据,这样就能保证统一
//设置对应的key-value
window.sessionStorage.setItem('isAuthenticated', 'true');//通过getItem获取 (取不到时为null)
const isAuthenticated = window.sessionStorage.getItem('isAuthenticated');//去除浏览器缓存
window.sessionStorage.removeItem('isAuthenticated')
6. 登出功能实现
登出功能本质上是跟登录没什么区别的,就是后端清除存储的数据token , reids中权限数据等,前端清除login获取到的所有数据(回到出厂设置的感觉)在初始版本中是没有登出这个按钮的,经常登录网页的朋友都知道,登出的按钮一般是在右上角,那这里我们就遵循惯例先找找最上边的栏目是在哪一个vue页面里面(最笨的方法就是一个一个去搜索是否有相应的字眼)
那么我就以我的理解来告诉大家如何快速找到相应的模块。首先要知道的是所有的组件都是放在src/components
文件夹下的,那我们就去下边找,一展开就很明显看到navbar
的字眼(导航栏嘛,也就是我们要找的上边栏所在位置)点开后发现又有个components
(根据上面的知识不用我说都知道这是放组件的吧)点开就看到GitHubButton
这不就是我们要找的上边栏上的github按钮吗,说明我们找对地方了,最终就锁定范围在这两个vue文件中,是不是一下子节省很多工作量😁 , 具体示例文件所在处如下图所示
找到这个文件后我们预期的效果是跟下图这样加一个Logout 按钮 用户点击就可以退出登录
在AppNavbarActions这个文件中点开就发现其实实现起来很简单,就是依葫芦画瓢,照抄原来有的button组件就好啦,具体代码如下
<VaButtonv-if="!isMobile"preset="secondary"@click="logoutOper" <!--自定义点击事件-->target="_blank"color="textPrimary"class="app-navbar-actions__item flex-shrink-0 mx-0"
>{{ t('Logout') }}
</VaButton>
因为我们绑定了点击事件,自然要实现的啦(如下代码在script中原有的基础上添加)
import { logout } from '@/api/system/auth/index'
import { useAuthStore } from '@/stores'
import { useToast } from 'vuestic-ui'
import { useRouter } from 'vue-router'
const { push } = useRouter()
const { init } = useToast()const logoutOper = () => {logout().then(() => {const store = useAuthStore() // 获取store实例store.reset() // 重置store//去除浏览器缓存window.sessionStorage.removeItem('isAuthenticated')//跳转路由init({ message: "logout success", color: 'success' });push({ name: 'login' })}).catch(() => {init({ message: "logged out fail , please contact administration", color: '#FF0000' });});
}
7. 每次请求时带上token访问服务器
由于加入了权限认证功能,所以登录后的每一次请求都必须携带上token(这里的token就遵循OAuth2的规范以"Bearer "
开头),不然会认为没有登录跳转的登录页面重新登录,在每一次请求中添加请求头是不是就是定义一个全局filter,也就是在上一讲中提到的axios请求拦截器,那如下代码就在utils/request.ts
下修改(还没有的请看上一讲)
import { useAuthStore } from '@/stores'/* 请求拦截器 */
service.interceptors.request.use((config: InternalAxiosRequestConfig) => { const authStore = useAuthStore()if (authStore != undefined) {//获取tokenconst token = authStore.getTokenconfig.headers.Authorization = `Bearer ${token}`;} else {// 如果不存在 token,则拒绝请求并跳转到登录页面window.location.href = '/auth/login';//去除浏览器缓存window.sessionStorage.removeItem('isAuthenticated')return Promise.reject('Authenticated fail');}return config; }, (error: AxiosError) => {Message.error(error.message);return Promise.reject(error)
})
终于讲完啦,这篇内容挺多的,给看到这里的读者点赞👍,希望能够对你们有所帮助(本篇主要实现前端的功能,后续会结合权限管理给出后端认证授权功能实现,敬请期待…)
各位读者我回来填坑啦,对于上面的后端实现我又写了点自己的想法,感兴趣的读者可以点击查阅后端认证授权功能实现