全栈开发个人博客12.嵌套评论设计

通过使用 Prisma 和 GraphQL 设计文章嵌套评论功能,可以学习如何处理递归数据结构、实现层级化的评论关系,以及如何优化查询以支持高效的数据提取和更新。

1. 评论逻辑

  • 评论支持回复,嵌套深度自定义,默认3级
  • 评论作者和管理员有权删除
  • 点击加载更多评论

2. 评论模型设计

prisma.schema 新增 Comment 模型,parent表示父级评论,为空表示顶级评论。同步数据库 npx prisma migrate dev --name add_comment

ts 复制代码
model Comment {
  id          String    @id @default(cuid())
  content     String
  postId      String
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  post        Post      @relation(fields: [postId], references: [id])
  createdById String
  createdBy   User      @relation(fields: [createdById], references: [id])
  parent      Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  parentId    String?
  replies     Comment[] @relation("CommentReplies")
}

3. Graphql 增加评论接口

  • postComments:每篇文章默认展示顶级评论,支持查看更多
  • commentReplies:评论回复列表查询,支持查看更多
  • addComment: 新增评论接口,parentId为空表示顶级评论
  • deleteComment:父级评论删除,子评论都删除
ts 复制代码
// graphql/types/Comment.ts
import { builder } from '../builder'

builder.prismaObject('Comment', {
  fields: (t: any) => ({
    id: t.exposeID('id'),
    content: t.exposeString('content'),
    post: t.relation('post'),
    createdBy: t.relation('createdBy'),
    createdAt: t.expose('createdAt', {
      type: 'DateTime',
    }),
    updatedAt: t.expose('updatedAt', {
      type: 'DateTime',
    }),
    parent: t.relation('parent', {
      nullable: true,
    }),
    replies: t.relation('replies', {
      nullable: true,
    }),
    replyCount: t.int({
      resolve: async (root: any, args: any, ctx: any) => {
        const count = await ctx.prisma.comment.count({
          where: { parentId: root.id },
        })
        return count
      },
    }),
    isTopLevel: t.expose('parent', {
      type: 'Boolean',
      resolve: (root: any) => !root.parent,
    }),
  }),
})

builder.queryType({
  fields: (t) => ({
    postComments: t.field({
      type: ['Comment'] as any,
      args: {
        postId: t.arg.id(),
        skip: t.arg.int({ defaultValue: 0 }),
        take: t.arg.int({ defaultValue: 10 }),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, skip, take } = args
        const comments = await ctx.prisma.comment.findMany({
          where: { postId: postId, parentId: null }, // 只获取顶级评论
          skip: skip || 0,
          take: take || 10,
          orderBy: {
            createdAt: 'desc',
          },
        })
        return comments
      },
    }),
    commentReplies: t.field({
      type: ['Comment'] as any,
      args: {
        commentId: t.arg.id(),
        skip: t.arg.int({ defaultValue: 0 }),
        take: t.arg.int({ defaultValue: 10 }),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { commentId, skip, take } = args
        const replies = await ctx.prisma.comment.findMany({
          where: { parentId: commentId },
          skip: skip || 0,
          take: take || 10,
          orderBy: {
            createdAt: 'desc',
          },
        })
        return replies
      },
    }),
  }),
})

builder.mutationType({
  fields: (t: any) => ({
    addComment: t.field({
      type: 'Comment' as any,
      args: {
        postId: t.arg.id(),
        content: t.arg.string(),
        parentId: t.arg.id({ nullable: true }), //可选 如果提供则创建回复
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, content, parentId } = args
        const comment = await ctx.prisma.comment.create({
          data: { postId, content, createdById: ctx.user.id, parentId },
        })
        return comment
      },
    }),
    deleteComment: t.field({
      type: 'Comment' as any,
      args: {
        id: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { id } = args

        const comment = await ctx.prisma.comment.findUnique({
          where: { id },
        })
        if (!comment) {
          throw new Error('Comment not found')
        }
        if (comment.createdById !== ctx.user.id || ctx.user.role !== 'ADMIN') {
          throw new Error('You are not allowed to delete this comment')
        }
        // 删除评论及其所有子评论
        await ctx.prisma.comment.deleteMany({
          where: { OR: [{ id }, { parentId: id }] },
        })
        return true
      },
    }),
  }),
} as any)

4. 文章接口拿到评论数据

  • commentCount:统计文章评论数量
  • topLevelCommentsCount:顶级评论数
  • 删除文章,把下面的评论一并删除
ts 复制代码
// graphql/types/Post.ts

import prisma from '@/lib/prisma'
import { builder } from '../builder'

builder.prismaObject('Post', {
  fields: (t: any) => ({
    //...
    comments: t.relation('comments'),
    commentCount: t.int({
      resolve: async (parent: any, _args: any, ctx: any) => {
        const commentCount = await ctx.prisma.comment.count({
          where: {
            postId: parent.id,
          },
        })
        return commentCount
      },
    }),
    // 计算字段:顶级评论数(不包括回复)
    topLevelCommentsCount: t.int({
      resolve: async (parent: any, _args: any, ctx: any) => {
        return ctx.prisma.comment.count({
          where: {
            postId: parent.id,
            parentId: null,
          },
        })
      },
    }),
  }),
})

// 定义添加链接的类型
builder.mutationType({
  fields: (t: any) => ({
    deletePost: t.field({
      type: 'Post' as any,
      args: {
        id: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { id } = args

        // 删除与该 Post 相关的 Like 记录
        await ctx.prisma.like.deleteMany({
          where: { postId: id },
        })
        // 删除与该 Post 相关的 Comment 记录
        await ctx.prisma.comment.deleteMany({
          where: { postId: id },
        })

        // 然后删除 Post
        await ctx.prisma.post.delete({
          where: { id },
        })

        return true
      },
    }),
  }),
} as any)

5. 前端评论组件

  • 顶级评论组件
tsx 复制代码
// CommentList.tsx
'use client'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { gql, useMutation, useQuery } from '@apollo/client'
import React, { useEffect, useState } from 'react'
import CommentItem from './CommentItem'
import { Loader, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useSearchParams } from 'next/navigation'
// 创建评论变更
const ADD_COMMENT = gql`
  mutation AddComment($content: String!, $postId: ID!, $parentId: ID) {
    addComment(content: $content, postId: $postId, parentId: $parentId) {
      id
    }
  }
`

const GET_COMMENTS = gql`
  query GetComments($postId: ID!, $skip: Int, $take: Int) {
    postComments(postId: $postId, skip: $skip, take: $take) {
      id
      content
      createdAt
      createdBy {
        id
        nickname
      }
      replyCount
    }
  }
`

type Props = {
  post: any
  currentUserId?: string
}

const COMMENTS_PER_PAGE = 10

const CommentList = ({ post, currentUserId }: Props) => {
  const [newComment, setNewComment] = useState('')
  const [commentsCount, setCommentsCount] = useState(post.topLevelCommentsCount)
  const {
    data: comments,
    loading,
    error,
    refetch: refetchComments,
    fetchMore,
  } = useQuery(GET_COMMENTS, {
    variables: {
      postId: post.id,
      skip: 0,
      take: COMMENTS_PER_PAGE,
    },
    notifyOnNetworkStatusChange: true,
  })

  const [addComment, { loading: addingComment }] = useMutation(ADD_COMMENT, {
    onCompleted: (data) => {
      setNewComment('')
      refetchComments()
      setCommentsCount((prev: number) => prev + 1)
    },
  })

  const handleCommentDeleted = () => {
    setCommentsCount((prev: number) => prev - 1)
    refetchComments()
  }

  const handleSubmitComment = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (!currentUserId) {
      toast.info('Please login to comment')
      return
    }
    if (!newComment.trim()) return

    await addComment({
      variables: {
        content: newComment.trim(),
        postId: post.id,
        parentId: null, // 创建顶级评论
      },
    })
  }

  const handleLoadMoreComments = async () => {
    await fetchMore({
      variables: {
        skip: comments?.postComments.length ?? 0,
        take: COMMENTS_PER_PAGE,
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev
        return {
          postComments: [...prev.postComments, ...fetchMoreResult.postComments],
        }
      },
    })
  }

  const searchParams = useSearchParams()
  useEffect(() => {
    const scrollTo = searchParams.get('scrollTo')
    if (scrollTo === 'comments') {
      const commentsElement = document.getElementById('comments')
      if (commentsElement)
        commentsElement.scrollIntoView({ behavior: 'smooth' })
    }
  }, [searchParams])

  return (
    <div className="mt-8 space-y-4">
      <h4 className="font-semibold" id="comments">
        Comments
      </h4>
      {/* 评论表单 */}
      <form onSubmit={handleSubmitComment} className="max-w-[800px] space-y-2">
        <Textarea
          placeholder="Enter your comment..."
          value={newComment}
          onChange={(e) => setNewComment(e.target.value)}
          rows={3}
        />
        <Button
          type="submit"
          disabled={addingComment || !newComment.trim()}
          size="sm"
          className="cursor-pointer"
        >
          {addingComment ? 'Submitting...' : 'Submit'}
        </Button>
      </form>
      {/* 评论列表 */}
      {loading && (
        <div className="flex justify-center">
          <Loader className="h-4 w-4 animate-spin" />
        </div>
      )}
      <div className="space-y-4">
        {comments?.postComments.map((comment: any) => (
          <CommentItem
            key={comment.id}
            comment={comment}
            currentUserId={currentUserId}
            onCommentDeleted={handleCommentDeleted}
            postId={post.id}
            level={0}
          />
        ))}
      </div>
      {commentsCount > comments?.postComments.length && !loading && (
        <div className="flex justify-center pt-4">
          <Button
            variant="outline"
            size="sm"
            onClick={handleLoadMoreComments}
            className="cursor-pointer text-gray-500 hover:bg-gray-100"
          >
            load more comments
          </Button>
        </div>
      )}
    </div>
  )
}

export default CommentList
  • 子级评论组件,支持嵌套
tsx 复制代码
//CommentItem.tsx
import { Button } from '@/components/ui/button'
import {
  ChevronDown,
  ChevronUp,
  Loader,
  Reply,
  Send,
  Trash2,
  User,
} from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import React, { useEffect, useState } from 'react'
import { gql, useMutation, useQuery } from '@apollo/client'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'

type Props = {
  comment: any
  currentUserId?: string
  onCommentDeleted?: () => void
  level: number
  postId: string
}

const DELETE_COMMENT = gql`
  mutation DeleteComment($id: ID!) {
    deleteComment(id: $id) {
      id
    }
  }
`

const ADD_REPLY = gql`
  mutation AddReply($content: String!, $postId: ID!, $parentId: ID) {
    addComment(content: $content, postId: $postId, parentId: $parentId) {
      id
    }
  }
`

const GET_REPLIES = gql`
  query GetReplies($commentId: ID!, $skip: Int!, $take: Int!) {
    commentReplies(commentId: $commentId, skip: $skip, take: $take) {
      id
      content
      createdAt
      createdBy {
        id
        nickname
      }
      replyCount
    }
  }
`

const REPLIES_PER_PAGE = 10

const CommentItem = ({
  comment,
  currentUserId,
  onCommentDeleted,
  level,
  postId,
}: Props) => {
  console.log('comment---', comment)

  const isMaxLevel = level >= 2

  const [showReplies, setShowReplies] = useState(false)
  const [repliesCount, setRepliesCount] = useState(comment.replyCount)
  const {
    data: repliesData,
    refetch: refetchReplies,
    loading: repliesLoading,
    fetchMore,
  } = useQuery(GET_REPLIES, {
    variables: { commentId: comment.id, skip: 0, take: REPLIES_PER_PAGE },
    skip: !showReplies,
    notifyOnNetworkStatusChange: true,
  })

  // 加载更多回复
  const handleLoadMoreReplies = async () => {
    const currentReplies = repliesData?.commentReplies || []

    try {
      await fetchMore({
        variables: {
          commentId: comment.id,
          skip: currentReplies.length,
          take: REPLIES_PER_PAGE,
        },
        updateQuery: (prev, { fetchMoreResult }) => {
          if (!fetchMoreResult) return prev
          return {
            ...prev,
            commentReplies: [
              ...prev.commentReplies,
              ...fetchMoreResult.commentReplies,
            ],
          }
        },
      })
    } catch (error) {
      console.error('加载更多回复失败:', error)
    }
  }

  const [showReplyForm, setShowReplyForm] = useState(false)
  const [newReply, setNewReply] = useState('')

  const handleSubmitReply = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    await addReply({
      variables: {
        content: newReply,
        postId: postId,
        parentId: comment.id,
      },
    })
  }

  const [deleteComment, { loading: deletingComment }] = useMutation(
    DELETE_COMMENT,
    {
      onCompleted: (data) => {
        onCommentDeleted?.()
      },
    },
  )

  const handleCommentDeleted = () => {
    setRepliesCount((prev: number) => prev - 1)
    refetchReplies()
  }

  const [addReply, { loading: addingReply }] = useMutation(ADD_REPLY, {
    onCompleted: (data) => {
      setShowReplyForm(false)
      setNewReply('')
      refetchReplies()
      setRepliesCount((prev: number) => prev + 1)
    },
  })

  const handleDeleteComment = async (id: string) => {
    await deleteComment({
      variables: { id },
    })
  }

  return (
    <div
      key={comment.id}
      className={`${level > 0 ? 'border-l-1 border-gray-200 pl-4' : ''} border-b-1 border-gray-200 pb-3`}
    >
      <div className="flex h-8 items-center gap-8">
        <div className="flex items-center text-xs text-gray-500">
          <User className="mr-1 size-3" />
          <span>{comment.createdBy?.nickname || 'xxxx'}</span>
          <span className="mx-2">•</span>
          <span>
            {formatDistanceToNow(new Date(comment.createdAt), {
              addSuffix: true,
            })}
          </span>
        </div>
        {comment.createdBy?.id === currentUserId && (
          <Button
            variant="ghost"
            size="sm"
            onClick={() => handleDeleteComment(comment.id)}
            disabled={deletingComment}
            className="cursor-pointer text-gray-500 hover:bg-gray-100"
          >
            <Trash2 className="size-3" />
          </Button>
        )}
      </div>
      <p className="mt-1">{comment.content}</p>
      <div className="mt-1 flex items-center gap-2">
        {!isMaxLevel && (
          <Button
            variant="ghost"
            size="sm"
            className="cursor-pointer text-gray-500"
            onClick={() => {
              if (!currentUserId) {
                toast.info('Please login to reply')
                return
              }
              setShowReplyForm(!showReplyForm)
            }}
          >
            <Reply className="size-3" />
          </Button>
        )}
        {repliesCount > 0 && (
          <div className="flex items-center gap-1">
            <Button
              variant="link"
              size="sm"
              className="cursor-pointer text-xs text-gray-500"
              onClick={() => setShowReplies(!showReplies)}
            >
              {repliesCount} replies
              {showReplies ? (
                <ChevronDown className="size-3" />
              ) : (
                <ChevronUp className="size-3" />
              )}
            </Button>
          </div>
        )}
      </div>
      <div className="ml-2">
        {showReplyForm && (
          <div className="">
            <form className="max-w-[800px]" onSubmit={handleSubmitReply}>
              <Textarea
                placeholder="Enter your reply..."
                name="reply"
                value={newReply}
                onChange={(e) => setNewReply(e.target.value)}
              />
              <div className="flex gap-2">
                <Button
                  variant="outline"
                  size="sm"
                  className="mt-2 cursor-pointer text-gray-500"
                  type="submit"
                  disabled={addingReply}
                >
                  {addingReply ? 'replying...' : 'reply'}
                </Button>
                <Button
                  variant="link"
                  size="sm"
                  className="mt-2 cursor-pointer text-gray-500"
                  onClick={() => setShowReplyForm(false)}
                >
                  cancel
                </Button>
              </div>
            </form>
          </div>
        )}
        {showReplies && (
          <div className="mt-2">
            {repliesLoading && (
              <div className="flex justify-center">
                <Loader className="h-4 w-4 animate-spin" />
              </div>
            )}
            {repliesData?.commentReplies.length > 0 && (
              <div className="replies">
                {repliesData?.commentReplies.map((reply: any) => (
                  <CommentItem
                    key={reply.id}
                    comment={reply}
                    currentUserId={currentUserId}
                    onCommentDeleted={handleCommentDeleted}
                    level={level + 1}
                    postId={postId}
                  />
                ))}
              </div>
            )}

            {repliesCount > repliesData?.commentReplies.length &&
              !repliesLoading && (
                <Button
                  variant="outline"
                  size="sm"
                  className="mt-2 text-gray-500"
                  onClick={handleLoadMoreReplies}
                >
                  load more replies
                </Button>
              )}
          </div>
        )}
      </div>
    </div>
  )
}

export default CommentItem

6. Graphql Provider 支持加载更多

缓存评论查询的接口数据: postCommentscommentReplies

tsx 复制代码
// ApolloProvider.tsx

//...

// Function to create a new Apollo Client instance
function createApolloClient() {
  return new ApolloClient({
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            postComments: {
              keyArgs: ['id'],
              merge(existing = [], incoming, { args }) {
                if (!args?.skip || args.skip === 0) {
                  return incoming
                }

                const existingIds = new Set(
                  existing.map((item: any) => item.id),
                )
                const uniqueIncoming = incoming.filter(
                  (item: any) => !existingIds.has(item.id),
                )

                return [...existing, ...uniqueIncoming]
              },
            },
            commentReplies: {
              keyArgs: ['commentId'],
              merge(existing = [], incoming, { args }) {
                // 如果是第一页或者重新获取,直接返回新数据
                if (!args?.skip || args.skip === 0) {
                  return incoming
                }

                // 创建一个 Set 来去重,基于 id
                const existingIds = new Set(
                  existing.map((item: any) => item.id),
                )
                const uniqueIncoming = incoming.filter(
                  (item: any) => !existingIds.has(item.id),
                )

                return [...existing, ...uniqueIncoming]
              },
            },
          },
        },
      },
    }),
    //...
  })
}

// ...
相关推荐
siwangqishiq21 分钟前
Vulkan Tutorial 教程翻译(四) 绘制三角形 2.2 呈现
前端
李三岁_foucsli3 分钟前
js中消息队列和事件循环到底是怎么个事,宏任务和微任务还存在吗?
前端·chrome
尽欢i3 分钟前
HTML5 拖放 API
前端·html
PasserbyX19 分钟前
一句话解释JS链式调用
前端·javascript
1024小神20 分钟前
tauri项目,如何在rust端读取电脑环境变量
前端·javascript
Nano25 分钟前
前端适配方案深度解析:从响应式到自适应设计
前端
古夕30 分钟前
如何将异步操作封装为Promise
前端·javascript
小小小小宇30 分钟前
前端定高和不定高虚拟列表
前端
古夕41 分钟前
JS 模块化
前端·javascript
wandongle41 分钟前
HTML面试整理
前端·面试·html