✔ 能够实现热门推荐的业务逻辑。
✔ 能够实现商品分类的业务逻辑。
✔ 能够实现商品详情的业务逻辑
热门推荐准备
参考效果
推荐模块的布局结构是相同的,因此我们可以复用相同的页面及交互,只是所展示的数据不同。

静态结构
新建热门推荐页面文件,并在 pages.json
中添加路由(VSCode 插件自动完成),pages/hot/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 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
| <script setup lang="ts">
const hotMap = [ { type: '1', title: '特惠推荐', url: '/hot/preference' }, { type: '2', title: '爆款推荐', url: '/hot/inVogue' }, { type: '3', title: '一站买全', url: '/hot/oneStop' }, { type: '4', title: '新鲜好物', url: '/hot/new' }, ] </script>
<template> <view class="viewport"> <view class="cover"> <image src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-05-20/84abb5b1-8344-49ae-afc1-9cb932f3d593.jpg" ></image> </view> <view class="tabs"> <text class="text active">抢先尝鲜</text> <text class="text">新品预告</text> </view> <scroll-view scroll-y class="scroll-view"> <view class="goods"> <navigator hover-class="none" class="navigator" v-for="goods in 10" :key="goods" :url="`/pages/goods/goods?id=`" > <image class="thumb" src="https://yanxuan-item.nosdn.127.net/5e7864647286c7447eeee7f0025f8c11.png" ></image> <view class="name ellipsis">不含酒精,使用安心爽肤清洁湿巾</view> <view class="price"> <text class="symbol">¥</text> <text class="number">29.90</text> </view> </navigator> </view> <view class="loading-text">正在加载...</view> </scroll-view> </view> </template>
<style lang="scss"> page { height: 100%; background-color: #f4f4f4; } .viewport { display: flex; flex-direction: column; height: 100%; padding: 180rpx 0 0; position: relative; } .cover { width: 750rpx; height: 225rpx; border-radius: 0 0 40rpx 40rpx; overflow: hidden; position: absolute; left: 0; top: 0; } .scroll-view { flex: 1; } .tabs { display: flex; justify-content: space-evenly; height: 100rpx; line-height: 90rpx; margin: 0 20rpx; font-size: 28rpx; border-radius: 10rpx; box-shadow: 0 4rpx 5rpx rgba(200, 200, 200, 0.3); color: #333; background-color: #fff; position: relative; z-index: 9; .text { margin: 0 20rpx; position: relative; } .active { &::after { content: ''; width: 40rpx; height: 4rpx; transform: translate(-50%); background-color: #27ba9b; position: absolute; left: 50%; bottom: 24rpx; } } } .goods { display: flex; flex-wrap: wrap; justify-content: space-between; padding: 0 20rpx 20rpx; .navigator { width: 345rpx; padding: 20rpx; margin-top: 20rpx; border-radius: 10rpx; background-color: #fff; } .thumb { width: 305rpx; height: 305rpx; } .name { height: 88rpx; font-size: 26rpx; } .price { line-height: 1; color: #cf4444; font-size: 30rpx; } .symbol { font-size: 70%; } .decimal { font-size: 70%; } }
.loading-text { text-align: center; font-size: 28rpx; color: #666; padding: 20rpx 0 50rpx; } </style>
|
动态标题
获取页面参数动态设置标题。
- 项目首页传递参数,
pages/index/components/HotPanel.vue
。
1 2 3
| <navigator :url="`/pages/hot/hot?type=${item.type}`"> …省略 </navigator>
|
- 热门推荐页获取参数,
pages/hot/hot.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <script setup lang="ts">
const hotMap = [ { type: '1', title: '特惠推荐', url: '/hot/preference' }, { type: '2', title: '爆款推荐', url: '/hot/inVogue' }, { type: '3', title: '一站买全', url: '/hot/oneStop' }, { type: '4', title: '新鲜好物', url: '/hot/new' }, ]
const query = defineProps<{ type: string }>()
const currUrlMap = hotMap.find((v) => v.type === query.type)
uni.setNavigationBarTitle({ title: currUrlMap!.title }) </script>
|
小结一下
获取热门数据
地址参数
不同类型的推荐,需要调用不同的 API 接口,如下。
type |
推荐类型 |
接口路径 |
1 |
特惠推荐 |
/hot/preference |
2 |
爆款推荐 |
/hot/inVogue |
3 |
一站买全 |
/hot/oneStop |
4 |
新鲜好物 |
/hot/new |
接口封装
经过分析,尽管不同类型请求 url 不同,但请求参数及响应格式都具有一致性,因此可以将接口的调用进行封装,参考代码如下所示。
- 封装接口,
services/hot.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import type { PageParams } from '@/types/global' import { http } from '@/utils/http'
type HotParams = PageParams & { subType?: string }
export const getHotRecommendAPI = (url: string, data?: HotParams) => { return http({ method: 'GET', url, data }) }
|
- 调用接口,
pages/hot/hot.vue
。
1 2 3 4 5 6 7 8 9 10
| import { getHotRecommendAPI } from '@/services/hot' import { onLoad } from '@dcloudio/uni-app'
const getHotRecommendData = async () => { const res = await getHotRecommendAPI(currUrlMap!.url) console.log(res) } onLoad(() => { getHotRecommendData() })
|
小结一下
推荐类型声明
返回数据
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
| const result = { id: '', title: '', bannerPicture: '', subTypes: [ { id: '', title: '', goodsItems: { page: 1, pageSize: 10, pages: 35, counts: 8, items: [ { id: '', name: '', orderNum: 47, picture: '', price: '', desc: '' } ], } }, {} ], }
|
类型定义
- 商品的类型是可复用的,封装到
src/types/global.d.ts
文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| export type GoodsItem = { desc: string discount: number id: string name: string orderNum: number picture: string price: number }
|
- 猜你喜欢的商品类型和上面相同,可复用,修改如下。
types/home.d.ts
1 2 3
| import type { GoodsItem } from './global'
export type GuessItem = GoodsItem
|
- 热门推荐,
src/types/hot.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
| import type { PageResult, GoodsItem } from './global'
export type HotResult = { id: string bannerPicture: string title: string subTypes: SubTypeItem[] }
export type SubTypeItem = { id: string title: string goodsItems: PageResult<GoodsItem> }
|
- 为接口指定返回值类型,
services/hot.ts
。
1 2 3 4 5 6 7 8 9
| import type { HotResult } from '@/types/hot'
export const getHotRecommendAPI = (url: string, data?: HotParams) => { return http<HotResult>({ method: 'GET', url, data }) }
|
小结一下
多 Tabs 逻辑
封面图片
pages/hot/hot.vue
1 2 3 4 5 6 7
| const bannerPicture = ref('') const getHotRecommendData = async () => { const res = await getHotRecommendAPI(currUrlMap!.url) bannerPicture.value = res.result.bannerPicture }
|
1 2 3 4
| <view class="cover"> <image :src="bannerPicture"></image> </view>
|
点击高亮
pages/hot/hot.vue
1 2 3 4 5 6 7 8 9 10
| const subTypes = ref<SubTypeItem[]>([])
const activeIndex = ref(0) const getHotRecommendData = async () => { const res = await getHotRecommendAPI(currUrlMap!.url) bannerPicture.value = res.result.bannerPicture subTypes.value = res.result.subTypes }
|
1 2 3 4 5 6 7 8 9 10 11 12
| <view class="tabs"> <text v-for="(item, index) in subTypes" :key="item.id" class="text" :class="{ active: index === activeIndex }" @tap="activeIndex = index" >{{ item.title }}</text > </view>
|
列表渲染
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
| <scroll-view scroll-y class="scroll-view" v-for="(item, index) in subTypes" :key="item.id" v-show="activeIndex === index" > <view class="goods"> <navigator hover-class="none" class="navigator" v-for="goods in item.goodsItems.items" :key="goods.id" :url="`/pages/goods/goods?id=${goods.id}`" > <image class="thumb" :src="goods.picture"></image> <view class="name ellipsis">{{ goods.name }}</view> <view class="price"> <text class="symbol">¥</text> <text class="number">{{ goods.price }}</text> </view> </navigator> </view> <view class="loading-text">正在加载...</view> </scroll-view>
|
小结一下
推荐分页处理
根据当前用户选中的 Tabs 加载对应的列表数据。
操作流程
根据高亮下标,获取对应列表数据。
提取列表的分页参数,用于发送请求。
滚动触底事件,页码累加,数组追加,退出判断等业务和常规分页基本一致。
具体步骤
- 绑定
@scrolltolower
,pages/hot/hot.vue
。
1 2 3 4 5 6 7 8
| <scroll-view scroll-y class="scroll-view" v-for="(item, index) in subTypes" :key="item.id" v-show="activeIndex === index" @scrolltolower="onScrolltolower" ></scroll-view>
|
- 逻辑处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const onScrolltolower = async () => { const currsubTypes = subTypes.value[activeIndex.value] currsubTypes.goodsItems.page++ const res = await getHotRecommendAPI(currUrlMap!.url, { subType: currsubTypes.id, page: currsubTypes.goodsItems.page, pageSize: currsubTypes.goodsItems.pageSize }) const newsubTypes = res.result.subTypes[activeIndex.value] currsubTypes.goodsItems.items.push(...newsubTypes.goodsItems.items) }
|
小结一下
推荐分页条件
完整代码
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
| <script setup lang="ts"> import { getHotRecommendAPI } from '@/services/hot' import { onLoad } from '@dcloudio/uni-app' import { ref } from 'vue' import type { SubTypeItem } from '@/types/hot'
const hotMap = [ { type: '1', title: '特惠推荐', url: '/hot/preference' }, { type: '2', title: '爆款推荐', url: '/hot/inVogue' }, { type: '3', title: '一站买全', url: '/hot/oneStop' }, { type: '4', title: '新鲜好物', url: '/hot/new' } ] const query = defineProps<{ type: string }>() const currUrlMap = hotMap.find((v) => v.type === query.type)
uni.setNavigationBarTitle({ title: currUrlMap!.title }) const bannerPicture = ref('')
const subTypes = ref<(SubTypeItem & { finish?: boolean })[]>([]) const activeIndex = ref(0) const getHotRecommendData = async () => { const res = await getHotRecommendAPI(currUrlMap!.url, { page: import.meta.env.DEV ? 30 : 1, pageSize: 10 }) bannerPicture.value = res.result.bannerPicture subTypes.value = res.result.subTypes }
const onScrolltolower = async () => { const currsubTypes = subTypes.value[activeIndex.value] if (currsubTypes.goodsItems.page < currsubTypes.goodsItems.pages) { currsubTypes.goodsItems.page++ } else { currsubTypes.finish = true return uni.showToast({ icon: 'none', title: '没有更改数据了~' }) } const res = await getHotRecommendAPI(currUrlMap!.url, { subType: currsubTypes.id, page: currsubTypes.goodsItems.page, pageSize: currsubTypes.goodsItems.pageSize }) const newsubTypes = res.result.subTypes[activeIndex.value] currsubTypes.goodsItems.items.push(...newsubTypes.goodsItems.items) }
onLoad(() => { getHotRecommendData() }); </script>
<template> <view class="viewport"> <view class="cover"> <image :src="bannerPicture"></image> </view> <view class="tabs"> <text v-for="(item, index) in subTypes" :key="item.id" class="text" :class="{ active: index === activeIndex }" @tap="activeIndex = index" >{{ item.title }}</text > </view> <scroll-view scroll-y class="scroll-view" v-for="(item, index) in subTypes" :key="item.id" v-show="activeIndex === index" @scrolltolower="onScrolltolower" > <view class="goods"> <navigator hover-class="none" class="navigator" v-for="goods in item.goodsItems.items" :key="goods.id" :url="`/pages/goods/goods?id=${goods.id}`" > <image class="thumb" :src="goods.picture"></image> <view class="name ellipsis">{{ goods.name }}</view> <view class="price"> <text class="symbol">¥</text> <text class="number">{{ goods.price }}</text> </view> </navigator> </view> <view class="loading-text">{{ item.finish ? '没有更多数据了~' : '正在加载...' }}</view> </scroll-view> </view> </template>
|
小结一下
分类准备工作
参考效果
商品分类页中的广告位,可复用之前定义的轮播图组件 XtxSwiper
。

静态结构
商品分类页静态结构,pages/category/category.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
| <script setup lang="ts">
</script>
<template> <view class="viewport"> <view class="search"> <view class="input"> <text class="icon-search">女靴</text> </view> </view> <view class="categories"> <scroll-view class="primary" scroll-y> <view v-for="(item, index) in 10" :key="item" class="item" :class="{ active: index === 0 }"> <text class="name"> 居家 </text> </view> </scroll-view> <scroll-view class="secondary" scroll-y> <XtxSwiper class="banner" :list="[]" /> <view class="panel" v-for="item in 3" :key="item"> <view class="title"> <text class="name">宠物用品</text> <navigator class="more" hover-class="none">全部</navigator> </view> <view class="section"> <navigator v-for="goods in 4" :key="goods" class="goods" hover-class="none" :url="`/pages/goods/goods?id=`" > <image class="image" src="https://yanxuan-item.nosdn.127.net/674ec7a88de58a026304983dd049ea69.jpg" ></image> <view class="name ellipsis">木天蓼逗猫棍</view> <view class="price"> <text class="symbol">¥</text> <text class="number">16.00</text> </view> </navigator> </view> </view> </scroll-view> </view> </view> </template>
<style lang="scss"> page { height: 100%; overflow: hidden; } .viewport { height: 100%; display: flex; flex-direction: column; } .search { padding: 0 30rpx 20rpx; background-color: #fff; .input { display: flex; align-items: center; justify-content: space-between; height: 64rpx; padding-left: 26rpx; color: #8b8b8b; font-size: 28rpx; border-radius: 32rpx; background-color: #f3f4f4; } } .icon-search { &::before { margin-right: 10rpx; } }
.categories { flex: 1; min-height: 400rpx; display: flex; }
.primary { overflow: hidden; width: 180rpx; flex: none; background-color: #f6f6f6; .item { display: flex; justify-content: center; align-items: center; height: 96rpx; font-size: 26rpx; color: #595c63; position: relative; &::after { content: ''; position: absolute; left: 42rpx; bottom: 0; width: 96rpx; border-top: 1rpx solid #e3e4e7; } } .active { background-color: #fff; &::before { content: ''; position: absolute; left: 0; top: 0; width: 8rpx; height: 100%; background-color: #27ba9b; } } } .primary .item:last-child::after, .primary .active::after { display: none; }
.secondary { background-color: #fff; .carousel { height: 200rpx; margin: 0 30rpx 20rpx; border-radius: 4rpx; overflow: hidden; } .panel { margin: 0 30rpx 0rpx; } .title { height: 60rpx; line-height: 60rpx; color: #333; font-size: 28rpx; border-bottom: 1rpx solid #f7f7f8; .more { float: right; padding-left: 20rpx; font-size: 24rpx; color: #999; } } .more { &::after { font-family: 'erabbit' !important; content: '\e6c2'; } } .section { width: 100%; display: flex; flex-wrap: wrap; padding: 20rpx 0; .goods { width: 150rpx; margin: 0rpx 30rpx 20rpx 0; &:nth-child(3n) { margin-right: 0; } image { width: 150rpx; height: 150rpx; } .name { padding: 5rpx; font-size: 22rpx; color: #333; } .price { padding: 5rpx; font-size: 18rpx; color: #cf4444; } .number { font-size: 24rpx; margin-left: 2rpx; } } } } </style>
|
渲染轮播图
接口地址
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { getHomeBannerAPI } from '@/services/home' import type { BannerItem } from '@/types/home' import { onLoad } from '@dcloudio/uni-app' import { ref } from 'vue'
const bannerList = ref<BannerItem[]>([]) const getBannerData = async () => { const res = await getHomeBannerAPI(2) bannerList.value = res.result } onLoad(() => { getBannerData() })
|
1
| <XtxSwiper class="banner" :list="bannerList" />
|
小结一下
一级分类渲染
获取数据
接口地址
- 封装接口,
services/category.ts
。
1 2 3 4 5 6 7 8 9 10 11
| import { http } from '@/utils/http'
export const getCategoryTopAPI = () => { return http({ method: 'GET', url: '/category/top' }) }
|
- 调用接口,
pages/category/category.vue
。
1 2 3 4 5 6 7 8 9 10
| import { getCategoryTopAPI } from '@/services/category'
const getCategoryTopData = async () => { const res = await getCategoryTopAPI() console.log(res) } onLoad(() => { getCategoryTopData() })
|
类型声明
- 定义类型,
types/category.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
| import type { GoodsItem } from './global'
export type CategoryTopItem = { children: CategoryChildItem[] id: string imageBanners: string[] name: string picture: string }
export type CategoryChildItem = { goods: GoodsItem[] id: string name: string picture: string }
|
- 修改接口,指定后端返回数据的类型,
services/category.ts
。
1 2 3 4 5 6 7 8 9 10 11 12
| import type { CategoryTopItem } from '@/types/category' import { http } from '@/utils/http'
export const getCategoryTopAPI = () => { return http<CategoryTopItem[]>({ method: 'GET', url: '/category/top' }) }
|
- 页面中存储数据并渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13
| const categoryList = ref<CategoryTopItem[]>([])
const activeIndex = ref(0) const getCategoryTopData = async () => { const res = await getCategoryTopAPI() categoryList.value = res.result } onLoad(() => { getBannerData() getCategoryTopData() })
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| <scroll-view class="primary" scroll-y> <view v-for="(item, index) in categoryList" :key="item.id" class="item" :class="{ active: index === activeIndex }" @tap="activeIndex = index" > <text class="name"> {{ item.name }} </text> </view> </scroll-view>
|
小结一下
二级分类渲染
商品二级分类是从属于某个一级分类的,通过 computed
配合高亮下标提取当前二级分类数据。
参考代码
1 2 3 4
| const subCategoryList = computed(() => { return categoryList.value[activeIndex.value]?.children || [] })
|
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
| <view class="panel" v-for="item in subCategoryList" :key="item.id"> <view class="title"> <text class="name">{{ item.name }}</text> <navigator class="more" hover-class="none">全部</navigator> </view> <view class="section"> <navigator v-for="goods in item.goods" :key="goods.id" class="goods" hover-class="none" :url="`/pages/goods/goods?id=${goods.id}`" > <image class="image" :src="goods.picture"></image> <view class="name ellipsis">{{ goods.name }}</view> <view class="price"> <text class="symbol">¥</text> <text class="number">{{ goods.price }}</text> </view> </navigator> </view> </view>
|
小结一下
骨架屏的优化
参考效果
实现步骤参考首页的骨架屏。

代码参考
pages/category/category.vue
1 2 3 4 5 6 7 8 9 10
| import PageSkeleton from './components/PageSkeleton.vue'
const isFinish = ref(false)
onLoad(async () => { isFinish.value = false await Promise.all([getBannerData(), getCategoryTopData()]) isFinish.value = true })
|
1 2
| <view class="viewport" v-if="isFinish"></view> <PageSkeleton v-else />
|
小结一下
详情准备工作
参考效果
用户点击商品列表,跳转到对应的商品详情页。

静态结构
pages/goods/goods.vue
,记得配置到 pages.json
中。
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 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413
| <script setup lang="ts">
const { safeAreaInsets } = uni.getSystemInfoSync() </script>
<template> <scroll-view scroll-y class="viewport"> <view class="goods"> <view class="preview"> <swiper circular> <swiper-item> <image mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/99c83709ca5f9fd5c5bb35d207ad7822.png" /> </swiper-item> <swiper-item> <image mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/f9107d47c08f0b99c097e30055c39e1a.png" /> </swiper-item> <swiper-item> <image mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/754c56785cc8c39f7414752f62d79872.png" /> </swiper-item> <swiper-item> <image mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/ef16f8127610ef56a2a10466d6dae157.jpg" /> </swiper-item> <swiper-item> <image mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/1f0c3f5d32b0e804deb9b3d56ea6c3b2.png" /> </swiper-item> </swiper> <view class="indicator"> <text class="current">1</text> <text class="split">/</text> <text class="total">5</text> </view> </view>
<view class="meta"> <view class="price"> <text class="symbol">¥</text> <text class="number">29.90</text> </view> <view class="name ellipsis">云珍·轻软旅行长绒棉方巾 </view> <view class="desc"> 轻巧无捻小方巾,旅行便携 </view> </view>
<view class="action"> <view class="item arrow"> <text class="label">选择</text> <text class="text ellipsis"> 请选择商品规格 </text> </view> <view class="item arrow"> <text class="label">送至</text> <text class="text ellipsis"> 请选择收获地址 </text> </view> <view class="item arrow"> <text class="label">服务</text> <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text> </view> </view> </view>
<view class="detail panel"> <view class="title"> <text>详情</text> </view> <view class="content"> <view class="properties"> <view class="item"> <text class="label">属性名</text> <text class="value">属性值</text> </view> <view class="item"> <text class="label">属性名</text> <text class="value">属性值</text> </view> </view> <image mode="widthFix" src="https://yanxuan-item.nosdn.127.net/a8d266886d31f6eb0d7333c815769305.jpg" ></image> <image mode="widthFix" src="https://yanxuan-item.nosdn.127.net/a9bee1cb53d72e6cdcda210071cbd46a.jpg" ></image> </view> </view>
<view class="similar panel"> <view class="title"> <text>同类推荐</text> </view> <view class="content"> <navigator v-for="item in 4" :key="item" class="goods" hover-class="none" :url="`/pages/goods/goods?id=`" > <image class="image" mode="aspectFill" src="https://yanxuan-item.nosdn.127.net/e0cea368f41da1587b3b7fc523f169d7.png" ></image> <view class="name ellipsis">简约山形纹全棉提花毛巾</view> <view class="price"> <text class="symbol">¥</text> <text class="number">18.50</text> </view> </navigator> </view> </view> </scroll-view>
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"> <view class="icons"> <button class="icons-button"><text class="icon-heart"></text>收藏</button> <button class="icons-button" open-type="contact"> <text class="icon-handset"></text>客服 </button> <navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab"> <text class="icon-cart"></text>购物车 </navigator> </view> <view class="buttons"> <view class="addcart"> 加入购物车 </view> <view class="buynow"> 立即购买 </view> </view> </view> </template>
<style lang="scss"> page { height: 100%; overflow: hidden; display: flex; flex-direction: column; }
.viewport { background-color: #f4f4f4; }
.panel { margin-top: 20rpx; background-color: #fff; .title { display: flex; justify-content: space-between; align-items: center; height: 90rpx; line-height: 1; padding: 30rpx 60rpx 30rpx 6rpx; position: relative; text { padding-left: 10rpx; font-size: 28rpx; color: #333; font-weight: 600; border-left: 4rpx solid #27ba9b; } navigator { font-size: 24rpx; color: #666; } } }
.arrow { &::after { position: absolute; top: 50%; right: 30rpx; content: '\e6c2'; color: #ccc; font-family: 'erabbit' !important; font-size: 32rpx; transform: translateY(-50%); } }
.goods { background-color: #fff; .preview { height: 750rpx; position: relative; .indicator { height: 40rpx; padding: 0 24rpx; line-height: 40rpx; border-radius: 30rpx; color: #fff; font-family: Arial, Helvetica, sans-serif; background-color: rgba(0, 0, 0, 0.3); position: absolute; bottom: 30rpx; right: 30rpx; .current { font-size: 26rpx; } .split { font-size: 24rpx; margin: 0 1rpx 0 2rpx; } .total { font-size: 24rpx; } } } .meta { position: relative; border-bottom: 1rpx solid #eaeaea; .price { height: 130rpx; padding: 25rpx 30rpx 0; color: #fff; font-size: 34rpx; box-sizing: border-box; background-color: #35c8a9; } .number { font-size: 56rpx; } .brand { width: 160rpx; height: 80rpx; overflow: hidden; position: absolute; top: 26rpx; right: 30rpx; } .name { max-height: 88rpx; line-height: 1.4; margin: 20rpx; font-size: 32rpx; color: #333; } .desc { line-height: 1; padding: 0 20rpx 30rpx; font-size: 24rpx; color: #cf4444; } } .action { padding-left: 20rpx; .item { height: 90rpx; padding-right: 60rpx; border-bottom: 1rpx solid #eaeaea; font-size: 26rpx; color: #333; position: relative; display: flex; align-items: center; &:last-child { border-bottom: 0 none; } } .label { width: 60rpx; color: #898b94; margin: 0 16rpx 0 10rpx; } .text { flex: 1; -webkit-line-clamp: 1; } } }
.detail { padding-left: 20rpx; .content { margin-left: -20rpx; } .properties { padding: 0 20rpx; margin-bottom: 30rpx; .item { display: flex; line-height: 2; padding: 10rpx; font-size: 26rpx; color: #333; border-bottom: 1rpx dashed #ccc; } .label { width: 200rpx; } .value { flex: 1; } } }
.similar { padding-left: 20rpx; .content { padding: 0 20rpx 20rpx; margin-left: -20rpx; background-color: #f4f4f4; overflow: hidden; navigator { width: 345rpx; padding: 24rpx 20rpx 20rpx; margin: 20rpx 20rpx 0 0; border-radius: 10rpx; background-color: #fff; float: left; } .image { height: 260rpx; } .name { height: 80rpx; margin: 10rpx 0; font-size: 26rpx; color: #262626; } .price { line-height: 1; font-size: 20rpx; color: #cf4444; } .number { font-size: 26rpx; margin-left: 2rpx; } } navigator { &:nth-child(even) { margin-right: 0; } } }
.toolbar { background-color: #fff; height: 100rpx; padding: 0 20rpx; border-top: 1rpx solid #eaeaea; display: flex; justify-content: space-between; align-items: center; box-sizing: content-box; .buttons { display: flex; & > view { width: 220rpx; text-align: center; line-height: 72rpx; font-size: 26rpx; color: #fff; border-radius: 72rpx; } .addcart { background-color: #ffa868; } .buynow { background-color: #27ba9b; margin-left: 20rpx; } } .icons { padding-right: 10rpx; display: flex; align-items: center; flex: 1; .icons-button { flex: 1; text-align: center; line-height: 1.4; padding: 0; margin: 0; border-radius: 0; font-size: 20rpx; color: #333; background-color: #fff; } text { display: block; font-size: 34rpx; } } } </style>
|
获取数据
接口地址
- 接收商品 ID,
pages/goods/goods.vue
。
1 2 3 4
| const query = defineProps<{ id: string }>()
|
- 接口封装,
services/goods.ts
。
1 2 3 4 5 6 7 8 9 10 11 12
| import { http } from '@/utils/http'
export const getGoodsByIdAPI = (id: string) => { return http({ method: 'GET', url: '/goods', data: { id }, }) }
|
- 页面中调用,
pages/goods/goods.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { getGoodsByIdAPI } from '@/services/goods' import { onLoad } from '@dcloudio/uni-app'
const { safeAreaInsets } = uni.getSystemInfoSync()
const query = defineProps<{ id: string }>()
const getGoodsByIdData = async () => { const res = await getGoodsByIdAPI(query.id) console.log(res) }
onLoad(() => { getGoodsByIdData() })
|
小结一下
详情数据渲染
基本操作
- 定义类型,
types/goods.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 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
| import type { GoodsItem } from './global'
export type GoodsResult = { id: string name: string desc: string price: number oldPrice: number details: Details mainPictures: string[] similarProducts: GoodsItem[] skus: SkuItem[] specs: SpecItem[] userAddresses: AddressItem[] }
export type Details = { properties: DetailsPropertyItem[] pictures: string[] }
export type DetailsPropertyItem = { name: string value: string }
export type SkuItem = { id: string inventory: number oldPrice: number picture: string price: number skuCode: string specs: SkuSpecItem[] }
export type SkuSpecItem = { name: string valueName: string }
export type SpecItem = { name: string values: SpecValueItem[] }
export type SpecValueItem = { available: boolean desc: string name: string picture: string }
export type AddressItem = { receiver: string contact: string provinceCode: string cityCode: string countyCode: string address: string isDefault: number id: string fullLocation: string }
|
- 给后端返回的详情数据指定类型,
services/goods.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import type { GoodsResult } from '@/types/goods' import { http } from '@/utils/http'
export const getGoodsByIdAPI = (id: string) => { return http<GoodsResult>({ method: 'GET', url: '/goods', data: { id } }) }
|
- 存储详情数据到组件中并渲染。
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
| <script setup lang="ts"> import { getGoodsByIdAPI } from '@/services/goods' import { onLoad } from '@dcloudio/uni-app' import { ref } from 'vue' import type { GoodsResult } from '@/types/goods'
const { safeAreaInsets } = uni.getSystemInfoSync()
const query = defineProps<{ id: string }>()
const goods = ref<GoodsResult>()
const getGoodsByIdData = async () => { const res = await getGoodsByIdAPI(query.id) goods.value = res.result }
onLoad(() => { getGoodsByIdData() }); </script>
<template> <scroll-view scroll-y class="viewport"> <view class="goods"> <view class="preview"> <swiper circular> <swiper-item v-for="item in goods?.mainPictures" :key="item"> <image mode="aspectFill" :src="item" /> </swiper-item> </swiper> <view class="indicator"> <text class="current">1</text> <text class="split">/</text> <text class="total">5</text> </view> </view>
<view class="meta"> <view class="price"> <text class="symbol">¥</text> <text class="number">{{ goods?.price }}</text> </view> <view class="name ellipsis">{{ goods?.name }}</view> <view class="desc">{{ goods?.desc }}</view> </view>
<view class="action"> <view class="item arrow"> <text class="label">选择</text> <text class="text ellipsis"> 请选择商品规格 </text> </view> <view class="item arrow"> <text class="label">送至</text> <text class="text ellipsis"> 请选择收获地址 </text> </view> <view class="item arrow"> <text class="label">服务</text> <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text> </view> </view> </view>
<view class="detail panel"> <view class="title"> <text>详情</text> </view> <view class="content"> <view class="properties"> <view class="item" v-for="item in goods?.details.properties" :key="item.name" > <text class="label">{{ item.name }}</text> <text class="value">{{ item.value }}</text> </view> </view> <image v-for="item in goods?.details.pictures" :key="item" mode="widthFix" :src="item" ></image> </view> </view>
<view class="similar panel"> <view class="title"> <text>同类推荐</text> </view> <view class="content"> <navigator v-for="item in goods?.similarProducts" :key="item.id" class="goods" hover-class="none" :url="`/pages/goods/goods?id=${item.id}`" > <image class="image" mode="aspectFill" :src="item.picture"></image> <view class="name ellipsis">{{ item.name }}</view> <view class="price"> <text class="symbol">¥</text> <text class="number">{{ item.price }}</text> </view> </navigator> </view> </view> </scroll-view>
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" > <view class="icons"> <button class="icons-button"><text class="icon-heart"></text>收藏</button> <button class="icons-button" open-type="contact"> <text class="icon-handset"></text>客服 </button> <navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab" > <text class="icon-cart"></text>购物车 </navigator> </view> <view class="buttons"> <view class="addcart"> 加入购物车 </view> <view class="buynow"> 立即购买 </view> </view> </view> </template>
|
小结一下
详情交互处理
轮播图
参考效果
当轮播图滑动切换的时候更新自定义下标,当图片被点击的时候大图预览。

代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13
| const currentIndex = ref(0) const onChange: UniHelper.SwiperOnChange = (ev) => { currentIndex.value = ev.detail.current }
const onTapImage = (url: string) => { uni.previewImage({ current: url, urls: goods.value!.mainPictures }) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <view class="preview"> <swiper circular @change="onChange"> <swiper-item v-for="item in goods?.mainPictures" :key="item"> <image @tap="onTapImage(item)" mode="aspectFill" :src="item" /> </swiper-item> </swiper> <view class="indicator"> <text class="current">{{ currentIndex + 1 }}</text> <text class="split">/</text> <text class="total">{{ goods?.mainPictures.length }}</text> </view> </view>
|
小结一下
弹出层
uni-ui
弹出层组件:uni-popup

基本使用
- 准备 ref 变量。
1 2 3 4
| const popup = ref<{ open: (type?: UniHelper.UniPopupType) => void close: () => void }>()
|
- 和 uni-popup 的 ref 属性进行绑定。
1 2 3 4 5
| <uni-popup ref="popup" type="bottom" background-color="#fff"> <view>内容1</view> <button @tap="popup?.close">关闭</button> </uni-popup>
|
- 点击【服务】打开弹出层。
1 2 3 4
| <view class="item arrow" @tap="popup?.open('bottom')"> <text class="label">服务</text> <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text> </view>
|
小结一下
静态结构
提供 服务说明 和 收获地址 两个组件的静态结构,实现弹出层交互。
- 服务说明,
pages/goods/components/ServicePanel.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
| <template> <view class="service-panel"> <text class="close icon-close"></text> <view class="title">服务说明</view> <view class="content"> <view class="item"> <view class="dt">无忧退货</view> <view class="dd"> 自收到商品之日起30天内,可在线申请无忧退货服务(食品等特殊商品除外) </view> </view> <view class="item"> <view class="dt">快速退款</view> <view class="dd"> 收到退货包裹并确认无误后,将在48小时内办理退款, 退款将原路返回,不同银行处理时间不同,预计1-5个工作日到账 </view> </view> <view class="item"> <view class="dt">满88元免邮费</view> <view class="dd"> 单笔订单金额(不含运费)满88元可免邮费,不满88元, 单笔订单收取10元邮费 </view> </view> </view> </view> </template>
<style lang="scss"> .service-panel { padding: 0 30rpx; border-radius: 10rpx 10rpx 0 0; position: relative; background-color: #fff; }
.title { line-height: 1; padding: 40rpx 0; text-align: center; font-size: 32rpx; font-weight: normal; border-bottom: 1rpx solid #ddd; color: #444; }
.close { position: absolute; right: 24rpx; top: 24rpx; }
.content { padding: 20rpx 20rpx 100rpx 20rpx;
.item { margin-top: 20rpx; }
.dt { margin-bottom: 10rpx; font-size: 28rpx; color: #333; font-weight: 500; position: relative;
&::before { content: ''; width: 10rpx; height: 10rpx; border-radius: 50%; background-color: #eaeaea; transform: translateY(-50%); position: absolute; top: 50%; left: -20rpx; } }
.dd { line-height: 1.6; font-size: 26rpx; color: #999; } } </style>
|
- 收获地址组件,
pages/goods/components/AddressPanel.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
| <template> <view class="address-panel"> <text class="close icon-close"></text> <view class="title">配送至</view> <view class="content"> <view class="item"> <view class="user">李明 13824686868</view> <view class="address">北京市顺义区后沙峪地区安平北街6号院</view> <text class="icon icon-checked"></text> </view> <view class="item"> <view class="user">王东 13824686868</view> <view class="address">北京市顺义区后沙峪地区安平北街6号院</view> <text class="icon icon-ring"></text> </view> <view class="item"> <view class="user">张三 13824686868</view> <view class="address">北京市朝阳区孙河安平北街6号院</view> <text class="icon icon-ring"></text> </view> </view> <view class="footer"> <view class="button primary"> 新建地址 </view> <view v-if="false" class="button primary">确定</view> </view> </view> </template>
<style lang="scss"> .address-panel { padding: 0 30rpx; border-radius: 10rpx 10rpx 0 0; position: relative; background-color: #fff; }
.title { line-height: 1; padding: 40rpx 0; text-align: center; font-size: 32rpx; font-weight: normal; border-bottom: 1rpx solid #ddd; color: #444; }
.close { position: absolute; right: 24rpx; top: 24rpx; }
.content { min-height: 300rpx; max-height: 540rpx; overflow: auto; padding: 20rpx; .item { padding: 30rpx 50rpx 30rpx 60rpx; background-size: 40rpx; background-repeat: no-repeat; background-position: 0 center; background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png); position: relative; } .icon { color: #999; font-size: 40rpx; transform: translateY(-50%); position: absolute; top: 50%; right: 0; } .icon-checked { color: #27ba9b; } .icon-ring { color: #444; } .user { font-size: 28rpx; color: #444; font-weight: 500; } .address { font-size: 26rpx; color: #666; } }
.footer { display: flex; justify-content: space-between; padding: 20rpx 0 40rpx; font-size: 28rpx; color: #444;
.button { flex: 1; height: 72rpx; text-align: center; line-height: 72rpx; margin: 0 20rpx; color: #fff; border-radius: 72rpx; }
.primary { color: #fff; background-color: #27ba9b; }
.secondary { background-color: #ffa868; } } </style>
|
- 导入组件,
pages/goods/goods.vue
。
1 2 3 4
| <uni-popup ref="popup" type="bottom" background-color="#fff"> <AddressPanel /> <ServicePanel /> </uni-popup>
|
参考实现
- 准备 popupName(用于区分展示哪一个),并准备一个方法用于修改 popupName 和控制弹框的展示。
1 2 3 4 5
| const popupName = ref<'address' | 'service'>() const openPopup = (name: typeof popupName.value) => { popupName.value = name popup.value?.open() }
|
- 点击对应按钮的时候调用 openPopup 方法。
1 2 3 4
| <view class="item arrow" @tap="openPopup('address')"> <text class="label">送至</text> <text class="text ellipsis"> 请选择收获地址 </text> </view>
|
1 2 3 4
| <view class="item arrow" @tap="openPopup('service')"> <text class="label">服务</text> <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text> </view>
|
- 弹框中根据 popupName 进行区分展示。
1 2 3 4 5
| <uni-popup ref="popup" type="bottom" background-color="#fff"> <AddressPanel v-if="popupName === 'address'" /> <ServicePanel v-if="popupName === 'service'" /> </uni-popup>
|
- 点击弹框内部关闭按钮时,关闭弹框(子调父),
pages/goods/components/AddressPanel.vue
。
1 2 3 4 5 6 7 8 9 10 11
| <script lang="ts" setup> const emit = defineEmits<{ (event: 'close'): void }>() </script> <template> <view class="address-panel"> <text class="close icon-close" @tap="emit('close')"></text> </view> </template>
|
- 父组件定义 @close 事件。
1
| <AddressPanel v-if="popupName === 'address'" @close="popup?.close()" />
|
小结一下