危险

为之则易,不为则难

0%

08_商品详情

今日目标

✔ 商品详情。

路由配置

  1. 新建页面组件,src/views/goods/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
42
43
44
45
46
47
48
49
50
51
52
<template>
<div class="xtx-goods-page">
<div class="container">
<!-- 面包屑 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem to="/">手机</XtxBreadItem>
<XtxBreadItem to="/">华为</XtxBreadItem>
<XtxBreadItem to="/">p30</XtxBreadItem>
</XtxBread>
<!-- 商品信息 -->
<div class="goods-info"></div>
<!-- 商品详情 -->
<div class="goods-footer">
<div class="goods-article">
<!-- 商品+评价 -->
<div class="goods-tabs"></div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"></div>
</div>
</div>
</div>
</template>

<style scoped lang="less">
.goods-info {
min-height: 600px;
background: #fff;
}
.goods-footer {
display: flex;
margin-top: 20px;
.goods-article {
width: 940px;
margin-right: 20px;
}
.goods-aside {
width: 280px;
min-height: 1000px;
}
}
.goods-tabs {
min-height: 600px;
background: #fff;
}
.goods-warn {
min-height: 600px;
background: #fff;
margin-top: 20px;
}
</style>
  1. 路由配置,src/router/index.js
1
2
3
4
5
6
7
8
9
10
11
12
routes: [
{
path: '/',
component: Layout,
children: [
{
path: '/goods/:id',
component: () => import('@/views/goods/index.vue'),
},
],
},
],
  1. 修改跳转路径,views/category/components/goods-item.vue
1
2
3
4
5
6
7
8
<template>
<RouterLink :to="`/goods/${goods.id}`" 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. 定义商品的数据类型,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
// 商品模块的类型声明
export type GoodsInfo = {
id: string
name: string
spuCode: string
desc: string
price: string
oldPrice: string
discount: number
inventory: number
salesCount: number
commentCount: number
collectCount: number
mainVideos: any[]
videoScale: number
mainPictures: string[]
isPreSale: boolean
isCollect?: any
recommends?: any
userAddresses?: any
evaluationInfo?: any
categories: {
id: string
name: string
}[]
}
  1. 新建文件,store/modules/goods.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ApiRes } from '@/types/data'
import { GoodsInfo } from '@/types/data'
import request from '@/utils/request'
import { defineStore } from 'pinia'

export default defineStore('goods', {
state: () => ({
// 商品详细信息
info: {} as GoodsInfo,
}),
actions: {
async getGoodsInfo(id: string) {
const res = await request.get<ApiRes<GoodsInfo>>('/goods', {
params: {
id,
},
})
this.info = res.data.result
},
},
})
  1. 引入 good 模块,store/index.ts
1
2
3
4
5
6
7
8
9
10
11
12
import useCategoryStore from './modules/category'
import useHomeStore from './modules/home'
import useGoodsStore from './modules/goods'
const useStore = () => {
return {
category: useCategoryStore(),
home: useHomeStore(),
goods: useGoodsStore(),
}
}

export default useStore
  1. 在组件中获取数据并且渲染,views/goods/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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<script lang="ts" setup name="Goods">
import useStore from '@/store'
import { storeToRefs } from 'pinia'
import { watchEffect } from 'vue'
import { useRoute } from 'vue-router'
const { goods } = useStore()
const route = useRoute()
watchEffect(() => {
const id = route.params.id as string
if (route.fullPath === `/goods/${id}`) {
goods.getGoodsInfo(id)
}
})

const { info } = storeToRefs(goods)
</script>

<template>
<div class="xtx-goods-page">
<div class="container">
<!-- 渲染 -->
<div v-if="info.categories">
<!-- 面包屑 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
<XtxBreadItem :to="`/category/${info.categories[1].id}`"> {{ info.categories[1].name }} </XtxBreadItem>
<XtxBreadItem :to="`/category/sub/${info.categories[0].id}`"> {{ info.categories[0].name }} </XtxBreadItem>
<XtxBreadItem>{{ info.name }}</XtxBreadItem>
</XtxBread>
<!-- 商品信息 -->
<div class="goods-info"></div>
<!-- 商品详情 -->
<div class="goods-footer">
<div class="goods-article">
<!-- 商品+评价 -->
<div class="goods-tabs"></div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"></div>
</div>
</div>

<!-- 占位的 -->
<div v-else>
<!-- 面包屑 -->
<XtxBread>
<XtxBreadItem to="/">首页</XtxBreadItem>
</XtxBread>
<!-- 商品信息 -->
<div class="goods-info"></div>
<!-- 商品详情 -->
<div class="goods-footer">
<div class="goods-article">
<!-- 商品+评价 -->
<div class="goods-tabs"></div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"></div>
</div>
</div>
</div>
</div>
</template>

注意:由于发送请求需要时间,在刷新页面的时候,会发现渲染报错,因为数据还没有获取到,所以需要加 v-if 进行判断,保证数据加载完成后才渲染;当商品详情页跳转到分类页,或者分类页跳转到商品详情页时会报错,此时需要在 watchEffect 中进行额外的判断。

商品图片

  1. 新建组件 src/views/goods/components/goods-image.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="GoodsImage"></script>
<template>
<div class="goods-image">
<div class="middle">
<img src="https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png" alt="" />
</div>
<ul class="small">
<li v-for="item in 5" :key="item" :class="{ active: item === 1 }">
<img src="https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png" alt="" />
</li>
</ul>
</div>
</template>

<style scoped lang="less">
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;
.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
}
.small {
width: 80px;
li {
width: 68px;
height: 68px;
margin-left: 12px;
margin-bottom: 15px;
cursor: pointer;
&:hover,
&.active {
border: 2px solid @xtxColor;
}
}
}
}
</style>
  1. 渲染商品图片组件,src/views/goods/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
<!-- 商品信息 -->
<div class="goods-info">
<div class="media">
<GoodsImage></GoodsImage>
</div>
<div class="spec"></div>
</div>

<style scoped lang="less">
.goods-info {
min-height: 600px;
background: #fff;
display: flex;
.media {
width: 580px;
height: 600px;
padding: 30px 50px;
}
.spec {
flex: 1;
padding: 30px 30px 30px 0;
}
}
</style>
  1. 数据展示,src/views/goods/index.vue
1
<GoodsImage :images="info.mainPictures"></GoodsImage>

views/goods/components/goods-image.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script lang="ts" setup name="GoodsImage">
import { ref } from 'vue'

defineProps<{
images: string[]
}>()
// 默认高亮的下标
const active = ref(0)
</script>
<template>
<div class="goods-image">
<div class="middle">
<img :src="images[active]" alt="" />
</div>
<ul class="small">
<li v-for="(item, index) in images" :key="item" :class="{ active: index === active }" @mouseenter="active = index">
<img :src="item" alt="" />
</li>
</ul>
</div>
</template>

放大镜效果

基本结构

  1. 准备大图片容器,src/views/goods/components/goods-image.vue
1
2
3
4
5
6
<div class="goods-image">
<div class="large" :style="[{ backgroundImage: `url(${images[active]})` }]"></div>
<div class="middle">
<img :src="images[active]" alt="" />
</div>
</div>
  1. 提供大图片容器的样式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;
z-index: 500;
.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
background-size: 800px 800px;
background-color: #f8f8f8;
}
}
  1. 准备移动的遮罩层。
1
2
3
4
<div class="middle" ref="target">
<img :src="images[active]" alt="" />
<div class="layer"></div>
</div>
  1. 给遮罩层添加样式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.middle {
width: 400px;
height: 400px;
position: relative;
cursor: move;
.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
left: 0;
top: 0;
position: absolute;
}
}

控制显示隐藏

  1. 使用 useMouseInElement 获取鼠标在元素中的信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
<script lang="ts" setup name="GoodsImage">
import { useMouseInElement } from '@vueuse/core'
import { ref } from 'vue'

defineProps<{
images: string[]
}>()

const current = ref(0)

const target = ref(null)
const { isOutside, elementX, elementY } = useMouseInElement(target)
</script>
  1. 通过 v-show 配合 isOutSide 控制大图和遮罩的显示与否。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div class="goods-image">
<!-- 大图片容器 -->
<div class="large" :style="[{ backgroundImage: `url(${images[active]})` }]" v-show="!isOutside"></div>
<div class="middle" ref="target">
<img :src="images[active]" alt="" />
<!-- 遮罩层 -->
<div class="layer" v-show="!isOutside"></div>
</div>
<ul class="small">
<li v-for="(item, index) in images" :key="index" :class="{ active: index === active }">
<img :src="images[index]" @mouseenter="active = index" alt="" />
</li>
</ul>
</div>
</template>

控制移动

  1. 计算遮罩和大图的坐标。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts" setup name="GoodsImage">
import { computed, ref } from 'vue'
import { useMouseInElement } from '@vueuse/core'
defineProps<{
images: string[]
}>()
const active = ref(0)
const target = ref(null)
const { isOutside, elementX, elementY } = useMouseInElement(target)

const position = computed(() => {
let x = elementX.value - 100
let y = elementY.value - 100
if (x <= 0) x = 0
if (x >= 200) x = 200
if (y <= 0) y = 0
if (y >= 200) y = 200
return {
x,
y,
}
})
</script>
  1. 渲染坐标到对应的元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
<div class="goods-image">
<div
class="large"
:style="[
{
backgroundImage: `url(${images[current]})`,
backgroundPosition: `${-position.x * 2}px ${-position.y * 2}px`,
},
]"
v-show="!isOutside"
></div>
<div class="middle" ref="target">
<img :src="images[current]" alt="" />
<div class="layer" v-show="!isOutside" :style="{ left: position.x + 'px', top: position.y + 'px' }"></div>
</div>
<ul class="small">
<li v-for="(item, index) in images" :key="index" :class="{ active: index === current }">
<img :src="images[index]" @mouseenter="current = index" alt="" />
</li>
</ul>
</div>
</template>

商品基本信息

底部销量信息

src/views/goods/components/goods-sales.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
68
69
70
<script lang="ts" setup name="GoodsSale"></script>

<template>
<ul class="goods-sales">
<li>
<p>销量人气</p>
<p>200+</p>
<p><i class="iconfont icon-task-filling"></i>销量人气</p>
</li>
<li>
<p>商品评价</p>
<p>400+</p>
<p><i class="iconfont icon-comment-filling"></i>查看评价</p>
</li>
<li>
<p>收藏人气</p>
<p>600+</p>
<p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
</li>
<li>
<p>品牌信息</p>
<p>苏宁电器</p>
<p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
</li>
</ul>
</template>

<style scoped lang="less">
.goods-sales {
display: flex;
width: 400px;
align-items: center;
text-align: center;
height: 140px;
li {
flex: 1;
position: relative;
~ li::after {
position: absolute;
top: 10px;
left: 0;
height: 60px;
border-left: 1px solid #e4e4e4;
content: '';
}
p {
&:first-child {
color: #999;
}
&:nth-child(2) {
color: @priceColor;
margin-top: 10px;
}
&:last-child {
color: #666;
margin-top: 10px;
i {
color: @xtxColor;
font-size: 14px;
margin-right: 2px;
}
&:hover {
color: @xtxColor;
cursor: pointer;
}
}
}
}
}
</style>

右侧商品信息

src/views/goods/components/goods-info.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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<template>
<p class="g-name">2件装 粉釉花瓣心意点缀 点心盘*2 碟子盘子</p>
<p class="g-desc">花瓣造型干净简约 多功能使用堆叠方便</p>
<p class="g-price">
<span>108.00</span>
<span>199.00</span>
</p>
<div class="g-service">
<dl>
<dt>促销</dt>
<dd>12月好物放送,App领券购买直降120元</dd>
</dl>
<dl>
<dt>配送</dt>
<dd></dd>
</dl>
<dl>
<dt>服务</dt>
<dd>
<span>无忧退货</span>
<span>快速退款</span>
<span>免费包邮</span>
<a href="javascript:;">了解详情</a>
</dd>
</dl>
</div>
</template>

<style lang="less" scoped>
.g-name {
font-size: 22px;
}
.g-desc {
color: #999;
margin-top: 10px;
}
.g-price {
margin-top: 10px;
span {
&::before {
content: '¥';
font-size: 14px;
}
&:first-child {
color: @priceColor;
margin-right: 10px;
font-size: 22px;
}
&:last-child {
color: #999;
text-decoration: line-through;
font-size: 16px;
}
}
}
.g-service {
background: #f5f5f5;
width: 500px;
padding: 20px 10px 0 10px;
margin-top: 10px;
dl {
padding-bottom: 20px;
display: flex;
align-items: center;
dt {
width: 50px;
color: #999;
}
dd {
color: #666;
&:last-child {
span {
margin-right: 10px;
&::before {
content: '•';
color: @xtxColor;
margin-right: 2px;
}
}
a {
color: @xtxColor;
}
}
}
}
}
</style>

引入组件并渲染

src/views/goods/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
import GoodsSale from './components/goods-sale.vue'
import GoodsInfo from './components/goods-info.vue'

<!-- 商品信息 -->
<div class="goods-info">
<div class="media">
<GoodsImage :images="info.mainPictures"/>
<GoodsSale />
</div>
<div class="spec">
<GoodsInfo :goods="info" />
</div>
</div>

src/views/goods/components/goods-info.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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<script lang="ts" setup name="GoodInfo">
import { GoodsInfo } from '@/types/data'

defineProps<{
goods: GoodsInfo
}>()
</script>
<template>
<p class="g-name">{{ goods.name }}</p>
<p class="g-desc">{{ goods.desc }}</p>
<p class="g-price">
<span>{{ goods.price }}</span>
<span>{{ goods.oldPrice }}</span>
</p>
<div class="g-service">
<dl>
<dt>促销</dt>
<dd>12月好物放送,App领券购买直降120元</dd>
</dl>
<dl>
<dt>配送</dt>
<dd></dd>
</dl>
<dl>
<dt>服务</dt>
<dd>
<span>无忧退货</span>
<span>快速退款</span>
<span>免费包邮</span>
<a href="javascript:;">了解详情</a>
</dd>
</dl>
</div>
</template>

<style lang="less" scoped>
.g-name {
font-size: 22px;
}
.g-desc {
color: #999;
margin-top: 10px;
}
.g-price {
margin-top: 10px;
span {
&::before {
content: '¥';
font-size: 14px;
}
&:first-child {
color: @priceColor;
margin-right: 10px;
font-size: 22px;
}
&:last-child {
color: #999;
text-decoration: line-through;
font-size: 16px;
}
}
}
.g-service {
background: #f5f5f5;
width: 500px;
padding: 20px 10px 0 10px;
margin-top: 10px;
dl {
padding-bottom: 20px;
display: flex;
align-items: center;
dt {
width: 50px;
color: #999;
}
dd {
color: #666;
&:last-child {
span {
margin-right: 10px;
&::before {
content: '•';
color: @xtxColor;
margin-right: 2px;
}
}
a {
color: @xtxColor;
}
}
}
}
}
</style>

城市选择

基本功能

  1. 封装通用组件 src/components/city/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
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
<script lang="ts" setup name="XtxCity">
// 注意这儿一定不能为空,否则组件注册不成功,坑。
</script>
<template>
<div class="xtx-city">
<div class="select">
<span class="placeholder">请选择配送地址</span>
<span class="value"></span>
<i class="iconfont icon-angle-down"></i>
</div>
<div class="option">
<span class="ellipsis" v-for="i in 24" :key="i">北京市</span>
</div>
</div>
</template>

<style scoped lang="less">
.xtx-city {
display: inline-block;
position: relative;
z-index: 400;
.select {
border: 1px solid #e4e4e4;
height: 30px;
padding: 0 5px;
line-height: 28px;
cursor: pointer;
&.active {
background: #fff;
}
.placeholder {
color: #999;
}
.value {
color: #666;
font-size: 12px;
}
i {
font-size: 12px;
margin-left: 5px;
}
}
.option {
width: 542px;
border: 1px solid #e4e4e4;
position: absolute;
left: 0;
top: 29px;
background: #fff;
min-height: 30px;
line-height: 30px;
display: flex;
flex-wrap: wrap;
padding: 10px;
> span {
width: 130px;
text-align: center;
cursor: pointer;
border-radius: 4px;
padding: 0 3px;
&:hover {
background: #f5f5f5;
}
}
}
}
</style>
  1. 全局注册,components/index.ts
1
2
3
4
5
6
import XtxCity from '@/components/city/index.vue'
export default {
install(app: App) {
app.component('XtxCity', XtxCity)
},
}
  1. 提供类型,src/global.d.ts
1
2
3
4
5
import XtxCity from '@/components/city/index.vue'
declare module 'vue' {
XtxCity: typeof XtxCity
}
}
  1. 在商品详情组件中渲染 city 组件 src/views/goods/components/goods-info.vue
1
2
3
4
<dl>
<dt>配送</dt>
<dd><XtxCity /></dd>
</dl>
  1. 控制城市的显示和隐藏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script lang="ts" setup name="XtxCity">
import { ref } from 'vue'
// #1
const active = ref(false)
// #2
const toggle = () => {
active.value = !active.value
}
</script>
<template>
<div class="xtx-city">
<!-- #3 -->
<div class="select" @click="toggle" :class="{ active }">
<span class="placeholder">请选择配送地址</span>
<span class="value"></span>
<i class="iconfont icon-angle-down"></i>
</div>
<!-- #4 -->
<div class="option" v-show="active">
<span class="ellipsis" v-for="i in 24" :key="i">北京市</span>
</div>
</div>
</template>
  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
26
27
28
<script lang="ts" setup name="XtxCity">
import { ref } from 'vue'
// #1
import { onClickOutside } from '@vueuse/core'
const active = ref(false)
const toggle = () => {
active.value = !active.value
}
// #2
const target = ref(null)
// #4
onClickOutside(target, () => {
active.value = false
})
</script>
<template>
<!-- #3 -->
<div class="xtx-city" ref="target">
<div class="select" @click="toggle" :class="{ active }">
<span class="placeholder">请选择配送地址</span>
<span class="value"></span>
<i class="iconfont icon-angle-down"></i>
</div>
<div class="option" v-show="active">
<span class="ellipsis" v-for="i in 24" :key="i">北京市</span>
</div>
</div>
</template>

动态渲染

城市数据并不是直接从接口服务器中获取的,而是从阿里云服务器上获取的数据,所以不能使用封装好的 request 发送请求,直接使用 axios 发送请求即可,https://yjy-oss-files.oss-cn-zhangjiakou.aliyuncs.com/tuxian/area.json。

  1. 定义数据类型,types/data.d.ts
1
2
3
4
5
6
7
// 城市列表类型
export type AreaList = {
code: string
level: number
name: string
areaList: AreaList[]
}
  1. 获取数据 src/components/city/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
<script lang="ts" setup name="XtxCity">
import { ref } from 'vue'
import axios from 'axios'
import { onClickOutside } from '@vueuse/core'
// #1
import type { AreaList } from '@/types/data'
const active = ref(false)
const toggle = () => {
active.value = !active.value
}
const target = ref(null)
onClickOutside(target, () => {
active.value = false
})

// #2
const cityList = ref<AreaList[]>([])
const getCityList = async () => {
const res = await axios.get<AreaList[]>('https://yjy-oss-files.oss-cn-zhangjiakou.aliyuncs.com/tuxian/area.json')
cityList.value = res.data
}
getCityList()
</script>
  1. 渲染数据 src/components/City/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div class="xtx-city" ref="target">
<div class="select" :class="{ active: active }" @click="toggle">
<span class="placeholder">请选择配送地址</span>
<span class="value"></span>
<i class="iconfont icon-angle-down"></i>
</div>
<div class="option" v-show="active">
<span class="ellipsis" v-for="item in cityList" :key="item.code"> {{ item.name }} </span>
</div>
</div>
</template>

交互逻辑

点击某个省,显示省下面的市;点击市,显示市下面的县;可以根据 level 判断级别。

  1. 给城市注册点击事件。
1
2
3
<div class="option" v-if="active">
<span class="ellipsis" v-for="item in cityList" :key="item.code" @click="selectCity(item)"> {{ item.name }} </span>
</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
26
27
28
29
30
const changeResult = ref({
provinceCode: '',
provinceName: '',
cityCode: '',
cityName: '',
countyCode: '',
countyName: '',
})

const selectCity = (city: AreaList) => {
if (city.level === 0) {
// 省
changeResult.value.provinceName = city.name
changeResult.value.provinceCode = city.code
cityList.value = city.areaList
}
if (city.level === 1) {
// 市
changeResult.value.cityName = city.name
changeResult.value.cityCode = city.code
cityList.value = city.areaList
}
if (city.level === 2) {
// 县(区)
changeResult.value.countyName = city.name
changeResult.value.countyCode = city.code
// 关闭弹窗
active.value = false
}
}
  1. 关闭时恢复城市数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const cityList = ref<AreaList[]>([])
const cacheList = ref<AreaList[]>([])

function getCityData() {
axios.get<AreaList[]>('https://yjy-oss-files.oss-cn-zhangjiakou.aliyuncs.com/tuxian/area.json').then((res) => {
cityList.value = res.data
cacheList.value = res.data
})
}

// 监听关闭弹窗的处理,恢复数据
watch(active, (value) => {
// 当关闭active的时候,需要回复数据
if (!value) {
cityList.value = cacheList.value
}
})

完整地址

🤔 完整地址需要父组件传递给子组件,将来如果登录的用户,父组件可以获取到完整的地址。

  1. 父组件将城市数据传递给子组件。

views/goods/components/goods-info.vue

1
2
3
4
5
<script lang="ts" setup name="GoodInfo">
const userAddress = ref('河南省 周口市 淮阳县')
</script>

<XtxCity :userAddress="userAddress" />
  1. 子组件接收,并且进行展示。

src/components/city/index.vue

1
2
3
4
5
6
7
8
9
10
11
12
<script lang="ts" setup name="XtxCity">
defineProps<{
userAddress?: string
}>()
</script>
<template>
<div class="select" @click="toggle" :class="{ active }">
<span class="value" v-if="userAddress">{{ userAddress }}</span>
<span class="placeholder" v-else>请选择配送地址</span>
<i class="iconfont icon-angle-down"></i>
</div>
</template>
  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
26
27
28
29
30
31
32
33
34
35
36
37
// 选择的城市结果类型
export type CityResult = {
provinceCode: string
provinceName: string
cityCode: string
cityName: string
countyCode: string
countyName: string
}

const emit = defineEmits<{
(e: 'changeCity', value: CityResult): void
}>()

const selectCity = (city: AreaList) => {
if (city.level === 0) {
// 省
changeResult.value.provinceName = city.name
changeResult.value.provinceCode = city.code
cityList.value = city.areaList
}
if (city.level === 1) {
// 市
changeResult.value.cityName = city.name
changeResult.value.cityCode = city.code
cityList.value = city.areaList
}
if (city.level === 2) {
// 县(区)
changeResult.value.countyName = city.name
changeResult.value.countyCode = city.code
// 关闭弹窗
active.value = false
// 子传父
emit('changeCity', changeResult.value)
}
}
  1. 父组件接受数据并且处理。

views/goods/components/goods-info.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dl>
<dt>配送</dt>
<dd>

<XtxCity :userAddress="userAddress" @changeCity="changeCity" />
</dd>
</dl>

<script>
const userAddress = ref('江西省 九江市 不知道县')
const changeCity = (changeResult: CityResult) => {
userAddress.value = changeResult.provinceName + ' ' + changeResult.cityName + ' ' + changeResult.countyName
}
</script>

商品规格

SPU 和 SKU

  • SPU(Standard Product Unit):标准化产品单元。是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个 SPU。

  • SKU(Stock Keeping Unit)库存量单位,即库存进出计量的单位, 可以是以件、盒、托盘等为单位。SKU 是物理上不可分割的最小存货单元,在使用时要根据不同业态,不同管理模式来处理。

image-20210726205210768

总结一下:SPU 代表一类商品,例如 iPhone6;SKU 代表该商品可选规格的任意组合,是具体的一个商品。

基础结构和样式

image-20210726205245468

  1. 创建组件结构,src/views/goods/components/goods-sku.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
68
69
70
71
72
73
<template>
<div class="goods-sku">
<dl>
<dt>颜色</dt>
<dd>
<img class="selected" src="https://yanxuan-item.nosdn.127.net/d77c1f9347d06565a05e606bd4f949e0.png" alt="" />
<img class="disabled" src="https://yanxuan-item.nosdn.127.net/d77c1f9347d06565a05e606bd4f949e0.png" alt="" />
</dd>
</dl>
<dl>
<dt>尺寸</dt>
<dd>
<span class="disabled">10英寸</span>
<span class="selected">20英寸</span>
<span>30英寸</span>
</dd>
</dl>
<dl>
<dt>版本</dt>
<dd>
<span>美版</span>
<span>港版</span>
</dd>
</dl>
</div>
</template>

<style scoped lang="less">
.sku-state-mixin () {
border: 1px solid #e4e4e4;
margin-right: 10px;
cursor: pointer;
&.selected {
border-color: @xtxColor;
}
&.disabled {
opacity: 0.6;
border-style: dashed;
cursor: not-allowed;
}
}
.goods-sku {
padding-left: 10px;
padding-top: 20px;
dl {
display: flex;
padding-bottom: 20px;
align-items: center;
dt {
width: 50px;
color: #999;
}
dd {
flex: 1;
color: #666;
> img {
width: 50px;
height: 50px;
margin-top: 5px;
.sku-state-mixin ();
}
> span {
display: inline-block;
height: 30px;
line-height: 30px;
padding: 0 20px;
margin-top: 5px;
.sku-state-mixin ();
}
}
}
}
</style>
  1. 使用组件,src/views/goods/index.vue
1
2
3
4
5
6
7
8
9
<script lang="ts" setup name="Goods">
import GoodsSku from './components/goods-sku.vue'
</script>

<div class="spec">
<GoodsInfo :goods="info" />
<!-- 规格组件 -->
<GoodsSku />
</div>

规格组件渲染

测试商品 ID:1369155859933827074。

  1. 定义 specs 类型,types/data.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 商品的规格的值的类型
export type SpecValue = {
desc: string
name: string
picture: string
selected: boolean // 控制选中与否
disabled: boolean // 控制禁用与否
}
// 商品的规格类型
export type Spec = {
name: string
values: SpecValue[]
}
// 商品模块的类型声明
export type GoodsInfo = {
// ...
specs: Spec[] // 新增
}
  1. 父传子,src/views/goods/index.vue
1
2
<!-- 商品规格 -->
<GoodsSku :goods="info" />
  1. 基本渲染,views/goods/components/goods-sku.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts" name="GoodsSku">
import { GoodsInfo } from '@/types/data'

defineProps<{
goods: GoodsInfo
}>()
</script>
<template>
<div class="goods-sku">
<dl v-for="item in goods.specs" :key="item.name">
<dt>{{ item.name }}</dt>
<dd>
<template v-for="sub in item.values" :key="sub.name">
<img v-if="sub.picture" :src="sub.picture" alt="" :title="sub.name" />
<span v-else>{{ sub.name }}</span>
</template>
</dd>
</dl>
</div>
</template>

选中规格功能

  1. 注册点击事件,views/goods/components/goods-sku.vue
1
2
3
4
5
6
<dd>
<template v-for="sub in item.values" :key="sub.name">
<img v-if="sub.picture" :src="sub.picture" alt="" :title="sub.name" @click="changeSelected(item, sub)" />
<span v-else @click="changeSelected(item, sub)">{{ sub.name }}</span>
</template>
</dd>
  1. 逻辑处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts" name="GoodsSku">
import { GoodsInfo, Spec, SpecValue } from '@/types/data'
defineProps<{
goods: GoodsInfo
}>()
const changeSelected = (item: Spec, sub: SpecValue) => {
if (sub.selected) {
// 如果已经是选中了,取消选中
sub.selected = false
} else {
// 把同级所有(包括sub)的全部取消选中
item.values.forEach((v) => (v.selected = false))
// 让sub选中
sub.selected = true
}
}
</script>
  1. 控制选中的类名。
1
2
3
4
<template v-for="sub in item.values" :key="sub.name">
<img v-if="sub.picture" :src="sub.picture" alt="" :title="sub.name" :class="{ selected: sub.selected }" @click="changeSelected(item, sub)" />
<span v-else @click="changeSelected(item, sub)" :class="{ selected: sub.selected }">{{ sub.name }}</span>
</template>

商品 SKU 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 数据中的 SKU 也需要有类型
export type Sku = {
id: string
inventory: number
oldPrice: string
price: string
skuCode: string
specs: {
name: string
valueName: string
}[]
}

// 商品模块的类型声明
export type GoodsInfo = {
// ...
skus: Sku[]
}

禁用思路分析

推荐演示商品:http://localhost:3000/#/goods/1369155859933827074,大致了解禁用效果的整体思路即可,注意只是了解。

image-20210726221134503

  1. 根据后台返回的 skus 数据得到有效 sku 组合(过滤掉库存为 0 的)。

  2. 根据有效的 sku 组合得到所有的子集集合(如何得到幂集)。

  3. 根据子集集合组合成一个路径字典,也就是对象。

  4. 在组件初始化的时候去判断每个规格是否点击。

  5. 在点击规格的时候去判断其他规格是否可点击。

  6. 判断的依据是,拿着说有规格和现在已经选中的规则取搭配,得到可走路径。如果可走路径在字典中,可点击,否则禁用。

得到路径字典

image-20210728171809364

JS 算法库幂集算法

  1. src/utils/power-set.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
28
29
30
31
32
33
34
/**
* Find power-set of a set using BITWISE approach.
*
* @param {*[]} originalSet
* @return {*[][]}
*/
export default function bwPowerSet(originalSet: any[]) {
const subSets = []

// We will have 2^n possible combinations (where n is a length of original set).
// It is because for every element of original set we will decide whether to include
// it or not (2 options for each set element).
const numberOfCombinations = 2 ** originalSet.length

// Each number in binary representation in a range from 0 to 2^n does exactly what we need:
// it shows by its bits (0 or 1) whether to include related element from the set or not.
// For example, for the set {1, 2, 3} the binary number of 0b010 would mean that we need to
// include only "2" to the current set.
for (let combinationIndex = 0; combinationIndex < numberOfCombinations; combinationIndex += 1) {
const subSet = []

for (let setElementIndex = 0; setElementIndex < originalSet.length; setElementIndex += 1) {
// Decide whether we need to include current element into the subset or not.
if (combinationIndex & (1 << setElementIndex)) {
subSet.push(originalSet[setElementIndex])
}
}

// Add current subset to the list of all subsets.
subSets.push(subSet)
}

return subSets
}
  1. 处理路径字典,views/goods/components/goods-sku.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
const spliter = '🤔'
const getPathMap = () => {
const pathMap: { [key: string]: string[] } = {}
// 1. 过滤掉库存为0的sku
const skus = props.goods.skus.filter((item) => item.inventory > 0)
// console.log(skus)
// 2. 遍历有效的sku,获取sku的幂集
skus.forEach((item) => {
const arr = item.specs.map((sub) => sub.valueName)
// console.log(arr)
// 3. 调用powerSet获取幂集
const powerSet = bwPowerSet(arr)
// 4. 把这些powerSet合并到一个路径字典中
powerSet.forEach((sub) => {
const key = sub.join(spliter)
// 5. 判断pathMap中有没有key
if (key in pathMap) {
// 6. 存在
pathMap[key].push(item.id)
} else {
// 7. 不存在
pathMap[key] = [item.id]
}
})
})

return pathMap
}

console.log(getPathMap())

默认禁用按钮

  • 测试商品 id:1379052170040578049 和 1369155859933827074。

  • 目标:在组件初始化的时候,点击规格的时候,去更新其他按钮的禁用状态。

  • 核心思路:遍历所有的规格按钮,判断规格按钮在 pathMap 中是否存在,如果不存在,就要禁用。

image-20220222232708245

  1. 根据 disabled 属性控制按钮是否禁用,views/goods/components/goods-sku.vue
1
2
3
4
<template v-for="sub in item.values" :key="sub.name">
<img v-if="sub.picture" :src="sub.picture" alt="" :title="sub.name" :class="{ selected: sub.selected, disabled: sub.disabled }" @click="changeSelected(item, sub)" />
<span v-else @click="changeSelected(item, sub)" :class="{ selected: sub.selected, disabled: sub.disabled }">{{ sub.name }}</span>
</template>
  1. 封装更新按钮禁用状态的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 更新按钮的禁用状态
const updateDisabledStatus = (specs: Spec[], pathMap: { [key: string]: string[] }) => {
specs.forEach((item) => {
item.values.forEach((sub) => {
if (sub.name in pathMap) {
// 当前规格的名字在 pathMap 存在,不禁用
sub.disabled = false
} else {
// 当前规格在 pathMap 找不到,禁用
sub.disabled = true
}
})
})
}
updateDisabledStatus(props.goods.specs, pathMap)

image-20220222233636856

  1. 禁用的时候不允许选中。
1
2
3
4
5
6
7
8
9
10
11
12
const changeSelected = (item: Spec, sub: SpecValue) => {
if (sub.disabled) return
if (sub.selected) {
// 如果已经是选中了,取消选中
sub.selected = false
} else {
// 把同级所有(包括sub)的全部取消选中
item.values.forEach((v) => (v.selected = false))
// 让sub选中
sub.selected = true
}
}

点击禁用其他

默认进来的禁用已经完成,但是一旦我们选中某个按钮了,需要决定其他按钮是否禁用(中国,日本是否禁用)。

  1. 封装一个方法,获取选中的值。

例如:什么都没选 => ["", "", ""];选中黑色 => ["黑色", "", ""];选中 10cm => ["", "", "10cm"]

1
2
3
4
5
6
7
8
9
// 获取选中的规格
const getSelectedSpec = (specs: Spec[]) => {
const arr: string[] = []
specs.forEach((item) => {
const temp = item.values.find((sub) => sub.selected)
arr.push(temp ? temp.name : '')
})
return arr
}
  1. 修改 updateDisabledStatus 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 控制按钮的禁用状态
const updateDisabledStatus = (specs: Spec[], pathMap: { [key: string]: string[] }) => {
// 循环每一个 btnObj 的时候
// 【尝试】用原来【选中】的数据,和每一个 btnObj 中 name 进行组合,看看能不能在 pathMap 中找到
// 这些操作的目的并不会影响后续的选中状态,而是其他按钮的禁用/启用
specs.forEach((spec, i) => {
spec.values.forEach((btnObj) => {
if (btnObj.selected) return
const originSpecs = getSelectedSpec(specs)
originSpecs[i] = btnObj.name
const key = originSpecs.filter((v) => v).join(spliter)
if (pathMap[key]) {
btnObj.disabled = false
} else {
btnObj.disabled = true
}
/* if (pathMap[btnObj.name]) {
btnObj.disabled = false
} else {
btnObj.disabled = true
} */
})
})
}
  1. 在选择规格的时候,调用 updateDisabledStatus。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const changeSelected = (item: Spec, sub: SpecValue) => {
if (sub.disabled) return
if (sub.selected) {
// 如果已经是选中了,取消选中
sub.selected = false
} else {
// 把同级所有(包括sub)的全部取消选中
item.values.forEach((v) => (v.selected = false))
// 让sub选中
sub.selected = true
}
// mark
updateDisabledStatus(props.goods.specs, pathMap)
}

一个 Bug

进入:http://localhost:3000/#/goods/1379052170040578049。

发现 goods.info.specs[1].values[1].disabled 是 false,没问题。

点击母婴一级类目。

再点击浏览器返回,此时 views/goods/index.vue 中的 info.categories 是有数据的,所以会直接渲染 GoodsSku 组件。

执行 GoodsSku 组件中的代码(setup 和 template),该禁用禁用。

views/goods/index.vue 中会再次发起请求,拿到最新的数据后会覆盖原数据,注意此时会把原来带 disabled 的覆盖掉,可以在 goods/index.vue 的模板中输出 {{ info.specs[1].values[1] }} 证明。

此时数据会再次渲染 goods/index.vue 模板,渲染 goods/components/goods-sku.vue 模板,但是!goods-sku.vue setup 中的逻辑则不会再被执行了。

解决方式 1:保证 goods-sku.vue 重新执行 setup 中的代码。

1
<GoodsSku :goods="info" @changeSku="changeSku" :key="Math.random()" />

解决方式 2:请求新数据前,每次删除 Pinia 中的旧数据,保证 goods-sku.vue 模板中的渲染只执行 1 次(当然这一次肯定也会经过 setup 中的逻辑处理)。

views/goods/index.vue

1
2
3
4
5
6
7
watchEffect(() => {
const id = route.params.id as string
if (route.fullPath === `/goods/${id}`) {
goods.resetGoodsInfo()
goods.getGoodsInfo(id)
}
})

store/modules/goods.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { ApiRes } from '@/types/data'
import { GoodsInfo } from '@/types/data'
import request from '@/utils/request'
import { defineStore } from 'pinia'

export default defineStore('goods', {
state: () => ({
info: {} as GoodsInfo,
}),
actions: {
// ...
resetGoodsInfo() {
this.info = {} as GoodsInfo
},
},
})

证明:goods-sku.vue 组件,setup 走 1 次,模板走 2 次,可以在此组件中通过下面代码证明。

1
2
3
4
5
6
7
<script setup lang="ts" name="GoodsSku">
// 不要用 ref(0) 测试,会死循环
let test = 0
</script>
<template>
<div>{{++test}}</div>
</template>

如果数据是存储在当前组件中的也不会出现这个问题,因为当前组件销毁后数据自然也就销毁了。

处理默认选中

目的:根据传入的 skuId 进行默认选中,选择规格后触发 change 事件传出选择的 sku 数据。

  1. 父组件传入 skuId,views/goods/index.vue
1
<GoodsSku :goods="info" skuId="1369155864430120962" />
  1. 子组件接收 skuId,src/views/goods/components/goods-sku.vue
1
2
3
4
const props = defineProps<{
goods: GoodsInfo
skuId?: String
}>()
  1. 初始化默认选中状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始化时,设置默认选中效果
const initSelectedSpec = () => {
if (!props.skuId) return
const sku = props.goods.skus.find((item) => item.id === props.skuId)
// console.log(sku)
if (sku) {
// 如果根据父组件传递的skuId找到了对应的sku,设置默认选中
props.goods.specs.forEach((item, index) => {
// 获取到sku中选中的规格
const value = sku.specs[index].valueName
// 让value对应的规格选中
const spec = item.values.find((item) => item.name === value)
spec!.selected = true
})
}
}
initSelectedSpec()
  1. 设置默认选中须在默认禁用之前设置,否则可能会看到又选中又禁用的视觉效果。
1
2
3
4
// 先设置默认选中
initSelectedSpec()
// 初始化时,控制所有按钮的状态
updateDisabledStatus()

选中信息传递

goods-sku.vue 组件选择了所有的规格之后,还需要将 sku 的信息传递给父组件,父组件可以用于渲染商品的数据,且加入购物车时也会用到。

  1. 选择完整的 sku 规格时传出 sku 信息,src/views/goods/components/goods-sku.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
const emit = defineEmits<{
(e: 'changeSku', skuId: string): void
}>()

const changeSelected = (spec: Spec, sub: SpecValue) => {
if (sub.disabled) return
// 增加一个selected
if (sub.selected) {
sub.selected = false
} else {
// 让所有的都不选中
spec.values.forEach((item) => {
item.selected = false
})
// 让自己选中
sub.selected = true
}
updateDisabledStatus(props.goods.specs, pathMap)

// 1. 判断规格是否全部选中
const selected = getSelectedSpec(props.goods.specs).filter((v) => v)
if (selected.length === props.goods.specs.length) {
// 说明全部选中
// 2. 去pathMap找到对应的skuid
const key = selected.join(spliter)
const [skuId] = pathMap[key]
// 3. 子传父,给父组件
emit('changeSku', skuId)
}
}
  1. 父组件注册事件,views/goods/index.vue
1
2
3
4
5
<div class="spec">
<GoodsInfo :goods="info" />
<!-- 规格组件 -->
<GoodsSku :goods="info" @changeSku="changeSku" skuId="1369155864430120962" />
</div>
1
2
3
4
5
6
7
8
const changeSku = (skuId: string) => {
const sku = info.value.skus.find((item) => item.id === skuId)
if (sku) {
info.value.inventory = sku.inventory
info.value.price = sku.price
info.value.oldPrice = sku.oldPrice
}
}

数量选择组件

基本结构

  1. 准备基本结构,components/numbox/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
42
43
44
45
46
47
48
49
50
51
<script lang="ts" setup name="XtxNumbox">
//
</script>
<template>
<div class="xtx-numbox">
<div class="label">数量</div>
<div class="numbox">
<a href="javascript:;">-</a>
<input type="text" readonly value="1" />
<a href="javascript:;">+</a>
</div>
</div>
</template>

<style scoped lang="less">
.xtx-numbox {
display: flex;
align-items: center;
.label {
width: 60px;
color: #999;
padding-left: 10px;
}
.numbox {
width: 120px;
height: 30px;
border: 1px solid #e4e4e4;
display: flex;
> a {
width: 29px;
line-height: 28px;
text-align: center;
background: #f8f8f8;
font-size: 16px;
color: #666;
&:first-of-type {
border-right: 1px solid #e4e4e4;
}
&:last-of-type {
border-left: 1px solid #e4e4e4;
}
}
> input {
width: 60px;
padding: 0 5px;
text-align: center;
color: #666;
}
}
}
</style>
  1. 全局注册,components/index.ts
1
2
3
4
5
6
import XtxNumbox from '@/components/numbox/index.vue'
export default {
install(app: App) {
app.component('XtxNumbox', XtxNumbox)
},
}
  1. 提供类型声明,global.d.ts
1
2
3
4
5
6
7
8
import XtxNumbox from '@/components/numbox/index.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxNumbox: typeof XtxNumbox
}
}

export {}
  1. 渲染,views/goods/index.vue
1
2
3
4
5
6
7
<div class="spec">
<GoodsInfo :goods="info" />
<!-- 规格组件 -->
<GoodsSku :goods="info" @changeSku="changeSku" skuId="1369155864430120962" />
<!-- 数字选择框 -->
<XtxNumbox :max="info.inventory" isLabel v-model="count" />
</div>

v-model 语法

  • Vue2.0 中 v-mode 语法糖简写的代码 <Son :value="msg" @input="msg=$event" />

  • Vue3.0 中 v-model 语法糖有所调整:<Son :modelValue="msg" @update:modelValue="msg=$event" />

功能实现

  • 默认值为 1。

  • 可限制最大最小值。

  • 点击 - 就是减 1,点击 + 就是加 1。

  • 需要完成 v-model 得实现。

  • 存在无 label 情况。

components/numbox/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
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<script lang="ts" setup name="XtxNumbox">
const props = defineProps({
modelValue: {
type: Number,
default: 1,
},
min: {
type: Number,
default: 1,
},
max: {
type: Number,
default: 20,
},
showLabel: {
type: Boolean,
default: false,
},
})

const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()

const add = () => {
if (props.modelValue >= props.max) return
emit('update:modelValue', props.modelValue + 1)
}

const sub = () => {
if (props.modelValue <= props.min) return
emit('update:modelValue', props.modelValue - 1)
}
</script>
<template>
<div class="xtx-numbox">
<div class="label" v-if="showLabel"><slot>数量</slot></div>
<div class="numbox">
<a href="javascript:;" @click="sub">-</a>
<input type="text" readonly :value="modelValue" />
<a href="javascript:;" @click="add">+</a>
</div>
</div>
</template>

<style scoped lang="less">
.xtx-numbox {
display: flex;
align-items: center;
.label {
width: 60px;
color: #999;
padding-left: 10px;
}
.numbox {
width: 120px;
height: 30px;
border: 1px solid #e4e4e4;
display: flex;
> a {
width: 29px;
line-height: 28px;
text-align: center;
background: #f8f8f8;
font-size: 16px;
color: #666;
&:first-of-type {
border-right: 1px solid #e4e4e4;
}
&:last-of-type {
border-left: 1px solid #e4e4e4;
}
}
> input {
width: 60px;
padding: 0 5px;
text-align: center;
color: #666;
}
}
}
</style>

商品详情

按钮组件

目的:封装一个通用按钮组件,有大、中、小、超小四种尺寸,有默认、主要、次要、灰色四种类型。

  1. src/components/button/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
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
68
69
<script lang="ts" setup name="XtxButton">
import { PropType } from 'vue'

defineProps({
size: {
type: String as PropType<'large' | 'middle' | 'small' | 'mini'>,
default: 'middle',
},
type: {
type: String as PropType<'default' | 'primary' | 'plain' | 'gray'>,
default: 'default',
},
})
</script>
<template>
<button class="xtx-button ellipsis" :class="[size, type]">
<slot />
</button>
</template>

<style scoped lang="less">
.xtx-button {
appearance: none;
border: none;
outline: none;
background: #fff;
text-align: center;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.large {
width: 240px;
height: 50px;
font-size: 16px;
}
.middle {
width: 180px;
height: 50px;
font-size: 16px;
}
.small {
width: 100px;
height: 32px;
}
.mini {
width: 60px;
height: 32px;
}
.default {
border-color: #e4e4e4;
color: #666;
}
.primary {
border-color: @xtxColor;
background: @xtxColor;
color: #fff;
}
.plain {
border-color: @xtxColor;
color: @xtxColor;
background: lighten(@xtxColor, 50%);
}
.gray {
border-color: #ccc;
background: #ccc;
color: #fff;
}
</style>
  1. 全局注册组件,components/index.ts
1
2
3
4
5
import XtxButton from '@/components/button/index.vue'
export default {
install(app: App) {
app.component('XtxButton', XtxButton)
}
  1. 定义组件类型,global.d.ts
1
2
3
4
5
6
7
import XtxButton from '@/components/button/index.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxButton: typeof XtxButton
}
}
export {}
  1. 使用组件:src/views/goods/index.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 商品信息 -->
<div class="goods-info">
<div class="media">
<GoodsImage :images="info.mainPictures"></GoodsImage>
<GoodsSale />
</div>
<div class="spec">
<GoodsInfo :goods="info" />
<!-- 规格组件 -->
<GoodsSku :goods="info" @changeSku="changeSku" skuId="1369155864430120962" />
<!-- 数字选择框 -->
<XtxNumbox :max="info.inventory" isLabel v-model="count"></XtxNumbox>
<XtxButton type="primary" style="margin-top: 20px"> 加入购物车 </XtxButton>
</div>
</div>

详情渲染

  1. src/goods/components/goods-detail.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
68
69
70
71
72
73
74
<script setup lang="ts">
import type { GoodsInfo } from '@/types/goods'

interface Props {
goods: GoodsInfo
}
defineProps<Props>()
</script>

<template>
<div class="goods-tabs">
<nav>
<a>商品详情</a>
</nav>
<div class="goods-detail">
<!-- 属性 -->
<ul class="attrs">
<li v-for="item in goods.details.properties" :key="item.value">
<span class="dt">{{ item.name }}</span>
<span class="dd">{{ item.value }}</span>
</li>
</ul>
<!-- 图片 -->
<img v-for="item in goods.details.pictures" :key="item" :src="item" alt="" />
</div>
</div>
</template>

<style scoped lang="less">
.goods-tabs {
min-height: 600px;
background: #fff;
nav {
height: 70px;
line-height: 70px;
display: flex;
border-bottom: 1px solid #f5f5f5;
a {
padding: 0 40px;
font-size: 18px;
position: relative;
> span {
color: @priceColor;
font-size: 16px;
margin-left: 10px;
}
}
}
}
.goods-detail {
padding: 40px;
.attrs {
display: flex;
flex-wrap: wrap;
margin-bottom: 30px;
li {
display: flex;
margin-bottom: 10px;
width: 50%;
.dt {
width: 100px;
color: #999;
}
.dd {
flex: 1;
color: #666;
}
}
}
> img {
width: 100%;
}
}
</style>
  1. 提供数据类型,types/data.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
type GoodsDetail = {
pictures: string[]
properties: {
name: string
value: string
}[]
}
// 商品模块的类型声明
export type GoodsInfo = {
// ...
details: GoodsDetail
}
  1. 商品组件中渲染,views/goods/index.vue
1
2
3
4
5
6
7
8
9
10
11
<div class="goods-footer">
<div class="goods-article">
<!-- 商品+评价 -->
<div class="goods-tabs">
<!-- 商品详情 -->
<GoodsDetail :goods="info" />
</div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside"></div>
</div>

热榜组件

  1. 基本结构,views/goods/components/goods-hot.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
<script setup lang="ts">
import GoodsItem from '@/views/category/components/goods-item.vue'
import type { PropType } from 'vue'
const props = defineProps({
type: {
type: Number as PropType<1 | 2 | 3>,
default: 1,
},
})

// 标题对象
const titleObj = {
1: '24小时热销榜',
2: '周热销榜',
3: '总热销榜',
}
</script>

<template>
<div class="goods-hot">
<h3>{{ titleObj[props.type] }}</h3>
<div class="goods-list">
<!-- 商品区块 -->
<GoodsItem v-for="(item, index) in 4" :key="index" />
</div>
</div>
</template>

<style scoped lang="less">
.goods-hot {
background-color: #fff;
margin-bottom: 20px;
h3 {
height: 70px;
background: @helpColor;
color: #fff;
font-size: 18px;
line-height: 70px;
padding-left: 25px;
margin-bottom: 10px;
font-weight: normal;
}
.goods-list {
display: flex;
flex-direction: column;
align-items: center;
}
}
</style>
  1. 父组件中渲染,views/goods/index.vue
1
2
3
4
5
6
7
8
9
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<GoodsHot :type="1" />
<GoodsHot :type="2" />
<GoodsHot :type="3" />
</div>
</div>
  1. 发送请求并且渲染,views/goods/components/goods-hot.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
<script setup lang="ts">
import { ApiRes, GoodItem } from '@/types/data'
import request from '@/utils/request'
import GoodsItem from '@/views/category/components/goods-item.vue'
import { onMounted, PropType, ref } from 'vue'
import { useRoute } from 'vue-router'
const props = defineProps({
type: {
type: Number as PropType<1 | 2 | 3>,
default: 1,
},
})

// 标题对象
const titleObj = {
1: '24小时热销榜',
2: '周热销榜',
3: '总热销榜',
}

// 发送请求获取数据
const route = useRoute()
const id = route.params.id as string
const list = ref<GoodItem[]>([])
onMounted(async () => {
const res = await request.get<ApiRes<GoodItem[]>>('/goods/hot', {
params: {
id: id,
type: props.type,
},
})
list.value = res.data.result
})
</script>

<template>
<div class="goods-hot">
<h3>{{ titleObj[props.type] }}</h3>
<div class="goods-list">
<!-- 商品区块 -->
<GoodsItem v-for="(item, index) in list" :key="index" :goods="item" />
</div>
</div>
</template>

<style scoped lang="less">
.goods-hot {
background-color: #fff;
margin-bottom: 20px;
h3 {
height: 70px;
background: @helpColor;
color: #fff;
font-size: 18px;
line-height: 70px;
padding-left: 25px;
margin-bottom: 10px;
font-weight: normal;
}
.goods-list {
display: flex;
flex-direction: column;
align-items: center;
}
}
</style>