yggdrasil/components/assets/
asset_picker.rs1use 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
21const ASSETS_PER_PAGE: i32 = 60;
23#[cfg(target_arch = "wasm32")]
25const SEARCH_DEBOUNCE_MS: u32 = 300;
26const EXIT_ANIM_MS: u32 = 200;
28
29#[derive(Clone, Debug, PartialEq, serde::Serialize)]
34pub struct AssetSelection {
35 #[serde(rename = "src")]
37 pub url: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub alt: Option<String>,
41}
42
43fn 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#[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 let mut uploading_preview = use_signal(|| None::<String>);
86 let mut closing = use_signal(|| false);
88 let mut opened = use_signal(|| false);
89 let mut uploaded_url = use_signal(|| None::<String>);
91 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 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 #[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 #[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 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 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 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 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 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 div { class: "relative min-h-0 flex-1 overflow-y-auto p-6",
302 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 let uploaded_order = if multi {
364 selected.read().iter().position(|s| s.url == uploaded_url)
365 } else {
366 None
367 };
368 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 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 let order = if multi {
426 selected.read().iter().position(|s| s.url == url)
427 } else {
428 None
429 };
430 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 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 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 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 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}