# 写博客功能 设计方案 日期:2026-06-25 状态:已与作者确认,待评审 ## 目标 把博客页从写死的占位 `POSTS` 数组,变成能真正**写、存、读、改、删**的个人博客。 确认的产品决策: - **作者范围**:每个登录用户有自己的博客,按用户隔离(像照片一样只看得到/改得了自己的文章)。 - **正文格式**:Markdown,阅读时渲染成排版。 - **阅读体验**:点列表里一篇 → 进独立全文阅读页。 - **路由**:`#post/` 真路由,文章 URL 可分享、可刷新。 ## 非目标(YAGNI) - 不做评论、点赞、标签筛选、全文搜索。 - 不做草稿/发布状态机(保存即"已发布",对自己可见)。 - 不做图片上传进正文(Markdown 里可引用外链图,但不新增上传管线)。 - 不引 `@tailwindcss/typography`,手工套设计风格。 ## 架构总览 ``` 前端 (React) 后端 (axum) ───────────── ─────────── BlogPage ── api/posts.ts ──HTTP──> /api/web/posts ── PostStore ──> blog//.json ├ 列表 (AuthUser 鉴权) ├ PostArticle (#post/) ├ PostEditor (内部 state) └ Markdown (react-markdown) ``` ## 后端 ### PostStore(新增 `Server/src/posts.rs`) 沿用 `PhotoStore` 套路(按用户隔离的磁盘目录、廉价 `Clone`、串行化临界区): - 根目录取 env `BLOG_DIR`,默认 `blog/`(相对进程 cwd)。 - 用户目录 `blog//`,复用 `crate::photos::sanitize_user` 防路径穿越(仅 `[A-Za-z0-9_-]`,长度 1–64)。 - 每篇文章一个文件 `.json`,id 用和照片同样的 16 位 hex 文件名干(`new_stem` 思路;读取已鉴权,无需密码学随机)。 - 文件内容(持久化结构): ```json { "title": "...", "tag": "...", "body": "...(Markdown 原文)", "createdAt": 1719300000, "updatedAt": 1719300000 } ``` - `list(user)`:读目录下全部合法 `.json`,解析后按 `createdAt` **倒序**返回。 - `read(user, id)` / `create(user, draft)` / `update(user, id, draft)` / `delete(user, id)`。 - `id` 合法性:`.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 `,`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/` 前缀: - 当 `hash` 以 `post/` 开头 → 当作博客视图(登录后可见),把 `` 作为 `initialPostId` 传给 `BlogPage`;**不**走 folder/gate 检查。 - 未登录访问 `#post/` → 复用登录页(像 `#photo`),登录后落到该文章。 - 空 hash + 已登录 → `BlogPage` 列表(不变)。 `BlogPage` 负责把"列表 ↔ 阅读"反映到地址栏(列表抹掉 hash,阅读 `history` 切到 `#post/`),并监听 `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)。