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};
24use 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.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                div { class: "flex-1 overflow-y-auto p-6 flex flex-col gap-4",
194                    // 拖放区 = label:点击天然触发隐藏 file input(同 CoverUploader),无需 JS。
195                    // 内部内容包一层 pointer-events-none,防子元素进出触发 dragleave 高亮闪烁。
196                    label {
197                        class: "flex flex-col items-center justify-center gap-2 px-6 py-10 border border-dashed rounded-2xl cursor-pointer transition-colors",
198                        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)]" },
199                        // ondragover 必须 prevent_default,否则浏览器直接打开文件。
200                        ondragover: move |evt| {
201                            evt.prevent_default();
202                            drag_active.set(true);
203                        },
204                        ondragenter: move |evt| {
205                            evt.prevent_default();
206                        },
207                        ondragleave: move |_| {
208                            drag_active.set(false);
209                        },
210                        ondrop: move |evt| {
211                            evt.prevent_default();
212                            drag_active.set(false);
213                            #[cfg(target_arch = "wasm32")]
214                            {
215                                let collected: Vec<web_sys::File> = evt
216                                    .files()
217                                    .into_iter()
218                                    .filter_map(|f| f.get_web_file())
219                                    .collect();
220                                if !collected.is_empty() {
221                                    enqueue_files(
222                                        items,
223                                        pool_for_drop.clone(),
224                                        concurrency,
225                                        on_uploaded,
226                                        collected,
227                                    );
228                                }
229                            }
230                        },
231                        div { class: "pointer-events-none flex flex-col items-center gap-2",
232                            // 上传图标(Feather 风格线框,照抄 CoverUploader)。
233                            svg {
234                                class: "w-8 h-8 text-[var(--color-paper-tertiary)]",
235                                xmlns: "http://www.w3.org/2000/svg",
236                                view_box: "0 0 24 24",
237                                fill: "none",
238                                stroke: "currentColor",
239                                stroke_width: "1.8",
240                                stroke_linecap: "round",
241                                stroke_linejoin: "round",
242                                path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
243                                polyline { points: "17 8 12 3 7 8" }
244                                line {
245                                    x1: "12",
246                                    y1: "3",
247                                    x2: "12",
248                                    y2: "15",
249                                }
250                            }
251                            p { class: "text-sm font-medium text-[var(--color-paper-primary)]",
252                                "拖拽图片到这里,或点击选择"
253                            }
254                            p { class: "text-xs text-[var(--color-paper-tertiary)]",
255                                "支持 Ctrl/⌘+V 粘贴 · JPEG / PNG / GIF / WebP · 单张 ≤ 5MB"
256                            }
257                        }
258                        input {
259                            r#type: "file",
260                            accept: "image/jpeg,image/png,image/gif,image/webp",
261                            multiple: true,
262                            class: "hidden",
263                            onchange: move |evt| {
264                                #[cfg(target_arch = "wasm32")]
265                                {
266                                    let collected: Vec<web_sys::File> = evt
267                                        .files()
268                                        .into_iter()
269                                        .filter_map(|f| f.get_web_file())
270                                        .collect();
271                                    if !collected.is_empty() {
272                                        enqueue_files(
273                                            items,
274                                            pool_for_change.clone(),
275                                            concurrency,
276                                            on_uploaded,
277                                            collected,
278                                        );
279                                    }
280                                }
281                            },
282                        }
283                    }
284
285                    // 逐文件状态列表。
286                    if !items_snapshot.is_empty() {
287                        div { class: "flex flex-col gap-2",
288                            for (idx, item) in items_snapshot.iter().enumerate() {
289                                {
290                                    let item_id = item.id;
291                                    // 状态切换 key(q/u/d/f):status 变化强制重挂载状态区 → 重放 status-pop。
292                                    let status_key = match &item.status {
293                                        UploadStatus::Queued => "q",
294                                        UploadStatus::Uploading => "u",
295                                        UploadStatus::Done => "d",
296                                        UploadStatus::Failed(_) => "f",
297                                    };
298                                    rsx! {
299                                        div {
300                                            key: "{item_id}",
301                                            class: "flex items-center gap-3 rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-theme)] px-4 py-3",
302                                            class: if item.removing { "animate-row-leave" } else { "animate-row-enter" },
303                                            // 进场阶梯 delay(复用 .animate-row-enter);removing 时清空,避免退出动画被残留 delay 推迟。
304                                            style: if item.removing { String::new() } else { format!("animation-delay:{}ms", idx * 30) },
305                                            // 左:文件名 + 大小(min-w-0 + truncate 防长文件名撑破行)。
306                                            div { class: "flex-1 min-w-0",
307                                                p {
308                                                    class: "text-sm truncate text-[var(--color-paper-primary)]",
309                                                    title: "{item.name}",
310                                                    "{item.name}"
311                                                }
312                                                p { class: "text-xs font-mono text-[var(--color-paper-tertiary)]", "{item.size}" }
313                                            }
314                                            // 右:状态 + 操作。外层 keyed(status 变化强制重挂载)→ 重放 status-pop。
315                                            for _ in std::iter::once(()) {
316                                                {
317                                                    // for 闭包体内声明 Rc 局部:下层 onclick 的 move 捕获落在本层
318                                                    // 局部(可自由 move),而非跨层 move 外层闭包捕获(FnMut 链 E0507)。
319                                                    #[cfg(target_arch = "wasm32")]
320                                                    let (pool_for_retry, pool_for_remove) =
321                                                        (pool_for_rows.clone(), pool_for_rows.clone());
322                                                    rsx! {
323                                                        div {
324                                                            key: "{status_key}",
325                                                            class: "flex items-center gap-2 shrink-0 animate-status-pop",
326                                                            match &item.status {
327                                                                UploadStatus::Queued => rsx! {
328                                                                    span { class: "text-xs text-[var(--color-paper-tertiary)] shrink-0", "等待中" }
329                                                                }, // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。 // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。
330                                                                UploadStatus::Uploading => rsx! {
331                                                                    span { class: "flex items-center gap-1.5 text-xs text-[var(--color-paper-secondary)] shrink-0",
332                                                                        span {
333                                                                            class: "inline-block w-3.5 h-3.5",
334                                                                            dangerous_inner_html: SPINNER_SVG,
335                                                                        }
336                                                                        "上传中"
337                                                                    }
338                                                                },
339                                                                UploadStatus::Done => rsx! {
340                                                                    span { class: "text-xs text-emerald-600 dark:text-emerald-400 shrink-0", "✓ 已上传" }
341                                                                },
342                                                                UploadStatus::Failed(msg) => rsx! {
343                                                                    // 失败原因文字展示(不只靠颜色),过长截断 + title 全文。
344                                                                    span { class: "text-xs text-red-500 shrink-0 max-w-56 truncate", title: "{msg}", "{msg}" }
345                                                                    button {
346                                                                        class: "text-xs cursor-pointer text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] shrink-0",
347                                                                        onclick: move |_| {
348                                                                            #[cfg(target_arch = "wasm32")] // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会 // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会
349                                                                            {
350                                                                                // 从 files 表取回句柄重发本条。
351                                                                                let file = pool_for_retry.find_file(item_id);
352                                                                                if let Some(file) = file {
353                                                                                    set_status(&mut items, item_id, UploadStatus::Uploading);
354                                                                                    spawn(async move {
355                                                                                        match upload_image_file(file).await {
356                                                                                            Ok(_) => {
357                                                                                                set_status(&mut items, item_id, UploadStatus::Done);
358                                                                                                on_uploaded.call(());
359                                                                                            }
360                                                                                            Err(msg) => {
361                                                                                                set_status(&mut items, item_id, UploadStatus::Failed(msg));
362                                                                                            }
363                                                                                        }
364                                                                                    });
365                                                                                }
366                                                                            }
367                                                                        },
368                                                                        "重试"
369                                                                    }
370                                                                    button {
371                                                                        class: "text-xs cursor-pointer text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
372                                                                        aria_label: "移除",
373                                                                        onclick: move |_| {
374                                                                            // 先标记 removing 播退出动画;文件句柄立即释放,批任务取不到句柄会
375                                                                            // 跳过该条(不会在淡出行上写状态);EXIT_ANIM_MS 后真正摘除列表项。
376                                                                            {
377                                                                                let mut guard = items.write();
378                                                                                if let Some(it) = guard.iter_mut().find(|it| it.id == item_id) {
379                                                                                    it.removing = true;
380                                                                                }
381                                                                            }
382                                                                            #[cfg(target_arch = "wasm32")]
383                                                                            pool_for_remove.remove_file(item_id);
384                                                                            spawn(async move {
385                                                                                crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
386                                                                                items.write().retain(|it| it.id != item_id);
387                                                                            });
388                                                                        },
389                                                                        "×"
390                                                                    }
391                                                                },
392                                                            }
393                                                        }
394                                                    }
395                                                }
396                                            }
397                                        }
398                                    }
399                                }
400                            }
401                        }
402                    }
403                }
404        }
405    }
406}