黑马程序员前端项目uniapp小兔鲜儿微信小程序项目视频教程,基于Vue3+Ts+Pinia+uni-app的最新组合技术栈开发的电商业务全流程
一、uni-app 基础
创建 uni-app 项目
uni-app 支持两种方式创建项目:
-
通过 HBuilderX 创建(需安装 HBuilderX 编辑器)
-
通过命令行创建(需安装 NodeJS 环境)
通过 HBuilderX 创建
创建步骤
1.下载安装 HbuilderX 编辑器
2.通过 HbuilderX 创建 uni-app vue3 项目
3.安装 uni-app vue3 编译器插件
4.编译成微信小程序端代码
5.开启服务端口
小技巧分享:模拟器窗口分离和置顶
HBuildeX 和 微信开发者工具 关系(vscode跟微信开发者工具的关系也是同理)
通过命令行创建
优势:通过命令行创建 uni-app 项目,不必依赖 HBuilderX,TypeScript 类型支持友好。创建命令如下:
vue3 + ts 版
# 通过 npx 从 github 下载
npx degit dcloudio/uni-preset-vue#vite-ts 项目名称
# 通过 git 从 gitee 克隆下载 (👉备用地址)
git clone -b vite-ts https://gitee.com/dcloud/uni-preset-vue.git
vue3 + Js 版
npx degit dcloudio/uni-preset-vue#vite 项目名称
创建其他版本可查看:uni-app 官网
下载失败时的常见问题
- 运行
npx命令下载失败,请尝试换成手机热点重试 - 换手机热点依旧失败,请尝试从国内备用地址下载
- 在
manifest.json文件添加 小程序 AppID 用于真机预览 - 运行
npx命令需依赖 NodeJS 环境,NodeJS 下载地址 - 运行
git命令需依赖 Git 环境,Git 下载地址
编译和运行 uni-app 项目
- manifase.json 添加 appid
- 安装依赖
pnpm install - 编译成微信小程序
pnpm dev:mp-weixin(编译成 H5 端的话,可运行npm run dev:h5来通过浏览器预览项目) - 导入微信开发者工具
项目结构
我们先来认识 uni-app 项目的目录结构和pages.json等文件的用处,后面会详细介绍pages.json这几个文件的用处
目录
├─pages 业务页面文件存放的目录
│ └─index
│ └─index.vue index页面
├─static 存放应用引用的本地静态资源的目录(注意:静态资源只能存放于此)
├─unpackage 非工程代码,一般存放运行或发行的编译结果
├─index.html H5端页面
├─main.js Vue初始化入口文件
├─App.vue 配置App全局样式、监听应用生命周期
├─pages.json 配置页面路由、导航栏、tabBar等页面类信息
├─manifest.json 配置appid、应用名称、logo、版本等打包信息
└─uni.scss uni-app内置的常用样式变量
└─tsconfig.json TypeScript项目的配置文件,用于配置类型声明文件
pages.json
作用:用于配置页面路由、导航栏、tabBar 等页面类信息
案例练习
效果预览
参考代码
{
// 页面路由
"pages": [
{
"path": "pages/index/index",
// 页面样式配置
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/my/my",
"style": {
"navigationBarTitleText": "我的"
}
}
],
// 全局样式-默认窗口配置
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#27BA9B",
"backgroundColor": "#F8F8F8"
},
// tabBar 配置
"tabBar": {
"selectedColor": "#27BA9B",
"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"
}
]
}
}
tsconfig.json
作用:TypeScript配置文件,用于Vue.js项目,同时包含一些针对uni-app和微信小程序的配置
{
"extends": "@vue/tsconfig/tsconfig.json", // Vue CLI 默认的 TypeScript 扩展配置文件
"compilerOptions": { // 编译器配置选项
"allowJs": true, // 允许编译 JavaScript 文件
"sourceMap": true, // 生成 source map 文件,用于调试
"baseUrl": ".", // 用于解析非相对模块名称的基本目录
"paths": { // 指定模块名的基于 baseUrl 路径映射
"@/*": [
"./src/*"
]
},
"lib": [ // 编译过程中包含的库文件列表
"esnext",
"dom"
],
"types": [ // 要包含的类型声明文件列表
"@dcloudio/types", // uni-app API 类型
"miniprogram-api-typings", // 原生微信小程序类型
"@uni-helper/uni-app-types", // uni-app 组件类型
"@uni-helper/uni-ui-types" // uni-ui 组件类型
]
},
"vueCompilerOptions": { // Vue 模板编译器选项
// experimentalRuntimeMode 已废弃,现调整为 nativeTags,请升级 Volar 插件至最新版本
"nativeTags": [
"block",
"component",
"template",
"slot"
]
},
"include": [ // TypeScript 编译器应该包含的文件列表,通常包括 TypeScript 文件、类型声明文件和 Vue 文件
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}
manifest.json
作用:移动应用的配置文件,基于 uni-app 跨平台框架开发,包含多个平台的基本应用信息、版本信息、权限信息等
在hbuilder中打开可通过键值填空的形式进行配置
{
"name": "小兔鲜儿", // 应用名
"appid": "", // 移动应用的AppID
"description": "", // 说明
"versionName": "1.0.0", // 版本名
"versionCode": 1, // 版本号
"transformPx": false, // 是否将像素转换为视口单位
/* 5+App配置 */
"app-plus": {
"usingComponents": true, // 是否组件化
"nvueStyleCompiler": "uni-app", // nvue样式编译器
"compilerVersion": 3, // 编译器版本
"splashscreen": { // 启动画面配置
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
/* 模块配置 */
"modules": {},
/* 应用发布信息 */
"distribute": {
/* android打包配置 */
"android": {
"permissions": [ // 权限配置
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios打包配置 */
"ios": {},
/* SDK配置 */
"sdkConfigs": {}
}
},
/* 快应用 */
"quickapp": {},
/* 微信小程序 */
"mp-weixin": {
"appid": "", // 微信公众平台的AppId
"setting": {
"urlCheck": false // 不进行路径检查配置
},
"usingComponents": true // 使用组件
},
/* 支付宝小程序 */
"mp-alipay": {
"usingComponents": true
},
/* 百度小程序 */
"mp-baidu": {
"usingComponents": true
},
/* 字节跳动小程序 */
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false // 不授权uni数据统计
},
"vueVersion": "3" // 启动vue3版本
}
与原生开发的区别
开发区别
uni-app 项目每个页面是一个 .vue 文件,数据绑定及事件处理同 Vue.js 规范:
-
属性绑定
src="{ { url }}"升级成:src="url" -
事件绑定
bindtap="eventName"升级成@tap="eventName",支持()传参 -
支持 Vue 常用指令
v-for、v-if、v-show、v-model等
其他区别补充
- 调用接口能力,建议前缀
wx替换为uni,养成好习惯,支持多端开发。 <style>页面样式不需要写scoped,小程序是多页面应用,页面样式自动隔离。- 生命周期分三部分:应用生命周期(小程序),页面生命周期(小程序),组件生命周期(Vue)
案例练习
主要功能
- 滑动轮播图
- 点击大图预览
效果预览
参考代码
<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 {
// 轮播图数据
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',
},
],
}
},
methods: {
onPreviewImage(url) {
// 大图预览
uni.previewImage({
urls: this.pictures.map((v) => v.url),
current: url,
})
},
},
}
</script>
<style>
.banner,
.banner image {
width: 750rpx;
height: 750rpx;
}
</style>
开发工具 vscode、安装插件
开发工具上有vscode、hbuilder、idea等,通常选用vscode,而为什么选择 VS Code?
- VS Code 对 TS 类型支持友好,前端开发者主流的编辑器
- HbuilderX 对 TS 类型支持暂不完善,使用它会有很多ts方面的报错,期待官方完善 👀
用 VS Code 开发配置
- 👉 前置工作:安装 Vue3 插件,点击查看官方文档
- 安装 Vue Language Features (Volar) :Vue3 语法提示插件
- 安装 TypeScript Vue Plugin (Volar) :Vue3+TS 插件(已弃用,改成安装 Vue-Official,装完才有ts类型鼠标悬停提示)
- 工作区禁用 Vue2 的 Vetur 插件(Vue3 插件和 Vue2 冲突)
- 工作区禁用 @builtin typescript 插件(禁用后开启 Vue3 的 TS 托管模式)
- 👉 安装 uni-app 插件
- uni-create-view :快速创建 uni-app 页面
- uni-helper uni-app :代码提示
- uniapp 小程序扩展 :鼠标悬停查文档
- 👉 TS 类型校验(后面项目架构里会再次提到)
- 安装类型声明文件:
pnpm i -D miniprogram-api-typings @uni-helper/uni-app-types - 配置
tsconfig.json
- 安装类型声明文件:
// tsconfig.json 参考
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"],
// 类型声明文件
"types": [
"@dcloudio/types", // uni-app API 类型
"miniprogram-api-typings", // 原生微信小程序类型
"@uni-helper/uni-app-types" // uni-app 组件类型
]
},
// vue 编译器类型,校验标签类型
"vueCompilerOptions": {
// 原配置 `experimentalRuntimeMode` 现调整为 `nativeTags`
"nativeTags": ["block", "component", "template", "slot"], // [!code ++]
"experimentalRuntimeMode": "runtime-uni-app" // [!code --]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
- 👉 JSON 注释问题
- 使用vscode时,manifest.json注释会报错,需设置文件关联,把
manifest.json和pages.json设置为jsonc
- 使用vscode时,manifest.json注释会报错,需设置文件关联,把
// .vscode/settings.json
{
// 在保存时格式化文件
"editor.formatOnSave": true,
// 文件格式化配置
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
// 配置语言的文件关联
"files.associations": {
"pages.json": "jsonc", // pages.json 可以写注释
"manifest.json": "jsonc" // manifest.json 可以写注释
}
}
如果以上设置不生效,可以手动到文件 - 首选项 - 设置 - 输入“files.associations” 里进行这两个注释选项的设置
版本升级,这一步处理很关键,否则 TS 项目无法校验组件属性类型。
- 原依赖
@types/wechat-miniprogram现调整为 miniprogram-api-typings。- 原配置
experimentalRuntimeMode现调整为nativeTags。
二、项目架构
资料说明
📀 视频学习 www.bilibili.com/video/BV1Bp…
📗 接口文档 www.apifox.cn/apidoc/shar…
✏️ 在线笔记 megasu.gitee.io/uni-app-sho…
📦 项目源码 gitee.com/Megasu/unia…
项目架构图
效果预览
| 体验小程序端 | 体验 H5 端 | 体验 App 端(安卓) |
拉取项目模板代码
项目模板包含:目录结构,项目素材,代码风格。
首先打开放置项目的目录,然后cmd打开命令窗口,再输入以下命令:
git clone http://git.itcast.cn/heimaqianduan/erabbit-uni-app-vue3-ts.git heima-shop
注意:小程序真机预览需在
manifest.json中添加微信小程序的appid
运行项目
hbuilder运行
一键运行到微信开发者工具,无需额外操作
vscode运行
- 通过package.json文件里的scripts,选择有关的命令,运行其脚本
或者手动在命令行窗口输入 npm run dev:mp-weixin跑起来
-
然后手动打开微信开发者工具,将编译生成的dist\dev\mp-weixin导入运行(选择mp-weixin该路径即可)
注意需要填写AppID,没有的话可暂时用测试号代替
如果有绑定了小程序的账号也可用该账号的AppID(登录微信公众平台 - 设置 - 基本设置 - 账号信息 - 第一行)
因为该项目用的老师教程后端接口,所以后端无需使用云服务
引入 uni-ui 组件库
安装 uni-ui 组件库,如果pnpm不行就用npm,另外如果node版本过低,也可用nvm来切换node版本
pnpm i @dcloudio/uni-ui
装完的插件在package.json里均有体现,查找可得
配置自动导入组件
// pages.json
{
// 组件自动导入
"easycom": {
"autoscan": true,
"custom": {
// uni-ui 规则如下配置 // [!code ++]
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue" // [!code ++]
}
},
"pages": [
// …省略
]
}
安装类型声明文件
pnpm i -D @uni-helper/uni-ui-types
配置类型声明文件
// tsconfig.json
{
"compilerOptions": {
// ...
// 指定了 TypeScript 编译器应该包含哪些类型声明文件列表
"types": [
"@dcloudio/types", // uni-app API 类型
"miniprogram-api-typings", // 原生微信小程序类型
"@uni-helper/uni-app-types", // uni-app 组件类型
"@uni-helper/uni-ui-types" // uni-ui 组件类型 // [!code ++]
]
},
// vue 编译器类型,校验标签类型
"vueCompilerOptions": {
"nativeTags": ["block", "component", "template", "slot"]
}
}
小程序端 Pinia 持久化
说明:Pinia 用法同vue2的vuex一样都是为了持久化存储,与 Vue3 项目完全一致,uni-app 项目仅需解决持久化插件兼容性问题。
持久化存储插件
安装持久化存储插件: pinia-plugin-persistedstate
pnpm i pinia-plugin-persistedstate
插件默认使用 localStorage 实现持久化,小程序端不兼容,需要替换持久化 API。
基本用法
// src/stores/modules/member.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 定义 Store
export const useMemberStore = defineStore(
'member',
() => {
// 会员信息
const profile = ref<any>()
// 保存会员信息,登录时使用
const setProfile = (val: any) => {
profile.value = val
}
// 清理会员信息,退出时使用
const clearProfile = () => {
profile.value = undefined
}
// 记得 return
return {
profile,
setProfile,
clearProfile,
}
},
// TODO: 持久化,这部分代码在后面有(TODO表示未完待续)
{
persist: true,
},
)
// src/stores/index.ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
// 创建 pinia 实例
const pinia = createPinia()
// 使用持久化存储插件
pinia.use(persist)
// 默认导出,给 main.ts 使用
export default pinia
// 模块统一导出
export * from './modules/member'
// main.ts
import { createSSRApp } from 'vue'
import pinia from './stores'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
app.use(pinia)
return {
app,
}
}
多端兼容
网页端持久化 API
// 网页端API
localStorage.setItem()
localStorage.getItem()
多端持久化 API
// 兼容多端API
uni.setStorageSync()
uni.getStorageSync()
参考代码
// stores/modules/member.ts
export const useMemberStore = defineStore(
'member',
() => {
//…省略
},
{
// 配置持久化
persist: {
// 调整为兼容多端的API
storage: {
setItem(key, value) {
uni.setStorageSync(key, value) // [!code warning]
},
getItem(key) {
return uni.getStorageSync(key) // [!code warning]
},
},
},
},
)
uni.request 请求封装
请求和上传文件拦截器
uniapp 拦截器: uni.addInterceptor
接口说明:接口文档
实现步骤
- 拼接基础地址
- 设置超时时间
- 添加请求头标识
- 添加 token
参考代码
// src/utils/http.ts
import { useMemberStore } from '@/stores'
// 请求基地址
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. 请求超时
options.timeout = 10000
// 3. 添加小程序端请求头标识
options.header = {
'source-client': 'miniapp',
...options.header,
}
// 4. 添加 token 请求头标识
const memberStore = useMemberStore()
const token = memberStore.profile?.token
if (token) {
options.header.Authorization = token
}
},
}
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
测试请求:点击http请求测试按钮后,在Network里即可看到该网络请求
<script setup lang="ts">
import '@/utils/http'
const getData = () => {
uni.request({ // 这样写每次请求都得写上uni.request,不太好,后面会把这部分封装到 Promise 请求函数里
method: 'GET',
url: '/home/banner',
})
}
</script>
<template>
<view>
<button @tap="getData" size="mini">http请求测试</button>
</view>
</template>
注意:
问:为什么用手机预览没有数据?
答:微信小程序端,需登录 微信公众平台 配置以下地址为合法域名 👇
https://pcapi-xiaotuxian-front-devtest.itheima.net
封装 Promise 请求函数
实现步骤
- 返回 Promise 对象,用于处理返回值类型
- 成功 resolve(提取数据、添加泛型)
- 失败 reject(401 错误、其他错误、网络错误)
参考代码
// src/utils/http.ts (继续写在拦截器下面即可)
/**
* 请求函数
* @param UniApp.RequestOptions
* @returns Promise
* 1. 返回 Promise 对象,用于处理返回值类型
* 2. 获取数据成功
* 2.1 提取核心数据 res.data
* 2.2 添加类型,支持泛型
* 3. 获取数据失败
* 3.1 401错误 -> 清理用户信息,跳转到登录页
* 3.2 其他错误 -> 根据后端错误信息轻提示
* 3.3 网络错误 -> 提示用户换网络
*/
// 使用 TypeScript 中的 interface 关键字来定义接口
interface Data<T> {
code: string
msg: string
result: T
}
// 使用了 TypeScript 中的类型别名(Type Aliases)来定义接口,功能同上面的一样,只是语法不同
// type Data<T> = {
// code: string
// msg: string
// result: T
}
// 2.2 添加类型,支持泛型
export const http = <T>(options: UniApp.RequestOptions) => {
// 1. 返回 Promise 对象
return new Promise<Data<T>>((resolve, reject) => {
uni.request({ // 把uni.request封装到 promise 里,这样每次调用接口,直接调用http即可
...options,
// 响应成功
success(res) {
// 状态码 2xx,参考 axios 的设计
if (res.statusCode >= 200 && res.statusCode < 300) {
// 2.1 提取核心数据 res.data
resolve(res.data as Data<T>)
} 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)
},
})
})
}
测试请求:
// my.vue
import { http } from '@/utils/http'
const getData = async () => {
const res = await http<BannerItem[]>({
methods: 'GET',
url: '/home/banner',
header: {},
})
console.log(res.result) // 除了这里可以看到打印结果,也可以到网络请求里查看回传的请求接口数据
}
代码规范【拓展】
为什么需要代码规范
如果没有统一代码风格,团队协作不便于查看代码提交时所做的修改。
统一代码风格
- 安装
eslint+prettier
pnpm i -D eslint prettier eslint-plugin-vue @vue/eslint-config-prettier @vue/eslint-config-typescript @rushstack/eslint-patch @vue/tsconfig
- 新建
.eslintrc.cjs文件,添加以下eslint配置
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
],
// 小程序全局变量
globals: {
uni: true,
wx: true,
WechatMiniprogram: true,
getCurrentPages: true,
getApp: true,
UniApp: true,
UniHelper: true,
App: true,
Page: true,
Component: true,
AnyObject: true,
},
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true,
semi: false,
printWidth: 100,
trailingComma: 'all',
endOfLine: 'auto',
},
],
'vue/multi-word-component-names': ['off'],
'vue/no-setup-props-destructure': ['off'],
'vue/no-deprecated-html-element-is': ['off'],
'@typescript-eslint/no-unused-vars': ['off'],
},
}
- 配置
package.json
{
"script": {
// ... 省略 ...
"lint": "eslint . --ext .vue,.js,.ts --fix --ignore-path .gitignore"
}
}
- 运行
pnpm lint
到此,你已完成 eslint + prettier 的配置。
Git 工作流规范
- 安装并初始化
husky
pnpm dlx husky-init
npx husky-init
- 安装
lint-staged
pnpm i -D lint-staged
- 配置
package.json
{
"script": {
// ... 省略 ...
},
"lint-staged": {
"*.{vue,ts,js}": ["eslint --fix"]
}
}
- 修改
.husky/pre-commit文件
npm test // [!code --]
npm run lint-staged // [!code ++]
到此,你已完成 husky + lint-staged 的配置。
三、首页模块
涉及知识点:组件通信、组件自动导入、数据渲染、触底分页加载、下拉刷新等。
自定义导航栏
参考效果:自定义导航栏的样式需要适配不同的机型。
实现步骤
-
修改首页配置:隐藏默认导航栏,修改文字颜色
-
准备好导航栏的静态结构组件
-
对导航栏组件安全区进行样式适配:因为不同手机的安全区域不同,适配安全区域能防止页面重要内容被刘海屏的摄像头所遮挡。
可通过
uni.getSystemInfoSync()获取屏幕边界到安全区的距离(安全距离)
- 将准备好的组件应用到首页即可
参考代码
修改首页配置:隐藏默认导航栏,修改文字颜色
// src/pages.json
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom", // 隐藏默认导航
"navigationBarTextStyle": "white", // 修改文字颜色
"navigationBarTitleText": "首页"
}
}
准备好导航栏的静态结构组件
<!-- 新建 src/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/componets/CustomNavbar.vue -->
<script>
// 获取屏幕边界到安全区距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>
<template>
<!-- 顶部占位 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<!-- ...省略 -->
</view>
</template>
应用到首页组件
<!-- src/pages/index -->
<script setup lang="ts">
import CustomNavbar from '@/components/CustomNavbar.vue'
</script>
<template>
<CustomNavbar />
<view class="index">index</view>
</template>
<style lang="scss">
//
</style>
通用轮播组件
参考效果
小兔鲜儿项目中总共有两处广告位,分别位于【首页】和【商品分类页】。
轮播图组件需要在这两处地方使用,因此需要封装成通用组件 XtxSwiper。
该组件定义了 list 属性接收外部传入的数据,内部通过小程序内置组件 swiper 展示首页广告的数据
实现步骤
-
准备组件:写好轮播图通用组件的静态结构
-
自动导入:使用uni-app提供的自动化配置后,将通用的全局组件导入使用时,则无需再额外写import
-
类型声明:给导入的全局组件提供鼠标悬停的数据类型提示说明
- 获取数据:定义轮播图数据类型(类型声明),封装请求接口API
- 渲染数据:在首页父组件页面初始化调用轮播图API,将数据传给子组件轮播图,并渲染出轮播图数据
参考代码
准备组件
<!-- 新建 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">
/* 轮播图 */
.carousel {
height: 280rpx;
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>
自动导入
// pages.json
{
// 组件自动引入规则
"easycom": {
...
"custom": {
...
// 以 Xtx 开头的组件,在 components 目录中查找,实现自动导入(注意配置完需要重启前端npm)
"^Xtx(.*)": "@/components/Xtx$1.vue"
}
}
}
全局组件类型声明
// src/types/components.d.ts
import 'vue'
import XtxSwiper from '@/components/XtxSwiper.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxSwiper: typeof XtxSwiper
}
}
注意:新版 Volar 把
declare module '@vue/runtime-core'调整为declare module 'vue'
获取数据
先进行类型声明,存放路径:src/types/home.d.ts
/** 首页-广告区域数据类型 */
export type BannerItem = {
/** 跳转链接 */
hrefUrl: string
/** id */
id: string
/** 图片链接 */
imgUrl: string
/** 跳转类型 */
type: number
}
然后调用接口:该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现,并跳转到对应链接地址即可。
接口地址:/home/banner
请求方式:GET
请求参数:
| 字段名 | 必须 | 默认值 | 备注 |
|---|---|---|---|
| distributionSite | 否 | 1 | 活动 banner 位置,1 代表首页,2 代表商品分类页,默认为 1 |
请求封装
// 存放路径: src/services/home.ts
import type { BannerItem } from '@/types/home'
import { http } from '@/utils/http'
/**
* 首页-广告区域-小程序
* @param distributionSite 广告区域展示位置(投放位置 投放位置,1为首页,2为分类商品页) 默认是1
*/
export const getHomeBannerAPI = (distributionSite = 1) => {
return http<BannerItem[]>({
method: 'GET',
url: '/home/banner',
data: {
distributionSite,
},
})
}
渲染数据
因为每一处使用到轮播图组件的数据可能都不一样,轮播图作为通用组件,不应该存放请求到的数据,所以:
最好在首页父组件页面初始化调用轮播图API,然后再将数据通过bannerList传给子组件轮播图
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import { getHomeBannerAPI } from '@/services/home'
import { onLoad } from '@dcloudio/uni-app'
import type { BannerItem } from '@/types/home' // 注意因为是导入的类型声明,所以前面要加上type
import { ref } from 'vue'
const bannerList = ref<BannerItem[]>([])
const getHomeBannerData = async () => {
const res = await getHomeBannerAPI()
bannerList.value = res.result
}
onLoad(() => {
getHomeBannerData()
})
</script>
<template>
...
<XtxSwiper :list="bannerList" />
...
</template>
子组件轮播图通过props接收并渲染出list数据,注意要将前面的XtxSwiper静态组件改成下面这种动态接收
<!-- src\components\XtxSwiper.vue -->
<script setup lang="ts">
import type { BannerItem } from '@/types/home'
import { ref } from 'vue'
const activeIndex = ref(0)
// 当 swiper 下标发生变化时触发
// UniHelper: 类型声明文件,为Uni-app提供事件类型
const onChange: UniHelper.SwiperOnChange = (ev) => {
// ! 非空断言,主观上排除掉空值情况
activeIndex.value = ev.detail!.current
}
// 定义 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>
小知识点:
- UniHelper 提供事件类型
- ?(可选链) 允许前面表达式为空值
- !(非空断言) 主观上排除掉空值情况
总结
首页分类
参考效果
实现步骤
- 准备组件,只有首页使用
- 获取数据:定义分类数据类型(类型声明),封装请求接口API
- 渲染数据:在首页父组件页面初始化调用API,将数据传给子组件,并渲染出数据
参考代码
静态结构
前台类目布局为独立的组件 CategoryPanel属于首页的业务组件,存放到首页目录中。
<!-- src/pages/index/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/services/home.d.ts
/** 首页-前台类目数据类型 */
export type CategoryItem = {
/** 图标路径 */
icon: string
/** id */
id: string
/** 分类名称 */
name: string
}
接口调用:该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现。
接口地址:/home/category/mutli
请求方式:GET
请求参数:无
请求封装
// services/home.ts
import type { CategoryItem } from '@/types/home'
/**
* 首页-前台分类-小程序
*/
export const getHomeCategoryAPI = () => {
return http<CategoryItem[]>({
method: 'GET',
url: '/home/category/mutli',
})
}
最后,将获得的数据结合模板语法渲染到页面中。
渲染数据
父组件调用数据,并将其传给子组件CategoryPanel
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import { getHomeCategoryAPI } from '@/services/home'
import { onLoad } from '@dcloudio/uni-app'
import type { CategoryItem } from '@/types/home'
import { ref } from 'vue'
import CategoryPanel from './CategoryPanel.vue'
...
const categoryList = ref<CategoryItem[]>([])
const getHomeCategoryData = async () => {
const res = await getHomeCategoryAPI()
categoryList.value = res.result
}
onLoad(() => {
getHomeCategoryData()
})
</script>
<template>
...
<CategoryPanel :list="categoryList" />
</template>
子组件接收并渲染数据
<!-- src/pages/index/CategoryPanel.vue -->
<script setup lang="ts">
import type { CategoryItem } from '@/types/home'
// 定义 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>
热门推荐
热门推荐功能,后端根据用户的消费习惯等信息向用户推荐的一系列商品,前端负责展示这些商品展示给用户。
参考效果
实现步骤
- 准备组件,只有首页使用
- 获取数据:定义分类数据类型(类型声明),封装请求接口API
- 渲染数据:在首页父组件页面初始化调用API,将数据传给子组件,并渲染出数据
参考代码
静态结构
热门推荐布局为独立的组件 HotPanel,属于首页的业务组件,存放到首页目录中。
<!-- src/pages/index/HotPanel.vue -->
<script setup lang="ts">
//
</script>
<template>
<!-- 推荐专区 -->
<view class="panel hot">
<view class="item" v-for="item in 4" :key="item">
<view class="title">
<text class="title-text">特惠推荐</text>
<text class="title-desc">精选全攻略</text>
</view>
<navigator hover-class="none" url="/pages/hot/hot" class="cards">
<image
class="image"
mode="aspectFit"
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_small_1.jpg"
></image>
<image
class="image"
mode="aspectFit"
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_small_2.jpg"
></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>
获取数据
接口调用:该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现。
接口地址:/home/hot/mutli
请求方式:GET
请求参数:
Headers:
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| source-client | 是 | 无 | 后端程序区分接口调用者,miniapp 代表小程序端 |
成功响应结果:
| 字段名称 | 数据类型 | 备注 |
|---|---|---|
| id | string | ID |
| title | string | 推荐标题 |
| type | number | 推荐类型 |
| alt | string | 推荐说明 |
| pictures | array[string] | 图片集合[ 图片路径 ] |
类型声明
// src/services/home.d.ts
/** 首页-热门推荐数据类型 */
export type HotItem = {
/** 说明 */
alt: string
/** id */
id: string
/** 图片集合[ 图片路径 ] */
pictures: string[]
/** 跳转地址 */
target: string
/** 标题 */
title: string
/** 推荐类型 */
type: string
}
接口封装
// services/home.ts
/**
* 首页-热门推荐-小程序
*/
export const getHomeHotAPI = () => {
return http<HotItem[]>({
method: 'GET',
url: '/home/hot/mutli',
})
}
最后将获得的数据结合模板语法渲染到页面中。
渲染数据
父组件调用数据,并将其传给子组件
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import { getHomeHotAPI } from '@/services/home'
import { onLoad } from '@dcloudio/uni-app'
import type { HotItem } from '@/types/home'
import { ref } from 'vue'
import HotPanel from './HotPanel.vue'
const hotList = ref<HotItem[]>([])
const getHomeHotData = async () => {
const res = await getHomeHotAPI()
hotList.value = res.result
}
onLoad(() => {
getHomeHotData()
})
</script>
<template>
<CustomNavbar />
<XtxSwiper :list="bannerList" />
<CategoryPanel :list="categoryList" />
<HotPanel :list="hotList" />
<view class="index">index</view>
</template>
子组件接收并渲染数据
<!-- src/pages/index/HotPanel.vue -->
<script setup lang="ts">
import type { HotItem } from '@/types/home'
// 定义 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" class="cards">
<image
v-for="src in item.pictures"
:key="src"
class="image"
mode="aspectFit"
:src="src"
>
</image>
</navigator>
</view>
</view>
</template>
猜你喜欢(*)
参考效果
猜你喜欢功能,后端根据用户的浏览记录等信息向用户随机推荐的一系列商品,前端负责把商品在多个页面中展示。
实现步骤
-
准备组件 (通用组件,多页面使用,因此可封装到components里)
-
配置自动导入(轮播图组件里已经定义,这里只强调下)、定义全局组件类型
-
获取数据:定义分类数据类型(类型声明),封装请求接口API
- 准备scroll-view滚动容器,设置page和scroll-view样式,完成除了顶部+底部导航栏之外可视窗口的滚动
- 渲染数据:因为数据跟组件一样都是通用的,所以数据的调用也一并放到子组件《猜你喜欢》里即可
- 滚动加载分页数据:滚动到第一页数据触底时,父组件调用子组件暴露的数据请求,实现下一页数据的加载
参考代码
静态结构:猜你喜欢是一个通用组件 XtxGuess,多个页面会用到该组件,存放到 src/components 目录中。
<!-- 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/types/components.d.ts
import XtxGuess from '@/components/XtxGuess.vue'
declare module 'vue' {
export interface GlobalComponents {
XtxGuess: typeof XtxGuess
}
}
// 组件实例类型
export type XtxGuessInstance = InstanceType<typeof XtxGuess>
获取数据
接口调用:该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现。
接口地址:/home/goods/guessLike
请求方式:GET
请求参数:
Query:
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| page | 否 | 1 | 分页的页码 |
| pageSize | 否 | 10 | 每页数据的条数 |
类型声明:新建通用的分页结果类型、分页参数类型
// src/types/global.d.ts
/** 通用分页结果类型 */
export type PageResult<T> = {
/** 列表数据 */
items: T[]
/** 总条数 */
counts: number
/** 当前页数 */
page: number
/** 总页数 */
pages: number
/** 每页条数 */
pageSize: number
}
/** 通用分页参数类型,加?表示可选参数 */
export type PageParams = {
/** 页码:默认值为 1 */
page?: number
/** 页大小:默认值为 10 */
pageSize?: number
}
猜你喜欢-商品类型如下,存放到 src/types/home.d.ts 文件:
// 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 type { PageParams, PageResult } from '@/types/global'
import type { GuessItem } from '@/types/home'
/**
* 猜你喜欢-小程序
*/
export const getHomeGoodsGuessLikeAPI = (data?: PageParams) => {
return http<PageResult<GuessItem>>({
method: 'GET',
url: '/home/goods/guessLike',
data,
})
}
准备scroll-view滚动容器,设置page和scroll-view样式
<!-- pages/index/index.vue -->
<script setup lang="ts">
import type { XtxGuessInstance } from '@/types/components'
import { ref } from 'vue'
// 获取猜你喜欢组件实例
const guessRef = ref<XtxGuessInstance>()
// 滚动触底事件
const onScrolltolower = () => {
guessRef.value?.getMore()
}
</script>
<template>
<CustomNavbar />
<!-- 滚动容器 -->
<scroll-view scroll-y @scrolltolower="onScrolltolower">
...
<!-- 猜你喜欢,注意这里要绑定ref -->
<XtxGuess ref="guessRef" />
</scroll-view>
</template>
<style lang="scss">
/* 设置样式 */
page {
background-color: #f7f7f7;
height: 100%;
display: flex;
flex-direction: column;
}
.scroll-view {
flex: 1;
}
</style>
渲染数据、滚动加载分页数据
<!-- src/components/XtxGuess.vue -->
<script setup lang="ts">
import { getHomeGoodsGuessLikeAPI } from '@/services/home'
import type { PageParams } from '@/types/global'
import type { GuessItem } from '@/types/home'
import { onMounted, ref } from 'vue'
// 分页参数(Required:防止在下面页码累加时传undefined报错,把number/undefined可选转为number必选)
const pageParams: Required<PageParams> = {
page: 1,
pageSize: 10,
}
// 猜你喜欢的列表
const guessList = ref<GuessItem[]>([])
// 已结束标记
const finish = ref(false)
// 获取猜你喜欢数据
const getHomeGoodsGuessLikeData = async () => {
// 退出分页判断
if (finish.value === true) {
return uni.showToast({ icon: 'none', title: '没有更多数据~' })
}
const res = await getHomeGoodsGuessLikeAPI(pageParams)
// 数组追加
guessList.value.push(...res.result.items)
// 分页条件
if (pageParams.page < res.result.pages) {
// 页码累加
pageParams.page++
} else {
finish.value = true
}
}
// 重置数据(这个用于后面下拉刷新数据用的)
const resetData = () => {
pageParams.page = 1
guessList.value = []
finish.value = false
}
// 组件挂载完毕
onMounted(() => {
getHomeGoodsGuessLikeData()
})
// 暴露方法
defineExpose({
resetData,
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=${item.id}`"
>
<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>
<style lang="scss">
... // 前面的静态组件里已经写过一遍
</style>
下拉刷新
下拉刷新实际上是在用户操作下拉交互时重新调用接口,然后将新获取的数据再次渲染到页面中。
这里主要分为:猜你喜欢组件的数据重置、其他组件的数据加载这2部分
实现步骤
基于 scroll-view 组件实现下拉刷新,需要通过以下方式来实现下拉刷新的功能。
- 配置
refresher-enabled属性,开启下拉刷新交互 - 监听
@refresherrefresh事件,判断用户是否执行了下拉操作 - 配置
refresher-triggered属性,关闭下拉状态
参考代码
猜你喜欢组件定义重置数据的方法
// src/components/XtxGuess.vue
// 重置数据
const resetData = () => {
pageParams.page = 1 // 重置页码
guessList.value = [] // 重置列表
finish.value = false // 重置结束标记
}
// 暴露方法
defineExpose({
resetData,
})
首页触发下拉刷新
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
// 下拉刷新状态
const isTriggered = ref(false)
// 自定义下拉刷新被触发
const onRefresherrefresh = async () => {
isTriggered.value = true // 开启动画
guessRef.value?.resetData() // 重置猜你喜欢组件数据
// 加载数据
await Promise.all([
getHomeBannerData(),
getHomeCategoryData(),
getHomeHotData(),
guessRef.value?.getMore(),
])
isTriggered.value = false // 关闭动画
}
</script>
<!-- 滚动容器 -->
<scroll-view
refresher-enabled
@refresherrefresh="onRefresherrefresh"
:refresher-triggered="isTriggered"
class="scroll-view"
scroll-y
>
…省略
</scroll-view>
骨架屏
骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。
参考效果
骨架屏作用是缓解用户等待时的焦虑情绪,属于用户体验优化方案。
生成骨架屏
微信开发者工具提供了自动生成骨架屏代码的能力。
实现步骤
- 快捷生成:点击分离出来的页面信息,或者点击下面页面路径右边三个点;即可看到生成骨架屏,点击后生成在dist目录里。
- 封装组件:把自动生成的
xxx.skeleton.vue和xxx.skeleton.wxss封装成一个vue组件,放到首页目录里。 - 使用组件:把封装好的骨架屏组件导入首页,然后在首页滚动容器的置顶位使用该组件,使用v-if/v-else来展示骨架屏跟正常屏
- 保留以下关键代码:去掉不需要生成骨架屏的顶部查询跟,然后给报错的代码加上v-bind动态传参解决报错
<!-- 如果轮播图没显示出来,则加上高度即可 -->
<view class="carousel XtxSwiper--carousel" style="height: 280rpx">
<!-- v-bind动态传参 -->
<swiper :circular="true" :interval="3000" :current="0" :autoplay="false">
参考代码
封装骨架屏
注意:如果生成的骨架屏文件里没有包含《猜你喜欢》组件的代码,可将预览手机型号调整为iPhoneX等大一点的型号再点击生成
<!-- src/pages/index/PageSkeleton.vue -->
<template name="skeleton">
<view is="components/XtxSwiper">
<view class="carousel XtxSwiper--carousel" style="height: 280rpx">
<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/CategoryPanel">
...
</view>
<view is="pages/index/HotPanel">
...
</view>
<view is="components/XtxGuess" class="r r">
...
</view>
</template>
<style>
.sk-transparent {
color: transparent !important;
}
...
</style>
使用骨架屏
<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import PageSkeletion from '@/components/PageSkeleton.vue'
// 加载中标记
const isLoading = ref(false)
onLoad(async()=>{
isLoading.value = true
await Promise.all([
getHomeBannerData(),
getHomecategoryData(),
getHomeHotData()
])
isLoading.value = false
})
</script>
<!-- 滚动容器 -->
<scroll-view
refresher-enabled
@refresherrefresh="onRefresherrefresh"
:refresher-triggered="isTriggered"
class="scroll-view"
scroll-y>
<PageSkeletion v-if="isLoading" />
<template v-else>
<!-- 自定义轮播图 -->
<XtxSwiper :list="bannerList" />
<!-- 分类面板 -->
<CategoryPanel :list="categoryList" />
<!-- 热门推荐 -->
<HotPanel :list="hotList" />
<!-- 猜你喜欢 -->
<XtxGuess ref="guessRef" />
</template>
</scroll-view>
测试
将网速从Online调成3G网,然后重新编译(刷新)就可以看见骨架屏的生成了
四、推荐模块
主要实现 Tabs 交互、多 Tabs 列表分页加载数据。
参考效果
推荐模块的布局结构是相同的,因此我们可以复用相同的页面及交互,只是所展示的数据不同。
实现步骤
- 新建热门推荐页面组件,并在
pages.json中手动添加路由(如果是微信小程序创建则会自动完成路由添加) - 动态标题:热门推荐页要根据页面参数区分需要获取的是哪种类型的推荐列表,然后再去调用相应的接口,来获取不同的数据,再渲染到页面当中;利用页面传参,来实现动态标题的设置。
-
获取数据:定义数据类型,封装请求接口API(注意 subType 跟 subTypeItem 这俩参数)
subType:请求参数,用来区分从首页打开的热门推荐里每个模块的类型
subTypeItem:返回结果,用来区分热门推荐里单个模块下的具体item模块
- 渲染数据:因为数据跟组件一样都是通用的,所以数据的调用也一并放到子组件《猜你喜欢》里即可
- 分页加载(滚动触底事件时触发)
- 根据高亮下标,获取对应列表数据
- 然后判断分页条件(根据页码和总数的大小判断,对当前页面进行累加或者退出标记、轻提示、底部提示)
- 提取列表的分页参数,用于发送请求,并且将返回的添加到新数组中
- 骨架屏:前面已经详细介绍过过程,这里就只放代码参考
参考代码
静态结构
新建热门推荐页面文件,并在 pages.json 中添加路由(VS Code 插件自动完成)。
<!-- 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' },
]
</script>
<template>
<view class="viewport">
<!-- 推荐封面图 -->
<view class="cover">
<image
src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-05-20/84abb5b1-8344-49ae-afc1-9cb932f3d593.jpg"
></image>
</view>
<!-- 推荐选项 -->
<view class="tabs">
<text class="text active">抢先尝鲜</text>
<text class="text">新品预告</text>
</view>
<!-- 推荐列表 -->
<scroll-view scroll-y class="scroll-view">
<view class="goods">
<navigator
hover-class="none"
class="navigator"
v-for="goods in 10"
:key="goods"
:url="`/pages/goods/goods?id=`"
>
<image
class="thumb"
src="https://yanxuan-item.nosdn.127.net/5e7864647286c7447eeee7f0025f8c11.png"
></image>
<view class="name ellipsis">不含酒精,使用安心爽肤清洁湿巾</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">29.90</text>
</view>
</navigator>
</view>
<view class="loading-text">正在加载...</view>
</scroll-view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
background-color: #f4f4f4;
}
.viewport {
display: flex;
flex-direction: column;
height: 100%;
padding: 180rpx 0 0;
position: relative;
}
.cover {
width: 750rpx;
height: 225rpx;
border-radius: 0 0 40rpx 40rpx;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
}
.scroll-view {
flex: 1;
}
.tabs {
display: flex;
justify-content: space-evenly;
height: 100rpx;
line-height: 90rpx;
margin: 0 20rpx;
font-size: 28rpx;
border-radius: 10rpx;
box-shadow: 0 4rpx 5rpx rgba(200, 200, 200, 0.3);
color: #333;
background-color: #fff;
position: relative;
z-index: 9;
.text {
margin: 0 20rpx;
position: relative;
}
.active {
&::after {
content: '';
width: 40rpx;
height: 4rpx;
transform: translate(-50%);
background-color: #27ba9b;
position: absolute;
left: 50%;
bottom: 24rpx;
}
}
}
.goods {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0 20rpx 20rpx;
.navigator {
width: 345rpx;
padding: 20rpx;
margin-top: 20rpx;
border-radius: 10rpx;
background-color: #fff;
}
.thumb {
width: 305rpx;
height: 305rpx;
}
.name {
height: 88rpx;
font-size: 26rpx;
}
.price {
line-height: 1;
color: #cf4444;
font-size: 30rpx;
}
.symbol {
font-size: 70%;
}
.decimal {
font-size: 70%;
}
}
.loading-text {
text-align: center;
font-size: 28rpx;
color: #666;
padding: 20rpx 0 50rpx;
}
</style>
在 pages.json 中手动添加路由
"pages": [
{
"path": "pages/hot/hot",
"style": {
"navigationBarTitleText": "热门推荐"
}
},
]
动态标题
热门推荐页要根据页面参数区分需要获取的是哪种类型的推荐列表,调对应接口来获取不同的数据,再渲染到页面当中,从而实现同个推荐页,根据不同的参数,展示不同的页面标题。
项目首页(传递参数)
<!-- src/pages/index/components/HotPanel.vue -->
<navigator :url="`/pages/hot/hot?type=${item.type}`">
…省略
</navigator>
热门推荐页(获取参数)
<!-- 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 获取页面参数
const query = defineProps<{
type: string
}>()
// 传递不同的页面参数,动态设置推荐页标题
const currHot = hotMap.find((v) => v.type === query.type)
uni.setNavigationBarTitle({ title: currHot!.title })
</script>
获取数据
地址参数
不同类型的推荐,需要调用不同的 API 接口:
| type | 推荐类型 | 接口路径 |
|---|---|---|
| 1 | 特惠推荐 | /hot/preference |
| 2 | 爆款推荐 | /hot/inVogue |
| 3 | 一站买全 | /hot/oneStop |
| 4 | 新鲜好物 | /hot/new |
接口调用
调用接口获取推荐商品列表的数据,然后再将这些数据渲染出来。
接口地址:见上表
请求方式:GET
请求参数:
Query:
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| subType | 否 | 无 | 推荐列表 Tab 项的 id |
| page | 否 | 1 | 页码 |
| pageSize | 否 | 10 | 每页商品数量 |
类型声明
电商项目较为常见商品展示,商品的类型是可复用的,封装到 src/types/global.d.ts 文件中:
// src/types/global.d.ts
/** 通用商品类型 */
export type GoodsItem = {
/** 商品描述 */
desc: string
/** 商品折扣 */
discount: number
/** id */
id: string
/** 商品名称 */
name: string
/** 商品已下单数量 */
orderNum: number
/** 商品图片 */
picture: string
/** 商品价格 */
price: number
}
其实猜你喜欢的商品类型也相同,可复用通用商品类型,封装到 src/services/home.d.ts 文件中:
// src/services/home.d.ts
import type { GoodsItem } from '@/types/global'
// GuessItem 和 GoodsItem 类型相同
export type GuessItem = GoodsItem
热门推荐类型如下,新建 src/types/hot.d.ts 文件:
// src/types/hot.d.ts
import type { PageResult, GoodsItem } from './global'
/** 热门推荐-子类选项 */
export type SubTypeItem = {
/** 子类id */
id: string
/** 子类标题 */
title: string
/** 子类对应的商品集合 */
goodsItems: PageResult<GoodsItem>
}
/** 热门推荐 */
export type HotResult = {
/** id信息 */
id: string
/** 活动图片 */
bannerPicture: string
/** 活动标题 */
title: string
/** 子类选项 */
subTypes: SubTypeItem[]
}
请求封装
经过分析,尽管不同类型推荐的请求 url 不同,但请求参数及响应格式都具有一致性,因此可以将接口的调用进行封装,参考如下:
// src/services/hot.ts
import { http } from '@/utils/http'
import type { PageParams } from '@/types/global'
import type { HotResult } from '@/types/hot'
// &:交叉类型,作用是类型扩展,加入额外的参数
type HotParams = PageParams & {
/** Tab 项的 id,默认查询全部 Tab 项的第 1 页数据 */
subType?: string
}
/**
* 通用热门推荐类型
* @param url 请求地址
* @param data 请求参数
*/
export const getHotRecommendAPI = (url: string, data?: HotParams) => {
return http<HotResult>({
method: 'GET',
url,
data,
})
}
渲染数据
需要根据当前用户选中的 Tabs 加载对应的列表数据
<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
import { getHotRecommendAPI } from '@/services/hot'
import { ref } from 'vue'
// 热门推荐页 标题和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 })
// 推荐封面图
const bannerPicture = ref('')
// 推荐选项
const subTypes = ref<SubTypeItem[]>([])
// 高亮下标
const activeIndex = ref(0)
// 获取热门推荐数据
const getHotRecommendData = async () => {
const res = await getHotRecommendAPI(currHot!.url)
// 保存封面图
bannerPicture.value = res.result.bannerPicture
// 保存推荐选项
subTypes.value = res.result.subTypes
}
</script>
<template>
<view class="viewport">
<!-- 推荐封面图 -->
<view class="cover">
<image :src="bannerPicture"></image>
</view>
<!-- 推荐选项 -->
<view class="tabs">
<text
v-for="(item,index) in subTypes"
:key="item.id"
class="text"
:class="{ active: index === activeIndex }"
@tap="activeIndex = index">
{{ item.title }}
</text>
</view>
<!-- 推荐列表 -->
<scroll-view
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>
<view class="loading-text">正在加载...</view>
</scroll-view>
</view>
</template>
分页加载
根据当前用户选中的 Tabs 加载对应的列表数据,并进行分页加载
<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
import { getHotRecommendAPI } from '@/services/hot'
import type { SubTypeItem } from '@/types/hot'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 热门推荐页 标题和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 })
// 推荐封面图
const bannerPicture = ref('')
// 推荐选项
const subTypes = ref<(SubTypeItem & { finish?: boolean })[]>([])
// 高亮的下标
const activeIndex = ref(0)
// 获取热门推荐数据
const getHotRecommendData = async () => {
const res = await getHotRecommendAPI(currHot!.url, {
// 技巧:环境变量,开发环境,修改初始页面方便测试分页结束
page: import.meta.env.DEV ? 30 : 1,
pageSize: 10,
})
// 保存封面
bannerPicture.value = res.result.bannerPicture
// 保存列表
subTypes.value = res.result.subTypes
}
// 页面加载
onLoad(() => {
getHotRecommendData()
})
// 滚动触底
const onScrolltolower = async () => {
// 获取当前选项
const currsubTypes = subTypes.value[activeIndex.value]
// 分页条件
if (currsubTypes.goodsItems.page < currsubTypes.goodsItems.pages) {
// 当前页码累加
currsubTypes.goodsItems.page++
} else {
// 标记已结束
currsubTypes.finish = true
// 退出并轻提示
return uni.showToast({ icon: 'none', title: '没有更多数据了~' })
}
// 调用API传参
const res = await getHotRecommendAPI(currHot!.url, {
subType: currsubTypes.id,
page: currsubTypes.goodsItems.page,
pageSize: currsubTypes.goodsItems.pageSize,
})
// 新的列表选项
const newSubTypes = res.result.subTypes[activeIndex.value]
// 数组追加
currsubTypes.goodsItems.items.push(...newSubTypes.goodsItems.items)
}
</script>
<template>
<view class="viewport">
<!-- 推荐封面图 -->
<view class="cover">
<image :src="bannerPicture"></image>
</view>
<!-- 推荐选项 -->
<view class="tabs">
<text
v-for="(item, index) in subTypes"
:key="item.id"
class="text"
:class="{ active: index === activeIndex }"
@tap="activeIndex = index"
>{{ item.title }}</text
>
</view>
<!-- 推荐列表 -->
<scroll-view
v-for="(item, index) in subTypes"
:key="item.id"
v-show="activeIndex === index"
scroll-y
class="scroll-view"
@scrolltolower="onScrolltolower"
>
<view class="goods">
<navigator
hover-class="none"
class="navigator"
v-for="goods in item.goodsItems.items"
:key="goods.id"
:url="`/pages/goods/goods?id=${goods.id}`"
>
<image class="thumb" :src="goods.picture"></image>
<view class="name ellipsis">{{ goods.name }}</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ goods.price }}</text>
</view>
</navigator>
</view>
<view class="loading-text">
{{ item.finish ? '没有更多数据了~' : '正在加载...' }}
</view>
</scroll-view>
</view>
</template>
骨架屏
<script setup lang="ts">
// 数据是否加载完毕
const isFinish = ref(false)
// 页面加载
onLoad(async() => {
// getHotRecommendData()
await Promise.all([getHotRecommendData()])
isFinish.value = true
})
</script>
<template>
<view class="viewport" v-if="isFinish">
</view>
<!-- 商品骨架屏 -->
<HotSkeleton v-else />
</template>
五、分类模块
用户点击左菜单的一级分类,切换右侧对应的二级分类和商品。
参考效果
商品分类页中的广告位,可复用之前定义的轮播图组件 XtxSwiper。
实现步骤
- 写好分类页静态结构
- 渲染轮播图:定义轮播图数据类型、数据接口(这两步在首页里已经完成),然后获取并渲染轮播图数据即可
-
一级分类:定义数据类型 → 封装请求接口API → 初始化调用获取数据 → 渲染一级分类 → Tab交互
因为该组件+数据都是通用的,所以无需将数据的获取放到父组件中,而是直接放到分类组件中即可
- 二级分类:基于高亮下标和computed属性提取二级分类数据,渲染出二级分类商品
骨架屏:实现步骤可参考首页的骨架屏,基本一致;记得将封装好的骨架屏文件 CategorySkeleton 放到分类模块目录下
参考代码
静态结构
<!-- src/pages/category/category.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="viewport">
<!-- 搜索框 -->
<view class="search">
<view class="input">
<text class="icon-search">女靴</text>
</view>
</view>
<!-- 分类 -->
<view class="categories">
<!-- 左侧:一级分类 -->
<scroll-view class="primary" scroll-y>
<view v-for="(item, index) in 10" :key="item" class="item" :class="{ active: index === 0 }">
<text class="name"> 居家 </text>
</view>
</scroll-view>
<!-- 右侧:二级分类 -->
<scroll-view class="secondary" scroll-y>
<!-- 焦点图 -->
<XtxSwiper class="banner" :list="[]" />
<!-- 内容区域 -->
<view class="panel" v-for="item in 3" :key="item">
<view class="title">
<text class="name">宠物用品</text>
<navigator class="more" hover-class="none">全部</navigator>
</view>
<view class="section">
<navigator
v-for="goods in 4"
:key="goods"
class="goods"
hover-class="none"
:url="`/pages/goods/goods?id=`"
>
<image
class="image"
src="https://yanxuan-item.nosdn.127.net/674ec7a88de58a026304983dd049ea69.jpg"
></image>
<view class="name ellipsis">木天蓼逗猫棍</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">16.00</text>
</view>
</navigator>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
overflow: hidden;
}
.viewport {
height: 100%;
display: flex;
flex-direction: column;
}
.search {
padding: 0 30rpx 20rpx;
background-color: #fff;
.input {
display: flex;
align-items: center;
justify-content: space-between;
height: 64rpx;
padding-left: 26rpx;
color: #8b8b8b;
font-size: 28rpx;
border-radius: 32rpx;
background-color: #f3f4f4;
}
}
.icon-search {
&::before {
margin-right: 10rpx;
}
}
/* 分类 */
.categories {
flex: 1;
min-height: 400rpx;
display: flex;
}
/* 一级分类 */
.primary {
overflow: hidden;
width: 180rpx;
flex: none;
background-color: #f6f6f6;
.item {
display: flex;
justify-content: center;
align-items: center;
height: 96rpx;
font-size: 26rpx;
color: #595c63;
position: relative;
&::after {
content: '';
position: absolute;
left: 42rpx;
bottom: 0;
width: 96rpx;
border-top: 1rpx solid #e3e4e7;
}
}
.active {
background-color: #fff;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 8rpx;
height: 100%;
background-color: #27ba9b;
}
}
}
.primary .item:last-child::after,
.primary .active::after {
display: none;
}
/* 二级分类 */
.secondary {
background-color: #fff;
.carousel {
height: 200rpx;
margin: 0 30rpx 20rpx;
border-radius: 4rpx;
overflow: hidden;
}
.panel {
margin: 0 30rpx 0rpx;
}
.title {
height: 60rpx;
line-height: 60rpx;
color: #333;
font-size: 28rpx;
border-bottom: 1rpx solid #f7f7f8;
.more {
float: right;
padding-left: 20rpx;
font-size: 24rpx;
color: #999;
}
}
.more {
&::after {
font-family: 'erabbit' !important;
content: '\e6c2';
}
}
.section {
width: 100%;
display: flex;
flex-wrap: wrap;
padding: 20rpx 0;
.goods {
width: 150rpx;
margin: 0rpx 30rpx 20rpx 0;
&:nth-child(3n) {
margin-right: 0;
}
image {
width: 150rpx;
height: 150rpx;
}
.name {
padding: 5rpx;
font-size: 22rpx;
color: #333;
}
.price {
padding: 5rpx;
font-size: 18rpx;
color: #cf4444;
}
.number {
font-size: 24rpx;
margin-left: 2rpx;
}
}
}
}
</style>
渲染轮播图
接口调用
渲染轮播图数据业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现。
注意:传递参数 2 标识获取商品分类页广告。
接口地址:/home/banner
请求方式:GET
请求参数:
Query:
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| distributionSite | 否 | 1 | 活动 banner 位置,1 代表首页,2 代表商品分类页,默认为 1 |
<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { getHomeBannerAPI } from '@/services/home'
import type { BannerItem } from '@/types/home'
import { onLoad } from '@dcloudio/uni-app'
// 获取轮播图数据
const bannerList = ref<BannerItem[]>([])
const getHomeBannerData = async () => {
const res = await getHomeBannerAPI(2)
bannerList.value = res.result
}
// 加载数据
onLoad(() => {
getHomeBannerData()
})
</script>
<template>
...
<!-- 焦点图 -->
<XtxSwiper class="banner" :list="bannerList" />
...
</XtxSwiper>
...
</template>
一级分类
获取数据
该接口同时包含一级分类和二级分类数据,二级分类数据需要先对数据进行处理,再进行渲染。
接口调用
接口地址:/category/top
请求方式:GET
请求参数:无
类型声明
// 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/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',
})
}
一级分类:接下来,在分类组件里获取数据,再把一级分类数据结合模板语法渲染到页面中。
Tab交互:当用户点击一级分类时,需要高亮显示,即给它添加 .active 类名即可。
<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { getCategoryTopAPI } from '@/services/category'
import type { CategoryTopItem } from '@/types/category'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取分类列表数据
const categoryList = ref<CategoryTopItem[]>([])
const getCategoryTopData = async () => {
const res = await getCategoryTopAPI()
categoryList.value = res.result
}
// 高亮下标
const activeIndex = ref(0)
// 页面加载
onLoad(() => {
getCategoryTopData()
})
</script>
<template>
<view class="viewport">
<!-- 分类 -->
<view class="categories">
<!-- 左侧:一级分类 -->
<scroll-view class="primary" scroll-y>
<view
class="item"
v-for="(item, index) in categoryList"
:key="item.id"
:class="{ active: index === activeIndex }"
@tap="activeIndex = index"
>
{{ item.name }}
</view>
</scroll-view>
</view>
</view>
</template>
二级分类
商品二级分类是从属于某个一级分类的,通过 computed 配合高亮下标提取当前二级分类数据。
提取当前二级分类数据后,剩下的就是列表渲染。
<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { computed } from 'vue'
// ...省略
// 提取当前二级分类数据
const subCategoryList = computed(() => {
return categoryList.value[activeIndex.value]?.children || []
})
</script>
<template>
<view class="viewport">
<!-- ... -->
<!-- 右侧:二级分类 -->
<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/category.vue -->
<script setup lang="ts">
import { getCategoryTopAPI } from '@/services/category'
import { getHomeBannerAPI } from '@/services/home'
import { onLoad } from '@dcloudio/uni-app'
import PageSkeleton from './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>
未完待续...