前端学习笔记 7:小兔鲜

前端学习笔记 7:小兔鲜

准备工作

创建项目

创建项目:

npm init vue@latest

相关选项如下:

image-20240110103054731

src目录下添加以下目录:

image-20240110103546382

别名路径联想

默认情况下在 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'

为了方便查看错误提示信息,可以添加插件:

image-20240111120319630

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">&yen;{{ 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">&yen;{{ 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">&yen;{{ 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">&yen;{{ 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 }"可以让当前生效的下标对应的小图拥有activeclass值,也就是有被选中的样式。

图片蒙版随鼠标移动

<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>&yen; {{ 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小兔鲜电商项目实战

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/640623.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

使用宝塔面板安装wiki.js详细教程

因为在安装过程中遇到了一些问题&#xff0c;花费了很长时间在解决问题上。根据这篇教程可以少踩很多坑。点赞加关注吧。 准备运行环境 Nodejs 在宝塔面板的软件商店中找到nodejs版本管理器并安装。 点击设置&#xff0c;选择一个稳定版安装。 PostgreSQL 官方推荐的数据库是…

用户洞察:精准解读用户的真实需求!

洞察用户需求的过程和谈恋爱一样。你不能简简单单地问客户&#xff0c;你想要什么&#xff1f;你有什么痛点&#xff1f;这样的问法是无法得到任何有价值的信息。这就好比谈恋爱的场景&#xff0c;如果你问对方想吃什么&#xff0c;大概率会得到“随便”“都行”这类的答案&…

力扣62. 不同路径

动态规划 思路&#xff1a; 定义 dp[r][c] 为到达坐标 (r, c) 的路径数&#xff1a; 它只能有同一行左边相邻方格向右到达或者同一列上方相邻方格向下到达&#xff1b;状态转移方程&#xff1a; dp[r][c] dp[r][c - 1] dp[r - 1][c]初始状态 dp[0][0] 1第一行的路径数是 1第…

2526. 随机数生成器(BSGS,推导)

题目路径&#xff1a; https://www.acwing.com/problem/content/2528/ 思路&#xff1a;

HNU-数据挖掘-实验1-实验平台及环境安装

数据挖掘课程实验实验1 实验平台及环境安装 计科210X 甘晴void 202108010XXX 文章目录 数据挖掘课程实验<br>实验1 实验平台及环境安装实验背景实验目标实验步骤1.安装虚拟机和Linux平台&#xff0c;熟悉Ubuntu环境。2.在Linux平台上搭建Python平台&#xff0c;并安装…

esp32-idf eclipse 分区表(partition table / NVS)的读写demo

前言&#xff1a; 分区表&#xff08;Partition Table&#xff09;和 NVS&#xff08;Non-Volatile Storage&#xff09;是 ESP-IDF 中用于存储数据的两种不同机制。 分区表&#xff08;Partition Table&#xff09;&#xff1a; 分区表定义了将 Flash 存储器划分为不同逻辑分…

RT-DETR 模型改进 | AKConv:具有任意采样形状和任意参数数量的卷积核

基于卷积操作的神经网络在深度学习领域取得了显著的成果,但标准卷积操作存在两个固有缺陷。一方面,卷积操作受限于局部窗口,无法捕捉其他位置的信息,而其采样形状是固定的。另一方面,卷积核的大小固定为kk,呈固定的正方形形状,而参数数量往往随大小呈平方增长。显然,不…

2024 年大促入手哪些云服务器实用划算?

2024年各大云厂商的“价格战”又已拉开帷幕&#xff0c;作为用户的我们最为关心的是这些云服务商的年终大促中&#xff0c;实用划算的云服务器配置有哪些&#xff1f;小编看了一下&#xff0c;今年的年终大促活动中&#xff0c;国内云平台几位大佬&#xff0c;阿里云&#xff0…

C++入门学习(十一)字符型

C中的字符型可以表示ASCII码中的所有字符&#xff0c;包括字母、数字、标点符号等。 ASCII码是一种用于编码字符的编码系统&#xff0c;它使用不同的数值来表示不同的字符。ASCII码使用7位或8位二进制数来表示每个字符&#xff0c;因此可以表示128或256个不同的字符。 在ASCI…

构建开源的多模态 RAG 系统

每日推荐一篇专注于解决实际问题的外文,精准翻译并深入解读其要点,助力读者培养实际问题解决和代码动手的能力。 欢迎关注公众号(NLP Research),及时查看最新内容 原文标题:Building an Open Source Multi-Modal RAG System 原文地址:https://medium.com/nadsoft/buil…

性能利器Caffeine缓存全面指南

第1章&#xff1a;引言 大家好&#xff0c;我是小黑&#xff0c;今天咱们聊聊Caffeine缓存&#xff0c;小黑在网上购物&#xff0c;每次查看商品都要等几秒钟&#xff0c;那体验肯定不咋地。但如果用了缓存&#xff0c;常见的商品信息就像放在口袋里一样&#xff0c;随时取用&…

杭电网课笔记

技巧 1.判断得数为整数还是小数&#xff0c;可以%1&#xff0c;得数为0是整数 或者用instanceof Integer number 9; // 自动装箱 System.out.println(number instanceof Integer); // 输出&#xff1a;true 2.a * b 最大公约数 * 最小公倍数 LCM 最小公倍数 GCD 最大公…

Java:扫码登录

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 目录 一、需求背景 二、问题分析 三、对比APP和打印机设备的特点 四、设计 五、编码 总结 提示&#xff1a;以下是本篇文章正文内容&#xff0c;下面案例可供参考 一、…

美团收银餐饮版培训教程

硬件连接方式及介绍: 双屏收银机 收银一体机 双屏收银机连接图 收银一体机连接图 前台打印机 后厨打印机 标签打印机 前台打印机连接图 后厨打印机连接图 其它收银机配件 软件前期设置 1、机器联网 点开桌面的设置&#xff0c;点击更多&#xff0c;点击以太网&#xff0c;最上…

SpringBoot之文件上传

1、文件上传原理&#x1f618; 表单的enctype 属性规定在发送到服务器之前应该如何对表单数据进行编码。 当表单的enctype"application/x-www-form-urlencoded"&#xff08;默认&#xff09;时&#xff0c;form表单中的数据格式为&#xff1a;keyvalue&keyvalue …

《Linux高性能服务器编程》笔记03

Linux高性能服务器编程 本文是读书笔记&#xff0c;如有侵权&#xff0c;请联系删除。 参考 Linux高性能服务器编程源码: https://github.com/raichen/LinuxServerCodes 豆瓣: Linux高性能服务器编程 文章目录 Linux高性能服务器编程第07章 Linux服务器程序规范7.1日志7.2用…

把Mybatis Generator生成的代码加上想要的注释

1 前言 在日常开发工作中&#xff0c;我们经常用Mybatis Generator根据表结构生成对应的实体类和Mapper文件。但是Mybatis Generator默认生成的代码中&#xff0c;注释并不是我们想要的&#xff0c;所以一般在Generator配置文件中&#xff0c;会设置不自动生成注释。带来的问题…

【数据结构】在链队列中你可能忽视的二三事

链队列及其基本操作的C语言实现 导言一、链队列二、链队列的基本操作的实现2.1 链队列的数据类型2.2 链队列的初始化2.2.1 带头结点的链队列的初始化2.2.3 不带头结点的链队列的初始化 2.3 链队列的判空2.3.1 带头结点的链队列的判空2.3.2 不带头结点的链队列的判空 2.4 链队列…

IS-IS:01 ISIS基本配置

这是实验拓扑&#xff0c;下面是基本配置&#xff1a; R1: sys sysname R1 user-interface console 0 idle-timeout 0 0 int loop 0 ip add 1.1.1.1 24 int g0/0/0 ip add 192.168.12.1 24 qR2: sys sysname R2 user-interface console 0 idle-timeout 0 0 int loop 0 ip add …

samba服务搭建,并将共享目录映射到windows

系统版本&#xff1a;centos7 1、centos 安装samba yum -y install samba 2、查看安装信息 rpm -qa |grep samba 3、设置开机自启动 systemctl enable smb.service systemctl enable nmb.service 4、设置samba服务器配置文件 sudo vi /etc/samba/smb.conf 注意&#…