Day05
文章目录
- Day05
- 登录
- 1. 整体认识和路由设置
- 2. 表单校验实现
- 3. 表单-统一校验
- 4. 基础登录业务实现
- 5. Pinia管理用户数据
- 6. Pinia 数据持久化
- 7. 登录和非登录状态下的模板适配
- 8. 请求拦截器携带Token
- 9. 退出登录功能的实现
- 10. Token失效401拦截处理
- 购物车
- 1. 流程梳理
- 2. 本地购物车-加入购物车功能实现
- 3. 头部购物车列表渲染
- 4. 头部购物车删除功能
- 5. 头部购物车统计计算
- 总结
登录
1. 整体认识和路由设置
准备模板
Login/index.vue
<script setup></script><template><div><header class="login-header"><div class="container m-top-20"><h1 class="logo"><RouterLink to="/">小兔鲜</RouterLink></h1><RouterLink class="entry" to="/">进入网站首页<i class="iconfont icon-angle-right"></i><i class="iconfont icon-angle-right"></i></RouterLink></div></header><section class="login-section"><div class="wrapper"><nav><a href="javascript:;">账户登录</a></nav><div class="account-box"><div class="form"><el-form label-position="right" label-width="60px"status-icon><el-form-item label="账户"><el-input/></el-form-item><el-form-item label="密码"><el-input/></el-form-item><el-form-item label-width="22px"><el-checkbox size="large">我已同意隐私条款和服务条款</el-checkbox></el-form-item><el-button size="large" class="subBtn">点击登录</el-button></el-form></div></div></div></section><footer class="login-footer"><div class="container"><p><a href="javascript:;">关于我们</a><a href="javascript:;">帮助中心</a><a href="javascript:;">售后服务</a><a href="javascript:;">配送与验收</a><a href="javascript:;">商务合作</a><a href="javascript:;">搜索推荐</a><a href="javascript:;">友情链接</a></p><p>CopyRight © 小兔鲜儿</p></div></footer></div>
</template><style scoped lang='scss'>
.login-header {background: #fff;border-bottom: 1px solid #e4e4e4;.container {display: flex;align-items: flex-end;justify-content: space-between;}.logo {width: 200px;a {display: block;height: 132px;width: 100%;text-indent: -9999px;background: url("@/assets/images/logo.png") no-repeat center 18px / contain;}}.sub {flex: 1;font-size: 24px;font-weight: normal;margin-bottom: 38px;margin-left: 20px;color: #666;}.entry {width: 120px;margin-bottom: 38px;font-size: 16px;i {font-size: 14px;color: $xtxColor;letter-spacing: -5px;}}
}.login-section {background: url('@/assets/images/login-bg.png') no-repeat center / cover;height: 488px;position: relative;.wrapper {width: 380px;background: #fff;position: absolute;left: 50%;top: 54px;transform: translate3d(100px, 0, 0);box-shadow: 0 0 10px rgba(0, 0, 0, 0.15);nav {font-size: 14px;height: 55px;margin-bottom: 20px;border-bottom: 1px solid #f5f5f5;display: flex;padding: 0 40px;text-align: right;align-items: center;a {flex: 1;line-height: 1;display: inline-block;font-size: 18px;position: relative;text-align: center;}}}
}.login-footer {padding: 30px 0 50px;background: #fff;p {text-align: center;color: #999;padding-top: 20px;a {line-height: 1;padding: 0 10px;color: #999;display: inline-block;~a {border-left: 1px solid #ccc;}}}
}.account-box {.toggle {padding: 15px 40px;text-align: right;a {color: $xtxColor;i {font-size: 14px;}}}.form {padding: 0 20px 20px 20px;&-item {margin-bottom: 28px;.input {position: relative;height: 36px;>i {width: 34px;height: 34px;background: #cfcdcd;color: #fff;position: absolute;left: 1px;top: 1px;text-align: center;line-height: 34px;font-size: 18px;}input {padding-left: 44px;border: 1px solid #cfcdcd;height: 36px;line-height: 36px;width: 100%;&.error {border-color: $priceColor;}&.active,&:focus {border-color: $xtxColor;}}.code {position: absolute;right: 1px;top: 1px;text-align: center;line-height: 34px;font-size: 14px;background: #f5f5f5;color: #666;width: 90px;height: 34px;cursor: pointer;}}>.error {position: absolute;font-size: 12px;line-height: 28px;color: $priceColor;i {font-size: 14px;margin-right: 2px;}}}.agree {a {color: #069;}}.btn {display: block;width: 100%;height: 40px;color: #fff;text-align: center;line-height: 40px;background: $xtxColor;&.disabled {background: #cfcdcd;}}}.action {padding: 20px 40px;display: flex;justify-content: space-between;align-items: center;.url {a {color: #999;margin-left: 10px;}}}
}.subBtn {background: $xtxColor;width: 100%;color: #fff;
}
</style>
主页有个 ‘请先登录’ 点击进入登录页,主页没有,我们适配一下
分为登录和非登录两种情况,我们将true
改为false
<!--Layout/HomeNav.vue 跳转到登录页面--><li><a href="javascript:;" @click="router.push('/login')">请先登录</a></li>
2. 表单校验实现
校验:提前校验省去一些错误的请求提交,减小接口压力
elementPlus
组件内置了表单校验功能,我们看官方文档完成校验功能。
步骤:
- 按照接口字段准备表单对象并绑定
- 按照产品要求准备规则对象并绑定
- 指定表单域的校验字段名
- 把表单对象进行双向绑定
校验相关代码如下,勾选框使用的是自定义校验:
<script setup>
import { ref } from "vue"//登录-账户名,密码,是否勾选
const userInfo = ref({account: '1311111111',password: '123456',agree: true
})const rules = {account: [{ required: true, message: '账户名不能为空', trigger: 'blur' }],password: [{ required: true, message: '密码不能为空', trigger: 'blur' },{ min: 6, max: 14, message: '密码长度要求6-14个字符', trigger: 'blur' }],agree: [// 自定义校验{validator: (rule, value, callback) => {if (value) {callback()} else {callback(new Error("请勾选协议"))}}}]
}</script>
<div class="form"><el-form label-position="right" label-width="60px" status-icon :rules="rules" :model="userInfo"><el-form-item label="账户" prop="account"><el-input v-model="userInfo.account" /></el-form-item><el-form-item label="密码" prop="password"><el-input v-model="userInfo.password" /></el-form-item><el-form-item label-width="22px" prop="agree"><el-checkbox size="large" v-model="userInfo.agree">我已同意隐私条款和服务条款</el-checkbox></el-form-item><el-button size="large" class="subBtn">点击登录</el-button></el-form></div>
3. 表单-统一校验
点击 登录 按钮时进行统一校验,弹幕有人说这个功能是防 鲨臂 的哈哈哈笑死
步骤:
获取form
组件实例 =》 调用实例方法
通过ref获取组件实例,定义formRef
,绑定给表单,给按钮绑定方法doValidate
,获取组件实例调用validate
方法,相关代码如下
const formRef = ref(null)<el-form label-position="right" label-width="60px" status-icon :rules="rules" :model="userInfo" ref="formRef"><el-button size="large" class="subBtn" @click="doValidate">点击登录</el-button>//表单统一校验
const doValidate = () => {formRef.value.validate((value) => {if (value) {//通过校验执行的逻辑}})
}
4. 基础登录业务实现
基础思想
- 调用登录接口获取用户信息
//api/user.js
// 用户相关接口函数import httpInstance from "@/utils/http"export const loginAPI = ({ account, password }) => {return httpInstance({url: '/login',method: 'post',data: {account,password}})
}
const { account, password } = userInfo.valueformRef.value.validate(async (value) => {if (value) {//通过校验执行的逻辑const res = await loginAPI({ account, password }) //获取数据}}
- 提示用户当前是否成功
- 跳转到首页
//Login/index.vue
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
import { useRouter } from "vue-router"const router = useRouter()
const doLogin = () => {const { account, password } = form.value// 调用实例方法formRef.value.validate(async (valid) => {// valid: 所有表单都通过校验 才为trueconsole.log(valid)// 以valid做为判断条件 如果通过校验才执行登录逻辑if (valid) {// TODO LOGINawait loginAPI({ account, password })// 1. 提示用户ElMessage({ type: 'success', message: '登录成功' })// 2. 跳转首页router.replace({ path: '/' })}})
}
响应拦截器写 统一的错误提示
//utils/http.jsimport { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
// axios响应式拦截器
httpInstance.interceptors.response.use(res => res.data, e => {//统一错误提示ElMessage({type: 'warning',message:e.response.data.message})return Promise.reject(e)
})
当用户账号密码错误会有提示
5. Pinia管理用户数据
将和数据相关的所有操作(state+action
)都放在Pinia
中,组件只负责出发action
函数
//Store/user.js
//用户相关数据
import { defineStore } from "pinia";
import { ref } from "vue";
import { loginAPI } from '@/apis/user.js'
export const useUserStore = defineStore('user', () => {//数据const userInfo = ref({})//actionconst getUserInfo = async ({ account, password }) => {const res = await loginAPI({ account, password })userInfo.value = res.result}// return,用对象格式return {userInfo,getUserInfo}
})
<!--Login/index.vue-->
import { useUserStore } from '@/stores/user.js'
const userStore = useUserStore() //定义pinia实例if (value) {//通过校验执行的逻辑// const res = await loginAPI({ account, password }) //获取数据// console.log(res)await userStore.getUserInfo({ account, password })
...
}
6. Pinia 数据持久化
用户数据有个关键数据Token(用来标识当前用户是否登录),而Token需要持续一段时间才会过期。
Pinia的存储时基于内存的,刷新就丢失,为了保持登录状态就要做到刷新不丢失,需要配合持久化进行存储。
目的:保持token不丢失,保持登录状态
最终效果:操作state时会自动把用户数据在本地的localStorage也存一份,刷新的时候会从localStorage中先取。
我们使用的是:
安装,在main.js
引入注册这个插件,添加一个配置项persist
(看文档使用哈)
npm i pinia-plugin-persistedstate
//main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
//store/user.js
export const useUserStore = defineStore('user', () => {//数据const userInfo = ref({})//actionconst getUserInfo = async ({ account, password }) => {const res = await loginAPI({ account, password })userInfo.value = res.result}// return,用对象格式return {userInfo,getUserInfo}
}, {persist: true
})
验证,看后台localStorage
是否能在登录时同步
运行机制:设置state
时会自动把数据同步给localStorage
,在获取state数据时候会优先从localStorage
中取。
7. 登录和非登录状态下的模板适配
区分登录状态和非登录状态 ,根据是否有token 这个条件判别,来使用不同的模板渲染
从Store中
拿到token
<script setup>
import { useUserStore } from "@/stores/user";const userStore = useUserStore()
</script><template v-if="userStore.userInfo.token">...<!--用户名动态渲染--><li><a href="javascript:;"><i class=" iconfont icon-user"></i>{{ userStore.userInfo.account }}</a>
8. 请求拦截器携带Token
Token数据会被注入到header中,格式按照后端要求的格式进行拼接处理。
//utils/http.js// axios请求拦截器
httpInstance.interceptors.request.use(config => {// 1. 从pinia获取token数据const userStore = useUserStore()// 2. 按照后端的要求拼接token数据const token = userStore.userInfo.tokenif (token) {config.headers.Authorization = `Bearer ${token}`}return config
}, e => Promise.reject(e))
请求里都会带,只需要这一次配置
9. 退出登录功能的实现
通用逻辑:清除当前用户信息 =》跳转到登录页面
confirm
点击确认触发的事件。
清除信息需要使用pinia
管理,在组件中调用。跳回登录页使用useRouter
相关代码如下:
<!--HomeNav.vue--><script setup>
import { useUserStore } from "@/stores/user";
import { useRouter } from "vue-router";const router = useRouter()
const userStore = useUserStore()//退出登录
const logoutConfirm = () => {userStore.clearData()router.push('/login')
}
</script><el-popconfirm @confirm="logoutConfirm" title="确认退出吗?" confirm-button-text="确认" ...
//store/user.js//退出登录,数据清空const clearData = () => {userInfo.value = {}}
10. Token失效401拦截处理
Token
有效性可以保持一定的时间,如果用户一段时间不做任何操作,Token
就会失效,使用失效的Token
请求一些接口,接口会报401错误,需要我们做额外的处理。
我们要做的:
-
失败回调拦截401
-
清除过期的用户信息,跳转到登录页
在utils/http.js
中书写逻辑
// axios响应式拦截器
httpInstance.interceptors.response.use(res => res.data, e => {//统一错误提示ElMessage({type: 'warning',message: e.response.data.message})//处理401const userStore = useUserStore()if (e.response.status === 401) {//1.清除用户数据,数据在pinia中存储着userStore.clearData()//2.跳转到登录页面router.push('/login')}return Promise.reject(e)
})
购物车
难度较大,建议认真学习
1. 流程梳理
业务逻辑梳理拆解:
2. 本地购物车-加入购物车功能实现
非登录状态
- 封装
cartStore
- 添加该商品原
count+1
,未添加过直接push
// 封装购物车模块import { defineStore } from 'pinia'
import { ref } from 'vue'export const useCartStore = defineStore('cart', () => {// 1. 定义state - cartListconst cartList = ref([])// 2. 定义action - addCartconst addCart = (goods) => {console.log('添加', goods)// 添加购物车操作// 已添加过 - count + 1// 没有添加过 - 直接push// 思路:通过匹配传递过来的商品对象中的skuId能不能在cartList中找到,找到了就是添加过const item = cartList.value.find((item) => goods.skuId === item.skuId)if (item) {// 找到了item.count++} else {// 没找到cartList.value.push(goods)}}return {cartList,addCart}
}, {persist: true,
})
数据组件用的是elementPlus
的input-number
<el-input-number v-model="count" @change="countChange" />//js 购物车件数count
const count = ref(1)
const countChange = (count) => {
}
//规格操作时
let skuObj = {}
const skuChange = (sku) => {console.log(sku)skuObj = skuconsole.log(skuObj) //选满了就有值,可以作为一个判断条件
}
- 组件点击 添加 按钮
<el-button size="large" class="btn" @click="addCart">
加入购物车
</el-button>
- 选中规格 -> 调用
action
添加(传递商品参数)。规格两个都要选中对象不为空
//添加购物车
const addCart = () => {if (skuObj.skuId) {//规格选择全了,触发action} else {ElMessage.warning('请选择规格')}
}
引入cartStore,调用方法,传参
import { useCartStore } from '@/stores/cartStore';const cartStore = useCartStore()
//添加购物车
const addCart = () => {if (skuObj.skuId) {//规格选择全了,触发actioncartStore.addCart({//传参id: goods.value.id, //商品idname: goods.value.name, //商品名称picture: goods.value.mainPictures[0], //图片price: goods.value.price, //最新价格count: count.value, //商品数量skuId: skuObj.skuId,attrsText: skuObj.specsText, //商品规格文本selected: true //商品是否选中})} else {ElMessage.warning('请选择规格')}
}
3. 头部购物车列表渲染
头部购物车组件Layout/components/HeaderCart.vue
,在LayoutHeader
中引入并使用:
<script setup></script><template><div class="cart"><a class="curr" href="javascript:;"><i class="iconfont icon-cart"></i><em>2</em></a><div class="layer"><div class="list"><!--<div class="item" v-for="i in cartList" :key="i"><RouterLink to=""><img :src="i.picture" alt="" /><div class="center"><p class="name ellipsis-2">{{ i.name }}</p><p class="attr ellipsis">{{ i.attrsText }}</p></div><div class="right"><p class="price">¥{{ i.price }}</p><p class="count">x{{ i.count }}</p></div></RouterLink><i class="iconfont icon-close-new" @click="store.delCart(i.skuId)"></i></div>--></div><div class="foot"><div class="total"><p>共 10 件商品</p><p>¥ 100.00 </p></div><el-button size="large" type="primary" >去购物车结算</el-button></div></div>
</div>
</template><style scoped lang="scss">
.cart {width: 50px;position: relative;z-index: 600;.curr {height: 32px;line-height: 32px;text-align: center;position: relative;display: block;.icon-cart {font-size: 22px;}em {font-style: normal;position: absolute;right: 0;top: 0;padding: 1px 6px;line-height: 1;background: $helpColor;color: #fff;font-size: 12px;border-radius: 10px;font-family: Arial;}}&:hover {.layer {opacity: 1;transform: none;}}.layer {opacity: 0;transition: all 0.4s 0.2s;transform: translateY(-200px) scale(1, 0);width: 400px;height: 400px;position: absolute;top: 50px;right: 0;box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);background: #fff;border-radius: 4px;padding-top: 10px;&::before {content: "";position: absolute;right: 14px;top: -10px;width: 20px;height: 20px;background: #fff;transform: scale(0.6, 1) rotate(45deg);box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1);}.foot {position: absolute;left: 0;bottom: 0;height: 70px;width: 100%;padding: 10px;display: flex;justify-content: space-between;background: #f8f8f8;align-items: center;.total {padding-left: 10px;color: #999;p {&:last-child {font-size: 18px;color: $priceColor;}}}}}.list {height: 310px;overflow: auto;padding: 0 10px;&::-webkit-scrollbar {width: 10px;height: 10px;}&::-webkit-scrollbar-track {background: #f8f8f8;border-radius: 2px;}&::-webkit-scrollbar-thumb {background: #eee;border-radius: 10px;}&::-webkit-scrollbar-thumb:hover {background: #ccc;}.item {border-bottom: 1px solid #f5f5f5;padding: 10px 0;position: relative;i {position: absolute;bottom: 38px;right: 0;opacity: 0;color: #666;transition: all 0.5s;}&:hover {i {opacity: 1;cursor: pointer;}}a {display: flex;align-items: center;img {height: 80px;width: 80px;}.center {padding: 0 10px;width: 200px;.name {font-size: 16px;}.attr {color: #999;padding-top: 5px;}}.right {width: 100px;padding-right: 20px;text-align: center;.price {font-size: 16px;color: $priceColor;}.count {color: #999;margin-top: 5px;font-size: 16px;}}}}}
}
</style>
pinia获取数据,渲染:
<script setup>
import { useCartStore } from '@/stores/cartStore';const cartStore = useCartStore()
</script><template><div class="cart"><a class="curr" href="javascript:;"><i class="iconfont icon-cart"></i><em>{{ cartStore.cartList.length }}</em></a><div class="layer"><div class="list"><div class="item" v-for="i in cartStore.cartList" :key="i"><RouterLink to=""><img :src="i.picture" alt="" /><div class="center"><p class="name ellipsis-2">{{ i.name }}</p><p class="attr ellipsis">{{ i.attrsText }}</p></div><div class="right"><p class="price">¥{{ i.price }}</p><p class="count">x{{ i.count }}</p></div></RouterLink><i class="iconfont icon-close-new" @click="store.delCart(i.skuId)"></i></div></div><div class="foot"><div class="total"><p>共 10 件商品</p><p>¥ 100.00 </p></div><el-button size="large" type="primary">去购物车结算</el-button></div></div></div>
</template><style scoped lang="scss">
.cart {width: 50px;position: relative;z-index: 600;.curr {height: 32px;line-height: 32px;text-align: center;position: relative;display: block;.icon-cart {font-size: 22px;}em {font-style: normal;position: absolute;right: 0;top: 0;padding: 1px 6px;line-height: 1;background: $helpColor;color: #fff;font-size: 12px;border-radius: 10px;font-family: Arial;}}&:hover {.layer {opacity: 1;transform: none;}}.layer {opacity: 0;transition: all 0.4s 0.2s;transform: translateY(-200px) scale(1, 0);width: 400px;height: 400px;position: absolute;top: 50px;right: 0;box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);background: #fff;border-radius: 4px;padding-top: 10px;&::before {content: "";position: absolute;right: 14px;top: -10px;width: 20px;height: 20px;background: #fff;transform: scale(0.6, 1) rotate(45deg);box-shadow: -3px -3px 5px rgba(0, 0, 0, 0.1);}.foot {position: absolute;left: 0;bottom: 0;height: 70px;width: 100%;padding: 10px;display: flex;justify-content: space-between;background: #f8f8f8;align-items: center;.total {padding-left: 10px;color: #999;p {&:last-child {font-size: 18px;color: $priceColor;}}}}}.list {height: 310px;overflow: auto;padding: 0 10px;&::-webkit-scrollbar {width: 10px;height: 10px;}&::-webkit-scrollbar-track {background: #f8f8f8;border-radius: 2px;}&::-webkit-scrollbar-thumb {background: #eee;border-radius: 10px;}&::-webkit-scrollbar-thumb:hover {background: #ccc;}.item {border-bottom: 1px solid #f5f5f5;padding: 10px 0;position: relative;i {position: absolute;bottom: 38px;right: 0;opacity: 0;color: #666;transition: all 0.5s;}&:hover {i {opacity: 1;cursor: pointer;}}a {display: flex;align-items: center;img {height: 80px;width: 80px;}.center {padding: 0 10px;width: 200px;.name {font-size: 16px;}.attr {color: #999;padding-top: 5px;}}.right {width: 100px;padding-right: 20px;text-align: center;.price {font-size: 16px;color: $priceColor;}.count {color: #999;margin-top: 5px;font-size: 16px;}}}}}
}
</style>
4. 头部购物车删除功能
// 删除购物车const delCart = async (skuId) => {// 思路:// 1. 找到要删除项的下标值 - splice// 2. 使用数组的过滤方法 - filterconst idx = cartList.value.findIndex((item) => skuId === item.skuId)cartList.value.splice(idx, 1)}
<i class="iconfont icon-close-new" @click="cartStore.delCart(i.skuId)"></i>
5. 头部购物车统计计算
使用computed
计算属性
计算逻辑是什么:
- 商品总数计算逻辑:商品列表中的所商品 count 累加之和
- 商品总价钱计算逻辑:商品列表中的所有商品的 count*price 累加之和
//计算购物车件数和总价格//总数const allCount = computed(() => cartList.value.reduce((a, c) => a + c.count, 0))//总价const allPrice = computed(() => cartList.value.reduce((a, c) => a + c.count * c.price, 0))return {cartList,allCount,allPrice,addCart,delCart}
<div class="total"><p>共 {{ cartStore.allCount }}件商品</p><p>¥ {{ cartStore.allPrice }}</p></div>
总结
love and peace
持续更新~~