yggdrasil/components/assets/
asset_picker.rs1use 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
24const ASSETS_PER_PAGE: i32 = 60;
26#[cfg(target_arch = "wasm32")]
28const SEARCH_DEBOUNCE_MS: u32 = 300;
29
30#[derive(Clone, Debug, PartialEq, serde::Serialize)]
35pub struct AssetSelection {
36 #[serde(rename = "src")]
38 pub url: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub alt: Option<String>,
42}
43
44fn 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#[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 let mut uploading_preview = use_signal(|| None::<String>);
87 let mut closing = use_signal(|| false);
89 let mut opened = use_signal(|| false);
90 let mut uploaded_url = use_signal(|| None::<String>);
92 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 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 #[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 #[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 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 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 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 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 div { class: "relative min-h-0 flex-1 overflow-y-auto p-6",
292 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 let uploaded_order = if multi {
354 selected.read().iter().position(|s| s.url == uploaded_url)
355 } else {
356 None
357 };
358 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 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 let order = if multi {
416 selected.read().iter().position(|s| s.url == url)
417 } else {
418 None
419 };
420 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 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 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 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 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}