黑马程序员前端项目uniapp小兔鲜儿微信小程序项目视频教程

day1

导学


创建


pages.json 和 tabBar案例


<!--src/pages/my/my.vue-->
<template>
    <view>

    </view>
</template>

<script>
    export default {
    data() {
    return {}
},
    methods: {

}
}
</script>


<style scoped lang="scss">

</style>
// src/pages.json
{
  "pages": [
    //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/my/my",
      "style": {
        "navigationBarTitleText": "我的",
        "enablePullDownRefresh": true
      }
    }
  ],
  "tabBar": {
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "static/tabs/home_default.png",
        "selectedIconPath": "static/tabs/home_selected.png"
      },
      {
        "pagePath": "pages/my/my",
        "text": "我的",
        "iconPath": "static/tabs/user_default.png",
        "selectedIconPath": "static/tabs/user_selected.png"
      }
    ],
    "color": "",
    "selectedColor": "#27BA9B",
    "backgroundColor": ""
  },
  "globalStyle": {
    //        "navigationBarTextStyle": "black",
    "navigationBarTextStyle": "white",
    "navigationBarTitleText": "uni-app",
    //        "navigationBarBackgroundColor": "#F8F8F8",
    "navigationBarBackgroundColor": "#27BA9B",
    "backgroundColor": "#F8F8F8"
  }
}

uniapp和原生微信小程序的区别


<template>
    <swiper class="banner" indicator-dots circular
    :autoplay="false">
    <swiper-item v-for="item in pictures"
    :key="item.id">
    <image
    @tap="onPreviewImage(item.url)" :src="item.url">
</image>
</swiper-item>
</swiper>
</template>

<script>
    export default {
    data() {
    return {
    title: 'Hello',
    // 轮播图数据
    pictures: [
{id: '1', url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_1.jpg"},
{id: '2', url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_2.jpg"},
{id: '3', url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_3.jpg"},
{id: '4', url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_4.jpg"},
{id: '5', url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_5.jpg"},
    ]
}
},
    onLoad() {
},
    methods: {
    // 点击事件
    onPreviewImage(url) {
    // console.log(url)
    // 预览
    // wx.previewImage({
    uni.previewImage( { // uni.支持多端
    urls: this.pictures.map(v => v.url),
    current: url
})
}
},
}
</script>

<style>
    .banner, .banner image {
    width: 750rpx;
    height: 750rpx;
}
</style>

命令行创建uni-app项目

可以用这个

vscode开发uniapp

pnpm i -D @types/wechat-miniprogram @uni-helper/uni-app-types
// tsconfig.json
{
  "extends": "@vue/tsconfig/tsconfig.json",
  "compilerOptions": {
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [
      "@dcloudio/types",
      "@types/wechat-miniprogram",
      "@uni-helper/uni-app-types"
    ]
  },
  "vueCompilerOptions": {
    "experimentalRuntimeMode": "runtime-uni-app"
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ]
}

拉取小兔鲜儿模板

git clone http://git.itcast.cn/heimaqianduan/erabbit-uni-app-vue3-ts.git heima-shop

小兔鲜儿搭建教程(在笔记)

安装uni-ui组件库

pnpm i @dcloudio/uni-ui

// pages.json
{
  "easycom": {
    "autoscan": true,
    "custom": {
      // uni-ui 规则如下配置
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
    }
  },
  // 其他内容
  pages: [
    // ...
  ]
}

uni-ui的ts类型

pnpm i @uni-helper/uni-ui-types

tsconfig.json

pinia状态持久化


持久化之后重新编译可以再次获取到

请求和上传文件拦截器

// my.vue
<script setup lang="ts">
    import {useMemberStore} from '@/stores'
    import {http} from '@/utils/http'

    const memberStore = useMemberStore()
    const getData = async () => {
    uni.request({
        method: 'GET',
        url: '/home/banner',
    })
}
</script>

<template>
    <view class="my">
        <view>会员信息:{{memberStore.profile}}</view>
        <button
        @tap="
        memberStore.setProfile({
        nickname: '黑马先锋',
        token: '123456',
    })
        "
        size="mini"
        plain
        type="primary"
        >
        保存用户信息
    </button>
    <button
    @tap="memberStore.clearProfile()" size="mini" plain type="warn">清理用户信息
</button>
<button @tap = "getData"
size = "mini"
plain
type = "primary" > 测试请求 < /button>
</view>
</template>

<style lang="scss">
    //
</style>
// src/utils/http.ts
import {useMemberStore} from '@/stores'

/**
 * 添加拦截器:
 *    拦截 request 请求
 *    拦截 uploadFile 文件上传
 *
 * TODO:
 *    1: 非 http 开头需拼接地址
 *    2: 请求超时
 *    3: 添加小程序端请求头标识
 *    4: 添加 token 请求头标识
 */

const baseURL = 'https://pcapi-xiaotuxian-front-devtest.itheima.net'

// 添加拦截器
const httpInterceptor = {
    // 拦截前触发
    invoke(options: UniApp.RequestOptions) {
        // 1: 非 http 开头需拼接地址
        if (!options.url.startsWith('http')) {
            options.url = baseURL + options.url
        }
        // 2: 请求超时, 默认60s
        options.timeout = 10000
        // 3: 添加小程序端请求头标识
        options.header = {
            // 保留原本的请求头
            ...options.header,
            'source-client': 'miniapp',
        }
        // 4: 添加 token 请求头标识
        const memberStore = useMemberStore()
        const token = memberStore.profile?.token
        if (token) {
            options.header.Authorization = token
        }
        console.log(options)
    },
}

uni.addInterceptor('request', httpInterceptor)
uni.addInterceptor('uploadFile', httpInterceptor)

请求函数封装

<!--src/pages/my/my.vue-->
<script setup lang="ts">
    import {useMemberStore} from '@/stores'
    import {http} from '@/utils/http'

    const memberStore = useMemberStore()
    const getData = async () => {
    // uni.request({
    //   method: 'GET',
    //   url: '/home/banner',
    // })
    const res = await http<string[]>({
    method: 'GET',
    url: '/home/banner',
    header: {}
})
    console.log('请求成功: ', res)
}
</script>
// src/utils/http.ts
// ...
/**
 * 请求函数
 * @param UniApp.RequestOptions
 * @returns Promise
 *  1. 返回 Promise 对象
 *  2. 请求成功
 *    2.1 提取核心数据 res.data
 *    2.2 添加类型, 支持泛型
 *  3. 请求失败
 *    3.1 网络错误 -> 提示用户换网络
 *    3.2 401错误 -> 清理用户信息, 跳转到登录页
 *    3.3 其他错误 -> 根据后端错误信息轻提示
 */
interface
Data < T > {
    code: string
    msg: string
    result: T
}

请求函数封装 - 失败情况

<!--src/pages/my/my.vue-->
<script setup lang="ts">
    import {useMemberStore} from '@/stores'
    import {http} from '@/utils/http'

    const memberStore = useMemberStore()
    const getData = async () => {
    // uni.request({
    //   method: 'GET',
    //   url: '/home/banner',
    // })
    const res = await http<string[]>({
    method: 'GET',
    // url: '/home/banner',
    // url: "/member/profile",
    url: "",
    header: {},
})
    console.log('请求成功: ', res)
}
</script>
// src/utils/http.ts
// ...
/**
 * 请求函数
 * @param UniApp.RequestOptions
 * @returns Promise
 *  1. 返回 Promise 对象
 *  2. 请求成功
 *    2.1 提取核心数据 res.data
 *    2.2 添加类型, 支持泛型
 *  3. 请求失败
 *    3.1 网络错误 -> 提示用户换网络
 *    3.2 401错误 -> 清理用户信息, 跳转到登录页
 *    3.3 其他错误 -> 根据后端错误信息轻提示
 */
interface
Data < T > {
    code: string
    msg: string
    result: T
}

// 2.2 添加类型, 支持泛型
export const http =
<
T > (options: UniApp.RequestOptions)
=>
{
    return new Promise < Data < T >> ((resolve, reject) => {
        uni.request({
            ...options,
            // 2. 请求成功
            success(res) {
                // 状态码 2xx
                if (res.statusCode >= 200 && res.statusCode < 300) {
                    // 2.1 提取核心数据 res.data
                    resolve(res.data
                    as
                    Data < T >
                ) // as 类型断言
                } else if (res.statusCode === 401) {
                    // 401错误 -> 清理用户信息, 跳转到登录页
                    const memberStore = useMemberStore()
                    memberStore.clearProfile()
                    uni.navigateTo({url: '/pages/login/login'})
                    reject(res)
                } else {
                    // 其他错误 -> 根据后端错误信息轻提示
                    uni.showToast({
                        icon: 'none',
                        title: (res.data as Data < T >).msg || '请求错误'
                })
                    reject(res)
                }
            },
            // 响应失败
            fail(err) {
                uni.showToast({
                    icon: 'none',
                    title: '网络错误, 换个网络试试'
                })
                reject(err)
            }
        })
    })
}

首页 自定义导航栏

// 新建业务组件: src/pages/index/componets/CustomNavbar.vue
<script setup lang="ts">
    //
</script>
<template>
    <view class="navbar">
        <!-- logo文字 -->
        <view class="logo">
            <image class="logo-image" src="@/static/images/logo.png"></image>
            <text class="logo-text">新鲜 · 亲民 · 快捷</text>
        </view>
        <!-- 搜索条 -->
        <view class="search">
            <text class="icon-search">搜索商品</text>
            <text class="icon-scan"></text>
        </view>
    </view>
</template>
<style lang="scss">
    /* 自定义导航条 */
    .navbar {
    background - image: url(@/static/images/navigator_bg.png);
    background-size: cover;
    position: relative;
    display: flex;
    flex-direction: column;
    padding-top: 20px;

    .logo {
    display: flex;
    align-items: center;
    height: 64rpx;
    padding-left: 30rpx;
    padding-top: 20rpx;

    .logo-image {
    width: 166rpx;
    height: 39rpx;
}

    .logo-text {
    flex: 1;
    line-height: 28rpx;
    color: #fff;
    margin: 2rpx 0 0 20rpx;
    padding-left: 20rpx;
    border-left: 1rpx solid #fff;
    font-size: 26rpx;
}
}

    .search {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 10rpx 0 26rpx;
    height: 64rpx;
    margin: 16rpx 20rpx;
    color: #fff;
    font-size: 28rpx;
    border-radius: 32rpx;
    background-color: rgba(255, 255, 255, 0.5);
}

    .icon-search {
    &::before {
    margin-right: 10rpx;
}
}

    .icon-scan {
    font-size: 30rpx;
    padding: 15rpx;
}
}
</style>

// 新建业务组件: src/pages/index/componets/CustomNavbar.vue
<script setup lang="ts">
    // 获取屏幕边界到安全区域距离
    const {safeAreaInsets} = uni.getSystemInfoSync()
    console.log(safeAreaInsets)
</script>

<template>
    <view class="navbar"
    :style="{paddingTop:safeAreaInsets?.top+'px'}">
    <!-- logo文字 -->
    <view class="logo">
        <image class="logo-image" src="@/static/images/logo.png"></image>
        <text class="logo-text">新鲜 · 亲民 · 快捷</text>
    </view>
    <!-- 搜索条 -->
    <view class="search">
        <text class="icon-search">搜索商品</text>
        <text class="icon-scan"></text>
    </view>
</view>
</template>
<style lang="scss">
    /* 自定义导航条 */
    .navbar {
    background - image: url(@/static/images/navigator_bg.png);
    background-size: cover;
    position: relative;
    display: flex;
    flex-direction: column;
    padding-top: 20px;

    .logo {
    display: flex;
    align-items: center;
    height: 64rpx;
    padding-left: 30rpx;
    padding-top: 20rpx;

    .logo-image {
    width: 166rpx;
    height: 39rpx;
}

    .logo-text {
    flex: 1;
    line-height: 28rpx;
    color: #fff;
    margin: 2rpx 0 0 20rpx;
    padding-left: 20rpx;
    border-left: 1rpx solid #fff;
    font-size: 26rpx;
}
}

    .search {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 10rpx 0 26rpx;
    height: 64rpx;
    margin: 16rpx 20rpx;
    color: #fff;
    font-size: 28rpx;
    border-radius: 32rpx;
    background-color: rgba(255, 255, 255, 0.5);
}

    .icon-search {
    &::before {
    margin-right: 10rpx;
}
}

    .icon-scan {
    font-size: 30rpx;
    padding: 15rpx;
}
}
</style>
{
  "pages": [
    //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
    {
      "path": "pages/index/index",
      "style": {
        // 去掉默认导航栏
        "navigationStyle": "custom",
        "navigationBarTextStyle": "white",
        "navigationBarTitleText": "首页"
      }
    },
    // ...
  ]
}

day2

轮播图

通用组件自动导入

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from "@/pages/index/componets/CustomNavbar.vue";
    import XtxSwiper from "@/components/XtxSwiper.vue";
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <!-- 自定义轮播图 -->
    <XtxSwiper/>
    <view class="index">index</view>
</template>

<style lang="scss">
    //
</style>
// src/pages.json
{
  // 组件自动引入规则
  "easycom": {
    "autoscan": true,
    "custom": {
      // uni-ui 规则如下配置
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
      // 自动导入src/components的组件(名称Xtx开头)
      "^Xtx(.*)": "@/components/Xtx$1.vue"
    }
  },
}
<!--静态结构: src/components/XtxSwiper.vue-->
<script setup lang="ts">
    import {ref} from 'vue'
    const activeIndex = ref(0)
</script>
<template>
    <view class="carousel">
        <swiper
        :circular="true" :autoplay="false" :interval="3000">
        <swiper-item>
            <navigator url="/pages/index/index" hover-class="none" class="navigator">
                <image
                    mode="aspectFill"
                    class="image"
                    src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_1.jpg"
                ></image>
            </navigator>
        </swiper-item>
        <swiper-item>
            <navigator url="/pages/index/index" hover-class="none" class="navigator">
                <image
                    mode="aspectFill"
                    class="image"
                    src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_2.jpg"
                ></image>
            </navigator>
        </swiper-item>
        <swiper-item>
            <navigator url="/pages/index/index" hover-class="none" class="navigator">
                <image
                    mode="aspectFill"
                    class="image"
                    src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_3.jpg"
                ></image>
            </navigator>
        </swiper-item>
    </swiper>
    <!-- 指示点 -->
    <view class="indicator">
        <text
            v-for="(item, index) in 3"
        :key="item"
        class="dot"
        :class="{active: index === activeIndex}"
        >
    </text>
</view>
</view>
</template>
<style lang="scss">
    :host {
    display: block;
    height: 280rpx;
}
    /* 轮播图 */
    .carousel {
    height: 100%;
    position: relative;
    overflow: hidden;
    transform: translateY(0);
    background-color: #efefef;
    .indicator {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 16rpx;
    display: flex;
    justify-content: center;
    .dot {
    width: 30rpx;
    height: 6rpx;
    margin: 0 8rpx;
    border-radius: 6rpx;
    background-color: rgba(255, 255, 255, 0.4);
}
    .active {
    background-color: #fff;
}
}
    .navigator,
    .image {
    width: 100%;
    height: 100%;
}
}
</style>

// src/types/components.d.ts
import XtxSwiper from './XtxSwiper.vue'

declare module 'vue' {
    export interface GlobalComponents {
        XtxSwiper: typeof XtxSwiper
    }
}

轮播图指示点

<!--静态结构: src/components/XtxSwiper.vue-->
<script setup lang="ts">
    import {ref} from 'vue'

    const activeIndex = ref(0)

    const onChange: UniHelper.SwiperOnChange = (ev) => {
    // !. 非空断言, 主动让编译器排除undefined的可能
    activeIndex.value = ev.detail!.current
}
</script>
<template>
    <view class="carousel">
        <swiper
        :circular="true" :autoplay="false" :interval="3000" @change="onChange">
    </swiper>
</view>
...
<template>

获取轮播图数据

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/componets/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI} from "@/services/home";
    import {onLoad} from "@dcloudio/uni-app";
    import {ref} from "vue";

    const bannerList = ref([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI();
    console.log(res)
    bannerList.value = res.result
}

    onLoad(() => {
    getHomeBannerData()
})
</script>
// src/services/home.ts
import {http} from "@/utils/http";

/**
 * 首页-广告区域-小程序
 * @param distributionSite 广告区域显示位置(1-首页, 2-分类商品页) 默认1
 */
export const getHomeBannerAPI = (distributionSite = 1) => {
    return http({
        method: 'GET',
        url: '/home/banner',
        data: {
            distributionSite
        }
    })
}

类型定义和渲染

<!--静态结构: src/components/XtxSwiper.vue-->
<script setup lang="ts">
    import {ref} from 'vue'
    import type {BannerItem} from "@/types/home";

    const activeIndex = ref(0)

    const onChange: UniHelper.SwiperOnChange = (ev) => {
    // !. 非空断言, 主动让编译器排除undefined的可能
    activeIndex.value = ev.detail!.current
}

    // 定义props接收
    const props = defineProps
    <
    {
    list: BannerItem[]
    }>()
</script>
<template>
    <view class="carousel">
        <swiper
        :circular="true" :autoplay="false" :interval="3000" @change="onChange">
        <swiper-item v-for="item  in list"
        :key="item.id">
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
            <image
                mode="aspectFill"
                class="image"
            :src="item.imgUrl"
            >
        </image>
    </navigator>
</swiper-item>
</swiper>
<!-- 指示点 -->
<view class="indicator">
    <text
        v-for="(item, index) in list"
    :key="item.id"
    class="dot"
    :class="{active: index === activeIndex}"
    >
</text>
</view>
</view>
</template>
<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/componets/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI} from "@/services/home";
    import {onLoad} from "@dcloudio/uni-app";
    import {ref} from "vue";
    import type {BannerItem} from "@/types/home";

    // 获取轮播图数据
    const bannerList = ref
    <BannerItem
    []>([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI();
    console.log(res)
    bannerList.value = res.result
}

    onLoad(() => {
    getHomeBannerData()
})
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList"/>
    <view class="index">index</view>
</template>
// src/services/home.ts
import {http} from "@/utils/http";
import type {BannerItem} from "@/types/home";

/**
 * 首页-广告区域-小程序
 * @param distributionSite 广告区域显示位置(1-首页, 2-分类商品页) 默认1
 */
export const getHomeBannerAPI = (distributionSite = 1) => {
    return http<BannerItem[]>({
        method: 'GET',
        url: '/home/banner',
        data: {
            distributionSite
        }
    })
}
// src/types/home.d.ts
/** 首页-广告区域数据类型 */
export type BannerItem = {
    /** 跳转链接 */
    hrefUrl: string
    /** id */
    id: string
    /** 图片链接 */
    imgUrl: string
    /** 跳转类型 */
    type: number
}

总结

前台分类

组件封装

<!--src/pages/index/components/CategoryPanel.vue-->
<script setup lang="ts">
    //
</script>
<template>
    <view class="category">
        <navigator
            class="category-item"
            hover-class="none"
            url="/pages/index/index"
            v-for="item in 10"
        :key="item"
        >
        <image
            class="icon"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/nav_icon_1.png"
        ></image>
        <text class="text">居家</text>
    </navigator>
</view>
</template>
<style lang="scss">
    /* 前台类目 */
    .category {
    margin: 20rpx 0 0;
    padding: 10rpx 0;
    display: flex;
    flex-wrap: wrap;
    min-height: 328rpx;
    .category-item {
    width: 150rpx;
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
    box-sizing: border-box;
    .icon {
    width: 100rpx;
    height: 100rpx;
}
    .text {
    font-size: 26rpx;
    color: #666;
}
}
}
</style>
<!--src/pages/index/index.vue-->
<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList" />
    <!-- 分类面板 -->
    <CategoryPanel/>
</template>

<style lang="scss">
    page {
    background - color: #f7f7f7;
}
</style>

获取数据

// src/services/home.ts
import {http} from '@/utils/http'
import type {BannerItem} from '@/types/home'

// ...

/**
 * 首页-前台分类-小程序
 */
export const getHomeCategoryAPI = () => {
    return http({
        method: 'GET',
        url: '/home/category/mutli'
    })
}
<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'

    // 获取轮播图数据
    const bannerList = ref
    <BannerItem
    []>([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI()
    console.log(res)
    bannerList.value = res.result
}

    // 获取前台分类数据
    const getHomeCategoryData = async () => {
    const res = await getHomeCategoryAPI()
    console.log(res)
}

    // 页面加载
    onLoad(() => {
    getHomeBannerData()
    getHomeCategoryData()
})
</script>

类型定义和渲染

<!--src/pages/index/components/CategoryPanel.vue-->
<script setup lang="ts">
    // 接收props
    import type {CategoryItem} from "@/types/home";

    const props = defineProps
    <
    {
    list: CategoryItem[]
    }>()
</script>
<template>
    <view class="category">
        <navigator
            class="category-item"
            hover-class="none"
            url="/pages/index/index"
            v-for="item in list"
        :key="item.id"
        >
        <image
            class="icon"
        :src="item.icon"
        >
    </image>
    <text class="text">{{item.name}}</text>
</navigator>
</view>
</template>
<style lang="scss">
    /* 前台类目 */
    .category {
    margin: 20rpx 0 0;
    padding: 10rpx 0;
    display: flex;
    flex-wrap: wrap;
    min-height: 328rpx;

    .category-item {
    width: 150rpx;
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
    box-sizing: border-box;

    .icon {
    width: 100rpx;
    height: 100rpx;
}

    .text {
    font-size: 26rpx;
    color: #666;
}
}
}
</style>
<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem, CategoryItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'

    // 获取轮播图数据
    const bannerList = ref
    <BannerItem
    []>([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI()
    console.log(res)
    bannerList.value = res.result
}

    // 获取前台分类数据
    const categoryList = ref
    <CategoryItem
    []>([])
    const getHomeCategoryData = async () => {
    const res = await getHomeCategoryAPI()
    console.log(res)
    categoryList.value = res.result
}

    // 页面加载
    onLoad(() => {
    getHomeBannerData()
    getHomeCategoryData()
})
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList" />
    <!-- 分类面板 -->
    <CategoryPanel
    :list="categoryList" />
</template>
// src/services/home.ts
import {http} from '@/utils/http'
import type {BannerItem, CategoryItem} from '@/types/home'

// ...

/**
 * 首页-前台分类-小程序
 */
export const getHomeCategoryAPI = () => {
    return http<CategoryItem[]>({
        method: 'GET',
        url: '/home/category/mutli',
    })
}
// src/types/home.d.ts
// ...
/** 首页-前台类目数据类型 */
export type CategoryItem = {
    /** 图标路径 */
    icon: string
    /** id */
    id: string
    /** 分类名称 */
    name: string
}

热门推荐

<!--src/pages/index/components/HotPanel.vue-->
<script setup lang="ts">
    // 接收props
    import type {HotItem} from "@/types/home";

    const props = defineProps
    <
    {
    list: HotItem[]
    }>()
</script>
<template>
    <!-- 推荐专区 -->
    <view class="panel hot">
        <view class="item" v-for="item in list"
        :key="item.id">
        <view class="title">
            <text class="title-text">{{item.title}}</text>
            <text class="title-desc">{{item.alt}}</text>
        </view>
        <navigator hover-class="none" url="/pages/hot/hot" class="cards">
            <image
                v-for="src in item.pictures"
            :key="src"
            class="image"
            mode="aspectFit"
            :src="src"
            >
        </image>
    </navigator>
</view>
</view>
</template>
<style lang="scss">
    /* 热门推荐 */
    .hot {
    display: flex;
    flex-wrap: wrap;
    min-height: 508rpx;
    margin: 20rpx 20rpx 0;
    border-radius: 10rpx;
    background-color: #fff;

    .title {
    display: flex;
    align-items: center;
    padding: 24rpx 24rpx 0;
    font-size: 32rpx;
    color: #262626;
    position: relative;

    .title-desc {
    font-size: 24rpx;
    color: #7f7f7f;
    margin-left: 18rpx;
}
}

    .item {
    display: flex;
    flex-direction: column;
    width: 50%;
    height: 254rpx;
    border-right: 1rpx solid #eee;
    border-top: 1rpx solid #eee;

    .title {
    justify-content: start;
}

    &:nth-child(2n) {
    border-right: 0 none;
}

    &:nth-child(-n + 2) {
    border-top: 0 none;
}

    .image {
    width: 150rpx;
    height: 150rpx;
}
}

    .cards {
    flex: 1;
    padding: 15rpx 20rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
}
}
</style>
<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI, getHomeHotAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem, CategoryItem, HotItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'
    import HotPanel from "@/pages/index/components/HotPanel.vue";

    // 获取轮播图数据
    const bannerList = ref
    <BannerItem
    []>([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI()
    bannerList.value = res.result
}

    // 获取前台分类数据
    const categoryList = ref
    <CategoryItem
    []>([])
    const getHomeCategoryData = async () => {
    const res = await getHomeCategoryAPI()
    categoryList.value = res.result
}

    // 获取热门推荐数据
    const hotList = ref
    <HotItem
    []>([])
    const getHomeHotData = async () => {
    const res = await getHomeHotAPI()
    hotList.value = res.result
}

    // 页面加载
    onLoad(() => {
    getHomeBannerData()
    getHomeCategoryData()
    getHomeHotData()
})
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList"/>
    <!-- 分类面板 -->
    <CategoryPanel
    :list="categoryList"/>
    <!-- 热门推荐 -->
    <HotPanel
    :list="hotList"/>
</template>
// src/services/home.ts
import {http} from '@/utils/http'
import type {BannerItem, CategoryItem, HotItem} from '@/types/home'

// ...

/**
 * 首页-热门推荐-小程序
 */
export const getHomeHotAPI = () => {
    return http<HotItem[]>({
        method: 'GET',
        url: '/home/hot/mutli'
    })
}
// src/types/home.d.ts
// ...
/** 首页-热门推荐数据类型 */
export type HotItem = {
    /** 说明 */
    alt: string
    /** id */
    id: string
    /** 图片集合[ 图片路径 ] */
    pictures: string[]
    /** 跳转地址 */
    target: string
    /** 标题 */
    title: string
    /** 推荐类型 */
    type: string
}

猜你喜欢

组件封装

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI, getHomeHotAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem, CategoryItem, HotItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'
    import HotPanel from "@/pages/index/components/HotPanel.vue";
    import XtxGuess from "@/components/XtxGuess.vue";

    // 获取轮播图数据
    const bannerList = ref
    <BannerItem
    []>([])
    const getHomeBannerData = async () => {
    const res = await getHomeBannerAPI()
    bannerList.value = res.result
}

    // 获取前台分类数据
    const categoryList = ref
    <CategoryItem
    []>([])
    const getHomeCategoryData = async () => {
    const res = await getHomeCategoryAPI()
    categoryList.value = res.result
}

    // 获取热门推荐数据
    const hotList = ref
    <HotItem
    []>([])
    const getHomeHotData = async () => {
    const res = await getHomeHotAPI()
    hotList.value = res.result
}

    // 页面加载
    onLoad(() => {
    getHomeBannerData()
    getHomeCategoryData()
    getHomeHotData()
})
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <scroll-view class="scroll-view" scroll-y>
        <!-- 自定义轮播图 -->
        <XtxSwiper
        :list="bannerList"/>
        <!-- 分类面板 -->
        <CategoryPanel
        :list="categoryList"/>
        <!-- 热门推荐 -->
        <HotPanel
        :list="hotList"/>
        <!-- 猜你喜欢 -->
        <XtxGuess/>
    </scroll-view>
</template>

<style lang="scss">
    page {
    background - color: #f7f7f7;
    height: 100%;
    display: flex;
    flex-direction: column;
}

    .scroll-view {
    // 占满剩余高度
    flex: 1;
}
</style>
// src/types/components.d.ts
import XtxSwiper from '../components/XtxSwiper.vue'
import XtxGuess from "@/components/XtxGuess.vue";

declare module 'vue' {
    export interface GlobalComponents {
        XtxSwiper: typeof XtxSwiper,
        XtxGuess: typeof XtxGuess
    }
}
<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    //
</script>
<template>
    <!-- 猜你喜欢 -->
    <view class="caption">
        <text class="text">猜你喜欢</text>
    </view>
    <view class="guess">
        <navigator
            class="guess-item"
            v-for="item in 10"
        :key="item"
        :url="`/pages/goods/goods?id=4007498`"
        >
        <image
            class="image"
            mode="aspectFill"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_big_1.jpg"
        ></image>
        <view class="name">德国THORE男表 超薄手表男士休闲简约夜光石英防水直径40毫米</view>
        <view class="price">
            <text class="small">¥</text>
            <text>899.00</text>
        </view>
    </navigator>
</view>
<view class="loading-text">正在加载...</view>
</template>
<style lang="scss">
    :host {
    display: block;
}
    /* 分类标题 */
    .caption {
    display: flex;
    justify-content: center;
    line-height: 1;
    padding: 36rpx 0 40rpx;
    font-size: 32rpx;
    color: #262626;
    .text {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 0 28rpx 0 30rpx;
    &::before,
    &::after {
    content: '';
    width: 20rpx;
    height: 20rpx;
    background-image: url(@/static/images/bubble.png);
    background-size: contain;
    margin: 0 10rpx;
}
}
}
    /* 猜你喜欢 */
    .guess {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    padding: 0 20rpx;
    .guess-item {
    width: 345rpx;
    padding: 24rpx 20rpx 20rpx;
    margin-bottom: 20rpx;
    border-radius: 10rpx;
    overflow: hidden;
    background-color: #fff;
}
    .image {
    width: 304rpx;
    height: 304rpx;
}
    .name {
    height: 75rpx;
    margin: 10rpx 0;
    font-size: 26rpx;
    color: #262626;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}
    .price {
    line-height: 1;
    padding-top: 4rpx;
    color: #cf4444;
    font-size: 26rpx;
}
    .small {
    font-size: 80%;
}
}
    // 加载提示文字
    .loading-text {
    text - align: center;
    font-size: 28rpx;
    color: #666;
    padding: 20rpx 0;
}
</style>

获取数据

<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from "@/services/home";
    import {onMounted} from "vue"

    // 获取猜你喜欢数据
    const getHomeGoodsGuessLikeData = async () => {
    const res = await getHomeGoodsGuessLikeAPI()
    console.log(res)
}

    // 组件挂载完毕
    onMounted(() => {
    getHomeGoodsGuessLikeData()
})
</script>
// src/services/home.ts
import {http} from '@/utils/http'
import type {BannerItem, CategoryItem, HotItem} from '@/types/home'
// ...
/**
 * 猜你喜欢-小程序
 */
export const getHomeGoodsGuessLikeAPI = () => {
    return http({
        method: 'GET',
        url: '/home/goods/guessLike'
    })
}

类型定义 和 列表渲染

<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from "@/services/home";
    import {onMounted, ref} from "vue"
    import type {GuessItem} from "@/types/home";

    // 获取猜你喜欢数据
    const guessList = ref
    <GuessItem
    []>([])
    const getHomeGoodsGuessLikeData = async () => {
    const res = await getHomeGoodsGuessLikeAPI()
    guessList.value = res.result.items
}

    // 组件挂载完毕
    onMounted(() => {
    getHomeGoodsGuessLikeData()
})
</script>
<template>
    <!-- 猜你喜欢 -->
    <view class="caption">
        <text class="text">猜你喜欢</text>
    </view>
    <view class="guess">
        <navigator
            class="guess-item"
            v-for="item in guessList"
        :key="item.id"
        :url="`/pages/goods/goods?id=4007498`"
        >
        <image
            class="image"
            mode="aspectFill"
        :src="item.picture"
        >
    </image>
    <view class="name">{{item.name}}</view>
    <view class="price">
        <text class="small">¥</text>
        <text>{{item.price}}</text>
    </view>
</navigator>
</view>
<view class="loading-text">正在加载...</view>
</template>
// src/types/global.d.ts
/** 通用分页结果类型 */
export type PageResult<T> = {
    /** 列表数据 */
    items: T[]
    /** 总条数 */
    counts: number
    /** 当前页数 */
    page: number
    /** 总页数 */
    pages: number
    /** 每页条数 */
    pageSize: number
}
// src/types/home.d.ts
/** 猜你喜欢-商品类型 */
export type GuessItem = {
    /** 商品描述 */
    desc: string
    /** 商品折扣 */
    discount: number
    /** id */
    id: string
    /** 商品名称 */
    name: string
    /** 商品已下单数量 */
    orderNum: number
    /** 商品图片 */
    picture: string
    /** 商品价格 */
    price: number
}
// src/services/home.ts
import {http} from '@/utils/http'
import type {BannerItem, CategoryItem, GuessItem, HotItem} from '@/types/home'
import type {PageResult} from '@/types/global'

// ...
/**
 * 猜你喜欢-小程序
 */
export const getHomeGoodsGuessLikeAPI = () => {
    return http<PageResult<GuessItem>>({
        method: 'GET',
        url: '/home/goods/guessLike',
    })
}

分页准备

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI, getHomeHotAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem, CategoryItem, HotItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'
    import HotPanel from '@/pages/index/components/HotPanel.vue'
    import XtxGuess from '@/components/XtxGuess.vue'
    import type {XtxGuessInstance} from "@/types/component";

    // ...

    // 获取猜你喜欢组件实例
    const guessRef = ref
    <XtxGuessInstance>()
        // 滚动触底
        const onScrollToLower = () => {
            // console.log('触底')
            guessRef.value?.getMore()
        }
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <scroll-view class="scroll-view"
    @scrolltolower="onScrollToLower" scroll-y>
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList"/>
    <!-- 分类面板 -->
    <CategoryPanel
    :list="categoryList"/>
    <!-- 热门推荐 -->
    <HotPanel
    :list="hotList"/>
    <!-- 猜你喜欢 -->
    <XtxGuess ref="guessRef"/>
</scroll-view>
</template>
<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from "@/services/home";
    import {onMounted, ref} from "vue"
    import type {GuessItem} from "@/types/home";

    // ...

    // 暴露方法给外部
    defineExpose({
    getMore: getHomeGoodsGuessLikeData
})
</script>
/**
 * declare module '@vue/runtime-core'
 *   现调整为
 * declare module 'vue'
 */
import 'vue'
import XtxSwiper from '../components/XtxSwiper.vue'
import XtxGuess from '@/components/XtxGuess.vue'

declare module 'vue' {
    export interface GlobalComponents {
        XtxSwiper: typeof XtxSwiper
        XtxGuess: typeof XtxGuess
    }
}

// 组件实例类型
export type XtxGuessInstance = InstanceType<typeof XtxGuess>

分页加载

// src/types/global.d.ts
/** 通用分页参数类型 */
export type PageParams = {
    /** 页码:默认值为 1 */
    page?: number
    /** 页大小:默认值为 10 */
    pageSize?: number
}
<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from "@/services/home";
    import {onMounted, ref} from "vue"
    import type {GuessItem} from "@/types/home";
    import type {PageParams} from "@/types/global";

    // 分页参数
    const pageParams: Required
    <PageParams>= {
        page: 1,
        pageSize: 10
    }
        // 获取猜你喜欢数据
        const guessList = ref
        <GuessItem
        []>([])
        const getHomeGoodsGuessLikeData = async () => {
            const res = await getHomeGoodsGuessLikeAPI(pageParams)
            // guessList.value = res.result.items
            // 改为 数组追加
            guessList.value.push(...res.result.items)
            // 页码累加
            pageParams.page++
        }

        // ...
</script>
// src/services/home.ts
// ...
/**
 * 猜你喜欢-小程序
 */
export const getHomeGoodsGuessLikeAPI = (data?: PageParams) => {
    return http<PageResult<GuessItem>>({
        method: 'GET',
        url: '/home/goods/guessLike',
        data
    })
}

分页条件

<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from "@/services/home";
    import {onMounted, ref} from "vue"
    import type {GuessItem} from "@/types/home";
    import type {PageParams} from "@/types/global";

    // 分页参数
    const pageParams: Required
    <PageParams>= {
        page: 1,
        pageSize: 10
    }
        // 获取猜你喜欢数据
        const guessList = ref
        <GuessItem
        []>([])
        // 已结束标记
        const finish = ref(false)
        const getHomeGoodsGuessLikeData = async () => {
            // 退出判断
            if (finish.value) {
            return uni.showToast({icon: 'none', title: '没有更多数据了'})
        }
            const res = await getHomeGoodsGuessLikeAPI(pageParams)
            // guessList.value = res.result.items
            // 改为 数组追加
            guessList.value.push(...res.result.items)
            // 没到最后一页才累加
            if (pageParams.page < res.result.pages) {
            // 页码累加
            pageParams.page++
        } else {
            finish.value = true
        }
        }

        // 组件挂载完毕
        onMounted(() => {
            getHomeGoodsGuessLikeData()
        })

        // 暴露方法给外部
        defineExpose({
            getMore: getHomeGoodsGuessLikeData
        })
</script>
<template>
    <!-- 猜你喜欢 -->
    <view class="caption">
        <text class="text">猜你喜欢</text>
    </view>
    <view class="guess">
        <navigator
            class="guess-item"
            v-for="item in guessList"
        :key="item.id"
        :url="`/pages/goods/goods?id=4007498`"
        >
        <image
            class="image"
            mode="aspectFill"
        :src="item.picture"
        >
    </image>
    <view class="name">{{item.name}}</view>
    <view class="price">
        <text class="small">¥</text>
        <text>{{item.price}}</text>
    </view>
</navigator>
</view>
<view class="loading-text">{{finish ? '没有更多了' : '正在加载...'}}</view>
</template>

下拉刷新

下拉刷新

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    import CustomNavbar from '@/pages/index/components/CustomNavbar.vue'
    import XtxSwiper from '@/components/XtxSwiper.vue'
    import {getHomeBannerAPI, getHomeCategoryAPI, getHomeHotAPI} from '@/services/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {ref} from 'vue'
    import type {BannerItem, CategoryItem, HotItem} from '@/types/home'
    import CategoryPanel from '@/pages/index/components/CategoryPanel.vue'
    import HotPanel from '@/pages/index/components/HotPanel.vue'
    import XtxGuess from '@/components/XtxGuess.vue'
    import type {XtxGuessInstance} from '@/types/component'

    // ...

    // 切换刷新动画开关
    const isTriggered = ref(false)
    // 下拉刷新触发
    const onRefresherrefresh = async () => {
    // 开启动画
    isTriggered.value = true
    // await getHomeBannerData()
    // await getHomeCategoryData()
    // await getHomeHotData()
    // 并行加载
    await Promise.all([getHomeBannerData(), getHomeCategoryData(), getHomeHotData()])
    // 关闭动画
    isTriggered.value = false
}
</script>

<template>
    <!-- 自定义导航栏 -->
    <CustomNavbar/>
    <scroll-view
        refresher-enabled
    @refresherrefresh="onRefresherrefresh"
    :refresher-triggered="isTriggered"
    class="scroll-view"
    @scrolltolower="onScrollToLower"
    scroll-y
    >
    <!-- 自定义轮播图 -->
    <XtxSwiper
    :list="bannerList" />
    <!-- 分类面板 -->
    <CategoryPanel
    :list="categoryList" />
    <!-- 热门推荐 -->
    <HotPanel
    :list="hotList" />
    <!-- 猜你喜欢 -->
    <XtxGuess ref="guessRef"/>
</scroll-view>
</template>

猜你喜欢数据

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    // ...

    // 切换刷新动画开关
    const isTriggered = ref(false)
    // 下拉刷新触发
    const onRefresherrefresh = async () => {
    // 开启动画
    isTriggered.value = true
    // await getHomeBannerData()
    // await getHomeCategoryData()
    // await getHomeHotData()
    // 重置猜你喜欢的数据
    guessRef.value?.resetData()
    // 并行加载
    await Promise.all([
    getHomeBannerData(), getHomeCategoryData(),
    getHomeHotData(), guessRef.value?.getMore()
    ])
    // 关闭动画
    isTriggered.value = false
}
</script>
<!--src/components/XtxGuess.vue-->
<script setup lang="ts">
    import {getHomeGoodsGuessLikeAPI} from '@/services/home'
    import {onMounted, ref} from 'vue'
    import type {GuessItem} from '@/types/home'
    import type {PageParams} from '@/types/global'

    // 分页参数
    const pageParams: Required
    <PageParams>= {
        page: 1,
        pageSize: 10,
    }
        // 重置数据的方法
        const resetData = () => {
            pageParams.page = 1
            guessList.value = []
            finish.value = false
        }
        // ...

        // 暴露方法给外部
        defineExpose({
            resetData,
            getMore: getHomeGoodsGuessLikeData,
        })
</script>

day3

首页 - 骨架屏

复制生成的骨架文件, 复制到vue文件

<!--src/pages/index/index.vue-->
<script setup lang="ts">
    // ...
import PageSkeleton from "@/pages/index/components/PageSkeleton.vue";

    // ...

// 是否加载中标记
const isLoading = ref(false)

// 页面加载
onLoad(async () => {
  isLoading.value = true
  await Promise.all([
    getHomeBannerData(),
    getHomeCategoryData(),
    getHomeHotData()
  ])
  isLoading.value = false
})

    // ...
    
</script>

<template>
  <!-- 自定义导航栏 -->
  <CustomNavbar/>
  <scroll-view
    refresher-enabled
    @refresherrefresh="onRefresherrefresh"
    :refresher-triggered="isTriggered"
    class="scroll-view"
    @scrolltolower="onScrollToLower"
    scroll-y
  >
    <!--  数据未加载时显示: 骨架屏  -->
    <PageSkeleton v-if="isLoading"/>
    <template v-else>
      <!-- 自定义轮播图 -->
      <XtxSwiper :list="bannerList"/>
      <!-- 分类面板 -->
      <CategoryPanel :list="categoryList"/>
      <!-- 热门推荐 -->
      <HotPanel :list="hotList"/>
      <!-- 猜你喜欢 -->
      <XtxGuess ref="guessRef"/>
    </template>
  </scroll-view>
</template>

// ...
<!--src/pages/index/components/PageSkeleton.vue-->
<template>
    <view class="sk-container">
        <scroll-view :refresher-enabled="true" :scroll-y="true" class="scroll-view scroll-view">
        <view is="components/XtxSwiper">
            <view class="carousel XtxSwiper--carousel">
                <swiper :circular="true" :interval="3000" :current="0" :autoplay="false">
                <swiper-item style="position: absolute; width: 100%; height: 100%; transform: translate(0%, 0px) translateZ(0px);">
                    <navigator class="navigator XtxSwiper--navigator" hover-class="none">
                        <image class="image XtxSwiper--image sk-image" mode="aspectFill"></image>
                    </navigator>
                </swiper-item>
            </swiper>
            <view class="indicator XtxSwiper--indicator">
                <text class="dot XtxSwiper--dot active XtxSwiper--active"></text>
                <text class="dot XtxSwiper--dot"></text>
                <text class="dot XtxSwiper--dot"></text>
                <text class="dot XtxSwiper--dot"></text>
                <text class="dot XtxSwiper--dot"></text>
            </view>
        </view>
    </view>
    <view is="pages/index/components/CategoryPanel">
        <view class="category CategoryPanel--category">
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-979 sk-text">居家</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-430 sk-text">锦鲤</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-352 sk-text">服饰</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-550 sk-text">母婴</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-772 sk-text">个护</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-247 sk-text">严选</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-512 sk-text">数码</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-956 sk-text">运动</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-19 sk-text">杂项</text>
            </navigator>
            <navigator class="category-item CategoryPanel--category-item" hover-class="none">
                <image class="icon CategoryPanel--icon sk-image"></image>
                <text class="text CategoryPanel--text sk-transparent sk-text-14-2857-105 sk-text">品牌</text>
            </navigator>
        </view>
    </view>
    <view is="pages/index/components/HotPanel">
        <view class="panel HotPanel--panel hot HotPanel--hot">
            <view class="item HotPanel--item">
                <view class="title HotPanel--title">
                    <text class="title-text HotPanel--title-text sk-transparent sk-text-14-2857-278 sk-text">特惠推荐</text>
                    <text class="title-desc HotPanel--title-desc sk-transparent sk-text-14-2857-515 sk-text">精选全攻略</text>
                </view>
                <navigator class="cards HotPanel--cards" hover-class="none">
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                </navigator>
            </view>
            <view class="item HotPanel--item">
                <view class="title HotPanel--title">
                    <text class="title-text HotPanel--title-text sk-transparent sk-text-14-2857-463 sk-text">爆款推荐</text>
                    <text class="title-desc HotPanel--title-desc sk-transparent sk-text-14-2857-998 sk-text">最受欢迎</text>
                </view>
                <navigator class="cards HotPanel--cards" hover-class="none">
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                </navigator>
            </view>
            <view class="item HotPanel--item">
                <view class="title HotPanel--title">
                    <text class="title-text HotPanel--title-text sk-transparent sk-text-14-2857-982 sk-text">一站买全</text>
                    <text class="title-desc HotPanel--title-desc sk-transparent sk-text-14-2857-757 sk-text">精心优选</text>
                </view>
                <navigator class="cards HotPanel--cards" hover-class="none">
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                </navigator>
            </view>
            <view class="item HotPanel--item">
                <view class="title HotPanel--title">
                    <text class="title-text HotPanel--title-text sk-transparent sk-text-14-2857-722 sk-text">新鲜好物</text>
                    <text class="title-desc HotPanel--title-desc sk-transparent sk-text-14-2857-632 sk-text">生活加分项</text>
                </view>
                <navigator class="cards HotPanel--cards" hover-class="none">
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                    <image class="image HotPanel--image sk-image" mode="aspectFit"></image>
                </navigator>
            </view>
        </view>
    </view>
</scroll-view>
</view>
</template>

热门推荐

准备工作

<!--src/pages/index/components/HotPanel.vue-->
<script setup lang="ts">
//  
</script>
<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> 
<!--src/pages/hot/hot.vue-->
<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 获取url页面参数
    const query = defineProps
    <
    {
    type: string
    }>()

    const curHotMap = hotMap.find(v => v.type === query.type)
    // 动态设置标题
    uni.setNavigationBarTitle({title: curHotMap!.title})
</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>

获取数据

<!--src/pages/hot/hot.vue-->
<script setup lang="ts">
    // ...

    // 获取热门推荐数据
    const getHotRecommendData = async () => {
        const res = await getHotRecommendAPI(curHotMap
        !
    .
        url
    )
        console.log(res)
    }

    // 页面加载
    onLoad(() => {
        getHotRecommendData()
    })
</script>
// src/services/hot.ts
import {http} from "@/utils/http";
import type {PageParams} from "@/types/global";

// & 将类型扩展
type HotParams = PageParams & { subType: string }

/**
 * 通用热门推荐类型
 * @param url 请求地址
 * @param data 请求参数
 */
export const getHotRecommendAPI = (
    url: string,
    data?: HotParams
) => {
    return http({
        method: 'GET',
        url, data
    })
}

类型定义

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>
}
// src/types/home.d.ts
import type {GoodsItem} from '@/types/global'

/** 猜你喜欢-商品类型 */
export type GuessItem = GoodsItem

// ...
// src/services/hot.ts
import {http} from '@/utils/http'
import type {PageParams} from '@/types/global'
import type {HotResult} from '@/types/hot'

// & 将类型扩展
type HotParams = PageParams & { subType: string }

/**
 * 通用热门推荐类型
 * @param url 请求地址
 * @param data 请求参数
 */
export const getHotRecommendAPI = (url: string, data?: HotParams) => {
    return http<HotResult>({
        method: 'GET',
        url,
        data,
    })
}
// src/types/global.d.ts
// ...

/** 通用商品类型 */
export type GoodsItem = {
    /** 商品描述 */
    desc: string
    /** 商品折扣 */
    discount: number
    /** id */
    id: string
    /** 商品名称 */
    name: string
    /** 商品已下单数量 */
    orderNum: number
    /** 商品图片 */
    picture: string
    /** 商品价格 */
    price: number
}

页面渲染和tab交互

<!--src/pages/hot/hot.vue-->
<script setup lang="ts">
    ///

    // 推荐封面图
    const bannerPicture = ref('')
    // 推荐选项
    const subTypes = ref < SubTypeItem[] > ([])
    // 当前tab索引
    const activeIndex = ref(0)

    // 获取热门推荐数据
    const getHotRecommendData = async () => {
        const res = await getHotRecommendAPI(curHotMap
        !
    .
        url
    )
        // console.log(res)
        bannerPicture.value = res.result.bannerPicture
        subTypes.value = res.result.subTypes
    }

    // 页面加载
    onLoad(() => {
        getHotRecommendData()
    })
</script>
<template>
    <view class="viewport">
        <!-- 推荐封面图 -->
        <view class="cover">
            <image :src="bannerPicture"></image>
        </view>
        <!-- 推荐选项 -->
        <view class="tabs">
            <text v-for="(item,idx) in subTypes"
                  :key="item.id" class="text"
                  @tap="activeIndex = idx"
                  :class="{active: activeIndex === idx }"
            >{{ item.title }}
            </text>
        </view>
        <!--
          推荐列表
          v-show是样式的显示隐藏, 适合多次展示和隐藏的组件
         -->
        <scroll-view v-for="(item,idx) in subTypes" v-show="activeIndex===idx" :key="item.id" 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>
            <view class="loading-text">正在加载...</view>
        </scroll-view>
    </view>
</template>

分页加载

<!--src/pages/hot/hot.vue-->
<script setup lang="ts">
    ///

    // 滚动触底
    const onScrollToLower = async () => {
        // 获取当前选项
        const curSubTypes = subTypes.value[activeIndex.value]
        // 当前页码累加
        curSubTypes.goodsItems.page++
        // 调用API
        const res = await getHotRecommendAPI(curHotMap
        !
    .
        url, {
            subType: curSubTypes.id,
            page: curSubTypes.goodsItems.page,
            pageSize: curSubTypes.goodsItems.pageSize,
        }
    )
        // 新的列表项
        const newSubTypes = res.result.subTypes[activeIndex.value]
        // 数组追加
        curSubTypes.goodsItems.items.push(...newSubTypes.goodsItems.items)
    }
</script>
<template>
    <view class="viewport">
        <!-- 推荐封面图 -->
        ///
        <!-- 推荐选项 -->
        ///
        <!--
          推荐列表
          v-show是样式的显示隐藏, 适合多次展示和隐藏的组件
         -->
        <scroll-view
                v-for="(item, idx) in subTypes"
                v-show="activeIndex === idx"
                :key="item.id"
                @scrolltolower="onScrollToLower"
                scroll-y
                class="scroll-view"
        >
            ///
        </scroll-view>
    </view>
</template>

分页条件

<!--src/pages/hot/hot.vue-->
<script setup lang="ts">
    // ...

    // 推荐封面图
    const bannerPicture = ref('')
    // 推荐选项
    const subTypes = ref < (SubTypeItem & {finish? : boolean})[] > ([])
    // 当前tab索引
    const activeIndex = ref(0)

    // 获取热门推荐数据
    const getHotRecommendData = async () => {
        // const res = await getHotRecommendAPI(curHotMap!.url)
        const res = await getHotRecommendAPI(curHotMap
        !
    .
        url, {
            // 从30页开始, 方便测试
            // 可通过环境变量来修改
            page: import.meta.env.DEV ? 30 : 1,
            pageSize: 10,
        }
    )
        // console.log(res)
        bannerPicture.value = res.result.bannerPicture
        subTypes.value = res.result.subTypes
    }

    // 页面加载 ...

    // 滚动触底
    const onScrollToLower = async () => {
        // 获取当前选项
        const curSubTypes = subTypes.value[activeIndex.value]
        // 分页条件
        if (curSubTypes.goodsItems.page < curSubTypes.goodsItems.pages) {
            // 当前页码累加
            curSubTypes.goodsItems.page++
        } else {
            // 标记已结束
            curSubTypes.finish = true
            // 退出并轻提示
            return uni.showToast({title: '没有更多了', icon: 'none'})
        }
        // ...
    }
</script>
<template>
    <view class="viewport">
        <!-- ... -->
        <!--
          推荐列表
          v-show是样式的显示隐藏, 适合多次展示和隐藏的组件
         -->
        <scroll-view
                v-for="(item, idx) in subTypes"
                v-show="activeIndex === idx"
                :key="item.id"
                @scrolltolower="onScrollToLower"
                scroll-y
                class="scroll-view"
        >
            ...
            <view class="loading-text"> {{ item.finish ? '没有更多数据了' : '正在加载...' }}</view>
        </scroll-view>
    </view>
</template> 

商品分类

准备工作

<!--src/pages/category/category.vue-->
<script setup lang="ts">
    // 获取轮播图数据
    import {getHomeBannerAPI} from "@/services/home";
    import {ref} from "vue"; // [vite]: Rollup failed to resolve import "@vue/reactivity"
    import type {BannerItem} from "@/types/home";
    import {onLoad} from "@dcloudio/uni-app";

    const bannerList = ref < BannerItem[] > ([])
    const getBannerData = async () => {
        const res = await getHomeBannerAPI(2) // 2-分类商品页
        bannerList.value = res.result
    }

    // 页面加载
    onLoad(() => {
        getBannerData()
    })
</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="bannerList"/>
                <!-- 内容区域 -->
                <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>

渲染一级分类和tab交互

// src/services/category.ts
import {http} from '@/utils/http'
import type {CategoryTopItem} from "@/types/category";

/**
 * 分类列表-小程序
 */
export const getCategoryTopAPI = () => {
    return http<CategoryTopItem[]>({
        method: 'GET',
        url: '/category/top',
    })
}
// src/types/category.d.ts
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
}
<!--src/pages/category/category.vue-->
<script setup lang="ts">
    // 获取轮播图数据
    import {getHomeBannerAPI} from "@/services/home";
    import {ref} from "vue"; // [vite]: Rollup failed to resolve import "@vue/reactivity"
    import type {BannerItem} from "@/types/home";
    import {onLoad} from "@dcloudio/uni-app";
    import {getCategoryTopAPI} from "@/services/category";
    import type {CategoryTopItem} from "@/types/category";

    // 获取轮播图数据 

    // 获取分类列表数据
    const categoryList = ref < CategoryTopItem[] > ([])
    const activeIndex = ref(0) // 当前选中的tab索引
    const getCategoryTopData = async () => {
        const res = await getCategoryTopAPI()
        categoryList.value = res.result
    }

    // 页面加载
    onLoad(() => {
        getBannerData()
        getCategoryTopData()
    })
</script>

<template>
    <view class="viewport">
        <!-- 搜索框 -->
        <!-- 分类 -->
        <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>
            <!-- 右侧:二级分类 -->
        </view>
    </view>
</template> 

渲染二级分类和商品

<!--src/pages/category/category.vue-->
<script setup lang="ts">
    // ...

    // 提取当前二级分类数据
    const subCategoryList = computed(() => {
        return categoryList.value[activeIndex.value]?.children || []
    })
</script>

<template>
    <view class="viewport">
        <!-- 搜索框 -->
        <!-- 分类 -->
        <view class="categories">
            <!-- 左侧:一级分类 -->
            <!-- 右侧:二级分类 -->
            <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> 

骨架屏

<!--src/pages/category/components/PageSkeleton.vue-->
<template name="skeleton">
    <view class="sk-container">
        <view class="viewport viewport">
            <view class="search search">
                <view class="input input">
                    <text class="icon-search sk-transparent sk-text-14-2857-26 sk-text sk-pseudo sk-pseudo-circle">
                        女靴
                    </text>
                </view>
            </view>
            <view class="categories categories">
                <scroll-view :scroll-y="true" class="primary primary">
                    <view class="item active sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-864 sk-text">居家</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-287 sk-text">美食</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-139 sk-text">服饰</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-396 sk-text">母婴</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-87 sk-text">个护</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-719 sk-text">严选</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-928 sk-text">数码</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-993 sk-text">运动</text>
                    </view>
                    <view class="item sk-pseudo sk-pseudo-circle">
                        <text class="name sk-transparent sk-text-14-2857-593 sk-text">杂项</text>
                    </view>
                </scroll-view>
                <scroll-view :scroll-y="true" class="secondary secondary">
                    <view is="components/XtxSwiper" class="banner banner">
                        <view class="carousel XtxSwiper--carousel">
                            <swiper :circular="true" :interval="3000" :current="0" :autoplay="false">
                                <swiper-item
                                        style="position: absolute; width: 100%; height: 100%; transform: translate(0%, 0px) translateZ(0px);">
                                    <navigator class="navigator XtxSwiper--navigator" hover-class="none">
                                        <image class="image XtxSwiper--image sk-image" mode="aspectFill"></image>
                                    </navigator>
                                </swiper-item>
                                <swiper-item
                                        style="position: absolute; width: 100%; height: 100%; transform: translate(100%, 0px) translateZ(0px);">
                                    <navigator class="navigator XtxSwiper--navigator" hover-class="none">
                                        <image class="image XtxSwiper--image sk-image" mode="aspectFill"></image>
                                    </navigator>
                                </swiper-item>
                            </swiper>
                            <view class="indicator XtxSwiper--indicator">
                                <text class="dot XtxSwiper--dot active XtxSwiper--active"></text>
                                <text class="dot XtxSwiper--dot"></text>
                                <text class="dot XtxSwiper--dot"></text>
                                <text class="dot XtxSwiper--dot"></text>
                                <text class="dot XtxSwiper--dot"></text>
                            </view>
                        </view>
                    </view>
                    <view class="panel panel">
                        <view class="title title">
                            <text class="name sk-transparent sk-text-26-7857-722 sk-text">居家生活用品</text>
                            <navigator
                                    class="more sk-transparent sk-text-30-3571-467 sk-text sk-pseudo sk-pseudo-circle"
                                    hover-class="none">全部
                            </navigator>
                        </view>
                        <view class="section section">
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-58 sk-text">
                                    梅乃宿梅酒720毫升
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-243 sk-text">168.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-711 sk-text">
                                    法国年份雅文邑700毫升
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-807 sk-text">1480.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-886 sk-text">
                                    多米尼加陈年朗姆酒700毫升
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-231 sk-text">238.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-637 sk-text">
                                    川味牛肉辣椒酱190克
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-834 sk-text">38.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-778 sk-text">全新升级,四川酸辣粉195克*6杯
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-90 sk-text">69.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                                <view class="name ellipsis sk-transparent sk-text-14-2857-342 sk-text">极光限定
                                    珠光蓝珐琅锅
                                </view>
                                <view class="price price">
                                    <text class="symbol sk-transparent sk-opacity">¥</text>
                                    <text class="number sk-transparent sk-text-14-2857-25 sk-text">199.00</text>
                                </view>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                            </navigator>
                            <navigator class="goods goods" hover-class="none">
                                <image class="image sk-image"></image>
                            </navigator>
                        </view>
                    </view>
                </scroll-view>
            </view>
        </view>
    </view>
</template>
<style>
    .sk-transparent {
        color: transparent !important;
    }

    .sk-text-14-2857-26 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 37.9167rpx;
        position: relative !important;
    }

    .sk-text {
        background-origin: content-box !important;
        background-clip: content-box !important;
        background-color: transparent !important;
        color: transparent !important;
        background-repeat: repeat-y !important;
    }

    .sk-text-14-2857-864 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-287 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-139 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-396 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-87 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-719 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-928 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-993 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-14-2857-593 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 35.0000rpx;
        position: relative !important;
    }

    .sk-text-26-7857-722 {
        background-image: linear-gradient(transparent 26.7857%, #EEEEEE 0%, #EEEEEE 73.2143%, transparent 0%) !important;
        background-size: 100% 58.3333rpx;
        position: relative !important;
    }

    .sk-text-30-3571-467 {
        background-image: linear-gradient(transparent 30.3571%, #EEEEEE 0%, #EEEEEE 69.6429%, transparent 0%) !important;
        background-size: 100% 58.3333rpx;
        position: relative !important;
    }

    .sk-text-14-2857-58 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-opacity {
        opacity: 0 !important;
    }

    .sk-text-14-2857-243 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-text-14-2857-711 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-text-14-2857-807 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-text-14-2857-886 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-text-14-2857-231 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-text-14-2857-637 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-text-14-2857-834 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-text-14-2857-778 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-text-14-2857-90 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-text-14-2857-342 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 29.1667rpx;
        position: relative !important;
    }

    .sk-text-14-2857-25 {
        background-image: linear-gradient(transparent 14.2857%, #EEEEEE 0%, #EEEEEE 85.7143%, transparent 0%) !important;
        background-size: 100% 32.0833rpx;
        position: relative !important;
    }

    .sk-image {
        background: #EFEFEF !important;
    }

    .sk-pseudo::before, .sk-pseudo::after {
        background: #EFEFEF !important;
        background-image: none !important;
        color: transparent !important;
        border-color: transparent !important;
    }

    .sk-pseudo-rect::before, .sk-pseudo-rect::after {
        border-radius: 0 !important;
    }

    .sk-pseudo-circle::before, .sk-pseudo-circle::after {
        border-radius: 50% !important;
    }

    .sk-container {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        overflow: hidden;
        background-color: transparent;
    }
</style>
<!--src/pages/category/category.vue-->
<script setup lang="ts">
    // 获取轮播图数据
    import {getHomeBannerAPI} from '@/services/home'
    import {computed, ref} from 'vue' // [vite]: Rollup failed to resolve import "@vue/reactivity"
    import type {BannerItem} from '@/types/home'
    import {onLoad} from '@dcloudio/uni-app'
    import {getCategoryTopAPI} from '@/services/category'
    import type {CategoryTopItem} from '@/types/category'
    import PageSkeleton from "@/pages/category/components/PageSkeleton.vue";

    //

    // 数据是否加载完毕
    const isFinish = ref(false)
    // 页面加载
    onLoad(async () => {
        await Promise.all([getBannerData(), getCategoryTopData()])
        isFinish.value = true
    })

    // 
</script>

<template>
    <view class="viewport" v-if="isFinish">
        <!-- ... -->
    </view>
    <PageSkeleton v-else/>
</template> 

商品详情


  目录