1use crate::components::admin_layout::AdminPageHead;
14use leptos::ev::KeyboardEvent;
15use leptos::prelude::*;
16use leptos_router::{lazy_route, LazyRoute};
17use std::collections::HashSet;
18
19use crate::api::assets::AssetListResponse;
20use crate::components::assets::AssetUploadModal;
21use crate::components::empty_state::{EmptyState, EmptyStateAction};
22use crate::components::enhance_assets::LightboxAssets;
23use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
24use crate::components::mascot::Mascot;
25use crate::components::skeletons::assets_skeleton::AssetsGridSkeleton;
26use crate::components::ui::{
27 button_class, use_toast, ButtonSize, ButtonVariant, ConfirmDialog, Icon, IconButton, IconName,
28 LoadError, Menu, MenuItem, SegmentedControl, SegmentedItem, TablePager,
29};
30use crate::models::asset::{AssetDto, AssetFilter, AssetSort};
31use crate::utils::error::failure_message;
32use crate::utils::format_bytes;
33
34mod card;
35mod references;
36
37use card::AssetTile;
38use references::AssetDetail;
39
40const ASSETS_PER_PAGE: i32 = 60;
42const SEARCH_DEBOUNCE_MS: u32 = 300;
44
45pub struct AssetsRoute;
47
48#[lazy_route]
49impl LazyRoute for AssetsRoute {
50 fn data() -> Self {
51 Self
52 }
53
54 fn view(_this: Self) -> AnyView {
55 view! { <Assets/> }.into_any()
56 }
57}
58
59fn parse_filter(value: &str) -> AssetFilter {
60 match value {
61 "used" => AssetFilter::Used,
62 "orphan" => AssetFilter::Orphan,
63 _ => AssetFilter::All,
64 }
65}
66
67fn detail_docked() -> bool {
69 #[cfg(target_arch = "wasm32")]
71 const DETAIL_DOCK_MIN_WIDTH: f64 = 1100.0;
72
73 #[cfg(target_arch = "wasm32")]
74 {
75 web_sys::window()
76 .and_then(|w| w.inner_width().ok())
77 .and_then(|w| w.as_f64())
78 .is_some_and(|w| w >= DETAIL_DOCK_MIN_WIDTH)
79 }
80 #[cfg(not(target_arch = "wasm32"))]
81 {
82 false
83 }
84}
85
86fn find_asset(data: &Option<AssetListResponse>, id: &str) -> Option<AssetDto> {
88 data.as_ref()
89 .and_then(|r| r.assets.iter().find(|a| a.asset.id == id))
90 .cloned()
91}
92
93#[derive(Clone, Copy, PartialEq)]
95enum Body {
96 Error,
97 Skeleton,
98 EmptyFiltered,
100 EmptyOrphan,
102 EmptyAll,
103 Grid,
104}
105
106#[component]
108pub fn Assets() -> impl IntoView {
109 let toast = use_toast();
110 let filter = RwSignal::new("all".to_string());
111 let query = RwSignal::new(String::new());
112 let debounced_query = RwSignal::new(String::new());
113 let sort = RwSignal::new(AssetSort::CreatedDesc);
114 let page = RwSignal::new(1_i32);
115 let reload = RwSignal::new(0_u32);
116 let data = RwSignal::new(None::<AssetListResponse>);
117 let loading = RwSignal::new(true);
118 let error = RwSignal::new(false);
119 let active = RwSignal::new(None::<String>);
121 let checked = RwSignal::new(HashSet::<String>::new());
122 let confirm_delete = RwSignal::new(false);
123 let confirm_batch = RwSignal::new(false);
124 let confirm_purge = RwSignal::new(false);
125 let busy = RwSignal::new(false);
126 let upload_open = RwSignal::new(false);
127
128 Effect::new(move |_| {
130 let q = query.get();
131 leptos::task::spawn_local(async move {
132 crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
133 if query.try_get_untracked().as_deref() == Some(q.as_str())
134 && debounced_query.try_get_untracked().as_deref() != Some(q.as_str())
135 {
136 debounced_query.try_set(q);
137 page.try_set(1);
138 }
139 });
140 });
141
142 let generation = StoredValue::new(0_u64);
144 let bump_generation = move || {
145 generation
146 .try_update_value(|g| {
147 *g = g.wrapping_add(1);
148 *g
149 })
150 .unwrap_or(0)
151 };
152 on_cleanup(move || {
153 bump_generation();
154 });
155
156 Effect::new(move |_| {
157 let f = parse_filter(&filter.get());
158 let q = debounced_query.get();
159 let s = sort.get();
160 let p = page.get();
161 let _ = reload.get();
162 let request_id = bump_generation();
163 leptos::task::spawn_local(async move {
164 loading.try_set(true);
165 let result = crate::api::assets::list_assets(f, q, s, p).await;
166 if generation.try_get_value() != Some(request_id) {
167 return;
168 }
169 match result {
170 Ok(resp) => {
171 let keep = active.try_with_untracked(|id| {
173 id.as_ref()
174 .is_some_and(|id| resp.assets.iter().any(|a| &a.asset.id == id))
175 });
176 if keep == Some(false) {
177 active.try_set(
178 resp.assets
179 .first()
180 .filter(|_| detail_docked())
181 .map(|a| a.asset.id.clone()),
182 );
183 }
184 data.try_set(Some(resp));
185 error.try_set(false);
186 }
187 Err(_) => {
188 error.try_set(true);
189 }
190 }
191 loading.try_set(false);
192 });
193 });
194
195 Effect::new(move |_| {
198 let has_active = active.get().is_some();
199 #[cfg(target_arch = "wasm32")]
200 if has_active {
201 leptos::task::spawn_local(async move {
202 crate::utils::time::sleep_ms(30).await;
203 crate::bridges::globals::init_lightbox(&[".assets-lightbox"]);
204 });
205 }
206 #[cfg(not(target_arch = "wasm32"))]
207 let _ = has_active;
208 });
209
210 let refresh = move || {
211 reload.try_update(|n| *n += 1);
212 };
213 let delete_one = move |id: String| {
214 busy.set(true);
215 leptos::task::spawn_local(async move {
216 match crate::api::assets::delete_asset(id.clone()).await {
217 Ok(res) if res.success => {
218 toast.success(res.message);
219 checked.try_update(|c| {
220 c.remove(&id);
221 });
222 active.try_set(None);
223 refresh();
224 }
225 Ok(res) => toast.error(res.message),
226 Err(e) => toast.error(failure_message("删除失败", &e)),
227 }
228 busy.try_set(false);
229 });
230 };
231 let delete_checked = move || {
232 let ids: Vec<String> = checked.get_untracked().iter().cloned().collect();
233 busy.set(true);
234 leptos::task::spawn_local(async move {
235 match crate::api::assets::batch_delete_assets(ids).await {
236 Ok(res) => {
237 let mut msg = res.message;
238 if res.freed_bytes > 0 {
239 msg.push_str(&format!(",释放 {}", format_bytes(res.freed_bytes)));
240 }
241 if res.failures > 0 {
242 msg.push_str(&format!("({} 项失败)", res.failures));
243 toast.error(msg);
244 } else {
245 toast.success(msg);
246 }
247 checked.try_update(HashSet::clear);
248 refresh();
249 }
250 Err(e) => toast.error(failure_message("批量删除失败", &e)),
251 }
252 busy.try_set(false);
253 });
254 };
255 let purge = move || {
256 busy.set(true);
257 leptos::task::spawn_local(async move {
258 match crate::api::assets::purge_orphan_assets().await {
259 Ok(res) => {
260 let mut msg = format!(
261 "已清理 {} 张未引用素材,释放 {}",
262 res.deleted_count,
263 format_bytes(res.freed_bytes)
264 );
265 if res.failures > 0 {
266 msg.push_str(&format!("({} 个文件删除失败)", res.failures));
267 }
268 toast.success(msg);
269 refresh();
270 }
271 Err(e) => toast.error(failure_message("清理失败", &e)),
272 }
273 busy.try_set(false);
274 });
275 };
276 let rebuild = move || {
277 busy.set(true);
278 leptos::task::spawn_local(async move {
279 match crate::api::assets::rebuild_assets_index().await {
280 Ok(res) => {
281 toast.success(res.message);
282 refresh();
283 }
284 Err(e) => toast.error(failure_message("重建失败", &e)),
285 }
286 busy.try_set(false);
287 });
288 };
289
290 let loaded = Memo::new(move |_| data.with(Option::is_some));
292 let used = Memo::new(move |_| data.with(|d| d.as_ref().map_or(0, |r| r.used_count)));
293 let orphan = Memo::new(move |_| data.with(|d| d.as_ref().map_or(0, |r| r.orphan_count)));
294 let purgeable = Memo::new(move |_| data.with(|d| d.as_ref().map_or(0, |r| r.purgeable_count)));
295 let purgeable_bytes =
296 Memo::new(move |_| data.with(|d| d.as_ref().map_or(0, |r| r.purgeable_bytes)));
297 let total = Memo::new(move |_| data.with(|d| d.as_ref().map_or(0, |r| r.total)));
298 let summary = Memo::new(move |_| {
299 loaded.get().then(|| {
300 let (used, orphan, purgeable) = (used.get(), orphan.get(), purgeable.get());
301 let mut s = format!("{} 张图片 · 已引用 {used} · 未引用 {orphan}", used + orphan);
302 if purgeable > 0 {
303 s.push_str(&format!(" · 可清理 {purgeable}"));
304 }
305 s
306 })
307 });
308 let detail_id = Memo::new(move |_| {
310 active.get().filter(|id| {
311 data.with(|d| {
312 d.as_ref()
313 .is_some_and(|r| r.assets.iter().any(|a| &a.asset.id == id))
314 })
315 })
316 });
317 let detail_name = Memo::new(move |_| {
318 detail_id
319 .get()
320 .and_then(|id| data.with(|d| find_asset(d, &id)))
321 .map(|a| a.asset.filename)
322 .unwrap_or_default()
323 });
324 let selection_count = Memo::new(move |_| checked.with(HashSet::len));
325 let has_selection = Memo::new(move |_| selection_count.get() > 0);
326
327 let menu_items = Signal::derive(move || {
328 let mut purge_item = MenuItem::new("purge", "清理未引用…")
329 .icon(IconName::Trash)
330 .danger()
331 .separated();
332 if purgeable.get() == 0 {
333 purge_item = purge_item.disabled("没有上传满 7 天的未引用素材");
334 }
335 vec![
336 MenuItem::new("rebuild", "重建索引").icon(IconName::RefreshCw),
337 purge_item,
338 ]
339 });
340 let filter_items = Signal::derive(move || {
341 let loaded = loaded.get();
342 let (used, orphan) = (used.get(), orphan.get());
343 [
344 ("all", "全部", used + orphan),
345 ("used", "已引用", used),
346 ("orphan", "未引用", orphan),
347 ]
348 .into_iter()
349 .map(|(value, label, n)| {
350 let item = SegmentedItem::new(value.to_string(), label);
351 if loaded {
352 item.count(n)
353 } else {
354 item
355 }
356 })
357 .collect::<Vec<_>>()
358 });
359
360 let body = Memo::new(move |_| {
361 if error.get() {
362 Body::Error
363 } else if !loaded.get() {
364 Body::Skeleton
365 } else if data.with(|d| d.as_ref().is_some_and(|r| r.assets.is_empty())) {
366 if debounced_query.with(|q| !q.is_empty()) {
367 Body::EmptyFiltered
368 } else if filter.with(|f| f == "orphan") {
369 Body::EmptyOrphan
370 } else {
371 Body::EmptyAll
372 }
373 } else {
374 Body::Grid
375 }
376 });
377
378 view! {
379 <LightboxAssets/>
381 <div class="admin-assets" data-detail=move || detail_id.get().map(|_| "true")>
382 <div class="admin-assets-main">
383 <AdminPageHead
384 title="素材"
385 actions=move || {
386 view! {
387 <Menu
388 label="更多操作"
389 size=ButtonSize::Md
390 items=menu_items
391 on_select=move |id: &'static str| match id {
392 "rebuild" => rebuild(),
393 "purge" => confirm_purge.set(true),
394 _ => {}
395 }
396 />
397 <button
398 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
399 type="button"
400 on:click=move |_| upload_open.set(true)
401 >
402 <Icon name=IconName::Upload size=15/>
403 "上传"
404 </button>
405 }
406 }
407 >
408 {move || summary.get()}
409 </AdminPageHead>
410
411 <div class="admin-toolbar">
412 <SegmentedControl
413 aria_label="引用状态"
414 items=filter_items
415 value=filter
416 on_change=move |v: String| {
417 filter.set(v);
418 page.set(1);
419 }
420 />
421 <div class="admin-toolbar-filters">
422 <label class="field field-sm admin-filter-search">
423 <Icon name=IconName::Search size=14/>
424 <span class="sr-only">"搜索素材"</span>
425 <input
426 type="search"
427 placeholder="文件名或替代文本"
428 prop:value=move || query.get()
429 on:input=move |e| query.set(event_target_value(&e))
430 on:keydown=move |e: KeyboardEvent| {
431 if e.key() == "Escape" && query.with_untracked(|q| !q.is_empty()) {
432 e.prevent_default();
433 query.set(String::new());
434 }
435 }
436 />
437 </label>
438 <FormSelect
439 id="admin-assets-sort"
440 value=sort
441 options=vec![
442 (AssetSort::CreatedDesc, "最新上传"),
443 (AssetSort::SizeDesc, "文件最大"),
444 ]
445 trigger_class=FORM_SELECT_COMPACT_CLASS
446 aria_label="排序"
447 onchange=move |v: AssetSort| {
448 sort.set(v);
449 page.set(1);
450 }
451 />
452 </div>
453 </div>
454
455 {move || match body.get() {
456 Body::Error => {
457 view! { <LoadError title="素材加载失败" on_retry=move |_| refresh()/> }
458 .into_any()
459 }
460 Body::Skeleton => view! { <AssetsGridSkeleton/> }.into_any(),
461 Body::EmptyFiltered => {
462 view! {
463 <div class="admin-panel">
464 <EmptyState
465 icon=IconName::SearchX
466 title="没有符合条件的素材"
467 description="换个文件名或替代文本再试。"
468 action=EmptyStateAction::new(
469 "清除搜索",
470 move |_| query.set(String::new()),
471 )
472 />
473 </div>
474 }
475 .into_any()
476 }
477 Body::EmptyOrphan => {
478 view! {
479 <div class="admin-panel">
480 <EmptyState
481 icon=IconName::Image
482 title="没有未引用的素材"
483 description="每张图片都有文章、笔记、评论或头像在用。"
484 />
485 </div>
486 }
487 .into_any()
488 }
489 Body::EmptyAll => {
490 view! {
491 <div class="admin-panel">
492 <EmptyState
493 mascot=Mascot::Sit
494 title="还没有素材"
495 description="在编辑器里插入的图片,或在这里上传的图片,都会出现在这里。"
496 action=EmptyStateAction::new(
497 "上传图片",
498 move |_| upload_open.set(true),
499 )
500 />
501 </div>
502 }
503 .into_any()
504 }
505 Body::Grid => {
506 view! {
507 <ul class="asset-grid" aria-busy=move || loading.get().then_some("true")>
508 <For
509 each=move || {
510 data.with(|d| {
511 d.as_ref().map(|r| r.assets.clone()).unwrap_or_default()
512 })
513 }
514 key=|item| item.asset.id.clone()
515 children=move |item| {
516 let id = item.asset.id.clone();
517 let asset = Memo::new({
520 let id = id.clone();
521 move |_| {
522 data.with(|d| find_asset(d, &id))
523 .unwrap_or_else(|| item.clone())
524 }
525 });
526 let is_active = Signal::derive({
527 let id = id.clone();
528 move || {
529 active.with(|a| a.as_deref() == Some(id.as_str()))
530 }
531 });
532 let is_checked = Signal::derive(move || {
533 checked.with(|c| c.contains(&id))
534 });
535 view! {
536 <AssetTile
537 asset=asset
538 active=is_active
539 checked=is_checked
540 on_open=move |id: String| active.set(Some(id))
541 on_check=move |(id, on): (String, bool)| {
542 checked
543 .update(|c| {
544 if on {
545 c.insert(id);
546 } else {
547 c.remove(&id);
548 }
549 });
550 }
551 />
552 }
553 }
554 />
555 </ul>
556 <div class="admin-panel asset-pager">
557 <TablePager
558 page=page
559 total=total
560 per_page=ASSETS_PER_PAGE
561 unit="张"
562 on_change=move |p| page.set(p)
563 />
564 </div>
565 }
566 .into_any()
567 }
568 }}
569
570 {move || {
571 has_selection
572 .get()
573 .then(|| {
574 view! {
575 <div class="batch-bar" role="toolbar" aria-label="批量操作">
576 <span class="batch-bar-count">
577 {move || format!("已选 {} 张", selection_count.get())}
578 </span>
579 <button
580 class=format!(
581 "{} batch-bar-danger",
582 button_class(ButtonVariant::Ghost, ButtonSize::Sm),
583 )
584 type="button"
585 disabled=move || busy.get()
586 on:click=move |_| confirm_batch.set(true)
587 >
588 "删除所选"
589 </button>
590 <span class="batch-bar-sep" aria-hidden="true"></span>
591 <IconButton
592 icon=IconName::X
593 label="取消选择"
594 size=ButtonSize::Sm
595 onclick=move |_| {
596 checked.update(HashSet::clear);
597 }
598 />
599 </div>
600 }
601 })
602 }}
603 </div>
604
605 {move || {
606 detail_id
607 .get()
608 .and_then(|id| {
609 let fallback = data.with_untracked(|d| find_asset(d, &id))?;
610 let asset = Memo::new(move |_| {
611 data.with(|d| find_asset(d, &id)).unwrap_or_else(|| fallback.clone())
612 });
613 Some(
614 view! {
615 <div class="asset-detail-slot">
616 <AssetDetail
617 asset=asset
618 on_changed=move |_| refresh()
619 on_delete=move |_| confirm_delete.set(true)
620 on_close=move |_| active.set(None)
621 />
622 </div>
623 },
624 )
625 })
626 }}
627
628 <ConfirmDialog
629 visible=confirm_delete
630 title="删除这张图片?"
631 description=move || {
632 format!("「{}」会从素材库和磁盘上删除,无法恢复。", detail_name.get())
633 }
634 confirm_label="删除"
635 on_confirm=move |_| {
636 if let Some(id) = active.get_untracked() {
637 delete_one(id);
638 }
639 }
640 />
641 <ConfirmDialog
642 visible=confirm_batch
643 title="删除所选图片?"
644 description=move || {
645 format!(
646 "{} 张未引用的图片会从素材库和磁盘上删除,无法恢复。",
647 selection_count.get(),
648 )
649 }
650 confirm_label="删除"
651 on_confirm=move |_| delete_checked()
652 />
653 <ConfirmDialog
654 visible=confirm_purge
655 title="清理未引用的素材?"
656 description=move || {
657 format!(
658 "{} 张上传满 7 天且没有任何引用的图片会被删除,释放 {}。上传不满 7 天的保留,以免误删还没保存的草稿里的图片。",
659 purgeable.get(),
660 format_bytes(purgeable_bytes.get()),
661 )
662 }
663 confirm_label="清理"
664 on_confirm=move |_| purge()
665 />
666 <AssetUploadModal visible=upload_open on_uploaded=move |_| refresh()/>
667 </div>
668 }
669}