Nuxt: 综合练习 — 核心功能

最后更新:2026-08-26

Phase 2 学了 Pinia、Composable、Server API、Middleware、Plugins——现在是把它们串起来的时候。Charlie 要求:购物车从 API 到 Store 到页面完整打通,Bob 的管理后台要有权限守卫,Alice 的购物体验要流畅无断点。

1. 你将学到


2. 一个团队的真实故事

(1) 痛点:各模块独立运行但无法串联

Alice 的购物车页面能显示商品,但刷新后数据丢失。Bob 的 API 写好了但前端没调用。中间件写了但没接入。每个模块单独看没问题,连起来就断链。

(2) 端到端联调的解法

把 API → Composable → Store → 页面这条数据链完整打通,每个环节的数据流向清晰。

(3) 收益:完整的购物流程

Alice 能添加商品到购物车 → 刷新不丢失 → 进入结算页 → 管理后台有权限保护,全流程无断点。


3. Phase 2 核心功能模块协作架构

100%
graph TB
    A[MegaShop Phase 2] --> B[Server API Layer]
    A --> C[Composable Layer]
    A --> D[State Layer - Pinia]
    A --> E[Middleware Layer]
    A --> F[Plugin Layer]

    B --> B1[/api/products CRUD]
    B --> B2[/api/cart CRUD]
    B --> B3[/api/auth Login/Register]

    C --> C1[useCart → Store + API]
    C --> C2[useProductFilter → API]
    C --> C3[usePriceFormat → Pure]

    D --> D1[cartStore → items/total]
    D --> D2[userStore → auth/profile]

    E --> E1[auth.global.ts → Login check]
    E --> E2[admin.ts → Role check]

    F --> F1[logger → Debug]
    F --> F2[stripe.client → Payment]

    C1 --> D1
    C1 --> B2
    C2 --> B1

(1) 数据流完整路径

操作 数据流 涉及模块
浏览商品 API → useFetch → 页面 Server API
添加购物车 API → useCart → cartStore → 页面 API + Composable + Store
查看购物车 cartStore → 页面 Store
结算 cartStore → useCheckout → stripe → API Store + Composable + Plugin + API
管理后台 auth middleware → admin middleware → 页面 Middleware

4. 完整购物车 API

▶ 示例:购物车 API 全套

TYPESCRIPT
// server/api/cart/index.get.ts
export default defineEventHandler((event) => {
  const sessionId = getCookie(event, 'session-id') || 'default'
  const cart = mockCarts.find(c => c.sessionId === sessionId)
  return cart || { sessionId, items: [], total: 0 }
})

输出:

TEXT 📖 仅展示
// 执行成功
TYPESCRIPT
// server/api/cart/add.post.ts
export default defineEventHandler(async (event) => {
  const sessionId = getCookie(event, 'session-id') || 'default'
  const { productId, quantity = 1 } = await readBody(event)

  const product = mockProducts.find(p => p.id === productId)
  if (!product) throw createError({ statusCode: 404, message: 'Product not found' })

  let cart = mockCarts.find(c => c.sessionId === sessionId)
  if (!cart) {
    cart = { sessionId, items: [], total: 0 }
    mockCarts.push(cart)
  }

  const existing = cart.items.find(i => i.productId === productId)
  if (existing) {
    existing.quantity += quantity
  } else {
    cart.items.push({ productId, name: product.name, price: product.price, quantity, image: product.image })
  }
  cart.total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0)
  setCookie(event, 'session-id', sessionId, { httpOnly: true, maxAge: 86400 * 30 })
  return cart
})
TYPESCRIPT
// server/api/cart/remove.delete.ts
export default defineEventHandler(async (event) => {
  const sessionId = getCookie(event, 'session-id') || 'default'
  const { productId } = await readBody(event)

  const cart = mockCarts.find(c => c.sessionId === sessionId)
  if (!cart) throw createError({ statusCode: 404, message: 'Cart not found' })

  cart.items = cart.items.filter(i => i.productId !== productId)
  cart.total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0)
  return cart
})

5. Pinia Store + Composable 集成

▶ 示例:增强版 cartStore

TYPESCRIPT
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const isLoading = ref(false)

  const totalItems = computed(() => items.value.reduce((s, i) => s + i.quantity, 0))
  const totalPrice = computed(() => items.value.reduce((s, i) => s + i.price * i.quantity, 0))
  const formattedTotal = computed(() =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPrice.value)
  )

  async function fetchCart() {
    isLoading.value = true
    try {
      const cart = await $fetch('/api/cart')
      items.value = cart.items || []
    } finally {
      isLoading.value = false
    }
  }

  async function addItem(productId: number, quantity = 1) {
    const cart = await $fetch('/api/cart/add', {
      method: 'POST', body: { productId, quantity }
    })
    items.value = cart.items
  }

  async function removeItem(productId: number) {
    const cart = await $fetch('/api/cart/remove', {
      method: 'DELETE', body: { productId }
    })
    items.value = cart.items
  }

  function clearCart() {
    items.value = []
  }

  return { items, isLoading, totalItems, totalPrice, formattedTotal, fetchCart, addItem, removeItem, clearCart }
})

interface CartItem { productId: number; name: string; price: number; quantity: number; image: string }

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:useCart Composable

TYPESCRIPT
// composables/useCart.ts
export function useCart() {
  const cartStore = useCartStore()
  const { $logger } = useNuxtApp()
  const { notify } = useNotification()

  async function addToCart(productId: number, productName: string) {
    try {
      await cartStore.addItem(productId)
      notify(`${productName} added to cart`, 'success')
      $logger.info('Item added to cart', { productId })
    } catch (err) {
      notify('Failed to add item', 'error')
      $logger.error('Add to cart failed', { productId, err })
    }
  }

  async function removeFromCart(productId: number) {
    try {
      await cartStore.removeItem(productId)
      notify('Item removed from cart', 'info')
    } catch (err) {
      notify('Failed to remove item', 'error')
    }
  }

  return {
    items: computed(() => cartStore.items),
    totalItems: computed(() => cartStore.totalItems),
    totalPrice: computed(() => cartStore.totalPrice),
    formattedTotal: computed(() => cartStore.formattedTotal),
    isLoading: computed(() => cartStore.isLoading),
    addToCart,
    removeFromCart
  }
}

输出:

TEXT 📖 仅展示
// 执行成功

6. 权限中间件配置

▶ 示例:完整的中间件体系

TYPESCRIPT
// middleware/01-auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
  const publicPaths = ['/', '/products', '/about', '/login', '/register']
  if (publicPaths.some(p => to.path === p || to.path.startsWith('/products'))) return

  const token = useCookie('auth-token')
  if (!token.value) return navigateTo(`/login?redirect=${encodeURIComponent(to.path)}`)
})

输出:

TEXT 📖 仅展示
// 执行成功
TYPESCRIPT
// middleware/admin.ts
export default defineNuxtRouteMiddleware((to) => {
  const userCookie = useCookie('user-data')
  if (!userCookie.value) return navigateTo('/login')
  if ((userCookie.value as any).role !== 'admin') {
    throw createError({ statusCode: 403, message: 'Admin access required' })
  }
})

7. 端到端联调页面

▶ 示例:商品详情页(完整数据流)

VUE
<!-- pages/products/[id].vue -->
<template>
  <div v-if="product" class="product-detail">
    <img :src="product.image" :alt="product.name" />
    <div class="info">
      <h1>{{ product.name }}</h1>
      <p class="price">{{ formattedPrice }}</p>
      <p>{{ product.reviewCount }} reviews</p>
      <button @click="handleAddToCart" :disabled="adding">
        {{ adding ? 'Adding...' : 'Add to Cart' }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
const { formatted } = usePriceFormat(computed(() => product.value?.price || 0))
const formattedPrice = formatted

const { addToCart } = useCart()
const adding = ref(false)

async function handleAddToCart() {
  if (!product.value) return
  adding.value = true
  await addToCart(product.value.id, product.value.name)
  adding.value = false
}
</script>

输出:

TEXT 📖 仅展示
// 执行成功

8. 联调检查清单

检查项 验证方法 预期结果
API 可用 curl /api/products 返回商品 JSON
Store 状态共享 首页加商品 → 详情页看数量 数量一致
Composable 调用 点击 Add to Cart 成功添加 + 通知
中间件保护 未登录访问 /admin 跳转 /login
插件工作 查看控制台 有 logger 输出
SSR 数据水合 查看源代码 HTML 含商品数据
Cookie 持久化 刷新页面 购物车数据保留

❓ 常见问题

Q Store 和 API 数据不同步怎么办?
A Store 的 addItem 调用 API 后用返回值更新 items,保证 Store 和后端一致。不要只改 Store 不调 API。
Q Composable 里同时用 Store 和 $fetch 会不会冗余?
A 不会。Store 管客户端状态,$fetch 同步到后端。useCart 把两者封装在一起,调用方无需关心细节。
Q 中间件和 API middleware 都做鉴权,会冲突吗?
A 不会。页面中间件保护路由跳转(前端),server middleware 保护 API 请求(后端)。两者互补,都需要。
Q 如何调试端到端数据流?
A 用 Nuxt DevTools 的 Pinia 面板看 Store 状态,Network 面板看 API 请求,console 看 logger 输出。从 API 返回 → Store 更新 → 页面渲染,逐环节排查。
Q SSR 时购物车数据怎么初始化?
A SSR 时通过 cookie 读取 session-id,API 根据 sessionId 返回购物车。客户端水合后 Store 从 API payload 获取数据。
Q Phase 2 的 mock 数据什么时候换成真数据库?
A Phase 4 第 20 课会用 Prisma 替换 mock。当前 API 接口格式已设计好,后续只需改 server API 内部实现。

📖 小节


📝 作业

  1. 基础题(难度⭐):完成购物车 API + cartStore 集成,在商品详情页点击 Add to Cart 后购物车数量更新
  2. 进阶题(难度⭐⭐):实现完整的中间件体系:未登录访问 /checkout 跳转登录,普通用户访问 /admin 返回 403
  3. 挑战题(难度⭐⭐⭐):实现端到端购物流程:浏览商品 → 加购 → 查看购物车 → 结算(用 stripe 插件模拟支付),全流程数据一致

---|

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏