vue实践之商场app - 详情页

详情页需要进行商品展示、规格选择、加入购物车等多种功能的展示。在View/Detail文件夹下新建index.vue和components文件夹。index.vue的代码如下

效果图

用到的接口

在apis文件夹下新建details.js,需要用到的接口为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import request from "@/utils/http"

export const getDetail = (id) => {
return request({
url: '/goods',
params: {
id
}
})
}

export const getHotGoodsAPI = ({ id, type, limit = 3 }) => {
return request({
url: '/goods/hot',
params: {
id,
type,
limit
}
})
}

组件概览

面包屑组件

面包屑组件以前已经写过,这里不再说明其具体写法

1
2
3
4
5
6
7
8
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/${goods.categories[1].id}` }">{{ goods.categories[1].name }}</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[0].id}` }">{{ goods.categories[0].name }}</el-breadcrumb-item>
<el-breadcrumb-item>{{ goods.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>

但在实际运行中会发现其报错。原因是goods一开始的初始值是一个空对象

1
const goods = ref({})

在goods获取数据前就开始渲染了,因此后面的数据都变成了undefined类型。要想解决该问题,有两种方法

  • 添加可选链(添加一个?)语法,在获取到值后才开始计算,如

    1
    <el-breadcrumb-item :to="{ path: `/category/${goods.categories[1]?.id}` }">{{ goods.categories[1]?.name }}</el-breadcrumb-item>
  • 此处使用的是v-if手动控制渲染时机,保证获取到数据后才开始渲染(注意container后的v-if)。

商品概览

预览图片

该部分中的图片预览区组件需要实现放大和获取图片列表的功能。在src/components文件夹下新建index.js文件(用于全局注册)和ImageView文件夹,这个文件夹下新建index.vue代码如下

还没有预览时的效果图

defineProps是做过的传参,这里不再赘述。
middle部分为左侧的预览图。从图片数组内挑选符合条件的图片。同时准备好蒙版组件,蒙版的位置由js部分决定,当鼠标在预览图内时才渲染。

1
2
3
4
<div class="middle" ref="target">
<img :src="imageList[activeIndex]" alt="" />
<div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div>
</div>

条件控制在右侧的小图中。当鼠标进入某张图片的范围时将activeIndex转换为自己所在的索引,同时改变类名,显示高亮。

1
2
3
4
5
<ul class="small">
<li v-for="(img, i) in imageList" :key="i" @mouseenter="enterHandler(i)" :class="{ active: i === activeIndex }">
<img :src="img" alt="" />
</li>
</ul>
1
const enterHandler = (i) => { activeIndex.value = i }

当鼠标在预览图中时,大图开始显示,大图显示的位置由js部分决定。

效果图

1
2
3
4
5
<div class="large" :style="[{
backgroundImage: `url(${imageList[activeIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},]" v-show="!isOutside"></div>

js部分如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)

// 滑块随鼠标移动
const left = ref(0), top = ref(0), positionX = ref(0), positionY = ref(0)
watch([elementX, elementY, isOutside], () => {
if (isOutside.value) return
// 有效范围内
if (elementX.value > 100 && elementX.value < 300) { left.value = elementX.value - 100 }
if (elementY.value > 100 && elementY.value < 300) { top.value = elementY.value - 100 }
// 边界
if (elementX.value > 300) { left.value = 200 }
if (elementX.value < 100) { left.value = 0 }
if (elementY.value > 300) { top.value = 200 }
if (elementY.value < 100) { top.value = 0 }
// 控制大图的显示
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})

target传入一个组件(这里是middle),watch方法用于监听传入3个值的变化。变化时回调函数会被触发(但前两个一直在变,就相当于一直在触发了)。当鼠标在预览图中时,蒙版和大图的位置开始变化。

  • 蒙版的点位在鼠标往左、往上各100px

    • 鼠标在100-300范围内,蒙版左上角点的值为鼠标坐标-100

    • 鼠标在0-100范围内,为防止蒙版跑出,值固定为0

    • 鼠标在200-300范围内,为防止蒙版抛出,值固定为200

  • 大图的位置以蒙版的位置为准。由于大图相较于不变的大图框是反向运动,因此需要加符号

组件统一注册

index.js下的代码为

1
2
3
4
5
6
7
8
9
import ImageView from './ImageView/index.vue'
import Sku from './XtxSku/index.vue'
export const componentPlugin = {
install(app) {
// app.component('组件名字', 组件配置对象)
app.component('ImaView', ImageView)
app.component('XtxSku', Sku)
}
}

该部分注册了文件夹下的所有组件,只需要在main,js文件下注册

1
app.use(componentPlugin)

即可,减少了main.js部分的代码量。

统计信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ul class="goods-sales">
<li>
<p>销量人气</p>
<p> {{ goods.salesCount }} </p>
<p><i class="iconfont icon-task-filling"></i>销量人气</p>
</li>
<li>
<p>商品评价</p>
<p> {{ goods.commentCount }} </p>
<p><i class="iconfont icon-comment-filling"></i>查看评价</p>
</li>
<li>
<p>收藏人气</p>
<p> {{ goods.collectCount }} </p>
<p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
</li>
<li>
<p>品牌信息</p>
<p> {{ goods.brand.name }} </p>
<p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
</li>
</ul>

商品信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<p class="g-name"> {{ goods.name }} </p>
<p class="g-desc"> {{ goods.desc }} </p>
<p class="g-price">
<span> {{ goods.oldPrice }}</span>
<span> {{ goods.price }} </span>
</p>
<div class="g-service">
<dl>
<dt>促销</dt>
<dd>12月好物放送,App领券购买直降120元</dd>
</dl>
<dl>
<dt>服务</dt>
<dd>
<span>无忧退货</span>
<span>快速退款</span>
<span>免费包邮</span>
<a href="javascript:;">了解详情</a>
</dd>
</dl>
</div>

oldPrice部分应该加个v-if以更符合现实情况。

sku组件

这部分在课程中作为选修部分,没有多讲。点击查看源码

热榜组件

侧栏显示热榜之类的牛皮藓是很多网站惯用的功能。此处能显示24小时热榜和周热榜

1
2
3
4
5
6
7
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<!-- 24小时 -->
<DetailHot :hotType="1" />
<!-- 周 -->
<DetailHot :hotType="2" />
</div>

其中的<Detail />为components文件夹下的DetailHot.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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
<script setup>
import { getDetail } from '@/apis/detail'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import DetailHot from './components/DetailHot.vue'
import { ElMessage } from 'element-plus'
import { useCartStore } from "@/stores/cartStore"

const cartStore = useCartStore()
const goods = ref({})
const route = useRoute()
const getGoods = async () => {
const res = await getDetail(route.params.id)
goods.value = res.result
}

onMounted(() => getGoods())

let skuObj = {}
const skuChange = (sku) => {
skuObj = sku
}

const count = ref(1)
const countChange = (count) => {
}

const addCart = () => {
if (skuObj.skuId) {
cartStore.addCart({
id: goods.value.id,
name: goods.value.name,
picture: goods.value.mainPictures[0],
price: goods.value.price,
count: count.value,
skuId: skuObj.skuId,
attrsText: skuObj.specsText,
selected: true
})
} else {
ElMessage.warning('请选择规格')
}
}
</script>

<template>
<div class="xtx-goods-page">
<div class="container" v-if="goods.details">
<div class="bread-container">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/${goods.categories[1].id}` }">{{
goods.categories[1].name }}</el-breadcrumb-item>
<el-breadcrumb-item :to="{ path: `/category/sub/${goods.categories[0].id}` }">{{
goods.categories[0].name }}</el-breadcrumb-item>
<el-breadcrumb-item>{{ goods.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="info-container">
<div class="goods-info">
<div class="media">
<!-- 图片预览区 -->
<ImageView :image-list="goods.mainPictures" />
<!-- 统计数量 -->
<ul class="goods-sales">
<li>
<p>销量人气</p>
<p> {{ goods.salesCount }} </p>
<p><i class="iconfont icon-task-filling"></i>销量人气</p>
</li>
<li>
<p>商品评价</p>
<p> {{ goods.commentCount }} </p>
<p><i class="iconfont icon-comment-filling"></i>查看评价</p>
</li>
<li>
<p>收藏人气</p>
<p> {{ goods.collectCount }} </p>
<p><i class="iconfont icon-favorite-filling"></i>收藏商品</p>
</li>
<li>
<p>品牌信息</p>
<p> {{ goods.brand.name }} </p>
<p><i class="iconfont icon-dynamic-filling"></i>品牌主页</p>
</li>
</ul>
</div>
<div class="spec">
<!-- 商品信息区 -->
<p class="g-name"> {{ goods.name }} </p>
<p class="g-desc"> {{ goods.desc }} </p>
<p class="g-price">
<span> {{ goods.oldPrice }}</span>
<span> {{ goods.price }} </span>
</p>
<div class="g-service">
<dl>
<dt>促销</dt>
<dd>12月好物放送,App领券购买直降120元</dd>
</dl>
<dl>
<dt>服务</dt>
<dd>
<span>无忧退货</span>
<span>快速退款</span>
<span>免费包邮</span>
<a href="javascript:;">了解详情</a>
</dd>
</dl>
</div>
<!-- sku组件 -->
<XtxSku :goods="goods" @change="skuChange" />
<!-- 数据组件 -->
<el-input-number v-model="count" @change="countChange" />
<!-- 按钮组件 -->
<div>
<el-button size="large" class="btn" @click="addCart">
加入购物车
</el-button>
</div>
</div>
</div>
</div>

<div class="goods-footer">
<div class="goods-article">
<!-- 商品详情 -->
<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="img in goods.details.pictures" :src="img" :key="img" alt="">
</div>
</div>
</div>
<!-- 24热榜+专题推荐 -->
<div class="goods-aside">
<!-- 24小时 -->
<DetailHot :hotType="1" />
<!-- 周 -->
<DetailHot :hotType="2" />
</div>
</div>
</div>
</div>
</template>

<style scoped lang='scss'>
.xtx-goods-page {
.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;
}
}

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

.number-box {
display: flex;
align-items: center;

.label {
width: 60px;
color: #999;
padding-left: 10px;
}
}

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

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

.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%;
}
}

.btn {
margin-top: 20px;

}

.bread-container {
padding: 25px 0;
}
</style>

图片组件

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<script setup>
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'

// 适配图片列表
defineProps({
imageList: {
type: Array,
default: () => []
}
})

// 小图切换大图显示
const activeIndex = ref(0)

const enterHandler = (i) => {
activeIndex.value = i
}

// 获取鼠标相对位置
const target = ref(null)
const { elementX, elementY, isOutside } = useMouseInElement(target)

// 滑块随鼠标移动
const left = ref(0), top = ref(0), positionX = ref(0), positionY = ref(0)
watch([elementX, elementY, isOutside], () => {
if (isOutside.value) return
// 有效范围内
if (elementX.value > 100 && elementX.value < 300) { left.value = elementX.value - 100 }
if (elementY.value > 100 && elementY.value < 300) { top.value = elementY.value - 100 }
// 边界
if (elementX.value > 300) { left.value = 200 }
if (elementX.value < 100) { left.value = 0 }
if (elementY.value > 300) { top.value = 200 }
if (elementY.value < 100) { top.value = 0 }
// 控制大图的显示
positionX.value = -left.value * 2
positionY.value = -top.value * 2
})
</script>

<template>
<div class="goods-image">
<div class="middle" ref="target">
<img :src="imageList[activeIndex]" alt="" />

<div class="layer" v-show="!isOutside" :style="{ left: `${left}px`, top: `${top}px` }"></div>
</div>
<ul class="small">
<li v-for="(img, i) in imageList" :key="i" @mouseenter="enterHandler(i)"
:class="{ active: i === activeIndex }">
<img :src="img" alt="" />
</li>
</ul>

<div class="large" :style="[{
backgroundImage: `url(${imageList[activeIndex]})`,
backgroundPositionX: `${positionX}px`,
backgroundPositionY: `${positionY}px`,
},]" v-show="!isOutside"></div>
</div>
</template>

<style scoped lang="scss">
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;

.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
}

.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
// 背景图:盒子的大小 = 2:1 将来控制背景图的移动来实现放大的效果查看 background-position
background-size: 800px 800px;
background-color: #f8f8f8;
}

.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
// 绝对定位 然后跟随咱们鼠标控制lefttop属性就可以让滑块移动起来
left: 0;
top: 0;
position: absolute;
}

.small {
width: 80px;

li {
width: 68px;
height: 68px;
margin-left: 12px;
margin-bottom: 15px;
cursor: pointer;

&:hover,
&.active {
border: 2px solid $xtxColor;
}
}
}
}
</style>

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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
<template>
<div class="goods-sku">
<dl v-for="item in goods.specs" :key="item.id">
<dt>{{ item.name }}</dt>
<dd>
<template v-for="val in item.values" :key="val.name">
<img :class="{ selected: val.selected, disabled: val.disabled }" @click="clickSpecs(item, val)"
v-if="val.picture" :src="val.picture" />
<span :class="{ selected: val.selected, disabled: val.disabled }" @click="clickSpecs(item, val)" v-else>{{
val.name
}}</span>
</template>
</dd>
</dl>
</div>
</template>
<script>
import { watchEffect } from 'vue'
import getPowerSet from './power-set'
const spliter = '★'
// 根据skus数据得到路径字典对象
const getPathMap = (skus) => {
const pathMap = {}
if (skus && skus.length > 0) {
skus.forEach(sku => {
// 1. 过滤出有库存有效的sku
if (sku.inventory) {
// 2. 得到sku属性值数组
const specs = sku.specs.map(spec => spec.valueName)
// 3. 得到sku属性值数组的子集
const powerSet = getPowerSet(specs)
// 4. 设置给路径字典对象
powerSet.forEach(set => {
const key = set.join(spliter)
// 如果没有就先初始化一个空数组
if (!pathMap[key]) {
pathMap[key] = []
}
pathMap[key].push(sku.id)
})
}
})
}
return pathMap
}

// 初始化禁用状态
function initDisabledStatus (specs, pathMap) {
if (specs && specs.length > 0) {
specs.forEach(spec => {
spec.values.forEach(val => {
// 设置禁用状态
val.disabled = !pathMap[val.name]
})
})
}
}

// 得到当前选中规格集合
const getSelectedArr = (specs) => {
const selectedArr = []
specs.forEach((spec, index) => {
const selectedVal = spec.values.find(val => val.selected)
if (selectedVal) {
selectedArr[index] = selectedVal.name
} else {
selectedArr[index] = undefined
}
})
return selectedArr
}

// 更新按钮的禁用状态
const updateDisabledStatus = (specs, pathMap) => {
// 遍历每一种规格
specs.forEach((item, i) => {
// 拿到当前选择的项目
const selectedArr = getSelectedArr(specs)
// 遍历每一个按钮
item.values.forEach(val => {
if (!val.selected) {
selectedArr[i] = val.name
// 去掉undefined之后组合成key
const key = selectedArr.filter(value => value).join(spliter)
val.disabled = !pathMap[key]
}
})
})
}


export default {
name: 'XtxGoodSku',
props: {
// specs:所有的规格信息 skus:所有的sku组合
goods: {
type: Object,
default: () => ({ specs: [], skus: [] })
}
},
emits: ['change'],
setup (props, { emit }) {
let pathMap = {}
watchEffect(() => {
// 得到所有字典集合
pathMap = getPathMap(props.goods.skus)
// 组件初始化的时候更新禁用状态
initDisabledStatus(props.goods.specs, pathMap)
})

const clickSpecs = (item, val) => {
if (val.disabled) return false
// 选中与取消选中逻辑
if (val.selected) {
val.selected = false
} else {
item.values.forEach(bv => { bv.selected = false })
val.selected = true
}
// 点击之后再次更新选中状态
updateDisabledStatus(props.goods.specs, pathMap)
// 把选择的sku信息传出去给父组件
// 触发change事件将sku数据传递出去
const selectedArr = getSelectedArr(props.goods.specs).filter(value => value)
// 如果选中得规格数量和传入得规格总数相等则传出完整信息(都选择了)
// 否则传出空对象
if (selectedArr.length === props.goods.specs.length) {
// 从路径字典中得到skuId
const skuId = pathMap[selectedArr.join(spliter)][0]
const sku = props.goods.skus.find(sku => sku.id === skuId)
// 传递数据给父组件
emit('change', {
skuId: sku.id,
price: sku.price,
oldPrice: sku.oldPrice,
inventory: sku.inventory,
specsText: sku.specs.reduce((p, n) => `${p} ${n.name}${n.valueName}`, '').trim()
})
} else {
emit('change', {})
}
}
return { clickSpecs }
}
}
</script>

<style scoped lang="scss">
@mixin 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-bottom: 4px;
@include sku-state-mixin;
}

>span {
display: inline-block;
height: 30px;
line-height: 28px;
padding: 0 20px;
margin-bottom: 4px;
@include sku-state-mixin;
}
}
}
}
</style>

js部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

export default function bwPowerSet (originalSet) {
const subSets = []
const numberOfCombinations = 2 ** originalSet.length
for (let combinationIndex = 0; combinationIndex < numberOfCombinations; combinationIndex += 1) {
const subSet = []
for (let setElementIndex = 0; setElementIndex < originalSet.length; setElementIndex += 1) {
if (combinationIndex & (1 << setElementIndex)) {
subSet.push(originalSet[setElementIndex])
}
}
subSets.push(subSet)
}
return subSets
}

热榜

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
<script setup>
import { getHotGoodsAPI } from '@/apis/detail'
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';

const props = defineProps({
hotType: {
type: Number
}
})

const TYPEMAP = {
1: '24小时热榜',
2: '周热榜'
}

const title = computed(() => TYPEMAP[props.hotType])

const hotList = ref([])
const route = useRoute()
const getHotList = async () => {
const res = await getHotGoodsAPI({
id: route.params.id,
type: props.hotType
})
hotList.value = res.result
}

onMounted(() => getHotList())
</script>
<template>
<div class="goods-hot">
<h3>{{ title }}</h3>
<router-link to="/" class="goods-item" v-for="item in hotList" :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>
</router-link>
</div>
</template>

<style scoped lang="scss">
.goods-hot {
h3 {
height: 70px;
background: $helpColor;
color: #fff;
font-size: 18px;
line-height: 70px;
padding-left: 25px;
margin-bottom: 10px;
font-weight: normal;
}

.goods-item {
display: block;
padding: 20px 30px;
text-align: center;
background: #fff;

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>

vue实践之商场app - 详情页
http://example.com/2025/05/04/mall-front-5/
作者
Ivan Chen
发布于
2025年5月4日
许可协议
IVAN