chore: remove unused docs/superpowers directory
Some checks failed
CI / check (push) Failing after 4m37s
CI / build (push) Has been skipped

This commit is contained in:
xfy 2026-06-29 16:19:17 +08:00
parent b309f2dbc1
commit 2776d39ac1
7 changed files with 0 additions and 4451 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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-ratiomoka sync cache前端 JS 用 IntersectionObserver 懒加载高清图并淡入。
**Tech Stack:** Rustimage crate + zenwebp + moka sync cache + regex、Dioxus 组件、原生 JSIntersectionObserver、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` | 改 |
任务顺序1dimensions cache→ 2sanitizer→ 3markdown 包装)→ 4CSS→ 5ImageViewer→ 6post-content.js→ 7.env→ 8验证
---
## Task 1: Dimensions 缓存与读取image.rs
**Files:**
- Modify: `src/api/image.rs`(顶部 import 区 + 文件末尾新增)
服务端读图片真实尺寸(只读 headermoka 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 cacherender_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 headergif/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: &regex::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-ratioSSR 时读 dimensions**
在组件内(`let mut is_open = use_signal(|| false);` 之后)添加:
```rust
// 计算 aspect-ratioSSR 时读图片真实尺寸。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: 灯箱用高清图 URLfull_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 checkwasm32 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

View File

@ -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`63809 行,约 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` 闭包230249与封面 rsx474708内。**注意**:经核实 `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 块 474708 剪切粘贴到这里,
// 去掉一层缩进使其成为 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 块474708 行)**
`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 声明7580 行,保留 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` 闭包230249 行)**
删除 `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+ passedTask 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 三轮文档对照**:已确认合规,无改动。

View File

@ -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

View File

@ -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 就是占位图 URLJS 替换高清) |
| 过渡实现 | 双层叠加 + opacity 淡入(底层占位图常驻,上层高清图淡入覆盖) |
| 作用范围 | 三处都加(卡片封面、详情封面、正文图) |
| 占位图尺寸 | 统一 `?w=20` |
| DOM 生成方式 | 方案 ASSR 渲染双层结构 + 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 headerRIFF chunk就能拿到 width/height**不需要全量解码像素**(见 `src/webp.rs:143-145` 现有代码在 decode 前就读了 info。开销极小。GIF/PNG/JPEG 走 `image::ImageReader::into_dimensions()`(只读 header
- **上传后磁盘格式**:非 gif/webp 统一转 webpgif 保持 gifwebp 保持 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` classCSS 控制 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 时传入 dimensionsSSR 算好) |
| `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配置不动
- [ ] 点击图片放大(灯箱)功能仍正常工作

View File

@ -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`,若变化则重新渲染对应的遮罩 UIuploading→error、error→uploading、任意→done。这是占位符状态切换的驱动机制——NodeView 本身不持有状态,纯由节点属性驱动。
#### 1.3 Markdown 序列化
`@tiptap/markdown` 默认会丢掉非标准属性。占位符节点序列化后会变成 `![](blob:url)``![]()`。**这是可接受的**——保存拦截(见 §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"