// Created At 2026-03-08
// Nuxt · Security
This article is currently available in Chinese only.

从零到一:为 Moongate 博客打造一个支持多级引用的评论区

📌 代码说明

本文所有代码均基于作者的项目环境编写,旨在清晰展示设计思路与核心实现。由于不同项目的配置(如数据库连接、环境变量、文件路径等)可能存在差异,请根据实际情况灵活调整。直接复制粘贴可能无法运行,理解原理后再动手,才是最高效的学习方式。

1. 背景与需求

在个人技术博客中,评论区是连接作者与读者的重要桥梁。常见的评论区实现要么过于简单(仅支持一级评论),要么依赖第三方服务(如 Disqus),无法自由定制和掌控数据。Moongate 博客需要一个轻量、可定制、支持技术讨论深度的评论区,具体要求包括:

  • 多级引用:读者可以针对某条评论或回复进行精确回应,形成对话链。
  • 扁平时间线:所有评论和回复按时间混合排列,避免视觉上的嵌套混乱。
  • 引用块跳转:点击引用内容可直接跳转到原评论并高亮,方便追溯上下文。
  • 用户认证:仅 GitHub 登录用户可发言,保证社区质量。
  • 响应式设计:在移动端同样有良好体验。

技术栈基于 Nuxt v4、Vue 3、Pinia v3、Drizzle ORM 和 PostgreSQL v18,UI 层采用 Nuxt UI 组件库。

2. 技术选型与设计思路

2.1 数据库设计:多态关联

传统的评论系统通常设计为“评论表 + 回复表”,回复通过外键指向所属评论。但这种结构无法支持“回复的回复”,即多级引用。为此我们选择了多态关联:

  • comments 表存储独立评论(根节点)。
  • replies 表存储所有回复,使用 target_id + target_type 字段指向任意目标(评论或另一条回复)。

这种设计灵活性极高,只需增加枚举类型 target_type 即可支持未来的扩展(如指向文章、用户等)。同时,使用 PostgreSQL 枚举确保数据完整性。

CREATE TYPE target_type AS ENUM ('comment', 'reply');

CREATE TABLE replies (
  id SERIAL PRIMARY KEY,
  target_id INTEGER NOT NULL,
  target_type target_type NOT NULL DEFAULT 'comment',
  user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_replies_target ON replies(target_id, target_type);

2.2 扁平时间线 vs 嵌套展示

传统嵌套回复(如楼中楼)在视觉上会随着深度增加而不断缩进,导致界面复杂,且阅读长对话链时容易迷失。扁平时间线将所有条目(评论和回复)按创建时间统一排序,每条回复通过引用块表明针对的对象。这样既保持了上下文的连贯性,又让界面干净清爽。

2.3 引用块设计

回复内容上方显示引用块,格式为 @用户名: 摘要,左侧用细边框线视觉弱化,但保留可点击性。用户点击引用块可跳转到被引用的原内容并高亮,利用现代 CSS 特性 color-mix 实现柔和的高亮效果。

3. 后端实现

3.1 数据库 Schema 与关系

使用 Drizzle ORM 定义表和关系。由于多态关联的特殊性,我们放弃在 ORM 中定义复杂的关系,而是在业务代码中手动组装数据,保证灵活性和可读性。

查看完整 schema #### `server/db/schema/comments.ts`
import {
  pgTable,
  serial,
  varchar,
  timestamp,
  integer,
  text,
} from "drizzle-orm/pg-core"
import { users } from "./users"

export const comments = pgTable("comments", {
  id: serial("id").primaryKey(),
  user_id: integer("user_id").references(() => users.id, {
    onDelete: "set null",
  }),
  content: text("content").notNull(),
  permalink: varchar("permalink", { length: 255 }).notNull(),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
})

export type CommentSelect = typeof comments.$inferSelect
export type CommentInsert = typeof comments.$inferInsert
#### `server/db/schema/replies.ts`
import {
  pgEnum,
  pgTable,
  serial,
  integer,
  text,
  timestamp,
} from "drizzle-orm/pg-core"
import { users } from "./users"

export const targetTypeEnum = pgEnum("target_type", ["comment", "reply"])

export const replies = pgTable("replies", {
  id: serial("id").primaryKey(),
  target_id: integer("target_id").notNull(),
  target_type: targetTypeEnum("target_type").notNull().default("comment"),
  user_id: integer("user_id").references(() => users.id, {
    onDelete: "set null",
  }),
  content: text("content").notNull(),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
})

export type ReplySelect = typeof replies.$inferSelect
export type ReplyInsert = typeof replies.$inferInsert
#### `server/db/schema/users.ts`
import {
  pgTable,
  serial,
  varchar,
  boolean,
  timestamp,
} from "drizzle-orm/pg-core"

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  github_id: varchar("github_id", { length: 39 }).notNull().unique(),
  username: varchar("username", { length: 100 }).notNull(),
  is_admin: boolean("is_admin").default(false),
  created_at: timestamp("created_at", { withTimezone: true }).defaultNow(),
})

export type UserSelect = typeof users.$inferSelect
export type UserInsert = typeof users.$inferInsert
查看完整关系表
import { relations } from "drizzle-orm"
import { users, comments, replies } from "./index"

// comments 表的关系
export const commentsRelations = relations(comments, ({ one, many }) => ({
  user: one(users, {
    fields: [comments.user_id],
    references: [users.id],
  }),
  // 指向此评论的回复(通过 target_id 和 target_type 筛选)
  // 注意:这只是一个定义,实际查询时需在 where 中添加 target_type = 'comment'
  repliesFrom: many(replies, {
    relationName: "commentTarget",
  }),
}))

// users 表的关系(不变)
export const usersRelations = relations(users, ({ many }) => ({
  comments: many(comments),
  replies: many(replies),
}))

// replies 表的关系
export const repliesRelations = relations(replies, ({ one }) => ({
  user: one(users, {
    fields: [replies.user_id],
    references: [users.id],
  }),
  // 当 target_type = 'comment' 时,指向被引用的评论
  targetComment: one(comments, {
    fields: [replies.target_id],
    references: [comments.id],
    relationName: "commentTarget", // 与 commentsRelations 中的 repliesFrom 对应
  }),
  // 当 target_type = 'reply' 时,指向被引用的回复
  targetReply: one(replies, {
    fields: [replies.target_id],
    references: [replies.id],
    relationName: "replyTarget",
  }),
}))

3.2 获取时间线接口

该接口需要返回当前文章的所有评论和回复,并为每条回复附上被引用内容的摘要(reply_to)。我们采用两步查询:先获取所有评论,再获取所有回复,然后在内存中组装并排序。

server/api/comment/timeline.get.ts

完整的获取时间线接口代码
import { eq, sql } from "drizzle-orm"
import { useDB } from "~~/server/db"
import { comments, replies, users } from "~~/server/db/schema"

export default defineEventHandler(async (event) => {
  const { permalink } = getQuery(event)
  if (!permalink)
    throw createError({ status: 400, statusText: "缺少 permalink" })

  const db = useDB()

  // 获取所有评论
  const commentsData = await db
    .select({
      id: comments.id,
      content: comments.content,
      user_id: comments.user_id,
      created_at: comments.created_at,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(comments)
    .leftJoin(users, eq(comments.user_id, users.id))
    .where(eq(comments.permalink, permalink as string))
    .orderBy(comments.created_at)

  // 构建评论映射,供后续引用摘要使用
  const commentMap = new Map(
    commentsData.map((c) => [
      c.id,
      { content: c.content, username: c.user?.username },
    ]),
  )

  // 获取所有回复(限制属于当前文章)
  const repliesData = await db
    .select({
      id: replies.id,
      content: replies.content,
      user_id: replies.user_id,
      created_at: replies.created_at,
      target_id: replies.target_id,
      target_type: replies.target_type,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(replies)
    .leftJoin(users, eq(replies.user_id, users.id))
    .where(
      sql`${replies.target_id} IN (SELECT id FROM comments WHERE permalink = ${permalink})
                OR ${replies.target_id} IN (SELECT id FROM replies r2 WHERE r2.target_id IN (SELECT id FROM comments WHERE permalink = ${permalink}))`,
    )
    .orderBy(replies.created_at)

  // 构建回复映射
  const replyMap = new Map(
    repliesData.map((r) => [
      r.id,
      { content: r.content, username: r.user?.username },
    ]),
  )

  // 格式化评论
  const formattedComments = commentsData.map((c) => ({
    id: c.id,
    type: "comment" as const,
    content: c.content,
    user: c.user,
    created_at: c.created_at,
  }))

  // 格式化回复并添加引用摘要
  const formattedReplies = repliesData.map((r) => {
    const target =
      r.target_type === "comment"
        ? commentMap.get(r.target_id)
        : replyMap.get(r.target_id)

    return {
      id: r.id,
      type: "reply" as const,
      content: r.content,
      user: r.user,
      created_at: r.created_at,
      target_id: r.target_id,
      target_type: r.target_type,
      reply_to: target
        ? {
            id: r.target_id,
            type: r.target_type,
            username: target.username,
            excerpt:
              target.content.substring(0, 100) +
              (target.content.length > 100 ? "…" : ""),
          }
        : null,
    }
  })

  // 合并并按时间排序
  const timeline = [...formattedComments, ...formattedReplies].sort(
    (a, b) =>
      new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
  )

  return { success: true, data: timeline }
})

3.3 提交评论接口

简单地将用户输入插入 comments 表,返回新评论数据:

server/api/comment/post.ts

import { useDB } from "~~/server/db"
import { comments } from "~~/server/db/schema"

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const session = await getUserSession(event)

  if (!session.user?.id)
    throw createError({ status: 401, statusText: "请先登录" })
  if (!body.content?.trim() || !body.permalink)
    throw createError({ status: 400, statusText: "参数错误" })

  const db = useDB()
  const [newComment] = await db
    .insert(comments)
    .values({
      user_id: session.user.id,
      content: body.content.trim(),
      permalink: body.permalink,
    })
    .returning()

  event.node.res.statusCode = 201
  return { success: true, data: newComment }
})

💡 更完整的安全加固(用户校验、敏感词过滤、文档归属验证、限流)见本系列第 3 篇 《为评论区添加内容过滤与安全防护》。

3.4 提交回复接口

需要验证目标是否存在,并处理多态引用。注意使用 createError 抛出规范错误。

server/api/reply/post.ts

查看完整的回复接口代码
import { eq } from "drizzle-orm"
import { useDB } from "~~/server/db"
import { replies, users, comments } from "~~/server/db/schema"

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const session = await getUserSession(event)

  // 参数校验
  if (
    !body.target_id ||
    !["comment", "reply"].includes(body.target_type) ||
    !body.content?.trim()
  ) {
    throw createError({ status: 400, statusText: "参数错误" })
  }
  if (!session.user?.id)
    throw createError({ status: 401, statusText: "请先登录" })

  const db = useDB()

  // 验证用户存在
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  })
  if (!user) {
    await clearUserSession(event)
    throw createError({ status: 401, statusText: "用户不存在" })
  }

  // 验证目标存在
  if (body.target_type === "comment") {
    const comment = await db
      .select()
      .from(comments)
      .where(eq(comments.id, body.target_id))
      .limit(1)
    if (!comment.length)
      throw createError({ status: 404, statusText: "评论不存在" })
  } else {
    const reply = await db
      .select()
      .from(replies)
      .where(eq(replies.id, body.target_id))
      .limit(1)
    if (!reply.length)
      throw createError({ status: 404, statusText: "回复不存在" })
  }

  // 插入回复
  try {
    const [newReply] = await db
      .insert(replies)
      .values({
        user_id: user.id,
        target_id: body.target_id,
        target_type: body.target_type,
        content: body.content.trim(),
      })
      .returning()

    event.node.res.statusCode = 201
    return { success: true, data: newReply }
  } catch (error) {
    console.error(error)
    throw createError({ status: 500, statusText: "服务器内部错误" })
  }
})

4. 前端状态管理

使用 Pinia 管理评论相关状态,包括当前输入内容、评论列表、加载状态等。

stores/comment.ts

查看完整的pinia代码
import { defineStore } from "pinia"

export const useCommentStore = defineStore("comment", () => {
  const comment = ref("") // 当前输入的评论内容
  const permalink = ref("") // 当前文章标识
  const commentList = ref<any[]>([]) // 扁平时间线数据
  const loading = ref(false) // 获取列表加载状态
  const submitting = ref(false) // 提交评论/回复中

  const getCommentList = async (newPermalink?: string) => {
    if (newPermalink) permalink.value = newPermalink
    if (!permalink.value) return
    loading.value = true
    try {
      const { data } = await $fetch("/api/comment/timeline", {
        query: { permalink: permalink.value },
      })
      commentList.value = data || []
    } catch (error) {
      console.error("获取评论失败", error)
      commentList.value = []
    } finally {
      loading.value = false
    }
  }

  const submitComment = async () => {
    if (!comment.value.trim() || submitting.value) return false
    submitting.value = true
    try {
      const response = await $fetch("/api/comment/post", {
        method: "POST",
        body: { content: comment.value, permalink: permalink.value },
      })
      if (response.success) {
        comment.value = ""
        await getCommentList()
        return true
      }
      return false
    } catch (error) {
      console.error(error)
      return false
    } finally {
      submitting.value = false
    }
  }

  const submitReply = async (
    targetId: number,
    targetType: string,
    content: string,
  ) => {
    if (!content.trim() || submitting.value) return false
    submitting.value = true
    try {
      const response = await $fetch("/api/reply/post", {
        method: "POST",
        body: { target_id: targetId, target_type: targetType, content },
      })
      if (response.success) {
        await getCommentList()
        return true
      }
      return false
    } catch (error) {
      console.error(error)
      return false
    } finally {
      submitting.value = false
    }
  }

  return {
    comment,
    permalink,
    commentList,
    loading,
    submitting,
    getCommentList,
    submitComment,
    submitReply,
  }
})

5. 前端组件实现

5.1 评论区容器组件

components/docs/CommentSection.vue

查看完整组件代码 ```vue ```

5.2 评论列表组件

components/docs/CommentList.vue

查看完整组件代码
<template>
  <div class="max-h-150 overflow-y-auto mt-4 space-y-4">
    <div
      v-for="item in commentStore.commentList"
      :key="`${item.type}-${item.id}`"
      :id="`${item.type}-${item.id}`"
      class="group relative py-6 border-b border-ui-border/30 hover:bg-ui-bg-elevated/50 transition-colors"
    >
      <!-- 引用块(仅回复) -->
      <div
        v-if="item.type === 'reply' && item.reply_to"
        class="mb-2 pl-3 text-sm text-ui-text-muted/80 border-l-2 border-ui-border/40 hover:border-ui-primary/40 transition-colors cursor-pointer"
        @click="scrollToElement(item.reply_to.id, item.reply_to.type)"
      >
        <span class="font-medium">@{{ item.reply_to.username }}</span>
        <span class="italic ml-1">{{ item.reply_to.excerpt }}</span>
      </div>

      <!-- 作者信息 -->
      <div class="flex items-center gap-2 mb-1 text-xs">
        <span class="font-mono font-bold text-ui-text">{{
          item.user?.username
        }}</span>
        <span
          v-if="item.user?.is_admin"
          class="text-[9px] px-1 bg-ui-primary/10 text-ui-primary border border-ui-primary/20"
        >
          {{ t("comment.badge.admin") }}
        </span>
        <span class="text-ui-text-muted/60 text-[10px]">
          {{ dayjs(item.created_at).format("MM-DD HH:mm") }}
        </span>
      </div>

      <!-- 评论内容 -->
      <div
        class="text-ui-text/90 text-sm leading-relaxed break-words max-w-3xl"
      >
        <DocsMarkdownRenderer :content="item.content" />
      </div>

      <!-- 回复按钮 -->
      <div class="flex justify-end mt-2">
        <button
          class="text-xs text-ui-text-muted/70 hover:text-ui-primary transition-colors cursor-pointer"
          @click="toggleReply(item.id, item.type)"
        >
          {{ t("comment.actions.reply") }}
        </button>
      </div>

      <!-- 回复输入框 -->
      <div
        v-if="replyingTo?.id === item.id && replyingTo?.type === item.type"
        class="mt-3 pt-3 border-t border-ui-border/20"
      >
        <DocsCommentInputPreview
          v-model="reply"
          :permalink="commentStore.permalink"
        />
        <div class="flex justify-end gap-2 mt-2">
          <UButton size="sm" variant="ghost" @click="cancelReply">
            {{ t("common.cancel") }}
          </UButton>
          <UButton
            size="sm"
            :disabled="!reply.trim() || commentStore.submitting"
            :loading="commentStore.submitting"
            @click="handleReply"
          >
            {{ t("comment.actions.send") }}
          </UButton>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import dayjs from 'dayjs';
import { useCommentStore } from '~/stores/comment';

const commentStore = useCommentStore();
const { user, loggedIn } = useUserSession();
const { t } = useI18n();

const replyingTo = ref<{ id: number; type: string } | null>(null);
const reply = ref('');

const toggleReply = (id: number, type: string) => {
  if (replyingTo.value?.id === id && replyingTo.value?.type === type) {
    replyingTo.value = null;
  } else {
    replyingTo.value = { id, type };
  }
  reply.value = '';
};

const cancelReply = () => {
  replyingTo.value = null;
  reply.value = '';
};

const handleReply = async () => {
  if (!replyingTo.value) return;
  const success = await commentStore.submitReply(
    replyingTo.value.id,
    replyingTo.value.type,
    reply.value
  );
  if (success) {
    cancelReply();
  }
};

const scrollToElement = (id: number, type: string) => {
  const el = document.getElementById(`${type}-${id}`);
  if (el) {
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
    el.classList.add('highlight-flash');
    setTimeout(() => el.classList.remove('highlight-flash'), 1000);
  }
};
</script>

<style scoped>
.highlight-flash {
  background-color: color-mix(in srgb, var(--ui-primary), transparent 90%);
  transition: background-color 0.3s ease;
}
</style>

5.3 输入预览组件

components/docs/CommentInputPreview.vue 实现了带防抖的 Markdown 输入和预览。

查看完整组件代码
<template>
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 mb-6">
    <!-- 左侧预览 -->
    <div class="bg-ui-bg-elevated">
      <div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
        // {{ t("comment.input.preview") }}
      </div>
      <DocsMarkdownRenderer
        class="text-ui-text/90 text-base leading-relaxed"
        :content="localValue"
      />
    </div>

    <!-- 右侧输入 -->
    <div class="bg-ui-bg">
      <div class="text-xs font-mono text-ui-text-muted mb-2 tracking-wider">
        // {{ t("comment.input.input") }}
      </div>
      <UTextarea
        :model-value="localValue"
        autoresize
        :rows="5"
        variant="none"
        :placeholder="t('comment.input.placeholder')"
        class="w-full bg-transparent border-0 focus:ring-0 p-0 text-ui-text placeholder:text-ui-text-muted/50 font-mono text-sm"
        @update:model-value="(value) => handleInput(value)"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useDebounceFn } from "@vueuse/core"
const { t } = useI18n()

const props = defineProps({
  modelValue: { type: String, default: "" }, // v-model 绑定的值
  debounceTime: { type: Number, default: 300 }, // 防抖延迟(毫秒),默认 300ms
  permalink: { type: String, required: true }, // 用于构建存储 key
  storageType: {
    type: String,
    default: "none",
    validator: (val: string) => ["session", "local", "none"].includes(val),
  },
})

const emit = defineEmits(["update:modelValue"])

// 创建一个 ref 来存储本地输入值
const localValue = ref(props.modelValue)

// 监听父组件 prop 变化,同步到本地
watch(
  () => props.modelValue,
  (newVal) => {
    localValue.value = newVal
  },
)

// 用防抖函数包装 emit
const debouncedEmit = useDebounceFn((value: string) => {
  emit("update:modelValue", value)
}, props.debounceTime)

// 当输入框的文本改变时
const handleInput = (value: string) => {
  localValue.value = value // 立即更新预览
  debouncedEmit(value) // 防抖更新父组件
}
</script>

6. 交互细节打磨

6.1 防抖输入

在 CommentInputPreview 中使用 useDebounceFn 实现用户停止输入 300ms 后才更新父组件,避免频繁请求。

6.2 点击引用跳转并高亮

如上代码所示,点击引用块时调用 scrollToElement,利用 scrollIntoView 平滑滚动到目标元素,并添加一个临时 CSS 类实现高亮。高亮采用 color-mix 生成半透明背景色,简洁现代。

6.3 回复框的开关管理

每个条目独立控制回复框的展开/关闭,使用 replyingTo 记录目标,确保同时只能打开一个回复框,防止界面混乱。

6.4 提交后自动刷新

提交评论或回复成功后,调用 getCommentList 刷新整个列表,确保数据一致性。

7. 总结与展望

至此,博客拥有了一套功能完备、体验优雅的评论区系统。它不仅支持多级引用、扁平时间线、引用跳转高亮,还具备良好的响应式设计和用户体验。

If this article helped you, you can buy me a coffee ☕️
Ali PayWechat Pay