PersonalWebApplication/docs/superpowers/plans/2026-06-25-blog-writing.md
2026-06-25 22:54:23 +08:00

1520 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 写博客功能 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_-]`,长度 164
- 上限:`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<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) -> 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_POSTS`、`MAX_BODY`、`MAX_TITLE`、`MAX_TAG`
- [ ] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。
先写骨架 + 测试:
```rust
//! 个人博客文章存储:按用户隔离到 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;`
```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<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**
```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 <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;` 下加:
```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<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**
```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 <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) => 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<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**
```bash
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: 写组件**
```tsx
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**
```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 <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:
```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<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**
```bash
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:
```ts
interface PostArticleProps {
token: string
id: string
onBack: () => void
onEdit: (post: Post) => void
onDeleted: () => void
}
export function PostArticle(props: PostArticleProps)
```
- [ ] **Step 1: 写组件**
```tsx
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**
```bash
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 状态:
```tsx
// 文章路由:#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 路由:
```tsx
const [resolving, setResolving] = useState(() => {
const h = window.location.hash.replace(/^#/, '')
return !!h && h !== PHOTO_HASH && !h.startsWith('post/')
})
```
`useEffect``check` 里,把开头同步段改为同时解析 post
```tsx
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
```tsx
} else {
view = (
<BlogPage
username={auth.username}
token={auth.token ?? ''}
initialPostId={postId}
onSignOut={goStart}
/>
)
}
```
把未登录复用登录页的条件(原第 109 行)加上 `postId`,让 `#post/<id>` 未登录时也走登录:
```tsx
} else if (wantsPhoto || wantsFolder || postId) {
```
- [ ] **Step 2: 改 BlogPage.tsx —— 外壳 + 三态中心内容**
整体替换 `Client/src/components/BlogPage.tsx` 为下面内容(保留原 Hero / 三栏 / 页脚视觉,删除写死的 `POSTS`,列表改读真实数据;`PostArticle`/`PostEditor` 渲染进中间玻璃纸):
```tsx
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_URL``cd 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**
```bash
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 决定(`initialPostId``BlogPage` 仅管编辑器浮层,避免双方争用 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 也参与路由、复杂度上升,故不采用。
```