Skip to content

L24 · 购物车:状态管理实战 ​

🎯 本节目标:用 Pinia 实现购物车添加、数量调整、全选与结算预览
📦 本节产出:购物车 Store + 购物车页面 + 底部结算栏 + 结算预览
🔗 前置钩子:L23 的商品列表(点击加入购物车)
🔗 后续钩子:L25 将从购物车创建订单

1. 购物车数据设计 ​

typescript
// client/src/types/cart.ts
export interface CartItem {
  productId: string
  name: string
  price: number
  image: string
  quantity: number
  stock: number       // 加入时取得的库存快照,不代表实时库存
  selected: boolean   // 是否选中(用于结算)
}

2. Cart Store ​

购物车保存当前浏览器的选购草稿,游客也能添加;本课不按账号隔离,也不在退出时自动清空。价格与库存都是上次读取的快照,下单时由 L25 后端重新查询并校验,不能信任本地缓存。

typescript
// client/src/stores/cartStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { CartItem } from '@/types/cart'
import type { Product } from '@/types/product'

function isCartItem(value: unknown): value is CartItem {
  if (!value || typeof value !== 'object') return false
  const item = value as Record<string, unknown>
  return typeof item.productId === 'string' && /^[a-f\d]{24}$/i.test(item.productId)
    && typeof item.name === 'string' && typeof item.image === 'string'
    && typeof item.price === 'number' && Number.isFinite(item.price) && item.price >= 0
    && typeof item.stock === 'number' && Number.isSafeInteger(item.stock) && item.stock > 0
    && typeof item.quantity === 'number' && Number.isSafeInteger(item.quantity)
    && item.quantity > 0 && item.quantity <= item.stock && typeof item.selected === 'boolean'
    && Number.isSafeInteger(Math.round(item.price * 100))
    && Number.isSafeInteger(Math.round(item.price * 100) * item.quantity)
}

function hasSafeTotals(items: CartItem[]): boolean {
  const cents = items.reduce((sum, item) => sum + Math.round(item.price * 100) * item.quantity, 0)
  const count = items.reduce((sum, item) => sum + item.quantity, 0)
  return Number.isSafeInteger(cents) && Number.isSafeInteger(count)
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const selectedItems = computed(() => items.value.filter(item => item.selected))
  // 单价先四舍五入到分,避免直接反复累加 0.1、0.2 等浮点小数。
  // 这里只显示预估金额;订单使用服务端重新查询并计算的金额。
  const totalPrice = computed(() =>
    selectedItems.value.reduce((sum, item) => sum + Math.round(item.price * 100) * item.quantity, 0) / 100,
  )
  const selectedCount = computed(() => selectedItems.value.reduce((sum, item) => sum + item.quantity, 0))
  const totalCount = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
  const isAllSelected = computed(() => items.value.length > 0 && items.value.every(item => item.selected))

  function addItem(product: Product, quantity = 1): boolean {
    if (!Number.isSafeInteger(quantity) || quantity < 1 ||
        !Number.isSafeInteger(product.stock) || product.stock < 1) return false
    const existing = items.value.find(item => item.productId === product._id)
    const nextQuantity = Math.min((existing?.quantity || 0) + quantity, product.stock)
    const snapshot: CartItem = {
      productId: product._id, name: product.name, price: product.price,
      image: product.images[0] || '', stock: product.stock,
      quantity: nextQuantity, selected: true,
    }
    if (!isCartItem(snapshot)) return false
    const nextItems = [...items.value.filter(item => item.productId !== product._id), snapshot]
    if (!hasSafeTotals(nextItems)) return false
    if (existing) {
      const increased = nextQuantity > existing.quantity
      Object.assign(existing, snapshot) // 同时更新最新取得的价格、名称与库存
      return increased
    }
    items.value.push(snapshot)
    return true
  }
  function removeItem(productId: string) {
    items.value = items.value.filter(item => item.productId !== productId)
  }
  function updateQuantity(productId: string, quantity: number) {
    if (!Number.isSafeInteger(quantity) || quantity < 1) return
    const item = items.value.find(item => item.productId === productId)
    if (!item) return
    const nextQuantity = Math.min(quantity, item.stock)
    const nextItems = items.value.map(value => value === item ? { ...item, quantity: nextQuantity } : value)
    if (hasSafeTotals(nextItems)) item.quantity = nextQuantity
  }
  function toggleSelect(productId: string) {
    const item = items.value.find(item => item.productId === productId)
    if (item) item.selected = !item.selected
  }
  function toggleAll() {
    const selected = !isAllSelected.value
    items.value.forEach(item => { item.selected = selected })
  }
  function clearSelected() { items.value = items.value.filter(item => !item.selected) }
  function clearCart() { items.value = [] }
  return {
    items, selectedItems, totalPrice, selectedCount, totalCount, isAllSelected,
    addItem, removeItem, updateQuantity, toggleSelect, toggleAll, clearSelected, clearCart,
  }
}, {
  persist: {
    key: 'shop-cart-v1',
    pick: ['items'],
    serializer: {
      serialize: JSON.stringify,
      deserialize(text) {
        try {
          const saved: unknown = JSON.parse(text)
          const raw = saved && typeof saved === 'object' ? (saved as Record<string, unknown>).items : null
          if (!Array.isArray(raw)) return { items: [] }
          const seen = new Set<string>()
          const items: CartItem[] = []
          for (const item of raw) {
            if (!isCartItem(item) || seen.has(item.productId) || !hasSafeTotals([...items, item])) continue
            seen.add(item.productId)
            items.push(item)
          }
          return { items }
        } catch { return { items: [] } }
      },
    },
  },
})

L22 的 main.ts 已注册 persistedstate 插件。这里只持久化 items,恢复时过滤损坏、重复、超库存或金额/数量超出安全整数范围的条目;类型断言不会替我们检查 localStorage 内容。验证通过也不代表缓存的价格/库存可信。pick 是本课程 persistedstate 4 的配置名。插件配置


3. 购物车页面 ​

vue
<!-- client/src/views/CartView.vue -->
<script setup lang="ts">
import { useCartStore } from '@/stores/cartStore'
import { useRouter, RouterLink } from 'vue-router'

const cartStore = useCartStore()
const router = useRouter()

function handleQuantityChange(productId: string, delta: number) {
  const item = cartStore.items.find(i => i.productId === productId)
  if (item) {
    cartStore.updateQuantity(productId, item.quantity + delta)
  }
}

function handleCheckout() {
  if (cartStore.selectedItems.length === 0) return
  void router.push({ name: 'checkout' })
}
</script>

<template>
  <div class="cart-page">
    <h1>🛒 购物车 <span class="count">({{ cartStore.totalCount }})</span></h1>

    <!-- 空购物车 -->
    <div v-if="cartStore.items.length === 0" class="empty-cart">
      <div class="empty-icon">🛒</div>
      <p>购物车是空的</p>
      <RouterLink to="/products" class="btn-primary">去购物</RouterLink>
    </div>

    <template v-else>
      <!-- 全选栏 -->
      <div class="select-all-bar">
        <label class="checkbox-label">
          <input
            type="checkbox"
            :checked="cartStore.isAllSelected"
            @change="cartStore.toggleAll()"
          />
          全选
        </label>
        <button
          v-if="cartStore.selectedItems.length > 0"
          @click="cartStore.clearSelected()"
          class="btn-text danger"
        >
          删除选中 ({{ cartStore.selectedItems.length }})
        </button>
      </div>

      <!-- 商品列表 -->
      <div class="cart-list">
        <div v-for="item in cartStore.items" :key="item.productId" class="cart-item">
          <!-- 选中 -->
          <input
            type="checkbox"
            :checked="item.selected"
            @change="cartStore.toggleSelect(item.productId)"
            class="item-checkbox"
            :aria-label="`选择 ${item.name}`"
          />

          <!-- 图片 -->
          <img v-if="item.image" :src="item.image" :alt="item.name" class="item-image" />
          <span v-else class="item-image">暂无图片</span>

          <!-- 信息 -->
          <div class="item-info">
            <h3 class="item-name">{{ item.name }}</h3>
            <span class="item-price">¥{{ item.price.toLocaleString() }}</span>
          </div>

          <!-- 数量控制 -->
          <div class="quantity-control">
            <button
              @click="handleQuantityChange(item.productId, -1)"
              :disabled="item.quantity <= 1"
              :aria-label="`减少 ${item.name} 数量`"
              class="qty-btn"
            >
              −
            </button>
            <span class="qty-value">{{ item.quantity }}</span>
            <button
              @click="handleQuantityChange(item.productId, 1)"
              :disabled="item.quantity >= item.stock"
              :aria-label="`增加 ${item.name} 数量`"
              class="qty-btn"
            >
              +
            </button>
          </div>

          <!-- 小计 -->
          <div class="item-subtotal">
            ¥{{ (Math.round(item.price * 100) * item.quantity / 100).toFixed(2) }}
          </div>

          <!-- 删除 -->
          <button
            @click="cartStore.removeItem(item.productId)"
            class="delete-btn"
            :aria-label="`删除 ${item.name}`"
            title="删除"
          >
            🗑️
          </button>
        </div>
      </div>

      <!-- 底部结算栏 -->
      <div class="checkout-bar">
        <div class="checkout-info">
          <span>
            已选 <strong>{{ cartStore.selectedCount }}</strong> 件商品
          </span>
          <span class="checkout-total">
            预估合计:<strong class="total-price">
              ¥{{ cartStore.totalPrice.toFixed(2) }}
            </strong>
          </span>
        </div>
        <button
          class="checkout-btn"
          :disabled="cartStore.selectedItems.length === 0"
          @click="handleCheckout"
        >
          去结算 ({{ cartStore.selectedCount }} 件)
        </button>
      </div>
    </template>
  </div>
</template>

<style scoped>
.cart-page { padding: 24px; max-width: 900px; margin: 0 auto; padding-bottom: 100px; }
.cart-page h1 { font-size: 1.5rem; margin-bottom: 20px; }
.count { color: #999; font-weight: 400; }

/* 空状态 */
.empty-cart { text-align: center; padding: 60px 20px; }
.empty-icon { font-size: 4rem; margin-bottom: 16px; }
.empty-cart p { color: #999; margin-bottom: 20px; }

/* 全选栏 */
.select-all-bar {
  display: flex; justify-content: space-between; align-items: center;
  padding: 12px 16px; background: var(--bg-secondary, #f8f9fa);
  border-radius: 8px; margin-bottom: 12px;
}
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }

/* 商品项 */
.cart-item {
  display: flex; align-items: center; gap: 14px;
  padding: 16px; border-bottom: 1px solid #f0f0f0;
}
.item-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; }
.item-image { width: 80px; height: 80px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: 0.9rem; margin: 0 0 6px; line-height: 1.4; }
.item-price { font-size: 0.85rem; color: #e74c3c; }

/* 数量控制 */
.quantity-control { display: flex; align-items: center; gap: 0; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; }
.qty-btn { width: 32px; height: 32px; border: none; background: #f5f5f5; cursor: pointer; font-size: 1rem; }
.qty-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.qty-btn:hover:not(:disabled) { background: #e0e0e0; }
.qty-value { width: 40px; text-align: center; font-size: 0.9rem; font-weight: 600; }

.item-subtotal { width: 90px; text-align: right; font-weight: 600; color: #333; }

.delete-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: 0.4; transition: opacity 0.15s; }
.delete-btn:hover { opacity: 1; }

/* 结算栏 */
.checkout-bar {
  position: fixed; bottom: 0; left: 0; right: 0;
  display: flex; justify-content: space-between; align-items: center;
  padding: 14px 24px; background: white;
  border-top: 1px solid #e0e0e0; box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
  z-index: 100;
}
.checkout-info { display: flex; gap: 20px; align-items: center; }
.total-price { font-size: 1.3rem; color: #e74c3c; }
.checkout-btn {
  padding: 12px 32px; background: #e74c3c; color: white; border: none;
  border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
}
.checkout-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* 按钮 */
.btn-primary { padding: 10px 24px; background: #42b883; color: white; border: none; border-radius: 8px; cursor: pointer; text-decoration: none; }
.btn-text { background: none; border: none; cursor: pointer; font-size: 0.85rem; }
.btn-text.danger { color: #e74c3c; }
@media (max-width: 640px) {
  .cart-page { padding-bottom: 160px; }
  .cart-item { flex-wrap: wrap; gap: 10px; }
  .item-info { flex-basis: calc(100% - 130px); }
  .checkout-bar { padding: 12px; gap: 10px; }
  .checkout-info { flex-direction: column; align-items: flex-start; gap: 4px; }
  .checkout-btn { padding: 12px; }
}
</style>

4. 商品详情页添加购物车 ​

替换 L23 的详情页,保留数据获取和错误状态。路由 id 变化时重置数量;有库存时才能添加,反馈显示本次结果。

vue
<!-- client/src/views/ProductDetailView.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { productApi } from '@/api/products'
import { useRequest } from '@/composables/useRequest'
import { useCartStore } from '@/stores/cartStore'
const route = useRoute()
const cartStore = useCartStore()
const quantity = ref(1)
const message = ref('')
const { data, loading, error, execute } = useRequest(
  (signal, id: string) => productApi.getById(id, signal),
)
watch(() => route.params.id, id => {
  quantity.value = 1
  message.value = ''
  if (typeof id === 'string') void execute(id)
}, { immediate: true })
function handleAddToCart() {
  if (!data.value || loading.value || error.value) return
  message.value = cartStore.addItem(data.value.data, quantity.value)
    ? '已加入购物车' : '未增加数量,请检查库存上限'
}
</script>
<template>
  <main>
    <RouterLink to="/products">返回商品列表</RouterLink>
    <p v-if="loading">加载中…</p>
    <p v-else-if="error" role="alert">{{ error }}</p>
    <article v-else-if="data">
      <h1>{{ data.data.name }}</h1>
      <img v-if="data.data.images[0]" :src="data.data.images[0]" :alt="data.data.name" width="320" />
      <p>{{ data.data.description }}</p><p>¥{{ data.data.price.toFixed(2) }}</p>
      <p>{{ data.data.stock > 0 ? `库存 ${data.data.stock}` : '售罄' }}</p>
      <div class="add-to-cart-section">
        <button :disabled="quantity <= 1" @click="quantity--" aria-label="减少数量">−</button>
        <span>{{ quantity }}</span>
        <button :disabled="quantity >= data.data.stock" @click="quantity++" aria-label="增加数量">+</button>
        <button :disabled="data.data.stock === 0" @click="handleAddToCart">加入购物车</button>
      </div>
      <p role="status">{{ message }}</p>
    </article>
  </main>
</template>

商品列表也可以直接添加。在 L23 的 ProductListView 中导入并创建 useCartStore(),增加 const cartMessage = ref('')。将 v-for 的最外层 RouterLink 改成 article,把图片和卡片文字放进内部 RouterLink,按钮作为链接的兄弟元素;不要把按钮嵌套进链接:

vue
<!-- ProductListView.vue 的 product-grid 内替换卡片循环;其余过滤与分页保留 -->
<article v-for="product in data.data" :key="product._id" class="product-card">
  <RouterLink :to="{ name: 'product-detail', params: { id: product._id } }">
    <div class="card-image">
      <img v-if="product.images[0]" :src="product.images[0]" :alt="product.name" loading="lazy" />
      <span v-else>暂无图片</span>
    </div>
    <div class="card-body"><h3>{{ product.name }}</h3><p>¥{{ product.price.toFixed(2) }} · ⭐ {{ product.rating.toFixed(1) }}</p></div>
  </RouterLink>
  <button :disabled="product.stock === 0"
    @click="cartMessage = cartStore.addItem(product) ? '已加入购物车' : '未增加数量,请检查库存上限'">
    {{ product.stock === 0 ? '售罄' : '加入购物车' }}
  </button>
</article>

在 product-grid 外增加 <p role="status"></p>,用于宣告添加结果。脚本新增的是 import { useCartStore } from '@/stores/cartStore' 和 const cartStore = useCartStore();L23 已经导入 ref。

将下面样式追加到 ProductListView 的 style scoped,让内部链接沿用商品卡片的文字样式:

css
.product-card > a { display: block; color: inherit; text-decoration: none; }

5. Header 购物车图标 ​

当前商城的 header 位于 L22 的 App.vue。向它的 script 加入 import { useCartStore } from '@/stores/cartStore' 和 const cartStore = useCartStore(),再在现有 header 中追加链接;保留登录、退出和个人资料入口:

vue
<!-- client/src/App.vue:header 内新增 -->
<RouterLink to="/cart" class="cart-icon" aria-label="查看购物车">
  🛒
  <span v-if="cartStore.totalCount > 0" class="cart-badge">
    {{ cartStore.totalCount > 99 ? '99+' : cartStore.totalCount }}
  </span>
</RouterLink>
css
/* 追加到 App.vue 的 style scoped */
.cart-icon { position: relative; font-size: 1.4rem; text-decoration: none; }
.cart-badge {
  position: absolute; top: -8px; right: -12px;
  background: #e74c3c; color: white; font-size: 0.65rem;
  padding: 1px 6px; border-radius: 10px; font-weight: 700;
  min-width: 18px; text-align: center;
}

购物车与结算路由 ​

在 L22/L23 的 routes 数组中追加两项。购物车允许游客使用,结算要求登录;L22 的守卫会携带 redirect,并在登录后返回结算页。

typescript
// client/src/router/index.ts:routes 数组新增项
{ path: '/cart', name: 'cart', component: () => import('@/views/CartView.vue') },
{ path: '/checkout', name: 'checkout', component: () => import('@/views/CheckoutView.vue'), meta: { requiresAuth: true } },

本课先显示结算预览,L25 再加入收货地址表单与创建订单的提交按钮。在提交成功之前,不能清空购物车;请求只发送商品 id、数量和收货地址,不能把缓存价格作为订单价格。

vue
<!-- client/src/views/CheckoutView.vue:L25 会在此基础上加入下单 -->
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useCartStore } from '@/stores/cartStore'
const cartStore = useCartStore()
</script>
<template>
  <main>
    <h1>结算预览</h1>
    <p v-if="cartStore.selectedItems.length === 0">尚未选择商品,请返回购物车选择。</p>
    <template v-else>
      <ul><li v-for="item in cartStore.selectedItems" :key="item.productId">{{ item.name }} × {{ item.quantity }}</li></ul>
      <p>预估金额:¥{{ cartStore.totalPrice.toFixed(2) }},最终以创建订单时的金额为准。</p>
    </template>
    <RouterLink to="/cart">返回购物车调整</RouterLink>
  </main>
</template>

6. 本节总结 ​

数据流 ​

检查清单 ​

  • [ ] 能设计 CartItem 数据结构
  • [ ] 能实现 cartStore 的添加/删除/数量调整/选中逻辑
  • [ ] 能用 computed 计算总价、总数、全选状态
  • [ ] 能实现数量控制组件(±按钮 + 库存上限)
  • [ ] 能实现全选/取消全选
  • [ ] 能实现固定底部的结算栏
  • [ ] 能在 Header 显示购物车角标
  • [ ] 购物车数据通过 persist 插件持久化

Git 提交 ​

bash
git add .
git commit -m "L24: 购物车 Store + 购物车页面 + 结算栏"

🔗 → 下一节 ​

L25 将从购物车的选中商品创建订单——实现订单状态机设计。