微信小程序电商实战 05:商品模块——列表页、详情页与 SKU 选择
微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 ← 本文 | 06 购物车与订单 | 07 微信支付 | 08 用户中心 | 09 性能优化 | 10 上线部署
# 商品模块
## 首页
### 轮播图 Swiper
### 分类快捷入口
### 推荐商品列表
## 商品列表页
### 左右双栏布局
- 左:分类 Tabs
- 右:商品网格
### 无限滚动加载
- InfiniteLoading
- 分页参数管理
### 筛选与排序
## 商品详情页
### 图片轮播
### 商品信息区
- 价格 / 标题 / 销量
### 规格选择
- SKU 弹窗
- 数量调节
### 购买操作栏
- 加入购物车
- 立即购买
## 数据层
### useProductStore
### 无限滚动 composable
### 云函数调用
一、首页结构
首页采用「Banner + 分类入口 + 推荐商品」的标准电商布局:
<!-- src/pages/index/index.vue -->
<template>
<view class="page">
<!-- Banner 轮播 -->
<swiper class="banner" autoplay circular :interval="3000">
<swiper-item v-for="banner in banners" :key="banner.id">
<nut-image :src="banner.image" width="100%" height="160px" fit="cover" />
</swiper-item>
</swiper>
<!-- 分类快捷入口 -->
<view class="category-grid">
<view
v-for="cat in categories"
:key="cat._id"
class="category-item"
@tap="goCategory(cat._id)"
>
<nut-image :src="cat.icon" width="48px" height="48px" />
<text>{{ cat.name }}</text>
</view>
</view>
<!-- 推荐商品 -->
<view class="section-title">热门推荐</view>
<view class="product-grid">
<ProductCard
v-for="product in products"
:key="product._id"
:product="product"
@open-sku="openSkuPanel"
/>
</view>
<!-- SKU 选择弹窗 -->
<nut-sku
v-model:visible="skuVisible"
:sku="currentSku.tree"
:goods="currentSku.goods"
@add-cart="handleAddCart"
@buy-clicked="handleBuyNow"
/>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { Sku as NutSku, Image as NutImage, showToast } from '@nutui/nutui-taro'
import ProductCard from '@/components/ProductCard.vue'
import { getProductList, getCategories } from '@/api/product'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const banners = ref([])
const categories = ref([])
const products = ref([])
const skuVisible = ref(false)
const currentSku = ref({ tree: [], goods: [] })
onMounted(async () => {
const [catsRes, productsRes] = await Promise.all([
getCategories(),
getProductList({ page: 1, pageSize: 10 }),
])
categories.value = catsRes
products.value = productsRes.list
})
const openSkuPanel = async (productId: string) => {
const product = await getProductDetail(productId)
currentSku.value = { tree: product.skuTree, goods: product.skuList }
skuVisible.value = true
}
const handleAddCart = (skuInfo: any) => {
cartStore.addItem({
id: skuInfo.goodsId,
skuId: skuInfo.skuId,
name: skuInfo.goodsInfo.name,
price: Number(skuInfo.price),
quantity: skuInfo.number,
image: skuInfo.goodsInfo.coverImage,
})
showToast.success('已加入购物车')
skuVisible.value = false
}
const handleBuyNow = (skuInfo: any) => {
skuVisible.value = false
Taro.navigateTo({ url: `/pages/order/confirm?skuId=${skuInfo.skuId}&qty=${skuInfo.number}` })
}
const goCategory = (catId: string) => {
Taro.navigateTo({ url: `/pages/index/list?categoryId=${catId}` })
}
</script>
二、商品列表页(左分类 + 右商品)
经典电商双栏布局:
<!-- src/pages/index/list.vue -->
<template>
<view class="product-list-page">
<!-- 左侧分类 -->
<scroll-view class="category-sidebar" scroll-y>
<view
v-for="cat in categories"
:key="cat._id"
:class="['category-item', { active: activeCatId === cat._id }]"
@tap="switchCategory(cat._id)"
>
{{ cat.name }}
</view>
</scroll-view>
<!-- 右侧商品列表(带无限滚动) -->
<scroll-view class="product-content" scroll-y @scrolltolower="loadMore">
<view class="product-grid">
<ProductCard
v-for="product in products"
:key="product._id"
:product="product"
@open-sku="openSkuPanel"
/>
</view>
<!-- 加载状态 -->
<view class="load-more">
<nut-loading v-if="loading" />
<text v-else-if="noMore" class="no-more">— 没有更多了 —</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import Taro from '@tarojs/taro'
import { Loading as NutLoading } from '@nutui/nutui-taro'
import ProductCard from '@/components/ProductCard.vue'
import { getProductList, getCategories } from '@/api/product'
const { categoryId } = Taro.getCurrentInstance().router?.params ?? {}
const categories = ref([])
const products = ref([])
const activeCatId = ref(categoryId as string || '')
const page = ref(1)
const loading = ref(false)
const noMore = ref(false)
const PAGE_SIZE = 10
const loadProducts = async (reset = false) => {
if (loading.value || noMore.value) return
loading.value = true
try {
const res = await getProductList({
categoryId: activeCatId.value,
page: page.value,
pageSize: PAGE_SIZE,
})
if (reset) {
products.value = res.list
} else {
products.value.push(...res.list)
}
noMore.value = products.value.length >= res.total
page.value++
} finally {
loading.value = false
}
}
const switchCategory = (catId: string) => {
activeCatId.value = catId
page.value = 1
noMore.value = false
loadProducts(true)
}
const loadMore = () => loadProducts()
// 初始化
getCategories().then(cats => {
categories.value = cats
if (!activeCatId.value && cats.length) {
activeCatId.value = cats[0]._id
}
loadProducts(true)
})
</script>
<style lang="scss">
.product-list-page {
display: flex;
height: 100vh;
}
.category-sidebar {
width: 90px;
background: #f5f5f5;
flex-shrink: 0;
}
.category-item {
padding: 16px 8px;
font-size: 13px;
text-align: center;
color: #333;
&.active {
background: #fff;
color: #E31D1A;
font-weight: 600;
border-left: 3px solid #E31D1A;
}
}
.product-content {
flex: 1;
overflow: hidden;
}
.product-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 8px;
}
.no-more {
display: block;
text-align: center;
padding: 16px;
font-size: 12px;
color: #ccc;
}
</style>
三、商品详情页
<!-- src/pages/product/index.vue -->
<template>
<view class="product-detail">
<!-- 商品图片轮播 -->
<swiper class="image-swiper" :indicator-dots="true" circular>
<swiper-item v-for="(img, i) in product.images" :key="i">
<nut-image :src="img" width="100%" height="375px" fit="cover" />
</swiper-item>
</swiper>
<!-- 价格信息 -->
<view class="price-section">
<nut-price :price="selectedSku?.price ?? product.price" size="large" />
<nut-price :price="product.originalPrice" size="small" :strikethrough="true" />
<text class="sold-count">已售 {{ product.soldCount }}</text>
</view>
<!-- 商品标题 -->
<view class="title-section">
<text class="product-title">{{ product.name }}</text>
</view>
<!-- 规格选择入口 -->
<view class="sku-entry" @tap="skuVisible = true">
<text class="sku-label">规格</text>
<text class="sku-value">{{ selectedSkuDesc || '请选择规格' }}</text>
<text class="sku-arrow">›</text>
</view>
<!-- 商品详情图(富文本/图片列表) -->
<view class="detail-section">
<view class="section-title">商品详情</view>
<nut-image
v-for="(img, i) in product.detailImages"
:key="i"
:src="img"
width="100%"
lazy-load
/>
</view>
<!-- 底部操作栏 -->
<view class="action-bar">
<view class="action-icon" @tap="goHome">
<nut-icon name="home" /><text>首页</text>
</view>
<view class="action-icon" @tap="goCart">
<nut-badge :value="cartStore.totalCount">
<nut-icon name="cart" />
</nut-badge>
<text>购物车</text>
</view>
<nut-button type="warning" @tap="openSku('cart')">加入购物车</nut-button>
<nut-button type="primary" @tap="openSku('buy')">立即购买</nut-button>
</view>
<!-- SKU 弹窗 -->
<nut-sku
v-model:visible="skuVisible"
:sku="product.skuTree"
:goods="product.skuList"
:action-type="skuAction"
@select-sku="onSelectSku"
@add-cart="onAddCart"
@buy-clicked="onBuyNow"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { Badge as NutBadge, Button as NutButton, Sku as NutSku,
Price as NutPrice, Image as NutImage, showToast } from '@nutui/nutui-taro'
import { getProductDetail } from '@/api/product'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const { id } = Taro.getCurrentInstance().router?.params ?? {}
const product = ref<any>({})
const skuVisible = ref(false)
const skuAction = ref<'cart' | 'buy'>('cart')
const selectedSku = ref<any>(null)
const selectedSkuDesc = computed(() => {
if (!selectedSku.value) return ''
return Object.values(selectedSku.value).filter(Boolean).join(' / ')
})
onMounted(async () => {
product.value = await getProductDetail(id as string)
Taro.setNavigationBarTitle({ title: product.value.name })
})
const openSku = (action: 'cart' | 'buy') => {
skuAction.value = action
skuVisible.value = true
}
const onSelectSku = (info: any) => {
selectedSku.value = info.selectObj
}
const onAddCart = (info: any) => {
cartStore.addItem({
id: info.goodsId,
skuId: info.skuId,
name: product.value.name,
price: Number(info.price),
quantity: info.number,
image: product.value.coverImage,
})
showToast.success('已加入购物车')
skuVisible.value = false
}
const onBuyNow = (info: any) => {
skuVisible.value = false
Taro.navigateTo({
url: `/pages/order/confirm?skuId=${info.skuId}&qty=${info.number}`,
})
}
const goHome = () => Taro.switchTab({ url: '/pages/index/index' })
const goCart = () => Taro.switchTab({ url: '/pages/cart/index' })
</script>
微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 ← 本文 | 06 购物车与订单 | 07 微信支付 | 08 用户中心 | 09 性能优化 | 10 上线部署
实操清单
- 创建
src/pages/index/index.vue,实现 Banner + 分类入口 + 推荐商品列表 - 创建
src/pages/index/list.vue,实现左右双栏布局(分类侧边栏 + 商品网格) - 在
app.config.ts注册pages/index/list路由 - 实现
loadMore()无限滚动加载逻辑,确认noMore状态生效(底部显示"没有更多了") - 创建
src/pages/product/index.vue,实现图片轮播 + 价格 + SKU 入口 - 在
app.config.ts注册pages/product/index路由 - 在 products 集合插入含
skuTree和skuList的商品,验证详情页 SKU 弹窗数据正确渲染 - 验证「加入购物车」后
cartStore.totalCount增加,购物车 tab 角标更新 - 验证「立即购买」跳转到
/pages/order/confirm(页面可以是空的占位) - 检查图片在模拟器中懒加载效果正常,不出现空白闪烁