第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
}
}
})
🎯 本章总结
通过本章学习,你完成了:
- 项目初始化:Nuxt3 + TypeScript + Prisma 项目搭建
- 数据库设计:用户、文章、评论、标签等数据模型
- 认证系统:用户注册、登录、JWT 认证
- 内容管理:文章发布、编辑、预览功能
- 社区功能:评论、点赞、关注系统
- SEO 优化:服务端渲染、元数据管理
- 组件开发:可复用的 UI 组件
📝 练习题
- 实现用户个人资料页面和编辑功能
- 添加文章搜索和筛选功能
- 实现用户关注和粉丝系统
- 添加文章收藏和书签功能
- 实现消息通知系统