# 写博客 Blog(个人 Markdown 博客 + `#post/` 路由) 登录后的个人博客:按登录用户隔离,可写 / 存 / 读 / 改 / 删 Markdown 文章。文章正文走 Markdown 渲染,单篇文章有 `#post/` 可分享路由(刷新/直链都能定位到该文章)。 **职责切分**:文章正文**按登录用户存服务器**(最多 200 篇/人、严格私有、JWT 鉴权), 每篇一个 JSON 文件;`readingTime`/`excerpt` 等派生信息不入库,前端实时算。 设计方案见 [specs/2026-06-25-blog-writing-design.md](superpowers/specs/2026-06-25-blog-writing-design.md), 实现计划见 [plans/2026-06-25-blog-writing.md](superpowers/plans/2026-06-25-blog-writing.md)。 ## 入口与路由 博客页是开发者入口门(`dev_gate`)登录后的**默认页**(非 `#photo`、非特殊文件夹时)。 列表↔文章的唯一真相源是 `App.tsx` 的地址栏 hash: ``` 浏览器(已登录,无 #photo / 文件夹 hash) └─ BlogPage:Hero + 文章列表 点某篇文章 → 地址栏置 #post/ └─ App 解析 #post/ → 传 initialPostId 给 BlogPage → 渲染 PostArticle 全文 未登录直接开 #post/ → 复用登录页,登录后落到该文章 ``` - 列表↔文章由 hash 驱动(可分享、可刷新、浏览器后退回列表);**编辑器浮层不进 URL** (不可分享,符合设计)。 - 文章页「编辑」保存后,靠 `articleNonce` 作 `PostArticle` 的 `key` 强制重挂载, 保证显示最新内容(否则 id 不变其 fetch effect 不重跑)。 ## 涉及文件 前端: - `Client/src/App.tsx` — 解析 `#post/`:置 `postId`,传 `token`/`initialPostId` 给 `BlogPage`;`#post/` 未登录时也走登录页。 - `Client/src/components/BlogPage.tsx` — 外壳(Hero / 三栏 / 页脚)+ 中心三态内容 (列表 / 文章 / 编辑器),列表读真实数据,写死的占位数组已删除。 - `Client/src/components/PostArticle.tsx` — 全文阅读页(Markdown 渲染、编辑入口、 删除二次确认)。 - `Client/src/components/PostEditor.tsx` — 写 / 改文章(标题 / 标签 / 正文 + Markdown 预览)。 - `Client/src/components/Markdown.tsx` — `react-markdown` + `remark-gfm`,套站点暖色编辑风; 默认不解析原始 HTML(未引 `rehype-raw`),天然防注入。 - `Client/src/api/posts.ts` — 文章接口封装(见下)+ 派生工具 `readingTime`/`excerpt`。 后端(Rust): - `Server/src/posts.rs` — `PostStore`:按用户隔离到 `BLOG_DIR/<用户>/.json`,含 路径安全(`valid_id` 仅小写 hex ≤32)、限额、`create` 加锁防并发越额;单测覆盖 CRUD / 倒序 / 坏输入 / 上限 / 用户隔离 / 坏 id。 - `Server/src/state.rs` — `AppState` 新增 `posts: PostStore` 字段。 - `Server/src/routes/web.rs` — 文章接口(见下);CORS 放行 `PUT`。 - `Server/src/main.rs` — `mod posts;`。 - 复用 `Server/src/routes/extract.rs` 的 `AuthUser` 提取器、`photos::sanitize_user`。 ## 接口(均经 `AuthUser` 鉴权,按用户隔离) | 方法 | 路径 | 行为 | |---|---|---| | GET | `/api/web/posts` | 列出当前用户文章(按 `createdAt` 倒序,`{ items:[Post], max:200 }`) | | POST | `/api/web/posts` | 新建(body `{ title, tag, body }`)→ 201 + `Post`;满 200 篇 409、超 64KB 413、标题空/标签过长 400 | | GET | `/api/web/posts/{id}` | 取单篇 `Post`(属主校验,非法/不存在 404) | | PUT | `/api/web/posts/{id}` | 更新 → 204(创建时间不变,更新时间刷新) | | DELETE | `/api/web/posts/{id}` | 删除 → 204(不存在 404) | `Post` 字段 camelCase:`id, title, tag, body, createdAt, updatedAt`(秒级时间戳)。 上限常量:`MAX_POSTS=200`、`MAX_BODY=64KiB`、标题 ≤200 字、标签 ≤40 字,标题 trim 后不得为空。 存储:`BLOG_DIR`(默认 `blog`,线上 `/opt/internet-project/blog/`)下每用户一子目录, 每篇随机十六进制文件名 `.json`。读取已鉴权,文件名干无需密码学随机。 ## 功能 - **写 / 改**:标题、标签、Markdown 正文;编辑器内「Write / Preview」切换实时预览。 - **读**:全文 Markdown 渲染(标题/列表/引用/代码块/链接/图片/分隔线/GFM 表格等), 顶部显示标签、日期、估算阅读时长。 - **删**:文章页二次确认(「删除」→「确认删除」)。 - **列表**:最新在前,每篇卡片显示日期、标签、摘要(剥除 Markdown 标记)、阅读时长; 空态引导「写文章」。 - **派生**:`readingTime`(~200 字/分钟,最少 1 分钟)、`excerpt`(剥标记取前 80 字) 均前端实时算,不入库。 ## 实现注记 - **路由真相源单一**:列表↔文章统一由 `App.tsx` 经 hash 决定(`initialPostId`), `BlogPage` 仅管编辑器浮层,避免双方争用 URL。 - **就地编辑刷新**:`PostArticle` 的 fetch effect 依赖 `[token, id]`,id 不变时不重拉; 故编辑保存后 bump `articleNonce` 并用作 `key` 强制重挂载。 - **Markdown code 边界**:以 `language-` 类名区分行内/块级,未指定语言的围栏块按行内样式 渲染(写 ` ```lang ` 即按块级)——个人博客可接受,不引额外判定增复杂度。 - **防注入**:未引 `rehype-raw`,原始 HTML 不解析;正文区 `data-selectable` 放开选择以便复制。 ## 线上访问 `http://8.130.143.54:8548/`(开发者入口门登录后即默认进博客页)。博客引入后端改动 → 走**全量重部署**(重编 Rust → 传二进制 → `restart_rust.sh` 重启),非 frontend-only。 存储目录 `BLOG_DIR` 默认 `blog`(即 `/opt/internet-project/blog/`),首次写文章自动建 `blog/<用户>/`。复用现有 `weather.env` 的 `DATABASE_URL`(登录校验)与 `JWT_SECRET`(token 签名)。