From 051ca947e3c818c34aa5c9e99945dda0dd39de09 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 24 Jul 2026 17:39:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(assets):=20=E7=B4=A0=E6=9D=90=E7=BD=91?= =?UTF-8?q?=E6=A0=BC=E6=8E=A5=E5=85=A5=E7=81=AF=E7=AE=B1=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缩略图改为与前台正文图一致的 .blur-img 双层结构(?w=20 占位 + ?thumb=300x300 展示层 data-src),点击由全局注入的 lightbox.js 接管; 图集模式(当前页内 ←/→ 切换),灯箱加载原图(data-src 去 query)。 - 数据异步到达/筛选/翻页/刷新后由 use_effect 调 __initLightbox 重新绑定 (双保险契约同 PostContent:__lightboxSelectors + 直接调用兜底)。 - lightbox.ts 补 data-lb-bound 幂等守卫:Dioxus keyed diff 复用节点时 重复初始化会叠加 click 监听(点一次开两次);附回归测试。 - invoke_optional_global 提取为 src/utils/js.rs 共享,post_content 复用。 浏览器实测:47 张绑定齐全,原图打开/图集切换/Esc 关闭正常, 筛选重建与重建索引重取后重绑幂等(单次点击仅 append 一次 overlay)。 --- libs/lightbox/src/index.ts | 14 +++++++-- libs/lightbox/src/lightbox.test.ts | 19 +++++++++++ src/components/post/post_content.rs | 23 ++------------ src/pages/admin/assets.rs | 49 ++++++++++++++++++++++++----- src/utils/js.rs | 28 +++++++++++++++++ src/utils/mod.rs | 4 +++ 6 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 src/utils/js.rs diff --git a/libs/lightbox/src/index.ts b/libs/lightbox/src/index.ts index fe5228e..b6e3768 100644 --- a/libs/lightbox/src/index.ts +++ b/libs/lightbox/src/index.ts @@ -506,18 +506,28 @@ window.__initLightbox = (selectors: string | string[]): void => { initLazyLoad(node); } + // 幂等守卫:SPA 页面(如 /admin/assets)数据刷新后会重复调用 __initLightbox, + // 而 Dioxus keyed diff 可能复用同一批 DOM 节点——无守卫会叠加 click 监听, + // 点一次连续触发多次 openLightbox(飞行动画重启/闪烁)。 + // initLazyLoad 已有 data-blur-init 守卫,这里给 click 绑定补同款。 + const bindClick = (node: HTMLElement, handler: (e: MouseEvent) => void): void => { + if (node.getAttribute('data-lb-bound')) return; + node.setAttribute('data-lb-bound', 'true'); + node.addEventListener('click', handler); + }; + // 正文图:带 index。for..of + const 天然捕获每次迭代的 idx, // 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。 const gallery = collected.gallery; gallery.forEach((node, idx) => { - node.addEventListener('click', (e: MouseEvent) => { + bindClick(node, (e: MouseEvent) => { e.preventDefault(); openLightbox(node, gallery, idx); }); }); // 单张图(封面):index = null,gallery 传空数组表示单张 for (const node of collected.singles) { - node.addEventListener('click', (e: MouseEvent) => { + bindClick(node, (e: MouseEvent) => { e.preventDefault(); openLightbox(node, [], null); }); diff --git a/libs/lightbox/src/lightbox.test.ts b/libs/lightbox/src/lightbox.test.ts index eaf9b35..c0c1a02 100644 --- a/libs/lightbox/src/lightbox.test.ts +++ b/libs/lightbox/src/lightbox.test.ts @@ -237,6 +237,25 @@ describe('lightbox 黑盒行为', () => { }); }); + describe('重复初始化幂等(SPA 数据刷新场景)', () => { + it('同一批节点重复 __initLightbox,点击只创建一次 overlay', () => { + const img = makeGalleryImage('/a.webp', 'A'); + mountRoot([img]); + window.__initLightbox('.post-content'); + // 模拟 /admin/assets 刷新后 Dioxus 复用 DOM 节点导致的重复初始化 + window.__initLightbox('.post-content'); + + const appendSpy = vi.spyOn(document.body, 'appendChild'); + clickEl(img); + // 无守卫时两个 click 监听先后触发 openLightbox → overlay 被 append 两次 + const overlayAppends = appendSpy.mock.calls.filter( + (c) => c[0] instanceof HTMLElement && c[0].classList.contains('lightbox-overlay'), + ); + expect(overlayAppends).toHaveLength(1); + appendSpy.mockRestore(); + }); + }); + describe('单张图不参与图集切换', () => { it('单张图打开后按 ←/→ 不切换(无 counter、无箭头)', () => { const img = makeSingleImage('/cover.webp', '封面'); diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs index c11a2b2..bcf02b5 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -12,6 +12,8 @@ use dioxus::prelude::*; use crate::components::code_runner::CodeRunner; use crate::infra::runner_config::ResourceLimits; +#[cfg(target_arch = "wasm32")] +use crate::utils::js::invoke_optional_global; /// 内容片段:普通 HTML 文本,或一个可运行代码块。 #[derive(Clone, PartialEq, Debug)] @@ -92,27 +94,6 @@ fn decode_html_entities(s: &str) -> String { .replace("&", "&") } -/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。 -/// -/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用 -/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge` -/// 的类型化 extern 风格一致。 -#[cfg(target_arch = "wasm32")] -fn invoke_optional_global(window: &web_sys::Window, name: &str, args: &[wasm_bindgen::JsValue]) { - use wasm_bindgen::JsCast; - if let Ok(fn_val) = js_sys::Reflect::get(window, &name.into()) { - if !fn_val.is_undefined() && !fn_val.is_null() { - let arr = js_sys::Array::new(); - for a in args { - arr.push(a); - } - let _ = fn_val - .unchecked_into::() - .apply(window, &arr); - } - } -} - /// 文章内容组件。 /// /// Props: diff --git a/src/pages/admin/assets.rs b/src/pages/admin/assets.rs index 5d24eb4..6cd4628 100644 --- a/src/pages/admin/assets.rs +++ b/src/pages/admin/assets.rs @@ -3,6 +3,11 @@ //! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选 //! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。 //! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。 +//! +//! 缩略图采用与前台正文图一致的 `.blur-img` 双层结构(`?w=20` 占位 + `data-src` +//! 展示层),点击由全局注入的 `lightbox.js` 接管为灯箱预览(图集模式,当前页内 +//! 左右切换,灯箱加载原图 = `data-src` 去 query)。数据异步到达/刷新后由 +//! `use_effect` 调 `__initLightbox` 绑定;TS 端有 `data-lb-bound` 守卫,重复绑定幂等。 use dioxus::prelude::*; @@ -17,6 +22,8 @@ use crate::api::assets::{PurgeOrphansResponse, RebuildAssetsResponse}; use crate::components::empty_state::EmptyState; use crate::components::ui::{FilterTabs, Pagination}; #[cfg(target_arch = "wasm32")] +use crate::utils::js::invoke_optional_global; +#[cfg(target_arch = "wasm32")] use crate::models::asset::{AssetFilter, AssetSort}; /// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。 @@ -103,6 +110,25 @@ pub fn Assets() -> Element { } }); + // 灯箱初始化:lightbox.js 由 Dioxus.toml 全局注入。网格随数据异步渲染, + // 需在数据到达(DOM 提交后)调 __initLightbox 绑定;筛选/翻页/刷新重建节点后 + // 重跑此 effect 重新绑定。TS 端 data-lb-bound 守卫保证重复绑定幂等。 + #[cfg(target_arch = "wasm32")] + use_effect(move || { + // 订阅 data:取数/刷新后网格重渲染,effect 在 DOM 更新后运行。 + if data.read().is_none() { + return; + } + let window = + web_sys::window().expect("assets use_effect 仅在 WASM 浏览器上下文执行:无 window"); + // 双保险契约(同 PostContent):先设全局配置,lightbox.js 若尚未加载完, + // 其 IIFE 尾部读到配置自启动;已加载则下方直接调用兜底。 + let selectors = js_sys::Array::of1(&".assets-lightbox".into()); + let selectors_val = js_sys::Object::from(selectors).into(); + let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val); + invoke_optional_global(&window, "__initLightbox", &[selectors_val]); + }); + let resp = data.read(); let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) = match resp.as_ref() { @@ -283,12 +309,14 @@ pub fn Assets() -> Element { description: "在编辑器中上传图片后会自动出现在这里".to_string(), } } else { - // 网格:缩略图卡片 - div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2", + // 网格:缩略图卡片(assets-lightbox 为 __initLightbox 的根选择器) + div { class: "assets-lightbox grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2", for asset in assets.iter() { { let a = &asset.asset; let thumb = format!("/uploads/{}?thumb=300x300", a.path); + let placeholder = format!("/uploads/{}?w=20", a.path); + let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone()); let is_orphan = asset.ref_count == 0; let badge_class = if is_orphan { "absolute top-2 left-2 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white" @@ -299,12 +327,19 @@ pub fn Assets() -> Element { div { key: "{a.id}", class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all", - div { class: "aspect-square overflow-hidden bg-[var(--color-paper-theme)]", + // blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 + + // data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱 + // (图集模式,原图 = data-src 去 query)。不加 lightbox-single。 + div { class: "blur-img aspect-square m-0 cursor-pointer bg-[var(--color-paper-theme)]", img { - class: "w-full h-full object-cover", - src: "{thumb}", - alt: a.alt.clone().unwrap_or_else(|| a.filename.clone()), - loading: "lazy", + class: "blur-img-placeholder", + src: "{placeholder}", + alt: "", + } + img { + class: "blur-img-full", + "data-src": "{thumb}", + alt: "{img_alt}", } } // 引用徽标 diff --git a/src/utils/js.rs b/src/utils/js.rs new file mode 100644 index 0000000..e2129ab --- /dev/null +++ b/src/utils/js.rs @@ -0,0 +1,28 @@ +//! WASM 端 JS 互操作小工具(调用 `window` 上的可选全局函数)。 +//! +//! 仅在 `target_arch = "wasm32"` 下编译;服务端 SSR 路径不参与。 + +#![cfg(target_arch = "wasm32")] + +/// 读取 `window` 上的可选全局函数并调用;函数未定义/为 null 时静默跳过。 +/// +/// 替代 `js_sys::eval("if(window.__x) window.__x(...)")` 字符串拼贴模式:用 +/// `Reflect::get` 取属性 + `Function::apply` 调用,无字符串求值,与 `tiptap_bridge` +/// 的类型化 extern 风格一致。 +/// +/// 用于调用各 IIFE 库暴露的 `window.__init*` 入口(lightbox / mermaid / 锚点拦截等), +/// 这些库由 `Dioxus.toml` 全局 `