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 / 粘贴监听挂在 window 上,只在 mount 注册一次、`use_drop` 移除;handler 内用
8//! `visible.peek()` 守卫——modal 关闭后粘贴绝不触发上传。无文件的文本粘贴不拦截
9//! (不 prevent_default),搜索框等正常粘贴不受影响。原生监听器触发时 Dioxus 的
10//! scope 栈为空(`spawn` 会因 `current_scope_id` unwrap 空栈而 panic),渲染期捕获的
11//! 组件 scope id 在粘贴 handler 里经 `Runtime::in_scope` 显式进栈后再入队,使粘贴路径
12//! 与 Dioxus 事件入口(拖拽/选择)的 spawn 语义完全一致。
13//!
14//! 组件始终挂载(`visible` 只控制渲染早退),上传中途允许关闭弹窗:spawn 的 worker 与
15//! signals 随组件实例存活,后台续传,每批(一次拖拽/选择/粘贴)收尾时 ≥1 个成功仍照常
16//! 回调 `on_uploaded`。
17
18use dioxus::prelude::*;
19
20use crate::components::ui::{ModalShell, BTN_ICON, EXIT_ANIM_MS, SPINNER_SVG};
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// 从 Dioxus 事件拿底层 web_sys::File(write.rs L30-34 同款 cfg 门控惯例):
29// - HasFileData:evt.files()(FormEvent / DragEvent 取文件)
30// - WebFileExt:file.get_web_file()(FileData 取底层 web_sys::File)
31#[cfg(target_arch = "wasm32")]
32use dioxus::html::HasFileData;
33#[cfg(target_arch = "wasm32")]
34use dioxus::web::WebFileExt;
35
36/// 素材上传 modal。
37///
38/// - `visible`:显隐控制(父组件持有;× / Esc / 点遮罩都会置 false)。
39/// - `on_uploaded`:一批上传完成且 ≥1 个成功时回调一次(父组件刷新网格)。
40#[component]
41#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
42pub fn AssetUploadModal(mut visible: Signal<bool>, on_uploaded: EventHandler<()>) -> Element {
43    let mut items: Signal<Vec<UploadItem>> = use_signal(Vec::new);
44    let mut drag_active = use_signal(|| false);
45    // 关闭动画状态:visible 翻 false 时先置 closing 播退出动画,EXIT_ANIM_MS 后再卸载内容。
46    let mut closing = use_signal(|| false);
47    // worker 池共享状态(WASM-only):use_hook 持 Rc,跨渲染共享同一实例。
48    #[cfg(target_arch = "wasm32")]
49    let pool = use_hook(|| std::rc::Rc::new(UploadPool::new()));
50    // 并发配置:默认值先行,挂载后经 admin server fn 拉服务端值覆盖(面板改了
51    // 无需刷新页面——下次打开本站页面即生效;进行中的 worker live 读取停顿)。
52    let mut concurrency: Signal<i32> =
53        use_signal(|| crate::models::settings::DEFAULT_UPLOAD_CONCURRENCY);
54
55    // 挂载即读服务端并发配置;失败静默回退默认值,不阻塞上传。
56    #[cfg(target_arch = "wasm32")]
57    {
58        use_effect(move || {
59            spawn(async move {
60                if let Ok(s) = crate::api::settings::get_upload_settings().await {
61                    concurrency.set(s.concurrency);
62                }
63            });
64        });
65    }
66
67    // window 全局监听(Esc 关闭 + 粘贴上传):组件始终挂载,故只在 mount 注册一次,
68    // use_drop 移除;handler 内 peek 守卫——modal 关闭后 Esc/粘贴都不响应。
69    // 骨架照抄 ui.rs Popover(use_hook 持 Closure + use_effect 注册 + use_drop 移除)。
70    #[cfg(target_arch = "wasm32")]
71    {
72        use std::cell::RefCell;
73        use std::rc::Rc;
74        type KeyClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>;
75        type PasteClosure = wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::ClipboardEvent)>;
76        type Listeners = Rc<RefCell<Option<(KeyClosure, PasteClosure)>>>;
77        let listeners: Listeners = use_hook(|| Rc::new(RefCell::new(None)));
78        let listeners_for_drop = listeners.clone();
79        // 组件 scope id(渲染期捕获):原生 window 监听器触发时 Dioxus 的 scope 栈
80        // 为空,`spawn` 会因 current_scope_id unwrap 空栈而 panic(与 sql_console
81        // 的 Ctrl+Enter 同源),监听器里用 Runtime::in_scope 重建 scope 上下文。
82        let scope_id = dioxus::core::Runtime::current()
83            .try_current_scope_id()
84            .unwrap_or(dioxus::core::ScopeId::ROOT);
85        // effect 闭包 move 捕获的是这份克隆,原值留给下方 drop/change 事件闭包。
86        let pool_for_paste = pool.clone();
87        use_effect(move || {
88            let Some(window) = web_sys::window() else {
89                return;
90            };
91            let on_keydown =
92                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
93                    if !*visible.peek() {
94                        return;
95                    }
96                    if ev.key() == "Escape" {
97                        closing.set(true);
98                        visible.set(false);
99                    }
100                })
101                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
102            // 内层 paste 闭包再持一份克隆(FnMut effect 的捕获不可被 move 出)。
103            let pool_in_paste = pool_for_paste.clone();
104            let on_paste =
105                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::ClipboardEvent| {
106                    if !*visible.peek() {
107                        return;
108                    }
109                    let Some(dt) = ev.clipboard_data() else {
110                        return;
111                    };
112                    let Some(list) = dt.files() else {
113                        return;
114                    };
115                    // 文本粘贴(无文件)不拦截:不 prevent_default,搜索框正常粘贴。
116                    if list.length() == 0 {
117                        return;
118                    }
119                    ev.prevent_default();
120                    let collected: Vec<web_sys::File> =
121                        (0..list.length()).filter_map(|i| list.item(i)).collect();
122                    if !collected.is_empty() {
123                        // 原生监听里无 scope 上下文:进入组件 scope 使 enqueue 内的
124                        // spawn 与 Dioxus 事件入口(拖拽/选择)行为完全一致。
125                        // Rc 句柄逐事件克隆进内层闭包(外层 FnMut 的捕获不可 move 出,
126                        // 同 sql_console 的 on_run_shortcut 闭包包装考虑)。
127                        let pool_call = pool_in_paste.clone();
128                        dioxus::core::Runtime::current().in_scope(scope_id, move || {
129                            enqueue_files(items, pool_call, concurrency, on_uploaded, collected);
130                        });
131                    }
132                })
133                    as Box<dyn FnMut(web_sys::ClipboardEvent)>);
134            use wasm_bindgen::JsCast;
135            let _ = window
136                .add_event_listener_with_callback("keydown", on_keydown.as_ref().unchecked_ref());
137            let _ =
138                window.add_event_listener_with_callback("paste", on_paste.as_ref().unchecked_ref());
139            *listeners.borrow_mut() = Some((on_keydown, on_paste));
140        });
141        use_drop(move || {
142            if let Some((on_keydown, on_paste)) = listeners_for_drop.borrow_mut().take() {
143                if let Some(window) = web_sys::window() {
144                    use wasm_bindgen::JsCast;
145                    let _ = window.remove_event_listener_with_callback(
146                        "keydown",
147                        on_keydown.as_ref().unchecked_ref(),
148                    );
149                    let _ = window.remove_event_listener_with_callback(
150                        "paste",
151                        on_paste.as_ref().unchecked_ref(),
152                    );
153                }
154            }
155        });
156    }
157
158    // 每个 move 事件闭包独占一份 Rc 克隆(move 闭包按整个变量捕获)。
159    #[cfg(target_arch = "wasm32")]
160    let (pool_for_drop, pool_for_change) = (pool.clone(), pool.clone());
161    // 行列表内层 for 闭包用:状态区 keyed 包装引入的嵌套闭包无法 move 外层闭包捕获的
162    // Rc(FnMut 链,E0507),故在组件体预克隆一份,供内层闭包从组件体直接捕获。
163    #[cfg(target_arch = "wasm32")]
164    let (pool_for_rows, pool_for_remove) = (pool.clone(), pool.clone());
165
166    // 快照当前列表(小 Vec,clone 成本可忽略;status 变化驱动重渲染)。
167    let items_snapshot = items.read().clone();
168
169    rsx! {
170        ModalShell {
171            visible,
172            closing,
173            title: "上传素材",
174
175                // 头部(照抄 AssetPickerModal 头部类)。
176                div { class: "flex items-center gap-3 px-6 py-4 border-b border-[var(--color-paper-border)]",
177                    h2 { class: "text-lg font-bold text-[var(--color-paper-primary)]",
178                        "上传素材"
179                    }
180                    div { class: "flex-1" }
181                    button {
182                        class: "{BTN_ICON} shrink-0 rounded-full",
183                        aria_label: "关闭",
184                        onclick: move |_| {
185                            closing.set(true);
186                            visible.set(false);
187                        },
188                        "×"
189                    }
190                }
191
192                // 主体:拖放区 + 逐文件状态列表。
193                    UploadPanel {
194                        items: items_snapshot,
195                        on_retry: move |item_id: u64| {
196                            #[cfg(target_arch = "wasm32")]
197                            if let Some(file) = pool_for_rows.find_file(item_id) {
198                                set_status(&mut items, item_id, UploadStatus::Uploading);
199                                spawn(async move {
200                                    match upload_image_file(file).await {
201                                        Ok(_) => {
202                                            set_status(&mut items, item_id, UploadStatus::Done);
203                                            on_uploaded.call(());
204                                        }
205                                        Err(msg) => set_status(&mut items, item_id, UploadStatus::Failed(msg)),
206                                    }
207                                });
208                            }
209                        },
210                        on_remove: move |item_id: u64| {
211                            if let Some(item) = items.write().iter_mut().find(|item| item.id == item_id) {
212                                item.removing = true;
213                            }
214                            #[cfg(target_arch = "wasm32")]
215                            pool_for_remove.remove_file(item_id);
216                            spawn(async move {
217                                crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
218                                items.write().retain(|item| item.id != item_id);
219                            });
220                        },
221
222                    // 拖放区 = label:点击天然触发隐藏 file input(同 CoverUploader),无需 JS。
223                    // 内部内容包一层 pointer-events-none,防子元素进出触发 dragleave 高亮闪烁。
224                    label {
225                        class: "flex flex-col items-center justify-center gap-2 px-6 py-10 border border-dashed rounded-2xl cursor-pointer transition-colors",
226                        class: if drag_active() { "border-[var(--color-paper-primary)] bg-[var(--color-paper-theme)]" } else { "border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] hover:border-[var(--color-paper-primary)]" },
227                        // ondragover 必须 prevent_default,否则浏览器直接打开文件。
228                        ondragover: move |evt| {
229                            evt.prevent_default();
230                            drag_active.set(true);
231                        },
232                        ondragenter: move |evt| {
233                            evt.prevent_default();
234                        },
235                        ondragleave: move |_| {
236                            drag_active.set(false);
237                        },
238                        ondrop: move |evt| {
239                            evt.prevent_default();
240                            drag_active.set(false);
241                            #[cfg(target_arch = "wasm32")]
242                            {
243                                let collected: Vec<web_sys::File> = evt
244                                    .files()
245                                    .into_iter()
246                                    .filter_map(|f| f.get_web_file())
247                                    .collect();
248                                if !collected.is_empty() {
249                                    enqueue_files(
250                                        items,
251                                        pool_for_drop.clone(),
252                                        concurrency,
253                                        on_uploaded,
254                                        collected,
255                                    );
256                                }
257                            }
258                        },
259                        UploadDropZoneContent {}
260                        input {
261                            r#type: "file",
262                            accept: "image/jpeg,image/png,image/gif,image/webp",
263                            multiple: true,
264                            class: "hidden",
265                            onchange: move |evt| {
266                                #[cfg(target_arch = "wasm32")]
267                                {
268                                    let collected: Vec<web_sys::File> = evt
269                                        .files()
270                                        .into_iter()
271                                        .filter_map(|f| f.get_web_file())
272                                        .collect();
273                                    if !collected.is_empty() {
274                                        enqueue_files(
275                                            items,
276                                            pool_for_change.clone(),
277                                            concurrency,
278                                            on_uploaded,
279                                            collected,
280                                        );
281                                    }
282                                }
283                            },
284                        }
285                    }
286
287
288
289                }
290        }
291    }
292}
293
294/// Visual panel shared by the upload controller and local demonstration.
295#[component]
296pub(crate) fn UploadPanel(
297    items: Vec<UploadItem>,
298    on_retry: EventHandler<u64>,
299    on_remove: EventHandler<u64>,
300    children: Element,
301) -> Element {
302    rsx! {
303        div { class: "flex-1 overflow-y-auto p-6 flex flex-col gap-4",
304            {children}
305            UploadRows { items, on_retry, on_remove }
306        }
307    }
308}
309
310/// Shared drop-zone artwork; the caller owns its file or local-sample action.
311#[component]
312pub(crate) fn UploadDropZoneContent(
313    #[props(default = "拖拽图片到这里,或点击选择")] title: &'static str,
314    #[props(default = "支持 Ctrl/⌘+V 粘贴 · JPEG / PNG / GIF / WebP · 单张 ≤ 5MB")]
315    hint: &'static str,
316) -> Element {
317    rsx! {
318        div { class: "pointer-events-none flex flex-col items-center gap-2",
319            svg { class: "w-8 h-8 text-[var(--color-paper-tertiary)]", xmlns: "http://www.w3.org/2000/svg",
320                view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "1.8",
321                stroke_linecap: "round", stroke_linejoin: "round",
322                path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
323                polyline { points: "17 8 12 3 7 8" }
324                line { x1: "12", y1: "3", x2: "12", y2: "15" }
325            }
326            p { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
327            p { class: "text-xs text-[var(--color-paper-tertiary)]", "{hint}" }
328        }
329    }
330}
331
332/// Shared file status list; production and showcase provide their own actions.
333#[component]
334pub(crate) fn UploadRows(
335    items: Vec<UploadItem>,
336    on_retry: EventHandler<u64>,
337    on_remove: EventHandler<u64>,
338) -> Element {
339    rsx! {
340        div { class: "flex flex-col gap-2",
341            for (index, item) in items.iter().enumerate() {
342                {
343                    let id = item.id;
344                    let status_key = match item.status {
345                        UploadStatus::Queued => "q",
346                        UploadStatus::Uploading => "u",
347                        UploadStatus::Done => "d",
348                        UploadStatus::Failed(_) => "f",
349                    };
350                    rsx! {
351                        div { key: "{id}",
352                            class: "flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] px-4 py-3",
353                            class: if item.removing { "animate-row-leave" } else { "animate-row-enter" },
354                            style: if item.removing { String::new() } else { format!("animation-delay:{}ms", index * 30) },
355                            div { class: "flex-1 min-w-0",
356                                p { class: "text-sm truncate text-[var(--color-paper-primary)]", title: "{item.name}", "{item.name}" }
357                                p { class: "text-xs font-mono text-[var(--color-paper-tertiary)]", "{item.size}" }
358                            }
359                            for status_key in std::iter::once(status_key) {
360                                div { key: "{status_key}", class: "flex items-center gap-2 shrink-0 animate-status-pop",
361                                    match &item.status {
362                                        UploadStatus::Queued => rsx! { span { class: "text-xs text-[var(--color-paper-tertiary)] shrink-0", "等待中" } },
363                                        UploadStatus::Uploading => rsx! {
364                                            span { class: "flex items-center gap-1.5 text-xs text-[var(--color-paper-secondary)] shrink-0",
365                                                span { class: "inline-block w-3.5 h-3.5", dangerous_inner_html: SPINNER_SVG }
366                                                "上传中"
367                                            }
368                                        },
369                                        UploadStatus::Done => rsx! { span { class: "text-xs text-emerald-600 dark:text-emerald-400 shrink-0", "✓ 已上传" } },
370                                        UploadStatus::Failed(message) => rsx! {
371                                            span { class: "text-xs text-red-500 shrink-0 max-w-56 truncate", title: "{message}", "{message}" }
372                                            button { r#type: "button", class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] shrink-0",
373                                                onclick: move |_| on_retry.call(id), "重试" }
374                                            button { r#type: "button", class: "text-xs cursor-pointer text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
375                                                aria_label: "移除", onclick: move |_| on_remove.call(id), "×" }
376                                        },
377                                    }
378                                }
379                            }
380                        }
381                    }
382                }
383            }
384        }
385    }
386}