Skip to main content

yggdrasil/components/assets/
asset_picker.rs

1//! 素材选择 modal(封面/头像「从素材库选择」、正文编辑器「素材库」插图共用)。
2//!
3//! 网格展示素材库(默认最新排序,支持文件名/alt 搜索)。
4//! 单选模式(默认):单击选中即回填并关闭,供封面/头像使用。
5//! 多选模式(`multi: true`,正文插图):单击切换勾选态(跨页/搜索保留),
6//! 底部确认栏按勾选顺序批量回填。
7//! 内嵌「上传新图」入口(复用 `upload_image_file`),上传成功后留在网格中供用户选择
8//! (多选模式下自动加入选中集——上传即意图)。
9//! 纯 Dioxus 组件,不触碰 Tiptap;数据加载仅在 WASM 前端发生。
10
11use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
12use crate::components::ui::{
13    ModalShell, Pagination, BTN_ICON, BTN_OUTLINE, BTN_PRIMARY, SPINNER_SVG,
14};
15use dioxus::prelude::*;
16
17#[cfg(target_arch = "wasm32")]
18use crate::api::assets::list_assets;
19use crate::models::asset::AssetDto;
20#[cfg(target_arch = "wasm32")]
21use crate::models::asset::{AssetFilter, AssetSort};
22use crate::router::Route;
23
24/// 每页素材数,与素材管理页及服务端列表接口保持一致。
25const ASSETS_PER_PAGE: i32 = 60;
26/// 搜索输入防抖窗口,复用素材管理页的请求节流约定。
27#[cfg(target_arch = "wasm32")]
28const SEARCH_DEBOUNCE_MS: u32 = 300;
29
30/// 素材选中载荷:`on_select` 的回传单元。
31///
32/// 序列化形状即编辑器桥接契约 `[{ "src", "alt?" }]`(`insertImagesFromLibrary`),
33/// 写字页确认后直接 `serde_json::to_string` 传给 JS 侧,无需二次映射。
34#[derive(Clone, Debug, PartialEq, serde::Serialize)]
35pub struct AssetSelection {
36    /// `/uploads/<path>` URL(序列化为编辑器 Image 节点的 `src`)。
37    #[serde(rename = "src")]
38    pub url: String,
39    /// 图片替代文本(素材 alt);None 时不序列化,JS 侧等同未提供。
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub alt: Option<String>,
42}
43
44/// Grid data shared by the authenticated picker and the local showcase.
45#[derive(Clone, PartialEq)]
46pub(crate) struct PickerAsset {
47    pub(crate) url: String,
48    pub(crate) thumbnail: String,
49    pub(crate) filename: String,
50    pub(crate) alt: Option<String>,
51    pub(crate) fresh: bool,
52}
53
54impl From<&AssetDto> for PickerAsset {
55    fn from(dto: &AssetDto) -> Self {
56        let url = format!("/uploads/{}", dto.asset.path);
57        Self {
58            thumbnail: format!("{url}?thumb=300x300"),
59            url,
60            filename: dto.asset.filename.clone(),
61            alt: dto.asset.alt.clone(),
62            fresh: false,
63        }
64    }
65}
66
67fn prepend_uploaded_asset(items: &mut Vec<PickerAsset>, url: String) {
68    if items.iter().any(|item| item.url == url) {
69        return;
70    }
71    items.insert(
72        0,
73        PickerAsset {
74            thumbnail: url.clone(),
75            url,
76            filename: "新上传图片".to_string(),
77            alt: None,
78            fresh: true,
79        },
80    );
81}
82
83/// 切换素材勾选态:已选(按 URL 判重)则移除,未选则按点击顺序追加。
84///
85/// 以 URL 为唯一键:上传新图在网格刷新前后可能以不同条目形态出现
86/// (`uploaded_url` 暂存 vs 素材列表行),URL 判重保证同图不会被选两次。
87/// 抽成纯函数便于单元测试(见本文件 tests 模块)。
88fn toggle_selection(selected: &mut Vec<AssetSelection>, item: AssetSelection) {
89    if let Some(pos) = selected.iter().position(|s| s.url == item.url) {
90        selected.remove(pos);
91    } else {
92        selected.push(item);
93    }
94}
95
96/// 素材选择 modal。
97///
98/// - `visible`:显隐控制(父组件持有,选中/点遮罩/× 都会置 false)。
99/// - `on_select`:选中回填。单选模式恰含一个元素;多选模式按勾选顺序排列。
100/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。
101/// - `title`:modal 标题;封面场景缺省为「选择封面图」,头像/正文场景各自传入。
102/// - `multi`:多选模式(正文插图)。网格项变为勾选切换,底部确认栏批量回填。
103#[component]
104#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
105pub fn AssetPickerModal(
106    mut visible: Signal<bool>,
107    on_select: EventHandler<Vec<AssetSelection>>,
108    cover_uploading: Signal<bool>,
109    #[props(default = "选择封面图")] title: &'static str,
110    #[props(default)] multi: bool,
111) -> Element {
112    #[allow(unused_mut)]
113    let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
114    #[allow(unused_mut)]
115    let mut loading = use_signal(|| false);
116    let mut query = use_signal(String::new);
117    #[allow(unused_mut)]
118    let mut debounced_query = use_signal(String::new);
119    #[allow(unused_mut)]
120    let mut error = use_signal(|| None::<String>);
121    let mut page = use_signal(|| 1_i32);
122    #[allow(unused_mut)]
123    let mut total = use_signal(|| 0_i64);
124    // 上传中的本地对象 URL 只用于当前 modal 预览,上传结束立即释放。
125    let mut uploading_preview = use_signal(|| None::<String>);
126    // 关闭时保留 DOM 播放淡出;动画结束后再卸载,避免弹窗瞬间消失。
127    let mut closing = use_signal(|| false);
128    let mut opened = use_signal(|| false);
129    // 上传完成后先把新图留在弹窗网格中,用户点击后才真正应用头像。
130    let mut uploaded_url = use_signal(|| None::<String>);
131    // 多选模式的选中集:按勾选顺序排列,跨页/搜索/上传保留;每次打开时重置。
132    // 以 URL 判重(见 toggle_selection)。单选模式不使用。
133    let mut selected: Signal<Vec<AssetSelection>> = use_signal(Vec::new);
134
135    #[cfg(target_arch = "wasm32")]
136    let request_generation = use_hook(|| std::rc::Rc::new(std::cell::Cell::new(0_u64)));
137
138    #[cfg(target_arch = "wasm32")]
139    let request_generation_for_close = request_generation.clone();
140    use_effect(move || {
141        if visible() {
142            opened.set(true);
143            uploaded_url.set(None);
144            // 每次打开都是一次全新的选择会话(取消或确认后关闭均不留旧勾选)。
145            selected.set(Vec::new());
146        } else if *opened.peek() {
147            #[cfg(target_arch = "wasm32")]
148            request_generation_for_close.set(request_generation_for_close.get().wrapping_add(1));
149            page.set(1);
150        }
151    });
152
153    // 搜索防抖:保留输入框原值,停顿 300ms 后才提交查询并回到第 1 页。
154    // 每次新输入都会递增请求代际,立即丢弃尚未返回的旧查询结果。
155    #[cfg(target_arch = "wasm32")]
156    let request_generation_for_debounce = request_generation.clone();
157    use_effect(move || {
158        let q = query();
159        #[cfg(target_arch = "wasm32")]
160        {
161            request_generation_for_debounce
162                .set(request_generation_for_debounce.get().wrapping_add(1));
163            spawn(async move {
164                crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
165                if *query.peek() == q {
166                    if *debounced_query.peek() != q {
167                        debounced_query.set(q);
168                    }
169                    page.set(1);
170                }
171            });
172        }
173    });
174
175    // 打开、搜索词或页码变化时加载当前页;请求代际保证只有最后一次响应能落地。
176    #[cfg(target_arch = "wasm32")]
177    let request_generation_for_load = request_generation.clone();
178    use_effect(move || {
179        let open = visible();
180        let q = debounced_query();
181        let requested_page = page();
182        if open {
183            #[cfg(target_arch = "wasm32")]
184            {
185                let request_generation = request_generation_for_load.clone();
186                let request_id = request_generation.get().wrapping_add(1);
187                request_generation.set(request_id);
188                spawn(async move {
189                    if request_generation.get() != request_id {
190                        return;
191                    }
192                    loading.set(true);
193                    error.set(None);
194                    let result =
195                        list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, requested_page)
196                            .await;
197                    if request_generation.get() != request_id {
198                        return;
199                    }
200                    match result {
201                        Ok(resp) => {
202                            let last_page = ((resp.total + ASSETS_PER_PAGE as i64 - 1)
203                                / ASSETS_PER_PAGE as i64)
204                                .max(1) as i32;
205                            if resp.assets.is_empty() && requested_page > last_page {
206                                page.set(last_page);
207                                return;
208                            }
209                            assets.set(resp.assets);
210                            total.set(resp.total);
211                            error.set(None);
212                        }
213                        Err(e) => error.set(Some(e.to_string())),
214                    }
215                    if request_generation.get() == request_id {
216                        loading.set(false);
217                    }
218                });
219            }
220        }
221    });
222
223    let current_page = page();
224    let total_assets = total();
225    let loading_now = loading();
226    let show_pagination = total_assets > ASSETS_PER_PAGE as i64;
227    let mut gallery_items: Vec<PickerAsset> = assets.read().iter().map(PickerAsset::from).collect();
228    if let Some(url) = uploaded_url() {
229        prepend_uploaded_asset(&mut gallery_items, url);
230    }
231
232    rsx! {
233        ModalShell {
234            visible,
235            closing,
236            title,
237            overlay_padding: "p-4 sm:p-6",
238            panel_class: "w-full max-w-3xl min-h-0",
239
240            // 头部:标题、搜索、上传与关闭按钮共享同一组内边距。
241            div { class: "flex flex-wrap items-center gap-3 border-b border-[var(--color-paper-border)] p-6",
242                    h2 { class: "shrink-0 text-lg font-bold text-[var(--color-paper-primary)]",
243                        "{title}"
244                    }
245                    FormInput {
246                        r#type: "search",
247                        placeholder: "搜索文件名 / alt",
248                        value: query(),
249                        class: INPUT_INLINE_CLASS,
250                        oninput: move |v: String| query.set(v),
251                    }
252                    // 上传新图:成功后保留在当前网格,用户点击后才选中
253                    label {
254                        class: "inline-flex shrink-0 cursor-pointer items-center justify-center {BTN_PRIMARY}",
255                        class: if cover_uploading() { "pointer-events-none cursor-wait opacity-60" } else { "" },
256                        "上传新图"
257                        input {
258                            r#type: "file",
259                            accept: "image/jpeg,image/png,image/gif,image/webp",
260                            class: "hidden",
261                            disabled: cover_uploading(),
262                            onchange: move |evt| {
263                                #[cfg(target_arch = "wasm32")]
264                                {
265                                    use dioxus::web::WebFileExt;
266                                    if cover_uploading() {
267                                        return;
268                                    }
269                                    if let Some(file) = evt.files().into_iter().next() {
270                    if let Some(web_file) = file.get_web_file() {
271                        // 与 AssetUploadModal 的 worker 池入队校验(upload_pool::validate_file)
272                        // 同一份规则:不合格直接提示、不发请求——此前这里只靠 accept 属性
273                        // (客户端可绕过)+ 服务端兜底校验,缺了与批量上传一致的客户端预检。
274                        if let Err(msg) = crate::components::assets::upload_pool::validate_file(
275                            &web_file.type_(),
276                            web_file.size() as u64,
277                        ) {
278                            error.set(Some(msg));
279                        } else {
280                            let preview_url =
281                                web_sys::Url::create_object_url_with_blob(&web_file).ok();
282                            uploading_preview.set(preview_url.clone());
283                            cover_uploading.set(true);
284                            error.set(None);
285                            spawn(async move {
286                                let result = crate::bridges::tiptap::upload_image_file(web_file)
287                                    .await;
288                                if let Some(preview_url) = preview_url.as_deref() {
289                                    let _ = web_sys::Url::revoke_object_url(preview_url);
290                                }
291                                match result {
292                                    Ok(url) => {
293                                        // 多选模式:上传即意图,新图直接进入选中集
294                                        // (URL 判重,网格刷新后同图不会重复选中)。
295                                        if multi {
296                                            toggle_selection(
297                                                &mut selected.write(),
298                                                AssetSelection {
299                                                    url: url.clone(),
300                                                    alt: None,
301                                                },
302                                            );
303                                        }
304                                        uploaded_url.set(Some(url));
305                                        uploading_preview.set(None);
306                                        cover_uploading.set(false);
307                                    }
308                                    Err(msg) => {
309                                        error.set(Some(msg));
310                                        uploading_preview.set(None);
311                                        cover_uploading.set(false);
312                                    }
313                                }
314                            });
315                        }
316                    }
317                                    }
318                                }
319                            },
320                        }
321                    }
322                    button {
323                        class: "{BTN_ICON} shrink-0 rounded-full",
324                        aria_label: "关闭",
325                        onclick: move |_| {
326                            closing.set(true);
327                            visible.set(false);
328                        },
329                        "×"
330                    }
331                }
332
333                PickerGallery {
334                    items: gallery_items.clone(),
335                    selected,
336                    multi,
337                    loading: loading_now,
338                    uploading: cover_uploading(),
339                    error: error(),
340                    uploading_preview: uploading_preview(),
341                    on_pick: move |picks| {
342                        on_select.call(picks);
343                        closing.set(true);
344                        visible.set(false);
345                    },
346                }
347                if show_pagination {
348                    {
349                        let mut page_for_prev = page;
350                        let loading_for_prev = loading;
351                        let mut page_for_next = page;
352                        let loading_for_next = loading;
353                        let mut page_for_jump = page;
354                        let loading_for_jump = loading;
355                        rsx! {
356                            div { class: "shrink-0 px-6 py-3 shadow-[inset_0_1px_0_var(--color-paper-border)]",
357                                div { class: if loading_now { "pointer-events-none opacity-60" } else { "" },
358                                    Pagination::<Route> {
359                                        variant: "admin",
360                                        compact: true,
361                                        current_page,
362                                        total: total_assets,
363                                        per_page: ASSETS_PER_PAGE,
364                                        unit: "张",
365                                        on_prev: move |_| {
366                                            if !loading_for_prev() {
367                                                page_for_prev.with_mut(|p| *p = (*p - 1).max(1));
368                                            }
369                                        },
370                                        on_next: move |_| {
371                                            if !loading_for_next() {
372                                                page_for_next.with_mut(|p| *p += 1);
373                                            }
374                                        },
375                                        on_jump: move |next_page: i32| {
376                                            if !loading_for_jump() {
377                                                page_for_jump.set(next_page.max(1));
378                                            }
379                                        },
380                                    }
381                                }
382                            }
383                        }
384                    }
385                }
386
387                if multi {
388                    PickerSelectionFooter { selected, on_pick: move |picks| {
389                        on_select.call(picks);
390                        closing.set(true);
391                        visible.set(false);
392                    } }
393                }
394
395        }
396    }
397}
398
399/// The actual picker grid is shared; callers provide already loaded display rows.
400#[component]
401pub(crate) fn PickerGallery(
402    items: Vec<PickerAsset>,
403    mut selected: Signal<Vec<AssetSelection>>,
404    multi: bool,
405    loading: bool,
406    uploading: bool,
407    error: Option<String>,
408    uploading_preview: Option<String>,
409    on_pick: EventHandler<Vec<AssetSelection>>,
410) -> Element {
411    rsx! {
412        div { class: "relative min-h-0 flex-1 overflow-y-auto p-6",
413            if loading && !items.is_empty() {
414                div { class: "pointer-events-none absolute inset-0 z-10 flex items-start justify-center bg-[var(--color-paper-theme)]/35 pt-6 backdrop-blur-[1px]",
415                    aria_live: "polite", aria_label: "正在加载素材",
416                    span { class: "inline-flex items-center gap-2 rounded-full bg-[var(--color-paper-entry)] px-3 py-1.5 text-xs text-[var(--color-paper-secondary)] shadow-sm",
417                        span { class: "inline-flex h-4 w-4", dangerous_inner_html: SPINNER_SVG }
418                        "加载中…"
419                    }
420                }
421            }
422            if let Some(error) = error {
423                div { class: "rounded-2xl bg-red-500/10 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400", "加载失败:{error}" }
424            } else if loading && items.is_empty() && !uploading {
425                div { class: "px-4 py-16 text-center text-sm text-[var(--color-paper-secondary)]", "加载中..." }
426            } else if items.is_empty() && !uploading {
427                div { class: "px-4 py-16 text-center text-sm text-[var(--color-paper-secondary)]", "素材库为空" }
428            } else {
429                div { class: "grid grid-cols-3 gap-4 sm:grid-cols-4",
430                    if uploading {
431                        div { class: "relative aspect-square overflow-hidden rounded-2xl border border-[var(--color-paper-accent)]/60 bg-[var(--color-paper-theme)] shadow-sm", aria_live: "polite", aria_label: "正在上传图片",
432                            if let Some(preview_url) = uploading_preview {
433                                img { class: "h-full w-full scale-110 object-cover blur-md opacity-60", src: "{preview_url}", alt: "正在上传" }
434                            } else {
435                                div { class: "absolute inset-0 animate-pulse bg-[var(--color-paper-code-bg)]" }
436                            }
437                            div { class: "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/30 backdrop-blur-[2px]",
438                                span { class: "inline-flex h-5 w-5 text-white", dangerous_inner_html: SPINNER_SVG }
439                                span { class: "text-xs font-medium text-white drop-shadow", "上传中" }
440                            }
441                        }
442                    }
443                    for item in items.iter() {
444                        {
445                            let selection = AssetSelection { url: item.url.clone(), alt: item.alt.clone() };
446                            let order = if multi { selected.read().iter().position(|value| value.url == item.url) } else { None };
447                            let item_class = if order.is_some() {
448                                "border-2 border-[var(--color-paper-accent)] shadow-sm"
449                            } else if multi {
450                                "border-2 border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)]"
451                            } else {
452                                "border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)]"
453                            };
454                            rsx! {
455                                button {
456                                    key: "{item.url}",
457                                    class: "group relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-[var(--color-paper-theme)] transition-all hover:shadow-md {item_class}",
458                                    title: "{item.filename}",
459                                    aria_label: "{item.filename}",
460                                    onclick: move |_| {
461                                        if multi {
462                                            toggle_selection(&mut selected.write(), selection.clone());
463                                        } else {
464                                            on_pick.call(vec![selection.clone()]);
465                                        }
466                                    },
467                                    img { class: "h-full w-full object-cover", src: "{item.thumbnail}", alt: item.alt.clone().unwrap_or_else(|| item.filename.clone()), loading: "lazy" }
468                                    if item.fresh { span { class: "absolute inset-x-2 bottom-2 rounded-full bg-black/55 px-2 py-1 text-center text-xs font-medium text-white backdrop-blur-sm", "刚上传" } }
469                                    if let Some(index) = order {
470                                        span { class: "absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-[var(--color-paper-accent)] text-xs font-bold text-[var(--color-paper-theme)] shadow", "{index + 1}" }
471                                    }
472                                }
473                            }
474                        }
475                    }
476                }
477            }
478        }
479    }
480}
481
482#[component]
483pub(crate) fn PickerSelectionFooter(
484    mut selected: Signal<Vec<AssetSelection>>,
485    on_pick: EventHandler<Vec<AssetSelection>>,
486) -> Element {
487    let count = selected.read().len();
488    rsx! {
489        div { class: "flex shrink-0 items-center justify-between gap-3 px-6 py-3 shadow-[inset_0_1px_0_var(--color-paper-border)]",
490            span { class: "text-sm text-[var(--color-paper-secondary)]", if count == 0 { "未选择图片" } else { "已选 {count} 张" } }
491            div { class: "flex items-center gap-2",
492                button { class: "{BTN_OUTLINE}", disabled: count == 0, onclick: move |_| selected.set(Vec::new()), "清空" }
493                button { class: "{BTN_PRIMARY}", disabled: count == 0, onclick: move |_| {
494                    let picks = selected();
495                    if !picks.is_empty() { on_pick.call(picks); }
496                }, "插入 {count} 张图片" }
497            }
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::{prepend_uploaded_asset, toggle_selection, AssetSelection, PickerAsset};
505
506    fn sel(url: &str) -> AssetSelection {
507        AssetSelection {
508            url: url.to_string(),
509            alt: None,
510        }
511    }
512
513    #[test]
514    fn toggle_appends_in_click_order() {
515        let mut selected = Vec::new();
516        toggle_selection(&mut selected, sel("/uploads/b.webp"));
517        toggle_selection(&mut selected, sel("/uploads/a.webp"));
518        toggle_selection(&mut selected, sel("/uploads/c.webp"));
519        let urls: Vec<&str> = selected.iter().map(|s| s.url.as_str()).collect();
520        assert_eq!(
521            urls,
522            ["/uploads/b.webp", "/uploads/a.webp", "/uploads/c.webp"]
523        );
524    }
525
526    #[test]
527    fn toggle_removes_existing_by_url() {
528        let mut selected = vec![sel("/uploads/a.webp"), sel("/uploads/b.webp")];
529        toggle_selection(&mut selected, sel("/uploads/a.webp"));
530        assert_eq!(selected, vec![sel("/uploads/b.webp")]);
531    }
532
533    #[test]
534    fn toggle_dedupes_same_url_regardless_of_alt() {
535        // 同一 URL 先以 alt=None(刚上传形态)入选,网格刷新后同图带 alt 再点:
536        // 应移除(视为同一张图),而非追加重复条目。
537        let mut selected = vec![sel("/uploads/a.webp")];
538        toggle_selection(
539            &mut selected,
540            AssetSelection {
541                url: "/uploads/a.webp".to_string(),
542                alt: Some("封面".to_string()),
543            },
544        );
545        assert!(selected.is_empty());
546    }
547
548    #[test]
549    fn uploaded_asset_does_not_duplicate_a_loaded_grid_url() {
550        let mut items = vec![PickerAsset {
551            url: "/uploads/a.webp".to_string(),
552            thumbnail: "/uploads/a.webp?thumb=300x300".to_string(),
553            filename: "a.webp".to_string(),
554            alt: Some("封面".to_string()),
555            fresh: false,
556        }];
557        prepend_uploaded_asset(&mut items, "/uploads/a.webp".to_string());
558        assert_eq!(items.len(), 1);
559        prepend_uploaded_asset(&mut items, "/uploads/b.webp".to_string());
560        assert_eq!(
561            items
562                .iter()
563                .map(|item| item.url.as_str())
564                .collect::<Vec<_>>(),
565            ["/uploads/b.webp", "/uploads/a.webp"]
566        );
567    }
568
569    #[test]
570    fn selection_serializes_to_editor_bridge_shape() {
571        // 桥接契约:insertImagesFromLibrary 的载荷为 [{"src","alt"?}],
572        // JS 侧 parseLibraryInsertItems 依赖字段名 src/alt 与 alt 缺省不序列化。
573        let picks = vec![
574            AssetSelection {
575                url: "/uploads/a.webp".to_string(),
576                alt: Some("封面".to_string()),
577            },
578            sel("/uploads/b.webp"),
579        ];
580        let json = serde_json::to_string(&picks).expect("AssetSelection 必然可序列化");
581        assert_eq!(
582            json,
583            r#"[{"src":"/uploads/a.webp","alt":"封面"},{"src":"/uploads/b.webp"}]"#
584        );
585    }
586}