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}