diff --git a/docs/superpowers/plans/2026-06-11-comment-localstorage.md b/docs/superpowers/plans/2026-06-11-comment-localstorage.md deleted file mode 100644 index 1bf462b..0000000 --- a/docs/superpowers/plans/2026-06-11-comment-localstorage.md +++ /dev/null @@ -1,1026 +0,0 @@ -# Comment localStorage Persistence + Pending Visibility 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:** Auto-fill comment form from localStorage and show pending comments with "审核中" badge to the submitting user. - -**Architecture:** Client-side localStorage stores author info and pending comment data keyed by post_id. A new server function `check_pending_status` validates local pending IDs on page load. Pending comments render via a separate `PendingCommentItem` component merged chronologically with approved comments in `CommentList`. - -**Tech Stack:** Dioxus 0.7, `web_sys` for localStorage, `serde_json` for serialization, `chrono` for timestamps. - ---- - -## Task 1: Add `comment_id` to `CommentResponse` + extract ID in `create.rs` - -**Files:** -- Modify: `src/api/comments/types.rs:16-20` -- Modify: `src/api/comments/create.rs:190-221` - -- [ ] **Step 1: Add `comment_id` field to `CommentResponse`** - -In `src/api/comments/types.rs`, change the struct to: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommentResponse { - pub success: bool, - pub message: String, - pub error_code: Option, - #[serde(default)] - pub comment_id: Option, - #[serde(default)] - pub avatar_url: Option, - #[serde(default)] - pub depth: Option, -} -``` - -- [ ] **Step 2: Add `comment_id: None` to all error-path `CommentResponse` constructors in `create.rs`** - -Every early-return `Ok(CommentResponse { ... })` in `create.rs` needs `comment_id: None, avatar_url: None, depth: None,` added. There are 12 such sites (lines 27, 36, 43, 50, 58, 78, 88, 109, 121, 128, 137, 164). Add these three fields after `error_code: ...` in each. - -- [ ] **Step 3: Extract returned ID and set it in the success response** - -Replace `create.rs:190-221` with: - -```rust - let row = client - .query_one( - "INSERT INTO comments \ - (post_id, parent_id, depth, author_name, author_email, author_url, \ - content_md, content_html, content_hash, status, ip_address, user_agent) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11) \ - RETURNING id", - &[ - &post_id, - &parent_id, - &depth, - &author_name.trim(), - &author_email.trim(), - &author_url.as_ref().map(|u| u.trim()).filter(|u| !u.is_empty()), - &content_md, - &content_html, - &content_hash, - &ip_address, - &user_agent, - ], - ) - .await - .map_err(AppError::query)?; - - let comment_id: i64 = row.get(0); - - let avatar_url = crate::api::comments::helpers::gravatar_url(&author_email); - - cache::invalidate_comments_by_post(post_id).await; - - Ok(CommentResponse { - success: true, - message: "评论已提交,等待审核".to_string(), - error_code: None, - comment_id: Some(comment_id), - avatar_url: Some(avatar_url), - depth: Some(depth), - }) -``` - -- [ ] **Step 4: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: no errors related to `CommentResponse`. - -- [ ] **Step 5: Commit** - -```bash -git add src/api/comments/types.rs src/api/comments/create.rs -git commit -m "feat(api): return comment_id, avatar_url, depth from create_comment - -- Add comment_id/avatar_url/depth Option fields to CommentResponse with serde default -- Extract RETURNING id from INSERT in create.rs -- Compute gravatar_url server-side (md5 not available in WASM) -- Return computed depth for correct client-side pending comment indentation -- All error paths return None for all new fields" -``` - ---- - -## Task 2: Create `CheckPendingStatus` server function - -**Files:** -- Create: `src/api/comments/check.rs` -- Modify: `src/api/comments/mod.rs:1-17` - -- [ ] **Step 1: Create `src/api/comments/check.rs`** - -```rust -use dioxus::prelude::*; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PendingStatusItem { - pub id: i64, - pub status: String, -} - -#[server(CheckPendingStatus, "/api")] -pub async fn check_pending_status(ids: Vec) -> Result, ServerFnError> { - #[cfg(feature = "server")] - { - use crate::db::pool::get_conn; - use crate::api::error::AppError; - - if ids.is_empty() { - return Ok(vec![]); - } - - let client = get_conn().await.map_err(AppError::db_conn)?; - - let rows = client - .query( - "SELECT id, status FROM comments WHERE id = ANY($1)", - &[&ids], - ) - .await - .map_err(AppError::query)?; - - let found: std::collections::HashMap = rows - .iter() - .map(|r| (r.get::<_, i64>(0), r.get::<_, String>(1))) - .collect(); - - let result: Vec = ids - .into_iter() - .map(|id| { - let status = found.get(&id).cloned().unwrap_or_else(|| "gone".to_string()); - PendingStatusItem { id, status } - }) - .collect(); - - Ok(result) - } - #[cfg(not(feature = "server"))] - unreachable!() -} -``` - -- [ ] **Step 2: Register module and export in `src/api/comments/mod.rs`** - -Add `mod check;` to the module declarations and `pub use check::check_pending_status;` to the exports: - -```rust -#![allow(clippy::unused_unit, deprecated, unused_imports, clippy::too_many_arguments)] - -mod types; -mod helpers; -mod markdown; -mod create; -mod read; -mod update; -mod list; -mod check; - -pub use types::*; -pub use create::create_comment; -pub use read::get_comments; -pub use update::{approve_comment, spam_comment, trash_comment, batch_update_comment_status}; -pub use list::{get_pending_count, get_all_comments}; -pub use check::check_pending_status; - -#[cfg(feature = "server")] -pub use markdown::render_comment_markdown; -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/api/comments/check.rs src/api/comments/mod.rs -git commit -m "feat(api): add CheckPendingStatus server function - -Accepts Vec of comment IDs, returns their current status. -IDs not found in DB return status 'gone'. Empty vec returns early. -Used by client to prune localStorage pending comments that are -no longer pending (approved/spam/trash/deleted)." -``` - ---- - -## Task 3: Create `comment_storage` hook - -**Files:** -- Create: `src/hooks/comment_storage.rs` -- Modify: `src/hooks/mod.rs:1` - -- [ ] **Step 1: Create `src/hooks/comment_storage.rs`** - -```rust -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -const AUTHOR_KEY: &str = "yggdrasil-comment-author"; -const PENDING_KEY: &str = "yggdrasil-pending-comments"; -const TTL_DAYS: i64 = 7; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthorInfo { - pub name: String, - pub email: String, - #[serde(default)] - pub url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PendingComment { - pub id: i64, - pub parent_id: Option, - pub depth: i32, - pub author_name: String, - pub author_url: Option, - pub avatar_url: String, - pub content_md: String, - pub created_at: String, - pub stored_at: String, -} - -type PendingMap = std::collections::HashMap>; - -fn read_storage(key: &str) -> Option { - #[cfg(target_arch = "wasm32")] - { - let window = web_sys::window()?; - let storage = window.local_storage().ok()??; - storage.get_item(key).ok()? - } - #[cfg(not(target_arch = "wasm32"))] - { - None - } -} - -fn write_storage(key: &str, value: &str) { - #[cfg(target_arch = "wasm32")] - { - if let Some(window) = web_sys::window() { - if let Ok(Some(storage)) = window.local_storage() { - let _ = storage.set_item(key, value); - } - } - } -} - -fn now_iso() -> String { - Utc::now().to_rfc3339() -} - -fn is_expired(stored_at: &str) -> bool { - let Ok(dt) = DateTime::parse_from_rfc3339(stored_at) else { - return true; - }; - let Ok(now) = DateTime::parse_from_rfc3339(&now_iso()) else { - return false; - }; - (now - dt).num_days() > TTL_DAYS -} - -pub fn save_author(name: &str, email: &str, url: &str) { - let info = AuthorInfo { - name: name.to_string(), - email: email.to_string(), - url: url.to_string(), - }; - if let Ok(json) = serde_json::to_string(&info) { - write_storage(AUTHOR_KEY, &json); - } -} - -pub fn load_author() -> Option { - let json = read_storage(AUTHOR_KEY)?; - serde_json::from_str(&json).ok() -} - -pub fn save_pending_comment(post_id: i32, comment: PendingComment) { - let mut map: PendingMap = load_all_pending(); - let key = post_id.to_string(); - let list = map.entry(key).or_default(); - - if list.iter().any(|c| c.id == comment.id) { - return; - } - list.push(comment); - - if let Ok(json) = serde_json::to_string(&map) { - write_storage(PENDING_KEY, &json); - } -} - -pub fn load_pending_comments(post_id: i32) -> Vec { - let mut map = load_all_pending(); - let key = post_id.to_string(); - - let comments = map.remove(&key).unwrap_or_default(); - let non_expired: Vec = comments - .into_iter() - .filter(|c| !is_expired(&c.stored_at)) - .collect(); - - if !non_expired.is_empty() { - map.insert(key, non_expired.clone()); - } - if let Ok(json) = serde_json::to_string(&map) { - write_storage(PENDING_KEY, &json); - } - - non_expired -} - -pub fn remove_pending_ids(post_id: i32, ids: &[i64]) { - let mut map = load_all_pending(); - let key = post_id.to_string(); - - let should_remove = if let Some(comments) = map.get_mut(&key) { - comments.retain(|c| !ids.contains(&c.id)); - comments.is_empty() - } else { - false - }; - if should_remove { - map.remove(&key); - } - - if let Ok(json) = serde_json::to_string(&map) { - write_storage(PENDING_KEY, &json); - } -} - -pub fn prune_all_expired() { - let mut map = load_all_pending(); - let mut changed = false; - - let keys: Vec = map.keys().cloned().collect(); - for key in keys { - let should_remove = if let Some(comments) = map.get_mut(&key) { - let before = comments.len(); - comments.retain(|c| !is_expired(&c.stored_at)); - if comments.len() != before { - changed = true; - } - comments.is_empty() - } else { - false - }; - if should_remove { - map.remove(&key); - changed = true; - } - } - - if changed { - if let Ok(json) = serde_json::to_string(&map) { - write_storage(PENDING_KEY, &json); - } - } -} - -fn load_all_pending() -> PendingMap { - let json = match read_storage(PENDING_KEY) { - Some(j) => j, - None => return PendingMap::new(), - }; - serde_json::from_str(&json).unwrap_or_default() -} - -pub fn escape_html(input: &str) -> String { - input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -pub fn render_pending_content(md: &str) -> String { - let escaped = escape_html(md); - escaped.replace('\n', "
") -} -``` - -- [ ] **Step 2: Register module in `src/hooks/mod.rs`** - -```rust -pub mod delayed_loading; -pub mod comment_storage; -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/hooks/comment_storage.rs src/hooks/mod.rs -git commit -m "feat(hooks): add comment_storage module for localStorage persistence - -Provides save/load for author info (yggdrasil-comment-author) and -pending comments (yggdrasil-pending-comments) in localStorage. -- 7-day TTL with auto-pruning -- Per-post-id storage keyed by post_id string -- HTML escaping for pending content_md rendering -- All web_sys calls behind #[cfg(target_arch = \"wasm32\")]" -``` - ---- - -## Task 4: Update `CommentContext` and `CommentSection` for pending comments - -**Files:** -- Modify: `src/components/comments/section.rs` (full rewrite) - -- [ ] **Step 1: Rewrite `src/components/comments/section.rs`** - -```rust -use dioxus::prelude::*; - -use crate::api::comments::{get_comments, check_pending_status, CommentTreeResponse}; -use crate::hooks::comment_storage::{self, PendingComment}; -use crate::components::comments::form::CommentForm; -use crate::components::comments::list::CommentList; -use crate::components::skeletons::comment_skeleton::CommentListSkeleton; - -#[derive(Clone, Copy)] -pub struct CommentContext { - pub active_reply: Signal>, - pub refresh_trigger: Signal, - pub pending_comments: Signal>, -} - -#[component] -pub fn CommentSection(post_id: i32) -> Element { - let ctx = use_context_provider(|| { - let pending: Vec = comment_storage::load_pending_comments(post_id); - comment_storage::prune_all_expired(); - - CommentContext { - active_reply: Signal::new(None), - refresh_trigger: Signal::new(false), - pending_comments: Signal::new(pending), - } - }); - - use_future(move || { - let pending = ctx.pending_comments; - async move { - let ids: Vec = pending().iter().map(|c| c.id).collect(); - if ids.is_empty() { - return; - } - if let Ok(statuses) = check_pending_status(ids).await { - let to_remove: Vec = statuses - .into_iter() - .filter(|s| s.status != "pending") - .map(|s| s.id) - .collect(); - if !to_remove.is_empty() { - comment_storage::remove_pending_ids(post_id, &to_remove); - pending.write().retain(|c| !to_remove.contains(&c.id)); - } - } - } - }); - - let comments_resource = use_server_future(move || { - let _ = ctx.refresh_trigger; - get_comments(post_id) - })?; - - let data = comments_resource.read(); - - match data.as_ref().map(|r| r.as_ref()) { - Some(Ok(CommentTreeResponse { comments, count })) => { - let approved_count = *count; - let pending_count = ctx.pending_comments.read().len() as i64; - let total_count = approved_count + pending_count; - let has_any = approved_count > 0 || pending_count > 0; - rsx! { - div { class: "space-y-8", - h2 { class: "text-xl font-bold text-paper-primary", - "评论区 ({total_count})" - } - - CommentForm { post_id, parent_id: None } - - if !has_any { - p { class: "text-paper-tertiary text-center py-8", - "暂无评论,成为第一个评论的人吧!" - } - } else { - CommentList { - comments: comments.clone(), - pending: ctx.pending_comments.read().clone(), - post_id, - } - } - } - } - } - Some(Err(_)) => { - rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-8", - "评论加载失败" - } - } - } - None => rsx! { CommentListSkeleton {} }, - } -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: may show errors in `CommentList` (signature changed) — that's expected, fixed in Task 5. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/comments/section.rs -git commit -m "feat(comments): add pending_comments to CommentContext and sync on mount - -- Extend CommentContext with pending_comments Signal -- Load pending comments from localStorage on provider init -- Run check_pending_status on mount to prune non-pending entries -- Pass both approved and pending comments to CommentList -- Include pending count in section heading" -``` - ---- - -## Task 5: Update `CommentList` to merge approved + pending comments - -**Files:** -- Modify: `src/components/comments/list.rs` (full rewrite) - -- [ ] **Step 1: Rewrite `src/components/comments/list.rs`** - -```rust -use dioxus::prelude::*; - -use crate::models::comment::PublicComment; -use crate::hooks::comment_storage::PendingComment; -use crate::components::comments::item::CommentItem; -use crate::components::comments::pending_item::PendingCommentItem; - -enum MergedComment { - Approved(PublicComment), - Pending(PendingComment), -} - -fn merge_comments( - approved: Vec, - pending: Vec, -) -> Vec { - let mut merged: Vec = approved - .into_iter() - .map(MergedComment::Approved) - .chain(pending.into_iter().map(MergedComment::Pending)) - .collect(); - - merged.sort_by(|a, b| { - let time_a = match a { - MergedComment::Approved(c) => c.created_at_iso.as_str(), - MergedComment::Pending(c) => c.created_at.as_str(), - }; - let time_b = match b { - MergedComment::Approved(c) => c.created_at_iso.as_str(), - MergedComment::Pending(c) => c.created_at.as_str(), - }; - time_a.cmp(time_b) - }); - - merged -} - -#[component] -pub fn CommentList( - comments: Vec, - pending: Vec, - post_id: i32, -) -> Element { - let merged = merge_comments(comments, pending); - - rsx! { - div { class: "space-y-0 divide-y divide-gray-100 dark:divide-[#2a2a2a]", - for item in merged { - match item { - MergedComment::Approved(comment) => rsx! { - CommentItem { comment, post_id } - }, - MergedComment::Pending(comment) => rsx! { - PendingCommentItem { comment, post_id } - }, - } - } - } - } -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: error about missing `pending_item` module — that's expected, fixed in Task 6. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/comments/list.rs -git commit -m "feat(comments): merge approved and pending comments in CommentList - -- Accept both comments and pending props -- Merge into chronologically sorted list -- Route to CommentItem or PendingCommentItem per item type" -``` - ---- - -## Task 6: Create `PendingCommentItem` component - -**Files:** -- Create: `src/components/comments/pending_item.rs` -- Modify: `src/components/comments/mod.rs:1-5` - -- [ ] **Step 1: Create `src/components/comments/pending_item.rs`** - -```rust -use dioxus::prelude::*; - -use crate::hooks::comment_storage::{PendingComment, render_pending_content}; - -#[component] -pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element { - let _ = post_id; - - let depth = if comment.parent_id.is_none() && comment.depth > 0 { - 0 - } else { - comment.depth - }; - - let indent = depth.min(6) * 24; - let content_html = render_pending_content(&comment.content_md); - - let author_element = match &comment.author_url { - Some(url) if !url.is_empty() => rsx! { - a { - href: "{url}", - rel: "nofollow noopener", - target: "_blank", - class: "font-medium text-paper-primary hover:text-paper-accent transition-colors", - "{comment.author_name}" - } - }, - _ => rsx! { - span { class: "font-medium text-paper-primary", - "{comment.author_name}" - } - }, - }; - - rsx! { - div { - class: "py-4 opacity-70", - style: "margin-left: {indent}px", - - div { class: "flex gap-3", - img { - src: "{comment.avatar_url}", - alt: "{comment.author_name} 的头像", - loading: "lazy", - decoding: "async", - class: "w-8 h-8 rounded-full shrink-0 mt-0.5 bg-gray-200 dark:bg-[#2a2a2a]", - } - - div { class: "flex-1 min-w-0", - div { class: "flex items-center gap-1.5 text-sm mb-1.5 flex-wrap", - {author_element} - span { class: "text-paper-tertiary", "·" } - span { - class: "text-paper-tertiary", - "刚刚" - } - span { - class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", - "审核中" - } - } - - div { - class: "prose prose-sm dark:prose-invert max-w-none text-paper-secondary", - dangerous_inner_html: "{content_html}", - } - } - } - } - } -} -``` - -- [ ] **Step 2: Register module in `src/components/comments/mod.rs`** - -```rust -pub mod section; -pub mod form; -pub mod list; -pub mod item; -pub mod pending_item; -pub mod actions; -``` - -- [ ] **Step 3: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/components/comments/pending_item.rs src/components/comments/mod.rs -git commit -m "feat(comments): add PendingCommentItem component - -Renders pending (unapproved) comments with: -- opacity-70 for visual distinction -- amber '审核中' badge -- Client-side content_md rendering (HTML escape + newline→br) -- No reply button (server rejects replies to pending parents) -- Same depth/indent logic as approved comments" -``` - ---- - -## Task 7: Update `CommentForm` for auto-fill + localStorage save - -**Files:** -- Modify: `src/components/comments/form.rs` (full rewrite) - -- [ ] **Step 1: Rewrite `src/components/comments/form.rs`** - -```rust -use dioxus::prelude::*; - -use crate::api::comments::create_comment; -use crate::components::comments::section::CommentContext; -use crate::components::forms::{INPUT_CLASS, BUTTON_PRIMARY_CLASS, AlertBox}; -use crate::hooks::comment_storage::{self, PendingComment}; - -#[component] -pub fn CommentForm(post_id: i32, parent_id: Option) -> Element { - let ctx: CommentContext = use_context(); - let mut active_reply = ctx.active_reply; - let mut refresh_trigger = ctx.refresh_trigger; - let mut pending_comments = ctx.pending_comments; - - let mut author_name = use_signal(String::new); - let mut author_email = use_signal(String::new); - let mut author_url = use_signal(String::new); - let mut content_md = use_signal(String::new); - let mut honeypot = use_signal(String::new); - let mut submitting = use_signal(|| false); - let mut message = use_signal(|| Option::<(String, &'static str)>::None); - - use_effect(move || { - if !author_name().is_empty() { - return; - } - if let Some(info) = comment_storage::load_author() { - author_name.set(info.name); - author_email.set(info.email); - author_url.set(info.url); - } - }); - - if let Some(pid) = parent_id { - if active_reply() != Some(pid) { - return rsx! {}; - } - } - - let is_reply = parent_id.is_some(); - - rsx! { - div { - class: if is_reply { "mt-3 pt-3 border-t border-gray-100 dark:border-[#333]" } else { "" }, - role: "form", - aria_label: if is_reply { "回复评论" } else { "发表评论" }, - - if let Some((msg, variant)) = message() { - div { aria_live: "polite", - AlertBox { message: msg, variant } - } - } - - div { class: "space-y-3", - div { class: "grid grid-cols-1 sm:grid-cols-2 gap-3", - div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", - "昵称 *" - } - input { - class: INPUT_CLASS, - r#type: "text", - placeholder: "你的昵称", - value: "{author_name}", - disabled: submitting(), - oninput: move |e| author_name.set(e.value()), - } - } - div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", - "邮箱 *" - } - input { - class: INPUT_CLASS, - r#type: "email", - placeholder: "your@email.com", - value: "{author_email}", - disabled: submitting(), - oninput: move |e| author_email.set(e.value()), - } - } - } - div { - label { class: "block text-sm font-medium text-paper-secondary mb-1", - "网站" - } - input { - class: INPUT_CLASS, - r#type: "url", - placeholder: "https://example.com(可选)", - value: "{author_url}", - disabled: submitting(), - oninput: move |e| author_url.set(e.value()), - } - } - - textarea { - class: "{INPUT_CLASS} min-h-[100px] resize-y", - value: "{content_md}", - disabled: submitting(), - oninput: move |e| content_md.set(e.value()), - } - - p { class: "text-xs text-paper-tertiary", - "支持 Markdown 语法" - } - - textarea { - class: "hidden", - aria_hidden: "true", - tabindex: "-1", - value: "{honeypot}", - oninput: move |e| honeypot.set(e.value()), - } - - button { - class: BUTTON_PRIMARY_CLASS, - disabled: submitting(), - onclick: move |_| { - let post_id = post_id; - let parent_id = parent_id; - let name = author_name(); - let email = author_email(); - let url_val = author_url(); - let content = content_md(); - let hp = honeypot(); - - if !hp.is_empty() { - return; - } - - if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() { - message.set(Some(("请填写所有必填项".to_string(), "error"))); - return; - } - - submitting.set(true); - message.set(None); - - spawn(async move { - let result = create_comment( - post_id, - parent_id, - name.clone(), - email.clone(), - if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, - content.clone(), - ).await; - - submitting.set(false); - - match result { - Ok(resp) => { - if resp.success { - comment_storage::save_author( - &name, - &email, - &url_val, - ); - - if let Some(comment_id) = resp.comment_id { - let avatar_url = resp.avatar_url.unwrap_or_default(); - let depth = resp.depth.unwrap_or(0); - - let now = chrono::Utc::now().to_rfc3339(); - let pending = PendingComment { - id: comment_id, - parent_id, - depth, - author_name: name.clone(), - author_url: if url_val.trim().is_empty() { None } else { Some(url_val) }, - avatar_url, - content_md: content, - created_at: now.clone(), - stored_at: now, - }; - - comment_storage::save_pending_comment(post_id, pending.clone()); - pending_comments.write().push(pending); - } - - content_md.set(String::new()); - message.set(Some((resp.message, "success"))); - if parent_id.is_some() { - active_reply.set(None); - } - refresh_trigger.set(!refresh_trigger()); - } else { - message.set(Some((resp.message, "error"))); - } - } - Err(_) => { - message.set(Some(("提交失败,请稍后重试".to_string(), "error"))); - } - } - }); - }, - - if submitting() { - "提交中…" - } else if is_reply { - "回复" - } else { - "发表评论" - } - } - } - } - } -} -``` - -- [ ] **Step 2: Verify compilation** - -Run: `cargo check 2>&1 | head -30` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/components/comments/form.rs -git commit -m "feat(comments): auto-fill form from localStorage and save pending comments - -- Load author info from localStorage on mount via use_effect -- Save author info + pending comment to localStorage on successful submit -- Use server-returned avatar_url and depth (no client-side md5 needed) -- Push pending comment to CommentContext signal for immediate render -- Pre-fill works for both main form and reply forms" -``` - ---- - -## Task 8: Run full build + tests - -- [ ] **Step 1: Run cargo test** - -Run: `cargo test 2>&1 | tail -20` -Expected: all tests pass. - -- [ ] **Step 2: Run cargo clippy** - -Run: `cargo clippy 2>&1 | tail -20` -Expected: no warnings on changed files. - -- [ ] **Step 3: Run cargo check** - -Run: `cargo check 2>&1 | tail -10` -Expected: no errors. - -- [ ] **Step 4: Final commit if any fixups needed** - -```bash -git add -A -git commit -m "chore: fix compilation/lint issues from comment localStorage feature" -``` diff --git a/docs/superpowers/plans/2026-06-22-blur-up-images.md b/docs/superpowers/plans/2026-06-22-blur-up-images.md deleted file mode 100644 index 4794770..0000000 --- a/docs/superpowers/plans/2026-06-22-blur-up-images.md +++ /dev/null @@ -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> = LazyLock::new(|| { - let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS") - .ok() - .and_then(|s| s.parse::().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#"tt"#; - 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. 生成 包裹两层 img -#[cfg(feature = "server")] -fn wrap_images_with_blur(html: &str) -> String { - use regex::Regex; - use std::sync::LazyLock; - - // 匹配 pulldown-cmark 产出的 ...... - // pulldown-cmark 格式可控:src 在前,alt 在后,属性用双引号 - static IMG_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#""#).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!( - "", - 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#"

test

"#; - 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#"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 条验收标准全部满足 diff --git a/docs/superpowers/plans/2026-06-22-upload-placeholder.md b/docs/superpowers/plans/2026-06-22-upload-placeholder.md deleted file mode 100644 index cb5f9ae..0000000 --- a/docs/superpowers/plans/2026-06-22-upload-placeholder.md +++ /dev/null @@ -1,1276 +0,0 @@ -# 编辑器图片上传占位符与失败提示 实现计划 - -> **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:** 在文章编辑器上传图片时显示本地预览占位符 + loading 遮罩,上传失败显示错误卡片(带重试/移除)+ 顶部堆叠提示,并在有未完成上传时阻止保存。 - -**Architecture:** JS 主导——自定义 Tiptap Image 扩展(NodeView 承载上传状态属性)+ UploadCoordinator(集中上传逻辑,按 upload-id 定位节点更新属性)。Rust 侧通过 500ms 轮询 `window.__tiptap_uploads` 全局对象消费上传事件与计数,驱动顶部提示和保存拦截。 - -**Tech Stack:** TypeScript(Tiptap 3.25.0 NodeView API)、Rust/Dioxus 0.7(eval 桥接 + 信号)、Tailwind CSS - -**前置 spec:** `docs/superpowers/specs/2026-06-22-upload-placeholder-design.md` - -**关键实现约定(贯穿所有任务):** -- NodeView 用 plain class 实现(参考 `@tiptap/core` 的 `ResizableNodeView`),不继承 NodeView 基类,不使用 React/Vue。 -- uploadId 生成用 `crypto.randomUUID()` 带 fallback:`crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)`(兼容非安全上下文)。 -- `onImageUpload: (file: File) => Promise` 的契约不变(resolve 服务端 URL),coordinator 内部消费它。 - ---- - -## 文件结构 - -| 文件 | 责任 | 操作 | -|------|------|------| -| `libs/tiptap-editor/src/upload-coordinator.ts` | UploadCoordinator 类:管理 pending Map、发起上传、按 id 定位更新/删除节点、维护 `window.__tiptap_uploads` | 新建 | -| `libs/tiptap-editor/src/upload-image.ts` | 自定义 Image 扩展(继承属性 + 三个上传属性 + NodeView 类) | 新建 | -| `libs/tiptap-editor/src/index.ts` | 替换 Image 为自定义扩展;FileHandler/SlashCommand 走 coordinator;暴露 removeUploadByUploadId | 改 | -| `libs/tiptap-editor/src/slash-command.ts` | 上传命令调 coordinator.insertUploading | 改 | -| `libs/tiptap-editor/src/style.css` | NodeView 三种态样式(遮罩/spinner/错误卡片/按钮) | 改 | -| `src/pages/admin/write.rs` | signal、轮询 effect、顶部提示、保存拦截、fetch 改造 | 改 | - -任务顺序:先建 JS 侧(coordinator → upload-image 扩展 → index 接线 → slash-command → 样式),再做 Rust 侧(signal/轮询 → 提示渲染 → 保存拦截 → fetch 改造),最后整体验证。 - ---- - -## Task 1: UploadCoordinator 基础类(pending Map + insertUploading + removeUpload) - -**Files:** -- Create: `libs/tiptap-editor/src/upload-coordinator.ts` - -此任务建立 coordinator 的核心:持有 pending Map、生成 uploadId、创建 blob URL、按 id 定位节点删除。上传发起逻辑(runUpload)在 Task 2 加入,notifyRust 在 Task 3 加入。先让 coordinator 能管理 pending 状态和节点删除。 - -- [ ] **Step 1: 创建 upload-coordinator.ts 骨架与类型** - -创建 `libs/tiptap-editor/src/upload-coordinator.ts`: - -```typescript -import type { Editor } from '@tiptap/core' - -/** pending 上传条目:保留 File 供重试,blobUrl 供本地预览。 */ -interface UploadEntry { - file: File - blobUrl: string - fileName: string -} - -/** coordinator 推给 Rust 的事件(通过 window.__tiptap_uploads.events)。 */ -export interface UploadEvent { - kind: 'error' | 'success' | 'removed' - uploadId: string - fileName: string - errorMsg?: string - ts: number -} - -/** 生成 uploadId,兼容非安全上下文(无 crypto.randomUUID 时)。 */ -function genUploadId(): string { - return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) -} - -/** - * 上传协调器:集中管理图片上传生命周期。 - * - * 职责: - * - 生成 uploadId、创建 blob URL、插入占位符节点 - * - 发起上传,成功更新节点 src、失败转 error 态 - * - 按 upload-id 定位节点更新/删除(上传完成时光标早已移走) - * - 维护 window.__tiptap_uploads 供 Rust 轮询 - * - * pending Map 保留 File 对象直到上传成功或显式移除,支持无限重试。 - */ -export class UploadCoordinator { - private pending = new Map() - - constructor( - private editor: Editor, - private onImageUpload: (file: File) => Promise, - ) {} - - /** - * 插入上传中占位符并发起上传。 - * pos 省略时插入当前选区。 - */ - insertUploading(file: File, pos?: number): void { - const uploadId = genUploadId() - const blobUrl = URL.createObjectURL(file) - this.pending.set(uploadId, { file, blobUrl, fileName: file.name }) - - this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, { - type: 'image', - attrs: { - src: blobUrl, - 'data-upload-state': 'uploading', - 'data-upload-id': uploadId, - }, - }).run() - } - - /** 按 uploadId 删除节点(revoke blob、清 pending)。NodeView 移除按钮 / Rust ×关闭 共用。 */ - removeUpload(uploadId: string): boolean { - const entry = this.pending.get(uploadId) - if (!entry) return false - this.removeNodeByUploadId(uploadId) - URL.revokeObjectURL(entry.blobUrl) - this.pending.delete(uploadId) - return true - } - - /** 按 uploadId 在文档中定位节点并删除。 */ - private removeNodeByUploadId(uploadId: string): void { - let targetPos: number | null = null - let nodeSize = 0 - this.editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) { - targetPos = pos - nodeSize = node.nodeSize - return false - } - return true - }) - if (targetPos !== null) { - const tr = this.editor.state.tr.delete(targetPos, targetPos + nodeSize) - this.editor.view.dispatch(tr) - } - } - - /** 按 uploadId 定位节点并合并更新属性。 */ - private updateNodeAttrs(uploadId: string, attrs: Record): void { - let targetPos: number | null = null - let oldAttrs: Record | null = null - this.editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) { - targetPos = pos - oldAttrs = node.attrs - return false - } - return true - }) - if (targetPos !== null && oldAttrs) { - const tr = this.editor.state.tr.setNodeMarkup( - targetPos, - undefined, - { ...oldAttrs, ...attrs }, - ) - this.editor.view.dispatch(tr) - } - } - - /** pending Map 仅供内部/测试访问。 */ - hasPending(uploadId: string): boolean { - return this.pending.has(uploadId) - } -} -``` - -- [ ] **Step 2: 验证 TypeScript 编译** - -Run: `cd libs/tiptap-editor && npx tsc --noEmit src/upload-coordinator.ts 2>&1 | head -20` -Expected: 无错误(可能有 "cannot find module @tiptap/core" 若 tsc 单文件不解析 node_modules,改用 `npx tsc --noEmit -p .` 若有 tsconfig;若无 tsconfig,跳过此步靠 vite build 兜底)。实际项目无 tsconfig.json,依赖 vite 的 esbuild 转译,此步改为 Step 3 的 vite build 验证。 - -注:`libs/tiptap-editor/` 无独立 tsconfig,类型检查依赖最终 `vite build`。此文件此刻未被 import,vite build 不会包含它——类型错误要到 Task 4 接线后才暴露。**此步仅确认文件创建成功**。 - -Run: `test -f libs/tiptap-editor/src/upload-coordinator.ts && echo "created"` -Expected: `created` - -- [ ] **Step 3: Commit** - -```bash -git add libs/tiptap-editor/src/upload-coordinator.ts -git commit -m "feat(editor): add UploadCoordinator skeleton with pending map and node removal" -``` - ---- - -## Task 2: coordinator 的上传逻辑(runUpload + retryUpload) - -**Files:** -- Modify: `libs/tiptap-editor/src/upload-coordinator.ts` - -加入核心上传发起逻辑和重试。Task 1 建好的 `insertUploading` 此时调用 `runUpload`,重试从 pending 取回 File 重跑。 - -- [ ] **Step 1: 在 insertUploading 末尾追加 runUpload 调用** - -在 `upload-coordinator.ts` 的 `insertUploading` 方法 `.run()` 之后追加: - -```typescript - this.runUpload(uploadId) -``` - -完整方法变为: -```typescript - insertUploading(file: File, pos?: number): void { - const uploadId = genUploadId() - const blobUrl = URL.createObjectURL(file) - this.pending.set(uploadId, { file, blobUrl, fileName: file.name }) - - this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, { - type: 'image', - attrs: { - src: blobUrl, - 'data-upload-state': 'uploading', - 'data-upload-id': uploadId, - }, - }).run() - - this.runUpload(uploadId) - } -``` - -- [ ] **Step 2: 添加 runUpload 私有方法** - -在 `UploadCoordinator` 类内(`insertUploading` 之后)添加: - -```typescript - /** 核心上传逻辑:成功更新 src + 清上传属性,失败转 error 态。 */ - private async runUpload(uploadId: string): Promise { - const entry = this.pending.get(uploadId) - if (!entry) return - try { - const url = await this.onImageUpload(entry.file) - // 成功:替换 src,清除上传状态属性 - this.updateNodeAttrs(uploadId, { - src: url, - 'data-upload-state': null, - 'data-upload-id': null, - 'data-error-msg': null, - }) - URL.revokeObjectURL(entry.blobUrl) - this.pending.delete(uploadId) - } catch (err) { - const msg = this.extractErrorMessage(err) - this.updateNodeAttrs(uploadId, { - 'data-upload-state': 'error', - 'data-error-msg': msg, - }) - } - } -``` - -- [ ] **Step 3: 添加 retryUpload 公开方法** - -在 `runUpload` 之后添加(NodeView 重试按钮调用): - -```typescript - /** 重试:从 pending 取回原 File,节点转回 uploading,重跑上传。 */ - retryUpload(uploadId: string): void { - const entry = this.pending.get(uploadId) - if (!entry) return - this.updateNodeAttrs(uploadId, { - 'data-upload-state': 'uploading', - 'data-error-msg': null, - }) - this.runUpload(uploadId) - } -``` - -- [ ] **Step 4: 添加 extractErrorMessage 私有方法** - -在 `updateNodeAttrs` 之后添加: - -```typescript - /** 从错误对象提取消息。改造后的 fetch 直接抛服务端中文(如"文件超过大小限制")。 */ - private extractErrorMessage(err: unknown): string { - if (err instanceof Error) return err.message - return String(err) - } -``` - -- [ ] **Step 5: Commit** - -```bash -git add libs/tiptap-editor/src/upload-coordinator.ts -git commit -m "feat(editor): add runUpload and retryUpload to UploadCoordinator" -``` - ---- - -## Task 3: coordinator 的 Rust 通知(notifyRust + window 全局) - -**Files:** -- Modify: `libs/tiptap-editor/src/upload-coordinator.ts` - -加入 `window.__tiptap_uploads` 全局对象维护逻辑。coordinator 在每次状态变化后追加 event 并重算 counts。 - -- [ ] **Step 1: 添加 notifyRust 私有方法** - -在 `extractErrorMessage` 之后添加: - -```typescript - /** - * 追加事件到 window.__tiptap_uploads.events,并重算 counts。 - * Rust 侧 500ms 轮询消费 events 并读取 counts。 - */ - private notifyRust(event: Omit): void { - const w = window as unknown as { __tiptap_uploads?: { events: UploadEvent[]; counts: { uploading: number; error: number } } } - if (!w.__tiptap_uploads) { - w.__tiptap_uploads = { events: [], counts: { uploading: 0, error: 0 } } - } - w.__tiptap_uploads.events.push({ ...event, ts: Date.now() }) - - // 重算 counts:遍历文档统计当前 uploading/error 占位符 - let uploading = 0 - let 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 - }) - w.__tiptap_uploads.counts = { uploading, error } - } -``` - -- [ ] **Step 2: 在 runUpload 成功分支追加 notifyRust** - -`runUpload` 成功分支(`this.pending.delete(uploadId)` 之后)追加: - -```typescript - this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName }) -``` - -- [ ] **Step 3: 在 runUpload 失败分支追加 notifyRust** - -`runUpload` catch 分支(`updateNodeAttrs(...error...)` 之后)追加: - -```typescript - this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg }) -``` - -- [ ] **Step 4: 在 removeUpload 追加 notifyRust** - -`removeUpload` 方法(`this.pending.delete(uploadId)` 之后,`return true` 之前)追加: - -```typescript - this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName }) -``` - -- [ ] **Step 5: Commit** - -```bash -git add libs/tiptap-editor/src/upload-coordinator.ts -git commit -m "feat(editor): maintain window.__tiptap_uploads for Rust polling" -``` - ---- - -## Task 4: 自定义 Image 扩展与 NodeView 类(upload-image.ts) - -**Files:** -- Create: `libs/tiptap-editor/src/upload-image.ts` - -核心:自定义 Image 扩展继承父类属性 + 三个上传属性 + NodeView 类渲染三种态。NodeView 是 plain class,参考 `@tiptap/core` 的 ResizableNodeView 形态。 - -- [ ] **Step 1: 创建 upload-image.ts — 自定义扩展与属性** - -创建 `libs/tiptap-editor/src/upload-image.ts`: - -```typescript -import { Image, mergeAttributes, type Editor } from '@tiptap/core' -import type { Node as PMNode } from '@tiptap/pm/model' - -/** NodeView 按钮点击回调注入接口。 */ -export interface UploadNodeViewCallbacks { - onRetry: (uploadId: string) => void - onRemove: (uploadId: string) => void -} - -/** - * 上传图片占位符 NodeView:plain class,实现 ProseMirror NodeView 接口。 - * - * 根据 data-upload-state 渲染三种态: - * - null:普通 img - * - uploading:img(本地 blob 预览)+ 遮罩(spinner + "上传中…") - * - error:img(灰化)+ 遮罩(⚠ + 错误文案 + 重试/移除按钮) - * - * NodeView 纯渲染,不发起上传——按钮点击转发给注入的 callbacks(实际是 coordinator)。 - * 属性变化时 ProseMirror 调 update(node),NodeView 比较新旧 data-upload-state 重渲染遮罩。 - */ -class UploadImageNodeView { - private editor: Editor - private node: PMNode - private getPos: () => number | undefined - private callbacks: UploadNodeViewCallbacks - - private container: HTMLDivElement - private img: HTMLImageElement - private overlay: HTMLDivElement | null = null - - constructor(opts: { - node: PMNode - editor: Editor - getPos: () => number | undefined - HTMLAttributes: Record - callbacks: UploadNodeViewCallbacks - }) { - this.node = opts.node - this.editor = opts.editor - this.getPos = opts.getPos - this.callbacks = opts.callbacks - - this.container = document.createElement('div') - this.container.classList.add('upload-image-container') - - this.img = document.createElement('img') - this.img.draggable = false - const merged = mergeAttributes(opts.HTMLAttributes) - Object.entries(merged).forEach(([k, v]) => { - if (v != null) this.img.setAttribute(k, String(v)) - }) - const src = this.node.attrs.src - if (src != null) this.img.src = src - this.container.appendChild(this.img) - - this.renderOverlay() - } - - get dom(): HTMLElement { - return this.container - } - - get contentDOM(): HTMLElement | null { - return null - } - - /** ProseMirror 调用:节点属性变化时重渲染遮罩。返回 false 拒绝非同类节点。 */ - update(node: PMNode): boolean { - if (node.type !== this.node.type) return false - const oldState = this.node.attrs['data-upload-state'] - const newState = node.attrs['data-upload-state'] - const oldSrc = this.node.attrs.src - const newSrc = node.attrs.src - this.node = node - if (oldSrc !== newSrc && newSrc != null) { - this.img.src = newSrc - } - if (oldState !== newState || this.overlay === null) { - this.renderOverlay() - } - return true - } - - /** 遮罩内按钮点击不被 ProseMirror 当编辑,避免误触发事务。 */ - ignoreMutation(): boolean { - return true - } - - /** 事件不被编辑器 stopEvent 拦截(按钮点击要响应)。 */ - stopEvent(event: Event): boolean { - return false - } - - /** 根据 data-upload-state 渲染遮罩。null 时移除遮罩。 */ - private renderOverlay(): void { - if (this.overlay) { - this.overlay.remove() - this.overlay = null - } - const state = this.node.attrs['data-upload-state'] - if (state == null) { - this.container.classList.remove('is-uploading', 'is-error') - return - } - this.overlay = document.createElement('div') - this.overlay.classList.add('upload-image-overlay') - if (state === 'uploading') { - this.container.classList.add('is-uploading') - this.container.classList.remove('is-error') - this.overlay.innerHTML = - '
上传中…
' - } else if (state === 'error') { - this.container.classList.add('is-error') - this.container.classList.remove('is-uploading') - const msg = this.node.attrs['data-error-msg'] || '上传失败' - this.overlay.innerHTML = - '
' + - '
' + - '
' + - '' + - '' + - '
' - const msgEl = this.overlay.querySelector('.upload-error-msg') as HTMLElement - msgEl.textContent = msg - const uploadId = this.node.attrs['data-upload-id'] as string | null - this.overlay.querySelector('.upload-btn-retry')?.addEventListener('click', (e) => { - e.preventDefault() - if (uploadId) this.callbacks.onRetry(uploadId) - }) - this.overlay.querySelector('.upload-btn-remove')?.addEventListener('click', (e) => { - e.preventDefault() - if (uploadId) this.callbacks.onRemove(uploadId) - }) - } - this.container.appendChild(this.overlay) - } - - destroy(): void { - this.overlay?.remove() - this.overlay = null - this.container.remove() - } -} - -/** coordinator 引用(由 index.ts 在创建 editor 前注入)。 */ -let coordinatorRef: { retryUpload: (id: string) => void; removeUpload: (id: string) => boolean } | null = null - -/** index.ts 注入 coordinator,供 NodeView 的 onRetry/onRemove 调用。 */ -export function setUploadCoordinator(c: typeof coordinatorRef): void { - coordinatorRef = c -} - -/** - * 自定义 Image 扩展:继承父类属性,加三个上传状态属性,用自定义 NodeView。 - */ -export const UploadImage = Image.configure({ allowBase64: true }).extend({ - addAttributes() { - return { - ...this.parent?.(), - 'data-upload-state': { - default: null, - parseHTML: (el) => el.getAttribute('data-upload-state'), - renderHTML: (attrs) => { - const v = attrs['data-upload-state'] - return v == null ? {} : { 'data-upload-state': v } - }, - }, - 'data-upload-id': { - default: null, - parseHTML: (el) => el.getAttribute('data-upload-id'), - renderHTML: (attrs) => { - const v = attrs['data-upload-id'] - return v == null ? {} : { 'data-upload-id': v } - }, - }, - 'data-error-msg': { - default: null, - parseHTML: (el) => el.getAttribute('data-error-msg'), - renderHTML: (attrs) => { - const v = attrs['data-error-msg'] - return v == null ? {} : { 'data-error-msg': v } - }, - }, - } - }, - - addNodeView() { - return ({ node, getPos, HTMLAttributes, editor }) => { - return new UploadImageNodeView({ - node, - editor, - getPos, - HTMLAttributes, - callbacks: { - onRetry: (id) => coordinatorRef?.retryUpload(id), - onRemove: (id) => coordinatorRef?.removeUpload(id), - }, - }) - } - }, -}) -``` - -- [ ] **Step 2: Commit** - -```bash -git add libs/tiptap-editor/src/upload-image.ts -git commit -m "feat(editor): add custom Image extension with upload-state NodeView" -``` - ---- - -## Task 5: index.ts 接线(coordinator + 自定义 Image + 移除旧 onPaste/onDrop 逻辑) - -**Files:** -- Modify: `libs/tiptap-editor/src/index.ts` - -替换 Image 为自定义扩展;实例化 coordinator 并注入给 NodeView;FileHandler.onPaste/onDrop 改走 coordinator;暴露 removeUploadByUploadId 供 Rust 调用。 - -- [ ] **Step 1: 添加 import** - -在 `index.ts` 顶部 import 区(`import './style.css'` 之前)添加: - -```typescript -import { UploadCoordinator } from './upload-coordinator' -import { UploadImage, setUploadCoordinator } from './upload-image' -``` - -- [ ] **Step 2: 在 TiptapEditorInstance 添加 coordinator 私有字段** - -在 `private toggleButton: HTMLButtonElement | null = null`(第 29 行)之后添加: - -```typescript - private coordinator: UploadCoordinator | null = null -``` - -- [ ] **Step 3: 替换 Image 为 UploadImage,在 editor 创建后实例化 coordinator** - -将 `extensions` 数组中的 `Image.configure({ allowBase64: true }),`(第 84 行)替换为: - -```typescript - UploadImage, -``` - -然后在 `this.editor = new Editor({...})` 赋值之后(第 141 行 `})` 之后,即 `onUpdate`/`onBlur` 等配置结束、Editor 创建完成的 `})` 闭合处之后)添加: - -```typescript - // 创建上传协调器,注入给 NodeView 的 onRetry/onRemove - if (this.options.onImageUpload) { - this.coordinator = new UploadCoordinator(this.editor, this.options.onImageUpload) - setUploadCoordinator(this.coordinator) - } -``` - -- [ ] **Step 4: 改写 FileHandler.onPaste 走 coordinator** - -将 `onPaste`(第 93-105 行)替换为: - -```typescript - onPaste: (editor, files) => { - if (this.coordinator) { - files.forEach((file) => this.coordinator!.insertUploading(file)) - } - }, -``` - -- [ ] **Step 5: 改写 FileHandler.onDrop 走 coordinator** - -将 `onDrop`(第 107-123 行)替换为: - -```typescript - onDrop: (editor, files, pos) => { - if (this.coordinator) { - files.forEach((file) => this.coordinator!.insertUploading(file, pos)) - } - }, -``` - -- [ ] **Step 6: 添加 removeUploadByUploadId 公开方法** - -在 `destroy()` 方法(第 238 行附近)之前添加: - -```typescript - /** Rust 侧"×关闭"提示时调用(通过 eval)。返回是否成功删除。 */ - removeUploadByUploadId(uploadId: string): boolean { - return this.coordinator?.removeUpload(uploadId) ?? false - } -``` - -- [ ] **Step 7: 在 destroy 中清理 coordinator** - -在 `destroy()` 方法(第 238-246 行)的 `this.editor = null` 之后添加: - -```typescript - this.coordinator = null -``` - -- [ ] **Step 8: 构建验证** - -Run: `make build-editor-incremental` -Expected: `✓ built in ...`,无错误。若有 TS 错误,修正后再构建。 - -- [ ] **Step 9: Commit** - -```bash -git add libs/tiptap-editor/src/index.ts -git commit -m "feat(editor): wire UploadCoordinator and custom Image in TiptapEditorInstance" -``` - ---- - -## Task 6: slash-command.ts 上传命令走 coordinator - -**Files:** -- Modify: `libs/tiptap-editor/src/slash-command.ts` - -当前斜杠"上传图片"命令直接调 `onImageUpload(file).then(setImage).catch(console.error)`。改为调 coordinator.insertUploading。但 slash-command 扩展拿不到 coordinator——通过新增 `onInsertUploading` option 注入。 - -- [ ] **Step 1: 扩展 SlashCommandOptions,加 onInsertUploading** - -在 `slash-command.ts` 的 `SlashCommandOptions` 接口(约第 19-21 行)改为: - -```typescript -export interface SlashCommandOptions { - onImageUpload?: (file: File) => Promise - /** 由 index.ts 注入:直接调 coordinator.insertUploading(走占位符 + 上传)。 */ - onInsertUploading?: (file: File) => void -} -``` - -- [ ] **Step 2: addOptions 返回新字段** - -将 `addOptions()` 的返回(约第 33-35 行)改为: - -```typescript - addOptions() { - return { - onImageUpload: undefined, - onInsertUploading: undefined, - } - }, -``` - -- [ ] **Step 3: 上传图片命令改调 onInsertUploading** - -在 `addProseMirrorPlugins` 内,"上传图片"命令的 command(约第 119-137 行)改为: - -```typescript - { - title: '上传图片', - description: '从本地选择并上传图片', - icon: '📤', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).run() - const input = document.createElement('input') - input.type = 'file' - input.accept = 'image/jpeg,image/png,image/gif,image/webp' - input.addEventListener('change', () => { - const file = input.files?.[0] - if (!file) return - // 优先走 coordinator(占位符 + 上传),否则退回直接上传(无占位符) - if (this.options.onInsertUploading) { - this.options.onInsertUploading(file) - } else if (uploadFn) { - uploadFn(file) - .then((url) => editor.chain().focus().setImage({ src: url }).run()) - .catch((err) => { - const msg = err instanceof Error ? err.message : String(err) - console.error('[SlashCommand] Upload failed:', msg) - }) - } - }) - input.click() - }, - }, -``` - -注意:`uploadFn` 变量(第 38 行 `const uploadFn = this.options.onImageUpload`)保留,作为 "是否显示上传图片命令" 的判断条件(下一行的 `if (uploadFn)`)。新的 `onInsertUploading` 是 coordinator 走向,`uploadFn` 仅用于决定命令可见性。 - -- [ ] **Step 4: index.ts 注入 onInsertUploading 给 SlashCommand** - -在 `index.ts` 的 `SlashCommand.configure({...})`(约第 88-90 行)改为: - -```typescript - SlashCommand.configure({ - onImageUpload: this.options.onImageUpload, - onInsertUploading: this.coordinator - ? (file) => this.coordinator!.insertUploading(file) - : undefined, - }), -``` - -- [ ] **Step 5: 构建验证** - -Run: `make build-editor-incremental` -Expected: `✓ built in ...` - -- [ ] **Step 6: Commit** - -```bash -git add libs/tiptap-editor/src/slash-command.ts libs/tiptap-editor/src/index.ts -git commit -m "feat(editor): route slash-command image upload through coordinator" -``` - ---- - -## Task 7: NodeView 样式(upload-image overlay/spinner/error card) - -**Files:** -- Modify: `libs/tiptap-editor/src/style.css` - -在现有 Image 样式块(第 361-373 行)之后追加 NodeView 三种态样式。 - -- [ ] **Step 1: 追加亮色样式** - -在 `style.css` 第 373 行(`.ProseMirror-selectednode` 块之后)追加: - -```css -/* 上传图片占位符 NodeView */ -.upload-image-container { - position: relative; - display: inline-block; - max-width: 100%; -} -.upload-image-container img { - max-width: 100%; - height: auto; - border-radius: 6px; - margin: 1em 0; - display: block; -} -.upload-image-container.is-error img { - opacity: 0.5; - filter: grayscale(0.5); -} -.upload-image-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - border-radius: 6px; - pointer-events: none; -} -.upload-image-container.is-error .upload-image-overlay { - background: rgba(0, 0, 0, 0.35); - pointer-events: auto; -} -.upload-spinner { - width: 28px; - height: 28px; - border: 3px solid rgba(255, 255, 255, 0.4); - border-top-color: #fff; - border-radius: 50%; - animation: upload-spin 0.8s linear infinite; -} -@keyframes upload-spin { - to { transform: rotate(360deg); } -} -.upload-overlay-text { - color: #fff; - font-size: 13px; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); -} -.upload-error-icon { - color: #fff; - font-size: 22px; - line-height: 1; -} -.upload-error-msg { - color: #fff; - font-size: 13px; - text-align: center; - padding: 0 12px; - max-width: 90%; - word-break: break-word; -} -.upload-error-actions { - display: flex; - gap: 8px; -} -.upload-btn { - padding: 3px 12px; - font-size: 12px; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.15); - color: #fff; - cursor: pointer; -} -.upload-btn:hover { - background: rgba(255, 255, 255, 0.3); -} -.upload-btn-remove { - border-color: rgba(254, 202, 202, 0.7); - color: #fecaca; -} -.upload-btn-remove:hover { - background: rgba(239, 68, 68, 0.3); -} -``` - -- [ ] **Step 2: 追加暗色样式** - -在 `style.css` 暗色 Image 块(约第 419-422 行 `.dark ... img.ProseMirror-selectednode`)之后追加: - -```css -.dark .upload-image-container.is-error img { - opacity: 0.45; -} -.dark .upload-btn { - border-color: rgba(200, 200, 200, 0.4); -} -.dark .upload-btn-remove { - border-color: rgba(239, 68, 68, 0.5); -} -``` - -- [ ] **Step 3: 构建验证** - -Run: `make build-editor-incremental` -Expected: `✓ built in ...`,`public/tiptap/editor.css` 包含新选择器。 - -- [ ] **Step 4: Commit** - -```bash -git add libs/tiptap-editor/src/style.css -git commit -m "style(editor): add upload placeholder NodeView styles (overlay/spinner/error)" -``` - ---- - -## Task 8: write.rs — 新增 signal 与类型定义 - -**Files:** -- Modify: `src/pages/admin/write.rs` - -加入 `UploadsInFlight` 类型、`uploads_in_flight` 与 `upload_errors` signal。为后续轮询和渲染做准备。 - -- [ ] **Step 1: 添加类型定义** - -在 `write.rs` 的 `use` 语句之后、`Write()` 组件之前(约第 20 行附近,`use crate::router::Route;` 之后)添加: - -```rust -/// 当前编辑器内进行中的上传计数(来自轮询 counts)。 -#[derive(Clone, Copy, Default)] -struct UploadsInFlight { - uploading: u32, - error: u32, -} - -/// 顶部堆叠的上传失败提示条目。 -#[derive(Clone, PartialEq)] -struct UploadErrorEntry { - id: String, - file_name: String, - message: String, -} -``` - -- [ ] **Step 2: 添加 signal 声明** - -在 `write_editor` 的 signal 声明块(第 69 行 `let mut edit_post = use_signal(|| None::);` 之后)添加: - -```rust - // 上传状态:当前进行中计数 + 顶部失败提示堆叠 - let mut uploads_in_flight = use_signal(UploadsInFlight::default); - let mut upload_errors: Signal> = use_signal(Vec::new); -``` - -- [ ] **Step 3: 编译验证** - -Run: `cargo check 2>&1 | tail -10` -Expected: 无错误(signal 类型正确,可能有 `unused` 警告,后续任务会消费)。 - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/admin/write.rs -git commit -m "feat(write): add upload state signals for placeholder tracking" -``` - ---- - -## Task 9: write.rs — 轮询 effect 消费 window.__tiptap_uploads - -**Files:** -- Modify: `src/pages/admin/write.rs` - -新增 `use_future` 500ms 轮询 `window.__tiptap_uploads`,消费 events 更新 signal。 - -- [ ] **Step 1: 添加 use_future 轮询** - -在 ready-polling `use_effect`(第 201-244 行)之后添加新的 `use_future`: - -```rust - // 轮询 window.__tiptap_uploads,消费上传事件并更新 signal - use_future(move || { - let mut uploads_in_flight = uploads_in_flight; - let mut upload_errors = upload_errors; - async move { - #[cfg(target_arch = "wasm32")] - { - use std::collections::HashSet; - let mut seen_error_ids: HashSet = HashSet::new(); - loop { - // 500ms 间隔 - if let Ok(promise_val) = js_sys::eval("new Promise(r => setTimeout(r, 500))") { - if let Ok(promise) = promise_val.dyn_into::() { - let _ = wasm_bindgen_futures::JsFuture::from(promise).await; - } - } - - // 读取并清空 events,读取 counts - 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 { - #[derive(serde::Deserialize)] - struct SnapshotEvent { - kind: String, - #[serde(rename = "uploadId")] - upload_id: String, - #[serde(rename = "fileName")] - file_name: String, - #[serde(rename = "errorMsg")] - error_msg: Option, - } - #[derive(serde::Deserialize)] - struct Snapshot { - events: Vec, - counts: Counts, - } - #[derive(serde::Deserialize, Default, Clone, Copy)] - struct Counts { - uploading: u32, - error: u32, - } - if let Ok(parsed) = serde_json::from_str::(&json) { - for ev in parsed.events { - match ev.kind.as_str() { - "error" => { - if !seen_error_ids.contains(&ev.upload_id) { - seen_error_ids.insert(ev.upload_id.clone()); - upload_errors.write().push(UploadErrorEntry { - id: ev.upload_id, - file_name: ev.file_name, - message: ev.error_msg.unwrap_or_else(|| "上传失败".to_string()), - }); - } - } - "success" | "removed" => { - seen_error_ids.remove(&ev.upload_id); - upload_errors.write().retain(|e| e.id != ev.upload_id); - } - _ => {} - } - } - uploads_in_flight.set(UploadsInFlight { - uploading: parsed.counts.uploading, - error: parsed.counts.error, - }); - } - } - } - } - } - }); -``` - -注:闭包开头显式 capture `uploads_in_flight` 和 `upload_errors` 两个 mutable signal(Dioxus 的 use_future 闭包需捕获要写的 signal)。`serde::Deserialize` 派生需要 `serde` 在依赖中——项目已用 serde,确认 `Cargo.toml` 的 `[dependencies] serde = { version = "...", features = ["derive"] }`。若 features 无 derive,用 `serde_json::Value` 手动解析代替派生。 - -- [ ] **Step 2: 确认 serde derive feature 可用** - -Run: `grep -A2 '^serde' Cargo.toml | head -5` -Expected: 看到 `features = ["derive"]` 或类似。若无 derive,将 Step 1 的派生改为 `serde_json::Value` 手动解析(`v["events"][i]["kind"].as_str()` 等)。 - -- [ ] **Step 3: 编译验证** - -Run: `cargo check 2>&1 | tail -15` -Expected: 无错误。若 serde derive 缺失,改用 Value 手动解析后重试。 - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/admin/write.rs -git commit -m "feat(write): poll window.__tiptap_uploads for upload events and counts" -``` - ---- - -## Task 10: write.rs — 顶部上传失败提示渲染 - -**Files:** -- Modify: `src/pages/admin/write.rs` - -在现有 error/success 提示块(第 452-469 行)之后渲染 upload_errors,每条带 ×关闭(同时删占位符)。 - -- [ ] **Step 1: 添加顶部提示渲染** - -在 `success` 提示 div(第 465-469 行)之后、底部操作栏(第 471 行)之前添加: - -```rust - // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符 - for err in upload_errors.read().iter() { - div { class: "flex-shrink-0 flex items-center justify-between gap-3 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: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", - aria_label: "关闭提示", - onclick: move |_| { - // 关闭提示同时删除编辑器内失败占位符(避免孤儿) - let id = err.id.clone(); - 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({:?});}}}})()", - id - )); - upload_errors.write().retain(|e| e.id != id); - }, - "×" - } - } - } -``` - -- [ ] **Step 2: 编译验证** - -Run: `cargo check 2>&1 | tail -10` -Expected: 无错误。 - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/admin/write.rs -git commit -m "feat(write): render stacked upload failure notices with dismiss" -``` - ---- - -## Task 11: write.rs — 保存拦截(counts 检查 + markdown 兜底) - -**Files:** -- Modify: `src/pages/admin/write.rs` - -在 `on_submit` 开头加 counts 检查;在拿到 markdown 后加 blob: 兜底扫描。 - -- [ ] **Step 1: 在 on_submit 开头加 counts 检查** - -将 `on_submit` 开头(第 248 行 `let on_submit = move |_| {` 之后,title 校验之前)改为: - -```rust - let on_submit = move |_| { - // 上传未完成/失败拦截:有占位符时阻止保存 - 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); - - if title().trim().is_empty() { -``` - -- [ ] **Step 2: 在拿到 markdown 后加 blob: 兜底扫描** - -将 markdown 读取与空内容检查(第 258-268 行)改为: - -```rust - let md = js_sys::eval(r#" - (function() { - var editor = window.TiptapEditor && window.TiptapEditor._instances && window.TiptapEditor._instances.get('tiptap-editor'); - return editor ? editor.getMarkdown() : (window.__tiptap_content || ''); - })() - "#).ok().and_then(|v| v.as_string()).unwrap_or_default(); - - // 兜底:扫描残留的 blob: 或 data-upload-state(轮询窗口期漏判防护) - if md.contains("blob:") || md.contains("data-upload-state") { - error.set(Some("检测到未完成上传的图片,请处理后保存".to_string())); - return; - } - - if md.trim().is_empty() { - error.set(Some("内容不能为空".to_string())); - return; - } -``` - -- [ ] **Step 3: 编译验证** - -Run: `cargo check 2>&1 | tail -10` -Expected: 无错误。 - -- [ ] **Step 4: Commit** - -```bash -git add src/pages/admin/write.rs -git commit -m "feat(write): block save when uploads in flight or blob residue detected" -``` - ---- - -## Task 12: write.rs — fetch 改造透传服务端中文错误 - -**Files:** -- Modify: `src/pages/admin/write.rs` - -当前 `onImageUpload` 的 fetch 在非 2xx 时丢弃服务端错误体。改为读取 `data.error`。 - -- [ ] **Step 1: 改写 fetch 的非 2xx 处理** - -将 eval 字符串中 `onImageUpload` 的 `.then(function(response) {...})`(第 169-173 行)改为: - -```javascript - .then(function(response) { - if (!response.ok) { - // 读取服务端返回的中文错误({"success":false,"error":"文件超过大小限制"}) - return response.json().catch(function() { return null; }).then(function(data) { - if (data && data.error) { - throw new Error(data.error); - } - throw new Error('上传失败: ' + response.status); - }); - } - return response.json(); - }) -``` - -- [ ] **Step 2: 构建验证** - -Run: `make build-editor-incremental && dx check 2>&1 | tail -5` -Expected: tiptap 构建成功,dx check 无问题。 - -- [ ] **Step 3: Commit** - -```bash -git add src/pages/admin/write.rs -git commit -m "fix(write): surface server error messages in upload failures" -``` - ---- - -## Task 13: 全量验证 - -**Files:** 无新改动,仅验证。 - -- [ ] **Step 1: cargo test + clippy** - -Run: `cargo test 2>&1 | tail -5 && cargo clippy --all-targets 2>&1 | tail -5` -Expected: 测试全过,clippy 无警告。 - -- [ ] **Step 2: dx check** - -Run: `dx check 2>&1 | tail -3` -Expected: `No issues found.` - -- [ ] **Step 3: tiptap 构建产物校验** - -Run: `make build-editor-incremental 2>&1 | tail -5` -Expected: `✓ built in ...` - -- [ ] **Step 4: 手动验证清单(需 dev server 运行)** - -启动 `make dev`,浏览器打开编辑器页面,硬刷新(Cmd+Shift+R)后逐项验证: - -- [ ] 粘贴图片:编辑器立即显示本地预览 + "上传中…"遮罩 -- [ ] 上传成功:遮罩消失,src 换为服务端 URL,无光标跳动 -- [ ] 拖拽图片:同上,且插入到拖放位置 -- [ ] 斜杠 /上传图片:弹出文件选择器,选图后出现占位符 -- [ ] 上传超大文件(>5MB):占位符变红,显示"文件超过大小限制" -- [ ] 失败占位符上点"重试":转回 uploading,重跑上传 -- [ ] 失败占位符上点"移除":节点删除 -- [ ] 上传失败时顶部出现堆叠提示(文件名 + 错误原因) -- [ ] 多张同时失败:顶部多条堆叠 -- [ ] 编辑器内移除失败占位符:顶部对应提示同步消失 -- [ ] 点顶部 ×:编辑器内对应占位符同步删除 -- [ ] 有 uploading 占位符时点保存:被阻止,提示"有 N 张图片正在上传" -- [ ] 有 error 占位符时点保存:被阻止,提示"有 N 张图片上传失败" -- [ ] 全部完成后保存:正常通过 - -- [ ] **Step 5: 最终 commit(如有验证中发现的小修复)** - -```bash -# 仅当验证中修复了问题时 -git add -A -git commit -m "fix(editor): address verification findings" -``` - ---- - -## 完成标准 - -- 所有 Task 的 Step checkbox 打勾 -- `cargo test` / `cargo clippy` / `dx check` / `make build-editor-incremental` 全部通过 -- 手动验证清单全部通过 -- spec(`docs/superpowers/specs/2026-06-22-upload-placeholder-design.md`)的 11 条验收标准全部满足 diff --git a/docs/superpowers/plans/2026-06-25-dioxus-antipatterns-cleanup.md b/docs/superpowers/plans/2026-06-25-dioxus-antipatterns-cleanup.md deleted file mode 100644 index f984429..0000000 --- a/docs/superpowers/plans/2026-06-25-dioxus-antipatterns-cleanup.md +++ /dev/null @@ -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!` 里用 ``。 -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::()`。语义上它是 `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::() - .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::() - .ok() - .map(|d| d != settings().retention_days) - .unwrap_or(true) - }); -``` - -**要点**: -- `use_memo` 已在 prelude(`use dioxus::prelude::*`,`trash.rs` 顶部已导入,无需额外 use)。 -- `dirty` 的读取方式不变:原本 `dirty` 是 `bool`,现在 `dirty` 是 `Memo`,但在 `rsx!` 里 `{dirty()}` / `disabled: !dirty()` 这种用法——**注意**:`Memo` 实现了 `Deref`,但在条件判断里仍需 `*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` 类错误,定位 `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` prop(双向:父组件声明该 signal 并传引用,子组件内 `set` 写最终 URL,父组件读它用于保存)。 -- `write_editor` 删除上述信号/闭包/rsx,净减约 150 行。 - -**Files:** -- Modify: `src/pages/admin/write.rs`(删除 cover signal/闭包/rsx,改为 ``,新增 `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) -> Element { - let mut cover_uploading = use_signal(|| false); - let mut cover_error = use_signal(|| None::); - 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::()` 等真实 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::); - 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`),不是 `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, 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/`(有封面的文章):封面正确回填显示 -- [ ] 填写标题+正文后点保存:成功(`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 三轮文档对照**:已确认合规,无改动。 diff --git a/docs/superpowers/specs/2026-06-11-comment-localstorage-design.md b/docs/superpowers/specs/2026-06-11-comment-localstorage-design.md deleted file mode 100644 index b73f4ff..0000000 --- a/docs/superpowers/specs/2026-06-11-comment-localstorage-design.md +++ /dev/null @@ -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→`
`. -- **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, - pub comment_id: Option, // NEW: set only on success -} -``` - -Backward-compatible: `Option` 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) -> Result, 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` | Read author info | -| `save_pending_comment(post_id, comment)` | Append to `yggdrasil-pending-comments` | -| `load_pending_comments(post_id) -> Vec` | 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>, - pub refresh_trigger: Signal>, - pub pending_comments: Signal>, // 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` → `
` -- **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` and `pending: Vec`. 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` 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 diff --git a/docs/superpowers/specs/2026-06-22-blur-up-images-design.md b/docs/superpowers/specs/2026-06-22-blur-up-images-design.md deleted file mode 100644 index eaab458..0000000 --- a/docs/superpowers/specs/2026-06-22-blur-up-images-design.md +++ /dev/null @@ -1,313 +0,0 @@ -# 文章图片 Blur-up 渐进加载设计 - -## 背景与目标 - -文章页面的缩略图(卡片封面 `?thumb=400x300`、详情封面 `?w=1200`、正文图 `?w=800`)在加载过程中**没有任何占位**:`` 裸标签无 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: - - ← SSR 内嵌,立即加载 - ← JS 懒加载 - - -前端 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> = LazyLock::new(|| { - let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS") - .ok() - .and_then(|s| s.parse::().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.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` 产出 `...`。 - -**改造**:在 `push_html` 产出 HTML 后、`clean_html` 之前,对字符串里的 `` 做后处理: - -```rust -fn wrap_images_with_blur(html: &str) -> String { - // 用正则/简单解析找到 标签 - // 对每个匹配的 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 依赖)匹配 `]*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,配置不动) -- [ ] 点击图片放大(灯箱)功能仍正常工作 diff --git a/docs/superpowers/specs/2026-06-22-upload-placeholder-design.md b/docs/superpowers/specs/2026-06-22-upload-placeholder-design.md deleted file mode 100644 index da69679..0000000 --- a/docs/superpowers/specs/2026-06-22-upload-placeholder-design.md +++ /dev/null @@ -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`(已完成)**:普通 ``,透传 `src`/`alt`/`width`。与原生行为一致。 -- **`"uploading"`**:容器内放 ``(本地预览)+ 绝对定位遮罩(半透明黑底 + 居中 spinner + "上传中…" 文字)。 -- **`"error"`**:``(`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` 默认会丢掉非标准属性。占位符节点序列化后会变成 `![](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() // uploadId → {file, blobUrl, fileName} - constructor( - private editor: Editor, - private onImageUpload: (file: File) => Promise, - 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`** — 核心上传逻辑: -```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> = 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 = 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::(&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"