chore: remove unused docs/superpowers directory
This commit is contained in:
parent
b309f2dbc1
commit
2776d39ac1
File diff suppressed because it is too large
Load Diff
@ -1,760 +0,0 @@
|
||||
# 文章图片 Blur-up 渐进加载 实现计划
|
||||
|
||||
> **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:** 文章页面图片加载时显示低分辨率模糊占位图,高清缩略图加载完后平滑淡入,消除 CLS。
|
||||
|
||||
**Architecture:** SSR 渲染双层 DOM(底层 `?w=20` 模糊占位 + 上层高清 `data-src`),服务端读图片 header 拿真实尺寸生成 aspect-ratio(moka sync cache),前端 JS 用 IntersectionObserver 懒加载高清图并淡入。
|
||||
|
||||
**Tech Stack:** Rust(image crate + zenwebp + moka sync cache + regex)、Dioxus 组件、原生 JS(IntersectionObserver)、CSS
|
||||
|
||||
**前置 spec:** `docs/superpowers/specs/2026-06-22-blur-up-images-design.md`
|
||||
|
||||
**关键约定(贯穿所有任务):**
|
||||
- 仅处理 `/uploads/` 路径的图片;外链图保持原生 img
|
||||
- dimensions 读取是 server-only(`#[cfg(feature = "server")]`),用 `moka::sync::Cache`
|
||||
- 正则匹配 img 标签由 pulldown-cmark 产出,格式可控(src 在前、alt 在后)
|
||||
- 涉及 `#[cfg(target_arch = "wasm32")]` 或 `#[cfg(feature = "server")]` 的代码改动,验证必须用 `dx check`(wasm32 target)+ `cargo test`(默认 target),不能只靠 `cargo check`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 责任 | 操作 |
|
||||
|------|------|------|
|
||||
| `src/api/image.rs` | `IMAGE_DIMENSIONS_CACHE`(moka sync)+ `get_image_dimensions` + `read_dimensions_from_bytes` | 改(新增) |
|
||||
| `src/api/sanitizer.rs` | `clean_html` 扩展 img 放行 `data-src/class/style`、span 放行 `style` | 改 |
|
||||
| `src/api/markdown.rs` | `render_markdown_enhanced` 增加 `wrap_images_with_blur` 后处理 | 改 |
|
||||
| `src/components/image_viewer.rs` | 渲染双层结构(placeholder + full) | 改 |
|
||||
| `public/js/post-content.js` | 删除改 src;新增 IntersectionObserver 懒加载 + onload 淡入 | 改 |
|
||||
| `input.css` | 新增 `.blur-img*` 样式(含暗色) | 改 |
|
||||
| `.env.example` | 新增 `IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400` | 改 |
|
||||
|
||||
任务顺序:1(dimensions cache)→ 2(sanitizer)→ 3(markdown 包装)→ 4(CSS)→ 5(ImageViewer)→ 6(post-content.js)→ 7(.env)→ 8(验证)。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Dimensions 缓存与读取(image.rs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api/image.rs`(顶部 import 区 + 文件末尾新增)
|
||||
|
||||
服务端读图片真实尺寸(只读 header),moka sync cache 缓存。这是后续任务的基础。
|
||||
|
||||
- [ ] **Step 1: 添加 moka::sync::Cache import**
|
||||
|
||||
在 `src/api/image.rs` 的 `#[cfg(feature = "server")] use moka::future::Cache;`(约第 17 行)之后添加:
|
||||
|
||||
```rust
|
||||
#[cfg(feature = "server")]
|
||||
use moka::sync::Cache as SyncCache;
|
||||
```
|
||||
|
||||
注意:保留原有 `moka::future::Cache`(图片处理管线还在用),新增 `SyncCache` 别名给 dimensions 用。
|
||||
|
||||
- [ ] **Step 2: 在 image.rs 末尾添加 dimensions 缓存与读取函数**
|
||||
|
||||
在 `src/api/image.rs` 文件末尾(最后一个 `}` 之后,或在 `#[cfg(test)]` 模块之前)添加:
|
||||
|
||||
```rust
|
||||
/// 图片尺寸缓存(moka sync)。key = 相对路径如 "2026/06/22/x.webp"。
|
||||
/// 用 sync cache 而非 future cache:render_markdown_enhanced 是同步函数,不能 .await。
|
||||
#[cfg(feature = "server")]
|
||||
static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
|
||||
let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs)
|
||||
.unwrap_or(std::time::Duration::from_secs(86400)); // 默认 24h
|
||||
SyncCache::builder().time_to_live(ttl).build()
|
||||
});
|
||||
|
||||
/// 读取图片真实尺寸(只读 header,不解码像素)。
|
||||
///
|
||||
/// - `rel_path`:相对路径如 "2026/06/22/x.webp"(不含 /uploads/ 前缀和 query)
|
||||
/// - 优先查缓存;miss 时读文件、解析 header、写入缓存
|
||||
/// - 失败返回 None(调用方回退到不设 aspect-ratio)
|
||||
#[cfg(feature = "server")]
|
||||
pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
|
||||
if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
|
||||
return Some(dims);
|
||||
}
|
||||
let full_path = std::path::Path::new("uploads").join(rel_path);
|
||||
let data = std::fs::read(&full_path).ok()?;
|
||||
let dims = read_dimensions_from_bytes(&data, rel_path)?;
|
||||
IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
|
||||
Some(dims)
|
||||
}
|
||||
|
||||
/// 按扩展名分发:webp 走 zenwebp header,gif/png/jpeg 走 image crate。
|
||||
#[cfg(feature = "server")]
|
||||
fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()?
|
||||
.to_str()?
|
||||
.to_lowercase();
|
||||
match ext.as_str() {
|
||||
"webp" => {
|
||||
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素
|
||||
let decoder = zenwebp::WebPDecoder::build(data).ok()?;
|
||||
let info = decoder.info();
|
||||
Some((info.width, info.height))
|
||||
}
|
||||
"gif" | "png" | "jpg" | "jpeg" => {
|
||||
// image crate 的 into_dimensions 只读 header
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(data))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
reader.into_dimensions().ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加单元测试**
|
||||
|
||||
在 `src/api/image.rs` 的 `#[cfg(test)]` 模块内(或新建测试块)添加:
|
||||
|
||||
```rust
|
||||
#[cfg(all(test, feature = "server"))]
|
||||
mod dimensions_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_webp_dimensions_from_bytes() {
|
||||
// 构造一个 16x9 的 webp
|
||||
let img = image::DynamicImage::new_rgb8(16, 9);
|
||||
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
||||
let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
|
||||
assert_eq!(dims, Some((16, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_png_dimensions_from_bytes() {
|
||||
let img = image::DynamicImage::new_rgb8(32, 24);
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||
let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
|
||||
assert_eq!(dims, Some((32, 24)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dimensions_unknown_extension_returns_none() {
|
||||
let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
|
||||
assert_eq!(dims, None);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试验证**
|
||||
|
||||
Run: `cargo test dimensions_tests 2>&1 | tail -10`
|
||||
Expected: 3 个测试通过。若 webp 测试失败,检查 `crate::webp::encode` 签名是否匹配(参数:img, quality, method)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/image.rs
|
||||
git commit -m "feat(image): add image dimensions cache with header-only reading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: sanitizer 扩展(sanitizer.rs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api/sanitizer.rs:333-361`(`clean_html` 函数)
|
||||
|
||||
文章正文 sanitizer 放行双层结构需要的属性。评论配置不动。
|
||||
|
||||
- [ ] **Step 1: 扩展 clean_html 的 extra_tag_attrs**
|
||||
|
||||
在 `src/api/sanitizer.rs` 的 `clean_html` 函数(约第 333 行)的 `extra_tag_attrs` vec 中:
|
||||
|
||||
把:
|
||||
```rust
|
||||
extra_tag_attrs: vec![
|
||||
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||
("span", vec!["class"]),
|
||||
("h1", vec!["id", "class"]),
|
||||
("h2", vec!["id", "class"]),
|
||||
("h3", vec!["id", "class"]),
|
||||
("h4", vec!["id", "class"]),
|
||||
("h5", vec!["id", "class"]),
|
||||
("h6", vec!["id", "class"]),
|
||||
],
|
||||
```
|
||||
|
||||
改为(img 行是新增,span 加 style):
|
||||
```rust
|
||||
extra_tag_attrs: vec![
|
||||
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||
("img", vec!["data-src", "class", "style"]),
|
||||
("span", vec!["class", "style"]),
|
||||
("h1", vec!["id", "class"]),
|
||||
("h2", vec!["id", "class"]),
|
||||
("h3", vec!["id", "class"]),
|
||||
("h4", vec!["id", "class"]),
|
||||
("h5", vec!["id", "class"]),
|
||||
("h6", vec!["id", "class"]),
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加测试验证新属性放行**
|
||||
|
||||
在 `src/api/sanitizer.rs` 的测试模块内添加:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn clean_html_allows_blur_img_attributes() {
|
||||
let input = r#"<span class="blur-img" style="--ar:16/9"><img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="t"><img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="t"></span>"#;
|
||||
let result = clean_html(input);
|
||||
assert!(result.contains("data-src"), "data-src should be allowed");
|
||||
assert!(result.contains("blur-img-placeholder"), "class should be allowed");
|
||||
assert!(result.contains("--ar"), "style should be allowed");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试验证**
|
||||
|
||||
Run: `cargo test clean_html_allows_blur 2>&1 | tail -5`
|
||||
Expected: 测试通过,data-src/class/style 都保留。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/sanitizer.rs
|
||||
git commit -m "feat(sanitizer): allow data-src/class/style on img for blur-up"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Markdown 渲染器 img 包装(markdown.rs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/api/markdown.rs`(`render_markdown_enhanced` 第 196-199 行 + 新增函数)
|
||||
|
||||
正文图转双层 wrapper。在 `push_html` 产出 HTML 后、`clean_html` 之前插入后处理。
|
||||
|
||||
- [ ] **Step 1: 在 render_markdown_enhanced 的返回前插入包装调用**
|
||||
|
||||
在 `src/api/markdown.rs` 的 `render_markdown_enhanced` 函数末尾(约第 196-199 行):
|
||||
|
||||
把:
|
||||
```rust
|
||||
RenderedContent {
|
||||
html: clean_html(&html),
|
||||
toc_html,
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
let html = wrap_images_with_blur(&html);
|
||||
RenderedContent {
|
||||
html: clean_html(&html),
|
||||
toc_html,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 wrap_images_with_blur 函数**
|
||||
|
||||
在 `src/api/markdown.rs` 的 `render_markdown_enhanced` 函数之后添加:
|
||||
|
||||
```rust
|
||||
/// 把 HTML 里的 /uploads/ 图片转成 blur-up 双层结构。
|
||||
///
|
||||
/// 仅处理 src 以 /uploads/ 开头的 img;外链图保持原样。
|
||||
/// 对每个匹配的 img:
|
||||
/// 1. 提取 src,解析出 rel_path(去 /uploads/ 前缀和 query)
|
||||
/// 2. 查 get_image_dimensions 拿真实宽高,算 --ar(如 "16/9")
|
||||
/// 3. 生成 <span class="blur-img" style="--ar:.."> 包裹两层 img
|
||||
#[cfg(feature = "server")]
|
||||
fn wrap_images_with_blur(html: &str) -> String {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// 匹配 pulldown-cmark 产出的 <img src="..." alt="..." /> 或 <img src="..." alt="...">
|
||||
// pulldown-cmark 格式可控:src 在前,alt 在后,属性用双引号
|
||||
static IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"<img\s+src="(/uploads/[^"]+)"(?:\s+alt="([^"]*)")?\s*/?>"#).unwrap()
|
||||
});
|
||||
|
||||
IMG_RE.replace_all(html, |caps: ®ex::Captures| {
|
||||
let src = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
// 从 src 解析 rel_path:去 /uploads/ 前缀 + 去 query
|
||||
let rel_path = src
|
||||
.strip_prefix("/uploads/")
|
||||
.unwrap_or(src)
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
// 查 dimensions,算 aspect-ratio
|
||||
let ar_style = crate::api::image::get_image_dimensions(rel_path)
|
||||
.map(|(w, h)| format!(" style=\"--ar:{}:{};\"", w, h))
|
||||
.unwrap_or_default();
|
||||
|
||||
// alt 转义(src/alt 来自 markdown,可能含特殊字符,但 pulldown-cmark 已转义过,这里直接用)
|
||||
let alt_attr = if alt.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" alt=\"{}\"", alt)
|
||||
};
|
||||
|
||||
format!(
|
||||
"<span class=\"blur-img\"{ar}><img class=\"blur-img-placeholder\" src=\"{src}?w=20\"{alt_attr}><img class=\"blur-img-full\" data-src=\"{src}?w=800\"{alt_attr}></span>",
|
||||
ar = ar_style,
|
||||
src = src,
|
||||
alt_attr = alt_attr,
|
||||
)
|
||||
}).to_string()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 添加测试**
|
||||
|
||||
在 `src/api/markdown.rs` 测试模块内添加:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn wrap_images_with_blur_wraps_uploads_image() {
|
||||
// 注意:此测试依赖 uploads/ 目录下存在对应文件才能拿到 dimensions。
|
||||
// 用一个不含 dimensions 的路径验证 --ar 缺省时的结构正确性。
|
||||
let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
|
||||
let result = wrap_images_with_blur(html);
|
||||
assert!(result.contains("blur-img-placeholder"), "should have placeholder");
|
||||
assert!(result.contains("blur-img-full"), "should have full layer");
|
||||
assert!(result.contains("?w=20"), "placeholder should use ?w=20");
|
||||
assert!(result.contains("?w=800"), "full should use ?w=800");
|
||||
assert!(result.contains("data-src"), "full should use data-src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_images_with_blur_skips_external_image() {
|
||||
let html = r#"<img src="https://example.com/img.png" alt="ext">"#;
|
||||
let result = wrap_images_with_blur(html);
|
||||
// 外链图不处理,保持原样
|
||||
assert!(!result.contains("blur-img"), "external image should not be wrapped");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试验证**
|
||||
|
||||
Run: `cargo test wrap_images_with_blur 2>&1 | tail -10`
|
||||
Expected: 2 个测试通过。外链图保持原样,uploads 图被包装。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api/markdown.rs
|
||||
git commit -m "feat(markdown): wrap uploads images with blur-up double-layer structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Blur-up CSS 样式(input.css)
|
||||
|
||||
**Files:**
|
||||
- Modify: `input.css`(追加样式)
|
||||
|
||||
双层结构的视觉:占位图模糊放大、高清图淡入、aspect-ratio 预留空间。
|
||||
|
||||
- [ ] **Step 1: 确认 input.css 位置和暗色模式约定**
|
||||
|
||||
Run: `head -20 input.css`
|
||||
确认 CSS 入口和暗色模式前缀(项目用 `.dark` 还是 `@media (prefers-color-scheme)`)。
|
||||
|
||||
- [ ] **Step 2: 在 input.css 末尾追加样式**
|
||||
|
||||
在 `input.css` 末尾追加:
|
||||
|
||||
```css
|
||||
/* ========== Blur-up 渐进图片加载 ========== */
|
||||
.blur-img {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: var(--ar);
|
||||
/* 加载中灰底(占位图未加载完时) */
|
||||
background: var(--color-paper-code-bg, #f5f5f5);
|
||||
border-radius: 6px;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.blur-img-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 20px 占位图放大后模糊,scale 遮边缘 */
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.blur-img-full {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
.blur-img-full.is-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 暗色模式灰底 */
|
||||
.dark .blur-img {
|
||||
background: var(--color-paper-code-bg, #2a2a2a);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 构建验证 CSS 进 bundle**
|
||||
|
||||
Run: `make css 2>&1 | tail -3`
|
||||
Expected: tailwindcss 编译成功,`public/style.css` 包含 `.blur-img`。
|
||||
|
||||
Run: `grep -c "blur-img-placeholder" public/style.css`
|
||||
Expected: 至少 1(样式进 bundle)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add input.css
|
||||
git commit -m "style: add blur-up progressive image loading styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: ImageViewer 双层改造(image_viewer.rs)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/image_viewer.rs`
|
||||
|
||||
卡片封面/详情封面通过 ImageViewer 渲染。改为双层结构。
|
||||
|
||||
- [ ] **Step 1: 添加 placeholder_params prop + dimensions 获取**
|
||||
|
||||
在 `src/components/image_viewer.rs` 的 `ImageViewer` 组件签名(约第 23-28 行):
|
||||
|
||||
把:
|
||||
```rust
|
||||
#[component]
|
||||
pub fn ImageViewer(
|
||||
src: String,
|
||||
#[props(default = "?w=800".to_string())] thumb_params: String,
|
||||
#[props(default = "图片".to_string())] alt: String,
|
||||
#[props(default = false)] lazy_load: bool,
|
||||
) -> Element {
|
||||
```
|
||||
|
||||
改为:
|
||||
```rust
|
||||
#[component]
|
||||
pub fn ImageViewer(
|
||||
src: String,
|
||||
#[props(default = "?w=800".to_string())] thumb_params: String,
|
||||
#[props(default = "?w=20".to_string())] placeholder_params: String,
|
||||
#[props(default = "图片".to_string())] alt: String,
|
||||
#[props(default = false)] lazy_load: bool,
|
||||
) -> Element {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 计算 aspect-ratio(SSR 时读 dimensions)**
|
||||
|
||||
在组件内(`let mut is_open = use_signal(|| false);` 之后)添加:
|
||||
|
||||
```rust
|
||||
// 计算 aspect-ratio:SSR 时读图片真实尺寸。WASM 端不读(--ar 已在 SSR 写入 HTML)。
|
||||
// 非 /uploads/ 的外链图或读不到尺寸时不设 --ar。
|
||||
let ar_style = {
|
||||
let mut s = String::new();
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if let Some(rel) = src.strip_prefix("/uploads/").map(|p| p.split('?').next().unwrap_or(p)) {
|
||||
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
|
||||
s = format!("--ar:{}:{};", w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 改造 rsx 渲染双层结构**
|
||||
|
||||
把当前的缩略图 img 块(约第 80-87 行):
|
||||
|
||||
```rust
|
||||
// 缩略图
|
||||
img {
|
||||
class: "cursor-pointer transition-opacity hover:opacity-90",
|
||||
src: "{thumb_src}",
|
||||
alt: "{alt}",
|
||||
loading: if lazy_load { "lazy" } else { "eager" },
|
||||
onclick: move |_| is_open.set(true),
|
||||
}
|
||||
```
|
||||
|
||||
改为双层结构。注意:原来用 `thumb_src`(拼接了 thumb_params),现在底层用 placeholder、上层用 full。需要计算两个 URL:
|
||||
|
||||
在 `ar_style` 之后、`rsx!` 之前添加 URL 计算:
|
||||
|
||||
```rust
|
||||
// 拼接占位图 URL 和高清图 URL
|
||||
let placeholder_src = if src.contains('?') {
|
||||
format!("{}&{}", src.split('?').next().unwrap_or(&src), placeholder_params.trim_start_matches('?'))
|
||||
} else {
|
||||
format!("{}{}", src, placeholder_params)
|
||||
};
|
||||
let full_src = if src.contains('?') {
|
||||
format!("{}&{}", src.split('?').next().unwrap_or(&src), thumb_params.trim_start_matches('?'))
|
||||
} else {
|
||||
format!("{}{}", src, thumb_params)
|
||||
};
|
||||
```
|
||||
|
||||
然后 rsx 的缩略图块改为:
|
||||
|
||||
```rust
|
||||
// blur-up 双层:底层占位图 + 上层高清图(data-src 由 post-content.js/前端懒加载)
|
||||
span {
|
||||
class: "blur-img",
|
||||
style: "{ar_style}",
|
||||
onclick: move |_| is_open.set(true),
|
||||
img {
|
||||
class: "blur-img-placeholder",
|
||||
src: "{placeholder_src}",
|
||||
alt: "{alt}",
|
||||
loading: if lazy_load { "lazy" } else { "eager" },
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full",
|
||||
"data-src": "{full_src}",
|
||||
alt: "{alt}",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
删除原来的 `let thumb_src = ...`(约第 68-77 行),它被 `placeholder_src`/`full_src` 取代。
|
||||
|
||||
- [ ] **Step 4: 灯箱用高清图 URL(full_src)**
|
||||
|
||||
灯箱的 img(约第 100-105 行)原来用 `src: "{src}"`。保持用原始 `src`(原图,无缩略图参数)作为放大图——这是点击放大看的完整图。不改灯箱。
|
||||
|
||||
- [ ] **Step 5: 类型检查**
|
||||
|
||||
Run: `dx check 2>&1 | tail -5`
|
||||
Expected: No issues found。若有 `#[cfg(feature="server")]` 在 wasm32 端的编译问题,确认 `get_image_dimensions` 调用在 cfg gate 内(WASM 端 ar_style 为空字符串,双层结构仍渲染但无 --ar)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/image_viewer.rs
|
||||
git commit -m "feat(image-viewer): render blur-up double-layer structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: post-content.js 懒加载与淡入
|
||||
|
||||
**Files:**
|
||||
- Modify: `public/js/post-content.js:50-110`(initImageZoom 函数)
|
||||
|
||||
当前 initImageZoom 把 img.src 改成 `?w=800`。新职责:懒加载高清图 + onload 淡入 + 灯箱改用 data-src。
|
||||
|
||||
- [ ] **Step 1: 重写 initImageZoom 为 blur-up 初始化**
|
||||
|
||||
把 `public/js/post-content.js` 的 `initImageZoom` 函数(约第 50-110 行)整体替换为:
|
||||
|
||||
```javascript
|
||||
function initImageZoom(root) {
|
||||
var containers = root.querySelectorAll(".blur-img");
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
var container = containers[i];
|
||||
if (container.getAttribute("data-blur-init")) continue;
|
||||
container.setAttribute("data-blur-init", "true");
|
||||
|
||||
var fullImg = container.querySelector(".blur-img-full");
|
||||
if (!fullImg) continue;
|
||||
var fullSrc = fullImg.getAttribute("data-src");
|
||||
if (!fullSrc) continue;
|
||||
|
||||
// 加载高清图:onload 后加 is-loaded 触发 CSS opacity 淡入
|
||||
fullImg.addEventListener("load", function () {
|
||||
this.classList.add("is-loaded");
|
||||
});
|
||||
|
||||
// 懒加载:进入视口才设 src
|
||||
if ("IntersectionObserver" in window) {
|
||||
var io = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
fullImg.src = fullSrc;
|
||||
io.unobserve(container);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
io.observe(container);
|
||||
} else {
|
||||
// 不支持 IO:直接加载
|
||||
fullImg.src = fullSrc;
|
||||
}
|
||||
|
||||
// 灯箱:点击用高清图 URL 放大
|
||||
(function (src, altText) {
|
||||
container.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var overlay = document.createElement("div");
|
||||
overlay.className = "md-image-lightbox-overlay";
|
||||
|
||||
var containerEl = document.createElement("div");
|
||||
containerEl.className = "md-image-lightbox-content";
|
||||
|
||||
var bigImg = document.createElement("img");
|
||||
bigImg.src = src;
|
||||
bigImg.alt = altText;
|
||||
|
||||
var closeBtn = document.createElement("button");
|
||||
closeBtn.className = "md-image-lightbox-close";
|
||||
closeBtn.textContent = "\u2715";
|
||||
|
||||
containerEl.appendChild(bigImg);
|
||||
containerEl.appendChild(closeBtn);
|
||||
overlay.appendChild(containerEl);
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
var onKey = function (ev) {
|
||||
if (ev.key === "Escape") {
|
||||
cleanup(overlay, onKey);
|
||||
}
|
||||
};
|
||||
var cleanup = function (ol, kh) {
|
||||
closeLightbox(ol);
|
||||
document.removeEventListener("keydown", kh);
|
||||
};
|
||||
overlay.addEventListener("click", function () {
|
||||
cleanup(overlay, onKey);
|
||||
});
|
||||
containerEl.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
closeBtn.addEventListener("click", function () {
|
||||
cleanup(overlay, onKey);
|
||||
});
|
||||
document.addEventListener("keydown", onKey);
|
||||
});
|
||||
})(fullSrc, fullImg.getAttribute("alt") || "");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 JS 语法**
|
||||
|
||||
Run: `node -c public/js/post-content.js 2>&1`
|
||||
Expected: 无语法错误(`-c` 是 node 的语法检查)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add public/js/post-content.js
|
||||
git commit -m "feat(post-content): lazy-load hi-res images with blur-up fade-in"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: .env.example 配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env.example`(末尾追加)
|
||||
|
||||
- [ ] **Step 1: 在 .env.example 末尾追加配置**
|
||||
|
||||
在 `.env.example` 末尾(`IMAGE_DISK_CACHE_MAX_AGE_HOURS=168` 之后)追加:
|
||||
|
||||
```
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 图片尺寸缓存(blur-up 占位图的 aspect-ratio 来源)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 图片尺寸缓存的 TTL,单位秒(默认 86400,即 24 小时)。
|
||||
# 服务端读取图片 header 拿真实宽高,用于生成 aspect-ratio 避免布局跳动。
|
||||
# 图片尺寸永不变,理论可设很长,但缓存重启会清空。
|
||||
IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "docs(env): document IMAGE_DIMENSIONS_CACHE_TTL_SECS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 全量验证
|
||||
|
||||
**Files:** 无新改动,仅验证。
|
||||
|
||||
- [ ] **Step 1: cargo test(默认 target,含 server feature)**
|
||||
|
||||
Run: `cargo test 2>&1 | tail -5`
|
||||
Expected: 所有测试通过(含 Task 1/2/3 新增的测试)。
|
||||
|
||||
- [ ] **Step 2: cargo clippy**
|
||||
|
||||
Run: `cargo clippy --all-targets 2>&1 | tail -5`
|
||||
Expected: 无本次引入的新警告。
|
||||
|
||||
- [ ] **Step 3: dx check(wasm32 target,验证 ImageViewer 改造)**
|
||||
|
||||
Run: `dx check 2>&1 | tail -3`
|
||||
Expected: No issues found。
|
||||
|
||||
- [ ] **Step 4: CSS 构建**
|
||||
|
||||
Run: `make css 2>&1 | tail -3`
|
||||
Expected: 编译成功,`public/style.css` 含 `.blur-img`。
|
||||
|
||||
- [ ] **Step 5: JS 语法检查**
|
||||
|
||||
Run: `node -c public/js/post-content.js`
|
||||
Expected: 无错误。
|
||||
|
||||
- [ ] **Step 6: 手动验证清单(需 dev server)**
|
||||
|
||||
启动 `make dev`,浏览器硬刷新,逐项验证:
|
||||
|
||||
- [ ] 文章正文图片加载时先显示模糊的占位图,无布局跳动(有 aspect-ratio 预留空间)
|
||||
- [ ] 高清缩略图加载完后,opacity 平滑淡入覆盖占位图(约 0.4s 过渡)
|
||||
- [ ] 滚动到视口外图片时才加载高清图(IntersectionObserver 懒加载)
|
||||
- [ ] 卡片封面同样有 blur-up 效果
|
||||
- [ ] 详情封面同样有 blur-up 效果
|
||||
- [ ] 外链图(非 /uploads/)保持原生 img,无 blur-up
|
||||
- [ ] 点击图片放大(灯箱)功能正常
|
||||
- [ ] 禁用 JS 时(浏览器禁用 JS),正文图显示模糊占位图(可读但模糊)
|
||||
|
||||
- [ ] **Step 7: 最终 commit(如有验证中发现的小修复)**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix(blur-up): address verification findings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 所有 Task 的 Step checkbox 打勾
|
||||
- `cargo test` / `cargo clippy` / `dx check` / `make css` / `node -c` 全部通过
|
||||
- 手动验证清单全部通过
|
||||
- spec(`docs/superpowers/specs/2026-06-22-blur-up-images-design.md`)的 12 条验收标准全部满足
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,392 +0,0 @@
|
||||
# Dioxus 反模式清零 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:** 清零项目中残留的 Dioxus 0.7 antipatterns 官方指南点名的三类反模式——render body 副作用、用 `use_signal`/内联计算存派生值、god component。使代码库与已新建的 `.agents/skills/dioxus-render-purity/SKILL.md` 完全一致。
|
||||
|
||||
**Architecture:** 三个独立任务,互不依赖,可单独提交、单独回滚:
|
||||
- **Task 1**(小):`trash.rs` 的 `dirty` 派生布尔值改为 `use_memo`,消除「render 期内联重算派生值」。
|
||||
- **Task 2**(中):`post_detail.rs` 的 render 期 `set signal` 反模式——**已在 commit `225bb24` 修复**,本任务为补一个回归测试锁定行为。
|
||||
- **Task 3**(大):`write_editor`(`src/pages/admin/write.rs`,746 行 god component)抽取「封面上传」子组件 `CoverUploader`,把 5 个 cover 相关 signal + 上传闭包 + 封面 rsx 整体迁出,让 `write_editor` 减重约 150 行。
|
||||
|
||||
god component 的**全量拆分**(`TrashPage` 等)超出本计划范围——它风险高、收益是可维护性而非正确性,建议作为独立后续计划;本计划只做其中收益最明确、边界最清晰的一块(封面上传),验证「抽取子组件」的模式可行后再决定是否扩展。
|
||||
|
||||
**Tech Stack:** Rust, Dioxus 0.7(`use_memo` / `use_signal` / `#[component]` / `rsx!`),wasm-bindgen(`web_sys::File`),cargo + dx CLI。
|
||||
|
||||
---
|
||||
|
||||
## 前置知识:本项目 Dioxus 约定(执行者必读)
|
||||
|
||||
1. **双 target 编译**:`dx check` 同时校验 server(`aarch64-apple-darwin`)与 wasm client(`wasm32-unknown-unknown`)。任何改动都要过 `dx check`,不能只过 `cargo build`。
|
||||
2. **wasm 专属代码**用 `#[cfg(target_arch = "wasm32")]` 包裹(如 `web_sys::File`、`EditorHandle`、`Closure`)。`#[cfg(not(target_arch = "wasm32"))]` 分支必须给出降级返回,否则 server 端编译报「未初始化」。
|
||||
3. **`write_editor` 不是 `#[component]`**:它是普通函数,由 `Write` / `WriteEdit` 两个 `#[component]` 薄包装委托。抽取子组件后,新组件必须是 `#[component]` 才能在 `rsx!` 里用 `<CoverUploader ... />`。
|
||||
4. **`#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]`**:这是项目惯例,避免 server 构建因 wasm-only 的 `mut` signal 报 unused 警告。新组件若含 wasm-only signal 要沿用此属性。
|
||||
5. **测试**:`cargo test`(405 个 server 端单测)。前端组件无单测框架,靠 `dx check` 类型校验 + 手动 `make dev` 验证。
|
||||
6. **提交风格**:`type(scope): 中文描述`,如 `refactor(write): 抽取封面上传子组件`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/pages/admin/trash.rs` | Modify(~112 行) | `dirty` 改 `use_memo` |
|
||||
| `src/models/settings.rs` | Read only | 确认 `TrashSettings` 字段名(`retention_days`/`auto_purge_enabled`) |
|
||||
| `src/pages/post_detail.rs` | Read only | 已修复;Task 2 仅加测试 |
|
||||
| `tests/post_detail_slug_rerun.rs` | Create | 锁定 slug 变化重跑的回归测试 |
|
||||
| `src/pages/admin/write.rs` | Modify(大改) | 抽取 `CoverUploader` 子组件,`write_editor` 减重 |
|
||||
| `.agents/skills/dioxus-render-purity/SKILL.md` | Read only | 参照规则(已存在) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `trash.rs` 的 `dirty` 改用 `use_memo`
|
||||
|
||||
**问题**:`src/pages/admin/trash.rs:112-118` 在 render body 内联计算 `dirty` 布尔值,每次渲染都重新 `trim().parse::<i32>()`。语义上它是 `settings_draft_*` / `settings` 的派生值,文档要求派生值用 `use_memo`(依赖不变则不重算)。
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/admin/trash.rs:111-118`
|
||||
|
||||
**当前代码(`src/pages/admin/trash.rs:111-118`):**
|
||||
|
||||
```rust
|
||||
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存”提示。
|
||||
let dirty = settings_draft_enabled() != settings().auto_purge_enabled
|
||||
|| settings_draft_days()
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.map(|d| d != settings().retention_days)
|
||||
.unwrap_or(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 把 `dirty` 改为 `use_memo`**
|
||||
|
||||
用以下内容替换 `src/pages/admin/trash.rs:111-118`(保持注释语义):
|
||||
|
||||
```rust
|
||||
// 草稿相对已保存配置是否存在差异:控制保存按钮可用性与“未保存」提示。
|
||||
// 派生值用 use_memo:依赖信号不变时不重算(避免每次渲染重复 parse 字符串)。
|
||||
let dirty = use_memo(move || {
|
||||
settings_draft_enabled() != settings().auto_purge_enabled
|
||||
|| settings_draft_days()
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.map(|d| d != settings().retention_days)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- `use_memo` 已在 prelude(`use dioxus::prelude::*`,`trash.rs` 顶部已导入,无需额外 use)。
|
||||
- `dirty` 的读取方式不变:原本 `dirty` 是 `bool`,现在 `dirty` 是 `Memo<bool>`,但在 `rsx!` 里 `{dirty()}` / `disabled: !dirty()` 这种用法——**注意**:`Memo<T>` 实现了 `Deref<Target = T>`,但在条件判断里仍需 `*dirty()` 或 `dirty()()`。实测:`if dirty() {` 在 Dioxus 里 `dirty()` 返回 `bool`(`Memo::read` 返回 `&T`,但 `if` 会自动解引用),保持原写法 `dirty()` 即可。若 `dx check` 报类型错,改为 `*dirty.read()`。
|
||||
- 闭包用 `move ||` 捕获三个 signal 的读取。
|
||||
|
||||
- [ ] **Step 2: 用 `dx check` 验证类型**
|
||||
|
||||
运行:`dx check`
|
||||
预期:`No issues found.`
|
||||
|
||||
若报 `expected bool, found Memo<bool>` 类错误,定位 `dirty` 的所有使用点(`grep -n "dirty" src/pages/admin/trash.rs`),把 `dirty()` 改为 `dirty()`(多数情况下无需改,`Signal`/`Memo` 在 `if`/`disabled:` 上下文自动解引用)。仍报错则改 `*dirty()`。
|
||||
|
||||
- [ ] **Step 3: 跑测试**
|
||||
|
||||
运行:`cargo test`
|
||||
预期:`test result: ok. 405 passed; 0 failed`
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/pages/admin/trash.rs
|
||||
git commit -m "refactor(trash): dirty 派生值改用 use_memo
|
||||
|
||||
依据 dioxus-render-purity skill 规则二:派生值不应在 render 期内联重算。
|
||||
dirty 是 settings_draft_* 与 settings 的纯派生布尔值,改用 use_memo,
|
||||
依赖信号不变时跳过 trim/parse 重算。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 为已修复的 `post_detail` slug 反模式补回归测试
|
||||
|
||||
**背景**:`src/pages/post_detail.rs` 原本用 `use_signal` 镜像 `slug` prop 并在 render 期 `set`(反模式),已在 commit `225bb24` 改为直接读 prop。本任务补一个回归测试,锁定「`PostDetail` 组件不依赖镜像 signal」的契约,防止后续回退。
|
||||
|
||||
**注意**:Dioxus 组件在 `cargo test`(非 wasm、纯逻辑)下无法直接渲染。可行的回归方式是写一个**静态契约测试**:读取 `post_detail.rs` 源码字符串,断言它不再包含反模式签名(`slug_signal` / `use_signal(|| slug`)。这是项目已有模式(见 `src/theme.rs` 的 `theme_preload_script_*` 系列字符串断言测试)。
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/post_detail_slug_rerun.rs`
|
||||
|
||||
- [ ] **Step 1: 写失败的契约测试**
|
||||
|
||||
创建 `tests/post_detail_slug_rerun.rs`:
|
||||
|
||||
```rust
|
||||
//! 回归测试:锁定 PostDetail 组件不再用镜像 signal 触发 server future。
|
||||
//!
|
||||
//! 背景:src/pages/post_detail.rs 曾用 `use_signal(|| slug.clone())` 镜像 slug prop,
|
||||
//! 并在 render 期 `if slug_signal() != slug { slug_signal.set(...) }` 触发重取——
|
||||
//! 这是 Dioxus antipatterns 明确点名的「render body 副作用」。
|
||||
//! 已在 commit 225bb24 改为直接读 prop。本测试通过源码字符串断言防止回退。
|
||||
|
||||
/// PostDetail 组件源码不得包含镜像 slug 的 signal 反模式签名。
|
||||
#[test]
|
||||
fn post_detail_does_not_mirror_slug_into_signal() {
|
||||
let src = include_str!("../src/pages/post_detail.rs");
|
||||
|
||||
// 反模式签名:用 use_signal 镜像 slug prop。
|
||||
assert!(
|
||||
!src.contains("slug_signal"),
|
||||
"post_detail.rs 重新引入了 slug_signal 镜像——这是 render 期 set signal 反模式。\
|
||||
应直接在 use_server_future 闭包里读 slug prop,让 Dioxus 在 prop 变化时自动重跑。\
|
||||
详见 .agents/skills/dioxus-render-purity/SKILL.md 规则一。"
|
||||
);
|
||||
|
||||
// 反模式签名:render 期对 slug 相关 signal 调用 set。
|
||||
assert!(
|
||||
!src.contains("slug_signal.set"),
|
||||
"post_detail.rs 在 render 期 set signal——违反渲染纯净性。"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,验证它通过(源码已修复,断言应成立)**
|
||||
|
||||
运行:`cargo test --test post_detail_slug_rerun`
|
||||
预期:`test result: ok. 1 passed; 0 failed`
|
||||
|
||||
**反向验证**(可选,确认测试有效):临时把 `src/pages/post_detail.rs` 改回含 `slug_signal` 的写法,重跑应 FAIL;验证后 `git checkout src/pages/post_detail.rs` 还原。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add tests/post_detail_slug_rerun.rs
|
||||
git commit -m "test(post_detail): 锁定 slug 重取不再依赖镜像 signal
|
||||
|
||||
防止 post_detail.rs 回退到 render 期 set signal 反模式(commit 225bb24 已修)。
|
||||
用源码字符串契约断言,与 theme.rs 的 preload-script 测试同模式。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 从 `write_editor` 抽取 `CoverUploader` 子组件
|
||||
|
||||
**问题**:`src/pages/admin/write.rs` 的 `write_editor`(63–809 行,约 746 行)是 god component。封面上传是一个**高内聚、低耦合**的子领域,分布在三处:
|
||||
- **信号声明**:`src/pages/admin/write.rs:75-80`(`cover_uploading`/`cover_error`/`cover_url_mode`/`cover_drag_active`/`cover_url_input` 五个,注意 `cover_image` 在 71 行,**必须保留**)。
|
||||
- **上传闭包**:`src/pages/admin/write.rs:230-249`(`spawn_cover_upload`,`#[cfg(target_arch = "wasm32")]` 包裹)。
|
||||
- **封面 rsx**:`src/pages/admin/write.rs:474-708`(封面 `div` + URL 输入模式 + 错误提示,一个连续块)。
|
||||
|
||||
把它整体抽成 `CoverUploader` 子组件,`write_editor` 只保留 `cover_image`(保存时需要读)。
|
||||
|
||||
**抽取边界**:
|
||||
- `CoverUploader` 拥有 5 个私有 cover signal + `spawn_cover_upload` 闭包 + 封面 rsx。
|
||||
- 对外**只**暴露一个 `cover_image: Signal<String>` prop(双向:父组件声明该 signal 并传引用,子组件内 `set` 写最终 URL,父组件读它用于保存)。
|
||||
- `write_editor` 删除上述信号/闭包/rsx,净减约 150 行。
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/admin/write.rs`(删除 cover signal/闭包/rsx,改为 `<CoverUploader cover_image=cover_image />`,新增 `CoverUploader` 组件)
|
||||
|
||||
### Step 1: 通读 cover 相关代码,确认边界与引用点
|
||||
|
||||
- [ ] **Step 1: 确认 `cover_image` 的全部引用点(必须保留在 `write_editor`)**
|
||||
|
||||
运行:`grep -n "cover_image" src/pages/admin/write.rs`
|
||||
|
||||
预期引用点(**这些保留在 `write_editor`**,是 `cover_image` 不能迁走的理由):
|
||||
- `:71` 声明 `let mut cover_image = use_signal(|| "".to_string());`
|
||||
- `:114` backfill effect:`cover_image.set(post.cover_image.clone().unwrap_or_default());`
|
||||
- `:325` / `:328` 保存逻辑:`let cover_image_opt = if cover_image().trim().is_empty() {...}`
|
||||
- `rsx` 内多处读 `cover_image()`
|
||||
|
||||
确认:`cover_image` 留在 `write_editor`,作为 prop 传给 `CoverUploader`。
|
||||
|
||||
- [ ] **Step 2: 确认其余 5 个 cover signal 的引用点(全部迁入 `CoverUploader`)**
|
||||
|
||||
运行:`grep -n "cover_uploading\|cover_error\|cover_url_mode\|cover_url_input\|cover_drag_active" src/pages/admin/write.rs`
|
||||
|
||||
预期:所有引用都集中在 `spawn_cover_upload` 闭包(230–249)与封面 rsx(474–708)内。**注意**:经核实 `on_submit` 保存逻辑里**没有**读 `cover_uploading()` 的拦截,故无需额外处理;若执行时发现存在该拦截(仓库后续可能改动),一并随闭包删除即可。
|
||||
|
||||
### Step 2: 在 `write.rs` 文件末尾新增 `CoverUploader` 组件骨架
|
||||
|
||||
- [ ] **Step 3: 在文件末尾(`write_editor` 函数 `}` 之后)追加 `CoverUploader` 骨架**
|
||||
|
||||
在 `src/pages/admin/write.rs` **末尾**追加以下骨架(仅信号声明 + 上传闭包 + 空 `rsx!`,封面 UI 在 Step 4 用「剪切粘贴」填入):
|
||||
|
||||
```rust
|
||||
|
||||
/// 封面上传子组件。
|
||||
///
|
||||
/// 封装封面图的全部状态与交互:拖拽/粘贴/选择文件上传、URL 输入、预览、移除。
|
||||
/// 通过 `cover_image` signal 与父组件双向绑定——子组件写入最终 URL,
|
||||
/// 父组件读取它用于保存。其余上传中间态(uploading/error/drag/url)对本组件私有。
|
||||
///
|
||||
/// 从 `write_editor` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
|
||||
#[component]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
fn CoverUploader(cover_image: Signal<String>) -> Element {
|
||||
let mut cover_uploading = use_signal(|| false);
|
||||
let mut cover_error = use_signal(|| None::<String>);
|
||||
let mut cover_url_mode = use_signal(|| false);
|
||||
let mut cover_drag_active = use_signal(|| false);
|
||||
let mut cover_url_input = use_signal(|| "".to_string());
|
||||
|
||||
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
||||
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut spawn_cover_upload = move |file: web_sys::File| {
|
||||
cover_uploading.set(true);
|
||||
cover_error.set(None);
|
||||
spawn(async move {
|
||||
match upload_image_file(file).await {
|
||||
Ok(url) => cover_image.set(url),
|
||||
Err(msg) => cover_error.set(Some(msg)),
|
||||
}
|
||||
cover_uploading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
// (Step 4:把 write_editor 的封面 rsx 块 474–708 剪切粘贴到这里,
|
||||
// 去掉一层缩进使其成为 CoverUploader 的直接子元素)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**要点**:
|
||||
- `upload_image_file` 已在 `write_editor` 所在文件顶部 import(`use ...upload_image_file`),`CoverUploader` 同文件可直接用,无需重复 import。执行前用 `grep -n "use.*upload_image_file\|upload_image_file" src/pages/admin/write.rs | head -1` 确认 import 路径。
|
||||
- `#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]` 与 `write_editor` 同款,避免 server 构建因 wasm-only signal 报 unused。
|
||||
- **不要**手写封面 rsx——Step 4 从 `write_editor` 剪切现有代码,确保 `evt.files().into_iter().next()` / `get_web_file()` / `try_as_web_event()` / `dyn_ref::<ClipboardEvent>()` 等真实 API 调用一字不差。
|
||||
|
||||
### Step 3: 把封面 rsx 从 `write_editor` 剪切到 `CoverUploader`
|
||||
|
||||
- [ ] **Step 4: 剪切封面 rsx 块(474–708 行)**
|
||||
|
||||
在 `src/pages/admin/write.rs` 中,**剪切**(不是复制)从这一行:
|
||||
|
||||
```rust
|
||||
// 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。
|
||||
```
|
||||
|
||||
(约 474 行)一直到这一行(含):
|
||||
|
||||
```rust
|
||||
}
|
||||
```
|
||||
|
||||
(约 708 行——即「封面上传失败提示」`div` 的闭合 `}`)。这一整段是封面 `div` + URL 输入模式 `if` + 错误提示 `if` 三个连续块。
|
||||
|
||||
**粘贴位置**:`CoverUploader` 的 `rsx! { ... }` 内部,替换 Step 3 骨架里的占位注释。粘贴后**去掉一层缩进**(原在 `write_editor` 内是 5 级缩进 20 空格,在 `CoverUploader` 内应是 4 级 16 空格——以 `rsx! {` 为基准对齐)。
|
||||
|
||||
**校对**:粘贴后,`CoverUploader` 内部的所有 `cover_image` / `cover_uploading` / `cover_error` / `cover_url_mode` / `cover_url_input` / `cover_drag_active` / `spawn_cover_upload` 引用都解析到本组件的本地 signal/闭包。
|
||||
|
||||
### Step 4: 从 `write_editor` 删除已迁出的信号与闭包
|
||||
|
||||
- [ ] **Step 5: 删除 5 个 cover signal 声明(75–80 行,保留 71 行 `cover_image`)**
|
||||
|
||||
删除 `src/pages/admin/write.rs` 中这 6 行(注释 + 5 个 signal,**不含 `cover_image`**):
|
||||
|
||||
```rust
|
||||
// 封面图上传状态:uploading 进度态、错误消息、URL 输入框展开、拖拽高亮。
|
||||
let mut cover_uploading = use_signal(|| false);
|
||||
let mut cover_error = use_signal(|| None::<String>);
|
||||
let mut cover_url_mode = use_signal(|| false);
|
||||
let mut cover_drag_active = use_signal(|| false);
|
||||
// 封面 URL 输入框的临时值(确认前不直接写入 cover_image,避免半截 URL 触发预览加载)。
|
||||
let mut cover_url_input = use_signal(|| "".to_string());
|
||||
```
|
||||
|
||||
保留后该区域应只剩:
|
||||
|
||||
```rust
|
||||
let mut cover_image = use_signal(|| "".to_string());
|
||||
let mut status = use_signal(|| "draft".to_string());
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 删除 `spawn_cover_upload` 闭包(230–249 行)**
|
||||
|
||||
删除 `src/pages/admin/write.rs` 中 `spawn_cover_upload` 的整段定义,含上方注释:
|
||||
|
||||
```rust
|
||||
// 封面图上传:spawn 一个 async 调用 upload_image_file。
|
||||
// 三条入口(file input / drop / paste)收敛成拿到 web_sys::File 后统一调用此闭包。
|
||||
// 仅在 WASM 端有意义(upload_image_file 与 spawn 都依赖 WASM 运行时),
|
||||
// server SSR 不渲染上传逻辑,故整体 cfg-gate 避免引用 wasm-only 符号。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut spawn_cover_upload = move |file: web_sys::File| {
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: 在 `write_editor` 原封面位置插入 `CoverUploader` 调用
|
||||
|
||||
- [ ] **Step 7: 在 `write_editor` 的 `rsx!` 中,原封面块位置插入子组件调用**
|
||||
|
||||
Step 4 剪切后,`write_editor` 的 `rsx!` 里原封面块的位置现在是空的。在该位置(原 474 行处)插入:
|
||||
|
||||
```rust
|
||||
// 封面图上传区(抽取为子组件 CoverUploader)。
|
||||
CoverUploader { cover_image }
|
||||
```
|
||||
|
||||
**缩进**:与原封面 `div` 同级(5 级缩进,20 空格)。`cover_image` 不加括号——传 signal 本身(prop 类型是 `Signal<String>`),不是 `cover_image()`(那是 `String`)。
|
||||
|
||||
### Step 6: 编译与验证
|
||||
|
||||
- [ ] **Step 8: `dx check` 验证类型**
|
||||
|
||||
运行:`dx check`
|
||||
预期:`No issues found.`
|
||||
|
||||
**常见报错与修复**:
|
||||
- `cannot find value cover_uploading in this scope`(在 `write_editor` 内):有遗漏——`grep -n "cover_uploading\|cover_error\|cover_url_mode\|cover_url_input\|cover_drag_active\|spawn_cover_upload" src/pages/admin/write.rs`,确认这些名字只出现在 `CoverUploader` 组件函数体内(从 `fn CoverUploader` 到它的闭合 `}`)。
|
||||
- `cannot find function spawn_cover_upload`(在 `write_editor`):封面 rsx 没剪切干净,有残留调用。回到 Step 4 确认整块已迁出。
|
||||
- `expected Signal<String>, found String` on `cover_image` prop:调用处误写 `CoverUploader { cover_image: cover_image() }`——改为 `CoverUploader { cover_image }`。
|
||||
- `unused import: upload_image_file`:若 `CoverUploader` 是唯一用 `upload_image_file` 的地方且 import 在文件顶部,import 仍有效(同文件),不会报;若报则确认 import 行还在。
|
||||
|
||||
- [ ] **Step 9: 跑测试**
|
||||
|
||||
运行:`cargo test`
|
||||
预期:`test result: ok. 405 passed; 0 failed`(cover 抽取是纯前端重组,不影响 server 单测)。
|
||||
|
||||
- [ ] **Step 10: 手动验证封面功能(`make dev`)**
|
||||
|
||||
运行:`make dev`,登录后台,进入 `/admin/write`,逐项验证:
|
||||
- [ ] 空态:显示「拖拽 · 点击 · 粘贴封面图」+「或使用图片 URL」
|
||||
- [ ] 点空态区:弹出文件选择,选图后显示预览
|
||||
- [ ] 拖拽图片到上传区:边框高亮 → 上传成功显示预览
|
||||
- [ ] 粘贴图片(Ctrl/Cmd+V):上传成功显示预览
|
||||
- [ ] 点「或使用图片 URL」:展开 URL 输入框,输入有效图片 URL → 确认 → 显示预览
|
||||
- [ ] 预览态点右上角「×」:清空封面回到空态
|
||||
- [ ] 上传中:显示「上传中...」骨架
|
||||
- [ ] 进入编辑模式 `/admin/edit/<id>`(有封面的文章):封面正确回填显示
|
||||
- [ ] 填写标题+正文后点保存:成功(`cover_image` 正确传给 server function)
|
||||
|
||||
验证无误后 `Ctrl+C` 停止 dev server。
|
||||
|
||||
- [ ] **Step 11: 提交**
|
||||
|
||||
```bash
|
||||
git add src/pages/admin/write.rs
|
||||
git commit -m "refactor(write): 抽取 CoverUploader 子组件,降低 god component 复杂度
|
||||
|
||||
write_editor 从 746 行降至约 600 行。封面上传(5 个私有 signal + 上传闭包 +
|
||||
封面 rsx)整体迁入 CoverUploader 子组件,仅通过 cover_image signal 双向绑定
|
||||
与父组件通信。依据 dioxus-render-purity skill 的 god component 治理建议。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收清单(全部任务完成后过一遍)
|
||||
|
||||
- [ ] `dx check` 全绿
|
||||
- [ ] `cargo test` 405+ passed(Task 2 新增 1 个,共 406)
|
||||
- [ ] `grep -rn "slug_signal" src/` 无结果(Task 2 测试已锁定)
|
||||
- [ ] `src/pages/admin/trash.rs` 的 `dirty` 是 `use_memo`
|
||||
- [ ] `src/pages/admin/write.rs` 的 `write_editor` 不再含 `cover_uploading`/`spawn_cover_upload`(已迁入 `CoverUploader`)
|
||||
- [ ] `make dev` 手动验证封面功能正常
|
||||
|
||||
## 不在本计划范围(明确排除)
|
||||
|
||||
- **`TrashPage`(481 行)god component 全量拆分**:风险高、收益是可维护性而非正确性。建议作为独立后续计划,且应在 Task 3 验证「抽取子组件」模式可行后再评估。
|
||||
- **`AdminCommentsPage`(315 行)/ `PostsPage`(181 行)拆分**:体量中等,同上,后续单独评估。
|
||||
- **`post_id` prop drilling(评论树 3 跳)**:轻微,`CommentContext` 已存在,改造成本高于收益。
|
||||
- **streaming / SSR / server functions 三轮文档对照**:已确认合规,无改动。
|
||||
@ -1,205 +0,0 @@
|
||||
# Comment localStorage Persistence + Pending Visibility
|
||||
|
||||
## Problem
|
||||
|
||||
After submitting a comment, users see only a green "评论已提交,等待审核" alert. The comment is invisible to everyone (including the author) until admin approval. Additionally, returning users must re-enter their name/email/website every time.
|
||||
|
||||
## Solution
|
||||
|
||||
Two localStorage-backed features:
|
||||
|
||||
1. **Form auto-fill**: Save author info (name/email/url) to localStorage, pre-fill on subsequent visits.
|
||||
2. **Pending comment visibility**: Store pending comments locally by server-returned ID. Display them with a "审核中" badge, visible only to the submitting browser.
|
||||
|
||||
## localStorage Keys
|
||||
|
||||
### `yggdrasil-comment-author`
|
||||
|
||||
Written on every successful comment submission. Read on `CommentForm` mount.
|
||||
|
||||
```json
|
||||
{ "name": "张三", "email": "zhang@example.com", "url": "https://example.com" }
|
||||
```
|
||||
|
||||
### `yggdrasil-pending-comments`
|
||||
|
||||
Written on successful submission with server-returned ID. Read on `CommentSection` mount. Keyed by `post_id` (string).
|
||||
|
||||
```json
|
||||
{
|
||||
"42": [
|
||||
{
|
||||
"id": 123,
|
||||
"parent_id": null,
|
||||
"depth": 0,
|
||||
"author_name": "张三",
|
||||
"author_url": null,
|
||||
"avatar_url": "https://cravatar.cn/avatar/xxx?d=mp&s=80",
|
||||
"content_md": "评论内容",
|
||||
"created_at": "2026-06-11T10:00:00Z",
|
||||
"stored_at": "2026-06-11T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Design decisions:
|
||||
- **No `content_html`** — XSS safety. Render `content_md` client-side with HTML escaping + newline→`<br>`.
|
||||
- **No `author_email`** — Already in `yggdrasil-comment-author`. Avoid PII duplication.
|
||||
- **`avatar_url`** computed at save time from the stored author email.
|
||||
- **`stored_at`** for 7-day TTL. Pruned on every read.
|
||||
- Empty post_id arrays removed on cleanup.
|
||||
|
||||
## Server-Side Changes
|
||||
|
||||
### 1. `CommentResponse` — add `comment_id`
|
||||
|
||||
```rust
|
||||
pub struct CommentResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub error_code: Option<String>,
|
||||
pub comment_id: Option<i64>, // NEW: set only on success
|
||||
}
|
||||
```
|
||||
|
||||
Backward-compatible: `Option<i64>` serde defaults to `None` for old callers.
|
||||
|
||||
### 2. `create_comment` — extract and return ID
|
||||
|
||||
The existing INSERT already uses `RETURNING id` but discards the row. Change to:
|
||||
|
||||
```rust
|
||||
let row = client
|
||||
.query_one("INSERT INTO comments ... RETURNING id", &[...])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let comment_id: i64 = row.get(0);
|
||||
```
|
||||
|
||||
All existing error paths add `comment_id: None`. Only the final success path sets `comment_id: Some(comment_id)`.
|
||||
|
||||
### 3. New server function: `CheckPendingStatus`
|
||||
|
||||
```rust
|
||||
#[server(CheckPendingStatus, "/api")]
|
||||
pub async fn check_pending_status(ids: Vec<i64>) -> Result<Vec<(i64, String)>, ServerFnError>
|
||||
```
|
||||
|
||||
- Early return empty vec if `ids.is_empty()`.
|
||||
- Query: `SELECT id, status FROM comments WHERE id = ANY($1)`
|
||||
- IDs not found in result → status `"gone"` (soft-deleted or hard-deleted).
|
||||
- Client uses this to prune localStorage entries that are no longer pending.
|
||||
|
||||
## Client-Side Changes
|
||||
|
||||
### New module: `src/hooks/comment_storage.rs`
|
||||
|
||||
Provides `use_comment_storage()` hook with:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `save_author(name, email, url)` | Write `yggdrasil-comment-author` |
|
||||
| `load_author() -> Option<AuthorInfo>` | Read author info |
|
||||
| `save_pending_comment(post_id, comment)` | Append to `yggdrasil-pending-comments` |
|
||||
| `load_pending_comments(post_id) -> Vec<PendingComment>` | Read + prune expired (7-day TTL) |
|
||||
| `remove_pending_ids(post_id, ids)` | Remove specific IDs, clean empty post entries |
|
||||
| `prune_expired()` | Remove all entries across all posts older than 7 days |
|
||||
|
||||
All `web_sys` calls behind `#[cfg(target_arch = "wasm32")]`. Non-WASM returns defaults (empty/None).
|
||||
|
||||
Serialization via `serde_json` (already a project dependency).
|
||||
|
||||
### `CommentContext` — extend with pending state
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CommentContext {
|
||||
pub active_reply: Signal<Option<i64>>,
|
||||
pub refresh_trigger: Signal<bool>>,
|
||||
pub pending_comments: Signal<Vec<PendingComment>>, // NEW
|
||||
}
|
||||
```
|
||||
|
||||
Placing `pending_comments` in `CommentContext` ensures it survives `refresh_trigger` re-renders (blocker B2 from review).
|
||||
|
||||
### `CommentForm` changes
|
||||
|
||||
- **On mount**: call `load_author()` → set `author_name`, `author_email`, `author_url` signals if localStorage has saved values.
|
||||
- **On successful submit**:
|
||||
1. `save_author(name, email, url)` — persist form fields
|
||||
2. Construct `PendingComment` from form data + returned `comment_id`
|
||||
3. Compute `avatar_url` from stored email (same gravatar formula as server)
|
||||
4. `save_pending_comment(post_id, pending)` — persist pending comment
|
||||
5. Push to `ctx.pending_comments` signal — immediate UI update
|
||||
|
||||
### `CommentSection` changes
|
||||
|
||||
- **On mount**:
|
||||
1. `load_pending_comments(post_id)` → populate `ctx.pending_comments`
|
||||
2. `prune_expired()` — clean 7-day old entries
|
||||
3. If pending IDs exist, call `check_pending_status(ids)` → `remove_pending_ids()` for non-pending
|
||||
- **Rendering**: merge approved + pending comments sorted by `created_at`, interleaving them chronologically.
|
||||
|
||||
### New component: `PendingCommentItem`
|
||||
|
||||
Renders pending comments distinctly from approved ones:
|
||||
- Semi-transparent card style (e.g., `opacity-70`)
|
||||
- Amber badge: "审核中"
|
||||
- `content_md` rendered with HTML escaping + `\n` → `<br>`
|
||||
- **No reply button** — server rejects replies to non-approved parents
|
||||
- **No admin actions** — pending comments from localStorage are visitor-submitted
|
||||
|
||||
### `CommentList` changes
|
||||
|
||||
Accept both `comments: Vec<PublicComment>` and `pending: Vec<PendingComment>`. Merge into a sorted iterator by `created_at` and render either `CommentItem` or `PendingCommentItem` per item.
|
||||
|
||||
For threaded display: pending replies appear under their `parent_id` parent, using the same `depth` indentation logic.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Submit comment
|
||||
→ server returns { success: true, comment_id: 123 }
|
||||
→ save_author() to localStorage
|
||||
→ save_pending_comment() to localStorage
|
||||
→ push to pending_comments Signal
|
||||
→ UI shows comment with "审核中" badge
|
||||
|
||||
Page load / CommentSection mount
|
||||
→ load_pending_comments(post_id) from localStorage
|
||||
→ prune_expired() (7-day TTL)
|
||||
→ check_pending_status(pending_ids) via server
|
||||
→ remove_pending_ids() for non-pending (approved/spam/trash/gone)
|
||||
→ merge approved + pending → render chronologically
|
||||
|
||||
Admin approves comment
|
||||
→ next page load: check_pending_status() returns "approved"
|
||||
→ remove_pending_ids() removes it from localStorage
|
||||
→ comment appears normally via get_comments() API
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/api/comments/types.rs` | Add `comment_id: Option<i64>` to `CommentResponse` |
|
||||
| `src/api/comments/create.rs` | Extract returned ID, populate `comment_id` in response |
|
||||
| `src/api/comments/mod.rs` | Export new `check_pending_status` |
|
||||
| `src/api/comments/check.rs` | **NEW**: `CheckPendingStatus` server function |
|
||||
| `src/hooks/comment_storage.rs` | **NEW**: localStorage hook + `AuthorInfo`/`PendingComment` structs |
|
||||
| `src/hooks/mod.rs` | Export `comment_storage` module |
|
||||
| `src/components/comments/section.rs` | Load pending, call check, merge for rendering |
|
||||
| `src/components/comments/form.rs` | Pre-fill from localStorage, save on submit |
|
||||
| `src/components/comments/list.rs` | Accept both comment types, merge sorted |
|
||||
| `src/components/comments/item.rs` | No change (approved comments unchanged) |
|
||||
| `src/components/comments/pending_item.rs` | **NEW**: `PendingCommentItem` component |
|
||||
| `src/components/comments/mod.rs` | Export `pending_item` |
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit test: `CommentResponse` deserialization without `comment_id` → `None`
|
||||
- Unit test: `PendingComment` serde roundtrip
|
||||
- Unit test: `check_pending_status` with empty vec → empty result
|
||||
- Unit test: `check_pending_status` with mix of pending/approved/gone IDs
|
||||
- Integration: submit comment → verify localStorage written → verify pending visible → verify cleanup after admin approval
|
||||
@ -1,313 +0,0 @@
|
||||
# 文章图片 Blur-up 渐进加载设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
文章页面的缩略图(卡片封面 `?thumb=400x300`、详情封面 `?w=1200`、正文图 `?w=800`)在加载过程中**没有任何占位**:`<img>` 裸标签无 width/height/aspect-ratio,加载时区域高度为 0,加载完撑开——导致 CLS(布局跳动)明显,尤其在列表页和长文章里。
|
||||
|
||||
本设计的目标:
|
||||
- 加载前先显示**低分辨率模糊占位图**(`?w=20`),消除空白与跳动
|
||||
- 加载完高清缩略图后,**平滑过渡**到清晰(opacity 淡入)
|
||||
- 服务端 SSR 内嵌占位图(渐进增强,JS 禁用也能看到占位)
|
||||
- 三处图片位置(卡片封面、详情封面、正文图)统一应用
|
||||
|
||||
## 关键决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|--------|------|
|
||||
| 占位图来源 | SSR 内嵌(HTML 里 img src 就是占位图 URL,JS 替换高清) |
|
||||
| 过渡实现 | 双层叠加 + opacity 淡入(底层占位图常驻,上层高清图淡入覆盖) |
|
||||
| 作用范围 | 三处都加(卡片封面、详情封面、正文图) |
|
||||
| 占位图尺寸 | 统一 `?w=20` |
|
||||
| DOM 生成方式 | 方案 A:SSR 渲染双层结构 + JS 接管高清加载 |
|
||||
| aspect-ratio 尺寸来源 | 方案 B2:渲染时实时读图片 header + moka 缓存(零迁移) |
|
||||
| 外链图 | 不处理(保持原生 img,无法生成占位图) |
|
||||
| dimensions 缓存 TTL | 24h,可通过 `IMAGE_DIMENSIONS_CACHE_TTL_SECS` 环境变量配置 |
|
||||
|
||||
## 技术约束(探索结论)
|
||||
|
||||
- **服务端图片能力现成**:`/uploads/{path}` 支持 `?w=`/`?thumb=`,已有内存+磁盘两级缓存(`src/api/image.rs`)。`?w=20` 产出 <1KB 的极小图。
|
||||
- **sanitizer 约束**:当前 `clean_post_html` 对 img 只放行 `src/alt/width/height/align`,对 span 放行 `class`。双层结构需要额外放行 `data-src`/`class`/`style`(img)和 `style`(span)。评论配置不动。
|
||||
- **现有缓存是异步的**:`src/cache.rs` 用 `moka::future::Cache`(`.get().await`),但 `render_markdown_enhanced` 是同步函数。dimensions 缓存需要用 `moka::sync::Cache`(同步版本)。
|
||||
- **webp 尺寸读取可行**:`zenwebp::WebPDecoder::build(data)` + `.info()` 只解析 WebP header(RIFF chunk)就能拿到 width/height,**不需要全量解码像素**(见 `src/webp.rs:143-145` 现有代码在 decode 前就读了 info)。开销极小。GIF/PNG/JPEG 走 `image::ImageReader::into_dimensions()`(只读 header)。
|
||||
- **上传后磁盘格式**:非 gif/webp 统一转 webp,gif 保持 gif,webp 保持 webp。所以 dimensions 读取要支持 webp + gif/png/jpeg。
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 架构总览
|
||||
|
||||
```
|
||||
SSR 渲染文章/封面时
|
||||
↓
|
||||
对每张 /uploads/ 图片:
|
||||
├─ get_image_dimensions(path) → (w, h) [sync moka cache + 读 header]
|
||||
└─ 产出双层 DOM:
|
||||
<span class="blur-img" style="--ar: W/H;">
|
||||
<img class="blur-img-placeholder" src="...?w=20"> ← SSR 内嵌,立即加载
|
||||
<img class="blur-img-full" data-src="...?w=800"> ← JS 懒加载
|
||||
</span>
|
||||
|
||||
前端 JS (post-content.js / image-viewer 初始化)
|
||||
├─ IntersectionObserver 监听 .blur-img 进入视口
|
||||
├─ 把 .blur-img-full 的 data-src 赋给 src → 触发高清图加载
|
||||
└─ 高清图 onload → 加 .is-loaded class → CSS opacity 0→1 淡入
|
||||
```
|
||||
|
||||
### 1. Dimensions 缓存与读取(新增)
|
||||
|
||||
**`src/api/image.rs` 新增**(`#[cfg(feature = "server")]`):
|
||||
|
||||
```rust
|
||||
use moka::sync::Cache;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
static IMAGE_DIMENSIONS_CACHE: LazyLock<Cache<String, (u32, u32)>> = LazyLock::new(|| {
|
||||
let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(86400)); // 默认 24h
|
||||
Cache::builder().time_to_live(ttl).build()
|
||||
});
|
||||
|
||||
/// 读取图片真实尺寸(只读 header,不解码像素)。
|
||||
/// 路径如 "2026/06/22/xxx.webp"(不含 /uploads/ 前缀)。
|
||||
/// 失败返回 None(渲染时回退到不设 aspect-ratio)。
|
||||
pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
|
||||
if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
|
||||
return Some(dims);
|
||||
}
|
||||
let full_path = std::path::Path::new("uploads").join(rel_path);
|
||||
let data = std::fs::read(&full_path).ok()?;
|
||||
let dims = read_dimensions_from_bytes(&data, rel_path)?;
|
||||
IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
|
||||
Some(dims)
|
||||
}
|
||||
|
||||
/// 按扩展名分发:webp 走 zenwebp header,其他走 image crate。
|
||||
fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_lowercase();
|
||||
match ext.as_str() {
|
||||
"webp" => {
|
||||
let decoder = zenwebp::WebPDecoder::build(data).ok()?;
|
||||
let info = decoder.info();
|
||||
Some((info.width, info.height))
|
||||
}
|
||||
"gif" | "png" | "jpg" | "jpeg" => {
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(data))
|
||||
.with_guessed_format().ok()?;
|
||||
reader.into_dimensions().ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:这是同步函数,在 server function 的异步上下文里同步读文件头。文件头很小(几十到几百字节),开销可接受;moka cache 命中时不读盘。`std::fs::read` 读整个文件——优化点:可改为只读前 N 字节(webp/jpeg/png/gif 的 header 都在前几百字节内),但首版用 `std::fs::read` 保持简单,配合 cache 降低频率。
|
||||
|
||||
### 2. 双层 DOM 结构
|
||||
|
||||
```html
|
||||
<span class="blur-img" style="--ar: 16/9;">
|
||||
<img class="blur-img-placeholder" src="/uploads/x.webp?w=20" alt="...">
|
||||
<img class="blur-img-full" data-src="/uploads/x.webp?w=800" alt="...">
|
||||
</span>
|
||||
```
|
||||
|
||||
- **`span.blur-img`**:相对定位容器,`aspect-ratio: var(--ar)` 预留空间(零 CLS),`overflow: hidden` 裁剪模糊边缘
|
||||
- **`img.blur-img-placeholder`**:SSR 内嵌的 `?w=20` 占位图,绝对定位填满容器,CSS 模糊放大
|
||||
- **`img.blur-img-full`**:`data-src` 存高清 URL,初始无 src(不立即加载),JS 接管;绝对定位,初始 `opacity: 0`,加载完 `.is-loaded` → `opacity: 1`
|
||||
|
||||
`--ar` 由服务端用 `get_image_dimensions` 拿到的真实宽高生成(如 `16/9`)。dimensions 读取失败时不设 `--ar`,退化为无 aspect-ratio(轻微 CLS,但不阻塞渲染)。
|
||||
|
||||
### 3. Markdown 渲染器改造(`render_markdown_enhanced`)
|
||||
|
||||
正文图转换发生在 Markdown→HTML 阶段。当前用 `pulldown_cmark::html::push_html` 产出 `<img src="..." alt="...">`。
|
||||
|
||||
**改造**:在 `push_html` 产出 HTML 后、`clean_html` 之前,对字符串里的 `<img>` 做后处理:
|
||||
|
||||
```rust
|
||||
fn wrap_images_with_blur(html: &str) -> String {
|
||||
// 用正则/简单解析找到 <img src="/uploads/..." ...> 标签
|
||||
// 对每个匹配的 img:
|
||||
// 1. 提取 src(如 /uploads/2026/06/22/x.webp)
|
||||
// 2. 从 src 解析出 rel_path(去掉 /uploads/ 前缀和 query)
|
||||
// 3. get_image_dimensions(rel_path) → (w,h),算 --ar
|
||||
// 4. 生成双层结构替换原 img
|
||||
// 非 /uploads/ 的外链图不处理(保持原样)
|
||||
}
|
||||
```
|
||||
|
||||
**正则选择**:用 `regex` crate(已是 server feature 的 optional 依赖)匹配 `<img\s+[^>]*src="(/uploads/[^"?]+)[^"]*"[^>]*>`。注意 HTML 用正则有边界情况,但 img 标签由 pulldown-cmark 生成,格式可控(属性顺序固定:src 在前,alt 在后)。
|
||||
|
||||
### 4. ImageViewer 改造(卡片封面 / 详情封面)
|
||||
|
||||
`ImageViewer` 当前渲染单层 img + 灯箱。改为渲染双层结构:
|
||||
|
||||
```rust
|
||||
ImageViewer {
|
||||
src: cover.clone(),
|
||||
thumb_params: "?w=1200", // 高清尺寸 → data-src
|
||||
placeholder_params: "?w=20", // 占位图尺寸
|
||||
alt: "封面图片",
|
||||
}
|
||||
```
|
||||
|
||||
ImageViewer 内部:
|
||||
- **dimensions 获取**:组件内部调用 `get_image_dimensions`(`#[cfg(feature = "server")]`)。SSR 渲染时组件树在服务端执行,server-only 函数可用;dimensions 有 moka 缓存,命中时是内存查询,miss 时读小文件头。调用方(`post_card.rs`/`post_cover.rs`)无需改动 dimensions 相关逻辑。WASM 端无法调此函数,但图片的 `--ar` 在 SSR 阶段已渲染进 HTML,前端无需再算。
|
||||
- 底层 placeholder:`src = {原图}{placeholder_params}`
|
||||
- 上层 full:`data-src = {原图}{thumb_params}`
|
||||
- `--ar` 由 dimensions 计算,SSR 时写入 `style`
|
||||
- 灯箱逻辑不变(点击用原图 src 放大)
|
||||
|
||||
`get_image_dimensions` 在组件里调用属于 SSR 渲染时的同步读盘。首版接受这个阻塞(小文件 + cache);如 SSR 性能成问题,后续可改为 server function 预算 + 模型字段传递。
|
||||
|
||||
### 5. post-content.js 改造
|
||||
|
||||
当前职责:把 img.src 改成 `?w=800`、加灯箱。
|
||||
|
||||
**新职责**:
|
||||
- **不再改 src**(渲染器已产出双层结构,placeholder src 已是 `?w=20`)
|
||||
- **加载高清图**:用 `IntersectionObserver` 监听 `.blur-img` 进入视口,把 `.blur-img-full` 的 `data-src` 赋给 `src`
|
||||
- **触发淡入**:高清 img 的 `onload` 加 `.is-loaded` class,CSS 控制 opacity 过渡
|
||||
- **灯箱**:保留现有点击放大。灯箱用 `.blur-img-full` 的 `data-src`(高清图 URL)作为放大图源——灯箱是看大图,应用高清版本而非 20px 占位图。
|
||||
|
||||
```javascript
|
||||
function initBlurUp(root) {
|
||||
var containers = root.querySelectorAll('.blur-img');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
// 不支持 IO 的浏览器:直接加载所有
|
||||
containers.forEach(loadFull);
|
||||
return;
|
||||
}
|
||||
var io = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(entry) {
|
||||
if (entry.isIntersecting) {
|
||||
loadFull(entry.target);
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '200px' });
|
||||
containers.forEach(function(c) { io.observe(c); });
|
||||
}
|
||||
|
||||
function loadFull(container) {
|
||||
var full = container.querySelector('.blur-img-full');
|
||||
if (!full || !full.dataset.src) return;
|
||||
full.onload = function() { full.classList.add('is-loaded'); };
|
||||
full.src = full.dataset.src;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. CSS
|
||||
|
||||
新增到全局样式(`input.css` 或对应样式入口):
|
||||
|
||||
```css
|
||||
.blur-img {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: var(--ar);
|
||||
background: var(--color-paper-code-bg, #f5f5f5); /* 加载中灰底 */
|
||||
}
|
||||
.blur-img-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.1); /* 放大遮住 blur 边缘 */
|
||||
}
|
||||
.blur-img-full {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
.blur-img-full.is-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
暗色模式:`.blur-img` 的 background 用暗色灰底。
|
||||
|
||||
### 7. sanitizer 配置扩展
|
||||
|
||||
`clean_post_html`(文章专用)扩展放行属性:
|
||||
- img:额外放行 `data-src`、`class`、`style`
|
||||
- span:额外放行 `style`(用于 `--ar`)
|
||||
|
||||
评论配置(`clean_comment_html`)**不动**(评论无 img)。
|
||||
|
||||
## 实现边界与清单
|
||||
|
||||
### 服务端(`#[cfg(feature = "server")]`)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/api/image.rs` | 新增 `IMAGE_DIMENSIONS_CACHE`(moka sync)+ `get_image_dimensions` + `read_dimensions_from_bytes` |
|
||||
| `src/api/markdown.rs` | `render_markdown_enhanced` 增加 `wrap_images_with_blur` 后处理(在 clean_html 前) |
|
||||
| `src/api/sanitizer.rs` | `clean_post_html` 扩展 img 放行 `data-src/class/style`、span 放行 `style` |
|
||||
|
||||
### 前端(Rust 组件)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/components/image_viewer.rs` | 渲染双层结构(placeholder + full),接收 dimensions/aspect-ratio |
|
||||
| `src/components/post_card.rs` | 调 ImageViewer 时传入 dimensions(SSR 算好) |
|
||||
| `src/components/post/post_cover.rs` | 同上 |
|
||||
|
||||
### 前端(JS)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `public/js/post-content.js` | 删除改 src 逻辑;新增 IntersectionObserver 懒加载 + onload 淡入;灯箱改用 data-src |
|
||||
|
||||
### 样式
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `input.css`(或全局样式入口) | 新增 `.blur-img*` 样式(含暗色模式) |
|
||||
|
||||
### 配置
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `.env.example` | 新增 `IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400` |
|
||||
|
||||
### 不做的事
|
||||
|
||||
- 不改评论图片(评论无 img)
|
||||
- 不处理外链图(非 `/uploads/` 的图保持原生 img)
|
||||
- 不给上传流程加尺寸持久化(B2 方案零迁移,实时读 + 缓存)
|
||||
- 不做 LQIP 之外的图片优化(avif 转码、响应式 srcset 等)
|
||||
- 不改图片服务端点(`/uploads/{path}` 的 `?w=` 能力现成)
|
||||
|
||||
## 实现风险
|
||||
|
||||
1. **正则解析 img 标签**:`wrap_images_with_blur` 用正则匹配 img。pulldown-cmark 产出的 img 格式可控(src 在前、alt 在后),但需写测试覆盖各种 src 格式(带/不带 query、外链、相对路径)。
|
||||
|
||||
2. **`std::fs::read` 读全文件**:首版 `get_image_dimensions` 读整个文件再解析 header。对大图(几 MB)有内存/IO 开销。优化:改为只读前 N 字节(webp/jpeg/png/gif header 都在前 100 字节内)。首版保持简单,配合 cache 降低频率,后续可优化。
|
||||
|
||||
3. **SSR 阶段 ImageViewer 拿 dimensions**:ImageViewer 是通用组件,dimensions 是 server-only。需在 SSR 上层(post_card/post_cover)算好传入,避免组件内部调用 server-only 函数。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 文章正文图片加载时先显示模糊的 `?w=20` 占位图,无布局跳动(aspect-ratio 预留空间)
|
||||
- [ ] 高清缩略图加载完后,opacity 平滑淡入覆盖占位图(0.4s 过渡)
|
||||
- [ ] 视口外的高清图不加载(IntersectionObserver 懒加载)
|
||||
- [ ] 卡片封面、详情封面同样有 blur-up 效果
|
||||
- [ ] 外链图(非 /uploads/)保持原生 img,无 blur-up
|
||||
- [ ] JS 禁用时,正文图显示 `?w=20` 模糊占位图(渐进增强,可读但模糊)
|
||||
- [ ] dimensions 缓存命中时不读盘(二次访问快)
|
||||
- [ ] webp/gif/png/jpeg 图片的尺寸都能正确读取
|
||||
- [ ] `.env.example` 含 `IMAGE_DIMENSIONS_CACHE_TTL_SECS=86400`
|
||||
- [ ] sanitizer 不误删双层结构的 `data-src`/`class`/`style` 属性
|
||||
- [ ] 评论图片不受影响(评论无 img,配置不动)
|
||||
- [ ] 点击图片放大(灯箱)功能仍正常工作
|
||||
@ -1,479 +0,0 @@
|
||||
# 编辑器图片上传占位符与失败提示设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
文章编辑器(`libs/tiptap-editor/`)已有图片上传功能(见 `2025-06-05-image-upload-design.md`),支持粘贴、拖拽、斜杠命令三种入口上传到 `/api/upload`。但当前上传过程存在两个体验缺陷:
|
||||
|
||||
1. **上传中无反馈**:图片仅在成功后才插入编辑器,用户在等待期间看不到任何"正在上传"的占位符,不知道发生了什么。
|
||||
2. **上传失败完全静默**:三个上传入口(`FileHandler.onPaste`/`onDrop`、`SlashCommand`)失败时只有 `console.error`,用户在编辑器里什么都看不到。更糟的是,`write.rs` 的 fetch 还丢弃了服务端返回的中文错误(如"文件超过大小限制"),只拼成 `'Upload failed: 413'`。
|
||||
|
||||
本设计的目标:
|
||||
- 上传过程中在编辑器内显示**占位符**(本地预览图 + loading 遮罩)
|
||||
- 上传失败时把占位符变成**错误态卡片**(含服务端错误文案 + 重试/移除按钮),同时在页面顶部显示**可堆叠的失败提示**
|
||||
- 保存文章时,若有未完成或失败的占位符,**阻止保存**
|
||||
|
||||
## 关键决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|--------|------|
|
||||
| 占位符内容 | 本地预览图(blob URL)+ 半透明遮罩 + spinner + "上传中…" |
|
||||
| 失败呈现 | 占位符变错误态(灰化 + 红色图标 + 服务端错误文案 + 重试/移除按钮)+ 顶部多条堆叠提示 |
|
||||
| 重试机制 | 无限重试,File 对象保留在节点上直到成功或移除 |
|
||||
| 顶部提示形态 | 静态多条堆叠,手动关闭(×) |
|
||||
| 保存处理 | 有 loading/error 占位符时阻止保存,顶部提示 |
|
||||
| 架构 | JS 主导(自定义 Image 节点视图)+ Rust 轮询全局变量获取上传状态 |
|
||||
|
||||
## 技术约束(探索结论)
|
||||
|
||||
- Tiptap Image 扩展(v3.25.0)**无原生上传状态**:`addAttributes` 只有 `src/alt/title/width/height`,没有 `uploading`/`uploadId` 之类。需要自定义扩展或 NodeView。
|
||||
- 项目无 JS→Rust 事件通道:现有 eval 桥接是单向的(Rust eval 调 JS 方法读返回值,或 JS 写全局变量 Rust 轮询)。没有 wasm-bindgen 导出机制。失败提示要落到 Rust 的 Dioxus signal,必须新增一个轮询通道。
|
||||
- 现有无 toast 组件:所有提示都是 signal 驱动的静态内联块(`AlertBox` 或 write.rs 自有的红/绿条)。
|
||||
|
||||
## 详细设计
|
||||
|
||||
### 架构总览
|
||||
|
||||
```
|
||||
用户操作 (粘贴/拖拽/斜杠)
|
||||
↓
|
||||
uploadCoordinator (index.ts, 实例级单例)
|
||||
├─ 生成 uploadId, 创建 blob URL
|
||||
├─ 插入占位符节点 (image, data-upload-state=uploading)
|
||||
├─ 发起 onImageUpload(file) ← write.rs 注入的 fetch /api/upload
|
||||
│
|
||||
├─ 成功: updateImageNode(uploadId, {src:url, 清除上传属性}) + revokeObjectURL
|
||||
└─ 失败: updateImageNode(uploadId, {data-upload-state=error, data-error-msg})
|
||||
+ notifyRust(推 event 到 window.__tiptap_uploads)
|
||||
|
||||
NodeView (upload-image.ts)
|
||||
├─ 渲染三种态 (uploading/error/done)
|
||||
└─ 按钮点击转发给 coordinator: onRetry(uploadId) / onRemove(uploadId)
|
||||
|
||||
Rust (write.rs)
|
||||
├─ 轮询 window.__tiptap_uploads (500ms)
|
||||
│ ├─ 消费 events → upload_errors signal
|
||||
│ └─ 读 counts → uploads_in_flight signal
|
||||
├─ 顶部渲染 upload_errors (多条堆叠 + ×关闭)
|
||||
└─ on_submit 检查 uploads_in_flight + 扫描 markdown 兜底 → 阻止保存
|
||||
```
|
||||
|
||||
### 1. JS 侧:自定义 Image 扩展与 NodeView
|
||||
|
||||
**新文件 `libs/tiptap-editor/src/upload-image.ts`**
|
||||
|
||||
基于 `@tiptap/extension-image` 派生的扩展,覆盖三件事:
|
||||
|
||||
#### 1.1 自定义属性
|
||||
|
||||
继承父类的 `src/alt/title/width/height`,新增三个上传状态属性:
|
||||
|
||||
```typescript
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(), // 继承 src/alt/title/width/height
|
||||
'data-upload-state': { default: null, parseHTML: el => el.getAttribute('data-upload-state') },
|
||||
'data-upload-id': { default: null, parseHTML: el => el.getAttribute('data-upload-id') },
|
||||
'data-error-msg': { default: null, parseHTML: el => el.getAttribute('data-error-msg') },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data-upload-state` 取值:`null`(已完成/正常图片)| `"uploading"` | `"error"`。
|
||||
|
||||
#### 1.2 自定义 NodeView(`addNodeView`)
|
||||
|
||||
NodeView 根据 `data-upload-state` 渲染三种 UI。**NodeView 只负责渲染和转发按钮点击,不直接发起上传**——上传逻辑集中在 coordinator(见 §2)。
|
||||
|
||||
- **`null`(已完成)**:普通 `<img>`,透传 `src`/`alt`/`width`。与原生行为一致。
|
||||
- **`"uploading"`**:容器内放 `<img src="blob:url">`(本地预览)+ 绝对定位遮罩(半透明黑底 + 居中 spinner + "上传中…" 文字)。
|
||||
- **`"error"`**:`<img>`(`opacity-50` 灰化)+ 遮罩(红色 ⚠ 图标 + `data-error-msg` 文案 + 两个按钮:重试 / 移除)。
|
||||
|
||||
NodeView 持有对 `editor` 的引用。按钮点击时:
|
||||
- "重试" → 读取当前节点的 `data-upload-id`,调用 `this.options.onRetry(uploadId)`
|
||||
- "移除" → 读取 `data-upload-id`,调用 `this.options.onRemove(uploadId)`
|
||||
|
||||
`onRetry`/`onRemove` 回调由 `index.ts` 注入(实际调用 coordinator)。
|
||||
|
||||
NodeView 需正确实现 Tiptap NodeView 接口:`update`(节点属性变化时重新渲染遮罩状态)、`ignoreMutation`(遮罩内的按钮点击不应被 ProseMirror 当作编辑)、`destroy`(清理 DOM)。
|
||||
|
||||
**属性变化重渲染机制**:当 coordinator 调用 `updateNode` 更新 `data-upload-state` 等属性时,ProseMirror 派发事务,NodeView 的 `update(node)` 被调用。NodeView 在 `update` 里比较新旧节点的 `data-upload-state`,若变化则重新渲染对应的遮罩 UI(uploading→error、error→uploading、任意→done)。这是占位符状态切换的驱动机制——NodeView 本身不持有状态,纯由节点属性驱动。
|
||||
|
||||
#### 1.3 Markdown 序列化
|
||||
|
||||
`@tiptap/markdown` 默认会丢掉非标准属性。占位符节点序列化后会变成 `` 或 `![]()`。**这是可接受的**——保存拦截(见 §4)保证脏内容不进数据库,且编辑器内只对最终态(`data-upload-state=null`)的图片关心序列化正确性,此时节点属性与原生 Image 一致。
|
||||
|
||||
### 2. JS 侧:上传协调器(`index.ts`)
|
||||
|
||||
`TiptapEditorInstance` 新增一个实例级的 `uploadCoordinator`,统一管理三个上传入口(粘贴/拖拽/斜杠命令),替换现有分散在 `FileHandler.onPaste`/`onDrop` 和 `SlashCommand` 里的 `.then/.catch`。
|
||||
|
||||
#### 2.1 协调器职责与状态
|
||||
|
||||
```typescript
|
||||
interface UploadEntry {
|
||||
file: File
|
||||
blobUrl: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
class UploadCoordinator {
|
||||
private pending = new Map<string, UploadEntry>() // uploadId → {file, blobUrl, fileName}
|
||||
constructor(
|
||||
private editor: Editor,
|
||||
private onImageUpload: (file: File) => Promise<string>,
|
||||
private notifyRust: (event: UploadEvent) => void,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 公共方法
|
||||
|
||||
**`insertUploading(file: File, pos?: number): void`** — 首次上传入口(粘贴/拖拽/斜杠都调它):
|
||||
```typescript
|
||||
const uploadId = crypto.randomUUID()
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
this.pending.set(uploadId, { file, blobUrl, fileName: file.name })
|
||||
|
||||
// 插入占位符节点
|
||||
editor.chain().focus()
|
||||
.insertContentAt(pos ?? editor.state.selection.head, {
|
||||
type: 'image',
|
||||
attrs: { src: blobUrl, 'data-upload-state': 'uploading', 'data-upload-id': uploadId }
|
||||
}).run()
|
||||
|
||||
this.runUpload(uploadId)
|
||||
```
|
||||
|
||||
**`retryUpload(uploadId: string): void`** — NodeView"重试"按钮调用:
|
||||
```typescript
|
||||
const entry = this.pending.get(uploadId)
|
||||
if (!entry) return
|
||||
// 节点先转回 uploading
|
||||
this.updateNode(uploadId, { 'data-upload-state': 'uploading', 'data-error-msg': null })
|
||||
this.runUpload(uploadId)
|
||||
```
|
||||
|
||||
**`removeUpload(uploadId: string): void`** — NodeView"移除"按钮调用:
|
||||
```typescript
|
||||
const entry = this.pending.get(uploadId)
|
||||
if (!entry) return
|
||||
// 删除节点(按 data-upload-id 定位)
|
||||
this.removeNodeByUploadId(uploadId)
|
||||
URL.revokeObjectURL(entry.blobUrl)
|
||||
this.pending.delete(uploadId)
|
||||
this.notifyRust({ kind: 'removed', uploadId })
|
||||
```
|
||||
|
||||
**`removeUploadByUploadId(uploadId: string): boolean`** — Rust 侧"×关闭"提示时调用(通过 eval)。逻辑与 `removeUpload` 相同(删节点 + revoke + pending.delete + notifyRust),只是入口不同:`removeUpload` 由 NodeView 内部按钮触发,`removeUploadByUploadId` 由 Rust eval 从外部触发。返回是否成功删除(供 Rust 判断是否要清顶部提示)。
|
||||
|
||||
#### 2.3 私有方法
|
||||
|
||||
**`private async runUpload(uploadId): Promise<void>`** — 核心上传逻辑:
|
||||
```typescript
|
||||
const entry = this.pending.get(uploadId)
|
||||
if (!entry) return
|
||||
try {
|
||||
const url = await this.onImageUpload(entry.file)
|
||||
// 成功:替换 src + 清除上传属性
|
||||
this.updateNode(uploadId, {
|
||||
src: url,
|
||||
'data-upload-state': null,
|
||||
'data-upload-id': null,
|
||||
'data-error-msg': null,
|
||||
})
|
||||
URL.revokeObjectURL(entry.blobUrl)
|
||||
this.pending.delete(uploadId)
|
||||
this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName })
|
||||
} catch (err) {
|
||||
const msg = this.extractErrorMessage(err)
|
||||
this.updateNode(uploadId, {
|
||||
'data-upload-state': 'error',
|
||||
'data-error-msg': msg,
|
||||
})
|
||||
this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg })
|
||||
}
|
||||
```
|
||||
|
||||
**`private updateNode(uploadId, attrs): void`** — 按 `data-upload-id` 定位节点并更新属性。上传完成时光标早已移走,不能依赖选区,必须遍历文档回查:
|
||||
```typescript
|
||||
let targetPos: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) {
|
||||
targetPos = pos
|
||||
return false // 停止遍历
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (targetPos !== null) {
|
||||
const tr = editor.state.tr.setNodeMarkup(targetPos, undefined, { ...node.attrs, ...attrs })
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
```
|
||||
|
||||
**`private removeNodeByUploadId(uploadId): void`** — 类似 `updateNode` 定位后用 `tr.delete(pos, pos + nodeSize)`。
|
||||
|
||||
**`private extractErrorMessage(err): string`** — 从错误对象提取服务端中文消息:
|
||||
- 若 err 是 Error 且 message 以 `"Upload failed: "` 开头(`write.rs` 的旧格式),尝试回退到通用提示
|
||||
- 否则直接用 message
|
||||
- **注意**:见 §5 的 `write.rs` fetch 改造,改造后 err.message 会直接是服务端中文(如"文件超过大小限制"),此处只需透传
|
||||
|
||||
#### 2.4 三个上传入口的改造
|
||||
|
||||
| 入口 | 改动 |
|
||||
|------|------|
|
||||
| `FileHandler.onPaste` | 改为 `coordinator.insertUploading(file)`(无 pos,插入选区) |
|
||||
| `FileHandler.onDrop` | 改为 `coordinator.insertUploading(file, pos)`(用 onDrop 给的 pos) |
|
||||
| `SlashCommand` 上传图片命令 | 改为 `coordinator.insertUploading(file)` |
|
||||
|
||||
三处原本的 `.then(setImage).catch(console.error)` 全部删除,统一走 coordinator。
|
||||
|
||||
### 3. JS→Rust 状态通道
|
||||
|
||||
#### 3.1 全局对象结构
|
||||
|
||||
```javascript
|
||||
window.__tiptap_uploads = {
|
||||
// 新事件队列:Rust 消费后清空
|
||||
events: [
|
||||
{ kind: 'error', uploadId: 'uuid1', fileName: 'cat.png', errorMsg: '文件超过大小限制', ts: 1719... },
|
||||
{ kind: 'success', uploadId: 'uuid2', fileName: 'dog.png', ts: 1719... },
|
||||
{ kind: 'removed', uploadId: 'uuid1', ts: 1719... },
|
||||
],
|
||||
// 实时计数(始终反映当前编辑器内占位符状态)
|
||||
counts: { uploading: 2, error: 1 }
|
||||
}
|
||||
```
|
||||
|
||||
- `events`:追加型队列。coordinator 每次 `runUpload` 成功/失败、`removeUpload` 时追加一个 event。Rust 轮询时读取并清空(`u.events = []`)。
|
||||
- `counts`:coordinator 在每次状态变化后重新计算(遍历 `editor.state.doc` 统计 `data-upload-state` 为 `uploading`/`error` 的节点数),写入 `counts`。Rust 每次轮询直接读当前值。
|
||||
|
||||
#### 3.2 `notifyRust(event)` 实现
|
||||
|
||||
```typescript
|
||||
private notifyRust(event: UploadEvent) {
|
||||
if (!window.__tiptap_uploads) {
|
||||
window.__tiptap_uploads = { events: [], counts: { uploading: 0, error: 0 } }
|
||||
}
|
||||
window.__tiptap_uploads.events.push({ ...event, ts: Date.now() })
|
||||
// 重新计算 counts
|
||||
let uploading = 0, error = 0
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
const state = node.attrs['data-upload-state']
|
||||
if (state === 'uploading') uploading++
|
||||
else if (state === 'error') error++
|
||||
return true
|
||||
})
|
||||
window.__tiptap_uploads.counts = { uploading, error }
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Rust 侧:轮询消费与渲染(`write.rs`)
|
||||
|
||||
#### 4.1 新增 signal
|
||||
|
||||
```rust
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct UploadsInFlight { uploading: u32, error: u32 }
|
||||
|
||||
// 当前进行中的上传计数(保存拦截用)
|
||||
let mut uploads_in_flight = use_signal(UploadsInFlight::default);
|
||||
|
||||
// 顶部堆叠的失败提示(用户手动关闭)
|
||||
struct UploadErrorEntry { id: String, file_name: String, message: String }
|
||||
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
|
||||
```
|
||||
|
||||
#### 4.2 轮询 effect
|
||||
|
||||
复用现有 `spawn_local` 轮询模式(参考 `write.rs:210-238` 的 `__tiptap_ready` 轮询)。新增独立 `use_future`,500ms 间隔:
|
||||
|
||||
```rust
|
||||
use_future(move || async move {
|
||||
let mut seen_error_ids: HashSet<String> = HashSet::new();
|
||||
loop {
|
||||
sleep(500ms);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let snapshot = js_sys::eval(r#"
|
||||
(function() {
|
||||
var u = window.__tiptap_uploads;
|
||||
if (!u) return null;
|
||||
var events = u.events || [];
|
||||
u.events = [];
|
||||
return JSON.stringify({ events: events, counts: u.counts || {uploading:0,error:0} });
|
||||
})()
|
||||
"#).ok().and_then(|v| v.as_string());
|
||||
|
||||
if let Some(json) = snapshot {
|
||||
if let Ok(parsed) = serde_json::from_str::<UploadSnapshot>(&json) {
|
||||
// 1. 消费 events
|
||||
for ev in parsed.events {
|
||||
match ev.kind.as_str() {
|
||||
"error" => {
|
||||
if !seen_error_ids.contains(&ev.uploadId) {
|
||||
seen_error_ids.insert(ev.uploadId.clone());
|
||||
upload_errors.write().push(UploadErrorEntry {
|
||||
id: ev.uploadId,
|
||||
file_name: ev.fileName,
|
||||
message: ev.errorMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
"success" | "removed" => {
|
||||
// 该 id 不再是失败态,从顶部提示移除
|
||||
seen_error_ids.remove(&ev.uploadId);
|
||||
upload_errors.write().retain(|e| e.id != ev.uploadId);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// 2. 更新 counts
|
||||
uploads_in_flight.set(UploadsInFlight {
|
||||
uploading: parsed.counts.uploading,
|
||||
error: parsed.counts.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**counts 同步的重要性**:当用户在编辑器内点"移除"删掉失败占位符,coordinator 发 `removed` event + 重算 counts。Rust 消费 `removed` event 时从 `upload_errors` 移除对应条目——**保证编辑器内卡片和顶部提示同步**,不会出现"占位符删了但顶部提示还在"的孤儿状态。
|
||||
|
||||
注:`success` event 在 Rust 侧也会走 `seen_error_ids.remove + upload_errors.retain` 分支,但 success 的 id 从未进过 `seen_error_ids`/`upload_errors`(只有 error 才进),所以这是无害的多余操作,保留统一处理逻辑即可。
|
||||
|
||||
#### 4.3 顶部提示渲染
|
||||
|
||||
在 `write.rs` 现有 `load_error()`/`error()`/`success()` 提示区附近(约 line 453-469),新增上传错误区:
|
||||
|
||||
```rust
|
||||
for err in upload_errors.read().iter() {
|
||||
div {
|
||||
class: "flex-shrink-0 flex items-center justify-between px-4 py-2
|
||||
bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400
|
||||
rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
|
||||
span { "图片上传失败: {err.file_name} — {err.message}" }
|
||||
button {
|
||||
class: "ml-3 text-red-400 hover:text-red-600 cursor-pointer",
|
||||
onclick: move |_| {
|
||||
// 关闭提示,同时删除编辑器内的失败占位符(避免孤儿)
|
||||
let _ = js_sys::eval(&format!(
|
||||
"(function(){{var e=window.TiptapEditor&&window.TiptapEditor._instances&&window.TiptapEditor._instances.get('tiptap-editor');if(e&&e.removeUploadByUploadId){{e.removeUploadByUploadId({:?});}}}})()",
|
||||
err.id
|
||||
));
|
||||
upload_errors.write().retain(|e| e.id != err.id);
|
||||
},
|
||||
"×"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**×关闭同时删除失败占位符**:用户点×的语义是"清掉这个失败",保留编辑器内的红色卡片会变成孤儿(顶部无提示了但编辑器里还挂着)。通过 eval 调 `removeUploadByUploadId` 删除占位符 + revoke blob URL + 发 `removed` event。
|
||||
|
||||
#### 4.4 保存拦截(双重防护)
|
||||
|
||||
**第一道:counts 检查**(主提示)
|
||||
|
||||
`on_submit`(约 line 247)开头,读 markdown 前加检查:
|
||||
|
||||
```rust
|
||||
let in_flight = uploads_in_flight.read();
|
||||
if in_flight.uploading > 0 || in_flight.error > 0 {
|
||||
let msg = if in_flight.uploading > 0 {
|
||||
format!("有 {} 张图片正在上传,请等待完成后再保存", in_flight.uploading)
|
||||
} else {
|
||||
format!("有 {} 张图片上传失败,请移除或重试后再保存", in_flight.error)
|
||||
};
|
||||
error.set(Some(msg));
|
||||
return;
|
||||
}
|
||||
drop(in_flight);
|
||||
```
|
||||
|
||||
**第二道:markdown 兜底扫描**(防御性)
|
||||
|
||||
500ms 轮询有窗口期(用户刚传完、counts 还没更新就点保存)。拿到 markdown 后扫描是否含残留的占位符标记:
|
||||
|
||||
```rust
|
||||
// 拿到 md 后
|
||||
if md.contains("blob:") || md.contains("data-upload-state") {
|
||||
error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
这两道防护共同保证:脏内容(blob URL 或带上传状态的节点)不会写入数据库。
|
||||
|
||||
### 5. `write.rs` fetch 改造(透传服务端错误)
|
||||
|
||||
当前 `onImageUpload` 的 fetch 在非 2xx 时丢弃了服务端的中文错误:
|
||||
|
||||
```javascript
|
||||
// 当前(丢弃错误体)
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed: ' + response.status);
|
||||
}
|
||||
```
|
||||
|
||||
改为读取错误响应体:
|
||||
|
||||
```javascript
|
||||
// 改造后
|
||||
if (!response.ok) {
|
||||
// 读取服务端返回的中文错误({"success":false,"error":"文件超过大小限制"})
|
||||
// 服务端所有失败路径都返回此 JSON 格式(见 upload.rs),解析可靠
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
// 响应体不是 JSON(极端情况,如反向代理错误页),退回状态码
|
||||
throw new Error('上传失败: ' + response.status);
|
||||
}
|
||||
```
|
||||
|
||||
改造后,coordinator 的 `extractErrorMessage` 直接透传即可拿到"文件超过大小限制"等中文消息。
|
||||
|
||||
## 实现边界与清单
|
||||
|
||||
### JS 侧(`libs/tiptap-editor/src/`)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `upload-image.ts` | **新建**:自定义 Image 扩展(继承父类属性 + 三个上传属性 + NodeView) |
|
||||
| `index.ts` | 替换 `Image.configure(...)` 为自定义扩展;新增 `UploadCoordinator` 类;`FileHandler.onPaste/onDrop`、`SlashCommand` 统一走 `coordinator.insertUploading`;实现 `notifyRust` + `removeUploadByUploadId` 暴露给 Rust |
|
||||
| `slash-command.ts` | 上传图片命令的 `.then/.catch` 改为 `coordinator.insertUploading(file)` |
|
||||
| `style.css` | 新增 NodeView 三种态的样式(遮罩、spinner、错误卡片、重试/移除按钮) |
|
||||
|
||||
### Rust 侧(`src/pages/admin/write.rs`)
|
||||
|
||||
| 改动点 | 说明 |
|
||||
|--------|------|
|
||||
| 新增 signal | `uploads_in_flight`、`upload_errors` |
|
||||
| 新增轮询 effect | 500ms 消费 `window.__tiptap_uploads` |
|
||||
| 顶部提示渲染 | 多条堆叠 + ×关闭(同时删占位符) |
|
||||
| `on_submit` 拦截 | counts 检查 + markdown 兜底扫描 |
|
||||
| `onImageUpload` fetch 改造 | 读取非 2xx 响应体的 `error` 字段 |
|
||||
|
||||
### 不做的事
|
||||
|
||||
- 不引入 wasm-bindgen 导出机制(保持 eval 桥接一致性)
|
||||
- 不做 toast/自动消失提示(保持与现有静态提示风格一致)
|
||||
- 不引入图片编辑/裁剪能力
|
||||
- 不改动服务端 `upload.rs`(它的错误响应格式已满足需求,只是前端没读)
|
||||
- 不处理编辑模式(`WriteEdit`)下的旧文章占位符回填——旧文章的图片都是 `data-upload-state=null` 的正常图片,不涉及上传态
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 粘贴/拖拽/斜杠命令上传图片时,编辑器立即显示本地预览图 + "上传中…"遮罩
|
||||
- [ ] 上传成功后,遮罩消失,图片 src 替换为服务端 URL,无光标跳动
|
||||
- [ ] 上传失败时,占位符变红,显示服务端中文错误(如"文件超过大小限制")
|
||||
- [ ] 失败占位符上有"重试"和"移除"按钮,点重试用原文件重新上传,点移除删除节点
|
||||
- [ ] 上传失败时页面顶部出现堆叠提示,显示文件名 + 错误原因 + ×关闭
|
||||
- [ ] 多张图片同时失败时,顶部提示多条堆叠,逐条可关闭
|
||||
- [ ] 在编辑器内移除失败占位符,顶部对应提示同步消失
|
||||
- [ ] 点顶部×关闭,编辑器内对应失败占位符同步删除
|
||||
- [ ] 有 uploading 占位符时点保存,被阻止并提示"有 N 张图片正在上传"
|
||||
- [ ] 有 error 占位符时点保存,被阻止并提示"有 N 张图片上传失败"
|
||||
- [ ] markdown 兜底扫描:即使轮询窗口期漏判,blob: 残留也能被拦下
|
||||
- [ ] 上传超大文件(>5MB)时错误提示为"文件超过大小限制"而非"Upload failed: 413"
|
||||
Loading…
x
Reference in New Issue
Block a user