Featured image of post 自建 CMS 系统:从数据库到富文本编辑器

自建 CMS 系统:从数据库到富文本编辑器

为什么自建 CMS

RuiToolAI 需要管理:

  • 网站导航(顶部菜单、Footer 链接)
  • 博客文章、文档页面
  • 媒体资源(图片、文件)
  • FAQ 内容

用第三方 CMS(Contentful、Sanity)需要额外付费,而且数据不在自己手里。自建 CMS 虽然工作量大,但完全可控,而且可以复用到其他站点。

数据库表设计

导航节点表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export const cmsNavigationNodeTable = sqliteTable("cms_navigation_node", {
  id: text().primaryKey(),
  label: text().notNull(),
  href: text(),
  parentId: text().references(() => cmsNavigationNodeTable.id),
  position: integer().notNull().default(0),
  isExternal: integer({ mode: "boolean" }).default(false),
  createdAt: integer({ mode: "timestamp" }).notNull(),
  updatedAt: integer({ mode: "timestamp" }).notNull(),
});

内容条目表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const cmsEntryTable = sqliteTable("cms_entry", {
  id: text().primaryKey(),
  slug: text().notNull().unique(),
  title: text().notNull(),
  content: text(),           // 富文本 HTML
  excerpt: text(),
  status: text({
    enum: ["draft", "published"]
  }).notNull().default("draft"),
  publishedAt: integer({ mode: "timestamp" }),
  createdAt: integer({ mode: "timestamp" }).notNull(),
  updatedAt: integer({ mode: "timestamp" }).notNull(),
});

媒体资源表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const cmsMediaTable = sqliteTable("cms_media", {
  id: text().primaryKey(),
  filename: text().notNull(),
  r2Key: text().notNull(),
  mimeType: text().notNull(),
  size: integer().notNull(),
  width: integer(),
  height: integer(),
  alt: text(),
  createdAt: integer({ mode: "timestamp" }).notNull(),
});

导航管理

导航支持多级嵌套,通过 parentId 实现树形结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 查询导航树
export async function getNavigationTree() {
  const db = getDB();
  const nodes = await db.query.cmsNavigationNodeTable.findMany({
    orderBy: (table, { asc }) => [asc(table.position)],
  });

  // 构建树形结构
  const nodeMap = new Map(nodes.map(n => [n.id, { ...n, children: [] }]));
  const roots: typeof nodes = [];

  for (const node of nodeMap.values()) {
    if (node.parentId) {
      nodeMap.get(node.parentId)?.children.push(node);
    } else {
      roots.push(node);
    }
  }

  return roots;
}

拖拽排序

管理后台支持拖拽排序,更新 position 字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const updateNavigationPositionsAction = actionClient
  .inputSchema(updatePositionsSchema)
  .action(async ({ parsedInput }) => {
    const db = getDB();
    for (const { id, position } of parsedInput.positions) {
      await db
        .update(cmsNavigationNodeTable)
        .set({ position })
        .where(eq(cmsNavigationNodeTable.id, id));
    }
  });

页面内容管理

Tiptap 富文本编辑器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/app/(admin)/admin/cms/entries/[id]/editor.client.tsx
"use client";

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";

export function ContentEditor({ content, onChange }) {
  const editor = useEditor({
    extensions: [
      StarterKit,
      Image,
      Link.configure({ openOnClick: false }),
    ],
    content,
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
  });

  return (
    <div className="border rounded-lg overflow-hidden">
      <Toolbar editor={editor} />
      <EditorContent editor={editor} className="prose max-w-none p-4" />
    </div>
  );
}

内联编辑

管理员在前台页面可以直接点击内容进行编辑,不需要跳转到后台:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 检测是否是管理员
const { data: session } = useSessionStore();
const isAdmin = session?.user?.role === "admin";

return (
  <div>
    {isAdmin ? (
      <InlineEditor
        content={entry.content}
        onSave={(html) => updateEntryAction({ id: entry.id, content: html })}
      />
    ) : (
      <div dangerouslySetInnerHTML={{ __html: entry.content }} />
    )}
  </div>
);

媒体资源管理

上传文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export const uploadCmsMediaAction = actionClient
  .inputSchema(uploadMediaSchema)
  .action(async ({ parsedInput }) => {
    const { file, alt } = parsedInput;
    const { env } = await getCloudflareContext();

    const fileId = createId();
    const ext = file.name.split(".").pop();
    const r2Key = `cms/media/${fileId}.${ext}`;

    // 上传到 R2
    await env.USER_UPLOADS_BUCKET.put(r2Key, await file.arrayBuffer(), {
      httpMetadata: { contentType: file.type },
    });

    // 保存到 DB
    const db = getDB();
    await db.insert(cmsMediaTable).values({
      id: `media_${fileId}`,
      filename: file.name,
      r2Key,
      mimeType: file.type,
      size: file.size,
      alt,
      createdAt: new Date(),
    });
  });

删除文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
export const deleteCmsMediaAction = actionClient
  .inputSchema(z.object({ id: z.string() }))
  .action(async ({ parsedInput }) => {
    const db = getDB();
    const media = await db.query.cmsMediaTable.findFirst({
      where: eq(cmsMediaTable.id, parsedInput.id),
    });

    if (!media) throw new ActionError("NOT_FOUND", "Media not found");

    // 删除 R2 文件
    const { env } = await getCloudflareContext();
    await env.USER_UPLOADS_BUCKET.delete(media.r2Key);

    // 删除 DB 记录
    await db.delete(cmsMediaTable)
      .where(eq(cmsMediaTable.id, parsedInput.id));

    // 清除 KV 缓存
    await invalidateCmsCache();
  });

管理后台

管理后台的路由结构:

1
2
3
4
5
6
7
/admin
  /admin/users          # 用户管理
  /admin/users/[id]     # 用户详情
  /admin/cms            # CMS 概览
  /admin/cms/navigation # 导航管理
  /admin/cms/entries    # 内容管理
  /admin/cms/media      # 媒体管理

权限控制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// src/app/(admin)/layout.tsx
export default async function AdminLayout({ children }) {
  const session = await getSessionFromCookie();

  if (!session || session.user.role !== "admin") {
    redirect("/sign-in");
  }

  return <>{children}</>;
}

总结

自建 CMS 的核心模块:

模块功能
导航管理树形结构、拖拽排序
内容管理Tiptap 富文本、内联编辑
媒体管理R2 存储、图片预览
权限控制Admin 角色验证

自建 CMS 的好处是完全可控,可以根据业务需求定制功能,而且数据存在自己的 D1,不依赖第三方服务。

使用 Hugo 构建
主题 StackJimmy 设计