6.9 KiB
写博客功能 设计方案
日期:2026-06-25 状态:已与作者确认,待评审
目标
把博客页从写死的占位 POSTS 数组,变成能真正写、存、读、改、删的个人博客。
确认的产品决策:
- 作者范围:每个登录用户有自己的博客,按用户隔离(像照片一样只看得到/改得了自己的文章)。
- 正文格式:Markdown,阅读时渲染成排版。
- 阅读体验:点列表里一篇 → 进独立全文阅读页。
- 路由:
#post/<id>真路由,文章 URL 可分享、可刷新。
非目标(YAGNI)
- 不做评论、点赞、标签筛选、全文搜索。
- 不做草稿/发布状态机(保存即"已发布",对自己可见)。
- 不做图片上传进正文(Markdown 里可引用外链图,但不新增上传管线)。
- 不引
@tailwindcss/typography,手工套设计风格。
架构总览
前端 (React) 后端 (axum)
───────────── ───────────
BlogPage ── api/posts.ts ──HTTP──> /api/web/posts ── PostStore ──> blog/<user>/<id>.json
├ 列表 (AuthUser 鉴权)
├ PostArticle (#post/<id>)
├ PostEditor (内部 state)
└ Markdown (react-markdown)
后端
PostStore(新增 Server/src/posts.rs)
沿用 PhotoStore 套路(按用户隔离的磁盘目录、廉价 Clone、串行化临界区):
-
根目录取 env
BLOG_DIR,默认blog/(相对进程 cwd)。 -
用户目录
blog/<user>/,复用crate::photos::sanitize_user防路径穿越(仅[A-Za-z0-9_-],长度 1–64)。 -
每篇文章一个文件
<hex_id>.json,id 用和照片同样的 16 位 hex 文件名干(new_stem思路;读取已鉴权,无需密码学随机)。 -
文件内容(持久化结构):
{ "title": "...", "tag": "...", "body": "...(Markdown 原文)", "createdAt": 1719300000, "updatedAt": 1719300000 } -
list(user):读目录下全部合法<hex>.json,解析后按createdAt倒序返回。 -
read(user, id)/create(user, draft)/update(user, id, draft)/delete(user, id)。 -
id合法性:<hex>.json,仅小写 hex 干,杜绝路径穿越(仿valid_id)。
上限与校验
| 项 | 上限 | 超限错误 |
|---|---|---|
| 单用户文章数 | MAX_POSTS = 200 |
409 Conflict |
| 正文字节 | MAX_BODY = 64 * 1024 |
413 Payload Too Large |
| 标题字符 | 200 | 400 Bad Request |
| 标签字符 | 40 | 400 Bad Request |
标题去除首尾空白后不得为空(400)。SaveError 枚举映射 HTTP 状态码,和 photos.rs 同构。
接口(Server/src/routes/web.rs,全部 AuthUser 鉴权)
新增子路由 post_routes,body 上限用默认 2MB(正文是文本,足够):
| 方法 | 路径 | 请求体 | 成功响应 |
|---|---|---|---|
| GET | /api/web/posts |
— | { items: Post[], max } |
| POST | /api/web/posts |
{title,tag,body} |
201 {id} |
| GET | /api/web/posts/{id} |
— | Post |
| PUT | /api/web/posts/{id} |
{title,tag,body} |
204 |
| DELETE | /api/web/posts/{id} |
— | 204 |
Post(线上 JSON,camelCase):{ id, title, tag, body, createdAt, updatedAt }。
readingTime 与 excerpt 不入库、不走接口,由前端按正文实时算(阅读时长 ≈ 字数/200 分钟;摘要取正文前 ~80 字、去掉 Markdown 标记)。
state.rs
AppState 加一个字段 pub posts: PostStore,AppState::new() 里 PostStore::from_env()。
前端
api/posts.ts(新增)
仿 api/photos.ts:listPosts / getPost / createPost / updatePost / deletePost,统一带 Authorization: Bearer <token>,API_BASE 取法一致。Post 类型与后端对齐。可区分错误(full / too-large / bad-input / failed)供 UI 提示。
Markdown.tsx(新增)
react-markdown + remark-gfm 封装。给一组自定义组件,套上暖色编辑风:
h1/h2/h3→ Fraunces(font-display)、text-text,逐级缩小。p→text-muted行距宽松;a→text-accent下划线偏移。code(行内)→ 暖色背景小药丸;pre code(块)→surface底、line描边、等宽。blockquote→ 左侧 accent 竖条;ul/ol/li、img(圆角、最大宽度)。- 仅渲染受信任的自有内容;
react-markdown默认不渲染原始 HTML,保持默认即可(防注入)。
路由(改 App.tsx)
在现有 hash 解析器里识别 post/<id> 前缀:
- 当
hash以post/开头 → 当作博客视图(登录后可见),把<id>作为initialPostId传给BlogPage;不走 folder/gate 检查。 - 未登录访问
#post/<id>→ 复用登录页(像#photo),登录后落到该文章。 - 空 hash + 已登录 →
BlogPage列表(不变)。
BlogPage 负责把"列表 ↔ 阅读"反映到地址栏(列表抹掉 hash,阅读 history 切到 #post/<id>),并监听 hashchange 与浏览器后退保持同步。编辑态用组件内 state,不进 URL。
组件拆分(职责单一、可独立理解/测试)
BlogPage.tsx(改):编排 + 列表。挂载时listPosts;渲染真实文章卡(替换写死的POSTS,保留现有卡片视觉:日期/标签、彩虹标题、摘要、阅读时长)。顶部 Hero 旁加"写文章"入口(每人都是自己博客作者,常驻)。initialPostId存在则首屏直接进阅读页。视图 state:list | reading | editing。PostArticle.tsx(新增):全文阅读页。getPost取单篇 →Markdown渲染;含返回、编辑、删除(删除二次确认)。PostEditor.tsx(新增):标题输入 + 标签输入 + 正文textarea+ 实时预览(复用Markdown)+ 保存/取消。新建与编辑共用(有 id 即编辑)。Markdown.tsx(新增):见上。
边界情况
- 空列表:友好引导态("还没有文章,写第一篇吧")。
- 加载中 / 取单篇 404(落回列表并提示)。
- 保存失败:按状态码区分提示(满 / 太长 / 输入非法 / 失败),沿用 photos 的错误区分风格。
- 删除:二次确认。
测试
- 后端
posts.rs单测,仿photos.rs:sanitize/valid_id守卫、create→list→read→update→delete 往返、MAX_POSTS/MAX_BODY/空标题的拒绝、跨用户隔离(读不到别人的)。 - 前端以手工验证为主(列表/写/读/改/删/刷新回到文章/未登录深链)。
涉及文件
新增:Server/src/posts.rs、Client/src/api/posts.ts、Client/src/components/PostArticle.tsx、Client/src/components/PostEditor.tsx、Client/src/components/Markdown.tsx。
修改:Server/src/main.rs(mod posts)、Server/src/state.rs、Server/src/routes/web.rs、Client/src/App.tsx、Client/src/components/BlogPage.tsx、Client/package.json(react-markdown、remark-gfm)。