黑马程序员前端项目uniapp小兔鲜儿微信小程序项目视频教程,基于Vue3+Ts+Pinia+uni-app的最新组合技术栈开发的电商业务全流程
六、商品详情(登录前)
商品详情页分为两部分讲解:
- 登录前:展示商品信息,轮播图交互(当前模块)
- 登录后:加入购物车,立即购买(SKU 模块)
参考效果
用户点击商品列表,跳转到对应的商品详情页。
实现步骤
- 写好商品详情页静态结构,并在
pages.json中手动添加路由(如果是微信小程序创建则会自动完成路由添加) - 获取数据:需要根据商品 id 跳转到对应详情页(先传递并获取页面参数,然后封装请求接口,并初始化调用)
-
渲染数据:为了请求的数据更加严谨,需要添加 ts 数据类型,然后包在http接口中(引用类型),最后进行页面渲染
前面几个模块是为了不重复写两遍 封装API接口 + 初始化调用,所以干脆把数据类型的定义放到《获取数据》步骤中,无伤大雅
- 轮播图交互:当轮播图滑动切换的时候更新自定义下标,当图片被点击的时候大图预览。
-
弹出层交互
- 先理解下uni-ui的弹出层组件 uni-popup,通过组件
ref获取弹出层组件实例,调用弹出层打开方法 - 再使用 uni-popup,结合条件渲染出所需要的 地址/服务 组件,最后关闭
- 先理解下uni-ui的弹出层组件 uni-popup,通过组件
- 骨架屏:骨架屏文件的封装这里就省略不写,参考其他模块的即可,大体流程都一样,下面只展示怎么导入使用
参考代码
静态结构
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>
<template>
<scroll-view scroll-y class="viewport">
<!-- 基本信息 -->
<view class="goods">
<!-- 商品主图 -->
<view class="preview">
<swiper circular>
<swiper-item>
<image
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/99c83709ca5f9fd5c5bb35d207ad7822.png"
/>
</swiper-item>
<swiper-item>
<image
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/f9107d47c08f0b99c097e30055c39e1a.png"
/>
</swiper-item>
<swiper-item>
<image
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/754c56785cc8c39f7414752f62d79872.png"
/>
</swiper-item>
<swiper-item>
<image
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/ef16f8127610ef56a2a10466d6dae157.jpg"
/>
</swiper-item>
<swiper-item>
<image
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/1f0c3f5d32b0e804deb9b3d56ea6c3b2.png"
/>
</swiper-item>
</swiper>
<view class="indicator">
<text class="current">1</text>
<text class="split">/</text>
<text class="total">5</text>
</view>
</view>
<!-- 商品简介 -->
<view class="meta">
<view class="price">
<text class="symbol">¥</text>
<text class="number">29.90</text>
</view>
<view class="name ellipsis">云珍·轻软旅行长绒棉方巾 </view>
<view class="desc"> 轻巧无捻小方巾,旅行便携 </view>
</view>
<!-- 操作面板 -->
<view class="action">
<view class="item arrow">
<text class="label">选择</text>
<text class="text ellipsis"> 请选择商品规格 </text>
</view>
<view class="item arrow">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view class="item arrow">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
</view>
<!-- 商品详情 -->
<view class="detail panel">
<view class="title">
<text>详情</text>
</view>
<view class="content">
<view class="properties">
<!-- 属性详情 -->
<view class="item">
<text class="label">属性名</text>
<text class="value">属性值</text>
</view>
<view class="item">
<text class="label">属性名</text>
<text class="value">属性值</text>
</view>
</view>
<!-- 图片详情 -->
<image
mode="widthFix"
src="https://yanxuan-item.nosdn.127.net/a8d266886d31f6eb0d7333c815769305.jpg"
></image>
<image
mode="widthFix"
src="https://yanxuan-item.nosdn.127.net/a9bee1cb53d72e6cdcda210071cbd46a.jpg"
></image>
</view>
</view>
<!-- 同类推荐 -->
<view class="similar panel">
<view class="title">
<text>同类推荐</text>
</view>
<view class="content">
<navigator
v-for="item in 4"
:key="item"
class="goods"
hover-class="none"
:url="`/pages/goods/goods?id=`"
>
<image
class="image"
mode="aspectFill"
src="https://yanxuan-item.nosdn.127.net/e0cea368f41da1587b3b7fc523f169d7.png"
></image>
<view class="name ellipsis">简约山形纹全棉提花毛巾</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">18.50</text>
</view>
</navigator>
</view>
</view>
</scroll-view>
<!-- 用户操作 -->
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<view class="icons">
<button class="icons-button"><text class="icon-heart"></text>收藏</button>
<button class="icons-button" open-type="contact">
<text class="icon-handset"></text>客服
</button>
<navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
<text class="icon-cart"></text>购物车
</navigator>
</view>
<view class="buttons">
<view class="addcart"> 加入购物车 </view>
<view class="buynow"> 立即购买 </view>
</view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewport {
background-color: #f4f4f4;
}
.panel {
margin-top: 20rpx;
background-color: #fff;
.title {
display: flex;
justify-content: space-between;
align-items: center;
height: 90rpx;
line-height: 1;
padding: 30rpx 60rpx 30rpx 6rpx;
position: relative;
text {
padding-left: 10rpx;
font-size: 28rpx;
color: #333;
font-weight: 600;
border-left: 4rpx solid #27ba9b;
}
navigator {
font-size: 24rpx;
color: #666;
}
}
}
.arrow {
&::after {
position: absolute;
top: 50%;
right: 30rpx;
content: '\e6c2';
color: #ccc;
font-family: 'erabbit' !important;
font-size: 32rpx;
transform: translateY(-50%);
}
}
/* 商品信息 */
.goods {
background-color: #fff;
.preview {
height: 750rpx;
position: relative;
.image {
width: 750rpx;
height: 750rpx;
}
.indicator {
height: 40rpx;
padding: 0 24rpx;
line-height: 40rpx;
border-radius: 30rpx;
color: #fff;
font-family: Arial, Helvetica, sans-serif;
background-color: rgba(0, 0, 0, 0.3);
position: absolute;
bottom: 30rpx;
right: 30rpx;
.current {
font-size: 26rpx;
}
.split {
font-size: 24rpx;
margin: 0 1rpx 0 2rpx;
}
.total {
font-size: 24rpx;
}
}
}
.meta {
position: relative;
border-bottom: 1rpx solid #eaeaea;
.price {
height: 130rpx;
padding: 25rpx 30rpx 0;
color: #fff;
font-size: 34rpx;
box-sizing: border-box;
background-color: #35c8a9;
}
.number {
font-size: 56rpx;
}
.brand {
width: 160rpx;
height: 80rpx;
overflow: hidden;
position: absolute;
top: 26rpx;
right: 30rpx;
}
.name {
max-height: 88rpx;
line-height: 1.4;
margin: 20rpx;
font-size: 32rpx;
color: #333;
}
.desc {
line-height: 1;
padding: 0 20rpx 30rpx;
font-size: 24rpx;
color: #cf4444;
}
}
.action {
padding-left: 20rpx;
.item {
height: 90rpx;
padding-right: 60rpx;
border-bottom: 1rpx solid #eaeaea;
font-size: 26rpx;
color: #333;
position: relative;
display: flex;
align-items: center;
&:last-child {
border-bottom: 0 none;
}
}
.label {
width: 60rpx;
color: #898b94;
margin: 0 16rpx 0 10rpx;
}
.text {
flex: 1;
-webkit-line-clamp: 1;
}
}
}
/* 商品详情 */
.detail {
padding-left: 20rpx;
.content {
margin-left: -20rpx;
.image {
width: 100%;
}
}
.properties {
padding: 0 20rpx;
margin-bottom: 30rpx;
.item {
display: flex;
line-height: 2;
padding: 10rpx;
font-size: 26rpx;
color: #333;
border-bottom: 1rpx dashed #ccc;
}
.label {
width: 200rpx;
}
.value {
flex: 1;
}
}
}
/* 同类推荐 */
.similar {
.content {
padding: 0 20rpx 200rpx;
background-color: #f4f4f4;
display: flex;
flex-wrap: wrap;
.goods {
width: 340rpx;
padding: 24rpx 20rpx 20rpx;
margin: 20rpx 7rpx;
border-radius: 10rpx;
background-color: #fff;
}
.image {
width: 300rpx;
height: 260rpx;
}
.name {
height: 80rpx;
margin: 10rpx 0;
font-size: 26rpx;
color: #262626;
}
.price {
line-height: 1;
font-size: 20rpx;
color: #cf4444;
}
.number {
font-size: 26rpx;
margin-left: 2rpx;
}
}
navigator {
&:nth-child(even) {
margin-right: 0;
}
}
}
/* 底部工具栏 */
.toolbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
background-color: #fff;
height: 100rpx;
padding: 0 20rpx var(--window-bottom);
border-top: 1rpx solid #eaeaea;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: content-box;
.buttons {
display: flex;
& > view {
width: 220rpx;
text-align: center;
line-height: 72rpx;
font-size: 26rpx;
color: #fff;
border-radius: 72rpx;
}
.addcart {
background-color: #ffa868;
}
.buynow,
.payment {
background-color: #27ba9b;
margin-left: 20rpx;
}
}
.icons {
padding-right: 10rpx;
display: flex;
align-items: center;
flex: 1;
.icons-button {
flex: 1;
text-align: center;
line-height: 1.4;
padding: 0;
margin: 0;
border-radius: 0;
font-size: 20rpx;
color: #333;
background-color: #fff;
&::after {
border: none;
}
}
text {
display: block;
font-size: 34rpx;
}
}
}
</style>
在 pages.json 中手动添加路由
"pages": [
{
"path": "pages/goods/goods",
"style": {
"navigationBarTitleText": "商品详情"
}
}
]
获取数据
传递页面参数
<!-- src/pages/category/category.vue -->
<navigator :url="`/pages/goods/goods?id=${goods.id}`">
...
</navigator>
获取页面参数
根据商品的 id 查询到某个商品的详细信息,如图片、价格、型号等展示给用户。
<!-- src/pages/goods/goods.vue -->
// 接收页面参数
const query = defineProps<{
id: string
}>()
接口调用
接口信息如下:
接口地址:/goods
请求方式:GET
请求参数:
Query
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| id | 是 | 无 | 商品 id |
请求封装
注意这里http还没接上 goodsResult 数据类型,后面渲染数据时会再强调一遍
// src/services/goods.ts
import { http } from '@/utils/http'
/**
* 商品详情
* @param id 商品id
*/
export const getGoodsByIdAPI = (id: string) => {
return http({
method: 'GET',
url: '/goods',
data: { id },
})
}
初始化调用
<!-- src/pages/goods/goods.vue -->
<script lang="ts">
import { ref } from 'vue'
import { getGoodsByIdAPI } from '@/services/goods'
import { onLoad } from '@dcloudio/uni-app'
// 根据商品id获取商品详情
const getGoodsByIdData = async () => {
const res = await getGoodsByIdAPI(query.id)
...// 未完待续,后面得继续把获取到的数据保存到定义好的数组中并渲染到组件里
}
onLoad(() => {
getGoodsByIdData()
})
</script>
渲染数据
定义类型
// src/types/goods.d.ts
import type { GoodsItem } from './global'
/** 商品信息 */
export type GoodsResult = {
/** id */
id: string
/** 商品名称 */
name: string
/** 商品描述 */
desc: string
/** 当前价格 */
price: number
/** 原价 */
oldPrice: number
/** 商品详情: 包含详情属性 + 详情图片 */
details: Details
/** 主图图片集合[ 主图图片链接 ] */
mainPictures: string[]
/** 同类商品[ 商品信息 ] */
similarProducts: GoodsItem[]
/** sku集合[ sku信息 ] */
skus: SkuItem[]
/** 可选规格集合备注[ 可选规格信息 ] */
specs: SpecItem[]
/** 用户地址列表[ 地址信息 ] */
userAddresses: AddressItem[]
}
/** 商品详情: 包含详情属性 + 详情图片 */
export type Details = {
/** 商品属性集合[ 属性信息 ] */
properties: DetailsPropertyItem[]
/** 商品详情图片集合[ 图片链接 ] */
pictures: string[]
}
/** 属性信息 */
export type DetailsPropertyItem = {
/** 属性名称 */
name: string
/** 属性值 */
value: string
}
/** sku信息 */
export type SkuItem = {
/** id */
id: string
/** 库存 */
inventory: number
/** 原价 */
oldPrice: number
/** sku图片 */
picture: string
/** 当前价格 */
price: number
/** sku编码 */
skuCode: string
/** 规格集合[ 规格信息 ] */
specs: SkuSpecItem[]
}
/** 规格信息 */
export type SkuSpecItem = {
/** 规格名称 */
name: string
/** 可选值名称 */
valueName: string
}
/** 可选规格信息 */
export type SpecItem = {
/** 规格名称 */
name: string
/** 可选值集合[ 可选值信息 ] */
values: SpecValueItem[]
}
/** 可选值信息 */
export type SpecValueItem = {
/** 是否可售 */
available: boolean
/** 可选值备注 */
desc: string
/** 可选值名称 */
name: string
/** 可选值图片链接 */
picture: string
}
/** 地址信息 */
export type AddressItem = {
/** 收货人姓名 */
receiver: string
/** 联系方式 */
contact: string
/** 省份编码 */
provinceCode: string
/** 城市编码 */
cityCode: string
/** 区/县编码 */
countyCode: string
/** 详细地址 */
address: string
/** 默认地址,1为是,0为否 */
isDefault: number
/** 收货地址 id */
id: string
/** 省市区 */
fullLocation: string
}
引用类型:将定义好的类型声明 GoodsResult 引用到封装好的请求中
// src/services/goods.ts
import type { GoodsResult } from '@/types/goods'
/**
* 商品详情
* @param id 商品id
*/
export const getGoodsByIdAPI = (id: string) => {
return http<GoodsResult>({
method: 'GET',
url: '/goods',
data: { id },
})
}
页面渲染:最后将获取到的数据结合模板语法渲染到页面中。
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
import { getGoodsByIdAPI } from '@/services/goods'
import type { GoodsResult } from '@/types/goods'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取商品详情信息
const goods = ref<goodsResult>()
cosnt getGoodsByIdData = async () => {
const res = await getGoodsByIdAPI(query.id)
goods.value = res.result
}
// 加载数据
onLoad(() => {
getGoodsByIdData()
})
</script>
<template>
<scroll-view scroll-y class="viewport">
<!-- 基本信息 -->
<view class="goods">
<!-- 商品主图 -->
<view class="preview">
<swiper circular>
<swiper-item v-for="(item,index) in goods?.mainPictures" :key="item">
<image class="image" mode="aspectFill" :src="item" />
</swiper-item>
</swiper>
...
</view>
<!-- 商品简介 -->
<view class="meta">
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ goods?.price }}</text>
</view>
<view class="name ellipsis">{{ goods?.name }}</view>
<view class="desc"> {{ goods?.desc }} </view>
</view>
<!-- 操作面板 -->
<view class="action">
<view class="item arrow">
<text class="label">选择</text>
<text class="text ellipsis"> 请选择商品规格 </text>
</view>
<view class="item arrow">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view class="item arrow">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
</view>
<!-- 商品详情 -->
<view class="detail panel">
<view class="title">
<text>详情</text>
</view>
<view class="content">
<view class="properties">
<!-- 属性详情 -->
<view class="item" v-for="item in goods?.details.properties" :key="item.name">
<text class="label">{{ item.name }}</text>
<text class="value">{{ item.value }}</text>
</view>
</view>
<!-- 图片详情 -->
<image
class="image"
v-for="item in goods?.details.pictures"
:key="item"
mode="widthFix"
:src="item"
></image>
</view>
</view>
<!-- 同类推荐 -->
<view class="similar panel">
<view class="title">
<text>同类推荐</text>
</view>
<view class="content">
<navigator
v-for="item in goods?.similarProducts"
:key="item.id"
class="goods"
hover-class="none"
:url="`/pages/goods/goods?id=${item.id}`"
>
<image class="image" mode="aspectFill" :src="item.picture"></image>
<view class="name ellipsis">{{ item.name }}</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ item.price }}</text>
</view>
</navigator>
</view>
</view>
</scroll-view>
<!-- 用户操作 -->
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<view class="icons">
<button class="icons-button"><text class="icon-heart"></text>收藏</button>
<button class="icons-button" open-type="contact">
<text class="icon-handset"></text>客服
</button>
<navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
<text class="icon-cart"></text>购物车
</navigator>
</view>
<view class="buttons">
<view class="addcart"> 加入购物车 </view>
<view class="buynow"> 立即购买 </view>
</view>
</view>
</template>
轮播图交互
实现自定义下标切换 + 大图预览
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
// 轮播图变化时
const currentIndex = ref(0)
const onChange: UniHelper.SwiperOnChange = (ev) => {
currentIndex.value = ev.detail.current
}
// 点击图片时
const onTapImage = (url: string) => {
// 大图预览
uni.previewImage({
current: url,
urls: goods.value!.mainPictures,
})
}
</script>
<template>
<!-- 商品主图 -->
<view class="preview">
<swiper @change="onChange" circular>
<swiper-item v-for="item in goods?.mainPictures" :key="item">
<image @tap="onTapImage(item)" mode="aspectFill" :src="item" />
</swiper-item>
</swiper>
<view class="indicator">
<text class="current">{{ currentIndex + 1 }}</text>
<text class="split">/</text>
<text class="total">{{ goods?.mainPictures.length }}</text>
</view>
</view>
</template>
弹出层交互
参考效果
uni-ui 弹出层组件:uni-popup
静态结构
先写好 服务说明 和 收获地址 两个组件的静态结构,方便实现弹出层交互;放到 components 里。
组件 1:服务说明
<!-- src/components/ServicePanel.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="service-panel">
<!-- 关闭按钮 -->
<text class="close icon-close"></text>
<!-- 标题 -->
<view class="title">服务说明</view>
<!-- 内容 -->
<view class="content">
<view class="item">
<view class="dt">无忧退货</view>
<view class="dd">
自收到商品之日起30天内,可在线申请无忧退货服务(食品等特殊商品除外)
</view>
</view>
<view class="item">
<view class="dt">快速退款</view>
<view class="dd">
收到退货包裹并确认无误后,将在48小时内办理退款,
退款将原路返回,不同银行处理时间不同,预计1-5个工作日到账
</view>
</view>
<view class="item">
<view class="dt">满88元免邮费</view>
<view class="dd">
单笔订单金额(不含运费)满88元可免邮费,不满88元, 单笔订单收取10元邮费
</view>
</view>
</view>
</view>
</template>
<style lang="scss">
.service-panel {
padding: 0 30rpx;
border-radius: 10rpx 10rpx 0 0;
position: relative;
background-color: #fff;
}
.title {
line-height: 1;
padding: 40rpx 0;
text-align: center;
font-size: 32rpx;
font-weight: normal;
border-bottom: 1rpx solid #ddd;
color: #444;
}
.close {
position: absolute;
right: 24rpx;
top: 24rpx;
}
.content {
padding: 20rpx 20rpx 100rpx 20rpx;
.item {
margin-top: 20rpx;
}
.dt {
margin-bottom: 10rpx;
font-size: 28rpx;
color: #333;
font-weight: 500;
position: relative;
&::before {
content: '';
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background-color: #eaeaea;
transform: translateY(-50%);
position: absolute;
top: 50%;
left: -20rpx;
}
}
.dd {
line-height: 1.6;
font-size: 26rpx;
color: #999;
}
}
</style>
组件 2:收获地址组件
<!-- src/components/AddressPanel.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="address-panel">
<!-- 关闭按钮 -->
<text class="close icon-close"></text>
<!-- 标题 -->
<view class="title">配送至</view>
<!-- 内容 -->
<view class="content">
<view class="item">
<view class="user">李明 13824686868</view>
<view class="address">北京市顺义区后沙峪地区安平北街6号院</view>
<text class="icon icon-checked"></text>
</view>
<view class="item">
<view class="user">王东 13824686868</view>
<view class="address">北京市顺义区后沙峪地区安平北街6号院</view>
<text class="icon icon-ring"></text>
</view>
<view class="item">
<view class="user">张三 13824686868</view>
<view class="address">北京市朝阳区孙河安平北街6号院</view>
<text class="icon icon-ring"></text>
</view>
</view>
<view class="footer">
<view class="button primary"> 新建地址 </view>
<view v-if="false" class="button primary">确定</view>
</view>
</view>
</template>
<style lang="scss">
.address-panel {
padding: 0 30rpx;
border-radius: 10rpx 10rpx 0 0;
position: relative;
background-color: #fff;
}
.title {
line-height: 1;
padding: 40rpx 0;
text-align: center;
font-size: 32rpx;
font-weight: normal;
border-bottom: 1rpx solid #ddd;
color: #444;
}
.close {
position: absolute;
right: 24rpx;
top: 24rpx;
}
.content {
min-height: 300rpx;
max-height: 540rpx;
overflow: auto;
padding: 20rpx;
.item {
padding: 30rpx 50rpx 30rpx 60rpx;
background-size: 40rpx;
background-repeat: no-repeat;
background-position: 0 center;
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png);
position: relative;
}
.icon {
color: #999;
font-size: 40rpx;
transform: translateY(-50%);
position: absolute;
top: 50%;
right: 0;
}
.icon-checked {
color: #27ba9b;
}
.icon-ring {
color: #444;
}
.user {
font-size: 28rpx;
color: #444;
font-weight: 500;
}
.address {
font-size: 26rpx;
color: #666;
}
}
.footer {
display: flex;
justify-content: space-between;
padding: 20rpx 0 40rpx;
font-size: 28rpx;
color: #444;
.button {
flex: 1;
height: 72rpx;
text-align: center;
line-height: 72rpx;
margin: 0 20rpx;
color: #fff;
border-radius: 72rpx;
}
.primary {
color: #fff;
background-color: #27ba9b;
}
.secondary {
background-color: #ffa868;
}
}
</style>
商品详情页:通过组件 ref 获取弹出层组件实例,调用打开弹出层方法。
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
import AddressPanel from './components/AddressPanel.vue'
import ServicePanel from './components/ServicePanel.vue'
// uni-ui 弹出层组件 ref
const popup = ref<{
open: (type?: UniHelper.UniPopupType) => void
close: () => void
}>()
// 弹出层条件渲染
const popupName = ref<'address' | 'service'>()
const openPopup = (name: typeof popupName.value) => {
// 修改弹出层名称
popupName.value = name
// 打开弹出层
popup.value?.open()
}
</script>
<template>
<!-- 操作面板 -->
<view class="action">
<view class="item arrow">
<text class="label">选择</text>
<text class="text ellipsis"> 请选择商品规格 </text>
</view>
<view @tap="openPopup('address')" class="item arrow">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view @tap="openPopup('service')" class="item arrow">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
<!-- uni-ui 弹出层 -->
<uni-popup ref="popup" type="bottom" background-color="#fff">
<AddressPanel v-if="popupName === 'address'" @close="popup?.close()" />
<ServicePanel v-if="popupName === 'service'" @close="popup?.close()" />
</uni-popup>
</template>
服务说明 和 收获地址 组件通讯:通过子调父,关闭弹出层。
<!-- src/components/AddressPanel.vue | src/components/ServicePanel.vue -->
<script setup lang="ts">
// 子调父
const emit = defineEmits<{
(event: 'close'): void
}>()
</script>
<template>
<view class="service-panel">
<!-- 关闭按钮 -->
<text class="close icon-close" @tap="emit('close')"></text>
...省略
</view>
</template>
骨架屏
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { getGoodsByIdAPI } from '@/services/goods'
import { onLoad } from '@dcloudio/uni-app'
import PageSkeleton from './PageSkeleton.vue'
...
// 数据是否加载完毕
const isFinish = ref(false)
// 初始化调用
onLoad(async () => {
// getGoodsByIdData()
await Promise.all([getGoodsByIdData()])
isFinish.value = true
})
</script>
<template>
<scroll-view scroll-y class="viewport" v-if="isFinish">
...
</view>
<!-- 骨架屏 -->
<GoodsSkeleton v-else />
</template>
参考代码(总)
<!-- src/pages/goods/goods.vue -->
<script setup lang="ts">
import { getGoodsByIdAPI } from '@/services/goods'
import type { GoodsResult } from '@/types/goods'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import AddressPanel from './components/AddressPanel.vue'
import ServicePanel from './components/ServicePanel.vue'
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 接收页面参数
const query = defineProps<{
id: string
}>()
// 获取商品详情信息
const goods = ref<GoodsResult>()
const getGoodsByIdData = async () => {
const res = await getGoodsByIdAPI(query.id)
goods.value = res.result
}
// 页面加载
onLoad(() => {
getGoodsByIdData()
})
// 轮播图变化时
const currentIndex = ref(0)
const onChange: UniHelper.SwiperOnChange = (ev) => {
currentIndex.value = ev.detail.current
}
// 点击图片时
const onTapImage = (url: string) => {
// 大图预览
uni.previewImage({
current: url,
urls: goods.value!.mainPictures,
})
}
// uni-ui 弹出层组件 ref
const popup = ref<{
open: (type?: UniHelper.UniPopupType) => void
close: () => void
}>()
// 弹出层条件渲染
const popupName = ref<'address' | 'service'>()
const openPopup = (name: typeof popupName.value) => {
// 修改弹出层名称
popupName.value = name
popup.value?.open()
}
</script>
<template>
<scroll-view scroll-y class="viewport" v-if="isFinish">
<!-- 基本信息 -->
<view class="goods">
<!-- 商品主图 -->
<view class="preview">
<swiper @change="onChange" circular>
<swiper-item v-for="item in goods?.mainPictures" :key="item">
<image @tap="onTapImage(item)" mode="aspectFill" :src="item" />
</swiper-item>
</swiper>
<view class="indicator">
<text class="current">{{ currentIndex + 1 }}</text>
<text class="split">/</text>
<text class="total">{{ goods?.mainPictures.length }}</text>
</view>
</view>
<!-- 商品简介 -->
<view class="meta">
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ goods?.price }}</text>
</view>
<view class="name ellipsis">{{ goods?.name }}</view>
<view class="desc"> {{ goods?.desc }} </view>
</view>
<!-- 操作面板 -->
<view class="action">
<view class="item arrow">
<text class="label">选择</text>
<text class="text ellipsis"> 请选择商品规格 </text>
</view>
<view @tap="openPopup('address')" class="item arrow">
<text class="label">送至</text>
<text class="text ellipsis"> 请选择收获地址 </text>
</view>
<view @tap="openPopup('service')" class="item arrow">
<text class="label">服务</text>
<text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
</view>
</view>
</view>
<!-- 商品详情 -->
<view class="detail panel">
<view class="title">
<text>详情</text>
</view>
<view class="content">
<view class="properties">
<!-- 属性详情 -->
<view class="item" v-for="item in goods?.details.properties" :key="item.name">
<text class="label">{{ item.name }}</text>
<text class="value">{{ item.value }}</text>
</view>
</view>
<!-- 图片详情 -->
<image
v-for="item in goods?.details.pictures"
:key="item"
mode="widthFix"
:src="item"
></image>
</view>
</view>
<!-- 同类推荐 -->
<view class="similar panel">
<view class="title">
<text>同类推荐</text>
</view>
<view class="content">
<navigator
v-for="item in goods?.similarProducts"
:key="item.id"
class="goods"
hover-class="none"
:url="`/pages/goods/goods?id=${item.id}`"
>
<image class="image" mode="aspectFill" :src="item.picture"></image>
<view class="name ellipsis">{{ item.name }}</view>
<view class="price">
<text class="symbol">¥</text>
<text class="number">{{ item.price }}</text>
</view>
</navigator>
</view>
</view>
</scroll-view>
<GoodsSkeleton v-else />
<!-- 用户操作 -->
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
<view class="icons">
<button class="icons-button"><text class="icon-heart"></text>收藏</button>
<button class="icons-button" open-type="contact">
<text class="icon-handset"></text>客服
</button>
<navigator class="icons-button"><text class="icon-cart"></text>购物车</navigator>
</view>
<view class="buttons">
<view class="addcart"> 加入购物车 </view>
<view class="payment"> 立即购买 </view>
</view>
</view>
<!-- uni-ui 弹出层 -->
<uni-popup ref="popup" type="bottom" background-color="#fff">
<AddressPanel v-if="popupName === 'address'" @close="popup?.close()" />
<ServicePanel v-if="popupName === 'service'" @close="popup?.close()" />
</uni-popup>
</template>
<style lang="scss">
...
</style>
七、微信登录
涉及知识点:微信授权登录,文件上传,Store 状态管理等。
参考效果
微信小程序的开放能力,允许开发者获取微信用户的基本信息(昵称、性别、手机号码等),开发者常用来实现注册/登录的功能。
常见登录/注册方式:
- 用户名/手机号 + 密码
- 手机号 + 验证码
- 授权登录
实际开发过程中常常需要实现以上的一种或多种方式,我们的项目主要实现授权登录。
微信授权登录
用户在使用小程序时,其实已登录微信,其本质上就是:微信授权给小程序读取微信用户信息。
传统登录方式
传统登录方式,一般是通过输入密码或者手机验证码实现登录。
实现步骤
- 创建好登录页静态结构
- 获取登录凭证、手机号码并登录(这一步只用于生产环境,用于测试用的开发环境不需要配置)
-
快捷登录(生产环境):定义数据类型,封装请求接口,然后调用API进行快捷登录(上一步已给出)
LoginResult后面信息Pinia持久化存储时会再强调一遍,这里先作铺垫
- 模拟登录(开发环境):定义数据类型,这一步前面已经定义好了,然后封装请求接口,点击按钮模拟快捷登录
- 保存信息:接口请求成功后,将用户数据存储在Pinia状态管理容器中,并且提示登录成功和跳转页面
参考代码
静态结构
<!-- src/pages/login/login.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="viewport">
<view class="logo">
<image
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png"
></image>
</view>
<view class="login">
<!-- 网页端表单登录 -->
<!-- <input class="input" type="text" placeholder="请输入用户名/手机号码" /> -->
<!-- <input class="input" type="text" password placeholder="请输入密码" /> -->
<!-- <button class="button phone">登录</button> -->
<!-- 小程序端授权登录 -->
<button class="button phone">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>
<view class="extra">
<view class="caption">
<text>其他登录方式</text>
</view>
<view class="options">
<!-- 通用模拟登录 -->
<button>
<text class="icon icon-phone">模拟快捷登录</text>
</button>
</view>
</view>
<view class="tips">登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view>
</view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
}
.viewport {
display: flex;
flex-direction: column;
height: 100%;
padding: 20rpx 40rpx;
}
.logo {
flex: 1;
text-align: center;
image {
width: 220rpx;
height: 220rpx;
margin-top: 15vh;
}
}
.login {
display: flex;
flex-direction: column;
height: 60vh;
padding: 40rpx 20rpx 20rpx;
.input {
width: 100%;
height: 80rpx;
font-size: 28rpx;
border-radius: 72rpx;
border: 1px solid #ddd;
padding-left: 30rpx;
margin-bottom: 20rpx;
}
.button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 80rpx;
font-size: 28rpx;
border-radius: 72rpx;
color: #fff;
.icon {
font-size: 40rpx;
margin-right: 6rpx;
}
}
.phone {
background-color: #28bb9c;
}
.wechat {
background-color: #06c05f;
}
.extra {
flex: 1;
padding: 70rpx 70rpx 0;
.caption {
width: 440rpx;
line-height: 1;
border-top: 1rpx solid #ddd;
font-size: 26rpx;
color: #999;
position: relative;
text {
transform: translate(-40%);
background-color: #fff;
position: absolute;
top: -12rpx;
left: 50%;
}
}
.options {
display: flex;
justify-content: center;
align-items: center;
margin-top: 70rpx;
button {
padding: 0;
background-color: transparent;
}
}
.icon {
font-size: 24rpx;
color: #444;
display: flex;
flex-direction: column;
align-items: center;
&::before {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-bottom: 6rpx;
font-size: 40rpx;
border: 1rpx solid #444;
border-radius: 50%;
}
}
.icon-weixin::before {
border-color: #06c05f;
color: #06c05f;
}
}
}
.tips {
position: absolute;
bottom: 80rpx;
left: 20rpx;
right: 20rpx;
font-size: 22rpx;
color: #999;
text-align: center;
}
</style>
获取登录凭证、手机号
小兔鲜儿项目采用常见的 登录凭证 + 手机号 实现授权登录。
前端:调用 wx.login() 接口获取登录凭证(code)。
后端:通过凭证(code)向微信服务器生成用户登录态。
注意:
- code 获取不要在 getphonenumber 事件回调函数调用,否则可能会出现错误!!!
- 用户登录态,不包含用户昵称、性别、手机号码等信息,作用是用于后端与微信服务器通讯。
出于安全限制,小程序【规定】想获取用户的手机号,必须由用户主动【点击按钮】并【允许申请】才可获取加密的手机号信息。
前端:提供 open-type 按钮,在事件处理函数中获取加密的手机号信息。
后端:解密手机号信息,把手机号和用户登录态关联在一起。
<!-- src/pages/login/login.vue -->
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { postLoginWxMinAPI } from '@/services/login'
// 获取 code 登录凭证
let code = ''
onLoad(async () => {
const res = await wx.login()
code = res.code
})
// 获取用户手机号码
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => {
// 获取参数
const encryptedData = ev.detail!.encryptedData!
const iv = ev.detail!.iv!
// 登录请求
await postLoginWxMinAPI({ code, encryptedData, iv })
// 成功提示
uni.showToast({ icon: 'none', title: '登录成功' })
}
</script>
<template>
<view class="viewport">
<view class="login">
<button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>
</view>
</view>
</template>
注意:
为什么开发者开发完后,无法唤起获取手机号的界面?
因为获取手机号功能目前只针对非个人开发者,且完成认证的小程序开放,所以个人开发者无法唤起获取手机号界面,为了不影响后续功能的开发测试,本项目提供了模拟登录 API 用于练习,模拟手机号一键登录,请移步小程序
快捷登录(生产环境)
获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放,详见文档。
接口调用
接口地址:/login/wxMin
请求方式:POST
请求参数:
Body
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| code | 是 | 无 | wx.login 获取 |
| iv | 是 | 无 | getphonenumber 事件回调获取 |
| encryptedData | 是 | 无 | getphonenumber 事件回调获取 |
类型声明
// src/types/member.d.ts
/** 小程序登录 登录用户信息 */
export type LoginResult = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
/** 手机号 */
mobile: string
/** 登录凭证 */
token: string
}
请求封装
// src/services/login.ts
import { http } from '@/utils/http'
import type { LoginResult } from '@/types/member'
type LoginParams = {
code: string
encryptedData: string
iv: string
}
/**
* 小程序登录_运行版
* @param data 请求参数
*/
export const postLoginWxMinAPI = (data: LoginParams) => {
return http<LoginResult>({
method: 'POST',
url: '/login/wxMin',
data,
})
}
模拟登录(开发环境)
获取手机号功能,目前针对非个人开发者,且完成了认证的小程序开放,详见文档。
为了更好实现登录后续业务,后端提供内部测试接口,只需要传手机号即可实现快捷登录。
请求接口
接口地址:/login/wxMin/simple
请求方式:POST
请求参数:
Body
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| phoneNumber | 是 | 无 | 模拟的手机号 |
该接口跟微信登录接口返回的数据格式是相同的。
请求封装
// src/services/login.ts
/**
* 小程序登录_内测版
* @param phoneNumber 模拟手机号码
*/
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => {
return http<LoginResult>({
method: 'POST',
url: '/login/wxMin/simple',
data: {
phoneNumber,
},
})
}
调用API
<!-- src/pages/login/login.vue -->
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { postLoginWxMinSimpleAPI } from '@/services/login'
// 模拟快捷登录
const onGetphonenumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('13021427304')
uni.showToast({ icon: 'success', title: '登录成功' })
}
</script>
<template>
...
<view class="extra">
<view class="caption">
<text>其他登录方式</text>
</view>
<view class="options">
<!-- 通用模拟登录 -->
<button @tap="onGetphonenumberSimple">
<text class="icon icon-phone">模拟快捷登录</text>
</button>
</view>
</view>
...
</template>
保存登录会员信息
利用Pinia,进行用户信息的持久化存储
Pinia 的持久化存储插件在 项目起步 模块已经搭建完成,现在只需要在用户登录成功后,补充 TS 类型声明并保存用户信息即可。
Store
// src/stores/modules/member.ts
import type { LoginResult } from '@/types/member'
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 定义 Store
export const useMemberStore = defineStore(
'member',
() => {
// 会员信息
const profile = ref<LoginResult>() // [!code ++]
// 保存会员信息,登录时使用
const setProfile = (val: LoginResult) => {
profile.value = val
}
// 清理会员信息,退出时使用
const clearProfile = () => {
profile.value = undefined
}
// 记得 return
return { profile, setProfile, clearProfile }
},
{
// 小程序端配置
persist: {
storage: {
getItem(key) {
return uni.getStorageSync(key)
},
setItem(key, value) {
uni.setStorageSync(key, value)
},
},
},
},
)
登录页
<!-- src/pages/login/login.vue -->
<script setup lang="ts">
import { postLoginWxMinAPI, postLoginWxMinSimpleAPI } from '@/services/login'
import { onLoad } from '@dcloudio/uni-app'
import { useMemberStore } from '@/stores'
import type { LoginResult } from '@/types/member'
// 获取 code 登录凭证
let code = ''
onLoad(async () => {
const res = await wx.login()
code = res.code
})
// 获取用户手机号码(企业中写法)
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => {
const encryptedData = ev.detail.encryptedData!
const iv = ev.detail.iv!
const res = await postLoginWxMinAPI({ code, encryptedData, iv })
loginSuccess(res.result)
}
// 模拟手机号码快捷登录(开发练习)
const onGetphonenumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('13123456789')
loginSuccess(res.result)
}
// 存储登录成功的会员信息
const loginSuccess = (profile: LoginResult) => {
// 保存会员信息
const memberStore = useMemberStore() // [!code ++]
memberStore.setProfile(profile) // [!code ++]
// 成功提示
uni.showToast({ icon: 'success', title: '登录成功' })
setTimeout(() => {
// 页面跳转(因为switchTab会关闭所有非tabBar页面,所以这里要延迟跳转,不然看不到上面的成功提示)
uni.switchTab({ url: '/pages/my/my' })
}, 500)
}
</script>
到此,微信登录模块已经完成,如需测试,则在微信小程序顶部添加编译模式,然后选择登录页路径运行即可。
八、用户模块
在用户登录/注册成功后,展示并更新会员信息。同时包含会员设置页、会员信息页的设置
“我的”用户页
参考效果
实现步骤
- 写好静态结构,自定义导航,渲染当前登录会员的昵称和头像(从 Store 中获取)
- 猜你喜欢分页加载,可封装成组合式函数实现复用逻辑。
参考代码
会员中心页,替换掉原本的练习代码。
注意:以下路径中包含pagesMember的跳转页面都是关于后面分包页的设置,先提前设置好,不懂的话跳过先看后面小节
<!-- src/pages/my/my.vue -->
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 订单选项
const orderTypes = [
{ type: 1, text: '待付款', icon: 'icon-currency' },
{ type: 2, text: '待发货', icon: 'icon-gift' },
{ type: 3, text: '待收货', icon: 'icon-check' },
{ type: 4, text: '待评价', icon: 'icon-comment' },
]
</script>
<template>
<scroll-view class="viewport" scroll-y enable-back-to-top>
<!-- 个人资料 -->
<view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
<!-- 情况1:已登录 -->
<view class="overview" v-if="false">
<navigator url="/pagesMember/profile/profile" hover-class="none">
<image
class="avatar"
mode="aspectFill"
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/avatar_3.jpg"
></image>
</navigator>
<view class="meta">
<view class="nickname"> 黑马程序员 </view>
<navigator class="extra" url="/pagesMember/profile/profile" hover-class="none">
<text class="update">更新头像昵称</text>
</navigator>
</view>
</view>
<!-- 情况2:未登录 -->
<view class="overview" v-else>
<navigator url="/pages/login/login" hover-class="none">
<image
class="avatar gray"
mode="aspectFill"
src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-04-06/db628d42-88a7-46e7-abb8-659448c33081.png"
></image>
</navigator>
<view class="meta">
<navigator url="/pages/login/login" hover-class="none" class="nickname">
未登录
</navigator>
<view class="extra">
<text class="tips">点击登录账号</text>
</view>
</view>
</view>
<navigator class="settings" url="/pagesMember/settings/settings" hover-class="none">
设置
</navigator>
</view>
<!-- 我的订单 -->
<view class="orders">
<view class="title">
我的订单
<navigator class="navigator" url="/pagesOrder/list/list?type=0" hover-class="none">
查看全部订单<text class="icon-right"></text>
</navigator>
</view>
<view class="section">
<!-- 订单 -->
<navigator
v-for="item in orderTypes"
:key="item.type"
:class="item.icon"
:url="`/pagesOrder/list/list?type=${item.type}`"
class="navigator"
hover-class="none"
>
{{ item.text }}
</navigator>
<!-- 客服 -->
<button class="contact icon-handset" open-type="contact">售后</button>
</view>
</view>
<!-- 猜你喜欢 -->
<view class="guess">
<XtxGuess ref="guessRef" />
</view>
</scroll-view>
</template>
<style lang="scss">
page {
height: 100%;
overflow: hidden;
background-color: #f7f7f8;
}
.viewport {
height: 100%;
background-repeat: no-repeat;
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/center_bg.png);
background-size: 100% auto;
}
/* 用户信息 */
.profile {
margin-top: 20rpx;
position: relative;
.overview {
display: flex;
height: 120rpx;
padding: 0 36rpx;
color: #fff;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: #eee;
}
.gray {
filter: grayscale(100%);
}
.meta {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
line-height: 30rpx;
padding: 16rpx 0;
margin-left: 20rpx;
}
.nickname {
max-width: 350rpx;
margin-bottom: 16rpx;
font-size: 30rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extra {
display: flex;
font-size: 20rpx;
}
.tips {
font-size: 22rpx;
}
.update {
padding: 3rpx 10rpx 1rpx;
color: rgba(255, 255, 255, 0.8);
border: 1rpx solid rgba(255, 255, 255, 0.8);
margin-right: 10rpx;
border-radius: 30rpx;
}
.settings {
position: absolute;
bottom: 0;
right: 40rpx;
font-size: 30rpx;
color: #fff;
}
}
/* 我的订单 */
.orders {
position: relative;
z-index: 99;
padding: 30rpx;
margin: 50rpx 20rpx 0;
background-color: #fff;
border-radius: 10rpx;
box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6);
.title {
height: 40rpx;
line-height: 40rpx;
font-size: 28rpx;
color: #1e1e1e;
.navigator {
font-size: 24rpx;
color: #939393;
float: right;
}
}
.section {
width: 100%;
display: flex;
justify-content: space-between;
padding: 40rpx 20rpx 10rpx;
.navigator,
.contact {
text-align: center;
font-size: 24rpx;
color: #333;
&::before {
display: block;
font-size: 60rpx;
color: #ff9545;
}
}
.contact {
padding: 0;
margin: 0;
border: 0;
background-color: transparent;
line-height: inherit;
}
}
}
/* 猜你喜欢 */
.guess {
background-color: #f7f7f8;
margin-top: 20rpx;
}
</style>
修改我的配置:隐藏默认导航栏,修改文字颜色
// src/pages.json
{
"path": "pages/my/my",
"style": {
"navigationStyle": "custom", // 隐藏默认导航
"navigationBarTextStyle": "white", // 修改文字颜色
"navigationBarTitleText": "我的"
}
}
封装猜你喜欢组合式函数
// src/composables/index.ts
import type { XtxGuessInstance } from '@/types/components'
import { ref } from 'vue'
/**
* 猜你喜欢组合式函数
*/
export const useGuessList = () => {
// 获取猜你喜欢组件实例
const guessRef = ref<XtxGuessInstance>()
// 滚动触底事件
const onScrolltolower = () => {
guessRef.value?.getMore()
}
// 返回 ref 和事件处理函数
return { guessRef, onScrolltolower }
}
会员中心页(我的):渲染用户头像,昵称,完善猜你喜欢分页加载。
<!-- src/pages/my/my.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores'
import { useGuessList } from '@/composables'
// 获取会员信息
const memberStore = useMemberStore() // [!code ++]
// 猜你喜欢组合式函数
const { guessRef, onScrolltolower } = useGuessList() // [!code ++]
</script>
<template>
<scroll-view class="viewport" scroll-y enable-back-to-top @scrolltolower="onScrolltolower">
<!-- 个人资料 -->
<view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
<!-- 情况1:已登录 -->
<view class="overview" v-if="memberStore.profile">
<navigator url="/pagesMember/profile/profile" hover-class="none">
<image class="avatar" :src="memberStore.profile.avatar" mode="aspectFill"></image>
</navigator>
<view class="meta">
<view class="nickname">
{{ memberStore.profile.nickname || memberStore.profile.account }}
</view>
<navigator class="extra" url="/pagesMember/profile/profile" hover-class="none">
<text class="update">更新头像昵称</text>
</navigator>
</view>
</view>
<!-- 情况2:未登录 -->
<view class="overview" v-else> ...省略 </view>
</view>
<!-- 猜你喜欢 -->
<view class="guess">
<XtxGuess ref="guessRef" />
</view>
</scroll-view>
</template>
会员设置页
会员模块的二级页面,按模块处理成分包页面,有以下好处:
- 按模块管理页面,方便项目维护。
- 减少主包体积,用到的时候再加载分包,属于性能优化解决方案。
tips:通过 VS Code 插件 uni-create-view 可以快速新建分包页面,自动配置分包路由。
实现步骤
-
设置页分包和预下载(官方文档),然后写好设置页的静态结构
- 小程序分包:将小程序的代码分割成多个部分,分别打包成多个小程序包,减少小程席的加载时间,提高用户体验
- 分包预下载:在进入小程序某个页面时,由框架自动预下载可能需要的分包,提升进入后续分包页面时的启动速度。
经验:分包一般是按照项目的业务模块划分,如会员模块分包,订单块分包等
- 退出登录:清理用户信息,返回上一页;根据登录状态,按需展示页面内容。
参考代码
设置分包规则:当用户进入【我的】页面时,由框架自动预下载【会员模块】的分包,提升进入后续分包页面时的启动速度。
// src/pages.json
{
// ...省略
// 分包加载规则
"subPackages": [
{
// 子包的根目录
"root": "pagesMember",
// 页面路径和窗口表现
"pages": [
{
"path": "settings/settings",
"style": {
"navigationBarTitleText": "设置"
}
}
]
}
],
// 分包预下载规则(如果不清楚可以去uni-app官网-全局文件-pages.json页面路由里找找)
"preloadRule": {
"pages/my/my": {
"network": "all", // [!code ++]
"packages": ["pagesMember"] // [!code ++]
}
}
}
静态结构
<!-- src/pagesMember/settings/settings.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="viewport">
<!-- 列表1 -->
<view class="list" v-if="true">
<navigator url="/pagesMember/address/address" hover-class="none" class="item arrow">
我的收货地址
</navigator>
</view>
<!-- 列表2 -->
<view class="list">
<button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
<button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
<button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
</view>
<!-- 列表3 -->
<view class="list">
<navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
</view>
<!-- 操作按钮 -->
<view class="action">
<view class="button">退出登录</view>
</view>
</view>
</template>
<style lang="scss">
page {
background-color: #f4f4f4;
}
.viewport {
padding: 20rpx;
}
/* 列表 */
.list {
padding: 0 20rpx;
background-color: #fff;
margin-bottom: 20rpx;
border-radius: 10rpx;
.item {
line-height: 90rpx;
padding-left: 10rpx;
font-size: 30rpx;
color: #333;
border-top: 1rpx solid #ddd;
position: relative;
text-align: left;
border-radius: 0;
background-color: #fff;
&::after {
width: auto;
height: auto;
left: auto;
border: none;
}
&:first-child {
border: none;
}
&::after {
right: 5rpx;
}
}
.arrow::after {
content: '\e6c2';
position: absolute;
top: 50%;
color: #ccc;
font-family: 'erabbit' !important;
font-size: 32rpx;
transform: translateY(-50%);
}
}
/* 操作按钮 */
.action {
text-align: center;
line-height: 90rpx;
margin-top: 40rpx;
font-size: 32rpx;
color: #333;
.button {
background-color: #fff;
margin-bottom: 20rpx;
border-radius: 10rpx;
}
}
</style>
退出登录
<!-- src/pagesMember/settings/settings.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores'
const memberStore = useMemberStore()
// 退出登录
const onLogout = () => {
// 模态弹窗
uni.showModal({
content: '是否退出登录?',
success: (res) => {
if (res.confirm) {
// 清理用户信息
memberStore.clearProfile()
// 返回上一页
uni.navigateBack()
}
},
})
}
</script>
<template>
<view class="viewport">
<!-- 列表1 -->
<view class="list" v-if="memberStore.profile">
<navigator url="./address/address" hover-class="none" class="item arrow">
我的收货地址
</navigator>
</view>
<!-- 列表2 -->
<view class="list">
<button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
<button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
<button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
</view>
<!-- 列表3 -->
<view class="list">
<navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
</view>
<!-- 操作按钮 -->
<view class="action" v-if="memberStore.profile">
<view @tap="onLogout" class="button">退出登录</view>
</view>
</view>
</template>
会员信息页
用户可以对会员信息进行更新操作,涉及到表单数据提交、图片读取、文件上传等知识点。
实现步骤
- 会员信息页也进行分包处理,然后写好静态结构,再自定义导航栏
-
获取会员信息:需要登录后才能获取信息,在项目起步模块已封装请求拦截器,会自动添加
token,无需再手动添加具体操作步骤跟前面的获取数据一致,都是《定义类型,封装请求接口,初始化调用》这三件套
- 渲染会员信息
- 修改用户头像
- 修改用户昵称
- 更新Store信息(这一部分就不在参考代码里单独演示了,直接写在 ”修改用户头像“ 跟 “修改用户昵称” 里)
- 修改性别
- 修改生日
- 修改城市
注意:为了不重复赘述html结构,从修改用户头像开始,以上函数的绑定均提前写在《渲染会员信息》小节中
参考代码
静态结构
<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>
<template>
<view class="viewport">
<!-- 导航栏 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator>
<view class="title">个人信息</view>
</view>
<!-- 头像 -->
<view class="avatar">
<view class="avatar-content">
<image class="image" src=" " mode="aspectFill" />
<text class="text">点击修改头像</text>
</view>
</view>
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">账号</text>
<text class="account">账号名</text>
</view>
<view class="form-item">
<text class="label">昵称</text>
<input class="input" type="text" placeholder="请填写昵称" value="" />
</view>
<view class="form-item">
<text class="label">性别</text>
<radio-group>
<label class="radio">
<radio value="男" color="#27ba9b" :checked="true" />
男
</label>
<label class="radio">
<radio value="女" color="#27ba9b" :checked="false" />
女
</label>
</radio-group>
</view>
<view class="form-item">
<text class="label">生日</text>
<picker
class="picker"
mode="date"
start="1900-01-01"
:end="new Date()"
value="2000-01-01"
>
<view v-if="false">2000-01-01</view>
<view class="placeholder" v-else>请选择日期</view>
</picker>
</view>
<view class="form-item">
<text class="label">城市</text>
<picker class="picker" mode="region" :value="['广东省', '广州市', '天河区']">
<view v-if="false">广东省广州市天河区</view>
<view class="placeholder" v-else>请选择城市</view>
</picker>
</view>
<view class="form-item">
<text class="label">职业</text>
<input class="input" type="text" placeholder="请填写职业" value="" />
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button">保 存</button>
</view>
</view>
</template>
<style lang="scss">
page {
background-color: #f4f4f4;
}
.viewport {
display: flex;
flex-direction: column;
height: 100%;
background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png);
background-size: auto 420rpx;
background-repeat: no-repeat;
}
/* 导航栏 */
.navbar {
position: relative;
.title {
height: 40px;
display: flex;
justify-content: center;
align-items: center;
font-size: 16px;
font-weight: 500;
color: #fff;
}
.back {
position: absolute;
height: 40px;
width: 40px;
left: 0;
font-size: 20px;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
}
}
/* 头像 */
.avatar {
text-align: center;
width: 100%;
height: 260rpx;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.image {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background-color: #eee;
}
.text {
display: block;
padding-top: 20rpx;
line-height: 1;
font-size: 26rpx;
color: #fff;
}
}
/* 表单 */
.form {
background-color: #f4f4f4;
&-content {
margin: 20rpx 20rpx 0;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;
}
&-item {
display: flex;
height: 96rpx;
line-height: 46rpx;
padding: 25rpx 10rpx;
background-color: #fff;
font-size: 28rpx;
border-bottom: 1rpx solid #ddd;
&:last-child {
border: none;
}
.label {
width: 180rpx;
color: #333;
}
.account {
color: #666;
}
.input {
flex: 1;
display: block;
height: 46rpx;
}
.radio {
margin-right: 20rpx;
}
.picker {
flex: 1;
}
.placeholder {
color: #808080;
}
}
&-button {
height: 80rpx;
text-align: center;
line-height: 80rpx;
margin: 30rpx 20rpx;
color: #fff;
border-radius: 80rpx;
font-size: 30rpx;
background-color: #27ba9b;
}
}
</style>
自定义导航栏
// src/pages.json
// 分包加载规则
"subPackages": [
{
// 子包的根目录
"root": "pagesMember",
// 页面路径和窗口表现
"pages": [
...
{
"path": "profile/profile",
"style": {
"navigationStyle": "custom",
"navigationBarTextStyle": "white",
"navigationBarTitleText": "个人信息"
}
}
]
}
],
获取会员信息
需要登录后才能获取用户个人信息,在 项目起步 模块已封装请求拦截器,拦截器中自动添加 token ,无需再手动添加。
类型声明
// src/types/member.d.ts
/** 个人信息 用户详情信息 */
export type ProfileDetail = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
/** 性别 */
gender?: Gender
/** 生日 */
birthday?: string
/** 省市区 */
fullLocation?: string
/** 职业 */
profession?: string
}
/** 性别 */
export type Gender = '女' | '男'
类型声明封装升级(可选),提取用户信息通用部分,再复用类型。
// src/types/member.d.ts
/** 封装通用信息 */
type BaseProfile = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
}
/** 小程序登录 登录用户信息 */
export type LoginResult = BaseProfile & {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
/** 手机号 */
mobile: string
/** 登录凭证 */
token: string
}
/** 个人信息 用户详情信息 */
export type ProfileDetail = BaseProfile & {
/** 性别 */
gender?: Gender
/** 生日 */
birthday?: string
/** 省市区 */
fullLocation?: string
/** 职业 */
profession?: string
}
/** 性别 */
export type Gender = '女' | '男'
接口调用
接口地址:/member/profile
请求方式:GET
登录权限: 是
请求参数:无
接口封装
// src/services/profile.ts
import type { ProfileDetail } from '@/types/member'
import { http } from '@/utils/http'
/**
* 获取个人信息
*/
export const getMemberProfileAPI = () => {
return http<ProfileDetail>({
method: 'GET',
url: '/member/profile',
})
}
渲染会员信息
<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
import { getMemberProfileAPI } from '@/services/member'
import type { ProfileDetail } from '@/types/member'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 获取个人信息
const profile = ref<ProfileDetail>()
const getMemberProfileData = async () => {
const res = await getMemberProfileAPI()
profile.value = res.result
}
onLoad(() => {
getMemberProfileData()
})
</script>
<template>
<view class="viewport">
<!-- 导航栏 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator>
<view class="title">个人信息</view>
</view>
<!-- 头像 -->
<view class="avatar">
<view class="avatar-content">
<image class="image" :src="profile?.avatar" mode="aspectFill" @tap="onAvatarChange" />
<text class="text">点击修改头像</text>
</view>
</view>
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">账号</text>
<text class="account">{{ profile?.account }}</text>
</view>
<view class="form-item">
<text class="label">昵称</text>
<input class="input" type="text" placeholder="请填写昵称" v-model="profile!.nickname" />
</view>
<view class="form-item">
<text class="label">性别</text>
<radio-group @change="onGenderChange">
<label class="radio">
<radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" />
男
</label>
<label class="radio">
<radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" />
女
</label>
</radio-group>
</view>
<view class="form-item">
<text class="label">出生日期</text>
<picker
class="picker"
mode="date"
:value="profile?.birthday"
start="1900-01-01"
:end="new Date()"
@change="onBirthdayChange"
>
<view v-if="profile?.birthday">{{ profile?.birthday }}</view>
<view class="placeholder" v-else>请选择日期</view>
</picker>
</view>
<view class="form-item">
<text class="label">城市</text>
<picker
class="picker"
:value="profile.fullLocation?.split(' ')"
mode="region"
@change="onFullLocationChange">
<view v-if="profile?.fullLocation">{{ profile?.fullLocation }}</view>
<view class="placeholder" v-else>请选择城市</view>
</picker>
</view>
<view class="form-item">
<text class="label">职业</text>
<input class="input" type="text" placeholder="请填写职业" v-model="profile!.profession" />
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
修改用户头像
- 通过
uni.chooseMedia()读取用户相册的照片或者拍照。 - 通过
uni.uploadFile()上传用户图片。
接口信息
接口地址:/member/profile/avatar
请求方式:POST
登录权限: 是
请求参数:
Body
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| name | 是 | 无 | 后端数据字段名 |
| filePath | 是 | 无 | 新头像 |
参考代码
修改用户头像、更新store信息
<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
import { useMemberStore } from '@/stores/index'
// 获取Store
const memberStore = useMemberStore()
// 修改头像
const onAvatarChange = () => {
// 调用拍照/选择图片
uni.chooseMedia({
// 文件个数
count: 1,
// 文件类型
mediaType: ['image'],
success: (res) => {
// 本地路径
const { tempFilePath } = res.tempFiles[0]
// 文件上传
uni.uploadFile({
url: '/member/profile/avatar', // [!code ++]
name: 'file', // 后端数据字段名 // [!code ++]
filePath: tempFilePath, // 新头像 // [!code ++]
success: (res) => {
// 判断状态码是否上传成功
if (res.statusCode === 200) {
// 提取头像
const { avatar } = JSON.parse(res.data).result
// 当前页面更新头像
profile.value!.avatar = avatar // [!code ++]
// 更新 Store 头像
memberStore.profile!.avatar = avatar // [!code ++]
uni.showToast({ icon: 'success', title: '更新成功' })
} else {
uni.showToast({ icon: 'error', title: '出现错误' })
}
},
})
},
})
}
</script>
<template>
<!-- 以上函数的调用去 “渲染会员信息” 里找 -->
...
</template>
tip 知识回顾
- 网页端上传文件用
Axios + FormData - 小程序端上传文件用 wx.uploadFile()
- 使用 uni.uploadFile() 能自动多端兼容
- 小程序端需配置上传文件安全域名
修改用户昵称
涉及到 <input>、<radio>、<picker> 表单组件的数据收集。
接口信息
接口地址:/member/profile
请求方式:PUT
登录权限: 是
请求参数:
Body
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| nickname | 是 | 无 | 用户昵称 |
| gender | 是 | 无 | 用户性别 |
| birthday | 是 | 无 | 用户生日 |
| profession | 是 | 无 | 用户职业 |
类型声明
// src/types/member.d.ts
/** 个人信息 修改请求体参数 */
export type ProfileParams = Pick<
ProfileDetail,
'nickname' | 'gender' | 'birthday' | 'profession'
> & {
/** 省份编码 */
provinceCode?: string
/** 城市编码 */
cityCode?: string
/** 区/县编码 */
countyCode?: string
}
接口封装
// src/services/member.ts
import type { ProfileParams } from '@/types/member'
/**
* 修改个人信息
* @param data 请求体参数
*/
export const putMemberProfileAPI = (data: ProfileParams) => {
return http<ProfileDetail>({
method: 'PUT',
url: '/member/profile',
data,
})
}
点击保存调用,并成功提示
<input> 组件使用 v-model 收集数据,<radio-group> 组件使用 @change 事件收集数据。
<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
import type { ProfileDetail } from '@/types/member'
import { putMemberProfileAPI } from '@/services/member'
import { useMemberStore } from '@/stores/index'
// 获取Store
const memberStore = useMemberStore()
// 获取个人信息,修改个人信息需提供初始值 // [!code ++]
const profile = ref({} as ProfileDetail) // [!code ++]
const profile = ref<ProfileDetail>() // [!code --]
// 提交表单
const onSubmit = async () => {
const res = await putMemberProfileAPI({
nickname: profile.value.nickname,
})
// 更新Store昵称
memberStore.profile!.nickname = res.result.nickname
// 成功提示
uni.showToast({ icon: 'success', title: '保存成功' })
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<!-- 以上函数的调用去 “渲染会员信息” 里找 -->
...
</template>
修改其他信息
分别修改性别、生日、城市、职业(这个仅需双向绑定即可,无需绑定事件)
<!-- src/pagesMember/profile/profile.vue -->
<script setup lang="ts">
import type { Gender } from '@/types/member'
// 修改性别
const onGenderChange: UniHelper.RadioGroupOnChange = (ev) => {
profile.value.gender = ev.detail.value as Gender
}
// 修改生日
const onBirthdayChange: UniHelper.DatePickerOnChange = (ev) => {
profile.value.birthday = ev.detail.value
}
// 修改城市
let fullLocationCode: [string, string, string] = ['', '', '']
const onFullLocationChange: UniHelper.RegionPickerOnChange = (ev) => {
// 修改前端界面
profile.value.fullLocation = ev.detail.value.join(' ')
// 提交后端更新
fullLocationCode = ev.detail.code!
}
// 点击保存提交表单
const onSubmit = async () => {
const { nickname, gender, birthday, profession } = prifile.value // 解构其中的字段
// 老师的这种解构写法有bug,会在修改其他字段然后保存时,会将空数组赋值给城市,从而修改为空
// const [provinceCode, cityCode, countyCode] = fullLocationCode
const res = await putMemberProfileAPI({
// nickname: profile.value.nickname, // 这种写法冗余,已弃用
nickname,
gender,
birthday,
profession,
// 采用传统写法赋值并加上判断即可,这样不会出现以上bug
provinceCode: fullLocationCode[0] || undefined,
cityCode: fullLocationCode[1] || undefined,
countyCode: fullLocationCode[2] || undefined,
})
...
}
</script>
<template>
<!-- 以上函数的调用去 “渲染会员信息” 里找 -->
...
</template>
九、地址模块
预期功能
- 能够获取不同类型的表单数据
- 能够动态设置导航栏的标题
- 能够使用 uni-ui 组件库的组件
- 能够完成收货地址的增删改查的功能
准备工作
地址模块共两个页面:地址管理页,新建地址页 ,划分到会员分包中。
实现步骤
- 新建分包页面
- 创建好静态结构(地址管理页,新建/修改地址页)
- 动态设置标题(用来区分“新建地址” 还是 “修改地址”)
参考代码
分包规则
// src/pages.json
// 分包加载规则
"subPackages": [
{
// 子包的根目录
"root": "pagesMember",
// 页面路径和窗口表现
"pages": [
...
{
"path": "address/address",
"style": {
"navigationBarTitleText": "地址管理"
}
},
{
"path": "address/address-form",
"style": {
"navigationBarTitleText": "新建/修改地址" // 这个会因为动态标题的设置而改变
}
}
]
}
],
静态结构
地址管理页
<!-- src/pagesMember/address/address.vue -->
<script setup lang="ts">
//
</script>
<template>
<view class="viewport">
<!-- 地址列表 -->
<scroll-view class="scroll-view" scroll-y>
<view v-if="true" class="address">
<view class="address-list">
<!-- 收货地址项 -->
<view class="item">
<view class="item-content">
<view class="user">
黑马小王子
<text class="contact">13111111111</text>
<text v-if="true" class="badge">默认</text>
</view>
<view class="locate">广东省 广州市 天河区 黑马程序员</view>
<navigator
class="edit"
hover-class="none"
:url="`/pagesMember/address/address-form?id=1`"
>
修改
</navigator>
</view>
</view>
<!-- 收货地址项 -->
<view class="item">
<view class="item-content">
<view class="user">
黑马小公主
<text class="contact">13222222222</text>
<text v-if="false" class="badge">默认</text>
</view>
<view class="locate">北京市 北京市 顺义区 黑马程序员</view>
<navigator
class="edit"
hover-class="none"
:url="`/pagesMember/address/address-form?id=2`"
>
修改
</navigator>
</view>
</view>
</view>
</view>
<view v-else class="blank">暂无收货地址</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="add-btn">
<navigator hover-class="none" url="/pagesMember/address/address-form">
新建地址
</navigator>
</view>
</view>
</template>
<style lang="scss">
page {
height: 100%;
overflow: hidden;
}
/* 删除按钮 */
.delete-button {
display: flex;
justify-content: center;
align-items: center;
width: 50px;
height: 100%;
font-size: 28rpx;
color: #fff;
border-radius: 0;
padding: 0;
background-color: #cf4444;
}
.viewport {
display: flex;
flex-direction: column;
height: 100%;
background-color: #f4f4f4;
.scroll-view {
padding-top: 20rpx;
}
}
.address {
padding: 0 20rpx;
margin: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;
.item-content {
line-height: 1;
padding: 40rpx 10rpx 38rpx;
border-bottom: 1rpx solid #ddd;
position: relative;
.edit {
position: absolute;
top: 36rpx;
right: 30rpx;
padding: 2rpx 0 2rpx 20rpx;
border-left: 1rpx solid #666;
font-size: 26rpx;
color: #666;
line-height: 1;
}
}
.item:last-child .item-content {
border: none;
}
.user {
font-size: 28rpx;
margin-bottom: 20rpx;
color: #333;
.contact {
color: #666;
}
.badge {
display: inline-block;
padding: 4rpx 10rpx 2rpx 14rpx;
margin: 2rpx 0 0 10rpx;
font-size: 26rpx;
color: #27ba9b;
border-radius: 6rpx;
border: 1rpx solid #27ba9b;
}
}
.locate {
line-height: 1.6;
font-size: 26rpx;
color: #333;
}
}
.blank {
margin-top: 300rpx;
text-align: center;
font-size: 32rpx;
color: #888;
}
.add-btn {
height: 80rpx;
text-align: center;
line-height: 80rpx;
margin: 30rpx 20rpx;
color: #fff;
border-radius: 80rpx;
font-size: 30rpx;
background-color: #27ba9b;
}
</style>
新建/修改地址页,这两个共用一个表单页
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
import { ref } from 'vue'
// 表单数据
const form = ref({
receiver: '', // 收货人
contact: '', // 联系方式
fullLocation: '', // 省市区(前端展示)
provinceCode: '', // 省份编码(后端参数)
cityCode: '', // 城市编码(后端参数)
countyCode: '', // 区/县编码(后端参数)
address: '', // 详细地址
isDefault: 0, // 默认地址,1为是,0为否
})
</script>
<template>
<view class="content">
<form>
<!-- 表单内容 -->
<view class="form-item">
<text class="label">收货人</text>
<input class="input" placeholder="请填写收货人姓名" value="" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input class="input" placeholder="请填写收货人手机号码" value="" />
</view>
<view class="form-item">
<text class="label">所在地区</text>
<picker class="picker" mode="region" value="">
<view v-if="false">广东省 广州市 天河区</view>
<view v-else class="placeholder">请选择省/市/区(县)</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
<input class="input" placeholder="街道、楼牌号等信息" value="" />
</view>
<view class="form-item">
<label class="label">设为默认地址</label>
<switch class="switch" color="#27ba9b" :checked="true" />
</view>
</form>
</view>
<!-- 提交按钮 -->
<button class="button">保存并使用</button>
</template>
<style lang="scss">
page {
background-color: #f4f4f4;
}
.content {
margin: 20rpx 20rpx 0;
padding: 0 20rpx;
border-radius: 10rpx;
background-color: #fff;
.form-item,
.uni-forms-item {
display: flex;
align-items: center;
min-height: 96rpx;
padding: 25rpx 10rpx 40rpx;
background-color: #fff;
font-size: 28rpx;
border-bottom: 1rpx solid #ddd;
position: relative;
margin-bottom: 0;
// 调整 uni-forms 样式
.uni-forms-item__content {
display: flex;
}
.uni-forms-item__error {
margin-left: 200rpx;
}
&:last-child {
border: none;
}
.label {
width: 200rpx;
color: #333;
}
.input {
flex: 1;
display: block;
height: 46rpx;
}
.switch {
position: absolute;
right: -20rpx;
transform: scale(0.8);
}
.picker {
flex: 1;
}
.placeholder {
color: #808080;
}
}
}
.button {
height: 80rpx;
margin: 30rpx 20rpx;
color: #fff;
border-radius: 80rpx;
font-size: 30rpx;
background-color: #27ba9b;
}
</style>
动态标题
新建地址 和 修改地址 共同复用同一个地址表单页,需要根据地址管理页的参数 id 来动态设置页面标题。
地址管理页(传递参数)
<!-- src/pagesMember/address/address.vue -->
...
<navigator
class="edit"
hover-class="none"
:url="`/pagesMember/address/address-form?id=${item.id}`"
>
修改
</navigator>
...
<navigator hover-class="none" url="`/pagesMember/address/address-form">
新建地址
</navigator>
...
新建地址页(获取参数)
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
// 获取页面参数
const query = defineProps<{
id?: string
}>()
// 动态设置标题(有传id过来就修改,没有就新建)
uni.setNavigationBarTitle({ title: query.id ? '修改地址' : '新建地址' })
</script>
新建地址页
新用户没有收货地址,先完成新建地址,新建成功返回地址管理页。
主要功能:前端收集表单的数据,提交表单给后端。
实现步骤
参考代码
接口调用
接口地址:/member/address
请求方式:POST
登录权限: 是
请求参数:
Body
| 字段名称 | 是否必须 | 类型 | 备注 |
|---|---|---|---|
| receiver | 是 | string | 收货人姓名 |
| contact | 是 | string | 收货人联系方式 |
| provinceCode | 是 | string | 省对应的 code |
| cityCode | 是 | string | 市对应的 code |
| countyCode | 是 | string | 区/县对应的 code |
| address | 是 | string | 收货人详细地址 |
| isDefault | 是 | number | 是否设置为默认地址(数值类型) |
接口封装
// src/services/address.ts
import type { AddressParams } from '@/types/address'
import { http } from '@/utils/http'
/**
* 添加收货地址
* @param data 请求参数
*/
export const postMemberAddressAPI = (data: AddressParams) => {
return http({
method: 'POST',
url: '/member/address',
data,
})
}
类型声明
// src/types/address.d.ts
/** 添加收货地址: 请求参数 */
export type AddressParams = {
/** 收货人姓名 */
receiver: string
/** 联系方式 */
contact: string
/** 省份编码 */
provinceCode: string
/** 城市编码 */
cityCode: string
/** 区/县编码 */
countyCode: string
/** 详细地址 */
address: string
/** 默认地址,1为是,0为否 */
isDefault: number
}
收集并提交 表单数据
新建地址的表单页,input 组件通过 v-model 获取数据,其他表单组件结合 @change 事件获取。
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
import { postMemberAddressAPI } from '@/services/address'
import { ref } from 'vue'
// 表单数据
const form = ref({
receiver: '', // 收货人
contact: '', // 联系方式
fullLocation: '', // 省市区(前端展示)
provinceCode: '', // 省份编码(后端参数)
cityCode: '', // 城市编码(后端参数)
countyCode: '', // 区/县编码(后端参数)
address: '', // 详细地址
isDefault: 0, // 默认地址,1为是,0为否
})
// 收集所在地区
const onRegionChange: UniHelper.RegionPickerOnChange = (ev) => {
// 省市区(前端展示)
form.value.fullLocation = ev.detail.value.join(' ')
// 省市区(后端参数)
const [provinceCode, cityCode, countyCode] = ev.detail.code!
// 合并数据
Object.assign(form.value, { provinceCode, cityCode, countyCode })
}
// 收集是否默认收货地址
const onSwitchChange: UniHelper.SwitchOnChange = (ev) => {
form.value.isDefault = ev.detail.value ? 1 : 0
}
// 提交表单
const onSubmit = async () => {
// 新建地址请求
await postMemberAddressAPI(form.value)
// 成功提示
uni.showToast({ icon: 'success', title: '添加成功' })
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<view class="content">
<form>
<!-- 表单内容 -->
<view class="form-item">
<text class="label">收货人</text>
<input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" />
</view>
<view class="form-item">
<text class="label">手机号码</text>
<input class="input" placeholder="请填写收货人手机号码" v-model="form.contact" />
</view>
<view class="form-item">
<text class="label">所在地区</text>
<picker
class="picker"
mode="region"
:value="form.fullLocation.split(' ')"
@change="onRegionChange"
>
<view v-if="form.fullLocation">{{ form.fullLocation }}</view>
<view v-else class="placeholder">请选择省/市/区(县)</view>
</picker>
</view>
<view class="form-item">
<text class="label">详细地址</text>
<input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" />
</view>
<view class="form-item">
<label class="label">设为默认地址</label>
<switch
class="switch"
color="#27ba9b"
:checked="form.isDefault === 1"
@change="onSwitchChange"
/>
</view>
</form>
</view>
<!-- 提交按钮 -->
<button @tap="onSubmit" class="button">保存并使用</button>
</template>
地址管理页
为了能及时看到新建的收货地址,需在 onShow 生命周期中获取地址列表数据。
实现步骤
参考代码
接口调用
接口地址:/member/address
请求方式:GET
登录权限: 是
请求参数:无
接口封装
// src/types/address.ts
import type { AddressItem } from '@/types/address'
import { http } from '@/utils/http'
/**
* 获取收货地址列表
*/
export const getMemberAddressAPI = () => {
return http<AddressItem[]>({
method: 'GET',
url: '/member/address',
})
}
定义类型(复用)
因为在前面新建地址页的类型声明中定义过 AddressParams,而AddressItem跟AddressParams相差无几,所以可以进行复用
// src/types/address.d.ts
// 普通写法,因为存在字段冗余,所以改成下面的复用写法
/** 收货地址项 */
// export type AddressItem = {
// /** 收货人姓名 */
// receiver: string
// /** 联系方式 */
// contact: string
// /** 省份编码 */
// provinceCode: string
// /** 城市编码 */
// cityCode: string
// /** 区/县编码 */
// countyCode: string
// /** 详细地址 */
// address: string
// /** 默认地址,1为是,0为否 */
// isDefault: number
// /** 收货地址 id */
// id: string
// /** 省市区 */
// fullLocation: string
// }
// 复用之前在address.d.ts中定义过的AddressParams,并添加上俩属于AddressItem的新字段
/** 收货地址项 */
export type AddressItem = AddressParams & {
/** 收货地址id */
id: string
/** 省市区 */
fullLocation: string
}
又因为之前商品页goods.d.ts 里也有一模一样的地址类型,所以可删掉商品页里的 AddressItem,改为复用地址页address.d.ts 里面的
// src/types/goods.d.ts
+ import type { AddressItem } from './address'
/** 商品信息 */
export type GoodsResult = {
...
/** 用户地址列表[ 地址信息 ] */
userAddresses: AddressItem[] // 不用下面定义的,改用address里一模一样的
}
- /** 地址信息 */
- export type AddressItem = {
- receiver: string
- contact: string
- provinceCode: string
- cityCode: string
- countyCode: string
- address: string
- isDefault: number
- id: string
- fullLocation: string
- }
温馨提示
用户登录后再访问商品详情,商品详情字段中包含用户收货地址列表,可以复用收货地址类型。
渲染列表
<!-- src/pagesMember/address/address.vue -->
<script setup lang="ts">
import { getMemberAddressAPI } from '@/services/address'
import type { AddressItem } from '@/types/address'
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取收货地址列表数据
const addressList = ref<AddressItem[]>([])
const getMemberAddressData = async () => {
const res = await getMemberAddressAPI()
addressList.value = res.result
}
// 初始化调用(页面显示)
onShow(() => {
getMemberAddressData()
})
</script>
<template>
<view class="viewport">
<!-- 地址列表 -->
<scroll-view class="scroll-view" scroll-y>
<view v-if="true" class="address">
<view class="address-list">
<!-- 收获地址项 -->
<view class="item" v-for="item in addressList" :key="item.id">
<view class="item-content">
<view class="user">
{{ item.receiver }}
<text class="contact">{{ item.contact }}</text>
<text v-if="item.isDefault" class="badge">默认</text>
</view>
<view class="locate">{{ item.fullLocation }} {{ item.address }}</view>
<navigator
class="edit"
hover-class="none"
:url="`/pagesMember/address/address-form?id=${item.id}`"
>
修改
</navigator>
</view>
</view>
</view>
</view>
<view v-else class="blank">暂无收货地址</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="add-btn">
<navigator hover-class="none" url="/pagesMember/address/address-form">
新建地址
</navigator>
</view>
</view>
</template>
修改地址页
通过页面参数 id 来区分当前是修改地址还是新建地址。
数据回显
修改地址之前,需要先实现数据回显(先显示对应的表单数据),用户再进行有针对性的修改。
接口详情
接口地址:/member/address/:id
请求方式:GET
登录权限: 是
请求参数:
路径参数
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| id | 是 | 无 | 收货地址 ID |
接口封装
// src/services/address.ts
import type { AddressItem } from '@/types/address'
import { http } from '@/utils/http'
/**
* 获取收货地址详情
* @param id 地址id(路径参数)
*/
export const getMemberAddressByIdAPI = (id: string) => {
return http<AddressItem[]>({
method: 'GET',
url: `/member/address/${id}`,
})
}
注意别跟 “获取收货地址列表” 搞混了
数据初始化并调用
页面初始化的时候根据 id 获取地址详情,把获取的数据合并到表单数据中,用于数据回显。
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { getMemberAddressByIdAPI } from '@/services/address'
// 获取收货地址详情数据
const getMemberAddressData = async () => {
// 有 id 才调用接口
if (query.id) {
// 发送请求
const res = await getMemberAddressByIdAPI(query.id)
// 把数据合并到表单中
Object.assign(form.value, res.result)
}
}
// 页面加载
onLoad(() => {
getMemberAddressData()
})
</script>
更新地址
根据id来决定调用修改/新建地址的接口,将用户修改后的地址信息重新发送到服务端进行存储。
接口详情
接口地址:/member/address/:id
请求方式:PUT
登录权限: 是
请求参数:
路径参数
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| id | 是 | 无 | 收货地址 ID |
Body
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| receiver | 是 | 无 | 收货人姓名 |
| contact | 是 | 无 | 收货人联系方式 |
| provinceCode | 是 | 无 | 行政省对应的 code |
| cityCode | 是 | 无 | 行政市对应的 code |
| countyCode | 是 | 无 | 行政区县对应的 code |
| address | 是 | 无 | 收货人详细地址 |
| isDefault | 是 | 无 | 是否设置为默认地址(数值类型) |
接口封装
// src/services/address.ts
/**
* 修改收货地址
* @param id 地址id(路径参数)
* @param data 表单数据(请求体参数)
*/
export const putMemberAddressByIdAPI = (id: string, data: AddressParams) => {
return http({
method: 'PUT',
url: `/member/address/${id}`,
data,
})
}
保存提交表单
根据是否有地址 id 来判断提交表单到底是新建地址还是更新地址。
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
// 提交表单
const onSubmit = async () => {
// 判断当前页面是否有地址 id
if (query.id) {
// 修改地址请求
await putMemberAddressByIdAPI(query.id, form.value)
} else {
// 新建地址请求
await postMemberAddressAPI(form.value)
}
// 成功提示
uni.showToast({ icon: 'success', title: query.id ? '修改成功' : '添加成功' })
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
表单校验
通过 uni-ui 组件库的 uni-forms 组件实现表单校验。
实现步骤
- 定义校验规则
- 修改表单结构:uni-forms、uni-form-item
- 绑定校验规则::rules="rules",:model="form",ref="formRef",name="xxx"
- 提交时校验表单
参考代码
<!-- src/pagesMember/address/address-form.vue -->
<script setup lang="ts">
// 定义校验规则
const rules: UniHelper.UniFormsRules = {
receiver: {
rules: [{ required: true, errorMessage: '请输入收货人姓名' }],
},
contact: {
rules: [
{ required: true, errorMessage: '请输入联系方式' },
{ pattern: /^1[3-9]\d{9}$/, errorMessage: '手机号格式不正确' },
],
},
fullLocation: {
rules: [{ required: true, errorMessage: '请选择所在地区' }],
},
address: {
rules: [{ required: true, errorMessage: '请选择详细地址' }],
},
}
// 获取表单组件实例,用于调用表单方法
const formRef = ref<UniHelper.UniFormsInstance>() // [!code ++]
// 提交表单
const onSubmit = async () => {
try {
// 表单校验
await formRef.value?.validate?.() // [!code ++]
// 校验通过后再发送请求
if (query.id) {
// 修改地址请求
await putMemberAddressByIdAPI(query.id, form.value)
} else {
// 新建地址请求
await postMemberAddressAPI(form.value)
}
// 成功提示
uni.showToast({ icon: 'success', title: query.id ? '修改成功' : '添加成功' })
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
} catch (error) {
uni.showToast({ icon: 'error', title: '请填写完整信息' }) // [!code ++]
}
}
</script>
<template>
<view class="content">
<uni-forms :rules="rules" :model="form" ref="formRef">
<!-- 表单内容 -->
<uni-forms-item name="receiver" class="form-item">
<text class="label">收货人</text>
<input class="input" placeholder="请填写收货人姓名" v-model="form.receiver" />
</uni-forms-item>
<uni-forms-item name="contact" class="form-item">
<text class="label">手机号码</text>
<input
class="input"
placeholder="请填写收货人手机号码"
:maxlength="11"
v-model="form.contact"
/>
</uni-forms-item>
<uni-forms-item name="fullLocation" class="form-item">
<text class="label">所在地区</text>
<picker
class="picker"
@change="onRegionChange"
mode="region"
:value="form.fullLocation.split(' ')"
>
<view v-if="form.fullLocation">{{ form.fullLocation }}</view>
<view v-else class="placeholder">请选择省/市/区(县)</view>
</picker>
</uni-forms-item>
<uni-forms-item name="address" class="form-item">
<text class="label">详细地址</text>
<input class="input" placeholder="街道、楼牌号等信息" v-model="form.address" />
</uni-forms-item>
<view class="form-item">
<label class="label">设为默认地址</label>
<switch
class="switch"
color="#27ba9b"
@change="onSwitchChange"
:checked="form.isDefault === 1"
/>
</view>
</uni-forms>
</view>
<!-- 提交按钮 -->
<button @tap="onSubmit" class="button">保存并使用</button>
</template>
删除地址
通过 uni-ui 组件库的 uni-swipe-action 组件实现侧滑删除。
实现步骤
参考代码
侧滑组件用法
<template>
<!-- 滑动操作分区 -->
<uni-swipe-action>
<!-- 滑动操作项 -->
<uni-swipe-action-item>
<!-- 默认插槽 -->
<view>内容</view>
<!-- 右侧插槽 -->
<template #right>
<button class="delete-button">删除</button>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</template>
接口详情
接口地址:/member/address/:id
请求方式:DELETE
登录权限: 是
请求参数:
路径参数
| 字段名称 | 是否必须 | 默认值 | 备注 |
|---|---|---|---|
| id | 是 | 无 | ID |
接口封装
// src/services/address.ts
/**
* 删除收货地址
* @param id 地址id(路径参数)
*/
export const deleteMemberAddressByIdAPI = (id: string) => {
return http({
method: 'DELETE',
url: `/member/address/${id}`,
})
}
绑定删除事件
侧滑地址列表项,右侧显示删除按钮,删除地址前需二次确认,调用删除API。
<!-- src/pagesMember/address/address.vue -->
<script setup lang="ts">
// 删除收货地址
const onDeleteAddress = (id: string) => {
// 二次确认
uni.showModal({
content: '删除地址?',
success: async (res) => {
if (res.confirm) {
// 根据id删除收货地址
await deleteMemberAddressByIdAPI(id)
// 重新获取收货地址列表
getMemberAddressData()
}
},
})
}
</script>
<template>
<view class="viewport">
<!-- 地址列表 -->
<scroll-view class="scroll-view" scroll-y>
<view v-if="addressList.length" class="address">
<uni-swipe-action class="address-list">
<!-- 收货地址项 -->
<uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id">
<view class="item-content">
<view class="user">
{{ item.receiver }}
<text class="contact">{{ item.contact }}</text>
<text v-if="item.isDefault" class="badge">默认</text>
</view>
<view class="locate">{{ item.fullLocation }} {{ item.address }}</view>
<navigator
class="edit"
hover-class="none"
:url="`/pagesMember/address/address-form?id=${item.id}`"
>
修改
</navigator>
</view>
<!-- 右侧插槽 -->
<template #right>
<button @tap="onDeleteAddress(item.id)" class="delete-button">删除</button>
</template>
</uni-swipe-action-item>
</uni-swipe-action>
</view>
<view v-else class="blank">暂无收货地址</view>
</scroll-view>
<!-- 添加按钮 -->
<view class="add-btn">
<navigator hover-class="none" url="/pagesMember/address/address-form">
新建地址
</navigator>
</view>
</view>
</template>
未完待续...