前端笔记GO-SHOPPING 商城项目笔记
4rozeN
本项目是基于 uni-app 技术栈开发的,能够运行在多端 (微信小程序、h5、ios/Android) 的移动端商城项目。
| 码值 |
说明 |
| http 状态码 500 |
服务器端异常 |
| http 状态码 404 |
服务器端异常 |
| http 状态码 401 |
授权信息不正确 |
测试环境,登录短信验证码统一为:246810 (不再提供真实的短信验证服务)
接口文档地址
Vant2 官网地址
本项目使用 ESLint+Standard config 的代码规范标准
项目结构
## 一级路由配置
将每个基础的一级页面以文件夹形式存放于 views 包中。包括:
具体项目结构如下:

路由页如下:

二级路由配置
实现底部导航栏 Tabbar
配置二级路由之前需要完成底部 Tabbar 栏的设计,可以结合 vant 官网文档进行设计。vant2 官网
实现二级路由配置

在组件中的 vant2 的 Tabbar 要实现路由模式如下:

于是有:
1 2 3 4 5 6 7 8 9
| <div> <router-view></router-view> <van-tabbar route v-model="active" active-color="#ee0a24" inactive-color="#000"> <van-tabbar-item to= "/home" icon="gem-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>
|
最后优化路由逻辑,将默认匹配的页面重定向到 home

登录静态页
新建 styles/common.less 重置默认样式(可以对一些想要多组件生效的样式进行重新调整)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| * { margin: 0; padding: 0; box-sizing: borde-box; }
.text-ellipsis-2 { overflow: hidden; -webkit-line-clamp: 2; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; }
|
导入于 main.js 中:import '@/styles/common.less'
接着准备一些素材图片于 assets

配置头部 NavBar
1 2 3 4 5 6
| <!-- 头部 NavBar --> <van-nav-bar title="尊敬的用户,请登录" left-arrow @click-left="$router.go(-1)" />
|
$router.go(-1) 的作用是返回上一页。
接着将左边的返回符号 left-arrow 颜色样式改为灰色 #333,于是找到 common.less 进行编写(两个类名增加权重)
1 2 3 4 5 6
| .van-nav-bar { .van-nav-bar__arrow { color: #333; } }
|
这样的好处是,一旦配置好了将来其他页面使用到导航栏此处的返回颜色都是我们此时配好的颜色。这就是通用样式的覆盖。
完善主体
接着编写主体的静态结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| <template> <div class="login"> <!-- 头部 NavBar --> <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: "LoginIndex", }; </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, 0.1); letter-spacing: 2px; display: flex; justify-content: center; align-items: center; } } </style>
|
页面效果如下:
图形验证码暂时写死,短信验证码暂时不作处理
完善登录逻辑
登录页面一共有三个请求需要发送,图形验证码、短信验证码和登录请求。我们使用 axios 来请求后端接口,一般都会对 axios 进行一些配置(配置基础地址,请求响应拦截器等),于是封装 axios 为一个 request 模块,便于维护。以后使用 axios 都是创建实例去请求,这样多个实例相互独立,互不干扰。
新建 request.js 于 utils 包下,创建一个 axios 实例(cv 中文文档中的实例和拦截器进行改造):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| import axios from "axios";
const instance = axios.create({ baseURL: "http://smart-shop.itheima.net/index.php?s=/api", timeout: 5000, headers: { platform: "h5" }, });
instance.interceptors.request.use( function (config) { return config; }, function (error) { return Promise.reject(error); } );
instance.interceptors.response.use( function (response) { return response.data; }, function (error) { return Promise.reject(error); } );
export default instance;
|
响应器中,response.data 原本为 response,这里改为上文是因为 axios 默认会对响应多包装一层 data,所以这里需要取出 data
接着回到登录页面 index.vue 上,导入 request 模块,异步检查一下:
1 2 3 4 5 6 7 8 9 10 11
| <script> import request from "@/utils/request";
export default { name: "LoginIndex", async created() { const res = await request.get("/captcha/image"); console.log(res); }, }; </script>
|
调试返回如下

解析 base64 并完善点击刷新图片验证码
由于提交的图形验证码必须带 key 才能让后端进行唯一校验,所以在 data 中提供:
1 2 3 4 5 6 7
| data () { return { picCode: '', picKey: '', picUrl: '' } },
|
接着进行响应结果的解构,存储好数据以便后续提交后端进行校验
1 2 3 4 5 6 7 8 9 10
| async created () { this.getPicCode() }, methods: { async getPicCode () { const { data: { base64, Key } } = await request.get('/captcha/image') this.picUrl = base64 this.picKey = Key } }
|
完善 template 相关代码:
1 2 3 4
| <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>
|
封装登录请求到 api 包下,新建 login.js
1 2 3 4 5 6
| import request from "@/utils/request";
export const getPicCode = () => { return request.get("/captcha/image"); };
|
使用时按需导入。于是到登录页 index.js 中改 import request from '@/utils/request' 为 import { getPicCode } from '@/api/login' 并修改调用语句:
1 2 3 4 5 6 7
| methods: { async getPicCode () { const { data: { base64, Key } } = await getPicCode() this.picUrl = base64 this.picKey = Key } }
|
上述语句不会因为重名冲突,我们需要知道的是,如果是引入的 getPicCode 前面是不带 this 的,而自身的 getPicCode 是需要在前面加上 this. 的
Toast 轻提示
校验手机号是否输入、是否输入格式正确等并给予提示。
使用 vant 库中的 Toast 轻提示,完成提示显示。需要注意的是他的两种调用方式:
- Toast (' 提示内容 ');
- this.toast (' 提示文案 '); 引入 Toast 组件后,会自动在 Vue 的 prototype 上挂载 `toast` 方法,便于在组件内调用。
这两种调用,第一种是任意地方都可以调用显示出提示内容,第二种是只有组件内才可以显示提示内容。Toast 默认采用单例模式,即同一时间只会存在一个 Toast,如果需要在同一时间弹出多个 Toast,可以参考下面的示例:
1 2 3 4 5 6 7
| Toast.allowMultiple();
const toast1 = Toast("第一个 Toast"); const toast2 = Toast.success("第二个 Toast");
toast1.clear(); toast2.clear();
|
短信验证倒计时
点击获取验证码之后要开始倒计时,一分钟只能发送一次。给获取短信验证码的按钮注册点击事件并改写显示文字
1 2 3 4
| <div class="form-item"> <input class="inp" placeholder="请输入短信验证码" type="text"> <button @click="getCode">{{ totalTime === timeNow ? '获取验证码' : `${timeNow}秒后重试` }}</button> </div>
|
提供短信验证码获取函数 getCode
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| async getCode () { if (!this.timerId && this.timeNow === this.totalTime) { this.timerId = setInterval(() => { this.timeNow--
if (this.timeNow <= 0) { clearInterval(this.timerId) this.timerId = null this.timeNow = this.totalTime } }, 1000) Toast('获取短信验证码成功') } }
|
并考虑到性能问题,于是增加在用户离开登录页面之后清除定时器的功能,实现在 destroy 生命函数
1 2 3 4
| destroyed () { clearInterval(this.timerId) }
|
手机号和图形验证码类型校验
由于前端无法校验具体,只能校验数据类型和长度。所以新增两个正则数据和绑定用户输入的手机号:
1 2 3 4
| reg_phone: /^1[3-9]\d{9}$/, reg_picCode: /^\w{4}$/, phoneNum: '',
|
增加校验的函数 validFn
1 2 3 4 5 6 7 8 9 10 11 12
| validFn () { if (!this.reg_phone.test(this.phoneNum)) { Toast('请输入正确的手机号!') return false } if (!this.reg_picCode.test(this.picCode)) { Toast('图形验证码错误!') return false } return true },
|
并在短信验证码获取函数 getCode 中加入一行判断
1 2 3 4 5 6
| getCode () { if (!this.validFn()) { return } ... }
|
封装发送短信验证码请求
转到 login.js 中进行封装(结合接口文档)
1 2 3 4 5 6 7 8 9
| export const getMsgCode = (captchaCode, captchaKey, mobile) => { return request.post("/captcha/sendSmsCaptcha", { form: { captchaCode, captchaKey, mobile, }, }); };
|
然后在短信验证码获取函数 getCode 中加入调用代码
1 2 3 4 5 6 7 8
| async getCode () { await getMsgCode(this.picCode, this.picKey, this.phoneNum) Toast('短信发送成功,请注意查收') ... }
|
实现登录功能 - 封装登录接口
封装登录请求
1 2 3 4 5 6 7 8 9 10
| export const codeLogin = (mobile, smsCode) => { return request.post("/passport/login", { form: { isParty: false, mobile, partyData: {}, smsCode, }, }); };
|
绑定登录按钮事件为 login,绑定用户输入的短信验证码为 smsCode(默认短信为 246810)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| async login () { if (!this.validFn()) { return } if (!/^\d{6}$/.test(this.smsCode)) { Toast('请输入正确的短信验证码!') return }
const res = await codeLogin(this.phoneNum, this.smsCode) if (res.status === 200) { Toast('登录成功') this.$router.push('/home') } else { Toast('登录失败,请检查手机号和验证码是否正确') } }
|
后面失败的多种情况可以通过响应拦截器进行处理,在这里暂时只考虑成功的情况
响应处理器统一处理错误提示
对 utils 包下的 request 拦截器进行修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| instance.interceptors.response.use( function (response) { const res = response.data; if (res.status !== 200) { Toast(res.message); return Promise.reject(res.message); } return res; }, function (error) { if (error.response) { Toast(`服务器错误: ${error.response.data.status}`); } else if (error.request) { Toast("请求超时或网络错误"); } else { Toast("请求配置错误"); } return Promise.reject(error); } );
|
登录权证信息存储

使用 vuex 构建 user 模块存储登录权证(token 和 userID)。好处是易获取、响应式,分模块便于管理维护。
在 store 包下新建 modules 包,然后在 modules 包下新建 user.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export default { namespaced: true, state() { return { userInfo: { token: "", userId: "", }, }; }, mutations: { setUserInfo(state, obj) { state.userInfo = obj; }, }, actions: {}, getters: {}, };
|
然后在 vuex 中进行挂载
1 2 3 4 5
| import User from "@/store/modules/user";
modules: { User; }
|
并在登录页面将要实现跳转到首页的前一刻完成用户权证信息的存储
1
| this.$store.commit("User/setUserInfo", res.data);
|

vuex 持久化处理
vuex 刷新就会丢失信息,于是引入持久化存储。我们将获取、设置和移除信息的操作封装为 storage 模块。
在 utils 包下新建一个 storage.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const INFO_KEY = "go_shopping_info";
export const getInfo = () => { const defaultInfo = { token: "", userId: "", }; const info = localStorage.getItem(INFO_KEY); return info ? JSON.parse(info) : defaultInfo; };
export const setInfo = (obj) => { localStorage.setItem(INFO_KEY, JSON.stringify(obj)); };
export const removeInfo = () => { localStorage.removeItem(INFO_KEY); };
|
然后到 vuex 的 user 子模块中进行导入使用即可
1
| import { getInfo, setInfo } from "@/utils/storage";
|

添加请求 Loading 效果
需求分析:有时候因为网络原因,一次请求的结果可能需要一段时间后才能回来。此时,需要给用户添加 loading 提示。
添加 loading 提示的好处:
- 节流处理:防止用户在一次请求还没回来之前,多次进行点击,发送无效请求
- 友好提示:告知用户,目前是在加载中,请耐心等待,用户体验会更好
加在哪呢?可以统一加在拦截器中,这样一来后面也可以复用:
- 请求拦截器中,每次请求,打开 loading
- 响应拦截器中,每次响应,关闭 loading
转到 utils 包下的 request 拦截器,在请求的时候显示 Toast 提示,并设置背景不可点击且使其不会定时消失,只能由我们清除。
1 2 3 4 5 6 7 8 9 10
| instance.interceptors.request.use(function (config) { Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 }) return config }, function (error) {...})
|
在响应拦截器添加清除提示的代码
1 2 3 4 5 6 7 8 9 10
| const res = response.data; if (res.status !== 200) { Toast(res.message); return Promise.reject(res.message); } else { Toast.clear(); } return res;
|
全局路由前置守卫
Vue-router 官网地址

在 router 中编写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import store from '@/store'
...
const authNeedRouters = ['/myorder', '/pay', '/productdetail/:id']
router.beforeEach((to, from, next) => { if (!authNeedRouters.includes(to.path)) { next() } else { const token = store.state.User.userInfo.token if (token) { next() } else { next('/login') } } })
|
首页
完成首页静态结构
要用到的 vant 组件有
- search(搜索框)
- swipe & swipe-item (轮播图)
- grid & grid-item (宫格)
grid 宫格主要是使用自定义列数的:

所以在 utils 包下的 vant2-ui.js 中进行添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import Vue from "vue"; import { Button, Tabbar, TabbarItem, NavBar, Toast, Search, Swipe, SwipeItem, Grid, GridItem, } from "vant";
Vue.use(Tabbar); Vue.use(TabbarItem); Vue.use(Button); Vue.use(NavBar); Vue.use(Toast); Vue.use(Search); Vue.use(Swipe); Vue.use(SwipeItem); Vue.use(Grid); Vue.use(GridItem);
|
静态结构和样式 layout/home.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
| <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>
|
其中将商品项封装成组件 GoodsItem.vue 在 components 包下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| <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 {}; </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>
|
首页 - 动态渲染
根据接口文档封装请求首页数据模块于 api 包下,新建 home.js:
1 2 3 4 5 6 7 8 9 10
| import request from "@/utils/request";
export const getHomeData = () => { return request("/page/detail", { params: { pageId: 0, }, }); };
|
接着转到 layout 的 home.vue 处理动态渲染:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import GoodsItem from "@/components/GoodsItem.vue"; import { getHomeData } from "@/api/home"; export default { name: "HomePage", components: { GoodsItem, }, data() { return { bannerList: [], navList: [], prodsList: [], }; }, async created() { const { data: { pageData }, } = await getHomeData(); console.log(pageData); this.bannerList = pageData.items[1].data; this.navList = pageData.items[3].data; this.prodsList = pageData.items[6].data; }, };
|
改造 template 中轮播图和导航部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <!-- 轮播图 --> <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="item.text" @click="$router.push('/category')" /> </van-grid>
|
接着改造商品组:
1 2 3 4 5 6 7 8
| <!-- 猜你喜欢 --> <div class="guess"> <p class="guess-title">—— 猜你喜欢 ——</p> <div class="goods-list"> <GoodsItem v-for="item in prodsList" :key="item.goods_id" :goods="item"></GoodsItem> </div> </div> </div>
|
父传子将 item 整个对象传给 GoodsItem 进行接收:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export default { name: 'GoodsItem', props: { goods: { type: Object, default: () => { return {} } } }, data () { return { } }
|
然后改造 template
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <div class="goods-item" v-if="goods.goods_id" @click="$router.push(`/prodetail/${goods.goods_id}`)" > <div class="left"> <img :src="goods.goods_image" alt="" /> </div> <div class="right"> <p class="tit text-ellipsis-2"> {{ goods.goods_name }} </p> <p class="count">{{goods.goods_sales}}</p> <p class="price"> <span class="new">{{goods.goods_price_min}}</span> <span class="old">{{goods.goods_price_max}}</span> </p> </div>
|
注意将商品 id 携带进行跳转时要动态取值使用反引号。
搜索
页面设计:

搜索页静态结构
view/search/index.vue(需要导入 vant 组件 Icon)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| <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="historyList.length > 0"> <div class="title"> <span>最近搜索</span> <van-icon name="delete-o" size="16" /> </div> <div class="list"> <div class="list-item" v-for="item in historyList" :key="item" @click="$router.push('/searchlist')" > {{ item }} </div> </div> </div> </div> </template>
<script> export default { name: "SearchIndex", data() { return { historyList: ["炒锅", "电视", "冰箱", "手机", "自行车"], }; }, }; </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: 25%; 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>
|
历史记录管理
目标:构建搜索页的静态布局,完成历史记录的管理
需求分析:
- 搜索历史基本渲染
- 点击搜索 (添加历史)
添加历史说明:
点击搜索按钮或底下历史记录,都能进行搜索
- 若之前没有相同搜索关键字,则直接追加到最前面
- 若之前已有相同搜索关键字,将该原有关键字移除,再追加(相当于置顶)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| <template> <div class="search"> <van-nav-bar title="商品搜索" left-arrow @click-left="$router.go(-1)" />
<van-search v-model="search" @search="goSearch(search)" show-action placeholder="请输入搜索关键词" clearable > <template #action> <div @click="goSearch(search)">搜索</div> </template> </van-search>
<!-- 搜索历史 --> <div class="search-history" v-if="historyList.length > 0"> <div class="title"> <span>最近搜索</span> <van-icon name="delete-o" @click="clearHistory" size="16" /> </div> <div class="list"> <div class="list-item" v-for="item in historyList" :key="item" @click="goSearch(item)" > {{ item }} </div> </div> </div> </div> </template>
<script> import { getHistory, setHistory } from "@/utils/storage";
export default { name: "SearchIndex", data() { return { search: "", historyList: getHistory(), }; }, methods: { goSearch(searchContent) { // 判断输入的搜索词是否已经在historyList中 const index = this.historyList.indexOf(searchContent); if (index !== -1) { // 说明存在,则将其进行删除 this.historyList.splice(index, 1); // splice语法:splice(index, howMany, item1, ....., itemX) } // 将搜索词添加到historyList的最前面 this.historyList.unshift(searchContent); setHistory(this.historyList); // 跳转到搜索列表页面 this.$router.push(`/searchlist?search=${searchContent}`); }, clearHistory() { this.historyList = []; setHistory([]); }, }, }; </script> ...
|
持久化存储代码:
1 2 3 4 5 6 7 8 9 10 11
| const HISTORY_KEY = "shopping_history_info";
export const getHistory = () => { const history = localStorage.getItem(HISTORY_KEY); return history ? JSON.parse(history) : []; };
export const setHistory = (arr) => { localStorage.setItem(HISTORY_KEY, JSON.stringify(arr)); };
|
搜索列表页
静态布局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| <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>
|
渲染
封装请求获取商品列表 api/product.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import request from "../utils/request";
export const getProducts = (obj) => { const { sortType, sortPrice, categoryId, goodsName, page } = obj; return request.get("/goods/list", { params: { sortType, sortPrice, categoryId, goodsName, page, }, }); };
|
基于搜索词进行渲染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
| <template> <div class="search"> <van-nav-bar fixed title="商品列表" left-arrow @click-left="$router.go(-1)" />
<van-search readonly shape="round" background="#ffffff" :value="queryParam" 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" :class="{ active: currentSortType === 'all' }" @click="sortGoods('all')" > 综合 </div> <div class="sort-item" :class="{ active: currentSortType === 'sales' }" @click="sortGoods('sales')" > 销量 </div> <div class="sort-item" :class="{ active: currentSortType === 'price' }" @click="sortGoods('price')" > 价格 </div> </div>
<div class="goods-list"> <GoodsItem v-for="item in productList" :key="item.goods_id" :goods="item" ></GoodsItem> </div> </div> </template>
<script> import GoodsItem from "@/components/GoodsItem.vue"; import { getProducts } from "@/api/product"; export default { name: "SearchIndex", components: { GoodsItem, }, computed: { // 从router的query中拿到查询参数 queryParam() { // console.log(this.$route.query.search) // 如果查询参数不存在就返回一个空字符串 return this.$route.query.search || ""; }, }, async created() { // 获取商品列表数据 const res = await getProducts({ goodsName: this.queryParam, // page: this.page }); this.productList = res.data.list.data; console.log(res.data.list); }, data() { return { currentSortType: "all", productList: [], page: 1, }; }, methods: { // 排序商品 async sortGoods(sortType) { // console.log(sortType) this.currentSortType = sortType; // 更新当前选中的排序类型 const res = await getProducts({ goodsName: this.queryParam, sortType: sortType, }); this.productList = res.data.list.data; }, }, }; </script>
<style lang="less" scoped> .search { padding-top: 46px; ::v-deep .van-icon-arrow-left { color: #333; } .tool { font-size: 24px; height: 40px; line-height: 40px; }
.sort-btns { display: flex; height: 36px; line-height: 36px; .sort-item { text-align: center; flex: 1; font-size: 16px; } .active { color: #ee0a24; } } }
// 商品样式 .goods-list { background-color: #f6f6f6; } </style>
|
基于分类页面的分类 id 进行渲染
封装请求分类页数据 api/category.js
1 2 3 4 5 6
| import request from "@/utils/request";
export const getCategoryData = () => { return request.get("/category/list"); };
|
完成分类页静态结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
| <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"> <van-sidebar v-model="activeKey"> <van-sidebar-item v-for="(item, index) in list" :key="item.category_id" :title="item.name" :class="{ active: index === activeKey }" @click="activeKey = index" href="javascript:;" /> </van-sidebar> </div> <div class="right"> <div @click="$router.push(`/searchlist?categoryId=${item.category_id}`)" v-for="item in list[activeKey]?.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 { activeKey: 0, list: [], activeIndex: 0, }; }, methods: { async getCategoryList() { const { data: { list }, } = await getCategoryData(); this.list = list; // console.log(this.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; van-sidebar-item { 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>
|
修改搜索页列表的查询参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| <script> import GoodsItem from '@/components/GoodsItem.vue' import { getProducts } from '@/api/product' export default { name: 'SearchIndex', components: { GoodsItem }, computed: { querySearch () { return this.$route.query.search || '' }, queryCategory () { return this.$route.query.categoryId || '' } }, async created () { const res = await getProducts({ categoryId: this.queryCategory, goodsName: this.querySearch }) this.productList = res.data.list.data }, data () { return { currentSortType: 'all', productList: [], page: 1 } }, methods: { async sortGoods (sortType) { this.currentSortType = sortType const res = await getProducts({ categoryId: this.queryCategory, goodsName: this.querySearch, sortType: sortType }) this.productList = res.data.list.data } } } </script>
|
商品详情页
静态结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278
| <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>
|
封装获取商品详情的请求模块 api/goodsDetail.js
1 2 3 4 5 6 7 8 9 10
| import request from "@/utils/request";
export const getGoodsDetail = (goodsId) => { return request.get("/goods/detail", { params: { goodsId, }, }); };
|
获取的一个示例:

于是进行动态渲染
商品说明动态渲染
改造 template 中对图片的渲染:
1 2 3
| <van-swipe-item v-for="(image, index) in images" :key="index"> <img :src="image.external_url" /> </van-swipe-item>
|
script 部分如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <script> import { getGoodsDetail } from '@/api/goodsDetail'
export default { name: 'ProDetail', computed: { detailId () { return this.$route.params.id } }, async created () { const goodsId = this.detailId const res = await getGoodsDetail(goodsId) this.goodsObj = res.data.detail this.images = this.goodsObj.goods_images }, data () { return { goodsObj: {}, images: [], current: 0 } }, methods: { onChange (index) { this.current = index } } } </script>
|
修改商品说明进行动态渲染:
1 2 3 4 5 6 7 8 9 10 11 12
| <!-- 商品说明 --> <div class="info"> <div class="title"> <div class="price"> <span class="now">¥{{ goodsObj.goods_price_min }}</span> <span class="oldprice">¥{{ goodsObj.goods_price_max }}</span> </div> <div class="sellcount">已售{{ goodsObj.goods_sales }}件</div> </div> <div class="msg text-ellipsis-2"> {{ goodsObj.goods_name }} </div>
|
到这里还没有完成评论区的动态渲染
商品评论区渲染
封装请求获取商品评价详情的模块 api/goodsDetail.js
1 2 3 4 5 6 7 8 9 10 11
| export const getGoodsCommentDetail = (obj) => { const { scoreType, goodsId, page } = obj; return request.get("/comment/list", { params: { scoreType, goodsId, page, }, }); };
|
请求解构:

完成商品评价渲染(默认只展示三条评价)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!-- 商品评价 --> <div class="comment"> <div class="comment-title"> <div class="left">商品评价 ({{ goodsCommentArray.length }}条)</div> <div class="right" @click="$router.push(`/productComment?id=${detailId}`)">查看更多 <van-icon name="arrow" /> </div> </div> <div class="comment-list"> <div class="comment-item" v-for="(item, index) in goodsCommentArray" :key="item.comment_id"> <div class="top" v-if="index < 3"> <img :src="item.user.avatar_url ? item.user.avatar_url : defaultAvatar" alt=""> <div class="name">{{item.user.nick_name}}</div> <van-rate :size="16" :value="item.score" color="#ffd21e" void-icon="star" void-color="#eee"/> </div> <div class="content" v-if="index < 3"> {{item.content}} </div> <div class="time" v-if="index < 3"> {{item.create_time}} </div> </div> </div> </div>
|
在生命周期钩子 created 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| async created () { const goodsId = this.detailId const res = await getGoodsDetail(goodsId) this.goodsDetailObj = res.data.detail this.images = this.goodsDetailObj.goods_images
const commentRes = await getGoodsCommentDetail({ scoreType: 10, goodsId: goodsId, page: 1 }) this.goodsCommentArray = commentRes.data.list.data },
|
data 提供:
1 2 3 4 5 6 7 8 9
| data () { return { defaultAvatar: 'https://.../coding/202408301648039.png', goodsDetailObj: {}, goodsCommentArray: {}, images: [], current: 0 } },
|
现在剩下商品说明的图片未渲染、商品评论区未渲染
商品说明的图片渲染

接口返回的数据中 content 就是商品详细说明的图:

于是再于 data 中提供两个数据用于处理:
1 2
| imgSrcRegex: /<img[^>]+src="([^">]+)"/g, imgSrcArray: [],
|
再到 created 中编写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| async created () { ...
...
const htmlString = this.goodsDetailObj.content const srcArray = [] const regex = /<img\s+[^>]*src="([^"]*)"/g
let match while ((match = regex.exec(htmlString)) !== null) { srcArray.push(match[1]) } this.imgSrcArray = srcArray },
|
接下来就剩下完全的评价区渲染了。
商品评价区
这是一个新的页面所以直接新建了。views/productdetail/evaluation.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
| <template> <div class="goodsEvaluation"> <van-nav-bar fixed title="商品评价页" left-arrow @click-left="$router.go(-1)" />
<div class="comment-list"> <div class="comment-item" v-for="item in goodsCommentArray" :key="item.comment_id" > <div class="top"> <img :src="item.user.avatar_url || defaultAvatar" alt="" /> <div class="name">{{ item.user.nick_name }}</div> <van-rate :size="16" :value="item.score" 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> </template>
<script> import { getGoodsCommentDetail } from "@/api/goodsDetail"; export default { name: "GoodsEvaluation", computed: { detailId() { return this.$route.params.id; }, }, async created() { try { const goodsId = this.detailId; const commentRes = await getGoodsCommentDetail({ scoreType: -1, // 全部评价 goodsId, }); this.goodsCommentArray = commentRes.data.list.data; console.log(this.goodsCommentArray); } catch (error) { console.error("Failed to fetch goods comments:", error); // 可以在这里添加更多的错误处理逻辑,比如显示错误提示 } }, data() { return { defaultAvatar: "https://.../coding/202408301648039.png", goodsCommentArray: [], // 商品评价 }; }, }; </script>
<style lang="less" scoped> .goodsEvaluation { padding-top: 46px; background-color: #f7f7f7; min-height: 100vh; ::v-deep .van-icon-arrow-left { color: #333; } .comment-list { padding: 10px 15px; } .comment-item { background-color: #fff; border-radius: 8px; padding: 15px; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); font-size: 14px; line-height: 22px; .top { display: flex; align-items: center; margin-bottom: 10px; img { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; object-fit: cover; border: 1px solid #eaeaea; } .name { font-weight: 600; color: #333; flex-grow: 1; } .van-rate { margin-left: auto; } } .content { color: #555; margin-bottom: 10px; white-space: normal; word-wrap: break-word; } .time { font-size: 12px; color: #999; text-align: right; } } } </style>
|
并转到 router 包下修改路由:
1 2 3 4 5 6
| { path: '/productdetail/:id', component: ProductDetail }, { path: '/evaluation/:id', component: GoodsEvaluation },
{ path: '*', component: NotFound }
|
加入购物车 / 购买
该功能使用到弹层组件,可以在 vant 组件库中找到 ActionSheet
页面代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <!-- 弹层 --> <van-action-sheet v-model="showPannel" :title="mode === 'cart' ? '加入购物车' : '立刻购买'" > <div class="product"> <div class="product-title"> <div class="left"> <img :src="goodsDetailObj.goods_image" alt=""> </div> <div class="right"> <div class="price"> <span>¥</span> <span class="nowprice">{{ goodsDetailObj.goods_price_min }}</span> </div> <div class="count"> <span>库存</span> <span>{{ goodsDetailObj.stock_total }}</span> </div> </div> </div> <div class="num-box"> <span>数量</span> 数字框占位 </div> <!-- 有库存才显示可购买 --> <div class="showbtn" v-if="goodsDetailObj.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>
|
样式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| .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; } }
|
data 中提供两个数据:
1 2
| showPannel: false, mode: 'cart',
|
methods 中提供两个方法:
1 2 3 4 5 6 7 8
| addToCart () { this.showPannel = true this.mode = 'cart' }, buyNow () { this.showPannel = true this.mode = 'buy' }
|
接下来还需要将弹层中的数量展示封装成数字框组件。
数字框组件封装

分析:组件名 CountBox
- 静态结构,左中右三部分
- 数字框的数字,应该是外部传递进来的 (父传子)
- 点击
+-号,可以修改数字(子传父)
- 使用
v-model 实现封装(:value 和 @input 的简写)
- 数字不能减到小于 1
封装组件 components/CountBox.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| <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) { // 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>
|
使用组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import CountBox from '@/components/CountBox.vue'
export default { name: 'ProDetail', components: { CountBox }, data () { return { addCount: 1 ... } }, }
<div class="num-box"> <span>数量</span> <CountBox v-model="addCount"></CountBox> </div>
|
加入购物车 - 判断登录状态
需要使用到 vant 的 Dialog 组件
1 2 3
| import { Dialog } from "vant";
Dialog({ message: "提示" });
|
给弹层的加入购物车按钮绑定点击事件
1
| <div class="btn" v-if="this.mode === 'cart'" @click="addCart">加入购物车</div>
|
添加 token 鉴权判断,跳转携带回跳地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| addCart () { if (!this.$store.getters.token) { this.$dialog.confirm({ message: '当前操作需要登录后才能进行哦', confirmButtonText: '去登录', cancelButtonText: '再逛逛' }).then(() => { this.$router.replace({ path: '/login', query: { backUrl: this.$route.fullPath } }) }) .catch(() => { }) } }
|
登录后如果有回跳地址则 replace 地回跳回去:
1 2 3
| const url = this.$route.query.backUrl || "/"; this.$router.replace(url);
|
this.$router.replace 和 this.$router.push 的区别
假设有 A、B、C 三个页面。
我从 A 点击进入 B 页面,然后在 B 页面需要进行跳转到 C 页面,此时我触发的是 this.$router.replace 进行跳转,那么跳转到 C 之后 C 就会将 B 的访问记录完全覆盖。如果我在 C 页面的操作结束之后,进行返回上一页的操作,就会直接返回到 A 页面而不是 B 页面。
相对的,this.$router.push 则会将记录保持,你的每次访问都是一个累加的过程,不会清除。
如果把页面比作真实的纸张,跳转的过程就像把我们手里的一张纸覆盖到桌子上的另一张纸上面一样,桌子上纸张堆的是已经访问过的页面,桌子上的纸张堆中最上面那一张就是我们现在正在访问的页面,而手里的是将要访问的页面。但覆盖的方式上述两种各有不同:replace 会在覆盖桌子上的纸张之前将桌子上最上面的那一张丢到垃圾桶,然后再将手中的纸张覆盖上去;push 则是简单的将纸张放在桌子上的纸张上面,不进行其他任何操作,最终你的访问记录都在桌子上。
加入购物车 - 封装接口进行请求
接口如下:

封装加入购物车的请求接口模块 api/cart.js
1 2 3 4 5 6 7 8 9 10 11
| import request from "@/utils/request";
export const addCart = (goodsId, goodsNum, goodsSkuId) => { return request.post("/cart/add", { goodsId, goodsNum, goodsSkuId, }); };
|
由于需要携带请求头且携带的是鉴权信息,每次请求手动去写比较麻烦,所以直接在请求拦截器中携带 utils/request.js
1 2 3 4 5 6 7 8 9
| import store from "@/store";
const token = store.getters.token; if (token) { config.headers["Access-Token"] = token; config.headers.platform = "h5"; } return config;
|
修改购物车渲染代码进行购物车内数量角标展示 views/productdetail/index.vue
1 2 3 4
| <div class="icon-cart"> <van-icon :badge="cartTotal || ''" name="shopping-cart-o" /> <span>购物车</span> </div>
|
导入并将商品规格赋值给 data 数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { addCart } from '@/api/cart' ...
async created () { const goodsId = this.detailId const res = await getGoodsDetail(goodsId) this.goodsDetailObj = res.data.detail
this.goodsSkuId = this.goodsDetailObj.skuList[0].goods_sku_id
this.images = this.goodsDetailObj.goods_images ... }
|
提供 data 数据来接收必要信息
1 2 3
| addCount: 1, goodsSkuId: 0, cartTotal: 0,
|
编写请求代码
1 2 3 4 5 6 7 8 9 10 11
| async addCart () { ...
const { data } = await addCart(this.detailId, this.addCount, this.goodsSkuId) this.cartTotal = data.cartTotal this.$toast('加入购物车成功!') this.showPannel = false } }
|
购物车页面
需求分析:
- 基本静态结构 (快速实现)
- 构建 vuexcart 模块,获取数据存储
- 基于数据居动态渲染购物车列表
- 封装 getters 实现动态统计
- 全选反选功能
- 数字框修改数量功能
- 编辑切换状态,删除功能
- 空购物车处理
静态页面
修改 layout/cart.vue
使用到了 vant 的 Checkbox, CheckboxGroup 组件
1 2 3 4
| import { Checkbox, CheckboxGroup } from "vant";
Vue.use(Checkbox); Vue.use(CheckboxGroup);
|
静态页面如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
| <template> <div class="cart"> <van-nav-bar title="购物车" fixed /> <!-- 购物车开头 --> <div class="cart-title"> <span class="all">共<i>4</i>件商品</span> <span class="edit"> <van-icon name="edit" /> 编辑 </span> </div>
<!-- 购物车列表 --> <div class="cart-list"> <div class="cart-item" v-for="item in 10" :key="item"> <van-checkbox></van-checkbox> <div class="show"> <img src="http://cba.itlike.com/public/uploads/10001/20230321/a072ef0eef1648a5c4eae81fad1b7583.jpg" alt="" /> </div> <div class="info"> <span class="tit text-ellipsis-2" >新Pad 14英寸 12+128 远峰蓝 M6平板电脑 智能安卓娱乐十核游戏学习二合一 低蓝光护眼超清4K全面三星屏5GWIFI全网通 蓝魔快本平板</span > <span class="bottom"> <div class="price">¥ <span>1247.04</span></div> <div class="count-box"> <button class="minus">-</button> <input class="inp" :value="4" type="text" readonly /> <button class="add">+</button> </div> </span> </div> </div> </div>
<div class="footer-fixed"> <div class="all-check"> <van-checkbox icon-size="18"></van-checkbox> 全选 </div>
<div class="all-total"> <div class="price"> <span>合计:</span> <span>¥ <i class="totalPrice">99.99</i></span> </div> <div v-if="true" class="goPay">结算(5)</div> <div v-else class="delete">删除</div> </div> </div> </div> </template>
<script> export default { name: "CartPage", }; </script>
<style lang="less" scoped> // 主题 padding .cart { padding-top: 46px; padding-bottom: 100px; background-color: #f5f5f5; min-height: 100vh; .cart-title { height: 40px; display: flex; justify-content: space-between; align-items: center; padding: 0 10px; font-size: 14px; .all { i { font-style: normal; margin: 0 2px; color: #fa2209; font-size: 16px; } } .edit { .van-icon { font-size: 18px; } } }
.cart-item { margin: 0 10px 10px 10px; padding: 10px; display: flex; justify-content: space-between; background-color: #ffffff; border-radius: 5px;
.show img { width: 100px; height: 100px; } .info { width: 210px; padding: 10px 5px; font-size: 14px; display: flex; flex-direction: column; justify-content: space-between;
.bottom { display: flex; justify-content: space-between; .price { display: flex; align-items: flex-end; color: #fa2209; font-size: 12px; span { font-size: 16px; } } .count-box { display: flex; width: 110px; .add, .minus { width: 30px; height: 30px; outline: none; border: none; } .inp { width: 40px; height: 30px; outline: none; border: none; background-color: #efefef; text-align: center; margin: 0 5px; } } } } } }
.footer-fixed { position: fixed; left: 0; bottom: 50px; height: 50px; width: 100%; border-bottom: 1px solid #ccc; background-color: #fff; display: flex; justify-content: space-between; align-items: center; padding: 0 10px;
.all-check { display: flex; align-items: center; .van-checkbox { margin-right: 5px; } }
.all-total { display: flex; line-height: 36px; .price { font-size: 14px; margin-right: 10px; .totalPrice { color: #fa2209; font-size: 18px; font-style: normal; } }
.goPay, .delete { min-width: 100px; height: 36px; line-height: 36px; text-align: center; background-color: #fa2f21; color: #fff; border-radius: 18px; &.disabled { background-color: #ff9779; } } } } </style>
|
构建 vuex cart 模块
新建子模块 cart:@/store/modules/cart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import { getCartList } from "@/api/cart";
export default { namespaced: true, state() { return { cartList: [], }; }, mutations: { setCartList(state, newCartList) { state.cartList = newCartList; }, }, actions: { async getCartAction(context) { const { data } = await getCartList();
data.list.forEach((element) => { element.isChecked = true; }); context.commit("setCartList", data.list); }, }, getters: {}, };
|
到 vuex 中挂载:
1 2 3 4 5 6 7
| import Cart from '@/store/modules/cart'
... modules: { User, Cart }
|
购物车页面中使用 created 调用 actions 异步
1 2 3 4 5 6
| created () { if (this.$store.getters.token) { this.$store.dispatch('Cart/getCartAction') }
|
动态渲染
接口地址:/cart/list

使用辅助函数映射数组:
1 2 3 4 5 6 7
| import { mapState } from 'vuex'
...
computed: { ...mapState('Cart', ['cartList']) },
|
将 template 中对应的地方进行改造:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <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>
|
但未完成其他功能按钮等事件。
封装 getters 实现动态统计
合计商品数量使用到 getters
1 2 3 4 5 6
| getters: { countCartTotal (state) { return state.cartList.reduce((sum, item, index) => sum + item.goods_num, 0) } }
|
reduce 函数的 20 个高级用法
最终完整结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| getters: { countCartTotal (state) { return state.cartList.reduce((sum, item, index) => sum + item.goods_num, 0) }, selectedCartList (state) { return state.cartList.filter(item => item.isChecked) }, selectedCartCount (state, getters) { return Array.isArray(getters.selectedCartList) ? getters.selectedCartList.reduce((sum, item, index) => sum + item.goods_num, 0) : 0 }, selectedPrice (state, getters) { return Array.isArray(getters.selectedCartList) ? getters.selectedCartList.reduce((sum, item, index) => sum + item.goods_num * item.goods.goods_price_min, 0).toFixed(2) : 0 } }
|
修改购物车页面的相关 template
1 2 3 4 5 6 7 8 9 10 11 12
| <span class="all">共<i>{{ countCartTotal }}</i>件商品</span>
...
<div class="all-total"> <div class="price"> <span>合计:</span> <span>¥ <i class="totalPrice">{{selectedPrice}}</i></span> </div> <div v-if="true" class="goPay" :class="{disabled: selectedCartCount === 0}">结算({{selectedCartCount}})</div> <div v-else class="delete" :class="{disabled: selectedCartCount === 0}">删除</div> </div>
|
全选反选
提供 mutations 便于修改 state
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ...
toggleChecked (state, id) { state.cartList.forEach(item => { if (item.goods_id === id) { item.isChecked = !item.isChecked } }) }, allToggle (state, flag) { state.cartList.forEach(item => { item.isChecked = flag }) }
|
给全选和商品选择框注册点击事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <van-checkbox @click="toggleChecked(item.goods_id)" :value="item.isChecked" ></van-checkbox>
...
<div class="all-check"> <van-checkbox @click="allToggle" :value="isAllChecked" icon-size="18"></van-checkbox> 全选 </div>
... methods: { toggleChecked (goodsId) { this.$store.commit('Cart/toggleChecked', goodsId) }, allToggle () { this.$store.commit('Cart/allToggle', !this.isAllChecked) } },
|
辅助函数导入 isAllChecked
1
| ...mapGetters('Cart', ['countCartTotal', 'selectedCartList', 'selectedCartCount', 'selectedPrice', 'isAllChecked'])
|
数字框修改数量功能
提供更新购物车的接口模块:api/cart.js
1 2 3 4 5 6 7 8 9
| export const updateCart = (obj) => { const { goodsId, goodsNum, goodsSkuId } = obj; return request.post("/cart/update", { goodsId, goodsNum, goodsSkuId, }); };
|
在 vuex 的 cart 子模块导入使用
1
| import { getCartList, updateCart } from "@/api/cart";
|
cart 子模块增加修改商品数量的 mutations 方法
1 2 3 4 5 6 7 8 9
| changeCount (state, obj) { const { goodsId, goodsNum, goodsSkuId } = obj state.cartList.forEach(item => { if (item.goods_id === goodsId && item.goods_sku_id === goodsSkuId) { item.goods_num = goodsNum } }) }
|
cart 子模块增加同步购物车到后台的 actions 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| async syncCartAction (context) { const paramsObj = {} context.state.cartList.forEach(item => { if (item.isChecked) { paramsObj.goodsId = item.goods_id paramsObj.goodsNum = item.goods_num paramsObj.goodsSkuId = item.goods_sku_id updateCart(paramsObj) } }) console.log(paramsObj) }
|
给数量框组件添加绑定事件
1 2 3 4
| <CountBox @input="changeCount(item.goods_id, $event, item.goods_sku_id)" :value="item.goods_num" ></CountBox>
|
给结算按钮添加绑定事件
1 2 3 4 5 6
| <div v-if="true" class="goPay" @click="goPay" :class="{ disabled: selectedCartCount === 0 }" >结算({{selectedCartCount}})</div>
|
methods 中提供相对应的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| changeCount (goodsId, e, goodsSkuId) { const obj = { goodsId: goodsId, goodsNum: e, goodsSkuId: goodsSkuId } this.$store.commit('Cart/changeCount', obj) }, goPay () { this.$store.dispatch('Cart/syncCartAction')
}
|
真正的结算待做。
编辑切换状态,删除功能
点击编辑按钮,结算按钮变为删除按钮。
给购物车页面提供一个数据用于标识是否处于编辑状态
1 2 3 4 5
| data () { return { isEdit: false } },
|
并监视其状态
1 2 3 4 5 6 7 8 9 10 11
| watch: { isEdit (val) { if (val) { this.$store.commit('Cart/allToggle', false) } else { this.$store.commit('Cart/allToggle', true) } } }
|
相对应的对 template 中结算按钮和编辑按钮进行修改
1 2 3 4 5 6 7 8 9 10
| <span class="edit" @click="isEdit = !isEdit"> <van-icon name="edit" /> 编辑 </span>
...
</div> <div v-if="!isEdit" class="goPay" @click="goPay" :class="{disabled: selectedCartCount === 0}">结算({{selectedCartCount}}) </div>
|
删除功能待做。
删除商品
封装接口模块 api/cart.js
1 2 3 4 5 6 7
| export const deleteSelected = (cartIds) => { return request.post("/cart/clear", { cartIds, }); };
|
vuex 子模块 Cart 提供异步方法
1 2 3 4 5 6 7 8 9 10 11
| async delSelCartA (context) { const ids = context.getters.selectedCartList.map(item => item.id) deleteSelected(ids) Toast('删除成功')
context.dispatch('getCartAction') }
|
接着修复了一些小 bug:商品详情页初始不显示购物车数量、添加到购物车之后数量不会自动更新、购物车页面编辑状态退出后按钮未恢复到结算按钮。下面是修复后的 store/modules/cart.js 和 views/productdetail.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
| import { getCartList, updateCart, deleteSelected, addCart } from "@/api/cart"; import { Toast } from "vant";
export default { namespaced: true, state() { return { cartList: [], }; }, mutations: { setCartList(state, newCartList) { state.cartList = newCartList; }, toggleChecked(state, id) { state.cartList.forEach((item) => { if (item.goods_id === id) { item.isChecked = !item.isChecked; } }); }, allToggle(state, flag) { state.cartList.forEach((item) => { item.isChecked = flag; }); }, changeCount(state, obj) { const { goodsId, goodsNum, goodsSkuId } = obj; state.cartList.forEach((item) => { if (item.goods_id === goodsId && item.goods_sku_id === goodsSkuId) { item.goods_num = goodsNum; } }); }, }, actions: { async addCartAction(context, obj) { const { goodsId, goodsNum, goodsSkuId } = obj; context.state.cartList.forEach((item) => { if (item.goods_id === goodsId && item.goods_sku_id === goodsSkuId) { item.goods_num += goodsNum; } }); await addCart(goodsId, goodsNum, goodsSkuId); await context.dispatch("getCartAction"); Toast("添加成功"); },
async getCartAction(context) { const { data } = await getCartList();
data.list.forEach((element) => { element.isChecked = true; }); context.commit("setCartList", data.list); },
async syncCartAction(context) { const paramsObj = {}; context.state.cartList.forEach((item) => { if (item.isChecked) { paramsObj.goodsId = item.goods_id; paramsObj.goodsNum = item.goods_num; paramsObj.goodsSkuId = item.goods_sku_id; updateCart(paramsObj); } }); },
async delSelCartA(context) { const ids = context.getters.selectedCartList.map((item) => item.id); deleteSelected(ids); Toast("删除成功");
context.dispatch("getCartAction"); }, }, getters: { countCartTotal(state) { return state.cartList.reduce( (sum, item, index) => sum + item.goods_num, 0 ); }, selectedCartList(state) { return state.cartList.filter((item) => item.isChecked); }, selectedCartCount(state, getters) { return Array.isArray(getters.selectedCartList) ? getters.selectedCartList.reduce( (sum, item, index) => sum + item.goods_num, 0 ) : 0; }, selectedPrice(state, getters) { return Array.isArray(getters.selectedCartList) ? getters.selectedCartList .reduce( (sum, item, index) => sum + item.goods_num * item.goods.goods_price_min, 0 ) .toFixed(2) : 0; }, isAllChecked(state) { return state.cartList.every((item) => item.isChecked); }, }, };
|
商品详情页增加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import { mapGetters, mapActions } from 'vuex'
... computed: { ...mapGetters('Cart', ['countCartTotal']), ...mapActions('Cart', ['getCartAction']), detailId () { return this.$route.params.id } },
...
let match while ((match = regex.exec(htmlString)) !== null) { srcArray.push(match[1]) } this.imgSrcArray = srcArray
await this.getCartAction this.cartTotal = this.countCartTotal
...
const obj = { goodsId: this.detailId, goodsNum: this.addCount, goodsSkuId: this.goodsSkuId } this.$store.dispatch('Cart/addCartAction', obj) this.cartTotal = this.countCartTotal this.$toast('加入购物车成功!') this.showPannel = false
...
watch: { countCartTotal (newVal) { this.cartTotal = newVal } }
|
空购物车处理
将除标题以外的盒子用大盒子包裹起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <template> <div class="cart"> <van-nav-bar title="购物车" fixed /> <!-- 判断登录,且购物车列表不为空才渲染 --> <div v-if="isLogin && cartList.length > 0">...</div> <!-- 未登录或购物车列表为空时渲染提示页面 --> <van-empty v-else description="空空如也,快去逛逛吧~"> <van-button round type="danger" class="bottom-button" @click="$router.push('/home')" >去逛逛</van-button > </van-empty> </div> </template>
|
并提供计算属性
1 2 3
| isLogin () { return this.$store.getters.token }
|
并引入对应 Empty 组件的 css 样式
1 2 3 4
| .bottom-button { width: 160px; height: 40px; }
|
地址管理
地址选择里面涉及到一个地区选择器,可以自行定义数据也可以使用官方的数据。可以参考:Vant 使用 Vant Area Data
配置
封装 api 接口:api/address.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import request from "@/utils/request";
export const getAddressList = () => { return request.get("/address/list"); };
export const getAddressDetail = (addressId) => { return request.get("/address/detail", { params: { addressId: addressId, }, }); };
export const addAddress = (dataObj) => { return request.post("/address/add", { form: { name: dataObj.name, phone: dataObj.phone, region: dataObj.region, detail: dataObj.detail, }, }); };
export const updateAddress = (dataObj) => { return request.post("/address/edit", { addressId: dataObj.address_id, form: dataObj.form, }); };
export const setDefaultAddress = (addressId) => { return request.post("/address/setDefault", { addressId }); };
export const deleteAddress = (addressId) => { return request.post("/address/remove", { addressId }); };
|
Vuex 子模块:store/modules/address.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| import { getAddressList, updateAddress, deleteAddress, getAddressDetail, addAddress, setDefaultAddress, } from "@/api/address";
export default { namespaced: true, state() { return { AddressList: [], defaultAddressId: localStorage.getItem("defaultAddressId") || "", }; }, mutations: { editAddress(state, newAddress) { this.AddressList = newAddress; }, setDefaultAddressId(state, addressId) { state.defaultAddressId = addressId; localStorage.setItem("defaultAddressId", addressId); }, }, actions: { async getAddressList() { const { data } = await getAddressList(); return data; },
async getAddressDetail(context, addressId) { if (!addressId) { throw new Error("Address ID is required"); } const data = await getAddressDetail(addressId); return data; },
async addAddress(context, dataObj) { console.log("vuex_dataObj:", dataObj); const res = await addAddress(dataObj); if (res.status === 200) { context.dispatch("getAddressList"); } },
async setDefaultAddress(context, addressId) { await setDefaultAddress(addressId);
getAddressList(); },
async updateAddress(context, dataObj) { await updateAddress(dataObj);
getAddressList(); },
async deleteAddress(context, addressId) { await deleteAddress(addressId);
getAddressList(); }, }, getters: { getDefaultAddressId: (state) => state.defaultAddressId || localStorage.getItem("defaultAddressId"), }, };
|
Vuex 子模块 addressMap 处理引入的 vant 官方地区数据:store/modules/addressMap.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| import { areaList } from "@vant/area-data";
const state = { codeToNameMap: { provinceMap: {}, cityMap: {}, countyMap: {}, }, nameToCodeMap: { provinceMap: {}, cityMap: {}, countyMap: {}, }, };
const getters = { nameToCode: (state) => (name) => { return ( state.nameToCodeMap.provinceMap[name] || state.nameToCodeMap.cityMap[name] || state.nameToCodeMap.countyMap[name] || null ); }, codeToName: (state) => (code) => { return ( state.codeToNameMap.provinceMap[code] || state.codeToNameMap.cityMap[code] || state.codeToNameMap.countyMap[code] || null ); }, };
const mutations = { SET_CODE_TO_NAME_MAP(state, { map, type }) { state.codeToNameMap[type] = map; }, SET_NAME_TO_CODE_MAP(state, { map, type }) { state.nameToCodeMap[type] = map; }, };
const actions = { buildReverseMaps({ commit }) { const provinceCodeToNameMap = buildCodeToNameMap(areaList.province_list); const cityCodeToNameMap = buildCodeToNameMap(areaList.city_list); const countyCodeToNameMap = buildCodeToNameMap(areaList.county_list);
const provinceNameToCodeMap = buildNameToCodeMap(areaList.province_list); const cityNameToCodeMap = buildNameToCodeMap(areaList.city_list); const countyNameToCodeMap = buildNameToCodeMap(areaList.county_list);
commit("SET_CODE_TO_NAME_MAP", { map: provinceCodeToNameMap, type: "provinceMap", }); commit("SET_CODE_TO_NAME_MAP", { map: cityCodeToNameMap, type: "cityMap" }); commit("SET_CODE_TO_NAME_MAP", { map: countyCodeToNameMap, type: "countyMap", });
commit("SET_NAME_TO_CODE_MAP", { map: provinceNameToCodeMap, type: "provinceMap", }); commit("SET_NAME_TO_CODE_MAP", { map: cityNameToCodeMap, type: "cityMap" }); commit("SET_NAME_TO_CODE_MAP", { map: countyNameToCodeMap, type: "countyMap", }); }, };
function buildCodeToNameMap(list) { const map = {}; Object.keys(list).forEach((code) => { map[code] = list[code]; }); return map; }
function buildNameToCodeMap(list) { const map = {}; Object.keys(list).forEach((code) => { map[list[code]] = code; }); return map; }
export default { namespaced: true, state, getters, mutations, actions, };
|
配置路由并新增守卫规则:router/index.js
1 2 3 4 5 6 7 8 9 10 11 12 13
| import Address from '@/views/address/index.vue' import AddressEdit from '@/views/address/edit.vue'
...
{ path: '/address/manage', component: Address }, { path: '/address/edit', component: AddressEdit },
...
const authNeedRouters = ['/myorder', '/pay', '/address', '/address/edit', '/address/manage']
|
地址列表
地址列表代码:views/address/index.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
| <template> <div class="address-list"> <van-nav-bar fixed title="地址列表" left-arrow @click-left="$router.go(-1)" /> <div v-if="list.length > 0"> <!-- 用于区分的文字 --> <div class="address-list-header"> <p class="header-text">请选择或管理收货地址</p> </div>
<!-- 地址列表 --> <van-address-list v-model="chosenAddressId" :switchable="true" :list="list" :disabled-list="disabledList" :disabled-text="disabledText" default-tag-text="默认" @add="onAdd" @edit="onEdit" /> </div> <van-empty v-else description="空空如也"> <van-button round type="danger" class="bottom-button" @click="onAdd" >添加地址</van-button > </van-empty> </div> </template>
<script> import { Toast } from "vant"; import { mapActions, mapGetters } from "vuex";
export default { name: "AddressList", data() { return { // 默认选中的地址id chosenAddressId: "", // 地址列表 list: [], // 禁用状态的地址列表(超出快递范围) disabledList: [], }; }, computed: { ...mapGetters("AddressMap", ["codeToName"]), ...mapGetters("Address", ["getDefaultAddressId"]), disabledText() { return this.disabledList.length > 0 ? "以下地址超出配送范围" : ""; }, }, async created() { try { // 构建地区映射表 await this.buildReverseMaps();
// 获取地址列表 const response = await this.getAddressList(); // console.log('获取地址列表成功:', response) const addressData = response.list || []; // console.log('地址数据:', addressData)
// 格式化地址列表数据 this.list = addressData .filter((item) => !item.is_disabled) // 过滤掉禁用的地址 .map((item) => ({ id: item.address_id, name: item.name, tel: item.phone, address: this.formatAddress(item), isDefault: item.is_default || false, }));
// 从Vuex中获取默认地址的id const defaultAddressId = this.getDefaultAddressId; console.log("默认地址id:", defaultAddressId);
if (Number(defaultAddressId) !== -1) { // 遍历比对列表每一项的id是否与defaultAddressId相等,找到后返回索引(强等于比较,注意类型) const defaultIndex = this.list.findIndex( (item) => Number(item.id) === Number(defaultAddressId) ); if (Number(defaultIndex) !== -1) { // 找到后设置为默认地址 this.list[defaultIndex].isDefault = true; this.chosenAddressId = Number(defaultAddressId); } } else { // Vuex没有默认地址,则默认选中第一个地址 Toast("小主还没有默认地址哦\n快来设置一个吧❤️"); console.log("未找到有效的默认地址"); this.chosenAddressId = this.list[0].id || ""; } // 设置禁用的地址列表 this.disabledList = addressData .filter((item) => item.is_disabled) .map((item) => ({ id: item.address_id, name: item.name, tel: item.phone, address: this.formatAddress(item), isDefault: item.is_default || false, })); } catch (error) { // console.error('获取地址列表失败:', error) Toast.fail("获取地址列表失败"); } }, methods: { ...mapActions("Address", ["getAddressList"]), ...mapActions("AddressMap", ["buildReverseMaps"]), // 格式化地址: formatAddress(item) { const provinceName = this.codeToName(item.province_id) || ""; const cityName = this.codeToName(item.city_id) || ""; const countyName = this.codeToName(item.county_id) || ""; const detailAddress = item.detail || ""; const result = `${provinceName}${cityName}${countyName}${detailAddress}`; // console.log('codeToname:', this.codeToName(item.province_id)) return result; }, onAdd() { this.$router.push("/address/edit?adsid="); }, onEdit(item) { // console.log('跳转到编辑:', item) this.$router.push(`/address/edit?adsid=${item.id}`); }, }, }; </script>
<style scoped lang="less"> .custom-nav-bar { background-color: #ffffff; // 导航栏背景色 height: 50px; // 设置导航栏的高度 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); // 添加阴影效果 }
.address-list { padding-top: 60px; // 增加顶部内边距,以避免内容被导航栏遮挡 }
.address-list-header { padding: 10px 16px; background-color: #f8f8f8; }
.header-text { font-size: 16px; color: #333; text-align: left; margin: 0; }
/* 地址列表项样式 */ .van-address-list__item { margin-bottom: 10px; // 每一项之间的间距 padding: 10px 16px; // 内边距,使内容不贴边 border: 1px solid #ddd; // 边框颜色 border-radius: 4px; // 圆角边框 background-color: #fff; // 背景色
.van-cell__title, .van-cell__label { white-space: normal; // 允许内容换行 word-wrap: break-word; // 自动换行 word-break: break-all; // 长单词换行 } }
/* 禁用状态的地址项样式 */ .van-address-list__item--disabled { background-color: #f5f5f5; // 禁用项的背景色 color: #999; // 禁用项的文字颜色 } </style>
|
地址编辑
地址编辑可以是新建也可以是更新操作:views/address/edit.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
| <template> <!-- 可编辑也可以作为添加地址的页面 --> <div class="address-list"> <van-nav-bar fixed title="地址编辑" left-arrow @click-left="$router.go(-1)" />
<div class="address-edit"> <van-address-edit :address-info="addressInfo" :area-list="areaList" show-delete :show-set-default="this.adsId != -1 ? true : false" show-search-result :search-result="searchResult" :area-columns-placeholder="['请选择', '请选择', '请选择']" :detail-maxlength="20" @save="onSave" @delete="onDelete" @change-default="onChangeDefault" /> </div> </div> </template>
<script> import { Toast } from "vant"; import { mapActions, mapGetters } from "vuex"; import { areaList } from "@vant/area-data"; // 引入vant官方的地区数据
export default { name: "AddressEdit", data() { return { adsId: 0, // 地址id addressInfo: {}, // 初始地址详情对象,若是新建地址则为空对象 areaList: areaList, // 地区列表 searchResult: [], // 详细地址搜索结果 checkDefault: false, // 默认地址的标识,数据内的isDefault会随着设置按钮而改变,此字段标识数据原始状态 }; }, computed: { ...mapGetters("AddressMap", ["nameToCode", "codeToName"]), // 获取要进行编辑的地址id,如果为空则赋值为-1表示为新建地址 getAdsId() { return this.$route.query.adsid || -1; }, }, async created() { // 构建映射表 await this.buildReverseMaps();
// 获取要进行编辑的地址id,如果为空则赋值为-1表示为新建地址 this.adsId = this.getAdsId; // console.log('adsId:', this.adsId)
// 如果adsId不等于-1,则进行地址详情的拉取 if (this.adsId !== -1) { // 说明是编辑地址,则需要拉取地址详情 const { data: { detail }, } = await this.getAddressDetail(this.adsId); this.addressInfo = { id: detail.address_id, name: detail.name, tel: detail.phone, province: this.codeToName(detail.province_id) || "", city: this.codeToName(detail.city_id) || "", county: this.codeToName(detail.county_id) || "", addressDetail: detail.detail, areaCode: String(detail.region_id), isDefault: this.isDefault, }; // 从Vuex得到默认地址的id const defaultAddressId = this.$store.state.Address.defaultAddressId; // 比对当前地址是否为默认地址 if (Number(defaultAddressId) === Number(detail.address_id)) { console.log("当前地址为默认地址"); this.addressInfo.isDefault = true; // 设置默认按钮为打开状态 this.checkDefault = true; // 为真说明该地址获取的时候就是默认地址 } else { // 说明当前地址不是默认地址 console.log("当前地址不是默认地址"); this.addressInfo.isDefault = false; // 设置默认按钮为关闭状态 this.checkDefault = false; // 为假说明该地址获取的时候不是默认地址 } } else { // 说明是新建地址,不做操作 } }, methods: { ...mapActions("Address", [ "getAddressDetail", "addAddress", "updateAddress", ]), ...mapActions("AddressMap", ["buildReverseMaps"]),
// 保存地址 async onSave(content) { // 判断是新建地址还是编辑地址 if (this.adsId === -1) { // 新建地址 content.country = "中国"; this.addressInfo = content;
// 使用映射表进行地区数据处理 const region = [ { value: Number(this.nameToCode(content.province)) || "", label: content.province, }, { value: Number(this.nameToCode(content.city)) || "", label: content.city, }, { value: Number(this.nameToCode(content.county)) || "", label: content.county, }, ];
// 封装数据对象 const dataObj = { name: this.addressInfo.name, phone: this.addressInfo.tel, region: region, detail: this.addressInfo.addressDetail, };
// 调用接口进行地址的保存 await this.addAddress(dataObj); Toast("保存成功"); this.$router.replace({ path: "/address/manage" }); // 保存成功后返回地址列表页面 } else { // 编辑地址 // console.log('编辑地址的content:', content) this.addressInfo = content; // 封装对象,用于发送请求 const dataObj = { address_id: this.addressInfo.id, form: { name: this.addressInfo.name, phone: this.addressInfo.tel, region: [ { label: this.addressInfo.province, value: Number(this.nameToCode(this.addressInfo.province)) || "", }, { label: this.addressInfo.city, value: Number(this.nameToCode(this.addressInfo.city)) || "", }, { label: this.addressInfo.county, value: Number(this.nameToCode(this.addressInfo.county)) || "", }, ], detail: this.addressInfo.addressDetail, }, }; // console.log('dataObj:', dataObj) await this.updateAddress(dataObj); Toast("保存成功");
// 处理默认地址的标识问题 if (this.checkDefault) { // 为真说明当前编辑的是默认地址 console.log("当前是默认地址,content:", content); console.log("当前是默认地址,this.adressInfo:", this.addressInfo);
if (content.isDefault !== this.checkDefault) { // 说明用户取消了这个默认地址,需要将Vuex的默认地址id赋值为-1 this.$store.commit("Address/setDefaultAddressId", -1); } // 否则什么也不做 } else { // 说明当前编辑的不是默认地址 console.log("当前不是默认地址,content:", content); console.log("当前不是默认地址,this.adressInfo:", this.addressInfo);
if (content.isDefault !== this.checkDefault) { // 说明用户将当前不是默认地址的地址设置为默认地址 this.$store.commit( "Address/setDefaultAddressId", this.addressInfo.id ); } // 否则什么也不做 }
// 处理完成后跳转会地址列表页 this.$router.replace({ path: "/address/manage" }); } }, async onDelete() { // 判断是否删除的是默认地址,如果是,则将Vuex的默认地址id赋值为-1 if (this.checkDefault) { this.$store.commit("Address/setDefaultAddressId", -1); console.log( "删除默认地址, Vuex的默认地址id:", this.$store.state.Address.defaultAddressId ); } await this.$store.dispatch("Address/deleteAddress", this.addressInfo.id); Toast("删除成功"); setTimeout(() => { this.$router.replace("/address/manage"); }, 1000); }, // 设置默认地址 onChangeDefault(val) { // console.log(val) // 只有编辑才可以设置默认地址 this.addressInfo.isDefault = val; console.log( "设置默认地址的按钮被触发, this.addressInfo.isDefault:", this.addressInfo.isDefault ); }, }, }; </script>
<style scoped lang="less"> .address-list { padding-top: 60px; /* 增加与导航栏等高的内边距,避免内容被导航栏覆盖 */ }
.address-edit { padding: 10px 16px; /* 给地址编辑区域增加一些内边距,使内容不贴边 */ background-color: #fff; /* 设置背景色为白色 */ } </style>
|
导入 vant 组件略过。
订单结算台
静态结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
| <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>
|
渲染
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
| <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="addressList.length > 0"> <div class="info-content"> <span class="name">{{ chosenAddress.name }} </span> <span class="mobile">{{ chosenAddress.phone }}</span> </div> <div class="info-address"> {{ regionName.province }} {{ regionName.city }} {{ regionName.county }} {{ chosenAddress.detail }} </div> </div>
<div class="info" v-else>还没有地址哦,点击右侧按钮添加吧</div>
<div class="right-icon" @click="$router.push('/address/manage')"> <van-icon name="arrow" /> </div> </div>
<!-- 订单明细 --> <div class="pay-list"> <div class="list"> <div class="goods-item" v-for="item in cartList" :key="item.id"> <div class="left"> <img :src="item.goods.goods_image" alt="" /> </div> <div class="right"> <p class="tit text-ellipsis-2"> {{ item.goods.goods_name }} </p> <p class="info"> <span class="count">共{{ item.goods_num }}件</span> <span class="price" >¥{{ item.goods.goods_price_min * item.goods_num }}</span > </p> </div> </div> </div>
<div class="flow-num-box"> <span>共 {{ selectedCartCount() }} 件商品,合计:</span> <span class="money">¥{{ selectedPrice() }}</span> </div>
<div class="pay-detail"> <div class="pay-cell"> <span>订单总金额:</span> <span class="red">¥{{ selectedPrice() }}</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>¥{{ selectedPrice() }}</span> </div> <div class="tipsbtn" @click="$router.push('/order/confirm')"> 提交订单 </div> </div> </div> </template>
<script> import { mapActions, mapGetters } from "vuex";
export default { name: "PayIndex", async created() { // 构建地区映射表 await this.buildReverseMaps();
// 处理购物车详情展示 // 拉取购物车列表 await this.getCartAction(); this.cartList = this.selectedCartList(); console.log("购物车列表:", this.cartList);
// 获取地址列表 const { list } = await this.getAddressList(); this.addressList = list; console.log("地址列表:", this.addressList);
// 从Vuex中获取默认地址的id const defaultAddressId = this.getDefaultAddressId(); console.log("默认地址id:", defaultAddressId);
if (this.addressList.length > 0) { // 如果有地址查询参数?adsid,则说明进行了地址切换 console.log("地址切换参数:", this.getadsid); if (this.getadsid) { // chosenAddress被赋值为切换的id的地址 const address = this.addressList.find( (item) => String(item.address_id) === String(this.getadsid) ); this.chosenAddress = address; console.log("切换后的地址:", this.chosenAddress); } else { // 如果没有地址切换参数,则说明没有切换地址 if (Number(defaultAddressId) !== -1) { // 遍历比对列表每一项的id是否与defaultAddressId相等,找到后返回索引(强等于比较,注意类型) const defaultIndex = this.addressList.findIndex( (item) => String(item.address_id) === String(defaultAddressId) ); // console.log('默认地址索引:', defaultIndex) if (Number(defaultIndex) !== -1) { // 找到后将数组数据转对象赋值给chosenAddress属性 this.chosenAddress = this.addressList[defaultIndex];
// 处理地址Code转为Name const regionId = String(this.chosenAddress.region_id); // 调用 getFullAddressInfo 方法 this.regionName = await this.fetchFullAddressName(regionId); } else { console.log("未找到有效的默认地址索引"); } } else { // Vuex没有默认地址,则默认选中第一个地址 // 设置chosenAddress属性为addressList的第一个地址 this.chosenAddress = this.addressList[0]; const regionId = String(this.chosenAddress.region_id); this.regionName = await this.fetchFullAddressName(regionId); console.log( "无默认地址,展示数据chosenAddress:", this.chosenAddress ); } } } }, data() { return { addressList: [], // 地址列表 chosenAddress: {}, // 被选择进行展示的地址 regionName: {}, // 将地址Code转为Name cartList: [], // 购物车列表 }; }, computed: { getadsid() { return this.$route.query.adsid; }, }, methods: { ...mapActions("Address", ["getAddressList", "getAddressDetail"]), ...mapGetters("Address", ["getDefaultAddressId"]), ...mapActions("AddressMap", ["buildReverseMaps", "fetchFullAddressName"]), ...mapActions("Cart", ["getCartAction"]), ...mapGetters("Cart", [ "selectedCartList", "selectedCartCount", "selectedPrice", ]), }, }; </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>
|
订单结算
购物车携带参数:

文件上传 ——unfinished
图片上传
接口要求:POST /upload/image
Header 参数:
- Access-Token (String) 示例:
1741f74aed758a688515f72572dc8e37
- platform (String) 示例值:H5
Body 参数:multipart/form-data
上传图片通常涉及到裁剪操作,于是使用 vue-cropper 插件辅助完成。
安装 vue-cropper
1
| npm install vue-cropper --save
|
在组件中使用 vue-cropper
创建上传的组件,允许用户选择图片、裁剪图片并实时预览裁剪效果,然后上传裁剪后的图片。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
| <template> <div class="avatar-upload"> <vue-cropper v-if="showCropper" ref="cropper" :img="imageSrc" :output-size="{ width: 200, height: 200 }" :output-type="'jpeg'" :can-move-box="true" :auto-crop="true" :fixed-box="true" :fixed="true" :center-box="true" ></vue-cropper>
<div v-else> <img :src="avatarUrl" alt="Avatar" v-if="avatarUrl" class="avatar-preview" /> <input type="file" @change="onAvatarChange" /> </div>
<div v-if="showCropper" class="cropper-controls"> <button @click="cropImage">裁剪并上传</button> <button @click="cancelCrop">取消</button> </div> </div> </template>
<script> import VueCropper from "vue-cropper"; import { uploadImage } from "@/api/upload";
export default { components: { VueCropper, }, data() { return { avatarUrl: "", // 存储裁剪后的头像URL imageSrc: "", // 存储用户选择的图片的URL showCropper: false, // 控制是否显示裁剪器 }; }, methods: { onAvatarChange(event) { const file = event.target.files[0]; if (file) { this.imageSrc = URL.createObjectURL(file); this.showCropper = true; } }, async cropImage() { // 获取裁剪后的图片数据 this.$refs.cropper.getCropBlob(async (blob) => { // 上传裁剪后的图片 try { const formData = new FormData(); formData.append("file", blob); const response = await uploadImage(blob); if (response.status === 200) { this.avatarUrl = response.data.fileInfo.preview_url; this.showCropper = false; } else { this.$toast.fail("上传失败,请重试"); } } catch (error) { this.$toast.fail("上传失败,请重试"); console.error(error); } }); }, cancelCrop() { this.showCropper = false; this.imageSrc = ""; }, }, }; </script>
<style> .avatar-upload { display: flex; flex-direction: column; align-items: center; }
.avatar-preview { width: 200px; height: 200px; border-radius: 50%; object-fit: cover; margin-bottom: 10px; }
.cropper-controls { margin-top: 10px; display: flex; gap: 10px; }
.cropper-controls button { padding: 5px 10px; background-color: #ff3e47; color: white; border: none; border-radius: 5px; cursor: pointer; }
.cropper-controls button:hover { background-color: #e0363d; } </style>
|
打包优化
优化访问路径
打包命令:yarn build 或者 npm run build
打包如果没有配置 vue.config.js 的 publicPath,默认生成的匹配文件的写法是绝对路径,这意味着将来的可移植性降低。于是配置:
1 2 3 4 5
| const { defineConfig } = require("@vue/cli-service"); module.exports = defineConfig({ publicPath: "./", transpileDependencies: true, });
|
懒加载
打包实际上将多个文件多合一,如果一次性加载所有的 js 文件是非常消耗性能的,因此推荐配置懒加载。
路由懒加载
1 2 3
| const ProDetail = () => import('@/views/prodetail') const Pay = () => import('@/views/pay') ...
|
1 2 3 4 5 6 7 8
| const router = new VueRouter({ routes: [ ... {path:'prodetail/:id', component: ProDetail}, {Path:'/Pay', component: Pay}, ... ] })
|
对比:
