Skip to content

解决刷新个人信息丢失与退出登陆(Lesson21 2/2) ​

本页对应 Lesson21,共 6 段视频,约 1 小时 30 分。

学习目标 ​

  • 能够通过路由前置守卫判断 token 与用户信息状态,在刷新后信息缺失时请求并保存用户信息,且切换路由不重复请求。
  • 能够使用响应拦截器识别 token 异常状态码,完成信息清理、友好提示并跳转登录页。
  • 能够实现退出登录:调用退出接口、清空 token 与用户信息、跳转登录页,并说明如何阻止后续逻辑执行。
  • 能够为交易页配置路由与权限元信息,未登录访问时重定向登录页,登录成功后按 redirect 参数回跳原目标页。
  • 能够请求并保存用户地址列表,渲染地址数据,计算默认地址并实现点击地址切换选中状态。
  • 能够请求并保存交易页订单信息,渲染商品清单、图片、标题、价格、数量及金额汇总,并正确处理初始空数据避免渲染报错。

知识点 ​

先看现象:刷新后个人信息为什么丢了 ​

为什么需要:登录成功后,代码会请求个人信息并保存到 Vuex 的 store.state.user.userInfo。但 Vuex 的状态保存在内存里,页面一刷新,整个应用重新初始化,userInfo 就变空了。此时 token 可能还在,于是出现“有 token、却没有用户信息”的状态,页面上的头像、昵称等信息自然丢失。

怎么想:转写稿里明确说,不建议把个人信息长期塞进本地存储,因为那样会一直存在,不够实时。也不要把请求随手放到使用个人信息的组件里,比如 header。因为 header 这类公共组件在切换路由时不一定重新执行 mounted,你把它放在组件里,切换路由并不会重新拉取;而刷新时又不可靠。更稳的思路是:在进入路由之前统一检查,缺用户信息就补一次。

怎么做:在路由前置守卫里判断两个条件:有 token,并且 Vuex 里还没有 userInfo,才发请求。请求完成后必须等待,再放行路由。

易错点:转写稿里的“死日志/死号日志”就是 store;“比报遗产”就是 beforeEach;“冒题”就是 mounted。不要把这些同音词带进代码。另一个易错点是把请求放进公共组件的 mounted,以为切换路由会重新渲染,其实不会。

方案:用路由前置守卫补齐用户信息 ​

为什么需要:路由守卫能在每次进入路由前统一处理“用户信息是否齐全”,比在每个页面里各写一遍更集中,也不会因为组件是否重新渲染而漏掉。

怎么想:不是每次路由都请求,而是“有 token 且没有用户信息”才请求。已经有 userInfo 时直接 next(),避免切换每个路由都重复请求。请求是异步的,所以守卫要写成 async,并用 await 等 dispatch 完成后再 next()。

怎么做:本节笔记的核心代码在 src/router/index.js:

js
router.beforeEach(async function(to,from,next){
	if(getToken() && !store.state.user.userInfo)
		await store.dispatch("user/getUserInfoAsync");
	next();
})

这里的 getToken() 判断是否登录;!store.state.user.userInfo 判断当前是否缺个人信息;store.dispatch("user/getUserInfoAsync") 调用 user 模块里获取个人信息的 action;await 保证请求完成后再执行 next()。

易错点:第一,next() 必须调用,否则路由会卡住。第二,不要漏掉 await,否则请求还没回来就放行,页面可能仍拿不到信息。第三,模块名和 action 名要和 store 里一致,转写稿里用的是 user/getUserInfoAsync。第四,条件不要写反:有 token 且没有 userInfo 才请求,不是每次路由都请求。

调试过程:刷新会请求,切换路由不会重复请求 ​

为什么需要:只看代码容易误以为“每次路由都会请求”。实际调试时要区分刷新和普通路由切换。

怎么想:刷新时应用重新初始化,Vuex 为空,守卫会命中条件并请求个人信息;普通切换路由时,如果之前已经拿到 userInfo,条件不成立,就不会再请求。转写稿中验证到:刷新后个人信息出现;切换路由时 getUserInfoAsync 不会再次调用。这正是想要的效果。

怎么做:打开浏览器 Network,刷新页面,观察是否发出获取个人信息的请求;再切换路由,观察该请求是否重复发出。页面上检查头像、昵称等是否恢复。转写稿里还提到,刷新时这个请求也能顺带验证 token 是否正确;但 token 过期、208 状态码、退出登录属于后续问题,本节先不展开。

易错点:不要把“刷新时请求一次”写成“每次路由都请求”,否则会造成不必要的接口压力。也不要在守卫里直接改 store.state,应通过 action/mutation 维护状态。若请求失败,要意识到这可能是 token 异常,需要后续在响应拦截中处理,而不是在本节代码里硬塞逻辑。

完整落地与检查清单 ​

为什么需要:这段代码很短,但依赖 getToken、store、user 模块 action 三处配合,任何一处名字不对都会导致刷新后仍然丢信息。

怎么想:先确认 getToken 已正确导入;再确认 store 已导入;最后确认 user 模块里确实有 getUserInfoAsync,并且请求成功后会把数据保存到 state.user.userInfo。守卫只负责“缺了就补”,不负责重复拉取。

怎么做:保留上面的守卫代码,检查 src/router/index.js 中导入语句和 store 模块命名。刷新页面后,如果 userInfo 能恢复,说明链路通了;切换路由不重复请求,说明条件判断正确。

易错点:常见问题是模块命名空间写错、action 名写错、忘记 next()、忘记 await,以及把请求放到 header 的 mounted 里。记住转写稿的结论:刷新时调一次,切换路由不重复调,这才是本节要的效果。

退出登录要解决什么问题 ​

退出登录不是只把页面上的用户名藏起来,而是要把“登录态”完整清掉:state.userInfo、本地保存的 token,以及当前页面跳转。转写稿里反复强调:主动点“退出登录”和 token 过期/异常,本质上要做同一件事——清空用户信息、移除 token、回到登录页。所以这段逻辑不适合散落在每个页面里,而应集中封装到 Vuex 的 user 模块中。这样主动退出和拦截器发现 208 时都能复用。

怎么想:先调后端退出接口,再清前端登录态。因为接口职责是通知服务端“该用户退出了”,转写稿也提到这个接口本应记录退出时间,但实际项目里可能没做处理,我们按接口存在就调用。
怎么做:在 src/api/user.js 中封装退出接口。
易错点:退出接口是 GET,不要写成 POST;路径不要拼错。

js
// 退出登陆 /api/user/passport/logout
export const getLogout = ()=> sphRequest("/user/passport/logout");

在 Vuex 中集中清理登录态 ​

为什么需要:如果每个组件退出时都写一遍 userInfo = null、removeToken()、router.push("/login"),重复且容易漏。转写稿明确建议把行为封装到 store/user.js,拦截器里也能直接 commit。
怎么想:mutation 只负责同步改状态,action 负责先请求接口,成功后再提交 mutation。
怎么做:保留本节笔记代码。注意笔记目录写作 sotre,实际项目目录通常是 store;removeTOken 的大小写也要和项目工具函数一致。

js
const mutations = {
	OUT_LOG(){
		state.userInfo = null;
		removeTOken();
		router.push("/login");
	}
}
const actions = {
	async getLogOutAsync({commit}){
	    await getLogout();
		commit("OUT_LOG");
	}
}
export default {
	namespaced:true,
	actions,
	state,
	mutations
}

易错点:namespaced: true 后,组件里要写 user/getLogOutAsync;commit("OUT_LOG") 在模块内部可简写,外部提交要带命名空间。router 要正确引入,否则跳转失败。转写稿还提到 action 可返回 Promise,用于阻止后续程序执行;如果业务需要“退出后不再继续”,可在 action 中返回 Promise,但本节笔记核心是 await getLogout() 后再 commit。

在页面按钮上触发退出 ​

为什么需要:退出入口通常在头部用户区域。转写稿中在头部按钮上加点击事件,并调用 user/getLogOutAsync。
怎么想:按钮只负责触发 action,不直接改 state,也不直接删 token。
怎么做:模板中加 @click.prevent,方法里 this.$store.dispatch("user/getLogOutAsync")。转写稿调试时看到退出接口返回 200,说明请求成功;随后执行 OUT_LOG 完成清理和跳转。
易错点:不要忘记 prevent 导致页面刷新;不要只调接口不 commit;如果接口失败,是否仍退出要按业务决定,转写稿当前实现是 await 成功后再 commit。

token 过期与主动退出复用同一套逻辑 ​

为什么需要:刷新页面时会重新获取个人信息,如果 token 被改坏或过期,后端会返回未登录,转写稿中看到 code 为 208。如果只在获取个人信息接口里判断,其他隐私接口还要重复写。
怎么想:在响应拦截器统一判断 res.data.code === 208,一旦发现就提交 user/OUT_LOG,并提示“请重新登录”。
怎么做:拦截器中判断后 store.commit("user/OUT_LOG");转写稿还建议加 Message.error("请重新登录") 之类提示。这样主动退出和异常退出都走同一 mutation。
易错点:不要在每个接口里各写一遍 208 判断;拦截器里要确保 store 已引入;208 判断用数字比较,注意接口返回结构是 res.data.code。转写稿调试时改坏 token 后刷新,请求返回 208,随后清空信息并跳登录页,说明链路正确。

调试顺序与易错点 ​

先确认退出接口能返回 200;再确认 OUT_LOG 中 state.userInfo 被清空、token 被移除;最后确认 router.push("/login") 生效。转写稿中刷新个人信息时还用了路由守卫:有 token 且 state.userInfo 为空才请求用户信息,避免每次切换路由都请求。退出逻辑与它配合:退出后 token 没了,守卫自然不会继续拉用户信息。易错点集中在命名空间、函数名大小写、接口方法、以及“只清页面不清 token”。按“接口 → action → mutation → 页面/拦截器”的顺序排查即可。

先明确要解决的问题:token 异常不能只在一个接口里处理 ​

为什么需要:登录后我们把 token 和用户信息保存到 store。刷新页面时,路由守卫发现“有 token 但没有 userInfo”,会重新请求用户信息;如果 token 被篡改或过期,后端会返回未登录状态码 208。此时如果不处理,页面可能继续渲染空数据,或者后续接口继续报错。

怎么想:转写稿里先试过在“获取个人信息”接口里判断 code === 208,但这样每个隐私接口都要写一遍,麻烦且容易漏。更合理的是放到 src/request/sphRequest.js 的响应拦截器里统一判断:只要响应体里的 code 是 208,就说明 token 异常,统一清空登录态并跳登录页。

怎么做:在响应拦截器成功回调里,先结束进度条,再判断 response.data.code === 208。注意 208 是数字,不要写成字符串。

js
sphRequest.interceptors.response.use(response => {
	nprogress.done();// 结束进度条
	// token异常
	if(response.data.code === 208){
		// 清空token
		// 跳转至登陆界面
		store.commit("user/OUT_LOG");
		return new Promise(()=>{});
	}
	return response.data;// 返回响应体
}, (err) => {
	nprogress.done();// 结束进度条
	alert(err);
	return new Promise(() => {
	});// 返回一个状态为pending的Promise
});

易错点:不要只清 token 不清 userInfo;不要忘记 return new Promise(()=>{}),否则原请求还会继续走 then,组件可能拿到异常数据继续渲染。

把“退出登录”的行为封装到 store ​

为什么需要:token 异常后的动作和用户主动退出登录本质一样:清空用户信息、移除 token、跳转登录页。如果拦截器里直接写一堆逻辑,后续退出登录又要写一遍,容易不一致。

怎么想:在 store/user 里提供 OUT_LOG 这个 mutation,或者在 actions 里封装退出逻辑。转写稿里强调:先把 userInfo 复原成空,再 removeToken,最后 router.push 到登录页。这样拦截器只负责“发现异常”,具体清理交给 store。

怎么做:拦截器中通过 store.commit("user/OUT_LOG") 触发清理。转写稿还建议在拦截器里补一个友好提示,例如“token 超时,请重新登录”,但核心阻断逻辑仍是清空并跳转。

易错点:跳转要放在清理之后;如果先跳转再清理,可能出现登录页仍带着旧状态。还要注意 store 中 mutation 名称要和 commit 一致,不能把自定义函数名和服务端字段名混淆。

为什么要返回 pending 的 Promise ​

为什么需要:响应拦截器里发现 208 后,当前请求已经失败。如果直接 return response.data,调用方还会拿到一个“看似成功”的响应,继续执行 then 里的保存用户信息、渲染页面等逻辑。

怎么想:返回一个永远 pending 的 Promise,等于告诉调用方“这次请求没有后续结果”,从而阻止后续 then 和 catch 继续执行。转写稿里明确说这是“阻止你后续程序的执行”。

怎么做:成功回调里 return new Promise(()=>{});错误回调里也返回 pending Promise,避免错误继续向下抛。注意错误回调里仍然要先 nprogress.done(),否则进度条可能卡住。

易错点:写成 return Promise.resolve() 会继续执行;写成 return null 也可能让调用方误判。pending Promise 不是“返回空数据”,而是“主动阻断”。

调试流程:改坏 token 观察 208 与跳转 ​

为什么需要:只看代码容易漏掉执行顺序。转写稿中的调试方式是:登录成功后打开应用,找到本地保存的 token,故意改一个字符,然后刷新页面。

怎么想:刷新后路由守卫会请求用户信息;请求携带错误 token,后端返回 code: 208;响应拦截器命中 208,执行 OUT_LOG,清空 userInfo 和 token,跳转登录页。此时网络面板里能看到该请求的响应码字段是 208,不是 HTTP 状态码。

怎么做:按“登录成功 → 改 token → 刷新 → 观察请求响应 → 观察是否跳登录 → 再登录验证”的顺序排查。若没有跳转,先检查拦截器是否真的拿到 response.data.code,再检查 OUT_LOG 是否被正确 commit。

易错点:把 code 和 HTTP 状态码混为一谈;忘记 nprogress.done();在拦截器里直接写 router.push 却没有引入 router;只处理成功响应,忽略错误回调里的进度条结束。

一、交易页路由与结算入口 ​

为什么需要:购物车点“结算”后要进入独立交易页,交易页需要自己的路由和组件,不能塞在购物车里。
怎么想:先在 src/pages/Trade/index.vue 建页面,再在 src/router/index.js 注册 /trade,最后把购物车“结算”改成 router-link。转写稿里强调:交易页是“填写并核对订单信息”的完整页面,先把路由骨架搭起来,再谈权限。
怎么做:保留本节关键模板与图片标签,alt 改为中文:

vue
<!-- src/pages/Trade/index.vue -->
<template>
  <div>
    <h3 class="title">填写并核对订单信息</h3>
    <div class="content">
      <h5 class="receive">收件人信息</h5>
      <div class="address clearFix">
        <span class="username selected">张三</span>
        <p>
          <span class="s1">北京市昌平区宏福科技园综合楼6层</span>
          <span class="s2">15010658793</span>
          <span class="s3">默认地址</span>
        </p>
      </div>
      <div class="address clearFix">
        <span class="username selected">李四</span>
        <p>
          <span class="s1">北京市昌平区宏福科技园综合楼6层</span>
          <span class="s2">13590909098</span>
          <span class="s3">默认地址</span>
        </p>
      </div>
      <div class="address clearFix">
        <span class="username selected">王五</span>
        <p>
          <span class="s1">北京市昌平区宏福科技园综合楼6层</span>
          <span class="s2">18012340987</span>
          <span class="s3">默认地址</span>
        </p>
      </div>
      <div class="line"></div>
      <h5 class="pay">支付方式</h5>
      <div class="address clearFix">
        <span class="username selected">在线支付</span>
        <span class="username" style="margin-left:5px;">货到付款</span>
      </div>
      <div class="line"></div>
      <h5 class="pay">送货清单</h5>
      <div class="way">
        <h5>配送方式</h5>
        <div class="info clearFix">
          <span class="s1">天天快递</span>
          <p>配送时间:预计8月10日(周三)09:00-15:00送达</p>
        </div>
      </div>
      <div class="detail">
        <h5>商品清单</h5>
        <ul class="list clearFix">
          <li><img src="./images/goods.png" alt="商品图片"></li>
          <li>
            <p>Apple iPhone 6s (A1700) 64G 玫瑰金色 移动联通电信4G手机硅胶透明防摔软壳 本色系列</p>
            <h4>7天无理由退货</h4>
          </li>
          <li><h3>¥5399.00</h3></li>
          <li>X1</li>
          <li>有货</li>
        </ul>
        <ul class="list clearFix">
          <li><img src="./images/goods.png" alt="商品图片"></li>
          <li>
            <p>Apple iPhone 6s (A1700) 64G 玫瑰金色 移动联通电信4G手机硅胶透明防摔软壳 本色系列</p>
            <h4>7天无理由退货</h4>
          </li>
          <li><h3>¥5399.00</h3></li>
          <li>X1</li>
          <li>有货</li>
        </ul>
      </div>
      <div class="bbs">
        <h5>买家留言:</h5>
        <textarea placeholder="建议留言前先与商家沟通确认" class="remarks-cont"></textarea>
      </div>
      <div class="line"></div>
      <div class="bill">
        <h5>发票信息:</h5>
        <div>普通发票(电子) 个人 明细</div>
        <h5>使用优惠/抵用</h5>
      </div>
    </div>
    <div class="money clearFix">
      <ul>
        <li><b><i>1</i>件商品,总商品金额</b><span>¥5399.00</span></li>
        <li><b>返现:</b><span>0.00</span></li>
        <li><b>运费:</b><span>0.00</span></li>
      </ul>
    </div>
    <div class="trade">
      <div class="price">应付金额: <span>¥5399.00</span></div>
      <div class="receiveInfo">
        寄送至:<span>北京市昌平区宏福科技园综合楼6层</span>
        收货人:<span>张三</span><span>15010658793</span>
      </div>
    </div>
    <div class="sub clearFix">
      <a href="##" class="subBtn">提交订单</a>
    </div>
  </div>
</template>

<script>
export default {
  name: "Trade"
}
</script>
vue
<!-- src/pages/Cart/index.vue -->
<template>
  <div class="sumbtn">
    <router-link class="sum-btn" to="/trade">结算</router-link>
  </div>
</template>

易错点:router-link 的 to 要写 /trade;图片路径按笔记放在 Trade/images 下;交易页模板外层要有包裹标签。

二、给交易页增加权限验证 ​

为什么需要:未登录也能直接访问 /trade 不合理,交易页必须登录后才能看。
怎么想:转写稿里先用 router.beforeEach 打印日志,确认每次路由切换都会执行;再给路由加 meta.isAuth,由守卫统一判断,而不是每个页面各写一遍。
怎么做:

js
// src/router/index.js
const routes = [{
  path: "/trade",
  component: Trade,
  meta: {
    isAuth: true
  }
}];

router.beforeEach(async function (to, from, next) {
  if (getToken() && !store.state.user.userInfo)
    await store.dispatch("user/getUserInfoAsync");

  // 判断路由是否需要登录
  if (to.meta.isAuth) {
    if (getToken()) next();
    else next({
      path: "/login",
      query: {
        cb: to.path
      }
    });
  } else {
    next();
  }
});

易错点:同音字要纠正为 beforeEach、next、to.meta.isAuth、token、code;getUserInfoAsync 前要 await,否则守卫可能先放行;没有 token 时用 next({ path:"/login", query:{ cb:to.path } }) 记住原目标。

三、刷新丢失个人信息与登录后回跳 ​

为什么需要:登录后 userInfo 存在 store 里,刷新会丢;如果每次切路由都请求又太频繁。转写稿的结论是:有 token 且 store 里没有 userInfo 时才请求。
怎么想:在全局前置守卫里先补个人信息,再判断权限;登录成功时如果有 cb,就回到交易页。
怎么做:

js
// src/store/user.js
async postLoginAsync({ dispatch }, body) {
  const { code, data } = await postLogin(body);
  if (code === 200) {
    Message.success("恭喜您,登陆成功!");
    saveToken(data.token);
    const { cb } = router.history.current.query;
    if (cb) router.push(cb);
    else router.push("/"); // 跳转至首页
  } else {
    Message.error("账号或密码错误!");
  }
}

易错点:router.history.current.query.cb 才是登录页地址栏里的回跳参数;不要写成 router.query;刷新时只请求一次,切换路由不会重复请求,这是预期效果。

四、token 异常与退出登录 ​

为什么需要:token 过期或异常时,隐私接口会返回 208,页面不能继续假装已登录。转写稿中改坏 token 后刷新,接口返回未登录、code 为 208。
怎么想:不要在每个接口里判断 208,应在响应拦截器统一处理:清空用户信息、移除 token、跳登录;退出登录也复用同一套清理逻辑。
怎么做:转写稿演示了 logout:清空 userInfo、removeToken()、router.push("/login"),并可在拦截器里给“token 异常,请重新登录”的提示。
易错点:208 是数字比较;清理后要跳登录;退出登录和 token 异常行为要一致,避免残留用户信息。

交易页要渲染什么:先理清数据链路 ​

为什么需要:交易页同时要展示收件人地址、商品清单、总数量、总金额等信息,这些数据不在页面里写死,而是来自“用户地址”和“交易页信息”两个接口。怎么想:地址属于用户模块,交易信息属于订单模块,所以分别建 API 和 Vuex 模块;页面只负责 dispatch 和渲染。怎么做:先补 API,再在 Vuex 中保存。易错点:namespaced:true 后,dispatch/commit 要带模块名,如 user/findUserAddressListAsync、order/getTradeListAsync。

js
// src/api/user.js
// 获取用户地址信息 /user/userAddress/auth/findUserAddressList
export const findUserAddressList = ()=>sphRequest("/user/userAddress/auth/findUserAddressList");
js
// src/api/order.js
import {sphRequest} from "@/request";
// 获取交易页信息:/order/auth/trade get
export const getTradeList = ()=>sphRequest.get("/order/auth/trade");
js
// src/store/user.js
import {findUserAddressList} from "@/api/user";
const state = { addressList:[] }
const mutations = {
  SAVE_ADDRESS_LIST(state,payload){ state.addressList = payload; }
}
const actions = {
  async findUserAddressListAsync({commit}){
    const {data} = await findUserAddressList();
    commit("SAVE_ADDRESS_LIST",data);
  }
}
const getters = {
  addressDefault(state){
    return state.addressList.find(v=>v.isDefault==='1') || {}
  }
}
export default { namespaced:true, actions, state, mutations, getters }
js
// src/store/order.js
import {getTradeList} from "@/api/order";
const state = {
  tradeInfo:{
    orderDetailVoList:[ { orderDetailList:[] } ]
  }
}
const mutations = {
  SAVE_TRADE_INFO(state,payload){ state.tradeInfo = payload; }
}
const actions = {
  async getTradeListAsync({commit}){
    const result = await getTradeList();
    commit("SAVE_TRADE_INFO",result.data);
    console.log(result);
  }
}
export default { namespaced:true, actions, state, mutations }
js
// src/store/index.js
import Vue from "vue";
import Vuex from "vuex";
import order from "@/store/order";
Vue.use(Vuex);
const store = new Vuex.Store({ modules:{ order } });
export default store;

路由与权限:交易页不能裸奔 ​

为什么需要:交易页涉及订单和地址,未登录不应进入;从购物车点“结算”跳转,登录成功后还要回到交易页。怎么想:不要在每个组件 mounted 里写权限,放到全局路由前置守卫 beforeEach。怎么做:转写稿中的做法是给需要登录的路由在 meta 加标识,守卫里判断是否有 token;有 token 放行,没有就跳登录,并带上原本要去的路径。登录页成功后读取回跳地址,有回跳地址就 push 回去,否则回首页。易错点:登录后回跳地址别丢;不要在组件里重复写权限判断;meta 标识名要和守卫判断一致。

地址列表:默认地址与点击切换 ​

为什么需要:地址接口返回数组,默认地址只能有一个,页面要显示“默认地址”并让下方寄送信息跟着切换。怎么想:Vuex 存 addressList;getter 用 find(v=>v.isDefault==='1') 找默认地址;初次没有数据时 find 返回 undefined,所以加 || {}。怎么做:模板 v-for 地址列表,用 item.isDefault==='1' 控制“默认地址”和选中样式;点击时提交 user/CHANGE_ADDRESS_DEFAULT_BY_ID,按 item.id 改默认项。注意:笔记模板里提交了这个 mutation,但笔记 store/user.js 未给出它的实现,转写稿中补了“按 ID 改默认地址”的逻辑,实际项目要补上,否则点击无效。易错点:isDefault 是字符串 '1',不要写成数字 1;不要直接改 state;下方寄送信息要用 getter 的 addressDefault,否则点击后不联动。

交易页组件与商品清单渲染 ​

为什么需要:交易信息结构较深,商品在 tradeInfo.orderDetailVoList[0].orderDetailList,初始值不构造好会报 undefined[0]。怎么想:order 模块 state 初始值写成嵌套对象;页面 mounted 依次 dispatch 地址和交易信息;金额用 currency(2,"¥") 过滤器。怎么做:保留下面组件代码,图片 alt 改为中文“商品图片”。

vue
<template>
  <div>
    <h3 class="title">填写并核对订单信息</h3>
    <div class="content">
      <h5 class="receive">收件人信息</h5>
      <div @click="$store.commit('user/CHANGE_ADDRESS_DEFAULT_BY_ID',item.id)" v-for="item in $store.state.user.addressList" :key="item.id" class="address clearFix">
        <span :class="'username'+ (item.isDefault==='1'?' selected':'')">{{item.consignee}}</span>
        <p>
          <span class="s1">{{item.fullAddress}}</span>
          <span class="s2">{{item.phoneNum}}</span>
          <span v-show="item.isDefault==='1'" class="s3">默认地址</span>
        </p>
      </div>
      <div class="line"></div>
      <h5 class="pay">支付方式</h5>
      <div class="address clearFix">
        <span class="username selected">在线支付</span>
        <span class="username" style="margin-left:5px;">货到付款</span>
      </div>
      <div class="line"></div>
      <h5 class="pay">送货清单</h5>
      <div class="way">
        <h5>配送方式</h5>
        <div class="info clearFix">
          <span class="s1">天天快递</span>
          <p>配送时间:预计8月10日(周三)09:00-15:00送达</p>
        </div>
      </div>
      <div class="detail">
        <h5>商品清单</h5>
        <ul v-for="item in $store.state.order.tradeInfo.orderDetailVoList[0].orderDetailList" class="list clearFix">
          <li><img width="82" :src="item.imgUrl" alt="商品图片"></li>
          <li><p>{{item.skuName}}</p><h4>7天无理由退货</h4></li>
          <li><h3>{{item.orderPrice | currency(2,"¥")}}</h3></li>
          <li>X{{item.skuNum}}</li>
          <li>有货</li>
        </ul>
      </div>
      <div class="bbs">
        <h5>买家留言:</h5>
        <textarea placeholder="建议留言前先与商家沟通确认" class="remarks-cont"></textarea>
      </div>
      <div class="line"></div>
      <div class="bill">
        <h5>发票信息:</h5>
        <div>普通发票(电子) 个人 明细</div>
        <h5>使用优惠/抵用</h5>
      </div>
    </div>
    <div class="money clearFix">
      <ul>
        <li><b><i>{{$store.state.order.tradeInfo.totalNum}}</i>件商品,总商品金额</b><span>{{$store.state.order.tradeInfo.totalAmount | currency(2,"¥")}}</span></li>
        <li><b>返现:</b><span>0.00</span></li>
        <li><b>运费:</b><span>0.00</span></li>
      </ul>
    </div>
    <div class="trade">
      <div class="price">应付金额: <span>{{$store.state.order.tradeInfo.totalAmount | currency(2,"¥")}}</span></div>
      <div class="receiveInfo">
        寄送至:<span>{{addressDefault.userAddress}}</span>
        收货人:<span>{{addressDefault.consignee}}</span>
        <span>{{addressDefault.phoneNum}}</span>
      </div>
    </div>
    <div class="sub clearFix"><a href="##" class="subBtn">提交订单</a></div>
  </div>
</template>
<script>
import {mapGetters} from "vuex";
export default {
  name: "Trade",
  computed:mapGetters("user",["addressDefault"]),
  async mounted(){
    await this.$store.dispatch("user/findUserAddressListAsync");
    await this.$store.dispatch("order/getTradeListAsync");
  }
}
</script>

调试顺序与易错点 ​

转写稿里的调试顺序是:先调接口,在 Network 看 data 是否返回数组;再 commit 保存;在 Vuex 面板看 state 是否变化。地址不显示时检查 isDefault==='1';下方寄送信息不联动时补 getter 并 mapGetters;商品清单报错时检查 state 初始值,必须给 orderDetailVoList:[{orderDetailList:[]}],否则 orderDetailVoList[0] 会因 undefined 报错。图片不显示先看路径,再设 width="82"。金额显示用 currency(2,"¥")。易错点还有:接口返回是 result.data 还是解构 data;mutation 名拼写;commit 路径带命名空间;v-for 加 key;find 无结果时用 || {} 兜底。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:路由前置守卫 —— 解决刷新丢失个人信息 + 交易页权限验证 ​

js
// src/router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import store from "@/store";
import { getToken } from "@/utils/token";
import Trade from "@/pages/Trade";

Vue.use(VueRouter);

const routes = [
  { path: "/", component: () => import("@/pages/Home") },
  { path: "/login", component: () => import("@/pages/Login") },
  { path: "/cart", component: () => import("@/pages/Cart") },
  {
    path: "/trade",
    component: Trade,
    meta: {
      isAuth: true // 标记该路由需要登录
    }
  }
];

const router = new VueRouter({ routes });

router.beforeEach(async function (to, from, next) {
  // 1. 有 token 但仓库里没有用户信息 → 重新拉取(解决刷新丢失)
  if (getToken() && !store.state.user.userInfo)
    await store.dispatch("user/getUserInfoAsync");

  // 2. 判断路由是否需要登陆
  if (to.meta.isAuth) {
    if (getToken()) next();
    else
      next({
        path: "/login",
        query: {
          cb: to.path // 记住想去哪,登录后回跳
        }
      });
  } else {
    next();
  }
});

export default router;
js
// src/store/user.js —— 登录成功后回跳 cb
async postLoginAsync({ dispatch }, body) {
  const { code, data } = await postLogin(body);
  if (code === 200) {
    Message.success("恭喜您,登陆成功!");
    saveToken(data.token);
    const { cb } = router.history.current.query;
    if (cb) router.push(cb);
    else router.push("/"); // 跳转至首页
  } else {
    Message.error("账号或密码错误!");
  }
}

关键行与易错点

  • if (getToken() && !store.state.user.userInfo) 两个条件缺一不可:没有 token 去拉信息必然 208;已有 userInfo 还拉就是每次切路由都发请求。
  • await 必须写:不等待,next() 会先放行,页面渲染时 userInfo 还是空的。
  • 这段逻辑不能放进 mounted:切换路由时组件若被复用不会重新执行 mounted,所以只能放全局守卫(或 watch $route,但更乱)。
  • to.meta.isAuth 只对声明了该 meta 的路由生效,else 分支里必须 next(),否则其他页面白屏。
  • 跳登录时带 query.cb = to.path,登录成功后从 router.history.current.query 取 cb 回跳,这是"登录后回到结算页"的关键。

示例二:响应拦截器处理 token 过期(208)+ 退出登录 ​

js
// src/request/sphRequest.js
import axios from "axios";
import nprogress from "nprogress";
import store from "@/store";

const sphRequest = axios.create({
  baseURL: "/api",
  timeout: 5000
});

sphRequest.interceptors.response.use(
  response => {
    nprogress.done(); // 结束进度条
    // token 异常:隐私接口未登录/过期统一返回 208
    if (response.data.code === 208) {
      // 清空个人信息与 token,并跳转登录
      store.commit("user/OUT_LOG");
      return new Promise(() => {}); // 阻断后续 then,避免拿到脏数据
    }
    return response.data; // 返回响应体
  },
  err => {
    nprogress.done(); // 结束进度条
    alert(err);
    return new Promise(() => {}); // 返回状态为 pending 的 Promise
  }
);

export default sphRequest;
js
// src/api/user.js
import { sphRequest } from "@/request";
// 退出登陆 /api/user/passport/logout
export const getLogout = () => sphRequest("/user/passport/logout");
js
// src/store/user.js
import router from "@/router";
import { getLogout } from "@/api/user";
import { removeToken } from "@/utils/token";

const mutations = {
  OUT_LOG(state) {
    state.userInfo = null;
    removeToken();
    router.push("/login");
  }
};

const actions = {
  async getLogOutAsync({ commit }) {
    await getLogout();
    commit("OUT_LOG");
  }
};

export default {
  namespaced: true,
  actions,
  state,
  mutations
};

关键行与易错点

  • response.data.code === 208 判断的是业务状态码,不是 HTTP 状态码,所以写在 use(onFulfilled) 里而不是 onRejected。
  • return new Promise(() => {}) 返回一个永远 pending 的 Promise,让调用方的 then/await 不再往下走,避免用空数据继续渲染。返回 undefined 或 Promise.reject() 都不合适(前者报 Cannot read property 'data' of undefined,后者会再触发一次错误流程)。
  • OUT_LOG 同时做三件事:清 userInfo、removeToken、跳登录。退出登录和 token 过期复用同一个 mutation,行为一致,不要写两份。
  • 拦截器里用 store.commit("user/OUT_LOG"),命名空间 user/ 前缀不能丢,否则 mutation 找不到,控制台静默失败。
  • import store from "@/store" 与 store/index.js 里的 router 容易形成循环依赖,实操中把 router 的引入放在 store 之后、并确认 OUT_LOG 里才用 router 即可。

示例三:交易页渲染(地址列表 + 商品清单 + 默认地址) ​

js
// src/store/order.js
import { getTradeList } from "@/api/order";

const state = {
  tradeInfo: {
    orderDetailVoList: [
      {
        orderDetailList: []
      }
    ]
  }
};

const mutations = {
  SAVE_TRADE_INFO(state, payload) {
    state.tradeInfo = payload;
  }
};

const actions = {
  async getTradeListAsync({ commit }) {
    const result = await getTradeList();
    commit("SAVE_TRADE_INFO", result.data);
    console.log(result);
  }
};

export default {
  namespaced: true,
  actions,
  state,
  mutations
};
js
// src/store/user.js —— 地址列表 + 默认地址 getter
import { findUserAddressList } from "@/api/user";

const state = {
  addressList: []
};

const mutations = {
  SAVE_ADDRESS_LIST(state, payload) {
    state.addressList = payload;
  },
  CHANGE_ADDRESS_DEFAULT_BY_ID(state, id) {
    state.addressList = state.addressList.map(item => {
      if (item.id === id) item.isDefault = "1";
      else item.isDefault = "0";
      return item;
    });
  }
};

const actions = {
  async findUserAddressListAsync({ commit }) {
    const { data } = await findUserAddressList();
    commit("SAVE_ADDRESS_LIST", data);
  }
};

const getters = {
  addressDefault(state) {
    return state.addressList.find(v => v.isDefault === "1") || {};
  }
};

export default {
  namespaced: true,
  actions,
  state,
  mutations,
  getters
};
vue
<!-- src/pages/Trade/index.vue -->
<template>
  <div>
    <h3 class="title">填写并核对订单信息</h3>
    <div class="content">
      <h5 class="receive">收件人信息</h5>
      <div
        @click="$store.commit('user/CHANGE_ADDRESS_DEFAULT_BY_ID', item.id)"
        v-for="item in $store.state.user.addressList"
        :key="item.id"
        class="address clearFix"
      >
        <span :class="'username' + (item.isDefault === '1' ? ' selected' : '')">
          {{ item.consignee }}
        </span>
        <p>
          <span class="s1">{{ item.fullAddress }}</span>
          <span class="s2">{{ item.phoneNum }}</span>
          <span v-show="item.isDefault === '1'" class="s3">默认地址</span>
        </p>
      </div>

      <div class="line"></div>
      <h5 class="pay">送货清单</h5>
      <div class="detail">
        <h5>商品清单</h5>
        <ul
          v-for="item in $store.state.order.tradeInfo.orderDetailVoList[0].orderDetailList"
          :key="item.id"
          class="list clearFix"
        >
          <li><img width="82" :src="item.imgUrl" alt="" /></li>
          <li>
            <p>{{ item.skuName }}</p>
            <h4>7天无理由退货</h4>
          </li>
          <li><h3>{{ item.orderPrice | currency(2, "¥") }}</h3></li>
          <li>X{{ item.skuNum }}</li>
          <li>有货</li>
        </ul>
      </div>
    </div>

    <div class="money clearFix">
      <ul>
        <li>
          <b><i>{{ $store.state.order.tradeInfo.totalNum }}</i>件商品,总商品金额</b>
          <span>{{ $store.state.order.tradeInfo.totalAmount | currency(2, "¥") }}</span>
        </li>
      </ul>
    </div>

    <div class="trade">
      <div class="price">
        应付金额: <span>{{ $store.state.order.tradeInfo.totalAmount | currency(2, "¥") }}</span>
      </div>
      <div class="receiveInfo">
        寄送至:
        <span>{{ addressDefault.userAddress }}</span>
        收货人:<span>{{ addressDefault.consignee }}</span>
        <span>{{ addressDefault.phoneNum }}</span>
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from "vuex";

export default {
  name: "Trade",
  computed: mapGetters("user", ["addressDefault"]),
  async mounted() {
    await this.$store.dispatch("user/findUserAddressListAsync");
    await this.$store.dispatch("order/getTradeListAsync");
  }
};
</script>

关键行与易错点

  • tradeInfo.orderDetailVoList[0].orderDetailList 是两层数组:orderDetailVoList 是"包裹/商家"维度,[0] 才是商品明细。直接写 orderDetailVoList.orderDetailList 拿不到数据。
  • 报 Cannot read property '0' of undefined 的原因:初始 state 里没有把 orderDetailVoList 预置成 [{ orderDetailList: [] }],undefined[0] 直接抛错。初始值必须把整条链路上的空壳补齐。
  • getters 里 find(...) 查不到会返回 undefined,undefined.userAddress 会报错,所以 || {} 兜底;find 返回的是元素,filter 返回的是数组,这里必须用 find。
  • mapGetters("user", ["addressDefault"]) 的命名空间参数不能省,user 模块是 namespaced: true。
  • 切换默认地址用 map + 返回 item 的方式整体替换,直接改原数组元素 item.isDefault 在某些场景下不会触发视图更新;改完必须保证只有一个 isDefault === '1'。
  • 接口返回的 isDefault 是字符串 '1',用 === '1' 判断,写成 === 1 永远不成立。
  • currency 是全局过滤器(main.js 中 Vue.filter 注册),模板里不写 this.。

常见错误 ​

  1. 现象:刷新页面后 userInfo 丢失,头像/用户名不显示。
    原因:把获取个人信息放在某个组件的 mounted 里,切换路由时组件不重新渲染,刷新时也没有统一入口恢复登录态。
    修法:在 router.beforeEach 中判断 getToken() && !store.state.user.userInfo,await store.dispatch("user/getUserInfoAsync") 后再 next()。

  2. 现象:只有某个接口遇到 code === 208 时跳登录,其他隐私接口异常时页面卡住或继续渲染。
    原因:在单个接口/组件里判断 token 异常,没有统一处理。
    修法:在 sphRequest 响应拦截器中统一判断 response.data.code === 208,执行 store.commit("user/OUT_LOG"),并返回 new Promise(() => {}) 阻断后续逻辑。

  3. 现象:退出登录后仍能继续执行后续请求/跳转,或清空信息与跳转分散在多处。
    原因:退出逻辑没有统一封装,OUT_LOG 后仍返回有效数据或继续走正常流程。
    修法:把清 userInfo、removeToken()、router.push("/login") 统一放进 OUT_LOG;退出 action 调接口后 commit("OUT_LOG"),异常拦截处返回 pending Promise。

  4. 现象:未登录也能直接进入 /trade,或登录后没有回到原本要去的交易页。
    原因:路由 meta.isAuth 写了但守卫没判断;登录成功固定 router.push("/"),没有读取 cb。
    修法:守卫中判断 to.meta.isAuth,有 token 放行,否则 next({ path: "/login", query: { cb: to.path } });登录成功后读取 router.history.current.query.cb,有则回跳,否则回首页。

  5. 现象:交易页地址列表全部显示“默认地址”,或点击地址后底部寄送信息不变。
    原因:selected 写死;默认地址没有按 isDefault === '1' 判断;底部信息没有使用随默认地址变化的 getter。
    修法:用 :class 按 isDefault 拼接 selected;点击时 commit("user/CHANGE_ADDRESS_DEFAULT_BY_ID", item.id);底部使用 mapGetters("user", ["addressDefault"])。

  6. 现象:addressDefault 报错,提示无法读取 undefined 的属性。
    原因:addressList.find(...) 首次没有数据时返回 undefined,后续直接取 userAddress、consignee 等属性。
    修法:getter 中返回 state.addressList.find(v => v.isDefault === '1') || {}。

  7. 现象:交易页商品清单渲染时报错,提示不能读取 orderDetailList 或 [0]。
    原因:tradeInfo 初始结构不完整,orderDetailVoList[0] 或 orderDetailList 为 undefined 时直接访问。
    修法:给 tradeInfo 设置完整初始结构,例如 orderDetailVoList: [{ orderDetailList: [] }],再渲染嵌套数据。

练习 ​

  1. 实现刷新恢复登录态:在全局前置守卫中恢复 userInfo,并验证切换路由时不会重复请求个人信息。
  2. 在响应拦截器中统一处理 code === 208:清空登录态、提示“请重新登录”、跳转登录页,并阻断后续业务逻辑。
  3. 完成退出登录闭环:调用退出接口、清空 userInfo、移除 token、跳转登录页,并保证退出后不能再进入需要权限的页面。
  4. 给 /trade 增加 meta.isAuth 权限验证;未登录进入时跳登录并携带 cb,登录成功后回到 /trade。
  5. 完成交易页渲染:地址列表默认选中、点击切换默认地址、底部寄送信息联动;商品清单、总数量、总金额正常渲染,并处理初始空数据不报错。

原视频对照 ​

#视频时长
1解决刷新个人信息丢失08:24
2token过期或异常问题06:44
3退出登陆08:11
4交易页路由搭建及权限验证23:17
5完成地址的渲染21:48
6完成交易商品渲染21:47

小结 ​

  1. 登录态恢复应放在全局路由守卫,不要依赖某个业务组件的 mounted。
  2. token 异常处理应集中在响应拦截器,避免每个接口重复判断 208。
  3. 退出登录要作为一个统一动作封装,清信息、清 token、跳登录不可分散。
  4. 权限路由必须同时具备 meta.isAuth 标记和守卫判断,缺一不可。
  5. 登录回跳依赖 cb 参数形成闭环:守卫写入、登录页读取、登录成功后消费。
  6. 交易页默认地址建议用 getter 派生,点击切换用 mutation 按 id 修改,保证列表与底部信息联动。
  7. 复杂交易数据必须给足初始结构,尤其是 orderDetailVoList[0].orderDetailList 这类嵌套字段。
  8. 页面接口调用后要 commit 保存到对应模块,渲染层只消费 store 中的稳定结构。