51 KiB
写博客功能 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/<id> 可分享路由。
Architecture: 后端新增文件存储 PostStore(仿 PhotoStore,blog/<user>/<id>.json)+ 5 个 AuthUser 鉴权接口。前端新增 api/posts.ts、Markdown(react-markdown)、PostEditor、PostArticle,并改造 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
- 文件存储根目录走 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*接口走AuthUserBearer 鉴权,按用户隔离。 - 线上 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<String>(已存在,pub)。 -
Produces:
pub struct PostStore,PostStore::from_env() -> Self、PostStore::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) -> boolpub 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!()骨架),运行确认失败。
先写骨架 + 测试:
//! 个人博客文章存储:按用户隔离到 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.rs 的 mod 区块加一行(紧跟 mod photos;):
mod posts;
- Step 2: 跑测试确认失败
Run: cd Server && cargo test posts::
Expected: 编译通过但运行时 panic(todo!()),多个测试 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: PASS(7 个测试全绿)。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.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; 下加:
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.rs 的 router() 里,在 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.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<Post>createPost(token, input) => Promise<Post>、updatePost(token, id, input) => Promise<void>、deletePost(token, id) => Promise<void>readingTime(body: string) => string、excerpt(body: string, max?) => stringtype 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-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: 写组件
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:
createPost、updatePost、Post、PostError(Task 3);Markdown(Task 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:
getPost、deletePost、readingTime、Post(Task 3);Markdown(Task 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:
listPosts、readingTime、excerpt、Post(Task 3);PostEditor(Task 5);PostArticle(Task 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/')
})
在 useEffect 的 check 里,把开头同步段改为同时解析 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)
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>,关掉编辑器即回到该文章(会重拉)。
// 新建:hash 为空,关掉后回列表。两种都刷新列表数据。
setEditing(false)
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
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_URL):cd Server && cargo run(建议设 BLOG_DIR=./blog-dev)。
启动前端:cd Client && npm run dev,浏览器进登录入口、登录后到博客页。逐项确认:
- 空态:新账号显示「还没有文章…」。
- 写:点「写文章」→ 填标题/标签/正文(含
# 标题、**粗**、列表、代码块)→ 点 Preview 看渲染 → 保存 → 回列表看到新文章卡(日期/标签/摘要/时长)。 - 读:点文章卡 → 地址栏变
#post/<id>、显示全文(Markdown 正确渲染)。 - 刷新:在文章页刷新浏览器 → 仍停在该文章(验证
#post/<id>路由)。 - 改:文章页点「编辑」→ 改内容 → 保存 → 回到该文章看到更新。
- 删:文章页点「删除」→「确认删除」→ 回列表、该文章消失。
- 后退:从文章页点浏览器后退 → 回到列表。
- 隔离:换另一账号登录 → 看不到上一个账号的文章。
- 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-2);api 封装 + 派生(Task 3);Markdown(Task 4);编辑器(Task 5);阅读页(Task 6);列表改造 +
#post/<id>路由 + 边界态(Task 7)。spec 各节均有对应任务。 - 路由真相源:列表↔文章统一由
App.tsx经 hash 决定(initialPostId),BlogPage仅管编辑器浮层,避免双方争用 URL。编辑器不进 URL(不可分享,符合 spec)。 - 类型一致:
Post/PostInput/PostError在 Task 3 定义,Task 5/6/7 一致引用;后端PostcamelCase 与前端字段对齐。 - 已知取舍:返回列表用
window.location.hash = ''会留下一个光秃秃的#(cosmetic),换页/刷新无副作用;如介意可在backToList改用history.pushState+ 手动置空initialPostId,但那会让 BlogPage 也参与路由、复杂度上升,故不采用。