创建项目:
npm init vue@latest
相关选项如下:
在src
目录下添加以下目录:
默认情况下在 VSCode 中输入import xxx from '@...'
时不会启用路径联想功能,要启用需要在项目根目录下添加 VSCode 配置文件jsconfig.json
:
{
"compilerOptions" : {
"baseUrl" : "./",
"paths" : {
"@/*":["src/*"]
}
}
}
如果 VSCode 已经自动创建该文件,可以跳过这一步。
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 实例,并进行统一配置,这样可以对所有接口调用都要用的配置信息进行统一管理。
安装:
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 文件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>
在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 存储数据。
创建分类的数据存储:
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 页拆分成以下几部分:
<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.value
stop()
}
},
)
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等)。
解决这个问题有两种方案:
第一种方案,修改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 = 1
loadGoods()
}
可以通过 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 = true
return
}
// 与已有商品数据合并
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小时热榜组件/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 中用于定位鼠标在元素中相对位置的函数。其返回值的含义:
这里用 vue 的 watch 函数监听鼠标在元素中的位置改变,位置发生变化后控制蒙版的位置改变。
<script setup>
// ...
const largeLeft = ref(0)
const largeTop = ref(0)
watch([x, y], () => {
// ...
largeLeft.value = -left.value * 2
largeTop.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 控件放入/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.value
await 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)
})
创建存储库文件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.value
await 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,这一点可以通过 Axios 的请求拦截器做到:
// axios请求拦截器
http.interceptors.request.use(config => {
// 获取token
const 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 = () => {
// 清理 userStore
userStore.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 失效,服务端接口会返回 401 状态码,此时需要在 Axios 的响应拦截器进行统一处理:
import router from '@/router';
// ...
// axios响应式拦截器
http.interceptors.response.use(res => res.data, e => {
ElMessage.warning(e.response.data.message)
// token 失效时服务端返回 http 状态码为 401
if (e.response.status === 401) {
// 清理 userStore
const 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) {
// 购物车中已经存在相同的 sku
matched.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
}
// 如果数量小于等于0
if (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) {
// 购物车中已经存在相同的 sku
matched.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.value
curAddress.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.value
showDialog.value = false
activeAddr.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.id
router.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 = totalSeconds
let 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.result
timer.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 = index
loadOrderList()
}
绑定事件:
<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.items
total.value = res.result.counts
}
loadOrderList()
// 标签页切换事件
const tabChanged = (index) => {
params.value.orderState = index
loadOrderList()
}
// 页码跳转
const pageChanged = (currentPage)=>{
params.value.page = currentPage
loadOrderList()
}
为 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>