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