# 写博客功能 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" />