微信小程序电商实战 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 集合插入含 skuTreeskuList 的商品,验证详情页 SKU 弹窗数据正确渲染
  • 验证「加入购物车」后 cartStore.totalCount 增加,购物车 tab 角标更新
  • 验证「立即购买」跳转到 /pages/order/confirm(页面可以是空的占位)
  • 检查图片在模拟器中懒加载效果正常,不出现空白闪烁