Skip to main content

yggdrasil/pages/admin/
assets.rs

1//! 素材管理页面。
2//!
3//! 网格浏览 `uploads/` 已登记图片:搜索(文件名/alt)、引用状态筛选
4//! (全部/引用中/未引用)、排序(最新/最大)、客户端分页。
5//! 缩略图直接复用 `serve_image` 的动态处理(`?thumb=300x300`),零额外成本。
6//!
7//! 缩略图采用与前台正文图一致的 `.blur-img` 双层结构(`?w=20` 占位 + `data-src`
8//! 展示层),点击由全局注入的 `lightbox.js` 接管为灯箱预览(图集模式,当前页内
9//! 左右切换,灯箱加载原图 = `data-src` 去 query)。数据异步到达/刷新后由
10//! `use_effect` 调 `__initLightbox` 绑定;TS 端有 `data-lb-bound` 守卫,重复绑定幂等。
11
12use dioxus::prelude::*;
13
14// server fn 仅在 WASM 前端调用(全部包在 cfg(wasm32) 块内),server SSR 只编译类型。
15use crate::api::assets::AssetListResponse;
16#[cfg(target_arch = "wasm32")]
17use crate::api::assets::{
18    batch_delete_assets, delete_asset, list_assets, purge_orphan_assets, rebuild_assets_index,
19    update_asset_alt,
20};
21#[cfg(target_arch = "wasm32")]
22use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse};
23use crate::components::empty_state::EmptyState;
24use crate::components::forms::FormInput;
25use crate::components::ui::{FilterTabs, Pagination};
26#[cfg(target_arch = "wasm32")]
27use crate::models::asset::{AssetFilter, AssetSort};
28#[cfg(target_arch = "wasm32")]
29use crate::utils::js::invoke_optional_global;
30use std::collections::HashSet;
31
32/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
33const ASSETS_PER_PAGE: i32 = 60;
34
35/// 搜索输入防抖窗口:停顿该时长后才提交查询,避免逐键发请求。
36/// 仅在 wasm32 的防抖任务中引用,server 编译门控掉。
37#[cfg(target_arch = "wasm32")]
38const SEARCH_DEBOUNCE_MS: u32 = 300;
39
40/// 格式化字节数为可读字符串(B/KB/MB/GB)。
41fn format_bytes(bytes: i64) -> String {
42    const KB: f64 = 1024.0;
43    const MB: f64 = KB * 1024.0;
44    const GB: f64 = MB * 1024.0;
45    let b = bytes as f64;
46    if b >= GB {
47        format!("{:.1} GB", b / GB)
48    } else if b >= MB {
49        format!("{:.1} MB", b / MB)
50    } else if b >= KB {
51        format!("{:.1} KB", b / KB)
52    } else {
53        format!("{} B", bytes)
54    }
55}
56
57/// 素材管理入口组件。
58// 交互逻辑全部 cfg(wasm32) 门控,server SSR 编译时一批绑定未使用,按 CoverUploader 惯例放行。
59#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
60#[component]
61pub fn Assets() -> Element {
62    // 筛选/搜索/排序/分页状态:全部客户端驱动(单路由 + signal,对齐「管理文章」模式)。
63    let mut filter = use_signal(|| "all".to_string());
64    let mut query = use_signal(String::new);
65    let mut sort = use_signal(|| "created".to_string());
66    let mut page = use_signal(|| 1_i32);
67
68    #[allow(unused_mut)]
69    let mut data: Signal<Option<AssetListResponse>> = use_signal(|| None);
70    #[allow(unused_mut)]
71    let mut loading: Signal<bool> = use_signal(|| true);
72    #[allow(unused_mut)]
73    let mut error: Signal<Option<String>> = use_signal(|| None);
74
75    // 操作结果横幅(删除/清理/alt 编辑的反馈)。
76    #[allow(unused_mut)]
77    let mut op_message: Signal<Option<String>> = use_signal(|| None);
78    // 待二次确认的删除目标(素材 id)与一键清理确认态。
79    let mut confirm_delete: Signal<Option<String>> = use_signal(|| None);
80    let mut purge_confirm = use_signal(|| false);
81    // 重建索引进行中状态。
82    let mut rebuilding = use_signal(|| false);
83    // alt 内联编辑:目标素材 id + 输入框值。
84    let mut editing_alt: Signal<Option<String>> = use_signal(|| None);
85    let mut alt_input = use_signal(String::new);
86    // 重载触发器:操作成功后 +1 让 effect 重新请求。
87    let mut reload = use_signal(|| 0_i32);
88    // 多选:选中素材 id 集合 + 批量删除确认态。仅未引用素材可选(被引用的禁删,
89    // 与单删保护语义一致);选择跨翻页/筛选保留,批量删除成功后整体清空。
90    let mut selected_ids: Signal<HashSet<String>> = use_signal(HashSet::new);
91    let mut batch_confirm = use_signal(|| false);
92
93    // 搜索防抖:query 是输入框原始值(受控绑定),debounced_query 才是请求参数。
94    // 停顿 300ms 无新输入才提交;每次击键重启本 effect 并新 spawn 一个延时任务,
95    // 旧任务醒来后用 peek 比对当前 query,已过期则静默丢弃。提交时连带重置页码——
96    // 若放在 oninput 里,page 变化会立刻用旧关键词抢发一次请求。
97    let mut debounced_query = use_signal(String::new);
98    use_effect(move || {
99        let q = query();
100        #[cfg(target_arch = "wasm32")]
101        spawn(async move {
102            crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
103            if *query.peek() == q && *debounced_query.peek() != q {
104                debounced_query.set(q);
105                page.set(1);
106            }
107        });
108    });
109
110    // 数据加载:任一查询条件或 reload 变化时重新请求。筛选/搜索/排序变化时重置到第 1 页。
111    use_effect(move || {
112        let f = filter();
113        let q = debounced_query();
114        let s = sort();
115        let p = page();
116        let _ = reload();
117
118        #[cfg(target_arch = "wasm32")]
119        {
120            let filter_enum = match f.as_str() {
121                "used" => AssetFilter::Used,
122                "orphan" => AssetFilter::Orphan,
123                _ => AssetFilter::All,
124            };
125            let sort_enum = if s == "size" {
126                AssetSort::SizeDesc
127            } else {
128                AssetSort::CreatedDesc
129            };
130            spawn(async move {
131                loading.set(true);
132                error.set(None);
133                match list_assets(filter_enum, q, sort_enum, p).await {
134                    Ok(resp) => data.set(Some(resp)),
135                    Err(e) => error.set(Some(e.to_string())),
136                }
137                loading.set(false);
138            });
139        }
140    });
141
142    // 灯箱初始化:lightbox.js 由 Dioxus.toml 全局注入。网格随数据异步渲染,
143    // 需在数据到达(DOM 提交后)调 __initLightbox 绑定;筛选/翻页/刷新重建节点后
144    // 重跑此 effect 重新绑定。TS 端 data-lb-bound 守卫保证重复绑定幂等。
145    #[cfg(target_arch = "wasm32")]
146    use_effect(move || {
147        // 订阅 data:取数/刷新后网格重渲染,effect 在 DOM 更新后运行。
148        if data.read().is_none() {
149            return;
150        }
151        let window =
152            web_sys::window().expect("assets use_effect 仅在 WASM 浏览器上下文执行:无 window");
153        // 双保险契约(同 PostContent):先设全局配置,lightbox.js 若尚未加载完,
154        // 其 IIFE 尾部读到配置自启动;已加载则下方直接调用兜底。
155        let selectors = js_sys::Array::of1(&".assets-lightbox".into());
156        let selectors_val = js_sys::Object::from(selectors).into();
157        let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
158        invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
159    });
160
161    let resp = data.read();
162    let (assets, total, used_count, orphan_count, purgeable_count, purgeable_bytes) =
163        match resp.as_ref() {
164            Some(r) => (
165                r.assets.clone(),
166                r.total,
167                r.used_count,
168                r.orphan_count,
169                r.purgeable_count,
170                r.purgeable_bytes,
171            ),
172            None => (Vec::new(), 0, 0, 0, 0, 0),
173        };
174    let all_count = used_count + orphan_count;
175    drop(resp);
176
177    // Dioxus 格式化段不支持内联 if 块表达式,条件 class 提前算好。
178    let sort_btn_base = "text-xs font-mono tracking-widest uppercase cursor-pointer px-3 py-2 rounded-full border transition-colors";
179    let sort_active = "border-[var(--color-paper-primary)] text-[var(--color-paper-primary)]";
180    let sort_idle = "border-[var(--color-paper-border)] text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)]";
181    let sort_created_class = if sort() == "created" {
182        format!("{sort_btn_base} {sort_active}")
183    } else {
184        format!("{sort_btn_base} {sort_idle}")
185    };
186    let sort_size_class = if sort() == "size" {
187        format!("{sort_btn_base} {sort_active}")
188    } else {
189        format!("{sort_btn_base} {sort_idle}")
190    };
191
192    // 多选派生值:本页可删(未引用)素材 id、是否已全选本页、是否有任何选择
193    // (驱动未选中卡片的勾选框常显)。全选/取消本页闭包各需一份 id 列表拷贝。
194    let page_orphan_ids: Vec<String> = assets
195        .iter()
196        .filter(|item| item.ref_count == 0)
197        .map(|item| item.asset.id.clone())
198        .collect();
199    let any_selected = !selected_ids().is_empty();
200    let all_page_selected =
201        !page_orphan_ids.is_empty() && page_orphan_ids.iter().all(|id| selected_ids().contains(id));
202    let page_orphan_ids_for_toggle = page_orphan_ids.clone();
203
204    rsx! {
205        div {
206            h1 { class: "text-3xl font-extrabold tracking-tight mb-2", "素材管理" }
207            p { class: "text-sm text-[var(--color-paper-secondary)] mb-8",
208                "管理文章编辑器上传的图片。共 {all_count} 张,引用中 {used_count} 张,未引用 {orphan_count} 张。"
209            }
210
211            // 顶栏:筛选 tabs + 搜索 + 排序
212            div { class: "flex flex-wrap items-end justify-between gap-4",
213                FilterTabs {
214                    items: vec![("all", "全部"), ("used", "引用中"), ("orphan", "未引用")],
215                    active_value: filter(),
216                    on_change: move |v: String| {
217                        filter.set(v);
218                        page.set(1);
219                    },
220                }
221                // 右侧控件与 FilterTabs 同加 mb-6:items-end 对齐的是 margin box,
222                // 两者底边同落在 tabs 下划线处,到下方横幅/网格的距离均为 24px。
223                div { class: "flex items-center gap-3 mb-6",
224                    // 重建索引:以磁盘为准全量自愈(存量回填/不一致修复)。
225                    button {
226                        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",
227                        disabled: rebuilding(),
228                        title: "扫描 uploads/ 全量文件,同步素材注册表与文章引用(幂等,可随时重跑)",
229                        onclick: move |_| {
230                            rebuilding.set(true);
231                            #[cfg(target_arch = "wasm32")]
232                            spawn(async move {
233                                match rebuild_assets_index().await {
234                                    Ok(RebuildAssetsResponse { message, .. }) => {
235                                        op_message.set(Some(message));
236                                        reload.set(reload() + 1);
237                                    }
238                                    Err(e) => op_message.set(Some(format!("重建失败:{e}"))),
239                                }
240                                rebuilding.set(false);
241                            });
242                        },
243                        if rebuilding() {
244                            "重建中..."
245                        } else {
246                            "重建索引"
247                        }
248                    }
249                    FormInput {
250                        r#type: "search",
251                        placeholder: "搜索文件名 / alt",
252                        value: query(),
253                        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",
254                        oninput: move |v: String| query.set(v),
255                    }
256                    button {
257                        class: "{sort_created_class}",
258                        onclick: move |_| {
259                            sort.set("created".to_string());
260                            page.set(1);
261                        },
262                        "最新"
263                    }
264                    button {
265                        class: "{sort_size_class}",
266                        onclick: move |_| {
267                            sort.set("size".to_string());
268                            page.set(1);
269                        },
270                        "最大"
271                    }
272                    // 一键清理孤儿:仅 7 天保护窗外的无引用素材;两步确认。
273                    if purgeable_count > 0 {
274                        if purge_confirm() {
275                            button {
276                                class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
277                                onclick: move |_| {
278                                    purge_confirm.set(false);
279                                    #[cfg(target_arch = "wasm32")]
280                                    spawn(async move {
281                                        match purge_orphan_assets().await {
282                                            Ok(
283                                                PurgeOrphansResponse { deleted_count, freed_bytes, failures, .. },
284                                            ) => {
285                                                let mut msg = format!(
286                                                    "已清理 {} 张未引用素材,释放 {}",
287                                                    deleted_count,
288                                                    format_bytes(freed_bytes),
289                                                );
290                                                if failures > 0 {
291                                                    msg.push_str(
292                                                        &format!("({} 个文件删除失败)", failures),
293                                                    );
294                                                }
295                                                op_message.set(Some(msg));
296                                                reload.set(reload() + 1);
297                                            }
298                                            Err(e) => op_message.set(Some(format!("清理失败:{e}"))),
299                                        }
300                                    });
301                                },
302                                "确认清理 {purgeable_count} 张({format_bytes(purgeable_bytes)})"
303                            }
304                            button {
305                                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",
306                                onclick: move |_| purge_confirm.set(false),
307                                "取消"
308                            }
309                        } else {
310                            button {
311                                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",
312                                title: "仅清理无引用且上传超过 7 天的素材(保护未保存的草稿)",
313                                onclick: move |_| purge_confirm.set(true),
314                                "清理未引用({purgeable_count} 张 · {format_bytes(purgeable_bytes)})"
315                            }
316                        }
317                    }
318                }
319            }
320
321            // 操作结果横幅
322            if let Some(msg) = op_message() {
323                // mt-4 会与 FilterTabs 自带的 mb-6 叠加成大空洞;改用 mb-6 后
324                // 上方 = tabs 标准间距 24px,下方与网格 mt-2 塌陷同为 24px,对称。
325                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",
326                    span { "{msg}" }
327                    button {
328                        class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] cursor-pointer",
329                        onclick: move |_| op_message.set(None),
330                        "×"
331                    }
332                }
333            }
334
335            // 多选批量操作条:出现即与操作横幅同槽位(顶栏与网格之间)。
336            if any_selected {
337                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",
338                    span { class: "text-sm text-[var(--color-paper-secondary)]",
339                        "已选 {selected_ids().len()} 张"
340                    }
341                    button {
342                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
343                        onclick: move |_| {
344                            let mut s = selected_ids();
345                            if all_page_selected {
346                                for id in &page_orphan_ids_for_toggle {
347                                    s.remove(id);
348                                }
349                            } else {
350                                for id in &page_orphan_ids_for_toggle {
351                                    s.insert(id.clone());
352                                }
353                            }
354                            selected_ids.set(s);
355                        },
356                        if all_page_selected {
357                            "取消本页"
358                        } else {
359                            "全选本页"
360                        }
361                    }
362                    button {
363                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
364                        onclick: move |_| {
365                            selected_ids.set(HashSet::new());
366                            batch_confirm.set(false);
367                        },
368                        "清除"
369                    }
370                    div { class: "flex-1" }
371                    if batch_confirm() {
372                        button {
373                            class: "text-xs font-medium cursor-pointer px-3 py-2 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
374                            onclick: move |_| {
375                                batch_confirm.set(false);
376                                let ids: Vec<String> = selected_ids().iter().cloned().collect();
377                                #[cfg(target_arch = "wasm32")]
378                                spawn(async move {
379                                    match batch_delete_assets(ids).await {
380                                        Ok(resp) => {
381                                            let BatchDeleteAssetsResponse {
382                                                message,
383                                                freed_bytes,
384                                                failures,
385                                                deleted_count,
386                                                ..
387                                            } = resp;
388                                            let mut msg = message;
389                                            if freed_bytes > 0 {
390                                                msg.push_str(
391                                                    &format!(",释放 {}", format_bytes(freed_bytes)),
392                                                );
393                                            }
394                                            if failures > 0 {
395                                                msg.push_str(&format!("({failures} 项失败)"));
396                                            }
397                                            op_message.set(Some(msg));
398                                            if deleted_count > 0 {
399                                                selected_ids.set(HashSet::new());
400                                                reload.set(reload() + 1);
401                                            }
402                                        }
403                                        Err(e) => op_message.set(Some(format!("批量删除失败:{e}"))),
404                                    }
405                                });
406                            },
407                            "确认删除 {selected_ids().len()} 张"
408                        }
409                        button {
410                            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",
411                            onclick: move |_| batch_confirm.set(false),
412                            "取消"
413                        }
414                    } else {
415                        button {
416                            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",
417                            onclick: move |_| batch_confirm.set(true),
418                            "删除所选"
419                        }
420                    }
421                }
422            }
423
424            // 内容区
425            if let Some(err) = error() {
426                div { class: "mt-8 text-sm text-red-500", "加载失败:{err}" }
427            } else if loading() && assets.is_empty() {
428                div { class: "mt-8 text-sm text-[var(--color-paper-secondary)]", "加载中..." }
429            } else if assets.is_empty() {
430                EmptyState {
431                    title: "暂无素材".to_string(),
432                    description: "在编辑器中上传图片后会自动出现在这里".to_string(),
433                }
434            } else {
435                // 网格:缩略图卡片(assets-lightbox 为 __initLightbox 的根选择器)
436                div { class: "assets-lightbox grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 mt-2",
437                    for asset in assets.iter() {
438                        {
439                            let a = &asset.asset;
440                            let thumb = format!("/uploads/{}?thumb=300x300", a.path);
441                            let placeholder = format!("/uploads/{}?w=20", a.path);
442                            let img_alt = a.alt.clone().unwrap_or_else(|| a.filename.clone());
443                            let is_orphan = asset.ref_count == 0;
444                            let is_selected = selected_ids().contains(&a.id);
445                            // 选中卡片加主题色描边(ring 与原有 border 叠加,不动布局)。
446                            let card_ring = if is_selected {
447                                "ring-2 ring-[var(--color-paper-accent)]"
448                            } else {
449                                ""
450                            };
451                            // 勾选框:未引用素材才可删可选。选中或有任何选择时常显,
452                            // 否则随卡片 hover 显现(与卡片操作按钮同一显现语言)。
453                            let checkbox_class = if is_selected {
454                                "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)]"
455                            } else if any_selected {
456                                "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"
457                            } else {
458                                "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"
459                            };
460                            // z-10:.blur-img-full 带 z-index:1,不提升会被展示层盖住(灯箱改造的回归)。
461                            let badge_class = if is_orphan {
462                                "absolute top-2 left-2 z-10 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-amber-500/80 text-white"
463                            } else {
464                                "absolute top-2 left-2 z-10 text-[10px] font-mono px-2 py-0.5 rounded-full backdrop-blur-sm bg-black/50 text-white"
465                            };
466                            rsx! {
467                                div {
468                                    key: "{a.id}",
469                                    class: "group relative rounded-3xl overflow-hidden border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-sm hover:shadow-md transition-all {card_ring}",
470                                    // blur-img 双层结构(对齐前台正文图):?w=20 模糊占位 +
471                                    // data-src 展示层(IO 懒加载);点击由 lightbox.js 接管为灯箱
472                                    // (图集模式,原图 = data-src 去 query)。不加 lightbox-single。
473                                    div { class: "blur-img aspect-square m-0 cursor-pointer bg-[var(--color-paper-theme)]",
474                                        img {
475                                            class: "blur-img-placeholder",
476                                            src: "{placeholder}",
477                                            alt: "",
478                                        }
479                                        img {
480                                            class: "blur-img-full",
481                                            "data-src": "{thumb}",
482                                            alt: "{img_alt}",
483                                        }
484                                    }
485                                    // 引用徽标
486                                    span {
487                                    class: "{badge_class}",
488                                        if is_orphan {
489                                            "未引用"
490                                        } else {
491                                            "被 {asset.ref_count} 篇引用"
492                                        }
493                                    }
494                                    // 多选勾选框(仅未引用素材;stop_propagation 防触发灯箱)
495                                    if is_orphan {
496                                        button {
497                                            class: "{checkbox_class}",
498                                            title: if is_selected { "取消选择" } else { "选择" },
499                                            onclick: {
500                                                let id = a.id.clone();
501                                                move |evt| {
502                                                    evt.stop_propagation();
503                                                    let mut s = selected_ids();
504                                                    if s.contains(&id) {
505                                                        s.remove(&id);
506                                                    } else {
507                                                        s.insert(id.clone());
508                                                    }
509                                                    selected_ids.set(s);
510                                                }
511                                            },
512                                            if is_selected {
513                                                "✓"
514                                            }
515                                        }
516                                    }
517                                    div { class: "p-3",
518                                        p {
519                                            class: "text-xs font-medium truncate text-[var(--color-paper-primary)]",
520                                            title: "{a.filename}",
521                                            "{a.filename}"
522                                        }
523                                        p { class: "text-[10px] font-mono text-[var(--color-paper-tertiary)] mt-0.5",
524                                            "{a.width}×{a.height} · {format_bytes(a.size_bytes)}"
525                                        }
526                                        if let Some(alt_text) = &a.alt {
527                                            p {
528                                                class: "text-[10px] truncate text-[var(--color-paper-secondary)] mt-0.5",
529                                                title: "{alt_text}",
530                                                "alt: {alt_text}"
531                                            } // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在) // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在) // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在)  行已不在 DB(refs 为空的业务拒绝 = 素材不存在) // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在)  行已不在 DB(refs 为空的业务拒绝 = 素材不存在)  行已不在 DB(refs 为空的业务拒绝 = 素材不存在)  行已不在 DB(refs 为空的业务拒绝 = 素材不存在)
532                                        }
533
534                                        // 操作区:确认删除 / alt 编辑 / 常规三按钮 三态互斥
535                                        if confirm_delete().as_deref() == Some(a.id.as_str()) {
536                                            div { class: "flex items-center gap-2 mt-2",
537                                                button {
538                                                    class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors",
539                                                    onclick: {
540                                                        let id = a.id.clone();
541                                                        move |_| {
542                                                            confirm_delete.set(None);
543                                                            let id = id.clone();
544                                                            #[cfg(target_arch = "wasm32")]
545                                                            spawn(async move {
546                                                                match delete_asset(id).await {
547                                                                    Ok(resp) => {
548                                                                        // 行已不在 DB(refs 为空的业务拒绝 = 素材不存在)
549                                                                        // 说明网格是过期数据,同样触发刷新自愈。
550                                                                        let stale = !resp.success && resp.refs.is_empty();
551                                                                        op_message.set(Some(resp.message));
552                                                                        if resp.success || stale {
553                                                                            reload.set(reload() + 1);
554                                                                        }
555                                                                    }
556                                                                    Err(e) => op_message
557                                                                        .set(Some(format!("删除失败:{e}"))),
558                                                                }
559                                                            });
560                                                        }
561                                                    },
562                                                    "确认删除"
563                                                }
564                                                button {
565                                                    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",
566                                                    onclick: move |_| confirm_delete.set(None),
567                                                    "取消"
568                                                }
569                                            }
570                                        } else if editing_alt().as_deref() == Some(a.id.as_str()) {
571                                            div { class: "flex items-center gap-1 mt-2",
572                                                FormInput {
573                                                    r#type: "text",
574                                                    placeholder: "alt 文本",
575                                                    value: alt_input(),
576                                                    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",
577                                                    oninput: move |v: String| alt_input.set(v),
578                                                }
579                                                button {
580                                                    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",
581                                                    onclick: {
582                                                        let id = a.id.clone();
583                                                        move |_| {
584                                                            let id = id.clone();
585                                                            let alt = alt_input();
586                                                            editing_alt.set(None);
587                                                            #[cfg(target_arch = "wasm32")]
588                                                            spawn(async move {
589                                                                match update_asset_alt(id, alt).await {
590                                                                    Ok(resp) => {
591                                                                        op_message.set(Some(resp.message));
592                                                                        if resp.success {
593                                                                            reload.set(reload() + 1);
594                                                                        }
595                                                                    }
596                                                                    Err(e) => op_message
597                                                                        .set(Some(format!("保存失败:{e}"))),
598                                                                }
599                                                            });
600                                                        }
601                                                    },
602                                                    "存"
603                                                }
604                                                button {
605                                                    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",
606                                                    onclick: move |_| editing_alt.set(None),
607                                                    "×"
608                                                }
609                                            }
610                                        } else {
611                                            div { class: "flex items-center gap-2 mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
612                                                button {
613                                                    class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
614                                                    title: "复制图片相对路径",
615                                                    onclick: {
616                                                        let url = format!("/uploads/{}", a.path);
617                                                        move |_| {
618                                                            #[cfg(target_arch = "wasm32")]
619                                                            if let Some(window) = web_sys::window() {
620                                                                let _ = window
621                                                                    .navigator()
622                                                                    .clipboard()
623                                                                    .write_text(&url);
624                                                                op_message.set(Some(format!("已复制 {url}")));
625                                                            }
626                                                        }
627                                                    },
628                                                    "复制路径"
629                                                }
630                                                button {
631                                                    class: "text-[10px] cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors",
632                                                    title: "编辑 alt",
633                                                    onclick: {
634                                                        let id = a.id.clone();
635                                                        let current_alt = a.alt.clone().unwrap_or_default();
636                                                        move |_| {
637                                                            alt_input.set(current_alt.clone());
638                                                            editing_alt.set(Some(id.clone()));
639                                                        }
640                                                    },
641                                                    "alt"
642                                                }
643                                                if asset.ref_count > 0 {
644                                                    {
645                                                        let refs_tip = asset
646                                                            .refs
647                                                            .iter()
648                                                            .map(|r| r.title.clone())
649                                                            .collect::<Vec<_>>()
650                                                            .join("、");
651                                                        rsx! {
652                                                            span {
653                                                                class: "text-[10px] text-[var(--color-paper-tertiary)] cursor-not-allowed",
654                                                                title: "被引用:{refs_tip}",
655                                                                "删除"
656                                                            }
657                                                        }
658                                                    }
659                                                } else {
660                                                    button {
661                                                        class: "text-[10px] cursor-pointer text-red-500/70 hover:text-red-500 transition-colors",
662                                                        onclick: {
663                                                            let id = a.id.clone();
664                                                            move |_| confirm_delete.set(Some(id.clone()))
665                                                        },
666                                                        "删除"
667                                                    }
668                                                }
669                                            }
670                                        }
671                                    }
672                                }
673                            }
674                        }
675                    }
676                }
677
678                Pagination {
679                    variant: "admin",
680                    current_page: page(),
681                    total,
682                    per_page: ASSETS_PER_PAGE,
683                    unit: "张",
684                    on_prev: move |_| page.set((page() - 1).max(1)),
685                    on_next: move |_| page.set(page() + 1),
686                    on_jump: move |p: i32| page.set(p),
687                }
688            }
689        }
690    }
691}