这篇文档讲这套后台的整体设计 —— 怎么连起来的、改了哪几个地方、想加新字段怎么扩、以及部署到线上时要注意什么。代码层面刚刚做过一次重构,共享认证 / 路径校验全部下沉到了 lib/,所以这里和旧版会有些不一样。
如果你只是想用,直接打开 管理后台 输密码就行;下面的部分是"怎么搭"。
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/page.tsx ← 文档列表
components/admin/
├─ admin-gate.tsx ← 客户端:密码输入框
├─ home-editor.tsx ← 客户端:home.json 编辑表单
├─ doc-editor.tsx ← 客户端:Markdown 文档编辑 + 实时预览 + 删除确认
├─ logout-button.tsx ← 客户端:两 tab 共用的退出按钮
├─ admin-hero.tsx ← 渐变 hero + 返回链接(被 admin / docs / docs/[slug] / docs/tag 复用)
├─ admin-tabs.tsx ← 管理后台 tab 切换
└─ back-link.tsx ← hero 左上角"返回"链接
lib/
├─ admin-auth.ts ← safeEqual / AUTH_COOKIE / getAdminSession / requireAdmin
└─ content-paths.ts ← CONTENT_DIR / DOCS_DIR / resolveContentPath / resolveDocPath
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; - 服务端用
getAdminSession()检查admin_authcookie:没有 → 渲染密码门;有 → 渲染 tab + 编辑器; - 密码对了 → 后端种 HTTP-only cookie,7 天有效;前端弹个 toast 再刷新,服务端这次看到 cookie 就显示编辑器;
- 编辑器改完点保存 → POST 到
/api/admin/save;后端用requireAdmin()再校验一次 cookie,然后用resolveContentPath()校验路径,再写content/*.json或content/docs/*.md; - 主页
/和文档页/docs/{slug}都设了dynamic = "force-dynamic",每次访问都重新读数据。
2 · 安全:密码到底防谁
这是一层最低门槛的认证,不是企业级安全:
- 密码对 → 服务端用
safeEqual做常量时间比较,通过就种HttpOnly+SameSite=Lax的 cookie; - cookie 不是签名 token,值就是密码本身 —— 拿到 cookie 的人能直接重发请求。对个人站够用,不要拿这套去管重要数据;
requireAdmin()每个写操作路由(API handler)都会再调一次,所以即使有人绕过 UI 直接 POST,没 cookie 也写不进去;/admin页面加了robots: { index: false, follow: false },搜索引擎不收录;- 文件名 / slug 通过
resolveContentPath()/resolveDocPath()做白名单校验 + 越界检查,不允许..或绝对路径。
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?tab=docs 文档列表里点"+ 新建文档" → 填 slug(URL 段,有自动建议)、标题、Markdown 内容 → 保存。文件就写到 content/docs/{slug}.md,立刻能在 /docs/{slug} 看到。
编辑:在列表里点对应文档 → 改 Markdown 源码 → 保存。可以在"编辑"和"预览"之间切换,所见即所得地用 .prose-doc 样式渲染。底部显示"未保存 / 已同步"状态 + 字符数。
删除:列表里有删除按钮,点击后弹模态确认(二次确认),不再用浏览器原生 confirm()。
文档用 Markdown 写。支持的语法:
- 标题
# ## ### - 加粗
**、斜体* - 行内代码
`、代码块``` - 列表
-/1. - 链接
[text](url) - 引用
> - 分隔线
---
文档顶部可以用 HTML 注释写元信息(不会渲染):
<!-- title: 文档标题 -->
<!-- date: 2026-07-12 -->
<!-- author: Ender -->
<!-- tag: 回忆 -->
<!-- accent: info -->
accent决定顶部渐变色:brand/info/success/warning- 多个标签用
/或,分隔:<!-- tag: 折腾 / Next.js -->
5 · admin 用到的 UI 组件
后台几个组件都用了 components/atoms/ 下已有的原子组件,而不是手写 Tailwind:
| 用途 | 用的组件 |
|---|---|
| 按钮 | Button(variant: primary / outline / ghost / destructive / icon;size: sm / md / lg) |
| 表单字段 | Field + FieldControl / Textarea(自动关联 label / hint / error / invalid 样式) |
| 反馈提示 | Alert(variant: info / success / warning / error)+ useToast()(右上角 toast) |
| 二次确认 | Modal(基于 Radix Dialog,有标题/描述/右上角关闭按钮) |
| 退出登录 | LogoutButton(两 tab 共用) |
Toaster Provider 已经在根布局挂好了,所有页面都能用 useToast()。
6 · 部署到生产时要注意
关键限制: 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 里。
7 · 环境变量
只用一个:
# .env.local(gitignored)
EDITOR_PASSWORD=你的密码
- 不设 → /admin 永远显示"EDITOR_PASSWORD 没配置"。
- 改了 → 旧 cookie 立刻失效,要重输。
- 生产部署也要在平台 env 里设同一份值。
8 · 踩过的坑(防止再踩)
改了 home.json 主页没变化 → 大概率是主页 build time 缓存了 import 进来的 JSON。解决:主页已经加了
dynamic = "force-dynamic",每次请求重新渲染。任何要读 content/*.json 的页面都要加这一句。保存成功但下一次访问还是旧内容 → 文件确实写进去了,但生产平台没读。在 serverless 上十有八九就是这个,看第 6 节。
文件名想包含子目录就改不了 → API 故意限制
/^(?:docs\/)?[\w-]+\.(json|md)$/,防止有人传../../etc/passwd。想存到其他子目录先来lib/content-paths.ts改这个正则 +resolveContentPath。bash 命令发中文字符到 API 后变成乱码 → Git Bash on Windows 默认 cp936,UTF-8 字节被解释错位。用 Node 写测试脚本或浏览器表单提交就没这问题。
Markdown 不渲染 → 看看 marked 有没有装好(
npm install marked),动态路由依赖它。改了
lib/admin-auth.ts但行为没变 → 这是预期:重构只把safeEqual/ cookie 名 /requireAdmin()从 4 个 API 路由 + admin 页面集中到这里,行为应该 100% 等价。/api/admin-auth端点和 cookie 名(admin_auth)都保持不变,现有浏览器 cookie 继续生效。
#后台 #API #dev-doc #重构
— end —