[uni-app]小兔鲜-04推荐+分类+详情
热门推荐
新建热门推荐组件, 动态设置组件的标题
<template>
<!-- 推荐专区 -->
<view class="panel hot">
<view class="item" v-for="item in list" :key="item.id">
... ...
<navigator hover-class="none" :url="`/pages/hot/hot?type=${item.type}`" class="cards">
<image
v-for="src in item.pictures"
:key="src"
class="image"
mode="aspectFit"
:src="src"
></image>
</navigator>
</view>
</view>
</template>
<script setup lang="ts">
// 热门推荐页 标题和url
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' },
]
// uniapp 获取页面参数
const query = defineProps<{
type: string
}>()
const currHot = hotMap.find((v) => v.type === query.type)
// 动态设置标题
uni.setNavigationBarTitle({ title: currHot!.title })
</script>
<template>
... ...
</template>
- !类型断言: 页面标题是基于query参数和hotMap数组计算出来的, 而query是页面参数, TS认为这个参数有可能是undefiend, 所以计算出来的currHot对象可能是undefined, undefined是没有title属性的, 所以TS会进行语法警告, 但是我们明确知道 query 参数不会为undefiend, 所以这里只用类型断言, 告诉TS, 这个参数不会出现问题, 让程序顺利执行
定义数据类型
/** 通用分页结果类型 */
export type PageResult<T> = {
/** 列表数据 */
items: T[]
/** 总条数 */
counts: number
/** 当前页数 */
page: number
/** 总页数 */
pages: number
/** 每页条数 */
pageSize: number
}
/** 通用分页参数类型 */
export type PageParams = {
/** 页码:默认值为 1 */
page?: number
/** 页大小:默认值为 10 */
pageSize?: number
}
/** 通用商品类型 */
export type GoodsItem = {
/** 商品描述 */
desc: string
/** 商品折扣 */
discount: number
/** id */
id: string
/** 商品名称 */
name: string
/** 商品已下单数量 */
orderNum: number
/** 商品图片 */
picture: string
/** 商品价格 */
price: number
}
import type { PageResult, GoodsItem } from './global'
/** 热门推荐 */
export type HotResult = {
/** id信息 */
id: string
/** 活动图片 */
bannerPicture: string
/** 活动标题 */
title: string
/** 子类选项 */
subTypes: SubTypeItem[]
}
/** 热门推荐-子类选项 */
export type SubTypeItem = {
/** 子类id */
id: string
/** 子类标题 */
title: string
/** 子类对应的商品集合 */
goodsItems: PageResult<GoodsItem>
}
请求接口封装
import type { PageParams } from '@/types/global'
import type { HotResult } from '@/types/hot'
import { http } from '@/utils/http'
// 通过联合类型,复用之前的类型
type HotParams = PageParams & { subType?: string }
// 通用热门推荐类型
export const getHotRcommendAPI = (url: string, data?: HotParams) => {
return http<HotResult>({
method: 'GET',
url,
data,
})
}
- 联合类型: 通过组合, 产生新的类型, 完成复用
页面渲染和Tab交互
<script setup lang="ts">
import { ref } from 'vue'
import { getHotRcommendAPI } from '@/services/hot'
import { onLoad } from '@dcloudio/uni-app'
import type { SubTypeItem } from '@/types/hot'
// 推荐封面图
const bannerPicture = ref('')
// 推荐选项
const subTypes = ref<SubTypeItem[]>([])
// tab高亮索引
const activeIndex = ref(0)
// 获取热门推荐数据
const getHotRecommendData = async () => {
const res = await getHotRcommendAPI(currHot!.url)
bannerPicture.value = res.result.bannerPicture
subTypes.value = res.result.subTypes
}
// 页面加载
onLoad(async () => {
await 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="{ active: index === activeIndex }"
@tap="activeIndex = index"
class="text"
>抢先尝鲜</text
>
</view>
<!-- 推荐列表 -->
<scroll-view
v-for="(item, index) in subTypes"
:key="item.id"
v-show="activeIndex === index"
scroll-y
class="scroll-view"
>
<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>
</scroll-view>
</view>
</template>
分页加载
<script setup lang="ts">
// 推荐封面图
const bannerPicture = ref('')
// 推荐选项
// (SubTypeItem & { finish?: boolean }) 在subTypeItem中添加finish属性,用于判断是否数据枯竭
const subTypes = ref<(SubTypeItem & { finish?: boolean })[]>([])
// tab高亮索引
const activeIndex = ref(0)
// 获取热门推荐数据
const getHotRecommendData = async () => {
const res = await getHotRcommendAPI(currHot!.url, {
// 技巧: 使用环境变量,开发环境用30页请求, 生产环境用1页请求
page: import.meta.env.MODE ? 30 : 1,
pageSize: 10,
})
bannerPicture.value = res.result.bannerPicture
subTypes.value = res.result.subTypes
}
// 触底加载
const onScrolltolower = async () => {
// 获取当前选项
const currsubType = subTypes.value[activeIndex.value]
// 分页条件
if (currsubType.goodsItems.page < currsubType.goodsItems.pages) {
// 当前页码累加
currsubType.goodsItems.page++
} else {
// 标记已结束
currsubType.finish = true
// 标记数据枯
return uni.showToast({
title: '没有更多数据了',
icon: 'none',
})
}
// 获取分页后的数据
const res = await getHotRcommendAPI(currHot!.url, {
subType: currsubType.id,
page: currsubType.goodsItems.page,
pageSize: currsubType.goodsItems.pageSize,
})
// 新的列表数据
const newsubType = res.result.subTypes[activeIndex.value]
// 数组追加
currsubType.goodsItems.items.push(...newsubType.goodsItems.items)
}
</script>
<template>
<view class="viewport">
... ...
<!-- 推荐列表 -->
<scroll-view
v-for="(item, index) in subTypes"
:key="item.id"
v-show="activeIndex === index"
scroll-y
class="scroll-view"
@scrolltolower="onScrolltolower"
>
<view class="goods">
... ...
</view>
<view class="loading-text">
{{ item.finish ? '没有更多数据了' : '正在加载...' }}
</view>
</scroll-view>
</view>
</template>
- 类型属性扩展: 在TS中, 对象中未定义的属性是不能使用的, 可以使用加超过类型& 给对象扩展属性, 扩展后返回一个新类型, 如果直接使用还需要使用联合类型(), 作为整体使用
- 环境变量: 在viet项目中, 可以使用固定语法 import.meta.env.DEV 获取当前项目所运行的环境
商品分类
复用轮播图组件
<script setup lang="ts">
import { getHomeBannerAPI } from '@/services/home'
import type { BannerItem } from '@/types/home'
import type { CategoryTopItem } from '@/types/category'
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
// 获取轮播图数据
const bannerList = ref<BannerItem[]>([])
const getBannerData = async () => {
const res = await getHomeBannerAPI(2)
bannerList.value = res.result
}
// 页面加载
onLoad(() => {
getBannerData()
})
</script>
<template>
<view class="viewport">
<!-- 分类 -->
<view class="categories">
<!-- 左侧:一级分类 -->
<scroll-view class="primary" scroll-y>
... ...
</scroll-view>
<!-- 右侧:二级分类 -->
<scroll-view class="secondary" scroll-y>
<!-- 焦点图 -->
<XtxSwiper class="banner" :list="bannerList" />
<!-- 内容区域 -->
<view class="panel" v-for="item in subCategoryList" :key="item.id">
... ...
</view>
</scroll-view>
</view>
</view>
</template>
渲染一级分类和Tab交互
<script setup lang="ts">
import { getCategoryListAPI } from '@/services/category'
import type { CategoryTopItem } from '@/types/category'
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
// 获取分类列表数据
const categoryList = ref<CategoryTopItem[]>([])
const activeIndex = ref(0)
const getCategoryTopDate = async () => {
const res = await getCategoryListAPI()
categoryList.value = res.result
}
// 页面加载
onLoad(() => {
getBannerData()
getCategoryTopDate()
})
</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 categoryList"
:key="item.id"
class="item"
:class="{ active: index === activeIndex }"
@tap="activeIndex = index"
>
<text class="name"> {{ item.name }} </text>
</view>
</scroll-view>
<!-- 右侧:二级分类 -->
<scroll-view class="secondary" scroll-y>
<!-- 焦点图 -->
<XtxSwiper class="banner" :list="bannerList" />
<!-- 内容区域 -->
<view class="panel" v-for="item in subCategoryList" :key="item.id">
... ...
</view>
</scroll-view>
</view>
</view>
</template>
import type { GoodsItem } from './global'
/** 一级分类项 */
export type CategoryTopItem = {
/** 二级分类集合[ 二级分类项 ] */
children: CategoryChildItem[]
/** 一级分类id */
id: string
/** 一级分类图片集[ 一级分类图片项 ] */
imageBanners: string[]
/** 一级分类名称 */
name: string
/** 一级分类图片 */
picture: string
}
/** 二级分类项 */
export type CategoryChildItem = {
/** 商品集合[ 商品项 ] */
goods: GoodsItem[]
/** 二级分类id */
id: string
/** 二级分类名称 */
name: string
/** 二级分类图片 */
picture: string
}
import { http } from '@/utils/http'
import type { CategoryTopItem } from '@/types/category'
// 分类列表
export const getCategoryListAPI = () => {
return http<CategoryTopItem[]>({
method: 'GET',
url: '/category/top',
})
}
- 通过添加编译模式, 可以快速打开需要调试的页面, 提高开发效率
二级分类和商品渲染
<script setup lang="ts">
import { getCategoryListAPI } from '@/services/category'
import type { CategoryTopItem } from '@/types/category'
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
// 获取分类列表数据
const categoryList = ref<CategoryTopItem[]>([])
const activeIndex = ref(0)
const getCategoryTopDate = async () => {
const res = await getCategoryListAPI()
categoryList.value = res.result
}
//计算当前二级分类数据
const subCategoryList = computed(() => {
// categoryList.value[activeIndex.value] 可能是undefind
return categoryList.value[activeIndex.value]?.children || []
})
// 页面加载
onLoad(async () => {
getBannerData(),
getCategoryTopDate()
})
</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 categoryList"
:key="item.id"
class="item"
:class="{ active: index === activeIndex }"
@tap="activeIndex = index"
>
<text class="name"> {{ item.name }} </text>
</view>
</scroll-view>
<!-- 右侧:二级分类 -->
<scroll-view class="secondary" scroll-y>
<!-- 焦点图 -->
<XtxSwiper class="banner" :list="bannerList" />
<!-- 内容区域 -->
<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>
</scroll-view>
</view>
</view>
</template>
- 代码优化: 当请求的分类数据回来之前是一个空数组, 空数组访问访问元素会返回undefiend, undefiend在取属性会报错, 所以使用安全访问符? 进行代码优化, 并且结果是undefiend时返回空数组, 提高代码健壮性
骨架屏
<script setup lang="ts">
// 数据请求完成
const isShow = ref(false)
// 页面加载
onLoad(async () => {
await Promise.all([getBannerData(), getCategoryTopDate()])
isShow.value = true
})
</script>
<template>
<view class="viewport" v-if="isShow">
... ...
</view>
<PageSkeleton v-else />
</template>
<template name="skeleton">
<view class="sk-container">
<view class="viewport">
<view class="search">
<view class="input">
<text
class="icon-search sk-transparent sk-text-14-2857-225 sk-text sk-pseudo sk-pseudo-circle"
>女靴</text
>
</view>
</view>
....
</view>
</view>
</template>
效果展示
商品详情
创建商品详情页面, 请求数据, 渲染数据
<template>
<!-- 右侧:二级分类 -->
<scroll-view class="secondary" scroll-y>
<!-- 内容区域 -->
<view class="panel">
...
<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}`"
>
... ...
</navigator>
</view>
</view>
</scroll-view>
</template>
import type { GoodsItem } from './global'
import type { AddressItem } from '@/types/address'
/** 商品信息 */
export type GoodsResult = {
/** id */
id: string
/** 商品名称 */
name: string
/** 商品描述 */
desc: string
/** 当前价格 */
price: number
/** 原价 */
oldPrice: number
/** 商品详情: 包含详情属性 + 详情图片 */
details: Details
/** 主图图片集合[ 主图图片链接 ] */
mainPictures: string[]
/** 同类商品[ 商品信息 ] */
similarProducts: GoodsItem[]
/** sku集合[ sku信息 ] */
skus: SkuItem[]
/** 可选规格集合备注[ 可选规格信息 ] */
specs: SpecItem[]
/** 用户地址列表[ 地址信息 ] */
userAddresses: AddressItem[]
}
/** 商品详情: 包含详情属性 + 详情图片 */
export type Details = {
/** 商品属性集合[ 属性信息 ] */
properties: DetailsPropertyItem[]
/** 商品详情图片集合[ 图片链接 ] */
pictures: string[]
}
/** 属性信息 */
export type DetailsPropertyItem = {
/** 属性名称 */
name: string
/** 属性值 */
value: string
}
/** sku信息 */
export type SkuItem = {
/** id */
id: string
/** 库存 */
inventory: number
/** 原价 */
oldPrice: number
/** sku图片 */
picture: string
/** 当前价格 */
price: number
/** sku编码 */
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
}
import type { GoodsResult } from '@/types/goods'
import { http } from '@/utils/http'
// 商品详情
export const getGoodsByIdApi = (id: string) => {
return http<GoodsResult>({
url: '/goods',
method: 'GET',
data: {
id,
},
})
}
轮播图交互和大图预览
<script setup lang="ts">
// 轮播图变化
const currentIndex = ref(0)
const onChange: UniHelper.SwiperOnChange = (e) => {
currentIndex.value = e.detail.current
}
// 图片点击事件
const onTapImage = (url: string) => {
// 大图预览
uni.previewImage({
current: url,
urls: goods.value?.mainPictures,
})
}
</script>
<template>
<scroll-view scroll-y class="viewport">
<!-- 基本信息 -->
<view class="goods">
<!-- 商品主图 -->
<view class="preview">
<swiper circular @change="onChange">
<swiper-item v-for="item in goods?.mainPictures" :key="item">
<image mode="aspectFill" :src="item" @tap="onTapImage(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>
... ...
</view>
...
</scroll-view>
</template>
- 在TS中事件对象也要有类型, 我们使用uniHelper提供的类型对象即可, 固定写法UniHelper.组件名On事件名
弹出层交互
// 服务说明组件
<script setup lang="ts">
// 子调父
const emit = defineEmits<{
(event: 'close'): void
}>()
</script>
<template>
<view class="service-panel">
<!-- 关闭按钮 -->
<text class="close icon-close" @tap="emit('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>
// 收货地址组件
<script setup lang="ts">
// 子调父
const emit = defineEmits<{
(event: 'close'): void
}>()
</script>
<template>
<view class="address-panel">
<!-- 关闭按钮 -->
<text class="close icon-close" @tap="emit('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>
<script setup lang="ts">
// 弹出层
const popup = ref<{
open: (type?: UniHelper.UniPopupType) => void
close: () => void
}>()
// 条件渲染弹出层
const popupName = ref<'address' | 'service'>()
const openPopop = (name: typeof popupName.value) => {
// 修改弹出层名称
popupName.value = name
popup.value?.open()
}
</script>
<template>
<scroll-view scroll-y class="viewport">
<!-- 基本信息 -->
<view class="goods">
<!-- 操作面板 -->
<view class="action">
<view class="item arrow" @tap="openSkuPopup(1)">
<text class="label">选择</text>
<text class="text ellipsis"> {{ selectArrText }} </text>
</view>
<view class="item arrow" @tap="openPopop('address')">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view class="item arrow" @tap="openPopop('service')">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
</view>
</scroll-view>
<!-- uni-ui 弹出层 -->
<uni-popup ref="popup" type="bottom" background-color="#fff">
<AddressPanel v-if="popupName === 'address'" @close="popup?.close()" />
<ServicePanel v-if="popupName === 'service'" @close="popup?.close()" />
</uni-popup>
</template>
- 在TS中通过 typeof关键字 可以把对象的属性提取出来, 作为类型使用
效果展示