写这篇的时候我已经实现完了。下面是这套东西的整体设计 —— 怎么连起来的、改了哪几个地方、想加新字段怎么扩、以及部署到线上时要注意什么。
如果你只是想用,直接打开 管理后台 输密码就行;下面的部分是"怎么搭"。
1 · 整体架构
整个后台就这几个文件夹 + 数据文件夹,做的事很简单:
content/
├─ home.json ← 数据:主页文字存这里(可手改、可被后台覆盖)
└─ docs/ ← 数据:每篇文档一个 .md 文件
├─ junior-high.md
├─ tic-tac-toe.md
└─ ...
app/(marketing)/
├─ admin/page.tsx ← 服务端:看 cookie 决定渲染门还是编辑器
├─ docs/[slug]/page.tsx ← 动态文档路由(读 content/docs/{slug}.md)
└─ docs/admin/page.tsx ← (本文件)开发文档
components/admin/
├─ admin-gate.tsx ← 客户端:密码输入框
├─ home-editor.tsx ← 客户端:home.json 编辑表单
└─ doc-editor.tsx ← 客户端:Markdown 文档编辑表单
app/api/
├─ admin-auth/route.ts ← POST 验密码种 cookie / DELETE 退出 / GET 状态
├─ admin/save/route.ts ← POST:收 { file, data } → 写 content/{file}
└─ admin/docs/ ← 文档专用接口
├─ list/route.ts ← GET:列出 docs/*.md
├─ read/route.ts ← GET:?file=xxx.md → 返回原文
└─ delete/route.ts ← DELETE:?file=xxx.md → 删文件
流程:
- 用户访问
/admin; - 服务端看
admin_authcookie:没有 → 渲染密码门;有 → 渲染文档列表(主页 + 文档); - 密码对了 → 后端种 HTTP-only cookie,7 天有效;前端整页刷新,服务端这次看到 cookie 就显示编辑器;
- 编辑器改完点保存 → POST 到
/api/admin/save;后端再校验一次 cookie,然后写content/*.json或content/docs/*.md; - 主页
/和文档页/docs/{slug}都设了dynamic = "force-dynamic",每次访问都重新读数据。
2 · 安全:密码到底防谁
这是一层最低门槛的认证,不是企业级安全:
- 密码对 → 服务端用
timingSafeEqual做常量时间比较,通过就种HttpOnly+SameSite=Lax的 cookie; - cookie 不是签名 token,值就是密码本身 —— 拿到 cookie 的人能直接重发请求。对个人站够用,不要拿这套去管重要数据;
/api/admin/save每次都会再验一次 cookie,所以即使有人绕过 UI 直接 POST,没 cookie 也写不进去;/admin页面加了robots: { index: false, follow: false },搜索引擎不收录。
3 · 加新字段怎么扩(主页)
假设你想让用户还能改"页脚那一行"。改 3 个地方就行:
① 在 content/home.json 加字段
{
"title": "Ender 的猫猫乐园",
"brandHighlight": "猫猫乐园",
"subtitle": "...",
"announcement": "",
"footer": "© 2026 Ender"
}
② 在 components/admin/home-editor.tsx 的 FIELD_META 加一行
{
key: "footer",
label: "页脚",
help: "整站最底下那行。",
},
③ 在 app/(marketing)/page.tsx 渲染
<footer className="border-t border-border py-6 text-center text-xs text-muted-fg">
{homeContent.footer}
</footer>
完事。不用动 API,不用动 admin page。
4 · 新建 / 编辑文档
新建:在 /admin 文档列表里点"+ 新建文档" → 填 slug(URL 段)、标题、Markdown 内容 → 保存。文件就写到 content/docs/{slug}.md,立刻能在 /docs/{slug} 看到。
编辑:在列表里点对应文档 → 改 Markdown 源码 → 保存。
删除:列表里有删除按钮(会二次确认)。
文档用 Markdown 写。支持的语法:
- 标题
# ## ### - 加粗
**、斜体* - 行内代码
`、代码块``` - 列表
-/1. - 链接
[text](url) - 引用
> - 分隔线
---
文档顶部可以用 HTML 注释写元信息(不会渲染):
accent决定顶部渐变色:brand/info/success/warning
5 · 部署到生产时要注意
关键限制: fs.writeFile 在大多数 serverless 平台(Vercel、Cloudflare Workers 等)写完不会持久化 —— 实例销毁文件就没了。
按平台分两种情况:
✅ 自建服务器 / 长驻进程(Node + PM2 / Docker)
直接用现在的实现。文件写下去就一直在,下次访问读到新内容。推荐配 nginx + 备份,万一硬盘出事能恢复。
⚠️ Vercel / Cloudflare Pages(只读文件系统)
现在的实现会"看起来成功",但下一次部署或者冷启动就没了。要让这个方案在 serverless 上也能用,需要把 /api/admin/save 改成调 GitHub Contents API 直接 commit 到仓库:
PUT https://api.github.com/repos/{owner}/{repo}/contents/{path}
Authorization: Bearer {GITHUB_TOKEN}
{
"message": "edit home.json via admin",
"content": <base64 of new file>,
"sha": <current file sha, 必填否则覆盖会失败>
}
流程变成:拿当前文件 sha → base64 编码新内容 → PUT → 等几秒 Vercel 自动 redeploy。还需要去 GitHub 拿个 PAT 放 GITHUB_TOKEN env 里。
6 · 环境变量
只用一个:
# .env.local(gitignored)
EDITOR_PASSWORD=你的密码
- 不设 → /admin 永远显示"EDITOR_PASSWORD 没配置"。
- 改了 → 旧 cookie 立刻失效,要重输。
- 生产部署也要在平台 env 里设同一份值。
7 · 踩过的坑(防止再踩)
改了 home.json 主页没变化 → 大概率是主页 build time 缓存了 import 进来的 JSON。解决:主页已经加了
dynamic = "force-dynamic",每次请求重新渲染。任何要读 content/*.json 的页面都要加这一句。保存成功但下一次访问还是旧内容 → 文件确实写进去了,但生产平台没读。在 serverless 上十有八九就是这个,看第 5 节。
文件名想包含子目录就改不了 → API 故意限制
/^[\w-]+\.json$/,防止有人传../../etc/passwd。想存到子目录先来改这个正则。bash 命令发中文字符到 API 后变成乱码 → Git Bash on Windows 默认 cp936,UTF-8 字节被解释错位。用 Node 写测试脚本或浏览器表单提交就没这问题。
Markdown 不渲染 → 看看 marked 有没有装好(
npm install marked),动态路由依赖它。
#后台 #API #dev-doc
— end —