前端学习笔记 7:小兔鲜
准备工作
创建项目
创建项目:
npm init vue@latest
相关选项如下:
在src
目录下添加以下目录:
别名路径联想
默认情况下在 VSCode 中输入import xxx from '@...'
时不会启用路径联想功能,要启用需要在项目根目录下添加 VSCode 配置文件jsconfig.json
:
{"compilerOptions" : {"baseUrl" : "./","paths" : {"@/*":["src/*"]}}
}
如果 VSCode 已经自动创建该文件,可以跳过这一步。
添加 ElementPlus
ElementPlus 加入的方式分为全部引入和按需引入,后者可以减少项目打包后的体积,所以这里采用按需引入。
安装 ElementPlus:
npm install element-plus --save
安装插件:
npm install -D unplugin-vue-components unplugin-auto-import
修改vite.config.js
,添加以下内容:
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'export default defineConfig({// ...plugins: [// ...AutoImport({resolvers: [ElementPlusResolver()],}),Components({resolvers: [ElementPlusResolver()],}),],
})
修改App.vue
进行验证:
<script setup>
</script><template><el-button type="primary">Primary</el-button>
</template>
定制主题色
安装 sass:
npm i sass -D
添加主题色样式文件styles/element/index.scss
:
/* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with ($colors: ('primary': (// 主色'base': #27ba9b,),'success': (// 成功色'base': #1dc779,),'warning': (// 警告色'base': #ffb302,),'danger': (// 危险色'base': #e26237,),'error': (// 错误色'base': #cf4444,),)
)
修改vite.config.js
:
export default defineConfig({plugins: [// ...Components({resolvers: [ElementPlusResolver({ importStyle: 'sass' })],}),],// ...css: {preprocessorOptions: {scss: {// 自动导入定制化样式文件进行样式覆盖additionalData: `@use "@/styles/element/index.scss" as *;`,}}}
})
Axios 基础配置
最好在框架代码中创建 Axios 实例,并进行统一配置,这样可以对所有接口调用都要用的配置信息进行统一管理。
安装:
npm i axios
添加utils/http.js
:
import axios from 'axios'// 创建axios实例
const http = axios.create({baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',timeout: 5000
})// axios请求拦截器
http.interceptors.request.use(config => {return config
}, e => Promise.reject(e))// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {return Promise.reject(e)
})export default http
添加测试代码apis/test.js
:
import http from '@/utils/http'export const getCategoryService = () => {return http.get('home/category/head')
}
在 App.vue
中进行测试:
import { getCategoryService } from '@/apis/test'
getCategoryService().then((res) => {console.log(res)
})
路由设计
添加views/layout/index.vue
作为首页:
<template>首页
</template>
依次添加:
views/login/index.vue
,登录页views/home/index.vue
,Home页views/category/index.vue
,分类页
eslint 会报错,提示文件命名不符合标准,可以修改.eslintrc.cjs
关闭报错:
module.exports = {// ...rules: {'vue/multi-word-component-names': "off"}
}
修改路由配置router/index.js
:
import { createRouter, createWebHistory } from 'vue-router'
import LayoutVue from '@/views/layout/index.vue'
import LoginVue from '@/views/login/index.vue'
import HomeVue from '@/views/home/index.vue'
import CategoryVue from '@/views/category/index.vue'const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: '/category', component: CategoryVue }]},{ path: '/login', component: LoginVue }]
})export default router
值得注意的是,代表 Home 页的子路由 path 设置为空字符串,这样可以让/
路径默认展示 Home 页。
修改App.vue
,添加路由出口:
<template><RouterView />
</template>
修改views/layout/index.vue
,添加路由出口:
<template>首页<RouterView />
</template>
现在项目的路由是:
/
,Home 页/category
,分类页/login
,登录页
引入静态资源和样式
将图片相关资源 images 添加到assets
目录下,将样式文件common.scss
添加到styles
目录下。
修改 main.js
,导入样式:
// import './assets/main.css'
import '@/styles/common.scss'
为了方便查看错误提示信息,可以添加插件:
sass 自动导入
添加一个存放颜色相关变量的 sass 文件styles/var.scss
:
$xtxColor: #27ba9b;
$helpColor: #e26237;
$sucColor: #1dc779;
$warnColor: #ffb302;
$priceColor: #cf4444;
修改 vite.config.js
:
css: {preprocessorOptions: {scss: {// 自动导入scss文件additionalData: `@use "@/styles/element/index.scss" as *;@use "@/styles/var.scss" as *;`,}}
}
测试,修改App.vue
:
<template><div class="test">Hello World!</div><RouterView />
</template>
<style scoped lang="scss">
.test{color: $helpColor;
}
</style>
Layout
页面搭建
在vies/layout
中添加以下视图:LayoutNav.vue、LayoutHeader.vue、LayoutFooter.vue。
修改views/layout/index.vue
,使用这些视图填充页面:
<script setup>
import LayoutNav from './components/LayoutNav.vue'
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutFooter from './components/LayoutFooter.vue'
</script><template><LayoutNav /><LayoutHeader /><RouterView /><LayoutFooter />
</template>
字体图标引入
修改根目录下的index.html
,添加:
<link rel="stylesheet" href="//at.alicdn.com/t/font_2143783_iq6z4ey5vu.css">
这里使用的是阿里的素材库,具体的使用方式可以参考这个视频。
一级导航渲染
封装接口调用,添加apis/layout.js
:
import http from "../utils/http";export const getCategorysService = ()=>{return http.get('/home/category/head')
}
调用接口,将返回值填充进响应式数据,用响应式数据完成页面渲染。
修改LayoutHeader.vue
:
<script setup>
import { getCategorysService } from "@/apis/layout.js";
import {ref} from 'vue'
const categorys = ref([])
const getCategorys = async ()=>{const result = await getCategorysService()categorys.value = result.result
}
getCategorys()
</script>
<template><li v-for="cat in categorys" :key="cat.id"> <RouterLink to="/">{{ cat.name }}</RouterLink> </li>
</template>
吸顶导航栏
添加views/layout/component/LayoutFixed.vue
。
在 views/layout/index.vue
中引入:
<script setup>
import LayoutNav from './components/LayoutNav.vue'
import LayoutHeader from './components/LayoutHeader.vue'
import LayoutFooter from './components/LayoutFooter.vue'
import LayoutFixed from '@/views/layout/components/LayoutFixed.vue'
</script><template><LayoutFixed /><LayoutNav /><LayoutHeader /><RouterView /><LayoutFooter />
</template>
吸顶导航栏中,用show
类别控制是否显示:
<div class="app-header-sticky show">
需要知道鼠标在y轴的滚动距离,这里用一个函数库 vueuse 获取。
安装:
npm i @vueuse/core
使用函数获取滚动距离:
<script setup>
import { useWindowScroll } from '@vueuse/core'const { y } = useWindowScroll()
</script>
<div class="app-header-sticky" :class="{ show: y > 78 }">
Pinia 优化重复请求
吸顶导航与普通的导航栏使用相同的商品分类数据,为了避免重复请求接口,可以使用 Pinia 存储数据。
创建分类的数据存储:
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getCategorysService } from '@/apis/layout'
export const useCategoryStore = defineStore('category', () => {const categorys = ref([])const loadCategorys = async () => {const result = await getCategorysService()categorys.value = result.result}return { categorys, loadCategorys }
})
在吸顶导航和普通导航共同的父组件layout/index.vue
中触发 Store 的 action 以加载分类数据:
<script setup>
// ...
import { useCategoryStore } from '@/stores/category'
const categoryStore = useCategoryStore()
categoryStore.loadCategorys()
</script>
在固定导航栏中使用数据填充导航栏:
<script setup>
import { useWindowScroll } from '@vueuse/core'
import {useCategoryStore} from '@/stores/category'
const categoryStore = useCategoryStore()
const { y } = useWindowScroll()
</script><template><div class="app-header-sticky" :class="{ show: y > 78 }"><div class="container"><RouterLink class="logo" to="/" /><!-- 导航区域 --><ul class="app-header-nav "><li class="home"><RouterLink to="/">首页</RouterLink></li><li v-for="cat in categoryStore.categorys" :key="cat.id"><RouterLink to="/">{{ cat.name }}</RouterLink></li></ul><div class="right"><RouterLink to="/">品牌</RouterLink><RouterLink to="/">专题</RouterLink></div></div></div>
</template>
普通导航栏中的使用方式是相同的,这里不再赘述。
Home
整体结构拆分
将 Home 页拆分成以下几部分:
<script setup>
import HomeBannerVue from './components/HomeBanner.vue'
import HomeCategoryVue from './components/HomeCategory.vue'
import HomeHotVue from './components/HomeHot.vue'
import HomeNewVue from './components/HomeNew.vue'
import HomeProductVue from './components/HomeProduct.vue'
</script>
<template><div class="container"><HomeCategoryVue /><HomeBannerVue /></div><HomeNewVue /><HomeHotVue /><HomeProductVue />
</template>
分类
分类组件的基本实现见这里。
所依赖的数据可以从 Pinia 中的分类信息获取:
<script setup>
import { useCategoryStore } from '@/stores/category'
const categoryStore = useCategoryStore()
</script><template><div class="home-category"><ul class="menu"><li v-for="cat in categoryStore.categorys" :key="cat.id"><RouterLink to="/">{{ cat.name }}</RouterLink><RouterLink v-for="child in cat.children.slice(0, 2)" :key="child.id" to="/">{{ child.name }}</RouterLink><!-- 弹层layer位置 --><div class="layer"><h4>分类推荐 <small>根据您的购买或浏览记录推荐</small></h4><ul><li v-for="good in cat.goods" :key="good.id"><RouterLink to="/"><img alt="" :src="good.picture"/><div class="info"><p class="name ellipsis-2">{{ good.name }}</p><p class="desc ellipsis">{{ good.desc }}</p><p class="price"><i>¥</i>{{ good.price }}</p></div></RouterLink></li></ul></div></li></ul></div>
</template>
轮播图
基本实现代码可以从这里获取。
封装接口:
import http from '@/utils/http'export const getHomeBannerService = ()=>{return http.get('/home/banner')
}
加载数据:
<script setup>
import { getHomeBannerService } from '@/apis/home';
import { ref } from 'vue';
const banner = ref([])
const loadHomeBanner = async ()=>{const result = await getHomeBannerService()banner.value = result.result
}
loadHomeBanner()
</script><template><div class="home-banner"><el-carousel height="500px"><el-carousel-item v-for="item in banner" :key="item.id"><img :src="item.imgUrl" alt=""></el-carousel-item></el-carousel></div>
</template>
面板组件封装
面板组件HomePannel.vue
的基本实现可以从这里获取。
将简单信息封装成 props(属性),将复杂信息封装成 slot(插槽):
<script setup>
defineProps({title: {type: String},subTitle: {type: String}
})
</script><template><div class="home-panel"><div class="container"><div class="head"><!-- 主标题和副标题 --><h3>{{ title }}<small>{{ subTitle }}</small></h3></div><!-- 主体内容区域 --><slot></slot></div></div>
</template>
测试:
<HomePannelVue title="新鲜好物" subTitle="更多商品">新鲜好物
</HomePannelVue>
<HomePannelVue title="热销商品" subTitle="更多商品">热销商品
</HomePannelVue>
新鲜好物
新鲜好物页面HomeNew.vue
的基本实现见这里。
封装接口:
//新鲜好物
export const getNewService = ()=>{return http.get('/home/new')
}
从接口获取数据渲染页面:
<script setup>
import { getNewService } from '@/apis/home'
import { ref } from 'vue'
import HomePannelVue from './HomePannel.vue';
const newGoods = ref([])
const loadNewGoods = async () => {const result = await getNewService()newGoods.value = result.result
}
loadNewGoods()
</script><template><HomePannelVue title="新鲜好物" subTitle="新鲜出炉 品质靠谱"><ul class="goods-list"><li v-for="good in newGoods" :key="good.id"><RouterLink to="/"><img :src="good.picture" alt="" /><p class="name">{{ good.name }}</p><p class="price">¥{{ good.price }}</p></RouterLink></li></ul></HomePannelVue>
</template>
图片懒加载
需要实现一个自定义指令v-img-lazy
。
修改main.js
:
// import './assets/main.css'
import '@/styles/common.scss'import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { useIntersectionObserver } from '@vueuse/core'import App from './App.vue'
import router from './router'const app = createApp(App)app.use(createPinia())
app.use(router)app.directive('img-lazy', {mounted(el, binding) {//el,指令绑定的对象//binding.value,指令 = 后的表达式的值console.log(el, binding.value)useIntersectionObserver(el,([{ isIntersecting }]) => {if (isIntersecting) {el.src = binding.value}},)},
})
app.mount('#app')
插件封装
在入口文件中写入懒加载逻辑是不合适的,应当封装成插件。
创建插件文件directives/img-lazy.js
:
import { useIntersectionObserver } from '@vueuse/core'
//图片懒加载插件
export const imgLazyPlugin = {install(app) {// 配置此应用app.directive('img-lazy', {mounted(el, binding) {//el,指令绑定的对象//binding.value,指令 = 后的表达式的值console.log(el, binding.value)useIntersectionObserver(el,([{ isIntersecting }]) => {if (isIntersecting) {el.src = binding.value}},)},})}
}
这里的useIntersectionObserver
函数是 vueuse 库中用于监听某个控件是否在 Window 中显示的函数。
修改main.js
,使用插件:
// import './assets/main.css'
import '@/styles/common.scss'import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { imgLazyPlugin } from './directives/img-lazy'import App from './App.vue'
import router from './router'const app = createApp(App)app.use(createPinia())
app.use(router)
app.use(imgLazyPlugin)app.mount('#app')
避免重复监听
如果不在图片加载后手动停止监听,监听行为就一直存在。
修改img-lazy.js
,手动停止监听:
const { stop } = useIntersectionObserver(el,([{ isIntersecting }]) => {if (isIntersecting) {el.src = binding.valuestop()}},
)
useIntersectionObserver
会返回一个停止的函数,在合适的时候调用即可。
商品列表
商品列表控件HomeProduct.vue
的初始代码可以从这里获取。
封装接口:
export const getGoodsService = ()=>{return http.get('/home/goods')
}
渲染数据:
<script setup>
import HomePanel from './HomePannel.vue'
import { getGoodsService } from '@/apis/home'
import { ref } from 'vue'
const goodsProduct = ref([])
const loadGoods = async () => {const res = await getGoodsService()goodsProduct.value = res.result
}
loadGoods()
</script><template><div class="home-product"><HomePanel :title="cate.name" v-for="cate in goodsProduct" :key="cate.id"><div class="box"><RouterLink class="cover" to="/"><img :src="cate.picture" /><strong class="label"><span>{{ cate.name }}馆</span><span>{{ cate.saleInfo }}</span></strong></RouterLink><ul class="goods-list"><li v-for="good in cate.goods" :key="good.id"><RouterLink to="/" class="goods-item"><img :src="good.picture" alt="" /><p class="name ellipsis">{{ good.name }}</p><p class="desc ellipsis">{{ good.desc }}</p><p class="price">¥{{ good.price }}</p></RouterLink></li></ul></div></HomePanel></div>
</template>
分类页
导航
分类页的 url 类似于/category/分类ID
,因此需要修改导航,让路径有分类ID:
routes: [{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: '/category/:id', component: CategoryVue }]},{ path: '/login', component: LoginVue }]
修改LayoutHeader.vue
中的导航栏,让超链接定位到分类的 url:
<RouterLink :to="`/category/${cat.id}`">{{ cat.name }}</RouterLink>
吸顶导航栏以同样的方式修改,这里不再赘述。
面包屑导航
分类页category/index.vue
中面包屑导航的基本实现见这里。
封装接口category.js
:
import http from '@/utils/http'// 获取一级分类详情
export const getCategoryService = (id) => {return http.get('/category?id=' + id)
}
渲染数据:
<script setup>
import { getCategoryService } from '@/apis/category'
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const category = ref({})
const route = useRoute()
const loadCategory = async (id) => {const res = await getCategoryService(id)category.value = res.result
}
onMounted(() => {loadCategory(route.params.id)
})
</script><template><div class="top-category"><div class="container m-top-20"><!-- 面包屑 --><div class="bread-container"><el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item>{{ category.name }}</el-breadcrumb-item></el-breadcrumb></div></div></div>
</template>
轮播
修改接口home.js
:
//轮播
export const getHomeBannerService = (distributionSite = '1') => {return http.get('/home/banner', { params: { distributionSite } })
}
增加分类页轮播控件category/components/CategoryBanner.vue
:
<script setup>
import { getHomeBannerService } from '@/apis/home';
import { ref } from 'vue';
const banner = ref([])
const loadHomeBanner = async ()=>{const result = await getHomeBannerService('2')banner.value = result.result
}
loadHomeBanner()
</script><template><div class="home-banner"><el-carousel height="500px"><el-carousel-item v-for="item in banner" :key="item.id"><img :src="item.imgUrl" alt=""></el-carousel-item></el-carousel></div>
</template><style scoped lang='scss'>
.home-banner {width: 1240px;height: 500px;margin: 0 auto;img {width: 100%;height: 500px;}
}
</style>
修改category/index.vue
:
<script setup>
// ...
import CategoryBannerVue from './components/CategoryBanner.vue'
// ...
</script>
<template><!-- ... --><CategoryBannerVue/>
</template>
激活状态控制
RouterLink 增加属性active-class="active"
:
<RouterLink active-class="active" :to="`/category/${cat.id}`">{{ cat.name }}</RouterLink>
分类列表渲染
<template><div class="top-category"><div class="container m-top-20"><!-- 面包屑 --><div class="bread-container"><el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item>{{ category.name }}</el-breadcrumb-item></el-breadcrumb></div></div><CategoryBannerVue /><div class="sub-list"><h3>全部分类</h3><ul><li v-for="i in category.children" :key="i.id"><RouterLink to="/"><img :src="i.picture" /><p>{{ i.name }}</p></RouterLink></li></ul></div><div class="ref-goods" v-for="item in category.children" :key="item.id"><div class="head"><h3>- {{ item.name }}-</h3></div><div class="body"><GoodsItem v-for="good in item.goods" :good="good" :key="good.id" /></div></div></div>
</template>
路由缓存问题
当路由中包含参数,且切换路径时只有参数发生变化,会复用组件而不是将组件销毁并重新创建,此时组件的相关钩子函数不会被触发(setup、onMounted等)。
解决这个问题有两种方案:
- 为组件赋予一个独一无二的 key 属性,让组件强制销毁
- 监听路径更新钩子,手动更新数据
第一种方案,修改layout/index.vue
:
<RouterView :key="$route.fullPath"/>
这种方案的缺陷是性能较差,会将原本可以复用的组件也销毁,需要重新通过网络请求创建。
第二种方案可以使用一个 vue-router 的 导航守卫:
<script setup>
import { getCategoryService } from '@/apis/category'
import { ref, onMounted } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
import CategoryBannerVue from './components/CategoryBanner.vue'
import GoodsItem from '@/views/home/components/GoodsItem.vue'
const category = ref({})
const route = useRoute()
const loadCategory = async (id) => {const res = await getCategoryService(id)category.value = res.result
}
onMounted(() => {loadCategory(route.params.id)
})
onBeforeRouteUpdate(async (to) => {await loadCategory(to.params.id)
})
重构
当 Vue 中的 js 部分包含太多逻辑,可以进行封装和重构。
将/category/index.vue
中渲染分类数据的部分代码拆分到category/composable/useCategory.js
中:
import { ref, onMounted } from 'vue'
import { getCategoryService } from '@/apis/category'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
export const useCategory=()=>{const category = ref({})const route = useRoute()const loadCategory = async (id) => {const res = await getCategoryService(id)category.value = res.result}onMounted(() => {loadCategory(route.params.id)})onBeforeRouteUpdate(async (to) => {await loadCategory(to.params.id)})return {category}
}
/category/index.vue
中就只包含以下的 JS 代码:
import CategoryBannerVue from './components/CategoryBanner.vue'
import GoodsItem from '@/views/home/components/GoodsItem.vue'
import { useCategory } from './composable/useCategory'
const { category } = useCategory()
二级分类
跳转
创建二级分类页/views/subcategory/index.vue
,基本代码见这里。
修改路由/router/index.js
:
const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: 'category/:id', component: CategoryVue },{ path: 'category/sub/:id', component: SubCategoryVue }]},{ path: '/login', component: LoginVue }]
})
修改分类页/views/category/index.vue
,让二级分类链接跳转到二级分类页面:
<RouterLink :to="`/category/sub/${i.id}`">
面包屑
接口:
// 获取二级分类详情
export const getSubCategoryService = (id) => {return http.get('/category/sub/filter?id=' + id)
}
获取数据:
import { getSubCategoryService } from "@/apis/category";
import { useRoute } from 'vue-router'
import { ref } from 'vue'
const route = useRoute()
const subCategory = ref({})
const loadSubCategory = async () => {const res = await getSubCategoryService(route.params.id)subCategory.value = res.result
}
loadSubCategory()
渲染数据:
<div class="bread-container"><el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${subCategory.parentId}` }">{{ subCategory.parentName }}</el-breadcrumb-item><el-breadcrumb-item>{{ subCategory.name }}</el-breadcrumb-item></el-breadcrumb>
</div>
基本商品列表
接口:
/*** @description: 获取导航数据* @data { categoryId: 1005000 ,page: 1,pageSize: 20,sortField: 'publishTime' | 'orderNum' | 'evaluateNum'} * @return {*}*/export const getSubCategoryGoodsService = (data) => {return http({url:'/category/goods/temporary',method:'POST',data})}
加载数据:
const goods = ref([])
const params = ref({categoryId: route.params.id,page: 1,pageSize: 20,sortField: 'publishTime'
})
const loadGoods = async () => {const res = await getSubCategoryGoodsService(params.value)goods.value = res.result.items
}
loadGoods()
渲染数据:
<div class="body"><!-- 商品列表--><GoodsItem v-for="good in goods" :good="good" :key="good.id"/>
</div>
筛选
在 ElementPlus 的选项卡组件上绑定数据模型和事件:
<el-tabs v-model="params.sortField" @tab-change="tabChange"><el-tab-pane label="最新商品" name="publishTime"></el-tab-pane><el-tab-pane label="最高人气" name="orderNum"></el-tab-pane><el-tab-pane label="评论最多" name="evaluateNum"></el-tab-pane>
</el-tabs>
这样,某个选项卡被点击后,params.sortField
的值就会变为对应选项卡的name
,并会执行tab-change
事件。
tabChange
定义:
const tabChange = ()=>{params.value.page = 1loadGoods()
}
无限加载
可以通过 ElementPlus 的 无限滚动 功能实现对产品列表的无限加载。
<div class="body" v-infinite-scroll="loadMoreGoods" :infinite-scroll-disabled="loadMoreDisabled"><!-- 商品列表--><GoodsItem v-for="good in goods" :good="good" :key="good.id" />
</div>
这里的v-infinite-scroll
属性对应当前窗口滚动到商品列表底部时会触发的方法,infinite-scroll-disabled
属性对应的响应式数据如果为true
,将会停止无限加载。
loadMoreGoods
函数定义:
const loadMoreDisabled = ref(false)
const loadMoreGoods = async () => {// 翻页params.value.page++// 获取商品数据const res = await getSubCategoryGoodsService(params.value)// 如果已经没有数据了,停止加载if (res.result.items.length === 0) {loadMoreDisabled.value = truereturn}// 与已有商品数据合并goods.value = [...goods.value, ...res.result.items]
}
定制路由滚动行为
要在切换路由的时候让窗口滚动(定位)到页面的顶部,需要定制路由的滚动行为:
const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: 'category/:id', component: CategoryVue },{ path: 'category/sub/:id', component: SubCategoryVue }]},{ path: '/login', component: LoginVue }],scrollBehavior() {// 始终滚动到顶部return { top: 0 }},
})
商品详情页
路由
商品详情页的基本代码见这里。
添加二级路由:
{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: 'category/:id', component: CategoryVue },{ path: 'category/sub/:id', component: SubCategoryVue },{ path: 'detail/:id', component: DetailVue }]
},
修改HomeNew.vue
,添加商品跳转链接:
<RouterLink :to="`/detail/${good.id}`">
基础数据
接口,新建apis/detail.js
:
import http from '@/utils/http'// 获取商品详情
export const getGoodService = (id) => {return http.get('/goods?id=' + id)
}
修改detail/index.vue
,加载数据:
<script setup>
import { getGoodService } from '@/apis/detail'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
const good = ref({})
const route = useRoute()
const loadGood = async () => {const res = await getGoodService(route.params.id)good.value = res.result
}
loadGood()
</script>
渲染面包屑导航:
<el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories[1].id}` }">{{ good.categories[1].name }}</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories[0].id}` }">{{ good.categories[0].id }}</el-breadcrumb-item><el-breadcrumb-item>抓绒保暖,毛毛虫子儿童运动鞋</el-breadcrumb-item>
</el-breadcrumb>
实际运行会报错,因为页面刚加载时响应式数据good
的初始值是空对象,所以good.categories
的值是undefined
,因此试图访问其下标会报错。
解决的方式有两种,其一是使用条件访问符?.
,只在good.categories
存在时访问其下标:
<el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories?.[1].id}` }">{{ good.categories?.[1].name }}</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories?.[0].id}` }">{{ good.categories?.[0].id }}</el-breadcrumb-item><el-breadcrumb-item>抓绒保暖,毛毛虫子儿童运动鞋</el-breadcrumb-item>
</el-breadcrumb>
还有一种更简单的方式,使用 vue 的v-if
指令控制,只在存在某属性时才加载对应的控件:
<div class="container" v-if="good.details"><div class="bread-container"><el-breadcrumb separator=">"><el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories[1].id}` }">{{ good.categories[1].name }}</el-breadcrumb-item><el-breadcrumb-item :to="{ path: `/category/${good.categories[0].id}` }">{{ good.categories[0].id }}</el-breadcrumb-item><el-breadcrumb-item>抓绒保暖,毛毛虫子儿童运动鞋</el-breadcrumb-item></el-breadcrumb></div><!-- ... -->
</div>
详情页其他基本数据的页面渲染这里不再赘述。
24小时热榜
新建24小时热榜组件/detail/components/DetailHot.vue
,其基础代码见这里。
在商品详情页使用:
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"><!-- 24小时 --><DetailHotVue/><!-- 周榜 --><DetailHotVue/>
</div>
封装接口:
/*** 获取热榜商品* @param {Number} id - 商品id* @param {Number} type - 1代表24小时热销榜 2代表周热销榜* @param {Number} limit - 获取个数*/
export const fetchHotGoodsService = ({ id, type, limit = 3 }) => {return http({url:'/goods/hot',params:{id, type, limit}})
}
渲染数据:
<script setup>
import { fetchHotGoodsService } from '@/apis/detail'
import { ref } from 'vue'
import { useRoute } from 'vue-router';
const hotGoods = ref([])
const route = useRoute()
const loadHotGoods = async () => {const res = await fetchHotGoodsService({id: route.params.id,type: 1})hotGoods.value = res.result
}
loadHotGoods()
</script><template><div class="goods-hot"><h3>周日榜单</h3><!-- 商品区块 --><RouterLink to="/" class="goods-item" v-for="item in hotGoods" :key="item.id"><img :src="item.picture" alt="" /><p class="name ellipsis">{{ item.name }}</p><p class="desc ellipsis">{{ item.desc }}</p><p class="price">¥{{ item.price }}</p></RouterLink></div>
</template>
参数化热榜
为了能让周热榜和24小时热榜复用同一个控件,可以将热榜参数化:
<script setup>
import { fetchHotGoodsService } from '@/apis/detail'
import { ref } from 'vue'
import { useRoute } from 'vue-router';
const props = defineProps({hotType: {type: Number}
})
const title = props.hotType === 1 ? '24小时热榜' : '周热榜'
const hotGoods = ref([])
const route = useRoute()
const loadHotGoods = async () => {const res = await fetchHotGoodsService({id: route.params.id,type: props.hotType})hotGoods.value = res.result
}
loadHotGoods()
</script><template><div class="goods-hot"><h3>{{ title }}</h3><!-- 商品区块 --><RouterLink to="/" class="goods-item" v-for="item in hotGoods" :key="item.id"><img :src="item.picture" alt="" /><p class="name ellipsis">{{ item.name }}</p><p class="desc ellipsis">{{ item.desc }}</p><p class="price">¥{{ item.price }}</p></RouterLink></div>
</template>
对应的,只要在商品详情页指定不同的参数,就能加载不同的热榜:
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"><!-- 24小时 --><DetailHotVue :hotType="1" /><!-- 周榜 --><DetailHotVue :hotType="2" />
</div>
图片预览
新建图片预览控件/src/components/imageview/index.vue
,基本代码见这里。
实现:
<script setup>
import { ref } from 'vue'
// ...
const activeIndex = ref(0)
const mouseEnter = (i) => {activeIndex.value = i
}
</script><template><div class="goods-image"><!-- ... --><!-- 小图列表 --><ul class="small"><li v-for="(img, i) in imageList" :key="i" @mouseenter="mouseEnter(i)" :class="{ active: i === activeIndex }"><img :src="img" alt="" /></li></ul><!-- ... --></div>
</template>
这里的@mouseenter
事件对应鼠标移入小图的事件,所绑定的mouseEnter
方法中用当前小图的下标替换activeIndex
的值。:class="{ active: i === activeIndex }"
可以让当前生效的下标对应的小图拥有active
的class
值,也就是有被选中的样式。
图片蒙版随鼠标移动
<script setup>
import { ref, watch } from 'vue'
import { useMouseInElement } from '@vueuse/core'
// ...
const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)
const x = elementX
const y = elementY
const top = ref(0)
const left = ref(0)
watch([x, y], () => {if (x.value > 100 && x.value < 300) {left.value = x.value - 100}if (y.value > 100 && y.value < 300) {top.value = y.value - 100}if (x.value <= 100) {left.value = 0}if (x.value >= 300) {left.value = 200}if (y.value <= 100) {top.value = 0}if (y.value >= 300) {top.value = 200}
})
</script><template><div class="goods-image"><!-- 左侧大图--><div class="middle" ref="target"><img :src="imageList[activeIndex]" alt="" /><!-- 蒙层小滑块 --><div class="layer" :style="{ left: `${left}px`, top: `${top}px` }"></div></div><!-- ... --></div>
</template>
useMouseInElement
是 vue-use 中用于定位鼠标在元素中相对位置的函数。其返回值的含义:
- elementX,鼠标在元素中的 x 轴坐标
- elementY,鼠标在元素中的 y 轴坐标
- isOutside,鼠标是否在元素外
这里用 vue 的 watch 函数监听鼠标在元素中的位置改变,位置发生变化后控制蒙版的位置改变。
放大镜
<script setup>
// ...
const largeLeft = ref(0)
const largeTop = ref(0)
watch([x, y], () => {// ...largeLeft.value = -left.value * 2largeTop.value = -top.value * 2
})
</script><template><div class="goods-image"><!-- 左侧大图--><div class="middle" ref="target"><img :src="imageList[activeIndex]" alt="" /><!-- 蒙层小滑块 --><div class="layer" :style="{ left: `${left}px`, top: `${top}px` }" v-show="!isOutside"></div></div><!-- 小图列表 --><ul class="small"><li v-for="(img, i) in imageList" :key="i" @mouseenter="mouseEnter(i)" :class="{ active: i === activeIndex }"><img :src="img" alt="" /></li></ul><!-- 放大镜大图 --><div class="large" :style="[{backgroundImage: `url(${imageList[activeIndex]})`,backgroundPositionX: `${largeLeft}px`,backgroundPositionY: `${largeTop}px`,},]" v-show="!isOutside"></div></div>
</template>
这里的放大镜实际上是一张长宽是预览图2倍大的图片,通过控制图片移动(方向与蒙版相反)来控制放大镜内容的改变。此外,这里还通过v-show="!isOutside"
来控制鼠标移出预览图时隐藏放大镜与蒙版。
组件参数化
将图片预览组件中使用的硬编码图片列表参数化:
defineProps({imageList: {type: Array,default: () => []}
})
修改图片详情页views/detail/index.vue
,传递参数:
<ImageViewVue :imageList="good.mainPictures"/>
SKU控件
将 SKU 控件放入/src/components
下。
导入并使用控件:
<script setup>
import XtxSkuVue from "@/components/XtxSku/index.vue";
// ...
const skuChanged = (sku) => {console.log(sku)
}
</script>
<template><!-- ... --><!-- sku组件 --><XtxSkuVue :goods="good" @change="skuChanged" />
</template>
该控件需要传入一个表示商品的参数,在规格被选中时,会调用change
方法返回选中的规格。
全局组件注册
可以将常用组件注册为全局组件。
新建/src/components/index.js
:
// 将 components 下的组件注册为全局组件
import ImageView from './imageview/index.vue'
import Sku from './XtxSku/index.vue'
export const componentsPlugin = {install: (app) => {app.component('XtxImageView', ImageView)app.component('XtxSku', Sku)}
}
在main.js
中以插件方式使用:
// ...
import { componentsPlugin } from './components'
// ...
app.use(componentsPlugin)app.mount('#app')
在views/detail/index.vue
中直接使用全局控件:
<XtxImageView :imageList="good.mainPictures" />
<!-- ... -->
<XtxSku :goods="good" @change="skuChanged" />
登录
页面
新建登录页login/index.vue
,基本代码可以从这里获取。
修改页头的用户状态显示/layout/components/LayoutNav.vue
,强制显示非登录状态:
<template v-if="false">
修改跳转链接:
<li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li>
表单校验
<script setup>
import { ref } from 'vue'
const loginData = ref({account: '',password: '',agree: true
})
const rules = {account: [{ required: true, message: '账户不能为空', trigger: 'blur' }],password: [{ required: true, message: '密码不能为空', trigger: 'blur' },{ min: 6, max: 14, message: '密码为6~14个字符', trigger: 'blur' }],agree: [{validator: (rule, value, callback) => {if (value) {callback()}else {callback(new Error('请同意用户协议'))}}}]
}
</script><template>
<!-- ... -->
<el-form :model="loginData" :rules="rules" label-position="right" label-width="60px" status-icon><el-form-item label="账户" prop="account"><el-input v-model="loginData.account" /></el-form-item><el-form-item label="密码" prop="password"><el-input v-model="loginData.password" /></el-form-item><el-form-item label-width="22px" prop="agree"><el-checkbox size="large" v-model="loginData.agree">我已同意隐私条款和服务条款</el-checkbox></el-form-item><el-button size="large" class="subBtn">点击登录</el-button>
</el-form>
</template>
登录统一校验
在表单上配置的校验规则只会在表单元素失去焦点时触发,直接点击登录按钮并不会触发校验规则,因此需要在点击登录按钮时手动执行表单对象的校验规则:
const formRef = ref(null)
const btnLoginClick = () => {formRef.value.validate((valid) => {console.log(valid)if(valid){// 执行登录操作}})
}
这里的formRef
绑定的是表单对象:
<el-form ref="formRef" :model="loginData" :rules="rules" label-position="right" label-width="60px" status-icon>
btnLoginClick
对应的是登录按钮点击事件:
<el-button size="large" class="subBtn" @click="btnLoginClick">点击登录</el-button>
登录
封装接口,新增接口文件/src/apis/user.js
:
import http from '@/utils/http'/*** 登录* @param {String} account* @param {String} password* @returns */
export const loginService = (params) => {return http.post('/login', params)
}
调用接口进行登录:
import { ElMessage } from 'element-plus';
import 'element-plus/theme-chalk/el-message.css'
// ...
const btnLoginClick = () => {formRef.value.validate(async (valid) => {console.log(valid)if (valid) {// 执行登录操作const { account, password } = loginData.valueawait loginService({ account, password })ElMessage.success('登录成功')// 登录成功后跳转到首页router.replace({ path: '/' })}})
}
登录失败的提示信息由 Axios 的响应拦截器完成:
import { ElMessage } from 'element-plus';
import 'element-plus/theme-chalk/el-message.css'
// ...
// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {ElMessage.warning(e.response.data.message)return Promise.reject(e)
})
Pinia 存储用户数据
创建存储库文件stores/user.js
:
import { defineStore } from "pinia";
import { ref } from 'vue'
import { loginService } from '@/apis/user'export const useUserStore = defineStore('user', () => {const userInfo = ref({})const loadUserInfo = async (account, password) => {const res = await loginService({ account, password })userInfo.value = res.result}return { userInfo, loadUserInfo }
})
在登录时调用存储库的 Action 存储用户数据:
<script setup>
// ...
import { useUserStore } from '@/stores/user'
// ...
const userStore = useUserStore()
const btnLoginClick = () => {formRef.value.validate(async (valid) => {console.log(valid)if (valid) {// 执行登录操作const { account, password } = loginData.valueawait userStore.loadUserInfo(account, password)ElMessage.success('登录成功')// 登录成功后跳转到首页router.replace({ path: '/' })}})
}
</script>
用户数据持久化
这里使用 Pinia 插件 pinia-plugin-persistedstate 实现。
安装:
npm i pinia-plugin-persistedstate
修改main.js
,使用插件:
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.use(imgLazyPlugin)
app.use(componentsPlugin)app.mount('#app')
修改stores/user.js
,持久化用户数据:
export const useUserStore = defineStore('user', () => {// ...
},{persist: true,}
)
登录状态显示
处于登录状态时,标题栏显示用户名称。
修改LayoutNav.vue
:
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script><template><nav class="app-topnav"><div class="container"><ul><template v-if="userStore.userInfo.token"><li><a href="javascript:;"><i class="iconfont icon-user"></i>{{ userStore.userInfo.account }}</a></li><li><el-popconfirm title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"><template #reference><a href="javascript:;">退出登录</a></template></el-popconfirm></li><li><a href="javascript:;">我的订单</a></li><li><a href="javascript:;">会员中心</a></li></template><template v-else><li><a href="javascript:;" @click="$router.push('/login')">请先登录</a></li><li><a href="javascript:;">帮助中心</a></li><li><a href="javascript:;">关于我们</a></li></template></ul></div></nav>
</template>
传递 token
很多接口都要求通过报文头传递token,这一点可以通过 Axios 的请求拦截器做到:
// axios请求拦截器
http.interceptors.request.use(config => {// 获取tokenconst userStore = useUserStore()const token = userStore.userInfo.token// 将 token 设置为请求头if (token) {config.headers.Authorization = `Bearer ${token}`}return config
}, e => Promise.reject(e))
退出登录
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
// 确认退出
const confirmed = () => {// 清理 userStoreuserStore.clearUserInfo()// 跳转到登录页router.push({ path: '/login' })
}
</script><template>
<!-- ... -->
<el-popconfirm @confirm="confirmed" title="确认退出吗?" confirm-button-text="确认" cancel-button-text="取消"><template #reference><a href="javascript:;">退出登录</a>
</template>
</el-popconfirm>
<!-- ... -->
</template>
el-popconfirm
是一个绑定到按钮的确认框,@confirm
是绑定的点击确认框中确认按钮后的事件。
处理 token 失效
长时间不操作会导致 token 失效,服务端接口会返回 401 状态码,此时需要在 Axios 的响应拦截器进行统一处理:
import router from '@/router';
// ...
// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {ElMessage.warning(e.response.data.message)// token 失效时服务端返回 http 状态码为 401if (e.response.status === 401) {// 清理 userStoreconst userStore = useUserStore()userStore.clearUserInfo()// 跳转到登录页router.push({ path: '/login' })}return Promise.reject(e)
})
需要注意的是,因为加载顺序的关系,这里不能使用useRouter
函数获取router
对象。
购物车
添加购物车
为购物车创建存储库stores/cart.js
:
import { defineStore } from "pinia";
import { ref } from "vue";// 购物车
export const useCartStore = defineStore('cart', () => {// 商品列表const goods = ref([])// 添加商品const addGood = (good) => {console.log(good)const matched = goods.value.find((item) => item.skuId === good.skuId)if (matched) {// 购物车中已经存在相同的 skumatched.count += good.count}else {// 购物车中没有goods.value.push(good)}}return { goods, addGood }
}, {persist: true,
})
修改商品详情页detail/index.vue
:
<script setup>
import { getGoodService } from '@/apis/detail'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import DetailHotVue from './components/DetailHot.vue'
import { ElMessage } from 'element-plus';
import 'element-plus/theme-chalk/el-message.css'
import { useCartStore } from '@/stores/cart'
const good = ref({})
const route = useRoute()
const loadGood = async () => {const res = await getGoodService(route.params.id)good.value = res.result
}
loadGood()
// 选中的 sku
let skuSelected = {}
const skuChanged = (sku) => {console.log(sku)skuSelected = sku
}
// 选购商品数量
const num = ref(1)
const cartStore = useCartStore()
// 点击加入购物车按钮
const btnCartClick = () => {if (!skuSelected.skuId) {// 如果没有选中规格ElMessage.warnning('请选择规格')return}// 如果数量小于等于0if (num.value <= 0) {ElMessage.warnning('请选择数量')}// 加入购物车cartStore.addGood({id: good.value.id,name: good.value.name,picture: good.value.mainPictures[0],price: good.value.price,count: num.value,skuId: skuSelected.skuId,attrText: skuSelected.specsText,selected: true})
}
</script>
添加数量控件并绑定购物车按钮点击事件:
<!-- 数据组件 -->
<el-input-number v-model="num" :min="1" :max="10" @change="handleChange" />
<!-- 按钮组件 -->
<div><el-button size="large" class="btn" @click="btnCartClick">加入购物车</el-button>
</div>
头部购物车
创建头部购物车控件views/layout/HeaderCart.vue
,基础代码见这里。
在LayoutHeader.vue
中使用头部购物车:
<!-- 头部购物车 -->
<HeaderCartVue/>
为购物车添加删除功能:
import { defineStore } from "pinia";
import { ref, computed } from "vue";// 购物车
export const useCartStore = defineStore('cart', () => {// ...const delGood = (skuId) => {const index = goods.value.findIndex(item => item.skuId === skuId)console.log(index)if (index >= 0) {goods.value.splice(index, 1)}}// ...return { goods, addGood, delGood }
}, {persist: true,
})
修改HeaderCart.vue
,绑定删除按钮点击事件:
<i class="iconfont icon-close-new" @click="cartStore.delGood(i.skuId)"></i>
为购物车添加计算属性以统计购物车中的总数和总金额:
// ...
export const useCartStore = defineStore('cart', () => {// ...const count = computed(() => {return goods.value.reduce((totalCount, good) => {return totalCount + good.count}, 0)})const price = computed(() => {return goods.value.reduce((totalPrice, good) => {return totalPrice + good.price * good.count}, 0)})return { goods, addGood, delGood, count, price }
}, {persist: true,
})
在头部购物车中显示总数和总金额:
<div class="foot"><div class="total"><p>共 {{ cartStore.count }} 件商品</p><p>¥ {{ cartStore.price.toFixed(2) }} </p></div><el-button size="large" type="primary">去购物车结算</el-button>
</div>
列表购物车
创建列表购物车控件/views/cartlist/index.vue
,基本代码见这里。
添加路由:
{path: '/',component: LayoutVue,children: [{ path: '', component: HomeVue },{ path: 'category/:id', component: CategoryVue },{ path: 'category/sub/:id', component: SubCategoryVue },{ path: 'detail/:id', component: DetailVue },{ path: 'cartlist', component: CartListVue }]
},
修改头部购物车/views/layout/components/HeaderCart.vue
,绑定点击事件:
<el-button size="large" type="primary" @click="$router.push('/cartlist')">去购物车结算</el-button>
修改购物车列表,使用存储库数据渲染列表:
import {useCartStore} from '@/stores/cart'
const cartStore = useCartStore()
const cartList = cartStore.goods
单选按钮
为列表购物车的单选按钮绑定事件和值:
<el-checkbox :model-value="i.selected" @change="(selected) => ckboxChanged(i.skuId, selected)" />
这里并没有直接使用v-model
属性进行双向绑定,而是采用model-value
属性和change
事件实现双向绑定,这样可以在change
事件中加入自定义逻辑,更为灵活。
change
事件的实现:
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const cartList = cartStore.goods
const ckboxChanged = (skuId, selected) => {cartStore.changeSelected(skuId, selected)
}
全选按钮
为购物车存储库增加一个计算属性,用于表示是否所有商品都被选中:
// 是否全部选中
const isAllSelected = computed(() => {return goods.value.every(g => g.selected)
})
使用该计算属性作为全选按钮的值:
<el-checkbox :model-value="cartStore.isAllSelected" @change="ckboxAllChanged" />
为购物车存储库增加一个 Action,用于修改所有商品的选中状态:
// 修改所有商品的选中状态
const changeAllSelected = (selected) => {goods.value.forEach(g => g.selected = selected)
}
使用该 Action 实现全选按钮的change
事件:
const ckboxAllChanged = (selected) => {cartStore.changeAllSelected(selected)
}
合计
列表购物车中需要显示选中商品的合计情况,同样需要使用存储库的计算属性实现:
// 选中商品的数目总和
const selectedCount = computed(() => {return goods.value.filter(g => g.selected).reduce((total, g) => total + g.count, 0)
})
// 选中商品的价格总和
const selectedPrice = computed(() => {return goods.value.filter(g => g.selected).reduce((total, g) => total + g.count * g.price, 0)
})
将相关内容渲染到页面:
<div class="batch">共 {{ cartStore.count }} 件商品,已选择 {{ cartStore.selectedCount }} 件,商品合计:<span class="red">¥ {{ cartStore.selectedPrice.toFixed(2) }} </span>
</div>
购物车接口
加入购物车
修改加入购物车逻辑,如果用户已经登录,通过接口加入商品到购物车,并且通过接口获取最新的购物车信息并覆盖本地购物车数据。
新增购物车相关接口apis/cart.js
:
import http from '@/utils/http'/*** 添加商品到购物车* @param {String} skuId * @param {Number} count * @returns */
export const addGood2CartService = (skuId, count) => {return http.post('/member/cart', { skuId, count })
}/*** 从购物车获取商品列表* @returns */
export const getGoodsFromCartService = () => {return http.get('/member/cart')
}
修改存储库stores/user.js
,增加一个表示是否登录的计算属性:
const isLogin = computed(() => {if(userInfo.value.token){return true}return false
})
修改存储库stores/cart.js
:
// ...
import { addGood2CartService, getGoodsFromCartService } from '@/apis/cart'
// 添加商品
const addGood = async (good) => {// 用户是否登录,如果已经登录,通过接口添加购物车,并获取购物车信息覆盖本地数据const userStore = useUserStore()if (userStore.isLogin) {// 用户已经登录// 通过接口添加购物车await addGood2CartService(good.skuId, good.count)// 从接口获取购物车信息const res = await getGoodsFromCartService()// 覆盖本地购物车goods.value = res.result}else {const matched = goods.value.find((item) => item.skuId === good.skuId)if (matched) {// 购物车中已经存在相同的 skumatched.count += good.count}else {// 购物车中没有goods.value.push(good)}}
}
删除购物车
封装接口:
/*** 从购物车删除商品* @param {Array} skuIds skuId 的集合* @returns */
export const delGoodsFromCartService = (skuIds) => {return http.delete('/member/cart', {data: {ids: skuIds}})
}
修改购物车存储库的删除 Action:
const delGood = async (skuId) => {const userStore = useUserStore()if (userStore.isLogin) {// 用户登录时,通过接口删除商品await delGoodsFromCartService([skuId])// 通过接口获取最新购物车数据const res = await getGoodsFromCartService()// 覆盖本地购物车数据goods.value = res.result}const index = goods.value.findIndex(item => item.skuId === skuId)console.log(index)if (index >= 0) {goods.value.splice(index, 1)}
}
有多个地方都会从服务端更新购物车信息到本地,这部分逻辑可以封装复用:
// 从服务端读取购物车数据并更新到本地
const loadGoodsFromServer = async ()=>{// 从接口获取购物车信息const res = await getGoodsFromCartService()// 覆盖本地购物车goods.value = res.result
}
清空购物车
需要在退出登录时清除购物车信息。
为购物车存储库增加清除信息 Action:
// 清除购物车中的商品信息
const clear = () => {goods.value = []
}
修改用户存储库,在退出时清除购物车信息:
const clearUserInfo = () => {userInfo.value = {}// 清除本地购物车const cartStore = useCartStore()cartStore.clear()
}
合并购物车
封装接口:
/*** 合并购物车* @param {[skuId:String, selected:string, count:Number]} goods * @returns */
export const mergeCartService = (goods) => {return http.post('/member/cart/merge', goods)
}
修改购物车存储库,增加合并 Action:
// 合并购物车
const merge = () => {// 合并购物车const items = goods.value.map(g => {return { skuId: g.skuId, selected: g.selected, count: g.count }})mergeCartService(items)// 更新本地购物车loadGoodsFromServer()
}
修改用户存储库,在登录后合并购物车:
const loadUserInfo = async (account, password) => {const res = await loginService({ account, password })userInfo.value = res.result// 合并购物车cartStore.merge()
}
结算
基本数据渲染
创捷结算页/views/checkout/index.vue
,基本代码见这里
封装接口apis/checkout.js
:
import http from '@/utils/http'// 获取结算页订单信息
export const getCheckoutOrderService = () => {return http.get('/member/order/pre')
}
渲染页面:
<script setup>
import { getCheckoutOrderService } from '@/apis/checkout'
import { onMounted, ref } from 'vue';
const order = ref({})
const loadCheckoutOrder = async () => {const res = await getCheckoutOrderService()order.value = res.result
}
const checkInfo = ref({}) // 订单对象
const curAddress = ref({}) // 地址对象
onMounted(async () => {await loadCheckoutOrder()const addr = order.value.userAddresses.find(a => a.isDefault === 0)checkInfo.value = order.valuecurAddress.value = addr
})
</script>
切换地址弹窗
<!-- 切换地址 -->
<el-dialog v-model="showDialog" title="切换收货地址" width="30%" center><div class="addressWrapper"><div class="text item" v-for="item in checkInfo.userAddresses" :key="item.id"><ul><li><span>收<i />货<i />人:</span>{{ item.receiver }} </li><li><span>联系方式:</span>{{ item.contact }}</li><li><span>收货地址:</span>{{ item.fullLocation + item.address }}</li></ul></div></div><template #footer><span class="dialog-footer"><el-button>取消</el-button><el-button type="primary">确定</el-button></span></template>
</el-dialog>
定义showDialog
:
// 是否显示切换地址弹窗
const showDialog = ref(false)
绑定按钮点击事件:
<el-button size="large" @click="showDialog = true">切换地址</el-button>
切换地址
创建一个变量记录当前激活的地址:
// 当前激活的地址
const activeAddr = ref({})
点击地址信息后记录该地址,并设置动态类名显示当前激活的地址:
<div class="text item" :class="{ active: item.id == activeAddr.id }" v-for="item in checkInfo.userAddresses" :key="item.id" @click="activeAddr = item">
为弹窗确认按钮绑定点击事件:
<el-button type="primary" @click="btnDialogConfirmClick">确定</el-button>
const btnDialogConfirmClick = () => {curAddress.value = activeAddr.valueshowDialog.value = falseactiveAddr.value = {}
}
提交订单
创建提交订单后要跳转到的支付页面views/pay/index.vue
,基本代码见这里。
配置二级路由:
{ path: 'pay', component: PayVue }
封装接口:
// 提交订单
export const commitOrderService = (data) => {return http.post('/member/order', data)
}
修改结算页,增加提交订单点击事件:
const router = useRouter()
const cartStore = useCartStore()
const btnCommitOrderClick = async () => {const res = await commitOrderService({deliveryTimeType: 1,payType: 1,payChannel: 1,buyerMessage: '',goods: checkInfo.value.goods.map(g => { return { skuId: g.skuId, count: g.count } }),addressId: curAddress.value.id})// 提交订单成功后需要更新购物车信息await cartStore.loadGoodsFromServer()const orderId = res.result.idrouter.push('/pay?id=' + orderId)
}
为按钮绑定事件:
<el-button type="primary" size="large" @click="btnCommitOrderClick">提交订单</el-button>
支付
渲染数据
封装接口apis/pay.js
:
import http from '@/utils/http'export const getOrderInfoService = (id) => {return http.get(`/member/order/${id}`)
}
渲染数据到支付页:
<script setup>
import { getOrderInfoService } from '@/apis/pay'
import { ref } from "vue";
import { useRoute } from 'vue-router'
const payInfo = ref({})
const route = useRoute()
const loadPayInfo = async () => {const res = await getOrderInfoService(route.query.id)payInfo.value = res.result
}
loadPayInfo()
</script>
支付
拼接支付地址:
// 支付地址
const baseURL = 'http://pcapi-xiaotuxian-front-devtest.itheima.net/'
const backURL = 'http://127.0.0.1:5173/paycallback'
const redirectUrl = encodeURIComponent(backURL)
const payUrl = `${baseURL}pay/aliPay?orderId=${route.query.id}&redirect=${redirectUrl}
让支付链接使用该地址:
<a class="btn alipay" :href="payUrl"></a>
点击链接即可跳转到支付宝沙箱环境支付。
黑马程序员提供的沙箱账号已经没有余额,无法进行后续步骤。
支付结果展示
新建支付结果页views/pay/PayBack.vue
,基本代码见这里。
获取订单数据:
<script setup>
import { ref } from 'vue';
import { getOrderInfoService } from '@/apis/pay'
import { useRoute } from 'vue-router'
const route = useRoute()
const payInfo = ref({})
const loadPayInfo = async () => {const res = await getOrderInfoService(route.query.orderId)payInfo.value = res.result
}
loadPayInfo()
</script>
渲染数据:
<span class="iconfont icon-queren2 green" v-if="$route.query.payResult === 'true'"></span>
<span class="iconfont icon-shanchu red" v-else></span>
<p class="tit">支付{{ $route.query.payResult === 'true' ? '成功' : '失败' }}</p>
<p class="tip">我们将尽快为您发货,收货期间请保持手机畅通</p>
<p>支付方式:<span>支付宝</span></p>
<p>支付金额:<span>¥{{ payInfo.payMoney?.toFixed(2) }}</span></p>
倒计时
待支付页面有个倒计时,编写一个第三方倒计时组件composables/timer.js
:
import { ref, onUnmounted, computed } from 'vue'
import { dayjs } from 'element-plus'// 计时器
export const useTimer = () => {const leftSeconds = ref(0)const formatTime = computed(() => {return dayjs.unix(leftSeconds.value).format('mm分ss秒')})const start = (totalSeconds) => {if(totalSeconds<=0){return}leftSeconds.value = totalSecondslet interval = setInterval(() => {leftSeconds.value--if (leftSeconds.value <= 0) {clearInterval(interval)}}, 1000)// 如果控件销毁时还存在定时任务,结束onUnmounted(() => {if (interval) {clearInterval(interval)}})}return { formatTime, start }
}
修改待支付页面pay/index.vue
,启动计时器:
const timer = useTimer()
const loadPayInfo = async () => {const res = await getOrderInfoService(route.query.id)payInfo.value = res.resulttimer.start(payInfo.value.countdown)
}
渲染计时器:
<p>支付还剩 <span>{{ timer.formatTime }}</span>, 超时后将取消订单</p>
个人中心
路由
新增个人中心框架组件/views/member/index.vue
,基本代码见这里。
新增个人中心组件/member/components/UserInfo.vue
,基本代码见这里。
新增我的订单组件/member/components/UserOrder.vue
,基本代码见这里。
增加路由:
{path: 'member', component: MemberVue, children: [{ path: 'user', component: UserInfoVue },{ path: 'order', component: UserOrderVue }]
}
渲染个人中心数据
封装接口:
import http from '@/utils/http'export const getLikeListService = ({ limit = 4 }) => {return http({url: '/goods/relevant',params: {limit}})
}
渲染数据:
<script setup>
import { useUserStore } from '@/stores/user'
import { getLikeListService } from "@/apis/member";
import { ref } from 'vue'
import GoodsItem from "@/views/home/components/GoodsItem.vue";
const userStore = useUserStore()
const likeList = ref([])
const loadLikeList = async () => {const res = await getLikeListService({})likeList.value = res.result
}
loadLikeList()
</script>
我的订单
基本数据
新增订单接口/apis/order.js
:
import http from '@/utils/http'/*
params: {orderState:0,page:1,pageSize:2
}
*/
export const getUserOrderService = (params) => {return http({url: '/member/order',method: 'GET',params})
}
渲染数据:
// 订单列表
const orderList = ref([])
const loadOrderList = async () => {const params = {orderState: 0,page: 1,pageSize: 2}const res = await getUserOrderService(params)orderList.value = res.result.items
}
loadOrderList()
订单状态切换
定义状态切换事件:
// 订单列表
const params = ref({orderState: 0,page: 1,pageSize: 2
})
const orderList = ref([])
const loadOrderList = async () => {const res = await getUserOrderService(params.value)orderList.value = res.result.items
}
loadOrderList()
// 标签页切换事件
const tabChanged = (index) => {params.value.orderState = indexloadOrderList()
}
绑定事件:
<el-tabs @tab-change="tabChanged">
分页
设置总条数和页面跳转事件:
// 总条数
const total = ref(0)
const orderList = ref([])
const loadOrderList = async () => {const res = await getUserOrderService(params.value)orderList.value = res.result.itemstotal.value = res.result.counts
}
loadOrderList()
// 标签页切换事件
const tabChanged = (index) => {params.value.orderState = indexloadOrderList()
}
// 页码跳转
const pageChanged = (currentPage)=>{params.value.page = currentPageloadOrderList()
}
为 ElementPlus 分页组件绑定属性和方法:
<el-pagination :total="total" :page-size="params.pageSize" @current-change="pageChanged" background layout="prev, pager, next" />
订单状态中文显示
准备转换函数:
const fomartPayState = (payState) => {
const stateMap = {1: '待付款',2: '待发货',3: '待收货',4: '待评价',5: '已完成',6: '已取消'
}
return stateMap[payState]
}
在显示订单状态时用函数转换内容:
<p>{{ fomartPayState(order.orderState) }}</p>
默认显示个人中心页面
修改路由:
path: 'member', component: MemberVue, children: [{ path: '', component: UserInfoVue },{ path: 'order', component: UserOrderVue }
]
修改views/member/index.vue
中的菜单路径:
RouterLink to="/member">个人中心</RouterLink>
修改/views/layout/components/LayoutNav.vue
中的链接:
<li><a href="/member/order">我的订单</a></li>
<li><a href="/member">会员中心</a></li>
参考资料
- 黑马程序员前端Vue3小兔鲜电商项目实战