Nuxt 3 完整电商应用实现指南

HTMLPAGE 团队
35分钟 分钟阅读

从零构建一个完整的 Nuxt 3 电商应用,涵盖商品展示、购物车、用户认证、订单管理、支付集成等核心功能的实现方案。

#Nuxt #电商开发 #Vue #购物车 #支付集成

电商应用架构概览

一个完整的电商应用需要实现以下核心模块:

模块功能技术要点
商品展示列表、详情、搜索、筛选SSR/SSG、SEO 优化
购物车添加、修改、删除、持久化Pinia 状态管理、本地存储
用户系统注册、登录、个人中心JWT 认证、中间件保护
订单管理创建、支付、查询、取消API 集成、状态机
支付系统多种支付方式第三方支付 SDK

项目初始化

创建项目并安装依赖

# 创建 Nuxt 项目
npx nuxi init nuxt-shop
cd nuxt-shop

# 安装核心依赖
npm install @pinia/nuxt @nuxt/image @vueuse/nuxt

# 安装 UI 框架
npm install @nuxtjs/tailwindcss

# 安装工具库
npm install zod dayjs

基础配置

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt',
    '@nuxt/image',
    '@vueuse/nuxt',
    '@nuxtjs/tailwindcss'
  ],
  
  runtimeConfig: {
    // 服务端私有配置
    apiSecret: process.env.API_SECRET,
    
    // 客户端公开配置
    public: {
      apiBase: process.env.API_BASE || 'https://api.example.com',
      siteUrl: process.env.SITE_URL || 'https://shop.example.com'
    }
  },
  
  app: {
    head: {
      title: '优选商城',
      meta: [
        { name: 'description', content: '高品质商品,优质服务' }
      ]
    }
  }
})

目录结构

nuxt-shop/
├── components/
│   ├── product/
│   │   ├── ProductCard.vue
│   │   ├── ProductGallery.vue
│   │   └── ProductReviews.vue
│   ├── cart/
│   │   ├── CartItem.vue
│   │   ├── CartSummary.vue
│   │   └── CartDrawer.vue
│   └── checkout/
│       ├── AddressForm.vue
│       └── PaymentMethods.vue
├── composables/
│   ├── useAuth.ts
│   ├── useCart.ts
│   └── useProduct.ts
├── stores/
│   ├── cart.ts
│   ├── user.ts
│   └── order.ts
├── pages/
│   ├── index.vue
│   ├── products/
│   │   ├── index.vue
│   │   └── [id].vue
│   ├── cart.vue
│   ├── checkout.vue
│   └── account/
│       ├── index.vue
│       ├── orders.vue
│       └── settings.vue
└── middleware/
    └── auth.ts

商品展示模块

商品列表页

<!-- pages/products/index.vue -->
<template>
  <div class="products-page">
    <!-- 筛选侧边栏 -->
    <aside class="filters">
      <ProductFilters 
        v-model:category="filters.category"
        v-model:priceRange="filters.priceRange"
        v-model:sortBy="filters.sortBy"
      />
    </aside>
    
    <!-- 商品网格 -->
    <main class="products-grid">
      <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        <ProductCard 
          v-for="product in products" 
          :key="product.id"
          :product="product"
        />
      </div>
      
      <!-- 分页 -->
      <Pagination 
        :total="total"
        :page="page"
        :page-size="pageSize"
        @change="handlePageChange"
      />
    </main>
  </div>
</template>

<script setup>
const route = useRoute()
const router = useRouter()

// 筛选条件
const filters = reactive({
  category: route.query.category || '',
  priceRange: route.query.price || '',
  sortBy: route.query.sort || 'newest'
})

const page = computed(() => Number(route.query.page) || 1)
const pageSize = 20

// 获取商品数据
const { data, pending } = await useFetch('/api/products', {
  query: {
    ...filters,
    page,
    pageSize
  },
  // 响应式查询参数变化时自动重新请求
  watch: [filters, page]
})

const products = computed(() => data.value?.items || [])
const total = computed(() => data.value?.total || 0)

// 筛选变化时更新 URL
watch(filters, (newFilters) => {
  router.push({
    query: { ...newFilters, page: 1 }
  })
})

// SEO
useHead({
  title: '全部商品 - 优选商城',
  meta: [
    { name: 'description', content: '浏览我们的全部商品,发现优质好物' }
  ]
})
</script>

商品卡片组件

<!-- components/product/ProductCard.vue -->
<template>
  <NuxtLink 
    :to="`/products/${product.id}`"
    class="product-card group"
  >
    <div class="aspect-square relative overflow-hidden rounded-lg">
      <NuxtImg 
        :src="product.images[0]"
        :alt="product.name"
        class="object-cover w-full h-full group-hover:scale-105 transition-transform"
        loading="lazy"
        placeholder
      />
      
      <!-- 标签 -->
      <div class="absolute top-2 left-2 flex gap-2">
        <span v-if="product.isNew" class="badge badge-new">新品</span>
        <span v-if="product.discount" class="badge badge-sale">
          -{{ product.discount }}%
        </span>
      </div>
      
      <!-- 快捷操作 -->
      <div class="absolute bottom-0 inset-x-0 p-3 opacity-0 group-hover:opacity-100 transition-opacity">
        <button 
          @click.prevent="addToCart"
          class="w-full btn btn-primary"
        >
          加入购物车
        </button>
      </div>
    </div>
    
    <div class="mt-3">
      <h3 class="font-medium line-clamp-2">{{ product.name }}</h3>
      <div class="mt-1 flex items-baseline gap-2">
        <span class="text-lg font-bold text-primary">
          ¥{{ formatPrice(product.price) }}
        </span>
        <span v-if="product.originalPrice" class="text-sm text-gray-400 line-through">
          ¥{{ formatPrice(product.originalPrice) }}
        </span>
      </div>
    </div>
  </NuxtLink>
</template>

<script setup>
const props = defineProps({
  product: {
    type: Object,
    required: true
  }
})

const cartStore = useCartStore()

const formatPrice = (price) => {
  return (price / 100).toFixed(2)
}

const addToCart = () => {
  cartStore.addItem({
    id: props.product.id,
    name: props.product.name,
    price: props.product.price,
    image: props.product.images[0],
    quantity: 1
  })
}
</script>

商品详情页

<!-- pages/products/[id].vue -->
<template>
  <div class="product-detail">
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
      <!-- 图片画廊 -->
      <ProductGallery :images="product.images" />
      
      <!-- 商品信息 -->
      <div class="product-info">
        <h1 class="text-2xl font-bold">{{ product.name }}</h1>
        
        <div class="mt-4 flex items-baseline gap-3">
          <span class="text-3xl font-bold text-primary">
            ¥{{ formatPrice(currentPrice) }}
          </span>
          <span v-if="product.originalPrice" class="text-gray-400 line-through">
            ¥{{ formatPrice(product.originalPrice) }}
          </span>
        </div>
        
        <!-- SKU 选择 -->
        <div class="mt-6 space-y-4">
          <div v-for="attr in product.attributes" :key="attr.name">
            <label class="text-sm font-medium">{{ attr.name }}</label>
            <div class="mt-2 flex flex-wrap gap-2">
              <button
                v-for="option in attr.options"
                :key="option.value"
                :class="[
                  'px-4 py-2 border rounded-lg',
                  selectedSku[attr.name] === option.value
                    ? 'border-primary bg-primary/10'
                    : 'border-gray-200 hover:border-gray-300'
                ]"
                @click="selectAttribute(attr.name, option.value)"
              >
                {{ option.label }}
              </button>
            </div>
          </div>
        </div>
        
        <!-- 数量选择 -->
        <div class="mt-6">
          <label class="text-sm font-medium">数量</label>
          <div class="mt-2 flex items-center gap-4">
            <QuantitySelector v-model="quantity" :max="stock" />
            <span class="text-sm text-gray-500">库存 {{ stock }} 件</span>
          </div>
        </div>
        
        <!-- 操作按钮 -->
        <div class="mt-8 flex gap-4">
          <button 
            class="flex-1 btn btn-primary btn-lg"
            :disabled="!canAddToCart"
            @click="addToCart"
          >
            加入购物车
          </button>
          <button 
            class="flex-1 btn btn-secondary btn-lg"
            :disabled="!canAddToCart"
            @click="buyNow"
          >
            立即购买
          </button>
        </div>
      </div>
    </div>
    
    <!-- 商品描述 -->
    <div class="mt-12">
      <Tabs>
        <Tab title="商品详情">
          <div class="prose max-w-none" v-html="product.description" />
        </Tab>
        <Tab title="规格参数">
          <ProductSpecs :specs="product.specs" />
        </Tab>
        <Tab :title="`评价 (${product.reviewCount})`">
          <ProductReviews :product-id="product.id" />
        </Tab>
      </Tabs>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const cartStore = useCartStore()

// 获取商品详情
const { data: product } = await useFetch(`/api/products/${route.params.id}`)

// 如果商品不存在,显示 404
if (!product.value) {
  throw createError({
    statusCode: 404,
    message: '商品不存在'
  })
}

// SKU 选择状态
const selectedSku = reactive({})
const quantity = ref(1)

// 计算当前选中 SKU 的价格和库存
const currentVariant = computed(() => {
  if (Object.keys(selectedSku).length !== product.value.attributes.length) {
    return null
  }
  return product.value.variants.find(v => 
    Object.entries(selectedSku).every(([key, value]) => v.attributes[key] === value)
  )
})

const currentPrice = computed(() => 
  currentVariant.value?.price || product.value.price
)

const stock = computed(() => 
  currentVariant.value?.stock || product.value.stock
)

const canAddToCart = computed(() => 
  currentVariant.value && quantity.value > 0 && quantity.value <= stock.value
)

// 操作方法
const selectAttribute = (name, value) => {
  selectedSku[name] = value
}

const addToCart = () => {
  cartStore.addItem({
    id: product.value.id,
    variantId: currentVariant.value.id,
    name: product.value.name,
    sku: Object.values(selectedSku).join(' / '),
    price: currentPrice.value,
    image: product.value.images[0],
    quantity: quantity.value
  })
}

// SEO 优化
useHead({
  title: `${product.value.name} - 优选商城`,
  meta: [
    { name: 'description', content: product.value.summary }
  ]
})

// 结构化数据
useSchemaOrg([
  defineProduct({
    name: product.value.name,
    image: product.value.images,
    description: product.value.summary,
    offers: {
      price: currentPrice.value / 100,
      priceCurrency: 'CNY',
      availability: stock.value > 0 ? 'InStock' : 'OutOfStock'
    }
  })
])
</script>

购物车系统

购物车 Store

// stores/cart.ts
import { defineStore } from 'pinia'

interface CartItem {
  id: string
  variantId?: string
  name: string
  sku?: string
  price: number
  image: string
  quantity: number
}

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[]
  }),
  
  getters: {
    // 商品总数
    itemCount: (state) => 
      state.items.reduce((sum, item) => sum + item.quantity, 0),
    
    // 总金额
    totalAmount: (state) =>
      state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    
    // 是否为空
    isEmpty: (state) => state.items.length === 0
  },
  
  actions: {
    // 添加商品
    addItem(item: CartItem) {
      const key = item.variantId || item.id
      const existing = this.items.find(
        i => (i.variantId || i.id) === key
      )
      
      if (existing) {
        existing.quantity += item.quantity
      } else {
        this.items.push({ ...item })
      }
      
      this.saveToStorage()
      this.showNotification('已加入购物车')
    },
    
    // 更新数量
    updateQuantity(id: string, quantity: number) {
      const item = this.items.find(i => (i.variantId || i.id) === id)
      if (item) {
        item.quantity = Math.max(1, quantity)
        this.saveToStorage()
      }
    },
    
    // 删除商品
    removeItem(id: string) {
      const index = this.items.findIndex(i => (i.variantId || i.id) === id)
      if (index > -1) {
        this.items.splice(index, 1)
        this.saveToStorage()
      }
    },
    
    // 清空购物车
    clear() {
      this.items = []
      this.saveToStorage()
    },
    
    // 持久化存储
    saveToStorage() {
      if (process.client) {
        localStorage.setItem('cart', JSON.stringify(this.items))
      }
    },
    
    // 从存储恢复
    loadFromStorage() {
      if (process.client) {
        const saved = localStorage.getItem('cart')
        if (saved) {
          this.items = JSON.parse(saved)
        }
      }
    },
    
    // 显示通知
    showNotification(message: string) {
      // 使用 toast 组件显示
    }
  }
})

购物车页面

<!-- pages/cart.vue -->
<template>
  <div class="cart-page">
    <h1 class="text-2xl font-bold mb-8">购物车</h1>
    
    <div v-if="cartStore.isEmpty" class="empty-cart">
      <IconCart class="w-24 h-24 text-gray-300" />
      <p class="mt-4 text-gray-500">购物车是空的</p>
      <NuxtLink to="/products" class="mt-4 btn btn-primary">
        去逛逛
      </NuxtLink>
    </div>
    
    <div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-8">
      <!-- 商品列表 -->
      <div class="lg:col-span-2 space-y-4">
        <CartItem 
          v-for="item in cartStore.items"
          :key="item.variantId || item.id"
          :item="item"
          @update-quantity="handleUpdateQuantity"
          @remove="handleRemove"
        />
      </div>
      
      <!-- 结算面板 -->
      <div class="lg:col-span-1">
        <div class="bg-gray-50 rounded-lg p-6 sticky top-24">
          <h2 class="text-lg font-medium">订单摘要</h2>
          
          <div class="mt-4 space-y-3 text-sm">
            <div class="flex justify-between">
              <span class="text-gray-600">商品金额</span>
              <span>¥{{ formatPrice(cartStore.totalAmount) }}</span>
            </div>
            <div class="flex justify-between">
              <span class="text-gray-600">运费</span>
              <span>{{ shipping === 0 ? '免运费' : `¥${shipping}` }}</span>
            </div>
            <div v-if="discount" class="flex justify-between text-green-600">
              <span>优惠</span>
              <span>-¥{{ formatPrice(discount) }}</span>
            </div>
          </div>
          
          <div class="mt-4 pt-4 border-t flex justify-between">
            <span class="font-medium">合计</span>
            <span class="text-xl font-bold text-primary">
              ¥{{ formatPrice(finalAmount) }}
            </span>
          </div>
          
          <button 
            class="mt-6 w-full btn btn-primary btn-lg"
            @click="goToCheckout"
          >
            去结算 ({{ cartStore.itemCount }})
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
const cartStore = useCartStore()
const router = useRouter()

const shipping = computed(() => cartStore.totalAmount >= 9900 ? 0 : 800)
const discount = ref(0)
const finalAmount = computed(() => 
  cartStore.totalAmount + shipping.value - discount.value
)

const formatPrice = (price) => (price / 100).toFixed(2)

const handleUpdateQuantity = (id, quantity) => {
  cartStore.updateQuantity(id, quantity)
}

const handleRemove = (id) => {
  cartStore.removeItem(id)
}

const goToCheckout = () => {
  router.push('/checkout')
}
</script>

用户认证系统

认证 Store

// stores/user.ts
import { defineStore } from 'pinia'

interface User {
  id: string
  name: string
  email: string
  phone?: string
  avatar?: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    token: null as string | null
  }),
  
  getters: {
    isLoggedIn: (state) => !!state.token,
    displayName: (state) => state.user?.name || '游客'
  },
  
  actions: {
    // 登录
    async login(credentials: { email: string; password: string }) {
      const { data, error } = await useFetch('/api/auth/login', {
        method: 'POST',
        body: credentials
      })
      
      if (error.value) {
        throw new Error(error.value.message)
      }
      
      this.token = data.value.token
      this.user = data.value.user
      this.saveSession()
      
      return data.value
    },
    
    // 注册
    async register(userData: { name: string; email: string; password: string }) {
      const { data, error } = await useFetch('/api/auth/register', {
        method: 'POST',
        body: userData
      })
      
      if (error.value) {
        throw new Error(error.value.message)
      }
      
      this.token = data.value.token
      this.user = data.value.user
      this.saveSession()
      
      return data.value
    },
    
    // 登出
    logout() {
      this.user = null
      this.token = null
      this.clearSession()
    },
    
    // 获取用户信息
    async fetchUser() {
      if (!this.token) return
      
      const { data } = await useFetch('/api/auth/me', {
        headers: {
          Authorization: `Bearer ${this.token}`
        }
      })
      
      if (data.value) {
        this.user = data.value
      }
    },
    
    // 会话持久化
    saveSession() {
      if (process.client) {
        localStorage.setItem('token', this.token)
      }
    },
    
    loadSession() {
      if (process.client) {
        this.token = localStorage.getItem('token')
        if (this.token) {
          this.fetchUser()
        }
      }
    },
    
    clearSession() {
      if (process.client) {
        localStorage.removeItem('token')
      }
    }
  }
})

认证中间件

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const userStore = useUserStore()
  
  // 需要登录的页面
  const protectedRoutes = ['/checkout', '/account']
  
  const isProtected = protectedRoutes.some(route => 
    to.path.startsWith(route)
  )
  
  if (isProtected && !userStore.isLoggedIn) {
    return navigateTo({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  }
})

订单与支付

结算页面

<!-- pages/checkout.vue -->
<template>
  <div class="checkout-page">
    <h1 class="text-2xl font-bold mb-8">确认订单</h1>
    
    <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
      <div class="lg:col-span-2 space-y-6">
        <!-- 收货地址 -->
        <section class="bg-white rounded-lg p-6">
          <h2 class="text-lg font-medium mb-4">收货地址</h2>
          
          <div v-if="selectedAddress" class="address-card selected">
            <p class="font-medium">{{ selectedAddress.name }}</p>
            <p class="text-gray-600">{{ selectedAddress.phone }}</p>
            <p class="text-gray-600">
              {{ selectedAddress.province }}{{ selectedAddress.city }}{{ selectedAddress.district }}{{ selectedAddress.detail }}
            </p>
            <button @click="showAddressSelector = true" class="text-primary">
              修改
            </button>
          </div>
          
          <button 
            v-else 
            @click="showAddressSelector = true"
            class="btn btn-outline"
          >
            + 添加收货地址
          </button>
        </section>
        
        <!-- 商品清单 -->
        <section class="bg-white rounded-lg p-6">
          <h2 class="text-lg font-medium mb-4">商品清单</h2>
          <div class="divide-y">
            <div 
              v-for="item in cartStore.items"
              :key="item.id"
              class="py-4 flex gap-4"
            >
              <NuxtImg :src="item.image" class="w-20 h-20 object-cover rounded" />
              <div class="flex-1">
                <h3>{{ item.name }}</h3>
                <p class="text-gray-500 text-sm">{{ item.sku }}</p>
                <div class="mt-2 flex justify-between">
                  <span>¥{{ formatPrice(item.price) }} × {{ item.quantity }}</span>
                  <span class="font-medium">
                    ¥{{ formatPrice(item.price * item.quantity) }}
                  </span>
                </div>
              </div>
            </div>
          </div>
        </section>
        
        <!-- 支付方式 -->
        <section class="bg-white rounded-lg p-6">
          <h2 class="text-lg font-medium mb-4">支付方式</h2>
          <div class="grid grid-cols-3 gap-4">
            <button
              v-for="method in paymentMethods"
              :key="method.id"
              :class="[
                'p-4 border rounded-lg text-center',
                selectedPayment === method.id ? 'border-primary' : 'border-gray-200'
              ]"
              @click="selectedPayment = method.id"
            >
              <component :is="method.icon" class="w-8 h-8 mx-auto" />
              <span class="mt-2 block text-sm">{{ method.name }}</span>
            </button>
          </div>
        </section>
      </div>
      
      <!-- 订单汇总 -->
      <div class="lg:col-span-1">
        <div class="bg-white rounded-lg p-6 sticky top-24">
          <h2 class="text-lg font-medium">订单汇总</h2>
          
          <div class="mt-4 space-y-3 text-sm">
            <div class="flex justify-between">
              <span>商品金额</span>
              <span>¥{{ formatPrice(cartStore.totalAmount) }}</span>
            </div>
            <div class="flex justify-between">
              <span>运费</span>
              <span>{{ shipping === 0 ? '免运费' : `¥${shipping}` }}</span>
            </div>
          </div>
          
          <div class="mt-4 pt-4 border-t flex justify-between">
            <span class="font-medium">应付金额</span>
            <span class="text-xl font-bold text-primary">
              ¥{{ formatPrice(finalAmount) }}
            </span>
          </div>
          
          <button 
            class="mt-6 w-full btn btn-primary btn-lg"
            :disabled="!canSubmit || isSubmitting"
            @click="submitOrder"
          >
            {{ isSubmitting ? '提交中...' : '提交订单' }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
// 使用认证中间件
definePageMeta({
  middleware: 'auth'
})

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

const selectedAddress = ref(null)
const selectedPayment = ref('wechat')
const isSubmitting = ref(false)

const paymentMethods = [
  { id: 'wechat', name: '微信支付', icon: 'IconWechat' },
  { id: 'alipay', name: '支付宝', icon: 'IconAlipay' },
  { id: 'card', name: '银行卡', icon: 'IconCard' }
]

const shipping = computed(() => cartStore.totalAmount >= 9900 ? 0 : 800)
const finalAmount = computed(() => cartStore.totalAmount + shipping.value)

const canSubmit = computed(() => 
  selectedAddress.value && selectedPayment.value && !cartStore.isEmpty
)

const formatPrice = (price) => (price / 100).toFixed(2)

const submitOrder = async () => {
  if (!canSubmit.value) return
  
  isSubmitting.value = true
  
  try {
    // 创建订单
    const { data: order } = await useFetch('/api/orders', {
      method: 'POST',
      body: {
        items: cartStore.items,
        addressId: selectedAddress.value.id,
        paymentMethod: selectedPayment.value
      }
    })
    
    // 清空购物车
    cartStore.clear()
    
    // 跳转到支付页面
    router.push(`/pay/${order.value.id}`)
  } catch (error) {
    console.error('订单提交失败:', error)
  } finally {
    isSubmitting.value = false
  }
}
</script>

性能优化

图片懒加载与优化

// nuxt.config.ts
export default defineNuxtConfig({
  image: {
    provider: 'cloudinary', // 或其他 CDN
    cloudinary: {
      baseURL: 'https://res.cloudinary.com/your-cloud/image/upload/'
    },
    screens: {
      xs: 320,
      sm: 640,
      md: 768,
      lg: 1024,
      xl: 1280
    },
    presets: {
      product: {
        modifiers: {
          format: 'webp',
          quality: 80
        }
      },
      thumbnail: {
        modifiers: {
          format: 'webp',
          width: 200,
          height: 200,
          fit: 'cover'
        }
      }
    }
  }
})

预渲染热门商品

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/products/1', '/products/2'], // 热门商品
      crawlLinks: true
    }
  },
  
  routeRules: {
    // 商品列表页使用 ISR,1 小时更新
    '/products': { isr: 3600 },
    // 商品详情页使用 ISR,10 分钟更新
    '/products/**': { isr: 600 },
    // 购物车和结算页禁止缓存
    '/cart': { ssr: true },
    '/checkout': { ssr: true }
  }
})

总结

构建 Nuxt 电商应用的核心要点:

  1. 模块化架构 - 按功能拆分 Store 和组件
  2. SEO 优化 - SSR/SSG + 结构化数据
  3. 用户体验 - 流畅的购物流程
  4. 性能保障 - 图片优化 + 智能缓存
  5. 安全措施 - 认证中间件 + 支付安全

以此为基础,可根据实际业务需求扩展更多功能。