登录
静态页面搭建,并给首页的请求登录绑定跳转
<li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li>
表单校验
当使用UI组件库中提供的Form表单的时候,会提供表单的校验功能。根据文档设计需要的校验规则即可。
制定登录表单的校验规则
如下数据制定后绑定到表单中可以正常显示数据
const loginFormRef = ref()
const loginForm = reactive({
account: "xiaotuxian001",
password: "123456",
})
const loginRules = reactive({
account: [
{ required: true, message: "用户名不能为空", trigger: "blur" }
],
password: [
// 双校验
{ required: true, message: "密码不能为空", trigger: "blur" },
{ min: 6, max: 14, message: "长度在 6 到 14 个字符", trigger: "blur" }
]
})
自定义校验规则
在Element plus表单组件中内置了一些简单的校验规则,如上面的代码就是。像这种简单的校验只需要调用提供的配置项即可。如果想定制一些特殊的校验规则就需要采用自定义校验规则,其有对应的验证格式。
自定义校验规则依旧是在表单的校验对象中完成。添加一个新的字段,负责处理勾选时候的逻辑同时绑定自定义校验规则。
自定义校验的基本格式如下:
{
validator:(rule,value,callback)=>{
//callback不论成功或失败都需要调用
}
}
const loginForm = reactive({
......
agree: true
})
const loginRules = reactive({
.........
agree: [
{
// 自定义校验规则
validator: (rule, value, callback) => {
// value 每次勾选为true,否则false
// callback 勾选通过的回调函数,否则调用打印错误提示
if (value) {
callback()
} else {
callback(new Error("请同意用户协议!"))
}
}
}
]
})
<el-form-item prop="agree" label-width="22px">
<el-checkbox v-model="loginForm.agree" size="large">
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
登录校验
这里创建一个点击登录进行统一检验的函数,防止用户不输入内容直接登录,在函数内部,调用表单提供的validate函数
,该函数传入一个回调函数并且配置参数,在回调函数内部会对所有的表单内容进行校验是否通过。全部通过则valid为true,这个时候就可以在true的逻辑代码片段中实现登录的主体功能了。
// 登录方法
const login = () => {
// 调用表单提供的函数进行统一验证
loginFormRef.value.validate((valid) => {
console.log(valid)
})
}
以下是基本的结构代码结合了校验属性
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-position="right" label-width="60px"
status-icon>
<el-form-item prop="account" label="账户">
<el-input v-model="loginForm.account" />
</el-form-item>
<el-form-item prop="password" label="密码">
<el-input v-model="loginForm.password" />
</el-form-item>
<el-form-item prop="agree" label-width="22px">
<el-checkbox v-model="loginForm.agree" size="large">
我已同意隐私条款和服务条款
</el-checkbox>
</el-form-item>
<el-button size="large" class="subBtn" @click="login">点击登录</el-button>
</el-form>
实现登录与服务器验证功能
整个登录的基本逻辑如下,最外层if (valid)...else
判断用户是否输入了数据,内层使用了try..catch
进行报错捕获,即用户输入不正确信息的时候,将服务器返回的数据提示给用户看。同时在路由拦截器中做了一个统一错误提示。用户登录正确的时候,清空输入的内容,跳转至主页。
//封装的用户登录接口
export function loginAPI({ account, password }) {
return instance({
url: "/login",
method: "POST",
data: {
account,
password
}
})
}
const router = useRouter()
// 登录方法
const login = () => {
const { account, password } = loginForm
// 调用表单提供的函数进行统一验证
loginFormRef.value.validate(async (valid) => {
if (valid) {
try {
// 验证通过不报错
await loginAPI({ account, password })
ElMessage({
message: '登录成功',
type: 'success',
})
loginForm.account = ''
loginForm.password = ''
router.replace("/") //登录成功路由跳转
} catch (e) {
loginForm.account = ''
loginForm.password = ''
}
} else {
ElMessage({
message: '请输入用户名和密码',
type: 'error',
})
}
})
}
// 添加响应拦截器
instance.interceptors.response.use(res => {
return res
}, e => {
ElMessage({
type: 'warning',
message: e.response.data.message
})
return Promise.reject(e)
});
pinia管理用户数据
封装一个stores/userStore.js
管理用户的store数据,这里采用对象式,也可以使用函数式返回数据使用
import { defineStore } from "pinia";
import { loginAPI } from '@/apis/use';
export const useUserStore = defineStore('user', {
state: () => {
return {
userInfo: {} //proxy响应式对象
}
},
actions: {
async getUserInfo({ account, password }) {
let res = await loginAPI({ account, password })
this.userInfo = res
}
}
})
在这里调用封装在pinia中的接口去请求数据,使用await
等待后面的语句执行完毕后,才会往下执行
try {
// 验证通过不报错
// await loginAPI({ account, password })
await useUser.getUserInfo({ account, password })
}
这边验证,可以实现逻辑功能。
pinia用户数据持久化
引入第三方库实现持久化
这里采用插件帮助我们快速实现持久化效果。使用npm安装:npm i pinia-plugin-persistedstate
在入口文件只引入该插件注册使用
// 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const app = createApp(App)
// 注册并使用插件
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
这里采用的是选项式配置store,因此直接在对象内部配置:persist: true
,即可每次操作state中的数据自动进行本地存储器
export const useUserStore = defineStore('user', {
.....
persist: true,
})
登录成功后本地存储中出现登录数据即可。
自定义插件实现数据持久化
首先建立一个persistencePlugin.js
文件,该代码的主要功能:针对userStore
进行数据持久化处理。$subscribe()
方法监听store状态的变化,即state数据每次变化的时候都会执行一次。mutation
参数中存放不同Store的信息,可以根据信息只针对某一个store进行处理。state
是当前store中的state数据。
export function persistencePlugin(ctx) {
ctx.store.$subscribe((store, state) => {
if (store.storeId === 'user') { //每一个store的唯一id
localStorage.setItem(store.storeId, JSON.stringify(state.userInfo))
}
}, {
detached: true //组件被卸载时,它将不被自动清理掉
})
}
在useUserStore.js
文件中新增修改state中userInfo
的方法。并在Layout/Index.vue
文件的生命周期函数中引入并调用useUser.updateUserInfo()
。这样子每次刷新的时候,pinia中数据不丢失。
updateUserInfo() {
this.userInfo = JSON.parse(localStorage.getItem('user') || "{}")
}
在入口文件中引入并使用接口
import { persistencePlugin } from './stores/plugin/persistence'
const pinia = createPinia()
pinia.use(persistencePlugin)
这样子也能实现数据保存至本地存储中
登录和非登录模块切换
控制如图所示区域登录或非登录状态下显示正确的内容
在LayoutNav
组件中修改部分代码
import { useUserStore } from '@/stores/userStore';
const useUser = useUserStore()
验证该store中的数据字段是否存在token。同时修改名字字段显示正确的内容
<template v-if="useUser.userInfo.token">....</template> ....
统一携带token
如果一些服务器的安全系数很高,要求每次访问的时候都需要带上token,如果页面中多个接口都需要携带token,就可以使用拦截器配置。
在请求拦截器中配置每次请求的时候都带上token
let token = JSON.parse(localStorage.getItem('user')).token //可能为undefined
// 每次请求之前带上token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
退出登录
给退出登录气泡窗绑定UI组件库提供的确认事件 @confirm="handleConfirm"
主体代码如下,退出的时候删除对应store中的数据,同时情况本地存储,最后实现跳转。
//useUserStore中新增的删除方法
deleteUserInfo() {
this.userInfo = {}
}
在这里可以不主动删除本地存储,因为在插件中间接性的完成了。插件代码如下,每次退出登录到登录页面的时候,都会经过useUserStore()
重新注册该store,而监视store改变的时候成功进入if判断语句中,而当前useUserStore
的state中的userInfo数据为空,所以会同步更新到本地存储中。
const handleConfirm = () => {
// 情况本地存储中的数据和pinia中的数据
// localStorage.removeItem("user")
useUser.deleteUserInfo()
router.push('/login')
}
token失效处理
在网页中token是具有时效期的,如果过期就了就需要重新登录获取新的token保存。在所有的页面中都需要验证token的时效性,这个时候就可以使用拦截器了,在拦截器中的请求拦截器中每次将token发送给服务器验证,如果token失效了,就会返回401状态码被响应拦截器接收处理。
在响应拦截器中处理逻辑代码如下,先引入需要的文件,这里需要重点了解vue3中非vue文件中引入路由的方式如下 import router from "@/router";
import { useUserStore } from "@/stores/userStore";
import router from "@/router";
// 添加响应拦截器
instance.interceptors.response.use(res => {
return res
}, e => {
const userStore = useUserStore()
ElMessage({
type: 'warning',
message: e.response.data.message
})
// 处理token失效问题
if (e.response.status === 401) {
userStore.deleteUserInfo() //删除store中的数据,然后跳转到登录页面
router.push('/login') //跳转的同时会同步删除本地存储
}
return Promise.reject(e)
});
购物车
购物车中的数据需要进行持久化处理,下图是整体逻辑
本地购物车
以下是未登录情况下,按钮点击添加商品加入购物车的逻辑如下
首先创建一个skuObj
变量保存每次返回的商品规格信息
let skuObj = {}
// 处理sku
const handleChange = (data) => {
skuObj = data //每一个sku都具有唯一id,若空则无
}
引入增减计数组件,实现商品的增加和减少,在这里先不进行赋值,只进行简单的处理
// 增减商品数量
const count = ref(1)
// 数量改变时的回调
const handleCount = (value) => {
//value为修改后的数量
console.log(value)
}
<el-input-number v-model="count" @change="handleCount" />
其次每次点击最终的加入购物车按钮都需要进行sku验证。
<el-button size="large" class="btn" @click="addCart">加入购物车</el-button>
const useCart = useCartStore()
// 添加购物车
const addCart = () => {
// 是否能够添加购物车依据sku中的id号
if (skuObj.skuId) { //未全部选择商品规格,则为undefined
// 添加购物车
useCart.addCart({
id: goods.value.id, //商品id
name: 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({
message: "请选择规格",
type: "warning"
})
}
}
创建一个useCart
文件,在该store中采用函数式的写法,其中updateCart
是为了每次输出的时候保持本地存储中数据和pinia中数据一致,在Layout/Index.vue
中调用。
import { defineStore } from "pinia";
import { ref } from "vue";
export const useCartStore = defineStore("cart", () => {
// 定义state
const cartList = ref([])
// 定义actions
const addCart = (goods) => {
// 首先查看当前已有商品中是否存在该商品
// find方法返回数组中第一个满足条件的元素
const item = cartList.value.find(item => item.skuId === goods.skuId)
if (item) {
// 找到的情况下,同商品数量+1
item.count++ //这里的item会同步到carList中对应的item项
} else {
// 没有找到的情况下,直接添加到数组中
cartList.value.push(goods)
}
}
// 更新本地存储数据到pinia
const updateCart = () => {
cartList.value = JSON.parse(localStorage.getItem('cart') || '[]')
}
return {
cartList,
addCart,
updateCart
}
})
最后在持久化的插件中引入该代码,实现持久化效果
if (store.storeId === 'cart') {
localStorage.setItem(store.storeId, JSON.stringify(state.cartList))
}
头部购物车图标逻辑代码实现
默认情况将商品的数量显示在右上角,每次点击进去的时候查看商品的规格信息。
创建一个文件,Layout/HeaderCart.vue
,并在LayoutHeader.vue
组件中引入使用<HeaderCart></HeaderCart>
。
在HeaderCart.vue
文件中代码如下,首先引入对应的store并使用,然后将数据遍历显示
// 读取pinia中商品的数量显示
const useCart = useCartStore()
<div class="cart">
<a class="curr" href="javascript:;">
<i class="iconfont icon-cart"></i><em>{{ useCart.cartList.length }}</em>
</a>
<div class="layer">
<div class="list">
<div class="item" v-for="i in useCart.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">去购物车结算</el-button>
</div>
</div>
</div>
其中,最主要的控制商品显示的样式如下,默认情况下不显示,transform: translateY(-200px) scale(1, 0);
该样式中scale(1, 0)
控制容器缩放,不加translateY(-200px)
的时候默认在容器中间进行上下缩放,因为scale(1, 0)
中x轴为正常大小,y轴为0,代表横向大小正常,纵向被缩小了,且往中间缩放。这个时候translateY(-200px)
可以造成视觉冲突从顶部下来的感觉,默认将位置移动到顶部,鼠标移入的时候将transform
移除,进入过渡效果,一边恢复位置一边放大。
opacity: 0;
transition: all 0.4s 0.2s;
transform: translateY(-200px) scale(1, 0);
扩展设置滚动条样式
&::-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;
}
删除购物车商品
在cartStore
中新增删除函数,这里使用filter函数
过滤不需要的数据,同时需要知道该函数不会影响原数组,返回新数组,需要将新数组赋值过去
// 删除购物车商品
const delCart = (skuId) => {
cartList.value = cartList.value.filter(item => item.skuId !== skuId)
}
在HeaderCart
组件中使用该方法
<!-- 删除按钮 -->
<i class="iconfont icon-close-new" @click="useCart.delCart(i.skuId)"></i>
购物车统计模块
这里采用在pinia中使用计算属性来获取商品的总数量和总价使用。
// cartStore中定义
// 计算数量
const allCount = computed(() => {
return cartList.value.reduce((sum, item) => sum += item.count, 0)
})
// 计算总价
const allPrice = computed(() => {
return cartList.value.reduce((sum, item) => sum += item.count * item.price, 0)
})
<p>共 {{ useCart.allCount }} 件商品</p>
<p>¥ {{ useCart.allPrice.toFixed(2) }} </p>
列表购物车基本结构搭建
给上图所示的结算按钮绑定路由跳转
{
path: "cartList",
component: () => import('@/views/CartList/Index.vue')
}
<el-button size="large" @click="$router.push('/cartList')">去购物车结算</el-button>
创建一个CartList/Index.vue
文件并编写基本结构代码,在代码中,对pinia中的商品数组cartList
进行了判断,如果存在商品就显示,否则就显示一个空页面提示用户
列表购物车单选功能
需要将单选功能同步到pinia中的字段数据,保持两者之间同步。这里不直接使用v-model
而是使用它的一般状态:model-value
和@change
实现。读取pinia中的数据,并且在数据改变的时候能够即使同步到pinia中。
- 首先双向数据绑定,将pinia中对应商品的信息通过遍历绑定到复选框上显示
<el-checkbox :model-value="i.selected" @change="singleChecked(i, $event)" />
//也可以如下写法接收默认参数
@change="(selected) => singleCheck(i, selected)"
- 绑定一个
change
事件,并且需要在回调函数中传入两个参数,每次需要知道修改哪一个商品和修改的值是什么。其中el-checkbox
组件提供了change
事件,并且默认会提供一个参数,该参数为布尔值,决定了该复选框是否选择。如果即需要默认参数也需要使用自定义参数,那么就需要写出完整写法如上。在这里$event
并事件对象,而是一个布尔值。
const singleChecked = (item, e) => {
useCart.singleChecked(item.skuId, e) // e为true或false
}
- 在
cartStore
中新增修改复选框的代码
//更新单选按钮状态
const singleChecked = (skuId, selected) => {
let item = cartList.value.find(item => item.skuId === skuId)
item.selected = selected
}
效果图如下
列表购物车全选功能
在cartStore
中新增如下方法
//actions
// 全选影响单选
const allCheck = (selected) => {
cartList.value.forEach(item => item.selected = selected)
}
//getter
// 全选按钮(单选影响全选)
const isAll = computed(() => {
return cartList.value.every(item => item.selected === true)
})
<el-checkbox :model-value="useCart.isAll" @change="allChecked" />
const allChecked = (selected) => {
useCart.allCheck(selected)
}
购物车列表数据统计
在cartStore
中编写如下代码,最后在模板中使用即可
// 统计已选数量
const selectedCount = computed(() => {
return cartList.value.filter(item => item.selected).reduce((sum, item) => {
return sum += item.count
}, 0)
})
// 统计已选数量的总价
const selectedPrice = computed(() => {
return cartList.value.filter(item => item.selected).reduce((sum, item) => {
return sum += item.price * item.count
}, 0)
})
登录使用接口–购物车
当用户登录的时候,点击购物车添加的时候都会走接口请求。如何判断是否登录需要使用到token。
加入购物车
首先封装一个cart.js
API文件
// 加入购物车
export function insertCartAPI({ skuId, count }) {
return instance({
method: "POST",
url: "/member/cart",
data: {
skuId,
count
}
})
}
//获取最新购物车列表数据
export function getNewCartList() {
return instance({
method: "GET",
url: "/member/cart"
})
}
在cartStore
中引入userStore
使用其中的token字段
const useUser = useUserStore() //必须写在defineStore函数中
const isHasToken = computed(() => useUser.userInfo.token) //判断是否存在数据
// 添加购物车商品
const addCart = async (goods) => {
// 判断是否登录,进入不同的分支
if (isHasToken.value) {
// 登录状态,调用接口
await insertCartAPI(goods)
let res = await getNewCartList()
cartList.value = res.data.result
} else {
// 没有登录
// 首先查看当前已有商品中是否存在该商品
// find方法返回数组中第一个满足条件的元素
const item = cartList.value.find(item => item.skuId === goods.skuId)
if (item) {
// 找到的情况下,同商品数量+1
item.count++
} else {
// 没有找到的情况下,直接添加到数组中
cartList.value.push(goods)
}
}
}
删除购物车
逻辑和加入购物车基本一致
封装一个删除数据接口
// 删除购物车商品
export function delCartAPI(ids) {
return instance({
method: "DELETE",
url: "/member/cart",
data: {
ids
}
})
}
// 删除购物车商品
const delCart = async (skuId) => {
if (isHasToken.value) {
await delCartAPI([skuId]) //传入数组,参数支持一个或多个
let res = await getNewCartList()
cartList.value = res.data.result
} else {
cartList.value = cartList.value.filter(item => item.skuId !== skuId)
}
}
在这里会存在多个模块需要用到获取最新列表数据的接口,如加入和删除购物车,购物车全选或单选等。所以可以将公共的部分暂时分离出来使用。之后使用函数,替换掉重复代码的地方即可。
const getCartList = async () => {
let res = await getNewCartList()
cartList.value = res.data.result
}
退出登录–清空购物车
在之前的退出登录中,只清空了用户的数据,这次需要额外清空用户添加的商品信息(这里是清空本地存储的商品信息,非服务器)
在cartStore
中添加情况购物车数据的逻辑代码
// 清除购物车数据
const clearCart = () => {
cartList.value = []
}
在userStore
中代码如下
state: () => {
return {
.......
useCart: useCartStore()
}
},
//actions中代码
deleteUserInfo() {
this.userInfo = {} //清空用户数据
this.useCart.clearCart() //清空用户添加的商品信息
}
合并本地和登录状态下的购物车
如果未登录的时候用户在本地加入购物车,希望在登录后,将本地的数据与服务器购物车的数据进行合并。
封装一个合并购物车模块接口。用户需要在登录的时候调用该接口使用
// 合并购物车
export function mergeCartAPI(data) {
return instance({
method: "POST",
url: "/member/cart/merge",
data
})
}
在userStore
的登录函数中添加如下代码
async getUserInfo({ account, password }) {
let res = await loginAPI({ account, password })
this.userInfo = res.data.result
// 合并购物车
await mergeCartAPI(this.useCart.cartList.map(item => {
return {
skuId: item.skuId,
selected: item.selected,
count: item.count
}
}))
// 调用最新列表数据显示
this.useCart.getCartList()
},
结算模块
路由配置和搭建结构
首先创建一个Checkout/index.vue
组件并为其搭建路由信息
{
path: "checkout",
component: () => import('@/views/Checkout/index.vue')
}
之后在CartList/Index.vue
组件中为按钮绑定点击跳转事件
<el-button class="btn" size="large" type="primary" @click="$router.push('/checkout')">下单结算</el-button>
封装一个接口文件,用于获取详情页订单数据/apis/checkout.js
export const getCheckoutInfoAPI = () => {
return instance({
method: "GET",
url: '/member/order/pre'
})
}
在Checkout/index.vue
文件中引入并使用
const checkInfo = ref({}) // 订单对象
const curAddress = ref({}) // 地址对象
//获取详情订单数据,包括了商品信息,用户地址信息等
const getCheckoutInfo = async () => {
const res = await getCheckoutInfoAPI()
checkInfo.value = res.data.result
checkInfo.value.userAddresses.filter(item => {
if (item.isDefault === 0) { //字段为0的默认显示
curAddress.value = item
}
})
}
onMounted(() => {
getCheckoutInfo()
})
地址切换
弹出层控制需要设置一个变量,并且在点击的时候使用v-model
双向数据绑定该值
const isShow = ref(false)
<el-button size="large" @click="isShow = !isShow">切换地址</el-button>
......
<el-dialog v-model="isShow" >....</el-dialog>
接下来需要处理地址激活的部分,首先如何判断激活的地址数据和当前地址数据是否一致(每一个地址信息都具有id),
- 创建一个激活地址变量,保存数据,方便比较
const activeAddress = ref({}) //当前激活对象
- 给每一个地址对象绑定点击事件,同时动态赋值
active
属性
<div class="text item" :class="{ active: activeAddress.id === item.id }" @click="switchAddress(item)">...</div>
// 激活的当前地址
const switchAddress = (item) => {
activeAddress.value = item //保存当前点击激活的地点
}
- 添加初始状态绑定激活
if (item.isDefault === 0) { //字段为0的默认显示
curAddress.value = item
activeAddress.value = curAddress.value //将默认的数据显示激活
}
- 弹出层确定按钮,更换数据
<el-button type="primary" @click="confirm">确定</el-button>
// 地址弹出层确定按钮
const confirm = () => {
// 显示的地址位激活的地址
curAddress.value = activeAddress.value
isShow.value = false //关闭弹出层
}
生成订单
首先准备基本模板。创建一个Pay/Index.vue
文件,用于生成订单信息,每次跳转过来的时候路由地址如下,采用query形式传参,非params
创建对应的路由信息
{
path: "pay",
component: () => import('@/views/Pay/Index.vue')
}
创建对应的接口
export function createOrderAPI(data) {
return instance({
method: "POST",
url: "/member/order",
data
})
}
在Checkout/index,vue
组件中为提交订单绑定点击事件,在该回调函数中调用接口传入参数,这里传参只需要注意goods
和addressId
属性即可,其他均写默认值。并且根据返回的对象数据信息,取出生成订单的id
数据进行路由跳转,并且每次跳转成功后,调用最新的购物车列表数据显示。
<el-button type="primary" size="large" @click="createOrder">提交订单</el-button>
const useCart = useCartStore()
const router = useRouter()
// 创建一个订单信息
const createOrder = async () => {
let res = await createOrderAPI({
deliveryTimeType: 1,
payType: 1,
payChannel: 1,
buyerMessage: "",
goods: checkInfo.value.goods.map(item => {
return {
skuId: item.skuId,
count: item.count
}
}),
addressId: curAddress.value.id
})
let orderId = res.data.result.id
router.push({
path: "/pay",
query: {
id: orderId
}
})
useCart.getCartList()
}
支付模块
渲染基础数据
创建一个pay.js
文件并编写代码
export function getOrderAPI(id) {
return instance({
method: 'GET',
url: `/member/order/${id}`
})
}
在Pay/Index.vue
文件中代码如下
const route = useRoute()
const payInfo = ref({})
// 获取订单详情信息
const getOreder = async () => {
let res = await getOrderAPI(route.query.id)
payInfo.value = res.data.result
console.log(payInfo.value)
}
onMounted(() => {
getOreder()
})
获取的输出结果如下,其中支付的限定时间为30分钟,如果countdown=-1
则代表超时
实现支付功能
业务流程
在Pay/Index.vue
组件中设置支付宝的跳转地址,
// 支付地址
const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/';
const backURL = 'http://127.0.0.1:5173/paycallback'; //支付成功后的自动跳转页面
const redirectUrl = encodeURIComponent(backURL);
const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}`;
支付结果展示
{
path: "paycallback",
component: () => import('@/views/Pay/PayBack.vue')
},
如果支付成功,跳转后的地址如下,根据payResult
和orderId
属性的值渲染不同的内容
首先根据payResult
的值只显示一个字体图标,同时修改对应显示的内容提示
<span class="iconfont icon-queren2 green" v-if="$route.query.payResult === 'true'"></span>
<span class="iconfont icon-shanchu red" v-else></span>
<p class="tit">支付{{ $route.query.payResult === 'true' ? '成功' : '失败' }}</p>
之后根据路由参数中的的orderId
获取当前订单的支付金额显示
let orderInfo = ref({});
let route = useRoute()
const gerOrderInfo = async () => {
let res = await getOrderAPI(route.query.orderId);
orderInfo.value = res.data.result
}
onMounted(() => gerOrderInfo())
<p>支付金额:<span>¥{{ orderInfo.payMoney?.toFixed(2) }}</span></p>
封装倒计时函数
将倒计时的函数封装在一个通用性的文件夹中使用,封装一个useCountDown.js
文件
import { computed, onUnmounted, ref } from "vue";
import dayjs from "dayjs";
// 倒计时函数
export function useCountDown() {
let timer = null
const time = ref(0)
// 格式化时间
const formatTime = computed(() => {
return dayjs.unix(time.value).format("mm分ss秒")
})
// 启动倒计时函数,参数为启动的时间
const start = (currentTime) => {
time.value = currentTime
timer = setInterval(() => {
if (time.value <= 0) return
time.value--
}, 1000)
}
// 组件卸载时候关闭定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start
}
}
在Pay/Index.vue
文件中引入并使用
const { formatTime, start } = useCountDown()
const getOreder = async () => {
.......
start(res.data.result.countdown) //传入30分钟的时间戳
}
会员中心
路由搭建
搭建一个如下图所示的路由结构,在该模块中使用到了三级路由
{
path: "member",
component: () => import('@/views/Member/Index.vue'),
children: [
{
path: "user",
component: () => import('@/views/Member/components/UserCenter.vue')
},
{
path: "order",
component: () => import('@/views/Member/components/UserOrder.vue')
}
]
}
个人中心信息展示
效果图如下
在user.js
文件中封装获取喜欢列表商品展示
// 封装猜你喜欢接口
export function getLikeListAPI(limit = 4) {
return instance({
method: "GET",
url: "/goods/relevant",
params: {
limit
}
})
}
用户的信息展示可以直接读取pinia中存储的数据
const useUser = useUserStore()
const likeList = ref([])
const getLikeList = async () => {
let res = await getLikeListAPI()
likeList.value = res.data.result
}
onMounted(() => getLikeList())
我的订单列表渲染
封装一个oreder.js
文件用于获取订单列表数据
export const getUserOrder = (params) => {
return instance({
url: '/member/order',
method: 'GET',
params
})
}
在UserOrder
组件中代码如下
// 订单列表
const orderList = ref([])
// 接口参数
let params = ref({
/* 订单状态,1为待付款、2为待发货、3为待收货、4为待评价、5为已完成、6为已取消,未传该参数或0为全部 */
orderState: 0,
page: 1,
pageSize: 2
})
const getOrderList = async () => {
let res = await getUserOrder(params.value)
orderList.value = res.data.result.items
}
onMounted(() => getOrderList())
tab栏切换
点击不同的tab栏的时候需要修改对应的orderState
的值,然后重新调用接口获取新数据显示。在tab栏UI组件中,提供了tab-change
事件,在该事件的回调函数中默认提供了一个参数使用,该参数一一对应orderState
状态的值,该参数对应当前元素在tab栏数组中的下标位置。
<el-tabs @tab-change="tabChange">
<el-tab-pane v-for="item in tabTypes" :key="item.name" :label="item.label" />
。。。。。
</el-tabs>
// tab列表
const tabTypes = [
{ name: "all", label: "全部订单" },
{ name: "unpay", label: "待付款" },
{ name: "deliver", label: "待发货" },
{ name: "receive", label: "待收货" },
{ name: "comment", label: "待评价" },
{ name: "complete", label: "已完成" },
{ name: "cancel", label: "已取消" }
]
// tab栏切换事件
const tabChange = (id) => {
params.value.orderState = id
getOrderList()
}
订单分页
使用Element plus提供的分页器组件完成,分页数=总的商品数量/每页数量。总的商品数量在初始化调用接口的时候就已经返回了。设置完分页数后,用户每次点击页数的时候都将当前页码获取修改params.page
参数并重新发送网络请求。
const total = ref(0) //总的商品数量
const getOrderList = async () => {
.........
total.value = res.data.result.counts
}
//默认page-size每页大小为10
<el-pagination
@current-change="pageChange"
:total="total"
:page-size="params.pageSize"
background layout="prev, pager, next"
/>
之后需要使用组件库提供的 current-change
事件获取每次点击的页数
// 分页请求获取数据
const pageChange = (page) => {
params.value.page = page
getOrderList()
}
之后需要格式化页面中的部分数据显示,下图所示区域就需要进行状态适配
<p>{{ formatState(order.orderState) }}</p>
// 格式化状态显示
const formatState = (state) => {
const stateLabel = {
1: "待付款",
2: "待发货",
3: "待收货",
4: "待评价",
5: "已完成",
6: "已取消"
}
return stateLabel[state]
}
sku组件
功能拆分
用户每次选择商品规格的时候,组件的激活状态都需要进行功能,如下图每次点击都会显示绿色边框。同时用户每次选择的时候还需要提示用户当前规格是否禁用,如下图中国字段。最后每次选择完成提交的时候都需要产出sku数据。
<template>
<div class="goods-sku">
<dl v-for="item in goods.specs" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>
<template v-for="val in item.values" :key="val.name">
<!-- 图片类型规格 -->
<img v-if="val.picture" :src="val.picture" :title="val.name">
<!-- 文字类型规格 -->
<span v-else>{{ val.name }}</span>
</template>
</dd>
</dl>
</div>
</template>
// 商品数据
const goods = ref({})
const getGoods = async () => {
// 1135076 初始化就有无库存的规格
// 1369155859933827074 更新之后有无库存项(蓝色-20cm-中国)
const res = await axios.get('http://pcapi-xiaotuxian-front-devtest.itheima.net/goods?id=1369155859933827074')
goods.value = res.data.result
}
onMounted(() => getGoods())
点击更新规格
- 点击已选规格的时候,将该规格取消
- 点击未选规格的时候,先将其他已选规格取消,在将自身规格选中
基本的逻辑代码如下
const changeSelectedStatus = (row, item) => {
//row 一排的总商品规格信息
// item 当前选中的规格
if (item.selected) {
//如果已选规格则取消
item.selected = false
} else {
// 未选规格
row.forEach(element => {
element.selected = false //相当于给每一个对象添加新属性
})
item.selected = true
}
}
<!-- 图片类型规格 -->
<img @click="changeSelectedStatus(item.values, val)" :class="{ selected: val.selected }" v-if="val.picture"
:src="val.picture" :title="val.name">
<!-- 文字类型规格 -->
<span @click="changeSelectedStatus(item.values, val)" :class="{ selected: val.selected }" v-else>
{{ val.name}}
</span>
点击规格禁用路径字典
原理:在电商网站中,一个商品的规格信息会对应不同的库存信息,如果当前选中商品规格的库存为0时,就需要禁用当前规格项,生成路径字典就是为了协助和简化这个匹配过程。
const getGoods = async () => {
.......
getPathMap(goods.value)
}
在这里,重点是将每组符合条件的valueName
组成一个数组
// 创建路径字典
const getPathMap = (goods) => {
// 筛选出所有存在库存的数据,即inventory 库存字段>0
const effectiveSkus = goods.skus.filter(item => item.inventory > 0)
// 根据有效的数据筛选出子集:[1,2]=>[[1],[2],[1,2]]
effectiveSkus.forEach(sku => {
const selectedValArr = sku.specs.map(val => val.valueName)
console.log(selectedValArr);
})
}
然后封装一个获取子集的接口,[1, 2]=> [[], [1], [2], [1, 2]]
export function getSubsets(arr) {
const result = [[]];
for (const num of arr) {
const len = result.length; //1 , 2
for (let i = 0; i < len; i++) {
const subset = result[i];
result.push([...subset, num]); //扩展运算符展开一个空数组时,它将不会展开任何元素
}
}
return result;
}
紧跟上面输出代码后面调用获取子集的接口实现。
const valueArrPowerSet = getSubsets(selectedValArr)
console.log(valueArrPowerSet);
之后继续添加如下代码,该函数功能是为每一个子集组合一个唯一编码。
// 根据子集插入数据
valueArrPowerSet.forEach(arr => {
const key = arr.join('-') //进行分割
if (pathMap[key]) {
pathMap[key].push(sku.id)
} else {
pathMap[key] = [sku.id]
}
console.log(pathMap);
})
完整代码
const getPathMap = (goods) => {
const pathMap = {}
// 筛选出所有存在库存的数据
const effectiveSkus = goods.skus.filter(item => item.inventory > 0)
// 根据有效的数据筛选出子集:[1,2]=>[[1],[2],[1,2]]
effectiveSkus.forEach(sku => {
const selectedValArr = sku.specs.map(val => val.valueName)
// 筛选子集
const valueArrPowerSet = getSubsets(selectedValArr) //[1,2]=>[[],[1],[2],[1,2]]
// 根据子集插入数据
valueArrPowerSet.forEach(arr => {
const key = arr.join('-') //进行分割
if (pathMap[key]) {
pathMap[key].push(sku.id)
} else {
pathMap[key] = [sku.id]
}
})
})
return pathMap
}
初始化禁用规格
这次需要根据spec
中提供values
字段的name
完成禁用
封装一个initDisabledState
函数,传入需要匹配的参数
const getGoods = async () => {
....
const pathMap = getPathMap(goods.value)
// 初始化规格禁用
initDisabledState(goods.value.specs, pathMap)
}
主体代码如下,根据每一个val.name
的值去匹配pathMap
中是否存在该值动态赋予每一个元素disable
的值
// 初始化规格函数
const initDisabledState = (specs, pathMap) => {
specs.forEach(item => {
item.values.forEach(val => {
console.log(val.name);
})
})
}
const initDisabledState = (specs, pathMap) => {
specs.forEach(item => {
item.values.forEach(val => {
if (pathMap[val.name]) {
val.disabled = false //存在就不禁用
} else {
val.disabled = true //不存在就禁用
}
//可以简化 val.disabled = !pathMap[val.name]
})
})
}
最后给元素动态绑定class。但是这样子会出现一个bug,即被禁用的商品缺还可以被点击,这样子是不对的,所以需要在激活样式中进行判断。
<img @click="changeSelectedStatus(item.values, val)" :class="{ selected: val.selected, disabled: val.disabled }">
const changeSelectedStatus = (row, item) => {
if (item.disabled) return //被禁用直接不执行后续激活逻辑
.........
}
组合的时候如何处理禁用状态
大致思路如下:例如每次点击蓝色+20cm+中国的时候,会生成一个key,拿着该key值去pathMap中匹配,匹配不成功,就禁用当前项
封装如下代码
// 获取每次点击组成的规格数组
const getSelectedValues = (specs) => {
let arr = []
specs.forEach(spec => {
// 每行规格当前激活选项有且只有一个或者全部未激活
const selectedVal = spec.values.find(item => item.selected)
arr.push(selectedVal ? selectedVal.name : undefined) //处理未点击的其他规格选项
})
console.log(arr);
return arr
}
效果图如下
// 点击切换更新禁用状态
const updateDisabledStatus = (specs, pathMap) => {
specs.forEach((spce, index) => {
let selectedVal = getSelectedValues(specs) //接收返回的规格数组
// 填充字段
spce.values.forEach(val => {
selectedVal[index] = val.name
// 过滤掉undefined字段
let key = selectedVal.filter(item => item).join('-')
// 进行pathMap匹配
if (pathMap[key]) {
val.disabled = false
} else {
val.disabled = true
}
})
})
}
最终实现效果如下
产出有效sku信息
当一个商品所有的规格信息都被选中的时候,才会产出一个规格对象信息,否则就产出一个空对象。用户每次点击的时候都需要进行判断。
getSelectedValues()
函数中负责返回结构如下的数据:["数据",undefined,undefined]
,这个时候只需要判断每个元素是否为undefined
即可判断是否全部选中规格。
当规格全部选中的时候,就可以去路径字典中匹配路径所对应的skuId
,并将该skuId
与返回的数据skus
对象中的每一个数据的id
进行比对。
只要选择所有的商品规格,那么只会唯一产出一个id信息存放在pathMap
中保存。
let res = {} //接收最终返回的数据
// 激活项
const changeSelectedStatus = (row, item) => {
.......
let index = getSelectedValues(goods.value.specs).findIndex(item => item === undefined)
if (index != -1) {
console.log(res);
} else {
// 组成路径去匹配
let key = getSelectedValues(goods.value.specs).join("-")
let skuIds = pathMap[key] //找找唯一id
res = goods.value.skus.filter(item => { //匹配唯一id所对应的规格信息
if (item.id === skuIds[0]) {
return item
}
})[0]
console.log(res);
}
}
对应输出如下