feat(assets): 素材网格接入灯箱预览

- 缩略图改为与前台正文图一致的 .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)。
This commit is contained in:
xfy 2026-07-24 17:39:34 +08:00
parent fa40f4bd95
commit 051ca947e3
6 changed files with 107 additions and 30 deletions

View File

@ -506,18 +506,28 @@ window.__initLightbox = (selectors: string | string[]): void => {
initLazyLoad(node); 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 // 正文图:带 index。for..of + const 天然捕获每次迭代的 idx
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。 // 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
const gallery = collected.gallery; const gallery = collected.gallery;
gallery.forEach((node, idx) => { gallery.forEach((node, idx) => {
node.addEventListener('click', (e: MouseEvent) => { bindClick(node, (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
openLightbox(node, gallery, idx); openLightbox(node, gallery, idx);
}); });
}); });
// 单张图封面index = nullgallery 传空数组表示单张 // 单张图封面index = nullgallery 传空数组表示单张
for (const node of collected.singles) { for (const node of collected.singles) {
node.addEventListener('click', (e: MouseEvent) => { bindClick(node, (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
openLightbox(node, [], null); openLightbox(node, [], null);
}); });

View File

@ -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('单张图不参与图集切换', () => { describe('单张图不参与图集切换', () => {
it('单张图打开后按 ←/→ 不切换(无 counter、无箭头', () => { it('单张图打开后按 ←/→ 不切换(无 counter、无箭头', () => {
const img = makeSingleImage('/cover.webp', '封面'); const img = makeSingleImage('/cover.webp', '封面');

View File

@ -12,6 +12,8 @@ use dioxus::prelude::*;
use crate::components::code_runner::CodeRunner; use crate::components::code_runner::CodeRunner;
use crate::infra::runner_config::ResourceLimits; use crate::infra::runner_config::ResourceLimits;
#[cfg(target_arch = "wasm32")]
use crate::utils::js::invoke_optional_global;
/// 内容片段:普通 HTML 文本,或一个可运行代码块。 /// 内容片段:普通 HTML 文本,或一个可运行代码块。
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
@ -92,27 +94,6 @@ fn decode_html_entities(s: &str) -> String {
.replace("&", "&") .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::<js_sys::Function>()
.apply(window, &arr);
}
}
}
/// 文章内容组件。 /// 文章内容组件。
/// ///
/// Props /// Props

View File

@ -3,6 +3,11 @@
//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt、引用状态筛选 //! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt、引用状态筛选
//! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。 //! (全部/引用中/孤儿)、排序(最新/最大)、客户端分页。
//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。 //! 缩略图直接复用 `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::*; use dioxus::prelude::*;
@ -17,6 +22,8 @@ use crate::api::assets::{PurgeOrphansResponse, RebuildAssetsResponse};
use crate::components::empty_state::EmptyState; use crate::components::empty_state::EmptyState;
use crate::components::ui::{FilterTabs, Pagination}; use crate::components::ui::{FilterTabs, Pagination};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::utils::js::invoke_optional_global;
#[cfg(target_arch = "wasm32")]
use crate::models::asset::{AssetFilter, AssetSort}; use crate::models::asset::{AssetFilter, AssetSort};
/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。 /// 每页素材数,与服务端 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 resp = data.read();
let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) = let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) =
match resp.as_ref() { match resp.as_ref() {
@ -283,12 +309,14 @@ pub fn Assets() -> Element {
description: "在编辑器中上传图片后会自动出现在这里".to_string(), description: "在编辑器中上传图片后会自动出现在这里".to_string(),
} }
} else { } else {
// 网格:缩略图卡片 // 网格:缩略图卡片assets-lightbox 为 __initLightbox 的根选择器)
div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2", 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() { for asset in assets.iter() {
{ {
let a = &asset.asset; let a = &asset.asset;
let thumb = format!("/uploads/{}?thumb=300x300", a.path); 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 is_orphan = asset.ref_count == 0;
let badge_class = if is_orphan { 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" "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 { div {
key: "{a.id}", 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", 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 { img {
class: "w-full h-full object-cover", class: "blur-img-placeholder",
src: "{thumb}", src: "{placeholder}",
alt: a.alt.clone().unwrap_or_else(|| a.filename.clone()), alt: "",
loading: "lazy", }
img {
class: "blur-img-full",
"data-src": "{thumb}",
alt: "{img_alt}",
} }
} }
// 引用徽标 // 引用徽标

28
src/utils/js.rs Normal file
View File

@ -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` 全局 `<script src>` 注入,加载时机不保证早于调用点。
pub 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::<js_sys::Function>()
.apply(window, &arr);
}
}
}

View File

@ -2,6 +2,7 @@
//! //!
//! - `comment_storage`:评论草稿 localStorage 持久化WASM 端)。 //! - `comment_storage`:评论草稿 localStorage 持久化WASM 端)。
//! - `html`HTML 转义(两端通用)。 //! - `html`HTML 转义(两端通用)。
//! - `js`WASM 端调用 `window.__init*` 可选全局函数(仅 wasm32
//! - `text`Markdown / 纯文本处理(仅 `server` feature //! - `text`Markdown / 纯文本处理(仅 `server` feature
//! - `time`:跨平台时间/睡眠工具WASM 与原生异步版本)。 //! - `time`:跨平台时间/睡眠工具WASM 与原生异步版本)。
@ -9,6 +10,9 @@
pub mod comment_storage; pub mod comment_storage;
/// HTML 转义工具(前端后端通用)。 /// HTML 转义工具(前端后端通用)。
pub mod html; pub mod html;
/// WASM 端 JS 全局函数调用工具(仅 wasm32 编译)。
#[cfg(target_arch = "wasm32")]
pub mod js;
/// Markdown / 纯文本处理工具。 /// Markdown / 纯文本处理工具。
#[cfg(feature = "server")] #[cfg(feature = "server")]
pub mod text; pub mod text;