Skip to main content

yggdrasil/pages/admin/assets/
card.rs

1//! 素材网格的一格:缩略图、尺寸、文件名、大小与引用概况。点击在详情栏查看,
2//! 未引用的素材右上角可勾选,供批量删除。
3use crate::components::ui::{Icon, IconName};
4use crate::models::asset::{AssetDto, AssetRef};
5use crate::utils::format_bytes;
6use leptos::prelude::*;
7
8/// 引用概况:第一处引用的类型,多处时加「等 N 处」。
9pub(super) fn ref_summary(asset: &AssetDto) -> String {
10    let Some(first) = asset.refs.first() else {
11        return "未引用".into();
12    };
13    let kind = ref_kind(first);
14    if asset.ref_count > 1 {
15        format!("{kind}等 {} 处", asset.ref_count)
16    } else {
17        kind.into()
18    }
19}
20
21/// 引用来源的类型名。
22pub(super) fn ref_kind(r: &AssetRef) -> &'static str {
23    match r {
24        AssetRef::Post { .. } => "文章",
25        AssetRef::Note { .. } => "笔记",
26        AssetRef::Comment { .. } => "评论",
27        AssetRef::UserAvatar { .. } => "头像",
28        AssetRef::FriendAvatar { .. } => "友链",
29    }
30}
31
32/// 网格中的一格。
33///
34/// `asset` 随列表刷新更新(`For` 按 id 复用格子,数据变化不重建 DOM,保住键盘焦点);
35/// `active` / `checked` 由页面按详情栏与勾选集派生。
36#[component]
37pub(super) fn AssetTile(
38    #[prop(into)] asset: Signal<AssetDto>,
39    #[prop(into)] active: Signal<bool>,
40    #[prop(into)] checked: Signal<bool>,
41    #[prop(into)] on_open: Callback<String>,
42    #[prop(into)] on_check: Callback<(String, bool)>,
43) -> impl IntoView {
44    // id 与 path 在同一素材的生命周期内不变。
45    let (id, thumb) = asset.with_untracked(|d| {
46        (
47            d.asset.id.clone(),
48            format!("/uploads/{}?thumb=300x300", d.asset.path),
49        )
50    });
51    let check_id = id.clone();
52    let filename = move || asset.with(|d| d.asset.filename.clone());
53    let orphan = Memo::new(move |_| asset.with(|d| d.ref_count == 0));
54    let dims = Memo::new(move |_| {
55        asset.with(|d| (d.asset.width > 0).then(|| format!("{}×{}", d.asset.width, d.asset.height)))
56    });
57    let no_alt = Memo::new(move |_| {
58        asset.with(|d| d.asset.alt.as_deref().is_none_or(|t| t.trim().is_empty()))
59    });
60    let meta = move || {
61        asset.with(|d| format!("{} · {}", format_bytes(d.asset.size_bytes), ref_summary(d)))
62    };
63    view! {
64        <li
65            class="asset-tile"
66            data-active=move || active.get().then_some("true")
67            data-checked=move || checked.get().then_some("true")
68        >
69            <button
70                class="asset-tile-open"
71                type="button"
72                aria-pressed=move || active.get().to_string()
73                aria-label=move || format!("查看 {}", filename())
74                on:click=move |_| on_open.run(id.clone())
75            >
76                <span class="asset-tile-thumb">
77                    <img src=thumb alt="" loading="lazy" decoding="async"/>
78                    {move || dims.get().map(|d| view! { <span class="asset-tile-dims">{d}</span> })}
79                    {move || orphan.get().then(|| view! { <span class="asset-tile-badge">"未引用"</span> })}
80                    {move || {
81                        no_alt.get()
82                            .then(|| {
83                                view! {
84                                    <span
85                                        class="asset-tile-noalt"
86                                        title="未填写替代文本"
87                                        aria-hidden="true"
88                                    ></span>
89                                }
90                            })
91                    }}
92                </span>
93                <span class="asset-tile-text">
94                    <span class="asset-tile-name">{filename}</span>
95                    <span class="asset-tile-meta">{meta}</span>
96                </span>
97            </button>
98            {move || {
99                let check_id = check_id.clone();
100                orphan.get()
101                    .then(|| {
102                        view! {
103                            <label
104                                class="asset-tile-check"
105                                title=move || if checked.get() { "取消选择" } else { "选择" }
106                            >
107                                <input
108                                    type="checkbox"
109                                    prop:checked=move || checked.get()
110                                    aria-label=move || format!("选择 {}", filename())
111                                    on:change=move |e| {
112                                        on_check.run((check_id.clone(), event_target_checked(&e)))
113                                    }
114                                />
115                                <Icon name=IconName::Check size=12/>
116                            </label>
117                        }
118                    })
119            }}
120        </li>
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::models::asset::{Asset, AssetRefPostStatus};
128
129    fn asset(refs: Vec<AssetRef>) -> AssetDto {
130        AssetDto {
131            asset: Asset {
132                id: "a".into(),
133                path: "p.webp".into(),
134                filename: "p.webp".into(),
135                mime: "image/webp".into(),
136                size_bytes: 1,
137                width: 1,
138                height: 1,
139                alt: None,
140                created_at: chrono::Utc::now(),
141            },
142            ref_count: refs.len() as i64,
143            refs,
144        }
145    }
146
147    #[test]
148    fn ref_summary_names_the_first_kind_and_counts_the_rest() {
149        assert_eq!(ref_summary(&asset(vec![])), "未引用");
150        let post = AssetRef::Post {
151            post_id: 1,
152            title: "t".into(),
153            slug: "t".into(),
154            status: AssetRefPostStatus::Published,
155        };
156        let avatar = AssetRef::UserAvatar {
157            user_id: 1,
158            label: "xfy".into(),
159        };
160        assert_eq!(ref_summary(&asset(vec![post.clone()])), "文章");
161        assert_eq!(ref_summary(&asset(vec![post, avatar])), "文章等 2 处");
162    }
163}