文章目录
- 1.静态页面结构准备和动态渲染
- 2.搜索 - 历史记录管理
- 1. 写好基础静态页面,可以先往里面加一点假数据
- 2. 上面基本的渲染直接利用history渲染就可以了
- 3. 搜索历史基本渲染结束了,开始点击搜索添加历史
- 4. vant内用v-model=" ",可以快速拿到搜索框的值
- 5. 往历史记录里面追加,追加到最前面,要用到`onshift`
- 6. 清空数组,就是把它变成一个空数组,只需要在垃圾桶图标的地方注册一个点击事件,然后在methods中写方法
- 7. 完成搜索历史的持久化,往storage模块里封装方法就可以了
- 3.搜索列表 - 静态布局 & 渲染
- 1.现在这个手机是写死的,不管搜什么都是手机(基于搜索关键字渲染)
- 2.基于分类页进行渲染
- 4.商品详情 - 静态布局 & 渲染
- 1.图片部分
- 2.商品评价部分(获取接口)
- 5.加入购物车 - 唤起弹层
- 6.加入购物车 - 封装数字组件
- 7.加入购物车 - 判断token登录提示
- 1.封装接口 api/cart.js
- 2.页面中调用请求
- 3.请求拦截器中,统一携带 token
- 8.构建 vuex cart模块,获取数据存储
- 10. 购物车 - 封装 getters - 动态计算展示
- 11. 购物车 - 全选反选功能
- 12. 购物车 - 数字框修改数量
- 13. 购物车 - 编辑、删除、空购物车处理
- 14. 订单结算台
1.静态页面结构准备和动态渲染
van-search是搜索框
van-swipe & van-swipe-item是轮播图
van-grid & van-grid-item是grid布局
2.搜索 - 历史记录管理
目标:构建搜索页面的静态布局,完成历史记录的管理
历史管理的需求:
1.搜索历史基本渲染(展示之前搜索过的标签
2.点击搜索(添加历史)
点击 搜索按钮 或 底下历史记录, 都能进行搜索
①若之前 没有 相同搜索关键字,则直接追加到最前面
②若之前 已有 相同搜索关键字, 将该原有关键字移除,再追加
3.清空历史:添加清空图标, 可以清空历史记录
4.持久化:搜索历史需要持久化,刷新历史不丢失
搜索部分在views/search/index.vue里面写
1. 写好基础静态页面,可以先往里面加一点假数据
<script>
export default {
name: 'SearchIndex',
data () {
return {
history: ['手机', '白酒', '电视']
}
}
}
</script>
2. 上面基本的渲染直接利用history渲染就可以了
<!-- 搜索历史 -->
<div class="search-history" v-if="history.length > 0">
<!-- 上面的 v-if 是,历史的长度大于0,有历史,才去渲染下面的东西 -->
<div class="title">
<span>最近搜索</span>
<van-icon name="delete-o" size="16" />
</div>
<div class="list">
<!-- 内容用v-for循环 -->
<div v-for ="item in history" :key="item" class="list-item" @click="$router.push('/searchlist')">{{ item }}</div>
</div>
</div>
3. 搜索历史基本渲染结束了,开始点击搜索添加历史
给搜索和最近搜索标签添加点击事件,
goSearch
,在下面添加方法
<div @click="goSearch">搜索</div>
<div v-for ="item in history" :key="item" class="list-item" @click="goSearch">{{ item }}</div>
methods: {
goSearch () {
console.log('进行了搜索')
}
}
4. vant内用v-model=" ",可以快速拿到搜索框的值
<van-search v-model="search" show-action placeholder="请输入搜索关键词" clearable>
<div @click="goSearch(search)">搜索</div> <!--把search传过去-->
<div v-for ="item in history" :key="item" class="list-item" @click="goSearch(item)">{{ item }}</div>
data () {
return {
search: '',
}
},
methods: {
goSearch (key) {
console.log('进行了搜索')
}
}
5. 往历史记录里面追加,追加到最前面,要用到onshift
methods: {
goSearch (key) {
// console.log('进行了搜索')
const index = this.history.indexOf(key) // indexOf的作用是用来查找当前这个key在history里的下标,如果将来真的找到了,便于删除
if (index !== -1) {
// 存在相同的项,将原有的关键字移除
// splice (从哪开始,删除几个,项1,项2)
this.history.splice(index, 1)
}
this.history.unshift(key)
}
}
6. 清空数组,就是把它变成一个空数组,只需要在垃圾桶图标的地方注册一个点击事件,然后在methods中写方法
<van-icon @click="clear" name="delete-o" size="16" />
clear () {
this.history = []
}
7. 完成搜索历史的持久化,往storage模块里封装方法就可以了
先在storage里面写
const HISTORY_KEY = 'zxy_history_list'
// 获取搜索历史
export const getHiatoryList = () => {
const result = localStorage.getItem(HISTORY_KEY)
return result ? JSON.parse(result) : []
}
// 设置搜索历史
export const setHiatoryList = (arr) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}
然后历史记录应该优先从本地去读,直接调用(看有"👈"的行)
import { getHistoryList, setHistoryList } from '@/utils/storage'👈
export default {
name: 'SearchIndex',
data () {
return {
search: '',
history: getHistoryList()// 从本地读取👈
}
},
methods: {
goSearch (key) {
// console.log('进行了搜索')
const index = this.history.indexOf(key) // indexOf的作用是用来查找当前这个key在history里的下标,如果将来真的找到了,便于删除
if (index !== -1) {
// 存在相同的项,将原有的关键字移除
// splice (从哪开始,删除几个,项1,项2)
this.history.splice(index, 1)
}
this.history.unshift(key)
setHistoryList(this.history)// 本地存👈
// 跳转到搜索列表页
this.$router.push(`/searchlist?search=${key}`)👈
},
clear () {
this.history = []
setHistoryList([])👈
}
}
}
3.搜索列表 - 静态布局 & 渲染
1.现在这个手机是写死的,不管搜什么都是手机(基于搜索关键字渲染)
要去找接口文档
api/product.js
import requset from '@/utils/request'
// 获取搜索商品列表的数据
export const getProList = (obj) => {
const { categoryId, goodsName, page } = obj
return requset.get('/goods/list', {
params: {
categoryId,
goodsName,
page
}
})
}
计算属性,query拿地址栏参数
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
}
}
在created里面发请求,拿数据然后渲染
data () {
return {
page: 1,
proList: []
}
},
async created () {
const { data: { list } } = await getProList({
goodsName: this.querySearch,
page: this.page
})
this.proList = list.data
}
list.vue把item传进去,用Goodsitem.vue解析
list.vue
<div class="goods-list">
<GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
</div>
2.基于分类页进行渲染
新建api/category.js
import request from '@/utils/request'
// 获取分类数据
export const getCategoryData = () => {
return request.get('/category/list')
}
list.vue
async created () {
const { data: { list } } = await getProList({
categoryId: this.$route.query.categoryId,👈
goodsName: this.querySearch,
page: this.page
})
this.proList = list.data
}
4.商品详情 - 静态布局 & 渲染
1.图片部分
product.js
// 获取商品详情数据
export const getProDetail = (goodsId) => {
return requset.get('/goods/detail', {
params: {
goodsId
}
})
}
prodetail/index.vue
<van-swipe :autoplay="3000" @change="onChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />👈
</van-swipe-item>
<template #indicator>
<div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
</template>
</van-swipe>
<!-- 商品说明 -->
<div class="info">
<div class="title">
<div class="price">
<span class="now">¥{{ detail.goods_price_min }}</span>👈
<span class="oldprice">¥{{ detail.goods_price_max }}</span>👈
</div>
<div class="sellcount">已售 {{ detail.goods_sales }} 件</div>👈
</div>
<div class="msg text-ellipsis-2">
{{ detail.goods_name }}👈
</div>
data () {
return {
images: [],
current: 0,
detail: {}
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
},
methods: {
onChange (index) {
this.current = index
},
async getDetail () {
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
console.log(this.images)
}
}
商品描述部分不能用{{ }},因为里面包含p标签
<!-- 商品描述 -->
<div class="desc" v-html="detail.content">
</div>
2.商品评价部分(获取接口)
product.js
// 获取商品评价
export const getProComments = (goodsId, limit) => {
return request.get('/comment/listRows', {
params: {
goodsId,
limit
}
})
}
index.vue
<!-- 商品评价 -->
<div class="comment">
<div class="comment-title">
<div class="left">商品评价 ({{ total }})</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<div class="name">{{ item.user.nick_name }}</div>
<van-rate :size="16" :value="item.score / 2" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
{{ item.content }}
</div>
<div class="time">
{{ item.create_time }}
</div>
</div>
</div>
</div>
import defaultImg from '@/assets/default-avatar.png'
export default {
name: 'ProDetail',
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 评价总数👈
commentList: [], // 评价列表👈
defaultImg👈
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()👈
},
methods: {
onChange (index) {
this.current = index
},
async getDetail () {
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
console.log(this.images)
},
async getComments () {👈
const { data: { list, total } } = await getProComments(this.goodsId, 3)👈
this.commentList = list👈
this.total = total👈
}👈
}
}
</script>
5.加入购物车 - 唤起弹层
弹层用的是vant中的反馈组件
import { ActionSheet } from 'vant';
Vue.use(ActionSheet);
自定义面板
通过插槽可以自定义面板的展示内容,同时可以使用title属性展示标题栏
<van-action-sheet v-model="show" title="标题">
<div class="content">内容</div>
</van-action-sheet>
<style>
.content {
padding: 16px 16px 160px;
}
</style>
index.vue
<!-- 加入购物车的弹层 -->
<van-action-sheet v-model="showPannel" :title="mode === 'cart' ? '加入购物车' : '立刻购买'">
<div class="product">
<div class="product-title">
<div class="left">
<img :src="detail.goods_image" alt="">
</div>
<div class="right">
<div class="price">
<span>¥</span>
<span class="nowprice">{{ detail.goods_price_min }}</span>
</div>
<div class="count">
<span>库存</span>
<span>{{ detail.stock_total }}</span>
</div>
</div>
</div>
<div class="num-box">
<span>数量</span>
数字框占位
</div>
<!-- 有库存才显示提交按钮 -->
<div class="showbtn" v-if="detail.stock_total > 0">
<div class="btn" v-if="mode === 'cart'">加入购物车</div>
<div class="btn now" v-else>立刻购买</div>
</div>
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
6.加入购物车 - 封装数字组件
components/CountBox.vue
<template>
<div class="count-box">
<button @click="handleSub" class="minus">-</button>
<input :value="value" @change="handleChange" class="inp" type="text">
<button @click="handleAdd" class="add">+</button>
</div>
</template>
<script>
export default {
props: {
value: {
type: Number,
default: 1
}
},
methods: {
handleSub () {
if (this.value <= 1) {
return
}
this.$emit('input', this.value - 1)
},
handleAdd () {
this.$emit('input', this.value + 1)
},
handleChange (e) {
// console.log(e.target.value)
const num = +e.target.value // 转数字处理 (1) 数字 (2) NaN
// 输入了不合法的文本 或 输入了负值,回退成原来的 value 值
if (isNaN(num) || num < 1) {
e.target.value = this.value
return
}
this.$emit('input', num)
}
}
}
</script>
<style lang="less" scoped>
.count-box {
width: 110px;
display: flex;
.add, .minus {
width: 30px;
height: 30px;
outline: none;
border: none;
background-color: #efefef;
}
.inp {
width: 40px;
height: 30px;
outline: none;
border: none;
margin: 0 5px;
background-color: #efefef;
text-align: center;
}
}
</style>
prodeatil/index.js
import CountBox from '@/components/CountBox.vue'👈
export default {
name: 'ProDetail',
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 评价总数
commentList: [], // 评价列表
defaultImg,
showPannel: false, // 控制弹层的显示隐藏
mode: 'cart', // 标记弹层状态
addCount: 1 // 数字框绑定的数据👈
}
},
7.加入购物车 - 判断token登录提示
1.封装接口 api/cart.js
// 加入购物车
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
})
}
2.页面中调用请求
data () {
return {
cartTotal: 0
}
},
async addCart () {
...
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
},
3.请求拦截器中,统一携带 token
// 自定义配置 - 请求/响应 拦截器
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
...
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
8.构建 vuex cart模块,获取数据存储
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
新建 modules/cart.js 模块
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
},
actions: {
},
getters: {
}
}
挂载到 store 上面
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import cart from './modules/cart'
Vue.use(Vuex)
export default new Vuex.Store({
getters: {
token: state => state.user.userInfo.token
},
modules: {
user,
cart
}
})
封装 API 接口 api/cart.js
// 获取购物车列表数据
export const getCartList = () => {
return request.get('/cart/list')
}
封装 action 和 mutation
mutations: {
setCartList (state, newList) {
state.cartList = newList
},
},
actions: {
async getCartAction (context) {
const { data } = await getCartList()
data.list.forEach(item => {
item.isChecked = true
})
context.commit('setCartList', data.list)
}
},
页面中 dispatch 调用
computed: {
isLogin () {
return this.$store.getters.token
}
},
created () {
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
- 购物车 - mapState - 渲染购物车列表
将数据映射到页面
import { mapState } from 'vuex'
computed: {
...mapState('cart', ['cartList'])
}
动态渲染
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<van-checkbox icon-size="18" :value="item.isChecked"></van-checkbox>
<div class="show" @click="$router.push(`/prodetail/${item.goods_id}`)">
<img :src="item.goods.goods_image" alt="">
</div>
<div class="info">
<span class="tit text-ellipsis-2">{{ item.goods.goods_name }}</span>
<span class="bottom">
<div class="price">¥ <span>{{ item.goods.goods_price_min }}</span></div>
<CountBox :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
10. 购物车 - 封装 getters - 动态计算展示
封装 getters:商品总数 / 选中的商品列表 / 选中的商品总数 / 选中的商品总价
getters: {
cartTotal (state) {
return state.cartList.reduce((sum, item, index) => sum + item.goods_num, 0)
},
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
selCount (state, getters) {
return getters.selCartList.reduce((sum, item, index) => sum + item.goods_num, 0)
},
selPrice (state, getters) {
return getters.selCartList.reduce((sum, item, index) => {
return sum + item.goods_num * item.goods.goods_price_min
}, 0).toFixed(2)
}
}
页面中 mapGetters 映射使用
computed: {
...mapGetters('cart', ['cartTotal', 'selCount', 'selPrice']),
},
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal || 0 }}</i>件商品</span>
<span class="edit">
<van-icon name="edit" />
编辑
</span>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
</div>
<div v-if="true" :class="{ disabled: selCount === 0 }" class="goPay">
结算({{ selCount }})
</div>
<div v-else :class="{ disabled: selCount === 0 }" class="delete">
删除({{ selCount }})
</div>
</div>
</div>
11. 购物车 - 全选反选功能
全选 getters
getters: {
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
...mapGetters('cart', ['isAllChecked']),
<div class="all-check">
<van-checkbox :value="isAllChecked" icon-size="18"></van-checkbox>
全选
</div>
点击小选,修改状态
<van-checkbox @click="toggleCheck(item.goods_id)" ...></van-checkbox>
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
mutations: {
toggleCheck (state, goodsId) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.isChecked = !goods.isChecked
},
}
点击全选,重置状态
<div @click="toggleAllCheck" class="all-check">
<van-checkbox :value="isAllChecked" icon-size="18"></van-checkbox>
全选
</div>
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
mutations: {
toggleAllCheck (state, flag) {
state.cartList.forEach(item => {
item.isChecked = flag
})
},
}
12. 购物车 - 数字框修改数量
封装 api 接口
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/update', {
goodsId,
goodsNum,
goodsSkuId
})
}
页面中注册点击事件,传递数据
<CountBox :value="item.goods_num" @input="value => changeCount(value, item.goods_id, item.goods_sku_id)"></CountBox>
changeCount (value, goodsId, skuId) {
this.$store.dispatch('cart/changeCountAction', {
value,
goodsId,
skuId
})
},
提供 action 发送请求, commit mutation
mutations: {
changeCount (state, { goodsId, value }) {
const obj = state.cartList.find(item => item.goods_id === goodsId)
obj.goods_num = value
}
},
actions: {
async changeCountAction (context, obj) {
const { goodsId, value, skuId } = obj
context.commit('changeCount', {
goodsId,
value
})
await changeCount(goodsId, value, skuId)
},
}
13. 购物车 - 编辑、删除、空购物车处理
data 提供数据, 定义是否在编辑删除的状态
data () {
return {
isEdit: false
}
},
注册点击事件,修改状态
<span class="edit" @click="isEdit = !isEdit">
<van-icon name="edit" />
编辑
</span>
底下按钮根据状态变化
<div v-if="!isEdit" :class="{ disabled: selCount === 0 }" class="goPay">
去结算({{ selCount }})
</div>
<div v-else :class="{ disabled: selCount === 0 }" class="delete">删除</div>
监视编辑状态,动态控制复选框状态
watch: {
isEdit (value) {
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
购物车 - 删除功能完成
查看接口,封装 API ( 注意:此处 id 为获取回来的购物车数据的 id )
// 删除购物车
export const delSelect = (cartIds) => {
return request.post('/cart/clear', {
cartIds
})
}
注册删除点击事件
<div v-else :class="{ disabled: selCount === 0 }" @click="handleDel" class="delete">
删除({{ selCount }})
</div>
async handleDel () {
if (this.selCount === 0) return
await this.$store.dispatch('cart/delSelect')
this.isEdit = false
},
提供 actions
actions: {
// 删除购物车数据
async delSelect (context) {
const selCartList = context.getters.selCartList
const cartIds = selCartList.map(item => item.id)
await delSelect(cartIds)
Toast('删除成功')
// 重新拉取最新的购物车数据 (重新渲染)
context.dispatch('getCartAction')
}
},
购物车 - 空购物车处理
外面包个大盒子,添加 v-if 判断
<div class="cart-box" v-if="isLogin && cartList.length > 0">
<!-- 购物车开头 -->
<div class="cart-title">
...
</div>
<!-- 购物车列表 -->
<div class="cart-list">
...
</div>
<div class="footer-fixed">
...
</div>
</div>
<div class="empty-cart" v-else>
<img src="@/assets/empty.png" alt="">
<div class="tips">
您的购物车是空的, 快去逛逛吧
</div>
<div class="btn" @click="$router.push('/')">去逛逛</div>
</div>
14. 订单结算台
所谓的 “立即结算”,本质就是跳转到订单结算台,并且跳转的同时,需要携带上对应的订单参数。
而具体需要哪些参数,就需要基于 【订单结算台】 的需求来定。
(1) 静态布局
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
(2) 获取收货地址列表
- 封装获取地址的接口
import request from '@/utils/request'
// 获取地址列表
export const getAddressList = () => {
return request.get('/address/list')
}
- 页面中 - 调用获取地址
data () {
return {
addressList: []
}
},
computed: {
selectAddress () {
// 这里地址管理不是主线业务,直接获取默认第一条地址
return this.addressList[0]
}
},
async created () {
this.getAddressList()
},
methods: {
async getAddressList () {
const { data: { list } } = await getAddressList()
this.addressList = list
}
}
- 页面中 - 进行渲染
computed: {
longAddress () {
const region = this.selectAddress.region
return region.province + region.city + region.region + this.selectAddress.detail
}
},
<div class="info" v-if="selectAddress?.address_id">
<div class="info-content">
<span class="name">{{ selectAddress.name }}</span>
<span class="mobile">{{ selectAddress.phone }}</span>
</div>
<div class="info-address">
{{ longAddress }}
</div>
</div>
(3) 订单结算 - 封装通用接口
思路分析 : 这里的订单结算,有两种情况:
购物车结算,需要两个参数
① mode=“cart”
② cartIds=“cartId, cartId”
立即购买结算,需要三个参数
① mode=“buyNow”
② goodsId=“商品id”
③ goodsSkuId=“商品skuId”
都需要跳转时将参数传递过来
封装通用 API 接口 api/order
import request from '@/utils/request'
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode,
delivery: 0,
couponId: 0,
isUsePoints: 0,
...obj
}
})
}