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}