危险

为之则易,不为则难

0%

07_商品分类

今日目标

✔ 掌握商品一级和二级分类的处理。

面包屑封装

  1. 拷贝面包屑组件 Bread 和 BreadItem。

src/components/bread/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<script lang="ts" setup name="XtxBread">
// 分隔符数据是位于Bread组件中 而对于分隔符数据的使用是在底层的组件中使用
// provide/inject
import { provide } from 'vue'

const props = defineProps({
separator: {
type: String,
default: '',
},
})

// 为底层组件提供数据
provide('separator', props.separator)
</script>
<template>
<div class="xtx-bread">
<slot />
</div>
</template>
<style scoped lang="less">
.xtx-bread {
display: flex;
padding: 25px 10px;
&-item {
a {
color: #666;
transition: all 0.4s;
&:hover {
color: @xtxColor;
}
}
}
i {
font-size: 12px;
margin-left: 5px;
margin-right: 5px;
line-height: 22px;
}
}
</style>

src/components/bread/item.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script lang="ts" setup name="XtxBreadItem">
import { inject } from 'vue'

defineProps({
to: {
type: String,
},
})

const separator = inject('separator')
</script>
<template>
<div class="xtx-bread-item">
<!--
如果to存在 有值 我们就渲染一个router-link标签
如果to不存在 那就渲染一个span标签
-->
<router-link v-if="to" :to="to"><slot /></router-link>
<span v-else><slot /></span>
<!-- 分隔符 -->
<i v-if="separator">{{ separator }}</i>
<i v-else class="iconfont icon-angle-right"></i>
</div>
</template>

<style lang="less" scoped>
.xtx-bread-item {
i {
margin: 0 6px;
font-size: 10px;
}
// 最后一个i隐藏
&:nth-last-of-type(1) {
i {
display: none;
}
}
}
</style>
  1. 注册成全局组件应用。
1
2
3
4
5
6
7
8
9
import Bread from './bread/index.vue'
import BreadItem from './bread/item.vue'

export default {
install(app) {
app.component('XtxBread', Bread)
app.component('XtxBreadItem', BreadItem)
},
}
  1. 提供类型声明,globale.d.ts
1
2
3
4
5
6
7
8
import XtxBread from '@/components/bread/index.vue'
import XtxBreadItem from '@/components/bread/item.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxBread: typeof XtxBread
XtxBreadItem: typeof XtxBreadItem
}
}
  1. 测试,views/playground/index.vue
1
2
3
4
5
6
<template>
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem>美食</XtxBreadItem>
</XtxBread>
</template>

顶级分类

获取面包屑数据并渲染

  1. types/data.d.ts 中定义分类的数据类型。
1
2
3
4
5
6
export type TopCategory = {
id: string
name: string
picture: string
children: CategoryItem[]
}
  1. 准备数据 store/modules/category.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineStore } from 'pinia'
import request from '@/utils/request'
import { ApiRes, CategoryItem, TopCategory } from '@/types/data'
import { topCategory } from '../constants'
const defaultCategory = topCategory.map((item) => {
return {
name: item,
}
})
export default defineStore('category', {
state: () => ({
topCategory: {} as TopCategory,
}),
actions: {
async getTopCategory(id: string) {
const res = await request.get<ApiRes<TopCategory>>('/category', {
params: {
id,
},
})
this.topCategory = res.data.result
},
},
})
  1. 组件中渲染,views/category/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<script lang="ts" setup name="TopCategory">
import useStore from '@/store'
import { useRoute } from 'vue-router'
const { category } = useStore()
const route = useRoute()
category.getTopCategory(route.params.id as string)
</script>

<template>
<div class="top-category">
<div class="container">
<!-- 渲染面包屑导航 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem>{{category.topCategory.name}}</XtxBreadItem>
</XtxBread>
</div>
</div>
</template>

监听路由变化

  • 问题:切换分类的时候,商品的数据不会发生变化。

  • 原因:由于共用了同一个路由组件,setup 的代码只会执行一次。

  • 解决:可以通过监听路由的变化来发请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { watch } from 'vue'
import useStore from '@/store'
import { useRoute } from 'vue-router'
const { category } = useStore()
const route = useRoute()
watch(
() => route.params.id,
(newValue) => {
category.getTopCategory(newValue as string)
},
{
immediate: true,
}
)
  • 报错:从分类页跳转到首页会报错,需要判断路由地址。
1
2
3
4
5
6
7
8
9
10
watch(
() => route.params.id,
(newValue) => {
if (!newValue) return
category.getTopCategory(newValue as string)
},
{
immediate: true,
}
)
  • 报错:先点击一级类目,再点击其分类下的二级类目。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
watch(
() => route.params.id,
(newValue) => {
if (!newValue) return
// 解决
if (route.fullPath !== `/category/${newValue}`) return
category.getTopCategory(newValue as string)

/* if (route.fullPath === `/category/${newValue}`) {
category.getTopCategory(newValue)
} */
},
{
immediate: true,
}
)

watchEffect 的使用

使用 watchEffect 监听路由的变化,参考文档:https://staging-cn.vuejs.org/guide/essentials/watchers.html#watcheffect。

1
2
3
4
5
6
7
watchEffect(() => {
// 只有是一级分类的情况下,才发送这个请求
const id = route.params.id as string
if (route.fullPath === `/category/${id}`) {
category.getTopCategory(id)
}
})

watch vs watchEffect,相同点:都给我们提供了创建副作用的能力;不同点:追踪响应式依赖的方式。

  • watch 只跟踪明确监视的源,回调仅会在源确实改变了才会被触发,这让我们对如何触发回调有更多的控制权。

  • watchEffect 则将依赖追踪和副作用耦合,会自动追踪依赖的变化,一般来说代码会更简洁,但其响应性依赖关系则不那么显式。

渲染轮播图

  1. 获取轮播图数据并渲染,views/category/index.vue
1
2
3
4
5
6
<script lang="ts" setup name="TopCategory">
import useStore from '@/store'
// 注意这儿使用的还是首页轮播图中的数据。
const { category, home } = useStore()
home.getBannerList()
</script>
1
2
3
<XtxBread></XtxBread>
<!-- 轮播图 -->
<XtxCarousel :slides="home.bannerList" style="height: 500px" auto-play />

二级分类渲染

  1. 基本结构和样式,views/category/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
<XtxCarousel></XtxCarousel>
<!-- 所有二级分类 -->
<div class="sub-list">
<h3>全部分类</h3>
<ul>
<li v-for="i in 8" :key="i">
<a href="javascript:;">
<img src="https://yanxuan.nosdn.127.net/3102b963e7a3c74b9d2ae90e4380da65.png?quality=95&imageView" />
<p>空调</p>
</a>
</li>
</ul>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<style scoped lang="less">
.top-category {
h3 {
font-size: 28px;
color: #666;
font-weight: normal;
text-align: center;
line-height: 100px;
}
.sub-list {
margin-top: 20px;
background-color: #fff;
ul {
display: flex;
padding: 0 32px;
flex-wrap: wrap;
li {
width: 168px;
height: 160px;
a {
text-align: center;
display: block;
font-size: 16px;
img {
width: 100px;
height: 100px;
}
p {
line-height: 40px;
}
&:hover {
color: @xtxColor;
}
}
}
}
}
}
</style>
  1. 动态渲染数据,views/category/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script lang="ts" name="TopCategory" setup>
const { topCategory } = storeToRefs(category)
</script>

<!-- 所有二级分类 -->
<div class="sub-list">
<h3>全部分类</h3>
<ul>
<li v-for="item in topCategory.children" :key="item.id">
<RouterLink :to="`/category/sub/${item.id}`">
<img :src="item.picture" />
<p>{{ item.name }}</p>
</RouterLink>
</li>
</ul>
</div>

商品展示

基本结构

  1. 封装商品信息组件 src/views/category/components/goods-item.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<RouterLink to="/" class="goods-item">
<img src="https://yanxuan-item.nosdn.127.net/46898c7fa475dbfc89dac2f7e7c2c16f.jpg" alt="" />
<p class="name ellipsis">红功夫 麻辣小龙虾 19.99/500g 实惠到家</p>
<p class="desc ellipsis">火锅食材</p>
<p class="price">&yen;19.99</p>
</RouterLink>
</template>

<script lang="ts" setup name="GoodsItem"></script>

<style scoped lang="less">
.goods-item {
display: block;
width: 220px;
padding: 20px 30px;
text-align: center;
.hoverShadow();
img {
width: 160px;
height: 160px;
}
p {
padding-top: 10px;
}
.name {
font-size: 16px;
}
.desc {
color: #999;
height: 29px;
}
.price {
color: @priceColor;
font-size: 20px;
}
}
</style>
  1. 导入 goodsItem 组件并渲染,src/views/category/index.vue
1
2
3
<script lang="ts" setup name="TopCategory">
import GoodsItem from './components/goods-item.vue'
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 对应二级分类 -->
<div class="sub-list"></div>
<!-- 分类关联商品 -->
<div class="ref-goods">
<div class="head">
<h3>- 海鲜 -</h3>
<p class="tag">温暖柔软,品质之选</p>
<XtxMore />
</div>
<div class="body">
<GoodsItem v-for="i in 5" :key="i" />
</div>
</div>
  1. 样式处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.ref-goods {
background-color: #fff;
margin-top: 20px;
position: relative;
.head {
.xtx-more {
position: absolute;
top: 20px;
right: 20px;
}
.tag {
text-align: center;
color: #999;
font-size: 20px;
position: relative;
top: -20px;
}
}
.body {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
padding: 0 65px 30px;
}
}

动态渲染

  1. src/views/category/index.vue
1
2
3
4
5
6
7
8
9
10
11
<!-- 分类关联商品 -->
<div class="ref-goods" v-for="item in category.topCategory.children">
<div class="head">
<h3>- {{ item.name }} -</h3>
<p class="tag">温暖柔软,品质之选</p>
<XtxMore />
</div>
<div class="body">
<GoodsItem v-for="goods in item.goods" :key="goods.id" :goods="goods" />
</div>
</div>
  1. src/views/category/components/goods-item.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script lang="ts" setup name="GoodsItem">
import { GoodItem } from '@/types/data' // orderNum?:
import { PropType } from 'vue'
// defineProps<{ goods: GoodItem }>()
defineProps({
goods: {
type: Object as PropType<GoodItem>,
default: () => ({}),
},
})
</script>

<template>
<RouterLink to="/" class="goods-item">
<img v-lazy="goods.picture" alt="" />
<p class="name ellipsis">{{ goods.name }}</p>
<p class="desc ellipsis">{{ goods.desc }}</p>
<p class="price">&yen;{{ goods.price }}</p>
</RouterLink>
</template>

处理跳转

  1. 在路由切换的时候,需要滚动到页面最顶部。

文档router/index.ts

1
2
3
4
5
6
7
8
const router = createRouter({
history: createWebHashHistory(),
scrollBehavior: () => {
return {
top: 0,
}
},
})
  1. 鼠标滑过一级类目母婴,再移动到二级类目连体衣/礼盒,滚动窗口,点击二级类名连体衣/礼盒,发现二级类名不会被隐藏。

views/layout/components/app-header-nav.vue

1
2
3
<li class="home"><RouterLink to="/">首页</RouterLink></li>
<!-- 之前的 @mouseenter 改成了 @mousemove -->
<li @mousemove="show(item)"></li>

二级类目

展示面包屑

  1. 基本结构 src/views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script lang="ts" setup name="SubCategory"></script>

<template>
<div class="sub-category">
<div class="container">
<!-- 面包屑 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem to="/">居家</XtxBreadItem>
<XtxBreadItem>水壶</XtxBreadItem>
</XtxBread>
</div>
</div>
</template>
  1. 定义二级分类的数据类型,types/data.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export type SaleProperty = {
id: string
name: string
properties: {
id: string
name: string
}[]
}

export type SubCategory = {
id: string
name: string
picture?: any
parentId: string
parentName: string
brands: {
id: string
name: string
nameEn: string
logo: string
picture: string
type?: any
desc: string
place: string
}[]
saleProperties: SaleProperty[]
}
  1. 准备二级分类的数据,store/modules/category.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const useCategoryStore = defineStore('category', {
state: () => ({
subCategory: {} as SubCategory,
}),
actions: {
async getSubFilter(id: string) {
const res = await request.get<ApiRes<SubCategory>>('/category/sub/filter', {
params: {
id,
},
})
this.subCategory = res.data.result
},
},
})
  1. 渲染面包屑,views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script lang="ts" setup name="SubCategory">
import useStore from '@/store'
import { watchEffect } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const { category } = useStore()
watchEffect(() => {
const id = route.params.id as string
if (route.fullPath !== `/category/sub/${id}`) return
category.getSubFilter(id)
})
</script>

<template>
<div class="sub-category">
<div class="container">
<!-- 面包屑 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem :to="`/category/${category.subCategory.parentId}`"> {{ category.subCategory.parentName }} </XtxBreadItem>
<XtxBreadItem>{{ category.subCategory.name }}</XtxBreadItem>
</XtxBread>
</div>
</div>
</template>

筛选区展示

  1. 基础布局和样式,src/views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
<XtxBread></XtxBread>
<!-- 筛选区 -->
<div class="sub-filter">
<div class="item" v-for="i in 4" :key="i">
<div class="head">品牌:</div>
<div class="body">
<a href="javascript:;">全部</a>
<a href="javascript:;" v-for="i in 4" :key="i">小米</a>
</div>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<style scoped lang="less">
// 筛选区
.sub-filter {
background: #fff;
padding: 25px;
.item {
display: flex;
line-height: 40px;
.head {
width: 80px;
color: #999;
}
.body {
flex: 1;
a {
margin-right: 36px;
transition: all 0.3s;
display: inline-block;
&.active,
&:hover {
color: @xtxColor;
}
}
}
}
}
</style>
  1. 渲染真实数据,views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 筛选区 -->
<div class="sub-filter">
<div class="item">
<!-- 品牌独立渲染 -->
<div class="head">品牌:</div>
<div class="body">
<a href="javascript:;">全部</a>
<a href="javascript:;" v-for="item in category.subCategory.brands" :key="item.id"> {{ item.name }} </a>
</div>
</div>
<div class="item" v-for="item in category.subCategory.saleProperties" :key="item.id">
<div class="head">{{ item.name }}:</div>
<div class="body">
<a href="javascript:;">全部</a>
<a href="javascript:;" v-for="sub in item.properties" :key="sub.id">{{ sub.name }}</a>
</div>
</div>
</div>

商品列表结构

  1. 基本结构 src/views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script lang="ts" setup name="SubCategory">
import GoodsItem from './components/goods-item.vue'
</script>
<template>
<!-- 商品区域 -->
<div class="goods-list">
<!-- 排序区域 -->
<div class="sub-sort">
<div class="sort">
<a href="javascript:;" class="active">默认排序</a>
<a href="javascript:;">最新商品</a>
<a href="javascript:;">最高人气</a>
<a href="javascript:;">评论最多</a>
<a href="javascript:;">
价格排序
<i class="arrow up" />
<i class="arrow down" />
</a>
</div>
</div>
<!-- 商品列表 -->
<ul>
<li v-for="goods in 20" :key="goods">
<GoodsItem />
</li>
</ul>
</div>
</template>
  1. 样式,src/views/category/sub.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
.goods-list {
background: #fff;
padding: 0 25px;
margin-top: 25px;
ul {
display: flex;
flex-wrap: wrap;
padding: 0 5px;
li {
margin-right: 20px;
margin-bottom: 20px;
&:nth-child(5n) {
margin-right: 0;
}
}
}
.sub-sort {
height: 80px;
display: flex;
align-items: center;
justify-content: space-between;
.sort {
display: flex;
a {
height: 30px;
line-height: 28px;
border: 1px solid #e4e4e4;
padding: 0 20px;
margin-right: 20px;
color: #999;
border-radius: 2px;
position: relative;
transition: all 0.3s;
&.active {
background: @xtxColor;
border-color: @xtxColor;
color: #fff;
}
.arrow {
position: absolute;
border: 5px solid transparent;
right: 8px;
&.up {
top: 3px;
border-bottom-color: #bbb;
&.active {
border-bottom-color: @xtxColor;
}
}
&.down {
top: 15px;
border-top-color: #bbb;
&.active {
border-top-color: @xtxColor;
}
}
}
}
}
.check {
.xtx-checkbox {
margin-left: 20px;
color: #999;
}
}
}
}

商品基本渲染

types/data.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export type SubCategory = {
id: string
name: string
picture?: any
parentId: string
parentName: string
brands: {
id: string
name: string
nameEn: string
logo: string
picture: string
type?: any
desc: string
place: string
}[]
saleProperties: SaleProperty[]
goods: GoodItem[] // 多加了这个
}

views/category/sub.vue

1
2
3
4
5
6
<!-- 商品列表 -->
<ul>
<li v-for="goods in category.subCategory.goods" :key="goods.id">
<GoodsItem :goods="goods" />
</li>
</ul>