Skip to main content

yggdrasil/components/assets/
asset_upload.rs

1//! 素材上传 modal(素材管理页内上传)。
2//!
3//! UI 外壳(遮罩/面板/关闭动效经 [`crate::components::ui::ModalShell`],逐文件状态列表
4//! 在此渲染);上传状态机、worker 池并发调度与校验规则见
5//! [`crate::components::assets::upload_pool`]。
6//!
7//! Esc 关闭由 `ModalShell` 负责(显示期间监听 keydown);粘贴监听挂在 window 上,
8//! 只在 mount 注册一次、`on_cleanup` 移除,handler 内用 `get_untracked` 守卫——
9//! modal 关闭后粘贴绝不触发上传。无文件的文本粘贴不拦截(不 prevent_default),
10//! 搜索框等正常粘贴不受影响。
11//!
12//! 组件始终挂载(`visible` 只控制渲染早退),上传中途允许关闭弹窗:worker 与
13//! signals 随组件实例存活,后台续传,每批(一次拖拽/选择/粘贴)收尾时 ≥1 个成功仍照常
14//! 回调 `on_uploaded`;组件卸载时 worker 池释放文件句柄并停止。
15
16use 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/// 把 `FileList` 展开成 `Vec<File>`(拖拽 / 选择 / 粘贴三个入口共用)。
30#[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/// 素材上传 modal。
39///
40/// - `visible`:显隐控制(父组件持有;× / Esc / 点遮罩都会置 false)。
41/// - `on_uploaded`:一批上传完成且 ≥1 个成功时回调一次(父组件刷新网格)。
42#[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    // 关闭动画状态:visible 翻 false 时先置 closing 播退出动画,EXIT_ANIM_MS 后再卸载内容。
51    let closing = RwSignal::new(false);
52    // worker 池共享状态(WASM-only):持 Rc,含 `web_sys::File`,不是 Send + Sync,放本地存储。
53    #[cfg(target_arch = "wasm32")]
54    let pool = StoredValue::new_local(std::rc::Rc::new(UploadPool::new()));
55    // 并发配置:默认值先行,挂载后经 admin server fn 拉服务端值覆盖(面板改了
56    // 无需刷新页面——下次打开本站页面即生效;进行中的 worker live 读取停顿)。
57    let concurrency = RwSignal::new(crate::models::settings::DEFAULT_UPLOAD_CONCURRENCY);
58
59    // 挂载即读服务端并发配置;失败静默回退默认值,不阻塞上传。
60    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    // window 粘贴监听:组件始终挂载,故只在 mount 注册一次,on_cleanup 移除;
69    // handler 内 get_untracked 守卫——modal 关闭后粘贴不响应。
70    #[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                // 文本粘贴(无文件)不拦截:不 prevent_default,搜索框正常粘贴。
86                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    // 卸载:释放文件句柄、清空队列并让在跑的 worker 退出。
101    #[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            // 头部(照抄 AssetPickerModal 头部类)。
150            <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:点击天然触发隐藏 file input(同 CoverUploader),无需 JS。
165                // 内部内容包一层 pointer-events-none,防子元素进出触发 dragleave 高亮闪烁。
166                <label
167                    class="asset-drop"
168                    data-drag=move || drag_active.get().then_some("true")
169                    // dragover 必须 prevent_default,否则浏览器直接打开文件。
170                    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/// 底栏:完成、上传中与失败张数,「完成」关闭弹窗。
240#[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
276/// 「1 张完成 · 1 张上传中 · 1 张失败」;没有文件时给出操作提示。
277fn 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/// 上传面板主体:`children`(拖放区)在上,逐文件状态列表在下。
296///
297/// 图鉴预览与 [`AssetUploadModal`] 共用:列表数据由调用方以信号提供,重试 / 移除经回调上抛。
298#[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/// 拖放区的内容(图标 + 标题 + 提示),文案可由图鉴预览覆盖。
313#[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/// 逐文件状态列表;空列表不渲染 `<ul>`。
328///
329/// 行元素以 id 为键保持稳定(入场动画只在插入时播放),状态 / 退出标记经 `items`
330/// 按 id 回查后原地更新属性。
331#[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/// 单行:名称 / 大小固定,状态随 `items` 中同 id 条目更新。
364#[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    // 行被移除后 `For` 会随后销毁本组件;期间回查不到条目时沿用上一次的值。
374    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}