Vue2-智慧商城移动端项目
vue2
智慧商城项目
项目流程

项目收获

步骤
创建项目
基于VueCli自定义项目架子

当进入创建的项目目录中,运行项目可以在网页看见默认布局时表示项目创建成功
调整初始化目录
将目录调整成符合企业规范的目录
-
删除多余的文件:
-
assets下的logo.png
-
components下的HelloWorld.vue
-
views下的vue文件
-
-
修改路由配置和App.vue
-
清空路由规则:
import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) const routes = [ ] const router = new VueRouter({ routes }) export default router -
删除App.vue中的样式及nav元素
-
-
新增两个目录:
-
api:发送ajax请求的接口模块
-
utils:自己封装的一些工具方法模块
-
vant-ui组件库
组件库:第三方封装好了很多的组件,整合在一起就是一个组件库
-
vue3对应vant3和vant4:https://vant-ui.github.io/vant/#/zh-CN
常见组件库:
-
PC端:
-
element-ui(vue2):组件 | Element
-
element-plus(vue3):Watermark 水印 | Element Plus
-
ant-design-vue(均支持):Ant Design of Vue - Ant Design Vue
-
-
移动端:
-
vant-ui:
-
Mint UI(饿了么):Mint UI
-
Cube UI(滴滴):cube-ui Document
-
vant2安装:npm i vant@latest-v2 -S
引入组件:
-
按需导入(自动/手动):性能高(上线时用户加载速度快),但需要多次引入
-
安装插件:
npm i babel-plugin-import -DD表示仅在开发过程使用(如果安装失败提示版本冲突可以使用
--force强制安装) -
在
babel.config.js中配置:module.exports = { plugins: [ ['import', { libraryName: 'vant', libraryDirectory: 'es', style: true }, 'vant'] ] }; -
在
main.js中按需导入并注册://按需导入 import { Button,Switch } from 'vant'; Vue.use(Button) Vue.use(Switch)
如果需要导入的组件越来越多,那么main.js中的内容会越来越多,为了便于管理,将导入组件的代码放在
utils>vant-ui.js并且只需在main.js中导入import @/utils/vant-ui即可测试使用:
<van-button type="primary">主要按钮</van-button> <van-button type="info">信息按钮</van-button> <van-button type="default">默认按钮</van-button> <van-button type="warning">警告按钮</van-button> <van-button type="danger">危险按钮</van-button>
-
-
全部导入:方便但是会增加项目体积
//在main.js中注册 import Vue from 'vue'; import Vant from 'vant'; import 'vant/lib/index.css'; //导入所有组件 Vue.use(Vant); //插件安装初始化<!-- 使用测试 --> <van-button type="primary">主要按钮</van-button> <van-button type="info">信息按钮</van-button> <van-button type="default">默认按钮</van-button> <van-button type="warning">警告按钮</van-button> <van-button type="danger">危险按钮</van-button>

项目中的vw(viewport)适配
基于postcss插件实现项目vw适配,可以实现px->vw的自动转换
-
postcss-px-to-viewport插件安装:
npm i postcss-px-to-viewport --force -
在根目录下新建
postcss.config.js// postcss.config.js module.exports = { plugins: { 'postcss-px-to-viewport': { viewportWidth: 375, //vw适配的标准屏宽度(iPhoneX) }, }, };![image]()
300/375=0.8
0.8*100vw=80vw
路由设计配置
只要是单个页面,独立展示的都是一级路由

推荐将每个一级路由模块新建为一个文件夹,并在每个文件夹中新建index.vue 页面
配置路由规则:
import Login from '@/views/login'
import Layout from '@/views/layout'
import Myorder from '@/views/myorder'
import Pay from '@/views/pay'
import Prodetail from '@/views/prodetail'
import Search from '@/views/search/index.vue'
import SearchList from '@/views/search/list.vue'
const routes = [
{ path: '/login', component: Login },
{ path: '/', component: Layout },
{ path: '/myorder', component: Myorder },
{ path: '/pay', component: Pay },
// 动态路由传参 确认是哪个商品
{ path: '/prodetail/:id', component: Prodetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
首页底部导航配置:
基于vant组件库,实现底部导航tabbar
-
按需导入
import { Tabbar, TabbarItem } from 'vant' import Vue from 'vue' Vue.use(Tabbar) Vue.use(TabbarItem) -
在
layout.vue中使用,并将组件中绑定的变量先删除 -
修改原本组件中的文字内容及图标
-
按需导入图标

二级路由配置:
-
在layout文件夹下新建二级路由对应的组件
-
配置导航规则
{ path: '/', component: Layout, children: [ { path: '/home', component: Home }, { path: '/category', component: Category }, { path: '/cart', component: Cart }, { path: '/user', component: User } ] } -
配置导航链接
<van-tabbar active-color="#ee0a24" inactive-color="#000" route> <van-tabbar-item replace to="/home" icon="wap-home-o">首页</van-tabbar-item> <van-tabbar-item replace to="/category" icon="apps-o" >分类页</van-tabbar-item> <van-tabbar-item replace to="/cart" icon="shopping-cart-o">购物车</van-tabbar-item> <van-tabbar-item replace to="/user" icon="user-o">我的</van-tabbar-item> </van-tabbar>
-
配置导航出口
<router-view /> -
首页的路由/添加重定向:
redirect: '/home',

登录页的静态布局
-
头部组件:
NavBar按需导入使用 -
其他
-
新建style>common.less重置默认样式,并让文本在两行显示多余省略
// 重置默认样式 *{ margin: 0; padding: 0; box-sizing: border-box; } //让文本在显示两行后溢出部分以省略号显示 .text-ellipsis-2{ overflow: hidden; // 隐藏超出元素框的内容 -webkit-line-clamp: 2; // 指定文本最多显示 2 行(WebKit 内核浏览器) text-overflow: ellipsis; // 文本溢出时显示省略号 display: -webkit-box; // 将元素作为弹性盒容器显示(WebKit 内核浏览器) -webkit-box-orient: vertical; // 指定弹性盒内子元素垂直排列(WebKit 内核浏览器) } //导航栏通用样式覆盖 .van-nav-bar{ .van-nav-bar__arrow{ color: black; } } -
将common.less导入main.js中:
import '@/style/common.less' -
编写登录页样式
<template> <div class="login"> <van-nav-bar title="会员登录" left-text=" " left-arrow @click-left="$router.go(-1)" /> <!-- 静态结构 --> <div class="container"> <div class="title"> <h3>手机号登录</h3> <p>未注册的手机号登录后将自动注册</p> </div> <div class="form"> <van-cell-group> <van-field v-model="tel" type="tel" placeholder="请输入手机号码" /> <van-field v-model="number" type="number" placeholder="请输入图形验证码" /> <div class="code"> <van-field v-model="number" type="number" placeholder="请输入短信验证码" /> <span>获取验证码</span> </div> <van-field v-model="number" type="number" /> </van-cell-group> <van-button round type="info">登录</van-button> </div> </div> </div> </template> <script> export default { name: 'LoginIndex' } </script> <style scoped> .login{ .container{ margin: 0 auto; .title{ margin-left: 20px; margin-top: 60px; margin-bottom: 30px; p{ color: gainsboro; font-size: 12px; margin-top: 10px; } h3{ font-size: 23px; font-weight: normal; } } .form{ margin-left: 10px; text-align: center; .code{ display: flex; span{ display: block; color: rgb(255, 154, 19); font-size: 12px; margin-top: 10px; width: 160px; } } } } .van-button{ margin-top: 40px; width: 80%; background-color: rgb(255, 154, 19); border: none; } } </style>![image]()
-
登录页的数据请求
通常会将axios请求方法(配置基地址,请求响应拦截器等)封装到request模块中
所有项目开发中,都会对axios进行基本的二次封装,单独封装到一个request模块中,便于维护使用
接口文档:wiki - 智慧商城-实战项目
步骤:
-
安装axios:
npm i axios -
新建request模块:在
utils>request.js中配置axios -
创建实例/配置,导出实例
-
测试使用
// 创建axios实例,将来对创建的实例进行自定义配置,好处:不会污染原始的axios实例
import axios from 'axios'
const instance = axios.create({
baseURL: 'http://smart-shop.itheima.net/index.php?s=/api',
timeout: 5000
})
// 自定义配置-请求/响应拦截器
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么(默认axios会多包装一层data)
return response.data
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
<script>
import request from '@/utils/request'
export default {
name: 'LoginIndex',
async created () {
const res = await request.get('/captcha/image')
console.log(res) // 测试封装的axios
}
}
</script>

图形验证码功能
基于获取的请求中的base64图片实现图形验证码的功能
图形验证码用于强制人机交互,可以抵御机器自动化攻击 (避免批量请求获取短信)
需求:
-
动态将请求回来的base64图片解析渲染出来
-
点击验证码图片盒子,可以实现刷新验证码

其中base64是验证码图片,key是该验证码图片的唯一标识,后台基于唯一标识key去找到验证码的结果与发送的进行匹配
<div class="pic">
<van-field v-model="picCode" type="number" placeholder="请输入图形验证码" />
<img v-if="picUrl" :src="picUrl" alt="" @click="getPicCode">
</div>
<script>
import request from '@/utils/request'
export default {
name: 'LoginIndex',
async created () {
this.getPicCode()
},
data () {
return {
picCode: '', // 用户输入的图形验证码
picKey: '', // 图形验证码的唯一标识
picUrl: '' // 图形验证码的url地址
}
},
methods: {
async getPicCode () {
const { data: { base64, key } } = await request.get('/captcha/image')
this.picUrl = base64
this.picKey = key
}
}
}
</script>

封装图片验证码接口
将请求封装成方法,统一存放到api模块(存放封装好的请求函数)
好处:
-
请求与页面逻辑分离
-
相同的请求可以直接复用
-
请求进行了统一管理
步骤:
-
新建请求模块
在api目录下新建
login.js,用于存放所有登录相关的接口请求 -
封装请求函数
// 获取图形验证码 export const getPicCode = () => { return request.get('/captcha/image') } -
页面中导入调用
async created () { this.getPicCode() }, data () { return { picCode: '', // 用户输入的图形验证码 picKey: '', // 图形验证码的唯一标识 picUrl: '' // 图形验证码的url地址 } }, methods: { async getPicCode () { const { data: { base64, key } } = await getPicCode() this.picUrl = base64 this.picKey = key } } }
Toast轻提示
基于vant文档,完成Toast请提示
步骤:
-
注册安装:
import { Toast } from 'vant'; Vue.use(Toast); -
导入调用:
-
组件内/非组件内均可:
import { Toast } from 'vant' Toast('提示内容') -
通过this直接调用(必须在组件内):本质:将方法注册挂载到了Vue原型上
Vue.prototype.$toast=xxxthis.$toast('提示内容')
-
短信倒计时模块(节流)
默认是:246810
步骤:
-
点击按钮,实现倒计时效果(往后台发送请求才开始倒计时),在离开页面后要清除定时器
data () { return { picCode: '', // 用户输入的图形验证码 picKey: '', // 图形验证码的唯一标识 picUrl: '', // 图形验证码的url地址 totalSecond: 60, // 倒计时总秒数 second: 60, // 当前秒数 timer: null // 定时器id } }, methods: { async getPicCode () { const { data: { base64, key } } = await getPicCode() this.picUrl = base64 this.picKey = key }, // 获取短信验证码 getCode () { // 当目前不存在倒计时,且当前秒数为总秒数时(归位),开启倒计时 if (!this.timer && this.second === this.totalSecond) { // 开启定时器 this.timer = setInterval(() => { this.second-- }, 1000) if (this.second === 0) { clearInterval(this.timer) this.timer = null this.second = this.totalSecond } } }, // 离开页面清除定时器 destroyed () { clearInterval(this.timer) }
-
倒计时之前的校验处理
-
手机号
-
验证码
// 校验手机号和验证码是否合法 checkPhoneAndCode () { if (!/^1[3-9]\d{9}$/.test(this.tel)) { this.$toast('请输入正确的手机号') return false } if (!/^\w{4}$/.test(this.picCode)) { this.$toast('请输入正确的图形验证码') return false } return true }
-
-
封装短信验证请求接口,发送请求添加提示
// 获取短信验证码 export const getSmsCode = (captchaCode, captchaKey, mobile) => { return request.post('/captcha/sendSmsCaptcha', { form: { captchaCode, captchaKey, mobile } }) }// 获取短信验证码 async getCode () { if (this.checkPhoneAndCode()) { // 发送请求 await getSmsCode(this.picCode, this.picKey, this.tel) this.$toast('短信验证码发送成功') // 当目前不存在倒计时,且当前秒数为总秒数时(归位),开启倒计时 if (!this.timer && this.second === this.totalSecond) { // 开启定时器 this.timer = setInterval(() => { this.second-- }, 1000) if (this.second === 0) { clearInterval(this.timer) this.timer = null this.second = this.totalSecond } } } }
登录功能
封装api登录接口,实现登录功能
步骤:
-
阅读接口文档,封装登录接口
// 登录接口 export const login = (mobile, smsCode) => { return request.post('/passport/login', { form: { isParty: false, mobile, partyData: {}, smsCode } }) }
-
登录前校验:
-
手机号
-
图形验证码
-
短信验证码
-
-
调用方法,发送请求,成功添加提示并跳转
// 登录
async login () {
// 重新校验手机号和验证码是否合法,防止在发送短信后修改
if (!this.checkPhoneAndCode()) return
// 验证短信验证码
if (!/^\d{6}$/.test(this.smsNumber)) {
this.$toast('请输入正确的短信验证码')
return false
}
await loginCode(this.tel, this.picCode)
this.$toast('登录成功')
this.$router.push('/')
}
只需关注成功的情况,失败有响应拦截器
相应拦截器统一处理错误提示
上述代码只关注成功的情况,但是每次请求都有可能有错误,需要错误提示,可在相应拦截器进行统一的错误处理
相应拦截器是第一个数据流转站
当发送请求响应的status非200,抛出一个promise错误,await只会等待成功的promise
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么(默认axios会多包装一层data)
const res = response.data
if (res.status !== 200) {
// 给提示
Toast(res.message)
// 抛出错误的promise
return Promise.reject(res.message)
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
登录权证信息存储
基于vuex构建user 模块存储登录权证:
-
token
-
userId
步骤:
-
构建user模块
export default { namespaced: true, state () { return { userInfo: { token: '', userId: '' } } }, mutations: {}, actions: {}, getters: {} }
-
挂载到vuex
import user from './modules/user' modules: { user }
-
提供mutations
mutations: { setUserInfo (state, userInfo) { state.userInfo = userInfo } },
-
页面中commit调用
this.$store.commit('user/setUserInfo', res.data)
vuex的持久化处理
利用本地存储实现vuex的持久化处理
在utils>storage.js中封装相关方法
// 约定一个通用的键名
const INFO_KEY = 'SHOP_INFO'
// 获取个人信息
export const getInfo = () => {
const defaultInfo = {
token: '',
userId: ''
}
const result = localStorage.getItem(INFO_KEY)
return result ? JSON.parse(result) : defaultInfo
}
// 修改个人信息
export const setInfo = (obj) => {
localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}
// 删除个人信息
export const removeInfo = () => {
localStorage.removeItem(INFO_KEY)
}
在storage>utils>user.js中使用方法存入localStorage中
import { setInfo, getInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
userInfo: getInfo()
}
},
mutations: {
setUserInfo (state, userInfo) {
state.userInfo = userInfo
setInfo(userInfo)
}
},
actions: {},
getters: {}
}

添加请求loading效果
统一在每次请求后台时,添加loading效果
有时因为网络原因一次请求的结果可能需要一段时间后才能回来,此时需要给用户添加loading提示
好处:
-
节流处理:防止用户在一次请求还没回来之前多次进行点击,发送无效的请求
-
友好提示:告知用户,目前在加载中,请耐心等待,增加用户体验感
步骤:
-
在请求拦截器中:每次请求打开loading,禁止背景点击
(基于vant-ui)
instance.interceptors.request.use(function (config) { // 在发送请求之前做些什么 Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 // 表示一直加载请求回来之后才能关闭 }) return config }, function (error) { // 对请求错误做些什么 return Promise.reject(error) })
-
在响应拦截器中:每次响应关闭loading
instance.interceptors.response.use(function (response) { // 2xx 范围内的状态码都会触发该函数。 // 对响应数据做点什么(默认axios会多包装一层data) const res = response.data // if (res.status !== 200) { // // 给提示 // Toast(res.message) // // 抛出错误的promise // return Promise.reject(res.message) // } Toast.clear() return res }, function (error) { // 超出 2xx 范围的状态码都会触发该函数。 // 对响应错误做点什么 return Promise.reject(error) })
Toast默认是单例模式,后面的Toast调用了,会将前一个Toast效果覆盖(同时只能存在一个toast)

页面访问拦截
基于全局前置导航守卫,进行页面访问拦截处理
对于大部分的页面游客可以直接访问,如果遇到需要登录(支付页、订单页等)才能进行的操作,提示并跳转到登录页,需要做拦截处理
-
所有的路由一旦被匹配到,都会被经过全局前置守卫
-
只有全局前置守卫通过,才会真正解析渲染组件,才能看见页面内容
官网参考文档:导航守卫 | Vue Router
当一个导航触发时,全局前置守卫按照创建顺序调用
语法:
router.beforeEach((to,from,next) =>{
// 1. to 往哪去,到哪去的路由信息对象
// 2. from 往哪来,从哪来的路由信息对象
// 3. next()是否放行
// 如果next()调用就是放行;如果next(路径)拦截到某个路径页面
})
访问权限页面时,拦截或放行的关键点:用户是否具有token

// 全局前置路由守卫
router.beforeEach((to, from, next) => {
if (!authPages.includes(to.path)) {
next() // 非权限页面,直接放行
return
}
// 权限页面,需要判断token
const token = store.getters['user/token']
if (token) {
next() // 有token,放行
// return
} else {
next('/login')
}
})
首页
步骤:
-
完成首页的静态布局
<!--home.vue--> <template> <div class="home"> <van-nav-bar class="custom" title="智慧商城" /> <van-search v-model="value" shape="round" placeholder="请输入搜索关键词" /> <van-swipe class="my-swipe" :autoplay="3000" indicator-color="white"> <van-swipe-item><img src="@/assets/1.jpg" alt=""></van-swipe-item> <van-swipe-item><img src="@/assets/2.png" alt=""></van-swipe-item> <van-swipe-item><img src="@/assets/3.png" alt=""></van-swipe-item> </van-swipe> <van-grid :column-num="5"> <van-grid-item v-for="value in 10" :key="value" icon="photo-o" text="新品首发" /> </van-grid> <div class="pic"> <a href=""><img src="@/assets/banner.png" alt=""></a> </div> <p>——猜你喜欢——</p> <div class="list"> <GoodsItem v-for="item in 10" :key="item"></GoodsItem> </div> </div> </template> <script> import 'vant/lib/index.less' import GoodsItem from '@/components/GoodsItem.vue' export default { name: 'HomePage', components: { GoodsItem } } </script> <style scoped lang="less"> *{ margin: 0; padding: 0; } .van-nav-bar{ background-color: rgb(207, 35, 0); /deep/ .van-nav-bar__title { color: white; font-weight: 700; } } .my-swipe img{ width: 100vw; height: 30vh; } .pic { margin-bottom: 20px; img{ width: 100vw; } } p{ text-align: center; } .list{ margin-top: 20px; } </style><!--GoodsItem.vue--> <template> <div class="goods-item" @click="$router.push('/prodetail')"> <div class="left"> <img src="@/assets/4.jpg" alt=""> </div> <div class="right"> <p>小熊玩偶</p> <p class="count">已售104件</p> <span class="new">¥3999.00</span> <span class="old">¥6699.00</span> </div> </div> </template> <style scoped> .goods-item{ display: flex; height: 148px; width: 100vw; .left{ margin-right: 10px; margin-left: 10px; img{ width: 120px; height: 120px; } } .right{ .count{ color: #999; font-size: 12px; margin-bottom: 10px; margin-top: 10px; } .new{ color: red; font-size: 16px; margin-right: 10px; } .old{ color: #999; font-size: 16px; text-decoration: line-through; } } } </style>![image]()
-
封装接口:
import request from '@/utils/request' // 获取首页数据 export const getHomeData = () => { return request.get('/page/detail', { params: { pageId: 0 } }) } -
页面调用:
<!--GoodsItem.vue--> <template> <div v-if="item.goods_id" class="goods-item" @click="$router.push(`/prodetail/${item.goods_id}`)"> <div class="left"> <img :src="item.goods_image" alt=""> </div> <div class="right"> <p class="text-ellipsis-2">{{ item.goods_name }}</p> <p class="count">已售{{item.goods_sales}}件</p> <span class="new">¥{{item.goods_price_min}}</span> <span class="old">¥{{item.goods_price_max}}</span> </div> </div> </template> <style scoped> .goods-item { display: flex; height: 148px; width: 100vw; .left { margin-right: 10px; margin-left: 10px; img { width: 120px; height: 120px; } } .right { .count { color: #999; font-size: 12px; margin-bottom: 10px; margin-top: 10px; } .new { color: red; font-size: 16px; margin-right: 10px; } .old { color: #999; font-size: 16px; text-decoration: line-through; } } } </style> <script> export default { name: 'GoodsItem', props: { item: { type: Object, default: () => { return {} } } } } </script><!--home.vue--> <template> <div class="home"> <van-nav-bar class="custom" title="智慧商城" /> <van-search v-model="value" shape="round" placeholder="请输入搜索关键词" /> <van-swipe class="my-swipe" :autoplay="3000" indicator-color="white"> <van-swipe-item v-for="item in bannerList" :key="item.imgUrl"> <img :src="item.imgUrl" alt=""> </van-swipe-item> </van-swipe> <van-grid :column-num="5"> <van-grid-item v-for="value in navList" :key="value.imgUrl" :icon="value.imgUrl" text="新品首发" /> </van-grid> <div class="pic"> <a href=""><img src="@/assets/banner.png" alt=""></a> </div> <p>——猜你喜欢——</p> <div class="list"> <GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem> </div> </div> </template> <script> import 'vant/lib/index.less' import GoodsItem from '@/components/GoodsItem.vue' import { getHomeData } from '@/api/home' export default { name: 'HomePage', components: { GoodsItem }, data () { return { bannerList: [], // 轮播图数据 navList: [], // 导航数据 proList: [] // 商品数据 } }, async created () { const { data: { pageData } } = await getHomeData() this.bannerList = pageData.items[1].data this.navList = pageData.items[3].data this.proList = pageData.items[6].data } } </script> <style scoped lang="less"> *{ margin: 0; padding: 0; } .van-nav-bar{ background-color: rgb(207, 35, 0); /deep/ .van-nav-bar__title { color: white; font-weight: 700; } } .my-swipe img{ width: 100vw; height: 30vh; } .pic { margin-bottom: 20px; img{ width: 100vw; } } p{ text-align: center; } .list{ margin-top: 20px; } </style>
搜索页
构建搜索页的静态布局,完成历史记录的管理
功能:
-
搜索历史基本渲染
-
点击搜索(添加历史)
点击搜索按钮或底下历史记录,都能进行搜索
-
若之前没有相同搜索关键词:直接追加到最前面
-
若之前有相同搜索关键词:将原有的关键字移除,再追加到最前面
-
-
清空历史:添加清空图标,可以清空历史记录
-
持久化:刷新历史不丢失
(通常多端的搜索历史并不同步,不需要存入后台)
const HISTORTY_KEY = 'HISTORYLIST'
// 获取历史记录
export const getHistory = () => {
const result = localStorage.getItem(HISTORTY_KEY)
return result ? JSON.parse(result) : []
}
// 修改历史记录
export const setHistory = (list) => {
localStorage.setItem(HISTORTY_KEY, JSON.stringify(list))
}
<template>
<div class="search">
<van-nav-bar
title="商品搜索"
left-arrow
@click-left="$router.back()"
/>
<div class="searchInput">
<van-search
v-model="value"
show-action
placeholder="请输入搜索关键词"
>
<template #action>
<van-button @click="goSearch(value)" square type="primary" size="small" color="red">搜索</van-button>
</template>
</van-search>
</div>
<div class="history" v-if="history.length>0">
<div class="delete">
<p>最近搜索</p>
<van-icon name="delete-o" @click="clear"/>
</div>
<!-- <van-row>
<van-col span="8"> -->
<div class="tag">
<van-tag round type="primary" plain size="large"
color="grey" text-color="black" @click="goSearch(item)"
v-for="item in history" :key="item">{{item}}</van-tag>
</div>
<!-- </van-col>
</van-row> -->
</div>
</div>
</template>
<script>
import { getHistory, setHistory } from '@/utils/storage'
export default {
name: 'SearchIndex',
data () {
return {
value: '', // 搜索框的值
history: getHistory()
}
},
methods: {
goSearch (value) {
const index = this.history.indexOf(value)
// 如果存在相同的项
if (index !== -1) {
this.history.splice(index, 1)
}
this.history.unshift(value)
setHistory(this.history)
this.value = ''
this.$router.push(`/searchlist?search=${value}`)
},
clear () {
this.history = []
setHistory([])
}
}
}
</script>
<style scoped>
.searchInput {
button{
width: 70px;
display: block;
margin-top: 1px;
margin: 0;
}
}
.history {
.delete{
width: 95vw;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
padding: 10px;
p {
font-size: 14px;
color: #999;
margin-bottom: 10px;
}
.tag{
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
span{
display: block;
width: 80px;
height: 30px;
text-align: center;
margin-right: 30px;
margin-bottom: 10px;
}
}
}
</style>

搜索列表页
步骤:
-
完成静态结构
-
封装接口
-
完成搜索列表页的渲染:通过
this.$route.query.search获取地址栏的参数(如果传递的是undefined或null,则axios会自动把该值屏蔽)
import request from '@/utils/request.js'
export const getProList = (obj) => {
const { categoryId, goodsName, page } = obj
return request.get('/goods/list', {
params: {
categoryId,
goodsName,
page
}
})
}
<!--search > list.vue-->
<template>
<div class="searchList">
<van-nav-bar
title="商品列表"
left-arrow
@click-left="$router.back()"
/>
<div class="searchInput">
<van-search
:value="querySearch || '搜索商品'"
@click="$router.push('/search')"
shape="round"
>
<template #action>
<van-icon name="apps-o" size="25"/>
</template>
</van-search>
</div>
<div class="txt">
<span>综合</span>
<span>销量</span>
<span>价格</span>
</div>
<GoodsItem v-for="item in list" :key="item.goods_id" :item="item"/>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getProList } from '@/api/product'
export default {
name: 'SearchList',
components: {
GoodsItem
},
data () {
return {
list: [],
page: 1
}
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
},
async created () {
const { data: { list } } = await getProList({
goodsName: this.querySearch,
page: this.page,
categoryId: this.$router.currentRoute.query.categoryId
})
this.list = list.data
}
}
</script>
<style scoped>
.searchInput{
i{
margin-top: 10px;
}
}
.txt{
display: flex;
justify-content: space-around;
margin-top: 10px;
margin-bottom: 10px;
}
</style>

分类页
-
展示不同类别下的商品
-
点击商品图片可以实现跳转到对应类别的搜索结果
import request from '@/utils/request'
export const getCategory = () => {
return request.get('/category/list')
}
<!--category.vue-->
<template>
<div class="category">
<van-nav-bar
title="全部分类"
/>
<van-search
v-model="value"
shape="round"
background="rgb(241,241,241)"
placeholder="请输入搜索关键词"
@click="$router.push('/search')"
/>
<div class="contain">
<van-sidebar v-model="activeKey">
<van-sidebar-item v-for="(item,index) in list" :key="index" :title="item" @click="getIndex(index)" />
</van-sidebar>
<van-grid :border="false" :column-num="3" >
<van-grid-item v-for="item in proList" :key="item.category_id"
:icon="item.image.external_url" :text="item.name" @click="$router.push(`/searchlist?categoryId=${item.category_id}`)"/>
</van-grid>
</div>
</div>
</template>
<script>
import { getCategory } from '@/api/category'
export default {
name: 'CategoryPage',
data () {
return {
list: [],
activeKey: 0,
proList: []
}
},
methods: {
getIndex (index) {
this.activeKey = index
this.loadData()
},
async loadData () {
const { data: { list } } = await getCategory()
this.list = list.map(item => item.name)
const res = list.slice(0, 7).map(item => item.children)
this.proList = res[this.activeKey]
}
},
async created () {
this.loadData()
}
}
</script>
<style scoped lang="less">
.contain{
display: flex;
justify-content: space-between;
align-items: flex-start;
.van-sidebar-item{
width: 100px;
}
/deep/ .van-grid{
width: 230px;
height: auto;
display: flex;
flex: 1;
/deep/ .van-grid-item{
height: 20px !important;
}
}
}
</style>


商品详情页
可以看见不同商品的详情及购买用户的评论
步骤:
-
静态结构布局
-
封装接口
// 获取商品详情信息 export const getProDetail = (goodsId) => { return request.get('/goods/detail', { params: { goodsId } }) } // 获取商品评价 export const getProComment = (goodsId, limit) => { return request.get('/comment/listRows', { params: { goodsId, limit } }) }
-
动态路由获取参数
computed: { goodsId () { // 动态路由获取参数 return this.$route.params.id } },
-
获取数据动态渲染
<template>
<div class="prodetail">
<van-nav-bar
title="商品详情页"
left-arrow
@click-left="$router.back()"
/>
<van-swipe :autoplay="3000" @change="onSwipeChange">
<van-swipe-item v-for="(image, index) in images" :key="index" >
<img :src="image.external_url" />
</van-swipe-item>
</van-swipe>
<van-tag round type="primary" color="rgb(230,230,230)">{{ index }}/{{ images.length }}</van-tag>
<div class="info">
<div class="price">
<span class="new">¥{{ info.line_price_min }}</span>
<span class="old">¥{{ info.line_price_max }}</span>
</div>
<p class="count">已售 {{ info.goods_sales }} 件</p>
</div>
<p class=".text-ellipsis-2">{{ info.goods_name }}</p>
<van-goods-action>
<van-goods-action-icon icon="wap-home-o" text="首页" @click="$router.push('/')"/>
<van-goods-action-icon icon="shopping-cart-o" text="购物车" @click="$router.push('/cart')" />
<van-goods-action-button color="rgb(241,153,61)" type="warning" text="加入购物车" />
<van-goods-action-button color="rgb(229,84,60)" type="danger" text="立即购买" />
</van-goods-action>
<div class="power">
<van-icon name="success" color="red"/><span style="margin-right: 10px;">七天无理由退货</span>
<van-icon name="success" color="red"/><span>48小时发货</span>
</div>
<div class="comment">
<p style="margin-left: 10px;">商品评价({{ total }}条)</p>
<p class="more">查看更多<van-icon name="arrow" /></p>
</div>
<div class="list" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<span class="name">{{item.user.nick_name}}</span>
<van-rate
:value="item.score / 2"
:size="16"
color="#ffd21e"
void-icon="star"
void-color="#eee"
/>
</div>
<p class="content">{{ item.content }}</p>
<p class="time">{{ item.create_time }}</p>
</div>
<div class="desc" v-html="info.content"></div>
</div>
</template>
<script>
import { getProDetail, getProComment } from '@/api/product'
import defaultImg from '@/assets/user.jpg'
export default {
name: 'ProdetailIndex',
computed: {
goodsId () {
// 动态路由获取参数
return this.$route.params.id
}
},
data () {
return {
defaultImg: defaultImg,
info: {},
images: [],
index: 1,
total: 0, // 总评论数
commentList: [] // 评论列表
}
},
async created () {
const { data: res } = await getProDetail(this.goodsId)
this.info = res.detail
this.images = res.detail.goods_images
// 获取评价,只显示前3条评论
const { data: { list, total } } = await getProComment(this.goodsId, 3)
this.commentList = list
this.total = total
},
methods: {
onSwipeChange (index) {
this.index = index + 1
}
}
}
</script>
<style scoped>
*{
margin: 0;
padding: 0;
}
.more{
color: #999;
font-size: 12px;
margin-bottom: 10px;
}
.info{
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.new {
color: red;
font-size: 16px;
margin-right: 10px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
.van-goods-action{
position: fixed;
bottom: 0;
width: 100vw;
background-color: #fff;
}
.van-swipe{
img{
width: 100%;
height: 400px;
}
}
.van-tag{
position: absolute;
top: 430px;
right: 0;
}
.power{
line-height: 50px;
background-color: rgb(251, 251, 251);
vertical-align: middle;
height: 50px;
}
.comment{
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 10px;
}
.desc{
width: 100vw;
}
.top img{
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: 10px;
}
.name{
font-size: 14px;
margin-right: 10px;
}
.content{
font-size: 14px;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
.time{
font-size: 12px;
color: #999;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
</style>


加入购物车功能
点击加入购物车,唤起弹层效果
基于vant-ui的ActionSheet动作面板使用弹层
封装了数字框组件,实现商品数量的改变
<!--Components > CountBox.vue-->
<template>
<div class="count-box">
<button class="minus" @click="handleSub">-</button>
<input class="inp" @change="handleChange" type="text" :value="1">
<button class="add" @click="handleAdd">+</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) {
const num = +e.target.value // 转化为数字
// 如果输入的值不合理则回退为原来的值
if (isNaN(num) || num < 1) {
e.target.value = this.value
return
}
this.$emit('input', num)
}
}
}
</script>
<style scoped>
.count-box{
display: flex;
width: 110px;
.minus,.add{
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>
<!--prodetail > index.vue-->
<template>
<div class="prodetail">
<van-nav-bar
title="商品详情页"
left-arrow
@click-left="$router.back()"
/>
<van-swipe :autoplay="3000" @change="onSwipeChange">
<van-swipe-item v-for="(image, index) in images" :key="index" >
<img :src="image.external_url" />
</van-swipe-item>
</van-swipe>
<van-tag round type="primary" color="rgb(230,230,230)">{{ index }}/{{ images.length }}</van-tag>
<div class="info">
<div class="price">
<span class="new">¥{{ info.line_price_min }}</span>
<span class="old">¥{{ info.line_price_max }}</span>
</div>
<p class="count">已售 {{ info.goods_sales }} 件</p>
</div>
<p class=".text-ellipsis-2">{{ info.goods_name }}</p>
<van-goods-action>
<van-goods-action-icon icon="wap-home-o" text="首页" @click="$router.push('/')"/>
<van-goods-action-icon icon="shopping-cart-o" text="购物车" @click="$router.push('/cart')" />
<van-goods-action-button color="rgb(241,153,61)" @click="addFn" type="warning" text="加入购物车"/>
<van-goods-action-button color="rgb(229,84,60)" @click="buyFn" type="danger" text="立即购买" />
</van-goods-action>
<div class="power">
<van-icon name="success" color="red"/><span style="margin-right: 10px;">七天无理由退货</span>
<van-icon name="success" color="red"/><span>48小时发货</span>
</div>
<div class="comment">
<p style="margin-left: 10px;">商品评价({{ total }}条)</p>
<p class="more">查看更多<van-icon name="arrow" /></p>
</div>
<div class="list" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<span class="name">{{item.user.nick_name}}</span>
<van-rate
:value="item.score / 2"
:size="16"
color="#ffd21e"
void-icon="star"
void-color="#eee"
/>
</div>
<p class="content">{{ item.content }}</p>
<p class="time">{{ item.create_time }}</p>
</div>
<div class="desc" v-html="info.content"></div>
<!-- 弹层 -->
<van-action-sheet v-model="show"
style="width: 100vw;"
:title="mode === 'cart' ? '加入购物车' : '立即购买'">
<div class="contentPannel">
<div class="main">
<div class="left">
<img :src="info.goods_image" alt="">
</div>
<div class="right">
<p class="price" style="color: rgb(240,149,60);font-size: 22px;">¥{{ info.line_price_min }}</p>
<p class="nums">库存{{ info.stock_total }}</p>
</div>
</div>
<div class="text">
<p>数量</p>
<p><CountBox v-model="addCount"></CountBox></p>
</div>
<div class="button" v-if="info.stock_total>0">
<van-goods-action-button
type="danger"
text="加入购物车"
color="rgb(251,151,1)"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;"
/>
</div>
<div class="button" v-else>
<van-goods-action-button
text="该商品已抢完"
color="rgb(204,204,204)"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;"
/>
</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { getProDetail, getProComment } from '@/api/product'
import defaultImg from '@/assets/user.jpg'
import CountBox from '@/components/CountBox.vue'
export default {
name: 'ProdetailIndex',
components: {
CountBox
},
computed: {
goodsId () {
// 动态路由获取参数
return this.$route.params.id
}
},
data () {
return {
addCount: 1, // 数字框绑定的数据
show: false,
mode: 'cart',
defaultImg: defaultImg,
info: {},
images: [],
index: 1,
total: 0, // 总评论数
commentList: [] // 评论列表
}
},
async created () {
const { data: res } = await getProDetail(this.goodsId)
this.info = res.detail
console.log(this.info)
this.images = res.detail.goods_images
// 获取评价,只显示前3条评论
const { data: { list, total } } = await getProComment(this.goodsId, 3)
this.commentList = list
this.total = total
},
methods: {
onSwipeChange (index) {
this.index = index + 1
},
addFn () {
this.show = true
},
buyFn () {
this.mode = 'buy'
this.show = true
}
}
}
</script>
<style scoped>
*{
margin: 0;
padding: 0;
}
.text{
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.main{
display: flex;
}
.left img{
width: 100px;
height: 100px;
margin-right: 10px;
}
.button{
width: 100vw;
text-align:left;
padding-left: -10px;
}
.contentPannel {
margin: auto;
padding: 16px 16px 160px;
width: 100vw;
}
.more{
color: #999;
font-size: 12px;
margin-bottom: 10px;
}
.info{
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.new {
color: red;
font-size: 16px;
margin-right: 10px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
.van-goods-action{
position: fixed;
bottom: 0;
width: 100vw;
background-color: #fff;
}
.van-swipe{
img{
width: 100%;
height: 400px;
}
}
.van-tag{
position: absolute;
top: 430px;
right: 0;
}
.power{
line-height: 50px;
background-color: rgb(251, 251, 251);
vertical-align: middle;
height: 50px;
}
.comment{
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 10px;
}
.desc{
width: 100vw;
}
.top img{
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: 10px;
}
.name{
font-size: 14px;
margin-right: 10px;
}
.content{
font-size: 14px;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
.time{
font-size: 12px;
color: #999;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
</style>

判断token
加入购物车必须是登录的用户才可以进行的操作,需要进行鉴权判断用户的token是否存在
addCart () {
// 判断用户是否登录,是否具有token
console.log(this.$store.getters['user/token'])
if (!this.$store.getters['user/token']) {
// 没有登录,弹出对话框
this.$dialog.confirm({
message: '请先登录',
title: '温馨提示',
confirmButtonText: '去登录',
cancelButtonText: '取消'
}).then(() => {
// on confirm
this.$router.push('/login')
}).catch(() => {
// on cancel
})
}
},

登录页回跳:
//login > index.vue
const url = this.$route.query.backUrl || '/'
this.$router.replace(url)
addCart () {
// 判断用户是否登录,是否具有token
console.log(this.$store.getters['user/token'])
if (!this.$store.getters['user/token']) {
// 没有登录,弹出对话框
this.$dialog.confirm({
message: '请先登录',
title: '温馨提示',
confirmButtonText: '去登录',
cancelButtonText: '取消'
}).then(() => {
// 如果需要登录之后跳转回当前页面,需要携带参数
// this.$router.fullPath(带查询参数)
console.log('fullpath', this.$router.fullPath)
this.$router.replace({
path: '/login',
query: {
backUrl: this.$router.currentRoute.fullPath
}
})
}).catch(() => {
})
}
},
加入购物车
import request from '@/utils/request'
// 加入购物车
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
})
}
在请求拦截器中增加header,补充token信息
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
Toast.loading({
message: '加载中...',
forbidClick: true,
duration: 0 // 表示一直加载请求回来之后才能关闭
})
// 携带token
const token = store.getters['user/token']
if (token) {
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
<!--prodetail > index.vue-->
<template>
<div class="prodetail">
<van-nav-bar title="商品详情页" left-arrow @click-left="$router.back()" />
<van-swipe :autoplay="3000" @change="onSwipeChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />
</van-swipe-item>
</van-swipe>
<van-tag round type="primary" color="rgb(230,230,230)">{{ index }}/{{ images.length }}</van-tag>
<div class="info">
<div class="price">
<span class="new">¥{{ info.line_price_min }}</span>
<span class="old">¥{{ info.line_price_max }}</span>
</div>
<p class="count">已售 {{ info.goods_sales }} 件</p>
</div>
<p class=".text-ellipsis-2">{{ info.goods_name }}</p>
<van-goods-action>
<van-goods-action-icon icon="wap-home-o" text="首页" @click="$router.push('/')" />
<van-goods-action-icon icon="shopping-cart-o" text="购物车" :badge="cartTotal" @click="$router.push('/cart')" />
<van-goods-action-button color="rgb(241,153,61)" @click="addFn" type="warning" text="加入购物车" />
<van-goods-action-button color="rgb(229,84,60)" @click="buyFn" type="danger" text="立即购买" />
</van-goods-action>
<div class="power">
<van-icon name="success" color="red" /><span style="margin-right: 10px;">七天无理由退货</span>
<van-icon name="success" color="red" /><span>48小时发货</span>
</div>
<div class="comment">
<p style="margin-left: 10px;">商品评价({{ total }}条)</p>
<p class="more">查看更多<van-icon name="arrow" /></p>
</div>
<div class="list" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<span class="name">{{ item.user.nick_name }}</span>
<van-rate :value="item.score / 2" :size="16" color="#ffd21e" void-icon="star" void-color="#eee" />
</div>
<p class="content">{{ item.content }}</p>
<p class="time">{{ item.create_time }}</p>
</div>
<div class="desc" v-html="info.content"></div>
<!-- 弹层 -->
<van-action-sheet v-model="show" style="width: 100vw;" :title="mode === 'cart' ? '加入购物车' : '立即购买'">
<div class="contentPannel">
<div class="main">
<div class="left">
<img :src="info.goods_image" alt="">
</div>
<div class="right">
<p class="price" style="color: rgb(240,149,60);font-size: 22px;">¥{{ info.line_price_min }}</p>
<p class="nums">库存{{ info.stock_total }}</p>
</div>
</div>
<div class="text">
<p>数量</p>
<p>
<CountBox v-model="addCount"></CountBox>
</p>
</div>
<div class="button" v-if="info.stock_total > 0">
<van-goods-action-button type="danger" text="加入购物车" color="rgb(251,151,1)" @click="addCart"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;" />
</div>
<div class="button" v-else>
<van-goods-action-button text="该商品已抢完" color="rgb(204,204,204)"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;" />
</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { getProDetail, getProComment } from '@/api/product'
import defaultImg from '@/assets/user.jpg'
import CountBox from '@/components/CountBox.vue'
import { addCart } from '@/api/cart'
export default {
name: 'ProdetailIndex',
components: {
CountBox
},
computed: {
goodsId () {
// 动态路由获取参数
return this.$route.params.id
}
},
data () {
return {
cartTotal: 0,
addCount: 5, // 数字框绑定的数据
show: false,
mode: 'cart',
defaultImg: defaultImg,
info: {},
images: [],
index: 1,
total: 0, // 总评论数
commentList: [] // 评论列表
}
},
async created () {
const { data: res } = await getProDetail(this.goodsId)
this.info = res.detail
this.images = res.detail.goods_images
// 获取评价,只显示前3条评论
const { data: { list, total } } = await getProComment(this.goodsId, 3)
this.commentList = list
this.total = total
},
methods: {
onSwipeChange (index) {
this.index = index + 1
},
addFn () {
this.show = true
},
async addCart () {
// 判断用户是否登录,是否具有token
console.log(this.$store.getters['user/token'])
if (!this.$store.getters['user/token']) {
// 没有登录,弹出对话框
this.$dialog.confirm({
message: '请先登录',
title: '温馨提示',
confirmButtonText: '去登录',
cancelButtonText: '取消'
}).then(() => {
// 如果需要登录之后跳转回当前页面,需要携带参数
// this.$router.fullPath(带查询参数)
console.log('fullpath', this.$router.fullPath)
this.$router.replace({
path: '/login',
query: {
backUrl: this.$router.currentRoute.fullPath
}
})
}).catch(() => {
})
return
}
const { data } = await addCart(this.goodsId, this.addCount, this.info.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('添加购物车成功')
this.show = false
},
buyFn () {
this.mode = 'buy'
this.show = true
}
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
}
.text {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.main {
display: flex;
}
.left img {
width: 100px;
height: 100px;
margin-right: 10px;
}
.button {
width: 100vw;
text-align: left;
padding-left: -10px;
}
.contentPannel {
margin: auto;
padding: 16px 16px 160px;
width: 100vw;
}
.more {
color: #999;
font-size: 12px;
margin-bottom: 10px;
}
.info {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.new {
color: red;
font-size: 16px;
margin-right: 10px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
.van-goods-action {
position: fixed;
bottom: 0;
width: 100vw;
background-color: #fff;
}
.van-swipe {
img {
width: 100%;
height: 400px;
}
}
.van-tag {
position: absolute;
top: 430px;
right: 0;
}
.power {
line-height: 50px;
background-color: rgb(251, 251, 251);
vertical-align: middle;
height: 50px;
}
.comment {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 10px;
}
.desc {
width: 100vw;
}
.top img {
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: 10px;
}
.name {
font-size: 14px;
margin-right: 10px;
}
.content {
font-size: 14px;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
.time {
font-size: 12px;
color: #999;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
</style>
购物车
通常会将购物车数据基于vuex分模块管理
//cart.js
import { getCartList } from '@/api/cart'
export default {
namespaced: true,
state: {
cartList: []
},
mutations: {
setCartList (state, newList) {
state.cartList = newList
}
},
actions: {
// 异步获取购物车列表数据
async getCartList (constext) {
const { data } = await getCartList('/cart/list')
// 后台返回的数据不包含复选框的选中状态,需要手动维护数据,为每一项添加isChecked状态
data.list.forEach(item => {
item.isChecked = true
})
constext.commit('setCartList', data.list)
}
},
getters: {}
}
需要注意的是vuex中的数据不能使用v-model直接双向绑定,应该使用:value 进行单向绑定
<!--cart.vue-->
<template>
<div class="cart">
<van-nav-bar title="购物车" />
<div class="text">
<p>共<span>{{ totalCount }}</span>件商品</p>
<p><van-icon name="edit" />编辑</p>
</div>
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<div class="main">
<div class="left">
<van-checkbox :value="item.isChecked">
<img :src="item.goods.goods_image" alt="">
</van-checkbox>
</div>
<div class="right">
<p class="text-ellipsis-2" style="font-size: 12px;">{{item.goods.goods_name}}</p>
<div class="bottom">
<span class="new">¥{{ item.goods.goods_price_min }}</span>
<CountBox :value="item.goods_num"></CountBox>
</div>
</div>
</div>
</div>
<van-submit-bar :price="selTotalPrice" button-text="提交订单" @submit="onSubmit">
<van-checkbox v-model="checked">全选</van-checkbox>
</van-submit-bar>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import CountBox from '@/components/CountBox'
export default {
name: 'CartPage',
components: {
CountBox
},
computed: {
...mapState('cart', ['cartList']),
...mapGetters('cart', ['totalCount', 'selCartList', 'selTotalCount', 'selTotalPrice'])
},
created () {
if (this.$store.getters['user/token']) {
this.$store.dispatch('cart/getCartList')
}
}
}
</script>
<style scoped>
.text {
display: flex;
justify-content: space-between;
margin: 10px auto;
background-color: rgb(245, 245, 245);
height: 40px;
line-height: 40px;
padding: 0 10px;
span {
color: red;
}
}
.cart-item {
padding-left: 10px;
}
.left img {
width: 80px;
height: 80px;
}
.main {
display: flex;
}
.bottom {
display: flex;
margin-top: 25px;
.new {
margin-right: 50px;
margin-left: 10px;
color: rgb(234, 101, 65);
}
}
.right p {
margin-left: 10px;
}
</style>
获取选中的商品项:
getters: {
// 计算购物车商品总数量
totalCount (state) {
return state.cartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品总数量
selTotalCount (state, getters) {
return getters.selCartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品总价格
selTotalPrice (state, getters) {
return getters.selCartList.reduce((preTotal, item) => {
const price = +item.goods.goods_price_min
return preTotal + item.goods_num * price
}, 0)
}
}

全选/反选功能
// cart.js
import { getCartList } from '@/api/cart'
export default {
namespaced: true,
state: {
cartList: []
},
mutations: {
setCartList (state, newList) {
state.cartList = newList
},
toggle (state, goodsId) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.isChecked = !goods.isChecked
},
toggleAll (state, isChecked) {
state.cartList.forEach(item => {
item.isChecked = isChecked
})
}
},
actions: {
// 异步获取购物车列表数据
async getCartList (constext) {
const { data } = await getCartList('/cart/list')
// 后台返回的数据不包含复选框的选中状态,需要手动维护数据,为每一项添加isChecked状态
data.list.forEach(item => {
item.isChecked = true
})
constext.commit('setCartList', data.list)
}
},
getters: {
// 计算购物车商品总数量
totalCount (state) {
return state.cartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品总数量
selTotalCount (state, getters) {
return getters.selCartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品总价格
selTotalPrice (state, getters) {
return getters.selCartList.reduce((preTotal, item) => {
const price = +item.goods.goods_price_min
return preTotal + item.goods_num * price
}, 0)
},
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
toggleAll () {
this.$store.commit('cart/toggleAll', !this.isAllChecked)
}
购物车数据更新
// 更新购物车商品
export const updateCart = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/update', {
goodsId,
goodsNum,
goodsSkuId
})
}
技巧:既希望保留原本的形参,有需要通过调用函数传参,可以使用箭头函数包装一层
<CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)" :value="item.goods_num"></CountBox>
// cart.js
import { getCartList, updateCart } from '@/api/cart'
export default {
namespaced: true,
state: {
cartList: []
},
mutations: {
setCartList (state, newList) {
state.cartList = newList
},
toggle (state, goodsId) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.isChecked = !goods.isChecked
},
toggleAll (state, isChecked) {
state.cartList.forEach(item => {
item.isChecked = isChecked
})
},
// 改变购物车商品数量
changeCartNum (state, obj) {
const goods = state.cartList.find(item => item.goods_id === obj.goodsId)
goods.goods_num = obj.goodsNum
}
},
actions: {
// 异步获取购物车列表数据
async getCartList (constext) {
const { data } = await getCartList('/cart/list')
// 后台返回的数据不包含复选框的选中状态,需要手动维护数据,为每一项添加isChecked状态
data.list.forEach(item => {
item.isChecked = true
})
constext.commit('setCartList', data.list)
},
// 改变购物车商品数量
async changeCartNumAction (constext, obj) {
const { goodsNum, goodsId, goodsSkuId } = obj
// console.log(res)
// 先本地修改再同步到后台
constext.commit('changeCartNum', obj)
await updateCart(goodsNum, goodsId, goodsSkuId)
}
},
getters: {
// 计算购物车商品总数量
totalCount (state) {
return state.cartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品总数量
selTotalCount (state, getters) {
return getters.selCartList.reduce((preTotal, item) => preTotal + item.goods_num, 0)
},
// 选中的商品总价格
selTotalPrice (state, getters) {
return getters.selCartList.reduce((preTotal, item) => {
const price = +item.goods.goods_price_min
return preTotal + item.goods_num * price
}, 0)
},
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 调用vuex的action进行数量的修改
this.$store.dispatch('cart/changeCartNumAction', { goodsNum, goodsId, goodsSkuId })
}
点击编辑切换结算与删除的状态
<p @click="isEdit = !isEdit"><van-icon name="edit" />编辑</p>
<van-submit-bar v-if="isEdit" :price="selTotalPrice" button-text="删除">
<van-checkbox :value="isAllChecked" @click="toggleAll">全选</van-checkbox>
</van-submit-bar>
<van-submit-bar v-else :price="selTotalPrice" button-text="提交订单">
<van-checkbox :value="isAllChecked" @click="toggleAll">全选</van-checkbox>
</van-submit-bar>
data () {
return {
isEdit: false
}
},
watch: {
// 监视是否进行编辑,如果需要编辑那么就将全选框的状态改为false
isEdit (isEdit) {
if (isEdit) {
// 如果是编辑状态,那么希望选中的删除的商品是0项
this.$store.commit('cart/toggleAll', false)
} else {
// 如果是结算状态,那么希望选中所有商品进行购买
this.$store.commit('cart/toggleAll', true)
}
}
},
删除购物车商品
// 删除购物车商品
export const deleteCart = (cartIds) => {
return request.post('/cart/clear', {
cartIds
})
}
<van-submit-bar @submit="handleDel" v-if="isEdit" :price="selTotalPrice" button-text="删除">
<van-checkbox :value="isAllChecked" @click="toggleAll">全选</van-checkbox>
</van-submit-bar>
async handleDel () {
console.log(this.selTotalCount)
if (this.selTotalCount === 0) {
return
}
// 调用vuex的action进行删除
await this.$store.dispatch('cart/delCartAction')
this.isEdit = false
}
// 删除购物车商品
async delCartAction (constext) {
const cartId = constext.getters.selCartList.map(item => item.id)
await deleteCart(cartId)
Toast('删除成功')
// 重新加载一次购物车数据
constext.dispatch('getCartList')
}
空购物车处理
<!--定义新的计算属性-->
isLogin () {
return this.$store.getters['user/token']
}
<!--cart.vue-->
<template>
<div class="cart">
<van-nav-bar title="购物车" />
<div class="box" v-if="isLogin && cartList.length > 0">
<div class="text">
<p>共<span>{{ totalCount }}</span>件商品</p>
<p @click="isEdit = !isEdit"><van-icon name="edit" />编辑</p>
</div>
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<div class="main">
<div class="left">
<van-checkbox :value="item.isChecked" @click="toggle(item.goods_id)">
<img :src="item.goods.goods_image" alt="">
</van-checkbox>
</div>
<div class="right">
<p class="text-ellipsis-2" style="font-size: 12px;">{{item.goods.goods_name}}</p>
<div class="bottom">
<span class="new">¥{{ item.goods.goods_price_min }}</span>
<!-- 既希望保留原本的形参,有需要通过调用函数传参,可以使用箭头函数包装一层 -->
<!-- <CountBox @input="changeCount(item)" :value="item.goods_num"></CountBox> -->
<CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)"
:value="item.goods_num">
</CountBox>
</div>
</div>
</div>
</div>
<van-submit-bar @submit="handleDel" v-if="isEdit" :price="selTotalPrice" button-text="删除">
<van-checkbox :value="isAllChecked" @click="toggleAll">全选</van-checkbox>
</van-submit-bar>
<van-submit-bar v-else :price="selTotalPrice" button-text="提交订单">
<van-checkbox :value="isAllChecked" @click="toggleAll">全选</van-checkbox>
</van-submit-bar>
</div>
<div v-else>
<img src="@/assets/empty.png" style="width: 200px;position: fixed;top: 30%;left: 30%;" alt="">
<van-button round type="info" color="rgb(255,44,67)" style="width: 96px;height: 37px;position: fixed;top: 61%;left: 41%;" @click="$router.push('/')">去逛逛</van-button>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex'
import CountBox from '@/components/CountBox'
export default {
name: 'CartPage',
components: {
CountBox
},
data () {
return {
isEdit: false
}
},
watch: {
// 监视是否进行编辑,如果需要编辑那么就将全选框的状态改为false
isEdit (isEdit) {
if (isEdit) {
// 如果是编辑状态,那么希望选中的删除的商品是0项
this.$store.commit('cart/toggleAll', false)
} else {
// 如果是结算状态,那么希望选中所有商品进行购买
this.$store.commit('cart/toggleAll', true)
}
}
},
computed: {
...mapState('cart', ['cartList']),
...mapGetters('cart', ['totalCount', 'selCartList', 'selTotalCount', 'selTotalPrice', 'isAllChecked']),
isLogin () {
return this.$store.getters['user/token']
}
},
created () {
if (this.isLogin) {
this.$store.dispatch('cart/getCartList')
}
},
methods: {
toggle (goodsid) {
this.$store.commit('cart/toggle', goodsid)
},
toggleAll () {
this.$store.commit('cart/toggleAll', !this.isAllChecked)
},
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 调用vuex的action进行数量的修改
this.$store.dispatch('cart/changeCartNumAction', { goodsNum, goodsId, goodsSkuId })
},
async handleDel () {
console.log(this.selTotalCount)
if (this.selTotalCount === 0) {
return
}
// 调用vuex的action进行删除
await this.$store.dispatch('cart/delCartAction')
this.isEdit = false
}
}
}
</script>
<style scoped>
.van-submit-bar{
position: fixed;
bottom: 60px;
}
.text {
display: flex;
justify-content: space-between;
margin: 10px auto;
background-color: rgb(245, 245, 245);
height: 40px;
line-height: 40px;
padding: 0 10px;
span {
color: red;
}
}
.cart-item {
padding-left: 10px;
}
.left img {
width: 80px;
height: 80px;
}
.main {
display: flex;
}
.bottom {
display: flex;
margin-top: 25px;
.new {
margin-right: 50px;
margin-left: 10px;
color: rgb(234, 101, 65);
}
}
.right p {
margin-left: 10px;
}
</style>

结算
跳转到订单结算页面,且跳转的同时需要提供订单相关的参数
-
购物车跳转结算
-
商品详情页跳转结算
import request from '@/utils/request'
// 获取收获地址
export const getAddress = (id) => {
return request.get('/address/list')
}
import request from '@/utils/request'
// 订单结算确认
// mdoe: cart => obj { cartIds }
// mode: buyNow => obj { goodsId, goodsNum, goodsSkuId }
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // 结算模式 cart/buyNow
delivery: 10, // 配送方式 10: 快递 20: 自提
couponId: 0, // 优惠券id
isUsePoints: 0, // 是否使用积分
...obj // 将传递过来的参数对象 动态展开
}
})
}
<!--pay > index.vue-->
<template>
<div class="pay">
<van-nav-bar title="订单结算台" left-arrow @click-left="$router.back()" />
<div class="top">
<van-icon name="logistics" style="margin:0 20px" color="gray" />
<div class="address" v-if="selectedAddress.address_id">
<p>{{ selectedAddress.name}}{{ selectedAddress.phone }}</p>
<p>{{ longAddress }}</p>
</div>
<div v-else>
请选择配送地址
</div>
<van-icon name="arrow" color="gray" style="position: fixed; right: 15px;" />
</div>
<van-divider dashed :style="{ color: '#1989fa', padding: '0 16px' }"></van-divider>
<div class="goods-item" v-for="item in order.goodsList" :key="item.goods_id">
<div class="left">
<img :src="item.goods_image" alt="">
</div>
<div class="right">
<p class="text-ellipsis-2">{{ item.goods_name }}</p>
<div class="num">
<p class="count">× {{ item.total_num }} 件</p>
<p class="new">¥{{ item.total_pay_price }}</p>
</div>
</div>
</div>
<p style="position: fixed;right: 10px;">共{{ order.orderTotalNum }}件商品,合计:<span class="new">¥{{ order.orderTotalPrice }}</span></p>
<van-divider class="first" />
<div class="one">
<p>订单金额:</p>
<p class="new" style="margin-right: 10px;">¥{{ order.orderTotalPrice }}</p>
</div>
<div class="one">
<p>优惠券:</p>
<p style="margin-right: 10px;">无优惠券可用</p>
</div>
<div class="one">
<p>配送费用:</p>
<p class="new" style="margin-right: 10px;">+¥{{ 0.00 }}</p>
</div>
<van-divider style="margin-bottom: 10px;" />
<p style="margin-bottom: 10px;margin-left: 10px;">支付方式</p>
<div class="money">
<div class="ddd">
<van-icon name="balance-o" style="margin-right: 5px;" />
<p>余额支付(可用¥<span>{{ personal.balance}} </span>元)</p>
</div>
<van-icon name="certificate" color="red" style="margin-right: 10px;" />
</div>
<van-divider class="first" />
<p style="color: gray;font-size: 12px;">选填:买家留言(50字内)</p>
<div class="bottom">
<p>实付款:<span class="new">¥{{ order.orderTotalPrice }}</span></p>
<van-button type="primary" color="rgb(255,103,37)">提交订单</van-button>
</div>
</div>
</template>
<script>
import { getAddress } from '@/api/pay'
import { checkOrder } from '@/api/order'
export default {
name: 'PayIndex',
async created () {
const { data: { list } } = await getAddress()
this.addressList = list
// 基于购物车进行结算
if (this.mode === 'cart') {
const { data: { order, personal } } = await checkOrder(this.mode, {
cartIds: this.cartsId
})
this.order = order
this.personal = personal
}
// 基于立即购买进行结算
if (this.mode === 'buyNow') {
const { data: { order, personal } } = await checkOrder(this.mode, {
goodsId: this.goodsId,
goodsNum: this.goodsNum,
goodsSkuId: this.goodsSkuId
})
this.order = order
this.personal = personal
}
},
data () {
return {
addressList: [],
order: {},
personal: {}
}
},
computed: {
selectedAddress () {
return this.addressList[0] || {}
},
longAddress () {
const region = this.selectedAddress.region
return region.province + region.city + region.region + this.selectedAddress.detail
},
mode () {
return this.$route.query.mode
},
cartsId () {
return this.$route.query.cartIds
},
goodsId () {
return this.$route.query.goodsId
},
goodsNum () {
return this.$route.query.goodsNum
},
goodsSkuId () {
return this.$route.query.goodsSkuId
}
}
}
</script>
<style scoped>
.bottom{
position: fixed;
bottom: 0;
background-color: #fff;
height: 40px;
width: 100vw;
padding-left: 10px;
line-height: 40px;
display: flex;
justify-content: space-between;
}
.money{
display: flex;
align-items: center;
justify-content: space-between;
.ddd{
display: flex;
align-items: center;
margin-left: 10px;
}
}
.one{
display: flex;
justify-content: space-between;
margin-bottom: 10px;
margin-left: 10px;
}
.first{
margin-top: 30px;
}
.new {
color: red;
font-size: 16px;
margin-right: 20px;
}
.num{
margin-top: 40px;
display: flex;
align-items: center;
.new{
margin-left: 160px;
}
}
.address p{
color: gray;
font-size: 12px;
}
.top{
display: flex;
align-items: center;
margin: 15px 0;
}
.goods-item {
display: flex;
height: 148px;
width: 100vw;
padding-top: 15px;
.left {
margin-right: 10px;
margin-left: 10px;
img {
width: 120px;
height: 120px;
}
}
.right {
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
width: 40px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
}
}
.goods-item:last-child {
border-bottom: none;
}
</style>
prodetail > index.vue
<template>
<div class="prodetail">
<van-nav-bar title="商品详情页" left-arrow @click-left="$router.back()" />
<van-swipe :autoplay="3000" @change="onSwipeChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />
</van-swipe-item>
</van-swipe>
<van-tag round type="primary" color="rgb(230,230,230)">{{ index }}/{{ images.length }}</van-tag>
<div class="info">
<div class="price">
<span class="new">¥{{ info.line_price_min }}</span>
<span class="old">¥{{ info.line_price_max }}</span>
</div>
<p class="count">已售 {{ info.goods_sales }} 件</p>
</div>
<p class=".text-ellipsis-2">{{ info.goods_name }}</p>
<van-goods-action>
<van-goods-action-icon icon="wap-home-o" text="首页" @click="$router.push('/')" />
<van-goods-action-icon icon="shopping-cart-o" text="购物车" @click="$router.push('/cart')" />
<van-tag round type="primary" color="red" v-if="cartTotal>0">{{cartTotal}}</van-tag>
<van-goods-action-button color="rgb(241,153,61)" @click="addFn" type="warning" text="加入购物车" />
<van-goods-action-button color="rgb(229,84,60)" @click="buyFn" type="danger" text="立即购买" />
</van-goods-action>
<div class="power">
<van-icon name="success" color="red" /><span style="margin-right: 10px;">七天无理由退货</span>
<van-icon name="success" color="red" /><span>48小时发货</span>
</div>
<div class="comment">
<p style="margin-left: 10px;">商品评价({{ total }}条)</p>
<p class="more">查看更多<van-icon name="arrow" /></p>
</div>
<div class="list" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<span class="name">{{ item.user.nick_name }}</span>
<van-rate :value="item.score / 2" :size="16" color="#ffd21e" void-icon="star" void-color="#eee" />
</div>
<p class="content">{{ item.content }}</p>
<p class="time">{{ item.create_time }}</p>
</div>
<div class="desc" v-html="info.content"></div>
<!-- 加入购物车/立即购买公用弹层 -->
<van-action-sheet v-model="show" style="width: 100vw;" :title="mode === 'cart' ? '加入购物车' : '立即购买'">
<div class="contentPannel">
<div class="main">
<div class="left">
<img :src="info.goods_image" alt="">
</div>
<div class="right">
<p class="price" style="color: rgb(240,149,60);font-size: 22px;">¥{{ info.line_price_min }}</p>
<p class="nums">库存{{ info.stock_total }}</p>
</div>
</div>
<div class="text">
<p>数量</p>
<p>
<CountBox v-model="addCount"></CountBox>
</p>
</div>
<div class="button" v-if="info.stock_total > 0">
<van-goods-action-button type="danger" text="加入购物车" color="rgb(251,151,1)" @click="addCart"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;" />
</div>
<div class="button" v-if="mode == 'buy'">
<van-goods-action-button type="danger" text="立即购买" color="rgb(229,84,60)" @click="goBuy"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;" />
</div>
<div class="button" v-if="info.stock_total <= 0">
<van-goods-action-button text="该商品已抢完" color="rgb(204,204,204)"
style="position: fixed; bottom: 0;left: 30px;width: 80vw;" />
</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { getProDetail, getProComment } from '@/api/product'
import defaultImg from '@/assets/user.jpg'
import CountBox from '@/components/CountBox.vue'
import { addCart } from '@/api/cart'
export default {
name: 'ProdetailIndex',
components: {
CountBox
},
computed: {
goodsId () {
// 动态路由获取参数
return this.$route.params.id
}
},
data () {
return {
cartTotal: 0,
addCount: 5, // 数字框绑定的数据
show: false,
mode: 'cart',
defaultImg: defaultImg,
info: {},
images: [],
index: 1,
total: 0, // 总评论数
commentList: [] // 评论列表
}
},
async created () {
const { data: res } = await getProDetail(this.goodsId)
this.info = res.detail
this.images = res.detail.goods_images
// 获取评价,只显示前3条评论
const { data: { list, total } } = await getProComment(this.goodsId, 3)
this.commentList = list
this.total = total
},
methods: {
onSwipeChange (index) {
this.index = index + 1
},
addFn () {
this.show = true
},
async addCart () {
// 判断用户是否登录,是否具有token
if (!this.$store.getters['user/token']) {
// 没有登录,弹出对话框
this.$dialog.confirm({
message: '请先登录',
title: '温馨提示',
confirmButtonText: '去登录',
cancelButtonText: '取消'
}).then(() => {
// 如果需要登录之后跳转回当前页面,需要携带参数
// this.$router.fullPath(带查询参数)
console.log('fullpath', this.$router.fullPath)
this.$router.replace({
path: '/login',
query: {
backUrl: this.$router.currentRoute.fullPath
}
})
}).catch(() => {
})
return
}
const { data } = await addCart(this.goodsId, this.addCount, this.info.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('添加购物车成功')
this.show = false
},
buyFn () {
this.mode = 'buy'
this.show = true
},
goBuy () {
// token判断
// 判断用户是否登录,是否具有token
if (!this.$store.getters['user/token']) {
// 没有登录,弹出对话框
this.$dialog.confirm({
message: '请先登录',
title: '温馨提示',
confirmButtonText: '去登录',
cancelButtonText: '取消'
}).then(() => {
// 如果需要登录之后跳转回当前页面,需要携带参数
// this.$router.fullPath(带查询参数)
this.$router.replace({
path: '/login',
query: {
backUrl: this.$router.currentRoute.fullPath
}
})
}).catch(() => {
})
return
}
this.$router.push({
path: '/pay',
query: {
mode: 'buyNow',
goodsId: this.goodsId,
goodsNum: this.addCount,
goodsSkuId: this.info.skuList[0].goods_sku_id
}
})
}
}
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
}
.text {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.main {
display: flex;
}
.left img {
width: 100px;
height: 100px;
margin-right: 10px;
}
.button {
width: 100vw;
text-align: left;
padding-left: -10px;
}
.contentPannel {
margin: auto;
padding: 16px 16px 160px;
width: 100vw;
}
.more {
color: #999;
font-size: 12px;
margin-bottom: 10px;
}
.info {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.new {
color: red;
font-size: 16px;
margin-right: 10px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
.van-goods-action {
position: fixed;
bottom: 0;
width: 100vw;
background-color: #fff;
}
.van-swipe {
img {
width: 100%;
height: 400px;
}
}
.van-tag {
position: absolute;
top: 430px;
right: 0;
}
.power {
line-height: 50px;
background-color: rgb(251, 251, 251);
vertical-align: middle;
height: 50px;
}
.comment {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-top: 10px;
}
.desc {
width: 100vw;
}
.top img {
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: 10px;
}
.name {
font-size: 14px;
margin-right: 10px;
}
.content {
font-size: 14px;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
.time {
font-size: 12px;
color: #999;
margin-bottom: 10px;
margin-top: 10px;
margin-left: 10px;
}
</style>

Vue2封装复用技巧:
-
组件内部复用:封装成一个方法进行多次调用
-
跨组件复用:mixins混入
export default{ // 此处编写Vue实例的配置项,通过一定语法可以直接混入到组件内部 // data methods computed 生命周期函数... // 如果此处和组件内具有同名的data或methods,那么组件中的优先级更高 // 如果编写了生命周期函数,则mixins中的生命周期函数和页面的生命周期函数会用数组管理 data(){ return{ title: '标题' } }, methods:{ sayHi(){ console.log('你好') } } }导入使用:
<script> import loginConfirm from '@/mixins/loginConfirm export default{ // 如果是多个混入,则后面混入的优先级更高,会把前面的覆盖 mixins: ['loginConfirm'] } </script>
支付功能
步骤:
-
封装通用请求方法
// 提交订单 // mdoe: cart => obj { cartIds, remark} // mode: buyNow => obj { goodsId, goodsNum, goodsSkuId, remark } export const submitOrder = (mode, obj) => { return request.post('/checkout/submit', { mode, // 结算模式 cart/buyNow delivery: 10, // 配送方式 10: 快递 20: 自提 couponId: 0, // 优惠券id isUsePoints: 0, // 是否使用积分 payType: 10, // 支付方式 10: 余额支付 ...obj // 其余参数 })
-
买家留言绑定
-
注册事件,调用方法提交订单并支付
pay > index.vue
<template>
<div class="pay">
<van-nav-bar title="订单结算台" left-arrow @click-left="$router.back()" />
<div class="top">
<van-icon name="logistics" style="margin:0 20px" color="gray" />
<div class="address" v-if="selectedAddress.address_id">
<p>{{ selectedAddress.name}}{{ selectedAddress.phone }}</p>
<p>{{ longAddress }}</p>
</div>
<div v-else>
请选择配送地址
</div>
<van-icon name="arrow" color="gray" style="position: fixed; right: 15px;" />
</div>
<van-divider dashed :style="{ color: '#1989fa', padding: '0 16px' }"></van-divider>
<div class="goods-item" v-for="item in order.goodsList" :key="item.goods_id">
<div class="left">
<img :src="item.goods_image" alt="">
</div>
<div class="right">
<p class="text-ellipsis-2">{{ item.goods_name }}</p>
<div class="num">
<p class="count">× {{ item.total_num }} 件</p>
<p class="new">¥{{ item.total_pay_price }}</p>
</div>
</div>
</div>
<p style="position: fixed;right: 10px;">共{{ order.orderTotalNum }}件商品,合计:<span class="new">¥{{ order.orderTotalPrice }}</span></p>
<van-divider class="first" />
<div class="one">
<p>订单金额:</p>
<p class="new" style="margin-right: 10px;">¥{{ order.orderTotalPrice }}</p>
</div>
<div class="one">
<p>优惠券:</p>
<p style="margin-right: 10px;">无优惠券可用</p>
</div>
<div class="one">
<p>配送费用:</p>
<p class="new" style="margin-right: 10px;">+¥{{ 0.00 }}</p>
</div>
<van-divider style="margin-bottom: 10px;" />
<p style="margin-bottom: 10px;margin-left: 10px;">支付方式</p>
<div class="money">
<div class="ddd">
<van-icon name="balance-o" style="margin-right: 5px;" />
<p>余额支付(可用¥<span>{{ personal.balance}} </span>元)</p>
</div>
<van-icon name="certificate" color="red" style="margin-right: 10px;" />
</div>
<van-divider class="first" />
<p style="color: gray;font-size: 12px;">选填:买家留言(50字内)</p>
<textarea v-model="remark"></textarea>
<div class="bottom">
<p>实付款:<span class="new">¥{{ order.orderTotalPrice }}</span></p>
<van-button type="primary" color="rgb(255,103,37)" @click="submitOrder">提交订单</van-button>
</div>
</div>
</template>
<script>
import { getAddress } from '@/api/pay'
import { checkOrder, submitOrder } from '@/api/order'
export default {
name: 'PayIndex',
async created () {
const { data: { list } } = await getAddress()
this.addressList = list
// 基于购物车进行结算
if (this.mode === 'cart') {
const { data: { order, personal } } = await checkOrder(this.mode, {
cartIds: this.cartsId
})
this.order = order
this.personal = personal
}
// 基于立即购买进行结算
if (this.mode === 'buyNow') {
const { data: { order, personal } } = await checkOrder(this.mode, {
goodsId: this.goodsId,
goodsNum: this.goodsNum,
goodsSkuId: this.goodsSkuId
})
this.order = order
this.personal = personal
}
},
data () {
return {
addressList: [],
order: {},
personal: {},
remark: '' // 买家留言
}
},
computed: {
selectedAddress () {
return this.addressList[0] || {}
},
longAddress () {
const region = this.selectedAddress.region
return region.province + region.city + region.region + this.selectedAddress.detail
},
mode () {
return this.$route.query.mode
},
cartsId () {
return this.$route.query.cartIds
},
goodsId () {
return this.$route.query.goodsId
},
goodsNum () {
return this.$route.query.goodsNum
},
goodsSkuId () {
return this.$route.query.goodsSkuId
}
},
methods: {
async submitOrder () {
if (this.mode === 'cart') {
const res = await submitOrder(this.mode, {
cartIds: this.cartsId,
remark: this.remark
})
console.log(res)
}
if (this.mode === 'buyNow') {
const res = await submitOrder(this.mode, {
goodsId: this.goodsId,
goodsNum: this.goodsNum,
goodsSkuId: this.goodsSkuId,
remark: this.remark
})
console.log(res)
}
this.$toast.success('支付成功')
setTimeout(() => {
this.$router.push('myorder')
}, 1000)
}
}
}
</script>
<style scoped>
.bottom{
position: fixed;
bottom: 0;
background-color: #fff;
height: 40px;
width: 100vw;
padding-left: 10px;
line-height: 40px;
display: flex;
justify-content: space-between;
}
.money{
display: flex;
align-items: center;
justify-content: space-between;
.ddd{
display: flex;
align-items: center;
margin-left: 10px;
}
}
.one{
display: flex;
justify-content: space-between;
margin-bottom: 10px;
margin-left: 10px;
}
.first{
margin-top: 30px;
}
.new {
color: red;
font-size: 16px;
margin-right: 20px;
}
.num{
margin-top: 40px;
display: flex;
align-items: center;
.new{
margin-left: 160px;
}
}
.address p{
color: gray;
font-size: 12px;
}
.top{
display: flex;
align-items: center;
margin: 15px 0;
}
.goods-item {
display: flex;
height: 148px;
width: 100vw;
padding-top: 15px;
.left {
margin-right: 10px;
margin-left: 10px;
img {
width: 120px;
height: 120px;
}
}
.right {
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
width: 40px;
}
.old {
color: #999;
font-size: 16px;
text-decoration: line-through;
}
}
}
.goods-item:last-child {
border-bottom: none;
}
</style>
订单管理
// 订单列表
export const getOrderList = (dataType, page) => {
return request.get('/order/list', {
params: {
dataType,
page
}
})
}
OrderListItem.vue
<template>
<div class="orderListItem">
<div class="container" v-for="(goods, index) in item.goods" :key="index">
<div class="info">
<p>{{ item.create_time }}</p>
<p style="color: orange;">待支付</p>
</div>
<div class="goods-item">
<div class="left">
<img :src="goods.goods_image" alt="">
</div>
<div class="right">
<p class="text-ellipsis-2" style="width: 150px;">{{ goods.goods_name }}</p>
</div>
<div class="box">
<p class="new">¥{{ goods.total_pay_price }}</p>
<p class="old">×{{ goods.total_num }}</p>
</div>
</div>
<div class="total">
<p style="margin: 10px 0;">共{{ item.total_num }}件商品,总金额¥{{ item.total_price }}</p>
<button>申请取消</button>
</div>
</div>
</div>
</template>
<style>
.total {
font-size: 14px;
margin-left: 45vw;
margin-right: 10px;
text-align: center;
margin-bottom: 10px;
}
.info {
display: flex;
justify-content: space-between;
font-size: 14px;
margin: 20px 10px;
}
.goods-item {
display: flex;
height: 148px;
width: 100vw;
padding-top: 15px;
.left {
margin-right: 10px;
margin-left: 10px;
img {
width: 100px;
height: 100px;
}
}
.right {
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
}
}
.goods-item:last-child {
border-bottom: none;
}
.new,
.old {
color: #999;
font-size: 14px;
margin-right: 10px;
}
.box {
margin-left: 10px;
}
</style>
<script>
export default {
name: 'OrderListItem',
props: {
item: {
type: Object,
default: () => {}
}
}
}
</script>
myorder > index.vue
<template>
<div class="myorder">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.back()" />
<van-tabs>
<van-tab title="全部" name="all"></van-tab>
<van-tab title="待支付" name="payment"></van-tab>
<van-tab title="待发货" name="delivery"></van-tab>
<van-tab title="待收货" name="received"></van-tab>
<van-tab title="待评价" name="comment"></van-tab>
</van-tabs>
<!-- 商品项 -->
<OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
</div>
</template>
<script>
import { getOrderList } from '@/api/order'
import OrderListItem from '@/components/OrderListItem'
export default {
name: 'MyorderIndex',
components: {
OrderListItem
},
data () {
return {
active: this.$route.query.dataType || 'all',
page: 1,
list: []
}
},
methods: {
async getList () {
const { data: { list } } = await getOrderList(this.active, this.page)
console.log('list', list)
list.data.forEach(item => {
item.total_num = 0
item.goods.forEach(goods => {
item.total_num += goods.total_num
})
})
this.list = list.data
console.log('this.list', this.list)
}
},
watch: {
active: {
immediate: true,
handler () {
this.getList()
}
}
}
}
</script>
<style scoped>
.total{
font-size: 14px;
float: right;
margin-right: 10px;
text-align: center;
}
.info{
display: flex;
justify-content: space-between;
font-size: 14px;
margin: 10px 10px;
}
.goods-item {
display: flex;
height: 148px;
width: 100vw;
padding-top: 15px;
.left {
margin-right: 10px;
margin-left: 10px;
img {
width: 100px;
height: 100px;
}
}
.right {
.count {
color: #999;
font-size: 12px;
margin-bottom: 10px;
margin-top: 10px;
}
}
}
.goods-item:last-child {
border-bottom: none;
}
.new, .old {
color: #999;
font-size: 14px;
margin-right: 10px;
}
.box{
margin-left: 49vw;
}
</style>

用户页
import request from '@/utils/request'
// 获取个人信息
export const getUser = () => {
return request.get('/user/info')
}
<template>
<div class="user">
<div class="info1">
<div class="userInfo">
<img src="@/assets/user.jpg" alt="">
<div class="box">
<p style="color: goldenrod;font-size: 20px;font-weight: 700;">{{ userInfo.mobile }}</p>
<p>会员</p>
</div>
</div>
</div>
<!-- <img class="bg" src="https://smart-shop.itheima.net/static/background/user-header2.png" alt=""> -->
<div class="top">
<div class="first">
<p style="color: red;font-size: 20px;">{{ userInfo.balance }}</p>
<p>账户余额</p>
</div>
<div class="second">
<p style="color: red;font-size: 20px;">{{ userInfo.points }}</p>
<p>积分</p>
</div>
<div class="third">
<p style="color: red;font-size: 20px;">{{ 0 }}</p>
<p>优惠券</p>
</div>
<div class="fourth">
<p><van-icon name="balance-pay" size="25" /></p>
<p>我的钱包</p>
</div>
</div>
<van-divider />
<div class="service">
<div class="one">
<p><van-icon name="bill-o" size="25" /></p>
<p>全部订单</p>
</div>
<div class="two">
<p><van-icon name="clock-o" size="25" /></p>
<p>待支付</p>
</div>
<div class="three">
<p><van-icon name="logistics" size="25" /></p>
<p>代发货</p>
</div>
<div class="four">
<p><van-icon name="bag-o" size="25" /></p>
<p>待收货</p>
</div>
</div>
<van-divider />
<div class="myService">
<p style="margin: 10px;">我的服务</p>
<van-grid style="border: none;">
<van-grid-item icon="guide-o" text="收货地址" style="color: orange;font-size: 14px;" />
<van-grid-item icon="point-gift-o" text="领券中心" style="color: orange;font-size: 14px;" />
<van-grid-item icon="gift-card-o" text="优惠券" style="color: orange;font-size: 14px;" />
<van-grid-item icon="question-o" text="我的帮助" style="color: orange;font-size: 14px;" />
<van-grid-item icon="balance-o" text="我的积分" style="color: orange;font-size: 14px;" />
<van-grid-item icon="refund-o" text="退还/售后" style="color: orange;font-size: 14px;" />
</van-grid>
</div>
<div class="out">
<van-button type="default" @click="logOut" style="width: 150px;margin-top: 10px;margin-left: 30%;">退出登录</van-button>
</div>
</div>
</template>
<script>
import { getUser } from '@/api/user'
export default {
name: 'UserPage',
async created () {
this.getUser()
},
data () {
return {
userInfo: {}
}
},
methods: {
logOut () {
this.$dialog.confirm({
title: '温馨提示',
message: '确定要退出登录吗?'
}).then(() => {
this.$store.dispatch('user/logOut')
// 重新请求数据实现页面刷新
this.getUser()
}).catch({})
},
async getUser () {
const { data: { userInfo } } = await getUser()
this.userInfo = userInfo
}
}
}
</script>
<style scoped>
.out{
width: 100%;
height: 70px;
background-color: rgb(248, 248, 248);
}
.info1{
width: 100%;
height: 150px;
padding-top: 35px;
background: url('https://smart-shop.itheima.net/static/background/user-header2.png') no-repeat center;
}
.userInfo{
display: flex;
align-items: center;
margin-left: 10px;
width: 100%;
height: 80px;
img{
width: 60px;
height: 60px;
border-radius: 50%;
}
}
.bg{
width: 100%;
height: 33vw;
}
.top{
display: flex;
text-align: center;
justify-content: space-between;
margin: 20px 10px;
line-height: 30px;
}
.service{
display: flex;
text-align: center;
justify-content: space-between;
margin: 20px 10px;
line-height: 30px;
}
.van-grid-item__icon{
color: orange;
}
</style>

退出:
actions: {
logOut (context) {
// 个人信息重置
context.commit('setUserInfo', {})
// 购物车信息重置 { root: true }开启全局模式,可以访问根级别的mutations
context.commit('cart/setCartList', [], { root: true })
}
},
打包发布
Vue脚手架只参与开发,不参与上线
打包的作用:
-
将多个文件压缩合并成一个文件
-
语法降级
-
less、sass、ts语法解析
-
...
打包后,可以生成浏览器能够直接运行的网页

命令:npm run build
默认情况下,需要放到服务器的根目录打开
如果希望双击运行,需要在vue.config.js 中配置: publicPath: './'
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
publicPath: './',
transpileDependencies: true
})
打包优化-路由懒加载
在打包构建应用时,JS包会变得非常大,影响页面加载

一打开网页会把所有的JS文件都加载,影响性能
优化思路:把不用路由对应的组件分割成不同的代码块,当路由被访问的时候才加载对应组件
步骤:将首页的子路由设置为默认加载;其他页面设置为按需加载
异步组件改造:将router文件中的组件加载方法改变,并将改变后的代码放置于import代码下方
import Login from '@/views/login'
import Myorder from '@/views/myorder'
import Pay from '@/views/pay'
import Prodetail from '@/views/prodetail'
import Search from '@/views/search/index.vue'
import SearchList from '@/views/search/list.vue'
const Login = () => import('@/views/login')
const Myorder = () => import('@/views/myorder')
const Pay = () => import('@/views/pay')
const Prodetail = () => import('@/views/prodetail')
const Search = () => import('@/views/search')
const SearchList = () => import('@/views/search/list.vue')

实现JS文件的拆分与按需加载



浙公网安备 33010602011771号