智慧商城
项目收获
- 完整电商购物的业务流
- 组件库vant-ui的学习与使用
- 移动端的vw适配(基于postcss插件实现)
- request请求方法的封装
- storage存储模块的封装
- api请求模块的封装
- 请求响应拦截器
- 嵌套路由配置
- 路由导航守卫
- 路由跳转传参
- vuex分模块管理数据
- 项目的打包优化
项目前期准备工作
- 调整初始化目录
引入vant组件库
-
vant的全部导入
-
vant的按需导入
-
其他的vue组件库
使用postcss插件实现的vw适配
路由设计配置
- 确定一级路由和二级路由,例如展示一个完整的页面的就需要配置一级路由
- 根据路由设置,在views文件夹中创建对应的组件
- 通过阅读vant组件库文档,实现底部导航tabbar
- 配置二级路由
routes: [
{ path: '/', component: layout, redirect: '/home' },
{
path: '/layout',
component: layout,
children: [
{ path: '/cart', component: cartPage },
{ path: '/category', component: catecategoryPageory },
{ path: '/home', component: homePage },
{ path: '/user', component: userPage }
]
},
{ path: '/login', component: login },
{ path: '/search', component: search },
{ path: '/searchList', component: searchList },
{ path: '/prodetail/:id', component: goodsDetail },
{ path: '/pay', component: pay },
{ path: '/myOrder', component: myOrder },
{ path: '/address', component: address },
{ path: '/update', component: update }
]
登录模块
登录静态页面
- 顶部使用vant组件库中的NbvBar导航条
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
request模块 - 基于axios封装
-
使用导入axios包,通过axios.create()函数创建实例,并配置基地址baseUrl和请求时间timeout,优点如下:
- 全局配置管理:通过创建axios实例,可以在一个地方集中管理所有的请求配置,例如基本URL、默认头部信息、超时设置等。这样可以确保整个项目中的请求遵循统一的规范,提高了代码的可维护性。
- 拦截器管理:axios实例可以使用拦截器来统一处理请求和响应,例如在请求发送前做一些处理(如添加token),或者在响应返回后对数据进行统一处理(如错误处理或消息提示)。通过创建实例,可以更灵活地管理拦截器,根据需要对不同的实例设置不同的拦截逻辑
- 实现多个后端接口:在大型项目中,可能需要同时访问多个后端接口,而这些接口可能具有不同的基本URL或者需要不同的认证信息。通过创建多个axios实例,可以轻松地管理不同的接口请求,而不会产生混乱
- 避免跨域问题:在实际开发中,可能会遇到跨域请求的问题。通过创建axios实例,可以在实例中统一配置跨域请求所需的相关信息,从而避免在每次请求中都需要手动处理跨域。
-
创建请求响应拦截器
图形验证码功能
// 获取图形验证码
async getPicImage () {
const { data: { base64, key } } = await getPicImage()
// 获取到的图片地址
this.PicImage = base64
// 图片的唯一标识,将来发送到服务器验证
this.PicKey = key
},
api接口模块
Toast轻提示
短信验证码倒计时
- 步骤分析
- 代码实现
在这里插入代码片 // 实现倒计时效果
async countdown () {
// 前端校验处理函数
if (!this.validFn()) {
return
}
// 当timer为空(此时没有开启定时器)和两个second(一个second控制时间,一个演示倒计时效果)相等时开启
if (!this.timer && this.second === this.totalSceond) {
// 发送验证码请求
const res = await getMcode(this.PicCode, this.PicKey, this.mobile)
console.log(res.data)
// 在响应拦截器里统一设置错误处理
// 以下是成功的情况
this.$toast('获取短信验证码成功')
this.timer = setInterval(() => {
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.second = this.totalSceond
this.timer = null
}
}, 1000)
}
},
// 前端校验
validFn () {
// 判断手机号的正则表达式
if (!(/^1[3-9]\d{9}$/.test(this.mobile))) {
this.$toast('请输入正确手机号')
return false
}
if (!/^\w{4}$/.test(this.PicCode)) {
this.$toast('请输入正确验证码')
return false
}
return true
}
},
// 页面关闭,清除定时器ID
destroyed () {
clearInterval(this.timer)
}
实现登录功能
- 步骤
- 代码实现
async login () {
// 前端校验
if (!this.validFn()) {
return false
}
const res = await login(this.isParty, this.mobile, this.partyData, this.Mcode)
console.log('已经登录了')
// 将数据存储进store,更新vuex的数据
this.$store.commit('user/setUserInfo', res.data)
// 错误情况会有响应拦截器阻止,只需考虑正确情况即可
this.$toast('登录成功')
// 1. 地址携带查询参数,需要回跳
// 2. 直接跳转到首页
const url = this.$route.query.backUrl || '/'
this.$router.replace(url)
},
响应拦截器 - 统一处理错误提示
- 代码实现
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 对响应数据做点什么
const res = response.data
if (res.status !== 200) {
// 给错误提示,Toast默认是单例模式,后面的Toast调用了,会将前一个Toast效果覆盖
// 同时只能存在一个Toast
Toast(res.message)
// 抛错误
return Promise.reject(res.message)
} else {
Toast.clear()
}
return res
}, function (error) {
// 对响应错误做点什么
return Promise.reject(error)
})
storage存储模块
- 需求
- 代码实现
// 本地存储模块,利用本地存储进行vuex持久化处理
const InfoKey = 'pro-shooping'
const HistoryKey = 'pro-History'
export const setUserInfo = (UserInfo) => {
localStorage.setItem(InfoKey, JSON.stringify(UserInfo))
}
export const getUserInfo = () => {
const result = localStorage.getItem(InfoKey)
return result ? JSON.parse(result) : { token: '', userId: '' }
}
export const removeInfo = () => {
localStorage.removeItem(InfoKey)
}
export const getHistory = () => {
const result = localStorage.getItem(HistoryKey)
return result ? JSON.parse(result) : []
}
export const setHistory = (arr) => {
localStorage.setItem(HistoryKey, JSON.stringify(arr))
}
登录权证信息存储 - vuex持久化存储
- 步骤
- 构建user模块
// 存储 user 信息
// 导入storage模块,处理vuex持久化
import { setUserInfo, getUserInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
userInfo: getUserInfo()
}
},
mutations: {
// mutations中的第一个形参都是state,第二个才是payload
setUserInfo (state, obj) {
console.log(obj)
state.userInfo = obj
setUserInfo(obj)
}
},
actions: {
logout (context) {
context.commit('setUserInfo', {})
// 第三个参数设置成root:true,可以将此方法挂载到全局上,可以按照全局使用
context.commit('cart/setcartList', [], { root: true })
}
},
getters: {}
}
- 挂载到vuex上
// 在index.js中导入
import user from './modules/user'
import cart from './modules/cart'
// 挂载
export default new Vuex.Store({
state: {
},
getters: {
token (state) {
return state.user.userInfo.token
}
},
mutations: {
},
actions: {
},
// 模块化
modules: {
user,
cart
}
})
添加请求loading效果
- 实操步骤
- 代码实现
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 添加loading效果
Toast.loading({
message: '加载中...',
forbidClick: true, // 禁止背景点击
loadingType: 'spinner', //加载图标
duration: 0 // 设置loding不会关闭
})
页面访问拦截
- 需求
- 路由导航守卫-全局前置守卫
// 创建一个需要访问权限页面的数组
const authArray = ['/pay', '/myOrder']
// 前置守卫
router.beforeEach((to, from, next) => {
// 如果不是权限页面
if (!authArray.includes(to.path)) {
next()
return
}
// 是需要权限的页面 ->判断是否有token凭证,有则放行,无则跳转到登录页面
const token = store.getters.token
console.log(token)
if (token) {
next()
} else {
next('/login')
}
})
首页模块
静态页面-基于vant完成
-
步骤
-
代码
<template>
<div class="home">
<!-- 导航条 -->
<van-nav-bar title="智慧商城" fixed />
<!-- 搜索框 -->
<van-search
readonly
shape="round"
background="#f1f1f2"
placeholder="请在此输入搜索关键词"
@click="$router.push('/search')"
/>
<!-- 轮播图 -->
<van-swipe class="my-swipe" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="item in items[1].data" :key="item.imgUrl">
<img :src="item.imgUrl" alt="">
</van-swipe-item>
</van-swipe>
<!-- 导航 -->
<van-grid column-num="5" icon-size="40">
<van-grid-item
v-for="item in items[3].data" :key="item.imgUrl"
:icon="item.imgUrl"
:text="item.text"
@click="$router.push('/category')"
/>
</van-grid>
<!-- 主会场 -->
<div class="main">
<img src="@/assets/main.png" alt="">
</div>
<!-- 猜你喜欢 -->
<div class="guess">
<p class="guess-title">—— 猜你喜欢 ——</p>
<div class="goods-list">
<GoodsItem v-for="(item) in items[6].data" :key="item.goods_id" :item="item" ></GoodsItem>
</div>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getHomeData } from '@/api/home'
export default {
name: 'HomePage',
data () {
return {
items: []
}
},
components: {
GoodsItem
},
async created () {
const res = await getHomeData()
console.log(res)
this.items = res.data.pageData.items
console.log(this.items[6].data)
}
}
</script>
<style lang="less" scoped>
// 主题 padding
.home {
padding-top: 100px;
padding-bottom: 50px;
}
// 导航条样式定制
.van-nav-bar {
z-index: 999;
background-color: #c21401;
::v-deep .van-nav-bar__title {
color: #fff;
}
}
// 搜索框样式定制
.van-search {
position: fixed;
width: 100%;
top: 46px;
z-index: 999;
}
// 分类导航部分
.my-swipe .van-swipe-item {
height: 185px;
color: #fff;
font-size: 20px;
text-align: center;
background-color: #39a9ed;
}
.my-swipe .van-swipe-item img {
width: 100%;
height: 185px;
}
// 主会场
.main img {
display: block;
width: 100%;
}
// 猜你喜欢
.guess .guess-title {
height: 40px;
line-height: 40px;
text-align: center;
}
// 商品样式
.goods-list {
background-color: #f6f6f6;
}
</style>
搜索模块
搜索-历史记录管理
- 需求
- 核心js代码
<script>
import { getHistory, setHistory } from '@/utils/storage'
export default {
name: 'SearchIndex',
data () {
return {
search: '', //和搜索框双向绑定
list: getHistory() || [] //优先从本地拿取数据
}
},
methods: {
goSearch (key) {
// indexOf 是用来查询数组中是否包含某个值,若有则返回该值的下标,否则返回-1
const index = this.list.indexOf(key)
if (index !== -1) {
// 搜索的是数组中存在的值,则将其删除
this.list.splice(index, 1)
}
// 将key加入数组首位
this.list.unshift(key)
// 更新本地存储
setHistory(this.list)
// this.search = ''
this.$router.push(`/searchlist?key=${key}`)
},
clear () {
this.list = []
setHistory([])
}
}
}
</script>
搜索列表 - 静态布局&动态渲染
- 步骤
- 核心js逻辑
<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getProList } from '@/api/product'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
data () {
return {
proList: [],
sortType: 'all',
categoryId: ''
}
},
// 使用计算属性来动态获取地址查询参数
computed: {
searchKey () {
// 查询参数
return this.$route.query.key
}
},
async created () {
this.getProList()
},
methods: {
// 获取商品列表
async getProList () {
const res = await getProList({
sortType: this.sortType,
categoryId: this.$route.query.categoryId,
goodsName: this.searchKey,
page: 1
})
const { data: { list } } = res
this.proList = list.data
},
// 商品排序
async choice (sortType) {
this.sortType = sortType
this.getProList()
}
}
}
</script>
商品详情
- 步骤
- 核心js逻辑
<script>
import { getGoodsComment, getGoodsDetail } from '@/api/product'
import CountBox from '@/components/CountBox.vue'
import defaultImage from '@/assets/default-avatar.png'
import { cartAdd } from '@/api/cart'
import loginConfirm from '@/mixins/loginConfirm'
export default {
name: 'ProDetail',
mixins: [loginConfirm],
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
list: [],
total: '',
showPannel: false,
mode: 'cart', // 控制弹窗
goodsNum: 1,
defaultImage,
cartTotal: ''
}
},
created () {
this.goodsDetail()
this.goodsComment()
},
computed: {
getGoodsId () {
return this.$route.params.id
}
},
methods: {
onChange (index) {
this.current = index
},
// 获取商品详情
async goodsDetail () {
const res = await getGoodsDetail(this.getGoodsId)
this.detail = res.data.detail
this.images = this.detail.goods_images
console.log(this.detail)
},
// 获取商品评论
async goodsComment () {
const res = await getGoodsComment(this.getGoodsId, 3)
console.log(res)
this.total = res.data.total
this.list = res.data.list
},
// 购物车
cartAdd () {
this.showPannel = true
this.mode = 'cart'
},
buynow () {
this.showPannel = true
this.mode = ''
}
</script>
- 在请求拦截器里设置请求头参数
// 在config中携带请求头参数
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
购物车模块
购物车弹层-vant
<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>
<CountBox v-model="goodsNum"></CountBox>
</div>
<div class="showbtn" v-if="detail.stock_total>0 ">
<div @click="AddCart" class="btn" v-if="mode">加入购物车</div>
<div @click="buyNow" class="btn now" v-else>立刻购买</div>
</div>
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
封装数字框组件
- 步骤
- 代码实现
<template>
<div class="countBox">
<div class="left" @click="countSub">-</div>
<input :value="value" @change="inpChange" class="inp" type="text">
<div class="right" @click="countAdd">+</div>
</div>
</template>
<script>
export default {
// 通过props接收
props: {
value: {
type: Number,
default: 1
}
},
methods: {
countSub () {
if (this.value <= 1) {
return
}
// 子传父
this.$emit('input', this.value - 1)
},
countAdd () {
this.$emit('input', this.value + 1)
},
inpChange (e) {
const num = +e.target.value
// 输入不合法的情况下
if (isNaN(num) || num <= 0) {
e.target.value = this.value
return
}
this.$emit('input', num)
}
}
}
</script>
<style lang="less" scoped>
.countBox{
width: 110px;
height: 30px;
display: flex;
font-size: 15px;
.left,.right{
width: 30px;
height: 30px;
background-color: #efefef;
outline: none;
border: none;
text-align: center;
line-height: 30px;
}
.inp{
width: 40px;
height: 30px;
margin: 0px 5px;
background-color: #efefef;
outline: none;
border: none;
text-align: center;
line-height: 30px;
}
}
</style>
判断token添加登录提示
- 步骤
- 代码实现
async AddCart () {
// 判断token是否存在
if (this.loginConfirm()) {
return
}
// 若有token则需要发送请求
// 遇到需要携带请求头的参数,直接前往请求拦截器设置
const res = await cartAdd(this.getGoodsId, this.goodsNum, this.detail.skuList[0].goods_sku_id)
console.log(res)
this.cartTotal = res.data.cartTotal
this.$toast('加入购物车成功')
},
路由回跳
封装接口进行请求
- 步骤
- 静态结构
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>4</i>件商品</span>
<span class="edit">
<van-icon name="edit" />
编辑
</span>
</div>
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in 10" :key="item">
<van-checkbox></van-checkbox>
<div class="show">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a072ef0eef1648a5c4eae81fad1b7583.jpg" alt="">
</div>
<div class="info">
<span class="tit text-ellipsis-2">新Pad 14英寸 12+128 远峰蓝 M6平板电脑 智能安卓娱乐十核游戏学习二合一 低蓝光护眼超清4K全面三星屏5GWIFI全网通 蓝魔快本平板</span>
<span class="bottom">
<div class="price">¥ <span>1247.04</span></div>
<div class="count-box">
<button class="minus">-</button>
<input class="inp" :value="4" type="text" readonly>
<button class="add">+</button>
</div>
</span>
</div>
</div>
</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">99.99</i></span>
</div>
<div v-if="true" class="goPay">结算(5)</div>
<div v-else class="delete">删除</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CartPage'
}
</script>
<style lang="less" scoped>
// 主题 padding
.cart {
padding-top: 46px;
padding-bottom: 100px;
background-color: #f5f5f5;
min-height: 100vh;
.cart-title {
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
font-size: 14px;
.all {
i {
font-style: normal;
margin: 0 2px;
color: #fa2209;
font-size: 16px;
}
}
.edit {
.van-icon {
font-size: 18px;
}
}
}
.cart-item {
margin: 0 10px 10px 10px;
padding: 10px;
display: flex;
justify-content: space-between;
background-color: #ffffff;
border-radius: 5px;
.show img {
width: 100px;
height: 100px;
}
.info {
width: 210px;
padding: 10px 5px;
font-size: 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
.bottom {
display: flex;
justify-content: space-between;
.price {
display: flex;
align-items: flex-end;
color: #fa2209;
font-size: 12px;
span {
font-size: 16px;
}
}
.count-box {
display: flex;
width: 110px;
.add,
.minus {
width: 30px;
height: 30px;
outline: none;
border: none;
}
.inp {
width: 40px;
height: 30px;
outline: none;
border: none;
background-color: #efefef;
text-align: center;
margin: 0 5px;
}
}
}
}
}
}
.footer-fixed {
position: fixed;
left: 0;
bottom: 50px;
height: 50px;
width: 100%;
border-bottom: 1px solid #ccc;
background-color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
.all-check {
display: flex;
align-items: center;
.van-checkbox {
margin-right: 5px;
}
}
.all-total {
display: flex;
line-height: 36px;
.price {
font-size: 14px;
margin-right: 10px;
.totalPrice {
color: #fa2209;
font-size: 18px;
font-style: normal;
}
}
.goPay, .delete {
min-width: 100px;
height: 36px;
line-height: 36px;
text-align: center;
background-color: #fa2f21;
color: #fff;
border-radius: 18px;
&.disabled {
background-color: #ff9779;
}
}
}
}
</style>
- 构建vuex的cart模块并动态渲染
import { getCartList, delSelect } from '@/api/cart'
import { Toast } from 'vant'
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
setcartList (state, newList) {
state.cartList = newList
},
},
actions: {
// 异步获取数据,通过mutations修改购物车数据
async getcartActions (context) {
const res = await getCartList()
// 挂在ischecked来控制复选框
const { data } = res
data.list.forEach(item => {
item.ischecked = false
})
console.log(res)
context.commit('setcartList', data.list)
},
getters: {}
- 封装getters实现动态统计
getters: {
// 统计商品数量
countTotal (state) {
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 统计选中的商品项
selProduct (state) {
return state.cartList.filter(item => item.ischecked)
},
// 统计选中的商品数量
// 在getters中可以设置第二个参数使用其他的getters
selCount (state, getters) {
return getters.selProduct.reduce((sum, item) => sum + item.goods_num, 0)
},
// 统计选中的商品总价
selPrice (state, getters) {
return getters.selProduct.reduce((sum, item) => sum + item.goods_num * item.goods.goods_price_min, 0)
},
// 设置是否全选
isAllCheck (state) {
return state.cartList.every(item => item.ischecked)
}
}
}
- 全选反选功能实现
// 1. 因为后返回的数据没有ischecked属性来标识商品
// 所以我们需要前端手动的加上此属性来标识
// 异步获取数据,通过mutations修改购物车数据
async getcartActions (context) {
const res = await getCartList()
// 挂在ischecked来控制复选框
const { data } = res
data.list.forEach(item => {
item.ischecked = false
})
console.log(res)
context.commit('setcartList', data.list)
},
// 2. 提供mutations函数,完成全选反选功能
// 状态取反
toggleCheck (state, goodsId) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.ischecked = !goods.ischecked
},
// 全选反选
toggleAllCheck (state, flag) {
state.cartList.forEach(item => {
item.ischecked = flag
})
},
- 数字框修改数量功能
changeCount (state, obj) {
const { goodsId, goodsNum } = obj
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.goods_num = goodsNum
}
- 删除购物车
// 删除购物车 -- 需要选中的商品的id发送请求
async delSelectActions (context) {
// 通过getters拿到被选中的商品
const selGoods = context.getters.selProduct
// 通过map遍历被选中的商品,拿到其中的id值
const cartIds = selGoods.map((item) => item.id)
console.log(cartIds)
const res = await delSelect(cartIds)
console.log(res)
Toast('删除成功')
// 删除成功,重新渲染
context.dispatch('getcartActions')
}
订单结算模块
购物车结算
立刻购买结算
mixins混入
- 使用时只需要在组件内引用即可
export default {
data () {
return {
}
},
methods: {
loginConfirm () {
// 因为token事先存放在了store仓库里的getters里
// 若是没有token,则需要跳转到登录页
if (!this.$store.getters.token) {
// 使用一个dialog弹出框
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
confirmButtonText: '去登录',
cancelButtonText: '在逛逛'
})
// 点击确定按钮走then操作,此时需要跳转到登录界面
.then(() => {
// 如果希望跳转到登录 =》登录完跳转回来,就必须携带参数(当前的路径地址)
// this.$route.fullpath(会包含查询参数)
// replace 与 push 的区别在于不会产生新的历史记录,而是直接替换即可
this.$router.replace({
path: '/login',
query: {
backUrl: this.$route.fullPath
}
})
})
.catch(() => {
// on cancel
})
return true
}
return false
}
}
}
提交订单并支付
跨模块调用actions
打包
打包发布
打包优化:路由懒加载
import search from '@/views/search'
import searchList from '@/views/search/list'
import myOrder from '@/views/myOrder'
import store from '@/store/index'
import address from '@/views/address'
// 二级路由
import homePage from '@/views/layout/homePage'
import cartPage from '@/views/layout/cartPage'
import catecategoryPageory from '@/views/layout/categoryPage'
import userPage from '@/views/layout/userPage'
const login = () => import('@/views/login')
const goodsDetail = () => import('@/views/goodsDetail')
const pay = () => import('@/views/pay')
const update = () => import('@/views/address/update')