微信小程序电商实战 06:购物车与订单——Pinia 状态设计与完整流程

微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 | 06 购物车与订单 ← 本文 | 07 微信支付 | 08 用户中心 | 09 性能优化 | 10 上线部署


# 购物车与订单 ## 购物车 Store(Pinia) ### state - items 商品列表 - checkedIds 已选中 ### getters - totalCount 总数量(角标) - checkedItems 选中商品 - totalPrice 结算金额 ### actions - addItem / removeItem - updateQuantity - toggleCheck / checkAll - clearChecked(结算后) ## 购物车页面 ### 全选/反选 ### 滑动删除(SwipeCell) ### 数量调节(InputNumber) ### 底部结算栏 ## 订单确认页 ### 地址选择 ### 商品清单快照 ### 价格明细 ### 提交订单(云函数) ## 订单列表页 ### Tab 切换状态 ### 订单卡片 ### 操作按钮(支付/取消/确认收货) ## 云函数 ### order/create ### order/list ### order/cancel

一、完善购物车 Store

// src/stores/cart.ts
import { defineStore } from 'pinia'

export interface CartItem {
  id: string
  skuId: string
  name: string
  skuDesc: string
  price: number
  quantity: number
  image: string
  stock: number
}

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
    checkedIds: new Set<string>() as Set<string>,
  }),
  getters: {
    totalCount: (state): number =>
      state.items.reduce((sum, i) => sum + i.quantity, 0),

    checkedItems: (state): CartItem[] =>
      state.items.filter(i => state.checkedIds.has(i.skuId)),

    totalPrice: (state): number =>
      state.items
        .filter(i => state.checkedIds.has(i.skuId))
        .reduce((sum, i) => sum + i.price * i.quantity, 0),

    isAllChecked: (state): boolean =>
      state.items.length > 0 && state.items.every(i => state.checkedIds.has(i.skuId)),
  },
  actions: {
    addItem(item: Omit<CartItem, 'skuDesc'> & { skuDesc?: string }) {
      const existing = this.items.find(i => i.skuId === item.skuId)
      if (existing) {
        existing.quantity = Math.min(existing.quantity + item.quantity, existing.stock)
      } else {
        this.items.push({ skuDesc: '', ...item })
        this.checkedIds.add(item.skuId)  // 新加入默认勾选
      }
    },
    removeItem(skuId: string) {
      this.items = this.items.filter(i => i.skuId !== skuId)
      this.checkedIds.delete(skuId)
    },
    updateQuantity(skuId: string, quantity: number) {
      const item = this.items.find(i => i.skuId === skuId)
      if (item) item.quantity = Math.max(1, Math.min(quantity, item.stock))
    },
    toggleCheck(skuId: string) {
      if (this.checkedIds.has(skuId)) {
        this.checkedIds.delete(skuId)
      } else {
        this.checkedIds.add(skuId)
      }
    },
    checkAll(checked: boolean) {
      if (checked) {
        this.items.forEach(i => this.checkedIds.add(i.skuId))
      } else {
        this.checkedIds.clear()
      }
    },
    removeChecked() {
      const ids = new Set(this.checkedIds)
      this.items = this.items.filter(i => !ids.has(i.skuId))
      this.checkedIds.clear()
    },
  },
})

二、购物车页面

<!-- src/pages/cart/index.vue -->
<template>
  <view class="cart-page">
    <view v-if="cartStore.items.length === 0" class="empty">
      <nut-empty description="购物车空空如也" image="empty" />
      <nut-button type="primary" @tap="goShopping">去逛逛</nut-button>
    </view>

    <template v-else>
      <scroll-view scroll-y class="cart-list">
        <nut-swipe-cell
          v-for="item in cartStore.items"
          :key="item.skuId"
        >
          <view class="cart-item">
            <nut-checkbox
              :model-value="cartStore.checkedIds.has(item.skuId)"
              @update:model-value="cartStore.toggleCheck(item.skuId)"
            />
            <nut-image :src="item.image" width="80px" height="80px" fit="cover" radius="4px" />
            <view class="item-info">
              <text class="item-name">{{ item.name }}</text>
              <text class="item-sku">{{ item.skuDesc }}</text>
              <view class="item-price-row">
                <nut-price :price="item.price" />
                <nut-input-number
                  v-model="item.quantity"
                  :min="1"
                  :max="item.stock"
                  @change="val => cartStore.updateQuantity(item.skuId, val)"
                />
              </view>
            </view>
          </view>

          <!-- 滑动删除按钮 -->
          <template #right>
            <nut-button type="danger" @tap="cartStore.removeItem(item.skuId)">删除</nut-button>
          </template>
        </nut-swipe-cell>
      </scroll-view>

      <!-- 底部结算栏 -->
      <view class="checkout-bar">
        <nut-checkbox
          :model-value="cartStore.isAllChecked"
          @update:model-value="cartStore.checkAll"
        >全选</nut-checkbox>
        <view class="total">
          <text>合计:</text>
          <nut-price :price="cartStore.totalPrice" size="large" />
        </view>
        <nut-button
          type="primary"
          :disabled="cartStore.checkedItems.length === 0"
          @tap="goCheckout"
        >
          结算({{ cartStore.checkedItems.length }})
        </nut-button>
      </view>
    </template>
  </view>
</template>

<script setup lang="ts">
import Taro from '@tarojs/taro'
import { Checkbox as NutCheckbox, SwipeCell as NutSwipeCell,
         InputNumber as NutInputNumber, Button as NutButton,
         Price as NutPrice, Image as NutImage, Empty as NutEmpty } from '@nutui/nutui-taro'
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()

const goShopping = () => Taro.switchTab({ url: '/pages/index/index' })

const goCheckout = () => {
  if (cartStore.checkedItems.length === 0) return
  Taro.navigateTo({ url: '/pages/order/confirm' })
}
</script>

三、订单确认页

<!-- src/pages/order/confirm.vue -->
<template>
  <view class="confirm-page">
    <!-- 收货地址 -->
    <view class="address-card" @tap="selectAddress">
      <template v-if="address">
        <text class="addr-name">{{ address.name }} {{ address.phone }}</text>
        <text class="addr-detail">{{ address.province }}{{ address.city }}{{ address.district }}{{ address.detail }}</text>
      </template>
      <view v-else class="addr-empty">
        <text>+ 添加收货地址</text>
      </view>
      <text class="addr-arrow">›</text>
    </view>

    <!-- 商品清单 -->
    <view class="goods-card">
      <view v-for="item in cartStore.checkedItems" :key="item.skuId" class="goods-item">
        <nut-image :src="item.image" width="64px" height="64px" fit="cover" />
        <view class="goods-info">
          <text class="goods-name">{{ item.name }}</text>
          <text class="goods-sku">{{ item.skuDesc }}</text>
          <view class="goods-price-row">
            <nut-price :price="item.price" />
            <text class="goods-qty">×{{ item.quantity }}</text>
          </view>
        </view>
      </view>
    </view>

    <!-- 价格明细 -->
    <view class="price-card">
      <view class="price-row">
        <text>商品金额</text>
        <nut-price :price="cartStore.totalPrice" />
      </view>
      <view class="price-row">
        <text>运费</text>
        <text>免运费</text>
      </view>
      <view class="price-row total">
        <text>实付款</text>
        <nut-price :price="cartStore.totalPrice" size="large" />
      </view>
    </view>

    <!-- 底部提交 -->
    <view class="submit-bar">
      <nut-price :price="cartStore.totalPrice" size="large" />
      <nut-button type="primary" :loading="submitting" @tap="submitOrder">
        提交订单
      </nut-button>
    </view>
  </view>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import { Button as NutButton, Price as NutPrice, Image as NutImage, showToast } from '@nutui/nutui-taro'
import { useCartStore } from '@/stores/cart'
import { useUserStore } from '@/stores/user'
import { createOrder } from '@/api/order'

const cartStore = useCartStore()
const userStore = useUserStore()
const submitting = ref(false)
const address = ref(userStore.defaultAddress)

const selectAddress = () => {
  Taro.navigateTo({ url: '/pages/user/address?mode=select' })
}

const submitOrder = async () => {
  if (!address.value) {
    showToast.fail('请先填写收货地址')
    return
  }
  submitting.value = true
  try {
    const order = await createOrder({
      items: cartStore.checkedItems,
      address: address.value,
      totalAmount: cartStore.totalPrice,
    })
    cartStore.removeChecked()
    // 跳转支付页(下一篇)
    Taro.navigateTo({ url: `/pages/order/pay?orderId=${order._id}` })
  } catch (e) {
    showToast.fail('下单失败,请重试')
  } finally {
    submitting.value = false
  }
}
</script>

四、创建订单云函数

// cloudfunctions/order/index.js
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()

exports.main = async (event, context) => {
  const { OPENID } = cloud.getWXContext()
  const { action } = event

  if (action === 'create') {
    const { items, address, totalAmount } = event
    const orderNo = Date.now().toString()

    const orderId = `order_${orderNo}`

    // 事务创建订单
    await db.collection('orders').add({
      data: {
        _id: orderId,
        _openid: OPENID,
        orderNo,
        status: 1,
        statusText: '待付款',
        totalAmount,
        address,
        createdAt: db.serverDate(),
        updatedAt: db.serverDate(),
      },
    })

    // 批量写入订单商品
    await Promise.all(items.map(item =>
      db.collection('order_items').add({
        data: {
          orderId,
          _openid: OPENID,
          productId: item.id,
          skuId: item.skuId,
          productName: item.name,
          skuDesc: item.skuDesc,
          coverImage: item.image,
          price: item.price,
          quantity: item.quantity,
          totalPrice: item.price * item.quantity,
        },
      })
    ))

    return { _id: orderId, orderNo }
  }

  if (action === 'list') {
    const { status, page = 1, pageSize = 10 } = event
    const query = db.collection('orders').where({ _openid: OPENID })
    if (status) query.where({ status })
    const res = await query
      .orderBy('createdAt', 'desc')
      .skip((page - 1) * pageSize)
      .limit(pageSize)
      .get()
    return res.data
  }

  if (action === 'cancel') {
    await db.collection('orders').doc(event.orderId).update({
      data: { status: -1, statusText: '已取消', updatedAt: db.serverDate() },
    })
    return { success: true }
  }
}

微信小程序电商实战 01 技术选型 | 02 脚手架搭建 | 03 NutUI 配置 | 04 云开发环境 | 05 商品模块 | 06 购物车与订单 ← 本文 | 07 微信支付 | 08 用户中心 | 09 性能优化 | 10 上线部署

实操清单

  • 完善 useCartStore:实现 checkedIdsisAllCheckedcheckAllremoveChecked 等 getter/action
  • 实现购物车页面,验证全选/反选、滑动删除、数量增减与 Store 正确联动
  • 确认底部结算栏实时显示已选商品数量和总价
  • 创建订单确认页 src/pages/order/confirm.vue,能正确展示购物车已选商品快照
  • 实现地址展示区域(地址选择在第 08 篇实现,此处先显示占位)
  • 创建 cloudfunctions/order/index.js,部署到云端
  • 在小程序端调用 createOrder(),在云数据库控制台验证订单记录已写入
  • 订单创建成功后调用 cartStore.removeChecked(),验证已结算商品从购物车清除
  • app.config.ts 注册 pages/order/confirmpages/order/list 路由