diff --git a/docs/superpowers/plans/2026-06-25-blog-writing.md b/docs/superpowers/plans/2026-06-25-blog-writing.md new file mode 100644 index 0000000..75cadb5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-blog-writing.md @@ -0,0 +1,1511 @@ +# 写博客功能 Implementation Plan + +> **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. + +**Goal:** 把博客页从写死的占位数组变成按用户隔离、可写/存/读/改/删的 Markdown 个人博客,文章走 `#post/` 可分享路由。 + +**Architecture:** 后端新增文件存储 `PostStore`(仿 `PhotoStore`,`blog//.json`)+ 5 个 `AuthUser` 鉴权接口。前端新增 `api/posts.ts`、`Markdown`(react-markdown)、`PostEditor`、`PostArticle`,并改造 `BlogPage` 为外壳 + 三态中心内容。`App.tsx` 识别 `#post/` 作为列表↔文章的唯一真相源,`BlogPage` 只管编辑器浮层。 + +**Tech Stack:** Rust / axum 0.8 / serde / sqlx(已有,无关);React 19 / TypeScript / Tailwind v4 / framer-motion / 新增 react-markdown + remark-gfm。 + +## Global Constraints + +- 文件存储根目录走 env:`BLOG_DIR`,默认 `blog`(相对进程 cwd)。 +- 用户名安全化复用 `crate::photos::sanitize_user`(仅 `[A-Za-z0-9_-]`,长度 1–64)。 +- 上限:`MAX_POSTS = 200`、`MAX_BODY = 64 * 1024`、标题 ≤ 200 字符、标签 ≤ 40 字符。标题 trim 后不得为空。 +- 所有 `/api/web/posts*` 接口走 `AuthUser` Bearer 鉴权,按用户隔离。 +- 线上 JSON 字段 camelCase:`id, title, tag, body, createdAt, updatedAt`。`readingTime`/`excerpt` 不入库,前端实时算。 +- 前端 `API_BASE` 取法与现有一致:`import.meta.env.VITE_API_BASE ?? 'http://localhost:8080'`。 +- 前端无单测框架(项目现状):React 任务用 `npx tsc -b` 类型检查 + 手工浏览器验证;Rust 任务走 TDD(`cargo test`)。 +- 命令工作目录:Rust 在 `Server/`,前端在 `Client/`。 + +--- + +## Task 1: 后端 PostStore(存储层 + 单测) + +**Files:** +- Create: `Server/src/posts.rs` +- Modify: `Server/src/main.rs:7-13`(加 `mod posts;`) + +**Interfaces:** +- Consumes: `crate::photos::sanitize_user(&str) -> Option`(已存在,pub)。 +- Produces: + - `pub struct PostStore`,`PostStore::from_env() -> Self`、`PostStore::new(impl Into) -> Self`(廉价 `Clone`)。 + - `pub fn list(&self, user: &str) -> Vec`(按 `created_at` 倒序) + - `pub fn read(&self, user: &str, id: &str) -> Option` + - `pub fn create(&self, user: &str, draft: PostDraft) -> Result` + - `pub fn update(&self, user: &str, id: &str, draft: PostDraft) -> Result` + - `pub fn delete(&self, user: &str, id: &str) -> bool` + - `pub struct Post { id, title, tag, body, created_at, updated_at }`(Serialize/Deserialize,camelCase) + - `pub struct PostDraft { title, tag, body }` + - `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!()` 骨架),运行确认失败。 + +先写骨架 + 测试: + +```rust +//! 个人博客文章存储:按用户隔离到 BLOG_DIR//,每篇一个 .json。 +//! 仿 photos.rs(廉价 Clone、串行化 计数→写 临界区)。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::photos::sanitize_user; + +pub const MAX_POSTS: usize = 200; +pub const MAX_BODY: usize = 64 * 1024; +pub const MAX_TITLE: usize = 200; +pub const MAX_TAG: usize = 40; + +#[derive(Debug, PartialEq, Eq)] +pub enum PostError { + Full, + TooLarge, + BadInput, + BadUser, + NotFound, + Io, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Post { + pub id: String, + pub title: String, + pub tag: String, + pub body: String, + #[serde(rename = "createdAt")] + pub created_at: u64, + #[serde(rename = "updatedAt")] + pub updated_at: u64, +} + +pub struct PostDraft { + pub title: String, + pub tag: String, + pub body: String, +} + +#[derive(Clone)] +pub struct PostStore { + base: PathBuf, + lock: std::sync::Arc>, +} + +impl PostStore { + pub fn from_env() -> Self { + let base = std::env::var("BLOG_DIR").unwrap_or_else(|_| "blog".to_string()); + Self::new(base) + } + + pub fn new(base: impl Into) -> Self { + Self { + base: base.into(), + lock: std::sync::Arc::new(std::sync::Mutex::new(())), + } + } + + fn user_dir(&self, user: &str) -> Option { + Some(self.base.join(sanitize_user(user)?)) + } + + pub fn list(&self, _user: &str) -> Vec { + todo!() + } + + pub fn read(&self, _user: &str, _id: &str) -> Option { + todo!() + } + + pub fn create(&self, _user: &str, _draft: PostDraft) -> Result { + todo!() + } + + pub fn update(&self, _user: &str, _id: &str, _draft: PostDraft) -> Result { + todo!() + } + + pub fn delete(&self, _user: &str, _id: &str) -> bool { + todo!() + } +} + +/// 文章 id 合法性:非空、全小写 hex、长度 ≤ 32;杜绝路径穿越。 +pub fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 32 + && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TmpDir(PathBuf); + impl TmpDir { + fn new() -> Self { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("blog-test-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn draft(title: &str, body: &str) -> PostDraft { + PostDraft { title: title.into(), tag: "Note".into(), body: body.into() } + } + + #[test] + fn valid_id_guards_hex_and_path() { + assert!(valid_id("abc123")); + assert!(!valid_id("ABC123")); // 非小写 + assert!(!valid_id("../x")); + assert!(!valid_id("a.b")); + assert!(!valid_id("")); + } + + #[test] + fn create_list_read_update_delete_round_trip() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert_eq!(store.list("cc").len(), 0); + + let p = store.create("cc", draft("Hello", "world body")).unwrap(); + assert!(valid_id(&p.id)); + assert_eq!(p.title, "Hello"); + assert_eq!(p.created_at, p.updated_at); + + let listed = store.list("cc"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, p.id); + + let got = store.read("cc", &p.id).unwrap(); + assert_eq!(got.body, "world body"); + + let updated = store + .update("cc", &p.id, draft("Hello2", "new body")) + .unwrap(); + assert_eq!(updated.title, "Hello2"); + assert_eq!(updated.created_at, p.created_at); // 创建时间不变 + assert!(updated.updated_at >= p.created_at); + + assert!(store.delete("cc", &p.id)); + assert_eq!(store.list("cc").len(), 0); + } + + #[test] + fn list_is_newest_first() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + let a = store.create("cc", draft("A", "a")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + let b = store.create("cc", draft("B", "b")).unwrap(); + let listed = store.list("cc"); + assert_eq!(listed[0].id, b.id); + assert_eq!(listed[1].id, a.id); + } + + #[test] + fn rejects_bad_input() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert_eq!(store.create("cc", draft(" ", "body")), Err(PostError::BadInput)); + let long_title: String = "x".repeat(MAX_TITLE + 1); + assert_eq!(store.create("cc", draft(&long_title, "b")), Err(PostError::BadInput)); + let big_body: String = "x".repeat(MAX_BODY + 1); + assert_eq!(store.create("cc", draft("ok", &big_body)), Err(PostError::TooLarge)); + } + + #[test] + fn enforces_max_posts() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + for i in 0..MAX_POSTS { + store.create("cc", draft(&format!("t{i}"), "b")).unwrap(); + } + assert_eq!(store.create("cc", draft("over", "b")), Err(PostError::Full)); + } + + #[test] + fn isolates_users() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + let p = store.create("cc", draft("mine", "b")).unwrap(); + assert!(store.read("dave", &p.id).is_none()); + assert_eq!(store.list("dave").len(), 0); + assert!(!store.delete("dave", &p.id)); + } + + #[test] + fn read_and_update_reject_bad_id() { + let tmp = TmpDir::new(); + let store = PostStore::new(&tmp.0); + assert!(store.read("cc", "../secret").is_none()); + assert_eq!(store.update("cc", "../secret", draft("x", "y")), Err(PostError::NotFound)); + assert!(!store.delete("cc", "../secret")); + } +} +``` + +也在 `Server/src/main.rs` 的 `mod` 区块加一行(紧跟 `mod photos;`): + +```rust +mod posts; +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd Server && cargo test posts::` +Expected: 编译通过但运行时 panic(`todo!()`),多个测试 FAIL。 + +- [ ] **Step 3: 用真实实现替换 `todo!()` 骨架** + +把 `impl PostStore` 里五个方法体与文件末尾(`valid_id` 之后、`#[cfg(test)]` 之前)的私有辅助函数替换/补全为: + +```rust + pub fn list(&self, user: &str) -> Vec { + let Some(dir) = self.user_dir(user) else { + return Vec::new(); + }; + let mut out: Vec = json_files(&dir) + .iter() + .filter_map(|p| read_one(p)) + .collect(); + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + out + } + + pub fn read(&self, user: &str, id: &str) -> Option { + if !valid_id(id) { + return None; + } + let dir = self.user_dir(user)?; + read_one(&dir.join(format!("{id}.json"))) + } + + pub fn create(&self, user: &str, draft: PostDraft) -> Result { + let draft = validate(draft)?; + let dir = self.user_dir(user).ok_or(PostError::BadUser)?; + // 串行化 计数→写:防止并发创建越过 MAX_POSTS。 + let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + if json_files(&dir).len() >= MAX_POSTS { + return Err(PostError::Full); + } + std::fs::create_dir_all(&dir).map_err(|_| PostError::Io)?; + let now = now_secs(); + let id = new_stem(); + let post = Post { + id, + title: draft.title, + tag: draft.tag, + body: draft.body, + created_at: now, + updated_at: now, + }; + write_one(&dir.join(format!("{}.json", post.id)), &post)?; + Ok(post) + } + + pub fn update(&self, user: &str, id: &str, draft: PostDraft) -> Result { + if !valid_id(id) { + return Err(PostError::NotFound); + } + let draft = validate(draft)?; + let dir = self.user_dir(user).ok_or(PostError::BadUser)?; + let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner()); + let path = dir.join(format!("{id}.json")); + let mut post = read_one(&path).ok_or(PostError::NotFound)?; + post.title = draft.title; + post.tag = draft.tag; + post.body = draft.body; + post.updated_at = now_secs(); + write_one(&path, &post)?; + Ok(post) + } + + pub fn delete(&self, user: &str, id: &str) -> bool { + if !valid_id(id) { + return false; + } + match self.user_dir(user) { + Some(dir) => std::fs::remove_file(dir.join(format!("{id}.json"))).is_ok(), + None => false, + } + } +} + +/// 校验并归一化草稿(trim 标题)。 +fn validate(draft: PostDraft) -> Result { + let title = draft.title.trim().to_string(); + if title.is_empty() || title.chars().count() > MAX_TITLE { + return Err(PostError::BadInput); + } + if draft.tag.chars().count() > MAX_TAG { + return Err(PostError::BadInput); + } + if draft.body.len() > MAX_BODY { + return Err(PostError::TooLarge); + } + Ok(PostDraft { title, tag: draft.tag, body: draft.body }) +} + +/// 目录下所有 `*.json` 文件路径(目录不存在 → 空)。 +fn json_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + let p = entry.path(); + if p.extension().and_then(|x| x.to_str()) == Some("json") { + out.push(p); + } + } + } + out +} + +fn read_one(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_one(path: &Path, post: &Post) -> Result<(), PostError> { + let bytes = serde_json::to_vec(post).map_err(|_| PostError::Io)?; + std::fs::write(path, bytes).map_err(|_| PostError::Io) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// 16 位 hex 文件名干(纳秒 + 进程内计数器哈希;读取已鉴权,无需密码学随机)。 +fn new_stem() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes()); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} +``` + +注意:删掉原骨架里 `impl PostStore { ... }` 结尾那个 `}` 与方法体的 `todo!()`,确保 `validate`/`json_files` 等自由函数在 `impl` 块**之外**。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd Server && cargo test posts::` +Expected: PASS(7 个测试全绿)。`enforces_max_posts` 较慢(写 200 文件)可接受。 + +- [ ] **Step 5: Commit** + +```bash +cd Server && git add src/posts.rs src/main.rs +git commit -m "feat(blog): PostStore 文件存储 + 单测(按用户隔离 CRUD) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 2: 后端接口接线(routes + state + CORS) + +**Files:** +- Modify: `Server/src/state.rs:10-51`(加 `posts` 字段与初始化) +- Modify: `Server/src/routes/web.rs`(CORS 加 PUT、新增 posts 路由与 5 个 handler) + +**Interfaces:** +- 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** + +`Server/src/state.rs`,在 `use crate::photos::PhotoStore;` 下加: + +```rust +use crate::posts::PostStore; +``` + +在 `pub struct AppState { ... }` 末尾(`folder_photos` 字段后)加: + +```rust + /// 个人博客文章仓库(按用户隔离的磁盘目录)。 + pub posts: PostStore, +``` + +在 `AppState::new()` 的返回结构体里(`folder_photos: FolderStore::from_env(),` 后)加: + +```rust + posts: PostStore::from_env(), +``` + +- [ ] **Step 2: CORS 放行 PUT** + +`Server/src/routes/web.rs` 第 38 行 `.allow_methods([Method::GET, Method::POST, Method::DELETE])` 改为: + +```rust + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) +``` + +- [ ] **Step 3: 注册 posts 路由** + +`Server/src/routes/web.rs` 的 `router()` 里,在 `let state_routes = ...` 之后、`Router::new()` 链之前加: + +```rust + // 博客文章路由(正文是文本,默认 2MB body 上限足够)。 + let post_routes = Router::new() + .route("/api/web/posts", get(list_posts).post(create_post)) + .route("/api/web/posts/{id}", get(get_post).put(update_post).delete(delete_post)); +``` + +并在 `Router::new()....merge(state_routes)` 之后补一行 `.merge(post_routes)`(在 `.layer(cors)` 之前): + +```rust + .merge(state_routes) + .merge(post_routes) + .layer(cors) +``` + +- [ ] **Step 4: 加 5 个 handler** + +`Server/src/routes/web.rs` 顶部 `use` 区,在 `use crate::photos::{...};` 下加: + +```rust +use crate::posts::{Post, PostDraft, PostError, MAX_POSTS}; +``` + +在文件末尾追加: + +```rust +/* ════════════════════════════════════════════════════════════════ + 博客文章接口(按用户隔离,全部 AuthUser 鉴权) + ════════════════════════════════════════════════════════════════ */ + +#[derive(Serialize)] +struct PostListResponse { + items: Vec, + max: usize, +} + +#[derive(Deserialize)] +struct PostInput { + title: String, + tag: String, + body: String, +} + +/// PostError → HTTP 状态码(与 photos 同构)。 +fn post_status(e: PostError) -> StatusCode { + match e { + PostError::Full => StatusCode::CONFLICT, + PostError::TooLarge => StatusCode::PAYLOAD_TOO_LARGE, + PostError::BadInput | PostError::BadUser => StatusCode::BAD_REQUEST, + PostError::NotFound => StatusCode::NOT_FOUND, + PostError::Io => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +async fn list_posts(AuthUser(user): AuthUser, State(state): State) -> Json { + Json(PostListResponse { + items: state.posts.list(&user), + max: MAX_POSTS, + }) +} + +async fn create_post( + AuthUser(user): AuthUser, + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let draft = PostDraft { title: req.title, tag: req.tag, body: req.body }; + state + .posts + .create(&user, draft) + .map(|p| (StatusCode::CREATED, Json(p))) + .map_err(post_status) +} + +async fn get_post( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> Result, StatusCode> { + state.posts.read(&user, &id).map(Json).ok_or(StatusCode::NOT_FOUND) +} + +async fn update_post( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, + Json(req): Json, +) -> Result { + let draft = PostDraft { title: req.title, tag: req.tag, body: req.body }; + state + .posts + .update(&user, &id, draft) + .map(|_| StatusCode::NO_CONTENT) + .map_err(post_status) +} + +async fn delete_post( + AuthUser(user): AuthUser, + State(state): State, + AxPath(id): AxPath, +) -> StatusCode { + if state.posts.delete(&user, &id) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} +``` + +- [ ] **Step 5: 编译 + 跑全部测试** + +Run: `cd Server && cargo build && cargo test` +Expected: 编译通过、全部测试 PASS(无新测试,但确认接线不破坏已有)。 + +- [ ] **Step 6: Commit** + +```bash +cd Server && git add src/state.rs src/routes/web.rs +git commit -m "feat(blog): 接入 posts 接口(CRUD + CORS PUT) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 3: 前端 api/posts.ts(fetch 封装 + 派生工具) + +**Files:** +- Create: `Client/src/api/posts.ts` + +**Interfaces:** +- Consumes: Task 2 的 HTTP 接口。 +- Produces: + - `interface Post { id, title, tag, body, createdAt, updatedAt }` + - `type PostError = 'full' | 'too-large' | 'bad-input' | 'failed'` + - `listPosts(token) => Promise<{ items: Post[]; max: number }>` + - `getPost(token, id) => Promise` + - `createPost(token, input) => Promise`、`updatePost(token, id, input) => Promise`、`deletePost(token, id) => Promise` + - `readingTime(body: string) => string`、`excerpt(body: string, max?) => string` + - `type PostInput = { title: string; tag: string; body: string }` + +- [ ] **Step 1: 写文件** + +```ts +// 博客文章接口封装。基址与 photos.ts 一致:生产留空走同源相对路径。 +const API_BASE = + (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080' + +export interface Post { + id: string + title: string + tag: string + body: string + createdAt: number + updatedAt: number +} + +export type PostInput = { title: string; tag: string; body: string } + +/** 保存失败的可区分原因,供 UI 给出对应提示 */ +export type PostError = 'full' | 'too-large' | 'bad-input' | 'failed' + +function authHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` } +} + +function saveError(status: number): PostError { + return status === 409 + ? 'full' + : status === 413 + ? 'too-large' + : status === 400 + ? 'bad-input' + : 'failed' +} + +/** 列出当前用户全部文章(已按最新在前排序)与数量上限 */ +export async function listPosts(token: string): Promise<{ items: Post[]; max: number }> { + const res = await fetch(`${API_BASE}/api/web/posts`, { headers: authHeaders(token) }) + if (!res.ok) throw new Error(`list failed: ${res.status}`) + return (await res.json()) as { items: Post[]; max: number } +} + +/** 取单篇文章 */ +export async function getPost(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/web/posts/${id}`, { headers: authHeaders(token) }) + if (!res.ok) throw new Error(`get failed: ${res.status}`) + return (await res.json()) as Post +} + +/** 新建文章,返回服务器分配的完整文章对象 */ +export async function createPost(token: string, input: PostInput): Promise { + const res = await fetch(`${API_BASE}/api/web/posts`, { + method: 'POST', + headers: { ...authHeaders(token), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (res.ok) return (await res.json()) as Post + throw new Error(saveError(res.status)) +} + +/** 更新文章 */ +export async function updatePost(token: string, id: string, input: PostInput): Promise { + const res = await fetch(`${API_BASE}/api/web/posts/${id}`, { + method: 'PUT', + headers: { ...authHeaders(token), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (!res.ok) throw new Error(saveError(res.status)) +} + +/** 删除文章(404 视为已删,不报错) */ +export async function deletePost(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/web/posts/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }) + if (!res.ok && res.status !== 404) throw new Error(`delete failed: ${res.status}`) +} + +/** 估算阅读时长(按 ~200 字/分钟,最少 1 分钟) */ +export function readingTime(body: string): string { + const mins = Math.max(1, Math.round(body.trim().length / 200)) + return `${mins} min` +} + +/** 取正文前若干字做摘要,去掉常见 Markdown 标记 */ +export function excerpt(body: string, max = 80): string { + const plain = body + .replace(/```[\s\S]*?```/g, ' ') // 代码块 + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接/图片 → 文本 + .replace(/[#>*_`~]+/g, ' ') // 标记符号 + .replace(/\s+/g, ' ') + .trim() + return plain.length > max ? `${plain.slice(0, max)}…` : plain +} +``` + +- [ ] **Step 2: 类型检查** + +Run: `cd Client && npx tsc -b` +Expected: 无错误(新文件自洽,未被引用也应通过类型检查)。 + +- [ ] **Step 3: Commit** + +```bash +cd Client && git add src/api/posts.ts +git commit -m "feat(blog): 前端 posts API 封装 + 摘要/时长派生 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 4: Markdown 渲染组件(装 react-markdown) + +**Files:** +- Modify: `Client/package.json`(加 `react-markdown`、`remark-gfm` 依赖) +- Create: `Client/src/components/Markdown.tsx` + +**Interfaces:** +- Produces: `export function Markdown({ children }: { children: string })` —— 把 Markdown 字符串渲染为套了站点暖色编辑风的 React 节点。 + +- [ ] **Step 1: 装依赖** + +Run: `cd Client && npm install react-markdown remark-gfm` +Expected: `package.json` 的 `dependencies` 多出 `react-markdown` 与 `remark-gfm`;`npm install` 成功。 + +- [ ] **Step 2: 写组件** + +```tsx +import ReactMarkdown, { type Components } from 'react-markdown' +import remarkGfm from 'remark-gfm' + +// 把 Markdown 渲染套上站点暖色编辑风。只渲染自有可信内容; +// react-markdown 默认不解析原始 HTML(未引 rehype-raw),天然防注入。 +const components: Components = { + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + p: ({ children }) =>

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) =>
    {children}
    , + code: ({ className, children }) => { + const isBlock = /language-/.test(className ?? '') + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ) + }, + img: ({ src, alt }) => ( + {alt + ), + hr: () =>
    , +} + +export function Markdown({ children }: { children: string }) { + // data-selectable:全站默认禁选,正文区放开以便复制。 + return ( +
    + + {children} + +
    + ) +} +``` + +- [ ] **Step 3: 类型检查** + +Run: `cd Client && npx tsc -b` +Expected: 无错误。若报 `Components` 导出路径不符,改为 `import ReactMarkdown from 'react-markdown'` + `import type { Components } from 'react-markdown'`。 + +- [ ] **Step 4: Commit** + +```bash +cd Client && git add package.json package-lock.json src/components/Markdown.tsx +git commit -m "feat(blog): Markdown 渲染组件(react-markdown + 暖色编辑风) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Task 5: PostEditor(写/改文章) + +**Files:** +- Create: `Client/src/components/PostEditor.tsx` + +**Interfaces:** +- Consumes: `createPost`、`updatePost`、`Post`、`PostError`(Task 3);`Markdown`(Task 4)。 +- Produces: + ```ts + interface PostEditorProps { + token: string + post?: Post // 传入 = 编辑;不传 = 新建 + onSaved: () => void // 保存成功回调(由 BlogPage 决定去向) + onCancel: () => void + } + export function PostEditor(props: PostEditorProps) + ``` + +- [ ] **Step 1: 写组件** + +```tsx +import { useState } from 'react' +import { createPost, updatePost, type Post, type PostError } from '../api/posts' +import { Markdown } from './Markdown' + +interface PostEditorProps { + token: string + post?: Post + onSaved: () => void + onCancel: () => void +} + +const ERROR_TEXT: Record = { + full: '文章数已达上限(200 篇)', + 'too-large': '正文太长了(上限 64KB)', + 'bad-input': '标题不能为空,标签也别太长', + failed: '保存失败,稍后再试', +} + +export function PostEditor({ token, post, onSaved, onCancel }: PostEditorProps) { + const [title, setTitle] = useState(post?.title ?? '') + const [tag, setTag] = useState(post?.tag ?? '') + const [body, setBody] = useState(post?.body ?? '') + const [preview, setPreview] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const save = async () => { + setSaving(true) + setError(null) + try { + const input = { title, tag, body } + if (post) await updatePost(token, post.id, input) + else await createPost(token, input) + onSaved() + } catch (e) { + const reason = (e as Error).message as PostError + setError(ERROR_TEXT[reason] ?? ERROR_TEXT.failed) + setSaving(false) + } + } + + return ( +
    +
    +

    + {post ? 'Edit post' : 'New post'} +

    + +
    + + {preview ? ( +
    +

    + {title || '(无标题)'} +

    + {body || '_正文为空_'} +
    + ) : ( +
    + setTitle(e.target.value)} + placeholder="标题" + className="w-full rounded-lg border border-line bg-surface px-4 py-3 font-display text-2xl text-text outline-none focus:border-accent" + /> + setTag(e.target.value)} + placeholder="标签(如 Rust、随笔)" + className="w-full rounded-lg border border-line bg-surface px-4 py-2 text-sm text-text outline-none focus:border-accent" + /> +