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