前言
本系列将以肯德基自助点餐页面为模板,搭建一款自助点餐系统,第一次开发移动端h5项目,免不了有所差错和不足,欢迎各位大佬指正。 本章我们将继续开发,主要设计商品和购物车的逻辑和页面。
一、对象类型的设计
1.1、菜单和商品的类型设计
我设计每个大类的菜单为menu,菜单内的子项目为good,将其放在utils/interface/index.ts下方便维护管理,如下
export interface good { id: string; image_path: string; name: string; description?: string; month_sales?: number; rating?: number; discount?: number; price: number; } export interface menu { id: string; name: string; description?: string; goods?: good[]; }
复制
其中image_pth传的是商品主图,传图像的路径,我们暂时使用的就是静态路径,后面搭建好node后可以传图像的base64,下面是一个具体的例子:
const goodMenu: Ref<menu[]> = ref([ { id: "1", name: "人气热卖", description: "超人气爆款,低至4折起", goods: [ { id: "1-1", image_path: "..\\src\\assets\\4.png", name: "人气三件套", description: "香辣鸡腿堡+薯条+可乐", month_sales: 585, rating: 98, discount: 5, price: 24, }, { id: "1-2", image_path: "..\\src\\assets\\4.png", name: "蜜汁全鸡两件套", description: "蜜汁全鸡+可乐", month_sales: 685, rating: 98, discount: 5, price: 56, }, { id: "1-3", image_path: "..\\src\\assets\\4.png", name: "嫩牛五方三件套", description: "嫩牛五方+薯条+可乐", month_sales: 585, rating: 96, discount: 5, price: 24, }, ], }, { id: "2", name: "单人餐", goods: [ { id: "1-1", image_path: "..\\src\\assets\\4.png", name: "人气三件套人气三件套人气三件套", description: "香辣鸡腿堡+薯条+可乐", month_sales: 585, rating: 98, discount: 5, price: 24, }, { id: "1-2", image_path: "..\\src\\assets\\4.png", name: "蜜汁全鸡两件套", description: "蜜汁全鸡+可乐", month_sales: 685, rating: 98, discount: 5, price: 56, }, ], }, // 这里以此类推扩展 { id: "3", name: "多人餐" }, { id: "4", name: "全鸡" }, { id: "5", name: "汉堡卷" }, { id: "6", name: "饮品" }, { id: "7", name: "甜品" }, { id: "8", name: "冰淇淋" }, { id: "9", name: "儿童餐" }, { id: "10", name: "玩具" }, ]);
复制
当然后续我将把这个menu和goods都设计为动态的,商家在中台维护上架下架和管理商品的各个属性,移动端用户在进入页面时动态获取menu和goods。
1.2、购物车的类型设计
购物车是这个页面的逻辑核心,在顾客选购商品的下方我们是需要给顾客插入一个购物车栏,如下红框所示,比较重要的信息就是要像肯德基这样显示已加入购物车的总件数和总金额:
第二个比较重要的就是要提供给顾客在这个页面查看购物车的功能,也就是点击后弹出类似抽屉的框,显示出我的购物车当前加了哪些商品
此时购物车里的商品列表跟原本的商品列表类似,但展示的信息要少一点(也就是我在设计商品good里面那些必选即不带问号的属性),而图上这个编辑功能实际上是用于编辑商品的一些扩展的属性(比如辣度、冰度、风味等),这个原理其实都类似,不是本项目的核心,故暂时不设计,这里我们设计购物车提供的功能主要是提交购物车进入结算、增加和减少(删除)单类商品和清空购物车的功能。
我将购物车设计为对象类型,购物车的类型设计如下
//src/utils/interface/index.ts export interface CartItem { good: good; quantity: number; } export interface ShoppingCart { items: CartItem[]; totalPrice: number; }
复制
我首先设计了一个CartItem,在 CartItem 中,我们使用 Good 接口来表示商品的详细信息,并添加了一个 quantity 属性表示用户选择的数量。
购物车设计叫ShoppingCart,在 ShoppingCart 中,我们使用 CartItem 数组来存储购物车中的所有商品项,并添加了一个 totalPrice 属性表示购物车中所有商品的总价。(这只是一种方案,当然你也可以不维护总金额这个属性,直接通过逻辑计算可能会更加安全)
然后我们在Pinia状态管理器当中去做计算总金额,增减项目的方法实现,在src/store/modules
中设计一个cartStore 的store,设计如下,其中getters和actions我们在后面进行编写:
import { defineStore } from "pinia"; import { shoppingCart, cartItem, good } from "@/utils/interface/index"; export const usecartStore = defineStore("cart", { state: (): shoppingCart => { return { items: [] as cartItem[], totalPrice: 0, }; }, /* 类似于组件的computed,用来封装计算属性,有缓存的功能 */ getters: {}, /* 类似于methods,封装业务逻辑,修改state */ actions: {}, });
复制
1.3、整体设计
主要修改了四个文件:goods/Goods.vue
是主要界面,展示商品列表、购物车的弹出框,stepper/Stepper.vue
是Goods.vue中的一个组件,是步进器的设计,是购物的主要逻辑所在,在点击增加和减少时调用相应的actions,而store/modules/modules.ts
主要用于存放usecartStore,utils/interface/index.ts
主要放对象类型的设计。
二、商品设计
2.1、商品列表的界面设计
打开肯德基分析其商品页面:
商品页面是这个界面的显示主体,单个商品可以分为左边的主图和右边的信息,如下图所示,其中右边的信息包括,最上方的名称(如人气三件套、香辣鸡腿堡),中部的详情可以放内容介绍,月销量,好评率和金额等信息,底部的icon栏用于添加或减少所选的商品进入购物车。
我初步设计为如下:
商品的界面设计不怎么复杂,主要是一些组件的设计和样式调整:
<template> <div class="content-container"> <van-sidebar class="content-menu" v-model="active" @change="onChange"> <van-sidebar-item v-for="(item, index) in goodMenu" class="content-menu-menuitem" :key="index" :title="item.name" @click="menuNav(index)" /> </van-sidebar> <div class="content-good"> <div class="content-good-box" v-for="(item, index) in goodMenu" :key="index" > <div class="content-good-box-head"> <div class="content-good-box-headname">{{ item.name }}</div> <div class="content-goood-box-headdescription"> {{ item.description }} </div> </div> <div class="content-good-box-item" v-for="(jitem, jindex) in item.goods" :key="jindex" > <div class="content-good-box-itemleft"> <img :src="jitem.image_path" alt="" /> </div> <div class="content-good-box-itemright"> <div class="irt"> <div class="irt-name">{{ jitem.name }}</div> <p class="irt-description">{{ jitem.description }}</p> <p class="irt-rate" v-if="jitem.month_sales"> <span>月销:{{ jitem.month_sales }}</span> <span> 好评率:{{ jitem.rating }}%</span> </p> </div> <div class="irb"> <div class="irb-price"> <div>¥{{ jitem.price }}</div> </div> <div class="item-cart-num"> <CartStepper :choosenItem="jitem"></CartStepper> </div> </div> </div> </div> </div> </div> </div> <!-- 底部弹出 --> <van-sticky position="bottom" offset-bottom="4vw" ><div class="cart"> <div class="cart-content"> <van-icon class="cart-content-icon" size="5vh" name="shopping-cart" @click="showCart" /> <div class="cart-content-num" @click="showCart"> <!-- 未选购商品 --> <span v-if="cartStore.totalPrice == 0">还未选购商品</span> <!-- 已选购商品 --> <span v-else>¥{{ cartStore.totalPrice }}</span> </div> <van-button class="cart-content-button">提交订单</van-button> </div> </div> </van-sticky> <van-popup v-model:show="cartShow" round position="bottom" :style="{ height: '40%' }" /> </template>
复制
2.2、步进器的设计
由于vant本身提供的步进器不够好用,我在这里重新写了一个CartStepper步进器,这个步进器涉及到的主要是购物车里商品的增加和减少,直接调用对应的actions即可(actions的设计可以看3.1)
<!-- src/stepper/Stepper.vue --> <template> <van-icon name="minus" size="15" color="#1989fa" @click="minusJitemCartNum(choosenItem)" /> <span class="cartnum">{{ cartStore.getGoodNum(props.choosenItem) }}</span> <van-icon name="plus" size="15" color="#1989fa" @click="addJitemCartNum(choosenItem)" /> </template> <script setup lang="ts"> import { ref } from "vue"; import { good } from "@/utils/interface"; import { usecartStore } from "@/store/modules/modules"; const cartStore = usecartStore(); interface Props { choosenItem: good; } const props = defineProps<Props>(); // 增加当前单品的数量并加入购物车 const addJitemCartNum = (item: good) => { cartStore.addCartNum(item); }; // 减少当前单品的数量 const minusJitemCartNum = (item: good) => { // 如果已经是0了,拒绝这次减少,直接返回空 if (cartStore.getGoodNum(props.choosenItem) == 0) return; cartStore.minusCartNum(item); }; </script> <style lang="less"> .cartnum { font-size: 4vw; padding-left: 1vw; padding-right: 1vw; } </style>
复制
到这里,主页面的设计基本完成,效果如下:
2.3、侧边导航的左右菜单联动
用过自助点餐系统的同学都知道,在点餐时,左侧导航和右侧商品栏的滑动是联动的,左边被点击时,右边商品自动滑动到对应位置,右边滑动时,左侧导航也会有相应变换,下面我们来设计这个联动的逻辑。
这里主要参考的是:https://juejin.cn/post/7078592288611368990
我使用的是van-sidebar侧边导航组件和addEventListener
监听滚动事件来做这个逻辑,首先定义id值如下:
<van-sidebar class="content-menu" v-model="tableValue"> <van-sidebar-item v-for="(item, index) in goodMenu" class="content-menu-menuitem" :key="index" :title="item.name" @click="menuNav(index)" /> </van-sidebar> <div class="content-good" id="goodListId"> <div class="content-good-box" v-for="(item, index) in goodMenu" :key="index" :id="'scroll' + index" > <!-- 内容 --> </div> </div>
复制
点击侧边导航栏,右边联动:就是点击后将goodListId
的滚动条跳转到特定位置,这个特定位置的获取方法为: navPage?.offsetTop - parent?.offsetTop
,其中navPage = <HTMLImageElement>document.querySelector("#scroll" + index)
而parent = <HTMLImageElement>document.querySelector("#goodListId")
,计算需要跳转到的地方和父div的高度差,使用scrollTop
垂直方向跳转这个高度差。
// 自动翻页到当前类别的位置 // 左侧点击联动右侧 const menuNav = (index: number) => { // console.log(index); tableValue.value = index; let navPage = <HTMLImageElement>document.querySelector("#scroll" + index); let parent = <HTMLImageElement>document.querySelector("#goodListId"); document.querySelector("#goodListId")!.scrollTop = navPage?.offsetTop - parent?.offsetTop; };
复制
滚动右边内容,左边导航栏联动:就是使用一个监听器,监听滚动事件,使用offsetTop
获取每块scroll的距离屏幕最顶端的值,一但这个最顶端的值(<HTMLImageElement>document.querySelector("#scroll" + i)).offsetTop - start < parent.scrollTop
减去最开始第一块距离屏幕最顶端的值,小于scrollTop时,立即改变左边导航栏的value:
// 监听食品列表的滚动,滚动到一定位置要更新左部的侧边导航栏 onMounted(() => { window.addEventListener("scroll", scollNav, true); }); // 右侧菜单联动左侧 // 滑动右侧 左侧自动切换导航标签 const scollNav = () => { let parent = <HTMLImageElement>document.querySelector("#goodListId"); let start = (<HTMLImageElement>document.querySelector("#scroll" + 0)) .offsetTop; for (let i = 0; i < goodMenu.value.length; i++) { if ( (<HTMLImageElement>document.querySelector("#scroll" + i)).offsetTop - start < parent.scrollTop ) { tableValue.value = i; } } };
复制
三、购物车设计
3.1、逻辑分析
先捋一下购物车的逻辑:
1、在选中该商品的时候,总价变为该商品的价格。
2、点击”+“或 ” - “的时候,商品的数量随之变化,总价也跟着变化。
3、点击删除的时候,该商品从列表中被删除,如果该商品被选中的情况下,总价随之而变。
首先,我将购物车一些常用的方法写到购物车store的actions当中:
在添加商品时,判断购物车的carts数组有没有对应的商品,如果没有则将商品push进去,对应商品的数量+1,如果有则只将数量+1,两种情况都要将对应总金额增加。
addCartNum(item: good) { // 若cartStore.carts里已有,则该商品购物车数量+1,总金额增加 for (let index = 0; index < this.carts.length; index++) { if (this.carts[index].good == item) { this.carts[index].quantity += 1; this.totalPrice += item.price; return; } } // 若cartStore.carts没有,则加入,总金额增加 const keycart: cartItem = { good: item, quantity: 1, }; this.carts.push(keycart); this.totalPrice += item.price; },
复制
同样,对于减少,去找对应的商品,若不存在返回相应的提示(也可以直接设计逻辑避免这个情况),若存在且当前数量不为1的时候,则将数量-1,若存在且当前数量为1,则将cart数组中的这个元素删除,我们使用的是splice来做删除:
// 购物车减少逻辑 minusCartNum(item: good) { // 如果购物车里有这个单品,则减除数量,若数量为1时则删除 for (let index = 0; index < this.carts.length; index++) { if (this.carts[index].good == item) { if (this.carts[index].quantity == 1) { this.carts.splice(index, 1); } else { this.carts[index].quantity -= 1; } this.totalPrice -= item.price; return; } } // 如没有,则返回一个提示,这里先打印一下 console.log("fail"); },
复制
同时在actions里面还要写清空购物车,计算当前商品数量的方法,逻辑比较简单不再赘述,最终购物车的store(usecartStore )设计如下:
// utils/interface/index.ts import { defineStore } from "pinia"; import { shoppingCart, cartItem, good } from "@/utils/interface/index"; export const usecartStore = defineStore("cart", { state: (): shoppingCart => { return { carts: [] as cartItem[], totalPrice: 0, }; }, /* 类似于组件的computed,用来封装计算属性,有缓存的功能 */ getters: { priceSum(state): number { let sum = 0; state.carts.forEach((item: cartItem) => { // 商品的价格乘以数量 sum += item.good.price * item.quantity; }); return sum; }, }, /* 类似于methods,封装业务逻辑,修改state */ actions: { // 清空购物车 clearCart() { this.totalPrice = 0; this.carts = []; }, // 计算商品数量 getGoodNum(item: good): number { for (let index = 0; index < this.carts.length; index++) { if (this.carts[index].good == item) { return this.carts[index].quantity; } } return 0; }, // 购物车增加逻辑 addCartNum(item: good) { // 若cartStore.carts里已有,则该商品购物车数量+1,总金额增加 for (let index = 0; index < this.carts.length; index++) { if (this.carts[index].good == item) { this.carts[index].quantity += 1; this.totalPrice += item.price; console.log(this); return; } } // 若cartStore.carts没有,则加入,总金额增加 const keycart: cartItem = { good: item, quantity: 1, }; this.carts.push(keycart); this.totalPrice += item.price; }, // 购物车减少逻辑 minusCartNum(item: good) { // 如果购物车里有这个单品,则减除数量,若数量为1时则删除 for (let index = 0; index < this.carts.length; index++) { if (this.carts[index].good == item) { if (this.carts[index].quantity == 1) { this.carts.splice(index, 1); } else { this.carts[index].quantity -= 1; } this.totalPrice -= item.price; console.log(this); return; } } // 如没有,则返回一个提示 console.log("fail"); }, }, });
复制
到这里已经成功能够实现购物车的加减操作,金额动态变化功能了:
3.2、购物车弹出层的基本设计
除了显示金额,在常用应用里面,点击下面那个浮动栏时,还需要显示当前已经加购的商品,提供预览和修改功能,如肯德基中是这样子:
这里我选择使用Vant提供的Popup弹出层来实现这个功能,跟着做的朋友可以发现,我在2.1中已经放置了初步的Popup,不过还没填写内容,下面就让我们一起来实现一下,这里需要同样显示商品的列表,但是是已加购的商品,然后要有对指定商品数量的增减功能(依然可以调用store里面的actions),另外还需要提供一个一键清空购物车的按钮。
分析一下肯德基小程序布局主要分为,头部左侧显示我的购物车字样,右侧提供清空购物车功能,中部是购物车列表,底部是提交按钮。分析购物车列表布局,和之前商品列表类似,不过图要小一点,商品名称和价格要全,然后同样提供一个步进器对指定商品数量进行增减。
那么我们可以新建一个Cart.vue
组件插入到vant-popup
组件当中,
<template> <van-popup v-model:show="cartShow" round position="bottom" :style="{ height: '40%' }" > <Cart></Cart> </van-popup> </template> <script setup lang="ts"> import Cart from "@/components/cart/Cart.vue"; </script>
复制
新建cart
文件夹,在其中新建Cart.vue
文件,Cart.vue
文件主要效果如下:
<template> <div class="cart"> <div class="cart-head"> <div class="cart-headleft">我的购物车<van-icon name="cart-o" /></div> <div class="cart-headright" @click="clearCart()"> <van-icon name="delete-o" />清空 </div> </div> <div class="cart-item" v-for="(cartitem, index) in cartStore.carts" :key="index" > <img :src="cartitem.good.image_path" alt="" /> <div class="cart-item-content"> <div class="cart-item-name">{{ cartitem.good.name }}</div> <div class="cart-item-bottom"> <div class="cart-item-price">¥{{ cartitem.good.price }}</div> <CartStepper class="cart-item-quantity" :choosenItem="cartitem.good" ></CartStepper> <!-- <div class="cart-item-quantity">{{ cartitem.quantity }}</div> --> </div> </div> </div> </div> <van-sticky position="bottom" offset-bottom="1vw"> <div class="cart-button"> <div class="cart-content"> <van-icon class="cart-content-icon" size="5vh" name="shopping-cart" /> <div class="cart-content-num"> <!-- 未选购商品 --> <span v-if="cartStore.totalPrice == 0">还未选购商品</span> <!-- 已选购商品 --> <span v-else>¥{{ cartStore.totalPrice }}</span> </div> <van-button class="cart-content-button">提交订单</van-button> </div> </div> </van-sticky> </template> <script setup lang="ts"> import { usecartStore } from "@/store/modules/modules"; import CartStepper from "@/components/stepper/Stepper.vue"; import { showConfirmDialog } from "vant"; import "vant/es/dialog/style"; const cartStore = usecartStore(); const clearCart = () => { showConfirmDialog({ message: "确认要清空购物车吗?", }) .then(() => { // on confirm cartStore.clearCart(); }) .catch(() => { // on cancel }); }; </script>
复制
购物车弹出层的最终效果如下:
3.3、购物车弹出层的交互逻辑
在弹出层中,点击增减商品的数量需要调用store的action来做,这样才能保证主商品页面和弹出层页面数据一致。这里,我们复用CartStepper组件,传入属性choosenItem="cartitem.good"
,
<CartStepper class="cart-item-quantity" :choosenItem="cartitem.good" ></CartStepper>
复制
另外,还需要设计点击弹出层下方栏,隐藏弹出层页面,点击清空购物车,对store进行清空操作,在清空时还需要设计一个dialog二次确认删除,以下是这部分的设计。
<!-- src/components/cart/Cart.vue --> <van-sticky position="bottom" offset-bottom="0vw" @click="unshowCart()"> <div class="cart-button"> <div class="cart-content"> <van-icon class="cart-content-icon" size="5vh" name="shopping-cart" /> <div class="cart-content-num"> <!-- 未选购商品 --> <span v-if="cartStore.totalPrice == 0">还未选购商品</span> <!-- 已选购商品 --> <span v-else>¥{{ cartStore.totalPrice }}</span> </div> <van-button class="cart-content-button">提交订单</van-button> </div> </div> </van-sticky> <van-dialog v-model:show="showdiag" message="确定要清空购物车吗?" width="60vw" show-cancel-button @confirm="confirmClear()" ></van-dialog> <script setup lang="ts"> const cartStore = usecartStore(); let showdiag = ref(false); const emit = defineEmits(["unshow"]); const unshowCart = () => { emit("unshow"); }; const clearCart = () => { showdiag.value = true; }; const confirmClear = () => { cartStore.clearCart(); }; </script>
复制
最终我们完成了商品和购物车逻辑设计,效果如下:
四、可能遇见的问题
4.1、Toast,Dialog,Notify 和 ImagePreview 组件无法调用
这是因为Vant 中有个别组件是以函数的形式提供的,包括 Toast
,Dialog
,Notify
和 ImagePreview
组件。在使用函数组件时,unplugin-vue-components
无法自动引入对应的样式,因此需要手动引入样式:
// Toast import { showToast } from 'vant'; import 'vant/es/toast/style'; // Dialog import { showDialog } from 'vant'; import 'vant/es/dialog/style'; // Notify import { showNotify } from 'vant'; import 'vant/es/notify/style'; // ImagePreview import { showImagePreview } from 'vant'; import 'vant/es/image-preview/style';
复制
需要在入口文件或者公共模块中引入以上组件的样式,这样在业务代码中使用组件时,便不再需要重复引入样式了。
4.2、类型“Element”上不存在属性“offsetTop”
这是因为ts默认用的是Element,如果需要访问属性offsetTop,需要声明为HTMLElement,需要使用<>进行类型声明或者用as方法:
let top = <HTMLImageElement>document.querySelector('.Top'); let top = document.querySelector('.Top') as HTMLElement;
复制
4.3、HTML精确定位
HTML精确定位:scrollLeft,scrollWidth,clientWidth,offsetWidth
- scrollHeight: 获取对象的滚动高度。
- scrollLeft:设置或获取位于对象左边界和窗口中目前可见内容的最左端之间的距离
- scrollTop:设置或获取位于对象最顶端和窗口中可见内容的最顶端之间的距离
- scrollWidth:获取对象的滚动宽度
- offsetHeight:获取对象相对于版面或由父坐标 offsetParent 属性指定的父坐标的高度
- offsetLeft:获取对象相对于版面或由 offsetParent 属性指定的父坐标的计算左侧位置
- offsetTop:获取对象相对于版面或由 offsetTop 属性指定的父坐标的计算顶端位置
- event.clientX 相对文档的水平坐标
- event.clientY 相对文档的垂直坐标
- event.offsetX 相对容器的水平坐标
- event.offsetY 相对容器的垂直坐标
- document.documentElement.scrollTop 垂直方向滚动的值
- event.clientX+document.documentElement.scrollTop 相对文档的水平座标+垂直方向滚动的量