yggdrasil/components/assets/
asset_picker.rs1use 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
22const ASSETS_PER_PAGE: i32 = 60;
24const SEARCH_DEBOUNCE_MS: u64 = 300;
26
27#[derive(Clone, Debug, PartialEq, serde::Serialize)]
32pub struct AssetSelection {
33 #[serde(rename = "src")]
35 pub url: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub alt: Option<String>,
39}
40
41#[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
80fn 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#[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 let uploading_preview = RwSignal::new(None::<String>);
118 let closing = RwSignal::new(false);
120 let opened = RwSignal::new(false);
121 let uploaded_url = RwSignal::new(None::<String>);
123 let selected = RwSignal::new(Vec::<AssetSelection>::new());
126
127 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 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 selected.set(Vec::new());
147 } else if opened.get_untracked() {
148 bump_generation();
149 page.set(1);
150 }
151 });
152
153 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 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 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 <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#[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 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 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#[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 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#[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#[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 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 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}