前端学习笔记 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.vueLayoutHeader.vueLayoutFooter.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.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">&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 = 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小时热榜

新建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 * 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控件

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)
})

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.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

很多接口都要求通过报文头传递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 失效

长时间不操作会导致 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>&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) {
            // 购物车中已经存在相同的 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>

参考资料

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值