RaisFastRaisFast
Full-Stack Development

博客实战

端到端构建完整博客平台 — 内容建模、文章列表、详情页、分类筛选和 SEO。

你将构建什么

一个包含以下功能的博客:

  • 带分页的文章列表
  • Markdown 渲染的文章详情
  • 分类筛选
  • 标签浏览
  • SEO 友好的 URL(基于 slug)
  • 封面图

第 1 步 — 定义内容类型

创建两个内容类型 — 分类和文章:

name = "Category"
table = "blog_categories"
plural = "categories"

[fields.name]
type = "text"
required = true
unique = true

[fields.slug]
type = "text"
required = true
unique = true

[fields.description]
type = "text"
name = "Post"
table = "blog_posts"
plural = "posts"

[fields.title]
type = "text"
required = true

[fields.slug]
type = "text"
required = true
unique = true

[fields.body]
type = "rich_text"

[fields.excerpt]
type = "text"
max_length = 300

[fields.cover_image]
type = "media"
accept = "image/*"

[fields.category]
type = "relation"
related_type = "categories"
relation = "many_to_one"

[fields.tags]
type = "relation"
related_type = "tags"
relation = "many_to_many"

[fields.status]
type = "enum"
options = ["draft", "published"]
default = "draft"

[fields.published_at]
type = "timestamp"

重启服务以创建数据库表。

第 2 步 — 创建示例数据

# 创建分类
curl -X POST http://localhost:9898/api/v1/admin/cms/categories \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"技术","slug":"technology","description":"技术文章"}'

# 创建文章
curl -X POST http://localhost:9898/api/v1/admin/cms/posts \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "RaisFast 入门指南",
    "slug": "getting-started",
    "body": "## 欢迎\n\n这是我的第一篇文章...",
    "status": "published",
    "published_at": "2025-01-15T10:00:00Z"
  }'

第 3 步 — 前端:文章列表页

import { client } from "@/lib/client";
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";

interface Post {
  id: string;
  title: string;
  slug: string;
  excerpt?: string;
  cover_image?: string;
  published_at: string;
}

export function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    client.contentTypes
      .list("posts", {
        page,
        limit: 10,
        status: "published",
        sort: "published_at",
        order: "desc",
      })
      .then((res) => setPosts(res.data));
  }, [page]);

  return (
    <div className="max-w-4xl mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">博客</h1>
      <div className="space-y-6">
        {posts.map((post) => (
          <Link
            key={post.id}
            to={`/blog/${post.slug}`}
            className="block p-6 border rounded-lg hover:shadow-md transition"
          >
            {post.cover_image && (
              <img src={post.cover_image} alt={post.title} className="mb-4 rounded" />
            )}
            <h2 className="text-xl font-semibold">{post.title}</h2>
            {post.excerpt && (
              <p className="text-gray-600 mt-2">{post.excerpt}</p>
            )}
            <time className="text-sm text-gray-400 mt-2 block">
              {new Date(post.published_at).toLocaleDateString()}
            </time>
          </Link>
        ))}
      </div>
      <div className="flex gap-4 mt-8">
        {page > 1 && (
          <button onClick={() => setPage(page - 1)}>上一页</button>
        )}
        <button onClick={() => setPage(page + 1)}>下一页</button>
      </div>
    </div>
  );
}

第 4 步 — 前端:文章详情页

import { client } from "@/lib/client";
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";

interface Post {
  id: string;
  title: string;
  slug: string;
  body: string;
  published_at: string;
}

export function PostDetail() {
  const { slug } = useParams();
  const [post, setPost] = useState<Post | null>(null);

  useEffect(() => {
    if (!slug) return;
    client.contentTypes
      .list("posts", { slug, status: "published", limit: 1 })
      .then((res) => {
        if (res.data.length > 0) setPost(res.data[0]);
      });
  }, [slug]);

  if (!post) return <div>加载中...</div>;

  return (
    <article className="max-w-4xl mx-auto py-8">
      <Link to="/blog" className="text-sm text-gray-500 mb-4 block">
        &larr; 返回博客
      </Link>
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <time className="text-gray-400 block mb-8">
        {new Date(post.published_at).toLocaleDateString()}
      </time>
      <div
        className="prose max-w-none"
        dangerouslySetInnerHTML={{ __html: post.body }}
      />
    </article>
  );
}

第 5 步 — 分类筛选

export function CategoryFilter({ onFilter }) {
  const [categories, setCategories] = useState([]);

  useEffect(() => {
    client.contentTypes.list("categories", { limit: 100 }).then((res) => {
      setCategories(res.data);
    });
  }, []);

  return (
    <div className="flex gap-2 mb-8">
      <button onClick={() => onFilter(null)} className="px-3 py-1 rounded border">
        全部
      </button>
      {categories.map((cat) => (
        <button
          key={cat.id}
          onClick={() => onFilter(cat.id)}
          className="px-3 py-1 rounded border"
        >
          {cat.name}
        </button>
      ))}
    </div>
  );
}

将分类筛选传递到文章列表:

client.contentTypes.list("posts", {
  page: 1,
  limit: 10,
  status: "published",
  category: selectedCategoryId, // 按关联筛选
  sort: "published_at",
  order: "desc",
});

SEO 技巧

  • 使用基于 slug 的 URL(/blog/getting-started)而不是 ID
  • 从文章的标题和摘要设置 <title><meta description>
  • 添加 Open Graph 标签用于社交分享
  • 从文章 API 生成站点地图

下一步

学习构建电商商城 — 商品、购物车和支付。

On this page