Skip to content

14 智慧商城 登录和首页

项目资料

登录页 loginPage

  • 页面:views/login/index.vue

静态布局

  • 编写静态结构代码。

NavBar 导航栏 | Vant 2

  1. utils/vant-ui.js 中引入 TabbarTabbarItem 组件。
  2. views/login/index.vue 中使用 NavBar 组件。
  3. 添加通用样式:styles/common.less 设置导航条,返回箭头颜色

示例代码

js
import Vue from 'vue'

import { NavBar, Tabbar, TabbarItem } from 'vant'

Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
vue
<script>
import './index.less'

export default {
  name: 'LoginPage',
  data() {
    return {}
  },
}
</script>

<template>
  <!--  <div> -->
  <!--    login页面 -->
  <!--  </div> -->
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
        </div>
        <div class="form-item">
          <input class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img alt="" src="@/assets/code.png">
        </div>
        <div class="form-item">
          <input class="inp" placeholder="请输入短信验证码" type="text">
          <button>获取验证码</button>
        </div>
      </div>

      <div class="login-btn">
        登录
      </div>
    </div>
  </div>
</template>
less
// 重置默认样式
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

// 文字溢出省略号
.text-ellipsis-2 {
  overflow: hidden;
  -webkit-line-clamp: 2;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-box-orient: vertical;
}

// 添加导航的通用样式
.van-nav-bar {
  .van-nav-bar__arrow {
    color: #333;
  }
}

二次封装 axios 为模块

  • 我们会使用 axios 来请求后端接口, 一般都会对 axios 进行一些配置 (比如:配置基础地址,请求响应拦截器等等)
  • 一般项目开发中,都会对 axios 进行基本的二次封装, 单独封装到一个模块中,便于维护使用
  1. 安装 axios 依赖 pnpm add axios
  2. 新建 utils/request.js 封装 request 模块。
    • 导入 axios 模块。
    • 创建实例。
    • 添加请求/响应拦截器。在请求或响应被 then 或 catch 处理前拦截它们。
    • 导出实例。

图形验证码功能

基于请求回来的 base64 图片,实现图形验证码功能。

  1. 查看接口文档 获取图形验证码,了解接口的请求方式和参数。
  2. 使用 request 模块发送请求,获取图形验证码。
    • 图形验证码,本质就是一个请求回来的图片
    • 用户将来输入图形验证码,用于强制人机交互,可以抵御机器自动化攻击 (例如:避免批量请求获取短信)
  3. views/login/index.vue 中动态渲染图形验证码,并且点击时要重新刷新验证码。
    • 动态将请求回来的 base64 图片,解析渲染出来
    • 点击验证码图片盒子,要刷新验证码

示例代码

js
import axios from 'axios'

// 创建 axios 实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的 axios 实例
const instance = axios.create({
  baseURL: 'http://cba.itlike.com/public/index.php?s=/api/',
  timeout: 5000,
  headers: {
    'platform': 'H5',
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
  },
})

// 在请求或响应被 then 或 catch 处理前拦截它们
// 添加请求拦截器
instance.interceptors.request.use((config) => {
  // 在发送请求之前做些什么
  return config
}, (error) => {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 添加响应拦截器
instance.interceptors.response.use((response) => {
  // 2xx 范围内的状态码都会触发该函数。
  // 对响应数据做点什么
  return response.data
}, (error) => {
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么
  return Promise.reject(error)
})

// 导出自定义配置的 axios 实例
export default instance
vue
<script>
import './index.less'
import Request from '@/utils/request'

export default {
  name: 'LoginPage',
  data() {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picString: '', // 存储请求渲染的图片地址
    }
  },
  async created() {
    await this.getCaptchaImage()
  },
  methods: {
    // 获取图形验证码
    async getCaptchaImage() {
      const { data: { key, base64 } } = await Request.get('/captcha/image')
      this.picKey = key
      this.picString = base64
    },
  },
}
</script>

<template>
  <!--  <div> -->
  <!--    login页面 -->
  <!--  </div> -->
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
        </div>
        <div class="form-item">
          <input class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picString" :src="picString" alt="" referrerpolicy="no-referrer" @click="getCaptchaImage">
        </div>
        <div class="form-item">
          <input class="inp" placeholder="请输入短信验证码" type="text">
          <button>获取验证码</button>
        </div>
      </div>

      <div class="login-btn">
        登录
      </div>
    </div>
  </div>
</template>

封装 api 接口

将请求封装成方法,统一存放到 api 模块,与页面分离

  • 以前的模式:

    c7dc9b39-7ccc-4599-8160-ff65e5e97c5a

    • 页面中充斥着请求代码

    • 可阅读性不高

    • 相同的请求没有复用请求没有统一管理

  • 期望:

    e74c28a8-a254-40da-8333-d411376b0356

    • 请求与页面逻辑分离
    • 相同的请求可以直接复用请求
    • 进行了统一管理
  • 步骤:

    • 新建请求模块
    • 封装请求函数
    • 页面中导入调用

封装上一步图形验证码 Api 接口:

  1. 新建 api/login.js 提供获取图形验证码 Api 函数
  2. login/index.vue页面中调用

示例代码

js
// 此处用于存放所有登录相关的接口请求
import Request from '@/utils/request'

// 获取图形验证码
export const getCaptchaImage = () => Request.get('/captcha/image')
vue
<script>
import './index.less'
import { getCaptchaImage } from '@/api/login'

export default {
  name: 'LoginPage',
  data() {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picString: '', // 存储请求渲染的图片地址
    }
  },
  async created() {
    await this.getCaptchaImage()
  },
  methods: {
    async getCaptchaImage() {
      const { data: { key, base64 } } = await getCaptchaImage()
      this.picKey = key
      this.picString = base64
    },
  },
}
</script>

<template>
  <!--  <div> -->
  <!--    login页面 -->
  <!--  </div> -->
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
        </div>
        <div class="form-item">
          <input class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picString" :src="picString" alt="" referrerpolicy="no-referrer" @click="getCaptchaImage">
        </div>
        <div class="form-item">
          <input class="inp" placeholder="请输入短信验证码" type="text">
          <button>获取验证码</button>
        </div>
      </div>

      <div class="login-btn">
        登录
      </div>
    </div>
  </div>
</template>

Toast 轻提示组件

Toast 轻提示 | Vant 2

在页面中间弹出黑色半透明提示,用于消息通知、加载提示、操作结果提示等场景。

  • 注册安装
    js
    import { Toast } from 'vant'
    Vue.use(Toast)
  • 使用
    • 导入调用 ( 组件内非组件中均可 )
      js
      import { Toast } from 'vant'
      Toast('提示内容')
    • 通过this直接调用 ( 组件内)。引入 Toast 组件后,会自动在 Vue 的 prototype 上挂载 $toast 方法,便于在组件内调用。
      js
      this.$toast('提示内容')

  1. utils/vant-ui.js 中引入 Toast 组件。
  2. views/login/index.vue 中使用 Toast 组件。

短信验证和倒计时功能

获取短信验证码和倒计时功能。

  1. 查看接口文档 获取短信验证码,了解接口的请求方式和参数。
  2. 点击获取验证码按钮后,在发送请求之前,需要对手机号和图形验证码进行校验。
    • 手机号校验:正则校验 (!/^1[3-9]\d{9}$/.test(this.$refs.phone.value))
    • 图形验证码校验:非空校验 (!this.$refs.captcha.value) 格式校验 (!/^\w{4}$/.test(this.$refs.captcha.value)) 或者 (!/^[a-zA-Z0-9]{4}$/.test(this.$refs.captcha.value))
  3. 校验通过后,发送请求获取短信验证码。
  4. 请求成功后,按钮变为不可点击状态,同时开始倒计时,按钮内容变为 "xxs 后重新获取"
  5. 倒计时结束后,按钮恢复可点击状态,按钮内容恢复为 "获取验证码"
  6. 如果在倒计时过程中,用户再次点击获取验证码按钮,需要提示用户等待倒计时结束 无需提示,按钮不可点击即可
  7. 如果用户在倒计时过程中,离开了当前页面,需要清除倒计时

示例代码

js
import Vue from 'vue'

import { NavBar, Tabbar, TabbarItem, Toast } from 'vant'

Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
js
// 此处用于存放所有登录相关的接口请求
import Request from '@/utils/request'

// 获取图形验证码
export const getCaptchaImage = () => Request.get('/captcha/image')

// 获取短信验证码
export const getSmsCode = (captchaCode, captchaKey, mobile) => Request.post('/captcha/sendSmsCaptcha', { form: { captchaCode, captchaKey, mobile } })
vue
<script>
import './index.less'
import { getCaptchaImage, getSmsCode } from '@/api/login'

export default {
  name: 'LoginPage',
  data() {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picString: '', // 存储请求渲染的图片地址
      isButtonDisabled: false, // 按钮禁用状态(默认为 false,即可点击状态)
      timer: null, // 倒计时的定时器 id
      buttonText: '获取验证码', // 按钮文字
    }
  },
  async created() {
    await this.getCaptchaImage()
  },
  beforeUnmount() {
    if (this.timer)
      clearInterval(this.timer)
  },
  methods: {
    // 获取图形验证码
    async getCaptchaImage() {
      const { data: { key, base64 } } = await getCaptchaImage()
      this.picKey = key
      this.picString = base64
    },

    // 校验 手机号 和 图形验证码 是否合法
    // 通过为 true,不通过为 false
    validatePhoneAndCaptcha() {
      // 校验手机号
      if (!/^1[3-9]\d{9}$/.test(this.$refs.phone.value)) {
        this.$toast('手机号格式不正确')
        return false
      }
      // 校验图形验证码
      if (!this.$refs.captcha.value) {
        this.$toast('图形验证码不能为空')
        return false
      }
      // 校验图形验证码是否正确
      if (!/^[a-zA-Z0-9]{4}$/.test(this.$refs.captcha.value)) {
        this.$toast('图形验证码格式不正确')
        return false
      }
      return true
    },

    // 获取短信验证码 和 添加倒计时功能
    // 倒计时功能:点击获取验证码后,按钮变为不可点击状态,同时开始倒计时,倒计时结束后,按钮恢复可点击状态
    // 1. 点击获取验证码按钮,发送请求获取短信验证码
    // 2. 请求成功后,按钮变为不可点击状态,同时开始倒计时,button 内容变为 "重新获取(60)s"
    // 3. 倒计时结束后,按钮恢复可点击状态
    // 4. ~~如果在倒计时过程中,用户再次点击获取验证码按钮,需要提示用户等待倒计时结束~~ // 无需提示,按钮不可点击即可
    // 5. 如果用户在倒计时过程中,离开了当前页面,需要清除倒计时
    async getSmsCode() {
      // 校验手机号和图形验证码
      if (!this.validatePhoneAndCaptcha())
        return false

      // 如果倒计时还在进行
      // if (this.timer) {
      //   this.$toast('请等待倒计时结束')
      //   return
      // }

      try {
        const { message } = await getSmsCode(this.$refs.captcha.value, this.picKey, this.$refs.phone.value)
        this.$toast(message)
        this.isButtonDisabled = true
        this.countdown = 10
        this.timer = setInterval(() => {
          this.countdown--
          this.buttonText = `${this.countdown}s 后重新获取`
          if (this.countdown === 0) {
            // 倒计时结束, 清除定时器, 按钮禁用状态解除,按钮文字恢复
            clearInterval(this.timer)
            // this.timer = null
            this.isButtonDisabled = false
            this.buttonText = '获取验证码'
          }
        }, 1000)
      }
      catch (error) {
        this.$toast('获取短信验证码失败')
      }
    },
  },
}
</script>

<template>
  <!--  <div> -->
  <!--    login页面 -->
  <!--  </div> -->
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input ref="phone" class="inp" maxlength="11" placeholder="请输入手机号码" type="text" value="19868686868">
        </div>
        <div class="form-item">
          <input ref="captcha" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picString" :src="picString" alt="" referrerpolicy="no-referrer" @click="getCaptchaImage">
        </div>
        <div class="form-item">
          <input class="inp" placeholder="请输入短信验证码" type="text">
          <button :disabled="isButtonDisabled" @click="getSmsCode">
            {{ buttonText }}
          </button>
        </div>
      </div>

      <div class="login-btn">
        登录
      </div>
    </div>
  </div>
</template>

登录功能

封装 api 登录接口,实现登录功能

  1. 查看接口文档 手机验证码登录封装登录接口
  2. 登录前的校验 (手机号,图形验证码,短信验证码)
  3. 调用方法,发送请求,成功添加提示并跳转

示例代码

js
// 此处用于存放所有登录相关的接口请求
import Request from '@/utils/request'

// 获取图形验证码
export const getCaptchaImage = () => Request.get('/captcha/image')

/**
 * 获取短信验证码
 * @param captchaCode 图形验证码
 * @param captchaKey 图形验证码的 key
 * @param mobile 接收验证码手机
 * @returns {Promise} 返回一个 Promise
 */
export const getSmsCode = (captchaCode, captchaKey, mobile) => Request.post('/captcha/sendSmsCaptcha', { form: { captchaCode, captchaKey, mobile } })

/**
 * 登录 login
 * @param mobile 手机号
 * @param smsCode 短信验证码, 测试环境验证码为:246810
 * @returns {Promise} 返回一个 Promise
 */
export const login = (mobile, smsCode) => Request.post('/passport/login', { form: { isParty: false, mobile, partyData: {}, smsCode } })
vue
<script>
import './index.less'
import { getCaptchaImage, getSmsCode, login } from '@/api/login'

export default {
  name: 'LoginPage',
  data() {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picString: '', // 存储请求渲染的图片地址
      isButtonDisabled: false, // 按钮禁用状态(默认为 false,即可点击状态)
      timer: null, // 倒计时的定时器 id
      buttonText: '获取验证码', // 按钮文字
    }
  },
  async created() {
    await this.getCaptchaImage()
  },
  beforeUnmount() {
    if (this.timer)
      clearInterval(this.timer)
  },
  methods: {
    // 获取图形验证码
    async getCaptchaImage() {
      const { data: { key, base64 } } = await getCaptchaImage()
      this.picKey = key
      this.picString = base64
    },

    // 校验 手机号 和 图形验证码 是否合法
    // 通过为 true,不通过为 false
    validatePhoneAndCaptcha() {
      // 校验手机号
      if (!/^1[3-9]\d{9}$/.test(this.$refs.phone.value)) {
        this.$toast('手机号格式不正确')
        return false
      }
      // 校验图形验证码
      if (!this.$refs.captcha.value) {
        this.$toast('图形验证码不能为空')
        return false
      }
      // 校验图形验证码是否正确
      if (!/^[a-zA-Z0-9]{4}$/.test(this.$refs.captcha.value)) {
        this.$toast('图形验证码格式不正确')
        return false
      }
      return true
    },

    // 获取短信验证码 和 添加倒计时功能
    // 倒计时功能:点击获取验证码后,按钮变为不可点击状态,同时开始倒计时,倒计时结束后,按钮恢复可点击状态
    // 1. 点击获取验证码按钮,发送请求获取短信验证码
    // 2. 请求成功后,按钮变为不可点击状态,同时开始倒计时,button 内容变为 "重新获取 (60)s"
    // 3. 倒计时结束后,按钮恢复可点击状态
    // 4. ~~如果在倒计时过程中,用户再次点击获取验证码按钮,需要提示用户等待倒计时结束~~ // 无需提示,按钮不可点击即可
    // 5. 如果用户在倒计时过程中,离开了当前页面,需要清除倒计时
    async getSmsCode() {
      // 校验手机号和图形验证码
      if (!this.validatePhoneAndCaptcha())
        return false

      // 如果倒计时还在进行
      // if (this.timer) {
      //   this.$toast('请等待倒计时结束')
      //   return
      // }

      try {
        const { message } = await getSmsCode(this.$refs.captcha.value, this.picKey, this.$refs.phone.value)
        this.$toast(message)
        this.isButtonDisabled = true
        this.countdown = 10
        this.timer = setInterval(() => {
          this.countdown--
          this.buttonText = `${this.countdown}s 后重新获取`
          if (this.countdown === 0) {
            // 倒计时结束,清除定时器,按钮禁用状态解除,按钮文字恢复
            clearInterval(this.timer)
            // this.timer = null
            this.isButtonDisabled = false
            this.buttonText = '获取验证码'
          }
        }, 1000)
      }
      catch (error) {
        this.$toast('获取短信验证码失败')
      }
    },

    // 登录
    async login() {
      // 校验手机号和图形验证码
      if (!this.validatePhoneAndCaptcha())
        return false

      // 校验短信验证码
      if (!this.$refs.smsCode.value) {
        this.$toast('短信验证码不能为空')
        return false
      }

      // 登录逻辑
      try {
        const { data: { userId, token }, message } = await login(this.$refs.phone.value, this.$refs.smsCode.value)
        this.$toast(message)
        console.log(userId, token)
        // 登录成功后跳转到首页
        await this.$router.push('/')
      }
      catch (error) {
        this.$toast('登录失败')
      }
    },
  },
}
</script>

<template>
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input ref="phone" class="inp" maxlength="11" placeholder="请输入手机号码" type="text" value="19868686868">
        </div>
        <div class="form-item">
          <input ref="captcha" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picString" :src="picString" alt="" referrerpolicy="no-referrer" @click="getCaptchaImage">
        </div>
        <div class="form-item">
          <input ref="smsCode" class="inp" placeholder="请输入短信验证码" type="text">
          <button :disabled="isButtonDisabled" @click="getSmsCode">
            {{ buttonText }}
          </button>
        </div>
      </div>

      <div class="login-btn" @click="login">
        登录
      </div>
    </div>
  </div>
</template>

响应拦截器统一处理错误提示

  • 响应拦截器统一处理错误提示
  • 目标:通过响应拦截器,统一处理接口的错误提示
  • 问题:每次请求,都会有可能会错误,就都需要错误提示
  • 说明:响应拦截器是咱们拿到数据的 第一个 数据流转站,可以在里面统一处理错误。只要不是 200, 就给默认提示,抛出错误

示例代码

js
import axios from 'axios'
import { Toast } from 'vant'

// 创建 axios 实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的 axios 实例
const instance = axios.create({
  baseURL: 'http://cba.itlike.com/public/index.php?s=/api/',
  timeout: 5000,
  headers: {
    'platform': 'H5',
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
  },
})

// 在请求或响应被 then 或 catch 处理前拦截它们
// 添加请求拦截器
instance.interceptors.request.use((config) => {
  // 在发送请求之前做些什么
  return config
}, (error) => {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 添加响应拦截器
instance.interceptors.response.use((response) => {
  // 2xx 范围内的状态码都会触发该函数。
  // 对响应数据做点什么,例如:直接返回服务器的响应结果对象
  // 优化 axios 响应结果:可以让逻辑页面少点一层 data 就能拿到后端返回的真正数据对象
  const res = response.data
  // 如果响应状态码不是 200,说明后端接口出现了错误,这时候就可以在这里统一处理错误:弹出错误提示信息 Toast 和 返回一个失败的 Promise 对象
  if (res.status !== 200) {
    Toast(res.message)
    return Promise.reject(new Error(res.message || 'Error'))
  }
  return res
}, (error) => {
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么,例如:统一对 401 身份验证失败情况做出处理
  // console.dir(error);
  if (error?.response?.status === 401)
    Toast('身份验证失败,请重新登录')
  return Promise.reject(error)
})

// 导出自定义配置的 axios 实例
export default instance

将登录权证信息存入 vuex

  • 目标:vuex 构建 user 模块存储登录权证 (token & userId)
  • token 存入 vuex 的好处,易获取,响应式
  • vuex 需要分模块 => user 模块
  • 步骤:
    1. 创建 user 模块
    2. 创建 token 和 userId 状态,提供 mutations,然后挂载到 vuex 上
    3. 登录成功后,将 token 和 userId 存入 vuex

示例代码

js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'

Vue.use(Vuex)
export default new Vuex.Store({
  modules: {
    user,
  },
})
js
const state = {
  userInfo: {
    token: '',
    userId: '',
  },
}

const getters = {}

const actions = {}

const mutations = {
  setUserInfo(state, { token, userId }) {
    state.userInfo = {
      token,
      userId,
    }
  },
  clearUserInfo(state) {
    state.userInfo = {
      token: '',
      userId: '',
    }
  },
}

export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations,
}
vue
<script>
import './index.less'
import { mapMutations } from 'vuex'
import { getCaptchaImage, getSmsCode, login } from '@/api/login'

export default {
  name: 'LoginPage',
  data() {
    return {
      picKey: '', // 将来请求传递的图形验证码唯一标识
      picString: '', // 存储请求渲染的图片地址
      isButtonDisabled: false, // 按钮禁用状态(默认为 false,即可点击状态)
      timer: null, // 倒计时的定时器 id
      buttonText: '获取验证码', // 按钮文字
    }
  },
  async created() {
    await this.getCaptchaImage()
  },
  beforeUnmount() {
    if (this.timer)
      clearInterval(this.timer)
  },
  methods: {
    ...mapMutations(['user/setUserInfo', 'user/clearUserInfo']),
    // 获取图形验证码
    async getCaptchaImage() {
      const { data: { key, base64 } } = await getCaptchaImage()
      this.picKey = key
      this.picString = base64
    },

    // 校验 手机号 和 图形验证码 是否合法
    // 通过为 true,不通过为 false
    validatePhoneAndCaptcha() {
      // 校验手机号
      if (!/^1[3-9]\d{9}$/.test(this.$refs.phone.value)) {
        this.$toast('手机号格式不正确')
        return false
      }
      // 校验图形验证码
      if (!this.$refs.captcha.value) {
        this.$toast('图形验证码不能为空')
        return false
      }
      // 校验图形验证码是否正确
      if (!/^[a-zA-Z0-9]{4}$/.test(this.$refs.captcha.value)) {
        this.$toast('图形验证码格式不正确')
        return false
      }
      return true
    },

    // 获取短信验证码 和 添加倒计时功能
    // 倒计时功能:点击获取验证码后,按钮变为不可点击状态,同时开始倒计时,倒计时结束后,按钮恢复可点击状态
    // 1. 点击获取验证码按钮,发送请求获取短信验证码
    // 2. 请求成功后,按钮变为不可点击状态,同时开始倒计时,button 内容变为 "重新获取 (60)s"
    // 3. 倒计时结束后,按钮恢复可点击状态
    // 4. ~~如果在倒计时过程中,用户再次点击获取验证码按钮,需要提示用户等待倒计时结束~~ // 无需提示,按钮不可点击即可
    // 5. 如果用户在倒计时过程中,离开了当前页面,需要清除倒计时
    async getSmsCode() {
      // 校验手机号和图形验证码
      if (!this.validatePhoneAndCaptcha())
        return false

      // 如果倒计时还在进行
      // if (this.timer) {
      //   this.$toast('请等待倒计时结束')
      //   return
      // }

      try {
        const { message } = await getSmsCode(this.$refs.captcha.value, this.picKey, this.$refs.phone.value)
        this.$toast(message)
        this.isButtonDisabled = true
        this.countdown = 10
        this.timer = setInterval(() => {
          this.countdown--
          this.buttonText = `${this.countdown}s 后重新获取`
          if (this.countdown === 0) {
            // 倒计时结束,清除定时器,按钮禁用状态解除,按钮文字恢复
            clearInterval(this.timer)
            // this.timer = null
            this.isButtonDisabled = false
            this.buttonText = '获取验证码'
          }
        }, 1000)
      }
      catch (error) {
        this.$toast('获取短信验证码失败')
      }
    },

    // 登录
    async login() {
      // 校验手机号和图形验证码
      if (!this.validatePhoneAndCaptcha())
        return false

      // 校验短信验证码
      if (!this.$refs.smsCode.value) {
        this.$toast('短信验证码不能为空')
        return false
      }

      // 登录逻辑
      try {
        const { data: { userId, token }, message } = await login(this.$refs.phone.value, this.$refs.smsCode.value)
        this.$toast(message)
        // console.log(userId, token)
        // 将用户信息存储到 vuex 中
        this.$store.commit('user/setUserInfo', { userId, token })
        // 登录成功后跳转到首页
        await this.$router.push('/')
      }
      catch (error) {
        this.$toast('登录失败')
      }
    },
  },
}
</script>

<template>
  <div class="login">
    <van-nav-bar left-arrow left-text="返回" title="会员登录" @click-left="$router.back()" />
    <div class="container">
      <div class="title">
        <h3>手机号登录</h3>
        <p>未注册的手机号登录后将自动注册</p>
      </div>

      <div class="form">
        <div class="form-item">
          <input ref="phone" class="inp" maxlength="11" placeholder="请输入手机号码" type="text" value="19868686868">
        </div>
        <div class="form-item">
          <input ref="captcha" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
          <img v-if="picString" :src="picString" alt="" referrerpolicy="no-referrer" @click="getCaptchaImage">
        </div>
        <div class="form-item">
          <input ref="smsCode" class="inp" placeholder="请输入短信验证码" type="text">
          <button :disabled="isButtonDisabled" @click="getSmsCode">
            {{ buttonText }}
          </button>
        </div>
      </div>

      <div class="login-btn" @click="login">
        登录
      </div>
    </div>
  </div>
</template>

vuex 持久化处理

  • 目标:封装 storage 存储模块,利用本地存储,进行 vuex 持久化处理
  • 步骤:
    1. 新建 utils/storage.js 封装方法
    2. store/modules/user.js 中引入并使用

示例代码

js
// 设置 localStorage 的存储键名
const INFO_KEY = 'hm-shopping-userInfo'

// 获取 localStorage 中的个人信息
export const getUserInfoLocalStorage = () => JSON.parse(localStorage.getItem(INFO_KEY) || '{ "token": "", "userId": {} }')

// 设置 localStorage 中的个人信息
export const setUserInfoLocalStorage = userInfo => localStorage.setItem(INFO_KEY, JSON.stringify(userInfo))

// 删除 localStorage 中的个人信息
export const removeUserInfoLocalStorage = () => localStorage.removeItem(INFO_KEY)
js
import { getUserInfoLocalStorage, removeUserInfoLocalStorage, setUserInfoLocalStorage } from '@/utils/storage'

const state = {
  userInfo: getUserInfoLocalStorage(),
}

const getters = {}

const actions = {}

const mutations = {
  setUserInfo(state, { token, userId }) {
    state.userInfo = {
      token,
      userId,
    }
    setUserInfoLocalStorage({ token, userId })
  },
  clearUserInfo(state) {
    state.userInfo = {
      token: '',
      userId: '',
    }
    removeUserInfoLocalStorage()
  },
}

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

添加请求 loading 效果

Toast 轻提示 | Vant 2

  • 目标:统一在每次请求后台时,添加 loading 效果
  • 背景:有时候因为网络原因,一次请求的结果可能需要一段时间后才能回来,此时,需要给用户 添加 loading 提示
  • 添加 loading 提示的好处:
    • 节流处理:防止用户在一次请求还没回来之前,多次进行点击,发送无效请求
    • 友好提示:告知用户,目前是在加载中,请耐心等待,用户体验会更好
  • 步骤:
    • 使用 Toast.loading 方法展示加载提示,通过 forbidClick 属性可以禁用背景点击。
    • 请求拦截器中,每次请求,打开 loading
    • 响应拦截器中,每次响应,关闭 loading

示例代码

js
import axios from 'axios'
import { Toast } from 'vant'

// 创建 axios 实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的 axios 实例
const instance = axios.create({
  baseURL: 'http://cba.itlike.com/public/index.php?s=/api/',
  timeout: 5000,
  headers: {
    'platform': 'H5',
    'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
  },
})

// 在请求或响应被 then 或 catch 处理前拦截它们
// 添加请求拦截器
instance.interceptors.request.use((config) => {
  // 在发送请求之前做些什么
  // 使用 Toast.loading 方法展示加载提示
  Toast.loading({
    message: '加载中...',
    forbidClick: true, // 禁止背景点击
    loadingType: 'spinner', // 加载图标类型 spinner|circular
    duration: 0, // 展示时长 (ms),值为 0 时,toast 不会消失,需要手动调用 Toast.clear()
  })
  return config
}, (error) => {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 添加响应拦截器
instance.interceptors.response.use((response) => {
  // 2xx 范围内的状态码都会触发该函数。
  // 对响应数据做点什么,例如:直接返回服务器的响应结果对象
  // 优化 axios 响应结果:可以让逻辑页面少点一层 data 就能拿到后端返回的真正数据对象
  const res = response.data
  // 如果响应状态码不是 200,说明后端接口出现了错误,这时候就可以在这里统一处理错误:弹出错误提示信息 Toast 和 返回一个失败的 Promise 对象
  if (res.status !== 200) {
    Toast(res.message)
    return Promise.reject(new Error(res.message || 'Error'))
  }
  else {
    // 使用 Toast.clear() 方法关闭加载提示
    Toast.clear()
  }
  return res
}, (error) => {
  // 超出 2xx 范围的状态码都会触发该函数。
  // 对响应错误做点什么,例如:统一对 401 身份验证失败情况做出处理
  // console.dir(error);
  if (error?.response?.status === 401)
    Toast('身份验证失败,请重新登录')
  return Promise.reject(error)
})

// 导出自定义配置的 axios 实例
export default instance

页面访问拦截

导航守卫 | Vue Router

  • 目标:基于全局前置守卫,进行页面访问拦截处理
  • 说明:智慧商城项目,大部分页面,游客都可以直接访问, 如遇到需要登录才能进行的操作,提示并跳转到登录
  • 但是:对于支付页,订单页等,必须是登录的用户才能访问的,游客不能进入该页面,需要做拦截处理

40383f0a-ee78-45a9-94b6-772829826150

  1. 所有的路由一旦被匹配到,都会先经过全局前置守卫
  2. 只有全局前置守卫放行,才会真正解析渲染组件,才能看到页面内容

示例代码

js
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '@/store'

import LoginPage from '@/views/login/index.vue'
import LayoutPage from '@/views/layout/index.vue'
import SearchPage from '@/views/search/index.vue'
import SearchListPage from '@/views/searchlist/index.vue'
import GoodsDetailPage from '@/views/goodsdetail/index.vue'
import PayPage from '@/views/pay/index.vue'
import OrderPage from '@/views/order/index.vue'

import HomePage from '@/views/layout/home.vue'
import CategoryPage from '@/views/layout/category.vue'
import CartPage from '@/views/layout/cart.vue'
import MyPage from '@/views/layout/my.vue'

Vue.use(VueRouter)

const routes = [
  { path: '/login', name: 'login', component: LoginPage },
  {
    path: '/',
    name: 'layout',
    component: LayoutPage,
    redirect: '/home',
    children: [
      { path: '/home', name: 'home', component: HomePage },
      { path: '/category', name: 'category', component: CategoryPage },
      { path: '/cart', name: 'cart', component: CartPage },
      { path: '/my', name: 'my', component: MyPage },
    ],
  },
  { path: '/search', name: 'search', component: SearchPage },
  { path: '/searchlist', name: 'searchlist', component: SearchListPage },
  // 动态路由传参,确认将来是哪个商品,路由参数中携带 id
  {
    path: '/goodsdetail/:id',
    name: 'goodsdetail',
    component: GoodsDetailPage,
    props: true,
  },
  { path: '/pay', name: 'pay', component: PayPage },
  { path: '/order', name: 'order', component: OrderPage },
]

// 所有的路由在真正被访问到之前 (解析渲染对应组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面

// 全局前置导航守卫
// to:   到哪里去,到哪去的完整路由信息对象 (路径,参数)
// from: 从哪里来,从哪来的完整路由信息对象 (路径,参数)
// next(): 是否放行
// (1) next()     直接放行,放行到 to 要去的路径
// (2) next(路径)  进行拦截,拦截到 next 里面配置的路径

// 定义一个数组,专门用户存放所有需要权限访问的页面
const authUrls = ['/pay', '/order']
routes.beforeEach((to, from, next) => {
  // console.log('全局前置导航守卫', to, from)
  // 如果要去的页面在 authUrls 中,就需要校验登录状态
  if (authUrls.includes(to.path)) {
    // 校验登录状态
    const token = store.getters.getToken
    if (token) {
      next()
    }
    else {
      // 没有登录,跳转到登录页
      next('/login')
    }
  }
  else {
    // 不需要校验登录状态的页面,直接放行
    next()
  }
})

const router = new VueRouter({
  mode: 'history',
  routes,
})

export default router
js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'

Vue.use(Vuex)
export default new Vuex.Store({
  modules: {
    user,
  },
  getters: {
    getToken(state) {
      return state.user.userInfo.token
    },
  },
})