Next.js项目MindAI教程 - 第七章:社区功能实现
1. 数据模型设计
首先,让我们在Prisma模型中添加社区相关的数据模型:
// prisma/schema.prisma
model Post {
id String @id @default(cuid())
title String
content String @db.Text
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
comments Comment[]
likes Like[]
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
}
model Comment {
id String @id @default(cuid())
content String
postId String
post Post @relation(fields: [postId], references: [id])
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([postId])
@@index([authorId])
}
model Like {
id String @id @default(cuid())
postId String
post Post @relation(fields: [postId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@unique([postId, userId])
@@index([postId])
@@index([userId])
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
}
2. 帖子组件实现
2.1 帖子列表组件
// src/components/community/PostList.tsx
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { format } from 'date-fns'
import { Card } from '@/components/ui/Card'
interface Post {
id: string
title: string
content: string
author: {
name: string
image?: string
}
_count: {
likes: number
comments: number
}
tags: { name: string }[]
createdAt: string
}
export function PostList() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/posts')
.then((res) => res.json())
.then(setPosts)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
if (loading) return <div>加载中...</div>
return (
<div className="space-y-4">
{posts.map((post) => (
<Card key={post.id} className="p-6 hover:shadow-lg transition-shadow">
<Link href={`/community/post/${post.id}`}>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{post.author.image && (
<img
src={post.author.image}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
)}
<div>
<h3 className="text-xl font-semibold hover:text-primary-600">
{post.title}
</h3>
<p className="text-sm text-gray-500">
{post.author.name} •{' '}
{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
</div>
<p className="text-gray-600 line-clamp-2">{post.content}</p>
<div className="flex items-center justify-between">
<div className="flex space-x-2">
{post.tags.map((tag) => (
<span
key={tag.name}
className="px-2 py-1 text-sm bg-gray-100 rounded-full"
>
{tag.name}
</span>
))}
</div>
<div className="flex items-center space-x-4 text-gray-500">
<span className="flex items-center">
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{post._count.likes}
</span>
<span className="flex items-center">
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{post._count.comments}
</span>
</div>
</div>
</div>
</Link>
</Card>
))}
</div>
)
}
2.2 发帖组件
// src/components/community/CreatePostForm.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Card } from '@/components/ui/Card'
export function CreatePostForm() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
title: '',
content: '',
tags: '',
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...formData,
tags: formData.tags.split(',').map((tag) => tag.trim()),
}),
})
if (!response.ok) throw new Error('发布失败')
const data = await response.json()
router.push(`/community/post/${data.id}`)
} catch (error) {
console.error(error)
alert('发布失败,请重试')
} finally {
setLoading(false)
}
}
return (
<Card className="p-6">
<h2 className="text-2xl font-bold mb-6">发布帖子</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
标题
</label>
<input
type="text"
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
className="mt-1 input-primary"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
内容
</label>
<textarea
value={formData.content}
onChange={(e) =>
setFormData((prev) => ({ ...prev, content: e.target.value }))
}
rows={6}
className="mt-1 input-primary"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
标签 (用逗号分隔)
</label>
<input
type="text"
value={formData.tags}
onChange={(e) =>
setFormData((prev) => ({ ...prev, tags: e.target.value }))
}
className="mt-1 input-primary"
placeholder="例如: 心理健康, 情绪管理"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full btn-primary"
>
{loading ? '发布中...' : '发布'}
</button>
</form>
</Card>
)
}
3. 评论功能实现
3.1 评论列表组件
// src/components/community/CommentList.tsx
'use client'
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
import { useSession } from 'next-auth/react'
interface Comment {
id: string
content: string
author: {
name: string
image?: string
}
createdAt: string
}
interface CommentListProps {
postId: string
}
export function CommentList({ postId }: CommentListProps) {
const { data: session } = useSession()
const [comments, setComments] = useState<Comment[]>([])
const [newComment, setNewComment] = useState('')
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch(`/api/posts/${postId}/comments`)
.then((res) => res.json())
.then(setComments)
.catch(console.error)
.finally(() => setLoading(false))
}, [postId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim() || !session) return
setSubmitting(true)
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newComment }),
})
if (!response.ok) throw new Error('评论失败')
const comment = await response.json()
setComments((prev) => [comment, ...prev])
setNewComment('')
} catch (error) {
console.error(error)
alert('评论失败,请重试')
} finally {
setSubmitting(false)
}
}
if (loading) return <div>加载中...</div>
return (
<div className="space-y-6">
{session && (
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="写下你的评论..."
className="w-full input-primary"
rows={3}
required
/>
<button
type="submit"
disabled={submitting}
className="btn-primary"
>
{submitting ? '发送中...' : '发送评论'}
</button>
</form>
)}
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3 mb-2">
{comment.author.image && (
<img
src={comment.author.image}
alt={comment.author.name}
className="w-8 h-8 rounded-full"
/>
)}
<div>
<p className="font-medium">{comment.author.name}</p>
<p className="text-sm text-gray-500">
{format(new Date(comment.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
</div>
)
}
4. API路由实现
4.1 帖子API
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next'
import { prisma } from '@/lib/prisma'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const tag = searchParams.get('tag')
const where = tag ? { tags: { some: { name: tag } } } : {}
try {
const posts = await prisma.post.findMany({
where,
include: {
author: {
select: {
name: true,
image: true,
},
},
tags: {
select: {
name: true,
},
},
_count: {
select: {
likes: true,
comments: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json(posts)
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '获取帖子失败' },
{ status: 500 }
)
}
}
export async function POST(req: Request) {
const session = await getServerSession()
if (!session) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
try {
const { title, content, tags } = await req.json()
const post = await prisma.post.create({
data: {
title,
content,
authorId: session.user.id,
tags: {
connectOrCreate: tags.map((tag: string) => ({
where: { name: tag },
create: { name: tag },
})),
},
},
include: {
author: {
select: {
name: true,
image: true,
},
},
tags: true,
},
})
return NextResponse.json(post)
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '创建帖子失败' },
{ status: 500 }
)
}
}
4.2 评论API
// src/app/api/posts/[postId]/comments/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next'
import { prisma } from '@/lib/prisma'
export async function GET(
req: Request,
{ params }: { params: { postId: string } }
) {
try {
const comments = await prisma.comment.findMany({
where: { postId: params.postId },
include: {
author: {
select: {
name: true,
image: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json(comments)
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '获取评论失败' },
{ status: 500 }
)
}
}
export async function POST(
req: Request,
{ params }: { params: { postId: string } }
) {
const session = await getServerSession()
if (!session) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
try {
const { content } = await req.json()
const comment = await prisma.comment.create({
data: {
content,
postId: params.postId,
authorId: session.user.id,
},
include: {
author: {
select: {
name: true,
image: true,
},
},
},
})
return NextResponse.json(comment)
} catch (error) {
console.error(error)
return NextResponse.json(
{ error: '创建评论失败' },
{ status: 500 }
)
}
}
5. 页面路由实现
5.1 社区首页
// src/app/community/page.tsx
import { PostList } from '@/components/community/PostList'
import { CreatePostForm } from '@/components/community/CreatePostForm'
export default function CommunityPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">心理社区</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<PostList />
</div>
<div>
<CreatePostForm />
</div>
</div>
</div>
)
}
5.2 帖子详情页
// src/app/community/post/[id]/page.tsx
import { prisma } from '@/lib/prisma'
import { format } from 'date-fns'
import { CommentList } from '@/components/community/CommentList'
import { notFound } from 'next/navigation'
interface PostPageProps {
params: {
id: string
}
}
export default async function PostPage({ params }: PostPageProps) {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: {
author: {
select: {
name: true,
image: true,
},
},
tags: true,
_count: {
select: {
likes: true,
comments: true,
},
},
},
})
if (!post) {
notFound()
}
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="space-y-4">
<h1 className="text-3xl font-bold">{post.title}</h1>
<div className="flex items-center space-x-4">
{post.author.image && (
<img
src={post.author.image}
alt={post.author.name}
className="w-12 h-12 rounded-full"
/>
)}
<div>
<p className="font-medium">{post.author.name}</p>
<p className="text-sm text-gray-500">
{format(new Date(post.createdAt), 'yyyy-MM-dd HH:mm')}
</p>
</div>
</div>
<div className="flex space-x-2">
{post.tags.map((tag) => (
<span
key={tag.name}
className="px-2 py-1 text-sm bg-gray-100 rounded-full"
>
{tag.name}
</span>
))}
</div>
<div className="prose max-w-none">
{post.content}
</div>
<div className="flex items-center space-x-4 text-gray-500">
<span className="flex items-center">
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{post._count.likes}
</span>
<span className="flex items-center">
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{post._count.comments}
</span>
</div>
</div>
<div>
<h2 className="text-2xl font-bold mb-4">评论</h2>
<CommentList postId={params.id} />
</div>
</div>
)
}
6. 下一步计划
- 实现数据统计功能
- 添加管理员功能
- 优化用户体验
- 完善错误处理