目录
结算模块-地址切换交互实现
地址切换交互需求分析
打开弹框交互实现
地址激活交互实现
订单模块-生成订单功能实现
支付模块-实现支付功能
支付业务流程
支付模块-支付结果展示
支付模块-封装倒计时函数
理解需求
实现思路分析
会员中心-个人中心信息渲染
分页逻辑实现
SKU组件封装
认识SKU组件
点击规格更新选中状态
点击规格更新禁用状态 - 生成有效路径字典(1)
点击规格更新禁用状态 - 生成有效路径字典(2)
点击规格更新禁用状态 - 初始化规格禁用
击规格更新禁用状态 - 点击时组合禁用更新
产出有效的SKU信息
完整代码
结算模块-地址切换交互实现
地址切换交互需求分析
1. 打开弹框交互:点击切换地址按钮,打开弹框,回显用户可选地址列表
2. 切换地址交互:点击切换地址,点击确定按钮,激活地址替换默认收货地址
打开弹框交互实现
1. 准备弹框模版
<el-dialog title="切换收货地址" width="30%" center>
<div class="addressWrapper">
<div class="text item" v-for="item in checkInfo.userAddresses" :key="item.id">
<ul>
<li><span>收<i />货<i />人:</span>{{ item.receiver }} </li>
<li><span>联系方式:</span>{{ item.contact }}</li>
<li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li>
</ul>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button>取消</el-button>
<el-button type="primary">确定</el-button>
</span>
</template>
</el-dialog>
2. 控制弹框打开
const showDialog = ref(false)
<el-button size="large" @click="showDialog = true">切换地址</el-button>
<el-dialog v-model="showDialog" title="切换收货地址" width="30%" center>
<!-- 省略 -->
</el-dialog>
地址激活交互实现
原理:地址切换是我们经常遇到的 `tab切换类` 需求,这类需求的实现逻辑都是相似的
1. 点击时记录一个当前激活地址对象activeAddress, 点击哪个地址就把哪个地址对象记录下来
2. 通过动态类名:class 控制激活样式类型 active是否存在,判断条件为:激活地址对象的id === 当前项id
<script setup>
// 切换地址
const activeAddress = ref({})
const switchAddress = (item) => {
activeAddress.value = item
}
</script>
<template>
<div class="text item"
:class="{ active: activeAddress.id === item.id }"
@click="switchAddress(item)"
:key="item.id">
<!-- 省略... -->
</div>
</template>
切换地址属于哪类通用型交互功能?
tab切换类交互
记录激活项(整个对象/id/index) + 动态类名控制
订单模块-生成订单功能实现
业务需求说明
确定结算信息没有问题之后,点击提交订单按钮,需要做以下俩个事情:
1. 调用接口生成订单id,并且携带id跳转到支付页
2. 调用更新购物车列表接口,更新购物车状态
<script setup>
import { createOrderAPI } from '@/apis/checkout'
// 创建订单
const createOrder = async () => {
const 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
})
const orderId = res.result.id
router.push({
path: '/pay',
query: {
id: orderId
}
})
}
</script>
<template>
<!-- 提交订单 -->
<div class="submit">
<el-button @click="createOrder" type="primary" size="large">提交订单</el-button>
</div>
</template>
支付模块-实现支付功能
支付业务流程
// 支付地址
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}`
支付模块-支付结果展示
业务需求理解
<script setup>
import { getOrderAPI } from '@/apis/pay'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const orderInfo = ref({})
const getOrderInfo = async () => {
const res = await getOrderAPI(route.query.orderId)
orderInfo.value = res.result
}
onMounted(() => getOrderInfo())
</script>
<template>
<div class="xtx-pay-page">
<div class="container">
<!-- 支付结果 -->
<div class="pay-result">
<!-- 路由参数获取到的是字符串而不是布尔值 -->
<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>
<p class="tip">我们将尽快为您发货,收货期间请保持手机畅通</p>
<p>支付方式:<span>支付宝</span></p>
<p>支付金额:<span>¥{{ orderInfo.payMoney?.toFixed(2) }}</span></p>
<div class="btn">
<el-button type="primary" style="margin-right:20px">查看订单</el-button>
<el-button>进入首页</el-button>
</div>
<p class="alert">
<span class="iconfont icon-tip"></span>
温馨提示:小兔鲜儿不会以订单异常、系统升级为由要求您点击任何网址链接进行退款操作,保护资产、谨慎操作。
</p>
</div>
</div>
</div>
</template>
支付模块-封装倒计时函数
理解需求
实现思路分析
import { computed, onUnmounted, ref } from 'vue'
import dayjs from 'dayjs'
// 封装倒计时逻辑
export const useCountDown = () => {
const formatTime = computed(() => dayjs.unix(time.value).format('mm分ss秒'))
// 1. 响应式的数据
let timer = null
const time = ref(0)
// 2. 开启倒计时的函数
const start = (currentTime) => {
// 开始倒计时的逻辑
// 核心逻辑的编写:每隔1s就减一
time.value = currentTime
timer = setInterval(() => {
time.value--
}, 1000)
}
// 组件销毁时清除定时器
onUnmounted(() => {
timer && clearInterval(timer)
})
return {
formatTime,
start
}
}
会员中心-个人中心信息渲染
分页逻辑实现
页数 = 总条数 / 每页条数
<script setup>
// 补充总条数
const total = ref(0)
const getOrderList = async () => {
const res = await getUserOrder(params.value)
// 存入总条数
total.value = res.result.counts
}
// 页数切换
const pageChange = (page) => {
params.value.page = page
getOrderList()
}
</script>
<template>
<el-pagination
:total="total"
@current-change="pageChange"
:page-size="params.pageSize"
background
layout="prev, pager, next" />
</template>
SKU组件封装
认识SKU组件
SKU组件的作用是为了让用户能够选择商品的规格,从而提交购物车,在选择的过程中,组件的选中状态要进行更新, 组件还要提示用户当前规格是否禁用,每次选择都要产出对应的Sku数据\
点击规格更新选中状态
基本思路:
- 每一个规格按钮都拥有自己的选中状态数据-selected,true为选中,false为取消选中
- 配合动态class,把选中状态selected作为判断条件,true让active类名显示,false让active类名不显示
- 点击的是未选中,把同一个规格的其他取消选中,当前点击项选中;点击的是已选中,直接取消
script setup>
// 省略代码
// 选中和取消选中实现
const changeSku = (item, val) => {
// 点击的是未选中,把同一个规格的其他取消选中,当前点击项选中,点击的是已选中,直接取消
if (val.selected) {
val.selected = false
} else {
item.values.forEach(valItem => valItem.selected = false)
val.selected = true
}
}
</script>
<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"
@click="changeSku(item, val)"
:class="{ selected: val.selected }"
:src="val.picture"
:title="val.name">
<span v-else
@click="changeSku(val)"
:class="{ selected: val.selected }">{{ val.name }}</span>
</template>
</dd>
</dl>
</div>
</template>
点击规格更新禁用状态 - 生成有效路径字典(1)
规格禁用的判断依据是什么?
核心原理:当前的规格Sku,或者组合起来的规格Sku,在skus数组中对应项的库存为零时,当前规格会被禁用,生成 路径字典是为了协助和简化这个匹配过程
点击规格更新禁用状态 - 生成有效路径字典(2)
实现步骤:
1. 根据库存字段得到有效的Sku数组
2. 根据有效的Sku数组使用powerSet算法得到所有子集 3. 根据子集生成路径字典对象
export default function bwPowerSet (originalSet) {
const subSets = []
// We will have 2^n possible combinations (where n is a length of original set).
// It is because for every element of original set we will decide whether to include
// it or not (2 options for each set element).
const numberOfCombinations = 2 ** originalSet.length
// Each number in binary representation in a range from 0 to 2^n does exactly what we need:
// it shows by its bits (0 or 1) whether to include related element from the set or not.
// For example, for the set {1, 2, 3} the binary number of 0b010 would mean that we need to
// include only "2" to the current set.
for (let combinationIndex = 0; combinationIndex < numberOfCombinations; combinationIndex += 1) {
const subSet = []
for (let setElementIndex = 0; setElementIndex < originalSet.length; setElementIndex += 1) {
// Decide whether we need to include current element into the subset or not.
if (combinationIndex & (1 << setElementIndex)) {
subSet.push(originalSet[setElementIndex])
}
}
// Add current subset to the list of all subsets.
subSets.push(subSet)
}
return subSets
}
// 创建生成路径字典对象函数
const getPathMap = (goods) => {
const pathMap = {};
// 1. 得到所有有效的Sku集合
const effectiveSkus = goods.filter((sku) => sku.inventory > 0);
// 2. 根据有效的Sku集合使用powerSet算法得到所有子集 [1,2] => [[1], [2], [1,2]]
effectiveSkus.forEach((sku) => {
// 2.1 获取可选规格值数组
const selectedValArr = sku.specs.map((val) => val.valueName);
// 2.2 获取可选值数组的子集
const valueArrPowerSet = bwPowerSet(selectedValArr);
// 3. 根据子集生成路径字典对象
// 3.1 遍历子集 往pathMap中插入数据
valueArrPowerSet.forEach((arr) => {
// 根据Arr得到字符串的key,约定使用-分割 ['蓝色','美国'] => '蓝色-美国'
const key = arr.join("-");
// 给pathMap设置数据
if (pathMap[key]) {
pathMap[key].push(sku.id);
} else {
pathMap[key] = [sku.id];
}
});
});
console.log(pathMap);
return pathMap;
};
点击规格更新禁用状态 - 初始化规格禁用
思路:遍历每一个规格对象,使用name字段作为key去路径字典pathMap中做匹配,匹配不上则禁用
思路:判断规格的name属性是否能在有效路径字典中找到,如果找不到就禁用
// 1. 定义初始化函数
// specs:商品源数据 pathMap:路径字典
const initDisabledState = (specs, pathMap) => {
// 约定:每一个按钮的状态由自身的disabled进行控制
specs.forEach((item) => {
item.values.forEach((val) => {
console.log(val);
if (pathMap[val.name]) {
val.disabled = false;
} else {
val.disabled = true;
}
});
});
};
击规格更新禁用状态 - 点击时组合禁用更新
思路(点击规格时):
1. 按照顺序得到规格选中项的数组 [‘蓝色’,‘20cm’, undefined]
2. 遍历每一个规格
2.1 把name字段的值填充到对应的位置
2.2 过滤掉undefined项使用join方法形成一个有效的key
2.3 使用key去pathMap中进行匹配,匹配不上,则当前项禁用
// 获取选中匹配数组 ['黑色',undefined,undefined]
const getSelectedValues = (specs) => {
const arr = []
specs.forEach(spec => {
const selectedVal = spec.values.find(value => value.selected)
arr.push(selectedVal ? selectedVal.name : undefined)
})
return arr
}
// 切换时更新选中状态
const updateDisabledState = (specs, pathMap) => {
// 约定:每一个按钮的状态由自身的disabled进行控制
specs.forEach((item, i) => {
const selectedValues = getSelectedValues(specs);
console.log(selectedValues);
item.values.forEach((val) => {
selectedValues[i] = val.name;
const key = selectedValues.filter((value) => value).join("-");
console.log(key);
if (pathMap[key]) {
val.disabled = false;
} else {
val.disabled = true;
}
});
});
};
产出有效的SKU信息
什么是有效的SKU?
如何判断当前用户已经选择了所有有效的规格?
已选择项数组 [‘蓝色’,‘20cm’, undefined] 中找不到undefined, 那么用户已经选择了所有的有效规格,此时可以产出数据
如何获取当前的SKU信息对象?
把已选择项数组拼接为路径字典的key,去路径字典pathMap中找即可
// 选中和取消选中实现
const changeSku = (item, val) => {
if (val.disabled) return;
// 点击的是未选中,把同一个规格的其他取消选中,当前点击项选中,点击的是已选中,直接取消
if (val.selected) {
val.selected = false;
} else {
item.values.forEach((valItem) => (valItem.selected = false));
val.selected = true;
}
updateDisabledState(goods.value.specs, pathMap);
const index = getSelectedValues(goods.value.specs).findIndex(
(item) => item === undefined
);
if (index > -1) {
console.log("找到了,信息不完整");
} else {
console.log("没有找到,信息完整,可以产出");
// 获取sku对象
const key = getSelectedValues(goods.value.specs).join("-");
const skuIds = pathMap[key];
console.log(skuIds);
// 以skuId作为匹配项去goods.value.skus数组中找
const skuObj = goods.value.skus.find((item) => item.id === skuIds[0]);
console.log("sku对象为", skuObj);
}
};
完整代码
<script setup>
import { onMounted, ref } from "vue";
import axios from "axios";
import { bwPowerSet } from "@/utils/getPathMap";
// 商品数据
const goods = ref({});
let pathMap = {};
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;
// 后端返回的库存列表
let skus = res.data.result.skus;
// 规格列表
let specs = res.data.result.specs;
pathMap = getPathMap(skus);
initDisabledState(specs, pathMap);
};
onMounted(() => getGoods());
// 创建生成路径字典对象函数
const getPathMap = (goods) => {
const pathMap = {};
// 1. 得到所有有效的Sku集合
const effectiveSkus = goods.filter((sku) => sku.inventory > 0);
// 2. 根据有效的Sku集合使用powerSet算法得到所有子集 [1,2] => [[1], [2], [1,2]]
effectiveSkus.forEach((sku) => {
// 2.1 获取可选规格值数组
const selectedValArr = sku.specs.map((val) => val.valueName);
// 2.2 获取可选值数组的子集
const valueArrPowerSet = bwPowerSet(selectedValArr);
// 3. 根据子集生成路径字典对象
// 3.1 遍历子集 往pathMap中插入数据
valueArrPowerSet.forEach((arr) => {
// 根据Arr得到字符串的key,约定使用-分割 ['蓝色','美国'] => '蓝色-美国'
const key = arr.join("-");
// 给pathMap设置数据
if (pathMap[key]) {
pathMap[key].push(sku.id);
} else {
pathMap[key] = [sku.id];
}
});
});
console.log(pathMap);
return pathMap;
};
// 1. 定义初始化函数
// specs:商品源数据 pathMap:路径字典
const initDisabledState = (specs, pathMap) => {
// 约定:每一个按钮的状态由自身的disabled进行控制
specs.forEach((item) => {
item.values.forEach((val) => {
console.log(val);
if (pathMap[val.name]) {
val.disabled = false;
} else {
val.disabled = true;
}
});
});
};
// 获取选中匹配数组 ['黑色',undefined,undefined]
const getSelectedValues = (specs) => {
const arr = [];
specs.forEach((spec) => {
const selectedVal = spec.values.find((value) => value.selected);
arr.push(selectedVal ? selectedVal.name : undefined);
});
return arr;
};
// 切换时更新选中状态
const updateDisabledState = (specs, pathMap) => {
// 约定:每一个按钮的状态由自身的disabled进行控制
specs.forEach((item, i) => {
const selectedValues = getSelectedValues(specs);
console.log(selectedValues);
item.values.forEach((val) => {
selectedValues[i] = val.name;
const key = selectedValues.filter((value) => value).join("-");
console.log(key);
if (pathMap[key]) {
val.disabled = false;
} else {
val.disabled = true;
}
});
});
};
// 选中和取消选中实现
const changeSku = (item, val) => {
if (val.disabled) return;
// 点击的是未选中,把同一个规格的其他取消选中,当前点击项选中,点击的是已选中,直接取消
if (val.selected) {
val.selected = false;
} else {
item.values.forEach((valItem) => (valItem.selected = false));
val.selected = true;
}
updateDisabledState(goods.value.specs, pathMap);
const index = getSelectedValues(goods.value.specs).findIndex(
(item) => item === undefined
);
if (index > -1) {
console.log("找到了,信息不完整");
} else {
console.log("没有找到,信息完整,可以产出");
// 获取sku对象
const key = getSelectedValues(goods.value.specs).join("-");
const skuIds = pathMap[key];
console.log(skuIds);
// 以skuId作为匹配项去goods.value.skus数组中找
const skuObj = goods.value.skus.find((item) => item.id === skuIds[0]);
console.log("sku对象为", skuObj);
}
};
</script>
<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"
@click="changeSku(item, val)"
:class="{ selected: val.selected, disabled: val.disabled }"
:src="val.picture"
:title="val.name"
/>
<span
v-else
@click="changeSku(item, val)"
:class="{ selected: val.selected, disabled: val.disabled }"
>{{ val.name }}</span
>
</template>
</dd>
</dl>
</div>
</template>
<style scoped lang="scss">
@mixin sku-state-mixin {
border: 1px solid #e4e4e4;
margin-right: 10px;
cursor: pointer;
&.selected {
border-color: #27ba9b;
}
&.disabled {
opacity: 0.6;
border-style: dashed;
cursor: not-allowed;
}
}
.goods-sku {
padding-left: 10px;
padding-top: 20px;
dl {
display: flex;
padding-bottom: 20px;
align-items: center;
dt {
width: 50px;
color: #999;
}
dd {
flex: 1;
color: #666;
> img {
width: 50px;
height: 50px;
margin-bottom: 4px;
@include sku-state-mixin;
}
> span {
display: inline-block;
height: 30px;
line-height: 28px;
padding: 0 20px;
margin-bottom: 4px;
@include sku-state-mixin;
}
}
}
}
</style>