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//! 纯 Leptos 组件,不触碰 Tiptap;数据加载仅在浏览器端(Effect)发生。
10
11use std::time::Duration;
12
13use leptos::prelude::*;
14
15use crate::api::assets::list_assets;
16use crate::components::ui::{
17    button_class, ButtonSize, ButtonVariant, Icon, IconButton, IconName, ModalShell, TablePager,
18};
19use crate::models::asset::{AssetDto, AssetFilter, AssetSort};
20use crate::utils::error::failure_message;
21
22/// 每页素材数,与素材管理页及服务端列表接口保持一致。
23const ASSETS_PER_PAGE: i32 = 60;
24/// 搜索输入防抖窗口,复用素材管理页的请求节流约定。
25const SEARCH_DEBOUNCE_MS: u64 = 300;
26
27/// 素材选中载荷:`on_select` 的回传单元。
28///
29/// 序列化形状即编辑器桥接契约 `[{ "src", "alt?" }]`(`insertImagesFromLibrary`),
30/// 写字页确认后直接 `serde_json::to_string` 传给 JS 侧,无需二次映射。
31#[derive(Clone, Debug, PartialEq, serde::Serialize)]
32pub struct AssetSelection {
33    /// `/uploads/<path>` URL(序列化为编辑器 Image 节点的 `src`)。
34    #[serde(rename = "src")]
35    pub url: String,
36    /// 图片替代文本(素材 alt);None 时不序列化,JS 侧等同未提供。
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub alt: Option<String>,
39}
40
41/// Grid data shared by the authenticated picker and the local showcase.
42#[derive(Clone, PartialEq)]
43pub struct PickerAsset {
44    pub(crate) url: String,
45    pub(crate) thumbnail: String,
46    pub(crate) filename: String,
47    pub(crate) alt: Option<String>,
48    pub(crate) fresh: bool,
49}
50
51impl From<&AssetDto> for PickerAsset {
52    fn from(dto: &AssetDto) -> Self {
53        let url = format!("/uploads/{}", dto.asset.path);
54        Self {
55            thumbnail: format!("{url}?thumb=300x300"),
56            url,
57            filename: dto.asset.filename.clone(),
58            alt: dto.asset.alt.clone(),
59            fresh: false,
60        }
61    }
62}
63
64fn prepend_uploaded_asset(items: &mut Vec<PickerAsset>, url: String) {
65    if items.iter().any(|item| item.url == url) {
66        return;
67    }
68    items.insert(
69        0,
70        PickerAsset {
71            thumbnail: url.clone(),
72            url,
73            filename: "新上传图片".to_string(),
74            alt: None,
75            fresh: true,
76        },
77    );
78}
79
80/// 切换素材勾选态:已选(按 URL 判重)则移除,未选则按点击顺序追加。
81///
82/// 以 URL 为唯一键:上传新图在网格刷新前后可能以不同条目形态出现
83/// (`uploaded_url` 暂存 vs 素材列表行),URL 判重保证同图不会被选两次。
84/// 抽成纯函数便于单元测试(见本文件 tests 模块)。
85fn toggle_selection(selected: &mut Vec<AssetSelection>, item: AssetSelection) {
86    if let Some(pos) = selected.iter().position(|s| s.url == item.url) {
87        selected.remove(pos);
88    } else {
89        selected.push(item);
90    }
91}
92
93/// 素材选择 modal。
94///
95/// - `visible`:显隐控制(父组件持有,选中/点遮罩/× 都会置 false)。
96/// - `on_select`:选中回填。单选模式恰含一个元素;多选模式按勾选顺序排列。
97/// - `cover_uploading`:modal 内上传新图时置位,供父页面拦截保存(与 CoverUploader 语义一致)。
98/// - `title`:modal 标题;封面场景缺省为「选择封面图」,头像/正文场景各自传入。
99/// - `multi`:多选模式(正文插图)。网格项变为勾选切换,底部确认栏批量回填。
100#[component]
101#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
102pub fn AssetPickerModal(
103    visible: RwSignal<bool>,
104    #[prop(into)] on_select: Callback<Vec<AssetSelection>>,
105    cover_uploading: RwSignal<bool>,
106    #[prop(default = "选择封面图".to_string(), into)] title: String,
107    #[prop(optional)] multi: bool,
108) -> impl IntoView {
109    let assets = RwSignal::new(Vec::<AssetDto>::new());
110    let loading = RwSignal::new(false);
111    let query = RwSignal::new(String::new());
112    let debounced_query = RwSignal::new(String::new());
113    let error = RwSignal::new(None::<String>);
114    let page = RwSignal::new(1_i32);
115    let total = RwSignal::new(0_i64);
116    // 上传中的本地对象 URL 只用于当前 modal 预览,上传结束立即释放。
117    let uploading_preview = RwSignal::new(None::<String>);
118    // 关闭时保留 DOM 播放淡出;动画结束后再卸载,避免弹窗瞬间消失。
119    let closing = RwSignal::new(false);
120    let opened = RwSignal::new(false);
121    // 上传完成后先把新图留在弹窗网格中,用户点击后才真正应用头像。
122    let uploaded_url = RwSignal::new(None::<String>);
123    // 多选模式的选中集:按勾选顺序排列,跨页/搜索/上传保留;每次打开时重置。
124    // 以 URL 判重(见 toggle_selection)。单选模式不使用。
125    let selected = RwSignal::new(Vec::<AssetSelection>::new());
126
127    // 请求代际:任何新输入 / 翻页 / 关闭都会递增,旧响应据此丢弃。
128    let generation = StoredValue::new(0_u64);
129    let bump_generation = move || {
130        generation
131            .try_update_value(|g| {
132                *g = g.wrapping_add(1);
133                *g
134            })
135            .unwrap_or(0)
136    };
137    let current_generation = move || generation.try_get_value().unwrap_or(0);
138    // 防抖定时器句柄:新输入前清除旧的,卸载时清除。
139    let debounce_timer = StoredValue::new(None::<TimeoutHandle>);
140
141    Effect::new(move |_| {
142        if visible.get() {
143            opened.set(true);
144            uploaded_url.set(None);
145            // 每次打开都是一次全新的选择会话(取消或确认后关闭均不留旧勾选)。
146            selected.set(Vec::new());
147        } else if opened.get_untracked() {
148            bump_generation();
149            page.set(1);
150        }
151    });
152
153    // 搜索防抖:保留输入框原值,停顿 300ms 后才提交查询并回到第 1 页。
154    // 每次新输入都会递增请求代际,立即丢弃尚未返回的旧查询结果。
155    Effect::new(move |_| {
156        let q = query.get();
157        bump_generation();
158        if let Some(Some(old)) = debounce_timer.try_update_value(Option::take) {
159            old.clear();
160        }
161        let handle = set_timeout_with_handle(
162            move || {
163                if query.try_get_untracked().as_deref() == Some(q.as_str()) {
164                    if debounced_query.try_get_untracked().as_deref() != Some(q.as_str()) {
165                        let _ = debounced_query.try_set(q);
166                    }
167                    let _ = page.try_set(1);
168                }
169            },
170            Duration::from_millis(SEARCH_DEBOUNCE_MS),
171        );
172        if let Ok(handle) = handle {
173            debounce_timer.set_value(Some(handle));
174        }
175    });
176
177    // 打开、搜索词或页码变化时加载当前页;请求代际保证只有最后一次响应能落地。
178    Effect::new(move |_| {
179        let open = visible.get();
180        let q = debounced_query.get();
181        let requested_page = page.get();
182        if !open {
183            return;
184        }
185        let request_id = bump_generation();
186        leptos::task::spawn_local(async move {
187            if current_generation() != request_id {
188                return;
189            }
190            let _ = loading.try_set(true);
191            let _ = error.try_set(None);
192            let result =
193                list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, requested_page).await;
194            if current_generation() != request_id {
195                return;
196            }
197            match result {
198                Ok(resp) => {
199                    let last_page = ((resp.total + ASSETS_PER_PAGE as i64 - 1)
200                        / ASSETS_PER_PAGE as i64)
201                        .max(1) as i32;
202                    if resp.assets.is_empty() && requested_page > last_page {
203                        let _ = page.try_set(last_page);
204                        return;
205                    }
206                    let _ = assets.try_set(resp.assets);
207                    let _ = total.try_set(resp.total);
208                    let _ = error.try_set(None);
209                }
210                Err(e) => {
211                    let _ = error.try_set(Some(failure_message("素材加载失败", &e)));
212                }
213            }
214            if current_generation() == request_id {
215                let _ = loading.try_set(false);
216            }
217        });
218    });
219
220    // 卸载:丢弃在途请求结果、清防抖定时器、释放尚未回收的预览对象 URL。
221    on_cleanup(move || {
222        bump_generation();
223        if let Some(Some(timer)) = debounce_timer.try_update_value(Option::take) {
224            timer.clear();
225        }
226        #[cfg(target_arch = "wasm32")]
227        if let Some(Some(preview_url)) = uploading_preview.try_get_untracked() {
228            let _ = web_sys::Url::revoke_object_url(&preview_url);
229        }
230    });
231
232    let show_pagination = move || total.get() > ASSETS_PER_PAGE as i64;
233    let gallery_items = Memo::new(move |_| {
234        let mut items: Vec<PickerAsset> =
235            assets.with(|list| list.iter().map(PickerAsset::from).collect());
236        if let Some(url) = uploaded_url.get() {
237            prepend_uploaded_asset(&mut items, url);
238        }
239        items
240    });
241
242    let close = move || {
243        closing.set(true);
244        visible.set(false);
245    };
246    let pick_and_close = move |picks: Vec<AssetSelection>| {
247        on_select.run(picks);
248        close();
249    };
250
251    view! {
252        <ModalShell
253            visible=visible
254            closing=closing
255            title=title.clone()
256            overlay_padding="p-4 sm:p-6"
257            panel_class="asset-modal asset-picker"
258        >
259            <div class="asset-modal-head">
260                <h2>{title.clone()}</h2>
261                <IconButton
262                    icon=IconName::X
263                    label="关闭"
264                    size=ButtonSize::Sm
265                    onclick=move |_| close()
266                />
267            </div>
268            <div class="asset-modal-bar">
269                <label class="field field-sm admin-filter-search asset-modal-search">
270                    <Icon name=IconName::Search size=14/>
271                    <span class="sr-only">"搜索素材"</span>
272                    <input
273                        type="search"
274                        placeholder="文件名或替代文本"
275                        prop:value=move || query.get()
276                        on:input=move |e| query.set(event_target_value(&e))
277                    />
278                </label>
279                // 上传新图:成功后留在网格里,单选模式由用户再点一次确认。
280                <label
281                    class=format!(
282                        "{} asset-modal-upload",
283                        button_class(ButtonVariant::Secondary, ButtonSize::Sm),
284                    )
285                    aria-disabled=move || cover_uploading.get().then_some("true")
286                >
287                    <Icon name=IconName::Upload size=14/>
288                    {move || if cover_uploading.get() { "上传中…" } else { "上传新图片" }}
289                    <input
290                        type="file"
291                        accept="image/jpeg,image/png,image/gif,image/webp"
292                        class="sr-only"
293                        disabled=move || cover_uploading.get()
294                        on:change=move |evt| {
295                            #[cfg(target_arch = "wasm32")]
296                            start_inline_upload(
297                                &evt,
298                                multi,
299                                cover_uploading,
300                                uploading_preview,
301                                uploaded_url,
302                                selected,
303                                error,
304                            );
305                            #[cfg(not(target_arch = "wasm32"))]
306                            let _ = evt;
307                        }
308                    />
309                </label>
310            </div>
311
312            <PickerGallery
313                items=gallery_items
314                selected=selected
315                multi=multi
316                loading=loading
317                uploading=cover_uploading
318                error=error
319                uploading_preview=uploading_preview
320                on_pick=pick_and_close
321            />
322            <Show when=show_pagination>
323                <div
324                    class="asset-modal-pager"
325                    aria-busy=move || loading.get().then_some("true")
326                >
327                    <TablePager
328                        page=page
329                        total=total
330                        per_page=ASSETS_PER_PAGE
331                        unit="张"
332                        on_change=move |next: i32| {
333                            if !loading.get_untracked() {
334                                page.set(next.max(1));
335                            }
336                        }
337                    />
338                </div>
339            </Show>
340
341            <Show when=move || multi>
342                <PickerSelectionFooter selected=selected on_pick=pick_and_close/>
343            </Show>
344        </ModalShell>
345    }
346}
347
348/// 选择文件后的内嵌上传:预检 → 预览 → 上传 → 回填网格。仅浏览器端。
349#[cfg(target_arch = "wasm32")]
350fn start_inline_upload(
351    evt: &web_sys::Event,
352    multi: bool,
353    cover_uploading: RwSignal<bool>,
354    uploading_preview: RwSignal<Option<String>>,
355    uploaded_url: RwSignal<Option<String>>,
356    selected: RwSignal<Vec<AssetSelection>>,
357    error: RwSignal<Option<String>>,
358) {
359    if cover_uploading.get_untracked() {
360        return;
361    }
362    let Some(web_file) = event_target::<web_sys::HtmlInputElement>(evt)
363        .files()
364        .and_then(|list| list.item(0))
365    else {
366        return;
367    };
368    // 与 AssetUploadModal 的 worker 池入队校验(upload_pool::validate_file)同一份规则:
369    // 不合格直接提示、不发请求,补上与批量上传一致的客户端预检。
370    if let Err(msg) = super::upload_pool::validate_file(&web_file.type_(), web_file.size() as u64) {
371        error.set(Some(format!("上传失败:{msg}")));
372        return;
373    }
374    let preview_url = web_sys::Url::create_object_url_with_blob(&web_file).ok();
375    uploading_preview.set(preview_url.clone());
376    cover_uploading.set(true);
377    error.set(None);
378    leptos::task::spawn_local(async move {
379        let result = crate::bridges::tiptap::upload_image_file(web_file).await;
380        if let Some(preview_url) = preview_url.as_deref() {
381            let _ = web_sys::Url::revoke_object_url(preview_url);
382        }
383        match result {
384            Ok(url) => {
385                // 多选模式:上传即意图,新图直接进入选中集
386                // (URL 判重,网格刷新后同图不会重复选中)。
387                if multi {
388                    let _ = selected.try_update(|list| {
389                        toggle_selection(
390                            list,
391                            AssetSelection {
392                                url: url.clone(),
393                                alt: None,
394                            },
395                        )
396                    });
397                }
398                let _ = uploaded_url.try_set(Some(url));
399                let _ = uploading_preview.try_set(None);
400                let _ = cover_uploading.try_set(false);
401            }
402            Err(msg) => {
403                let _ = error.try_set(Some(format!("上传失败:{msg}")));
404                let _ = uploading_preview.try_set(None);
405                let _ = cover_uploading.try_set(false);
406            }
407        }
408    });
409}
410
411/// The actual picker grid is shared; callers provide already loaded display rows.
412#[component]
413pub(crate) fn PickerGallery(
414    #[prop(into)] items: Signal<Vec<PickerAsset>>,
415    selected: RwSignal<Vec<AssetSelection>>,
416    multi: bool,
417    #[prop(into)] loading: Signal<bool>,
418    #[prop(into)] uploading: Signal<bool>,
419    #[prop(into)] error: Signal<Option<String>>,
420    #[prop(into)] uploading_preview: Signal<Option<String>>,
421    #[prop(into)] on_pick: Callback<Vec<AssetSelection>>,
422) -> impl IntoView {
423    // 分支判别量用 Memo 去重:只有错误 / 读取中 / 空 / 网格切换时才重建主体,
424    // 网格内部的增删与勾选由各自的响应式绑定原地更新。
425    let branch = Memo::new(move |_| {
426        let empty = items.with(Vec::is_empty);
427        if error.with(Option::is_some) {
428            0
429        } else if loading.get() && empty && !uploading.get() {
430            1
431        } else if empty && !uploading.get() {
432            2
433        } else {
434            3
435        }
436    });
437    view! {
438        <div class="asset-modal-body" aria-busy=move || loading.get().then_some("true")>
439            {move || match branch.get() {
440                0 => {
441                    view! {
442                        <p class="asset-modal-error" role="alert">
443                            {move || error.get().unwrap_or_default()}
444                        </p>
445                    }
446                        .into_any()
447                }
448                1 => {
449                    view! {
450                        <p class="asset-modal-empty" role="status">
451                            "正在读取素材…"
452                        </p>
453                    }
454                        .into_any()
455                }
456                2 => {
457                    view! { <p class="asset-modal-empty">"素材库还是空的,先上传一张图片。"</p> }
458                        .into_any()
459                }
460                _ => {
461                    view! {
462                        <ul class="asset-pick-grid">
463                            {move || {
464                                uploading
465                                    .get()
466                                    .then(|| {
467                                        view! {
468                                            <li
469                                                class="asset-pick"
470                                                data-uploading="true"
471                                                aria-live="polite"
472                                                aria-label="正在上传图片"
473                                            >
474                                                <span class="asset-pick-thumb">
475                                                    {move || {
476                                                        uploading_preview
477                                                            .get()
478                                                            .map(|preview_url| {
479                                                                view! { <img src=preview_url alt=""/> }
480                                                            })
481                                                    }} <span class="asset-pick-progress">"上传中"</span>
482                                                </span>
483                                                <span class="asset-pick-name">"新图片"</span>
484                                            </li>
485                                        }
486                                    })
487                            }}
488                            <For
489                                each=move || items.get()
490                                key=|item| {
491                                    (
492                                        item.url.clone(),
493                                        item.thumbnail.clone(),
494                                        item.filename.clone(),
495                                        item.alt.clone(),
496                                        item.fresh,
497                                    )
498                                }
499                                children=move |item| {
500                                    view! {
501                                        <PickerItem
502                                            item=item
503                                            selected=selected
504                                            multi=multi
505                                            on_pick=on_pick
506                                        />
507                                    }
508                                }
509                            />
510                        </ul>
511                    }
512                        .into_any()
513                }
514            }}
515        </div>
516    }
517}
518
519/// 网格中的单个素材:单选点击即回填,多选点击切换勾选并显示勾选序号。
520#[component]
521fn PickerItem(
522    item: PickerAsset,
523    selected: RwSignal<Vec<AssetSelection>>,
524    multi: bool,
525    on_pick: Callback<Vec<AssetSelection>>,
526) -> impl IntoView {
527    let PickerAsset {
528        url,
529        thumbnail,
530        filename,
531        alt,
532        fresh,
533    } = item;
534    let selection = AssetSelection {
535        url: url.clone(),
536        alt,
537    };
538    let order = {
539        let url = url.clone();
540        move || {
541            if multi {
542                selected.with(|list| list.iter().position(|value| value.url == url))
543            } else {
544                None
545            }
546        }
547    };
548    let order_for_checked = order.clone();
549    let order_for_aria = order.clone();
550    let title_attr = filename.clone();
551    let aria_label = filename.clone();
552    view! {
553        <li>
554            <button
555                class="asset-pick"
556                type="button"
557                role=if multi { "checkbox" } else { "button" }
558                aria-checked=move || multi.then(|| order_for_aria().is_some().to_string())
559                data-checked=move || order_for_checked().is_some().then_some("true")
560                title=title_attr
561                aria-label=aria_label
562                on:click=move |_| {
563                    if multi {
564                        selected.update(|list| toggle_selection(list, selection.clone()));
565                    } else {
566                        on_pick.run(vec![selection.clone()]);
567                    }
568                }
569            >
570                <span class="asset-pick-thumb">
571                    <img src=thumbnail alt="" loading="lazy"/>
572                    {multi
573                        .then(|| {
574                            view! {
575                                <span class="asset-pick-order" aria-hidden="true">
576                                    {move || order().map(|index| (index + 1).to_string())}
577                                </span>
578                            }
579                        })} {fresh.then(|| view! { <span class="asset-pick-fresh">"刚上传"</span> })}
580                </span>
581                <span class="asset-pick-name">{filename}</span>
582            </button>
583        </li>
584    }
585}
586
587/// 多选模式底栏:已选计数、清空与确认插入。
588#[component]
589pub(crate) fn PickerSelectionFooter(
590    selected: RwSignal<Vec<AssetSelection>>,
591    #[prop(into)] on_pick: Callback<Vec<AssetSelection>>,
592) -> impl IntoView {
593    let count = move || selected.with(Vec::len);
594    view! {
595        <div class="asset-modal-foot">
596            <span>
597                {move || {
598                    match count() {
599                        0 => "可以多选,插入顺序与勾选顺序一致".to_string(),
600                        n => format!("已选 {n} 张 · 按勾选顺序插入"),
601                    }
602                }}
603            </span>
604            <div class="asset-modal-foot-actions">
605                <button
606                    class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
607                    type="button"
608                    disabled=move || count() == 0
609                    on:click=move |_| selected.set(Vec::new())
610                >
611                    "清空"
612                </button>
613                <button
614                    class=button_class(ButtonVariant::Primary, ButtonSize::Md)
615                    type="button"
616                    disabled=move || count() == 0
617                    on:click=move |_| {
618                        let picks = selected.get_untracked();
619                        if !picks.is_empty() {
620                            on_pick.run(picks);
621                        }
622                    }
623                >
624                    {move || match count() {
625                        0 => "插入".to_string(),
626                        n => format!("插入 {n} 张"),
627                    }}
628                </button>
629            </div>
630        </div>
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    fn sel(url: &str) -> AssetSelection {
639        AssetSelection {
640            url: url.to_string(),
641            alt: None,
642        }
643    }
644
645    fn picker_asset(url: &str, filename: &str) -> PickerAsset {
646        PickerAsset {
647            url: url.to_string(),
648            thumbnail: format!("{url}?thumb=300x300"),
649            filename: filename.to_string(),
650            alt: Some("封面".to_string()),
651            fresh: false,
652        }
653    }
654
655    #[test]
656    fn toggle_appends_in_click_order() {
657        let mut selected = Vec::new();
658        toggle_selection(&mut selected, sel("/uploads/b.webp"));
659        toggle_selection(&mut selected, sel("/uploads/a.webp"));
660        toggle_selection(&mut selected, sel("/uploads/c.webp"));
661        let urls: Vec<&str> = selected.iter().map(|s| s.url.as_str()).collect();
662        assert_eq!(
663            urls,
664            ["/uploads/b.webp", "/uploads/a.webp", "/uploads/c.webp"]
665        );
666    }
667
668    #[test]
669    fn toggle_removes_existing_by_url() {
670        let mut selected = vec![sel("/uploads/a.webp"), sel("/uploads/b.webp")];
671        toggle_selection(&mut selected, sel("/uploads/a.webp"));
672        assert_eq!(selected, vec![sel("/uploads/b.webp")]);
673    }
674
675    #[test]
676    fn toggle_dedupes_same_url_regardless_of_alt() {
677        // 同一 URL 先以 alt=None(刚上传形态)入选,网格刷新后同图带 alt 再点:
678        // 应移除(视为同一张图),而非追加重复条目。
679        let mut selected = vec![sel("/uploads/a.webp")];
680        toggle_selection(
681            &mut selected,
682            AssetSelection {
683                url: "/uploads/a.webp".to_string(),
684                alt: Some("封面".to_string()),
685            },
686        );
687        assert!(selected.is_empty());
688    }
689
690    #[test]
691    fn uploaded_asset_does_not_duplicate_a_loaded_grid_url() {
692        let mut items = vec![picker_asset("/uploads/a.webp", "a.webp")];
693        prepend_uploaded_asset(&mut items, "/uploads/a.webp".to_string());
694        assert_eq!(items.len(), 1);
695        prepend_uploaded_asset(&mut items, "/uploads/b.webp".to_string());
696        assert_eq!(
697            items
698                .iter()
699                .map(|item| item.url.as_str())
700                .collect::<Vec<_>>(),
701            ["/uploads/b.webp", "/uploads/a.webp"]
702        );
703    }
704
705    #[test]
706    fn selection_serializes_to_editor_bridge_shape() {
707        // 桥接契约:insertImagesFromLibrary 的载荷为 [{"src","alt"?}],
708        // JS 侧 parseLibraryInsertItems 依赖字段名 src/alt 与 alt 缺省不序列化。
709        let picks = vec![
710            AssetSelection {
711                url: "/uploads/a.webp".to_string(),
712                alt: Some("封面".to_string()),
713            },
714            sel("/uploads/b.webp"),
715        ];
716        let json = serde_json::to_string(&picks).expect("AssetSelection 必然可序列化");
717        assert_eq!(
718            json,
719            r#"[{"src":"/uploads/a.webp","alt":"封面"},{"src":"/uploads/b.webp"}]"#
720        );
721    }
722
723    fn render_gallery(multi: bool, selected: Vec<AssetSelection>) -> String {
724        Owner::new().with(|| {
725            let items = RwSignal::new(vec![
726                picker_asset("/uploads/a.webp", "a.webp"),
727                PickerAsset {
728                    fresh: true,
729                    ..picker_asset("/uploads/b.webp", "新上传图片")
730                },
731            ]);
732            let selected = RwSignal::new(selected);
733            view! {
734                <PickerGallery
735                    items=items
736                    selected=selected
737                    multi=multi
738                    loading=false
739                    uploading=false
740                    error={None::<String>}
741                    uploading_preview={None::<String>}
742                    on_pick=Callback::new(|_| {})
743                />
744            }
745            .to_html()
746        })
747    }
748
749    #[test]
750    fn single_gallery_renders_button_role_and_fresh_badge() {
751        let html = render_gallery(false, Vec::new());
752        assert!(html.contains("role=\"button\""), "{html}");
753        assert!(html.contains("asset-pick-fresh"), "{html}");
754        assert!(!html.contains("asset-pick-order"), "{html}");
755    }
756
757    #[test]
758    fn multi_gallery_marks_checked_items_with_order() {
759        let html = render_gallery(true, vec![sel("/uploads/b.webp")]);
760        assert!(html.contains("role=\"checkbox\""), "{html}");
761        assert_eq!(html.matches("data-checked=\"true\"").count(), 1, "{html}");
762        assert!(html.contains("aria-checked=\"true\""), "{html}");
763        assert!(html.contains("aria-checked=\"false\""), "{html}");
764    }
765
766    #[test]
767    fn gallery_shows_empty_and_error_states() {
768        let html = Owner::new().with(|| {
769            view! {
770                <PickerGallery
771                    items={Vec::<PickerAsset>::new()}
772                    selected=RwSignal::new(Vec::new())
773                    multi=false
774                    loading=false
775                    uploading=false
776                    error={None::<String>}
777                    uploading_preview={None::<String>}
778                    on_pick=Callback::new(|_| {})
779                />
780            }
781            .to_html()
782        });
783        assert!(html.contains("素材库还是空的"), "{html}");
784        let html = Owner::new().with(|| {
785            view! {
786                <PickerGallery
787                    items={Vec::<PickerAsset>::new()}
788                    selected=RwSignal::new(Vec::new())
789                    multi=false
790                    loading=false
791                    uploading=false
792                    error=Some("素材加载失败,请稍后再试".to_string())
793                    uploading_preview={None::<String>}
794                    on_pick=Callback::new(|_| {})
795                />
796            }
797            .to_html()
798        });
799        assert!(html.contains("role=\"alert\""), "{html}");
800        assert!(html.contains("素材加载失败"), "{html}");
801    }
802}