当前位置: 首页 > news >正文

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. 下一步计划

  • 实现数据统计功能
  • 添加管理员功能
  • 优化用户体验
  • 完善错误处理

相关文章:

  • 73.HarmonyOS NEXT PicturePreviewImage组件深度剖析:高级功能扩展与性能优化策略(三)
  • 多条件下的免杀webshell
  • 虚拟化数据恢复—重装系统服务器崩了的数据恢复过程
  • Spring Boot对接twilio发送邮件信息
  • 我的博客素材
  • 《灵珠觉醒:从零到算法金仙的C++修炼》卷三·天劫试炼(45)血海轮回阵 - Floyd-Warshall 多源最短路径
  • 02-Canvas-fabric.ActiveSelection
  • Rabit
  • Uniapp 开发 App 端上架用户隐私协议实现指南
  • Vuetify v-data-table footer文本适配中文
  • Redis基本命令手册——五大类型
  • 便捷搞定计算机名、IP 与 Mac 地址修改及网卡问题的软件
  • 【Erdas实验教程】015:哨兵二号卫星数据简介及下载方法
  • 将pdf或者word转换成base64格式
  • 设计心得——多态
  • 沐数科技数据开发岗笔试题2025
  • Unity开发中对象池设计与使用
  • Ansible 自动化运维
  • Docker容器命令速查表
  • Leetcode 刷题笔记1 动态规划part11
  • 国家卫健委:工作相关肌肉骨骼疾病、精神和行为障碍成职业健康新挑战
  • 视觉周刊|2025上海车展的科技范
  • 人民时评:投资于人,促高质量充分就业
  • 商超展销延长、专区专柜亮相……上海“外贸拓内销”商品与市民见面
  • 野猪穿过江苏电视台楼前广场,被抓捕后送往红山森林动物园
  • 70后供销合作总社理事会原副主任侯顺利任中国融通外部董事