基于 vuestic-ui 实战教程 - 登录篇

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>
    
    <VaInput
      v-model="formData.email"
      :rules="[validators.required, validators.email]"
      class="mb-4"
      label="Email"
      type="email"
    />
    <VaValue v-slot="isPasswordVisible" :default-value="false">
      <VaInput
        v-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' });
	        // 登陆成功后就重定向到主页面dashboard
	        push({ 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, 其中包括了一些属性像是

  1. id (自己设定,但是要保证全局唯一)
  2. state (定义的所有状态)
  3. getters (获取状态的方法)
  4. 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 = false
    this.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 = false
      this.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下修改,具体做三种判断

  1. 防止重复登录: 登录后的用户不能在登录了,只能主动退出或者关闭浏览器(token失效也是一个,这个后面讲)
  2. 白名单直接放行:对于可以供给全部用户访问的一些静态资源、页面(比如登录页面,和一些docs帮助文档是可以直接访问的)
  3. 没有登录:对于没有登录的用户无法访问系统的资源,为了提防有些通过导航栏修改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. 那么这到底是嘛玩意,作用范围生命周期又是什么呢?下面将一一解答:

  1. sessionStorage为Web开发者提供了一种在用户的浏览器中临时存储数据的方式。这种存储方式特定于用户打开的特定窗口或标签页,并且数据只在这个特定的窗口或标签页有效。当用户关闭这个窗口或标签页时,存储在sessionStorage中的所有数据将被清除。这就意味着不同的浏览器窗口或标签页,即使是打开相同的网页,它们之间的sessionStorage数据是不共享的。
  2. 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组件就好啦,具体代码如下

<VaButton
  v-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) {
      //获取token
      const token = authStore.getToken
      config.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)
})

终于讲完啦,这篇内容挺多的,给看到这里的读者点赞👍,希望能够对你们有所帮助(本篇主要实现前端的功能,后续会结合权限管理给出后端认证授权功能实现,敬请期待…)


各位读者我回来填坑啦,对于上面的后端实现我又写了点自己的想法,感兴趣的读者可以点击查阅后端认证授权功能实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值