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">
← 返回博客
</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 生成站点地图
下一步
学习构建电商商城 — 商品、购物车和支付。
