电商应用架构概览
一个完整的电商应用需要实现以下核心模块:
| 模块 | 功能 | 技术要点 |
|---|---|---|
| 商品展示 | 列表、详情、搜索、筛选 | 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 电商应用的核心要点:
- 模块化架构 - 按功能拆分 Store 和组件
- SEO 优化 - SSR/SSG + 结构化数据
- 用户体验 - 流畅的购物流程
- 性能保障 - 图片优化 + 智能缓存
- 安全措施 - 认证中间件 + 支付安全
以此为基础,可根据实际业务需求扩展更多功能。


