Skip to main content

yggdrasil/pages/admin/
assets.rs

1//! 素材管理(`/admin/assets`)。
2//!
3//! 标题行(图片数、已引用、未引用、可清理;「⋯」菜单里重建索引与清理未引用,主操作
4//! 上传)→ 引用状态选项卡(带数量)、文件名 / 替代文本搜索与排序 → 缩略图网格与底栏
5//! 分页;右侧详情栏查看大图、文件信息、替代文本、引用来源,复制链接或删除(被引用的
6//! 禁止删除)。未引用的素材可勾选,底部浮起批量删除条。窄屏详情浮于网格上方。
7//! 缩略图走 `serve_image` 的动态缩放(`?thumb=300x300`)。样式见
8//! styles/pages/admin-assets.css。
9//!
10//! 后台数据不进 SSR:列表在浏览器端由 Effect 拉取(带请求世代号防竞态),直开页面时
11//! 首屏是骨架屏,水合后再加载。
12
13use 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
40/// 每页素材数,与服务端 list.rs 的 PER_PAGE 对齐。
41const ASSETS_PER_PAGE: i32 = 60;
42/// 搜索输入停顿多久后提交(毫秒)。
43const SEARCH_DEBOUNCE_MS: u32 = 300;
44
45/// 素材管理路由:纯客户端数据,无 SSR 预取。
46pub 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
67/// 视口够宽时详情栏常驻,首屏自动选中第一张。只在 Effect 内调用;服务端恒为 `false`。
68fn detail_docked() -> bool {
69    /// 详情栏常驻的最小视口宽度;更窄时只在点开素材后浮出。
70    #[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
86/// 在本页数据里按 id 找素材。
87fn 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/// 列表区的互斥状态;用 `Memo` 去重,同一状态内的数据刷新不会重建网格。
94#[derive(Clone, Copy, PartialEq)]
95enum Body {
96    Error,
97    Skeleton,
98    /// 有搜索词但没有命中。
99    EmptyFiltered,
100    /// 「未引用」选项卡下没有素材。
101    EmptyOrphan,
102    EmptyAll,
103    Grid,
104}
105
106/// 素材管理入口组件。
107#[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    // 详情栏里的素材与勾选(勾选跨翻页保留,删除成功后清空)。
120    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    // 搜索防抖:停顿后才提交,并回到第 1 页。
129    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    // 请求世代号:筛选、翻页、刷新都会递增,只有最后一次请求的响应能落地。
143    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                    // 详情里的素材不在本页时,宽屏换成第一张,窄屏收起。
172                    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    // 详情大图换了之后重新绑定灯箱(data-lb-bound 守卫保证幂等)。灯箱脚本
196    // P5 §8.2 起按需注入(本页声明 LightboxAssets)。
197    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    // 列表统计;尚未加载时全为 0。
291    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    // 详情栏只在素材仍在本页时显示;按 id 重建详情组件。
309    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        // P5 §8.2:素材详情灯箱按需注入(脚本未就绪时迟到调用入队兜底)。
380        <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                                        // 同一素材的格子跨刷新复用,内容随列表更新;
518                                        // 素材离开本页时 fallback 保证重算不会取空。
519                                        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}