HiHuo
首页
博客
手册
工具
首页
博客
手册
工具
  • Vue & CSS 进阶

    • 第0章:工具链与开发体验
    • 第1章:TypeScript 核心
    • 第2章:CSS 现代能力
    • 第3章:Vue3 核心与原理
    • 第4章:路由与状态管理
    • 第5章:工程化与性能
    • 第6章:测试与质量
    • 第7章:CSS 进阶专题
    • 第8章:项目实战A - SaaS仪表盘
    • 第9章:项目实战B - 内容社区
    • 第10章:Vue 内核深入
    • 第11章:微前端与部署
    • CSS 变量体系与主题系统深度指南
    • 前端安全与防护专题
    • CSS 专题:动效优化与微交互
    • CSS 专题:图片与图形处理
    • 实时通信与WebSocket专题
    • 开发工具与调试技巧专题
    • 新兴技术与未来趋势专题
    • 移动端开发与PWA专题
    • 附录A:代码片段库
    • 附录B:练习题集

第9章:项目实战B - 内容社区

使用 Nuxt3 构建一个现代化的内容社区平台,掌握 SSR、SEO 优化和内容管理

📚 项目概览

项目目标

构建一个功能完整的内容社区平台,支持文章发布、评论互动、用户关注等社交功能。

技术栈

  • 框架:Nuxt 3 + TypeScript
  • 样式:Tailwind CSS + SCSS
  • 状态管理:Pinia
  • 内容管理:Markdown + MDC
  • 数据库:Prisma + SQLite
  • 认证:Nuxt Auth
  • 部署:Vercel / Netlify

项目结构

content-community/
├── components/              # 组件
├── pages/                  # 页面路由
├── layouts/                # 布局
├── middleware/             # 中间件
├── plugins/                # 插件
├── server/                 # 服务端 API
├── prisma/                 # 数据库
├── public/                 # 静态资源
└── types/                  # TypeScript 类型

🚀 9.1 项目初始化

9.1.1 创建 Nuxt3 项目

# 创建 Nuxt3 项目
npx nuxi@latest init content-community
cd content-community

# 安装依赖
pnpm install

# 安装额外依赖
pnpm add @nuxtjs/tailwindcss
pnpm add @pinia/nuxt
pnpm add @prisma/client prisma
pnpm add @nuxtjs/color-mode
pnpm add @nuxtjs/google-fonts
pnpm add @nuxt/content
pnpm add @nuxtjs/algolia
pnpm add @nuxt/image
pnpm add @vueuse/nuxt

9.1.2 项目配置

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  
  // 模块
  modules: [
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
    '@nuxt/content',
    '@nuxtjs/color-mode',
    '@nuxtjs/google-fonts',
    '@nuxt/image',
    '@vueuse/nuxt'
  ],
  
  // CSS
  css: ['~/assets/css/main.css'],
  
  // 运行时配置
  runtimeConfig: {
    // 私有配置(仅在服务器端可用)
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,
    // 公共配置(客户端也可用)
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
      siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'
    }
  },
  
  // 内容配置
  content: {
    highlight: {
      theme: 'github-light',
      preload: ['javascript', 'typescript', 'vue', 'css', 'html']
    }
  },
  
  // 图片配置
  image: {
    domains: ['images.unsplash.com', 'via.placeholder.com']
  },
  
  // 颜色模式
  colorMode: {
    classSuffix: ''
  },
  
  // Google 字体
  googleFonts: {
    families: {
      Inter: [400, 500, 600, 700]
    }
  },
  
  // 构建配置
  build: {
    transpile: ['@prisma/client']
  }
})

9.1.3 数据库配置

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  username  String   @unique
  name      String?
  avatar    String?
  bio       String?
  website   String?
  location  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 关联
  posts     Post[]
  comments  Comment[]
  likes     Like[]
  follows   Follow[] @relation("UserFollows")
  followers Follow[] @relation("UserFollowers")

  @@map("users")
}

model Post {
  id          String   @id @default(cuid())
  title       String
  slug        String   @unique
  content     String
  excerpt     String?
  coverImage  String?
  published   Boolean  @default(false)
  publishedAt DateTime?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  // 关联
  authorId    String
  author      User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  comments    Comment[]
  likes       Like[]
  tags        PostTag[]
  categories  PostCategory[]

  @@map("posts")
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 关联
  authorId  String
  author    User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
  postId    String
  post      Post   @relation(fields: [postId], references: [id], onDelete: Cascade)
  parentId  String?
  parent    Comment? @relation("CommentReplies", fields: [parentId], references: [id])
  replies   Comment[] @relation("CommentReplies")

  @@map("comments")
}

model Like {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now())

  // 关联
  userId    String
  user      User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  postId    String
  post      Post   @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@unique([userId, postId])
  @@map("likes")
}

model Follow {
  id          String   @id @default(cuid())
  createdAt   DateTime @default(now())

  // 关联
  followerId  String
  follower    User   @relation("UserFollows", fields: [followerId], references: [id], onDelete: Cascade)
  followingId String
  following   User   @relation("UserFollowers", fields: [followingId], references: [id], onDelete: Cascade)

  @@unique([followerId, followingId])
  @@map("follows")
}

model Tag {
  id        String   @id @default(cuid())
  name      String   @unique
  slug      String   @unique
  createdAt DateTime @default(now())

  // 关联
  posts     PostTag[]

  @@map("tags")
}

model Category {
  id        String   @id @default(cuid())
  name      String   @unique
  slug      String   @unique
  createdAt DateTime @default(now())

  // 关联
  posts     PostCategory[]

  @@map("categories")
}

model PostTag {
  id     String @id @default(cuid())
  postId String
  tagId  String

  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag    Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@unique([postId, tagId])
  @@map("post_tags")
}

model PostCategory {
  id         String @id @default(cuid())
  postId     String
  categoryId String

  post     Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)

  @@unique([postId, categoryId])
  @@map("post_categories")
}

🎨 9.2 设计系统与布局

9.2.1 全局样式

/* assets/css/main.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    font-family: 'Inter', sans-serif;
  }
  
  body {
    @apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
  }
}

@layer components {
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200;
  }
  
  .btn-primary {
    @apply btn bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
  }
  
  .btn-secondary {
    @apply btn bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600;
  }
  
  .card {
    @apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
  }
  
  .input {
    @apply block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white;
  }
}

9.2.2 主布局

<!-- layouts/default.vue -->
<template>
  <div class="min-h-screen bg-gray-50 dark:bg-gray-900">
    <!-- 导航栏 -->
    <AppHeader />
    
    <!-- 主要内容 -->
    <main class="container mx-auto px-4 py-8">
      <slot />
    </main>
    
    <!-- 页脚 -->
    <AppFooter />
  </div>
</template>

<script setup lang="ts">
// 设置页面元数据
useHead({
  titleTemplate: '%s - 内容社区',
  meta: [
    { name: 'description', content: '一个现代化的内容社区平台' },
    { name: 'viewport', content: 'width=device-width, initial-scale=1' }
  ]
})
</script>

9.2.3 导航组件

<!-- components/AppHeader.vue -->
<template>
  <header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
    <div class="container mx-auto px-4">
      <div class="flex items-center justify-between h-16">
        <!-- Logo -->
        <NuxtLink to="/" class="flex items-center space-x-2">
          <div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
            <span class="text-white font-bold text-lg">C</span>
          </div>
          <span class="text-xl font-bold text-gray-900 dark:text-white">内容社区</span>
        </NuxtLink>

        <!-- 搜索框 -->
        <div class="flex-1 max-w-lg mx-8">
          <div class="relative">
            <input
              v-model="searchQuery"
              type="text"
              placeholder="搜索文章、用户..."
              class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
              @keyup.enter="handleSearch"
            />
            <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
              <Icon name="heroicons:magnifying-glass" class="h-5 w-5 text-gray-400" />
            </div>
          </div>
        </div>

        <!-- 导航菜单 -->
        <nav class="flex items-center space-x-4">
          <NuxtLink to="/" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400">
            首页
          </NuxtLink>
          <NuxtLink to="/explore" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400">
            发现
          </NuxtLink>
          <NuxtLink to="/tags" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400">
            标签
          </NuxtLink>

          <!-- 用户菜单 -->
          <div v-if="user" class="flex items-center space-x-4">
            <NuxtLink to="/write" class="btn-primary">
              <Icon name="heroicons:pencil" class="w-4 h-4 mr-2" />
              写文章
            </NuxtLink>
            
            <UserDropdown :user="user" />
          </div>
          
          <div v-else class="flex items-center space-x-2">
            <NuxtLink to="/login" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400">
              登录
            </NuxtLink>
            <NuxtLink to="/register" class="btn-primary">
              注册
            </NuxtLink>
          </div>

          <!-- 主题切换 -->
          <ColorModeToggle />
        </nav>
      </div>
    </div>
  </header>
</template>

<script setup lang="ts">
const { user } = useAuth()
const searchQuery = ref('')

const handleSearch = () => {
  if (searchQuery.value.trim()) {
    navigateTo(`/search?q=${encodeURIComponent(searchQuery.value)}`)
  }
}
</script>

🏗️ 9.3 核心功能开发

9.3.1 用户认证

// plugins/auth.client.ts
export default defineNuxtPlugin(async () => {
  const { $fetch } = useNuxtApp()
  
  // 检查用户登录状态
  const token = useCookie('auth-token')
  
  if (token.value) {
    try {
      const user = await $fetch('/api/auth/me')
      const userStore = useUserStore()
      userStore.setUser(user)
    } catch (error) {
      // Token 无效,清除
      token.value = null
    }
  }
})
// composables/useAuth.ts
export const useAuth = () => {
  const userStore = useUserStore()
  
  const login = async (credentials: LoginCredentials) => {
    const { $fetch } = useNuxtApp()
    
    try {
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: credentials
      })
      
      const token = useCookie('auth-token')
      token.value = response.token
      
      userStore.setUser(response.user)
      
      return response
    } catch (error) {
      throw error
    }
  }
  
  const logout = async () => {
    const token = useCookie('auth-token')
    token.value = null
    
    userStore.clearUser()
    
    await navigateTo('/login')
  }
  
  const register = async (userData: RegisterData) => {
    const { $fetch } = useNuxtApp()
    
    try {
      const response = await $fetch('/api/auth/register', {
        method: 'POST',
        body: userData
      })
      
      return response
    } catch (error) {
      throw error
    }
  }
  
  return {
    user: computed(() => userStore.user),
    isLoggedIn: computed(() => !!userStore.user),
    login,
    logout,
    register
  }
}

9.3.2 文章管理

<!-- pages/write/index.vue -->
<template>
  <div class="max-w-4xl mx-auto">
    <div class="mb-8">
      <h1 class="text-3xl font-bold text-gray-900 dark:text-white">写文章</h1>
      <p class="text-gray-600 dark:text-gray-400 mt-2">分享你的想法和见解</p>
    </div>

    <form @submit.prevent="handleSubmit" class="space-y-6">
      <!-- 标题 -->
      <div>
        <label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
          标题
        </label>
        <input
          id="title"
          v-model="form.title"
          type="text"
          placeholder="输入文章标题..."
          class="input text-2xl font-bold"
          required
        />
      </div>

      <!-- 封面图片 -->
      <div>
        <label for="coverImage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
          封面图片
        </label>
        <div class="flex items-center space-x-4">
          <input
            id="coverImage"
            v-model="form.coverImage"
            type="url"
            placeholder="输入图片 URL..."
            class="input flex-1"
          />
          <button
            type="button"
            @click="showImagePicker = true"
            class="btn-secondary"
          >
            选择图片
          </button>
        </div>
        <div v-if="form.coverImage" class="mt-4">
          <img
            :src="form.coverImage"
            alt="封面预览"
            class="w-full h-48 object-cover rounded-lg"
          />
        </div>
      </div>

      <!-- 标签 -->
      <div>
        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
          标签
        </label>
        <div class="flex flex-wrap gap-2">
          <span
            v-for="tag in form.tags"
            :key="tag"
            class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
          >
            {{ tag }}
            <button
              type="button"
              @click="removeTag(tag)"
              class="ml-2 text-blue-600 hover:text-blue-800"
            >
              ×
            </button>
          </span>
          <input
            v-model="newTag"
            type="text"
            placeholder="添加标签..."
            class="input w-32"
            @keyup.enter="addTag"
          />
        </div>
      </div>

      <!-- 内容编辑器 -->
      <div>
        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
          内容
        </label>
        <div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
          <div class="bg-gray-50 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600">
            <div class="flex space-x-2">
              <button
                type="button"
                @click="editorMode = 'write'"
                :class="[
                  'px-3 py-1 text-sm rounded',
                  editorMode === 'write'
                    ? 'bg-blue-600 text-white'
                    : 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
                ]"
              >
                编辑
              </button>
              <button
                type="button"
                @click="editorMode = 'preview'"
                :class="[
                  'px-3 py-1 text-sm rounded',
                  editorMode === 'preview'
                    ? 'bg-blue-600 text-white'
                    : 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
                ]"
              >
                预览
              </button>
            </div>
          </div>
          
          <div v-if="editorMode === 'write'" class="p-4">
            <textarea
              v-model="form.content"
              placeholder="开始写作..."
              class="w-full h-96 resize-none border-none outline-none dark:bg-gray-800 dark:text-white"
            />
          </div>
          
          <div v-else class="p-4 prose dark:prose-invert max-w-none">
            <ContentRenderer :value="parsedContent" />
          </div>
        </div>
      </div>

      <!-- 发布选项 -->
      <div class="flex items-center justify-between">
        <div class="flex items-center space-x-4">
          <label class="flex items-center">
            <input
              v-model="form.published"
              type="checkbox"
              class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
            />
            <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">立即发布</span>
          </label>
        </div>
        
        <div class="flex space-x-4">
          <button
            type="button"
            @click="saveDraft"
            class="btn-secondary"
            :disabled="saving"
          >
            {{ saving ? '保存中...' : '保存草稿' }}
          </button>
          <button
            type="submit"
            class="btn-primary"
            :disabled="saving"
          >
            {{ saving ? '发布中...' : '发布文章' }}
          </button>
        </div>
      </div>
    </form>
  </div>
</template>

<script setup lang="ts">
import { parseMarkdown } from '@nuxt/content'

const { user } = useAuth()
const { $fetch } = useNuxtApp()

const editorMode = ref<'write' | 'preview'>('write')
const newTag = ref('')
const saving = ref(false)

const form = reactive({
  title: '',
  content: '',
  coverImage: '',
  tags: [] as string[],
  published: false
})

const parsedContent = computed(() => {
  return parseMarkdown(form.content)
})

const addTag = () => {
  if (newTag.value.trim() && !form.tags.includes(newTag.value.trim())) {
    form.tags.push(newTag.value.trim())
    newTag.value = ''
  }
}

const removeTag = (tag: string) => {
  const index = form.tags.indexOf(tag)
  if (index > -1) {
    form.tags.splice(index, 1)
  }
}

const saveDraft = async () => {
  saving.value = true
  try {
    await $fetch('/api/posts', {
      method: 'POST',
      body: {
        ...form,
        published: false
      }
    })
    // 显示成功消息
  } catch (error) {
    // 显示错误消息
  } finally {
    saving.value = false
  }
}

const handleSubmit = async () => {
  saving.value = true
  try {
    await $fetch('/api/posts', {
      method: 'POST',
      body: form
    })
    
    await navigateTo('/')
  } catch (error) {
    // 显示错误消息
  } finally {
    saving.value = false
  }
}

// 页面元数据
useHead({
  title: '写文章'
})
</script>

9.3.3 文章列表与详情

<!-- pages/index.vue -->
<template>
  <div class="max-w-6xl mx-auto">
    <!-- 推荐文章 -->
    <section class="mb-12">
      <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">推荐阅读</h2>
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        <ArticleCard
          v-for="post in featuredPosts"
          :key="post.id"
          :post="post"
          :featured="true"
        />
      </div>
    </section>

    <!-- 最新文章 -->
    <section>
      <div class="flex items-center justify-between mb-6">
        <h2 class="text-2xl font-bold text-gray-900 dark:text-white">最新文章</h2>
        <NuxtLink to="/explore" class="text-blue-600 hover:text-blue-800 dark:text-blue-400">
          查看更多 →
        </NuxtLink>
      </div>
      
      <div class="space-y-6">
        <ArticleCard
          v-for="post in recentPosts"
          :key="post.id"
          :post="post"
        />
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
const { $fetch } = useNuxtApp()

// 获取推荐文章
const { data: featuredPosts } = await useFetch('/api/posts/featured')

// 获取最新文章
const { data: recentPosts } = await useFetch('/api/posts/recent')

// 页面元数据
useHead({
  title: '首页',
  meta: [
    { name: 'description', content: '发现优质内容,分享你的见解' }
  ]
})
</script>
<!-- pages/posts/[slug].vue -->
<template>
  <div v-if="post" class="max-w-4xl mx-auto">
    <!-- 文章头部 -->
    <article class="prose dark:prose-invert max-w-none">
      <header class="mb-8">
        <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
          {{ post.title }}
        </h1>
        
        <div class="flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400 mb-6">
          <div class="flex items-center space-x-2">
            <img
              :src="post.author.avatar || '/default-avatar.png'"
              :alt="post.author.name"
              class="w-8 h-8 rounded-full"
            />
            <span>{{ post.author.name }}</span>
          </div>
          <span>·</span>
          <time :datetime="post.publishedAt">
            {{ formatDate(post.publishedAt) }}
          </time>
          <span>·</span>
          <span>{{ post.readTime }} 分钟阅读</span>
        </div>

        <!-- 标签 -->
        <div v-if="post.tags.length" class="flex flex-wrap gap-2 mb-6">
          <NuxtLink
            v-for="tag in post.tags"
            :key="tag.id"
            :to="`/tags/${tag.slug}`"
            class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800"
          >
            {{ tag.name }}
          </NuxtLink>
        </div>
      </header>

      <!-- 封面图片 -->
      <div v-if="post.coverImage" class="mb-8">
        <img
          :src="post.coverImage"
          :alt="post.title"
          class="w-full h-64 object-cover rounded-lg"
        />
      </div>

      <!-- 文章内容 -->
      <div class="prose dark:prose-invert max-w-none">
        <ContentRenderer :value="post.content" />
      </div>
    </article>

    <!-- 文章操作 -->
    <div class="flex items-center justify-between mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
      <div class="flex items-center space-x-4">
        <button
          @click="toggleLike"
          :class="[
            'flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors',
            isLiked
              ? 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400'
              : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
          ]"
        >
          <Icon name="heroicons:heart" class="w-5 h-5" />
          <span>{{ post.likesCount }}</span>
        </button>
        
        <button class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600">
          <Icon name="heroicons:chat-bubble-left" class="w-5 h-5" />
          <span>{{ post.commentsCount }}</span>
        </button>
      </div>

      <div class="flex items-center space-x-2">
        <button class="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
          <Icon name="heroicons:bookmark" class="w-5 h-5" />
        </button>
        <button class="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
          <Icon name="heroicons:share" class="w-5 h-5" />
        </button>
      </div>
    </div>

    <!-- 评论区 -->
    <CommentsSection :post-id="post.id" />
  </div>
</template>

<script setup lang="ts">
const route = useRoute()
const { user } = useAuth()
const { $fetch } = useNuxtApp()

// 获取文章详情
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)

if (!post.value) {
  throw createError({
    statusCode: 404,
    statusMessage: '文章不存在'
  })
}

const isLiked = ref(false)

const toggleLike = async () => {
  if (!user.value) {
    await navigateTo('/login')
    return
  }

  try {
    if (isLiked.value) {
      await $fetch(`/api/posts/${post.value.id}/unlike`, { method: 'POST' })
      post.value.likesCount--
    } else {
      await $fetch(`/api/posts/${post.value.id}/like`, { method: 'POST' })
      post.value.likesCount++
    }
    isLiked.value = !isLiked.value
  } catch (error) {
    // 处理错误
  }
}

const formatDate = (date: string) => {
  return new Date(date).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

// 页面元数据
useHead({
  title: post.value.title,
  meta: [
    { name: 'description', content: post.value.excerpt },
    { property: 'og:title', content: post.value.title },
    { property: 'og:description', content: post.value.excerpt },
    { property: 'og:image', content: post.value.coverImage },
    { property: 'og:type', content: 'article' }
  ]
})
</script>

🧩 9.4 组件开发

9.4.1 文章卡片组件

<!-- components/ArticleCard.vue -->
<template>
  <article class="card overflow-hidden hover:shadow-lg transition-shadow duration-200">
    <!-- 封面图片 -->
    <div v-if="post.coverImage" class="aspect-video overflow-hidden">
      <NuxtLink :to="`/posts/${post.slug}`">
        <img
          :src="post.coverImage"
          :alt="post.title"
          class="w-full h-full object-cover hover:scale-105 transition-transform duration-200"
        />
      </NuxtLink>
    </div>

    <!-- 内容 -->
    <div class="p-6">
      <!-- 标签 -->
      <div v-if="post.tags.length" class="flex flex-wrap gap-2 mb-3">
        <NuxtLink
          v-for="tag in post.tags.slice(0, 3)"
          :key="tag.id"
          :to="`/tags/${tag.slug}`"
          class="text-xs px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full hover:bg-blue-200 dark:hover:bg-blue-800"
        >
          {{ tag.name }}
        </NuxtLink>
      </div>

      <!-- 标题 -->
      <h2 class="text-xl font-bold text-gray-900 dark:text-white mb-3 line-clamp-2">
        <NuxtLink :to="`/posts/${post.slug}`" class="hover:text-blue-600 dark:hover:text-blue-400">
          {{ post.title }}
        </NuxtLink>
      </h2>

      <!-- 摘要 -->
      <p v-if="post.excerpt" class="text-gray-600 dark:text-gray-400 mb-4 line-clamp-3">
        {{ post.excerpt }}
      </p>

      <!-- 作者信息 -->
      <div class="flex items-center justify-between">
        <div class="flex items-center space-x-3">
          <img
            :src="post.author.avatar || '/default-avatar.png'"
            :alt="post.author.name"
            class="w-8 h-8 rounded-full"
          />
          <div>
            <p class="text-sm font-medium text-gray-900 dark:text-white">
              {{ post.author.name }}
            </p>
            <p class="text-xs text-gray-500 dark:text-gray-400">
              {{ formatDate(post.publishedAt) }}
            </p>
          </div>
        </div>

        <!-- 互动数据 -->
        <div class="flex items-center space-x-4 text-sm text-gray-500 dark:text-gray-400">
          <span class="flex items-center space-x-1">
            <Icon name="heroicons:heart" class="w-4 h-4" />
            <span>{{ post.likesCount }}</span>
          </span>
          <span class="flex items-center space-x-1">
            <Icon name="heroicons:chat-bubble-left" class="w-4 h-4" />
            <span>{{ post.commentsCount }}</span>
          </span>
        </div>
      </div>
    </div>
  </article>
</template>

<script setup lang="ts">
interface Props {
  post: {
    id: string
    title: string
    slug: string
    excerpt?: string
    coverImage?: string
    publishedAt: string
    likesCount: number
    commentsCount: number
    author: {
      id: string
      name: string
      avatar?: string
    }
    tags: Array<{
      id: string
      name: string
      slug: string
    }>
  }
  featured?: boolean
}

defineProps<Props>()

const formatDate = (date: string) => {
  return new Date(date).toLocaleDateString('zh-CN', {
    month: 'short',
    day: 'numeric'
  })
}
</script>

9.4.2 评论组件

<!-- components/CommentsSection.vue -->
<template>
  <div class="mt-12">
    <h3 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
      评论 ({{ comments.length }})
    </h3>

    <!-- 评论表单 -->
    <div v-if="user" class="mb-8">
      <form @submit.prevent="submitComment" class="space-y-4">
        <div>
          <textarea
            v-model="newComment"
            placeholder="写下你的评论..."
            class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white"
            rows="4"
            required
          />
        </div>
        <div class="flex justify-end">
          <button
            type="submit"
            :disabled="submitting"
            class="btn-primary"
          >
            {{ submitting ? '发布中...' : '发布评论' }}
          </button>
        </div>
      </form>
    </div>

    <!-- 登录提示 -->
    <div v-else class="mb-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-center">
      <p class="text-gray-600 dark:text-gray-400 mb-4">
        请 <NuxtLink to="/login" class="text-blue-600 hover:text-blue-800 dark:text-blue-400">登录</NuxtLink> 后发表评论
      </p>
    </div>

    <!-- 评论列表 -->
    <div class="space-y-6">
      <CommentItem
        v-for="comment in comments"
        :key="comment.id"
        :comment="comment"
        @reply="handleReply"
        @delete="handleDelete"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  postId: string
}

const props = defineProps<Props>()
const { user } = useAuth()
const { $fetch } = useNuxtApp()

const comments = ref([])
const newComment = ref('')
const submitting = ref(false)

// 加载评论
const loadComments = async () => {
  try {
    const data = await $fetch(`/api/posts/${props.postId}/comments`)
    comments.value = data
  } catch (error) {
    console.error('加载评论失败:', error)
  }
}

// 提交评论
const submitComment = async () => {
  if (!newComment.value.trim()) return

  submitting.value = true
  try {
    const comment = await $fetch(`/api/posts/${props.postId}/comments`, {
      method: 'POST',
      body: {
        content: newComment.value.trim()
      }
    })
    
    comments.value.unshift(comment)
    newComment.value = ''
  } catch (error) {
    console.error('发布评论失败:', error)
  } finally {
    submitting.value = false
  }
}

// 回复评论
const handleReply = (parentId: string, content: string) => {
  // 实现回复逻辑
}

// 删除评论
const handleDelete = async (commentId: string) => {
  try {
    await $fetch(`/api/comments/${commentId}`, {
      method: 'DELETE'
    })
    
    const index = comments.value.findIndex(c => c.id === commentId)
    if (index > -1) {
      comments.value.splice(index, 1)
    }
  } catch (error) {
    console.error('删除评论失败:', error)
  }
}

onMounted(() => {
  loadComments()
})
</script>

🚀 9.5 服务端 API

9.5.1 文章 API

// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const { page = 1, limit = 10, tag, category } = query

  const posts = await prisma.post.findMany({
    where: {
      published: true,
      ...(tag && {
        tags: {
          some: {
            tag: {
              slug: tag
            }
          }
        }
      }),
      ...(category && {
        categories: {
          some: {
            category: {
              slug: category
            }
          }
        }
      })
    },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          avatar: true
        }
      },
      tags: {
        include: {
          tag: true
        }
      },
      _count: {
        select: {
          likes: true,
          comments: true
        }
      }
    },
    orderBy: {
      publishedAt: 'desc'
    },
    skip: (Number(page) - 1) * Number(limit),
    take: Number(limit)
  })

  const total = await prisma.post.count({
    where: {
      published: true
    }
  })

  return {
    data: posts.map(post => ({
      ...post,
      tags: post.tags.map(t => t.tag),
      likesCount: post._count.likes,
      commentsCount: post._count.comments
    })),
    total,
    page: Number(page),
    limit: Number(limit)
  }
})
// server/api/posts/index.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const user = await getUserFromToken(event)

  if (!user) {
    throw createError({
      statusCode: 401,
      statusMessage: '未授权'
    })
  }

  const post = await prisma.post.create({
    data: {
      title: body.title,
      slug: generateSlug(body.title),
      content: body.content,
      excerpt: generateExcerpt(body.content),
      coverImage: body.coverImage,
      published: body.published,
      publishedAt: body.published ? new Date() : null,
      authorId: user.id,
      tags: {
        create: body.tags.map((tagName: string) => ({
          tag: {
            connectOrCreate: {
              where: { name: tagName },
              create: {
                name: tagName,
                slug: generateSlug(tagName)
              }
            }
          }
        }))
      }
    },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          avatar: true
        }
      },
      tags: {
        include: {
          tag: true
        }
      }
    }
  })

  return {
    ...post,
    tags: post.tags.map(t => t.tag)
  }
})

9.5.2 认证 API

// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const { email, password } = body

  // 验证用户
  const user = await prisma.user.findUnique({
    where: { email }
  })

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

  // 生成 JWT token
  const token = jwt.sign(
    { userId: user.id },
    process.env.JWT_SECRET!,
    { expiresIn: '7d' }
  )

  return {
    token,
    user: {
      id: user.id,
      email: user.email,
      username: user.username,
      name: user.name,
      avatar: user.avatar
    }
  }
})

🎯 本章总结

通过本章学习,你完成了:

  1. 项目初始化:Nuxt3 + TypeScript + Prisma 项目搭建
  2. 数据库设计:用户、文章、评论、标签等数据模型
  3. 认证系统:用户注册、登录、JWT 认证
  4. 内容管理:文章发布、编辑、预览功能
  5. 社区功能:评论、点赞、关注系统
  6. SEO 优化:服务端渲染、元数据管理
  7. 组件开发:可复用的 UI 组件

📝 练习题

  1. 实现用户个人资料页面和编辑功能
  2. 添加文章搜索和筛选功能
  3. 实现用户关注和粉丝系统
  4. 添加文章收藏和书签功能
  5. 实现消息通知系统

🔗 相关资源

  • Nuxt 3 官方文档
  • Prisma 文档
  • Tailwind CSS 文档
  • @nuxt/content 文档
  • Vercel 部署指南
Prev
第8章:项目实战A - SaaS仪表盘
Next
第10章:Vue 内核深入