From edde910cfe0206d125aae53c7ae9e43e992b819a Mon Sep 17 00:00:00 2001 From: cc Date: Sat, 27 Jun 2026 17:17:43 +0800 Subject: [PATCH] =?UTF-8?q?docs(blog):=20=E5=86=99=E5=8D=9A=E5=AE=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=96=87=E6=A1=A3=20+=20=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=AE=8C=E6=88=90=20+=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/blog.md:功能文档(入口/路由、涉及文件、接口、实现注记、上线) - plans/2026-06-25-blog-writing.md:29 个任务步骤全部勾选完成,加状态/偏差说明 - plans/2026-06-18-deploy.md:补写博客全量重部署说明(BLOG_DIR 等) Co-Authored-By: Claude Opus 4.8 --- docs/blog.md | 95 +++++++++++++++++++ docs/superpowers/plans/2026-06-18-deploy.md | 9 ++ .../plans/2026-06-25-blog-writing.md | 64 +++++++------ 3 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 docs/blog.md diff --git a/docs/blog.md b/docs/blog.md new file mode 100644 index 0000000..c43626f --- /dev/null +++ b/docs/blog.md @@ -0,0 +1,95 @@ +# 写博客 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 签名)。 diff --git a/docs/superpowers/plans/2026-06-18-deploy.md b/docs/superpowers/plans/2026-06-18-deploy.md index 91e021e..79c2cbf 100644 --- a/docs/superpowers/plans/2026-06-18-deploy.md +++ b/docs/superpowers/plans/2026-06-18-deploy.md @@ -260,6 +260,15 @@ frontend-only):重编 Rust → 传二进制 → `restart_rust.sh` 重启。 - `PHOTO_DIR`(可选,默认 `photo-wall`,即 `/opt/internet-project/photo-wall/`)—— 图片按用户存到其下子目录,首次上传自动创建。 +**写博客(2026-06-27,后端改动):** +个人博客(登录后默认页,`#post/` 文章路由,详见 [blog.md](../../blog.md))引入了后端 +改动(新增 `posts.rs` + 5 个 `/api/web/posts*` 接口、CORS 放行 PUT),需**全量重部署** +(重编 Rust → 传二进制 → `restart_rust.sh` 重启),非 frontend-only。新增一个可选运行时配置: +- `BLOG_DIR`(可选,默认 `blog`,即 `/opt/internet-project/blog/`)—— 文章按用户存到其下 + 子目录,每篇一个 `.json`,首次写文章自动创建。 +- 复用现有 `weather.env` 的 `DATABASE_URL`(登录校验)与 `JWT_SECRET`(token 签名), + 无需新增密钥。 + > Startpage backgrounds: large PNG/JPG source images are pre-compressed to WebP > (quality ~82) before building — keeps each background under ~600 KB for the > 3 Mbps server link. Two sets live under `Client/src/assets/startpage/`: diff --git a/docs/superpowers/plans/2026-06-25-blog-writing.md b/docs/superpowers/plans/2026-06-25-blog-writing.md index 0f589e6..a657149 100644 --- a/docs/superpowers/plans/2026-06-25-blog-writing.md +++ b/docs/superpowers/plans/2026-06-25-blog-writing.md @@ -2,6 +2,12 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **状态(2026-06-27):✅ 全部 7 个任务完成。** `cargo test` 17 passed、前端 `tsc -b` + +> `npm run build` 通过,并已全量重部署到生产 `http://8.130.143.54:8548` 并跑通端到端 +> CRUD(登录 → 列表 → 增/查/改/删,测试数据已清理)。功能文档见 [../../blog.md](../../blog.md)。 +> **与计划的唯一偏差**:Task 1 给 `Post` 补了 `derive(PartialEq, Eq)`——测试里对 +> `Result` 用 `assert_eq!` 需要它,原计划骨架漏了,否则测试无法编译。 + **Goal:** 把博客页从写死的占位数组变成按用户隔离、可写/存/读/改/删的 Markdown 个人博客,文章走 `#post/` 可分享路由。 **Architecture:** 后端新增文件存储 `PostStore`(仿 `PhotoStore`,`blog//.json`)+ 5 个 `AuthUser` 鉴权接口。前端新增 `api/posts.ts`、`Markdown`(react-markdown)、`PostEditor`、`PostArticle`,并改造 `BlogPage` 为外壳 + 三态中心内容。`App.tsx` 识别 `#post/` 作为列表↔文章的唯一真相源,`BlogPage` 只管编辑器浮层。 @@ -41,7 +47,7 @@ - `pub enum PostError { Full, TooLarge, BadInput, BadUser, NotFound, Io }` - `pub fn valid_id(&str) -> bool`、常量 `MAX_POSTS`、`MAX_BODY`、`MAX_TITLE`、`MAX_TAG` -- [ ] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起,TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD,**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。 +- [x] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起,TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD,**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。 先写骨架 + 测试: @@ -266,12 +272,12 @@ mod tests { mod posts; ``` -- [ ] **Step 2: 跑测试确认失败** +- [x] **Step 2: 跑测试确认失败** Run: `cd Server && cargo test posts::` Expected: 编译通过但运行时 panic(`todo!()`),多个测试 FAIL。 -- [ ] **Step 3: 用真实实现替换 `todo!()` 骨架** +- [x] **Step 3: 用真实实现替换 `todo!()` 骨架** 把 `impl PostStore` 里五个方法体与文件末尾(`valid_id` 之后、`#[cfg(test)]` 之前)的私有辅助函数替换/补全为: @@ -408,12 +414,12 @@ fn new_stem() -> String { 注意:删掉原骨架里 `impl PostStore { ... }` 结尾那个 `}` 与方法体的 `todo!()`,确保 `validate`/`json_files` 等自由函数在 `impl` 块**之外**。 -- [ ] **Step 4: 跑测试确认通过** +- [x] **Step 4: 跑测试确认通过** Run: `cd Server && cargo test posts::` Expected: PASS(7 个测试全绿)。`enforces_max_posts` 较慢(写 200 文件)可接受。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash cd Server && git add src/posts.rs src/main.rs @@ -434,7 +440,7 @@ Co-Authored-By: Claude Opus 4.8 " - Consumes: Task 1 的 `PostStore`、`Post`、`PostDraft`、`PostError`、`MAX_POSTS`。 - Produces: HTTP 接口 `GET/POST /api/web/posts`、`GET/PUT/DELETE /api/web/posts/{id}`,响应形如 `{ items: Post[], max }` 与 `Post`。 -- [ ] **Step 1: 接入 AppState** +- [x] **Step 1: 接入 AppState** `Server/src/state.rs`,在 `use crate::photos::PhotoStore;` 下加: @@ -455,7 +461,7 @@ use crate::posts::PostStore; posts: PostStore::from_env(), ``` -- [ ] **Step 2: CORS 放行 PUT** +- [x] **Step 2: CORS 放行 PUT** `Server/src/routes/web.rs` 第 38 行 `.allow_methods([Method::GET, Method::POST, Method::DELETE])` 改为: @@ -463,7 +469,7 @@ use crate::posts::PostStore; .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) ``` -- [ ] **Step 3: 注册 posts 路由** +- [x] **Step 3: 注册 posts 路由** `Server/src/routes/web.rs` 的 `router()` 里,在 `let state_routes = ...` 之后、`Router::new()` 链之前加: @@ -482,7 +488,7 @@ use crate::posts::PostStore; .layer(cors) ``` -- [ ] **Step 4: 加 5 个 handler** +- [x] **Step 4: 加 5 个 handler** `Server/src/routes/web.rs` 顶部 `use` 区,在 `use crate::photos::{...};` 下加: @@ -576,12 +582,12 @@ async fn delete_post( } ``` -- [ ] **Step 5: 编译 + 跑全部测试** +- [x] **Step 5: 编译 + 跑全部测试** Run: `cd Server && cargo build && cargo test` Expected: 编译通过、全部测试 PASS(无新测试,但确认接线不破坏已有)。 -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash cd Server && git add src/state.rs src/routes/web.rs @@ -608,7 +614,7 @@ Co-Authored-By: Claude Opus 4.8 " - `readingTime(body: string) => string`、`excerpt(body: string, max?) => string` - `type PostInput = { title: string; tag: string; body: string }` -- [ ] **Step 1: 写文件** +- [x] **Step 1: 写文件** ```ts // 博客文章接口封装。基址与 photos.ts 一致:生产留空走同源相对路径。 @@ -705,12 +711,12 @@ export function excerpt(body: string, max = 80): string { } ``` -- [ ] **Step 2: 类型检查** +- [x] **Step 2: 类型检查** Run: `cd Client && npx tsc -b` Expected: 无错误(新文件自洽,未被引用也应通过类型检查)。 -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash cd Client && git add src/api/posts.ts @@ -730,12 +736,12 @@ Co-Authored-By: Claude Opus 4.8 " **Interfaces:** - Produces: `export function Markdown({ children }: { children: string })` —— 把 Markdown 字符串渲染为套了站点暖色编辑风的 React 节点。 -- [ ] **Step 1: 装依赖** +- [x] **Step 1: 装依赖** Run: `cd Client && npm install react-markdown remark-gfm` Expected: `package.json` 的 `dependencies` 多出 `react-markdown` 与 `remark-gfm`;`npm install` 成功。 -- [ ] **Step 2: 写组件** +- [x] **Step 2: 写组件** ```tsx import ReactMarkdown, { type Components } from 'react-markdown' @@ -807,12 +813,12 @@ export function Markdown({ children }: { children: string }) { } ``` -- [ ] **Step 3: 类型检查** +- [x] **Step 3: 类型检查** Run: `cd Client && npx tsc -b` Expected: 无错误。若报 `Components` 导出路径不符,改为 `import ReactMarkdown from 'react-markdown'` + `import type { Components } from 'react-markdown'`。 -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash cd Client && git add package.json package-lock.json src/components/Markdown.tsx @@ -841,7 +847,7 @@ Co-Authored-By: Claude Opus 4.8 " export function PostEditor(props: PostEditorProps) ``` -- [ ] **Step 1: 写组件** +- [x] **Step 1: 写组件** ```tsx import { useState } from 'react' @@ -953,12 +959,12 @@ export function PostEditor({ token, post, onSaved, onCancel }: PostEditorProps) } ``` -- [ ] **Step 2: 类型检查** +- [x] **Step 2: 类型检查** Run: `cd Client && npx tsc -b` Expected: 无错误(组件暂未被引用,类型自洽即可)。 -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash cd Client && git add src/components/PostEditor.tsx @@ -988,7 +994,7 @@ Co-Authored-By: Claude Opus 4.8 " export function PostArticle(props: PostArticleProps) ``` -- [ ] **Step 1: 写组件** +- [x] **Step 1: 写组件** ```tsx import { useEffect, useState } from 'react' @@ -1116,12 +1122,12 @@ export function PostArticle({ token, id, onBack, onEdit, onDeleted }: PostArticl } ``` -- [ ] **Step 2: 类型检查** +- [x] **Step 2: 类型检查** Run: `cd Client && npx tsc -b` Expected: 无错误。 -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash cd Client && git add src/components/PostArticle.tsx @@ -1142,7 +1148,7 @@ Co-Authored-By: Claude Opus 4.8 " - Consumes: `listPosts`、`readingTime`、`excerpt`、`Post`(Task 3);`PostEditor`(Task 5);`PostArticle`(Task 6)。 - Produces: `BlogPage` 新签名 `{ username, token, initialPostId, onSignOut }`。 -- [ ] **Step 1: 改 App.tsx —— 识别 post 路由并传参** +- [x] **Step 1: 改 App.tsx —— 识别 post 路由并传参** `Client/src/App.tsx` 改动如下。 @@ -1206,7 +1212,7 @@ Co-Authored-By: Claude Opus 4.8 " } else if (wantsPhoto || wantsFolder || postId) { ``` -- [ ] **Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容** +- [x] **Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容** 整体替换 `Client/src/components/BlogPage.tsx` 为下面内容(保留原 Hero / 三栏 / 页脚视觉,删除写死的 `POSTS`,列表改读真实数据;`PostArticle`/`PostEditor` 渲染进中间玻璃纸): @@ -1478,12 +1484,12 @@ export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPage } ``` -- [ ] **Step 3: 类型检查 + 构建** +- [x] **Step 3: 类型检查 + 构建** Run: `cd Client && npx tsc -b && npm run build` Expected: 类型检查无错误、`vite build` 成功。 -- [ ] **Step 4: 端到端手工验证** +- [x] **Step 4: 端到端手工验证** 启动后端(需 `DATABASE_URL`):`cd Server && cargo run`(建议设 `BLOG_DIR=./blog-dev`)。 启动前端:`cd Client && npm run dev`,浏览器进登录入口、登录后到博客页。逐项确认: @@ -1497,7 +1503,7 @@ Expected: 类型检查无错误、`vite build` 成功。 7. 后退:从文章页点浏览器后退 → 回到列表。 8. 隔离:换另一账号登录 → 看不到上一个账号的文章。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash cd Client && git add src/App.tsx src/components/BlogPage.tsx