Skip to main content

yggdrasil/pages/admin/
assets.rs

1//! 素材管理页面。
2//!
3//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选
4//! (全部/引用中/未引用)、排序(最新/最大)、客户端分页。
5//! 「被 N 处引用」徽标可点击,弹出引用明细浮层(文章/评论/头像分组,
6//! 含跳转链接与草稿/回收站状态徽标)。
7//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。
8//!
9//! 缩略图采用与前台正文图一致的 `.blur-img` 双层结构(`?w=20` 占位 + `data-src`
10//! 展示层),点击由全局注入的 `lightbox.js` 接管为灯箱预览(图集模式,当前页内
11//! 左右切换,灯箱加载原图 = `data-src` 去 query)。数据异步到达/刷新后由
12//! `use_effect` 调 `__initLightbox` 绑定;TS 端有 `data-lb-bound` 守卫,重复绑定幂等。
13
14use dioxus::prelude::*;
15
16// server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内),server SSR 只编译类型。
17use crate::api::assets::AssetListResponse;
18#[cfg(target_arch = "wasm32")]
19use crate::api::assets::{
20    batch_delete_assets, delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index,
21    update_asset_alt,
22};
23#[cfg(target_arch = "wasm32")]
24use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse};
25use crate::components::assets::AssetUploadModal;
26use crate::components::empty_state::EmptyState;
27use crate::components::forms::FormInput;
28use crate::components::skeletons::assets_skeleton::AssetsSkeleton;
29use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
30use crate::components::ui::{FilterTabs, Pagination, Popover, BADGE_BASE, MEDIA_BADGE_BASE};
31#[cfg(target_arch = "wasm32")]
32use crate::models::asset::{AssetFilter, AssetSort};
33use crate::models::asset::{AssetRef, AssetRefPostStatus};
34use crate::router::Route;
35use crate::utils::format_bytes;
36#[cfg(target_arch = "wasm32")]
37use crate::utils::js::invoke_optional_global;
38use std::collections::HashSet;
39
40/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
41const ASSETS_PER_PAGE: i32 = 60;
42
43/// 搜索输入防抖窗口:停顿该时长后才提交查询,避免逐键发请求。
44/// 仅在 wasm32 的防抖任务中引用,server 编译门控掉。
45#[cfg(target_arch = "wasm32")]
46const SEARCH_DEBOUNCE_MS: u32 = 300;
47
48/// 操作横幅/批量条退出动画时长 ms,与 input.css 的 .animate-row-leave(200ms)一一对应;
49/// 退出动画放完后由 spawn 真正摘除节点(仅 wasm 端的延时任务引用,server 编译门控掉)。
50#[cfg(target_arch = "wasm32")]
51const BANNER_EXIT_MS: u32 = 200;
52
53// —— 引用明细浮层 ——
54
55/// Material Symbols 图标 path(Google 原版存档见 public/icons/;
56/// 组件内 fill=currentColor 随明暗主题)。
57const ARTICLE_ICON_PATH: &str = "M277-279h275v-60H277v60Zm0-171h406v-60H277v60Zm0-171h406v-60H277v60Zm-97 501q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm0-600v600-600Z";
58const COMMENT_ICON_PATH: &str = "M880-80 720-240H140q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h680q24 0 42 18t18 42v740ZM140-300h606l74 80v-600H140v520Zm0 0v-520 520Z";
59const ACCOUNT_ICON_PATH: &str = "M222-255q63-44 125-67.5T480-346q71 0 133.5 23.5T739-255q44-54 62.5-109T820-480q0-145-97.5-242.5T480-820q-145 0-242.5 97.5T140-480q0 61 19 116t63 109Zm160.5-234.5Q343-529 343-587t39.5-97.5Q422-724 480-724t97.5 39.5Q617-645 617-587t-39.5 97.5Q538-450 480-450t-97.5-39.5ZM480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm107.5-76Q640-172 691-212q-51-36-104-55t-107-19q-54 0-107 19t-104 55q51 40 103.5 56T480-140q55 0 107.5-16Zm-52-375.5Q557-553 557-587t-21.5-55.5Q514-664 480-664t-55.5 21.5Q403-621 403-587t21.5 55.5Q446-510 480-510t55.5-21.5ZM480-587Zm0 374Z";
60const LINK_ICON_PATH: &str = "M450-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h170v60H280q-58.33 0-99.17 40.76-40.83 40.77-40.83 99Q140-422 180.83-381q40.84 41 99.17 41h170v60ZM325-450v-60h310v60H325Zm185 170v-60h170q58.33 0 99.17-40.76 40.83-40.77 40.83-99Q820-538 779.17-579q-40.84-41-99.17-41H510v-60h170q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H510Z";
61const OPEN_NEW_ICON_PATH: &str = "M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h279v60H180v600h600v-279h60v279q0 24-18 42t-42 18H180Zm202-219-42-43 398-398H519v-60h321v321h-60v-218L382-339Z";
62
63/// 浮层行内 Material Symbols 图标(标准 960 视口 + currentColor,明暗主题自适应)。
64fn ref_icon(d: &'static str, class: &'static str) -> Element {
65    rsx! {
66        svg {
67            class,
68            xmlns: "http://www.w3.org/2000/svg",
69            height: "24px",
70            view_box: "0 -960 960 960",
71            width: "24px",
72            fill: "currentColor",
73            path { d }
74        }
75    }
76}
77
78/// 引用明细浮层的一行:图标 + 文案 + 状态徽标 + 跳转。
79///
80/// 跳转规则:
81/// - 已发布文章 → 原生 `<a target="_blank">` 新标签整页打开前台文章页
82///   (规避 issue #36:admin→前台跨布局 Link + 目标页 suspend 触发 Dioxus 0.7
83///   arena 双重回收 bug;新标签也不打断后台上下文)。
84/// - 草稿/回收站文章 → 同 AdminLayout 的 WriteEdit 客户端导航(同布局链安全);
85///   页面随导航卸载,浮层随之销毁,无需显式关闭。
86/// - 用户头像无管理页可链,纯展示;友链 → /admin/friends(同布局客户端导航)。
87fn render_ref_row(r: &AssetRef, close: Callback<()>) -> Element {
88    const ICON_CLASS: &str = "w-4 h-4 shrink-0 text-[var(--color-paper-tertiary)]";
89    const ROW_CLASS: &str = "flex items-center gap-2.5 -mx-1.5 px-1.5 py-1.5 rounded-xl hover:bg-[var(--color-paper-theme)] transition-colors group/row";
90    const TEXT_CLASS: &str = "flex-1 min-w-0 truncate text-sm text-[var(--color-paper-primary)]";
91    const OPEN_CLASS: &str = "w-3.5 h-3.5 shrink-0 text-[var(--color-paper-tertiary)] opacity-0 group-hover/row:opacity-100 transition-opacity";
92    match r {
93        AssetRef::Note { note_id, title } => rsx! {
94            Link {class:ROW_CLASS,to:Route::EditNote {id:*note_id},
95                {ref_icon(ARTICLE_ICON_PATH, ICON_CLASS)}
96                span {class:TEXT_CLASS,"{title}"}
97                span {class:"text-xs text-[var(--color-paper-secondary)]","含历史版本"}
98            }
99        },
100        AssetRef::Post {
101            post_id,
102            title,
103            slug,
104            status,
105        } => {
106            let content = rsx! {
107                {ref_icon(ARTICLE_ICON_PATH, ICON_CLASS)}
108                span { class: TEXT_CLASS, "{title}" }
109                if let Some((badge_label, badge_class)) = status.badge() {
110                    span { class: "{BADGE_BASE} shrink-0 {badge_class}", "{badge_label}" }
111                }
112            };
113            if *status == AssetRefPostStatus::Published {
114                rsx! {
115                    a {
116                        class: ROW_CLASS,
117                        href: "/post/{slug}",
118                        target: "_blank",
119                        rel: "noopener noreferrer",
120                        title: "新标签打开文章页",
121                        onclick: move |_| close(()),
122                        {content}
123                        {ref_icon(OPEN_NEW_ICON_PATH, OPEN_CLASS)}
124                    }
125                }
126            } else {
127                rsx! {
128                    Link {
129                        class: ROW_CLASS,
130                        to: Route::WriteEdit { id: *post_id },
131                        {content}
132                    }
133                }
134            }
135        }
136        AssetRef::Comment {
137            author_name,
138            post_id,
139            post_title,
140            post_slug,
141            post_status,
142            ..
143        } => {
144            let content = rsx! {
145                {ref_icon(COMMENT_ICON_PATH, ICON_CLASS)}
146                span { class: TEXT_CLASS,
147                    span { class: "text-[var(--color-paper-secondary)]", "{author_name} 在 " }
148                    "《{post_title}》"
149                }
150                if let Some((badge_label, badge_class)) = post_status.badge() {
151                    span { class: "{BADGE_BASE} shrink-0 {badge_class}", "{badge_label}" }
152                }
153            };
154            if *post_status == AssetRefPostStatus::Published {
155                rsx! {
156                    a {
157                        class: ROW_CLASS,
158                        href: "/post/{post_slug}",
159                        target: "_blank",
160                        rel: "noopener noreferrer",
161                        title: "新标签打开所属文章",
162                        onclick: move |_| close(()),
163                        {content}
164                        {ref_icon(OPEN_NEW_ICON_PATH, OPEN_CLASS)}
165                    }
166                }
167            } else {
168                rsx! {
169                    Link {
170                        class: ROW_CLASS,
171                        to: Route::WriteEdit { id: *post_id },
172                        {content}
173                    }
174                }
175            }
176        }
177        AssetRef::UserAvatar { label, .. } => rsx! {
178            div { class: "flex items-center gap-2.5 -mx-1.5 px-1.5 py-1.5 rounded-xl",
179                {ref_icon(ACCOUNT_ICON_PATH, ICON_CLASS)}
180                span { class: TEXT_CLASS,
181                    span { class: "text-[var(--color-paper-secondary)]", "用户头像 · " }
182                    "{label}"
183                }
184            }
185        },
186        AssetRef::FriendAvatar { name, .. } => rsx! {
187            Link {
188                class: ROW_CLASS,
189                to: Route::FriendsAdmin {},
190                {ref_icon(LINK_ICON_PATH, ICON_CLASS)}
191                span { class: TEXT_CLASS,
192                    span { class: "text-[var(--color-paper-secondary)]", "友链头像 · " }
193                    "{name}"
194                }
195            }
196        },
197    }
198}
199
200/// 素材管理入口组件。
201// 交互逻辑全部 cfg(wasm32) 门控,server SSR 编译时一批绑定未使用,按 CoverUploader 惯例放行。
202#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
203#[component]
204pub fn Assets() -> Element {
205    // 筛选/搜索/排序/分页状态:全部客户端驱动(单路由 + signal,对齐「全部文章」模式)。
206    let mut filter = use_signal(|| "all".to_string());
207    let mut query = use_signal(String::new);
208    let mut sort = use_signal(|| "created".to_string());
209    let mut page = use_signal(|| 1_i32);
210
211    #[allow(unused_mut)]
212    let mut data: Signal<Option<AssetListResponse>> = use_signal(|| None);
213    #[allow(unused_mut)]
214    let mut loading: Signal<bool> = use_signal(|| true);
215    #[allow(unused_mut)]
216    let mut error: Signal<Option<String>> = use_signal(|| None);
217
218    // 操作结果横幅(删除/清理/alt 编辑的反馈)。
219    #[allow(unused_mut)]
220    let mut op_message: Signal<Option<String>> = use_signal(|| None);
221    // 操作横幅退出动画态:× 后置 true 播 .animate-row-leave,BANNER_EXIT_MS 后真正摘除。
222    #[allow(unused_mut)]
223    let mut op_closing = use_signal(|| false);
224    // 待二次确认的删除目标(素材 id)与一键清理确认态。
225    let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
226    let mut purge_confirm = use_signal(|| false);
227    // 重建索引进行中状态。
228    let mut rebuilding = use_signal(|| false);
229    // alt 内联编辑:目标素材 id + 输入框值。
230    let mut editing_alt: Signal<Option<String>> = use_signal(|| None);
231    let mut alt_input = use_signal(String::new);
232    // 重载触发器:操作成功后 +1 让 effect 重新请求。
233    let mut reload = use_signal(|| 0_i32);
234    // 已加载视图标识(filter|query|sort|page):fetch 成功后更新,作为网格 key。
235    // 同视图内的 reload(删除/上传/alt/重建/清理)不改变它 → 走 keyed diff 不重播动画。
236    let mut loaded_view = use_signal(String::new);
237    // 多选:选中素材 id 集合 + 批量删除确认态。仅未引用素材可选(被引用的禁删,
238    // 与单删保护语义一致);选择跨翻页/筛选保留,批量删除成功后整体清空。
239    let mut selected_ids: Signal<HashSet<String>> = use_signal(HashSet::new);
240    let mut batch_confirm = use_signal(|| false);
241    // 批量操作条退出动画态:选择清空后置 true 播 .animate-row-leave,延迟真正清空
242    // selected_ids,使退出动画期间内容不退化为"已选 0 张"(保持消失前最后一帧)。
243    let mut batch_closing = use_signal(|| false);
244    // 页内上传 modal 显隐。
245    let mut upload_open = use_signal(|| false);
246    // 引用明细浮层:打开的素材 id + 视口锚点坐标 + 水平对齐(点击时按视口宽度预算)。
247    let mut refs_popover: Signal<Option<(String, i32, i32, &'static str)>> = use_signal(|| None);
248    // —— 操作横幅与批量条的进出动画 ——
249    // 出现走 .animate-row-enter(挂载即播);消失先置 closing=true 切到 .animate-row-leave,
250    // BANNER_EXIT_MS 后由 spawn 真正摘除。peek 门控:摘除前若被新消息/新选择覆盖(closing
251    // 已被重置为 false)则放弃摘除,避免覆盖掉用户刚触发的新内容。
252    // 三 helper 用 Callback(Copy,可在多处 onclick 中重复引用);普通闭包因 Signal
253    // 的 set(&mut self) 而成为 FnMut、非 Copy,无法跨 move 处理器共享。
254    // show_op(String):弹新消息(先重置 closing,取消上一条还在播的退出动画)。
255    let show_op = Callback::new(move |msg: String| {
256        op_closing.set(false);
257        op_message.set(Some(msg));
258    });
259    // dismiss_op(()):× 收起操作横幅(播退出动画,延迟摘除;peek 防覆盖)。
260    let dismiss_op = Callback::new(move |()| {
261        op_closing.set(true);
262        #[cfg(target_arch = "wasm32")]
263        spawn(async move {
264            crate::utils::time::sleep_ms(BANNER_EXIT_MS).await;
265            if *op_closing.peek() {
266                op_message.set(None);
267                op_closing.set(false);
268            }
269        });
270    });
271    // dismiss_batch(()):选择清空时收起批量条(延迟清空 selected_ids,使退出动画
272    // 期间内容不退化为"已选 0 张";peek 防新选择覆盖刚发起的收起)。
273    let dismiss_batch = Callback::new(move |()| {
274        batch_closing.set(true);
275        #[cfg(target_arch = "wasm32")]
276        spawn(async move {
277            crate::utils::time::sleep_ms(BANNER_EXIT_MS).await;
278            if *batch_closing.peek() {
279                selected_ids.set(HashSet::new());
280                batch_confirm.set(false);
281                batch_closing.set(false);
282            }
283        });
284    });
285
286    // dismiss_refs(()):关闭引用明细浮层(遮罩/Escape/行内跳转共用)。
287    let dismiss_refs = Callback::new(move |()| {
288        refs_popover.set(None);
289    });
290    // 搜索防抖:query 是输入框原始值(受控绑定),debounced_query 才是请求参数。
291    // 停顿 300ms 无新输入才提交;每次击键重启本 effect 并新 spawn 一个延时任务,
292    // 旧任务醒来后用 peek 比对当前 query,已过期则静默丢弃。提交时连带重置页码——
293    // 若放在 oninput 里,page 变化会立刻用旧关键词抢发一次请求。
294    let mut debounced_query = use_signal(String::new);
295    use_effect(move || {
296        let q = query();
297        #[cfg(target_arch = "wasm32")]
298        spawn(async move {
299            crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
300            if *query.peek() == q && *debounced_query.peek() != q {
301                debounced_query.set(q);
302                page.set(1);
303            }
304        });
305    });
306
307    // 数据加载:任一查询条件或 reload 变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。
308    use_effect(move || {
309        let f = filter();
310        let q = debounced_query();
311        let s = sort();
312        let p = page();
313        let _ = reload();
314
315        #[cfg(target_arch = "wasm32")]
316        {
317            let filter_enum = match f.as_str() {
318                "used" => AssetFilter::Used,
319                "orphan" => AssetFilter::Orphan,
320                _ => AssetFilter::All,
321            };
322            let sort_enum = if s == "size" {
323                AssetSort::SizeDesc
324            } else {
325                AssetSort::CreatedDesc
326            };
327            // 视图 key 在 spawn 前构造(q 随后被 move 进 list_assets)。reload 故意不参与:
328            // 同视图重操作只 diff 网格,不重播入场动画。
329            let view_key = format!("{f}|{q}|{s}|{p}");
330            // 任何重新取数都关闭引用明细浮层(引用数据可能已变化)。
331            refs_popover.set(None);
332            spawn(async move {
333                loading.set(true);
334                error.set(None);
335                match list_assets(filter_enum, q, sort_enum, p).await {
336                    Ok(resp) => {
337                        // 先 key 后数据:即使两次 set 未被批处理,网格也只在最终
338                        // key 下挂载一次,不会 "" → 正式 key 二次挂载闪双动画。
339                        loaded_view.set(view_key);
340                        data.set(Some(resp));
341                    }
342                    Err(e) => error.set(Some(e.to_string())),
343                }
344                loading.set(false);
345            });
346        }
347    });
348
349    // 灯箱初始化:lightbox.js 由 Dioxus.toml 全局注入。网格随数据异步渲染,
350    // 需在数据到达(DOM 提交后)调 __initLightbox 绑定;筛选/翻页/刷新重建节点后
351    // 重跑此 effect 重新绑定。TS 端 data-lb-bound 守卫保证重复绑定幂等。
352    #[cfg(target_arch = "wasm32")]
353    use_effect(move || {
354        // 订阅 data:取数/刷新后网格重渲染,effect 在 DOM 更新后运行。
355        if data.read().is_none() {
356            return;
357        }
358        let window =
359            web_sys::window().expect("assets use_effect 仅在 WASM 浏览器上下文执行:无 window");
360        // 双保险契约(同 PostContent):先设全局配置,lightbox.js 若尚未加载完,
361        // 其 IIFE 尾部读到配置自启动;已加载则下方直接调用兜底。
362        let selectors = js_sys::Array::of1(&".assets-lightbox".into());
363        let selectors_val = js_sys::Object::from(selectors).into();
364        let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
365        invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
366    });
367
368    let resp = data.read();
369    let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) =
370        match resp.as_ref() {
371            Some(r) => (
372                r.assets.clone(),
373                r.total,
374                r.used_count,
375                r.orphan_count,
376                r.purgeable_count,
377                r.purgeable_bytes,
378            ),
379            None => (Vec::new(), 0, 0, 0, 0, 0),
380        };
381    let all_count = used_count + orphan_count;
382    drop(resp);
383
384    // Dioxus 格式化段不支持内联 if 块表达式,条件 class 提前算好。
385    let sort_btn_base = "text-xs font-mono tracking-widest uppercase cursor-pointer px-3 py-2 rounded-full border transition-colors";
386    let sort_active = "border-[var(--color-paper-primary)] text-[var(--color-paper-primary)]";
387    let sort_idle = "border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)]";
388    let sort_created_class = if sort() == "created" {
389        format!("{sort_btn_base} {sort_active}")
390    } else {
391        format!("{sort_btn_base} {sort_idle}")
392    };
393    let sort_size_class = if sort() == "size" {
394        format!("{sort_btn_base} {sort_active}")
395    } else {
396        format!("{sort_btn_base} {sort_idle}")
397    };
398
399    // 多选派生值:本页可删(未引用)素材 id、是否已全选本页、是否有任何选择
400    // (驱动未选中卡片的勾选框常显)。全选/取消本页闭包各需一份 id 列表拷贝。
401    let page_orphan_ids: Vec<String> = assets
402        .iter()
403        .filter(|item| item.ref_count == 0)
404        .map(|item| item.asset.id.clone())
405        .collect();
406    let any_selected = !selected_ids().is_empty();
407    let all_page_selected =
408        !page_orphan_ids.is_empty() && page_orphan_ids.iter().all(|id| selected_ids().contains(id));
409    let page_orphan_ids_for_toggle = page_orphan_ids.clone();
410
411    rsx! {
412        // min-h-full:AdminLayout 卡片是 flex 列滚动容器,main(flex-1)的高度为 definite,
413        // 故本根节点可解析百分比最小高度——内容不足一页时撑满 main 内容盒,
414        // 配合下方分页的 mt-auto wrapper 把分页条吸附到卡片底部。
415        div { class: "animate-page-enter min-h-full flex flex-col",
416            h1 { class: "animate-row-enter text-3xl font-extrabold tracking-tight mb-2",
417                "素材管理"
418            }
419            p {
420                class: "animate-row-enter text-sm text-[var(--color-paper-secondary)] mb-8",
421                style: "animation-delay: 60ms",
422                "管理文章编辑器上传的图片。共 {all_count} 张,引用中 {used_count} 张,未引用 {orphan_count} 张。"
423            }
424
425            // 顶栏:筛选 tabs + 搜索 + 排序
426            div {
427                class: "animate-row-enter flex flex-wrap items-end justify-between gap-4",
428                style: "animation-delay: 120ms",
429                FilterTabs {
430                    items: vec![("all", "全部"), ("used", "引用中"), ("orphan", "未引用")],
431                    active_value: filter(),
432                    on_change: move |v: String| {
433                        filter.set(v);
434                        page.set(1);
435                    },
436                }
437                // 右侧控件与 FilterTabs 同加 mb-6:items-end 对齐的是 margin box,
438                // 两者底边同落在 tabs 下划线处,到下方横幅/网格的距离均为 24px。
439                div { class: "flex items-center gap-3 mb-6",
440                    // 页内上传:主 CTA(实心 paper-primary,对齐 AssetPickerModal「上传新图」先例)。
441                    button {
442                        class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity",
443                        onclick: move |_| upload_open.set(true),
444                        "上传素材"
445                    }
446                    // 重建索引:以磁盘为准全量自愈(存量回填/不一致修复)。
447                    button {
448                        class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:border-[var(--color-paper-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
449                        disabled: rebuilding(),
450                        title: "扫描 uploads/ 全量文件,同步素材注册表与文章引用(幂等,可随时重跑)",
451                        onclick: move |_| {
452                            rebuilding.set(true);
453                            #[cfg(target_arch = "wasm32")]
454                            spawn(async move {
455                                match rebuild_assets_index().await {
456                                    Ok(RebuildAssetsResponse { message, .. }) => {
457                                        show_op(message);
458                                        reload.set(reload() + 1);
459                                    }
460                                    Err(e) => show_op(format!("重建失败:{e}")),
461                                }
462                                rebuilding.set(false);
463                            });
464                        },
465                        if rebuilding() {
466                            "重建中..."
467                        } else {
468                            "重建索引"
469                        }
470                    }
471                    FormInput {
472                        r#type: "search",
473                        placeholder: "搜索文件名 / alt",
474                        value: query(),
475                        class: "w-56 text-sm px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors",
476                        oninput: move |v: String| query.set(v),
477                    }
478                    button {
479                        class: "{sort_created_class}",
480                        onclick: move |_| {
481                            sort.set("created".to_string());
482                            page.set(1);
483                        },
484                        "最新"
485                    }
486                    button {
487                        class: "{sort_size_class}",
488                        onclick: move |_| {
489                            sort.set("size".to_string());
490                            page.set(1);
491                        },
492                        "最大"
493                    }
494                    // 一键清理孤儿:仅 7 天保护窗外的无引用素材;两步确认。
495                    if purgeable_count > 0 {
496                        if purge_confirm() {
497                            button {
498                                class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
499                                onclick: move |_| {
500                                    purge_confirm.set(false);
501                                    #[cfg(target_arch = "wasm32")]
502                                    spawn(async move {
503                                        match purge_orphan_assets().await {
504                                            Ok(
505                                                PurgeOrphansResponse { deleted_count, freed_bytes, failures, .. },
506                                            ) => {
507                                                let mut msg = format!(
508                                                    "已清理 {} 张未引用素材,释放 {}",
509                                                    deleted_count,
510                                                    format_bytes(freed_bytes),
511                                                );
512                                                if failures > 0 {
513                                                    msg.push_str(
514                                                        &format!("({} 个文件删除失败)", failures),
515                                                    );
516                                                }
517                                                show_op(msg);
518                                                reload.set(reload() + 1);
519                                            }
520                                            Err(e) => show_op(format!("清理失败:{e}")),
521                                        }
522                                    });
523                                },
524                                "确认清理 {purgeable_count} 张({format_bytes(purgeable_bytes)})"
525                            }
526                            button {
527                                class: "text-xs cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
528                                onclick: move |_| purge_confirm.set(false),
529                                "取消"
530                            }
531                        } else {
532                            button {
533                                class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-amber-500/50 text-amber-600 dark:text-amber-400 hover:bg-amber-500/10 transition-colors",
534                                title: "仅清理无引用且上传超过 7 天的素材(保护未保存的草稿)",
535                                onclick: move |_| purge_confirm.set(true),
536                                "清理未引用({purgeable_count} 张 · {format_bytes(purgeable_bytes)})"
537                            }
538                        }
539                    }
540                }
541            }
542
543            // 操作结果横幅
544            if let Some(msg) = op_message() {
545                // mt-4 会与 FilterTabs 自带的 mb-6 叠加成大空洞;改用 mb-6 后
546                // 上方 = tabs 标准间距 24px,下方与网格 mt-2 塌陷同为 24px,对称。
547                div { class: "mb-6 flex items-center justify-between gap-4 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] px-4 py-3 text-sm text-[var(--color-paper-primary)] shadow-sm",
548                    class: if op_closing() { "animate-row-leave" } else { "animate-row-enter" },
549                    span { "{msg}" }
550                    button {
551                        class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] cursor-pointer",
552                        onclick: move |_| dismiss_op(()),
553                        "×"
554                    }
555                }
556            }
557
558            // 多选批量操作条:出现即与操作横幅同槽位(顶栏与网格之间)。
559            if any_selected {
560                div { class: "mb-6 flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] px-4 py-3 text-sm shadow-sm",
561                    class: if batch_closing() { "animate-row-leave" } else { "animate-row-enter" },
562                    span { class: "text-sm text-[var(--color-paper-secondary)]",
563                        "已选 {selected_ids().len()} 张"
564                    }
565                    button {
566                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
567                        onclick: move |_| {
568                            let mut s = selected_ids();
569                            if all_page_selected {
570                                for id in &page_orphan_ids_for_toggle {
571                                    s.remove(id);
572                                }
573                            } else {
574                                for id in &page_orphan_ids_for_toggle {
575                                    s.insert(id.clone());
576                                }
577                            }
578                            if s.is_empty() {
579                                // 取消本页正好清空 → 收起批量条(延迟清空播退出动画)
580                                dismiss_batch(());
581                            } else {
582                                batch_closing.set(false);
583                                selected_ids.set(s);
584                            }
585                        },
586                        if all_page_selected {
587                            "取消本页"
588                        } else {
589                            "全选本页"
590                        }
591                    }
592                    button {
593                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
594                        onclick: move |_| {
595                            batch_confirm.set(false);
596                            dismiss_batch(());
597                        },
598                        "清除"
599                    }
600                    div { class: "flex-1" }
601                    if batch_confirm() {
602                        button {
603                            class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
604                            onclick: move |_| {
605                                batch_confirm.set(false);
606                                let ids: Vec<String> = selected_ids().iter().cloned().collect();
607                                #[cfg(target_arch = "wasm32")]
608                                spawn(async move {
609                                    match batch_delete_assets(ids).await {
610                                        Ok(resp) => {
611                                            let BatchDeleteAssetsResponse {
612                                                message,
613                                                freed_bytes,
614                                                failures,
615                                                deleted_count,
616                                                ..
617                                            } = resp;
618                                            let mut msg = message;
619                                            if freed_bytes > 0 {
620                                                msg.push_str(
621                                                    &format!(",释放 {}", format_bytes(freed_bytes)),
622                                                );
623                                            }
624                                            if failures > 0 {
625                                                msg.push_str(&format!("({failures} 项失败)"));
626                                            }
627                                            show_op(msg);
628                                            if deleted_count > 0 {
629                                                reload.set(reload() + 1);
630                                                dismiss_batch(());
631                                            }
632                                        }
633                                        Err(e) => show_op(format!("批量删除失败:{e}")),
634                                    }
635                                });
636                            },
637                            "确认删除 {selected_ids().len()} 张"
638                        }
639                        button {
640                            class: "text-xs cursor-pointer px-3 py-2 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
641                            onclick: move |_| batch_confirm.set(false),
642                            "取消"
643                        }
644                    } else {
645                        button {
646                            class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full border border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/10 transition-colors",
647                            onclick: move |_| batch_confirm.set(true),
648                            "删除所选"
649                        }
650                    }
651                }
652            }
653
654            // 内容区
655            if let Some(err) = error() {
656                div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" }
657            } else if loading() && assets.is_empty() {
658                DelayedSkeleton { AssetsSkeleton {} }
659            } else if assets.is_empty() {
660                EmptyState {
661                    title: "暂无素材".to_string(),
662                    description: "在编辑器中上传图片后会自动出现在这里".to_string(),
663                }
664            } else {
665                // 网格:缩略图卡片(assets-lightbox 为 __initLightbox 的根选择器)。
666                // loaded_view 作 key 强制 remount:视图切换(tab/搜索/排序/翻页)后新数据
667                // 落地时整网重挂载、卡片重播阶梯入场;同视图内 reload(删除/上传/alt)
668                // key 不变,走普通 keyed diff 不重播。std::iter::once 先例见 post_detail.rs。
669                for view_key in std::iter::once(loaded_view()) {
670                    div {
671                        key: "{view_key}",
672                        class: "assets-lightbox grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2",
673                        for (idx, asset) in assets.iter().enumerate() {
674                            {
675                                let a = &asset.asset;
676                                let thumb = format!("/uploads/{}?thumb=300x300", a.path);
677                                let placeholder = format!("/uploads/{}?w=20", a.path);
678                                let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone());
679                                let is_orphan = asset.ref_count == 0;
680                                let is_selected = selected_ids().contains(&a.id);
681                                // 选中卡片加主题色描边(ring 与原有 border 叠加,不动布局)。
682                                let card_ring = if is_selected {
683                                    "ring-2 ring-[var(--color-paper-accent)]"
684                                } else {
685                                    ""
686                                };
687                                // 阶梯入场 delay:30ms 步长、300ms 封顶——前 10 张阶梯、其余同播,
688                                // 60 张/页时尾部不晚于 ~750ms 全部到位(对齐 asset_upload 的 idx*30 先例)。
689                                let enter_delay = (idx * 30).min(300);
690                                // 勾选框:未引用素材才可删可选。选中或有任何选择时常显,
691                                // 否则随卡片 hover 显现(与卡片操作按钮同一显现语言)。
692                                let checkbox_class = if is_selected {
693                                    "absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-[var(--color-paper-accent)] text-white border border-[var(--color-paper-accent)]"
694                                } else if any_selected {
695                                    "absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-black/40 backdrop-blur-sm text-white border border-white/60"
696                                } else {
697                                    "absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full text-xs cursor-pointer transition-all bg-black/40 backdrop-blur-sm text-white border border-white/60 opacity-0 group-hover:opacity-100"
698                                };
699                                // z-10:.blur-img-full 带 z-index:1,不提升会被展示层盖住(灯箱改造的回归)。
700                                let badge_tone = if is_orphan {
701                                    "bg-amber-500/80 text-white"
702                                } else {
703                                    "bg-black/50 text-white"
704                                };
705                                rsx! {
706                                    div {
707                                        key: "{a.id}",
708                                        class: "group relative rounded-2xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300 animate-row-enter {card_ring}",
709                                        style: "animation-delay: {enter_delay}ms",
710                                        // blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 +
711                                        // data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱
712                                        // (图集模式,原图 = data-src 去 query)。不加 lightbox-single。
713                                        // data-error-text:缩略图重试耗尽仍失败(本地文件丢失等)时
714                                        // 卡片占位与灯箱错误态显示的定制文案。
715                                        div {
716                                            class: "blur-img !rounded-none aspect-square m-0 cursor-pointer bg-[var(--color-paper-theme)]",
717                                            "data-error-text": "本地文件已丢失",
718                                            img { class: "blur-img-placeholder", src: "{placeholder}", alt: "" }
719                                            img {
720                                                class: "blur-img-full",
721                                                "data-src": "{thumb}",
722                                                alt: "{img_alt}",
723                                            }
724                                        }
725                                        // 引用徽标:未引用静态展示;引用中可点击 →
726                                        // 引用明细浮层(stop_propagation 防触发灯箱)。
727                                        if is_orphan {
728                                            span {
729                                                class: "absolute top-2 left-2 z-10 {MEDIA_BADGE_BASE} backdrop-blur-sm {badge_tone}",
730                                                "未引用"
731                                            }
732                                        } else {
733                                            button {
734                                                class: "absolute top-2 left-2 z-10 {MEDIA_BADGE_BASE} backdrop-blur-sm {badge_tone} cursor-pointer hover:brightness-125 transition",
735                                                title: "查看被谁引用",
736                                                onclick: {
737                                                    let id = a.id.clone();
738                                                    move |evt| {
739                                                        evt.stop_propagation();
740                                                        #[cfg(target_arch = "wasm32")]
741                                                        {
742                                                            let coords = evt.client_coordinates();
743                                                            let x = coords.x as i32;
744                                                            let y = coords.y as i32;
745                                                            // 动态 align:靠视口左/右缘的卡片让面板
746                                                            // 朝内侧延伸(240px ≈ 面板宽 w-80 的 3/4)。
747                                                            let vw = web_sys::window()
748                                                                .and_then(|w| w.inner_width().ok())
749                                                                .and_then(|v| v.as_f64())
750                                                                .unwrap_or(1280.0)
751                                                                as i32;
752                                                            let align = if x < 240 {
753                                                                "start"
754                                                            } else if x > vw - 240 {
755                                                                "end"
756                                                            } else {
757                                                                "center"
758                                                            };
759                                                            refs_popover.set(Some((id.clone(), x, y, align)));
760                                                        }
761                                                    }
762                                                },
763                                                "被 {asset.ref_count} 处引用"
764                                            }
765                                        }
766                                        // 多选勾选框(仅未引用素材;stop_propagation 防触发灯箱)
767                                        if is_orphan {
768                                            button {
769                                                class: "{checkbox_class}",
770                                                title: if is_selected { "取消选择" } else { "选择" },
771                                                onclick: {
772                                                    let id = a.id.clone();
773                                                    move |evt| {
774                                                        evt.stop_propagation();
775                                                        let mut s = selected_ids();
776                                                        if s.contains(&id) {
777                                                            s.remove(&id);
778                                                        } else {
779                                                            s.insert(id.clone());
780                                                        }
781                                                        if s.is_empty() {
782                                                            // 取消最后一个勾选 → 收起批量条(延迟清空播退出动画)
783                                                            dismiss_batch(());
784                                                        } else {
785                                                            batch_closing.set(false);
786                                                            selected_ids.set(s);
787                                                        }
788                                                    }
789                                                },
790                                                if is_selected {
791                                                    "✓"
792                                                }
793                                            }
794                                        }
795                                        div { class: "p-3",
796                                            p {
797                                                class: "text-xs font-medium truncate text-[var(--color-paper-primary)]",
798                                                title: "{a.filename}",
799                                                "{a.filename}"
800                                            }
801                                            p { class: "text-[10px] font-mono text-[var(--color-paper-tertiary)] mt-0.5",
802                                                "{a.width}×{a.height} · {format_bytes(a.size_bytes)}"
803                                            }
804                                            if let Some(alt_text) = &a.alt {
805                                                p {
806                                                    class: "text-[10px] truncate text-[var(--color-paper-secondary)] mt-0.5",
807                                                    title: "{alt_text}",
808                                                    "alt: {alt_text}"
809                                                }
810                                            }
811
812                                            // 操作区:确认删除 / alt 编辑 / 常规三按钮 三态互斥
813                                            if confirm_delete().as_deref() == Some(a.id.as_str()) {
814                                                div { class: "flex items-center gap-2 mt-2",
815                                                    button {
816                                                        class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
817                                                        onclick: {
818                                                            let id = a.id.clone();
819                                                            move |_| {
820                                                                confirm_delete.set(None);
821                                                                let id = id.clone();
822                                                                #[cfg(target_arch = "wasm32")]
823                                                                spawn(async move {
824                                                                    match delete_asset(id).await {
825                                                                        Ok(resp) => {
826                                                                            // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在)
827                                                                            // 说明网格是过期数据,同样触发刷新自愈。
828                                                                            let stale = !resp.success && resp.refs.is_empty();
829                                                                            show_op(resp.message);
830                                                                            if resp.success || stale {
831                                                                                reload.set(reload() + 1);
832                                                                            }
833                                                                        }
834                                                                        Err(e) => show_op(format!("删除失败:{e}")),
835                                                                    }
836                                                                });
837                                                            }
838                                                        },
839                                                        "确认删除"
840                                                    }
841                                                    button {
842                                                        class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
843                                                        onclick: move |_| confirm_delete.set(None),
844                                                        "取消"
845                                                    }
846                                                }
847                                            } else if editing_alt().as_deref() == Some(a.id.as_str()) {
848                                                div { class: "flex items-center gap-1 mt-2",
849                                                    FormInput {
850                                                        r#type: "text",
851                                                        placeholder: "alt 文本",
852                                                        value: alt_input(),
853                                                        class: "flex-1 min-w-0 text-[10px] px-2 py-1 rounded-full border border-paper-border bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent transition-colors",
854                                                        oninput: move |v: String| alt_input.set(v),
855                                                    }
856                                                    button {
857                                                        class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity",
858                                                        onclick: {
859                                                            let id = a.id.clone();
860                                                            move |_| {
861                                                                let id = id.clone();
862                                                                let alt = alt_input();
863                                                                editing_alt.set(None);
864                                                                #[cfg(target_arch = "wasm32")]
865                                                                spawn(async move {
866                                                                    match update_asset_alt(id, alt).await {
867                                                                        Ok(resp) => {
868                                                                            show_op(resp.message);
869                                                                            if resp.success {
870                                                                                reload.set(reload() + 1);
871                                                                            }
872                                                                        }
873                                                                        Err(e) => show_op(format!("保存失败:{e}")),
874                                                                    }
875                                                                });
876                                                            }
877                                                        },
878                                                        "存"
879                                                    }
880                                                    button {
881                                                        class: "text-[10px] cursor-pointer px-2 py-1 rounded-full border border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
882                                                        onclick: move |_| editing_alt.set(None),
883                                                        "×"
884                                                    }
885                                                }
886                                            } else {
887                                                div { class: "flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
888                                                    button {
889                                                        class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
890                                                        title: "复制图片相对路径",
891                                                        onclick: {
892                                                            let url = format!("/uploads/{}", a.path);
893                                                            move |_| {
894                                                                #[cfg(target_arch = "wasm32")]
895                                                                if let Some(window) = web_sys::window() {
896                                                                    let _ = window
897                                                                        .navigator()
898                                                                        .clipboard()
899                                                                        .write_text(&url);
900                                                                    show_op(format!("已复制 {url}"));
901                                                                }
902                                                            }
903                                                        },
904                                                        "复制路径"
905                                                    }
906                                                    button {
907                                                        class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
908                                                        title: "编辑 alt",
909                                                        onclick: {
910                                                            let id = a.id.clone();
911                                                            let current_alt = a.alt.clone().unwrap_or_default();
912                                                            move |_| {
913                                                                alt_input.set(current_alt.clone());
914                                                                editing_alt.set(Some(id.clone()));
915                                                            }
916                                                        },
917                                                        "alt"
918                                                    }
919                                                    if asset.ref_count > 0 {
920                                                        {
921                                                            let refs_tip = asset
922                                                                .refs
923                                                                .iter()
924                                                                .map(|r| r.describe())
925                                                                .collect::<Vec<_>>()
926                                                                .join("、");
927                                                            rsx! {
928                                                                span {
929                                                                    class: "text-[10px] text-[var(--color-paper-tertiary)] cursor-not-allowed",
930                                                                    title: "被引用:{refs_tip}",
931                                                                    "删除"
932                                                                }
933                                                            }
934                                                        }
935                                                    } else {
936                                                        button {
937                                                            class: "text-[10px] cursor-pointer text-red-500/70 hover:text-red-500 transition-colors",
938                                                            onclick: {
939                                                                let id = a.id.clone();
940                                                                move |_| confirm_delete.set(Some(id.clone()))
941                                                            },
942                                                            "删除"
943                                                        }
944                                                    }
945                                                }
946                                            }
947                                        }
948                                    }
949                                }
950                            }
951                        }
952                    }
953                }
954
955                // mt-auto 吸收上方自由空间:内容短时分页贴卡片底(main 的 py-12 保留 48px
956                // 底距);内容超一页时 auto margin 为 0,分页跟在网格后随卡片滚动。
957                // wrapper 是 flex item 建立独立格式化上下文,Pagination nav 自带的 mt-6
958                // 不会穿透塌陷,24px 间距恒在。
959                div { class: "mt-auto",
960                    Pagination::<Route> {
961                        variant: "admin",
962                        current_page: page(),
963                        total,
964                        per_page: ASSETS_PER_PAGE,
965                        unit: "张",
966                        on_prev: move |_| page.set((page() - 1).max(1)),
967                        on_next: move |_| page.set(page() + 1),
968                        on_jump: move |p: i32| page.set(p),
969                    }
970                }
971            }
972
973            // 引用明细浮层:页面根部渲染(fixed 定位须避开卡片 transform 动画祖先);
974            // 数据复用已加载列表(服务端按 文章→评论→头像 分组序返回),点开零额外请求。
975            if let Some((ref_id, anchor_x, anchor_y, pop_align)) = refs_popover() {
976                if let Some(target) = assets.iter().find(|item| item.asset.id == ref_id) {
977                    {
978                        let posts: Vec<&AssetRef> = target
979                            .refs
980                            .iter()
981                            .filter(|r| matches!(r, AssetRef::Post { .. }))
982                            .collect();
983                        let comments: Vec<&AssetRef> = target
984                            .refs
985                            .iter()
986                            .filter(|r| matches!(r, AssetRef::Comment { .. }))
987                            .collect();
988                        let notes: Vec<&AssetRef> = target.refs.iter().filter(|r|matches!(r,AssetRef::Note {..})).collect();
989                        let avatars: Vec<&AssetRef> = target
990                            .refs
991                            .iter()
992                            .filter(|r| {
993                                matches!(
994                                    r,
995                                    AssetRef::UserAvatar { .. } | AssetRef::FriendAvatar { .. }
996                                )
997                            })
998                            .collect();
999                        let total = target.ref_count;
1000                        rsx! {
1001                            Popover {
1002                                open: true,
1003                                anchor_x,
1004                                anchor_y,
1005                                placement: "bottom",
1006                                align: pop_align,
1007                                on_close: move |_| dismiss_refs(()),
1008                                div { class: "w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto",
1009                                    p { class: "text-sm font-medium text-[var(--color-paper-primary)]",
1010                                        "引用此素材 · {total} 处"
1011                                    }
1012                                    if target.refs.is_empty() {
1013                                        // ref_count 与明细正常必然一致;不一致(极端竞态)时兜底。
1014                                        p { class: "mt-2 text-xs text-[var(--color-paper-tertiary)]",
1015                                            "未找到引用明细,请刷新后重试"
1016                                        }
1017                                    }
1018                                    if !posts.is_empty() {
1019                                        p { class: "mt-3 mb-1 text-[10px] font-mono tracking-widest text-[var(--color-paper-tertiary)]",
1020                                            "文章 · {posts.len()}"
1021                                        }
1022                                        for r in posts {
1023                                            {render_ref_row(r, dismiss_refs)}
1024                                        }
1025                                    }
1026                                    if !comments.is_empty() {
1027                                        // 评论与笔记分别展示,笔记历史引用也受保护。
1028                                        p { class: "mt-3 mb-1 text-[10px] font-mono tracking-widest text-[var(--color-paper-tertiary)]",
1029                                            "评论 · {comments.len()}"
1030                                        }
1031                                        for r in comments {
1032                                            {render_ref_row(r, dismiss_refs)}
1033                                        }
1034                                    }
1035                                    if !notes.is_empty() {
1036                                        p {class:"mt-3 mb-1 text-[10px] font-mono tracking-widest text-[var(--color-paper-tertiary)]","笔记 · {notes.len()}"}
1037                                        for r in notes {{render_ref_row(r,dismiss_refs)}}
1038                                    }
1039                                    if !avatars.is_empty() {
1040                                        p { class: "mt-3 mb-1 text-[10px] font-mono tracking-widest text-[var(--color-paper-tertiary)]",
1041                                            "头像 · {avatars.len()}"
1042                                        }
1043                                        for r in avatars {
1044                                            {render_ref_row(r, dismiss_refs)}
1045                                        }
1046                                    }
1047                                }
1048                            }
1049                        }
1050                    }
1051                }
1052            }
1053
1054            // 页内上传 modal:始终挂载,visible 控制渲染;上传成功刷新网格
1055            // (不重置页码/排序,按当前排序自然刷新——「最新」下新图在头部)。
1056            AssetUploadModal {
1057                visible: upload_open,
1058                on_uploaded: move |_| reload.set(reload() + 1),
1059            }
1060        }
1061    }
1062}