Skip to content

购物车 Store 示例 ​

这个示例展示了使用 Pinia 实现的真实电商购物车。它展示了高级模式,包括跨 Store 通信、异步操作、乐观更新和复杂业务逻辑。

概述 ​

购物车示例演示了:

  • 购物车、商品和用户 Store 之间的跨 Store 通信
  • 带有加载状态和错误处理的异步操作
  • 更好用户体验的乐观更新
  • 复杂计算(税费、折扣、运费)
  • 库存管理
  • 购物车状态持久化
  • 实时价格更新

功能特性 ​

  • ✅ 添加/移除购物车商品
  • ✅ 更新商品数量
  • ✅ 应用折扣码
  • ✅ 计算税费和运费
  • ✅ 库存验证
  • ✅ 游客和认证用户购物车
  • ✅ 购物车持久化
  • ✅ 价格变动通知
  • ✅ 批量操作
  • ✅ 购物车放弃恢复

类型定义 ​

ts
// types/cart.ts
export interface Product {
  id: string
  name: string
  price: number
  originalPrice?: number
  description: string
  image: string
  category: string
  stock: number
  sku: string
  weight: number
  dimensions: {
    length: number
    width: number
    height: number
  }
}

export interface CartItem {
  id: string
  productId: string
  product: Product
  quantity: number
  addedAt: Date
  selectedVariant?: ProductVariant
  customizations?: Record<string, any>
}

export interface ProductVariant {
  id: string
  name: string
  price: number
  stock: number
  attributes: Record<string, string>
}

export interface DiscountCode {
  code: string
  type: 'percentage' | 'fixed' | 'shipping'
  value: number
  minOrderAmount?: number
  maxDiscount?: number
  expiresAt?: Date
  usageLimit?: number
  usedCount: number
}

export interface ShippingOption {
  id: string
  name: string
  price: number
  estimatedDays: number
  description: string
}

export interface CartSummary {
  subtotal: number
  discount: number
  tax: number
  shipping: number
  total: number
  itemCount: number
  weight: number
}

商品 Store ​

ts
// stores/products.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Product } from '../types/cart'

export const useProductsStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  const getProduct = computed(() => {
    return (id: string) => products.value.find(p => p.id === id)
  })
  
  const getProductsByCategory = computed(() => {
    return (category: string) => products.value.filter(p => p.category === category)
  })
  
  const fetchProducts = async () => {
    loading.value = true
    error.value = null
    
    try {
      // 模拟 API 调用
      const response = await fetch('/api/products')
      if (!response.ok) throw new Error('获取商品失败')
      
      products.value = await response.json()
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
    } finally {
      loading.value = false
    }
  }
  
  const updateProductStock = (productId: string, newStock: number) => {
    const product = products.value.find(p => p.id === productId)
    if (product) {
      product.stock = newStock
    }
  }
  
  return {
    products: readonly(products),
    loading: readonly(loading),
    error: readonly(error),
    getProduct,
    getProductsByCategory,
    fetchProducts,
    updateProductStock
  }
})

购物车 Store ​

ts
// stores/cart.ts
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { useProductsStore } from './products'
import { useUserStore } from './user'
import type { CartItem, Product, DiscountCode, ShippingOption, CartSummary } from '../types/cart'

export const useCartStore = defineStore('cart', () => {
  const productsStore = useProductsStore()
  const userStore = useUserStore()
  
  // 状态
  const items = ref<CartItem[]>([])
  const appliedDiscountCode = ref<DiscountCode | null>(null)
  const selectedShippingOption = ref<ShippingOption | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  const lastSyncedAt = ref<Date | null>(null)
  
  // 可用的配送选项
  const shippingOptions = ref<ShippingOption[]>([
    {
      id: 'standard',
      name: '标准配送',
      price: 5.99,
      estimatedDays: 5,
      description: '5-7 个工作日'
    },
    {
      id: 'express',
      name: '快速配送',
      price: 12.99,
      estimatedDays: 2,
      description: '2-3 个工作日'
    },
    {
      id: 'overnight',
      name: '隔夜配送',
      price: 24.99,
      estimatedDays: 1,
      description: '下一个工作日'
    }
  ])
  
  // 计算属性
  const itemCount = computed(() => {
    return items.value.reduce((total, item) => total + item.quantity, 0)
  })
  
  const totalWeight = computed(() => {
    return items.value.reduce((total, item) => {
      return total + (item.product.weight * item.quantity)
    }, 0)
  })
  
  const subtotal = computed(() => {
    return items.value.reduce((total, item) => {
      const price = item.selectedVariant?.price || item.product.price
      return total + (price * item.quantity)
    }, 0)
  })
  
  const discountAmount = computed(() => {
    if (!appliedDiscountCode.value) return 0
    
    const discount = appliedDiscountCode.value
    const subtotalValue = subtotal.value
    
    if (discount.minOrderAmount && subtotalValue < discount.minOrderAmount) {
      return 0
    }
    
    let discountValue = 0
    
    if (discount.type === 'percentage') {
      discountValue = subtotalValue * (discount.value / 100)
    } else if (discount.type === 'fixed') {
      discountValue = discount.value
    }
    
    if (discount.maxDiscount) {
      discountValue = Math.min(discountValue, discount.maxDiscount)
    }
    
    return Math.min(discountValue, subtotalValue)
  })
  
  const taxAmount = computed(() => {
    const taxableAmount = subtotal.value - discountAmount.value
    const taxRate = userStore.user?.address?.state === 'CA' ? 0.0875 : 0.06
    return taxableAmount * taxRate
  })
  
  const shippingAmount = computed(() => {
    if (appliedDiscountCode.value?.type === 'shipping') {
      return 0
    }
    
    if (subtotal.value >= 50) {
      return 0 // 满 $50 免运费
    }
    
    return selectedShippingOption.value?.price || 0
  })
  
  const total = computed(() => {
    return subtotal.value - discountAmount.value + taxAmount.value + shippingAmount.value
  })
  
  const summary = computed<CartSummary>(() => ({
    subtotal: subtotal.value,
    discount: discountAmount.value,
    tax: taxAmount.value,
    shipping: shippingAmount.value,
    total: total.value,
    itemCount: itemCount.value,
    weight: totalWeight.value
  }))
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const hasOutOfStockItems = computed(() => {
    return items.value.some(item => {
      const currentStock = item.selectedVariant?.stock || item.product.stock
      return item.quantity > currentStock
    })
  })
  
  // 操作方法
  const addItem = async (product: Product, quantity = 1, variant?: any) => {
    loading.value = true
    error.value = null
    
    try {
      // 检查库存可用性
      const availableStock = variant?.stock || product.stock
      if (quantity > availableStock) {
        throw new Error(`库存仅剩 ${availableStock} 件`)
      }
      
      const existingItemIndex = items.value.findIndex(item => 
        item.productId === product.id && 
        item.selectedVariant?.id === variant?.id
      )
      
      if (existingItemIndex !== -1) {
        // 更新现有商品
        const existingItem = items.value[existingItemIndex]
        const newQuantity = existingItem.quantity + quantity
        
        if (newQuantity > availableStock) {
          throw new Error(`无法添加更多商品。仅剩 ${availableStock} 件可用。`)
        }
        
        await updateItemQuantity(existingItem.id, newQuantity)
      } else {
        // 添加新商品
        const cartItem: CartItem = {
          id: generateId(),
          productId: product.id,
          product,
          quantity,
          addedAt: new Date(),
          selectedVariant: variant
        }
        
        items.value.push(cartItem)
      }
      
      await syncCart()
      
    } catch (err) {
      error.value = err instanceof Error ? err.message : '添加商品失败'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const removeItem = async (itemId: string) => {
    const index = items.value.findIndex(item => item.id === itemId)
    if (index !== -1) {
      items.value.splice(index, 1)
      await syncCart()
    }
  }
  
  const updateItemQuantity = async (itemId: string, quantity: number) => {
    const item = items.value.find(item => item.id === itemId)
    if (!item) return
    
    if (quantity <= 0) {
      await removeItem(itemId)
      return
    }
    
    // 检查库存可用性
    const availableStock = item.selectedVariant?.stock || item.product.stock
    if (quantity > availableStock) {
      throw new Error(`库存仅剩 ${availableStock} 件`)
    }
    
    item.quantity = quantity
    await syncCart()
  }
  
  const clearCart = async () => {
    items.value = []
    appliedDiscountCode.value = null
    selectedShippingOption.value = null
    await syncCart()
  }
  
  const applyDiscountCode = async (code: string) => {
    loading.value = true
    error.value = null
    
    try {
      // 模拟 API 调用验证折扣码
      const response = await fetch(`/api/discount-codes/${code}`)
      if (!response.ok) {
        throw new Error('无效的折扣码')
      }
      
      const discountCode: DiscountCode = await response.json()
      
      // 验证折扣码
      if (discountCode.expiresAt && new Date(discountCode.expiresAt) < new Date()) {
        throw new Error('折扣码已过期')
      }
      
      if (discountCode.usageLimit && discountCode.usedCount >= discountCode.usageLimit) {
        throw new Error('折扣码使用次数已达上限')
      }
      
      if (discountCode.minOrderAmount && subtotal.value < discountCode.minOrderAmount) {
        throw new Error(`需要满 $${discountCode.minOrderAmount} 才能使用此折扣码`)
      }
      
      appliedDiscountCode.value = discountCode
      await syncCart()
      
    } catch (err) {
      error.value = err instanceof Error ? err.message : '应用折扣码失败'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  const removeDiscountCode = async () => {
    appliedDiscountCode.value = null
    await syncCart()
  }
  
  const setShippingOption = async (optionId: string) => {
    const option = shippingOptions.value.find(opt => opt.id === optionId)
    if (option) {
      selectedShippingOption.value = option
      await syncCart()
    }
  }
  
  const validateCart = async () => {
    const validationErrors: string[] = []
    
    for (const item of items.value) {
      // 刷新商品数据
      const currentProduct = productsStore.getProduct(item.productId)
      if (!currentProduct) {
        validationErrors.push(`商品 ${item.product.name} 已不可用`)
        continue
      }
      
      // 检查库存
      const availableStock = item.selectedVariant?.stock || currentProduct.stock
      if (item.quantity > availableStock) {
        validationErrors.push(
          `${item.product.name} 仅剩 ${availableStock} 件(您的购物车中有 ${item.quantity} 件)`
        )
      }
      
      // 检查价格变动
      const currentPrice = item.selectedVariant?.price || currentProduct.price
      const cartPrice = item.selectedVariant?.price || item.product.price
      if (currentPrice !== cartPrice) {
        validationErrors.push(
          `${item.product.name} 的价格已从 $${cartPrice} 变更为 $${currentPrice}`
        )
      }
    }
    
    return validationErrors
  }
  
  const refreshPrices = async () => {
    for (const item of items.value) {
      const currentProduct = productsStore.getProduct(item.productId)
      if (currentProduct) {
        item.product = { ...currentProduct }
      }
    }
    await syncCart()
  }
  
  const syncCart = async () => {
    if (userStore.isAuthenticated) {
      try {
        await fetch('/api/cart', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${userStore.token}`
          },
          body: JSON.stringify({
            items: items.value,
            discountCode: appliedDiscountCode.value,
            shippingOption: selectedShippingOption.value
          })
        })
        lastSyncedAt.value = new Date()
      } catch (err) {
        console.error('同步购物车失败:', err)
      }
    } else {
      // 为游客用户保存到 localStorage
      localStorage.setItem('guest-cart', JSON.stringify({
        items: items.value,
        discountCode: appliedDiscountCode.value,
        shippingOption: selectedShippingOption.value
      }))
    }
  }
  
  const loadCart = async () => {
    if (userStore.isAuthenticated) {
      try {
        const response = await fetch('/api/cart', {
          headers: {
            'Authorization': `Bearer ${userStore.token}`
          }
        })
        
        if (response.ok) {
          const cartData = await response.json()
          items.value = cartData.items || []
          appliedDiscountCode.value = cartData.discountCode || null
          selectedShippingOption.value = cartData.shippingOption || null
        }
      } catch (err) {
        console.error('加载购物车失败:', err)
      }
    } else {
      // 为游客用户从 localStorage 加载
      const savedCart = localStorage.getItem('guest-cart')
      if (savedCart) {
        try {
          const cartData = JSON.parse(savedCart)
          items.value = cartData.items || []
          appliedDiscountCode.value = cartData.discountCode || null
          selectedShippingOption.value = cartData.shippingOption || null
        } catch (err) {
          console.error('解析保存的购物车失败:', err)
        }
      }
    }
  }
  
  const mergeGuestCart = async () => {
    const guestCart = localStorage.getItem('guest-cart')
    if (guestCart) {
      try {
        const guestCartData = JSON.parse(guestCart)
        
        // 将游客购物车商品与认证用户购物车合并
        for (const guestItem of guestCartData.items || []) {
          await addItem(guestItem.product, guestItem.quantity, guestItem.selectedVariant)
        }
        
        // 清除游客购物车
        localStorage.removeItem('guest-cart')
      } catch (err) {
        console.error('合并游客购物车失败:', err)
      }
    }
  }
  
  const generateId = () => {
    return Date.now().toString(36) + Math.random().toString(36).substr(2)
  }
  
  // 监听用户认证状态变化
  watch(
    () => userStore.isAuthenticated,
    async (isAuthenticated) => {
      if (isAuthenticated) {
        await mergeGuestCart()
        await loadCart()
      } else {
        await syncCart() // 保存到 localStorage
      }
    }
  )
  
  // 初始化购物车
  loadCart()
  
  return {
    // 状态
    items: readonly(items),
    appliedDiscountCode: readonly(appliedDiscountCode),
    selectedShippingOption: readonly(selectedShippingOption),
    shippingOptions: readonly(shippingOptions),
    loading: readonly(loading),
    error: readonly(error),
    lastSyncedAt: readonly(lastSyncedAt),
    
    // 计算属性
    itemCount,
    totalWeight,
    subtotal,
    discountAmount,
    taxAmount,
    shippingAmount,
    total,
    summary,
    isEmpty,
    hasOutOfStockItems,
    
    // 操作方法
    addItem,
    removeItem,
    updateItemQuantity,
    clearCart,
    applyDiscountCode,
    removeDiscountCode,
    setShippingOption,
    validateCart,
    refreshPrices,
    syncCart,
    loadCart
  }
})

组件使用 ​

购物车组件 ​

vue
<!-- components/ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <div class="cart-header">
      <h2>购物车</h2>
      <span class="item-count">{{ store.itemCount }} 件商品</span>
    </div>
    
    <div v-if="store.isEmpty" class="empty-cart">
      <p>您的购物车是空的</p>
      <router-link to="/products" class="continue-shopping">
        继续购物
      </router-link>
    </div>
    
    <div v-else class="cart-content">
      <div class="cart-items">
        <CartItem
          v-for="item in store.items"
          :key="item.id"
          :item="item"
          @update-quantity="handleUpdateQuantity"
          @remove="store.removeItem"
        />
      </div>
      
      <div class="cart-actions">
        <button @click="validateAndRefresh" :disabled="store.loading">
          验证并刷新价格
        </button>
        
        <button @click="store.clearCart" class="clear-cart">
          清空购物车
        </button>
      </div>
      
      <DiscountCodeForm
        :applied-code="store.appliedDiscountCode"
        @apply="handleApplyDiscount"
        @remove="store.removeDiscountCode"
      />
      
      <ShippingOptions
        :options="store.shippingOptions"
        :selected="store.selectedShippingOption"
        @select="store.setShippingOption"
      />
      
      <CartSummary :summary="store.summary" />
      
      <div class="checkout-actions">
        <button 
          @click="proceedToCheckout"
          :disabled="store.hasOutOfStockItems || store.loading"
          class="checkout-btn"
        >
          去结算
        </button>
      </div>
    </div>
    
    <div v-if="validationErrors.length" class="validation-errors">
      <h3>购物车问题:</h3>
      <ul>
        <li v-for="error in validationErrors" :key="error">
          {{ error }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useCartStore } from '../stores/cart'
import CartItem from './CartItem.vue'
import DiscountCodeForm from './DiscountCodeForm.vue'
import ShippingOptions from './ShippingOptions.vue'
import CartSummary from './CartSummary.vue'

const store = useCartStore()
const validationErrors = ref<string[]>([])

const handleUpdateQuantity = async (itemId: string, quantity: number) => {
  try {
    await store.updateItemQuantity(itemId, quantity)
  } catch (error) {
    console.error('更新数量失败:', error)
  }
}

const handleApplyDiscount = async (code: string) => {
  try {
    await store.applyDiscountCode(code)
  } catch (error) {
    console.error('应用折扣失败:', error)
  }
}

const validateAndRefresh = async () => {
  try {
    validationErrors.value = await store.validateCart()
    await store.refreshPrices()
  } catch (error) {
    console.error('验证购物车失败:', error)
  }
}

const proceedToCheckout = () => {
  // 导航到结算页面
  console.log('进入结算页面:', store.summary)
}
</script>

测试 ​

ts
// tests/cart.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '../stores/cart'
import { useProductsStore } from '../stores/products'
import { useUserStore } from '../stores/user'

// 模拟 fetch
global.fetch = vi.fn()

describe('购物车 Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    localStorage.clear()
    vi.clearAllMocks()
  })
  
  it('添加商品到购物车', async () => {
    const cartStore = useCartStore()
    const product = {
      id: '1',
      name: '测试商品',
      price: 29.99,
      stock: 10,
      // ... 其他商品属性
    }
    
    await cartStore.addItem(product, 2)
    
    expect(cartStore.items).toHaveLength(1)
    expect(cartStore.items[0].quantity).toBe(2)
    expect(cartStore.itemCount).toBe(2)
    expect(cartStore.subtotal).toBe(59.98)
  })
  
  it('更新商品数量', async () => {
    const cartStore = useCartStore()
    const product = {
      id: '1',
      name: '测试商品',
      price: 29.99,
      stock: 10,
      // ... 其他商品属性
    }
    
    await cartStore.addItem(product, 1)
    const itemId = cartStore.items[0].id
    
    await cartStore.updateItemQuantity(itemId, 3)
    
    expect(cartStore.items[0].quantity).toBe(3)
    expect(cartStore.subtotal).toBe(89.97)
  })
  
  it('从购物车移除商品', async () => {
    const cartStore = useCartStore()
    const product = {
      id: '1',
      name: '测试商品',
      price: 29.99,
      stock: 10,
      // ... 其他商品属性
    }
    
    await cartStore.addItem(product, 1)
    const itemId = cartStore.items[0].id
    
    await cartStore.removeItem(itemId)
    
    expect(cartStore.items).toHaveLength(0)
    expect(cartStore.isEmpty).toBe(true)
  })
  
  it('正确计算总价', async () => {
    const cartStore = useCartStore()
    const userStore = useUserStore()
    
    // 设置用户状态用于税费计算
    userStore.user = {
      address: { state: 'CA' }
    }
    
    const product = {
      id: '1',
      name: '测试商品',
      price: 100,
      stock: 10,
      weight: 1,
      // ... 其他商品属性
    }
    
    await cartStore.addItem(product, 1)
    await cartStore.setShippingOption('standard')
    
    expect(cartStore.subtotal).toBe(100)
    expect(cartStore.taxAmount).toBe(8.75) // 8.75% CA 税
    expect(cartStore.shippingAmount).toBe(0) // 满 $50 免运费
    expect(cartStore.total).toBe(108.75)
  })
  
  it('正确应用折扣码', async () => {
    const cartStore = useCartStore()
    
    // 模拟成功的折扣码响应
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({
        code: 'SAVE10',
        type: 'percentage',
        value: 10,
        minOrderAmount: 50
      })
    } as Response)
    
    const product = {
      id: '1',
      name: '测试商品',
      price: 100,
      stock: 10,
      // ... 其他商品属性
    }
    
    await cartStore.addItem(product, 1)
    await cartStore.applyDiscountCode('SAVE10')
    
    expect(cartStore.appliedDiscountCode?.code).toBe('SAVE10')
    expect(cartStore.discountAmount).toBe(10)
  })
})

核心概念 ​

1. 跨 Store 通信 ​

购物车 Store 与商品和用户 Store 通信以获取当前数据和用户偏好。

2. 异步操作 ​

所有购物车操作都是异步的,以支持 API 调用和适当的错误处理。

3. 乐观更新 ​

UI 立即更新,同时在后台与服务器同步。

4. 复杂业务逻辑 ​

处理税费、折扣、运费计算和库存验证。

5. 状态持久化 ​

支持认证用户(服务器同步)和游客用户(localStorage)。

最佳实践 ​

  1. 验证数据 在执行操作前验证数据
  2. 优雅处理错误 提供用户友好的错误消息
  3. 同步状态 在客户端和服务器之间同步状态
  4. 使用乐观更新 提供更好的用户体验
  5. 实现适当的加载状态
  6. 彻底测试复杂业务逻辑
  7. 考虑离线场景

相关内容 ​

Released under the MIT License.