本篇文章主要实现的是Vue.js仿饿了么外卖App商品相关的组件的实现,主要包括商品菜单和商品列表的展示(左右联动),shopcart组件,和cartcontrol组件,使用到了better-scroll技术,加入了购物车小球动画的实现
最终实现的效果如图:
商品菜单和列表展示:
shopcart组件
cartcontrol组件
商品的展示采用两栏布局,左侧是商品分类,固定宽高
,右侧是具体的商品的列表,采用自适应的布局(flex)
。超出部分隐藏,使用better-scroll实现滚动。
部分代码:
<template>
<div>
<div class="goods">
<!-- 左侧菜单 -->
<div class="menu-wrapper" ref="menuWrapper">
<ul>
<li
v-for="(item,index) in goods"
:key="index"
class="menu-item"
:class="{'current':currentIndex===index}"
@click="selectMenu(index,$event)">
<span class="text">
<span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>
{{item.name}}
</span>
</li>
</ul>
</div>
<!-- 右侧商品 -->
<div class="foods-wrapper" ref="foodsWrapper">
<ul>
<li v-for="(item,index) in goods" :key="index" class="food-list food-list-hook">
<h1 class="title">{{item.name}}</h1>
<ul>
<li @click="selectedFoods(food,$event)"
v-for="(food,index) in item.foods" :key="index" class="food-item">
<div class="icon">
<img :src="food.icon" alt="" width="57" height="57">
</div>
<div class="content">
<h2 class="name">{{food.name}}</h2>
<p class="desc">{{food.description}}</p>
<div class="extra">
<span class="count">月售{{food.sellCount}}份</span><span>好评率{{food.rating}}%</span>
</div>
<div class="price">
<span class="now">¥{{food.price}}</span><span class="old" v-show="food.oldPrice">¥{{food.oldPrice}}</span>
</div>
<div class="cart-control">
<Cartcontrol :food="food" v-on:car-add="carAdd"></Cartcontrol>
</div>
</div>
</li>
</ul>
</li>
</ul>
</div>
<!-- 购物车 -->
<ShopCart
ref="shopcart"
:delivery-price="seller.deliveryPrice"
:min-price="seller.minPrice"
:select-foods="selectFoods"></ShopCart>
</div>
<Food :food="selectedFood" ref="food"></Food>
</div>
</template>
data () {
return {
goods: [],
}
},
created () {
axios.get('/api/goods').then((response) => {
response = response.data
// console.log(response)
if (response.errno === ERR_OK) {
this.goods = response.data
}
})
},
使用的better-scroll库实现页面的滚动
npm install better-scroll
import BScroll from 'better-scroll'
BScroll()有两个参数,第一个是dom对象,第二个是可选对象。给dom对象增加ref属性
<div class="menu-wrapper" ref="menuWrapper">
<div class="foods-wrapper" ref="foodsWrapper">
滚动函数:
methods: {
// 滚动函数
_initScroll () {
// BScroll()有两个参数,第一个是dom对象,第二个是可选对象。给dom对象增加ref属性
this.menuScroll = new BScroll(this.$refs.menuWrapper, {
click: true
})
this.foodsScroll = new BScroll(this.$refs.foodsWrapper, {
click: true,
// 获取实时滚动的位置
probeType: 3
})
// 监听滚动事件
this.foodsScroll.on('scroll', (pos) => {
this.scrollY = Math.abs(Math.round(pos.y))
})
},
}
在页面created的时候调用滚动函数,由于DOM对象的异步更新的,因此在修改数据之后使用 $nextTick,则可以在回调中获取更新后的 DOM。
created () {
this.classMap = ['decrease', 'discount', 'special', 'invoice', 'guarantee']
axios.get('/api/goods').then((response) => {
response = response.data
// console.log(response)
if (response.errno === ERR_OK) {
this.goods = response.data
// console.log(this.goods)
this.$nextTick(() => {
// 由于DOM对象是异步更新的
// $nextTick 是在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用 $nextTick,则可以在回调中获取更新后的 DOM
this._initScroll()
// 计算每一模块的高度,实现左右联动
this._calculateHeight()
})
}
})
},
这样就可以实现商品菜单和商品列表的滚动了。
实现左右联动,依赖的是右边列表实时变动的y值,即y轴落到的某个区间对应的菜单就要显示在某个区间,计算落在某一个区间就要知道每个区间的高度,将每个区间的高度计算出来保存在数组中,在监听滚动的时候能够实时拿到y坐标,对比坐标落在哪个区间,就可以知道当前应该高亮的菜单区间。
data () {
return {
goods: [],
listHeight: [], // 用来存储每个区间的高度
scrollY: 0,
selectedFood: {}
}
},
_calculateHeight () {
// 使用原生DOM的方法获取高度
// 通过food-list-hook获取每一个区间DOM
let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook')
// 高度初始值为0
let height = 0
this.listHeight.push(height)
for (let i = 0; i < foodList.length; i++) {
let item = foodList[i]
// 函数clientHeight得到的DOM对象的高度
height += item.clientHeight
this.listHeight.push(height)
}
},
在better-scroll中有一个参数probeType: 3可以监测到实时滚动的位置。
// 滚动函数
_initScroll () {
// BScroll()有两个参数,第一个是dom对象,第二个是可选对象。给dom对象增加ref属性
this.menuScroll = new BScroll(this.$refs.menuWrapper, {
click: true
})
this.foodsScroll = new BScroll(this.$refs.foodsWrapper, {
click: true,
// 获取实时滚动的位置
probeType: 3
})
// 监听滚动事件
this.foodsScroll.on('scroll', (pos) => {
this.scrollY = Math.abs(Math.round(pos.y))
})
},
接下来就需要将scrollY 和左侧的索引做映射
computed: {
// 计算对应切换的菜单下标
currentIndex () {
for (let i = 0; i < this.listHeight.length; i++) {
// 获取当前高度
let height1 = this.listHeight[i]
// 获取下一个高度
let height2 = this.listHeight[i + 1]
if (!height2 || (this.scrollY >= height1 && this.scrollY < height2)) {
return i
}
}
return 0
},
},
拿到对应的映射以后添加相应的样式
<li
v-for="(item,index) in goods"
:key="index"
class="menu-item"
:class="{'current':currentIndex===index}"
@click="selectMenu(index,$event)">
<span class="text">
<span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>
{{item.name}}
</span>
</li>
点击事件
<ul>
<li
v-for="(item,index) in goods"
:key="index"
class="menu-item"
:class="{'current':currentIndex===index}"
@click="selectMenu(index,$event)">
<span class="text">
<span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>
{{item.name}}
</span>
</li>
</ul>
点击左边的menu列表时,根据index,通过scrollToElement把右边的列表滚动到对应的位置
selectMenu (index, event) {
// 当自己触发一个事件的时候event._constructed为true,但是浏览器没有这个event._constructed的话为false
if (!event._constructed) {
}
let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook')
let el = foodList[index]
this.foodsScroll.scrollToElement(el, 300)
// console.log(index)
},
购物车组件主要有三种状态:
<template>
<transition name="fade">
<div>
<div class="shopcart">
<!-- 购物车栏 -->
<div class="content" @click="toggleList">
<div class="content-left">
<div class="logo-wrapper">
<div class="logo" :class="{'highlight':totalCount>0}">
<i
class="iconfont icon-gouwucheman"
:class="{'highlight':totalCount>0}"
></i>
</div>
<div class="num">{{totalCount}}</div>
</div>
<div
class="price"
:class="{'highlight1':totalCount>0}"
>¥{{totalPrice}}</div>
<div class="desc">另需配送费¥{{deliveryPrice}}元</div>
</div>
<div class="content-right">
<div class="pay" :class="payClass" @click.stop.prevent="pay">{{payDesc}}</div>
</div>
<!-- 小球下落 -->
<div class="ball-container">
<div v-for="ball in balls" :key="ball.id">
<transition
name="drop"
@before-enter="beforeEnter"
@enter="dropping"
@after-enter="afterDrop">
<div v-show="ball.show" class="ball">
<div class="inner inner-hook">
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 商品详情页展示 -->
<transition name="slide-fade">
<div class="shopcart-list" v-show="listShow">
<div class="list-header">
<h1 class="title">购物车</h1>
<span class="empty" @click="empty">清空</span>
</div>
<div class="list-content" ref="listContent">
<ul>
<li class="food" v-for="(food,index) in selectFoods" :key="index">
<span class="name">{{food.name}}</span>
<div class="price">
<span>¥{{food.price*food.count}}</span>
</div>
<div class="cartcontrol-wrapper">
<CartControl :food="food"></CartControl>
</div>
</li>
</ul>
</div>
</div>
</transition>
</div>
<div class="list-mask" v-show="listShow" @click="hideList()"></div>
</div>
</transition>
</template>
good.vue,在good.vue中接收到父组件传递来的seller,再将数据传给子组件ShopCart
<ShopCart
ref="shopcart"
:delivery-price="seller.deliveryPrice"
:min-price="seller.minPrice"
:select-foods="selectFoods">
</ShopCart>
ShopCart.vue
props: {
deliveryPrice: {
type: Number,
default: 0
},
minPrice: {
type: Number,
default: 0
},
}
computed: {
// 总价计算属性
totalPrice () {
let total = 0
this.selectFoods.forEach((food) => {
total += food.price * food.count
})
return total
},
// 选择商品的个数总和
totalCount () {
let count = 0
this.selectFoods.forEach((food) => {
count += food.count
})
return count
},
// 起送
payDesc () {
if (this.totalPrice === 0) {
return `¥${this.minPrice}元起送`
} else if (this.totalPrice < this.minPrice) {
let index = this.minPrice - this.totalPrice
return `还差¥${index}元起送`
} else {
return '去结算'
}
},
payClass () {
if (this.totalPrice < this.minPrice) {
return 'not-enough'
} else {
return 'enough'
}
},
},
<template>
<div class="cartcontrol">
<transition name="slide-fade">
<div class="decrease" v-show="food.count>0" @click.stop.prevent="decreaseCart">
<span class="inner iconfont icon-jianshao"></span>
</div>
</transition>
<div class="count" v-show="food.count>0">{{food.count}}</div>
<div class="add iconfont icon-tianjia" @click.stop.prevent="addCart"></div>
</div>
</template>
添加商品的时候,如果this.food.count存在直接给商品的数量加1,如果this.food.count不存在使用this.$set(this.food, ‘count’, 1)添加count并且设置为1。
addCart (event) {
if (!event._constructed) {
}
// console.log('aaa')
if (!this.food.count) {
this.$set(this.food, 'count', 1)
} else {
this.food.count++
}
// 将DOM对象作为事件参数传入
this.$emit('car-add', event.target)
// console.log(this.count)
},
decreaseCart (event) {
if (!event._constructed) {
}
if (this.food.count) {
this.food.count--
}
}
}
这里过渡动画使用的是vue的transition 实现的。
<transition name="slide-fade">
<div class="decrease" v-show="food.count>0" @click.stop.prevent="decreaseCart">
<span class="inner iconfont icon-jianshao"></span>
</div>
</transition>
样式:
.slide-fade-enter-active, .slide-fade-leave-active {
transition: all 0.4s linear
}
.slide-fade-enter , .slide-fade-leave{
opacity: 0;
transform: translate3d(44px,0,0);
}
在子组件shopcart组件中需要用到已经被选中的food的信息,在父组件goods中我们可以通过遍历goods中的foods找到那些count值大于0,即时被选中过的food然后将它们保存在selectFood中。
<ShopCart
ref="shopcart"
:delivery-price="seller.deliveryPrice"
:min-price="seller.minPrice"
:select-foods="selectFoods"></ShopCart>
computed: {
// 选中的food
selectFoods () {
let foods = []
// 找到所有被选择的foods
this.goods.forEach((good) => {
good.foods.forEach((food) => {
// 如果food.count不为0的话,表示已经被选中过,将food push进foods中
if (food.count) {
foods.push(food)
}
})
})
return foods
}
},
shopcart组件中通过prop属性接收数据
selectFoods: {
type: Array,
default () {
return [
{
price: 5,
count: 2
}
]
}
}
点击添加按钮的时候,小球从加号开始做下落动画,我们可以假设购物车里面有很多个小球,点击加号按钮的时候,小球会在加号按钮位置显示,然后经过下落动画滚动到购物车内,支持多个小球同时运动。
在data中定义一个ball用来存放小球,小球的show属性默认为false,不显示。
data () {
return {
// 定义五个小球来存放小球的初始状态
balls: [
{
show: false
},
{
show: false
},
{
show: false
},
{
show: false
},
{
show: false
}
],
dropBalls: [],
// 折叠
fold: true
}
},
使用vue的动画transition实现下落动画,定义了三个钩子函数before-enter、enter、after-enter实现动画。beforeEnter用来拿到点击位置,并且将小球放到该位置,dropping触发浏览器重绘,重绘之后才可以设置transform,afterDrop完成后修改小球的show为false。
<div class="ball-container">
<div v-for="ball in balls" :key="ball.id">
<transition
name="drop"
@before-enter="beforeEnter"
@enter="dropping"
@after-enter="afterDrop">
<div v-show="ball.show" class="ball">
<div class="inner inner-hook">
</div>
</div>
</transition>
</div>
</div>
在cartcontrol中addCart函数中触发一个事件将DOM对象作为事件参数传入
通过event.target可以拿到DOM对象
addCart (event) {
if (!event._constructed) {
}
if (!this.food.count) {
this.$set(this.food, 'count', 1)
} else {
this.food.count++
}
// 将DOM对象作为事件参数传入
this.$emit('car-add', event.target)
},
在goods组件中监听事件carAdd
<div class="cart-control">
<Cartcontrol :food="food" v-on:car-add="carAdd"></Cartcontrol>
</div>
修改carAdd函数,并在函数中驱动drop方法。在vue中父组件访问子组件使用 ref
<ShopCart
ref="shopcart"
:delivery-price="seller.deliveryPrice"
:min-price="seller.minPrice"
:select-foods="selectFoods"></ShopCart>
驱动drop方法:
carAdd (target) {
this.$refs.shopcart.drop(target)
}
drop (el) {
//console.log(el)
// 遍历data里面的ball,将拿到的小球show设置为true,并将它添加到dropBalls里面
for (let i = 0; i < this.balls.length; i++) {
const ball = this.balls[i]
if (!ball.show) {
ball.show = true
ball.el = el
this.dropBalls.push(ball)
return
}
}
},
beforeEnter (el) {
let count = this.balls.length
while (count--) {
//拿到小球
let ball = this.balls[count]
if (ball.show) {
//获取该元素相当于视口的位置,rect返回值的left和top就是该元素相当于视口的偏移
let rect = ball.el.getBoundingClientRect()
//获取最终落点和点击位置的差值
let x = rect.left - 2
let y = -(window.innerHeight - rect.top - 22)
//设置初始位置
el.style.display = ''
el.style.webkitTransform = `translate3d(0,${y}px,0)`
el.style.transform = `translate3d(0,${y}px,0)`
//设置内层的平移
let inner = el.getElementsByClassName('inner-hook')[0]
inner.style.webkitTransform = `translate3d(${x}px,0,0)`
inner.style.transform = `translate3d(${x}px,0,0)`
}
}
},
dropping (el) {
// 触发浏览器重绘,重绘之后才可以设置transform
/* eslint-disable no-unused-vars */
let rf = el.offsetHeight
this.$nextTick(() => {
el.style.webkitTransform = 'translate3d(0, 0, 0)'
el.style.transform = 'translate3d(0, 0, 0)'
let inner = el.getElementsByClassName('inner-hook')[0]
inner.style.webkitTransform = 'translate3d(0, 0, 0)'
inner.style.transform = 'translate3d(0, 0, 0)'
})
},
afterDrop (el) {
//做完一个动画取一个小球
let ball = this.dropBalls.shift()
if (ball) {
//将小球show属性修改为false
ball.show = false
el.style.display = 'none'
}
},
当点击购物车区块的时候,如果购物车中有商品,详情就会展开。列表有一个最大高度611,如果超过这个高度的时候,列表可以进行滚动,到没有超过这个高度的时候,这个列表高度只能被自己内容高度撑高。
<transition name="slide-fade">
<div class="shopcart-list" v-show="listShow">
<div class="list-header">
<h1 class="title">购物车</h1>
<span class="empty" @click="empty">清空</span>
</div>
<div class="list-content" ref="listContent">
<ul>
<li class="food" v-for="(food,index) in selectFoods" :key="index">
<span class="name">{{food.name}}</span>
<div class="price">
<span>¥{{food.price*food.count}}</span>
</div>
<div class="cartcontrol-wrapper">
<CartControl :food="food"></CartControl>
</div>
</li>
</ul>
</div>
</div>
</transition>
<div class="list-mask" v-show="listShow" @click="hideList()"></div>
</div>
定义一个计算属性listshow来控制列表
listShow () {
// get: function () {
// return this.fold
// },
// set: function () {
// if (!this.totalCount) {
// this.fold = false
// return false
// }
// let show = !this.fold
// if (show) {
// this.$nextTick(() => {
// if (!this.scroll) {
// this.toScorll()
// } else {
// this.scroll.refresh()
// }
// })
// }
// return show
// }
if (!this.totalCount) {
// 计算属性无法直接修改data里面的数据,因此我们调用toFalse函数修改isShow的值
this.toFalse()
return false
}
let show = !this.fold
if (show) {
// 由于这里计算属性还是无法修改data里面的值,因此我们将对scroll的操作封装成一个函数toScroll,这里调用函数就可以
// this.$nextTick(() => {
// if (!this.scroll) {
// this.toScorll()
// } else {
// this.scroll.refresh()
// }
// })
this.toScorll()
}
return show
}
// 展开折叠
toggleList () {
if (!this.totalCount) {
return
}
this.fold = !this.fold
},
// 修改isShow
toFalse () {
this.isShow = false
},
// 收回购物车详情列表
hideList () {
this.fold = true
},
// 购物详情的菜单滑动
toScorll () {
this.$nextTick(() => {
if (!this.scroll) {
this.scroll = new BScroll(this.$refs.listContent, {
click: true
})
} else {
this.scroll.refresh()
}
})
},
相关源码
goods.vue
<template>
<div>
<div class="goods">
<!-- 左侧菜单 -->
<div class="menu-wrapper" ref="menuWrapper">
<ul>
<li
v-for="(item,index) in goods"
:key="index"
class="menu-item"
:class="{'current':currentIndex===index}"
@click="selectMenu(index,$event)">
<span class="text">
<span v-show="item.type>0" class="icon" :class="classMap[item.type]"></span>
{{item.name}}
</span>
</li>
</ul>
</div>
<!-- 右侧商品 -->
<div class="foods-wrapper" ref="foodsWrapper">
<ul>
<li v-for="(item,index) in goods" :key="index" class="food-list food-list-hook">
<h1 class="title">{{item.name}}</h1>
<ul>
<li @click="selectedFoods(food,$event)"
v-for="(food,index) in item.foods" :key="index" class="food-item">
<div class="icon">
<img :src="food.icon" alt="" width="57" height="57">
</div>
<div class="content">
<h2 class="name">{{food.name}}</h2>
<p class="desc">{{food.description}}</p>
<div class="extra">
<span class="count">月售{{food.sellCount}}份</span><span>好评率{{food.rating}}%</span>
</div>
<div class="price">
<span class="now">¥{{food.price}}</span><span class="old" v-show="food.oldPrice">¥{{food.oldPrice}}</span>
</div>
<div class="cart-control">
<Cartcontrol :food="food" v-on:car-add="carAdd"></Cartcontrol>
</div>
</div>
</li>
</ul>
</li>
</ul>
</div>
<!-- 购物车 -->
<ShopCart
ref="shopcart"
:delivery-price="seller.deliveryPrice"
:min-price="seller.minPrice"
:select-foods="selectFoods"></ShopCart>
</div>
<Food :food="selectedFood" ref="food"></Food>
</div>
</template>
<script>
import axios from 'axios'
import BScroll from 'better-scroll'
import ShopCart from '@/components/shopcart/shopcart'
import Cartcontrol from '@/components/cartcontrol/cartcontrol'
import Food from '@/components/food/food'
const ERR_OK = 0
export default {
props: {
seller: {
type: Object
}
},
data () {
return {
goods: [],
listHeight: [], // 用来存储每个区间的高度
scrollY: 0,
selectedFood: {}
}
},
components: {
ShopCart,
Cartcontrol,
Food
},
computed: {
// 计算对应切换的菜单下标
currentIndex () {
for (let i = 0; i < this.listHeight.length; i++) {
let height1 = this.listHeight[i]
let height2 = this.listHeight[i + 1]
if (!height2 || (this.scrollY >= height1 && this.scrollY < height2)) {
return i
}
}
return 0
},
// 选中的food
selectFoods () {
let foods = []
// 找到所有被选择的foods
this.goods.forEach((good) => {
good.foods.forEach((food) => {
// 如果food.count不为0的话,表示已经被选中过,将food push进foods中
if (food.count) {
foods.push(food)
}
})
})
return foods
}
},
created () {
this.classMap = ['decrease', 'discount', 'special', 'invoice', 'guarantee']
axios.get('/api/goods').then((response) => {
response = response.data
// console.log(response)
if (response.errno === ERR_OK) {
this.goods = response.data
console.log(this.goods)
this.$nextTick(() => {
// 由于DOM对象是异步更新的
// $nextTick 是在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用 $nextTick,则可以在回调中获取更新后的 DOM
this._initScroll()
// 计算每一模块的高度,实现左右联动
this._calculateHeight()
})
}
})
},
methods: {
// 滚动函数
_initScroll () {
// BScroll()有两个参数,第一个是dom对象,第二个是可选对象。给dom对象增加ref属性
this.menuScroll = new BScroll(this.$refs.menuWrapper, {
click: true
})
this.foodsScroll = new BScroll(this.$refs.foodsWrapper, {
click: true,
// 获取实时滚动的位置
probeType: 3
})
// 监听滚动事件
this.foodsScroll.on('scroll', (pos) => {
this.scrollY = Math.abs(Math.round(pos.y))
})
},
_calculateHeight () {
// 使用原生DOM的方法获取高度
// 通过food-list-hook获取每一个区间DOM
let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook')
// 高度初始值为0
let height = 0
this.listHeight.push(height)
for (let i = 0; i < foodList.length; i++) {
let item = foodList[i]
// 函数clientHeight得到的DOM对象的高度
height += item.clientHeight
this.listHeight.push(height)
}
},
// 点击左侧menu切换
// 点击左边的menu列表时,根据index,通过scrollToElement把右边的列表滚动到对应的位置
selectMenu (index, event) {
// 当自己触发一个事件的时候event._constructed为true,但是浏览器没有这个event._constructed的话为false
if (!event._constructed) {
}
let foodList = this.$refs.foodsWrapper.getElementsByClassName('food-list-hook')
let el = foodList[index]
this.foodsScroll.scrollToElement(el, 300)
// console.log(index)
},
selectedFoods (food, event) {
if (!event._constructed) {
return
}
this.selectedFood = food
// console.log('1')
console.log(this.selectedFood)
this.$refs.food.show()
},
carAdd (target) {
this.$refs.shopcart.drop(target)
}
}
}
</script>
<style>
.goods{
display: flex;
position: absolute;
width: 100%;
top: 174px;
bottom: 46px;
overflow: hidden;
}
.goods .menu-wrapper{
flex: 0 0 80px;
width: 80px;
background: #f3f5f7;
}
.goods .menu-wrapper .menu-item{
display: table; /**垂直居中,不管是一行还是两行 */
height: 54px;
width: 56px;
padding: 0 12px;
line-height: 14px;
}
.goods .menu-wrapper .current{
font-size: 12px;
position: relative;
margin-top: -1px;
z-index: 10;
background: #fff;
font-weight: 700;
}
.goods .menu-wrapper .menu-item .icon{
display: inline-block;
width: 12px;
height: 12px;
margin-right: 2px;
background-size: 12px 12px;
background-repeat: no-repeat;
}
.goods .menu-wrapper .menu-item .decrease{
background-image: url('./[email protected]')
}
.goods .menu-wrapper .menu-item .discount{
background-image: url('./[email protected]')
}
.goods .menu-wrapper .menu-item .guarantee{
background-image: url('./[email protected]')
}
.goods .menu-wrapper .menu-item .invoice{
background-image: url('./[email protected]')
}
.goods .menu-wrapper .menu-item .special{
background-image: url('./[email protected]')
}
.goods .menu-wrapper .menu-item .text{
font-size: 12px;
display: table-cell;
width: 56px;
vertical-align: middle; /**垂直居中 */
}
.goods .foods-wrapper{
flex: 1;
}
.goods .foods-wrapper .title{
padding-left:14px;
height: 26px;
line-height: 26px;
border-left: 2px solid #d9dde1;
font-size: 12px;
color: rgb(147, 153, 159);
background: #f3f5f7;
}
.goods .foods-wrapper .food-item{
display: flex;
margin: 18px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(7, 17, 27, 0.1);
}
.goods .foods-wrapper .food-item .icon{
flex: 0 0 57px;
margin-right: 10px;
}
.goods .foods-wrapper .food-item .content{
flex: 1;
}
.goods .foods-wrapper .food-item .content .name{
margin: 2px 0 8px 0;
height: 14px;
line-height: 14px;
font-size: 14px;
color: rgb(7, 17, 27);
}
.goods .foods-wrapper .food-item .content .desc,
.goods .foods-wrapper .food-item .content .extra{
line-height: 10px;
font-size: 10px;
color: rgb(147, 153, 159);
}
.goods .foods-wrapper .food-item .content .desc{
margin-bottom: 8px;
line-height: 12px;
}
.goods .foods-wrapper .food-item .content .extra .count{
margin-right: 12px;
}
.goods .foods-wrapper .food-item .content .price{
font-weight: 700;
line-height: 24px;
}
.goods .foods-wrapper .food-item .content .price .now{
margin-right: 18px;
font-size: 14px;
color: rgb(240, 20, 20);
}
.goods .foods-wrapper .food-item .content .price .old{
text-decoration: line-through;
font-size: 10px;
color: rgb(147, 153, 159);
}
.goods .foods-wrapper .food-item .content .cart-control{
position: absolute;
right: 0;
/* bottom: 1px; */
}
</style>
shopcart.vue
<template>
<transition name="fade">
<div>
<div class="shopcart">
<!-- 购物车栏 -->
<div class="content" @click="toggleList">
<div class="content-left">
<div class="logo-wrapper">
<div class="logo" :class="{'highlight':totalCount>0}">
<i
class="iconfont icon-gouwucheman"
:class="{'highlight':totalCount>0}"
></i>
</div>
<div class="num">{{totalCount}}</div>
</div>
<div
class="price"
:class="{'highlight1':totalCount>0}"
>¥{{totalPrice}}</div>
<div class="desc">另需配送费¥{{deliveryPrice}}元</div>
</div>
<div class="content-right">
<div class="pay" :class="payClass" @click.stop.prevent="pay">{{payDesc}}</div>
</div>
<!-- 小球下落 -->
<div class="ball-container">
<div v-for="ball in balls" :key="ball.id">
<transition
name="drop"
@before-enter="beforeEnter"
@enter="dropping"
@after-enter="afterDrop">
<div v-show="ball.show" class="ball">
<div class="inner inner-hook">
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 商品详情页展示 -->
<transition name="slide-fade">
<div class="shopcart-list" v-show="listShow">
<div class="list-header">
<h1 class="title">购物车</h1>
<span class="empty" @click="empty">清空</span>
</div>
<div class="list-content" ref="listContent">
<ul>
<li class="food" v-for="(food,index) in selectFoods" :key="index">
<span class="name">{{food.name}}</span>
<div class="price">
<span>¥{{food.price*food.count}}</span>
</div>
<div class="cartcontrol-wrapper">
<CartControl :food="food"></CartControl>
</div>
</li>
</ul>
</div>
</div>
</transition>
</div>
<div class="list-mask" v-show="listShow" @click="hideList()"></div>
</div>
</transition>
</template>
<script>
import CartControl from '@/components/cartcontrol/cartcontrol'
import BScroll from 'better-scroll'
export default {
props: {
deliveryPrice: {
type: Number,
default: 0
},
minPrice: {
type: Number,
default: 0
},
// selectFoods用来存放选择的商品
selectFoods: {
type: Array,
default () {
return [
{
price: 5,
count: 2
}
]
}
}
},
components: {
CartControl
},
data () {
return {
// 定义五个小球来存放小球的初始状态
balls: [
{
show: false
},
{
show: false
},
{
show: false
},
{
show: false
},
{
show: false
}
],
dropBalls: [],
// 折叠
fold: true
}
},
computed: {
// 总价计算属性
totalPrice () {
let total = 0
this.selectFoods.forEach((food) => {
total += food.price * food.count
})
return total
},
// 选择商品的个数总和
totalCount () {
let count = 0
this.selectFoods.forEach((food) => {
count += food.count
})
return count
},
// 起送
payDesc () {
if (this.totalPrice === 0) {
return `¥${this.minPrice}元起送`
} else if (this.totalPrice < this.minPrice) {
let index = this.minPrice - this.totalPrice
return `还差¥${index}元起送`
} else {
return '去结算'
}
},
payClass () {
if (this.totalPrice < this.minPrice) {
return 'not-enough'
} else {
return 'enough'
}
},
listShow () {
// get: function () {
// return this.fold
// },
// set: function () {
// if (!this.totalCount) {
// this.fold = false
// return false
// }
// let show = !this.fold
// if (show) {
// this.$nextTick(() => {
// if (!this.scroll) {
// this.toScorll()
// } else {
// this.scroll.refresh()
// }
// })
// }
// return show
// }
if (!this.totalCount) {
// 计算属性无法直接修改data里面的数据,因此我们调用toFalse函数修改isShow的值
this.toFalse()
return false
}
let show = !this.fold
if (show) {
// 由于这里计算属性还是无法修改data里面的值,因此我们将对scroll的操作封装成一个函数toScroll,这里调用函数就可以
// this.$nextTick(() => {
// if (!this.scroll) {
// this.toScorll()
// } else {
// this.scroll.refresh()
// }
// })
this.toScorll()
}
return show
}
},
methods: {
drop (el) {
console.log(el)
// 遍历data里面的ball,将拿到的小球show设置为true,并将它添加到dropBalls里面
for (let i = 0; i < this.balls.length; i++) {
const ball = this.balls[i]
if (!ball.show) {
ball.show = true
ball.el = el
this.dropBalls.push(ball)
return
}
}
},
beforeEnter (el) {
let count = this.balls.length
while (count--) {
let ball = this.balls[count]
if (ball.show) {
let rect = ball.el.getBoundingClientRect()
let x = rect.left - 2
let y = -(window.innerHeight - rect.top - 22)
el.style.display = ''
el.style.webkitTransform = `translate3d(0,${y}px,0)`
el.style.transform = `translate3d(0,${y}px,0)`
let inner = el.getElementsByClassName('inner-hook')[0]
inner.style.webkitTransform = `translate3d(${x}px,0,0)`
inner.style.transform = `translate3d(${x}px,0,0)`
}
}
},
dropping (el) {
// 触发浏览器重绘,重绘之后才可以设置transform
/* eslint-disable no-unused-vars */
let rf = el.offsetHeight
this.$nextTick(() => {
el.style.webkitTransform = 'translate3d(0, 0, 0)'
el.style.transform = 'translate3d(0, 0, 0)'
let inner = el.getElementsByClassName('inner-hook')[0]
inner.style.webkitTransform = 'translate3d(0, 0, 0)'
inner.style.transform = 'translate3d(0, 0, 0)'
})
},
afterDrop (el) {
let ball = this.dropBalls.shift()
if (ball) {
ball.show = false
el.style.display = 'none'
}
},
// 展开折叠
toggleList () {
if (!this.totalCount) {
return
}
this.fold = !this.fold
},
// 修改isShow
toFalse () {
this.isShow = false
},
// 购物详情的菜单滑动
toScorll () {
this.$nextTick(() => {
if (!this.scroll) {
this.scroll = new BScroll(this.$refs.listContent, {
click: true
})
} else {
this.scroll.refresh()
}
})
},
// 清空
empty () {
this.selectFoods.forEach((food) => {
food.count = 0
})
},
// 收回购物车详情列表
hideList () {
this.fold = true
},
// 付款
pay () {
if (this.totalPrice < this.minPrice) {
return
}
window.alert(`支付¥${this.totalPrice}元`)
}
}
}
</script>
<style scoped>
.shopcart{
position: fixed;
left: 0;
bottom: 0;
z-index: 100; /**遮住上面区块 */
width: 100%;
height: 48px;
background: #000;
}
.shopcart .content{
display: flex;
background: #141d27;
font-size: 0; /**由于有间隙,所以将父级的font-size设置为0 */
/* vertical-align: center; */
}
/* .shopcart .content .ball-container{
transition: all 0.4s;
} */
.shopcart .content .ball-container .ball{
position: fixed;
left:2px;
bottom: 22px;
z-index: 200;
transition: all 0.4s cubic-bezier(0.49, -0.29, 0.75, 0.41);
}
.shopcart .content .ball-container .inner{
width: 16px;
height: 16px;
border-radius: 50%;
background: rgb(0,160,220);
transition: all 0.6s linear;
}
.shopcart .content .content-left{
flex: 1; /**自适应 */
/* vertical-align: center; */
}
.shopcart .content .content-left .logo-wrapper{
/* display: inline-block; */
position: relative;
vertical-align: center;
top: -10px;
margin: 0 12px;
padding:6px;
width: 56px;
height: 56px;
box-sizing: border-box;
vertical-align: top;
border-radius: 50%;
background: #141d27;
}
.shopcart .content .content-left .logo-wrapper .logo{
width: 100%;
height: 100%;
border-radius: 50%;
background: #2b343c;
text-align: center; /**子元素水平居中 */
}
.shopcart .content .content-left .logo-wrapper .logo .iconfont{
font-size: 24px;
color: #80858a;
line-height: 44px; /**居中 */
}
.shopcart .content .content-left .logo-wrapper .highlight{
background: rgb(0,160,220);
color: #fff;
}
.shopcart .content .content-left .logo-wrapper .logo .highlight{
color: #fff;
}
.shopcart .content .content-left .logo-wrapper .num{
position: absolute;
top: 0;
right: 0;
width: 24px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: 16px;
font-size: 9px;
font-weight: 700;
color: white;
background: rgb(240,20,20);
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.4);
}
.shopcart .content .content-left .price{
display: inline-block;
font-size: 16px;
font-weight: 700;
line-height: 24px;
margin-top: -45px;
margin-left: 80px;
padding-right: 12px;
box-sizing: border-box;
border-right: 1px solid rgba(255, 255, 255, 0.1);
vertical-align: top;
color: rgba(255, 255, 255, 0.4);
}
.shopcart .content .content-left .highlight1{
color: #fff;
}
.shopcart .content .content-left .desc{
display: inline-block;
vertical-align: top;
line-height: 24px;
margin: -45px 0 0 10px;
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
}
.shopcart .content .content-right{
flex: 0 0 105px;
width: 105px;
}
.shopcart .content .content-right .pay{
height: 48px;
line-height: 48px;
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-weight: 700;
background: #2b333b;
}
.shopcart .content .content-right .not-enough{
background: #2b333b;
}
.shopcart .content .content-right .enough{
background: #00b43c;
color: #fff;
}
.shopcart .shopcart-list{
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
transform: translate3d(0, -100%, 0)
}
.slide-fade-enter-active, .slide-fade-leave-active{
transition: all .6s cubic-bezier(1.0, 0.5, 0.8, 1.0);
transform : translate3d(0, -100%, 0); /*相对当前自身的额高度做偏移*/
}
.slide-fade-enter, .slide-fade-leave{
transform: translate3d(0, 0, 0);
opacity: 0;
}
.shopcart .shopcart-list .list-header{
height: 40px;
line-height: 40px;
padding: 0 18px;
background: #f3f5f7;
border-bottom: 1px solid rgba(7, 17, 27, 0.1);
}
.shopcart .shopcart-list .list-header .title{
font-size: 14px;
float: left;
color: rgb(7, 17, 27);
}
.shopcart .shopcart-list .list-header .empty{
float: right;
font-size: 12px;
color: rgb(0,160,220);
}
.shopcart .shopcart-list .list-content{
padding: 0 18px;
max-height: 217px;
background: #fff;
overflow: hidden;
}
.shopcart .shopcart-list .list-content .food{
position: relative;
padding: 12px 0;
box-sizing: border-box;
border-bottom: 1px solid rgba(7, 17, 27, 0.1);
}
.shopcart .shopcart-list .list-content .food .name{
font-size: 14px;
line-height: 24px;
color: rgb(7, 17, 27);
}
.shopcart .shopcart-list .list-content .food .price{
position: absolute;
right: 120px;
bottom: 12px;
line-height: 24px;
font-size: 14px;
color: rgb(240,20,20);
font-weight: 700;
}
.shopcart .shopcart-list .list-content .food .cartcontrol-wrapper{
position: absolute;
right: 0;
/* bottom: 3px; */
}
.list-mask{
position: fixed;
top: 0;
left: 0;
width: 100%;
height:100%;
z-index: 40;
backdrop-filter: blur(10px);
opacity: 1;
background: rgba(7, 17, 27, 0.6);
}
.fade-enter-active, .fade-leave-active{
opacity: 1;
transition: all 0.5s;
background: rgba(7, 17, 27, 0.6);
}
.fade-enter, .fade-leave{
opacity: 0;
background: rgba(7, 17, 27, 0);
}
</style>
cartcontrol.vue
<template>
<div class="cartcontrol">
<transition name="slide-fade">
<div class="decrease" v-show="food.count>0" @click.stop.prevent="decreaseCart">
<span class="inner iconfont icon-jianshao"></span>
</div>
</transition>
<div class="count" v-show="food.count>0">{{food.count}}</div>
<div class="add iconfont icon-tianjia" @click.stop.prevent="addCart"></div>
</div>
</template>
<script>
// import Vue from 'vue'
export default {
props: {
food: {
type: Object
}
},
methods: {
addCart (event) {
if (!event._constructed) {
}
if (!this.food.count) {
this.$set(this.food, 'count', 1)
} else {
this.food.count++
}
// 将DOM对象作为事件参数传入
this.$emit('car-add', event.target)
},
decreaseCart (event) {
if (!event._constructed) {
}
if (this.food.count) {
this.food.count--
}
}
}
}
</script>
<style scoped>
.cartcontrol{
display: inline-block;
font-size: 0; /**子元素中间间隙为0 */
}
.cartcontrol .slide-fade{
position: relative;
}
.cartcontrol .decrease{
display: inline-block;
}
.cartcontrol .decrease .inner{
display: inline-block;
position: absolute;
right: 60px;
bottom: 7px;
font-size: 24px;
line-height: 24px;
color: rgb(0, 160, 220);
}
.slide-fade-enter-active, .slide-fade-leave-active {
transition: all 0.4s linear
}
.slide-fade-enter , .slide-fade-leave{
opacity: 0;
transform: translate3d(44px,0,0);
}
.cartcontrol .count{
display: inline-block;
position: absolute;
right: 30px;
bottom: 10px;
width: 12px;
margin-right: 30px;
margin-bottom: 6px;
padding-top: 6px;
line-height: 24px;
text-align: center;
font-size: 10px;
color: rgb(147, 153, 159);
}
.cartcontrol .add{
position: absolute;
right: 20px;
bottom: 10px;
padding: 6px;
font-size: 24px;
line-height: 24px;
color: rgb(0, 160, 220);
}
.cartcontrol .decrease{
position: absolute;
right: 20px;
bottom: 10px;
}
</style>