[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>
  1. !类型断言: 页面标题是基于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,
    })
}
  1. 联合类型: 通过组合, 产生新的类型, 完成复用

页面渲染和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>
  1. 类型属性扩展: 在TS中, 对象中未定义的属性是不能使用的, 可以使用加超过类型& 给对象扩展属性, 扩展后返回一个新类型, 如果直接使用还需要使用联合类型(), 作为整体使用
  2. 环境变量: 在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',
    })
}
  1. 通过添加编译模式, 可以快速打开需要调试的页面, 提高开发效率

二级分类和商品渲染

<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>
  1. 代码优化: 当请求的分类数据回来之前是一个空数组, 空数组访问访问元素会返回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>
  1. 在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>
  1. 在TS中通过 typeof关键字 可以把对象的属性提取出来, 作为类型使用

效果展示

相关推荐
前端小小王1 分钟前
React Hooks
前端·javascript·react.js
迷途小码农零零发10 分钟前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀33 分钟前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪1 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6413 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻3 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云3 小时前
npm淘宝镜像
前端·npm·node.js
dz88i83 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr3 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook