Skip to main content

yggdrasil/pages/admin/assets/
references.rs

1//! 素材详情栏:大图(点击进灯箱)、文件信息、替代文本、引用来源、复制链接与删除。
2//! 被引用的素材禁止删除并写明引用数。
3use super::card::ref_kind;
4use crate::components::forms::FormInput;
5use crate::components::ui::{
6    button_class, use_toast, ButtonSize, ButtonVariant, IconButton, IconName,
7};
8use crate::models::asset::{AssetDto, AssetRef, AssetRefPostStatus};
9use crate::routes::Route;
10use crate::utils::error::failure_message;
11use crate::utils::format_bytes;
12use leptos::prelude::*;
13
14/// 引用来源的一行。已发布文章与评论新标签打开前台页(规避后台到前台跨布局导航的
15/// 回收问题,也不打断后台上下文);草稿、回收站、笔记进入后台编辑页;头像只展示。
16fn ref_row(r: &AssetRef) -> AnyView {
17    let kind = ref_kind(r);
18    match r {
19        AssetRef::Post {
20            post_id,
21            title,
22            slug,
23            status,
24        } => {
25            let note = status.note();
26            if *status == AssetRefPostStatus::Published {
27                view! {
28                    <a
29                        class="asset-ref"
30                        href=format!("/post/{slug}")
31                        target="_blank"
32                        rel="noopener"
33                    >
34                        <span class="asset-ref-kind">{kind}</span>
35                        <span class="asset-ref-label">{title.clone()}</span>
36                    </a>
37                }
38                .into_any()
39            } else {
40                view! {
41                    <a class="asset-ref" href=Route::WriteEdit { id: *post_id }.to_string()>
42                        <span class="asset-ref-kind">
43                            {kind}
44                            {note.map(|note| format!(" · {note}"))}
45                        </span>
46                        <span class="asset-ref-label">{title.clone()}</span>
47                    </a>
48                }
49                .into_any()
50            }
51        }
52        AssetRef::Comment {
53            author_name,
54            post_id,
55            post_title,
56            post_slug,
57            post_status,
58            ..
59        } => {
60            let label = format!("{author_name} 于《{post_title}》");
61            if *post_status == AssetRefPostStatus::Published {
62                view! {
63                    <a
64                        class="asset-ref"
65                        href=format!("/post/{post_slug}")
66                        target="_blank"
67                        rel="noopener"
68                    >
69                        <span class="asset-ref-kind">{kind}</span>
70                        <span class="asset-ref-label">{label}</span>
71                    </a>
72                }
73                .into_any()
74            } else {
75                view! {
76                    <a class="asset-ref" href=Route::WriteEdit { id: *post_id }.to_string()>
77                        <span class="asset-ref-kind">{kind}</span>
78                        <span class="asset-ref-label">{label}</span>
79                    </a>
80                }
81                .into_any()
82            }
83        }
84        AssetRef::Note { note_id, title } => view! {
85            <a class="asset-ref" href=Route::EditNote { id: *note_id }.to_string()>
86                <span class="asset-ref-kind">{format!("{kind} · 含历史版本")}</span>
87                <span class="asset-ref-label">{title.clone()}</span>
88            </a>
89        }
90        .into_any(),
91        AssetRef::UserAvatar { label, .. } => view! {
92            <div class="asset-ref">
93                <span class="asset-ref-kind">{kind}</span>
94                <span class="asset-ref-label">{label.clone()}</span>
95            </div>
96        }
97        .into_any(),
98        AssetRef::FriendAvatar { name, .. } => view! {
99            <a class="asset-ref" href=Route::FriendsAdmin.to_string()>
100                <span class="asset-ref-kind">{kind}</span>
101                <span class="asset-ref-label">{name.clone()}</span>
102            </a>
103        }
104        .into_any(),
105    }
106}
107
108/// 详情栏。页面按素材 id 重建本组件,`asset` 随列表刷新更新(替代文本输入框的内容与
109/// 焦点因此在保存后保持)。
110#[component]
111#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
112pub(super) fn AssetDetail(
113    #[prop(into)] asset: Signal<AssetDto>,
114    #[prop(into)] on_changed: Callback<()>,
115    #[prop(into)] on_delete: Callback<()>,
116    #[prop(into)] on_close: Callback<()>,
117) -> impl IntoView {
118    let toast = use_toast();
119    // 文件本身的字段在同一素材的生命周期内不变,只取一次。
120    let a = asset.with_untracked(|d| d.asset.clone());
121    let id = a.id.clone();
122    let alt = RwSignal::new(a.alt.clone().unwrap_or_default());
123    let saving = RwSignal::new(false);
124    let used = Memo::new(move |_| asset.with(|d| d.ref_count > 0));
125    let ref_count = Memo::new(move |_| asset.with(|d| d.ref_count));
126    let saved_alt = Memo::new(move |_| asset.with(|d| d.asset.alt.clone().unwrap_or_default()));
127    let refs = Memo::new(move |_| asset.with(|d| d.refs.clone()));
128    let missing_alt = Memo::new(move |_| saved_alt.with(|s| s.trim().is_empty()));
129    let preview = format!("/uploads/{}?w=640", a.path);
130    let url = format!("/uploads/{}", a.path);
131
132    // 失焦与回车共用。
133    let save_alt = Callback::new(move |()| {
134        let next = alt.get_untracked().trim().to_string();
135        if next == saved_alt.with_untracked(|s| s.trim().to_string()) || saving.get_untracked() {
136            return;
137        }
138        saving.set(true);
139        let id = id.clone();
140        leptos::task::spawn_local(async move {
141            match crate::api::assets::update_asset_alt(id, next).await {
142                Ok(res) if res.success => {
143                    toast.success("替代文本已保存");
144                    on_changed.try_run(());
145                }
146                Ok(res) => toast.error(res.message),
147                Err(e) => toast.error(failure_message("保存失败", &e)),
148            }
149            saving.try_set(false);
150        });
151    });
152    let copy = {
153        let url = url.clone();
154        move |_| {
155            #[cfg(target_arch = "wasm32")]
156            {
157                let url = url.clone();
158                leptos::task::spawn_local(async move {
159                    let origin = web_sys::window()
160                        .and_then(|w| w.location().origin().ok())
161                        .unwrap_or_default();
162                    if crate::utils::js::copy_text(&format!("{origin}{url}")).await {
163                        toast.success("已复制图片链接");
164                    } else {
165                        toast.error("复制失败,请手动复制路径");
166                    }
167                });
168            }
169        }
170    };
171
172    view! {
173        <aside class="asset-detail" aria-label="素材详情">
174            <div class="asset-detail-head">
175                <h2>"详情"</h2>
176                <IconButton
177                    icon=IconName::X
178                    label="关闭详情"
179                    size=ButtonSize::Sm
180                    onclick=move |_| on_close.run(())
181                />
182            </div>
183            // 点击大图进入灯箱(lightbox-single:只看这一张,原图 = 去掉 query)。
184            <div class="asset-detail-preview assets-lightbox">
185                <img
186                    class="lightbox-single"
187                    src=preview
188                    alt=move || saved_alt.get()
189                    data-error-text="本地文件已丢失"
190                />
191            </div>
192            <div class="asset-detail-file">
193                <p class="asset-detail-name">{a.filename.clone()}</p>
194                <p class="asset-detail-meta">
195                    {(a.width > 0).then(|| format!("{}×{} · ", a.width, a.height))}
196                    {format!("{} · {}", format_bytes(a.size_bytes), a.mime)}
197                </p>
198                <p class="asset-detail-path">{format!("/uploads/{}", a.path)}</p>
199            </div>
200            <div>
201                <label class="asset-detail-label" for="asset-alt">
202                    <span>"替代文本"</span>
203                    {move || {
204                        missing_alt.get().then(|| view! { <span class="asset-detail-warn">"未填写"</span> })
205                    }}
206                </label>
207                <FormInput
208                    id="asset-alt"
209                    placeholder="描述这张图片,供读屏与搜索使用"
210                    maxlength=500
211                    value=alt
212                    disabled=saving
213                    oninput=move |v: String| alt.set(v)
214                    onblur=move |_| save_alt.run(())
215                    onkeydown=move |e: leptos::ev::KeyboardEvent| {
216                        if e.key() == "Enter" && !e.is_composing() {
217                            e.prevent_default();
218                            save_alt.run(());
219                        }
220                    }
221                />
222            </div>
223            <div>
224                <p class="asset-detail-label">"引用"</p>
225                {move || {
226                    refs
227                        .with(|refs| {
228                            if refs.is_empty() {
229                                view! {
230                                    <p class="asset-detail-empty">
231                                        "没有文章、笔记、评论或头像引用这张图片。"
232                                    </p>
233                                }
234                                    .into_any()
235                            } else {
236                                view! {
237                                    <div class="asset-refs">
238                                        {refs
239                                            .iter()
240                                            .map(|r| view! { <div>{ref_row(r)}</div> })
241                                            .collect_view()}
242                                    </div>
243                                }
244                                    .into_any()
245                            }
246                        })
247                }}
248            </div>
249            <div class="asset-detail-actions">
250                <button
251                    class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
252                    type="button"
253                    on:click=copy
254                >
255                    "复制链接"
256                </button>
257                <button
258                    class=button_class(ButtonVariant::Danger, ButtonSize::Md)
259                    type="button"
260                    disabled=move || used.get()
261                    on:click=move |_| on_delete.run(())
262                >
263                    "删除"
264                </button>
265            </div>
266            {move || {
267                used.get()
268                    .then(|| {
269                        view! {
270                            <p class="asset-detail-note">
271                                {move || format!("被 {} 处引用,移除引用后才能删除。", ref_count.get())}
272                            </p>
273                        }
274                    })
275            }}
276        </aside>
277    }
278}