L24 · 购物车:状态管理实战
🎯 本节目标:用 Pinia 实现购物车添加、数量调整、全选与结算预览
📦 本节产出:购物车 Store + 购物车页面 + 底部结算栏 + 结算预览
🔗 前置钩子:L23 的商品列表(点击加入购物车)
🔗 后续钩子:L25 将从购物车创建订单1. 购物车数据设计
// 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 后端重新查询并校验,不能信任本地缓存。
// 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. 购物车页面
<!-- 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 变化时重置数量;有库存时才能添加,反馈显示本次结果。
<!-- 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,按钮作为链接的兄弟元素;不要把按钮嵌套进链接:
<!-- 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,让内部链接沿用商品卡片的文字样式:
.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 中追加链接;保留登录、退出和个人资料入口:
<!-- 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>/* 追加到 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,并在登录后返回结算页。
// 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、数量和收货地址,不能把缓存价格作为订单价格。
<!-- 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 提交
git add .
git commit -m "L24: 购物车 Store + 购物车页面 + 结算栏"🔗 → 下一节
L25 将从购物车的选中商品创建订单——实现订单状态机设计。