yggdrasil/components/assets/
asset_upload.rs1use leptos::prelude::*;
17
18use crate::components::ui::{
19 button_class, ButtonSize, ButtonVariant, Icon, IconButton, IconName, ModalShell, EXIT_ANIM_MS,
20};
21
22#[cfg(target_arch = "wasm32")]
23use super::upload_pool::{enqueue_files, set_status, UploadPool};
24pub(crate) use super::upload_pool::{UploadItem, UploadStatus};
25
26#[cfg(target_arch = "wasm32")]
27use crate::bridges::tiptap::upload_image_file;
28
29#[cfg(target_arch = "wasm32")]
31fn collect_files(list: Option<web_sys::FileList>) -> Vec<web_sys::File> {
32 let Some(list) = list else {
33 return Vec::new();
34 };
35 (0..list.length()).filter_map(|i| list.item(i)).collect()
36}
37
38#[component]
43#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
44pub fn AssetUploadModal(
45 visible: RwSignal<bool>,
46 #[prop(into)] on_uploaded: Callback<()>,
47) -> impl IntoView {
48 let items = RwSignal::new(Vec::<UploadItem>::new());
49 let drag_active = RwSignal::new(false);
50 let closing = RwSignal::new(false);
52 #[cfg(target_arch = "wasm32")]
54 let pool = StoredValue::new_local(std::rc::Rc::new(UploadPool::new()));
55 let concurrency = RwSignal::new(crate::models::settings::DEFAULT_UPLOAD_CONCURRENCY);
58
59 Effect::new(move |_| {
61 leptos::task::spawn_local(async move {
62 if let Ok(s) = crate::api::settings::get_upload_settings().await {
63 let _ = concurrency.try_set(s.concurrency);
64 }
65 });
66 });
67
68 #[cfg(target_arch = "wasm32")]
71 {
72 let paste_handle = StoredValue::new(None::<WindowListenerHandle>);
73 Effect::new(move |_| {
74 if paste_handle.with_value(Option::is_some) {
75 return;
76 }
77 let handle = window_event_listener(leptos::ev::paste, move |ev| {
78 if !visible.try_get_untracked().unwrap_or(false) {
79 return;
80 }
81 let Some(dt) = ev.clipboard_data() else {
82 return;
83 };
84 let collected = collect_files(dt.files());
85 if collected.is_empty() {
87 return;
88 }
89 ev.prevent_default();
90 enqueue_files(items, pool.get_value(), concurrency, on_uploaded, collected);
91 });
92 paste_handle.set_value(Some(handle));
93 });
94 on_cleanup(move || {
95 if let Some(Some(handle)) = paste_handle.try_update_value(Option::take) {
96 handle.remove();
97 }
98 });
99 }
100 #[cfg(target_arch = "wasm32")]
102 on_cleanup(move || {
103 if let Some(pool) = pool.try_get_value() {
104 pool.dispose();
105 }
106 });
107
108 let on_retry = move |item_id: u64| {
109 #[cfg(target_arch = "wasm32")]
110 if let Some(file) = pool.get_value().find_file(item_id) {
111 set_status(items, item_id, UploadStatus::Uploading);
112 leptos::task::spawn_local(async move {
113 match upload_image_file(file).await {
114 Ok(_) => {
115 if set_status(items, item_id, UploadStatus::Done) {
116 on_uploaded.run(());
117 }
118 }
119 Err(msg) => {
120 set_status(items, item_id, UploadStatus::Failed(msg));
121 }
122 }
123 });
124 }
125 #[cfg(not(target_arch = "wasm32"))]
126 let _ = item_id;
127 };
128 let on_remove = move |item_id: u64| {
129 items.update(|list| {
130 if let Some(item) = list.iter_mut().find(|item| item.id == item_id) {
131 item.removing = true;
132 }
133 });
134 #[cfg(target_arch = "wasm32")]
135 pool.get_value().remove_file(item_id);
136 leptos::task::spawn_local(async move {
137 crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
138 let _ = items.try_update(|list| list.retain(|item| item.id != item_id));
139 });
140 };
141
142 view! {
143 <ModalShell
144 visible=visible
145 closing=closing
146 title="上传素材"
147 panel_class="asset-modal asset-uploader"
148 >
149 <div class="asset-modal-head">
151 <h2>"上传素材"</h2>
152 <IconButton
153 icon=IconName::X
154 label="关闭"
155 size=ButtonSize::Sm
156 onclick=move |_| {
157 closing.set(true);
158 visible.set(false);
159 }
160 />
161 </div>
162
163 <UploadPanel items=items on_retry=on_retry on_remove=on_remove>
164 <label
167 class="asset-drop"
168 data-drag=move || drag_active.get().then_some("true")
169 on:dragover=move |evt| {
171 evt.prevent_default();
172 drag_active.set(true);
173 }
174 on:dragenter=move |evt| {
175 evt.prevent_default();
176 }
177 on:dragleave=move |_| {
178 drag_active.set(false);
179 }
180 on:drop=move |evt| {
181 evt.prevent_default();
182 drag_active.set(false);
183 #[cfg(target_arch = "wasm32")]
184 {
185 let collected = collect_files(
186 evt.data_transfer().and_then(|dt| dt.files()),
187 );
188 if !collected.is_empty() {
189 enqueue_files(
190 items,
191 pool.get_value(),
192 concurrency,
193 on_uploaded,
194 collected,
195 );
196 }
197 }
198 }
199 >
200 <UploadDropZoneContent/>
201 <input
202 type="file"
203 accept="image/jpeg,image/png,image/gif,image/webp"
204 multiple=true
205 class="sr-only"
206 on:change=move |evt| {
207 #[cfg(target_arch = "wasm32")]
208 {
209 let collected = collect_files(
210 event_target::<web_sys::HtmlInputElement>(&evt).files(),
211 );
212 if !collected.is_empty() {
213 enqueue_files(
214 items,
215 pool.get_value(),
216 concurrency,
217 on_uploaded,
218 collected,
219 );
220 }
221 }
222 #[cfg(not(target_arch = "wasm32"))]
223 let _ = evt;
224 }
225 />
226 </label>
227 </UploadPanel>
228 <UploadFooter
229 items=items
230 on_done=move |_| {
231 closing.set(true);
232 visible.set(false);
233 }
234 />
235 </ModalShell>
236 }
237}
238
239#[component]
241fn UploadFooter(
242 #[prop(into)] items: Signal<Vec<UploadItem>>,
243 #[prop(into)] on_done: Callback<()>,
244) -> impl IntoView {
245 let summary = move || {
246 items.with(|list| {
247 let done = list
248 .iter()
249 .filter(|i| i.status == UploadStatus::Done)
250 .count();
251 let busy = list
252 .iter()
253 .filter(|i| matches!(i.status, UploadStatus::Queued | UploadStatus::Uploading))
254 .count();
255 let failed = list
256 .iter()
257 .filter(|i| matches!(i.status, UploadStatus::Failed(_)))
258 .count();
259 upload_summary(done, busy, failed)
260 })
261 };
262 view! {
263 <div class="asset-modal-foot">
264 <span role="status">{summary}</span>
265 <button
266 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
267 type="button"
268 on:click=move |_| on_done.run(())
269 >
270 "完成"
271 </button>
272 </div>
273 }
274}
275
276fn upload_summary(done: usize, busy: usize, failed: usize) -> String {
278 let mut parts = Vec::new();
279 if done > 0 {
280 parts.push(format!("{done} 张完成"));
281 }
282 if busy > 0 {
283 parts.push(format!("{busy} 张上传中"));
284 }
285 if failed > 0 {
286 parts.push(format!("{failed} 张失败"));
287 }
288 if parts.is_empty() {
289 "可以一次选择多张,也可以直接粘贴".to_string()
290 } else {
291 parts.join(" · ")
292 }
293}
294
295#[component]
299pub(crate) fn UploadPanel(
300 #[prop(into)] items: Signal<Vec<UploadItem>>,
301 #[prop(into)] on_retry: Callback<u64>,
302 #[prop(into)] on_remove: Callback<u64>,
303 children: Children,
304) -> impl IntoView {
305 view! {
306 <div class="asset-modal-body asset-upload-body">
307 {children()} <UploadRows items=items on_retry=on_retry on_remove=on_remove/>
308 </div>
309 }
310}
311
312#[component]
314pub(crate) fn UploadDropZoneContent(
315 #[prop(default = "拖拽图片到这里,或点击选择")] title: &'static str,
316 #[prop(default = "也可以粘贴 · JPEG / PNG / GIF / WebP · 单张不超过 5MB")] hint: &'static str,
317) -> impl IntoView {
318 view! {
319 <span class="asset-drop-content">
320 <Icon name=IconName::Upload size=22/>
321 <span class="asset-drop-title">{title}</span>
322 <span class="asset-drop-hint">{hint}</span>
323 </span>
324 }
325}
326
327#[component]
332pub(crate) fn UploadRows(
333 #[prop(into)] items: Signal<Vec<UploadItem>>,
334 #[prop(into)] on_retry: Callback<u64>,
335 #[prop(into)] on_remove: Callback<u64>,
336) -> impl IntoView {
337 move || {
338 (!items.with(Vec::is_empty)).then(|| {
339 view! {
340 <ul class="asset-upload-rows">
341 <For
342 each=move || items.get()
343 key=|item| item.id
344 children=move |item| {
345 view! {
346 <UploadRow
347 id=item.id
348 name=item.name
349 size=item.size
350 items=items
351 on_retry=on_retry
352 on_remove=on_remove
353 />
354 }
355 }
356 />
357 </ul>
358 }
359 })
360 }
361}
362
363#[component]
365fn UploadRow(
366 id: u64,
367 name: String,
368 size: String,
369 items: Signal<Vec<UploadItem>>,
370 on_retry: Callback<u64>,
371 on_remove: Callback<u64>,
372) -> impl IntoView {
373 let current = Memo::new(move |prev: Option<&UploadItem>| {
375 items
376 .with(|list| list.iter().find(|item| item.id == id).cloned())
377 .or_else(|| prev.cloned())
378 .expect("行组件创建时条目必然存在")
379 });
380 let status = move || current.get().status;
381 let state = move || match status() {
382 UploadStatus::Queued => "queued",
383 UploadStatus::Uploading => "uploading",
384 UploadStatus::Done => "done",
385 UploadStatus::Failed(_) => "failed",
386 };
387 let detail = {
388 let size = size.clone();
389 move || match status() {
390 UploadStatus::Uploading => view! {
391 <span class="asset-upload-bar" aria-hidden="true">
392 <span></span>
393 </span>
394 }
395 .into_any(),
396 UploadStatus::Failed(message) => {
397 let title_attr = message.clone();
398 view! {
399 <span class="asset-upload-error" title=title_attr>
400 {message}
401 </span>
402 }
403 .into_any()
404 }
405
406 _ => view! { <span class="asset-upload-size">{size.clone()}</span> }.into_any(),
407 }
408 };
409 let trailing = move || match status() {
410 UploadStatus::Queued => view! { "等待中" }.into_any(),
411 UploadStatus::Uploading => view! { "上传中" }.into_any(),
412 UploadStatus::Done => view! {
413 <Icon name=IconName::Check size=13/>
414 "已上传"
415 }
416 .into_any(),
417 UploadStatus::Failed(_) => view! {
418 <button
419 class=button_class(ButtonVariant::Ghost, ButtonSize::Xs)
420 type="button"
421 on:click=move |_| on_retry.run(id)
422 >
423 "重试"
424 </button>
425 <IconButton
426 icon=IconName::X
427 label="移除"
428 size=ButtonSize::Xs
429 onclick=move |_| on_remove.run(id)
430 />
431 }
432 .into_any(),
433 };
434 let title_attr = name.clone();
435 view! {
436 <li
437 class="asset-upload-row"
438 data-state=state
439 data-removing=move || current.get().removing.then_some("true")
440 >
441 <span class="asset-upload-thumb" aria-hidden="true"></span>
442 <span class="asset-upload-text">
443 <span class="asset-upload-name" title=title_attr>
444 {name}
445 </span>
446 {detail}
447 </span>
448 <span class="asset-upload-status">{trailing}</span>
449 </li>
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
458 fn upload_summary_lists_only_nonzero_states() {
459 assert_eq!(upload_summary(1, 1, 1), "1 张完成 · 1 张上传中 · 1 张失败");
460 assert_eq!(upload_summary(3, 0, 0), "3 张完成");
461 assert_eq!(upload_summary(0, 0, 0), "可以一次选择多张,也可以直接粘贴");
462 }
463
464 fn item(id: u64, status: UploadStatus, removing: bool) -> UploadItem {
465 UploadItem {
466 id,
467 name: format!("图-{id}.png"),
468 size: "1.0 MB".to_string(),
469 status,
470 removing,
471 }
472 }
473
474 #[test]
475 fn rows_render_state_markers_and_actions() {
476 let html = Owner::new().with(|| {
477 let items = RwSignal::new(vec![
478 item(1, UploadStatus::Done, false),
479 item(2, UploadStatus::Uploading, false),
480 item(3, UploadStatus::Failed("大小超过 5MB 限制".into()), true),
481 ]);
482 view! {
483 <UploadRows
484 items=items
485 on_retry=Callback::new(|_| {})
486 on_remove=Callback::new(|_| {})
487 />
488 }
489 .to_html()
490 });
491 assert!(html.contains("data-state=\"done\""), "{html}");
492 assert!(html.contains("data-state=\"uploading\""), "{html}");
493 assert!(html.contains("data-state=\"failed\""), "{html}");
494 assert_eq!(html.matches("data-removing=\"true\"").count(), 1, "{html}");
495 assert!(html.contains("class=\"asset-upload-bar\""), "{html}");
496 assert!(html.contains("重试"), "{html}");
497 assert!(html.contains("已上传"), "{html}");
498 }
499
500 #[test]
501 fn empty_rows_render_no_list() {
502 let html = Owner::new().with(|| {
503 let items = RwSignal::new(Vec::<UploadItem>::new());
504 view! {
505 <UploadRows
506 items=items
507 on_retry=Callback::new(|_| {})
508 on_remove=Callback::new(|_| {})
509 />
510 }
511 .to_html()
512 });
513 assert!(!html.contains("asset-upload-rows"), "{html}");
514 }
515}