智慧商城项目
1. 项目演示
目标:查看项目效果,明确功能模块 -> 完整的电商购物流程
2. 项目收获
目标:明确做完本项目,能够收获哪些内容
3. 创建项目
目标:基于VueCli自定义创建项目架子
4. 调整初始化目录
目标:将目录调整成符合企业规范的目录
5. vant 组件库
目标:认识第三方Vue组件库 vant-ui
组件库:第三方封装好了很多的组件,整合到一起就是一个组件库。
https://vant-contrib.gitee.io/vant/v2/#/zh-CN/
上面的链接打不开的试试这个:Vant 2 - Mobile UI Components built on Vue
6. 其他Vue组件库
目标:了解其他Vue组件库
Vue的组件库并不是唯一的,vant-ui也仅仅是组件库的一种。
一般会按照不同平台进行分类:
①PC端:element-ui、(element-plus)、ant-design-vue
②移动端:vant-ui、 Mint UI(饿了么)、 Cube UI(滴滴)
7. vant全部导入 和 按需导入
目标:明确 全部导入 和 按需导入 的区别
目标:阅读文档,掌握 全部导入 的基本使用
官网:Vant 2 - Mobile UI Components built on Vue
全部导入:
①安装vant-ui
npm i vant@latest-v2 -S
如果出现下面的依赖冲突问题,可以尝试更新依赖
②main.js中注册
import Vant from 'vant'
import 'vant/lib/index.css'
// 把vant中所有的组件都导入了
Vue.use(Vant)
③使用测试
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
目标:阅读文档,掌握 按需导入 的基本使用
按需导入:
①安装vant-ui(已安装)
npm i vant@latest-v2 -S
②安装插件
npm i babel-plugin-import -D
③babel.config.js中配置
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
],
plugins: [
['import', {
libraryName: 'vant',
libraryDirectory: 'es',
style: true
}, 'vant']
]
};
④main.js 按需导入注册
import Vue from 'vue';
import { Button } from 'vant';
Vue.use(Button);
⑤测试使用
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
<van-button type="default">默认按钮</van-button>
⑥提取到vant-ui.js中,main.js中导入
// 导入按需导入的配置文件
import '@/utils/vant-ui'
8. 项目中的 vw 适配
目标:基于 postcss 插件实现项目vw适配
官方配置
①安装插件
npm i postcss-px-to-viewport@1.1.1 -D
②根目录新建postcss.config.js文件,填入配置
// postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
// 标准屏宽度
viewportWidth: 375
}
}
}
9. 路由设计配置
目标:分析项目页面,设计路由,配置一级路由
但凡是单个页面,独立展示的,都是一级路由
main.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '@/views/login'
import Layout from '@/views/layout'
import Search from '@/views/search'
import SearchList from '@/views/search/list'
import ProDetail from '@/views/prodetail'
import Pay from '@/views/pay'
import MyOrder from '@/views/myorder'
Vue.use(VueRouter)
const router = new VueRouter({
routes: [
{ path: '/login', component: Login },
{ path: '/', component: Layout },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList },
// 动态路由传参,确认将来是哪个商品,路由参数中携带id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/pay', component: Pay },
{ path: '/myorder', component: MyOrder }
]
})
export default router
目标:阅读vant组件库文档,实现底部导航tabbar
tabbar标签页:
①vant-ui.js 按需引入
import { Tabbar, TabbarItem } from 'vant'
Vue.use(Tabbar)
Vue.use(TabbarItem)
②layout.vue粘贴官方代码测试
<van-tabbar>
<van-tabbar-item icon="home-o">标签</van-tabbar-item>
<van-tabbar-item icon="search">标签</van-tabbar-item>
<van-tabbar-item icon="friends-o">标签</van-tabbar-item>
<van-tabbar-item icon="setting-o">标签</van-tabbar-item>
</van-tabbar>
③修改文字、图表、颜色
<van-tabbar active-color="#ee0a24" inactive-color="#000">
<van-tabbar-item icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item icon="apps-o">分类页</van-tabbar-item>
<van-tabbar-item icon="shopping-cart-o">购物车</van-tabbar-item>
<van-tabbar-item icon="user-o">我的</van-tabbar-item>
</van-tabbar>
目标:基于底部导航,完成二级路由配置
1. 配置二级路由 router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '@/views/login'
import Layout from '@/views/layout'
import Search from '@/views/search'
import SearchList from '@/views/search/list'
import ProDetail from '@/views/prodetail'
import Pay from '@/views/pay'
import MyOrder from '@/views/myorder'
import Home from '@/views/layout/home'
import Category from '@/views/layout/category'
import Cart from '@/views/layout/cart'
import User from '@/views/layout/user'
Vue.use(VueRouter)
const router = new VueRouter({
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
redirect: '/home',
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList },
// 动态路由传参,确认将来是哪个商品,路由参数中携带id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/pay', component: Pay },
{ path: '/myorder', component: MyOrder }
]
})
export default router
2. 配置导航链接、配置二级路由出口 src/views/layout/index.vue
<template>
<div>
<!-- 二级路由出口,二级组件展示的位置 -->
<router-view></router-view>
<van-tabbar active-color="#ee0a24" inactive-color="#000" route>
<van-tabbar-item to="/home" icon="wap-home-o">首页</van-tabbar-item>
<van-tabbar-item to="/category" icon="apps-o">分类页</van-tabbar-item>
<van-tabbar-item to="/cart" icon="shopping-cart-o">购物车</van-tabbar-item>
<van-tabbar-item to="/user" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script>
export default {
name: 'LayoutIndex'
}
</script>
<style></style>
10. 登录页静态布局
目标:基于笔记,快速实现登录页静态布局
1. 准备工作
(1)新建`styles/common.less`充值默认样式
(2)main.js导入common.less
(3)图片素材拷贝到assets目录【备用】
2. 登录页静态布局编写
(1)头部组件说明(NavBar)
utils/vant-ui.js
import Vue from 'vue';
import { NavBar } from 'vant';
Vue.use(NavBar);
views/login/index.vue
<template>
<div class="login">
<!-- 头部 NavBar-->
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)"/>
<!-- 主体 -->
</div>
</template>
<script>
export default {
name: 'LoginIndex'
}
</script>
<style></style>
(2)通用样式覆盖
src/styles/common.less
// 重置默认样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 文字溢出省略号
.text-ellipsis-2 {
overflow: hidden;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
// 添加导航的通用样式
.van-nav-bar {
.van-nav-bar__arrow {
color: #333;
}
}
(3)其他静态结构编写
<template>
<div class="login">
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
<div class="container">
<div class="title">
<h3>手机号登录</h3>
<p>未注册的手机号登录后将自动注册</p>
</div>
<div class="form">
<div class="form-item">
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" alt="">
</div>
<div class="form-item">
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
<button >
{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
}
}
</script>
<style lang="less" scoped>
.container {
padding: 49px 29px;
.title {
margin-bottom: 20px;
h3 {
font-size: 26px;
font-weight: normal;
}
p {
line-height: 40px;
font-size: 14px;
color: #b8b8b8;
}
}
.form-item {
border-bottom: 1px solid #f3f1f2;
padding: 8px;
margin-bottom: 14px;
display: flex;
align-items: center;
.inp {
display: block;
border: none;
outline: none;
height: 32px;
font-size: 14px;
flex: 1;
}
img {
width: 94px;
height: 31px;
}
button {
height: 31px;
border: none;
font-size: 13px;
color: #cea26a;
background-color: transparent;
padding-right: 9px;
}
}
.login-btn {
width: 100%;
height: 42px;
margin-top: 39px;
background: linear-gradient(90deg,#ecb53c,#ff9211);
color: #fff;
border-radius: 39px;
box-shadow: 0 10px 20px 0 rgba(0,0,0,.1);
letter-spacing: 2px;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>
效果:
11. request模块 - axios封装
目标:将axios请求方法,封装到request模块
使用axios来请求后端接口,一般会对axios进行一些配置(如:配置基础地址,请求响应拦截器等)。所以项目开发中,都会对axios进行基本的二次封装,单独封装到一个request模块中,便于维护使用。
接口文档地址:https://apifox.com/apidoc/shared-12ab6b18-abc2-444c-ad11-0e60f5693f66/doc-2221080(已失效)
基地址:http://smart-shop.itheima.net/index.php?s=/api/
1. 安装axios
npm install axios
2. 新建request模块,创建实例 & 配置,导出实例
axios官方文档:Axios 实例 | Axios中文文档 | Axios中文网
utils/request.js
import axios from 'axios'
// 创建axios实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的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
3. 测试使用
src/views/login/index.vue
<script>
import request from '@/utils/request'
export default {
name: 'LoginPage',
async created () {
const res = await request.get('/captcha/image')
console.log(res)
}
}
</script>
12. 图形验证码功能完成
目标:基于请求回来的base64图片,实现图形验证码功能
说明:
1. 图形验证码,本质就是一个请求回来的图片
2. 用户将来输入图形验证码,用于强制人机交互,可以抵御机器自动化攻击(例如:避免批量请求获取短信)
需求:
1. 动态将请求回来的base64图片,解析渲染除了
2. 点击验证码图片盒子,要刷新验证码
src/views/login/index.vue
<template>
<div class="login">
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
<div class="container">
<div class="title">
<h3>手机号登录</h3>
<p>未注册的手机号登录后将自动注册</p>
</div>
<div class="form">
<div class="form-item">
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
<button >
{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
import request from '@/utils/request'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识(将来验证需要携带)
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
async created () {
// const res = await request.get('/captcha/image')
// console.log(res)
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
const { data: { base64, key } } = await request.get('/captcha/image')
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
}
}
}
</script>
<style lang="less" scoped>
</style>
13. api接口模块 - 封装图片验证码接口
目标:将请求封装成方法,统一存放到api模块,与页面分离
1. 新建请求模块,封装请求函数
src/api/login.js
// 此处用于存放所有登录相关的接口请求
import request from '@/utils/request'
// 1. 获取图形验证码
export const getPicCode = () => {
return request.get('/captcha/image')
}
2. 页面中导入调用
src/views/login/index.vue
<script>
import { getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识(将来验证需要携带)
picUrl: '', // 存储请求渲染的图片地址
picCode: '' // 用户输入的图形验证码
}
},
async created () {
// const res = await request.get('/captcha/image')
// console.log(res)
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
}
}
}
</script>
14. Toast轻提示
目标:阅读文档,掌握toast轻提示
1. 注册安装:
import { Toast } from 'vant'
Vue.use(Toast)
2. 两种使用方式
①导入调用(组件内或非组件内均可)
import { Toast } from 'vant'
Toast('提示内容')
②通过this直接调用(必须组件内)
本质:将方法,注册挂载到了Vue原型上 Vue.prototype.$toast = xxx
this.$toast('提示内容')
15. 短信验证倒计时
目标:实现短信验证倒计时功能
步骤分析:
1. 点击按钮,实现 倒计时 效果
src/views/login/index.vue
<template>
<div class="login">
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
<div class="container">
<div class="title">
<h3>手机号登录</h3>
<p>未注册的手机号登录后将自动注册</p>
</div>
<div class="form">
<div class="form-item">
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
<button @click="getCode">
{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送' }}
</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
import { getPicCode } from '@/api/login'
// import { Toast } from 'vant'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识(将来验证需要携带)
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('666666')
// this.$toast.success('成功文案')
},
// 获取短信验证码,
getCode () {
// 当目前没有定时器开着,且 totalSecond 和 second一致(秒数归位)才可以倒计时
if (!this.timer && this.second === this.totalSecond) {
// 开启倒计时
this.timer = setInterval(() => {
// console.log('正在倒计时……')
this.second--
if (this.second <= 0) {
clearInterval(this.timer) // 清空计时器
this.timer = null // 重置定时器id
this.second = this.totalSecond // 归位
}
}, 1000)
}
}
},
destroyed () {
// 离开页面清除定时器
clearInterval(this.timer)
}
}
</script>
<style lang="less" scoped>
</style>
2. 倒计时之前的 校验处理(手机号、验证码)
src/views/login/index.vue
methods: {
// 校验手机号 、验证码是否合法
// 通过校验,返回true; 不通过校验,返回false
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
},
// 获取短信验证码,
getCode () {
if (!this.validFn()) {
// 如果没通过校验,直接返回
return
}
// 当目前没有定时器开着,且 totalSecond 和 second一致(秒数归位)才可以倒计时
if (!this.timer && this.second === this.totalSecond) {
// 开启倒计时
this.timer = setInterval(() => {
// console.log('正在倒计时……')
this.second--
if (this.second <= 0) {
clearInterval(this.timer) // 清空计时器
this.timer = null // 重置定时器id
this.second = this.totalSecond // 归位
}
}, 1000)
}
}
},
3. 封装 短信验证请求接口,发送请求添加提示
src/api/login.js
// 此处用于存放所有登录相关的接口请求
import request from '@/utils/request'
// 1. 获取图形验证码
export const getPicCode = () => {
return request.get('/captcha/image')
}
// 2. 获取短信验证码
export const getMsgCode = (captchaCode, captchaKey, mobile) => {
return request.post('/captcha/sendSmsCaptcha', {
form: {
captchaCode,
captchaKey,
mobile
}
})
}
src/views/login/index.vue
// 获取短信验证码,
async getCode () {
if (!this.validFn()) {
// 如果没通过校验,直接返回
return
}
// 当目前没有定时器开着,且 totalSecond 和 second一致(秒数归位)才可以倒计时
if (!this.timer && this.second === this.totalSecond) {
// 发送请求
await getMsgCode(this.picCode, this.picKey, this.mobile)
this.$toast('短信发送成功,请注意查收')
// 开启倒计时
......
}
}
16. 登录功能
目标:封装api登录接口,实现登录功能
步骤分析:
1. 阅读接口文档,封装登录接口
src/api/login.js
// 3. 登录接口
export const codeLogin = (mobile, smsCode) => {
return request.post('/passport/login', {
form: {
isParty: false,
partyData: {},
mobile,
smsCode
}
})
}
2. 登录前的校验(手机号,图形验证码,短信验证码)
3. 调用方法,发送请求,成功添加提示并跳转
src/views/login/index.vue
// 登录
async login () {
if (!this.validFn()) {
return
}
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
return
}
const res = await codeLogin(this.mobile, this.msgCode)
console.log(res)
this.$toast('登录成功')
this.$router.push('/')
}
17. 响应拦截器统一处理错误提示
目标:通过响应拦截器,统一处理接口的错误提示
问题:每次请求,都会有可能会错误,就都需要错误提示
说明:响应拦截器是咱们拿到数据的第一个数据流转站,可以再里面统一处理错误。
src/utils/request.js
import axios from 'axios'
import { Toast } from 'vant'
// 创建axios实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的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,需要响应拦截器中处理一下)
const res = response.data
console.log(res)
if (res.status !== 200) {
// 给提示
Toast(res.message)
// 抛出一个错误的promise
return Promise.reject(res.message)
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
如果出现了下面的错误,可以在src/utils/request.js中添加请求头
const instance = axios.create({
baseURL: 'http://smart-shop.itheima.net/index.php?s=/api/',
timeout: 5000,
headers: {
platform: 'H5'
}
})
18. 登录权证信息存储
目标:vuex构建user模块存储登录权证(token & userId)
补充说明:
1. token存入vuex的好处,易获取,响应式
2. vuex需要分模块 => user模块
1. 构建user模块
src/store/modules/user.js
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: {
token: '',
userId: ''
}
}
},
mutations: {
},
actions: {
},
getters: {
}
}
2. 挂载到vuex
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
user
}
})
3. 提供mutations
src/store/modules/user.js
mutations: {
// 所有mutations的第一个参数都是state
setUserInfo (state, obj) {
state.userInfo = obj
}
},
4. 页面中commit调用
src/views/login/index.vue
// 登录
async login () {
if (!this.validFn()) {
return
}
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
return
}
const res = await codeLogin(this.mobile, this.msgCode)
this.$store.commit('user/setUserInfo', res.data)
console.log(res)
this.$toast('登录成功')
this.$router.push('/')
}
19. storage存储模块 - vuex持久化处理
目标:封装storage存储模块,利用本地存储,进行vuex持久化处理
问题1:vuex刷新会丢失,怎么办?
// 将token存入本地
localStorage.setItem('hm_shopping_info', JSON.stringify(xxx))
src/utils/storage.js
// 约定一个通用的键名
const INFO_KEY = 'hm_shopping_info'
// 获取个人信息
export const getInfo = () => {
const defaultObj = { token: '', userId: '' }
const res = localStorage.getItem(INFO_KEY)
return res ? JSON.parse(res) : defaultObj
}
// 设置个人信息
export const setInfo = (obj) => {
localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}
// 移除个人信息
export const removeInfo = () => {
localStorage.removeItem(INFO_KEY)
}
src/store/modules/user.js
import { getInfo, setInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: getInfo()
}
},
mutations: {
// 所有mutations的第一个参数都是state
setUserInfo (state, obj) {
state.userInfo = obj
setInfo(obj)
}
},
actions: {
},
getters: {
}
}
20. 添加请求loading效果
目标:统一在每次请求后台时,添加loading效果
背景:有时候因为网络原因,一次请求的结果可能需要一段时间后才能回来,此时,需要给用户添加loading提示。
添加loading提示的好处:
1. 节流处理:防止用户在一次请求还没回来之前,多次进行点击,发送无效请求
2. 友好提示:告知用户,目前是在加载中,请耐心等待,用户体验会更好
实现步骤:
1. 请求拦截器中,每次请求,打开loading
2. 响应拦截器中,每次响应,关闭loading
src/utils/request.js
import axios from 'axios'
import { Toast } from 'vant'
// 创建axios实例,将来对创建出来的实例,进行自定义配置
// 好处:不会污染原始的axios实例
const instance = axios.create({
baseURL: 'http://smart-shop.itheima.net/index.php?s=/api/',
timeout: 5000,
headers: {
platform: 'H5'
}
})
// 自定义配置 - 请求/响应 拦截器
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 开启loading,禁止背景点击(节流处理,防止多次无效触发)
Toast.loading({
message: '加载中...',
forbidClick: true,
loadingType: 'spinner', // 配置loading图标
duration: 0 // 不会自动消失
})
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么(默认axios会多包装一层data,需要响应拦截器中处理一下)
const res = response.data
if (res.status !== 200) {
// 给错误提示,Toast默认是单例模式,后面的Toast调用了,会将前一个Toast效果覆盖,同时只能存在一个Toast
Toast(res.message)
// 抛出一个错误的promise
return Promise.reject(res.message)
} else {
// 正确情况,直接走业务核心逻辑,清除loading效果
Toast.clear()
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
21. 页面访问拦截
目标:基于全局前置防卫,进行页面访问拦截处理
说明:智慧商城项目,大部分页面,游客都可以直接访问,如遇到需要登录才能进行的操作,提示并跳转到登录。但是,对于支付页,订单页等,必须是登录的用户才能访问的,游客不能进入该页面,需要左拦截处理。
路由导航守卫- 全局前置守卫
1. 所有的路由一旦倍匹配到,都会先经过全局前置守卫
2. 只有全局前置守卫放行,才会真正解析渲染组件,才能看到页面内容
访问权限页面时,拦截或放行的关键点?-> 用户是否有登录权证token
router.beforeEach((to, from, next) => {
// 1. to 往哪里去,到哪去的路由信息对象
// 2. from 从哪里来,从哪里来的路由信息对象
// 3. next() 是否放行
// 如果next()调用,就是放行
// next(路径) 拦截到某个路径页面
})
1. src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
getters: {
token (state) {
return state.user.userInfo.token
}
},
mutations: {
},
actions: {
},
modules: {
user
}
})
2. src/router/index.js
import store from '@/store'
// 所有路由在真正被访问到之前(解析渲染对应的组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面
// 全局前置导航守卫
// 1. to 往哪里去,到哪去的路由信息对象(路径,参数)
// 2. from 从哪里来,从哪里来的路由信息对象(路径,参数)
// 3. next() 是否放行
// 如果next()调用,就是放行到to要去的路径
// next(路径) 拦截到某个路径页面
// 定义一个数组,专门存放所有需要权限访问的页面
const authUrls = ['/pay', '/myorder']
router.beforeEach((to, from, next) => {
// 看 to.path是否在authUrls中出现过
if (!authUrls.includes(to.path)) {
// 非权限页面,直接放行
next()
} else {
// 权限页面,需要判定token
const token = store.getters.token
// console.log(token)
if (token) {
next()
} else {
next('/login')
}
}
})
22. 首页 - 静态结构准备 & 动态渲染
目标:实现首页静态结构,封装接口,完成首页动态渲染
1. src/utils/vant-ui.js
// 按需导入
import Vue from 'vue'
import { Button, Switch, Rate, Tabbar, TabbarItem, NavBar, Toast, Search, Swipe, SwipeItem, Grid, GridItem } from 'vant'
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Search)
Vue.use(Toast)
Vue.use(NavBar)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
2. src/views/layout/home.vue
<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 bannerList" :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 navList" :key="item.imgUrl"
:icon="item.imgUrl"
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 proList" :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',
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
console.log(this.proList)
}
}
</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>
3. src/components/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="tit text-ellipsis-2">
{{ item.goods_name }}
</p>
<p class="count">已售 {{ item.goods_sales }} 件</p>
<p class="price">
<span class="new">¥{{ item.goods_price_min }}</span>
<span class="old">¥{{ item.goods_price_max }}</span>
</p>
</div>
</div>
</template>
<script>
export default {
name: 'GoodsItem',
props: {
item: {
type: Object,
default: () => {
return {}
}
}
}
}
</script>
<style lang="less" scoped>
.goods-item {
height: 148px;
margin-bottom: 6px;
padding: 10px;
background-color: #fff;
display: flex;
.left {
width: 127px;
img {
display: block;
width: 100%;
}
}
.right {
flex: 1;
font-size: 14px;
line-height: 1.3;
padding: 10px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
.count {
color: #999;
font-size: 12px;
}
.price {
color: #999;
font-size: 16px;
.new {
color: #f03c3c;
margin-right: 10px;
}
.old {
text-decoration: line-through;
font-size: 12px;
}
}
}
}
</style>
4. src/api/home.js
import request from '@/utils/request'
// 获取首页数据
export const getHomeData = () => {
return request.get('/page/detail', {
params: {
pageId: 0
}
})
}
效果:
23. 搜索 - 历史记录管理
目标:构建搜索页的静态布局,完成历史记录的管理
需求:
1. 搜索历史基本渲染
2. 点击搜索(添加历史)
点击搜索按钮或底下历史记录,都能进行搜索
- ①若之前没有相同搜索关键字,则直接追加到最前面
- ②若之前已有相同搜索关键字,将该原有关键字移除,再追加
3. 清空历史:添加清空图标,可以清空历史记录
4. 持久化:搜索历史需要持久化,刷新历史不丢失
代码:
src/views/search/index.vue
<template>
<div class="search">
<van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search v-model="search" show-action placeholder="请输入搜索关键词" clearable>
<template #action>
<div @click="goSearch(search)">搜索</div>
</template>
</van-search>
<!-- 搜索历史 -->
<div class="search-history" v-if="history.length > 0">
<div class="title">
<span>最近搜索</span>
<van-icon @click="clear" name="delete-o" size="16" />
</div>
<div class="list">
<div v-for="item in history" :key="item" class="list-item" @click="goSearch(item)">
{{ item }}
</div>
</div>
</div>
</div>
</template>
<script>
import { getHistoryList, setHistoryList } from '@/utils/storage'
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
history: getHistoryList() // 历史记录
}
},
methods: {
goSearch (key) {
// console.log('进行了搜索,搜索历史要更新', key)
const index = this.history.indexOf(key)
if (index !== -1) {
// 存在相同的项,将原有关键字移除
// splice(从哪开始, 删除几个, 项1, 项2)
this.history.splice(index, 1)
}
this.history.unshift(key)
setHistoryList(this.history)
// 跳转到搜索列表页
this.$router.push(`/searchlist?search=${key}`)
},
clear () {
this.history = []
setHistoryList([])
}
}
}
</script>
<style lang="less" scoped>
.search {
.searchBtn {
background-color: #fa2209;
color: #fff;
}
::v-deep .van-search__action {
background-color: #c21401;
color: #fff;
padding: 0 20px;
border-radius: 0 5px 5px 0;
margin-right: 10px;
}
::v-deep .van-icon-arrow-left {
color: #333;
}
.title {
height: 40px;
line-height: 40px;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
}
.list {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
padding: 0 10px;
gap: 5%;
}
.list-item {
width: 30%;
text-align: center;
padding: 7px;
line-height: 15px;
border-radius: 50px;
background: #fff;
font-size: 13px;
border: 1px solid #efefef;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-bottom: 10px;
}
}
</style>
src/utils/storage.js
// 约定一个通用的键名
const INFO_KEY = 'hm_shopping_info'
const HISTORY_KEY = 'hm_history_list'
// 获取搜索历史
export const getHistoryList = () => {
const res = localStorage.getItem(HISTORY_KEY)
return res ? JSON.parse(res) : []
}
// 设置搜索历史
export const setHistoryList = (arr) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}
效果:
24. 搜索列表 - 静态布局 & 动态渲染
目标:实现搜索列表页静态结构,封装接口,完成搜索列表页的渲染
(1)搜索关键字搜索
1. src/views/search/list.vue
<template>
<div class="search">
<van-nav-bar fixed title="商品列表" left-arrow @click-left="$router.go(-1)" />
<van-search
readonly
shape="round"
background="#ffffff"
:value="querySearch || '搜索商品'"
show-action
@click="$router.push('/search')"
>
<template #action>
<van-icon class="tool" name="apps-o" />
</template>
</van-search>
<!-- 排序选项按钮 -->
<div class="sort-btns">
<div class="sort-item">综合</div>
<div class="sort-item">销量</div>
<div class="sort-item">价格 </div>
</div>
<div class="goods-list">
<GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getProList } from '@/api/product'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
},
data () {
return {
page: 1,
proList: []
}
},
async created () {
const { data: { list } } = await getProList({
categoryId: this.$route.query.categoryId,
goodsName: this.querySearch,
page: this.page
})
this.proList = list.data
}
}
</script>
<style lang="less" scoped>
.search {
padding-top: 46px;
::v-deep .van-icon-arrow-left {
color: #333;
}
.tool {
font-size: 24px;
height: 40px;
line-height: 40px;
}
.sort-btns {
display: flex;
height: 36px;
line-height: 36px;
.sort-item {
text-align: center;
flex: 1;
font-size: 16px;
}
}
}
// 商品样式
.goods-list {
background-color: #f6f6f6;
}
</style>
2. src/api/product.js
import request from '@/utils/request'
// 获取搜索商品列表的数据
export const getProList = (obj) => {
const { categoryId, goodsName, page } = obj
return request.get('/goods/list', {
params: {
categoryId,
goodsName,
page
}
})
}
// 获取商品详情数据
export const getProDetail = (goodsId) => {
return request.get('/goods/detail', {
params: {
goodsId
}
})
}
// 获取商品评价
export const getProComments = (goodsId, limit) => {
return request.get('/comment/listRows', {
params: {
goodsId,
limit
}
})
}
效果:
(2)分类id搜索
1. src/api/category.js
import request from '@/utils/request'
// 获取分类数据
export const getCategoryData = () => {
return request.get('/category/list')
}
2. src/views/layout/category.vue
<template>
<div class="category">
<!-- 分类 -->
<van-nav-bar title="全部分类" fixed />
<!-- 搜索框 -->
<van-search
readonly
shape="round"
background="#f1f1f2"
placeholder="请输入搜索关键词"
@click="$router.push('/search')"
/>
<!-- 分类列表 -->
<div class="list-box">
<div class="left">
<ul>
<li v-for="(item, index) in list" :key="item.category_id">
<a :class="{ active: index === activeIndex }" @click="activeIndex = index" href="javascript:;">{{ item.name }}</a>
</li>
</ul>
</div>
<div class="right">
<div @click="$router.push(`/searchlist?categoryId=${item.category_id}`)" v-for="item in list[activeIndex]?.children" :key="item.category_id" class="cate-goods">
<img :src="item.image?.external_url" alt="">
<p>{{ item.name }}</p>
</div>
</div>
</div>
</div>
</template>
<script>
import { getCategoryData } from '@/api/category'
export default {
name: 'CategoryPage',
created () {
this.getCategoryList()
},
data () {
return {
list: [],
activeIndex: 0
}
},
methods: {
async getCategoryList () {
const { data: { list } } = await getCategoryData()
this.list = list
}
}
}
</script>
<style lang="less" scoped>
// 主题 padding
.category {
padding-top: 100px;
padding-bottom: 50px;
height: 100vh;
.list-box {
height: 100%;
display: flex;
.left {
width: 85px;
height: 100%;
background-color: #f3f3f3;
overflow: auto;
a {
display: block;
height: 45px;
line-height: 45px;
text-align: center;
color: #444444;
font-size: 12px;
&.active {
color: #fb442f;
background-color: #fff;
}
}
}
.right {
flex: 1;
height: 100%;
background-color: #ffffff;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
padding: 10px 0;
overflow: auto;
.cate-goods {
width: 33.3%;
margin-bottom: 10px;
img {
width: 70px;
height: 70px;
display: block;
margin: 5px auto;
}
p {
text-align: center;
font-size: 12px;
}
}
}
}
}
// 导航条样式定制
.van-nav-bar {
z-index: 999;
}
// 搜索框样式定制
.van-search {
position: fixed;
width: 100%;
top: 46px;
z-index: 999;
}
</style>
效果:
25. 商品详情 - 静态布局 & 渲染
目标:实现商品详情静态结构,封装接口,完成商品详情页渲染
1. src/views/prodetail/index.vue
见27
2. src/api/product.js
// 获取商品详情数据
export const getProDetail = (goodsId) => {
return request.get('/goods/detail', {
params: {
goodsId
}
})
}
// 获取商品评价
export const getProComments = (goodsId, limit) => {
return request.get('/comment/listRows', {
params: {
goodsId,
limit
}
})
}
3. src/utils/vant-ui.js
import { ActionSheet } from 'vant';
Vue.use(ActionSheet);
26. 加入购物车 - 唤起弹层
目标:点击加入购物车,唤起弹层效果
27. 加入购物车 - 封装数字框组件
目标:封装弹层中的数字框组件
分析:组件名CountBox
1. 静态结构,左中右三部分
2. 数字框的数字,应该是外部传递进来的(父传子)
3. 点击 + - 号,可以修改数字(子传父)
4. 使用v-model实现封装(:value和@input的简写)
5. 数字不能减到小于1
①src/components/CountBox.vue
<template>
<div class="count-box">
<button @click="handleSub" class="minus">-</button>
<input :value="value" class="inp" type="text">
<button @click="handleAdd" class="add">+</button>
</div>
</template>
<script>
export default {
// 父传子
props: {
value: {
type: Number,
default: 1
}
},
methods: {
// 子传父
handleSub () {
if (this.value <= 1) {
return
}
this.$emit('input', this.value - 1)
},
handleAdd () {
this.$emit('input', this.value + 1)
}
}
}
</script>
<style lang="less" scoped>
.count-box {
width:110px;
display: flex;
.add, .minus {
width: 30px;
height: 30px;
outline: none;
border: none;
background-color: #efefef;
}
.inp {
width: 40px;
height: 30px;
outline: none;
border: none;
margin: 0 5px;
background-color: #efefef;
text-align: center;
}
}
</style>
②src/views/prodetail/index.vue
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="4000" @change="onChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />
</van-swipe-item>
<template #indicator>
<div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
</template>
</van-swipe>
<!-- 商品说明 -->
<div class="info">
<div class="title">
<div class="price">
<span class="now">¥{{ detail.goods_price_min }}</span>
<span class="oldprice">¥{{ detail.goods_price_max }}</span>
</div>
<div class="sellcount">已售 {{ detail.goods_sales }} 件</div>
</div>
<div class="msg text-ellipsis-2">
{{ detail.goods_name }}
</div>
<div class="service">
<div class="left-words">
<span><van-icon name="passed" />七天无理由退货</span>
<span><van-icon name="passed" />48小时发货</span>
</div>
<div class="right-icon">
<van-icon name="arrow" />
</div>
</div>
</div>
<!-- 商品评价 -->
<div class="comment">
<div class="comment-title">
<div class="left">商品评价 ({{ total }}条)</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<div class="name">{{ item.user.nick_name }}</div>
<van-rate :size="16" :value="item.score / 2" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
{{ item.content }}
</div>
<div class="time">
{{ item.create_time }}
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div class="desc" v-html="detail.content">
</div>
<!-- 底部 -->
<div class="footer">
<div @click="$router.push('/')" class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div @click="$router.push('/cart')" class="icon-cart">
<span v-if="cartTotal > 0" class="num">{{ cartTotal }}</span>
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div @click="addFn" class="btn-add">加入购物车</div>
<div @click="buyNow" class="btn-buy">立刻购买</div>
</div>
<!-- 加入购物车/立即购买 公用的弹层 -->
<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>
<!-- v-model 本质上 :value 和 @input 的简写 -->
<CountBox v-model="addCount"></CountBox>
</div>
<!-- 有库存才显示提交按钮 -->
<div class="showbtn" v-if="detail.stock_total > 0">
<div class="btn" v-if="mode === 'cart'">加入购物车</div>
<div class="btn now" v-else @click="goBuyNow">立刻购买</div>
</div>
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { getProComments, getProDetail } from '@/api/product'
import defaultImg from '@/assets/default-avatar.png'
import CountBox from '@/components/CountBox.vue'
export default {
name: 'ProDetail',
// mixins: [loginConfirm],
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 评价总数
commentList: [], // 评价列表
defaultImg,
showPannel: false, // 控制弹层的显示隐藏
mode: 'cart', // 标记弹层状态
addCount: 1, // 数字框绑定的数据
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
onChange (index) {
this.current = index
},
async getDetail () {
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
console.log(this.images)
},
async getComments () {
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
goBuyNow () {
if (this.loginConfirm()) {
return
}
this.$router.push({
path: '/pay',
query: {
mode: 'buyNow',
goodsId: this.goodsId,
goodsSkuId: this.detail.skuList[0].goods_sku_id,
goodsNum: this.addCount
}
})
}
}
}
</script>
<style lang="less" scoped>
.prodetail {
padding-top: 46px;
::v-deep .van-icon-arrow-left {
color: #333;
}
img {
display: block;
width: 100%;
}
.custom-indicator {
position: absolute;
right: 10px;
bottom: 10px;
padding: 5px 10px;
font-size: 12px;
background: rgba(0, 0, 0, 0.1);
border-radius: 15px;
}
.desc {
width: 100%;
overflow: scroll;
::v-deep img {
display: block;
width: 100%!important;
}
}
.info {
padding: 10px;
}
.title {
display: flex;
justify-content: space-between;
.now {
color: #fa2209;
font-size: 20px;
}
.oldprice {
color: #959595;
font-size: 16px;
text-decoration: line-through;
margin-left: 5px;
}
.sellcount {
color: #959595;
font-size: 16px;
position: relative;
top: 4px;
}
}
.msg {
font-size: 16px;
line-height: 24px;
margin-top: 5px;
}
.service {
display: flex;
justify-content: space-between;
line-height: 40px;
margin-top: 10px;
font-size: 16px;
background-color: #fafafa;
.left-words {
span {
margin-right: 10px;
}
.van-icon {
margin-right: 4px;
color: #fa2209;
}
}
}
.comment {
padding: 10px;
}
.comment-title {
display: flex;
justify-content: space-between;
.right {
color: #959595;
}
}
.comment-item {
font-size: 16px;
line-height: 30px;
.top {
height: 30px;
display: flex;
align-items: center;
margin-top: 20px;
img {
width: 20px;
height: 20px;
}
.name {
margin: 0 10px;
}
}
.time {
color: #999;
}
}
.footer {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 55px;
background-color: #fff;
border-top: 1px solid #ccc;
display: flex;
justify-content: space-evenly;
align-items: center;
.icon-home, .icon-cart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
.van-icon {
font-size: 24px;
}
}
.btn-add,
.btn-buy {
height: 36px;
line-height: 36px;
width: 120px;
border-radius: 18px;
background-color: #ffa900;
text-align: center;
color: #fff;
font-size: 14px;
}
.btn-buy {
background-color: #fe5630;
}
}
}
.tips {
padding: 10px;
}
// 弹层的样式
.product {
.product-title {
display: flex;
.left {
img {
width: 90px;
height: 90px;
}
margin: 10px;
}
.right {
flex: 1;
padding: 10px;
.price {
font-size: 14px;
color: #fe560a;
.nowprice {
font-size: 24px;
margin: 0 5px;
}
}
}
}
.num-box {
display: flex;
justify-content: space-between;
padding: 10px;
align-items: center;
}
.btn, .btn-none {
height: 40px;
line-height: 40px;
margin: 20px;
border-radius: 20px;
text-align: center;
color: rgb(255, 255, 255);
background-color: rgb(255, 148, 2);
}
.btn.now {
background-color: #fe5630;
}
.btn-none {
background-color: #cccccc;
}
}
.footer .icon-cart {
position: relative;
padding: 0 6px;
.num {
z-index: 999;
position: absolute;
top: -2px;
right: 0;
min-width: 16px;
padding: 0 4px;
color: #fff;
text-align: center;
background-color: #ee0a24;
border-radius: 50%;
}
}
</style>
效果:
28. 加入购物车 - 判断token添加登录提示
目标:给未登录的用户,添加登录提示
说明:加入购物车,是一个登录后的用户才能进行的操作,所以需要进行鉴权判断,判断用户token是否存在。
1. 若存在:继续加入购物车操作
2. 不存在:提示用户未登录,引导到登录页,登录完回跳
29. 加入购物车 - 封装接口进行请求
目标:封装接口,进行加入购物车的请求
1. api/cart.js中封装接口
2. 页面中调用接口
3. 遇到问题:接口需要传递token
4. 解决问题:请求拦截器中统一携带token
5. 小图标定制
①src/utils/vant-ui.js
import { Dialog } from 'vant';
// 全局注册
Vue.use(Dialog);
②src/views/login/index.vue
// 登录
async login () {
if (!this.validFn()) {
return
}
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
return
}
console.log('发送登录请求')
const res = await codeLogin(this.mobile, this.msgCode)
this.$store.commit('user/setUserInfo', res.data)
this.$toast('登录成功')
// 进行判断,看地址栏有无回跳地址
// 1. 如果有,说明是其他页面,拦截到登录来的,需要回跳
// 2. 如果没有,正常去首页
const url = this.$route.query.backUrl || '/'
this.$router.replace(url)
}
③src/api/cart.js
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
})
}
④src/mixins/loginConfirm.js
export default {
// 此处编写的就是 Vue组件实例的 配置项,通过一定语法,可以直接混入到组件内部
// data methods computed 生命周期函数 ...
// 注意点:
// 1. 如果此处 和 组件内,提供了同名的 data 或 methods, 则组件内优先级更高
// 2. 如果编写了生命周期函数,则mixins中的生命周期函数 和 页面的生命周期函数,
// 会用数组管理,统一执行
created () {
// console.log('嘎嘎')
},
data () {
return {
title: '标题'
}
},
methods: {
sayHi () {
// console.log('你好')
},
// 根据登录状态,判断是否需要显示登录确认框
// 1. 如果未登录 => 显示确认框 返回 true
// 2. 如果已登录 => 啥也不干 返回 false
loginConfirm () {
// 判断 token 是否存在
if (!this.$store.getters.token) {
// 弹确认框
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 如果希望,跳转到登录 => 登录后能回跳回来,需要在跳转去携带参数(当前的路径地址)
this.$router.replace({
path: '/login',
query: {
// this.$route.fullPath(会包含查询参数)
backUrl: this.$route.fullPath
}
})
})
.catch(() => {})
return true
}
return false
}
}
}
⑤src/views/prodetail/index.vue
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="4000" @change="onChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />
</van-swipe-item>
<template #indicator>
<div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
</template>
</van-swipe>
<!-- 商品说明 -->
<div class="info">
<div class="title">
<div class="price">
<span class="now">¥{{ detail.goods_price_min }}</span>
<span class="oldprice">¥{{ detail.goods_price_max }}</span>
</div>
<div class="sellcount">已售 {{ detail.goods_sales }} 件</div>
</div>
<div class="msg text-ellipsis-2">
{{ detail.goods_name }}
</div>
<div class="service">
<div class="left-words">
<span><van-icon name="passed" />七天无理由退货</span>
<span><van-icon name="passed" />48小时发货</span>
</div>
<div class="right-icon">
<van-icon name="arrow" />
</div>
</div>
</div>
<!-- 商品评价 -->
<div class="comment">
<div class="comment-title">
<div class="left">商品评价 ({{ total }}条)</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in commentList" :key="item.comment_id">
<div class="top">
<img :src="item.user.avatar_url || defaultImg" alt="">
<div class="name">{{ item.user.nick_name }}</div>
<van-rate :size="16" :value="item.score / 2" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
{{ item.content }}
</div>
<div class="time">
{{ item.create_time }}
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div class="desc" v-html="detail.content">
</div>
<!-- 底部 -->
<div class="footer">
<div @click="$router.push('/')" class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div @click="$router.push('/cart')" class="icon-cart">
<span v-if="cartTotal > 0" class="num">{{ cartTotal }}</span>
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div @click="addFn" class="btn-add">加入购物车</div>
<div @click="buyNow" class="btn-buy">立刻购买</div>
</div>
<!-- 加入购物车/立即购买 公用的弹层 -->
<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>
<!-- v-model 本质上 :value 和 @input 的简写 -->
<CountBox v-model="addCount"></CountBox>
</div>
<!-- 有库存才显示提交按钮 -->
<div class="showbtn" v-if="detail.stock_total > 0">
<div class="btn" v-if="mode === 'cart'" @click="addCart">加入购物车</div>
<div class="btn now" v-else @click="goBuyNow">立刻购买</div>
</div>
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { getProComments, getProDetail } from '@/api/product'
import defaultImg from '@/assets/default-avatar.png'
import CountBox from '@/components/CountBox.vue'
import { addCart } from '@/api/cart'
import loginConfirm from '@/mixins/loginConfirm'
export default {
name: 'ProDetail',
mixins: [loginConfirm],
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 评价总数
commentList: [], // 评价列表
defaultImg,
showPannel: false, // 控制弹层的显示隐藏
mode: 'cart', // 标记弹层状态
addCount: 1, // 数字框绑定的数据
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
onChange (index) {
this.current = index
},
async getDetail () {
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
console.log(this.images)
},
async getComments () {
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async addCart () {
// 判断token是否存在
// 1. 如果token不存在,弹确认框
// 2. 如果token存在,继续请求操作
if (this.loginConfirm()) {
return
}
console.log('正常请求')
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
},
goBuyNow () {
if (this.loginConfirm()) {
return
}
this.$router.push({
path: '/pay',
query: {
mode: 'buyNow',
goodsId: this.goodsId,
goodsSkuId: this.detail.skuList[0].goods_sku_id,
goodsNum: this.addCount
}
})
}
}
}
</script>
<style lang="less" scoped>
.prodetail {
padding-top: 46px;
::v-deep .van-icon-arrow-left {
color: #333;
}
img {
display: block;
width: 100%;
}
.custom-indicator {
position: absolute;
right: 10px;
bottom: 10px;
padding: 5px 10px;
font-size: 12px;
background: rgba(0, 0, 0, 0.1);
border-radius: 15px;
}
.desc {
width: 100%;
overflow: scroll;
::v-deep img {
display: block;
width: 100%!important;
}
}
.info {
padding: 10px;
}
.title {
display: flex;
justify-content: space-between;
.now {
color: #fa2209;
font-size: 20px;
}
.oldprice {
color: #959595;
font-size: 16px;
text-decoration: line-through;
margin-left: 5px;
}
.sellcount {
color: #959595;
font-size: 16px;
position: relative;
top: 4px;
}
}
.msg {
font-size: 16px;
line-height: 24px;
margin-top: 5px;
}
.service {
display: flex;
justify-content: space-between;
line-height: 40px;
margin-top: 10px;
font-size: 16px;
background-color: #fafafa;
.left-words {
span {
margin-right: 10px;
}
.van-icon {
margin-right: 4px;
color: #fa2209;
}
}
}
.comment {
padding: 10px;
}
.comment-title {
display: flex;
justify-content: space-between;
.right {
color: #959595;
}
}
.comment-item {
font-size: 16px;
line-height: 30px;
.top {
height: 30px;
display: flex;
align-items: center;
margin-top: 20px;
img {
width: 20px;
height: 20px;
}
.name {
margin: 0 10px;
}
}
.time {
color: #999;
}
}
.footer {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
height: 55px;
background-color: #fff;
border-top: 1px solid #ccc;
display: flex;
justify-content: space-evenly;
align-items: center;
.icon-home, .icon-cart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
.van-icon {
font-size: 24px;
}
}
.btn-add,
.btn-buy {
height: 36px;
line-height: 36px;
width: 120px;
border-radius: 18px;
background-color: #ffa900;
text-align: center;
color: #fff;
font-size: 14px;
}
.btn-buy {
background-color: #fe5630;
}
}
}
.tips {
padding: 10px;
}
// 弹层的样式
.product {
.product-title {
display: flex;
.left {
img {
width: 90px;
height: 90px;
}
margin: 10px;
}
.right {
flex: 1;
padding: 10px;
.price {
font-size: 14px;
color: #fe560a;
.nowprice {
font-size: 24px;
margin: 0 5px;
}
}
}
}
.num-box {
display: flex;
justify-content: space-between;
padding: 10px;
align-items: center;
}
.btn, .btn-none {
height: 40px;
line-height: 40px;
margin: 20px;
border-radius: 20px;
text-align: center;
color: rgb(255, 255, 255);
background-color: rgb(255, 148, 2);
}
.btn.now {
background-color: #fe5630;
}
.btn-none {
background-color: #cccccc;
}
}
// 角标
.footer .icon-cart {
position: relative;
padding: 0 6px;
.num {
z-index: 999;
position: absolute;
top: -2px;
right: 0;
min-width: 16px;
padding: 0 4px;
color: #fff;
text-align: center;
background-color: #ee0a24;
border-radius: 50%;
}
}
</style>
6. src/utils/request.js
import store from '@/store'
// 自定义配置 - 请求/响应 拦截器
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 开启loading,禁止背景点击(节流处理,防止多次无效触发)
Toast.loading({
message: '加载中...',
forbidClick: true,
loadingType: 'spinner', // 配置loading图标
duration: 0 // 不会自动消失
})
// 只有有token,就在请求时携带,便于请求需要授权的接口
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
30. 购物车模块
说明:购物车 数据联动关系 较多,且通常会封装一些 小组件,所以为了便于维护,一般都会将购物车的数据 基于vuex分模块管理
需求分析:
1. 基本静态结构(快速实现)
2. 构建vuex cart模块,获取数据存储
3. 基于数据动态渲染购物车列表
4. 封装getters实现动态统计
5. 全选反选功能
6. 数字框修改数量功能
7. 编辑切换状态,删除功能
8. 空购物车处理
①src/utils/vant-ui.js
import { Checkbox } from 'vant';
Vue.use(Checkbox);
②新建 src/store/modules/cart.js 模块,封装action和mutation
import { changeCount, delSelect, getCartList } from '@/api/cart'
import { Toast } from 'vant'
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
// 提供一个设置cartList的mutation
setCartList (state, newList) {
state.cartList = newList
},
toggleCheck (state, goodsId) {
// 让对应的id的项的状态取反
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, { goodsId, goodsNum }) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.goods_num = goodsNum
}
},
actions: {
async getCartAction (context) {
const { data } = await getCartList()
// 后台返回的数据中,不包含复选框的选中状态,为了实现将来的功能
// 需要手动维护数据,给每一项,添加一个isChecked状态,标记当前商品是否选中
data.list.forEach(item => {
item.isChecked = true
})
context.commit('setCartList', data.list)
},
async changeCountAction (context, obj) {
const { goodsNum, goodsId, goodsSkuId } = obj
// 先本地修改
context.commit('changeCount', { goodsId, goodsNum })
// 再同步到后台
await changeCount(goodsId, goodsNum, goodsSkuId)
},
// 删除购物车数据
async delSelect (context) {
const selCartList = context.getters.selCartList
const cartIds = selCartList.map(item => item.id)
await delSelect(cartIds)
Toast('删除成功')
// 重新拉取最新的购物车数据(重新渲染)
context.dispatch('getCartAction')
}
},
getters: {
// 求所有的商品累加总数
cartTotal (state) {
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
// 选中的总数
selCount (state, getters) {
return getters.selCartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的总价
selPrice (state, getters) {
return getters.selCartList.reduce((sum, item) => sum + item.goods_num * item.goods.goods_price_min, 0).toFixed(2)
},
// 判断是否全部选中
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
③挂载到store上面,scr/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import cart from './modules/cart'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
getters: {
token (state) {
return state.user.userInfo.token
}
},
mutations: {
},
actions: {
},
modules: {
user,
cart
}
})
④封装API接口,src/api/cart.js
// 获取购物车列表
export const getCartList = () => {
return request.get('/cart/list')
}
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/update', {
goodsId,
goodsNum,
goodsSkuId
})
}
// 删除购物车商品
export const delSelect = (cartIds) => {
return request.post('/cart/clear', {
cartIds
})
}
⑤src/views/layout/cart.vue
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<div v-if="isLogin && cartList.length > 0">
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
<span class="edit" @click="isEdit = !isEdit">
<van-icon name="edit" />
编辑
</span>
</div>
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<van-checkbox @click="toggleCheck(item.goods_id)" :value="item.isChecked"></van-checkbox>
<div class="show">
<img :src="item.goods.goods_image" alt="">
</div>
<div class="info">
<span class="tit text-ellipsis-2">{{ item.goods.goods_name }}</span>
<span class="bottom">
<div class="price">¥ <span>{{ item.goods.goods_price_min }}</span></div>
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层 -->
<CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)" :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div @click="toggleAllCheck" class="all-check">
<van-checkbox :value="isAllChecked" icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
</div>
<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }" @click="goPay">结算({{ selCount }})</div>
<div v-else @click="handleDel" class="delete" :class="{ disabled: selCount === 0 }" >删除</div>
</div>
</div>
</div>
<div class="empty-cart" v-else>
<img src="@/assets/empty.png" alt="">
<div class="tips">
您的购物车是空的, 快去逛逛吧
</div>
<div class="btn" @click="$router.push('/')">去逛逛</div>
</div>
</div>
</template>
<script>
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
components: {
CountBox
},
data () {
return {
isEdit: false
}
},
computed: {
...mapState('cart', ['cartList']),
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked']),
isLogin () {
return this.$store.getters.token
}
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 小选控制全选
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 全选控制小选
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
},
async handleDel () {
if (this.selCount === 0) return
await this.$store.dispatch('cart/delSelect')
this.isEdit = false
},
goPay () {
// 判断有没有选中商品
if (this.selCount > 0) {
// 有选中的 商品 才进行结算跳转
this.$router.push({
path: '/pay',
query: {
mode: 'cart',
cartIds: this.selCartList.map(item => item.id).join(',') // 'cartId,cartId,cartId'
}
})
}
}
},
watch: {
// 监视编辑状态,动态控制复选框状态
isEdit (value) {
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</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;
}
}
}
}
.empty-cart {
padding: 80px 30px;
img {
width: 140px;
height: 92px;
display: block;
margin: 0 auto;
}
.tips {
text-align: center;
color: #666;
margin: 30px;
}
.btn {
width: 110px;
height: 32px;
line-height: 32px;
text-align: center;
background-color: #fa2c20;
border-radius: 16px;
color: #fff;
display: block;
margin: 0 auto;
}
}
</style>
效果:
31. 订单结算台
说明1:所有的结算,本质上就是跳转到“订单结算台”,并且,跳转的同时,需要携带上对应的订单相关参数,具体需要哪些参数,基于“订单结算台”的需求来定。
目标:封装通用的订单信息确认接口
说明2:这里的订单信息确认结算,有两种情况
- 1. 购物车结算
- 2. 立即购买结算
- 订单信息确认,可以共用同一个接口(参数不同)
①src/api/address.js
import request from '@/utils/request'
// 获取地址列表
export const getAddressList = () => {
return request.get('/address/list')
}
②src/api/order.js
import request from '@/utils/request'
// 订单信息确认
// mode: cart => obj { cartIds }
// mode: buyNow => obj { goodsId, goodsNum, goodsSkuId }
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // cart or buyNow
delivery: 10, // 10 快递, 20 门店自提
couponId: 0, // 优惠券id,传0表示不使用优惠券
isUsePoints: 0, // 积分,传0,表示不使用积分
...obj // 将传递过来的参数动态展开
}
})
}
// 提交订单
// 订单信息确认
// mode: cart => obj { cartIds, remark }
// mode: buyNow => obj { goodsId, goodsNum, goodsSkuId, remark }
export const submitOrder = (mode, obj) => {
return request.post('/checkout/submit', {
mode,
delivery: 10, // 配送方式-快递
couponId: 0, // 优惠券
isUsePoints: 0, // 积分
payType: 10, // 支付方式 - 余额支付
...obj
})
}
③src/views/pay/index.vue
<template>
<div class="pay">
<van-nav-bar fixed title="订单结算台" left-arrow @click-left="$router.go(-1)" />
<!-- 地址相关 -->
<div class="address">
<div class="left-icon">
<van-icon name="logistics" />
</div>
<div class="info" v-if="selectedAddress.address_id">
<div class="info-content">
<span class="name">{{ selectedAddress.name }}</span>
<span class="mobile">{{ selectedAddress.phone }}</span>
</div>
<div class="info-address">
{{ longAddress }}
</div>
</div>
<div class="info" v-else>
请选择配送地址
</div>
<div class="right-icon">
<van-icon name="arrow" />
</div>
</div>
<!-- 订单明细 -->
<div class="pay-list" v-if="order.goodsList">
<div class="list">
<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="tit text-ellipsis-2">
{{ item.goods_name }}
</p>
<p class="info">
<span class="count">x{{ item.total_num }}</span>
<span class="price">¥{{ item.total_pay_price }}</span>
</p>
</div>
</div>
</div>
<div class="flow-num-box">
<span>共 {{ order.orderTotalNum }} 件商品,合计:</span>
<span class="money">¥{{ order.orderTotalPrice }}</span>
</div>
<div class="pay-detail">
<div class="pay-cell">
<span>订单总金额:</span>
<span class="red">¥{{ order.orderTotalPrice }}</span>
</div>
<div class="pay-cell">
<span>优惠券:</span>
<span>无优惠券可用</span>
</div>
<div class="pay-cell">
<span>配送费用:</span>
<span v-if="!selectedAddress">请先选择配送地址</span>
<span v-else class="red">+¥0.00</span>
</div>
</div>
<!-- 支付方式 -->
<div class="pay-way">
<span class="tit">支付方式</span>
<div class="pay-cell">
<span><van-icon name="balance-o" />余额支付(可用 ¥ {{ personal.balance }} 元)</span>
<!-- <span>请先选择配送地址</span> -->
<span class="red"><van-icon name="passed" /></span>
</div>
</div>
<!-- 买家留言 -->
<div class="buytips">
<textarea v-model="remark" placeholder="选填:买家留言(50字内)" name="" id="" cols="30" rows="10"></textarea>
</div>
</div>
<!-- 底部提交 -->
<div class="footer-fixed">
<div class="left">实付款:<span>¥{{ order.orderTotalPrice }}</span></div>
<div class="tipsbtn" @click="submitOrder">提交订单</div>
</div>
</div>
</template>
<script>
import { getAddressList } from '@/api/address'
import { checkOrder, submitOrder } from '@/api/order'
import loginConfirm from '@/mixins/loginConfirm'
export default {
name: 'PayIndex',
mixins: [loginConfirm],
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
},
cartIds () {
return this.$route.query.cartIds
},
goodsId () {
return this.$route.query.goodsId
},
goodsSkuId () {
return this.$route.query.goodsSkuId
},
goodsNum () {
return this.$route.query.goodsNum
}
},
created () {
this.getAddressList()
this.getOrderList()
},
methods: {
async submitOrder () {
// 购物车结算
if (this.mode === 'cart') {
await submitOrder(this.mode, {
cartIds: this.cartIds,
remark: this.remark
})
}
// 立即购买
if (this.mode === 'buyNow') {
await submitOrder(this.mode, {
goodsId: this.goodsId,
goodsSkuId: this.goodsSkuId,
goodsNum: this.goodsNum,
remark: this.remark
})
}
this.$toast.success('支付成功')
this.$router.replace('/myorder')
},
async getAddressList () {
const { data: { list } } = await getAddressList()
this.addressList = list
},
async getOrderList () {
// 购物车结算
if (this.mode === 'cart') {
const { data: { order, personal } } = await checkOrder(this.mode, {
cartIds: this.cartIds
})
this.order = order
this.personal = personal
}
// 立刻购买结算
if (this.mode === 'buyNow') {
const { data: { order, personal } } = await checkOrder(this.mode, {
goodsId: this.goodsId,
goodsSkuId: this.goodsSkuId,
goodsNum: this.goodsNum
})
this.order = order
this.personal = personal
}
}
}
}
</script>
<style lang="less" scoped>
.pay {
padding-top: 46px;
padding-bottom: 46px;
::v-deep {
.van-nav-bar__arrow {
color: #333;
}
}
}
.address {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 20px;
font-size: 14px;
color: #666;
position: relative;
background: url(@/assets/border-line.png) bottom repeat-x;
background-size: 60px auto;
.left-icon {
margin-right: 20px;
}
.right-icon {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-7px);
}
}
.goods-item {
height: 100px;
margin-bottom: 6px;
padding: 10px;
background-color: #fff;
display: flex;
.left {
width: 100px;
img {
display: block;
width: 80px;
margin: 10px auto;
}
}
.right {
flex: 1;
font-size: 14px;
line-height: 1.3;
padding: 10px;
padding-right: 0px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
color: #333;
.info {
margin-top: 5px;
display: flex;
justify-content: space-between;
.price {
color: #fa2209;
}
}
}
}
.flow-num-box {
display: flex;
justify-content: flex-end;
padding: 10px 10px;
font-size: 14px;
border-bottom: 1px solid #efefef;
.money {
color: #fa2209;
}
}
.pay-cell {
font-size: 14px;
padding: 10px 12px;
color: #333;
display: flex;
justify-content: space-between;
.red {
color: #fa2209;
}
}
.pay-detail {
border-bottom: 1px solid #efefef;
}
.pay-way {
font-size: 14px;
padding: 10px 12px;
border-bottom: 1px solid #efefef;
color: #333;
.tit {
line-height: 30px;
}
.pay-cell {
padding: 10px 0;
}
.van-icon {
font-size: 20px;
margin-right: 5px;
}
}
.buytips {
display: block;
textarea {
display: block;
width: 100%;
border: none;
font-size: 14px;
padding: 12px;
height: 100px;
}
}
.footer-fixed {
position: fixed;
background-color: #fff;
left: 0;
bottom: 0;
width: 100%;
height: 46px;
line-height: 46px;
border-top: 1px solid #efefef;
font-size: 14px;
display: flex;
.left {
flex: 1;
padding-left: 12px;
color: #666;
span {
color:#fa2209;
}
}
.tipsbtn {
width: 121px;
background: linear-gradient(90deg,#f9211c,#ff6335);
color: #fff;
text-align: center;
line-height: 46px;
display: block;
font-size: 14px;
}
}
</style>
如果出现收货地址为空的问题,可以清空token,重新登录,手机号填:18917286702(这个号应该是实现了编辑收货地址)
效果:
32. 订单管理 & 个人中心(快速实现)
目标:基于笔记,快速实现订单管理和个人中心跑通流程
(1)订单管理
①src/utils/vant-ui.js
import { Tab, Tabs } from 'vant';
Vue.use(Tab);
Vue.use(Tabs);
②src/api/order.js
// 订单列表
export const getMyOrderList = (dataType, page) => {
return request.get('/order/list', {
params: {
dataType,
page // List
}
})
}
③src/views/myorder/index.vue
<template>
<div class="order">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.go(-1)" />
<van-tabs v-model="active" sticky>
<van-tab name="all" title="全部"></van-tab>
<van-tab name="payment" title="待支付"></van-tab>
<van-tab name="delivery" title="待发货"></van-tab>
<van-tab name="received" title="待收货"></van-tab>
<van-tab name="comment" title="待评价"></van-tab>
</van-tabs>
<OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
</div>
</template>
<script>
import OrderListItem from '@/components/OrderListItem.vue'
import { getMyOrderList } from '@/api/order'
export default {
name: 'OrderPage',
components: {
OrderListItem
},
data () {
return {
active: this.$route.query.dataType || 'all',
page: 1,
list: []
}
},
methods: {
async getOrderList () {
const { data: { list } } = await getMyOrderList(this.active, this.page)
list.data.forEach((item) => {
item.total_num = 0
item.goods.forEach(goods => {
item.total_num += goods.total_num
})
})
this.list = list.data
}
},
watch: {
active: {
immediate: true,
handler () {
this.getOrderList()
}
}
}
}
</script>
<style lang="less" scoped>
.order {
background-color: #fafafa;
}
.van-tabs {
position: sticky;
top: 0;
}
</style>
④src/components/OrderListItem.vue
<template>
<div class="order-list-item" v-if="item.order_id">
<div class="tit">
<div class="time">{{ item.create_time }}</div>
<div class="status">
<span>{{ item.state_text }}</span>
</div>
</div>
<div class="list" >
<div class="list-item" v-for="(goods, index) in item.goods" :key="index">
<div class="goods-img">
<img :src="goods.goods_image" alt="">
</div>
<div class="goods-content text-ellipsis-2">
{{ goods.goods_name }}
</div>
<div class="goods-trade">
<p>¥ {{ goods.total_pay_price }}</p>
<p>x {{ goods.total_num }}</p>
</div>
</div>
</div>
<div class="total">
共 {{ item.total_num }} 件商品,总金额 ¥{{ item.total_price }}
</div>
<div class="actions">
<div v-if="item.order_status === 10">
<span v-if="item.pay_status === 10">立刻付款</span>
<span v-else-if="item.delivery_status === 10">申请取消</span>
<span v-else-if="item.delivery_status === 20 || item.delivery_status === 30">确认收货</span>
</div>
<div v-if="item.order_status === 30">
<span>评价</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {
return {}
}
}
}
}
</script>
<style lang="less" scoped>
.order-list-item {
margin: 10px auto;
width: 94%;
padding: 15px;
background-color: #ffffff;
box-shadow: 0 0.5px 2px 0 rgba(0,0,0,.05);
border-radius: 8px;
color: #333;
font-size: 13px;
.tit {
height: 24px;
line-height: 24px;
display: flex;
justify-content: space-between;
margin-bottom: 20px;
.status {
color: #fa2209;
}
}
.list-item {
display: flex;
.goods-img {
width: 90px;
height: 90px;
margin: 0px 10px 10px 0;
img {
width: 100%;
height: 100%;
}
}
.goods-content {
flex: 2;
line-height: 18px;
max-height: 36px;
margin-top: 8px;
}
.goods-trade {
flex: 1;
line-height: 18px;
text-align: right;
color: #b39999;
margin-top: 8px;
}
}
.total {
text-align: right;
}
.actions {
text-align: right;
span {
display: inline-block;
height: 28px;
line-height: 28px;
color: #383838;
border: 0.5px solid #a8a8a8;
font-size: 14px;
padding: 0 15px;
border-radius: 5px;
margin: 10px 0;
}
}
}
</style>
效果:
(2)个人中心
①src/api/user.js
import request from '@/utils/request'
// 获取个人信息
export const getUserInfoDetail = () => {
return request.get('/user/info')
}
②src/store/modules/user.js
import { getInfo, setInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: getInfo()
}
},
mutations: {
// 所有mutations的第一个参数都是state
setUserInfo (state, obj) {
state.userInfo = obj
setInfo(obj)
}
},
actions: {
logout (context) {
// 重置个人信息
context.commit('setUserInfo', {})
// 重置购物车信息(跨模块调用mutation) cart/setCartList
context.commit('cart/setCartList', [], { root: true })
}
},
getters: {
}
}
③src/views/layout/user.vue
<template>
<div class="user">
<div class="head-page" v-if="isLogin">
<div class="head-img">
<img src="@/assets/default-avatar.png" alt="" />
</div>
<div class="info">
<div class="mobile">{{ detail.mobile }}</div>
<div class="vip">
<van-icon name="diamond-o" />
普通会员
</div>
</div>
</div>
<div v-else class="head-page" @click="$router.push('/login')">
<div class="head-img">
<img src="@/assets/default-avatar.png" alt="" />
</div>
<div class="info">
<div class="mobile">未登录</div>
<div class="words">点击登录账号</div>
</div>
</div>
<div class="my-asset">
<div class="asset-left">
<div class="asset-left-item">
<span>{{ detail.pay_money || 0 }}</span>
<span>账户余额</span>
</div>
<div class="asset-left-item">
<span>0</span>
<span>积分</span>
</div>
<div class="asset-left-item">
<span>0</span>
<span>优惠券</span>
</div>
</div>
<div class="asset-right">
<div class="asset-right-item">
<van-icon name="balance-pay" />
<span>我的钱包</span>
</div>
</div>
</div>
<div class="order-navbar">
<div class="order-navbar-item" @click="$router.push('/myorder?dataType=all')">
<van-icon name="balance-list-o" />
<span>全部订单</span>
</div>
<div class="order-navbar-item" @click="$router.push('/myorder?dataType=payment')">
<van-icon name="clock-o" />
<span>待支付</span>
</div>
<div class="order-navbar-item" @click="$router.push('/myorder?dataType=delivery')">
<van-icon name="logistics" />
<span>待发货</span>
</div>
<div class="order-navbar-item" @click="$router.push('/myorder?dataType=received')">
<van-icon name="send-gift-o" />
<span>待收货</span>
</div>
</div>
<div class="service">
<div class="title">我的服务</div>
<div class="content">
<div class="content-item">
<van-icon name="records" />
<span>收货地址</span>
</div>
<div class="content-item">
<van-icon name="gift-o" />
<span>领券中心</span>
</div>
<div class="content-item">
<van-icon name="gift-card-o" />
<span>优惠券</span>
</div>
<div class="content-item">
<van-icon name="question-o" />
<span>我的帮助</span>
</div>
<div class="content-item">
<van-icon name="balance-o" />
<span>我的积分</span>
</div>
<div class="content-item">
<van-icon name="refund-o" />
<span>退换/售后</span>
</div>
</div>
</div>
<div class="logout-btn">
<button v-if="isLogin" @click="logout">退出登录</button>
</div>
</div>
</template>
<script>
import { getUserInfoDetail } from '@/api/user.js'
export default {
name: 'UserPage',
data () {
return {
detail: {}
}
},
created () {
if (this.isLogin) {
this.getUserInfoDetail()
}
},
computed: {
isLogin () {
return this.$store.getters.token
}
},
methods: {
async getUserInfoDetail () {
const { data: { userInfo } } = await getUserInfoDetail()
this.detail = userInfo
},
logout () {
this.$dialog.confirm({
title: '温馨提示',
message: '你确认要退出么'
}).then(() => {
// 退出是一个动作 => 包含了两步,分别是将 user 和 cart 进行重置
this.detail = {}
this.$store.dispatch('user/logout')
}).catch(() => {})
}
}
}
</script>
<style lang="less" scoped>
.user {
min-height: 100vh;
background-color: #f7f7f7;
padding-bottom: 50px;
}
.head-page {
height: 130px;
background: url("http://cba.itlike.com/public/mweb/static/background/user-header2.png");
background-size: cover;
display: flex;
align-items: center;
.head-img {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
margin: 0 10px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.info {
.mobile {
margin-bottom: 5px;
color: #c59a46;
font-size: 18px;
font-weight: bold;
}
.vip {
display: inline-block;
background-color: #3c3c3c;
padding: 3px 5px;
border-radius: 5px;
color: #e0d3b6;
font-size: 14px;
.van-icon {
font-weight: bold;
color: #ffb632;
}
}
}
.my-asset {
display: flex;
padding: 20px 0;
font-size: 14px;
background-color: #fff;
.asset-left {
display: flex;
justify-content: space-evenly;
flex: 3;
.asset-left-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
span:first-child {
margin-bottom: 5px;
color: #ff0000;
font-size: 16px;
}
}
}
.asset-right {
flex: 1;
.asset-right-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.van-icon {
font-size: 24px;
margin-bottom: 5px;
}
}
}
}
.order-navbar {
display: flex;
padding: 15px 0;
margin: 10px;
font-size: 14px;
background-color: #fff;
border-radius: 5px;
.order-navbar-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 25%;
.van-icon {
font-size: 24px;
margin-bottom: 5px;
}
}
}
.service {
font-size: 14px;
background-color: #fff;
border-radius: 5px;
margin: 10px;
.title {
height: 50px;
line-height: 50px;
padding: 0 15px;
font-size: 16px;
}
.content {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
font-size: 14px;
background-color: #fff;
border-radius: 5px;
.content-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 25%;
margin-bottom: 20px;
.van-icon {
font-size: 24px;
margin-bottom: 5px;
color: #ff3800;
}
}
}
}
.logout-btn {
button {
width: 60%;
margin: 10px auto;
display: block;
font-size: 13px;
color: #616161;
border-radius: 9px;
border: 1px solid #dcdcdc;
padding: 7px 0;
text-align: center;
background-color: #fafafa;
}
}
</style>
效果:
33. 打包发布
目标:明确打包的作用
说明:vue脚手架只是开发过程中,协助开发的工具,当真正开发完成了,脚手架不参与上线。
打包的作用:
①将多个文件压缩合并成一个文件;
②语法降级
③less sass ts语法解析
打包后,可以生成浏览器能够直接运行的网页,就是需要上线的源码
目标:打包的命令和配置
说明:vue脚手架工具已经提供了打包命令,直接使用即可。
①配置:默认情况下,需要放到服务器根目录打开,如果希望双击运行,需要配置publicPath配成相对路径
vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
publicPath: './', // 相对路径
transpileDependencies: true
})
②打包构建
命令: npm run build
结果:在项目的根目录会自动创建一个文件夹`dist`,dist中的文件就是打包后的文件,只需要放到服务器中即可。
③结果:将打包后的dist文件夹里的内容上传到服务器当中,双击index.html在浏览器中即可查看项目
34. 打包优化:路由懒加载
目标:配置路由懒加载,实现打包优化
说明:当打包构建应用时,JavaScript包会变得非常大,影响页面加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后然后当路由被访问的时候才加载对应组件,这样就更加高效率。
步骤1:异步组件改造 => src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Layout from '@/views/layout'
import Home from '@/views/layout/home'
import Category from '@/views/layout/category'
import Cart from '@/views/layout/cart'
import User from '@/views/layout/user'
import store from '@/store'
const Login = () => import('@/views/login')
const Search = () => import('@/views/search')
const SearchList = () => import('@/views/search/list')
const ProDetail = () => import('@/views/prodetail')
const Pay = () => import('@/views/pay')
const MyOrder = () => import('@/views/myorder')
Vue.use(VueRouter)
步骤2:路由中应用
const router = new VueRouter({
routes: [
...
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/pay', component: Pay },
...
]
})
步骤3:构建
npm run build
结果:
至此,Vue2学习告一段落,接下来进入Vue3的学习。