02 登录功能实现
1. 登录界面基础校验
1.1 基础数据的双向绑定
登录界面的element-UI组件由三层组成,最外层是el-form,中间层是el-form-item,最内层是el-form表单元素组件。
三个组件各施其职共同完成表单校验功能,具体的实现方式:在el-input组件上使用v-model指令实现双向绑定,比如:v-model="form.username";
1.2 表单校验配置
-
表单对象的字段命名需与接口字段保持一致,便于后续表单提交。比如,这里的username和password。
-
规则对象是采用对象嵌套数组的形式,比如:
rules: {
username: [
{
required: true,
message: '请输入账号',
trigger: 'blur'
}
]
}
校验规则:required设置必填项;message校验失败提示信息;trigger触发校验的事件,常用blur失焦事件。
- 组件绑定
el-form绑定:需要同时绑定:model="form"和:rules="rules"两个属性。el-form-item配置:通过prop属性指定对应的校验规则。
1.3 总结
表单校验是通过el-form、el-form-item、el-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:不输入任何内容直接点击登录,valid值为false,阻止登录流程。
- 测试场景2:输入符合规则的账号密码后点击登录,valid值为true,允许继续登录操作。
- 调试技巧:可以在回调函数中添加console.log(valid)来验证校验结果是否符合预期。
3. 使用vuex管理用户的token
登录的目的是获取token用于多组件共享,token是字符串类型。
三大模块:
- state:存储token数据状态
- mutation: 同步修改token的唯一途径
- 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获取,本地获取不到再初始化为空字符串。
- 在
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)
}
- 在
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存取方式对比
- 为什么要使用vuex+cookies存储的方式
内存存储优势:Vuex基于内存管理,存取速度特别快(毫秒级),且封装了便捷的方法调用方式,适合高频操作场景
持久化需求:Cookies/localStorage基于磁盘存储,虽然存取速度稍慢(约慢一个量级),但具有刷新不丢失的特性
组合方案价值:同时利用Vuex的速度优势(运行时状态管理)和Cookies的持久化特性(长期存储),典型场景如用户登录态保持
- 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.未选中状态:当用户未选中复选框时登录,再次返回登录页会清除本地存储的账号密码数据
实现逻辑:
- 登录时处理: 当remember为true时,将账号密码存入localStorage;当remember为false时,清除localStorage中的账号密码
- 初始化处理:组件初始化时从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的有无来控制路由的跳转。
- 新建
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参数传递