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