登录模块

组件封装
cp-nav-bar-静态结构
掌握:van-nav-bar 组件的基础使用,抽取到 cp-nav-bar 组件,作为通用组件
提取原因:
- 样式需要需改
- 项目中使用的 nav-bar 组件功能有相似之处
组件使用:了解 van-nav-bar 组件的基本功能属性文档
抽离组件:components/CpNavBar.vue
<script setup lang="ts"></script>
<template>
<van-nav-bar left-arrow fixed placeholder title="注册" right-text="注册" />
</template>
<style lang="scss" scoped>
::v-deep() {
.van-nav-bar {
&__arrow {
font-size: 18px;
color: var(--cp-text1);
}
&__text {
font-size: 15px;
}
}
}
</style>提问:
怎么深度作用其他组件样式?
::v-deep(){ // 样式 }
sass 中&的作用
- sass
.foo { &_xxx { } // &指代父级类名, 是sass和less提供的简写方式 } // 等价于上方 .foo{ .foo_xxx { } }
cp-nav-bar 组件功能
需求:
支持自定义 title
支持自定义右侧文字
支持自定义 right-text 点击事件
components/CpNavBar.vue
<script lang="ts" setup>
+ // 1. 定义props类型
+ interface Props {
+ title?: string
+ rightText?: string
+ }
+ // 2. 定义props
+ defineProps<Props>()
+ // 4. 定义Emits:
+ interface Emits {
+ (e: 'click-right'): void
+ }
+ const emit = defineEmits<Emits>()
</script>
<template>
<van-nav-bar
- title="标题"
- right-text="右侧文字"
+ // 3. 动态绑定props
+ :title="title"
+ :right-text="rightText"
+ // 5. 绑定click-right事件
+ @click-right="emit('click-right')"
left-arrow
placeholder
fixed
/>
</template>
// ... 省略其它代码提问:
- 怎么定义属性,怎么定义事件
definePropsdefineEmits
- 为什么可以直接使用组件,不导入不注册?
- 使用了
unplugin-vue-components默认src/compoenents自动导入注册
- 使用了
cp-nav-bar-支持默认回退
需求:支持默认回退功能
<script lang="ts" setup>
+ // ...省略其它代码
interface Props {
title?: string
rightText?: string
+ // 1. props中定义一个onBack函数
onBack?: () => void
}
+ const router = useRouter()
+ // 2. 封装默认行为
+ const clickLeft = () => {
+ if (props.onBack) return props.onBack()
+ router.back()
+ }
</script>
<template>
<!-- 3. 绑定默认行为 -->
<van-nav-bar
:title="title"
left-arrow
:right-text="rightText"
@click-right="emits('click-right')"
+ @click-left="clickLeft"
/>
</template>组合式函数-userRouter
在 vue 中以 use 开头的,叫组合式函数。它们往往是封装了一些逻辑的函数。
vue-router内置的组合式函数
const router = useRouter()等价于$routerconst route = useRoute()等价于$route
注意:
- 使用限制
- 可以 setup 语法,顶层作用域中使用
- 可以在 vue 的生命周期钩子函数中使用
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
// 💥正常使用
const router = useRouter()
console.log('router -----> ', router)
onMounted(() => {
// 💥正常使用
const router = useRouter()
console.log('router -----> ', router)
})
const fn = () => {
// 非顶层作用域,💥会得到一个undefined
const router = useRouter()
console.log('router -----> ', router)
}
</script>
<template>
<div class="home-page" @click="fn">home</div>
</template>
<style lang="scss" scoped></style>utils/requst.ts
// 非setup语法, 💥会得到一个undefined
const router = useRouter();
console.log("router -----> ", router);cp-nav-bar 组件类型
解释:给组件添加类型,让写属性和事件可以有提示
提问:
- vant 的组件为啥有提示?
- 看下 vant 的组件类型声明文件
发现:
// 核心代码
// 1. 导入组件
import NavBar from "./NavBar.vue";
// 2. 声明 vue 类型模块
declare module "vue" {
// 3. 给 vue 添加全局组件类型,interface 和之前的合并
interface GlobalComponents {
// 4. 定义具体组件类型,typeof 获取到组件实例类型
// typeof 作用是得到对应的TS类型
VanNavBar: typeof NavBar;
}
}给 cp-nav-bar 组件添加类型
types/components.d.ts
import CpNavBar from '@/components/CpNavBar.vue'
declare module 'vue' {
interface GlobalComponents {
CpNavBar: typeof CpNavBar
}
}验证:看看属性提示,事件提示,鼠标放上去有没有类型。
小结:
- 怎么给全局的组件提供类型?
- 写一个类型声明文件,
declare module 'vue'声明一个 vue 类型模块 - 然后
interface GlobalComponents书写全局组件的类型 - key 组件名称支持大驼峰,value 是组件类型,通过 typeof 组件实例得到
- 写一个类型声明文件,
cp-radio-btn- 静态结构
实现:按钮组单选框组件
步骤:
- 静态结构准备
- 定义 props,接收 options
- 给组件增加类型提示
代码:
1) 定义组件components/CpRadioBtn.vue,静态结构准备
<script setup lang="ts"></script>
<template>
<div class="cp-radio-btn">
<a class="item" href="javascript:;">男</a>
<a class="item" href="javascript:;">女</a>
</div>
</template>
<style lang="scss" scoped>
.cp-radio-btn {
display: flex;
flex-wrap: wrap;
.item {
height: 32px;
min-width: 60px;
line-height: 30px;
padding: 0 14px;
text-align: center;
border: 1px solid var(--cp-bg);
background-color: var(--cp-bg);
margin-right: 10px;
box-sizing: border-box;
color: var(--cp-text2);
margin-bottom: 10px;
border-radius: 4px;
transition: all 0.3s;
&.active {
border-color: var(--cp-primary);
background-color: var(--cp-plain);
}
}
}
</style>2)定义 props,接收 options
components/CpRadioBtn.vue
<script setup lang="ts">
+ interface Props {
+ options: {
+ label: string
+ value: string | number
+ }[]
+ }
+ defineProps<Props>()
</script>
<template>
<div class="cp-radio-btn">
+ <a class="item" href="javascript:;" v-for="item in options" :key="item.value">
+ {{ item.label }}
+ </a>
</div>
</template>3)给组件增加类型提示
src/types/components.d.ts
import CpNavBar from '@/components/CpNavBar.vue'
+import CpRadioBtn from '@/components/CpRadioBtn.vue'
declare module 'vue' {
interface GlobalComponents {
CpNavBar: typeof CpNavBar
+ CpRadioBtn: typeof CpRadioBtn
}
}cp-radio-btn- 双向绑定
目标:组件支持 v-model 双向绑定
步骤:
- 定义 props,
modelValue - 定义 emits,
update:ModelValue事件 - 插值显示
modelValue, - 点击事件,触发自定义事件
update:ModelValue - 绑定 v-model 测试
代码
<script setup lang="ts">
export interface Props {
options: { label: string; value: string | number }[]
// 1. 定义props,`modelValue`
// 💥💥 注意定义为可选属性
+ modelValue?: string | number
}
defineProps<Props>()
// 2. 定义emits,`update:ModelValue`事件
+ interface Emits {
+ (name: 'update:modelValue', value: string | number): void
+ }
+ const emit = defineEmits<Emits>()
</script>
<template>
<div class="cp-radio-btn">
<a
v-for="item in options"
:key="item.value"
class="item"
href="javascript:;"
// 3. 动态class控制选中效果
+ :class="{ active: item.value === modelValue }"
// 4. 点击触发自定义事件
+ @click="emit('update:modelValue', item.value)"
>
{{ item.label }}
</a>
</div>
</template>提问:
v-model语法糖,拆分写法?:modelValue="count"和@update:modelValue="..."
图标组件-打包 svg 地图
实现:根据 icons 文件 svg 图片打包到项目中,通过组件使用图标
- 准备svg图标文件夹icons, 放在src文件下
- 安装插件
pnpm i vite-plugin-svg-icons -D- 使用插件
vite.config.ts
+ import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
+ import path from 'path'
export default defineConfig({
plugins: [
+ // 省略其它代码
+ createSvgIconsPlugin({
+ // 指定图标文件夹,绝对路径(NODE代码)
+ iconDirs: [path.resolve(process.cwd(), 'src/icons')]
+ })
],- 导入到 main
import router from './router'
+import 'virtual:svg-icons-register'
import 'vant/lib/index.css'- 使用 svg 精灵地图
<svg aria-hidden="true">
<!-- #icon-文件夹名称-图片名称 -->
<use href="#icon-login-eye-off" />
</svg>小结:
icons 文件打包的产物?
- 会生成一个 svg 结构(js 创建的)包含所有图标,理解为
精灵图
- 会生成一个 svg 结构(js 创建的)包含所有图标,理解为
怎么使用 svg 图标?
- 通过 svg 标签
#icon-文件夹名称-图片名称指定图片,理解精灵图定位坐标
- 通过 svg 标签
图标组件-封装 CpIcon 组件
实现:把 svg 标签使用图标封装起来,使用组件完成密码可见切换功能。
- 组件
components/CpIcon.vue
<script setup lang="ts">
// 提供name属性即可
defineProps<{
name: string;
}>();
</script>
<template>
<svg aria-hidden="true" class="cp-icon">
<use :href="`#icon-${name}`" />
</svg>
</template>
<style lang="scss" scoped>
.cp-icon {
// 和字体一样大
width: 1em;
height: 1em;
}
</style>- 类型
types/components.d.ts
+import CpIcon from '@/components/CpIcon.vue'
declare module 'vue' {
interface GlobalComponents {
CpNavBar: typeof CpNavBar
CpRadioBtn: typeof CpRadioBtn
+ CpIcon: typeof CpIcon
}
}实现切换密码可见功能:Login/index.vue
// 控制密码是否显示
const show = ref(false);<cp-icon @click="show = !show" :name="`login-eye-${show ? 'on' : 'off'}`" />小结:
- 表单绑定数据后,通过 show 切换,对应切换图标组件的 name 即可。
登录页面-静态结构准备
实现:页面的基础布局,定制
van-cell的样式
- k拷贝素材的assets文件夹的所有图片
- 基础布局
vies/Login/index.vue
<script setup lang="ts"></script>
<template>
<div class="login-page">
<cp-nav-bar right-text="注册"></cp-nav-bar>
<div class="login-head">
<h3>密码登录</h3>
<a href="javascript:;">
<span>短信验证码登录</span>
<van-icon name="arrow"></van-icon>
</a>
</div>
<!-- form 表单 -->
<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" 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 {
&-head {
padding: 30px 30px 50px;
display: flex;
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;
}
}
}
}
</style>小结:
- 使用
van-iconvan-divider组件完成登录,头部和底部布局。
表单校验
实现:单个表单项校验,以及整体表单校验
- 声明数据mobile和password,v-model绑定表单
const mobile = ref('')
const password = ref('')<van-field
placeholder="请输入手机号"
type="tel"
+ v-model="mobile"
/>
<van-field
placeholder="请输入密码"
type="password"
+ v-model="password"
/>- 提取表单校验规则(为了其他页面复用)
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
placeholder="请输入手机号"
type="tel"
+ :rules="mobileRules"
/>
<van-field
placeholder="请输入密码"
type="password"
+ :rules="passwordRules"
/>- 监听表单校验成功后 submit 事件
<van-form autocomplete="off" @submit="onLogin"></van-form>// 表单提交
import { showToast } from "vant";
const onLogin = () => {
showToast("请勾选我已同意");
// 验证完毕,进行登录
};小结:
- 怎么给单个表单加校验?
- reules 属性,规则和 element-ui 类似
密码登录
实现:通过手机号和密码进行登录
温馨提示
- 提供了 100 个测试账号
- 手机号:13230000001 - 13230000100
- 密码:abc12345
登录逻辑:
- 定义一个 api 接口函数
- 登录成功:
- 存储用户信息
- 回跳页面,或者进入个人中心
- 提示用户
实现代码:
- api 函数
services/user.ts
import request from '@/utils/request'
export const loginAPI = (mobile: string, password: string) => {
return request({
url: '/login/password',
method: 'post',
data: { mobile, password }
})
}- 进行登录
Login/index.vue
<script>
// ... 省略其它代码 ...
import { loginAPI } from "@/services/user";
import { useRoute, useRouter } from "vue-router";
const router = useRouter();
// 表单提交
const onLogin = async () => {
if (!agree.value) return showToast("请勾选我已同意");
const res = await loginAPI(mobile.value, password.value);
// 保存数据
// 缓存数据
localStorage.setItem("user-info", JSON.stringify(res.data));
// 提示用户
showSuccessToast("登陆成功");
// 跳转路由
router.push("/");
};
</script>用户状态仓库
完成:用户信息仓库创建,提供用户信息,修改用信息,删除用户信息的方法
需求:
- 用户信息仓库创建
- 提供用户信息
- 删除用信息的方法
代码:
types/user.d.ts
/** 用户登录接口返回的数据 */
export interface User {
/**
* 用户名
*/
account?: string;
/**
* 头像
*/
avatar?: string;
/**
* 用户id
*/
id?: string;
/**
* 脱敏手机号,带星号的手机号
*/
mobile?: string;
/**
* refreshToken
*/
refreshToken: string;
/**
* token
*/
token: string;
}stores/user.ts
import type { User } from "@/types/user";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useUserStore = defineStore("cp-user", () => {
const user = ref<User>();
const saveUser = (u: User) => {
user.value = u;
};
// 清空用户,退出后使用
const delUser = () => {
user.value = undefined;
};
return {
user,
saveUser,
delUser,
};
});数据持久化
掌握:使用
pinia-plugin-persistedstate实现 pinia 仓库状态持久化,且完成测试
- 安装
pnpm i pinia-plugin-persistedstate- 使用
main.ts
+ import persist from 'pinia-plugin-persistedstate'
const app = createApp(App)
+ const pinia = createPinia()
// 使用pinia插件
+ pinia.use(persist)
app.use(pinia)
// ...省略其它代码- 配置
stores/user.ts
export const useUserStore = defineStore(
'cp-user',
() => {
+ // ...省略其它代码
},
+ {
+ persist: true
+ }
)- 重新登录-测试缓存效果
小结:
pinia 存储这个数据的意义?
- 数据共享,提供给项目中任何位置使用
如果存储了数据,刷新页面后数据还在吗?
- 不在,现在仅仅是 js 内存中,需要进行本地存储(持久化)
stores 统一导出
实现:仓库的导出统一从
./stores代码简洁,职能单一,入口唯一
- 抽取 pinia 实例代码,职能单一
stores/index.ts
import { createPinia } from "pinia";
import persist from "pinia-plugin-persistedstate";
// 创建pinia实例
const pinia = createPinia();
// 使用pinia插件
pinia.use(persist);
// 导出pinia实例,给main使用
export default pinia;main.ts
import { createApp } from 'vue'
import App from './App.vue'
- import persist from 'pinia-plugin-persistedstate'
+ import pinia from './stores'
const app = createApp(App)
- const pinia = createPinia()
- pinia.use(persist)
app.use(pinia)- 统一导出,代码简洁,入口唯一
stores/index
export * from "./user";App.vue
-import { useUserStore } from './stores/user'
+import { useUserStore } from './stores'小结:
- 统一导出是什么意思?
- 一个模块下的所有资源通过 index 导出
短信登录
实现:添加短信登录与密码登录界面切换,添加 code 的校验
切换界面
Login/index.vue
- 声明数据
const isPass = ref(true);- 标题切换
<div class="login-head">
- <h3>密码登录</h3>
+ <h3>{{ isPass ? '密码登录' : '短信验证码登录' }}</h3>
- <a href="javascript:;">
+ <a href="javascript:;" @click="isPass = !isPass">
- <span>短信验证码登录</span>
+ <span>{{ isPass ? '短信验证码登录' : '密码登录' }}</span>
<van-icon name="arrow" />
</a>
</div>- 表单项切换
<van-field
+ v-show="isPass"
v-model="password"
:rules="passwordRules"
placeholder="请输入密码"
:type="show ? 'text' : 'password'"
>
<template #button>
<cp-icon @click="show = !show" :name="`login-eye-${show ? 'on' : 'off'}`" />
</template>
</van-field>
+ <van-field
+ v-show
+ placeholder="短线验证码"
+ />验证码数据校验
- 声明数据,
const code = ref("");- v-model 绑定,添加校验规则
<van-field
v-else
placeholder="请输入验证码"
+ v-model="code"
type="digit"
+ :rules="[
+ { required: true, message: '请输入验证码' },
+ { pattern: /^\d{6}$/, message: '验证码6个数字' }
+ ]"
>
+ <template #button>
+ <span class="btn-send">发送验证码</span>
+ </template>
</van-field>- 添加样式
// van-form 下添加
.btn-send {
color: var(--cp-primary);
}验证码倒计时
步骤:
- 声明秒数、插值显示
- 定义发送验证码事件
- 修改秒数为 60,开启定时器,每秒-1
- 当秒数为 0 时,关闭定时器
<script>
const second = ref(0);
</script>
<template #button>
<span v-if="second">{{ second }}s后再次发送</span>
<span v-else class="btn-send" @click="sendCode">发送验证码</span>
</template>let timerId: number;
const sendCode = () => {
second.value = 60;
timerId = setInterval(() => {
second.value--;
if (second.value === 0) clearInterval(timerId);
}, 1000);
};
onUnmounted(() => {
clearInterval(timeId);
});发送短信
实现:点击按钮发送验证码功能
提示:🔔🔔 获取验证码,同一手机号,一分钟内只能获取一次。
步骤:
- 定义 API 接口
- 调用 API 接口
- 提示用户,接收验证码
代码:
1)API 接口函数
- 接口
services/user.ts
export const sendCodeAPI = (mobile: string, type = "login") => {
return request({
url: "/code",
params: { mobile, type },
});
};2)调用 API 接口
const sendCode = async () => {
+ // 2. 调用API
+ const { data } = await sendCodeAPI(mobile.value)
+ showSuccessToast('发送成功')
+ // 3. 保存验证码,真实情况在手机端接收验证码
+ code.value = data.code
second.value = 60
timerId = window.setInterval(() => {
second.value--
if (second.value === 0) window.clearInterval(timerId)
}, 1000)
}短信登录
实现:通过短信进行登录
步骤:
- 定义 API 函数
- 条件判断,调用 API 函数
代码:
- 定义 API 函数,
services/user.ts
export const loginByCodeAPI = (mobile: string, code: string) => {
return request({
url: "/login",
method: "post",
data: { mobile, code },
});
};- 条件判断,调用 API 函数
// 表单提交
const onLogin = async () => {
if (!agree.value) return showToast('请勾选我已同意')
- const res = await loginAPI(mobile.value, password.value)
+ const res = isPass.value
+ ? await loginAPI(mobile.value, password.value)
+ : await loginByCodeAPI(mobile.value, code.value)
store.saveUser(res.data)
showSuccessToast('登陆成功')
router.push('/')
}小结:
- 处理接口和传参不一样,成功后的逻辑都一样的。
优医问诊H5