智慧商城项目
智慧商城项目
以下为学习过程中的极简提炼笔记,以供重温巩固学习
学习准备
准备工作
学习目的
Vue2阶段练手项目:
- 目的1:巩固之前所学的vue核心基础语法,包括route、vuex等
- 目的2:商城项目,掌握完整的商品购物流程
理解程度自评:
- 阶段1:看不懂
- 阶段2:看懂了,复述不出来
- 阶段3:看懂了,能复述,记不住大致的方法
- 阶段4:看懂了,能复述,记住了大致的方法,不懂怎么提问让GPT帮我写
智慧商城项目
项目演示
- 目标:查看项目效果,明确功能模块 → 完整的电商购物流程
- 核心:购物车环节,应用vuex
项目收获
- 目标:明确做完本项目,能够收获哪些内容
- 收获:
- 完成本项目后,就是vue3、小程序等下个阶段
- 学会组件库全部/按需部分导入
- 移动端适配
- 封装请求方法,拆解请求方法,分类控制不同模块请求操作
- 存储模块封装,刷新时保持数据不丢失
- api请求与页面逻辑分离开来,独立封装api请求
- 请求拦截器统一处理
- 路由相关,嵌套路由配置
- 路由导航守卫,拦截用户,禁止访问没有权限访问的页面
- 路由跳转传参,包括查询参数/动态路由传参
- vuex分模块管理数据,购物车模块
- 打包
创建项目
目标:基于 VueCli 自定义创建项目架子
选择本项目需要使用到的核心模块
- Babel:语法降级,翻译解析
- Router:路由管理配置
- Vuex:状态管理配置
- CSS:预处理器配置
- Linter:代码规范配置
I:\smartMall>vue create mail
Vue CLI v5.0.8
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, Router, Vuex, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 2.x
? Use history mode for router? (Requires proper server setup for index fallback in production) No
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Less
? Pick a linter / formatter config: Standard
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No
Vue CLI v5.0.8
✨ Creating project in I:\smartMall\mail.
🗃 Initializing git repository...
⚙️ Installing CLI plugins. This might take a while...
🚀 Invoking generators...
📦 Installing additional dependencies...
⚓ Running completion hooks...
📄 Generating README.md...
🎉 Successfully created project mail.
👉 Get started with the following commands:
$ cd mail
$ pnpm run serve
调整初始化目录
- 目标:改造脚手架自动生成的目录,将目录调整成符合企业规范的目录
- 删除 多余的文件/业务
- 修改 路由配置 和 App.vue
- 新增 两个目录 api / utils
- ① api 接口模块:发送ajax请求的接口模块,单独封装,分离存放
- ② utils 工具模块:自己封装的一些工具方法模块
vant 组件库
目标:认识第三方 Vue组件库 vant-ui
背景:
- 实际开发中,会遇到需要引入现成的、已封装好的组件库的场景,如日期选择、数字框、评分、注册表单等
- 组件库是已封装好的控件集合,便于导入复用,提升开发效率
组件库:第三方 封装 好了很多很多的 组件,整合到一起就是一个组件库。
Vant:
- 定义:Vant 是一个轻量、可定制的移动端组件库,于 2017 年开源。
- 目前 Vant 官方提供了 Vue 2 版本(Vant2)、Vue 3(Vant3、Vant4) 版本和微信小程序版本,并由社区团队维护 React 版本和支付宝小程序版本
- Vant2
- Vant4 Github 也就是项目默认地址 https://vant-ui.github.io/vant/#/zh-CN
- Vant4 码云
其他 Vue 组件库
目标:了解其他 Vue 组件库
- Vue的组件库并不是唯一的,vant-ui 也仅仅只是组件库的一种。
组件库一般会按照不同平台进行分类:(也就是组件库的原生主维护平台)
- ① PC端:
- element-ui 基于 Vue 2.x
- element-plus 基于 Vue 3.x
- ant-design-vue 同时支持vue2和vue3
- ② 移动端:
- 组件库的具体使用方式大同小异,参见组件库的官方文档
- ① PC端:
vant 全部导入 和 按需导入
目标:
- 明确 全部导入 和 按需导入 的区别
使用方式:
- 使用组件库的方式分为2种:全部导入&按需导入
- 理清组件库使用逻辑:
- 可以认为是一个插件/组件工具箱,先下包/CDN引入,再根据实际情况来选择按需/全局导入
- 根据不同项目的特性(桌面/移动端),不同项目实际使用需要,选择引入方式
- 全部导入:相对方便、完整
- 在项目下的任意页面,都可以直接使用
- 所有组件都可以使用(65+组件)
- 因此性能相对低
- 按需导入(官方推荐):性能更优、体积更小
- 用哪个导哪个,代码体积小,上线时加载速度/用户访问性能更好
- vant分自动/手动按需导入,按需导入后将不允许全局导入,相斥
- 按需导入写代码相对麻烦一点点
- 移动端性能优先,优先考虑按需导入
- 使用步骤:
- 官网:vant-ui 见 Vant2、Vant4 Github
- 按指南操作,参见快速上手
vant-ui导入方式案例(vue2)
- 阅读组件库的官方文档,掌握全部导入的基本使用
① 安装 vant-ui
yarn add vant@latest-v2
// 或
pnpm add vant@latest-v2
② main.js 中注册
// 核心包导入
import Vant from 'vant'
// 样式导入
import 'vant/lib/index.css'
// 插件安装初始化:内部会将所有的vant所有组件进行导入注册,把vant中所有的组件都导入了
Vue.use(Vant)
③ 使用测试
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
<van-button type="default">默认按钮</van-button>
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 核心包导入
import Vant from 'vant'
// 样式导入
import 'vant/lib/index.css'
// 插件安装初始化:内部会将所有的vant所有组件进行导入注册,把vant中所有的组件都导入了
Vue.use(Vant)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
<template>
<div id="app">
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
<van-button type="default">默认按钮</van-button>
<van-switch v-model="checked" />
<router-view/>
</div>
</template>
<script>
export default {
data () {
return {
checked: true
}
}
}
</script>
<style lang="less">
</style>
- 阅读组件库的官方文档,掌握按需导入的基本使用
- 通过babel-plugin-import插件,实现自动按需导入,可维护性更好
- 原理:该插件会在打包编译过程中,将设置的导入语法,转换成按需引入的方式
-D
为安装成开发依赖
① 安装 vant-ui (已安装)
yarn add vant@latest-v2
// 或
pnpm add vant@latest-v2
② 安装插件(需要安装babel-plugin-import插件)
npm i babel-plugin-import -D
// 或
pnpm add babel-plugin-import -D
③ babel.config.js中配置babel-plugin-import插件
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
],
plugins: [
['import', {
libraryName: 'vant',
libraryDirectory: 'es',
style: true
}, 'vant']
]
}
④ main.js 按需导入注册(遇到报错学会解决,如果报Unknown custom element一般是使用了组件但没有成功引入)
import Vue from 'vue';
import { Button } from 'vant';
Vue.use(Button);
⑤ 测试使用
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
⑥ 随着引入的组件变多,可将同一组件库引入的组件,抽离到一个文件vant-ui.js中,专门用于配置对应组件库的按需导入情况,再在main.js导入
// 引入配置按需导入组件库的配置文件
import '@/utils/vant-ui'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
// 按需导入
import { Button, Rate, Switch } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
<template>
<div id="app">
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
<van-button type="default">默认按钮</van-button>
<van-switch v-model="checked" />
<van-rate v-model="value" />
<router-view/>
</div>
</template>
<script>
export default {
data () {
return {
checked: true,
value: 3
}
}
}
</script>
<style lang="less">
</style>
================================
// 抽离到vant-ui.js
import Vue from 'vue'
// 按需导入
import { Button, Rate, Switch } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
===============================
// 原main.js引入抽离后的文件vant-ui.js
import '@/utils/vant-ui'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
================================
- 附:另一个是手动按需导入,不推荐,因为每次都需要手动导入一下css样式,一般不使用手动按需,除非项目需要
- 另外组件库一般支持通过脚手架cli安装,例如vue可以通过
vue ui
打开图形化界面,作依赖安装
- 另外组件库一般支持通过脚手架cli安装,例如vue可以通过
项目中的 vw 适配
目标:
- 基于 postcss 插件 实现项目 vw 适配,解决移动端适配问题
背景:智慧商城项目,需要适配移动端
- 移动设备界面显示,需要根据设备实际情况匹配,不同设备硬件显示屏情况不一样
- 需要统一不同设备的展示效果,比如宽度高度等
- Vant 默认使用 px 作为样式单位,如果需要使用 viewport 单位 (vw, vh, vmin, vmax),推荐使用 postcss-px-to-viewport 进行转换。
vw定义:
- vw 为css3新单位,100vw相当于屏幕宽度
- vw 本质上是一个相对比例显示单位,同样的vw值,能在不同的设备上,按同样的vw比例(即代码写的像素与vm适配的标准屏宽度的比例)适配宽高
- 通过vw单位,将设计稿/JavaScript设计/实际代码编写 的真实px像素宽高,按设计稿适配的标准屏宽度作为相对坐标系基准,转换成相对的比例显示单位vm
- 渲染时,按照实际屏幕尺寸,适配根据设计稿标准宽转换得来的vw单位,最终以相同的比例,显示在不同尺寸的设备上
- 使用 vw 单位时,需要通过插件,完成从 px像素单位 到 CSS3标准下的相对vw单位 转换
PostCSS插件
- 定义:
- postcss-px-to-viewport 是一款 PostCSS 插件,用于将 px 单位转化为 vw/vh 单位,可在脚手架中使用
- 是一个用 JavaScript 转换 CSS 的工具,实现px像素到vm相对宽度单位的自动转换
- 官网:https://postcss.org/、https://www.postcss.com.cn/,
- 定义:
① 安装PostCSS插件
// 以-D开发依赖安装
yarn add postcss-px-to-viewport@1.1.1 -D
// 或
pnpm add postcss-px-to-viewport@1.1.1 -D
② 根目录新建 postcss.config.js 文件,填入配置
module.exports = {
plugins: {
'postcss-px-to-viewport': {
// vm适配的标准屏宽度,375像素=iPhone X
// 设计图 750,调成1倍 => 适配375标准屏幕
// 设计图 640,调成1倍 => 适配320标准屏幕
viewportWidth: 375
}
}
}
- 备注:
- 如果拿到的设计图是750的,可以在对应的设计图软件中(如:蓝湖),将二倍图尺寸调成一倍,就是375的屏幕
- 后续在一倍图中,在设计图上量取多少,在开发时就能在页面写多少,写一样的标准屏宽度
- 二倍图%2等于标准屏宽度=一倍图
尺寸适配测试:
<template>
<div id="app">
<van-button type="primary">主要按钮</van-button>
<van-button type="info">信息按钮</van-button>
<van-button type="default">默认按钮</van-button>
<van-switch v-model="checked" />
<van-rate v-model="value" />
<div class="box"></div>
<router-view/>
</div>
</template>
<script>
export default {
data () {
return {
checked: true,
value: 3
}
}
}
</script>
<style lang="less">
.box{
width: 300px;
height: 300px;
background-color: rgb(0, 128, 36);
}
</style>
- 效果:
- 调试中拖动窗口大小/改变调试设备,不影响元素的相对大小
- 通过元素审查,在调试中发现,元素单位是vm,不随窗口大小变化而改变vm值,而随窗口大小变化动态比例适配尺寸
- 开发时,在
</style>
中,仍然写的是设计尺寸(实际上在设计尺寸中规定了标准屏宽度) - 开发移动端必须要用vw适配
路由设计配置(分析并配置一级路由)
目标:
- 分析项目页面,设计路由,配置一级路由
路由与页面思路:
- 先配路由,写路由对应的页面,再往页面加功能
- 先配一级路由,再配二级路由
- 但凡是单个页面,独立展示的,都是一级路由
- 框架页下的标签页,可以理解为二级路由
- 存在嵌套关系的都可以理解为二级路由
独立/单独展示的单个页面(一级路由)包括:
- 登录页
- 首页大框架组件
- 搜索页(搜索前点击搜索框时出来的搜索页,包含历史记录)
- 搜索后展示的搜索列表
- 商品单品详情页
- 结算支付页
- 订单管理页
标签页(二级路由)包括:
- 首页大框架组件下的内容组件:首页组件、分类页组件、购物车页组件、我的页组件
- 重点:
- 但凡是单个页面,独立展示的,都是一级路由
- 养成习惯,随着项目开发,功能、结构、模块都会越来越复杂,有可能一个页面或者一个模块功能就很复杂,会封装属于对应页面/模块的组件
- 因此,在新建页面时,更推荐将每个一级页面/模块,新建成文件夹
- 每一个一级模块文件夹,都新建主模块文件index.vue
- 在vscode中,通过安装插件,快速生成模板代码
- vetur插件:使用时输入
<vue>
- Vue VSCode Snippets插件:使用时输入
<vbase>
- VScode自带:左上角文件 ==> 首选项 ==> 配置代码片段,手写自己的配置
- vetur插件:使用时输入
- 如果引入加载的文件,是该模块文件夹下的名为index.vue的根文件(index开头),在引入时的路径,只需要写到该文件夹即可,引入时,会自动寻找index
- 存在嵌套关系的都可以理解为二级路由
配置路由规则 router/index.js 一级路由配置
import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
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'
import SearchList from '@/views/search/list.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
routes: [
{ path: '/login', component: Login },
{ path: '/', component: Layout },
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
export default router
分析通过借助引入组件实现路由切换
目标:
- 阅读vant组件库文档,实现底部导航 tabbar
分析:
- 二级路由,在页面展示上,一般借助导航实现,点击不同的导航按钮,页面切换不同的二级路由
- 先配置导航模块,通过vant组件库实现导航模块配置
基于vant组件库,使用现成的导航组件 tabbar标签页组件
- 在组件引入配置文档vant-ui.js中配置引入组件
- 参考组件库的官方文档,定制文字、图标、颜色,组件库中有图标组件
① vant-ui.js 按需引入
import { Tabbar, TabbarItem } from 'vant'
Vue.use(Tabbar)
Vue.use(TabbarItem)
② layout/index.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 icon="apps-o">分类页</...>
<van-tabbar-item icon="shopping-cart-o">购物车</...>
<van-tabbar-item icon="user-o">我的</...>
</van-tabbar>
路由设计配置(基于组件配置二级路由)
目标:
- 基于底部导航,完成二级路由配置
分析:
- 准备二级路由组件 & 配置二级路由规则(在children中配置,格式为数组[]包对象{})
- 配置导航链接,以实现点击导航组件的不同tab页,完成路由切换
- 通过组件库官方文档确认,tabbar导航组件支持路由模式,需要加上route属性,用于搭配vue-router使用,路由模式下会匹配to属性
- 配置路由出口标签对,以及路由出口路径,组件内预留路由插槽实现路由切换
<template>
<div>Cart</div>
</template>
<script>
export default {
// 模块导出名
name: 'CartIndex'
}
</script>
<style>
</style>
======================
<template>
<div>Category</div>
</template>
<script>
export default {
name: 'CategoryIndex'
}
</script>
<style>
</style>
======================
<template>
<div>Home</div>
</template>
<script>
export default {
name: 'HomeIndex'
}
</script>
<style>
</style>
======================
<template>
<div>User</div>
</template>
<script>
export default {
name: 'UserIndex'
}
</script>
<style>
</style>
import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
import Layout from '@/views/layout'
import Cart from '@/views/layout/cart.vue'
import Category from '@/views/layout/category.vue'
import Home from '@/views/layout/home.vue'
import User from '@/views/layout/user.vue'
import MyOrder from '@/views/myorder'
import Pay from '@/views/pay'
import ProDetail from '@/views/prodetail'
import Search from '@/views/search'
import SearchList from '@/views/search/list.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const router = new VueRouter({
// 路由配置中,component中写的组件名字,可以不等于组件中的name属性
// 但是component中写的组件名字需要等于import 引入 from 的名字
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
// 重定向到home页
redirect: '/home',
// 二级子路由页面写在children中,格式为数组[]包对象{}
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
export default router
<template>
<!-- <div>首页大框架LayoutIndex</div> -->
<div>
<!-- 二级路由出口:二级组件展示的位置 -->
<router-view></router-view>
<van-tabbar route active-color="#ee0a24" inactive-color="#000">
<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>
附:gpt关于component属性的回答
VueRouter中的component属性,需要等于组件名字吗,还是需要等于路径名字,还是需要等于组件的name属性?
VueRouter 中的 component 属性需要等于组件的 名称。
在 Vue.js 中,组件的名称可以通过两种方式定义:
**全局注册组件:**使用 Vue.component() 方法全局注册组件,组件名称必须是一个字符串。例如:
Copy
Vue.component('my-component', {
// 组件定义
})
**局部注册组件:**在 Vue 实例中使用 <component> 标签局部注册组件,组件名称可以使用字符串或一个包含组件选项对象的 JavaScript 表达式。例如:
Copy
const MyComponent = {
// 组件定义
}
new Vue({
components: {
'my-component': MyComponent
}
})
在 VueRouter 中,component 属性接受一个组件名称作为值。该组件名称可以是全局注册的组件名称,也可以是局部注册的组件名称。
因此,VueRouter 中的 component 属性需要等于组件的名称,而不是路径名称或组件的 name 属性。
例如,如果你有一个名为 MyComponent 的组件,并且你希望在路由 /my-route 中使用它,那么你的路由配置应该如下所示:
Copy
const routes = [
{
path: '/my-route',
component: 'MyComponent'
}
]
希望这个解释对你有帮助!
登录页静态布局
目标:
- 基于笔记,快速实现登录页静态布局
重点:
- 整理样式文件,在src目录下,新建一个styles样式文件夹,用于存放样式
- 后续在使用样式时,通过该目录引入样式
- 后续项目中的公共样式,都可以集中打包提炼到styles样式文件夹下,使用时再引入
- 整理需要引入的图片素材,在src目录下,新建一个assets图片资源文件夹,用于存放项目中需要引入的图片
- 后续在使用图片时,通过该目录引入
- 后续项目中的图片,都可以集中打包提炼到assets图片资源文件夹
- 整理样式文件,在src目录下,新建一个styles样式文件夹,用于存放样式
- 准备工作
- (1) 新建
styles/common.less
重置默认样式 - (2) main.js 导入 common.less
- (3) 图片素材拷贝到 assets 目录【备用】
- 登录页静态布局编写 view/login/index.vue
- (1) 头部组件说明 (NavBar)
- 在vant-ui.js组件库配置中,引入头部组件
- 在页面.vue中使用
- (2) 通用样式覆盖
- 通过浏览器调试 > 审查元素,去确认当前组件的样式的CSS类名,.van-nav-bar__arrow
- 为了增加权重,在外面再套一层.van-nav-bar
- 如果还不行,就换成伪元素.van-nav-bar__arrow::before
- 再去在styles/common.less加入全局通用样式设置
- 行内标签 > id属性选择器 > 类选择器 > 标签选择器 > 通用选择器
- (3) 其他静态结构编写
// 重置默认样式
* {
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;
// }
// }
// 换成伪元素
// 添加导航的通用样式
.van-nav-bar {
.van-nav-bar__arrow::before {
color: #333;
}
}
import '@/styles/common.less'
import '@/utils/vant-ui'
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
import Vue from 'vue'
// 按需导入
import { Button, NavBar, Rate, Switch, Tabbar, TabbarItem } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
<template>
<div class="login">
<!-- 头部 使用van-nav-bar -->
<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 class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img src="@/assets/code.png" alt="">
</div>
<div class="form-item">
<input class="inp" placeholder="请输入短信验证码" type="text">
<button>获取验证码</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
export default {
name: 'LoginPage'
}
</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>
request模块 - axios 封装
背景:登录页静态布局已经完成,接着是登录逻辑
分析:登录页有图形验证码、短信验证码、登录按钮,共计3个地方是根据请求动态变化的,因此应该是要发3个请求获取
目标:将 axios 请求方法,封装到 request 模块
重点:
- 做项目时,通常将 axios 请求方法,封装到 request 模块
- 使用 axios 来请求后端接口, 一般都会对 axios 进行 一些配置 (比如: 配置基础地址,请求响应拦截器等)
- 所以项目开发中, 都会对 axios 进行基本的二次封装, 单独封装到一个 request 模块中, 便于维护使用
封装必要性理解:
- 随着配置增多,需要将不同的、但又在项目中多次复用的配置,集合并封装成模块
- 将请求相关的写成一个模块整体(即:新建成一个axios 实例),便于维护使用,方便业务分离
- 每个实例之间互不影响,对const创建出来的实例,进行自定义配置,单独在实例中封装配置模块规则,不污染axios
- 后续import这个const创建出来的实例,通过
实例.方法(传参)
直接调用封装好的模块 - 以后可能出现根据地址来封装模块,一个功能可能请求多个后端地址多个服务器
封装步骤:
- 安装&引入axios
- 新建request模块:在工具组件目录utils/request.js下新建
- 在request.js中创建实例,const创建实例instance,并向实例添加请求、响应拦截器
- 基于项目实际业务需要,配置基础地址,请求响应拦截器等,导出实例
- 根据接口文档测试使用,在页面中导入模块
资料:
- 接口文档地址:https://apifox.com/apidoc/shared-12ab6b18-adc2-444c-ad11-0e60f5693f66/doc-2221080
- 基地址:http://smart-shop.itheima.net/index.php?s=/api
- axios实例:https://www.axios-http.cn/docs/instance
- axios请求拦截器:https://www.axios-http.cn/docs/interceptors
- 注意:需将axios改为新const创建的实例instance
pnpm add axios
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 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
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
<template>
<div class="login">
<!-- 头部 使用van-nav-bar -->
<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 class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img src="@/assets/code.png" alt="">
</div>
<div class="form-item">
<input class="inp" placeholder="请输入短信验证码" type="text">
<button>获取验证码</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
import request from '@/utils/request.js'
export default {
name: 'LoginPage',
async created () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 根据接口文档,获取图形验证码
const res = await request.get('/captcha/image')
console.log(res)
}
}
</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>
图形验证码点击获取及渲染功能
目标:
- 基于请求回来的 base64 图片,实现图形验证码功能
说明:
- 图形验证码,本质就是一个请求回来的图片
- 用户将来输入图形验证码,用于强制人机交互,可以抵御机器自动化攻击 (例如:避免批量请求获取短信)
需求:
- 动态将请求回来的 base64 图片,解析渲染出来
- 点击验证码图片盒子,要刷新验证码
分析:
- 从打印的接口返回数据分析,需要用到data.base64和data.key
- data.base64用于渲染验证码图片
- data.key是图形验证码的key,用于后续向后台提交短信验证码时,作唯一标识验证
<template>
<div class="login">
<!-- 头部 使用van-nav-bar -->
<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 class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<!-- v-model="picCode"绑定表单后续用户输入后可以提交 -->
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<!-- v-if="picUrl"有图片返回的时候才渲染,点击时重新请求getPicCode -->
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input class="inp" placeholder="请输入短信验证码" type="text">
<button>获取验证码</button>
</div>
</div>
<div class="login-btn">登录</div>
</div>
</div>
</template>
<script>
import request from '@/utils/request.js'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '' // 用户输入的图形验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构
const { data: { base64, key } } = await request.get('/captcha/image')
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
}
}
}
</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>
api 接口模块 - 封装图片验证码接口
- 目标:
- 将请求封装成方法/函数,统一存放到 api 模块,与页面分离
- 封装api模块的好处:
- 请求与页面逻辑分离
- 相同的请求可以直接复用
- 请求进行了统一管理
- 做页面专注于页面,做逻辑的话到api请求中,实现分离,页面中对应位置只保留一个函数调用,请求都是基于接口文档去编写和管理
意义:
- 中大型项目中,一个页面会有很多请求,页面中充斥着请求代码,可阅读性不高
- 通过将封装好的请求方法/函数,统一存放到 api 模块 ,便于对相同的请求,作复用
- 统一管理请求,方便维护修改更新,例如统一修改请求路径,统一修改返回的响应格式等等,方便定位接口
- 做页面专注于页面,做逻辑的话到api请求中,实现分离,页面中对应位置只保留一个函数调用,请求都是基于接口文档去编写和管理
影响:
- 将方法封装成api后,单一页面的可读性可能会降低,单一页面逻辑变差,但是方便了对方法的管理
步骤:
新建请求模块:
- 项目src文件夹下新建api目录,用于后续存放封装好的请求函数
- 按功能/用途/模块/组件/引入使用的工具,划分创建api模块
- 如新建src/api/login.js,作为登录相关逻辑的接口api请求
封装请求函数
- 到页面中寻找能抽离封装的请求,例如改造后的axios请求
request.get('/captcha/image')
,包含了请求方式和地址 - 在src/api/login.js中,将其封装成一个方法
getPicCode()
,注意必须加上return,配合export,作导出 - 注意导出的方式,是按方法,按需导出,因此在页面中使用方法函数时,也是按需导入
- 如在src/api/login.js中,引入改造axios后的request模块,并使用箭头函数,封装
getPicCode()
方法并导出export const getPicCode = () => {return request.get('/captcha/image')}
(方法写到对象{}中) - 后续可能会有更为复杂的请求需要封装,包含了请求参数、请求处理等
- 到页面中寻找能抽离封装的请求,例如改造后的axios请求
页面中导入调用
- 注意导出的方式,是按方法,按需导出,因此在页面中使用方法函数时,也是按需导入
- 后续在页面中,通过
import { codeLogin, getMsgCode, getPicCode } from '@/api/login'
按需引入相关api - 后续在页面中,根据实际需要,配置不同的参数,调用api直接使用方法
getPicCode()
即可 - 注意调用时,看方法前面有没有前缀,有前缀this的,例如this.getPicCode(),代表的是调用本vue实例中的方法;没有前缀的,通过import{}引入的,为全局方法,调用时没有前缀
简单理解:
- api里面的js文件,就是写的与 请求工具/axios/改造后的axios 相关的,配合接口文档的传参要求,使用 请求工具/axios/改造后的axios 方法,获取对应接口数据的js逻辑
- api目录,就是存放这些根据接口文档所写的、一系列获取数据的js逻辑
- 前端的封装接口,就是根据接口文档,写获取对应接口数据的js逻辑
- 在页面中,通过import引入api目录中获取对应接口数据的js逻辑,再调用其中的方法,实现接口数据的获取
重点:
- 注意导出的方式,是封装成一个方法
getPicCode()
,注意必须加上return,配合export,作导出 - 注意导入的方式,因为导出时是按方法按需导出,因此在页面中使用方法函数时,也是按需导入,通过import{}花括号引入,为全局方法,调用时没有前缀
- 注意导出的方式,是封装成一个方法
// 此处用于存放所有登录相关的接口请求
import request from '@/utils/request'
// 1. 获取图形验证码
export const getPicCode = () => {
return request.get('/captcha/image')
}
<script>
// 改为引入方法
import { getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '' // 用户输入的图形验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
}
}
}
</script>
Toast 轻提示
背景:
- 完成图形验证码功能,验证码请求也完成api模块封装,接下来到短信验证功能
- 短信验证功能前,需要校验,根据校验情况,需要返回不同的 Toast 轻提示
目标:阅读文档,掌握 toast 轻提示
步骤:通过从组件库引入组件,完成 toast 轻提示功能
注意:
- 语法的使用,可以通过官方文档熟悉并巩固
- 导入,注册安装:组件库配置下导入
import { Toast } from 'vant'
并安装Vue.use(Toast)
- 使用方式1:
- 组件下引入(或者在需要使用的地方,先引入)
import { Toast } from 'vant'
- 引入后才能使用方法调用
Toast('提示内容')
- 灵活性更高,如在某个js中使用,或者在某个封装的api.js中使用
- 组件下引入(或者在需要使用的地方,先引入)
- 使用方式2:
- 在Vue原型下,直接挂this实例下调用,不需要引入
this.$toast('提示内容')
- 本质:通过原型挂载了公共的方法,直接使用
- 简单,但是有局限性,组件范围内使用
- 在Vue原型下,直接挂this实例下调用,不需要引入
- toast不仅用于文本提示,也用于添加loading效果等
先导入,注册安装:
import { Toast } from 'vant'
Vue.use(Toast)
- 两种使用方式(使用Toast组件的本质,是通过方法作调用)
- ① 导入调用 (组件内 或 非组件中均可),只要到想使用的地方,引入&方法调用即可
// 组件下引入(或者在需要使用的地方,先引入)
import { Toast } from 'vant'
// 引入后才能使用方法调用
Toast('提示内容')
- ② 通过this直接调用 (必须组件内)
- 本质:将方法,注册挂载到了Vue原型上 Vue.prototype.$toast = xxx
- 原型挂载了公共的方法
// 在Vue原型下,直接挂this实例下调用,不需要引入
this.$toast('提示内容')
import Vue from 'vue'
// 按需导入
import { Button, NavBar, Rate, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
<script>
// 改为引入方法
import { getPicCode } from '@/api/login'
import { Toast } from 'vant'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '' // 用户输入的图形验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
}
}
}
</script>
短信倒计时&验证
目标:实现短信验证倒计时功能
步骤分析:
- 点击按钮,实现 倒计时 效果
- 需要使用变量,提供两个变量,存储总秒数(60秒循环结束后恢复),以及当前秒数(根据倒计时变化)
- 有60秒的倒计时间隔,本质上是一个节流控制
- 注册点击事件,触发倒计时控制开始
<button @click="getCode">
- 验证码按钮动态显示倒计时剩余秒数
<button @click="getCode">{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}</button>
- bug解决:
- 禁止开启多个定时器(加变量保存定时器,以判断是否当前已经有定时器),
- 以及禁止大于60秒后的倒计时
- 倒计时结束时,清空及重置定时器
- 离开页面时,通过destroyed钩子,销毁定时器,节约性能
- 注册点击事件,触发倒计时控制开始
- 倒计时之前的 校验处理 (手机号、验证码)
- 拦截手机号/图形验证码没输入/输错的情况
- 一旦开始倒计时,应该同步给后台发送了短信验证
- 短信验证不仅在倒计时前要校验,后续提交登录时也需要校验(手机号、图形验证码、短信验证码),需要封装成方法复用
- 思路:
- 需要校验,则需要准备变量接收,拿到手机号和图形验证码输入情况,在data中提供变量,如mobile、picCode
- 绑定输入框
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
,获取输入数据用于判断,this.mobile、this.picCode - 判断用的正则表达式,可以到在线网站生成
- 写校验判断逻辑
validFn () {}
- 在倒计时开始前,调用校验判断
if (!this.validFn()) {// 如果没通过校验,没必要往下走了return}
- 封装短信验证请求接口
- 结合接口文档封装逻辑,form对象3个参数
- 所有的请求,都应该封装到api接口模块目录中
- 请求短信验证接口方法,并传递三个参数
getMsgCode(this.picCode, this.picKey, this.mobile)
- 倒计时开始,调用封装好的短信验证请求接口,发送请求,添加提示
- 本质上,倒计时开始的事件,就等于调用短信验证请求接口的事件,可以将获取短信验证的接口请求,放到倒计时之前,校验之后
- 倒计时开始,返回的对象中,看到状态码200,表示成功
- 点击按钮,实现 倒计时 效果
<template>
<div class="login">
<!-- 头部 使用van-nav-bar -->
<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 class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<!-- v-model="picCode"绑定表单后续用户输入后可以提交 -->
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<!-- v-if="picUrl"有图片返回的时候才渲染,点击时重新请求getPicCode -->
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input 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'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '', // 用户输入的图形验证码
totalSecond: 60, // 总秒数
second: 60 // 当前秒数,开定时器对 second--
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 获取短信验证码
getCode () {
// 开启倒计时
this.timer = setInterval(() => {
this.second--
}, 1000)
}
}
}
</script>
<script>
// 改为引入方法
import { getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '', // 用户输入的图形验证码
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null // 定时器 id
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 获取短信验证码
async getCode () {
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 开启倒计时
this.timer = setInterval(() => {
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
<script>
// 改为引入方法
import { getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '' // 用户输入的图形验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 开启倒计时
this.timer = setInterval(() => {
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
// 此处用于存放所有登录相关的接口请求
import request from '@/utils/request'
// 1. 获取图形验证码
export const getPicCode = () => {
return request.get('/captcha/image')
}
// 2. 获取短信验证码,定义一个方法getMsgCode等于箭头函数,并传递3个参数
export const getMsgCode = (captchaCode, captchaKey, mobile) => {
return request.post('/captcha/sendSmsCaptcha', {
form: {
captchaCode,
captchaKey,
mobile
}
})
}
<script>
// 改为引入方法
import { getMsgCode, getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '' // 用户输入的图形验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 封装发送请求的api到api/login.js中,并在调用时传三个参数
// 预期:希望如果响应的status非200,最好抛出一个promise错误,await只会等待成功的promise
// 获取短信验证的接口请求,放到倒计时之前,校验之后
await getMsgCode(this.picCode, this.picKey, this.mobile)
// 可以赋予个变量打印一下返回的情况console.log(res);
this.$toast('短信发送成功,注意查收')
// 开启倒计时
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>
登录功能
- 目标:封装api登录接口,实现登录功能
- 步骤分析:
- 阅读接口文档,封装登录接口,封装到api/login.js
- 登录前的校验 (手机号,图形验证码,短信验证码),
- view/login/index.vue中,写校验逻辑
- 需要绑定
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
拿到用户输入的短信验证码 - 登录必须要校验,屏蔽无效请求,减轻后台压力
- 登录按钮绑定事件,调用方法,发送请求,成功添加提示并跳转
- 需要绑定
<div @click="login" class="login-btn">登录</div>
登录按钮,用于发送登录请求 - 调用api/login.js中封装的登录接口,并按文档传参
- 写方法调用的时候,有提示,直接回车,会自动导入,同时文字变颜色变成方法一类的颜色
- 需要绑定
- 最后,3个接口都只做了成功的逻辑,后续通过拦截器处理失败的逻辑
// 此处用于存放所有登录相关的接口请求
import request from '@/utils/request'
// 1. 获取图形验证码
export const getPicCode = () => {
return request.get('/captcha/image')
}
// 2. 获取短信验证码,定义一个方法getMsgCode等于箭头函数,并传递3个参数
export const getMsgCode = (captchaCode, captchaKey, mobile) => {
return request.post('/captcha/sendSmsCaptcha', {
form: {
captchaCode,
captchaKey,
mobile
}
})
}
// 3. 登录接口,根据接口文档,传递4个参数
export const codeLogin = (mobile, smsCode) => {
return request.post('/passport/login', {
form: {
isParty: false,
partyData: {},
mobile,
smsCode
}
})
}
<script>
// 改为引入方法
import { getMsgCode, getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 封装发送请求的api到api/login.js中,并在调用时传三个参数
// 预期:希望如果响应的status非200,最好抛出一个promise错误,await只会等待成功的promise
// 获取短信验证的接口请求,放到倒计时之前,校验之后
await getMsgCode(this.picCode, this.picKey, this.mobile)
// 可以赋予个变量打印一下返回的情况console.log(res);
this.$toast('短信发送成功,注意查收')
// 开启倒计时
this.timer = setInterval(() => {
console.log('倒计时已开始')
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
},
// 登录
async login () {
// 如果此前写的validFn()校验不过,直接返回
if (!this.validFn()) {
return
}
// 短信验证码校验
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
}
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
<script>
// 改为引入方法
import { codeLogin, getMsgCode, getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 封装发送请求的api到api/login.js中,并在调用时传三个参数
// 预期:希望如果响应的status非200,最好抛出一个promise错误,await只会等待成功的promise
// 获取短信验证的接口请求,放到倒计时之前,校验之后
await getMsgCode(this.picCode, this.picKey, this.mobile)
// 可以赋予个变量打印一下返回的情况console.log(res);
this.$toast('短信发送成功,注意查收')
// 开启倒计时
this.timer = setInterval(() => {
console.log('倒计时已开始')
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
},
// 登录
async login () {
// 如果此前写的validFn()校验不过,直接返回
if (!this.validFn()) {
return
}
// 短信验证码校验
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
}
console.log('发送登录请求')
// 根据接口文档传参,两个参数
// 写方法调用的时候,有提示,直接回车,会自动导入,同时文字变颜色变成方法一类的颜色
const res = await codeLogin(this.mobile, this.msgCode)
// 通过打印查看,确认登录成功,打印的返回数据对象data中,看到返回了userID和token数据
console.log(res)
// 数据存入vuex
this.$store.commit('user/setUserInfo', res.data)
// toast提示登录成功
this.$toast('登录成功')
// 跳转到路由首页
this.$router.push('/')
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
响应拦截器 - 统一处理错误提示
目标:通过响应拦截器,统一处理接口的错误提示
问题:
- 每次请求,都会有可能会错误,就都需要错误提示
- 在开发主逻辑时,不需要考虑错误情况,先确保主逻辑正确
- 针对各种可能出现的错误,只要错误出现了(只要返回的状态码不是200不是正确的情况),都需要提示
说明:
- 响应拦截器是咱们拿到数据的 第一个 数据流转站,可以在里面统一处理错误。
- 只要不是 200,就给默认提示,抛出错误
响应拦截器定义:
- 当请求接口数据返回时,数据在真正被业务代码处理之前,都会先经过响应拦截器
- 被响应拦截器处理过的数据,才会流转到业务代码
- 在响应拦截器中,统一处理错误响应
- 响应拦截器对不同的接口,在错误的处理上是不一样的,需要观察如果报错,报错的情况是怎么样,是否带有后端返回的错误提示,如果后端有带则更好处理
案例:
- 当输入图形验证码字符输错时,可以通过调试>网络>选中返回的信息名称>右侧响应可以看到返回信息
{"status":200,"message":"^_^小智提示: 测试环境验证码为: 246810","data":[]}
- 通过
const res = await getMsgCode(this.picCode, this.picKey, this.mobile)
接收的话,可以拿到这个接口返回对象,可以拿到状态码"status":200
- 因为页面专注于页面,做拦截其他status的逻辑的话,到utils/request.js中,实现分离
- js中需通过import{}来做提示
- 当输入图形验证码字符输错时,可以通过调试>网络>选中返回的信息名称>右侧响应可以看到返回信息
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,需要响应拦截器中处理一下,扒掉一层)
// return response.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
登录权证信息存储
目标:vuex 构建 user 模块存储登录权证 (token & userId)
- 将登陆成功时,服务器返回的登录权证userID和token,以对象的形式,存储在vuex中
补充说明:
- token 存入 vuex 的好处,易获取,响应式
- vuex 需要分模块 => user 模块
逻辑步骤:
- 构建 user 模块
- 挂载到 vuex
- 提供 mutations
- 页面中 commit 调用
操作步骤:
- 到store/modules目录下,构建 store/modules/user.js 模块,用于存储服务器返回的 用户登录权证userID和token
- state可以写成
state(){}函数
,或者state:{}对象
,为了保持数据的独立性,通常写成state(){return{对象}}函数
state(){}函数
提供数据状态,mutations(){}函数
提供修改数据的方法,actions(){}函数
异步提交修改操作调用mutations,getters(){}函数
提供基于state派生的数据属性- 将store/modules/user.js挂载到vues主模块中
- 观察vue调试中,vuex下的root下的user,是否有namespaced,有则代表开启了命名空间,数据独立
- 在 store/modules/user.js 模块中,提供mutations,给页面封装一个调用mutations的方法,以覆盖/设置上面state中的信息,将来在页面中调用这个方法
- 所有mutations的第一个参数,都是state,第二个是payload形参,调用时的形参,接口返回的是一个对象obj形参
- 页面中通过commit,调用mutations方法,将数据存入vuex
export default {
namespaced: true,
state () {
return {
// 个人权证相关
// 准备一些默认数据
userInfo: {
token: '',
userId: ''
}
}
},
mutations: {
},
actions: {
},
getters: {}
}
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
}
})
export default {
namespaced: true,
state () {
return {
// 个人权证相关
// 准备一些默认数据
userInfo: {
token: '',
userId: ''
}
}
},
mutations: {
// 所有mutations的第一个参数,都是state,第二个是payload形参,调用时的形参,接口返回的是一个对象obj形参
// 封装一个方法,以覆盖/设置上面state中的信息,将来在页面中调用这个方法
setUserInfo (state, obj) {
state.userInfo = obj
}
},
actions: {
},
getters: {}
}
<script>
// 改为引入方法
import { codeLogin, getMsgCode, getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 封装发送请求的api到api/login.js中,并在调用时传三个参数
// 预期:希望如果响应的status非200,最好抛出一个promise错误,await只会等待成功的promise,到utils/request.js中,实现分离拦截
// 获取短信验证的接口请求,放到倒计时之前,校验之后
await getMsgCode(this.picCode, this.picKey, this.mobile)
// 可以赋予个变量打印一下返回的情况console.log(res);
this.$toast('短信发送成功,注意查收')
// 开启倒计时
this.timer = setInterval(() => {
console.log('倒计时已开始')
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
},
// 登录
async login () {
// 如果此前写的validFn()校验不过,直接返回
if (!this.validFn()) {
return
}
// 短信验证码校验
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
}
console.log('发送登录请求')
// 根据接口文档传参,两个参数
// 写方法调用的时候,有提示,直接回车,会自动导入,同时文字变颜色变成方法一类的颜色
const res = await codeLogin(this.mobile, this.msgCode)
// 通过打印查看,确认登录成功,打印的返回数据对象data中,看到返回了userID和token数据
console.log(res)
// 数据存入vuex,user模块下的setUserInfo方法,形参是res.data
this.$store.commit('user/setUserInfo', res.data)
// toast提示登录成功
this.$toast('登录成功')
// 跳转到路由首页
this.$router.push('/')
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
storage存储模块 - vuex 持久化处理
问题1:vuex 刷新会丢失,怎么办?
目标:
- 通过封装 storage 存储模块,利用本地存储,进行 vuex 持久化处理
注意点:
- 通过上节可知,用户登录权证userID和token是一个对象,需要通过
JSON.stringify(xxx)
序列化存入 - 为了防止登录权证信息与其他信息重名,键名不能太简单,写成
'hm_shopping_info'
相对复杂的形式 - 出现一系列问题:键名太长+每次使用需要序列化+取出后还需要解析,因此,不应该放在页面
- 将存储相关的操作,集合并封装成模块,后续在页面中直接通过函数调用即可
- 优点:避免键名太长写错+避免json反复转换处理出错+本地存储异常underfind无法parse取出时的判断,一系列微小处理集合到模块中
- 因为有一份数据转json存到本地了,如果刷新页面,vuex中丢失了,会
userInfo: getInfo()
从本地json初始化vuex数据 - 备注:token的过期时间,是后端使用jwt令牌实现的,可以实现跨域请求,cookie无法实现跨域
- 通过上节可知,用户登录权证userID和token是一个对象,需要通过
JSON本地持久化存储语法
// 将token存入本地
localStorage.setItem('hm_shopping_info', JSON.stringify(xxx))
// 约定一个通用的键名
const INFO_KEY = 'hm_shopping_info'
// const HISTORY_KEY = 'hm_history_list'
// 获取个人信息,有返回值return
export const getInfo = () => {
const defaultObj = { token: '', userId: '' }
const result = localStorage.getItem(INFO_KEY)
// return的结果不一定有,因此不能直接 JSON.parse序列化,需要加一层判断,false不存在的时候,赋予空值
return result ? JSON.parse(result) : defaultObj
}
// 设置个人信息,传入存储登录权证对象obj,序列化JSON.stringify(obj)后再传入键值
export const setInfo = (obj) => {
localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}
// 移除个人信息
export const removeInfo = () => {
localStorage.removeItem(INFO_KEY)
}
export default {
namespaced: true,
state () {
return {
// 个人权证相关
// 准备一些默认数据
// userInfo: {
// token: '',
// userId: ''
// }
// 获取时,调用storage中的方法,从本地获取,拿不到时也会自动赋予默认值
userInfo: getInfo()
}
},
mutations: {
// 所有mutations的第一个参数,都是state,第二个是payload形参,调用时的形参,接口返回的是一个对象obj形参
// 封装一个方法,以覆盖/设置上面state中的信息,将来在页面中调用这个方法
setUserInfo (state, obj) {
state.userInfo = obj
// 同时存一份数据,调用storage中的方法,转json存入本地
setInfo(obj)
}
},
actions: {
},
getters: {}
}
添加请求 loading 效果
问题2:每次存取操作太长,太麻烦?
- 背景:有时候因为网络原因,一次请求的结果可能需要一段时间后才能回来,此时,需要给用户 添加 loading 提示。
目标:
- 统一在每次请求后台时,添加 loading 效果
- 请求回来后,将loading toast关掉
- 结合vant 组件库使用 loading 效果,根据文档可知,默认时长是2000,需要配
duration: 0
不让自动消失
添加 loading 提示的好处:
- 节流处理:
- 防止用户在一次请求还没回来之前,避免多次进行点击,避免发送无效请求,有利于保留性能
- 一旦开启loading时,可以配置禁止背景点击逻辑,实现节流
- 友好提示:告知用户,目前是在加载中,请耐心等待,用户体验会更好
- 节流处理:
思考:
- loading 提示不单一在点击登录时需要,在其他场合例如重新加载图形验证码、加入购物车、结算页、支付页也需要,loading效果需要复用
- 拦截器理解:某某拦截器,就是在执行某某逻辑前,需要拦截,先做拦截逻辑,完了再回去执行某某逻辑
实操步骤:
- 请求拦截器中,每次请求,打开 loading
- 响应拦截器中,每次响应,关闭 loading
备注:
- 浏览器调试中>网络>无限制中,下拉,可以模拟慢速网络,实现调试
- 所有的路由在真正被访问到之前(解析渲染对应组件页面前),都会先经过全局前置守卫,只有全局前置守卫放行了,才会到达对应的页面
- 浏览器调试中>应用程序application中>本地存储,看本地有没有token
- token的过期是后端的工作,token过期后,后端应该返回相应的错误状态码,后续在拦截器中判断
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) {
// 在发送请求之前做些什么
// 开启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,需要响应拦截器中处理一下,扒掉一层)
// return response.data
// 添加拦截器,加判断,存一下接收回来的数据
const res = response.data
if (res.status !== 200) {
// 给错误提示, Toast 默认是单例模式,后面的 Toast调用了,会将前一个 Toast 效果覆盖
// 同时只能存在一个 Toast
// 查看了后端的返回信息,有提示,可以直接打印提示
Toast(res.message)
// 控制台抛出一个错误的promise
return Promise.reject(res.message)
} else {
// 正确情况,直接走业务核心逻辑,清除loading效果,按组件库文档,调用方法关闭loading效果
Toast.clear()
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
页面访问拦截
目标:基于全局前置守卫,进行页面访问拦截处理
说明:智慧商城项目,大部分页面,游客都可以直接访问,如遇到需要登录才能进行的操作,提示并跳转到登录
但是:对于支付页,订单页等,必须是登录的用户才能访问的,游客不能进入该页面,需要做拦截处理
- 路由导航守卫 - 全局前置守卫
- 所有的路由一旦被匹配到,都会先经过全局前置守卫(无论访问哪个路由页,都会先经过全局前置守卫)
- 只有全局前置守卫放行,才会真正解析渲染组件,才能看到页面内容
路由导航守卫语法
router.beforeEach((to, from, next) => {
// 1. to 往哪里去, 到哪去的路由信息对象
// 2. from 从哪里来, 从哪来的路由信息对象
// 3. next() 是否放行
// 如果next()调用,就是放行
// next(路径) 拦截到某个路径页面
})
- 判断访问权限页面时,拦截或放行的关键点,在于核实用户是否有登录权证 token
- 判断如果token不存在,直接重定向到登录页
import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
import Layout from '@/views/layout'
import Cart from '@/views/layout/cart.vue'
import Category from '@/views/layout/category.vue'
import Home from '@/views/layout/home.vue'
import User from '@/views/layout/user.vue'
import MyOrder from '@/views/myorder'
import Pay from '@/views/pay'
import ProDetail from '@/views/prodetail'
import Search from '@/views/search'
import SearchList from '@/views/search/list.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
import store from '@/store'
Vue.use(VueRouter)
const router = new VueRouter({
// 路由配置中,component中写的组件名字,可以不完全等于组件中的name属性或者文件名字(文件名大小写不一致也可以),会自动找index
// 但是component中写的组件名字需要等于import 引入 from 的名字
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
// 重定向到home页
redirect: '/home',
// 二级子路由页面写在children中,格式为数组[]包对象{}
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
// 所有的路由在真正被访问到之前(解析渲染对应组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面
// 全局前置导航守卫
// to: 到哪里去,到哪去的完整路由信息对象 (路径,参数)
// from: 从哪里来,从哪来的完整路由信息对象 (路径,参数)
// next(): 是否放行
// (1) next() 直接放行,放行到to要去的路径
// (2) next(路径) 进行拦截,拦截到next里面配置的路径
router.beforeEach((to, from, next) => {
console.log(to, from, next)
// 打印to, from两个路由信息对象,next是函数
next()
})
export default router
import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
import Layout from '@/views/layout'
import Cart from '@/views/layout/cart.vue'
import Category from '@/views/layout/category.vue'
import Home from '@/views/layout/home.vue'
import User from '@/views/layout/user.vue'
import MyOrder from '@/views/myorder'
import Pay from '@/views/pay'
import ProDetail from '@/views/prodetail'
import Search from '@/views/search'
import SearchList from '@/views/search/list.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
// 拿vuex中的token判断
import store from '@/store'
Vue.use(VueRouter)
const router = new VueRouter({
// 路由配置中,component中写的组件名字,可以不完全等于组件中的name属性或者文件名字(文件名大小写不一致也可以),会自动找index
// 但是component中写的组件名字需要等于import 引入 from 的名字
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
// 重定向到home页
redirect: '/home',
// 二级子路由页面写在children中,格式为数组[]包对象{}
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
// 所有的路由在真正被访问到之前(解析渲染对应组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面
// 全局前置导航守卫
// to: 到哪里去,到哪去的完整路由信息对象 (路径,参数)
// from: 从哪里来,从哪来的完整路由信息对象 (路径,参数)
// next(): 是否放行
// (1) next() 直接放行,放行到to要去的路径
// (2) next(路径) 进行拦截,拦截到next里面配置的路径
// 定义一个数组,专门用户存放所有需要权限访问的页面
// 需要鉴权的路径,数组方便随时添加
const authUrls = ['/pay', '/myorder']
router.beforeEach((to, from, next) => {
// console.log(to, from, next)
// 打印to, from两个路由信息对象,next是函数
// 看 to.path 是否在 authUrls 中出现过
if (!authUrls.includes(to.path)) {
// 非权限页面,直接放行
next()
return
}
// 是权限页面,需要判断token是否存在,true就放行,拿vuex中的token判断
// const token = store.state.user.userInfo.token
// 到store/index.js中,封装一个全局的getters,后续通过全局的store.getters.token,拿到store.state.user.userInfo.token
const token = store.getters.token
if (token) {
next()
} else {
next('/login')
}
})
export default router
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
}
})
首页 - 静态结构准备 & 动态渲染
目标:实现首页静态结构,封装接口,完成首页动态渲染
重点:
- 封装api模块、封装请求接口,页面调用,完成首页动态渲染
- 分析哪些能使用组件库,哪些需要自己写
- 或者说,如果要自己写不能用第三方组件库,可以怎么写
步骤:
静态结构
- 头部导航条:vant组件库中的van-nav-bar
- 搜索框:vant组件库中的search组件van-search
- 轮播图:vant组件库中的van-swipe
- 分类导航布局:vant组件库中的van-grid,宫格图标导航组件
- 最下方是商品列表GoodsItem:商品封装成单独的静态组件,很多地方会用到,例如首页智慧商城、搜索结果页列表
- 渲染home.vue首页静态结构,并通过调试,找到需要按需引入的vant组件,并引入(偷代码时可以参考该步骤)
封装接口
- 确认需要动态渲染的部分:轮播图、分类导航、猜你喜欢/商品列表
- 找接口文档,根据接口文档,确认渲染哪一块对应哪些接口,可根据渲染的模块来封装接口,如src/api/home.js封装获取首页数据接口
页面调用
- 到src/views/layout/home.vue首页中,在
create(){}
钩子函数中,通过async和await,引入从接口获取首页数据的方法import { getHomeData } from '@/api/home'
- 通过
async created () {const res = await getHomeData()}
调用从接口获取首页数据的方法,并通过res接收、打印,发现首页的所有数据,都在一个接口内返回了,数据在data对象中的pageData - 数据接收注意点:
- 接收父组件传过来的数据,接收的类型是对象
- 对于对象类型需要给默认值时,默认值default是一个函数,在函数的返回值,返回一个空对象
- 函数的返回值,就是默认值,也就是一个空对象
- 到src/views/layout/home.vue首页中,在
动态渲染
- 通过res接收、打印,确认需要渲染的数据为item中的1图片轮播、3导航组、6商品组,直接解构,并依次将其赋值存储起来
- 通过打印,确认每一组需要渲染的数据的结构
- 可根据调用"从接口获取首页数据"的方法,得到的打印结果,动态渲染其他页面,好处是,可以非常方便地从后台配置首页需要动态渲染的样式
- 子组件直接准备一个空对象,接收父组件传过来的数据对象,直接item.渲染,并在点击跳转时,动态传参
- 静态结构
<template>
<div class="goods-item" @click="$router.push('/prodetail')">
<div class="left">
<img src="@/assets/product.jpg" alt="" />
</div>
<div class="right">
<p class="tit text-ellipsis-2">
三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫
5G手机 游戏拍照旗舰机s23
</p>
<p class="count">已售104件</p>
<p class="price">
<span class="new">¥3999.00</span>
<span class="old">¥6699.00</span>
</p>
</div>
</div>
</template>
<script>
export default {
name:'GoodsItem'
}
</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>
<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>
<img src="@/assets/banner1.jpg" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="@/assets/banner2.jpg" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="@/assets/banner3.jpg" alt="">
</van-swipe-item>
</van-swipe>
<!-- 导航 -->
<van-grid column-num="5" icon-size="40">
<van-grid-item
v-for="item in 10" :key="item"
icon="http://cba.itlike.com/public/uploads/10001/20230320/58a7c1f62df4cb1eb47fe83ff0e566e6.png"
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 10" :key="item"></GoodsItem>
</div>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'HomePage',
components: {
GoodsItem
}
}
</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>
import Vue from 'vue'
// 按需导入
import { Button, Grid, GridItem, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
- 封装接口
// 导入改造后的axios
import request from '@/utils/request'
// 封装方法,获取首页数据
export const getHomeData = () => {
// 类似axios用法,通过request.get,传/page/detail地址,拿数据
return request.get('/page/detail', {
// get请求,如果要传参,在第二个配置项位置,传params作为参数传参
// 可以参考axios原理中“封装支持传递查询参数的 类axios 函数”这一节,传入params键值对象
params: {
pageId: 0
}
})
}
- 页面调用,在页面上调用封装的接口方法,存入数据到本组件的data中
<script>
import { getHomeData } from '@/api/home'
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'HomePage',
components: {
GoodsItem
},
data () {
return {
// 接收数据
bannerList: [], // 轮播
navList: [], // 导航
proList: [] // 商品
}
},
async created () {
// const res = await getHomeData()
// 打印接收结果,发现首页的所有数据,都在一个接口内返回了,数据在data对象中的pageData
// console.log(res)
// 直接解构,并赋值,存到本页面的data () {return {}}中,最后在上面模板{{}}渲染
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>
- 动态渲染1,从data数据中,通过
{{模板}}
渲染本页面
<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">
<!-- key选一个唯一值即可 -->
<van-swipe-item v-for="item in bannerList" :key="item.imgUrl">
<img :src="item.imgUrl" alt="">
</van-swipe-item>
<!-- 以下为原静态结构 -->
<!-- <van-swipe-item>
<img src="@/assets/banner2.jpg" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="@/assets/banner3.jpg" 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-item
v-for="item in 10" :key="item"
icon="http://cba.itlike.com/public/uploads/10001/20230320/58a7c1f62df4cb1eb47fe83ff0e566e6.png"
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 10" :key="item"></GoodsItem>
</div>
</div>
</div>
</template>
<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">
<!-- key选一个唯一值即可 -->
<van-swipe-item v-for="item in bannerList" :key="item.imgUrl">
<img :src="item.imgUrl" alt="">
</van-swipe-item>
<!-- 以下为原静态结构 -->
<!-- <van-swipe-item>
<img src="@/assets/banner2.jpg" alt="">
</van-swipe-item>
<van-swipe-item>
<img src="@/assets/banner3.jpg" 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-item
v-for="item in 10" :key="item"
icon="http://cba.itlike.com/public/uploads/10001/20230320/58a7c1f62df4cb1eb47fe83ff0e566e6.png"
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">
<!-- 父传子,:item="item",将"item"的数据传递下去:item,即GoodsItem子组件 -->
<GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
</div>
</div>
</div>
</template>
- 动态渲染2,子组件直接准备一个空对象,接收父组件传过来的数据对象,直接item.渲染,并在点击跳转时,动态传参
<template>
<!-- 如果item.goods_id存在,就是拿到数据了,则开始渲染,并在点击跳转时,动态传参 -->
<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>
<!-- 以下为原静态数据 -->
<!-- <div class="goods-item" @click="$router.push('/prodetail')">
<div class="left">
<img src="@/assets/product.jpg" alt="" />
</div>
<div class="right">
<p class="tit text-ellipsis-2">
三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫
5G手机 游戏拍照旗舰机s23
</p>
<p class="count">已售104件</p>
<p class="price">
<span class="new">¥3999.00</span>
<span class="old">¥6699.00</span>
</p>
</div>
</div> -->
</template>
<script>
export default {
name: 'GoodsItem',
// 接收父组件传过来的数据
props: {
item: {
// 接收的类型是对象
type: Object,
// 给默认值default,对于对象类型,默认值是一个函数,在函数的返回值,返回一个空对象
// 函数的返回值,就是默认值,也就是一个空对象
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>
搜索 - 历史记录管理
- 目标:
- 构建搜索页的静态布局,完成历史记录的管理
- 需求及功能实现:
- 搜索历史基本静态布局渲染
- 头部导航条:vant组件库中的van-nav-bar
- 搜索框:vant组件库中的search组件van-search
- 删除历史记录的图标:vant组件库中的van-icon组件
name="delete-o"
- 将页面中原作为结构样式占位用的、写死的结构,改为假数据,方便后续作动态渲染
- 在data中预设假数据
data () {return {history: ['手机', '啤酒', '白酒', '显示器'] }},
- 通过假数据遍历
<div class="search-history" v-if="history.length > 0">
并渲染<div v-for="item in history" :key="item" class="list-item" >{{ item }}</div>
- 在data中预设假数据
- 点击搜索&添加历史
- 分析:点击 搜索按钮 或 底下历史记录,都能进行搜索
- ① 若之前 没有 相同搜索关键字,则直接追加到最前面
- ② 若之前 已有 相同搜索关键字,将该原有关键字移除,再追加(等同于更新搜索历史)
- 步骤:
提供搜索方法,并获取到用户的输入作为搜索值:
- 先在methods中提供一个方法,作为搜索的逻辑方法
methods: {goSearch()}
- 将 搜索按钮 和 底下历史记录,都注册对应的点击事件,并共用同一个方法
@click="goSearch"
调用同一个method方法 - 再在
data () {return {search: '' }},
data中,新建一个参数占位,以存放search的值(即this.search,如在模板中,不需要加this) - 同时在methods提供的方法中,形参用key占位
methods: {goSearch (key) {具体搜索逻辑}}
,以接收不同的搜索方法传入的搜索关键字作为参数值 - 点击时都发起搜索,但是搜索的方法传参不一样,搜索按钮传参
@click="goSearch(search)"
,搜索历史传参点击时的内容@click="goSearch(item)"
- 可通过打印
goSearch (key) {console.log('执行了搜索,需要同时更新搜索历史记录', key)}
,确认传入的参数
- 可通过打印
- 先在methods中提供一个方法,作为搜索的逻辑方法
提供搜索记录存储:
- 在src/utils/storage.js个人信息模块中,新建并导出setHistoryList和getHistoryList方法,用于存储和获取用户的搜索历史记录
实现功能:
- ① 点搜索按钮,是获取搜索框的输入内容并搜索,并添加历史到最前面
- 查vant组件库search组件用法:可通过v-model,实时绑定输入框获取到用户的输入内容,并通过
<van-search v-model="search" show-action placeholder="请输入搜关 键词" clearable>
,将获取到的表单值/用户实时在搜索框输入的内容,存入data () {return {search: '' }},
data中 - 点击搜索按钮时,调用methods的方法goSearch,并传参data中的search数据值
<div @click="goSearch(search)">搜索</div>
,实现带参调用搜索方法 - methods的方法goSearch中,还应该包括对搜索记录的处理和存储
- 追加历史记录:本质是数组的操作,往history数组中,通过
this.history.unshift(key)
实现数组头部/最前面追加历史记录,并存到history数组中 - 搜索记录追加前,需要先判断数组是否存在,并处理
if (index !== -1) {......}
,如果存在,需要先移除splice(index, 1)
,再追加到最前面,相当于置顶 - 在追加后,需要通过
setHistoryList(this.history)
方法传参存储,注意,仅作本地存储,存储到src/utils/storage.js模块 - 并在history中,通过
getHistoryList方法
获取最新的历史 - 完成追加,路由跳转
this.$router.push(`/searchlist?search=${key}`)
- 查vant组件库search组件用法:可通过v-model,实时绑定输入框获取到用户的输入内容,并通过
- ② 点历史记录按钮,传参历史记录数据搜索,将该原有关键字移除,再并添加历史到最前面
- 搜索历史传参点击时的内容
@click="goSearch(item)"
传参搜索,同时调用与搜索按钮一样的goSearch方法,实现历史记录存储和更新
- 搜索历史传参点击时的内容
- ① 点搜索按钮,是获取搜索框的输入内容并搜索,并添加历史到最前面
- 清空历史:添加清空图标,可以清空历史记录
- 步骤:
- 在删除垃圾桶按钮图标注册点击事件,并绑定method方法
<van-icon @click="clear" name="delete-o" size="16" />
- method方法中,清空数组
- 在删除垃圾桶按钮图标注册点击事件,并绑定method方法
- 持久化:搜索历史需要持久化,刷新历史不丢失
步骤:
- 关于终端数据持久化,都统一放到src/utils/storage.js个人信息模块中,封装方法,后续需要使用时,用到哪一个字段/本地存储的方法,直接到storage.js中寻找即可
- 可以在storage.js模块下,清晰看到所有的键名,互相之间命名也不容易冲突
- 确认需要存的目标:是一个数组:
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
,本质上就是将这个数组持久化,需要JSON序列化 - 在src/utils/storage.js个人信息模块中,新建并导出setHistoryList和getHistoryList方法,用于存储和获取用户的搜索历史记录
- 移除时,就是调用setHistoryList,存入一个空数组并持久化即可
- 获取历史,优先从本地去读取历史
history: getHistoryList()
- 任何一个methods操作后,都需要存,在
goSearch (key) {}
方法中,在最后加上setHistoryList(this.history)
将页面data中的数据,转json固化,清空同理,改成传入空数组 - 最后加上页面跳转
备注:
- 搜索历史一般跟随设备,没有放到后台
- 如果有多端同步的需求,则需要记录到后台数据库,如视频观看历史
- 在vscode中,鼠标选中方法函数的调用(如鼠标悬浮在
getHistoryList()
上)- 右键转到引用/shift + f12:查看同样引用了这个方法的地方,以及引入的情况,可以通过shift + alt +f12查找所有引用
- 右键转到定义/f12:在当前页面下,展开这个方法的逻辑定义,可编辑
- 右键转到实现/ctrl + f12:打开写着这个方法的逻辑定义的文件
- 搜索历史基本静态布局渲染
<template>
<div class="search">
<van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search show-action placeholder="请输入搜索关键词" clearable>
<template #action>
<div>搜索</div>
</template>
</van-search>
<!-- 搜索历史 -->
<div class="search-history">
<div class="title">
<span>最近搜索</span>
<van-icon name="delete-o" size="16" />
</div>
<div class="list">
<div class="list-item" @click="$router.push('/searchlist')">炒锅</div>
<div class="list-item" @click="$router.push('/searchlist')">电视</div>
<div class="list-item" @click="$router.push('/searchlist')">冰箱</div>
<div class="list-item" @click="$router.push('/searchlist')">手机</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SearchIndex'
}
</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>
import Vue from 'vue'
// 按需导入
import { Button, Grid, GridItem, Icon, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Icon)
<template>
<div class="search">
<van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search show-action placeholder="请输入搜索关键词" clearable>
<template #action>
<div>搜索</div>
</template>
</van-search>
<!-- 搜索历史 -->
<!-- 有历史时渲染下方内容 -->
<div class="search-history" v-if="history.length > 0">
<div class="title">
<span>最近搜索</span>
<van-icon name="delete-o" size="16" />
</div>
<div class="list">
<!-- 将写死的结构,改为假数据 -->
<div v-for="item in history" :key="item" class="list-item" @click="$router.push('/searchlist')">{{ item }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SearchIndex',
data () {
return {
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
}
}
}
</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>
- 点击搜索 (添加历史)
<template>
<div class="search">
<van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search show-action placeholder="请输入搜索关键词" clearable>
<template #action>
<div @click="goSearch">搜索</div>
</template>
</van-search>
<!-- 搜索历史 -->
<!-- 有历史时渲染下方内容 -->
<div class="search-history" v-if="history.length > 0">
<div class="title">
<span>最近搜索</span>
<van-icon name="delete-o" size="16" />
</div>
<div class="list">
<div v-for="item in history" :key="item" class="list-item" @click="goSearch">{{ item }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SearchIndex',
data () {
return {
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
}
},
methods: {
goSearch () {
console.log('执行了搜索,需要同时更新搜索历史记录')
}
}
}
</script>
<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 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>
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
}
},
methods: {
goSearch (key) {
console.log('执行了搜索,需要同时更新搜索历史记录', key)
}
}
}
</script>
<script>
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
}
},
methods: {
goSearch (key) {
// console.log('执行了搜索,需要同时更新搜索历史记录', key)
// indexOf(key):查找当前的key,在history中的下标
const index = this.history.indexOf(key)
if (index !== -1) {
// index !== -1,则存在相同的项,将原有关键字移除
// splice(第一个参数是从哪开始, 第二个参数是删除几个, 项1, 项2)
this.history.splice(index, 1)
}
// 数组前面追加
this.history.unshift(key)
}
}
}
</script>
<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>
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
}
},
methods: {
goSearch (key) {
// console.log('执行了搜索,需要同时更新搜索历史记录', key)
// indexOf(key):查找当前的key,在history中的下标
const index = this.history.indexOf(key)
if (index !== -1) {
// index !== -1,则存在相同的项,将原有关键字移除
// splice(第一个参数是从哪开始, 第二个参数是删除几个, 项1, 项2)
this.history.splice(index, 1)
}
// 数组前面追加
this.history.unshift(key)
},
clear () {
// 清空数组
this.history = []
}
}
}
</script>
// 约定一个通用的键名
const INFO_KEY = 'hm_shopping_info'
const HISTORY_KEY = 'hm_history_list'
// 获取个人信息,有返回值return
export const getInfo = () => {
const defaultObj = { token: '', userId: '' }
const result = localStorage.getItem(INFO_KEY)
// return的结果不一定有,因此不能直接 JSON.parse序列化,需要加一层判断,false不存在的时候,赋予空值
return result ? JSON.parse(result) : defaultObj
}
// 设置个人信息,传入存储登录权证对象obj,序列化JSON.stringify(obj)后再传入键值
export const setInfo = (obj) => {
localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}
// 移除个人信息
export const removeInfo = () => {
localStorage.removeItem(INFO_KEY)
}
// 获取搜索历史,需要return返回
export const getHistoryList = () => {
// 从本地读
const result = localStorage.getItem(HISTORY_KEY)
// 不一定能读到,如果存在,则JSON.parse(result),如果类似underfind不存在,则给一个默认值,初始化为空数组
return result ? JSON.parse(result) : []
}
// 设置搜索历史,传入数组,序列化JSON.stringify(arr)后再传入键值
export const setHistoryList = (arr) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}
<script>
import { getHistoryList, setHistoryList } from '@/utils/storage'
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
// history: ['手机', '啤酒', '白酒', '显示器'] // 历史记录
// 获取历史,优先从本地去读取历史
history: getHistoryList()
}
},
methods: {
goSearch (key) {
// console.log('执行了搜索,需要同时更新搜索历史记录', key)
// indexOf(key):查找当前的key,在history中的下标
const index = this.history.indexOf(key)
if (index !== -1) {
// index !== -1,则存在相同的项,将原有关键字移除
// splice(第一个参数是从哪开始, 第二个参数是删除几个, 项1, 项2)
this.history.splice(index, 1)
}
// 数组前面追加
this.history.unshift(key)
// 将页面data中的数据,转json固化
setHistoryList(this.history)
// 跳转到搜索列表页
this.$router.push(`/searchlist?search=${key}`)
},
clear () {
// 清空数组
this.history = []
// 将空数组,传入历史记录设置方法
setHistoryList([])
}
}
}
</script>
搜索列表 - 静态布局 & 动态渲染
目标:
- 实现搜索列表页静态结构,封装接口,完成搜索列表页的渲染
分析:
- 搜索列表页获取到search/index.vue搜索页传过来的数据,接收搜索关键字
- 基于关键字,渲染列表
- 可以自行扩展,做排序功能
步骤:
- 静态结构
- 接口文档分析参数,确认传参
- 通过接口文档,确认有5个传参,categoryId分类id、goodsName商品名称、page分页,以及goodsNamesortType和sortPrice是排序用
- 在分类页中,通过分类id,可以按层级进行分类选中,并跳转到搜索列表页(不仅支持基于商品名称搜索,也支持通过商品分类id搜索)
- 如果搜索结果超过一页,加载下一页时,可以通过page分页参数,实现指定页加载
- 封装接口
- 在src/api接口目录下,新建src/api/product.js,封装与商品相关的接口getProList
- 获取参数,调用接口,获取结果(在src/view/search/list.vue商品列表页面组件中,写商品搜索结果列表的逻辑,通过请求src/api/product.js商品相关的接口,获取搜索结果)
- 通过路由传参,通过computed计算属性,先通过
querySearch () {return this.$route.query.参数名}
,拿到路径地址传递过来的参数/搜索关键字,并渲染:value="querySearch || '搜索商品'"
,或者没有值时给默认值“搜索商品”(从别的页面跳转过来时,例如分类页,不一定有传值) - created钩子,一进页面,就发请求getProList,去获取商品列表,直接解构+调用传参3个参数
- 调用接口请求时,page默认传1,先拿到第一页
- 通过路由传参,通过computed计算属性,先通过
- 动态渲染传参(基于搜索关键字)
- 在data函数中,准备一个变量
proList: []
去存储商品列表,打印返回的信息并解构,通过this.proList = list.data
接收接口返回的数据并存入data中的proList: []
- 通过
v-for="item in proList"
渲染,通过:item="item"
父传子渲染 - 如果要做“综合、销量、价格”的渲染,就是在接口传参时,再多加相应的参数传参
- 在data函数中,准备一个变量
- 动态渲染传参(基于分类id)
- 分类页渲染:分类页created 构造钩子调用api中的getCategoryList ()方法,从接口获取到数据并动态渲染分类页
- 基于分类页点击,路由传参的分类页中商品的二级分类id
searchlist?categoryId=${item.category_id}
渲染商品列表,即@click="$router.push(`/searchlist?categoryId=${item.category_id}`)"
- 分类页逻辑:封装接口获取,点击时,带商品的二级分类id参,发搜索请求给搜索列表页
- 分类页组件:可以用插件(回弹插件,拉伸回弹效果)可以自己写(overflow)
- 根据传参渲染页面
- 在src/search/index.vue搜索页中,传参调用的
goSearch (key){方法}
中,添加路由传参跳转this.$router.push(`/searchlist?search=${key}`)
跳转到搜索结果列表 - 在src/view/layout/category.vue分类页中,监听点击分类并传参
searchlist?categoryId=${item.category_id}
给搜索结果列表 - 在搜索列表页src/view/search/list.vue中,在data函数中,准备一个变量
proList: []
去存储商品列表,通过computed计算属性,拿到从搜索页或者分类页传过来的传参 - 在搜索列表页src/view/search/list.vue中,在created构建钩子函数中,调用api,并传递3个参数(goodsName搭配page或者categoryId搭配page),根据搜索页或者分类页传过来的2种传参(两对任意一对传参),拿到本list.vue列表页需要渲染的数据,并存入、渲染
- 在src/search/index.vue搜索页中,传参调用的
- 扩展:搜索列表页src/view/search/list.vue中排序
- 在src/api接口目录下的src/api/product.js中,即此前封装的与商品相关的接口getProList中,根据接口文档,加两个参数sortType和sortPrice
- 在搜索列表页src/view/search/list.vue中,给两个参数sortType和sortPrice,并设置默认值
- 在搜索列表页src/view/search/list.vue中,调用api时,多加传参两个参数去发起调用
备注:附api的简单理解:
- api里面的js文件,就是写的与 请求工具/axios/改造后的axios 相关的,配合接口文档的传参要求,使用 请求工具/axios/改造后的axios 方法,获取对应接口数据的js逻辑
- api目录,就是存放这些根据接口文档所写的、一系列获取数据的js逻辑
- 前端的封装接口,就是根据接口文档,写获取对应接口数据的js逻辑
- 在页面中,通过import引入api目录中获取对应接口数据的js逻辑,再调用其中的方法,实现接口数据的获取
- axios中,如果传递的参数无值,如null、underfind这种假值时,axios在实际请求时,会将对应参数自动屏蔽掉,只要没有传值,就是相当于忽略该值参数
<template>
<div class="search">
<van-nav-bar fixed title="商品列表" left-arrow @click-left="$router.go(-1)" />
<van-search
readonly
shape="round"
background="#ffffff"
value="手机"
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 10" :key="item"></GoodsItem>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'SearchIndex',
components: {
GoodsItem
}
}
</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>
import request from '@/utils/request'
// 获取搜索商品列表的数据
export const getProList = (obj) => {
const { categoryId, goodsName, page } = obj
return request.get('/goods/list', {
// 参数是对象
params: {
categoryId,
goodsName,
page
}
})
}
<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 10" :key="item"></GoodsItem>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
}
}
</script>
<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 { getProList } from '@/api/product'
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
},
data () {
return {
page: 1,
// 准备一个变量去存储商品列表
proList: []
}
},
// created钩子,一进页面,就发请求getProList,去获取商品列表,直接解构+调用传参3个参数
async created () {
// const res = await getProList({
// 直接解构+调用传参
const { data: { list } } = await getProList({
goodsName: this.querySearch,
page: this.page
})
// console.log(res)
this.proList = list.data
}
}
</script>
<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>
import request from '@/utils/request'
// 获取分类数据
export const getCategoryData = () => {
return request.get('/category/list')
}
<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 { getProList } from '@/api/product'
import GoodsItem from '@/components/GoodsItem.vue'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
},
data () {
return {
page: 1,
// 准备一个变量去存储商品列表
proList: []
}
},
// created钩子,一进页面,就发请求getProList,去获取商品列表,直接解构+调用传参3个参数
async created () {
// const res = await getProList({
// 直接解构+调用传参
const { data: { list } } = await getProList({
categoryId: this.$route.query.categoryId,
goodsName: this.querySearch,
page: this.page
})
// console.log(res)
this.proList = list.data
}
}
</script>
商品详情- 静态布局 & 渲染
- 目标:实现商品详情静态结构,封装接口,完成商品详情页渲染
- 步骤及分析:
- 静态结构
- 封装接口
- 分析:除商品的图片和文字信息外,还需要渲染评论,因此有两个接口需要封装api和渲染
- 在src/api/product.js中,分别封装getProDetail获取商品详情数据api和getProComments获取商品评价api
- 基于动态路由
this.$route.params.id
,获取参数(商品id),在src/view/prodetail/index.vue中:- 通过computed计算属性,拿到地址栏的传参
- 在data中建存储数据的对象,
- 一进页面就发请求,在created钩子函数中,调用页面方法
- 在页面的methods中,写页面方法的逻辑,引入并调用api中的方法
- 在页面方法中,写多个方法,包括获取详情的方法,获取评价的方法,分别调用不同的api,并赋值回去data
- 调用api中的方法时(如getProDetai),传入基于动态路由
this.$route.params.id
拿到的参数 - 第一部分渲染商品详情,第二部分渲染评价
- 获取数据,动态渲染
- 在vue模板中,调用data中的数据,取出对应数据渲染
- 注意,评价渲染部分,有的用户没有头像,需要给默认头像
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image" />
</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">¥0.01</span>
<span class="oldprice">¥6699.00</span>
</div>
<div class="sellcount">已售1001件</div>
</div>
<div class="msg text-ellipsis-2">
三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫 5G手机 游戏拍照旗舰机s23
</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">商品评价 (5条)</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in 3" :key="item">
<div class="top">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a0db9adb2e666a65bc8dd133fbed7834.png" alt="">
<div class="name">神雕大侠</div>
<van-rate :size="16" :value="5" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
质量很不错 挺喜欢的
</div>
<div class="time">
2023-03-21 15:01:35
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div class="desc">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/kHgx21fZMWwqirkMhawkAw.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/0rRMmncfF0kGjuK5cvLolg.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/2P04A4Jn0HKxbKYSHc17kw.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/MT4k-mPd0veQXWPPO5yTIw.jpg" alt="">
</div>
<!-- 底部 -->
<div class="footer">
<div class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div class="btn-add">加入购物车</div>
<div class="btn-buy">立刻购买</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProDetail',
data () {
return {
images: [
'https://img01.yzcdn.cn/vant/apple-1.jpg',
'https://img01.yzcdn.cn/vant/apple-2.jpg'
],
current: 0
}
},
methods: {
onChange (index) {
this.current = index
}
}
}
</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;
}
</style>
import request from '@/utils/request'
// 获取搜索商品列表的数据
export const getProList = (obj) => {
const { categoryId, goodsName, page, sortType, sortPrice } = obj
return request.get('/goods/list', {
// 参数是对象
params: {
categoryId,
goodsName,
page,
sortType,
sortPrice
}
})
}
// 获取商品详情数据
export const getProDetail = (goodsId) => {
return request.get('/goods/detail', {
params: {
goodsId
}
})
}
// 获取商品评价
export const getProComments = (goodsId, limit) => {
return request.get('/comment/listRows', {
params: {
goodsId,
limit
}
})
}
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">商品评价 (5条)</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in 3" :key="item">
<div class="top">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a0db9adb2e666a65bc8dd133fbed7834.png" alt="">
<div class="name">神雕大侠</div>
<van-rate :size="16" :value="5" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
质量很不错 挺喜欢的
</div>
<div class="time">
2023-03-21 15:01:35
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div class="desc">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/kHgx21fZMWwqirkMhawkAw.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/0rRMmncfF0kGjuK5cvLolg.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/2P04A4Jn0HKxbKYSHc17kw.jpg" alt="">
<img src="https://uimgproxy.suning.cn/uimg1/sop/commodity/MT4k-mPd0veQXWPPO5yTIw.jpg" alt="">
</div>
<!-- 底部 -->
<div class="footer">
<div class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div class="btn-add">加入购物车</div>
<div class="btn-buy">立刻购买</div>
</div>
</div>
</template>
<script>
import { getProDetail } from '@/api/product'
export default {
name: 'ProDetail',
data () {
return {
images: [],
current: 0,
detail: {}
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
const res = await getProDetail(this.goodsId)
console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
console.log(this.images)
}
}
}
</script>
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">商品评价 (5条)</div>
<div class="right">查看更多 <van-icon name="arrow" /> </div>
</div>
<div class="comment-list">
<div class="comment-item" v-for="item in 3" :key="item">
<div class="top">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a0db9adb2e666a65bc8dd133fbed7834.png" alt="">
<div class="name">神雕大侠</div>
<van-rate :size="16" :value="5" color="#ffd21e" void-icon="star" void-color="#eee"/>
</div>
<div class="content">
质量很不错 挺喜欢的
</div>
<div class="time">
2023-03-21 15:01:35
</div>
</div>
</div>
</div>
<!-- 商品描述 -->
<div class="desc" v-html="detail.content">
</div>
<!-- 底部 -->
<div class="footer">
<div class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div class="btn-add">加入购物车</div>
<div class="btn-buy">立刻购买</div>
</div>
</div>
</template>
import request from '@/utils/request'
// 获取搜索商品列表的数据
export const getProList = (obj) => {
const { categoryId, goodsName, page, sortType, sortPrice } = obj
return request.get('/goods/list', {
// 参数是对象
params: {
categoryId,
goodsName,
page,
sortType,
sortPrice
}
})
}
// 获取商品详情数据
export const getProDetail = (goodsId) => {
return request.get('/goods/detail', {
params: {
goodsId
}
})
}
// 获取商品评价
export const getProComments = (goodsId, limit) => {
return request.get('/comment/listRows', {
params: {
goodsId,
limit
}
})
}
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
<div class="btn-add">加入购物车</div>
<div class="btn-buy">立刻购买</div>
</div>
</div>
</template>
<script>
import { getProComments, getProDetail } from '@/api/product'
// 导入默认头像资源
import defaultImg from '@/assets/default-avatar.png'
export default {
name: 'ProDetail',
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg // 评价中的用户头像默认图片
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
}
}
}
</script>
加入购物车 - 唤起弹层
- 目标:点击加入购物车,唤起弹层效果
- 步骤及分析:
- 熟悉弹层基本展示
- 通过组件库,使用
import { ActionSheet } from 'vant'
反馈组件中的ActionSheet动作面板,引入van-action-sheet组件 - 参照组件库中的组件使用语法,先将大致的组件库结构放入prodetail/index.vue中,作为静态结构
- 熟悉组件用法,
v-model="控制量",控制量=true
时,组件弹出,false时,组件收起 - 考虑在操作哪两个按钮时,唤出弹层,并注册点击事件
<div @click="addFn" class="btn-add">加入购物车</div>
和<div @click="buyNow" class="btn-buy">立刻购买</div>
- 在methods中,写点击事件的逻辑,将控制量
this.showPannel
,在按钮点击的方法addFn和buyNow中赋值为true,在data中默认赋值为false
- 通过组件库,使用
- 完善弹层结构
- 在van-action-sheet标签对中,补充完整弹层静态结构,以及样式
- 弹层动态渲染
- 通过打印返回的数据,动态渲染
- 熟悉弹层基本展示
import Vue from 'vue'
// 按需导入
import { ActionSheet, Button, Grid, GridItem, Icon, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 需要分开两个写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Icon)
Vue.use(ActionSheet)
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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' ? '加入购物车' : '立刻购买'">
111
</van-action-sheet>
</div>
</template>
<script>
import { getProComments, getProDetail } from '@/api/product'
// 导入默认头像资源
import defaultImg from '@/assets/default-avatar.png'
export default {
name: 'ProDetail',
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
}
}
}
</script>
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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="http://cba.itlike.com/public/uploads/10001/20230321/ 8f505c6c437fc3d4b4310b57b1567544.jpg" alt="">
</div>
<div class="right">
<div class="price">
<span>¥</span>
<span class="nowprice">9.99</span>
</div>
<div class="count">
<span>库存</span>
<span>55</span>
</div>
</div>
</div>
<div class="num-box">
<span>数量</span>
数字框占位
</div>
<div class="showbtn" v-if="true">
<div class="btn" v-if="true">加入购物车</div>
<div class="btn now" v-else>立刻购买</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'
export default {
name: 'ProDetail',
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
}
}
}
</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>
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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>
数字框占位
</div>
<!-- 有库存的情况下,才显示可买的弹层按钮 -->
<div class="showbtn" v-if="detail.stock_total > 0">
<div class="btn" v-if="true">加入购物车</div>
<div class="btn now" v-else>立刻购买</div>
</div>
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
</div>
</template>
加入购物车 - 封装数字框组件
目标:
- 自主封装一个弹层中的数字框组件
分析:
- 组件名 CountBox
- 是一个通用组件,既在加入购物车弹层中使用,在购物车列表中也使用
- 静态结构,左中右三部分,
-
数字
+
- 数字框的数字,应该是外部传递进来的 (父传子)
- 点击
+
-
号,可以修改数字 (子传父) - 使用 v-model 实现封装 (:value 和 @input 的简写);
- 因为用到了子传父&父传子&子组件实时修改父组件,因此使用v-model来双向绑定代替
- 数字不能减到小于 1
- 可以直接输入内容,输入完成判断是否合法
<template>
<div class="count-box">
<button @click="handleSub" class="minus">-</button>
<!-- 注册change事件,监听输入变化,监听回车/失焦确认 -->
<input :value="value" @change="handleChange" class="inp" type="text">
<button @click="handleAdd" class="add">+</button>
</div>
</template>
<script>
export default {
// 父传子,拿到父级v-model双向传递的数据
props: {
value: {
type: Number,
default: 1
}
},
methods: {
handleSub () {
if (this.value <= 1) {
return
}
// 子传父,无论加还是减,都需要通过给父组件传递input事件(子组件单腿改父组件)
this.$emit('input', this.value - 1)
},
handleAdd () {
// 子传父
this.$emit('input', this.value + 1)
},
handleChange (e) {
// 事件.对象目标的.值
// console.log(e.target.value)
const num = +e.target.value // 转数字处理,出现两种情况 (1) 数字 (2) 输入的字母无法成功转数字,会变为NaN
// 输入了不合法的文本 或 输入了负值,回退成原来的 value 值
if (isNaN(num) || num < 1) {
e.target.value = this.value
return
}
this.$emit('input', num)
}
}
}
</script>
<style lang="less" scoped>
.count-box {
width: 110px;
display: flex;
.add, .minus {
width: 30px;
height: 30px;
outline: none;
border: none;
background-color: #efefef;
}
.inp {
width: 40px;
height: 30px;
outline: none;
border: none;
margin: 0 5px;
background-color: #efefef;
text-align: center;
}
}
</style>
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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="true">加入购物车</div>
<div class="btn now" v-else>立刻购买</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',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1 // 数字框绑定的数据,默认为1
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
}
}
}
</script>
加入购物车 - 判断 token 添加登录提示
目标:给未登录的用户,添加登录提示
分析:
- 加入购物车,需要向后台发请求
- 这个加入购物车请求,是一个 登录后的用户 才能进行的操作,所以需要进行鉴权判断,判断用户 token 是否存在
- 加入购物车和立即购买的两个按钮,注册点击事件,调用methods方法,在页面逻辑中,先判断 token 是否存在
- 导入并注册弹窗组件,在页面中使用组件
引入 Dialog 组件后,会自动在 Vue 的 prototype 上挂载 $dialog 方法,在所有组件内部都可以直接调用此方法
- 并根据按钮的不同,在判断 token完成后,再调用不同的api
- 先做加入购物车的按钮方法和api
- 若存在:允许调用加入购物车操作的api,完成加入购物车操作,并收起弹层
- 不存在:提示 用户未登录,引导到登录页,登录完回跳当前页面
复习:
- 附一个简单理解:命名导入和默认导入的区别:
- 如果是对另一个文件中的 方法/或函数 的引入,使用花括号,本页面中直接使用 方法/或函数
- 如果是对另一个文件整个文件完整引入,例如静态资源、组件引入、引入分拆的模块等,不需要花括号
- 附一个简单理解:命名导入和默认导入的区别:
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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="true" @click="addCart">加入购物车</div>
<div class="btn now" v-else>立刻购买</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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1 // 数字框绑定的数据,默认为1
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
console.log('弹确认框')
return
}
console.log('正常请求')
}
}
}
</script>
import Vue from 'vue'
// 按需导入
import { ActionSheet, Button, Dialog, Grid, GridItem, Icon, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 全局注册
// 需要分开写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Icon)
Vue.use(ActionSheet)
Vue.use(Dialog)
<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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1 // 数字框绑定的数据,默认为1
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
// console.log('弹确认框')
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
// 配按钮文本
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 跳转登录页
this.$router.push('/login')
})
// 点取消,啥也不干
.catch(() => { })
return
}
console.log('正常请求')
if (this.loginConfirm()) {
return
}
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
}
}
}
</script>
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<div class="icon-cart">
<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="true" @click="addCart">加入购物车</div>
<div class="btn now" v-else>立刻购买</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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1 // 数字框绑定的数据,默认为1
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
// console.log('弹确认框')
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
// 配按钮文本
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 跳转登录页(不返回原页面)
// this.$router.push('/login')
// 跳转登录页,操作登陆后,返回原页面,需要在跳转时,携带当前所在路径地址
// this.$route.Path(当前路径,不带查询参数)
// this.$route.fullPath(当前路径 + 带查询参数)回跳必用
// 同时需要更改登录页的跳转代码,为了不新增历史,原页面更替,将登陆跳转改为replace
this.$router.replace({
path: '/login',
query: {
backUrl: this.$route.fullPath
}
})
})
// 点取消,啥也不干
.catch(() => { })
return
}
console.log('正常请求')
if (this.loginConfirm()) {
return
}
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
}
}
}
</script>
<script>
// 改为引入方法
import { codeLogin, getMsgCode, getPicCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
totalSecond: 60, // 总秒数
second: 60, // 当前秒数,开定时器对 second--
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '' // 短信验证码
}
},
// 将获取短信验证码,封装成方法,并在create钩子中调用
async created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
// 与axios使用一致,这里导入的是request.js因此使用时是request.
// 直接解构,并改为调用引入的方法
const { data: { base64, key } } = await getPicCode()
this.picUrl = base64 // 存储地址
this.picKey = key // 存储唯一标识
// Toast('获取图形验证码成功')
// this.$toast('获取成功')
// this.$toast.success('成功文案')
},
// 校验 手机号 和 图形验证码 是否合法
// 通过校验,返回true
// 不通过校验,返回false
// 需要两个都通过,才return true
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
},
// 获取短信验证码
async getCode () {
// 在倒计时开始前,调用校验判断
if (!this.validFn()) {
// 如果没通过校验,没必要往下走了
return
}
// 当前目前没有定时器开着,且 totalSecond 和 second 一致 (秒数归位) 才可以倒计时
// 当timer不存在,且秒数归位时
if (!this.timer && this.second === this.totalSecond) {
// 封装发送请求的api到api/login.js中,并在调用时传三个参数
// 预期:希望如果响应的status非200,最好抛出一个promise错误,await只会等待成功的promise,到utils/request.js中,实现分离拦截
// 获取短信验证的接口请求,放到倒计时之前,校验之后
await getMsgCode(this.picCode, this.picKey, this.mobile)
// 可以赋予个变量打印一下返回的情况console.log(res);
this.$toast('短信发送成功,注意查收')
// 开启倒计时
this.timer = setInterval(() => {
console.log('倒计时已开始')
this.second--
if (this.second <= 0) {
clearInterval(this.timer)
this.timer = null // 重置定时器 id
this.second = this.totalSecond // 归位
}
}, 1000)
}
},
// 登录
async login () {
// 如果此前写的validFn()校验不过,直接返回
if (!this.validFn()) {
return
}
// 短信验证码校验
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的手机验证码')
}
console.log('发送登录请求')
// 根据接口文档传参,两个参数
// 写方法调用的时候,有提示,直接回车,会自动导入,同时文字变颜色变成方法一类的颜色
const res = await codeLogin(this.mobile, this.msgCode)
// 通过打印查看,确认登录成功,打印的返回数据对象data中,看到返回了userID和token数据
console.log(res)
// 数据存入vuex,user模块下的setUserInfo方法,形参是res.data
this.$store.commit('user/setUserInfo', res.data)
// toast提示登录成功
this.$toast('登录成功')
// 跳转到路由首页
// this.$router.push('/')
// 进行判断,看地址栏有无回跳地址
// 1. 如果有 => 说明是其他页面,拦截到登录来的,需要回跳
// 2. 如果没有 => 正常去首页
const url = this.$route.query.backUrl || '/'
// 使用this.$router.push(url)的话,在浏览器中点击页面返回时会返回登陆页,同时,push会累加历史
// 使用this.$router.replace(url)的话,本质上是当前页面更替,不新增历史,同时也需要改商品详情页的登陆跳转为replace
this.$router.replace(url)
}
},
// 离开页面清除定时器
destroyed () {
clearInterval(this.timer)
}
}
</script>
加入购物车 - 封装接口进行请求
- 目标:封装接口,进行加入购物车的请求
- api/cart.js 中封装加入购物车接口
- 页面中调用接口
- 必传1:3个 Body 参数goodsId,goodsNum,goodsSkuId
- 必传2(不传会报错):Access-Token,告知服务器往哪个用户的账号加购物车
- 必传3(不传会报错):当前请求的客户端(APP、小程序、H5等)
- 遇到问题:接口需要传递 token
- 解决问题:
- 方法1:在api中,即api/cart.js加购物车请求中,追加;但加入购物车,立即购买,支付等每次请求,都需要更改,因此不采用
- 方法2:到utils/request.js中,通过添加请求截器逻辑,统一携带 token
- 小图标定制
// 购物车相关接口
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId, headers, platform) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
}, {
// 第三个配置,缺点,每次请求都需要更改,包括加入购物车,立即购买,支付等
// headers,
// platform
}
)
}
<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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1 // 数字框绑定的数据,默认为1
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
// console.log('弹确认框')
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
// 配按钮文本
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 跳转登录页(不返回原页面)
// this.$router.push('/login')
// 跳转登录页,操作登陆后,返回原页面,需要在跳转时,携带当前所在路径地址
// this.$route.Path(当前路径,不带查询参数)
// this.$route.fullPath(当前路径 + 带查询参数)回跳必用
// 同时需要更改登录页的跳转代码,为了不新增历史,原页面更替,将登陆跳转改为replace
this.$router.replace({
path: '/login',
query: {
backUrl: this.$route.fullPath
}
})
})
// 点取消,啥也不干
.catch(() => { })
return
}
console.log('正常请求')
if (this.loginConfirm()) {
return
}
// 调用购物车方法,并传参
// const res = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// console.log(res)
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
}
}
}
</script>
import store from '@/store'
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) {
// 在发送请求之前做些什么
// 开启loading,禁止背景点击 (节流处理,防止多次无效触发)
Toast.loading({
message: '加载中...',
forbidClick: true, // 禁止背景点击
loadingType: 'spinner', // 配置loading图标
duration: 0 // 不会自动消失
})
// 引入import store from '@/store'
// 只要有token,就在请求时携带,便于请求需要授权的接口
const token = store.getters.token
if (token) {
// 即config.headers.Authorization,由于是在对象中带特殊字符,因此需要中括号加引号['']包裹
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么(默认axios会多包装一层data,需要响应拦截器中处理一下,扒掉一层)
// return response.data
// 添加拦截器,加判断,存一下接收回来的数据
const res = response.data
if (res.status !== 200) {
// 给错误提示, Toast 默认是单例模式,后面的 Toast调用了,会将前一个 Toast 效果覆盖
// 同时只能存在一个 Toast
// 查看了后端的返回信息,有提示,可以直接打印提示
Toast(res.message)
// 控制台抛出一个错误的promise
return Promise.reject(res.message)
} else {
// 正确情况,直接走业务核心逻辑,清除loading效果,按组件库文档,调用方法关闭loading效果
Toast.clear()
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
// 导出配置好的实例
export default instance
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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 class="icon-home">
<van-icon name="wap-home-o" />
<span>首页</span>
</div>
<!-- 购物车数量小角标 -->
<div 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="true" @click="addCart">加入购物车</div>
<div class="btn now" v-else>立刻购买</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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1, // 数字框绑定的数据,默认为1
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
// console.log('弹确认框')
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
// 配按钮文本
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 跳转登录页(不返回原页面)
// this.$router.push('/login')
// 跳转登录页,操作登陆后,返回原页面,需要在跳转时,携带当前所在路径地址
// this.$route.Path(当前路径,不带查询参数)
// this.$route.fullPath(当前路径 + 带查询参数)回跳必用
// 同时需要更改登录页的跳转代码,为了不新增历史,原页面更替,将登陆跳转改为replace
this.$router.replace({
path: '/login',
query: {
backUrl: this.$route.fullPath
}
})
})
// 点取消,啥也不干
.catch(() => { })
return
}
console.log('正常请求')
// 调用购物车方法,并传参
// const res = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// console.log(res)
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// 购物车数量小角标
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
console.log(this.cartTotal)
}
}
}
</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>
购物车模块 概述 & 静态结构 & vuex数据获取和存储
说明:
- 购物车 数据联动关系 较多,包括数量加减、商品勾选、取消、删除,结算数量、总价等,属于经典场景
- 且通常会封装一些 小组件,例如数字框组件,商品组件等,组件嵌套关系较多
- 所以为了便于维护,一般都会将购物车的数据基于 vuex 分模块管理
需求分析:
- 基本静态结构 (快速实现)/src/views/layout/cart.vue
- 构建 vuex cart 模块,获取数据存储
- 基于 数据 动态渲染 购物车列表
- 封装 getters 实现动态统计,包括:商品总数 / 选中的商品列表 / 选中的商品总数 / 选中的商品总价
- 全选反选功能
- 数字框修改数量功能
- 编辑切换状态,删除功能
- 空购物车处理
购物车模块 1静态结构
- 基本静态结构 (快速实现)/src/views/layout/cart.vue
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>4</i>件商品</span>
<span class="edit">
<van-icon name="edit" />
编辑
</span>
</div>
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in 10" :key="item">
<van-checkbox></van-checkbox>
<div class="show">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a072ef0eef1648a5c4eae81fad1b7583.jpg" alt="">
</div>
<div class="info">
<span class="tit text-ellipsis-2">新Pad 14英寸 12+128 远峰蓝 M6平板电脑 智能安卓娱乐十核游戏学习二合一 低蓝光护眼超清4K全面三星屏5GWIFI全网通 蓝魔快本平板</span>
<span class="bottom">
<div class="price">¥ <span>1247.04</span></div>
<div class="count-box">
<button class="minus">-</button>
<input class="inp" :value="4" type="text" readonly>
<button class="add">+</button>
</div>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">99.99</i></span>
</div>
<div v-if="true" class="goPay">结算(5)</div>
<div v-else class="delete">删除</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CartPage'
}
</script>
<style lang="less" scoped>
// 主题 padding
.cart {
padding-top: 46px;
padding-bottom: 100px;
background-color: #f5f5f5;
min-height: 100vh;
.cart-title {
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
font-size: 14px;
.all {
i {
font-style: normal;
margin: 0 2px;
color: #fa2209;
font-size: 16px;
}
}
.edit {
.van-icon {
font-size: 18px;
}
}
}
.cart-item {
margin: 0 10px 10px 10px;
padding: 10px;
display: flex;
justify-content: space-between;
background-color: #ffffff;
border-radius: 5px;
.show img {
width: 100px;
height: 100px;
}
.info {
width: 210px;
padding: 10px 5px;
font-size: 14px;
display: flex;
flex-direction: column;
justify-content: space-between;
.bottom {
display: flex;
justify-content: space-between;
.price {
display: flex;
align-items: flex-end;
color: #fa2209;
font-size: 12px;
span {
font-size: 16px;
}
}
.count-box {
display: flex;
width: 110px;
.add,
.minus {
width: 30px;
height: 30px;
outline: none;
border: none;
}
.inp {
width: 40px;
height: 30px;
outline: none;
border: none;
background-color: #efefef;
text-align: center;
margin: 0 5px;
}
}
}
}
}
}
.footer-fixed {
position: fixed;
left: 0;
bottom: 50px;
height: 50px;
width: 100%;
border-bottom: 1px solid #ccc;
background-color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
.all-check {
display: flex;
align-items: center;
.van-checkbox {
margin-right: 5px;
}
}
.all-total {
display: flex;
line-height: 36px;
.price {
font-size: 14px;
margin-right: 10px;
.totalPrice {
color: #fa2209;
font-size: 18px;
font-style: normal;
}
}
.goPay, .delete {
min-width: 100px;
height: 36px;
line-height: 36px;
text-align: center;
background-color: #fa2f21;
color: #fff;
border-radius: 18px;
&.disabled {
background-color: #ff9779;
}
}
}
}
</style>
import Vue from 'vue'
// 按需导入
import { ActionSheet, Button, Checkbox, Dialog, Grid, GridItem, Icon, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tabbar, TabbarItem, Toast } from 'vant'
// 全局注册
// 需要分开写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Icon)
Vue.use(ActionSheet)
Vue.use(Dialog)
Vue.use(Checkbox)
<!-- 底部 -->
<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>
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>4</i>件商品</span>
<span class="edit">
<van-icon name="edit" />
编辑
</span>
</div>
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in 10" :key="item">
<van-checkbox></van-checkbox>
<div class="show">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/a072ef0eef1648a5c4eae81fad1b7583.jpg" alt="">
</div>
<div class="info">
<span class="tit text-ellipsis-2">新Pad 14英寸 12+128 远峰蓝 M6平板电脑 智能安卓娱乐十核游戏学习二合一 低蓝光护眼超清4K全面三星屏5GWIFI全网通 蓝魔快本平板</span>
<span class="bottom">
<div class="price">¥ <span>1247.04</span></div>
<CountBox></CountBox>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">99.99</i></span>
</div>
<div v-if="true" class="goPay">结算(5)</div>
<div v-else class="delete">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
}
}
</script>
购物车模块 2vuex数据获取和存储
- 构建 vuex cart 模块,获取数据,并存储
- 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
- 通过vue调试工具确认,vuex中root有cart namespaced模块
- 封装api接口,调用接口,获取数据,并存入cart.js
- 操作是往vuex中存,发请求是异步操作(原因:为了避免阻塞页面的渲染和交互,如果前端发起请求是同步的,那么在请求返回之前,浏览器会一直等待,页面就会被阻塞,用户无法进行其他操作,体验不好)
- 将异步的发请求获取数据操作,放到vuex统一管理,因此将请求放入src/store/modules/cart.js的actions中
- 在/src/views/layout/cart.vue页面中,created钩子,一进页面,就触发调用异步操作
- 后台返回的数据中,并不包含商品选中的状态,分析,用户是否选中商品的状态,是由本地维护的,需要做额外处理,给当前后台返回的数据,额外增加字段,
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
mutations: {},
actions: {},
getOwnPropertyDescriptor: {}
}
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
}
})
// 购物车相关接口
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId, headers, platform) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
}, {
// 第三个配置,缺点,每次请求都需要更改,包括加入购物车,立即购买,支付等
// headers,
// platform
}
)
}
// 获取购物车列表
export const getCartList = () => {
return request.get('/cart/list')
}
import { getCartList } from '@/api/cart.js'
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
// 仅支持同步操作
mutations: {},
// 异步操作
actions: {
async getCartAction (context) {
const res = await getCartList()
console.log(res)
const { data } = await getCartList()
// 后台返回的数据中,不包含复选框的选中状态,为了实现将来的功能
// 需要手动维护数据,给每一项,添加一个 isChecked 状态 (标记当前商品是否选中)
data.list.forEach(item => {
item.isChecked = true
})
context.commit('setCartList', data.list)
}
},
getters: {}
}
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
created () {
// 必须是登录过的用户,才能用户购物车列表
// if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
}
}
</script>
import { getCartList } from '@/api/cart.js'
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
// 仅支持同步操作
mutations: {
// 提供一个设置 cartList 的 mutation,存入从接口获取到的购物车数据
setCartList (state, newList) {
state.cartList = newList
}
},
// 异步操作
actions: {
// 调用mutations
async getCartAction (context) {
const res = await getCartList()
console.log(res)
const { data } = await getCartList()
// 后台返回的数据中,不包含复选框的选中状态,为了实现将来的功能
// 需要手动维护数据,给每一项,添加一个 isChecked 状态 (标记当前商品是否选中)
data.list.forEach(item => {
item.isChecked = true
})
context.commit('setCartList', data.list)
}
},
getters: {}
}
购物车模块 3计算属性&辅助函数映射拿vuex数据渲染
- 基于 数据 动态渲染 购物车列表
- 打印确认哪些数据需要渲染
- 确认数据是存储在src/store/modules/cart.js中的state中的
cartList: []
,需要在/src/views/layout/cart.vue页面中拿到数据,通过计算属性拿到 - 通过辅助函数进行映射,拿到数据
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>4</i>件商品</span>
<span class="edit">
<van-icon name="edit" />
编辑
</span>
</div>
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<van-checkbox :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 :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">99.99</i></span>
</div>
<div v-if="true" class="goPay">结算(5)</div>
<div v-else class="delete">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
}
}
</script>
购物车模块 4封装 getters 实现动态统计
- 封装 getters 实现动态统计(state数据的派生状态)
- 封装 getters动态统计包括:商品总数 / 选中的商品列表 / 选中的商品总数 / 选中的商品总价
- Getter 也可以接受其他 getter 作为第二个参数
import { getCartList } from '@/api/cart.js'
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
// 仅支持同步操作
mutations: {
// 提供一个设置 cartList 的 mutation,存入从接口获取到的购物车数据
setCartList (state, newList) {
state.cartList = newList
}
},
// 异步操作
actions: {
// 调用mutations
async getCartAction (context) {
const res = await getCartList()
console.log(res)
const { data } = await getCartList()
// 后台返回的数据中,不包含复选框的选中状态,为了实现将来的功能
// 需要手动维护数据,给每一项,添加一个 isChecked 状态 (标记当前商品是否选中)
data.list.forEach(item => {
item.isChecked = true
})
context.commit('setCartList', data.list)
}
},
getters: {
// 求所有的商品累加总数
cartTotal (state) {
// reduce((形参sum每次累计的结果, item每一项, index下标) => 方法, 起始累计值)
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
// 过滤filter(item => item.isChecked),true时选中
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品累加总数
// 在一个getters中访问另一个getters,vuex官方文档[Getter 也可以接受其他 getter 作为第二个参数](https://vuex.vuejs.org/zh/guide/getters.html)
// Getter 也可以接受其他 getter 作为第二个参数
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)
// 计算结果对象包裹
return getters.selCartList.reduce((sum, item) => {
return sum + item.goods_num * item.goods.goods_price_min
}, 0).toFixed(2)
// .toFixed(2)方法为保留两位小数
}
}
}
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
<span class="edit">
<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 :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 :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
</div>
<!-- 当前选中项为0时,置灰按钮 -->
<div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
}
}
</script>
购物车模块 5全选反选功能
- 全选反选功能
注册点击事件,让复选框根据点击事件变化状态
- /src/views/layout/cart.vue中item前面的复选框注册点击事件,methods中提供方法,调用vuex中mutations修改,传参goodsId
- 页面methods中提供方法,
.commit('cart/toggleCheck', goodsId)
调用src/store/modules/cart.js,即vuex中的mutations修改,根据vant组件库用法更改复选框状态 - 通过取反状态,完成全选框根据item状态变化
点击商品item前面的复选框,可以控制下面的全选
- 每一个item都选中时触发,由每一个item前面的复选框决定
- 是属于getters动态统计,通过计算属性提供
点击下面的全选复选框,可以控制所有商品的选中和反选
- 给全选按钮注册点击事件,并提供methods方法
- 通过
.commit('cart/toggleAllCheck', !this.isAllChecked)
提交mutations修改vuex
注册点击事件,让复选框根据点击事件变化状态
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
<span class="edit">
<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 :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
<div class="footer-fixed">
<div class="all-check">
<van-checkbox icon-size="18"></van-checkbox>
全选
</div>
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
</div>
<!-- 当前选中项为0时,置灰按钮 -->
<div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
}
}
}
</script>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
}
}
}
</script>
- 点击商品item前面的复选框,可以控制下面的全选
// 是否全选
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
<van-checkbox :value="isAllChecked" icon-size="18"></van-checkbox>
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
- 点击下面的全选复选框,可以控制所有商品的选中和反选
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
<span class="edit">
<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 :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>
<!-- 当前选中项为0时,置灰按钮 -->
<div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
}
}
}
</script>
toggleAllCheck (state, flag) {
// 让所有的小选框,同步设置
state.cartList.forEach(item => {
item.isChecked = flag
})
}
购物车模块 6数字框修改数量功能
- 数字框修改数量功能
- 数字框原组件支持数量修改,但当前购物车引入数字框时,数据已绑定vuex中
- 因此,数字框加减,需要监听点击事件,监听数字框组件整体的input事件,通过
@input="changeCount"
形参拿到传递的参数 - 通过mutations修改vuex中的数据,既需要传递监听到的数量修改,也需要传递
item.goods_id, item.goods_sku_id
多个参数给vuex,此时可通过箭头函数包裹数据传递- 既希望保留原本的形参,又需要通过调用函数时传参调用 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参
<CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)" :value="item.goods_num"></CountBox>
- 箭头函数特点:单向、自动return
- 加减后的数字,需要同步到后台,需要走接口(选中不选中属于本地行为,购物车中数量加减需要更新后台数量),因此需要封装数字框接口,即购物车商品更新
// 购物车相关接口
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId, headers, platform) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
}, {
// 第三个配置,缺点,每次请求都需要更改,包括加入购物车,立即购买,支付等
// headers,
// platform
// 转到拦截器配置
}
)
}
// 获取购物车列表
export const getCartList = () => {
return request.get('/cart/list')
}
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/update', {
goodsId,
goodsNum,
goodsSkuId
})
}
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
<span class="edit">
<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="changeCount" :value="item.goods_num"></CountBox> -->
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参 -->
<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>
<!-- 当前选中项为0时,置灰按钮 -->
<div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
}
}
}
</script>
import { changeCount, getCartList } from '@/api/cart.js'
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
// 仅支持同步操作
mutations: {
// 提供一个设置 cartList 的 mutation,存入从接口获取到的购物车数据
setCartList (state, newList) {
state.cartList = newList
},
toggleCheck (state, goodsId) {
// .find(item => item.goods_id === goodsId)找出数组中的item.goods_id等于goodsId的每一项赋值给新数组,以找出被选中项
const goods = state.cartList.find(item => item.goods_id === goodsId)
// 让对应的 id 的项 状态取反
goods.isChecked = !goods.isChecked
},
toggleAllCheck (state, flag) {
// 让所有的小选框,同步设置
state.cartList.forEach(item => {
item.isChecked = flag
})
},
// 数字框修改购物车存到vuex的数量
changeCount (state, { goodsId, goodsNum }) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.goods_num = goodsNum
}
},
// 异步操作
actions: {
// 调用mutations
async getCartAction (context) {
// const res = await getCartList()
// console.log(res)
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
// 先本地修改,给mutations调vuex的修改方法,并传参
context.commit('changeCount', { goodsId, goodsNum })
// 再同步到后台,给api
await changeCount(goodsId, goodsNum, goodsSkuId)
}
},
getters: {
// 求所有的商品累加总数
cartTotal (state) {
// reduce((形参sum每次累计的结果, item每一项, index下标) => 方法, 起始累计值)
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
// 过滤filter(item => item.isChecked),true时选中
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品累加总数
// 在一个getters中访问另一个getters,vuex官方文档[Getter 也可以接受其他 getter 作为第二个参数](https://vuex.vuejs.org/zh/guide/getters.html)
// Getter 也可以接受其他 getter 作为第二个参数
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)
// 计算结果对象包裹
return getters.selCartList.reduce((sum, item) => {
return sum + item.goods_num * item.goods.goods_price_min
}, 0).toFixed(2)
// .toFixed(2)方法为保留两位小数
},
// 是否全选
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
购物车模块 7编辑切换状态,删除功能
- 编辑切换状态,删除功能
- 状态切换,点击编辑后,展示选中删除的勾选框
- 准备变量存放状态
- 点击编辑后,改变下方的结算按钮/删除按钮状态
- 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
- 封装接口,调用,删除
- 接口
array[string]
,要求是数组包字符串 - 在src/store/modules/cart.js的action中,调用delSelect的api方法,
- 形参context包括购物车数组,能拿到state则拿到购物车数组,context能拿到getter,能拿到实时选中项
- 接口
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<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="changeCount" :value="item.goods_num"></CountBox> -->
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参 -->
<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>
<!-- 当前选中项为0时,置灰按钮 -->
<!-- 编辑状态时不结算 -->
<!-- <div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div> -->
<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }" >结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
// 建一个data存储购物车编辑状态
data () {
return {
// 购物车编辑状态,默认false,点击时让状态取反
isEdit: false
}
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
}
},
// 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
watch: {
isEdit (value) {
// 如果变化后的值为true,就是编辑状态
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</script>
<template>
<div class="cart">
<van-nav-bar title="购物车" fixed />
<!-- 购物车开头 -->
<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="changeCount" :value="item.goods_num"></CountBox> -->
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参 -->
<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>
<!-- 当前选中项为0时,置灰按钮 -->
<!-- 编辑状态时不结算 -->
<!-- <div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div> -->
<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }" >结算({{ selCount }})</div>
<div v-else @click="handleDel" class="delete" :class="{ disabled: selCount === 0 }">删除</div>
</div>
</div>
</div>
</template>
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
// 建一个data存储购物车编辑状态
data () {
return {
// 购物车编辑状态,默认false,点击时让状态取反
isEdit: false
}
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
},
handleDel () {
// 如果没有选中删除项,直接返回
if (this.selCount === 0) return
// 调接口删除
this.$store.dispatch('cart/delSelect')
}
},
// 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
watch: {
isEdit (value) {
// 如果变化后的值为true,就是编辑状态
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</script>
// 购物车相关接口
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId, headers, platform) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
}, {
// 第三个配置,缺点,每次请求都需要更改,包括加入购物车,立即购买,支付等
// headers,
// platform
// 转到拦截器配置
}
)
}
// 获取购物车列表
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
})
}
import { changeCount, delSelect, getCartList } from '@/api/cart.js'
import { Toast } from 'vant'
export default {
namespaced: true,
state () {
return {
// 初始化购物车列表,使用数组维护数据,数组中包对象,每一个对象渲染一个商品
cartList: []
}
},
// 仅支持同步操作
mutations: {
// 提供一个设置 cartList 的 mutation,存入从接口获取到的购物车数据
setCartList (state, newList) {
state.cartList = newList
},
toggleCheck (state, goodsId) {
// .find(item => item.goods_id === goodsId)找出数组中的item.goods_id等于goodsId的每一项赋值给新数组,以找出被选中项
const goods = state.cartList.find(item => item.goods_id === goodsId)
// 让对应的 id 的项 状态取反
goods.isChecked = !goods.isChecked
},
// 全选
toggleAllCheck (state, flag) {
// 让所有的小选框,同步设置
state.cartList.forEach(item => {
item.isChecked = flag
})
},
// 数字框修改购物车存到vuex的数量
changeCount (state, { goodsId, goodsNum }) {
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.goods_num = goodsNum
}
},
// 异步操作
actions: {
// 调用mutations
async getCartAction (context) {
// const res = await getCartList()
// console.log(res)
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
// 先本地修改,给mutations调vuex的修改方法,并传参
context.commit('changeCount', { goodsId, goodsNum })
// 再同步到后台,给api
await changeCount(goodsId, goodsNum, goodsSkuId)
},
// 删除购物车数据
// 删除选中的购物车商品
// context上下文,默认提交的就是自己模块的action和mutation,理解为store中的state
async delSelect (context) {
// 获取选中的购物车列表
const selCartList = context.getters.selCartList
// 将选中的商品id提取成数组
const cartIds = selCartList.map((item) => item.id)
// 调用删除接口
await delSelect(cartIds)
// 提示删除成功
Toast('删除成功')
// 重新拉取最新的购物车数据 (重新渲染)
context.dispatch('getCartAction')
}
},
getters: {
// 求所有的商品累加总数
cartTotal (state) {
// reduce((形参sum每次累计的结果, item每一项, index下标) => 方法, 起始累计值)
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的商品项
selCartList (state) {
// 过滤filter(item => item.isChecked),true时选中
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品累加总数
// 在一个getters中访问另一个getters,vuex官方文档[Getter 也可以接受其他 getter 作为第二个参数](https://vuex.vuejs.org/zh/guide/getters.html)
// Getter 也可以接受其他 getter 作为第二个参数
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)
// 计算结果对象包裹
return getters.selCartList.reduce((sum, item) => {
return sum + item.goods_num * item.goods.goods_price_min
}, 0).toFixed(2)
// .toFixed(2)方法为保留两位小数
},
// 是否全选
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
<script>
// 引入组件
import CountBox from '@/components/CountBox.vue'
import { mapGetters, mapState } from 'vuex'
export default {
name: 'CartPage',
// 注册组件
components: {
CountBox
},
// 建一个data存储购物车编辑状态
data () {
return {
// 购物车编辑状态,默认false,点击时让状态取反
isEdit: false
}
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked'])
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
},
async handleDel () {
// 如果没有选中删除项,直接返回
if (this.selCount === 0) return
// 调接口删除
await this.$store.dispatch('cart/delSelect')
// 删除后重置状态,需要等删除完成,因此前面调接口改为async和await
this.isEdit = false
}
},
// 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
watch: {
isEdit (value) {
// 如果变化后的值为true,就是编辑状态
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</script>
购物车模块 8空购物车处理
- 空购物车处理
- 购物车为空时的页面,不应该是购物车列表除去商品条以后的状态,应显示购物车为空页
- 将整个购物车内容,包成一个大div,利用v-if判断,二选一显示,else为购物车空页内容
- 当购物车有东西且已登录时才显示
<div v-if="isLogin && cartList.length > 0">
- 当用户未登录时,点加入购物车引导登录,购物车没有商品也应显示购物车为空页
<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="changeCount" :value="item.goods_num"></CountBox> -->
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参 -->
<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>
<!-- 当前选中项为0时,置灰按钮 -->
<!-- 编辑状态时不结算 -->
<!-- <div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div> -->
<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }" >结算({{ 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存储购物车编辑状态
data () {
return {
// 购物车编辑状态,默认false,点击时让状态取反
isEdit: false
}
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked']),
isLogin () {
return this.$store.getters.token
}
},
created () {
// 必须是登录过的用户,才能用户购物车列表
// if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
},
async handleDel () {
// 如果没有选中删除项,直接返回
if (this.selCount === 0) return
// 调接口删除
await this.$store.dispatch('cart/delSelect')
// 删除后重置状态,需要等删除完成,因此前面调接口改为async和await
this.isEdit = false
}
},
// 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
watch: {
isEdit (value) {
// 如果变化后的值为true,就是编辑状态
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>
订单结算台 - 静态页面&从后端获取收货地址
说明:
- 所有的结算,本质上就是 跳转到 "订单结算台",
- 并且,跳转的同时,需要 携带上对应的订单相关参数,
- 具体需要哪些参数,基于 "订单结算台" 的需求来定。
步骤:
- 先准备静态页面,通过静态页面确认跳转时需要传递的参数
- 确认收货地址,非重点,每个注册用户都会有默认的收货地址
- 需要通过后端获取地址,在进入页面时,发请求拿到并渲染(有相关的修改接口,可拓展做,通过点击跳转路由,按接口实现即可)
- 先准备静态页面,通过静态页面确认跳转时需要传递的参数
<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="true">
<div class="info-content">
<span class="name">小红</span>
<span class="mobile">13811112222</span>
</div>
<div class="info-address">
江苏省 无锡市 南长街 110号 504
</div>
</div>
<div class="info" v-else>
请选择配送地址
</div>
<div class="right-icon">
<van-icon name="arrow" />
</div>
</div>
<!-- 订单明细 -->
<div class="pay-list">
<div class="list">
<div class="goods-item">
<div class="left">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/8f505c6c437fc3d4b4310b57b1567544.jpg" alt="" />
</div>
<div class="right">
<p class="tit text-ellipsis-2">
三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫 5G手机 游戏拍照旗舰机s23
</p>
<p class="info">
<span class="count">x3</span>
<span class="price">¥9.99</span>
</p>
</div>
</div>
</div>
<div class="flow-num-box">
<span>共 12 件商品,合计:</span>
<span class="money">¥1219.00</span>
</div>
<div class="pay-detail">
<div class="pay-cell">
<span>订单总金额:</span>
<span class="red">¥1219.00</span>
</div>
<div class="pay-cell">
<span>优惠券:</span>
<span>无优惠券可用</span>
</div>
<div class="pay-cell">
<span>配送费用:</span>
<span v-if="false">请先选择配送地址</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" />余额支付(可用 ¥ 999919.00 元)</span>
<!-- <span>请先选择配送地址</span> -->
<span class="red"><van-icon name="passed" /></span>
</div>
</div>
<!-- 买家留言 -->
<div class="buytips">
<textarea placeholder="选填:买家留言(50字内)" name="" id="" cols="30" rows="10"></textarea>
</div>
</div>
<!-- 底部提交 -->
<div class="footer-fixed">
<div class="left">实付款:<span>¥999919</span></div>
<div class="tipsbtn">提交订单</div>
</div>
</div>
</template>
<script>
export default {
name: 'PayIndex',
data () {
return {
}
},
methods: {
}
}
</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>
import request from '@/utils/request'
// 获取地址列表
export const getAddressList = () => {
return request.get('/address/list')
}
<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">
<div class="list">
<div class="goods-item">
<div class="left">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/8f505c6c437fc3d4b4310b57b1567544.jpg" alt="" />
</div>
<div class="right">
<p class="tit text-ellipsis-2">
三星手机 SAMSUNG Galaxy S23 8GB+256GB 超视觉夜拍系统 超清夜景 悠雾紫 5G手机 游戏拍照旗舰机s23
</p>
<p class="info">
<span class="count">x3</span>
<span class="price">¥9.99</span>
</p>
</div>
</div>
</div>
<div class="flow-num-box">
<span>共 12 件商品,合计:</span>
<span class="money">¥1219.00</span>
</div>
<div class="pay-detail">
<div class="pay-cell">
<span>订单总金额:</span>
<span class="red">¥1219.00</span>
</div>
<div class="pay-cell">
<span>优惠券:</span>
<span>无优惠券可用</span>
</div>
<div class="pay-cell">
<span>配送费用:</span>
<span v-if="false">请先选择配送地址</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" />余额支付(可用 ¥ 999919.00 元)</span>
<!-- <span>请先选择配送地址</span> -->
<span class="red"><van-icon name="passed" /></span>
</div>
</div>
<!-- 买家留言 -->
<div class="buytips">
<textarea placeholder="选填:买家留言(50字内)" name="" id="" cols="30" rows="10"></textarea>
</div>
</div>
<!-- 底部提交 -->
<div class="footer-fixed">
<div class="left">实付款:<span>¥999919</span></div>
<div class="tipsbtn">提交订单</div>
</div>
</div>
</template>
<script>
import { getAddressList } from '@/api/address'
export default {
name: 'PayIndex',
data () {
return {
addressList: []
}
},
created () {
this.getAddressList()
},
computed: {
// 通过计算属性取出地址,选中的地址
selectedAddress () {
// 这里地址管理非主线业务,直接获取第一个项作为选中的地址,默认值是一个空对象
return this.addressList[0] || {}
},
// 通过计算属性,拼接由接口返回的数据,字符串拼接成长地址
longAddress () {
const region = this.selectedAddress.region
return region.province + region.city + region.region + this.selectedAddress.detail
}
},
methods: {
async getAddressList () {
// 获取收货地址,不需传参
// const res = await getAddressList()
// console.log(res)
const { data: { list } } = await getAddressList()
this.addressList = list
}
}
}
</script>
订单结算台 - 封装通用的订单信息确认接口
目标:封装通用的订单信息确认接口
说明:这里的订单信息确认结算,有两种情况
- 购物车结算
- 立即购买结算
通过接口文档,确认api写法
- 订单信息确认,可以共用同一个接口(仅参数不同),封装通用接口
- 接口文档显示是Query 参数,在get请求中传递,通过params传参
- 有两种传参模式,根据传参模式的不同,传不同的具体参数
复习
Query 参数是什么意思,以及如何使用呢
什么是查询参数
查询参数(又称 URL 参数)是附加在 URL 末尾的一组键值对,用于向服务器传递附加信息。查询参数以问号 (?) 开头,每个键值对由等号 (=) 分隔,并由 & 符号连接。
例如:
Copy
https://example.com/search?q=python&page=2
在这个 URL 中,q 和 page 是查询参数,python 和 2 是它们对应的值。
如何使用查询参数
要在 URL 中使用查询参数,只需将它们附加到 URL 末尾,如下所示:
Copy
https://example.com/search?q=python
你可以在 URL 中添加多个查询参数,每个参数用 & 符号分隔:
Copy
https://example.com/search?q=python&page=2&sort=relevance
查询参数的用途
查询参数有各种用途,包括:
**过滤和排序结果:**例如,在搜索引擎中,你可以使用查询参数来过滤搜索结果或对结果进行排序。
**传递表单数据:**当提交 HTML 表单时,表单数据通常会通过查询参数传递到服务器。
**跟踪网站流量:**可以使用查询参数来跟踪网站流量,例如,你可以使用查询参数来跟踪用户来自哪个来源或他们点击了哪个链接。
**自定义页面:**可以使用查询参数来定制页面,例如,你可以使用查询参数来更改页面的语言或主题。
**注意:**查询参数是通过 HTTP 协议传递的,因此它们对所有人可见。如果你需要传递敏感信息,请使用其他方法,例如 POST 请求或加密。
import request from '@/utils/request'
// 订单结算确认
// mode: cart => obj { cartIds }
// mode: buyNow => obj { goodsId goodsNum goodsSkuId }
// 在调用时需要传参,传参mode模式,剩余额外参数obj放到对象
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // cart buyNow
delivery: 10, // 10 快递配送 20 门店自提
couponId: 0, // 优惠券ID 传0 不使用优惠券
isUsePoints: 0, // 积分 传0 不使用积分
...obj // 将传递过来的参数对象 动态展开
}
})
}
订单结算台 - 以购物车传参方式结算
目标:购物车结算跳转,传递参数,调用接口渲染订单结算台
核心步骤:
- 跳转传递查询参数 模式mode="cart" 和 选中项的cartIds(字符串)
- 注册点击事件
@click="goPay"
,即<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }" @click="goPay" >结算({{ selCount }})</div>
- methods中写方法goPay,并作基础判断,有没有选中商品
- 注册点击事件
- 页面中 $route.query 接收地址栏的参数
- 在pay结算页面,定义computed计算属性,拿到地址栏的参数
- 调用接口,获取数据
- 一进页面就发请求调接口获取,因此在created函数中定义this.getOrderList()
- 在methods中,定义getOrderList的具体实现方法和调用api的逻辑,引入api/order中的方法
- 基于数据渲染
- 跳转传递查询参数 模式mode="cart" 和 选中项的cartIds(字符串)
<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="changeCount" :value="item.goods_num"></CountBox> -->
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层,可以在箭头函数中,加入额外的形参 -->
<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>
<!-- 当前选中项为0时,置灰按钮 -->
<!-- 编辑状态时不结算 -->
<!-- <div v-if="true" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</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存储购物车编辑状态
data () {
return {
// 购物车编辑状态,默认false,点击时让状态取反
isEdit: false
}
},
computed: {
// 页面中使用计算属性&辅助函数映射拿vuex数据
...mapState('cart', ['cartList']),
// 渲染派生的(即用户勾选的状态数据)
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice', 'isAllChecked']),
isLogin () {
return this.$store.getters.token
}
},
created () {
// 必须是登录过的用户,才能用户购物车列表
// if (this.$store.getters.token) { this.$store.dispatch('cart/getCartAction') }
if (this.isLogin) {
this.$store.dispatch('cart/getCartAction')
}
},
methods: {
// 带参传递要修改选中状态的项给vuex
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 给vuex取反全选,传递的状态,是操作前的状态取反
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
// 单个参数时
// changeCount (goodsNum) {
// console.log(goodsNum)
// }
// 多个参数.dispatch给vuex
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 打印可确认,传递了3个参数给vuex
// .dispatch调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/changeCountAction', {
goodsNum,
goodsId,
goodsSkuId
})
},
async handleDel () {
// 如果没有选中删除项,直接返回
if (this.selCount === 0) return
// 调接口删除
await this.$store.dispatch('cart/delSelect')
// 删除后重置状态,需要等删除完成,因此前面调接口改为async和await
this.isEdit = false
},
goPay () {
// 判断有没有选中商品,length大于0
if (this.selCount > 0) {
// 有选中的 商品 才进行结算跳转
this.$router.push({
path: '/pay',
query: {
// 传参模式
mode: 'cart',
// 格式:'cartId,cartId,cartId'.join(',')转字符串,逗号隔开
cartIds: this.selCartList.map(item => item.id).join(',')
}
})
// 可通过打印选中项列表确认
console.log(this.selCartList)
}
}
},
// 监视编辑状态,当切换到删除状态时,将商品的选中状态,默认置为不选中
watch: {
isEdit (value) {
// 如果变化后的值为true,就是编辑状态
if (value) {
this.$store.commit('cart/toggleAllCheck', false)
} else {
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</script>
<script>
import { getAddressList } from '@/api/address'
export default {
name: 'PayIndex',
data () {
return {
addressList: []
}
},
created () {
this.getAddressList()
},
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
}
},
methods: {
async getAddressList () {
// 获取收货地址,不需传参
// const res = await getAddressList()
// console.log(res)
const { data: { list } } = await getAddressList()
this.addressList = list
}
}
}
</script>
<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 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" >提交订单</div>
</div>
</div>
</template>
<script>
import { getAddressList } from '@/api/address'
import { checkOrder } from '@/api/order'
export default {
name: 'PayIndex',
data () {
return {
addressList: [],
// 存一下结算台接口返回的数据
order: {},
personal: {}
}
},
created () {
this.getAddressList()
this.getOrderList()
},
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
}
},
methods: {
async getAddressList () {
// 获取收货地址,不需传参
// const res = await getAddressList()
// console.log(res)
const { data: { list } } = await getAddressList()
this.addressList = list
},
async getOrderList () {
// 购物车结算
if (this.mode === 'cart') {
// 可以先打印
const res = await checkOrder(this.mode, { cartIds: this.cartIds })
console.log(res)
// 传参(模式,obj)
const { data: { order, personal } } = await checkOrder(this.mode, {
cartIds: this.cartIds
})
this.order = order
this.personal = personal
}
}
}
}
</script>
订单结算台 - 复用逻辑的封装 & 以立即购买传参方式结算
目标:购物车结算跳转,传递参数,调用接口渲染订单结算台
核心步骤:
- 跳转传递查询参数 模式mode="buyNow", 商品goodsId, 商品规格goodsSkuId, 数量goodsNum
- 跳转的起始出发页,应该是商品详情页src\views\prodetail\index.vue
- 商品详情页在立刻购买按钮,注册点击事件
@click="goBuyNow"
- 商品详情页在methods中写方法goBuyNow,带参数跳转
- 补充:在方法goBuyNow中,还需要确认登录状态
- 在跳转目的页,页面中 $route.query 接收参数
- 跳转目的页,是结算页src\views\pay\index.vue
- 跳转后,跳转目的页应通过计算属性,拿到地址栏中携带的跳转参数
- 在跳转目的页,调用接口,获取数据
- 一进页面就发请求调接口获取,因此在created函数中定义this.getOrderList()
- 在methods方法中,getOrderList()中通过if条件判断,是什么模式,然后传递对应模式的参数
- 调用api的逻辑,引入api/order中的方法
- 基于数据渲染
- 仅传参的数据不一样,因此结构同上一节,不用动
- 未登录时,确认框的复用 (mixins混入)
- 补充:在方法goBuyNow中,还需要确认登录状态
- 核心步骤6:在多个不同的地方,都需要使用到“针对登录状态的判断”
- 将 针对登录状态的判断 的逻辑,进行通用化封装
- 跳转传递查询参数 模式mode="buyNow", 商品goodsId, 商品规格goodsSkuId, 数量goodsNum
核心知识点:
- 在vue2中,逻辑的复用与封装,使用到 mixins 混入技术
- 将需要复用的逻辑,先在组件内封装成一个方法,成为该组件内部的封装逻辑,组件内部的复用封装
- 此方法并非普通的函数,因为其中使用了this.$store、路由等等属于组件内部的数据,调用了各种组件内部才能访问的属性和状态
- 通过mixins 混入技术,在src目录下新建mixins(道理类似展开运算符...obj,展开并加入对象,扩展并新增到对象中)
- 如当前组件中,复用的逻辑部分,确认过没有与属性相关的逻辑、没有与方法相关的逻辑、没有与生命周期提供的逻辑相关的逻辑,后续都可以通过mixins抽离实现复用
- 将原有组件内部的封装逻辑抽离,到一个单独文件src/mixins/loginConfirm.js,进行通用化封装,后续别的组件如果也需要用到判断,可直接引入
- 这个抽离后的单独文件,编写的就是 Vue组件实例的 配置项,通过一定语法,可以直接混入到组件内部
- 注意点1. 如果此处 和 组件内,提供了同名的 data 或 methods, 则组件内优先级更高
- 注意点2. 如果编写了生命周期函数,则mixins中的生命周期函数 和 页面的生命周期函数,会用数组管理,统一执行
- 在需要用到复用逻辑的地方引入
- 通过
import loginConfirm from '@/mixins/loginConfirm'
引入,引入的是整个文件,文件名不需要{}
(引入方法调用需要{}
) - 在export default中,需要写
export default {mixins: [loginConfirm],}
,数组形式中括号 + 复用逻辑的文件名 - 数组形式的引入,表明可以在一个组件内,同时引入多个复用逻辑,将引入的多个复用逻辑,逗号隔开写到
mixins: [复用逻辑a,复用逻辑b]
中 - 引入后,在使用复用的逻辑方法时,直接通过复用逻辑中的方法发起调用,如src\mixins\loginConfirm.js中的loginConfirm()方法,引入后已挂载到this,使用时直接
this.loginConfirm()
- 在复用的逻辑方法中的return中,提供复用部分的逻辑结果,当别的地方发起调用时,直接就拿到结果
- 通过
- mixins 混入技术使用注意
- 多个逻辑混入时,后面混入的逻辑的优先级更高,如逻辑有相交的地方,后面引入的逻辑会覆盖前面的部分
- 当混入的逻辑有与组件内同名的data 或 methods, 则组件内优先级更高
- 当混入的逻辑有生命周期函数,则mixins中的生命周期函数 和 页面的生命周期函数,会用数组管理,统一执行,不会冲突
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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'
// 引入token鉴权,文件/整个模块引入,不用花括号
// import loginConfirm from
export default {
name: 'ProDetail',
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1, // 数字框绑定的数据,默认为1
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
async addCart () {
// 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
if (!this.$store.getters.token) {
// 弹确认框
// console.log('弹确认框')
this.$dialog.confirm({
title: '温馨提示',
message: '此时需要先登录才能继续操作哦',
// 配按钮文本
confirmButtonText: '去登陆',
cancelButtonText: '再逛逛'
})
.then(() => {
// 跳转登录页(不返回原页面)
// this.$router.push('/login')
// 跳转登录页,操作登陆后,返回原页面,需要在跳转时,携带当前所在路径地址
// this.$route.Path(当前路径,不带查询参数)
// this.$route.fullPath(当前路径 + 带查询参数)回跳必用
// 同时需要更改登录页的跳转代码,为了不新增历史,原页面更替,将登陆跳转改为replace
this.$router.replace({
path: '/login',
query: {
backUrl: this.$route.fullPath
}
})
})
// 点取消,啥也不干
.catch(() => { })
return
}
console.log('正常请求')
// 调用购物车方法,并传参
// const res = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// console.log(res)
const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// 购物车数量小角标
this.cartTotal = data.cartTotal
this.$toast('加入购物车成功')
this.showPannel = false
console.log(this.cartTotal)
},
goBuyNow () {
// 需要在商品详情页中的立即购买按钮,增加逻辑判断,对未登录的情况作处理:弹出对话框=》你当前的操作需要登陆后才能继续 =A去登陆 =B再逛逛
// 确认当前已属于已登录的状态
// 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>
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
}
},
<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 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" >提交订单</div>
</div>
</div>
</template>
<script>
import { getAddressList } from '@/api/address'
import { checkOrder } from '@/api/order'
export default {
name: 'PayIndex',
data () {
return {
addressList: [],
// 存一下结算台接口返回的数据
order: {},
personal: {}
}
},
created () {
this.getAddressList()
this.getOrderList()
},
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
}
},
methods: {
async getAddressList () {
// 获取收货地址,不需传参
// const res = await getAddressList()
// console.log(res)
const { data: { list } } = await getAddressList()
this.addressList = list
},
async getOrderList () {
// 购物车结算
if (this.mode === 'cart') {
// 可以先打印
const res = await checkOrder(this.mode, { cartIds: this.cartIds })
console.log(res)
// 传参(模式,obj)
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>
// 将原有组件内部的封装逻辑抽离到一个单独文件
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: {
backUrl: this.$route.fullPath
}
})
})
// 点取消,啥也不干
.catch(() => { })
// 已弹框,返回true
return true
}
// 没有弹框,返回false
return false
}
}
}
<template>
<div class="prodetail">
<van-nav-bar fixed title="商品详情页" left-arrow @click-left="$router.go(-1)" />
<van-swipe :autoplay="3000" @change="onChange">
<!-- v-for="(image, index) in images"为遍历images数组里面的对象(image, index) -->
<van-swipe-item v-for="(image, index) in images" :key="index">
<!-- 轮播图,单个image对象中的external_url -->
<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">
<!-- 评论中没有头像的用户,即item.user.avatar_url为null时,给默认头像 -->
<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'
// 通过mixins技术,引入复用模块,文件/整个模块引入,不用花括号
import loginConfirm from '@/mixins/loginConfirm'
export default {
name: 'ProDetail',
// 引入复用逻辑
mixins: [loginConfirm],
// 引入数字框组件,局部注册
components: {
CountBox
},
data () {
return {
images: [],
current: 0,
detail: {},
total: 0, // 设定的默认评价总数
commentList: [], // 从打印的接口返回数据,开一个commentList数组,用于存放评价列表
defaultImg, // 评价中的用户头像默认图片
mode: 'cart', // 声明一个数据状态量/变量,用来标记,目前唤起弹层的是那个按钮/内容
showPannel: false, // 控制弹层的显示隐藏的data数据状态量,默认赋值为false隐藏
addCount: 1, // 数字框绑定的数据,默认为1
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
// 轮播图
onChange (index) {
this.current = index
},
addFn () {
this.mode = 'cart'
this.showPannel = true
},
buyNow () {
this.mode = 'buyNow'
this.showPannel = true
},
async getDetail () {
// 打印getProDetail接口api返回的数据详情
// const res = await getProDetail(this.goodsId)
// console.log(res)
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
// 打印返回的轮播图照片信息
// console.log(this.images)
},
async getComments () {
// const rescommen = await getProComments(this.goodsId, 3)
// 通过打印,看接口返回的数据,然后再赋值存储,然后到data中设置存储
// console.log(rescommen)
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
// 下面部分改为复用,引入mixins中的loginConfirm
// async addCart () {
// // 判断token是否存在,如果不存在,弹出登录确认框,如果存在,继续加购物车数据操作
// if (!this.$store.getters.token) {
// // 弹确认框
// // console.log('弹确认框')
// this.$dialog.confirm({
// title: '温馨提示',
// message: '此时需要先登录才能继续操作哦',
// // 配按钮文本
// confirmButtonText: '去登陆',
// cancelButtonText: '再逛逛'
// })
// .then(() => {
// // 跳转登录页(不返回原页面)
// // this.$router.push('/login')
// // 跳转登录页,操作登陆后,返回原页面,需要在跳转时,携带当前所在路径地址
// // this.$route.Path(当前路径,不带查询参数)
// // this.$route.fullPath(当前路径 + 带查询参数)回跳必用
// // 同时需要更改登录页的跳转代码,为了不新增历史,原页面更替,将登陆跳转改为replace
// this.$router.replace({
// path: '/login',
// query: {
// backUrl: this.$route.fullPath
// }
// })
// })
// // 点取消,啥也不干
// .catch(() => { })
// return
// }
// console.log('正常请求')
// // 调用购物车方法,并传参
// // const res = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// // console.log(res)
// const { data } = await addCart(this.goodsId, this.addCount, this.detail.skuList[0].goods_sku_id)
// // 购物车数量小角标
// this.cartTotal = data.cartTotal
// this.$toast('加入购物车成功')
// this.showPannel = false
// console.log(this.cartTotal)
// },
// goBuyNow () {
// // 需要在商品详情页中的立即购买按钮,增加逻辑判断,对未登录的情况作处理:弹出对话框=》你当前的操作需要登陆后才能继续 =A去登陆 =B再逛逛
// // 确认当前已属于已登录的状态
// // 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
// }
// })
// }
async addCart () {
// 直接通过复用逻辑中的方法发起调用,如src\mixins\loginConfirm.js中的loginConfirm()方法,引入后已挂载到this,并且返回结果return true或return false
if (this.loginConfirm()) {
return
}
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>
提交订单并支付
目标:封装 API 请求方法,提交订单并支付(支付接口提交订单方式为通过余额)
核心步骤:
- 封装通用请求方法
- 立刻购买调用
- 购物车购买调用
- 买家留言绑定,请求支付时携带
- 注册事件,调用方法提交订单并支付
- 封装通用请求方法
import request from '@/utils/request'
// 订单结算确认
// mode: cart => obj { cartIds }
// mode: buyNow => obj { goodsId goodsNum goodsSkuId }
// 在调用时需要传参,传参mode模式,剩余额外参数obj放到对象
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // cart 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, // 10 快递配送
couponId: 0,
isUsePoints: 0,
payType: 10, // 余额支付
...obj
})
}
<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'
export default {
name: 'PayIndex',
data () {
return {
addressList: [],
// 存一下结算台接口返回的数据
order: {},
personal: {},
remark: '' // 备注留言
}
},
created () {
this.getAddressList()
this.getOrderList()
},
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
}
},
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('支付成功')
// 支付完成,跳转到订单管理页myOrder
this.$router.replace('/myorder')
},
async getAddressList () {
// 获取收货地址,不需传参
// const res = await getAddressList()
// console.log(res)
const { data: { list } } = await getAddressList()
this.addressList = list
},
async getOrderList () {
// 购物车结算
if (this.mode === 'cart') {
// 可以先打印
const res = await checkOrder(this.mode, { cartIds: this.cartIds })
console.log(res)
// 传参(模式,obj)
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>
订单管理 & 个人中心 (快速实现)
目标:基于笔记,快速实现 订单管理 和 个人中心 跑通流程
订单管理部分步骤:
- 订单管理静态布局src\views\myorder\index.vue
- 将小定单封装成组件src\components\OrderListItem.vue,将来有多个订单,通过v-for渲染即可;同时,点击上方的tab,渲染不同状态的订单数据
- 封装获取订单列表的 API 接口src\api\order.js
- 后续页面中发请求调接口渲染src\views\myorder\index.vue
- 订单管理中的各个tab(全部、待支付、待发货、待收货、待评价)不仅在订单管理中,将来也可能是从个人中心进入
- 因此各个van-tab,设置成不同的name属性作绑定,后续通过路由传参,实现访问不同数据的van-tab
- 页面中src\views\myorder\index.vue封装发请求调接口的methods方法
- 小定单组件src\components\OrderListItem.vue动态渲染
订单管理部分
<template>
<div class="order">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.go(-1)" />
<van-tabs v-model="active">
<van-tab title="全部"></van-tab>
<van-tab title="待支付"></van-tab>
<van-tab title="待发货"></van-tab>
<van-tab title="待收货"></van-tab>
<van-tab title="待评价"></van-tab>
</van-tabs>
<!-- 将小定单封装成组件,将来有多个订单,通过v-for渲染即可;点击上方的tab,渲染不同状态的订单数据 -->
<OrderListItem></OrderListItem>
</div>
</template>
<script>
import OrderListItem from '@/components/OrderListItem.vue'
export default {
name: 'OrderPage',
components: {
OrderListItem
},
data () {
return {
active: 0
}
}
}
</script>
<style lang="less" scoped>
.order {
background-color: #fafafa;
}
.van-tabs {
position: sticky;
top: 0;
}
</style>
<template>
<div class="order-list-item">
<div class="tit">
<div class="time">2023-07-01 12:02:13</div>
<div class="status">
<span>待支付</span>
</div>
</div>
<div class="list">
<div class="list-item">
<div class="goods-img">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/c4b5c61e46489bb9b9c0630002fbd69e.jpg" alt="">
</div>
<div class="goods-content text-ellipsis-2">
Apple iPhone 14 Pro Max 256G 银色 移动联通电信5G双卡双待手机
</div>
<div class="goods-trade">
<p>¥ 1299.00</p>
<p>x 3</p>
</div>
</div>
<div class="list-item">
<div class="goods-img">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/c4b5c61e46489bb9b9c0630002fbd69e.jpg" alt="">
</div>
<div class="goods-content text-ellipsis-2">
Apple iPhone 14 Pro Max 256G 银色 移动联通电信5G双卡双待手机
</div>
<div class="goods-trade">
<p>¥ 1299.00</p>
<p>x 3</p>
</div>
</div>
<div class="list-item">
<div class="goods-img">
<img src="http://cba.itlike.com/public/uploads/10001/20230321/c4b5c61e46489bb9b9c0630002fbd69e.jpg" alt="">
</div>
<div class="goods-content text-ellipsis-2">
Apple iPhone 14 Pro Max 256G 银色 移动联通电信5G双卡双待手机
</div>
<div class="goods-trade">
<p>¥ 1299.00</p>
<p>x 3</p>
</div>
</div>
</div>
<div class="total">
共12件商品,总金额 ¥29888.00
</div>
<div class="actions">
<span v-if="false">立刻付款</span>
<span v-if="true">申请取消</span>
<span v-if="false">确认收货</span>
<span v-if="false">评价</span>
</div>
</div>
</template>
<script>
export default {
}
</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>
import Vue from 'vue'
// 按需导入
import { ActionSheet, Button, Checkbox, Dialog, Grid, GridItem, Icon, NavBar, Rate, Search, Swipe, SwipeItem, Switch, Tab, Tabbar, TabbarItem, Tabs, Toast } from 'vant'
// 全局注册
// 需要分开写
Vue.use(Button)
Vue.use(Switch)
Vue.use(Rate)
Vue.use(Tabbar)
Vue.use(TabbarItem)
Vue.use(NavBar)
Vue.use(Toast)
Vue.use(Search)
Vue.use(Swipe)
Vue.use(SwipeItem)
Vue.use(Grid)
Vue.use(GridItem)
Vue.use(Icon)
Vue.use(ActionSheet)
Vue.use(Dialog)
Vue.use(Checkbox)
Vue.use(Tab)
Vue.use(Tabs)
import request from '@/utils/request'
// 订单结算确认
// mode: cart => obj { cartIds }
// mode: buyNow => obj { goodsId goodsNum goodsSkuId }
// 在调用时需要传参,传参mode模式,剩余额外参数obj放到对象
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // cart 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, // 10 快递配送
couponId: 0,
isUsePoints: 0,
payType: 10, // 余额支付
...obj
})
}
// 订单列表
export const getMyOrderList = (dataType, page) => {
return request.get('/order/list', {
params: {
dataType, // 订单类型
page // 用到vant中的List,订单分页
}
})
}
<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>
<!-- 将小定单封装成组件,将来有多个订单,通过v-for渲染即可;点击上方的tab,渲染不同状态的订单数据 -->
<OrderListItem></OrderListItem>
</div>
</template>
<script>
import OrderListItem from '@/components/OrderListItem.vue'
export default {
name: 'OrderPage',
components: {
OrderListItem
},
data () {
return {
active: this.$route.query.dataType || 'all',
page: 1,
list: []
}
}
}
</script>
<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>
<!-- 将小定单封装成组件,将来有多个订单,通过v-for渲染即可;点击上方的tab,渲染不同状态的订单数据 -->
<OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
</div>
</template>
<script>
import { getMyOrderList } from '@/api/order'
import OrderListItem from '@/components/OrderListItem.vue'
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
}
},
// 监视tab的变化,发methods请求拿对应tap页面的数据渲染
watch: {
active: {
immediate: true,
handler () {
this.getOrderList()
}
}
}
}
</script>
<style lang="less" scoped>
.order {
background-color: #fafafa;
}
.van-tabs {
position: sticky;
top: 0;
}
</style>
<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>
<!-- 将小定单封装成组件,将来有多个订单,通过v-for渲染即可;点击上方的tab,渲染不同状态的订单数据 -->
<OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
</div>
</template>
<script>
import { getMyOrderList } from '@/api/order'
import OrderListItem from '@/components/OrderListItem.vue'
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>
个人中心部分步骤:
- 封装获取个人信息的 API 接口src\api\user.js
- 个人中心静态布局
- 个人中心发请求调接口渲染src\views\layout\user.vue
- 在个人中心中,提供methods方法,实现退出登录功能
- 注册点击事件
<button @click="logout">退出登录</button>
- 在src\views\layout\user.vue个人中心页中提供退出登录logout的methods方法,需要
$store.dispatch('user/logout')
提交操作vuex中的数据 - 在vuex中的src\store\modules\user.js用户信息相关的vuex中,提供
logout (context)
退出登录的异步actions,提交mutations中的setUserInfo方法,清空个人信息 - 同时,通过
context.commit('cart/setCartList', [], { root: true })
全局根级别的actions调用,从而实现跨模块调用mutation,清空购物车vuex中数据 - 购物车vuex中数据和token被清空后,src\store\modules\user.js用户信息页的isLogin变为false,v-if的渲染变为未登录页,
<div v-else class="head-page" @click="$router.push('/login')">
- 备注:第一个参数模块名字,第二个是参数空数组,第三个是root: true全局根级别
- vue2跨模块调用
- 注册点击事件
个人中心部分
import request from '@/utils/request'
// 获取个人信息
export const getUserInfoDetail = () => {
return request.get('/user/info')
}
<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>退出登录</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: {
// 通过计算属性,先拿到token
isLogin () {
return this.$store.getters.token
}
},
methods: {
// 拿个人信息的方法
async getUserInfoDetail () {
// 调用api中的getUserInfoDetail()方法
const { data: { userInfo } } = await getUserInfoDetail()
this.detail = userInfo
console.log(this.detail)
}
}
}
</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>
<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 @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: {
// 通过计算属性,先拿到token
isLogin () {
return this.$store.getters.token
}
},
methods: {
// 拿个人信息的方法
async getUserInfoDetail () {
// 调用api中的getUserInfoDetail()方法
const { data: { userInfo } } = await getUserInfoDetail()
this.detail = userInfo
console.log(this.detail)
},
// 退出登录的方法逻辑
logout () {
// 提供退出登录logout的methods方法,需要操作vuex中的数据,提交mutations
// 应该是涉及vuex的数据,需要加$符号?
// vant组件库弹出对话框的方法dialog.confirm,见[vue2中的Dialog 弹出框](https://vant-ui.github.io/vant/v2/#/zh-CN/dialog)
this.$dialog.confirm({
title: '温馨提示',
message: '你确认要退出么?'
})
.then(() => {
// 退出是一个动作 => 包含了两步,分别是将 user 和 cart 进行重置,通过$store.dispatch提交vuex的user模块下的logout方法user/logout
// 因此,到vuex中的src\store\modules\user.js封装一个actions
this.$store.dispatch('user/logout')
})
.catch(() => {
})
}
}
}
</script>
import { getInfo, setInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
// 个人权证相关
// 准备一些默认数据
// userInfo: {
// token: '',
// userId: ''
// }
// 获取时,调用storage中的方法,从本地获取,拿不到时也会自动赋予默认值
userInfo: getInfo()
}
},
mutations: {
// 所有mutations的第一个参数,都是state,第二个是payload形参,调用时的形参,接口返回的是一个对象obj形参
// 封装一个方法,以覆盖/设置上面state中的信息,将来在页面中调用这个方法
setUserInfo (state, obj) {
state.userInfo = obj
// 同时存一份数据,调用storage中的方法,转json存入本地
setInfo(obj)
}
},
actions: {
logout (context) {
// 退出时,异步清空cart和user数据
// 个人信息要重置
// 本模块的参数context通过commit调用mutations传参{ }空对象
context.commit('setUserInfo', {})
// 购物车信息要重置 (跨模块调用 mutation) cart/setCartList
context.commit('cart/setCartList', [], { root: true })
}
},
getters: {}
}
打包发布
打包的作用
目标:明确打包的作用
- 项目上线前需要打包
说明:
- vue脚手架只是开发过程中,协助开发的工具,基于脚手架环境创建项目&开发
- 当真正开发完了 => 脚手架不参与上线(因为脚手架包含开发时的依赖node_modules,也有浏览器不识别的语法)
打包的作用:
- ① 将多个文件压缩合并成一个文件(节约请求次数,减轻服务器压力)
- ② 语法降级(高版本语法降级低版本)
- ③ less sass ts 语法解析(浏览器不直接识别的语法需要解析转换、编译)
- ④ ....
打包的效果:
- 打包后,可以生成,浏览器能够直接运行的网页 => 就是需要上线的源码!
打包的命令 和 配置
- 目标:打包的命令 和 配置
- 说明:vue脚手架工具已经提供了打包命令,直接使用即可。
- 命令:yarn build 或 pnpm build 等
- 结果:
- 在项目的根目录会自动创建一个文件夹
dist
,作为打包好后的文件输出目录 - dist中的文件就是打包后的文件,只需要放到服务器中即可。
- 在项目的根目录会自动创建一个文件夹
- 配置:
- 默认情况下,打包输出的所有文件,运行时加载资源是通过绝对路径,需要放到服务器根目录打开
- 如果希望双击运行,或者放到服务器的子目录,需要配置publicPath 配成相对路径
pnpm build
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
// publicPath的默认值是:'/'绝对路径
// 配置成相对路径
publicPath: './',
transpileDependencies: true
})
打包优化 - 路由懒加载
目标:配置路由懒加载,实现打包优化
背景说明:
- 当打包构建应用时,JavaScript 包会变得非常大(多个页面合并成一个js,一次性将多个资源合并到一个文件),影响页面加载。
- 如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应组件,这样就更加高效了。 官方链接
意义:通过路由懒加载打包配置,分割打包,访问页面时,实现页面按需加载
方法:在src\router\index.js中,将组件的导入方式,改为按需异步导入
- 首页部分,频繁用到,采用默认加载
- 其他页面/模块组件,改为按需加载
步骤1: 异步组件改造
const ProDetail = () => import('@/views/prodetail')
const Pay = () => import('@/views/pay')
...
步骤2: 路由中应用
const router = new VueRouter({
routes: [
...
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/pay', component: Pay },
...
]
})
// import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
// 以下改为按需加载
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'
import SearchList from '@/views/search/list'
// 首页部分,频繁用到,采用默认加载
import Layout from '@/views/layout'
import Cart from '@/views/layout/cart'
import Category from '@/views/layout/category'
import Home from '@/views/layout/home'
import User from '@/views/layout/user'
// 拿vuex中的token判断
import store from '@/store'
Vue.use(VueRouter)
const router = new VueRouter({
// 路由配置中,component中写的组件名字,可以不完全等于组件中的name属性或者文件名字(文件名大小写不一致也可以),会自动找index
// 但是component中写的组件名字需要等于import 引入 from 的名字
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
// 重定向到home页
redirect: '/home',
// 二级子路由页面写在children中,格式为数组[]包对象{}
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
// 所有的路由在真正被访问到之前(解析渲染对应组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面
// 全局前置导航守卫
// to: 到哪里去,到哪去的完整路由信息对象 (路径,参数)
// from: 从哪里来,从哪来的完整路由信息对象 (路径,参数)
// next(): 是否放行
// (1) next() 直接放行,放行到to要去的路径
// (2) next(路径) 进行拦截,拦截到next里面配置的路径
// 定义一个数组,专门用户存放所有需要权限访问的页面
// 需要鉴权的路径,数组方便随时添加
const authUrls = ['/pay', '/myorder']
router.beforeEach((to, from, next) => {
// console.log(to, from, next)
// 打印to, from两个路由信息对象,next是函数
// 看 to.path 是否在 authUrls 中出现过
if (!authUrls.includes(to.path)) {
// 非权限页面,直接放行
next()
return
}
// 是权限页面,需要判断token是否存在,true就放行,拿vuex中的token判断
// const token = store.state.user.userInfo.token
// 到store/index.js中,封装一个全局的getters,后续通过全局的store.getters.token,拿到store.state.user.userInfo.token
const token = store.getters.token
if (token) {
next()
} else {
next('/login')
}
})
export default router
// import Login from '@/views/login'
// 等价于 import Login from '@/views/login/index.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
// 首页部分,频繁用到,采用默认加载
import Layout from '@/views/layout'
import Cart from '@/views/layout/cart'
import Category from '@/views/layout/category'
import Home from '@/views/layout/home'
import User from '@/views/layout/user'
// 拿vuex中的token判断
import store from '@/store'
// 以下改为按需加载
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.use(VueRouter)
const router = new VueRouter({
// 路由配置中,component中写的组件名字,可以不完全等于组件中的name属性或者文件名字(文件名大小写不一致也可以),会自动找index
// 但是component中写的组件名字需要等于import 引入 from 的名字
routes: [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
// 重定向到home页
redirect: '/home',
// 二级子路由页面写在children中,格式为数组[]包对象{}
children: [
{ path: '/home', component: Home },
{ path: '/category', component: Category },
{ path: '/cart', component: Cart },
{ path: '/user', component: User }
]
},
{ path: '/myorder', component: MyOrder },
{ path: '/pay', component: Pay },
// 商品详情,动态路由传参,确认将来是哪个商品,路由参数中携带 id
{ path: '/prodetail/:id', component: ProDetail },
{ path: '/search', component: Search },
{ path: '/searchlist', component: SearchList }
]
})
// 所有的路由在真正被访问到之前(解析渲染对应组件页面前),都会先经过全局前置守卫
// 只有全局前置守卫放行了,才会到达对应的页面
// 全局前置导航守卫
// to: 到哪里去,到哪去的完整路由信息对象 (路径,参数)
// from: 从哪里来,从哪来的完整路由信息对象 (路径,参数)
// next(): 是否放行
// (1) next() 直接放行,放行到to要去的路径
// (2) next(路径) 进行拦截,拦截到next里面配置的路径
// 定义一个数组,专门用户存放所有需要权限访问的页面
// 需要鉴权的路径,数组方便随时添加
const authUrls = ['/pay', '/myorder']
router.beforeEach((to, from, next) => {
// console.log(to, from, next)
// 打印to, from两个路由信息对象,next是函数
// 看 to.path 是否在 authUrls 中出现过
if (!authUrls.includes(to.path)) {
// 非权限页面,直接放行
next()
return
}
// 是权限页面,需要判断token是否存在,true就放行,拿vuex中的token判断
// const token = store.state.user.userInfo.token
// 到store/index.js中,封装一个全局的getters,后续通过全局的store.getters.token,拿到store.state.user.userInfo.token
const token = store.getters.token
if (token) {
next()
} else {
next('/login')
}
})
export default router
异步组件改造前的打包效果,js是主核心逻辑,chunk是第三方包
异步组件改造后的打包效果,将不同的模块组件按需拆分打包,访问对应路由时才去加载
备注Vue.prototype
Vue.prototype
Vue.prototype 是 Vue 实例上的一个特殊属性,它允许你向所有 Vue 实例添加自定义属性和方法。这意味着你可以扩展 Vue 的核心功能,而无需修改 Vue 源代码本身。
用法
要在 Vue.prototype 上添加一个自定义属性或方法,可以使用以下语法:
Copy
Vue.prototype.myCustomProperty = 'foo'
Vue.prototype.myCustomMethod = function () { ... }
一旦你向 Vue.prototype 添加了一个自定义属性或方法,你就可以在任何 Vue 实例中使用它。例如:
Copy
const vm = new Vue({
data: {
message: 'Hello, world!'
}
})
console.log(vm.myCustomProperty) // 'foo'
vm.myCustomMethod() // ...
使用场景
Vue.prototype 通常用于以下场景:
**添加全局方法:**你可以向 Vue.prototype 添加自定义方法,这些方法可以在所有 Vue 实例中使用。这对于创建可重用的实用程序函数非常有用。
**添加全局属性:**你可以向 Vue.prototype 添加自定义属性,这些属性可以在所有 Vue 实例中访问。这对于存储全局配置或状态非常有用。
**创建插件:**你可以通过向 Vue.prototype 添加属性和方法来创建 Vue 插件。这允许你扩展 Vue 的功能,而无需修改 Vue 源代码本身。
最佳实践
在使用 Vue.prototype 时,遵循以下最佳实践非常重要:
**避免与现有属性和方法冲突:**在向 Vue.prototype 添加自定义属性或方法之前,请确保它们不会与现有属性或方法冲突。
**保持模块化:**将相关的自定义属性和方法组织到模块中,以保持代码整洁和易于维护。
**谨慎使用:**只在绝对必要时才向 Vue.prototype 添加自定义属性和方法。滥用 Vue.prototype 会导致代码库臃肿和难以维护。
替代方案
在某些情况下,使用 Vue.mixin 或创建一个 Vue 插件可能是向 Vue 实例添加自定义属性和方法的更好选择。
Vue.mixin:Vue.mixin 允许你创建可重用的对象,这些对象可以混合到 Vue 实例中。这对于创建可重用的组件或添加影响多个组件的功能非常有用。
**Vue 插件:**Vue 插件允许你扩展 Vue 的功能,而无需修改 Vue 源代码本身。这对于创建可与其他插件和应用程序集成的模块化功能非常有用。
最终,选择使用 Vue.prototype、Vue.mixin 或 Vue 插件取决于你的具体需求和偏好。