Compare commits
3 Commits
7d2a1f0ea5
...
c2016a8ea5
| Author | SHA1 | Date | |
|---|---|---|---|
| c2016a8ea5 | |||
| d08a23cced | |||
| 677856851b |
1519
docs/superpowers/plans/2026-06-25-blog-writing.md
Normal file
1519
docs/superpowers/plans/2026-06-25-blog-writing.md
Normal file
File diff suppressed because it is too large
Load Diff
140
docs/superpowers/specs/2026-06-25-blog-writing-design.md
Normal file
140
docs/superpowers/specs/2026-06-25-blog-writing-design.md
Normal file
@ -0,0 +1,140 @@
|
||||
# 写博客功能 设计方案
|
||||
|
||||
日期:2026-06-25
|
||||
状态:已与作者确认,待评审
|
||||
|
||||
## 目标
|
||||
|
||||
把博客页从写死的占位 `POSTS` 数组,变成能真正**写、存、读、改、删**的个人博客。
|
||||
|
||||
确认的产品决策:
|
||||
|
||||
- **作者范围**:每个登录用户有自己的博客,按用户隔离(像照片一样只看得到/改得了自己的文章)。
|
||||
- **正文格式**:Markdown,阅读时渲染成排版。
|
||||
- **阅读体验**:点列表里一篇 → 进独立全文阅读页。
|
||||
- **路由**:`#post/<id>` 真路由,文章 URL 可分享、可刷新。
|
||||
|
||||
## 非目标(YAGNI)
|
||||
|
||||
- 不做评论、点赞、标签筛选、全文搜索。
|
||||
- 不做草稿/发布状态机(保存即"已发布",对自己可见)。
|
||||
- 不做图片上传进正文(Markdown 里可引用外链图,但不新增上传管线)。
|
||||
- 不引 `@tailwindcss/typography`,手工套设计风格。
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
前端 (React) 后端 (axum)
|
||||
───────────── ───────────
|
||||
BlogPage ── api/posts.ts ──HTTP──> /api/web/posts ── PostStore ──> blog/<user>/<id>.json
|
||||
├ 列表 (AuthUser 鉴权)
|
||||
├ PostArticle (#post/<id>)
|
||||
├ PostEditor (内部 state)
|
||||
└ Markdown (react-markdown)
|
||||
```
|
||||
|
||||
## 后端
|
||||
|
||||
### PostStore(新增 `Server/src/posts.rs`)
|
||||
|
||||
沿用 `PhotoStore` 套路(按用户隔离的磁盘目录、廉价 `Clone`、串行化临界区):
|
||||
|
||||
- 根目录取 env `BLOG_DIR`,默认 `blog/`(相对进程 cwd)。
|
||||
- 用户目录 `blog/<user>/`,复用 `crate::photos::sanitize_user` 防路径穿越(仅 `[A-Za-z0-9_-]`,长度 1–64)。
|
||||
- 每篇文章一个文件 `<hex_id>.json`,id 用和照片同样的 16 位 hex 文件名干(`new_stem` 思路;读取已鉴权,无需密码学随机)。
|
||||
- 文件内容(持久化结构):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "...",
|
||||
"tag": "...",
|
||||
"body": "...(Markdown 原文)",
|
||||
"createdAt": 1719300000,
|
||||
"updatedAt": 1719300000
|
||||
}
|
||||
```
|
||||
|
||||
- `list(user)`:读目录下全部合法 `<hex>.json`,解析后按 `createdAt` **倒序**返回。
|
||||
- `read(user, id)` / `create(user, draft)` / `update(user, id, draft)` / `delete(user, id)`。
|
||||
- `id` 合法性:`<hex>.json`,仅小写 hex 干,杜绝路径穿越(仿 `valid_id`)。
|
||||
|
||||
### 上限与校验
|
||||
|
||||
| 项 | 上限 | 超限错误 |
|
||||
|---|---|---|
|
||||
| 单用户文章数 | `MAX_POSTS = 200` | 409 Conflict |
|
||||
| 正文字节 | `MAX_BODY = 64 * 1024` | 413 Payload Too Large |
|
||||
| 标题字符 | 200 | 400 Bad Request |
|
||||
| 标签字符 | 40 | 400 Bad Request |
|
||||
|
||||
标题去除首尾空白后不得为空(400)。`SaveError` 枚举映射 HTTP 状态码,和 photos.rs 同构。
|
||||
|
||||
### 接口(`Server/src/routes/web.rs`,全部 `AuthUser` 鉴权)
|
||||
|
||||
新增子路由 `post_routes`,body 上限用默认 2MB(正文是文本,足够):
|
||||
|
||||
| 方法 | 路径 | 请求体 | 成功响应 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/web/posts` | — | `{ items: Post[], max }` |
|
||||
| POST | `/api/web/posts` | `{title,tag,body}` | 201 `{id}` |
|
||||
| GET | `/api/web/posts/{id}` | — | `Post` |
|
||||
| PUT | `/api/web/posts/{id}` | `{title,tag,body}` | 204 |
|
||||
| DELETE | `/api/web/posts/{id}` | — | 204 |
|
||||
|
||||
`Post`(线上 JSON,camelCase):`{ id, title, tag, body, createdAt, updatedAt }`。
|
||||
|
||||
`readingTime` 与 `excerpt` **不入库、不走接口**,由前端按正文实时算(阅读时长 ≈ 字数/200 分钟;摘要取正文前 ~80 字、去掉 Markdown 标记)。
|
||||
|
||||
### state.rs
|
||||
|
||||
`AppState` 加一个字段 `pub posts: PostStore`,`AppState::new()` 里 `PostStore::from_env()`。
|
||||
|
||||
## 前端
|
||||
|
||||
### api/posts.ts(新增)
|
||||
|
||||
仿 `api/photos.ts`:`listPosts / getPost / createPost / updatePost / deletePost`,统一带 `Authorization: Bearer <token>`,`API_BASE` 取法一致。`Post` 类型与后端对齐。可区分错误(`full / too-large / bad-input / failed`)供 UI 提示。
|
||||
|
||||
### Markdown.tsx(新增)
|
||||
|
||||
`react-markdown` + `remark-gfm` 封装。给一组自定义组件,套上暖色编辑风:
|
||||
|
||||
- `h1/h2/h3` → Fraunces(`font-display`)、`text-text`,逐级缩小。
|
||||
- `p` → `text-muted` 行距宽松;`a` → `text-accent` 下划线偏移。
|
||||
- `code`(行内)→ 暖色背景小药丸;`pre code`(块)→ `surface` 底、`line` 描边、等宽。
|
||||
- `blockquote` → 左侧 accent 竖条;`ul/ol/li`、`img`(圆角、最大宽度)。
|
||||
- 仅渲染受信任的自有内容;`react-markdown` 默认不渲染原始 HTML,保持默认即可(防注入)。
|
||||
|
||||
### 路由(改 `App.tsx`)
|
||||
|
||||
在现有 hash 解析器里识别 `post/<id>` 前缀:
|
||||
|
||||
- 当 `hash` 以 `post/` 开头 → 当作博客视图(登录后可见),把 `<id>` 作为 `initialPostId` 传给 `BlogPage`;**不**走 folder/gate 检查。
|
||||
- 未登录访问 `#post/<id>` → 复用登录页(像 `#photo`),登录后落到该文章。
|
||||
- 空 hash + 已登录 → `BlogPage` 列表(不变)。
|
||||
|
||||
`BlogPage` 负责把"列表 ↔ 阅读"反映到地址栏(列表抹掉 hash,阅读 `history` 切到 `#post/<id>`),并监听 `hashchange` 与浏览器后退保持同步。编辑态用组件内 state,不进 URL。
|
||||
|
||||
### 组件拆分(职责单一、可独立理解/测试)
|
||||
|
||||
- `BlogPage.tsx`(改):编排 + 列表。挂载时 `listPosts`;渲染真实文章卡(替换写死的 `POSTS`,保留现有卡片视觉:日期/标签、彩虹标题、摘要、阅读时长)。顶部 Hero 旁加"写文章"入口(每人都是自己博客作者,常驻)。`initialPostId` 存在则首屏直接进阅读页。视图 state:`list | reading | editing`。
|
||||
- `PostArticle.tsx`(新增):全文阅读页。`getPost` 取单篇 → `Markdown` 渲染;含返回、编辑、删除(删除二次确认)。
|
||||
- `PostEditor.tsx`(新增):标题输入 + 标签输入 + 正文 `textarea` + 实时预览(复用 `Markdown`)+ 保存/取消。新建与编辑共用(有 id 即编辑)。
|
||||
- `Markdown.tsx`(新增):见上。
|
||||
|
||||
### 边界情况
|
||||
|
||||
- 空列表:友好引导态("还没有文章,写第一篇吧")。
|
||||
- 加载中 / 取单篇 404(落回列表并提示)。
|
||||
- 保存失败:按状态码区分提示(满 / 太长 / 输入非法 / 失败),沿用 photos 的错误区分风格。
|
||||
- 删除:二次确认。
|
||||
|
||||
## 测试
|
||||
|
||||
- 后端 `posts.rs` 单测,仿 `photos.rs`:`sanitize`/`valid_id` 守卫、create→list→read→update→delete 往返、`MAX_POSTS`/`MAX_BODY`/空标题的拒绝、跨用户隔离(读不到别人的)。
|
||||
- 前端以手工验证为主(列表/写/读/改/删/刷新回到文章/未登录深链)。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
新增:`Server/src/posts.rs`、`Client/src/api/posts.ts`、`Client/src/components/PostArticle.tsx`、`Client/src/components/PostEditor.tsx`、`Client/src/components/Markdown.tsx`。
|
||||
修改:`Server/src/main.rs`(mod posts)、`Server/src/state.rs`、`Server/src/routes/web.rs`、`Client/src/App.tsx`、`Client/src/components/BlogPage.tsx`、`Client/package.json`(react-markdown、remark-gfm)。
|
||||
Loading…
Reference in New Issue
Block a user