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