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