项目演示
查看项目效果, 明确功能模块
项目收获
通过该项目的学习, 可以收获的内容
创建项目
- 创建命令: vue create hm-shopping-app
- 清理项目多余文件
- 清理 路由配置文件 和 App.vue文件
- 新增 API接口目录 和 utils工具方法目录
vant组件库
第三方封装好了很多的组件, 整合起来就是一个组件库
官网: Vant 2 - Mobile UI Components built on Vue
使用
- 安装: yarn add vant@latest-v2 -S
- 如果遇到安装报错, 可能是版本冲突导致, 尝试 npm i vant@latest-v2 -S --legacy-peer-deps 命令
- 全部导入与按需导入
- 根据官网(快速上手)配置按需导入
- 统一管理Vant组件
// 按需导入vant组件
import Vue from 'vue'
import { Button, Switch } from 'vant'
Vue.use(Button)
Vue.use(Switch)
- 引入配置文件
...
import '@/utils/vant-ui'
- 验证按需加载是否成功
其他组件库
PC端: element-ui (element-plus)(饿了么) ant-design-vue(阿里)
移动端 vant-ui(有赞) Mint UI(饿了么) Cibe UI(滴滴)
vw适配
开发移动端,就要解决屏幕适配问题, 在脚手架环境中,目前主流的解决方案,就是使用postcss插件, 实现px单位自动换算成vw单位
- 安装: yarn add -D postcss-px-to-viewport@1.1.1
- 说明: postcss插件不同版本的配置略有差异, 本项目统一使用1.1.1版本
- 根目录新建 postcss.config.js文件
module.exports = {
plugins: {
'postcss-px-to-viewport': {
// vw适配的标准屏幕宽度 iphoneX
// 设计图750时, 使用1倍图=>适配375的标准屏幕(最常用)
// 设计图640时, 使用1倍图=>适配320的标准屏幕
viewportWidth: 375
}
}
}
- 测试: css书写px单位, 编译后检查元素单位是否是vw
配置路由
但凡是单个页面, 独立展示的, 都是一级路由
一级路由
- 配置路由规则
... ...
const routes = [
{ path: '/', redirect: '/layout' },
{ path: '/login', component: () => import('../views/login/index.vue') },
{ path: '/myorder', component: () => import('../views/myorder/index.vue') },
{ path: '/pay', component: () => import('../views/pay/index.vue') },
{ path: '/search', component: () => import('../views/search/index.vue') },
{ path: '/searchlist', component: () => import('../views/search/list.vue') },
{ path: '/prodetail/:id', component: () => import('../views/prodetail/index.vue') },
{ path: '/search', component: () => import('../views/search/index.vue') },
{ path: '/address', component: () => import('../views/address/index.vue') }
]
.. ...
- 一级路由出口
<template>
<div id="app">
<router-view></router-view>
</div>
</template>
二级路由
- 配置二级路由规则
... ...
const routes = [
{
path: '/layout',
component: () => import('../views/layout/index.vue'),
redirect: '/home',
children: [
{ path: '/home', component: () => import('../views/layout/home.vue') },
{ path: '/cart', component: () => import('../views/layout/cart.vue') },
{ path: '/user', component: () => import('../views/layout/user.vue') },
{ path: '/category', component: () => import('../views/layout/category.vue') }
]
}
]
... ...
- 配置导航链接/二级路由出口
<template>
<div class="continer">
<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>
登录页
1.样式初始化
- 新建style/common.less, 重置默认样式
// 重置默认样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
// color: #333;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
}
// 文字溢出省略号
.text-ellipsis-2 {
overflow: hidden;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
- main.js导入common.less
... ...
import '@/style/common.less'
... ...
- 把图片等素材拷贝到assets目录
2.静态布局
- 使用vant-nav-bar组件
<template>
<div class="login">
<!-- 注意要按需引入 -->
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
... ...
</div>
</template>
- 通过样式覆盖修改箭头的颜色
// 自定义van-nav-bar左侧箭头颜色
.van-nav-bar {
.van-nav-bar__arrow {
color: #333;
}
}
- 其他静态结构的编写
<template>
<div class="login">
<van-nav-bar title="会员登录" left-arrow @click-left="$router.go(-1)" />
<div class="container">
<div class="title">
<h3>手机号登录</h3>
<p>未注册的手机号登录后将自动注册</p>
</div>
<div class="form">
<div class="form-item">
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
<button @click="getMagCode">
{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
</button>
</div>
</div>
<div class="login-btn" @click="login">登录</div>
</div>
</div>
</template>
<script>
export default {
name: 'LoginPage',
data () {
return { }
},
created () { },
methods: { }
}
</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>
3.封装axios模块
把axios请求进行封装到request模块, 主要目的是添加一些配置, 如基础地址, 请求和响应拦截器等
如果项目中需要请求不同的地址,可以创建多个axios实例
步骤
- 官网: Axios 实例 | Axios中文文档 | Axios中文网
- 安装: yarn add axios
- 新建src/utils/request.js模块
import axios from 'axios'
// 1,创建axios实例
const instance = axios.create({
baseURL: 'http://cba.itlike.com/public/index.php?s=/api/',
timeout: 1500
})
// 2,添加请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})
// 3,添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么(默认axios会多包装一层data,需要响应拦截器中处理一下)
const res = response.data
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
export default instance
- 测试使用
... ...
<script>
import request from '@/utils/request'
export default {
created () {
request.get('/captcha/image')
}
}
</script>
4.封装api模块
把请求封装成方法, 与页面分离, 统一管理请求
好处
- 请求与页面逻辑分离
- 相同的请求可以复用
- 请求统一管理便于查找修改
步骤
- 新建请求模块 src/api/login.js
- 封装请求函数( 方法要按需导出, 请求结果要return )
// 登录相关接口
import request from '@/utils/request'
// 获取图形验证码
export const getPriceCode = () => {
return request.get('/captcha/image')
}
- 页面中导入使用
<script>
import { getPriceCode } from '@/api/login'
export default {
... ...
created () {
this.getPicCode()
},
methods: {
// 获取图形验证码
async getPicCode () {
const { data: { base64, key } } = await getPriceCode()
},
}
}
</script>
5.图形验证码
图形验证码本质就是一个请求回来的图片, 作用就是强制人机交互, 可以抵御机器自动化攻击, 比如通过批量请求爆破接口, 恶意刷票,论坛灌水等
需求:
- 效果
- 把请求回来的base64图片,动态展示出来
- 点击验证码盒子, 要刷新验证码
步骤:
- 图片就是让用户看的
- 用户输入的验证码要和key值一起提交, 服务器才能判断验证是否成功
- 代码实现
<template>
<div class="login">
... ...
<div class="form-item">
<!-- 3,展示验证码 -->
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
... ...
</div>
</template>
<script>
import { getPriceCode, getMsgCode, codeLogin } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
picKey: '', // 将来请求传递的图形验证码唯一标识
picUrl: '', // 存储请求渲染的图片地址
picCode: '', // 用户输入的图形验证码
}
},
created () {
this.getPicCode()
},
methods: {
// 1,获取图形验证码
async getPicCode () {
const { data: { base64, key } } = await getPriceCode()
//2, 保存关键数据
this.picUrl = base64 // 存储图片地址
this.picKey = key // 存储唯一标识
},
}
}
</script>
6.totas轻提示
- 注册安装
- 导入调用: 组件和非组件都可以使用
- this调用: 必须组件内调用
- 在注册该组件后, vant自动把该方法, 挂载到了vue原型上 (Vue.prototype.$toast = xxxx)
7,短信验证码
- 点击按钮, 实现倒计时效果
- 倒计时之前, 校验手机号和验证码
- 封装接口, 获取短信验证码
<template>
<div class="login">
<div class="container">
<div class="form">
<div class="form-item">
<input v-model="mobile" class="inp" maxlength="11" placeholder="请输入手机号码" type="text">
</div>
<div class="form-item">
<input v-model="picCode" class="inp" maxlength="5" placeholder="请输入图形验证码" type="text">
<img v-if="picUrl" :src="picUrl" @click="getPicCode" alt="">
</div>
<div class="form-item">
<input v-model="msgCode" class="inp" placeholder="请输入短信验证码" type="text">
<button @click="getMagCode">
{{ second === totalSecond ? '获取验证码' : second + '秒后重新发送'}}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { getMsgCode } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
... ...
totalSecond: 60, // 总秒数 (用于归位)
second: 60, // 当前秒数,(用于倒计时)
timer: null, // 定时器 id
mobile: '', // 手机号
picCode: '', // 用户输入的图形验证码
msgCode: '246810' // 短信验证码
}
},
methods: {
// 手机号/图形码校验
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 getMagCode () {
// 2,校验手机号/图形码
if (!this.validFn()) return
// 定时器为null 且 当前秒数==总秒数, 才可以获取短信验证码
if (!this.timer && this.second === this.totalSecond) {
// 3,获取短信验证码
await getMsgCode(this.picCode, this.picKey, this.mobile)
this.$toast('验证码已发送')
// 1,倒计时效果
this.timer = setInterval(() => {
this.second--
if (this.second <= 0) {
clearInterval(this.timer) // 清除定时器
this.timer = null // 清除定时器
this.second = this.totalSecond // 重置秒数
}
}, 1000)
}
},
destroyed () {
// 清除定时器
clearInterval(this.timer)
}
}
}
</script>
8.登录
- 封装登录接口
- 登录前校验(手机号/图形验证码/短信验证码)
- 发起登录请求, 成功后提示, 跳转首页
<template>
<div class="login">
... ...
<div class="login-btn" @click="login">登录</div>
</div>
</div>
</template>
<script>
import { getPriceCode, getMsgCode, codeLogin } from '@/api/login'
export default {
name: 'LoginPage',
data () {
return {
mobile: '', // 手机号
msgCode: '246810' // 短信验证码
}
},
methods: {
... ...
// 登录
async login () {
// 校验手机号/图形码
if (!this.validFn()) return
// 校验短信验证码
if (!/^\d{6}$/.test(this.msgCode)) {
this.$toast('请输入正确的短信验证码')
return
}
// 登录
const res = await codeLogin(this.mobile, this.msgCode)
this.$toast('登录成功')
this.$router.replace('/')
},
}
}
</script>
9.统一处理错误
所有的请求, 都可能出现错误,, 可以单独处理, 但是更好的建议是, 通过响应拦截器, 统一处理接口错误
优势:
- 统一处理请求错误后, 请求相关的代码都只需要考虑正常逻辑即可
- 全局处理请求错误依赖后台, 后台需要返回合适的错误信息
import { Toast } from 'vant'
... ...
// 响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。
const res = response.data
// 统一处理请求错误
if (res.status !== 200) {
// 预期: 响应的状态码非200, 抛出错误, 给用户提示
// 原理: await只会等待成功的promise, 抛出错误后程序不再往下执行
Toast(res.message) // 弹出提示
return Promise.reject(res) // 返回一个错误的promise
}
return res
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error)
})
10.保存权证信息
把权证信息保存到vuex中, 方便其他页面的使用
- 构建user模块
export default {
namespaced: true,
state () {
return {}
},
mutations: {},
actions: {},
getters: {}
}
- 挂载到vuex
import user from './modules/user'
export default new Vuex.Store({
... ...
modules: {
user,
}
})
- 提供mutations
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: {
token: '',
userId: '',
}
}
},
mutations: {
// 设置用户信息
SET_USER_INFO (state, userInfo) {
state.userInfo = userInfo
setInfo(userInfo) // 存储用户信息到本地
}
},
actions: {},
getters: {}
}
- 页面中调用
<script>
export default {
... ...
methods: {
... ...
// 登录
async login () {
... ...
const res = await codeLogin(this.mobile, this.msgCode)
// 保存用户权证
this.$store.commit('user/SET_USER_INFO', res.data)
... ...
},
}
}
</script>
11.持久化存储
封装storage存储模块, 利用本地存储, 进行vuex持久化处理
// 约定通用的键名
const INFO_KEY = 'hm_shopping_info'
// 获取个人信息
export const getInfo = () => {
const defaultObj = { token: '', userId: '' }
const result = localStorage.getItem(INFO_KEY)
return result ? JSON.parse(result) : defaultObj
}
// 设置个人信息
export const setInfo = (obj) => {
localStorage.setItem(INFO_KEY, JSON.stringify(obj))
}
// 清除个人信息
export const clearInfo = () => {
localStorage.removeItem(INFO_KEY)
}
import { getInfo, setInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: getInfo()
}
},
mutations: {
// 设置用户信息
SET_USER_INFO (state, userInfo) {
state.userInfo = userInfo
setInfo(userInfo) // 存储用户信息到本地
}
},
actions: {},
getters: {}
}
12.添加loading效果
请求的时候统一添加loading效果
好处
- 节流处理: 防止用户在一次请求结束之前, 多次进行点击, 发送无效的请求
- 友好提示: 告知用户, 正在加载中, 用户体验更好
步骤
- 在请求拦截器中, 打开loading
- 在响应拦截器中, 关闭loading
import { Toast } from 'vant'
// 请求拦截器
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 1,开启loading效果
Toast.loading({
message: '加载中...',
forbidClick: true,
loadingType: 'spinner', // loading的类型
duration: 0 // 不会自动消失
})
... ...
}, function (error) {
... ...
})
// 响应拦截器
instance.interceptors.response.use(function (response) {
if (res.status !== 200) {
... ...
} else {
// 请求正常, 关闭loading效果,代码继续执行后面的业务
Toast.clear()
}
}, function (error) {
... ...
})
13.页面鉴权
基于全局前置路由守卫, 进行页面访问拦截处理
路由导航守卫
- 所有的路由一旦被匹配到, 都会先经过全局前置守卫
- 只有全局前置守卫放行, 才会真正解析渲染组件, 才能看到页面的内容
- fullPath可以拿到路由参数, path只可以拿到路由
import store from '@/store/index'
... ...
// 所有的权限页面
const authUrls = ['/pay','/myorder']
// 路由前置守卫
router.beforeEach((to, from, next) => {
if (!authUrls.includes(to.path)) {
// 非权限页面 直接放行
next()
// return是必须的, 不然会一直执行next()
return
}
const token = store.getters.token
if (token) {
// 权限页面, 判断有token, 放行
next()
} else {
// 权限页面, 无token,强制去登录
next('/login')
}
})
... ...
export default new Vuex.Store({
getters: {
// 方便获取token, 封装getters
token (state) {
return state.user.userInfo.token
}
},
... ...
})
首页
1.0静态结构
2.0封装接口
import request from '@/utils/request'
// 获取首页数据
export const getHomeData = () => {
return request.get('/page/detail', {
params: {
pageId: 0
}
})
}
3.0页面调用
<script>
import { getHomeData } from '@/api/home'
export default {
data () {
return {
bannerList: [], // 轮播
navList: [], // 导航
proList: [] // 商品
}
},
async created () {
const { data: { pageData } } = await getHomeData()
this.bannerList = pageData.items[1].data
this.navList = pageData.items[3].data
this.proList = pageData.items[6].data
}
}
</script>
4.0动态渲染
<template>
<div class="home">
<!-- 导航条 -->
<van-nav-bar title="智慧商城" fixed />
<!-- 搜索框 -->
<van-search
readonly
shape="round"
background="#f1f1f2"
placeholder="请在此输入搜索关键词"
@click="$router.push('/search')"
/>
<!-- 轮播图 -->
<van-swipe class="my-swipe" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="item in bannerList" :key="item.imgUrl">
<img :src="item.imgUrl" alt="">
</van-swipe-item>
</van-swipe>
<!-- 导航 -->
<van-grid column-num="5" icon-size="40">
<van-grid-item
v-for="item in navList" :key="item.imgUrl"
:icon="item.imgUrl"
text="新品首发"
@click="$router.push('/category')"
/>
</van-grid>
<!-- 主会场 -->
<div class="main">
<img src="@/assets/main.png" alt="">
</div>
<!-- 猜你喜欢 -->
<div class="guess">
<p class="guess-title">—— 猜你喜欢 ——</p>
<div class="goods-list">
<GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
</div>
</div>
</div>
</template>
<template>
<div v-if="item.goods_id" class="goods-item" @click="$router.push(`/prodetail/${item.goods_id}`)">
<div class="left">
<img :src="item.goods_image" alt="" />
</div>
<div class="right">
<p class="tit text-ellipsis-2">
{{ item.goods_name }}
</p>
<p class="count">已售 {{ item.goods_sales }} 件</p>
<p class="price">
<span class="new">¥{{ item.goods_price_min }}</span>
<span class="old">¥{{ item.goods_price_max }}</span>
</p>
</div>
</div>
</template>
<script>
export default {
name: 'GoodsItem',
props: {
item: {
type: Object,
default: () => {
return {}
}
}
}
}
</script>
5.0搜索管理页
需求
- 搜索历史动态渲染
- 点击搜索按钮或者历史记录, 进行搜索
- 已存在的搜索关键字, 先移除再添加到最前面
- 不存在的搜索关键字, 添加到最前面
- 点击清空图标, 清空历史记录
- 持久化储存
代码
<template>
<div class="search">
<van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search v-model="search" show-action placeholder="请输入搜索关键词" clearable>
<template #action>
<div @click="goSearch(search)">搜索</div>
</template>
</van-search>
<!-- 搜索历史 -->
<div class="search-history" v-if="history.length > 0">
<div class="title">
<span>最近搜索</span>
<van-icon @click="clear" name="delete-o" size="16" />
</div>
<div class="list">
<div v-for="item in history" :key="item" class="list-item" @click="goSearch(item)">
{{ item }}
</div>
</div>
</div>
</div>
</template>
<script>
import { setHistory, getHistory } from '@/utils/storage'
export default {
name: 'SearchIndex',
data () {
return {
search: '', // 输入框的内容
history: getHistory() // 历史记录
}
},
methods: {
// 添加搜索历史
goSearch (key) {
const index = this.history.indexOf(key)
if (index !== -1) {
// 存在于原数组, 删除
this.history.splice(index, 1)
}
// 头部追加
this.history.unshift(key)
// 持久化存储
setHistory(this.history)
// 跳转列表页
this.$router.push(`/searchlist?search=${key}`)
},
// 清空搜索历史
clear () {
this.history = []
setHistory([])
}
}
}
</script>
// 约定通用的键名
const HISTORY_KEY = 'hm_history_key'
// 添加搜索历史记录
export const setHistory = (arr) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr))
}
// 获取搜索历史记录
export const getHistory = () => {
const result = localStorage.getItem(HISTORY_KEY)
return result ? JSON.parse(result) : []
}
6.0搜索列表页
需求
- 准备静态结构
- 封装接口
- 请求数据
- 动态渲染
代码
import request from '@/utils/request'
// 获取搜索商品列表
export const getProList = (obj) => {
const { categoryId, goodsName, page } = obj
return request.get('/goods/list', {
params: {
categoryId, // 商品分类id
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>
<!-- 3,渲染商品列表 -->
<div class="goods-list">
<GoodsItem v-for="item in proList" :key="item.goods_id" :item="item"></GoodsItem>
</div>
</div>
</template>
<script>
import GoodsItem from '@/components/GoodsItem.vue'
import { getProList } from '@/api/product'
export default {
name: 'SearchIndex',
components: {
GoodsItem
},
computed: {
// 1, 获取地址栏的搜索关键字
querySearch () {
return this.$route.query.search
}
},
data () {
return {
page: 1,
proList: []
}
},
// 2, 请求列表数据
async created () {
const { data: { list } } = await getProList({
goodsName: this.querySearch,
page: this.page
// 商品分类id: 兼容商品分类页,
// 如果值是undefiend或null, axios会自动过滤
categoryId: this.$route.query.categoryId,
})
this.proList = list.data
}
}
</script>
7.0商品分类页
需求
- 准备静态结构
- 封装接口
- 请求数据
- 动态渲染
- 点击跳转搜索列表页
代码
import request from '@/utils/request'
// 获取分类数据
export const getCategoryData = () => {
return request.get('/category/list')
}
<template>
<div class="category">
<!-- 分类 -->
<van-nav-bar title="全部分类" fixed />
<!-- 搜索框 -->
<van-search
readonly
shape="round"
background="#f1f1f2"
placeholder="请输入搜索关键词"
@click="$router.push('/search')"
/>
<!-- 分类列表 -->
<!-- 2,渲染数据 -->
<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">
<!-- 3,点击二级分类, 跳转搜索列表页 -->
<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 () {
// 1, 请求数据
this.getCategoryList()
},
data () {
return {
list: [],
activeIndex: 0
}
},
methods: {
async getCategoryList () {
const { data: { list } } = await getCategoryData()
this.list = list
}
}
}
</script>
商品详情页
步骤
- 静态结构
- 封装接口
- 请求数据
- 渲染页面
代码
import request from '@/utils/request'
// 获取商品详情数据
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="4000" @change="onChange">
<van-swipe-item v-for="(image, index) in images" :key="index">
<img :src="image.external_url" />
</van-swipe-item>
<template #indicator>
<div class="custom-indicator">{{ current + 1 }} / {{ images.length }}</div>
</template>
</van-swipe>
<!-- 3,商品说明 -->
<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>
<!-- 5,商品评价 -->
<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">
<!-- 头像可能为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>
<!-- 4,商品描述 -->
<div class="desc" v-html="detail.content">
</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: [], // 评价列表
defaultImg, // 评论的默认头像(头像可能会为null)
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
created () {
this.getDetail()
this.getComments()
},
methods: {
onChange (index) {
this.current = index
},
// 1,获取商品详情
async getDetail () {
const { data: { detail } } = await getProDetail(this.goodsId)
this.detail = detail
this.images = detail.goods_images
},
// 2,获取商品评价
async getComments () {
const { data: { list, total } } = await getProComments(this.goodsId, 3)
this.commentList = list
this.total = total
},
}
}
</script>
加入购物车
1.0唤起弹层
需求
- 熟悉van-action-sheet组件
- 完善弹层结构
- 动态渲染
<template>
<div class="prodetail">
... ...
<!-- 底部 -->
<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>
<span>数字框组件占位</span?
</div>
<!-- 有库存才显示提交按钮 -->
<div class="showbtn" v-if="detail.stock_total > 0">
<div class="btn" v-if="mode === 'cart'">加入购物车</div>
<div class="btn now" v-else >立刻购买</div>
</div>
<!-- 没有库存显示商品暂无 -->
<div class="btn-none" v-else>该商品已抢完</div>
</div>
</van-action-sheet>
</div>
</template>
2.0数字框组件
步骤
- 静态结构, 左中右三部分
- 数字框的数字, 应该是外部传递
- 点击+-号, 可以修改数据
- 使用v-model实现封装
- 数字不能小于1
代码
<template>
<!-- 加入购物车/立即购买 公用的弹层 -->
<van-action-sheet>
... ...
<div class="num-box">
<span>数量</span>
<!-- 1, 使用组件 -->
<!-- v-model 本质上 :value 和 @input 的简写 -->
<CountBox v-model="addCount"></CountBox>
</div>
</van-action-sheet>
</template>
<script>
import CountBox from '@/components/CountBox.vue'
export default {
components: {
CountBox
},
data () {
return {
addCount: 1, // 数字框绑定的数据
}
},
}
</script>
<template>
<div class="count-box">
<button @click="handleSub" class="minus">-</button>
<input :value="value" @change="handleChange" class="inp" type="text">
<button @click="handleAdd" class="add">+</button>
</div>
</template>
<script>
export default {
props: {
value: {
type: Number,
default: 1
}
},
methods: {
// +号事件
handleSub () {
if (this.value <= 1) {
return
}
this.$emit('input', this.value - 1)
},
// -号事件
handleAdd () {
this.$emit('input', this.value + 1)
},
// 输入事件
handleChange (e) {
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>
3.0添加购物车
步骤
- 加入购物车, 是一个登录后的用户才能进行的操作
- 不存在token, 引导用户登录, 登录完回跳
- 存在token, 继续加入购物车
- 封装添加购物车的接口(添加请求头参数)
- 加入购物车后, 展示数量的角标
代码
// 添加请求拦截器
instance.interceptors.request.use(function (config) {
// 只要有token, 就在请求时携带token, 便于请求需要授权的接口
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
config.headers.platform = 'H5'
}
return config
}, function (error) {
... ...
})
import request from '@/utils/request'
// 加入购物车
// goodsId => 商品id iphone8
// goodsSkuId => 商品规格id 红色的iphone8 粉色的iphone8
export const addCart = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/add', {
goodsId,
goodsNum,
goodsSkuId
})
}
<template>
<div class="prodetail">
... ...
<!-- 底部 -->
<div class="footer">
... ...
<div @click="$router.push('/cart')" class="icon-cart">
<!-- 6,展示购物车角标 -->
<span v-if="cartTotal > 0" class="num">{{ cartTotal }}</span>
<van-icon name="shopping-cart-o" />
<span>购物车</span>
</div>
</div>
<!-- 加入购物车/立即购买 公用的弹层 -->
<van-action-sheet>
... ...
<div class="showbtn" v-if="detail.stock_total > 0">
<!-- 1, 加入购物车 -->
<div class="btn" v-if="mode === 'cart'" @click="addCart">加入购物车</div>
<div class="btn now" v-else @click="goBuyNow">立刻购买</div>
</div>
</van-action-sheet>
</div>
</template>
<script>
import { addCart } from '@/api/cart'
export default {
name: 'ProDetail',
data () {
return {
showPannel: false, // 控制弹层的显示隐藏
mode: 'cart', // 标记弹层状态
addCount: 1, // 数字框绑定的数据
cartTotal: 0 // 购物车角标
}
},
computed: {
goodsId () {
return this.$route.params.id
}
},
methods: {
// 登录判断
loginConfirm () {
if (!this.$store.getters.token) {
// 4,没有登录, 展示弹窗
this.$dialog.confirm({
title: '温馨提示',
message: '登录后才可以操作购物车',
confirmButtonText: '去登录',
cancelButtonText: '再逛逛'
})
.then(() => {
this.$router.replace({
path: '/login',
query: {
// 携带参数,用于回跳
backPath: this.$route.fullPath
}
})
})
return false
}
// 已经登录
return true
}
// 2,加入购物车
async addCart () {
// 3,判断是否登录
if (!this.loginConfirm()) {
return
}
// 5,已经登录
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>
export default {
methods: {
async login () {
... ...
this.$toast('登录成功')
// 判断处理:
// 1,地址栏有参数,别的页面拦截过来的->回跳操作
// 2,地址栏无参数->去首页
const url = this.$route.query.backPath || '/'
this.$router.replace(url)
},
}
}
</script>
购物车
需求
- 静态结构
- 创建vuex的cart模块
- 请求数据, 动态渲染购物车列表
- 封装getters实现动态统计
- 全选反选功能
- 数字框修改数量功能
- 编辑切换状态, 删除功能
- 空购物车处理
1.0创建cart模块
import request from '@/utils/request'
// 获取购物车列表
export const getCartList = () => {
return request.get('/cart/list')
}
import { getCartList } from '@/api/cart'
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
// 转存购物车数据
setCartList (state, cartList) {
state.cartList = cartList
},
},
actions: {
// 获取购物车列表
async GET_CART_LIST ({ commit }) {
const { data } = await getCartList()
// 后台返回的数据中缺少复选框状态, 我们自己添加
data.list.forEach(item => {
item.isChecked = true
})
commit('setCartList', data.list)
},
},
getters: {}
}
import cart from './modules/cart'
export default new Vuex.Store({
modules: {
cart
}
})
<script>
export default {
computed: {
// 用户是否登录
isLogin () {
return this.$store.getters.token
}
},
created () {
// 必须是登录过的用户,才能用户购物车列表
if (this.isLogin) {
this.$store.dispatch('cart/GET_CART_LIST')
}
},
}
</script>
2.0渲染购物车列表
<template>
<div class="cart">
... ...
<div v-if="isLogin && cartList.length > 0">
<!-- 购物车列表 -->
<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>
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层 -->
<!-- @input="changeCount" -->
<CountBox :value="item.goods_num"></CountBox>
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
export default {
computed: {
...mapState('cart', ['cartList']),
},
}
</script>
3.0动态统计
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
getters: {
// 所有商品的总数
cartTotal (state) {
return state.cartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中的商品
selCartList (state) {
return state.cartList.filter(item => item.isChecked)
},
// 选中的商品总数
selCount (stat, getterse) {
// 注意: 可以在getters中继续使用其他getters的值
return getterse.selCartList.reduce((sum, item) => sum + item.goods_num, 0)
},
// 选中商品的总价
selPrice (stat, getters) {
return getters.selCartList.reduce((sum, item) => {
return sum + item.goods.goods_price_min * item.goods_num
}, 0).toFixed(2)
},
}
}
<template>
<div class="cart">
<div v-if="isLogin && cartList.length > 0">
<!-- 购物车开头 -->
<div class="cart-title">
<span class="all">共<i>{{ cartTotal }}</i>件商品</span>
... ...
</div>
<div class="footer-fixed">
... ...
<div class="all-total">
<div class="price">
<span>合计:</span>
<span>¥ <i class="totalPrice">{{ selPrice }}</i></span>
</div>
<div v-if="!isEdit" class="goPay" :class="{ disabled: selCount === 0 }">结算({{ selCount }})</div>
<div v-else class="delete" :class="{ disabled: selCount === 0 }" >删除</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters('cart', ['cartTotal', 'selCartList', 'selCount', 'selPrice']),
},
}
</script>
4.0全选反选功能
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
// 改变商品的选中状态
toggleCheck (state, goodsIs) {
const goods = state.cartList.find((item) => item.goods_id === goodsIs)
goods.isChecked = !goods.isChecked
},
// 改变所有商品的状态
toggleAllCheck (state, status) {
state.cartList.forEach(item => {
item.isChecked = status
})
},
},
getters: {
// 是否全选
isAllChecked (state) {
return state.cartList.every(item => item.isChecked)
}
}
}
<template>
<div class="cart">
... ...
<div v-if="isLogin && cartList.length > 0">
<!-- 购物车列表 -->
<div class="cart-list">
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
<!-- 1, 切换商品选中状态 -->
<van-checkbox @click="toggleCheck(item.goods_id)" :value="item.isChecked"></van-checkbox>
... ...
</div>
</div>
<div class="footer-fixed">
<!-- 3, 全选/全不选商品 -->
<div @click="toggleAllCheck" class="all-check">
<!-- 2, 如果所有商品都选中, 自动激活全选按钮 -->
<van-checkbox :value="isAllChecked" icon-size="18"></van-checkbox>
全选
</div>
... ...
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters('cart', ['isAllChecked']),
},
methods: {
// 商品选择框
toggleCheck (goodsId) {
this.$store.commit('cart/toggleCheck', goodsId)
},
// 商品全选按钮
toggleAllCheck () {
this.$store.commit('cart/toggleAllCheck', !this.isAllChecked)
},
},
}
</script>
5.0修改数量
import request from '@/utils/request'
// 更新购物车商品数量
export const changeCount = (goodsId, goodsNum, goodsSkuId) => {
return request.post('/cart/update', {
goodsId,
goodsNum,
goodsSkuId
})
}
import { changeCount } from '@/api/cart'
export default {
namespaced: true,
state () {
return {
cartList: []
}
},
mutations: {
// 改变商品数量
changeGoodsCount (state, obj) {
const { goodsId, goodsNum } = obj
const goods = state.cartList.find(item => item.goods_id === goodsId)
goods.goods_num = goodsNum
}
},
actions: {
// 修改购物车商品的数量
async CHANGE_GOODS_NUM ({ commit }, obj) {
const { goodsId, goodsNum, goodsSkuId } = obj
// 先更新本地数量
commit('changeGoodsCount', { goodsId, goodsNum })
// 再更新后台数量
await changeCount(goodsId, goodsNum, goodsSkuId)
},
},
}
<template>
<div class="cart-list">
<div class="cart-item" v-for="item in cartList" :key="item.goods_id">
... ...
<!-- 既希望保留原本的形参,又需要通过调用函数传参 => 箭头函数包装一层 -->
<!-- @input="changeCount" -->
<CountBox @input="(value) => changeCount(value, item.goods_id, item.goods_sku_id)" :value="item.goods_num"></CountBox>
... ...
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex'
export default {
methods: {
// 修改商品数量
changeCount (goodsNum, goodsId, goodsSkuId) {
// console.log(goodsNum, goodsId, goodsSkuId)
// 调用 vuex 的 action,进行数量的修改
this.$store.dispatch('cart/CHANGE_GOODS_NUM', {
goodsNum,
goodsId,
goodsSkuId
})
},
},
}
</script>
6.0切换状态
点击编辑, 结算模式切换到删除模式, 点击删除可以删除购物车商品, 点击结算跳转到订单结算页面
import request from '@/utils/request'
// 删除购物车商品
export const delSelect = (cartIds) => {
return request.post('/cart/clear', {
cartIds
})
}
import { delSelect } from '@/api/cart'
export default {
actions: {
// 删除商品
async DEL_SELECT_GOODS ({ getters, dispatch }) {
// 拿到选中的商品id
const goodsIds = getters.selCartList.map(item => item.id)
// 删除
await delSelect(goodsIds)
// 刷新数据
dispatch('GET_CART_LIST')
}
},
}
<template>
<div>
<!-- 购物车开头 -->
<div class="cart-title">
<!-- 1, 切换页面模式 -->
<span class="edit" @click="isEdit = !isEdit">
<van-icon name="edit" />
编辑
</span>
</div>
<div class="footer-fixed">
<div class="all-total">
... ...
<!-- 2, 不同模式显示不同按钮 -->
<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>
export default {
data () {
return {
// 页面模式(编辑|删除)
isEdit: false
}
},
methods: {
// 4,删除商品
async handleDel () {
if (this.selCount === 0) return false
await this.$store.dispatch('cart/DEL_SELECT_GOODS')
this.isEdit = false
},
},
watch: {
// 3,根据页面模式改变商品的选中状态
isEdit (value) {
if (value) {
// 删除模式, 商品全不选
this.$store.commit('cart/toggleAllCheck', false)
} else {
// 结算模式, 商品全选
this.$store.commit('cart/toggleAllCheck', true)
}
}
}
}
</script>
7.0空购物车
<template>
<div class="cart">
<!-- 数据列表 -->
<div v-if="isLogin && cartList.length > 0">
... ...
</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>
export default {
computed: {
// 用户是否登录
isLogin () {
return this.$store.getters.token
}
},
}
</script>
订单结算台
需求
- 订单收货地址管理
- 封装订单确认接口
- 结算台渲染
- 购物车结算
- 立即购买结算
- minxis混入
- 提交订单并支付
01.收货地址管理
import request from '@/utils/request'
// 添加地址列表
export const postAddress = (data) => {
return request({
headers: {
'Content-Type': 'application/json;charset=UTF-8'
},
url: '/address/add',
method: 'POST',
data
})
}
<template>
<div>
<van-nav-bar title="地址管理" left-arrow @click-left="$router.go(-1)" />
<van-button type="primary" @click="add">添加收获地址</van-button>
</div>
</template>
<script>
import { postAddress } from '@/api/address'
export default {
name: 'AddressIndex',
methods: {
// 添加收获地址
async add () {
await postAddress({
form: {
name: '张小二',
phone: '18999292929',
region: [
{
value: 782,
label: '上海'
},
{
value: 783,
label: '上海市'
},
{
value: 785,
label: '徐汇区'
}
],
detail: '北京路1号楼8888室'
}
})
}
}
}
</script>
02.封装订单确认接口
订单确认成功后,拿到返回值, 渲染到订单结算台
import request from '@/utils/request'
// 订单结算确认
// mode: cart => obj { cartIds }
// mode: buyNow => obj { goodsId goodsNum goodsSkuId }
export const checkOrder = (mode, obj) => {
return request.get('/checkout/order', {
params: {
mode, // cart(购物车) buyNow(立即购买)
delivery: 10, // 10 快递配送 20 门店自提
couponId: 0, // 优惠券ID 传0 不使用优惠券
isUsePoints: 0, // 积分 传0 不使用积分
...obj // 将传递过来的参数对象 动态展开
}
})
}
03.结算台渲染
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)" />
<!-- 2,地址相关 -->
<div class="address" @click="$router.push('/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>
<!-- 3,订单明细 -->
<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">提交订单</div>
</div>
</div>
</template>
<script>
import { getAddressList } from '@/api/address'
export default {
name: 'PayIndex',
data () {
return {
addressList: [], // 收获地址
order: {},
personal: {},
remark: '' // 备注留言
}
},
computed: {
// 选中的默认地址
selectedAddress () {
// 这里地址管理非主线业务,直接获取第一个项作为选中的地址
return this.addressList[0] || {}
},
// 完整的收获地址
longAddress () {
const region = this.selectedAddress.region
return region.province + region.city + region.region + this.selectedAddress.detail
},
// 拿到订单模式
mode () {
return this.$route.query.mode
},
// 拿到商品的id
cartIds () {
return this.$route.query.cartIds
},
// 拿到商品的id
goodsId () {
return this.$route.query.goodsId
},
// 拿到商品的skuid
goodsSkuId () {
return this.$route.query.goodsSkuId
},
// 拿到商品数量
goodsNum () {
return this.$route.query.goodsNum
}
},
created () {
// 获取收货地址
this.getAddressList()
// 获取订单详情
this.getOrderList()
},
methods: {
// 1,获取收获地址
async getAddressList () {
const { data: { list } } = await getAddressList()
this.addressList = list
},
// 2,获取订单详情
async getOrderList () {
// 购物车结算
if (this.mode === 'cart') {
const { data: { order, personal } } = await checkOrder(this.mode, {
cartIds: this.cartIds
})
this.order = order
this.personal = personal
}
// 立刻购买结算
if (this.mode === 'buyNow') {
const { data: { order, personal } } = await checkOrder(this.mode, {
goodsId: this.goodsId,
goodsSkuId: this.goodsSkuId,
goodsNum: this.goodsNum
})
this.order = order
this.personal = personal
}
}
}
}
</script>
04.购物车结算
<template>
<div class="all-total">
... ...
<div @click="goPay">结算({{ selCount }})</div>
... ...
</div>
</template>
<script>
export default {
methods: {
// 去支付
goPay () {
// 判断有没有选中商品
if (this.selCount > 0) {
// 有选中的 商品 才进行结算跳转
this.$router.push({
path: '/pay',
query: {
mode: 'cart',
// 参数示例: 'cartId,cartId,cartId'
cartIds: this.selCartList.map(item => item.id).join(',')
}
})
}
}
},
}
</script>
05.立即购买结算
<template>
<!-- 加入购物车/立即购买 公用的弹层 -->
<van-action-sheet>
... ...
<div class="showbtn" v-if="detail.stock_total > 0">
... ...
<div class="btn now" v-else @click="goBuyNow">立刻购买</div>
</div>
</van-action-sheet>
</template>
<script>
export default {
methods: {
// 登录提示
// 判断是否登录, 登录返回true, 未登录返回false(提示去登录)
loginConfirm () {
if (!this.$store.getters.token) {
// 没有登录, 去登录页
// 携带参数,用于回跳
this.$dialog.confirm({
title: '温馨提示',
message: '登录后才可以操作购物车',
confirmButtonText: '去登录',
cancelButtonText: '再逛逛'
})
.then(() => {
this.$router.replace({
path: '/login',
query: {
backPath: this.$route.fullPath
}
})
})
.catch(() => {})
return false
}
// 已经登录
return true
}
// 立即购买
goBuyNow () {
// 判断是否登录
if (!this.loginConfirm()) {
return
}
// 已经登录
this.$router.push({
path: '/pay',
query: {
mode: 'buyNow',
goodsId: this.goodsId,
goodsSkuId: this.detail.skuList[0].goods_sku_id,
goodsNum: this.addCount
}
})
}
}
}
</script>
06.minxis混入
export default {
// 混入文件里写的就是Vue实例的配置项,可以在需要的文件中使用, 提高代码复用性
// 支持 data methods computed 生命周期函数 ...
// 注意:
// 1, 如果混入文件和组件内, 提供了重名的属性, 组件内属性的优先级更高
// 2, 如果编写了生命周期函数, 混入文件内和组件内的生命周期函数不会冲突,会被数组管理
// 统一在组件内执行
data () {},
methods: {
// 登录提示
// 判断是否登录, 登录返回true, 未登录返回false(提示去登录)
loginConfirm () {
if (!this.$store.getters.token) {
// 没有登录, 去登录页
// 携带参数,用于回跳
this.$dialog.confirm({
title: '温馨提示',
message: '登录后才可以操作购物车',
confirmButtonText: '去登录',
cancelButtonText: '再逛逛'
})
.then(() => {
this.$router.replace({
path: '/login',
query: {
backPath: this.$route.fullPath
}
})
})
.catch(() => {})
return false
}
// 已经登录
return true
}
}
}
<script>
import loginConfirm from '@/mixins/loginConfirm'
export default {
name: 'ProDetail',
mixins: [loginConfirm],
methods: {
// 立即购买
goBuyNow () {
// 判断是否登录
if (!this.loginConfirm()) {
return
}
... ...
}
}
}
</script>
07.提交订单并支付
import request from '@/utils/request'
// 提交订单
// 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">
<!-- 底部提交 -->
<div class="footer-fixed">
... ...
<div class="tipsbtn" @click="submitOrder">提交订单</div>
</div>
</div>
</template>
<script>
import { submitOrder } from '@/api/order'
export default {
methods: {
// 订单支付
async submitOrder () {
if (this.mode === 'cart') {
await submitOrder(this.mode, {
cartIds: this.cartIds,
remark: this.remark
})
}
if (this.mode === 'buyNow') {
await submitOrder(this.mode, {
goodsId: this.goodsId,
goodsSkuId: this.goodsSkuId,
goodsNum: this.goodsNum,
remark: this.remark
})
}
this.$toast.success('支付成功')
this.$router.replace('/myorder')
},
}
}
</script>
订单管理
import request from '@/utils/request'
// 订单列表
export const getMyOrderList = (dataType, page) => {
return request.get('/order/list', {
params: {
dataType,
page
}
})
}
<template>
<div class="order-list-item" v-if="item.order_id">
<div class="tit">
<div class="time">{{ item.create_time }}</div>
<div class="status">
<span>{{ item.state_text }}</span>
</div>
</div>
<div class="list" >
<div class="list-item" v-for="(goods, index) in item.goods" :key="index">
<div class="goods-img">
<img :src="goods.goods_image" alt="">
</div>
<div class="goods-content text-ellipsis-2">
{{ goods.goods_name }}
</div>
<div class="goods-trade">
<p>¥ {{ goods.total_pay_price }}</p>
<p>x {{ goods.total_num }}</p>
</div>
</div>
</div>
<div class="total">
共 {{ item.total_num }} 件商品,总金额 ¥{{ item.total_price }}
</div>
<div class="actions">
<div v-if="item.order_status === 10">
<span v-if="item.pay_status === 10">立刻付款</span>
<span v-else-if="item.delivery_status === 10">申请取消</span>
<span v-else-if="item.delivery_status === 20 || item.delivery_status === 30">确认收货</span>
</div>
<div v-if="item.order_status === 30">
<span>评价</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {
return {}
}
}
}
}
</script>
<template>
<div class="order">
<van-nav-bar title="我的订单" left-arrow @click-left="$router.go(-1)" />
<van-tabs v-model="active" sticky>
<van-tab name="all" title="全部"></van-tab>
<van-tab name="payment" title="待支付"></van-tab>
<van-tab name="delivery" title="待发货"></van-tab>
<van-tab name="received" title="待收货"></van-tab>
<van-tab name="comment" title="待评价"></van-tab>
</van-tabs>
<OrderListItem v-for="item in list" :key="item.order_id" :item="item"></OrderListItem>
</div>
</template>
<script>
import OrderListItem from '@/components/OrderListItem.vue'
import { getMyOrderList } from '@/api/order'
export default {
name: 'OrderPage',
components: {
OrderListItem
},
data () {
return {
active: this.$route.query.dataType || 'all',
page: 1,
list: []
}
},
methods: {
async getOrderList () {
const { data: { list } } = await getMyOrderList(this.active, this.page)
list.data.forEach((item) => {
item.total_num = 0
item.goods.forEach(goods => {
item.total_num += goods.total_num
})
})
this.list = list.data
}
},
watch: {
active: {
immediate: true,
handler () {
this.getOrderList()
}
}
}
}
</script>
个人中心
import request from '@/utils/request'
// 获取个人信息
export const getUserInfoDetail = () => {
return request.get('/user/info')
}
import { getInfo, setInfo } from '@/utils/storage'
export default {
namespaced: true,
state () {
return {
// 个人权证相关
userInfo: getInfo()
}
},
mutations: {
// 设置用户信息
SET_USER_INFO (state, userInfo) {
state.userInfo = userInfo
setInfo(userInfo) // 存储用户信息到本地
}
},
actions: {
// 退出登录
logout ({ commit }) {
// 清空个人信息
commit('SET_USER_INFO', {})
// 情况购物车信息(跨模块调用mutations)
// commit('模块名/方法名', 传值/null, { root: true(开启全局) })
commit('cart/setCartList', [], { root: true })
}
},
getters: {}
}
<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: {
isLogin () {
return this.$store.getters.token
}
},
methods: {
async getUserInfoDetail () {
const { data: { userInfo } } = await getUserInfoDetail()
this.detail = userInfo
},
logout () {
this.$dialog.confirm({
title: '温馨提示',
message: '你确认要退出么'
}).then(() => {
// 退出是一个动作 => 包含了两步,分别是将 user 和 cart 进行重置
this.$store.dispatch('user/logout')
}).catch(() => {})
}
}
}
</script>
打包优化
vue脚手架只是开发过程中, 协助开发的工具, 开发完成后, 脚手架不参与上线
打包的作用
- 将多个文件压缩合并成一个文件
- 语法降级
- less sass ts语法解析
- 打包后, 生成浏览器可以直接运行的网页
打包命令
- yarn build
- 根目录下常见dist目录, 目录中存放打包后的文件
- 默认情况下, 需要放到服务器根目录, 如果需要双击运行, 需要配置相对路径
- 配置前的打包文件, 以绝对路径的形式查找资源
- 配置后的打包文件, 以相对路径的形式查找资源 (此处省略了 ./)
路由懒加载
当打包构建应用时, JS的会被打包在一起,文件就会很大, 影响页面加载, 可以把不同路由对应的组件分割成不同的代码块,然后当路由被访问时, 才加载对应的代码, 这样加载效率大大提高
步骤
- 异步组件改造
- 使用异步
- 打包结果对比