Chapter 10

实战:Nuxt 3 全栈应用

整合 Drizzle ORM 数据库、用户认证、文件上传,并部署到 Vercel — 构建一个完整的生产级全栈应用

10.1 项目架构概览

本章实战构建一个任务管理应用(类似简化版 Linear),包含:用户注册/登录、任务的 CRUD、文件附件上传、响应式暗色 UI。

# 创建项目
npx nuxi@latest init taskapp
cd taskapp

# 安装依赖
npm install @nuxt/ui @pinia/nuxt
npm install drizzle-orm better-sqlite3
npm install nuxt-auth-utils
npm install --save-dev drizzle-kit @types/better-sqlite3
export default defineNuxtConfig({
  modules: [
    '@nuxt/ui',
    '@pinia/nuxt',
    'nuxt-auth-utils'
  ],
  runtimeConfig: {
    sessionPassword: process.env.NUXT_SESSION_PASSWORD,
    public: {
      appName: 'TaskApp'
    }
  }
})

10.2 数据库:Drizzle ORM

Drizzle ORM 是一个轻量级、类型安全的 TypeScript ORM,在 Nuxt 服务端完美运行:

import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'

// 用户表
export const users = sqliteTable('users', {
  id:        text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  email:     text('email').notNull().unique(),
  name:      text('name').notNull(),
  password:  text('password').notNull(),  // 存储 bcrypt hash
  createdAt: integer('created_at', { mode: 'timestamp' })
               .$defaultFn(() => new Date())
})

// 任务表
export const tasks = sqliteTable('tasks', {
  id:          text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  title:       text('title').notNull(),
  description: text('description'),
  status:      text('status', { enum: ['todo', 'in_progress', 'done'] })
                 .default('todo'),
  priority:    text('priority', { enum: ['low', 'medium', 'high'] })
                 .default('medium'),
  userId:      text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  dueDate:     integer('due_date', { mode: 'timestamp' }),
  createdAt:   integer('created_at', { mode: 'timestamp' })
                 .$defaultFn(() => new Date())
})
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'

// 单例模式(server/ 目录的工具函数可自动导入)
let _db: ReturnType<typeof drizzle> | null = null

export function useDb() {
  if (!_db) {
    const sqlite = new Database('./data.db')
    _db = drizzle(sqlite)
  }
  return _db
}

10.3 用户认证:nuxt-auth-utils

import { eq } from 'drizzle-orm'
import { hash } from 'bcrypt'

export default defineEventHandler(async (event) => {
  const { email, name, password } = await readBody(event)

  // 检查邮箱是否已注册
  const db = useDb()
  const existing = await db.select().from(users)
    .where(eq(users.email, email)).get()

  if (existing) {
    throw createError({ statusCode: 409, message: '邮箱已注册' })
  }

  // 创建用户
  const hashedPassword = await hash(password, 12)
  const newUser = await db.insert(users).values({
    email, name, password: hashedPassword
  }).returning().get()

  // 设置 Session(nuxt-auth-utils 提供)
  await setUserSession(event, {
    user: { id: newUser.id, name: newUser.name, email: newUser.email }
  })

  return { success: true }
})
import { eq } from 'drizzle-orm'
import { compare } from 'bcrypt'

export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)
  const db = useDb()

  const user = await db.select().from(users)
    .where(eq(users.email, email)).get()

  if (!user || !(await compare(password, user.password))) {
    throw createError({ statusCode: 401, message: '邮箱或密码错误' })
  }

  await setUserSession(event, {
    user: { id: user.id, name: user.name, email: user.email }
  })
  return { success: true }
})
// middleware/auth.ts
export default defineNuxtRouteMiddleware(async () => {
  const { loggedIn } = useUserSession()
  if (!loggedIn.value) {
    return navigateTo('/login')
  }
})

// pages/dashboard.vue — 使用中间件
definePageMeta({ middleware: 'auth' })

10.4 任务 API

import { eq, and, desc } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  if (!session.user) throw createError({ statusCode: 401 })

  const query = getQuery(event)
  const { status, priority } = query
  const db = useDb()

  // 动态构建查询条件
  const conditions = [eq(tasks.userId, session.user.id)]
  if (status) conditions.push(eq(tasks.status, status as string))
  if (priority) conditions.push(eq(tasks.priority, priority as string))

  return db.select().from(tasks)
    .where(and(...conditions))
    .orderBy(desc(tasks.createdAt))
    .all()
})

10.5 文件上传

import { writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'

export default defineEventHandler(async (event) => {
  const session = await getUserSession(event)
  if (!session.user) throw createError({ statusCode: 401 })

  // 读取 multipart 表单数据
  const formData = await readFormData(event)
  const file = formData.get('file') as File

  if (!file) throw createError({ statusCode: 400, message: '未提供文件' })

  // 验证文件类型和大小
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    throw createError({ statusCode: 400, message: '不支持的文件类型' })
  }
  if (file.size > 5 * 1024 * 1024) {   // 5MB 限制
    throw createError({ statusCode: 400, message: '文件不能超过 5MB' })
  }

  // 保存文件(生产环境应使用 S3 等对象存储)
  const ext = file.name.split('.').pop()
  const filename = `${crypto.randomUUID()}.${ext}`
  const uploadDir = join(process.cwd(), 'public/uploads')
  await mkdir(uploadDir, { recursive: true })
  await writeFile(join(uploadDir, filename), Buffer.from(await file.arrayBuffer()))

  return { url: `/uploads/${filename}` }
})
<script setup lang="ts">
const uploading = ref(false)
const uploadedUrl = ref('')

async function handleFileChange(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (!file) return

  const formData = new FormData()
  formData.append('file', file)

  uploading.value = true
  try {
    const { url } = await $fetch<{ url: string }>('/api/upload', {
      method: 'POST',
      body: formData
    })
    uploadedUrl.value = url
  } finally {
    uploading.value = false
  }
}
</script>

<template>
  <div>
    <input type="file" accept="image/*" @change="handleFileChange" />
    <div v-if="uploading">上传中...</div>
    <img v-if="uploadedUrl" :src="uploadedUrl" alt="已上传图片" />
  </div>
</template>

10.6 部署到 Vercel

# 安装 Vercel 适配器
npm install --save-dev @vercel/nuxt

# 本地构建测试
npm run build
npm run preview
export default defineNuxtConfig({
  // Vercel 边缘函数(可选,更快响应)
  nitro: {
    preset: 'vercel-edge'
  }
})
{
  "buildCommand": "npm run build",
  "outputDirectory": ".output/public",
  "framework": "nuxtjs",
  "env": {
    "NUXT_SESSION_PASSWORD": "@nuxt-session-password"
  }
}
# 方式一:通过 Vercel CLI 部署
npm install -g vercel
vercel --prod

# 方式二:连接 GitHub 仓库(推荐)
# 在 vercel.com 中导入仓库,每次 push 自动部署
git add .
git commit -m "feat: initial commit"
git push origin main

10.7 Netlify 部署

[build]
  command = "npm run build"
  publish = ".output/public"

[build.environment]
  NODE_VERSION = "20"

[[redirects]]
  from = "/*"
  to = "/.netlify/functions/server"
  status = 200

10.8 生产检查清单

环境变量
所有敏感信息(数据库连接、JWT 密钥、API Keys)通过环境变量传入,不提交到 git。在 Vercel/Netlify 的 Environment Variables 中配置。
错误处理
创建 error.vue 处理全局错误,API 路由使用 createError() 返回标准错误格式,客户端用 useError() 捕获。
SEO 优化
每个页面使用 useSeoMeta() 设置 title/description/og:image,配置 robots.txt 和 sitemap(@nuxtjs/sitemap 模块)。
性能优化
使用 <NuxtImage> 自动优化图片,路由懒加载,合理设置 routeRules 缓存策略,开启 Nuxt DevTools 分析打包体积。
数据库迁移
生产环境使用 PostgreSQL(Neon、Supabase)或 MySQL(PlanetScale)替代 SQLite,Drizzle Kit 管理迁移文件:npx drizzle-kit migrate。
学习路径完成

恭喜完成 Vue 3 / Nuxt 全栈开发教程!你已经掌握了从 Composition API 基础到 Nuxt 全栈部署的完整技能链。建议接下来:深入阅读 Vue 3 官方文档、实践 Nuxt 官方示例,并尝试将所学知识应用到真实项目中。