1520 lines
52 KiB
Markdown
1520 lines
52 KiB
Markdown
# 写博客功能 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*` 接口走 `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/Deserialize,camelCase)
|
||
- `pub struct PostDraft { title, tag, body }`
|
||
- `pub enum PostError { Full, TooLarge, BadInput, BadUser, NotFound, Io }`
|
||
- `pub fn valid_id(&str) -> bool`、常量 `MAX_POSTS`、`MAX_BODY`、`MAX_TITLE`、`MAX_TAG`
|
||
|
||
- [ ] **Step 1: 写失败测试** —— 新建 `Server/src/posts.rs`,先放完整模块代码(实现 + 测试一起,TDD 在这一步用"先写测试块、再让它编译通过"的方式;因 Rust 模块需先能编译,按下面顺序:先贴实现骨架使其编译,再贴测试)。为符合 TDD,**先只写测试模块**(实现项暂留 `todo!()` 骨架),运行确认失败。
|
||
|
||
先写骨架 + 测试:
|
||
|
||
```rust
|
||
//! 个人博客文章存储:按用户隔离到 BLOG_DIR/<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: PASS(7 个测试全绿)。`enforces_max_posts` 较慢(写 200 文件)可接受。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd Server && git add src/posts.rs src/main.rs
|
||
git commit -m "feat(blog): PostStore 文件存储 + 单测(按用户隔离 CRUD)
|
||
|
||
Co-Authored-By: Claude Opus 4.8 <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.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?) => 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-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 一致引用;后端 `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 也参与路由、复杂度上升,故不采用。
|
||
```
|