Skip to main content

yggdrasil/pages/admin/
asset_picker.rs

1//! 素材选择 modal(封面上「从素材库选择」)。
2//!
3//! 网格展示素材库(默认最新排序,支持文件名/alt 搜索),单击选中回填封面 URL。
4//! 内嵌「上传新图」入口(复用 `upload_image_file`),上传成功即选中。
5//! 纯 Dioxus 组件,不触碰 Tiptap;数据加载仅在 WASM 前端发生。
6
7use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
8use dioxus::prelude::*;
9
10#[cfg(target_arch = "wasm32")]
11use crate::api::assets::list_assets;
12use crate::models::asset::AssetDto;
13#[cfg(target_arch = "wasm32")]
14use crate::models::asset::{AssetFilter, AssetSort};
15
16/// 素材选择 modal。
17///
18/// - `visible`:显隐控制(父组件持有,选中/点遮罩/× 都会置 false)。
19/// - `on_select`:选中回填,参数为 `/uploads/<path>` URL。
20/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。
21#[component]
22#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
23pub fn AssetPickerModal(
24    mut visible: Signal<bool>,
25    on_select: EventHandler<String>,
26    cover_uploading: Signal<bool>,
27) -> Element {
28    #[allow(unused_mut)]
29    let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
30    #[allow(unused_mut)]
31    let mut loading = use_signal(|| false);
32    let mut query = use_signal(String::new);
33    #[allow(unused_mut)]
34    let mut error = use_signal(|| None::<String>);
35
36    // 打开时与搜索词变化时加载第一页(最新 60 张,封面复用场景足够)。
37    use_effect(move || {
38        let open = visible();
39        let q = query();
40        if open {
41            #[cfg(target_arch = "wasm32")]
42            spawn(async move {
43                loading.set(true);
44                match list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, 1).await {
45                    Ok(resp) => {
46                        assets.set(resp.assets);
47                        error.set(None);
48                    }
49                    Err(e) => error.set(Some(e.to_string())),
50                }
51                loading.set(false);
52            });
53        }
54    });
55
56    if !visible() {
57        return rsx! {};
58    }
59
60    rsx! {
61        // 遮罩:点击关闭
62        div {
63            class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6",
64            onclick: move |_| visible.set(false),
65            // 面板:阻止点击穿透到遮罩
66            div {
67                class: "w-full max-w-3xl max-h-[80vh] flex flex-col rounded-[2rem] bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)] shadow-xl overflow-hidden",
68                onclick: move |evt| evt.stop_propagation(),
69
70                // 头部:标题 + 搜索 + 上传 + 关闭
71                div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
72                    h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0",
73                        "选择封面图"
74                    }
75                    FormInput {
76                        r#type: "search",
77                        placeholder: "搜索文件名 / alt",
78                        value: query(),
79                        class: INPUT_INLINE_CLASS,
80                        oninput: move |v: String| query.set(v),
81                    }
82                    // 上传新图:成功后直接选中
83                    label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer",
84                        "上传新图"
85                        input {
86                            r#type: "file",
87                            accept: "image/jpeg,image/png,image/gif,image/webp",
88                            class: "hidden",
89                            onchange: move |evt| {
90                                #[cfg(target_arch = "wasm32")]
91                                {
92                                    use dioxus::html::HasFileData;
93                                    use dioxus::web::WebFileExt;
94                                    if let Some(file) = evt.files().into_iter().next() {
95                                        if let Some(web_file) = file.get_web_file() {
96                                            cover_uploading.set(true);
97                                            spawn(async move {
98                                                match crate::tiptap_bridge::upload_image_file(web_file).await {
99                                                    Ok(url) => {
100                                                        on_select.call(url);
101                                                        visible.set(false);
102                                                    }
103                                                    // 失败留在 modal 内提示,不关闭。
104                                                    Err(msg) => error.set(Some(msg)),
105                                                }
106                                                cover_uploading.set(false);
107                                            });
108                                        }
109                                    }
110                                }
111                            },
112                        }
113                    }
114                    button {
115                        class: "shrink-0 w-8 h-8 flex items-center justify-center rounded-full text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
116                        aria_label: "关闭",
117                        onclick: move |_| visible.set(false),
118                        "×"
119                    }
120                }
121
122                // 网格内容区
123                div { class: "flex-1 overflow-y-auto p-4",
124                    if let Some(err) = error() {
125                        div { class: "py-12 text-center text-sm text-red-500", "加载失败:{err}" }
126                    } else if loading() && assets.read().is_empty() {
127                        div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
128                            "加载中..."
129                        }
130                    } else if assets.read().is_empty() {
131                        div { class: "py-12 text-center text-sm text-[var(--color-paper-secondary)]",
132                            "素材库为空,点击「上传新图」添加"
133                        }
134                    } else {
135                        div { class: "grid grid-cols-3 sm:grid-cols-4 gap-3",
136                            for asset in assets.read().iter() {
137                                {
138                                    let url = format!("/uploads/{}", asset.asset.path);
139                                    let thumb = format!("{}?thumb=300x300", url);
140                                    rsx! {
141                                        button {
142                                            key: "{asset.asset.id}",
143                                            class: "group relative aspect-square rounded-2xl overflow-hidden border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)] hover:shadow-md transition-all cursor-pointer",
144                                            title: "{asset.asset.filename}",
145                                            onclick: {
146                                                let url = url.clone();
147                                                move |_| {
148                                                    on_select.call(url.clone());
149                                                    visible.set(false);
150                                                }
151                                            },
152                                            img {
153                                                class: "w-full h-full object-cover",
154                                                src: "{thumb}",
155                                                alt: asset.asset.alt.clone().unwrap_or_else(|| { asset.asset.filename.clone() }),
156                                                loading: "lazy",
157                                            }
158                                        }
159                                    }
160                                }
161                            }
162                        }
163                    }
164                }
165            }
166        }
167    }
168}