之前我做了一个纯静态个人 Geek 博客,最初只有 HTML、CSS、JavaScript 和一些文章静态文件。部署到 Cloudflare Pages 之后,访问速度很快,也不用维护服务器。
但是纯静态博客有一个明显缺点:每次修改文章、个人简介、联系方式、项目链接,都需要手动改文件,然后重新部署。
于是我把它升级成了一个带数据库、带后台管理页面的博客系统:
- 前台:Cloudflare Pages
- 后端 API:Cloudflare Pages Functions
- 数据库:Cloudflare D1
- 后台页面:
/admin/ - 管理内容:个人资料、联系方式、项目、文章、模板
- 鉴权方式:Cloudflare Pages Secret 中保存
ADMIN_TOKEN
最终效果是:不需要自己买服务器,也不需要部署传统 Node.js 后端,就能拥有一个可以在线编辑内容的个人博客 CMS。
C:\Users\86182\Desktop\我的博客可以上瘾了
一、最终项目结构
改造后的项目结构如下:
text
geek-blog/
├─ index.html
├─ post.html
├─ wrangler.toml
├─ schema.sql
├─ package.json
├─ assets/
│ ├─ main.js
│ ├─ render.js
│ └─ style.css
├─ admin/
│ ├─ index.html
│ ├─ admin.js
│ └─ admin.css
├─ functions/
│ └─ api/
│ ├─ _shared.js
│ ├─ site.js
│ ├─ posts/
│ │ └─ [slug].js
│ └─ admin/
│ ├─ _auth.js
│ ├─ login.js
│ ├─ profile.js
│ ├─ links.js
│ ├─ projects.js
│ ├─ template.js
│ └─ posts/
│ └─ index.js
└─ tests/
├─ api.test.mjs
├─ admin-api.test.mjs
└─ render.test.mjs
其中:
index.html是博客首页post.html是文章详情页admin/是后台管理页面functions/api/是 Cloudflare Pages Functions APIschema.sql是 D1 数据库初始化脚本wrangler.toml是 Cloudflare 项目配置assets/render.js封装前台渲染逻辑
二、整体架构
整个系统的架构非常轻量:
text
浏览器
│
├─ 访问 / 博客首页
├─ 访问 /post.html 文章详情页
└─ 访问 /admin/ 后台管理页
Cloudflare Pages
│
├─ 托管 HTML/CSS/JS 静态资源
└─ Pages Functions 提供 /api/* 接口
Cloudflare D1
│
└─ 保存文章、项目、联系方式、个人资料、模板配置
这个架构的好处是:
- 不需要服务器
- 不需要数据库运维
- 不需要 Nginx
- 不需要 PM2
- 不需要传统后端部署
- 前端、API、数据库都在 Cloudflare 上完成
三、创建 Cloudflare Pages 项目
首先使用 Wrangler 创建 Cloudflare Pages 项目:
bash
npx wrangler pages project create geek-blog --production-branch main
创建成功后,Cloudflare 会分配一个默认域名,例如:
text
https://geek-blog-aw8.pages.dev/
后面所有部署都会发布到这个 Pages 项目。
四、创建 D1 数据库
带后台的博客必须有地方存数据,这里使用 Cloudflare D1。
执行:
bash
npx wrangler d1 create geek_blog_db
创建成功后,Wrangler 会返回一个 database_id,类似:
text
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
记住这个 ID,后面要写入 wrangler.toml。
五、配置 wrangler.toml
项目根目录创建 wrangler.toml:
toml
name = "geek-blog"
pages_build_output_dir = "."
compatibility_date = "2026-06-29"
[[d1_databases]]
binding = "DB"
database_name = "geek_blog_db"
database_id = "你的-database-id"
这里最重要的是:
toml
binding = "DB"
因为后端函数中会通过:
js
env.DB
访问 D1 数据库。
例如:
js
await env.DB.prepare("SELECT key, value FROM settings").all();
六、设计数据库表
数据库初始化脚本是 schema.sql。
这套博客系统设计了四张表。
1. settings 表
sql
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
settings 用来保存全局配置,例如:
- 当前模板
- 个人资料
因为这些数据结构比较灵活,所以直接用 JSON 字符串保存。
2. links 表
sql
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
url TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
用于保存联系方式,例如:
- GitHub
- CSDN
- X/Twitter
3. projects 表
sql
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
url TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
sort_order INTEGER NOT NULL DEFAULT 0
);
用于保存项目展示信息。
其中 tags 使用 JSON 数组字符串保存,例如:
json
["web", "typescript"]
4. posts 表
sql
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
published_at TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
文章表中比较关键的是 slug 字段。
文章访问方式是:
text
/post.html?slug=hello-world
而不是直接暴露数据库 id。
七、初始化数据库
创建好 schema.sql 后,执行:
bash
npx wrangler d1 execute geek_blog_db --remote --file schema.sql
如果网络环境导致 --file 上传失败,也可以用多条 --command 分别执行 SQL。
例如:
bash
npx wrangler d1 execute geek_blog_db --remote --command "SELECT 1"
如果返回成功,说明 D1 远程连接正常。
八、实现公开 API
前台需要两个公开接口:
text
GET /api/site
GET /api/posts/:slug
1. 首页聚合接口
文件:
text
functions/api/site.js
核心代码:
js
export async function onRequestGet({ env }) {
const settingsRows = await env.DB
.prepare("SELECT key, value FROM settings")
.all();
const settings = Object.fromEntries(
settingsRows.results.map((row) => [
row.key,
parseJson(row.value, row.value),
])
);
const links = await env.DB.prepare(
"SELECT label, url FROM links ORDER BY sort_order, id"
).all();
const projects = await env.DB.prepare(
"SELECT name, description, url, tags FROM projects ORDER BY sort_order, id"
).all();
const posts = await env.DB.prepare(
"SELECT slug, title, excerpt, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC, id DESC"
).all();
return json({
template: settings.template || "terminal",
profile: settings.profile || {},
links: links.results,
projects: projects.results.map(normalizeProject),
posts: posts.results,
});
}
这个接口一次性返回首页需要的所有数据:
- 当前模板
- 个人资料
- 联系方式
- 项目列表
- 文章列表
这样前台只需要请求一次 /api/site。
2. 文章详情接口
文件:
text
functions/api/posts/[slug].js
代码:
js
export async function onRequestGet({ env, params }) {
const post = await env.DB.prepare(
"SELECT slug, title, excerpt, content, published_at FROM posts WHERE slug = ? AND status = 'published'"
)
.bind(params.slug)
.first();
if (!post) {
return json({ error: "Post not found" }, 404);
}
return json(post);
}
这里使用了 D1 参数绑定:
js
.bind(params.slug)
比字符串拼接 SQL 更安全。
九、实现后台鉴权
后台不能直接公开写入接口,所以需要简单鉴权。
这里采用 ADMIN_TOKEN 方式。
后台登录时输入 token,前端保存到 sessionStorage,之后请求后台接口时带上:
http
Authorization: Bearer <token>
鉴权代码放在:
text
functions/api/admin/_auth.js
核心代码:
js
export function isAuthorized(request, env) {
const header = request.headers.get("authorization") || "";
const token = header.replace(/^Bearer\s+/i, "");
return Boolean(env.ADMIN_TOKEN && token && token === env.ADMIN_TOKEN);
}
export function requireAdmin(request, env) {
if (!isAuthorized(request, env)) {
return json({ error: "Unauthorized" }, 401);
}
return null;
}
登录接口:
text
POST /api/admin/login
后台写操作都会调用 requireAdmin()。
十、实现后台管理 API
后台 API 包括:
text
POST /api/admin/login
GET /api/admin/profile
PUT /api/admin/profile
GET /api/admin/links
PUT /api/admin/links
GET /api/admin/projects
PUT /api/admin/projects
GET /api/admin/posts
POST /api/admin/posts
PUT /api/admin/posts
DELETE /api/admin/posts
GET /api/admin/template
PUT /api/admin/template
以文章管理为例:
js
export async function onRequestPost({ request, env }) {
const auth = requireAdmin(request, env);
if (auth) return auth;
const post = cleanPost(await readJson(request));
if (!validatePost(post)) return json({ error: "Invalid post" }, 400);
await env.DB.prepare(
"INSERT INTO posts (slug, title, excerpt, content, status, published_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(
post.slug,
post.title,
post.excerpt,
post.content,
post.status,
post.published_at
)
.run();
return json({ post });
}
更新文章:
js
export async function onRequestPut({ request, env }) {
const auth = requireAdmin(request, env);
if (auth) return auth;
const post = cleanPost(await readJson(request));
if (!validatePost(post)) return json({ error: "Invalid post" }, 400);
await env.DB.prepare(
"UPDATE posts SET title = ?, excerpt = ?, content = ?, status = ?, published_at = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"
)
.bind(
post.title,
post.excerpt,
post.content,
post.status,
post.published_at,
post.slug
)
.run();
return json({ post });
}
删除文章:
js
export async function onRequestDelete({ request, env }) {
const auth = requireAdmin(request, env);
if (auth) return auth;
const body = await readJson(request);
const slug = String(body?.slug || "").trim();
if (!slug) return json({ error: "Missing slug" }, 400);
await env.DB
.prepare("DELETE FROM posts WHERE slug = ?")
.bind(slug)
.run();
return json({ ok: true });
}
十一、设置后台管理密钥
后台 token 不应该写死在代码里,而应该作为 Cloudflare Pages Secret 保存。
执行:
bash
npx wrangler pages secret put ADMIN_TOKEN --project-name geek-blog
然后输入一个强密码,例如:
text
your-strong-admin-token
注意:不要把真实 token 写进公开博客或 GitHub 仓库。
十二、实现后台页面
后台入口:
text
/admin/
主要文件:
text
admin/index.html
admin/admin.css
admin/admin.js
后台页面分为五个模块:
- 资料
- 联系方式
- 项目
- 文章
- 模板
登录成功后,把 token 保存到 sessionStorage:
js
sessionStorage.setItem("adminToken", token);
统一请求函数:
js
async function api(path, options = {}) {
const response = await fetch(path, {
method: options.method || "GET",
headers: {
"content-type": "application/json",
...(state.token ? { authorization: `Bearer ${state.token}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}
保存资料时:
js
await api("/api/admin/profile", {
method: "PUT",
body: {
name: profileName,
role: profileRole,
stack: profileStack,
location: profileLocation,
status: profileStatus,
intro: profileIntro,
},
});
保存文章时:
js
await api("/api/admin/posts", {
method: state.selectedPost ? "PUT" : "POST",
body: post,
});
十三、前台动态渲染
首页不再写死内容,而是请求:
js
const response = await fetch("/api/site");
然后渲染:
js
function renderSite(data) {
document.body.dataset.template = data.template || "terminal";
setHtml("profile", renderProfile(data.profile));
setHtml("project-list", renderProjects(data.projects));
setHtml("link-list", renderLinks(data.links));
setHtml("post-list", renderPosts(data.posts));
}
这里最关键的是:
js
document.body.dataset.template = data.template || "terminal";
它决定当前网站使用哪套模板。
十四、模板切换功能
目前内置三套模板:
text
terminal
minimal
cyber
CSS 通过属性选择器控制:
css
body[data-template="minimal"] {
--bg: #f7f7f4;
--fg: #151515;
--muted: #686868;
--line: #deded8;
background: var(--bg);
}
赛博风:
css
body[data-template="cyber"] {
--bg: #05070a;
--fg: #eaf7ff;
--muted: #7b8da1;
--accent: #00e0ff;
--line: #143344;
}
后台保存模板时调用:
text
PUT /api/admin/template
后端限制模板只能是:
js
const TEMPLATES = new Set(["terminal", "minimal", "cyber"]);
这样可以避免前端传入任意值。
十五、文章详情页
文章详情页是:
text
post.html
访问方式:
text
/post.html?slug=hello-world
页面读取 URL 参数:
js
const slug = new URLSearchParams(location.search).get("slug");
然后请求:
js
const response = await fetch(`/api/posts/${encodeURIComponent(slug)}`);
拿到文章后渲染标题、日期和正文。
正文使用简单 Markdown 渲染器,支持:
- 标题
- 段落
- 链接
- 行内代码
- 代码块
十六、HTML 转义与安全处理
因为后台输入的内容最终会显示在前台,所以渲染时必须做 HTML 转义。
例如:
js
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[char]);
}
这个函数用于:
- 文章标题
- 项目描述
- 个人简介
- 联系方式文字
- Markdown 普通文本
这样可以避免用户输入破坏页面结构,也能降低 XSS 风险。
十七、部署新版博客
所有功能写完后,执行测试:
bash
npm test
测试通过后部署:
bash
npx wrangler pages deploy . --project-name geek-blog
部署成功后 Wrangler 会输出预览地址,例如:
text
https://xxxxxx.geek-blog-aw8.pages.dev
稳定生产地址:
text
https://geek-blog-aw8.pages.dev/
后台地址:
text
https://geek-blog-aw8.pages.dev/admin/
十八、线上验证
部署后可以验证几个关键入口:
bash
curl https://geek-blog-aw8.pages.dev/api/site
验证文章详情接口:
bash
curl https://geek-blog-aw8.pages.dev/api/posts/hello-world
验证后台页面:
text
https://geek-blog-aw8.pages.dev/admin/
本项目测试结果:
text
# tests 12
# pass 12
# fail 0
说明公开 API、后台 API、渲染函数都通过了基础测试。
十九、部署过程中遇到的问题
1. Wrangler 需要登录
第一次使用 Wrangler 时需要登录:
bash
npx wrangler login
浏览器会打开 Cloudflare 授权页面,授权完成后即可部署 Pages 和操作 D1。
2. D1 schema 文件执行失败
有时执行:
bash
npx wrangler d1 execute geek_blog_db --remote --file schema.sql
可能因为网络问题失败。
可以先测试:
bash
npx wrangler d1 execute geek_blog_db --remote --command "SELECT 1"
如果 SELECT 1 成功,说明 D1 可用,只是 SQL 文件上传流程失败。
这时可以把 schema 拆成多条 --command 执行。
3. 不要把 ADMIN_TOKEN 写进文章
后台 token 只应该保存在 Cloudflare Pages Secret 中。
公开文章中应该写成:
text
your-strong-admin-token
不要泄露真实 token。
二十、这个方案适合什么场景
适合:
- 个人技术博客
- 个人主页
- 作品集
- 小型知识库
- 不想维护服务器的轻量 CMS
- 想用 Cloudflare 免费资源搭建动态站点
不太适合:
- 多人协作后台
- 复杂权限系统
- 大规模内容平台
- 需要全文搜索和复杂工作流的 CMS
总结
这次改造把一个纯静态博客升级成了带数据库和后台管理的在线 CMS。
整体链路是:
text
Cloudflare Pages
↓
静态前台页面
↓
Pages Functions API
↓
Cloudflare D1 数据库
↓
/admin 后台管理内容
部署流程可以总结为:
bash
npx wrangler pages project create geek-blog --production-branch main
npx wrangler d1 create geek_blog_db
npx wrangler d1 execute geek_blog_db --remote --file schema.sql
npx wrangler pages secret put ADMIN_TOKEN --project-name geek-blog
npx wrangler pages deploy . --project-name geek-blog
这个方案最大的优点是轻量。
它不像 WordPress 那样需要维护一整套服务,也不像纯静态博客那样每次改内容都要手动改文件。Cloudflare Pages Functions + D1 刚好提供了一个中间形态:既保留静态站点的速度,又拥有在线后台管理的便利。
对于个人开发者来说,这是一个很值得尝试的建站方案。