Vue3+TS+Pinia+Vant-登录模块

579 阅读1分钟

登录模块展示

image.png

添加登录路由

为后续路由模块化,将路由信息单独抽离封装
src\router\routes.ts

import type { RouteRecordRaw } from 'vue-router'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    component: () => import('@/views/login/index.vue'),
    meta: {
      title: '登录'
    }
  }
]

export default routes

封装nav-bar组件

支持组件的自定义返回功能,支持title、rightText属性,支持click-right事件

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()

const props = defineProps<{
  title: string
  rightText?: string
  back?: () => void
}>()

const emit = defineEmits<{
  (e: 'click-right'): void
}>()

// 点击左侧返回按钮
const onClickLeft = () => {
  // 支持自定义返回
  if (props.back) {
    return props.back()
  }
  // 判断上次访问页面是否存在
  if (history.state.back) {
    // 存在就返回
    router.back()
  } else {
    // 不存在就返回首页
    router.push('/')
  }
}
</script>

<template>
  <van-nav-bar
    fixed
    left-arrow
    :title="title"
    :right-text="rightText"
    @click-left="onClickLeft"
    @click-right="emit('click-right')"
  ></van-nav-bar>
</template>

<style lang="scss" scoped>
:deep() {
  .van-nav-bar {
     /* 修改 */
    &__arrow {
      font-size: 18px;
      color: var(--cp-text1);
    }
    &__text {
      font-size: 15px;
    }
  }
}
</style>

给组件添加类型,让写属性和事件可以有提示

点击VanNavBar看下vant的组件类型声明文件

// 核心代码
// 1. 导入组件实例
import NavBar from './NavBar.vue'
// 2. 声明 vue 类型模块
declare module 'vue' {
    // 3. 给 vue  添加全局组件类型,interface 和之前的合并
    interface GlobalComponents {
        // 4. 定义具体组件类型,typeof 获取到组件实例类型
        // typeof 作用是得到对应的TS类型
        VanNavBar: typeof NavBar;
    }
}

types文件夹下创建components.d.ts文件

// 为项目全局组件声明类型
import CpNavBar from '@/components/cp-nav-bar.vue'

declare module 'vue' {
  interface GlobalComponents {
    // 添加组件类型
    CpNavBar: typeof CpNavBar
  }
}

页面布局

<script setup lang="ts"></script>

<template>
  <div class="login-page">
    <nav-bar
      right-text="注册"
      @click-right="$router.push('/register')"
    ></nav-bar>
    <!-- 头部 -->
    <div class="login-head">
      <h3>密码登录</h3>
      <a href="javascript:;">
        <span>短信验证码登录</span>
        <van-icon name="arrow"></van-icon>
      </a>
    </div>
    <!-- 表单 -->
    <van-form autocomplete="off">
      <van-field placeholder="请输入手机号" type="tel"></van-field>
      <van-field placeholder="请输入密码" type="password"></van-field>
      <div class="cp-cell">
        <van-checkbox>
          <span>我已同意</span>
          <a href="javascript:;">用户协议</a>
          <span></span>
          <a href="javascript:;">隐私条款</a>
        </van-checkbox>
      </div>
      <div class="cp-cell">
        <van-button block round type="primary">登 录</van-button>
      </div>
      <div class="cp-cell">
        <a href="javascript:;">忘记密码?</a>
      </div>
    </van-form>
    <!-- 底部 -->
    <div class="login-other">
      <van-divider>第三方登录</van-divider>
      <div class="icon">
        <img src="@/assets/qq.svg" alt="" />
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.login {
  &-page {
    padding-top: 46px;
  }
  &-head {
    display: flex;
    padding: 30px 30px 50px;
    justify-content: space-between;
    align-items: flex-end;
    line-height: 1;
    h3 {
      font-weight: normal;
      font-size: 24px;
    }
    a {
      font-size: 15px;
    }
  }
  &-other {
    margin-top: 60px;
    padding: 0 30px;
    .icon {
      display: flex;
      justify-content: center;
      img {
        width: 36px;
        height: 36px;
        padding: 4px;
      }
    }
  }
}
.van-form {
  padding: 0 14px;
  .cp-cell {
    height: 52px;
    line-height: 24px;
    padding: 14px 16px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    .van-checkbox {
      a {
        color: var(--cp-primary);
        padding: 0 5px;
      }
    }
  }
  .btn-send {
    color: var(--cp-primary);
    &.active {
      color: rgba(22,194,163,0.5);
    }
  }
}
</style>

表单校验

提取表单校验规则(为了其他页面复用)
utils/rules.ts

// 表单校验
const mobileRules = [
  { required: true, message: '请输入手机号' },
  { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }
]

const passwordRules = [
  { required: true, message: '请输入密码' },
  { pattern: /^\w{8,24}$/, message: '密码需8-24个字符' }
]

export { mobileRules, passwordRules }

Login/index.vue

import { mobileRules, passwordRules } from '@/utils/rules'
+  <van-field v-model="mobile" :rules="mobileRules" placeholder="请输入手机号" type="tel"></van-field>
  <van-field
    v-model="password"
+   :rules="passwordRules"
    placeholder="请输入密码"
    :type="show ? 'text' : 'password'"
  >

设置button组件为原生 submit 类型按钮

<van-button block round type="primary" native-type="submit"> 登 录 </van-button>

监听表单校验成功后 submit 事件

<van-form autocomplete="off" @submit="login">
// 表单提交
const login = () => {
  if (!agree.value) return Toast('请勾选我已同意')
  // 验证完毕,进行登录
}

密码登录

定义一个 api 接口函数
apis/user.ts

import type { User } from '@/types/user'
import { request } from '@/utils/request'

// 密码登录
export const loginByPassword = (mobile: string, password: string) =>
  request<User>('/login/password', 'POST', { mobile, password })

进行登录
Login/index.vue

import { loginByPassword } from '@/services/login'
import { useUserStore } from '@/stores'
import { useRoute, useRouter } from 'vue-router'
import { showFailToast, showSuccessToast, type FormInstance } from 'vant'

const store = useUserStore()
const router = useRouter()
const route = useRoute()
// 表单提交
const login = async () => {
  if (!agree.value) return Toast('请勾选我已同意')
  // 验证完毕,进行登录
  const res = await loginByPassword(mobile.value, password.value)
  store.setUser(res.data)
  // 如果有回跳地址就进行回跳,没有跳转到个人中心
  router.push((route.query.returnUrl as string) || '/user')
  showSuccessToast('登录成功')
}

切换短信登录

isPass默认是密码登录,为false为短信验证码登录

const isPass = ref(true)
<div class="login-head">
  <h3>{{ isPass ? '密码登录' : '短信验证码登录' }}</h3>
  <a href="javascript:;" @click="isPass = !isPass">
    <span>{{ !isPass ? '密码登录' : '短信验证码登录' }}</span>
    <van-icon name="arrow"></van-icon>
  </a>
</div>
  <van-field
    v-if="isPass"
    v-model="password"
    :rules="passwordRules"
    placeholder="请输入密码"
    :type="show ? 'text' : 'password'"
  >
    <template #button>
      <cp-icon @click="show = !show" :name="`login-eye-${show ? 'on' : 'off'}`"></cp-icon>
    </template>
  </van-field>
  <van-field v-else placeholder="短信验证码">
    <template #button>
      <van-button size="small" type="primary" >发送验证码 </van-button>
    </template>
  </van-field>

添加校验规则

utils/rules.ts

const codeRules = [
  { required: true, message: '请输入验证码' },
  { pattern: /^\d{6}$/, message: '验证码6个数字' }
]

export { mobileRules, passwordRules, codeRules }

使用规则
Login/index.vue

import { mobileRules, passwordRules, codeRules } from '@/utils/rules'

const code = ref('')
<van-field v-else v-model="code" :rules="codeRules" placeholder="短信验证码">

发送短信

定义接口
services/user.ts

+import type { CodeType, User } from '@/types/user'
import { request } from '@/utils/request'

// 密码登录
export const loginByPassword = (mobile: string, password: string) =>
  request<User>('/login/password', 'POST', { mobile, password })

+// 发送验证码
+export const sendMobileCode = (mobile: string, type: CodeType) =>
+  request('/code', 'GET', { mobile, type })

定义接口类型
types/user.d.ts

// 短信验证码类型,登录|注册|修改手机号|忘记密码|绑定手机号
export type CodeType = 'login' | 'register' | 'changeMobile' | 'forgetPassword' | 'bindMobile'

发送验证码按钮添加点击事件,并设置time不为0时disabled为true

<van-button size="small" type="primary" @click="send" :disabled="time !== 0">发送验证码</van-button>

发送验证码逻辑

const time = ref(0)
const send = async () => {
  // 已经倒计时time的值大于0,此时不能发送验证码
  if (time.value > 0) return
}

发送验证码需要校验手机号码是否准确

  <van-field
    v-model="mobile"
+   name="mobile"
    :rules="mobileRules"
    placeholder="请输入手机号"
    type="tel"
  ></van-field>

+ const form = ref<FormInstance>()
const time = ref(0)
const send = async () => {
  if (time.value > 0) return
+  // 验证不通过报错,阻止程序继续执行
+  await form.value?.validate('mobile')
}

发送短信验证码

const send = async () => {
  if (time.value > 0) return
  await form.value?.validate('mobile')
+  await sendMobileCode(mobile.value, 'login')
+  showSuccessToast('发送成功')
}

点击验证码会有60秒禁用时间,组件卸载时也要清除定时器

const form = ref<FormInstance>()
const time = ref(0)
+let timeId: number
const send = async () => {
  if (time.value > 0) return
  await form.value?.validate('mobile')
  await sendMobileCode(mobile.value, 'login')
  Toast.success('发送成功')
  time.value = 60
+  // 倒计时
+  clearInterval(timeId)
+  timeId = window.setInterval(() => {
+    time.value--
+    if (time.value <= 0) window.clearInterval(timeId)
+  }, 1000)
}
+onUnmounted(() => {
+  window.clearInterval(timeId)
+})

进行登录

添加接口

// 短信登录
export const loginByMobile = (mobile: string, code: string) =>
  request<User>('/login', 'POST', { mobile, code })

在登录函数添加判断

// 表单提交
const login = async () => {
  if (!agree.value) return Toast('请勾选我已同意')
  // 验证完毕,进行登录
+  const res = isPass.value
+    ? await loginByPassword(mobile.value, password.value)
+    : await loginByMobile(mobile.value, code.value)
  store.setUser(res.data)
  // 如果有回跳地址就进行回跳,没有跳转到个人中心,replace目的 a => login  => b  变成 a => b
  router.replace((route.query.returnUrl as string) || '/user')
  Toast.success('登录成功')
}

全部代码

<script setup lang="ts">
import { loginByMobile, loginByPassword, sendMobileCode } from '@/api/user'
import { useUserStore } from '@/stores'
import { mobileRules, passwordRules, codeRules } from '@/utils/rules'
import { showFailToast, showSuccessToast, type FormInstance } from 'vant'

import { onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const mobile = ref('13211112222')
const password = ref('abc12345')
const agree = ref(false)
const isShowPass = ref(false)
const isPass = ref(true)
const code = ref('')
const time = ref(0)
const form = ref<FormInstance>()
// 定时器
let timeId: number

const userStore = useUserStore()
const router = useRouter()
const route = useRoute()

// 表单提交
const login = async () => {
  if (!agree.value) return showFailToast('请勾选我已同意')
  // 验证完毕,进行登录
  try {
    const res = isPass.value
      ? await loginByPassword(mobile.value, password.value)
      : await loginByMobile(mobile.value, code.value)
    if (res?.data) {
      userStore.setUser(res.data)
      // 如果有回跳地址就进行回跳,没有跳转到个人中心
      router.push((route.query.returnUrl as string) || '/user')
      showSuccessToast('登录成功')
    }
  } catch (error: any) {
    showFailToast(error)
  }
}

const send = async () => {
  // 已经倒计时time的值大于0,此时不能发送验证码
  if (time.value > 0) return
  // 验证不通过报错,阻止程序继续执行
  await form.value?.validate('mobile')
  time.value = 60
  // 倒计时
  clearInterval(timeId)
  timeId = window.setInterval(() => {
    time.value--
    if (time.value <= 0) window.clearInterval(timeId)
  }, 1000)
  try {
    // 发送验证码
    const res = await sendMobileCode(mobile.value, 'login')
    showSuccessToast('发送成功')
    if (res?.data?.code) {
      code.value = res.data.code
    }
  } catch (error: any) {
    showFailToast(error)
  }
}

onUnmounted(() => {
  window.clearInterval(timeId)
})
</script>

<template>
  <div class="login-page">
    <nav-bar
      title="登录"
      right-text="注册"
      @click-right="$router.push('/register')"
    ></nav-bar>
    <!-- 头部 -->
    <div class="login-head">
      <h3>{{ isPass ? '密码登录' : '短信验证码登录' }}</h3>
      <a href="javascript:;" @click="isPass = !isPass">
        <span>{{ !isPass ? '密码登录' : '短信验证码登录' }}</span>
        <van-icon name="arrow"></van-icon>
      </a>
    </div>

    <!-- 表单 -->
    <van-form autocomplete="off" @submit="login">
      <van-field
        placeholder="请输入手机号"
        type="tel"
        v-model="mobile"
        :rules="mobileRules"
        name="mobile"
        clearable
      ></van-field>
      <!-- isPass控制切换 -->
      <van-field
        v-if="isPass"
        placeholder="请输入密码"
        v-model="password"
        :type="`${isShowPass ? 'tel' : 'password'}`"
        :rules="passwordRules"
      >
        <template #button>
          <cp-icon
            :name="`login-eye-${isShowPass ? 'on' : 'off'}`"
            @click="isShowPass = !isShowPass"
          />
        </template>
      </van-field>
      <van-field
        v-else
        placeholder="短信验证码"
        v-model="code"
        :rules="codeRules"
      >
        <template #button>
          <van-button
            size="small"
            type="primary"
            @click="send"
            :disabled="time !== 0"
           >
              {{ time > 0 ? `${time}s后再次发送` : '发送验证码' }}
           </van-button>
        </template>
      </van-field>
      <div class="cp-cell">
        <van-checkbox v-model="agree">
          <span>我已同意</span>
          <a href="javascript:;">用户协议</a>
          <span></span>
          <a href="javascript:;">隐私条款</a>
        </van-checkbox>
      </div>
      <div class="cp-cell">
        <van-button block round type="primary" native-type="submit"
          >登 录</van-button>
      </div>
      <div class="cp-cell">
        <a href="javascript:;">忘记密码?</a>
      </div>
    </van-form>
    <!-- 底部 -->
    <div class="login-other">
      <van-divider>第三方登录</van-divider>
      <div class="icon">
        <img src="@/assets/qq.svg" alt="" />
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.login {
  &-page {
    padding-top: 46px;
  }
  &-head {
    display: flex;
    padding: 30px 30px 50px;
    justify-content: space-between;
    align-items: flex-end;
    line-height: 1;
    h3 {
      font-weight: normal;
      font-size: 24px;
    }
    a {
      font-size: 15px;
    }
  }
  &-other {
    margin-top: 60px;
    padding: 0 30px;
    .icon {
      display: flex;
      justify-content: center;
      img {
        width: 36px;
        height: 36px;
        padding: 4px;
      }
    }
  }
}
.van-form {
  padding: 0 14px;
  .cp-cell {
    height: 52px;
    line-height: 24px;
    padding: 14px 16px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    .van-checkbox {
      a {
        color: var(--cp-primary);
        padding: 0 5px;
      }
    }
  }
  .btn-send {
    color: var(--cp-primary);
    &.active {
      color: rgba(22, 194, 163, 0.5);
    }
  }
}
</style>