Skip to content

记住功能与月卡管理(一) ​

本页对应 记住功能与月卡管理,共 6 段视频,约 54 分钟。

学习目标 ​

  • 能在 axios 请求拦截器中从 Vuex/cookie 读取 token,按后端约定拼接后写入请求头 Authorization,并验证后续接口自动携带该请求头
  • 能通过 v-model 绑定“记住我”复选框状态,并在点击登录时根据选中与否分别写入或清除本地存储中的账号密码
  • 能在组件初始化生命周期中读取本地存储的账号密码并回填到响应式表单数据,同时使用常量变量替代硬编码 key、并对取不到值的情况做容错判断
  • 能实现退出登录:调用确认弹框征询用户,确认后提交 Vuex action 同时清空 state 与本地 cookie 中的用户数据,并跳转到登录页
  • 能在独立 permission 模块中注册全局前置守卫,依据 token 有无与白名单(登录页、404)做分支判断,正确使用 to、from、next 完成放行或重定向
  • 能在响应拦截器的错误回调中统一提取后端返回的错误码与提示信息,使用 Element UI 的 Message 在非组件环境下向用户弹出错误提示

知识点 ​

先明确:这一节只做“基础双向绑定” ​

为什么需要: 登录页首先要解决“用户输入的内容,组件能不能拿到”的问题。账号、密码、是否记住我,都是用户操作产生的数据;如果组件拿不到,后续登录、记住我、回填都无从谈起。

怎么想: 先把登录表单看成一组数据:username、password、remember。输入框和复选框负责改数据,组件里的 data 负责存数据。v-model 就是把这两边连起来。

怎么做: 在 data 中准备 formData 对象,把三个字段放进去;模板里用 v-model="formData.username"、v-model="formData.password"、v-model="formData.remember" 分别绑定。

易错点: 不要只写 v-model="username" 却在 data 里没有对应字段;也不要漏掉 formData 这一层。绑定路径必须和 data 结构一致。

用对象集中管理表单数据 ​

为什么需要: 登录表单不止一个字段。如果每个字段都单独挂在 data 根上,字段一多就散,提交、重置、回填时都要逐个处理。

怎么想: 转写稿里提到一个判断:remember 将来要不要提交给后端?如果要和账号密码一起提交,放进同一个对象会省事;如果不需要提交,单独放也可以。本节笔记把 remember 放进了 formData,说明这里按“表单数据统一管理”的思路处理。

怎么做: 保留笔记中的结构:

vue
<template>
    <el-form>
      <el-form-item label="账号">
        <el-input v-model="formData.username" />
      </el-form-item>

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

      <el-form-item>
        <el-checkbox v-model="formData.remember">记住我</el-checkbox>
      </el-form-item>

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

<script>
export default {
  name: 'Login',
  data() {
    return {
      formData: {
        username: '',
        password: '',
        remember: ''
      }
    }
  }
}

</script>

易错点: formData 是对象,v-model 绑定的是对象里的属性,不是对象本身。写成 v-model="formData" 会把整个对象当成值,输入框行为就不对了。

复选框的绑定与调试:看 remember 是否切换 ​

为什么需要: 复选框不是普通文本框,它关心的是“选中/未选中”。必须确认点击后绑定的数据真的发生变化,否则后面“记住我”的判断没有依据。

怎么想: 转写稿里的调试思路很直接:先不管登录逻辑,只验证 v-model 能不能拿到选中状态。点击“记住我”,观察组件数据里的 remember 是否在 true 和 false 之间切换。

怎么做: 打开登录页,切到调试工具,定位到当前组件,看 remember 的初始值;点一下“记住我”,它应变成选中状态;再点一下,应取消选中。转写稿中演示时默认给的是 false,点击后变 true,再点变 false。

易错点: 笔记代码里 remember 初始写的是空字符串 ''。空字符串在 JavaScript 中不是布尔值,虽然复选框通常会给布尔值,但初始值语义不清晰。实际调试时更建议用 false 表示“未选中”。这一步要先验证数据切换,再写后续逻辑,不要一上来就把登录、存储、跳转全混在一起。

这一节的落地检查 ​

为什么需要: 双向绑定是后续“记住我”“回填”“提交表单”的地基。地基没验证,后面出错很难判断是绑定问题还是业务逻辑问题。

怎么想: 按“先绑定、再验证、后扩展”的顺序走:输入账号看 formData.username 是否变化;输入密码看 formData.password 是否变化;切换复选框看 formData.remember 是否变化。

怎么做: 在调试工具中逐项检查三个字段;确认输入框能写进去、复选框能切换;暂时不要加登录请求和本地存储。

易错点: 不要只凭页面“看起来能输入”就认为绑定成功,要去看组件数据是否同步更新。还要注意:本节只是基础双向绑定,转写稿中后面的“记住我存储、回填、取消记住”属于后续交互逻辑,不能提前混进这一节。

一、先写 rules:把业务要求翻译成校验规则对象 ​

为什么需要: 登录页的账号、密码不能空着提交。如果把“不能为空”的判断散写在模板或点击事件里,字段一多就难维护。rules 的作用是把业务要求集中成一份规则配置。

怎么想: 业务要求是“账号必填、密码必填”,那就为每个字段准备一个数组,数组里放规则对象。规则对象至少写清楚三件事:是否必填 required、提示文案 message、什么时候触发 trigger。登录场景常用 blur,即输入框失去焦点时校验。

怎么做: 在 data 中返回 rules:

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

易错点: required 只负责非空判断,不负责长度、格式;message 要写成用户能看懂的提示;trigger 要根据交互选 blur 或 change;rules 要放在 data 的返回对象里,才能被模板响应式使用。转写稿里提到“硬编码容易拼错、抽成变量更稳”,迁移到这里就是:rules 的键名、prop、formData 字段名三处要统一,不要多处手写不同名字。

二、el-form 绑定 model 和 rules:让表单和规则建立关联 ​

为什么需要: el-form 需要知道两件事:校验谁、用哪套规则。model 提供表单当前值,rules 提供校验规则,两者绑定后,表单项才能按规则工作。

怎么想: formData 是表单数据对象,里面放 username、password、remember;rules 是规则对象。模板中用 :model="formData" 和 :rules="rules" 绑定,注意冒号表示绑定表达式,不是普通字符串。

怎么做:

vue
<el-form :model="formData" :rules="rules">

并在 data 中返回:

js
formData: {
  username: '',
  password: '',
  remember: ''
},
rules: { /* 上面的规则 */ }

调试过程可借鉴转写稿里的登录表单思路: 先完成 checkbox 的双向绑定,在调试工具里观察 remember 从 false 到 true 再到 false;再输入账号密码,观察 formData.username、formData.password 是否随输入变化。这说明 model 承载的是当前表单值,校验规则读取的也是同一份值。转写稿中“组件初始化时从本地取账号密码放到双向绑定数据上”的回填逻辑,回填后也会进入 model,校验会基于回填值判断;取值前先判断有没有数据,提升健壮性。

易错点: 漏写冒号会把 formData 当字符串传入;model 不是规则对象;rules 不要写成数组;formData 字段名要和后面 prop 一致。

三、el-form-item 用 prop 指定规则:把规则落到具体表单项 ​

为什么需要: el-form 只知道总规则,但每个 el-form-item 要知道自己该用哪条规则。prop 就是字段路径,它会让表单项去 rules 里找同名规则,并去 model 里取同名字段值。

怎么想: 账号项写 prop="username",密码项写 prop="password"。el-input 用 v-model="formData.username" 绑定值。这样“值”和“规则”通过同一个字段名对上。

怎么做:

vue
<el-form-item label="账号" prop="username">
  <el-input v-model="formData.username" />
</el-form-item>

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

易错点: prop 与 rules 键名不一致,规则不生效;prop 与 v-model 字段不一致,会校验错值;rules 里没有的 prop 不会报错,但不会触发校验,例如笔记中的 prop="remember" 没有对应规则,只作为字段标识;只配置规则不调用 validate,点击登录不会自动拦截,本笔记未写提交校验。调试时可清空账号后失焦,按代码应触发“请输入账号”;若没提示,先查三处名字是否一致。

四、完整代码 ​

vue
<template>
    <el-form :model="formData" :rules="rules">
      <el-form-item
        label="账号"
        prop="username"
      >
        <el-input v-model="formData.username" />
      </el-form-item>

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

      <el-form-item prop="remember">
        <el-checkbox v-model="formData.remember">记住我</el-checkbox>
      </el-form-item>

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

<script>
export default {
  name: 'Login',
  data() {
    return {
      formData: {
        username: '',
        password: '',
        remember: ''
      },
      rules: {
        username: [
          { required: true, message: '请输入账号', trigger: 'blur' }
        ],
        password: [
          { required: true, message: '请输入密码', trigger: 'blur' }
        ]
      }
    }
  }
}

</script>

本节完成三件事:写 rules、给 el-form 绑定 model 和 rules、给 el-form-item 写 prop。转写稿中登录表单的绑定与调试顺序可以迁移到校验配置:先让 v-model 有值,再挂规则,最后用 prop 关联;后续提交时再调用 validate 做统一校验。

本节要解决的问题 ​

这一节的核心不是写登录页面,而是把“登录请求”封装成一个可复用的接口函数。
为什么需要:如果组件里直接写 axios.post('https://api-hmzs.itheima.net/v1/park/login', ...),会出现三个问题:域名和超时配置分散、登录参数散落在组件里、后续加 token 或统一错误提示时到处改。
怎么想:先有统一的 request 实例,再在 src/apis/user.js 里声明 loginAPI,组件只关心“调用登录函数并拿到 promise”。
怎么做:request.js 负责 baseURL、timeout 等公共配置;user.js 负责业务接口路径、方法、参数。
易错点:不要把完整域名写进 loginAPI,否则会和 baseURL 重复;不要在 API 层做页面跳转或弹窗,保持接口层职责单一。

请求实例是登录接口的地基 ​

为什么需要:项目里不会只有一个登录接口,后面还有用户信息、列表、详情等接口。如果每个接口都手写域名和超时时间,维护成本很高。
怎么想:转写稿里反复提到“拦截器”“请求头”“token”,这些都属于 request.js 的统一控制范围。登录接口本身只声明“我要请求 /park/login”,真正发请求时由 request 实例拼接 baseURL。
怎么做:保留笔记中的 utils/request.js 配置:

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

这样 loginAPI 里的 url: '/park/login' 最终会拼成 https://api-hmzs.itheima.net/v1/park/login。
易错点:baseURL 已经带了 /v1,接口里不要再写 /v1/park/login;timeout 单位是毫秒,5000 表示 5 秒。转写稿里“蓝人器”就是拦截器,“拓困”就是 token,“健全”就是鉴权,后续在 request.js 的请求拦截器里统一加 token,登录接口不需要单独处理。

设计 loginAPI 的入参与返回值 ​

为什么需要:登录接口需要接收用户名和密码,并把后端返回的 promise 交给组件。
怎么想:组件里通常有表单对象,例如 { username, password }。API 函数可以直接解构这个对象,只取需要的字段,避免把无关字段发给后端。
怎么做:函数参数写成 { username, password },请求体 data 里放 username 和 password,并且一定要 return request(...)。
易错点:笔记注释里写的是 { mobile,password},但实现里解构的是 username,password,这里注释和实现不一致,实际应以接口文档和代码为准;如果组件传 { mobile, password },username 会是 undefined。另外 POST 请求体用 data,不是 params。

实现 loginAPI ​

为什么需要:把登录请求集中到 src/apis/user.js,组件调用更清晰,也方便后续复用和统一维护。
怎么想:import request 后,export function loginAPI 返回 request 的调用结果;url 写相对路径,method 写 POST,data 写登录参数。
怎么做:保留笔记代码如下:

src/apis/user.js

javascript
import request from '@/utils/request'

// 登录函数
/**
 * @description: 登录函数
 * @param {*} data { mobile,password}
 * @return {*} promise
 */
export function loginAPI({ username, password }) {
  return request({
    url: '/park/login',
    method: 'POST',
    data: {
      username,
      password
    }
  })
}

组件里调用时,应该是 loginAPI(form),其中 form 至少包含 username 和 password。
易错点:不要漏掉 return,否则组件拿不到 promise;不要把 method: 'POST' 写成小写或拼错;不要在这里手写 baseURL。调试时打开 Network,看请求地址是否为 /v1/park/login,请求体是否为 username/password。

与拦截器、统一错误处理的衔接 ​

为什么需要:登录成功后后端会返回 token,后续接口需要鉴权;接口失败时也要给用户提示。
怎么想:这些是“所有接口”的公共逻辑,不应该写在 loginAPI 里,而应放在 request.js 的拦截器中。转写稿提到:请求拦截器里先取 token,有 token 才加到请求头;请求头前缀是否加 Bearer 由后端接口文档决定。响应错误回调里可以读取后端返回的错误信息,用 ElMessage.warning 之类的方式统一提示。
怎么做:本节先把登录接口封装好;后续在 request.js 中集中加 token、集中处理错误。这样 loginAPI 只负责“登录请求”这一件事。
易错点:不要在 loginAPI 里手动拼 token;不要每个接口都写一遍错误提示;转写稿中“Bearer”不要写成“B假头”,是否添加以接口文档为准。

调试与易错点清单 ​

调试过程:先确认 request.js 的 baseURL 正确;再调用 loginAPI,在 Network 中检查 URL、Method、Request Payload;最后看返回值是否为 promise,组件是否能拿到成功或失败结果。
易错点:

  1. loginAPI 参数解构字段与组件表单字段不一致;
  2. 注释写 mobile、实现写 username,造成误判;
  3. URL 重复写 /v1;
  4. POST 误用 params;
  5. 忘记 return;
  6. 把 token 注入、错误提示、路由跳转塞进 loginAPI,导致接口层职责混乱。
    按“请求实例 → API 函数 → 组件调用 → 拦截器统一处理”的顺序理解,这一节就稳了。

为什么把 token 放进 Vuex 的 user 模块 ​

登录成功后,后端返回的 token 不是只给登录页用的。请求拦截器要给接口加鉴权头,路由守卫要判断用户有没有登录,退出登录时还要清空用户状态。如果每个地方都各自取、各自存,逻辑会散,容易漏。转写稿里反复强调:token 类似“钥匙”,接口鉴权需要它;而放在 axios 拦截器里统一加,是因为“有那么多接口都需要加鉴权,不能每个都写一次”。所以第一步是把 token 收进 Vuex 的 user 模块,集中管理。

怎么想:user 模块只负责用户相关的 token 状态;用 namespaced: true 隔离命名空间,避免以后其他模块也有 setToken 之类同名 mutation。怎么做:state 写成函数返回对象,初始 token: '';mutation 提供 setToken;action 提供 doLogin 处理异步登录。易错点:state 不能写成普通对象,否则模块复用时可能共享状态;组件里也不要直接改 state.token,必须提交 mutation。

javascript
import { loginAPI } from '@/apis/user'
export default {
  namespaced: true,
  state: () => {
    return {
      token: ''
    }
  },
  mutations: {
    setToken(state, token) {
      state.token = token
    }
  },
  actions: {
    async doLogin(ctx, { username, password }) {
      // 1. 调用接口
      const res = await loginAPI({ username, password })
      // 2. 提交mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

登录 action:异步请求和提交 mutation 分开 ​

为什么需要 action:登录要发请求,是异步操作;Vuex 规定 mutation 只做同步状态变更,所以异步逻辑放 action。怎么想:组件只收集 username、password,调用 dispatch('user/doLogin', form);action 内部 await loginAPI,拿到结果后 commit('setToken', res.data.token)。这样做的好处是登录流程集中,组件不关心 token 怎么写入。

怎么做:注意 doLogin 的第二个参数解构出账号密码;接口返回结构按项目实际来,本节代码是 res.data.token。易错点:不要在 action 里直接写 ctx.state.token = ...,否则 Devtools 追踪不到;不要漏写 await,否则 res 可能是 Promise;提交 mutation 时,在模块内部可写 ctx.commit('setToken'),但在组件里提交要带命名空间,通常是 commit('user/setToken') 或 dispatch('user/doLogin')。

调试时可以打开 Vuex 调试工具,登录成功后看 user/token 是否从空串变成后端返回值;如果没变,先检查接口是否成功、res.data.token 是否存在、mutation 是否被提交。

mutation 只做同步赋值 ​

为什么需要 mutation:Vuex 要求状态变更可预测、可追踪。setToken(state, token) 只做一件事:把 action 传来的 token 赋给 state.token。怎么想:mutation 不写请求、不写 setTimeout、不做复杂判断;它就是一个同步写入入口。怎么做:保持参数简单,state 和 token 两个参数即可。

易错点:一是在 mutation 里写异步;二是提交时命名空间写错;三是把 state 写成解构参数导致无法赋值。转写稿里退出登录时也提到“清空 Vuex 里的 token 和本地 cookie 两处”,说明 token 状态不是孤立的,后面清空时也要通过 mutation 统一处理,而不是在组件里东改一处西改一处。

和请求头、路由守卫、退出登录的衔接 ​

token 存进 Vuex 后,要在请求拦截器里取出来加到请求头。转写稿说明:前面请求头字段名是固定的,后面 token 的拼接模式由后端决定;有的后端要求 Bearer 前缀,有的不要求,不能自己猜。易错点:不要把 token 拼错位置;不要漏掉判断,只有取到 token 才加。

路由守卫里判断“有无 token”:有 token 直接 next();没有 token 再看是否在白名单,比如登录页、404 页,在白名单内放行,否则 next('/login')。转写稿调试时出现过参数写反的报错:beforeEach(to, from, next) 的位置是固定的,第一个参数永远是“去哪里”,第二个是“从哪里来”,即使变量名写反,实际对象位置也不变。易错点:白名单判断用 to.path,不是 from.path;include 判断的是目标路径是否在数组里。

退出登录时,先询问用户是否确认,确认后清空用户数据并跳回登录页。转写稿的做法是:Vuex 里 state.token = '',同时清除本地 cookie 中的 token;组件里提交对应 mutation。易错点:只清 Vuex 不清本地,或只清本地不清 Vuex,都会导致状态不一致。验证时看 Vuex 的 token 是否为空串、cookie 是否被移除、再访问受控页面是否回到登录页。

为什么要在组件中提交 action ​

登录按钮被点击后,组件拿到了表单数据,但真正“登录”这件事通常不只是改一个本地变量:它要调用接口、处理 token、把用户信息写入 Vuex,还可能触发后续跳转。组件负责收集用户输入和交互,Vuex 的 action 负责组织异步业务逻辑,二者分工清楚,代码才不容易散。

从转写稿看,前面已经把 token 作为接口鉴权标识放到请求头里,也讲了路由前置守卫根据 token 有无做权限控制。登录成功后能否进入首页,取决于登录流程是否真正完成。因此组件里不能只写一句“登录”,而要把表单数据交给 user/doLogin 这个 action,等它完成后再跳转。图片所示位置就是登录表单校验与提交 action 的衔接处:

登录表单校验与提交 action 的代码位置示意

怎么想:先校验,再提交,最后跳转 ​

这段代码的思路可以拆成三步。

第一步,表单要先校验。this.$refs.form.validate 接收一个回调,回调参数 valid 表示校验是否通过。只有 valid 为真,才继续登录;否则直接停在这里,避免把不合法数据发给后端。

第二步,从 this.formData 中解构出 username 和 password。这样传给 action 的 payload 很明确,不把整个表单对象一股脑丢过去,也避免把“记住我”等不属于登录接口的字段带进去。

第三步,使用 this.$store.dispatch('user/doLogin', { username, password }) 提交 action。这里带命名空间 user/,说明登录逻辑放在 user 模块里。因为 dispatch 返回 Promise,所以前面加 await,等 action 内部异步流程完成后再执行 this.$router.push('/')。跳转放在 await 之后,才不会出现“接口还没回来就进首页”的问题。

怎么做:保留代码结构并理解每个参数 ​

javascript
doLogin() {
  this.$refs.form.validate(async(valid) => {
    console.log(valid)
    if (valid) {
      // TODO
      console.log('登录')
      const { username, password } = this.formData
      await this.$store.dispatch('user/doLogin', { username, password })
      this.$router.push('/')
    }
  })
}

这里的 async 写在 validate 的回调函数上,因为回调内部要使用 await。valid 是校验结果,不能省略判断。dispatch 的第一个参数是 action 类型,第二个参数是 payload。await 之后再做路由跳转,表示登录流程已经走完。代码里的 console.log 是调试痕迹,实际项目可以按规范保留或删除,但不能用它代替错误处理。

易错点:async、命名空间和跳转时机 ​

第一个易错点是 async 的位置。如果写成 validate(valid => { await ... }),回调不是 async 函数,await 会报语法错误。正确做法是把 async 加在回调参数前。

第二个易错点是 action 类型写错。user/doLogin 里的 user/ 是 Vuex 模块命名空间,不能随手写成 doLogin,否则可能找不到 action。payload 也要与 action 内部接收的参数结构一致。

第三个易错点是跳转时机。若没有 await,dispatch 发出后立刻 push('/'),登录接口可能还没成功,路由守卫又依赖 token,容易被拦回登录页或出现数据未就绪。转写稿里路由守卫按 token 有无判断,这也说明跳转必须建立在登录流程完成之后。

第四个易错点是只写成功路径。当前代码在 valid 为真后直接提交,若 action 内部抛错,组件层是否提示、是否停留登录页,需要与项目统一错误处理配合。转写稿后面讲了接口错误统一提示,实际调试时应观察请求返回和错误提示,而不是只看 console.log('登录')。

调试过程:从日志到请求结果 ​

调试时先看 console.log(valid) 是否按预期输出。若点击登录没有反应,先确认 valid 是否为真,再检查 this.formData 中是否有 username、password。若 action 已发出但页面没跳转,检查 dispatch 的模块名和 action 名是否匹配,并观察网络请求是否成功。若请求成功但被路由守卫拦回,则回到 token 写入与路由判断那条链路排查。这样按“校验结果 → payload → action → 请求结果 → 路由跳转”的顺序看,问题定位会比盲目改代码更稳。

代码示例 ​

1. 登录组件:双向绑定 + 表单校验 + 记住我 ​

vue
<template>
  <el-form ref="form" :model="formData" :rules="rules">
    <el-form-item label="账号" prop="username">
      <el-input v-model="formData.username" />
    </el-form-item>

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

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

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

<script>
// 抽成常量,避免在多个位置手写字符串导致拼写不一致
const REMEMBER_KEY = 'rememberKey'

export default {
  name: 'Login',
  data() {
    return {
      formData: {
        username: '',
        password: ''
      },
      // remember 不需要提交给后端,单独放,不塞进 formData
      remember: false,
      rules: {
        username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
        password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
      }
    }
  },
  created() {
    // 组件初始化时优先从本地取,取到再回填
    const info = localStorage.getItem(REMEMBER_KEY)
    if (info) {
      const { username, password } = JSON.parse(info)
      this.formData.username = username
      this.formData.password = password
    }
  },
  methods: {
    doLogin() {
      this.$refs.form.validate(async (valid) => {
        if (!valid) return

        const { username, password } = this.formData

        // 选中则记录,未选中则清除之前记录的
        if (this.remember) {
          localStorage.setItem(REMEMBER_KEY, JSON.stringify({ username, password }))
        } else {
          localStorage.removeItem(REMEMBER_KEY)
        }

        await this.$store.dispatch('user/doLogin', { username, password })
        this.$router.push('/')
      })
    }
  }
}
</script>

要点与易错点:

  • el-form 必须同时绑定 :model="formData" 和 :rules="rules",el-form-item 的 prop 必须与 rules 中的字段名完全一致,否则规则不生效。
  • 校验结果只能从 validate(valid => {}) 的回调里拿,valid 为 true 才发请求。
  • localStorage 只能存字符串,所以存的时候要 JSON.stringify,取的时候要 JSON.parse,两者必须配对。
  • 取值前一定要先判断 if (info),从没存过时是 null,直接 JSON.parse(null) 会出问题。
  • remember 单独放 data,因为不需要提交给后端;放进 formData 反而要多做解构处理。

2. 请求封装 + 登录接口 + Vuex 用户模块 ​

javascript
// utils/request.js
import axios from 'axios'
import { Message } from 'element-ui'
import { getToken } from '@/utils/auth'

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

// 请求拦截器:统一注入 token
service.interceptors.request.use(
  (config) => {
    const token = getToken()
    if (token) {
      config.headers.Authorization = token
    }
    // 必须 return,否则接口拿不到 config
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一错误提示
service.interceptors.response.use(
  (response) => response.data,
  (error) => {
    const message = error.response.data.message
    Message.warning(message)
    return Promise.reject(error)
  }
)

export default service
javascript
// apis/user.js
import request from '@/utils/request'

/**
 * @description: 登录函数
 * @param {*} data { username, password }
 * @return {*} promise
 */
export function loginAPI({ username, password }) {
  return request({
    url: '/park/login',
    method: 'POST',
    data: {
      username,
      password
    }
  })
}
javascript
// store/modules/user.js
import { loginAPI } from '@/apis/user'
import { removeToken } from '@/utils/auth'

export default {
  namespaced: true,
  state: () => {
    return {
      token: ''
    }
  },
  mutations: {
    setToken(state, token) {
      state.token = token
    },
    // 退出登录:清 Vuex + 清本地 cookie
    clearUser(state) {
      state.token = ''
      removeToken()
    }
  },
  actions: {
    async doLogin(ctx, { username, password }) {
      // 1. 调用接口
      const res = await loginAPI({ username, password })
      // 2. 提交 mutation
      ctx.commit('setToken', res.data.token)
    }
  }
}

要点与易错点:

  • 请求拦截器里 return config 千万不能漏,漏了相当于把参数改没了,接口直接拿不到配置。
  • 请求头名称(如 Authorization)和 token 的拼接方式(有的后端要求 Bearer + 空格 + token)由后端接口文档决定,不要自己猜。
  • 统一错误提示写在 response 拦截器的错误回调里,所有被 request 接管的接口出错都会走到这里,只需写一次。
  • 拦截器里不能用 this.$message(没有组件实例,this 不指向组件),必须 import { Message } from 'element-ui' 后调用 Message.warning(...)。
  • clearUser 要清两个地方:Vuex 里的 state.token 和本地 cookie,只清一个会导致刷新后状态不一致。

3. 路由守卫 + 退出登录 ​

javascript
// permission.js
import router from '@/router'
import { getToken } from '@/utils/auth'

// 白名单:没有 token 也能进
const whiteList = ['/login', '/404']

router.beforeEach((to, from, next) => {
  // 判断依据:当前用户是否有 token(注意是"有无",不是"有效")
  const token = getToken()

  if (token) {
    // 有 token,直接放行
    next()
  } else {
    // 无 token,再看是否在白名单内
    if (whiteList.includes(to.path)) {
      next()
    } else {
      // 不在白名单,强制回到登录页去拿 token
      next('/login')
    }
  }
})
javascript
// main.js 中让守卫生效
import '@/permission' // 直接 import,文件内的可执行代码立即执行一遍
javascript
// Layout 组件中的退出登录
logout() {
  this.$confirm('确认要退出登录吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    // 确定:清空用户数据 + 回登录页
    this.$store.commit('user/clearUser')
    this.$router.push('/login')
  }).catch(() => {
    // 取消或关闭弹窗,什么都不做
  })
}

要点与易错点:

  • beforeEach 的三个参数位置是固定的:第一个永远是 to(要去哪),第二个永远是 from(从哪来),第三个是 next。名字可以随便叫,但位置不能换,写反了 to.path 拿到的就是上一个页面。
  • 白名单判断用的是 to.path(要前往的地址),不是 from.path。
  • 必须调用 next() 才会真正跳转;未登录时用 next('/login') 而不是 next()。
  • permission.js 本身不会自动生效,必须在 main.js 里 import '@/permission',这种写法是把文件里的代码立即执行一遍。
  • 退出登录属于危险操作,先 $confirm 询问用户;then 是点击确定,catch 是取消或关闭。
  • 提交 mutation 时要带模块名:commit('user/clearUser'),因为 user 模块开启了 namespaced: true。

常见错误 ​

  1. 现象: 登录成功后,后续接口仍然返回 401(未授权)。 原因: 请求拦截器里取了 token 但忘记 return config,拦截器返回 undefined,请求被中断或丢失配置;或者取值用的 key 与存入时不一致。 修法: 拦截器末尾必须 return config;把 storage 的 key 抽成常量(如 TOKEN_KEY)在存/取/删三处共享,杜绝手写字符串拼错。

  2. 现象: 刷新页面后陷入无限重定向,或者控制台报 “Maximum call stack / Navigation duplicated”。 原因: 白名单判断写成了 from.path 而不是 to.path;或白名单里漏了 /login,导致“跳登录页”这个动作本身又被守卫拦下来。 修法: 守卫内一律以 to.path 判定目标路由,白名单至少包含登录页与 404 页,并在 next('/login') 前确认目标不是登录页本身。

  3. 现象: 跳转到了错误的页面,或者参数取到的是“上一个页面”而非“要去的页面”。 原因: 把 beforeEach(to, from, next) 的前两个形参名写反了(如写成 from, to),误以为改名字就能改语义。 修法: 记住位置是固定的:第一个永远是“去哪”,第二个永远是“从哪来”。形参名只是别名,改名字不改变实参顺序。

  4. 现象: 勾选“记住我”后回填,账号密码框显示 [object Object],或直接抛异常导致组件白屏。 原因: 存入时 JSON.stringify,取出时忘了 JSON.parse(反之亦然),把对象直接赋给了双向绑定的字符串字段。 修法: 存/取成对书写,setItem 配 parse、getItem 配 stringify 一一对应;取出后先判空再解析,避免首次访问时 JSON.parse(null/undefined) 报错。

  5. 现象: 退出登录后当前页看似已退出,但手动刷新或新开标签页仍然是登录态。 原因: 只清了 Vuex 里的 token,没清本地存储(或只清了一处),两处数据源不同步。 修法: 退出逻辑集中写成一个 mutation/action,内部同时清空 state 与 storage;组件里只派发这一个动作,禁止在组件里零散地各自清。

  6. 现象: 接口报错时页面无任何提示,或控制台报 Cannot read property '$message' of undefined。 原因: 在 utils/request.js 这种非组件环境里用了 this.$message(或 this.$router),此时 this 不指向组件实例。 修法: 按需引入 Element 的独立方法(import { Message } from 'element-plus' 后用 Message.warning(...)),路由跳转同理用导入的 router 实例。

  7. 现象: 本地联调正常,联调环境一上线就 401。 原因: token 的拼接格式(是否带 Bearer 前缀、是否加空格)是自己猜的,而实际由后端接口文档规定。 修法: 前缀以接口文档为准,把拼接逻辑收敛到拦截器一处,并抽成可配置常量,切换环境时只改一个地方。

练习 ​

  1. 拦截器改造:后端要求 token 必须以 Bearer 前缀发送。请改造 request.js,要求:无 token 时不加该请求头;前缀可配置;不破坏现有的其他请求。
  2. 记住我·安全变体:产品要求“只回填账号,密码框始终为空”。请说明需要改动哪几处、为什么不能简单地把密码也一起 parse 后赋值。
  3. 守卫补全:实现“已登录用户访问 /login 时自动跳回首页”,并说明该分支应放在现有流程图(有 token → 放行)的哪个位置,为什么不能放在最前面。
  4. 错误分级:把 response 拦截器里的统一提示升级为按业务 code 区分——401 清空登录态并跳登录页、500 提示“服务异常”、其余提示后端 message。请写出判断结构,并说明为什么跳转要用导入的 router 而非 this.$router。
  5. 退出登录抽取:把退出登录逻辑从组件抽成 Vuex action,要求清空用户模块所有状态 + 本地存储,并保证组件侧调用只写一行。写出 action 与组件调用代码。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1token添加到请求头06:37
2记住我功能-实现记住功能15:41
3记住我功能-实现取消记住功能02:23
4退出登录实现07:35
5根据token的有无控制路由跳转15:40
6接口统一错误提示处理06:11

小结 ​

  1. token 注入只做一次:凡是“每个请求都要带的东西”,位置永远在拦截器,绝不在业务代码里重复;判断条件是“有没有”,不要和“是否有效”混为一谈。
  2. 权限控制的判定依据要单一:路由守卫只看 token 的有无,白名单只看 to.path;两个维度分开写,流程图转代码就是 if/else 的逐层映射。
  3. 参数顺序是硬约定:to, from, next 的位置不可交换,名字可以随意改——这是最容易“看起来对、跑起来错”的地方。
  4. 本地存储的键必须常量化:同一个 key 出现在两处以上就是隐患,抽成常量后由变量提示兜底,比手写字符串可靠。
  5. 存与取是成对契约:序列化方式、key、数据结构三者必须在写入和读取两端完全对齐,任何一端改动都要同步另一端。
  6. 退出登录 = 清干净 + 跳回去:危险操作先二次确认;清空要覆盖所有数据源(Vuex + storage),并且收敛到一处调用。
  7. 非组件环境没有 this:工具层、拦截器里要用导入式 API(Message、router),这是“在组件里能跑、抽出去就崩”的根因。
  8. 健壮性优先于简洁:取值判空、解析兜底、错误分级,多写两行判断,换的是线上少一类白屏。