阅读视图

发现新文章,点击刷新页面。

02 登录功能实现

1. 登录界面基础校验

1.1 基础数据的双向绑定

登录界面的element-UI组件由三层组成,最外层是el-form,中间层是el-form-item,最内层是el-form表单元素组件。

三个组件各施其职共同完成表单校验功能,具体的实现方式:在el-input组件上使用v-model指令实现双向绑定,比如:v-model="form.username"

1.2 表单校验配置

  1. 表单对象的字段命名需与接口字段保持一致,便于后续表单提交。比如,这里的username和password。

  2. 规则对象是采用对象嵌套数组的形式,比如:

rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ]
      }

校验规则:required设置必填项;message校验失败提示信息;trigger触发校验的事件,常用blur失焦事件。

  1. 组件绑定 el-form绑定:需要同时绑定:model="form":rules="rules"两个属性。el-form-item配置:通过prop属性指定对应的校验规则。

1.3 总结

表单校验是通过el-formel-form-itemel-input组件联合实现表单检验。其中,el-form绑定表单对象和规则对象,el-form-item指定校验规则,el-input双向绑定数据。

完整代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  }
}

</script>

2. 登录界面统一校验

实际上,登录时表单的校验分为两部分,第一部分是用户输入框失焦时触发的校验,另一根时点击登录按钮时的统一校验。

因为用户如果没进行任何输入,直接点击登录按钮,第一部分的校验将没法触发,因此,需要第二部分的统一校验来兜底。

实现方式: 通过给el-form组件添加ref="form"属性,然后使用this.$refs.form获取表单实例对象。

每个表单项需要通过prop属性指定对应的校验规则,如prop="username"会关联rules对象中定义的username的校验规则。

核心方法:调用表单实例的validate方法会对所有带prop属性的表单项进行统一校验。

回调参数:validate方法接收回调函数,参数valid为布尔值,当所有校验通过时为true,否则为false。

逻辑处理:通常在回调函数中判断valid值,为true时才执行后续登录逻辑。

事件绑定:给登录按钮添加@click="loginHandler"事件,在methods中定义loginHandler方法。

方法实现:在loginHandler中调用this.$refs.form.validate方法进行统一校验。

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>

        <el-form-item prop="remember">
          <el-checkbox>记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          console.log(111111)
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

  1. 测试场景1:不输入任何内容直接点击登录,valid值为false,阻止登录流程。
  2. 测试场景2:输入符合规则的账号密码后点击登录,valid值为true,允许继续登录操作。
  3. 调试技巧:可以在回调函数中添加console.log(valid)来验证校验结果是否符合预期。

3. 使用vuex管理用户的token

登录的目的是获取token用于多组件共享,token是字符串类型。

e8ec8a07-8bf2-47d3-b750-043fc0c39ae5.png 三大模块:

  1. state:存储token数据状态
  2. mutation: 同步修改token的唯一途径
  3. action: 包含接口调用和提交mutation

组件只需触发action,完成接口调用后提交mutation更新state。 store/modules/user.js中代码:

import { loginAPI } from '@/api/user'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    token: ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    setToken(state, newToken) {
      state.token = newToken
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

代码解读:

首先,token初始化为'',与后端返回的类型一致。namespaced:true确保模块的独立性。

mutations为规范写法,第一个参数为state对象,第二个参数newToken为荷载(payload),通过state.token=newToken直接赋值。

异步请求接口为规范写法,第一个参数ctx为上下文对象,第二个参数使用解构赋值明确参数要求。接口分为两步:第一步调用loginAPI接口获取token;第二步通过ctx.commit提交mutation并更新state。

api/user.js中代码:

import request from '@/utils/request'

// 登录函数
/**
 * @description: 登录函数
 * @param {*} data { username,password}
 * @return {*} promise
 */

// 函数:参数 + 逻辑 + 返回值
export function loginAPI(data) {
  return request({
    url: '/park/login', // baseURL + url
    method: 'POST', // GET/POST/PUT/DELETE
    data // 请求体参数
  })
  // 返回的是一个promise
}

这里面定义了loginAPI接口,接口参数:

url: '/park/login'
method: 'POST'
参数对象包含username和password字段
返回值:Promise对象,data中包含token字段

views/Login/index.vue组件中执行代码this.$store.dispatch('user/asyncLogin', this.form)即可触发调用。

4. 登录后跳转到首页

Token存储机制:登录接口调用成功后,将获取的token数据存储在Vuex状态管理中

路由跳转时机:登录成功后需要进行首页跳转,但要注意异步操作的顺序问题

职责分离原则:

  • Vuex只负责处理用户数据相关逻辑(如token存储)
  • 业务代码(路由跳转、提示消息)应放在业务组件中实现
  • 避免将业务逻辑混入状态管理模块

核心代码:

<script>

export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

5. token持久化操作

Token的有效期会持续一定时间,在这段时间内没有必要重复请求token,但是vuex本身是基于内存的管理方式,刷新浏览器Token会丢失,为了避免丢失需要配置持久化进行缓存。

解决思路:1. 存储Token数据时,一份存入vuex,一份存入cookie;2.vuex中初始化Token时,优先从本地cookie获取,本地获取不到再初始化为空字符串。

  1. utils/auth.js中封装cookie操作的方法
// 专门用来操作cookie的方法包
// 内部封装了繁琐的操作方法 参数处理 暴露三个函数 get,set,remove
import Cookies from 'js-cookie'
import { TOKEN_KEY } from '@/constants/KEY'
// 获取token的方法
export function getToken() {
  return Cookies.get(TOKEN_KEY)
}

// 设置方法
export function setToken(token) {
  return Cookies.set(TOKEN_KEY, token)
}

// 删除方法
export function removeToken() {
  return Cookies.remove(TOKEN_KEY)
}
  1. store/modules/user.js中导入操作cookie的方法并新增存入cookie的操作,并在token初始化时优先从cookie获取
import { loginAPI } from '@/api/user'
import { setToken, getToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

6. token存取方式对比

  1. 为什么要使用vuex+cookies存储的方式

内存存储优势:Vuex基于内存管理,存取速度特别快(毫秒级),且封装了便捷的方法调用方式,适合高频操作场景

持久化需求:Cookies/localStorage基于磁盘存储,虽然存取速度稍慢(约慢一个量级),但具有刷新不丢失的特性

组合方案价值:同时利用Vuex的速度优势(运行时状态管理)和Cookies的持久化特性(长期存储),典型场景如用户登录态保持

  1. cookie vs localStorage

存储容量:

localStorage约5MB(不同浏览器有差异);
cookie仅几KB(个位数级别),大容量数据存储首选localStorage

操作权限:

localStorage纯前端操作;
cookie前后端均可操作(实际开发中后端操作为主),通过Set-Cookie头实现

请求携带:

cookie自动跟随接口发送(无需手动设置)
localStorage需手动添加到请求头(如Authorization头)

7. 添加token到请求头

前端请求接口后,后端需要对接口做鉴权,只有token有效,接口才能正常响应,返回正常的数据,token就是后端接口判断的标识。项目中,前端页面会请求非常多的接口,axios请求拦截器可以统一控制,一次添加,多个接口都生效。

utils/request.js中写入如下代码:

import axios from 'axios'
import { getToken } from './auth'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 添加token
    const token = getToken()
    if (token) {
      // 前面是固定写法,后面token的拼接模式是由后端来决定。
      config.headers.Authorization = token
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    return Promise.reject(error)
  }
)

export default service

8. 记住我功能的实现

交互表现

1.选中状态:当用户选中"记住我"复选框并登录后,再次返回登录页时,系统会自动回填之前输入的用户名和密码
2.未选中状态:当用户未选中复选框时登录,再次返回登录页会清除本地存储的账号密码数据

实现逻辑:

  1. 登录时处理: 当remember为true时,将账号密码存入localStorage;当remember为false时,清除localStorage中的账号密码
  2. 初始化处理:组件初始化时从localStorage取值并回填到表单

views/Login/index.vue中代码:

<template>
  <div class="login_body">
    <div class="bg" />
    <div class="box">
      <div class="title">智慧园区-登录</div>
      <!-- 基础校验:
        el-form :model="表单对象" :rules="规则对象"
        el-form-item prop属性指定一下要使用哪条规则
        el-input v-model双向绑定
        统一校验:
        获取表单的实例对象
        调用validate方法
      -->
      <el-form ref="form" :model="form" :rules="rules">
        <el-form-item
          label="账号"
          prop="username"
        >
          <el-input v-model="form.username"/>
        </el-form-item>

        <el-form-item
          label="密码"
          prop="password"
        >
          <el-input v-model="form.password"/>
        </el-form-item>
        <!-- 1.完成选择框的双向绑定,得到一个true或者false的选中状态 -->
        <!-- 2. 如果当前为true,点击登陆时,表示要己住,把当前的用户名和密码存入本地 -->
        <!-- 3. 组件初始化的时候,从本地取账号和密码,把账号密码存入用来双向绑定的form身上。 -->
         <!-- 4. 如果当前用户没有记住,状态为false,点击登录的时候要把之前的数据清空 -->
        <el-form-item prop="remember">
          <el-checkbox v-model="remember">记住我</el-checkbox>
        </el-form-item>

        <el-form-item>
          <el-button type="primary" class="login_btn" @click="loginHandler">登录</el-button>
        </el-form-item>
      </el-form>
    </div>
  </div>
</template>

<script>
const REMEMBER_KEY = 'remember_key'
export default {
  name: 'Login',
  data() {
    return {
      // 表单对象
      form: {
        username: '',
        password: ''
      },
      remember: false,
      // 规则对象
      rules: {
        username: [
          {
            required: true,
            message: '请输入账号',
            trigger: 'blur'
          }
        ],
        password: [
          {
            required: true,
            message: '请输入密码',
            trigger: 'blur'
          }
        ]
      }
    }
  },
  created() {
    // 去本地取一下之前存入的账号和密码,如果取到了就进行赋值操作
    const formStr = localStorage.getItem(REMEMBER_KEY)
    if (formStr) {
      const formObj = JSON.parse(formStr)
      this.form = formObj
    }
  },
  methods: {
    loginHandler() {
      this.$refs.form.validate(async valid => {
        // 所有的表单项都通过校验,valid变量才为true,否则就是false
        console.log(valid)
        if (valid) {
          // 添加记住我逻辑
          if (this.remember) {
            localStorage.setItem(REMEMBER_KEY, JSON.stringify(this.form))
          } else {
            localStorage.removeItem(REMEMBER_KEY)
          }
          // 确保token返回之后再跳转到首页,防止首页有一些需要依赖token的逻辑
          await this.$store.dispatch('user/asyncLogin', this.form)
          // 跳转到首页
          this.$router.push('/')
          // 提示用户登录成功
          this.$message({
            type: 'success',
            message: '登录成功'
          })
        }
      })
    }
  }
}

</script>

<style scoped lang="scss">
  .login_body {
    display: flex;
  }
  .bg {
    width: 60vw;
    height: 100vh;
    background: url('~@/assets/login-bg.svg') no-repeat;
    background-position: right top;
    background-size: cover;
  }
  .box {
    margin: 200px 10% 0;
    flex: 1;
    .title {
      padding-bottom: 76px;
      font-size: 26px;
      font-weight: 500;
      color: #1e2023;
    }
    ::v-deep() {
      .ant-form-item {
        display: flex;
        margin-bottom: 62px;
        flex-direction: column;
      }
      .ant-form-item-label label {
        font-size: 16px;
        color: #8b929d;
      }
      .ant-input,
      .ant-input-password {
        border-radius: 8px;
      }
    }
  }
  .login_btn{
    width: 100%;
  }
</style>

代码关键点分析:

1)选择框的双向绑定
绑定方式:使用v-model绑定remember变量
数据位置:remember作为独立变量而非表单对象属性,因为它不需要提交给后端
默认值:初始设置为false表示未选中状态
2)记住我逻辑 
存储机制:
使用localStorage存储账号密码
需要将表单对象转为JSON字符串存储
条件判断:仅在remember为true时执行存储操作,remember为false时,清除操作
常量定义:使用REMEMBER_KEY常量避免硬编码字符串重复
3)组件初始化回填
生命周期:在created或mounted钩子中执行回填逻辑
取值流程:从localStorage获取存储的字符串
使用JSON.parse转为对象
赋值给表单对象完成回填
健壮性处理:添加非空判断避免未存储数据时的错误
4)代码优化
常量提取:将'remember_key'提取为常量REMEMBER_KEY

9. 退出登录实现

退出登录功能位于导航栏右上角的用户下拉菜单中,对应组件文件为src/layout/components/Navbar.vue,方法绑定:已预置logout方法绑定在退出登录按钮的点击事件上。

<template>
  <div class="navbar">
    <div class="right-menu">
      <el-dropdown class="avatar-container" trigger="click">
        <div class="avatar-wrapper">
          <!-- 用户名称 -->
          <span class="name">黑马管理员</span>
        </div>
        <el-dropdown-menu slot="dropdown" class="user-dropdown">
          <router-link to="/">
            <el-dropdown-item> 首页 </el-dropdown-item>
          </router-link>
          <a target="_blank">
            <el-dropdown-item> 项目地址 </el-dropdown-item>
          </a>
          <!-- 实现思路:1.询问用户是否真的要退出登录;2. 用户同意之后,清空当前的用户数据并跳转到登录页 -->
          <el-dropdown-item divided @click.native="logout">
            <span style="display: block">退出登录</span>
          </el-dropdown-item>
        </el-dropdown-menu>
      </el-dropdown>
    </div>
  </div>
</template>

<script>
export default {
  methods: {
    // 退出登录
    logout() {
      // 1. 询问用户
      this.$confirm('确认要退出登录吗,是否继续?', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        // 确认回调
        // 1. 清空数据
        this.$store.commit('user/clearUserInfo')
        // 2. 跳转到登录界面
        this.$router.push(`/login?redirect=${this.$route.fullPath}`)
      }).catch(() => {
        // 取消或者.then中有错误
      })
    }
  }
}
</script>

使用Element UI的$confirm方法实现二次确认,文案修改为"确认要退出登录吗,是否继续?"。

确认回调:.then()中执行数据清除和跳转逻辑
取消回调:.catch()中显示"已取消删除"提示信息
  • 数据清除

Vuex清除:通过state.token = ''清空状态管理中的token

Cookie清除:调用removeToken()方法清除本地存储的token

mutation编写:在user模块中添加clearUserInfo方法,同时处理Vuex和Cookie的清理

  • 跳转登录页

路由跳转:使用this.$router.push('/login')实现页面跳转

完整调用:通过this.$store.commit('user/clearUserInfo')提交mutation

store/modules/user.js中具体代码如下:

import { loginAPI } from '@/api/user'
import { setToken, getToken, removeToken } from '@/utils/auth'

export default {
  namespaced: true,
  // 数据状态 响应式
  state: {
    // 2. vuex中初始化Token时,优先从本地cookie取,取不到再初始化为空字符串。
    token: getToken() || ''
  },
  // 同步修改 Vuex架构中,有且只有一种提交mutation
  mutations: {
    // 1. 存Token数据时,一份存入vuex,一份存入cookie
    setToken(state, newToken) {
      // 存入vuex
      state.token = newToken
      // 存入cookie
      setToken(newToken)
    },
    clearUserInfo(state) {
      // 清除vuex中的
      state.token = ''
      // 清除本地cookie中的
      removeToken()
    }
  },
  // 异步,接口请求 + 提交mutation
  actions: {
    async asyncLogin(ctx, { username, password }) {
      // 1. 调用登录接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

10. Token控制路由跳转

如果用户没有登录,即没有token,则不让用户进入某些页面。因此,需要通过token的有无来控制路由的跳转。

54168ac7-5dfa-4156-9fe3-c434ecc83f69.png

  1. 新建src/permission.js文件,并在main.js中通过import './permission'方式引入。
permission.js文件代码

// 所有和权限控制相关的代码
import router from "./router"
import { getToken } from "./utils/auth"

console.log('权限控制生效了')

const WHITE_LIST = ['/login', '/404']

// 1. 路由前置守卫
router.beforeEach((to, from, next) => {
     // to: 目标路由对象 到哪里去
    // from:路由对象 从哪里来的那个对象
    // next: 放行函数
    const token = getToken()
    if (token) {
        // 有token
        next()
    } else {
        // 没有token
        // 1. 是否在白名单内,即在白名单数组中是否存在
        if (WHITE_LIST.includes(to.path)) {
            next()
        } else {
            next('/login')
        }

    }
})

11. 接口错误统一处理

系统中会调用很多接口,每个接口都可能会出错,为了交互体验,需要对所有的接口进行错误处理,当错误发生时,告诉用户。

实现方式:利用axios的响应拦截器捕获所有接口错误。位置:utils/request.js

import axios from 'axios'
import { getToken } from './auth'
import { Message } from 'element-ui'

const service = axios.create({
  baseURL: 'https://api-hmzs.itheima.net/v1',
  timeout: 5000 // request timeout
})

// 请求拦截器
......

// 响应拦截器
service.interceptors.response.use(
  response => {
    return response.data
  },
  // 接口出错时,自动执行这个回调
  error => {
    // console.dir(error.response.data.msg)
    // 错误类型有可能有很多种,根据不同的错误码做不同的用户提示,写的位置都在这里
    Message({
      type: 'warning',
      message: error.response.data.msg
    })
    return Promise.reject(error)
  }
)

export default service

非组件调用:通过import { Message } from 'element-ui'引入独立消息组件

类型配置:使用type: 'warning'设置警告类型提示框

参数传递:所有的错误在error中,本次的封装在将error.response.data.msg中的,取出来作为message参数传递

❌