1use 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
44#[derive(Clone, PartialEq)]
46pub(crate) struct PickerAsset {
47 pub(crate) url: String,
48 pub(crate) thumbnail: String,
49 pub(crate) filename: String,
50 pub(crate) alt: Option<String>,
51 pub(crate) fresh: bool,
52}
53
54impl From<&AssetDto> for PickerAsset {
55 fn from(dto: &AssetDto) -> Self {
56 let url = format!("/uploads/{}", dto.asset.path);
57 Self {
58 thumbnail: format!("{url}?thumb=300x300"),
59 url,
60 filename: dto.asset.filename.clone(),
61 alt: dto.asset.alt.clone(),
62 fresh: false,
63 }
64 }
65}
66
67fn prepend_uploaded_asset(items: &mut Vec<PickerAsset>, url: String) {
68 if items.iter().any(|item| item.url == url) {
69 return;
70 }
71 items.insert(
72 0,
73 PickerAsset {
74 thumbnail: url.clone(),
75 url,
76 filename: "新上传图片".to_string(),
77 alt: None,
78 fresh: true,
79 },
80 );
81}
82
83fn toggle_selection(selected: &mut Vec<AssetSelection>, item: AssetSelection) {
89 if let Some(pos) = selected.iter().position(|s| s.url == item.url) {
90 selected.remove(pos);
91 } else {
92 selected.push(item);
93 }
94}
95
96#[component]
104#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
105pub fn AssetPickerModal(
106 mut visible: Signal<bool>,
107 on_select: EventHandler<Vec<AssetSelection>>,
108 cover_uploading: Signal<bool>,
109 #[props(default = "选择封面图")] title: &'static str,
110 #[props(default)] multi: bool,
111) -> Element {
112 #[allow(unused_mut)]
113 let mut assets: Signal<Vec<AssetDto>> = use_signal(Vec::new);
114 #[allow(unused_mut)]
115 let mut loading = use_signal(|| false);
116 let mut query = use_signal(String::new);
117 #[allow(unused_mut)]
118 let mut debounced_query = use_signal(String::new);
119 #[allow(unused_mut)]
120 let mut error = use_signal(|| None::<String>);
121 let mut page = use_signal(|| 1_i32);
122 #[allow(unused_mut)]
123 let mut total = use_signal(|| 0_i64);
124 let mut uploading_preview = use_signal(|| None::<String>);
126 let mut closing = use_signal(|| false);
128 let mut opened = use_signal(|| false);
129 let mut uploaded_url = use_signal(|| None::<String>);
131 let mut selected: Signal<Vec<AssetSelection>> = use_signal(Vec::new);
134
135 #[cfg(target_arch = "wasm32")]
136 let request_generation = use_hook(|| std::rc::Rc::new(std::cell::Cell::new(0_u64)));
137
138 #[cfg(target_arch = "wasm32")]
139 let request_generation_for_close = request_generation.clone();
140 use_effect(move || {
141 if visible() {
142 opened.set(true);
143 uploaded_url.set(None);
144 selected.set(Vec::new());
146 } else if *opened.peek() {
147 #[cfg(target_arch = "wasm32")]
148 request_generation_for_close.set(request_generation_for_close.get().wrapping_add(1));
149 page.set(1);
150 }
151 });
152
153 #[cfg(target_arch = "wasm32")]
156 let request_generation_for_debounce = request_generation.clone();
157 use_effect(move || {
158 let q = query();
159 #[cfg(target_arch = "wasm32")]
160 {
161 request_generation_for_debounce
162 .set(request_generation_for_debounce.get().wrapping_add(1));
163 spawn(async move {
164 crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
165 if *query.peek() == q {
166 if *debounced_query.peek() != q {
167 debounced_query.set(q);
168 }
169 page.set(1);
170 }
171 });
172 }
173 });
174
175 #[cfg(target_arch = "wasm32")]
177 let request_generation_for_load = request_generation.clone();
178 use_effect(move || {
179 let open = visible();
180 let q = debounced_query();
181 let requested_page = page();
182 if open {
183 #[cfg(target_arch = "wasm32")]
184 {
185 let request_generation = request_generation_for_load.clone();
186 let request_id = request_generation.get().wrapping_add(1);
187 request_generation.set(request_id);
188 spawn(async move {
189 if request_generation.get() != request_id {
190 return;
191 }
192 loading.set(true);
193 error.set(None);
194 let result =
195 list_assets(AssetFilter::All, q, AssetSort::CreatedDesc, requested_page)
196 .await;
197 if request_generation.get() != request_id {
198 return;
199 }
200 match result {
201 Ok(resp) => {
202 let last_page = ((resp.total + ASSETS_PER_PAGE as i64 - 1)
203 / ASSETS_PER_PAGE as i64)
204 .max(1) as i32;
205 if resp.assets.is_empty() && requested_page > last_page {
206 page.set(last_page);
207 return;
208 }
209 assets.set(resp.assets);
210 total.set(resp.total);
211 error.set(None);
212 }
213 Err(e) => error.set(Some(e.to_string())),
214 }
215 if request_generation.get() == request_id {
216 loading.set(false);
217 }
218 });
219 }
220 }
221 });
222
223 let current_page = page();
224 let total_assets = total();
225 let loading_now = loading();
226 let show_pagination = total_assets > ASSETS_PER_PAGE as i64;
227 let mut gallery_items: Vec<PickerAsset> = assets.read().iter().map(PickerAsset::from).collect();
228 if let Some(url) = uploaded_url() {
229 prepend_uploaded_asset(&mut gallery_items, url);
230 }
231
232 rsx! {
233 ModalShell {
234 visible,
235 closing,
236 title,
237 overlay_padding: "p-4 sm:p-6",
238 panel_class: "w-full max-w-3xl min-h-0",
239
240 div { class: "flex flex-wrap items-center gap-3 border-b border-[var(--color-paper-border)] p-6",
242 h2 { class: "shrink-0 text-lg font-bold text-[var(--color-paper-primary)]",
243 "{title}"
244 }
245 FormInput {
246 r#type: "search",
247 placeholder: "搜索文件名 / alt",
248 value: query(),
249 class: INPUT_INLINE_CLASS,
250 oninput: move |v: String| query.set(v),
251 }
252 label {
254 class: "inline-flex shrink-0 cursor-pointer items-center justify-center {BTN_PRIMARY}",
255 class: if cover_uploading() { "pointer-events-none cursor-wait opacity-60" } else { "" },
256 "上传新图"
257 input {
258 r#type: "file",
259 accept: "image/jpeg,image/png,image/gif,image/webp",
260 class: "hidden",
261 disabled: cover_uploading(),
262 onchange: move |evt| {
263 #[cfg(target_arch = "wasm32")]
264 {
265 use dioxus::web::WebFileExt;
266 if cover_uploading() {
267 return;
268 }
269 if let Some(file) = evt.files().into_iter().next() {
270 if let Some(web_file) = file.get_web_file() {
271 if let Err(msg) = crate::components::assets::upload_pool::validate_file(
275 &web_file.type_(),
276 web_file.size() as u64,
277 ) {
278 error.set(Some(msg));
279 } else {
280 let preview_url =
281 web_sys::Url::create_object_url_with_blob(&web_file).ok();
282 uploading_preview.set(preview_url.clone());
283 cover_uploading.set(true);
284 error.set(None);
285 spawn(async move {
286 let result = crate::bridges::tiptap::upload_image_file(web_file)
287 .await;
288 if let Some(preview_url) = preview_url.as_deref() {
289 let _ = web_sys::Url::revoke_object_url(preview_url);
290 }
291 match result {
292 Ok(url) => {
293 if multi {
296 toggle_selection(
297 &mut selected.write(),
298 AssetSelection {
299 url: url.clone(),
300 alt: None,
301 },
302 );
303 }
304 uploaded_url.set(Some(url));
305 uploading_preview.set(None);
306 cover_uploading.set(false);
307 }
308 Err(msg) => {
309 error.set(Some(msg));
310 uploading_preview.set(None);
311 cover_uploading.set(false);
312 }
313 }
314 });
315 }
316 }
317 }
318 }
319 },
320 }
321 }
322 button {
323 class: "{BTN_ICON} shrink-0 rounded-full",
324 aria_label: "关闭",
325 onclick: move |_| {
326 closing.set(true);
327 visible.set(false);
328 },
329 "×"
330 }
331 }
332
333 PickerGallery {
334 items: gallery_items.clone(),
335 selected,
336 multi,
337 loading: loading_now,
338 uploading: cover_uploading(),
339 error: error(),
340 uploading_preview: uploading_preview(),
341 on_pick: move |picks| {
342 on_select.call(picks);
343 closing.set(true);
344 visible.set(false);
345 },
346 }
347 if show_pagination {
348 {
349 let mut page_for_prev = page;
350 let loading_for_prev = loading;
351 let mut page_for_next = page;
352 let loading_for_next = loading;
353 let mut page_for_jump = page;
354 let loading_for_jump = loading;
355 rsx! {
356 div { class: "shrink-0 px-6 py-3 shadow-[inset_0_1px_0_var(--color-paper-border)]",
357 div { class: if loading_now { "pointer-events-none opacity-60" } else { "" },
358 Pagination::<Route> {
359 variant: "admin",
360 compact: true,
361 current_page,
362 total: total_assets,
363 per_page: ASSETS_PER_PAGE,
364 unit: "张",
365 on_prev: move |_| {
366 if !loading_for_prev() {
367 page_for_prev.with_mut(|p| *p = (*p - 1).max(1));
368 }
369 },
370 on_next: move |_| {
371 if !loading_for_next() {
372 page_for_next.with_mut(|p| *p += 1);
373 }
374 },
375 on_jump: move |next_page: i32| {
376 if !loading_for_jump() {
377 page_for_jump.set(next_page.max(1));
378 }
379 },
380 }
381 }
382 }
383 }
384 }
385 }
386
387 if multi {
388 PickerSelectionFooter { selected, on_pick: move |picks| {
389 on_select.call(picks);
390 closing.set(true);
391 visible.set(false);
392 } }
393 }
394
395 }
396 }
397}
398
399#[component]
401pub(crate) fn PickerGallery(
402 items: Vec<PickerAsset>,
403 mut selected: Signal<Vec<AssetSelection>>,
404 multi: bool,
405 loading: bool,
406 uploading: bool,
407 error: Option<String>,
408 uploading_preview: Option<String>,
409 on_pick: EventHandler<Vec<AssetSelection>>,
410) -> Element {
411 rsx! {
412 div { class: "relative min-h-0 flex-1 overflow-y-auto p-6",
413 if loading && !items.is_empty() {
414 div { 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]",
415 aria_live: "polite", aria_label: "正在加载素材",
416 span { 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",
417 span { class: "inline-flex h-4 w-4", dangerous_inner_html: SPINNER_SVG }
418 "加载中…"
419 }
420 }
421 }
422 if let Some(error) = error {
423 div { class: "rounded-2xl bg-red-500/10 px-4 py-3 text-center text-sm text-red-600 dark:text-red-400", "加载失败:{error}" }
424 } else if loading && items.is_empty() && !uploading {
425 div { class: "px-4 py-16 text-center text-sm text-[var(--color-paper-secondary)]", "加载中..." }
426 } else if items.is_empty() && !uploading {
427 div { class: "px-4 py-16 text-center text-sm text-[var(--color-paper-secondary)]", "素材库为空" }
428 } else {
429 div { class: "grid grid-cols-3 gap-4 sm:grid-cols-4",
430 if uploading {
431 div { class: "relative aspect-square overflow-hidden rounded-2xl border border-[var(--color-paper-accent)]/60 bg-[var(--color-paper-theme)] shadow-sm", aria_live: "polite", aria_label: "正在上传图片",
432 if let Some(preview_url) = uploading_preview {
433 img { class: "h-full w-full scale-110 object-cover blur-md opacity-60", src: "{preview_url}", alt: "正在上传" }
434 } else {
435 div { class: "absolute inset-0 animate-pulse bg-[var(--color-paper-code-bg)]" }
436 }
437 div { class: "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/30 backdrop-blur-[2px]",
438 span { class: "inline-flex h-5 w-5 text-white", dangerous_inner_html: SPINNER_SVG }
439 span { class: "text-xs font-medium text-white drop-shadow", "上传中" }
440 }
441 }
442 }
443 for item in items.iter() {
444 {
445 let selection = AssetSelection { url: item.url.clone(), alt: item.alt.clone() };
446 let order = if multi { selected.read().iter().position(|value| value.url == item.url) } else { None };
447 let item_class = if order.is_some() {
448 "border-2 border-[var(--color-paper-accent)] shadow-sm"
449 } else if multi {
450 "border-2 border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)]"
451 } else {
452 "border border-[var(--color-paper-border)] hover:border-[var(--color-paper-primary)]"
453 };
454 rsx! {
455 button {
456 key: "{item.url}",
457 class: "group relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-[var(--color-paper-theme)] transition-all hover:shadow-md {item_class}",
458 title: "{item.filename}",
459 aria_label: "{item.filename}",
460 onclick: move |_| {
461 if multi {
462 toggle_selection(&mut selected.write(), selection.clone());
463 } else {
464 on_pick.call(vec![selection.clone()]);
465 }
466 },
467 img { class: "h-full w-full object-cover", src: "{item.thumbnail}", alt: item.alt.clone().unwrap_or_else(|| item.filename.clone()), loading: "lazy" }
468 if item.fresh { 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", "刚上传" } }
469 if let Some(index) = order {
470 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", "{index + 1}" }
471 }
472 }
473 }
474 }
475 }
476 }
477 }
478 }
479 }
480}
481
482#[component]
483pub(crate) fn PickerSelectionFooter(
484 mut selected: Signal<Vec<AssetSelection>>,
485 on_pick: EventHandler<Vec<AssetSelection>>,
486) -> Element {
487 let count = selected.read().len();
488 rsx! {
489 div { class: "flex shrink-0 items-center justify-between gap-3 px-6 py-3 shadow-[inset_0_1px_0_var(--color-paper-border)]",
490 span { class: "text-sm text-[var(--color-paper-secondary)]", if count == 0 { "未选择图片" } else { "已选 {count} 张" } }
491 div { class: "flex items-center gap-2",
492 button { class: "{BTN_OUTLINE}", disabled: count == 0, onclick: move |_| selected.set(Vec::new()), "清空" }
493 button { class: "{BTN_PRIMARY}", disabled: count == 0, onclick: move |_| {
494 let picks = selected();
495 if !picks.is_empty() { on_pick.call(picks); }
496 }, "插入 {count} 张图片" }
497 }
498 }
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::{prepend_uploaded_asset, toggle_selection, AssetSelection, PickerAsset};
505
506 fn sel(url: &str) -> AssetSelection {
507 AssetSelection {
508 url: url.to_string(),
509 alt: None,
510 }
511 }
512
513 #[test]
514 fn toggle_appends_in_click_order() {
515 let mut selected = Vec::new();
516 toggle_selection(&mut selected, sel("/uploads/b.webp"));
517 toggle_selection(&mut selected, sel("/uploads/a.webp"));
518 toggle_selection(&mut selected, sel("/uploads/c.webp"));
519 let urls: Vec<&str> = selected.iter().map(|s| s.url.as_str()).collect();
520 assert_eq!(
521 urls,
522 ["/uploads/b.webp", "/uploads/a.webp", "/uploads/c.webp"]
523 );
524 }
525
526 #[test]
527 fn toggle_removes_existing_by_url() {
528 let mut selected = vec![sel("/uploads/a.webp"), sel("/uploads/b.webp")];
529 toggle_selection(&mut selected, sel("/uploads/a.webp"));
530 assert_eq!(selected, vec![sel("/uploads/b.webp")]);
531 }
532
533 #[test]
534 fn toggle_dedupes_same_url_regardless_of_alt() {
535 let mut selected = vec![sel("/uploads/a.webp")];
538 toggle_selection(
539 &mut selected,
540 AssetSelection {
541 url: "/uploads/a.webp".to_string(),
542 alt: Some("封面".to_string()),
543 },
544 );
545 assert!(selected.is_empty());
546 }
547
548 #[test]
549 fn uploaded_asset_does_not_duplicate_a_loaded_grid_url() {
550 let mut items = vec![PickerAsset {
551 url: "/uploads/a.webp".to_string(),
552 thumbnail: "/uploads/a.webp?thumb=300x300".to_string(),
553 filename: "a.webp".to_string(),
554 alt: Some("封面".to_string()),
555 fresh: false,
556 }];
557 prepend_uploaded_asset(&mut items, "/uploads/a.webp".to_string());
558 assert_eq!(items.len(), 1);
559 prepend_uploaded_asset(&mut items, "/uploads/b.webp".to_string());
560 assert_eq!(
561 items
562 .iter()
563 .map(|item| item.url.as_str())
564 .collect::<Vec<_>>(),
565 ["/uploads/b.webp", "/uploads/a.webp"]
566 );
567 }
568
569 #[test]
570 fn selection_serializes_to_editor_bridge_shape() {
571 let picks = vec![
574 AssetSelection {
575 url: "/uploads/a.webp".to_string(),
576 alt: Some("封面".to_string()),
577 },
578 sel("/uploads/b.webp"),
579 ];
580 let json = serde_json::to_string(&picks).expect("AssetSelection 必然可序列化");
581 assert_eq!(
582 json,
583 r#"[{"src":"/uploads/a.webp","alt":"封面"},{"src":"/uploads/b.webp"}]"#
584 );
585 }
586}