PersonalWebApplication/docs/superpowers/plans/2026-06-25-blog-writing.md
cc edde910cfe docs(blog): 写博客功能文档 + 计划标记完成 + 部署说明
- 新增 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 <noreply@anthropic.com>
2026-06-27 17:17:43 +08:00

52 KiB
Raw Blame History

写博客功能 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.

状态2026-06-27 全部 7 个任务完成。 cargo test 17 passed、前端 tsc -b + npm run build 通过,并已全量重部署到生产 http://8.130.143.54:8548 并跑通端到端 CRUD登录 → 列表 → 增/查/改/删,测试数据已清理)。功能文档见 ../../blog.md与计划的唯一偏差Task 1 给 Post 补了 derive(PartialEq, Eq)——测试里对 Result<Post, _>assert_eq! 需要它,原计划骨架漏了,否则测试无法编译。

Goal: 把博客页从写死的占位数组变成按用户隔离、可写/存/读/改/删的 Markdown 个人博客,文章走 #post/<id> 可分享路由。

Architecture: 后端新增文件存储 PostStore(仿 PhotoStoreblog/<user>/<id>.json+ 5 个 AuthUser 鉴权接口。前端新增 api/posts.tsMarkdownreact-markdownPostEditorPostArticle,并改造 BlogPage 为外壳 + 三态中心内容。App.tsx 识别 #post/<id> 作为列表↔文章的唯一真相源,BlogPage 只管编辑器浮层。

Tech Stack: Rust / axum 0.8 / serde / sqlx已有无关React 19 / TypeScript / Tailwind v4 / framer-motion / 新增 react-markdown + remark-gfm。

Global Constraints

  • 文件存储根目录走 envBLOG_DIR,默认 blog(相对进程 cwd
  • 用户名安全化复用 crate::photos::sanitize_user(仅 [A-Za-z0-9_-],长度 164
  • 上限:MAX_POSTS = 200MAX_BODY = 64 * 1024、标题 ≤ 200 字符、标签 ≤ 40 字符。标题 trim 后不得为空。
  • 所有 /api/web/posts* 接口走 AuthUser Bearer 鉴权,按用户隔离。
  • 线上 JSON 字段 camelCaseid, title, tag, body, createdAt, updatedAtreadingTime/excerpt 不入库,前端实时算。
  • 前端 API_BASE 取法与现有一致:import.meta.env.VITE_API_BASE ?? 'http://localhost:8080'
  • 前端无单测框架项目现状React 任务用 npx tsc -b 类型检查 + 手工浏览器验证Rust 任务走 TDDcargo 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<String>已存在pub

  • Produces:

    • pub struct PostStorePostStore::from_env() -> SelfPostStore::new(impl Into<PathBuf>) -> Self(廉价 Clone)。
    • pub fn list(&self, user: &str) -> Vec<Post>(按 created_at 倒序)
    • pub fn read(&self, user: &str, id: &str) -> Option<Post>
    • pub fn create(&self, user: &str, draft: PostDraft) -> Result<Post, PostError>
    • pub fn update(&self, user: &str, id: &str, draft: PostDraft) -> Result<Post, PostError>
    • pub fn delete(&self, user: &str, id: &str) -> bool
    • pub struct Post { id, title, tag, body, created_at, updated_at }Serialize/DeserializecamelCase
    • pub struct PostDraft { title, tag, body }
    • pub enum PostError { Full, TooLarge, BadInput, BadUser, NotFound, Io }
    • pub fn valid_id(&str) -> bool、常量 MAX_POSTSMAX_BODYMAX_TITLEMAX_TAG
  • Step 1: 写失败测试 —— 新建 Server/src/posts.rs,先放完整模块代码(实现 + 测试一起TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD先只写测试模块(实现项暂留 todo!() 骨架),运行确认失败。

先写骨架 + 测试:

//! 个人博客文章存储:按用户隔离到 BLOG_DIR/<user>/,每篇一个 <hex>.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<std::sync::Mutex<()>>,
}

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<PathBuf>) -> Self {
        Self {
            base: base.into(),
            lock: std::sync::Arc::new(std::sync::Mutex::new(())),
        }
    }

    fn user_dir(&self, user: &str) -> Option<PathBuf> {
        Some(self.base.join(sanitize_user(user)?))
    }

    pub fn list(&self, _user: &str) -> Vec<Post> {
        todo!()
    }

    pub fn read(&self, _user: &str, _id: &str) -> Option<Post> {
        todo!()
    }

    pub fn create(&self, _user: &str, _draft: PostDraft) -> Result<Post, PostError> {
        todo!()
    }

    pub fn update(&self, _user: &str, _id: &str, _draft: PostDraft) -> Result<Post, PostError> {
        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.rsmod 区块加一行(紧跟 mod photos;

mod posts;
  • Step 2: 跑测试确认失败

Run: cd Server && cargo test posts:: Expected: 编译通过但运行时 panictodo!()),多个测试 FAIL。

  • Step 3: 用真实实现替换 todo!() 骨架

impl PostStore 里五个方法体与文件末尾(valid_id 之后、#[cfg(test)] 之前)的私有辅助函数替换/补全为:

    pub fn list(&self, user: &str) -> Vec<Post> {
        let Some(dir) = self.user_dir(user) else {
            return Vec::new();
        };
        let mut out: Vec<Post> = 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<Post> {
        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<Post, PostError> {
        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<Post, PostError> {
        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<PostDraft, PostError> {
    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<PathBuf> {
    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<Post> {
    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: PASS7 个测试全绿)。enforces_max_posts 较慢(写 200 文件)可接受。

  • Step 5: Commit
cd Server && git add src/posts.rs src/main.rs
git commit -m "feat(blog): PostStore 文件存储 + 单测(按用户隔离 CRUD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: 后端接口接线routes + state + CORS

Files:

  • Modify: Server/src/state.rs:10-51(加 posts 字段与初始化)
  • Modify: Server/src/routes/web.rsCORS 加 PUT、新增 posts 路由与 5 个 handler

Interfaces:

  • Consumes: Task 1 的 PostStorePostPostDraftPostErrorMAX_POSTS

  • Produces: HTTP 接口 GET/POST /api/web/postsGET/PUT/DELETE /api/web/posts/{id},响应形如 { items: Post[], max }Post

  • Step 1: 接入 AppState

Server/src/state.rs,在 use crate::photos::PhotoStore; 下加:

use crate::posts::PostStore;

pub struct AppState { ... } 末尾(folder_photos 字段后)加:

    /// 个人博客文章仓库(按用户隔离的磁盘目录)。
    pub posts: PostStore,

AppState::new() 的返回结构体里(folder_photos: FolderStore::from_env(), 后)加:

            posts: PostStore::from_env(),
  • Step 2: CORS 放行 PUT

Server/src/routes/web.rs 第 38 行 .allow_methods([Method::GET, Method::POST, Method::DELETE]) 改为:

        .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
  • Step 3: 注册 posts 路由

Server/src/routes/web.rsrouter() 里,在 let state_routes = ... 之后、Router::new() 链之前加:

    // 博客文章路由(正文是文本,默认 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) 之前):

        .merge(state_routes)
        .merge(post_routes)
        .layer(cors)
  • Step 4: 加 5 个 handler

Server/src/routes/web.rs 顶部 use 区,在 use crate::photos::{...}; 下加:

use crate::posts::{Post, PostDraft, PostError, MAX_POSTS};

在文件末尾追加:

/* ════════════════════════════════════════════════════════════════
   博客文章接口(按用户隔离,全部 AuthUser 鉴权)
   ════════════════════════════════════════════════════════════════ */

#[derive(Serialize)]
struct PostListResponse {
    items: Vec<Post>,
    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<AppState>) -> Json<PostListResponse> {
    Json(PostListResponse {
        items: state.posts.list(&user),
        max: MAX_POSTS,
    })
}

async fn create_post(
    AuthUser(user): AuthUser,
    State(state): State<AppState>,
    Json(req): Json<PostInput>,
) -> Result<(StatusCode, Json<Post>), 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<AppState>,
    AxPath(id): AxPath<String>,
) -> Result<Json<Post>, StatusCode> {
    state.posts.read(&user, &id).map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn update_post(
    AuthUser(user): AuthUser,
    State(state): State<AppState>,
    AxPath(id): AxPath<String>,
    Json(req): Json<PostInput>,
) -> Result<StatusCode, StatusCode> {
    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<AppState>,
    AxPath(id): AxPath<String>,
) -> 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
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 <noreply@anthropic.com>"

Task 3: 前端 api/posts.tsfetch 封装 + 派生工具)

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<Post>
    • createPost(token, input) => Promise<Post>updatePost(token, id, input) => Promise<void>deletePost(token, id) => Promise<void>
    • readingTime(body: string) => stringexcerpt(body: string, max?) => string
    • type PostInput = { title: string; tag: string; body: string }
  • Step 1: 写文件

// 博客文章接口封装。基址与 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<string, string> {
  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<Post> {
  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<Post> {
  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<void> {
  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<void> {
  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
cd Client && git add src/api/posts.ts
git commit -m "feat(blog): 前端 posts API 封装 + 摘要/时长派生

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Markdown 渲染组件(装 react-markdown

Files:

  • Modify: Client/package.json(加 react-markdownremark-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.jsondependencies 多出 react-markdownremark-gfmnpm install 成功。

  • Step 2: 写组件
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'

// 把 Markdown 渲染套上站点暖色编辑风。只渲染自有可信内容;
// react-markdown 默认不解析原始 HTML未引 rehype-raw天然防注入。
const components: Components = {
  h1: ({ children }) => (
    <h1 className="mt-8 mb-4 font-display text-3xl font-medium text-text">{children}</h1>
  ),
  h2: ({ children }) => (
    <h2 className="mt-7 mb-3 font-display text-2xl font-medium text-text">{children}</h2>
  ),
  h3: ({ children }) => (
    <h3 className="mt-6 mb-2 font-display text-xl font-medium text-text">{children}</h3>
  ),
  p: ({ children }) => <p className="my-4 leading-relaxed text-muted">{children}</p>,
  a: ({ href, children }) => (
    <a
      href={href}
      target="_blank"
      rel="noreferrer"
      className="text-accent underline-offset-4 hover:underline"
    >
      {children}
    </a>
  ),
  ul: ({ children }) => (
    <ul className="my-4 list-disc pl-6 text-muted marker:text-accent/60">{children}</ul>
  ),
  ol: ({ children }) => (
    <ol className="my-4 list-decimal pl-6 text-muted marker:text-accent/60">{children}</ol>
  ),
  li: ({ children }) => <li className="my-1 leading-relaxed">{children}</li>,
  blockquote: ({ children }) => (
    <blockquote className="my-4 border-l-2 border-accent pl-4 italic text-muted/90">
      {children}
    </blockquote>
  ),
  pre: ({ children }) => <pre className="my-4">{children}</pre>,
  code: ({ className, children }) => {
    const isBlock = /language-/.test(className ?? '')
    return isBlock ? (
      <code className="block overflow-x-auto rounded-lg border border-line bg-surface px-4 py-3 font-mono text-sm text-text">
        {children}
      </code>
    ) : (
      <code className="rounded bg-surface px-1.5 py-0.5 font-mono text-[0.85em] text-accent-soft">
        {children}
      </code>
    )
  },
  img: ({ src, alt }) => (
    <img src={typeof src === 'string' ? src : ''} alt={alt ?? ''} className="my-4 max-w-full rounded-lg" />
  ),
  hr: () => <hr className="my-8 border-line" />,
}

export function Markdown({ children }: { children: string }) {
  // data-selectable全站默认禁选正文区放开以便复制。
  return (
    <div data-selectable>
      <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
        {children}
      </ReactMarkdown>
    </div>
  )
}
  • Step 3: 类型检查

Run: cd Client && npx tsc -b Expected: 无错误。若报 Components 导出路径不符,改为 import ReactMarkdown from 'react-markdown' + import type { Components } from 'react-markdown'

  • Step 4: Commit
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 <noreply@anthropic.com>"

Task 5: PostEditor写/改文章)

Files:

  • Create: Client/src/components/PostEditor.tsx

Interfaces:

  • Consumes: createPostupdatePostPostPostErrorTask 3MarkdownTask 4

  • Produces:

    interface PostEditorProps {
      token: string
      post?: Post          // 传入 = 编辑;不传 = 新建
      onSaved: () => void   // 保存成功回调(由 BlogPage 决定去向)
      onCancel: () => void
    }
    export function PostEditor(props: PostEditorProps)
    
  • Step 1: 写组件

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<PostError, string> = {
  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<string | null>(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 (
    <section className="w-full px-6 py-14">
      <div className="mb-8 flex items-center justify-between">
        <h2 className="text-xs font-medium uppercase tracking-[0.3em] text-muted">
          {post ? 'Edit post' : 'New post'}
        </h2>
        <button
          onClick={() => setPreview((p) => !p)}
          className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
        >
          {preview ? 'Write' : 'Preview'}
        </button>
      </div>

      {preview ? (
        <article>
          <h1 className="mb-6 font-display text-3xl font-medium text-text">
            {title || '(无标题)'}
          </h1>
          <Markdown>{body || '_正文为空_'}</Markdown>
        </article>
      ) : (
        <div className="flex flex-col gap-4">
          <input
            value={title}
            onChange={(e) => 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"
          />
          <input
            value={tag}
            onChange={(e) => 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"
          />
          <textarea
            value={body}
            onChange={(e) => setBody(e.target.value)}
            placeholder="正文,支持 Markdown…"
            rows={16}
            className="w-full resize-y rounded-lg border border-line bg-surface px-4 py-3 font-mono text-sm leading-relaxed text-text outline-none focus:border-accent"
          />
        </div>
      )}

      {error && <p className="mt-4 text-sm text-[#ff8585]">{error}</p>}

      <div className="mt-8 flex items-center gap-3">
        <button
          onClick={save}
          disabled={saving}
          className="rounded-full bg-accent px-6 py-2 text-sm font-medium text-bg transition-opacity hover:opacity-90 disabled:opacity-50"
        >
          {saving ? '保存中…' : '保存'}
        </button>
        <button
          onClick={onCancel}
          disabled={saving}
          className="rounded-full border border-line px-6 py-2 text-sm font-medium text-muted transition-colors hover:border-accent hover:text-accent disabled:opacity-50"
        >
          取消
        </button>
      </div>
    </section>
  )
}
  • Step 2: 类型检查

Run: cd Client && npx tsc -b Expected: 无错误(组件暂未被引用,类型自洽即可)。

  • Step 3: Commit
cd Client && git add src/components/PostEditor.tsx
git commit -m "feat(blog): PostEditor 写/改文章(含 Markdown 预览)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: PostArticle全文阅读页

Files:

  • Create: Client/src/components/PostArticle.tsx

Interfaces:

  • Consumes: getPostdeletePostreadingTimePostTask 3MarkdownTask 4

  • Produces:

    interface PostArticleProps {
      token: string
      id: string
      onBack: () => void
      onEdit: (post: Post) => void
      onDeleted: () => void
    }
    export function PostArticle(props: PostArticleProps)
    
  • Step 1: 写组件

import { useEffect, useState } from 'react'
import { deletePost, getPost, readingTime, type Post } from '../api/posts'
import { Markdown } from './Markdown'

interface PostArticleProps {
  token: string
  id: string
  onBack: () => void
  onEdit: (post: Post) => void
  onDeleted: () => void
}

function formatDate(secs: number): string {
  return new Date(secs * 1000).toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  })
}

export function PostArticle({ token, id, onBack, onEdit, onDeleted }: PostArticleProps) {
  const [post, setPost] = useState<Post | null>(null)
  const [status, setStatus] = useState<'loading' | 'ok' | 'notfound'>('loading')
  const [confirmDel, setConfirmDel] = useState(false)
  const [deleting, setDeleting] = useState(false)

  useEffect(() => {
    let alive = true
    setStatus('loading')
    getPost(token, id)
      .then((p) => {
        if (alive) {
          setPost(p)
          setStatus('ok')
        }
      })
      .catch(() => {
        if (alive) setStatus('notfound')
      })
    return () => {
      alive = false
    }
  }, [token, id])

  const doDelete = async () => {
    if (!post) return
    setDeleting(true)
    try {
      await deletePost(token, post.id)
      onDeleted()
    } catch {
      setDeleting(false)
      setConfirmDel(false)
    }
  }

  if (status === 'loading') {
    return <section className="w-full px-6 py-14 text-sm text-muted">加载中…</section>
  }

  if (status === 'notfound' || !post) {
    return (
      <section className="w-full px-6 py-14">
        <p className="text-sm text-muted">这篇文章不存在或已被删除。</p>
        <button
          onClick={onBack}
          className="mt-4 rounded-full border border-line px-4 py-1.5 text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
        >
           返回列表
        </button>
      </section>
    )
  }

  return (
    <article className="w-full px-6 py-14">
      <div className="mb-8 flex items-center justify-between">
        <button
          onClick={onBack}
          className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-accent"
        >
           Back
        </button>
        <div className="flex items-center gap-3">
          <button
            onClick={() => onEdit(post)}
            className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-accent"
          >
            编辑
          </button>
          {confirmDel ? (
            <button
              onClick={doDelete}
              disabled={deleting}
              className="text-xs uppercase tracking-[0.16em] text-[#ff8585] disabled:opacity-50"
            >
              {deleting ? '删除中…' : '确认删除'}
            </button>
          ) : (
            <button
              onClick={() => setConfirmDel(true)}
              className="text-xs uppercase tracking-[0.16em] text-muted transition-colors hover:text-[#ff8585]"
            >
              删除
            </button>
          )}
        </div>
      </div>

      <div className="mb-2 font-mono text-xs uppercase tracking-wider text-accent/70">
        {post.tag}
      </div>
      <h1 className="rainbow-clip font-display text-4xl font-medium text-text">{post.title}</h1>
      <div className="mt-3 font-mono text-xs uppercase tracking-wider text-muted">
        {formatDate(post.createdAt)} · {readingTime(post.body)} read
      </div>

      <div className="rainbow-gradient mt-6 mb-8 h-px w-full opacity-60" aria-hidden />

      <Markdown>{post.body}</Markdown>
    </article>
  )
}
  • Step 2: 类型检查

Run: cd Client && npx tsc -b Expected: 无错误。

  • Step 3: Commit
cd Client && git add src/components/PostArticle.tsx
git commit -m "feat(blog): PostArticle 全文阅读页(编辑/删除二次确认)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: BlogPage 改造 + App.tsx 路由集成(端到端)

Files:

  • Modify: Client/src/components/BlogPage.tsx(整体改造)
  • Modify: Client/src/App.tsx(识别 #post/<id>,传 token/initialPostId

Interfaces:

  • Consumes: listPostsreadingTimeexcerptPostTask 3PostEditorTask 5PostArticleTask 6

  • Produces: BlogPage 新签名 { username, token, initialPostId, onSignOut }

  • Step 1: 改 App.tsx —— 识别 post 路由并传参

Client/src/App.tsx 改动如下。

const [wantsFolder, setWantsFolder] = ... 之后加 postId 状态:

  // 文章路由:#post/<id> → 进 BlogPage 并首屏打开该文章
  const [postId, setPostId] = useState<string | null>(() => {
    const h = window.location.hash.replace(/^#/, '')
    return h.startsWith('post/') ? h.slice('post/'.length) : null
  })

resolving 初始化(第 25-28 行)改为也排除 post 路由:

  const [resolving, setResolving] = useState(() => {
    const h = window.location.hash.replace(/^#/, '')
    return !!h && h !== PHOTO_HASH && !h.startsWith('post/')
  })

useEffectcheck 里,把开头同步段改为同时解析 post

    const check = async () => {
      const hash = window.location.hash.replace(/^#/, '')
      const photo = hash === PHOTO_HASH
      const post = hash.startsWith('post/') ? hash.slice('post/'.length) : null
      if (alive) {
        setWantsPhoto(photo)
        setWantsFolder(null)
        setPostId(post)
        setShowLogin(false)
        // #photo / #post/<id> / 无 hash 都能同步定向,不需要异步解析
        setResolving(!!hash && !photo && !post)
      }
      if (photo || post || !hash) return

(其余 folder/gate 逻辑不变。)

在已登录分支里,把渲染 BlogPage 的那行(原第 107 行)改为传 token 与 initialPostId

    } else {
      view = (
        <BlogPage
          username={auth.username}
          token={auth.token ?? ''}
          initialPostId={postId}
          onSignOut={goStart}
        />
      )
    }

把未登录复用登录页的条件(原第 109 行)加上 postId,让 #post/<id> 未登录时也走登录:

  } else if (wantsPhoto || wantsFolder || postId) {
  • Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容

整体替换 Client/src/components/BlogPage.tsx 为下面内容(保留原 Hero / 三栏 / 页脚视觉,删除写死的 POSTS,列表改读真实数据;PostArticle/PostEditor 渲染进中间玻璃纸):

import { useCallback, useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { useSystemStats } from '../hooks/useSystemStats'
import { fadeUp, stagger } from './motion'
import { AnnouncementBar } from './AnnouncementBar'
import { RainbowSpotlight } from './RainbowSpotlight'
import { PostArticle } from './PostArticle'
import { PostEditor } from './PostEditor'
import { listPosts, readingTime, excerpt, type Post } from '../api/posts'

interface BlogPageProps {
  /** 当前登录用户名 */
  username: string
  /** 鉴权 token调 posts 接口用) */
  token: string
  /** 进入时若带 #post/<id>,首屏直接打开该文章 */
  initialPostId: string | null
  /** 退出登录 */
  onSignOut: () => void
}

// 两侧图片区:沿用原 import.meta.glob 索引 src/assets/sides/。
const sideImages = Object.entries(
  import.meta.glob<string>('../assets/sides/*.{jpg,jpeg,png,webp}', {
    eager: true,
    query: '?url',
    import: 'default',
  }),
)
  .sort(([pathA], [pathB]) => pathA.localeCompare(pathB))
  .map(([, url]) => url)

const SIDE_IMAGE_LEFT = sideImages[0]
const SIDE_IMAGE_RIGHT = sideImages[1] ?? sideImages[0]

function formatDate(secs: number): string {
  return new Date(secs * 1000).toLocaleDateString('zh-CN', { year: 'numeric', month: 'short' })
}

export function BlogPage({ username, token, initialPostId, onSignOut }: BlogPageProps) {
  const stats = useSystemStats()

  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)
  // 编辑器浮层editing=true 时无视路由优先显示编辑器editingPost=null 为新建。
  const [editing, setEditing] = useState(false)
  const [editingPost, setEditingPost] = useState<Post | null>(null)
  // 文章重挂载计数:保存编辑后 bump作为 PostArticle 的 key 强制重拉
  // (否则 id 不变时其 useEffect 不会重新 fetch显示旧内容  const [articleNonce, setArticleNonce] = useState(0)

  const refresh = useCallback(() => {
    setLoading(true)
    listPosts(token)
      .then((r) => setPosts(r.items))
      .catch(() => setPosts([]))
      .finally(() => setLoading(false))
  }, [token])

  useEffect(() => {
    refresh()
  }, [refresh])

  // 列表↔文章由地址栏驱动App 是唯一真相源):点文章只改 hash。
  const openPost = (id: string) => {
    window.location.hash = `post/${id}`
  }
  const backToList = () => {
    setEditing(false)
    if (window.location.hash) window.location.hash = ''
  }
  const startNew = () => {
    setEditingPost(null)
    setEditing(true)
  }
  const startEdit = (post: Post) => {
    setEditingPost(post)
    setEditing(true)
  }
  const onEditorSaved = () => {
    // 编辑既有文章hash 仍是 #post/<id>关掉编辑器即回到该文章bump
    // articleNonce 让 PostArticle 重挂载、重拉到最新内容。
    // 新建hash 为空,关掉后回列表。两种都刷新列表数据。
    setEditing(false)
    setArticleNonce((n) => n + 1)
    refresh()
  }

  const showHero = !editing && !initialPostId

  let center
  if (editing) {
    center = (
      <PostEditor
        token={token}
        post={editingPost ?? undefined}
        onSaved={onEditorSaved}
        onCancel={() => setEditing(false)}
      />
    )
  } else if (initialPostId) {
    center = (
      <PostArticle
        key={`${initialPostId}-${articleNonce}`}
        token={token}
        id={initialPostId}
        onBack={backToList}
        onEdit={startEdit}
        onDeleted={() => {
          refresh()
          backToList()
        }}
      />
    )
  } else {
    center = (
      <section className="w-full px-6 py-14">
        <div className="mb-10 flex items-center justify-between">
          <h2 className="text-xs font-medium uppercase tracking-[0.3em] text-muted">
            Latest writing
          </h2>
          <button
            onClick={startNew}
            className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
          >
            写文章
          </button>
        </div>

        {loading ? (
          <p className="text-sm text-muted">加载中…</p>
        ) : posts.length === 0 ? (
          <p className="text-sm text-muted">还没有文章,点右上角「写文章」开始第一篇吧。</p>
        ) : (
          <motion.ul
            variants={stagger(0.1, 0.08)}
            initial="hidden"
            animate="show"
            className="flex flex-col"
          >
            {posts.map((post) => (
              <motion.li key={post.id} variants={fadeUp}>
                <button
                  onClick={() => openPost(post.id)}
                  className="group flex w-full flex-col gap-3 border-b border-line/50 py-7 text-left transition-colors sm:flex-row sm:items-baseline sm:gap-8"
                >
                  <span
                    aria-hidden
                    className="rainbow-gradient hidden w-1 self-stretch rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:block"
                  />
                  <div className="w-28 shrink-0 font-mono text-xs uppercase tracking-wider text-muted">
                    <div>{formatDate(post.createdAt)}</div>
                    {post.tag && <div className="mt-1 text-accent/70">{post.tag}</div>}
                  </div>
                  <div className="flex-1">
                    <h3 className="rainbow-clip font-display text-xl font-medium text-text transition-colors duration-200 group-hover:text-transparent">
                      {post.title}
                    </h3>
                    <p className="mt-2 text-sm leading-relaxed text-muted">{excerpt(post.body)}</p>
                    <span className="mt-2 inline-block text-xs text-muted/70">
                      {readingTime(post.body)} read
                    </span>
                  </div>
                </button>
              </motion.li>
            ))}
          </motion.ul>
        )}
      </section>
    )
  }

  return (
    <div className="relative flex min-h-svh flex-col">
      <div className="aurora" aria-hidden />

      <AnnouncementBar text={stats.text} online={stats.online} />

      <header className="glass-sheet relative z-20 w-full">
        <div className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-5">
          <span className="rainbow-text font-display text-lg font-semibold tracking-tight">
            cc.dev
          </span>
          <div className="flex items-center gap-4 text-sm text-muted">
            <span className="hidden sm:inline">
              Signed in as <span className="text-text">{username}</span>
            </span>
            <button
              onClick={onSignOut}
              className="rounded-full border border-line px-4 py-1.5 text-xs font-medium uppercase tracking-[0.16em] text-muted transition-colors hover:border-accent hover:text-accent"
            >
              Sign out
            </button>
          </div>
        </div>
        <div className="rainbow-gradient h-1 w-full" aria-hidden />
      </header>

      {showHero && (
        <section className="glass-sheet relative z-10 w-full overflow-hidden py-20 sm:py-28">
          <RainbowSpotlight />
          <motion.div
            variants={stagger(0.15, 0.12)}
            initial="hidden"
            animate="show"
            className="relative z-10 mx-auto flex w-full max-w-3xl flex-col gap-6 px-6"
          >
            <motion.span
              variants={fadeUp}
              className="text-xs font-medium uppercase tracking-[0.3em] text-accent"
            >
              Personal blog
            </motion.span>
            <motion.h1
              variants={fadeUp}
              className="font-display text-5xl font-medium leading-[1.05] text-text sm:text-6xl lg:text-7xl"
            >
              Hello, I'm <span className="rainbow-text italic">{username}</span>
              <span className="cursor-bar ml-1 inline-block not-italic text-accent">_</span>
            </motion.h1>
            <motion.p variants={fadeUp} className="max-w-xl text-base leading-relaxed text-muted">
              我在这里写关于前端动效、Rust 后端和把事情做简单的笔记。把鼠标移到这片区域,
              会有一道彩虹跟着你——灵感来自{' '}
              <a
                href="https://www.joshwcomeau.com/"
                target="_blank"
                rel="noreferrer"
                className="text-accent underline-offset-4 hover:underline"
              >
                Josh W. Comeau
              </a>
            </motion.p>
          </motion.div>
        </section>
      )}

      <div className="relative flex flex-1 justify-center">
        <aside
          className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
          style={{ backgroundImage: `url(${SIDE_IMAGE_LEFT})`, backgroundSize: 'auto 100%' }}
          aria-hidden
        />

        <div className="glass-sheet relative flex w-full max-w-3xl flex-col">
          <main className="flex-1">{center}</main>

          <footer className="mt-auto">
            <div className="rainbow-gradient h-1 w-full" />
            <div className="flex w-full items-center justify-between px-6 py-8 text-xs text-muted">
              <span>© {new Date().getFullYear()} cc</span>
              <span className="font-mono tracking-wider">built with React · Rust · 🌈</span>
            </div>
          </footer>
        </div>

        <aside
          className="relative hidden flex-1 overflow-hidden bg-center bg-no-repeat lg:block"
          style={{ backgroundImage: `url(${SIDE_IMAGE_RIGHT})`, backgroundSize: 'auto 100%' }}
          aria-hidden
        />
      </div>
    </div>
  )
}
  • Step 3: 类型检查 + 构建

Run: cd Client && npx tsc -b && npm run build Expected: 类型检查无错误、vite build 成功。

  • Step 4: 端到端手工验证

启动后端(需 DATABASE_URLcd Server && cargo run(建议设 BLOG_DIR=./blog-dev)。 启动前端:cd Client && npm run dev,浏览器进登录入口、登录后到博客页。逐项确认:

  1. 空态:新账号显示「还没有文章…」。
  2. 写:点「写文章」→ 填标题/标签/正文(含 # 标题**粗**、列表、代码块)→ 点 Preview 看渲染 → 保存 → 回列表看到新文章卡(日期/标签/摘要/时长)。
  3. 读:点文章卡 → 地址栏变 #post/<id>、显示全文Markdown 正确渲染)。
  4. 刷新:在文章页刷新浏览器 → 仍停在该文章(验证 #post/<id> 路由)。
  5. 改:文章页点「编辑」→ 改内容 → 保存 → 回到该文章看到更新。
  6. 删:文章页点「删除」→「确认删除」→ 回列表、该文章消失。
  7. 后退:从文章页点浏览器后退 → 回到列表。
  8. 隔离:换另一账号登录 → 看不到上一个账号的文章。
  • Step 5: Commit
cd Client && git add src/App.tsx src/components/BlogPage.tsx
git commit -m "feat(blog): BlogPage 接真实数据 + #post/<id> 路由集成

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-Review 记录

  • Spec coverage:存储/上限/接口/鉴权Task 1-2api 封装 + 派生Task 3MarkdownTask 4编辑器Task 5阅读页Task 6列表改造 + #post/<id> 路由 + 边界态Task 7。spec 各节均有对应任务。
  • 路由真相源:列表↔文章统一由 App.tsx 经 hash 决定(initialPostIdBlogPage 仅管编辑器浮层,避免双方争用 URL。编辑器不进 URL不可分享符合 spec
  • 类型一致Post/PostInput/PostError 在 Task 3 定义Task 5/6/7 一致引用;后端 Post camelCase 与前端字段对齐。
  • 就地编辑刷新PostArticle 的 fetch effect 依赖 [token, id]id 不变时不会重拉;故编辑保存后 bump articleNonce 并用作 key 强制重挂载保证文章页显示最新内容Task 7
  • Markdown code 边界:以 language- 类名区分行内/块级,未指定语言的 围栏块 会被当作行内样式——个人博客可接受(写 ```lang 即可),不为此引 node 判定增复杂度。
  • 已知取舍:返回列表用 window.location.hash = '' 会留下一个光秃秃的 #cosmetic换页/刷新无副作用;如介意可在 backToList 改用 history.pushState + 手动置空 initialPostId,但那会让 BlogPage 也参与路由、复杂度上升,故不采用。